CiRunDetail.tsx
Raw
1import type {
2 CiArtifactRow,
3 CiStepRow,
4 RepositoryRow,
5} from "../../db/index.ts";
6import { formatDateTime } from "../../lib/formatDate.ts";
7import type { SessionUser } from "../../middleware/session.ts";
8import { Layout } from "../layout.tsx";
9import { RepoHeader } from "../repos/RepoHeader.tsx";
10import { RepoNav } from "../repos/RepoNav.tsx";
11import { CiStatusPill } from "./CiStatusPill.tsx";
12
13interface RunDetail {
14 id: number;
15 repo_run_id: number | null;
16 status: string;
17 trigger_source: string;
18 commit_sha: string | null;
19 commit_branch: string | null;
20 commit_tag: string | null;
21 variable_overrides: string | null;
22 started_at: string | null;
23 finished_at: string | null;
24 created_at: string;
25 triggered_by_username: string | null;
26}
27
28interface CiRunDetailProps {
29 user: SessionUser | null;
30 repo: RepositoryRow;
31 run: RunDetail;
32 steps: CiStepRow[];
33 artifacts: CiArtifactRow[];
34 autoRefresh: boolean;
35 queuePosition: number | null;
36}
37
38function queueTitle(position: number | null): string {
39 if (position === null) return "Waiting in the build queue";
40 if (position === 1) return "Waiting in the build queue — next up";
41 return `Waiting in the build queue — ${position - 1} run${position - 1 === 1 ? "" : "s"} ahead`;
42}
43
44function duration(start: string | null, end: string | null): string {
45 if (!start || !end) return "";
46 const ms = new Date(end).getTime() - new Date(start).getTime();
47 if (ms < 0) return "";
48 const s = Math.floor(ms / 1000);
49 if (s < 60) return `${s}s`;
50 const m = Math.floor(s / 60);
51 const rem = s % 60;
52 return rem > 0 ? `${m}m ${rem}s` : `${m}m`;
53}
54
55function formatBytes(bytes: number): string {
56 if (bytes < 1024) return `${bytes} B`;
57 if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`;
58 return `${(bytes / (1024 * 1024)).toFixed(1)} MB`;
59}
60
61export function CiRunDetail({
62 user,
63 repo,
64 run,
65 steps,
66 artifacts,
67 autoRefresh,
68 queuePosition,
69}: CiRunDetailProps) {
70 const isActive =
71 run.status === "pending" ||
72 run.status === "running" ||
73 run.status === "queued";
74 const isQueued = run.status === "queued";
75 const queueText = queueTitle(queuePosition);
76 const displayId = run.repo_run_id ?? run.id;
77
78 let variableOverrides: Record<string, string> = {};
79 if (run.variable_overrides) {
80 try {
81 variableOverrides = JSON.parse(run.variable_overrides);
82 } catch {
83 // corrupted DB value — treat as empty
84 }
85 }
86 const hasOverrides = Object.keys(variableOverrides).length > 0;
87
88 return (
89 <Layout user={user} title={`Pipeline #${displayId} — ${repo.name}`}>
90 {
91 (isActive && autoRefresh ? (
92 <meta http-equiv="refresh" content="3" />
93 ) : (
94 ""
95 )) as unknown as JSX.Element
96 }
97 <div class="container">
98 <RepoHeader repo={repo} />
99 <RepoNav repo={repo} active="ci" user={user} />
100
101 <div class="release-detail-header">
102 <div>
103 <h2 class="release-detail-title">
104 <CiStatusPill
105 status={run.status}
106 title={isQueued ? queueText : undefined}
107 />{" "}
108 Pipeline #{displayId}
109 </h2>
110 <div class="release-item-meta">
111 {run.commit_sha && (
112 <code class="ci-sha">
113 {run.commit_sha.slice(0, 8)}
114 </code>
115 )}
116 {run.commit_branch && (
117 <a
118 href={`/${repo.name}/tree/${run.commit_branch}`}
119 class="badge"
120 >
121 {run.commit_branch}
122 </a>
123 )}
124 {run.commit_tag && (
125 <a
126 href={`/${repo.name}/tree/${run.commit_tag}`}
127 class="badge"
128 >
129 {run.commit_tag}
130 </a>
131 )}
132 <span class="text-muted">
133 triggered by {run.trigger_source}
134 {run.triggered_by_username &&
135 ` (${run.triggered_by_username})`}
136 </span>
137 {run.started_at && run.finished_at && (
138 <span class="text-muted">
139 {duration(run.started_at, run.finished_at)}
140 </span>
141 )}
142 <time datetime={run.created_at} class="text-muted">
143 {formatDateTime(run.created_at)}
144 </time>
145 </div>
146 </div>
147 <div class="ci-run-actions">
148 {isActive && (
149 <a
150 href={autoRefresh ? "?refresh=off" : "?"}
151 class="btn btn-secondary btn-sm"
152 >
153 {autoRefresh
154 ? "Pause refresh"
155 : "Resume refresh"}
156 </a>
157 )}
158 {user?.isAdmin &&
159 (isActive ? (
160 <form
161 method="POST"
162 action={`/${repo.name}/ci/${run.id}/cancel`}
163 class="inline-form"
164 >
165 <button
166 type="submit"
167 class="btn btn-danger btn-sm"
168 >
169 Cancel
170 </button>
171 </form>
172 ) : (
173 <form
174 method="POST"
175 action={`/${repo.name}/ci/${run.id}/retry`}
176 class="inline-form"
177 >
178 <button
179 type="submit"
180 class="btn btn-secondary btn-sm"
181 title="Re-run with the same commit, trigger source, and variable overrides"
182 >
183 Retry
184 </button>
185 </form>
186 ))}
187 </div>
188 </div>
189
190 {hasOverrides && (
191 <div class="form-card ci-overrides">
192 <h3 class="section-title">Variable overrides</h3>
193 <dl class="ci-vars-list">
194 {Object.entries(variableOverrides).map(([k, v]) => (
195 <>
196 <dt>
197 <code>{k}</code>
198 </dt>
199 <dd>{v}</dd>
200 </>
201 ))}
202 </dl>
203 </div>
204 )}
205
206 <div class="ci-steps">
207 <h3 class="section-title">Steps</h3>
208 {steps.length === 0 ? (
209 <div class="ci-step-pending">
210 <span class="text-muted">
211 {isQueued ? queueText : "Waiting to start…"}
212 </span>
213 </div>
214 ) : (
215 steps.map((step) => (
216 <details
217 class={`ci-step ci-step-${step.status}`}
218 open={
219 step.status === "running" ? true : undefined
220 }
221 >
222 <summary class="ci-step-summary">
223 <CiStatusPill status={step.status} />
224 <span class="ci-step-name">
225 {step.name}
226 </span>
227 {step.started_at && step.finished_at && (
228 <span class="ci-step-duration text-muted">
229 {duration(
230 step.started_at,
231 step.finished_at,
232 )}
233 </span>
234 )}
235 </summary>
236 {step.log ? (
237 <pre class="ci-step-log">{step.log}</pre>
238 ) : step.status === "running" ? (
239 <div class="ci-step-running-indicator text-muted">
240 Running…
241 </div>
242 ) : null}
243 </details>
244 ))
245 )}
246 </div>
247
248 {artifacts.length > 0 && (
249 <div class="form-card ci-artifacts">
250 <h3 class="section-title">Artifacts</h3>
251 <ul class="ci-artifact-list">
252 {artifacts.map((artifact) => (
253 <li class="ci-artifact-item">
254 <a
255 href={`/${repo.name}/ci/${run.id}/artifacts/${artifact.id}`}
256 class="ci-artifact-name"
257 >
258 {artifact.filename}
259 </a>
260 <span class="ci-artifact-size text-muted">
261 {formatBytes(artifact.size)}
262 </span>
263 </li>
264 ))}
265 </ul>
266 </div>
267 )}
268 </div>
269 </Layout>
270 );
271}
272