File
Blob: src/client/pages/run-detail-page.tsx
| 1 | import type { LogEvent, RunDetail, RunWsStateMessage } from "@/contracts"; |
| 2 | import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; |
| 3 | import { Clock, GitBranch, GitCommitHorizontal, Terminal, Timer, XCircle, Zap } from "lucide-react"; |
| 4 | import { useMemo, useState } from "react"; |
| 5 | import { useParams } from "react-router-dom"; |
| 6 | import { LoadingPanel, LogViewer, StatusPill, StepRow } from "@/client/components"; |
| 7 | import { Breadcrumbs, Button, Card, ConfirmDialog, ErrorBanner } from "@/client/components/ui"; |
| 8 | import { useLogStream } from "@/client/hooks"; |
| 9 | import { |
| 10 | TRIGGER_TYPE_LABELS, |
| 11 | formatApiError, |
| 12 | formatDuration, |
| 13 | formatRunFailureMessage, |
| 14 | formatTimestamp, |
| 15 | getApiClient, |
| 16 | mergeLogEventBySeq, |
| 17 | queryKeys, |
| 18 | } from "@/client/lib"; |
| 19 | import { useToast } from "@/client/toast"; |
| 20 | |
| 21 | const TERMINAL_STATUSES = new Set(["passed", "failed", "canceled"]); |
| 22 | |
| 23 | interface RunDetailContentProps { |
| 24 | detail: RunDetail; |
| 25 | projectName: string | null; |
| 26 | runId: string; |
| 27 | } |
| 28 | |
| 29 | const isTerminalStatus = (status: string): boolean => TERMINAL_STATUSES.has(status); |
| 30 | |
| 31 | const mergeLogs = (currentLogs: LogEvent[], incomingLogs: LogEvent[]): LogEvent[] => |
| 32 | incomingLogs.reduce((merged, event) => mergeLogEventBySeq(merged, event), currentLogs); |
| 33 | |
| 34 | const 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 | |
| 201 | export 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 | }; |