CiHistory.tsx
⎇
Raw
1import type { RepositoryRow } from "../../db/index.ts";
2import { formatDateTime } from "../../lib/formatDate.ts";
3import type { SessionUser } from "../../middleware/session.ts";
4import type { CiVariableDef } from "../../services/ci.ts";
5import { Layout } from "../layout.tsx";
6import { Pagination, type PaginationInfo } from "../Pagination.tsx";
7import { RepoHeader } from "../repos/RepoHeader.tsx";
8import { RepoNav } from "../repos/RepoNav.tsx";
9import { CiStatusPill } from "./CiStatusPill.tsx";
10
11interface RunSummary {
12 id: number;
13 repo_run_id: number | null;
14 status: string;
15 trigger_source: string;
16 commit_sha: string | null;
17 commit_branch: string | null;
18 commit_tag: string | null;
19 started_at: string | null;
20 finished_at: string | null;
21 created_at: string;
22 triggered_by_username: string | null;
23 artifact_count: number;
24 queue_position: number | null;
25}
26
27interface CiHistoryProps {
28 user: SessionUser | null;
29 repo: RepositoryRow;
30 runs: RunSummary[];
31 pagination: PaginationInfo;
32 manualTriggerDisabledReason: string | null;
33 /** Declared in [variables]. Null when the config could not be read. */
34 ciVariables: Record<string, CiVariableDef> | null;
35 success?: string;
36 error?: string;
37}
38
39function queueTitle(position: number | null): string {
40 if (position === null) return "Waiting in the build queue";
41 if (position === 1) return "Waiting in the build queue — next up";
42 return `Waiting in the build queue — ${position - 1} run${position - 1 === 1 ? "" : "s"} ahead`;
43}
44
45function duration(start: string | null, end: string | null): string {
46 if (!start || !end) return "";
47 const ms = new Date(end).getTime() - new Date(start).getTime();
48 if (ms < 0) return "";
49 const s = Math.floor(ms / 1000);
50 if (s < 60) return `${s}s`;
51 const m = Math.floor(s / 60);
52 const rem = s % 60;
53 return rem > 0 ? `${m}m ${rem}s` : `${m}m`;
54}
55
56const CI_VARIABLES = [
57 ["CI", "always true"],
58 ["CI_PIPELINE_ID", "numeric run ID"],
59 ["CI_COMMIT_SHA", "full commit hash"],
60 ["CI_COMMIT_SHORT_SHA", "first 8 chars"],
61 ["CI_COMMIT_BRANCH", "branch name (empty for tags)"],
62 ["CI_COMMIT_TAG", "tag name (empty for branches)"],
63 ["CI_COMMIT_REF_NAME", "branch or tag name"],
64 ["CI_TRIGGER_SOURCE", "push · tag · manual"],
65 ["CI_REPO_NAME", "repository name"],
66 ["CI_SERVER_URL", "HearthForge base URL"],
67];
68
69function CiHelp({ repo }: { repo: RepositoryRow }) {
70 return (
71 <details class="form-card ci-help">
72 <summary class="ci-help-summary">
73 <span>How to define a pipeline file</span>
74 <a
75 href="/assets/hearthforge-ci-template.toml"
76 download=".hearthforge-ci.toml"
77 class="btn btn-sm btn-secondary ci-help-download"
78 >
79 Download template
80 </a>
81 </summary>
82 <div class="ci-help-body">
83 <p class="ci-help-desc">
84 Add <code>.hearthforge-ci.toml</code> to your repository
85 root. Each <code>[[steps]]</code> entry is a step, executed
86 in file order, and needs a <code>name</code>. Other tables:{" "}
87 <code>[on]</code> (triggers), <code>[variables]</code>{" "}
88 (user-overridable inputs) and <code>[[copy]]</code> (files
89 taken from another image, like <code>COPY --from</code>).
90 </p>
91 <div class="ci-help-sections">
92 <div class="ci-help-section">
93 <h4 class="ci-help-section-title">
94 Predefined variables
95 </h4>
96 <dl class="ci-help-vars">
97 {CI_VARIABLES.map(([name, desc]) => (
98 <>
99 <dt>
100 <code safe>{name}</code>
101 </dt>
102 <dd safe>{desc}</dd>
103 </>
104 ))}
105 </dl>
106 </div>
107 <div class="ci-help-section">
108 <h4 class="ci-help-section-title">Status badge</h4>
109 <p class="ci-help-badge-desc">Embed in your README:</p>
110 <code
111 class="ci-help-badge-code"
112 safe
113 >{`![pipeline](/${repo.name}/ci/badge.svg)`}</code>
114 <h4
115 class="ci-help-section-title"
116 style="margin-top: var(--space-4)"
117 >
118 Step options
119 </h4>
120 <dl class="ci-help-vars">
121 {[
122 ["run_sh", "shell command to run"],
123 ["run_if", "skip the step unless this exits 0"],
124 [
125 "always",
126 "run even after an earlier step failed",
127 ],
128 [
129 "warn_on_fail",
130 "step warns instead of failing; the run reports warning",
131 ],
132 [
133 "clear",
134 "re-extract a clean checkout before the step",
135 ],
136 ["timeout", "per-step timeout in seconds"],
137 ].map(([k, v]) => (
138 <>
139 <dt>
140 <code safe>{k}</code>
141 </dt>
142 <dd safe>{v}</dd>
143 </>
144 ))}
145 </dl>
146 <h4
147 class="ci-help-section-title"
148 style="margin-top: var(--space-4)"
149 >
150 Artifact types
151 </h4>
152 <dl class="ci-help-vars">
153 {[
154 ["publish_file", "copy file as-is"],
155 ["publish_tar", ".tar archive"],
156 ["publish_gzip", ".tar.gz archive"],
157 ["publish_zstd", ".tar.zst archive"],
158 ["publish_zip", ".zip archive"],
159 ].map(([k, v]) => (
160 <>
161 <dt>
162 <code safe>{k}</code>
163 </dt>
164 <dd safe>{v}</dd>
165 </>
166 ))}
167 </dl>
168 </div>
169 </div>
170 </div>
171 </details>
172 );
173}
174
175/**
176 * Manual trigger. With no declared variables it is a plain submit button.
177 * With variables it opens a panel of inputs seeded with their defaults, so a
178 * field left untouched sends the default back and counts as no override.
179 */
180function RunPipeline({
181 repo,
182 disabledReason,
183 variables,
184}: {
185 repo: RepositoryRow;
186 disabledReason: string | null;
187 variables: Array<[string, CiVariableDef]>;
188}) {
189 const form = (
190 <form method="POST" action={`/${repo.name}/ci/run`}>
191 {variables.length > 0 && (
192 <div class="ci-run-vars">
193 {variables.map(([name, def]) => (
194 <label class="form-group ci-run-var">
195 <code safe>{name}</code>
196 {!!def.description && (
197 <span class="ci-run-var-desc" safe>
198 {def.description}
199 </span>
200 )}
201 <input
202 type="text"
203 name={`var_${name}`}
204 value={def.default ?? ""}
205 autocomplete="off"
206 />
207 </label>
208 ))}
209 </div>
210 )}
211 <button
212 type="submit"
213 class="btn btn-primary btn-sm"
214 disabled={disabledReason ? true : undefined}
215 title={disabledReason ?? undefined}
216 >
217 Run pipeline
218 </button>
219 </form>
220 );
221
222 if (variables.length === 0) return <div class="inline-form">{form}</div>;
223
224 return (
225 <details class="confirm-details ci-run-details">
226 <summary
227 class="btn btn-primary btn-sm"
228 title={disabledReason ?? undefined}
229 >
230 Run pipeline
231 </summary>
232 <div class="confirm-popup ci-run-popup">{form}</div>
233 </details>
234 );
235}
236
237export function CiHistory({
238 user,
239 repo,
240 runs,
241 pagination,
242 manualTriggerDisabledReason,
243 ciVariables,
244 success,
245 error,
246}: CiHistoryProps) {
247 const varEntries = Object.entries(ciVariables ?? {});
248 const isRunning = runs.some(
249 (r) =>
250 r.status === "pending" ||
251 r.status === "running" ||
252 r.status === "queued",
253 );
254 return (
255 <Layout user={user} title={`Pipelines — ${repo.name}`}>
256 {isRunning ? <meta http-equiv="refresh" content="4" /> : null}
257 <div class="container">
258 <RepoHeader repo={repo} />
259 <RepoNav repo={repo} active="ci" user={user} />
260 {!!success && (
261 <p class="form-success" safe>
262 {success}
263 </p>
264 )}
265 {!!error && (
266 <p class="form-error" safe>
267 {error}
268 </p>
269 )}
270 <div class="list-header">
271 <h2 class="list-heading">Pipelines</h2>
272 {user?.isAdmin && (
273 <div class="list-header-actions">
274 <form
275 method="POST"
276 action={`/${repo.name}/ci/purge-cache`}
277 class="inline-form"
278 >
279 <button
280 type="submit"
281 class="btn btn-secondary btn-sm"
282 title="Delete all Docker cache volumes for this repository"
283 >
284 Purge caches
285 </button>
286 </form>
287 <RunPipeline
288 repo={repo}
289 disabledReason={manualTriggerDisabledReason}
290 variables={varEntries}
291 />
292 </div>
293 )}
294 </div>
295 {runs.length === 0 ? (
296 <div class="empty-state">
297 <p>No pipeline runs yet.</p>
298 <p class="text-muted">
299 Push a <code>.hearthforge-ci.toml</code> to your
300 repository to get started.
301 </p>
302 </div>
303 ) : (
304 <ul class="issue-list">
305 {runs.map((run) => (
306 <li class="issue-item">
307 <div class="release-item-header">
308 <div class="release-item-main">
309 <a
310 href={`/${repo.name}/ci/${run.id}`}
311 class="release-item-title"
312 >
313 <CiStatusPill
314 status={run.status}
315 title={
316 run.status === "queued"
317 ? queueTitle(
318 run.queue_position,
319 )
320 : undefined
321 }
322 />
323 <span class="ci-run-id">
324 #{run.repo_run_id ?? run.id}
325 </span>
326 </a>
327 <div class="release-item-meta">
328 {!!run.commit_sha && (
329 <code class="ci-sha" safe>
330 {run.commit_sha.slice(0, 8)}
331 </code>
332 )}
333 {!!run.commit_branch && (
334 <span class="badge" safe>
335 {run.commit_branch}
336 </span>
337 )}
338 {!!run.commit_tag && (
339 <span class="badge" safe>
340 {run.commit_tag}
341 </span>
342 )}
343 <span class="text-muted" safe>
344 {run.trigger_source}
345 </span>
346 {!!run.triggered_by_username && (
347 <span class="text-muted" safe>
348 by{" "}
349 {run.triggered_by_username}
350 </span>
351 )}
352 {run.artifact_count > 0 && (
353 <span>
354 {run.artifact_count}{" "}
355 artifact
356 {run.artifact_count !== 1
357 ? "s"
358 : ""}
359 </span>
360 )}
361 {!!run.started_at &&
362 !!run.finished_at && (
363 <span
364 class="text-muted"
365 safe
366 >
367 {duration(
368 run.started_at,
369 run.finished_at,
370 )}
371 </span>
372 )}
373 </div>
374 </div>
375 <div class="release-item-date">
376 <time datetime={run.created_at} safe>
377 {formatDateTime(run.created_at)}
378 </time>
379 </div>
380 </div>
381 </li>
382 ))}
383 </ul>
384 )}
385 <Pagination {...pagination} />
386 <CiHelp repo={repo} />
387 </div>
388 </Layout>
389 );
390}
391