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.
data/web/assets/js/map.js CHANGED
@@ -7,7 +7,8 @@ import { isLive, now as clock, onLive } from "./live.js";
7
7
  // are the stations, n is the end terminal. Tokens ride the main line ("fwd")
8
8
  // or the compensation rail beneath it ("back"). Tokens heading out of the
9
9
  // same station share up to three lanes; past SEGMENT_SHOWN the rest ride as
10
- // one "+N" pill.
10
+ // one "+N" pill. Executions that finished between two polls without being
11
+ // seen live replay their whole path quickly, in a lighter shade.
11
12
 
12
13
  const RAIL_OFFSET = 12;
13
14
  const LANES = { fwd: [0, -8, -16], back: [RAIL_OFFSET, RAIL_OFFSET + 7, RAIL_OFFSET + 14] };
@@ -16,6 +17,8 @@ const SEGMENT_SHOWN = 8;
16
17
  const CREEP_MIN = 0.1;
17
18
  const CREEP_MAX = 0.9;
18
19
  const CREEP_FRAME_MS = 66;
20
+ const FADE_IN_MS = 150;
21
+ const FLASH_S = 1.4;
19
22
 
20
23
  export class LineMap {
21
24
  constructor(host, { mode = "lines", label = "Saga lines" } = {}) {
@@ -24,6 +27,7 @@ export class LineMap {
24
27
  this.label = label;
25
28
  this.lines = [];
26
29
  this.tokens = new Map();
30
+ this.finished = new Map();
27
31
  this.width = 0;
28
32
  this.frame = null;
29
33
  this.svg = s("svg", { role: "group", "aria-label": label });
@@ -41,6 +45,7 @@ export class LineMap {
41
45
  this.offLive();
42
46
  cancelAnimationFrame(this.frame);
43
47
  clearTimeout(this.slowTimer);
48
+ clearTimeout(this.finishedTimer);
44
49
  this.frame = null;
45
50
  }
46
51
 
@@ -75,17 +80,52 @@ export class LineMap {
75
80
  seen.add(spec.id);
76
81
  const token = this.tokens.get(spec.id);
77
82
  if (token) {
83
+ // A token that stops moving leaves from where it was drawn, not from
84
+ // where its lane had held it back.
85
+ if (spec.leave && !token.leave && token.shownPos !== undefined) token.pos = token.shownPos;
78
86
  Object.assign(token, { leave: false }, spec);
79
87
  } else {
80
88
  this.tokens.set(spec.id, { pos: spec.from ?? spec.target, opacity: spec.from !== undefined && !reducedMotion.matches ? 0 : 1, ...spec });
81
89
  }
82
90
  }
83
91
  for (const [id, token] of this.tokens) {
84
- if (!seen.has(id) && !token.leave) token.leave = true;
92
+ if (seen.has(id) || token.leave || token.replay) continue;
93
+ if (token.shownPos !== undefined) token.pos = token.shownPos;
94
+ token.leave = true;
85
95
  }
86
96
  this.wake();
87
97
  }
88
98
 
99
+ // Executions that started and finished between two polls. Each runs its
100
+ // legs ([{rail, to}], from the start terminal) in `duration` ms after
101
+ // `delay` ms, then ends "done" (fades out) or "stop" (flashes, then fades).
102
+ replay(specs) {
103
+ const start = performance.now();
104
+ for (const spec of specs) {
105
+ if (this.tokens.has(spec.id)) continue;
106
+ this.tokens.set(spec.id, {
107
+ id: spec.id, line: spec.line, pos: -1, target: -1, rail: "fwd", kind: "move", opacity: 0,
108
+ replay: { legs: spec.legs, end: spec.end, duration: spec.duration, start: start + (spec.delay || 0) }
109
+ });
110
+ }
111
+ this.wake();
112
+ }
113
+
114
+ // Notes drawn at the end of a line for `ms`: [{line, parts: [{text, tone}]}],
115
+ // tone being "done", "back", "stop" or "pale".
116
+ setFinished(notes, ms) {
117
+ const until = performance.now() + ms;
118
+ for (const note of notes) this.finished.set(note.line, { ...note, until });
119
+ clearTimeout(this.finishedTimer);
120
+ const expire = () => {
121
+ const now = performance.now();
122
+ for (const [line, note] of this.finished) if (note.until <= now) this.finished.delete(line);
123
+ this.drawTokens();
124
+ if (this.finished.size) this.finishedTimer = setTimeout(expire, Math.max(50, Math.min(...[...this.finished.values()].map((note) => note.until)) - now));
125
+ };
126
+ expire();
127
+ }
128
+
89
129
  // Replay: put one token exactly where the caller says, no animation.
90
130
  place(spec) {
91
131
  this.tokens.clear();
@@ -431,9 +471,18 @@ export class LineMap {
431
471
 
432
472
  for (const [id, token] of this.tokens) {
433
473
  if (token.fixed) continue;
474
+ if (token.replay) {
475
+ if (reduce || !this.advanceReplay(token, now, dt)) this.tokens.delete(id);
476
+ else busy = true;
477
+ continue;
478
+ }
434
479
  const target = this.effectiveTarget(token);
435
480
  const gap = target - token.pos;
436
- if (reduce) {
481
+ const backward = !token.leave && Math.sign(gap) === (token.rail === "back" ? 1 : -1);
482
+ if (backward) {
483
+ // A refresh that lands a live token behind where it is waits for it
484
+ // to catch up rather than pulling it back.
485
+ } else if (reduce) {
437
486
  token.pos = target;
438
487
  } else if (Math.abs(gap) > 0.004) {
439
488
  const speed = Math.max(0.12, Math.min(3, Math.abs(gap) * 2.2));
@@ -476,6 +525,41 @@ export class LineMap {
476
525
  }
477
526
  }
478
527
 
528
+ // Moves a replayed token along its legs; false once it has faded out.
529
+ advanceReplay(token, now, dt) {
530
+ const replay = token.replay;
531
+ const elapsed = now - replay.start;
532
+ if (elapsed < 0) {
533
+ token.opacity = 0;
534
+ return true;
535
+ }
536
+ const total = replay.legs.reduce((sum, leg, index) => sum + Math.abs(leg.to - (index ? replay.legs[index - 1].to : -1)), 0) || 1;
537
+ let along = Math.min(1, elapsed / replay.duration) * total;
538
+ let from = -1;
539
+ for (const [index, leg] of replay.legs.entries()) {
540
+ const length = Math.abs(leg.to - from);
541
+ if (along <= length || index === replay.legs.length - 1) {
542
+ token.pos = from + Math.sign(leg.to - from) * Math.min(along, length);
543
+ token.rail = leg.rail;
544
+ token.kind = leg.rail === "back" ? "back" : "move";
545
+ break;
546
+ }
547
+ along -= length;
548
+ from = leg.to;
549
+ }
550
+ token.opacity = Math.min(1, elapsed / FADE_IN_MS);
551
+ if (elapsed < replay.duration) return true;
552
+
553
+ token.hold = (token.hold || 0) + dt;
554
+ const linger = replay.end === "stop" ? FLASH_S : 0.25;
555
+ if (replay.end === "stop") {
556
+ token.kind = "stop";
557
+ token.flash = true;
558
+ }
559
+ if (token.hold > linger) token.opacity = 1 - (token.hold - linger) * 2.2;
560
+ return token.opacity > 0;
561
+ }
562
+
479
563
  // Tokens leaving the same station on the same rail form a segment group.
480
564
  // The group shows its first SEGMENT_SHOWN tokens (stable by id) in up to
481
565
  // three lanes and folds the rest, plus any the poll did not list, into a pill.
@@ -491,7 +575,9 @@ export class LineMap {
491
575
  const geo = byLine.get(token.line);
492
576
  if (!geo) continue;
493
577
  if (token.kind === "idle" && !token.leave) continue;
494
- if (token.fixed || token.leave || this.layout.single) loose.push({ token, geo });
578
+ if (token.replay) {
579
+ if (token.opacity > 0) loose.push({ token, geo });
580
+ } else if (token.fixed || token.leave || this.layout.single) loose.push({ token, geo });
495
581
  else group(geo, token.rail, token.target).tokens.push(token);
496
582
  }
497
583
  if (!this.layout.single && this.tokensKnown) {
@@ -533,7 +619,9 @@ export class LineMap {
533
619
  const ahead = (p) => (p.x * geo.axis.x + p.y * geo.axis.y) * dir;
534
620
  const floor = ahead(this.point(geo, target)) + 10;
535
621
  const ceiling = Math.max(floor, ahead(this.point(geo, target + dir)) - (geo.vertical ? 22 : 64));
622
+ const segment = Math.max(1, Math.abs(ahead(this.point(geo, target + dir)) - ahead(this.point(geo, target))));
536
623
  const candidates = shown.map((token) => {
624
+ token.shownPos = token.pos;
537
625
  const p = this.point(geo, token.pos);
538
626
  return { token, p, raw: ahead(p), at: Math.min(ahead(p), ceiling) };
539
627
  }).sort((a, b) => b.at - a.at);
@@ -541,19 +629,29 @@ export class LineMap {
541
629
  const placed = [];
542
630
  let folded = 0;
543
631
  for (const item of candidates) {
544
- let lane = front.findIndex((at) => at - item.at >= LANE_GAP);
632
+ // A token keeps its lane and is never drawn behind where it was while
633
+ // it stays on this leg, so a newcomer never pushes it back.
634
+ const held = item.token.drawn;
635
+ const same = held && held.geo === geo && held.target === target && held.rail === rail;
636
+ let lane = same && front[held.lane] - item.at >= LANE_GAP ? held.lane : front.findIndex((at) => at - item.at >= LANE_GAP);
545
637
  let at = item.at;
546
638
  if (lane < 0) {
547
639
  lane = front.indexOf(Math.max(...front));
548
640
  at = front[lane] - LANE_GAP;
549
- if (at < floor) {
641
+ if (at < floor && !same) {
550
642
  folded++;
551
643
  continue;
552
644
  }
553
645
  }
554
- front[lane] = at;
646
+ if (same && at < held.at) {
647
+ at = held.at;
648
+ lane = held.lane;
649
+ }
650
+ item.token.drawn = { geo, target, rail, lane, at };
651
+ front[lane] = Math.min(front[lane], at);
555
652
  const shift = (item.raw - at) * dir;
556
653
  item.p = { x: item.p.x - geo.axis.x * shift, y: item.p.y - geo.axis.y * shift };
654
+ item.token.shownPos = item.token.pos - (item.raw - at) / segment * dir;
557
655
  item.lane = lane;
558
656
  placed.push(item);
559
657
  }
@@ -566,6 +664,27 @@ export class LineMap {
566
664
  }
567
665
  if (rest > 0) this.drawPill(layer, geo, rail, target, rest, hidden);
568
666
  }
667
+
668
+ for (const [name, note] of this.finished) {
669
+ const geo = byLine.get(name);
670
+ if (geo && !this.layout.single) this.drawNote(layer, geo, note);
671
+ }
672
+ }
673
+
674
+ // A "+N just finished" pill, or a summary of how executions ended, by the
675
+ // end terminal of the line.
676
+ drawNote(layer, geo, note) {
677
+ const text = note.parts.map((part) => part.text).join(" · ");
678
+ const w = Math.round(text.length * 6.6 + 18);
679
+ const hgt = 20;
680
+ const cx = geo.vertical ? geo.end.x + 26 + w / 2 : geo.end.x - 14 - w / 2;
681
+ const cy = geo.vertical ? geo.end.y : geo.end.y - 30;
682
+ const label = s("text", { class: "t-note-n", x: cx, y: cy + 4, "text-anchor": "middle" });
683
+ note.parts.forEach((part, index) => {
684
+ if (index) label.append(s("tspan", { class: "t-note-sep" }, " · "));
685
+ label.append(s("tspan", { class: `t-note-${part.tone || "pale"}` }, part.text));
686
+ });
687
+ layer.append(s("rect", { class: "t-note", x: cx - w / 2, y: cy - hgt / 2, width: w, height: hgt, rx: hgt / 2 }), label);
569
688
  }
570
689
 
571
690
  isPartial(geo, index) {
@@ -579,6 +698,15 @@ export class LineMap {
579
698
  const hgt = geo.vertical ? long : short;
580
699
  const opacity = token.opacity < 1 ? Math.max(0, token.opacity).toFixed(3) : null;
581
700
  const moving = token.kind === "move" || token.kind === "back";
701
+ const state = token.replay ? "replay" : token.leave ? "exit" : "live";
702
+ if (token.flash && token.hold) {
703
+ const fade = Math.max(0, 1 - token.hold / FLASH_S);
704
+ const pulse = 0.5 + 0.5 * Math.cos(token.hold * Math.PI * 4);
705
+ layer.append(s("rect", {
706
+ class: "t-flash", x: p.x - w / 2 - 6, y: p.y - hgt / 2 - 6, width: w + 12, height: hgt + 12, rx: short / 2 + 6,
707
+ opacity: (fade * pulse * 0.55 * Math.max(0, Math.min(1, token.opacity))).toFixed(3)
708
+ }));
709
+ }
582
710
  if (moving && trail) {
583
711
  const dir = token.kind === "back" ? -1 : 1;
584
712
  const length = token.big ? 70 : 46;
@@ -587,10 +715,14 @@ export class LineMap {
587
715
  const tw = geo.vertical ? 4 : length;
588
716
  const th = geo.vertical ? length : 4;
589
717
  const grad = `${this.uid}-${geo.vertical ? "v" : "h"}-${dir > 0 ? "f" : "b"}-${token.kind}`;
590
- layer.append(s("rect", { fill: `url(#${grad})`, x: tx - tw / 2, y: ty - th / 2, width: tw, height: th, rx: 2, opacity }));
718
+ const trailOpacity = token.replay ? (Math.max(0, token.opacity) * 0.5).toFixed(3) : opacity;
719
+ layer.append(s("rect", { fill: `url(#${grad})`, x: tx - tw / 2, y: ty - th / 2, width: tw, height: th, rx: 2, opacity: trailOpacity }));
591
720
  }
592
- const cls = ["t-token", token.kind && token.kind !== "move" ? `is-${token.kind}` : "", thin ? "is-thin" : ""].join(" ").trim();
593
- layer.append(s("rect", { class: cls, x: p.x - w / 2, y: p.y - hgt / 2, width: w, height: hgt, rx: short / 2, opacity }));
721
+ const cls = ["t-token", token.kind && token.kind !== "move" ? `is-${token.kind}` : "", thin ? "is-thin" : "", token.replay ? "is-replay" : ""].join(" ").trim();
722
+ layer.append(s("rect", {
723
+ class: cls, x: p.x - w / 2, y: p.y - hgt / 2, width: w, height: hgt, rx: short / 2, opacity,
724
+ "data-token": token.id, "data-state": state
725
+ }));
594
726
  }
595
727
 
596
728
  drawPill(layer, geo, rail, target, rest, hidden) {
@@ -1,5 +1,5 @@
1
1
  import { api } from "../api.js";
2
- import { h, s, clear, toast, copyButton, reducedMotion } from "../dom.js";
2
+ import { h, s, append, clear, toast, copyButton, reducedMotion } from "../dom.js";
3
3
  import { every, pollMs } from "../live.js";
4
4
  import { LineMap } from "../map.js";
5
5
  import { ago, duration, offset, plural, stamp, statusLabel, toTime } from "../format.js";
@@ -338,7 +338,7 @@ export function execution(root, { id, me }) {
338
338
  history.append(...rows);
339
339
  const ctx = h("div", {});
340
340
 
341
- root.append(
341
+ append(root, [
342
342
  h("p", { class: "crumbs" }, h("a", { href: "#/executions" }, "Executions"), " / ",
343
343
  h("a", { href: `#/executions?saga=${encodeURIComponent(execution.saga_name)}` }, execution.saga_name)),
344
344
  h("div", { class: "x-head" },
@@ -365,7 +365,7 @@ export function execution(root, { id, me }) {
365
365
  h("h2", { class: "section-title" }, "Other runs for this resource"),
366
366
  h("ul", { class: "related" }, related.map((other) => h("li", {}, h("a", { href: `#/executions/${encodeURIComponent(other.id)}` },
367
367
  h("span", { class: `state state-${other.status}` }, statusLabel(other.status)), stamp(other.started_at)))))) : null))
368
- );
368
+ ]);
369
369
 
370
370
  els = { play, range, now, rows, ctx };
371
371
  map = new LineMap(mapHost, { mode: "single", label: `${execution.saga_name} line` });
@@ -43,6 +43,15 @@ function glyph(execution, definition) {
43
43
 
44
44
  const shortError = (name) => name.split("::").pop();
45
45
 
46
+ // Only these columns sort: the API pages along either one.
47
+ const SORTS = [
48
+ { key: "started", api: "started_at", label: "Started", desc: "latest first", asc: "earliest first" },
49
+ { key: "took", api: "duration", label: "Took", desc: "longest first", asc: "shortest first" }
50
+ ];
51
+ const PAGE = 40;
52
+ const REFRESH_MAX = 200;
53
+ const ARROWS = { descending: "M2.5 3.5L5 6l2.5-2.5", ascending: "M2.5 6.5L5 4l2.5 2.5", none: "M2.5 3.8L5 1.5l2.5 2.3M2.5 6.2L5 8.5l2.5-2.3" };
54
+
46
55
  export function executions(root, { params, me }) {
47
56
  const stalled = params.get("stalled") === "1";
48
57
  const stallText = duration((me.stalled_after || 900) * 1000);
@@ -52,19 +61,24 @@ export function executions(root, { params, me }) {
52
61
  const step = params.get("step") || "";
53
62
  const error = params.get("error") || "";
54
63
  const errorStep = params.get("error_step") || "";
64
+ const sorts = stalled ? SORTS.slice(0, 1) : SORTS;
65
+ let sort = sorts.find((candidate) => candidate.key === params.get("sort")) || SORTS[0];
66
+ let dir = params.get("dir") === "asc" ? "asc" : "desc";
55
67
  let destroyed = false;
56
68
  let rows = [];
57
69
  let next = null;
58
- let extended = false;
70
+ let version = 0;
59
71
 
60
- const go = (changes) => {
72
+ const hashFor = (changes) => {
61
73
  const query = new URLSearchParams({
62
74
  status: stalled ? "" : group.key, saga, step, error, error_step: errorStep,
63
- range: range ? range.key : "", stalled: stalled ? "1" : "", ...changes
75
+ range: range ? range.key : "", stalled: stalled ? "1" : "",
76
+ sort: sort === SORTS[0] ? "" : sort.key, dir: dir === "desc" ? "" : dir, ...changes
64
77
  });
65
78
  for (const [key, value] of [...query]) if (!value) query.delete(key);
66
- location.hash = `#/executions${query.toString() ? `?${query}` : ""}`;
79
+ return `#/executions${query.toString() ? `?${query}` : ""}`;
67
80
  };
81
+ const go = (changes) => { location.hash = hashFor(changes); };
68
82
 
69
83
  const seg = h("div", { class: "seg", role: "group", "aria-label": "Status" },
70
84
  GROUPS.map((candidate) => h("button", {
@@ -82,28 +96,73 @@ export function executions(root, { params, me }) {
82
96
  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
97
  "failed with ", h("b", {}, errorLabel), h("span", { "aria-hidden": "true" }, "×")) : null;
84
98
 
99
+ const headers = new Map();
100
+ const sortHeader = (candidate) => {
101
+ const path = s("path", {});
102
+ const th = h("th", { scope: "col" },
103
+ h("button", { type: "button", class: "sort", onclick: () => pickSort(candidate, null) }, candidate.label,
104
+ s("svg", { viewBox: "0 0 10 10", width: 10, height: 10, "aria-hidden": "true" }, path)));
105
+ headers.set(candidate.key, { th, path });
106
+ return th;
107
+ };
108
+ const picker = h("select", { class: "select sort-pick", "aria-label": "Sort" },
109
+ sorts.flatMap((candidate) => ["desc", "asc"].map((way) => h("option", { value: `${candidate.key}:${way}` }, `${candidate.label}, ${candidate[way]}`))));
110
+ picker.addEventListener("change", () => {
111
+ const [key, way] = picker.value.split(":");
112
+ pickSort(SORTS.find((candidate) => candidate.key === key), way);
113
+ });
114
+
115
+ const caption = h("caption", { class: "visually-hidden" });
85
116
  const body = h("tbody");
86
117
  const table = h("table", { class: "rows" },
118
+ caption,
87
119
  h("thead", {}, h("tr", {},
88
120
  h("th", { class: "r-glyph", scope: "col" }, h("span", { class: "visually-hidden" }, "Position on the line")),
89
121
  h("th", { scope: "col" }, "Resource"),
90
122
  h("th", { class: "r-saga-col", scope: "col" }, "Saga"),
91
123
  h("th", { scope: "col" }, "Step"),
92
124
  h("th", { scope: "col" }, "Status"),
93
- h("th", { scope: "col" }, "Started"),
94
- h("th", { scope: "col" }, stalled ? "Waiting" : "Took"))),
125
+ sortHeader(SORTS[0]),
126
+ stalled ? h("th", { scope: "col" }, "Waiting") : sortHeader(SORTS[1]))),
95
127
  body);
96
- const more = h("button", { type: "button", class: "btn btn-quiet", hidden: true }, "Show older");
128
+ const more = h("button", { type: "button", class: "btn btn-quiet", hidden: true });
97
129
  const status = h("p", { class: "empty", hidden: true });
98
130
 
99
131
  root.append(
100
132
  h("div", { class: "list-head" },
101
133
  h("h1", { class: "list-title" }, saga ? saga : stalled ? "Stalled executions" : "Executions"),
102
- h("div", { class: "filters" }, seg, select, chip, errorChip, rangeChip, stalledChip)),
134
+ h("div", { class: "filters" }, seg, select, picker, chip, errorChip, rangeChip, stalledChip)),
103
135
  table, status, h("div", { class: "more-row" }, more));
104
136
 
105
137
  let definitions = new Map();
106
138
 
139
+ function showSort() {
140
+ for (const [key, { th, path }] of headers) {
141
+ const state = key === sort.key ? (dir === "desc" ? "descending" : "ascending") : "none";
142
+ if (state === "none") th.removeAttribute("aria-sort");
143
+ else th.setAttribute("aria-sort", state);
144
+ path.setAttribute("d", ARROWS[state]);
145
+ }
146
+ picker.value = `${sort.key}:${dir}`;
147
+ table.classList.toggle("is-by-took", sort.key === "took");
148
+ caption.textContent = `Executions, sorted by ${sort.label.toLowerCase()}, ${sort[dir]}`;
149
+ more.textContent = sort === SORTS[0] && dir === "desc" ? "Show older" : "Show more";
150
+ }
151
+
152
+ // A header sorts by its column, or flips the direction when it already
153
+ // does. The list starts over in the new order; the URL keeps it.
154
+ function pickSort(candidate, way) {
155
+ dir = way || (candidate === sort ? (dir === "desc" ? "asc" : "desc") : "desc");
156
+ sort = candidate;
157
+ history.replaceState(null, "", hashFor({}));
158
+ showSort();
159
+ version++;
160
+ rows = [];
161
+ next = null;
162
+ more.hidden = true;
163
+ refresh().catch(() => {});
164
+ }
165
+
107
166
  function draw() {
108
167
  const now = Date.now();
109
168
  refill(body, rows.map((execution) => {
@@ -117,7 +176,7 @@ export function executions(root, { params, me }) {
117
176
  h("td", { class: "r-step" }, execution.current_step || ""),
118
177
  h("td", { class: "r-state" }, h("span", { class: `state state-${execution.status}` }, statusLabel(execution.status))),
119
178
  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) : ""));
179
+ h("td", { class: "r-took" }, stalled ? waited(now - toTime(execution.last_progress_at)) : took(execution, started, end)));
121
180
  tr.addEventListener("click", (event) => { if (!event.target.closest("a")) location.hash = href; });
122
181
  return tr;
123
182
  }));
@@ -128,38 +187,53 @@ export function executions(root, { params, me }) {
128
187
 
129
188
  // With an error filter the range bounds when the attempt failed, as the
130
189
  // counts under "Needs attention" do, rather than when the execution started.
131
- function query(before) {
190
+ function took(execution, started, end) {
191
+ if (execution.duration_ms !== null && execution.duration_ms !== undefined) return duration(execution.duration_ms);
192
+ return end ? duration(end - started) : "";
193
+ }
194
+
195
+ function query(after, limit = PAGE) {
132
196
  const since = range ? rangeFrom(range) : null;
133
197
  const byError = Boolean(error || errorStep);
134
198
  return {
135
199
  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
200
+ stalled: stalled ? 1 : null, from: byError ? null : since, error_from: byError ? since : null,
201
+ sort: sort.api, dir, after, limit
137
202
  };
138
203
  }
139
204
 
205
+ // Polls reload every row shown so far, in the current order.
140
206
  async function refresh() {
141
- if (extended) return;
142
- const [defs, data] = await Promise.all([sagaDefinitions(), api.executions(query())]);
143
- if (destroyed) return;
207
+ if (rows.length > REFRESH_MAX) return;
208
+ const asked = version;
209
+ const [defs, data] = await Promise.all([sagaDefinitions(), api.executions(query(null, Math.min(REFRESH_MAX, Math.max(PAGE, rows.length))))]);
210
+ if (destroyed || asked !== version) return;
144
211
  definitions = defs;
145
212
  if (select.options.length === 1) {
146
213
  for (const name of defs.keys()) select.append(h("option", { value: name, selected: name === saga }, name));
147
214
  }
148
215
  rows = data.executions;
149
- next = data.next_before;
216
+ next = data.next;
150
217
  draw();
151
218
  }
152
219
 
153
220
  more.addEventListener("click", async () => {
221
+ const asked = version;
154
222
  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();
223
+ try {
224
+ const data = await api.executions(query(next));
225
+ if (destroyed || asked !== version) return;
226
+ const shown = new Set(rows.map((execution) => execution.id));
227
+ rows = rows.concat(data.executions.filter((execution) => !shown.has(execution.id)));
228
+ next = data.next;
229
+ draw();
230
+ } finally {
231
+ more.disabled = false;
232
+ }
161
233
  });
162
234
 
235
+ showSort();
236
+
163
237
  const stop = every(pollMs, refresh);
164
238
  return { destroy() { destroyed = true; stop(); } };
165
239
  }