From 9d59f4d8d0fe50815b1e9e60de3eae56c85efd03 Mon Sep 17 00:00:00 2001 From: Bryan Helmkamp Date: Fri, 8 May 2026 06:37:07 -0700 Subject: [PATCH] feat(web): add Artifacts page to run sidebar Lists captured artifacts grouped by stage and retry, with per-file download links that stream from the existing artifact download endpoint. Co-Authored-By: Claude Opus 4.7 (1M context) --- .../app/components/stage-sidebar.tsx | 22 +- apps/fabro-web/app/lib/queries.ts | 8 + apps/fabro-web/app/lib/query-keys.ts | 6 + apps/fabro-web/app/router.tsx | 2 + apps/fabro-web/app/routes/run-artifacts.tsx | 207 ++++++++++++++++++ 5 files changed, 243 insertions(+), 2 deletions(-) create mode 100644 apps/fabro-web/app/routes/run-artifacts.tsx diff --git a/apps/fabro-web/app/components/stage-sidebar.tsx b/apps/fabro-web/app/components/stage-sidebar.tsx index d18f85a1d..a5f1be26c 100644 --- a/apps/fabro-web/app/components/stage-sidebar.tsx +++ b/apps/fabro-web/app/components/stage-sidebar.tsx @@ -9,7 +9,12 @@ import { PauseCircleIcon, XCircleIcon, } from "@heroicons/react/24/solid"; -import { Bars3BottomLeftIcon, DocumentTextIcon, MapIcon } from "@heroicons/react/24/outline"; +import { + Bars3BottomLeftIcon, + DocumentTextIcon, + MapIcon, + PaperClipIcon, +} from "@heroicons/react/24/outline"; import { formatDurationSecs } from "../lib/format"; import { ACTIVE_STAGE_STATES, formatStageLabel } from "../lib/stage-sidebar"; import { useTickingNow } from "../lib/time"; @@ -39,7 +44,7 @@ interface StageSidebarProps { stages: Stage[]; runId: string; selectedStageId?: string; - activeLink?: "settings" | "source" | "logs"; + activeLink?: "settings" | "source" | "logs" | "artifacts"; } export function StageSidebar({ stages, runId, selectedStageId, activeLink }: StageSidebarProps) { @@ -151,6 +156,19 @@ export function StageSidebar({ stages, runId, selectedStageId, activeLink }: Sta Run Logs +
  • + + + Artifacts + +
  • diff --git a/apps/fabro-web/app/lib/queries.ts b/apps/fabro-web/app/lib/queries.ts index a958d743a..70c97a91e 100644 --- a/apps/fabro-web/app/lib/queries.ts +++ b/apps/fabro-web/app/lib/queries.ts @@ -7,6 +7,7 @@ import type { PaginatedRunList, PaginatedRunStageList, CommandLogResponse, + RunArtifactListResponse, RunBilling, RunProjection, ServerSettings, @@ -120,6 +121,13 @@ export function useRunLogs(id: string | undefined, refreshInterval?: number) { ); } +export function useRunArtifacts(id: string | undefined) { + return useSWR( + id ? queryKeys.runs.artifacts(id) : null, + apiNullableFetcher, + ); +} + export function useRunSettings(id: string | undefined) { return useSWR( id ? queryKeys.runs.settings(id) : null, diff --git a/apps/fabro-web/app/lib/query-keys.ts b/apps/fabro-web/app/lib/query-keys.ts index 2fa6669f1..9c6ebabe3 100644 --- a/apps/fabro-web/app/lib/query-keys.ts +++ b/apps/fabro-web/app/lib/query-keys.ts @@ -39,6 +39,12 @@ export const queryKeys = { graphSource: (id: string) => `/api/v1/runs/${pathSegment(id)}/graph/source`, settings: (id: string) => `/api/v1/runs/${pathSegment(id)}/settings`, logs: (id: string) => `/api/v1/runs/${pathSegment(id)}/logs`, + artifacts: (id: string) => `/api/v1/runs/${pathSegment(id)}/artifacts`, + artifactDownload: (id: string, stageId: string, filename: string, retry: number) => + withQuery( + `/api/v1/runs/${pathSegment(id)}/stages/${pathSegment(stageId)}/artifacts/download`, + { filename, retry }, + ), billing: (id: string) => `/api/v1/runs/${pathSegment(id)}/billing`, questions: (id: string, limit = 1, offset = 0) => withQuery(`/api/v1/runs/${pathSegment(id)}/questions`, { diff --git a/apps/fabro-web/app/router.tsx b/apps/fabro-web/app/router.tsx index 5d2c2e653..eaaea8979 100644 --- a/apps/fabro-web/app/router.tsx +++ b/apps/fabro-web/app/router.tsx @@ -18,6 +18,7 @@ import * as RunStages from "./routes/run-stages"; import * as RunSettings from "./routes/run-settings"; import * as RunSource from "./routes/run-source"; import * as RunLogs from "./routes/run-logs"; +import * as RunArtifacts from "./routes/run-artifacts"; import * as RunFiles from "./routes/run-files"; import * as RunBilling from "./routes/run-billing"; import * as Insights from "./routes/insights"; @@ -94,6 +95,7 @@ export const routes: RouteObject[] = [ route("settings", RunSettings), route("source", RunSource), route("logs", RunLogs), + route("artifacts", RunArtifacts), route("files", RunFiles), route("billing", RunBilling), ], diff --git a/apps/fabro-web/app/routes/run-artifacts.tsx b/apps/fabro-web/app/routes/run-artifacts.tsx new file mode 100644 index 000000000..f84769178 --- /dev/null +++ b/apps/fabro-web/app/routes/run-artifacts.tsx @@ -0,0 +1,207 @@ +import { useMemo } from "react"; +import { useParams } from "react-router"; +import { ArrowDownTrayIcon, PaperClipIcon } from "@heroicons/react/24/outline"; +import type { RunArtifactEntry } from "@qltysh/fabro-api-client"; + +import { EmptyState, ErrorState, LoadingState } from "../components/state"; +import { StageSidebar } from "../components/stage-sidebar"; +import { formatBytes } from "../lib/format"; +import { apiPath } from "../lib/api-client"; +import { queryKeys } from "../lib/query-keys"; +import { useRunArtifacts, useRunStages } from "../lib/queries"; +import { formatStageLabel, mapRunStagesToSidebarStages } from "../lib/stage-sidebar"; + +export const handle = { wide: true }; + +export default function RunArtifacts() { + const { id } = useParams(); + const stagesQuery = useRunStages(id); + const artifactsQuery = useRunArtifacts(id); + const stages = useMemo( + () => mapRunStagesToSidebarStages(stagesQuery.data), + [stagesQuery.data], + ); + + return ( +
    + +
    {renderBody(id!, artifactsQuery, stages)}
    +
    + ); +} + +function renderBody( + runId: string, + artifactsQuery: ReturnType, + stages: ReturnType, +) { + if (artifactsQuery.error) { + return ( + void artifactsQuery.mutate()} + /> + ); + } + if (artifactsQuery.data === undefined) { + return ; + } + const entries = artifactsQuery.data?.data ?? []; + if (entries.length === 0) { + return ( + + ); + } + return ; +} + +interface StageGroup { + key: string; + stageId: string; + retry: number; + label: string; + entries: RunArtifactEntry[]; + totalBytes: number; +} + +function groupArtifacts( + entries: readonly RunArtifactEntry[], + stages: ReturnType, +): StageGroup[] { + const stageLabels = new Map(); + for (const stage of stages) { + stageLabels.set(stage.id, formatStageLabel(stage)); + } + + const groups = new Map(); + for (const entry of entries) { + const key = `${entry.stage_id}#${entry.retry}`; + const existing = groups.get(key); + if (existing) { + existing.entries.push(entry); + existing.totalBytes += entry.size; + } else { + groups.set(key, { + key, + stageId: entry.stage_id, + retry: entry.retry, + label: stageLabels.get(entry.stage_id) ?? entry.node_slug, + entries: [entry], + totalBytes: entry.size, + }); + } + } + + for (const group of groups.values()) { + group.entries.sort((a, b) => a.relative_path.localeCompare(b.relative_path)); + } + return [...groups.values()].sort((a, b) => { + const labelCmp = a.label.localeCompare(b.label); + return labelCmp !== 0 ? labelCmp : a.retry - b.retry; + }); +} + +function ArtifactList({ + runId, + entries, + stages, +}: { + runId: string; + entries: readonly RunArtifactEntry[]; + stages: ReturnType; +}) { + const groups = useMemo(() => groupArtifacts(entries, stages), [entries, stages]); + const totalBytes = useMemo( + () => entries.reduce((sum, entry) => sum + entry.size, 0), + [entries], + ); + + return ( +
    +
    +

    + {entries.length} {entries.length === 1 ? "artifact" : "artifacts"} +

    + + {formatBytes(totalBytes)} total + +
    + + {groups.map((group) => ( + + ))} +
    + ); +} + +function StageGroupCard({ runId, group }: { runId: string; group: StageGroup }) { + return ( +
    +
    +
    +

    {group.label}

    + {group.retry > 0 && ( + + retry {group.retry} + + )} +
    + + {group.entries.length} {group.entries.length === 1 ? "file" : "files"} + {" · "} + {formatBytes(group.totalBytes)} + +
    +
      + {group.entries.map((entry) => ( + + ))} +
    +
    + ); +} + +function ArtifactRow({ runId, entry }: { runId: string; entry: RunArtifactEntry }) { + const href = apiPath( + queryKeys.runs.artifactDownload(runId, entry.stage_id, entry.relative_path, entry.retry), + ); + return ( +
  • + + {entry.relative_path} + + + {formatBytes(entry.size)} + + + +
  • + ); +} + +function basename(path: string): string { + const idx = path.lastIndexOf("/"); + return idx >= 0 ? path.slice(idx + 1) : path; +} + +function errorMessage(error: unknown): string | undefined { + return error instanceof Error ? error.message : undefined; +}