import { useState } from 'react' import { useParams, Link } from 'react-router-dom' import { useRunDetail, useJobLogs, useCancelRun, useRetryJob } from '../api/queries/pipelines' import { useRunSBOM, getRunSBOMDocumentURL, useGenerateSBOM } from '../api/queries/sbom' import { Skeleton } from '../ui/Skeleton' import { cn } from '../lib/utils' import type { PipelineJob, PipelineStep, RunStatus } from '../types/api' // ── Utilities ───────────────────────────────────────────────────────────────── function timeAgo(iso: string | null): string { if (!iso) return '—' const diff = Date.now() - new Date(iso).getTime() const min = Math.floor(diff / 60_000) if (min < 1) return 'just now' if (min < 60) return `${min}m ago` const hr = Math.floor(min / 60) if (hr < 24) return `${hr}h ago` return `${Math.floor(hr / 24)}d ago` } function duration(start: string | null, end: string | null): string { if (!start) return '—' const ms = new Date(end ?? Date.now()).getTime() - new Date(start).getTime() const s = Math.floor(ms / 1000) if (s < 60) return `${s}s` const m = Math.floor(s / 60) return `${m}m ${s % 60}s` } // ── SBOM section ────────────────────────────────────────────────────────────── function SBOMSection({ owner, repo, runId, runStatus, triggerSha }: { owner: string repo: string runId: number runStatus: RunStatus triggerSha: string }) { const { data: sbom, isLoading } = useRunSBOM(owner, repo, runId) const generateSBOM = useGenerateSBOM(owner, repo) if (isLoading) { return (

SBOM

) } if (sbom) { return (

SBOM — CycloneDX

{sbom.componentCount} components {sbom.sha.slice(0, 7)} {new Date(sbom.generatedAt).toLocaleString()}
Download BOM
) } // No SBOM yet — show generate option for completed/failed runs if (runStatus === 'succeeded' || runStatus === 'failed') { return (

SBOM

No SBOM generated for this run.

