Skip to content
363 linesCodeBlameRaw
1import 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. */
12export 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. */
16export 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. */
19export 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. */
25export 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)`. */
40export 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`. */
45function within(name: string, parent: string | null): string {
46 return parent && name.startsWith(`${parent} / `) ? name.slice(parent.length + 3) : name;
47}
48
49function 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 */
58export 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 // A matrix's calls each have their jobs under `build[i]/`, in `build`'s box.
68 const key = prefix + segment.replace(/\[\d+\]$/, "");
69 if (!direct.has(key)) {
70 order.push(key);
71 direct.set(key, []);
72 nested.set(key, []);
73 }
74 (rest === segment ? direct : nested).get(key)!.push(job);
75 }
76 return order.map((key): Unit => {
77 const own = direct.get(key)!;
78 const inner = nested.get(key)!;
79 const needs = unique(own.flatMap((job) => job.needs));
80 const label = own.length > 0 ? within(own.length > 1 ? baseName(own[0]!.name) : own[0]!.name, parent) : key.slice(prefix.length);
81 if (inner.length > 0 || own.some((job) => job.uses)) {
82 const callerName = own.length > 0 ? (own.length > 1 ? baseName(own[0]!.name) : own[0]!.name) : label;
83 // One call, or one for each combination of the caller's matrix,
84 // whose jobs keep the combination in their names.
85 const calls = unique(inner.map((job) => job.key.slice(0, job.key.indexOf("/", key.length) + 1)));
86 return {
87 kind: "call",
88 key,
89 label,
90 needs,
91 uses: own.find((job) => job.uses)?.uses ?? null,
92 callers: own,
93 units: calls.flatMap((under) => groupJobs(inner, under, under === `${key}/` ? callerName : baseName(callerName))),
94 };
95 }
96 if (own.length > 1) return { kind: "matrix", key, label, needs, jobs: own };
97 return { kind: "job", key, label, needs, job: own[0]! };
98 });
99}
100
101/** Every job of a unit, its called workflow's included. */
102export function jobsOf(unit: Unit): GraphJob[] {
103 if (unit.kind === "job") return [unit.job];
104 if (unit.kind === "matrix") return unit.jobs;
105 return [...unit.callers, ...unit.units.flatMap(jobsOf)];
106}
107
108/** Where several jobs stand together: running while any runs, else the worst way any ended. */
109export function standingOf(jobs: GraphJob[]): Standing {
110 if (jobs.length === 0) return { status: "waiting", conclusion: null };
111 if (jobs.every((job) => job.status === "completed")) {
112 const ended = jobs.map((job) => job.conclusion);
113 const conclusion: Conclusion = ended.includes("failure")
114 ? "failure"
115 : ended.includes("cancelled")
116 ? "cancelled"
117 : ended.includes("success")
118 ? "success"
119 : "skipped";
120 return { status: "completed", conclusion };
121 }
122 const has = (status: Job["status"]) => jobs.some((job) => job.status === status);
123 if (has("in_progress") || has("calling")) return { status: "in_progress", conclusion: null };
124 if (has("pending")) return { status: "pending", conclusion: null };
125 if (has("queued")) return { status: "queued", conclusion: null };
126 return { status: "waiting", conclusion: null };
127}
128
129/** A unit's standing; a called workflow's is its caller's while it has one. */
130export function unitStanding(unit: Unit): Standing {
131 if (unit.kind === "call" && unit.callers.length > 0) return standingOf(unit.callers);
132 return standingOf(jobsOf(unit));
133}
134
135/**
136 * Each unit's column: the longest path of `needs` from a unit needing
137 * nothing. Needs of keys not among `units` are left out, and so is a need
138 * that would close a cycle (a workflow cannot have one, but the graph does
139 * not trust that).
140 */
141export function columns(units: Unit[]): Map<string, number> {
142 const byKey = new Map(units.map((unit) => [unit.key, unit]));
143 const depth = new Map<string, number>();
144 const visiting = new Set<string>();
145 const visit = (key: string): number => {
146 const known = depth.get(key);
147 if (known !== undefined) return known;
148 if (visiting.has(key)) return -1;
149 visiting.add(key);
150 let column = 0;
151 for (const need of byKey.get(key)!.needs) {
152 if (need === key || !byKey.has(need)) continue;
153 const before = visit(need);
154 if (before >= 0) column = Math.max(column, before + 1);
155 }
156 visiting.delete(key);
157 depth.set(key, column);
158 return column;
159 };
160 for (const unit of units) visit(unit.key);
161 return depth;
162}
163
164/** The connectors of one level: from a need to what needs it, never backwards. */
165function links(units: Unit[], column: Map<string, number>): [string, string][] {
166 const out: [string, string][] = [];
167 for (const unit of units)
168 for (const need of unique(unit.needs))
169 if (need !== unit.key && column.has(need) && column.get(need)! < column.get(unit.key)!) out.push([need, unit.key]);
170 return out;
171}
172
173/**
174 * How many pairs of connectors cross, counting pairs that run between the
175 * same two columns.
176 */
177export function crossings(order: string[][], edges: [string, string][]): number {
178 const at = new Map<string, [number, number]>();
179 order.forEach((keys, column) => keys.forEach((key, row) => at.set(key, [column, row])));
180 let count = 0;
181 for (let i = 0; i < edges.length; i++)
182 for (let j = i + 1; j < edges.length; j++) {
183 const [a, b] = edges[i]!.map((key) => at.get(key)!);
184 const [c, d] = edges[j]!.map((key) => at.get(key)!);
185 if (a![0] !== c![0] || b![0] !== d![0]) continue;
186 if ((a![1] - c![1]) * (b![1] - d![1]) < 0) count++;
187 }
188 return count;
189}
190
191/**
192 * Units per column, top to bottom: first as listed, then sorted by the
193 * mean row of what they need (sweeping right) and of what needs them
194 * (sweeping left), a few times, keeping the order with the fewest
195 * crossings.
196 */
197export function orderColumns(units: Unit[], column: Map<string, number>, sweeps = 4): string[][] {
198 const count = units.length === 0 ? 0 : Math.max(...column.values()) + 1;
199 let order: string[][] = Array.from({ length: count }, () => []);
200 for (const unit of units) order[column.get(unit.key)!]!.push(unit.key);
201 const edges = links(units, column);
202 const needsOf = new Map<string, string[]>();
203 const neededBy = new Map<string, string[]>();
204 for (const [from, to] of edges) {
205 needsOf.set(to, [...(needsOf.get(to) ?? []), from]);
206 neededBy.set(from, [...(neededBy.get(from) ?? []), to]);
207 }
208 let best = order.map((keys) => [...keys]);
209 let fewest = crossings(best, edges);
210 const rowOf = () => {
211 const rows = new Map<string, number>();
212 order.forEach((keys) => keys.forEach((key, row) => rows.set(key, row)));
213 return rows;
214 };
215 const sortBy = (keys: string[], others: Map<string, string[]>, rows: Map<string, number>) => {
216 const centre = (key: string, index: number) => {
217 const near = others.get(key) ?? [];
218 return near.length === 0 ? index : near.reduce((sum, other) => sum + rows.get(other)!, 0) / near.length;
219 };
220 return keys
221 .map((key, index) => ({ key, index, at: centre(key, index) }))
222 .sort((x, y) => x.at - y.at || x.index - y.index)
223 .map(({ key }) => key);
224 };
225 for (let sweep = 0; sweep < sweeps && fewest > 0; sweep++) {
226 const right = sweep % 2 === 0;
227 const range = right ? [...order.keys()].slice(1) : [...order.keys()].reverse().slice(1);
228 for (const index of range) order[index] = sortBy(order[index]!, right ? needsOf : neededBy, rowOf());
229 const now = crossings(order, edges);
230 if (now < fewest) {
231 fewest = now;
232 best = order.map((keys) => [...keys]);
233 }
234 }
235 return best;
236}
237
238/** A placed node: a job, or a matrix's jobs. */
239export type PlacedNode = {
240 unit: Extract<Unit, { kind: "job" | "matrix" }>;
241 x: number;
242 y: number;
243 w: number;
244 h: number;
245 expanded: boolean;
246};
247
248/** A placed box: a job calling a workflow, its jobs inside. */
249export type PlacedGroup = { unit: Extract<Unit, { kind: "call" }>; x: number; y: number; w: number; h: number };
250
251/**
252 * `failed`: from a unit that failed. `active`: to a unit running now.
253 * `idle`: the rest.
254 */
255export type EdgeState = "idle" | "active" | "failed";
256export type PlacedEdge = { from: string; to: string; path: string; state: EdgeState };
257
258export type GraphLayout = { width: number; height: number; nodes: PlacedNode[]; groups: PlacedGroup[]; edges: PlacedEdge[] };
259
260type Level = { width: number; height: number; nodes: PlacedNode[]; groups: PlacedGroup[]; edges: PlacedEdge[] };
261
262/** A job node's height: taller with a deployment's address. */
263function jobHeight(job: GraphJob): number {
264 return GRAPH.nodeHeight + (job.environmentUrl ? GRAPH.urlHeight : 0);
265}
266
267/** The address a matrix's jobs all deploy to, when they share one. */
268export function sharedUrl(jobs: GraphJob[]): string | null {
269 const url = jobs[0]?.environmentUrl ?? null;
270 return url && jobs.every((job) => job.environmentUrl === url) ? url : null;
271}
272
273/** A rounded right-angled connector from a node's right edge to another's left. */
274export function connector(x1: number, y1: number, x2: number, y2: number): string {
275 if (Math.abs(y1 - y2) < 0.5) return `M${x1} ${y1}H${x2}`;
276 // Turn in the gap before the target, so the connector leaves at its source's row.
277 const mx = Math.max(x1 + 8, x2 - GRAPH.columnGap / 2);
278 const r = Math.min(8, Math.abs(y2 - y1) / 2, mx - x1, x2 - mx);
279 const down = y2 > y1 ? 1 : -1;
280 return [
281 `M${x1} ${y1}`,
282 `H${mx - r}`,
283 `Q${mx} ${y1} ${mx} ${y1 + down * r}`,
284 `V${y2 - down * r}`,
285 `Q${mx} ${y2} ${mx + r} ${y2}`,
286 `H${x2}`,
287 ].join("");
288}
289
290function edgeState(from: Unit, to: Unit): EdgeState {
291 const source = unitStanding(from);
292 if (source.status === "completed" && source.conclusion === "failure") return "failed";
293 const target = unitStanding(to);
294 if (source.status === "completed" && target.status === "in_progress") return "active";
295 return "idle";
296}
297
298function place(units: Unit[], expanded: ReadonlySet<string>, ox: number, oy: number): Level {
299 const column = columns(units);
300 const order = orderColumns(units, column);
301 const byKey = new Map(units.map((unit) => [unit.key, unit]));
302 const level: Level = { width: 0, height: 0, nodes: [], groups: [], edges: [] };
303 // Each unit's size; a called workflow's box holds its own level.
304 const size = (unit: Unit): [number, number] => {
305 if (unit.kind === "job") return [GRAPH.nodeWidth, jobHeight(unit.job)];
306 if (unit.kind === "matrix")
307 return [
308 GRAPH.nodeWidth,
309 GRAPH.nodeHeight + (sharedUrl(unit.jobs) ? GRAPH.urlHeight : 0) + (expanded.has(unit.key) ? unit.jobs.length * GRAPH.rowHeight + 6 : 0),
310 ];
311 const content = place(unit.units, expanded, 0, 0);
312 return [Math.max(GRAPH.nodeWidth, content.width) + GRAPH.groupPad * 2, GRAPH.groupHead + content.height + GRAPH.groupPad];
313 };
314 const sizes = new Map(units.map((unit) => [unit.key, size(unit)]));
315 const box = new Map<string, { x: number; y: number; w: number; h: number }>();
316 let x = ox;
317 for (const keys of order) {
318 let y = oy;
319 let widest = 0;
320 for (const key of keys) {
321 const [w, h] = sizes.get(key)!;
322 box.set(key, { x, y, w, h });
323 y += h + GRAPH.rowGap;
324 widest = Math.max(widest, w);
325 }
326 level.height = Math.max(level.height, y - GRAPH.rowGap - oy);
327 x += widest + GRAPH.columnGap;
328 }
329 level.width = Math.max(0, x - GRAPH.columnGap - ox);
330 for (const unit of units) {
331 const at = box.get(unit.key)!;
332 if (unit.kind === "call") {
333 level.groups.push({ unit, ...at });
334 // Its jobs, moved into its box.
335 const content = place(unit.units, expanded, at.x + GRAPH.groupPad, at.y + GRAPH.groupHead);
336 level.nodes.push(...content.nodes);
337 level.groups.push(...content.groups);
338 level.edges.push(...content.edges);
339 } else {
340 level.nodes.push({ unit, ...at, expanded: unit.kind === "matrix" && expanded.has(unit.key) });
341 }
342 }
343 for (const [from, to] of links(units, column)) {
344 const a = box.get(from)!;
345 const b = box.get(to)!;
346 // Connectors meet a node at the middle of its first line.
347 const anchor = GRAPH.nodeHeight / 2;
348 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)!) });
349 }
350 return level;
351}
352
353/** The whole graph, placed: `expanded` names the matrices shown open. */
354export function layoutRun(jobs: GraphJob[], expanded: ReadonlySet<string> = new Set()): GraphLayout {
355 const level = place(groupJobs(jobs), expanded, GRAPH.margin, GRAPH.margin);
356 return {
357 width: level.width + GRAPH.margin * 2,
358 height: level.height + GRAPH.margin * 2,
359 nodes: level.nodes,
360 groups: level.groups,
361 edges: level.edges,
362 };
363}