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,456 @@
|
|
|
1
|
+
import { h, s, clear, focusKey, graft, refill, reducedMotion } from "../dom.js";
|
|
2
|
+
import { compact, count, duration, perSecond, plural } from "../format.js";
|
|
3
|
+
import { DEFAULT_BAND, backingUp, isMain } from "../model.js";
|
|
4
|
+
|
|
5
|
+
const SHOWN = 30;
|
|
6
|
+
const PER_GROUP = 6;
|
|
7
|
+
const IDLE_FOLD = 2;
|
|
8
|
+
const PICKED_MS = 2400;
|
|
9
|
+
const ACTIVE = 0.01;
|
|
10
|
+
const OPEN_KEY = "lepus:queues:open";
|
|
11
|
+
const FOLD_KEY = "lepus:queues:folds";
|
|
12
|
+
const ROLES = { retry: "Retry", delay: "Delay", error: "Error" };
|
|
13
|
+
const ROLE_ORDER = Object.keys(ROLES);
|
|
14
|
+
|
|
15
|
+
const COLUMNS = [
|
|
16
|
+
{ key: "name", label: "Queue", value: (q) => q.name, text: true },
|
|
17
|
+
{ key: "ready", label: "Ready", value: (q) => q.ready },
|
|
18
|
+
{ key: "unacked", label: "Unacked", value: (q) => q.unacked },
|
|
19
|
+
{ key: "consumers", label: "Consumers", value: (q) => q.consumers },
|
|
20
|
+
{ key: "published", label: "Published/s", value: (q) => q.publish_rate },
|
|
21
|
+
{ key: "delivered", label: "Delivered/s", value: (q) => q.deliver_rate },
|
|
22
|
+
{ key: "acked", label: "Acked/s", value: (q) => q.ack_rate },
|
|
23
|
+
{ key: "retry", label: "Retry", value: (q) => (q.hasRetry ? q.retryCount : -1), hint: "Messages waiting in the queue's retry queues. – means the queue has no retry queue." },
|
|
24
|
+
{ key: "error", label: "Error", value: (q) => (q.hasError ? q.errorCount : -1), hint: "Messages parked in the queue's error queues. – means the queue has no error queue." }
|
|
25
|
+
];
|
|
26
|
+
|
|
27
|
+
function recall(key) {
|
|
28
|
+
try { return new Set(JSON.parse(sessionStorage.getItem(key)) || []); } catch { return new Set(); }
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
function remember(key, set) {
|
|
32
|
+
try { sessionStorage.setItem(key, JSON.stringify([...set])); } catch {}
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
function arrow() {
|
|
36
|
+
return s("svg", { viewBox: "0 0 10 10", width: 10, height: 10, "aria-hidden": "true" }, s("path", { d: "M1.5 3.5h7L5 8z" }));
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
function chevron() {
|
|
40
|
+
return s("svg", { class: "q-chevron", viewBox: "0 0 12 12", width: 12, height: 12, "aria-hidden": "true" }, s("path", { d: "M4.5 2.5 8 6l-3.5 3.5" }));
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
function pin() {
|
|
44
|
+
return s("svg", { class: "q-pin", viewBox: "0 0 12 12", width: 12, height: 12, "aria-hidden": "true" },
|
|
45
|
+
s("path", { d: "M6 11s3.5-3.4 3.5-6A3.5 3.5 0 0 0 2.5 5c0 2.6 3.5 6 3.5 6Z" }),
|
|
46
|
+
s("circle", { cx: 6, cy: 5, r: 1.2 }));
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
function num(n, cls) {
|
|
50
|
+
const shown = compact(n);
|
|
51
|
+
const exact = count(n);
|
|
52
|
+
const classes = [cls, n ? "" : "q-zero"].join(" ").trim() || null;
|
|
53
|
+
if (shown === exact) return h("span", { class: classes }, shown);
|
|
54
|
+
return h("span", { class: classes, title: exact }, h("span", { "aria-hidden": "true" }, shown), h("span", { class: "visually-hidden" }, exact));
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
const speed = (value) => h("span", { class: value ? null : "q-zero" }, perSecond(value));
|
|
58
|
+
const rateCell = (label, value) => h("td", { class: value ? null : "is-still", "data-label": label }, speed(value));
|
|
59
|
+
const dash = () => h("span", { class: "q-zero" }, "–");
|
|
60
|
+
const capitalize = (text) => text.charAt(0).toUpperCase() + text.slice(1);
|
|
61
|
+
const listed = (words) => (words.length > 1 ? `${words.slice(0, -1).join(", ")} and ${words[words.length - 1]}` : words[0]);
|
|
62
|
+
|
|
63
|
+
function onward(queue) {
|
|
64
|
+
const exchange = queue.dead_letter_exchange;
|
|
65
|
+
const key = queue.dead_letter_routing_key;
|
|
66
|
+
if (exchange === null || exchange === undefined) return null;
|
|
67
|
+
if (exchange === "") {
|
|
68
|
+
if (!key) return null;
|
|
69
|
+
return key === queue.parent ? `back to ${key}` : `then to ${key}`;
|
|
70
|
+
}
|
|
71
|
+
return `then to the ${exchange} exchange${key ? ` by ${key}` : ""}`;
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
function delivers(queue) {
|
|
75
|
+
const after = `releases after ${duration(queue.delay_ms)}`;
|
|
76
|
+
return queue.target_type === "exchange" ? `${after} to the ${queue.target} exchange, keeping the routing key` : `${after} to ${queue.target}`;
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
// What a retry, delay or error queue does with its messages, read from its
|
|
80
|
+
// arguments: "retries after 5 s, back to orders.billing" or "releases after
|
|
81
|
+
// 5 s to orders.billing, expires when unused for 1 h".
|
|
82
|
+
function childDuty(queue) {
|
|
83
|
+
const ttl = queue.ttl ? duration(queue.ttl) : null;
|
|
84
|
+
const next = onward(queue);
|
|
85
|
+
const parts = [];
|
|
86
|
+
if (queue.role === "delay" && queue.delay_ms) parts.push(delivers(queue));
|
|
87
|
+
else if (queue.role === "retry") parts.push(ttl ? `retries after ${ttl}` : "holds messages to retry");
|
|
88
|
+
else if (queue.role === "delay") parts.push("holds delayed messages");
|
|
89
|
+
else parts.push(ttl ? `keeps failed messages for ${ttl}` : "keeps failed messages until they are moved");
|
|
90
|
+
if (next && queue.role !== "delay" && (ttl || queue.role !== "error")) parts.push(next);
|
|
91
|
+
if (queue.expires) parts.push(`expires when unused for ${duration(queue.expires)}`);
|
|
92
|
+
return parts.join(", ");
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
const delaysOf = (kids) => kids.filter((kid) => kid.role === "delay").map((kid) => kid.delay_ms).filter(Boolean);
|
|
96
|
+
|
|
97
|
+
const busy = (q) => q.ready || q.unacked || q.publish_rate >= ACTIVE || q.deliver_rate >= ACTIVE || q.ack_rate >= ACTIVE;
|
|
98
|
+
|
|
99
|
+
function entry(queue, also, kids) {
|
|
100
|
+
const total = (role) => kids.filter((kid) => kid.role === role).reduce((sum, kid) => sum + kid.messages, 0);
|
|
101
|
+
return {
|
|
102
|
+
...queue,
|
|
103
|
+
also,
|
|
104
|
+
kids,
|
|
105
|
+
retryCount: total("retry"),
|
|
106
|
+
errorCount: total("error"),
|
|
107
|
+
hasRetry: kids.some((kid) => kid.role === "retry"),
|
|
108
|
+
hasError: kids.some((kid) => kid.role === "error"),
|
|
109
|
+
delayed: queue.delayed ?? kids.filter((kid) => kid.role === "delay").reduce((sum, kid) => sum + kid.messages, 0),
|
|
110
|
+
delays: delaysOf(kids),
|
|
111
|
+
idle: !busy(queue) && !kids.some((kid) => kid.messages || busy(kid))
|
|
112
|
+
};
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
// The map's bands as table groups: each exchange with the queues drawn on
|
|
116
|
+
// its band, then the default exchange's queues, which have no binding.
|
|
117
|
+
// Delay queues whose queue or exchange is gone are listed under the
|
|
118
|
+
// default exchange.
|
|
119
|
+
function groupsFrom(topology, bands) {
|
|
120
|
+
const queues = new Map(topology.queues.map((queue) => [queue.name, queue]));
|
|
121
|
+
const children = new Map();
|
|
122
|
+
for (const queue of topology.queues) {
|
|
123
|
+
if (isMain(queue, queues) || !queue.parent) continue;
|
|
124
|
+
if (!children.has(queue.parent)) children.set(queue.parent, []);
|
|
125
|
+
children.get(queue.parent).push(queue);
|
|
126
|
+
}
|
|
127
|
+
const kidsOf = (name) => [...(children.get(name) || [])]
|
|
128
|
+
.sort((a, b) => ROLE_ORDER.indexOf(a.role) - ROLE_ORDER.indexOf(b.role) || (a.delay_ms || 0) - (b.delay_ms || 0) || a.name.localeCompare(b.name));
|
|
129
|
+
const groups = bands.map((band) => {
|
|
130
|
+
const members = band.allRows.filter((row) => row.kind === "queue").map((row) => entry(row.queue, row.also, kidsOf(row.name)));
|
|
131
|
+
const unbound = band.key === DEFAULT_BAND;
|
|
132
|
+
return {
|
|
133
|
+
key: band.key,
|
|
134
|
+
name: band.name,
|
|
135
|
+
unbound,
|
|
136
|
+
type: band.type,
|
|
137
|
+
rate: unbound ? members.reduce((sum, q) => sum + q.publish_rate, 0) : band.rate,
|
|
138
|
+
delayed: (band.delayed ? band.delayed.messages : 0) + members.reduce((sum, q) => sum + q.delayed, 0),
|
|
139
|
+
delays: band.delayed ? band.delayed.queues : [],
|
|
140
|
+
queues: members
|
|
141
|
+
};
|
|
142
|
+
});
|
|
143
|
+
const placed = new Set(bands.map((band) => band.key));
|
|
144
|
+
const orphans = topology.queues.filter((queue) => queue.role === "delay" &&
|
|
145
|
+
!(queue.parent_exchange ? placed.has(queue.parent_exchange) : queues.has(queue.parent)));
|
|
146
|
+
if (orphans.length) {
|
|
147
|
+
let fallback = groups.find((group) => group.unbound);
|
|
148
|
+
if (!fallback) {
|
|
149
|
+
fallback = { key: DEFAULT_BAND, name: "Default exchange", unbound: true, type: "direct", rate: 0, delayed: 0, delays: [], queues: [] };
|
|
150
|
+
groups.push(fallback);
|
|
151
|
+
}
|
|
152
|
+
fallback.delays = [...fallback.delays, ...orphans].sort((a, b) => (a.delay_ms || 0) - (b.delay_ms || 0) || a.name.localeCompare(b.name));
|
|
153
|
+
fallback.delayed += orphans.reduce((sum, queue) => sum + queue.messages, 0);
|
|
154
|
+
}
|
|
155
|
+
return groups.filter((group) => group.queues.length || group.delays.length);
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
// Every main queue under the exchange that routes to it, in the map's order,
|
|
159
|
+
// with its retry, delay and error queues one disclosure away.
|
|
160
|
+
export class QueueTable {
|
|
161
|
+
constructor(host, { onShow }) {
|
|
162
|
+
this.onShow = onShow;
|
|
163
|
+
this.sort = { key: "name", dir: "asc" };
|
|
164
|
+
this.filter = "";
|
|
165
|
+
this.picked = null;
|
|
166
|
+
this.revealed = null;
|
|
167
|
+
this.groups = [];
|
|
168
|
+
this.open = recall(OPEN_KEY);
|
|
169
|
+
this.folds = recall(FOLD_KEY);
|
|
170
|
+
this.bodies = new Map();
|
|
171
|
+
this.note = h("p", { class: "section-note" });
|
|
172
|
+
this.input = h("input", { type: "search", class: "filter", placeholder: "Filter queues", "aria-label": "Filter queues by name" });
|
|
173
|
+
this.input.addEventListener("input", () => {
|
|
174
|
+
this.filter = this.input.value.trim().toLowerCase();
|
|
175
|
+
this.draw();
|
|
176
|
+
});
|
|
177
|
+
this.head = h("tr");
|
|
178
|
+
this.thead = h("thead", {}, this.head);
|
|
179
|
+
this.table = h("table", { class: "rows rows-sortable q-table" }, h("caption", { class: "visually-hidden" }, "Queues by exchange"), this.thead);
|
|
180
|
+
this.count = h("p");
|
|
181
|
+
host.append(
|
|
182
|
+
h("div", { class: "section-head" }, this.note, this.input),
|
|
183
|
+
this.table,
|
|
184
|
+
h("div", { class: "list-foot" }, this.count));
|
|
185
|
+
this.drawHead();
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
drawHead() {
|
|
189
|
+
refill(this.head, COLUMNS.map((column) => {
|
|
190
|
+
const sorted = this.sort.key === column.key;
|
|
191
|
+
return h("th", { scope: "col", "aria-sort": sorted ? (this.sort.dir === "asc" ? "ascending" : "descending") : null },
|
|
192
|
+
h("button", { type: "button", class: "sort", title: column.hint || null, onclick: () => this.sortBy(column) }, column.label, arrow()));
|
|
193
|
+
}));
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
sortBy(column) {
|
|
197
|
+
if (this.sort.key === column.key) this.sort.dir = this.sort.dir === "asc" ? "desc" : "asc";
|
|
198
|
+
else this.sort = { key: column.key, dir: column.text ? "asc" : "desc" };
|
|
199
|
+
this.drawHead();
|
|
200
|
+
this.draw();
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
set(topology, bands) {
|
|
204
|
+
this.groups = groupsFrom(topology, bands);
|
|
205
|
+
const mains = this.groups.reduce((sum, group) => sum + group.queues.length, 0);
|
|
206
|
+
const exchanges = this.groups.filter((group) => !group.unbound).length;
|
|
207
|
+
this.note.textContent = mains
|
|
208
|
+
? `${plural(mains, "queue")} under ${plural(exchanges, "exchange")}, in the map's order; sorting orders the queues within each exchange. ` +
|
|
209
|
+
"An exchange's Published/s is what reaches the exchange. Retry and Error count messages in a queue's retry and error queues, – means it has none. " +
|
|
210
|
+
"Delay queues hold messages published with a delay or handed back with delay! until they are released; under an exchange they release to the exchange. " +
|
|
211
|
+
"Rates are RabbitMQ's own, per second."
|
|
212
|
+
: "";
|
|
213
|
+
this.draw();
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
matches(queue) {
|
|
217
|
+
const text = this.filter;
|
|
218
|
+
return !text || queue.name.toLowerCase().includes(text) || (queue.application || "").toLowerCase().includes(text) ||
|
|
219
|
+
queue.kids.some((kid) => kid.name.toLowerCase().includes(text));
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
shownDelays(group) {
|
|
223
|
+
const text = this.filter;
|
|
224
|
+
if (!text) return group.delays;
|
|
225
|
+
return group.delays.filter((queue) => queue.name.toLowerCase().includes(text) || group.name.toLowerCase().includes(text));
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
toggle(set, storageKey, key) {
|
|
229
|
+
if (set === this.folds) this.revealed = null;
|
|
230
|
+
if (set.has(key)) set.delete(key);
|
|
231
|
+
else set.add(key);
|
|
232
|
+
remember(storageKey, set);
|
|
233
|
+
this.draw();
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
rowFor(name) {
|
|
237
|
+
return [...this.table.querySelectorAll("tr[data-queue]")].find((row) => row.dataset.queue === name) || null;
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
// Brings a queue picked on the map into view: unfiltered, shown even when
|
|
241
|
+
// its group folds it away, expanded, highlighted for a moment and focused.
|
|
242
|
+
pick(name) {
|
|
243
|
+
const group = this.groups.find((candidate) => candidate.queues.some((queue) => queue.name === name));
|
|
244
|
+
if (!group) return;
|
|
245
|
+
const queue = group.queues.find((candidate) => candidate.name === name);
|
|
246
|
+
if (!this.matches(queue)) {
|
|
247
|
+
this.filter = "";
|
|
248
|
+
this.input.value = "";
|
|
249
|
+
}
|
|
250
|
+
if (queue.kids.length && !this.open.has(name)) {
|
|
251
|
+
this.open.add(name);
|
|
252
|
+
remember(OPEN_KEY, this.open);
|
|
253
|
+
}
|
|
254
|
+
this.picked = name;
|
|
255
|
+
this.revealed = name;
|
|
256
|
+
this.draw();
|
|
257
|
+
const row = this.rowFor(name);
|
|
258
|
+
if (!row) return;
|
|
259
|
+
row.focus({ preventScroll: true });
|
|
260
|
+
row.scrollIntoView({ block: "center", behavior: reducedMotion.matches ? "auto" : "smooth" });
|
|
261
|
+
clearTimeout(this.pickedTimer);
|
|
262
|
+
this.pickedTimer = setTimeout(() => this.unpick(), PICKED_MS);
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
unpick() {
|
|
266
|
+
const row = this.rowFor(this.picked);
|
|
267
|
+
this.picked = null;
|
|
268
|
+
if (!row) return;
|
|
269
|
+
row.classList.remove("is-picked");
|
|
270
|
+
if (reducedMotion.matches) return;
|
|
271
|
+
row.classList.add("is-fading");
|
|
272
|
+
setTimeout(() => row.classList.remove("is-fading"), 1000);
|
|
273
|
+
}
|
|
274
|
+
|
|
275
|
+
draw() {
|
|
276
|
+
const column = COLUMNS.find((candidate) => candidate.key === this.sort.key);
|
|
277
|
+
const dir = this.sort.dir === "asc" ? 1 : -1;
|
|
278
|
+
const order = (a, b) => {
|
|
279
|
+
const av = column.value(a);
|
|
280
|
+
const bv = column.value(b);
|
|
281
|
+
const result = column.text ? String(av).localeCompare(String(bv)) : av - bv;
|
|
282
|
+
return result * dir || a.name.localeCompare(b.name);
|
|
283
|
+
};
|
|
284
|
+
const total = this.groups.reduce((sum, group) => sum + group.queues.length, 0);
|
|
285
|
+
const limit = this.filter || total <= SHOWN ? Infinity : PER_GROUP;
|
|
286
|
+
let matched = 0;
|
|
287
|
+
const drawn = [];
|
|
288
|
+
for (const group of this.groups) {
|
|
289
|
+
const queues = group.queues.filter((queue) => this.matches(queue)).sort(order);
|
|
290
|
+
const delays = this.shownDelays(group);
|
|
291
|
+
matched += queues.length + delays.length;
|
|
292
|
+
if (!queues.length && !delays.length) continue;
|
|
293
|
+
const idleQueues = queues.filter((queue) => queue.idle);
|
|
294
|
+
const idle = !this.filter && idleQueues.length >= IDLE_FOLD ? idleQueues : [];
|
|
295
|
+
const active = queues.filter((queue) => !idle.includes(queue));
|
|
296
|
+
const cut = active.length > limit + 1 ? limit : Infinity;
|
|
297
|
+
const moreOpen = this.folds.has(`${group.key}|more`);
|
|
298
|
+
const idleOpen = this.folds.has(`${group.key}|idle`);
|
|
299
|
+
const shown = active.filter((queue, index) => moreOpen || index < cut || queue.name === this.revealed);
|
|
300
|
+
const idleShown = idle.filter((queue) => idleOpen || queue.name === this.revealed);
|
|
301
|
+
drawn.push(this.body(`group:${group.key}`, [
|
|
302
|
+
this.groupRow(group, queues, delays),
|
|
303
|
+
delays.map((queue) => this.childRow(queue, "q-exchange-delay")),
|
|
304
|
+
shown.map((queue) => this.queueRows(queue)),
|
|
305
|
+
active.length > cut ? this.foldRow(group, "more", moreOpen ? active.length - cut : active.length - shown.length, moreOpen) : null,
|
|
306
|
+
idle.length ? this.foldRow(group, "idle", idleOpen ? idle.length : idle.length - idleShown.length, idleOpen) : null,
|
|
307
|
+
idleShown.map((queue) => this.queueRows(queue))
|
|
308
|
+
]));
|
|
309
|
+
}
|
|
310
|
+
this.count.textContent = this.filter ? `${plural(matched, "queue")} ${matched === 1 ? "matches" : "match"} “${this.input.value.trim()}”.` : "";
|
|
311
|
+
if (!drawn.length) {
|
|
312
|
+
const message = this.groups.length ? "No queue matches that filter." : "No queues to show. They appear once the management API answers.";
|
|
313
|
+
drawn.push(this.body("empty", h("tr", {}, h("td", { colspan: COLUMNS.length, class: "empty" }, message))));
|
|
314
|
+
}
|
|
315
|
+
this.place(drawn);
|
|
316
|
+
}
|
|
317
|
+
|
|
318
|
+
// Brings a group's rows up to date. A focused control stays the same
|
|
319
|
+
// element, so a poll neither moves focus nor makes a screen reader
|
|
320
|
+
// announce it again.
|
|
321
|
+
body(key, rows) {
|
|
322
|
+
if (!this.bodies.has(key)) this.bodies.set(key, h("tbody", { "data-key": key }));
|
|
323
|
+
const body = this.bodies.get(key);
|
|
324
|
+
const active = document.activeElement;
|
|
325
|
+
if (!active || active === body || !body.contains(active)) return refill(body, rows);
|
|
326
|
+
const fresh = h("tbody", {}, rows);
|
|
327
|
+
if (fresh.childNodes.length === body.childNodes.length && [...fresh.childNodes].every((row, index) => row.isEqualNode(body.childNodes[index]))) return body;
|
|
328
|
+
const match = [...fresh.querySelectorAll("[data-focus-key]")].find((node) => node.dataset.focusKey === focusKey(active));
|
|
329
|
+
if (match && graft(body, fresh, active, match)) return body;
|
|
330
|
+
clear(body).append(...fresh.childNodes);
|
|
331
|
+
match?.focus({ preventScroll: true });
|
|
332
|
+
return body;
|
|
333
|
+
}
|
|
334
|
+
|
|
335
|
+
// Puts the bodies in order, moving only those out of place so a focused
|
|
336
|
+
// row never leaves the document.
|
|
337
|
+
place(bodies) {
|
|
338
|
+
for (const [key, body] of this.bodies) {
|
|
339
|
+
if (bodies.includes(body)) continue;
|
|
340
|
+
body.remove();
|
|
341
|
+
this.bodies.delete(key);
|
|
342
|
+
}
|
|
343
|
+
let previous = this.thead;
|
|
344
|
+
for (const body of bodies) {
|
|
345
|
+
if (previous.nextSibling !== body) previous.after(body);
|
|
346
|
+
previous = body;
|
|
347
|
+
}
|
|
348
|
+
}
|
|
349
|
+
|
|
350
|
+
groupRow(group, queues, delays) {
|
|
351
|
+
const sum = (key) => queues.reduce((total, queue) => total + queue[key], 0);
|
|
352
|
+
const delayed = this.filter ? sum("delayed") + delays.reduce((total, queue) => total + queue.messages, 0) : group.delayed;
|
|
353
|
+
const meta = [group.unbound ? "no binding, published straight to the queue" : `${group.type || "unknown"} exchange`];
|
|
354
|
+
meta.push(queues.length === group.queues.length ? plural(group.queues.length, "queue") : `${count(queues.length)} of ${plural(group.queues.length, "queue")}`);
|
|
355
|
+
if (delayed) meta.push(`${compact(delayed)} delayed`);
|
|
356
|
+
else if (delays.length) meta.push(plural(delays.length, "empty delay queue"));
|
|
357
|
+
return h("tr", { class: "q-group" },
|
|
358
|
+
h("th", { scope: "rowgroup" },
|
|
359
|
+
h("span", { class: "q-ex" }, group.name),
|
|
360
|
+
h("span", { class: "q-ex-meta" }, meta.join(", "))),
|
|
361
|
+
h("td", { "data-label": "Ready" }, num(sum("ready"))),
|
|
362
|
+
h("td", { "data-label": "Unacked" }, num(sum("unacked"))),
|
|
363
|
+
h("td", { "data-label": "Consumers" }, num(sum("consumers"))),
|
|
364
|
+
h("td", { "data-label": group.unbound ? "Published" : "Into exchange", title: group.unbound ? null : "Published to the exchange" }, speed(group.rate)),
|
|
365
|
+
h("td"), h("td"), h("td"), h("td"));
|
|
366
|
+
}
|
|
367
|
+
|
|
368
|
+
foldRow(group, kind, hidden, open) {
|
|
369
|
+
const words = plural(hidden, kind === "idle" ? "idle queue" : "more queue");
|
|
370
|
+
return h("tr", { class: "q-fold" },
|
|
371
|
+
h("td", { colspan: COLUMNS.length },
|
|
372
|
+
h("button", {
|
|
373
|
+
type: "button",
|
|
374
|
+
class: "q-more",
|
|
375
|
+
"aria-expanded": String(open),
|
|
376
|
+
"data-focus-key": `${group.key}|${kind}`,
|
|
377
|
+
onclick: () => this.toggle(this.folds, FOLD_KEY, `${group.key}|${kind}`)
|
|
378
|
+
}, chevron(), open ? `Hide ${words}` : `+${words}`, h("span", { class: "visually-hidden" }, ` under ${group.name}`))));
|
|
379
|
+
}
|
|
380
|
+
|
|
381
|
+
queueRows(queue) {
|
|
382
|
+
const open = queue.kids.length > 0 && this.open.has(queue.name);
|
|
383
|
+
return [this.queueRow(queue, open), open ? queue.kids.map((kid) => this.childRow(kid)) : null];
|
|
384
|
+
}
|
|
385
|
+
|
|
386
|
+
queueRow(queue, open) {
|
|
387
|
+
const rising = backingUp(queue);
|
|
388
|
+
const roles = ROLE_ORDER.filter((role) => queue.kids.some((kid) => kid.role === role)).map((role) => ROLES[role].toLowerCase());
|
|
389
|
+
const words = [queue.application, queue.also.length ? `also bound to ${queue.also.join(", ")}` : null].filter(Boolean);
|
|
390
|
+
const waits = queue.delayed
|
|
391
|
+
? h("span", { class: words.length ? "q-wait is-after" : "q-wait" }, `${compact(queue.delayed)} delayed`, queue.delays.length ? ` for ${listed(queue.delays.map(duration))}` : null)
|
|
392
|
+
: null;
|
|
393
|
+
const sub = words.length || waits ? [words.join(" · "), words.length && waits ? " · " : null, waits] : null;
|
|
394
|
+
const classes = ["q-row", queue.idle && "is-idle", queue.name === this.picked && "is-picked"].filter(Boolean).join(" ");
|
|
395
|
+
return h("tr", { class: classes, tabindex: -1, "data-queue": queue.name, "data-focus-key": `${queue.name}|row` },
|
|
396
|
+
h("td", {},
|
|
397
|
+
h("div", { class: "q-head" },
|
|
398
|
+
queue.kids.length
|
|
399
|
+
? h("button", {
|
|
400
|
+
type: "button",
|
|
401
|
+
class: "q-toggle",
|
|
402
|
+
"aria-expanded": String(open),
|
|
403
|
+
"aria-label": `${capitalize(listed(roles))} ${queue.kids.length === 1 ? "queue" : "queues"} of ${queue.name}`,
|
|
404
|
+
"data-focus-key": `${queue.name}|kids`,
|
|
405
|
+
onclick: () => this.toggle(this.open, OPEN_KEY, queue.name)
|
|
406
|
+
}, chevron())
|
|
407
|
+
: h("span", { class: "q-toggle" }),
|
|
408
|
+
h("div", { class: "q-id" },
|
|
409
|
+
h("span", { class: "q-title" },
|
|
410
|
+
queue.kids.length
|
|
411
|
+
? h("button", {
|
|
412
|
+
type: "button",
|
|
413
|
+
class: "q-name",
|
|
414
|
+
"aria-expanded": String(open),
|
|
415
|
+
"data-focus-key": `${queue.name}|name`,
|
|
416
|
+
onclick: () => this.toggle(this.open, OPEN_KEY, queue.name)
|
|
417
|
+
}, queue.name)
|
|
418
|
+
: h("span", { class: "q-name" }, queue.name),
|
|
419
|
+
h("button", {
|
|
420
|
+
type: "button",
|
|
421
|
+
class: "q-map",
|
|
422
|
+
title: "Show on map",
|
|
423
|
+
"aria-label": `Show ${queue.name} on the map`,
|
|
424
|
+
"data-focus-key": `${queue.name}|map`,
|
|
425
|
+
onclick: () => this.onShow(queue.name)
|
|
426
|
+
}, pin())),
|
|
427
|
+
sub ? h("span", { class: "q-app" }, sub) : null))),
|
|
428
|
+
h("td", { "data-label": "Ready" }, num(queue.ready, rising ? "q-ready is-backlog" : "q-ready"),
|
|
429
|
+
rising && queue.backlog_rate > 0.05 ? h("span", { class: "q-trend" }, `+${perSecond(queue.backlog_rate)}`) : null),
|
|
430
|
+
h("td", { "data-label": "Unacked" }, num(queue.unacked)),
|
|
431
|
+
h("td", { "data-label": "Consumers" }, num(queue.consumers, queue.consumers === 0 && queue.ready > 0 ? "q-cons is-none" : null)),
|
|
432
|
+
rateCell("Published", queue.publish_rate),
|
|
433
|
+
rateCell("Delivered", queue.deliver_rate),
|
|
434
|
+
rateCell("Acked", queue.ack_rate),
|
|
435
|
+
h("td", { class: queue.retryCount ? null : "is-still", "data-label": "Retry" }, queue.hasRetry ? num(queue.retryCount, queue.retryCount ? "q-retry is-on" : null) : dash()),
|
|
436
|
+
h("td", { class: queue.errorCount ? null : "is-still", "data-label": "Error" }, queue.hasError ? num(queue.errorCount, queue.errorCount ? "q-error is-on" : null) : dash()));
|
|
437
|
+
}
|
|
438
|
+
|
|
439
|
+
childRow(kid, extra = null) {
|
|
440
|
+
const held = { error: "q-error is-on", retry: "q-retry is-on", delay: "q-wait" }[kid.role] || null;
|
|
441
|
+
return h("tr", { class: ["q-row q-child", extra].filter(Boolean).join(" ") },
|
|
442
|
+
h("td", {},
|
|
443
|
+
h("div", { class: "q-head" },
|
|
444
|
+
h("span", { class: "q-toggle" }),
|
|
445
|
+
h("div", { class: "q-id" },
|
|
446
|
+
h("span", { class: "q-kid" }, h("span", { class: `q-role is-${kid.role}` }, ROLES[kid.role]), " ", kid.name),
|
|
447
|
+
h("span", { class: "q-app" }, childDuty(kid))))),
|
|
448
|
+
h("td", { "data-label": "Ready" }, num(kid.ready, kid.ready ? held : null)),
|
|
449
|
+
h("td", { "data-label": "Unacked" }, num(kid.unacked)),
|
|
450
|
+
h("td", { "data-label": "Consumers" }, num(kid.consumers)),
|
|
451
|
+
rateCell("Published", kid.publish_rate),
|
|
452
|
+
rateCell("Delivered", kid.deliver_rate),
|
|
453
|
+
rateCell("Acked", kid.ack_rate),
|
|
454
|
+
h("td"), h("td"));
|
|
455
|
+
}
|
|
456
|
+
}
|