mirror of
https://github.com/fabro-sh/fabro.git
synced 2026-10-08 03:10:26 +00:00
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:
parent
3a6ef48e56
commit
4e9d3774ef
8 changed files with 274 additions and 170 deletions
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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`, {
|
||||
|
|
|
|||
|
|
@ -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),
|
||||
],
|
||||
|
|
|
|||
80
apps/fabro-web/app/routes/run-logs.tsx
Normal file
80
apps/fabro-web/app/routes/run-logs.tsx
Normal 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.";
|
||||
}
|
||||
2
lib/crates/fabro-spa/assets/assets/app.css
generated
2
lib/crates/fabro-spa/assets/assets/app.css
generated
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
2
lib/crates/fabro-spa/assets/index.html
generated
2
lib/crates/fabro-spa/assets/index.html
generated
|
|
@ -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>
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue