@intentius/behold 0.29.7 → 0.29.9
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 +7 -0
- package/dist/cli.js +1221 -765
- package/package.json +1 -1
- package/web/app.js +60 -4
- package/web/index.html +2 -0
- package/web/kiosk-annotations.js +528 -0
- package/web/kiosk-annotations.test.js +83 -0
- package/web/kiosk-approval.js +241 -0
- package/web/kiosk-approval.test.js +122 -0
- package/web/kiosk-popover.js +1 -0
- package/web/kiosk-repo.js +25 -0
- package/web/kiosk-repo.test.js +43 -0
- package/web/kiosk-roots.js +15 -7
- package/web/kiosk-runs.js +360 -25
- package/web/kiosk-runs.test.js +104 -1
- package/web/kiosk-vendor.js +397 -0
- package/web/kiosk-vendor.test.js +281 -0
- package/web/kiosk.html +160 -17
- package/web/kiosk.js +465 -51
- package/web/kiosk.test.js +73 -8
- package/web/theme.js +112 -0
- package/web/theme.test.js +51 -1
- package/web/vendors/choudoufu-128.png +0 -0
- package/web/vendors/choudoufu-64.png +0 -0
- package/web/view-repo.js +44 -0
package/web/kiosk-runs.test.js
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import { readFileSync } from "node:fs";
|
|
2
2
|
import { join } from "node:path";
|
|
3
3
|
import { describe, expect, it } from "vitest";
|
|
4
|
-
import { FOLLOW_LINES, followsLog, logKey, nearEnd, mergeRun, newerOf, agoWords, defaultJob, defaultStep, durationWords, jobOrder, liveRun, codeish, logLineWords, logRows, placeWords, scenarioTitles, setupLine, sidebarRuns, splitSkipped, STATUS_TONE, triggerWords } from "./kiosk-runs.js";
|
|
4
|
+
import { FEATURE_LINES, fitFeatures, FOLLOW_LINES, followsLog, logKey, nearEnd, mergeRun, newerOf, agoWords, defaultJob, defaultStep, durationWords, jobOrder, liveRun, codeish, logLineWords, logRows, placeWords, scenarioTitles, setupLine, sidebarRuns, splitSkipped, STATUS_TONE, triggerWords } from "./kiosk-runs.js";
|
|
5
5
|
|
|
6
6
|
const FIX = join(import.meta.dirname, "..", "src", "__fixtures__", "terragucci-demo", "runs");
|
|
7
7
|
const run = (id) => JSON.parse(readFileSync(join(FIX, `${id}.json`), "utf8"));
|
|
@@ -183,3 +183,106 @@ describe("the runs for a viewer new to the demo (behold#571)", () => {
|
|
|
183
183
|
expect(logRows(own.log)).toEqual(own.log.map((text, i) => ({ n: i + 1, text })));
|
|
184
184
|
});
|
|
185
185
|
});
|
|
186
|
+
|
|
187
|
+
describe("an artifact's upload and download fold with the set-up (behold#571)", () => {
|
|
188
|
+
it("knows upload-artifact's and download-artifact's lines", () => {
|
|
189
|
+
for (const l of [
|
|
190
|
+
"With the provided path, there will be 9 files uploaded",
|
|
191
|
+
"Starting artifact upload",
|
|
192
|
+
"For more detailed logs during the artifact upload process, enable step-debugging: https://docs.github.com/actions/monitoring-and-troubleshooting-workflows/enabling-debug-logging#enabling-step-debug-logging",
|
|
193
|
+
"Artifact name is valid!",
|
|
194
|
+
'Container for artifact "terragucci-report-apply-wave-1" successfully created. Starting upload of file(s)',
|
|
195
|
+
"Total size of all the files uploaded is 10011 bytes",
|
|
196
|
+
"File upload process has finished. Finalizing the artifact upload",
|
|
197
|
+
"Artifact has been finalized. All files have been successfully uploaded!",
|
|
198
|
+
"The raw size of all the files that were specified for upload is 38546 bytes",
|
|
199
|
+
"The size of all the files that were uploaded is 10011 bytes. This takes into account any gzip compression used to reduce the upload size, time and storage",
|
|
200
|
+
"Note: The size of downloaded zips can differ significantly from the reported size. For more information see: https://github.com/actions/upload-artifact#zipped-artifact-downloads ",
|
|
201
|
+
"Artifact terragucci-report-apply-wave-1 has been successfully uploaded!",
|
|
202
|
+
"Starting download for terragucci-report",
|
|
203
|
+
"Directory structure has been setup for the artifact",
|
|
204
|
+
"Total number of files that will be downloaded: 16",
|
|
205
|
+
"Artifact terragucci-report was downloaded to /workspace/terragucci-admin/demo/terragucci-report",
|
|
206
|
+
"Artifact download has finished successfully",
|
|
207
|
+
]) expect(setupLine(l), l).toBe(true);
|
|
208
|
+
expect(setupLine("wave 2: report copied to demo/forgejo:3000/x/tf-apply-wave-2")).toBe(false);
|
|
209
|
+
});
|
|
210
|
+
|
|
211
|
+
it("folds a whole upload step, its blank lines with it, into the one row", () => {
|
|
212
|
+
const keep = run(444).jobs.find((j) => j.name === "apply-wave-1").steps.find((s) => s.name === "Keep the apply report");
|
|
213
|
+
const rows = logRows(keep.log);
|
|
214
|
+
expect(rows).toEqual([{ setup: keep.log.length, open: false }]);
|
|
215
|
+
});
|
|
216
|
+
|
|
217
|
+
it("keeps a blank line that follows terragucci's own lines", () => {
|
|
218
|
+
expect(logRows(["3 members: 3 groups, 0 destroys or replacements.", "", "Group b6b888372b35: 1 member (outlier), change:"]).map((r) => r.n)).toEqual([1, 2, 3]);
|
|
219
|
+
});
|
|
220
|
+
|
|
221
|
+
it("never opens an upload step by default, even when it has the most lines", () => {
|
|
222
|
+
const check = run(444).jobs.find((j) => j.name === "check").steps;
|
|
223
|
+
expect(check[defaultStep(check)].name).toBe("Format check and validate, every root");
|
|
224
|
+
});
|
|
225
|
+
});
|
|
226
|
+
|
|
227
|
+
describe("the features strip, folded to whole lines (behold#571)", () => {
|
|
228
|
+
// A drawn list just big enough: each item where it wrapped to, and the "+N more" button.
|
|
229
|
+
const item = (cls, top) => ({ hidden: false, offsetTop: top, classList: { contains: (c) => c === cls } });
|
|
230
|
+
const list = (items, moreTop) => {
|
|
231
|
+
const more = { hidden: false, offsetTop: moreTop, textContent: "", classList: { contains: () => false } };
|
|
232
|
+
return { more, items, el: { children: [...items, more], querySelector: () => more } };
|
|
233
|
+
};
|
|
234
|
+
|
|
235
|
+
it("hides what wraps past the last line it shows, never part of a line, and counts the features hidden", () => {
|
|
236
|
+
expect(FEATURE_LINES).toBe(3);
|
|
237
|
+
const items = [item("r-feats-product", 0), item("r-feat", 0), item("r-feat", 20), item("r-feat", 40), item("r-feats-product", 60), item("r-feat", 60), item("r-feat", 80)];
|
|
238
|
+
const l = list(items, 40);
|
|
239
|
+
fitFeatures(l.el, false);
|
|
240
|
+
expect(items.map((i) => i.hidden)).toEqual([false, false, false, false, true, true, true]);
|
|
241
|
+
expect(l.more).toMatchObject({ hidden: false, textContent: "+2 more" });
|
|
242
|
+
});
|
|
243
|
+
|
|
244
|
+
it("shows everything, and no button, when it fits; everything with the button when opened", () => {
|
|
245
|
+
const fits = list([item("r-feat", 0), item("r-feat", 20)], 20);
|
|
246
|
+
fitFeatures(fits.el, false);
|
|
247
|
+
expect(fits.items.every((i) => !i.hidden)).toBe(true);
|
|
248
|
+
expect(fits.more.hidden).toBe(true);
|
|
249
|
+
const open = list([item("r-feat", 0), item("r-feat", 20), item("r-feat", 40), item("r-feat", 60)], 60);
|
|
250
|
+
fitFeatures(open.el, true);
|
|
251
|
+
expect(open.items.every((i) => !i.hidden)).toBe(true);
|
|
252
|
+
expect(open.more.hidden).toBe(false);
|
|
253
|
+
});
|
|
254
|
+
|
|
255
|
+
it("never shows \"+0 more\": past the last line only a tool's name, so no button", () => {
|
|
256
|
+
const items = [item("r-feats-product", 0), item("r-feat", 0), item("r-feat", 20), item("r-feat", 40), item("r-feats-product", 60)];
|
|
257
|
+
const l = list(items, 40);
|
|
258
|
+
fitFeatures(l.el, false);
|
|
259
|
+
expect(items.map((i) => i.hidden)).toEqual([false, false, false, false, true]);
|
|
260
|
+
expect(l.more.hidden).toBe(true);
|
|
261
|
+
expect(l.more.textContent).not.toBe("+0 more");
|
|
262
|
+
});
|
|
263
|
+
|
|
264
|
+
it("shows \"+N more\" with N the features hidden, and no button once a wider list fits them all", () => {
|
|
265
|
+
const items = [item("r-feat", 0), item("r-feat", 20), item("r-feat", 40), item("r-feat", 60), item("r-feat", 60), item("r-feat", 80)];
|
|
266
|
+
const l = list(items, 40);
|
|
267
|
+
fitFeatures(l.el, false);
|
|
268
|
+
expect(l.more).toMatchObject({ hidden: false, textContent: "+3 more" });
|
|
269
|
+
// Measured again after the list widened: everything on three lines.
|
|
270
|
+
for (const [i, top] of [0, 0, 20, 20, 40, 40].entries()) items[i].offsetTop = top;
|
|
271
|
+
fitFeatures(l.el, false);
|
|
272
|
+
expect(items.every((i) => !i.hidden)).toBe(true);
|
|
273
|
+
expect(l.more.hidden).toBe(true);
|
|
274
|
+
});
|
|
275
|
+
|
|
276
|
+
it("opened, offers to fold back only when folding would hide a feature", () => {
|
|
277
|
+
const open = list([item("r-feat", 0), item("r-feat", 20), item("r-feat", 40), item("r-feats-product", 60)], 60);
|
|
278
|
+
fitFeatures(open.el, true);
|
|
279
|
+
expect(open.more.hidden).toBe(true);
|
|
280
|
+
});
|
|
281
|
+
|
|
282
|
+
it("drops a tool's name left last on a line with none of its features after it", () => {
|
|
283
|
+
const items = [item("r-feat", 0), item("r-feat", 20), item("r-feats-product", 40), item("r-feat", 60)];
|
|
284
|
+
const l = list(items, 40);
|
|
285
|
+
fitFeatures(l.el, false);
|
|
286
|
+
expect(items.map((i) => i.hidden)).toEqual([false, false, true, true]);
|
|
287
|
+
});
|
|
288
|
+
});
|
|
@@ -0,0 +1,397 @@
|
|
|
1
|
+
// behold#571: a kiosk page per tool, `/<tool>`, that the tool's maker can
|
|
2
|
+
// share without it promoting a competitor (COMPETES). It shows that tool's scenarios
|
|
3
|
+
// alone: its name (and its logo where its owner allows one), its runs only,
|
|
4
|
+
// and a footer saying who makes it and that they don't endorse the demo.
|
|
5
|
+
//
|
|
6
|
+
// Live when demo.json's `tool` is this tool, as /kiosk shows it. Otherwise a
|
|
7
|
+
// replay of the tool's newest cycle that ran to its reset
|
|
8
|
+
// (`cycles/<tool>/latest.json`, which the loop driver publishes), stepped at
|
|
9
|
+
// the pace it was recorded, until the tool's scenario starts again.
|
|
10
|
+
//
|
|
11
|
+
// Pure functions here; kiosk.js draws them.
|
|
12
|
+
|
|
13
|
+
/**
|
|
14
|
+
* Each tool with a page: its name as its owner writes it, its owner, its
|
|
15
|
+
* official site, its logo when its owner's published rules allow one (else
|
|
16
|
+
* null, the name as text), and the footer's credit. `terms` are the words
|
|
17
|
+
* that name the tool or its owner. `kind` is what it competes as (see
|
|
18
|
+
* COMPETES): an `engine` plans and applies, an `orchestrator` drives an
|
|
19
|
+
* engine. Keep the keys in step with VENDOR_TOOLS in src/terragucci-demo.ts.
|
|
20
|
+
*
|
|
21
|
+
* - Terraform: HashiCorp's rules allow the name as text, with the mark noted.
|
|
22
|
+
* - Terragrunt: Gruntwork publishes logo files, but a logo on a page like
|
|
23
|
+
* this reads as a partnership, so the name is text.
|
|
24
|
+
* - CDK Terrain: the Open Construct Foundation's continuation of CDKTF; its
|
|
25
|
+
* site, cdktn.io, publishes no rules for its logo, so the name is text.
|
|
26
|
+
* - choudoufu: ours, logo and all.
|
|
27
|
+
*/
|
|
28
|
+
export const VENDORS = {
|
|
29
|
+
terraform: {
|
|
30
|
+
name: "Terraform",
|
|
31
|
+
owner: "HashiCorp",
|
|
32
|
+
url: "https://developer.hashicorp.com/terraform",
|
|
33
|
+
logo: null,
|
|
34
|
+
credit: "Terraform is a trademark of HashiCorp. HashiCorp makes Terraform and does not endorse this demo.",
|
|
35
|
+
kind: "engine",
|
|
36
|
+
terms: ["Terraform", "HashiCorp"],
|
|
37
|
+
// The demo's Terraform roots run in OpenTofu's image (terragucci's stack/demo.sh).
|
|
38
|
+
runsWith: ["opentofu"],
|
|
39
|
+
},
|
|
40
|
+
terragrunt: {
|
|
41
|
+
name: "Terragrunt",
|
|
42
|
+
owner: "Gruntwork, Inc.",
|
|
43
|
+
url: "https://terragrunt.com/",
|
|
44
|
+
logo: null,
|
|
45
|
+
credit: "Terragrunt is made by Gruntwork, Inc., who don't endorse this demo.",
|
|
46
|
+
kind: "orchestrator",
|
|
47
|
+
terms: ["Terragrunt", "Gruntwork"],
|
|
48
|
+
runsWith: [],
|
|
49
|
+
},
|
|
50
|
+
"cdk-terrain": {
|
|
51
|
+
name: "CDK Terrain",
|
|
52
|
+
owner: "the Open Construct Foundation",
|
|
53
|
+
url: "https://cdktn.io/",
|
|
54
|
+
logo: null,
|
|
55
|
+
credit: "CDK Terrain is made by the Open Construct Foundation, who don't endorse this demo.",
|
|
56
|
+
kind: "orchestrator",
|
|
57
|
+
terms: ["CDK Terrain", "CDKTN", "Open Construct"],
|
|
58
|
+
runsWith: [],
|
|
59
|
+
},
|
|
60
|
+
choudoufu: {
|
|
61
|
+
name: "choudoufu",
|
|
62
|
+
owner: "Intentius",
|
|
63
|
+
url: "https://intentius.io/choudoufu/",
|
|
64
|
+
logo: { src: "/vendors/choudoufu-64.png", src2x: "/vendors/choudoufu-128.png" },
|
|
65
|
+
credit: "choudoufu is made by Intentius, who also make this demo. It supports AWS and Kubernetes.",
|
|
66
|
+
kind: "engine",
|
|
67
|
+
terms: ["choudoufu"],
|
|
68
|
+
runsWith: [],
|
|
69
|
+
},
|
|
70
|
+
};
|
|
71
|
+
|
|
72
|
+
/** What a tool's scenarios run on, said on its page as the owner's rules allow ("works with OpenTofu"). */
|
|
73
|
+
export const RUNS_WITH = {
|
|
74
|
+
opentofu: {
|
|
75
|
+
name: "OpenTofu",
|
|
76
|
+
url: "https://opentofu.org/",
|
|
77
|
+
words: "The pipeline here works with OpenTofu, which runs these roots.",
|
|
78
|
+
kind: "engine",
|
|
79
|
+
terms: ["OpenTofu"],
|
|
80
|
+
},
|
|
81
|
+
};
|
|
82
|
+
|
|
83
|
+
/**
|
|
84
|
+
* Tools with no page yet that a page still must not promote: orchestrators
|
|
85
|
+
* that compete with Terragrunt and CDK Terrain.
|
|
86
|
+
*/
|
|
87
|
+
export const COMPETITORS = {
|
|
88
|
+
atmos: { kind: "orchestrator", terms: ["Atmos", "Cloud Posse"] },
|
|
89
|
+
terramate: { kind: "orchestrator", terms: ["Terramate"] },
|
|
90
|
+
};
|
|
91
|
+
|
|
92
|
+
/**
|
|
93
|
+
* What competes with what. Engines compete with engines (Terraform, OpenTofu,
|
|
94
|
+
* choudoufu); orchestrators with orchestrators (Terragrunt, CDK Terrain,
|
|
95
|
+
* Atmos, Terramate). An orchestrator runs on an engine, so its page names the
|
|
96
|
+
* engine freely; an engine's page names no other engine, but for the one its
|
|
97
|
+
* scenarios really run with (`runsWith`).
|
|
98
|
+
*/
|
|
99
|
+
export const COMPETES = { engine: "engine", orchestrator: "orchestrator" };
|
|
100
|
+
|
|
101
|
+
const own = (o, k) => Object.prototype.hasOwnProperty.call(o, k);
|
|
102
|
+
|
|
103
|
+
/** A tool's entry, or null for a tool with no page. */
|
|
104
|
+
export function vendorOf(tool) {
|
|
105
|
+
return typeof tool === "string" && own(VENDORS, tool) ? VENDORS[tool] : null;
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
/** The tool the page was served for (`<meta name="kiosk-tool">`, src/terragucci-demo.ts), or null on /kiosk. */
|
|
109
|
+
export function pageTool(doc) {
|
|
110
|
+
const m = doc && doc.querySelector ? doc.querySelector('meta[name="kiosk-tool"]') : null;
|
|
111
|
+
const t = m ? m.getAttribute("content") : null;
|
|
112
|
+
return vendorOf(t) ? t : null;
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
/**
|
|
116
|
+
* The words no page for `tool` may show: each competitor's name and maker's,
|
|
117
|
+
* the competitors being the other tools of its kind, less what its scenarios
|
|
118
|
+
* run with. A page for no tool (/kiosk) has none.
|
|
119
|
+
*/
|
|
120
|
+
export function otherTerms(tool) {
|
|
121
|
+
const v = vendorOf(tool);
|
|
122
|
+
if (!v) return [];
|
|
123
|
+
const rivals = COMPETES[v.kind];
|
|
124
|
+
const out = [];
|
|
125
|
+
for (const [id, x] of [...Object.entries(VENDORS), ...Object.entries(RUNS_WITH), ...Object.entries(COMPETITORS)]) {
|
|
126
|
+
if (id === tool || x.kind !== rivals || v.runsWith.includes(id)) continue;
|
|
127
|
+
out.push(...x.terms);
|
|
128
|
+
}
|
|
129
|
+
return out;
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
const escapeRe = (s) => s.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
|
133
|
+
|
|
134
|
+
/**
|
|
135
|
+
* Whether `text` names a competitor of `tool` or its maker, as a whole word
|
|
136
|
+
* in any case. A word inside an identifier is no brand: `terraform_data` is a
|
|
137
|
+
* resource type and `demo-terraform-state` a bucket.
|
|
138
|
+
*/
|
|
139
|
+
export function namesOther(text, tool) {
|
|
140
|
+
if (typeof text !== "string" || !text) return false;
|
|
141
|
+
return otherTerms(tool).some((t) => new RegExp(`(?<![\\w./-])${escapeRe(t)}(?![\\w/-])`, "i").test(text));
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
/**
|
|
145
|
+
* demo.json as a tool's page shows it: no count of the loop's scenarios or
|
|
146
|
+
* place in it (they would hint at the others), and any text the driver wrote
|
|
147
|
+
* that names a competitor left out: the intro, a card row, the title (the
|
|
148
|
+
* scenario's name instead), the step's line, the next action.
|
|
149
|
+
*/
|
|
150
|
+
export function scrubDemo(demo, tool) {
|
|
151
|
+
if (!demo) return demo;
|
|
152
|
+
const keep = (t) => (namesOther(t, tool) ? null : t);
|
|
153
|
+
const about = demo.about
|
|
154
|
+
? Object.fromEntries(Object.entries(demo.about).filter(([, t]) => !namesOther(t, tool)))
|
|
155
|
+
: null;
|
|
156
|
+
const name = keep(demo.name);
|
|
157
|
+
return {
|
|
158
|
+
...demo,
|
|
159
|
+
scenarios: null,
|
|
160
|
+
index: null,
|
|
161
|
+
intro: keep(demo.intro),
|
|
162
|
+
name,
|
|
163
|
+
title: keep(demo.title) ?? name ?? "",
|
|
164
|
+
label: keep(demo.label) ?? "",
|
|
165
|
+
about: about && Object.keys(about).length ? about : null,
|
|
166
|
+
next: demo.next ? { ...demo.next, action: keep(demo.next.action) ?? "the next step" } : null,
|
|
167
|
+
};
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
/**
|
|
171
|
+
* The tool a demo.json write is about. A driver before behold#571 wrote no
|
|
172
|
+
* `tool`, and ran Terraform scenarios only: a scenario the kiosk knows
|
|
173
|
+
* (`known`, kiosk.js SCENARIOS) is Terraform's then.
|
|
174
|
+
*/
|
|
175
|
+
export function demoTool(demo, known = {}) {
|
|
176
|
+
if (!demo) return null;
|
|
177
|
+
if (typeof demo.tool === "string" && demo.tool) return demo.tool;
|
|
178
|
+
return own(known, demo.scenario) ? "terraform" : null;
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
/** Whether demo.json is live for this tool's page: its scenario is the tool's, and it is no base. */
|
|
182
|
+
export function liveFor(demo, tool, known = {}) {
|
|
183
|
+
return !!demo && demo.scenario !== "base" && demoTool(demo, known) === tool;
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
/**
|
|
187
|
+
* Whether a run belongs on the tool's page: a run of its repo. A run with no
|
|
188
|
+
* `repo` is matched by its `tool`; a run with neither comes from a driver
|
|
189
|
+
* before behold#571, which ran Terraform's repo alone.
|
|
190
|
+
*/
|
|
191
|
+
export function keepRun(run, { tool, repo }) {
|
|
192
|
+
if (!run) return false;
|
|
193
|
+
if (typeof run.repo === "string" && run.repo) return !!repo && run.repo === repo;
|
|
194
|
+
if (typeof run.tool === "string" && run.tool) return run.tool === tool;
|
|
195
|
+
return tool === "terraform";
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
/** The runs index with only the tool's runs; any other answer as it came. */
|
|
199
|
+
export function filterIndex(index, which) {
|
|
200
|
+
if (!index || !index.doc || !Array.isArray(index.doc.runs)) return index;
|
|
201
|
+
return { ...index, doc: { ...index.doc, runs: index.doc.runs.filter((r) => keepRun(r, which)) } };
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
/**
|
|
205
|
+
* Whether this behold serves the repo: the reports' project (`<host>/<path>`)
|
|
206
|
+
* ends with the repo's `<owner>/<name>`. The graph frame and the plan's facts
|
|
207
|
+
* are the served repo's, so a page for another repo shows neither.
|
|
208
|
+
*/
|
|
209
|
+
export function servesRepo(project, repo) {
|
|
210
|
+
if (typeof project !== "string" || typeof repo !== "string" || !project || !repo) return false;
|
|
211
|
+
const p = project.replace(/\.git$/, "").toLowerCase();
|
|
212
|
+
const r = repo.toLowerCase();
|
|
213
|
+
return p === r || p.endsWith(`/${r}`);
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
// ---------------------------------------------------------------------------
|
|
217
|
+
// The replay.
|
|
218
|
+
|
|
219
|
+
/** How long the replay holds its last step (the reset) before it starts again: the driver's hold. */
|
|
220
|
+
export const REPLAY_LAST_HOLD_MS = 20_000;
|
|
221
|
+
|
|
222
|
+
/** How long each step of a cycle was shown, in ms, from the `at` of the next one; the last one the hold. */
|
|
223
|
+
export function stepDurations(steps) {
|
|
224
|
+
return steps.map((s, i) => {
|
|
225
|
+
if (i === steps.length - 1) return REPLAY_LAST_HOLD_MS;
|
|
226
|
+
const d = Date.parse(steps[i + 1].at) - Date.parse(s.at);
|
|
227
|
+
return Number.isFinite(d) && d > 0 ? d : 0;
|
|
228
|
+
});
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
/**
|
|
232
|
+
* Where the replay is, `elapsed` ms after it started, looping: the step's
|
|
233
|
+
* index, and the time it stands for (the step's `at` plus the time into it),
|
|
234
|
+
* which the page reads as now while it replays. `{ index, at, loop }`.
|
|
235
|
+
*/
|
|
236
|
+
export function replayAt(steps, elapsed) {
|
|
237
|
+
const ds = stepDurations(steps);
|
|
238
|
+
const loop = ds.reduce((a, b) => a + b, 0);
|
|
239
|
+
let t = loop > 0 ? ((elapsed % loop) + loop) % loop : 0;
|
|
240
|
+
for (let i = 0; i < steps.length; i++) {
|
|
241
|
+
if (t < ds[i] || i === steps.length - 1) return { index: i, at: new Date(Date.parse(steps[i].at) + Math.min(t, ds[i])), loop };
|
|
242
|
+
t -= ds[i];
|
|
243
|
+
}
|
|
244
|
+
return { index: 0, at: new Date(Date.parse(steps[0].at)), loop };
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
const ms = (v) => (v ? Date.parse(v) : NaN);
|
|
248
|
+
|
|
249
|
+
/** A job's or step's status at time `t`: queued before it started, running before it ended, then as it ended. */
|
|
250
|
+
function statusAt(o, t) {
|
|
251
|
+
if (o.status === "skipped") return { status: "skipped", started_at: o.started_at ?? null, ended_at: o.ended_at ?? null };
|
|
252
|
+
const s = ms(o.started_at);
|
|
253
|
+
const e = ms(o.ended_at);
|
|
254
|
+
if (!Number.isNaN(s) && t < s) return { status: "queued", started_at: null, ended_at: null };
|
|
255
|
+
if (!Number.isNaN(e) && t < e) return { status: "running", started_at: o.started_at, ended_at: null };
|
|
256
|
+
return { status: o.status, started_at: o.started_at ?? null, ended_at: o.ended_at ?? null };
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
/**
|
|
260
|
+
* A run as it stood at time `t` (ms), from the copy made when it ended: each
|
|
261
|
+
* job and step queued before it started and running until it ended, a
|
|
262
|
+
* running step's log cut to the share of its time gone by.
|
|
263
|
+
*/
|
|
264
|
+
export function runAsOf(run, t) {
|
|
265
|
+
if (!run) return run;
|
|
266
|
+
const out = { ...run, ...statusAt(run, t) };
|
|
267
|
+
if (Array.isArray(run.jobs)) {
|
|
268
|
+
out.jobs = run.jobs.map((j) => {
|
|
269
|
+
const jo = { ...j, ...statusAt(j, t) };
|
|
270
|
+
if (Array.isArray(j.steps)) {
|
|
271
|
+
jo.steps = j.steps.map((st) => {
|
|
272
|
+
const so = { ...st, ...statusAt(st, t) };
|
|
273
|
+
const log = Array.isArray(st.log) ? st.log : [];
|
|
274
|
+
if (so.status === "queued") so.log = [];
|
|
275
|
+
else if (so.status === "running") {
|
|
276
|
+
const a = ms(st.started_at);
|
|
277
|
+
const b = ms(st.ended_at);
|
|
278
|
+
const share = b > a ? Math.min(1, Math.max(0, (t - a) / (b - a))) : 1;
|
|
279
|
+
so.log = log.slice(0, Math.ceil(log.length * share));
|
|
280
|
+
}
|
|
281
|
+
return so;
|
|
282
|
+
});
|
|
283
|
+
}
|
|
284
|
+
return jo;
|
|
285
|
+
});
|
|
286
|
+
}
|
|
287
|
+
return out;
|
|
288
|
+
}
|
|
289
|
+
|
|
290
|
+
/** The replay's runs index at time `t`: the cycle's runs that had been created by then, newest first, as they stood. */
|
|
291
|
+
export function replayIndex(runs, t) {
|
|
292
|
+
const shown = runs.filter((r) => {
|
|
293
|
+
const c = ms(r.created_at || r.started_at);
|
|
294
|
+
return Number.isNaN(c) || c <= t;
|
|
295
|
+
});
|
|
296
|
+
return { doc: { schema: "demo.runs/v1", runs: shown.sort((a, b) => b.id - a.id).map((r) => runAsOf(r, t)) } };
|
|
297
|
+
}
|
|
298
|
+
|
|
299
|
+
/** "14:05", a time's hours and minutes in UTC; null for no time. */
|
|
300
|
+
export function hhmm(iso) {
|
|
301
|
+
const t = Date.parse(iso || "");
|
|
302
|
+
if (Number.isNaN(t)) return null;
|
|
303
|
+
const d = new Date(t);
|
|
304
|
+
return `${String(d.getUTCHours()).padStart(2, "0")}:${String(d.getUTCMinutes()).padStart(2, "0")}`;
|
|
305
|
+
}
|
|
306
|
+
|
|
307
|
+
const counted = (n) => Number.isInteger(n) && n > 0;
|
|
308
|
+
|
|
309
|
+
/**
|
|
310
|
+
* About how many minutes until the tool's scenario is live again, from the
|
|
311
|
+
* loop's order and the length of the tool's latest cycle: what is left of
|
|
312
|
+
* the scenario live now, and a cycle's length for each scenario between it
|
|
313
|
+
* and the tool's. Null when the loop's order isn't known (no index, a base,
|
|
314
|
+
* or a loop of another length than the one the cycle ran in).
|
|
315
|
+
*/
|
|
316
|
+
export function liveEstimate(live, cycle, now = new Date()) {
|
|
317
|
+
if (!live || !cycle || !cycle.steps || !cycle.steps.length) return null;
|
|
318
|
+
const first = cycle.steps[0];
|
|
319
|
+
const n = live.scenarios;
|
|
320
|
+
const i = live.index;
|
|
321
|
+
const j = first.index;
|
|
322
|
+
if (!counted(n) || !counted(i) || !counted(j) || first.scenarios !== n || j > n || i > n) return null;
|
|
323
|
+
const len = ms(cycle.ended) - ms(cycle.started);
|
|
324
|
+
if (!(len > 0)) return null;
|
|
325
|
+
const into = now.getTime() - ms(live.started_at);
|
|
326
|
+
if (Number.isNaN(into)) return null;
|
|
327
|
+
const ahead = (j - i - 1 + n) % n;
|
|
328
|
+
const left = Math.max(0, len - into);
|
|
329
|
+
return Math.max(1, Math.round((left + ahead * len) / 60_000));
|
|
330
|
+
}
|
|
331
|
+
|
|
332
|
+
/** The replay's banner: "Replay of the run at 14:05 UTC · live again in about 6 min", without the estimate when there is none. */
|
|
333
|
+
export function replayBanner(cycle, minutes) {
|
|
334
|
+
const at = hhmm(cycle && (cycle.started || (cycle.steps && cycle.steps[0] && cycle.steps[0].at)));
|
|
335
|
+
const head = at ? `Replay of the run at ${at} UTC` : "Replay of the latest run";
|
|
336
|
+
return Number.isInteger(minutes) && minutes > 0 ? `${head} · live again in about ${minutes} min` : head;
|
|
337
|
+
}
|
|
338
|
+
|
|
339
|
+
/** The page's words when the tool's scenario has no cycle yet; with `graph`, they say the graph below is its repo on the base. */
|
|
340
|
+
export function notRunWords(vendor, { graph = false } = {}) {
|
|
341
|
+
const text = `The ${vendor.name} scenario shows here once it has run through the demo's pipeline.`;
|
|
342
|
+
return {
|
|
343
|
+
title: "This scenario hasn't run yet",
|
|
344
|
+
text: graph ? `${text} Below, its repo as it stands on the base.` : text,
|
|
345
|
+
};
|
|
346
|
+
}
|
|
347
|
+
|
|
348
|
+
/**
|
|
349
|
+
* The view checkout of a tool's repo, by the names terragucci's demo driver
|
|
350
|
+
* gives the repos: `demo` for Terraform, `demo-<tool>` for the others. Null
|
|
351
|
+
* for a tool with no page.
|
|
352
|
+
*/
|
|
353
|
+
export function toolCheckout(tool) {
|
|
354
|
+
if (!vendorOf(tool)) return null;
|
|
355
|
+
return tool === "terraform" ? "demo" : `demo-${tool}`;
|
|
356
|
+
}
|
|
357
|
+
|
|
358
|
+
/** behold#571: the checkout a tool's page shows before its first cycle names its repo: the tool's, when this behold serves it (`/api/project`'s viewRepos); null otherwise. */
|
|
359
|
+
export function notRunRepo(tool, served) {
|
|
360
|
+
const name = toolCheckout(tool);
|
|
361
|
+
return name && Array.isArray(served) && served.includes(name) ? name : null;
|
|
362
|
+
}
|
|
363
|
+
|
|
364
|
+
/** The legend's words, without naming a competing engine on an engine's page. */
|
|
365
|
+
export function rootWord(tool) {
|
|
366
|
+
return namesOther("Terraform", tool) ? "a root" : "a Terraform root";
|
|
367
|
+
}
|
|
368
|
+
|
|
369
|
+
/**
|
|
370
|
+
* A line of text with names in it made links: `[{ text }, { text, url }, …]`,
|
|
371
|
+
* the first time each name appears. Every name a page shows links to its
|
|
372
|
+
* official site.
|
|
373
|
+
*/
|
|
374
|
+
export function linkSegments(text, links) {
|
|
375
|
+
let out = [{ text }];
|
|
376
|
+
for (const l of links) {
|
|
377
|
+
const i = out.findIndex((x) => !x.url && x.text.includes(l.name));
|
|
378
|
+
if (i < 0) continue;
|
|
379
|
+
const x = out[i].text;
|
|
380
|
+
const at = x.indexOf(l.name);
|
|
381
|
+
const parts = [{ text: x.slice(0, at) }, { text: l.name, url: l.url }, { text: x.slice(at + l.name.length) }].filter((p) => p.text);
|
|
382
|
+
out = [...out.slice(0, i), ...parts, ...out.slice(i + 1)];
|
|
383
|
+
}
|
|
384
|
+
return out;
|
|
385
|
+
}
|
|
386
|
+
|
|
387
|
+
/** The footer's lines on a tool's page: the credit, and what its scenarios run with, each with its names linked. */
|
|
388
|
+
export function creditLines(tool) {
|
|
389
|
+
const v = vendorOf(tool);
|
|
390
|
+
if (!v) return [];
|
|
391
|
+
const lines = [linkSegments(v.credit, [{ name: v.name, url: v.url }])];
|
|
392
|
+
for (const id of v.runsWith) {
|
|
393
|
+
const r = RUNS_WITH[id];
|
|
394
|
+
if (r) lines.push(linkSegments(r.words, [{ name: r.name, url: r.url }]));
|
|
395
|
+
}
|
|
396
|
+
return lines;
|
|
397
|
+
}
|