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.
- checksums.yaml +4 -4
- data/CHANGELOG.md +13 -0
- data/Gemfile.lock +2 -2
- data/README.md +10 -8
- data/lib/lepus/saga/execution_behaviour.rb +10 -1
- data/lib/lepus/saga/executor.rb +4 -4
- data/lib/lepus/saga/rspec/engine_examples.rb +8 -1
- data/lib/lepus/saga/rspec/query_examples.rb +187 -15
- data/lib/lepus/saga/rspec/store_examples.rb +8 -7
- data/lib/lepus/saga/stores/base.rb +89 -18
- data/lib/lepus/saga/stores/memory.rb +35 -10
- data/lib/lepus/saga/version.rb +1 -1
- data/lib/lepus/saga/web/api.rb +53 -4
- data/lib/lepus/saga/web/serializer.rb +14 -1
- data/lib/lepus/saga.rb +1 -1
- data/web/assets/app.css +45 -1
- data/web/assets/js/api.js +1 -0
- data/web/assets/js/dom.js +1 -1
- data/web/assets/js/map.js +142 -10
- data/web/assets/js/views/execution.js +3 -3
- data/web/assets/js/views/executions.js +95 -21
- data/web/assets/js/views/overview.js +171 -7
- metadata +1 -1
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 (
|
|
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
|
-
|
|
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.
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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", {
|
|
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
|
-
|
|
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
|
|
70
|
+
let version = 0;
|
|
59
71
|
|
|
60
|
-
const
|
|
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" : "",
|
|
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
|
-
|
|
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
|
-
|
|
94
|
-
h("th", { scope: "col" },
|
|
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 }
|
|
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)) :
|
|
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
|
|
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,
|
|
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 (
|
|
142
|
-
const
|
|
143
|
-
|
|
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.
|
|
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
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
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
|
}
|