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