feat(web): add run logs view

Add a "Run Logs" entry to the run detail sidebar that fetches the
worker tracing log via GET /api/v1/runs/{id}/logs and renders it with
auto-refresh while the run is live. Refreshes the embedded SPA bundle.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Bryan Helmkamp 2026-04-26 17:38:22 -04:00
parent 3a6ef48e56
commit 4e9d3774ef
No known key found for this signature in database
8 changed files with 274 additions and 170 deletions

View file

@ -7,7 +7,7 @@ import {
PauseCircleIcon,
XCircleIcon,
} from "@heroicons/react/24/solid";
import { DocumentTextIcon, MapIcon } from "@heroicons/react/24/outline";
import { Bars3BottomLeftIcon, DocumentTextIcon, MapIcon } from "@heroicons/react/24/outline";
import { formatDurationSecs } from "../lib/format";
export type StageStatus = "completed" | "running" | "pending" | "failed" | "cancelled";
@ -32,7 +32,7 @@ interface StageSidebarProps {
stages: Stage[];
runId: string;
selectedStageId?: string;
activeLink?: "settings" | "graph";
activeLink?: "settings" | "graph" | "logs";
}
export function StageSidebar({ stages, runId, selectedStageId, activeLink }: StageSidebarProps) {
@ -135,6 +135,19 @@ export function StageSidebar({ stages, runId, selectedStageId, activeLink }: Sta
Workflow Graph
</Link>
</li>
<li>
<Link
to={`/runs/${runId}/logs`}
className={`${linkBase} ${
activeLink === "logs"
? "bg-overlay text-fg"
: "text-fg-3 hover:bg-overlay hover:text-fg"
}`}
>
<Bars3BottomLeftIcon className="size-4 shrink-0 text-fg-muted" />
Run Logs
</Link>
</li>
</ul>
</div>
</nav>

View file

@ -91,6 +91,14 @@ export function useRunGraph(id: string | undefined, direction?: "LR" | "TB") {
);
}
export function useRunLogs(id: string | undefined, refreshInterval?: number) {
return useSWR<string | null>(
id ? queryKeys.runs.logs(id) : null,
apiNullableTextFetcher,
refreshInterval ? { refreshInterval } : undefined,
);
}
export function useRunSettings<T = Record<string, unknown>>(id: string | undefined) {
return useSWR<T>(
id ? queryKeys.runs.settings(id) : null,

View file

@ -33,6 +33,7 @@ export const queryKeys = {
graph: (id: string, direction?: "LR" | "TB") =>
withQuery(`/api/v1/runs/${pathSegment(id)}/graph`, { direction }),
settings: (id: string) => `/api/v1/runs/${pathSegment(id)}/settings`,
logs: (id: string) => `/api/v1/runs/${pathSegment(id)}/logs`,
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

@ -17,6 +17,7 @@ import * as RunOverview from "./routes/run-overview";
import * as RunStages from "./routes/run-stages";
import * as RunSettings from "./routes/run-settings";
import * as RunGraph from "./routes/run-graph";
import * as RunLogs from "./routes/run-logs";
import * as RunFiles from "./routes/run-files";
import * as RunBilling from "./routes/run-billing";
import * as Insights from "./routes/insights";
@ -92,6 +93,7 @@ export const routes: RouteObject[] = [
route("stages/:stageId", RunStages),
route("settings", RunSettings),
route("graph", RunGraph),
route("logs", RunLogs),
route("files", RunFiles),
route("billing", RunBilling),
],

View file

@ -0,0 +1,80 @@
import { useMemo } from "react";
import { useParams } from "react-router";
import { EmptyState, ErrorState, LoadingState } from "../components/state";
import { StageSidebar } from "../components/stage-sidebar";
import { CopyButton } from "../components/ui";
import { useRun, useRunLogs, useRunStages } from "../lib/queries";
import { mapRunStagesToSidebarStages } from "../lib/stage-sidebar";
export const handle = { wide: true };
const LIVE_REFRESH_MS = 5000;
export default function RunLogs() {
const { id } = useParams();
const runQuery = useRun(id);
const stagesQuery = useRunStages(id);
const isLive = runQuery.data?.status?.kind === "running";
const logsQuery = useRunLogs(id, isLive ? LIVE_REFRESH_MS : undefined);
const stages = useMemo(
() => mapRunStagesToSidebarStages(stagesQuery.data),
[stagesQuery.data],
);
return (
<div className="flex gap-6">
<StageSidebar stages={stages} runId={id!} activeLink="logs" />
<div className="min-w-0 flex-1">{renderBody(logsQuery)}</div>
</div>
);
}
function renderBody(logsQuery: ReturnType<typeof useRunLogs>) {
if (logsQuery.error) {
return (
<ErrorState
title="Couldn't load run log"
description={errorMessage(logsQuery.error)}
onRetry={() => logsQuery.mutate()}
/>
);
}
if (logsQuery.data === undefined) {
return <LoadingState label="Loading log…" />;
}
if (logsQuery.data === null) {
return (
<EmptyState
title="No run log yet"
description="The worker hasn't written any tracing output for this run."
/>
);
}
return <LogPanel text={logsQuery.data} />;
}
function LogPanel({ text }: { text: string }) {
const byteCount = useMemo(() => new Blob([text]).size, [text]);
return (
<div className="rounded-md border border-line bg-panel-alt">
<div className="flex items-center justify-between gap-3 border-b border-line px-3 py-2">
<span className="font-mono text-xs text-fg-muted">runtime/server.log</span>
<div className="flex items-center gap-3">
<span className="text-xs tabular-nums text-fg-muted">
{byteCount.toLocaleString()} bytes
</span>
<CopyButton value={text} label="Copy run log" />
</div>
</div>
<pre className="max-h-[70vh] overflow-auto whitespace-pre p-4 font-mono text-xs leading-5 text-fg-2">
{text}
</pre>
</div>
);
}
function errorMessage(error: unknown): string {
if (error instanceof Error) return error.message;
return "Please try again.";
}

File diff suppressed because one or more lines are too long

View file

@ -59,7 +59,7 @@
<script type="module" src="/assets/chunk-nkw6kj41.js"></script>
<script type="module" src="/assets/chunk-71s03bbh.js"></script>
<script type="module" src="/assets/chunk-ept66kdn.js"></script>
<script type="module" src="/assets/entry-xf46xn8z.js"></script>
<script type="module" src="/assets/entry-2fvwpvjj.js"></script>
<script type="module" src="/assets/chunk-dep0g6mr.js"></script>
<script type="module" src="/assets/chunk-c8zhk10v.js"></script>
<script type="module" src="/assets/chunk-n9tc6j4j.js"></script>