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