| 1 | "use client"; |
| 2 | |
| 3 | import { useState } from "react"; |
| 4 | import { Badge } from "@/app/components/ui/badge"; |
| 5 | import { Dropdown, DropdownItem } from "@/app/components/ui/dropdown"; |
| 6 | import { ElapsedTime } from "@/app/components/elapsed-time"; |
| 7 | import { TimeAgo } from "@/app/components/time-ago"; |
| 8 | |
| 9 | interface Run { |
| 10 | id: number; |
| 11 | pipeline_name: string; |
| 12 | status: string; |
| 13 | commit_id: string | null; |
| 14 | commit_message: string | null; |
| 15 | trigger_ref: string; |
| 16 | started_at: string | null; |
| 17 | duration_ms: number | null; |
| 18 | created_at: string; |
| 19 | } |
| 20 | |
| 21 | interface Props { |
| 22 | runs: Run[]; |
| 23 | owner: string; |
| 24 | repo: string; |
| 25 | } |
| 26 | |
| 27 | const statusLabels: Record<string, string> = { |
| 28 | pending: "Pending", |
| 29 | running: "Running", |
| 30 | passed: "Passed", |
| 31 | failed: "Failed", |
| 32 | cancelled: "Cancelled", |
| 33 | }; |
| 34 | |
| 35 | function formatDuration(ms: number | null): string { |
| 36 | if (!ms) return ""; |
| 37 | if (ms < 1000) return "<1s"; |
| 38 | const s = Math.floor(ms / 1000); |
| 39 | if (s < 60) return `${s}s`; |
| 40 | const m = Math.floor(s / 60); |
| 41 | const rem = s % 60; |
| 42 | return rem > 0 ? `${m}m ${rem}s` : `${m}m`; |
| 43 | } |
| 44 | |
| 45 | export function PipelineList({ runs, owner, repo }: Props) { |
| 46 | const pipelineNames: string[] = []; |
| 47 | for (const run of runs) { |
| 48 | if (!pipelineNames.includes(run.pipeline_name)) { |
| 49 | pipelineNames.push(run.pipeline_name); |
| 50 | } |
| 51 | } |
| 52 | |
| 53 | const [selected, setSelected] = useState<string | null>( |
| 54 | pipelineNames.length > 0 ? pipelineNames[0] : null |
| 55 | ); |
| 56 | |
| 57 | const filtered = selected |
| 58 | ? runs.filter((r) => r.pipeline_name === selected) |
| 59 | : runs; |
| 60 | |
| 61 | const pipelineInfo = pipelineNames.map((name) => { |
| 62 | const pipelineRuns = runs.filter((r) => r.pipeline_name === name); |
| 63 | const status = pipelineRuns[0]?.status ?? "pending"; |
| 64 | return { name, status, createdAt: pipelineRuns[0]?.created_at ?? "" }; |
| 65 | }); |
| 66 | const selectedInfo = pipelineInfo.find((p) => p.name === selected); |
| 67 | |
| 68 | return ( |
| 69 | <> |
| 70 | {/* Mobile: dropdown filter */} |
| 71 | {pipelineNames.length > 1 && ( |
| 72 | <div className="mb-3 md:hidden"> |
| 73 | <Dropdown |
| 74 | trigger={ |
| 75 | <button |
| 76 | type="button" |
| 77 | className="w-full px-3 py-2 text-sm flex items-center justify-between gap-2" |
| 78 | style={{ |
| 79 | backgroundColor: "var(--bg-input)", |
| 80 | border: "1px solid var(--border-subtle)", |
| 81 | color: "var(--text-primary)", |
| 82 | }} |
| 83 | > |
| 84 | <span className="flex items-center gap-2 min-w-0"> |
| 85 | {selectedInfo && ( |
| 86 | <Badge variant={selectedInfo.status} compact> |
| 87 | {(statusLabels[selectedInfo.status] ?? selectedInfo.status).charAt(0)} |
| 88 | </Badge> |
| 89 | )} |
| 90 | <span className="truncate">{selected ?? "All pipelines"}</span> |
| 91 | </span> |
| 92 | <span className="text-xs shrink-0" style={{ color: "var(--text-faint)" }}>▾</span> |
| 93 | </button> |
| 94 | } |
| 95 | > |
| 96 | {pipelineInfo.map((p) => ( |
| 97 | <DropdownItem |
| 98 | key={p.name} |
| 99 | active={selected === p.name} |
| 100 | onClick={() => setSelected(p.name)} |
| 101 | > |
| 102 | <div className="flex items-center gap-2"> |
| 103 | <Badge variant={p.status} compact className="shrink-0"> |
| 104 | {(statusLabels[p.status] ?? p.status).charAt(0)} |
| 105 | </Badge> |
| 106 | <span className="truncate">{p.name}</span> |
| 107 | <span className="text-xs ml-auto shrink-0" style={{ color: "var(--text-faint)" }}> |
| 108 | <TimeAgo date={p.createdAt} /> |
| 109 | </span> |
| 110 | </div> |
| 111 | </DropdownItem> |
| 112 | ))} |
| 113 | </Dropdown> |
| 114 | </div> |
| 115 | )} |
| 116 | |
| 117 | {/* Pipeline tabs */} |
| 118 | {pipelineNames.length > 0 && ( |
| 119 | <div |
| 120 | className="hidden md:flex items-center gap-1 pb-3 mb-4" |
| 121 | style={{ borderBottom: "1px solid var(--border)" }} |
| 122 | > |
| 123 | {pipelineInfo.map((p) => { |
| 124 | const active = selected === p.name; |
| 125 | return ( |
| 126 | <button |
| 127 | key={p.name} |
| 128 | onClick={() => setSelected(p.name)} |
| 129 | className="flex items-center gap-1.5 px-2.5 py-1.5 text-sm" |
| 130 | style={{ |
| 131 | backgroundColor: active ? "var(--bg-inset)" : "transparent", |
| 132 | border: "none", |
| 133 | cursor: "pointer", |
| 134 | font: "inherit", |
| 135 | color: active ? "var(--text-primary)" : "var(--text-muted)", |
| 136 | fontWeight: active ? 600 : 400, |
| 137 | }} |
| 138 | > |
| 139 | <Badge variant={p.status} compact> |
| 140 | {(statusLabels[p.status] ?? p.status).charAt(0)} |
| 141 | </Badge> |
| 142 | <span>{p.name}</span> |
| 143 | </button> |
| 144 | ); |
| 145 | })} |
| 146 | </div> |
| 147 | )} |
| 148 | |
| 149 | {/* Run list */} |
| 150 | <div className="flex-1 min-w-0"> |
| 151 | {filtered.map((run) => ( |
| 152 | <a |
| 153 | key={run.id} |
| 154 | href={`/${owner}/${repo}/builds/${run.id}`} |
| 155 | className="flex items-center gap-2 sm:gap-3 py-2.5 px-1 hover-row" |
| 156 | style={{ borderBottom: "1px solid var(--divide)", textDecoration: "none", color: "inherit" }} |
| 157 | > |
| 158 | {/* Compact badge on mobile, full badge on desktop */} |
| 159 | <span className="shrink-0 sm:hidden"> |
| 160 | <Badge variant={run.status} compact> |
| 161 | {(statusLabels[run.status] ?? run.status).charAt(0)} |
| 162 | </Badge> |
| 163 | </span> |
| 164 | <span className="shrink-0 hidden sm:block"> |
| 165 | <Badge variant={run.status} style={{ minWidth: "4rem", textAlign: "center" }}> |
| 166 | {statusLabels[run.status] ?? run.status} |
| 167 | </Badge> |
| 168 | </span> |
| 169 | {/* Content */} |
| 170 | <div className="min-w-0 flex-1"> |
| 171 | <div className="truncate text-sm" style={{ color: "var(--text-secondary)" }}> |
| 172 | {run.commit_message || (run.commit_id ? run.commit_id.substring(0, 8) : "-")} |
| 173 | </div> |
| 174 | <div className="flex items-center gap-1.5 mt-0.5 text-xs" style={{ color: "var(--text-faint)" }}> |
| 175 | <span className="font-mono">#{run.id}</span> |
| 176 | {run.commit_id && ( |
| 177 | <> |
| 178 | <span>·</span> |
| 179 | <span |
| 180 | role="link" |
| 181 | tabIndex={0} |
| 182 | className="font-mono hover:underline cursor-pointer" |
| 183 | style={{ color: "var(--text-faint)" }} |
| 184 | onClick={(e) => { |
| 185 | e.preventDefault(); |
| 186 | e.stopPropagation(); |
| 187 | window.open(`/${owner}/${repo}/commit/${run.commit_id}`, "_blank", "noopener"); |
| 188 | }} |
| 189 | > |
| 190 | {run.commit_id.substring(0, 8)} |
| 191 | </span> |
| 192 | </> |
| 193 | )} |
| 194 | {!selected && ( |
| 195 | <> |
| 196 | <span className="hidden sm:inline">·</span> |
| 197 | <span className="hidden sm:inline">{run.pipeline_name}</span> |
| 198 | </> |
| 199 | )} |
| 200 | <span>·</span> |
| 201 | <span> |
| 202 | {run.started_at |
| 203 | ? <TimeAgo date={run.started_at} /> |
| 204 | : <TimeAgo date={run.created_at} />} |
| 205 | </span> |
| 206 | </div> |
| 207 | </div> |
| 208 | {/* Duration — vertically centered */} |
| 209 | <span |
| 210 | className="text-xs whitespace-nowrap shrink-0" |
| 211 | style={{ color: "var(--text-faint)" }} |
| 212 | > |
| 213 | {run.status === "running" && run.started_at |
| 214 | ? <ElapsedTime since={run.started_at} /> |
| 215 | : run.duration_ms |
| 216 | ? formatDuration(run.duration_ms) |
| 217 | : run.status === "pending" |
| 218 | ? "—" |
| 219 | : run.started_at |
| 220 | ? "<1s" |
| 221 | : "—"} |
| 222 | </span> |
| 223 | </a> |
| 224 | ))} |
| 225 | </div> |
| 226 | </> |
| 227 | ); |
| 228 | } |
| 229 | |