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 datetime={run.created_at} class="text-muted" safe>
141 {formatDateTime(run.created_at)}
142 </time>
143 </div>
144 </div>
145 <div class="ci-run-actions">
146 {isActive && (
147 <a
148 href={autoRefresh ? "?refresh=off" : "?"}
149 class="btn btn-secondary btn-sm"
150 >
151 {autoRefresh
152 ? "Pause refresh"
153 : "Resume refresh"}
154 </a>
155 )}
156 {user?.isAdmin &&
157 (isActive ? (
158 <form
159 method="POST"
160 action={`/${repo.name}/ci/${run.id}/cancel`}
161 class="inline-form"
162 >
163 <button
164 type="submit"
165 class="btn btn-danger btn-sm"
166 >
167 Cancel
168 </button>
169 </form>
170 ) : (
171 <form
172 method="POST"
173 action={`/${repo.name}/ci/${run.id}/retry`}
174 class="inline-form"
175 >
176 <button
177 type="submit"
178 class="btn btn-secondary btn-sm"
179 title="Re-run with the same commit, trigger source, and variable overrides"
180 >
181 Retry
182 </button>
183 </form>
184 ))}
185 </div>
186 </div>
187
188 {hasOverrides && (
189 <div class="form-card ci-overrides">
190 <h3 class="section-title">Variable overrides</h3>
191 <dl class="ci-vars-list">
192 {Object.entries(variableOverrides).map(([k, v]) => (
193 <>
194 <dt>
195 <code safe>{k}</code>
196 </dt>
197 <dd safe>{v}</dd>
198 </>
199 ))}
200 </dl>
201 </div>
202 )}
203
204 <div class="ci-steps">
205 <h3 class="section-title">Steps</h3>
206 {steps.length === 0 ? (
207 <div class="ci-step-pending">
208 <span class="text-muted">
209 {isQueued ? queueText : "Waiting to start…"}
210 </span>
211 </div>
212 ) : (
213 steps.map((step) => (
214 <details
215 class={`ci-step ci-step-${step.status}`}
216 open={
217 step.status === "running" ? true : undefined
218 }
219 >
220 <summary class="ci-step-summary">
221 <CiStatusPill status={step.status} />
222 <span class="ci-step-name" safe>
223 {step.name}
224 </span>
225 {!!step.started_at && !!step.finished_at && (
226 <span class="ci-step-duration text-muted" safe>
227 {duration(
228 step.started_at,
229 step.finished_at,
230 )}
231 </span>
232 )}
233 </summary>
234 {step.log ? (
235 <pre class="ci-step-log" safe>
236 {step.log}
237 </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 safe
258 >
259 {artifact.filename}
260 </a>
261 <span class="ci-artifact-size text-muted" safe>
262 {formatBytes(artifact.size)}
263 </span>
264 </li>
265 ))}
266 </ul>
267 </div>
268 )}
269 </div>
270 </Layout>
271 );
272}
273