lepus 0.2.2 → 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.
- checksums.yaml +4 -4
- data/CHANGELOG.md +25 -0
- data/Gemfile.lock +3 -3
- data/README.md +104 -9
- data/bin/web-demo +592 -0
- data/docs/brand/BRAND.md +67 -0
- data/docs/brand/apple-touch-icon.png +0 -0
- data/docs/brand/favicon-16.png +0 -0
- data/docs/brand/favicon-32.png +0 -0
- data/docs/brand/favicon.ico +0 -0
- data/docs/brand/favicon.svg +1 -0
- data/docs/brand/icon-512.png +0 -0
- data/docs/brand/lockup-lepus-mono.svg +1 -0
- data/docs/brand/lockup-lepus-on-dark.svg +1 -0
- data/docs/brand/lockup-lepus-on-light.svg +1 -0
- data/docs/brand/lockup-lepus-saga-mono.svg +1 -0
- data/docs/brand/lockup-lepus-saga-on-dark.svg +1 -0
- data/docs/brand/lockup-lepus-saga-on-light.svg +1 -0
- data/docs/brand/lockup-lepus-saga.svg +1 -0
- data/docs/brand/lockup-lepus.svg +1 -0
- data/docs/brand/mark-mono.svg +1 -0
- data/docs/brand/mark-on-dark.svg +1 -0
- data/docs/brand/mark-on-light.svg +1 -0
- data/docs/brand/mark-small-mono.svg +1 -0
- data/docs/brand/mark-small-on-dark.svg +1 -0
- data/docs/brand/mark-small-on-light.svg +1 -0
- data/docs/brand/mark-small.svg +1 -0
- data/docs/brand/mark.svg +1 -0
- data/docs/configuration.md +1 -0
- data/docs/consumers.md +39 -5
- data/docs/getting-started.md +1 -1
- data/docs/images/lepus-web.png +0 -0
- data/docs/middleware.md +1 -1
- data/docs/producers.md +41 -0
- data/docs/testing.md +67 -48
- data/docs/web.md +39 -13
- data/gemfiles/Gemfile.rails-5.2.lock +2 -2
- data/gemfiles/Gemfile.rails-6.1.lock +4 -4
- data/gemfiles/Gemfile.rails-7.2.lock +1 -1
- data/gemfiles/Gemfile.rails-8.0.lock +1 -1
- data/lib/lepus/configuration.rb +6 -0
- data/lib/lepus/consumer.rb +34 -0
- data/lib/lepus/consumers/config.rb +8 -7
- data/lib/lepus/consumers/stats.rb +15 -2
- data/lib/lepus/delay.rb +180 -0
- data/lib/lepus/primitive/string.rb +2 -12
- data/lib/lepus/producer.rb +6 -1
- data/lib/lepus/producers/definition.rb +3 -1
- data/lib/lepus/publisher.rb +29 -3
- data/lib/lepus/testing/rspec_matchers.rb +20 -1
- data/lib/lepus/testing.rb +2 -0
- data/lib/lepus/version.rb +1 -1
- data/lib/lepus/web/api.rb +45 -0
- data/lib/lepus/web/app.rb +19 -16
- data/lib/lepus/web/management_api.rb +125 -2
- data/lib/lepus/web/topology.rb +341 -0
- data/lib/lepus/web.rb +17 -18
- data/lib/lepus.rb +1 -0
- data/web/assets/app.css +862 -0
- data/web/assets/apple-touch-icon.png +0 -0
- data/web/assets/favicon-32.png +0 -0
- data/web/assets/favicon.svg +1 -0
- data/web/assets/fonts/overpass/OFL.txt +93 -0
- data/web/assets/fonts/overpass/overpass-latin-wght.woff2 +0 -0
- data/web/assets/js/api.js +27 -0
- data/web/assets/js/dom.js +95 -0
- data/web/assets/js/format.js +63 -0
- data/web/assets/js/live.js +74 -0
- data/web/assets/js/main.js +156 -0
- data/web/assets/js/map.js +606 -0
- data/web/assets/js/model.js +262 -0
- data/web/assets/js/rates.js +161 -0
- data/web/assets/js/theme.js +8 -0
- data/web/assets/js/views/connections.js +61 -0
- data/web/assets/js/views/overview.js +229 -0
- data/web/assets/js/views/processes.js +101 -0
- data/web/assets/js/views/queues.js +456 -0
- data/web/index.html +58 -153
- data/web/sw.js +14 -143
- metadata +47 -12
- data/test_offline.html +0 -189
- data/web/assets/css/styles.css +0 -635
- data/web/assets/js/app.js +0 -6
- data/web/assets/js/bootstrap.js +0 -20
- data/web/assets/js/controllers/connection_controller.js +0 -44
- data/web/assets/js/controllers/dashboard_controller.js +0 -499
- data/web/assets/js/controllers/queue_controller.js +0 -17
- data/web/assets/js/controllers/theme_controller.js +0 -31
- data/web/assets/js/offline-manager.js +0 -233
- data/web/assets/js/service-worker-manager.js +0 -65
|
@@ -0,0 +1,606 @@
|
|
|
1
|
+
import { h, s, clear, graft, reducedMotion } from "./dom.js";
|
|
2
|
+
import { compact, count, perSecond, plural, duration } from "./format.js";
|
|
3
|
+
import { isLive, onLive } from "./live.js";
|
|
4
|
+
import { released } from "./model.js";
|
|
5
|
+
|
|
6
|
+
// Draws the broker as a transit map. Each band is an exchange: a terminal on
|
|
7
|
+
// the left with one line per bound queue. A line runs exchange -> queue ->
|
|
8
|
+
// consumer -> the acknowledged terminal on the right. Amber tokens ride each
|
|
9
|
+
// segment at a pace that grows with the log of its message rate; violet ones
|
|
10
|
+
// loop back through the retry queue beneath the line and red ones drop into
|
|
11
|
+
// the error queue. A backlog is a hollow amber bar piling up at the queue.
|
|
12
|
+
// Messages delayed on purpose wait in a teal clock pill tucked under the
|
|
13
|
+
// queue, or under the exchange name when they are released to the exchange.
|
|
14
|
+
|
|
15
|
+
const MAX_TOKENS = 320;
|
|
16
|
+
const RAIL = 13;
|
|
17
|
+
const WAIT = 17;
|
|
18
|
+
const WAIT_LINE = 18;
|
|
19
|
+
const WIDE = 1100;
|
|
20
|
+
|
|
21
|
+
const visualRate = (rate) => (rate > 0 ? Math.min(2.6, 0.2 + 0.6 * Math.log10(1 + rate)) : 0);
|
|
22
|
+
const charsFor = (px, size = 7.2) => Math.max(4, Math.floor(px / size));
|
|
23
|
+
|
|
24
|
+
function clock(x, y) {
|
|
25
|
+
return s("g", { class: "m-clock" },
|
|
26
|
+
s("circle", { cx: x, cy: y, r: 4.5 }),
|
|
27
|
+
s("path", { d: `M${x} ${y - 2.6}V${y}h2.2` }));
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
const pillWidth = (text) => text.length * 6.4 + 27;
|
|
31
|
+
|
|
32
|
+
// A pill with a clock face: messages waiting out a delay.
|
|
33
|
+
function waitPill(x, y, text, title) {
|
|
34
|
+
return s("g", { class: "m-wait" },
|
|
35
|
+
title ? s("title", {}, title) : null,
|
|
36
|
+
s("rect", { class: "m-wait-box", x, y: y - 8, width: pillWidth(text), height: 16, rx: 8 }),
|
|
37
|
+
clock(x + 10.5, y),
|
|
38
|
+
s("text", { class: "m-wait-n", x: x + 19, y: y + 4 }, text));
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
function fit(text, chars) {
|
|
42
|
+
if (text.length <= chars) return text;
|
|
43
|
+
return `${text.slice(0, Math.max(1, chars - 1))}…`;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
export class TopologyMap {
|
|
47
|
+
constructor(host, { label, onPick, onToggle }) {
|
|
48
|
+
this.host = host;
|
|
49
|
+
this.onPick = onPick;
|
|
50
|
+
this.onToggle = onToggle;
|
|
51
|
+
this.model = null;
|
|
52
|
+
this.width = 0;
|
|
53
|
+
this.flows = new Map();
|
|
54
|
+
this.tokens = [];
|
|
55
|
+
this.frame = null;
|
|
56
|
+
this.visible = true;
|
|
57
|
+
this.uid = `tm${Math.random().toString(36).slice(2, 8)}`;
|
|
58
|
+
this.svg = s("svg", { role: "group", "aria-label": label });
|
|
59
|
+
this.tokenLayer = s("g", { "aria-hidden": "true" });
|
|
60
|
+
this.tip = h("div", { class: "m-tip", role: "tooltip", hidden: true });
|
|
61
|
+
host.append(this.svg, this.tip);
|
|
62
|
+
this.tickBound = (time) => this.tick(time);
|
|
63
|
+
this.offLive = onLive(() => this.wake());
|
|
64
|
+
this.onVisibility = () => this.wake();
|
|
65
|
+
document.addEventListener("visibilitychange", this.onVisibility);
|
|
66
|
+
this.onMotion = () => this.render();
|
|
67
|
+
reducedMotion.addEventListener("change", this.onMotion);
|
|
68
|
+
this.resizeObserver = new ResizeObserver(() => this.resize());
|
|
69
|
+
this.resizeObserver.observe(host);
|
|
70
|
+
this.viewObserver = new IntersectionObserver((entries) => {
|
|
71
|
+
this.visible = entries.some((entry) => entry.isIntersecting);
|
|
72
|
+
this.wake();
|
|
73
|
+
});
|
|
74
|
+
this.viewObserver.observe(host);
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
destroy() {
|
|
78
|
+
this.resizeObserver.disconnect();
|
|
79
|
+
this.viewObserver.disconnect();
|
|
80
|
+
this.offLive();
|
|
81
|
+
document.removeEventListener("visibilitychange", this.onVisibility);
|
|
82
|
+
reducedMotion.removeEventListener("change", this.onMotion);
|
|
83
|
+
cancelAnimationFrame(this.frame);
|
|
84
|
+
this.frame = null;
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
set(model) {
|
|
88
|
+
this.model = model;
|
|
89
|
+
this.render();
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
resize() {
|
|
93
|
+
const width = Math.round(this.host.clientWidth);
|
|
94
|
+
if (width && width !== this.width) {
|
|
95
|
+
this.width = width;
|
|
96
|
+
if (this.model) this.render();
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
// Geometry
|
|
101
|
+
|
|
102
|
+
layout() {
|
|
103
|
+
const width = this.width || Math.round(this.host.clientWidth) || 1000;
|
|
104
|
+
const wide = width >= WIDE;
|
|
105
|
+
const nameCol = wide ? Math.min(260, Math.max(180, Math.round(width * 0.14))) : 0;
|
|
106
|
+
const capL = nameCol + 14;
|
|
107
|
+
const capR = width - 12;
|
|
108
|
+
const span = capR - capL;
|
|
109
|
+
const geo = {
|
|
110
|
+
width, wide, nameCol, capL, capR,
|
|
111
|
+
qx: Math.round(capL + span * (wide ? 0.4 : 0.44)),
|
|
112
|
+
cx: Math.round(capL + span * (wide ? 0.72 : 0.74)),
|
|
113
|
+
rowH: wide ? 56 : 66,
|
|
114
|
+
speed: wide ? 150 : 90,
|
|
115
|
+
bands: []
|
|
116
|
+
};
|
|
117
|
+
let y = 50;
|
|
118
|
+
for (const band of this.model.bands.filter((candidate) => candidate.rows.length)) {
|
|
119
|
+
const head = wide ? 0 : 30;
|
|
120
|
+
const top = y + head;
|
|
121
|
+
let rowTop = top + (wide ? 0 : 6);
|
|
122
|
+
const rows = band.rows.map((row) => {
|
|
123
|
+
const placed = { row, y: rowTop + geo.rowH / 2 };
|
|
124
|
+
rowTop += geo.rowH + (!wide && row.delayed > 0 ? WAIT_LINE : 0);
|
|
125
|
+
return placed;
|
|
126
|
+
});
|
|
127
|
+
const bottom = rowTop - (wide ? 0 : 6);
|
|
128
|
+
geo.bands.push({ band, top: y, rows, bottom });
|
|
129
|
+
y = bottom + (wide ? 26 : 22);
|
|
130
|
+
}
|
|
131
|
+
geo.height = Math.max(y - 10, 120);
|
|
132
|
+
return geo;
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
// Static drawing, redone on every poll; tokens live in their own layer.
|
|
136
|
+
|
|
137
|
+
render() {
|
|
138
|
+
if (!this.model) return;
|
|
139
|
+
const active = this.svg.contains(document.activeElement) ? document.activeElement : null;
|
|
140
|
+
const kept = this.stations;
|
|
141
|
+
const geo = this.layout();
|
|
142
|
+
this.geo = geo;
|
|
143
|
+
const svg = this.svg;
|
|
144
|
+
const fresh = s("svg");
|
|
145
|
+
svg.setAttribute("viewBox", `0 0 ${geo.width} ${geo.height}`);
|
|
146
|
+
svg.setAttribute("width", geo.width);
|
|
147
|
+
svg.setAttribute("height", geo.height);
|
|
148
|
+
this.base = s("g");
|
|
149
|
+
this.stations = s("g");
|
|
150
|
+
this.over = s("g");
|
|
151
|
+
fresh.append(this.gradients(), this.base, this.stations, this.tokenLayer, this.over);
|
|
152
|
+
|
|
153
|
+
this.drawHead(geo);
|
|
154
|
+
if (geo.wide && geo.bands.length) {
|
|
155
|
+
const first = geo.bands[0].rows[0];
|
|
156
|
+
const last = geo.bands[geo.bands.length - 1].rows.slice(-1)[0];
|
|
157
|
+
if (first && last) this.drawCap(geo.capR, first.y - 18, last.y + 18);
|
|
158
|
+
}
|
|
159
|
+
const flows = new Map();
|
|
160
|
+
for (const bandGeo of geo.bands) this.drawBand(geo, bandGeo, flows);
|
|
161
|
+
this.swapIn(svg, fresh, active, kept);
|
|
162
|
+
this.setFlows(flows);
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
find(key) {
|
|
166
|
+
return key ? [...this.stations.querySelectorAll("[data-key]")].find((node) => node.dataset.key === key) : null;
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
// Distance from the top of the drawing to the band that holds a station.
|
|
170
|
+
bandTop(key) {
|
|
171
|
+
const placed = this.geo && this.geo.bands.find((bandGeo) => bandGeo.rows.some(({ row }) => row.key === key));
|
|
172
|
+
return placed ? placed.top : null;
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
// Marks a station for a moment; the mark outlives the redraws in between.
|
|
176
|
+
flash(key) {
|
|
177
|
+
clearTimeout(this.flashTimer);
|
|
178
|
+
this.flashKey = key;
|
|
179
|
+
const mark = (on) => { const node = this.find(key); if (node) node.classList.toggle("is-flash", on); };
|
|
180
|
+
mark(true);
|
|
181
|
+
this.flashTimer = setTimeout(() => { mark(false); this.flashKey = null; }, 2400);
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
// Swaps the new drawing in. A focused station stays the same element, only
|
|
185
|
+
// brought up to date, so a poll neither moves focus nor makes a screen
|
|
186
|
+
// reader announce it again.
|
|
187
|
+
swapIn(svg, fresh, active, kept) {
|
|
188
|
+
const match = this.find(active ? active.dataset.key : this.tipKey);
|
|
189
|
+
if (active && match && graft(svg, fresh, active, match)) {
|
|
190
|
+
this.stations = kept;
|
|
191
|
+
active.tip = match.tip;
|
|
192
|
+
active.anchor = match.anchor;
|
|
193
|
+
if (!this.tip.hidden) this.showTip(active);
|
|
194
|
+
return;
|
|
195
|
+
}
|
|
196
|
+
clear(svg).append(...fresh.childNodes);
|
|
197
|
+
if (active && match) match.focus({ preventScroll: true });
|
|
198
|
+
else if (match && !active) this.showTip(match);
|
|
199
|
+
else this.hideTip();
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
drawHead(geo) {
|
|
203
|
+
const { totals } = this.model;
|
|
204
|
+
const left = s("text", { x: geo.capL - 9, y: 22 },
|
|
205
|
+
s("tspan", { class: "m-cap-num" }, perSecond(totals.publish)),
|
|
206
|
+
s("tspan", { class: "m-cap-label", dx: 6 }, "published"));
|
|
207
|
+
const right = s("text", { x: geo.capR + 9, y: 22, "text-anchor": "end" },
|
|
208
|
+
s("tspan", { class: "m-cap-num" }, perSecond(totals.ack)),
|
|
209
|
+
s("tspan", { class: "m-cap-label", dx: 6 }, "acknowledged"));
|
|
210
|
+
this.base.append(left, right);
|
|
211
|
+
if (geo.wide) {
|
|
212
|
+
this.base.append(
|
|
213
|
+
s("text", { class: "m-cap-label", x: 0, y: 22 }, "Exchange"),
|
|
214
|
+
s("text", { class: "m-cap-label", x: geo.qx - 9, y: 22 }, "Queue"),
|
|
215
|
+
s("text", { class: "m-cap-label", x: geo.cx - 9, y: 22 }, "Consumer"));
|
|
216
|
+
}
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
drawCap(x, top, bottom, quiet = false) {
|
|
220
|
+
this.base.append(s("rect", { class: quiet ? "m-cap is-quiet" : "m-cap", x: x - 9, y: top, width: 18, height: Math.max(34, bottom - top), rx: 9 }));
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
drawBand(geo, { band, top, rows }, flows) {
|
|
224
|
+
if (!rows.length) return;
|
|
225
|
+
const firstY = rows[0].y;
|
|
226
|
+
const lastY = rows[rows.length - 1].y;
|
|
227
|
+
const meta = [band.type].filter(Boolean);
|
|
228
|
+
if (band.rate >= 0.01) meta.push(`${perSecond(band.rate)} in`);
|
|
229
|
+
const delayed = band.delayed && band.delayed.messages ? band.delayed : null;
|
|
230
|
+
const waits = delayed ? released(delayed, `the ${band.name} exchange`) : null;
|
|
231
|
+
const title = s("title", {}, [`${band.name}, ${meta.join(", ")}`, waits].filter(Boolean).join(". "));
|
|
232
|
+
if (geo.wide) {
|
|
233
|
+
const chars = charsFor(geo.nameCol - 8, 8.8);
|
|
234
|
+
this.base.append(
|
|
235
|
+
s("text", { class: band.quiet ? "m-ex is-quiet" : "m-ex", x: 0, y: firstY - 2 }, title, fit(band.name, chars)),
|
|
236
|
+
s("text", { class: "m-meta", x: 0, y: firstY + 17 }, fit(meta.join(", "), charsFor(geo.nameCol - 8, 6.6))));
|
|
237
|
+
if (delayed) this.over.append(waitPill(0, firstY + 33, `${compact(delayed.messages)} delayed`, waits));
|
|
238
|
+
} else {
|
|
239
|
+
this.base.append(s("text", { x: geo.capL - 9, y: top + 18 }, title,
|
|
240
|
+
s("tspan", { class: band.quiet ? "m-ex is-quiet" : "m-ex" }, fit(band.name, charsFor(geo.width * 0.55, 8.8))),
|
|
241
|
+
s("tspan", { class: "m-meta", dx: 8 }, meta.join(", ")),
|
|
242
|
+
delayed ? s("tspan", { class: "m-meta m-wait-n", dx: 8 }, `${compact(delayed.messages)} delayed`) : null));
|
|
243
|
+
}
|
|
244
|
+
for (const { row, y } of rows) {
|
|
245
|
+
if (row.kind === "queue") this.drawQueue(geo, band, row, y, flows);
|
|
246
|
+
else if (row.kind === "exchange") this.drawExchangeRow(geo, band, row, y, flows);
|
|
247
|
+
else this.drawFold(geo, band, row, y);
|
|
248
|
+
}
|
|
249
|
+
this.drawCap(geo.capL, firstY - 18, lastY + 18, band.quiet);
|
|
250
|
+
if (!geo.wide) this.drawCap(geo.capR, firstY - 18, lastY + 18, band.quiet);
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
line(x1, x2, y, cls) {
|
|
254
|
+
this.base.append(s("path", { class: cls, d: `M${x1} ${y}H${x2}` }));
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
drawQueue(geo, band, row, y, flows) {
|
|
258
|
+
const { capL, capR, qx, cx, wide } = geo;
|
|
259
|
+
const q = row.queue;
|
|
260
|
+
const noConsumer = q.consumers === 0;
|
|
261
|
+
const quiet = row.quiet;
|
|
262
|
+
this.line(capL, qx, y, quiet ? "m-line is-quiet" : "m-line");
|
|
263
|
+
this.line(qx, cx, y, quiet ? "m-line is-quiet" : noConsumer ? "m-line is-future" : "m-line");
|
|
264
|
+
this.line(cx, capR, y, quiet ? "m-line is-quiet" : noConsumer ? "m-line is-future" : "m-line");
|
|
265
|
+
|
|
266
|
+
let barW = 0;
|
|
267
|
+
if (row.backlog > 0) {
|
|
268
|
+
const text = compact(row.backlog);
|
|
269
|
+
const room = (qx - capL) * 0.5;
|
|
270
|
+
barW = Math.max(text.length * 7.4 + 14, Math.min(room, 22 + 24 * Math.log10(row.backlog)));
|
|
271
|
+
const x = qx - 12 - barW;
|
|
272
|
+
this.over.append(
|
|
273
|
+
s("rect", { class: "m-backlog", x, y: y - 8, width: barW, height: 16, rx: 8 }),
|
|
274
|
+
s("text", { class: "m-backlog-n", x: x + barW / 2, y: y + 4, "text-anchor": "middle" }, text));
|
|
275
|
+
}
|
|
276
|
+
|
|
277
|
+
if (wide && row.delayed > 0) {
|
|
278
|
+
const full = `${compact(row.delayed)} delayed`;
|
|
279
|
+
const room = qx - 22 - (capL + 18);
|
|
280
|
+
const text = pillWidth(full) <= room ? full : compact(row.delayed);
|
|
281
|
+
const x = qx - 22 - pillWidth(text);
|
|
282
|
+
if (x >= capL + 18) {
|
|
283
|
+
this.base.append(s("path", { class: "m-wait-hook", d: `M${qx - 22} ${y + WAIT}H${qx - 9}q7 0 7-7` }));
|
|
284
|
+
this.over.append(waitPill(x, y + WAIT, text, released(row.delay, row.name)));
|
|
285
|
+
}
|
|
286
|
+
}
|
|
287
|
+
|
|
288
|
+
const keysX = capL + 18;
|
|
289
|
+
if (wide && (row.keys.length || row.also.length) && band.type !== "fanout") {
|
|
290
|
+
const parts = [];
|
|
291
|
+
if (row.keys.length) parts.push(row.keys.join(", "));
|
|
292
|
+
if (row.also.length) parts.push(`also ${row.also.join(", ")}`);
|
|
293
|
+
const room = qx - barW - 40 - keysX;
|
|
294
|
+
if (room > 40) this.base.append(s("text", { class: "m-keys", x: keysX, y: y - 11 }, fit(parts.join("; "), charsFor(room, 6.4))));
|
|
295
|
+
}
|
|
296
|
+
|
|
297
|
+
let errorX = null;
|
|
298
|
+
if (row.retry) {
|
|
299
|
+
const active = row.retry.messages > 0 || row.rates.retry > 0;
|
|
300
|
+
this.base.append(s("path", { class: active ? "m-rail is-active" : "m-rail", d: `M${cx - 10} ${y + RAIL}H${qx + 10}` }));
|
|
301
|
+
if (row.retry.messages > 0) {
|
|
302
|
+
const text = `${compact(row.retry.messages)} retrying`;
|
|
303
|
+
const w = text.length * 6.4 + 14;
|
|
304
|
+
const mid = (qx + cx) / 2;
|
|
305
|
+
this.over.append(
|
|
306
|
+
s("rect", { class: "m-retry", x: mid - w / 2, y: y + RAIL - 8, width: w, height: 16, rx: 8 }),
|
|
307
|
+
s("text", { class: "m-retry-n", x: mid, y: y + RAIL + 4, "text-anchor": "middle" }, text));
|
|
308
|
+
}
|
|
309
|
+
flows.set(`${row.key}|retry`, { x1: cx - 12, x2: qx + 12, y: y + RAIL, rate: row.rates.retry, kind: "back" });
|
|
310
|
+
}
|
|
311
|
+
if (row.error && row.error.messages > 0) {
|
|
312
|
+
errorX = wide ? Math.round(cx + Math.min(150, (capR - cx) * 0.45)) : capR - 28;
|
|
313
|
+
const n = row.error.messages;
|
|
314
|
+
const size = Math.min(24, 8 + 6 * Math.log10(Math.max(1, n)));
|
|
315
|
+
this.base.append(s("path", { class: "m-spur", d: `M${cx + 8} ${y + RAIL}H${errorX}` }));
|
|
316
|
+
this.over.append(
|
|
317
|
+
s("rect", { class: "m-block", x: errorX - 3, y: y + 6, width: 6, height: size, rx: 2 }),
|
|
318
|
+
s("text", { class: "m-block-n", x: wide ? errorX + 8 : capR - 14, y: wide ? y + RAIL + 5 : y + 34, "text-anchor": wide ? "start" : "end" }, `${compact(n)} in error`));
|
|
319
|
+
flows.set(`${row.key}|error`, { x1: cx + 10, x2: errorX - 6, y: y + RAIL, rate: row.rates.error, kind: "stop" });
|
|
320
|
+
}
|
|
321
|
+
|
|
322
|
+
flows.set(`${row.key}|in`, { x1: capL + 12, x2: qx - 14 - barW, y, rate: row.rates.in, kind: "move" });
|
|
323
|
+
if (!noConsumer) {
|
|
324
|
+
flows.set(`${row.key}|out`, { x1: qx + 12, x2: cx - 10, y, rate: row.rates.out, kind: "move" });
|
|
325
|
+
flows.set(`${row.key}|ack`, { x1: cx + 10, x2: capR - 12, y, rate: row.rates.ack, kind: "move" });
|
|
326
|
+
}
|
|
327
|
+
|
|
328
|
+
const states = ["m-station", quiet ? "is-quiet" : "", row.rising ? "is-backlog" : ""].join(" ").trim();
|
|
329
|
+
const consumerStates = ["m-station", quiet ? "is-quiet" : "", noConsumer ? "is-idle" : "", row.stopped ? "is-stopped" : ""].join(" ").trim();
|
|
330
|
+
const group = s("a", { class: row.key === this.flashKey ? "m-row is-flash" : "m-row", href: "#queues", "data-key": row.key, "aria-label": this.describe(band, row).join(". ") });
|
|
331
|
+
const hit = s("rect", { class: "m-hit", x: qx - 16, y: y - 26, width: capR - qx + 16, height: 46, rx: 6 });
|
|
332
|
+
const station = s("g", { class: states },
|
|
333
|
+
s("circle", { class: "m-dot", cx: qx, cy: y, r: 7 }));
|
|
334
|
+
const consumer = s("g", { class: consumerStates },
|
|
335
|
+
s("circle", { class: "m-halo", cx, cy: y, r: 15 }),
|
|
336
|
+
s("circle", { class: "m-dot", cx, cy: y, r: 6 }));
|
|
337
|
+
const consumerText = row.consumerLabel || "no consumer";
|
|
338
|
+
let name;
|
|
339
|
+
let consumerLabel = null;
|
|
340
|
+
if (wide) {
|
|
341
|
+
const sub = quiet || row.rates.in < 0.01 ? "" : ` ${perSecond(row.rates.in)}`;
|
|
342
|
+
name = s("text", { class: "m-name", x: qx + 14, y: y - 11 },
|
|
343
|
+
fit(row.name, charsFor(cx - qx - 40 - sub.length * 6.4, 7.4)), sub ? s("tspan", { class: "m-sub" }, sub) : null);
|
|
344
|
+
const ackSub = row.rates.ack >= 0.01 && !quiet ? ` ${perSecond(row.rates.ack)}` : "";
|
|
345
|
+
consumerLabel = s("text", {
|
|
346
|
+
class: ["m-consumer", row.consumerLabel ? "" : "is-missing", row.stopped ? "is-stopped" : ""].join(" ").trim(),
|
|
347
|
+
x: cx + 14,
|
|
348
|
+
y: y - 11
|
|
349
|
+
}, fit(consumerText, charsFor(capR - cx - 30 - ackSub.length * 6.4, 7)), ackSub ? s("tspan", { class: "m-sub" }, ackSub) : null);
|
|
350
|
+
} else {
|
|
351
|
+
const room = capR - capL - 30;
|
|
352
|
+
const shownName = fit(row.name, charsFor(room * 0.6, 7.4));
|
|
353
|
+
const rest = charsFor(room - shownName.length * 7.4 - 20, 6.6);
|
|
354
|
+
name = s("text", { class: "m-name", x: capL + 16, y: y - 16 }, shownName,
|
|
355
|
+
rest > 5 ? s("tspan", { class: row.stopped ? "m-sub m-sub-stop" : "m-sub" }, ` → ${fit(consumerText, rest)}`) : null);
|
|
356
|
+
if (!quiet && (row.rates.in >= 0.01 || row.rates.ack >= 0.01)) {
|
|
357
|
+
consumerLabel = s("text", { class: "m-meta", x: capL + 16, y: y + 24 }, `${perSecond(row.rates.in)} in, ${perSecond(row.rates.ack)} acked`);
|
|
358
|
+
}
|
|
359
|
+
if (row.delayed > 0) {
|
|
360
|
+
const text = `${compact(row.delayed)} delayed`;
|
|
361
|
+
const after = `after ${row.delay.queues.filter((kid) => kid.messages > 0).map((kid) => duration(kid.delay_ms)).join(" or ")}`;
|
|
362
|
+
const x = capL + 16 + pillWidth(text) + 8;
|
|
363
|
+
this.over.append(waitPill(capL + 16, y + 24 + WAIT_LINE - 4, text, released(row.delay, row.name)));
|
|
364
|
+
this.base.append(s("text", { class: "m-meta", x, y: y + 24 + WAIT_LINE }, fit(after, charsFor(capR - x - (errorX === null ? 12 : 90), 6.6))));
|
|
365
|
+
}
|
|
366
|
+
}
|
|
367
|
+
group.append(...[hit, station, consumer, name, consumerLabel].filter(Boolean));
|
|
368
|
+
this.bindRow(group, band, row);
|
|
369
|
+
this.stations.append(group);
|
|
370
|
+
}
|
|
371
|
+
|
|
372
|
+
drawExchangeRow(geo, band, row, y, flows) {
|
|
373
|
+
const { capL, qx, wide } = geo;
|
|
374
|
+
this.line(capL, qx, y, row.quiet ? "m-line is-quiet" : "m-line");
|
|
375
|
+
this.base.append(s("rect", { class: row.quiet ? "m-cap is-quiet" : "m-cap", x: qx - 6, y: y - 12, width: 12, height: 24, rx: 6 }));
|
|
376
|
+
const keys = row.keys.length ? ` by ${row.keys.join(", ")}` : "";
|
|
377
|
+
this.base.append(s("text", { class: "m-name", x: wide ? qx + 14 : capL + 16, y: wide ? y + 4 : y - 16 },
|
|
378
|
+
s("title", {}, `${band.name} routes to the ${row.name} exchange${keys}`),
|
|
379
|
+
`to ${fit(row.name, 40)}`, s("tspan", { class: "m-sub" }, " exchange")));
|
|
380
|
+
flows.set(`${row.key}|in`, { x1: capL + 12, x2: qx - 10, y, rate: row.rates.in, kind: "move" });
|
|
381
|
+
}
|
|
382
|
+
|
|
383
|
+
drawFold(geo, band, row, y) {
|
|
384
|
+
const { capL, qx, wide } = geo;
|
|
385
|
+
const more = row.kind === "more";
|
|
386
|
+
if (more) this.line(capL, qx - 16, y, "m-line is-quiet");
|
|
387
|
+
let text;
|
|
388
|
+
if (!more) text = "Show fewer";
|
|
389
|
+
else if (row.quietCount === row.count) text = `+${count(row.count)} quiet ${row.count === 1 ? "queue" : "queues"}`;
|
|
390
|
+
else text = `+${count(row.count)} more ${row.count === 1 ? "queue" : "queues"}`;
|
|
391
|
+
const w = text.length * 6.9 + 22;
|
|
392
|
+
const x = wide || !more ? qx - 12 : capL + 22;
|
|
393
|
+
const label = more ? `${text}${row.quietCount && row.quietCount !== row.count ? `, ${row.quietCount} of them quiet` : ""}. Show them` : `Show fewer queues for ${band.name}`;
|
|
394
|
+
const button = s("g", { class: "m-more", role: "button", tabindex: 0, "aria-expanded": String(!more), "aria-label": label, "data-key": row.key },
|
|
395
|
+
s("rect", { class: "m-more-box", x, y: y - 11, width: w, height: 22, rx: 11 }),
|
|
396
|
+
s("text", { class: "m-more-n", x: x + w / 2, y: y + 4, "text-anchor": "middle" }, text));
|
|
397
|
+
const toggle = () => this.onToggle(band.key);
|
|
398
|
+
button.addEventListener("click", toggle);
|
|
399
|
+
button.addEventListener("keydown", (event) => {
|
|
400
|
+
if (event.key === "Enter" || event.key === " ") {
|
|
401
|
+
event.preventDefault();
|
|
402
|
+
toggle();
|
|
403
|
+
}
|
|
404
|
+
});
|
|
405
|
+
this.stations.append(button);
|
|
406
|
+
if (more) {
|
|
407
|
+
const bits = [];
|
|
408
|
+
if (row.backlog) bits.push(`${compact(row.backlog)} ready`);
|
|
409
|
+
if (row.rates.in >= 0.01) bits.push(`${perSecond(row.rates.in)} in`);
|
|
410
|
+
if (row.errors) bits.push(`${compact(row.errors)} in error`);
|
|
411
|
+
if (row.delayed) bits.push(`${compact(row.delayed)} delayed`);
|
|
412
|
+
if (bits.length) this.base.append(s("text", { class: "m-meta", x: x + w + 10, y: y + 4 }, bits.join(", ")));
|
|
413
|
+
}
|
|
414
|
+
}
|
|
415
|
+
|
|
416
|
+
describe(band, row) {
|
|
417
|
+
const q = row.queue;
|
|
418
|
+
const lines = [row.name];
|
|
419
|
+
const bound = band.key ? `From ${band.name}${row.keys.length ? ` by ${row.keys.join(", ")}` : ""}` : "Published straight to the queue";
|
|
420
|
+
lines.push(row.also.length ? `${bound}, also from ${row.also.join(", ")}` : bound);
|
|
421
|
+
const held = [`${count(q.ready)} ready`, `${count(q.unacked)} unacked`];
|
|
422
|
+
if (q.backlog_rate > 0.05) held.push(`growing ${perSecond(q.backlog_rate)}`);
|
|
423
|
+
else if (q.backlog_rate < -0.05 && q.ready) held.push(`draining ${perSecond(-q.backlog_rate)}`);
|
|
424
|
+
lines.push(held.join(", "));
|
|
425
|
+
lines.push(`Published ${perSecond(q.publish_rate)}, delivered ${perSecond(q.deliver_rate)}, acknowledged ${perSecond(q.ack_rate)}`);
|
|
426
|
+
if (q.consumers === 0) {
|
|
427
|
+
lines.push("No consumer is subscribed");
|
|
428
|
+
} else {
|
|
429
|
+
const names = row.consumers.map((c) => c.class_name || c.connection_name).filter(Boolean);
|
|
430
|
+
lines.push(`${plural(q.consumers, "consumer channel")}${names.length ? `: ${names.join(", ")}` : ""}`);
|
|
431
|
+
}
|
|
432
|
+
if (row.stopped) lines.push("Errors in the last few minutes");
|
|
433
|
+
if (row.retry) lines.push(`Retry ${row.retry.name}: ${count(row.retry.messages)} waiting${row.retry.ttl ? ` ${duration(row.retry.ttl)}` : ""}`);
|
|
434
|
+
if (row.error) lines.push(`Error ${row.error.name}: ${plural(row.error.messages, "message")}`);
|
|
435
|
+
if (row.delay.queues.length) lines.push(row.delayed ? released(row.delay, row.name) : `Delay queues for ${row.delay.queues.map((kid) => duration(kid.delay_ms)).join(", ")}, empty`);
|
|
436
|
+
for (const consumer of row.consumers) {
|
|
437
|
+
if (!consumer.delays || !consumer.delays.length) continue;
|
|
438
|
+
const after = consumer.delays.map(duration).join(" or ");
|
|
439
|
+
lines.push(`${consumer.class_name} hands messages back with delay! after ${after}${consumer.delayed ? `, ${count(consumer.delayed)} so far` : ""}`);
|
|
440
|
+
}
|
|
441
|
+
return lines;
|
|
442
|
+
}
|
|
443
|
+
|
|
444
|
+
bindRow(group, band, row) {
|
|
445
|
+
const show = () => this.showTip(group);
|
|
446
|
+
const hide = () => this.hideTip();
|
|
447
|
+
group.addEventListener("pointerenter", show);
|
|
448
|
+
group.addEventListener("focus", show);
|
|
449
|
+
group.addEventListener("pointerleave", hide);
|
|
450
|
+
group.addEventListener("blur", hide);
|
|
451
|
+
group.addEventListener("keydown", (event) => { if (event.key === "Escape") hide(); });
|
|
452
|
+
group.addEventListener("click", (event) => {
|
|
453
|
+
event.preventDefault();
|
|
454
|
+
this.onPick(row.name);
|
|
455
|
+
});
|
|
456
|
+
group.tip = () => this.describe(band, row);
|
|
457
|
+
const y = Number(group.querySelector("circle").getAttribute("cy"));
|
|
458
|
+
group.anchor = () => ({ x: this.geo.qx, y });
|
|
459
|
+
}
|
|
460
|
+
|
|
461
|
+
showTip(group) {
|
|
462
|
+
if (!group.tip) return;
|
|
463
|
+
this.tipKey = group.dataset.key;
|
|
464
|
+
const [head, ...rest] = group.tip();
|
|
465
|
+
clear(this.tip).append(h("b", {}, head), ...rest.map((line) => h("span", {}, line)));
|
|
466
|
+
this.tip.hidden = false;
|
|
467
|
+
const p = group.anchor();
|
|
468
|
+
const hostW = this.host.clientWidth;
|
|
469
|
+
const tipW = this.tip.offsetWidth;
|
|
470
|
+
const tipH = this.tip.offsetHeight;
|
|
471
|
+
const left = Math.max(0, Math.min(hostW - tipW, p.x - 24));
|
|
472
|
+
const above = p.y - 34 - tipH;
|
|
473
|
+
this.tip.style.left = `${Math.round(left)}px`;
|
|
474
|
+
this.tip.style.top = `${Math.round(above >= -40 ? above : p.y + 30)}px`;
|
|
475
|
+
}
|
|
476
|
+
|
|
477
|
+
hideTip() {
|
|
478
|
+
this.tipKey = null;
|
|
479
|
+
this.tip.hidden = true;
|
|
480
|
+
}
|
|
481
|
+
|
|
482
|
+
gradients() {
|
|
483
|
+
const defs = s("defs");
|
|
484
|
+
for (const kind of ["move", "back", "stop"]) {
|
|
485
|
+
for (const dir of ["f", "b"]) {
|
|
486
|
+
defs.append(s("linearGradient", { id: `${this.uid}-${kind}-${dir}`, x1: dir === "f" ? 0 : 1, x2: dir === "f" ? 1 : 0, y1: 0, y2: 0 },
|
|
487
|
+
s("stop", { offset: 0, class: `g-trail-${kind}`, "stop-opacity": 0 }),
|
|
488
|
+
s("stop", { offset: 1, class: `g-trail-${kind}`, "stop-opacity": 0.9 })));
|
|
489
|
+
}
|
|
490
|
+
}
|
|
491
|
+
return defs;
|
|
492
|
+
}
|
|
493
|
+
|
|
494
|
+
// Motion
|
|
495
|
+
|
|
496
|
+
setFlows(next) {
|
|
497
|
+
const fresh = this.flows.size === 0;
|
|
498
|
+
for (const [key, flow] of next) {
|
|
499
|
+
const old = this.flows.get(key);
|
|
500
|
+
flow.acc = old ? old.acc : Math.random();
|
|
501
|
+
flow.length = Math.max(1, Math.abs(flow.x2 - flow.x1));
|
|
502
|
+
if (!old && !fresh) flow.seed = true;
|
|
503
|
+
if (fresh) flow.seed = true;
|
|
504
|
+
}
|
|
505
|
+
this.flows = next;
|
|
506
|
+
this.tokens = this.tokens.filter((token) => {
|
|
507
|
+
if (this.flows.has(token.flow)) return true;
|
|
508
|
+
token.el.remove();
|
|
509
|
+
token.trail.remove();
|
|
510
|
+
return false;
|
|
511
|
+
});
|
|
512
|
+
if (reducedMotion.matches) {
|
|
513
|
+
this.drawStill();
|
|
514
|
+
return;
|
|
515
|
+
}
|
|
516
|
+
this.tokens = this.tokens.filter((token) => {
|
|
517
|
+
if (!token.still) return true;
|
|
518
|
+
token.el.remove();
|
|
519
|
+
token.trail.remove();
|
|
520
|
+
return false;
|
|
521
|
+
});
|
|
522
|
+
for (const [key, flow] of this.flows) {
|
|
523
|
+
if (!flow.seed) continue;
|
|
524
|
+
flow.seed = false;
|
|
525
|
+
const pace = visualRate(flow.rate) * (this.geo.wide ? 1 : 0.7);
|
|
526
|
+
const n = Math.floor((pace * flow.length) / this.geo.speed);
|
|
527
|
+
for (let i = 0; i < n && this.tokens.length < MAX_TOKENS; i++) this.spawn(key, flow, (i + Math.random() * 0.6) / Math.max(1, n));
|
|
528
|
+
}
|
|
529
|
+
this.place();
|
|
530
|
+
this.wake();
|
|
531
|
+
}
|
|
532
|
+
|
|
533
|
+
drawStill() {
|
|
534
|
+
for (const token of this.tokens) {
|
|
535
|
+
token.el.remove();
|
|
536
|
+
token.trail.remove();
|
|
537
|
+
}
|
|
538
|
+
this.tokens = [];
|
|
539
|
+
for (const [key, flow] of this.flows) {
|
|
540
|
+
if (flow.rate > 0) this.spawn(key, flow, 0.5, true);
|
|
541
|
+
}
|
|
542
|
+
this.place();
|
|
543
|
+
}
|
|
544
|
+
|
|
545
|
+
spawn(key, flow, t = 0, still = false) {
|
|
546
|
+
const el = s("rect", { class: flow.kind === "move" ? "t-token" : `t-token is-${flow.kind}`, width: 18, height: 10, rx: 5 });
|
|
547
|
+
const trail = s("rect", { height: 4, width: 42, rx: 2, fill: `url(#${this.uid}-${flow.kind}-${flow.x2 >= flow.x1 ? "f" : "b"})` });
|
|
548
|
+
if (still) trail.setAttribute("visibility", "hidden");
|
|
549
|
+
this.tokenLayer.append(trail, el);
|
|
550
|
+
this.tokens.push({ flow: key, t, el, trail, still });
|
|
551
|
+
}
|
|
552
|
+
|
|
553
|
+
place() {
|
|
554
|
+
for (const token of this.tokens) {
|
|
555
|
+
const flow = this.flows.get(token.flow);
|
|
556
|
+
if (!flow) continue;
|
|
557
|
+
const dir = flow.x2 >= flow.x1 ? 1 : -1;
|
|
558
|
+
const x = flow.x1 + (flow.x2 - flow.x1) * token.t;
|
|
559
|
+
token.el.setAttribute("x", (x - 9).toFixed(1));
|
|
560
|
+
token.el.setAttribute("y", flow.y - 5);
|
|
561
|
+
const tx = dir > 0 ? x - 9 - 38 : x + 9 - 4;
|
|
562
|
+
const visible = Math.abs(x - flow.x1) > 30;
|
|
563
|
+
token.trail.setAttribute("x", tx.toFixed(1));
|
|
564
|
+
token.trail.setAttribute("y", flow.y - 2);
|
|
565
|
+
token.trail.setAttribute("visibility", visible && !token.still ? "visible" : "hidden");
|
|
566
|
+
const edge = Math.min(token.t, 1 - token.t);
|
|
567
|
+
const opacity = token.still ? 1 : Math.max(0, Math.min(1, edge * 12));
|
|
568
|
+
token.el.setAttribute("opacity", opacity.toFixed(2));
|
|
569
|
+
token.trail.setAttribute("opacity", opacity.toFixed(2));
|
|
570
|
+
}
|
|
571
|
+
}
|
|
572
|
+
|
|
573
|
+
wake() {
|
|
574
|
+
if (this.frame === null && !reducedMotion.matches) this.frame = requestAnimationFrame(this.tickBound);
|
|
575
|
+
}
|
|
576
|
+
|
|
577
|
+
tick(now) {
|
|
578
|
+
this.frame = null;
|
|
579
|
+
if (!isLive() || document.hidden || !this.visible || !this.geo) {
|
|
580
|
+
this.lastTime = null;
|
|
581
|
+
return;
|
|
582
|
+
}
|
|
583
|
+
const dt = Math.min(0.1, (now - (this.lastTime || now)) / 1000);
|
|
584
|
+
this.lastTime = now;
|
|
585
|
+
const scale = this.geo.wide ? 1 : 0.7;
|
|
586
|
+
for (const [key, flow] of this.flows) {
|
|
587
|
+
const pace = visualRate(flow.rate) * scale;
|
|
588
|
+
if (!pace) continue;
|
|
589
|
+
flow.acc += dt * pace * (0.6 + Math.random() * 0.8);
|
|
590
|
+
while (flow.acc >= 1) {
|
|
591
|
+
flow.acc -= 1;
|
|
592
|
+
if (this.tokens.length < MAX_TOKENS) this.spawn(key, flow);
|
|
593
|
+
}
|
|
594
|
+
}
|
|
595
|
+
this.tokens = this.tokens.filter((token) => {
|
|
596
|
+
const flow = this.flows.get(token.flow);
|
|
597
|
+
token.t += (dt * this.geo.speed) / flow.length;
|
|
598
|
+
if (token.t < 1) return true;
|
|
599
|
+
token.el.remove();
|
|
600
|
+
token.trail.remove();
|
|
601
|
+
return false;
|
|
602
|
+
});
|
|
603
|
+
this.place();
|
|
604
|
+
this.frame = requestAnimationFrame(this.tickBound);
|
|
605
|
+
}
|
|
606
|
+
}
|