"""M08 — Topology visualisation component.
Spec: docs/M08-ui.md §3.2
Renders the live mesh topology and recent call traces as an HTML widget.
Updates are pushed via TopologyComponent.push_trace() and push_topology().
"""
from __future__ import annotations
import time
from collections import deque
from typing import Any
try:
import gradio as gr
_HAS_GRADIO = True
except ImportError:
_HAS_GRADIO = False
# Max recent call traces to keep in memory
_MAX_TRACES = 200
_MAX_TOPOLOGY_HISTORY = 10
class TopologyComponent:
"""Live mesh topology and call-trace viewer.
Renders an HTML card showing:
- Connected peers (node_id, capabilities count, latency)
- Recent bus call traces (capability, duration_ms, success/error)
- Local capability count
Call push_trace() / push_topology() from bus hooks to keep it live.
Integrate into Gradio UI via render().
"""
def __init__(self, bus: Any = None) -> None:
self._bus = bus
self._traces: deque[dict] = deque(maxlen=_MAX_TRACES)
self._topology: dict = {}
self._last_updated: float = 0.0
def push_trace(self, event: Any) -> None:
"""Accept a CallTraceEvent (or dict) and store it."""
if hasattr(event, "__dict__"):
rec = {
"ts": getattr(event, "ts", time.strftime("%H:%M:%S")),
"capability": getattr(event, "capability", "?"),
"duration_ms": getattr(event, "duration_ms", 0),
"success": getattr(event, "success", True),
"error": getattr(event, "error", None),
"peer_node_id": getattr(event, "peer_node_id", "local"),
}
elif isinstance(event, dict):
rec = event
else:
return
self._traces.appendleft(rec)
self._last_updated = time.monotonic()
def push_topology(self, snapshot: Any) -> None:
"""Accept a TopologySnapshot (or dict) and store it."""
if isinstance(snapshot, dict):
self._topology = snapshot
elif hasattr(snapshot, "as_dict"):
self._topology = snapshot.as_dict()
elif hasattr(snapshot, "__dict__"):
self._topology = vars(snapshot)
self._last_updated = time.monotonic()
def render(self) -> Any:
"""Return a Gradio HTML component showing current topology."""
if not _HAS_GRADIO:
raise ImportError("gradio is required for TopologyComponent.render()")
html = self._build_html()
return gr.HTML(value=html, label="Mesh Topology")
def _build_html(self) -> str:
peers = self._topology.get("peers", [])
local_caps = self._topology.get("local_capabilities", 0)
community = self._topology.get("community_id", "—")
# Build peer rows
peer_rows = ""
for p in peers[:20]:
nid = str(p.get("node_id", "?"))[:12]
caps = p.get("capabilities_count", "?")
lat = p.get("latency_ms", "?")
peer_rows += f"
| {nid}… | {caps} | {lat}ms |
"
if not peers:
peer_rows = "| No peers discovered yet |
"
# Build trace rows
trace_rows = ""
for t in list(self._traces)[:15]:
cap = str(t.get("capability", "?"))[:35]
dur = t.get("duration_ms", "?")
ok = "✓" if t.get("success", True) else "✗"
color = "#4ade80" if t.get("success", True) else "#f87171"
trace_rows += (
f"| {ok} | {cap} | {dur}ms |
"
)
if not trace_rows:
trace_rows = "| No calls yet |
"
ts = time.strftime("%H:%M:%S") if self._last_updated else "never"
return f"""
Mesh Topology
updated {ts}
Community: {community} ·
Local caps: {local_caps} ·
Peers: {len(peers)}
| Node | Caps | Latency |
{peer_rows}
Recent calls
| Capability | Duration |
{trace_rows}
"""
def as_dict(self) -> dict:
return {
"topology": self._topology,
"recent_traces": list(self._traces)[:20],
"last_updated": self._last_updated,
}