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