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}
36
37function duration(start: string | null, end: string | null): string {
38 if (!start || !end) return "";
39 const ms = new Date(end).getTime() - new Date(start).getTime();
40 if (ms < 0) return "";
41 const s = Math.floor(ms / 1000);
42 if (s < 60) return `${s}s`;
43 const m = Math.floor(s / 60);
44 const rem = s % 60;
45 return rem > 0 ? `${m}m ${rem}s` : `${m}m`;
46}
47
48function formatBytes(bytes: number): string {
49 if (bytes < 1024) return `${bytes} B`;
50 if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`;
51 return `${(bytes / (1024 * 1024)).toFixed(1)} MB`;
52}
53
54export function CiRunDetail({
55 user,
56 repo,
57 run,
58 steps,
59 artifacts,
60 autoRefresh,
61}: CiRunDetailProps) {
62 const isActive = run.status === "pending" || run.status === "running";
63 const displayId = run.repo_run_id ?? run.id;
64
65 const variableOverrides: Record<string, string> = run.variable_overrides
66 ? JSON.parse(run.variable_overrides)
67 : {};
68 const hasOverrides = Object.keys(variableOverrides).length > 0;
69
70 return (
71 <Layout user={user} title={`Pipeline #${displayId} — ${repo.name}`}>
72 {
73 (isActive && autoRefresh ? (
74 <meta http-equiv="refresh" content="3" />
75 ) : (
76 ""
77 )) as unknown as JSX.Element
78 }
79 <div class="container">
80 <RepoHeader repo={repo} />
81 <RepoNav repo={repo} active="ci" user={user} />
82
83 <div class="release-detail-header">
84 <div>
85 <h2 class="release-detail-title">
86 <CiStatusPill status={run.status} /> Pipeline #
87 {displayId}
88 </h2>
89 <div class="release-item-meta">
90 {run.commit_sha && (
91 <code class="ci-sha">
92 {run.commit_sha.slice(0, 8)}
93 </code>
94 )}
95 {run.commit_branch && (
96 <a
97 href={`/${repo.name}/tree/${run.commit_branch}`}
98 class="badge"
99 >
100 {run.commit_branch}
101 </a>
102 )}
103 {run.commit_tag && (
104 <a
105 href={`/${repo.name}/tree/${run.commit_tag}`}
106 class="badge"
107 >
108 {run.commit_tag}
109 </a>
110 )}
111 <span class="text-muted">
112 triggered by {run.trigger_source}
113 {run.triggered_by_username &&
114 ` (${run.triggered_by_username})`}
115 </span>
116 {run.started_at && run.finished_at && (
117 <span class="text-muted">
118 {duration(run.started_at, run.finished_at)}
119 </span>
120 )}
121 <time datetime={run.created_at} class="text-muted">
122 {formatDateTime(run.created_at)}
123 </time>
124 </div>
125 </div>
126 <div class="ci-run-actions">
127 {isActive && (
128 <a
129 href={autoRefresh ? "?refresh=off" : "?"}
130 class="btn btn-secondary btn-sm"
131 >
132 {autoRefresh
133 ? "Pause refresh"
134 : "Resume refresh"}
135 </a>
136 )}
137 {user?.isAdmin &&
138 (isActive ? (
139 <form
140 method="POST"
141 action={`/${repo.name}/ci/${run.id}/cancel`}
142 class="inline-form"
143 >
144 <button
145 type="submit"
146 class="btn btn-danger btn-sm"
147 >
148 Cancel
149 </button>
150 </form>
151 ) : (
152 <form
153 method="POST"
154 action={`/${repo.name}/ci/${run.id}/retry`}
155 class="inline-form"
156 >
157 <button
158 type="submit"
159 class="btn btn-secondary btn-sm"
160 title="Re-run with the same commit, trigger source, and variable overrides"
161 >
162 Retry
163 </button>
164 </form>
165 ))}
166 </div>
167 </div>
168
169 {hasOverrides && (
170 <div class="form-card ci-overrides">
171 <h3 class="section-title">Variable overrides</h3>
172 <dl class="ci-vars-list">
173 {Object.entries(variableOverrides).map(([k, v]) => (
174 <>
175 <dt>
176 <code>{k}</code>
177 </dt>
178 <dd>{v}</dd>
179 </>
180 ))}
181 </dl>
182 </div>
183 )}
184
185 <div class="ci-steps">
186 <h3 class="section-title">Steps</h3>
187 {steps.length === 0 ? (
188 <div class="ci-step-pending">
189 <span class="text-muted">Waiting to start…</span>
190 </div>
191 ) : (
192 steps.map((step) => (
193 <details
194 class={`ci-step ci-step-${step.status}`}
195 open={
196 step.status === "running" ? true : undefined
197 }
198 >
199 <summary class="ci-step-summary">
200 <CiStatusPill status={step.status} />
201 <span class="ci-step-name">
202 {step.name}
203 </span>
204 {step.started_at && step.finished_at && (
205 <span class="ci-step-duration text-muted">
206 {duration(
207 step.started_at,
208 step.finished_at,
209 )}
210 </span>
211 )}
212 </summary>
213 {step.log ? (
214 <pre class="ci-step-log">{step.log}</pre>
215 ) : step.status === "running" ? (
216 <div class="ci-step-running-indicator text-muted">
217 Running…
218 </div>
219 ) : null}
220 </details>
221 ))
222 )}
223 </div>
224
225 {artifacts.length > 0 && (
226 <div class="form-card ci-artifacts">
227 <h3 class="section-title">Artifacts</h3>
228 <ul class="ci-artifact-list">
229 {artifacts.map((artifact) => (
230 <li class="ci-artifact-item">
231 <a
232 href={`/${repo.name}/ci/${run.id}/artifacts/${artifact.id}`}
233 class="ci-artifact-name"
234 >
235 {artifact.filename}
236 </a>
237 <span class="ci-artifact-size text-muted">
238 {formatBytes(artifact.size)}
239 </span>
240 </li>
241 ))}
242 </ul>
243 </div>
244 )}
245 </div>
246 </Layout>
247 );
248}
249