@intentius/behold 0.28.0 → 0.29.1
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 +27 -0
- package/dist/cli.js +570 -262
- package/package.json +1 -1
- package/web/app.js +141 -19
- package/web/kiosk.html +67 -0
- package/web/kiosk.js +385 -0
- package/web/operator.js +1 -0
- package/web/run-playhead.js +1 -0
- package/web/terragucci-change.js +423 -0
|
@@ -0,0 +1,423 @@
|
|
|
1
|
+
// #568 step 1: a change across several roots of one terragucci repo, in the
|
|
2
|
+
// card view. app.js has /api/terragucci (its `pull_request`: the newest
|
|
3
|
+
// tf-plan of a pull request) and /api/terragucci/progress (its `states` and
|
|
4
|
+
// `reads`: the run view of the newest apply), and draws what these return.
|
|
5
|
+
//
|
|
6
|
+
// Two stages. Before the merge there is no run view, so the pull request's
|
|
7
|
+
// plan is the picture: which root read which root's planned outputs, what it
|
|
8
|
+
// read before it was known, which wave plans again after which, the blast
|
|
9
|
+
// radius, the totals and the cost. From the merge on, the run view's waves
|
|
10
|
+
// carry the state, for any binary. Each line is dated by the run it came
|
|
11
|
+
// from; none of it is live.
|
|
12
|
+
//
|
|
13
|
+
// A wave that failed says so, and the waves before it that applied stay
|
|
14
|
+
// applied: nothing here is all-or-nothing.
|
|
15
|
+
//
|
|
16
|
+
// #568 step 3: a composed run (INTENTIUS/terragucci#1013, `waves.compose`):
|
|
17
|
+
// one wave covering several roots, each root's changes under its own name,
|
|
18
|
+
// the outputs read before they were known and the approval they hold the run
|
|
19
|
+
// for, and on a failure which roots applied, which didn't, and each state as
|
|
20
|
+
// written. What applied stays applied; nothing is called atomic. A report or
|
|
21
|
+
// run view without the composed fields draws as before.
|
|
22
|
+
|
|
23
|
+
import { ago, clock, hrefOf } from "./terragucci.js";
|
|
24
|
+
|
|
25
|
+
export const STATE_WORDS = {
|
|
26
|
+
"not-started": "not started",
|
|
27
|
+
planned: "planned",
|
|
28
|
+
waiting: "waiting for an approval",
|
|
29
|
+
applying: "applying",
|
|
30
|
+
applied: "applied",
|
|
31
|
+
refused: "refused",
|
|
32
|
+
failed: "failed",
|
|
33
|
+
};
|
|
34
|
+
|
|
35
|
+
const short = (digest) => (digest ? digest.replace(/^jcs1-sha256:/, "").slice(0, 12) : "");
|
|
36
|
+
const list = (xs) => xs.join(", ");
|
|
37
|
+
|
|
38
|
+
/**
|
|
39
|
+
* Which stage the change is in: `apply` once the run view is newer than the
|
|
40
|
+
* pull request's plan, `pull-request` before that, `none` with neither.
|
|
41
|
+
*/
|
|
42
|
+
export function changeStage(answer, progress) {
|
|
43
|
+
const pr = answer && !answer.error ? answer.pull_request : null;
|
|
44
|
+
const run = progress && !progress.error && progress.run && progress.run.present && progress.states && progress.states.length ? progress.run : null;
|
|
45
|
+
if (run && (!pr || (run.updated || "") >= pr.run.finished)) return "apply";
|
|
46
|
+
if (pr) return "pull-request";
|
|
47
|
+
return "none";
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/**
|
|
51
|
+
* The reads edges between roots: from the pull request's plan (`planned` or
|
|
52
|
+
* `applied`, with the outputs read before they were known), or from the run
|
|
53
|
+
* view once the change merged (its state as it stands then).
|
|
54
|
+
*/
|
|
55
|
+
export function readEdges(answer, progress) {
|
|
56
|
+
const stage = changeStage(answer, progress);
|
|
57
|
+
if (stage === "pull-request") {
|
|
58
|
+
const run = answer.pull_request.compose && answer.pull_request.compose.wave;
|
|
59
|
+
return answer.pull_request.reads.map((r) => ({
|
|
60
|
+
from: r.root,
|
|
61
|
+
to: r.upstream,
|
|
62
|
+
data: r.data,
|
|
63
|
+
outputs: r.outputs,
|
|
64
|
+
unknown: r.unknown || [],
|
|
65
|
+
...(r.why ? { why: r.why } : {}),
|
|
66
|
+
...(r.composed && run !== undefined ? { composed: run } : {}),
|
|
67
|
+
stage,
|
|
68
|
+
}));
|
|
69
|
+
}
|
|
70
|
+
if (stage === "apply") {
|
|
71
|
+
const runOf = new Map(progress.reads.filter((r) => r.composed).map((r) => [r.root, r.composed.wave]));
|
|
72
|
+
return progress.reads.flatMap((r) =>
|
|
73
|
+
r.reads.map((up) => ({
|
|
74
|
+
from: r.root,
|
|
75
|
+
to: up,
|
|
76
|
+
data: null,
|
|
77
|
+
outputs: "applied",
|
|
78
|
+
unknown: [],
|
|
79
|
+
stage,
|
|
80
|
+
wave: r.wave,
|
|
81
|
+
// #568 step 3: both ends in one composed run: the read is inside it.
|
|
82
|
+
...(r.composed && runOf.get(up) === r.composed.wave ? { composed: r.composed.wave } : {}),
|
|
83
|
+
})),
|
|
84
|
+
);
|
|
85
|
+
}
|
|
86
|
+
return [];
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
/** One reads edge as a sentence: "app reads network's planned outputs (data network), known after network applies: subnet_id". */
|
|
90
|
+
export function readLine(e) {
|
|
91
|
+
const via = e.data ? ` (data ${e.data})` : "";
|
|
92
|
+
if (e.composed !== undefined) {
|
|
93
|
+
// #568 step 3: a read inside a composed run: the run applies the reader after the root it reads.
|
|
94
|
+
const unknown = e.unknown.length ? `, read before known, known once ${e.to}'s part of the run applies: ${list(e.unknown)}` : "";
|
|
95
|
+
return `${e.from} reads ${e.to}${via}, in wave ${e.composed}'s composed run${unknown}`;
|
|
96
|
+
}
|
|
97
|
+
const what = e.outputs === "planned" ? `${e.to}'s planned outputs` : `${e.to}'s state as it stands`;
|
|
98
|
+
const unknown = e.unknown.length ? `, read before known, known after ${e.to} applies: ${list(e.unknown)}` : "";
|
|
99
|
+
const why = e.why ? ` (${e.why})` : "";
|
|
100
|
+
return `${e.from} reads ${what}${via}${unknown}${why}`;
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
/** The short tag on a reader's data card: "reads network · planned · 1 unknown". */
|
|
104
|
+
export function readTag(e) {
|
|
105
|
+
return `reads ${e.to} · ${e.composed !== undefined ? "in the composed run" : e.outputs}${e.unknown.length ? ` · ${e.unknown.length} unknown` : ""}`;
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
/** The checkout's card id for a reader's data block, for each kind of block that reads another root. */
|
|
109
|
+
export function readCardIds(answer, e) {
|
|
110
|
+
if (!e.data || !answer || !answer.roots) return [];
|
|
111
|
+
const r = answer.roots.find((x) => x.path === e.from);
|
|
112
|
+
if (!r) return [];
|
|
113
|
+
return [`${r.member}/${r.name}/data.terraform_remote_state.${e.data}`, `${r.member}/${r.name}/data.terraform_estate_outputs.${e.data}`];
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
/** "5 create · 1 update", no-ops and zeros left out; "no changes" when nothing moves. */
|
|
117
|
+
export function totalsLine(totals) {
|
|
118
|
+
if (!totals) return null;
|
|
119
|
+
const order = ["create", "update", "replace", "delete", "forget", "import", "read"];
|
|
120
|
+
const keys = [...order.filter((k) => k in totals), ...Object.keys(totals).filter((k) => !order.includes(k) && k !== "no-op").sort()];
|
|
121
|
+
const parts = keys.filter((k) => totals[k] > 0).map((k) => `${totals[k]} ${k}`);
|
|
122
|
+
return parts.length ? parts.join(" · ") : "no changes";
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
/** #568 step 2: "across 3 roots", from `totals_roots` (report minor 35); null on an older report, which has no count. */
|
|
126
|
+
export function rootsCount(n) {
|
|
127
|
+
if (n === undefined || n === null) return null;
|
|
128
|
+
return `across ${n} ${n === 1 ? "root" : "roots"}`;
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
const money = (n) => (n === null || n === undefined ? "no figure" : n.toFixed(2));
|
|
132
|
+
const signed = (n) => (n === null || n === undefined ? "no figure" : `${n >= 0 ? "+" : "-"}${Math.abs(n).toFixed(2)}`);
|
|
133
|
+
|
|
134
|
+
/** "cost: +10.50 USD a month, from 26.00 to 36.50 (estimated by node)". A wave's cost has no estimator. */
|
|
135
|
+
export function costLine(cost) {
|
|
136
|
+
if (!cost) return null;
|
|
137
|
+
const by = cost.estimator ? ` (estimated by ${cost.estimator})` : "";
|
|
138
|
+
const over = cost.approve_above !== undefined ? ` · approval above ${money(cost.approve_above)}${cost.over ? ", over it" : ""}` : "";
|
|
139
|
+
const missing = cost.unestimated && cost.unestimated.length ? ` · no estimate for ${list(cost.unestimated)}` : "";
|
|
140
|
+
const failed = (cost.roots || []).filter((r) => r.error).map((r) => r.root);
|
|
141
|
+
const rootsMissing = failed.length ? ` · no estimate for ${list(failed)}` : "";
|
|
142
|
+
if (cost.monthly_delta === null) return `cost: no estimate${by}${missing}${rootsMissing}`;
|
|
143
|
+
return `cost: ${signed(cost.monthly_delta)} ${cost.currency} a month, from ${money(cost.past_monthly_total)} to ${money(cost.monthly_total)}${by}${over}${missing}${rootsMissing}`;
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
const reachText = (x) => `${x.root}: ${x.address}${x.through ? ` through ${x.through.root} output ${x.through.output}` : ""}`;
|
|
147
|
+
|
|
148
|
+
/**
|
|
149
|
+
* #568 step 2: a changed resource's reaches, grouped by distance (`depth`,
|
|
150
|
+
* report minor 36), nearest first: "at 1: app: a; at 2: app: b, edge: c". A
|
|
151
|
+
* report before minor 36 has no depth: its reaches stay in their order,
|
|
152
|
+
* nearest first, with no count, as terragucci wrote them.
|
|
153
|
+
*/
|
|
154
|
+
export function reachGroups(reaches) {
|
|
155
|
+
if (!reaches.length || !reaches.every((x) => typeof x.depth === "number")) return [{ depth: null, reaches: reaches.map(reachText) }];
|
|
156
|
+
const by = new Map();
|
|
157
|
+
for (const x of reaches) {
|
|
158
|
+
if (!by.has(x.depth)) by.set(x.depth, []);
|
|
159
|
+
by.get(x.depth).push(reachText(x));
|
|
160
|
+
}
|
|
161
|
+
return [...by.keys()].sort((a, b) => a - b).map((depth) => ({ depth, reaches: by.get(depth) }));
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
/** The reach groups as one line: "at distance 1: …; at distance 2: …", or the plain list when there is no depth. */
|
|
165
|
+
export function reachLine(groups) {
|
|
166
|
+
if (!groups.length || (groups.length === 1 && !groups[0].reaches.length)) return "nothing";
|
|
167
|
+
if (groups[0].depth === null) return `nearest first: ${list(groups[0].reaches)}`;
|
|
168
|
+
return groups.map((g) => `at distance ${g.depth}: ${list(g.reaches)}`).join("; ");
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
/**
|
|
172
|
+
* The blast radius, nearest first, as terragucci recorded it: each changed
|
|
173
|
+
* resource and the resources it reaches, by distance from it across roots
|
|
174
|
+
* (an older report without depth: in their order, without a count); then the
|
|
175
|
+
* roots downstream, and the checkout's roots the pull request did not plan.
|
|
176
|
+
*/
|
|
177
|
+
export function blastRows(answer) {
|
|
178
|
+
const pr = answer && answer.pull_request;
|
|
179
|
+
if (!pr) return null;
|
|
180
|
+
const b = pr.blast;
|
|
181
|
+
const planned = new Set(pr.roots);
|
|
182
|
+
const unplanned = (answer.roots || []).map((r) => r.path).filter((p) => !planned.has(p));
|
|
183
|
+
return {
|
|
184
|
+
changed: b ? b.roots : [],
|
|
185
|
+
resources: b && b.resources
|
|
186
|
+
? b.resources.map((r) => ({
|
|
187
|
+
head: `${r.root}: ${r.address} (${r.actions.join(", ")})`,
|
|
188
|
+
reaches: reachGroups(r.reaches),
|
|
189
|
+
}))
|
|
190
|
+
: [],
|
|
191
|
+
downstream: b ? b.downstream.map((d) => `${d.root}${d.wave !== undefined ? ` (wave ${d.wave})` : ""}${d.planned ? "" : ", not planned"}`) : [],
|
|
192
|
+
unplanned,
|
|
193
|
+
};
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
/** "network, app and edge". */
|
|
197
|
+
const and = (xs) => (xs.length < 2 ? xs.join("") : `${xs.slice(0, -1).join(", ")} and ${xs[xs.length - 1]}`);
|
|
198
|
+
|
|
199
|
+
/**
|
|
200
|
+
* #568 step 3: the pull request's composed run, or why its change applies in
|
|
201
|
+
* waves (`compose`, report minor 38). Null when `waves.compose` is off.
|
|
202
|
+
*/
|
|
203
|
+
export function composeLine(pr) {
|
|
204
|
+
const c = pr && pr.compose;
|
|
205
|
+
if (!c) return null;
|
|
206
|
+
if (c.wave !== undefined) {
|
|
207
|
+
return `one composed run, in wave ${c.wave} (waves.compose): ${and(c.roots)} plan as one plan and apply as one apply. Each root keeps its own state: the run locks each state, applies, and writes each state back with what applied to it. If the apply fails part way, what applied stays applied.`;
|
|
208
|
+
}
|
|
209
|
+
if (c.fallback) return `applies in waves (waves.compose): ${c.fallback.reason}`;
|
|
210
|
+
return null;
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
const APPLY_WORDS = { applied: "applied", partial: "applied in part", failed: "failed", "not-started": "not started", "no-changes": "no changes" };
|
|
214
|
+
const STATE_AS = { written: "state written", unchanged: "state as it was, nothing to write", "not-written": "state not written" };
|
|
215
|
+
|
|
216
|
+
/** One member of a composed run as planned: "app as module.app: 1 create". */
|
|
217
|
+
export function memberPlanLine(m) {
|
|
218
|
+
const counts = m.counts ? totalsLine(m.counts) : null;
|
|
219
|
+
return `${m.root} as ${m.module}${counts ? `: ${counts}` : ""}`;
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
/**
|
|
223
|
+
* One member once the run ended: "network: applied, 1 of 1; state written".
|
|
224
|
+
* A state the run could not write says where it was kept and that its lock is
|
|
225
|
+
* still held.
|
|
226
|
+
*/
|
|
227
|
+
export function memberApplyLine(m) {
|
|
228
|
+
const parts = [];
|
|
229
|
+
if (m.apply) parts.push(`${APPLY_WORDS[m.apply] || m.apply}${m.changes ? `, ${m.changes.done} of ${m.changes.planned}` : ""}`);
|
|
230
|
+
if (m.state) {
|
|
231
|
+
let st = STATE_AS[m.state] || `state ${m.state}`;
|
|
232
|
+
if (m.state === "not-written") st += `${m.kept ? `, kept at ${m.kept}` : ""}${m.lock_held ? ", its lock still held" : ""}`;
|
|
233
|
+
parts.push(st);
|
|
234
|
+
}
|
|
235
|
+
if (m.error) parts.push(m.error);
|
|
236
|
+
return `${m.root}${parts.length ? `: ${parts.join("; ")}` : ` as ${m.module}`}`;
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
/**
|
|
240
|
+
* The members that read a value of another member unknown at plan time; with
|
|
241
|
+
* `waits`, as why the run waits for an approval ("waits for an approval, since
|
|
242
|
+
* app reads a value of another root in the run unknown at plan time").
|
|
243
|
+
*/
|
|
244
|
+
export function unknownFact(who, waits) {
|
|
245
|
+
const what = `${and(who)} ${who.length === 1 ? "reads a value" : "read values"} of another root in the run unknown at plan time`;
|
|
246
|
+
return waits ? `waits for an approval, since ${what}` : what;
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
/**
|
|
250
|
+
* The waves, by stage. In a pull request: each planned wave, the one that
|
|
251
|
+
* plans again after an earlier wave applies (with no digest until then), and
|
|
252
|
+
* its cost. From the merge on: each wave's state in the run view, the waiting
|
|
253
|
+
* wave's digest and approve line, and on a failed or refused wave, the waves
|
|
254
|
+
* before it that applied and stay applied.
|
|
255
|
+
*/
|
|
256
|
+
export function waveRows(answer, progress, now = new Date()) {
|
|
257
|
+
const stage = changeStage(answer, progress);
|
|
258
|
+
if (stage === "pull-request") {
|
|
259
|
+
return answer.pull_request.waves.map((w) => {
|
|
260
|
+
const facts = [];
|
|
261
|
+
const c = w.composed;
|
|
262
|
+
if (c) {
|
|
263
|
+
// #568 step 3: each root's changes under its own name, and the run's one digest.
|
|
264
|
+
facts.push(`composed run, each root under its own name: ${c.members.map(memberPlanLine).join("; ")}`);
|
|
265
|
+
if (c.plan_digest === null) facts.push("the composed plan failed");
|
|
266
|
+
if (c.error) facts.push(`the composed run: ${c.error}`);
|
|
267
|
+
}
|
|
268
|
+
if (w.replans_after && w.replans_after.length) facts.push(`plans again after wave ${list(w.replans_after)} applies, no digest until then`);
|
|
269
|
+
else if (w.review_digest) facts.push(`${c ? "one digest for the wave and its composed run, " : ""}review digest ${short(w.review_digest)}`);
|
|
270
|
+
else if (w.set_digest) facts.push(`plan ${short(w.set_digest)}`);
|
|
271
|
+
if (c && c.held_by_unknown && c.held_by_unknown.length) facts.push(unknownFact(c.held_by_unknown, !!w.waits));
|
|
272
|
+
else if (w.waits) facts.push("will wait for an approval");
|
|
273
|
+
if (w.reads && w.reads.length) facts.push(`after wave ${list(w.reads)}`);
|
|
274
|
+
const cost = costLine(w.cost);
|
|
275
|
+
if (cost) facts.push(cost);
|
|
276
|
+
return { number: w.number, state: w.state || "planned", tone: (w.replans_after && w.replans_after.length) || (c && c.held_by_unknown && c.held_by_unknown.length) ? "wave" : "plan", text: `wave ${w.number} ${STATE_WORDS[w.state || "planned"]}: ${list(w.roots)}${c ? ", composed" : ""}`, facts, ...(c ? { composed: true } : {}) };
|
|
277
|
+
});
|
|
278
|
+
}
|
|
279
|
+
if (stage === "apply") {
|
|
280
|
+
const states = progress.states;
|
|
281
|
+
return states.map((w) => {
|
|
282
|
+
const facts = [];
|
|
283
|
+
const c = w.composed;
|
|
284
|
+
// #568 step 3: roots that would be in this wave on their own, applied by an earlier wave's composed run.
|
|
285
|
+
const moved = progress.reads.filter((r) => r.composed && r.composed.own_wave === w.number && r.composed.wave !== w.number);
|
|
286
|
+
if (c) {
|
|
287
|
+
const modules = new Map(progress.reads.filter((r) => r.composed).map((r) => [r.root, r.composed.module]));
|
|
288
|
+
facts.push(`composed run: ${c.roots.map((r) => (modules.has(r) ? `${r} as ${modules.get(r)}` : r)).join(", ")}; one plan, one apply, each root keeps its own state`);
|
|
289
|
+
if (c.unknown && c.unknown.length) facts.push(unknownFact(c.unknown, w.state === "waiting"));
|
|
290
|
+
}
|
|
291
|
+
if (moved.length) {
|
|
292
|
+
const by = [...new Set(moved.map((r) => r.composed.wave))];
|
|
293
|
+
facts.push(`${and(moved.map((r) => r.root))} ${moved.length === 1 ? "applies" : "apply"} in wave ${list(by)}'s composed run`);
|
|
294
|
+
}
|
|
295
|
+
if (w.reads.length) facts.push(`after wave ${list(w.reads)}`);
|
|
296
|
+
if (w.digest) facts.push(`${c ? "one digest for the wave and its composed run, " : ""}plan ${short(w.digest)}`);
|
|
297
|
+
// #568 step 2: the wave's own totals, once it planned (a wave not started has none).
|
|
298
|
+
const totals = totalsLine(w.totals);
|
|
299
|
+
if (totals) facts.push(`changes: ${totals}${rootsCount(w.totals_roots) ? ` ${rootsCount(w.totals_roots)}` : ""}`);
|
|
300
|
+
if (c && c.members) {
|
|
301
|
+
// Once the apply ended: which roots applied, which didn't, and each state as written.
|
|
302
|
+
if (w.state === "failed") facts.push("the composed run failed part way: what applied stays applied. Each root's part and its state as written:");
|
|
303
|
+
for (const m of c.members) facts.push(memberApplyLine(m));
|
|
304
|
+
}
|
|
305
|
+
if (w.state === "failed" || w.state === "refused") {
|
|
306
|
+
const before = states.filter((x) => x.number < w.number && x.state === "applied").map((x) => x.number);
|
|
307
|
+
facts.push(before.length ? `wave ${list(before)} applied before it and stays applied` : "no wave before it applied");
|
|
308
|
+
}
|
|
309
|
+
const roots = !w.roots.length && moved.length ? "no roots of its own" : list(w.roots);
|
|
310
|
+
return {
|
|
311
|
+
number: w.number,
|
|
312
|
+
state: w.state,
|
|
313
|
+
tone: w.state === "failed" || w.state === "refused" ? "failed" : w.state === "waiting" || w.state === "applying" ? "wave" : w.state === "applied" ? "ok" : "none",
|
|
314
|
+
text: `wave ${w.number} ${STATE_WORDS[w.state] || w.state}: ${roots}${c ? ", composed" : ""}`,
|
|
315
|
+
...(c ? { composed: true } : {}),
|
|
316
|
+
facts,
|
|
317
|
+
...(w.command ? { command: w.command } : {}),
|
|
318
|
+
...(w.report ? { report: hrefOf(progress, w.report) } : {}),
|
|
319
|
+
};
|
|
320
|
+
});
|
|
321
|
+
}
|
|
322
|
+
return [];
|
|
323
|
+
}
|
|
324
|
+
|
|
325
|
+
/** The section's first line: which stage, which run, how old. Never "live". */
|
|
326
|
+
export function changeHead(answer, progress, now = new Date()) {
|
|
327
|
+
const stage = changeStage(answer, progress);
|
|
328
|
+
if (stage === "pull-request") {
|
|
329
|
+
const r = answer.pull_request.run;
|
|
330
|
+
return { text: `pull request #${r.pull_request}, planned ${clock(r.finished, now)}, ${ago(r.finished, now)}; not merged yet, so no run view`, href: hrefOf(answer, r.report), pr: r.pull_request_url || null };
|
|
331
|
+
}
|
|
332
|
+
if (stage === "apply") {
|
|
333
|
+
const r = progress.run;
|
|
334
|
+
return { text: `merged: commit ${r.commit.slice(0, 12)}, run view written ${clock(r.updated, now)}, ${ago(r.updated, now)}`, href: r.html ? hrefOf(progress, r.html) : null, pr: null };
|
|
335
|
+
}
|
|
336
|
+
return null;
|
|
337
|
+
}
|
|
338
|
+
|
|
339
|
+
/** The section in the Terragucci tab. `ui` is app.js's `{link, button, copy}`. */
|
|
340
|
+
export function changeSection(answer, progress, now, ui) {
|
|
341
|
+
const box = document.createElement("div");
|
|
342
|
+
box.className = "tg-change";
|
|
343
|
+
const head = changeHead(answer, progress, now);
|
|
344
|
+
if (!head) return box;
|
|
345
|
+
const add = (cls, text) => {
|
|
346
|
+
const d = document.createElement("div");
|
|
347
|
+
d.className = cls;
|
|
348
|
+
d.textContent = text;
|
|
349
|
+
box.appendChild(d);
|
|
350
|
+
return d;
|
|
351
|
+
};
|
|
352
|
+
add("tg-timeline-title", "Change across roots");
|
|
353
|
+
const h = add("tg-legend", head.text + " ");
|
|
354
|
+
if (head.href) h.appendChild(ui.link("report", head.href));
|
|
355
|
+
if (head.pr) h.append(" · ", ui.link("pull request", head.pr));
|
|
356
|
+
|
|
357
|
+
// #568 step 3: the composed run, or why the change applies in waves, before its reads.
|
|
358
|
+
if (changeStage(answer, progress) === "pull-request") {
|
|
359
|
+
const compose = composeLine(answer.pull_request);
|
|
360
|
+
if (compose) add("tg-legend", compose);
|
|
361
|
+
}
|
|
362
|
+
for (const e of readEdges(answer, progress)) add(e.unknown.length ? "tg-wave" : "tg-plan", readLine(e));
|
|
363
|
+
|
|
364
|
+
for (const w of waveRows(answer, progress, now)) {
|
|
365
|
+
const card = document.createElement("div");
|
|
366
|
+
card.className = "ws-gate tg-change-wave";
|
|
367
|
+
card.dataset.state = w.state;
|
|
368
|
+
if (w.composed) card.dataset.composed = "true";
|
|
369
|
+
const t = document.createElement("div");
|
|
370
|
+
t.className = `ws-gate-title tg-${w.tone}`;
|
|
371
|
+
t.textContent = w.text;
|
|
372
|
+
card.appendChild(t);
|
|
373
|
+
for (const f of w.facts) {
|
|
374
|
+
const n = document.createElement("div");
|
|
375
|
+
n.className = "ws-gate-note";
|
|
376
|
+
n.textContent = f;
|
|
377
|
+
card.appendChild(n);
|
|
378
|
+
}
|
|
379
|
+
if (w.command) {
|
|
380
|
+
const cmd = document.createElement("code");
|
|
381
|
+
cmd.className = "tg-command";
|
|
382
|
+
cmd.textContent = w.command;
|
|
383
|
+
card.appendChild(cmd);
|
|
384
|
+
if (ui.button && ui.copy) {
|
|
385
|
+
const copy = ui.button("Copy", "", () => ui.copy(w.command, copy));
|
|
386
|
+
copy.title = "Copy the line. Run it at your shell: the approval is yours, signed, in git.";
|
|
387
|
+
card.appendChild(copy);
|
|
388
|
+
}
|
|
389
|
+
}
|
|
390
|
+
if (w.report) {
|
|
391
|
+
const n = document.createElement("div");
|
|
392
|
+
n.className = "ws-gate-note";
|
|
393
|
+
n.appendChild(ui.link("report", w.report));
|
|
394
|
+
card.appendChild(n);
|
|
395
|
+
}
|
|
396
|
+
box.appendChild(card);
|
|
397
|
+
}
|
|
398
|
+
|
|
399
|
+
const pr = answer && answer.pull_request;
|
|
400
|
+
if (pr) {
|
|
401
|
+
const totals = totalsLine(pr.totals);
|
|
402
|
+
if (totals) add("tg-legend", `totals ${rootsCount(pr.totals_roots) || "across roots"} (pull request #${pr.run.pull_request}): ${totals}`);
|
|
403
|
+
const cost = costLine(pr.cost);
|
|
404
|
+
if (cost) add("tg-legend", cost);
|
|
405
|
+
const b = blastRows(answer);
|
|
406
|
+
if (b && (b.changed.length || b.resources.length)) {
|
|
407
|
+
const d = document.createElement("details");
|
|
408
|
+
d.className = "tg-blast";
|
|
409
|
+
const s = document.createElement("summary");
|
|
410
|
+
s.textContent = `blast radius: ${b.changed.length ? `changes in ${list(b.changed)}` : "no root changes"}${b.downstream.length ? `; downstream, nearest first: ${list(b.downstream)}` : ""}`;
|
|
411
|
+
d.appendChild(s);
|
|
412
|
+
for (const r of b.resources) {
|
|
413
|
+
const n = document.createElement("div");
|
|
414
|
+
n.className = "ws-gate-note";
|
|
415
|
+
n.textContent = `${r.head} reaches ${reachLine(r.reaches)}`;
|
|
416
|
+
d.appendChild(n);
|
|
417
|
+
}
|
|
418
|
+
box.appendChild(d);
|
|
419
|
+
}
|
|
420
|
+
if (b && b.unplanned.length) add("tg-legend", `not planned by this pull request: ${list(b.unplanned)}`);
|
|
421
|
+
}
|
|
422
|
+
return box;
|
|
423
|
+
}
|