8.0 KB229 lines
Blame
1"use client";
2
3import { useState } from "react";
4import { Badge } from "@/app/components/ui/badge";
5import { Dropdown, DropdownItem } from "@/app/components/ui/dropdown";
6import { ElapsedTime } from "@/app/components/elapsed-time";
7import { TimeAgo } from "@/app/components/time-ago";
8
9interface 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
21interface Props {
22 runs: Run[];
23 owner: string;
24 repo: string;
25}
26
27const statusLabels: Record<string, string> = {
28 pending: "Pending",
29 running: "Running",
30 passed: "Passed",
31 failed: "Failed",
32 cancelled: "Cancelled",
33};
34
35function 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
45export 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