mirror of
https://github.com/mvschwarz/openrig.git
synced 2026-11-08 17:31:56 -10:00
polish-3 topology activity flow
This commit is contained in:
1 parent
2ea5748c31
commit
0aa7960215
17 files changed
+1425
-105
No files matched your search
@@ -1,18 +1,25 @@
|
||||
import { useMemo, useCallback, useState, useRef, useEffect } from "react";
|
||||
import { useNavigate } from "@tanstack/react-router";
|
||||
import { ReactFlow, Controls, Handle, Position, type NodeTypes, type Node, type Edge, type NodeMouseHandler } from "@xyflow/react";
|
||||
import { ReactFlow, Controls, Handle, Position, type NodeTypes, type EdgeTypes, type Node, type Edge, type NodeMouseHandler } from "@xyflow/react";
|
||||
import "@xyflow/react/dist/style.css";
|
||||
import { useRigGraph } from "../hooks/useRigGraph.js";
|
||||
import { useRigEvents } from "../hooks/useRigEvents.js";
|
||||
import { useTopologyEdgeActivity } from "../hooks/useTopologyEdgeActivity.js";
|
||||
import { useDiscoveredSessionsConditional, type DiscoveredSession } from "../hooks/useDiscovery.js";
|
||||
import { useDiscoveryPlacement, useDrawerSelection } from "./AppShell.js";
|
||||
import { getEdgeStyle } from "@/lib/edge-styles";
|
||||
import { applyTreeLayout } from "@/lib/graph-layout";
|
||||
import { RigNode } from "./RigNode.js";
|
||||
import { HotPotatoEdge } from "./topology/HotPotatoEdge.js";
|
||||
import { Alert, AlertDescription } from "@/components/ui/alert";
|
||||
import { displayPodName, inferPodName } from "../lib/display-name.js";
|
||||
import { shortId } from "../lib/display-id.js";
|
||||
import { useTopologyActivity } from "../hooks/useTopologyActivity.js";
|
||||
import { usePrefersReducedMotion } from "../hooks/usePrefersReducedMotion.js";
|
||||
import {
|
||||
applyHotPotatoEdges,
|
||||
buildTopologySessionIndex,
|
||||
type TopologyActivityBaseline,
|
||||
} from "../lib/topology-activity.js";
|
||||
|
||||
function PodGroupNode({
|
||||
data,
|
||||
@@ -69,6 +76,10 @@ const nodeTypes: NodeTypes = {
|
||||
podGroup: PodGroupNode,
|
||||
};
|
||||
|
||||
const edgeTypes: EdgeTypes = {
|
||||
hotPotato: HotPotatoEdge,
|
||||
};
|
||||
|
||||
/** Wireframe ghost for empty topology */
|
||||
function EmptyTopologyGhost() {
|
||||
return (
|
||||
@@ -147,11 +158,11 @@ export function RigGraph({
|
||||
}, [allRawNodes, allRawEdges, podScope]);
|
||||
const error = queryError?.message ?? null;
|
||||
const { reconnecting } = useRigEvents(rigId);
|
||||
const edgeActivity = useTopologyEdgeActivity();
|
||||
const reducedMotion = usePrefersReducedMotion();
|
||||
const [focusMessage, setFocusMessage] = useState<FocusMessage | null>(null);
|
||||
const dismissTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||
|
||||
// Entrance animation tracking — keyed by rigId, fires once per navigation
|
||||
// Entrance animation tracking: keyed by rigId, fires once per navigation
|
||||
const animatedRigRef = useRef<string | null>(null);
|
||||
const shouldAnimate = rigId !== null && animatedRigRef.current !== rigId;
|
||||
|
||||
@@ -177,54 +188,40 @@ export function RigGraph({
|
||||
};
|
||||
}, []);
|
||||
|
||||
// PL-019 item 6: build a node-id → canonicalSessionName lookup so edge
|
||||
// activity (keyed on session names from /api/events) can be resolved
|
||||
// against React Flow edges (keyed on internal node ids). Built each
|
||||
// render off rawNodes; cheap (O(n) on graph nodes), and node-id is
|
||||
// stable for a given rig, so edges stay stable visually too.
|
||||
const sessionNameByNodeId = useMemo(() => {
|
||||
const map = new Map<string, string>();
|
||||
for (const node of rawNodes as Node[]) {
|
||||
const data = node.data as { canonicalSessionName?: string | null } | undefined;
|
||||
if (data?.canonicalSessionName) {
|
||||
map.set(node.id, data.canonicalSessionName);
|
||||
}
|
||||
}
|
||||
return map;
|
||||
}, [rawNodes]);
|
||||
const sessionIndex = useMemo(() => {
|
||||
return buildTopologySessionIndex((rawNodes as Node[])
|
||||
.filter((node) => node.type === "rigNode")
|
||||
.map((node) => {
|
||||
const data = node.data as {
|
||||
logicalId?: string | null;
|
||||
canonicalSessionName?: string | null;
|
||||
agentActivity?: TopologyActivityBaseline["agentActivity"];
|
||||
currentQitems?: TopologyActivityBaseline["currentQitems"];
|
||||
startupStatus?: string | null;
|
||||
} | undefined;
|
||||
return {
|
||||
nodeId: node.id,
|
||||
rigId,
|
||||
rigName,
|
||||
logicalId: data?.logicalId ?? null,
|
||||
canonicalSessionName: data?.canonicalSessionName ?? null,
|
||||
agentActivity: data?.agentActivity ?? null,
|
||||
currentQitems: data?.currentQitems ?? null,
|
||||
startupStatus: data?.startupStatus ?? null,
|
||||
};
|
||||
}));
|
||||
}, [rawNodes, rigId, rigName]);
|
||||
const topologyActivity = useTopologyActivity(sessionIndex);
|
||||
|
||||
// Apply edge styles from design system + entrance animation +
|
||||
// PL-019 traffic emphasis. Class composition order:
|
||||
// - shouldAnimate: one-shot entrance (existing behavior; not toggled
|
||||
// after first render).
|
||||
// - edge-recent-traffic: sustained 1.5x weight + brighter for any
|
||||
// edge whose directed pair fired in the last ~30s.
|
||||
// - edge-just-fired: one-shot 1.2s pulse on top of the sustained
|
||||
// emphasis when the most recent firing is within ~1.2s.
|
||||
// edgeActivity.version is included in deps so the memo refreshes when
|
||||
// a new event arrives or an old one ages out of the recent window.
|
||||
const rfEdges = useMemo(() => {
|
||||
return (rawEdges as (Edge & { data?: { kind?: string } })[]).map((edge) => {
|
||||
const kind = (edge as { data?: { kind?: string } }).data?.kind ??
|
||||
(edge as { label?: string }).label ?? "delegates_to";
|
||||
const styleResult = getEdgeStyle(kind);
|
||||
const sourceSession = sessionNameByNodeId.get(edge.source as string);
|
||||
const destSession = sessionNameByNodeId.get(edge.target as string);
|
||||
const recentTraffic = sourceSession && destSession
|
||||
? edgeActivity.recentTraffic(sourceSession, destSession)
|
||||
: false;
|
||||
const justFired = sourceSession && destSession
|
||||
? edgeActivity.justFired(sourceSession, destSession)
|
||||
: false;
|
||||
const trafficClass = [
|
||||
shouldAnimate ? "edge-draw-in" : "",
|
||||
recentTraffic ? "edge-recent-traffic" : "",
|
||||
justFired ? "edge-just-fired" : "",
|
||||
].filter(Boolean).join(" ") || undefined;
|
||||
return {
|
||||
...edge,
|
||||
...styleResult,
|
||||
className: trafficClass,
|
||||
className: shouldAnimate ? "edge-draw-in" : undefined,
|
||||
style: {
|
||||
...styleResult.style,
|
||||
animationDelay: shouldAnimate ? `${Math.min(rawNodes.length * 50 + 100, 2000)}ms` : undefined,
|
||||
@@ -232,7 +229,7 @@ export function RigGraph({
|
||||
};
|
||||
});
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [rawEdges, shouldAnimate, rawNodes.length, sessionNameByNodeId, edgeActivity.version]);
|
||||
}, [rawEdges, shouldAnimate, rawNodes.length]);
|
||||
|
||||
// Apply tree layout + entrance animation to nodes
|
||||
const podMetaById = useMemo(() => {
|
||||
@@ -337,6 +334,24 @@ export function RigGraph({
|
||||
return [...managed, ...discovered] as Node[];
|
||||
}, [rawNodes, rawEdges, shouldAnimate, discoveredSessions, placementMode, placementTarget]);
|
||||
|
||||
const activityNodes = useMemo(() => rfNodes.map((node) => {
|
||||
if (node.type !== "rigNode") return node;
|
||||
const data = node.data as TopologyActivityBaseline;
|
||||
return {
|
||||
...node,
|
||||
data: {
|
||||
...(node.data ?? {}),
|
||||
activityRing: topologyActivity.getNodeActivity(node.id, data),
|
||||
reducedMotion,
|
||||
},
|
||||
};
|
||||
}), [rfNodes, topologyActivity, reducedMotion]);
|
||||
|
||||
const activityEdges = useMemo(
|
||||
() => applyHotPotatoEdges(rfEdges, topologyActivity.packets, { reducedMotion }),
|
||||
[rfEdges, topologyActivity.packets, reducedMotion],
|
||||
);
|
||||
|
||||
// V1 polish slice Phase 5.1 P5.1-2 + DRIFT P5.1-D2: graph node click
|
||||
// navigates to /topology/seat/$rigId/$logicalId center page (matches
|
||||
// Explorer tree click + table row click contract). Replaces legacy
|
||||
@@ -460,7 +475,7 @@ export function RigGraph({
|
||||
);
|
||||
}
|
||||
|
||||
if (rfNodes.length === 0) {
|
||||
if (activityNodes.length === 0) {
|
||||
return <EmptyTopologyGhost />;
|
||||
}
|
||||
|
||||
@@ -486,7 +501,7 @@ export function RigGraph({
|
||||
{reconnecting && (
|
||||
<div className="absolute top-spacing-4 right-spacing-4 z-20">
|
||||
<Alert>
|
||||
<AlertDescription className="text-warning">Live updates disconnected from daemon — reconnecting...</AlertDescription>
|
||||
<AlertDescription className="text-warning">Live updates disconnected from daemon - reconnecting...</AlertDescription>
|
||||
</Alert>
|
||||
</div>
|
||||
)}
|
||||
@@ -504,13 +519,14 @@ export function RigGraph({
|
||||
data-testid="graph-placement-banner"
|
||||
className="absolute top-spacing-4 left-1/2 z-20 -translate-x-1/2 border border-emerald-300/90 bg-[rgba(236,253,245,0.92)] px-3.5 py-2 font-mono text-[10px] text-emerald-950 shadow-[0_12px_28px_rgba(34,197,94,0.14)] backdrop-blur-sm"
|
||||
>
|
||||
PLACEMENT MODE · click an available node to bind, or click a pod to add a new node.
|
||||
PLACEMENT MODE / click an available node to bind, or click a pod to add a new node.
|
||||
</div>
|
||||
)}
|
||||
<ReactFlow
|
||||
nodes={rfNodes}
|
||||
edges={rfEdges}
|
||||
nodes={activityNodes}
|
||||
edges={activityEdges}
|
||||
nodeTypes={nodeTypes}
|
||||
edgeTypes={edgeTypes}
|
||||
onNodeClick={onNodeClick}
|
||||
nodesDraggable={false}
|
||||
selectionOnDrag={false}
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { useRef, useEffect, useState } from "react";
|
||||
import { Handle, Position } from "@xyflow/react";
|
||||
import { PanelsTopLeft } from "lucide-react";
|
||||
import { copyText } from "../lib/copy-text.js";
|
||||
import { displayAgentName } from "../lib/display-name.js";
|
||||
import {
|
||||
@@ -12,6 +13,8 @@ import {
|
||||
} from "../lib/activity-visuals.js";
|
||||
import type { AgentActivitySummary, CurrentQitemSummary } from "../hooks/useNodeInventory.js";
|
||||
import { ContextUsageRing } from "./ContextUsageRing.js";
|
||||
import { ActivityRing } from "./topology/ActivityRing.js";
|
||||
import type { TopologyActivityVisual } from "../lib/topology-activity.js";
|
||||
|
||||
interface RigNodeData {
|
||||
logicalId: string;
|
||||
@@ -43,6 +46,8 @@ interface RigNodeData {
|
||||
// currentQitems surfaces in the hover hint when the agent is running.
|
||||
agentActivity?: AgentActivitySummary | null;
|
||||
currentQitems?: CurrentQitemSummary[];
|
||||
activityRing?: TopologyActivityVisual;
|
||||
reducedMotion?: boolean;
|
||||
}
|
||||
|
||||
/** Core roles get dark header stripe, workers get light */
|
||||
@@ -160,7 +165,7 @@ export function RigNode({ data }: { data: RigNodeData }) {
|
||||
: "bg-white text-stone-900 border-stone-300 hover:bg-stone-100"
|
||||
}`;
|
||||
|
||||
return (
|
||||
const card = (
|
||||
<div
|
||||
className={`group bg-white border min-w-[200px] hard-shadow relative ${
|
||||
data.placementState === "selected"
|
||||
@@ -287,7 +292,7 @@ export function RigNode({ data }: { data: RigNodeData }) {
|
||||
{(data.canonicalSessionName ?? data.binding?.tmuxSession ?? data.resumeToken ?? data.rigId) && (
|
||||
<div
|
||||
data-testid="node-toolbar"
|
||||
className="flex flex-wrap gap-1 pt-1"
|
||||
className="absolute right-2 top-8 z-20 flex flex-wrap justify-end gap-1 opacity-0 transition-opacity group-hover:!opacity-100 group-hover:opacity-100 group-focus-within:!opacity-100 group-focus-within:opacity-100"
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
onKeyDown={(e) => e.stopPropagation()}
|
||||
>
|
||||
@@ -305,9 +310,12 @@ export function RigNode({ data }: { data: RigNodeData }) {
|
||||
<button
|
||||
onClick={handleOpenCmux}
|
||||
data-testid="toolbar-cmux-open"
|
||||
className={buttonClass("cmux")}
|
||||
className={`${buttonClass("cmux")} inline-flex h-6 w-6 items-center justify-center px-0 py-0`}
|
||||
aria-label="Open in cmux"
|
||||
title="Open in cmux"
|
||||
>
|
||||
{actionFeedback === "cmux" ? "opened" : "cmux"}
|
||||
<PanelsTopLeft className="h-3.5 w-3.5" aria-hidden="true" />
|
||||
<span className="sr-only">{actionFeedback === "cmux" ? "opened" : "cmux"}</span>
|
||||
</button>
|
||||
)}
|
||||
{data.resumeToken && data.runtime && data.runtime !== "terminal" && (
|
||||
@@ -352,4 +360,15 @@ export function RigNode({ data }: { data: RigNodeData }) {
|
||||
<Handle type="source" position={Position.Bottom} />
|
||||
</div>
|
||||
);
|
||||
return (
|
||||
<ActivityRing
|
||||
state={data.activityRing?.state ?? "idle"}
|
||||
flash={data.activityRing?.flash ?? null}
|
||||
reducedMotion={data.reducedMotion}
|
||||
testId={`rig-node-activity-ring-${data.logicalId}`}
|
||||
className="rounded-none"
|
||||
>
|
||||
{card}
|
||||
</ActivityRing>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,61 @@
|
||||
import type { ReactNode } from "react";
|
||||
import { cn } from "../../lib/utils.js";
|
||||
import type { ActivityRingState, ActivityFlash } from "../../lib/topology-activity.js";
|
||||
|
||||
interface ActivityRingProps {
|
||||
as?: "div" | "span";
|
||||
state: ActivityRingState;
|
||||
flash?: ActivityFlash;
|
||||
reducedMotion?: boolean;
|
||||
children: ReactNode;
|
||||
className?: string;
|
||||
ringClassName?: string;
|
||||
testId?: string;
|
||||
}
|
||||
|
||||
const STATE_CLASS: Record<Exclude<ActivityRingState, "idle">, string> = {
|
||||
active: "border-emerald-500/70 shadow-[0_0_0_1px_rgba(16,185,129,0.10)]",
|
||||
needs_input: "border-amber-500/80 shadow-[0_0_0_1px_rgba(245,158,11,0.15)]",
|
||||
blocked: "border-red-600/80 shadow-[0_0_0_1px_rgba(220,38,38,0.16)]",
|
||||
};
|
||||
|
||||
const MOTION_CLASS: Record<Exclude<ActivityRingState, "idle">, string> = {
|
||||
active: "activity-ring-active",
|
||||
needs_input: "activity-ring-needs-input",
|
||||
blocked: "activity-ring-blocked",
|
||||
};
|
||||
|
||||
export function ActivityRing({
|
||||
as = "div",
|
||||
state,
|
||||
flash = null,
|
||||
reducedMotion = false,
|
||||
children,
|
||||
className,
|
||||
ringClassName,
|
||||
testId,
|
||||
}: ActivityRingProps) {
|
||||
const Component = as;
|
||||
return (
|
||||
<Component className={cn("relative", as === "span" ? "inline-flex" : "block", className)}>
|
||||
{state !== "idle" ? (
|
||||
<span
|
||||
aria-hidden="true"
|
||||
data-testid={testId}
|
||||
data-activity-ring-state={state}
|
||||
data-activity-flash={flash ?? "none"}
|
||||
data-reduced-motion={reducedMotion ? "true" : "false"}
|
||||
className={cn(
|
||||
"pointer-events-none absolute -inset-1 rounded-[inherit] border",
|
||||
STATE_CLASS[state],
|
||||
!reducedMotion && MOTION_CLASS[state],
|
||||
flash === "source" && "activity-ring-source-flash",
|
||||
flash === "target" && "activity-ring-target-flash",
|
||||
ringClassName,
|
||||
)}
|
||||
/>
|
||||
) : null}
|
||||
{children}
|
||||
</Component>
|
||||
);
|
||||
}
|
||||
@@ -27,6 +27,7 @@ import {
|
||||
type Node,
|
||||
type Edge,
|
||||
type NodeTypes,
|
||||
type EdgeTypes,
|
||||
type NodeMouseHandler,
|
||||
} from "@xyflow/react";
|
||||
import "@xyflow/react/dist/style.css";
|
||||
@@ -34,13 +35,21 @@ import { useQueries } from "@tanstack/react-query";
|
||||
import { usePsEntries } from "../../hooks/usePsEntries.js";
|
||||
import { RigGroupNode, type RigGroupNodeData } from "./RigGroupNode.js";
|
||||
import { HybridAgentNode, HybridPodGroupNode } from "./HybridTopologyNodes.js";
|
||||
import { HotPotatoEdge } from "./HotPotatoEdge.js";
|
||||
import { useTopologyOverlay } from "./topology-overlay-context.js";
|
||||
import { useTopologyActivity } from "../../hooks/useTopologyActivity.js";
|
||||
import { usePrefersReducedMotion } from "../../hooks/usePrefersReducedMotion.js";
|
||||
import {
|
||||
HYBRID_COLLAPSED_RIG_HEIGHT,
|
||||
HYBRID_COLLAPSED_RIG_WIDTH,
|
||||
layoutHybridOuterRigs,
|
||||
layoutHybridRig,
|
||||
} from "../../lib/hybrid-layout.js";
|
||||
import {
|
||||
applyHotPotatoEdges,
|
||||
buildTopologySessionIndex,
|
||||
type TopologyActivityBaseline,
|
||||
} from "../../lib/topology-activity.js";
|
||||
|
||||
interface GraphData {
|
||||
nodes: unknown[];
|
||||
@@ -59,9 +68,14 @@ const nodeTypes: NodeTypes = {
|
||||
rigNode: HybridAgentNode as unknown as NodeTypes[string],
|
||||
};
|
||||
|
||||
const edgeTypes: EdgeTypes = {
|
||||
hotPotato: HotPotatoEdge,
|
||||
};
|
||||
|
||||
export function HostMultiRigGraph() {
|
||||
const navigate = useNavigate();
|
||||
const { data: psEntries } = usePsEntries();
|
||||
const reducedMotion = usePrefersReducedMotion();
|
||||
|
||||
// V1 polish slice Phase 5.2 bounce-fix: rig-expanded state lifted to
|
||||
// TopologyOverlayProvider scope. Direct-URL entry to /topology/rig/$id
|
||||
@@ -120,6 +134,7 @@ export function HostMultiRigGraph() {
|
||||
const rawEdges = (queryResult?.data?.edges ?? []) as RawE[];
|
||||
const layout = layoutHybridRig({
|
||||
rigId: rig.rigId,
|
||||
rigName: rig.name,
|
||||
nodes: rawNodes,
|
||||
edges: rawEdges,
|
||||
collapsed: !isExpanded,
|
||||
@@ -187,6 +202,63 @@ export function HostMultiRigGraph() {
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [rigList, expanded, graphQueries]);
|
||||
|
||||
const sessionIndex = useMemo(() => buildTopologySessionIndex(
|
||||
mergedNodes
|
||||
.filter((node) => node.type === "rigNode")
|
||||
.map((node) => {
|
||||
const data = node.data as {
|
||||
rigId?: string | null;
|
||||
rigName?: string | null;
|
||||
logicalId?: string | null;
|
||||
canonicalSessionName?: string | null;
|
||||
agentActivity?: TopologyActivityBaseline["agentActivity"];
|
||||
currentQitems?: TopologyActivityBaseline["currentQitems"];
|
||||
startupStatus?: string | null;
|
||||
} | undefined;
|
||||
return {
|
||||
nodeId: node.id,
|
||||
rigId: data?.rigId ?? null,
|
||||
rigName: data?.rigName ?? null,
|
||||
logicalId: data?.logicalId ?? null,
|
||||
canonicalSessionName: data?.canonicalSessionName ?? null,
|
||||
agentActivity: data?.agentActivity ?? null,
|
||||
currentQitems: data?.currentQitems ?? null,
|
||||
startupStatus: data?.startupStatus ?? null,
|
||||
};
|
||||
}),
|
||||
), [mergedNodes]);
|
||||
const topologyActivity = useTopologyActivity(sessionIndex);
|
||||
|
||||
const activeNodes = useMemo(() => mergedNodes.map((node) => {
|
||||
if (node.type === "rigGroup") {
|
||||
const data = node.data as unknown as RigGroupNodeData;
|
||||
return {
|
||||
...node,
|
||||
data: {
|
||||
...data,
|
||||
recentActivity: topologyActivity.isRigRecentlyActive(data.rigId),
|
||||
} as unknown as Record<string, unknown>,
|
||||
};
|
||||
}
|
||||
if (node.type !== "rigNode") return node;
|
||||
const data = node.data as TopologyActivityBaseline & {
|
||||
logicalId?: string | null;
|
||||
};
|
||||
return {
|
||||
...node,
|
||||
data: {
|
||||
...(node.data ?? {}),
|
||||
activityRing: topologyActivity.getNodeActivity(node.id, data),
|
||||
reducedMotion,
|
||||
},
|
||||
};
|
||||
}), [mergedNodes, topologyActivity, reducedMotion]);
|
||||
|
||||
const activeEdges = useMemo(
|
||||
() => applyHotPotatoEdges(mergedEdges, topologyActivity.packets, { reducedMotion }),
|
||||
[mergedEdges, topologyActivity.packets, reducedMotion],
|
||||
);
|
||||
|
||||
// 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).
|
||||
@@ -229,9 +301,10 @@ export function HostMultiRigGraph() {
|
||||
className="w-full h-full relative"
|
||||
>
|
||||
<ReactFlow
|
||||
nodes={mergedNodes}
|
||||
edges={mergedEdges}
|
||||
nodes={activeNodes}
|
||||
edges={activeEdges}
|
||||
nodeTypes={nodeTypes}
|
||||
edgeTypes={edgeTypes}
|
||||
onNodeClick={onNodeClick}
|
||||
nodesDraggable={false}
|
||||
fitView
|
||||
|
||||
@@ -0,0 +1,77 @@
|
||||
import {
|
||||
BaseEdge,
|
||||
getBezierPath,
|
||||
type EdgeProps,
|
||||
} from "@xyflow/react";
|
||||
import type { HotPotatoEdgeData } from "../../lib/topology-activity.js";
|
||||
|
||||
export function HotPotatoEdge({
|
||||
id,
|
||||
sourceX,
|
||||
sourceY,
|
||||
targetX,
|
||||
targetY,
|
||||
sourcePosition,
|
||||
targetPosition,
|
||||
style,
|
||||
markerEnd,
|
||||
data,
|
||||
}: EdgeProps) {
|
||||
const [edgePath] = getBezierPath({
|
||||
sourceX,
|
||||
sourceY,
|
||||
sourcePosition,
|
||||
targetX,
|
||||
targetY,
|
||||
targetPosition,
|
||||
});
|
||||
const edgeData = (data ?? {}) as HotPotatoEdgeData;
|
||||
const packet = edgeData.hotPotatoPacket ?? null;
|
||||
const crossRig = edgeData.hotPotatoCrossRig ?? false;
|
||||
const reducedMotion = edgeData.hotPotatoReducedMotion ?? false;
|
||||
const strokeWidth = packet ? (crossRig ? 1.25 : 2.25) : style?.strokeWidth;
|
||||
|
||||
return (
|
||||
<>
|
||||
<BaseEdge
|
||||
id={id}
|
||||
path={edgePath}
|
||||
markerEnd={markerEnd}
|
||||
style={{
|
||||
...style,
|
||||
strokeWidth,
|
||||
filter: packet ? "drop-shadow(0 0 3px rgba(16,185,129,0.22))" : style?.filter,
|
||||
}}
|
||||
/>
|
||||
{packet ? (
|
||||
reducedMotion ? (
|
||||
<circle
|
||||
data-testid={`hot-potato-packet-${packet.id}`}
|
||||
data-reduced-motion="true"
|
||||
cx={targetX}
|
||||
cy={targetY}
|
||||
r={crossRig ? 2.5 : 3.25}
|
||||
className={crossRig ? "fill-stone-500" : "fill-emerald-600"}
|
||||
/>
|
||||
) : (
|
||||
<circle
|
||||
key={packet.id}
|
||||
data-testid={`hot-potato-packet-${packet.id}`}
|
||||
data-reduced-motion="false"
|
||||
r={crossRig ? 2.25 : 3}
|
||||
className={crossRig ? "fill-stone-500 hot-potato-packet-cross" : "fill-emerald-600 hot-potato-packet"}
|
||||
>
|
||||
<animateMotion
|
||||
dur={`${packet.durationMs}ms`}
|
||||
path={edgePath}
|
||||
fill="freeze"
|
||||
calcMode="spline"
|
||||
keySplines="0.2 0 0 1"
|
||||
keyTimes="0;1"
|
||||
/>
|
||||
</circle>
|
||||
)
|
||||
) : null}
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -1,4 +1,5 @@
|
||||
import { Handle, Position } from "@xyflow/react";
|
||||
import { PanelsTopLeft } from "lucide-react";
|
||||
import { displayAgentName, inferPodName } from "../../lib/display-name.js";
|
||||
import {
|
||||
getActivityAnimationClass,
|
||||
@@ -9,6 +10,9 @@ import {
|
||||
} from "../../lib/activity-visuals.js";
|
||||
import type { AgentActivitySummary } from "../../hooks/useNodeInventory.js";
|
||||
import { cn } from "../../lib/utils.js";
|
||||
import { useCmuxLaunch } from "../../hooks/useCmuxLaunch.js";
|
||||
import { ActivityRing } from "./ActivityRing.js";
|
||||
import type { TopologyActivityVisual } from "../../lib/topology-activity.js";
|
||||
|
||||
interface HybridPodGroupNodeData {
|
||||
podDisplayName?: string | null;
|
||||
@@ -33,6 +37,10 @@ interface HybridAgentNodeData {
|
||||
contextFresh?: boolean;
|
||||
contextAvailability?: string | null;
|
||||
agentActivity?: AgentActivitySummary | null;
|
||||
currentQitems?: unknown[];
|
||||
rigId?: string | null;
|
||||
activityRing?: TopologyActivityVisual;
|
||||
reducedMotion?: boolean;
|
||||
}
|
||||
|
||||
function isCoreRole(role: string | null | undefined): boolean {
|
||||
@@ -73,6 +81,7 @@ export function HybridPodGroupNode({ data }: { data: HybridPodGroupNodeData }) {
|
||||
}
|
||||
|
||||
export function HybridAgentNode({ data }: { data: HybridAgentNodeData }) {
|
||||
const cmuxLaunch = useCmuxLaunch();
|
||||
const core = isCoreRole(data.role);
|
||||
const isInfra = data.nodeKind === "infrastructure";
|
||||
const activityState = getActivityState(data.agentActivity);
|
||||
@@ -83,7 +92,7 @@ export function HybridAgentNode({ data }: { data: HybridAgentNodeData }) {
|
||||
const runtimeModel = [data.runtime, data.model].filter(Boolean).join(" / ");
|
||||
const contextKnown = data.contextAvailability === "known" && typeof data.contextUsedPercentage === "number";
|
||||
|
||||
return (
|
||||
const card = (
|
||||
<div
|
||||
data-testid="hybrid-agent-node"
|
||||
title={[
|
||||
@@ -123,6 +132,21 @@ export function HybridAgentNode({ data }: { data: HybridAgentNodeData }) {
|
||||
aria-label={`activity: ${activityLabel}`}
|
||||
/>
|
||||
</div>
|
||||
{data.rigId ? (
|
||||
<button
|
||||
type="button"
|
||||
data-testid={`hybrid-cmux-open-${data.logicalId}`}
|
||||
aria-label={`Open ${data.logicalId} in cmux`}
|
||||
title="Open in cmux"
|
||||
onClick={(event) => {
|
||||
event.stopPropagation();
|
||||
cmuxLaunch.mutate({ rigId: data.rigId!, logicalId: data.logicalId });
|
||||
}}
|
||||
className="absolute right-1.5 top-7 z-10 inline-flex h-6 w-6 items-center justify-center border border-outline-variant bg-white/90 text-stone-700 opacity-0 shadow-[1px_1px_0_rgba(46,52,46,0.14)] transition-opacity hover:bg-stone-100 hover:text-stone-950 focus:!opacity-100 focus:opacity-100 focus:outline-none focus:ring-2 focus:ring-stone-900/20 group-hover:!opacity-100 group-hover:opacity-100 group-focus-within:!opacity-100 group-focus-within:opacity-100"
|
||||
>
|
||||
<PanelsTopLeft className="h-3.5 w-3.5" aria-hidden="true" />
|
||||
</button>
|
||||
) : null}
|
||||
<div className="space-y-1 px-2 py-1.5">
|
||||
<div className="truncate font-mono text-[7px] text-stone-500">
|
||||
{data.canonicalSessionName ?? data.logicalId}
|
||||
@@ -137,4 +161,15 @@ export function HybridAgentNode({ data }: { data: HybridAgentNodeData }) {
|
||||
<Handle type="source" position={Position.Right} className="opacity-0" />
|
||||
</div>
|
||||
);
|
||||
return (
|
||||
<ActivityRing
|
||||
state={data.activityRing?.state ?? "idle"}
|
||||
flash={data.activityRing?.flash ?? null}
|
||||
reducedMotion={data.reducedMotion}
|
||||
testId={`hybrid-activity-ring-${data.logicalId}`}
|
||||
className="h-full w-full rounded-none"
|
||||
>
|
||||
{card}
|
||||
</ActivityRing>
|
||||
);
|
||||
}
|
||||
@@ -28,6 +28,7 @@ export interface RigGroupNodeData {
|
||||
nodeCount: number;
|
||||
runningCount: number;
|
||||
podCount?: number; // optional; populated post-graph-fetch when expanded
|
||||
recentActivity?: boolean;
|
||||
/** Click handler: owner threads through to toggle expand state. */
|
||||
onToggle: (rigId: string) => void;
|
||||
}
|
||||
@@ -39,7 +40,7 @@ function statusToPip(s: RigGroupNodeData["status"]): React.ComponentProps<typeof
|
||||
}
|
||||
|
||||
export function RigGroupNode({ data }: { data: RigGroupNodeData }) {
|
||||
const { rigId, rigName, collapsed, status, nodeCount, runningCount, podCount, onToggle } = data;
|
||||
const { rigId, rigName, collapsed, status, nodeCount, runningCount, podCount, recentActivity, onToggle } = data;
|
||||
return (
|
||||
<div
|
||||
data-testid={`rig-group-node-${rigId}`}
|
||||
@@ -55,6 +56,7 @@ export function RigGroupNode({ data }: { data: RigGroupNodeData }) {
|
||||
collapsed
|
||||
? "border border-outline-variant bg-white/40 backdrop-blur-[8px] hard-shadow hover:bg-white/60"
|
||||
: "border border-outline-variant/70 bg-white/[0.14] backdrop-blur-[2px] shadow-[0_0_0_1px_rgba(84,96,115,0.06)]",
|
||||
recentActivity && "rig-activity-frame-pulse",
|
||||
)}
|
||||
>
|
||||
<RegistrationMarks testIdPrefix={`rig-group-${rigId}`} />
|
||||
|
||||
@@ -1,20 +1,21 @@
|
||||
// V1 attempt-3 Phase 3 — Topology table view per topology-table-view.md + SC-25.
|
||||
// V1 attempt-3 Phase 3: Topology table view per topology-table-view.md + SC-25.
|
||||
//
|
||||
// Tanstack-backed table; row per agent across topology, scoped by URL.
|
||||
//
|
||||
// V1 attempt-3 Phase 5 P5-9 ship-gate bounce P0-1: rules-of-hooks fix.
|
||||
// Previous shape used `scopedRigs.map((r) => useNodeInventory(r.id))` which
|
||||
// calls hooks in a loop with variable count — when scopedRigs grew from 0
|
||||
// calls hooks in a loop with variable count. When scopedRigs grew from 0
|
||||
// (initial render before useRigSummary resolves) to N (after resolution),
|
||||
// React detected the hook count change and threw "Cannot read properties
|
||||
// of undefined (reading 'length')" downstream. This crashed /topology at
|
||||
// 375x812 mobile because P5-9 mounts the table immediately at first
|
||||
// render (graph view-mode degraded to table for narrow viewports) BEFORE
|
||||
// rigs data is available. Switched to `useQueries` from React Query —
|
||||
// rigs data is available. Switched to `useQueries` from React Query:
|
||||
// single hook call regardless of array length.
|
||||
|
||||
import { useMemo, useState } from "react";
|
||||
import { useNavigate } from "@tanstack/react-router";
|
||||
import { PanelsTopLeft } from "lucide-react";
|
||||
import {
|
||||
type ColumnDef,
|
||||
flexRender,
|
||||
@@ -31,6 +32,14 @@ import { VellumInput } from "../ui/vellum-input.js";
|
||||
import { StatusPip } from "../ui/status-pip.js";
|
||||
import { inferPodName } from "../../lib/display-name.js";
|
||||
import { useCmuxLaunch } from "../../hooks/useCmuxLaunch.js";
|
||||
import { useTopologyActivity } from "../../hooks/useTopologyActivity.js";
|
||||
import { usePrefersReducedMotion } from "../../hooks/usePrefersReducedMotion.js";
|
||||
import {
|
||||
buildTopologySessionIndex,
|
||||
type TopologyActivityBaseline,
|
||||
type TopologyActivityVisual,
|
||||
} from "../../lib/topology-activity.js";
|
||||
import { ActivityRing } from "./ActivityRing.js";
|
||||
|
||||
async function fetchNodeInventory(rigId: string): Promise<NodeInventoryEntry[]> {
|
||||
const res = await fetch(`/api/rigs/${encodeURIComponent(rigId)}/nodes`);
|
||||
@@ -47,6 +56,10 @@ interface AgentRow {
|
||||
runtime: string;
|
||||
status: string;
|
||||
startupStatus: string | null;
|
||||
agentActivity?: TopologyActivityBaseline["agentActivity"];
|
||||
currentQitems?: TopologyActivityBaseline["currentQitems"];
|
||||
activityRing?: TopologyActivityVisual;
|
||||
reducedMotion?: boolean;
|
||||
}
|
||||
|
||||
function statusToSemanticPip(s: string): "active" | "running" | "stopped" | "warning" | "error" | "info" {
|
||||
@@ -58,10 +71,6 @@ function statusToSemanticPip(s: string): "active" | "running" | "stopped" | "war
|
||||
return "info";
|
||||
}
|
||||
|
||||
// V1 polish slice Phase 5.1 P5.1-7: actions column with per-row cmux
|
||||
// button. Cell stops click propagation so the row's navigate handler
|
||||
// doesn't also fire (cmux button is the explicit action; row click is
|
||||
// the implicit "open detail" navigation per row-click contract).
|
||||
function CmuxButton({ row }: { row: AgentRow }) {
|
||||
const cmuxLaunch = useCmuxLaunch();
|
||||
return (
|
||||
@@ -72,34 +81,53 @@ function CmuxButton({ row }: { row: AgentRow }) {
|
||||
e.stopPropagation();
|
||||
cmuxLaunch.mutate({ rigId: row.rigId, logicalId: row.logicalId });
|
||||
}}
|
||||
className="px-2 py-1 border border-outline-variant bg-white/30 font-mono text-[9px] uppercase tracking-wide text-stone-700 hover:bg-stone-100/60 hover:text-stone-900"
|
||||
aria-label={`Open ${row.logicalId} in cmux`}
|
||||
title="Open in cmux"
|
||||
className="inline-flex h-7 w-7 items-center justify-center border border-outline-variant bg-white/65 text-stone-700 opacity-0 shadow-[1px_1px_0_rgba(46,52,46,0.12)] transition-opacity hover:bg-stone-100 hover:text-stone-950 focus:!opacity-100 focus:opacity-100 focus:outline-none focus:ring-2 focus:ring-stone-900/20 group-hover:!opacity-100 group-hover:opacity-100 group-focus-within:!opacity-100 group-focus-within:opacity-100"
|
||||
>
|
||||
CMUX
|
||||
<PanelsTopLeft className="h-3.5 w-3.5" aria-hidden="true" />
|
||||
<span className="sr-only">CMUX</span>
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
const COLUMNS: ColumnDef<AgentRow>[] = [
|
||||
{ accessorKey: "rigName", header: "Rig", cell: ({ getValue }) => <span className="font-mono text-xs">{String(getValue())}</span> },
|
||||
{ accessorKey: "podName", header: "Pod", cell: ({ getValue }) => <span className="font-mono text-xs">{String(getValue())}</span> },
|
||||
{ accessorKey: "logicalId", header: "Agent", cell: ({ getValue }) => <span className="font-mono text-xs">{String(getValue())}</span> },
|
||||
{ accessorKey: "runtime", header: "Runtime", cell: ({ getValue }) => <span className="font-mono text-[10px] uppercase tracking-wide">{String(getValue() ?? "—")}</span> },
|
||||
{
|
||||
accessorKey: "status",
|
||||
header: "Status",
|
||||
cell: ({ getValue }) => (
|
||||
<StatusPip status={statusToSemanticPip(String(getValue()))} label={String(getValue())} variant="pill" />
|
||||
),
|
||||
},
|
||||
// V1 polish slice Phase 5.1 P5.1-7: actions column with cmux launcher
|
||||
// per-row (founder-noted topology-table-view.md L54 column).
|
||||
{
|
||||
id: "actions",
|
||||
header: "Actions",
|
||||
enableSorting: false,
|
||||
cell: ({ row }) => <CmuxButton row={row.original} />,
|
||||
},
|
||||
];
|
||||
function agentColumns(): ColumnDef<AgentRow>[] {
|
||||
return [
|
||||
{ accessorKey: "rigName", header: "Rig", cell: ({ getValue }) => <span className="font-mono text-xs">{String(getValue())}</span> },
|
||||
{ accessorKey: "podName", header: "Pod", cell: ({ getValue }) => <span className="font-mono text-xs">{String(getValue())}</span> },
|
||||
{
|
||||
accessorKey: "logicalId",
|
||||
header: "Agent",
|
||||
cell: ({ row }) => (
|
||||
<ActivityRing
|
||||
as="span"
|
||||
state={row.original.activityRing?.state ?? "idle"}
|
||||
flash={row.original.activityRing?.flash ?? null}
|
||||
reducedMotion={row.original.reducedMotion}
|
||||
testId={`topology-table-activity-ring-${row.original.logicalId}`}
|
||||
className="inline-flex rounded-sm"
|
||||
ringClassName="-inset-1"
|
||||
>
|
||||
<span className="font-mono text-xs">{row.original.logicalId}</span>
|
||||
</ActivityRing>
|
||||
),
|
||||
},
|
||||
{ accessorKey: "runtime", header: "Runtime", cell: ({ getValue }) => <span className="font-mono text-[10px] uppercase tracking-wide">{String(getValue() ?? "-")}</span> },
|
||||
{
|
||||
accessorKey: "status",
|
||||
header: "Status",
|
||||
cell: ({ getValue }) => (
|
||||
<StatusPip status={statusToSemanticPip(String(getValue()))} label={String(getValue())} variant="pill" />
|
||||
),
|
||||
},
|
||||
{
|
||||
id: "actions",
|
||||
header: "",
|
||||
enableSorting: false,
|
||||
cell: ({ row }) => <CmuxButton row={row.original} />,
|
||||
},
|
||||
];
|
||||
}
|
||||
|
||||
export function TopologyTableView({ rigIdScope }: { rigIdScope?: string }) {
|
||||
// V1 polish slice Phase 5.1 P5.1-7: row click navigates to seat-scope
|
||||
@@ -107,6 +135,7 @@ export function TopologyTableView({ rigIdScope }: { rigIdScope?: string }) {
|
||||
// Topology Tree details-icon-retired contract).
|
||||
const navigate = useNavigate();
|
||||
const { data: rigs } = useRigSummary();
|
||||
const reducedMotion = usePrefersReducedMotion();
|
||||
const scopedRigs = useMemo(
|
||||
() =>
|
||||
rigIdScope
|
||||
@@ -116,8 +145,8 @@ export function TopologyTableView({ rigIdScope }: { rigIdScope?: string }) {
|
||||
);
|
||||
|
||||
// P0-1 fix: useQueries replaces the .map(useNodeInventory) loop. Single
|
||||
// hook call regardless of scopedRigs length — React's hook order stays
|
||||
// stable across renders even when rigs grows from undefined → [N].
|
||||
// hook call regardless of scopedRigs length. React's hook order stays
|
||||
// stable across renders even when rigs grows from undefined to [N].
|
||||
const inventoryResults = useQueries({
|
||||
queries: scopedRigs.map((r) => ({
|
||||
queryKey: ["rig", r.id, "nodes"] as const,
|
||||
@@ -139,21 +168,41 @@ export function TopologyTableView({ rigIdScope }: { rigIdScope?: string }) {
|
||||
podName: inferPodName(n.logicalId) ?? "default",
|
||||
logicalId: n.logicalId,
|
||||
sessionName: n.canonicalSessionName ?? n.logicalId,
|
||||
runtime: (n.runtime ?? "—") as string,
|
||||
runtime: (n.runtime ?? "-") as string,
|
||||
status: (n.sessionStatus ?? "unknown") as string,
|
||||
startupStatus: (n.startupStatus ?? null) as string | null,
|
||||
agentActivity: n.agentActivity ?? null,
|
||||
currentQitems: n.currentQitems ?? [],
|
||||
});
|
||||
}
|
||||
}
|
||||
return rows;
|
||||
}, [scopedRigs, inventoryResults]);
|
||||
|
||||
const sessionIndex = useMemo(() => buildTopologySessionIndex(data.map((row) => ({
|
||||
nodeId: `${row.rigId}::${row.logicalId}`,
|
||||
rigId: row.rigId,
|
||||
rigName: row.rigName,
|
||||
logicalId: row.logicalId,
|
||||
canonicalSessionName: row.sessionName,
|
||||
agentActivity: row.agentActivity ?? null,
|
||||
currentQitems: row.currentQitems ?? null,
|
||||
startupStatus: row.startupStatus,
|
||||
}))), [data]);
|
||||
const topologyActivity = useTopologyActivity(sessionIndex);
|
||||
const activityData = useMemo(() => data.map((row) => ({
|
||||
...row,
|
||||
activityRing: topologyActivity.getNodeActivity(`${row.rigId}::${row.logicalId}`, row),
|
||||
reducedMotion,
|
||||
})), [data, topologyActivity, reducedMotion]);
|
||||
|
||||
const [sorting, setSorting] = useState<SortingState>([]);
|
||||
const [search, setSearch] = useState("");
|
||||
const columns = useMemo(() => agentColumns(), []);
|
||||
|
||||
const table = useReactTable({
|
||||
data,
|
||||
columns: COLUMNS,
|
||||
data: activityData,
|
||||
columns,
|
||||
state: { sorting, globalFilter: search },
|
||||
onSortingChange: setSorting,
|
||||
onGlobalFilterChange: setSearch,
|
||||
@@ -184,7 +233,7 @@ export function TopologyTableView({ rigIdScope }: { rigIdScope?: string }) {
|
||||
testId="topology-table-search"
|
||||
/>
|
||||
<span className="font-mono text-[10px] uppercase tracking-wide text-on-surface-variant ml-auto">
|
||||
{table.getFilteredRowModel().rows.length} of {data.length}
|
||||
{table.getFilteredRowModel().rows.length} of {activityData.length}
|
||||
</span>
|
||||
</div>
|
||||
<div className="border border-outline-variant overflow-x-auto">
|
||||
@@ -208,7 +257,7 @@ export function TopologyTableView({ rigIdScope }: { rigIdScope?: string }) {
|
||||
<tbody>
|
||||
{table.getRowModel().rows.length === 0 ? (
|
||||
<tr>
|
||||
<td colSpan={COLUMNS.length} className="px-3 py-6 text-center font-mono text-xs text-on-surface-variant">
|
||||
<td colSpan={columns.length} className="px-3 py-6 text-center font-mono text-xs text-on-surface-variant">
|
||||
No agents match.
|
||||
</td>
|
||||
</tr>
|
||||
@@ -226,7 +275,7 @@ export function TopologyTableView({ rigIdScope }: { rigIdScope?: string }) {
|
||||
},
|
||||
})
|
||||
}
|
||||
className="border-b border-outline-variant last:border-b-0 hover:bg-surface-low cursor-pointer"
|
||||
className="group border-b border-outline-variant last:border-b-0 hover:bg-surface-low focus-within:bg-surface-low cursor-pointer"
|
||||
>
|
||||
{row.getVisibleCells().map((cell) => (
|
||||
<td key={cell.id} className="px-3 py-2">
|
||||
|
||||
@@ -298,6 +298,57 @@
|
||||
60% { stroke-width: 3px; filter: brightness(1.3); }
|
||||
100% { stroke-width: 3px; filter: brightness(1.15); }
|
||||
}
|
||||
|
||||
@keyframes activity-ring-active-frames {
|
||||
0%, 100% { opacity: 0.58; box-shadow: 0 0 0 0 rgba(16, 185, 129, 0.0); }
|
||||
50% { opacity: 0.92; box-shadow: 0 0 0 3px rgba(16, 185, 129, 0.14); }
|
||||
}
|
||||
@keyframes activity-ring-needs-input-frames {
|
||||
0%, 100% { opacity: 0.72; box-shadow: 0 0 0 0 rgba(245, 158, 11, 0.0); }
|
||||
50% { opacity: 1; box-shadow: 0 0 0 3px rgba(245, 158, 11, 0.22); }
|
||||
}
|
||||
@keyframes activity-ring-blocked-frames {
|
||||
0%, 100% { opacity: 0.78; box-shadow: 0 0 0 0 rgba(220, 38, 38, 0.0); }
|
||||
50% { opacity: 1; box-shadow: 0 0 0 2px rgba(220, 38, 38, 0.18); }
|
||||
}
|
||||
@keyframes activity-ring-flash-frames {
|
||||
0% { opacity: 1; box-shadow: 0 0 0 0 rgba(16, 185, 129, 0.42); }
|
||||
100% { opacity: 0.82; box-shadow: 0 0 0 5px rgba(16, 185, 129, 0.0); }
|
||||
}
|
||||
.activity-ring-active {
|
||||
animation: activity-ring-active-frames 2.2s ease-in-out infinite;
|
||||
}
|
||||
.activity-ring-needs-input {
|
||||
animation: activity-ring-needs-input-frames 1.9s ease-in-out infinite;
|
||||
}
|
||||
.activity-ring-blocked {
|
||||
animation: activity-ring-blocked-frames 3.2s ease-in-out infinite;
|
||||
}
|
||||
.activity-ring-source-flash,
|
||||
.activity-ring-target-flash {
|
||||
animation: activity-ring-flash-frames 700ms ease-out 1;
|
||||
}
|
||||
|
||||
.hot-potato-edge-active .react-flow__edge-path,
|
||||
.react-flow__edge.hot-potato-edge-active .react-flow__edge-path {
|
||||
transition: stroke-width 180ms ease-out, filter 180ms ease-out;
|
||||
}
|
||||
.hot-potato-packet,
|
||||
.hot-potato-packet-cross {
|
||||
filter: drop-shadow(0 0 3px rgba(16, 185, 129, 0.32));
|
||||
}
|
||||
.hot-potato-packet-cross {
|
||||
opacity: 0.72;
|
||||
filter: drop-shadow(0 0 2px rgba(120, 113, 108, 0.22));
|
||||
}
|
||||
|
||||
@keyframes rig-activity-frame-frames {
|
||||
0%, 100% { box-shadow: 0 0 0 0 rgba(16, 185, 129, 0.0); }
|
||||
50% { box-shadow: 0 0 0 2px rgba(16, 185, 129, 0.12); }
|
||||
}
|
||||
.rig-activity-frame-pulse {
|
||||
animation: rig-activity-frame-frames 2.4s ease-in-out infinite;
|
||||
}
|
||||
}
|
||||
|
||||
/* === REDUCED MOTION === */
|
||||
@@ -311,7 +362,13 @@
|
||||
.animate-pulse-tactical,
|
||||
.activity-pulse-running,
|
||||
.terminal-card-active,
|
||||
.edge-just-fired {
|
||||
.edge-just-fired,
|
||||
.activity-ring-active,
|
||||
.activity-ring-needs-input,
|
||||
.activity-ring-blocked,
|
||||
.activity-ring-source-flash,
|
||||
.activity-ring-target-flash,
|
||||
.rig-activity-frame-pulse {
|
||||
animation: none;
|
||||
opacity: 1;
|
||||
transform: none;
|
||||
|
||||
@@ -0,0 +1,25 @@
|
||||
import { useEffect, useState } from "react";
|
||||
|
||||
const REDUCED_MOTION_QUERY = "(prefers-reduced-motion: reduce)";
|
||||
|
||||
export function usePrefersReducedMotion(): boolean {
|
||||
const [prefersReducedMotion, setPrefersReducedMotion] = useState(() => {
|
||||
if (typeof window === "undefined" || typeof window.matchMedia !== "function") return false;
|
||||
return window.matchMedia(REDUCED_MOTION_QUERY).matches;
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
if (typeof window === "undefined" || typeof window.matchMedia !== "function") return;
|
||||
const mediaQuery = window.matchMedia(REDUCED_MOTION_QUERY);
|
||||
setPrefersReducedMotion(mediaQuery.matches);
|
||||
const onChange = () => setPrefersReducedMotion(mediaQuery.matches);
|
||||
mediaQuery.addEventListener?.("change", onChange);
|
||||
return () => mediaQuery.removeEventListener?.("change", onChange);
|
||||
}, []);
|
||||
|
||||
return prefersReducedMotion;
|
||||
}
|
||||
|
||||
export const __test_internals = {
|
||||
REDUCED_MOTION_QUERY,
|
||||
};
|
||||
@@ -0,0 +1,188 @@
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||||
import { subscribeTopologyEvents } from "../lib/topology-events.js";
|
||||
import {
|
||||
HOT_POTATO_TRAIL_TTL_MS,
|
||||
TOPOLOGY_NODE_ACTIVITY_TTL_MS,
|
||||
TOPOLOGY_RIG_ACTIVITY_TTL_MS,
|
||||
computeActivityVisual,
|
||||
isRigRecentlyActive,
|
||||
makeHotPotatoPacket,
|
||||
parseTopologyActivityEvent,
|
||||
resolveTopologySession,
|
||||
type ActivityRingState,
|
||||
type HotPotatoPacket,
|
||||
type TopologyActivityBaseline,
|
||||
type TopologyActivityVisual,
|
||||
type TopologyRecentNodeActivity,
|
||||
type TopologySessionIndex,
|
||||
} from "../lib/topology-activity.js";
|
||||
|
||||
interface TopologyActivityStore {
|
||||
nodeActivity: Map<string, TopologyRecentNodeActivity>;
|
||||
rigActivity: Map<string, number>;
|
||||
packets: HotPotatoPacket[];
|
||||
unresolvedCount: number;
|
||||
sequence: number;
|
||||
}
|
||||
|
||||
export interface TopologyActivitySnapshot {
|
||||
version: number;
|
||||
unresolvedCount: number;
|
||||
packets: HotPotatoPacket[];
|
||||
getNodeActivity(nodeId: string, baseline?: TopologyActivityBaseline | null): TopologyActivityVisual;
|
||||
isRigRecentlyActive(rigId: string | null | undefined): boolean;
|
||||
}
|
||||
|
||||
function createStore(): TopologyActivityStore {
|
||||
return {
|
||||
nodeActivity: new Map(),
|
||||
rigActivity: new Map(),
|
||||
packets: [],
|
||||
unresolvedCount: 0,
|
||||
sequence: 0,
|
||||
};
|
||||
}
|
||||
|
||||
function recordNodeActivity(
|
||||
store: TopologyActivityStore,
|
||||
nodeId: string,
|
||||
state: ActivityRingState,
|
||||
nowMs: number,
|
||||
flash: "source" | "target" | null,
|
||||
): void {
|
||||
const existing = store.nodeActivity.get(nodeId);
|
||||
const existingLive = existing && nowMs - existing.lastActiveAt <= TOPOLOGY_NODE_ACTIVITY_TTL_MS
|
||||
? existing
|
||||
: undefined;
|
||||
const next: TopologyRecentNodeActivity = {
|
||||
state: strongestRecentState(existingLive?.state ?? "idle", state),
|
||||
lastActiveAt: nowMs,
|
||||
sourceFlashAt: flash === "source" ? nowMs : existingLive?.sourceFlashAt,
|
||||
targetFlashAt: flash === "target" ? nowMs : existingLive?.targetFlashAt,
|
||||
};
|
||||
store.nodeActivity.set(nodeId, next);
|
||||
}
|
||||
|
||||
function strongestRecentState(a: ActivityRingState, b: ActivityRingState): ActivityRingState {
|
||||
const rank = (state: ActivityRingState) =>
|
||||
state === "blocked" ? 3 : state === "needs_input" ? 2 : state === "active" ? 1 : 0;
|
||||
return rank(b) > rank(a) ? b : a;
|
||||
}
|
||||
|
||||
function pruneStore(store: TopologyActivityStore, nowMs: number): boolean {
|
||||
let changed = false;
|
||||
const packetCount = store.packets.length;
|
||||
store.packets = store.packets.filter((packet) => nowMs - packet.createdAt <= HOT_POTATO_TRAIL_TTL_MS);
|
||||
changed ||= store.packets.length !== packetCount;
|
||||
|
||||
for (const [nodeId, activity] of store.nodeActivity.entries()) {
|
||||
if (nowMs - activity.lastActiveAt > TOPOLOGY_NODE_ACTIVITY_TTL_MS) {
|
||||
store.nodeActivity.delete(nodeId);
|
||||
changed = true;
|
||||
}
|
||||
}
|
||||
|
||||
for (const [rigId, lastActiveAt] of store.rigActivity.entries()) {
|
||||
if (nowMs - lastActiveAt > TOPOLOGY_RIG_ACTIVITY_TTL_MS) {
|
||||
store.rigActivity.delete(rigId);
|
||||
changed = true;
|
||||
}
|
||||
}
|
||||
|
||||
return changed;
|
||||
}
|
||||
|
||||
export function useTopologyActivity(index: TopologySessionIndex): TopologyActivitySnapshot {
|
||||
const [version, setVersion] = useState(0);
|
||||
const indexRef = useRef(index);
|
||||
const storeRef = useRef<TopologyActivityStore>(createStore());
|
||||
indexRef.current = index;
|
||||
|
||||
const bump = useCallback(() => {
|
||||
setVersion((current) => current + 1);
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
const unsubscribe = subscribeTopologyEvents((event) => {
|
||||
const parsed = parseTopologyActivityEvent(event);
|
||||
if (!parsed) return;
|
||||
|
||||
const nowMs = Date.now();
|
||||
const store = storeRef.current;
|
||||
const currentIndex = indexRef.current;
|
||||
let changed = false;
|
||||
|
||||
for (const item of parsed.sessions) {
|
||||
const resolved = resolveTopologySession(currentIndex, item.session);
|
||||
if (!resolved) {
|
||||
store.unresolvedCount += 1;
|
||||
changed = true;
|
||||
continue;
|
||||
}
|
||||
recordNodeActivity(store, resolved.nodeId, item.state, nowMs, item.flash ?? null);
|
||||
if (resolved.rigId) store.rigActivity.set(resolved.rigId, nowMs);
|
||||
changed = true;
|
||||
}
|
||||
|
||||
if (parsed.packet) {
|
||||
const source = resolveTopologySession(currentIndex, parsed.packet.sourceSession);
|
||||
const target = resolveTopologySession(currentIndex, parsed.packet.targetSession);
|
||||
if (source && target) {
|
||||
store.sequence += 1;
|
||||
store.packets.push(makeHotPotatoPacket({
|
||||
eventType: parsed.type,
|
||||
qitemId: parsed.qitemId,
|
||||
sourceSession: source,
|
||||
targetSession: target,
|
||||
createdAt: nowMs,
|
||||
sequence: store.sequence,
|
||||
}));
|
||||
recordNodeActivity(store, source.nodeId, "active", nowMs, "source");
|
||||
recordNodeActivity(store, target.nodeId, "active", nowMs, "target");
|
||||
if (source.rigId) store.rigActivity.set(source.rigId, nowMs);
|
||||
if (target.rigId) store.rigActivity.set(target.rigId, nowMs);
|
||||
changed = true;
|
||||
} else {
|
||||
store.unresolvedCount += 1;
|
||||
changed = true;
|
||||
}
|
||||
}
|
||||
|
||||
changed ||= pruneStore(store, nowMs);
|
||||
if (changed) bump();
|
||||
});
|
||||
return unsubscribe;
|
||||
}, [bump]);
|
||||
|
||||
useEffect(() => {
|
||||
const interval = window.setInterval(() => {
|
||||
const nowMs = Date.now();
|
||||
const store = storeRef.current;
|
||||
if (pruneStore(store, nowMs)) {
|
||||
bump();
|
||||
}
|
||||
}, 1_000);
|
||||
return () => window.clearInterval(interval);
|
||||
}, [bump]);
|
||||
|
||||
return useMemo(() => {
|
||||
const store = storeRef.current;
|
||||
const nowMs = Date.now();
|
||||
return {
|
||||
version,
|
||||
unresolvedCount: store.unresolvedCount,
|
||||
packets: [...store.packets],
|
||||
getNodeActivity(nodeId, baseline) {
|
||||
return computeActivityVisual({
|
||||
baseline,
|
||||
recent: store.nodeActivity.get(nodeId) ?? null,
|
||||
nowMs,
|
||||
});
|
||||
},
|
||||
isRigRecentlyActive(rigId) {
|
||||
if (!rigId) return false;
|
||||
return isRigRecentlyActive(store.rigActivity.get(rigId), nowMs);
|
||||
},
|
||||
};
|
||||
}, [version]);
|
||||
}
|
||||
@@ -58,6 +58,7 @@ interface StandaloneLayout {
|
||||
|
||||
export interface HybridRigLayoutInput {
|
||||
rigId: string;
|
||||
rigName?: string | null;
|
||||
nodes: HybridRawNode[];
|
||||
edges: HybridRawEdge[];
|
||||
collapsed: boolean;
|
||||
@@ -207,7 +208,7 @@ export function layoutHybridRig(input: HybridRigLayoutInput): HybridRigLayoutRes
|
||||
parentId: rigParentId,
|
||||
extent: "parent",
|
||||
position: pod.position,
|
||||
data: { ...(pod.node.data ?? {}), rigId: input.rigId, agentCount: pod.members.length },
|
||||
data: { ...(pod.node.data ?? {}), rigId: input.rigId, rigName: input.rigName ?? null, agentCount: pod.members.length },
|
||||
style: { ...(pod.node.style ?? {}), width: pod.width, height: pod.height },
|
||||
draggable: false,
|
||||
zIndex: 1,
|
||||
@@ -221,7 +222,7 @@ export function layoutHybridRig(input: HybridRigLayoutInput): HybridRigLayoutRes
|
||||
parentId: podId,
|
||||
extent: "parent",
|
||||
position: member.position,
|
||||
data: { ...(member.node.data ?? {}), rigId: input.rigId },
|
||||
data: { ...(member.node.data ?? {}), rigId: input.rigId, rigName: input.rigName ?? null },
|
||||
style: { ...(member.node.style ?? {}), width: HYBRID_AGENT_WIDTH, height: HYBRID_AGENT_HEIGHT },
|
||||
initialWidth: HYBRID_AGENT_WIDTH,
|
||||
initialHeight: HYBRID_AGENT_HEIGHT,
|
||||
@@ -241,7 +242,7 @@ export function layoutHybridRig(input: HybridRigLayoutInput): HybridRigLayoutRes
|
||||
parentId: rigParentId,
|
||||
extent: "parent",
|
||||
position: standalone.position,
|
||||
data: { ...(standalone.node.data ?? {}), rigId: input.rigId },
|
||||
data: { ...(standalone.node.data ?? {}), rigId: input.rigId, rigName: input.rigName ?? null },
|
||||
style: { ...(standalone.node.style ?? {}), width: HYBRID_AGENT_WIDTH, height: HYBRID_AGENT_HEIGHT },
|
||||
initialWidth: HYBRID_AGENT_WIDTH,
|
||||
initialHeight: HYBRID_AGENT_HEIGHT,
|
||||
|
||||
@@ -0,0 +1,434 @@
|
||||
import type { Edge, Node } from "@xyflow/react";
|
||||
import type { AgentActivitySummary, CurrentQitemSummary } from "../hooks/useNodeInventory.js";
|
||||
|
||||
const SESSION_PAIR_DELIMITER = "\u0000";
|
||||
|
||||
export const TOPOLOGY_NODE_ACTIVITY_TTL_MS = 10_000;
|
||||
export const TOPOLOGY_RIG_ACTIVITY_TTL_MS = 4_000;
|
||||
export const HOT_POTATO_WITHIN_RIG_DURATION_MS = 1_100;
|
||||
export const HOT_POTATO_CROSS_RIG_DURATION_MS = 1_600;
|
||||
export const HOT_POTATO_TRAIL_TTL_MS = 3_000;
|
||||
export const HOT_POTATO_SOURCE_FLASH_MS = 650;
|
||||
export const HOT_POTATO_TARGET_FLASH_MS = 900;
|
||||
export const HOT_POTATO_CROSS_RIG_STROKE_DASH = "6 7";
|
||||
|
||||
export type ActivityRingState = "active" | "needs_input" | "blocked" | "idle";
|
||||
export type ActivityFlash = "source" | "target" | null;
|
||||
|
||||
export interface TopologyActivityBaseline {
|
||||
agentActivity?: AgentActivitySummary | null;
|
||||
currentQitems?: CurrentQitemSummary[] | null;
|
||||
startupStatus?: string | null;
|
||||
}
|
||||
|
||||
export interface TopologyActivityVisual {
|
||||
state: ActivityRingState;
|
||||
flash: ActivityFlash;
|
||||
recent: boolean;
|
||||
}
|
||||
|
||||
export interface TopologySessionIndexEntry extends TopologyActivityBaseline {
|
||||
nodeId: string;
|
||||
rigId?: string | null;
|
||||
rigName?: string | null;
|
||||
logicalId?: string | null;
|
||||
canonicalSessionName?: string | null;
|
||||
}
|
||||
|
||||
export interface TopologyResolvedSession extends TopologySessionIndexEntry {
|
||||
session: string;
|
||||
}
|
||||
|
||||
export interface TopologySessionIndex {
|
||||
byNodeId: Map<string, TopologySessionIndexEntry>;
|
||||
bySession: Map<string, TopologySessionIndexEntry | null>;
|
||||
}
|
||||
|
||||
export interface ParsedTopologyEvent {
|
||||
type: string;
|
||||
qitemId?: string | null;
|
||||
packet?: {
|
||||
sourceSession: string;
|
||||
targetSession: string;
|
||||
};
|
||||
sessions: Array<{
|
||||
session: string;
|
||||
state: ActivityRingState;
|
||||
flash?: ActivityFlash;
|
||||
}>;
|
||||
}
|
||||
|
||||
export interface TopologyRecentNodeActivity {
|
||||
state: ActivityRingState;
|
||||
lastActiveAt: number;
|
||||
sourceFlashAt?: number;
|
||||
targetFlashAt?: number;
|
||||
}
|
||||
|
||||
export interface HotPotatoPacket {
|
||||
id: string;
|
||||
eventType: string;
|
||||
qitemId?: string | null;
|
||||
sourceSession: string;
|
||||
targetSession: string;
|
||||
sourceNodeId: string;
|
||||
targetNodeId: string;
|
||||
sourceRigId?: string | null;
|
||||
targetRigId?: string | null;
|
||||
crossRig: boolean;
|
||||
createdAt: number;
|
||||
durationMs: number;
|
||||
}
|
||||
|
||||
export interface HotPotatoEdgeData {
|
||||
hotPotatoPacket?: HotPotatoPacket | null;
|
||||
hotPotatoRecent?: boolean;
|
||||
hotPotatoCrossRig?: boolean;
|
||||
hotPotatoReducedMotion?: boolean;
|
||||
}
|
||||
|
||||
export function buildTopologySessionIndex(entries: readonly TopologySessionIndexEntry[]): TopologySessionIndex {
|
||||
const byNodeId = new Map<string, TopologySessionIndexEntry>();
|
||||
const bySession = new Map<string, TopologySessionIndexEntry | null>();
|
||||
|
||||
const addKey = (key: string | null | undefined, entry: TopologySessionIndexEntry) => {
|
||||
const clean = key?.trim();
|
||||
if (!clean) return;
|
||||
for (const candidate of [clean, clean.toLowerCase()]) {
|
||||
const existing = bySession.get(candidate);
|
||||
if (existing && existing.nodeId !== entry.nodeId) {
|
||||
bySession.set(candidate, null);
|
||||
} else if (!bySession.has(candidate)) {
|
||||
bySession.set(candidate, entry);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
for (const entry of entries) {
|
||||
byNodeId.set(entry.nodeId, entry);
|
||||
const localId = localNodeId(entry.nodeId);
|
||||
addKey(entry.nodeId, entry);
|
||||
addKey(localId, entry);
|
||||
addKey(entry.logicalId, entry);
|
||||
addKey(entry.canonicalSessionName, entry);
|
||||
for (const rigToken of [entry.rigName, entry.rigId]) {
|
||||
if (!rigToken) continue;
|
||||
addKey(`${entry.logicalId ?? localId}@${rigToken}`, entry);
|
||||
if (entry.canonicalSessionName?.includes("@")) {
|
||||
const [sessionLocal] = entry.canonicalSessionName.split("@");
|
||||
addKey(`${sessionLocal}@${rigToken}`, entry);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return { byNodeId, bySession };
|
||||
}
|
||||
|
||||
export function resolveTopologySession(
|
||||
index: TopologySessionIndex,
|
||||
session: string | null | undefined,
|
||||
): TopologyResolvedSession | null {
|
||||
const clean = session?.trim();
|
||||
if (!clean) return null;
|
||||
const direct = index.bySession.get(clean) ?? index.bySession.get(clean.toLowerCase());
|
||||
if (direct) return { ...direct, session: clean };
|
||||
if (direct === null) return null;
|
||||
|
||||
const at = clean.lastIndexOf("@");
|
||||
if (at < 0) return null;
|
||||
const local = clean.slice(0, at);
|
||||
const rigToken = clean.slice(at + 1);
|
||||
for (const entry of index.byNodeId.values()) {
|
||||
const entryLocal = entry.logicalId ?? localNodeId(entry.nodeId);
|
||||
const canonicalLocal = entry.canonicalSessionName?.split("@")[0] ?? null;
|
||||
const localMatches = entryLocal === local || canonicalLocal === local || localNodeId(entry.nodeId) === local;
|
||||
const rigMatches = entry.rigName === rigToken || entry.rigId === rigToken;
|
||||
if (localMatches && rigMatches) return { ...entry, session: clean };
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
export function getBaselineActivityState(input: TopologyActivityBaseline | null | undefined): ActivityRingState {
|
||||
if (!input) return "idle";
|
||||
if (input.startupStatus === "failed") return "blocked";
|
||||
if (input.startupStatus === "attention_required") return "needs_input";
|
||||
if (input.agentActivity?.state === "needs_input") return "needs_input";
|
||||
if (input.agentActivity?.state === "running") return "active";
|
||||
if ((input.currentQitems?.length ?? 0) > 0) return "active";
|
||||
return "idle";
|
||||
}
|
||||
|
||||
export function computeActivityVisual(input: {
|
||||
baseline?: TopologyActivityBaseline | null;
|
||||
recent?: TopologyRecentNodeActivity | null;
|
||||
nowMs: number;
|
||||
}): TopologyActivityVisual {
|
||||
const baseline = getBaselineActivityState(input.baseline);
|
||||
const recent = input.recent;
|
||||
const recentLive = recent && input.nowMs - recent.lastActiveAt <= TOPOLOGY_NODE_ACTIVITY_TTL_MS
|
||||
? recent
|
||||
: null;
|
||||
const state = recentLive ? strongestActivityState(baseline, recentLive.state) : baseline;
|
||||
const sourceFlash = recentLive?.sourceFlashAt && input.nowMs - recentLive.sourceFlashAt <= HOT_POTATO_SOURCE_FLASH_MS;
|
||||
const targetFlash = recentLive?.targetFlashAt && input.nowMs - recentLive.targetFlashAt <= HOT_POTATO_TARGET_FLASH_MS;
|
||||
return {
|
||||
state,
|
||||
flash: targetFlash ? "target" : sourceFlash ? "source" : null,
|
||||
recent: Boolean(recentLive),
|
||||
};
|
||||
}
|
||||
|
||||
export function isRigRecentlyActive(lastActiveAt: number | null | undefined, nowMs: number): boolean {
|
||||
return typeof lastActiveAt === "number" && nowMs - lastActiveAt <= TOPOLOGY_RIG_ACTIVITY_TTL_MS;
|
||||
}
|
||||
|
||||
export function parseTopologyActivityEvent(raw: unknown): ParsedTopologyEvent | null {
|
||||
if (!raw || typeof raw !== "object") return null;
|
||||
const event = raw as Record<string, unknown>;
|
||||
const type = stringField(event, "type");
|
||||
if (!type) return null;
|
||||
const qitemId = stringField(event, "qitemId");
|
||||
const active = (session: string | null, state: ActivityRingState = "active", flash: ActivityFlash = null) =>
|
||||
session ? { session, state, flash } : null;
|
||||
|
||||
if (type === "agent.activity") {
|
||||
const session = stringField(event, "sessionName");
|
||||
const activity = event["activity"] && typeof event["activity"] === "object"
|
||||
? event["activity"] as { state?: string }
|
||||
: null;
|
||||
const state = activity?.state === "needs_input"
|
||||
? "needs_input"
|
||||
: activity?.state === "running"
|
||||
? "active"
|
||||
: "idle";
|
||||
return {
|
||||
type,
|
||||
qitemId,
|
||||
sessions: [active(session, state)].filter(isPresent),
|
||||
};
|
||||
}
|
||||
|
||||
if (type === "queue.created") {
|
||||
const sourceSession = stringField(event, "sourceSession");
|
||||
const targetSession = stringField(event, "destinationSession");
|
||||
if (!sourceSession || !targetSession) return null;
|
||||
return {
|
||||
type,
|
||||
qitemId,
|
||||
packet: { sourceSession, targetSession },
|
||||
sessions: [
|
||||
active(sourceSession, "active", "source"),
|
||||
active(targetSession, "active", "target"),
|
||||
].filter(isPresent),
|
||||
};
|
||||
}
|
||||
|
||||
if (type === "queue.handed_off") {
|
||||
const sourceSession = stringField(event, "fromSession");
|
||||
const targetSession = stringField(event, "toSession");
|
||||
if (!sourceSession || !targetSession) return null;
|
||||
return {
|
||||
type,
|
||||
qitemId,
|
||||
packet: { sourceSession, targetSession },
|
||||
sessions: [
|
||||
active(sourceSession, "active", "source"),
|
||||
active(targetSession, "active", "target"),
|
||||
].filter(isPresent),
|
||||
};
|
||||
}
|
||||
|
||||
if (type === "queue.claimed") {
|
||||
const destination = stringField(event, "destinationSession");
|
||||
return {
|
||||
type,
|
||||
qitemId,
|
||||
sessions: [active(destination, "active", "target")].filter(isPresent),
|
||||
};
|
||||
}
|
||||
|
||||
if (type === "queue.updated") {
|
||||
const actor = stringField(event, "actorSession");
|
||||
const closureTarget = stringField(event, "closureTarget");
|
||||
const toState = stringField(event, "toState");
|
||||
const state: ActivityRingState = toState === "blocked" ? "blocked" : "active";
|
||||
return {
|
||||
type,
|
||||
qitemId,
|
||||
packet: actor && closureTarget ? { sourceSession: actor, targetSession: closureTarget } : undefined,
|
||||
sessions: [
|
||||
active(actor, state, "source"),
|
||||
active(closureTarget, state, "target"),
|
||||
].filter(isPresent),
|
||||
};
|
||||
}
|
||||
|
||||
if (type === "queue.unclaimed") {
|
||||
return {
|
||||
type,
|
||||
qitemId,
|
||||
sessions: [active(stringField(event, "destinationSession"), "active")].filter(isPresent),
|
||||
};
|
||||
}
|
||||
|
||||
if (type === "qitem.fallback_routed") {
|
||||
return {
|
||||
type,
|
||||
qitemId,
|
||||
sessions: [
|
||||
active(stringField(event, "originalDestination"), "active", "source"),
|
||||
active(stringField(event, "rerouteDestination"), "active", "target"),
|
||||
].filter(isPresent),
|
||||
};
|
||||
}
|
||||
|
||||
if (type === "qitem.closure_overdue") {
|
||||
return {
|
||||
type,
|
||||
qitemId,
|
||||
sessions: [active(stringField(event, "destinationSession"), "blocked", "target")].filter(isPresent),
|
||||
};
|
||||
}
|
||||
|
||||
if (type === "inbox.absorbed" || type === "inbox.denied") {
|
||||
return {
|
||||
type,
|
||||
qitemId,
|
||||
sessions: [
|
||||
active(stringField(event, "senderSession"), "active", "source"),
|
||||
active(stringField(event, "destinationSession"), "needs_input", "target"),
|
||||
].filter(isPresent),
|
||||
};
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
export function makeSessionPairKey(sourceNodeId: string, targetNodeId: string): string {
|
||||
return `${sourceNodeId}${SESSION_PAIR_DELIMITER}${targetNodeId}`;
|
||||
}
|
||||
|
||||
export function makeHotPotatoPacket(input: {
|
||||
eventType: string;
|
||||
qitemId?: string | null;
|
||||
sourceSession: TopologyResolvedSession;
|
||||
targetSession: TopologyResolvedSession;
|
||||
createdAt: number;
|
||||
sequence: number;
|
||||
}): HotPotatoPacket {
|
||||
const crossRig = Boolean(
|
||||
input.sourceSession.rigId &&
|
||||
input.targetSession.rigId &&
|
||||
input.sourceSession.rigId !== input.targetSession.rigId,
|
||||
);
|
||||
return {
|
||||
id: `${input.createdAt}-${input.sequence}-${input.sourceSession.nodeId}-${input.targetSession.nodeId}`,
|
||||
eventType: input.eventType,
|
||||
qitemId: input.qitemId ?? null,
|
||||
sourceSession: input.sourceSession.session,
|
||||
targetSession: input.targetSession.session,
|
||||
sourceNodeId: input.sourceSession.nodeId,
|
||||
targetNodeId: input.targetSession.nodeId,
|
||||
sourceRigId: input.sourceSession.rigId,
|
||||
targetRigId: input.targetSession.rigId,
|
||||
crossRig,
|
||||
createdAt: input.createdAt,
|
||||
durationMs: crossRig ? HOT_POTATO_CROSS_RIG_DURATION_MS : HOT_POTATO_WITHIN_RIG_DURATION_MS,
|
||||
};
|
||||
}
|
||||
|
||||
export function applyHotPotatoEdges(
|
||||
edges: readonly Edge[],
|
||||
packets: readonly HotPotatoPacket[],
|
||||
opts: { reducedMotion: boolean },
|
||||
): Edge[] {
|
||||
const latestPacketByPair = new Map<string, HotPotatoPacket>();
|
||||
for (const packet of packets) {
|
||||
latestPacketByPair.set(makeSessionPairKey(packet.sourceNodeId, packet.targetNodeId), packet);
|
||||
}
|
||||
|
||||
const matchedPairs = new Set<string>();
|
||||
const output = edges.map((edge) => {
|
||||
const key = makeSessionPairKey(edge.source, edge.target);
|
||||
const packet = latestPacketByPair.get(key) ?? null;
|
||||
if (packet) matchedPairs.add(key);
|
||||
return decorateHotPotatoEdge(edge, packet, opts.reducedMotion);
|
||||
});
|
||||
|
||||
for (const packet of packets) {
|
||||
const key = makeSessionPairKey(packet.sourceNodeId, packet.targetNodeId);
|
||||
if (matchedPairs.has(key)) continue;
|
||||
output.push(decorateHotPotatoEdge({
|
||||
id: `hot-potato-${packet.id}`,
|
||||
source: packet.sourceNodeId,
|
||||
target: packet.targetNodeId,
|
||||
type: "hotPotato",
|
||||
selectable: false,
|
||||
focusable: false,
|
||||
interactionWidth: 8,
|
||||
style: hotPotatoEdgeStyle(packet.crossRig),
|
||||
data: {},
|
||||
}, packet, opts.reducedMotion));
|
||||
}
|
||||
|
||||
return output;
|
||||
}
|
||||
|
||||
function decorateHotPotatoEdge(edge: Edge, packet: HotPotatoPacket | null, reducedMotion: boolean): Edge {
|
||||
const crossRig = packet?.crossRig || (edge.style as { strokeDasharray?: string } | undefined)?.strokeDasharray === HOT_POTATO_CROSS_RIG_STROKE_DASH;
|
||||
const data = {
|
||||
...(edge.data ?? {}),
|
||||
hotPotatoPacket: packet,
|
||||
hotPotatoRecent: Boolean(packet),
|
||||
hotPotatoCrossRig: crossRig,
|
||||
hotPotatoReducedMotion: reducedMotion,
|
||||
} satisfies HotPotatoEdgeData & Record<string, unknown>;
|
||||
return {
|
||||
...edge,
|
||||
type: "hotPotato",
|
||||
className: [edge.className, packet ? "hot-potato-edge-active" : ""].filter(Boolean).join(" ") || undefined,
|
||||
data,
|
||||
style: {
|
||||
...(edge.style ?? {}),
|
||||
...(packet ? hotPotatoEdgeStyle(packet.crossRig) : {}),
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function hotPotatoEdgeStyle(crossRig: boolean): Edge["style"] {
|
||||
return {
|
||||
stroke: crossRig ? "#a8a29e" : "#047857",
|
||||
strokeWidth: crossRig ? 1.25 : 2.25,
|
||||
strokeDasharray: crossRig ? HOT_POTATO_CROSS_RIG_STROKE_DASH : undefined,
|
||||
};
|
||||
}
|
||||
|
||||
function strongestActivityState(a: ActivityRingState, b: ActivityRingState): ActivityRingState {
|
||||
return activityRank(b) > activityRank(a) ? b : a;
|
||||
}
|
||||
|
||||
function activityRank(state: ActivityRingState): number {
|
||||
if (state === "blocked") return 3;
|
||||
if (state === "needs_input") return 2;
|
||||
if (state === "active") return 1;
|
||||
return 0;
|
||||
}
|
||||
|
||||
function localNodeId(nodeId: string): string {
|
||||
const index = nodeId.indexOf("::");
|
||||
return index < 0 ? nodeId : nodeId.slice(index + 2);
|
||||
}
|
||||
|
||||
function stringField(record: Record<string, unknown>, key: string): string | null {
|
||||
const value = record[key];
|
||||
return typeof value === "string" && value.trim() ? value : null;
|
||||
}
|
||||
|
||||
function isPresent<T>(value: T | null): value is T {
|
||||
return value !== null;
|
||||
}
|
||||
|
||||
export const __test_internals = {
|
||||
strongestActivityState,
|
||||
stringField,
|
||||
};
|
||||
@@ -1,11 +1,8 @@
|
||||
// V1 polish slice Phase 5.1 P5.1-7 — TopologyTableView CMUX column +
|
||||
// V1 polish slice Phase 5.1 P5.1-7: TopologyTableView CMUX column +
|
||||
// row click navigation regression guard.
|
||||
//
|
||||
// Founder direction at V1 founder-walk:
|
||||
// > "[Table view] is missing some columns... missing the CMUX button
|
||||
// > where you can basically click a button to launch cmux. Also you
|
||||
// > should be able to click one of the agents and it should open up
|
||||
// > the agent detail page."
|
||||
// Founder-walk direction requires a cmux launch affordance and row-click
|
||||
// navigation to the agent detail page.
|
||||
|
||||
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
|
||||
import { render, fireEvent, cleanup, waitFor } from "@testing-library/react";
|
||||
@@ -73,6 +70,8 @@ describe("TopologyTableView P5.1-7 CMUX column + row click", () => {
|
||||
const cmux = await findByTestId("topology-table-cmux-orch.lead");
|
||||
expect(cmux).toBeTruthy();
|
||||
expect((cmux as HTMLButtonElement).textContent).toContain("CMUX");
|
||||
expect((cmux as HTMLButtonElement).className).toContain("opacity-0");
|
||||
expect((cmux as HTMLButtonElement).className).toContain("group-hover:opacity-100");
|
||||
});
|
||||
|
||||
it("CMUX button click POSTs to /api/rigs/.../focus (existing useCmuxLaunch hook)", async () => {
|
||||
|
||||
@@ -0,0 +1,126 @@
|
||||
import { afterEach, describe, expect, it } from "vitest";
|
||||
import { cleanup, render, screen } from "@testing-library/react";
|
||||
import { readFileSync } from "node:fs";
|
||||
import path from "node:path";
|
||||
import { ActivityRing } from "../src/components/topology/ActivityRing.js";
|
||||
import { HotPotatoEdge } from "../src/components/topology/HotPotatoEdge.js";
|
||||
import {
|
||||
HOT_POTATO_WITHIN_RIG_DURATION_MS,
|
||||
type HotPotatoPacket,
|
||||
} from "../src/lib/topology-activity.js";
|
||||
|
||||
function packet(): HotPotatoPacket {
|
||||
return {
|
||||
id: "packet-1",
|
||||
eventType: "queue.created",
|
||||
qitemId: "q1",
|
||||
sourceSession: "orch.lead@rig",
|
||||
targetSession: "dev.driver@rig",
|
||||
sourceNodeId: "orch.lead",
|
||||
targetNodeId: "dev.driver",
|
||||
sourceRigId: "rig-1",
|
||||
targetRigId: "rig-1",
|
||||
crossRig: false,
|
||||
createdAt: Date.now(),
|
||||
durationMs: HOT_POTATO_WITHIN_RIG_DURATION_MS,
|
||||
};
|
||||
}
|
||||
|
||||
function edgeProps(reducedMotion: boolean) {
|
||||
return {
|
||||
id: "edge-1",
|
||||
source: "orch.lead",
|
||||
target: "dev.driver",
|
||||
sourceX: 0,
|
||||
sourceY: 0,
|
||||
targetX: 120,
|
||||
targetY: 40,
|
||||
sourcePosition: "right",
|
||||
targetPosition: "left",
|
||||
markerEnd: undefined,
|
||||
markerStart: undefined,
|
||||
interactionWidth: 10,
|
||||
data: {
|
||||
hotPotatoPacket: packet(),
|
||||
hotPotatoReducedMotion: reducedMotion,
|
||||
hotPotatoCrossRig: false,
|
||||
},
|
||||
style: {},
|
||||
selected: false,
|
||||
animated: false,
|
||||
} as any;
|
||||
}
|
||||
|
||||
describe("P5.3 ActivityRing and HotPotatoEdge", () => {
|
||||
afterEach(() => cleanup());
|
||||
|
||||
it("renders no ring for idle state", () => {
|
||||
render(
|
||||
<ActivityRing state="idle" testId="idle-ring">
|
||||
<span>agent</span>
|
||||
</ActivityRing>,
|
||||
);
|
||||
expect(screen.queryByTestId("idle-ring")).toBeNull();
|
||||
});
|
||||
|
||||
it("renders active ring with reduced-motion data and without pulse class", () => {
|
||||
render(
|
||||
<ActivityRing state="active" reducedMotion testId="active-ring">
|
||||
<span>agent</span>
|
||||
</ActivityRing>,
|
||||
);
|
||||
const ring = screen.getByTestId("active-ring");
|
||||
expect(ring.getAttribute("data-activity-ring-state")).toBe("active");
|
||||
expect(ring.getAttribute("data-reduced-motion")).toBe("true");
|
||||
expect(ring.className).not.toContain("activity-ring-active");
|
||||
});
|
||||
|
||||
it("HotPotatoEdge uses animateMotion when motion is allowed", () => {
|
||||
const { container } = render(
|
||||
<svg>
|
||||
<HotPotatoEdge {...edgeProps(false)} />
|
||||
</svg>,
|
||||
);
|
||||
expect(container.querySelector("animateMotion")).not.toBeNull();
|
||||
expect(screen.getByTestId("hot-potato-packet-packet-1").getAttribute("data-reduced-motion")).toBe("false");
|
||||
});
|
||||
|
||||
it("HotPotatoEdge substitutes a static packet under reduced motion", () => {
|
||||
const { container } = render(
|
||||
<svg>
|
||||
<HotPotatoEdge {...edgeProps(true)} />
|
||||
</svg>,
|
||||
);
|
||||
expect(container.querySelector("animateMotion")).toBeNull();
|
||||
expect(screen.getByTestId("hot-potato-packet-packet-1").getAttribute("data-reduced-motion")).toBe("true");
|
||||
});
|
||||
|
||||
it("source scan keeps activity and packet layers production-reachable", () => {
|
||||
const srcRoot = path.resolve(__dirname, "../src");
|
||||
const host = readFileSync(path.join(srcRoot, "components/topology/HostMultiRigGraph.tsx"), "utf8");
|
||||
const rigGraph = readFileSync(path.join(srcRoot, "components/RigGraph.tsx"), "utf8");
|
||||
const hybridNodes = readFileSync(path.join(srcRoot, "components/topology/HybridTopologyNodes.tsx"), "utf8");
|
||||
const table = readFileSync(path.join(srcRoot, "components/topology/TopologyTableView.tsx"), "utf8");
|
||||
const ring = readFileSync(path.join(srcRoot, "components/topology/ActivityRing.tsx"), "utf8");
|
||||
const edge = readFileSync(path.join(srcRoot, "components/topology/HotPotatoEdge.tsx"), "utf8");
|
||||
const activity = readFileSync(path.join(srcRoot, "lib/topology-activity.ts"), "utf8");
|
||||
const css = readFileSync(path.join(srcRoot, "globals.css"), "utf8");
|
||||
|
||||
expect(host).toMatch(/useTopologyActivity/);
|
||||
expect(host).toMatch(/applyHotPotatoEdges/);
|
||||
expect(host).toMatch(/edgeTypes=\{edgeTypes\}/);
|
||||
expect(rigGraph).toMatch(/useTopologyActivity/);
|
||||
expect(rigGraph).toMatch(/applyHotPotatoEdges/);
|
||||
expect(rigGraph).toMatch(/edgeTypes=\{edgeTypes\}/);
|
||||
expect(hybridNodes).toMatch(/ActivityRing/);
|
||||
expect(table).toMatch(/ActivityRing/);
|
||||
expect(table).toMatch(/group-hover:opacity-100/);
|
||||
expect(ring).not.toMatch(/StatusPip/);
|
||||
expect(edge).toMatch(/animateMotion/);
|
||||
expect(activity).toMatch(/TOPOLOGY_NODE_ACTIVITY_TTL_MS/);
|
||||
expect(activity).toMatch(/HOT_POTATO_CROSS_RIG_STROKE_DASH/);
|
||||
expect(css).toMatch(/@media \(prefers-reduced-motion: reduce\)/);
|
||||
expect(css).toMatch(/activity-ring-active/);
|
||||
expect(css).toMatch(/rig-activity-frame-pulse/);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,137 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import {
|
||||
HOT_POTATO_CROSS_RIG_DURATION_MS,
|
||||
HOT_POTATO_TRAIL_TTL_MS,
|
||||
HOT_POTATO_WITHIN_RIG_DURATION_MS,
|
||||
TOPOLOGY_NODE_ACTIVITY_TTL_MS,
|
||||
TOPOLOGY_RIG_ACTIVITY_TTL_MS,
|
||||
buildTopologySessionIndex,
|
||||
computeActivityVisual,
|
||||
getBaselineActivityState,
|
||||
makeHotPotatoPacket,
|
||||
parseTopologyActivityEvent,
|
||||
resolveTopologySession,
|
||||
} from "../src/lib/topology-activity.js";
|
||||
|
||||
describe("P5.3 topology activity parser and resolver", () => {
|
||||
const index = buildTopologySessionIndex([
|
||||
{
|
||||
nodeId: "rig-a::orch.lead",
|
||||
rigId: "rig-a",
|
||||
rigName: "openrig-a",
|
||||
logicalId: "orch.lead",
|
||||
canonicalSessionName: "orch.lead@openrig-a",
|
||||
},
|
||||
{
|
||||
nodeId: "rig-b::dev.driver",
|
||||
rigId: "rig-b",
|
||||
rigName: "openrig-b",
|
||||
logicalId: "dev.driver",
|
||||
canonicalSessionName: "dev.driver@openrig-b",
|
||||
},
|
||||
]);
|
||||
|
||||
it("resolves canonical sessions, logicalId@rigName, and prefixed node ids", () => {
|
||||
expect(resolveTopologySession(index, "orch.lead@openrig-a")?.nodeId).toBe("rig-a::orch.lead");
|
||||
expect(resolveTopologySession(index, "dev.driver@openrig-b")?.nodeId).toBe("rig-b::dev.driver");
|
||||
expect(resolveTopologySession(index, "rig-a::orch.lead")?.nodeId).toBe("rig-a::orch.lead");
|
||||
expect(resolveTopologySession(index, "missing@openrig-a")).toBeNull();
|
||||
});
|
||||
|
||||
it("queue created and handed-off events create directional packets", () => {
|
||||
expect(parseTopologyActivityEvent({
|
||||
type: "queue.created",
|
||||
qitemId: "q1",
|
||||
sourceSession: "orch.lead@openrig-a",
|
||||
destinationSession: "dev.driver@openrig-b",
|
||||
})?.packet).toEqual({
|
||||
sourceSession: "orch.lead@openrig-a",
|
||||
targetSession: "dev.driver@openrig-b",
|
||||
});
|
||||
expect(parseTopologyActivityEvent({
|
||||
type: "queue.handed_off",
|
||||
qitemId: "q2",
|
||||
fromSession: "dev.driver@openrig-b",
|
||||
toSession: "orch.lead@openrig-a",
|
||||
})?.packet).toEqual({
|
||||
sourceSession: "dev.driver@openrig-b",
|
||||
targetSession: "orch.lead@openrig-a",
|
||||
});
|
||||
});
|
||||
|
||||
it("claimed flashes destination only while update packets require a closure target", () => {
|
||||
const claimed = parseTopologyActivityEvent({
|
||||
type: "queue.claimed",
|
||||
qitemId: "q1",
|
||||
destinationSession: "dev.driver@openrig-b",
|
||||
});
|
||||
expect(claimed?.packet).toBeUndefined();
|
||||
expect(claimed?.sessions[0]).toEqual({
|
||||
session: "dev.driver@openrig-b",
|
||||
state: "active",
|
||||
flash: "target",
|
||||
});
|
||||
|
||||
const updated = parseTopologyActivityEvent({
|
||||
type: "queue.updated",
|
||||
qitemId: "q2",
|
||||
actorSession: "orch.lead@openrig-a",
|
||||
toState: "done",
|
||||
closureTarget: "dev.driver@openrig-b",
|
||||
});
|
||||
expect(updated?.packet).toEqual({
|
||||
sourceSession: "orch.lead@openrig-a",
|
||||
targetSession: "dev.driver@openrig-b",
|
||||
});
|
||||
});
|
||||
|
||||
it("classifies baseline activity without using StatusPip semantics", () => {
|
||||
expect(getBaselineActivityState({
|
||||
agentActivity: { state: "running", reason: "x", evidenceSource: "test", sampledAt: "now" },
|
||||
})).toBe("active");
|
||||
expect(getBaselineActivityState({
|
||||
agentActivity: { state: "needs_input", reason: "x", evidenceSource: "test", sampledAt: "now" },
|
||||
})).toBe("needs_input");
|
||||
expect(getBaselineActivityState({ currentQitems: [{ qitemId: "q", bodyExcerpt: "work", tier: null }] })).toBe("active");
|
||||
expect(getBaselineActivityState({ startupStatus: "failed" })).toBe("blocked");
|
||||
expect(getBaselineActivityState({
|
||||
agentActivity: { state: "idle", reason: "x", evidenceSource: "test", sampledAt: "now" },
|
||||
})).toBe("idle");
|
||||
});
|
||||
|
||||
it("expires event-driven activity after the shared TTL", () => {
|
||||
const nowMs = 10_000;
|
||||
expect(computeActivityVisual({
|
||||
baseline: null,
|
||||
recent: { state: "active", lastActiveAt: nowMs - 1_000 },
|
||||
nowMs,
|
||||
}).state).toBe("active");
|
||||
expect(computeActivityVisual({
|
||||
baseline: null,
|
||||
recent: { state: "active", lastActiveAt: nowMs - TOPOLOGY_NODE_ACTIVITY_TTL_MS - 1 },
|
||||
nowMs,
|
||||
}).state).toBe("idle");
|
||||
});
|
||||
|
||||
it("uses shared constants for packet duration and retention", () => {
|
||||
const source = resolveTopologySession(index, "orch.lead@openrig-a")!;
|
||||
const target = resolveTopologySession(index, "dev.driver@openrig-b")!;
|
||||
const crossRig = makeHotPotatoPacket({
|
||||
eventType: "queue.created",
|
||||
sourceSession: source,
|
||||
targetSession: target,
|
||||
createdAt: 1,
|
||||
sequence: 1,
|
||||
});
|
||||
expect(crossRig.crossRig).toBe(true);
|
||||
expect(crossRig.durationMs).toBe(HOT_POTATO_CROSS_RIG_DURATION_MS);
|
||||
expect(HOT_POTATO_WITHIN_RIG_DURATION_MS).toBeGreaterThanOrEqual(900);
|
||||
expect(HOT_POTATO_WITHIN_RIG_DURATION_MS).toBeLessThanOrEqual(1_300);
|
||||
expect(HOT_POTATO_CROSS_RIG_DURATION_MS).toBeGreaterThanOrEqual(1_400);
|
||||
expect(HOT_POTATO_CROSS_RIG_DURATION_MS).toBeLessThanOrEqual(1_800);
|
||||
expect(HOT_POTATO_TRAIL_TTL_MS).toBeGreaterThanOrEqual(2_000);
|
||||
expect(HOT_POTATO_TRAIL_TTL_MS).toBeLessThanOrEqual(4_000);
|
||||
expect(TOPOLOGY_RIG_ACTIVITY_TTL_MS).toBeGreaterThanOrEqual(3_000);
|
||||
expect(TOPOLOGY_RIG_ACTIVITY_TTL_MS).toBeLessThanOrEqual(5_000);
|
||||
});
|
||||
});
|
||||
@@ -5,7 +5,9 @@ import { readFileSync } from "node:fs";
|
||||
import path from "node:path";
|
||||
import { useActivityFeed } from "../src/hooks/useActivityFeed.js";
|
||||
import { useGlobalEvents } from "../src/hooks/useGlobalEvents.js";
|
||||
import { useTopologyActivity } from "../src/hooks/useTopologyActivity.js";
|
||||
import { useTopologyEdgeActivity } from "../src/hooks/useTopologyEdgeActivity.js";
|
||||
import { buildTopologySessionIndex } from "../src/lib/topology-activity.js";
|
||||
import { createMockEventSourceClass, instances } from "./helpers/mock-event-source.js";
|
||||
|
||||
let OriginalEventSource: typeof EventSource | undefined;
|
||||
@@ -14,11 +16,28 @@ function EventHubHarness() {
|
||||
useGlobalEvents();
|
||||
const feed = useActivityFeed();
|
||||
const edgeActivity = useTopologyEdgeActivity();
|
||||
const topologyActivity = useTopologyActivity(buildTopologySessionIndex([
|
||||
{
|
||||
nodeId: "rig-1::orch.lead",
|
||||
rigId: "rig-1",
|
||||
rigName: "rig-1",
|
||||
logicalId: "orch.lead",
|
||||
canonicalSessionName: "orch.lead@rig-1",
|
||||
},
|
||||
{
|
||||
nodeId: "rig-1::dev.driver",
|
||||
rigId: "rig-1",
|
||||
rigName: "rig-1",
|
||||
logicalId: "dev.driver",
|
||||
canonicalSessionName: "dev.driver@rig-1",
|
||||
},
|
||||
]));
|
||||
return (
|
||||
<div>
|
||||
<span data-testid="hub-connected">{String(feed.connected)}</span>
|
||||
<span data-testid="hub-event-count">{feed.events.length}</span>
|
||||
<span data-testid="hub-edge-version">{edgeActivity.version}</span>
|
||||
<span data-testid="hub-packet-count">{topologyActivity.packets.length}</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -70,6 +89,7 @@ describe("P5.3 shared topology event hub", () => {
|
||||
await waitFor(() => {
|
||||
expect(screen.getByTestId("hub-event-count").textContent).toBe("1");
|
||||
expect(Number(screen.getByTestId("hub-edge-version").textContent)).toBeGreaterThan(0);
|
||||
expect(screen.getByTestId("hub-packet-count").textContent).toBe("1");
|
||||
});
|
||||
|
||||
unmount();
|
||||
@@ -83,6 +103,7 @@ describe("P5.3 shared topology event hub", () => {
|
||||
"hooks/useGlobalEvents.ts",
|
||||
"hooks/useRigEvents.ts",
|
||||
"hooks/useTopologyEdgeActivity.ts",
|
||||
"hooks/useTopologyActivity.ts",
|
||||
];
|
||||
for (const relative of hookFiles) {
|
||||
const src = readFileSync(path.join(srcRoot, relative), "utf8");
|
||||
|
||||
Reference in new issue
Block a user