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