| 1 | "use client"; |
| 2 | |
| 3 | import { useEffect, useRef, useState, useCallback } from "react"; |
| 4 | import { canopy, type PipelineRun, type PipelineStep, type StepLog } from "@/lib/api"; |
| 5 | import { useAuth } from "@/lib/auth"; |
| 6 | import { useParams } from "next/navigation"; |
| 7 | import { Badge } from "@/app/components/ui/badge"; |
| 8 | import { TimeAgo } from "@/app/components/time-ago"; |
| 9 | import { ElapsedTime } from "@/app/components/elapsed-time"; |
| 10 | import { LogViewer } from "@/app/components/log-viewer"; |
| 11 | import { useCanopyEvents, type CanopyEvent } from "@/lib/use-canopy-events"; |
| 12 | |
| 13 | const 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 | |
| 22 | function 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 | |
| 32 | function 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 | |
| 39 | function 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 | |
| 47 | interface 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 | |
| 55 | export 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 | |