Skip to content

Where people and agents ship software together. The open-source git platform for the whole job: issues, agents, checks and deploys to the edge.

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