Fork Horse repository, listed by its owner. Publisher snapshot.

packages/forkhorse-dashboard/src/receipts.tsx

Snapshot at 34e3175

Containing directory

10,145 bytes · textView raw
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