c97863e05e
Rebuild the live "Team memory" view so the light/real-data version matches the dark Bauhaus mock and the graph is genuinely DYNAMIC instead of dead static columns. Frontend (frontend/src/components/graph/*, composed into GraphView.tsx): - forceSim.ts: a tiny dependency-free force layout (charge repulsion, link springs, centroid recentering + gentle pull, 2-pass collision, bounds clamp, alpha anneal). No d3-force dependency added — keeps the shared pnpm-lock untouched so CI's frozen-lockfile install and the deploy path are unaffected. - GraphCanvas.tsx: SVG render driven by the sim — draggable + pinnable nodes (double-click to release), curved edges that fan parallel pairs, weight-sized geometric node shapes, fade-in on new nodes/edges, animated learned_from dash, risk-path lighting with the rest dimmed, label collision-avoidance. - MetricsRail / LearningLoop / ActivityStream / SelectedNodePanel / encoding.ts: the mock's rails + stream + detail panel, light shadcn (ToggleGroup, ScrollArea, Badge, Button) on theme tokens; only the SVG is bespoke. - GraphView polls /api/pods/:id/graph every 5s and diffs (positions preserved across refreshes), with a best-effort ws /api/events nudge. A stale selection (node gone across a poll) is dropped so the canvas can't dim entirely. Backend (additive — materializer de-noise untouched): - live.ts: buildLoop() (observe→store→predict→outcome→adapt counts, deepest-recent stage active) and buildActivity() (time-sorted typed feed, same isFilePath / ENGINEER_NOISE / signature de-noise) emitted alongside nodes/edges/metrics. - demo.ts: fallback loop + activity so the panels render on the demo path. - shared/src/graph.ts: additive optional PodGraph.loop / .activity + LearningStage / ActivityEvent types. Verified: pnpm lint + -r typecheck + -r build pass; Playwright on the dev build confirmed force layout (distinct positions, ticks on load under StrictMode), drag, risk-mode dimming (opacity 0.14), selection panel, legend, and no overlaps on both the clean demo and the 31-node live hairball. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
218 lines
8.4 KiB
TypeScript
218 lines
8.4 KiB
TypeScript
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<PodGraph | null>(null);
|
|
const [error, setError] = useState<string | null>(null);
|
|
const [mode, setMode] = useState<Mode>('risk');
|
|
const [selected, setSelected] = useState<string | null>(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 (
|
|
<div className="min-h-screen bg-background text-foreground">
|
|
<style>{GRAPH_CSS}</style>
|
|
<div className="mx-auto w-full max-w-7xl px-4 py-4 sm:px-6 lg:px-8">
|
|
<div className="overflow-hidden rounded-xl border bg-card text-card-foreground shadow-sm">
|
|
{/* Header */}
|
|
<div className="flex items-center justify-between gap-3 border-b px-5 py-4">
|
|
<div className="min-w-0">
|
|
<div className="flex items-center gap-2">
|
|
<h2 className="font-heading text-base font-medium">Team memory</h2>
|
|
<span className="inline-flex items-center gap-1.5 rounded-full border px-2 py-0.5 text-[0.65rem] font-medium uppercase tracking-wide text-muted-foreground">
|
|
<span className="pm-pulse inline-block size-1.5 rounded-full bg-[#16a34a]" />
|
|
Live
|
|
</span>
|
|
</div>
|
|
<p className="mt-0.5 truncate text-xs text-muted-foreground">
|
|
What PodMan learned · {podId}
|
|
</p>
|
|
</div>
|
|
<Button variant="outline" size="sm" onClick={onClose}>
|
|
← Pods
|
|
</Button>
|
|
</div>
|
|
|
|
{/* Toggles */}
|
|
<div className="flex flex-wrap items-center gap-2 border-b px-4 py-3">
|
|
<ToggleGroup
|
|
type="single"
|
|
value={mode}
|
|
onValueChange={(v) => v && pick(v as Mode)}
|
|
variant="outline"
|
|
size="sm"
|
|
>
|
|
<ToggleGroupItem value="risk">Risk path</ToggleGroupItem>
|
|
<ToggleGroupItem value="learn">Learning edges</ToggleGroupItem>
|
|
<ToggleGroupItem value="all">Whole graph</ToggleGroupItem>
|
|
</ToggleGroup>
|
|
<span className="ml-auto hidden text-xs text-muted-foreground sm:inline">
|
|
Drag to rearrange · double-click to release · click to inspect
|
|
</span>
|
|
</div>
|
|
|
|
{error && <p className="px-4 py-4 text-sm text-destructive">Graph error: {error}</p>}
|
|
{!graph && !error && (
|
|
<p className="px-4 py-10 text-center text-sm text-muted-foreground">Loading graph…</p>
|
|
)}
|
|
|
|
{graph && (
|
|
<>
|
|
{/* Metrics · graph · learning loop */}
|
|
<div className="grid gap-4 p-4 lg:grid-cols-[180px_minmax(0,1fr)_212px]">
|
|
<MetricsRail metrics={graph.metrics} />
|
|
|
|
<div className="flex min-h-[440px] flex-col overflow-hidden rounded-xl border bg-card">
|
|
<GraphCanvas
|
|
graph={graph}
|
|
highlight={highlight}
|
|
selected={liveSelected}
|
|
onSelect={setSelected}
|
|
/>
|
|
</div>
|
|
|
|
{graph.loop?.length ? (
|
|
<LearningLoop stages={graph.loop} />
|
|
) : (
|
|
<div />
|
|
)}
|
|
</div>
|
|
|
|
{/* Activity stream · selected node */}
|
|
<div className="grid gap-4 border-t px-4 py-4 lg:grid-cols-[minmax(0,1fr)_320px]">
|
|
<div className="rounded-xl border bg-card p-4">
|
|
<ActivityStream events={graph.activity ?? []} />
|
|
</div>
|
|
<div className="rounded-xl border bg-muted/40 p-4">
|
|
<SelectedNodePanel node={sel} relCount={relCount} />
|
|
</div>
|
|
</div>
|
|
|
|
{/* Legend */}
|
|
<div className="flex flex-wrap items-center gap-x-3 gap-y-1.5 border-t px-4 py-2.5 text-xs text-muted-foreground">
|
|
{NODE_LEGEND.map((l) => (
|
|
<span key={l.label} className="flex items-center gap-1.5">
|
|
<span className="inline-block size-3" style={l.swatch} />
|
|
{l.label}
|
|
</span>
|
|
))}
|
|
<span className="mx-1 h-3 w-px bg-border" aria-hidden />
|
|
{EDGE_LEGEND.map((l) => (
|
|
<span key={l.label} className="flex items-center gap-1.5">
|
|
<span
|
|
className="inline-block h-[3px] w-3.5"
|
|
style={
|
|
l.dash
|
|
? { backgroundImage: `repeating-linear-gradient(90deg, ${l.color} 0 3px, transparent 3px 6px)` }
|
|
: { background: l.color }
|
|
}
|
|
/>
|
|
{l.label}
|
|
</span>
|
|
))}
|
|
</div>
|
|
</>
|
|
)}
|
|
</div>
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|