A run opens on its summary: what triggered it, its status, duration and artifacts, a graph of its jobs (what needs what, matrices folded, called workflows boxed, deploys with their address), annotations, and each job's summary; the job list groups the same way
9 files+1269−1170/9 viewed
| 576 | 576 | `pattern`, uploads them as one artifact (`name`, `merged-artifacts` unless | |
| 577 | 577 | you say), and deletes them with `delete-merged: true`. | |
| 578 | 578 | ||
| 579 | − | A run's page lists its artifacts with their size and when they expire. | |
| 579 | + | A run's summary lists its artifacts with their size and when they expire. | |
| 580 | 580 | Anyone who can see the run downloads them there; someone with the Write | |
| 581 | 581 | role can delete one before it expires. | |
| 582 | 582 | ||
| ⋯ | |||
| 877 | 877 | see its runs, run it by hand if it has `workflow_dispatch`, or turn it off | |
| 878 | 878 | without touching its file. | |
| 879 | 879 | ||
| 880 | − | A run's page shows its jobs, each job's steps, and their logs as they are | |
| 881 | − | written. Groups fold, errors and warnings are marked, and secrets are | |
| 882 | − | replaced with `***`. | |
| 880 | + | A run's page opens on its **Summary**. Its sidebar lists **All jobs**; | |
| 881 | + | pick one to see its steps and their logs as they are written. Groups | |
| 882 | + | fold, errors and warnings are marked, and secrets are replaced with | |
| 883 | + | `***`. The page keeps itself up to date while the run goes on. | |
| 883 | 884 | ||
| 884 | 885 | The start of each job's log lists what its [token](#the-jobs-token) may do. | |
| 885 | 886 | ||
| 887 | + | ### The run's summary | |
| 888 | + | ||
| 889 | + | The summary holds, top to bottom: | |
| 890 | + | ||
| 891 | + | | Part | What it shows | | |
| 892 | + | | --- | --- | | |
| 893 | + | | The run | What triggered it and when, who, the commit and its branch (or pull request), how the run stands, how long it took in all, and how many artifacts it kept. | | |
| 894 | + | | The jobs graph | The workflow file and its event (`deploy.yml · on: push`), then every job and how they depend on each other. See [the jobs graph](#the-jobs-graph). | | |
| 895 | + | | Annotations | Every job's errors, warnings and notices, counted by kind, each with the job it came from. Folds away. | | |
| 896 | + | | Job summaries | What each job's steps wrote to `$GITHUB_STEP_SUMMARY`, under `<job> summary`. See [job summaries](#job-summaries). | | |
| 897 | + | | Artifacts | What the run kept, to download. The count in the run's card jumps here. | | |
| 898 | + | ||
| 899 | + | Under **Run details** in the sidebar, **Workflow file** opens the | |
| 900 | + | workflow as it was at the run's commit. | |
| 901 | + | ||
| 902 | + | ### The jobs graph | |
| 903 | + | ||
| 904 | + | Jobs are laid out left to right by their `needs:`: a job that needs | |
| 905 | + | nothing starts a row on the left, and each job sits one column to the | |
| 906 | + | right of the furthest job it needs. Within a column, jobs are ordered so | |
| 907 | + | that as few connectors as possible cross. | |
| 908 | + | ||
| 909 | + | | Node | How it shows | | |
| 910 | + | | --- | --- | | |
| 911 | + | | A job | Its status, its name and how long it took. Click it to open its steps and logs. | | |
| 912 | + | | A job that deploys | Its environment's address (`environment.url`), as a link, under its name. | | |
| 913 | + | | A matrix | One node with how many jobs it made (`test · 6 jobs`) and how they stand together. Click it to list each combination; click one to open it. | | |
| 914 | + | | A reusable workflow | A box under the job that calls it, named with the called file, holding the called workflow's jobs laid out the same way. The jobs that need the calling job connect to the box. | | |
| 915 | + | ||
| 916 | + | Connectors run from a job to each job that needs it. They are red from a | |
| 917 | + | job that failed, and move while the job they lead to is running (they | |
| 918 | + | stay still if your system asks for reduced motion). | |
| 919 | + | ||
| 920 | + | A wide graph scrolls sideways inside its card, on a phone as on a desktop. | |
| 921 | + | ||
| 922 | + | The sidebar groups jobs the same way: a matrix's jobs, and a called | |
| 923 | + | workflow's jobs, fold under their name. | |
| 924 | + | ||
| 886 | 925 | ### Job summaries | |
| 887 | 926 | ||
| 888 | − | Markdown a step appends to the file in `$GITHUB_STEP_SUMMARY` shows at | |
| 889 | − | the top of the run's page, a card per job, in the order its steps wrote | |
| 890 | − | it: | |
| 927 | + | Markdown a step appends to the file in `$GITHUB_STEP_SUMMARY` shows on | |
| 928 | + | the run's summary, below the graph, a card per job, in the order its | |
| 929 | + | steps wrote it. The card's title opens the job: | |
| 891 | 930 | ||
| 892 | 931 | ```yaml | |
| 893 | 932 | - name: Report the tests | |
| 1 | + | import { ChevronDown, ChevronRight, ExternalLink, Workflow } from "lucide-react"; | |
| 2 | + | import { useMemo, useState } from "react"; | |
| 3 | + | import { Link } from "react-router"; | |
| 4 | + | ||
| 5 | + | import { Duration, StatusIcon } from "./actions"; | |
| 6 | + | import { GRAPH, type GraphJob, type EdgeState, type PlacedGroup, type PlacedNode, type Standing, layoutRun, sharedUrl, standingOf, unitStanding } from "../lib/run-graph"; | |
| 7 | + | ||
| 8 | + | /** A job calling a workflow shows as running while its jobs do. */ | |
| 9 | + | function Icon({ standing, environment, size = 14 }: { standing: Standing; environment?: string | null; size?: number }) { | |
| 10 | + | const status = standing.status === "calling" ? "in_progress" : standing.status; | |
| 11 | + | return <StatusIcon status={status} conclusion={standing.conclusion} of="job" environment={environment} size={size} />; | |
| 12 | + | } | |
| 13 | + | ||
| 14 | + | const EDGE: Record<EdgeState, string> = { | |
| 15 | + | idle: "stroke-line-strong", | |
| 16 | + | failed: "stroke-danger/70", | |
| 17 | + | active: "stroke-warn animate-dash", | |
| 18 | + | }; | |
| 19 | + | ||
| 20 | + | const NODE = "absolute overflow-hidden rounded-lg border bg-surface text-sm shadow-sm transition-colors"; | |
| 21 | + | ||
| 22 | + | function nodeBorder(selected: boolean): string { | |
| 23 | + | return selected ? "border-accent ring-1 ring-accent/40" : "border-line hover:border-line-strong"; | |
| 24 | + | } | |
| 25 | + | ||
| 26 | + | function JobNode({ node, href, selected }: { node: PlacedNode & { unit: { kind: "job" } }; href: (job: GraphJob) => string; selected: string | null }) { | |
| 27 | + | const { job, label } = node.unit; | |
| 28 | + | return ( | |
| 29 | + | <div className={`${NODE} ${nodeBorder(job.id === selected)}`} style={{ left: node.x, top: node.y, width: node.w, height: node.h }}> | |
| 30 | + | <Link | |
| 31 | + | to={href(job)} | |
| 32 | + | preventScrollReset | |
| 33 | + | className="flex items-center gap-2 px-3 hover:bg-raised/40" | |
| 34 | + | style={{ height: GRAPH.nodeHeight }} | |
| 35 | + | aria-current={job.id === selected ? "page" : undefined} | |
| 36 | + | > | |
| 37 | + | <Icon standing={job} environment={job.environment} /> | |
| 38 | + | <span className="min-w-0 grow truncate font-medium">{label}</span> | |
| 39 | + | <Duration className="shrink-0 font-mono text-xs text-faint" start={job.startedAt} end={job.finishedAt} /> | |
| 40 | + | </Link> | |
| 41 | + | {job.environmentUrl && <Address url={job.environmentUrl} />} | |
| 42 | + | </div> | |
| 43 | + | ); | |
| 44 | + | } | |
| 45 | + | ||
| 46 | + | /** Where a deployment is, under its node's name. */ | |
| 47 | + | function Address({ url }: { url: string }) { | |
| 48 | + | return ( | |
| 49 | + | <a | |
| 50 | + | href={url} | |
| 51 | + | target="_blank" | |
| 52 | + | rel="noreferrer" | |
| 53 | + | className="-mt-2 flex items-center gap-1 truncate pr-3 pl-9 font-mono text-xs text-accent hover:underline" | |
| 54 | + | style={{ height: GRAPH.urlHeight }} | |
| 55 | + | > | |
| 56 | + | <span className="truncate">{url.replace(/^https?:\/\//, "")}</span> | |
| 57 | + | <ExternalLink size={11} className="shrink-0" /> | |
| 58 | + | </a> | |
| 59 | + | ); | |
| 60 | + | } | |
| 61 | + | ||
| 62 | + | function MatrixNode({ | |
| 63 | + | node, | |
| 64 | + | href, | |
| 65 | + | selected, | |
| 66 | + | toggle, | |
| 67 | + | }: { | |
| 68 | + | node: PlacedNode & { unit: { kind: "matrix" } }; | |
| 69 | + | href: (job: GraphJob) => string; | |
| 70 | + | selected: string | null; | |
| 71 | + | toggle: (key: string) => void; | |
| 72 | + | }) { | |
| 73 | + | const { jobs, label, key } = node.unit; | |
| 74 | + | const holdsSelected = jobs.some((job) => job.id === selected); | |
| 75 | + | const url = sharedUrl(jobs); | |
| 76 | + | return ( | |
| 77 | + | <div className={`${NODE} ${nodeBorder(holdsSelected && !node.expanded)}`} style={{ left: node.x, top: node.y, width: node.w, height: node.h }}> | |
| 78 | + | <button | |
| 79 | + | type="button" | |
| 80 | + | onClick={() => toggle(key)} | |
| 81 | + | aria-expanded={node.expanded} | |
| 82 | + | className="flex w-full items-center gap-2 px-3 text-left hover:bg-raised/40" | |
| 83 | + | style={{ height: GRAPH.nodeHeight }} | |
| 84 | + | > | |
| 85 | + | <Icon standing={standingOf(jobs)} /> | |
| 86 | + | <span className="min-w-0 grow truncate font-medium">{label}</span> | |
| 87 | + | <span className="shrink-0 text-xs text-faint">{jobs.length} jobs</span> | |
| 88 | + | {node.expanded ? <ChevronDown size={14} className="shrink-0 text-faint" /> : <ChevronRight size={14} className="shrink-0 text-faint" />} | |
| 89 | + | </button> | |
| 90 | + | {url && <Address url={url} />} | |
| 91 | + | {node.expanded && ( | |
| 92 | + | <ul className="border-t border-line py-0.5"> | |
| 93 | + | {jobs.map((job) => ( | |
| 94 | + | <li key={job.id}> | |
| 95 | + | <Link | |
| 96 | + | to={href(job)} | |
| 97 | + | preventScrollReset | |
| 98 | + | aria-current={job.id === selected ? "page" : undefined} | |
| 99 | + | className={`flex items-center gap-2 px-3 text-[0.8125rem] ${job.id === selected ? "bg-raised text-fg" : "text-muted hover:bg-raised/60 hover:text-fg"}`} | |
| 100 | + | style={{ height: GRAPH.rowHeight }} | |
| 101 | + | > | |
| 102 | + | <Icon standing={job} environment={job.environment} size={13} /> | |
| 103 | + | <span className="min-w-0 grow truncate">{matrixValues(job.name, label)}</span> | |
| 104 | + | <Duration className="shrink-0 font-mono text-xs text-faint" start={job.startedAt} end={job.finishedAt} /> | |
| 105 | + | </Link> | |
| 106 | + | </li> | |
| 107 | + | ))} | |
| 108 | + | </ul> | |
| 109 | + | )} | |
| 110 | + | </div> | |
| 111 | + | ); | |
| 112 | + | } | |
| 113 | + | ||
| 114 | + | /** `ubuntu-latest, 20` from `test (ubuntu-latest, 20)`; the whole name when it is not that. */ | |
| 115 | + | function matrixValues(name: string, label: string): string { | |
| 116 | + | const own = name.split(" / ").pop() ?? name; | |
| 117 | + | const match = /\(([^()]*)\)$/.exec(own); | |
| 118 | + | return match && own.startsWith(label) ? match[1]! : own; | |
| 119 | + | } | |
| 120 | + | ||
| 121 | + | function GroupBox({ group, href, selected }: { group: PlacedGroup; href: (job: GraphJob) => string; selected: string | null }) { | |
| 122 | + | const { unit } = group; | |
| 123 | + | const caller = unit.callers.length === 1 ? unit.callers[0]! : null; | |
| 124 | + | const title = ( | |
| 125 | + | <> | |
| 126 | + | <Icon standing={unitStanding(unit)} size={13} /> | |
| 127 | + | <span className="truncate font-medium">{unit.label}</span> | |
| 128 | + | </> | |
| 129 | + | ); | |
| 130 | + | return ( | |
| 131 | + | <div | |
| 132 | + | className="absolute rounded-xl border border-dashed border-line-strong bg-raised/20" | |
| 133 | + | style={{ left: group.x, top: group.y, width: group.w, height: group.h }} | |
| 134 | + | > | |
| 135 | + | <div className="flex items-center gap-2 px-3 text-xs" style={{ height: GRAPH.groupHead }}> | |
| 136 | + | {caller ? ( | |
| 137 | + | <Link | |
| 138 | + | to={href(caller)} | |
| 139 | + | preventScrollReset | |
| 140 | + | className={`flex min-w-0 items-center gap-1.5 hover:text-fg ${caller.id === selected ? "text-fg" : "text-fg/85"}`} | |
| 141 | + | > | |
| 142 | + | {title} | |
| 143 | + | </Link> | |
| 144 | + | ) : ( | |
| 145 | + | <span className="flex min-w-0 items-center gap-1.5 text-fg/85">{title}</span> | |
| 146 | + | )} | |
| 147 | + | {unit.uses && ( | |
| 148 | + | <span className="ml-auto flex min-w-0 items-center gap-1 font-mono text-faint"> | |
| 149 | + | <Workflow size={11} className="shrink-0" /> | |
| 150 | + | <span className="truncate">{unit.uses.split("/").pop()}</span> | |
| 151 | + | </span> | |
| 152 | + | )} | |
| 153 | + | </div> | |
| 154 | + | </div> | |
| 155 | + | ); | |
| 156 | + | } | |
| 157 | + | ||
| 158 | + | /** | |
| 159 | + | * A run's jobs as a graph: each job a node, joined to the jobs that need | |
| 160 | + | * it. A matrix's jobs make one node that opens to list them; a called | |
| 161 | + | * workflow's jobs sit in a box under the job that calls it. Scrolls | |
| 162 | + | * sideways inside its card when wider than the page. | |
| 163 | + | */ | |
| 164 | + | export function RunGraph({ | |
| 165 | + | jobs, | |
| 166 | + | title, | |
| 167 | + | trigger, | |
| 168 | + | href, | |
| 169 | + | selected = null, | |
| 170 | + | }: { | |
| 171 | + | jobs: GraphJob[]; | |
| 172 | + | /** The workflow file's name. */ | |
| 173 | + | title: string; | |
| 174 | + | /** The event that started the run. */ | |
| 175 | + | trigger: string; | |
| 176 | + | href: (job: GraphJob) => string; | |
| 177 | + | selected?: string | null; | |
| 178 | + | }) { | |
| 179 | + | const [expanded, setExpanded] = useState<ReadonlySet<string>>(() => new Set()); | |
| 180 | + | const layout = useMemo(() => layoutRun(jobs, expanded), [jobs, expanded]); | |
| 181 | + | const toggle = (key: string) => | |
| 182 | + | setExpanded((was) => { | |
| 183 | + | const now = new Set(was); | |
| 184 | + | if (!now.delete(key)) now.add(key); | |
| 185 | + | return now; | |
| 186 | + | }); | |
| 187 | + | if (jobs.length === 0) return null; | |
| 188 | + | return ( | |
| 189 | + | <section aria-label="Jobs graph" className="overflow-hidden rounded-xl border border-line bg-surface"> | |
| 190 | + | <header className="flex min-w-0 items-center gap-1.5 border-b border-line px-4 py-2.5 text-sm"> | |
| 191 | + | <span className="truncate font-mono font-medium">{title}</span> | |
| 192 | + | <span className="shrink-0 text-faint">· on: {trigger}</span> | |
| 193 | + | </header> | |
| 194 | + | <div className="overflow-x-auto bg-bg/40"> | |
| 195 | + | <div className="relative" style={{ width: layout.width, height: layout.height }}> | |
| 196 | + | {layout.groups.map((group) => ( | |
| 197 | + | <GroupBox key={group.unit.key} group={group} href={href} selected={selected} /> | |
| 198 | + | ))} | |
| 199 | + | <svg className="pointer-events-none absolute inset-0" width={layout.width} height={layout.height} aria-hidden="true"> | |
| 200 | + | {layout.edges.map((edge) => ( | |
| 201 | + | <path | |
| 202 | + | key={`${edge.from}>${edge.to}`} | |
| 203 | + | d={edge.path} | |
| 204 | + | fill="none" | |
| 205 | + | strokeWidth={1.5} | |
| 206 | + | strokeDasharray={edge.state === "active" ? "4 4" : undefined} | |
| 207 | + | className={EDGE[edge.state]} | |
| 208 | + | /> | |
| 209 | + | ))} | |
| 210 | + | </svg> | |
| 211 | + | {layout.nodes.map((node) => | |
| 212 | + | node.unit.kind === "job" ? ( | |
| 213 | + | <JobNode key={node.unit.key} node={node as PlacedNode & { unit: { kind: "job" } }} href={href} selected={selected} /> | |
| 214 | + | ) : ( | |
| 215 | + | <MatrixNode | |
| 216 | + | key={node.unit.key} | |
| 217 | + | node={node as PlacedNode & { unit: { kind: "matrix" } }} | |
| 218 | + | href={href} | |
| 219 | + | selected={selected} | |
| 220 | + | toggle={toggle} | |
| 221 | + | /> | |
| 222 | + | ), | |
| 223 | + | )} | |
| 224 | + | </div> | |
| 225 | + | </div> | |
| 226 | + | </section> | |
| 227 | + | ); | |
| 228 | + | } |
| 1 | + | import assert from "node:assert/strict"; | |
| 2 | + | import { test } from "node:test"; | |
| 3 | + | ||
| 4 | + | import { | |
| 5 | + | GRAPH, | |
| 6 | + | type GraphJob, | |
| 7 | + | baseName, | |
| 8 | + | columns, | |
| 9 | + | connector, | |
| 10 | + | crossings, | |
| 11 | + | groupJobs, | |
| 12 | + | layoutRun, | |
| 13 | + | orderColumns, | |
| 14 | + | standingOf, | |
| 15 | + | } from "./run-graph.ts"; | |
| 16 | + | ||
| 17 | + | let next = 0; | |
| 18 | + | function job(key: string, needs: string[] = [], more: Partial<GraphJob> = {}): GraphJob { | |
| 19 | + | return { | |
| 20 | + | id: `job_${next++}`, | |
| 21 | + | key, | |
| 22 | + | name: key, | |
| 23 | + | needs, | |
| 24 | + | status: "completed", | |
| 25 | + | conclusion: "success", | |
| 26 | + | startedAt: null, | |
| 27 | + | finishedAt: null, | |
| 28 | + | ...more, | |
| 29 | + | }; | |
| 30 | + | } | |
| 31 | + | ||
| 32 | + | test("columns go by the longest path of needs", () => { | |
| 33 | + | const units = groupJobs([job("lint"), job("build"), job("test", ["build"]), job("deploy", ["build", "test"])]); | |
| 34 | + | const column = columns(units); | |
| 35 | + | assert.deepEqual(Object.fromEntries(column), { lint: 0, build: 0, test: 1, deploy: 2 }); | |
| 36 | + | }); | |
| 37 | + | ||
| 38 | + | test("a need of a job not in the run is left out", () => { | |
| 39 | + | const column = columns(groupJobs([job("a", ["gone"]), job("b", ["a"])])); | |
| 40 | + | assert.deepEqual(Object.fromEntries(column), { a: 0, b: 1 }); | |
| 41 | + | }); | |
| 42 | + | ||
| 43 | + | test("a cycle cannot loop the layout", () => { | |
| 44 | + | const jobs = [job("a", ["b"]), job("b", ["a"]), job("c", ["c"])]; | |
| 45 | + | const column = columns(groupJobs(jobs)); | |
| 46 | + | for (const value of column.values()) assert.ok(Number.isFinite(value) && value >= 0); | |
| 47 | + | const layout = layoutRun(jobs); | |
| 48 | + | // Never a connector going back a column. | |
| 49 | + | assert.ok(layout.edges.length <= 1); | |
| 50 | + | for (const edge of layout.edges) assert.ok(column.get(edge.from)! < column.get(edge.to)!); | |
| 51 | + | }); | |
| 52 | + | ||
| 53 | + | test("ordering a column by its needs takes out a crossing", () => { | |
| 54 | + | // As listed: a, b over x, y, with x needing b and y needing a: they cross. | |
| 55 | + | const units = groupJobs([job("a"), job("b"), job("x", ["b"]), job("y", ["a"])]); | |
| 56 | + | const column = columns(units); | |
| 57 | + | const edges: [string, string][] = [ | |
| 58 | + | ["b", "x"], | |
| 59 | + | ["a", "y"], | |
| 60 | + | ]; | |
| 61 | + | assert.equal(crossings([["a", "b"], ["x", "y"]], edges), 1); | |
| 62 | + | const order = orderColumns(units, column); | |
| 63 | + | assert.deepEqual(order, [ | |
| 64 | + | ["a", "b"], | |
| 65 | + | ["y", "x"], | |
| 66 | + | ]); | |
| 67 | + | assert.equal(crossings(order, edges), 0); | |
| 68 | + | }); | |
| 69 | + | ||
| 70 | + | test("ordering keeps the order as listed when nothing crosses", () => { | |
| 71 | + | const units = groupJobs([job("a"), job("b"), job("x", ["a"]), job("y", ["b"])]); | |
| 72 | + | assert.deepEqual(orderColumns(units, columns(units)), [ | |
| 73 | + | ["a", "b"], | |
| 74 | + | ["x", "y"], | |
| 75 | + | ]); | |
| 76 | + | }); | |
| 77 | + | ||
| 78 | + | test("a matrix's jobs are one node, named without their combination", () => { | |
| 79 | + | const units = groupJobs([ | |
| 80 | + | job("build"), | |
| 81 | + | job("test", ["build"], { name: "test (ubuntu-latest, 20)" }), | |
| 82 | + | job("test", ["build"], { name: "test (ubuntu-latest, 22)" }), | |
| 83 | + | job("test", ["build"], { name: "test (macos-latest, 22)" }), | |
| 84 | + | ]); | |
| 85 | + | assert.equal(units.length, 2); | |
| 86 | + | const matrix = units[1]!; | |
| 87 | + | assert.equal(matrix.kind, "matrix"); | |
| 88 | + | assert.equal(matrix.label, "test"); | |
| 89 | + | assert.equal(matrix.kind === "matrix" && matrix.jobs.length, 3); | |
| 90 | + | assert.equal(baseName("test (a, b)"), "test"); | |
| 91 | + | assert.equal(baseName("plain"), "plain"); | |
| 92 | + | }); | |
| 93 | + | ||
| 94 | + | test("an expanded matrix is taller, and its connectors still meet its first line", () => { | |
| 95 | + | const jobs = [job("build"), job("test", ["build"], { name: "test (1)" }), job("test", ["build"], { name: "test (2)" })]; | |
| 96 | + | const closed = layoutRun(jobs); | |
| 97 | + | const open = layoutRun(jobs, new Set(["test"])); | |
| 98 | + | const node = (layout: typeof closed) => layout.nodes.find((n) => n.unit.key === "test")!; | |
| 99 | + | assert.equal(node(closed).h, GRAPH.nodeHeight); | |
| 100 | + | assert.equal(node(open).h, GRAPH.nodeHeight + 2 * GRAPH.rowHeight + 6); | |
| 101 | + | assert.ok(node(open).expanded); | |
| 102 | + | assert.equal(open.edges.length, 1); | |
| 103 | + | assert.equal(open.edges[0]!.path, closed.edges[0]!.path); | |
| 104 | + | }); | |
| 105 | + | ||
| 106 | + | test("a called workflow's jobs sit in a box under the job calling it", () => { | |
| 107 | + | const jobs = [ | |
| 108 | + | job("build", [], { name: "Build", uses: "./.g1t/workflows/build.yml" }), | |
| 109 | + | job("build/compile", [], { name: "Build / compile" }), | |
| 110 | + | job("build/package", ["build/compile"], { name: "Build / package" }), | |
| 111 | + | job("deploy", ["build"]), | |
| 112 | + | ]; | |
| 113 | + | const units = groupJobs(jobs); | |
| 114 | + | assert.deepEqual( | |
| 115 | + | units.map((unit) => [unit.kind, unit.key]), | |
| 116 | + | [ | |
| 117 | + | ["call", "build"], | |
| 118 | + | ["job", "deploy"], | |
| 119 | + | ], | |
| 120 | + | ); | |
| 121 | + | const call = units[0]!; | |
| 122 | + | assert.ok(call.kind === "call"); | |
| 123 | + | assert.equal(call.uses, "./.g1t/workflows/build.yml"); | |
| 124 | + | assert.deepEqual( | |
| 125 | + | call.units.map((unit) => unit.label), | |
| 126 | + | ["compile", "package"], | |
| 127 | + | ); | |
| 128 | + | ||
| 129 | + | const layout = layoutRun(jobs); | |
| 130 | + | const box = layout.groups[0]!; | |
| 131 | + | const compile = layout.nodes.find((n) => n.unit.key === "build/compile")!; | |
| 132 | + | const pack = layout.nodes.find((n) => n.unit.key === "build/package")!; | |
| 133 | + | const deploy = layout.nodes.find((n) => n.unit.key === "deploy")!; | |
| 134 | + | // Inside the box, one after the other; the job needing the caller after the box. | |
| 135 | + | assert.ok(compile.x >= box.x && compile.x + compile.w <= box.x + box.w); | |
| 136 | + | assert.ok(pack.x > compile.x && pack.x + pack.w <= box.x + box.w); | |
| 137 | + | assert.ok(compile.y >= box.y + GRAPH.groupHead); | |
| 138 | + | assert.ok(deploy.x >= box.x + box.w + GRAPH.columnGap); | |
| 139 | + | assert.deepEqual( | |
| 140 | + | layout.edges.map((edge) => [edge.from, edge.to]).sort(), | |
| 141 | + | [ | |
| 142 | + | ["build", "deploy"], | |
| 143 | + | ["build/compile", "build/package"], | |
| 144 | + | ], | |
| 145 | + | ); | |
| 146 | + | assert.equal(layout.width, deploy.x + deploy.w + GRAPH.margin); | |
| 147 | + | }); | |
| 148 | + | ||
| 149 | + | test("a connector's colour follows the run: red from a failure, moving into a running job", () => { | |
| 150 | + | const layout = layoutRun([ | |
| 151 | + | job("a", [], { conclusion: "failure" }), | |
| 152 | + | job("b", ["a"], { conclusion: "skipped" }), | |
| 153 | + | job("c"), | |
| 154 | + | job("d", ["c"], { status: "in_progress", conclusion: null }), | |
| 155 | + | job("e", ["c"], { status: "waiting", conclusion: null }), | |
| 156 | + | ]); | |
| 157 | + | const state = Object.fromEntries(layout.edges.map((edge) => [`${edge.from}>${edge.to}`, edge.state])); | |
| 158 | + | assert.deepEqual(state, { "a>b": "failed", "c>d": "active", "c>e": "idle" }); | |
| 159 | + | }); | |
| 160 | + | ||
| 161 | + | test("a deployment's address makes its node taller", () => { | |
| 162 | + | const layout = layoutRun([job("deploy", [], { environment: "production", environmentUrl: "https://g1t.page" })]); | |
| 163 | + | assert.equal(layout.nodes[0]!.h, GRAPH.nodeHeight + GRAPH.urlHeight); | |
| 164 | + | }); | |
| 165 | + | ||
| 166 | + | test("several jobs stand together as running, then as the worst ending", () => { | |
| 167 | + | assert.deepEqual(standingOf([job("a"), job("b", [], { status: "calling", conclusion: null })]), { status: "in_progress", conclusion: null }); | |
| 168 | + | assert.deepEqual(standingOf([job("a"), job("b", [], { conclusion: "failure" }), job("c", [], { conclusion: "cancelled" })]), { | |
| 169 | + | status: "completed", | |
| 170 | + | conclusion: "failure", | |
| 171 | + | }); | |
| 172 | + | assert.deepEqual(standingOf([job("a", [], { conclusion: "skipped" })]), { status: "completed", conclusion: "skipped" }); | |
| 173 | + | }); | |
| 174 | + | ||
| 175 | + | test("connectors run straight on one row, and turn with rounded corners between rows", () => { | |
| 176 | + | assert.equal(connector(0, 10, 50, 10), "M0 10H50"); | |
| 177 | + | const turn = connector(0, 10, 100, 70); | |
| 178 | + | assert.match(turn, /^M0 10H\d+Q/); | |
| 179 | + | assert.ok(turn.endsWith("H100")); | |
| 180 | + | }); | |
| 181 | + | ||
| 182 | + | test("an empty run lays out as nothing", () => { | |
| 183 | + | const layout = layoutRun([]); | |
| 184 | + | assert.equal(layout.nodes.length, 0); | |
| 185 | + | assert.equal(layout.edges.length, 0); | |
| 186 | + | }); | |
| 187 | + | ||
| 188 | + | test("a matrix whose jobs deploy to one address shows it once", () => { | |
| 189 | + | const url = "https://g1t.sh"; | |
| 190 | + | const shared = layoutRun([ | |
| 191 | + | job("core", [], { name: "core (a)", environmentUrl: url }), | |
| 192 | + | job("core", [], { name: "core (b)", environmentUrl: url }), | |
| 193 | + | ]); | |
| 194 | + | assert.equal(shared.nodes[0]!.h, GRAPH.nodeHeight + GRAPH.urlHeight); | |
| 195 | + | const mixed = layoutRun([ | |
| 196 | + | job("core", [], { name: "core (a)", environmentUrl: url }), | |
| 197 | + | job("core", [], { name: "core (b)", environmentUrl: null }), | |
| 198 | + | ]); | |
| 199 | + | assert.equal(mixed.nodes[0]!.h, GRAPH.nodeHeight); | |
| 200 | + | }); |
| 1 | + | import type { Conclusion, Job } from "@g1t/contracts"; | |
| 2 | + | ||
| 3 | + | /** | |
| 4 | + | * A run's jobs as a graph: grouped (a matrix's jobs into one node, a called | |
| 5 | + | * workflow's jobs into a box under the job that calls it), placed in | |
| 6 | + | * columns by how deep their `needs` go, ordered within a column so fewer | |
| 7 | + | * connectors cross, and joined by connectors. Pure, so the run page draws | |
| 8 | + | * it again from each refresh of its data. | |
| 9 | + | */ | |
| 10 | + | ||
| 11 | + | /** What the graph reads of a job. */ | |
| 12 | + | export type GraphJob = Pick<Job, "id" | "key" | "name" | "needs" | "status" | "conclusion" | "startedAt" | "finishedAt"> & | |
| 13 | + | Partial<Pick<Job, "environment" | "environmentUrl" | "uses">>; | |
| 14 | + | ||
| 15 | + | /** Where a node or a group stands, as the status icon shows it. */ | |
| 16 | + | export type Standing = { status: Job["status"]; conclusion: Conclusion | null }; | |
| 17 | + | ||
| 18 | + | /** One job; a matrix's jobs (one key, several jobs); or a job calling a workflow, with that workflow's jobs. */ | |
| 19 | + | export type Unit = | |
| 20 | + | | { kind: "job"; key: string; label: string; needs: string[]; job: GraphJob } | |
| 21 | + | | { kind: "matrix"; key: string; label: string; needs: string[]; jobs: GraphJob[] } | |
| 22 | + | | { kind: "call"; key: string; label: string; needs: string[]; uses: string | null; callers: GraphJob[]; units: Unit[] }; | |
| 23 | + | ||
| 24 | + | /** Sizes, in pixels. */ | |
| 25 | + | export const GRAPH = { | |
| 26 | + | nodeWidth: 220, | |
| 27 | + | nodeHeight: 44, | |
| 28 | + | /** The line a deployment's address takes. */ | |
| 29 | + | urlHeight: 18, | |
| 30 | + | /** A matrix's job, listed in its expanded node. */ | |
| 31 | + | rowHeight: 32, | |
| 32 | + | columnGap: 48, | |
| 33 | + | rowGap: 16, | |
| 34 | + | groupPad: 12, | |
| 35 | + | groupHead: 32, | |
| 36 | + | margin: 16, | |
| 37 | + | } as const; | |
| 38 | + | ||
| 39 | + | /** `test` from `test (ubuntu-latest, 20)`. */ | |
| 40 | + | export function baseName(name: string): string { | |
| 41 | + | return name.replace(/\s*\([^()]*\)$/, "") || name; | |
| 42 | + | } | |
| 43 | + | ||
| 44 | + | /** A called workflow's job by its own name: `test`, not `build / test`. */ | |
| 45 | + | function within(name: string, parent: string | null): string { | |
| 46 | + | return parent && name.startsWith(`${parent} / `) ? name.slice(parent.length + 3) : name; | |
| 47 | + | } | |
| 48 | + | ||
| 49 | + | function unique(values: string[]): string[] { | |
| 50 | + | return [...new Set(values)]; | |
| 51 | + | } | |
| 52 | + | ||
| 53 | + | /** | |
| 54 | + | * The run's jobs as units, in the order they were listed. A called | |
| 55 | + | * workflow's jobs have keys under their caller's (`build/test`), and a | |
| 56 | + | * matrix's jobs share their key. | |
| 57 | + | */ | |
| 58 | + | export function groupJobs(jobs: GraphJob[], prefix = "", parent: string | null = null): Unit[] { | |
| 59 | + | const order: string[] = []; | |
| 60 | + | const direct = new Map<string, GraphJob[]>(); | |
| 61 | + | const nested = new Map<string, GraphJob[]>(); | |
| 62 | + | for (const job of jobs) { | |
| 63 | + | if (!job.key.startsWith(prefix)) continue; | |
| 64 | + | const rest = job.key.slice(prefix.length); | |
| 65 | + | if (!rest) continue; | |
| 66 | + | const segment = rest.split("/")[0]!; | |
| 67 | + | const key = prefix + segment; | |
| 68 | + | if (!direct.has(key)) { | |
| 69 | + | order.push(key); | |
| 70 | + | direct.set(key, []); | |
| 71 | + | nested.set(key, []); | |
| 72 | + | } | |
| 73 | + | (rest === segment ? direct : nested).get(key)!.push(job); | |
| 74 | + | } | |
| 75 | + | return order.map((key): Unit => { | |
| 76 | + | const own = direct.get(key)!; | |
| 77 | + | const inner = nested.get(key)!; | |
| 78 | + | const needs = unique(own.flatMap((job) => job.needs)); | |
| 79 | + | const label = own.length > 0 ? within(own.length > 1 ? baseName(own[0]!.name) : own[0]!.name, parent) : key.slice(prefix.length); | |
| 80 | + | if (inner.length > 0 || own.some((job) => job.uses)) { | |
| 81 | + | const callerName = own.length > 0 ? (own.length > 1 ? baseName(own[0]!.name) : own[0]!.name) : label; | |
| 82 | + | return { | |
| 83 | + | kind: "call", | |
| 84 | + | key, | |
| 85 | + | label, | |
| 86 | + | needs, | |
| 87 | + | uses: own.find((job) => job.uses)?.uses ?? null, | |
| 88 | + | callers: own, | |
| 89 | + | units: groupJobs(inner, `${key}/`, callerName), | |
| 90 | + | }; | |
| 91 | + | } | |
| 92 | + | if (own.length > 1) return { kind: "matrix", key, label, needs, jobs: own }; | |
| 93 | + | return { kind: "job", key, label, needs, job: own[0]! }; | |
| 94 | + | }); | |
| 95 | + | } | |
| 96 | + | ||
| 97 | + | /** Every job of a unit, its called workflow's included. */ | |
| 98 | + | export function jobsOf(unit: Unit): GraphJob[] { | |
| 99 | + | if (unit.kind === "job") return [unit.job]; | |
| 100 | + | if (unit.kind === "matrix") return unit.jobs; | |
| 101 | + | return [...unit.callers, ...unit.units.flatMap(jobsOf)]; | |
| 102 | + | } | |
| 103 | + | ||
| 104 | + | /** Where several jobs stand together: running while any runs, else the worst way any ended. */ | |
| 105 | + | export function standingOf(jobs: GraphJob[]): Standing { | |
| 106 | + | if (jobs.length === 0) return { status: "waiting", conclusion: null }; | |
| 107 | + | if (jobs.every((job) => job.status === "completed")) { | |
| 108 | + | const ended = jobs.map((job) => job.conclusion); | |
| 109 | + | const conclusion: Conclusion = ended.includes("failure") | |
| 110 | + | ? "failure" | |
| 111 | + | : ended.includes("cancelled") | |
| 112 | + | ? "cancelled" | |
| 113 | + | : ended.includes("success") | |
| 114 | + | ? "success" | |
| 115 | + | : "skipped"; | |
| 116 | + | return { status: "completed", conclusion }; | |
| 117 | + | } | |
| 118 | + | const has = (status: Job["status"]) => jobs.some((job) => job.status === status); | |
| 119 | + | if (has("in_progress") || has("calling")) return { status: "in_progress", conclusion: null }; | |
| 120 | + | if (has("pending")) return { status: "pending", conclusion: null }; | |
| 121 | + | if (has("queued")) return { status: "queued", conclusion: null }; | |
| 122 | + | return { status: "waiting", conclusion: null }; | |
| 123 | + | } | |
| 124 | + | ||
| 125 | + | /** A unit's standing; a called workflow's is its caller's while it has one. */ | |
| 126 | + | export function unitStanding(unit: Unit): Standing { | |
| 127 | + | if (unit.kind === "call" && unit.callers.length > 0) return standingOf(unit.callers); | |
| 128 | + | return standingOf(jobsOf(unit)); | |
| 129 | + | } | |
| 130 | + | ||
| 131 | + | /** | |
| 132 | + | * Each unit's column: the longest path of `needs` from a unit needing | |
| 133 | + | * nothing. Needs of keys not among `units` are left out, and so is a need | |
| 134 | + | * that would close a cycle (a workflow cannot have one, but the graph does | |
| 135 | + | * not trust that). | |
| 136 | + | */ | |
| 137 | + | export function columns(units: Unit[]): Map<string, number> { | |
| 138 | + | const byKey = new Map(units.map((unit) => [unit.key, unit])); | |
| 139 | + | const depth = new Map<string, number>(); | |
| 140 | + | const visiting = new Set<string>(); | |
| 141 | + | const visit = (key: string): number => { | |
| 142 | + | const known = depth.get(key); | |
| 143 | + | if (known !== undefined) return known; | |
| 144 | + | if (visiting.has(key)) return -1; | |
| 145 | + | visiting.add(key); | |
| 146 | + | let column = 0; | |
| 147 | + | for (const need of byKey.get(key)!.needs) { | |
| 148 | + | if (need === key || !byKey.has(need)) continue; | |
| 149 | + | const before = visit(need); | |
| 150 | + | if (before >= 0) column = Math.max(column, before + 1); | |
| 151 | + | } | |
| 152 | + | visiting.delete(key); | |
| 153 | + | depth.set(key, column); | |
| 154 | + | return column; | |
| 155 | + | }; | |
| 156 | + | for (const unit of units) visit(unit.key); | |
| 157 | + | return depth; | |
| 158 | + | } | |
| 159 | + | ||
| 160 | + | /** The connectors of one level: from a need to what needs it, never backwards. */ | |
| 161 | + | function links(units: Unit[], column: Map<string, number>): [string, string][] { | |
| 162 | + | const out: [string, string][] = []; | |
| 163 | + | for (const unit of units) | |
| 164 | + | for (const need of unique(unit.needs)) | |
| 165 | + | if (need !== unit.key && column.has(need) && column.get(need)! < column.get(unit.key)!) out.push([need, unit.key]); | |
| 166 | + | return out; | |
| 167 | + | } | |
| 168 | + | ||
| 169 | + | /** | |
| 170 | + | * How many pairs of connectors cross, counting pairs that run between the | |
| 171 | + | * same two columns. | |
| 172 | + | */ | |
| 173 | + | export function crossings(order: string[][], edges: [string, string][]): number { | |
| 174 | + | const at = new Map<string, [number, number]>(); | |
| 175 | + | order.forEach((keys, column) => keys.forEach((key, row) => at.set(key, [column, row]))); | |
| 176 | + | let count = 0; | |
| 177 | + | for (let i = 0; i < edges.length; i++) | |
| 178 | + | for (let j = i + 1; j < edges.length; j++) { | |
| 179 | + | const [a, b] = edges[i]!.map((key) => at.get(key)!); | |
| 180 | + | const [c, d] = edges[j]!.map((key) => at.get(key)!); | |
| 181 | + | if (a![0] !== c![0] || b![0] !== d![0]) continue; | |
| 182 | + | if ((a![1] - c![1]) * (b![1] - d![1]) < 0) count++; | |
| 183 | + | } | |
| 184 | + | return count; | |
| 185 | + | } | |
| 186 | + | ||
| 187 | + | /** | |
| 188 | + | * Units per column, top to bottom: first as listed, then sorted by the | |
| 189 | + | * mean row of what they need (sweeping right) and of what needs them | |
| 190 | + | * (sweeping left), a few times, keeping the order with the fewest | |
| 191 | + | * crossings. | |
| 192 | + | */ | |
| 193 | + | export function orderColumns(units: Unit[], column: Map<string, number>, sweeps = 4): string[][] { | |
| 194 | + | const count = units.length === 0 ? 0 : Math.max(...column.values()) + 1; | |
| 195 | + | let order: string[][] = Array.from({ length: count }, () => []); | |
| 196 | + | for (const unit of units) order[column.get(unit.key)!]!.push(unit.key); | |
| 197 | + | const edges = links(units, column); | |
| 198 | + | const needsOf = new Map<string, string[]>(); | |
| 199 | + | const neededBy = new Map<string, string[]>(); | |
| 200 | + | for (const [from, to] of edges) { | |
| 201 | + | needsOf.set(to, [...(needsOf.get(to) ?? []), from]); | |
| 202 | + | neededBy.set(from, [...(neededBy.get(from) ?? []), to]); | |
| 203 | + | } | |
| 204 | + | let best = order.map((keys) => [...keys]); | |
| 205 | + | let fewest = crossings(best, edges); | |
| 206 | + | const rowOf = () => { | |
| 207 | + | const rows = new Map<string, number>(); | |
| 208 | + | order.forEach((keys) => keys.forEach((key, row) => rows.set(key, row))); | |
| 209 | + | return rows; | |
| 210 | + | }; | |
| 211 | + | const sortBy = (keys: string[], others: Map<string, string[]>, rows: Map<string, number>) => { | |
| 212 | + | const centre = (key: string, index: number) => { | |
| 213 | + | const near = others.get(key) ?? []; | |
| 214 | + | return near.length === 0 ? index : near.reduce((sum, other) => sum + rows.get(other)!, 0) / near.length; | |
| 215 | + | }; | |
| 216 | + | return keys | |
| 217 | + | .map((key, index) => ({ key, index, at: centre(key, index) })) | |
| 218 | + | .sort((x, y) => x.at - y.at || x.index - y.index) | |
| 219 | + | .map(({ key }) => key); | |
| 220 | + | }; | |
| 221 | + | for (let sweep = 0; sweep < sweeps && fewest > 0; sweep++) { | |
| 222 | + | const right = sweep % 2 === 0; | |
| 223 | + | const range = right ? [...order.keys()].slice(1) : [...order.keys()].reverse().slice(1); | |
| 224 | + | for (const index of range) order[index] = sortBy(order[index]!, right ? needsOf : neededBy, rowOf()); | |
| 225 | + | const now = crossings(order, edges); | |
| 226 | + | if (now < fewest) { | |
| 227 | + | fewest = now; | |
| 228 | + | best = order.map((keys) => [...keys]); | |
| 229 | + | } | |
| 230 | + | } | |
| 231 | + | return best; | |
| 232 | + | } | |
| 233 | + | ||
| 234 | + | /** A placed node: a job, or a matrix's jobs. */ | |
| 235 | + | export type PlacedNode = { | |
| 236 | + | unit: Extract<Unit, { kind: "job" | "matrix" }>; | |
| 237 | + | x: number; | |
| 238 | + | y: number; | |
| 239 | + | w: number; | |
| 240 | + | h: number; | |
| 241 | + | expanded: boolean; | |
| 242 | + | }; | |
| 243 | + | ||
| 244 | + | /** A placed box: a job calling a workflow, its jobs inside. */ | |
| 245 | + | export type PlacedGroup = { unit: Extract<Unit, { kind: "call" }>; x: number; y: number; w: number; h: number }; | |
| 246 | + | ||
| 247 | + | /** | |
| 248 | + | * `failed`: from a unit that failed. `active`: to a unit running now. | |
| 249 | + | * `idle`: the rest. | |
| 250 | + | */ | |
| 251 | + | export type EdgeState = "idle" | "active" | "failed"; | |
| 252 | + | export type PlacedEdge = { from: string; to: string; path: string; state: EdgeState }; | |
| 253 | + | ||
| 254 | + | export type GraphLayout = { width: number; height: number; nodes: PlacedNode[]; groups: PlacedGroup[]; edges: PlacedEdge[] }; | |
| 255 | + | ||
| 256 | + | type Level = { width: number; height: number; nodes: PlacedNode[]; groups: PlacedGroup[]; edges: PlacedEdge[] }; | |
| 257 | + | ||
| 258 | + | /** A job node's height: taller with a deployment's address. */ | |
| 259 | + | function jobHeight(job: GraphJob): number { | |
| 260 | + | return GRAPH.nodeHeight + (job.environmentUrl ? GRAPH.urlHeight : 0); | |
| 261 | + | } | |
| 262 | + | ||
| 263 | + | /** The address a matrix's jobs all deploy to, when they share one. */ | |
| 264 | + | export function sharedUrl(jobs: GraphJob[]): string | null { | |
| 265 | + | const url = jobs[0]?.environmentUrl ?? null; | |
| 266 | + | return url && jobs.every((job) => job.environmentUrl === url) ? url : null; | |
| 267 | + | } | |
| 268 | + | ||
| 269 | + | /** A rounded right-angled connector from a node's right edge to another's left. */ | |
| 270 | + | export function connector(x1: number, y1: number, x2: number, y2: number): string { | |
| 271 | + | if (Math.abs(y1 - y2) < 0.5) return `M${x1} ${y1}H${x2}`; | |
| 272 | + | // Turn in the gap before the target, so the connector leaves at its source's row. | |
| 273 | + | const mx = Math.max(x1 + 8, x2 - GRAPH.columnGap / 2); | |
| 274 | + | const r = Math.min(8, Math.abs(y2 - y1) / 2, mx - x1, x2 - mx); | |
| 275 | + | const down = y2 > y1 ? 1 : -1; | |
| 276 | + | return [ | |
| 277 | + | `M${x1} ${y1}`, | |
| 278 | + | `H${mx - r}`, | |
| 279 | + | `Q${mx} ${y1} ${mx} ${y1 + down * r}`, | |
| 280 | + | `V${y2 - down * r}`, | |
| 281 | + | `Q${mx} ${y2} ${mx + r} ${y2}`, | |
| 282 | + | `H${x2}`, | |
| 283 | + | ].join(""); | |
| 284 | + | } | |
| 285 | + | ||
| 286 | + | function edgeState(from: Unit, to: Unit): EdgeState { | |
| 287 | + | const source = unitStanding(from); | |
| 288 | + | if (source.status === "completed" && source.conclusion === "failure") return "failed"; | |
| 289 | + | const target = unitStanding(to); | |
| 290 | + | if (source.status === "completed" && target.status === "in_progress") return "active"; | |
| 291 | + | return "idle"; | |
| 292 | + | } | |
| 293 | + | ||
| 294 | + | function place(units: Unit[], expanded: ReadonlySet<string>, ox: number, oy: number): Level { | |
| 295 | + | const column = columns(units); | |
| 296 | + | const order = orderColumns(units, column); | |
| 297 | + | const byKey = new Map(units.map((unit) => [unit.key, unit])); | |
| 298 | + | const level: Level = { width: 0, height: 0, nodes: [], groups: [], edges: [] }; | |
| 299 | + | // Each unit's size; a called workflow's box holds its own level. | |
| 300 | + | const size = (unit: Unit): [number, number] => { | |
| 301 | + | if (unit.kind === "job") return [GRAPH.nodeWidth, jobHeight(unit.job)]; | |
| 302 | + | if (unit.kind === "matrix") | |
| 303 | + | return [ | |
| 304 | + | GRAPH.nodeWidth, | |
| 305 | + | GRAPH.nodeHeight + (sharedUrl(unit.jobs) ? GRAPH.urlHeight : 0) + (expanded.has(unit.key) ? unit.jobs.length * GRAPH.rowHeight + 6 : 0), | |
| 306 | + | ]; | |
| 307 | + | const content = place(unit.units, expanded, 0, 0); | |
| 308 | + | return [Math.max(GRAPH.nodeWidth, content.width) + GRAPH.groupPad * 2, GRAPH.groupHead + content.height + GRAPH.groupPad]; | |
| 309 | + | }; | |
| 310 | + | const sizes = new Map(units.map((unit) => [unit.key, size(unit)])); | |
| 311 | + | const box = new Map<string, { x: number; y: number; w: number; h: number }>(); | |
| 312 | + | let x = ox; | |
| 313 | + | for (const keys of order) { | |
| 314 | + | let y = oy; | |
| 315 | + | let widest = 0; | |
| 316 | + | for (const key of keys) { | |
| 317 | + | const [w, h] = sizes.get(key)!; | |
| 318 | + | box.set(key, { x, y, w, h }); | |
| 319 | + | y += h + GRAPH.rowGap; | |
| 320 | + | widest = Math.max(widest, w); | |
| 321 | + | } | |
| 322 | + | level.height = Math.max(level.height, y - GRAPH.rowGap - oy); | |
| 323 | + | x += widest + GRAPH.columnGap; | |
| 324 | + | } | |
| 325 | + | level.width = Math.max(0, x - GRAPH.columnGap - ox); | |
| 326 | + | for (const unit of units) { | |
| 327 | + | const at = box.get(unit.key)!; | |
| 328 | + | if (unit.kind === "call") { | |
| 329 | + | level.groups.push({ unit, ...at }); | |
| 330 | + | // Its jobs, moved into its box. | |
| 331 | + | const content = place(unit.units, expanded, at.x + GRAPH.groupPad, at.y + GRAPH.groupHead); | |
| 332 | + | level.nodes.push(...content.nodes); | |
| 333 | + | level.groups.push(...content.groups); | |
| 334 | + | level.edges.push(...content.edges); | |
| 335 | + | } else { | |
| 336 | + | level.nodes.push({ unit, ...at, expanded: unit.kind === "matrix" && expanded.has(unit.key) }); | |
| 337 | + | } | |
| 338 | + | } | |
| 339 | + | for (const [from, to] of links(units, column)) { | |
| 340 | + | const a = box.get(from)!; | |
| 341 | + | const b = box.get(to)!; | |
| 342 | + | // Connectors meet a node at the middle of its first line. | |
| 343 | + | const anchor = GRAPH.nodeHeight / 2; | |
| 344 | + | level.edges.push({ from, to, path: connector(a.x + a.w, a.y + anchor, b.x, b.y + anchor), state: edgeState(byKey.get(from)!, byKey.get(to)!) }); | |
| 345 | + | } | |
| 346 | + | return level; | |
| 347 | + | } | |
| 348 | + | ||
| 349 | + | /** The whole graph, placed: `expanded` names the matrices shown open. */ | |
| 350 | + | export function layoutRun(jobs: GraphJob[], expanded: ReadonlySet<string> = new Set()): GraphLayout { | |
| 351 | + | const level = place(groupJobs(jobs), expanded, GRAPH.margin, GRAPH.margin); | |
| 352 | + | return { | |
| 353 | + | width: level.width + GRAPH.margin * 2, | |
| 354 | + | height: level.height + GRAPH.margin * 2, | |
| 355 | + | nodes: level.nodes, | |
| 356 | + | groups: level.groups, | |
| 357 | + | edges: level.edges, | |
| 358 | + | }; | |
| 359 | + | } |
| 6 | 6 | ChevronRight, | |
| 7 | 7 | Cloud, | |
| 8 | 8 | Download, | |
| 9 | + | FileCode, | |
| 9 | 10 | FileText, | |
| 10 | 11 | GitBranch, | |
| 11 | 12 | GitCommitHorizontal, | |
| 12 | 13 | History, | |
| 13 | 14 | Hourglass, | |
| 14 | 15 | Info, | |
| 16 | + | LayoutList, | |
| 15 | 17 | Package, | |
| 16 | 18 | Play, | |
| 17 | 19 | RotateCw, | |
| ⋯ | |||
| 21 | 23 | Square, | |
| 22 | 24 | Trash2, | |
| 23 | 25 | Users, | |
| 26 | + | Workflow, | |
| 24 | 27 | X, | |
| 25 | 28 | XCircle, | |
| 26 | 29 | } from "lucide-react"; | |
| ⋯ | |||
| 33 | 36 | import { page } from "../../lib/meta"; | |
| 34 | 37 | import { Duration, LogText, Notes, StatusIcon, shortRef, standingWord, useJobLog } from "../../components/actions"; | |
| 35 | 38 | import { Markdown } from "../../components/markdown"; | |
| 39 | + | import { RunGraph } from "../../components/run-graph"; | |
| 36 | 40 | import { DetailsDisclosure } from "../../components/details-disclosure"; | |
| 37 | 41 | import { Button, ErrorText, SubmitButton, TimeAgo, usePending } from "../../components/ui"; | |
| 38 | 42 | import { CheckboxOption } from "../../components/ui/checkbox"; | |
| ⋯ | |||
| 46 | 50 | import { assertSameOrigin, getViewer, requireUser, roleIn, unwrap } from "../../lib/session.server"; | |
| 47 | 51 | import { accessTo, refusal } from "../../lib/access.server"; | |
| 48 | 52 | import { useRefreshWhile } from "../../lib/refresh"; | |
| 53 | + | import { type GraphJob, type Unit, groupJobs, jobsOf, unitStanding } from "../../lib/run-graph"; | |
| 49 | 54 | ||
| 50 | 55 | export function meta({ loaderData, params, ...args }: Route.MetaArgs) { | |
| 51 | 56 | const run = loaderData?.detail.run; | |
| ⋯ | |||
| 264 | 269 | } | |
| 265 | 270 | ||
| 266 | 271 | /** What the jobs' steps wrote to $GITHUB_STEP_SUMMARY, a card per job. */ | |
| 267 | − | function Summaries({ summaries, repo }: { summaries: JobSummary[]; repo: { namespace: string; name: string } }) { | |
| 272 | + | function Summaries({ | |
| 273 | + | summaries, | |
| 274 | + | repo, | |
| 275 | + | jobHref, | |
| 276 | + | }: { | |
| 277 | + | summaries: JobSummary[]; | |
| 278 | + | repo: { namespace: string; name: string }; | |
| 279 | + | jobHref: (job: { id: string }) => string; | |
| 280 | + | }) { | |
| 268 | 281 | if (summaries.length === 0) return null; | |
| 269 | 282 | return ( | |
| 270 | 283 | <section aria-label="Job summaries" className="space-y-4"> | |
| 271 | 284 | {summaries.map((summary) => ( | |
| 272 | 285 | <article key={summary.jobId} className="overflow-hidden rounded-xl border border-line bg-surface"> | |
| 273 | − | <header className="flex items-center gap-2 border-b border-line px-4 py-2.5 text-sm"> | |
| 274 | − | <FileText size={14} className="text-muted" /> | |
| 275 | − | <span className="font-medium">{summary.name}</span> | |
| 276 | − | <span className="text-faint">summary</span> | |
| 286 | + | <header className="flex min-w-0 items-center gap-2 border-b border-line px-4 py-2.5 text-sm"> | |
| 287 | + | <FileText size={14} className="shrink-0 text-muted" /> | |
| 288 | + | <Link to={jobHref({ id: summary.jobId })} preventScrollReset className="min-w-0 truncate font-medium hover:underline"> | |
| 289 | + | {summary.name} | |
| 290 | + | </Link> | |
| 291 | + | <span className="shrink-0 text-faint">summary</span> | |
| 277 | 292 | </header> | |
| 278 | 293 | <div className="space-y-4 px-5 py-4"> | |
| 279 | 294 | {summary.steps.map((step) => ( | |
| ⋯ | |||
| 665 | 680 | ); | |
| 666 | 681 | } | |
| 667 | 682 | ||
| 683 | + | type RunData = Route.ComponentProps["loaderData"]; | |
| 684 | + | ||
| 685 | + | /** Where the run came from, how it stands, how long it took and what it kept. */ | |
| 686 | + | function RunCard({ | |
| 687 | + | detail, | |
| 688 | + | artifacts, | |
| 689 | + | standing, | |
| 690 | + | cancelling, | |
| 691 | + | base, | |
| 692 | + | }: { | |
| 693 | + | detail: RunData["detail"]; | |
| 694 | + | artifacts: RunData["artifacts"]; | |
| 695 | + | standing: string; | |
| 696 | + | cancelling: boolean; | |
| 697 | + | base: string; | |
| 698 | + | }) { | |
| 699 | + | const { run } = detail; | |
| 700 | + | const label = "text-xs text-muted"; | |
| 701 | + | return ( | |
| 702 | + | <section | |
| 703 | + | aria-label="Run" | |
| 704 | + | className="grid gap-x-6 gap-y-4 rounded-xl border border-line bg-surface p-4 text-sm sm:grid-cols-[minmax(0,2fr)_repeat(3,minmax(0,1fr))]" | |
| 705 | + | > | |
| 706 | + | <div className="min-w-0 space-y-1.5"> | |
| 707 | + | <p className={label}> | |
| 708 | + | Triggered via {run.event} <TimeAgo at={run.createdAt} /> | |
| 709 | + | </p> | |
| 710 | + | <p className="flex flex-wrap items-center gap-x-2 gap-y-1"> | |
| 711 | + | {run.actor && <span className="font-medium">{run.actor}</span>} | |
| 712 | + | <Link to={`${base}/commit/${run.sha}`} className="inline-flex items-center gap-1 font-mono text-xs text-muted hover:text-fg"> | |
| 713 | + | <GitCommitHorizontal size={12} /> | |
| 714 | + | {run.sha.slice(0, 7)} | |
| 715 | + | </Link> | |
| 716 | + | {run.pull != null && /^refs\/pull\//.test(run.ref) ? ( | |
| 717 | + | <Link to={`${base}/pull/${run.pull}`} className="rounded-md bg-accent/10 px-1.5 py-0.5 font-mono text-xs text-accent hover:underline"> | |
| 718 | + | #{run.pull} | |
| 719 | + | </Link> | |
| 720 | + | ) : ( | |
| 721 | + | <span className="inline-flex min-w-0 items-center gap-1 rounded-md bg-accent/10 px-1.5 py-0.5 font-mono text-xs text-accent"> | |
| 722 | + | <GitBranch size={11} className="shrink-0" /> | |
| 723 | + | <span className="truncate">{shortRef(run.ref)}</span> | |
| 724 | + | </span> | |
| 725 | + | )} | |
| 726 | + | </p> | |
| 727 | + | {detail.approval?.state === "approved" && detail.approval.approvedBy && ( | |
| 728 | + | <p className="text-xs text-muted">Approved by {detail.approval.approvedBy}</p> | |
| 729 | + | )} | |
| 730 | + | </div> | |
| 731 | + | <div className="grid grid-cols-3 gap-4 sm:contents"> | |
| 732 | + | <div className="min-w-0 space-y-1.5"> | |
| 733 | + | <p className={label}>Status</p> | |
| 734 | + | <p className="flex items-center gap-1.5 font-medium"> | |
| 735 | + | <StatusIcon status={run.status} conclusion={run.conclusion} size={14} /> | |
| 736 | + | <span className="truncate">{standing}</span> | |
| 737 | + | </p> | |
| 738 | + | {cancelling && <p className="text-xs text-warn">Its jobs are running their cleanup steps</p>} | |
| 739 | + | </div> | |
| 740 | + | <div className="min-w-0 space-y-1.5"> | |
| 741 | + | <p className={label}>Total duration</p> | |
| 742 | + | <p className="font-medium">{run.startedAt ? <Duration start={run.startedAt} end={run.finishedAt} /> : "—"}</p> | |
| 743 | + | {run.attempt > 1 && <p className="text-xs text-muted">Attempt #{run.attempt}</p>} | |
| 744 | + | </div> | |
| 745 | + | <div className="min-w-0 space-y-1.5"> | |
| 746 | + | <p className={label}>Artifacts</p> | |
| 747 | + | <p className="font-medium"> | |
| 748 | + | {artifacts.length > 0 ? ( | |
| 749 | + | <a href="#artifacts" className="text-accent hover:underline"> | |
| 750 | + | {artifacts.length} | |
| 751 | + | </a> | |
| 752 | + | ) : ( | |
| 753 | + | "—" | |
| 754 | + | )} | |
| 755 | + | </p> | |
| 756 | + | </div> | |
| 757 | + | </div> | |
| 758 | + | </section> | |
| 759 | + | ); | |
| 760 | + | } | |
| 761 | + | ||
| 762 | + | const LEVELS: { level: Annotation["level"]; one: string; many: string }[] = [ | |
| 763 | + | { level: "error", one: "error", many: "errors" }, | |
| 764 | + | { level: "warning", one: "warning", many: "warnings" }, | |
| 765 | + | { level: "notice", one: "notice", many: "notices" }, | |
| 766 | + | ]; | |
| 767 | + | ||
| 768 | + | /** Every job's errors, warnings and notices, folded under how many of each. */ | |
| 769 | + | function AnnotationsPanel({ jobs, jobHref }: { jobs: Job[]; jobHref: (job: { id: string }) => string }) { | |
| 770 | + | const notes = jobs.flatMap((job) => job.annotations.map((note) => ({ job, note }))); | |
| 771 | + | if (notes.length === 0) return null; | |
| 772 | + | const counts = LEVELS.flatMap(({ level, one, many }) => { | |
| 773 | + | const count = notes.filter(({ note }) => note.level === level).length; | |
| 774 | + | return count > 0 ? [`${count} ${count === 1 ? one : many}`] : []; | |
| 775 | + | }); | |
| 776 | + | return ( | |
| 777 | + | <details open className="group overflow-hidden rounded-xl border border-line bg-surface"> | |
| 778 | + | <summary className="flex min-h-11 cursor-pointer list-none items-center gap-2 px-4 py-2.5 text-sm hover:bg-raised/40"> | |
| 779 | + | <ChevronRight size={14} className="shrink-0 text-faint transition-transform group-open:rotate-90" /> | |
| 780 | + | <span className="font-medium">Annotations</span> | |
| 781 | + | <span className="min-w-0 truncate text-muted">{counts.join(", ")}</span> | |
| 782 | + | </summary> | |
| 783 | + | <ul className="divide-y divide-line border-t border-line text-sm"> | |
| 784 | + | {notes.map(({ job, note }, index) => ( | |
| 785 | + | <li key={index} className="flex gap-2 px-4 py-3"> | |
| 786 | + | {ANNOTATION_ICON[note.level]} | |
| 787 | + | <span className="min-w-0"> | |
| 788 | + | <Link to={jobHref(job)} preventScrollReset className="block text-xs font-medium text-muted hover:text-fg hover:underline"> | |
| 789 | + | {job.name} | |
| 790 | + | </Link> | |
| 791 | + | {note.title && <span className="font-medium">{note.title}: </span>} | |
| 792 | + | <span className="whitespace-pre-wrap wrap-break-word">{note.message}</span> | |
| 793 | + | {note.file && ( | |
| 794 | + | <span className="block font-mono text-xs text-faint"> | |
| 795 | + | {note.file} | |
| 796 | + | {note.line != null && `:${note.line}`} | |
| 797 | + | </span> | |
| 798 | + | )} | |
| 799 | + | </span> | |
| 800 | + | </li> | |
| 801 | + | ))} | |
| 802 | + | </ul> | |
| 803 | + | </details> | |
| 804 | + | ); | |
| 805 | + | } | |
| 806 | + | ||
| 807 | + | /** What the run kept, to download (and, with Write, to delete now). */ | |
| 808 | + | function ArtifactsList({ | |
| 809 | + | artifacts, | |
| 810 | + | member, | |
| 811 | + | busy, | |
| 812 | + | base, | |
| 813 | + | runId, | |
| 814 | + | }: { | |
| 815 | + | artifacts: RunData["artifacts"]; | |
| 816 | + | member: boolean; | |
| 817 | + | busy: boolean; | |
| 818 | + | base: string; | |
| 819 | + | runId: string; | |
| 820 | + | }) { | |
| 821 | + | if (artifacts.length === 0) return null; | |
| 822 | + | return ( | |
| 823 | + | <section id="artifacts" className="scroll-mt-20 rounded-xl border border-line bg-surface p-4"> | |
| 824 | + | <h3 className="flex items-center gap-2 text-sm font-medium"> | |
| 825 | + | <Package size={14} className="text-muted" /> | |
| 826 | + | Artifacts | |
| 827 | + | <span className="font-normal text-faint"> | |
| 828 | + | · {artifacts.length} · {formatBytes(artifacts.reduce((sum, a) => sum + a.size, 0))} | |
| 829 | + | </span> | |
| 830 | + | </h3> | |
| 831 | + | <ul className="mt-3 divide-y divide-line text-sm"> | |
| 832 | + | {artifacts.map((artifact) => ( | |
| 833 | + | <li key={artifact.name} className="flex flex-wrap items-center gap-x-3 gap-y-1 py-2"> | |
| 834 | + | <span className="min-w-0 grow basis-full truncate font-mono text-[0.8125rem] sm:basis-40">{artifact.name}</span> | |
| 835 | + | <span className="flex shrink-0 items-center gap-3 text-xs text-faint"> | |
| 836 | + | <span>{formatBytes(artifact.size)}</span> | |
| 837 | + | {artifact.expiresAt && <span>{expiresIn(artifact.expiresAt)}</span>} | |
| 838 | + | </span> | |
| 839 | + | <span className="ml-auto flex shrink-0 items-center gap-1.5"> | |
| 840 | + | <a | |
| 841 | + | href={`${base}/actions/runs/${runId}/artifacts/${encodeURIComponent(artifact.name)}`} | |
| 842 | + | className="inline-flex items-center gap-1 rounded-md px-2 py-1 text-xs text-muted ring-1 ring-line hover:text-fg" | |
| 843 | + | > | |
| 844 | + | <Download size={12} /> | |
| 845 | + | Download | |
| 846 | + | </a> | |
| 847 | + | {member && artifact.id != null && ( | |
| 848 | + | <Form method="post" preventScrollReset> | |
| 849 | + | <input type="hidden" name="artifact" value={artifact.id} /> | |
| 850 | + | <Hint label={`Delete ${artifact.name} now`}> | |
| 851 | + | <SubmitButton | |
| 852 | + | name="intent" | |
| 853 | + | value="delete-artifact" | |
| 854 | + | icon | |
| 855 | + | disabled={busy} | |
| 856 | + | aria-label={`Delete ${artifact.name}`} | |
| 857 | + | className="inline-flex items-center rounded-md p-1.5 text-muted ring-1 ring-line hover:text-danger disabled:opacity-50" | |
| 858 | + | > | |
| 859 | + | <Trash2 size={12} /> | |
| 860 | + | </SubmitButton> | |
| 861 | + | </Hint> | |
| 862 | + | </Form> | |
| 863 | + | )} | |
| 864 | + | </span> | |
| 865 | + | </li> | |
| 866 | + | ))} | |
| 867 | + | </ul> | |
| 868 | + | </section> | |
| 869 | + | ); | |
| 870 | + | } | |
| 871 | + | ||
| 872 | + | /** The run at a glance: the card, its jobs as a graph, their annotations, summaries and artifacts. */ | |
| 873 | + | function RunSummary({ | |
| 874 | + | detail, | |
| 875 | + | artifacts, | |
| 876 | + | summaries, | |
| 877 | + | standing, | |
| 878 | + | cancelling, | |
| 879 | + | member, | |
| 880 | + | busy, | |
| 881 | + | base, | |
| 882 | + | jobHref, | |
| 883 | + | }: { | |
| 884 | + | detail: RunData["detail"]; | |
| 885 | + | artifacts: RunData["artifacts"]; | |
| 886 | + | summaries: JobSummary[]; | |
| 887 | + | standing: string; | |
| 888 | + | cancelling: boolean; | |
| 889 | + | member: boolean; | |
| 890 | + | busy: boolean; | |
| 891 | + | base: string; | |
| 892 | + | jobHref: (job: { id: string }) => string; | |
| 893 | + | }) { | |
| 894 | + | const { run, jobs } = detail; | |
| 895 | + | const [namespace = "", name = ""] = base.slice(1).split("/"); | |
| 896 | + | return ( | |
| 897 | + | <section aria-label="Summary" className="min-w-0 space-y-4"> | |
| 898 | + | <RunCard detail={detail} artifacts={artifacts} standing={standing} cancelling={cancelling} base={base} /> | |
| 899 | + | <RunGraph jobs={jobs} title={run.path.split("/").pop() || run.name} trigger={run.event} href={jobHref} /> | |
| 900 | + | <AnnotationsPanel jobs={jobs} jobHref={jobHref} /> | |
| 901 | + | <Summaries summaries={summaries} repo={{ namespace, name }} jobHref={jobHref} /> | |
| 902 | + | <ArtifactsList artifacts={artifacts} member={member} busy={busy} base={base} runId={run.id} /> | |
| 903 | + | </section> | |
| 904 | + | ); | |
| 905 | + | } | |
| 906 | + | ||
| 907 | + | const NAV_ROW = "flex min-h-11 items-center gap-2 rounded-md px-2.5 py-1.5 sm:min-h-0"; | |
| 908 | + | ||
| 909 | + | function navRow(active: boolean): string { | |
| 910 | + | return `${NAV_ROW} ${active ? "bg-raised text-fg" : "text-muted hover:bg-raised/60 hover:text-fg"}`; | |
| 911 | + | } | |
| 912 | + | ||
| 913 | + | /** A job's row in the jobs list. */ | |
| 914 | + | function NavJob({ job, label, selected, jobHref }: { job: GraphJob; label: string; selected: Job | null; jobHref: (job: { id: string }) => string }) { | |
| 915 | + | const active = job.id === selected?.id; | |
| 916 | + | return ( | |
| 917 | + | <Link to={jobHref(job)} preventScrollReset aria-current={active ? "page" : undefined} className={navRow(active)}> | |
| 918 | + | <StatusIcon status={job.status} conclusion={job.conclusion} of="job" environment={job.environment} size={14} /> | |
| 919 | + | <span className="min-w-0 truncate">{label}</span> | |
| 920 | + | <Duration className="ml-auto shrink-0 font-mono text-xs text-faint" start={job.startedAt} end={job.finishedAt} /> | |
| 921 | + | </Link> | |
| 922 | + | ); | |
| 923 | + | } | |
| 924 | + | ||
| 925 | + | /** One unit of the jobs list: a job, or a matrix or called workflow folded under its name. */ | |
| 926 | + | function NavUnit({ unit, selected, jobHref }: { unit: Unit; selected: Job | null; jobHref: (job: { id: string }) => string }) { | |
| 927 | + | if (unit.kind === "job") return <NavJob job={unit.job} label={unit.label} selected={selected} jobHref={jobHref} />; | |
| 928 | + | const inside = jobsOf(unit).some((job) => job.id === selected?.id); | |
| 929 | + | const standing = unitStanding(unit); | |
| 930 | + | return ( | |
| 931 | + | <details open={unit.kind === "call" || inside} className="group/unit"> | |
| 932 | + | <summary className={`${NAV_ROW} cursor-pointer list-none text-muted hover:bg-raised/60 hover:text-fg`}> | |
| 933 | + | <ChevronRight size={13} className="shrink-0 text-faint transition-transform group-open/unit:rotate-90" /> | |
| 934 | + | <StatusIcon | |
| 935 | + | status={standing.status === "calling" ? "in_progress" : standing.status} | |
| 936 | + | conclusion={standing.conclusion} | |
| 937 | + | of="job" | |
| 938 | + | size={14} | |
| 939 | + | /> | |
| 940 | + | <span className="min-w-0 truncate">{unit.label}</span> | |
| 941 | + | {unit.kind === "matrix" && <span className="ml-auto shrink-0 text-xs text-faint">{unit.jobs.length}</span>} | |
| 942 | + | </summary> | |
| 943 | + | <div className="ml-3 space-y-0.5 border-l border-line pl-1.5"> | |
| 944 | + | {unit.kind === "matrix" ? ( | |
| 945 | + | unit.jobs.map((job) => ( | |
| 946 | + | <NavJob key={job.id} job={job} label={job.name.split(" / ").pop() ?? job.name} selected={selected} jobHref={jobHref} /> | |
| 947 | + | )) | |
| 948 | + | ) : ( | |
| 949 | + | <> | |
| 950 | + | {unit.callers.map((job) => ( | |
| 951 | + | <Link | |
| 952 | + | key={job.id} | |
| 953 | + | to={jobHref(job)} | |
| 954 | + | preventScrollReset | |
| 955 | + | aria-current={job.id === selected?.id ? "page" : undefined} | |
| 956 | + | className={navRow(job.id === selected?.id)} | |
| 957 | + | > | |
| 958 | + | <Workflow size={14} className="shrink-0 text-faint" /> | |
| 959 | + | <span className="min-w-0 truncate">{unit.uses ? `Calls ${unit.uses.split("/").pop()}` : job.name}</span> | |
| 960 | + | </Link> | |
| 961 | + | ))} | |
| 962 | + | {unit.units.map((inner) => ( | |
| 963 | + | <NavUnit key={inner.key} unit={inner} selected={selected} jobHref={jobHref} /> | |
| 964 | + | ))} | |
| 965 | + | </> | |
| 966 | + | )} | |
| 967 | + | </div> | |
| 968 | + | </details> | |
| 969 | + | ); | |
| 970 | + | } | |
| 971 | + | ||
| 972 | + | /** The run's pages: its summary, its jobs grouped as the graph groups them, and where it came from. */ | |
| 973 | + | function RunNav({ | |
| 974 | + | run, | |
| 975 | + | jobs, | |
| 976 | + | selected, | |
| 977 | + | base, | |
| 978 | + | jobHref, | |
| 979 | + | summaryHref, | |
| 980 | + | }: { | |
| 981 | + | run: RunData["detail"]["run"]; | |
| 982 | + | jobs: Job[]; | |
| 983 | + | selected: Job | null; | |
| 984 | + | base: string; | |
| 985 | + | jobHref: (job: { id: string }) => string; | |
| 986 | + | summaryHref: string; | |
| 987 | + | }) { | |
| 988 | + | const units = groupJobs(jobs); | |
| 989 | + | const heading = "px-2.5 pt-4 pb-1 text-xs font-medium text-faint"; | |
| 990 | + | return ( | |
| 991 | + | <nav aria-label="Run" className="space-y-0.5 text-sm"> | |
| 992 | + | <Link to={summaryHref} preventScrollReset aria-current={selected ? undefined : "page"} className={navRow(!selected)}> | |
| 993 | + | <LayoutList size={14} className="shrink-0" /> | |
| 994 | + | Summary | |
| 995 | + | </Link> | |
| 996 | + | <p className={heading}>All jobs</p> | |
| 997 | + | {units.map((unit) => ( | |
| 998 | + | <NavUnit key={unit.key} unit={unit} selected={selected} jobHref={jobHref} /> | |
| 999 | + | ))} | |
| 1000 | + | <p className={heading}>Run details</p> | |
| 1001 | + | <Link to={`${base}/blob/${run.sha}/${run.path}`} className={navRow(false)}> | |
| 1002 | + | <FileCode size={14} className="shrink-0" /> | |
| 1003 | + | Workflow file | |
| 1004 | + | </Link> | |
| 1005 | + | </nav> | |
| 1006 | + | ); | |
| 1007 | + | } | |
| 1008 | + | ||
| 668 | 1009 | export default function ActionsRun({ loaderData, actionData, params }: Route.ComponentProps) { | |
| 669 | 1010 | const { detail, artifacts, member, summaries } = loaderData; | |
| 670 | 1011 | const { run, jobs, notes } = detail; | |
| ⋯ | |||
| 684 | 1025 | // Waiting on a person needs no quick refresh; a running job does. | |
| 685 | 1026 | useRefreshWhile(live, run.status === "action_required" || run.status === "waiting" ? 8000 : 2500); | |
| 686 | 1027 | ||
| 687 | − | // The job asked for, else one that failed, is running, or the first. | |
| 688 | − | const selected = | |
| 689 | − | jobs.find((job) => job.id === search.get("job")) ?? | |
| 690 | − | jobs.find((job) => job.conclusion === "failure" && job.steps.length > 0) ?? | |
| 691 | − | jobs.find((job) => job.status === "in_progress") ?? | |
| 692 | − | jobs[0]; | |
| 1028 | + | // The job asked for; none is the run's summary. | |
| 1029 | + | const selected = jobs.find((job) => job.id === search.get("job")) ?? null; | |
| 1030 | + | const jobHref = (job: { id: string }) => (latest ? `?job=${job.id}` : `?attempt=${run.attempt}&job=${job.id}`); | |
| 1031 | + | const summaryHref = latest ? "?" : `?attempt=${run.attempt}`; | |
| 1032 | + | const standing = runWord(run.status, deployments) ?? (cancelling ? "Cancelling" : standingWord(run)); | |
| 693 | 1033 | const anyFailed = jobs.some((job) => job.conclusion === "failure" || job.conclusion === "cancelled"); | |
| 694 | 1034 | ||
| 695 | 1035 | return ( | |
| ⋯ | |||
| 765 | 1105 | )} | |
| 766 | 1106 | </div> | |
| 767 | 1107 | </div> | |
| 768 | − | <p className="flex flex-wrap items-center gap-x-3 gap-y-1 text-sm text-muted"> | |
| 769 | − | <span>{runWord(run.status, deployments) ?? (cancelling ? "Cancelling" : standingWord(run))}</span> | |
| 770 | − | {/* A pull request's ref is its number, which the link beside it already shows. */} | |
| 771 | − | {!(run.pull != null && /^refs\/pull\//.test(run.ref)) && ( | |
| 772 | − | <span className="inline-flex items-center gap-1 font-mono text-xs"> | |
| 773 | − | <GitBranch size={12} /> | |
| 774 | − | {shortRef(run.ref)} | |
| 1108 | + | {selected && ( | |
| 1109 | + | <p className="flex flex-wrap items-center gap-x-3 gap-y-1 text-sm text-muted"> | |
| 1110 | + | <span>{standing}</span> | |
| 1111 | + | {/* A pull request's ref is its number, which the link beside it already shows. */} | |
| 1112 | + | {!(run.pull != null && /^refs\/pull\//.test(run.ref)) && ( | |
| 1113 | + | <span className="inline-flex items-center gap-1 font-mono text-xs"> | |
| 1114 | + | <GitBranch size={12} /> | |
| 1115 | + | {shortRef(run.ref)} | |
| 1116 | + | </span> | |
| 1117 | + | )} | |
| 1118 | + | <Link to={`${base}/commit/${run.sha}`} className="inline-flex items-center gap-1 font-mono text-xs hover:text-fg"> | |
| 1119 | + | <GitCommitHorizontal size={12} /> | |
| 1120 | + | {run.sha.slice(0, 7)} | |
| 1121 | + | </Link> | |
| 1122 | + | {run.pull != null && ( | |
| 1123 | + | <Link to={`${base}/pull/${run.pull}`} className="hover:text-fg"> | |
| 1124 | + | #{run.pull} | |
| 1125 | + | </Link> | |
| 1126 | + | )} | |
| 1127 | + | <span> | |
| 1128 | + | {run.event} | |
| 1129 | + | {run.actor && ` by ${run.actor}`} · <TimeAgo at={run.createdAt} /> | |
| 775 | 1130 | </span> | |
| 776 | − | )} | |
| 777 | − | <Link to={`${base}/commit/${run.sha}`} className="inline-flex items-center gap-1 font-mono text-xs hover:text-fg"> | |
| 778 | − | <GitCommitHorizontal size={12} /> | |
| 779 | − | {run.sha.slice(0, 7)} | |
| 780 | − | </Link> | |
| 781 | − | {run.pull != null && ( | |
| 782 | − | <Link to={`${base}/pull/${run.pull}`} className="hover:text-fg"> | |
| 783 | − | #{run.pull} | |
| 784 | − | </Link> | |
| 785 | − | )} | |
| 786 | − | <span> | |
| 787 | − | {run.event} | |
| 788 | − | {run.actor && ` by ${run.actor}`} · <TimeAgo at={run.createdAt} /> | |
| 789 | − | </span> | |
| 790 | − | {run.startedAt && <Duration className="font-mono text-xs" start={run.startedAt} end={run.finishedAt} />} | |
| 791 | − | {run.attempt > 1 && attempts.length <= 1 && <span>Attempt {run.attempt}</span>} | |
| 792 | − | {cancelling && <span className="text-warn">Its jobs are running their cleanup steps</span>} | |
| 793 | − | {detail.approval?.state === "approved" && detail.approval.approvedBy && <span>Approved by {detail.approval.approvedBy}</span>} | |
| 794 | − | </p> | |
| 1131 | + | {run.startedAt && <Duration className="font-mono text-xs" start={run.startedAt} end={run.finishedAt} />} | |
| 1132 | + | {run.attempt > 1 && attempts.length <= 1 && <span>Attempt {run.attempt}</span>} | |
| 1133 | + | {cancelling && <span className="text-warn">Its jobs are running their cleanup steps</span>} | |
| 1134 | + | {detail.approval?.state === "approved" && detail.approval.approvedBy && <span>Approved by {detail.approval.approvedBy}</span>} | |
| 1135 | + | </p> | |
| 1136 | + | )} | |
| 795 | 1137 | </header> | |
| 796 | 1138 | ||
| 797 | 1139 | <ErrorText>{actionData && "error" in actionData ? actionData.error : null}</ErrorText> | |
| ⋯ | |||
| 815 | 1157 | </div> | |
| 816 | 1158 | )} | |
| 817 | 1159 | <Notes notes={notes} /> | |
| 818 | − | <Summaries summaries={summaries} repo={{ namespace: params.owner, name: params.repo }} /> | |
| 819 | − | {artifacts.length > 0 && ( | |
| 820 | − | <section className="rounded-xl border border-line bg-surface p-4"> | |
| 821 | − | <h3 className="flex items-center gap-2 text-sm font-medium"> | |
| 822 | − | <Package size={14} className="text-muted" /> | |
| 823 | − | Artifacts | |
| 824 | − | <span className="font-normal text-faint"> | |
| 825 | − | · {artifacts.length} · {formatBytes(artifacts.reduce((sum, a) => sum + a.size, 0))} | |
| 826 | − | </span> | |
| 827 | − | </h3> | |
| 828 | − | <ul className="mt-3 divide-y divide-line text-sm"> | |
| 829 | − | {artifacts.map((artifact) => ( | |
| 830 | − | <li key={artifact.name} className="flex flex-wrap items-center gap-x-3 gap-y-1 py-2"> | |
| 831 | − | <span className="min-w-0 grow basis-full truncate font-mono text-[0.8125rem] sm:basis-40">{artifact.name}</span> | |
| 832 | − | <span className="flex shrink-0 items-center gap-3 text-xs text-faint"> | |
| 833 | − | <span>{formatBytes(artifact.size)}</span> | |
| 834 | − | {artifact.expiresAt && <span>{expiresIn(artifact.expiresAt)}</span>} | |
| 835 | − | </span> | |
| 836 | − | <span className="ml-auto flex shrink-0 items-center gap-1.5"> | |
| 837 | − | <a | |
| 838 | − | href={`${base}/actions/runs/${run.id}/artifacts/${encodeURIComponent(artifact.name)}`} | |
| 839 | − | className="inline-flex items-center gap-1 rounded-md px-2 py-1 text-xs text-muted ring-1 ring-line hover:text-fg" | |
| 840 | − | > | |
| 841 | − | <Download size={12} /> | |
| 842 | − | Download | |
| 843 | − | </a> | |
| 844 | − | {member && artifact.id != null && ( | |
| 845 | − | <Form method="post" preventScrollReset> | |
| 846 | − | <input type="hidden" name="artifact" value={artifact.id} /> | |
| 847 | − | <Hint label={`Delete ${artifact.name} now`}> | |
| 848 | − | <SubmitButton | |
| 849 | − | name="intent" | |
| 850 | − | value="delete-artifact" | |
| 851 | − | icon | |
| 852 | − | disabled={busy} | |
| 853 | − | aria-label={`Delete ${artifact.name}`} | |
| 854 | − | className="inline-flex items-center rounded-md p-1.5 text-muted ring-1 ring-line hover:text-danger disabled:opacity-50" | |
| 855 | − | > | |
| 856 | − | <Trash2 size={12} /> | |
| 857 | − | </SubmitButton> | |
| 858 | − | </Hint> | |
| 859 | − | </Form> | |
| 860 | − | )} | |
| 861 | − | </span> | |
| 862 | − | </li> | |
| 863 | − | ))} | |
| 864 | − | </ul> | |
| 865 | − | </section> | |
| 866 | − | )} | |
| 867 | 1160 | ||
| 868 | − | {jobs.length > 0 && ( | |
| 1161 | + | {jobs.length === 0 ? ( | |
| 1162 | + | <RunSummary | |
| 1163 | + | detail={detail} | |
| 1164 | + | artifacts={artifacts} | |
| 1165 | + | summaries={summaries} | |
| 1166 | + | standing={standing} | |
| 1167 | + | cancelling={cancelling} | |
| 1168 | + | member={member} | |
| 1169 | + | busy={busy} | |
| 1170 | + | base={base} | |
| 1171 | + | jobHref={jobHref} | |
| 1172 | + | /> | |
| 1173 | + | ) : ( | |
| 869 | 1174 | <div className="grid gap-6 lg:grid-cols-[15rem_1fr]"> | |
| 870 | − | <DetailsDisclosure | |
| 871 | − | label="Jobs" | |
| 872 | − | summary={selected ? `${jobs.length} · ${selected.name}` : String(jobs.length)} | |
| 873 | − | bodyClassName="space-y-0.5" | |
| 874 | − | > | |
| 875 | − | <nav aria-label="Jobs" className="space-y-0.5 text-sm"> | |
| 876 | − | {jobs.map((job) => ( | |
| 877 | − | <Link | |
| 878 | − | key={job.id} | |
| 879 | − | to={latest ? `?job=${job.id}` : `?attempt=${run.attempt}&job=${job.id}`} | |
| 880 | − | preventScrollReset | |
| 881 | − | className={`flex min-h-11 items-center gap-2 rounded-md px-2.5 py-1.5 sm:min-h-0 ${ | |
| 882 | − | job.id === selected?.id ? "bg-raised text-fg" : "text-muted hover:bg-raised/60 hover:text-fg" | |
| 883 | − | }`} | |
| 884 | − | > | |
| 885 | − | <StatusIcon status={job.status} conclusion={job.conclusion} of="job" environment={job.environment} size={14} /> | |
| 886 | − | <span className="min-w-0 truncate">{job.name}</span> | |
| 887 | − | <Duration className="ml-auto shrink-0 font-mono text-xs text-faint" start={job.startedAt} end={job.finishedAt} /> | |
| 888 | − | </Link> | |
| 889 | − | ))} | |
| 890 | − | </nav> | |
| 1175 | + | <DetailsDisclosure label="Jobs" summary={selected ? `${jobs.length} · ${selected.name}` : `${jobs.length} · Summary`} bodyClassName="space-y-0.5"> | |
| 1176 | + | <RunNav run={run} jobs={jobs} selected={selected} base={base} jobHref={jobHref} summaryHref={summaryHref} /> | |
| 891 | 1177 | </DetailsDisclosure> | |
| 892 | 1178 | {/* Run again, a job keeps its id but its log starts afresh. */} | |
| 893 | − | {selected && ( | |
| 1179 | + | {selected ? ( | |
| 894 | 1180 | <JobView | |
| 895 | 1181 | key={`${selected.id}:${run.attempt}`} | |
| 896 | 1182 | job={selected} | |
| ⋯ | |||
| 921 | 1207 | ) : null | |
| 922 | 1208 | } | |
| 923 | 1209 | /> | |
| 1210 | + | ) : ( | |
| 1211 | + | <RunSummary | |
| 1212 | + | detail={detail} | |
| 1213 | + | artifacts={artifacts} | |
| 1214 | + | summaries={summaries} | |
| 1215 | + | standing={standing} | |
| 1216 | + | cancelling={cancelling} | |
| 1217 | + | member={member} | |
| 1218 | + | busy={busy} | |
| 1219 | + | base={base} | |
| 1220 | + | jobHref={jobHref} | |
| 1221 | + | /> | |
| 924 | 1222 | )} | |
| 925 | 1223 | </div> | |
| 926 | 1224 | )} | |
| 142 | 142 | /// steps and its post steps before it ends. | |
| 143 | 143 | #[serde(default)] | |
| 144 | 144 | pub cancelling: bool, | |
| 145 | + | /// Where its deployment is (`environment.url`), for a job that deploys | |
| 146 | + | /// and says; the current attempt's only. | |
| 147 | + | #[serde(default, skip_serializing_if = "Option::is_none")] | |
| 148 | + | pub environment_url: Option<String>, | |
| 149 | + | /// For a job that calls a reusable workflow: that workflow's file. Its | |
| 150 | + | /// jobs' keys start with this job's key and a `/`. | |
| 151 | + | #[serde(default, skip_serializing_if = "Option::is_none")] | |
| 152 | + | pub uses: Option<String>, | |
| 145 | 153 | } | |
| 146 | 154 | ||
| 147 | 155 | #[derive(Clone, Debug, Serialize, Deserialize)] |
| 1015 | 1015 | self_hosted: false, | |
| 1016 | 1016 | runner: None, | |
| 1017 | 1017 | cancelling: false, | |
| 1018 | + | environment_url: None, | |
| 1019 | + | uses: None, | |
| 1018 | 1020 | }; | |
| 1019 | 1021 | let check = job_check_run("acme/web", &workflow_run, &job); | |
| 1020 | 1022 | assert_eq!(check.display_name(), "CI / Test (push)"); |
| 105 | 105 | runner?: string | null; | |
| 106 | 106 | /** Cancelled, and running its `if: always()` and `cancelled()` steps and post steps before it ends. */ | |
| 107 | 107 | cancelling?: boolean; | |
| 108 | + | /** Where its deployment is (`environment.url`), for a job that deploys and says; the current attempt's only. */ | |
| 109 | + | environmentUrl?: string | null; | |
| 110 | + | /** For a job that calls a reusable workflow: that workflow's file. Its jobs' keys are this job's key, `/`, their own. */ | |
| 111 | + | uses?: string | null; | |
| 108 | 112 | }; | |
| 109 | 113 | ||
| 110 | 114 | /** One attempt of a run: the first, or a re-run. */ |
| 77 | 77 | self_hosted: row.labels.is_some(), | |
| 78 | 78 | runner: row.runner_name, | |
| 79 | 79 | cancelling: false, | |
| 80 | + | environment_url: None, | |
| 81 | + | uses: None, | |
| 80 | 82 | } | |
| 81 | 83 | } | |
| 82 | 84 | ||
| ⋯ | |||
| 100 | 102 | fn job_view(row: JobRow) -> Job { | |
| 101 | 103 | let needs = row.needs(); | |
| 102 | 104 | let cancelling = row.cancel_requested_at.is_some() && row.status != "completed"; | |
| 105 | + | let uses = row.call().filter(|call| call["role"] == "caller").and_then(|call| call["path"].as_str().map(str::to_owned)); | |
| 103 | 106 | Job { | |
| 107 | + | uses, | |
| 108 | + | environment_url: None, | |
| 104 | 109 | cancelling, | |
| 105 | 110 | id: row.id, | |
| 106 | 111 | run_id: row.run_id, | |
| ⋯ | |||
| 254 | 259 | async fn attempt_jobs(&self, run: &RunRow, attempt: Option<u64>) -> Result<Option<AttemptJobs>> { | |
| 255 | 260 | match attempt.filter(|n| *n != run.attempt) { | |
| 256 | 261 | None => { | |
| 257 | − | let jobs: Vec<Job> = self.job_rows(&run.id).await?.into_iter().map(job_view).collect(); | |
| 262 | + | // A job that deploys: where to, as its `environment.url` reads. | |
| 263 | + | let jobs: Vec<Job> = self | |
| 264 | + | .job_rows(&run.id) | |
| 265 | + | .await? | |
| 266 | + | .into_iter() | |
| 267 | + | .map(|row| { | |
| 268 | + | let environment_url = row.environment.as_ref().and_then(|_| crate::plan::deploys_to(run, &row)).and_then(|env| env.url); | |
| 269 | + | Job { environment_url, ..job_view(row) } | |
| 270 | + | }) | |
| 271 | + | .collect(); | |
| 258 | 272 | let log_ids = jobs.iter().map(|job| job.id.clone()).collect(); | |
| 259 | 273 | Ok(Some(AttemptJobs { jobs, log_ids })) | |
| 260 | 274 | } | |