Self-hosted, Docker-based agentic troubleshooting platform: FastAPI backend + LangGraph agent, Next.js UI, tiered LLM routing (local Ollama -> Gemini -> DeepSeek -> OpenRouter), MCP server manager, encrypted device credentials, RBAC, audit log, project-memory + Obsidian integrations, and editable troubleshooting decision rules tuned for the GeneseasX vessel stack. Co-authored-by: Cursor <cursoragent@cursor.com>
375 lines
13 KiB
Python
375 lines
13 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 format_tool_output_markdown(tool: str, output: str) -> str:
|
|
"""Turn raw MCP JSON/text into readable markdown when possible."""
|
|
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:
|
|
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)
|