21.6 KB624 lines
Blame
1"use client";
2
3import { useEffect, useRef, useState, useCallback } from "react";
4import { canopy, type PipelineRun, type PipelineStep, type StepLog } from "@/lib/api";
5import { useAuth } from "@/lib/auth";
6import { useParams } from "next/navigation";
7import { Badge } from "@/app/components/ui/badge";
8import { TimeAgo } from "@/app/components/time-ago";
9import { ElapsedTime } from "@/app/components/elapsed-time";
10import { LogViewer } from "@/app/components/log-viewer";
11import { useCanopyEvents, type CanopyEvent } from "@/lib/use-canopy-events";
12
13const statusIcon: Record<string, { color: string; label: string }> = {
14 pending: { color: "var(--text-faint)", label: "Pending" },
15 running: { color: "var(--status-merged-text)", label: "Running" },
16 passed: { color: "var(--status-open-text)", label: "Passed" },
17 failed: { color: "var(--status-closed-text)", label: "Failed" },
18 skipped: { color: "var(--text-faint)", label: "Skipped" },
19 cancelled: { color: "var(--text-faint)", label: "Cancelled" },
20};
21
22function formatDuration(ms: number | null): string {
23 if (!ms) return "-";
24 if (ms < 1000) return "<1s";
25 const s = Math.floor(ms / 1000);
26 if (s < 60) return `${s}s`;
27 const m = Math.floor(s / 60);
28 const rem = s % 60;
29 return `${m}m ${rem}s`;
30}
31
32function formatTrigger(trigger: PipelineRun["trigger_type"]): string {
33 return trigger
34 .split("_")
35 .map((part) => part.charAt(0).toUpperCase() + part.slice(1))
36 .join(" ");
37}
38
39function getActiveStepIndex(steps: PipelineStep[]): number | null {
40 const running = steps.find((step) => step.status === "running");
41 if (running) return running.step_index;
42 const pending = steps.find((step) => step.status === "pending");
43 if (pending) return pending.step_index;
44 return null;
45}
46
47interface PipelineRunDetailProps {
48 owner?: string;
49 repo?: string;
50 runId?: string;
51 embedded?: boolean;
52 renderLayout?: (stepsColumn: React.ReactNode, logsColumn: React.ReactNode, actions?: React.ReactNode) => React.ReactNode;
53}
54
55export function PipelineRunDetail(props: PipelineRunDetailProps) {
56 const routeParams = useParams<{ owner: string; repo: string; runId: string }>();
57 const owner = props.owner ?? routeParams.owner;
58 const repo = props.repo ?? routeParams.repo;
59 const runId = props.runId ?? routeParams.runId;
60 const embedded = props.embedded ?? false;
61 const { user } = useAuth();
62 const runNumericId = Number.parseInt(runId, 10);
63
64 const [run, setRun] = useState<PipelineRun | null>(null);
65 const [steps, setSteps] = useState<PipelineStep[]>([]);
66 const [expandedSteps, setExpandedSteps] = useState<Set<number>>(new Set());
67 const [stepLogs, setStepLogs] = useState<Record<number, StepLog[]>>({});
68 const [loading, setLoading] = useState(true);
69 const [cancelling, setCancelling] = useState(false);
70 const [groveOrigin, setGroveOrigin] = useState("");
71 const stepButtonRefs = useRef<Record<number, HTMLButtonElement | null>>({});
72 const stickToBottomRef = useRef<Record<number, boolean>>({});
73 const followedStepRef = useRef<number | null>(null);
74
75 useEffect(() => {
76 setLoading(true);
77 setRun(null);
78 setSteps([]);
79 setExpandedSteps(new Set());
80 setStepLogs({});
81 followedStepRef.current = null;
82 void loadRun();
83 }, [owner, repo, runId]);
84
85 useEffect(() => {
86 const { protocol, host } = window.location;
87 const groveHost = host.startsWith("canopy.") ? host.slice("canopy.".length) : host;
88 setGroveOrigin(`${protocol}//${groveHost}`);
89 }, []);
90
91 // Live updates via SSE
92 const isActive = run?.status === "running" || run?.status === "pending";
93 useCanopyEvents({
94 scope: "run",
95 runId: runNumericId,
96 enabled: isActive || !run,
97 onEvent: useCallback((event: CanopyEvent) => {
98 switch (event.type) {
99 case "run:started":
100 case "run:completed":
101 case "run:cancelled":
102 if (event.run) setRun(event.run as unknown as PipelineRun);
103 break;
104 case "step:started":
105 case "step:completed":
106 case "step:skipped":
107 if (event.step) {
108 setSteps((prev) =>
109 prev.map((s) =>
110 s.step_index === event.stepIndex
111 ? { ...s, ...(event.step as unknown as PipelineStep) }
112 : s
113 )
114 );
115 }
116 break;
117 case "log:append":
118 if (event.log && event.stepIndex != null) {
119 setStepLogs((prev) => ({
120 ...prev,
121 [event.stepIndex!]: [
122 ...(prev[event.stepIndex!] ?? []),
123 event.log as unknown as StepLog,
124 ],
125 }));
126 }
127 break;
128 }
129 }, []),
130 });
131
132 // Auto-follow currently active step, or auto-select on completed builds.
133 useEffect(() => {
134 if (steps.length === 0) return;
135 const activeStepIndex = getActiveStepIndex(steps);
136
137 // For finished builds, auto-select the failed step or last step.
138 const targetIndex = activeStepIndex
139 ?? steps.find((s) => s.status === "failed")?.step_index
140 ?? steps[steps.length - 1]?.step_index
141 ?? null;
142 if (targetIndex === null) return;
143
144 const isNewStep = followedStepRef.current !== targetIndex;
145 if (activeStepIndex !== null && isNewStep) {
146 stickToBottomRef.current[targetIndex] = true;
147 }
148 setExpandedSteps((prev) => {
149 if (prev.size === 1 && prev.has(targetIndex)) return prev;
150 return new Set([targetIndex]);
151 });
152 void loadStepLogs(targetIndex);
153
154 if (isNewStep) {
155 followedStepRef.current = targetIndex;
156 requestAnimationFrame(() => {
157 stepButtonRefs.current[targetIndex]?.scrollIntoView({
158 block: "nearest",
159 });
160 });
161 }
162 }, [steps, run?.status]);
163
164 // SSE handles live log streaming — no polling needed.
165
166 async function loadRun() {
167 try {
168 const data = await canopy.getRun(owner, repo, runNumericId);
169 setRun(data.run);
170 setSteps(data.steps);
171 } catch {}
172 setLoading(false);
173 }
174
175 async function loadStepLogs(stepIndex: number, force = false) {
176 if (!force && stepLogs[stepIndex]) return;
177 try {
178 const data = await canopy.getStepLogs(owner, repo, runNumericId, stepIndex);
179 setStepLogs((prev) => {
180 const existing = prev[stepIndex];
181 const next = data.logs;
182 if (
183 existing &&
184 existing.length === next.length &&
185 existing[existing.length - 1]?.created_at ===
186 next[next.length - 1]?.created_at &&
187 existing[existing.length - 1]?.content === next[next.length - 1]?.content
188 ) {
189 return prev;
190 }
191 return { ...prev, [stepIndex]: next };
192 });
193 } catch {}
194 }
195
196 async function toggleStep(stepIndex: number) {
197 if (expandedSteps.has(stepIndex)) return;
198
199 stickToBottomRef.current[stepIndex] = true;
200 setExpandedSteps(new Set([stepIndex]));
201 await loadStepLogs(stepIndex, true);
202 }
203
204 async function handleCancel() {
205 setCancelling(true);
206 try {
207 const data = await canopy.cancelRun(owner, repo, runNumericId);
208 setRun(data.run);
209 } catch {}
210 setCancelling(false);
211 }
212
213 // Update page title on status change (favicon is handled by CanopyNav)
214 useEffect(() => {
215 if (!run || embedded) return;
216 document.title = `Build #${run.id} · ${repo}`;
217 }, [run?.id, run?.status, owner, repo, embedded]);
218
219 if (loading) {
220 if (embedded) {
221 const skeletonSteps = (
222 <div className="flex-1 min-w-0 overflow-y-auto" style={{ borderRight: "1px solid var(--border-subtle)" }}>
223 <div className="px-3 py-1.5 text-xs" style={{ color: "var(--text-faint)", borderBottom: "1px solid var(--divide)" }}>
224 Steps
225 </div>
226 {Array.from({ length: 3 }).map((_, i) => (
227 <div key={i} className="w-full text-left px-3 py-2 text-sm" style={{ borderBottom: "1px solid var(--divide)" }}>
228 <div className="flex items-center gap-2">
229 <div className="skeleton" style={{ height: "1.25rem", width: "1.25rem", borderRadius: "2px" }} />
230 <div className="skeleton" style={{ height: "0.85rem", flex: 1 }} />
231 </div>
232 </div>
233 ))}
234 </div>
235 );
236 const skeletonLogs = (
237 <div className="flex-1 flex items-center justify-center">
238 <p className="text-sm" style={{ color: "var(--text-faint)" }}>Loading...</p>
239 </div>
240 );
241 if (props.renderLayout) {
242 return <>{props.renderLayout(skeletonSteps, skeletonLogs)}</>;
243 }
244 return (
245 <div className="flex" style={{ height: "100%" }} aria-busy="true" aria-live="polite">
246 {skeletonSteps}
247 {skeletonLogs}
248 </div>
249 );
250 }
251 return (
252 <div className="max-w-3xl mx-auto px-4 py-6" aria-busy="true" aria-live="polite">
253 <div
254 className="p-4 mb-4"
255 style={{
256 backgroundColor: "var(--bg-card)",
257 border: "1px solid var(--border-subtle)",
258 }}
259 >
260 <div className="skeleton mb-3" style={{ height: "1.5rem", width: "14rem" }} />
261 <div className="skeleton mb-2" style={{ height: "0.9rem", width: "78%" }} />
262
263 <div className="grid grid-cols-2 md:grid-cols-4 gap-3 text-xs mt-3">
264 {Array.from({ length: 4 }).map((_, i) => (
265 <div key={i}>
266 <div className="skeleton mb-1" style={{ height: "0.55rem", width: "2.8rem" }} />
267 <div className="skeleton" style={{ height: "0.75rem", width: i === 1 ? "4.5rem" : "5.5rem" }} />
268 </div>
269 ))}
270 </div>
271 </div>
272
273 <div style={{ border: "1px solid var(--border-subtle)" }}>
274 <div
275 className="px-3 py-2 text-xs"
276 style={{
277 color: "var(--text-muted)",
278 borderBottom: "1px solid var(--divide)",
279 backgroundColor: "var(--bg-card)",
280 }}
281 >
282 Build Steps
283 </div>
284 {Array.from({ length: 4 }).map((_, i) => (
285 <div
286 key={i}
287 style={{ borderTop: i > 0 ? "1px solid var(--divide)" : undefined }}
288 >
289 <div className="px-3 py-2.5">
290 <div className="flex items-center gap-2">
291 <div className="skeleton" style={{ height: "1.2rem", width: "4.6rem" }} />
292 <div className="skeleton" style={{ height: "0.95rem", flex: 1 }} />
293 <div className="skeleton" style={{ height: "0.8rem", width: "3rem" }} />
294 </div>
295 </div>
296 </div>
297 ))}
298 </div>
299 </div>
300 );
301 }
302
303 if (!run) {
304 return (
305 <div className={embedded ? "px-3 py-4" : "max-w-3xl mx-auto px-4 py-6"}>
306 <p className="text-sm" style={{ color: "var(--text-faint)" }}>
307 Build not found.
308 </p>
309 </div>
310 );
311 }
312 const activeStepIndex = getActiveStepIndex(steps);
313 const commitPath = run.commit_id ? `/${owner}/${repo}/commit/${run.commit_id}` : null;
314 const commitHref = commitPath ? `${groveOrigin}${commitPath}` : null;
315
316 if (embedded) {
317 const expandedStep = steps.find((s) => expandedSteps.has(s.step_index));
318 const expandedLogs = expandedStep ? stepLogs[expandedStep.step_index] : undefined;
319
320 const stepsColumn = (
321 <div
322 className="flex-1 min-w-0 overflow-y-auto"
323 style={{
324 borderRight: "1px solid var(--border-subtle)",
325 }}
326 >
327 <div className="px-3 py-1.5 text-xs" style={{ color: "var(--text-faint)", borderBottom: "1px solid var(--divide)" }}>
328 Steps
329 </div>
330 {steps.map((step, i) => {
331 const ss = statusIcon[step.status] ?? statusIcon.pending;
332 const expanded = expandedSteps.has(step.step_index);
333 const isActive = activeStepIndex === step.step_index;
334
335 return (
336 <button
337 key={step.id}
338 ref={(node) => {
339 stepButtonRefs.current[step.step_index] = node;
340 }}
341 onClick={() => toggleStep(step.step_index)}
342 className="w-full text-left px-3 py-2 hover-row text-sm"
343 style={{
344 cursor: "pointer",
345 backgroundColor: expanded
346 ? "var(--bg-inset)"
347 : isActive
348 ? "var(--accent-subtle)"
349 : undefined,
350 borderBottom: "1px solid var(--divide)",
351 fontWeight: expanded ? 600 : 400,
352 color: expanded ? "var(--text-primary)" : "var(--text-muted)",
353 }}
354 >
355 <div className="flex items-center gap-2">
356 <Badge variant={step.status} compact>
357 {ss.label.charAt(0)}
358 </Badge>
359 <span className="truncate" style={{ flex: 1 }}>
360 {step.name}
361 </span>
362 <span className="text-xs" style={{ color: "var(--text-faint)", flexShrink: 0 }}>
363 {step.status === "running"
364 ? <ElapsedTime since={step.started_at ?? run.started_at ?? run.created_at} />
365 : formatDuration(step.duration_ms)}
366 </span>
367 </div>
368 </button>
369 );
370 })}
371 </div>
372 );
373
374 const logsColumn = (
375 <div className="flex-1 min-w-0 flex flex-col" style={{ height: "100%" }}>
376 {expandedStep ? (
377 expandedLogs ? (
378 expandedLogs.length > 0 ? (
379 <LogViewer
380 logs={expandedLogs}
381 stickToBottom={stickToBottomRef.current[expandedStep.step_index] !== false}
382 onStickToBottomChange={(value) => {
383 stickToBottomRef.current[expandedStep.step_index] = value;
384 }}
385 style={{ height: "100%" }}
386 />
387 ) : (
388 <div className="flex-1 flex items-center justify-center">
389 <p className="text-sm" style={{ color: "var(--text-faint)" }}>
390 No output.
391 </p>
392 </div>
393 )
394 ) : (
395 <div className="flex-1 flex items-center justify-center">
396 <p className="text-sm" style={{ color: "var(--text-faint)" }}>
397 Loading logs...
398 </p>
399 </div>
400 )
401 ) : (
402 <div className="flex-1 flex items-center justify-center">
403 <p className="text-sm" style={{ color: "var(--text-faint)" }}>
404 Select a step to view logs
405 </p>
406 </div>
407 )}
408 </div>
409 );
410
411 const cancelAction = run.status === "running" && user ? (
412 <button
413 onClick={(e) => { e.stopPropagation(); handleCancel(); }}
414 disabled={cancelling}
415 className="px-1.5 py-0.5"
416 style={{
417 color: "var(--status-closed-text)",
418 border: "1px solid var(--status-closed-border)",
419 fontSize: "0.7rem",
420 background: "none",
421 cursor: "pointer",
422 }}
423 >
424 {cancelling ? "..." : "Cancel"}
425 </button>
426 ) : null;
427
428 if (props.renderLayout) {
429 return <>{props.renderLayout(stepsColumn, logsColumn, cancelAction)}</>;
430 }
431
432 return (
433 <div className="flex" style={{ height: "100%" }}>
434 {stepsColumn}
435 {logsColumn}
436 </div>
437 );
438 }
439
440 return (
441 <div className="max-w-3xl mx-auto px-4 py-6">
442 <div
443 className="p-4 mb-4"
444 style={{
445 backgroundColor: "var(--bg-card)",
446 border: "1px solid var(--border-subtle)",
447 }}
448 >
449 <div className="flex items-start justify-between gap-3 mb-2">
450 <div className="min-w-0">
451 <div className="flex items-center gap-2">
452 <span className="text-lg truncate">{run.pipeline_name}</span>
453 </div>
454 </div>
455 {run.status === "running" && user && (
456 <button
457 onClick={handleCancel}
458 disabled={cancelling}
459 className="text-xs px-2 py-1"
460 style={{
461 color: "var(--status-closed-text)",
462 border: "1px solid var(--status-closed-border)",
463 }}
464 >
465 {cancelling ? "Cancelling..." : "Cancel Build"}
466 </button>
467 )}
468 </div>
469 {run.commit_message && (
470 <div className="text-sm mt-1" style={{ color: "var(--text-secondary)" }}>
471 {run.commit_message}
472 </div>
473 )}
474
475 <div className="grid grid-cols-2 md:grid-cols-4 gap-3 text-xs mt-3">
476 <div>
477 <div style={{ color: "var(--text-faint)" }}>Trigger</div>
478 <div style={{ color: "var(--text-secondary)" }}>
479 {formatTrigger(run.trigger_type)}
480 {run.trigger_ref ? ` · ${run.trigger_ref}` : ""}
481 </div>
482 </div>
483 <div>
484 <div style={{ color: "var(--text-faint)" }}>Commit</div>
485 <div className="font-mono" style={{ color: "var(--text-secondary)" }}>
486 {run.commit_id ? (
487 <a
488 href={commitHref ?? commitPath ?? "#"}
489 className="hover:underline"
490 target="_blank"
491 rel="noopener noreferrer"
492 >
493 {run.commit_id.substring(0, 8)}
494 </a>
495 ) : (
496 "-"
497 )}
498 </div>
499 </div>
500 <div>
501 <div style={{ color: "var(--text-faint)" }}>Duration</div>
502 <div style={{ color: "var(--text-secondary)" }}>
503 {run.status === "running" && run.started_at
504 ? <ElapsedTime since={run.started_at} />
505 : formatDuration(run.duration_ms)}
506 </div>
507 </div>
508 <div>
509 <div style={{ color: "var(--text-faint)" }}>Started</div>
510 <div style={{ color: "var(--text-secondary)" }}>
511 <TimeAgo date={run.started_at ?? run.created_at} />
512 </div>
513 </div>
514 </div>
515 </div>
516
517 <div style={{ border: "1px solid var(--border-subtle)" }}>
518 <div
519 className="px-3 py-2 text-xs"
520 style={{
521 color: "var(--text-muted)",
522 borderBottom: "1px solid var(--divide)",
523 backgroundColor: "var(--bg-card)",
524 }}
525 >
526 Build Steps ({steps.length})
527 </div>
528 {steps.map((step, i) => {
529 const ss = statusIcon[step.status] ?? statusIcon.pending;
530 const expanded = expandedSteps.has(step.step_index);
531 const logs = stepLogs[step.step_index];
532 const isActive = activeStepIndex === step.step_index;
533
534 return (
535 <div
536 key={step.id}
537 style={{
538 borderTop: i > 0 ? "1px solid var(--divide)" : undefined,
539 }}
540 >
541 <button
542 ref={(node) => {
543 stepButtonRefs.current[step.step_index] = node;
544 }}
545 onClick={() => toggleStep(step.step_index)}
546 className="w-full text-left px-3 py-2.5 hover-row text-sm"
547 style={{
548 cursor: "pointer",
549 backgroundColor: isActive ? "var(--accent-subtle)" : undefined,
550 }}
551 aria-expanded={expanded}
552 >
553 <div className="flex items-center gap-2">
554 <Badge variant={step.status} style={{ minWidth: "4.6rem", textAlign: "center" }}>
555 {ss.label}
556 </Badge>
557 <span style={{ color: "var(--text-primary)", minWidth: 0, flex: 1 }}>
558 {step.name}
559 </span>
560 <span
561 className="text-xs"
562 style={{ color: "var(--text-faint)", width: "4.2rem", textAlign: "right" }}
563 >
564 {step.status === "running"
565 ? <ElapsedTime since={step.started_at ?? run.started_at ?? run.created_at} />
566 : formatDuration(step.duration_ms)}
567 </span>
568 <span
569 className="text-xs"
570 style={{ color: "var(--text-faint)", width: "1rem", textAlign: "right" }}
571 >
572 {expanded ? "▾" : "▸"}
573 </span>
574 </div>
575 </button>
576
577 {expanded && (
578 <div
579 className="px-3 pb-3"
580 style={{ backgroundColor: "var(--bg-inset)" }}
581 >
582 {logs ? (
583 logs.length > 0 ? (
584 <div
585 style={{
586 border: "1px solid var(--border-subtle)",
587 backgroundColor: "var(--bg-page)",
588 }}
589 >
590 <LogViewer
591 logs={logs}
592 stickToBottom={stickToBottomRef.current[step.step_index] !== false}
593 onStickToBottomChange={(value) => {
594 stickToBottomRef.current[step.step_index] = value;
595 }}
596 style={{ maxHeight: 380 }}
597 />
598 </div>
599 ) : (
600 <p
601 className="text-xs py-2"
602 style={{ color: "var(--text-faint)" }}
603 >
604 No output.
605 </p>
606 )
607 ) : (
608 <p
609 className="text-xs py-2"
610 style={{ color: "var(--text-faint)" }}
611 >
612 Loading logs...
613 </p>
614 )}
615 </div>
616 )}
617 </div>
618 );
619 })}
620 </div>
621 </div>
622 );
623}
624