Files
Agentic-OS/backend/app/services/diagnostic_format.py
nearxos 9fc35e86fe Add Asterisk configuration and diagnostics support
- Updated .env.example to include Asterisk templates path.
- Modified docker-compose.yml to mount the templates directory.
- Enhanced backend Dockerfile to copy templates into the container.
- Introduced Asterisk diagnostics functionality in asterisk_profiles.py, allowing for baseline checks and diagnostics reporting.
- Integrated Asterisk diagnostics into the device diagnostics workflow in graph.py.
- Added formatting for Asterisk baseline drift reports in diagnostic_format.py.
- Updated SKILL.md to document new config baseline drift feature for Asterisk.

This commit enhances the system's capabilities for managing Asterisk configurations and diagnostics, improving overall troubleshooting processes.
2026-06-15 07:49:10 +03:00

408 lines
14 KiB
Python

"""Format MCP diagnostic output for reports, Obsidian, and UI."""
from __future__ import annotations
import json
import re
from typing import Any
MAX_REPORT_OUTPUT_PER_TOOL = 4_000
MAX_OBSIDIAN_DIAG_CHARS = 120_000
MAX_EVENT_OUTPUT = 12_000
_TABLE_TOOLS = frozenset(
{
"pfsense_list_firewall_rules",
"pfsense_list_port_forwards",
"pfsense_list_outbound_nat_mappings",
"pfsense_list_aliases",
"pfsense_list_interfaces",
"proxmox_list_qemu",
"proxmox_list_lxc",
"proxmox_get_qemu_status",
"proxmox_get_qemu_config",
}
)
def _clip(text: str, limit: int) -> str:
if len(text) <= limit:
return text
return text[: limit - 40] + f"\n\n… ({len(text) - limit + 40} chars truncated)"
def _parse_json_payload(text: str) -> Any | None:
text = (text or "").strip()
if not text:
return None
try:
return json.loads(text)
except json.JSONDecodeError:
pass
start, end = text.find("{"), text.rfind("}")
if start != -1 and end > start:
try:
return json.loads(text[start : end + 1])
except json.JSONDecodeError:
return None
return None
def _cell(val: Any) -> str:
if val is None:
return ""
if isinstance(val, list):
return ", ".join(str(x) for x in val)
s = str(val)
s = re.sub(r"&amp;+", "&", s)
return s.replace("|", "\\|").replace("\n", " ")
def _firewall_rules_table(rows: list[dict]) -> str:
header = "| # | Action | Interface | Protocol | Source | Destination | Description |"
sep = "|---|---|---|---|---|---|---|"
lines = [header, sep]
for i, row in enumerate(rows[:300], start=1):
lines.append(
"| "
+ " | ".join(
[
str(i),
_cell(row.get("type")),
_cell(row.get("interface")),
_cell(row.get("protocol")),
_cell(row.get("source")),
_cell(row.get("destination")),
_cell(row.get("descr")),
]
)
+ " |"
)
if len(rows) > 300:
lines.append(f"\n_Showing 300 of {len(rows)} rules._")
return "\n".join(lines)
def _port_forwards_table(rows: list[dict]) -> str:
header = "| # | Interface | Protocol | Destination | Target | Local port | Description |"
sep = "|---|---|---|---|---|---|---|"
lines = [header, sep]
for i, row in enumerate(rows[:200], start=1):
lines.append(
"| "
+ " | ".join(
[
str(i),
_cell(row.get("interface")),
_cell(row.get("protocol")),
_cell(row.get("destination")),
_cell(row.get("target")),
_cell(row.get("local_port") or row.get("destination_port")),
_cell(row.get("descr")),
]
)
+ " |"
)
return "\n".join(lines)
def _nat_table(rows: list[dict]) -> str:
header = "| # | Interface | Source | Destination | Target | Description |"
sep = "|---|---|---|---|---|---|"
lines = [header, sep]
for i, row in enumerate(rows[:200], start=1):
lines.append(
"| "
+ " | ".join(
[
str(i),
_cell(row.get("interface")),
_cell(row.get("source")),
_cell(row.get("destination")),
_cell(row.get("target")),
_cell(row.get("descr")),
]
)
+ " |"
)
return "\n".join(lines)
def _aliases_table(rows: list[dict]) -> str:
header = "| Name | Type | Addresses | Description |"
sep = "|---|---|---|---|"
lines = [header, sep]
for row in rows[:200]:
lines.append(
"| "
+ " | ".join(
[
_cell(row.get("name")),
_cell(row.get("type")),
_cell(row.get("address")),
_cell(row.get("descr")),
]
)
+ " |"
)
return "\n".join(lines)
def _interfaces_table(rows: list[dict]) -> str:
header = "| ID | Interface | Enabled | Description | IPv4 |"
sep = "|---|---|---|---|---|"
lines = [header, sep]
for row in rows[:50]:
lines.append(
"| "
+ " | ".join(
[
_cell(row.get("id")),
_cell(row.get("if")),
_cell(row.get("enable")),
_cell(row.get("descr")),
_cell(row.get("ipaddr")),
]
)
+ " |"
)
return "\n".join(lines)
def _qemu_list_table(rows: list[dict]) -> str:
header = "| VMID | Name | Status | CPU | Memory | Disk |"
sep = "|---|---|---|---|---|---|"
lines = [header, sep]
for row in rows[:100]:
mem = row.get("maxmem") or row.get("mem")
if isinstance(mem, (int, float)) and mem > 1024 * 1024:
mem = f"{mem / (1024 ** 3):.1f} GB"
disk = row.get("maxdisk") or row.get("disk")
if isinstance(disk, (int, float)) and disk > 1024 * 1024:
disk = f"{disk / (1024 ** 3):.1f} GB"
lines.append(
"| "
+ " | ".join(
[
_cell(row.get("vmid")),
_cell(row.get("name")),
_cell(row.get("status")),
_cell(row.get("cpus") or row.get("cpu")),
_cell(mem),
_cell(disk),
]
)
+ " |"
)
return "\n".join(lines)
def _lxc_list_table(rows: list[dict]) -> str:
header = "| VMID | Name | Status |"
sep = "|---|---|---|"
lines = [header, sep]
for row in rows[:100]:
lines.append(
"| "
+ " | ".join([_cell(row.get("vmid")), _cell(row.get("name")), _cell(row.get("status"))])
+ " |"
)
return "\n".join(lines)
def _qemu_config_table(data: dict) -> str:
lines = ["| Key | Value |", "|---|---|"]
for key in sorted(data.keys()):
if key.startswith("digest"):
continue
lines.append(f"| {_cell(key)} | {_cell(data[key])} |")
return "\n".join(lines)
def _qemu_status_table(data: dict) -> str:
if not data:
return "_No status data._"
lines = ["| Field | Value |", "|---|---|"]
for key in ("name", "status", "qmpstatus", "vmid", "uptime", "cpu", "mem", "maxmem", "disk", "maxdisk", "pid"):
if key in data:
val = data[key]
if isinstance(val, (int, float)) and key in ("mem", "maxmem", "disk", "maxdisk") and val > 1024 * 1024:
val = f"{val / (1024 ** 3):.2f} GB"
lines.append(f"| {_cell(key)} | {_cell(val)} |")
for key, val in sorted(data.items()):
if key in ("name", "status", "qmpstatus", "vmid", "uptime", "cpu", "mem", "maxmem", "disk", "maxdisk", "pid"):
continue
lines.append(f"| {_cell(key)} | {_cell(val)} |")
return "\n".join(lines)
def _rows_from_payload(parsed: Any) -> list[dict]:
if isinstance(parsed, list):
return [r for r in parsed if isinstance(r, dict)]
if isinstance(parsed, dict):
data = parsed.get("data")
if isinstance(data, list):
return [r for r in data if isinstance(r, dict)]
return [parsed]
return []
def _qm_list_text_table(text: str) -> str:
lines = ["| VMID | Name | Status | Mem (MB) | Boot disk (GB) |", "|---|---|---|---|---|"]
for row in text.splitlines():
row = row.strip()
if not row or row.startswith("VMID") or row.startswith("-"):
continue
parts = row.split()
if not parts or not parts[0].isdigit():
continue
vmid, name, status = parts[0], parts[1] if len(parts) > 1 else "", parts[2] if len(parts) > 2 else ""
mem = parts[3] if len(parts) > 3 else ""
disk = parts[4] if len(parts) > 4 else ""
lines.append(f"| {vmid} | {name} | {status} | {mem} | {disk} |")
return "\n".join(lines) if len(lines) > 2 else f"```\n{_clip(text, 8000)}\n```"
def _qm_config_text_block(text: str) -> str:
lines = ["| Key | Value |", "|---|---|"]
for raw in text.splitlines():
raw = raw.strip()
if not raw or ":" not in raw:
continue
key, val = raw.split(":", 1)
lines.append(f"| {_cell(key.strip())} | {_cell(val.strip())} |")
return "\n".join(lines) if len(lines) > 2 else f"```\n{_clip(text, 8000)}\n```"
def _baseline_drift_table(payload: dict) -> str:
lines = [
f"**Profile:** `{payload.get('profile', '?')}` — {payload.get('summary', '')}",
"",
"| File | Check | Expected | Status |",
"|---|---|---|---|",
]
for file_entry in payload.get("files") or []:
path = file_entry.get("path") or "?"
if file_entry.get("missing"):
lines.append(f"| `{path}` | file | present | **MISSING** |")
continue
checks = file_entry.get("checks") or []
if not checks:
lines.append(f"| `{path}` | — | — | ok |")
continue
for i, check in enumerate(checks):
path_col = f"`{path}`" if i == 0 else ""
kind = check.get("kind") or "check"
expected = _cell(check.get("expected") or "")
status = "ok" if check.get("ok") else "**DRIFT**"
lines.append(f"| {path_col} | {kind} | {expected} | {status} |")
vars_used = payload.get("variables") or {}
if vars_used:
lines.append("")
lines.append("Variables: " + ", ".join(f"`{k}={v}`" for k, v in vars_used.items()))
return "\n".join(lines)
def format_tool_output_markdown(tool: str, output: str) -> str:
"""Turn raw MCP JSON/text into readable markdown when possible."""
if tool == "asterisk_config_baseline":
parsed = _parse_json_payload(output)
if isinstance(parsed, dict):
return _baseline_drift_table(parsed)
parsed = _parse_json_payload(output)
if tool == "proxmox_list_qemu":
if isinstance(parsed, (dict, list)):
rows = _rows_from_payload(parsed)
if rows and rows[0].get("vmid") is not None:
return _qemu_list_table(rows)
if re.search(r"^\s*\d+\s+\S+\s+(running|stopped)", output, re.MULTILINE):
return _qm_list_text_table(output)
if tool == "proxmox_list_lxc" and isinstance(parsed, (dict, list)):
rows = _rows_from_payload(parsed)
if rows:
return _lxc_list_table(rows)
if tool == "proxmox_get_qemu_config":
if isinstance(parsed, dict):
inner = parsed.get("data") if isinstance(parsed.get("data"), dict) else parsed
if isinstance(inner, dict) and inner:
return _qemu_config_table(inner)
if ":" in output:
return _qm_config_text_block(output)
if tool == "proxmox_get_qemu_status" and isinstance(parsed, dict):
inner = parsed.get("data") if isinstance(parsed.get("data"), dict) else parsed
if isinstance(inner, dict):
return _qemu_status_table(inner)
if isinstance(parsed, dict):
data = parsed.get("data")
if isinstance(data, list) and data:
if tool == "pfsense_list_firewall_rules":
return _firewall_rules_table(data)
if tool == "pfsense_list_port_forwards":
return _port_forwards_table(data)
if tool == "pfsense_list_outbound_nat_mappings":
return _nat_table(data)
if tool == "pfsense_list_aliases":
return _aliases_table(data)
if tool == "pfsense_list_interfaces":
return _interfaces_table(data)
if tool in _TABLE_TOOLS:
return f"```json\n{json.dumps(data[:50], indent=2)}\n```"
return f"```\n{_clip(output, 20_000)}\n```"
def prepare_report_diagnostics(diagnostics: list[dict]) -> list[dict]:
"""Normalize diagnostics for persistence in task.report."""
out: list[dict] = []
for d in diagnostics:
text = d.get("output") or ""
formatted = ""
tool = d.get("tool")
if text:
if tool in _TABLE_TOOLS or tool == "asterisk_config_baseline":
formatted = format_tool_output_markdown(tool, text)
elif tool == "ssh_run":
if re.search(r"^\s*\d+\s+\S+\s+(running|stopped)", text, re.MULTILINE):
formatted = format_tool_output_markdown("proxmox_list_qemu", text)
elif re.match(r"^[a-z][a-z0-9_-]*:", text.strip(), re.IGNORECASE):
formatted = format_tool_output_markdown("proxmox_get_qemu_config", text)
entry = {
"device": d.get("device"),
"tool": tool,
"ok": d.get("ok"),
"intent": d.get("intent"),
"vmid": d.get("vmid"),
"output_chars": len(text),
"raw_available": bool(text),
"truncated": len(text) > MAX_REPORT_OUTPUT_PER_TOOL,
}
if formatted:
entry["formatted"] = formatted
elif text:
entry["output"] = _clip(text, MAX_REPORT_OUTPUT_PER_TOOL)
out.append(entry)
return out
def diagnostics_to_markdown(diagnostics: list[dict]) -> str:
"""Build Obsidian section from report-style diagnostics."""
if not diagnostics:
return "_No live diagnostic output captured._"
parts: list[str] = []
total = 0
for d in diagnostics:
tool = d.get("tool") or "unknown"
device = d.get("device") or "?"
ok = d.get("ok")
vmid = d.get("vmid")
vm_label = f" VM {vmid}" if vmid is not None else ""
header = f"### [{device}] {tool}{vm_label} ({'ok' if ok else 'fail'})"
body = d.get("formatted") or format_tool_output_markdown(tool, d.get("output") or "")
chunk = f"{header}\n\n{body}\n"
if total + len(chunk) > MAX_OBSIDIAN_DIAG_CHARS:
parts.append("_Additional diagnostic output omitted (size limit)._")
break
parts.append(chunk)
total += len(chunk)
return "\n".join(parts)