polish-3 expand topology by default

This commit is contained in:
mvschwarz committed 2026-05-07 00:17:12 -07:00
1 parent 0aa7960215
commit 8cc36e9ee4
4 files changed
+179 -42

No files matched your search

@@ -6,24 +6,27 @@
// 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):
// Default state: rigs expanded so the topology opens as the full fleet
// canvas. Explicit collapse state is still persisted in TopologyOverlayProvider,
// and the operator can collapse / expand every rig from the canvas controls.
// 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 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,
// 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.
// only when rig is expanded). The V1 operator default now intentionally
// fetches expanded rigs up front; explicit Collapse All restores the prior
// low-fan-out behavior.
import { useMemo } from "react";
import { useCallback, useEffect, useMemo, useRef } from "react";
import { useNavigate } from "@tanstack/react-router";
import {
ReactFlow,
Controls,
Panel,
useReactFlow,
type Node,
type Edge,
type NodeTypes,
@@ -32,6 +35,7 @@ import {
} from "@xyflow/react";
import "@xyflow/react/dist/style.css";
import { useQueries } from "@tanstack/react-query";
import { Maximize2, Minimize2 } from "lucide-react";
import { usePsEntries } from "../../hooks/usePsEntries.js";
import { RigGroupNode, type RigGroupNodeData } from "./RigGroupNode.js";
import { HybridAgentNode, HybridPodGroupNode } from "./HybridTopologyNodes.js";
@@ -72,6 +76,11 @@ const edgeTypes: EdgeTypes = {
hotPotato: HotPotatoEdge,
};
const DEFAULT_RIG_EXPANDED = true;
const HOST_GRAPH_MIN_ZOOM = 0.03;
const HOST_GRAPH_MAX_ZOOM = 2;
const HOST_GRAPH_FIT_PADDING = 0.08;
export function HostMultiRigGraph() {
const navigate = useNavigate();
const { data: psEntries } = usePsEntries();
@@ -84,7 +93,14 @@ export function HostMultiRigGraph() {
// via the provider's auto-expand useEffect; when the operator returns
// to /topology, this component reads the persisted state via
// useTopologyOverlay() and renders the matching rig expanded.
const { expandedRigs: expanded, toggleRig } = useTopologyOverlay();
const { expandedRigs: expanded, setRigExpanded } = useTopologyOverlay();
const isRigExpanded = useCallback(
(rigId: string) => expanded.get(rigId) ?? DEFAULT_RIG_EXPANDED,
[expanded],
);
const toggleRig = useCallback((rigId: string) => {
setRigExpanded(rigId, !isRigExpanded(rigId));
}, [isRigExpanded, setRigExpanded]);
// 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
@@ -95,10 +111,20 @@ export function HostMultiRigGraph() {
queries: rigList.map((rig) => ({
queryKey: ["rig", rig.rigId, "graph"] as const,
queryFn: () => fetchGraph(rig.rigId),
enabled: expanded.get(rig.rigId) ?? false,
enabled: isRigExpanded(rig.rigId),
refetchInterval: 30_000,
})),
});
const expandedCount = useMemo(
() => rigList.filter((rig) => isRigExpanded(rig.rigId)).length,
[rigList, isRigExpanded],
);
const expandAllRigs = useCallback(() => {
for (const rig of rigList) setRigExpanded(rig.rigId, true);
}, [rigList, setRigExpanded]);
const collapseAllRigs = useCallback(() => {
for (const rig of rigList) setRigExpanded(rig.rigId, false);
}, [rigList, setRigExpanded]);
// Build per-rig nested subgraphs and lay out rig frames on the host canvas.
const { mergedNodes, mergedEdges } = useMemo(() => {
@@ -121,7 +147,7 @@ export function HostMultiRigGraph() {
for (let i = 0; i < rigList.length; i++) {
const rig = rigList[i]!;
const isExpanded = expanded.get(rig.rigId) ?? false;
const isExpanded = isRigExpanded(rig.rigId);
const queryResult = graphQueries[i];
let childNodes: Node[] = [];
@@ -200,7 +226,7 @@ export function HostMultiRigGraph() {
// toggleRig is stable per-render but useMemo doesn't know; safe to
// include rigList + expanded + graphQueries as deps.
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [rigList, expanded, graphQueries]);
}, [rigList, isRigExpanded, graphQueries]);
const sessionIndex = useMemo(() => buildTopologySessionIndex(
mergedNodes
@@ -258,6 +284,17 @@ export function HostMultiRigGraph() {
() => applyHotPotatoEdges(mergedEdges, topologyActivity.packets, { reducedMotion }),
[mergedEdges, topologyActivity.packets, reducedMotion],
);
const layoutSignature = useMemo(() => mergedNodes.map((node) => {
const style = node.style as { width?: string | number; height?: string | number } | undefined;
return [
node.id,
Math.round(node.position.x),
Math.round(node.position.y),
style?.width ?? "",
style?.height ?? "",
node.parentId ?? "",
].join(":");
}).join("|"), [mergedNodes]);
// P5.2-7 click handlers: agent -> seat URL; pod group -> pod URL;
// rig group -> toggle (handled inside RigGroupNode onClick; this
@@ -308,9 +345,38 @@ export function HostMultiRigGraph() {
onNodeClick={onNodeClick}
nodesDraggable={false}
fitView
fitViewOptions={{ padding: 0.15, includeHiddenNodes: false }}
fitViewOptions={{ padding: HOST_GRAPH_FIT_PADDING, includeHiddenNodes: false }}
minZoom={HOST_GRAPH_MIN_ZOOM}
maxZoom={HOST_GRAPH_MAX_ZOOM}
proOptions={{ hideAttribution: true }}
>
<HostGraphAutoFit layoutSignature={layoutSignature} />
<Panel position="top-right" className="!m-3">
<div className="flex items-center gap-1 border border-outline-variant bg-background/80 px-1.5 py-1 shadow-[2px_2px_0_rgba(46,52,46,0.10)] backdrop-blur-sm">
<button
type="button"
data-testid="topology-expand-all-rigs"
onClick={expandAllRigs}
disabled={expandedCount === rigList.length}
title="Expand all rigs"
className="inline-flex h-7 items-center gap-1 border border-transparent px-2 font-mono text-[9px] uppercase tracking-[0.08em] text-stone-700 hover:border-outline-variant hover:bg-white/70 hover:text-stone-950 disabled:pointer-events-none disabled:opacity-35"
>
<Maximize2 className="h-3.5 w-3.5" aria-hidden="true" />
Expand all
</button>
<button
type="button"
data-testid="topology-collapse-all-rigs"
onClick={collapseAllRigs}
disabled={expandedCount === 0}
title="Collapse all rigs"
className="inline-flex h-7 items-center gap-1 border border-transparent px-2 font-mono text-[9px] uppercase tracking-[0.08em] text-stone-700 hover:border-outline-variant hover:bg-white/70 hover:text-stone-950 disabled:pointer-events-none disabled:opacity-35"
>
<Minimize2 className="h-3.5 w-3.5" aria-hidden="true" />
Collapse all
</button>
</div>
</Panel>
<Controls
position="bottom-right"
showInteractive={false}
@@ -320,3 +386,23 @@ export function HostMultiRigGraph() {
</div>
);
}
function HostGraphAutoFit({ layoutSignature }: { layoutSignature: string }) {
const { fitView } = useReactFlow();
const lastSignatureRef = useRef<string | null>(null);
useEffect(() => {
if (!layoutSignature || lastSignatureRef.current === layoutSignature) return;
lastSignatureRef.current = layoutSignature;
const timer = window.setTimeout(() => {
void fitView({
padding: HOST_GRAPH_FIT_PADDING,
includeHiddenNodes: false,
duration: 250,
});
}, 50);
return () => window.clearTimeout(timer);
}, [fitView, layoutSignature]);
return null;
}
@@ -94,7 +94,7 @@ export function TopologyOverlayProvider({ children }: { children: ReactNode }) {
);
const setRigExpanded = useCallback((rigId: string, expanded: boolean) => {
setExpandedRigs((prev) => {
if ((prev.get(rigId) ?? false) === expanded) return prev;
if (prev.has(rigId) && prev.get(rigId) === expanded) return prev;
const next = new Map(prev);
next.set(rigId, expanded);
return next;
@@ -223,7 +223,7 @@ describe("TopologyOverlayProvider auto-expand on URL (P5.2 bounce-fix)", () => {
});
});
it("/topology root pathname does NOT auto-expand any rig (default-all-collapsed preserved)", async () => {
it("/topology root pathname does NOT write explicit expansion overrides", async () => {
const { findByTestId } = renderAt("/topology", <ContextProbe />);
const probe = await findByTestId("topology-context-probe");
// Allow the render to settle.
@@ -237,38 +237,37 @@ describe("TopologyOverlayProvider auto-expand on URL (P5.2 bounce-fix)", () => {
// ---------------------------------------------------------------------
describe("HostMultiRigGraph reads expandedRigs from context (P5.2 bounce-fix persistence)", () => {
it("when context's expandedRigs has rigId=true on mount, the rig group renders expanded", async () => {
// Custom probe that pre-sets the context state then mounts the graph.
function PreExpandedHarness() {
it("when context has rigId=false on mount, only that rig renders collapsed", async () => {
// Custom probe that pre-sets an explicit collapse then mounts the graph.
function PreCollapsedHarness() {
const { setRigExpanded } = useTopologyOverlay();
// Fire the pre-expand once on mount (simulates a prior visit to
// /topology/rig/rig-1 having set state, then user returns to
// /topology and HostMultiRigGraph mounts fresh).
// Fire the pre-collapse once on mount (simulates the operator
// collapsing a rig, navigating away, then returning to /topology).
// useEffect intentionally not used here — the test just calls in
// an effect via setTimeout so React batches commit before render.
if (typeof window !== "undefined") {
// Idempotent: subsequent renders won't double-set.
queueMicrotask(() => setRigExpanded("rig-1", true));
queueMicrotask(() => setRigExpanded("rig-2", false));
}
return <HostMultiRigGraph />;
}
const { findByTestId } = renderAt("/topology", <PreExpandedHarness />);
const node = await findByTestId("rig-group-node-rig-1");
const { findByTestId } = renderAt("/topology", <PreCollapsedHarness />);
const node = await findByTestId("rig-group-node-rig-2");
await waitFor(() => {
expect(node.getAttribute("data-collapsed")).toBe("false");
expect(node.getAttribute("data-collapsed")).toBe("true");
});
// Other rig stays collapsed.
const otherRig = await findByTestId("rig-group-node-rig-2");
expect(otherRig.getAttribute("data-collapsed")).toBe("true");
// Other rig remains expanded by default.
const otherRig = await findByTestId("rig-group-node-rig-1");
expect(otherRig.getAttribute("data-collapsed")).toBe("false");
});
it("when no rig is pre-expanded in context, all rigs render collapsed (default preserved)", async () => {
it("when no rig has an explicit context override, all rigs render expanded", async () => {
const { findByTestId } = renderAt("/topology", <HostMultiRigGraph />);
const r1 = await findByTestId("rig-group-node-rig-1");
const r2 = await findByTestId("rig-group-node-rig-2");
expect(r1.getAttribute("data-collapsed")).toBe("true");
expect(r2.getAttribute("data-collapsed")).toBe("true");
expect(r1.getAttribute("data-collapsed")).toBe("false");
expect(r2.getAttribute("data-collapsed")).toBe("false");
});
});
@@ -1,7 +1,7 @@
// 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
// ritual #9 (coupled-literal scan for default-expanded state across
// HostMultiRigGraph + RigGroupNode + multi-rig-layout).
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
@@ -244,7 +244,7 @@ describe("multi-rig-layout: computeBounds", () => {
// ----------------------------------------------------------------------
describe("HostMultiRigGraph (P5.2-1 reachability — ritual #6)", () => {
it("renders one rigGroup node per rig from /api/ps; default ALL collapsed", async () => {
it("renders one rigGroup node per rig from /api/ps; default ALL expanded", async () => {
setupFetchOk({});
const { findByTestId } = withQueryClient(<HostMultiRigGraph />);
expect(await findByTestId("host-multi-rig-graph")).toBeTruthy();
@@ -252,13 +252,18 @@ describe("HostMultiRigGraph (P5.2-1 reachability — ritual #6)", () => {
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).
// All expanded by default so the fleet canvas opens fully.
expect(
(await findByTestId("rig-group-node-rig-1")).getAttribute("data-collapsed"),
).toBe("true");
).toBe("false");
expect(
(await findByTestId("rig-group-node-rig-2")).getAttribute("data-collapsed"),
).toBe("true");
).toBe("false");
await waitFor(() => {
expect(mockFetch).toHaveBeenCalledWith("/api/rigs/rig-1/graph");
expect(mockFetch).toHaveBeenCalledWith("/api/rigs/rig-2/graph");
expect(mockFetch).toHaveBeenCalledWith("/api/rigs/rig-3/graph");
});
});
it("rig group body click toggles collapse state (P5.2-5)", async () => {
@@ -269,7 +274,7 @@ describe("HostMultiRigGraph (P5.2-1 reachability — ritual #6)", () => {
});
const { findByTestId } = withQueryClient(<HostMultiRigGraph />);
const node = await findByTestId("rig-group-node-rig-1");
expect(node.getAttribute("data-collapsed")).toBe("true");
expect(node.getAttribute("data-collapsed")).toBe("false");
fireEvent.click(node);
await waitFor(() => {
expect(
@@ -278,12 +283,31 @@ describe("HostMultiRigGraph (P5.2-1 reachability — ritual #6)", () => {
"[data-testid='rig-group-node-rig-1']",
) as HTMLElement
).getAttribute("data-collapsed"),
).toBe("false");
).toBe("true");
});
// Re-click → collapsed again.
// Re-click expands 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("false");
});
});
it("canvas controls collapse and expand every rig", async () => {
setupFetchOk({});
const { findByTestId } = withQueryClient(<HostMultiRigGraph />);
const collapseAll = await findByTestId("topology-collapse-all-rigs");
const expandAll = await findByTestId("topology-expand-all-rigs");
expect((await findByTestId("rig-group-node-rig-1")).getAttribute("data-collapsed")).toBe("false");
fireEvent.click(collapseAll);
await waitFor(() => {
expect(
(
@@ -292,6 +316,31 @@ describe("HostMultiRigGraph (P5.2-1 reachability — ritual #6)", () => {
) as HTMLElement
).getAttribute("data-collapsed"),
).toBe("true");
expect(
(
document.querySelector(
"[data-testid='rig-group-node-rig-2']",
) as HTMLElement
).getAttribute("data-collapsed"),
).toBe("true");
});
fireEvent.click(expandAll);
await waitFor(() => {
expect(
(
document.querySelector(
"[data-testid='rig-group-node-rig-1']",
) as HTMLElement
).getAttribute("data-collapsed"),
).toBe("false");
expect(
(
document.querySelector(
"[data-testid='rig-group-node-rig-2']",
) as HTMLElement
).getAttribute("data-collapsed"),
).toBe("false");
});
});
@@ -305,12 +354,12 @@ describe("HostMultiRigGraph (P5.2-1 reachability — ritual #6)", () => {
// 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
// Rig body remains expanded (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");
).toBe("false");
});
it("empty rig list renders honest empty-state (no /api/rigs/.../graph fetches)", async () => {
@@ -357,7 +406,7 @@ describe("source-assertion guards", () => {
expect(src).toContain("e.stopPropagation()");
});
it("HostMultiRigGraph default state is all-collapsed (ritual #9 coupled-literal)", () => {
it("HostMultiRigGraph default state is all-expanded (ritual #9 coupled-literal)", () => {
const ctxSrc = readFileSync(
path.join(SRC, "components/topology/topology-overlay-context.tsx"),
"utf8",
@@ -374,7 +423,10 @@ describe("source-assertion guards", () => {
path.join(SRC, "components/topology/HostMultiRigGraph.tsx"),
"utf8",
);
// collapsed: !p.isExpanded — the default-collapse semantic carrier
expect(hostSrc).toContain("const DEFAULT_RIG_EXPANDED = true");
expect(hostSrc).toContain("const HOST_GRAPH_MIN_ZOOM = 0.03");
expect(hostSrc).toContain("HostGraphAutoFit");
// collapsed: !p.isExpanded — the expanded/collapsed semantic carrier
// (still in the host component; reads from the lifted context).
expect(hostSrc).toMatch(/collapsed:\s*!p\.isExpanded/);
});