mirror of
https://github.com/fabro-sh/fabro.git
synced 2026-10-09 03:20:56 +00:00
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:
parent
d84e7a28ef
commit
9d59f4d8d0
5 changed files with 243 additions and 2 deletions
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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`, {
|
||||
|
|
|
|||
|
|
@ -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),
|
||||
],
|
||||
|
|
|
|||
207
apps/fabro-web/app/routes/run-artifacts.tsx
Normal file
207
apps/fabro-web/app/routes/run-artifacts.tsx
Normal 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;
|
||||
}
|
||||
Loading…
Add table
Reference in a new issue