lepus-saga 0.3.0

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.
Files changed (84) hide show
  1. checksums.yaml +7 -0
  2. data/CHANGELOG.md +28 -0
  3. data/Gemfile +5 -0
  4. data/Gemfile.lock +237 -0
  5. data/LICENSE.txt +21 -0
  6. data/README.md +337 -0
  7. data/Rakefile +8 -0
  8. data/docs/brand/BRAND.md +67 -0
  9. data/docs/brand/apple-touch-icon.png +0 -0
  10. data/docs/brand/favicon-16.png +0 -0
  11. data/docs/brand/favicon-32.png +0 -0
  12. data/docs/brand/favicon.ico +0 -0
  13. data/docs/brand/favicon.svg +1 -0
  14. data/docs/brand/icon-512.png +0 -0
  15. data/docs/brand/lockup-lepus-mono.svg +1 -0
  16. data/docs/brand/lockup-lepus-on-dark.svg +1 -0
  17. data/docs/brand/lockup-lepus-on-light.svg +1 -0
  18. data/docs/brand/lockup-lepus-saga-mono.svg +1 -0
  19. data/docs/brand/lockup-lepus-saga-on-dark.svg +1 -0
  20. data/docs/brand/lockup-lepus-saga-on-light.svg +1 -0
  21. data/docs/brand/lockup-lepus-saga.svg +1 -0
  22. data/docs/brand/lockup-lepus.svg +1 -0
  23. data/docs/brand/mark-mono.svg +1 -0
  24. data/docs/brand/mark-on-dark.svg +1 -0
  25. data/docs/brand/mark-on-light.svg +1 -0
  26. data/docs/brand/mark-small-mono.svg +1 -0
  27. data/docs/brand/mark-small-on-dark.svg +1 -0
  28. data/docs/brand/mark-small-on-light.svg +1 -0
  29. data/docs/brand/mark-small.svg +1 -0
  30. data/docs/brand/mark.svg +1 -0
  31. data/lib/lepus/saga/configuration.rb +48 -0
  32. data/lib/lepus/saga/control.rb +27 -0
  33. data/lib/lepus/saga/definition.rb +123 -0
  34. data/lib/lepus/saga/execution_behaviour.rb +58 -0
  35. data/lib/lepus/saga/executor.rb +282 -0
  36. data/lib/lepus/saga/executor_consumer.rb +38 -0
  37. data/lib/lepus/saga/instrumentation.rb +41 -0
  38. data/lib/lepus/saga/log_subscriber.rb +42 -0
  39. data/lib/lepus/saga/queue_depth.rb +28 -0
  40. data/lib/lepus/saga/resume_stuck_job.rb +19 -0
  41. data/lib/lepus/saga/retry_policy.rb +19 -0
  42. data/lib/lepus/saga/rspec/engine_examples.rb +635 -0
  43. data/lib/lepus/saga/rspec/helpers.rb +22 -0
  44. data/lib/lepus/saga/rspec/query_examples.rb +378 -0
  45. data/lib/lepus/saga/rspec/store_examples.rb +514 -0
  46. data/lib/lepus/saga/rspec.rb +6 -0
  47. data/lib/lepus/saga/step.rb +9 -0
  48. data/lib/lepus/saga/step_attempt_behaviour.rb +35 -0
  49. data/lib/lepus/saga/step_producer.rb +37 -0
  50. data/lib/lepus/saga/stores/base.rb +305 -0
  51. data/lib/lepus/saga/stores/memory.rb +375 -0
  52. data/lib/lepus/saga/stores.rb +40 -0
  53. data/lib/lepus/saga/testing.rb +35 -0
  54. data/lib/lepus/saga/version.rb +7 -0
  55. data/lib/lepus/saga/web/api.rb +326 -0
  56. data/lib/lepus/saga/web/configuration.rb +49 -0
  57. data/lib/lepus/saga/web/guard.rb +38 -0
  58. data/lib/lepus/saga/web/serializer.rb +59 -0
  59. data/lib/lepus/saga/web/static.rb +108 -0
  60. data/lib/lepus/saga/web.rb +61 -0
  61. data/lib/lepus/saga.rb +212 -0
  62. data/lib/lepus-saga.rb +3 -0
  63. data/mise.toml +2 -0
  64. data/web/assets/app.css +913 -0
  65. data/web/assets/apple-touch-icon.png +0 -0
  66. data/web/assets/favicon-32.png +0 -0
  67. data/web/assets/favicon.svg +1 -0
  68. data/web/assets/fonts/overpass/OFL.txt +93 -0
  69. data/web/assets/fonts/overpass/overpass-latin-wght.woff2 +0 -0
  70. data/web/assets/js/api.js +50 -0
  71. data/web/assets/js/dom.js +84 -0
  72. data/web/assets/js/format.js +79 -0
  73. data/web/assets/js/live.js +74 -0
  74. data/web/assets/js/main.js +143 -0
  75. data/web/assets/js/map.js +639 -0
  76. data/web/assets/js/range.js +19 -0
  77. data/web/assets/js/sagas.js +18 -0
  78. data/web/assets/js/theme.js +8 -0
  79. data/web/assets/js/throughput.js +262 -0
  80. data/web/assets/js/views/execution.js +426 -0
  81. data/web/assets/js/views/executions.js +165 -0
  82. data/web/assets/js/views/overview.js +465 -0
  83. data/web/index.html +63 -0
  84. metadata +323 -0
