lepus-saga 0.3.0 → 0.3.2

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.
@@ -1,6 +1,6 @@
1
1
  import { api } from "../api.js";
2
- import { h, s, clear, refill } from "../dom.js";
3
- import { every, pollMs } from "../live.js";
2
+ import { h, s, clear, refill, reducedMotion } from "../dom.js";
3
+ import { every, isLive, onLive, pollMs } from "../live.js";
4
4
  import { LineMap } from "../map.js";
5
5
  import { rememberSagas } from "../sagas.js";
6
6
  import { ago, compact, count, dayMonth, duration, hourMinute, plural, toTime, waited } from "../format.js";
@@ -14,6 +14,13 @@ const PER_STATION = 9;
14
14
  const STATIONS_FETCHED = 40;
15
15
  const SETTLE_PER_POLL = 12;
16
16
  const SLOW_MS = 30_000;
17
+ const REPLAY_PER_LINE = 8;
18
+ const REPLAY_MS = 1000;
19
+ const OVERLAP_MS = 2000;
20
+ const HANDLED_KEPT = 5000;
21
+ const NOTE_MIN_MS = 4000;
22
+ const TERMINAL = ["completed", "compensated", "failed", "compensation_failed"];
23
+ const ENDINGS = [["completed", "completed", "done"], ["compensated", "compensated", "back"], ["failed", "failed", "stop"], ["compensation_failed", "compensation failed", "stop"]];
17
24
 
