flagon-io/g1t

public

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

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

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