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>[section]</code> is a step executed in file
81 order. Reserved tables: <code>[on]</code> (triggers) and{" "}
82 <code>[variables]</code> (user-overridable inputs).
83 </p>
84 <div class="ci-help-sections">
85 <div class="ci-help-section">
86 <h4 class="ci-help-section-title">
87 Predefined variables
88 </h4>
89 <dl class="ci-help-vars">
90 {CI_VARIABLES.map(([name, desc]) => (
91 <>
92 <dt>
93 <code safe>{name}</code>
94 </dt>
95 <dd safe>{desc}</dd>
96 </>
97 ))}
98 </dl>
99 </div>
100 <div class="ci-help-section">
101 <h4 class="ci-help-section-title">Status badge</h4>
102 <p class="ci-help-badge-desc">Embed in your README:</p>
103 <code
104 class="ci-help-badge-code"
105 safe
106 >{`![pipeline](/${repo.name}/ci/badge.svg)`}</code>
107 <h4
108 class="ci-help-section-title"
109 style="margin-top: var(--space-4)"
110 >
111 Artifact types
112 </h4>
113 <dl class="ci-help-vars">
114 {[
115 ["publish_file", "copy file as-is"],
116 ["publish_tar", ".tar archive"],
117 ["publish_gzip", ".tar.gz archive"],
118 ["publish_zstd", ".tar.zst archive"],
119 ["publish_zip", ".zip archive"],
120 ].map(([k, v]) => (
121 <>
122 <dt>
123 <code safe>{k}</code>
124 </dt>
125 <dd safe>{v}</dd>
126 </>
127 ))}
128 </dl>
129 </div>
130 </div>
131 </div>
132 </details>
133 );
134}
135
136export function CiHistory({
137 user,
138 repo,
139 runs,
140 pagination,
141 manualTriggerDisabledReason,
142}: CiHistoryProps) {
143 const isRunning = runs.some(
144 (r) =>
145 r.status === "pending" ||
146 r.status === "running" ||
147 r.status === "queued",
148 );
149 return (
150 <Layout user={user} title={`Pipelines — ${repo.name}`}>
151 {isRunning ? <meta http-equiv="refresh" content="4" /> : null}
152 <div class="container">
153 <RepoHeader repo={repo} />
154 <RepoNav repo={repo} active="ci" user={user} />
155 <div class="list-header">
156 <h2 class="list-heading">Pipelines</h2>
157 {user?.isAdmin && (
158 <div class="list-header-actions">
159 <form
160 method="POST"
161 action={`/${repo.name}/ci/purge-cache`}
162 class="inline-form"
163 >
164 <button
165 type="submit"
166 class="btn btn-secondary btn-sm"
167 title="Delete all Docker cache volumes for this repository"
168 >
169 Purge caches
170 </button>
171 </form>
172 <form
173 method="POST"
174 action={`/${repo.name}/ci/run`}
175 class="inline-form"
176 >
177 <button
178 type="submit"
179 class="btn btn-primary btn-sm"
180 disabled={
181 manualTriggerDisabledReason
182 ? true
183 : undefined
184 }
185 title={
186 manualTriggerDisabledReason ?? undefined
187 }
188 >
189 Run pipeline
190 </button>
191 </form>
192 </div>
193 )}
194 </div>
195 {runs.length === 0 ? (
196 <div class="empty-state">
197 <p>No pipeline runs yet.</p>
198 <p class="text-muted">
199 Push a <code>.hearthforge-ci.toml</code> to your
200 repository to get started.
201 </p>
202 </div>
203 ) : (
204 <ul class="issue-list">
205 {runs.map((run) => (
206 <li class="issue-item">
207 <div class="release-item-header">
208 <div class="release-item-main">
209 <a
210 href={`/${repo.name}/ci/${run.id}`}
211 class="release-item-title"
212 >
213 <CiStatusPill
214 status={run.status}
215 title={
216 run.status === "queued"
217 ? queueTitle(
218 run.queue_position,
219 )
220 : undefined
221 }
222 />
223 <span class="ci-run-id">
224 #{run.repo_run_id ?? run.id}
225 </span>
226 </a>
227 <div class="release-item-meta">
228 {!!run.commit_sha && (
229 <code class="ci-sha" safe>
230 {run.commit_sha.slice(0, 8)}
231 </code>
232 )}
233 {!!run.commit_branch && (
234 <span class="badge" safe>
235 {run.commit_branch}
236 </span>
237 )}
238 {!!run.commit_tag && (
239 <span class="badge" safe>
240 {run.commit_tag}
241 </span>
242 )}
243 <span class="text-muted" safe>
244 {run.trigger_source}
245 </span>
246 {!!run.triggered_by_username && (
247 <span class="text-muted" safe>
248 by{" "}
249 {run.triggered_by_username}
250 </span>
251 )}
252 {run.artifact_count > 0 && (
253 <span>
254 {run.artifact_count}{" "}
255 artifact
256 {run.artifact_count !== 1
257 ? "s"
258 : ""}
259 </span>
260 )}
261 {!!run.started_at &&
262 !!run.finished_at && (
263 <span
264 class="text-muted"
265 safe
266 >
267 {duration(
268 run.started_at,
269 run.finished_at,
270 )}
271 </span>
272 )}
273 </div>
274 </div>
275 <div class="release-item-date">
276 <time datetime={run.created_at} safe>
277 {formatDateTime(run.created_at)}
278 </time>
279 </div>
280 </div>
281 </li>
282 ))}
283 </ul>
284 )}
285 <Pagination {...pagination} />
286 <CiHelp repo={repo} />
287 </div>
288 </Layout>
289 );
290}
291