- 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.
408 lines
14 KiB
Python
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"&+", "&", 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)
|