| 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, Hourglass, Info, Loader2, ShieldAlert, X } from "lucide-react"; |
| 7 | import { Fragment, memo, 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 | import { Card } from "./ui/card"; |
| 13 | import { Hint } from "./ui/hint"; |
| 14 | import { markSpans, parseAnsi, spanLook } from "../lib/ansi"; |
| 15 | import { type Line, blocks, searchLog } from "../lib/log-lines"; |
| 16 | import { 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 | */ |
| 24 | type 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. */ |
| 27 | function 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. */ |
| 35 | export 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 | |
| 66 | export 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. */ |
| 79 | export 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 | */ |
| 94 | export 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. */ |
| 103 | export 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 | |
| 109 | const 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. */ |
| 119 | function 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. */ |
| 124 | function 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. */ |
| 151 | type LogLineProps = { line: Line; number: number; query?: string; timestamps?: boolean }; |
| 152 | |
| 153 | const 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. */ |
| 175 | function 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 | */ |
| 191 | export 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. */ |
| 232 | export 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 | */ |
| 246 | export 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`). */ |
| 327 | const LOG_PAGE = 500; |
| 328 | |
| 329 | /** Now, as a number, again every `everyMs` while `on`: for times that count up as you watch. */ |
| 330 | export 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 | |
| 341 | const 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. */ |
| 348 | export 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. */ |
| 381 | export 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 | } |