mirror of
https://github.com/mvschwarz/openrig.git
synced 2026-11-08 17:31:56 -10:00
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:
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" ? (
|
||||
|
||||
@@ -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["']/);
|
||||
});
|
||||
});
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
Reference in new issue
Block a user