diff --git a/CLAUDE.md b/CLAUDE.md index a69ffaf..6d649be 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -4,7 +4,7 @@ OtelContext is a self-hosted OTLP observability platform. Single Go binary with embedded React frontend. - **Backend:** Go 1.25, native `net/http` (no frameworks), GORM ORM, gRPC + HTTP for OTLP ingestion -- **Frontend:** React 19 + TypeScript + TanStack Query/Virtual + wouter + Radix primitives + cmdk palette + hand-rolled token CSS (`ui/src/styles/tokens.css`) with CSS Modules. The **service map renders via React Flow (`@xyflow/react`, MIT)** fed by our own deterministic radial layout (`ui/src/lib/radialLayout.ts` — phyllotaxis, no physics); React Flow owns pan/zoom/fit/minimap/grid/a11y, nodes are token-themed React components, edges are straight centre-to-centre. No UI framework: `@ossrandom/design-system`, cytoscape, uplot remain removed (cytoscape's physics hairball → deterministic layout + React Flow; the prior hand-rolled-SVG map was replaced by React Flow on 2026-06-18) +- **Frontend:** React 19 + TypeScript + TanStack Query/Virtual + wouter + Radix primitives + cmdk palette + hand-rolled token CSS (`ui/src/styles/tokens.css`) with CSS Modules. The **service map renders via React Flow (`@xyflow/react`, MIT)** over a dagre layered layout (`ui/src/lib/dagreLayout.ts` — `@dagrejs/dagre`, MIT: crossing-minimised, left→right, with edge-routing waypoints) computed synchronously. React Flow owns pan/zoom/fit/minimap/grid/a11y; nodes are token-themed React components — draggable, and they collapse to status **dots** below a zoom threshold (level-of-detail) so they stop occluding edges at scale; edges route through the dagre waypoints and meet node borders. No UI framework: `@ossrandom/design-system`, cytoscape, uplot remain removed (cytoscape's physics hairball → deterministic layered layout + React Flow; the prior hand-rolled-SVG map was replaced by React Flow on 2026-06-18, then moved to the dagre layered layout) - **Ports:** gRPC `:4317` (OTLP), HTTP `:8080` (API + HTTP OTLP + WebSocket + UI) ## Strict Rules diff --git a/ui/budgets.json b/ui/budgets.json index 8e2e45d..c30fa82 100644 --- a/ui/budgets.json +++ b/ui/budgets.json @@ -1,12 +1,12 @@ { - "$comment": "Gzipped size budgets enforced by scripts/check-budgets.mjs (npm run check-budgets). 'initial' = assets referenced from dist/index.html (entry script + modulepreloads + stylesheets). Fonts are raw woff2 bytes (already compressed). lazyChunkExceptions maps a chunk-name prefix (before the content hash) to a per-chunk cap. The ConstellationHome lazy chunk carries the service map, which now renders via React Flow (@xyflow/react, MIT) instead of hand-rolled SVG — React Flow + zustand + d3-zoom add ~52 KB gz, all lazy (initial JS is unaffected, still ~106 KB). Measured ConstellationHome chunk: 61.3 KB gz; cap set to actual + headroom.", + "$comment": "Gzipped size budgets enforced by scripts/check-budgets.mjs (npm run check-budgets). 'initial' = assets referenced from dist/index.html (entry script + modulepreloads + stylesheets). Fonts are raw woff2 bytes (already compressed). lazyChunkExceptions maps a chunk-name prefix (before the content hash) to a per-chunk cap. The ConstellationHome lazy chunk carries the service map: React Flow (@xyflow/react, MIT) + the dagre layout engine (@dagrejs/dagre + graphlib, MIT — crossing-minimised layout + edge routing) computed synchronously. All lazy — initial JS is unaffected (~103 KB). Measured ConstellationHome chunk ~72 KB gz; cap set to actual + headroom.", "gzipKb": { "initialJs": 118, "initialCss": 6, "fonts": 75, "lazyChunkDefault": 10, "lazyChunkExceptions": { - "ConstellationHome": 66 + "ConstellationHome": 76 } } } diff --git a/ui/package-lock.json b/ui/package-lock.json index 9583a79..1ab90c1 100644 --- a/ui/package-lock.json +++ b/ui/package-lock.json @@ -8,6 +8,7 @@ "name": "otelcontext-ui", "version": "0.0.0", "dependencies": { + "@dagrejs/dagre": "^3.0.0", "@radix-ui/react-dialog": "^1.1.16", "@radix-ui/react-dropdown-menu": "^2.1.17", "@radix-ui/react-tabs": "^1.1.14", @@ -513,6 +514,21 @@ "node": ">=20.19.0" } }, + "node_modules/@dagrejs/dagre": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/@dagrejs/dagre/-/dagre-3.0.0.tgz", + "integrity": "sha512-ZzhnTy1rfuoew9Ez3EIw4L2znPGnYYhfn8vc9c4oB8iw6QAsszbiU0vRhlxWPFnmmNSFAkrYeF1PhM5m4lAN0Q==", + "license": "MIT", + "dependencies": { + "@dagrejs/graphlib": "4.0.1" + } + }, + "node_modules/@dagrejs/graphlib": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/@dagrejs/graphlib/-/graphlib-4.0.1.tgz", + "integrity": "sha512-IvcV6FduIIAmLwnH+yun+QtV36SC7mERqa86aClNqmMN09WhmPPYU8ckHrZBozErf+UvHPWOTJYaGYiIcs0DgA==", + "license": "MIT" + }, "node_modules/@emnapi/core": { "version": "1.10.0", "resolved": "https://registry.npmjs.org/@emnapi/core/-/core-1.10.0.tgz", diff --git a/ui/package.json b/ui/package.json index 576bae6..230394a 100644 --- a/ui/package.json +++ b/ui/package.json @@ -13,6 +13,7 @@ "check-budgets": "node scripts/check-budgets.mjs" }, "dependencies": { + "@dagrejs/dagre": "^3.0.0", "@radix-ui/react-dialog": "^1.1.16", "@radix-ui/react-dropdown-menu": "^2.1.17", "@radix-ui/react-tabs": "^1.1.14", diff --git a/ui/src/components/map/FlowMap.module.css b/ui/src/components/map/FlowMap.module.css index 90400b7..c72b926 100644 --- a/ui/src/components/map/FlowMap.module.css +++ b/ui/src/components/map/FlowMap.module.css @@ -5,9 +5,11 @@ * (background grid, panes, minimap, handles) to our --bg-inset instrument * surface and (b) style the custom node/edge classes the component renders. * - * Token-only colours (tokens.css). All motion is keyed off --dur-* tokens, which - * the global prefers-reduced-motion block zeros — so reduced motion collapses - * every transition/animation here without a local media query. + * Token-only colours (tokens.css). Token-driven motion is keyed off --dur-*, + * which the global prefers-reduced-motion block zeros. The ONE exception is + * React Flow's native edge animation (animated edges), which runs on the + * library's own keyframes — it gets an explicit prefers-reduced-motion guard at + * the bottom of this file. */ /* ---- the wrapper that holds the whole map ---- */ @@ -67,23 +69,48 @@ box-shadow: 0 0 0 1px var(--accent); } -/* Hidden centred handle: edges attach here, but FloatingEdge draws - * centre-to-centre so position is cosmetic. Must visually disappear AND not - * eat clicks. !important beats React Flow's base .react-flow__handle (a 6×6 - * #333/#141414 dot with a border). */ +/* ---- level-of-detail: a status dot replaces the chip when zoomed out (dense + * 120-service view), so the 168px boxes stop occluding the routed edges. The + * dot is centred in the node box (dagre's edge waypoints still meet the box + * border) and carries the status hue. ---- */ +.dotNode { + display: flex; + align-items: center; + justify-content: center; + width: 100%; + height: 100%; + cursor: pointer; +} +.dotNode::before { + content: ''; + width: 18px; + height: 18px; + border-radius: 50%; + background: var(--node-color); + box-shadow: 0 0 0 2px var(--bg-inset); + transition: box-shadow var(--dur-2) var(--ease); +} +/* Inspected / blast-root dot: accent ring, matching the chip's selected ring. */ +.dotSelected::before { + box-shadow: + 0 0 0 2px var(--bg-inset), + 0 0 0 4px var(--accent); +} + +/* Connection anchor, kept INVISIBLE. React Flow needs a source/target handle + * per node, but the floating edge computes the real border attachment point, so + * the handle dot itself must not show — otherwise edgeless nodes sprout a stray + * connector. opacity:0 keeps the element measured so edge routing still resolves + * its position; it never eats clicks. */ .handle { - position: absolute !important; - left: 50% !important; - top: 50% !important; - width: 1px !important; - height: 1px !important; - min-width: 0 !important; - min-height: 0 !important; - border: 0 !important; - background: transparent !important; - opacity: 0 !important; - pointer-events: none !important; - transform: translate(-50%, -50%); + width: 6px; + height: 6px; + min-width: 0; + min-height: 0; + border: 0; + background: transparent; + opacity: 0; + pointer-events: none; } /* Status dot — the same hue as the chip outline. */ @@ -115,34 +142,6 @@ color: var(--text-2); } -/* ---- custom edge: FloatingEdge renders a raw ---- */ -/* Hairline link, legible on both themes against the field. --edge-w is set - * inline from the call count. */ -.edge { - fill: none; - stroke: var(--text-3); - stroke-width: var(--edge-w, 1.5); - opacity: 0.75; -} - -/* Failing edge: --crit, full strength, marching dashes that drain toward the - * target. The march runs on calc(var(--dur-3) * 6) — so reduced motion - * (--dur-3: 0ms) yields a 0s duration and the dashes stop, leaving a static - * red dashed line. */ -.edgeFailing { - stroke: var(--crit); - opacity: 1; - stroke-dasharray: 4 4; - animation: march calc(var(--dur-3) * 6) linear infinite; -} - -@keyframes march { - to { - /* one full dash + gap (4 + 4) → seamless loop. */ - stroke-dashoffset: -8; - } -} - /* ============================================================================ * React Flow base overrides (file-scope :global — these target React Flow's * OWN DOM, which is a sibling of our chips, not nested under any module class). @@ -188,3 +187,77 @@ stroke-width: 1; } +/* ============================================================================ + * React Flow Controls + MiniMap — themed to the dark token instrument surface + * so the map reads as a first-class graph tool, not the library's default + * white chrome. (file-scope :global — these target React Flow's own DOM.) + * ==========================================================================*/ + +/* Zoom / fit control cluster: a hairline token-themed stack, not a white box. */ +.controls { + border: 1px solid var(--stroke-1); + border-radius: var(--radius-2); + overflow: hidden; + background: var(--bg-raised); + box-shadow: none; +} +.controls :global(.react-flow__controls-button) { + width: 28px; + height: 28px; + border: none; + border-bottom: 1px solid var(--stroke-1); + background: var(--bg-raised); + color: var(--text-2); + fill: var(--text-2); + transition: + background var(--dur-1) var(--ease), + color var(--dur-1) var(--ease); +} +.controls :global(.react-flow__controls-button):last-child { + border-bottom: none; +} +.controls :global(.react-flow__controls-button):hover { + background: var(--bg-overlay); + color: var(--text-1); + fill: var(--text-1); +} +.controls :global(.react-flow__controls-button svg) { + fill: currentColor; +} + +/* Minimap: dark inset panel, hairline border; the out-of-view area dimmed by a + * token overlay; dots carry the same status hue as the chips. */ +.minimap { + border: 1px solid var(--stroke-1); + border-radius: var(--radius-2); + background: var(--bg-inset); + overflow: hidden; +} +.minimap :global(.react-flow__minimap-mask) { + fill: var(--bg-inset); + opacity: 0.6; +} +/* Status fills override React Flow's base .react-flow__minimap-node fill. */ +.miniCrit { + fill: var(--crit) !important; +} +.miniWarn { + fill: var(--warn) !important; +} +.miniOk { + fill: var(--ok) !important; +} +.miniUnknown { + fill: var(--unknown) !important; +} + +/* React Flow's native animated edges run on the library's own keyframes (not a + * --dur token), so the global reduced-motion rule can't reach them. Stop the + * marching dashes for users who ask for reduced motion — leaving a static + * dashed edge and dropping the continuous edge-layer repaint for them. */ +@media (prefers-reduced-motion: reduce) { + :global(.react-flow__edge.animated path) { + animation: none; + } +} + diff --git a/ui/src/components/map/FlowMap.tsx b/ui/src/components/map/FlowMap.tsx index 1c3667f..3b1d0a9 100644 --- a/ui/src/components/map/FlowMap.tsx +++ b/ui/src/components/map/FlowMap.tsx @@ -1,7 +1,9 @@ import { useCallback, + useEffect, useImperativeHandle, useMemo, + useRef, type CSSProperties, type ReactNode, type Ref, @@ -9,13 +11,17 @@ import { import { Background, BackgroundVariant, + BaseEdge, + Controls, Handle, + MiniMap, Position, ReactFlow, ReactFlowProvider, - getStraightPath, useInternalNode, + useNodesState, useReactFlow, + useStore, type Edge, type EdgeProps, type EdgeTypes, @@ -26,18 +32,22 @@ import { } from '@xyflow/react' import '@xyflow/react/dist/style.css' import { NODE_H, NODE_W, compareIds, edgeWidth, neighborsOf, type GraphEdgeRef } from '@/lib/dagLayout' -import { layoutRadial } from '@/lib/radialLayout' +import { layoutDagre } from '@/lib/dagreLayout' import { formatPercent } from '@/lib/format' import { nodeStatus, statusToken } from '@/lib/triage' import type { SystemEdge, SystemNode } from '@/types/api' import styles from './FlowMap.module.css' -// React Flow service map. We keep our OWN deterministic radial layout -// (radialLayout.ts — phyllotaxis, no physics) and feed those fixed positions to -// React Flow, which owns the boilerplate: pan / zoom / pinch, fit-view, the grid -// background, the minimap and a11y focus. Nodes are React components, so the -// token-themed status chips port over directly; edges are straight, drawn -// centre-to-centre (chips are opaque, so the segment under a chip is occluded). +// React Flow service map. React Flow owns pan / zoom / fit, the grid, the +// minimap and the zoom controls; we feed it a dagre layout (dagreLayout.ts — +// crossing-minimised, left→right) for node positions AND edge ROUTING +// waypoints, so links thread between ranks instead of disappearing under node +// boxes at ~120 services. Two readability levers for scale: edges are drawn +// through the dagre waypoints (routed, capped-thin), and nodes drop to status +// DOTS below a zoom threshold (level-of-detail) so the 168px chips stop +// occluding the field when zoomed out. dagre seeds the positions; nodes stay +// DRAGGABLE for manual nudges — a drag survives metric polls, and a fresh +// layout (topology change) re-seeds. export type LayoutMode = 'radial' | 'dag' @@ -49,7 +59,7 @@ export interface FlowMapHandle { interface FlowMapProps { nodes: readonly SystemNode[] edges: readonly SystemEdge[] - /** Layout paradigm. Only 'radial' is used today; kept for API stability. */ + /** Layout paradigm. Only the layered dagre layout is used today; kept for API stability. */ mode?: LayoutMode /** The inspected service — accent ring + 1-hop neighborhood emphasis. */ selectedId: string | null @@ -83,18 +93,39 @@ type ServiceNode = Node const FIT_OPTIONS = { padding: 0.18 } as const -/** Centre of an internal node in flow coordinates. */ -function nodeCentre(n: InternalNode): { x: number; y: number } { - return { - x: n.internals.positionAbsolute.x + (n.measured?.width ?? NODE_W) / 2, - y: n.internals.positionAbsolute.y + (n.measured?.height ?? NODE_H) / 2, - } -} +// Below this zoom the chips collapse to status dots (level-of-detail) — at ~120 +// services fitView lands well under this, so the dense view is dots, not a wall +// of overlapping 168px boxes; zooming in past it restores the labelled chips. +const LOD_ZOOM = 0.62 -/** The token-themed status chip — a React Flow custom node. */ +/** The token-themed status chip, with a level-of-detail dot variant when zoomed + * out. Handles are invisible border anchors (React Flow needs them; the routed + * edge draws its own path). */ function ServiceMapNode({ data }: NodeProps) { + const compact = useStore((s) => s.transform[2] < LOD_ZOOM) const { node, emphasis, impactDepth } = data const status = nodeStatus(node.status) + const styleVar = { '--node-color': statusToken(status), width: NODE_W, height: NODE_H } as CSSProperties + const handles = ( + <> + + + + ) + if (compact) { + const cls = [ + styles.dotNode, + emphasis === 'dim' && styles.dimmed, + (emphasis === 'selected' || impactDepth === 0) && styles.dotSelected, + ] + .filter(Boolean) + .join(' ') + return ( +
+ {handles} +
+ ) + } const cls = [ styles.chip, emphasis === 'selected' && styles.selected, @@ -104,47 +135,109 @@ function ServiceMapNode({ data }: NodeProps) { .filter(Boolean) .join(' ') return ( -
- {/* Hidden centred handles — edges attach here; the floating edge draws - centre-to-centre regardless, so position is cosmetic. */} - - +
+
) } -/** Straight edge drawn centre-to-centre between the two node boxes. */ -function FloatingEdge({ source, target, data }: EdgeProps) { +const NODE_TYPES: NodeTypes = { service: ServiceMapNode } + +/** Unit vector (guards zero length). */ +function unit(dx: number, dy: number): { x: number; y: number } { + const m = Math.hypot(dx, dy) || 1 + return { x: dx / m, y: dy / m } +} + +/** SVG path through the dagre waypoints with lightly rounded corners — the + * routed "circuit" look, radius clamped so it never overshoots a short leg. */ +function routedPath(pts: { x: number; y: number }[], radius = 7): string { + if (pts.length < 2) return '' + if (pts.length === 2) return `M${pts[0].x},${pts[0].y} L${pts[1].x},${pts[1].y}` + let d = `M${pts[0].x},${pts[0].y}` + for (let i = 1; i < pts.length - 1; i++) { + const p0 = pts[i - 1] + const p1 = pts[i] + const p2 = pts[i + 1] + const r = Math.min(radius, Math.hypot(p1.x - p0.x, p1.y - p0.y) / 2, Math.hypot(p2.x - p1.x, p2.y - p1.y) / 2) + const u1 = unit(p1.x - p0.x, p1.y - p0.y) + const u2 = unit(p2.x - p1.x, p2.y - p1.y) + d += ` L${p1.x - u1.x * r},${p1.y - u1.y * r} Q${p1.x},${p1.y} ${p1.x + u2.x * r},${p1.y + u2.y * r}` + } + const last = pts.at(-1)! + d += ` L${last.x},${last.y}` + return d +} + +/** Centre of an internal node in flow coords (measured dims, NODE_* fallback). */ +function nodeCenter(n: InternalNode): { x: number; y: number } { + return { + x: n.internals.positionAbsolute.x + (n.measured?.width ?? NODE_W) / 2, + y: n.internals.positionAbsolute.y + (n.measured?.height ?? NODE_H) / 2, + } +} + +/** Point on a node's border along the ray toward `toward` (rectangle + * intersection, L∞). Computed from the node's CURRENT position, so edges meet + * the chip EDGE — not its centre — and follow the node when it's dragged. */ +function borderToward(n: InternalNode, toward: { x: number; y: number }): { x: number; y: number } { + const w = (n.measured?.width ?? NODE_W) / 2 + const h = (n.measured?.height ?? NODE_H) / 2 + const c = nodeCenter(n) + const ex = toward.x - c.x + const ey = toward.y - c.y + const scale = Math.max(Math.abs(ex) / w, Math.abs(ey) / h) + if (!Number.isFinite(scale) || scale === 0) return c + return { x: c.x + ex / scale, y: c.y + ey / scale } +} + +/** Edge drawn along dagre's routing waypoints (carried in data.points) so it + * threads between ranks at chip zoom. Two cases fall back to a centre-to-centre + * line instead: (1) LOD/dot zoom — chips collapse to a centred dot and the + * routed path ends ~84px away at the box border, so it would read as + * disconnected; (2) the cheap fallback layout, which has no waypoints. Either + * way the link visibly joins the two nodes. Colour/thickness ride the forwarded + * style; the flow animation is React Flow's native `animated` flag. */ +function RoutedEdge({ source, target, data, style }: EdgeProps) { + const compact = useStore((s) => s.transform[2] < LOD_ZOOM) const s = useInternalNode(source) const t = useInternalNode(target) if (!s || !t) return null - const a = nodeCentre(s) - const b = nodeCentre(t) - const [d] = getStraightPath({ sourceX: a.x, sourceY: a.y, targetX: b.x, targetY: b.y }) - const failing = Boolean(data?.failing) - return ( - - ) + const pts = (data?.points as { x: number; y: number }[] | undefined) ?? [] + // Routed (chip zoom, with waypoints): keep dagre's middle routing, but pin the + // endpoints to each node's BORDER toward the adjacent waypoint — so links meet + // the chip edge (not its centre) and still follow a dragged node. + if (!compact && pts.length >= 2) { + const a = borderToward(s, pts[1] ?? nodeCenter(t)) + const b = borderToward(t, pts.at(-2) ?? nodeCenter(s)) + return + } + // LOD dot zoom, or the fallback layout with no waypoints: plain centre line. + const a = nodeCenter(s) + const b = nodeCenter(t) + return } -const NODE_TYPES: NodeTypes = { service: ServiceMapNode } -const EDGE_TYPES: EdgeTypes = { floating: FloatingEdge } +const EDGE_TYPES: EdgeTypes = { routed: RoutedEdge } function isEdgeFailing(status: string | undefined): boolean { return status === 'critical' || status === 'failing' } +/** Minimap dot colour by status — token fill via a CSS class (presentation + * attrs can't read CSS vars, so MiniMap's nodeColor can't carry a token). */ +function miniNodeClass(n: Node): string { + const status = nodeStatus((n.data as ServiceNodeData).node.status) + if (status === 'critical') return styles.miniCrit + if (status === 'degraded') return styles.miniWarn + if (status === 'healthy') return styles.miniOk + return styles.miniUnknown +} + function FlowMapInner({ nodes, edges, @@ -158,23 +251,27 @@ function FlowMapInner({ Pick >) { const { fitView } = useReactFlow() - useImperativeHandle(ref, () => ({ fit: () => void fitView(FIT_OPTIONS) }), [fitView]) + useImperativeHandle(ref, () => ({ fit: () => { fitView(FIT_OPTIONS) } }), [fitView]) const edgeRefs: GraphEdgeRef[] = useMemo( () => edges.map((e) => ({ source: e.source, target: e.target })), [edges], ) - // Positions: recomputed ONLY when the node/edge SET changes (deterministic - // radial layout). Metric-only polls reuse these via the data memo below. + // Layout: recomputed ONLY when the node/edge SET changes (deterministic + // dagre). Carries both node positions and edge routing waypoints. const shapeKey = useMemo(() => { const ids = nodes.map((n) => n.id).sort(compareIds) const es = edgeRefs.map((e) => `${e.source}->${e.target}`).sort((a, b) => a.localeCompare(b)) return JSON.stringify({ ids, es }) }, [nodes, edgeRefs]) - const positions = useMemo(() => { + // dagre layout, computed synchronously. It's deterministic and instant at + // normal scale; the previous Web Worker added cross-browser fragility for a + // benefit (avoiding a ~0.5–1.2s freeze only at ~120 services) that doesn't + // apply here, so it was removed for this simpler, robust path. + const layout = useMemo(() => { const { ids } = JSON.parse(shapeKey) as { ids: string[] } - return layoutRadial(ids, edgeRefs).nodes + return layoutDagre(ids, edgeRefs) // eslint-disable-next-line react-hooks/exhaustive-deps }, [shapeKey]) @@ -184,9 +281,9 @@ function FlowMapInner({ [edgeRefs, selectedId], ) - const rfNodes: ServiceNode[] = useMemo(() => { + const desired: ServiceNode[] = useMemo(() => { return nodes.map((n) => { - const p = positions.get(n.id) + const p = layout.nodes.get(n.id) const depth = impact?.get(n.id) let emphasis: Emphasis = 'normal' if (impact) emphasis = depth === undefined ? 'dim' : 'normal' @@ -197,41 +294,82 @@ function FlowMapInner({ id: n.id, type: 'service', position: { x: p?.x ?? 0, y: p?.y ?? 0 }, + width: NODE_W, + height: NODE_H, data: { node: n, emphasis, impactDepth: depth ?? null }, - draggable: false, - selectable: false, connectable: false, focusable: true, } }) - }, [nodes, positions, selectedId, neighbors, impact, dimField]) + }, [nodes, layout, selectedId, neighbors, impact, dimField]) + + // React Flow owns node state; we reconcile `desired` into it. Re-seed + // positions when a FRESH layout lands (worker result or topology change) or on + // first mount; otherwise (a metric-only poll) keep the current positions so a + // user's DRAG survives, and carry React Flow's measured dims (else every poll + // forces a full re-measure of the graph). + const [rfNodes, setRfNodes, onNodesChange] = useNodesState([]) + const layoutRef = useRef(layout) + useEffect(() => { + setRfNodes((prev) => { + const reseed = layoutRef.current !== layout || prev.length === 0 + layoutRef.current = layout + if (reseed) return desired + const prevById = new Map(prev.map((n) => [n.id, n])) + return desired.map((n) => { + const p = prevById.get(n.id) + return p ? { ...n, position: p.position, measured: p.measured, width: p.width, height: p.height } : n + }) + }) + }, [desired, layout, setRfNodes]) + + // Re-fit when the layout changes (topology change). Stable on metric-only + // polls (layout ref unchanged), so it doesn't fight the user's zoom. rAF lets + // the nodes settle into the new positions before fitting. + useEffect(() => { + const raf = requestAnimationFrame(() => { fitView(FIT_OPTIONS) }) + return () => cancelAnimationFrame(raf) + }, [layout, fitView]) + // Routed animated edges. Waypoints come from the dagre layout; token colour + // rides the inline style (CSS custom props resolve in the style property, + // unlike SVG presentation attributes); width is capped so high call-counts + // stay a hairline. Failing edges carry --crit + an `edgeFailing` class hook. const rfEdges: Edge[] = useMemo( () => - edges.map((e) => ({ - id: `${e.source}->${e.target}`, - source: e.source, - target: e.target, - type: 'floating', - data: { failing: isEdgeFailing(e.status), w: edgeWidth(e.call_count) }, - })), - [edges], + edges.map((e) => { + const failing = isEdgeFailing(e.status) + const key = `${e.source}->${e.target}` + return { + id: key, + source: e.source, + target: e.target, + type: 'routed', + animated: true, + className: failing ? 'edgeFailing' : undefined, + data: { points: layout.edges.get(key) ?? [] }, + style: { + stroke: failing ? 'var(--crit)' : 'var(--text-3)', + strokeWidth: Math.min(2.25, edgeWidth(e.call_count)), + opacity: failing ? 1 : 0.7, + } as CSSProperties, + } + }), + [edges, layout], ) - const onNodeClick = useCallback( - (_: unknown, n: Node) => onSelect(n.id), - [onSelect], - ) + const onNodeClick = useCallback((_: unknown, n: Node) => onSelect(n.id), [onSelect]) return ( + + ) } diff --git a/ui/src/components/map/__tests__/FlowMap.test.tsx b/ui/src/components/map/__tests__/FlowMap.test.tsx index 76e3371..b46659b 100644 --- a/ui/src/components/map/__tests__/FlowMap.test.tsx +++ b/ui/src/components/map/__tests__/FlowMap.test.tsx @@ -123,66 +123,91 @@ function getMap() { describe('FlowMap — React Flow render', () => { it('renders a node per service', async () => { - renderMap() - // React Flow mounts nodes after measuring the pane — use findBy. - expect(await screen.findByText('checkout')).toBeInTheDocument() - expect(await screen.findByText('payments')).toBeInTheDocument() - expect(await screen.findByText('db')).toBeInTheDocument() + const { container } = renderMap() + // React Flow mounts nodes after measuring the pane — wait for the first to + // appear. Assert on the `.react-flow__node` wrappers, NOT on the service + // NAME text: at fitView zoom (and at 120-service scale generally) the map + // is below LOD_ZOOM, so each node renders as a textless status dot. The + // wrapper exists in BOTH the dot and the labelled-chip variant. + await waitFor(() => + expect(container.querySelectorAll('.react-flow__node')).toHaveLength(NODES.length), + ) }) it('wraps each service node with its stable React Flow data-id', async () => { const { container } = renderMap() - await screen.findByText('payments') + await waitFor(() => + expect(container.querySelectorAll('.react-flow__node')).toHaveLength(NODES.length), + ) + // Each service is addressable by its stable data-id (set from SystemNode.id), + // which React Flow stamps on the node wrapper in both LOD variants. for (const id of ['checkout', 'payments', 'db']) { expect(container.querySelector(`.react-flow__node[data-id="${id}"]`)).not.toBeNull() } }) - it('renders one edge path per dependency', async () => { + it('renders one edge per dependency', async () => { const { container } = renderMap() // Wait for nodes (edges render in the same pass once positions exist). - await screen.findByText('payments') + // Gate on the node wrappers, not name text — the dots are textless at this zoom. + await waitFor(() => + expect(container.querySelectorAll('.react-flow__node')).toHaveLength(NODES.length), + ) + // Each native edge is a `` group that holds TWO + // s (the visible `.react-flow__edge-path` + an invisible wide + // `.react-flow__edge-interaction` hit area), so counting raw `path`s gives + // 2× the edges. Count the edge GROUP instead — exactly one per dependency. await waitFor(() => { expect( - container.querySelectorAll('.react-flow__edges path').length, + container.querySelectorAll('.react-flow__edges .react-flow__edge').length, ).toBe(EDGES.length) }) }) it('marks exactly the failing edge and leaves the healthy one alone', async () => { const { container } = renderMap() - await screen.findByText('payments') - // React Flow joins the edge id with a NUL, not a space, so an exact - // [data-id="payments db"] match fails — instead count the edge s and - // match the failing one by the *unhashed* CSS-module token (`edgeFailing`) - // via [class*=…], which survives the hash suffix. EDGES has one critical - // (payments→db) edge, so exactly one path should be marked failing. + await waitFor(() => + expect(container.querySelectorAll('.react-flow__node')).toHaveLength(NODES.length), + ) + // The `edgeFailing` hook now rides the React Flow edge GROUP (the ``), not a custom edge — the native + // bezier path is React Flow's own. The class is a plain string literal + // (not a CSS-module token), so it is unhashed and matches exactly. EDGES + // has one critical (payments→db) edge, so exactly one group is failing. await waitFor(() => { - const paths = container.querySelectorAll('.react-flow__edges path') - expect(paths.length).toBe(EDGES.length) + const groups = container.querySelectorAll('.react-flow__edges .react-flow__edge') + expect(groups.length).toBe(EDGES.length) }) expect( - container.querySelectorAll('.react-flow__edges path[class*="edgeFailing"]'), + container.querySelectorAll('.react-flow__edge.edgeFailing'), ).toHaveLength(1) }) }) describe('FlowMap — selection', () => { it('node click fires onSelect with the id', async () => { - const { onSelect } = renderMap() + const { container, onSelect } = renderMap() + // Click the node WRAPPER by its stable data-id, not by name text — the + // textless LOD dot renders at this zoom, so there is no name to click. // fireEvent.click (not userEvent) — userEvent dispatches a full // mousedown→mouseup→click sequence, and d3-zoom's mousedown handler reads // event.view.document, which jsdom leaves null → an uncaught TypeError. // React Flow's onNodeClick fires on the click event alone, so a bare - // click both avoids the crash and exercises the handler. The click on the - // inner chip text bubbles to the React Flow node wrapper → onNodeClick. - fireEvent.click(await screen.findByText('payments')) + // click both avoids the crash and exercises the handler. + const payments = await waitFor(() => { + const el = container.querySelector('.react-flow__node[data-id="payments"]') + expect(el).not.toBeNull() + return el as Element + }) + fireEvent.click(payments) expect(onSelect).toHaveBeenCalledWith('payments') }) it('pane click clears the selection', async () => { const { container, onClearSelection } = renderMap({ selectedId: 'payments' }) - await screen.findByText('payments') + await waitFor(() => + expect(container.querySelectorAll('.react-flow__node')).toHaveLength(NODES.length), + ) const pane = container.querySelector('.react-flow__pane') expect(pane).not.toBeNull() fireEvent.click(pane as Element) @@ -191,19 +216,21 @@ describe('FlowMap — selection', () => { it('still renders the selected node (selection is a non-fatal data prop)', async () => { // Class names are hashed, so asserting the selected styling is brittle — - // we only verify the selected node continues to render. + // we only verify the selected node continues to render (by data-id, which is + // LOD-independent). const { container } = renderMap({ selectedId: 'payments' }) - await screen.findByText('payments') - expect( - container.querySelector('.react-flow__node[data-id="payments"]'), - ).not.toBeNull() + await waitFor(() => + expect( + container.querySelector('.react-flow__node[data-id="payments"]'), + ).not.toBeNull(), + ) }) }) describe('FlowMap — imperative fit()', () => { it('exposes fit() on the handle and runs without throwing', async () => { const ref = createRef() - render( + const { container } = render( { onClearSelection={noop} />, ) - await screen.findByText('payments') + // Gate on the node wrappers (textless dots at this zoom), not name text. + await waitFor(() => + expect(container.querySelectorAll('.react-flow__node')).toHaveLength(NODES.length), + ) expect(ref.current).not.toBeNull() expect(() => act(() => ref.current?.fit())).not.toThrow() }) diff --git a/ui/src/components/triage/ConstellationHome.module.css b/ui/src/components/triage/ConstellationHome.module.css index c37ae74..c67fabe 100644 --- a/ui/src/components/triage/ConstellationHome.module.css +++ b/ui/src/components/triage/ConstellationHome.module.css @@ -67,9 +67,20 @@ flex: none; flex-direction: column; gap: var(--space-2); - width: 300px; + /* Autosize to the widest service row (longest name) rather than a fixed + 300px that truncated names. The fixed width also hid a bug: `overflow-y: + auto` with a default `overflow-x: visible` computes overflow-x to `auto` + (CSS spec), so the selected row's scale(1.015) lift spilled a few px past + 300px and raised a phantom HORIZONTAL scrollbar. fit-content sizes the + column to the names; the cap stops a pathological name from eating the + canvas (it ellipsizes past the cap, per ServiceRow); and an explicit + `overflow-x: hidden` pins the x-axis so the lift can't reopen the bar. + The inner ServiceGroups list (.cards) owns vertical scrolling. */ + width: fit-content; + min-width: 300px; + max-width: 460px; min-height: 0; - overflow-y: auto; + overflow: hidden auto; padding-right: var(--space-1); } diff --git a/ui/src/lib/dagreLayout.ts b/ui/src/lib/dagreLayout.ts new file mode 100644 index 0000000..6941cfa --- /dev/null +++ b/ui/src/lib/dagreLayout.ts @@ -0,0 +1,66 @@ +// Layered service-map layout via dagre (@dagrejs/dagre, MIT). Unlike our +// hand-rolled dagLayout, dagre does crossing-minimisation AND emits edge +// ROUTING waypoints (g.edge().points) — polylines that thread between ranks +// instead of cutting straight through node boxes. That routing is what keeps +// edges visible once the graph grows to ~120 services. Left→right, deterministic +// (ids + edges sorted before insertion, dagre is stable for fixed input order). + +import dagre from '@dagrejs/dagre' +import { NODE_H, NODE_W, compareIds, type GraphEdgeRef } from './dagLayout' + +export interface DagreLayout { + /** id → node top-left position in React Flow flow coords. */ + nodes: Map + /** "source->target" → routed polyline waypoints (same flow coords). */ + edges: Map + width: number + height: number +} + +const RANKSEP = 110 // gap between dependency ranks (the long axis, LR) +const NODESEP = 26 // gap between siblings within a rank +const EDGESEP = 14 // gap between parallel edges in a routing channel + +const EMPTY: DagreLayout = { nodes: new Map(), edges: new Map(), width: 0, height: 0 } + +/** Deterministic left→right layered layout with edge routing. */ +export function layoutDagre(nodeIds: readonly string[], edges: readonly GraphEdgeRef[]): DagreLayout { + const ids = [...new Set(nodeIds)].sort(compareIds) + if (ids.length === 0) return EMPTY + + const g = new dagre.graphlib.Graph() + g.setGraph({ rankdir: 'LR', nodesep: NODESEP, ranksep: RANKSEP, edgesep: EDGESEP, marginx: NODE_W / 2, marginy: NODE_H }) + g.setDefaultEdgeLabel(() => ({})) + for (const id of ids) g.setNode(id, { width: NODE_W, height: NODE_H }) + + // Sanitize → dedupe → sort for a deterministic build. + const idSet = new Set(ids) + const seen = new Set() + const clean: GraphEdgeRef[] = [] + for (const e of edges) { + if (e.source === e.target || !idSet.has(e.source) || !idSet.has(e.target)) continue + const k = `${e.source}->${e.target}` + if (seen.has(k)) continue + seen.add(k) + clean.push(e) + } + clean.sort((a, b) => compareIds(`${a.source}->${a.target}`, `${b.source}->${b.target}`)) + for (const e of clean) g.setEdge(e.source, e.target) + + dagre.layout(g) + + const nodes = new Map() + for (const id of ids) { + const n = g.node(id) + if (n) nodes.set(id, { x: n.x - NODE_W / 2, y: n.y - NODE_H / 2 }) + } + const edgeMap = new Map() + for (const e of clean) { + const ed = g.edge(e.source, e.target) + if (ed && Array.isArray(ed.points)) { + edgeMap.set(`${e.source}->${e.target}`, ed.points.map((p: { x: number; y: number }) => ({ x: p.x, y: p.y }))) + } + } + const gr = g.graph() + return { nodes, edges: edgeMap, width: gr.width ?? 0, height: gr.height ?? 0 } +}