import { useEffect, useMemo, useState } from 'react'; import type { PodGraph } from '@podman/shared'; import { fetchPodGraph, backendEventsUrl } from '../lib/graph.js'; import { Button } from '@/components/ui/button'; import { ToggleGroup, ToggleGroupItem } from '@/components/ui/toggle-group'; import { GraphCanvas } from './graph/GraphCanvas.js'; import { MetricsRail } from './graph/MetricsRail.js'; import { LearningLoop } from './graph/LearningLoop.js'; import { ActivityStream } from './graph/ActivityStream.js'; import { SelectedNodePanel } from './graph/SelectedNodePanel.js'; import { highlightFor, NODE_LEGEND, EDGE_LEGEND, type Mode } from './graph/encoding.js'; const POLL_MS = 5000; // Note: pm-enter must NOT use animation-fill-mode (both/forwards) — a held final // keyframe (opacity:1) would override the .pm-dim cascade and defeat dimming. const GRAPH_CSS = ` .pm-node{cursor:grab;transition:opacity .25s ease} .pm-node:active{cursor:grabbing} .pm-edge{transition:opacity .25s ease} .pm-lbl{fill:var(--foreground);font-size:11px;font-weight:500;pointer-events:none; paint-order:stroke;stroke:var(--card);stroke-width:3.5px;stroke-linejoin:round} .pm-dim{opacity:.14} .pm-enter{animation:pm-fade .45s ease} .pm-dash{animation:pm-flow 1s linear infinite} .pm-pulse{animation:pm-pulse 1.7s ease-in-out infinite} @keyframes pm-fade{from{opacity:0}to{opacity:1}} @keyframes pm-flow{to{stroke-dashoffset:-26}} @keyframes pm-pulse{0%,100%{opacity:.45}50%{opacity:1}} @media (prefers-reduced-motion:reduce){ .pm-enter,.pm-dash,.pm-pulse{animation:none} } `; export function GraphView({ podId, onClose }: { podId: string; onClose: () => void }) { const [graph, setGraph] = useState(null); const [error, setError] = useState(null); const [mode, setMode] = useState('risk'); const [selected, setSelected] = useState(null); useEffect(() => { let alive = true; let nudge: number | null = null; setGraph(null); setError(null); setSelected(null); const load = () => fetchPodGraph(podId) .then((g) => { if (alive) { setGraph(g); setError(null); } }) .catch((e: unknown) => { if (alive) setError(e instanceof Error ? e.message : String(e)); }); void load(); const poll = window.setInterval(() => void load(), POLL_MS); // Best-effort realtime nudge: refetch (debounced) when the agent broadcasts. let ws: WebSocket | null = null; try { ws = new WebSocket(backendEventsUrl()); ws.onmessage = () => { if (nudge != null) return; nudge = window.setTimeout(() => { nudge = null; void load(); }, 800); }; } catch { /* event bus is optional */ } return () => { alive = false; window.clearInterval(poll); if (nudge != null) window.clearTimeout(nudge); ws?.close(); }; }, [podId]); const nodeById = useMemo(() => new Map((graph?.nodes ?? []).map((n) => [n.id, n])), [graph]); // A selected node can vanish across a poll/WS refresh. Ignore a stale id so the // graph doesn't dim entirely (highlightFor would otherwise light only a dead id). const liveSelected = selected && nodeById.has(selected) ? selected : null; useEffect(() => { if (selected && graph && !nodeById.has(selected)) setSelected(null); }, [graph, nodeById, selected]); const highlight = useMemo( () => (graph ? highlightFor(graph, mode, liveSelected) : null), [graph, mode, liveSelected], ); const sel = liveSelected ? nodeById.get(liveSelected) : undefined; const relCount = liveSelected ? (graph?.edges ?? []).filter((e) => e.source === liveSelected || e.target === liveSelected) .length : 0; function pick(next: Mode) { setMode(next); setSelected(null); } return (
{/* Header */}

Team memory

Live

What PodMan learned · {podId}

{/* Toggles */}
v && pick(v as Mode)} variant="outline" size="sm" > Risk path Learning edges Whole graph Drag to rearrange · double-click to release · click to inspect
{error &&

Graph error: {error}

} {!graph && !error && (

Loading graph…

)} {graph && ( <> {/* Metrics · graph · learning loop */}
{graph.loop?.length ? ( ) : (
)}
{/* Activity stream · selected node */}
{/* Legend */}
{NODE_LEGEND.map((l) => ( {l.label} ))} {EDGE_LEGEND.map((l) => ( {l.label} ))}
)}
); }