@@ -0,0 +1,465 @@
1
+ import { api } from "../api.js";
2
+ import { h, s, clear, refill } from "../dom.js";
3
+ import { every, pollMs } from "../live.js";
4
+ import { LineMap } from "../map.js";
5
+ import { rememberSagas } from "../sagas.js";
6
+ import { ago, compact, count, dayMonth, duration, hourMinute, plural, toTime, waited } from "../format.js";
7
+ import { Throughput } from "../throughput.js";
8
+ import { RANGES, findRange, rangeFrom, storeRange, storedRange } from "../range.js";
9
+
10
+ const MEDIAN_FALLBACK_MS = 3000;
11
+ const ERRORS_SHOWN = 8;
12
+ const WAITING_SHOWN = 10;
13
+ const PER_STATION = 9;
14
+ const STATIONS_FETCHED = 40;
15
+ const SETTLE_PER_POLL = 12;
16
+ const SLOW_MS = 30_000;
17
+
18
+ function symbol(kind) {
19
+ const svg = s("svg", { viewBox: "0 0 20 20", "aria-hidden": "true" });
20
+ if (kind === "move") svg.append(s("rect", { class: "sym-token", x: 1, y: 5, width: 18, height: 10, rx: 5 }));
21
+ if (kind === "idle") svg.append(s("rect", { class: "sym-idle", x: 2, y: 5.5, width: 16, height: 9, rx: 4.5 }));
22
+ if (kind === "stop") svg.append(s("rect", { class: "sym-stop", x: 7, y: 0, width: 6, height: 20, rx: 2 }));
23
+ if (kind === "back") svg.append(s("rect", { class: "sym-back", x: 1, y: 5, width: 18, height: 10, rx: 5 }));
24
+ return h("span", { class: "sym" }, svg);
25
+ }
26
+
27
+ const shortError = (name) => (name ? name.split("::").pop() : "Unknown error");
28
+ const withoutStep = (message, step) => (message || "").replace(`${step}: `, "");
29
+
30
+ // A compact number for the eye and the exact one for screen readers.
31
+ function amount(n, { exact = count(n), cls = "n" } = {}) {
32
+ const shown = typeof n === "number" ? compact(n) : n;
33
+ if (shown === exact) return h("span", { class: cls }, shown);
34
+ return h("span", { class: cls, title: exact },
35
+ h("span", { "aria-hidden": "true" }, shown),
36
+ h("span", { class: "visually-hidden" }, exact));
37
+ }
38
+
39
+ function stationHref(saga, step, status, range) {
40
+ const query = new URLSearchParams({ saga, step });
41
+ if (status) query.set("status", status);
42
+ if (status && range) query.set("range", range.key);
43
+ return `#/executions?${query}`;
44
+ }
45
+
46
+ function errorHref(group, range) {
47
+ const query = new URLSearchParams({ saga: group.saga_name, error: group.error_class || "", error_step: group.step_name, range: range.key });
48
+ return `#/executions?${query}`;
49
+ }
50
+
51
+ function stationCounts(station) {
52
+ const stalled = station.stalled || {};
53
+ const idleFwd = stalled.running || 0;
54
+ const idleBack = stalled.compensating || 0;
55
+ return {
56
+ moving: Math.max(0, (station.running || 0) - idleFwd),
57
+ undoing: Math.max(0, (station.compensating || 0) - idleBack),
58
+ stalled: idleFwd + idleBack
59
+ };
60
+ }
61
+
62
+ export function overview(root, { me, params }) {
63
+ const stallText = duration((me.stalled_after || 900) * 1000);
64
+ let range = findRange(params && params.get("range")) || storedRange() || findRange("24h");
65
+ const rangeHash = () => `#/?range=${range.key}`;
66
+ if (location.hash !== rangeHash()) history.replaceState(null, "", rangeHash());
67
+
68
+ const headline = h("h1", { class: "headline" }, h("span", { class: "quiet" }, "Reading the lines"));
69
+ const rangeButtons = RANGES.map((candidate) => h("button", {
70
+ type: "button",
71
+ "aria-pressed": String(candidate === range),
72
+ onclick: () => pickRange(candidate)
73
+ }, candidate.short));
74
+ const rangeGroup = h("div", { class: "range-row" },
75
+ h("span", { class: "range-label", id: "range-label" }, "Time range"),
76
+ h("div", { class: "seg range", role: "group", "aria-labelledby": "range-label" }, rangeButtons));
77
+ const subline = h("p", { class: "subline" });
78
+ const mapHost = h("div", { class: "map" });
79
+ const throughputNote = h("p", { class: "section-note" });
80
+ const throughputHost = h("div", { class: "tp" });
81
+ const attention = h("ul", { class: "errs" });
82
+ const attentionNote = h("p", { class: "section-note" });
83
+ const attentionShowMore = h("button", { type: "button", class: "btn btn-quiet btn-small", hidden: true, onclick: () => showMoreErrors() });
84
+ const attentionMore = h("a", { class: "link-more" });
85
+ const waiting = h("ul", { class: "stops" });
86
+ const waitingNote = h("p", { class: "section-note" });
87
+ const waitingMore = h("a", { class: "link-more", href: "#/executions?stalled=1" });
88
+ root.append(
89
+ headline,
90
+ rangeGroup,
91
+ subline,
92
+ mapHost,
93
+ h("section", { class: "through", "aria-labelledby": "through-title" },
94
+ h("div", { class: "through-head" },
95
+ h("h2", { class: "section-title", id: "through-title" }, "Throughput"),
96
+ throughputNote),
97
+ throughputHost),
98
+ h("div", { class: "lower" },
99
+ h("section", { "aria-labelledby": "attention-title" },
100
+ h("h2", { class: "section-title", id: "attention-title" }, "Needs attention"),
101
+ attentionNote,
102
+ attention,
103
+ h("div", { class: "list-foot" }, attentionShowMore, attentionMore)),
104
+ h("section", { "aria-labelledby": "waiting-title" },
105
+ h("h2", { class: "section-title", id: "waiting-title" }, "Waiting the longest"),
106
+ waitingNote,
107
+ waiting,
108
+ h("div", { class: "list-foot" }, waitingMore)))
109
+ );
110
+ const throughput = new Throughput(throughputHost);
111
+
112
+ function describeRange() {
113
+ subline.textContent = `Each line is a saga and each stop a step. Tokens are executions in flight right now, placed by how long they have spent in the step: halfway to the next stop at the step's median. Red bars are failures in ${range.phrase}. Point at a stop for exact counts, select it to list its executions.`;
114
+ throughputNote.textContent = `Executions per ${range.per} in ${range.phrase}, stacked by how they ended. The line counts starts.`;
115
+ attentionMore.href = `#/executions?status=failed&range=${range.key}`;
116
+ }
117
+ describeRange();
118
+
119
+ let failureTotal = null;
120
+ let errorGroups = null;
121
+ let errorsShown = ERRORS_SHOWN;
122
+
123
+ function pickRange(next) {
124
+ if (next === range) return;
125
+ range = next;
126
+ storeRange(range);
127
+ history.replaceState(null, "", rangeHash());
128
+ rangeButtons.forEach((button, index) => button.setAttribute("aria-pressed", String(RANGES[index] === range)));
129
+ describeRange();
130
+ errorsShown = ERRORS_SHOWN;
131
+ map.setTerminals(terminals(null));
132
+ refresh().catch(() => {});
133
+ refreshSlow().catch(() => {});
134
+ }
135
+
136
+ const map = new LineMap(mapHost, { label: "Saga lines with executions in flight" });
137
+ map.setTerminals(terminals(null));
138
+ const tokens = new Map();
139
+ let definitions = new Map();
140
+ let expected = new Map();
141
+ let first = true;
142
+ let destroyed = false;
143
+
144
+ const lineFor = (saga) => definitions.get(saga);
145
+ const stepIndex = (saga, step) => {
146
+ const def = lineFor(saga);
147
+ return def ? def.steps.findIndex((candidate) => candidate.name === step) : -1;
148
+ };
149
+
150
+ async function loadLatency() {
151
+ try {
152
+ const data = await api.latency();
153
+ expected = new Map(data.rows.filter((row) => row.p50).map((row) => [`${row.saga_name}|${row.step_name}|${row.direction}`, row.p50]));
154
+ } catch {}
155
+ }
156
+
157
+ function tokenSpec(token) {
158
+ const def = lineFor(token.saga);
159
+ const n = def ? def.steps.length : 0;
160
+ const back = token.direction === "compensate";
161
+ const spec = { id: token.id, line: token.saga, rail: back ? "back" : "fwd", target: token.index };
162
+ if (token.exit === "completed") return { ...spec, rail: "fwd", target: n, kind: "move", leave: true, creep: null, linger: 0.2 };
163
+ if (token.exit === "compensated") return { ...spec, rail: "back", target: -1, kind: "back", leave: true, creep: null, linger: 0.2 };
164
+ if (token.exit === "failed") return { ...spec, kind: "stop", leave: true, creep: null, linger: 1.6 };
165
+ if (token.exit) return { ...spec, leave: true, creep: null };
166
+ const ms = expected.get(`${token.saga}|${token.step}|${token.direction}`) || MEDIAN_FALLBACK_MS;
167
+ return {
168
+ ...spec,
169
+ kind: back ? "back" : "move",
170
+ creep: { from: token.since, ms, dir: back ? -1 : 1 }
171
+ };
172
+ }
173
+
174
+ let partialStations = null;
175
+
176
+ function sync() {
177
+ const specs = [...tokens.values()].map((token) => {
178
+ const spec = tokenSpec(token);
179
+ if (token.fresh) {
180
+ token.fresh = false;
181
+ return { ...spec, from: -1 };
182
+ }
183
+ return spec;
184
+ });
185
+ map.setTokens(specs, partialStations);
186
+ }
187
+
188
+ async function settle(id) {
189
+ const token = tokens.get(id);
190
+ try {
191
+ const { execution } = await api.execution(id);
192
+ token.exit = ["completed", "compensated", "failed", "compensation_failed"].includes(execution.status)
193
+ ? (execution.status === "compensation_failed" ? "failed" : execution.status)
194
+ : "gone";
195
+ token.index = stepIndex(token.saga, execution.current_step);
196
+ } catch {
197
+ token.exit = "gone";
198
+ }
199
+ sync();
200
+ setTimeout(() => { tokens.delete(id); }, 4000);
201
+ }
202
+
203
+ function renderHeadline({ moving, back, stalled, failed }) {
204
+ const parts = [];
205
+ const add = (n, kind, words) => parts.push([h("span", { class: "part" }, symbol(kind), amount(n), " ", words)]);
206
+ add(moving, "move", "moving");
207
+ if (back) add(back, "back", "compensating");
208
+ if (stalled) add(stalled, "idle", "stalled");
209
+ add(failed, "stop", failed === 1 ? "failure" : "failures");
210
+ clear(headline);
211
+ parts.forEach((part, index) => {
212
+ if (index > 0) headline.append(index === parts.length - 1 ? " and " : ", ");
213
+ headline.append(...part);
214
+ });
215
+ headline.append(h("span", { class: "window" }, ` in ${range.phrase}`), ".");
216
+ }
217
+
218
+ function renderLines(sagas) {
219
+ const lines = sagas.filter((saga) => saga.steps.length).map((saga) => {
220
+ const totals = saga.totals || {};
221
+ const failed = (totals.failed || 0) + (totals.compensation_failed || 0);
222
+ const runs = Object.values(totals).reduce((sum, n) => sum + n, 0);
223
+ const decor = {};
224
+ saga.steps.forEach((step, index) => {
225
+ const station = saga.stations[step.name] || {};
226
+ const blocks = (station.failed || 0) + (station.compensation_failed || 0);
227
+ const { moving, undoing, stalled } = stationCounts(station);
228
+ const tip = [`${step.name}, step ${index + 1} of ${saga.steps.length}`];
229
+ if (moving || undoing || stalled) {
230
+ const bits = [];
231
+ if (moving) bits.push(`${count(moving)} running`);
232
+ if (undoing) bits.push(`${count(undoing)} compensating`);
233
+ if (stalled) bits.push(`${count(stalled)} waiting ${stallText}+`);
234
+ tip.push(`Now: ${bits.join(", ")}`);
235
+ } else {
236
+ tip.push("Nothing here right now");
237
+ }
238
+ if (blocks) tip.push(`${plural(blocks, "execution")} failed here in ${range.phrase}`);
239
+ if (station.attempt_failures) tip.push(`${plural(station.attempt_failures, "failed attempt")}, retries included`);
240
+ decor[index] = { blocks, moving, undoing, stalled, tip, title: tip.join(". ") };
241
+ });
242
+ const meta = [{ text: `${plural(runs, "run")} in ${range.short}` }];
243
+ if (failed) meta.push({ text: ", " }, { text: `${count(failed)} failed`, stop: true });
244
+ return {
245
+ name: saga.name,
246
+ steps: saga.steps,
247
+ meta,
248
+ decor,
249
+ sagaHref: `#/executions?saga=${encodeURIComponent(saga.name)}`,
250
+ href: (index) => stationHref(saga.name, saga.steps[index].name, (decor[index].blocks ? "failed" : null), range)
251
+ };
252
+ });
253
+ map.setLines(lines);
254
+ }
255
+
256
+ function row(execution, { idle, when, side, detail }) {
257
+ return h("li", {},
258
+ h("a", { class: "stop-row", href: `#/executions/${encodeURIComponent(execution.id)}` },
259
+ h("span", { class: idle ? "stop-bar is-idle" : "stop-bar", "aria-hidden": "true" }),
260
+ h("span", { class: "stop-title" },
261
+ `${execution.resource_type} ${execution.resource_id} `,
262
+ h("span", { class: "at" }, `at ${execution.saga_name} / ${execution.current_step}`)),
263
+ h("span", { class: idle ? "stop-when is-wait" : "stop-when" }, side),
264
+ h("span", { class: idle ? "stop-reason is-idle" : "stop-reason" }, detail),
265
+ when ? h("span", { class: "visually-hidden" }, when) : null));
266
+ }
267
+
268
+ function terminals(sums) {
269
+ const n = (key) => (sums ? count(sums[key]) : "…");
270
+ return {
271
+ start: { n: n("started"), label: `started in ${range.short}` },
272
+ end: { n: n("completed"), label: `completed in ${range.short}` }
273
+ };
274
+ }
275
+
276
+ function showMoreErrors() {
277
+ const first = attention.children.length;
278
+ errorsShown += ERRORS_SHOWN;
279
+ renderAttention();
280
+ const next = attention.children[first];
281
+ if (next) next.querySelector("a")?.focus();
282
+ }
283
+
284
+ function errorRow(group, top) {
285
+ const share = h("span", { class: "err-share", "aria-hidden": "true" }, h("i"));
286
+ share.firstChild.style.inlineSize = `${Math.max(2, (group.count / top) * 100)}%`;
287
+ return h("li", { class: "err" },
288
+ h("span", { class: "err-count" },
289
+ amount(group.count, { cls: "err-n", exact: plural(group.count, "failed attempt") }),
290
+ h("span", { class: "err-unit", "aria-hidden": "true" }, group.count === 1 ? "attempt" : "attempts")),
291
+ h("div", { class: "err-body" },
292
+ h("p", { class: "err-title" },
293
+ h("b", {}, shortError(group.error_class)),
294
+ h("span", { class: "at" }, ` at ${group.saga_name} / ${group.step_name}`)),
295
+ share,
296
+ group.sample_message ? h("p", { class: "err-msg" }, withoutStep(group.sample_message, group.step_name)) : null,
297
+ h("p", { class: "err-links" },
298
+ h("span", {}, `Last ${ago(group.last_seen_at)}`),
299
+ group.sample_execution_id ? h("a", { href: `#/executions/${encodeURIComponent(group.sample_execution_id)}` }, "Latest example") : null,
300
+ h("a", { href: errorHref(group, range) }, `${plural(group.execution_count, "execution")} hit this`))));
301
+ }
302
+
303
+ // Failed attempts grouped by error, saga and step, most frequent first.
304
+ function renderAttention() {
305
+ const groups = errorGroups || [];
306
+ const total = groups.reduce((sum, group) => sum + group.count, 0);
307
+ const shown = groups.slice(0, errorsShown);
308
+ const left = groups.length - shown.length;
309
+ attentionMore.href = `#/executions?status=failed&range=${range.key}`;
310
+ attentionMore.textContent = `All ${count(failureTotal || 0)} failed executions in ${range.phrase}`;
311
+ attentionMore.hidden = !failureTotal;
312
+ attentionShowMore.hidden = left <= 0;
313
+ attentionShowMore.textContent = `Show ${Math.min(left, ERRORS_SHOWN)} more of ${count(left)}`;
314
+ if (!groups.length) {
315
+ attentionNote.textContent = failureTotal ? `No failed attempts recorded in ${range.phrase}.` : "";
316
+ const wider = RANGES[RANGES.indexOf(range) + 1];
317
+ refill(attention, h("li", { class: "empty" },
318
+ `Nothing failed in ${range.phrase}. `,
319
+ wider
320
+ ? h("button", { type: "button", class: "inline-link", onclick: () => pickRange(wider) }, `Look back over ${wider.phrase.replace("the last ", "")}`)
321
+ : "Every line ran clean all week."));
322
+ return;
323
+ }
324
+ const kinds = groups.length === 1 ? "" : `, in ${count(groups.length)} groups by error and step, most frequent first`;
325
+ attentionNote.textContent = `${plural(total, "failed attempt")} in ${range.phrase}${kinds}. Retries count, so one execution can fail more than once.`;
326
+ const top = groups[0].count;
327
+ refill(attention, shown.map((group) => errorRow(group, top)));
328
+ }
329
+
330
+ function renderWaiting(stalled, total) {
331
+ const now = Date.now();
332
+ const shown = Math.min(stalled.length, WAITING_SHOWN);
333
+ waitingNote.textContent = total > shown
334
+ ? `No progress for ${stallText} or more, right now. The ${shown} longest waits of ${count(total)}.`
335
+ : `No progress for ${stallText} or more, right now. Longest wait first.`;
336
+ const more = total - shown;
337
+ waitingMore.textContent = more > 0 ? `+${count(more)} more stalled executions` : `All ${count(total)} stalled executions`;
338
+ waitingMore.hidden = !total;
339
+ if (!stalled.length) {
340
+ refill(waiting, h("li", { class: "empty" }, `Every execution in flight made progress in the last ${stallText}. Tokens on the map show where each one is.`));
341
+ return;
342
+ }
343
+ refill(waiting, stalled.slice(0, WAITING_SHOWN).map((execution) => {
344
+ const at = toTime(execution.last_progress_at) || toTime(execution.updated_at);
345
+ const steps = lineFor(execution.saga_name)?.steps || [];
346
+ const index = steps.findIndex((step) => step.name === execution.current_step);
347
+ const bits = [index >= 0 ? `Step ${index + 1} of ${steps.length}` : "Step not in the current definition"];
348
+ if (execution.direction === "compensate") bits.push("compensating");
349
+ if (execution.resume_count) bits.push(`resumed ${plural(execution.resume_count, "time")}`);
350
+ bits.push(`stuck since ${new Date(at).toDateString() === new Date(now).toDateString() ? hourMinute(at) : dayMonth(at)}`);
351
+ return row(execution, {
352
+ idle: true,
353
+ side: h("span", {}, h("span", { class: "visually-hidden" }, "Waiting "), waited(now - at)),
354
+ detail: bits.join(", ")
355
+ });
356
+ }));
357
+ }
358
+
359
+ async function refresh() {
360
+ const asked = range;
361
+ const [sagaData, stalledData, movingData] = await Promise.all([
362
+ api.sagas({ from: rangeFrom(asked) }),
363
+ api.stalled({ limit: WAITING_SHOWN }),
364
+ api.moving({ per_station: PER_STATION, stations: STATIONS_FETCHED })
365
+ ]);
366
+ if (destroyed || asked !== range) return;
367
+ rememberSagas(sagaData.sagas);
368
+ definitions = new Map(sagaData.sagas.map((saga) => [saga.name, saga]));
369
+
370
+ const totals = { moving: 0, back: 0, stalled: 0, failed: 0 };
371
+ const busy = new Set();
372
+ for (const saga of sagaData.sagas) {
373
+ const sagaTotals = saga.totals || {};
374
+ totals.failed += (sagaTotals.failed || 0) + (sagaTotals.compensation_failed || 0);
375
+ for (const [step, station] of Object.entries(saga.stations || {})) {
376
+ const { moving, undoing, stalled } = stationCounts(station);
377
+ totals.moving += moving;
378
+ totals.back += undoing;
379
+ totals.stalled += stalled;
380
+ if (moving || undoing) busy.add(`${saga.name}|${step}`);
381
+ }
382
+ }
383
+
384
+ renderLines(sagaData.sagas);
385
+ renderWaiting(stalledData.executions, totals.stalled);
386
+ renderHeadline(totals);
387
+ failureTotal = totals.failed;
388
+ if (errorGroups) renderAttention();
389
+
390
+ // A station whose list was cut short, or left out, draws the rest of its
391
+ // count as a pill; its missing tokens may still be there.
392
+ const partial = new Set(busy);
393
+ const seen = new Set();
394
+ for (const station of movingData.stations) {
395
+ const key = `${station.saga_name}|${station.step_name}`;
396
+ if (station.executions.length >= station.count) partial.delete(key);
397
+ for (const execution of station.executions) {
398
+ const index = stepIndex(execution.saga_name, execution.current_step);
399
+ if (index < 0) continue;
400
+ seen.add(execution.id);
401
+ const existing = tokens.get(execution.id);
402
+ const since = toTime(execution.last_progress_at) || Date.now();
403
+ if (existing && !existing.exit) {
404
+ if (existing.index !== index || existing.direction !== execution.direction) existing.since = since;
405
+ Object.assign(existing, { index, step: execution.current_step, direction: execution.direction });
406
+ } else if (!existing) {
407
+ const token = { id: execution.id, saga: execution.saga_name, index, step: execution.current_step, direction: execution.direction, since };
408
+ tokens.set(execution.id, token);
409
+ if (!first) token.fresh = true;
410
+ }
411
+ }
412
+ }
413
+
414
+ partialStations = new Set([...partial].map((key) => {
415
+ const [saga, step] = key.split("|");
416
+ return `${saga}|${stepIndex(saga, step)}`;
417
+ }));
418
+
419
+ // Tokens that left a fully listed station are looked up to show how they
420
+ // ended; at a partial one they just drop out.
421
+ let settling = 0;
422
+ for (const [id, token] of tokens) {
423
+ if (seen.has(id) || token.exit || token.settling) continue;
424
+ if (partial.has(`${token.saga}|${token.step}`) || settling >= SETTLE_PER_POLL) {
425
+ tokens.delete(id);
426
+ continue;
427
+ }
428
+ token.settling = true;
429
+ settling++;
430
+ settle(id);
431
+ }
432
+ sync();
433
+ first = false;
434
+ }
435
+
436
+ async function refreshSlow() {
437
+ const asked = range;
438
+ const from = rangeFrom(asked);
439
+ const current = () => !destroyed && asked === range;
440
+ await Promise.all([
441
+ api.series({ from, bucket: asked.bucket }).then((series) => {
442
+ if (!current()) return;
443
+ throughput.set(series, asked);
444
+ map.setTerminals(terminals(throughput.sums()));
445
+ }),
446
+ api.failures({ from, limit: 200 }).then((failures) => {
447
+ if (!current()) return;
448
+ errorGroups = failures.failures;
449
+ renderAttention();
450
+ }),
451
+ expected.size ? null : loadLatency()
452
+ ]);
453
+ }
454
+
455
+ const stops = [every(pollMs, refresh), every(() => Math.max(SLOW_MS, pollMs()), refreshSlow)];
456
+
457
+ return {
458
+ destroy() {
459
+ destroyed = true;
460
+ stops.forEach((stop) => stop());
461
+ map.destroy();
462
+ throughput.destroy();
463
+ }
464
+ };
465
+ }
data/web/index.html ADDED
@@ -0,0 +1,63 @@
1
+ <!doctype html>
2
+ <html lang="en">
3
+ <head>
4
+ <meta charset="utf-8">
5
+ <meta name="viewport" content="width=device-width, initial-scale=1">
6
+ <meta name="color-scheme" content="light dark">
7
+ <base href="{{base}}">
8
+ <title>{{title}}</title>
9
+ <link rel="preload" href="{{assets}}/fonts/overpass/overpass-latin-wght.woff2" as="font" type="font/woff2" crossorigin>
10
+ <link rel="icon" href="{{assets}}/favicon-32.png" sizes="32x32" type="image/png">
11
+ <link rel="icon" href="{{assets}}/favicon.svg" type="image/svg+xml">
12
+ <link rel="apple-touch-icon" href="{{assets}}/apple-touch-icon.png">
13
+ <link rel="stylesheet" href="{{assets}}/app.css">
14
+ <script src="{{assets}}/js/theme.js"></script>
15
+ <script type="module" src="{{assets}}/js/main.js"></script>
16
+ </head>
17
+ <body>
18
+ <a class="skip" href="#view">Skip to content</a>
19
+ <header class="bar">
20
+ <a class="brand" href="#/">
21
+ <svg class="brand-mark" viewBox="0 0 64 64" width="24" height="24" aria-hidden="true">
22
+ <path class="brand-l" d="M12 6V40A16 16 0 0 0 28 56H42A10 10 0 0 0 42 36H28V18"/>
23
+ <circle class="brand-dot" cx="40" cy="46" r="5"/>
24
+ </svg>
25
+ <span id="brand-title">{{title}}</span>
26
+ </a>
27
+ <nav class="nav" aria-label="Views">
28
+ <a href="#/" data-nav="map">Map</a>
29
+ <a href="#/executions" data-nav="executions">Executions</a>
30
+ </nav>
31
+ <div class="bar-tools">
32
+ <div class="poll" id="poll">
33
+ <button type="button" class="live" id="live" aria-expanded="false" aria-controls="poll-menu">
34
+ <span class="live-dot" aria-hidden="true"></span><span class="live-text">Live</span><span class="live-rate"></span>
35
+ <svg class="live-caret" viewBox="0 0 10 10" width="10" height="10" aria-hidden="true"><path d="M2 3.5l3 3 3-3"/></svg>
36
+ </button>
37
+ <div class="poll-menu" id="poll-menu" hidden>
38
+ <fieldset>
39
+ <legend>Refresh</legend>
40
+ <div class="poll-options" id="poll-options"></div>
41
+ </fieldset>
42
+ <p class="poll-note">Updates also pause while this tab is in the background.</p>
43
+ </div>
44
+ </div>
45
+ <div class="theme" role="group" aria-label="Color theme">
46
+ <button type="button" data-theme-choice="auto" aria-label="Match system theme" title="Match system">
47
+ <svg viewBox="0 0 16 16" width="16" height="16" aria-hidden="true"><circle cx="8" cy="8" r="5.5"/><path d="M8 2.5v11a5.5 5.5 0 0 0 0-11z" class="fill"/></svg>
48
+ </button>
49
+ <button type="button" data-theme-choice="light" aria-label="Light theme" title="Light">
50
+ <svg viewBox="0 0 16 16" width="16" height="16" aria-hidden="true"><circle cx="8" cy="8" r="3"/><path d="M8 1.5v1.8M8 12.7v1.8M1.5 8h1.8M12.7 8h1.8M3.4 3.4l1.3 1.3M11.3 11.3l1.3 1.3M3.4 12.6l1.3-1.3M11.3 4.7l1.3-1.3"/></svg>
51
+ </button>
52
+ <button type="button" data-theme-choice="dark" aria-label="Dark theme" title="Dark">
53
+ <svg viewBox="0 0 16 16" width="16" height="16" aria-hidden="true"><path d="M13 10.2A5.6 5.6 0 0 1 5.8 3a5.6 5.6 0 1 0 7.2 7.2z"/></svg>
54
+ </button>
55
+ </div>
56
+ </div>
57
+ </header>
58
+ <main id="view" class="view" tabindex="-1">
59
+ <p class="loading">Loading the lines</p>
60
+ </main>
61
+ <div id="toast" class="toast" role="status" aria-live="polite"></div>
62
+ </body>
63
+ </html>