Files
Claude-Code-Monitor/client/src/components/workflows/OrchestrationDAG.tsx
T
nntrivi2001 7357070fb9 chore: remove unused desktop app, cloud deployment infra, and monitoring stack
Deletes desktop/ (Electron wrapper), deployments/ (Helm/Kustomize/
Terraform/CI for cloud deploy), and monitoring/ (Prometheus + Grafana
stack) along with DESKTOP.md, DEPLOYMENT.md, docker-compose.full.yml,
their npm scripts, and every dangling reference across README,
ARCHITECTURE, INSTALL, SETUP, docs/, and the repeated per-file
MODULE_GUIDE "Observability" boilerplate comment. The GET /api/metrics
endpoint itself is untouched — it's the dashboard's own route, not
part of the removed monitoring stack.
2026-08-11 12:16:54 +07:00

1081 lines
37 KiB
TypeScript

/**
* @file OrchestrationDAG.tsx
* @description Defines the OrchestrationDAG React component that visualizes orchestration data as a directed acyclic graph (DAG) using D3.js. The component takes in orchestration data, processes it to build a graph structure with nodes and edges, and renders it as an SVG. It includes interactive features such as tooltips on hover and click handlers for nodes. The graph is styled with gradients and colors to differentiate between different types of nodes and outcomes, providing a clear visual representation of the orchestration process.
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
*/
/* =============================================================================
* MODULE_GUIDE — extended in-file reference (comments only; safe to read, never executed)
* =============================================================================
* **Purpose:** Workflow analytics visualization built on D3; consumes aggregated session/run metrics from the workflows API.
*
* ## Design constraints
* - Local-first: no telemetry leaves the machine unless the user configures webhooks.
* - Fail-safe hooks path on the server must never block Claude Code; UI mirrors that
* philosophy by degrading gracefully (empty states, stale badges, reconnect loops).
* - Destructive flows stay behind explicit confirmation modals and server-side gates.
* - Internationalization: user-visible strings belong in i18n JSON, not literals here.
*
* ## Remote data & SSH
* Remote Data Sources let operators aggregate multiple machines. SSH entries describe
* how to reach a peer dashboard; the global data scope (`dataScope.ts`) narrows every
* scoped GET via `?sources=`. Health checks and import history surface in Settings.
*
* ## Internal dependencies
* - `../../lib/types`
*
* ## Public surface
* - `OrchestrationDAG` — exported API; see TSDoc on the symbol for behavior.
*
* ## Testing pointers
* - Prefer colocated `__tests__` with Vitest + Testing Library for UI.
* - Server contract changes require `npm run test:server` and OpenAPI sync.
* - MCP edits: `npm run mcp:typecheck` and `npm run mcp:build`.
*
* ## Related docs
* - `ARCHITECTURE.md` — hooks → API → SQLite → WebSocket → UI pipeline.
* - `docs/API.md` — REST reference.
* - `.claude/skills/file-headers/` — mandatory `@author` header policy.
* ============================================================================= */
/* -----------------------------------------------------------------------------
* EXPORT CATALOG — quick index of symbols defined below (documentation only).
* -----------------------------------------------------------------------------
* **OrchestrationDAG**
* Part of this module's public contract. Downstream imports should treat
* the signature and return type as stable unless release notes say otherwise.
* When behavior changes, update the `@file` overview and relevant tests.
*
* ----------------------------------------------------------------------------- */
import { useRef, useEffect, useMemo, useState, useCallback } from "react";
import { useTranslation } from "react-i18next";
import * as d3 from "d3";
import type { OrchestrationData } from "../../lib/types";
// ── Types ─────────────────────────────────────────────────────────────────────
interface OrchestrationDAGProps {
data: OrchestrationData;
onNodeClick?: (nodeType: string) => void;
selectedNode?: string | null;
}
interface DAGNode {
id: string;
label: string;
count: number;
layer: number;
kind: "session" | "main" | "subagent" | "nested" | "outcome";
meta?: {
completed?: number;
errors?: number;
subagent_type?: string;
status?: string;
};
x: number;
y: number;
width: number;
height: number;
}
interface DAGEdge {
source: string;
target: string;
weight: number;
sourceNode?: DAGNode;
targetNode?: DAGNode;
}
type TFn = (key: string, options?: Record<string, unknown>) => string;
// ── Constants ─────────────────────────────────────────────────────────────────
const NODE_W = 136;
const NODE_H = 44;
const NODE_RX = 8;
const LAYER_GAP = 80;
const NODE_V_GAP = 8;
const PADDING_X = 8;
const PADDING_TOP = 44;
const PADDING_BOTTOM = 40;
const MAX_SUBAGENT_NODES = 7;
const MAX_EDGE_STROKE = 10;
const MIN_EDGE_STROKE = 1.5;
const BADGE_W = 28;
const BADGE_H = 14;
// Layer labels are now computed via i18n inside the component
const OUTCOME_COLORS: Record<string, { fill: string; stroke: string; text: string }> = {
completed: { fill: "#052e16", stroke: "#16a34a", text: "#4ade80" },
error: { fill: "#1f0808", stroke: "#dc2626", text: "#f87171" },
abandoned: { fill: "#1c1a04", stroke: "#ca8a04", text: "#facc15" },
};
const KIND_GRADIENTS: Record<
DAGNode["kind"],
{ id: string; stops: Array<{ offset: string; color: string }> }
> = {
session: {
id: "grad-session",
stops: [
{ offset: "0%", color: "#312e81" },
{ offset: "100%", color: "#4338ca" },
],
},
main: {
id: "grad-main",
stops: [
{ offset: "0%", color: "#1e3a5f" },
{ offset: "100%", color: "#1d4ed8" },
],
},
subagent: {
id: "grad-subagent",
stops: [
{ offset: "0%", color: "#052e16" },
{ offset: "100%", color: "#166534" },
],
},
nested: {
id: "grad-nested",
stops: [
{ offset: "0%", color: "#134e4a" },
{ offset: "100%", color: "#0f766e" },
],
},
outcome: {
id: "grad-outcome",
stops: [
{ offset: "0%", color: "#1e1b4b" },
{ offset: "100%", color: "#4338ca" },
],
},
};
// ── Helpers ───────────────────────────────────────────────────────────────────
function isEmpty(data: OrchestrationData): boolean {
return (
data.sessionCount === 0 &&
data.mainCount === 0 &&
data.subagentTypes.length === 0 &&
data.outcomes.length === 0
);
}
function successRate(completed: number, total: number): string {
if (total === 0) return "-";
return `${Math.round((completed / total) * 100)}%`;
}
function outcomeColorSet(status: string) {
return OUTCOME_COLORS[status] ?? { fill: "#1a1a28", stroke: "#363650", text: "#9ca3af" };
}
// ── Layout builder ────────────────────────────────────────────────────────────
function buildGraph(
data: OrchestrationData,
t: (key: string, options?: Record<string, unknown>) => string
): {
nodes: DAGNode[];
edges: DAGEdge[];
svgWidth: number;
svgHeight: number;
} {
const rawNodes: Omit<DAGNode, "x" | "y">[] = [];
// Layer 0 - sessions
rawNodes.push({
id: "sessions",
label: t("orchestration.sessions"),
count: data.sessionCount,
layer: 0,
kind: "session",
width: NODE_W,
height: NODE_H,
});
// Layer 1 - main agent
rawNodes.push({
id: "main",
label: t("orchestration.mainAgent"),
count: data.mainCount,
layer: 1,
kind: "main",
width: NODE_W,
height: NODE_H,
});
// Layer 2 - subagent types (deduplicated, capped at MAX_SUBAGENT_NODES)
const subagentMap = new Map<string, { count: number; completed: number; errors: number }>();
for (const s of data.subagentTypes) {
const key = s.subagent_type || "unknown";
const existing = subagentMap.get(key);
if (existing) {
existing.count += s.count;
existing.completed += s.completed;
existing.errors += s.errors;
} else {
subagentMap.set(key, { count: s.count, completed: s.completed, errors: s.errors });
}
}
// Sort by count desc, take top N
const sortedSubagents = [...subagentMap.entries()].sort((a, b) => b[1].count - a[1].count);
const visible = sortedSubagents.slice(0, MAX_SUBAGENT_NODES);
const overflow = sortedSubagents.slice(MAX_SUBAGENT_NODES);
for (const [type, stats] of visible) {
rawNodes.push({
id: `subagent:${type}`,
label: type.length > 14 ? type.slice(0, 12) + "..." : type,
count: stats.count,
layer: 2,
kind: "subagent",
width: NODE_W,
height: NODE_H,
meta: { completed: stats.completed, errors: stats.errors, subagent_type: type },
});
}
if (overflow.length > 0) {
const overflowTotal = overflow.reduce((s, [, v]) => s + v.count, 0);
rawNodes.push({
id: "subagent:__overflow",
label: t("common:plusMore", { count: overflow.length }),
count: overflowTotal,
layer: 2,
kind: "subagent",
width: NODE_W,
height: NODE_H,
meta: {
completed: 0,
errors: 0,
subagent_type: t("orchestration.otherTypes", { count: overflow.length }),
},
});
}
// Layer 3 - compactions (context compressions)
const compactions = (data as unknown as { compactions?: { total: number; sessions: number } })
.compactions;
const compTotal = compactions?.total ?? 0;
const compSessions = compactions?.sessions ?? 0;
rawNodes.push({
id: "compaction:total",
label: t("orchestration.compactions"),
count: compTotal,
layer: 3,
kind: "nested",
width: NODE_W,
height: NODE_H,
meta: { subagent_type: "compaction" },
});
if (compSessions > 0) {
rawNodes.push({
id: "compaction:sessions",
label: `${compSessions} sessions`,
count: compSessions,
layer: 3,
kind: "nested",
width: NODE_W,
height: NODE_H,
meta: { subagent_type: "compaction" },
});
}
// Layer 4 - outcomes
const outcomeMap = new Map<string, number>();
for (const o of data.outcomes) {
outcomeMap.set(o.status, (outcomeMap.get(o.status) ?? 0) + o.count);
}
if (outcomeMap.size === 0) {
outcomeMap.set("completed", 0);
}
for (const [status, count] of outcomeMap) {
rawNodes.push({
id: `outcome:${status}`,
label: status.charAt(0).toUpperCase() + status.slice(1),
count,
layer: 4,
kind: "outcome",
width: NODE_W,
height: NODE_H,
meta: { status },
});
}
// Compute per-layer node lists
const layers: Omit<DAGNode, "x" | "y">[][] = [[], [], [], [], []];
for (const n of rawNodes) {
layers[n.layer]?.push(n);
}
// Compute SVG dimensions
const numLayers = layers.length;
const maxNodesInLayer = Math.max(...layers.map((l) => l.length));
const svgWidth = PADDING_X * 2 + numLayers * NODE_W + (numLayers - 1) * LAYER_GAP;
const naturalHeight =
PADDING_TOP +
PADDING_BOTTOM +
maxNodesInLayer * NODE_H +
Math.max(0, maxNodesInLayer - 1) * NODE_V_GAP;
const svgHeight = Math.min(naturalHeight, 520);
// Assign x/y positions
const nodes: DAGNode[] = [];
for (let li = 0; li < layers.length; li++) {
const layer = layers[li] ?? [];
const layerX = PADDING_X + li * (NODE_W + LAYER_GAP);
const layerTotalH = layer.length * NODE_H + Math.max(0, layer.length - 1) * NODE_V_GAP;
const layerStartY = PADDING_TOP + (svgHeight - PADDING_TOP - PADDING_BOTTOM - layerTotalH) / 2;
for (let ni = 0; ni < layer.length; ni++) {
const raw = layer[ni]!;
nodes.push({
...raw,
x: layerX,
y: layerStartY + ni * (NODE_H + NODE_V_GAP),
});
}
}
// Build nodeMap for edge lookup
const nodeMap = new Map<string, DAGNode>(nodes.map((n) => [n.id, n]));
// Build edges from data.edges + synthetic structural edges
const edgeSet = new Set<string>();
const rawEdges: DAGEdge[] = [];
const addEdge = (source: string, target: string, weight: number) => {
const key = `${source}${target}`;
if (edgeSet.has(key)) return;
edgeSet.add(key);
const sn = nodeMap.get(source);
const tn = nodeMap.get(target);
if (sn && tn) {
rawEdges.push({ source, target, weight, sourceNode: sn, targetNode: tn });
}
};
// Sessions → Main
addEdge("sessions", "main", data.mainCount || 1);
// Main → each subagent type (use data.edges if available, else uniform)
const subagentIds = nodes.filter((n) => n.kind === "subagent").map((n) => n.id);
const compactionIds = nodes.filter((n) => n.kind === "nested").map((n) => n.id);
const outcomeIds = nodes.filter((n) => n.kind === "outcome").map((n) => n.id);
for (const edge of data.edges) {
// Map source/target names to node IDs
const srcId =
edge.source === "main"
? "main"
: edge.source === "sessions"
? "sessions"
: (subagentIds.find((id) => id === `subagent:${edge.source}`) ??
compactionIds.find((id) => id === `nested:${edge.source}`) ??
`subagent:${edge.source}`);
const tgtId =
edge.target === "main"
? "main"
: (outcomeIds.find((id) => id === `outcome:${edge.target}`) ??
compactionIds.find((id) => id === `nested:${edge.target}`) ??
subagentIds.find((id) => id === `subagent:${edge.target}`) ??
`subagent:${edge.target}`);
addEdge(srcId, tgtId, edge.weight);
}
// Structural fallbacks: main → subagents if no data edges cover them
for (const sid of subagentIds) {
const hasEdge = rawEdges.some((e) => e.target === sid);
if (!hasEdge) {
const subNode = nodeMap.get(sid);
addEdge("main", sid, subNode?.count ?? 1);
}
}
// Subagents → compaction nodes
for (const cid of compactionIds) {
const cNode = nodeMap.get(cid);
const weight = Math.max(1, cNode?.count ?? 1);
// Connect from each subagent to compaction
for (const sid of subagentIds) {
addEdge(sid, cid, Math.max(0.5, Math.round(weight / Math.max(subagentIds.length, 1))));
}
}
// Compaction → outcomes
for (const cid of compactionIds) {
for (const oid of outcomeIds) {
const outcomeNode = nodeMap.get(oid);
const perComp = Math.max(
0.5,
Math.round((outcomeNode?.count ?? 1) / Math.max(compactionIds.length, 1))
);
addEdge(cid, oid, perComp);
}
}
// Also connect subagents directly to outcomes
for (const sid of subagentIds) {
for (const oid of outcomeIds) {
const outcomeNode = nodeMap.get(oid);
const perSub = Math.max(
0.5,
Math.round((outcomeNode?.count ?? 1) / Math.max(subagentIds.length, 1))
);
addEdge(sid, oid, perSub);
}
}
return { nodes, edges: rawEdges, svgWidth, svgHeight };
}
// ── Component ─────────────────────────────────────────────────────────────────
export function OrchestrationDAG({ data, onNodeClick, selectedNode }: OrchestrationDAGProps) {
const { t } = useTranslation("workflows");
const svgRef = useRef<SVGSVGElement>(null);
const containerRef = useRef<HTMLDivElement>(null);
const tipRef = useRef<HTMLDivElement>(null);
const [mounted, setMounted] = useState(false);
// Imperative tooltip helpers - bypass React entirely so hover never re-renders the chart.
const hideTip = useCallback(() => {
const tip = tipRef.current;
if (tip) tip.style.opacity = "0";
}, []);
const showTipForNode = useCallback(
(node: DAGNode, anchorEl: SVGGElement) => {
const tip = tipRef.current;
const container = containerRef.current;
if (!tip || !container) return;
// Build content
buildDAGTooltipContent(tip, node, t);
// Anchor the tooltip above the hovered node, clamped to the viewport.
const nodeRect = anchorEl.getBoundingClientRect();
// Show first so we can measure
tip.style.opacity = "0";
tip.style.display = "block";
const tipW = tip.offsetWidth || 260;
const tipH = tip.offsetHeight || 160;
const margin = 8;
// Default: center horizontally above the node
let left = nodeRect.left + nodeRect.width / 2 - tipW / 2;
// Clamp to viewport horizontally
if (left < margin) left = margin;
if (left + tipW > window.innerWidth - margin) left = window.innerWidth - tipW - margin;
// Vertical: prefer above the node, flip below if no space
let top = nodeRect.top - tipH - 10;
if (top < margin) top = nodeRect.bottom + 10;
tip.style.left = `${left}px`;
tip.style.top = `${top}px`;
tip.style.opacity = "1";
},
[t]
);
const layerLabels = [
t("orchestration.layers.origin"),
t("orchestration.layers.mainAgent"),
t("orchestration.layers.subagentTypes"),
t("orchestration.layers.compactions"),
t("orchestration.layers.outcomes"),
];
const graph = useMemo(() => buildGraph(data, t), [data, t]);
const handleNodeClick = useCallback(
(node: DAGNode) => {
onNodeClick?.(node.id);
},
[onNodeClick]
);
// Fade-in on mount
useEffect(() => {
const id = requestAnimationFrame(() => setMounted(true));
return () => cancelAnimationFrame(id);
}, []);
useEffect(() => {
const svg = svgRef.current;
if (!svg) return;
const { nodes, edges, svgHeight } = graph;
const currentLayerLabels = layerLabels;
const root = d3.select(svg);
root.selectAll("*").remove();
// ── Defs ──────────────────────────────────────────────────────────────────
const defs = root.append("defs");
// Gradients
for (const kind of Object.keys(KIND_GRADIENTS) as DAGNode["kind"][]) {
const g = KIND_GRADIENTS[kind];
const grad = defs
.append("linearGradient")
.attr("id", g.id)
.attr("x1", "0%")
.attr("y1", "0%")
.attr("x2", "100%")
.attr("y2", "0%");
for (const stop of g.stops) {
grad.append("stop").attr("offset", stop.offset).attr("stop-color", stop.color);
}
}
// Outcome-specific gradients
for (const [status, colors] of Object.entries(OUTCOME_COLORS)) {
const grad = defs
.append("linearGradient")
.attr("id", `grad-outcome-${status}`)
.attr("x1", "0%")
.attr("y1", "0%")
.attr("x2", "100%")
.attr("y2", "0%");
grad.append("stop").attr("offset", "0%").attr("stop-color", colors.fill);
grad
.append("stop")
.attr("offset", "100%")
.attr("stop-color", colors.stroke + "55");
}
// Glow filter for selected node
const glowFilter = defs
.append("filter")
.attr("id", "glow")
.attr("x", "-30%")
.attr("y", "-30%")
.attr("width", "160%")
.attr("height", "160%");
glowFilter.append("feGaussianBlur").attr("stdDeviation", "4").attr("result", "blur");
const feMerge = glowFilter.append("feMerge");
feMerge.append("feMergeNode").attr("in", "blur");
feMerge.append("feMergeNode").attr("in", "SourceGraphic");
// Edge shadow filter
const edgeFilter = defs
.append("filter")
.attr("id", "edge-glow")
.attr("x", "-10%")
.attr("y", "-50%")
.attr("width", "120%")
.attr("height", "200%");
edgeFilter.append("feGaussianBlur").attr("stdDeviation", "2").attr("result", "blur");
const edgeMerge = edgeFilter.append("feMerge");
edgeMerge.append("feMergeNode").attr("in", "blur");
edgeMerge.append("feMergeNode").attr("in", "SourceGraphic");
// ── Layer labels ──────────────────────────────────────────────────────────
const labelLayer = root.append("g").attr("class", "layer-labels");
const layerXPositions = [0, 1, 2, 3, 4].map(
(li) => PADDING_X + li * (NODE_W + LAYER_GAP) + NODE_W / 2
);
labelLayer
.selectAll("text")
.data(currentLayerLabels)
.join("text")
.attr("x", (_, i) => layerXPositions[i] ?? 0)
.attr("y", 22)
.attr("text-anchor", "middle")
.attr("fill", "#6b7280")
.attr("font-size", "10px")
.attr("font-weight", "500")
.attr("letter-spacing", "0.08em")
.attr("text-transform", "uppercase")
.text((d) => d.toUpperCase());
// ── Layer separator lines ──────────────────────────────────────────────────
const sepLayer = root.append("g").attr("class", "layer-separators");
for (let li = 1; li < 5; li++) {
const sepX = PADDING_X + li * (NODE_W + LAYER_GAP) - LAYER_GAP / 2;
sepLayer
.append("line")
.attr("x1", sepX)
.attr("y1", 32)
.attr("x2", sepX)
.attr("y2", svgHeight - PADDING_BOTTOM + 8)
.attr("stroke", "#1f1f30")
.attr("stroke-width", 1)
.attr("stroke-dasharray", "4 4");
}
// ── Edges ─────────────────────────────────────────────────────────────────
const weightExtent = d3.extent(edges, (e) => e.weight) as [number, number];
const strokeScale = d3
.scaleLinear()
.domain([Math.max(0.1, weightExtent[0] ?? 0.1), Math.max(1, weightExtent[1] ?? 1)])
.range([MIN_EDGE_STROKE, MAX_EDGE_STROKE])
.clamp(true);
const edgeLayer = root.append("g").attr("class", "edges");
for (const edge of edges) {
const sn = edge.sourceNode;
const tn = edge.targetNode;
if (!sn || !tn) continue;
const sx = sn.x + sn.width;
const sy = sn.y + sn.height / 2;
const tx = tn.x;
const ty = tn.y + tn.height / 2;
const cx = (sx + tx) / 2;
const path = `M ${sx},${sy} C ${cx},${sy} ${cx},${ty} ${tx},${ty}`;
const stroke = strokeScale(edge.weight);
const isZero = edge.weight <= 0.5;
// Shadow pass
edgeLayer
.append("path")
.attr("d", path)
.attr("fill", "none")
.attr("stroke", "#6366f1")
.attr("stroke-width", stroke + 2)
.attr("stroke-opacity", isZero ? 0 : 0.08)
.attr("filter", "url(#edge-glow)");
// Main edge
edgeLayer
.append("path")
.attr("d", path)
.attr("fill", "none")
.attr("stroke", isZero ? "#2a2a3d" : "#4f46e5")
.attr("stroke-width", isZero ? 1 : stroke)
.attr("stroke-opacity", isZero ? 0.3 : 0.55)
.attr("stroke-linecap", "round");
}
// ── Nodes ─────────────────────────────────────────────────────────────────
const nodeLayer = root.append("g").attr("class", "nodes");
const nodeGroups = nodeLayer
.selectAll<SVGGElement, DAGNode>("g.node")
.data(nodes, (d) => d.id)
.join("g")
.attr("class", "node")
.attr("transform", (d) => `translate(${d.x},${d.y})`)
.attr("cursor", "pointer")
.attr("role", "button")
.attr("aria-label", (d) => `${d.label}: ${d.count}`)
.on("click", (_event, d) => {
handleNodeClick(d);
})
.on("mouseenter", (event: MouseEvent, d: DAGNode) => {
showTipForNode(d, event.currentTarget as SVGGElement);
d3.select(event.currentTarget as SVGGElement)
.select("rect.node-bg")
.attr("stroke-opacity", 0.9);
})
.on("mouseleave", (event: MouseEvent, d: DAGNode) => {
hideTip();
const opacity = selectedNode === d.id ? 0.9 : 0.4;
d3.select(event.currentTarget as SVGGElement)
.select("rect.node-bg")
// eslint-disable-next-line @typescript-eslint/no-explicit-any
.attr("stroke-opacity", opacity as any);
});
// Outer glow ring for selected node
nodeGroups
.filter((d) => d.id === selectedNode)
.append("rect")
.attr("x", -3)
.attr("y", -3)
.attr("width", (d) => d.width + 6)
.attr("height", (d) => d.height + 6)
.attr("rx", NODE_RX + 3)
.attr("fill", "none")
.attr("stroke", "#6366f1")
.attr("stroke-width", 2)
.attr("filter", "url(#glow)")
.attr("opacity", 0.8);
// Background rect - outcome nodes use per-status fill
nodeGroups
.append("rect")
.attr("class", "node-bg")
.attr("width", (d) => d.width)
.attr("height", (d) => d.height)
.attr("rx", NODE_RX)
.attr("fill", (d) => {
if (d.kind === "outcome" && d.meta?.status) {
return `url(#grad-outcome-${d.meta.status})`;
}
return `url(#${KIND_GRADIENTS[d.kind].id})`;
})
.attr("stroke", (d) => {
if (d.id === selectedNode) return "#6366f1";
if (d.kind === "outcome" && d.meta?.status) {
return outcomeColorSet(d.meta.status).stroke;
}
return borderColorForKind(d.kind);
})
.attr("stroke-width", (d) => (d.id === selectedNode ? 1.5 : 1))
.attr("stroke-opacity", (d) => (d.id === selectedNode ? 0.9 : 0.4));
// Label text
nodeGroups
.append("text")
.attr("x", 10)
.attr("y", NODE_H / 2 - 3)
.attr("dominant-baseline", "middle")
.attr("fill", (d) => {
if (d.kind === "outcome" && d.meta?.status) return outcomeColorSet(d.meta.status).text;
return textColorForKind(d.kind);
})
.attr("font-size", "11px")
.attr("font-weight", "500")
.attr("font-family", "Inter, sans-serif")
.text((d) => d.label);
// Count badge background
nodeGroups
.append("rect")
.attr("x", (d) => d.width - (BADGE_W + 8))
.attr("y", NODE_H / 2 - BADGE_H / 2)
.attr("width", BADGE_W)
.attr("height", BADGE_H)
.attr("rx", 7)
.attr("fill", (d) => badgeBgForKind(d.kind, d.meta?.status))
.attr("opacity", 0.9);
// Count badge text
nodeGroups
.append("text")
.attr("x", (d) => d.width - (BADGE_W / 2 + 8))
.attr("y", NODE_H / 2 + 1)
.attr("text-anchor", "middle")
.attr("dominant-baseline", "middle")
.attr("fill", (d) => {
if (d.kind === "outcome" && d.meta?.status) return outcomeColorSet(d.meta.status).text;
return textColorForKind(d.kind);
})
.attr("font-size", "9px")
.attr("font-weight", "600")
.attr("font-family", "Inter, sans-serif")
.text((d) => fmtCount(d.count));
// Hide any stale tooltip when the chart re-renders so it cannot get stuck.
hideTip();
}, [graph, selectedNode, handleNodeClick, layerLabels, showTipForNode, hideTip]);
if (isEmpty(data)) {
return (
<div className="flex flex-col items-center justify-center py-20 text-center">
<div className="w-14 h-14 rounded-2xl bg-surface-4 flex items-center justify-center mb-5">
<svg
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth={1.5}
className="w-6 h-6 text-fg-muted"
>
<circle cx="6" cy="12" r="2" />
<circle cx="18" cy="6" r="2" />
<circle cx="18" cy="18" r="2" />
<line x1="8" y1="11" x2="16" y2="7" />
<line x1="8" y1="13" x2="16" y2="17" />
</svg>
</div>
<h3 className="text-base font-medium text-fg-secondary mb-2">
{t("orchestration.noData")}
</h3>
<p className="text-sm text-fg-muted max-w-sm">{t("orchestration.noDataDesc")}</p>
</div>
);
}
return (
<div
ref={containerRef}
className="relative w-full"
style={{
opacity: mounted ? 1 : 0,
transition: "opacity 0.4s ease-out",
}}
onMouseLeave={hideTip}
>
{/* SVG DAG */}
<div className="w-full overflow-x-auto" onMouseLeave={hideTip}>
<svg
ref={svgRef}
viewBox={`0 0 ${graph.svgWidth} ${graph.svgHeight}`}
preserveAspectRatio="xMidYMid meet"
style={{
width: "100%",
minWidth: graph.svgWidth,
height: graph.svgHeight,
display: "block",
}}
aria-label={t("orchestration.ariaLabel")}
role="img"
/>
</div>
{/* Legend */}
<div className="flex flex-wrap items-center gap-x-5 gap-y-2 px-1 mt-4">
<span className="text-[10px] text-fg-muted uppercase tracking-widest font-medium mr-1">
{t("orchestration.legend")}
</span>
{LEGEND_ITEMS.map((item) => (
<div key={item.label} className="flex items-center gap-1.5">
<span
className="inline-block w-3 h-3 rounded-sm flex-shrink-0"
style={{ background: item.color, border: `1px solid ${item.border}` }}
/>
<span className="text-[11px] text-fg-muted">{item.label}</span>
</div>
))}
<div className="flex items-center gap-1.5 ml-2">
<span
className="inline-block h-[2px] w-8 rounded flex-shrink-0"
style={{ background: "linear-gradient(to right, #312e81, #4f46e5)" }}
/>
<span className="text-[11px] text-fg-muted">{t("orchestration.edgeWeight")}</span>
</div>
</div>
{/* Tooltip - fixed-positioned, DOM-mutated to avoid React re-renders on hover */}
<div
ref={tipRef}
role="tooltip"
aria-hidden="true"
className="fixed z-50 px-3 py-2 bg-[#12121f] border border-[#2a2a4a] rounded-lg shadow-2xl pointer-events-none"
style={{
display: "none",
opacity: 0,
left: 0,
top: 0,
minWidth: 220,
maxWidth: 300,
transition: "opacity 120ms ease-out",
}}
/>
</div>
);
}
// ── Tooltip DOM builder ───────────────────────────────────────────────────────
// Builds the tooltip's content imperatively to avoid React state on hover.
function buildDAGTooltipContent(el: HTMLDivElement, node: DAGNode, t: TFn) {
// Clear existing children
while (el.firstChild) el.removeChild(el.firstChild);
const meta = describeNode(node, t);
const title = document.createElement("p");
title.className = "text-xs font-semibold text-fg-secondary";
title.textContent = node.label;
el.appendChild(title);
const subtitle = document.createElement("p");
subtitle.className = "text-[10px] uppercase tracking-wider text-fg-muted mt-0.5 mb-2";
subtitle.textContent = meta.layer;
el.appendChild(subtitle);
// Stat rows
const rows: Array<[string, string]> = [[t("orchestration.count"), String(node.count)]];
if (node.kind === "subagent" && node.meta) {
const { completed = 0, errors = 0 } = node.meta;
const abandoned = Math.max(0, node.count - completed - errors);
rows.push([t("common:status.completed", { defaultValue: "Completed" }), String(completed)]);
rows.push([t("common:status.error", { defaultValue: "Errors" }), String(errors)]);
if (abandoned > 0) {
rows.push([t("common:status.abandoned", { defaultValue: "Abandoned" }), String(abandoned)]);
}
rows.push([
t("effectiveness.success", { defaultValue: "Success rate" }),
successRate(completed, node.count),
]);
}
if (node.kind === "nested" && node.meta?.subagent_type) {
rows.push([t("common:type"), node.meta.subagent_type]);
}
if (node.kind === "outcome" && node.meta?.status) {
rows.push([
t("common:statusLabel"),
t(`common:status.${node.meta.status}`, { defaultValue: node.meta.status }),
]);
}
const rowList = document.createElement("div");
rowList.style.cssText = "margin-bottom:8px";
for (const [label, value] of rows) {
const row = document.createElement("div");
row.style.cssText =
"display:flex;justify-content:space-between;gap:16px;font-size:11px;line-height:1.6";
const lbl = document.createElement("span");
lbl.style.color = "#64748b";
lbl.textContent = label;
const val = document.createElement("span");
val.style.cssText = "color:#cbd5e1;font-weight:500;font-variant-numeric:tabular-nums";
val.textContent = value;
row.appendChild(lbl);
row.appendChild(val);
rowList.appendChild(row);
}
el.appendChild(rowList);
const desc = document.createElement("p");
desc.style.cssText =
"font-size:11px;color:#94a3b8;line-height:1.45;border-top:1px solid #2a2a4a;padding-top:8px;margin:0";
desc.textContent = meta.description;
el.appendChild(desc);
const hint = document.createElement("p");
hint.style.cssText = "font-size:10px;color:#64748b;line-height:1.45;margin:6px 0 0";
hint.textContent = t("orchestration.tooltip.tipClickToFilter");
el.appendChild(hint);
}
/**
* Layer-aware description for a DAG node. Pure function of the node - no I/O,
* no randomness, deterministic across renders. Returns translated strings via
* the supplied i18n function so all locales render correctly.
*/
function describeNode(node: DAGNode, t: TFn): { layer: string; description: string } {
switch (node.kind) {
case "session":
return {
layer: t("orchestration.tooltip.layer.session"),
description: t("orchestration.tooltip.desc.session"),
};
case "main":
return {
layer: t("orchestration.tooltip.layer.main"),
description: t("orchestration.tooltip.desc.main"),
};
case "subagent": {
if (node.id === "subagent:__overflow") {
return {
layer: t("orchestration.tooltip.layer.subagentOverflow"),
description: t("orchestration.tooltip.desc.subagentOverflow"),
};
}
const errors = node.meta?.errors ?? 0;
const completed = node.meta?.completed ?? 0;
const total = node.count;
let trailer = "";
if (total > 0) {
if (errors === 0 && completed === total) {
trailer = t("orchestration.tooltip.desc.subagentClean");
} else if (errors > 0 && completed === 0) {
trailer = t("orchestration.tooltip.desc.subagentAllError");
} else if (errors > 0) {
trailer = t("orchestration.tooltip.desc.subagentSomeErrors", { count: errors });
}
}
return {
layer: t("orchestration.tooltip.layer.subagent"),
description: t("orchestration.tooltip.desc.subagent") + trailer,
};
}
case "nested":
return {
layer: t("orchestration.tooltip.layer.nested"),
description: t("orchestration.tooltip.desc.nested"),
};
case "outcome": {
const status = node.meta?.status ?? "completed";
const descKey =
status === "completed"
? "orchestration.tooltip.desc.outcomeCompleted"
: status === "error"
? "orchestration.tooltip.desc.outcomeError"
: status === "abandoned"
? "orchestration.tooltip.desc.outcomeAbandoned"
: "orchestration.tooltip.desc.outcomeFallback";
return {
layer: t("orchestration.tooltip.layer.outcome"),
description: t(descKey),
};
}
}
}
// ── Utility functions ─────────────────────────────────────────────────────────
function borderColorForKind(kind: DAGNode["kind"]): string {
switch (kind) {
case "session":
return "#6366f1";
case "main":
return "#3b82f6";
case "subagent":
return "#22c55e";
case "nested":
return "#14b8a6";
case "outcome":
return "#6366f1";
}
}
function textColorForKind(kind: DAGNode["kind"]): string {
switch (kind) {
case "session":
return "#a5b4fc";
case "main":
return "#93c5fd";
case "subagent":
return "#86efac";
case "nested":
return "#5eead4";
case "outcome":
return "#c4b5fd";
}
}
function badgeBgForKind(kind: DAGNode["kind"], status?: string): string {
if (kind === "outcome" && status) {
return outcomeColorSet(status).stroke + "33";
}
switch (kind) {
case "session":
return "rgba(99,102,241,0.25)";
case "main":
return "rgba(59,130,246,0.25)";
case "subagent":
return "rgba(34,197,94,0.25)";
case "nested":
return "rgba(20,184,166,0.25)";
case "outcome":
return "rgba(99,102,241,0.25)";
}
}
function fmtCount(n: number): string {
if (n >= 1_000_000) return `${(n / 1_000_000).toFixed(1)}M`;
if (n >= 1_000) return `${(n / 1_000).toFixed(1)}k`;
return String(Math.round(n));
}
// ── Legend data ───────────────────────────────────────────────────────────────
const LEGEND_ITEMS = [
{ label: "Sessions", color: "#312e81", border: "#6366f1" },
{ label: "Main Agent", color: "#1e3a5f", border: "#3b82f6" },
{ label: "Subagent Types", color: "#052e16", border: "#22c55e" },
{ label: "Compactions", color: "#134e4a", border: "#14b8a6" },
{ label: "Completed", color: "#052e16", border: "#16a34a" },
{ label: "Error", color: "#1f0808", border: "#dc2626" },
{ label: "Abandoned", color: "#1c1a04", border: "#ca8a04" },
];