g1t/apps/web/app/components/actions.tsx
| 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 === "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. */ |
| 49 | export 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. */ |
| 60 | export 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 | |
| 66 | type Line = { kind: "text" | "error" | "warning" | "notice" | "debug" | "command"; text: string }; |
| 67 | type Block = { kind: "line"; line: Line; number: number } | { kind: "group"; title: string; lines: { line: Line; number: number }[] }; |
| 68 | |
| 69 | function 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. */ |
| 80 | function 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 | |
| 103 | const 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 | |
| 112 | function 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 | |
| 125 | export 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 | */ |
| 154 | export 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 | |
| 198 | const 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. */ |
| 205 | export 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. */ |
| 236 | export 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 | } |