shell-redesign V1 polish slice Phase 5.2: multi-rig single-canvas /topology graph + rig-collapse affordance

Unparks P5.1-4 with explicit V1 scope expansion to include the
rig-collapse affordance. The /topology landing page becomes a
figma-style canvas for fleet management at scale: by default ALL rigs
appear as collapsed vellum cards on a single ReactFlow canvas; click a
card body to expand it inline; click the "→" arrow on the rig name to
drill into the rig page.

Scope = 8 dispatch items bundled into one canvas component:

NEW packages/ui/src/components/topology/HostMultiRigGraph.tsx:
- P5.2-1 composition: ReactFlow canvas with merged nodes/edges across
  N rigs; nodeTypes registers rigGroup + podGroup + rigNode; controls
  pinned bottom-right with vellum styling.
- P5.2-2 useQueries pattern: single hook call regardless of rig count
  (P0-1 anti-pattern guarded; tests assert no .map(useRigGraph) loop).
  Per-rig graph fetch is enabled only when the rig is expanded —
  scales to 100+ rigs without upfront fan-out (counts come from a
  single /api/ps fetch).
- P5.2-3 cross-rig node-ID prefixing via prefixRigData helper:
  every node carries `data.rigId`; IDs become `${rigId}::${nodeId}`;
  parentId for react-flow parent/child also prefixed.
- P5.2-5 rig-collapse default + click-toggle: state held as
  Map<rigId, boolean>; default empty Map (all collapsed); rig body
  click toggles; multiple rigs expand independently; per-session.
