mirror of
https://github.com/mvschwarz/openrig.git
synced 2026-11-09 17:32:41 -10:00
polish-3 hybrid topology architecture checkpoint
This commit is contained in:
1 parent
cce70504ff
commit
2ea5748c31
15 files changed
+1127
-293
No files matched your search
Generated
+35
-1
@@ -2296,6 +2296,26 @@
|
||||
"react-dom": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@tanstack/react-table": {
|
||||
"version": "8.21.3",
|
||||
"resolved": "https://registry.npmjs.org/@tanstack/react-table/-/react-table-8.21.3.tgz",
|
||||
"integrity": "sha512-5nNMTSETP4ykGegmVkhjcS8tTLW6Vl4axfEGQN3v0zdHYbK4UfoqfPChclTrJ4EoK9QynqAu9oUf8VEmrpZ5Ww==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@tanstack/table-core": "8.21.3"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=12"
|
||||
},
|
||||
"funding": {
|
||||
"type": "github",
|
||||
"url": "https://github.com/sponsors/tannerlinsley"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"react": ">=16.8",
|
||||
"react-dom": ">=16.8"
|
||||
}
|
||||
},
|
||||
"node_modules/@tanstack/router-core": {
|
||||
"version": "1.168.3",
|
||||
"resolved": "https://registry.npmjs.org/@tanstack/router-core/-/router-core-1.168.3.tgz",
|
||||
@@ -2328,6 +2348,19 @@
|
||||
"url": "https://github.com/sponsors/tannerlinsley"
|
||||
}
|
||||
},
|
||||
"node_modules/@tanstack/table-core": {
|
||||
"version": "8.21.3",
|
||||
"resolved": "https://registry.npmjs.org/@tanstack/table-core/-/table-core-8.21.3.tgz",
|
||||
"integrity": "sha512-ldZXEhOBb8Is7xLs01fR3YEc3DERiz5silj8tnGkFZytt1abEvl/GhUmCE0PMLaMPTa3Jk4HbKmRlHmu+gCftg==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=12"
|
||||
},
|
||||
"funding": {
|
||||
"type": "github",
|
||||
"url": "https://github.com/sponsors/tannerlinsley"
|
||||
}
|
||||
},
|
||||
"node_modules/@testing-library/dom": {
|
||||
"version": "10.4.1",
|
||||
"resolved": "https://registry.npmjs.org/@testing-library/dom/-/dom-10.4.1.tgz",
|
||||
@@ -7412,7 +7445,7 @@
|
||||
"vitest": "^3.0.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": "^20 || ^22 || ^24"
|
||||
"node": ">=20"
|
||||
}
|
||||
},
|
||||
"packages/daemon": {
|
||||
@@ -7454,6 +7487,7 @@
|
||||
"@radix-ui/react-tooltip": "^1.2.8",
|
||||
"@tanstack/react-query": "^5.95.2",
|
||||
"@tanstack/react-router": "^1.168.3",
|
||||
"@tanstack/react-table": "^8.21.3",
|
||||
"@xyflow/react": "^12.10.1",
|
||||
"autoprefixer": "^10.4.27",
|
||||
"class-variance-authority": "^0.7.1",
|
||||
|
||||
@@ -1,22 +1,17 @@
|
||||
// V1 polish slice Phase 5.2 — multi-rig single-canvas /topology graph.
|
||||
// V1 polish slice: multi-rig hybrid /topology graph.
|
||||
//
|
||||
// Bundles 8 dispatch items in one canvas component:
|
||||
// P5.2-1 HostMultiRigGraph composition (this file)
|
||||
// P5.2-2 useQueries for N parallel rig graph fetches (lazy on expand)
|
||||
// P5.2-3 cross-rig node-ID prefixing via prefixRigData (multi-rig-layout.ts)
|
||||
// P5.2-4 rigGroup ReactFlow node type (RigGroupNode.tsx)
|
||||
// P5.2-5 default-collapsed rig groups + click-body-to-toggle affordance
|
||||
// P5.2-6 layout option (a) per-rig + outer offset (multi-rig-layout.ts)
|
||||
// P5.2-7 click handler reads rigId from node.data.rigId (NOT closure)
|
||||
// P5.2-8 per-rig features (cmux focus / agent click navigate / pod click
|
||||
// navigate) thread through node.data.rigId
|
||||
// Uses shared dagre helpers to lay out each expanded rig as a soft frame
|
||||
// containing pod sub-frames and compact agent leaves, then lays out rig
|
||||
// frames on the host canvas. V1 contracts remain load-bearing: lazy
|
||||
// per-rig graph fetches, cross-rig node ID prefixing, collapse persistence,
|
||||
// URL auto-expand, and graph/tree/table navigate parity.
|
||||
//
|
||||
// Default state: ALL rigs collapsed; rig cards on canvas with status pip
|
||||
// + counts. Auto-expand rule (parity with Phase 5.1 TopologyTreeView):
|
||||
// when route is /topology/rig/$rigId or /topology/seat/$rigId/* or
|
||||
// /topology/pod/$rigId/*, the matching rig is auto-expanded on mount /
|
||||
// route change. Click rig card body → toggle collapse. Click "→" Link
|
||||
// on rig card name → navigate to /topology/rig/$rigId (drill-in).
|
||||
// route change. Click rig card body -> toggle collapse. Click arrow Link
|
||||
// on rig card name -> navigate to /topology/rig/$rigId (drill-in).
|
||||
//
|
||||
// Performance: per-rig graph data is lazy-fetched (useQueries enabled
|
||||
// only when rig is expanded). At default state with N rigs collapsed,
|
||||
@@ -37,20 +32,15 @@ import {
|
||||
import "@xyflow/react/dist/style.css";
|
||||
import { useQueries } from "@tanstack/react-query";
|
||||
import { usePsEntries } from "../../hooks/usePsEntries.js";
|
||||
import { applyTreeLayout } from "../../lib/graph-layout.js";
|
||||
import { useShellViewport } from "../../hooks/useShellViewport.js";
|
||||
import { RigNode } from "../RigNode.js";
|
||||
import { RigGroupNode, type RigGroupNodeData } from "./RigGroupNode.js";
|
||||
import { HybridAgentNode, HybridPodGroupNode } from "./HybridTopologyNodes.js";
|
||||
import { useTopologyOverlay } from "./topology-overlay-context.js";
|
||||
import {
|
||||
prefixRigData,
|
||||
packRigGroups,
|
||||
computeBounds,
|
||||
COLLAPSED_RIG_WIDTH,
|
||||
COLLAPSED_RIG_HEIGHT,
|
||||
RIG_HEADER_HEIGHT,
|
||||
RIG_PADDING,
|
||||
} from "../../lib/multi-rig-layout.js";
|
||||
HYBRID_COLLAPSED_RIG_HEIGHT,
|
||||
HYBRID_COLLAPSED_RIG_WIDTH,
|
||||
layoutHybridOuterRigs,
|
||||
layoutHybridRig,
|
||||
} from "../../lib/hybrid-layout.js";
|
||||
|
||||
interface GraphData {
|
||||
nodes: unknown[];
|
||||
@@ -63,39 +53,17 @@ async function fetchGraph(rigId: string): Promise<GraphData> {
|
||||
return res.json();
|
||||
}
|
||||
|
||||
// Pod-group inline renderer for the multi-rig canvas. Mirrors RigGraph's
|
||||
// PodGroupNode shape (data.podDisplayName / podNamespace / etc.) but
|
||||
// stays local so this canvas's nodeTypes registry is self-contained.
|
||||
function MultiRigPodGroupNode({
|
||||
data,
|
||||
}: {
|
||||
data: { podDisplayName?: string | null; podNamespace?: string | null; podId?: string | null };
|
||||
}) {
|
||||
const label = data.podDisplayName ?? data.podNamespace ?? data.podId ?? "pod";
|
||||
return (
|
||||
<div
|
||||
data-testid="multi-rig-pod-group-node"
|
||||
className="w-full h-full relative pointer-events-auto"
|
||||
>
|
||||
<div className="absolute left-3 top-2 inline-flex items-center font-mono text-[11px] font-bold leading-none tracking-[0.08em] text-stone-700">
|
||||
{`${label} pod`}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const nodeTypes: NodeTypes = {
|
||||
rigGroup: RigGroupNode as unknown as NodeTypes[string],
|
||||
podGroup: MultiRigPodGroupNode,
|
||||
rigNode: RigNode,
|
||||
podGroup: HybridPodGroupNode as unknown as NodeTypes[string],
|
||||
rigNode: HybridAgentNode as unknown as NodeTypes[string],
|
||||
};
|
||||
|
||||
export function HostMultiRigGraph() {
|
||||
const navigate = useNavigate();
|
||||
const { data: psEntries } = usePsEntries();
|
||||
const { innerWidth } = useShellViewport();
|
||||
|
||||
// V1 polish slice Phase 5.2 bounce-fix — rig-expanded state lifted to
|
||||
// V1 polish slice Phase 5.2 bounce-fix: rig-expanded state lifted to
|
||||
// TopologyOverlayProvider scope. Direct-URL entry to /topology/rig/$id
|
||||
// (which mounts RigScopePage, NOT HostMultiRigGraph because topology
|
||||
// routes are SIBLING) still updates the context's expandedRigs map
|
||||
@@ -106,7 +74,7 @@ export function HostMultiRigGraph() {
|
||||
|
||||
// P5.2-2 + P5.2-3: useQueries for per-rig graph data; enabled only
|
||||
// when the rig is expanded. Stable hook-call count (single useQueries
|
||||
// call) regardless of how psEntries grows from undefined → [N], so
|
||||
// call) regardless of how psEntries grows from undefined to [N], so
|
||||
// no rules-of-hooks regression (P0-1 pattern preserved).
|
||||
const rigList = psEntries ?? [];
|
||||
const graphQueries = useQueries({
|
||||
@@ -118,13 +86,11 @@ export function HostMultiRigGraph() {
|
||||
})),
|
||||
});
|
||||
|
||||
// Build per-rig prefixed sub-graphs (only for expanded rigs); compute
|
||||
// each rig's bounding box; pack rig groups in a grid via packRigGroups.
|
||||
// Build per-rig nested subgraphs and lay out rig frames on the host canvas.
|
||||
const { mergedNodes, mergedEdges } = useMemo(() => {
|
||||
type RawN = { id: string; type?: string; data?: Record<string, unknown>; position?: { x: number; y: number }; parentId?: string; initialWidth?: number; initialHeight?: number };
|
||||
type RawE = { id: string; source: string; target: string };
|
||||
type RawN = Node & { data?: Record<string, unknown>; initialWidth?: number; initialHeight?: number };
|
||||
type RawE = Edge & { source: string; target: string; data?: Record<string, unknown>; label?: unknown };
|
||||
|
||||
// Step 1 — for each rig, compute its prefixed nodes/edges + bounds.
|
||||
const perRig: Array<{
|
||||
rigId: string;
|
||||
rigName: string;
|
||||
@@ -146,46 +112,24 @@ export function HostMultiRigGraph() {
|
||||
|
||||
let childNodes: Node[] = [];
|
||||
let childEdges: Edge[] = [];
|
||||
let width = COLLAPSED_RIG_WIDTH;
|
||||
let height = COLLAPSED_RIG_HEIGHT;
|
||||
let width = HYBRID_COLLAPSED_RIG_WIDTH;
|
||||
let height = HYBRID_COLLAPSED_RIG_HEIGHT;
|
||||
let podCount: number | undefined;
|
||||
|
||||
if (isExpanded && queryResult?.data) {
|
||||
// Apply per-rig layout (option (a) per Phase 5.2 ACK §2).
|
||||
const rawNodes = (queryResult.data.nodes ?? []) as RawN[];
|
||||
const rawEdges = (queryResult.data.edges ?? []) as RawE[];
|
||||
// Cast to LayoutNode shape for applyTreeLayout (it's permissive).
|
||||
const laidOut = applyTreeLayout(
|
||||
rawNodes as Parameters<typeof applyTreeLayout>[0],
|
||||
rawEdges as Parameters<typeof applyTreeLayout>[1],
|
||||
) as RawN[];
|
||||
// Cross-rig prefix step.
|
||||
const prefixed = prefixRigData(rig.rigId, laidOut, rawEdges);
|
||||
// Bounds for the rig group container.
|
||||
const bounds = computeBounds(
|
||||
prefixed.nodes.map((n) => ({
|
||||
position: n.position ?? { x: 0, y: 0 },
|
||||
initialWidth: n.initialWidth,
|
||||
initialHeight: n.initialHeight,
|
||||
})),
|
||||
);
|
||||
width = bounds.width;
|
||||
height = bounds.height;
|
||||
// Re-position relative to rig-internal origin (top-left = padding,
|
||||
// header eaten by RIG_HEADER_HEIGHT).
|
||||
childNodes = prefixed.nodes.map((n) => ({
|
||||
...(n as Node),
|
||||
position: {
|
||||
x: (n.position?.x ?? 0) - bounds.minX + RIG_PADDING,
|
||||
y: (n.position?.y ?? 0) - bounds.minY + RIG_HEADER_HEIGHT + RIG_PADDING,
|
||||
},
|
||||
parentId: `rig-${rig.rigId}`,
|
||||
extent: "parent" as const,
|
||||
}));
|
||||
childEdges = prefixed.edges as Edge[];
|
||||
podCount = prefixed.nodes.filter(
|
||||
(n) => n.type === "podGroup" || n.type === "group",
|
||||
).length;
|
||||
const rawNodes = (queryResult?.data?.nodes ?? []) as RawN[];
|
||||
const rawEdges = (queryResult?.data?.edges ?? []) as RawE[];
|
||||
const layout = layoutHybridRig({
|
||||
rigId: rig.rigId,
|
||||
nodes: rawNodes,
|
||||
edges: rawEdges,
|
||||
collapsed: !isExpanded,
|
||||
});
|
||||
width = layout.width;
|
||||
height = layout.height;
|
||||
if (isExpanded) {
|
||||
childNodes = layout.nodes;
|
||||
childEdges = layout.edges;
|
||||
podCount = layout.podCount;
|
||||
}
|
||||
|
||||
perRig.push({
|
||||
@@ -203,15 +147,10 @@ export function HostMultiRigGraph() {
|
||||
});
|
||||
}
|
||||
|
||||
// Step 2 — pack rig groups in the canvas grid.
|
||||
const packed = packRigGroups(
|
||||
const packed = layoutHybridOuterRigs(
|
||||
perRig.map((p) => ({ rigId: p.rigId, width: p.width, height: p.height })),
|
||||
Math.max(innerWidth, 1024) - 64, // canvas padding
|
||||
);
|
||||
|
||||
// Step 3 — emit rigGroup container nodes + child nodes (offset by
|
||||
// pack position, since children carry parentId so react-flow handles
|
||||
// the visual offset for us).
|
||||
const nodes: Node[] = [];
|
||||
const edges: Edge[] = [];
|
||||
for (let i = 0; i < perRig.length; i++) {
|
||||
@@ -230,12 +169,11 @@ export function HostMultiRigGraph() {
|
||||
nodes.push({
|
||||
id: `rig-${p.rigId}`,
|
||||
type: "rigGroup",
|
||||
position: { x: pack.offsetX, y: pack.offsetY },
|
||||
position: pack.position,
|
||||
data: data as unknown as Record<string, unknown>,
|
||||
style: { width: pack.width, height: pack.height },
|
||||
// Rig group must come BEFORE its children in the nodes array so
|
||||
// react-flow's parent/child positioning resolves correctly.
|
||||
// Children only included when expanded.
|
||||
draggable: false,
|
||||
zIndex: 0,
|
||||
});
|
||||
if (p.isExpanded) {
|
||||
nodes.push(...p.childNodes);
|
||||
@@ -245,12 +183,12 @@ export function HostMultiRigGraph() {
|
||||
|
||||
return { mergedNodes: nodes, mergedEdges: edges };
|
||||
// toggleRig is stable per-render but useMemo doesn't know; safe to
|
||||
// include rigList + expanded + graphQueries + innerWidth as deps.
|
||||
// include rigList + expanded + graphQueries as deps.
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [rigList, expanded, graphQueries, innerWidth]);
|
||||
}, [rigList, expanded, graphQueries]);
|
||||
|
||||
// P5.2-7 click handlers — agent → seat URL; pod group → pod URL;
|
||||
// rig group → toggle (handled inside RigGroupNode onClick; this
|
||||
// P5.2-7 click handlers: agent -> seat URL; pod group -> pod URL;
|
||||
// rig group -> toggle (handled inside RigGroupNode onClick; this
|
||||
// handler is a no-op for rigGroup type to avoid double-fire).
|
||||
const onNodeClick: NodeMouseHandler = (_evt, node) => {
|
||||
const data = node.data as { rigId?: string; logicalId?: string; podId?: string | null; podNamespace?: string | null } | undefined;
|
||||
@@ -295,6 +233,7 @@ export function HostMultiRigGraph() {
|
||||
edges={mergedEdges}
|
||||
nodeTypes={nodeTypes}
|
||||
onNodeClick={onNodeClick}
|
||||
nodesDraggable={false}
|
||||
fitView
|
||||
fitViewOptions={{ padding: 0.15, includeHiddenNodes: false }}
|
||||
proOptions={{ hideAttribution: true }}
|
||||
|
||||
@@ -0,0 +1,140 @@
|
||||
import { Handle, Position } from "@xyflow/react";
|
||||
import { displayAgentName, inferPodName } from "../../lib/display-name.js";
|
||||
import {
|
||||
getActivityAnimationClass,
|
||||
getActivityBgClass,
|
||||
getActivityLabel,
|
||||
getActivityState,
|
||||
isActivityStale,
|
||||
} from "../../lib/activity-visuals.js";
|
||||
import type { AgentActivitySummary } from "../../hooks/useNodeInventory.js";
|
||||
import { cn } from "../../lib/utils.js";
|
||||
|
||||
interface HybridPodGroupNodeData {
|
||||
podDisplayName?: string | null;
|
||||
podNamespace?: string | null;
|
||||
podId?: string | null;
|
||||
logicalId?: string | null;
|
||||
agentCount?: number;
|
||||
}
|
||||
|
||||
interface HybridAgentNodeData {
|
||||
logicalId: string;
|
||||
role?: string | null;
|
||||
runtime?: string | null;
|
||||
model?: string | null;
|
||||
status?: string | null;
|
||||
nodeKind?: "agent" | "infrastructure";
|
||||
startupStatus?: "pending" | "ready" | "attention_required" | "failed" | null;
|
||||
canonicalSessionName?: string | null;
|
||||
resolvedSpecName?: string | null;
|
||||
profile?: string | null;
|
||||
contextUsedPercentage?: number | null;
|
||||
contextFresh?: boolean;
|
||||
contextAvailability?: string | null;
|
||||
agentActivity?: AgentActivitySummary | null;
|
||||
}
|
||||
|
||||
function isCoreRole(role: string | null | undefined): boolean {
|
||||
return role === "architect" || role === "lead" || role === "orchestrator";
|
||||
}
|
||||
|
||||
function contextClass(percent: number | null | undefined, fresh: boolean | undefined): string {
|
||||
if (typeof percent !== "number") return "text-stone-300";
|
||||
const tone = percent >= 80
|
||||
? "text-red-600"
|
||||
: percent >= 60
|
||||
? "text-amber-600"
|
||||
: "text-green-700";
|
||||
return fresh === false ? `${tone} opacity-50` : tone;
|
||||
}
|
||||
|
||||
export function HybridPodGroupNode({ data }: { data: HybridPodGroupNodeData }) {
|
||||
const label = data.podDisplayName
|
||||
?? data.podNamespace
|
||||
?? inferPodName(data.logicalId ?? null)
|
||||
?? data.podId
|
||||
?? "pod";
|
||||
return (
|
||||
<div
|
||||
data-testid="hybrid-pod-group-node"
|
||||
className="relative h-full w-full border border-dashed border-stone-400/55 bg-stone-50/35"
|
||||
>
|
||||
<div className="absolute left-2 top-2 flex items-center gap-2 font-mono text-[9px] lowercase tracking-[0.02em] text-stone-600">
|
||||
<span>{label}</span>
|
||||
{typeof data.agentCount === "number" ? (
|
||||
<span className="text-stone-400">{data.agentCount}</span>
|
||||
) : null}
|
||||
</div>
|
||||
<Handle type="target" position={Position.Left} className="opacity-0 pointer-events-none" />
|
||||
<Handle type="source" position={Position.Right} className="opacity-0 pointer-events-none" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function HybridAgentNode({ data }: { data: HybridAgentNodeData }) {
|
||||
const core = isCoreRole(data.role);
|
||||
const isInfra = data.nodeKind === "infrastructure";
|
||||
const activityState = getActivityState(data.agentActivity);
|
||||
const activityLabel = getActivityLabel(activityState);
|
||||
const activityBgClass = getActivityBgClass(activityState);
|
||||
const activityAnimClass = getActivityAnimationClass(activityState);
|
||||
const activityStale = isActivityStale(data.agentActivity);
|
||||
const runtimeModel = [data.runtime, data.model].filter(Boolean).join(" / ");
|
||||
const contextKnown = data.contextAvailability === "known" && typeof data.contextUsedPercentage === "number";
|
||||
|
||||
return (
|
||||
<div
|
||||
data-testid="hybrid-agent-node"
|
||||
title={[
|
||||
data.canonicalSessionName,
|
||||
`activity: ${activityLabel}${activityStale ? " (stale)" : ""}`,
|
||||
runtimeModel || null,
|
||||
].filter(Boolean).join("\n")}
|
||||
className={cn(
|
||||
"group relative h-full w-full select-none border bg-white hard-shadow",
|
||||
data.startupStatus === "failed"
|
||||
? "border-red-700"
|
||||
: data.startupStatus === "attention_required"
|
||||
? "border-amber-700"
|
||||
: "border-stone-900",
|
||||
)}
|
||||
>
|
||||
<Handle type="target" position={Position.Left} className="opacity-0" />
|
||||
<div
|
||||
className={cn(
|
||||
"flex items-center justify-between gap-1 px-2 py-1 font-mono text-[8px]",
|
||||
isInfra
|
||||
? "bg-stone-500 text-white border-b border-stone-900"
|
||||
: core
|
||||
? "bg-stone-900 text-white"
|
||||
: "border-b border-stone-900 bg-stone-200 text-stone-900",
|
||||
)}
|
||||
>
|
||||
<span className="truncate font-bold">{displayAgentName(data.logicalId)}</span>
|
||||
<span
|
||||
className={cn(
|
||||
"inline-flex h-2 w-2 shrink-0 rounded-full border border-white/60",
|
||||
activityBgClass,
|
||||
activityAnimClass,
|
||||
)}
|
||||
data-testid={`hybrid-activity-dot-${data.logicalId}`}
|
||||
data-activity-state={activityState}
|
||||
aria-label={`activity: ${activityLabel}`}
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-1 px-2 py-1.5">
|
||||
<div className="truncate font-mono text-[7px] text-stone-500">
|
||||
{data.canonicalSessionName ?? data.logicalId}
|
||||
</div>
|
||||
<div className="truncate font-mono text-[7px] uppercase tracking-[0.08em] text-stone-400">
|
||||
{runtimeModel || data.resolvedSpecName || data.profile || "runtime unknown"}
|
||||
</div>
|
||||
<div className={cn("font-mono text-[12px] font-bold leading-none", contextClass(data.contextUsedPercentage, data.contextFresh))}>
|
||||
{contextKnown ? `${data.contextUsedPercentage}%` : "--"}
|
||||
</div>
|
||||
</div>
|
||||
<Handle type="source" position={Position.Right} className="opacity-0" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,21 +1,17 @@
|
||||
// V1 polish slice Phase 5.2 P5.2-4 — RigGroupNode renderer.
|
||||
// V1 polish slice Phase 5.2 P5.2-4: RigGroupNode renderer.
|
||||
//
|
||||
// Outermost container in the multi-rig single-canvas /topology graph.
|
||||
// Each rig appears as a vellum tactical card on the canvas; click body
|
||||
// to toggle collapse; click "→" affordance on the rig name to drill
|
||||
// into /topology/rig/$rigId. When collapsed, only the card body shows
|
||||
// (name + status pip + counts). When expanded, the card acts as a
|
||||
// bounding-box container; the rig's pod groups + agent nodes render
|
||||
// inside via react-flow parent/child relationship (extent='parent').
|
||||
// Each rig appears as a soft vellum frame on the canvas; click body to
|
||||
// toggle collapse; use the arrow affordance on the rig tab to drill into
|
||||
// /topology/rig/$rigId. When collapsed, the summary card shows counts.
|
||||
// When expanded, the card acts as a bounding-box container; pod groups +
|
||||
// agent nodes render inside via react-flow parent/child relationship.
|
||||
//
|
||||
// Visual: 1px outline-variant border (universal-shell.md L43-L48 doctrine)
|
||||
// + RegistrationMarks at 4 corners + paper-press hard-shadow + font-mono
|
||||
// uppercase rig name + counts subline. Collapsed elevation reads
|
||||
// coherent with paper-grid backdrop (radial-gradient on body globals.css
|
||||
// L73-L74).
|
||||
// + RegistrationMarks at 4 corners + font-mono uppercase rig tab.
|
||||
|
||||
import { Link } from "@tanstack/react-router";
|
||||
import { ChevronRight } from "lucide-react";
|
||||
import { ArrowUpRight, ChevronRight } from "lucide-react";
|
||||
import { Handle, Position } from "@xyflow/react";
|
||||
import { RegistrationMarks } from "../ui/registration-marks.js";
|
||||
import { StatusPip } from "../ui/status-pip.js";
|
||||
@@ -32,7 +28,7 @@ export interface RigGroupNodeData {
|
||||
nodeCount: number;
|
||||
runningCount: number;
|
||||
podCount?: number; // optional; populated post-graph-fetch when expanded
|
||||
/** Click handler — owner threads through to toggle expand state. */
|
||||
/** Click handler: owner threads through to toggle expand state. */
|
||||
onToggle: (rigId: string) => void;
|
||||
}
|
||||
|
||||
@@ -55,20 +51,14 @@ export function RigGroupNode({ data }: { data: RigGroupNodeData }) {
|
||||
onToggle(rigId);
|
||||
}}
|
||||
className={cn(
|
||||
"w-full h-full relative flex flex-col cursor-pointer select-none",
|
||||
"border border-outline-variant bg-white/40 backdrop-blur-[8px]",
|
||||
"hard-shadow",
|
||||
// Expanded state: the card acts as a frame; children render inside
|
||||
// via react-flow parent/child positioning. The body has a softer
|
||||
// fill so child nodes (pod groups + agents) read clearly inside.
|
||||
"w-full h-full relative flex flex-col cursor-pointer select-none overflow-visible",
|
||||
collapsed
|
||||
? "hover:bg-white/60"
|
||||
: "bg-white/20",
|
||||
? "border border-outline-variant bg-white/40 backdrop-blur-[8px] hard-shadow hover:bg-white/60"
|
||||
: "border border-outline-variant/70 bg-white/[0.14] backdrop-blur-[2px] shadow-[0_0_0_1px_rgba(84,96,115,0.06)]",
|
||||
)}
|
||||
>
|
||||
<RegistrationMarks testIdPrefix={`rig-group-${rigId}`} />
|
||||
{/* Header: rig name + drill-in Link + collapse-state chevron */}
|
||||
<header className="flex items-center justify-between gap-2 px-3 pt-3 pb-2 border-b border-outline-variant">
|
||||
<header className="absolute -top-8 left-4 z-10 flex items-center gap-2 border border-outline-variant/70 bg-background px-3 py-1.5 font-mono text-[10px] uppercase tracking-[0.10em] text-stone-900 shadow-[2px_2px_0_rgba(46,52,46,0.10)]">
|
||||
<div className="flex items-center gap-2 min-w-0">
|
||||
<ChevronRight
|
||||
className={cn(
|
||||
@@ -84,59 +74,48 @@ export function RigGroupNode({ data }: { data: RigGroupNodeData }) {
|
||||
{rigName}
|
||||
</span>
|
||||
</div>
|
||||
<StatusPip
|
||||
status={statusToPip(status)}
|
||||
label={status}
|
||||
variant="pill"
|
||||
testId={`rig-group-status-${rigId}`}
|
||||
/>
|
||||
<Link
|
||||
to="/topology/rig/$rigId"
|
||||
params={{ rigId }}
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
data-testid={`rig-group-drill-${rigId}`}
|
||||
className="shrink-0 p-0.5 rounded-sm text-on-surface-variant hover:text-stone-900 hover:bg-stone-200/60"
|
||||
className="shrink-0 p-0.5 text-on-surface-variant hover:text-stone-900 hover:bg-stone-200/60"
|
||||
aria-label={`Open ${rigName} rig page`}
|
||||
>
|
||||
<span className="font-mono text-[10px] uppercase tracking-wide">→</span>
|
||||
<ArrowUpRight className="h-3 w-3" aria-hidden="true" />
|
||||
</Link>
|
||||
</header>
|
||||
{/* Body: counts + status pip when collapsed; bounding-box frame
|
||||
(no inner content; children render via parentNode) when expanded. */}
|
||||
{collapsed ? (
|
||||
<div className="px-3 py-3 flex items-center justify-between gap-2 flex-1">
|
||||
<div className="px-3 pt-8 pb-3 flex items-center justify-between gap-2 flex-1">
|
||||
<div className="flex flex-col gap-0.5 min-w-0">
|
||||
<div className="font-mono text-[9px] uppercase tracking-wider text-on-surface-variant">
|
||||
{podCount !== undefined ? `${podCount} pods · ` : ""}
|
||||
{podCount !== undefined ? `${podCount} pods / ` : ""}
|
||||
{nodeCount} agent{nodeCount === 1 ? "" : "s"}
|
||||
</div>
|
||||
<div className="font-mono text-[9px] text-on-surface-variant">
|
||||
{runningCount} active
|
||||
</div>
|
||||
</div>
|
||||
<StatusPip
|
||||
status={statusToPip(status)}
|
||||
label={status}
|
||||
variant="pill"
|
||||
testId={`rig-group-status-${rigId}`}
|
||||
/>
|
||||
</div>
|
||||
) : (
|
||||
// Expanded: subtle counts strip at the top of the frame; children
|
||||
// (pod groups + agents) render absolutely within the bounding box
|
||||
// via react-flow parent/child + extent='parent'.
|
||||
<div
|
||||
className="px-3 py-1 flex items-center gap-2 font-mono text-[9px] text-on-surface-variant border-b border-outline-variant/60"
|
||||
className="absolute right-3 top-3 flex items-center gap-2 font-mono text-[8px] uppercase tracking-[0.12em] text-stone-500"
|
||||
data-testid={`rig-group-expanded-counts-${rigId}`}
|
||||
>
|
||||
<span>{podCount !== undefined ? `${podCount} pods` : ""}</span>
|
||||
{podCount !== undefined ? <span>·</span> : null}
|
||||
{podCount !== undefined ? <span>/</span> : null}
|
||||
<span>{nodeCount} agents</span>
|
||||
<span>·</span>
|
||||
<span>/</span>
|
||||
<span>{runningCount} active</span>
|
||||
<StatusPip
|
||||
status={statusToPip(status)}
|
||||
variant="dot"
|
||||
className="ml-auto"
|
||||
testId={`rig-group-status-${rigId}`}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
{/* React-flow handles (invisible) — rig groups are containers, not
|
||||
{/* React-flow handles (invisible): rig groups are containers, not
|
||||
edge endpoints, but handles keep react-flow's NodeRenderer happy
|
||||
for nodes inside the rig group that may have edges crossing the
|
||||
group boundary in V1.5+. */}
|
||||
|
||||
@@ -1,5 +1,10 @@
|
||||
import { useEffect, useRef, useState, useCallback } from "react";
|
||||
import { useEffect, useState, useCallback } from "react";
|
||||
import { useQueryClient } from "@tanstack/react-query";
|
||||
import {
|
||||
subscribeTopologyEvents,
|
||||
subscribeTopologyEventStatus,
|
||||
type TopologyEvent,
|
||||
} from "../lib/topology-events.js";
|
||||
|
||||
export const MAX_ACTIVITY_EVENTS = 100;
|
||||
|
||||
@@ -23,94 +28,71 @@ export function useActivityFeed(): UseActivityFeedResult {
|
||||
const [events, setEvents] = useState<ActivityEvent[]>([]);
|
||||
const [connected, setConnected] = useState(false);
|
||||
const [feedOpen, setFeedOpen] = useState(false);
|
||||
const hasErroredRef = useRef(false);
|
||||
|
||||
const addEvent = useCallback((data: string) => {
|
||||
try {
|
||||
const parsed = JSON.parse(data) as Record<string, unknown>;
|
||||
const event: ActivityEvent = {
|
||||
seq: typeof parsed["seq"] === "number" ? parsed["seq"] : Date.now(),
|
||||
type: (parsed["type"] as string) ?? "unknown",
|
||||
payload: parsed,
|
||||
createdAt: (parsed["createdAt"] as string) ?? new Date().toISOString(),
|
||||
receivedAt: Date.now(),
|
||||
};
|
||||
setEvents((prev) => [event, ...prev].slice(0, MAX_ACTIVITY_EVENTS));
|
||||
const addEvent = useCallback((parsed: TopologyEvent) => {
|
||||
const event: ActivityEvent = {
|
||||
seq: typeof parsed["seq"] === "number" ? parsed["seq"] : Date.now(),
|
||||
type: (parsed["type"] as string) ?? "unknown",
|
||||
payload: parsed,
|
||||
createdAt: (parsed["createdAt"] as string) ?? new Date().toISOString(),
|
||||
receivedAt: Date.now(),
|
||||
};
|
||||
setEvents((prev) => [event, ...prev].slice(0, MAX_ACTIVITY_EVENTS));
|
||||
|
||||
// Invalidate package queries on package mutation events
|
||||
if (event.type === "package.installed" || event.type === "package.rolledback") {
|
||||
queryClient.invalidateQueries({ queryKey: ["packages"] });
|
||||
}
|
||||
// Invalidate rig summary on bootstrap completion (new rig may have been created)
|
||||
if (event.type === "bootstrap.completed" || event.type === "bootstrap.partial") {
|
||||
queryClient.invalidateQueries({ queryKey: ["rigs", "summary"] });
|
||||
}
|
||||
// Invalidate discovery queries on discovery events
|
||||
if (event.type === "session.discovered" || event.type === "session.vanished") {
|
||||
queryClient.invalidateQueries({ queryKey: ["discovery"] });
|
||||
}
|
||||
// node.claimed: invalidate discovery + target rig graph
|
||||
if (event.type === "node.claimed") {
|
||||
queryClient.invalidateQueries({ queryKey: ["discovery"] });
|
||||
const rigId = event.payload["rigId"] as string | undefined;
|
||||
if (rigId) {
|
||||
queryClient.invalidateQueries({ queryKey: ["rig", rigId, "graph"] });
|
||||
queryClient.invalidateQueries({ queryKey: ["rig", rigId, "nodes"] });
|
||||
queryClient.invalidateQueries({ queryKey: ["rig", rigId, "sessions"] });
|
||||
queryClient.invalidateQueries({ queryKey: ["rigs", "summary"] });
|
||||
queryClient.invalidateQueries({ queryKey: ["ps"] });
|
||||
}
|
||||
}
|
||||
|
||||
if (event.type === "session.detached") {
|
||||
queryClient.invalidateQueries({ queryKey: ["discovery"] });
|
||||
}
|
||||
|
||||
if (
|
||||
event.type === "session.detached"
|
||||
|| event.type === "node.removed"
|
||||
|| event.type === "pod.deleted"
|
||||
|| event.type === "rig.expanded"
|
||||
|| event.type === "restore.completed"
|
||||
|| event.type === "rig.deleted"
|
||||
) {
|
||||
const rigId = event.payload["rigId"] as string | undefined;
|
||||
if (rigId) {
|
||||
queryClient.invalidateQueries({ queryKey: ["rig", rigId, "graph"] });
|
||||
queryClient.invalidateQueries({ queryKey: ["rig", rigId, "nodes"] });
|
||||
queryClient.invalidateQueries({ queryKey: ["rig", rigId, "sessions"] });
|
||||
}
|
||||
// Invalidate package queries on package mutation events.
|
||||
if (event.type === "package.installed" || event.type === "package.rolledback") {
|
||||
queryClient.invalidateQueries({ queryKey: ["packages"] });
|
||||
}
|
||||
if (event.type === "bootstrap.completed" || event.type === "bootstrap.partial") {
|
||||
queryClient.invalidateQueries({ queryKey: ["rigs", "summary"] });
|
||||
}
|
||||
if (event.type === "session.discovered" || event.type === "session.vanished") {
|
||||
queryClient.invalidateQueries({ queryKey: ["discovery"] });
|
||||
}
|
||||
if (event.type === "node.claimed") {
|
||||
queryClient.invalidateQueries({ queryKey: ["discovery"] });
|
||||
const rigId = event.payload["rigId"] as string | undefined;
|
||||
if (rigId) {
|
||||
queryClient.invalidateQueries({ queryKey: ["rig", rigId, "graph"] });
|
||||
queryClient.invalidateQueries({ queryKey: ["rig", rigId, "nodes"] });
|
||||
queryClient.invalidateQueries({ queryKey: ["rig", rigId, "sessions"] });
|
||||
queryClient.invalidateQueries({ queryKey: ["rigs", "summary"] });
|
||||
queryClient.invalidateQueries({ queryKey: ["ps"] });
|
||||
}
|
||||
} catch {
|
||||
// Ignore unparseable messages
|
||||
}
|
||||
|
||||
if (event.type === "session.detached") {
|
||||
queryClient.invalidateQueries({ queryKey: ["discovery"] });
|
||||
}
|
||||
|
||||
if (
|
||||
event.type === "session.detached"
|
||||
|| event.type === "node.removed"
|
||||
|| event.type === "pod.deleted"
|
||||
|| event.type === "rig.expanded"
|
||||
|| event.type === "restore.completed"
|
||||
|| event.type === "rig.deleted"
|
||||
) {
|
||||
const rigId = event.payload["rigId"] as string | undefined;
|
||||
if (rigId) {
|
||||
queryClient.invalidateQueries({ queryKey: ["rig", rigId, "graph"] });
|
||||
queryClient.invalidateQueries({ queryKey: ["rig", rigId, "nodes"] });
|
||||
queryClient.invalidateQueries({ queryKey: ["rig", rigId, "sessions"] });
|
||||
}
|
||||
queryClient.invalidateQueries({ queryKey: ["rigs", "summary"] });
|
||||
queryClient.invalidateQueries({ queryKey: ["ps"] });
|
||||
}
|
||||
}, [queryClient]);
|
||||
|
||||
useEffect(() => {
|
||||
hasErroredRef.current = false;
|
||||
setConnected(false);
|
||||
|
||||
const es = new EventSource("/api/events");
|
||||
|
||||
es.addEventListener("open", () => {
|
||||
setConnected(true);
|
||||
hasErroredRef.current = false;
|
||||
});
|
||||
|
||||
es.addEventListener("message", (event: Event) => {
|
||||
const msgEvent = event as MessageEvent;
|
||||
addEvent(msgEvent.data as string);
|
||||
});
|
||||
|
||||
es.addEventListener("error", () => {
|
||||
setConnected(false);
|
||||
hasErroredRef.current = true;
|
||||
const unsubscribeEvents = subscribeTopologyEvents((event) => addEvent(event));
|
||||
const unsubscribeStatus = subscribeTopologyEventStatus((status) => {
|
||||
setConnected(status.connected);
|
||||
});
|
||||
|
||||
return () => {
|
||||
es.close();
|
||||
unsubscribeEvents();
|
||||
unsubscribeStatus();
|
||||
};
|
||||
}, [addEvent]);
|
||||
|
||||
|
||||
@@ -1,9 +1,10 @@
|
||||
import { useEffect, useRef } from "react";
|
||||
import { useQueryClient } from "@tanstack/react-query";
|
||||
import { subscribeTopologyEvents } from "../lib/topology-events.js";
|
||||
|
||||
/**
|
||||
* Global SSE event listener. Connects to /api/events (all events)
|
||||
* and invalidates relevant queries when state-changing events arrive.
|
||||
* Global event listener. Subscribes to the shared /api/events hub and
|
||||
* invalidates relevant queries when state-changing events arrive.
|
||||
* Mounted once in AppShell.
|
||||
*/
|
||||
export function useGlobalEvents(): void {
|
||||
@@ -11,14 +12,9 @@ export function useGlobalEvents(): void {
|
||||
const debounceRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
const es = new EventSource("/api/events");
|
||||
const pendingInvalidations = new Set<string>();
|
||||
|
||||
es.addEventListener("message", (event) => {
|
||||
// Coalesce invalidations — collect all affected query keys, flush on debounce
|
||||
let parsed: { type?: string; rigId?: string } = {};
|
||||
try { parsed = JSON.parse(event.data); } catch { /* ignore */ }
|
||||
|
||||
const unsubscribe = subscribeTopologyEvents((parsed) => {
|
||||
const { type, rigId } = parsed;
|
||||
if (!type) return;
|
||||
|
||||
@@ -59,7 +55,7 @@ export function useGlobalEvents(): void {
|
||||
});
|
||||
|
||||
return () => {
|
||||
es.close();
|
||||
unsubscribe();
|
||||
if (debounceRef.current) {
|
||||
clearTimeout(debounceRef.current);
|
||||
debounceRef.current = null;
|
||||
|
||||
@@ -1,5 +1,9 @@
|
||||
import { useEffect, useRef, useState, useCallback } from "react";
|
||||
import { useQueryClient } from "@tanstack/react-query";
|
||||
import {
|
||||
subscribeTopologyEvents,
|
||||
subscribeTopologyEventStatus,
|
||||
} from "../lib/topology-events.js";
|
||||
|
||||
const DEBOUNCE_MS = 100;
|
||||
|
||||
@@ -20,7 +24,7 @@ export function useRigEvents(rigId: string | null): UseRigEventsResult {
|
||||
if (debounceTimerRef.current) return;
|
||||
debounceTimerRef.current = setTimeout(() => {
|
||||
debounceTimerRef.current = null;
|
||||
// Only invalidate the graph query — matches previous behavior
|
||||
// Only invalidate the graph query; this matches previous behavior.
|
||||
queryClient.invalidateQueries({ queryKey: ["rig", rigId, "graph"] });
|
||||
}, DEBOUNCE_MS);
|
||||
}, [rigId, queryClient]);
|
||||
@@ -35,31 +39,27 @@ export function useRigEvents(rigId: string | null): UseRigEventsResult {
|
||||
hasErroredRef.current = false;
|
||||
setConnected(false);
|
||||
setReconnecting(false);
|
||||
const es = new EventSource(`/api/events?rigId=${rigId}`);
|
||||
|
||||
es.addEventListener("open", () => {
|
||||
setConnected(true);
|
||||
if (hasErroredRef.current) {
|
||||
// Reconnect after error — clear indicator and trigger graph refetch
|
||||
setReconnecting(false);
|
||||
const unsubscribeStatus = subscribeTopologyEventStatus((status) => {
|
||||
setConnected(status.connected);
|
||||
setReconnecting(status.reconnecting);
|
||||
if (status.reconnecting) {
|
||||
hasErroredRef.current = true;
|
||||
return;
|
||||
}
|
||||
if (status.connected && hasErroredRef.current) {
|
||||
hasErroredRef.current = false;
|
||||
invalidateGraph();
|
||||
}
|
||||
// Initial open does NOT trigger invalidation (preserving existing behavior)
|
||||
});
|
||||
|
||||
es.addEventListener("message", () => {
|
||||
const unsubscribeEvents = subscribeTopologyEvents((event) => {
|
||||
if (event.rigId !== rigId) return;
|
||||
invalidateGraph();
|
||||
});
|
||||
|
||||
es.addEventListener("error", () => {
|
||||
setConnected(false);
|
||||
setReconnecting(true);
|
||||
hasErroredRef.current = true;
|
||||
});
|
||||
|
||||
return () => {
|
||||
es.close();
|
||||
unsubscribeEvents();
|
||||
unsubscribeStatus();
|
||||
if (debounceTimerRef.current) {
|
||||
clearTimeout(debounceTimerRef.current);
|
||||
debounceTimerRef.current = null;
|
||||
|
||||
Binary file not shown.
@@ -0,0 +1,472 @@
|
||||
import dagre from "dagre";
|
||||
import { Position, type Edge, type Node } from "@xyflow/react";
|
||||
|
||||
const PREFIX_DELIMITER = "::";
|
||||
|
||||
export const HYBRID_AGENT_WIDTH = 166;
|
||||
export const HYBRID_AGENT_HEIGHT = 116;
|
||||
export const HYBRID_POD_HEADER_HEIGHT = 38;
|
||||
export const HYBRID_POD_PADDING_X = 28;
|
||||
export const HYBRID_POD_PADDING_BOTTOM = 22;
|
||||
export const HYBRID_POD_MIN_WIDTH = 188;
|
||||
export const HYBRID_RIG_HEADER_HEIGHT = 78;
|
||||
export const HYBRID_RIG_PADDING_X = 42;
|
||||
export const HYBRID_RIG_PADDING_BOTTOM = 40;
|
||||
export const HYBRID_RIG_MIN_WIDTH = 320;
|
||||
export const HYBRID_COLLAPSED_RIG_WIDTH = 280;
|
||||
export const HYBRID_COLLAPSED_RIG_HEIGHT = 118;
|
||||
export const HYBRID_OUTER_MARGIN = 96;
|
||||
export const HYBRID_OUTER_LANE_COUNT = 3;
|
||||
export const HYBRID_CROSS_RIG_STROKE_DASH = "6 7";
|
||||
|
||||
interface LayoutItem {
|
||||
id: string;
|
||||
width: number;
|
||||
height: number;
|
||||
}
|
||||
|
||||
interface LayoutEdge {
|
||||
source: string;
|
||||
target: string;
|
||||
}
|
||||
|
||||
interface HybridRawNode extends Omit<Node, "data"> {
|
||||
data?: Record<string, unknown>;
|
||||
initialWidth?: number;
|
||||
initialHeight?: number;
|
||||
}
|
||||
|
||||
interface HybridRawEdge extends Omit<Edge, "data" | "label"> {
|
||||
data?: Record<string, unknown>;
|
||||
label?: unknown;
|
||||
}
|
||||
|
||||
interface PodLayout {
|
||||
node: HybridRawNode;
|
||||
width: number;
|
||||
height: number;
|
||||
position: { x: number; y: number };
|
||||
members: Array<{ node: HybridRawNode; position: { x: number; y: number } }>;
|
||||
}
|
||||
|
||||
interface StandaloneLayout {
|
||||
node: HybridRawNode;
|
||||
width: number;
|
||||
height: number;
|
||||
position: { x: number; y: number };
|
||||
}
|
||||
|
||||
export interface HybridRigLayoutInput {
|
||||
rigId: string;
|
||||
nodes: HybridRawNode[];
|
||||
edges: HybridRawEdge[];
|
||||
collapsed: boolean;
|
||||
}
|
||||
|
||||
export interface HybridRigLayoutResult {
|
||||
nodes: Node[];
|
||||
edges: Edge[];
|
||||
width: number;
|
||||
height: number;
|
||||
podCount: number;
|
||||
}
|
||||
|
||||
export interface HybridOuterRigInput {
|
||||
rigId: string;
|
||||
width: number;
|
||||
height: number;
|
||||
}
|
||||
|
||||
export interface HybridOuterRigPosition extends HybridOuterRigInput {
|
||||
position: { x: number; y: number };
|
||||
}
|
||||
|
||||
export function prefixedHybridNodeId(rigId: string, nodeId: string): string {
|
||||
return `${rigId}${PREFIX_DELIMITER}${nodeId}`;
|
||||
}
|
||||
|
||||
export function unprefixedHybridNodeId(nodeId: string): { rigId: string | null; localId: string } {
|
||||
const index = nodeId.indexOf(PREFIX_DELIMITER);
|
||||
if (index < 0) return { rigId: null, localId: nodeId };
|
||||
return {
|
||||
rigId: nodeId.slice(0, index),
|
||||
localId: nodeId.slice(index + PREFIX_DELIMITER.length),
|
||||
};
|
||||
}
|
||||
|
||||
export function isHybridPodNode(node: Pick<Node, "type">): boolean {
|
||||
return node.type === "podGroup" || node.type === "group";
|
||||
}
|
||||
|
||||
export function getHybridEdgeStyle(kind: string | null | undefined, crossRig = false): Edge["style"] {
|
||||
if (crossRig) {
|
||||
return {
|
||||
stroke: "#a8a29e",
|
||||
strokeWidth: 1,
|
||||
strokeDasharray: HYBRID_CROSS_RIG_STROKE_DASH,
|
||||
};
|
||||
}
|
||||
if (kind === "pod-internal" || kind === "contains" || kind === "member") {
|
||||
return {
|
||||
stroke: "#44403c",
|
||||
strokeWidth: 1.5,
|
||||
};
|
||||
}
|
||||
return {
|
||||
stroke: "#78716c",
|
||||
strokeWidth: 1.5,
|
||||
};
|
||||
}
|
||||
|
||||
export function layoutHybridRig(input: HybridRigLayoutInput): HybridRigLayoutResult {
|
||||
if (input.collapsed) {
|
||||
return {
|
||||
nodes: [],
|
||||
edges: [],
|
||||
width: HYBRID_COLLAPSED_RIG_WIDTH,
|
||||
height: HYBRID_COLLAPSED_RIG_HEIGHT,
|
||||
podCount: 0,
|
||||
};
|
||||
}
|
||||
|
||||
const podIds = new Set(input.nodes.filter(isHybridPodNode).map((node) => node.id));
|
||||
const membersByPod = new Map<string, HybridRawNode[]>();
|
||||
const standaloneNodes: HybridRawNode[] = [];
|
||||
|
||||
for (const node of input.nodes) {
|
||||
if (isHybridPodNode(node)) continue;
|
||||
if (typeof node.parentId === "string" && podIds.has(node.parentId)) {
|
||||
if (!membersByPod.has(node.parentId)) membersByPod.set(node.parentId, []);
|
||||
membersByPod.get(node.parentId)!.push(node);
|
||||
continue;
|
||||
}
|
||||
standaloneNodes.push(node);
|
||||
}
|
||||
|
||||
const podLayouts = input.nodes
|
||||
.filter(isHybridPodNode)
|
||||
.map((node) => layoutPod(node, membersByPod.get(node.id) ?? [], input.edges));
|
||||
const standaloneLayouts = standaloneNodes.map<StandaloneLayout>((node) => ({
|
||||
node,
|
||||
width: node.initialWidth ?? HYBRID_AGENT_WIDTH,
|
||||
height: node.initialHeight ?? HYBRID_AGENT_HEIGHT,
|
||||
position: { x: 0, y: 0 },
|
||||
}));
|
||||
|
||||
const entityItems: LayoutItem[] = [
|
||||
...podLayouts.map((pod) => ({ id: pod.node.id, width: pod.width, height: pod.height })),
|
||||
...standaloneLayouts.map((layout) => ({ id: layout.node.id, width: layout.width, height: layout.height })),
|
||||
];
|
||||
const entityByNode = buildEntityLookup(podLayouts, standaloneLayouts);
|
||||
const entityEdges = dedupeEdges(
|
||||
input.edges
|
||||
.map((edge) => {
|
||||
const source = entityByNode.get(edge.source);
|
||||
const target = entityByNode.get(edge.target);
|
||||
if (!source || !target || source === target) return null;
|
||||
return { source, target };
|
||||
})
|
||||
.filter((edge): edge is LayoutEdge => Boolean(edge)),
|
||||
);
|
||||
const entityCenters = layoutWithDagre(entityItems, entityEdges, "LR", {
|
||||
nodesep: 34,
|
||||
ranksep: 34,
|
||||
});
|
||||
const normalized = normalizeLayout(entityItems, entityCenters, {
|
||||
x: HYBRID_RIG_PADDING_X,
|
||||
y: HYBRID_RIG_HEADER_HEIGHT,
|
||||
});
|
||||
|
||||
for (const pod of podLayouts) {
|
||||
pod.position = normalized.positions.get(pod.node.id) ?? {
|
||||
x: HYBRID_RIG_PADDING_X,
|
||||
y: HYBRID_RIG_HEADER_HEIGHT,
|
||||
};
|
||||
}
|
||||
for (const layout of standaloneLayouts) {
|
||||
layout.position = normalized.positions.get(layout.node.id) ?? {
|
||||
x: HYBRID_RIG_PADDING_X,
|
||||
y: HYBRID_RIG_HEADER_HEIGHT,
|
||||
};
|
||||
}
|
||||
|
||||
const width = Math.max(HYBRID_RIG_MIN_WIDTH, normalized.width + HYBRID_RIG_PADDING_X * 2);
|
||||
const height = Math.max(
|
||||
HYBRID_COLLAPSED_RIG_HEIGHT,
|
||||
normalized.height + HYBRID_RIG_HEADER_HEIGHT + HYBRID_RIG_PADDING_BOTTOM,
|
||||
);
|
||||
const rigParentId = `rig-${input.rigId}`;
|
||||
const flowNodes: Node[] = [];
|
||||
|
||||
for (const pod of podLayouts) {
|
||||
const podId = prefixedHybridNodeId(input.rigId, pod.node.id);
|
||||
flowNodes.push({
|
||||
...pod.node,
|
||||
id: podId,
|
||||
type: "podGroup",
|
||||
parentId: rigParentId,
|
||||
extent: "parent",
|
||||
position: pod.position,
|
||||
data: { ...(pod.node.data ?? {}), rigId: input.rigId, agentCount: pod.members.length },
|
||||
style: { ...(pod.node.style ?? {}), width: pod.width, height: pod.height },
|
||||
draggable: false,
|
||||
zIndex: 1,
|
||||
});
|
||||
|
||||
for (const member of pod.members) {
|
||||
flowNodes.push({
|
||||
...member.node,
|
||||
id: prefixedHybridNodeId(input.rigId, member.node.id),
|
||||
type: "rigNode",
|
||||
parentId: podId,
|
||||
extent: "parent",
|
||||
position: member.position,
|
||||
data: { ...(member.node.data ?? {}), rigId: input.rigId },
|
||||
style: { ...(member.node.style ?? {}), width: HYBRID_AGENT_WIDTH, height: HYBRID_AGENT_HEIGHT },
|
||||
initialWidth: HYBRID_AGENT_WIDTH,
|
||||
initialHeight: HYBRID_AGENT_HEIGHT,
|
||||
sourcePosition: Position.Right,
|
||||
targetPosition: Position.Left,
|
||||
draggable: false,
|
||||
zIndex: 2,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
for (const standalone of standaloneLayouts) {
|
||||
flowNodes.push({
|
||||
...standalone.node,
|
||||
id: prefixedHybridNodeId(input.rigId, standalone.node.id),
|
||||
type: "rigNode",
|
||||
parentId: rigParentId,
|
||||
extent: "parent",
|
||||
position: standalone.position,
|
||||
data: { ...(standalone.node.data ?? {}), rigId: input.rigId },
|
||||
style: { ...(standalone.node.style ?? {}), width: HYBRID_AGENT_WIDTH, height: HYBRID_AGENT_HEIGHT },
|
||||
initialWidth: HYBRID_AGENT_WIDTH,
|
||||
initialHeight: HYBRID_AGENT_HEIGHT,
|
||||
sourcePosition: Position.Right,
|
||||
targetPosition: Position.Left,
|
||||
draggable: false,
|
||||
zIndex: 2,
|
||||
});
|
||||
}
|
||||
|
||||
const flowEdges = input.edges.map((edge) => {
|
||||
const sourceRig = input.rigId;
|
||||
const targetRig = input.rigId;
|
||||
const edgeId = edge.id || `${edge.source}->${edge.target}`;
|
||||
const kind = typeof edge.data?.["kind"] === "string"
|
||||
? edge.data["kind"]
|
||||
: typeof edge.label === "string"
|
||||
? edge.label
|
||||
: null;
|
||||
const { label: rawLabel, ...edgeWithoutLabel } = edge;
|
||||
return {
|
||||
...edgeWithoutLabel,
|
||||
id: prefixedHybridNodeId(input.rigId, edgeId),
|
||||
source: prefixedHybridNodeId(sourceRig, edge.source),
|
||||
target: prefixedHybridNodeId(targetRig, edge.target),
|
||||
label: typeof rawLabel === "string" ? rawLabel : undefined,
|
||||
type: edge.type ?? "default",
|
||||
selectable: false,
|
||||
focusable: false,
|
||||
interactionWidth: edge.interactionWidth ?? 10,
|
||||
style: {
|
||||
...getHybridEdgeStyle(kind),
|
||||
...(edge.style ?? {}),
|
||||
},
|
||||
data: { ...(edge.data ?? {}), kind },
|
||||
} satisfies Edge;
|
||||
});
|
||||
|
||||
return {
|
||||
nodes: flowNodes,
|
||||
edges: flowEdges,
|
||||
width,
|
||||
height,
|
||||
podCount: podLayouts.length,
|
||||
};
|
||||
}
|
||||
|
||||
export function layoutHybridOuterRigs(rigs: readonly HybridOuterRigInput[]): HybridOuterRigPosition[] {
|
||||
const items = rigs.map((rig) => ({ id: rig.rigId, width: rig.width, height: rig.height }));
|
||||
const lanes: string[][] = Array.from({ length: HYBRID_OUTER_LANE_COUNT }, () => []);
|
||||
rigs.forEach((rig, index) => {
|
||||
lanes[index % lanes.length]!.push(rig.rigId);
|
||||
});
|
||||
const guideEdges: LayoutEdge[] = [];
|
||||
for (const lane of lanes) {
|
||||
for (let index = 0; index < lane.length - 1; index += 1) {
|
||||
guideEdges.push({ source: lane[index]!, target: lane[index + 1]! });
|
||||
}
|
||||
}
|
||||
|
||||
const centers = layoutWithDagre(items, guideEdges, "TB", {
|
||||
nodesep: 68,
|
||||
ranksep: 62,
|
||||
});
|
||||
const normalized = normalizeLayout(items, centers, {
|
||||
x: HYBRID_OUTER_MARGIN,
|
||||
y: HYBRID_OUTER_MARGIN,
|
||||
});
|
||||
|
||||
return rigs.map((rig) => ({
|
||||
...rig,
|
||||
position: normalized.positions.get(rig.rigId) ?? {
|
||||
x: HYBRID_OUTER_MARGIN,
|
||||
y: HYBRID_OUTER_MARGIN,
|
||||
},
|
||||
}));
|
||||
}
|
||||
|
||||
function layoutPod(node: HybridRawNode, members: HybridRawNode[], edges: HybridRawEdge[]): PodLayout {
|
||||
const items = members.map((member) => ({
|
||||
id: member.id,
|
||||
width: HYBRID_AGENT_WIDTH,
|
||||
height: HYBRID_AGENT_HEIGHT,
|
||||
}));
|
||||
const memberIds = new Set(members.map((member) => member.id));
|
||||
const internalEdges = edges.filter((edge) =>
|
||||
memberIds.has(edge.source) &&
|
||||
memberIds.has(edge.target)
|
||||
);
|
||||
const centers = layoutWithDagre(items, internalEdges, "TB", {
|
||||
nodesep: 16,
|
||||
ranksep: 16,
|
||||
});
|
||||
const normalized = normalizeLayout(items, centers, {
|
||||
x: HYBRID_POD_PADDING_X,
|
||||
y: HYBRID_POD_HEADER_HEIGHT,
|
||||
});
|
||||
const width = Math.max(HYBRID_POD_MIN_WIDTH, normalized.width + HYBRID_POD_PADDING_X * 2);
|
||||
const height = Math.max(
|
||||
HYBRID_POD_HEADER_HEIGHT + HYBRID_POD_PADDING_BOTTOM,
|
||||
normalized.height + HYBRID_POD_HEADER_HEIGHT + HYBRID_POD_PADDING_BOTTOM,
|
||||
);
|
||||
|
||||
return {
|
||||
node,
|
||||
width,
|
||||
height,
|
||||
position: { x: 0, y: 0 },
|
||||
members: members.map((member) => ({
|
||||
node: member,
|
||||
position: normalized.positions.get(member.id) ?? {
|
||||
x: HYBRID_POD_PADDING_X,
|
||||
y: HYBRID_POD_HEADER_HEIGHT,
|
||||
},
|
||||
})),
|
||||
};
|
||||
}
|
||||
|
||||
function buildEntityLookup(pods: PodLayout[], standalone: StandaloneLayout[]): Map<string, string> {
|
||||
const lookup = new Map<string, string>();
|
||||
for (const pod of pods) {
|
||||
lookup.set(pod.node.id, pod.node.id);
|
||||
for (const member of pod.members) {
|
||||
lookup.set(member.node.id, pod.node.id);
|
||||
}
|
||||
}
|
||||
for (const layout of standalone) {
|
||||
lookup.set(layout.node.id, layout.node.id);
|
||||
}
|
||||
return lookup;
|
||||
}
|
||||
|
||||
function dedupeEdges(edges: LayoutEdge[]): LayoutEdge[] {
|
||||
const seen = new Set<string>();
|
||||
const result: LayoutEdge[] = [];
|
||||
for (const edge of edges) {
|
||||
const key = `${edge.source}->${edge.target}`;
|
||||
if (seen.has(key)) continue;
|
||||
seen.add(key);
|
||||
result.push(edge);
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
function layoutWithDagre(
|
||||
items: LayoutItem[],
|
||||
edges: LayoutEdge[],
|
||||
rankdir: "TB" | "LR",
|
||||
options: { nodesep: number; ranksep: number },
|
||||
): Map<string, { x: number; y: number }> {
|
||||
const graph = new dagre.graphlib.Graph();
|
||||
graph.setDefaultEdgeLabel(() => ({}));
|
||||
graph.setGraph({
|
||||
rankdir,
|
||||
nodesep: options.nodesep,
|
||||
ranksep: options.ranksep,
|
||||
marginx: 0,
|
||||
marginy: 0,
|
||||
});
|
||||
|
||||
for (const item of items) {
|
||||
graph.setNode(item.id, { width: item.width, height: item.height });
|
||||
}
|
||||
const itemIds = new Set(items.map((item) => item.id));
|
||||
for (const edge of edges) {
|
||||
if (!itemIds.has(edge.source) || !itemIds.has(edge.target) || edge.source === edge.target) {
|
||||
continue;
|
||||
}
|
||||
graph.setEdge(edge.source, edge.target);
|
||||
}
|
||||
|
||||
dagre.layout(graph);
|
||||
|
||||
const positions = new Map<string, { x: number; y: number }>();
|
||||
for (const item of items) {
|
||||
const node = graph.node(item.id) as { x: number; y: number } | undefined;
|
||||
positions.set(item.id, node ?? { x: 0, y: 0 });
|
||||
}
|
||||
return positions;
|
||||
}
|
||||
|
||||
function normalizeLayout(
|
||||
items: LayoutItem[],
|
||||
centers: Map<string, { x: number; y: number }>,
|
||||
offset: { x: number; y: number },
|
||||
): {
|
||||
positions: Map<string, { x: number; y: number }>;
|
||||
width: number;
|
||||
height: number;
|
||||
} {
|
||||
let minX = Infinity;
|
||||
let minY = Infinity;
|
||||
let maxX = -Infinity;
|
||||
let maxY = -Infinity;
|
||||
|
||||
for (const item of items) {
|
||||
const center = centers.get(item.id) ?? { x: 0, y: 0 };
|
||||
minX = Math.min(minX, center.x - item.width / 2);
|
||||
minY = Math.min(minY, center.y - item.height / 2);
|
||||
maxX = Math.max(maxX, center.x + item.width / 2);
|
||||
maxY = Math.max(maxY, center.y + item.height / 2);
|
||||
}
|
||||
|
||||
if (!Number.isFinite(minX) || !Number.isFinite(minY)) {
|
||||
return { positions: new Map(), width: 0, height: 0 };
|
||||
}
|
||||
|
||||
const positions = new Map<string, { x: number; y: number }>();
|
||||
for (const item of items) {
|
||||
const center = centers.get(item.id) ?? { x: 0, y: 0 };
|
||||
positions.set(item.id, {
|
||||
x: offset.x + center.x - item.width / 2 - minX,
|
||||
y: offset.y + center.y - item.height / 2 - minY,
|
||||
});
|
||||
}
|
||||
|
||||
return {
|
||||
positions,
|
||||
width: maxX - minX,
|
||||
height: maxY - minY,
|
||||
};
|
||||
}
|
||||
|
||||
export const __test_internals = {
|
||||
layoutWithDagre,
|
||||
normalizeLayout,
|
||||
dedupeEdges,
|
||||
};
|
||||
@@ -0,0 +1,123 @@
|
||||
export type TopologyEvent = Record<string, unknown> & {
|
||||
type?: string;
|
||||
rigId?: string;
|
||||
seq?: number;
|
||||
createdAt?: string;
|
||||
};
|
||||
|
||||
export interface TopologyEventStatus {
|
||||
connected: boolean;
|
||||
reconnecting: boolean;
|
||||
}
|
||||
|
||||
type TopologyEventListener = (event: TopologyEvent, rawData: string) => void;
|
||||
type TopologyStatusListener = (status: TopologyEventStatus) => void;
|
||||
|
||||
const GLOBAL_EVENTS_URL = "/api/events";
|
||||
|
||||
class TopologyEventHub {
|
||||
private eventSource: EventSource | null = null;
|
||||
private eventListeners = new Set<TopologyEventListener>();
|
||||
private statusListeners = new Set<TopologyStatusListener>();
|
||||
private connected = false;
|
||||
private reconnecting = false;
|
||||
|
||||
subscribe(listener: TopologyEventListener): () => void {
|
||||
this.eventListeners.add(listener);
|
||||
this.ensureConnected();
|
||||
return () => {
|
||||
this.eventListeners.delete(listener);
|
||||
this.closeIfIdle();
|
||||
};
|
||||
}
|
||||
|
||||
subscribeStatus(listener: TopologyStatusListener): () => void {
|
||||
this.statusListeners.add(listener);
|
||||
listener(this.snapshot());
|
||||
this.ensureConnected();
|
||||
return () => {
|
||||
this.statusListeners.delete(listener);
|
||||
this.closeIfIdle();
|
||||
};
|
||||
}
|
||||
|
||||
snapshot(): TopologyEventStatus {
|
||||
return {
|
||||
connected: this.connected,
|
||||
reconnecting: this.reconnecting,
|
||||
};
|
||||
}
|
||||
|
||||
private ensureConnected(): void {
|
||||
if (this.eventSource || typeof EventSource === "undefined") return;
|
||||
|
||||
const eventSource = new EventSource(GLOBAL_EVENTS_URL);
|
||||
this.eventSource = eventSource;
|
||||
|
||||
eventSource.addEventListener("open", () => {
|
||||
this.connected = true;
|
||||
this.reconnecting = false;
|
||||
this.emitStatus();
|
||||
});
|
||||
|
||||
eventSource.addEventListener("error", () => {
|
||||
this.connected = false;
|
||||
this.reconnecting = true;
|
||||
this.emitStatus();
|
||||
});
|
||||
|
||||
eventSource.addEventListener("message", (event) => {
|
||||
const data = (event as MessageEvent).data;
|
||||
if (typeof data !== "string") return;
|
||||
let parsed: unknown = null;
|
||||
try {
|
||||
parsed = JSON.parse(data);
|
||||
} catch {
|
||||
return;
|
||||
}
|
||||
if (!parsed || typeof parsed !== "object") return;
|
||||
const topologyEvent = parsed as TopologyEvent;
|
||||
for (const listener of [...this.eventListeners]) {
|
||||
listener(topologyEvent, data);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
private closeIfIdle(): void {
|
||||
if (this.eventListeners.size > 0 || this.statusListeners.size > 0) return;
|
||||
if (this.eventSource) {
|
||||
this.eventSource.close();
|
||||
this.eventSource = null;
|
||||
}
|
||||
this.connected = false;
|
||||
this.reconnecting = false;
|
||||
}
|
||||
|
||||
private emitStatus(): void {
|
||||
const status = this.snapshot();
|
||||
for (const listener of [...this.statusListeners]) {
|
||||
listener(status);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const topologyEventHub = new TopologyEventHub();
|
||||
|
||||
export function subscribeTopologyEvents(listener: TopologyEventListener): () => void {
|
||||
return topologyEventHub.subscribe(listener);
|
||||
}
|
||||
|
||||
export function subscribeTopologyEventStatus(listener: TopologyStatusListener): () => void {
|
||||
return topologyEventHub.subscribeStatus(listener);
|
||||
}
|
||||
|
||||
export function getTopologyEventStatus(): TopologyEventStatus {
|
||||
return topologyEventHub.snapshot();
|
||||
}
|
||||
|
||||
export const TOPOLOGY_EVENT_HUB_URL = GLOBAL_EVENTS_URL;
|
||||
|
||||
export const __test_internals = {
|
||||
TOPOLOGY_EVENT_HUB_URL,
|
||||
topologyEventHub,
|
||||
};
|
||||
@@ -481,18 +481,14 @@ describe("LibraryReview", () => {
|
||||
files: [], missingFiles: [],
|
||||
}), { status: 200 });
|
||||
}
|
||||
if (url === "/api/ps") {
|
||||
return new Response(JSON.stringify([
|
||||
{
|
||||
rigId: "rig-1",
|
||||
name: "demo",
|
||||
nodes: [
|
||||
{ canonicalSessionName: "driver@demo", sessionStatus: "running" },
|
||||
{ canonicalSessionName: "qa@demo", sessionStatus: "running" },
|
||||
{ canonicalSessionName: "stopped@demo", sessionStatus: "exited" },
|
||||
],
|
||||
},
|
||||
]), { status: 200 });
|
||||
if (url === "/api/mission-control/destinations") {
|
||||
return new Response(JSON.stringify({
|
||||
destinations: [
|
||||
{ sessionName: "driver@demo", label: "driver", source: "topology", status: "running" },
|
||||
{ sessionName: "qa@demo", label: "qa", source: "topology", status: "running" },
|
||||
{ sessionName: "stopped@demo", label: "stopped", source: "topology", status: "exited" },
|
||||
],
|
||||
}), { status: 200 });
|
||||
}
|
||||
throw new Error(`Unexpected fetch: ${url}`);
|
||||
});
|
||||
|
||||
@@ -0,0 +1,79 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import {
|
||||
HYBRID_COLLAPSED_RIG_HEIGHT,
|
||||
HYBRID_COLLAPSED_RIG_WIDTH,
|
||||
HYBRID_CROSS_RIG_STROKE_DASH,
|
||||
getHybridEdgeStyle,
|
||||
layoutHybridOuterRigs,
|
||||
layoutHybridRig,
|
||||
prefixedHybridNodeId,
|
||||
unprefixedHybridNodeId,
|
||||
} from "../src/lib/hybrid-layout.js";
|
||||
|
||||
describe("P5.3 hybrid layout helpers", () => {
|
||||
it("prefixes node IDs with rigId and can parse them back", () => {
|
||||
const id = prefixedHybridNodeId("rig-1", "agent-a");
|
||||
expect(id).toBe("rig-1::agent-a");
|
||||
expect(unprefixedHybridNodeId(id)).toEqual({ rigId: "rig-1", localId: "agent-a" });
|
||||
expect(unprefixedHybridNodeId("agent-a")).toEqual({ rigId: null, localId: "agent-a" });
|
||||
});
|
||||
|
||||
it("lays out expanded rig as nested rig -> pod -> agent nodes", () => {
|
||||
const layout = layoutHybridRig({
|
||||
rigId: "rig-1",
|
||||
collapsed: false,
|
||||
nodes: [
|
||||
{ id: "pod-dev", type: "podGroup", position: { x: 0, y: 0 }, data: { podNamespace: "dev" } },
|
||||
{ id: "dev.driver", type: "rigNode", parentId: "pod-dev", position: { x: 0, y: 0 }, data: { logicalId: "dev.driver" } },
|
||||
{ id: "dev.guard", type: "rigNode", parentId: "pod-dev", position: { x: 0, y: 0 }, data: { logicalId: "dev.guard" } },
|
||||
],
|
||||
edges: [
|
||||
{ id: "e1", source: "dev.driver", target: "dev.guard" },
|
||||
],
|
||||
});
|
||||
|
||||
expect(layout.width).toBeGreaterThan(HYBRID_COLLAPSED_RIG_WIDTH);
|
||||
expect(layout.height).toBeGreaterThanOrEqual(HYBRID_COLLAPSED_RIG_HEIGHT);
|
||||
expect(layout.podCount).toBe(1);
|
||||
|
||||
const pod = layout.nodes.find((node) => node.id === "rig-1::pod-dev");
|
||||
const agent = layout.nodes.find((node) => node.id === "rig-1::dev.driver");
|
||||
expect(pod?.parentId).toBe("rig-rig-1");
|
||||
expect(agent?.parentId).toBe("rig-1::pod-dev");
|
||||
expect(layout.edges[0]?.source).toBe("rig-1::dev.driver");
|
||||
expect(layout.edges[0]?.target).toBe("rig-1::dev.guard");
|
||||
});
|
||||
|
||||
it("keeps collapsed rigs cheap: no child graph and fixed card dimensions", () => {
|
||||
const layout = layoutHybridRig({
|
||||
rigId: "rig-1",
|
||||
collapsed: true,
|
||||
nodes: [
|
||||
{ id: "pod-dev", type: "podGroup", position: { x: 0, y: 0 }, data: { podNamespace: "dev" } },
|
||||
],
|
||||
edges: [],
|
||||
});
|
||||
|
||||
expect(layout.nodes).toHaveLength(0);
|
||||
expect(layout.edges).toHaveLength(0);
|
||||
expect(layout.width).toBe(HYBRID_COLLAPSED_RIG_WIDTH);
|
||||
expect(layout.height).toBe(HYBRID_COLLAPSED_RIG_HEIGHT);
|
||||
});
|
||||
|
||||
it("outer dagre layout gives each rig frame a stable canvas position", () => {
|
||||
const positions = layoutHybridOuterRigs([
|
||||
{ rigId: "rig-1", width: 300, height: 120 },
|
||||
{ rigId: "rig-2", width: 320, height: 160 },
|
||||
{ rigId: "rig-3", width: 280, height: 140 },
|
||||
{ rigId: "rig-4", width: 360, height: 180 },
|
||||
]);
|
||||
|
||||
expect(positions).toHaveLength(4);
|
||||
expect(new Set(positions.map((rig) => `${rig.position.x}:${rig.position.y}`)).size).toBe(4);
|
||||
});
|
||||
|
||||
it("shares the cross-rig dashed style constant", () => {
|
||||
const style = getHybridEdgeStyle("handoff", true);
|
||||
expect(style?.strokeDasharray).toBe(HYBRID_CROSS_RIG_STROKE_DASH);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,96 @@
|
||||
import { afterEach, beforeEach, describe, expect, it } from "vitest";
|
||||
import { cleanup, render, screen, waitFor, act } from "@testing-library/react";
|
||||
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
||||
import { readFileSync } from "node:fs";
|
||||
import path from "node:path";
|
||||
import { useActivityFeed } from "../src/hooks/useActivityFeed.js";
|
||||
import { useGlobalEvents } from "../src/hooks/useGlobalEvents.js";
|
||||
import { useTopologyEdgeActivity } from "../src/hooks/useTopologyEdgeActivity.js";
|
||||
import { createMockEventSourceClass, instances } from "./helpers/mock-event-source.js";
|
||||
|
||||
let OriginalEventSource: typeof EventSource | undefined;
|
||||
|
||||
function EventHubHarness() {
|
||||
useGlobalEvents();
|
||||
const feed = useActivityFeed();
|
||||
const edgeActivity = useTopologyEdgeActivity();
|
||||
return (
|
||||
<div>
|
||||
<span data-testid="hub-connected">{String(feed.connected)}</span>
|
||||
<span data-testid="hub-event-count">{feed.events.length}</span>
|
||||
<span data-testid="hub-edge-version">{edgeActivity.version}</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function renderHarness() {
|
||||
const queryClient = new QueryClient({
|
||||
defaultOptions: { queries: { retry: false, gcTime: 0 } },
|
||||
});
|
||||
return render(
|
||||
<QueryClientProvider client={queryClient}>
|
||||
<EventHubHarness />
|
||||
</QueryClientProvider>,
|
||||
);
|
||||
}
|
||||
|
||||
describe("P5.3 shared topology event hub", () => {
|
||||
beforeEach(() => {
|
||||
OriginalEventSource = globalThis.EventSource;
|
||||
globalThis.EventSource = createMockEventSourceClass() as unknown as typeof EventSource;
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
cleanup();
|
||||
if (OriginalEventSource) {
|
||||
globalThis.EventSource = OriginalEventSource;
|
||||
}
|
||||
});
|
||||
|
||||
it("feeds activity, invalidation, and edge activity hooks from one /api/events connection", async () => {
|
||||
const { unmount } = renderHarness();
|
||||
|
||||
await waitFor(() => {
|
||||
expect(instances).toHaveLength(1);
|
||||
expect(instances[0]!.url).toBe("/api/events");
|
||||
});
|
||||
await waitFor(() => {
|
||||
expect(screen.getByTestId("hub-connected").textContent).toBe("true");
|
||||
});
|
||||
|
||||
act(() => {
|
||||
instances[0]!.simulateMessage(JSON.stringify({
|
||||
type: "queue.created",
|
||||
sourceSession: "orch.lead@rig-1",
|
||||
destinationSession: "dev.driver@rig-1",
|
||||
seq: 1,
|
||||
}));
|
||||
});
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByTestId("hub-event-count").textContent).toBe("1");
|
||||
expect(Number(screen.getByTestId("hub-edge-version").textContent)).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
unmount();
|
||||
expect(instances[0]!.readyState).toBe(2);
|
||||
});
|
||||
|
||||
it("keeps EventSource construction centralized in topology-events", () => {
|
||||
const srcRoot = path.resolve(__dirname, "../src");
|
||||
const hookFiles = [
|
||||
"hooks/useActivityFeed.ts",
|
||||
"hooks/useGlobalEvents.ts",
|
||||
"hooks/useRigEvents.ts",
|
||||
"hooks/useTopologyEdgeActivity.ts",
|
||||
];
|
||||
for (const relative of hookFiles) {
|
||||
const src = readFileSync(path.join(srcRoot, relative), "utf8");
|
||||
expect(src).not.toMatch(/new\s+EventSource/);
|
||||
expect(src).toMatch(/subscribeTopology/);
|
||||
}
|
||||
|
||||
const hubSrc = readFileSync(path.join(srcRoot, "lib/topology-events.ts"), "utf8");
|
||||
expect(hubSrc.match(/new\s+EventSource/g) ?? []).toHaveLength(1);
|
||||
});
|
||||
});
|
||||
@@ -59,12 +59,12 @@ function getLastInstance(): MockEventSourceInstance {
|
||||
}
|
||||
|
||||
describe("useRigEvents hook", () => {
|
||||
it("opens EventSource to correct URL", async () => {
|
||||
it("opens the shared EventSource to the global events URL", async () => {
|
||||
renderWithQuery(<HookHarness rigId="rig-1" />);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(instances).toHaveLength(1);
|
||||
expect(instances[0]!.url).toBe("/api/events?rigId=rig-1");
|
||||
expect(instances[0]!.url).toBe("/api/events");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -81,7 +81,9 @@ describe("useRigEvents hook", () => {
|
||||
await waitFor(() => expect(instances).toHaveLength(1));
|
||||
const es = getLastInstance();
|
||||
|
||||
act(() => { es.simulateMessage("event1"); });
|
||||
act(() => {
|
||||
es.simulateMessage(JSON.stringify({ type: "node.startup_ready", rigId: "rig-1" }));
|
||||
});
|
||||
|
||||
// Wait for debounce (100ms)
|
||||
await waitFor(() => {
|
||||
@@ -104,9 +106,9 @@ describe("useRigEvents hook", () => {
|
||||
|
||||
// Rapid fire — all within debounce window
|
||||
act(() => {
|
||||
es.simulateMessage("e1");
|
||||
es.simulateMessage("e2");
|
||||
es.simulateMessage("e3");
|
||||
es.simulateMessage(JSON.stringify({ type: "node.startup_ready", rigId: "rig-1", seq: 1 }));
|
||||
es.simulateMessage(JSON.stringify({ type: "node.startup_ready", rigId: "rig-1", seq: 2 }));
|
||||
es.simulateMessage(JSON.stringify({ type: "node.startup_ready", rigId: "rig-1", seq: 3 }));
|
||||
});
|
||||
|
||||
await waitFor(() => {
|
||||
@@ -146,19 +148,19 @@ describe("useRigEvents hook", () => {
|
||||
expect(es.readyState).toBe(2); // CLOSED
|
||||
});
|
||||
|
||||
it("rigId change -> old EventSource closed, new one opened", async () => {
|
||||
it("rigId change keeps the shared URL while resubscribing the rig filter", async () => {
|
||||
const { getByText } = renderWithQuery(<ChangingRigHarness />);
|
||||
|
||||
await waitFor(() => expect(instances).toHaveLength(1));
|
||||
const first = instances[0]!;
|
||||
expect(first.url).toContain("rig-1");
|
||||
expect(first.url).toBe("/api/events");
|
||||
|
||||
act(() => { getByText("change").click(); });
|
||||
|
||||
await waitFor(() => {
|
||||
expect(instances).toHaveLength(2);
|
||||
expect(first.readyState).toBe(2); // CLOSED
|
||||
expect(instances[1]!.url).toContain("rig-2");
|
||||
expect(instances[1]!.url).toBe("/api/events");
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -1060,12 +1060,10 @@ describe("RigGraph SSE integration", () => {
|
||||
expect(mockFetch).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
// Fire SSE message
|
||||
// Fire shared topology event message.
|
||||
act(() => {
|
||||
// PL-019: RigGraph now opens two EventSources (useRigEvents + the
|
||||
// new useTopologyEdgeActivity). Target the rig-events one by URL.
|
||||
const es = instances.find((i) => i.url.includes("?rigId="))!;
|
||||
es.simulateMessage('{"type":"node.added"}');
|
||||
const es = instances.find((i) => i.url === "/api/events")!;
|
||||
es.simulateMessage('{"type":"node.added","rigId":"rig-1"}');
|
||||
});
|
||||
|
||||
// Wait for debounced refetch
|
||||
@@ -1092,12 +1090,10 @@ describe("RigGraph SSE integration", () => {
|
||||
expect(nodes.length).toBe(1);
|
||||
});
|
||||
|
||||
// Fire SSE message to trigger refetch
|
||||
// Fire shared topology event message to trigger refetch.
|
||||
act(() => {
|
||||
// PL-019: RigGraph now opens two EventSources (useRigEvents + the
|
||||
// new useTopologyEdgeActivity). Target the rig-events one by URL.
|
||||
const es = instances.find((i) => i.url.includes("?rigId="))!;
|
||||
es.simulateMessage('{"type":"node.added"}');
|
||||
const es = instances.find((i) => i.url === "/api/events")!;
|
||||
es.simulateMessage('{"type":"node.added","rigId":"rig-1"}');
|
||||
});
|
||||
|
||||
// Wait for re-render with 2 nodes
|
||||
@@ -1115,7 +1111,7 @@ describe("RigGraph SSE integration", () => {
|
||||
await waitFor(() => expect(instances.length).toBeGreaterThan(0));
|
||||
|
||||
act(() => {
|
||||
instances.find((i) => i.url.includes("?rigId="))!.simulateError();
|
||||
instances.find((i) => i.url === "/api/events")!.simulateError();
|
||||
});
|
||||
|
||||
await waitFor(() => {
|
||||
@@ -1133,7 +1129,7 @@ describe("RigGraph SSE integration", () => {
|
||||
|
||||
// Error
|
||||
act(() => {
|
||||
instances.find((i) => i.url.includes("?rigId="))!.simulateError();
|
||||
instances.find((i) => i.url === "/api/events")!.simulateError();
|
||||
});
|
||||
|
||||
await waitFor(() => {
|
||||
@@ -1145,7 +1141,7 @@ describe("RigGraph SSE integration", () => {
|
||||
|
||||
// Reconnect (open event)
|
||||
act(() => {
|
||||
instances.find((i) => i.url.includes("?rigId="))!.simulateOpen();
|
||||
instances.find((i) => i.url === "/api/events")!.simulateOpen();
|
||||
});
|
||||
|
||||
await waitFor(() => {
|
||||
|
||||
Reference in new issue
Block a user