Skip to content

g1t/apps/web/app/components/actions.tsx

277 lines11,101 bytesCodeBlame
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, Info, Loader2, X } from "lucide-react";
7import { useEffect, useMemo, useRef, useState } from "react";
8import { Link } from "react-router";
9
10import type { CommitStatus, Conclusion, LogChunk, WorkflowNote } from "@g1t/contracts";
11
12import { Hint } from "./ui/hint";
13
14type Standing = { status: string; conclusion: Conclusion | null };
15
16/** One icon for where a run, job or step stands. */
17export function StatusIcon({ status, conclusion, size = 16 }: Standing & { size?: number }) {
18 if (status === "in_progress") return <Loader2 size={size} className="shrink-0 animate-spin text-warn" aria-label="Running" />;
19 if (status !== "completed")
20 return <Clock size={size} className="shrink-0 text-faint" aria-label={status === "pending" ? "Waiting its turn" : "Queued"} />;
21 switch (conclusion) {
22 case "success":
23 return (
24 <span className="inline-flex shrink-0 rounded-full bg-success/15 p-0.5 text-success" aria-label="Succeeded">
25 <Check size={size - 4} strokeWidth={3} />
26 </span>
27 );
28 case "failure":
29 return (
30 <span className="inline-flex shrink-0 rounded-full bg-danger/15 p-0.5 text-danger" aria-label="Failed">
31 <X size={size - 4} strokeWidth={3} />
32 </span>
33 );
34 case "cancelled":
35 return <Ban size={size} className="shrink-0 text-faint" aria-label="Cancelled" />;
36 default:
37 return <CircleDashed size={size} className="shrink-0 text-faint" aria-label="Skipped" />;
38 }
39}
40
41export function standingWord({ status, conclusion }: Standing): string {
42 if (status === "in_progress") return "Running";
43 if (status === "pending") return "Waiting for its concurrency group";
44 if (status === "waiting") return "Waiting for the jobs it needs";
45 if (status === "calling") return "Running the workflow it calls";
46 if (status !== "completed") return "Queued";
47 return { success: "Succeeded", failure: "Failed", cancelled: "Cancelled", skipped: "Skipped" }[conclusion ?? "skipped"];
48}
49
50/** `1m 12s`, from two times, or from a start until now. */
51export function duration(start: string | null, end: string | null): string {
52 if (!start) return "";
53 const ms = (end ? new Date(end).getTime() : Date.now()) - new Date(start).getTime();
54 const seconds = Math.max(0, Math.round(ms / 1000));
55 if (seconds < 60) return `${seconds}s`;
56 const minutes = Math.floor(seconds / 60);
57 if (minutes < 60) return `${minutes}m ${seconds % 60}s`;
58 return `${Math.floor(minutes / 60)}h ${minutes % 60}m`;
59}
60
61/** `main` from `refs/heads/main`, `v1.2` from a tag, `#12` for a pull request. */
62export function shortRef(ref: string): string {
63 const pull = /^refs\/pull\/(\d+)\//.exec(ref);
64 if (pull) return `#${pull[1]}`;
65 return ref.replace(/^refs\/(heads|tags)\//, "");
66}
67
68type Line = { kind: "text" | "error" | "warning" | "notice" | "debug" | "command"; text: string };
69type Block = { kind: "line"; line: Line; number: number } | { kind: "group"; title: string; lines: { line: Line; number: number }[] };
70
71function classify(raw: string): Line | "group-end" | { group: string } {
72 if (raw.startsWith("##[group]")) return { group: raw.slice(9) };
73 if (raw.startsWith("##[endgroup]")) return "group-end";
74 for (const kind of ["error", "warning", "notice", "debug"] as const) {
75 if (raw.startsWith(`##[${kind}]`)) return { kind, text: raw.slice(kind.length + 4) };
76 }
77 if (raw.startsWith("[command]")) return { kind: "command", text: raw.slice(9) };
78 return { kind: "text", text: raw };
79}
80
81/** Lines into blocks: plain lines, and groups that fold. */
82function blocks(text: string): Block[] {
83 const out: Block[] = [];
84 let group: Extract<Block, { kind: "group" }> | null = null;
85 let number = 0;
86 for (const raw of text.split("\n")) {
87 if (raw === "" && number === 0) continue;
88 const line = classify(raw);
89 if (line === "group-end") {
90 group = null;
91 continue;
92 }
93 if ("group" in line) {
94 group = { kind: "group", title: line.group, lines: [] };
95 out.push(group);
96 continue;
97 }
98 number += 1;
99 if (group) group.lines.push({ line, number });
100 else out.push({ kind: "line", line, number });
101 }
102 return out;
103}
104
105const LINE_STYLE: Record<Line["kind"], string> = {
106 text: "text-fg/85",
107 error: "text-danger",
108 warning: "text-warn",
109 notice: "text-accent",
110 debug: "text-faint",
111 command: "text-muted",
112};
113
114function LogLine({ line, number }: { line: Line; number: number }) {
115 return (
116 <div className={`flex gap-4 px-4 hover:bg-raised/40 ${line.kind === "error" ? "bg-danger/5" : ""}`}>
117 <span className="w-8 shrink-0 select-none text-right text-faint/70">{number}</span>
118 <span className={`min-w-0 whitespace-pre-wrap break-all ${LINE_STYLE[line.kind]}`}>
119 {line.kind === "error" && <span className="font-semibold">Error: </span>}
120 {line.kind === "warning" && <span className="font-semibold">Warning: </span>}
121 {line.text.replace(/^(Error|Warning|Notice): /, "")}
122 </span>
123 </div>
124 );
125}
126
127export function LogText({ text }: { text: string }) {
128 const parsed = useMemo(() => blocks(text), [text]);
129 if (!text.trim()) return <p className="px-4 py-2 text-xs text-faint">No output.</p>;
130 return (
131 <div className="py-1 font-mono text-xs leading-5">
132 {parsed.map((block, index) =>
133 block.kind === "line" ? (
134 <LogLine key={index} line={block.line} number={block.number} />
135 ) : (
136 <details key={index} className="group/log">
137 <summary className="flex cursor-pointer list-none items-center gap-1.5 px-4 text-fg/85 hover:bg-raised/40">
138 <span className="w-8 shrink-0" />
139 <ChevronRight size={12} className="shrink-0 text-faint transition-transform group-open/log:rotate-90" />
140 {block.title}
141 </summary>
142 {block.lines.map(({ line, number }) => (
143 <LogLine key={number} line={line} number={number} />
144 ))}
145 </details>
146 ),
147 )}
148 </div>
149 );
150}
151
152/**
153 * A job's log, fetched as it grows, split by step: `render` gets each
154 * step's text (0 is the job's setup).
155 */
156export function useJobLog(url: string, live: boolean): Map<number, string> {
157 const [chunks, setChunks] = useState<LogChunk[]>([]);
158 const after = useRef(0);
159 useEffect(() => {
160 after.current = 0;
161 setChunks([]);
162 }, [url]);
163 useEffect(() => {
164 let stopped = false;
165 let timer: ReturnType<typeof setTimeout> | undefined;
166 const load = async () => {
167 try {
168 // Pages of up to 500 chunks, until there is no more for now.
169 for (;;) {
170 const response = await fetch(`${url}?after=${after.current}`);
171 if (!response.ok) break;
172 const page = (await response.json()) as { chunks: LogChunk[]; done: boolean };
173 if (stopped) return;
174 if (page.chunks.length > 0) {
175 after.current = page.chunks[page.chunks.length - 1].seq;
176 setChunks((known) => [...known, ...page.chunks]);
177 }
178 if (page.chunks.length < 500) {
179 if (!page.done && live) timer = setTimeout(load, 2000);
180 break;
181 }
182 }
183 } catch {
184 if (!stopped && live) timer = setTimeout(load, 4000);
185 }
186 };
187 load();
188 return () => {
189 stopped = true;
190 if (timer) clearTimeout(timer);
191 };
192 }, [url, live]);
193 return useMemo(() => {
194 const byStep = new Map<number, string>();
195 for (const chunk of chunks) byStep.set(chunk.step, (byStep.get(chunk.step) ?? "") + chunk.text);
196 return byStep;
197 }, [chunks]);
198}
199
200const NOTE_ICON = {
201 info: <Info size={14} className="mt-0.5 shrink-0 text-muted" />,
202 warning: <AlertTriangle size={14} className="mt-0.5 shrink-0 text-warn" />,
203 unsupported: <CircleSlash size={14} className="mt-0.5 shrink-0 text-danger" />,
204};
205
206/** What in a workflow runs differently on g1t, worst first. */
207export function Notes({ notes }: { notes: WorkflowNote[] }) {
208 if (notes.length === 0) return null;
209 const order = { unsupported: 0, warning: 1, info: 2 };
210 const sorted = [...notes].sort((a, b) => order[a.severity] - order[b.severity]);
211 const blocking = notes.filter((n) => n.severity === "unsupported").length;
212 return (
213 <details className="group rounded-xl border border-line bg-surface" open={blocking > 0}>
214 <summary className="cursor-pointer list-none px-4 py-2.5 text-sm">
215 <span className="font-medium">How this runs on g1t</span>
216 <span className="text-muted">
217 {" · "}
218 {blocking > 0 ? `${blocking} thing${blocking === 1 ? "" : "s"} g1t cannot run yet` : `${notes.length} note${notes.length === 1 ? "" : "s"}`}
219 </span>
220 </summary>
221 <ul className="space-y-2 border-t border-line px-4 py-3 text-sm">
222 {sorted.map((note, index) => (
223 <li key={index} className="flex gap-2">
224 {NOTE_ICON[note.severity]}
225 <span>
226 {note.job && <span className="font-mono text-xs text-muted">{note.job}: </span>}
227 {note.message}
228 </span>
229 </li>
230 ))}
231 </ul>
232 </details>
233 );
234}
235
236
237/** What workflow runs said about a pull request's head, each linking to its run. */
238export function WorkflowStatuses({ statuses }: { statuses: CommitStatus[] }) {
239 if (statuses.length === 0) return null;
240 const standing = (state: CommitStatus["state"]) =>
241 state === "pending"
242 ? { status: "in_progress", conclusion: null }
243 : { status: "completed", conclusion: (state === "success" ? "success" : "failure") as Conclusion };
244 const failed = statuses.filter((s) => s.state === "failure" || s.state === "error").length;
245 const pending = statuses.filter((s) => s.state === "pending").length;
246 return (
247 <section className="rounded-xl border border-line bg-surface p-4">
248 <h3 className="text-sm font-medium">
249 {failed > 0 ? `${failed} workflow${failed === 1 ? "" : "s"} failed` : pending > 0 ? "Workflows running" : "Workflows passed"}
250 </h3>
251 <ul className="mt-3 space-y-2 text-sm">
252 {statuses.map((status) => {
253 const path = status.targetUrl?.replace(/^https:\/\/g1t\.sh/, "") ?? null;
254 const row = (
255 <>
256 <StatusIcon {...standing(status.state)} size={14} />
257 <span className="min-w-0 truncate">{status.context}</span>
258 </>
259 );
260 return (
261 <li key={status.context}>
262 {path ? (
263 <Hint label={status.description}>
264 <Link to={path} className="flex items-center gap-2 hover:text-fg">
265 {row}
266 </Link>
267 </Hint>
268 ) : (
269 <span className="flex items-center gap-2">{row}</span>
270 )}
271 </li>
272 );
273 })}
274 </ul>
275 </section>
276 );
277}