fabro/apps/fabro-web/app/routes/run-artifacts.tsx
Bryan Helmkamp daaca3f479
refactor: simplify artifact ZIP download
Path safety now lives in one place. The NUL-byte and drive-letter rules
move from a server-only helper into the store's own filename validation,
so uploads reject those paths at write time instead of only the ZIP read
path catching them. The download still re-checks, because artifacts
stored before the rule existed can still carry an unsafe path, but it
now skips a bad path rather than failing the whole archive.

Promote is_boundary_stage to RunProjection and drop the three identical
private copies. The ZIP download used a node-name match instead, which
would have dropped artifacts from a working node that happened to be
named "start".

Compress the archive. Entries were Stored while the response was also
excluded from transfer compression, so text artifacts moved at full
size. async_zip gains the deflate feature; async-compression and flate2
were already in the lock file.

Log archive failures unconditionally. The send-succeeded guard meant a
client that had already disconnected left no record at all, which is the
case where the log is the only evidence.

Also: collapse the duplicate 500 arms, drop the dead stage-ID tiebreaker
and the cached order in the selection map, name the accessible label
after the visible one, share the run URL prefix between the two download
href builders, and document the mid-stream truncation behavior in the
OpenAPI description.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-01 09:09:54 -04:00

306 lines
10 KiB
TypeScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

