| 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, useCallback, useEffect, useLayoutEffect, 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, type Row, type StepLog, appendLog, emptyLog, groupOf, logRows, rowKey, searchStepLog } from "../lib/log-lines"; |
| 16 | import { WINDOW_FROM, rowStarts, windowOf } from "../lib/log-window"; |
| 17 | import { type PollAnswer, nextPoll, retryAfterMs } from "../lib/log-poll"; |
| 18 | |
| 19 | /** |
| 20 | * Where a run, job or step stands. `of` says which: a run's `pending` waits |
| 21 | * for its concurrency group and its `waiting` for its environments' rules, |
| 22 | * while a job's `pending` is held at its environment and its `waiting` |
| 23 | * waits for the jobs it needs. `environment` is the job's. |
| 24 | */ |
| 25 | type Standing = { status: string; conclusion: Conclusion | null; of?: "run" | "job"; environment?: string | null }; |
| 26 | |
| 27 | /** Held until a person (or a wait timer) lets it through. */ |
| 28 | function heldWord({ status, of = "run", environment }: Standing): string | null { |
| 29 | if (of === "run" && status === "action_required") return "Approval required"; |
| 30 | if (of === "run" && status === "waiting") return "Waiting for review"; |
| 31 | if (of === "job" && status === "pending") return environment ? `Waiting to deploy to ${environment}` : "Waiting at its environment"; |
| 32 | return null; |
| 33 | } |
| 34 | |
| 35 | /** One icon for where a run, job or step stands. */ |
| 36 | export function StatusIcon({ status, conclusion, of = "run", environment, size = 16 }: Standing & { size?: number }) { |
| 37 | if (status === "in_progress") return <Loader2 size={size} className="shrink-0 animate-spin text-warn" aria-label="Running" />; |
| 38 | const held = heldWord({ status, conclusion, of, environment }); |
| 39 | if (held) |
| 40 | return status === "action_required" ? ( |
| 41 | <ShieldAlert size={size} className="shrink-0 text-warn" aria-label={held} /> |
| 42 | ) : ( |
| 43 | <Hourglass size={size} className="shrink-0 text-warn" aria-label={held} /> |
| 44 | ); |
| 45 | if (status !== "completed") |
| 46 | return <Clock size={size} className="shrink-0 text-faint" aria-label={status === "pending" ? "Waiting its turn" : "Queued"} />; |
| 47 | switch (conclusion) { |
| 48 | case "success": |
| 49 | return ( |
| 50 | <span className="inline-flex shrink-0 rounded-full bg-success/15 p-0.5 text-success" aria-label="Succeeded"> |
| 51 | <Check size={size - 4} strokeWidth={3} /> |
| 52 | </span> |
| 53 | ); |
| 54 | case "failure": |
| 55 | return ( |
| 56 | <span className="inline-flex shrink-0 rounded-full bg-danger/15 p-0.5 text-danger" aria-label="Failed"> |
| 57 | <X size={size - 4} strokeWidth={3} /> |
| 58 | </span> |
| 59 | ); |
| 60 | case "cancelled": |
| 61 | return <Ban size={size} className="shrink-0 text-faint" aria-label="Cancelled" />; |
| 62 | default: |
| 63 | return <CircleDashed size={size} className="shrink-0 text-faint" aria-label="Skipped" />; |
| 64 | } |
| 65 | } |
| 66 | |
| 67 | export function standingWord(standing: Standing): string { |
| 68 | const { status, conclusion } = standing; |
| 69 | if (status === "in_progress") return "Running"; |
| 70 | const held = heldWord(standing); |
| 71 | if (held) return held; |
| 72 | if (status === "pending") return "Waiting for its concurrency group"; |
| 73 | if (status === "waiting") return "Waiting for the jobs it needs"; |
| 74 | if (status === "calling") return "Running the workflow it calls"; |
| 75 | if (status !== "completed") return "Queued"; |
| 76 | return { success: "Succeeded", failure: "Failed", cancelled: "Cancelled", skipped: "Skipped" }[conclusion ?? "skipped"]; |
| 77 | } |
| 78 | |
| 79 | /** `1m 12s`, from two times, or from a start until now. */ |
| 80 | export function duration(start: string | null, end: string | null): string { |
| 81 | if (!start) return ""; |
| 82 | const ms = (end ? new Date(end).getTime() : Date.now()) - new Date(start).getTime(); |
| 83 | const seconds = Math.max(0, Math.round(ms / 1000)); |
| 84 | if (seconds < 60) return `${seconds}s`; |
| 85 | const minutes = Math.floor(seconds / 60); |
| 86 | if (minutes < 60) return `${minutes}m ${seconds % 60}s`; |
| 87 | return `${Math.floor(minutes / 60)}h ${minutes % 60}m`; |
| 88 | } |
| 89 | |
| 90 | /** |
| 91 | * How long something ran, as text. While it is still running the server and |
| 92 | * the browser count to a different now, so the text may differ on hydration |
| 93 | * (as TimeAgo's does). |
| 94 | */ |
| 95 | export function Duration({ start, end, className }: { start: string | null; end: string | null; className?: string }) { |
| 96 | return ( |
| 97 | <span className={className} suppressHydrationWarning> |
| 98 | {duration(start, end)} |
| 99 | </span> |
| 100 | ); |
| 101 | } |
| 102 | |
| 103 | /** `main` from `refs/heads/main`, `v1.2` from a tag, `#12` for a pull request. */ |
| 104 | export function shortRef(ref: string): string { |
| 105 | const pull = /^refs\/pull\/(\d+)\//.exec(ref); |
| 106 | if (pull) return `#${pull[1]}`; |
| 107 | return ref.replace(/^refs\/(heads|tags)\//, ""); |
| 108 | } |
| 109 | |
| 110 | const LINE_STYLE: Record<Line["kind"], string> = { |
| 111 | text: "text-fg/85", |
| 112 | error: "text-danger", |
| 113 | warning: "text-warn", |
| 114 | notice: "text-accent", |
| 115 | debug: "text-faint", |
| 116 | command: "text-muted", |
| 117 | }; |
| 118 | |
| 119 | /** A line's time as the log shows it beside the line: GitHub's, to the millisecond. */ |
| 120 | function shownTime(at: string): string { |
| 121 | return at.replace(/(\.\d{3})\d*Z$/, "$1Z"); |
| 122 | } |
| 123 | |
| 124 | /** A line's text, coloured as the tool that printed it coloured it, and with a search's matches marked. */ |
| 125 | function LineText({ text, ansi, query }: { text: string; ansi?: string; query: string }) { |
| 126 | const spans = useMemo(() => (ansi ? parseAnsi(ansi) : [{ text, style: {} }]), [text, ansi]); |
| 127 | const pieces = useMemo(() => markSpans(spans, query), [spans, query]); |
| 128 | return ( |
| 129 | <> |
| 130 | {pieces.map((piece, index) => { |
| 131 | const look = spanLook(piece.style); |
| 132 | const drawn = look.className || look.style ? ( |
| 133 | <span className={look.className} style={look.style}> |
| 134 | {piece.text} |
| 135 | </span> |
| 136 | ) : ( |
| 137 | piece.text |
| 138 | ); |
| 139 | return piece.match ? ( |
| 140 | <mark key={index} className="rounded-sm bg-accent/30 text-fg"> |
| 141 | {drawn} |
| 142 | </mark> |
| 143 | ) : ( |
| 144 | <Fragment key={index}>{drawn}</Fragment> |
| 145 | ); |
| 146 | })} |
| 147 | </> |
| 148 | ); |
| 149 | } |
| 150 | |
| 151 | /** One line of a step's log: its number (a link to it), its time when asked for, and its text. Drawn again only when it changes. */ |
| 152 | type LogLineProps = { line: Line; number: number; query?: string; timestamps?: boolean; anchor?: string; targeted?: boolean }; |
| 153 | |
| 154 | const LogLine = memo(function LogLine({ line, number, query = "", timestamps = false, anchor, targeted = false }: LogLineProps) { |
| 155 | const label = /^(Error|Warning|Notice): /; |
| 156 | const text = line.text.replace(label, ""); |
| 157 | const ansi = line.ansi?.replace(label, ""); |
| 158 | const id = anchor ? `${anchor}:${number}` : undefined; |
| 159 | return ( |
| 160 | <div |
| 161 | id={id} |
| 162 | className={`flex scroll-mt-24 gap-4 px-4 hover:bg-raised/40 ${targeted ? "bg-accent/15" : line.kind === "error" ? "bg-danger/5" : ""}`} |
| 163 | > |
| 164 | {id ? ( |
| 165 | <a href={`#${id}`} className="w-8 shrink-0 select-none text-right text-faint/70 hover:text-fg hover:underline"> |
| 166 | {number} |
| 167 | </a> |
| 168 | ) : ( |
| 169 | <span className="w-8 shrink-0 select-none text-right text-faint/70">{number}</span> |
| 170 | )} |
| 171 | {timestamps && ( |
| 172 | <time dateTime={line.at} className="w-[24ch] shrink-0 select-none text-faint/70 tabular-nums"> |
| 173 | {line.at ? shownTime(line.at) : ""} |
| 174 | </time> |
| 175 | )} |
| 176 | <span className={`min-w-0 whitespace-pre-wrap break-all ${LINE_STYLE[line.kind]}`}> |
| 177 | {line.kind === "error" && <span className="font-semibold">Error: </span>} |
| 178 | {line.kind === "warning" && <span className="font-semibold">Warning: </span>} |
| 179 | <LineText text={text} ansi={ansi} query={query} /> |
| 180 | </span> |
| 181 | </div> |
| 182 | ); |
| 183 | }, sameLine); |
| 184 | |
| 185 | /** Whether a line would draw the same: a log that grows keeps its lines, but a search finds them again. */ |
| 186 | function sameLine(a: LogLineProps, b: LogLineProps): boolean { |
| 187 | return ( |
| 188 | a.number === b.number && |
| 189 | a.query === b.query && |
| 190 | a.timestamps === b.timestamps && |
| 191 | a.anchor === b.anchor && |
| 192 | a.targeted === b.targeted && |
| 193 | a.line === b.line |
| 194 | ); |
| 195 | } |
| 196 | |
| 197 | /** A group's title: a row that folds and opens the lines under it. */ |
| 198 | function GroupRow({ row, timestamps, onToggle }: { row: Extract<Row, { kind: "group" }>; timestamps: boolean; onToggle: (block: number) => void }) { |
| 199 | return ( |
| 200 | <button |
| 201 | type="button" |
| 202 | aria-expanded={row.open} |
| 203 | onClick={() => onToggle(row.block)} |
| 204 | className="flex w-full cursor-pointer items-center gap-1.5 px-4 text-left text-fg/85 hover:bg-raised/40" |
| 205 | > |
| 206 | <span className="w-8 shrink-0" /> |
| 207 | {timestamps && ( |
| 208 | <time dateTime={row.at} className="ml-2.5 w-[24ch] shrink-0 select-none text-faint/70 tabular-nums"> |
| 209 | {row.at ? shownTime(row.at) : ""} |
| 210 | </time> |
| 211 | )} |
| 212 | <ChevronRight size={12} className={`shrink-0 text-faint transition-transform ${row.open ? "rotate-90" : ""}`} /> |
| 213 | <span className="min-w-0 whitespace-pre-wrap break-all">{row.title}</span> |
| 214 | </button> |
| 215 | ); |
| 216 | } |
| 217 | |
| 218 | /** |
| 219 | * A step's log. With `query`, only the lines holding it (any case), groups |
| 220 | * opened, each match marked. With `timestamps`, each line's time beside it. |
| 221 | * `anchor` names the step in each line's link (`step:3` gives `#step:3:12`), |
| 222 | * and `target` is the line such a link names: its group opens, and it is |
| 223 | * scrolled to and marked. A log of more than a few thousand rows draws only |
| 224 | * those on screen (lib/log-window.ts). |
| 225 | */ |
| 226 | export function LogText({ |
| 227 | log, |
| 228 | query = "", |
| 229 | timestamps = false, |
| 230 | anchor, |
| 231 | target = null, |
| 232 | }: { |
| 233 | log: StepLog; |
| 234 | query?: string; |
| 235 | timestamps?: boolean; |
| 236 | anchor?: string; |
| 237 | target?: number | null; |
| 238 | }) { |
| 239 | const [open, setOpen] = useState<ReadonlySet<number>>(() => new Set()); |
| 240 | const searching = query.trim().length > 0; |
| 241 | const found = useMemo(() => searchStepLog(log, query), [log, query]); |
| 242 | const rows = useMemo<Row[]>(() => (searching ? found.map((entry) => ({ kind: "line", entry })) : logRows(log, open)), [searching, found, log, open]); |
| 243 | // A link to a line in a folded group opens the group. |
| 244 | const targetGroup = target === null ? null : groupOf(log, target); |
| 245 | useEffect(() => { |
| 246 | if (targetGroup !== null) setOpen((known) => (known.has(targetGroup) ? known : new Set(known).add(targetGroup))); |
| 247 | }, [targetGroup]); |
| 248 | const toggle = useCallback((block: number) => { |
| 249 | setOpen((known) => { |
| 250 | const next = new Set(known); |
| 251 | if (!next.delete(block)) next.add(block); |
| 252 | return next; |
| 253 | }); |
| 254 | }, []); |
| 255 | if (!log.hasText && !log.tail) return <p className="px-4 py-2 text-xs text-faint">No output.</p>; |
| 256 | return <LogRows rows={rows} query={searching ? query : ""} timestamps={timestamps} anchor={anchor} target={target} onToggle={toggle} />; |
| 257 | } |
| 258 | |
| 259 | /** The rows of a step's log, every one, or past `WINDOW_FROM` those on screen and a few around them. */ |
| 260 | function LogRows({ |
| 261 | rows, |
| 262 | query, |
| 263 | timestamps, |
| 264 | anchor, |
| 265 | target, |
| 266 | onToggle, |
| 267 | }: { |
| 268 | rows: Row[]; |
| 269 | query: string; |
| 270 | timestamps: boolean; |
| 271 | anchor?: string; |
| 272 | target: number | null; |
| 273 | onToggle: (block: number) => void; |
| 274 | }) { |
| 275 | const windowed = rows.length > WINDOW_FROM; |
| 276 | const box = useRef<HTMLDivElement>(null); |
| 277 | /** Each drawn row's height, by its key, as it was last measured. */ |
| 278 | const heights = useRef(new Map<string, number>()); |
| 279 | const [measured, setMeasured] = useState(0); |
| 280 | /** The part of the log on screen, from its top, in steps of `VIEW_STEP` pixels. */ |
| 281 | const [view, setView] = useState<[number, number]>([0, 0]); |
| 282 | const starts = useMemo( |
| 283 | () => (windowed ? rowStarts(rows.length, (index) => heights.current.get(rowKey(rows[index]!))) : null), |
| 284 | // `measured` changes when a row's height does. |
| 285 | // eslint-disable-next-line react-hooks/exhaustive-deps |
| 286 | [windowed, rows, measured], |
| 287 | ); |
| 288 | const [from, to] = starts ? windowOf(starts, view[0], view[1]) : [0, rows.length]; |
| 289 | // Follow the screen: the view, as the window scrolls or is resized. |
| 290 | useEffect(() => { |
| 291 | if (!windowed) return; |
| 292 | let frame = 0; |
| 293 | const look = () => { |
| 294 | frame = 0; |
| 295 | const rect = box.current?.getBoundingClientRect(); |
| 296 | if (!rect) return; |
| 297 | const top = Math.floor(-rect.top / VIEW_STEP) * VIEW_STEP; |
| 298 | const bottom = Math.ceil((window.innerHeight - rect.top) / VIEW_STEP) * VIEW_STEP; |
| 299 | setView((known) => (known[0] === top && known[1] === bottom ? known : [top, bottom])); |
| 300 | }; |
| 301 | const soon = () => { |
| 302 | if (!frame) frame = requestAnimationFrame(look); |
| 303 | }; |
| 304 | look(); |
| 305 | window.addEventListener("scroll", soon, { passive: true }); |
| 306 | window.addEventListener("resize", soon); |
| 307 | // A step folded or opened moves the log without a scroll. |
| 308 | document.addEventListener("toggle", soon, true); |
| 309 | return () => { |
| 310 | if (frame) cancelAnimationFrame(frame); |
| 311 | window.removeEventListener("scroll", soon); |
| 312 | window.removeEventListener("resize", soon); |
| 313 | document.removeEventListener("toggle", soon, true); |
| 314 | }; |
| 315 | }, [windowed]); |
| 316 | // Measure what was drawn; a row whose height is not what was kept for it |
| 317 | // has its space corrected. |
| 318 | useLayoutEffect(() => { |
| 319 | if (!windowed || !box.current) return; |
| 320 | let changed = false; |
| 321 | for (const element of box.current.querySelectorAll<HTMLElement>("[data-row]")) { |
| 322 | const height = element.offsetHeight; |
| 323 | const key = element.dataset.row!; |
| 324 | if (height > 0 && heights.current.get(key) !== height) { |
| 325 | heights.current.set(key, height); |
| 326 | changed = true; |
| 327 | } |
| 328 | } |
| 329 | if (changed) setMeasured((n) => n + 1); |
| 330 | }); |
| 331 | // A link to a line: scroll to it once it is in the log. |
| 332 | const targetRow = target === null ? -1 : rows.findIndex((row) => row.kind === "line" && row.entry.number === target); |
| 333 | const scrolledTo = useRef<number | null>(null); |
| 334 | useLayoutEffect(() => { |
| 335 | if (targetRow === -1 || scrolledTo.current === target || !box.current) return; |
| 336 | scrolledTo.current = target; |
| 337 | const top = box.current.getBoundingClientRect().top + window.scrollY + (starts ? starts[targetRow]! : 0); |
| 338 | if (starts) window.scrollTo({ top: Math.max(0, top - window.innerHeight / 3) }); |
| 339 | else document.getElementById(`${anchor}:${target}`)?.scrollIntoView({ block: "center" }); |
| 340 | }, [targetRow, target, starts, anchor]); |
| 341 | const drawn = rows.slice(from, to).map((row) => { |
| 342 | const key = rowKey(row); |
| 343 | return ( |
| 344 | <div key={key} data-row={windowed ? key : undefined}> |
| 345 | {row.kind === "line" ? ( |
| 346 | <LogLine |
| 347 | line={row.entry.line} |
| 348 | number={row.entry.number} |
| 349 | query={query} |
| 350 | timestamps={timestamps} |
| 351 | anchor={anchor} |
| 352 | targeted={row.entry.number === target} |
| 353 | /> |
| 354 | ) : ( |
| 355 | <GroupRow row={row} timestamps={timestamps} onToggle={onToggle} /> |
| 356 | )} |
| 357 | </div> |
| 358 | ); |
| 359 | }); |
| 360 | return ( |
| 361 | <div ref={box} className="py-1 font-mono text-xs leading-5"> |
| 362 | {starts && <div aria-hidden style={{ height: starts[from] }} />} |
| 363 | {drawn} |
| 364 | {starts && <div aria-hidden style={{ height: starts[rows.length]! - starts[to]! }} />} |
| 365 | </div> |
| 366 | ); |
| 367 | } |
| 368 | |
| 369 | /** How finely the view of a windowed log is followed, in pixels: a scroll of less draws nothing new. */ |
| 370 | const VIEW_STEP = 200; |
| 371 | |
| 372 | /** A job's log as the page has it: each step's log, read as it came (0 is the job's setup), when each last grew, and whether asking for more is failing. */ |
| 373 | export type JobLog = { |
| 374 | byStep: Map<number, StepLog>; |
| 375 | /** When a chunk of each step's last arrived, by this browser's clock. */ |
| 376 | grewAt: Map<number, number>; |
| 377 | /** The last ask failed, and the page is waiting to ask again. */ |
| 378 | retrying: boolean; |
| 379 | }; |
| 380 | |
| 381 | /** |
| 382 | * A job's log, fetched as it grows (lib/log-poll.ts says how often), split |
| 383 | * by step. A failed fetch is tried again, waiting longer each time; while |
| 384 | * the tab is hidden it waits, and asks at once when it is shown again. |
| 385 | * `running` is whether one of the job's steps is running now. |
| 386 | */ |
| 387 | export function useJobLog(url: string, live: boolean, running: boolean): JobLog { |
| 388 | // Each chunk is read once, into its step's log, as it arrives: a long |
| 389 | // step's log is not read again from its start for every chunk. |
| 390 | const steps = useRef(new Map<number, StepLog>()); |
| 391 | const [byStep, setByStep] = useState<Map<number, StepLog>>(new Map()); |
| 392 | const [grewAt, setGrewAt] = useState<Map<number, number>>(new Map()); |
| 393 | const [retrying, setRetrying] = useState(false); |
| 394 | const after = useRef(0); |
| 395 | const runningNow = useRef(running); |
| 396 | runningNow.current = running; |
| 397 | useEffect(() => { |
| 398 | after.current = 0; |
| 399 | steps.current = new Map(); |
| 400 | setByStep(new Map()); |
| 401 | setGrewAt(new Map()); |
| 402 | }, [url]); |
| 403 | useEffect(() => { |
| 404 | let stopped = false; |
| 405 | let busy = false; |
| 406 | let failures = 0; |
| 407 | let timer: ReturnType<typeof setTimeout> | undefined; |
| 408 | const schedule = (delayMs: number | null) => { |
| 409 | if (timer) clearTimeout(timer); |
| 410 | timer = undefined; |
| 411 | if (stopped || delayMs === null) return; |
| 412 | timer = setTimeout(load, delayMs); |
| 413 | }; |
| 414 | const load = async () => { |
| 415 | timer = undefined; |
| 416 | if (stopped || busy) return; |
| 417 | // Hidden, a live log waits; it asks again when the tab is shown. |
| 418 | if (live && typeof document !== "undefined" && document.visibilityState === "hidden") return; |
| 419 | busy = true; |
| 420 | let answer: PollAnswer; |
| 421 | try { |
| 422 | const response = await fetch(`${url}?after=${after.current}`, { cache: "no-store" }); |
| 423 | if (!response.ok) { |
| 424 | answer = { ok: false, status: response.status, retryAfterMs: retryAfterMs(response.headers.get("retry-after")) }; |
| 425 | } else { |
| 426 | const page = (await response.json()) as { chunks: LogChunk[]; done: boolean }; |
| 427 | if (stopped) return; |
| 428 | if (page.chunks.length > 0) { |
| 429 | after.current = page.chunks[page.chunks.length - 1]!.seq; |
| 430 | const now = Date.now(); |
| 431 | for (const chunk of page.chunks) steps.current.set(chunk.step, appendLog(steps.current.get(chunk.step) ?? emptyLog(), chunk.text)); |
| 432 | setByStep(new Map(steps.current)); |
| 433 | setGrewAt((known) => { |
| 434 | const next = new Map(known); |
| 435 | for (const chunk of page.chunks) next.set(chunk.step, now); |
| 436 | return next; |
| 437 | }); |
| 438 | } |
| 439 | answer = { ok: true, done: page.done, full: page.chunks.length >= LOG_PAGE }; |
| 440 | } |
| 441 | } catch { |
| 442 | answer = { ok: false }; |
| 443 | } finally { |
| 444 | busy = false; |
| 445 | } |
| 446 | if (stopped) return; |
| 447 | const next = nextPoll(answer, { failures, live, running: runningNow.current }); |
| 448 | failures = next.failures; |
| 449 | setRetrying(!answer.ok && next.delayMs !== null); |
| 450 | schedule(next.delayMs); |
| 451 | }; |
| 452 | const shown = () => { |
| 453 | if (document.visibilityState === "visible" && !timer && !busy && live) load(); |
| 454 | else if (document.visibilityState === "visible" && timer && live) schedule(0); |
| 455 | }; |
| 456 | load(); |
| 457 | document.addEventListener("visibilitychange", shown); |
| 458 | return () => { |
| 459 | stopped = true; |
| 460 | if (timer) clearTimeout(timer); |
| 461 | document.removeEventListener("visibilitychange", shown); |
| 462 | }; |
| 463 | }, [url, live]); |
| 464 | return { byStep, grewAt, retrying }; |
| 465 | } |
| 466 | |
| 467 | /** The most chunks one answer of the log carries (services/actions, `logs`). */ |
| 468 | const LOG_PAGE = 500; |
| 469 | |
| 470 | /** Now, as a number, again every `everyMs` while `on`: for times that count up as you watch. */ |
| 471 | export function useNow(on: boolean, everyMs = 1000): number { |
| 472 | const [now, setNow] = useState(() => Date.now()); |
| 473 | useEffect(() => { |
| 474 | if (!on) return; |
| 475 | setNow(Date.now()); |
| 476 | const timer = setInterval(() => setNow(Date.now()), everyMs); |
| 477 | return () => clearInterval(timer); |
| 478 | }, [on, everyMs]); |
| 479 | return now; |
| 480 | } |
| 481 | |
| 482 | const NOTE_ICON = { |
| 483 | info: <Info size={14} className="mt-0.5 shrink-0 text-muted" />, |
| 484 | warning: <AlertTriangle size={14} className="mt-0.5 shrink-0 text-warn" />, |
| 485 | unsupported: <CircleSlash size={14} className="mt-0.5 shrink-0 text-danger" />, |
| 486 | }; |
| 487 | |
| 488 | /** What in a workflow runs differently on g1t, worst first. */ |
| 489 | export function Notes({ notes }: { notes: WorkflowNote[] }) { |
| 490 | if (notes.length === 0) return null; |
| 491 | const order = { unsupported: 0, warning: 1, info: 2 }; |
| 492 | const sorted = [...notes].sort((a, b) => order[a.severity] - order[b.severity]); |
| 493 | const blocking = notes.filter((n) => n.severity === "unsupported").length; |
| 494 | return ( |
| 495 | <Card asChild className="group"> |
| 496 | <details open={blocking > 0}> |
| 497 | <summary className="cursor-pointer list-none px-4 py-2.5 text-sm"> |
| 498 | <span className="font-medium">How this runs on g1t</span> |
| 499 | <span className="text-muted"> |
| 500 | {" · "} |
| 501 | {blocking > 0 ? `${blocking} thing${blocking === 1 ? "" : "s"} g1t cannot run yet` : `${notes.length} note${notes.length === 1 ? "" : "s"}`} |
| 502 | </span> |
| 503 | </summary> |
| 504 | <ul className="space-y-2 border-t border-line px-4 py-3 text-sm"> |
| 505 | {sorted.map((note, index) => ( |
| 506 | <li key={index} className="flex gap-2"> |
| 507 | {NOTE_ICON[note.severity]} |
| 508 | <span> |
| 509 | {note.job && <span className="font-mono text-xs text-muted">{note.job}: </span>} |
| 510 | {note.message} |
| 511 | </span> |
| 512 | </li> |
| 513 | ))} |
| 514 | </ul> |
| 515 | </details> |
| 516 | </Card> |
| 517 | ); |
| 518 | } |
| 519 | |
| 520 | |
| 521 | /** What workflow runs said about a pull request's head, each linking to its run. */ |
| 522 | export function WorkflowStatuses({ statuses }: { statuses: CommitStatus[] }) { |
| 523 | if (statuses.length === 0) return null; |
| 524 | const standing = (state: CommitStatus["state"]) => |
| 525 | state === "pending" |
| 526 | ? { status: "in_progress", conclusion: null } |
| 527 | : { status: "completed", conclusion: (state === "success" ? "success" : "failure") as Conclusion }; |
| 528 | const failed = statuses.filter((s) => s.state === "failure" || s.state === "error").length; |
| 529 | const pending = statuses.filter((s) => s.state === "pending").length; |
| 530 | return ( |
| 531 | <Card asChild className="p-4"> |
| 532 | <section> |
| 533 | <h3 className="text-sm font-medium"> |
| 534 | {failed > 0 ? `${failed} workflow${failed === 1 ? "" : "s"} failed` : pending > 0 ? "Workflows running" : "Workflows passed"} |
| 535 | </h3> |
| 536 | <ul className="mt-3 space-y-2 text-sm"> |
| 537 | {statuses.map((status) => { |
| 538 | const path = status.targetUrl?.replace(/^https:\/\/g1t\.sh/, "") ?? null; |
| 539 | const row = ( |
| 540 | <> |
| 541 | <StatusIcon {...standing(status.state)} size={14} /> |
| 542 | <span className="min-w-0 truncate">{status.context}</span> |
| 543 | </> |
| 544 | ); |
| 545 | return ( |
| 546 | <li key={status.context}> |
| 547 | {path ? ( |
| 548 | <Hint label={status.description}> |
| 549 | <Link to={path} className="flex items-center gap-2 hover:text-fg"> |
| 550 | {row} |
| 551 | </Link> |
| 552 | </Hint> |
| 553 | ) : ( |
| 554 | <span className="flex items-center gap-2">{row}</span> |
| 555 | )} |
| 556 | </li> |
| 557 | ); |
| 558 | })} |
| 559 | </ul> |
| 560 | </section> |
| 561 | </Card> |
| 562 | ); |
| 563 | } |