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,262 @@
1
+ // Turns /api/topology into what the map draws: one band per exchange, one
2
+ // row per queue bound to it (exchange -> queue -> consumer -> acknowledged),
3
+ // with the queue's retry, error and delay queues folded into its row.
4
+ // Delay queues are the ones Lepus declares for delayed publishing and
5
+ // delay!: what waits in them is counted on the queue or the exchange it is
6
+ // released to, never as backlog or retry.
7
+
8
+ import { count, duration } from "./format.js";
9
+
10
+ const ACTIVE = 0.01;
11
+ const QUIET_AFTER_MS = 30_000;
12
+ const ERRORS_SHOW_MS = 300_000;
13
+ const ERROR_SHARE = 0.01;
14
+ export const DEFAULT_BAND = "";
15
+
16
+ const sum = (items, fn) => items.reduce((total, item) => total + (fn(item) || 0), 0);
17
+ const byName = (a, b) => (a.name < b.name ? -1 : a.name > b.name ? 1 : 0);
18
+ const byDelay = (a, b) => (a.delay_ms || 0) - (b.delay_ms || 0) || byName(a, b);
19
+
20
+ // "340 delayed, released after 1 min to emails.digest", or with several
21
+ // delays "272 delayed, released to emails.digest after 1 min (136) or 1 h (136)".
22
+ export function released(delay, target) {
23
+ const shown = delay.queues.filter((queue) => queue.messages > 0);
24
+ const queues = shown.length ? shown : delay.queues;
25
+ const head = `${count(delay.messages)} delayed`;
26
+ if (queues.length === 1) return `${head}, released after ${duration(queues[0].delay_ms)} to ${target}`;
27
+ const parts = queues.map((queue) => `${duration(queue.delay_ms)} (${count(queue.messages)})`);
28
+ return `${head}, released to ${target} after ${parts.slice(0, -1).join(", ")} or ${parts[parts.length - 1]}`;
29
+ }
30
+
31
+ // What waits in a set of delay queues: how many messages, how fast they
32
+ // arrive and each queue with its delay, shortest first.
33
+ export function waiting(delayQueues) {
34
+ const queues = [...delayQueues].sort(byDelay);
35
+ return { messages: sum(queues, (q) => q.messages), rate: sum(queues, (q) => q.publish_rate), queues };
36
+ }
37
+
38
+ export function isMain(queue, queues) {
39
+ if (queue.role === "main") return true;
40
+ if (queue.role === "delay") return false;
41
+ if (queue.parent_exchange) return false;
42
+ return !queues.has(queue.parent);
43
+ }
44
+
45
+ // Growing, or nobody is reading it.
46
+ export const backingUp = (queue) => queue.ready > 0 && (queue.backlog_rate > 0.05 || queue.consumers === 0);
47
+
48
+ export class Memory {
49
+ constructor() {
50
+ this.lastActive = new Map();
51
+ this.errored = new Map();
52
+ }
53
+
54
+ // Consumer counters only grow while a worker lives. When more than one in
55
+ // a hundred messages handled since the last report raised an error, the
56
+ // consumer stays red for a few minutes.
57
+ erroredRecently(consumer, now) {
58
+ const key = `${consumer.queue}|${consumer.class_name || consumer.connection_name}`;
59
+ const handled = consumer.processed + consumer.rejected + consumer.errored + (consumer.delayed || 0);
60
+ const seen = this.errored.get(key);
61
+ if (!seen) {
62
+ this.errored.set(key, { errored: consumer.errored, handled, changedAt: 0 });
63
+ return false;
64
+ }
65
+ const errors = consumer.errored - seen.errored;
66
+ const total = handled - seen.handled;
67
+ if (errors > 0 && total > 0 && errors / total >= ERROR_SHARE) seen.changedAt = now;
68
+ if (total !== 0) Object.assign(seen, { errored: consumer.errored, handled });
69
+ return now - seen.changedAt < ERRORS_SHOW_MS;
70
+ }
71
+
72
+ quiet(key, active, now) {
73
+ if (active) {
74
+ this.lastActive.set(key, now);
75
+ return false;
76
+ }
77
+ const last = this.lastActive.get(key);
78
+ return !last || now - last >= QUIET_AFTER_MS;
79
+ }
80
+ }
81
+
82
+ function consumerLabel(consumers) {
83
+ const named = consumers.filter((c) => c.channels || c.reporting);
84
+ if (!named.length) return null;
85
+ const first = named[0].class_name || named[0].connection_name || "Consumer";
86
+ return named.length > 1 ? `${first} +${named.length - 1}` : first;
87
+ }
88
+
89
+ function buildRow(queue, { keys, also, children, consumers, memory, now }) {
90
+ const kids = children.get(queue.name) || [];
91
+ const retry = kids.find((kid) => kid.role === "retry") || null;
92
+ const error = kids.find((kid) => kid.role === "error") || null;
93
+ const delay = waiting(kids.filter((kid) => kid.role === "delay"));
94
+ const mine = consumers.filter((consumer) => consumer.queue === queue.name);
95
+ const rates = {
96
+ in: queue.publish_rate,
97
+ out: queue.deliver_rate,
98
+ ack: queue.ack_rate,
99
+ retry: retry ? retry.publish_rate : 0,
100
+ error: error ? error.publish_rate : 0,
101
+ delay: delay.rate
102
+ };
103
+ const held = queue.messages + sum(kids, (kid) => kid.messages);
104
+ const active = held > 0 || Object.values(rates).some((value) => value >= ACTIVE);
105
+ const erroring = mine.map((consumer) => memory.erroredRecently(consumer, now)).some(Boolean);
106
+ const stopped = erroring || (rates.error >= 0.05 && rates.error >= rates.out * ERROR_SHARE);
107
+ return {
108
+ kind: "queue",
109
+ key: queue.name,
110
+ name: queue.name,
111
+ queue,
112
+ keys,
113
+ also,
114
+ retry,
115
+ error,
116
+ delayed: delay.messages,
117
+ delay,
118
+ consumers: mine,
119
+ consumerLabel: consumerLabel(mine),
120
+ rates,
121
+ backlog: queue.ready,
122
+ rising: backingUp(queue),
123
+ stopped,
124
+ quiet: memory.quiet(queue.name, active, now),
125
+ score: Math.log10(1 + queue.ready) * 2 + Math.log10(1 + rates.in + rates.out) + Math.log10(1 + delay.messages) + (stopped ? 6 : 0) + (error && error.messages ? 3 : 0)
126
+ };
127
+ }
128
+
129
+ function foldRows(rows, { expanded, limit, band }) {
130
+ const foldable = rows.filter((row) => row.kind === "queue");
131
+ const fixed = rows.filter((row) => row.kind !== "queue");
132
+ const active = foldable.filter((row) => !row.quiet);
133
+ if (expanded) {
134
+ const shown = [...fixed, ...foldable].sort(byName);
135
+ const canFold = active.length < foldable.length || foldable.length > limit;
136
+ return canFold ? [...shown, { kind: "less", key: `${band}|less`, band }] : shown;
137
+ }
138
+ const keep = active.length <= limit ? active : [...active].sort((a, b) => b.score - a.score).slice(0, limit);
139
+ const kept = new Set(keep);
140
+ const rest = foldable.filter((row) => !kept.has(row));
141
+ const shown = [...fixed, ...keep].sort(byName);
142
+ if (!rest.length) return shown;
143
+ const more = {
144
+ kind: "more",
145
+ key: `${band}|more`,
146
+ band,
147
+ count: rest.length,
148
+ quietCount: rest.filter((row) => row.quiet).length,
149
+ backlog: sum(rest, (row) => row.backlog),
150
+ errors: sum(rest, (row) => (row.error ? row.error.messages : 0)),
151
+ delayed: sum(rest, (row) => row.delayed),
152
+ rates: { in: sum(rest, (row) => row.rates.in), out: sum(rest, (row) => row.rates.out) },
153
+ names: rest.map((row) => row.name)
154
+ };
155
+ return [...shown, more];
156
+ }
157
+
158
+ export function buildModel(topology, memory, { now = Date.now(), expanded = new Set(), limit = 6 } = {}) {
159
+ const queues = new Map(topology.queues.map((queue) => [queue.name, queue]));
160
+ const exchanges = new Map(topology.exchanges.map((exchange) => [exchange.name, exchange]));
161
+ const children = new Map();
162
+ for (const queue of topology.queues) {
163
+ if (isMain(queue, queues)) continue;
164
+ if (!children.has(queue.parent)) children.set(queue.parent, []);
165
+ children.get(queue.parent).push(queue);
166
+ }
167
+
168
+ // "<exchange>.delayed" headers exchanges feed the delay queues that
169
+ // release into <exchange>; they draw no band of their own.
170
+ const delayFronts = new Set(topology.exchanges.filter((exchange) => exchange.role === "delay").map((exchange) => exchange.name));
171
+ const delayedByExchange = new Map();
172
+ for (const queue of topology.queues) {
173
+ if (queue.role !== "delay" || !queue.parent_exchange) continue;
174
+ if (!delayedByExchange.has(queue.parent_exchange)) delayedByExchange.set(queue.parent_exchange, []);
175
+ delayedByExchange.get(queue.parent_exchange).push(queue);
176
+ }
177
+
178
+ const sources = new Map();
179
+ for (const binding of topology.bindings) {
180
+ if (delayFronts.has(binding.source)) continue;
181
+ const target = binding.destination_type === "queue" ? queues.get(binding.destination) : null;
182
+ if (binding.destination_type === "queue" && (!target || !isMain(target, queues))) continue;
183
+ const key = `${binding.destination_type}:${binding.destination}`;
184
+ if (!sources.has(key)) sources.set(key, []);
185
+ sources.get(key).push(binding);
186
+ }
187
+
188
+ const bands = new Map();
189
+ const band = (name) => {
190
+ if (!bands.has(name)) {
191
+ const exchange = exchanges.get(name);
192
+ bands.set(name, {
193
+ key: name,
194
+ name: name === DEFAULT_BAND ? "Default exchange" : name,
195
+ type: exchange ? exchange.type || "" : "direct",
196
+ rate: exchange ? exchange.publish_in_rate : 0,
197
+ delayed: delayedByExchange.has(name) ? waiting(delayedByExchange.get(name)) : null,
198
+ rows: []
199
+ });
200
+ }
201
+ return bands.get(name);
202
+ };
203
+ const consumers = topology.consumers || [];
204
+
205
+ for (const [key, list] of sources) {
206
+ const sorted = [...list].sort((a, b) => (a.source < b.source ? -1 : a.source > b.source ? 1 : 0));
207
+ const primary = sorted[0];
208
+ const also = [...new Set(sorted.slice(1).map((binding) => binding.source))];
209
+ const keys = primary.routing_keys || [];
210
+ if (key.startsWith("exchange:")) {
211
+ const target = exchanges.get(primary.destination);
212
+ band(primary.source).rows.push({
213
+ kind: "exchange", key: key, name: primary.destination, keys, also,
214
+ rates: { in: target ? target.publish_in_rate : 0 }, quiet: !(target && target.publish_in_rate >= ACTIVE)
215
+ });
216
+ } else {
217
+ band(primary.source).rows.push(buildRow(queues.get(primary.destination), { keys, also, children, consumers, memory, now }));
218
+ }
219
+ }
220
+
221
+ for (const name of delayedByExchange.keys()) {
222
+ if (exchanges.has(name)) band(name);
223
+ }
224
+
225
+ const routed = new Set([...sources.keys()].filter((key) => key.startsWith("queue:")).map((key) => key.slice(6)));
226
+ for (const queue of topology.queues) {
227
+ if (!isMain(queue, queues) || routed.has(queue.name)) continue;
228
+ band(DEFAULT_BAND).rows.push(buildRow(queue, { keys: [], also: [], children, consumers, memory, now }));
229
+ }
230
+
231
+ const ordered = [...bands.values()].sort((a, b) => (a.key === DEFAULT_BAND) - (b.key === DEFAULT_BAND) || (a.name < b.name ? -1 : a.name > b.name ? 1 : 0));
232
+ for (const entry of ordered) {
233
+ entry.allRows = entry.rows;
234
+ entry.quiet = entry.rows.every((row) => row.quiet) && !(entry.rate >= ACTIVE);
235
+ entry.expanded = expanded.has(entry.key);
236
+ entry.rows = foldRows(entry.rows, { expanded: entry.expanded, limit, band: entry.key });
237
+ }
238
+
239
+ const unbound = topology.exchanges
240
+ .filter((exchange) => !(bands.has(exchange.name) && bands.get(exchange.name).rows.length) && !delayFronts.has(exchange.name))
241
+ .map((exchange) => exchange.name)
242
+ .sort();
243
+
244
+ const mainQueues = topology.queues.filter((queue) => isMain(queue, queues));
245
+ const roles = (role) => topology.queues.filter((queue) => queue.role === role && !isMain(queue, queues));
246
+ const drawnDelays = roles("delay").filter((queue) => queues.has(queue.parent) || bands.has(queue.parent_exchange));
247
+ return {
248
+ bands: ordered,
249
+ unbound,
250
+ totals: {
251
+ publish: sum(topology.exchanges, (e) => (delayFronts.has(e.name) ? 0 : e.publish_in_rate)) + sum(bands.get(DEFAULT_BAND)?.allRows || [], (row) => row.rates.in),
252
+ deliver: sum(mainQueues, (q) => q.deliver_rate),
253
+ ack: sum(mainQueues, (q) => q.ack_rate),
254
+ retry: sum(roles("retry"), (q) => q.publish_rate),
255
+ errors: sum(roles("error"), (q) => q.messages),
256
+ delayed: sum(drawnDelays, (q) => q.messages),
257
+ backingUp: mainQueues.filter(backingUp).length,
258
+ ready: sum(mainQueues, (q) => q.ready),
259
+ queues: mainQueues.length
260
+ }
261
+ };
262
+ }
@@ -0,0 +1,161 @@
1
+ import { h, s, clear } from "./dom.js";
2
+ import { hourMinute, perSecond, rate } from "./format.js";
3
+
4
+ const SERIES = [
5
+ ["publish", "Published"],
6
+ ["deliver", "Delivered"],
7
+ ["ack", "Acknowledged"],
8
+ ["redeliver", "Redelivered"]
9
+ ];
10
+
11
+ function niceTop(value) {
12
+ if (value <= 4) return 4;
13
+ const magnitude = 10 ** Math.floor(Math.log10(value));
14
+ for (const m of [1, 2, 2.5, 3, 4, 5, 6, 8, 10]) if (m * magnitude >= value) return m * magnitude;
15
+ return 10 * magnitude;
16
+ }
17
+
18
+ function ticks(start, end, px) {
19
+ const steps = [30, 60, 120, 300, 600, 900, 1800, 3600].map((seconds) => seconds * 1000);
20
+ const step = steps.find((ms) => (ms / (end - start)) * px >= 84) || steps[steps.length - 1];
21
+ const out = [];
22
+ for (let t = Math.ceil(start / step) * step; t <= end; t += step) out.push(t);
23
+ return out;
24
+ }
25
+
26
+ // Message rates across the broker over the chosen window, from the samples
27
+ // the management API keeps: acknowledged as the filled area, published as
28
+ // the ink line and delivered as the dashed amber one, redeliveries in violet.
29
+ export class RateStrip {
30
+ constructor(host) {
31
+ this.host = host;
32
+ this.totals = h("dl", { class: "tp-totals" });
33
+ this.chart = h("div", { class: "tp-chart" });
34
+ this.readout = h("p", { class: "tp-readout", "aria-hidden": "true" });
35
+ this.tableBody = h("tbody");
36
+ this.caption = h("caption", { class: "visually-hidden" });
37
+ host.append(
38
+ this.totals,
39
+ this.chart,
40
+ h("div", { class: "tp-foot" },
41
+ this.readout,
42
+ h("details", { class: "data-table" },
43
+ h("summary", {}, "Show as table"),
44
+ h("div", { class: "data-scroll" },
45
+ h("table", {},
46
+ this.caption,
47
+ h("thead", {}, h("tr", {},
48
+ h("th", { scope: "col" }, "Until"),
49
+ SERIES.map(([, name]) => h("th", { scope: "col" }, `${name}/s`)))),
50
+ this.tableBody)))));
51
+ this.observer = new ResizeObserver(() => {
52
+ const width = Math.round(this.chart.clientWidth);
53
+ if (width && width !== this.width && this.data) this.draw();
54
+ });
55
+ this.observer.observe(this.chart);
56
+ }
57
+
58
+ destroy() {
59
+ this.observer.disconnect();
60
+ }
61
+
62
+ set(overview, range) {
63
+ const rows = (overview.history || []).map((row) => ({ ...row, t: Date.parse(row.t) }));
64
+ this.data = { rows, range, current: overview.rates || {} };
65
+ this.drawTotals();
66
+ this.draw();
67
+ this.drawTable();
68
+ }
69
+
70
+ drawTotals() {
71
+ const { current } = this.data;
72
+ clear(this.totals).append(...SERIES.map(([key, name]) =>
73
+ h("div", {}, h("dt", {}, h("i", { class: `k-${key}` }), name), h("dd", {}, perSecond(current[key])))));
74
+ }
75
+
76
+ drawTable() {
77
+ const { rows, range } = this.data;
78
+ this.caption.textContent = `Messages per second across the broker over ${range.phrase}`;
79
+ clear(this.tableBody);
80
+ for (const row of [...rows].reverse()) {
81
+ this.tableBody.append(h("tr", {},
82
+ h("th", { scope: "row" }, hourMinute(row.t)),
83
+ SERIES.map(([key]) => h("td", {}, rate(row[key])))));
84
+ }
85
+ }
86
+
87
+ draw() {
88
+ const { rows, range } = this.data;
89
+ const width = Math.max(280, Math.round(this.chart.clientWidth) || 800);
90
+ this.width = width;
91
+ if (rows.length < 2) {
92
+ clear(this.chart).append(h("p", { class: "tp-empty" }, "No rate samples yet. The management API keeps them once messages flow."));
93
+ this.readout.textContent = "";
94
+ return;
95
+ }
96
+ const narrow = width < 600;
97
+ const height = narrow ? 140 : 168;
98
+ const m = { top: 12, right: 4, bottom: 24, left: 40 };
99
+ const plotW = width - m.left - m.right;
100
+ const plotH = height - m.top - m.bottom;
101
+ const start = rows[0].t;
102
+ const end = rows[rows.length - 1].t;
103
+ const x = (time) => m.left + ((time - start) / Math.max(1, end - start)) * plotW;
104
+ const top = niceTop(Math.max(...rows.map((row) => Math.max(row.publish, row.deliver, row.ack, row.redeliver))));
105
+ const y = (value) => m.top + plotH - (Math.min(value, top) / top) * plotH;
106
+ const path = (key) => rows.map((row, index) => `${index ? "L" : "M"}${x(row.t).toFixed(1)} ${y(row[key]).toFixed(1)}`).join("");
107
+ const area = (key) => `${path(key)}L${x(end)} ${y(0)}L${x(start)} ${y(0)}Z`;
108
+ const peak = rows.reduce((best, row) => (row.publish > best.publish ? row : best), rows[0]);
109
+
110
+ const svg = s("svg", {
111
+ viewBox: `0 0 ${width} ${height}`, width, height, role: "img",
112
+ "aria-label": `Over ${range.phrase}: published peaked at ${perSecond(peak.publish)} around ${hourMinute(peak.t)}. ` +
113
+ `Now ${SERIES.map(([key, name]) => `${name.toLowerCase()} ${perSecond(this.data.current[key])}`).join(", ")}.`
114
+ });
115
+ for (const value of [0, top / 2, top]) {
116
+ const gy = Math.round(y(value)) + 0.5;
117
+ svg.append(
118
+ s("line", { class: value ? "p-grid" : "p-rule", x1: m.left, x2: width - m.right, y1: gy, y2: gy }),
119
+ s("text", { class: "p-axis", x: m.left - 8, y: gy + 4, "text-anchor": "end" }, value ? perSecond(value) : "0"));
120
+ }
121
+ svg.append(
122
+ s("path", { class: "p-ack", d: area("ack") }),
123
+ s("path", { class: "p-ack-edge", d: path("ack") }),
124
+ s("path", { class: "p-redeliver", d: area("redeliver") }),
125
+ s("path", { class: "p-publish", d: path("publish") }),
126
+ s("path", { class: "p-deliver", d: path("deliver") }));
127
+
128
+ for (const tick of ticks(start, end, plotW)) {
129
+ const text = hourMinute(tick);
130
+ const tx = x(tick);
131
+ if (tx - text.length * 3.4 < m.left - 6 || tx + text.length * 3.4 > width) continue;
132
+ svg.append(
133
+ s("line", { class: "p-tick", x1: tx, x2: tx, y1: y(0), y2: y(0) + 4 }),
134
+ s("text", { class: "p-axis", x: tx, y: height - 6, "text-anchor": "middle" }, text));
135
+ }
136
+
137
+ const cursor = s("line", { class: "p-cursor", x1: 0, x2: 0, y1: m.top, y2: y(0), visibility: "hidden" });
138
+ const hit = s("rect", { class: "p-hit", x: m.left, y: 0, width: plotW, height });
139
+ const show = (event) => {
140
+ const box = svg.getBoundingClientRect();
141
+ const px = ((event.clientX - box.left) / box.width) * width;
142
+ const time = start + ((px - m.left) / plotW) * (end - start);
143
+ const row = rows.reduce((best, candidate) => (Math.abs(candidate.t - time) < Math.abs(best.t - time) ? candidate : best), rows[0]);
144
+ cursor.setAttribute("x1", x(row.t));
145
+ cursor.setAttribute("x2", x(row.t));
146
+ cursor.setAttribute("visibility", "visible");
147
+ clear(this.readout).append(h("b", {}, `${hourMinute(row.t)}: `),
148
+ SERIES.map(([key, name]) => `${name.toLowerCase()} ${perSecond(row[key])}`).join(", "));
149
+ };
150
+ const hide = () => {
151
+ cursor.setAttribute("visibility", "hidden");
152
+ this.readout.textContent = "Point at the chart to read the rates at a moment.";
153
+ };
154
+ hit.addEventListener("pointermove", show);
155
+ hit.addEventListener("pointerdown", show);
156
+ hit.addEventListener("pointerleave", hide);
157
+ svg.append(cursor, hit);
158
+ hide();
159
+ clear(this.chart).append(svg);
160
+ }
161
+ }
@@ -0,0 +1,8 @@
1
+ (function () {
2
+ try {
3
+ var theme = window.localStorage.getItem("lepus:theme");
4
+ if (theme === "light" || theme === "dark") document.documentElement.setAttribute("data-theme", theme);
5
+ } catch (error) {
6
+ document.documentElement.removeAttribute("data-theme");
7
+ }
8
+ })();
@@ -0,0 +1,61 @@
1
+ import { h, refill } from "../dom.js";
2
+ import { ago, count, plural } from "../format.js";
3
+
4
+ const SHOWN = 12;
5
+ const SELF = "(web-aggregator)";
6
+
7
+ const label = (connection) => (connection.client_properties && connection.client_properties.connection_name) || connection.name;
8
+
9
+ // The broker's client connections, kept quiet below the map.
10
+ export class ConnectionTable {
11
+ constructor(host) {
12
+ this.all = false;
13
+ this.note = h("p", { class: "section-note" });
14
+ this.body = h("tbody");
15
+ this.more = h("button", { type: "button", class: "btn btn-quiet btn-small", hidden: true, onclick: () => { this.all = !this.all; this.draw(); } });
16
+ host.append(
17
+ this.note,
18
+ h("table", { class: "rows is-quiet" },
19
+ h("caption", { class: "visually-hidden" }, "Connections"),
20
+ h("thead", {}, h("tr", {},
21
+ h("th", { scope: "col" }, "Connection"),
22
+ h("th", { scope: "col" }, "User"),
23
+ h("th", { scope: "col" }, "Channels"),
24
+ h("th", { scope: "col" }, "State"),
25
+ h("th", { scope: "col" }, "Connected"))),
26
+ this.body),
27
+ h("div", { class: "list-foot" }, this.more));
28
+ this.connections = [];
29
+ }
30
+
31
+ set(connections) {
32
+ this.connections = [...connections].sort((a, b) => label(a).localeCompare(label(b)));
33
+ const channels = this.connections.reduce((sum, c) => sum + (c.channels || 0), 0);
34
+ const users = new Set(this.connections.map((c) => c.user));
35
+ this.note.textContent = this.connections.length
36
+ ? `${plural(this.connections.length, "connection")} with ${plural(channels, "channel")} from ${plural(users.size, "user")}.`
37
+ : "";
38
+ this.draw();
39
+ }
40
+
41
+ draw() {
42
+ const now = Date.now();
43
+ const list = this.all ? this.connections : this.connections.slice(0, SHOWN);
44
+ this.more.hidden = this.connections.length <= SHOWN;
45
+ this.more.textContent = this.all ? "Show fewer" : `Show all ${count(this.connections.length)}`;
46
+ if (!list.length) {
47
+ refill(this.body, h("tr", {}, h("td", { colspan: 5, class: "empty" }, "No connections reported. They appear once the management API answers.")));
48
+ return;
49
+ }
50
+ refill(this.body, list.map((connection) => {
51
+ const name = label(connection);
52
+ const state = connection.state || "unknown";
53
+ return h("tr", {},
54
+ h("td", {}, name, name.endsWith(SELF) ? h("span", { class: "c-self" }, "this dashboard") : null),
55
+ h("td", { "data-label": "User" }, connection.user || ""),
56
+ h("td", { "data-label": "Channels" }, count(connection.channels)),
57
+ h("td", { "data-label": "State", class: `c-state is-${state}` }, state),
58
+ h("td", { "data-label": "Connected" }, connection.connected_at ? ago(connection.connected_at, now) : ""));
59
+ }));
60
+ }
61
+ }