diff --git a/atlasbot/snapshot/builder.py b/atlasbot/snapshot/builder.py index dc56af4..7543197 100644 --- a/atlasbot/snapshot/builder.py +++ b/atlasbot/snapshot/builder.py @@ -114,24 +114,34 @@ def _merge_cluster_summary(snapshot: dict[str, Any], summary: dict[str, Any]) -> cluster_summary = snapshot.get("summary") if isinstance(snapshot.get("summary"), dict) else {} if not cluster_summary: return - signals = cluster_summary.get("signals") - profiles = cluster_summary.get("profiles") - inventory = cluster_summary.get("inventory") - topology = cluster_summary.get("topology") - lexicon = cluster_summary.get("lexicon") - cross_stats = cluster_summary.get("cross_stats") - if isinstance(signals, list): - summary["signals"] = signals - if isinstance(profiles, dict): - summary["profiles"] = profiles - if isinstance(inventory, dict): - summary["inventory"] = inventory - if isinstance(topology, dict): - summary["topology"] = topology - if isinstance(lexicon, dict): - summary["lexicon"] = lexicon - if isinstance(cross_stats, dict): - summary["cross_stats"] = cross_stats + _merge_cluster_fields( + summary, + cluster_summary, + { + "signals": list, + "profiles": dict, + "inventory": dict, + "topology": dict, + "lexicon": dict, + "cross_stats": dict, + "baseline_deltas": dict, + "pod_issue_summary": dict, + "trend_requests": dict, + "pod_waiting_trends": dict, + "pod_terminated_trends": dict, + }, + ) + + +def _merge_cluster_fields( + summary: dict[str, Any], + cluster_summary: dict[str, Any], + field_types: dict[str, type], +) -> None: + for key, expected in field_types.items(): + value = cluster_summary.get(key) + if isinstance(value, expected): + summary[key] = value def _augment_lexicon(summary: dict[str, Any]) -> None: @@ -1640,6 +1650,76 @@ def _append_cluster_watchlist(lines: list[str], summary: dict[str, Any]) -> None lines.append("cluster_watchlist: " + "; ".join(watchlist)) +def _append_baseline_deltas(lines: list[str], summary: dict[str, Any]) -> None: + deltas = summary.get("baseline_deltas") if isinstance(summary.get("baseline_deltas"), dict) else {} + nodes = deltas.get("nodes") if isinstance(deltas.get("nodes"), dict) else {} + namespaces = deltas.get("namespaces") if isinstance(deltas.get("namespaces"), dict) else {} + for scope, block in (("nodes", nodes), ("namespaces", namespaces)): + if not isinstance(block, dict): + continue + for metric, entries in block.items(): + if not isinstance(entries, list) or not entries: + continue + parts: list[str] = [] + for entry in entries[:5]: + if not isinstance(entry, dict): + continue + name = entry.get("node") if scope == "nodes" else entry.get("namespace") + delta = entry.get("delta") + severity = entry.get("severity") + if not isinstance(name, str) or not name or not isinstance(delta, (int, float)): + continue + suffix = f" ({severity})" if isinstance(severity, str) and severity else "" + parts.append(f"{name}={_format_float(delta)}%{suffix}") + if parts: + lines.append(f"{scope}_baseline_delta_{metric}: " + "; ".join(parts)) + + +def _append_pod_issue_summary(lines: list[str], summary: dict[str, Any]) -> None: + issues = summary.get("pod_issue_summary") if isinstance(summary.get("pod_issue_summary"), dict) else {} + waiting = issues.get("waiting_reasons_top") if isinstance(issues.get("waiting_reasons_top"), list) else [] + phases = issues.get("phase_reasons_top") if isinstance(issues.get("phase_reasons_top"), list) else [] + namespace_issue = issues.get("namespace_issue_top") if isinstance(issues.get("namespace_issue_top"), dict) else {} + waiting_line = _reason_line(waiting, "pod_waiting_reasons_top") + if waiting_line: + lines.append(waiting_line) + phase_line = _reason_line(phases, "pod_phase_reasons_top") + if phase_line: + lines.append(phase_line) + if namespace_issue: + _append_namespace_issue_lines(lines, namespace_issue) + + +def _reason_line(entries: list[dict[str, Any]], label: str) -> str: + parts = [] + for entry in entries[:5]: + if not isinstance(entry, dict): + continue + reason = entry.get("reason") + count = entry.get("count") + if reason: + parts.append(f"{reason}={count}") + if parts: + return f"{label}: " + "; ".join(parts) + return "" + + +def _append_namespace_issue_lines(lines: list[str], namespace_issue: dict[str, Any]) -> None: + for key, entries in namespace_issue.items(): + if not isinstance(entries, list) or not entries: + continue + parts: list[str] = [] + for entry in entries[:5]: + if not isinstance(entry, dict): + continue + ns = entry.get("namespace") + value = entry.get("value") + if ns: + parts.append(f"{ns}={value}") + if parts: + lines.append(f"namespace_issue_top_{key}: " + "; ".join(parts)) + + def _build_cluster_watchlist(summary: dict[str, Any]) -> dict[str, Any]: items: list[str] = [] nodes_summary = summary.get("nodes_summary") if isinstance(summary.get("nodes_summary"), dict) else {} @@ -1880,6 +1960,7 @@ def summary_text(snapshot: dict[str, Any] | None) -> str: # noqa: PLR0915 _append_namespace_nodes(lines, summary) _append_node_pods(lines, summary) _append_pod_issues(lines, summary) + _append_pod_issue_summary(lines, summary) _append_workload_health(lines, summary) _append_events(lines, summary) _append_node_usage_stats(lines, summary) @@ -1895,6 +1976,7 @@ def summary_text(snapshot: dict[str, Any] | None) -> str: # noqa: PLR0915 _append_pvc_usage(lines, summary) _append_root_disk_headroom(lines, summary) _append_namespace_capacity_summary(lines, summary) + _append_baseline_deltas(lines, summary) _append_longhorn(lines, summary) _append_workloads(lines, summary) _append_topology(lines, summary)