diff --git a/apps/arc-web/app/routes.ts b/apps/arc-web/app/routes.ts
index 504e6b3dd..d32ea00f8 100644
--- a/apps/arc-web/app/routes.ts
+++ b/apps/arc-web/app/routes.ts
@@ -20,6 +20,7 @@ export default [
index("routes/run-overview.tsx"),
route("stages", "routes/run-stages.tsx"),
route("files", "routes/run-files-changed.tsx"),
+ route("usage", "routes/run-usage.tsx"),
]),
route("insights", "routes/insights.tsx"),
route("settings", "routes/settings.tsx"),
diff --git a/apps/arc-web/app/routes/run-detail.tsx b/apps/arc-web/app/routes/run-detail.tsx
index 6af9d54d9..34af58802 100644
--- a/apps/arc-web/app/routes/run-detail.tsx
+++ b/apps/arc-web/app/routes/run-detail.tsx
@@ -1,13 +1,14 @@
import { ChevronRightIcon } from "@heroicons/react/20/solid";
import { Link, Outlet, useLocation } from "react-router";
-import { findRun, statusColors, ciConfig } from "../data/runs";
+import { findRun, statusColors } from "../data/runs";
import { workflowData } from "./workflow-detail";
import type { Route } from "./+types/run-detail";
const tabs = [
- { name: "Overview", path: "" },
- { name: "Stages", path: "/stages" },
- { name: "Files Changed", path: "/files" },
+ { name: "Overview", path: "", count: null },
+ { name: "Stages", path: "/stages", count: 4 },
+ { name: "Files Changed", path: "/files", count: 3 },
+ { name: "Usage", path: "/usage", count: null },
];
export const handle = { hideHeader: true };
@@ -40,27 +41,30 @@ export default function RunDetail({ params }: Route.ComponentProps) {
{run.title}
-
-
{run.title}
-
-
-
- {run.statusLabel}
-
-
{run.repo}
- {run.elapsed && (
-
{run.elapsed}
- )}
- {run.number != null && (
-
#{run.number}
- )}
- {run.ci && (
-
-
- {ciConfig[run.ci].label}
+
+
+
{run.title}
+
+
+
+ {run.statusLabel}
- )}
+ {run.repo}
+ {run.elapsed && (
+ {run.elapsed}
+ )}
+
+
@@ -79,6 +83,13 @@ export default function RunDetail({ params }: Route.ComponentProps) {
}`}
>
{tab.name}
+ {tab.count != null && (
+
+ {tab.count}
+
+ )}
);
})}
diff --git a/apps/arc-web/app/routes/run-overview.tsx b/apps/arc-web/app/routes/run-overview.tsx
index 0520db15f..4818a8e2f 100644
--- a/apps/arc-web/app/routes/run-overview.tsx
+++ b/apps/arc-web/app/routes/run-overview.tsx
@@ -1,3 +1,315 @@
-export default function RunOverview() {
- return
Overview will appear here.
;
+import { useCallback, useEffect, useRef, useState } from "react";
+import { Link, useParams } from "react-router";
+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";
+
+export const handle = { wide: true };
+
+type StageStatus = "completed" | "running" | "pending" | "failed";
+
+interface Stage {
+ name: string;
+ status: StageStatus;
+ duration: string;
+}
+
+const stages: Stage[] = [
+ { name: "Detect Drift", status: "completed", duration: "1m 12s" },
+ { name: "Propose Changes", status: "completed", duration: "2m 34s" },
+ { name: "Review Changes", status: "completed", duration: "0m 45s" },
+ { name: "Apply Changes", status: "running", duration: "1m 58s" },
+];
+
+const statusConfig: Record
= {
+ completed: { icon: CheckCircleIcon, color: "text-mint" },
+ running: { icon: ArrowPathIcon, color: "text-teal-500" },
+ pending: { icon: PauseCircleIcon, color: "text-navy-600" },
+ failed: { icon: XCircleIcon, color: "text-coral" },
+};
+
+const THEME_ATTRS = `
+ bgcolor="transparent"
+ pad=0.5
+ fontname="ui-monospace, monospace"
+ fontsize=10
+ fontcolor="#5a7a94"
+ color="#2a3f52"
+
+ graph [
+ fontname="ui-monospace, monospace"
+ fontsize=10
+ fontcolor="#5a7a94"
+ color="#2a3f52"
+ style="dashed"
+ penwidth=1
+ ]
+ node [
+ fontname="ui-monospace, monospace"
+ fontsize=11
+ fontcolor="#c6d4e0"
+ color="#2a3f52"
+ fillcolor="#1a2b3c"
+ style=filled
+ penwidth=1.2
+ ]
+ edge [
+ fontname="ui-monospace, monospace"
+ fontsize=9
+ fontcolor="#5a7a94"
+ color="#2a3f52"
+ arrowsize=0.7
+ penwidth=1.2
+ ]
+`;
+
+function applyTheme(dot: string): string {
+ return dot.replace(/\{/, `{\n${THEME_ATTRS}\n`);
+}
+
+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();
+}
+
+type Direction = "LR" | "TB";
+
+const ZOOM_STEPS = [25, 50, 75, 100, 150, 200];
+const DEFAULT_ZOOM_INDEX = 2; // 75%
+
+function DotDiagram({ dot }: { dot: string }) {
+ 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;
+
+ async function render() {
+ const { instance } = await import("@viz-js/viz");
+ const viz = await instance();
+ if (cancelled) return;
+
+ try {
+ const themed = applyTheme(dot).replace(/rankdir\s*=\s*\w+/, `rankdir=${direction}`);
+ const svg = viz.renderSVGElement(themed);
+ stripGraphTitle(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; };
+ }, [dot, 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,
+ });
+ }, []);
+
+ 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 (
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ );
+}
+
+export default function RunOverview() {
+ const { id } = useParams();
+ const run = findRun(id ?? "");
+ const workflow = run ? workflowData[run.workflow] : undefined;
+
+ return (
+
+
+
+
+ {workflow ? (
+
+
+
+ ) : (
+
No workflow graph available.
+ )}
+
+
+ );
}
diff --git a/apps/arc-web/app/routes/run-stages.tsx b/apps/arc-web/app/routes/run-stages.tsx
index 0c518ac6f..80d4ba0fa 100644
--- a/apps/arc-web/app/routes/run-stages.tsx
+++ b/apps/arc-web/app/routes/run-stages.tsx
@@ -1,3 +1,120 @@
-export default function RunStages() {
- return Stages will appear here.
;
+import { useState } from "react";
+import { ChevronRightIcon } from "@heroicons/react/20/solid";
+import { CheckCircleIcon, ArrowPathIcon, PauseCircleIcon, XCircleIcon } from "@heroicons/react/24/solid";
+
+type StageStatus = "completed" | "running" | "pending" | "failed";
+
+interface StageEvent {
+ time: string;
+ description: string;
+}
+
+interface Stage {
+ name: string;
+ status: StageStatus;
+ duration: string;
+ events: StageEvent[];
+}
+
+const stages: Stage[] = [
+ {
+ name: "Detect Drift",
+ status: "completed",
+ duration: "1m 12s",
+ events: [
+ { time: "0s", description: "Loading environment configs for production and staging" },
+ { time: "8s", description: "Diffing infrastructure state files" },
+ { time: "24s", description: "Comparing application config values" },
+ { time: "45s", description: "Analyzing schema differences" },
+ { time: "1m 02s", description: "Drift detected in 3 resources" },
+ { time: "1m 12s", description: "Stage completed" },
+ ],
+ },
+ {
+ name: "Propose Changes",
+ status: "completed",
+ duration: "2m 34s",
+ events: [
+ { time: "0s", description: "Generating reconciliation plan" },
+ { time: "18s", description: "Computing minimal patch for Redis config" },
+ { time: "52s", description: "Computing minimal patch for IAM policies" },
+ { time: "1m 30s", description: "Computing minimal patch for env variables" },
+ { time: "2m 10s", description: "Validating proposed changes against constraints" },
+ { time: "2m 34s", description: "Stage completed — 3 patches ready" },
+ ],
+ },
+ {
+ name: "Review Changes",
+ status: "completed",
+ duration: "0m 45s",
+ events: [
+ { time: "0s", description: "Presenting changes for review" },
+ { time: "0m 32s", description: "All patches approved" },
+ { time: "0m 45s", description: "Stage completed" },
+ ],
+ },
+ {
+ name: "Apply Changes",
+ status: "running",
+ duration: "1m 58s",
+ events: [
+ { time: "0s", description: "Applying Redis config patch" },
+ { time: "22s", description: "Redis config applied successfully" },
+ { time: "35s", description: "Applying IAM policy patch" },
+ { time: "1m 10s", description: "IAM policy applied successfully" },
+ { time: "1m 24s", description: "Applying env variable patch" },
+ ],
+ },
+];
+
+const statusConfig: Record = {
+ completed: { icon: CheckCircleIcon, color: "text-mint" },
+ running: { icon: ArrowPathIcon, color: "text-teal-500" },
+ pending: { icon: PauseCircleIcon, color: "text-navy-600" },
+ failed: { icon: XCircleIcon, color: "text-coral" },
+};
+
+function StageRow({ stage }: { stage: Stage }) {
+ const [open, setOpen] = useState(false);
+ const config = statusConfig[stage.status];
+ const Icon = config.icon;
+
+ return (
+
+
+
+ {open && (
+
+ {stage.events.map((event, i) => (
+
+
+ {event.time}
+ {event.description}
+
+ ))}
+
+ )}
+
+ );
+}
+
+export default function RunStages() {
+ return (
+
+ {stages.map((stage) => (
+
+ ))}
+
+ );
}
diff --git a/apps/arc-web/app/routes/run-usage.tsx b/apps/arc-web/app/routes/run-usage.tsx
new file mode 100644
index 000000000..65e419484
--- /dev/null
+++ b/apps/arc-web/app/routes/run-usage.tsx
@@ -0,0 +1,57 @@
+const stages = [
+ { stage: "Detect Drift", model: "Opus 4.6", inputTokens: 12_480, outputTokens: 3_210, runtime: "1m 12s", cost: 0.48 },
+ { stage: "Propose Changes", model: "Opus 4.6", inputTokens: 28_640, outputTokens: 8_750, runtime: "2m 34s", cost: 1.12 },
+ { stage: "Review Changes", model: "Opus 4.6", inputTokens: 9_120, outputTokens: 2_640, runtime: "0m 45s", cost: 0.31 },
+ { stage: "Apply Changes", model: "Opus 4.6", inputTokens: 21_300, outputTokens: 6_480, runtime: "1m 58s", cost: 0.87 },
+];
+
+const totalRuntime = "6m 29s";
+const totalCost = stages.reduce((sum, s) => sum + s.cost, 0);
+const totalInput = stages.reduce((sum, s) => sum + s.inputTokens, 0);
+const totalOutput = stages.reduce((sum, s) => sum + s.outputTokens, 0);
+
+function formatTokens(n: number) {
+ return `${(n / 1000).toFixed(1)}k`;
+}
+
+export default function RunUsage() {
+ return (
+
+
+
+
+ | Stage |
+ Model |
+ Tokens |
+ Run time |
+ Cost |
+
+
+
+ {stages.map((row) => (
+
+ | {row.stage} |
+ {row.model} |
+
+ {formatTokens(row.inputTokens)} / {formatTokens(row.outputTokens)}
+ |
+ {row.runtime} |
+ ${row.cost.toFixed(2)} |
+
+ ))}
+
+
+
+ | Total |
+ |
+
+ {formatTokens(totalInput)} / {formatTokens(totalOutput)}
+ |
+ {totalRuntime} |
+ ${totalCost.toFixed(2)} |
+
+
+
+
+ );
+}