mirror of
https://github.com/mvschwarz/openrig.git
synced 2026-11-08 17:31:56 -10:00
polish-3 expand topology by default
This commit is contained in:
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/);
|
||||
});
|
||||
|
||||
Reference in new issue
Block a user