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.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)}
+
+
+
+ Download
+
+
+ );
+}
+
+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;
+}