web/app/%5Bowner%5D/%5Brepo%5D/(tabs)/pipelines/pipeline-list.tsxblame
View source
818dc901"use client";
818dc902
818dc903import { useState } from "react";
4dfd09b4import { Badge } from "@/app/components/ui/badge";
8a2c7d45import { Dropdown, DropdownItem } from "@/app/components/ui/dropdown";
a011f1e6import { ElapsedTime } from "@/app/components/elapsed-time";
a011f1e7import { TimeAgo } from "@/app/components/time-ago";
818dc908
818dc909interface Run {
818dc9010 id: number;
818dc9011 pipeline_name: string;
818dc9012 status: string;
818dc9013 commit_id: string | null;
818dc9014 commit_message: string | null;
818dc9015 trigger_ref: string;
a011f1e16 started_at: string | null;
818dc9017 duration_ms: number | null;
818dc9018 created_at: string;
818dc9019}
818dc9020
818dc9021interface Props {
818dc9022 runs: Run[];
818dc9023 owner: string;
818dc9024 repo: string;
818dc9025}
818dc9026
4dfd09b27const statusLabels: Record<string, string> = {
5ff7ede28 pending: "Pending",
5ff7ede29 running: "Running",
5ff7ede30 passed: "Passed",
5ff7ede31 failed: "Failed",
5ff7ede32 cancelled: "Cancelled",
818dc9033};
818dc9034
818dc9035function formatDuration(ms: number | null): string {
818dc9036 if (!ms) return "";
818dc9037 if (ms < 1000) return "<1s";
818dc9038 const s = Math.floor(ms / 1000);
818dc9039 if (s < 60) return `${s}s`;
818dc9040 const m = Math.floor(s / 60);
818dc9041 const rem = s % 60;
818dc9042 return rem > 0 ? `${m}m ${rem}s` : `${m}m`;
818dc9043}
818dc9044
818dc9045export function PipelineList({ runs, owner, repo }: Props) {
818dc9046 const pipelineNames: string[] = [];
818dc9047 for (const run of runs) {
818dc9048 if (!pipelineNames.includes(run.pipeline_name)) {
818dc9049 pipelineNames.push(run.pipeline_name);
818dc9050 }
818dc9051 }
818dc9052
4dfd09b53 const [selected, setSelected] = useState<string | null>(
4dfd09b54 pipelineNames.length > 0 ? pipelineNames[0] : null
4dfd09b55 );
818dc9056
818dc9057 const filtered = selected
818dc9058 ? runs.filter((r) => r.pipeline_name === selected)
818dc9059 : runs;
818dc9060
818dc9061 const pipelineInfo = pipelineNames.map((name) => {
8a2c7d462 const pipelineRuns = runs.filter((r) => r.pipeline_name === name);
b2bd12363 const status = pipelineRuns[0]?.status ?? "pending";
8a2c7d464 return { name, status, createdAt: pipelineRuns[0]?.created_at ?? "" };
818dc9065 });
8a2c7d466 const selectedInfo = pipelineInfo.find((p) => p.name === selected);
818dc9067
818dc9068 return (
8a2c7d469 <>
8a2c7d470 {/* Mobile: dropdown filter */}
8a2c7d471 {pipelineNames.length > 1 && (
8a2c7d472 <div className="mb-3 md:hidden">
8a2c7d473 <Dropdown
8a2c7d474 trigger={
818dc9075 <button
8a2c7d476 type="button"
8a2c7d477 className="w-full px-3 py-2 text-sm flex items-center justify-between gap-2"
818dc9078 style={{
8a2c7d479 backgroundColor: "var(--bg-input)",
8a2c7d480 border: "1px solid var(--border-subtle)",
8a2c7d481 color: "var(--text-primary)",
818dc9082 }}
818dc9083 >
8a2c7d484 <span className="flex items-center gap-2 min-w-0">
8a2c7d485 {selectedInfo && (
8a2c7d486 <Badge variant={selectedInfo.status} compact>
8a2c7d487 {(statusLabels[selectedInfo.status] ?? selectedInfo.status).charAt(0)}
8a2c7d488 </Badge>
8a2c7d489 )}
8a2c7d490 <span className="truncate">{selected ?? "All pipelines"}</span>
8a2c7d491 </span>
8a2c7d492 <span className="text-xs shrink-0" style={{ color: "var(--text-faint)" }}>▾</span>
8a2c7d493 </button>
8a2c7d494 }
8a2c7d495 >
8a2c7d496 {pipelineInfo.map((p) => (
8a2c7d497 <DropdownItem
8a2c7d498 key={p.name}
8a2c7d499 active={selected === p.name}
8a2c7d4100 onClick={() => setSelected(p.name)}
8a2c7d4101 >
8a2c7d4102 <div className="flex items-center gap-2">
8a2c7d4103 <Badge variant={p.status} compact className="shrink-0">
8a2c7d4104 {(statusLabels[p.status] ?? p.status).charAt(0)}
4dfd09b105 </Badge>
8a2c7d4106 <span className="truncate">{p.name}</span>
8a2c7d4107 <span className="text-xs ml-auto shrink-0" style={{ color: "var(--text-faint)" }}>
a011f1e108 <TimeAgo date={p.createdAt} />
4dfd09b109 </span>
4dfd09b110 </div>
8a2c7d4111 </DropdownItem>
8a2c7d4112 ))}
8a2c7d4113 </Dropdown>
8a2c7d4114 </div>
818dc90115 )}
818dc90116
fe3b509117 {/* Pipeline tabs */}
fe3b509118 {pipelineNames.length > 0 && (
fe3b509119 <div
fe3b509120 className="hidden md:flex items-center gap-1 pb-3 mb-4"
fe3b509121 style={{ borderBottom: "1px solid var(--border)" }}
fe3b509122 >
fe3b509123 {pipelineInfo.map((p) => {
fe3b509124 const active = selected === p.name;
fe3b509125 return (
fe3b509126 <button
fe3b509127 key={p.name}
fe3b509128 onClick={() => setSelected(p.name)}
fe3b509129 className="flex items-center gap-1.5 px-2.5 py-1.5 text-sm"
fe3b509130 style={{
fe3b509131 backgroundColor: active ? "var(--bg-inset)" : "transparent",
fe3b509132 border: "none",
fe3b509133 cursor: "pointer",
fe3b509134 font: "inherit",
fe3b509135 color: active ? "var(--text-primary)" : "var(--text-muted)",
fe3b509136 fontWeight: active ? 600 : 400,
fe3b509137 }}
fe3b509138 >
fe3b509139 <Badge variant={p.status} compact>
fe3b509140 {(statusLabels[p.status] ?? p.status).charAt(0)}
fe3b509141 </Badge>
fe3b509142 <span>{p.name}</span>
fe3b509143 </button>
fe3b509144 );
fe3b509145 })}
fe3b509146 </div>
fe3b509147 )}
8a2c7d4148
fe3b509149 {/* Run list */}
fe3b509150 <div className="flex-1 min-w-0">
8a2c7d4151 {filtered.map((run) => (
8a2c7d4152 <a
8a2c7d4153 key={run.id}
8a2c7d4154 href={`/${owner}/${repo}/builds/${run.id}`}
b2bd123155 className="flex items-center gap-2 sm:gap-3 py-2.5 px-1 hover-row"
8a2c7d4156 style={{ borderBottom: "1px solid var(--divide)", textDecoration: "none", color: "inherit" }}
8a2c7d4157 >
8a2c7d4158 {/* Compact badge on mobile, full badge on desktop */}
b2bd123159 <span className="shrink-0 sm:hidden">
8a2c7d4160 <Badge variant={run.status} compact>
8a2c7d4161 {(statusLabels[run.status] ?? run.status).charAt(0)}
8a2c7d4162 </Badge>
8a2c7d4163 </span>
b2bd123164 <span className="shrink-0 hidden sm:block">
8a2c7d4165 <Badge variant={run.status} style={{ minWidth: "4rem", textAlign: "center" }}>
8a2c7d4166 {statusLabels[run.status] ?? run.status}
8a2c7d4167 </Badge>
8a2c7d4168 </span>
8a2c7d4169 {/* Content */}
8a2c7d4170 <div className="min-w-0 flex-1">
8a2c7d4171 <div className="truncate text-sm" style={{ color: "var(--text-secondary)" }}>
8a2c7d4172 {run.commit_message || (run.commit_id ? run.commit_id.substring(0, 8) : "-")}
8a2c7d4173 </div>
b2bd123174 <div className="flex items-center gap-1.5 mt-0.5 text-xs" style={{ color: "var(--text-faint)" }}>
8a2c7d4175 <span className="font-mono">#{run.id}</span>
b2bd123176 {run.commit_id && (
b2bd123177 <>
b2bd123178 <span>·</span>
b2bd123179 <span
b2bd123180 role="link"
b2bd123181 tabIndex={0}
b2bd123182 className="font-mono hover:underline cursor-pointer"
b2bd123183 style={{ color: "var(--text-faint)" }}
b2bd123184 onClick={(e) => {
b2bd123185 e.preventDefault();
b2bd123186 e.stopPropagation();
b2bd123187 window.open(`/${owner}/${repo}/commit/${run.commit_id}`, "_blank", "noopener");
b2bd123188 }}
b2bd123189 >
b2bd123190 {run.commit_id.substring(0, 8)}
b2bd123191 </span>
b2bd123192 </>
b2bd123193 )}
8a2c7d4194 {!selected && (
b2bd123195 <>
b2bd123196 <span className="hidden sm:inline">·</span>
b2bd123197 <span className="hidden sm:inline">{run.pipeline_name}</span>
b2bd123198 </>
0fdef14199 )}
b2bd123200 <span>·</span>
b2bd123201 <span>
b2bd123202 {run.started_at
8a2c7d4203 ? <TimeAgo date={run.started_at} />
8a2c7d4204 : <TimeAgo date={run.created_at} />}
8a2c7d4205 </span>
8a2c7d4206 </div>
8a2c7d4207 </div>
b2bd123208 {/* Duration — vertically centered */}
8a2c7d4209 <span
b2bd123210 className="text-xs whitespace-nowrap shrink-0"
8a2c7d4211 style={{ color: "var(--text-faint)" }}
8a2c7d4212 >
8a2c7d4213 {run.status === "running" && run.started_at
8a2c7d4214 ? <ElapsedTime since={run.started_at} />
b2bd123215 : run.duration_ms
b2bd123216 ? formatDuration(run.duration_ms)
b2bd123217 : run.status === "pending"
b2bd123218 ? "—"
b2bd123219 : run.started_at
b2bd123220 ? "<1s"
b2bd123221 : "—"}
8a2c7d4222 </span>
8a2c7d4223 </a>
8a2c7d4224 ))}
818dc90225 </div>
8a2c7d4226 </>
818dc90227 );
818dc90228}