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