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