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 ( +
+
+
+ + +
+ +
+ +
+ +
+ + +
+
+ +
+
+

Loading diagram...

+
+
+
+ ); +} + +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 ( +
+ + + + + + + + + + + + {stages.map((row) => ( + + + + + + + + ))} + + + + + + + + + +
StageModelTokensRun timeCost
{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)}
+
+ ); +}