18
25
  function symbol(kind) {
19
26
  const svg = s("svg", { viewBox: "0 0 20 20", "aria-hidden": "true" });
@@ -21,9 +28,14 @@ function symbol(kind) {
21
28
  if (kind === "idle") svg.append(s("rect", { class: "sym-idle", x: 2, y: 5.5, width: 16, height: 9, rx: 4.5 }));
22
29
  if (kind === "stop") svg.append(s("rect", { class: "sym-stop", x: 7, y: 0, width: 6, height: 20, rx: 2 }));
23
30
  if (kind === "back") svg.append(s("rect", { class: "sym-back", x: 1, y: 5, width: 18, height: 10, rx: 5 }));
31
+ if (kind === "replay") svg.append(s("rect", { class: "sym-replay", x: 1, y: 5, width: 18, height: 10, rx: 5 }));
24
32
  return h("span", { class: "sym" }, svg);
25
33
  }
26
34
 
35
+ function keyItem(kind, text) {
36
+ return h("li", {}, symbol(kind), text);
37
+ }
38
+
27
39
  const shortError = (name) => (name ? name.split("::").pop() : "Unknown error");
28
40
  const withoutStep = (message, step) => (message || "").replace(`${step}: `, "");
29
41
 
@@ -75,6 +87,7 @@ export function overview(root, { me, params }) {
75
87
  h("span", { class: "range-label", id: "range-label" }, "Time range"),
76
88
  h("div", { class: "seg range", role: "group", "aria-labelledby": "range-label" }, rangeButtons));
77
89
  const subline = h("p", { class: "subline" });
90
+ const mapKey = h("ul", { class: "m-key", "aria-label": "Map key" });
78
91
  const mapHost = h("div", { class: "map" });
79
92
  const throughputNote = h("p", { class: "section-note" });
80
93
  const throughputHost = h("div", { class: "tp" });
@@ -89,6 +102,7 @@ export function overview(root, { me, params }) {
89
102
  headline,
90
103
  rangeGroup,
91
104
  subline,
105
+ mapKey,
92
106
  mapHost,
93
107
  h("section", { class: "through", "aria-labelledby": "through-title" },
94
108
  h("div", { class: "through-head" },
@@ -109,8 +123,16 @@ export function overview(root, { me, params }) {
109
123
  );
110
124
  const throughput = new Throughput(throughputHost);
111
125
 
126
+ function describeKey() {
127
+ const finished = reducedMotion.matches ? "Just finished, summed up at the end of the line" : "Just finished";
128
+ refill(mapKey, keyItem("move", "Running"), keyItem("back", "Compensating"), keyItem("idle", "Stalled"),
129
+ keyItem("stop", "Failed"), keyItem("replay", finished));
130
+ }
131
+ describeKey();
132
+ reducedMotion.addEventListener("change", describeKey);
133
+
112
134
  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.`;
135
+ 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. Between refreshes, finished executions run on to how they ended, and ones that started and finished in between replay their path in a lighter shade. Red bars are failures in ${range.phrase}. Point at a stop for exact counts, select it to list its executions.`;
114
136
  throughputNote.textContent = `Executions per ${range.per} in ${range.phrase}, stacked by how they ended. The line counts starts.`;
115
137
  attentionMore.href = `#/executions?status=failed&range=${range.key}`;
116
138
  }
@@ -140,6 +162,28 @@ export function overview(root, { me, params }) {
140
162
  let expected = new Map();
141
163
  let first = true;
142
164
  let destroyed = false;
165
+ // Server time of the last poll: the next one asks what finished since.
166
+ let serverNow = null;
167
+ // The interval in force at the last poll, and whether polling was paused
168
+ // or the tab hidden since: a longer gap than that is a resume.
169
+ let lastInterval = pollMs();
170
+ let paused = !isLive();
171
+ let away = document.hidden;
172
+ const offLive = onLive(() => { if (!isLive()) paused = true; });
173
+ const markAway = () => { if (document.hidden) away = true; };
174
+ document.addEventListener("visibilitychange", markAway);
175
+ const handled = new Map();
176
+ const probe = { continued: 0, replayed: 0, collapsed: 0 };
177
+
178
+ function remember(id) {
179
+ handled.set(id, true);
180
+ if (handled.size > HANDLED_KEPT) handled.delete(handled.keys().next().value);
181
+ }
182
+
183
+ function tally(key, n) {
184
+ probe[key] += n;
185
+ mapHost.dataset[key] = String(probe[key]);
186
+ }
143
187
 
144
188
  const lineFor = (saga) => definitions.get(saga);
145
189
  const stepIndex = (saga, step) => {
@@ -161,7 +205,7 @@ export function overview(root, { me, params }) {
161
205
  const spec = { id: token.id, line: token.saga, rail: back ? "back" : "fwd", target: token.index };
162
206
  if (token.exit === "completed") return { ...spec, rail: "fwd", target: n, kind: "move", leave: true, creep: null, linger: 0.2 };
163
207
  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 };
208
+ if (token.exit === "failed") return { ...spec, kind: "stop", leave: true, creep: null, linger: 1.6, flash: true };
165
209
  if (token.exit) return { ...spec, leave: true, creep: null };
166
210
  const ms = expected.get(`${token.saga}|${token.step}|${token.direction}`) || MEDIAN_FALLBACK_MS;
167
211
  return {
@@ -185,11 +229,114 @@ export function overview(root, { me, params }) {
185
229
  map.setTokens(specs, partialStations);
186
230
  }
187
231
 
232
+ // A token seen moving that has since finished runs on from where it is.
233
+ function finishToken(token, execution) {
234
+ const back = execution.status === "compensation_failed";
235
+ token.exit = execution.status === "compensation_failed" ? "failed" : execution.status;
236
+ if (token.exit === "failed") {
237
+ token.index = stepIndex(token.saga, back ? execution.back_to : execution.reached);
238
+ token.direction = back ? "compensate" : "execute";
239
+ }
240
+ setTimeout(() => { tokens.delete(token.id); }, 4000);
241
+ }
242
+
243
+ // The whole path of an execution that started and finished between polls.
244
+ function replaySpec(execution, def) {
245
+ const n = def.steps.length;
246
+ const reached = stepIndex(execution.saga_name, execution.reached);
247
+ const backTo = stepIndex(execution.saga_name, execution.back_to);
248
+ if (execution.status === "completed") return { legs: [{ rail: "fwd", to: n }], end: "done" };
249
+ if (reached < 0) return null;
250
+ if (execution.status === "failed") return { legs: [{ rail: "fwd", to: reached }], end: "stop" };
251
+ if (execution.status === "compensated") return { legs: [{ rail: "fwd", to: reached }, { rail: "back", to: -1 }], end: "done" };
252
+ if (execution.status === "compensation_failed" && backTo >= 0) {
253
+ return { legs: [{ rail: "fwd", to: reached }, { rail: "back", to: backTo }], end: "stop" };
254
+ }
255
+ return null;
256
+ }
257
+
258
+ // Executions that finished since the last poll: the ones seen moving run
259
+ // on to their end, the others replay quickly, staggered over the poll in
260
+ // the order they started, at most REPLAY_PER_LINE a line. After a pause
261
+ // only the last poll interval is animated; the rest of each line is a
262
+ // count at its end, and with reduced motion every line just sums up how
263
+ // its executions ended. Counts cover what finished since `since`; the
264
+ // executions listed from the overlap before it are drawn, not counted.
265
+ function finishLines(recent, since, gapLimit) {
266
+ const now = toTime(recent.now);
267
+ const interval = pollMs();
268
+ const resumed = now - since > gapLimit;
269
+ const cut = resumed ? now - interval : -Infinity;
270
+ const reduce = reducedMotion.matches;
271
+ const counted = (execution) => toTime(execution.finished_at) >= since;
272
+ const replays = [];
273
+ const notes = [];
274
+ const pending = [];
275
+ for (const line of recent.lines) {
276
+ const def = lineFor(line.saga_name);
277
+ if (!def || !def.steps.length) continue;
278
+ const counts = { ...line.counts };
279
+ let total = line.total;
280
+ let continued = 0;
281
+ let shown = 0;
282
+ const fresh = [];
283
+ for (const execution of line.executions) {
284
+ if (handled.has(execution.id)) {
285
+ if (counted(execution)) {
286
+ counts[execution.status] = Math.max(0, (counts[execution.status] || 0) - 1);
287
+ total--;
288
+ }
289
+ continue;
290
+ }
291
+ remember(execution.id);
292
+ const token = tokens.get(execution.id);
293
+ if (token && !token.exit) {
294
+ finishToken(token, execution);
295
+ continued++;
296
+ if (counted(execution)) shown++;
297
+ } else {
298
+ fresh.push(execution);
299
+ }
300
+ }
301
+ tally("continued", continued);
302
+ if (reduce) {
303
+ const parts = ENDINGS.filter(([status]) => counts[status]).map(([status, words, tone]) => ({ text: `${status === "completed" ? "+" : ""}${count(counts[status])} ${words}`, tone }));
304
+ if (parts.length) notes.push({ line: line.saga_name, parts });
305
+ continue;
306
+ }
307
+ const animated = fresh.filter((execution) => toTime(execution.finished_at) >= cut)
308
+ .map((execution) => ({ execution, spec: replaySpec(execution, def) }))
309
+ .filter(({ spec }) => spec)
310
+ .sort((a, b) => toTime(a.execution.started_at) - toTime(b.execution.started_at) || (a.execution.id < b.execution.id ? -1 : 1))
311
+ .slice(0, REPLAY_PER_LINE);
312
+ const spread = Math.max(0, Math.min(interval, now - Math.max(since, cut)) - REPLAY_MS);
313
+ animated.forEach(({ execution, spec }, index) => {
314
+ replays.push({ ...spec, id: execution.id, line: line.saga_name, duration: REPLAY_MS, delay: (index / Math.max(1, animated.length)) * spread });
315
+ });
316
+ tally("replayed", animated.length);
317
+ shown += animated.filter(({ execution }) => counted(execution)).length;
318
+ pending.push({ line: line.saga_name, left: Math.max(0, total - shown) });
319
+ }
320
+ const words = !resumed ? "just finished" : paused ? "finished while paused" : away ? "finished while away" : "finished earlier";
321
+ return { replays, notes, pending, resumed, words };
322
+ }
323
+
324
+ // The "+N" pill of each line, less the tokens that settle() runs out.
325
+ function finishNotes(finished, settledByLine) {
326
+ for (const { line, left: unsettled } of finished.pending) {
327
+ const left = Math.max(0, unsettled - (settledByLine.get(line) || 0));
328
+ if (!left) continue;
329
+ if (finished.resumed) tally("collapsed", left);
330
+ finished.notes.push({ line, parts: [{ text: `+${count(left)} ${finished.words}`, tone: "pale" }] });
331
+ }
332
+ }
333
+
188
334
  async function settle(id) {
189
335
  const token = tokens.get(id);
336
+ remember(id);
190
337
  try {
191
338
  const { execution } = await api.execution(id);
192
- token.exit = ["completed", "compensated", "failed", "compensation_failed"].includes(execution.status)
339
+ token.exit = TERMINAL.includes(execution.status)
193
340
  ? (execution.status === "compensation_failed" ? "failed" : execution.status)
194
341
  : "gone";
195
342
  token.index = stepIndex(token.saga, execution.current_step);
@@ -358,12 +505,15 @@ export function overview(root, { me, params }) {
358
505
 
359
506
  async function refresh() {
360
507
  const asked = range;
361
- const [sagaData, stalledData, movingData] = await Promise.all([
508
+ const since = serverNow;
509
+ const [sagaData, stalledData, movingData, recentData] = await Promise.all([
362
510
  api.sagas({ from: rangeFrom(asked) }),
363
511
  api.stalled({ limit: WAITING_SHOWN }),
364
- api.moving({ per_station: PER_STATION, stations: STATIONS_FETCHED })
512
+ api.moving({ per_station: PER_STATION, stations: STATIONS_FETCHED }),
513
+ since === null ? null : api.recent({ since: new Date(since).toISOString(), replay_from: new Date(since - OVERLAP_MS).toISOString(), per_saga: REPLAY_PER_LINE }).catch(() => null)
365
514
  ]);
366
515
  if (destroyed || asked !== range) return;
516
+ serverNow = toTime(recentData ? recentData.now : movingData.now) || serverNow;
367
517
  rememberSagas(sagaData.sagas);
368
518
  definitions = new Map(sagaData.sagas.map((saga) => [saga.name, saga]));
369
519
 
@@ -416,9 +566,16 @@ export function overview(root, { me, params }) {
416
566
  return `${saga}|${stepIndex(saga, step)}`;
417
567
  }));
418
568
 
569
+ const gapLimit = lastInterval * 2;
570
+ lastInterval = pollMs();
571
+ const finished = recentData ? finishLines(recentData, since, gapLimit) : { replays: [], notes: [], pending: [] };
572
+ paused = !isLive();
573
+ away = document.hidden;
574
+
419
575
  // Tokens that left a fully listed station are looked up to show how they
420
576
  // ended; at a partial one they just drop out.
421
577
  let settling = 0;
578
+ const settledByLine = new Map();
422
579
  for (const [id, token] of tokens) {
423
580
  if (seen.has(id) || token.exit || token.settling) continue;
424
581
  if (partial.has(`${token.saga}|${token.step}`) || settling >= SETTLE_PER_POLL) {
@@ -427,9 +584,13 @@ export function overview(root, { me, params }) {
427
584
  }
428
585
  token.settling = true;
429
586
  settling++;
587
+ settledByLine.set(token.saga, (settledByLine.get(token.saga) || 0) + 1);
430
588
  settle(id);
431
589
  }
590
+ finishNotes(finished, settledByLine);
432
591
  sync();
592
+ map.replay(finished.replays);
593
+ if (finished.notes.length) map.setFinished(finished.notes, Math.max(NOTE_MIN_MS, pollMs()));
433
594
  first = false;
434
595
  }
435
596
 
@@ -458,6 +619,9 @@ export function overview(root, { me, params }) {
458
619
  destroy() {
459
620
  destroyed = true;
460
621
  stops.forEach((stop) => stop());
622
+ offLive();
623
+ document.removeEventListener("visibilitychange", markAway);
624
+ reducedMotion.removeEventListener("change", describeKey);
461
625
  map.destroy();
462
626
  throughput.destroy();
463
627
  }
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: lepus-saga
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.3.0
4
+ version: 0.3.2
5
5
  platform: ruby
6
6
  authors:
7
7
  - Marcos G. Zimmermann