feat(graph): live materializer + light shadcn theme (real-data glue)
- backend/src/graph/live.ts: materializePodGraph builds the graph from the real collections (pods, engineer_states, observations, collisions, interventions, outcomes) instead of the demo seed. Parses git-status paths, fuses git+vision, draws collides/warns/learned_from, computes live metrics + a column layout. - store.ts: loadPodGraph now prefers live → seeded team_model.graph → demo. - GraphView.tsx: light/shadcn theme (from the team-memory-theme work), composed from the ruixen primitives; node-shape encoding unchanged. - docs/graph.md: live data→graph mapping + fallback order. Typechecks clean. Not yet runtime-verified end-to-end (Atlas creds rotated again). Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,287 @@
|
|||||||
|
import type {
|
||||||
|
PodGraph,
|
||||||
|
PodGraphNode,
|
||||||
|
PodGraphEdge,
|
||||||
|
PodGraphMetric,
|
||||||
|
PodGraphNodeKind,
|
||||||
|
PodGraphEdgeKind,
|
||||||
|
PodGraphNodeStatus,
|
||||||
|
} from '@podman/shared';
|
||||||
|
import { collections, getGitStates, getDb } from '../memory/db.js';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Live materializer: build a pod's continual-learning graph from the real
|
||||||
|
* collections the agent writes (pods, engineer_states, observations, collisions,
|
||||||
|
* interventions, outcomes) — NOT the hardcoded demo. See docs/live-ui-spec.md §1.
|
||||||
|
*
|
||||||
|
* Pure-read and best-effort. Returns `null` when there is no real activity yet
|
||||||
|
* (only bare roster), so `loadPodGraph` can fall back to the demo graph.
|
||||||
|
*/
|
||||||
|
|
||||||
|
const ACTIVE_WINDOW_MS = 90_000;
|
||||||
|
const MAX_OBSERVATIONS = 250;
|
||||||
|
|
||||||
|
/** Strip a `git status --short` XY code (and rename `old -> new`) to a clean path. */
|
||||||
|
export function parseGitStatusPath(line: string): string {
|
||||||
|
let s = line.trim();
|
||||||
|
const arrow = s.indexOf(' -> ');
|
||||||
|
if (arrow !== -1) s = s.slice(arrow + 4);
|
||||||
|
else s = s.replace(/^[ACDMRTU?!]{1,2}\s+/, '');
|
||||||
|
return normalizeFile(s);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Normalize a file path so vision (`collisions.file`) and git paths match. */
|
||||||
|
export function normalizeFile(f: string): string {
|
||||||
|
return f
|
||||||
|
.trim()
|
||||||
|
.replace(/^["']|["']$/g, '')
|
||||||
|
.replace(/^\.\//, '');
|
||||||
|
}
|
||||||
|
|
||||||
|
const STATUS_RANK: Record<PodGraphNodeStatus, number> = {
|
||||||
|
stable: 0,
|
||||||
|
active: 1,
|
||||||
|
learned: 2,
|
||||||
|
risk: 3,
|
||||||
|
};
|
||||||
|
|
||||||
|
interface Builder {
|
||||||
|
nodes: Map<string, PodGraphNode>;
|
||||||
|
edges: Map<string, PodGraphEdge>;
|
||||||
|
}
|
||||||
|
|
||||||
|
function nodeKey(kind: PodGraphNodeKind, key: string): string {
|
||||||
|
return `${kind}:${key}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function upsertNode(
|
||||||
|
b: Builder,
|
||||||
|
kind: PodGraphNodeKind,
|
||||||
|
key: string,
|
||||||
|
patch: Partial<Omit<PodGraphNode, 'id' | 'kind' | 'x' | 'y'>>,
|
||||||
|
): string {
|
||||||
|
const id = nodeKey(kind, key);
|
||||||
|
const cur = b.nodes.get(id);
|
||||||
|
if (!cur) {
|
||||||
|
b.nodes.set(id, {
|
||||||
|
id,
|
||||||
|
kind,
|
||||||
|
label: patch.label ?? key,
|
||||||
|
summary: patch.summary ?? '',
|
||||||
|
weight: patch.weight ?? 0.6,
|
||||||
|
status: patch.status ?? 'stable',
|
||||||
|
x: 0,
|
||||||
|
y: 0,
|
||||||
|
});
|
||||||
|
return id;
|
||||||
|
}
|
||||||
|
if (patch.label) cur.label = patch.label;
|
||||||
|
if (patch.summary) cur.summary = patch.summary;
|
||||||
|
if (patch.weight && patch.weight > cur.weight) cur.weight = patch.weight;
|
||||||
|
if (patch.status && STATUS_RANK[patch.status] > STATUS_RANK[cur.status])
|
||||||
|
cur.status = patch.status;
|
||||||
|
return id;
|
||||||
|
}
|
||||||
|
|
||||||
|
function upsertEdge(
|
||||||
|
b: Builder,
|
||||||
|
source: string,
|
||||||
|
target: string,
|
||||||
|
kind: PodGraphEdgeKind,
|
||||||
|
label: string,
|
||||||
|
strength: number,
|
||||||
|
): void {
|
||||||
|
const id = `${kind}:${source}->${target}`;
|
||||||
|
const cur = b.edges.get(id);
|
||||||
|
if (!cur) b.edges.set(id, { id, source, target, kind, label, strength });
|
||||||
|
else if (strength > cur.strength) cur.strength = strength;
|
||||||
|
}
|
||||||
|
|
||||||
|
const COLUMN_X: Record<PodGraphNodeKind, number> = {
|
||||||
|
engineer: 78,
|
||||||
|
file: 300,
|
||||||
|
feature: 360,
|
||||||
|
collision: 470,
|
||||||
|
intervention: 622,
|
||||||
|
};
|
||||||
|
|
||||||
|
/** Deterministic column layout so the SVG renders stably across refreshes. */
|
||||||
|
function layout(nodes: PodGraphNode[]): void {
|
||||||
|
const byKind = new Map<PodGraphNodeKind, PodGraphNode[]>();
|
||||||
|
for (const n of nodes) {
|
||||||
|
const list = byKind.get(n.kind) ?? [];
|
||||||
|
list.push(n);
|
||||||
|
byKind.set(n.kind, list);
|
||||||
|
}
|
||||||
|
for (const [kind, list] of byKind) {
|
||||||
|
list.sort((a, b) => a.id.localeCompare(b.id));
|
||||||
|
const n = list.length;
|
||||||
|
list.forEach((node, i) => {
|
||||||
|
node.x = COLUMN_X[kind];
|
||||||
|
node.y = Math.round(((i + 1) / (n + 1)) * 452) + 10;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const SEVERITY_WEIGHT: Record<string, number> = { info: 0.4, warn: 0.7, critical: 1 };
|
||||||
|
|
||||||
|
export async function materializePodGraph(podId: string): Promise<PodGraph | null> {
|
||||||
|
const c = await collections();
|
||||||
|
const db = await getDb();
|
||||||
|
|
||||||
|
const [pod, observations, collisionDocs, interventionDocs, outcomeDocs, gitStates] =
|
||||||
|
await Promise.all([
|
||||||
|
c.pods.findOne({ id: podId }),
|
||||||
|
c.observations.find({ podId }).sort({ observedAt: -1 }).limit(MAX_OBSERVATIONS).toArray(),
|
||||||
|
c.collisions.find({ podId }).sort({ detectedAt: -1 }).limit(100).toArray(),
|
||||||
|
c.interventions.find({ podId }).toArray(),
|
||||||
|
c.outcomes.find({ podId }).toArray(),
|
||||||
|
getGitStates(podId),
|
||||||
|
]);
|
||||||
|
|
||||||
|
// Optional supervised ownership map (team_model.ownership: file -> engineer).
|
||||||
|
let ownership: Record<string, string> = {};
|
||||||
|
try {
|
||||||
|
const tm = await db
|
||||||
|
.collection<{ podId: string; ownership?: Record<string, string> }>('team_model')
|
||||||
|
.findOne({ podId });
|
||||||
|
ownership = tm?.ownership ?? {};
|
||||||
|
} catch {
|
||||||
|
/* ownership is optional */
|
||||||
|
}
|
||||||
|
|
||||||
|
const b: Builder = { nodes: new Map(), edges: new Map() };
|
||||||
|
const now = Date.now();
|
||||||
|
|
||||||
|
// 1. Baseline engineer nodes from the roster.
|
||||||
|
for (const name of pod?.members ?? []) {
|
||||||
|
upsertNode(b, 'engineer', name, { label: name });
|
||||||
|
}
|
||||||
|
|
||||||
|
// 2. Git truth (engineer_states): unpushed work + edited files.
|
||||||
|
for (const [name, git] of gitStates) {
|
||||||
|
const files = git.changedFiles.map(parseGitStatusPath).filter(Boolean);
|
||||||
|
const eng = upsertNode(b, 'engineer', name, {
|
||||||
|
label: name,
|
||||||
|
status: files.length > 0 ? 'risk' : 'active',
|
||||||
|
summary: files.length
|
||||||
|
? `${files.length} changed file(s) on ${git.branch ?? 'detached'}`
|
||||||
|
: `on ${git.branch ?? 'detached'}`,
|
||||||
|
weight: 0.7,
|
||||||
|
});
|
||||||
|
for (const file of files) {
|
||||||
|
const f = upsertNode(b, 'file', file, { label: file, status: 'risk' });
|
||||||
|
upsertEdge(b, eng, f, 'editing', 'edits', 0.6);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 3. Vision (observations): who is active and on which file, with confidence.
|
||||||
|
for (const o of observations) {
|
||||||
|
if (!o.engineerId) continue;
|
||||||
|
const recent = o.observedAt && now - new Date(o.observedAt).getTime() < ACTIVE_WINDOW_MS;
|
||||||
|
const eng = upsertNode(b, 'engineer', o.engineerId, {
|
||||||
|
label: o.engineerId,
|
||||||
|
status: recent ? 'active' : undefined,
|
||||||
|
});
|
||||||
|
if (o.currentFile) {
|
||||||
|
const file = normalizeFile(o.currentFile);
|
||||||
|
const f = upsertNode(b, 'file', file, { label: file });
|
||||||
|
upsertEdge(b, eng, f, 'editing', o.activity ?? 'edits', Math.max(0.4, o.confidence ?? 0.5));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 4. Collisions: the detected overlaps (fused git + vision).
|
||||||
|
const collisionById = new Map<string, (typeof collisionDocs)[number]>();
|
||||||
|
for (const col of collisionDocs) {
|
||||||
|
collisionById.set(col.id, col);
|
||||||
|
const cNode = upsertNode(b, 'collision', col.id, {
|
||||||
|
label: col.symbol ? `${col.file}#${col.symbol}` : col.file,
|
||||||
|
status: 'risk',
|
||||||
|
weight: SEVERITY_WEIGHT[col.severity] ?? 0.7,
|
||||||
|
summary: `${col.engineers.join(' + ')} on ${col.file}${
|
||||||
|
(col as { memorySignature?: string }).memorySignature ? ' · seen before' : ''
|
||||||
|
}`,
|
||||||
|
});
|
||||||
|
const file = normalizeFile(col.file);
|
||||||
|
const fNode = upsertNode(b, 'file', file, { label: file, status: 'risk' });
|
||||||
|
upsertEdge(b, fNode, cNode, 'touches', 'hot', 0.6);
|
||||||
|
for (const name of col.engineers) {
|
||||||
|
const eng = upsertNode(b, 'engineer', name, { label: name });
|
||||||
|
upsertEdge(b, eng, cNode, 'collides', 'in', SEVERITY_WEIGHT[col.severity] ?? 0.7);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 5. Interventions: what PodMan offered for each collision.
|
||||||
|
const interventionById = new Map<string, (typeof interventionDocs)[number]>();
|
||||||
|
for (const iv of interventionDocs) {
|
||||||
|
interventionById.set(iv.id, iv);
|
||||||
|
const ivNode = upsertNode(b, 'intervention', iv.id, {
|
||||||
|
label:
|
||||||
|
iv.suggestedAction?.kind === 'open_sync_pr'
|
||||||
|
? 'sync PR'
|
||||||
|
: iv.suggestedAction?.kind === 'ping_teammate'
|
||||||
|
? 'ping'
|
||||||
|
: 'watch',
|
||||||
|
summary: iv.message,
|
||||||
|
});
|
||||||
|
if (b.nodes.has(nodeKey('collision', iv.collisionId))) {
|
||||||
|
upsertEdge(b, nodeKey('collision', iv.collisionId), ivNode, 'warns', 'nudges', 0.85);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 6. Outcomes: the supervised learning signal -> learned_from edges + owns.
|
||||||
|
for (const out of outcomeDocs) {
|
||||||
|
if (!out.accepted || !out.wasRealCollision) continue;
|
||||||
|
const iv = interventionById.get(out.interventionId);
|
||||||
|
const col = iv ? collisionById.get(iv.collisionId) : collisionById.get(out.collisionId);
|
||||||
|
if (!col) continue;
|
||||||
|
const file = normalizeFile(col.file);
|
||||||
|
const owner =
|
||||||
|
(out as { learnedOwner?: string }).learnedOwner ?? ownership[file] ?? col.engineers[0];
|
||||||
|
if (!owner) continue;
|
||||||
|
const engNode = upsertNode(b, 'engineer', owner, { label: owner, status: 'learned' });
|
||||||
|
const fNode = upsertNode(b, 'file', file, { label: file });
|
||||||
|
upsertEdge(b, engNode, fNode, 'owns', 'owns', 0.85);
|
||||||
|
const ivKey = iv ? nodeKey('intervention', iv.id) : null;
|
||||||
|
if (ivKey && b.nodes.has(ivKey)) {
|
||||||
|
upsertNode(b, 'intervention', iv!.id, { status: 'learned' });
|
||||||
|
upsertEdge(b, ivKey, engNode, 'learned_from', `learned: owns ${file}`, 0.6);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const nodes = [...b.nodes.values()];
|
||||||
|
// No real activity beyond the bare roster -> let the caller fall back to demo.
|
||||||
|
const hasActivity = nodes.some((n) => n.kind !== 'engineer');
|
||||||
|
if (!hasActivity) return null;
|
||||||
|
|
||||||
|
layout(nodes);
|
||||||
|
|
||||||
|
const acceptedReal = outcomeDocs.filter((o) => o.accepted && o.wasRealCollision).length;
|
||||||
|
const totalOutcomes = outcomeDocs.length;
|
||||||
|
const riskPaths = collisionDocs.filter((col) => new Set(col.engineers).size >= 2).length;
|
||||||
|
const metrics: PodGraphMetric[] = [
|
||||||
|
{
|
||||||
|
label: 'Learned owners',
|
||||||
|
value: String(acceptedReal),
|
||||||
|
detail: 'Ownership retained from accepted interventions.',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: 'Open risk paths',
|
||||||
|
value: String(riskPaths),
|
||||||
|
detail: 'Files with two or more converging editors.',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: 'Accept rate',
|
||||||
|
value: totalOutcomes ? `${Math.round((acceptedReal / totalOutcomes) * 100)}%` : '—',
|
||||||
|
detail: 'Interventions accepted this session.',
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
return {
|
||||||
|
podId,
|
||||||
|
generatedAt: new Date().toISOString(),
|
||||||
|
nodes,
|
||||||
|
edges: [...b.edges.values()],
|
||||||
|
metrics,
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -1,6 +1,7 @@
|
|||||||
import type { PodGraph, GraphNodeDoc, GraphEdgeDoc } from '@podman/shared';
|
import type { PodGraph, GraphNodeDoc, GraphEdgeDoc } from '@podman/shared';
|
||||||
import { getDb } from '../memory/db.js';
|
import { getDb } from '../memory/db.js';
|
||||||
import { createDemoPodGraph } from './demo.js';
|
import { createDemoPodGraph } from './demo.js';
|
||||||
|
import { materializePodGraph } from './live.js';
|
||||||
|
|
||||||
interface TeamModelDoc {
|
interface TeamModelDoc {
|
||||||
podId: string;
|
podId: string;
|
||||||
@@ -14,6 +15,14 @@ interface TeamModelDoc {
|
|||||||
* unreachable — so the demo path never depends on a populated DB.
|
* unreachable — so the demo path never depends on a populated DB.
|
||||||
*/
|
*/
|
||||||
export async function loadPodGraph(podId: string): Promise<PodGraph> {
|
export async function loadPodGraph(podId: string): Promise<PodGraph> {
|
||||||
|
// 1. Live: materialize from the real collections (observations/collisions/…).
|
||||||
|
try {
|
||||||
|
const live = await materializePodGraph(podId);
|
||||||
|
if (live) return live;
|
||||||
|
} catch (err) {
|
||||||
|
console.warn(`[graph] live materialize failed, falling back: ${(err as Error).message}`);
|
||||||
|
}
|
||||||
|
// 2. Seeded snapshot embedded in team_model.
|
||||||
try {
|
try {
|
||||||
const db = await getDb();
|
const db = await getDb();
|
||||||
const doc = await db.collection<TeamModelDoc>('team_model').findOne({ podId });
|
const doc = await db.collection<TeamModelDoc>('team_model').findOne({ podId });
|
||||||
@@ -21,6 +30,7 @@ export async function loadPodGraph(podId: string): Promise<PodGraph> {
|
|||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.warn(`[graph] loadPodGraph fell back to demo: ${(err as Error).message}`);
|
console.warn(`[graph] loadPodGraph fell back to demo: ${(err as Error).message}`);
|
||||||
}
|
}
|
||||||
|
// 3. Demo (stage safety — never an empty canvas).
|
||||||
return createDemoPodGraph(podId);
|
return createDemoPodGraph(podId);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+23
-6
@@ -76,13 +76,30 @@ Additive routes in `backend/src/server.ts` (shared file — additive only).
|
|||||||
|
|
||||||
- `shared/src/graph.ts` — `PodGraph`, `PodGraphNode/Edge/Metric`, `GraphNodeDoc`, `GraphEdgeDoc`
|
- `shared/src/graph.ts` — `PodGraph`, `PodGraphNode/Edge/Metric`, `GraphNodeDoc`, `GraphEdgeDoc`
|
||||||
- `backend/src/graph/demo.ts` — `createDemoPodGraph(podId)` (grounded in the demo-pod crew)
|
- `backend/src/graph/demo.ts` — `createDemoPodGraph(podId)` (grounded in the demo-pod crew)
|
||||||
- `backend/src/graph/store.ts` — `loadPodGraph`, `seedGraph`, `reachFrom` (`$graphLookup`)
|
- `backend/src/graph/live.ts` — **`materializePodGraph(podId)`**: builds the graph from the real
|
||||||
|
collections (pods, engineer_states, observations, collisions, interventions, outcomes)
|
||||||
|
- `backend/src/graph/store.ts` — `loadPodGraph` (live → seeded → demo), `seedGraph`, `reachFrom` (`$graphLookup`)
|
||||||
- `backend/src/graph/seed.ts` — `pnpm graph:seed` (writes demo into `team_model` + graph collections)
|
- `backend/src/graph/seed.ts` — `pnpm graph:seed` (writes demo into `team_model` + graph collections)
|
||||||
- `frontend/src/lib/graph.ts` — `fetchPodGraph(podId)`
|
- `frontend/src/lib/graph.ts` — `fetchPodGraph(podId)`
|
||||||
- `frontend/src/components/GraphView.tsx` — dark-Bauhaus SVG graph (toggle from `App.tsx`)
|
- `frontend/src/components/GraphView.tsx` — shadcn-themed SVG graph (theme-aware; toggle from `App.tsx`)
|
||||||
|
|
||||||
## Demo-first plan
|
## Live data → graph mapping
|
||||||
|
|
||||||
1. Serve `createDemoPodGraph()` from the route (demo-stable, no DB dependency on the demo path).
|
`materializePodGraph` reads the 5 real collections per pod and emits a `PodGraph`:
|
||||||
2. `pnpm graph:seed` writes the same graph into Mongo so `$graphLookup` is real, not a mock.
|
|
||||||
3. Swap `loadPodGraph` to read live `team_model.graph` once the ingest pipeline populates it.
|
| Collection | Produces |
|
||||||
|
| ----------------- | -------------------------------------------------------------------------- |
|
||||||
|
| `pods.members` | baseline **engineer** nodes |
|
||||||
|
| `engineer_states` | engineer `risk` if unpushed; **file** nodes (git paths parsed); `editing` |
|
||||||
|
| `observations` | engineer `active`; **file** from `currentFile`; `editing` (strength=conf.) |
|
||||||
|
| `collisions` | **collision** nodes; `collides` (eng→col) + `touches` (file→col) |
|
||||||
|
| `interventions` | **intervention** nodes; `warns` (col→intervention) |
|
||||||
|
| `outcomes` | `learned_from` (intervention→owner) on accepted; flips nodes to `learned` |
|
||||||
|
|
||||||
|
Metrics (learned owners / open risk paths / accept rate) are live counts.
|
||||||
|
|
||||||
|
## Fallback order (`loadPodGraph`)
|
||||||
|
|
||||||
|
1. **Live** — `materializePodGraph` from the real collections (returns `null` if only bare roster).
|
||||||
|
2. **Seeded** — `team_model.graph` (from `pnpm graph:seed`).
|
||||||
|
3. **Demo** — `createDemoPodGraph()` (stage safety; never an empty canvas).
|
||||||
|
|||||||
@@ -1,24 +1,36 @@
|
|||||||
import { useEffect, useMemo, useState, type CSSProperties } from 'react';
|
import { useEffect, useMemo, useState, type CSSProperties } from 'react';
|
||||||
import type { PodGraph, PodGraphNode, PodGraphEdge, PodGraphNodeKind } from '@podman/shared';
|
import type { PodGraph, PodGraphNode, PodGraphEdge, PodGraphNodeKind } from '@podman/shared';
|
||||||
import { fetchPodGraph } from '../lib/graph.js';
|
import { fetchPodGraph } from '../lib/graph.js';
|
||||||
|
import { Button } from '@/components/ui/button';
|
||||||
|
import { Badge } from '@/components/ui/badge';
|
||||||
|
|
||||||
type Mode = 'risk' | 'learn' | 'all';
|
type Mode = 'risk' | 'learn' | 'all';
|
||||||
|
|
||||||
|
// 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<PodGraphNodeKind, string> = {
|
const KIND_COLOR: Record<PodGraphNodeKind, string> = {
|
||||||
engineer: '#3B5BFF',
|
engineer: BLUE,
|
||||||
file: '#ECE7DA',
|
file: SLATE,
|
||||||
feature: '#F6C445',
|
feature: AMBER,
|
||||||
collision: '#E2403A',
|
collision: RED,
|
||||||
intervention: '#8b6cff',
|
intervention: VIOLET,
|
||||||
};
|
};
|
||||||
|
|
||||||
const EDGE: Record<PodGraphEdge['kind'], { c: string; w: number; dash?: boolean }> = {
|
const EDGE: Record<PodGraphEdge['kind'], { c: string; w: number; dash?: boolean }> = {
|
||||||
owns: { c: '#3B5BFF', w: 2.6 },
|
owns: { c: BLUE, w: 2.6 },
|
||||||
editing: { c: '#ECE7DA', w: 2 },
|
editing: { c: SLATE_EDGE, w: 2 },
|
||||||
touches: { c: '#5d5d66', w: 1.6 },
|
touches: { c: SLATE_FAINT, w: 1.6 },
|
||||||
collides: { c: '#E2403A', w: 3.2 },
|
collides: { c: RED, w: 3.2 },
|
||||||
warns: { c: '#F6C445', w: 3.2 },
|
warns: { c: AMBER, w: 3.2 },
|
||||||
learned_from: { c: '#8b6cff', w: 2.4, dash: true },
|
learned_from: { c: VIOLET, w: 2.4, dash: true },
|
||||||
};
|
};
|
||||||
|
|
||||||
function NodeShape({ node }: { node: PodGraphNode }) {
|
function NodeShape({ node }: { node: PodGraphNode }) {
|
||||||
@@ -26,7 +38,7 @@ function NodeShape({ node }: { node: PodGraphNode }) {
|
|||||||
const { x, y } = node;
|
const { x, y } = node;
|
||||||
switch (node.kind) {
|
switch (node.kind) {
|
||||||
case 'engineer':
|
case 'engineer':
|
||||||
return <rect x={x - 15} y={y - 15} width={30} height={30} fill={c} />;
|
return <rect x={x - 15} y={y - 15} width={30} height={30} rx={4} fill={c} />;
|
||||||
case 'file':
|
case 'file':
|
||||||
return (
|
return (
|
||||||
<rect
|
<rect
|
||||||
@@ -34,6 +46,7 @@ function NodeShape({ node }: { node: PodGraphNode }) {
|
|||||||
y={y - 15}
|
y={y - 15}
|
||||||
width={30}
|
width={30}
|
||||||
height={30}
|
height={30}
|
||||||
|
rx={4}
|
||||||
fill="none"
|
fill="none"
|
||||||
stroke={c}
|
stroke={c}
|
||||||
strokeWidth={2.6}
|
strokeWidth={2.6}
|
||||||
@@ -81,16 +94,19 @@ function highlightFor(graph: PodGraph, mode: Mode, selected: string | null): Hig
|
|||||||
}
|
}
|
||||||
|
|
||||||
const LEGEND: Array<{ label: string; swatch: CSSProperties }> = [
|
const LEGEND: Array<{ label: string; swatch: CSSProperties }> = [
|
||||||
{ label: 'engineer', swatch: { background: '#3B5BFF' } },
|
{ label: 'engineer', swatch: { background: BLUE } },
|
||||||
{ label: 'file', swatch: { border: '2px solid #ECE7DA' } },
|
{ label: 'file', swatch: { border: `2px solid ${SLATE}` } },
|
||||||
{ label: 'feature', swatch: { background: '#F6C445', borderRadius: '50%' } },
|
{ label: 'feature', swatch: { background: AMBER, borderRadius: '50%' } },
|
||||||
{
|
{ label: 'collision', swatch: { background: RED, clipPath: 'polygon(50% 0,100% 100%,0 100%)' } },
|
||||||
label: 'collision',
|
{ label: 'intervention', swatch: { background: VIOLET, transform: 'rotate(45deg)' } },
|
||||||
swatch: { background: '#E2403A', clipPath: 'polygon(50% 0,100% 100%,0 100%)' },
|
|
||||||
},
|
|
||||||
{ label: 'intervention', swatch: { background: '#8b6cff', 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 }) {
|
export function GraphView({ podId, onClose }: { podId: string; onClose: () => void }) {
|
||||||
const [graph, setGraph] = useState<PodGraph | null>(null);
|
const [graph, setGraph] = useState<PodGraph | null>(null);
|
||||||
const [error, setError] = useState<string | null>(null);
|
const [error, setError] = useState<string | null>(null);
|
||||||
@@ -128,99 +144,70 @@ export function GraphView({ podId, onClose }: { podId: string; onClose: () => vo
|
|||||||
setSelected(null);
|
setSelected(null);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const toggleVariant = (m: Mode) => (mode === m && !selected ? 'default' : 'outline');
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="pm-graph">
|
<div className="min-h-screen bg-background text-foreground">
|
||||||
|
<div className="mx-auto w-full max-w-7xl px-4 py-4 sm:px-6 lg:px-8">
|
||||||
<style>{`
|
<style>{`
|
||||||
.pm-graph{--bg:#0c0c0e;--panel:#141417;--line:#2a2a31;--paper:#ECE7DA;--mut:#8d897e;--red:#E2403A;--yel:#F6C445;--vio:#8b6cff;
|
|
||||||
font-family:'Space Grotesk',system-ui,sans-serif;background:var(--bg);color:var(--paper);border:1px solid var(--line);border-radius:14px;overflow:hidden}
|
|
||||||
.pm-graph *{box-sizing:border-box}
|
|
||||||
.pm-hd{display:flex;align-items:center;justify-content:space-between;padding:16px 20px;border-bottom:3px solid var(--paper)}
|
|
||||||
.pm-ttl{font-weight:800;font-size:18px;letter-spacing:.14em;text-transform:uppercase;font-family:Archivo,'Space Grotesk',sans-serif}
|
|
||||||
.pm-sub{font-size:10px;letter-spacing:.3em;color:var(--mut);text-transform:uppercase;margin-top:5px}
|
|
||||||
.pm-x{background:transparent;border:1px solid var(--line);color:var(--paper);font-size:11px;letter-spacing:.1em;text-transform:uppercase;padding:7px 12px;border-radius:2px;cursor:pointer}
|
|
||||||
.pm-x:hover{border-color:var(--paper)}
|
|
||||||
.pm-bar{display:flex;gap:8px;padding:12px 16px;border-bottom:1px solid var(--line);flex-wrap:wrap}
|
|
||||||
.pm-btn{font-size:11px;letter-spacing:.12em;text-transform:uppercase;color:var(--paper);background:transparent;border:1px solid var(--line);padding:7px 12px;cursor:pointer;border-radius:2px}
|
|
||||||
.pm-btn:hover{border-color:var(--paper)}
|
|
||||||
.pm-btn.on{background:var(--red);border-color:var(--red);color:#fff}
|
|
||||||
.pm-grid{display:grid;grid-template-columns:180px 1fr 240px}
|
|
||||||
.pm-col{padding:14px}
|
|
||||||
.pm-railR{border-left:1px solid var(--line);background:#17171b}
|
|
||||||
.pm-st{font-size:11px;letter-spacing:.24em;text-transform:uppercase;color:var(--mut);margin:2px 0 12px}
|
|
||||||
.pm-kpi{border:1px solid var(--line);border-left:5px solid var(--vio);padding:10px 11px;margin-bottom:10px}
|
|
||||||
.pm-num{font-weight:800;font-size:26px;line-height:.9;font-variant-numeric:tabular-nums;font-family:Archivo,sans-serif}
|
|
||||||
.pm-klab{font-size:10px;letter-spacing:.16em;text-transform:uppercase;color:var(--mut);margin-top:6px}
|
|
||||||
.pm-kdet{font-size:10px;color:var(--mut);margin-top:5px;line-height:1.4}
|
|
||||||
.pm-canvas{background:var(--panel);border-left:1px solid var(--line);border-right:1px solid var(--line);min-height:472px}
|
|
||||||
.pm-canvas svg{width:100%;height:auto;display:block}
|
|
||||||
.pm-node{cursor:pointer}
|
.pm-node{cursor:pointer}
|
||||||
.pm-lbl{font-weight:500;font-size:11px;letter-spacing:.06em;fill:var(--paper);text-transform:uppercase}
|
.pm-lbl{fill:var(--foreground);font-size:11px;font-weight:500}
|
||||||
.pm-dim{opacity:.12;transition:opacity .25s}
|
.pm-dim{opacity:.18;transition:opacity .25s}
|
||||||
.pm-dkind{font-size:10px;letter-spacing:.24em;text-transform:uppercase;color:var(--mut)}
|
|
||||||
.pm-dname{font-weight:800;font-size:20px;margin:5px 0 8px;font-family:Archivo,sans-serif}
|
|
||||||
.pm-drow{display:flex;justify-content:space-between;font-size:12px;padding:6px 0;border-bottom:1px solid var(--line);color:var(--mut)}
|
|
||||||
.pm-drow b{color:var(--paper);font-weight:500}
|
|
||||||
.pm-note{font-size:12px;color:var(--mut);line-height:1.5;margin-top:10px}
|
|
||||||
.pm-legend{display:flex;gap:14px;flex-wrap:wrap;padding:10px 16px;border-top:1px solid var(--line);font-size:10px;letter-spacing:.06em;text-transform:uppercase;color:var(--mut)}
|
|
||||||
.pm-lg{display:flex;align-items:center;gap:6px}
|
|
||||||
.pm-sw{width:13px;height:13px;display:inline-block}
|
|
||||||
@media(max-width:760px){.pm-grid{grid-template-columns:1fr}.pm-railR{border-left:0;border-top:1px solid var(--line)}.pm-canvas{border:0;border-top:1px solid var(--line)}}
|
|
||||||
`}</style>
|
`}</style>
|
||||||
|
|
||||||
<div className="pm-hd">
|
<div className="overflow-hidden rounded-xl border bg-card text-card-foreground">
|
||||||
|
<div className="flex items-center justify-between border-b px-5 py-4">
|
||||||
<div>
|
<div>
|
||||||
<div className="pm-ttl">Team memory</div>
|
<h2 className="text-base font-medium">Team memory</h2>
|
||||||
<div className="pm-sub">What PodMan learned · {podId}</div>
|
<p className="mt-0.5 text-xs text-muted-foreground">What PodMan learned · {podId}</p>
|
||||||
</div>
|
</div>
|
||||||
<button className="pm-x" onClick={onClose}>
|
<Button variant="outline" size="sm" onClick={onClose}>
|
||||||
← Pods
|
← Pods
|
||||||
</button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="pm-bar">
|
<div className="flex flex-wrap gap-2 border-b px-4 py-3">
|
||||||
<button
|
<Button variant={toggleVariant('risk')} size="sm" onClick={() => pick('risk')}>
|
||||||
className={`pm-btn ${mode === 'risk' && !selected ? 'on' : ''}`}
|
|
||||||
onClick={() => pick('risk')}
|
|
||||||
>
|
|
||||||
Risk path
|
Risk path
|
||||||
</button>
|
</Button>
|
||||||
<button
|
<Button variant={toggleVariant('learn')} size="sm" onClick={() => pick('learn')}>
|
||||||
className={`pm-btn ${mode === 'learn' && !selected ? 'on' : ''}`}
|
|
||||||
onClick={() => pick('learn')}
|
|
||||||
>
|
|
||||||
Learning edges
|
Learning edges
|
||||||
</button>
|
</Button>
|
||||||
<button
|
<Button variant={toggleVariant('all')} size="sm" onClick={() => pick('all')}>
|
||||||
className={`pm-btn ${mode === 'all' && !selected ? 'on' : ''}`}
|
|
||||||
onClick={() => pick('all')}
|
|
||||||
>
|
|
||||||
Whole graph
|
Whole graph
|
||||||
</button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{error && (
|
{error && <p className="px-4 py-4 text-sm text-destructive">Graph error: {error}</p>}
|
||||||
<p style={{ padding: '16px', color: '#ff7d76', fontSize: 13 }}>Graph error: {error}</p>
|
|
||||||
)}
|
|
||||||
{!graph && !error && (
|
{!graph && !error && (
|
||||||
<p style={{ padding: '16px', color: '#8d897e', fontSize: 13 }}>Loading graph…</p>
|
<p className="px-4 py-4 text-sm text-muted-foreground">Loading graph…</p>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{graph && (
|
{graph && (
|
||||||
<>
|
<>
|
||||||
<div className="pm-grid">
|
<div className="grid lg:grid-cols-[190px_1fr_250px]">
|
||||||
<div className="pm-col">
|
<div className="space-y-3 p-4">
|
||||||
<div className="pm-st">Workflow metrics</div>
|
<p className="text-xs font-medium uppercase tracking-wide text-muted-foreground">
|
||||||
|
Workflow metrics
|
||||||
|
</p>
|
||||||
{graph.metrics.map((m) => (
|
{graph.metrics.map((m) => (
|
||||||
<div className="pm-kpi" key={m.label}>
|
<div key={m.label} className="rounded-lg border bg-card px-3 py-2.5">
|
||||||
<div className="pm-num">{m.value}</div>
|
<p className="text-2xl font-medium tabular-nums">{m.value}</p>
|
||||||
<div className="pm-klab">{m.label}</div>
|
<p className="mt-1 text-xs font-medium uppercase text-muted-foreground">
|
||||||
<div className="pm-kdet">{m.detail}</div>
|
{m.label}
|
||||||
|
</p>
|
||||||
|
<p className="mt-1 text-xs leading-snug text-muted-foreground">{m.detail}</p>
|
||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="pm-canvas">
|
<div className="min-h-[472px] border-y bg-card lg:border-x lg:border-y-0">
|
||||||
<svg viewBox="0 0 720 472" role="img" aria-label="PodMan team-memory graph">
|
<svg
|
||||||
|
viewBox="0 0 720 472"
|
||||||
|
role="img"
|
||||||
|
aria-label="PodMan team-memory graph"
|
||||||
|
className="block h-auto w-full"
|
||||||
|
>
|
||||||
{graph.edges.map((e) => {
|
{graph.edges.map((e) => {
|
||||||
const a = nodeById.get(e.source);
|
const a = nodeById.get(e.source);
|
||||||
const b = nodeById.get(e.target);
|
const b = nodeById.get(e.target);
|
||||||
@@ -258,71 +245,73 @@ export function GraphView({ podId, onClose }: { podId: string; onClose: () => vo
|
|||||||
>
|
>
|
||||||
<NodeShape node={n} />
|
<NodeShape node={n} />
|
||||||
<text className="pm-lbl" x={n.x} y={n.y + 33} textAnchor="middle">
|
<text className="pm-lbl" x={n.x} y={n.y + 33} textAnchor="middle">
|
||||||
{n.label.toUpperCase()}
|
{n.label}
|
||||||
</text>
|
</text>
|
||||||
</g>
|
</g>
|
||||||
))}
|
))}
|
||||||
</svg>
|
</svg>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="pm-col pm-railR">
|
<div className="border-t bg-muted p-4 lg:border-l lg:border-t-0">
|
||||||
{sel ? (
|
{sel ? (
|
||||||
<>
|
<>
|
||||||
<div className="pm-dkind">{sel.kind}</div>
|
<p className="text-xs uppercase tracking-wide text-muted-foreground">
|
||||||
<div className="pm-dname">{sel.label}</div>
|
{sel.kind}
|
||||||
<div className="pm-drow">
|
</p>
|
||||||
|
<h3 className="mb-3 mt-1 text-lg font-medium">{sel.label}</h3>
|
||||||
|
<div className="flex items-center justify-between border-b py-1.5 text-sm text-muted-foreground">
|
||||||
<span>Status</span>
|
<span>Status</span>
|
||||||
<b
|
<Badge variant="outline" style={{ color: statusColor(sel.status) }}>
|
||||||
style={{
|
|
||||||
color:
|
|
||||||
sel.status === 'risk'
|
|
||||||
? '#E2403A'
|
|
||||||
: sel.status === 'learned'
|
|
||||||
? '#b7a4ff'
|
|
||||||
: '#ECE7DA',
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{sel.status}
|
{sel.status}
|
||||||
</b>
|
</Badge>
|
||||||
</div>
|
</div>
|
||||||
<div className="pm-drow">
|
<div className="flex items-center justify-between border-b py-1.5 text-sm text-muted-foreground">
|
||||||
<span>Relationships</span>
|
<span>Relationships</span>
|
||||||
<b>{relCount}</b>
|
<span className="font-medium text-foreground">{relCount}</span>
|
||||||
</div>
|
</div>
|
||||||
<div className="pm-note">{sel.summary}</div>
|
<p className="mt-2.5 text-sm leading-relaxed text-muted-foreground">
|
||||||
|
{sel.summary}
|
||||||
|
</p>
|
||||||
</>
|
</>
|
||||||
) : (
|
) : (
|
||||||
<>
|
<>
|
||||||
<div className="pm-dkind">Continual learning</div>
|
<p className="text-xs uppercase tracking-wide text-muted-foreground">
|
||||||
<div className="pm-dname">It learned</div>
|
Continual learning
|
||||||
<div className="pm-note">
|
</p>
|
||||||
Violet <b style={{ color: '#b7a4ff' }}>learned_from</b> edges are ownership
|
<h3 className="mb-3 mt-1 text-lg font-medium">It learned</h3>
|
||||||
PodMan retained from accepted interventions — the graph gets sharper every
|
<p className="text-sm leading-relaxed text-muted-foreground">
|
||||||
session. Click any node to trace its relationships.
|
The violet{' '}
|
||||||
</div>
|
<span className="font-medium" style={{ color: VIOLET }}>
|
||||||
|
learned_from
|
||||||
|
</span>{' '}
|
||||||
|
edges are ownership PodMan retained from accepted interventions — the graph
|
||||||
|
gets sharper every session. Click any node to trace its relationships.
|
||||||
|
</p>
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="pm-legend">
|
<div className="flex flex-wrap gap-3 border-t px-4 py-2.5 text-xs text-muted-foreground">
|
||||||
{LEGEND.map((l) => (
|
{LEGEND.map((l) => (
|
||||||
<span className="pm-lg" key={l.label}>
|
<span key={l.label} className="flex items-center gap-1.5">
|
||||||
<span className="pm-sw" style={l.swatch} />
|
<span className="inline-block size-3" style={l.swatch} />
|
||||||
{l.label}
|
{l.label}
|
||||||
</span>
|
</span>
|
||||||
))}
|
))}
|
||||||
<span className="pm-lg">
|
<span className="flex items-center gap-1.5">
|
||||||
<span className="pm-sw" style={{ background: '#E2403A', height: 3 }} />
|
<span className="inline-block h-[3px] w-3" style={{ background: RED }} />
|
||||||
collides
|
collides
|
||||||
</span>
|
</span>
|
||||||
<span className="pm-lg">
|
<span className="flex items-center gap-1.5">
|
||||||
<span className="pm-sw" style={{ background: '#8b6cff', height: 3 }} />
|
<span className="inline-block h-[3px] w-3" style={{ background: VIOLET }} />
|
||||||
learned_from
|
learned_from
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user