@intentius/behold 0.29.15 → 0.29.17
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +21 -0
- package/THIRD_PARTY.md +36 -0
- package/dist/cli.js +416 -278
- package/licenses/octicons-LICENSE +21 -0
- package/package.json +1 -1
- package/web/app.js +388 -43
- package/web/forge-mark.js +235 -0
- package/web/forge-mark.test.js +112 -0
- package/web/kiosk-runs.js +47 -2
- package/web/kiosk-vendor.js +24 -5
- package/web/kiosk-vendor.test.js +11 -2
- package/web/kiosk.html +17 -11
- package/web/kiosk.js +30 -3
- package/web/kiosk.test.js +15 -1
- package/web/theme.js +7 -3
- package/web/tool-mark.js +161 -0
- package/web/tool-mark.test.js +85 -0
- package/web/vendors/sql-yodeler-160.png +0 -0
- package/web/vendors/sql-yodeler-44.png +0 -0
|
@@ -0,0 +1,235 @@
|
|
|
1
|
+
// behold#571: the forge's mark on each wave box of the Components view, so the
|
|
2
|
+
// CI projection says which forge runs each wave. One mark is an icon and the
|
|
3
|
+
// forge's name beside it, in the box's title row, linked to the wave's job
|
|
4
|
+
// when behold has that job's URL. app.js draws it; nothing here touches the DOM.
|
|
5
|
+
//
|
|
6
|
+
// Which forge, in this order, from what behold already reads:
|
|
7
|
+
//
|
|
8
|
+
// 1. a forge the pipeline names: terragucci.components.json's
|
|
9
|
+
// `pipeline.forge`, or the forge chant's own CI projection was generated
|
|
10
|
+
// for (`/api/ci`'s `forge`);
|
|
11
|
+
// 2. the shape of a run's URLs in the reports (`job_url`, `commit_url`,
|
|
12
|
+
// `pull_request_url`): a known host, or GitLab's `/-/jobs/`, GitHub's
|
|
13
|
+
// `/actions/runs/<n>/job/<n>`, Forgejo's `/actions/runs/<n>/jobs/<n>`;
|
|
14
|
+
// 3. the pipeline file's path (`pipeline.path`), or the one workflow
|
|
15
|
+
// directory the repo has (`.forgejo/workflows`, `.github/workflows`,
|
|
16
|
+
// `.gitlab-ci.yml`), as the server found it (src/terragucci-components.ts).
|
|
17
|
+
//
|
|
18
|
+
// None of them, or URLs that disagree, is no forge, and no mark is drawn.
|
|
19
|
+
// The link is always a URL a report carried; behold never builds one.
|
|
20
|
+
|
|
21
|
+
/**
|
|
22
|
+
* The marks. Each is drawn in a `size` x `size` square from its own viewBox.
|
|
23
|
+
*
|
|
24
|
+
* Forgejo: the official logo by Caesar Schinas, CC BY-SA 4.0, from
|
|
25
|
+
* https://codeberg.org/forgejo/meta/src/branch/readme/branding (logo/forgejo.svg),
|
|
26
|
+
* its geometry and colours as published.
|
|
27
|
+
*
|
|
28
|
+
* GitHub: Octicons `mark-github` (16px), MIT, Copyright (c) GitHub Inc., from
|
|
29
|
+
* https://github.com/primer/octicons/blob/ea8e6bb79894cc7e85564ee9b53d86b418738d04/icons/mark-github-16.svg,
|
|
30
|
+
* filled in currentColor so it reads on either theme.
|
|
31
|
+
*
|
|
32
|
+
* GitLab: the tanuki behold has drawn since #58, one filled silhouette in GitLab's orange.
|
|
33
|
+
*/
|
|
34
|
+
export const FORGES = {
|
|
35
|
+
forgejo: {
|
|
36
|
+
label: "Forgejo",
|
|
37
|
+
viewBox: "0 0 212 212",
|
|
38
|
+
transform: "translate(6,6)",
|
|
39
|
+
shapes: [
|
|
40
|
+
{ tag: "path", d: "M58 168 v-98 a50 50 0 0 1 50-50 h20", fill: "none", stroke: "#ff6600", "stroke-width": 25 },
|
|
41
|
+
{ tag: "path", d: "M58 168 v-30 a50 50 0 0 1 50-50 h20", fill: "none", stroke: "#d40000", "stroke-width": 25 },
|
|
42
|
+
{ tag: "circle", cx: 142, cy: 20, r: 18, fill: "none", stroke: "#ff6600", "stroke-width": 15 },
|
|
43
|
+
{ tag: "circle", cx: 142, cy: 88, r: 18, fill: "none", stroke: "#d40000", "stroke-width": 15 },
|
|
44
|
+
{ tag: "circle", cx: 58, cy: 180, r: 18, fill: "none", stroke: "#d40000", "stroke-width": 15 },
|
|
45
|
+
],
|
|
46
|
+
},
|
|
47
|
+
github: {
|
|
48
|
+
label: "GitHub",
|
|
49
|
+
viewBox: "0 0 16 16",
|
|
50
|
+
shapes: [
|
|
51
|
+
{
|
|
52
|
+
tag: "path",
|
|
53
|
+
fill: "currentColor",
|
|
54
|
+
d: "M6.766 11.328c-2.063-.25-3.516-1.734-3.516-3.656 0-.781.281-1.625.75-2.188-.203-.515-.172-1.609.063-2.062.625-.078 1.468.25 1.968.703.594-.187 1.219-.281 1.985-.281.765 0 1.39.094 1.953.265.484-.437 1.344-.765 1.969-.687.218.422.25 1.515.046 2.047.5.593.766 1.39.766 2.203 0 1.922-1.453 3.375-3.547 3.64.531.344.89 1.094.89 1.954v1.625c0 .468.391.734.86.547C13.781 14.359 16 11.53 16 8.03 16 3.61 12.406 0 7.984 0 3.563 0 0 3.61 0 8.031a7.88 7.88 0 0 0 5.172 7.422c.422.156.828-.125.828-.547v-1.25c-.219.094-.5.156-.75.156-1.031 0-1.64-.562-2.078-1.609-.172-.422-.36-.672-.719-.719-.187-.015-.25-.093-.25-.187 0-.188.313-.328.625-.328.453 0 .844.281 1.25.86.313.452.64.655 1.031.655s.641-.14 1-.5c.266-.265.47-.5.657-.656",
|
|
55
|
+
},
|
|
56
|
+
],
|
|
57
|
+
},
|
|
58
|
+
gitlab: {
|
|
59
|
+
label: "GitLab",
|
|
60
|
+
viewBox: "0 0 24 24",
|
|
61
|
+
shapes: [
|
|
62
|
+
{
|
|
63
|
+
tag: "path",
|
|
64
|
+
fill: "#FC6D26",
|
|
65
|
+
d: "M23.6004 9.5927l-.0337-.0862L20.3.9814a.851.851 0 00-.3362-.405.8748.8748 0 00-.9997.0539.8748.8748 0 00-.29.4399l-2.2055 6.748H7.5375l-2.2055-6.748a.8573.8573 0 00-.29-.4412.8748.8748 0 00-.9997-.0537.8585.8585 0 00-.3362.405L.4332 9.5065l-.0325.0862a6.0657 6.0657 0 002.0119 7.0105l.0113.0087.03.0213 4.976 3.7264 2.462 1.8633 1.4995 1.1321a1.0085 1.0085 0 001.2197 0l1.4995-1.1321 2.462-1.8633 5.006-3.7489.0125-.01a6.0682 6.0682 0 002.0094-7.003z",
|
|
66
|
+
},
|
|
67
|
+
],
|
|
68
|
+
},
|
|
69
|
+
};
|
|
70
|
+
|
|
71
|
+
/** The icon's size and the label's, in the svg's units: about the tanuki's 17px. */
|
|
72
|
+
export const MARK = { size: 17, font: 11, gap: 4, pad: 12 };
|
|
73
|
+
|
|
74
|
+
/** A forge named outright ("forgejo", "GitHub Actions", "gitlab-ci"), or null. */
|
|
75
|
+
export function forgeOfName(name) {
|
|
76
|
+
const s = String(name || "").trim().toLowerCase();
|
|
77
|
+
if (!s) return null;
|
|
78
|
+
if (s.startsWith("forgejo") || s === "codeberg") return "forgejo";
|
|
79
|
+
if (s.startsWith("github")) return "github";
|
|
80
|
+
if (s.startsWith("gitlab")) return "gitlab";
|
|
81
|
+
return null;
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
/** The forge a pipeline file's path belongs to, or null. */
|
|
85
|
+
export function forgeOfPath(path) {
|
|
86
|
+
const p = String(path || "").replace(/\\/g, "/").replace(/^\.\/+/, "");
|
|
87
|
+
if (/(^|\/)\.forgejo\/workflows(\/|$)/.test(p)) return "forgejo";
|
|
88
|
+
if (/(^|\/)\.github\/workflows(\/|$)/.test(p)) return "github";
|
|
89
|
+
if (/(^|\/)\.gitlab-ci\.ya?ml$/.test(p)) return "gitlab";
|
|
90
|
+
return null;
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
/** The forge a run's URL is on, from its host or its path, or null. */
|
|
94
|
+
export function forgeOfUrl(url) {
|
|
95
|
+
let u;
|
|
96
|
+
try {
|
|
97
|
+
u = new URL(String(url || ""));
|
|
98
|
+
} catch {
|
|
99
|
+
return null;
|
|
100
|
+
}
|
|
101
|
+
if (u.protocol !== "https:" && u.protocol !== "http:") return null;
|
|
102
|
+
const host = u.hostname.toLowerCase();
|
|
103
|
+
if (host === "github.com" || host.endsWith(".github.com")) return "github";
|
|
104
|
+
if (host === "gitlab.com" || host.endsWith(".gitlab.com")) return "gitlab";
|
|
105
|
+
if (host === "codeberg.org" || host.endsWith(".codeberg.org")) return "forgejo";
|
|
106
|
+
const p = u.pathname;
|
|
107
|
+
if (/\/-\/(jobs|pipelines|merge_requests|commit)\//.test(p)) return "gitlab";
|
|
108
|
+
if (/\/actions\/runs\/\d+\/job\/\d+/.test(p)) return "github";
|
|
109
|
+
if (/\/actions\/runs\/\d+\/jobs\/\d+/.test(p)) return "forgejo";
|
|
110
|
+
return null;
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
/**
|
|
114
|
+
* The forge that runs the pipeline, or null when none of what behold reads
|
|
115
|
+
* says. `declared` is a forge the pipeline names, `urls` are run URLs from the
|
|
116
|
+
* reports, `path` is the pipeline file's path, and `found` a forge the server
|
|
117
|
+
* found from the repo's one workflow directory.
|
|
118
|
+
*/
|
|
119
|
+
export function detectForge({ declared = null, urls = [], path = null, found = null } = {}) {
|
|
120
|
+
const named = forgeOfName(declared);
|
|
121
|
+
if (named) return named;
|
|
122
|
+
const fromUrls = new Set(urls.map(forgeOfUrl).filter(Boolean));
|
|
123
|
+
if (fromUrls.size === 1) return [...fromUrls][0];
|
|
124
|
+
if (fromUrls.size > 1) return null;
|
|
125
|
+
return forgeOfPath(path) || forgeOfName(found);
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
/**
|
|
129
|
+
* The forge of a terragucci repo's components graph: `facets` is its
|
|
130
|
+
* `meta.terragucciComponents` (`forge` and what said so, `forgeFrom`:
|
|
131
|
+
* `declared` and `config` name it, `pipeline-path` and `workflow-dir` found it),
|
|
132
|
+
* `answer` the terragucci read whose run URLs come second.
|
|
133
|
+
*/
|
|
134
|
+
export function componentsForge(facets, answer) {
|
|
135
|
+
if (!facets) return null;
|
|
136
|
+
const from = facets.forgeFrom;
|
|
137
|
+
return detectForge({
|
|
138
|
+
declared: from === "declared" || from === "config" ? facets.forge : null,
|
|
139
|
+
urls: answerUrls(answer),
|
|
140
|
+
path: facets.pipeline || null,
|
|
141
|
+
found: from === "pipeline-path" || from === "workflow-dir" ? facets.forge : null,
|
|
142
|
+
});
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
/** Every run URL a terragucci read carries, for {@link detectForge}. */
|
|
146
|
+
export function answerUrls(answer) {
|
|
147
|
+
if (!answer || answer.error) return [];
|
|
148
|
+
const out = [];
|
|
149
|
+
const add = (r) => {
|
|
150
|
+
if (!r) return;
|
|
151
|
+
for (const k of ["job_url", "commit_url", "pull_request_url"]) if (typeof r[k] === "string" && r[k]) out.push(r[k]);
|
|
152
|
+
};
|
|
153
|
+
for (const r of Array.isArray(answer.roots) ? answer.roots : []) for (const s of ["plan", "drift", "apply"]) add(r[s]);
|
|
154
|
+
for (const w of Array.isArray(answer.waiting) ? answer.waiting : []) add(w);
|
|
155
|
+
if (answer.pull_request) add(answer.pull_request.run);
|
|
156
|
+
return out;
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
const sameSha = (a, b) => !!a && !!b && (a.startsWith(b) || b.startsWith(a));
|
|
160
|
+
|
|
161
|
+
/**
|
|
162
|
+
* The URL of the job that applied wave `n`, as a report carried it: the
|
|
163
|
+
* wave's waiting gate, or a root's newest apply in that wave. With a
|
|
164
|
+
* `commit`, only a run on that commit counts. Null when no report has one.
|
|
165
|
+
*/
|
|
166
|
+
export function waveJobUrl(answer, n, commit = null) {
|
|
167
|
+
if (!answer || answer.error) return null;
|
|
168
|
+
const found = [];
|
|
169
|
+
for (const w of Array.isArray(answer.waiting) ? answer.waiting : []) {
|
|
170
|
+
if (w.wave === n && w.job_url) found.push({ url: w.job_url, at: w.since || "", commit: w.commit });
|
|
171
|
+
}
|
|
172
|
+
for (const r of Array.isArray(answer.roots) ? answer.roots : []) {
|
|
173
|
+
const a = r && r.apply;
|
|
174
|
+
if (a && a.wave === n && a.job_url) found.push({ url: a.job_url, at: a.finished || "", commit: a.commit });
|
|
175
|
+
}
|
|
176
|
+
const runs = commit ? found.filter((f) => sameSha(f.commit, commit)) : found;
|
|
177
|
+
runs.sort((a, b) => (a.at < b.at ? 1 : a.at > b.at ? -1 : 0));
|
|
178
|
+
return runs.length ? runs[0].url : null;
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
/** The URL the forge's mark links to: a report's job URL, unless its own shape names another forge. */
|
|
182
|
+
export function markLink(url, forge) {
|
|
183
|
+
if (!url) return null;
|
|
184
|
+
const on = forgeOfUrl(url);
|
|
185
|
+
return on && on !== forge ? null : url;
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
/** The mark's tooltip: "apply-wave-2 on Forgejo". */
|
|
189
|
+
export function markTitle(job, forge) {
|
|
190
|
+
const f = FORGES[forge];
|
|
191
|
+
if (!f) return "";
|
|
192
|
+
return job ? `${job} on ${f.label}` : `Runs on ${f.label}`;
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
const SVGNS = "http://www.w3.org/2000/svg";
|
|
196
|
+
|
|
197
|
+
/**
|
|
198
|
+
* A mark's icon as an `<svg>` element of `size` x `size`, from a spec of
|
|
199
|
+
* `{ viewBox, transform?, fill?: { dark, light }, attrs?, shapes }` or
|
|
200
|
+
* `{ image }` (a PNG behold ships), for a dark or a `light` ground. It
|
|
201
|
+
* nests in the graph's svg and stands alone in HTML alike. `doc` is the
|
|
202
|
+
* document to make it in.
|
|
203
|
+
*/
|
|
204
|
+
export function iconSvg(doc, spec, size, { x = 0, y = 0, light = false } = {}) {
|
|
205
|
+
const svg = doc.createElementNS(SVGNS, "svg");
|
|
206
|
+
const set = (n, k, v) => n.setAttribute(k, String(v));
|
|
207
|
+
set(svg, "width", size);
|
|
208
|
+
set(svg, "height", size);
|
|
209
|
+
set(svg, "x", x);
|
|
210
|
+
set(svg, "y", y);
|
|
211
|
+
set(svg, "aria-hidden", "true");
|
|
212
|
+
set(svg, "focusable", "false");
|
|
213
|
+
if (spec.image) {
|
|
214
|
+
set(svg, "viewBox", "0 0 1 1");
|
|
215
|
+
const img = doc.createElementNS(SVGNS, "image");
|
|
216
|
+
for (const [k, v] of Object.entries({ href: spec.image, x: 0, y: 0, width: 1, height: 1 })) set(img, k, v);
|
|
217
|
+
svg.appendChild(img);
|
|
218
|
+
return svg;
|
|
219
|
+
}
|
|
220
|
+
set(svg, "viewBox", spec.viewBox);
|
|
221
|
+
for (const [k, v] of Object.entries(spec.attrs || {})) set(svg, k, v);
|
|
222
|
+
// A mark drawn in one colour per theme (`fill: { dark, light }`) takes it on a group.
|
|
223
|
+
const host = spec.transform || spec.fill ? doc.createElementNS(SVGNS, "g") : svg;
|
|
224
|
+
if (host !== svg) {
|
|
225
|
+
if (spec.transform) set(host, "transform", spec.transform);
|
|
226
|
+
if (spec.fill) set(host, "fill", light ? spec.fill.light : spec.fill.dark);
|
|
227
|
+
svg.appendChild(host);
|
|
228
|
+
}
|
|
229
|
+
for (const { tag, ...attrs } of spec.shapes) {
|
|
230
|
+
const n = doc.createElementNS(SVGNS, tag);
|
|
231
|
+
for (const [k, v] of Object.entries(attrs)) set(n, k, v);
|
|
232
|
+
host.appendChild(n);
|
|
233
|
+
}
|
|
234
|
+
return svg;
|
|
235
|
+
}
|
|
@@ -0,0 +1,112 @@
|
|
|
1
|
+
import { describe, expect, it } from "vitest";
|
|
2
|
+
import { FORGES, answerUrls, markLink, componentsForge, detectForge, forgeOfName, forgeOfPath, forgeOfUrl, markTitle, waveJobUrl } from "./forge-mark.js";
|
|
3
|
+
|
|
4
|
+
// behold#571: which forge runs a wave, from what behold already reads.
|
|
5
|
+
describe("forge detection", () => {
|
|
6
|
+
it("reads a forge named outright", () => {
|
|
7
|
+
expect(forgeOfName("forgejo")).toBe("forgejo");
|
|
8
|
+
expect(forgeOfName("GitHub Actions")).toBe("github");
|
|
9
|
+
expect(forgeOfName("gitlab-ci")).toBe("gitlab");
|
|
10
|
+
expect(forgeOfName("woodpecker")).toBeNull();
|
|
11
|
+
expect(forgeOfName(undefined)).toBeNull();
|
|
12
|
+
});
|
|
13
|
+
|
|
14
|
+
it("reads a pipeline file's path", () => {
|
|
15
|
+
expect(forgeOfPath(".forgejo/workflows/terragucci.yml")).toBe("forgejo");
|
|
16
|
+
expect(forgeOfPath("./.github/workflows/deploy.yml")).toBe("github");
|
|
17
|
+
expect(forgeOfPath(".gitlab-ci.yml")).toBe("gitlab");
|
|
18
|
+
expect(forgeOfPath("ci/pipeline.yml")).toBeNull();
|
|
19
|
+
});
|
|
20
|
+
|
|
21
|
+
it("reads a run URL's host and path shape", () => {
|
|
22
|
+
expect(forgeOfUrl("https://github.com/acme/shop/actions/runs/9/job/31")).toBe("github");
|
|
23
|
+
expect(forgeOfUrl("https://gitlab.com/acme/shop/-/jobs/12")).toBe("gitlab");
|
|
24
|
+
expect(forgeOfUrl("https://git.acme.dev/acme/shop/-/jobs/12")).toBe("gitlab");
|
|
25
|
+
expect(forgeOfUrl("https://codeberg.org/acme/shop/actions/runs/4/jobs/1")).toBe("forgejo");
|
|
26
|
+
expect(forgeOfUrl("http://forgejo:3000/admin/demo-terragrunt/actions/runs/448/jobs/3")).toBe("forgejo");
|
|
27
|
+
expect(forgeOfUrl("https://ghe.acme.dev/acme/shop/actions/runs/9/job/31")).toBe("github");
|
|
28
|
+
expect(forgeOfUrl("https://ci/9131")).toBeNull();
|
|
29
|
+
expect(forgeOfUrl("not a url")).toBeNull();
|
|
30
|
+
expect(forgeOfUrl("javascript:alert(1)")).toBeNull();
|
|
31
|
+
});
|
|
32
|
+
|
|
33
|
+
it("takes a named forge first, then the run URLs, then the path", () => {
|
|
34
|
+
const gh = ["https://github.com/a/b/actions/runs/1/job/2"];
|
|
35
|
+
expect(detectForge({ declared: "forgejo", urls: gh })).toBe("forgejo");
|
|
36
|
+
expect(detectForge({ urls: gh, path: ".forgejo/workflows/x.yml" })).toBe("github");
|
|
37
|
+
expect(detectForge({ path: ".forgejo/workflows/x.yml" })).toBe("forgejo");
|
|
38
|
+
expect(detectForge({ found: "gitlab" })).toBe("gitlab");
|
|
39
|
+
});
|
|
40
|
+
|
|
41
|
+
it("says no forge when nothing says, or the run URLs disagree", () => {
|
|
42
|
+
expect(detectForge({})).toBeNull();
|
|
43
|
+
expect(detectForge({ urls: ["https://ci/1"], path: "ci.yml" })).toBeNull();
|
|
44
|
+
expect(detectForge({ urls: ["https://github.com/a/b/actions/runs/1/job/2", "https://gitlab.com/a/b/-/jobs/3"], path: ".forgejo/workflows/x.yml" })).toBeNull();
|
|
45
|
+
});
|
|
46
|
+
|
|
47
|
+
it("reads a components graph's facets and the reports' URLs", () => {
|
|
48
|
+
const answer = { roots: [{ path: "app", apply: { wave: 2, job_url: "https://codeberg.org/a/b/actions/runs/4/jobs/1" } }], waiting: [] };
|
|
49
|
+
expect(componentsForge({ forge: "forgejo", forgeFrom: "declared" }, null)).toBe("forgejo");
|
|
50
|
+
expect(componentsForge({ forge: "github", forgeFrom: "config" }, answer)).toBe("github");
|
|
51
|
+
// A workflow directory is the weakest word: the reports' URLs outrank it.
|
|
52
|
+
expect(componentsForge({ forge: "github", forgeFrom: "workflow-dir" }, answer)).toBe("forgejo");
|
|
53
|
+
expect(componentsForge({ forge: "github", forgeFrom: "workflow-dir" }, null)).toBe("github");
|
|
54
|
+
expect(componentsForge({ pipeline: ".gitlab-ci.yml" }, null)).toBe("gitlab");
|
|
55
|
+
expect(componentsForge({ jobs: [] }, null)).toBeNull();
|
|
56
|
+
expect(componentsForge(null, answer)).toBeNull();
|
|
57
|
+
expect(answerUrls({ error: "x" })).toEqual([]);
|
|
58
|
+
});
|
|
59
|
+
});
|
|
60
|
+
|
|
61
|
+
describe("a wave's job URL", () => {
|
|
62
|
+
const answer = {
|
|
63
|
+
roots: [
|
|
64
|
+
{ path: "network", apply: { wave: 1, commit: "aaa111", finished: "2026-10-15T04:10:00Z", job_url: "https://f/r/actions/runs/7/jobs/2" } },
|
|
65
|
+
{ path: "app", apply: { wave: 2, commit: "aaa111", finished: "2026-10-15T04:12:00Z", job_url: "https://f/r/actions/runs/7/jobs/3" } },
|
|
66
|
+
{ path: "ledger", apply: { wave: 2, commit: "bbb222", finished: "2026-10-15T04:20:00Z", job_url: "https://f/r/actions/runs/9/jobs/3" } },
|
|
67
|
+
{ path: "edge", plan: { job_url: "https://f/r/actions/runs/6/jobs/1" } },
|
|
68
|
+
],
|
|
69
|
+
waiting: [{ wave: 3, commit: "bbb222", since: "2026-10-15T04:21:00Z", job_url: "https://f/r/actions/runs/9/jobs/4" }],
|
|
70
|
+
};
|
|
71
|
+
|
|
72
|
+
it("is the URL a report carried for that wave, on the drawn commit", () => {
|
|
73
|
+
expect(waveJobUrl(answer, 2, "aaa111")).toBe("https://f/r/actions/runs/7/jobs/3");
|
|
74
|
+
expect(waveJobUrl(answer, 2, "bbb2")).toBe("https://f/r/actions/runs/9/jobs/3");
|
|
75
|
+
expect(waveJobUrl(answer, 3, "bbb222")).toBe("https://f/r/actions/runs/9/jobs/4");
|
|
76
|
+
});
|
|
77
|
+
|
|
78
|
+
it("is the newest with no commit drawn", () => {
|
|
79
|
+
expect(waveJobUrl(answer, 2)).toBe("https://f/r/actions/runs/9/jobs/3");
|
|
80
|
+
});
|
|
81
|
+
|
|
82
|
+
it("is null when no report has one", () => {
|
|
83
|
+
expect(waveJobUrl(answer, 4)).toBeNull();
|
|
84
|
+
expect(waveJobUrl(answer, 1, "bbb222")).toBeNull();
|
|
85
|
+
expect(waveJobUrl(null, 1)).toBeNull();
|
|
86
|
+
});
|
|
87
|
+
});
|
|
88
|
+
|
|
89
|
+
describe("the mark's link", () => {
|
|
90
|
+
it("is the report's URL, unless that URL is another forge's", () => {
|
|
91
|
+
expect(markLink("https://github.com/a/b/actions/runs/9/job/1", "github")).toBe("https://github.com/a/b/actions/runs/9/job/1");
|
|
92
|
+
expect(markLink("https://ci.acme.dev/jobs/9", "forgejo")).toBe("https://ci.acme.dev/jobs/9");
|
|
93
|
+
expect(markLink("https://github.com/a/b/actions/runs/9", "forgejo")).toBeNull();
|
|
94
|
+
expect(markLink(null, "github")).toBeNull();
|
|
95
|
+
});
|
|
96
|
+
});
|
|
97
|
+
|
|
98
|
+
describe("the mark", () => {
|
|
99
|
+
it("says the job and the forge", () => {
|
|
100
|
+
expect(markTitle("apply-wave-2", "forgejo")).toBe("apply-wave-2 on Forgejo");
|
|
101
|
+
expect(markTitle(null, "github")).toBe("Runs on GitHub");
|
|
102
|
+
expect(markTitle("x", "bitbucket")).toBe("");
|
|
103
|
+
});
|
|
104
|
+
|
|
105
|
+
it("has every forge's name and an inline icon, with no external URL", () => {
|
|
106
|
+
for (const f of Object.values(FORGES)) {
|
|
107
|
+
expect(f.label).toMatch(/^(Forgejo|GitHub|GitLab)$/);
|
|
108
|
+
expect(JSON.stringify(f)).not.toMatch(/https?:/);
|
|
109
|
+
}
|
|
110
|
+
});
|
|
111
|
+
|
|
112
|
+
});
|
package/web/kiosk-runs.js
CHANGED
|
@@ -12,6 +12,8 @@
|
|
|
12
12
|
|
|
13
13
|
import { rootColour, rootPaint, rootSegments, rootText, rootsOfWave, waveOfJob } from "./kiosk-roots.js";
|
|
14
14
|
import { TOUR, chipWords, docsUrl, jobMarks, runFeatures } from "./kiosk-annotations.js";
|
|
15
|
+
import { iconSvg } from "./forge-mark.js";
|
|
16
|
+
import { TOOLS, jobTools } from "./tool-mark.js";
|
|
15
17
|
|
|
16
18
|
/** The run, job and step statuses, as a viewer reads them. */
|
|
17
19
|
export const STATUS_WORDS = {
|
|
@@ -465,7 +467,37 @@ export function renderSidebar(host, index, selectedId, { now = new Date(), scena
|
|
|
465
467
|
* null for the defaults. Returns what it drew, so the caller can keep a log's
|
|
466
468
|
* scroll.
|
|
467
469
|
*/
|
|
468
|
-
|
|
470
|
+
/**
|
|
471
|
+
* behold#571: a job row's tool marks, `<span class="r-job-tools">` of one
|
|
472
|
+
* `r-tool` per tool (its icon, then its name), or null for none. `carried`
|
|
473
|
+
* are the tools the roots the job applies carry inside them. A tool a vendor
|
|
474
|
+
* page may not name (`hideLine`) is left out.
|
|
475
|
+
*/
|
|
476
|
+
export function toolMarks(job, repoTools = [], { light = false, hideLine = null, doc = document, carried = [] } = {}) {
|
|
477
|
+
const ids = jobTools(job, repoTools, carried).filter((t) => !(hideLine && hideLine(TOOLS[t].name)));
|
|
478
|
+
if (!ids.length) return null;
|
|
479
|
+
const box = doc.createElement("span");
|
|
480
|
+
box.className = "r-job-tools";
|
|
481
|
+
for (const t of ids) {
|
|
482
|
+
const tool = TOOLS[t];
|
|
483
|
+
const m = doc.createElement("span");
|
|
484
|
+
m.className = `r-tool r-tool-${t}`;
|
|
485
|
+
m.title = `${job.name} runs ${tool.name}`;
|
|
486
|
+
if (tool.icon) {
|
|
487
|
+
const icon = iconSvg(doc, tool.icon, 11, { light });
|
|
488
|
+
icon.setAttribute("class", "r-tool-icon");
|
|
489
|
+
m.appendChild(icon);
|
|
490
|
+
}
|
|
491
|
+
const name = doc.createElement("span");
|
|
492
|
+
name.className = "r-tool-name";
|
|
493
|
+
name.textContent = tool.name;
|
|
494
|
+
m.appendChild(name);
|
|
495
|
+
box.appendChild(m);
|
|
496
|
+
}
|
|
497
|
+
return box;
|
|
498
|
+
}
|
|
499
|
+
|
|
500
|
+
export function renderRun(host, answer, ui, { now = new Date(), pickJob = () => {}, toggleStep = () => {}, toggleSetup = () => {}, jump = () => {}, toggleFeats = () => {}, featsOpen = false, note = null, flash = null, entry = null, roots = [], waves = [], tools = [], rootTools = {}, light = false, hideLine = null } = {}) {
|
|
469
501
|
// behold#571: a root's name with its colour's dot, where the line names one.
|
|
470
502
|
const textOf = (cls, text) => {
|
|
471
503
|
if (!roots.length || !rootSegments(text, roots).some((x) => x.root)) return el("span", cls, text);
|
|
@@ -506,7 +538,16 @@ export function renderRun(host, answer, ui, { now = new Date(), pickJob = () =>
|
|
|
506
538
|
const li = el("li", `r-job${job && j.name === job.name ? " r-selected" : ""}`);
|
|
507
539
|
li.style.setProperty("--depth", String(j.depth));
|
|
508
540
|
li.appendChild(statusIcon(j.status));
|
|
509
|
-
|
|
541
|
+
// behold#571: the tools the job runs, each its logo (where its owner allows) and its name, on a
|
|
542
|
+
// line of their own under the job's name, so the row's line keeps its width.
|
|
543
|
+
const jobWave = waveOfJob(j.name, waves);
|
|
544
|
+
const carried = jobWave !== null ? rootsOfWave(jobWave, waves).flatMap((r) => rootTools[r] || []) : [];
|
|
545
|
+
const marks = toolMarks(j, tools, { light, hideLine, carried });
|
|
546
|
+
if (marks) {
|
|
547
|
+
const head = el("span", "r-job-head");
|
|
548
|
+
head.append(el("span", "r-job-name", j.name), marks);
|
|
549
|
+
li.appendChild(head);
|
|
550
|
+
} else li.appendChild(el("span", "r-job-name", j.name));
|
|
510
551
|
// behold#571: a wave's job row names the roots it applies, each with its dot, and lights its wave box on hover.
|
|
511
552
|
const wave = waveOfJob(j.name, waves);
|
|
512
553
|
const applies = wave !== null ? rootsOfWave(wave, waves).filter((r) => rootColour(r, roots, { light })) : [];
|
|
@@ -738,6 +779,8 @@ export function startRuns(
|
|
|
738
779
|
onLive = () => {},
|
|
739
780
|
roots = () => [],
|
|
740
781
|
waves = () => [],
|
|
782
|
+
tools = () => [],
|
|
783
|
+
rootTools = () => ({}),
|
|
741
784
|
light = () => false,
|
|
742
785
|
source = () => null,
|
|
743
786
|
filter = (index) => index,
|
|
@@ -798,6 +841,8 @@ export function startRuns(
|
|
|
798
841
|
entry,
|
|
799
842
|
roots: roots(),
|
|
800
843
|
waves: waves(),
|
|
844
|
+
tools: tools(),
|
|
845
|
+
rootTools: rootTools(),
|
|
801
846
|
light: light(),
|
|
802
847
|
hideLine,
|
|
803
848
|
note: s.note,
|
package/web/kiosk-vendor.js
CHANGED
|
@@ -16,7 +16,11 @@
|
|
|
16
16
|
* null, the name as text), and the footer's credit. `terms` are the words
|
|
17
17
|
* that name the tool or its owner. `kind` is what it competes as (see
|
|
18
18
|
* COMPETES): an `engine` plans and applies, an `orchestrator` drives an
|
|
19
|
-
* engine
|
|
19
|
+
* engine, and a `pipeline` plans and applies a pull request's change with an
|
|
20
|
+
* engine. `reports: false` marks a tool whose pipeline writes no terragucci
|
|
21
|
+
* reports or run view: its page draws the checkout behold serves for its repo
|
|
22
|
+
* (kiosk.js `frameOk`). Keep the keys in step with VENDOR_TOOLS in
|
|
23
|
+
* src/terragucci-demo.ts.
|
|
20
24
|
*
|
|
21
25
|
* - Terraform: HashiCorp's rules allow the name as text, with the mark noted.
|
|
22
26
|
* - Terragrunt: Gruntwork publishes logo files, but a logo on a page like
|
|
@@ -24,6 +28,8 @@
|
|
|
24
28
|
* - CDK Terrain: the Open Construct Foundation's continuation of CDKTF; its
|
|
25
29
|
* site, cdktn.io, publishes no rules for its logo, so the name is text.
|
|
26
30
|
* - choudoufu: ours, logo and all.
|
|
31
|
+
* - chant: ours too. Its docs ship no logo, so the name is text. Its scenario
|
|
32
|
+
* runs chant's own pull-request pipeline, which installs OpenTofu.
|
|
27
33
|
*/
|
|
28
34
|
export const VENDORS = {
|
|
29
35
|
terraform: {
|
|
@@ -67,6 +73,17 @@ export const VENDORS = {
|
|
|
67
73
|
terms: ["choudoufu"],
|
|
68
74
|
runsWith: [],
|
|
69
75
|
},
|
|
76
|
+
chant: {
|
|
77
|
+
name: "chant",
|
|
78
|
+
owner: "Intentius",
|
|
79
|
+
url: "https://intentius.io/chant/",
|
|
80
|
+
logo: null,
|
|
81
|
+
credit: "chant is made by Intentius, who also make this demo.",
|
|
82
|
+
kind: "pipeline",
|
|
83
|
+
terms: ["chant"],
|
|
84
|
+
runsWith: ["opentofu"],
|
|
85
|
+
reports: false,
|
|
86
|
+
},
|
|
70
87
|
};
|
|
71
88
|
|
|
72
89
|
/** What a tool's scenarios run on, said on its page as the owner's rules allow ("works with OpenTofu"). */
|
|
@@ -92,11 +109,13 @@ export const COMPETITORS = {
|
|
|
92
109
|
/**
|
|
93
110
|
* What competes with what. Engines compete with engines (Terraform, OpenTofu,
|
|
94
111
|
* choudoufu); orchestrators with orchestrators (Terragrunt, CDK Terrain,
|
|
95
|
-
* Atmos, Terramate)
|
|
96
|
-
*
|
|
97
|
-
*
|
|
112
|
+
* Atmos, Terramate); pipelines with pipelines (chant, and none other has a
|
|
113
|
+
* page). An orchestrator runs on an engine, so its page names the engine
|
|
114
|
+
* freely; an engine's page names no other engine, but for the one its
|
|
115
|
+
* scenarios really run with (`runsWith`). chant is a pipeline, so the engine
|
|
116
|
+
* and orchestrator pages keep the `chant approve` lines their runs print.
|
|
98
117
|
*/
|
|
99
|
-
export const COMPETES = { engine: "engine", orchestrator: "orchestrator" };
|
|
118
|
+
export const COMPETES = { engine: "engine", orchestrator: "orchestrator", pipeline: "pipeline" };
|
|
100
119
|
|
|
101
120
|
const own = (o, k) => Object.prototype.hasOwnProperty.call(o, k);
|
|
102
121
|
|
package/web/kiosk-vendor.test.js
CHANGED
|
@@ -147,8 +147,8 @@ describe("the branding table (behold#571)", () => {
|
|
|
147
147
|
const v = VENDORS[tool];
|
|
148
148
|
const words = [v.name, ...creditLines(tool).map((l) => l.map((s) => s.text).join("")), ...Object.values(notRunWords(v)), legendWords("components", tool), legendWords("resources", tool)];
|
|
149
149
|
for (const w of words) expect(namesOther(w, tool), `${tool}: ${w}`).toBe(false);
|
|
150
|
-
// choudoufu
|
|
151
|
-
if (tool !== "choudoufu") expect(v.credit).toMatch(/(does not|don't) endorse this demo\./);
|
|
150
|
+
// choudoufu and chant are ours: their credits say so instead.
|
|
151
|
+
if (tool !== "choudoufu" && tool !== "chant") expect(v.credit).toMatch(/(does not|don't) endorse this demo\./);
|
|
152
152
|
}
|
|
153
153
|
expect(VENDORS.choudoufu.credit).toMatch(/AWS and Kubernetes/);
|
|
154
154
|
expect(VENDORS.choudoufu.credit).not.toMatch(/experimental/i);
|
|
@@ -170,9 +170,18 @@ describe("the branding table (behold#571)", () => {
|
|
|
170
170
|
terragrunt: ["CDK Terrain", "CDKTN", "Open Construct", "Atmos", "Cloud Posse", "Terramate"],
|
|
171
171
|
"cdk-terrain": ["Terragrunt", "Gruntwork", "Atmos", "Cloud Posse", "Terramate"],
|
|
172
172
|
choudoufu: ["Terraform", "HashiCorp", "OpenTofu"],
|
|
173
|
+
// A pipeline: no other tool with a page is one.
|
|
174
|
+
chant: [],
|
|
173
175
|
});
|
|
174
176
|
expect(otherTerms(null)).toEqual([]);
|
|
175
177
|
});
|
|
178
|
+
it("keeps the chant approve lines on every page, since chant is a pipeline and competes with no engine or orchestrator", () => {
|
|
179
|
+
for (const tool of VENDOR_TOOLS) expect(namesOther("chant approve tf-apply wave-2 --actor demo-reviewer --sign", tool), tool).toBe(false);
|
|
180
|
+
expect(VENDORS.chant.url).toBe("https://intentius.io/chant/");
|
|
181
|
+
expect(VENDORS.chant.owner).toBe("Intentius");
|
|
182
|
+
expect(creditLines("chant").flat().filter((s) => s.url).map((s) => s.text)).toEqual(["chant", "OpenTofu"]);
|
|
183
|
+
expect(toolCheckout("chant")).toBe("demo-chant");
|
|
184
|
+
});
|
|
176
185
|
it("lets an orchestrator's page name the engines it runs on, and keeps an engine's page from naming another engine", () => {
|
|
177
186
|
expect(namesOther("Big Terraform setups run on OpenTofu", "terragrunt")).toBe(false);
|
|
178
187
|
expect(namesOther("Big Terraform setups", "cdk-terrain")).toBe(false);
|
package/web/kiosk.html
CHANGED
|
@@ -9,16 +9,16 @@
|
|
|
9
9
|
href="data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 16 16'%3E%3Ccircle cx='8' cy='8' r='6' fill='none' stroke='%23a6adc8' stroke-width='1.6'/%3E%3Ccircle cx='8' cy='8' r='2.2' fill='%23a6adc8'/%3E%3C/svg%3E"
|
|
10
10
|
/>
|
|
11
11
|
<style>
|
|
12
|
-
/* #569 step 2. The pre-paint fallback
|
|
13
|
-
theme
|
|
12
|
+
/* #569 step 2. The pre-paint fallback: the kiosk's default theme's
|
|
13
|
+
tokens (KIOSK_THEME in theme.js), which initTheme() replaces with the viewer's theme. */
|
|
14
14
|
:root {
|
|
15
|
-
--bg: #
|
|
16
|
-
--edge: #
|
|
17
|
-
--degraded: #
|
|
18
|
-
--rule: #
|
|
19
|
-
/* behold#571: the kiosk's surfaces and ink (theme.js kioskSurfaces), the default theme's until initTheme() runs. */
|
|
20
|
-
--surface-box: #
|
|
21
|
-
--managed-ink: #
|
|
15
|
+
--bg: #09141b; --panel: #1c2325; --line: #2c2e2d; --fg: #deb88d; --muted: #746a5c;
|
|
16
|
+
--edge: #4d483f; --managed: #027c9b; --foreign: #fca02f; --pending: #1e4950;
|
|
17
|
+
--degraded: #d15123; --neutral: #695e4e; --runtime: #50a3b5;
|
|
18
|
+
--rule: #3d4142; --focus: #364548; --active: #243235; --well: #01070d; --shadow: #010304;
|
|
19
|
+
/* behold#571: the kiosk's surfaces and ink (theme.js kioskSurfaces), the kiosk's default theme's (KIOSK_THEME, Sea Shells) until initTheme() runs. */
|
|
20
|
+
--surface-box: #101b22; --surface-pane-a: #152128; --surface-pane-b: #1b272e; --zebra: #deb88d; --soft: #deb88d; --muted-ink: #7c7365;
|
|
21
|
+
--managed-ink: #0e7f9e; --foreign-ink: #fca02f; --pending-ink: #567b82; --degraded-ink: #d15123; --runtime-ink: #50a3b5;
|
|
22
22
|
--font-ui: ui-sans-serif, system-ui, -apple-system, "Segoe UI", Roboto, sans-serif;
|
|
23
23
|
--font-mono: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace;
|
|
24
24
|
}
|
|
@@ -67,6 +67,12 @@
|
|
|
67
67
|
.r-job.r-selected { background: var(--active); }
|
|
68
68
|
.r-job-name { font: 600 0.92em var(--font-mono); }
|
|
69
69
|
.r-job-needs { color: var(--muted); font-size: 0.78em; }
|
|
70
|
+
/* behold#571: the tools a job runs, each its logo and its name. Short of room, the names stay and the logos go. */
|
|
71
|
+
.r-job-head { display: inline-flex; flex-direction: column; flex: none; line-height: 1.15; }
|
|
72
|
+
.r-job-name { white-space: nowrap; flex: none; }
|
|
73
|
+
.r-job-tools { display: inline-flex; gap: 0.6em; font-size: 0.7em; color: var(--muted); white-space: nowrap; }
|
|
74
|
+
.r-tool { display: inline-flex; align-items: center; gap: 0.25em; }
|
|
75
|
+
.r-tool-icon { flex: none; }
|
|
70
76
|
/* behold#571: each root's colour, as a dot by its name; never a text or fill colour, which mean a state. */
|
|
71
77
|
.k-root::before { content: ""; display: inline-block; width: 0.6em; height: 0.6em; border-radius: 50%; background: var(--k-root); margin: 0 0.28em 0.1em 0.05em; vertical-align: middle; }
|
|
72
78
|
.r-job-roots { display: inline-flex; gap: 0.75em; font-size: 0.82em; color: var(--fg); }
|
|
@@ -292,10 +298,10 @@
|
|
|
292
298
|
</div>
|
|
293
299
|
<footer id="foot"></footer>
|
|
294
300
|
<script type="module">
|
|
295
|
-
import { initTheme } from "/theme.js";
|
|
301
|
+
import { initTheme, KIOSK_THEME } from "/theme.js";
|
|
296
302
|
import { startKiosk } from "/kiosk.js";
|
|
297
303
|
import { pageTool } from "/kiosk-vendor.js";
|
|
298
|
-
initTheme();
|
|
304
|
+
initTheme(KIOSK_THEME);
|
|
299
305
|
// behold#571: /<tool> serves this page with its tool in a meta tag; /kiosk has none.
|
|
300
306
|
startKiosk(
|
|
301
307
|
{
|