Skip to content
File

Blob: src/client/pages/run-detail-page.tsx

typescript266 lines
1import type { LogEvent, RunDetail, RunWsStateMessage } from "@/contracts";
2import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
3import { Clock, GitBranch, GitCommitHorizontal, Terminal, Timer, XCircle, Zap } from "lucide-react";
4import { useMemo, useState } from "react";
5import { useParams } from "react-router-dom";
6import { LoadingPanel, LogViewer, StatusPill, StepRow } from "@/client/components";
7import { Breadcrumbs, Button, Card, ConfirmDialog, ErrorBanner } from "@/client/components/ui";
8import { useLogStream } from "@/client/hooks";
9import {
10 TRIGGER_TYPE_LABELS,
11 formatApiError,
12 formatDuration,
13 formatRunFailureMessage,
14 formatTimestamp,
15 getApiClient,
16 mergeLogEventBySeq,
17 queryKeys,
18} from "@/client/lib";
19import { useToast } from "@/client/toast";
20 
21const TERMINAL_STATUSES = new Set(["passed", "failed", "canceled"]);
22 
23interface RunDetailContentProps {
24 detail: RunDetail;
25 projectName: string | null;
26 runId: string;
27}
28 
29const isTerminalStatus = (status: string): boolean => TERMINAL_STATUSES.has(status);
30 
31const mergeLogs = (currentLogs: LogEvent[], incomingLogs: LogEvent[]): LogEvent[] =>
32 incomingLogs.reduce((merged, event) => mergeLogEventBySeq(merged, event), currentLogs);
33 
34const RunDetailContent = ({ detail, projectName, runId }: RunDetailContentProps) => {
35 const queryClient = useQueryClient();
36 const { pushToast } = useToast();
37 const [confirmCancelOpen, setConfirmCancelOpen] = useState(false);
38 const [streamLogs, setStreamLogs] = useState<LogEvent[]>(() => detail.recentLogs);
39 const { run, steps, currentStep, errorMessage, detailAvailable } = detail;
40 const isTerminal = isTerminalStatus(run.status);
41 
42 const cancelRunMutation = useMutation({
43 mutationFn: () => getApiClient().cancelRun(runId),
44 onSuccess: (result) => {
45 queryClient.setQueryData(queryKeys.runDetail(runId), result);
46 void queryClient.invalidateQueries({ queryKey: queryKeys.projectDetail(result.run.projectId) });
47 void queryClient.invalidateQueries({ queryKey: queryKeys.projectRuns(result.run.projectId) });
48 pushToast({ tone: "success", title: "Cancel requested", message: "Run cancellation has been requested." });
49 },
50 onError: (reason) => {
51 pushToast({ tone: "error", title: "Cancel failed", message: formatApiError(reason) });
52 },
53 });
54 
55 const handleLogEvent = (event: LogEvent) => {
56 setStreamLogs((current) => mergeLogEventBySeq(current, event));
57 };
58 
59 const handleStateUpdate = (message: RunWsStateMessage) => {
60 queryClient.setQueryData<RunDetail>(queryKeys.runDetail(runId), (current) => {
61 const source = current ?? detail;
62 return {
63 ...source,
64 run: {
65 ...source.run,
66 status: message.run.status,
67 startedAt: message.run.startedAt,
68 finishedAt: message.run.finishedAt,
69 exitCode: message.run.exitCode,
70 },
71 currentStep: message.run.currentStep,
72 errorMessage: message.run.errorMessage,
73 steps: message.steps,
74 detailAvailable: true,
75 };
76 });
77 
78 if (isTerminalStatus(message.run.status)) {
79 void queryClient.invalidateQueries({ queryKey: queryKeys.runDetail(runId) });
80 }
81 };
82 
83 const logStreamStatus = useLogStream({
84 runId,
85 enabled: !isTerminal,
86 onEvent: handleLogEvent,
87 onStateUpdate: handleStateUpdate,
88 });
89 
90 const displayedLogs = useMemo(
91 () => (isTerminal ? mergeLogs(streamLogs, detail.recentLogs) : streamLogs),
92 [detail.recentLogs, isTerminal, streamLogs],
93 );
94 
95 const pageTitle = `Run ${runId.slice(0, 12)}`;
96 const commitDisplay = run.commitSha ? run.commitSha.slice(0, 7) : "\u2014";
97 const canCancel = !isTerminalStatus(run.status);
98 const isCancelPending = run.status === "cancel_requested" || run.status === "canceling";
99 const canceling = cancelRunMutation.isPending;
100 
101 return (
102 <div className="animate-slide-up space-y-5">
103 <h1 className="sr-only">{pageTitle}</h1>
104 <div className="flex flex-wrap items-center justify-between gap-x-6 gap-y-4 pb-1">
105 <Breadcrumbs
106 items={[
107 { label: "Projects", href: "/app/projects" },
108 { label: projectName ?? run.projectId.slice(0, 8), href: `/app/projects/${run.projectId}` },
109 { label: `Run ${run.id.slice(0, 12)}` },
110 ]}
111 />
112
113 <div className="flex flex-wrap items-center gap-x-4 gap-y-1 text-sm text-zinc-400">
114 <StatusPill status={run.status} />
115 <span className="inline-flex items-center gap-1">
116 <GitBranch className="h-3.5 w-3.5 text-zinc-500" />
117 {run.branch}
118 </span>
119 <span className="hidden items-center gap-1 font-mono text-xs sm:inline-flex">
120 <GitCommitHorizontal className="h-3.5 w-3.5 text-zinc-500" />
121 {commitDisplay}
122 </span>
123 <span className="hidden items-center gap-1 sm:inline-flex">
124 <Zap className="h-3.5 w-3.5 text-zinc-500" />
125 {TRIGGER_TYPE_LABELS[run.triggerType] ?? run.triggerType}
126 </span>
127 {run.startedAt && run.finishedAt ? (
128 <span className="inline-flex items-center gap-1 text-zinc-500">
129 <Timer className="h-3.5 w-3.5" />
130 {formatDuration(run.startedAt, run.finishedAt)}
131 </span>
132 ) : null}
133 <span
134 className="hidden items-center gap-1 text-zinc-500 sm:inline-flex"
135 title={`Queued ${formatTimestamp(run.queuedAt)}`}
136 >
137 <Clock className="h-3.5 w-3.5" />
138 Queued {formatTimestamp(run.queuedAt)}
139 </span>
140 </div>
141 </div>
142
143 {!detailAvailable ? (
144 <div className="rounded-2xl border border-accent-500/20 bg-accent-500/10 p-4 text-sm text-accent-400">
145 Detailed run data is no longer available.
146 </div>
147 ) : null}
148
149 {errorMessage ? <ErrorBanner message={formatRunFailureMessage(errorMessage)} /> : null}
150
151 <div className="grid gap-5 lg:grid-cols-[minmax(230px,400px)_minmax(0,1fr)]">
152 <div className="space-y-4">
153 {steps.length > 0 ? (
154 <Card>
155 <h2 className="mb-4 flex items-center gap-2 font-display text-sm font-semibold text-zinc-100">
156 <Terminal className="h-4 w-4 text-zinc-400" />
157 Steps
158 </h2>
159 <div className="space-y-2">
160 {steps.map((step) => (
161 <StepRow key={step.id} step={step} isActive={step.position === currentStep} />
162 ))}
163 </div>
164 </Card>
165 ) : null}
166
167 {canCancel ? (
168 <>
169 <Button
170 variant="danger"
171 className="w-full"
172 disabled={canceling || isCancelPending}
173 loading={canceling || isCancelPending}
174 icon={!(canceling || isCancelPending) ? <XCircle className="h-4 w-4" /> : undefined}
175 onClick={() => setConfirmCancelOpen(true)}
176 >
177 Cancel Run
178 </Button>
179 <ConfirmDialog
180 open={confirmCancelOpen}
181 onConfirm={() => {
182 setConfirmCancelOpen(false);
183 cancelRunMutation.mutate();
184 }}
185 onCancel={() => setConfirmCancelOpen(false)}
186 title="Cancel this run?"
187 description="This run will be cancelled. This action cannot be undone."
188 confirmLabel="Cancel Run"
189 variant="danger"
190 />
191 </>
192 ) : null}
193 </div>
194
195 <LogViewer logs={displayedLogs} logStreamStatus={logStreamStatus} />
196 </div>
197 </div>
198 );
199};
200 
201export const RunDetailPage = () => {
202 const { runId } = useParams<{ runId: string }>();
203 const resolvedRunId = runId ?? "";
204 
205 const runQuery = useQuery({
206 queryKey: queryKeys.runDetail(resolvedRunId),
207 queryFn: () => getApiClient().getRunDetail(resolvedRunId),
208 enabled: resolvedRunId.length > 0,
209 refetchInterval: (query) => {
210 const detail = query.state.data;
211 return detail !== undefined && !isTerminalStatus(detail.run.status) ? 30_000 : false;
212 },
213 });
214 
215 const projectId = runQuery.data?.run.projectId ?? "";
216 const projectNameQuery = useQuery({
217 queryKey: queryKeys.projectDetail(projectId),
218 queryFn: () => getApiClient().getProjectDetail(projectId),
219 enabled: projectId.length > 0,
220 staleTime: 60_000,
221 });
222 
223 const pageTitle = `Run ${runId?.slice(0, 12) ?? ""}`;
224 
225 if (!runId) {
226 return (
227 <div className="space-y-4">
228 <Breadcrumbs items={[{ label: "Projects", href: "/app/projects" }, { label: "Error" }]} />
229 <ErrorBanner message="Missing run id." />
230 </div>
231 );
232 }
233 
234 if (runQuery.isPending) {
235 return (
236 <>
237 <h1 className="sr-only">{pageTitle}</h1>
238 <LoadingPanel label="Loading run..." />
239 </>
240 );
241 }
242 
243 if (runQuery.isError && !runQuery.data) {
244 return (
245 <div className="space-y-4">
246 <Breadcrumbs items={[{ label: "Projects", href: "/app/projects" }, { label: "Error" }]} />
247 <ErrorBanner message={formatApiError(runQuery.error)} />
248 </div>
249 );
250 }
251 
252 if (!runQuery.data) return null;
253 
254 return (
255 <>
256 {runQuery.isError ? <ErrorBanner message={formatApiError(runQuery.error)} /> : null}
257 <RunDetailContent
258 key={runId}
259 detail={runQuery.data}
260 projectName={projectNameQuery.data?.project.name ?? null}
261 runId={runId}
262 />
263 </>
264 );
265};