Fork Horse repository, listed by its owner. Publisher snapshot.
packages/forkhorse-dashboard/src/receipts.tsx
Snapshot at 34e3175
1import { useEffect, useId, useRef, useState } from "react";
2import { Badge, Button, ClipboardText } from "@cloudflare/kumo";
3import { relativeTime } from "./time.ts";
4import type { OwnerReceipt, ReceiptStatus, ReceiptSummary, Review } from "./contracts.ts";
5
6export function checkCount(count: number): string {
7 return `${count} ${count === 1 ? "check" : "checks"}`;
8}
9
10export function duration(value: number | null): string {
11 if (value === null || !Number.isFinite(value) || value < 0) return "";
12 if (value < 1000) return `${Math.round(value)} ms`;
13 const seconds = Math.round(value / 1000);
14 if (seconds >= 3600) {
15 const minutes = Math.floor((seconds % 3600) / 60);
16 return `${Math.floor(seconds / 3600)} hr${minutes ? ` ${minutes} min` : ""}`;
17 }
18 return seconds < 60
19 ? `${seconds} s`
20 : `${Math.floor(seconds / 60)} min${seconds % 60 ? ` ${seconds % 60} s` : ""}`;
21}
22export function receiptWord(status: ReceiptStatus, recheck = false) {
23 switch (status) {
24 case "running":
25 return { label: recheck ? "Re-checking" : "Checks running", tone: "info" };
26 case "passed":
27 return { label: "Passed", tone: "success" };
28 case "failed":
29 return { label: "Failed", tone: "danger" };
30 case "timed-out":
31 return { label: "Timed out", tone: "warning" };
32 case "infrastructure-error":
33 return { label: "Couldn't run", tone: "warning" };
34 }
35}
36export function ReceiptBadge({
37 status,
38 recheck = false,
39}: {
40 status: ReceiptStatus;
41 recheck?: boolean;
42}) {
43 const { label, tone } = receiptWord(status, recheck);
44 return (
45 <Badge appearance="dot" variant="neutral" className={`status-dot status-${tone}`}>
46 {label}
47 </Badge>
48 );
49}
50export function ReceiptCell({
51 receipt,
52 packet,
53 sha,
54 required,
55 open,
56}: {
57 receipt?: ReceiptSummary | null;
58 packet: string;
59 sha: string;
60 required: boolean;
61 open(): void;
62}) {
63 const captionId = useId();
64 const [now, setNow] = useState(Date.now);
65 useEffect(() => {
66 if (receipt?.status !== "running" || receipt.trigger === "before-promote") return;
67 const timer = setInterval(() => setNow(Date.now()), 1000);
68 return () => clearInterval(timer);
69 }, [receipt?.status, receipt?.trigger, receipt?.startedAt]);
70 if (!receipt)
71 return <span className="caption muted">{required ? "Not started" : "No receipt"}</span>;
72 const failed = receipt.checks.filter((check) => check.outcome === "failed");
73 const timedOut = receipt.checks.find((check) => check.outcome === "timed-out");
74 let caption;
75 switch (receipt.status) {
76 case "running":
77 caption =
78 receipt.trigger === "before-promote" && required
79 ? "Before promote"
80 : now - Date.parse(receipt.startedAt) >= 3_600_000
81 ? `Started ${relativeTime(receipt.startedAt, now, "hour")}`
82 : `Started ${duration(Math.max(0, now - Date.parse(receipt.startedAt)))} ago`;
83 break;
84 case "passed":
85 caption = `${checkCount(receipt.checks.length)}${duration(receipt.durationMs) ? ` · ${duration(receipt.durationMs)}` : ""}`;
86 break;
87 case "failed":
88 caption = (
89 <>
90 {failed.length} of {receipt.checks.length} failed
91 {failed[0] && (
92 <>
93 {" "}
94 · <code>{failed[0].name}</code>
95 </>
96 )}
97 </>
98 );
99 break;
100 case "timed-out":
101 caption = (
102 <>
103 Stopped
104 {timedOut && duration(timedOut.durationMs) && <> after {duration(timedOut.durationMs)}</>}
105 {timedOut && (
106 <>
107 {" "}
108 · <code>{timedOut.name}</code>
109 </>
110 )}
111 </>
112 );
113 break;
114 case "infrastructure-error":
115 caption = "The checks didn't start.";
116 break;
117 }
118 return (
119 <button
120 type="button"
121 className="receipt-cell"
122 onClick={open}
123 aria-label={`View receipt for ${packet} at ${sha.slice(0, 7)}: ${receiptWord(receipt.status, required && receipt.trigger === "before-promote").label}`}
124 aria-describedby={captionId}
125 >
126 <ReceiptBadge
127 status={receipt.status}
128 recheck={required && receipt.trigger === "before-promote"}
129 />
130 <span id={captionId} className="caption muted">{caption}</span>
131 </button>
132 );
133}
134
135export function ReceiptDetails({
136 review,
137 project,
138 owner,
139 load,
140}: {
141 review: Review;
142 project: string;
143 owner: boolean;
144 load?: (review: string) => Promise<OwnerReceipt>;
145}) {
146 const [logs, setLogs] = useState<string>();
147 const [all, setAll] = useState(false);
148 const [busy, setBusy] = useState(false);
149 const [error, setError] = useState<string>();
150 const epoch = useRef(0);
151 const receipt = review.receipt;
152 useEffect(() => {
153 epoch.current++;
154 setLogs(undefined);
155 setError(undefined);
156 setAll(false);
157 setBusy(false);
158 return () => {
159 epoch.current++;
160 };
161 }, [project, review.id, review.sha, receipt?.digest, receipt?.startedAt, owner]);
162 const lines = logs?.split("\n") ?? [];
163 return (
164 <section className="receipt-details" aria-label="Receipt details">
165 <h3>Receipt</h3>
166 <p>
167 <code>{review.packet}</code> · <code>{review.sha.slice(0, 7)}</code>
168 </p>
169 {receipt ? (
170 <>
171 <ReceiptBadge status={receipt.status} recheck={receipt.trigger === "before-promote"} />
172 {receipt.checks.map((check) => (
173 <div className="receipt-check" key={check.name}>
174 <p>
175 <code>{check.name}</code> · {check.tier}
176 </p>
177 <ReceiptBadge status={check.outcome} />{" "}
178 <span className="caption tabular">{duration(check.durationMs)}</span>
179 </div>
180 ))}
181 {receipt.digest && (
182 <div className="receipt-digest">
183 <p className="caption muted">Digest</p>
184 <ClipboardText
185 text={receipt.digest.slice(0, 7)}
186 textToCopy={receipt.digest}
187 className="font-mono"
188 size="sm"
189 />
190 </div>
191 )}
192 {owner && load && (
193 <Button
194 variant="ghost"
195 size="sm"
196 disabled={busy}
197 className="receipt-log-toggle"
198 onClick={async () => {
199 if (logs !== undefined) {
200 epoch.current++;
201 setLogs(undefined);
202 setAll(false);
203 return;
204 }
205 const current = ++epoch.current;
206 setBusy(true);
207 setError(undefined);
208 setLogs(undefined);
209 try {
210 const stored = await load(review.id);
211 if (current !== epoch.current) return;
212 if (stored.receipt.commit !== review.sha || stored.digest !== receipt.digest)
213 throw new Error("Receipt changed. Refresh and view logs again.");
214 setLogs(
215 stored.receipt.checks
216 .flatMap((check) => [
217 `${check.check.name} · stdout`,
218 check.logs.stdout,
219 `${check.check.name} · stderr`,
220 check.logs.stderr,
221 ...(check.logs.truncated ? ["Logs were bounded by the runner."] : []),
222 ])
223 .join("\n"),
224 );
225 } catch (failure) {
226 if (current !== epoch.current) return;
227 setError(
228 failure instanceof Error ? failure.message : "Unable to load logs. Try again.",
229 );
230 } finally {
231 if (current === epoch.current) setBusy(false);
232 }
233 }}
234 >
235 {logs === undefined ? "View logs" : "Hide logs"}
236 </Button>
237 )}
238 {busy && (
239 <p role="status" className="caption">
240 Loading receipt logs…
241 </p>
242 )}
243 {error && (
244 <p role="alert" className="caption">
245 {error}
246 </p>
247 )}
248 {logs !== undefined && (
249 <>
250 <pre className="receipt-logs caption" tabIndex={0} aria-label="Receipt logs">
251 {(all ? lines : lines.slice(-200)).join("\n")}
252 </pre>
253 {!all && lines.length > 200 && (
254 <Button variant="ghost" size="sm" onClick={() => setAll(true)}>
255 Show all
256 </Button>
257 )}
258 </>
259 )}
260 </>
261 ) : (
262 <p className="caption muted">No receipt</p>
263 )}
264 </section>
265 );
266}
267
268/** Announce observed completion once; don't narrate every elapsed-time update. */
269export function ReceiptAnnouncements({ project, reviews }: { project: string; reviews: Review[] }) {
270 const previous = useRef(new Map<string, ReceiptStatus>());
271 const seen = useRef(new Set<string>());
272 const scope = useRef(project);
273 const [message, setMessage] = useState("");
274 useEffect(() => {
275 if (scope.current !== project) {
276 scope.current = project;
277 previous.current.clear();
278 seen.current.clear();
279 setMessage("");
280 }
281 const completed: string[] = [];
282 for (const review of reviews) {
283 const receipt = review.receipt;
284 if (!receipt) continue;
285 const key = `${review.id}:${receipt.digest ?? receipt.startedAt}:${receipt.status}`;
286 if (
287 previous.current.get(review.id) === "running" &&
288 receipt.status !== "running" &&
289 !seen.current.has(key)
290 ) {
291 completed.push(
292 `${receipt.status === "passed" ? "Checks passed" : receiptWord(receipt.status).label} for ${review.packet}`,
293 );
294 seen.current.add(key);
295 }
296 previous.current.set(review.id, receipt.status);
297 }
298 if (completed.length) setMessage(completed.join(". "));
299 }, [project, reviews]);
300 return (
301 <div className="sr-only" role="status" aria-live="polite">
302 {message}
303 </div>
304 );
305}
306