- Auto-expand rule (parity with Phase 5.1 TopologyTreeView): on
  /topology/seat/$rigId/* or /topology/pod/$rigId/* or
  /topology/rig/$rigId, the matching rig auto-expands on mount and
  on route change.
- P5.2-7 click handler reads rigId from `node.data.rigId` (NOT
  closure): agent click → useNavigate seat URL; pod group click →
  useNavigate pod URL; rigGroup body click → no-op at the canvas
  level (handled by RigGroupNode's own onClick → onToggle).
- P5.2-8 per-rig features: cmux focus / placement / edge activity
  thread through node.data.rigId by construction.

NEW packages/ui/src/components/topology/RigGroupNode.tsx:
- P5.2-4 ReactFlow node-type renderer for rigGroup. Vellum tactical
  composition per Phase 1 primitive grammar: 1px outline-variant
  border + RegistrationMarks at 4 corners + paper-press hard-shadow +
  font-mono uppercase rig name + counts subline + StatusPip.
- Body click → onToggle(rigId) (provided by HostMultiRigGraph).
- Drill-in "→" Link to /topology/rig/$rigId on the rig name; its
  onClick e.stopPropagation() prevents the body's toggle from also
  firing. Figma frame-name vs frame-body convention.
- Collapsed visual: card body with counts + status pill.
- Expanded visual: thin counts strip (header/border) acts as the
  bounding-box frame; children render inside via react-flow
  parent/child positioning.

NEW packages/ui/src/lib/multi-rig-layout.ts:
- P5.2-6 layout helpers (option (a) per-rig + outer offset per ACK §2):
  * prefixRigData(rigId, nodes, edges) — cross-rig prefixing + data.rigId
  * computeBounds(nodes) — bounding box for an expanded rig
  * packRigGroups(rigBounds, viewportWidth) — greedy row-fill grid
- Constants: COLLAPSED_RIG_WIDTH=280, COLLAPSED_RIG_HEIGHT=120,
  RIG_HEADER_HEIGHT=60, RIG_PADDING=16, RIG_GUTTER_X=48, RIG_GUTTER_Y=48.

REFACTORED packages/ui/src/components/topology/ScopePages.tsx:
- HostScopePage graph view-mode replaces the prior placeholder card
  with `<HostMultiRigGraph />`. Mobile graph→table degradation pattern
  preserved (effectiveActive gate via useShellViewport per P5-9).

NEW packages/ui/test/p5-2-host-multi-rig-graph.test.tsx (16 tests):
- multi-rig-layout pure-fn tests: prefixRigData prefixes IDs +
  threads data.rigId; two rigs with overlapping internal IDs have
  no collision after prefixing (ritual #9 coupled-literal scan);
  parentId preserved + prefixed; packRigGroups row-fill + wrap;
  computeBounds covers empty + multi-node cases.
- HostMultiRigGraph reachability (ritual #6): mounts cleanly with N
  rigs from /api/ps; default ALL collapsed; rig body click toggles
  collapse state; drill-in "→" Link rendered separately from body
  (stopPropagation contract guarded by source-assertion); empty rig
  list renders honest empty-state.
- Source-assertion guards: HostMultiRigGraph imports useQueries from
  @tanstack/react-query and calls it (positive); no .map(useRigGraph)
  rules-of-hooks anti-pattern (ritual #8 negative); no useNodeSelection
  (Phase 5.1 retired); RigGroupNode contains 1px outline-variant +
  hard-shadow + RegistrationMarks (vellum primitive grammar guard);
  drill Link contains e.stopPropagation() (ritual #9).
- Default-collapsed coupled-literal scan (ritual #9): useState<Map<...>>
  initializer is `() => new Map()` (empty map → all rigs default to
  collapsed via expanded.get(rigId) ?? false); collapsed boolean is
  `!p.isExpanded` (single source of truth for collapse semantic).

UPDATED packages/ui/test/p5-mobile-polish.test.tsx:
- Topology graph degradation tests: the prior `topology-host-graph-
  placeholder` testid no longer exists at any viewport (P5.2 removed
  the placeholder). Updated assertions to look for `host-multi-rig-
  graph` (or empty-state) at >= lg viewport; both placeholder and
  multi-rig canvas absent at <lg (table degradation path unchanged).

Verification:
- pnpm -F @openrig/ui exec tsc --noEmit: clean
- vitest p5-2-host-multi-rig-graph: 16/16 PASS
- vitest full UI suite: 692/694 PASS (1 pre-existing library-review
  Send-to-seat baseline failure UNCHANGED; 1 skipped)
- pnpm -F @openrig/ui build: clean (1.93s)

SC-29 posture: UNCHANGED — UI-only this slice. Daemon byte-equivalent
beyond Phase 4 + Phase 5 declared exceptions.

Co-Authored-By: Claude Opus 4.7 (1M context) <[email protected]>
This commit is contained in:
mvschwarzandClaude Opus 4.7 committed 2026-05-06 21:17:34 -07:00
1 parent d8ad5d4292
commit abb154eff3
6 files changed
+1015 -13

No files matched your search

@@ -0,0 +1,337 @@
// V1 polish slice Phase 5.2 — multi-rig single-canvas /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
//
// 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).
//
// Performance: per-rig graph data is lazy-fetched (useQueries enabled
// only when rig is expanded). At default state with N rigs collapsed,
// only one /api/ps fetch happens (for the rig cards' counts). Scales
// to 100+ rigs per the for-you-feed.md L9 north-star without upfront
// fan-out.
import { useEffect, useMemo, useState } from "react";
import { useNavigate, useRouterState } from "@tanstack/react-router";
import {
ReactFlow,
Controls,
type Node,
type Edge,
type NodeTypes,
type NodeMouseHandler,
} from "@xyflow/react";
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 {
prefixRigData,
packRigGroups,
computeBounds,
COLLAPSED_RIG_WIDTH,
COLLAPSED_RIG_HEIGHT,
RIG_HEADER_HEIGHT,
RIG_PADDING,
} from "../../lib/multi-rig-layout.js";
interface GraphData {
nodes: unknown[];
edges: unknown[];
}
async function fetchGraph(rigId: string): Promise<GraphData> {
const res = await fetch(`/api/rigs/${encodeURIComponent(rigId)}/graph`);
if (!res.ok) throw new Error(`HTTP ${res.status}`);
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,
};
/** Active topology context derived from the route pathname so the rig
* matching the URL auto-expands (parity with Phase 5.1 TopologyTreeView). */
function useActiveRigId(): string | null {
const pathname = useRouterState({ select: (s) => s.location.pathname });
const seat = pathname.match(/^\/topology\/seat\/([^/]+)\//);
if (seat) return decodeURIComponent(seat[1]!);
const pod = pathname.match(/^\/topology\/pod\/([^/]+)\//);
if (pod) return decodeURIComponent(pod[1]!);
const rig = pathname.match(/^\/topology\/rig\/([^/]+)$/);
if (rig) return decodeURIComponent(rig[1]!);
return null;
}
export function HostMultiRigGraph() {
const navigate = useNavigate();
const { data: psEntries } = usePsEntries();
const { innerWidth } = useShellViewport();
const activeRigId = useActiveRigId();
// Default-all-collapsed state; per-session Map<rigId, expanded?>.
const [expanded, setExpanded] = useState<Map<string, boolean>>(() => new Map());
const toggleRig = (rigId: string) => {
setExpanded((prev) => {
const next = new Map(prev);
next.set(rigId, !(prev.get(rigId) ?? false));
return next;
});
};
// Auto-expand the rig matching the active route on mount + on route
// change (parity with Phase 5.1 TopologyTreeView auto-expand).
useEffect(() => {
if (!activeRigId) return;
setExpanded((prev) => {
if (prev.get(activeRigId)) return prev;
const next = new Map(prev);
next.set(activeRigId, true);
return next;
});
}, [activeRigId]);
// 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
// no rules-of-hooks regression (P0-1 pattern preserved).
const rigList = psEntries ?? [];
const graphQueries = useQueries({
queries: rigList.map((rig) => ({
queryKey: ["rig", rig.rigId, "graph"] as const,
queryFn: () => fetchGraph(rig.rigId),
enabled: expanded.get(rig.rigId) ?? false,
refetchInterval: 30_000,
})),
});
// Build per-rig prefixed sub-graphs (only for expanded rigs); compute
// each rig's bounding box; pack rig groups in a grid via packRigGroups.
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 };
// Step 1 — for each rig, compute its prefixed nodes/edges + bounds.
const perRig: Array<{
rigId: string;
rigName: string;
status: "running" | "partial" | "stopped";
nodeCount: number;
runningCount: number;
podCount?: number;
isExpanded: boolean;
childNodes: Node[];
childEdges: Edge[];
width: number;
height: number;
}> = [];
for (let i = 0; i < rigList.length; i++) {
const rig = rigList[i]!;
const isExpanded = expanded.get(rig.rigId) ?? false;
const queryResult = graphQueries[i];
let childNodes: Node[] = [];
let childEdges: Edge[] = [];
let width = COLLAPSED_RIG_WIDTH;
let height = 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;
}
perRig.push({
rigId: rig.rigId,
rigName: rig.name,
status: rig.status,
nodeCount: rig.nodeCount,
runningCount: rig.runningCount,
podCount,
isExpanded,
childNodes,
childEdges,
width,
height,
});
}
// Step 2 — pack rig groups in the canvas grid.
const packed = packRigGroups(
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++) {
const p = perRig[i]!;
const pack = packed[i]!;
const data: RigGroupNodeData = {
rigId: p.rigId,
rigName: p.rigName,
collapsed: !p.isExpanded,
status: p.status,
nodeCount: p.nodeCount,
runningCount: p.runningCount,
podCount: p.podCount,
onToggle: toggleRig,
};
nodes.push({
id: `rig-${p.rigId}`,
type: "rigGroup",
position: { x: pack.offsetX, y: pack.offsetY },
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.
});
if (p.isExpanded) {
nodes.push(...p.childNodes);
edges.push(...p.childEdges);
}
}
return { mergedNodes: nodes, mergedEdges: edges };
// toggleRig is stable per-render but useMemo doesn't know; safe to
// include rigList + expanded + graphQueries + innerWidth as deps.
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [rigList, expanded, graphQueries, innerWidth]);
// 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;
const rigId = data?.rigId;
if (!rigId) return;
if (node.type === "rigGroup") return; // body click handled by RigGroupNode
if (node.type === "podGroup" || node.type === "group") {
const podName = data?.podNamespace ?? data?.podId;
if (!podName) return;
navigate({
to: "/topology/pod/$rigId/$podName",
params: { rigId, podName },
});
return;
}
if (data?.logicalId) {
navigate({
to: "/topology/seat/$rigId/$logicalId",
params: { rigId, logicalId: encodeURIComponent(data.logicalId) },
});
}
};
if (rigList.length === 0) {
return (
<div
data-testid="host-multi-rig-graph-empty"
className="flex flex-col items-center justify-center h-full font-mono text-[10px] text-on-surface-variant"
>
No rigs registered. Run <code className="ml-1 text-stone-700">rig up</code> to start one.
</div>
);
}
return (
<div
data-testid="host-multi-rig-graph"
className="w-full h-full relative"
>
<ReactFlow
nodes={mergedNodes}
edges={mergedEdges}
nodeTypes={nodeTypes}
onNodeClick={onNodeClick}
fitView
fitViewOptions={{ padding: 0.15, includeHiddenNodes: false }}
proOptions={{ hideAttribution: true }}
>
<Controls
position="bottom-right"
showInteractive={false}
className="!bg-white/40 !border !border-outline-variant"
/>
</ReactFlow>
</div>
);
}
@@ -0,0 +1,147 @@
// 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').
//
// 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).
import { Link } from "@tanstack/react-router";
import { ChevronRight } from "lucide-react";
import { Handle, Position } from "@xyflow/react";
import { RegistrationMarks } from "../ui/registration-marks.js";
import { StatusPip } from "../ui/status-pip.js";
import { cn } from "../../lib/utils.js";
export interface RigGroupNodeData {
rigId: string;
rigName: string;
/** When true, body shows the summary card and children render outside.
* When false, body acts as a bounding-box frame; children render inside
* via react-flow parent/child positioning. */
collapsed: boolean;
status: "running" | "partial" | "stopped";
nodeCount: number;
runningCount: number;
podCount?: number; // optional; populated post-graph-fetch when expanded
/** Click handler — owner threads through to toggle expand state. */
onToggle: (rigId: string) => void;
}
function statusToPip(s: RigGroupNodeData["status"]): React.ComponentProps<typeof StatusPip>["status"] {
if (s === "running") return "running";
if (s === "stopped") return "stopped";
return "warning"; // partial
}
export function RigGroupNode({ data }: { data: RigGroupNodeData }) {
const { rigId, rigName, collapsed, status, nodeCount, runningCount, podCount, onToggle } = data;
return (
<div
data-testid={`rig-group-node-${rigId}`}
data-collapsed={collapsed ? "true" : "false"}
onClick={(e) => {
// Body click toggles collapse. The drill-in Link inside stops
// propagation so its click navigates without ALSO toggling.
e.stopPropagation();
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.
collapsed
? "hover:bg-white/60"
: "bg-white/20",
)}
>
<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">
<div className="flex items-center gap-2 min-w-0">
<ChevronRight
className={cn(
"h-3 w-3 text-on-surface-variant shrink-0 transition-transform",
collapsed ? "" : "rotate-90",
)}
aria-hidden="true"
/>
<span
data-testid={`rig-group-name-${rigId}`}
className="font-mono text-[11px] font-bold uppercase tracking-[0.10em] text-stone-900 truncate"
>
{rigName}
</span>
</div>
<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"
aria-label={`Open ${rigName} rig page`}
>
<span className="font-mono text-[10px] uppercase tracking-wide">→</span>
</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="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 · ` : ""}
{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"
data-testid={`rig-group-expanded-counts-${rigId}`}
>
<span>{podCount !== undefined ? `${podCount} pods` : ""}</span>
{podCount !== undefined ? <span>·</span> : null}
<span>{nodeCount} agents</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
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+. */}
<Handle type="target" position={Position.Top} className="opacity-0 pointer-events-none" />
<Handle type="source" position={Position.Bottom} className="opacity-0 pointer-events-none" />
</div>
);
}
@@ -31,6 +31,10 @@ import { useTopologyOverlay } from "./topology-overlay-context.js";
// degrades to table view by default on mobile (graph is too dense for
// phone screens)").
import { useShellViewport } from "../../hooks/useShellViewport.js";
// V1 polish slice Phase 5.2: HostScopePage graph view-mode replaces
// the prior placeholder with the multi-rig single-canvas component
// (rig-collapse affordance; default-all-collapsed; auto-expand on URL).
import { HostMultiRigGraph } from "./HostMultiRigGraph.js";
/** Set the AppShell's Explorer overlay mode based on the scope page's
* active view-mode. Graph view-mode → overlay (vellum-translucent
@@ -108,18 +112,8 @@ export function HostScopePage() {
tabsNav={<TopologyViewModeTabs tabs={HOST_SCOPE_TABS} active={active} onSelect={setActive} testIdPrefix="topology-host" />}
>
{effectiveActive === "graph" ? (
<div className="p-6">
<EmptyState
label="HOST GRAPH"
description="Multi-rig graph view at host scope; click a rig to drill in."
variant="card"
testId="topology-host-graph-placeholder"
action={
rigs && rigs[0]
? { label: `Open ${rigs[0].name}`, href: `/topology/rig/${rigs[0].id}` }
: undefined
}
/>
<div className="flex-1 min-h-0 relative">
<HostMultiRigGraph />
</div>
) : null}
{effectiveActive === "table" ? (
+136
View File
@@ -0,0 +1,136 @@
// V1 polish slice Phase 5.2 P5.2-3 + P5.2-6 — multi-rig canvas helpers.
//
// Two responsibilities:
// 1. prefixRigData(rigId, nodes, edges): cross-rig node-ID prefixing
// so the merged canvas satisfies react-flow's unique-ID requirement.
// Each prefixed node carries `data.rigId` so downstream click
// handlers read rigId from `node.data.rigId` (NOT closure).
// 2. packRigGroups(rigBounds, viewportWidth): outer offset packing.
// Per-rig layout already happened via applyTreeLayout (or fixed
// collapsed-card dimensions when not yet expanded); this helper
// places each rig group at a grid offset in the host canvas.
//
// Layout strategy = option (a) per-rig + outer offset (per Phase 5.2
// ACK §2). Rationale: collapse stability — toggling rig N doesn't
// reflow rigs 1..N-1 because each rig's internal layout is independent
// and its outer offset is fixed by its grid slot.
const PREFIX_DELIMITER = "::";
export const COLLAPSED_RIG_WIDTH = 280;
export const COLLAPSED_RIG_HEIGHT = 120;
const RIG_GUTTER_X = 48;
const RIG_GUTTER_Y = 48;
/** Extra height the rig group adds above its expanded children
* (header + counts strip + padding). Used by expanded-bounds calc. */
export const RIG_HEADER_HEIGHT = 60;
export const RIG_PADDING = 16;
/** Prefix every node ID + edge endpoint with `${rigId}::` so the merged
* multi-rig graph has globally-unique IDs. Threads `data.rigId` onto
* every node so click handlers can read it without closure capture. */
export function prefixRigData<
N extends { id: string; data?: Record<string, unknown> },
E extends { id: string; source: string; target: string },
>(
rigId: string,
nodes: readonly N[],
edges: readonly E[],
): { nodes: N[]; edges: E[] } {
const prefixed = (id: string) => `${rigId}${PREFIX_DELIMITER}${id}`;
return {
nodes: nodes.map((n) => ({
...n,
id: prefixed(n.id),
// Some node shapes carry parentId for react-flow parent/child;
// prefix that too if present.
...((n as unknown as { parentId?: string }).parentId
? { parentId: prefixed((n as unknown as { parentId: string }).parentId) }
: {}),
data: { ...(n.data ?? {}), rigId },
})),
edges: edges.map((e) => ({
...e,
id: prefixed(e.id),
source: prefixed(e.source),
target: prefixed(e.target),
})),
};
}
/** Compute the bounding box of a set of laid-out nodes (per-rig
* expanded children) so the outer rig group can size correctly.
* Returns rig-internal-relative coordinates (the top-left node at
* origin); the outer offset is added by packRigGroups. */
export function computeBounds(
nodes: ReadonlyArray<{ position: { x: number; y: number }; initialWidth?: number; initialHeight?: number }>,
): { width: number; height: number; minX: number; minY: number } {
if (nodes.length === 0) {
return { width: COLLAPSED_RIG_WIDTH, height: COLLAPSED_RIG_HEIGHT, minX: 0, minY: 0 };
}
let minX = Infinity;
let minY = Infinity;
let maxX = -Infinity;
let maxY = -Infinity;
for (const n of nodes) {
const w = n.initialWidth ?? 240;
const h = n.initialHeight ?? 160;
minX = Math.min(minX, n.position.x);
minY = Math.min(minY, n.position.y);
maxX = Math.max(maxX, n.position.x + w);
maxY = Math.max(maxY, n.position.y + h);
}
return {
width: Math.max(maxX - minX + 2 * RIG_PADDING, COLLAPSED_RIG_WIDTH),
height: maxY - minY + 2 * RIG_PADDING + RIG_HEADER_HEIGHT,
minX,
minY,
};
}
export interface RigBounds {
rigId: string;
width: number;
height: number;
}
export interface PackedRig {
rigId: string;
offsetX: number;
offsetY: number;
width: number;
height: number;
}
/** Pack rig groups in a grid given a viewport width. Each rig keeps its
* own (possibly-different-sized) bounds; the grid uses a max-width
* column tracker per row so wider rigs don't squeeze narrower ones.
* Simple greedy row-fill — sufficient for V1 fleet sizes. */
export function packRigGroups(
rigs: readonly RigBounds[],
viewportWidth: number,
): PackedRig[] {
const packed: PackedRig[] = [];
const minViewport = Math.max(viewportWidth, COLLAPSED_RIG_WIDTH + 2 * RIG_GUTTER_X);
let cursorX = 0;
let cursorY = 0;
let rowMaxHeight = 0;
for (const rig of rigs) {
if (cursorX + rig.width > minViewport && cursorX > 0) {
// Wrap to next row.
cursorX = 0;
cursorY += rowMaxHeight + RIG_GUTTER_Y;
rowMaxHeight = 0;
}
packed.push({
rigId: rig.rigId,
offsetX: cursorX,
offsetY: cursorY,
width: rig.width,
height: rig.height,
});
cursorX += rig.width + RIG_GUTTER_X;
rowMaxHeight = Math.max(rowMaxHeight, rig.height);
}
return packed;
}
@@ -0,0 +1,373 @@
// V1 polish slice Phase 5.2 — multi-rig single-canvas /topology graph
// regression guard. Covers ritual #6 (HostMultiRigGraph reachability)
// + ritual #8 (no .map(useRigGraph) rules-of-hooks anti-pattern) +
// ritual #9 (coupled-literal scan for default-collapsed state across
// HostMultiRigGraph + RigGroupNode + multi-rig-layout).
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
import { render, fireEvent, cleanup, waitFor } from "@testing-library/react";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import {
createMemoryHistory,
createRootRoute,
createRoute,
createRouter,
Outlet,
RouterProvider,
} from "@tanstack/react-router";
import { readFileSync } from "node:fs";
import path from "node:path";
const navigateSpy = vi.fn();
vi.mock("@tanstack/react-router", async (importActual) => {
const actual = await importActual<typeof import("@tanstack/react-router")>();
return {
...actual,
useNavigate: () => navigateSpy,
};
});
import { HostMultiRigGraph } from "../src/components/topology/HostMultiRigGraph.js";
import {
prefixRigData,
packRigGroups,
computeBounds,
COLLAPSED_RIG_WIDTH,
COLLAPSED_RIG_HEIGHT,
} from "../src/lib/multi-rig-layout.js";
const mockFetch = vi.fn();
globalThis.fetch = mockFetch;
beforeEach(() => {
navigateSpy.mockClear();
mockFetch.mockReset();
});
afterEach(() => {
cleanup();
});
function withQueryClient(ui: React.ReactNode) {
const queryClient = new QueryClient({ defaultOptions: { queries: { retry: false } } });
// Wrap in TanStack Router with memory history so <Link> resolves the
// route context without crashing inside RigGroupNode.
const rootRoute = createRootRoute({ component: () => <Outlet /> });
const indexRoute = createRoute({
getParentRoute: () => rootRoute,
path: "/",
component: () => <>{ui}</>,
});
const fallbackRoute = createRoute({
getParentRoute: () => rootRoute,
path: "$",
component: () => null,
});
const router = createRouter({
routeTree: rootRoute.addChildren([indexRoute, fallbackRoute]),
history: createMemoryHistory({ initialEntries: ["/"] }),
});
return render(
<QueryClientProvider client={queryClient}>
<RouterProvider router={router} />
</QueryClientProvider>,
);
}
const PS_RESPONSE = [
{
rigId: "rig-1",
name: "openrig-velocity",
nodeCount: 13,
runningCount: 9,
status: "running",
uptime: null,
latestSnapshot: null,
},
{
rigId: "rig-2",
name: "openrig-discovery",
nodeCount: 5,
runningCount: 3,
status: "partial",
uptime: null,
latestSnapshot: null,
},
{
rigId: "rig-3",
name: "openrig-product-lab",
nodeCount: 0,
runningCount: 0,
status: "stopped",
uptime: null,
latestSnapshot: null,
},
];
function setupFetchOk(opts: {
ps?: typeof PS_RESPONSE;
graphsByRigId?: Record<string, { nodes: unknown[]; edges: unknown[] }>;
}) {
mockFetch.mockImplementation(async (url: string) => {
if (url === "/api/ps") {
return new Response(JSON.stringify(opts.ps ?? PS_RESPONSE));
}
const m = url.match(/\/api\/rigs\/([^/]+)\/graph/);
if (m) {
const rigId = decodeURIComponent(m[1]!);
return new Response(
JSON.stringify(opts.graphsByRigId?.[rigId] ?? { nodes: [], edges: [] }),
);
}
return new Response("[]");
});
}
// ----------------------------------------------------------------------
// multi-rig-layout helpers — pure-fn tests
// ----------------------------------------------------------------------
describe("multi-rig-layout: prefixRigData (P5.2-3 cross-rig prefixing)", () => {
it("prefixes node IDs with `${rigId}::` and threads data.rigId", () => {
const { nodes, edges } = prefixRigData(
"rig-1",
[
{ id: "n1", data: {} },
{ id: "n2", data: { foo: "bar" } },
],
[{ id: "e1", source: "n1", target: "n2" }],
);
expect(nodes[0]!.id).toBe("rig-1::n1");
expect(nodes[1]!.id).toBe("rig-1::n2");
expect(nodes[0]!.data?.rigId).toBe("rig-1");
expect(nodes[1]!.data?.foo).toBe("bar");
expect(nodes[1]!.data?.rigId).toBe("rig-1");
expect(edges[0]!.id).toBe("rig-1::e1");
expect(edges[0]!.source).toBe("rig-1::n1");
expect(edges[0]!.target).toBe("rig-1::n2");
});
it("two rigs with overlapping internal IDs have NO collision after prefixing (ritual #9)", () => {
const r1 = prefixRigData(
"rig-A",
[{ id: "orchestrator", data: {} }, { id: "review", data: {} }],
[],
);
const r2 = prefixRigData(
"rig-B",
[{ id: "orchestrator", data: {} }, { id: "review", data: {} }],
[],
);
const allIds = new Set([...r1.nodes.map((n) => n.id), ...r2.nodes.map((n) => n.id)]);
expect(allIds.size).toBe(4); // No collisions despite same internal IDs.
expect(allIds.has("rig-A::orchestrator")).toBe(true);
expect(allIds.has("rig-B::orchestrator")).toBe(true);
});
it("preserves and prefixes parentId for react-flow parent/child nodes", () => {
const { nodes } = prefixRigData(
"rig-A",
[
{ id: "podGroup-1", data: {} },
{ id: "agent-1", data: {}, parentId: "podGroup-1" } as any,
],
[],
);
expect((nodes[1] as { parentId?: string }).parentId).toBe("rig-A::podGroup-1");
});
});
describe("multi-rig-layout: packRigGroups (P5.2-6 outer offset packing)", () => {
it("places rigs in a row when viewport is wide enough", () => {
const packed = packRigGroups(
[
{ rigId: "rig-1", width: 300, height: 120 },
{ rigId: "rig-2", width: 300, height: 120 },
],
1024,
);
expect(packed[0]!.offsetX).toBe(0);
expect(packed[0]!.offsetY).toBe(0);
expect(packed[1]!.offsetX).toBeGreaterThan(0);
expect(packed[1]!.offsetY).toBe(0);
});
it("wraps to next row when total row width exceeds viewport", () => {
const packed = packRigGroups(
[
{ rigId: "rig-1", width: 600, height: 120 },
{ rigId: "rig-2", width: 600, height: 120 },
{ rigId: "rig-3", width: 600, height: 120 },
],
800,
);
// First fits at row 0; second wraps; third also wraps.
expect(packed[0]!.offsetY).toBe(0);
expect(packed[1]!.offsetY).toBeGreaterThan(0);
});
it("returns empty array for zero rigs", () => {
expect(packRigGroups([], 1024)).toEqual([]);
});
});
describe("multi-rig-layout: computeBounds", () => {
it("returns collapsed-card dimensions for empty node list", () => {
const b = computeBounds([]);
expect(b.width).toBe(COLLAPSED_RIG_WIDTH);
expect(b.height).toBe(COLLAPSED_RIG_HEIGHT);
});
it("computes bounding box covering all positioned nodes", () => {
const b = computeBounds([
{ position: { x: 10, y: 10 }, initialWidth: 100, initialHeight: 50 },
{ position: { x: 200, y: 100 }, initialWidth: 100, initialHeight: 50 },
]);
// Width covers minX=10 → maxX=300 → 290 + 2 * 16 padding = 322
expect(b.width).toBeGreaterThanOrEqual(290);
expect(b.height).toBeGreaterThan(140);
});
});
// ----------------------------------------------------------------------
// HostMultiRigGraph component — mounting + click contract + collapse
// ----------------------------------------------------------------------
describe("HostMultiRigGraph (P5.2-1 reachability — ritual #6)", () => {
it("renders one rigGroup node per rig from /api/ps; default ALL collapsed", async () => {
setupFetchOk({});
const { findByTestId } = withQueryClient(<HostMultiRigGraph />);
expect(await findByTestId("host-multi-rig-graph")).toBeTruthy();
// Each rig surfaces its rigGroup node.
expect(await findByTestId("rig-group-node-rig-1")).toBeTruthy();
expect(await findByTestId("rig-group-node-rig-2")).toBeTruthy();
expect(await findByTestId("rig-group-node-rig-3")).toBeTruthy();
// All collapsed by default (P5.2-5).
expect(
(await findByTestId("rig-group-node-rig-1")).getAttribute("data-collapsed"),
).toBe("true");
expect(
(await findByTestId("rig-group-node-rig-2")).getAttribute("data-collapsed"),
).toBe("true");
});
it("rig group body click toggles collapse state (P5.2-5)", async () => {
setupFetchOk({
graphsByRigId: {
"rig-1": { nodes: [], edges: [] },
},
});
const { findByTestId } = withQueryClient(<HostMultiRigGraph />);
const node = await findByTestId("rig-group-node-rig-1");
expect(node.getAttribute("data-collapsed")).toBe("true");
fireEvent.click(node);
await waitFor(() => {
expect(
(
document.querySelector(
"[data-testid='rig-group-node-rig-1']",
) as HTMLElement
).getAttribute("data-collapsed"),
).toBe("false");
});
// Re-click → collapsed again.
fireEvent.click(
document.querySelector("[data-testid='rig-group-node-rig-1']") as HTMLElement,
);
await waitFor(() => {
expect(
(
document.querySelector(
"[data-testid='rig-group-node-rig-1']",
) as HTMLElement
).getAttribute("data-collapsed"),
).toBe("true");
});
});
it("drill-in arrow Link is rendered separately from rig body (source contract)", async () => {
setupFetchOk({});
const { findByTestId } = withQueryClient(<HostMultiRigGraph />);
const drill = await findByTestId("rig-group-drill-rig-1");
// The drill Link element exists distinct from the rig body click target.
// Its onClick stopPropagation contract is verified by source-assertion
// (RigGroupNode.tsx contains `e.stopPropagation()` inside the Link
// onClick) per the bottom-of-file ritual #9 source-assertion guard.
expect(drill).toBeTruthy();
expect(drill.tagName).toBe("A");
// Rig body remains collapsed (clicking the drill link, even if its
// onClick doesn't fire in jsdom, must not bubble to the body's
// toggle handler — verified visually + via stopPropagation source).
expect(
(await findByTestId("rig-group-node-rig-1")).getAttribute("data-collapsed"),
).toBe("true");
});
it("empty rig list renders honest empty-state (no /api/rigs/.../graph fetches)", async () => {
setupFetchOk({ ps: [] });
const { findByTestId } = withQueryClient(<HostMultiRigGraph />);
expect(await findByTestId("host-multi-rig-graph-empty")).toBeTruthy();
});
});
// ----------------------------------------------------------------------
// Source-assertion guards (rituals #8 + #9)
// ----------------------------------------------------------------------
describe("source-assertion guards", () => {
const SRC = path.resolve(__dirname, "../src");
it("HostMultiRigGraph uses useQueries — NOT .map(useRigGraph) (ritual #8)", () => {
const src = readFileSync(
path.join(SRC, "components/topology/HostMultiRigGraph.tsx"),
"utf8",
);
// Positive-assertion: useQueries imported + called. Substring matches
// are robust to comment density (don't strip comments here — the file
// has heavy header comments that confuse line-comment regexes; the
// direct import line and call site survive verbatim either way).
expect(src).toContain('import { useQueries } from "@tanstack/react-query"');
expect(src).toContain("useQueries(");
// Negative-assertion ritual #8: no .map((r) => useRigGraph(r.id))
// anti-pattern (the rules-of-hooks violation P0-1 was caught for in
// TopologyTableView). Match against full source — comments don't
// contain that exact pattern.
expect(src).not.toMatch(/\.map\(\s*\([^)]*\)\s*=>\s*useRigGraph/);
// No useNodeSelection (Phase 5.1 retired the alias).
expect(src).not.toMatch(/\buseNodeSelection\s*\(/);
});
it("RigGroupNode Link onClick contains e.stopPropagation() (ritual #9)", () => {
const src = readFileSync(
path.join(SRC, "components/topology/RigGroupNode.tsx"),
"utf8",
);
// Drill-in Link must stop propagation so its click navigates without
// also firing the rig body's onToggle handler.
expect(src).toContain("e.stopPropagation()");
});
it("HostMultiRigGraph default state is all-collapsed (ritual #9 coupled-literal)", () => {
const src = readFileSync(
path.join(SRC, "components/topology/HostMultiRigGraph.tsx"),
"utf8",
);
// useState<Map<string, boolean>>(() => new Map()) → empty map → no
// rig is in the map → expanded.get(rigId) returns undefined → ?? false.
expect(src).toMatch(/useState<Map<string, boolean>>\s*\(\s*\(\s*\)\s*=>\s*new Map\(\)\s*\)/);
// collapsed: !p.isExpanded — the default-collapse semantic carrier.
expect(src).toMatch(/collapsed:\s*!p\.isExpanded/);
});
it("RigGroupNode uses 1px outline-variant border + hard-shadow + RegistrationMarks", () => {
const src = readFileSync(
path.join(SRC, "components/topology/RigGroupNode.tsx"),
"utf8",
);
expect(src).toMatch(/border\s+border-outline-variant/);
expect(src).toMatch(/hard-shadow/);
expect(src).toMatch(/RegistrationMarks/);
// Drill-in Link present (separate from body click).
expect(src).toMatch(/to=["']\/topology\/rig\/\$rigId["']/);
});
});
+16 -1
View File
@@ -146,13 +146,28 @@ describe("Topology graph degradation P5-9 (universal-shell.md L143)", () => {
// The table view-mode is what actually mounts (graph degraded).
expect(await findByTestId("topology-mobile-graph-degraded")).toBeTruthy();
// graph placeholder is NOT rendered at mobile.
// V1 polish slice Phase 5.2: host graph placeholder REPLACED with
// HostMultiRigGraph; the placeholder testid no longer exists at any
// viewport, so this negative assertion remains structurally correct
// (mobile path renders table view, not the multi-rig canvas).
expect(container.querySelector("[data-testid='topology-host-graph-placeholder']")).toBeNull();
expect(container.querySelector("[data-testid='host-multi-rig-graph']")).toBeNull();
});
it("at >= lg viewport, /topology graph view-mode renders the graph (no degradation hint)", async () => {
const { findByTestId, container } = await renderAt("/topology", 1440);
// Default tab is graph; placeholder visible.
expect(await findByTestId("topology-host-graph-placeholder")).toBeTruthy();
// V1 polish slice Phase 5.2: HostMultiRigGraph replaces the prior
// placeholder card at host scope graph view-mode. At >= lg viewport
// either the canvas mounts (with rigs) or the empty-state mounts
// (mock returns []). Either is acceptable proof that the desktop
// path is NOT degraded to the table; <lg path would have rendered
// the table degradation hint.
const desktopMount = await waitFor(() =>
container.querySelector("[data-testid='host-multi-rig-graph']") ??
container.querySelector("[data-testid='host-multi-rig-graph-empty']"),
);
expect(desktopMount).toBeTruthy();
expect(container.querySelector("[data-testid='topology-mobile-graph-degraded']")).toBeNull();
});
});