Skip to content
422 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, useEffect, 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, blocks, searchLog } from "../lib/log-lines";
16import { type PollAnswer, nextPoll, retryAfterMs } from "../lib/log-poll";
17
18/**
19 * Where a run, job or step stands. `of` says which: a run's `pending` waits
20 * for its concurrency group and its `waiting` for its environments' rules,
21 * while a job's `pending` is held at its environment and its `waiting`
22 * waits for the jobs it needs. `environment` is the job's.
23 */
24type Standing = { status: string; conclusion: Conclusion | null; of?: "run" | "job"; environment?: string | null };
25
26/** Held until a person (or a wait timer) lets it through. */
27function heldWord({ status, of = "run", environment }: Standing): string | null {
28 if (of === "run" && status === "action_required") return "Approval required";
29 if (of === "run" && status === "waiting") return "Waiting for review";
30 if (of === "job" && status === "pending") return environment ? `Waiting to deploy to ${environment}` : "Waiting at its environment";
31 return null;
32}
33
34/** One icon for where a run, job or step stands. */
35export function StatusIcon({ status, conclusion, of = "run", environment, size = 16 }: Standing & { size?: number }) {
36 if (status === "in_progress") return <Loader2 size={size} className="shrink-0 animate-spin text-warn" aria-label="Running" />;
37 const held = heldWord({ status, conclusion, of, environment });
38 if (held)
39 return status === "action_required" ? (
40 <ShieldAlert size={size} className="shrink-0 text-warn" aria-label={held} />
41 ) : (
42 <Hourglass size={size} className="shrink-0 text-warn" aria-label={held} />
43 );
44 if (status !== "completed")
45 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 = look.className || look.style ? (
132 <span className={look.className} style={look.style}>
133 {piece.text}
134 </span>
135 ) : (
136 piece.text
137 );
138 return piece.match ? (
139 <mark key={index} className="rounded-sm bg-accent/30 text-fg">
140 {drawn}
141 </mark>
142 ) : (
143 <Fragment key={index}>{drawn}</Fragment>
144 );
145 })}
146 </>
147 );
148}
149
150/** One line of a step's log: its number, its time when asked for, and its text. Drawn again only when it changes. */
151type LogLineProps = { line: Line; number: number; query?: string; timestamps?: boolean };
152
153const LogLine = memo(function LogLine({ line, number, query = "", timestamps = false }: LogLineProps) {
154 const label = /^(Error|Warning|Notice): /;
155 const text = line.text.replace(label, "");
156 const ansi = line.ansi?.replace(label, "");
157 return (
158 <div className={`flex gap-4 px-4 hover:bg-raised/40 ${line.kind === "error" ? "bg-danger/5" : ""}`}>
159 <span className="w-8 shrink-0 select-none text-right text-faint/70">{number}</span>
160 {timestamps && (
161 <time dateTime={line.at} className="w-[24ch] shrink-0 select-none text-faint/70 tabular-nums">
162 {line.at ? shownTime(line.at) : ""}
163 </time>
164 )}
165 <span className={`min-w-0 whitespace-pre-wrap break-all ${LINE_STYLE[line.kind]}`}>
166 {line.kind === "error" && <span className="font-semibold">Error: </span>}
167 {line.kind === "warning" && <span className="font-semibold">Warning: </span>}
168 <LineText text={text} ansi={ansi} query={query} />
169 </span>
170 </div>
171 );
172}, sameLine);
173
174/** Whether a line would draw the same: a log read again as it grows makes new objects of the lines it had. */
175function sameLine(a: LogLineProps, b: LogLineProps): boolean {
176 return (
177 a.number === b.number &&
178 a.query === b.query &&
179 a.timestamps === b.timestamps &&
180 a.line.kind === b.line.kind &&
181 a.line.text === b.line.text &&
182 a.line.ansi === b.line.ansi &&
183 a.line.at === b.line.at
184 );
185}
186
187/**
188 * A step's log. With `query`, only the lines holding it (any case), groups
189 * opened, each match marked. With `timestamps`, each line's time beside it.
190 */
191export function LogText({ text, query = "", timestamps = false }: { text: string; query?: string; timestamps?: boolean }) {
192 const parsed = useMemo(() => blocks(text), [text]);
193 const found = useMemo(() => searchLog(text, query), [text, query]);
194 if (!text.trim()) return <p className="px-4 py-2 text-xs text-faint">No output.</p>;
195 if (query.trim()) {
196 return (
197 <div className="py-1 font-mono text-xs leading-5">
198 {found.map(({ line, number }) => (
199 <LogLine key={number} line={line} number={number} query={query} timestamps={timestamps} />
200 ))}
201 </div>
202 );
203 }
204 return (
205 <div className="py-1 font-mono text-xs leading-5">
206 {parsed.map((block, index) =>
207 block.kind === "line" ? (
208 <LogLine key={index} line={block.line} number={block.number} timestamps={timestamps} />
209 ) : (
210 <details key={index} className="group/log">
211 <summary className="flex cursor-pointer list-none items-center gap-1.5 px-4 text-fg/85 hover:bg-raised/40">
212 <span className="w-8 shrink-0" />
213 {timestamps && (
214 <time dateTime={block.at} className="ml-2.5 w-[24ch] shrink-0 select-none text-faint/70 tabular-nums">
215 {block.at ? shownTime(block.at) : ""}
216 </time>
217 )}
218 <ChevronRight size={12} className="shrink-0 text-faint transition-transform group-open/log:rotate-90" />
219 {block.title}
220 </summary>
221 {block.lines.map(({ line, number }) => (
222 <LogLine key={number} line={line} number={number} timestamps={timestamps} />
223 ))}
224 </details>
225 ),
226 )}
227 </div>
228 );
229}
230
231/** A job's log as the page has it: each step's text (0 is the job's setup), when each last grew, and whether asking for more is failing. */
232export type JobLog = {
233 byStep: Map<number, string>;
234 /** When a chunk of each step's last arrived, by this browser's clock. */
235 grewAt: Map<number, number>;
236 /** The last ask failed, and the page is waiting to ask again. */
237 retrying: boolean;
238};
239
240/**
241 * A job's log, fetched as it grows (lib/log-poll.ts says how often), split
242 * by step. A failed fetch is tried again, waiting longer each time; while
243 * the tab is hidden it waits, and asks at once when it is shown again.
244 * `running` is whether one of the job's steps is running now.
245 */
246export function useJobLog(url: string, live: boolean, running: boolean): JobLog {
247 const [chunks, setChunks] = useState<LogChunk[]>([]);
248 const [grewAt, setGrewAt] = useState<Map<number, number>>(new Map());
249 const [retrying, setRetrying] = useState(false);
250 const after = useRef(0);
251 const runningNow = useRef(running);
252 runningNow.current = running;
253 useEffect(() => {
254 after.current = 0;
255 setChunks([]);
256 setGrewAt(new Map());
257 }, [url]);
258 useEffect(() => {
259 let stopped = false;
260 let busy = false;
261 let failures = 0;
262 let timer: ReturnType<typeof setTimeout> | undefined;
263 const schedule = (delayMs: number | null) => {
264 if (timer) clearTimeout(timer);
265 timer = undefined;
266 if (stopped || delayMs === null) return;
267 timer = setTimeout(load, delayMs);
268 };
269 const load = async () => {
270 timer = undefined;
271 if (stopped || busy) return;
272 // Hidden, a live log waits; it asks again when the tab is shown.
273 if (live && typeof document !== "undefined" && document.visibilityState === "hidden") return;
274 busy = true;
275 let answer: PollAnswer;
276 try {
277 const response = await fetch(`${url}?after=${after.current}`, { cache: "no-store" });
278 if (!response.ok) {
279 answer = { ok: false, status: response.status, retryAfterMs: retryAfterMs(response.headers.get("retry-after")) };
280 } else {
281 const page = (await response.json()) as { chunks: LogChunk[]; done: boolean };
282 if (stopped) return;
283 if (page.chunks.length > 0) {
284 after.current = page.chunks[page.chunks.length - 1]!.seq;
285 const now = Date.now();
286 setChunks((known) => [...known, ...page.chunks]);
287 setGrewAt((known) => {
288 const next = new Map(known);
289 for (const chunk of page.chunks) next.set(chunk.step, now);
290 return next;
291 });
292 }
293 answer = { ok: true, done: page.done, full: page.chunks.length >= LOG_PAGE };
294 }
295 } catch {
296 answer = { ok: false };
297 } finally {
298 busy = false;
299 }
300 if (stopped) return;
301 const next = nextPoll(answer, { failures, live, running: runningNow.current });
302 failures = next.failures;
303 setRetrying(!answer.ok && next.delayMs !== null);
304 schedule(next.delayMs);
305 };
306 const shown = () => {
307 if (document.visibilityState === "visible" && !timer && !busy && live) load();
308 else if (document.visibilityState === "visible" && timer && live) schedule(0);
309 };
310 load();
311 document.addEventListener("visibilitychange", shown);
312 return () => {
313 stopped = true;
314 if (timer) clearTimeout(timer);
315 document.removeEventListener("visibilitychange", shown);
316 };
317 }, [url, live]);
318 const byStep = useMemo(() => {
319 const byStep = new Map<number, string>();
320 for (const chunk of chunks) byStep.set(chunk.step, (byStep.get(chunk.step) ?? "") + chunk.text);
321 return byStep;
322 }, [chunks]);
323 return { byStep, grewAt, retrying };
324}
325
326/** The most chunks one answer of the log carries (services/actions, `logs`). */
327const LOG_PAGE = 500;
328
329/** Now, as a number, again every `everyMs` while `on`: for times that count up as you watch. */
330export function useNow(on: boolean, everyMs = 1000): number {
331 const [now, setNow] = useState(() => Date.now());
332 useEffect(() => {
333 if (!on) return;
334 setNow(Date.now());
335 const timer = setInterval(() => setNow(Date.now()), everyMs);
336 return () => clearInterval(timer);
337 }, [on, everyMs]);
338 return now;
339}
340
341const NOTE_ICON = {
342 info: <Info size={14} className="mt-0.5 shrink-0 text-muted" />,
343 warning: <AlertTriangle size={14} className="mt-0.5 shrink-0 text-warn" />,
344 unsupported: <CircleSlash size={14} className="mt-0.5 shrink-0 text-danger" />,
345};
346
347/** What in a workflow runs differently on g1t, worst first. */
348export function Notes({ notes }: { notes: WorkflowNote[] }) {
349 if (notes.length === 0) return null;
350 const order = { unsupported: 0, warning: 1, info: 2 };
351 const sorted = [...notes].sort((a, b) => order[a.severity] - order[b.severity]);
352 const blocking = notes.filter((n) => n.severity === "unsupported").length;
353 return (
354 <Card asChild className="group">
355 <details open={blocking > 0}>
356 <summary className="cursor-pointer list-none px-4 py-2.5 text-sm">
357 <span className="font-medium">How this runs on g1t</span>
358 <span className="text-muted">
359 {" · "}
360 {blocking > 0 ? `${blocking} thing${blocking === 1 ? "" : "s"} g1t cannot run yet` : `${notes.length} note${notes.length === 1 ? "" : "s"}`}
361 </span>
362 </summary>
363 <ul className="space-y-2 border-t border-line px-4 py-3 text-sm">
364 {sorted.map((note, index) => (
365 <li key={index} className="flex gap-2">
366 {NOTE_ICON[note.severity]}
367 <span>
368 {note.job && <span className="font-mono text-xs text-muted">{note.job}: </span>}
369 {note.message}
370 </span>
371 </li>
372 ))}
373 </ul>
374 </details>
375 </Card>
376 );
377}
378
379
380/** What workflow runs said about a pull request's head, each linking to its run. */
381export function WorkflowStatuses({ statuses }: { statuses: CommitStatus[] }) {
382 if (statuses.length === 0) return null;
383 const standing = (state: CommitStatus["state"]) =>
384 state === "pending"
385 ? { status: "in_progress", conclusion: null }
386 : { status: "completed", conclusion: (state === "success" ? "success" : "failure") as Conclusion };
387 const failed = statuses.filter((s) => s.state === "failure" || s.state === "error").length;
388 const pending = statuses.filter((s) => s.state === "pending").length;
389 return (
390 <Card asChild className="p-4">
391 <section>
392 <h3 className="text-sm font-medium">
393 {failed > 0 ? `${failed} workflow${failed === 1 ? "" : "s"} failed` : pending > 0 ? "Workflows running" : "Workflows passed"}
394 </h3>
395 <ul className="mt-3 space-y-2 text-sm">
396 {statuses.map((status) => {
397 const path = status.targetUrl?.replace(/^https:\/\/g1t\.sh/, "") ?? null;
398 const row = (
399 <>
400 <StatusIcon {...standing(status.state)} size={14} />
401 <span className="min-w-0 truncate">{status.context}</span>
402 </>
403 );
404 return (
405 <li key={status.context}>
406 {path ? (
407 <Hint label={status.description}>
408 <Link to={path} className="flex items-center gap-2 hover:text-fg">
409 {row}
410 </Link>
411 </Hint>
412 ) : (
413 <span className="flex items-center gap-2">{row}</span>
414 )}
415 </li>
416 );
417 })}
418 </ul>
419 </section>
420 </Card>
421 );
422}