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