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.
Files changed (90) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +25 -0
  3. data/Gemfile.lock +3 -3
  4. data/README.md +104 -9
  5. data/bin/web-demo +592 -0
  6. data/docs/brand/BRAND.md +67 -0
  7. data/docs/brand/apple-touch-icon.png +0 -0
  8. data/docs/brand/favicon-16.png +0 -0
  9. data/docs/brand/favicon-32.png +0 -0
  10. data/docs/brand/favicon.ico +0 -0
  11. data/docs/brand/favicon.svg +1 -0
  12. data/docs/brand/icon-512.png +0 -0
  13. data/docs/brand/lockup-lepus-mono.svg +1 -0
  14. data/docs/brand/lockup-lepus-on-dark.svg +1 -0
  15. data/docs/brand/lockup-lepus-on-light.svg +1 -0
  16. data/docs/brand/lockup-lepus-saga-mono.svg +1 -0
  17. data/docs/brand/lockup-lepus-saga-on-dark.svg +1 -0
  18. data/docs/brand/lockup-lepus-saga-on-light.svg +1 -0
  19. data/docs/brand/lockup-lepus-saga.svg +1 -0
  20. data/docs/brand/lockup-lepus.svg +1 -0
  21. data/docs/brand/mark-mono.svg +1 -0
  22. data/docs/brand/mark-on-dark.svg +1 -0
  23. data/docs/brand/mark-on-light.svg +1 -0
  24. data/docs/brand/mark-small-mono.svg +1 -0
  25. data/docs/brand/mark-small-on-dark.svg +1 -0
  26. data/docs/brand/mark-small-on-light.svg +1 -0
  27. data/docs/brand/mark-small.svg +1 -0
  28. data/docs/brand/mark.svg +1 -0
  29. data/docs/configuration.md +1 -0
  30. data/docs/consumers.md +39 -5
  31. data/docs/getting-started.md +1 -1
  32. data/docs/images/lepus-web.png +0 -0
  33. data/docs/middleware.md +1 -1
  34. data/docs/producers.md +41 -0
  35. data/docs/testing.md +67 -48
  36. data/docs/web.md +39 -13
  37. data/gemfiles/Gemfile.rails-5.2.lock +2 -2
  38. data/gemfiles/Gemfile.rails-6.1.lock +4 -4
  39. data/gemfiles/Gemfile.rails-7.2.lock +1 -1
  40. data/gemfiles/Gemfile.rails-8.0.lock +1 -1
  41. data/lib/lepus/configuration.rb +6 -0
  42. data/lib/lepus/consumer.rb +34 -0
  43. data/lib/lepus/consumers/config.rb +8 -7
  44. data/lib/lepus/consumers/stats.rb +15 -2
  45. data/lib/lepus/delay.rb +180 -0
  46. data/lib/lepus/primitive/string.rb +2 -12
  47. data/lib/lepus/producer.rb +6 -1
  48. data/lib/lepus/producers/definition.rb +3 -1
  49. data/lib/lepus/publisher.rb +29 -3
  50. data/lib/lepus/testing/rspec_matchers.rb +20 -1
  51. data/lib/lepus/testing.rb +2 -0
  52. data/lib/lepus/version.rb +1 -1
  53. data/lib/lepus/web/api.rb +45 -0
  54. data/lib/lepus/web/app.rb +19 -16
  55. data/lib/lepus/web/management_api.rb +125 -2
  56. data/lib/lepus/web/topology.rb +341 -0
  57. data/lib/lepus/web.rb +17 -18
  58. data/lib/lepus.rb +1 -0
  59. data/web/assets/app.css +862 -0
  60. data/web/assets/apple-touch-icon.png +0 -0
  61. data/web/assets/favicon-32.png +0 -0
  62. data/web/assets/favicon.svg +1 -0
  63. data/web/assets/fonts/overpass/OFL.txt +93 -0
  64. data/web/assets/fonts/overpass/overpass-latin-wght.woff2 +0 -0
  65. data/web/assets/js/api.js +27 -0
  66. data/web/assets/js/dom.js +95 -0
  67. data/web/assets/js/format.js +63 -0
  68. data/web/assets/js/live.js +74 -0
  69. data/web/assets/js/main.js +156 -0
  70. data/web/assets/js/map.js +606 -0
  71. data/web/assets/js/model.js +262 -0
  72. data/web/assets/js/rates.js +161 -0
  73. data/web/assets/js/theme.js +8 -0
  74. data/web/assets/js/views/connections.js +61 -0
  75. data/web/assets/js/views/overview.js +229 -0
  76. data/web/assets/js/views/processes.js +101 -0
  77. data/web/assets/js/views/queues.js +456 -0
  78. data/web/index.html +58 -153
  79. data/web/sw.js +14 -143
  80. metadata +47 -12
  81. data/test_offline.html +0 -189
  82. data/web/assets/css/styles.css +0 -635
  83. data/web/assets/js/app.js +0 -6
  84. data/web/assets/js/bootstrap.js +0 -20
  85. data/web/assets/js/controllers/connection_controller.js +0 -44
  86. data/web/assets/js/controllers/dashboard_controller.js +0 -499
  87. data/web/assets/js/controllers/queue_controller.js +0 -17
  88. data/web/assets/js/controllers/theme_controller.js +0 -31
  89. data/web/assets/js/offline-manager.js +0 -233
  90. 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
+ }