import { useMemo } from "react";
import { useParams } from "react-router";
import { Disclosure, DisclosureButton, DisclosurePanel } from "@headlessui/react";
import { ArrowDownTrayIcon, ChevronRightIcon, 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 {
runArtifactsDownloadUrl,
stageArtifactDownloadUrl,
} from "../lib/api-client";
import { formatBytes } from "../lib/format";
import { plural } from "../lib/plural";
import { useRunArtifacts, useRunStages } from "../lib/queries";
import { mapRunStagesToSidebarStages } from "../lib/stage-sidebar";
import type { ArtifactFile, ArtifactVersion } from "./run-artifacts/group";
import { groupArtifactsByFile } from "./run-artifacts/group";
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">
<RunArtifactsBody
runId={id!}
artifactsQuery={artifactsQuery}
stagesQuery={stagesQuery}
stages={stages}
/>
</div>
</div>
);
}
function RunArtifactsBody({
runId,
artifactsQuery,
stagesQuery,
stages,
}: {
runId: string;
artifactsQuery: ReturnType<typeof useRunArtifacts>;
stagesQuery: ReturnType<typeof useRunStages>;
stages: ReturnType<typeof mapRunStagesToSidebarStages>;
}) {
const error = artifactsQuery.error ?? stagesQuery.error;
if (error) {
return (
<ErrorState
title="Couldn't load artifacts"
description={errorMessage(error)}
onRetry={() => {
if (artifactsQuery.error) void artifactsQuery.mutate();
if (stagesQuery.error) void stagesQuery.mutate();
}}
/>
);
}
if (artifactsQuery.data === undefined || stagesQuery.data === undefined) {
return <LoadingState label="Loading artifacts…" />;
}
return (
<ArtifactFiles
runId={runId}
entries={artifactsQuery.data?.data ?? []}
stages={stages}
/>
);
}
function ArtifactFiles({
runId,
entries,
stages,
}: {
runId: string;
entries: readonly RunArtifactEntry[];
stages: ReturnType<typeof mapRunStagesToSidebarStages>;
}) {
const files = useMemo(() => groupArtifactsByFile(entries, stages), [entries, stages]);
if (files.length === 0) {
return (
<EmptyState
icon={PaperClipIcon}
title="No artifacts captured"
description="No stage in this run produced any artifacts."
/>
);
}
return <ArtifactList runId={runId} files={files} />;
}
function ArtifactList({ runId, files }: { runId: string; files: readonly ArtifactFile[] }) {
const { captures, latestBytes, storedBytes } = useMemo(() => {
let captures = 0;
let latestBytes = 0;
let storedBytes = 0;
for (const file of files) {
captures += file.versions.length;
latestBytes += file.versions[0].size;
for (const version of file.versions) storedBytes += version.size;
}
return { captures, latestBytes, storedBytes };
}, [files]);
// Only mention versions once some file actually has more than one.
const versioned = captures > files.length;
return (
<div className="space-y-4">
<div className="flex flex-wrap items-center justify-between gap-3">
<h2 className="text-sm font-medium text-fg">
{files.length} {plural(files.length, "file", "files")}
{versioned && (
<span className="font-normal text-fg-muted">
{" "}
· {captures} {plural(captures, "version", "versions")}
</span>
)}
</h2>
<div className="ml-auto flex max-w-full flex-wrap items-center justify-end gap-3">
<span className="text-xs text-fg-muted tabular-nums">
{versioned
? `${formatBytes(latestBytes)} latest · ${formatBytes(storedBytes)} stored`
: `${formatBytes(latestBytes)} total`}
</span>
<a
href={runArtifactsDownloadUrl(runId)}
aria-label={`Download all ${files.length} ${plural(files.length, "artifact", "artifacts")} as a ZIP file`}
className="inline-flex min-h-11 shrink-0 items-center gap-1.5 rounded-md bg-overlay px-2.5 py-1 text-xs font-medium text-fg-2 outline-1 -outline-offset-1 outline-line-strong hover:bg-overlay-strong hover:text-fg focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-teal-500 sm:min-h-8"
>
<ArrowDownTrayIcon className="size-3.5 shrink-0" aria-hidden="true" />
Download all
</a>
</div>
</div>
<section className="overflow-hidden rounded-md border border-line bg-panel-alt">
{files.map((file) => (
<ArtifactFileRow key={file.path} runId={runId} file={file} />
))}
</section>
</div>
);
}
function ArtifactFileRow({ runId, file }: { runId: string; file: ArtifactFile }) {
const hasEarlier = file.versions.length > 1;
const latest = file.versions[0];
return (
<Disclosure as="div" className="border-t border-line first:border-t-0">
{({ open }) => (
<>
<div className="flex items-center gap-2 px-3 py-2.5 sm:gap-4 sm:px-4">
{hasEarlier ? (
<DisclosureButton className="group shrink-0 rounded-md p-1 text-fg-3 transition-colors hover:bg-overlay hover:text-fg-2 focus-visible:outline-2 focus-visible:-outline-offset-1 focus-visible:outline-teal-500">
<span className="sr-only">
{open ? "Hide" : "Show"} earlier versions of {file.name}
</span>
<ChevronRightIcon
className="size-3.5 transition-transform group-data-open:rotate-90"
aria-hidden="true"
/>
</DisclosureButton>
) : (
<span className="size-5 shrink-0" aria-hidden="true" />
)}
<span className="min-w-0 flex-1" title={file.path}>
<span className="block truncate font-mono text-xs">
<span className="text-fg-muted">{file.dir}</span>
<span className="text-fg-2">{file.name}</span>
</span>
<span className="mt-0.5 block truncate text-[11px] text-fg-3 md:hidden">
<VersionLabel version={latest} />
</span>
</span>
{hasEarlier && (
<span className="hidden shrink-0 rounded-full bg-overlay-strong px-2 py-0.5 text-[11px] text-fg-3 lg:inline">
{file.versions.length}{" "}
{plural(file.versions.length, "version", "versions")}
</span>
)}
<span className="hidden max-w-48 shrink-0 truncate text-xs text-fg-3 md:inline">
<VersionLabel version={latest} />
</span>
<span className="shrink-0 text-xs text-fg-muted tabular-nums">
{formatBytes(latest.size)}
</span>
<DownloadLink runId={runId} file={file} version={latest} />
</div>
{hasEarlier && (
<DisclosurePanel
as="ul"
className="border-t border-line bg-black/15 py-1"
>
<EarlierVersions runId={runId} file={file} />
</DisclosurePanel>
)}
</>
)}
</Disclosure>
);
}
function EarlierVersions({ runId, file }: { runId: string; file: ArtifactFile }) {
return (
<>
{file.versions.map((version, index) =>
index === 0 ? null : (
<li
key={`${version.stageId}#${version.retry}`}
className="flex items-center gap-2 py-1.5 pr-3 pl-10 hover:bg-overlay sm:gap-4 sm:pr-4 sm:pl-14"
>
<span className="min-w-0 flex-1 truncate text-xs text-fg-3">
<VersionLabel version={version} />
</span>
<span className="shrink-0 text-xs text-fg-muted tabular-nums">
{formatBytes(version.size)}
</span>
<SizeDelta delta={version.delta} />
<DownloadLink runId={runId} file={file} version={version} />
</li>
),
)}
</>
);
}
function VersionLabel({ version }: { version: ArtifactVersion }) {
const attempt = attemptLabel(version);
return (
<>
{version.stageLabel}
{attempt && <span className="ml-2 text-fg-muted">{attempt}</span>}
</>
);
}
function attemptLabel(version: ArtifactVersion): string | null {
return version.retry > 1 ? `attempt ${version.retry}` : null;
}
function SizeDelta({ delta }: { delta: number | null }) {
if (delta === null) {
return <span className="shrink-0 text-[11px] text-fg-muted tabular-nums">first</span>;
}
const tone = delta < 0 ? "text-amber" : "text-mint";
const sign = delta < 0 ? "−" : "+";
return (
<span className={`shrink-0 text-[11px] ${tone} tabular-nums`}>
{sign}
{formatBytes(Math.abs(delta))}
</span>
);
}
function DownloadLink({
runId,
file,
version,
}: {
runId: string;
file: ArtifactFile;
version: ArtifactVersion;
}) {
const href = stageArtifactDownloadUrl(
runId,
version.stageId,
file.path,
version.retry,
);
const attempt = attemptLabel(version);
const source = attempt ? `${version.stageLabel}, ${attempt}` : version.stageLabel;
return (
<a
href={href}
download={file.name}
aria-label={`Download ${file.name} from ${source}`}
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" />
<span className="hidden sm:inline">Download</span>
</a>
);
}
function errorMessage(error: unknown): string | undefined {
return error instanceof Error ? error.message : undefined;
}