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) <noreply@anthropic.com>
This commit is contained in:
Bryan Helmkamp 2026-05-08 06:37:07 -07:00
parent d84e7a28ef
commit 9d59f4d8d0
No known key found for this signature in database
5 changed files with 243 additions and 2 deletions

View file

@ -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
</Link>
</li>
<li>
<Link
to={`/runs/${runId}/artifacts`}
className={`${linkBase} ${
activeLink === "artifacts"
? "bg-overlay text-fg"
: "text-fg-3 hover:bg-overlay hover:text-fg"
}`}
>
<PaperClipIcon className="size-4 shrink-0 text-fg-muted" />
Artifacts
</Link>
</li>
</ul>
</div>
</nav>

View file

@ -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<RunArtifactListResponse | null>(
id ? queryKeys.runs.artifacts(id) : null,
apiNullableFetcher,
);
}
export function useRunSettings<T = WorkflowSettings>(id: string | undefined) {
return useSWR<T>(
id ? queryKeys.runs.settings(id) : null,

View file

@ -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`, {

View file

@ -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),
],

View file

@ -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 (
<div className="flex gap-6">
<StageSidebar stages={stages} runId={id!} activeLink="artifacts" />
<div className="min-w-0 flex-1">{renderBody(id!, artifactsQuery, stages)}</div>
</div>
);
}
function renderBody(
runId: string,
artifactsQuery: ReturnType<typeof useRunArtifacts>,
stages: ReturnType<typeof mapRunStagesToSidebarStages>,
) {
if (artifactsQuery.error) {
return (
<ErrorState
title="Couldn't load artifacts"
description={errorMessage(artifactsQuery.error)}
onRetry={() => void artifactsQuery.mutate()}
/>
);
}
if (artifactsQuery.data === undefined) {
return <LoadingState label="Loading artifacts…" />;
}
const entries = artifactsQuery.data?.data ?? [];
if (entries.length === 0) {
return (
<EmptyState
icon={PaperClipIcon}
title="No artifacts captured"
description="No stage in this run produced any artifacts."
/>
);
}
return <ArtifactList runId={runId} entries={entries} stages={stages} />;
}
interface StageGroup {
key: string;
stageId: string;
retry: number;
label: string;
entries: RunArtifactEntry[];
totalBytes: number;
}
function groupArtifacts(
entries: readonly RunArtifactEntry[],
stages: ReturnType<typeof mapRunStagesToSidebarStages>,
): StageGroup[] {
const stageLabels = new Map<string, string>();
for (const stage of stages) {
stageLabels.set(stage.id, formatStageLabel(stage));
}
const groups = new Map<string, StageGroup>();
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<typeof mapRunStagesToSidebarStages>;
}) {
const groups = useMemo(() => groupArtifacts(entries, stages), [entries, stages]);
const totalBytes = useMemo(
() => entries.reduce((sum, entry) => sum + entry.size, 0),
[entries],
);
return (
<div className="space-y-4">
<div className="flex items-baseline justify-between">
<h2 className="text-sm font-medium text-fg">
{entries.length} {entries.length === 1 ? "artifact" : "artifacts"}
</h2>
<span className="text-xs tabular-nums text-fg-muted">
{formatBytes(totalBytes)} total
</span>
</div>
{groups.map((group) => (
<StageGroupCard key={group.key} runId={runId} group={group} />
))}
</div>
);
}
function StageGroupCard({ runId, group }: { runId: string; group: StageGroup }) {
return (
<section className="overflow-hidden rounded-md border border-line bg-panel-alt">
<header className="flex items-baseline justify-between border-b border-line px-4 py-2.5">
<div className="flex items-baseline gap-2">
<h3 className="text-sm font-medium text-fg">{group.label}</h3>
{group.retry > 0 && (
<span className="rounded bg-overlay px-1.5 py-0.5 text-[11px] font-medium text-fg-3">
retry {group.retry}
</span>
)}
</div>
<span className="text-xs tabular-nums text-fg-muted">
{group.entries.length} {group.entries.length === 1 ? "file" : "files"}
{" · "}
{formatBytes(group.totalBytes)}
</span>
</header>
<ul className="divide-y divide-line">
{group.entries.map((entry) => (
<ArtifactRow
key={`${group.key}#${entry.relative_path}`}
runId={runId}
entry={entry}
/>
))}
</ul>
</section>
);
}
function ArtifactRow({ runId, entry }: { runId: string; entry: RunArtifactEntry }) {
const href = apiPath(
queryKeys.runs.artifactDownload(runId, entry.stage_id, entry.relative_path, entry.retry),
);
return (
<li className="flex items-center gap-4 px-4 py-2">
<span
className="flex-1 truncate font-mono text-xs text-fg-2"
title={entry.relative_path}
>
{entry.relative_path}
</span>
<span className="shrink-0 tabular-nums text-xs text-fg-muted">
{formatBytes(entry.size)}
</span>
<a
href={href}
download={basename(entry.relative_path)}
className="inline-flex shrink-0 items-center gap-1 rounded-md px-2 py-1 text-xs text-fg-3 transition-colors hover:bg-overlay hover:text-fg focus-visible:outline-2 focus-visible:-outline-offset-1 focus-visible:outline-teal-500"
>
<ArrowDownTrayIcon className="size-3.5" aria-hidden="true" />
Download
</a>
</li>
);
}
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;
}