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 {isActive && autoRefresh ? (
91 <meta http-equiv="refresh" content="3" />
92 ) : null}
93 <div class="container">
94 <RepoHeader repo={repo} />
95 <RepoNav repo={repo} active="ci" user={user} />
96
97 <div class="release-detail-header">
98 <div>
99 <h2 class="release-detail-title">
100 <CiStatusPill
101 status={run.status}
102 title={isQueued ? queueText : undefined}
103 />{" "}
104 Pipeline #{displayId}
105 </h2>
106 <div class="release-item-meta">
107 {!!run.commit_sha && (
108 <code class="ci-sha" safe>
109 {run.commit_sha.slice(0, 8)}
110 </code>
111 )}
112 {!!run.commit_branch && (
113 <a
114 href={`/${repo.name}/tree/${run.commit_branch}`}
115 class="badge"
116 safe
117 >
118 {run.commit_branch}
119 </a>
120 )}
121 {!!run.commit_tag && (
122 <a
123 href={`/${repo.name}/tree/${run.commit_tag}`}
124 class="badge"
125 safe
126 >
127 {run.commit_tag}
128 </a>
129 )}
130 <span class="text-muted" safe>
131 triggered by {run.trigger_source}
132 {!!run.triggered_by_username &&
133 ` (${run.triggered_by_username})`}
134 </span>
135 {!!run.started_at && !!run.finished_at && (
136 <span class="text-muted" safe>
137 {duration(run.started_at, run.finished_at)}
138 </span>
139 )}
140 <time
141 datetime={run.created_at}
142 class="text-muted"
143 safe
144 >
145 {formatDateTime(run.created_at)}
146 </time>
147 </div>
148 </div>
149 <div class="ci-run-actions">
150 {isActive && (
151 <a
152 href={autoRefresh ? "?refresh=off" : "?"}
153 class="btn btn-secondary btn-sm"
154 >
155 {autoRefresh
156 ? "Pause refresh"
157 : "Resume refresh"}
158 </a>
159 )}
160 {user?.isAdmin &&
161 (isActive ? (
162 <form
163 method="POST"
164 action={`/${repo.name}/ci/${run.id}/cancel`}
165 class="inline-form"
166 >
167 <button
168 type="submit"
169 class="btn btn-danger btn-sm"
170 >
171 Cancel
172 </button>
173 </form>
174 ) : (
175 <form
176 method="POST"
177 action={`/${repo.name}/ci/${run.id}/retry`}
178 class="inline-form"
179 >
180 <button
181 type="submit"
182 class="btn btn-secondary btn-sm"
183 title="Re-run with the same commit, trigger source, and variable overrides"
184 >
185 Retry
186 </button>
187 </form>
188 ))}
189 </div>
190 </div>
191
192 {hasOverrides && (
193 <div class="form-card ci-overrides">
194 <h3 class="section-title">Variable overrides</h3>
195 <dl class="ci-vars-list">
196 {Object.entries(variableOverrides).map(([k, v]) => (
197 <>
198 <dt>
199 <code safe>{k}</code>
200 </dt>
201 <dd safe>{v}</dd>
202 </>
203 ))}
204 </dl>
205 </div>
206 )}
207
208 <div class="ci-steps">
209 <h3 class="section-title">Steps</h3>
210 {steps.length === 0 ? (
211 <div class="ci-step-pending">
212 <span class="text-muted">
213 {isQueued ? queueText : "Waiting to start…"}
214 </span>
215 </div>
216 ) : (
217 steps.map((step) => (
218 <details
219 class={`ci-step ci-step-${step.status}`}
220 open={
221 step.status === "running" ? true : undefined
222 }
223 >
224 <summary class="ci-step-summary">
225 <CiStatusPill status={step.status} />
226 <span class="ci-step-name" safe>
227 {step.name}
228 </span>
229 {!!step.started_at &&
230 !!step.finished_at && (
231 <span
232 class="ci-step-duration text-muted"
233 safe
234 >
235 {duration(
236 step.started_at,
237 step.finished_at,
238 )}
239 </span>
240 )}
241 </summary>
242 {step.log ? (
243 <pre class="ci-step-log" safe>
244 {step.log}
245 </pre>
246 ) : step.status === "running" ? (
247 <div class="ci-step-running-indicator text-muted">
248 Running…
249 </div>
250 ) : null}
251 </details>
252 ))
253 )}
254 </div>
255
256 {artifacts.length > 0 && (
257 <div class="form-card ci-artifacts">
258 <h3 class="section-title">Artifacts</h3>
259 <ul class="ci-artifact-list">
260 {artifacts.map((artifact) => (
261 <li class="ci-artifact-item">
262 <a
263 href={`/${repo.name}/ci/${run.id}/artifacts/${artifact.id}`}
264 class="ci-artifact-name"
265 safe
266 >
267 {artifact.filename}
268 </a>
269 <span
270 class="ci-artifact-size text-muted"
271 safe
272 >
273 {formatBytes(artifact.size)}
274 </span>
275 </li>
276 ))}
277 </ul>
278 </div>
279 )}
280 </div>
281 </Layout>
282 );
283}
284