diff --git a/apps/arc-web/app/api-client.ts b/apps/arc-web/app/api-client.ts index 340de4aac..92a469f4a 100644 --- a/apps/arc-web/app/api-client.ts +++ b/apps/arc-web/app/api-client.ts @@ -34,12 +34,23 @@ export async function apiFetch( throw new Error("ARC_API_BASE_URL environment variable is not set"); } - const token = await signToken(); const headers = new Headers(init?.headers); - headers.set("Authorization", `Bearer ${token}`); + if (ARC_JWT_PRIVATE_KEY) { + const token = await signToken(); + headers.set("Authorization", `Bearer ${token}`); + } return fetch(`${ARC_API_BASE_URL}${path}`, { ...init, headers, }); } + +/** + * Typed JSON fetch helper. Calls apiFetch and parses the JSON response. + */ +export async function apiJson(path: string, init?: RequestInit): Promise { + const res = await apiFetch(path, init); + if (!res.ok) throw new Error(`API ${res.status}: ${await res.text()}`); + return res.json() as Promise; +} diff --git a/apps/arc-web/app/lib/format.ts b/apps/arc-web/app/lib/format.ts new file mode 100644 index 000000000..ce3213617 --- /dev/null +++ b/apps/arc-web/app/lib/format.ts @@ -0,0 +1,35 @@ +/** + * Format a number of seconds into a human-readable duration string. + * Examples: "23s", "7m", "2h 15m", "3d" + */ +export function formatElapsedSecs(secs: number): string { + if (secs < 60) return `${Math.round(secs)}s`; + const minutes = Math.floor(secs / 60); + if (minutes < 60) { + const remainSecs = Math.round(secs % 60); + return remainSecs > 0 ? `${minutes}m ${remainSecs}s` : `${minutes}m`; + } + const hours = Math.floor(minutes / 60); + if (hours < 24) { + const remainMin = minutes % 60; + return remainMin > 0 ? `${hours}h ${remainMin}m` : `${hours}h`; + } + const days = Math.floor(hours / 24); + const remainHrs = hours % 24; + return remainHrs > 0 ? `${days}d ${remainHrs}h` : `${days}d`; +} + +/** + * Format seconds into a duration string for display (e.g., "1m 12s", "23s"). + */ +export function formatDurationSecs(secs: number): string { + if (secs < 60) return `${Math.round(secs)}s`; + const minutes = Math.floor(secs / 60); + const remainSecs = Math.round(secs % 60); + if (minutes < 60) { + return remainSecs > 0 ? `${minutes}m ${remainSecs}s` : `${minutes}m`; + } + const hours = Math.floor(minutes / 60); + const remainMin = minutes % 60; + return remainMin > 0 ? `${hours}h ${remainMin}m` : `${hours}h`; +} diff --git a/apps/arc-web/app/routes/insights.tsx b/apps/arc-web/app/routes/insights.tsx index d7ca9550a..9a9b22fdb 100644 --- a/apps/arc-web/app/routes/insights.tsx +++ b/apps/arc-web/app/routes/insights.tsx @@ -1,5 +1,7 @@ import { Link, Outlet, useNavigate } from "react-router"; import { PlusIcon } from "@heroicons/react/24/outline"; +import { apiJson } from "../api-client"; +import type { SavedQuery as ApiSavedQuery, HistoryEntry as ApiHistoryEntry } from "@qltysh/arc-api-client"; import type { Route } from "./+types/insights"; export function meta({}: Route.MetaArgs) { @@ -26,35 +28,28 @@ export interface HistoryEntry { rowsReturned: number; } -// ── Mock data ── +export async function loader() { + const [apiQueries, apiHistory] = await Promise.all([ + apiJson("/insights/queries"), + apiJson("/insights/history"), + ]); + const savedQueries: SavedQuery[] = apiQueries.map((q) => ({ + id: q.id, + name: q.name, + sql: q.sql, + })); + const historyEntries: HistoryEntry[] = apiHistory.map((h) => ({ + id: h.id, + sql: h.sql, + timestamp: h.timestamp, + elapsed: h.elapsed, + rowsReturned: h.row_count, + })); + return { savedQueries, historyEntries }; +} -export const savedQueries: SavedQuery[] = [ - { - id: "1", - name: "Run duration by workflow", - sql: "SELECT workflow_name, AVG(duration_seconds) as avg_duration,\n COUNT(*) as run_count\nFROM runs\nGROUP BY workflow_name\nORDER BY avg_duration DESC\nLIMIT 20", - }, - { - id: "2", - name: "Daily failure rate", - sql: "SELECT date_trunc('day', created_at) as day,\n COUNT(*) FILTER (WHERE status = 'failed') as failures,\n COUNT(*) as total,\n ROUND(100.0 * COUNT(*) FILTER (WHERE status = 'failed') / COUNT(*), 1) as failure_rate\nFROM runs\nGROUP BY 1\nORDER BY 1 DESC\nLIMIT 30", - }, - { - id: "3", - name: "Top repos by activity", - sql: "SELECT repo, COUNT(*) as runs, SUM(additions) as total_additions,\n SUM(deletions) as total_deletions\nFROM runs\nGROUP BY repo\nORDER BY runs DESC", - }, -]; - -export const historyEntries: HistoryEntry[] = [ - { id: "h1", sql: "SELECT workflow_name, COUNT(*) FROM runs GROUP BY 1", timestamp: "2 min ago", elapsed: 0.342, rowsReturned: 6 }, - { id: "h2", sql: "SELECT * FROM runs WHERE status = 'failed' LIMIT 100", timestamp: "8 min ago", elapsed: 0.127, rowsReturned: 23 }, - { id: "h3", sql: "SELECT date_trunc('day', created_at) as d, COUNT(*) FROM runs GROUP BY 1 ORDER BY 1", timestamp: "15 min ago", elapsed: 0.531, rowsReturned: 30 }, - { id: "h4", sql: "SELECT repo, AVG(duration_seconds) FROM runs GROUP BY repo", timestamp: "1 hr ago", elapsed: 0.089, rowsReturned: 12 }, - { id: "h5", sql: "DESCRIBE runs", timestamp: "1 hr ago", elapsed: 0.003, rowsReturned: 18 }, -]; - -export default function InsightsLayout() { +export default function InsightsLayout({ loaderData }: Route.ComponentProps) { + const { savedQueries, historyEntries } = loaderData; const navigate = useNavigate(); return ( diff --git a/apps/arc-web/app/routes/retros.tsx b/apps/arc-web/app/routes/retros.tsx index 670769d28..ac0226dc9 100644 --- a/apps/arc-web/app/routes/retros.tsx +++ b/apps/arc-web/app/routes/retros.tsx @@ -1,10 +1,36 @@ import { useState } from "react"; import { useNavigate } from "react-router"; import { MagnifyingGlassIcon, ChevronDownIcon } from "@heroicons/react/24/outline"; -import { allRetros, smoothnessConfig, formatDuration } from "../data/retros"; -import type { Retro, SmoothnessRating } from "../data/retros"; +import { smoothnessConfig, formatDuration } from "../data/retros"; +import type { SmoothnessRating } from "../data/retros"; +import { apiJson } from "../api-client"; +import type { RetroListItem } from "@qltysh/arc-api-client"; import type { Route } from "./+types/retros"; +interface RetroRow { + run_id: string; + workflow_name: string; + goal: string; + timestamp: string; + smoothness?: SmoothnessRating; + total_duration_ms: number; + friction_point_count: number; +} + +export async function loader() { + const apiRetros = await apiJson("/retros"); + const retros: RetroRow[] = apiRetros.map((r) => ({ + run_id: r.run_id, + workflow_name: r.workflow_name, + goal: r.goal, + timestamp: r.timestamp, + smoothness: r.smoothness as SmoothnessRating | undefined, + total_duration_ms: r.stats.total_duration_ms, + friction_point_count: r.friction_point_count, + })); + return { retros }; +} + export function meta({}: Route.MetaArgs) { return [{ title: "Retros \u2014 Arc" }]; } @@ -17,7 +43,7 @@ const smoothnessOptions: Array<{ value: SmoothnessRating; label: string }> = [ { value: "failed", label: "Failed" }, ]; -function SmoothnesssBadge({ smoothness }: { smoothness: Retro["smoothness"] }) { +function SmoothnesssBadge({ smoothness }: { smoothness: SmoothnessRating | undefined }) { if (!smoothness) { return --; } @@ -45,8 +71,8 @@ function truncate(text: string, maxLength: number): string { return text.slice(0, maxLength) + "\u2026"; } -export default function Retros() { - const retros = allRetros(); +export default function Retros({ loaderData }: Route.ComponentProps) { + const { retros } = loaderData; const navigate = useNavigate(); const [query, setQuery] = useState(""); const [smoothnessFilter, setSmoothnessFilter] = useState("all"); @@ -116,10 +142,10 @@ export default function Retros() { - {formatDuration(retro.stats.total_duration_ms)} + {formatDuration(retro.total_duration_ms)} - {retro.friction_points?.length ?? 0} + {retro.friction_point_count} {formatTimestamp(retro.timestamp)} diff --git a/apps/arc-web/app/routes/run-configuration.tsx b/apps/arc-web/app/routes/run-configuration.tsx index 6fc0116af..f765e668b 100644 --- a/apps/arc-web/app/routes/run-configuration.tsx +++ b/apps/arc-web/app/routes/run-configuration.tsx @@ -1,9 +1,11 @@ import { Link, useParams } from "react-router"; 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 { CollapsibleFile } from "../components/collapsible-file"; +import { apiFetch, apiJson } from "../api-client"; +import { formatDurationSecs } from "../lib/format"; +import type { RunStage } from "@qltysh/arc-api-client"; +import type { Route } from "./+types/run-configuration"; export const handle = { wide: true }; @@ -16,13 +18,6 @@ interface Stage { 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" }, -]; - const statusConfig: Record = { completed: { icon: CheckCircleIcon, color: "text-mint" }, running: { icon: ArrowPathIcon, color: "text-teal-500" }, @@ -30,10 +25,24 @@ const statusConfig: Record(`/runs/${params.id}/stages`), + apiFetch(`/runs/${params.id}/configuration`), + ]); + const stages: Stage[] = apiStages.map((s) => ({ + id: s.id, + name: s.name, + status: s.status as StageStatus, + duration: s.duration_secs != null ? formatDurationSecs(s.duration_secs) : "--", + })); + const configText = configRes.ok ? await configRes.text() : null; + return { stages, configText }; +} + +export default function RunConfiguration({ loaderData }: Route.ComponentProps) { const { id } = useParams(); - const run = findRun(id ?? ""); - const workflow = run ? workflowData[run.workflow] : undefined; + const { stages, configText } = loaderData; return (
@@ -60,37 +69,35 @@ export default function RunConfiguration() {
- {workflow && ( -
-

Workflow

-
    -
  • - - - Run Configuration - -
  • -
  • - - - Workflow Graph - -
  • -
-
- )} +
+

Workflow

+
    +
  • + + + Run Configuration + +
  • +
  • + + + Workflow Graph + +
  • +
+
- {workflow ? ( + {configText ? ( ) : (

No configuration found.

diff --git a/apps/arc-web/app/routes/run-detail.tsx b/apps/arc-web/app/routes/run-detail.tsx index aa5487266..2cfcbe215 100644 --- a/apps/arc-web/app/routes/run-detail.tsx +++ b/apps/arc-web/app/routes/run-detail.tsx @@ -1,14 +1,17 @@ import { ChevronDownIcon, ChevronRightIcon } from "@heroicons/react/20/solid"; import { Menu, MenuButton, MenuItem, MenuItems } from "@headlessui/react"; import { Link, Outlet, useLocation } from "react-router"; -import { findRun, statusColors } from "../data/runs"; -import { workflowData } from "./workflow-detail"; +import { statusColors } from "../data/runs"; +import type { ColumnStatus } from "../data/runs"; +import { apiJson } from "../api-client"; +import { formatElapsedSecs, formatDurationSecs } from "../lib/format"; +import type { RunListItem } from "@qltysh/arc-api-client"; import type { Route } from "./+types/run-detail"; const tabs = [ { name: "Overview", path: "", count: null }, - { name: "Stages", path: "/stages/detect-drift", count: 4 }, - { name: "Files Changed", path: "/files", count: 3 }, + { name: "Stages", path: "/stages/detect-drift", count: null }, + { name: "Files Changed", path: "/files", count: null }, { name: "Verifications", path: "/verifications", count: null }, { name: "Retro", path: "/retro", count: null }, { name: "Usage", path: "/usage", count: null }, @@ -16,13 +19,32 @@ const tabs = [ export const handle = { hideHeader: true }; -export function meta({ params }: Route.MetaArgs) { - const run = findRun(params.id); +export async function loader({ params }: Route.LoaderArgs) { + const apiRuns = await apiJson("/runs"); + const apiRun = apiRuns.find((r) => r.id === params.id); + if (!apiRun) return { run: null }; + return { + run: { + id: apiRun.id, + repo: apiRun.repo, + title: apiRun.title, + workflow: apiRun.workflow, + status: apiRun.status as ColumnStatus, + statusLabel: apiRun.status === "working" ? "Working" : apiRun.status === "pending" ? "Pending" : apiRun.status === "review" ? "Verify" : "Merge", + elapsed: apiRun.elapsed_secs != null ? formatElapsedSecs(apiRun.elapsed_secs) : undefined, + elapsedWarning: apiRun.elapsed_warning, + sandboxId: apiRun.sandbox_id, + }, + }; +} + +export function meta({ data }: Route.MetaArgs) { + const run = data?.run; return [{ title: run ? `${run.title} — Arc` : "Run — Arc" }]; } -export default function RunDetail({ params }: Route.ComponentProps) { - const run = findRun(params.id); +export default function RunDetail({ loaderData, params }: Route.ComponentProps) { + const { run } = loaderData; const { pathname } = useLocation(); const basePath = `/runs/${params.id}`; @@ -38,7 +60,7 @@ export default function RunDetail({ params }: Route.ComponentProps) { Runs - {workflowData[run.workflow]?.title ?? run.workflow} + {run.workflow} {run.title} diff --git a/apps/arc-web/app/routes/run-files-changed.tsx b/apps/arc-web/app/routes/run-files-changed.tsx index 4af9d56ac..2760ce08e 100644 --- a/apps/arc-web/app/routes/run-files-changed.tsx +++ b/apps/arc-web/app/routes/run-files-changed.tsx @@ -1,4 +1,5 @@ import { useCallback, useEffect, useRef, useState } from "react"; +import { useParams } from "react-router"; import { ChevronDownIcon, Cog6ToothIcon } from "@heroicons/react/24/outline"; import { MultiFileDiff, @@ -6,18 +7,18 @@ import { type DiffLineAnnotation, } from "@pierre/diffs/react"; import { useTheme } from "../lib/theme"; +import { apiJson } from "../api-client"; +import type { RunFiles } from "@qltysh/arc-api-client"; +import type { Route } from "./+types/run-files-changed"; export const handle = { wide: true }; -const checkpoints = [ - { id: "all", label: "All changes" }, - { id: "cp-4", label: "Checkpoint 4 — Apply Changes" }, - { id: "cp-3", label: "Checkpoint 3 — Review Changes" }, - { id: "cp-2", label: "Checkpoint 2 — Propose Changes" }, - { id: "cp-1", label: "Checkpoint 1 — Detect Drift" }, -]; +export async function loader({ params }: Route.LoaderArgs) { + const data = await apiJson(`/runs/${params.id}/files?checkpoint=all`); + return data; +} -const files = [ +const fallbackFiles = [ { oldFile: { name: "src/commands/run.ts", @@ -507,7 +508,20 @@ function buildAnnotationsForFile( return annotations; } -export default function RunFilesChanged() { +export default function RunFilesChanged({ loaderData }: Route.ComponentProps) { + const runFiles = loaderData; + const checkpoints = [ + { id: "all", label: "All changes" }, + ...runFiles.checkpoints.map((cp) => ({ id: cp.id, label: cp.label })), + ]; + const files = runFiles.files.length > 0 + ? runFiles.files.map((f) => ({ + oldFile: { name: f.old_file.name, contents: f.old_file.contents }, + newFile: { name: f.new_file.name, contents: f.new_file.contents }, + })) + : fallbackFiles; + const diffStats = runFiles.stats; + const [checkpoint, setCheckpoint] = useState(checkpoints[0].id); const [openSteers, setOpenSteers] = useState( () => new Map(), @@ -560,7 +574,7 @@ export default function RunFilesChanged() {
- +
- {workflow && ( -
-

Workflow

-
    -
  • - - - Run Configuration - -
  • -
  • - - - Workflow Graph - -
  • -
-
- )} +
+

Workflow

+
    +
  • + + + Run Configuration + +
  • +
  • + + + Workflow Graph + +
  • +
+
diff --git a/apps/arc-web/app/routes/run-overview.tsx b/apps/arc-web/app/routes/run-overview.tsx index ac4fa7765..71a89d492 100644 --- a/apps/arc-web/app/routes/run-overview.tsx +++ b/apps/arc-web/app/routes/run-overview.tsx @@ -3,10 +3,12 @@ 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"; import { useTheme } from "../lib/theme"; import { getGraphTheme } from "../lib/graph-theme"; +import { apiJson } from "../api-client"; +import { formatDurationSecs } from "../lib/format"; +import type { RunStage, RunListItem, WorkflowDetail } from "@qltysh/arc-api-client"; +import type { Route } from "./+types/run-overview"; export const handle = { wide: true }; @@ -19,12 +21,29 @@ interface Stage { 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" }, -]; +export async function loader({ params }: Route.LoaderArgs) { + const [apiStages, runs] = await Promise.all([ + apiJson(`/runs/${params.id}/stages`), + apiJson("/runs"), + ]); + const stages: Stage[] = apiStages.map((s) => ({ + id: s.id, + name: s.name, + status: s.status as StageStatus, + duration: s.duration_secs != null ? formatDurationSecs(s.duration_secs) : "--", + })); + const run = runs.find((r) => r.id === params.id); + let graphDot: string | null = null; + if (run) { + try { + const workflow = await apiJson(`/workflows/${run.workflow}`); + graphDot = workflow.graph; + } catch { + // workflow not found — leave graphDot null + } + } + return { stages, graphDot }; +} const statusConfig: Record = { completed: { icon: CheckCircleIcon, color: "text-mint" }, @@ -35,6 +54,7 @@ const statusConfig: Record) { return ` + rankdir=LR bgcolor="transparent" pad=0.5 fontname="ui-monospace, monospace" @@ -265,10 +285,9 @@ function DotDiagram({ dot }: { dot: string }) { ); } -export default function RunOverview() { +export default function RunOverview({ loaderData }: Route.ComponentProps) { const { id } = useParams(); - const run = findRun(id ?? ""); - const workflow = run ? workflowData[run.workflow] : undefined; + const { stages, graphDot } = loaderData; return (
@@ -295,37 +314,35 @@ export default function RunOverview() {
- {workflow && ( -
-

Workflow

-
    -
  • - - - Run Configuration - -
  • -
  • - - - Workflow Graph - -
  • -
-
- )} +
+

Workflow

+
    +
  • + + + Run Configuration + +
  • +
  • + + + Workflow Graph + +
  • +
+
- {workflow ? ( + {graphDot ? (
- +
) : (

No workflow graph available.

diff --git a/apps/arc-web/app/routes/run-retro.tsx b/apps/arc-web/app/routes/run-retro.tsx index 5d1845508..8af0a75fc 100644 --- a/apps/arc-web/app/routes/run-retro.tsx +++ b/apps/arc-web/app/routes/run-retro.tsx @@ -1,16 +1,22 @@ import { Link } from "react-router"; import { - findRetro, smoothnessConfig, learningCategoryConfig, frictionKindConfig, openItemKindConfig, formatDuration, } from "../data/retros"; +import type { Retro } from "../data/retros"; +import { apiJson } from "../api-client"; import type { Route } from "./+types/run-retro"; -export function meta({ params }: Route.MetaArgs) { - const retro = findRetro(params.id); +export async function loader({ params }: Route.LoaderArgs) { + const retro = await apiJson(`/runs/${params.id}/retro`); + return { retro }; +} + +export function meta({ data }: Route.MetaArgs) { + const retro = data?.retro; return [{ title: retro ? `Retro: ${retro.goal} \u2014 Arc` : "Retro \u2014 Arc" }]; } @@ -19,8 +25,8 @@ function formatCost(cost: number | undefined): string { return `$${cost.toFixed(2)}`; } -export default function RunRetro({ params }: Route.ComponentProps) { - const retro = findRetro(params.id); +export default function RunRetro({ loaderData }: Route.ComponentProps) { + const { retro } = loaderData; if (!retro) { return

No retrospective found for this run.

; diff --git a/apps/arc-web/app/routes/run-stages.tsx b/apps/arc-web/app/routes/run-stages.tsx index 1b7cdea11..14b1cd6b8 100644 --- a/apps/arc-web/app/routes/run-stages.tsx +++ b/apps/arc-web/app/routes/run-stages.tsx @@ -3,8 +3,10 @@ import { Link, useParams } from "react-router"; import { ChevronRightIcon } from "@heroicons/react/20/solid"; import { CheckCircleIcon, ArrowPathIcon, PauseCircleIcon, XCircleIcon } from "@heroicons/react/24/solid"; import { DocumentTextIcon, MapIcon, CommandLineIcon, ChatBubbleLeftIcon, WrenchScrewdriverIcon } from "@heroicons/react/24/outline"; -import { findRun } from "../data/runs"; -import { workflowData } from "./workflow-detail"; +import { apiJson } from "../api-client"; +import { formatDurationSecs } from "../lib/format"; +import type { RunStage, StageTurn as ApiStageTurn } from "@qltysh/arc-api-client"; +import type { Route } from "./+types/run-stages"; export const handle = { wide: true }; @@ -17,12 +19,24 @@ interface Stage { 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" }, -]; +export async function loader({ params }: Route.LoaderArgs) { + const apiStages = await apiJson(`/runs/${params.id}/stages`); + const stages: Stage[] = apiStages.map((s) => ({ + id: s.id, + name: s.name, + status: s.status as StageStatus, + duration: s.duration_secs != null ? formatDurationSecs(s.duration_secs) : "--", + })); + + // Fetch turns for the selected stage (first stage if none specified) + const selectedStageId = params.stageId ?? stages[0]?.id; + let turns: ApiStageTurn[] = []; + if (selectedStageId) { + turns = await apiJson(`/runs/${params.id}/stages/${selectedStageId}/turns`); + } + + return { stages, turns }; +} const statusConfig: Record = { completed: { icon: CheckCircleIcon, color: "text-mint" }, @@ -44,41 +58,6 @@ type TurnType = // selectedStage is resolved from the URL param in RunStages below -const turns: TurnType[] = [ - { - kind: "system", - content: `You are a drift detection agent. Compare the production and staging environments and identify any configuration or code drift.\n\nSource: production\nTarget: staging\nThreshold: warn`, - }, - { - kind: "assistant", - content: "I'll start by loading the environment configurations for both production and staging to compare them.", - }, - { - kind: "tool", - tools: [ - { - toolName: "read_file", - args: `{ "path": "environments/production/config.toml" }`, - result: `[redis]\nhost = "redis-prod.internal"\nport = 6379\nmax_connections = 200\ntls = true\n\n[iam]\nrole_arn = "arn:aws:iam::123456:role/prod-api"\nsession_duration = 3600`, - }, - { - toolName: "read_file", - args: `{ "path": "environments/staging/config.toml" }`, - result: `[redis]\nhost = "redis-staging.internal"\nport = 6379\nmax_connections = 100\ntls = false\n\n[iam]\nrole_arn = "arn:aws:iam::123456:role/staging-api"\nsession_duration = 1800`, - }, - { - toolName: "diff_configs", - args: `{ "source": "environments/production/config.toml", "target": "environments/staging/config.toml" }`, - result: `3 differences found:\n redis.max_connections: 200 → 100\n redis.tls: true → false\n iam.session_duration: 3600 → 1800`, - }, - ], - }, - { - kind: "assistant", - content: "I've detected drift in 3 resources between production and staging:\n\n1. **redis.max_connections** — production has 200, staging has 100\n2. **redis.tls** — enabled in production, disabled in staging\n3. **iam.session_duration** — production uses 3600s, staging uses 1800s\n\nThe TLS mismatch is the most critical — staging should match production's TLS configuration for accurate testing. The connection pool and session duration differences may be intentional for cost reasons but should be verified.", - }, -]; - function ToolRow({ tool }: { tool: ToolUse }) { const [open, setOpen] = useState(false); @@ -148,10 +127,24 @@ function AssistantBlock({ content }: { content: string }) { ); } -export default function RunStages() { +export default function RunStages({ loaderData }: Route.ComponentProps) { const { id, stageId } = useParams(); - const run = findRun(id ?? ""); - const workflow = run ? workflowData[run.workflow] : undefined; + const { stages, turns: apiTurns } = loaderData; + + const mappedTurns: TurnType[] = apiTurns.map((t) => { + if (t.kind === "tool" && t.tools) { + return { + kind: "tool" as const, + tools: t.tools.map((tu) => ({ + toolName: tu.tool_name, + args: tu.args, + result: tu.result, + })), + }; + } + return { kind: t.kind as "system" | "assistant", content: t.content ?? "" }; + }); + const selectedStage = stages.find((s) => s.id === stageId) ?? stages[0]; const selectedConfig = statusConfig[selectedStage.status]; const SelectedIcon = selectedConfig.icon; @@ -186,31 +179,29 @@ export default function RunStages() {
- {workflow && ( -
-

Workflow

-
    -
  • - - - Run Configuration - -
  • -
  • - - - Workflow Graph - -
  • -
-
- )} +
+

Workflow

+
    +
  • + + + Run Configuration + +
  • +
  • + + + Workflow Graph + +
  • +
+
@@ -220,7 +211,7 @@ export default function RunStages() { {selectedStage.duration}
- {turns.map((turn, i) => { + {mappedTurns.map((turn, i) => { switch (turn.kind) { case "system": return ; diff --git a/apps/arc-web/app/routes/run-usage.tsx b/apps/arc-web/app/routes/run-usage.tsx index 4bc41032f..baee0e166 100644 --- a/apps/arc-web/app/routes/run-usage.tsx +++ b/apps/arc-web/app/routes/run-usage.tsx @@ -1,35 +1,40 @@ -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: "Gemini 3.1", inputTokens: 28_640, outputTokens: 8_750, runtime: "2m 34s", cost: 0.72 }, - { stage: "Review Changes", model: "Codex 5.3", inputTokens: 9_120, outputTokens: 2_640, runtime: "0m 45s", cost: 0.19 }, - { stage: "Apply Changes", model: "Opus 4.6", inputTokens: 21_300, outputTokens: 6_480, runtime: "1m 58s", cost: 0.87 }, -]; +import { apiJson } from "../api-client"; +import { formatDurationSecs } from "../lib/format"; +import type { RunUsage } from "@qltysh/arc-api-client"; +import type { Route } from "./+types/run-usage"; -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); - -const modelBreakdown = Object.values( - stages.reduce>( - (acc, s) => { - const entry = acc[s.model] ?? { model: s.model, inputTokens: 0, outputTokens: 0, cost: 0, stages: 0 }; - entry.inputTokens += s.inputTokens; - entry.outputTokens += s.outputTokens; - entry.cost += s.cost; - entry.stages += 1; - acc[s.model] = entry; - return acc; - }, - {}, - ), -).sort((a, b) => b.cost - a.cost); +export async function loader({ params }: Route.LoaderArgs) { + const usage = await apiJson(`/runs/${params.id}/usage`); + const stages = usage.stages.map((s) => ({ + stage: s.stage, + model: s.model, + inputTokens: s.input_tokens, + outputTokens: s.output_tokens, + runtime: formatDurationSecs(s.runtime_secs), + cost: s.cost, + })); + const totalRuntime = formatDurationSecs(usage.totals.runtime_secs); + const totalCost = usage.totals.cost; + const totalInput = usage.totals.input_tokens; + const totalOutput = usage.totals.output_tokens; + const modelBreakdown = usage.by_model + .map((m) => ({ + model: m.model, + stages: m.stages, + inputTokens: m.input_tokens, + outputTokens: m.output_tokens, + cost: m.cost, + })) + .sort((a, b) => b.cost - a.cost); + return { stages, totalRuntime, totalCost, totalInput, totalOutput, modelBreakdown }; +} function formatTokens(n: number) { return `${(n / 1000).toFixed(1)}k`; } -export default function RunUsage() { +export default function RunUsage({ loaderData }: Route.ComponentProps) { + const { stages, totalRuntime, totalCost, totalInput, totalOutput, modelBreakdown } = loaderData; return (
diff --git a/apps/arc-web/app/routes/run-verifications.tsx b/apps/arc-web/app/routes/run-verifications.tsx index 51e89d5f8..c35ec249c 100644 --- a/apps/arc-web/app/routes/run-verifications.tsx +++ b/apps/arc-web/app/routes/run-verifications.tsx @@ -10,7 +10,6 @@ import { ChevronRightIcon, } from "@heroicons/react/20/solid"; import { - verificationCategories, statusConfig, typeConfig, getCriteriaSummary, @@ -20,6 +19,25 @@ import type { VerificationType, VerificationCategory, } from "../data/verifications"; +import { apiJson } from "../api-client"; +import type { RunVerification } from "@qltysh/arc-api-client"; +import type { Route } from "./+types/run-verifications"; + +export async function loader({ params }: Route.LoaderArgs) { + const apiCategories = await apiJson(`/runs/${params.id}/verifications`); + const categories: VerificationCategory[] = apiCategories.map((cat) => ({ + name: cat.name, + question: cat.question, + status: cat.status as VerificationStatus, + criteria: cat.controls.map((c) => ({ + name: c.name, + description: c.description, + type: (c.type ?? null) as VerificationType | null, + status: c.status as VerificationStatus, + })), + })); + return { categories }; +} function StatusIcon({ status, @@ -117,10 +135,11 @@ function CategoryCard({ category }: { category: VerificationCategory }) { ); } -export default function RunVerifications() { +export default function RunVerifications({ loaderData }: Route.ComponentProps) { + const { categories } = loaderData; return (
- {verificationCategories.map((category) => ( + {categories.map((category) => ( ))}
diff --git a/apps/arc-web/app/routes/runs.tsx b/apps/arc-web/app/routes/runs.tsx index c16fdabd2..f7497cd42 100644 --- a/apps/arc-web/app/routes/runs.tsx +++ b/apps/arc-web/app/routes/runs.tsx @@ -18,14 +18,77 @@ import { arrayMove, } from "@dnd-kit/sortable"; import { CSS } from "@dnd-kit/utilities"; -import { columns as staticColumns, ciConfig, statusColors, deriveCiStatus } from "../data/runs"; -import type { CiStatus, CheckRun, CheckStatus, RunItem, RunWithStatus } from "../data/runs"; +import { ciConfig, statusColors, deriveCiStatus } from "../data/runs"; +import type { CiStatus, CheckRun, CheckStatus, RunItem, RunWithStatus, ColumnStatus } from "../data/runs"; +import { apiJson } from "../api-client"; +import { formatElapsedSecs, formatDurationSecs } from "../lib/format"; +import type { RunListItem } from "@qltysh/arc-api-client"; import type { Route } from "./+types/runs"; export function meta({}: Route.MetaArgs) { return [{ title: "Runs — Arc" }]; } +function mapRunListItem(item: RunListItem): RunItem { + return { + id: item.id, + repo: item.repo, + title: item.title, + workflow: item.workflow, + number: item.number, + additions: item.additions, + deletions: item.deletions, + checks: item.checks?.map((c) => ({ + name: c.name, + status: c.status, + duration: c.duration_secs != null ? formatDurationSecs(c.duration_secs) : undefined, + })), + elapsed: item.elapsed_secs != null ? formatElapsedSecs(item.elapsed_secs) : undefined, + elapsedWarning: item.elapsed_warning, + resources: item.resources, + comments: item.comments, + question: item.question, + sandboxId: item.sandbox_id, + }; +} + +const columnConfig: { + id: ColumnStatus; + name: string; + accent: string; + iconColor: string; + iconType: "branch" | "pr"; + actions: string[]; +}[] = [ + { id: "working", name: "Working", accent: "bg-teal-500", iconColor: "text-teal-500", iconType: "branch", actions: ["Watch", "Steer"] }, + { id: "pending", name: "Pending", accent: "bg-amber", iconColor: "text-amber", iconType: "branch", actions: ["Answer Question"] }, + { id: "review", name: "Verify", accent: "bg-mint", iconColor: "text-mint", iconType: "pr", actions: ["Resolve"] }, + { id: "merge", name: "Merge", accent: "bg-teal-300", iconColor: "text-teal-300", iconType: "pr", actions: ["Merge"] }, +]; + +export async function loader() { + const apiRuns = await apiJson("/runs"); + const items = apiRuns.map(mapRunListItem); + + const grouped = new Map(); + for (const cfg of columnConfig) { + grouped.set(cfg.id, []); + } + for (const item of items) { + const status = apiRuns.find((r) => r.id === item.id)?.status; + if (status && grouped.has(status)) { + grouped.get(status)?.push(item); + } + } + + const columns = columnConfig.map((cfg) => ({ + ...cfg, + items: grouped.get(cfg.id) ?? [], + })); + + return { columns }; +} + function GitBranchIcon({ className }: { className?: string }) { return ( @@ -186,24 +249,8 @@ function ChecksStatus({ checks }: { checks: CheckRun[] }) { ); } -const totalCards = staticColumns.reduce((sum, col) => sum + col.items.length, 0); -const totalPrs = staticColumns.reduce( - (sum, col) => sum + col.items.filter((item) => item.number != null).length, - 0, -); - export const handle = { wide: true, - headerExtra: ( -
- - {totalCards} runs - - - {totalPrs} PRs - -
- ), }; function PrCard({ @@ -359,7 +406,17 @@ function SortablePrCard({ ); } -function BoardColumn({ column }: { column: (typeof staticColumns)[number] }) { +type Column = { + id: ColumnStatus; + name: string; + accent: string; + iconColor: string; + iconType: "branch" | "pr"; + actions: string[]; + items: RunItem[]; +}; + +function BoardColumn({ column }: { column: Column }) { const Icon = iconMap[column.iconType]; return (
@@ -463,14 +520,14 @@ function SortableRunRow({ run }: { run: RunWithStatus }) { ); } -const allRepos = [...new Set(staticColumns.flatMap((col) => col.items.map((item) => item.repo)))].sort(); - -export default function Runs() { +export default function Runs({ loaderData }: Route.ComponentProps) { + const initialColumns = loaderData.columns; + const allRepos = [...new Set(initialColumns.flatMap((col: Column) => col.items.map((item: RunItem) => item.repo)))].sort(); const [query, setQuery] = useState(""); const [repoFilter, setRepoFilter] = useState("all"); const [view, setView] = useState("columns"); const [collapsed, setCollapsed] = useState>(new Set()); - const [columns, setColumns] = useState(staticColumns); + const [columns, setColumns] = useState(initialColumns); const lowerQuery = query.toLowerCase(); const sensors = useSensors( diff --git a/apps/arc-web/app/routes/session-detail.tsx b/apps/arc-web/app/routes/session-detail.tsx index 0673734ae..db51fc973 100644 --- a/apps/arc-web/app/routes/session-detail.tsx +++ b/apps/arc-web/app/routes/session-detail.tsx @@ -9,6 +9,8 @@ import { UserIcon, WrenchScrewdriverIcon, } from "@heroicons/react/24/outline"; +import { apiJson } from "../api-client"; +import type { SessionDetail as ApiSessionDetail, SessionGroup } from "@qltysh/arc-api-client"; import type { Route } from "./+types/session-detail"; export const handle = { hideHeader: true, wide: true }; @@ -17,6 +19,43 @@ export function meta({}: Route.MetaArgs) { return [{ title: "Session — Arc" }]; } +export async function loader({ params }: Route.LoaderArgs) { + const [apiSession, apiGroups] = await Promise.all([ + apiJson(`/sessions/${params.sessionId}`), + apiJson("/sessions"), + ]); + const session: Session = { + id: apiSession.id, + title: apiSession.title, + repo: apiSession.repo, + model: apiSession.model, + time: "", + turns: apiSession.turns.map((t) => { + if (t.kind === "tool" && t.tools) { + return { + kind: "tool" as const, + tools: t.tools.map((tu) => ({ + toolName: tu.tool_name, + args: tu.args, + result: tu.result, + })), + }; + } + return { kind: t.kind as "user" | "assistant", content: t.content ?? "", date: t.date }; + }), + }; + const sessionGroups = apiGroups.map((g) => ({ + label: g.label, + sessions: g.sessions.map((s) => ({ + id: s.id, + title: s.title, + repo: s.repo, + time: s.time, + })), + })); + return { session, sessionGroups }; +} + interface ToolUse { toolName: string; args: string; @@ -37,6 +76,7 @@ interface Session { turns: Turn[]; } +// Keep hardcoded sessions as fallback const sessions: Record = { s1: { id: "s1", @@ -191,7 +231,7 @@ function makeFallbackSession(id: string): Session { }; } -interface SessionGroup { +interface SessionGroupType { label: string; sessions: { id: string; title: string; repo: string; time: string }[]; } @@ -326,7 +366,7 @@ function AssistantBlock({ content, showCopy }: { content: string; showCopy: bool ); } -function SessionSidebar({ activeId }: { activeId: string }) { +function SessionSidebar({ activeId, groups }: { activeId: string; groups: SessionGroupType[] }) { return (