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,426 @@
1
+ import { api } from "../api.js";
2
+ import { h, s, clear, toast, copyButton, reducedMotion } from "../dom.js";
3
+ import { every, pollMs } from "../live.js";
4
+ import { LineMap } from "../map.js";
5
+ import { ago, duration, offset, plural, stamp, statusLabel, toTime } from "../format.js";
6
+
7
+ const SECONDS_PER_EVENT = 0.85;
8
+
9
+ // One keyframe per attempt, plus the start and, once it ends, the end.
10
+ function keyframes(data) {
11
+ const { execution, definition, attempts } = data;
12
+ const steps = definition.steps;
13
+ const n = steps.length;
14
+ const index = (name) => steps.findIndex((step) => step.name === name);
15
+ const start = toTime(execution.started_at || execution.created_at);
16
+ const frames = [{ kind: "start", pos: -1, rail: "fwd", at: start }];
17
+ const sorted = [...attempts].sort((a, b) => toTime(a.started_at) - toTime(b.started_at));
18
+ for (const attempt of sorted) {
19
+ frames.push({
20
+ kind: "attempt",
21
+ attempt,
22
+ pos: index(attempt.step_name),
23
+ rail: attempt.direction === "compensate" ? "back" : "fwd",
24
+ at: toTime(attempt.started_at)
25
+ });
26
+ }
27
+ const end = toTime(execution.finished_at);
28
+ const last = frames[frames.length - 1];
29
+ if (execution.status === "completed") frames.push({ kind: "end", pos: n, rail: "fwd", at: end, status: "completed" });
30
+ if (execution.status === "compensated") frames.push({ kind: "end", pos: -1, rail: "back", at: end, status: "compensated" });
31
+ if (execution.status === "failed" || execution.status === "compensation_failed") {
32
+ frames.push({ kind: "end", pos: last.pos, rail: last.rail, at: end || last.at, status: execution.status });
33
+ }
34
+ return frames.map((frame) => ({ ...frame, offset: Math.max(0, (frame.at || start) - start) }));
35
+ }
36
+
37
+ function frameOutcome(frame) {
38
+ if (frame.kind === "start") return "start";
39
+ if (frame.kind === "end") return ["failed", "compensation_failed"].includes(frame.status) ? "stop" : frame.status === "compensated" ? "back" : "ok";
40
+ const { status, direction } = frame.attempt;
41
+ if (status === "failed") return "stop";
42
+ if (status === "running") return "run";
43
+ return direction === "compensate" ? "back" : "ok";
44
+ }
45
+
46
+ function describe(frame, execution) {
47
+ if (frame.kind === "start") return [h("b", {}, "Started"), ` for ${execution.resource_type} ${execution.resource_id}`];
48
+ if (frame.kind === "end") {
49
+ if (frame.status === "completed") return [h("b", {}, "Completed"), " every step"];
50
+ if (frame.status === "compensated") return [h("b", {}, "Compensated"), ", every completed step undone"];
51
+ return [h("b", {}, statusLabel(frame.status)), execution.failure_reason ? `: ${execution.failure_reason}` : ""];
52
+ }
53
+ const { attempt } = frame;
54
+ const verb = attempt.direction === "compensate" ? "undo" : "attempt";
55
+ const outcome = { succeeded: "succeeded", failed: "failed", running: "running", skipped: "skipped" }[attempt.status] || attempt.status;
56
+ return [h("b", {}, attempt.step_name), ` ${verb} ${attempt.attempt} ${outcome}`, attempt.error_message ? `: ${attempt.error_message.replace(`${attempt.step_name}: `, "")}` : ""];
57
+ }
58
+
59
+ function story(data, frames, stallMs) {
60
+ const { execution, attempts } = data;
61
+ const tries = attempts.filter((a) => a.step_name === execution.current_step && a.direction === "execute").length;
62
+ const undone = attempts.filter((a) => a.direction === "compensate" && a.status === "succeeded").length;
63
+ const took = duration(toTime(execution.finished_at) - toTime(execution.started_at));
64
+ const step = h("b", {}, execution.current_step);
65
+ switch (execution.status) {
66
+ case "completed": return ["Completed ", plural(data.definition.steps.length, "step"), " in ", took, "."];
67
+ case "failed": return ["Failed at ", step, " after ", plural(tries, "attempt"), ", ", took, " after it started."];
68
+ case "compensation_failed": return ["Compensation stopped at ", step, ". Some steps may still need undoing by hand."];
69
+ case "compensated": {
70
+ const failedStep = attempts.filter((a) => a.direction === "execute" && a.status === "failed").pop();
71
+ return failedStep
72
+ ? [h("b", {}, failedStep.step_name), " failed, then ", plural(undone, "step"), undone === 1 ? " was undone in " : " were undone in ", took, "."]
73
+ : ["Compensated: ", plural(undone, "step"), " undone in ", took, "."];
74
+ }
75
+ case "compensating": return ["Undoing ", step, " now."];
76
+ default: {
77
+ const idle = Date.now() - toTime(execution.last_progress_at);
78
+ if (idle > stallMs) return ["Waiting at ", step, " with no progress for ", duration(idle), ". The step message may be lost."];
79
+ return ["Running ", step, tries > 1 ? `, attempt ${tries}` : "", "."];
80
+ }
81
+ }
82
+ }
83
+
84
+ export function execution(root, { id, me }) {
85
+ root.append(h("p", { class: "loading" }, "Loading the execution"));
86
+ let destroyed = false;
87
+ let data = null;
88
+ let frames = [];
89
+ let u = 0;
90
+ let playing = false;
91
+ let raf = null;
92
+ let map = null;
93
+ let els = null;
94
+
95
+ const stallMs = (me.stalled_after || 900) * 1000;
96
+ const canAct = (action) => !me.read_only && me.actions && me.actions[action];
97
+
98
+ function stationDecor(upTo) {
99
+ const decor = {};
100
+ const n = data.definition.steps.length;
101
+ for (let i = 0; i < n; i++) decor[i] = { blocks: 0, state: null, title: data.definition.steps[i].name };
102
+ let reached = -1;
103
+ let backFrom = null;
104
+ let backTo = null;
105
+ frames.forEach((frame, k) => {
106
+ if (k > upTo || frame.pos < 0 || frame.pos >= n) return;
107
+ const d = decor[frame.pos];
108
+ if (frame.kind !== "attempt") return;
109
+ const { attempt } = frame;
110
+ if (attempt.status === "failed") d.blocks += 1;
111
+ if (attempt.direction === "execute") {
112
+ reached = Math.max(reached, frame.pos);
113
+ if (attempt.status === "succeeded") d.state = "passed";
114
+ if (attempt.status === "running") d.state = "current";
115
+ } else {
116
+ if (backFrom === null) backFrom = frame.pos;
117
+ backTo = frame.pos;
118
+ if (attempt.status === "succeeded") d.state = "undone";
119
+ }
120
+ });
121
+ const last = frames[Math.min(upTo, frames.length - 1)];
122
+ if (last && last.kind === "end" && last.status === "completed") reached = n;
123
+ if (last && last.kind === "end" && last.status === "compensated" && backTo !== null) backTo = -1;
124
+ for (const [i, d] of Object.entries(decor)) {
125
+ const bits = [data.definition.steps[i].name];
126
+ if (d.state) bits.push(d.state === "passed" ? "succeeded" : d.state === "undone" ? "undone" : "running");
127
+ if (d.blocks) bits.push(plural(d.blocks, "failed attempt"));
128
+ d.title = bits.join(", ");
129
+ }
130
+ return { decor, progress: { reached, backFrom, backTo } };
131
+ }
132
+
133
+ function ease(t) {
134
+ return t < 0.5 ? 2 * t * t : 1 - (-2 * t + 2) ** 2 / 2;
135
+ }
136
+
137
+ function seek(value, { fromInput = false } = {}) {
138
+ const max = frames.length - 1;
139
+ u = Math.max(0, Math.min(max, value));
140
+ const k = Math.floor(u + 1e-6);
141
+ const t = u - k;
142
+ const a = frames[k];
143
+ const b = frames[Math.min(max, k + 1)];
144
+ const e = ease(t);
145
+ const pos = a.pos + (b.pos - a.pos) * e;
146
+ const rail = t > 0.02 ? b.rail : a.rail;
147
+ const outcome = frameOutcome(frames[Math.min(max, Math.round(u))]);
148
+ const kind = outcome === "stop" ? "stop" : rail === "back" ? "back" : outcome === "ok" && a.kind === "end" ? "done" : "move";
149
+ map.place({ id: "replay", line: data.execution.saga_name, target: pos, rail, kind, big: true });
150
+ const { decor, progress } = stationDecor(k);
151
+ const line = map.lines[0];
152
+ line.decor = decor;
153
+ map.setLines([line]);
154
+ map.setProgress({ ...progress, reached: Math.max(progress.reached, a.rail === "fwd" ? Math.min(pos, Math.max(a.pos, b.pos)) : progress.reached) });
155
+
156
+ if (!fromInput) els.range.value = String(u);
157
+ els.range.setAttribute("aria-valuetext", textOf(frames[k]));
158
+ const current = frames[Math.round(u)] ? Math.round(u) : k;
159
+ clear(els.now).append(h("span", {}, offset(frames[current].offset), " "), ...describe(frames[current], data.execution));
160
+ els.rows.forEach((row, index) => {
161
+ if (index === current) row.setAttribute("aria-current", "step");
162
+ else row.removeAttribute("aria-current");
163
+ row.classList.toggle("is-future", index > current);
164
+ });
165
+ renderContext(current);
166
+ }
167
+
168
+ function textOf(frame) {
169
+ const div = h("span", {}, ...describe(frame, data.execution));
170
+ return `${offset(frame.offset)}, ${div.textContent}`;
171
+ }
172
+
173
+ function setPlaying(on) {
174
+ playing = on;
175
+ els.play.setAttribute("aria-label", on ? "Pause replay" : "Replay this execution");
176
+ clear(els.play).append(on
177
+ ? s("svg", { viewBox: "0 0 16 16", width: 16, height: 16, "aria-hidden": "true" }, s("rect", { x: 3, y: 2, width: 3.5, height: 12, rx: 1 }), s("rect", { x: 9.5, y: 2, width: 3.5, height: 12, rx: 1 }))
178
+ : s("svg", { viewBox: "0 0 16 16", width: 16, height: 16, "aria-hidden": "true" }, s("path", { d: "M4 2.5v11a.7.7 0 0 0 1 .6l9-5.5a.7.7 0 0 0 0-1.2l-9-5.5a.7.7 0 0 0-1 .6z" })));
179
+ cancelAnimationFrame(raf);
180
+ if (!on) return;
181
+ let last = null;
182
+ const step = (time) => {
183
+ if (!playing || destroyed) return;
184
+ const dt = last === null ? 0 : (time - last) / 1000;
185
+ last = time;
186
+ const next = u + dt / SECONDS_PER_EVENT;
187
+ if (next >= frames.length - 1) {
188
+ seek(frames.length - 1);
189
+ setPlaying(false);
190
+ return;
191
+ }
192
+ seek(next);
193
+ raf = requestAnimationFrame(step);
194
+ };
195
+ raf = requestAnimationFrame(step);
196
+ }
197
+
198
+ function renderContext(k) {
199
+ if (!els.ctx) return;
200
+ let latest = null;
201
+ let previous = null;
202
+ for (let i = 0; i <= k; i++) {
203
+ const frame = frames[i];
204
+ if (frame.kind === "attempt" && frame.attempt.status === "succeeded" && frame.attempt.context_after && Object.keys(frame.attempt.context_after).length) {
205
+ previous = latest;
206
+ latest = frame.attempt.context_after;
207
+ }
208
+ }
209
+ if (k === frames.length - 1 && data.execution.context) latest = latest ? { ...data.execution.context, ...latest } : data.execution.context;
210
+ clear(els.ctx);
211
+ if (!latest) {
212
+ els.ctx.append(h("p", { class: "empty" }, "The context appears once the first step succeeds."));
213
+ return;
214
+ }
215
+ const pre = h("pre", {});
216
+ pre.append("{\n");
217
+ const keys = Object.keys(latest);
218
+ keys.forEach((key, index) => {
219
+ const fresh = previous && !(key in previous);
220
+ const line = h("span", { class: fresh ? "new" : null }, " ", h("span", { class: "k" }, JSON.stringify(key)), ": ", JSON.stringify(latest[key]), index < keys.length - 1 ? "," : "");
221
+ pre.append(line, "\n");
222
+ });
223
+ pre.append("}");
224
+ els.ctx.append(pre);
225
+ }
226
+
227
+ async function act(kind, button) {
228
+ button.disabled = true;
229
+ try {
230
+ if (kind === "retry") await api.retry(id);
231
+ else await api.compensate(id, "compensated from the console");
232
+ toast(kind === "retry" ? `Retried from ${data.execution.current_step}` : "Compensation started");
233
+ await load(true);
234
+ } catch (error) {
235
+ toast(error.message, { error: true });
236
+ button.disabled = false;
237
+ }
238
+ }
239
+
240
+ function actions() {
241
+ const { execution } = data;
242
+ const wrap = h("div", { class: "x-actions" });
243
+ const stalled = execution.status === "running" && Date.now() - toTime(execution.last_progress_at) > stallMs;
244
+ if (canAct("retry") && (["failed", "compensation_failed"].includes(execution.status) || stalled)) {
245
+ wrap.append(h("button", { type: "button", class: "btn", onclick: (event) => act("retry", event.currentTarget) },
246
+ `Retry from ${execution.current_step}`));
247
+ }
248
+ if (canAct("compensate") && execution.status === "running") {
249
+ const start = h("button", { type: "button", class: "btn btn-danger" }, "Compensate");
250
+ start.addEventListener("click", () => {
251
+ const confirm = h("div", { class: "x-confirm" },
252
+ h("span", {}, "Undo the steps that already ran?"),
253
+ h("button", { type: "button", class: "btn btn-danger", onclick: (event) => act("compensate", event.currentTarget) }, "Compensate now"),
254
+ h("button", { type: "button", class: "btn btn-quiet", onclick: () => { confirm.replaceWith(start); start.focus(); } }, "Cancel"));
255
+ start.replaceWith(confirm);
256
+ confirm.querySelector("button").focus();
257
+ });
258
+ wrap.append(start);
259
+ }
260
+ return wrap;
261
+ }
262
+
263
+ function historyRow(frame, index) {
264
+ const outcome = frameOutcome(frame);
265
+ const classes = ["h-row", `is-${outcome}`];
266
+ if (frame.rail === "back") classes.push("is-back");
267
+ if (frame.kind === "end") classes.push("is-end");
268
+ if (frame.rail === "back" && outcome === "back") classes.push("is-ok");
269
+ const row = h("li", { class: classes.join(" ") },
270
+ h("button", { type: "button", class: "h-at", "aria-label": `Move the replay to ${offset(frame.offset)}` }, offset(frame.offset)),
271
+ h("span", { class: "h-mark", "aria-hidden": "true" }, h("i")),
272
+ h("span", { class: "h-what" }, ...describe(frame, data.execution).map((part) => (typeof part === "string" && part.includes(": ") ? part.split(": ")[0] : part))),
273
+ h("span", { class: "h-took" }, frame.attempt && frame.attempt.duration_ms !== null ? duration(frame.attempt.duration_ms) : ""));
274
+ const error = frame.attempt && frame.attempt.error_message;
275
+ if (error) {
276
+ row.append(h("span", { class: "h-error" }, frame.attempt.error_class ? `${frame.attempt.error_class.split("::").pop()}: ` : "", error.replace(`${frame.attempt.step_name}: `, "")));
277
+ if (frame.attempt.error_backtrace) {
278
+ row.append(h("details", { class: "h-trace" }, h("summary", {}, "Backtrace"), h("pre", {}, String(frame.attempt.error_backtrace))));
279
+ }
280
+ }
281
+ row.addEventListener("click", (event) => {
282
+ if (event.target.closest("details")) return;
283
+ setPlaying(false);
284
+ seek(index);
285
+ });
286
+ return row;
287
+ }
288
+
289
+ function facts() {
290
+ const { execution } = data;
291
+ const rows = [
292
+ ["Execution", [execution.id.slice(0, 8) + "…" + execution.id.slice(-4), copyButton(execution.id, "execution id")]],
293
+ ["Status", h("span", { class: `state state-${execution.status}` }, statusLabel(execution.status))],
294
+ ["Saga", h("a", { href: `#/executions?saga=${encodeURIComponent(execution.saga_name)}` }, execution.saga_name)],
295
+ ["Partition", execution.partition],
296
+ ["Correlation", execution.correlation_id ? [execution.correlation_id, copyButton(execution.correlation_id, "correlation id")] : "None"],
297
+ ["Epoch", String(execution.epoch)],
298
+ ["Resumed", plural(execution.resume_count || 0, "time")],
299
+ ["Started", stamp(execution.started_at)],
300
+ ["Finished", execution.finished_at ? stamp(execution.finished_at) : "Not yet"],
301
+ ["Last progress", ago(execution.last_progress_at)]
302
+ ];
303
+ return h("dl", { class: "facts" }, rows.map(([term, value]) => [h("dt", {}, term), h("dd", {}, value)]));
304
+ }
305
+
306
+ function render() {
307
+ const { execution, related } = data;
308
+ clear(root);
309
+ const definition = data.definition;
310
+ frames = keyframes(data);
311
+
312
+ const play = h("button", { type: "button", class: "scrub-play" });
313
+ play.addEventListener("click", () => {
314
+ if (!playing && u >= frames.length - 1.001) seek(0);
315
+ setPlaying(!playing);
316
+ });
317
+ const range = h("input", { type: "range", min: 0, max: frames.length - 1, step: "any", value: 0, "aria-label": "Replay position" });
318
+ range.addEventListener("input", () => { setPlaying(false); seek(Number(range.value), { fromInput: true }); });
319
+ range.addEventListener("keydown", (event) => {
320
+ if (["ArrowRight", "ArrowUp", "ArrowLeft", "ArrowDown"].includes(event.key)) {
321
+ event.preventDefault();
322
+ setPlaying(false);
323
+ const dir = event.key === "ArrowRight" || event.key === "ArrowUp" ? 1 : -1;
324
+ seek(dir > 0 ? Math.floor(u + 1e-6) + 1 : Math.ceil(u - 1e-6) - 1);
325
+ }
326
+ });
327
+ const ticks = s("svg", { "aria-hidden": "true", preserveAspectRatio: "none" },
328
+ s("line", { class: "tick-base", x1: "0", x2: "100%", y1: 14, y2: 14 }));
329
+ frames.forEach((frame, index) => {
330
+ const outcome = frameOutcome(frame);
331
+ const pct = frames.length > 1 ? (index / (frames.length - 1)) * 100 : 0;
332
+ ticks.append(s("rect", { class: `tick is-${outcome}`, x: `${pct}%`, y: outcome === "stop" ? 4 : 8, width: 3, height: outcome === "stop" ? 20 : 12, rx: 1, transform: "translate(-1.5 0)" }));
333
+ });
334
+ const now = h("div", { class: "scrub-now", "aria-live": "off" });
335
+ const mapHost = h("div", { class: "map" });
336
+ const history = h("ol", { class: "history", "aria-label": "Attempt history" });
337
+ const rows = frames.map(historyRow);
338
+ history.append(...rows);
339
+ const ctx = h("div", {});
340
+
341
+ root.append(
342
+ h("p", { class: "crumbs" }, h("a", { href: "#/executions" }, "Executions"), " / ",
343
+ h("a", { href: `#/executions?saga=${encodeURIComponent(execution.saga_name)}` }, execution.saga_name)),
344
+ h("div", { class: "x-head" },
345
+ h("h1", { class: "x-title", tabindex: "-1" }, `${execution.resource_type} `, h("b", {}, execution.resource_id)),
346
+ actions()),
347
+ h("p", { class: "x-story" }, ...story(data, frames, stallMs)),
348
+ execution.failure_reason ? h("p", {}, h("span", { class: "x-reason" }, execution.failure_reason)) : null,
349
+ h("section", { class: "replay", "aria-label": "Journey replay" },
350
+ mapHost,
351
+ h("div", { class: "scrub" }, play, h("div", { class: "scrub-track" }, h("div", { class: "scrub-ticks" }, ticks), range), now)),
352
+ h("div", { class: "x-body" },
353
+ h("section", { "aria-labelledby": "history-title" },
354
+ h("h2", { class: "section-title", id: "history-title" }, "History"),
355
+ h("p", { class: "section-note" }, "Every attempt in order, timed from the start. Select a time to move the replay there."),
356
+ history),
357
+ h("aside", {},
358
+ h("h2", { class: "section-title" }, "Details"),
359
+ facts(),
360
+ h("div", { class: "ctx" },
361
+ h("h2", { class: "section-title" }, "Context at this point"),
362
+ h("p", { class: "section-note" }, "Keys a step added are highlighted."),
363
+ ctx),
364
+ related.length ? h("div", { class: "ctx" },
365
+ h("h2", { class: "section-title" }, "Other runs for this resource"),
366
+ h("ul", { class: "related" }, related.map((other) => h("li", {}, h("a", { href: `#/executions/${encodeURIComponent(other.id)}` },
367
+ h("span", { class: `state state-${other.status}` }, statusLabel(other.status)), stamp(other.started_at)))))) : null))
368
+ );
369
+
370
+ els = { play, range, now, rows, ctx };
371
+ map = new LineMap(mapHost, { mode: "single", label: `${execution.saga_name} line` });
372
+ map.setLines([{ name: execution.saga_name, steps: definition.steps, decor: {} }]);
373
+ setPlaying(false);
374
+ if (reducedMotion.matches) {
375
+ seek(frames.length - 1);
376
+ } else {
377
+ seek(0);
378
+ setTimeout(() => { if (!destroyed && !playing && u === 0) setPlaying(true); }, 500);
379
+ }
380
+ }
381
+
382
+ // A poll waits while the reader is in the middle of something the redraw
383
+ // would throw away: a confirmation, the scrubber, the history or an open
384
+ // backtrace.
385
+ function interacting() {
386
+ const active = document.activeElement;
387
+ return Boolean(root.querySelector(".x-confirm, .h-trace[open]") ||
388
+ (active && root.contains(active) && active.closest(".x-actions, .scrub, .history")));
389
+ }
390
+
391
+ let signature = "";
392
+ async function load(force = false) {
393
+ const next = await api.execution(id);
394
+ if (destroyed) return;
395
+ const sig = `${next.execution.status}|${next.attempts.length}|${next.execution.updated_at}`;
396
+ if (!force && (sig === signature || (els && interacting()))) return;
397
+ const atEnd = frames.length && u >= frames.length - 1.001;
398
+ const hadFocus = force || root.contains(document.activeElement);
399
+ signature = sig;
400
+ data = next;
401
+ if (!els) return render();
402
+ const wasPlaying = playing;
403
+ if (map) map.destroy();
404
+ render();
405
+ if (atEnd || wasPlaying) { setPlaying(false); seek(frames.length - 1); }
406
+ if (hadFocus) root.querySelector(".x-title").focus();
407
+ }
408
+
409
+ const stop = every(pollMs, async () => {
410
+ try {
411
+ await load();
412
+ } catch (error) {
413
+ if (!data) clear(root).append(h("p", { class: "error" }, error.status === 404 ? "This execution no longer exists. It may have been pruned." : error.message));
414
+ }
415
+ });
416
+
417
+ return {
418
+ destroy() {
419
+ destroyed = true;
420
+ playing = false;
421
+ cancelAnimationFrame(raf);
422
+ stop();
423
+ if (map) map.destroy();
424
+ }
425
+ };
426
+ }
@@ -0,0 +1,165 @@
1
+ import { api } from "../api.js";
2
+ import { h, s, refill } from "../dom.js";
3
+ import { every, pollMs } from "../live.js";
4
+ import { sagaDefinitions } from "../sagas.js";
5
+ import { ago, duration, statusLabel, toTime, waited } from "../format.js";
6
+ import { findRange, rangeFrom } from "../range.js";
7
+
8
+ const GROUPS = [
9
+ { key: "", label: "All", statuses: null },
10
+ { key: "moving", label: "In flight", statuses: ["running", "compensating"] },
11
+ { key: "failed", label: "Failed", statuses: ["failed", "compensation_failed"] },
12
+ { key: "compensated", label: "Compensated", statuses: ["compensated"] },
13
+ { key: "completed", label: "Completed", statuses: ["completed"] }
14
+ ];
15
+
16
+ // A line the width of a table cell: done stops in ink, the rest pale, and a
17
+ // mark where the execution is.
18
+ function glyph(execution, definition) {
19
+ const steps = definition ? definition.steps : [];
20
+ const n = Math.max(1, steps.length);
21
+ const width = 112;
22
+ const x = (i) => 6 + (n === 1 ? 50 : ((width - 12) * i) / (n - 1));
23
+ const at = Math.max(0, steps.findIndex((step) => step.name === execution.current_step));
24
+ const done = execution.status === "completed" ? n - 1 : execution.status === "compensated" ? -1 : at;
25
+ const svg = s("svg", { class: "glyph", viewBox: `0 0 ${width} 16`, width, height: 16, "aria-hidden": "true" });
26
+ svg.append(s("line", { class: "g-line is-future", x1: x(0), x2: x(n - 1), y1: 8, y2: 8 }));
27
+ if (done > 0) svg.append(s("line", { class: "g-line", x1: x(0), x2: x(done), y1: 8, y2: 8 }));
28
+ for (let i = 0; i < n; i++) {
29
+ svg.append(s("circle", { class: i <= done ? "g-dot" : "g-dot is-future", cx: x(i), cy: 8, r: 3 }));
30
+ }
31
+ const status = execution.status;
32
+ if (status === "failed" || status === "compensation_failed") {
33
+ svg.append(s("rect", { class: "g-stop", x: x(at) - 2, y: 0, width: 4, height: 16, rx: 1.5 }));
34
+ } else if (status === "running") {
35
+ svg.append(s("rect", { class: "t-token", x: x(at) - 7, y: 3.5, width: 14, height: 9, rx: 4.5 }));
36
+ } else if (status === "compensating" || status === "compensated") {
37
+ svg.append(s("rect", { class: "t-token is-back", x: x(status === "compensated" ? 0 : at) - 7, y: 3.5, width: 14, height: 9, rx: 4.5 }));
38
+ } else if (status === "completed") {
39
+ svg.append(s("rect", { class: "t-token is-done", x: x(n - 1) - 7, y: 3.5, width: 14, height: 9, rx: 4.5 }));
40
+ }
41
+ return svg;
42
+ }
43
+
44
+ const shortError = (name) => name.split("::").pop();
45
+
46
+ export function executions(root, { params, me }) {
47
+ const stalled = params.get("stalled") === "1";
48
+ const stallText = duration((me.stalled_after || 900) * 1000);
49
+ const range = stalled ? null : findRange(params.get("range"));
50
+ const group = stalled ? GROUPS[1] : GROUPS.find((candidate) => candidate.key === (params.get("status") || "")) || GROUPS[0];
51
+ const saga = params.get("saga") || "";
52
+ const step = params.get("step") || "";
53
+ const error = params.get("error") || "";
54
+ const errorStep = params.get("error_step") || "";
55
+ let destroyed = false;
56
+ let rows = [];
57
+ let next = null;
58
+ let extended = false;
59
+
60
+ const go = (changes) => {
61
+ const query = new URLSearchParams({
62
+ status: stalled ? "" : group.key, saga, step, error, error_step: errorStep,
63
+ range: range ? range.key : "", stalled: stalled ? "1" : "", ...changes
64
+ });
65
+ for (const [key, value] of [...query]) if (!value) query.delete(key);
66
+ location.hash = `#/executions${query.toString() ? `?${query}` : ""}`;
67
+ };
68
+
69
+ const seg = h("div", { class: "seg", role: "group", "aria-label": "Status" },
70
+ GROUPS.map((candidate) => h("button", {
71
+ type: "button", "aria-pressed": String(candidate === group), onclick: () => go({ status: candidate.key, stalled: "" })
72
+ }, candidate.label)));
73
+ const select = h("select", { class: "select", "aria-label": "Saga" }, h("option", { value: "" }, "Every saga"));
74
+ select.addEventListener("change", () => go({ saga: select.value, step: "" }));
75
+ const chip = step ? h("button", { type: "button", class: "chip", onclick: () => go({ step: "" }), "aria-label": `Stop filtering by ${step}` },
76
+ "at ", h("b", {}, step), h("span", { "aria-hidden": "true" }, "×")) : null;
77
+ const rangeChip = range ? h("button", { type: "button", class: "chip", onclick: () => go({ range: "" }), "aria-label": `Stop limiting to ${range.phrase}` },
78
+ "in ", h("b", {}, range.phrase.replace("the ", "")), h("span", { "aria-hidden": "true" }, "×")) : null;
79
+ const stalledChip = stalled ? h("button", { type: "button", class: "chip", onclick: () => go({ stalled: "", status: "moving" }), "aria-label": "Show every execution in flight" },
80
+ h("b", {}, `no progress for ${stallText}+`), h("span", { "aria-hidden": "true" }, "×")) : null;
81
+ const errorLabel = [error && shortError(error), errorStep && `at ${errorStep}`].filter(Boolean).join(" ");
82
+ const errorChip = error || errorStep ? h("button", { type: "button", class: "chip", onclick: () => go({ error: "", error_step: "" }), "aria-label": `Stop filtering by ${errorLabel}`, title: error || null },
83
+ "failed with ", h("b", {}, errorLabel), h("span", { "aria-hidden": "true" }, "×")) : null;
84
+
85
+ const body = h("tbody");
86
+ const table = h("table", { class: "rows" },
87
+ h("thead", {}, h("tr", {},
88
+ h("th", { class: "r-glyph", scope: "col" }, h("span", { class: "visually-hidden" }, "Position on the line")),
89
+ h("th", { scope: "col" }, "Resource"),
90
+ h("th", { class: "r-saga-col", scope: "col" }, "Saga"),
91
+ h("th", { scope: "col" }, "Step"),
92
+ h("th", { scope: "col" }, "Status"),
93
+ h("th", { scope: "col" }, "Started"),
94
+ h("th", { scope: "col" }, stalled ? "Waiting" : "Took"))),
95
+ body);
96
+ const more = h("button", { type: "button", class: "btn btn-quiet", hidden: true }, "Show older");
97
+ const status = h("p", { class: "empty", hidden: true });
98
+
99
+ root.append(
100
+ h("div", { class: "list-head" },
101
+ h("h1", { class: "list-title" }, saga ? saga : stalled ? "Stalled executions" : "Executions"),
102
+ h("div", { class: "filters" }, seg, select, chip, errorChip, rangeChip, stalledChip)),
103
+ table, status, h("div", { class: "more-row" }, more));
104
+
105
+ let definitions = new Map();
106
+
107
+ function draw() {
108
+ const now = Date.now();
109
+ refill(body, rows.map((execution) => {
110
+ const href = `#/executions/${encodeURIComponent(execution.id)}`;
111
+ const started = toTime(execution.started_at);
112
+ const end = toTime(execution.finished_at);
113
+ const tr = h("tr", {},
114
+ h("td", { class: "r-glyph" }, glyph(execution, definitions.get(execution.saga_name))),
115
+ h("td", { class: "r-res" }, h("a", { href }, `${execution.resource_type} ${execution.resource_id}`)),
116
+ h("td", { class: "r-saga-col" }, execution.saga_name),
117
+ h("td", { class: "r-step" }, execution.current_step || ""),
118
+ h("td", { class: "r-state" }, h("span", { class: `state state-${execution.status}` }, statusLabel(execution.status))),
119
+ h("td", { class: "r-when" }, h("time", { datetime: execution.started_at }, ago(started))),
120
+ h("td", { class: "r-took" }, stalled ? waited(now - toTime(execution.last_progress_at)) : end ? duration(end - started) : ""));
121
+ tr.addEventListener("click", (event) => { if (!event.target.closest("a")) location.hash = href; });
122
+ return tr;
123
+ }));
124
+ status.hidden = rows.length > 0;
125
+ status.textContent = "No executions match these filters. Try another status or saga.";
126
+ more.hidden = !next;
127
+ }
128
+
129
+ // With an error filter the range bounds when the attempt failed, as the
130
+ // counts under "Needs attention" do, rather than when the execution started.
131
+ function query(before) {
132
+ const since = range ? rangeFrom(range) : null;
133
+ const byError = Boolean(error || errorStep);
134
+ return {
135
+ status: group.statuses, saga: saga || null, step: step || null, error: error || null, error_step: errorStep || null,
136
+ stalled: stalled ? 1 : null, from: byError ? null : since, error_from: byError ? since : null, before, limit: 40
137
+ };
138
+ }
139
+
140
+ async function refresh() {
141
+ if (extended) return;
142
+ const [defs, data] = await Promise.all([sagaDefinitions(), api.executions(query())]);
143
+ if (destroyed) return;
144
+ definitions = defs;
145
+ if (select.options.length === 1) {
146
+ for (const name of defs.keys()) select.append(h("option", { value: name, selected: name === saga }, name));
147
+ }
148
+ rows = data.executions;
149
+ next = data.next_before;
150
+ draw();
151
+ }
152
+
153
+ more.addEventListener("click", async () => {
154
+ more.disabled = true;
155
+ const data = await api.executions(query(next));
156
+ extended = true;
157
+ rows = rows.concat(data.executions);
158
+ next = data.next_before;
159
+ more.disabled = false;
160
+ draw();
161
+ });
162
+
163
+ const stop = every(pollMs, refresh);
164
+ return { destroy() { destroyed = true; stop(); } };
165
+ }