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,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
|
+
}
|