Run page: job summaries, attempts, re-run a job, debug, log search and downloads
The run's page renders each job's $GITHUB_STEP_SUMMARY (sanitised Markdown) above its jobs, picks among its attempts (an earlier one shows its jobs and logs as they ended), re-runs one job or the failed ones or all of them from a dialog that can turn on debug logging, and offers Force cancel while a cancelled run's jobs clean up. A job's log can be searched (matching lines only, marked) and downloaded as text, and every job's log of an attempt as a zip (actions/runs/:id/logs.zip).
10 files+677−940/10 viewed
| 10 | 10 | import type { CommitStatus, Conclusion, LogChunk, WorkflowNote } from "@g1t/contracts"; | |
| 11 | 11 | ||
| 12 | 12 | import { Hint } from "./ui/hint"; | |
| 13 | + | import { type Line, blocks, highlight, searchLog } from "../lib/log-lines"; | |
| 13 | 14 | ||
| 14 | 15 | /** | |
| 15 | 16 | * Where a run, job or step stands. `of` says which: a run's `pending` waits | |
| ⋯ | |||
| 87 | 88 | const pull = /^refs\/pull\/(\d+)\//.exec(ref); | |
| 88 | 89 | if (pull) return `#${pull[1]}`; | |
| 89 | 90 | return ref.replace(/^refs\/(heads|tags)\//, ""); | |
| 90 | − | } | |
| 91 | − | ||
| 92 | − | type Line = { kind: "text" | "error" | "warning" | "notice" | "debug" | "command"; text: string }; | |
| 93 | − | type Block = { kind: "line"; line: Line; number: number } | { kind: "group"; title: string; lines: { line: Line; number: number }[] }; | |
| 94 | − | ||
| 95 | − | function classify(raw: string): Line | "group-end" | { group: string } { | |
| 96 | − | if (raw.startsWith("##[group]")) return { group: raw.slice(9) }; | |
| 97 | − | if (raw.startsWith("##[endgroup]")) return "group-end"; | |
| 98 | − | for (const kind of ["error", "warning", "notice", "debug"] as const) { | |
| 99 | − | if (raw.startsWith(`##[${kind}]`)) return { kind, text: raw.slice(kind.length + 4) }; | |
| 100 | − | } | |
| 101 | − | if (raw.startsWith("[command]")) return { kind: "command", text: raw.slice(9) }; | |
| 102 | − | return { kind: "text", text: raw }; | |
| 103 | 91 | } | |
| 104 | 92 | ||
| 105 | − | /** Lines into blocks: plain lines, and groups that fold. */ | |
| 106 | − | function blocks(text: string): Block[] { | |
| 107 | − | const out: Block[] = []; | |
| 108 | − | let group: Extract<Block, { kind: "group" }> | null = null; | |
| 109 | − | let number = 0; | |
| 110 | − | for (const raw of text.split("\n")) { | |
| 111 | − | if (raw === "" && number === 0) continue; | |
| 112 | − | const line = classify(raw); | |
| 113 | − | if (line === "group-end") { | |
| 114 | − | group = null; | |
| 115 | − | continue; | |
| 116 | − | } | |
| 117 | − | if ("group" in line) { | |
| 118 | − | group = { kind: "group", title: line.group, lines: [] }; | |
| 119 | − | out.push(group); | |
| 120 | − | continue; | |
| 121 | − | } | |
| 122 | − | number += 1; | |
| 123 | − | if (group) group.lines.push({ line, number }); | |
| 124 | − | else out.push({ kind: "line", line, number }); | |
| 125 | − | } | |
| 126 | − | return out; | |
| 127 | − | } | |
| 128 | − | ||
| 129 | 93 | const LINE_STYLE: Record<Line["kind"], string> = { | |
| 130 | 94 | text: "text-fg/85", | |
| 131 | 95 | error: "text-danger", | |
| ⋯ | |||
| 135 | 99 | command: "text-muted", | |
| 136 | 100 | }; | |
| 137 | 101 | ||
| 138 | − | function LogLine({ line, number }: { line: Line; number: number }) { | |
| 102 | + | function LogLine({ line, number, query = "" }: { line: Line; number: number; query?: string }) { | |
| 103 | + | const text = line.text.replace(/^(Error|Warning|Notice): /, ""); | |
| 139 | 104 | return ( | |
| 140 | 105 | <div className={`flex gap-4 px-4 hover:bg-raised/40 ${line.kind === "error" ? "bg-danger/5" : ""}`}> | |
| 141 | 106 | <span className="w-8 shrink-0 select-none text-right text-faint/70">{number}</span> | |
| 142 | 107 | <span className={`min-w-0 whitespace-pre-wrap break-all ${LINE_STYLE[line.kind]}`}> | |
| 143 | 108 | {line.kind === "error" && <span className="font-semibold">Error: </span>} | |
| 144 | 109 | {line.kind === "warning" && <span className="font-semibold">Warning: </span>} | |
| 145 | − | {line.text.replace(/^(Error|Warning|Notice): /, "")} | |
| 110 | + | {query | |
| 111 | + | ? highlight(text, query).map((piece, index) => | |
| 112 | + | piece.match ? ( | |
| 113 | + | <mark key={index} className="rounded-sm bg-accent/30 text-fg"> | |
| 114 | + | {piece.text} | |
| 115 | + | </mark> | |
| 116 | + | ) : ( | |
| 117 | + | piece.text | |
| 118 | + | ), | |
| 119 | + | ) | |
| 120 | + | : text} | |
| 146 | 121 | </span> | |
| 147 | 122 | </div> | |
| 148 | 123 | ); | |
| 149 | 124 | } | |
| 150 | 125 | ||
| 151 | − | export function LogText({ text }: { text: string }) { | |
| 126 | + | /** | |
| 127 | + | * A step's log. With `query`, only the lines holding it (any case), groups | |
| 128 | + | * opened, each match marked. | |
| 129 | + | */ | |
| 130 | + | export function LogText({ text, query = "" }: { text: string; query?: string }) { | |
| 152 | 131 | const parsed = useMemo(() => blocks(text), [text]); | |
| 132 | + | const found = useMemo(() => searchLog(text, query), [text, query]); | |
| 153 | 133 | if (!text.trim()) return <p className="px-4 py-2 text-xs text-faint">No output.</p>; | |
| 134 | + | if (query.trim()) { | |
| 135 | + | return ( | |
| 136 | + | <div className="py-1 font-mono text-xs leading-5"> | |
| 137 | + | {found.map(({ line, number }) => ( | |
| 138 | + | <LogLine key={number} line={line} number={number} query={query} /> | |
| 139 | + | ))} | |
| 140 | + | </div> | |
| 141 | + | ); | |
| 142 | + | } | |
| 154 | 143 | return ( | |
| 155 | 144 | <div className="py-1 font-mono text-xs leading-5"> | |
| 156 | 145 | {parsed.map((block, index) => | |
| 1 | + | /** Answers for the log downloads: a file to save, or why there is none. */ | |
| 2 | + | ||
| 3 | + | export function refused(status: number, message: string): Response { | |
| 4 | + | return new Response(`${message}\n`, { status, headers: { "content-type": "text/plain; charset=utf-8", "cache-control": "no-store" } }); | |
| 5 | + | } | |
| 6 | + | ||
| 7 | + | export function attachment(body: BodyInit, type: string, file: string): Response { | |
| 8 | + | return new Response(body, { | |
| 9 | + | headers: { | |
| 10 | + | "content-type": type, | |
| 11 | + | "content-disposition": `attachment; filename="${file.replace(/["\\\r\n]/g, "")}"`, | |
| 12 | + | "cache-control": "no-store", | |
| 13 | + | }, | |
| 14 | + | }); | |
| 15 | + | } |
| 1 | + | import assert from "node:assert/strict"; | |
| 2 | + | import { test } from "node:test"; | |
| 3 | + | ||
| 4 | + | import type { JobLogText } from "@g1t/contracts"; | |
| 5 | + | ||
| 6 | + | import { archiveFiles, blocks, fileName, highlight, searchLog } from "./log-lines.ts"; | |
| 7 | + | ||
| 8 | + | const LOG = ["Job: test", "##[group]Run cargo test", "cargo test --locked", "##[endgroup]", "test greet ... FAILED", "##[error]Process completed with exit code 101."].join("\n"); | |
| 9 | + | ||
| 10 | + | test("a log reads as lines and folded groups, numbered past the group markers", () => { | |
| 11 | + | const parsed = blocks(LOG); | |
| 12 | + | assert.equal(parsed.length, 4); | |
| 13 | + | assert.deepEqual(parsed[1], { kind: "group", title: "Run cargo test", lines: [{ line: { kind: "text", text: "cargo test --locked" }, number: 2 }] }); | |
| 14 | + | assert.deepEqual(parsed[3], { kind: "line", line: { kind: "error", text: "Process completed with exit code 101." }, number: 4 }); | |
| 15 | + | }); | |
| 16 | + | ||
| 17 | + | test("search finds lines in any case, inside folded groups too", () => { | |
| 18 | + | assert.deepEqual( | |
| 19 | + | searchLog(LOG, "CARGO TEST").map((found) => found.number), | |
| 20 | + | [2], | |
| 21 | + | ); | |
| 22 | + | assert.deepEqual( | |
| 23 | + | searchLog(LOG, "test").map((found) => found.number), | |
| 24 | + | [1, 2, 3], | |
| 25 | + | ); | |
| 26 | + | assert.deepEqual(searchLog(LOG, " "), []); | |
| 27 | + | // The group's title is not a line of its own. | |
| 28 | + | assert.deepEqual(searchLog(LOG, "Run cargo"), []); | |
| 29 | + | }); | |
| 30 | + | ||
| 31 | + | test("matches are marked where they fall", () => { | |
| 32 | + | assert.deepEqual(highlight("Test a test", "test"), [ | |
| 33 | + | { text: "Test", match: true }, | |
| 34 | + | { text: " a ", match: false }, | |
| 35 | + | { text: "test", match: true }, | |
| 36 | + | ]); | |
| 37 | + | assert.deepEqual(highlight("plain", ""), [{ text: "plain", match: false }]); | |
| 38 | + | }); | |
| 39 | + | ||
| 40 | + | test("a run's archive has each job whole and a file per step, named as the API names them", () => { | |
| 41 | + | const job = (name: string, chunks: [number, string][], omitted = false): JobLogText => ({ | |
| 42 | + | jobId: "job_1", | |
| 43 | + | name, | |
| 44 | + | steps: [{ number: 1, name: "Run cargo test", status: "completed", conclusion: "success", startedAt: null, finishedAt: null }], | |
| 45 | + | chunks: chunks.map(([step, text], index) => ({ seq: index + 1, step, text })), | |
| 46 | + | done: true, | |
| 47 | + | omitted, | |
| 48 | + | }); | |
| 49 | + | const files = archiveFiles([job("test", [[0, "set up\n"], [1, "running\n"], [1, "ok\n"]]), job("deploy/x", [], true)]); | |
| 50 | + | assert.deepEqual( | |
| 51 | + | files.map((file) => file.path), | |
| 52 | + | ["1_test.txt", "test/0_Set up job.txt", "test/1_Run cargo test.txt", "2_deploy_x.txt"], | |
| 53 | + | ); | |
| 54 | + | assert.equal(files[0]!.text, "set up\nrunning\nok\n"); | |
| 55 | + | assert.equal(files[2]!.text, "running\nok\n"); | |
| 56 | + | assert.match(files[3]!.text, /left out/); | |
| 57 | + | assert.equal(fileName("build / a:b"), "build _ a_b"); | |
| 58 | + | assert.equal(fileName(".."), "job"); | |
| 59 | + | }); |
| 1 | + | /** | |
| 2 | + | * A job's log as GitHub shows one: lines, folded `##[group]`s, errors and | |
| 3 | + | * warnings marked; searching it; and the files of a run's log archive. | |
| 4 | + | */ | |
| 5 | + | ||
| 6 | + | import type { JobLogText } from "@g1t/contracts"; | |
| 7 | + | ||
| 8 | + | export type LineKind = "text" | "error" | "warning" | "notice" | "debug" | "command"; | |
| 9 | + | export type Line = { kind: LineKind; text: string }; | |
| 10 | + | export type Block = | |
| 11 | + | | { kind: "line"; line: Line; number: number } | |
| 12 | + | | { kind: "group"; title: string; lines: { line: Line; number: number }[] }; | |
| 13 | + | ||
| 14 | + | export function classify(raw: string): Line | "group-end" | { group: string } { | |
| 15 | + | if (raw.startsWith("##[group]")) return { group: raw.slice(9) }; | |
| 16 | + | if (raw.startsWith("##[endgroup]")) return "group-end"; | |
| 17 | + | for (const kind of ["error", "warning", "notice", "debug"] as const) { | |
| 18 | + | if (raw.startsWith(`##[${kind}]`)) return { kind, text: raw.slice(kind.length + 4) }; | |
| 19 | + | } | |
| 20 | + | if (raw.startsWith("[command]")) return { kind: "command", text: raw.slice(9) }; | |
| 21 | + | return { kind: "text", text: raw }; | |
| 22 | + | } | |
| 23 | + | ||
| 24 | + | /** Lines into blocks: plain lines, and groups that fold. Lines are numbered from 1, group markers aside. */ | |
| 25 | + | export function blocks(text: string): Block[] { | |
| 26 | + | const out: Block[] = []; | |
| 27 | + | let group: Extract<Block, { kind: "group" }> | null = null; | |
| 28 | + | let number = 0; | |
| 29 | + | for (const raw of text.split("\n")) { | |
| 30 | + | if (raw === "" && number === 0) continue; | |
| 31 | + | const line = classify(raw); | |
| 32 | + | if (line === "group-end") { | |
| 33 | + | group = null; | |
| 34 | + | continue; | |
| 35 | + | } | |
| 36 | + | if ("group" in line) { | |
| 37 | + | group = { kind: "group", title: line.group, lines: [] }; | |
| 38 | + | out.push(group); | |
| 39 | + | continue; | |
| 40 | + | } | |
| 41 | + | number += 1; | |
| 42 | + | if (group) group.lines.push({ line, number }); | |
| 43 | + | else out.push({ kind: "line", line, number }); | |
| 44 | + | } | |
| 45 | + | return out; | |
| 46 | + | } | |
| 47 | + | ||
| 48 | + | /** The lines of a log holding `query` (any case), numbered as `blocks` numbers them, folded groups opened. */ | |
| 49 | + | export function searchLog(text: string, query: string): { line: Line; number: number }[] { | |
| 50 | + | const wanted = query.trim().toLowerCase(); | |
| 51 | + | if (!wanted) return []; | |
| 52 | + | const found: { line: Line; number: number }[] = []; | |
| 53 | + | for (const block of blocks(text)) { | |
| 54 | + | const lines = block.kind === "line" ? [{ line: block.line, number: block.number }] : block.lines; | |
| 55 | + | for (const entry of lines) { | |
| 56 | + | if (entry.line.text.toLowerCase().includes(wanted)) found.push(entry); | |
| 57 | + | } | |
| 58 | + | } | |
| 59 | + | return found; | |
| 60 | + | } | |
| 61 | + | ||
| 62 | + | /** `text` cut where `query` (any case) appears, each piece marked as a match or not. */ | |
| 63 | + | export function highlight(text: string, query: string): { text: string; match: boolean }[] { | |
| 64 | + | const wanted = query.trim().toLowerCase(); | |
| 65 | + | if (!wanted) return [{ text, match: false }]; | |
| 66 | + | const lower = text.toLowerCase(); | |
| 67 | + | const out: { text: string; match: boolean }[] = []; | |
| 68 | + | let at = 0; | |
| 69 | + | for (let found = lower.indexOf(wanted); found !== -1; found = lower.indexOf(wanted, at)) { | |
| 70 | + | if (found > at) out.push({ text: text.slice(at, found), match: false }); | |
| 71 | + | out.push({ text: text.slice(found, found + wanted.length), match: true }); | |
| 72 | + | at = found + wanted.length; | |
| 73 | + | } | |
| 74 | + | if (at < text.length) out.push({ text: text.slice(at), match: false }); | |
| 75 | + | return out; | |
| 76 | + | } | |
| 77 | + | ||
| 78 | + | /** A name safe as a file name in an archive, as the API's archive names them. */ | |
| 79 | + | export function fileName(name: string): string { | |
| 80 | + | const cleaned = Array.from(name) | |
| 81 | + | .map((c) => (/[/\\:*?"<>|]/.test(c) || c.charCodeAt(0) < 32 ? "_" : c)) | |
| 82 | + | .slice(0, 100) | |
| 83 | + | .join("") | |
| 84 | + | .trim() | |
| 85 | + | .replace(/^\.+|\.+$/g, ""); | |
| 86 | + | return cleaned || "job"; | |
| 87 | + | } | |
| 88 | + | ||
| 89 | + | /** A job's whole log as one text. */ | |
| 90 | + | export function jobText(job: JobLogText): string { | |
| 91 | + | if (job.omitted) return "This job's log was left out: the run's logs are larger than one download holds. Download it on its own.\n"; | |
| 92 | + | return job.chunks.map((chunk) => chunk.text).join(""); | |
| 93 | + | } | |
| 94 | + | ||
| 95 | + | /** | |
| 96 | + | * The files of a run's log archive: `{n}_{job}.txt` with each job's whole | |
| 97 | + | * log, and `{job}/{step}_{step name}.txt` for each of its steps. | |
| 98 | + | */ | |
| 99 | + | export function archiveFiles(jobs: JobLogText[]): { path: string; text: string }[] { | |
| 100 | + | const files: { path: string; text: string }[] = []; | |
| 101 | + | jobs.forEach((job, index) => { | |
| 102 | + | const name = fileName(job.name); | |
| 103 | + | files.push({ path: `${index + 1}_${name}.txt`, text: jobText(job) }); | |
| 104 | + | if (job.omitted) return; | |
| 105 | + | const steps = [...new Set(job.chunks.map((chunk) => chunk.step))].sort((a, b) => a - b); | |
| 106 | + | for (const step of steps) { | |
| 107 | + | const title = step === 0 ? "Set up job" : (job.steps.find((s) => s.number === step)?.name ?? `Step ${step}`); | |
| 108 | + | const text = job.chunks | |
| 109 | + | .filter((chunk) => chunk.step === step) | |
| 110 | + | .map((chunk) => chunk.text) | |
| 111 | + | .join(""); | |
| 112 | + | files.push({ path: `${name}/${step}_${fileName(title)}.txt`, text }); | |
| 113 | + | } | |
| 114 | + | }); | |
| 115 | + | return files; | |
| 116 | + | } |
| 178 | 178 | route("actions/runs/:id", "routes/repo/actions-run.tsx"), | |
| 179 | 179 | route("actions/runs/:id/artifacts/:name", "routes/repo/actions-artifact.ts"), | |
| 180 | 180 | route("actions/jobs/:job/log", "routes/repo/actions-log.ts"), | |
| 181 | + | route("actions/jobs/:job/log.txt", "routes/repo/actions-job-log-download.ts"), | |
| 182 | + | route("actions/runs/:id/logs.zip", "routes/repo/actions-logs-download.ts"), | |
| 181 | 183 | route("deployments", "routes/repo/deployments.tsx"), | |
| 182 | 184 | route("deployments/:id", "routes/repo/deployment.tsx"), | |
| 183 | 185 | // Security: an overview, then a page for each part. Old links to |
| 1 | + | /** | |
| 2 | + | * One job's whole log as plain text (`actions/jobs/:job/log.txt`), any | |
| 3 | + | * attempt's, by the id the run page gives the job. For anyone who can see | |
| 4 | + | * the run. | |
| 5 | + | */ | |
| 6 | + | import type { Route } from "./+types/actions-job-log-download"; | |
| 7 | + | import { fileName, jobText } from "../../lib/log-lines"; | |
| 8 | + | import { attachment, refused } from "../../lib/log-download.server"; | |
| 9 | + | import { actions } from "../../lib/services.server"; | |
| 10 | + | import { getViewer } from "../../lib/session.server"; | |
| 11 | + | ||
| 12 | + | export async function loader({ params, context }: Route.LoaderArgs) { | |
| 13 | + | const repo = { namespace: params.owner, name: params.repo }; | |
| 14 | + | const log = await actions.jobLogText(repo, getViewer(context), params.job); | |
| 15 | + | if (!log.ok) return refused(log.error.code === "not_found" ? 404 : 400, log.error.message); | |
| 16 | + | return attachment(jobText(log.value), "text/plain; charset=utf-8", `${fileName(log.value.name)}.txt`); | |
| 17 | + | } |
| 1 | + | /** | |
| 2 | + | * Every job's log of a run's attempt as one zip | |
| 3 | + | * (`actions/runs/:id/logs.zip?attempt=`), laid out as the API's archive is: | |
| 4 | + | * `{n}_{job}.txt` whole, and `{job}/{step}_{step name}.txt`. For anyone who | |
| 5 | + | * can see the run. | |
| 6 | + | */ | |
| 7 | + | import type { Route } from "./+types/actions-logs-download"; | |
| 8 | + | import { archiveFiles } from "../../lib/log-lines"; | |
| 9 | + | import { attachment, refused } from "../../lib/log-download.server"; | |
| 10 | + | import { actions } from "../../lib/services.server"; | |
| 11 | + | import { getViewer } from "../../lib/session.server"; | |
| 12 | + | import { zip } from "../../lib/zip"; | |
| 13 | + | ||
| 14 | + | export async function loader({ params, context, request }: Route.LoaderArgs) { | |
| 15 | + | const repo = { namespace: params.owner, name: params.repo }; | |
| 16 | + | const asked = Number(new URL(request.url).searchParams.get("attempt") ?? ""); | |
| 17 | + | const attempt = Number.isInteger(asked) && asked > 0 ? asked : undefined; | |
| 18 | + | const logs = await actions.runLogs(repo, getViewer(context), params.id, attempt); | |
| 19 | + | if (!logs.ok) return refused(logs.error.code === "not_found" ? 404 : 400, logs.error.message); | |
| 20 | + | const encoder = new TextEncoder(); | |
| 21 | + | const archive = await zip(archiveFiles(logs.value).map((file) => ({ path: file.path, data: encoder.encode(file.text) }))); | |
| 22 | + | return attachment(archive, "application/zip", attempt ? `logs_${params.id}_attempt_${attempt}.zip` : `logs_${params.id}.zip`); | |
| 23 | + | } |
| 1 | − | import { AlertTriangle, Check, ChevronRight, Cloud, Download, GitBranch, GitCommitHorizontal, Hourglass, Info, Package, Play, RotateCw, ServerCog, ShieldAlert, Square, Trash2, Users, X, XCircle } from "lucide-react"; | |
| 2 | − | import { type ReactNode } from "react"; | |
| 1 | + | import { | |
| 2 | + | AlertTriangle, | |
| 3 | + | Bug, | |
| 4 | + | Check, | |
| 5 | + | ChevronDown, | |
| 6 | + | ChevronRight, | |
| 7 | + | Cloud, | |
| 8 | + | Download, | |
| 9 | + | FileText, | |
| 10 | + | GitBranch, | |
| 11 | + | GitCommitHorizontal, | |
| 12 | + | History, | |
| 13 | + | Hourglass, | |
| 14 | + | Info, | |
| 15 | + | Package, | |
| 16 | + | Play, | |
| 17 | + | RotateCw, | |
| 18 | + | Search, | |
| 19 | + | ServerCog, | |
| 20 | + | ShieldAlert, | |
| 21 | + | Square, | |
| 22 | + | Trash2, | |
| 23 | + | Users, | |
| 24 | + | X, | |
| 25 | + | XCircle, | |
| 26 | + | } from "lucide-react"; | |
| 27 | + | import { type ReactNode, useEffect, useRef, useState } from "react"; | |
| 3 | 28 | import { Form, Link, useLoaderData, useSearchParams } from "react-router"; | |
| 4 | 29 | ||
| 5 | − | import type { Annotation, EnvironmentReviewer, Job, PendingDeployment, RunApproval, StepState } from "@g1t/contracts"; | |
| 30 | + | import type { Annotation, EnvironmentReviewer, Job, JobSummary, PendingDeployment, RunApproval, RunAttempt, StepState } from "@g1t/contracts"; | |
| 6 | 31 | ||
| 7 | 32 | import type { Route } from "./+types/actions-run"; | |
| 8 | 33 | import { page } from "../../lib/meta"; | |
| 9 | 34 | import { LogText, Notes, StatusIcon, duration, shortRef, standingWord, useJobLog } from "../../components/actions"; | |
| 10 | − | import { ErrorText, SubmitButton, TimeAgo, usePending } from "../../components/ui"; | |
| 35 | + | import { Markdown } from "../../components/markdown"; | |
| 36 | + | import { Button, ErrorText, SubmitButton, TimeAgo, usePending } from "../../components/ui"; | |
| 37 | + | import { CheckboxOption } from "../../components/ui/checkbox"; | |
| 38 | + | import { Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle } from "../../components/ui/dialog"; | |
| 39 | + | import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuTrigger } from "../../components/ui/dropdown-menu"; | |
| 11 | 40 | import { Hint } from "../../components/ui/hint"; | |
| 41 | + | import { searchLog } from "../../lib/log-lines"; | |
| 12 | 42 | import { listArtifacts } from "../../lib/artifacts.server"; | |
| 13 | 43 | import { expiresIn, formatBytes } from "../../lib/artifacts"; | |
| 14 | 44 | import { actions } from "../../lib/services.server"; | |
| ⋯ | |||
| 21 | 51 | return page(args, { title: `${run ? `${run.title || run.name} #${run.number}` : "Run"} · ${params.owner}/${params.repo} · g1t` }); | |
| 22 | 52 | } | |
| 23 | 53 | ||
| 24 | − | export async function loader({ params, context }: Route.LoaderArgs) { | |
| 54 | + | export async function loader({ params, context, request }: Route.LoaderArgs) { | |
| 25 | 55 | const viewer = getViewer(context); | |
| 26 | 56 | const repo = { namespace: params.owner, name: params.repo }; | |
| 27 | − | const [detail, artifacts] = await Promise.all([ | |
| 28 | − | actions.run(repo, viewer, params.id).then(unwrap), | |
| 57 | + | // An earlier attempt, when one is asked for. | |
| 58 | + | const asked = Number(new URL(request.url).searchParams.get("attempt") ?? ""); | |
| 59 | + | const attempt = Number.isInteger(asked) && asked > 0 ? asked : undefined; | |
| 60 | + | const [detail, artifacts, summaries] = await Promise.all([ | |
| 61 | + | actions.run(repo, viewer, params.id, attempt).then(unwrap), | |
| 29 | 62 | listArtifacts(repo, viewer, params.id).catch(() => []), | |
| 63 | + | actions | |
| 64 | + | .summaries(repo, viewer, params.id, attempt) | |
| 65 | + | .then((found) => (found.ok ? found.value : [])) | |
| 66 | + | .catch((): JobSummary[] => []), | |
| 30 | 67 | ]); | |
| 31 | 68 | // Cancelling and re-running need Write. | |
| 32 | 69 | return { | |
| 33 | 70 | detail, | |
| 34 | 71 | artifacts, | |
| 72 | + | summaries, | |
| 35 | 73 | member: (await accessTo(context, params)).can.run, | |
| 36 | 74 | // The runners page is the workspace owners'. | |
| 37 | 75 | runnersPage: roleIn(viewer, params.owner) === "owner", | |
| ⋯ | |||
| 66 | 104 | const deleted = await actions.deleteArtifact(user, repo, Number(form.get("artifact"))); | |
| 67 | 105 | return deleted.ok ? {} : { error: deleted.error.message }; | |
| 68 | 106 | } | |
| 107 | + | // Debug logging, for a re-run that asks for it. | |
| 108 | + | const debug = form.get("debug") === "on"; | |
| 109 | + | const job = String(form.get("job") ?? "") || undefined; | |
| 69 | 110 | const done = | |
| 70 | 111 | intent === "approve-run" | |
| 71 | 112 | ? await actions.approveRun(user, repo, params.id) | |
| 72 | − | : intent === "cancel" | |
| 73 | − | ? await actions.cancel(user, repo, params.id) | |
| 74 | − | : await actions.rerun(user, repo, params.id, intent === "rerun-failed"); | |
| 113 | + | : intent === "cancel" || intent === "force-cancel" | |
| 114 | + | ? await actions.cancel(user, repo, params.id, intent === "force-cancel") | |
| 115 | + | : await actions.rerun(user, repo, params.id, intent === "rerun-failed", { job: intent === "rerun-job" ? job : undefined, debug }); | |
| 75 | 116 | return done.ok ? {} : { error: done.error.message }; | |
| 76 | 117 | } | |
| 77 | 118 | ||
| ⋯ | |||
| 81 | 122 | notice: <Info size={14} className="mt-0.5 shrink-0 text-muted" />, | |
| 82 | 123 | }; | |
| 83 | 124 | ||
| 84 | − | function StepRow({ step, text, defaultOpen }: { step: StepState; text: string | undefined; defaultOpen: boolean }) { | |
| 125 | + | function StepRow({ | |
| 126 | + | step, | |
| 127 | + | text, | |
| 128 | + | defaultOpen, | |
| 129 | + | query = "", | |
| 130 | + | matches = 0, | |
| 131 | + | }: { | |
| 132 | + | step: StepState; | |
| 133 | + | text: string | undefined; | |
| 134 | + | defaultOpen: boolean; | |
| 135 | + | /** A search of the job's log: only its matching lines are shown. */ | |
| 136 | + | query?: string; | |
| 137 | + | matches?: number; | |
| 138 | + | }) { | |
| 85 | 139 | return ( | |
| 86 | 140 | <details className="group border-t border-line first:border-t-0" open={defaultOpen}> | |
| 87 | 141 | <summary className="flex cursor-pointer list-none items-center gap-2.5 px-4 py-2 text-sm hover:bg-raised/40"> | |
| 88 | 142 | <ChevronRight size={14} className="shrink-0 text-faint transition-transform group-open:rotate-90" /> | |
| 89 | 143 | <StatusIcon status={step.status} conclusion={step.conclusion} size={14} /> | |
| 90 | 144 | <span className={`min-w-0 truncate ${step.conclusion === "skipped" ? "text-faint" : ""}`}>{step.name}</span> | |
| 145 | + | {query && ( | |
| 146 | + | <span className="shrink-0 rounded-full bg-accent/15 px-1.5 text-xs text-accent"> | |
| 147 | + | {matches} {matches === 1 ? "line" : "lines"} | |
| 148 | + | </span> | |
| 149 | + | )} | |
| 91 | 150 | <span className="ml-auto shrink-0 font-mono text-xs text-faint">{duration(step.startedAt, step.finishedAt)}</span> | |
| 92 | 151 | </summary> | |
| 93 | 152 | <div className="border-t border-line bg-bg/60"> | |
| 94 | 153 | {text === undefined ? ( | |
| 95 | 154 | <p className="px-4 py-2 text-xs text-faint">{step.status === "queued" ? "Not started." : step.conclusion === "skipped" ? "Skipped." : "No output yet."}</p> | |
| 96 | 155 | ) : ( | |
| 97 | − | <LogText text={text} /> | |
| 156 | + | <LogText text={text} query={query} /> | |
| 98 | 157 | )} | |
| 99 | 158 | </div> | |
| 100 | 159 | </details> | |
| 101 | 160 | ); | |
| 102 | 161 | } | |
| 103 | 162 | ||
| 163 | + | /** | |
| 164 | + | * Runs again, after asking whether with debug logging: the whole run, its | |
| 165 | + | * failed jobs, or one job (with the jobs that need it). | |
| 166 | + | */ | |
| 167 | + | function RerunDialog({ | |
| 168 | + | intent, | |
| 169 | + | job, | |
| 170 | + | title, | |
| 171 | + | description, | |
| 172 | + | children, | |
| 173 | + | busy, | |
| 174 | + | }: { | |
| 175 | + | intent: "rerun" | "rerun-failed" | "rerun-job"; | |
| 176 | + | job?: string; | |
| 177 | + | title: string; | |
| 178 | + | description: string; | |
| 179 | + | /** What opens it. */ | |
| 180 | + | children: (open: () => void) => ReactNode; | |
| 181 | + | busy: boolean; | |
| 182 | + | }) { | |
| 183 | + | const [open, setOpen] = useState(false); | |
| 184 | + | // Open, saying it is starting, until the new attempt shows or the error does. | |
| 185 | + | const running = usePending({ intent }); | |
| 186 | + | const was = useRef(false); | |
| 187 | + | useEffect(() => { | |
| 188 | + | if (was.current && !running) setOpen(false); | |
| 189 | + | was.current = running; | |
| 190 | + | }, [running]); | |
| 191 | + | return ( | |
| 192 | + | <> | |
| 193 | + | {children(() => setOpen(true))} | |
| 194 | + | <Dialog open={open} onOpenChange={setOpen}> | |
| 195 | + | <DialogContent> | |
| 196 | + | <Form method="post" preventScrollReset className="grid gap-4"> | |
| 197 | + | <DialogHeader> | |
| 198 | + | <DialogTitle>{title}</DialogTitle> | |
| 199 | + | <DialogDescription>{description}</DialogDescription> | |
| 200 | + | </DialogHeader> | |
| 201 | + | {job && <input type="hidden" name="job" value={job} />} | |
| 202 | + | <CheckboxOption | |
| 203 | + | name="debug" | |
| 204 | + | label="Enable debug logging" | |
| 205 | + | description="Sets RUNNER_DEBUG=1 and ACTIONS_STEP_DEBUG: ::debug:: lines are shown, and how each step's if: read." | |
| 206 | + | /> | |
| 207 | + | <DialogFooter> | |
| 208 | + | <Button type="button" variant="quiet" onClick={() => setOpen(false)}> | |
| 209 | + | Cancel | |
| 210 | + | </Button> | |
| 211 | + | <SubmitButton name="intent" value={intent} variant="accent" disabled={busy} pending="Re-running…"> | |
| 212 | + | <RotateCw size={13} /> | |
| 213 | + | {title} | |
| 214 | + | </SubmitButton> | |
| 215 | + | </DialogFooter> | |
| 216 | + | </Form> | |
| 217 | + | </DialogContent> | |
| 218 | + | </Dialog> | |
| 219 | + | </> | |
| 220 | + | ); | |
| 221 | + | } | |
| 222 | + | ||
| 223 | + | /** A link or menu button that sits beside the run's quiet buttons, at their size. */ | |
| 224 | + | const QUIET_LINK = | |
| 225 | + | "inline-flex items-center gap-2 rounded-md border border-line px-3.5 py-2 text-sm font-medium text-fg/80 transition-colors hover:border-line-strong hover:bg-surface hover:text-fg"; | |
| 226 | + | ||
| 227 | + | /** The run's attempts, newest first, each a link to how it went. */ | |
| 228 | + | function AttemptPicker({ attempts, shown }: { attempts: RunAttempt[]; shown: number }) { | |
| 229 | + | const latest = attempts[attempts.length - 1]?.attempt ?? shown; | |
| 230 | + | return ( | |
| 231 | + | <DropdownMenu> | |
| 232 | + | <DropdownMenuTrigger asChild> | |
| 233 | + | <button type="button" className={QUIET_LINK}> | |
| 234 | + | <History size={13} /> | |
| 235 | + | Attempt #{shown} | |
| 236 | + | <ChevronDown size={13} /> | |
| 237 | + | </button> | |
| 238 | + | </DropdownMenuTrigger> | |
| 239 | + | <DropdownMenuContent align="end" className="min-w-64"> | |
| 240 | + | {[...attempts].reverse().map((attempt) => ( | |
| 241 | + | <DropdownMenuItem key={attempt.attempt} asChild> | |
| 242 | + | <Link | |
| 243 | + | to={attempt.attempt === latest ? "?" : `?attempt=${attempt.attempt}`} | |
| 244 | + | preventScrollReset | |
| 245 | + | className={`flex items-center gap-2 ${attempt.attempt === shown ? "text-fg" : ""}`} | |
| 246 | + | > | |
| 247 | + | <StatusIcon status={attempt.status} conclusion={attempt.conclusion} size={14} /> | |
| 248 | + | <span className="font-medium">Attempt #{attempt.attempt}</span> | |
| 249 | + | {attempt.debug && <Bug size={12} className="text-faint" aria-label="Debug logging" />} | |
| 250 | + | <span className="ml-auto truncate text-xs text-faint"> | |
| 251 | + | {attempt.actor && `${attempt.actor} · `} | |
| 252 | + | {attempt.startedAt ? <TimeAgo at={attempt.startedAt} /> : "not started"} | |
| 253 | + | </span> | |
| 254 | + | </Link> | |
| 255 | + | </DropdownMenuItem> | |
| 256 | + | ))} | |
| 257 | + | </DropdownMenuContent> | |
| 258 | + | </DropdownMenu> | |
| 259 | + | ); | |
| 260 | + | } | |
| 261 | + | ||
| 262 | + | /** What the jobs' steps wrote to $GITHUB_STEP_SUMMARY, a card per job. */ | |
| 263 | + | function Summaries({ summaries, repo }: { summaries: JobSummary[]; repo: { namespace: string; name: string } }) { | |
| 264 | + | if (summaries.length === 0) return null; | |
| 265 | + | return ( | |
| 266 | + | <section aria-label="Job summaries" className="space-y-4"> | |
| 267 | + | {summaries.map((summary) => ( | |
| 268 | + | <article key={summary.jobId} className="overflow-hidden rounded-xl border border-line bg-surface"> | |
| 269 | + | <header className="flex items-center gap-2 border-b border-line px-4 py-2.5 text-sm"> | |
| 270 | + | <FileText size={14} className="text-muted" /> | |
| 271 | + | <span className="font-medium">{summary.name}</span> | |
| 272 | + | <span className="text-faint">summary</span> | |
| 273 | + | </header> | |
| 274 | + | <div className="space-y-4 px-5 py-4"> | |
| 275 | + | {summary.steps.map((step) => ( | |
| 276 | + | <Markdown key={step.step} source={step.markdown} repo={repo} /> | |
| 277 | + | ))} | |
| 278 | + | </div> | |
| 279 | + | </article> | |
| 280 | + | ))} | |
| 281 | + | </section> | |
| 282 | + | ); | |
| 283 | + | } | |
| 284 | + | ||
| 104 | 285 | /** Why a job did not run as asked, with "Integrations" linked where it says so. */ | |
| 105 | 286 | function Reason({ text, workspace }: { text: string; workspace: string }) { | |
| 106 | 287 | const [before, after] = text.split("Integrations"); | |
| ⋯ | |||
| 154 | 335 | ); | |
| 155 | 336 | } | |
| 156 | 337 | ||
| 157 | − | function JobView({ job, base }: { job: Job; base: string }) { | |
| 338 | + | function JobView({ job, base, rerun }: { job: Job; base: string; rerun: ReactNode }) { | |
| 158 | 339 | const live = job.status !== "completed"; | |
| 159 | 340 | const log = useJobLog(`${base}/actions/jobs/${job.id}/log`, live); | |
| 341 | + | const [query, setQuery] = useState(""); | |
| 160 | 342 | const failed = job.steps.find((s) => s.conclusion === "failure"); | |
| 161 | 343 | const running = job.steps.find((s) => s.status === "in_progress"); | |
| 344 | + | const searching = query.trim().length > 0; | |
| 345 | + | const setUp: StepState = { | |
| 346 | + | number: 0, | |
| 347 | + | name: "Set up job", | |
| 348 | + | status: job.startedAt ? "completed" : "queued", | |
| 349 | + | conclusion: job.startedAt ? "success" : null, | |
| 350 | + | startedAt: job.startedAt, | |
| 351 | + | finishedAt: job.startedAt, | |
| 352 | + | }; | |
| 353 | + | // While searching, the steps with a match, each with how many. | |
| 354 | + | const steps = [setUp, ...job.steps] | |
| 355 | + | .map((step) => ({ step, matches: searching ? searchLog(log.get(step.number) ?? "", query).length : 0 })) | |
| 356 | + | .filter(({ matches }) => !searching || matches > 0); | |
| 357 | + | const total = steps.reduce((sum, { matches }) => sum + matches, 0); | |
| 162 | 358 | return ( | |
| 163 | 359 | <section className="min-w-0 space-y-4"> | |
| 164 | 360 | <div className="flex flex-wrap items-center gap-3"> | |
| 165 | 361 | <StatusIcon status={job.status} conclusion={job.conclusion} of="job" environment={job.environment} size={18} /> | |
| 166 | 362 | <h3 className="text-base font-semibold">{job.name}</h3> | |
| 167 | 363 | <span className="text-sm text-muted"> | |
| 168 | − | {standingWord({ ...job, of: "job" })} | |
| 364 | + | {job.cancelling ? "Cancelling: running its cleanup steps" : standingWord({ ...job, of: "job" })} | |
| 169 | 365 | {job.startedAt && ` · ${duration(job.startedAt, job.finishedAt)}`} | |
| 170 | 366 | </span> | |
| 171 | − | <RanOn job={job} workspace={base.split("/")[1]!} /> | |
| 367 | + | <span className="ml-auto flex items-center gap-2"> | |
| 368 | + | {rerun} | |
| 369 | + | {job.startedAt && ( | |
| 370 | + | <Hint label="Download this job's log"> | |
| 371 | + | <a | |
| 372 | + | href={`${base}/actions/jobs/${job.id}/log.txt`} | |
| 373 | + | aria-label={`Download the log of ${job.name}`} | |
| 374 | + | className="inline-flex items-center rounded-md p-1.5 text-muted ring-1 ring-line hover:text-fg" | |
| 375 | + | > | |
| 376 | + | <Download size={13} /> | |
| 377 | + | </a> | |
| 378 | + | </Hint> | |
| 379 | + | )} | |
| 380 | + | <RanOn job={job} workspace={base.split("/")[1]!} /> | |
| 381 | + | </span> | |
| 172 | 382 | </div> | |
| 173 | 383 | {job.reason && <Reason text={job.reason} workspace={base.split("/")[1]} />} | |
| 174 | 384 | {job.annotations.length > 0 && ( | |
| ⋯ | |||
| 190 | 400 | ))} | |
| 191 | 401 | </ul> | |
| 192 | 402 | )} | |
| 403 | + | {job.startedAt && ( | |
| 404 | + | <label className="flex items-center gap-2 rounded-lg border border-line bg-surface px-3 py-1.5 text-sm focus-within:border-accent-dim"> | |
| 405 | + | <Search size={14} className="shrink-0 text-faint" /> | |
| 406 | + | <input | |
| 407 | + | type="search" | |
| 408 | + | value={query} | |
| 409 | + | onChange={(event) => setQuery(event.target.value)} | |
| 410 | + | placeholder="Search logs" | |
| 411 | + | aria-label={`Search the log of ${job.name}`} | |
| 412 | + | className="min-w-0 grow bg-transparent outline-none placeholder:text-faint" | |
| 413 | + | /> | |
| 414 | + | {searching && ( | |
| 415 | + | <span className="shrink-0 text-xs text-muted" aria-live="polite"> | |
| 416 | + | {total} {total === 1 ? "line" : "lines"} | |
| 417 | + | </span> | |
| 418 | + | )} | |
| 419 | + | </label> | |
| 420 | + | )} | |
| 193 | 421 | <div className="overflow-hidden rounded-xl border border-line bg-surface"> | |
| 194 | − | <StepRow | |
| 195 | − | step={{ number: 0, name: "Set up job", status: job.startedAt ? "completed" : "queued", conclusion: job.startedAt ? "success" : null, startedAt: job.startedAt, finishedAt: job.startedAt }} | |
| 196 | − | text={log.get(0)} | |
| 197 | − | defaultOpen={false} | |
| 198 | − | /> | |
| 199 | − | {job.steps.map((step) => ( | |
| 422 | + | {searching && steps.length === 0 && <p className="px-4 py-3 text-sm text-muted">No line of this job's log holds “{query.trim()}”.</p>} | |
| 423 | + | {steps.map(({ step, matches }) => ( | |
| 200 | 424 | <StepRow | |
| 201 | − | key={step.number} | |
| 425 | + | // Searching opens every step with a match; clearing it puts them back. | |
| 426 | + | key={`${step.number}:${searching}`} | |
| 202 | 427 | step={step} | |
| 203 | 428 | text={log.get(step.number)} | |
| 204 | − | defaultOpen={step.number === (failed ?? running)?.number} | |
| 429 | + | defaultOpen={searching || (step.number !== 0 && step.number === (failed ?? running)?.number)} | |
| 430 | + | query={searching ? query : ""} | |
| 431 | + | matches={matches} | |
| 205 | 432 | /> | |
| 206 | 433 | ))} | |
| 207 | 434 | </div> | |
| ⋯ | |||
| 430 | 657 | } | |
| 431 | 658 | ||
| 432 | 659 | export default function ActionsRun({ loaderData, actionData, params }: Route.ComponentProps) { | |
| 433 | − | const { detail, artifacts, member } = loaderData; | |
| 660 | + | const { detail, artifacts, member, summaries } = loaderData; | |
| 434 | 661 | const { run, jobs, notes } = detail; | |
| 435 | 662 | const deployments = detail.pendingDeployments ?? []; | |
| 663 | + | const attempts = detail.attempts ?? []; | |
| 664 | + | const latest = attempts.length === 0 || run.attempt === attempts[attempts.length - 1]!.attempt; | |
| 436 | 665 | const base = `/${params.owner}/${params.repo}`; | |
| 437 | 666 | const [search] = useSearchParams(); | |
| 438 | 667 | // One of the run's buttons is working: the others wait for it. | |
| 439 | 668 | const busy = usePending(); | |
| 440 | 669 | const live = run.status !== "completed"; | |
| 670 | + | // Cancelled, while its jobs run their cleanup steps. | |
| 671 | + | const cancelling = live && run.conclusion === "cancelled"; | |
| 672 | + | // Re-running is for the latest attempt of a finished run. | |
| 673 | + | const canRerun = member && !run.error && !live && latest; | |
| 674 | + | const logsUrl = `${base}/actions/runs/${run.id}/logs.zip${latest ? "" : `?attempt=${run.attempt}`}`; | |
| 441 | 675 | // Waiting on a person needs no quick refresh; a running job does. | |
| 442 | 676 | useRefreshWhile(live, run.status === "action_required" || run.status === "waiting" ? 8000 : 2500); | |
| 443 | 677 | ||
| ⋯ | |||
| 461 | 695 | <span className="min-w-0 truncate">{run.title || run.name}</span> | |
| 462 | 696 | <span className="font-normal text-muted">#{run.number}</span> | |
| 463 | 697 | </h2> | |
| 464 | − | {member && !run.error && ( | |
| 465 | − | <Form method="post" className="flex gap-2"> | |
| 466 | − | {live ? ( | |
| 467 | − | <SubmitButton name="intent" value="cancel" variant="quiet" disabled={busy} pending="Cancelling…"> | |
| 468 | − | <Square size={13} /> | |
| 469 | − | Cancel run | |
| 470 | − | </SubmitButton> | |
| 471 | − | ) : ( | |
| 472 | − | <> | |
| 473 | − | {anyFailed && ( | |
| 474 | − | <SubmitButton name="intent" value="rerun-failed" variant="quiet" disabled={busy} pending="Re-running…"> | |
| 475 | − | <RotateCw size={13} /> | |
| 476 | − | Re-run failed jobs | |
| 698 | + | <div className="flex flex-wrap items-center gap-2"> | |
| 699 | + | {attempts.length > 1 && <AttemptPicker attempts={attempts} shown={run.attempt} />} | |
| 700 | + | {jobs.some((job) => job.startedAt) && ( | |
| 701 | + | <Hint label={latest ? "Every job's log, as a zip" : `Attempt #${run.attempt}'s logs, as a zip`}> | |
| 702 | + | <a href={logsUrl} className={QUIET_LINK}> | |
| 703 | + | <Download size={13} /> | |
| 704 | + | Download logs | |
| 705 | + | </a> | |
| 706 | + | </Hint> | |
| 707 | + | )} | |
| 708 | + | {member && !run.error && live && ( | |
| 709 | + | <Form method="post" className="flex gap-2"> | |
| 710 | + | {cancelling ? ( | |
| 711 | + | <Hint label="Stop its jobs now, without waiting for their cleanup steps"> | |
| 712 | + | <SubmitButton name="intent" value="force-cancel" variant="danger" disabled={busy} pending="Stopping…"> | |
| 713 | + | <Square size={13} /> | |
| 714 | + | Force cancel | |
| 477 | 715 | </SubmitButton> | |
| 478 | − | )} | |
| 479 | − | <SubmitButton name="intent" value="rerun" variant="quiet" disabled={busy} pending="Re-running…"> | |
| 716 | + | </Hint> | |
| 717 | + | ) : ( | |
| 718 | + | <SubmitButton name="intent" value="cancel" variant="quiet" disabled={busy} pending="Cancelling…"> | |
| 719 | + | <Square size={13} /> | |
| 720 | + | Cancel run | |
| 721 | + | </SubmitButton> | |
| 722 | + | )} | |
| 723 | + | </Form> | |
| 724 | + | )} | |
| 725 | + | {canRerun && anyFailed && ( | |
| 726 | + | <RerunDialog | |
| 727 | + | intent="rerun-failed" | |
| 728 | + | title="Re-run failed jobs" | |
| 729 | + | description="The jobs that did not succeed run again as a new attempt, with every job that needs them. The others keep how they ended." | |
| 730 | + | busy={busy} | |
| 731 | + | > | |
| 732 | + | {(open) => ( | |
| 733 | + | <Button type="button" variant="quiet" disabled={busy} onClick={open}> | |
| 480 | 734 | <RotateCw size={13} /> | |
| 735 | + | Re-run failed jobs | |
| 736 | + | </Button> | |
| 737 | + | )} | |
| 738 | + | </RerunDialog> | |
| 739 | + | )} | |
| 740 | + | {canRerun && ( | |
| 741 | + | <RerunDialog | |
| 742 | + | intent="rerun" | |
| 743 | + | title="Re-run all jobs" | |
| 744 | + | description="Every job runs again as a new attempt. This attempt stays here, with its logs." | |
| 745 | + | busy={busy} | |
| 746 | + | > | |
| 747 | + | {(open) => ( | |
| 748 | + | <Button type="button" variant="quiet" disabled={busy} onClick={open}> | |
| 749 | + | <RotateCw size={13} /> | |
| 481 | 750 | Re-run all jobs | |
| 482 | − | </SubmitButton> | |
| 483 | − | </> | |
| 484 | − | )} | |
| 485 | − | </Form> | |
| 486 | − | )} | |
| 751 | + | </Button> | |
| 752 | + | )} | |
| 753 | + | </RerunDialog> | |
| 754 | + | )} | |
| 755 | + | </div> | |
| 487 | 756 | </div> | |
| 488 | 757 | <p className="flex flex-wrap items-center gap-x-3 gap-y-1 text-sm text-muted"> | |
| 489 | − | <span>{runWord(run.status, deployments) ?? standingWord(run)}</span> | |
| 758 | + | <span>{runWord(run.status, deployments) ?? (cancelling ? "Cancelling" : standingWord(run))}</span> | |
| 490 | 759 | <span className="inline-flex items-center gap-1 font-mono text-xs"> | |
| 491 | 760 | <GitBranch size={12} /> | |
| 492 | 761 | {shortRef(run.ref)} | |
| ⋯ | |||
| 505 | 774 | {run.actor && ` by ${run.actor}`} · <TimeAgo at={run.createdAt} /> | |
| 506 | 775 | </span> | |
| 507 | 776 | {run.startedAt && <span className="font-mono text-xs">{duration(run.startedAt, run.finishedAt)}</span>} | |
| 508 | − | {run.attempt > 1 && <span>Attempt {run.attempt}</span>} | |
| 777 | + | {run.attempt > 1 && attempts.length <= 1 && <span>Attempt {run.attempt}</span>} | |
| 778 | + | {cancelling && <span className="text-warn">Its jobs are running their cleanup steps</span>} | |
| 509 | 779 | {detail.approval?.state === "approved" && detail.approval.approvedBy && <span>Approved by {detail.approval.approvedBy}</span>} | |
| 510 | 780 | </p> | |
| 511 | 781 | </header> | |
| 512 | 782 | ||
| 513 | 783 | <ErrorText>{actionData && "error" in actionData ? actionData.error : null}</ErrorText> | |
| 784 | + | {!latest && ( | |
| 785 | + | <p className="flex flex-wrap items-center gap-x-2 gap-y-1 rounded-xl border border-line bg-surface px-4 py-3 text-sm text-muted"> | |
| 786 | + | <History size={15} className="shrink-0" /> | |
| 787 | + | <span> | |
| 788 | + | This is attempt #{run.attempt} of {attempts.length}, as it ended. | |
| 789 | + | </span> | |
| 790 | + | <Link to="?" className="text-accent hover:underline"> | |
| 791 | + | See the latest attempt | |
| 792 | + | </Link> | |
| 793 | + | </p> | |
| 794 | + | )} | |
| 514 | 795 | {detail.approval?.state === "required" && <ApprovalPanel approval={detail.approval} member={member} busy={busy} />} | |
| 515 | 796 | {live && held(deployments) && <DeploymentsPanel deployments={deployments} busy={busy} />} | |
| 516 | 797 | {run.error && ( | |
| ⋯ | |||
| 520 | 801 | </div> | |
| 521 | 802 | )} | |
| 522 | 803 | <Notes notes={notes} /> | |
| 804 | + | <Summaries summaries={summaries} repo={{ namespace: params.owner, name: params.repo }} /> | |
| 523 | 805 | {artifacts.length > 0 && ( | |
| 524 | 806 | <section className="rounded-xl border border-line bg-surface p-4"> | |
| 525 | 807 | <h3 className="flex items-center gap-2 text-sm font-medium"> | |
| ⋯ | |||
| 575 | 857 | {jobs.map((job) => ( | |
| 576 | 858 | <Link | |
| 577 | 859 | key={job.id} | |
| 578 | − | to={`?job=${job.id}`} | |
| 860 | + | to={latest ? `?job=${job.id}` : `?attempt=${run.attempt}&job=${job.id}`} | |
| 579 | 861 | preventScrollReset | |
| 580 | 862 | className={`flex items-center gap-2 rounded-md px-2.5 py-1.5 ${ | |
| 581 | 863 | job.id === selected?.id ? "bg-raised text-fg" : "text-muted hover:bg-raised/60 hover:text-fg" | |
| ⋯ | |||
| 588 | 870 | ))} | |
| 589 | 871 | </nav> | |
| 590 | 872 | {/* Run again, a job keeps its id but its log starts afresh. */} | |
| 591 | − | {selected && <JobView key={`${selected.id}:${run.attempt}`} job={selected} base={base} />} | |
| 873 | + | {selected && ( | |
| 874 | + | <JobView | |
| 875 | + | key={`${selected.id}:${run.attempt}`} | |
| 876 | + | job={selected} | |
| 877 | + | base={base} | |
| 878 | + | rerun={ | |
| 879 | + | canRerun && selected.status === "completed" ? ( | |
| 880 | + | <RerunDialog | |
| 881 | + | intent="rerun-job" | |
| 882 | + | job={selected.id} | |
| 883 | + | title="Re-run this job" | |
| 884 | + | description={`${selected.name} runs again as a new attempt, with every job that needs it. A job of a matrix runs again with the rest of its matrix.`} | |
| 885 | + | busy={busy} | |
| 886 | + | > | |
| 887 | + | {(open) => ( | |
| 888 | + | <Hint label="Re-run this job"> | |
| 889 | + | <button | |
| 890 | + | type="button" | |
| 891 | + | aria-label={`Re-run ${selected.name}`} | |
| 892 | + | disabled={busy} | |
| 893 | + | onClick={open} | |
| 894 | + | className="inline-flex items-center rounded-md p-1.5 text-muted ring-1 ring-line hover:text-fg disabled:opacity-50" | |
| 895 | + | > | |
| 896 | + | <RotateCw size={13} /> | |
| 897 | + | </button> | |
| 898 | + | </Hint> | |
| 899 | + | )} | |
| 900 | + | </RerunDialog> | |
| 901 | + | ) : null | |
| 902 | + | } | |
| 903 | + | /> | |
| 904 | + | )} | |
| 592 | 905 | </div> | |
| 593 | 906 | )} | |
| 594 | 907 | </div> | |
| 103 | 103 | selfHosted?: boolean; | |
| 104 | 104 | /** The self-hosted runner that took it, by name. */ | |
| 105 | 105 | runner?: string | null; | |
| 106 | + | /** Cancelled, and running its `if: always()` and `cancelled()` steps and post steps before it ends. */ | |
| 107 | + | cancelling?: boolean; | |
| 108 | + | }; | |
| 109 | + | ||
| 110 | + | /** One attempt of a run: the first, or a re-run. */ | |
| 111 | + | export type RunAttempt = { | |
| 112 | + | attempt: number; | |
| 113 | + | status: RunStatus; | |
| 114 | + | conclusion: Conclusion | null; | |
| 115 | + | /** Who started it: whoever caused the run, then whoever re-ran it. */ | |
| 116 | + | actor: string | null; | |
| 117 | + | /** It ran with debug logging. */ | |
| 118 | + | debug: boolean; | |
| 119 | + | startedAt: string | null; | |
| 120 | + | finishedAt: string | null; | |
| 106 | 121 | }; | |
| 107 | 122 | ||
| 123 | + | /** What a job's steps wrote to `$GITHUB_STEP_SUMMARY`, in Markdown, masked. */ | |
| 124 | + | export type JobSummary = { jobId: string; name: string; steps: { step: number; markdown: string }[] }; | |
| 125 | + | ||
| 126 | + | /** A job's whole log, to download. `omitted`: left out of a run's logs that grew too large. */ | |
| 127 | + | export type JobLogText = { | |
| 128 | + | jobId: string; | |
| 129 | + | name: string; | |
| 130 | + | steps: StepState[]; | |
| 131 | + | chunks: LogChunk[]; | |
| 132 | + | done: boolean; | |
| 133 | + | omitted?: boolean; | |
| 134 | + | }; | |
| 135 | + | ||
| 108 | 136 | export type RunDetail = { | |
| 109 | 137 | run: WorkflowRun; | |
| 110 | 138 | jobs: Job[]; | |
| ⋯ | |||
| 113 | 141 | approval?: RunApproval | null; | |
| 114 | 142 | /** The environments whose protection rules hold its jobs, this attempt. */ | |
| 115 | 143 | pendingDeployments?: PendingDeployment[]; | |
| 144 | + | /** Every attempt, oldest first, the one shown (`run.attempt`) included. */ | |
| 145 | + | attempts?: RunAttempt[]; | |
| 116 | 146 | }; | |
| 117 | 147 | ||
| 118 | 148 | export type RunApproval = { | |
| ⋯ | |||
| 279 | 309 | export interface ActionsApi { | |
| 280 | 310 | workflows(repo: RepoPath, viewer: Viewer): Promise<Result<Workflow[]>>; | |
| 281 | 311 | runs(repo: RepoPath, viewer: Viewer, filter?: RunsFilter): Promise<Result<WorkflowRun[]>>; | |
| 282 | − | run(repo: RepoPath, viewer: Viewer, id: string): Promise<Result<RunDetail>>; | |
| 312 | + | /** The latest attempt, or an earlier one. */ | |
| 313 | + | run(repo: RepoPath, viewer: Viewer, id: string, attempt?: number): Promise<Result<RunDetail>>; | |
| 283 | 314 | logs(repo: RepoPath, viewer: Viewer, job: string, after?: number): Promise<Result<JobLog>>; | |
| 315 | + | /** The job summaries of an attempt (the latest by default), jobs without one left out. */ | |
| 316 | + | summaries(repo: RepoPath, viewer: Viewer, id: string, attempt?: number): Promise<Result<JobSummary[]>>; | |
| 317 | + | /** A job's whole log, any attempt's, by the id the run gave the job. */ | |
| 318 | + | jobLogText(repo: RepoPath, viewer: Viewer, job: string): Promise<Result<JobLogText>>; | |
| 319 | + | /** Every job's whole log for an attempt, to download as one archive. */ | |
| 320 | + | runLogs(repo: RepoPath, viewer: Viewer, id: string, attempt?: number): Promise<Result<JobLogText[]>>; | |
| 284 | 321 | dispatch( | |
| 285 | 322 | actor: User, | |
| 286 | 323 | repo: RepoPath, | |
| ⋯ | |||
| 288 | 325 | ref: string | undefined, | |
| 289 | 326 | inputs: Record<string, unknown>, | |
| 290 | 327 | ): Promise<Result<WorkflowRun>>; | |
| 291 | − | cancel(actor: User, repo: RepoPath, id: string): Promise<Result<WorkflowRun>>; | |
| 292 | − | rerun(actor: User, repo: RepoPath, id: string, failedOnly?: boolean): Promise<Result<WorkflowRun>>; | |
| 328 | + | /** Running jobs clean up first; `force` (or cancelling a run that is cancelling) stops them outright. */ | |
| 329 | + | cancel(actor: User, repo: RepoPath, id: string, force?: boolean): Promise<Result<WorkflowRun>>; | |
| 330 | + | /** A new attempt: every job, the failed ones, or `job` and what needs it; `debug` for debug logging. */ | |
| 331 | + | rerun( | |
| 332 | + | actor: User, | |
| 333 | + | repo: RepoPath, | |
| 334 | + | id: string, | |
| 335 | + | failedOnly?: boolean, | |
| 336 | + | options?: { job?: string; debug?: boolean }, | |
| 337 | + | ): Promise<Result<WorkflowRun>>; | |
| 293 | 338 | setWorkflowEnabled(actor: User, repo: RepoPath, workflow: string, enabled: boolean): Promise<Result<Workflow>>; | |
| 294 | 339 | settings(actor: User, owner: SettingsOwner, kind: SettingKindFilter): Promise<Result<Setting[]>>; | |
| 295 | 340 | /** `value` null keeps an existing entry's default value. */ | |
| 761 | 761 | return { | |
| 762 | 762 | workflows: (repo, viewer) => call("workflows", { repo, viewer }), | |
| 763 | 763 | runs: (repo, viewer, filter = {}) => call("runs", { repo, viewer, ...filter }), | |
| 764 | − | run: (repo, viewer, id) => call("run", { repo, viewer, id }), | |
| 764 | + | run: (repo, viewer, id, attempt) => call("run", { repo, viewer, id, attempt: attempt ?? null }), | |
| 765 | 765 | logs: (repo, viewer, job, after = 0) => call("logs", { repo, viewer, job, after }), | |
| 766 | + | summaries: (repo, viewer, id, attempt) => call("summaries", { repo, viewer, id, attempt: attempt ?? null }), | |
| 767 | + | jobLogText: (repo, viewer, job) => call("job_log_text", { repo, viewer, job }), | |
| 768 | + | runLogs: (repo, viewer, id, attempt) => call("run_logs", { repo, viewer, id, attempt: attempt ?? null }), | |
| 766 | 769 | dispatch: (actor, repo, workflow, ref, inputs) => call("dispatch", { actor, repo, workflow, ref, inputs }), | |
| 767 | − | cancel: (actor, repo, id) => call("cancel", { actor, repo, id }), | |
| 768 | − | rerun: (actor, repo, id, failedOnly = false) => call("rerun", { actor, repo, id, failed_only: failedOnly }), | |
| 770 | + | cancel: (actor, repo, id, force = false) => call("cancel", { actor, repo, id, force }), | |
| 771 | + | rerun: (actor, repo, id, failedOnly = false, options = {}) => | |
| 772 | + | call("rerun", { actor, repo, id, failed_only: failedOnly, job: options.job ?? null, debug: options.debug ?? false }), | |
| 769 | 773 | setWorkflowEnabled: (actor, repo, workflow, enabled) => | |
| 770 | 774 | call("set_workflow_enabled", { actor, repo, workflow, enabled }), | |
| 771 | 775 | settings: (actor, owner, kind) => call("settings", { actor, ...owner, kind }), |