mirror of
https://github.com/fabro-sh/fabro.git
synced 2026-10-10 03:30:59 +00:00
Add run detail sub-tabs: stages, usage, overview with diagram
- Add Usage tab with token/cost/runtime table per stage - Add Stages tab with expandable event timeline per stage - Add Overview tab with left sidebar (stages, workflow links) and interactive DOT diagram with zoom/pan/direction controls - Style DOT subgraph labels and borders for dark theme - Remove PR number and CI status from run detail header - Add Open PR button to run detail header Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
parent
51d58a904c
commit
cb98825d0b
5 changed files with 525 additions and 27 deletions
|
|
@ -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"),
|
||||
|
|
|
|||
|
|
@ -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) {
|
|||
<span>{run.title}</span>
|
||||
</nav>
|
||||
|
||||
<div className="mb-6">
|
||||
<h2 className="text-xl font-semibold text-white">{run.title}</h2>
|
||||
<div className="mt-2 flex items-center gap-3 text-sm">
|
||||
<span className="flex items-center gap-1.5">
|
||||
<span className={`size-2 rounded-full ${colors.dot}`} />
|
||||
<span className={`font-medium ${colors.text}`}>{run.statusLabel}</span>
|
||||
</span>
|
||||
<span className="font-mono text-xs text-navy-600">{run.repo}</span>
|
||||
{run.elapsed && (
|
||||
<span className={`font-mono text-xs ${run.elapsedWarning ? "text-amber" : "text-navy-600"}`}>{run.elapsed}</span>
|
||||
)}
|
||||
{run.number != null && (
|
||||
<span className="font-mono text-xs text-navy-600">#{run.number}</span>
|
||||
)}
|
||||
{run.ci && (
|
||||
<span className={`flex items-center gap-1 font-mono text-xs ${ciConfig[run.ci].text}`}>
|
||||
<span className={`size-1.5 rounded-full ${ciConfig[run.ci].dot}`} />
|
||||
{ciConfig[run.ci].label}
|
||||
<div className="mb-6 flex items-center gap-4">
|
||||
<div className="min-w-0 flex-1">
|
||||
<h2 className="text-xl font-semibold text-white">{run.title}</h2>
|
||||
<div className="mt-2 flex items-center gap-3 text-sm">
|
||||
<span className="flex items-center gap-1.5">
|
||||
<span className={`size-2 rounded-full ${colors.dot}`} />
|
||||
<span className={`font-medium ${colors.text}`}>{run.statusLabel}</span>
|
||||
</span>
|
||||
)}
|
||||
<span className="font-mono text-xs text-navy-600">{run.repo}</span>
|
||||
{run.elapsed && (
|
||||
<span className={`font-mono text-xs ${run.elapsedWarning ? "text-amber" : "text-navy-600"}`}>{run.elapsed}</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
title="Open pull request"
|
||||
className="flex shrink-0 items-center gap-1.5 rounded-md border border-mint/20 px-3 py-1.5 text-sm font-medium text-mint transition-colors hover:border-mint/50 hover:bg-mint/10 hover:text-white"
|
||||
>
|
||||
<svg viewBox="0 0 16 16" fill="currentColor" className="size-3.5" aria-hidden="true">
|
||||
<path d="M1.5 3.25a2.25 2.25 0 1 1 3 2.122v5.256a2.251 2.251 0 1 1-1.5 0V5.372A2.25 2.25 0 0 1 1.5 3.25Zm5.677-.177L9.573.677A.25.25 0 0 1 10 .854V2.5h1A2.5 2.5 0 0 1 13.5 5v5.628a2.251 2.251 0 1 1-1.5 0V5a1 1 0 0 0-1-1h-1v1.646a.25.25 0 0 1-.427.177L7.177 3.427a.25.25 0 0 1 0-.354ZM3.75 2.5a.75.75 0 1 0 0 1.5.75.75 0 0 0 0-1.5Zm0 9.5a.75.75 0 1 0 0 1.5.75.75 0 0 0 0-1.5Zm8.25.75a.75.75 0 1 0 1.5 0 .75.75 0 0 0-1.5 0Z" />
|
||||
</svg>
|
||||
Open PR
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="border-b border-white/[0.06]">
|
||||
|
|
@ -79,6 +83,13 @@ export default function RunDetail({ params }: Route.ComponentProps) {
|
|||
}`}
|
||||
>
|
||||
{tab.name}
|
||||
{tab.count != null && (
|
||||
<span className={`ml-1.5 rounded-full px-1.5 py-0.5 text-xs font-normal tabular-nums ${
|
||||
isActive ? "bg-white/10 text-ice-300" : "bg-white/[0.04] text-navy-600"
|
||||
}`}>
|
||||
{tab.count}
|
||||
</span>
|
||||
)}
|
||||
</Link>
|
||||
);
|
||||
})}
|
||||
|
|
|
|||
|
|
@ -1,3 +1,315 @@
|
|||
export default function RunOverview() {
|
||||
return <p className="text-sm text-navy-600">Overview will appear here.</p>;
|
||||
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<StageStatus, { icon: typeof CheckCircleIcon; color: string }> = {
|
||||
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<HTMLDivElement>(null);
|
||||
const innerRef = useRef<HTMLDivElement>(null);
|
||||
const svgRef = useRef<SVGSVGElement | null>(null);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [zoomIndex, setZoomIndex] = useState(DEFAULT_ZOOM_INDEX);
|
||||
const [direction, setDirection] = useState<Direction>("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 <p className="text-sm text-coral">{error}</p>;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="relative">
|
||||
<div className="absolute right-3 top-3 z-10 flex items-center gap-2">
|
||||
<div className="flex items-center gap-0.5 rounded-md border border-white/[0.06] bg-navy-800/90 p-0.5">
|
||||
<button
|
||||
type="button"
|
||||
title="Left to right"
|
||||
onClick={() => setDirection("LR")}
|
||||
className={`flex size-7 items-center justify-center rounded transition-colors ${direction === "LR" ? "bg-white/10 text-ice-300" : "text-navy-400 hover:bg-white/5 hover:text-ice-300"}`}
|
||||
>
|
||||
<ArrowRightIcon className="size-3.5" />
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
title="Top to bottom"
|
||||
onClick={() => setDirection("TB")}
|
||||
className={`flex size-7 items-center justify-center rounded transition-colors ${direction === "TB" ? "bg-white/10 text-ice-300" : "text-navy-400 hover:bg-white/5 hover:text-ice-300"}`}
|
||||
>
|
||||
<ArrowDownIcon className="size-3.5" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center rounded-md border border-white/[0.06] bg-navy-800/90 p-0.5">
|
||||
<button
|
||||
type="button"
|
||||
title="Fit to window"
|
||||
onClick={fitToWindow}
|
||||
className="flex size-7 items-center justify-center rounded text-navy-400 transition-colors hover:bg-white/5 hover:text-ice-300"
|
||||
>
|
||||
<svg viewBox="0 0 14 14" fill="none" stroke="currentColor" className="size-3.5" aria-hidden="true">
|
||||
<rect x="1" y="1" width="12" height="12" rx="1.5" strokeWidth="1.5" strokeDasharray="3 2" />
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-0.5 rounded-md border border-white/[0.06] bg-navy-800/90 p-0.5">
|
||||
<button
|
||||
type="button"
|
||||
title="Zoom out"
|
||||
onClick={() => setZoomIndex((i) => Math.max(0, i - 1))}
|
||||
disabled={zoomIndex === 0}
|
||||
className="flex size-7 items-center justify-center rounded text-navy-400 transition-colors hover:bg-white/5 hover:text-ice-300 disabled:opacity-30 disabled:hover:bg-transparent disabled:hover:text-navy-400"
|
||||
>
|
||||
<MinusIcon className="size-4" />
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
title="Zoom in"
|
||||
onClick={() => setZoomIndex((i) => Math.min(ZOOM_STEPS.length - 1, i + 1))}
|
||||
disabled={zoomIndex === ZOOM_STEPS.length - 1}
|
||||
className="flex size-7 items-center justify-center rounded text-navy-400 transition-colors hover:bg-white/5 hover:text-ice-300 disabled:opacity-30 disabled:hover:bg-transparent disabled:hover:text-navy-400"
|
||||
>
|
||||
<PlusIcon className="size-4" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div
|
||||
ref={containerRef}
|
||||
className="overflow-hidden p-6"
|
||||
style={{ cursor: dragState.current ? "grabbing" : "grab" }}
|
||||
onPointerDown={onPointerDown}
|
||||
onPointerMove={onPointerMove}
|
||||
onPointerUp={onPointerUp}
|
||||
onPointerCancel={onPointerUp}
|
||||
>
|
||||
<div
|
||||
ref={innerRef}
|
||||
className="flex items-center justify-center"
|
||||
style={{ transform: `translate(${pan.x}px, ${pan.y}px) scale(${zoom / 100})`, transformOrigin: "center center" }}
|
||||
>
|
||||
<p className="text-sm text-navy-600">Loading diagram...</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default function RunOverview() {
|
||||
const { id } = useParams();
|
||||
const run = findRun(id ?? "");
|
||||
const workflow = run ? workflowData[run.workflow] : undefined;
|
||||
|
||||
return (
|
||||
<div className="flex gap-6">
|
||||
<nav className="w-56 shrink-0 space-y-6">
|
||||
<div>
|
||||
<h3 className="px-2 text-xs font-medium uppercase tracking-wider text-navy-600">Stages</h3>
|
||||
<ul className="mt-2 space-y-0.5">
|
||||
{stages.map((stage) => {
|
||||
const config = statusConfig[stage.status];
|
||||
const Icon = config.icon;
|
||||
return (
|
||||
<li key={stage.name}>
|
||||
<Link
|
||||
to={`/runs/${id}/stages`}
|
||||
className="flex items-center gap-2 rounded-md px-2 py-1.5 text-sm text-ice-300 transition-colors hover:bg-white/[0.04] hover:text-white"
|
||||
>
|
||||
<Icon className={`size-4 shrink-0 ${config.color} ${stage.status === "running" ? "animate-spin" : ""}`} />
|
||||
<span className="flex-1 truncate">{stage.name}</span>
|
||||
<span className="font-mono text-xs tabular-nums text-navy-600">{stage.duration}</span>
|
||||
</Link>
|
||||
</li>
|
||||
);
|
||||
})}
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
{workflow && (
|
||||
<div>
|
||||
<h3 className="px-2 text-xs font-medium uppercase tracking-wider text-navy-600">Workflow</h3>
|
||||
<ul className="mt-2 space-y-0.5">
|
||||
<li>
|
||||
<Link
|
||||
to={`/workflows/${run?.workflow}`}
|
||||
className="flex items-center gap-2 rounded-md px-2 py-1.5 text-sm text-ice-300 transition-colors hover:bg-white/[0.04] hover:text-white"
|
||||
>
|
||||
<DocumentTextIcon className="size-4 shrink-0 text-navy-600" />
|
||||
Run Configuration
|
||||
</Link>
|
||||
</li>
|
||||
<li>
|
||||
<Link
|
||||
to={`/workflows/${run?.workflow}/diagram`}
|
||||
className="flex items-center gap-2 rounded-md px-2 py-1.5 text-sm text-ice-300 transition-colors hover:bg-white/[0.04] hover:text-white"
|
||||
>
|
||||
<MapIcon className="size-4 shrink-0 text-navy-600" />
|
||||
Workflow Graph
|
||||
</Link>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
)}
|
||||
</nav>
|
||||
|
||||
<div className="min-w-0 flex-1">
|
||||
{workflow ? (
|
||||
<div className="rounded-lg border border-white/[0.06] bg-navy-900/40 overflow-hidden">
|
||||
<DotDiagram dot={workflow.graph} />
|
||||
</div>
|
||||
) : (
|
||||
<p className="text-sm text-navy-600">No workflow graph available.</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,3 +1,120 @@
|
|||
export default function RunStages() {
|
||||
return <p className="text-sm text-navy-600">Stages will appear here.</p>;
|
||||
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<StageStatus, { icon: typeof CheckCircleIcon; color: string }> = {
|
||||
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 (
|
||||
<div className="border-b border-white/[0.06] last:border-b-0">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setOpen((v) => !v)}
|
||||
className="flex w-full items-center gap-3 px-4 py-3 text-left transition-colors hover:bg-white/[0.02]"
|
||||
>
|
||||
<ChevronRightIcon
|
||||
className={`size-4 shrink-0 text-navy-600 transition-transform duration-150 ${open ? "rotate-90" : ""}`}
|
||||
/>
|
||||
<Icon className={`size-5 shrink-0 ${config.color} ${stage.status === "running" ? "animate-spin" : ""}`} />
|
||||
<span className="flex-1 text-sm text-ice-100">{stage.name}</span>
|
||||
<span className="font-mono text-xs tabular-nums text-navy-600">{stage.duration}</span>
|
||||
</button>
|
||||
|
||||
{open && (
|
||||
<div className="relative ml-[2.75rem] border-l border-white/[0.06] pb-3">
|
||||
{stage.events.map((event, i) => (
|
||||
<div key={i} className="relative flex gap-3 py-1.5 pl-5 pr-4">
|
||||
<span className="absolute left-[-3px] top-1/2 size-1.5 -translate-y-1/2 rounded-full bg-navy-600" />
|
||||
<span className="shrink-0 font-mono text-xs tabular-nums text-navy-600 w-16 text-right">{event.time}</span>
|
||||
<span className="text-xs text-ice-300">{event.description}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default function RunStages() {
|
||||
return (
|
||||
<div className="rounded-lg border border-white/[0.06] bg-navy-800/50 overflow-hidden">
|
||||
{stages.map((stage) => (
|
||||
<StageRow key={stage.name} stage={stage} />
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
57
apps/arc-web/app/routes/run-usage.tsx
Normal file
57
apps/arc-web/app/routes/run-usage.tsx
Normal file
|
|
@ -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 (
|
||||
<div className="rounded-lg border border-white/[0.06] overflow-hidden">
|
||||
<table className="w-full text-sm">
|
||||
<thead>
|
||||
<tr className="border-b border-white/[0.06] bg-navy-800/60 text-left text-xs text-navy-600">
|
||||
<th className="px-4 py-2.5 font-medium">Stage</th>
|
||||
<th className="px-4 py-2.5 font-medium">Model</th>
|
||||
<th className="px-4 py-2.5 font-medium text-right">Tokens</th>
|
||||
<th className="px-4 py-2.5 font-medium text-right">Run time</th>
|
||||
<th className="px-4 py-2.5 font-medium text-right">Cost</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{stages.map((row) => (
|
||||
<tr key={row.stage} className="border-b border-white/[0.06] last:border-b-0">
|
||||
<td className="px-4 py-3 text-ice-100">{row.stage}</td>
|
||||
<td className="px-4 py-3 font-mono text-xs text-ice-300">{row.model}</td>
|
||||
<td className="px-4 py-3 text-right font-mono text-xs tabular-nums text-ice-300">
|
||||
{formatTokens(row.inputTokens)} <span className="text-navy-600">/</span> {formatTokens(row.outputTokens)}
|
||||
</td>
|
||||
<td className="px-4 py-3 text-right font-mono text-xs text-ice-300">{row.runtime}</td>
|
||||
<td className="px-4 py-3 text-right font-mono text-xs text-ice-300">${row.cost.toFixed(2)}</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
<tfoot>
|
||||
<tr className="border-t border-white/[0.08] bg-navy-800/40">
|
||||
<td className="px-4 py-3 font-medium text-white">Total</td>
|
||||
<td />
|
||||
<td className="px-4 py-3 text-right font-mono text-xs tabular-nums font-medium text-white">
|
||||
{formatTokens(totalInput)} <span className="text-navy-600">/</span> {formatTokens(totalOutput)}
|
||||
</td>
|
||||
<td className="px-4 py-3 text-right font-mono text-xs font-medium text-white">{totalRuntime}</td>
|
||||
<td className="px-4 py-3 text-right font-mono text-xs font-medium text-white">${totalCost.toFixed(2)}</td>
|
||||
</tr>
|
||||
</tfoot>
|
||||
</table>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
Loading…
Add table
Reference in a new issue