{generateSBOM.isError && (

{(generateSBOM.error as Error).message}

)}
) } return null } function shortRef(ref: string): string { return ref.replace('refs/heads/', '').replace('refs/tags/', '') } function shortSHA(sha: string): string { return sha.slice(0, 7) } // ── Status helpers ───────────────────────────────────────────────────────────── const STATUS_DOT: Record = { queued: 'bg-[var(--c-subtle)]', running: 'bg-[var(--c-brand)] animate-pulse', succeeded: 'bg-[var(--c-success)]', failed: 'bg-[var(--c-danger)]', cancelled: 'bg-[var(--c-subtle)]', } const STATUS_BADGE: Record = { queued: 'bg-[var(--c-surface-muted)] border-[var(--c-border)] text-[var(--c-muted)]', running: 'bg-[var(--c-brand-tint)] border-[var(--c-brand-focus)] text-[var(--c-brand)]', succeeded: 'bg-[#E3FCEF] border-[#79F2C0] text-[#006644]', failed: 'bg-[var(--c-danger-tint)] border-[#FF8F73] text-[var(--c-danger-dark)]', cancelled: 'bg-[var(--c-surface-muted)] border-[var(--c-border)] text-[var(--c-muted)]', } const STATUS_LABEL: Record = { queued: 'Queued', running: 'Running', succeeded: 'Passed', failed: 'Failed', cancelled: 'Cancelled', } function StatusBadge({ status, size = 'md' }: { status: RunStatus; size?: 'sm' | 'md' }) { return ( {STATUS_LABEL[status]} ) } // ── DAG visualization ───────────────────────────────────────────────────────── type JobWithSteps = PipelineJob & { steps: PipelineStep[] } interface DAGProps { jobs: JobWithSteps[] selectedJobId: number | null onSelectJob: (id: number) => void } function DAGView({ jobs, selectedJobId, onSelectJob }: DAGProps) { // Build dependency columns using topological sort const columns = topoColumns(jobs) if (jobs.length === 0) { return (
No jobs in this run.
) } return (
{columns.map((col, colIdx) => (
{/* Column of jobs */}
{col.map(job => ( ))}
{/* Connector (not after last column) */} {colIdx < columns.length - 1 && (
)}
))}
) } function JobStatusIcon({ status }: { status: RunStatus }) { if (status === 'succeeded') { return } if (status === 'failed') { return } if (status === 'running') { return } if (status === 'cancelled') { return } // queued return } /** Arrange jobs into columns by dependency depth (0 = no deps). */ function topoColumns(jobs: JobWithSteps[]): JobWithSteps[][] { const nameToJob = new Map(jobs.map(j => [j.name, j])) const depth = new Map() function getDepth(name: string, visited = new Set()): number { if (depth.has(name)) return depth.get(name)! if (visited.has(name)) return 0 visited.add(name) const job = nameToJob.get(name) if (!job) return 0 let needs: string[] = [] try { needs = JSON.parse(job.needs || '[]') ?? [] } catch { needs = [] } const d = needs.length === 0 ? 0 : 1 + Math.max(...needs.map(n => getDepth(n, new Set(visited)))) depth.set(name, d) return d } jobs.forEach(j => getDepth(j.name)) const maxDepth = Math.max(...Array.from(depth.values()), 0) const cols: JobWithSteps[][] = Array.from({ length: maxDepth + 1 }, () => []) jobs.forEach(j => cols[depth.get(j.name) ?? 0].push(j)) return cols.filter(c => c.length > 0) } // ── Log viewer ───────────────────────────────────────────────────────────────── function LogViewer({ owner, repo, runId, jobId }: { owner: string; repo: string; runId: number; jobId: number }) { const { data, isLoading } = useJobLogs(owner, repo, runId, jobId) const [expandedSteps, setExpandedSteps] = useState>(new Set([0])) if (isLoading) { return (
{[1, 2, 3].map(i => )}
) } if (!data || data.length === 0) { return

No steps recorded for this job.

} function toggle(seq: number) { setExpandedSteps(prev => { const next = new Set(prev) if (next.has(seq)) next.delete(seq) else next.add(seq) return next }) } return (
{data.map(step => { const open = expandedSteps.has(step.seq) const logText = step.logs.map(l => l.content).join('') return (
{open && (
{logText ? (
                    {logText}
                  
) : (

No output captured.

)}
)}
) })}
) } // ── Main page ───────────────────────────────────────────────────────────────── export default function PipelineRunPage() { const { owner = '', repo = '', runId = '' } = useParams() const runIdNum = parseInt(runId, 10) const { data: run, isLoading, error } = useRunDetail(owner, repo, runIdNum) const cancelRun = useCancelRun(owner, repo) const retryJob = useRetryJob(owner, repo, runIdNum) const [selectedJobId, setSelectedJobId] = useState(null) // Auto-select first job once data loads const jobs = run?.jobs ?? [] const effectiveJobId = selectedJobId ?? jobs[0]?.id ?? null const selectedJob = jobs.find(j => j.id === effectiveJobId) ?? null if (error) { return (

Run not found.

← Back to repository
) } return (
{/* Breadcrumb */} {/* Run header */} {isLoading ? (
) : run ? (

Run #{run.id}

{shortRef(run.triggerRef)} {shortSHA(run.triggerSha)} triggered by {run.triggeredBy} {timeAgo(run.createdAt)} {run.startedAt && ( duration: {duration(run.startedAt, run.finishedAt)} )}
{/* Actions */}
{(run.status === 'running' || run.status === 'queued') && ( )}
) : null} {/* SBOM section */} {!isLoading && run && ( )} {/* DAG + log viewer */}
{/* DAG section */}

Jobs

{isLoading ? (
{[1, 2, 3].map(i => )}
) : ( setSelectedJobId(id)} /> )}
{/* Log viewer for selected job */} {effectiveJobId !== null && (

{selectedJob ? `Logs — ${selectedJob.name}` : 'Logs'}

{selectedJob && ( )} {selectedJob && (selectedJob.status === 'failed' || selectedJob.status === 'cancelled') && ( )}
)}
) }