From e05abeaacf8b913be4bb5eff32f2a94cfaa4e695 Mon Sep 17 00:00:00 2001 From: Bryan Helmkamp Date: Sun, 1 Mar 2026 15:06:01 -0500 Subject: [PATCH] Render run graph as interactive SVG with animated running node Replace DOT source code view with rendered SVG visualization using viz.js. Running stages pulse with a teal glow animation, completed stages are tinted green, and the graph supports pan/zoom/direction controls matching the workflow diagram page. Co-Authored-By: Claude Opus 4.6 (1M context) --- apps/arc-web/app/routes/run-graph.tsx | 308 +++++++++++++++++++++++--- 1 file changed, 283 insertions(+), 25 deletions(-) diff --git a/apps/arc-web/app/routes/run-graph.tsx b/apps/arc-web/app/routes/run-graph.tsx index c5552cb48..5940536b4 100644 --- a/apps/arc-web/app/routes/run-graph.tsx +++ b/apps/arc-web/app/routes/run-graph.tsx @@ -1,12 +1,10 @@ -import { useEffect, useState } from "react"; +import { useCallback, useEffect, useRef, useState } from "react"; import { Link, useParams } from "react-router"; -import type { BundledLanguage } from "@pierre/diffs"; +import { ArrowDownIcon, ArrowRightIcon, MinusIcon, PlusIcon } from "@heroicons/react/20/solid"; import { CheckCircleIcon, ArrowPathIcon, PauseCircleIcon, XCircleIcon } from "@heroicons/react/24/solid"; import { DocumentTextIcon, MapIcon } from "@heroicons/react/24/outline"; import { findRun } from "../data/runs"; import { workflowData } from "./workflow-detail"; -import { registerDotLanguage } from "../data/register-dot-language"; -import { CollapsibleFile } from "../components/collapsible-file"; export const handle = { wide: true }; @@ -15,15 +13,16 @@ type StageStatus = "completed" | "running" | "pending" | "failed"; interface Stage { id: string; name: string; + dotId: string; status: StageStatus; duration: string; } const stages: Stage[] = [ - { id: "detect-drift", name: "Detect Drift", status: "completed", duration: "1m 12s" }, - { id: "propose-changes", name: "Propose Changes", status: "completed", duration: "2m 34s" }, - { id: "review-changes", name: "Review Changes", status: "completed", duration: "0m 45s" }, - { id: "apply-changes", name: "Apply Changes", status: "running", duration: "1m 58s" }, + { id: "detect-drift", name: "Detect Drift", dotId: "detect", status: "completed", duration: "1m 12s" }, + { id: "propose-changes", name: "Propose Changes", dotId: "propose", status: "completed", duration: "2m 34s" }, + { id: "review-changes", name: "Review Changes", dotId: "review", status: "completed", duration: "0m 45s" }, + { id: "apply-changes", name: "Apply Changes", dotId: "apply", status: "running", duration: "1m 58s" }, ]; const statusConfig: Record = { @@ -33,22 +32,218 @@ const statusConfig: Record detect + detect -> exit [label="No drift", style=dashed] + detect -> propose [label="Drift found"] + propose -> review + review -> apply [label="Accept"] + review -> propose [label="Revise", style=dashed] + apply -> exit +}`; +} + +function stripGraphTitle(svg: SVGSVGElement) { + const title = svg.querySelector(".graph > title"); + if (!title) return; + let sibling = title.nextElementSibling; + while (sibling && sibling.tagName === "text") { + const next = sibling.nextElementSibling; + sibling.remove(); + sibling = next; + } + title.remove(); +} + +function annotateRunningNodes(svg: SVGSVGElement) { + const runningDotIds = new Set( + stages.filter((s) => s.status === "running").map((s) => s.dotId), + ); + const completedDotIds = new Set( + stages.filter((s) => s.status === "completed").map((s) => s.dotId), + ); + + const nodeGroups = svg.querySelectorAll(".node"); + for (const group of nodeGroups) { + const titleEl = group.querySelector("title"); + if (!titleEl) continue; + const nodeId = titleEl.textContent?.trim(); + if (!nodeId) continue; + + if (runningDotIds.has(nodeId)) { + // Style the running node with a pulsing glow + const shapes = group.querySelectorAll("ellipse, polygon, path"); + for (const shape of shapes) { + shape.setAttribute("class", "running-node"); + } + } else if (completedDotIds.has(nodeId)) { + // Tint completed nodes green + const shapes = group.querySelectorAll("ellipse, polygon, path"); + for (const shape of shapes) { + shape.setAttribute("fill", "#0a2a20"); + shape.setAttribute("stroke", "#34d399"); + } + const texts = group.querySelectorAll("text"); + for (const text of texts) { + text.setAttribute("fill", "#6ee7b7"); + } + } + } + + // Also color edges leading into completed nodes + const edgeGroups = svg.querySelectorAll(".edge"); + for (const group of edgeGroups) { + const titleEl = group.querySelector("title"); + if (!titleEl) continue; + const edgeLabel = titleEl.textContent?.trim() ?? ""; + const [, target] = edgeLabel.split("->"); + if (!target) continue; + const targetId = target.trim(); + + if (completedDotIds.has(targetId)) { + const paths = group.querySelectorAll("path, polygon"); + for (const p of paths) { + p.setAttribute("stroke", "#34d399"); + if (p.tagName === "polygon") p.setAttribute("fill", "#34d399"); + } + } + } + + // Inject CSS animation + const style = document.createElementNS("http://www.w3.org/2000/svg", "style"); + style.textContent = ` + @keyframes pulse-glow { + 0%, 100% { stroke: #14b8a6; fill: #0d3a3a; stroke-width: 2.4; } + 50% { stroke: #5eead4; fill: #0f4f4f; stroke-width: 3; } + } + .running-node { + animation: pulse-glow 2s ease-in-out infinite; + } + `; + svg.insertBefore(style, svg.firstChild); +} + +const ZOOM_STEPS = [25, 50, 75, 100, 150, 200]; +const DEFAULT_ZOOM_INDEX = 2; + export default function RunGraph() { const { id } = useParams(); const run = findRun(id ?? ""); const workflow = run ? workflowData[run.workflow] : undefined; - const [dotReady, setDotReady] = useState(false); + const containerRef = useRef(null); + const innerRef = useRef(null); + const svgRef = useRef(null); + const [error, setError] = useState(null); + const [zoomIndex, setZoomIndex] = useState(DEFAULT_ZOOM_INDEX); + const [direction, setDirection] = useState("LR"); + const [pan, setPan] = useState({ x: 0, y: 0 }); + const dragState = useRef<{ startX: number; startY: number; startPanX: number; startPanY: number } | null>(null); + const zoom = ZOOM_STEPS[zoomIndex]; useEffect(() => { let cancelled = false; - registerDotLanguage().then(() => { - if (!cancelled) setDotReady(true); + + async function render() { + const { instance } = await import("@viz-js/viz"); + const viz = await instance(); + if (cancelled) return; + + try { + const svg = viz.renderSVGElement(buildDot(direction)); + stripGraphTitle(svg); + annotateRunningNodes(svg); + + svgRef.current = svg; + if (innerRef.current) { + innerRef.current.replaceChildren(svg); + } + } catch (e) { + setError(e instanceof Error ? e.message : "Failed to render diagram"); + } + } + + setPan({ x: 0, y: 0 }); + render(); + return () => { cancelled = true; }; + }, [direction]); + + const onPointerDown = useCallback((e: React.PointerEvent) => { + if ((e.target as HTMLElement).closest("button")) return; + e.currentTarget.setPointerCapture(e.pointerId); + dragState.current = { startX: e.clientX, startY: e.clientY, startPanX: pan.x, startPanY: pan.y }; + }, [pan]); + + const onPointerMove = useCallback((e: React.PointerEvent) => { + const drag = dragState.current; + if (!drag) return; + setPan({ + x: drag.startPanX + e.clientX - drag.startX, + y: drag.startPanY + e.clientY - drag.startY, }); - return () => { - cancelled = true; - }; }, []); + const onPointerUp = useCallback(() => { + dragState.current = null; + }, []); + + const fitToWindow = useCallback(() => { + const svg = svgRef.current; + const container = containerRef.current; + if (!svg || !container) return; + + const svgW = svg.viewBox.baseVal.width || svg.getBoundingClientRect().width; + const svgH = svg.viewBox.baseVal.height || svg.getBoundingClientRect().height; + const padPx = 48; + const containerW = container.clientWidth - padPx; + const containerH = container.clientHeight - padPx; + + const fitPct = Math.min(containerW / svgW, containerH / svgH) * 100; + let best = 0; + for (let i = ZOOM_STEPS.length - 1; i >= 0; i--) { + if (ZOOM_STEPS[i] <= fitPct) { best = i; break; } + } + setZoomIndex(best); + setPan({ x: 0, y: 0 }); + }, []); + + if (error) { + return

{error}

; + } + return (
- {workflow && dotReady ? ( - - ) : ( -

No workflow graph available.

- )} +
+
+
+ + +
+ +
+ +
+ +
+ + +
+
+ +
+
+

Loading diagram...

+
+
+
);