Skip to content
563 linesCodeBlameRaw
1/**
2 * Pieces of the Actions pages: how a run, job or step stands, how long it
3 * took, and a job's log as GitHub shows one, with its groups folded and
4 * its errors and warnings marked.
5 */
6import { AlertTriangle, Ban, Check, ChevronRight, CircleDashed, CircleSlash, Clock, Hourglass, Info, Loader2, ShieldAlert, X } from "lucide-react";
7import { Fragment, memo, useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState } from "react";
8import { Link } from "react-router";
9
10import type { CommitStatus, Conclusion, LogChunk, WorkflowNote } from "@g1t/contracts";
11
12import { Card } from "./ui/card";
13import { Hint } from "./ui/hint";
14import { markSpans, parseAnsi, spanLook } from "../lib/ansi";
15import { type Line, type Row, type StepLog, appendLog, emptyLog, groupOf, logRows, rowKey, searchStepLog } from "../lib/log-lines";
16import { WINDOW_FROM, rowStarts, windowOf } from "../lib/log-window";
17import { type PollAnswer, nextPoll, retryAfterMs } from "../lib/log-poll";
18
19/**
20 * Where a run, job or step stands. `of` says which: a run's `pending` waits
21 * for its concurrency group and its `waiting` for its environments' rules,
22 * while a job's `pending` is held at its environment and its `waiting`
23 * waits for the jobs it needs. `environment` is the job's.
24 */
25type Standing = { status: string; conclusion: Conclusion | null; of?: "run" | "job"; environment?: string | null };
26
27/** Held until a person (or a wait timer) lets it through. */
28function heldWord({ status, of = "run", environment }: Standing): string | null {
29 if (of === "run" && status === "action_required") return "Approval required";
30 if (of === "run" && status === "waiting") return "Waiting for review";
31 if (of === "job" && status === "pending") return environment ? `Waiting to deploy to ${environment}` : "Waiting at its environment";
32 return null;
33}
34
35/** One icon for where a run, job or step stands. */
36export function StatusIcon({ status, conclusion, of = "run", environment, size = 16 }: Standing & { size?: number }) {
37 if (status === "in_progress") return <Loader2 size={size} className="shrink-0 animate-spin text-warn" aria-label="Running" />;
38 const held = heldWord({ status, conclusion, of, environment });
39 if (held)
40 return status === "action_required" ? (
41 <ShieldAlert size={size} className="shrink-0 text-warn" aria-label={held} />
42 ) : (
43 <Hourglass size={size} className="shrink-0 text-warn" aria-label={held} />
44 );
45 if (status !== "completed")
46 return <Clock size={size} className="shrink-0 text-faint" aria-label={status === "pending" ? "Waiting its turn" : "Queued"} />;
47 switch (conclusion) {
48 case "success":
49 return (
50 <span className="inline-flex shrink-0 rounded-full bg-success/15 p-0.5 text-success" aria-label="Succeeded">
51 <Check size={size - 4} strokeWidth={3} />
52 </span>
53 );
54 case "failure":
55 return (
56 <span className="inline-flex shrink-0 rounded-full bg-danger/15 p-0.5 text-danger" aria-label="Failed">
57 <X size={size - 4} strokeWidth={3} />
58 </span>
59 );
60 case "cancelled":
61 return <Ban size={size} className="shrink-0 text-faint" aria-label="Cancelled" />;
62 default:
63 return <CircleDashed size={size} className="shrink-0 text-faint" aria-label="Skipped" />;
64 }
65}
66
67export function standingWord(standing: Standing): string {
68 const { status, conclusion } = standing;
69 if (status === "in_progress") return "Running";
70 const held = heldWord(standing);
71 if (held) return held;
72 if (status === "pending") return "Waiting for its concurrency group";
73 if (status === "waiting") return "Waiting for the jobs it needs";
74 if (status === "calling") return "Running the workflow it calls";
75 if (status !== "completed") return "Queued";
76 return { success: "Succeeded", failure: "Failed", cancelled: "Cancelled", skipped: "Skipped" }[conclusion ?? "skipped"];
77}
78
79/** `1m 12s`, from two times, or from a start until now. */
80export function duration(start: string | null, end: string | null): string {
81 if (!start) return "";
82 const ms = (end ? new Date(end).getTime() : Date.now()) - new Date(start).getTime();
83 const seconds = Math.max(0, Math.round(ms / 1000));
84 if (seconds < 60) return `${seconds}s`;
85 const minutes = Math.floor(seconds / 60);
86 if (minutes < 60) return `${minutes}m ${seconds % 60}s`;
87 return `${Math.floor(minutes / 60)}h ${minutes % 60}m`;
88}
89
90/**
91 * How long something ran, as text. While it is still running the server and
92 * the browser count to a different now, so the text may differ on hydration
93 * (as TimeAgo's does).
94 */
95export function Duration({ start, end, className }: { start: string | null; end: string | null; className?: string }) {
96 return (
97 <span className={className} suppressHydrationWarning>
98 {duration(start, end)}
99 </span>
100 );
101}
102
103/** `main` from `refs/heads/main`, `v1.2` from a tag, `#12` for a pull request. */
104export function shortRef(ref: string): string {
105 const pull = /^refs\/pull\/(\d+)\//.exec(ref);
106 if (pull) return `#${pull[1]}`;
107 return ref.replace(/^refs\/(heads|tags)\//, "");
108}
109
110const LINE_STYLE: Record<Line["kind"], string> = {
111 text: "text-fg/85",
112 error: "text-danger",
113 warning: "text-warn",
114 notice: "text-accent",
115 debug: "text-faint",
116 command: "text-muted",
117};
118
119/** A line's time as the log shows it beside the line: GitHub's, to the millisecond. */
120function shownTime(at: string): string {
121 return at.replace(/(\.\d{3})\d*Z$/, "$1Z");
122}
123
124/** A line's text, coloured as the tool that printed it coloured it, and with a search's matches marked. */
125function LineText({ text, ansi, query }: { text: string; ansi?: string; query: string }) {
126 const spans = useMemo(() => (ansi ? parseAnsi(ansi) : [{ text, style: {} }]), [text, ansi]);
127 const pieces = useMemo(() => markSpans(spans, query), [spans, query]);
128 return (
129 <>
130 {pieces.map((piece, index) => {
131 const look = spanLook(piece.style);
132 const drawn = look.className || look.style ? (
133 <span className={look.className} style={look.style}>
134 {piece.text}
135 </span>
136 ) : (
137 piece.text
138 );
139 return piece.match ? (
140 <mark key={index} className="rounded-sm bg-accent/30 text-fg">
141 {drawn}
142 </mark>
143 ) : (
144 <Fragment key={index}>{drawn}</Fragment>
145 );
146 })}
147 </>
148 );
149}
150
151/** One line of a step's log: its number (a link to it), its time when asked for, and its text. Drawn again only when it changes. */
152type LogLineProps = { line: Line; number: number; query?: string; timestamps?: boolean; anchor?: string; targeted?: boolean };
153
154const LogLine = memo(function LogLine({ line, number, query = "", timestamps = false, anchor, targeted = false }: LogLineProps) {
155 const label = /^(Error|Warning|Notice): /;
156 const text = line.text.replace(label, "");
157 const ansi = line.ansi?.replace(label, "");
158 const id = anchor ? `${anchor}:${number}` : undefined;
159 return (
160 <div
161 id={id}
162 className={`flex scroll-mt-24 gap-4 px-4 hover:bg-raised/40 ${targeted ? "bg-accent/15" : line.kind === "error" ? "bg-danger/5" : ""}`}
163 >
164 {id ? (
165 <a href={`#${id}`} className="w-8 shrink-0 select-none text-right text-faint/70 hover:text-fg hover:underline">
166 {number}
167 </a>
168 ) : (
169 <span className="w-8 shrink-0 select-none text-right text-faint/70">{number}</span>
170 )}
171 {timestamps && (
172 <time dateTime={line.at} className="w-[24ch] shrink-0 select-none text-faint/70 tabular-nums">
173 {line.at ? shownTime(line.at) : ""}
174 </time>
175 )}
176 <span className={`min-w-0 whitespace-pre-wrap break-all ${LINE_STYLE[line.kind]}`}>
177 {line.kind === "error" && <span className="font-semibold">Error: </span>}
178 {line.kind === "warning" && <span className="font-semibold">Warning: </span>}
179 <LineText text={text} ansi={ansi} query={query} />
180 </span>
181 </div>
182 );
183}, sameLine);
184
185/** Whether a line would draw the same: a log that grows keeps its lines, but a search finds them again. */
186function sameLine(a: LogLineProps, b: LogLineProps): boolean {
187 return (
188 a.number === b.number &&
189 a.query === b.query &&
190 a.timestamps === b.timestamps &&
191 a.anchor === b.anchor &&
192 a.targeted === b.targeted &&
193 a.line === b.line
194 );
195}
196
197/** A group's title: a row that folds and opens the lines under it. */
198function GroupRow({ row, timestamps, onToggle }: { row: Extract<Row, { kind: "group" }>; timestamps: boolean; onToggle: (block: number) => void }) {
199 return (
200 <button
201 type="button"
202 aria-expanded={row.open}
203 onClick={() => onToggle(row.block)}
204 className="flex w-full cursor-pointer items-center gap-1.5 px-4 text-left text-fg/85 hover:bg-raised/40"
205 >
206 <span className="w-8 shrink-0" />
207 {timestamps && (
208 <time dateTime={row.at} className="ml-2.5 w-[24ch] shrink-0 select-none text-faint/70 tabular-nums">
209 {row.at ? shownTime(row.at) : ""}
210 </time>
211 )}
212 <ChevronRight size={12} className={`shrink-0 text-faint transition-transform ${row.open ? "rotate-90" : ""}`} />
213 <span className="min-w-0 whitespace-pre-wrap break-all">{row.title}</span>
214 </button>
215 );
216}
217
218/**
219 * A step's log. With `query`, only the lines holding it (any case), groups
220 * opened, each match marked. With `timestamps`, each line's time beside it.
221 * `anchor` names the step in each line's link (`step:3` gives `#step:3:12`),
222 * and `target` is the line such a link names: its group opens, and it is
223 * scrolled to and marked. A log of more than a few thousand rows draws only
224 * those on screen (lib/log-window.ts).
225 */
226export function LogText({
227 log,
228 query = "",
229 timestamps = false,
230 anchor,
231 target = null,
232}: {
233 log: StepLog;
234 query?: string;
235 timestamps?: boolean;
236 anchor?: string;
237 target?: number | null;
238}) {
239 const [open, setOpen] = useState<ReadonlySet<number>>(() => new Set());
240 const searching = query.trim().length > 0;
241 const found = useMemo(() => searchStepLog(log, query), [log, query]);
242 const rows = useMemo<Row[]>(() => (searching ? found.map((entry) => ({ kind: "line", entry })) : logRows(log, open)), [searching, found, log, open]);
243 // A link to a line in a folded group opens the group.
244 const targetGroup = target === null ? null : groupOf(log, target);
245 useEffect(() => {
246 if (targetGroup !== null) setOpen((known) => (known.has(targetGroup) ? known : new Set(known).add(targetGroup)));
247 }, [targetGroup]);
248 const toggle = useCallback((block: number) => {
249 setOpen((known) => {
250 const next = new Set(known);
251 if (!next.delete(block)) next.add(block);
252 return next;
253 });
254 }, []);
255 if (!log.hasText && !log.tail) return <p className="px-4 py-2 text-xs text-faint">No output.</p>;
256 return <LogRows rows={rows} query={searching ? query : ""} timestamps={timestamps} anchor={anchor} target={target} onToggle={toggle} />;
257}
258
259/** The rows of a step's log, every one, or past `WINDOW_FROM` those on screen and a few around them. */
260function LogRows({
261 rows,
262 query,
263 timestamps,
264 anchor,
265 target,
266 onToggle,
267}: {
268 rows: Row[];
269 query: string;
270 timestamps: boolean;
271 anchor?: string;
272 target: number | null;
273 onToggle: (block: number) => void;
274}) {
275 const windowed = rows.length > WINDOW_FROM;
276 const box = useRef<HTMLDivElement>(null);
277 /** Each drawn row's height, by its key, as it was last measured. */
278 const heights = useRef(new Map<string, number>());
279 const [measured, setMeasured] = useState(0);
280 /** The part of the log on screen, from its top, in steps of `VIEW_STEP` pixels. */
281 const [view, setView] = useState<[number, number]>([0, 0]);
282 const starts = useMemo(
283 () => (windowed ? rowStarts(rows.length, (index) => heights.current.get(rowKey(rows[index]!))) : null),
284 // `measured` changes when a row's height does.
285 // eslint-disable-next-line react-hooks/exhaustive-deps
286 [windowed, rows, measured],
287 );
288 const [from, to] = starts ? windowOf(starts, view[0], view[1]) : [0, rows.length];
289 // Follow the screen: the view, as the window scrolls or is resized.
290 useEffect(() => {
291 if (!windowed) return;
292 let frame = 0;
293 const look = () => {
294 frame = 0;
295 const rect = box.current?.getBoundingClientRect();
296 if (!rect) return;
297 const top = Math.floor(-rect.top / VIEW_STEP) * VIEW_STEP;
298 const bottom = Math.ceil((window.innerHeight - rect.top) / VIEW_STEP) * VIEW_STEP;
299 setView((known) => (known[0] === top && known[1] === bottom ? known : [top, bottom]));
300 };
301 const soon = () => {
302 if (!frame) frame = requestAnimationFrame(look);
303 };
304 look();
305 window.addEventListener("scroll", soon, { passive: true });
306 window.addEventListener("resize", soon);
307 // A step folded or opened moves the log without a scroll.
308 document.addEventListener("toggle", soon, true);
309 return () => {
310 if (frame) cancelAnimationFrame(frame);
311 window.removeEventListener("scroll", soon);
312 window.removeEventListener("resize", soon);
313 document.removeEventListener("toggle", soon, true);
314 };
315 }, [windowed]);
316 // Measure what was drawn; a row whose height is not what was kept for it
317 // has its space corrected.
318 useLayoutEffect(() => {
319 if (!windowed || !box.current) return;
320 let changed = false;
321 for (const element of box.current.querySelectorAll<HTMLElement>("[data-row]")) {
322 const height = element.offsetHeight;
323 const key = element.dataset.row!;
324 if (height > 0 && heights.current.get(key) !== height) {
325 heights.current.set(key, height);
326 changed = true;
327 }
328 }
329 if (changed) setMeasured((n) => n + 1);
330 });
331 // A link to a line: scroll to it once it is in the log.
332 const targetRow = target === null ? -1 : rows.findIndex((row) => row.kind === "line" && row.entry.number === target);
333 const scrolledTo = useRef<number | null>(null);
334 useLayoutEffect(() => {
335 if (targetRow === -1 || scrolledTo.current === target || !box.current) return;
336 scrolledTo.current = target;
337 const top = box.current.getBoundingClientRect().top + window.scrollY + (starts ? starts[targetRow]! : 0);
338 if (starts) window.scrollTo({ top: Math.max(0, top - window.innerHeight / 3) });
339 else document.getElementById(`${anchor}:${target}`)?.scrollIntoView({ block: "center" });
340 }, [targetRow, target, starts, anchor]);
341 const drawn = rows.slice(from, to).map((row) => {
342 const key = rowKey(row);
343 return (
344 <div key={key} data-row={windowed ? key : undefined}>
345 {row.kind === "line" ? (
346 <LogLine
347 line={row.entry.line}
348 number={row.entry.number}
349 query={query}
350 timestamps={timestamps}
351 anchor={anchor}
352 targeted={row.entry.number === target}
353 />
354 ) : (
355 <GroupRow row={row} timestamps={timestamps} onToggle={onToggle} />
356 )}
357 </div>
358 );
359 });
360 return (
361 <div ref={box} className="py-1 font-mono text-xs leading-5">
362 {starts && <div aria-hidden style={{ height: starts[from] }} />}
363 {drawn}
364 {starts && <div aria-hidden style={{ height: starts[rows.length]! - starts[to]! }} />}
365 </div>
366 );
367}
368
369/** How finely the view of a windowed log is followed, in pixels: a scroll of less draws nothing new. */
370const VIEW_STEP = 200;
371
372/** A job's log as the page has it: each step's log, read as it came (0 is the job's setup), when each last grew, and whether asking for more is failing. */
373export type JobLog = {
374 byStep: Map<number, StepLog>;
375 /** When a chunk of each step's last arrived, by this browser's clock. */
376 grewAt: Map<number, number>;
377 /** The last ask failed, and the page is waiting to ask again. */
378 retrying: boolean;
379};
380
381/**
382 * A job's log, fetched as it grows (lib/log-poll.ts says how often), split
383 * by step. A failed fetch is tried again, waiting longer each time; while
384 * the tab is hidden it waits, and asks at once when it is shown again.
385 * `running` is whether one of the job's steps is running now.
386 */
387export function useJobLog(url: string, live: boolean, running: boolean): JobLog {
388 // Each chunk is read once, into its step's log, as it arrives: a long
389 // step's log is not read again from its start for every chunk.
390 const steps = useRef(new Map<number, StepLog>());
391 const [byStep, setByStep] = useState<Map<number, StepLog>>(new Map());
392 const [grewAt, setGrewAt] = useState<Map<number, number>>(new Map());
393 const [retrying, setRetrying] = useState(false);
394 const after = useRef(0);
395 const runningNow = useRef(running);
396 runningNow.current = running;
397 useEffect(() => {
398 after.current = 0;
399 steps.current = new Map();
400 setByStep(new Map());
401 setGrewAt(new Map());
402 }, [url]);
403 useEffect(() => {
404 let stopped = false;
405 let busy = false;
406 let failures = 0;
407 let timer: ReturnType<typeof setTimeout> | undefined;
408 const schedule = (delayMs: number | null) => {
409 if (timer) clearTimeout(timer);
410 timer = undefined;
411 if (stopped || delayMs === null) return;
412 timer = setTimeout(load, delayMs);
413 };
414 const load = async () => {
415 timer = undefined;
416 if (stopped || busy) return;
417 // Hidden, a live log waits; it asks again when the tab is shown.
418 if (live && typeof document !== "undefined" && document.visibilityState === "hidden") return;
419 busy = true;
420 let answer: PollAnswer;
421 try {
422 const response = await fetch(`${url}?after=${after.current}`, { cache: "no-store" });
423 if (!response.ok) {
424 answer = { ok: false, status: response.status, retryAfterMs: retryAfterMs(response.headers.get("retry-after")) };
425 } else {
426 const page = (await response.json()) as { chunks: LogChunk[]; done: boolean };
427 if (stopped) return;
428 if (page.chunks.length > 0) {
429 after.current = page.chunks[page.chunks.length - 1]!.seq;
430 const now = Date.now();
431 for (const chunk of page.chunks) steps.current.set(chunk.step, appendLog(steps.current.get(chunk.step) ?? emptyLog(), chunk.text));
432 setByStep(new Map(steps.current));
433 setGrewAt((known) => {
434 const next = new Map(known);
435 for (const chunk of page.chunks) next.set(chunk.step, now);
436 return next;
437 });
438 }
439 answer = { ok: true, done: page.done, full: page.chunks.length >= LOG_PAGE };
440 }
441 } catch {
442 answer = { ok: false };
443 } finally {
444 busy = false;
445 }
446 if (stopped) return;
447 const next = nextPoll(answer, { failures, live, running: runningNow.current });
448 failures = next.failures;
449 setRetrying(!answer.ok && next.delayMs !== null);
450 schedule(next.delayMs);
451 };
452 const shown = () => {
453 if (document.visibilityState === "visible" && !timer && !busy && live) load();
454 else if (document.visibilityState === "visible" && timer && live) schedule(0);
455 };
456 load();
457 document.addEventListener("visibilitychange", shown);
458 return () => {
459 stopped = true;
460 if (timer) clearTimeout(timer);
461 document.removeEventListener("visibilitychange", shown);
462 };
463 }, [url, live]);
464 return { byStep, grewAt, retrying };
465}
466
467/** The most chunks one answer of the log carries (services/actions, `logs`). */
468const LOG_PAGE = 500;
469
470/** Now, as a number, again every `everyMs` while `on`: for times that count up as you watch. */
471export function useNow(on: boolean, everyMs = 1000): number {
472 const [now, setNow] = useState(() => Date.now());
473 useEffect(() => {
474 if (!on) return;
475 setNow(Date.now());
476 const timer = setInterval(() => setNow(Date.now()), everyMs);
477 return () => clearInterval(timer);
478 }, [on, everyMs]);
479 return now;
480}
481
482const NOTE_ICON = {
483 info: <Info size={14} className="mt-0.5 shrink-0 text-muted" />,
484 warning: <AlertTriangle size={14} className="mt-0.5 shrink-0 text-warn" />,
485 unsupported: <CircleSlash size={14} className="mt-0.5 shrink-0 text-danger" />,
486};
487
488/** What in a workflow runs differently on g1t, worst first. */
489export function Notes({ notes }: { notes: WorkflowNote[] }) {
490 if (notes.length === 0) return null;
491 const order = { unsupported: 0, warning: 1, info: 2 };
492 const sorted = [...notes].sort((a, b) => order[a.severity] - order[b.severity]);
493 const blocking = notes.filter((n) => n.severity === "unsupported").length;
494 return (
495 <Card asChild className="group">
496 <details open={blocking > 0}>
497 <summary className="cursor-pointer list-none px-4 py-2.5 text-sm">
498 <span className="font-medium">How this runs on g1t</span>
499 <span className="text-muted">
500 {" · "}
501 {blocking > 0 ? `${blocking} thing${blocking === 1 ? "" : "s"} g1t cannot run yet` : `${notes.length} note${notes.length === 1 ? "" : "s"}`}
502 </span>
503 </summary>
504 <ul className="space-y-2 border-t border-line px-4 py-3 text-sm">
505 {sorted.map((note, index) => (
506 <li key={index} className="flex gap-2">
507 {NOTE_ICON[note.severity]}
508 <span>
509 {note.job && <span className="font-mono text-xs text-muted">{note.job}: </span>}
510 {note.message}
511 </span>
512 </li>
513 ))}
514 </ul>
515 </details>
516 </Card>
517 );
518}
519
520
521/** What workflow runs said about a pull request's head, each linking to its run. */
522export function WorkflowStatuses({ statuses }: { statuses: CommitStatus[] }) {
523 if (statuses.length === 0) return null;
524 const standing = (state: CommitStatus["state"]) =>
525 state === "pending"
526 ? { status: "in_progress", conclusion: null }
527 : { status: "completed", conclusion: (state === "success" ? "success" : "failure") as Conclusion };
528 const failed = statuses.filter((s) => s.state === "failure" || s.state === "error").length;
529 const pending = statuses.filter((s) => s.state === "pending").length;
530 return (
531 <Card asChild className="p-4">
532 <section>
533 <h3 className="text-sm font-medium">
534 {failed > 0 ? `${failed} workflow${failed === 1 ? "" : "s"} failed` : pending > 0 ? "Workflows running" : "Workflows passed"}
535 </h3>
536 <ul className="mt-3 space-y-2 text-sm">
537 {statuses.map((status) => {
538 const path = status.targetUrl?.replace(/^https:\/\/g1t\.sh/, "") ?? null;
539 const row = (
540 <>
541 <StatusIcon {...standing(status.state)} size={14} />
542 <span className="min-w-0 truncate">{status.context}</span>
543 </>
544 );
545 return (
546 <li key={status.context}>
547 {path ? (
548 <Hint label={status.description}>
549 <Link to={path} className="flex items-center gap-2 hover:text-fg">
550 {row}
551 </Link>
552 </Hint>
553 ) : (
554 <span className="flex items-center gap-2">{row}</span>
555 )}
556 </li>
557 );
558 })}
559 </ul>
560 </section>
561 </Card>
562 );
563}