diff --git a/backend/src/graph/demo.ts b/backend/src/graph/demo.ts index 22b3f3d..4007105 100644 --- a/backend/src/graph/demo.ts +++ b/backend/src/graph/demo.ts @@ -11,21 +11,23 @@ export function createDemoPodGraph(podId: string): PodGraph { return { podId, generatedAt: new Date().toISOString(), + // Kept consistent with the graph below (3 owner engineers, 1 collision file, + // 1 of 1 interventions accepted) so the numbers never contradict the picture. metrics: [ { label: 'Learned owners', - value: '5', - detail: 'Ownership edges retained from accepted interventions.', + value: '3', + detail: 'Distinct owners retained from accepted interventions.', }, { label: 'Open risk paths', - value: '2', - detail: 'auth.ts and the memory API have converging editors.', + value: '1', + detail: 'File with two or more converging editors.', }, { label: 'Accept rate', - value: '86%', - detail: 'Interventions accepted this session (+14%).', + value: '100%', + detail: 'Interventions accepted vs total this session.', }, ], loop: { diff --git a/backend/src/graph/live.ts b/backend/src/graph/live.ts index d2e1c62..6dde1be 100644 --- a/backend/src/graph/live.ts +++ b/backend/src/graph/live.ts @@ -502,6 +502,7 @@ export async function materializePodGraph(podId: string): Promise o.accepted && o.wasRealCollision).length; const totalOutcomes = outcomeDocs.length; + // Raw distinct collision signatures — kept for the learning-loop throughput view. const riskPaths = new Set( collisionDocs.map( (col) => @@ -509,21 +510,38 @@ export async function materializePodGraph(podId: string): Promise(); + for (const e of finalEdges) { + if (e.kind === 'touches' && b.nodes.get(e.source)?.kind === 'file') riskFiles.add(e.source); + } + const openRiskPaths = riskFiles.size || nodes.filter((n) => n.kind === 'collision').length; + const ownerSet = new Set(); + for (const e of finalEdges) { + if (e.kind === 'learned_from') ownerSet.add(e.target); + if (e.kind === 'owns') ownerSet.add(e.source); + } + const learnedOwners = [...ownerSet].filter((id) => b.nodes.get(id)?.kind === 'engineer').length; + const metrics: PodGraphMetric[] = [ { label: 'Learned owners', - value: String(acceptedReal), - detail: 'Ownership retained from accepted interventions.', + value: String(learnedOwners), + detail: 'Distinct owners retained from accepted interventions.', }, { label: 'Open risk paths', - value: String(riskPaths), - detail: 'Files with two or more converging editors.', + value: String(openRiskPaths), + detail: `${openRiskPaths === 1 ? 'File' : 'Files'} with two or more converging editors.`, }, { label: 'Accept rate', value: totalOutcomes ? `${Math.round((acceptedReal / totalOutcomes) * 100)}%` : '—', - detail: 'Interventions accepted this session.', + detail: 'Interventions accepted vs total this session.', }, ]; const learnedEdges = [...b.edges.values()].filter((e) => e.kind === 'learned_from').length; diff --git a/frontend/src/components/GraphView.tsx b/frontend/src/components/GraphView.tsx index 7c13635..40d66d8 100644 --- a/frontend/src/components/GraphView.tsx +++ b/frontend/src/components/GraphView.tsx @@ -1,111 +1,36 @@ -import { useEffect, useMemo, useState, type CSSProperties } from 'react'; -import type { PodGraph, PodGraphNode, PodGraphEdge, PodGraphNodeKind } from '@podman/shared'; -import { fetchPodGraph } from '../lib/graph.js'; +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 { Badge } from '@/components/ui/badge'; +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, flowNarrative, NODE_LEGEND, EDGE_LEGEND, type Mode } from './graph/encoding.js'; -type Mode = 'risk' | 'learn' | 'all'; +const POLL_MS = 5000; -// Fixed, light-readable hues for the node/edge encoding (kept stable across -// light/dark so kinds stay distinguishable; the chrome uses shadcn tokens). -const BLUE = '#2563eb'; -const SLATE = '#475569'; -const SLATE_EDGE = '#94a3b8'; -const SLATE_FAINT = '#cbd5e1'; -const AMBER = '#d97706'; -const RED = '#dc2626'; -const VIOLET = '#7c3aed'; - -const KIND_COLOR: Record = { - engineer: BLUE, - file: SLATE, - feature: AMBER, - collision: RED, - intervention: VIOLET, -}; - -const EDGE: Record = { - owns: { c: BLUE, w: 2.6 }, - editing: { c: SLATE_EDGE, w: 2 }, - touches: { c: SLATE_FAINT, w: 1.6 }, - collides: { c: RED, w: 3.2 }, - warns: { c: AMBER, w: 3.2 }, - learned_from: { c: VIOLET, w: 2.4, dash: true }, -}; - -function NodeShape({ node }: { node: PodGraphNode }) { - const c = KIND_COLOR[node.kind]; - const { x, y } = node; - switch (node.kind) { - case 'engineer': - return ; - case 'file': - return ( - - ); - case 'feature': - return ; - case 'collision': - return ; - case 'intervention': - return ( - - ); - default: - return null; +// 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} } -} - -interface Highlight { - nodes: Set; - edges: Set; -} - -function highlightFor(graph: PodGraph, mode: Mode, selected: string | null): Highlight | null { - if (selected) { - const es = graph.edges.filter((e) => e.source === selected || e.target === selected); - return { - nodes: new Set([selected, ...es.flatMap((e) => [e.source, e.target])]), - edges: new Set(es.map((e) => e.id)), - }; - } - if (mode === 'all') return null; - const kinds: PodGraphEdge['kind'][] = - mode === 'risk' ? ['collides', 'warns', 'learned_from'] : ['learned_from', 'warns']; - const collisions = new Set(graph.nodes.filter((n) => n.kind === 'collision').map((n) => n.id)); - const es = graph.edges.filter( - (e) => - kinds.includes(e.kind) || - (mode === 'risk' && (collisions.has(e.target) || collisions.has(e.source))), - ); - return { - nodes: new Set(es.flatMap((e) => [e.source, e.target])), - edges: new Set(es.map((e) => e.id)), - }; -} - -const LEGEND: Array<{ label: string; swatch: CSSProperties }> = [ - { label: 'engineer', swatch: { background: BLUE } }, - { label: 'file', swatch: { border: `2px solid ${SLATE}` } }, - { label: 'feature', swatch: { background: AMBER, borderRadius: '50%' } }, - { label: 'collision', swatch: { background: RED, clipPath: 'polygon(50% 0,100% 100%,0 100%)' } }, - { label: 'intervention', swatch: { background: VIOLET, transform: 'rotate(45deg)' } }, -]; - -function statusColor(status: string): string { - if (status === 'risk') return RED; - if (status === 'learned') return VIOLET; - return 'var(--foreground)'; -} +`; export function GraphView({ podId, onClose }: { podId: string; onClose: () => void }) { const [graph, setGraph] = useState(null); @@ -115,198 +40,170 @@ export function GraphView({ podId, onClose }: { podId: string; onClose: () => vo useEffect(() => { let alive = true; + let nudge: number | null = null; setGraph(null); setError(null); - fetchPodGraph(podId) - .then((g) => alive && setGraph(g)) - .catch((e: unknown) => alive && setError(e instanceof Error ? e.message : String(e))); + 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 hi = useMemo( - () => (graph ? highlightFor(graph, mode, selected) : null), - [graph, mode, selected], - ); const nodeById = useMemo(() => new Map((graph?.nodes ?? []).map((n) => [n.id, n])), [graph]); - const sel = selected ? nodeById.get(selected) : undefined; - const relCount = selected - ? (graph?.edges ?? []).filter((e) => e.source === selected || e.target === selected).length - : 0; + // 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 dimNode = (id: string) => (hi ? !hi.nodes.has(id) : false); - const dimEdge = (id: string) => (hi ? !hi.edges.has(id) : false); - const hotEdge = (id: string) => (hi ? hi.edges.has(id) : false); + 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; + const flow = graph && liveSelected ? flowNarrative(graph, liveSelected) : ''; function pick(next: Mode) { setMode(next); setSelected(null); } - const toggleVariant = (m: Mode) => (mode === m && !selected ? 'default' : 'outline'); - return (
+
- - -
-
-
-

Team memory

-

What PodMan learned · {podId}

+
+ {/* 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…

+

Loading graph…

)} {graph && ( <> -
-
-

- Workflow metrics -

- {graph.metrics.map((m) => ( -
-

{m.value}

-

- {m.label} -

-

{m.detail}

-
- ))} + {/* Metrics · graph · learning loop */} +
+ + +
+
-
- - {graph.edges.map((e) => { - const a = nodeById.get(e.source); - const b = nodeById.get(e.target); - if (!a || !b) return null; - const s = EDGE[e.kind]; - return ( - - ); - })} - {graph.nodes.map((n) => ( - setSelected((cur) => (cur === n.id ? null : n.id))} - onKeyDown={(ev) => { - if (ev.key === 'Enter' || ev.key === ' ') { - ev.preventDefault(); - setSelected((cur) => (cur === n.id ? null : n.id)); - } - }} - > - - - {n.label} - - - ))} - -
+ {graph.loop?.steps?.length ? :
} +
-
- {sel ? ( - <> -

- {sel.kind} -

-

{sel.label}

-
- Status - - {sel.status} - -
-
- Relationships - {relCount} -
-

- {sel.summary} -

- - ) : ( - <> -

- Continual learning -

-

It learned

-

- The violet{' '} - - learned_from - {' '} - edges are ownership PodMan retained from accepted interventions — the graph - gets sharper every session. Click any node to trace its relationships. -

- - )} + {/* Activity stream · selected node */} +
+
+ +
+
+
-
- {LEGEND.map((l) => ( + {/* Legend */} +
+ {NODE_LEGEND.map((l) => ( {l.label} ))} - - - collides - - - - learned_from - + + {EDGE_LEGEND.map((l) => ( + + + {l.label} + + ))}
)} diff --git a/frontend/src/components/graph/ActivityStream.tsx b/frontend/src/components/graph/ActivityStream.tsx new file mode 100644 index 0000000..a0d944b --- /dev/null +++ b/frontend/src/components/graph/ActivityStream.tsx @@ -0,0 +1,52 @@ +import type { PodGraphActivity } from '@podman/shared'; +import { ScrollArea } from '@/components/ui/scroll-area'; +import { ACTIVITY_TAG } from './encoding.js'; + +const fmtTime = new Intl.DateTimeFormat([], { hour: '2-digit', minute: '2-digit', hour12: false }); + +function timeOf(at: string): string { + const t = new Date(at).getTime(); + return Number.isFinite(t) ? fmtTime.format(t) : '--:--'; +} + +export function ActivityStream({ events }: { events: PodGraphActivity[] }) { + return ( +
+

+ Activity stream +

+ {events.length === 0 ? ( +

No activity yet.

+ ) : ( + +
    + {events.map((e) => { + const tag = ACTIVITY_TAG[e.kind]; + return ( +
  • + + {timeOf(e.at)} + + + {tag.label} + + + {e.title} + {e.detail && ( + + {e.detail} + + )} + +
  • + ); + })} +
+
+ )} +
+ ); +} diff --git a/frontend/src/components/graph/GraphCanvas.tsx b/frontend/src/components/graph/GraphCanvas.tsx new file mode 100644 index 0000000..b87ed79 --- /dev/null +++ b/frontend/src/components/graph/GraphCanvas.tsx @@ -0,0 +1,333 @@ +import { + useCallback, + useEffect, + useRef, + useState, + type ReactElement, + type PointerEvent, +} from 'react'; +import type { PodGraph, PodGraphNode, PodGraphNodeKind } from '@podman/shared'; +import { ForceSim } from './forceSim.js'; +import { EDGE, KIND_COLOR, nodeRadius, type Highlight } from './encoding.js'; + +const W = 760; +const H = 480; +const MARGIN = 48; + +/** Map the server's 0..720×0..472 layout into the canvas as a seed position. */ +function mapX(x: number): number { + return MARGIN + (Math.max(0, Math.min(720, x)) / 720) * (W - 2 * MARGIN); +} +function mapY(y: number): number { + return MARGIN + (Math.max(0, Math.min(472, y)) / 472) * (H - 2 * MARGIN); +} + +function linkDistance(kind: string): number { + if (kind === 'collides') return 122; + if (kind === 'owns') return 104; + if (kind === 'learned_from') return 150; + return 134; +} +function linkStrength(strength: number): number { + return Math.max(0.18, Math.min(0.9, strength)); +} + +/** Stable +/- so parallel edges between the same pair fan to opposite sides. */ +function curveSign(id: string): number { + let h = 0; + for (let i = 0; i < id.length; i++) h = (h + id.charCodeAt(i)) % 2; + return h === 0 ? 1 : -1; +} + +function edgePath(ax: number, ay: number, bx: number, by: number, id: string): string { + const dx = bx - ax; + const dy = by - ay; + const len = Math.hypot(dx, dy) || 1; + const nx = -dy / len; + const ny = dx / len; + const off = curveSign(id) * len * 0.13; + const cx = (ax + bx) / 2 + nx * off; + const cy = (ay + by) / 2 + ny * off; + return `M${ax.toFixed(1)},${ay.toFixed(1)} Q${cx.toFixed(1)},${cy.toFixed(1)} ${bx.toFixed(1)},${by.toFixed(1)}`; +} + +function nodeShape( + kind: PodGraphNodeKind, + color: string, + cx: number, + cy: number, + r: number, +): ReactElement | null { + switch (kind) { + case 'engineer': + return ; + case 'file': + return ( + + ); + case 'feature': + return ; + case 'collision': + return ( + + ); + case 'intervention': + return ( + + ); + default: + return null; + } +} + +function showLabel( + node: PodGraphNode, + dimmed: boolean, + hovered: boolean, + selected: boolean, +): boolean { + if (hovered || selected) return true; + if (dimmed) return false; + // Collisions cluster and often share a filename — reveal on hover/select only. + if (node.kind === 'collision') return false; + return true; +} + +interface DragState { + id: string; + pointerId: number; + moved: boolean; +} + +export function GraphCanvas({ + graph, + highlight, + selected, + onSelect, +}: { + graph: PodGraph; + highlight: Highlight | null; + selected: string | null; + onSelect: (id: string | null) => void; +}) { + const svgRef = useRef(null); + const simRef = useRef(null); + if (!simRef.current) simRef.current = new ForceSim(W, H); + const rafRef = useRef(null); + const dragRef = useRef(null); + const sigRef = useRef(''); + const [, setFrame] = useState(0); + const [hovered, setHovered] = useState(null); + + const loop = useCallback(() => { + const sim = simRef.current; + if (!sim) return; + const working = sim.tick(); + setFrame((f) => (f + 1) % 1_000_000); + if (working || dragRef.current) { + rafRef.current = requestAnimationFrame(loop); + } else { + rafRef.current = null; + } + }, []); + + const ensureRaf = useCallback(() => { + if (rafRef.current == null) rafRef.current = requestAnimationFrame(loop); + }, [loop]); + + // Rebuild the simulation when the graph data changes, preserving positions. + useEffect(() => { + const sim = simRef.current; + if (!sim) return; + const nodeInputs = graph.nodes.map((n) => ({ + id: n.id, + radius: nodeRadius(n), + seedX: mapX(n.x), + seedY: mapY(n.y), + })); + const linkInputs = graph.edges.map((e) => ({ + source: e.source, + target: e.target, + distance: linkDistance(e.kind), + strength: linkStrength(e.strength), + })); + const sig = + nodeInputs + .map((n) => n.id) + .sort() + .join(',') + + '|' + + graph.edges + .map((e) => e.id) + .sort() + .join(','); + const first = sigRef.current === ''; + const changed = sig !== sigRef.current; + sim.setData(nodeInputs, linkInputs); + if (changed) { + sigRef.current = sig; + sim.reheat(first ? 1 : 0.5); + } + // Always (re)arm the loop — ensureRaf is idempotent via the rafRef==null + // guard. This must NOT be gated on `changed`: under React StrictMode the + // dev double-invoke cancels the frame between effect passes, and pass 2 sees + // an unchanged sig, so a `changed`-gated start would leave the sim frozen. + if (sim.nodes.length) ensureRaf(); + }, [graph, ensureRaf]); + + // Clean up the animation frame on unmount. + useEffect(() => { + return () => { + if (rafRef.current != null) cancelAnimationFrame(rafRef.current); + rafRef.current = null; + }; + }, []); + + function toSvg(evt: PointerEvent): { x: number; y: number } { + const svg = svgRef.current; + if (!svg) return { x: 0, y: 0 }; + const ctm = svg.getScreenCTM(); + if (!ctm) return { x: 0, y: 0 }; + const p = new DOMPoint(evt.clientX, evt.clientY).matrixTransform(ctm.inverse()); + return { x: p.x, y: p.y }; + } + + function onNodePointerDown(evt: PointerEvent, id: string) { + evt.stopPropagation(); + const sim = simRef.current; + if (!sim) return; + (evt.currentTarget as Element).setPointerCapture(evt.pointerId); + dragRef.current = { id, pointerId: evt.pointerId, moved: false }; + const { x, y } = toSvg(evt); + sim.pin(id, x, y); + sim.setActive(true); + ensureRaf(); + } + + function onNodePointerMove(evt: PointerEvent) { + const drag = dragRef.current; + const sim = simRef.current; + if (!drag || !sim || drag.pointerId !== evt.pointerId) return; + drag.moved = true; + const { x, y } = toSvg(evt); + sim.pin(drag.id, x, y); + ensureRaf(); + } + + function onNodePointerUp(evt: PointerEvent, id: string) { + const drag = dragRef.current; + const sim = simRef.current; + if (!drag || !sim || drag.pointerId !== evt.pointerId) return; + (evt.currentTarget as Element).releasePointerCapture?.(evt.pointerId); + sim.setActive(false); + // A press that never moved is a click — toggle selection (node stays pinned). + if (!drag.moved) onSelect(selected === id ? null : id); + dragRef.current = null; + ensureRaf(); + } + + function onNodeDoubleClick(id: string) { + const sim = simRef.current; + if (!sim) return; + sim.unpin(id); + sim.reheat(0.5); + ensureRaf(); + } + + const sim = simRef.current; + const dimNode = (id: string) => (highlight ? !highlight.nodes.has(id) : false); + const dimEdge = (id: string) => (highlight ? !highlight.edges.has(id) : false); + const hotEdge = (id: string) => (highlight ? highlight.edges.has(id) : false); + + return ( + onSelect(null)} + > + + {graph.edges.map((e) => { + const a = sim?.get(e.source); + const b = sim?.get(e.target); + if (!a || !b) return null; + const style = EDGE[e.kind]; + const hot = hotEdge(e.id); + return ( + + ); + })} + + + {graph.nodes.map((n) => { + const p = sim?.get(n.id); + if (!p) return null; + const r = p.radius; + const dimmed = dimNode(n.id); + const isHover = hovered === n.id; + const isSel = selected === n.id; + const color = KIND_COLOR[n.kind]; + const pinned = p.fx != null; + return ( + onNodePointerDown(ev, n.id)} + onPointerMove={onNodePointerMove} + onPointerUp={(ev) => onNodePointerUp(ev, n.id)} + onDoubleClick={() => onNodeDoubleClick(n.id)} + onMouseEnter={() => setHovered(n.id)} + onMouseLeave={() => setHovered((cur) => (cur === n.id ? null : cur))} + onKeyDown={(ev) => { + if (ev.key === 'Enter' || ev.key === ' ') { + ev.preventDefault(); + onSelect(selected === n.id ? null : n.id); + } + }} + > + {(isSel || isHover) && ( + + )} + {pinned && !isSel && !isHover && ( + + )} + {nodeShape(n.kind, color, p.x, p.y, r)} + {showLabel(n, dimmed, isHover, isSel) && ( + + {n.label} + + )} + + ); + })} + + + ); +} diff --git a/frontend/src/components/graph/LearningLoop.tsx b/frontend/src/components/graph/LearningLoop.tsx new file mode 100644 index 0000000..3e3ada3 --- /dev/null +++ b/frontend/src/components/graph/LearningLoop.tsx @@ -0,0 +1,49 @@ +import type { PodLearningLoop } from '@podman/shared'; +import { BLUE } from './encoding.js'; + +/** + * The continual-learning loop rail: observe → store → predict → outcome → adapt. + * The active stage (most-recent activity) gets a pulsing accent bar + ring. + */ +export function LearningLoop({ loop }: { loop: PodLearningLoop }) { + return ( +
+

+ Learning loop +

+ {loop.steps.map((s, i) => { + const active = s.status === 'active' || s.key === loop.activeStep; + return ( +
+
+ +
+

+ {String(i + 1).padStart(2, '0')} {s.label} +

+

{s.value}

+
+

{s.detail}

+
+ {i < loop.steps.length - 1 && ( +

+ ↓ +

+ )} +
+ ); + })} +
+ ); +} diff --git a/frontend/src/components/graph/MetricsRail.tsx b/frontend/src/components/graph/MetricsRail.tsx new file mode 100644 index 0000000..23f9d7e --- /dev/null +++ b/frontend/src/components/graph/MetricsRail.tsx @@ -0,0 +1,37 @@ +import type { PodGraphMetric } from '@podman/shared'; +import { BLUE, RED, VIOLET, GREEN, AMBER } from './encoding.js'; + +const ACCENTS: Array<{ test: RegExp; color: string }> = [ + { test: /risk|collision|open/i, color: RED }, + { test: /accept/i, color: GREEN }, + { test: /learn|owner|adapt/i, color: VIOLET }, + { test: /vector|memory|store/i, color: AMBER }, +]; + +function accentFor(label: string, i: number): string { + for (const a of ACCENTS) if (a.test.test(label)) return a.color; + return [BLUE, RED, VIOLET, GREEN, AMBER][i % 5] ?? BLUE; +} + +export function MetricsRail({ metrics }: { metrics: PodGraphMetric[] }) { + return ( +
+

+ Workflow metrics +

+ {metrics.map((m, i) => ( +
+

{m.value}

+

+ {m.label} +

+

{m.detail}

+
+ ))} +
+ ); +} diff --git a/frontend/src/components/graph/SelectedNodePanel.tsx b/frontend/src/components/graph/SelectedNodePanel.tsx new file mode 100644 index 0000000..d73d918 --- /dev/null +++ b/frontend/src/components/graph/SelectedNodePanel.tsx @@ -0,0 +1,63 @@ +import type { PodGraphNode } from '@podman/shared'; +import { Badge } from '@/components/ui/badge'; +import { statusColor, modeBlurb, VIOLET, type Mode } from './encoding.js'; + +export function SelectedNodePanel({ + node, + relCount, + flow, + mode, +}: { + node: PodGraphNode | undefined; + relCount: number; + flow: string; + mode: Mode; +}) { + if (!node) { + return ( +
+

+ {mode === 'learn' ? 'Learning edges' : mode === 'all' ? 'Whole graph' : 'Risk path'} +

+

What you're looking at

+

{modeBlurb(mode)}

+

+ Click any node to trace its{' '} + + flow + {' '} + — what PodMan saw, flagged, and learned. Drag to rearrange. +

+
+ ); + } + return ( +
+

+ {node.kind} +

+

{node.label}

+
+ Status + + {node.status} + +
+
+ Relationships + {relCount} +
+ {flow && ( + <> +

+ Flow +

+

{flow}

+ + )} + {node.summary && node.summary !== flow && ( +

{node.summary}

+ )} +
+ ); +} diff --git a/frontend/src/components/graph/encoding.ts b/frontend/src/components/graph/encoding.ts new file mode 100644 index 0000000..f1fc087 --- /dev/null +++ b/frontend/src/components/graph/encoding.ts @@ -0,0 +1,210 @@ +import type { CSSProperties } from 'react'; +import type { + PodGraph, + PodGraphNode, + PodGraphEdge, + PodGraphNodeKind, + PodGraphActivityKind, +} from '@podman/shared'; + +/** + * Fixed, light-readable hues for the node/edge encoding. Kept stable across + * light/dark so kinds stay distinguishable; only the chrome uses shadcn tokens. + */ +export const BLUE = '#2563eb'; +export const SLATE = '#475569'; +export const SLATE_EDGE = '#94a3b8'; +export const SLATE_FAINT = '#cbd5e1'; +export const AMBER = '#d97706'; +export const RED = '#dc2626'; +export const VIOLET = '#7c3aed'; +export const GREEN = '#16a34a'; + +/** Tag color + short label per activity-stream kind. */ +export const ACTIVITY_TAG: Record = { + editing: { color: SLATE, label: 'EDITING' }, + collision: { color: RED, label: 'COLLISION' }, + intervention: { color: AMBER, label: 'NUDGE' }, + outcome: { color: GREEN, label: 'OUTCOME' }, + learned: { color: VIOLET, label: 'LEARNED' }, + agent: { color: BLUE, label: 'AGENT' }, +}; + +export const KIND_COLOR: Record = { + engineer: BLUE, + file: SLATE, + feature: AMBER, + collision: RED, + intervention: VIOLET, +}; + +export interface EdgeStyle { + c: string; + w: number; + dash?: boolean; +} + +export const EDGE: Record = { + owns: { c: BLUE, w: 2.4 }, + editing: { c: SLATE_EDGE, w: 1.9 }, + touches: { c: SLATE_FAINT, w: 1.5 }, + collides: { c: RED, w: 2.8 }, + warns: { c: AMBER, w: 2.8 }, + learned_from: { c: VIOLET, w: 2.4, dash: true }, +}; + +/** Collision/drawing radius for a node — scaled by its 0..1 weight. */ +export function nodeRadius(node: PodGraphNode): number { + const base = node.kind === 'collision' || node.kind === 'intervention' ? 14 : 13; + return base + Math.max(0, Math.min(1, node.weight)) * 7; +} + +export function statusColor(status: string): string { + if (status === 'risk') return RED; + if (status === 'learned') return VIOLET; + if (status === 'active') return BLUE; + return 'var(--muted-foreground)'; +} + +export type Mode = 'risk' | 'learn' | 'all'; + +export interface Highlight { + nodes: Set; + edges: Set; +} + +/** + * The lit set for the current mode/selection. A selected node lights its + * incident edges + neighbors; otherwise the mode lights the risk or learning + * chain (collision → intervention → learned_from). `all` lights everything. + */ +export function highlightFor(graph: PodGraph, mode: Mode, selected: string | null): Highlight | null { + if (selected) { + const es = graph.edges.filter((e) => e.source === selected || e.target === selected); + return { + nodes: new Set([selected, ...es.flatMap((e) => [e.source, e.target])]), + edges: new Set(es.map((e) => e.id)), + }; + } + if (mode === 'all') return null; + const kinds: PodGraphEdge['kind'][] = + mode === 'risk' ? ['collides', 'warns', 'learned_from'] : ['learned_from', 'warns']; + const collisions = new Set(graph.nodes.filter((n) => n.kind === 'collision').map((n) => n.id)); + const es = graph.edges.filter( + (e) => + kinds.includes(e.kind) || + (mode === 'risk' && (collisions.has(e.target) || collisions.has(e.source))), + ); + return { + nodes: new Set(es.flatMap((e) => [e.source, e.target])), + edges: new Set(es.map((e) => e.id)), + }; +} + +function joinNames(ids: string[], label: (id: string) => string): string { + const u = [...new Set(ids)].map(label); + if (u.length <= 1) return u[0] ?? ''; + if (u.length === 2) return `${u[0]} and ${u[1]}`; + return `${u.slice(0, -1).join(', ')} and ${u[u.length - 1]}`; +} + +/** + * A plain-English walk of the flow through a node — what PodMan saw, flagged, + * suggested, and learned — so clicking a node explains the path, not just shows + * attributes. Built by traversing the node's incident edges. + */ +export function flowNarrative(graph: PodGraph, nodeId: string): string { + const byId = new Map(graph.nodes.map((n) => [n.id, n])); + const node = byId.get(nodeId); + if (!node) return ''; + const label = (id: string): string => byId.get(id)?.label ?? id; + const out = graph.edges.filter((e) => e.source === nodeId); + const inc = graph.edges.filter((e) => e.target === nodeId); + + switch (node.kind) { + case 'engineer': { + const edits = out.filter((e) => e.kind === 'editing').map((e) => e.target); + const collisions = out.filter((e) => e.kind === 'collides'); + const owns = out.filter((e) => e.kind === 'owns').map((e) => label(e.target)); + const learned = inc.some((e) => e.kind === 'learned_from'); + const parts: string[] = []; + if (edits.length) parts.push(`${node.label} is working in ${joinNames(edits, label)}.`); + if (collisions.length) + parts.push( + `PodMan flagged ${collisions.length} overlap${collisions.length === 1 ? '' : 's'} involving ${node.label}.`, + ); + if (learned) + parts.push( + `From an accepted intervention PodMan learned ${node.label} owns ${owns[0] ?? 'this file'} — retained across sessions.`, + ); + else if (owns.length) parts.push(`PodMan has ${node.label} owning ${joinNames(owns, (s) => s)}.`); + return parts.join(' ') || `${node.label} has no active flow right now.`; + } + case 'file': { + const editors = inc.filter((e) => e.kind === 'editing' || e.kind === 'owns').map((e) => e.source); + const hasCollision = out.some((e) => e.kind === 'touches'); + const parts: string[] = []; + if (editors.length) parts.push(`${node.label} is being edited by ${joinNames(editors, label)}.`); + if (hasCollision) + parts.push('Two of those edits overlap before push, so PodMan opened a collision on it.'); + return parts.join(' ') || node.summary || node.label; + } + case 'collision': { + const engineers = inc.filter((e) => e.kind === 'collides').map((e) => e.source); + const fileEdge = inc.find((e) => e.kind === 'touches'); + const file = fileEdge ? label(fileEdge.source) : 'the same file'; + const intervention = out.find((e) => e.kind === 'warns'); + let s = `${joinNames(engineers, label) || 'Two engineers'} are both editing ${file} before pushing — the overlap git can't see.`; + if (intervention) s += ` PodMan stepped in and suggested a ${label(intervention.target)}.`; + return s; + } + case 'intervention': { + const colEdge = inc.find((e) => e.kind === 'warns'); + const learned = out.find((e) => e.kind === 'learned_from'); + // Resolve the collision's underlying file via its touches edge (file → collision). + let file = ''; + if (colEdge) { + const fileEdge = graph.edges.find((e) => e.kind === 'touches' && e.target === colEdge.source); + file = fileEdge ? label(fileEdge.source) : ''; + } + let s = `PodMan offered a ${node.label}${file ? ` for the overlap on ${file}` : ''}.`; + if (learned) + s += ` The pod accepted it, so PodMan learned ${label(learned.target)} owns ${file || 'the file'} — the graph got sharper.`; + return s; + } + case 'feature': { + const contributors = inc.filter((e) => e.kind === 'owns' || e.kind === 'touches').map((e) => e.source); + return contributors.length + ? `${node.label} is built on work by ${joinNames(contributors, label)}.` + : node.summary || node.label; + } + default: + return node.summary ?? ''; + } +} + +/** Short explainer for the current view when nothing is selected. */ +export function modeBlurb(mode: Mode): string { + if (mode === 'learn') + return 'The violet learned_from links are ownership PodMan kept from accepted interventions — the graph sharpens every session.'; + if (mode === 'all') + return 'Everyone, every file, and every collision and intervention PodMan is tracking for this pod.'; + return 'The lit path: files where two editors collide before push → the nudge PodMan sent → what it learned.'; +} + +export const NODE_LEGEND: Array<{ label: string; swatch: CSSProperties }> = [ + { label: 'engineer', swatch: { background: BLUE } }, + { label: 'file', swatch: { border: `2px solid ${SLATE}` } }, + { label: 'feature', swatch: { background: AMBER, borderRadius: '50%' } }, + { label: 'collision', swatch: { background: RED, clipPath: 'polygon(50% 0,100% 100%,0 100%)' } }, + { label: 'intervention', swatch: { background: VIOLET, transform: 'rotate(45deg)' } }, +]; + +export const EDGE_LEGEND: Array<{ label: string; color: string; dash?: boolean }> = [ + { label: 'collides', color: RED }, + { label: 'warns', color: AMBER }, + { label: 'learned_from', color: VIOLET, dash: true }, + { label: 'owns', color: BLUE }, + { label: 'editing', color: SLATE_EDGE }, + { label: 'touches', color: SLATE_FAINT }, +]; diff --git a/frontend/src/components/graph/forceSim.ts b/frontend/src/components/graph/forceSim.ts new file mode 100644 index 0000000..b2432eb --- /dev/null +++ b/frontend/src/components/graph/forceSim.ts @@ -0,0 +1,284 @@ +/** + * A tiny dependency-free force-directed layout — the same family of forces as + * d3-force (charge repulsion, link springs, centering, collision) integrated + * with velocity-Verlet and an annealing `alpha`. Kept in-house so the dynamic + * graph adds no new package / lockfile churn to a fast-moving shared `main`. + * + * Usage: `setData()` (diff-preserving — existing nodes keep their position), + * then drive `tick()` from a requestAnimationFrame loop until `settled()`. + */ + +export interface SimNodeInput { + id: string; + /** Drawing/collision radius. */ + radius: number; + /** Initial position hint (e.g. the server layout), used only for new nodes. */ + seedX: number; + seedY: number; +} + +export interface SimLinkInput { + source: string; + target: string; + /** Preferred rest length of the spring. */ + distance: number; + /** 0..1 spring strength. */ + strength: number; +} + +export interface SimNode { + id: string; + x: number; + y: number; + vx: number; + vy: number; + /** When non-null the node is pinned (dragged) and forces don't move it. */ + fx: number | null; + fy: number | null; + radius: number; +} + +const ALPHA_MIN = 0.001; +const ALPHA_DECAY = 1 - Math.pow(ALPHA_MIN, 1 / 300); // settle in ~300 ticks +const FRICTION = 0.62; // velocity retained per tick +const REPEL = 4400; // charge repulsion strength — must dominate centering or the graph collapses +const LINK_K = 0.45; // spring stiffness multiplier +const CENTER_STRENGTH = 0.014; // gentle positional pull — only keeps the cloud roughly centered +const RECENTER = 0.5; // per-tick centroid recentering (no compression, keeps graph framed) +const COLLIDE_PAD = 12; +const COLLIDE_STRENGTH = 1; // hard separation so linked nodes never stack +const COLLIDE_ITERS = 2; +const BOUND_PAD = 30; // keep nodes this far inside the canvas edges + +export class ForceSim { + nodes: SimNode[] = []; + links: SimLinkInput[] = []; + alpha = 1; + private byId = new Map(); + private alphaTarget = 0; + private center: { x: number; y: number }; + private width: number; + private height: number; + + constructor(width: number, height: number) { + this.width = width; + this.height = height; + this.center = { x: width / 2, y: height / 2 }; + } + + settled(): boolean { + return this.alpha < ALPHA_MIN && this.alphaTarget === 0; + } + + reheat(a = 0.7): void { + this.alpha = Math.max(this.alpha, a); + } + + /** Hold the simulation warm while dragging, then release. */ + setActive(active: boolean): void { + this.alphaTarget = active ? 0.18 : 0; + if (active) this.reheat(0.25); + } + + get(id: string): SimNode | undefined { + return this.byId.get(id); + } + + pin(id: string, x: number, y: number): void { + const n = this.byId.get(id); + if (n) { + n.fx = x; + n.fy = y; + } + } + + unpin(id: string): void { + const n = this.byId.get(id); + if (n) { + n.fx = null; + n.fy = null; + } + } + + /** Replace the graph, preserving the positions/pins of nodes that persist. */ + setData(nodeInputs: SimNodeInput[], linkInputs: SimLinkInput[]): { added: string[] } { + const prev = this.byId; + const next = new Map(); + const added: string[] = []; + for (const inp of nodeInputs) { + const old = prev.get(inp.id); + if (old) { + old.radius = inp.radius; + next.set(inp.id, old); + } else { + next.set(inp.id, { + id: inp.id, + x: inp.seedX + (Math.random() - 0.5) * 14, + y: inp.seedY + (Math.random() - 0.5) * 14, + vx: 0, + vy: 0, + fx: null, + fy: null, + radius: inp.radius, + }); + added.push(inp.id); + } + } + this.byId = next; + this.nodes = [...next.values()]; + this.links = linkInputs.filter((l) => next.has(l.source) && next.has(l.target)); + return { added }; + } + + /** Advance one step. Returns false when already settled (no work done). */ + tick(): boolean { + if (this.settled()) return false; + this.alpha += (this.alphaTarget - this.alpha) * ALPHA_DECAY; + const a = this.alpha; + this.applyCharge(a); + this.applyLinks(a); + this.applyCenter(a); + for (let k = 0; k < COLLIDE_ITERS; k++) this.applyCollide(); + const maxX = this.width - BOUND_PAD; + const maxY = this.height - BOUND_PAD; + for (const n of this.nodes) { + if (n.fx != null) { + n.x = n.fx; + n.vx = 0; + } else { + n.vx *= FRICTION; + n.x += n.vx; + if (n.x < BOUND_PAD) { + n.x = BOUND_PAD; + n.vx = 0; + } else if (n.x > maxX) { + n.x = maxX; + n.vx = 0; + } + } + if (n.fy != null) { + n.y = n.fy; + n.vy = 0; + } else { + n.vy *= FRICTION; + n.y += n.vy; + if (n.y < BOUND_PAD) { + n.y = BOUND_PAD; + n.vy = 0; + } else if (n.y > maxY) { + n.y = maxY; + n.vy = 0; + } + } + } + return true; + } + + private applyCharge(alpha: number): void { + const ns = this.nodes; + for (let i = 0; i < ns.length; i++) { + const a = ns[i]; + if (!a) continue; + for (let j = i + 1; j < ns.length; j++) { + const b = ns[j]; + if (!b) continue; + let dx = b.x - a.x; + let dy = b.y - a.y; + let d2 = dx * dx + dy * dy; + if (d2 === 0) { + dx = (j - i) * 0.5; + dy = (i + 1) * 0.4; + d2 = dx * dx + dy * dy; + } + const dist = Math.sqrt(d2); + const force = (REPEL * alpha) / d2; + const ux = dx / dist; + const uy = dy / dist; + a.vx -= ux * force; + a.vy -= uy * force; + b.vx += ux * force; + b.vy += uy * force; + } + } + } + + private applyLinks(alpha: number): void { + for (const link of this.links) { + const s = this.byId.get(link.source); + const t = this.byId.get(link.target); + if (!s || !t) continue; + let dx = t.x - s.x; + let dy = t.y - s.y; + let d2 = dx * dx + dy * dy; + if (d2 === 0) { + dx = 0.5; + dy = 0.5; + d2 = 0.5; + } + const dist = Math.sqrt(d2); + const k = ((dist - link.distance) / dist) * alpha * link.strength * LINK_K; + const mx = dx * k * 0.5; + const my = dy * k * 0.5; + s.vx += mx; + s.vy += my; + t.vx -= mx; + t.vy -= my; + } + } + + private applyCenter(alpha: number): void { + const n = this.nodes.length; + if (!n) return; + // Recenter the whole cloud so its centroid sits at canvas center (this does + // NOT compress the layout — repulsion/links set the spread), plus a gentle + // positional pull so stray/isolated nodes don't park against the edge. + let cx = 0; + let cy = 0; + for (const nd of this.nodes) { + cx += nd.x; + cy += nd.y; + } + cx = (this.center.x - cx / n) * RECENTER; + cy = (this.center.y - cy / n) * RECENTER; + for (const nd of this.nodes) { + if (nd.fx == null) { + nd.x += cx; + nd.vx += (this.center.x - nd.x) * CENTER_STRENGTH * alpha; + } + if (nd.fy == null) { + nd.y += cy; + nd.vy += (this.center.y - nd.y) * CENTER_STRENGTH * alpha; + } + } + } + + private applyCollide(): void { + const ns = this.nodes; + for (let i = 0; i < ns.length; i++) { + const a = ns[i]; + if (!a) continue; + for (let j = i + 1; j < ns.length; j++) { + const b = ns[j]; + if (!b) continue; + let dx = b.x - a.x; + let dy = b.y - a.y; + const d2 = dx * dx + dy * dy; + const min = a.radius + b.radius + COLLIDE_PAD; + if (d2 >= min * min) continue; + let dist = Math.sqrt(d2); + if (dist === 0) { + dx = j - i; + dy = i + 1; + dist = Math.sqrt(dx * dx + dy * dy) || 1; + } + const push = ((min - dist) / dist) * 0.5 * COLLIDE_STRENGTH; + const ox = dx * push; + const oy = dy * push; + if (a.fx == null) a.x -= ox; + if (a.fy == null) a.y -= oy; + if (b.fx == null) b.x += ox; + if (b.fy == null) b.y += oy; + } + } + } +} diff --git a/frontend/src/lib/graph.ts b/frontend/src/lib/graph.ts index b3a57c7..9d746de 100644 --- a/frontend/src/lib/graph.ts +++ b/frontend/src/lib/graph.ts @@ -8,3 +8,8 @@ export async function fetchPodGraph(podId: string): Promise { if (!res.ok) throw new Error(`graph request failed: ${res.status}`); return res.json() as Promise; } + +/** WebSocket URL for the live event bus — used to nudge the graph to refetch. */ +export function backendEventsUrl(): string { + return `${BACKEND_URL.replace(/^http/, 'ws')}/api/events`; +}