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,229 @@
1
+ import { api } from "../api.js";
2
+ import { h, s, clear, reducedMotion } from "../dom.js";
3
+ import { every, pollMs } from "../live.js";
4
+ import { TopologyMap } from "../map.js";
5
+ import { Memory, buildModel } from "../model.js";
6
+ import { RateStrip } from "../rates.js";
7
+ import { compact, count, rate } from "../format.js";
8
+ import { ProcessList } from "./processes.js";
9
+ import { QueueTable } from "./queues.js";
10
+ import { ConnectionTable } from "./connections.js";
11
+
12
+ const RANGE_KEY = "lepus:range";
13
+ const RANGES = [
14
+ { key: "10m", age: 600, short: "10 min", phrase: "the last 10 minutes" },
15
+ { key: "1h", age: 3600, short: "1 h", phrase: "the last hour" }
16
+ ];
17
+ const EXPANDED_KEY = "lepus:expanded";
18
+
19
+ function symbol(kind) {
20
+ const svg = s("svg", { viewBox: "0 0 20 20", "aria-hidden": "true" });
21
+ if (kind === "move") svg.append(s("rect", { class: "sym-token", x: 1, y: 5, width: 18, height: 10, rx: 5 }));
22
+ if (kind === "idle") svg.append(s("rect", { class: "sym-idle", x: 2, y: 5.5, width: 16, height: 9, rx: 4.5 }));
23
+ if (kind === "stop") svg.append(s("rect", { class: "sym-stop", x: 7, y: 0, width: 6, height: 20, rx: 2 }));
24
+ if (kind === "back") svg.append(s("rect", { class: "sym-back", x: 1, y: 5, width: 18, height: 10, rx: 5 }));
25
+ if (kind === "wait") svg.append(s("g", { class: "sym-wait" }, s("circle", { cx: 10, cy: 10, r: 7.5 }), s("path", { d: "M10 5.5V10h3.6" })));
26
+ return h("span", { class: "sym" }, svg);
27
+ }
28
+
29
+ // A compact number for the eye and the exact one for screen readers.
30
+ function amount(shown, exact) {
31
+ if (shown === exact) return h("span", { class: "n" }, shown);
32
+ return h("span", { class: "n", title: exact }, h("span", { "aria-hidden": "true" }, shown), h("span", { class: "visually-hidden" }, exact));
33
+ }
34
+
35
+ function load(key, fallback) {
36
+ try { return JSON.parse(localStorage.getItem(key)) ?? fallback; } catch { return fallback; }
37
+ }
38
+
39
+ function store(key, value) {
40
+ try { localStorage.setItem(key, JSON.stringify(value)); } catch {}
41
+ }
42
+
43
+ function section(id, title, ...children) {
44
+ return h("section", { id, class: "page-section", "aria-labelledby": `${id}-title` },
45
+ h("h2", { class: "section-title", id: `${id}-title` }, title),
46
+ ...children);
47
+ }
48
+
49
+ export function overview(root, { meta }) {
50
+ let range = RANGES.find((candidate) => candidate.key === load(RANGE_KEY, "10m")) || RANGES[0];
51
+ const expanded = new Set(load(EXPANDED_KEY, []));
52
+ const memory = new Memory();
53
+ let topology = null;
54
+ let model = null;
55
+ let destroyed = false;
56
+
57
+ const headline = h("h1", { class: "headline", id: "map" }, h("span", { class: "quiet" }, "Reading the broker"));
58
+ const legend = h("ul", { class: "legend", "aria-label": "How to read the map" },
59
+ ...[
60
+ ["move", "Flowing", "messages on the move, more tokens on busier lines"],
61
+ ["idle", "Backing up", "a backlog waiting in its queue"],
62
+ ["back", "Retrying", "loops beneath a line"],
63
+ ["wait", "Delayed", "held on purpose until they are released"],
64
+ ["stop", "Errors", "messages parked in error queues"]
65
+ ].map(([kind, name, words]) => h("li", {}, symbol(kind), h("span", {}, h("b", {}, name), " ", words))));
66
+ const legendHint = h("p", { class: "legend-hint" },
67
+ "Each band is an exchange routing to its queues, then on to their consumers and acknowledgement. Point at a queue for exact numbers, select it to find it in the table.");
68
+ const status = h("p", { class: "status-note", role: "status", hidden: true });
69
+ const mapHost = h("div", { class: "map" });
70
+ const mapNote = h("p", { class: "map-note" });
71
+ const empty = h("p", { class: "map-empty", hidden: true });
72
+ const rangeButtons = RANGES.map((candidate) => h("button", {
73
+ type: "button",
74
+ "aria-pressed": String(candidate === range),
75
+ onclick: () => pickRange(candidate)
76
+ }, candidate.short));
77
+ const ratesNote = h("p", { class: "section-note" });
78
+ const ratesHost = h("div", { class: "tp" });
79
+ const processesHost = h("div");
80
+ const queuesHost = h("div");
81
+ const connectionsHost = h("div");
82
+
83
+ root.append(
84
+ h("header", { class: "intro" }, headline, legend, legendHint),
85
+ status,
86
+ empty,
87
+ mapHost,
88
+ h("div", { class: "map-foot" }, mapNote),
89
+ h("section", { class: "through", "aria-labelledby": "rates-title" },
90
+ h("div", { class: "through-head" },
91
+ h("div", {},
92
+ h("h2", { class: "section-title", id: "rates-title" }, "Message rates"),
93
+ ratesNote),
94
+ h("div", { class: "range-row" },
95
+ h("span", { class: "range-label", id: "range-label" }, "Window"),
96
+ h("div", { class: "seg", role: "group", "aria-labelledby": "range-label" }, rangeButtons))),
97
+ ratesHost),
98
+ section("queues", "Queues", queuesHost),
99
+ section("processes", "Processes", processesHost),
100
+ section("connections", "Connections", connectionsHost));
101
+
102
+ const queueTable = new QueueTable(queuesHost, { onShow: (name) => showOnMap(name) });
103
+ const map = new TopologyMap(mapHost, {
104
+ label: "Exchanges, their queues and consumers",
105
+ onPick: (name) => queueTable.pick(name),
106
+ onToggle: (band) => {
107
+ if (expanded.has(band)) expanded.delete(band);
108
+ else expanded.add(band);
109
+ store(EXPANDED_KEY, [...expanded]);
110
+ drawMap();
111
+ const key = expanded.has(band) ? `${band}|less` : `${band}|more`;
112
+ mapHost.querySelector(`[data-key="${CSS.escape(key)}"]`)?.focus({ preventScroll: true });
113
+ }
114
+ });
115
+ const strip = new RateStrip(ratesHost);
116
+ const processList = new ProcessList(processesHost, meta);
117
+ const connectionTable = new ConnectionTable(connectionsHost);
118
+
119
+ const describeRange = () => {
120
+ ratesNote.textContent = `Messages per second across the broker over ${range.phrase}, as sampled by RabbitMQ.`;
121
+ };
122
+ describeRange();
123
+
124
+ function pickRange(next) {
125
+ if (next === range) return;
126
+ range = next;
127
+ store(RANGE_KEY, range.key);
128
+ rangeButtons.forEach((button, index) => button.setAttribute("aria-pressed", String(RANGES[index] === range)));
129
+ describeRange();
130
+ refreshRates().catch(() => {});
131
+ }
132
+
133
+ function renderHeadline(totals) {
134
+ const parts = [];
135
+ const add = (kind, shown, exact, words) => parts.push(h("span", { class: "part" }, symbol(kind), amount(shown, exact), " ", words));
136
+ add("move", `${rate(totals.deliver)} msg/s`, `${count(totals.deliver)} messages a second`, "flowing");
137
+ if (totals.backingUp) add("idle", `${count(totals.backingUp)}`, `${count(totals.backingUp)}`, totals.backingUp === 1 ? "queue backing up" : "queues backing up");
138
+ if (totals.retry >= 0.01) add("back", `${rate(totals.retry)} msg/s`, `${count(totals.retry)} messages a second`, "retrying");
139
+ if (totals.delayed) add("wait", compact(totals.delayed), count(totals.delayed), "delayed");
140
+ add("stop", compact(totals.errors), count(totals.errors), totals.errors === 1 ? "message in error queues" : "messages in error queues");
141
+ clear(headline);
142
+ parts.forEach((part, index) => {
143
+ const last = index > 0 && index === parts.length - 1;
144
+ if (index > 0) headline.append(last ? " " : ", ");
145
+ if (last) part.prepend("and ");
146
+ headline.append(part);
147
+ });
148
+ headline.append(".");
149
+ }
150
+
151
+ // Finds a queue's station, unfolding its band when the queue is folded
152
+ // away, and moves focus there so its numbers show.
153
+ function showOnMap(name) {
154
+ const band = model && model.bands.find((candidate) => candidate.allRows.some((row) => row.kind === "queue" && row.name === name));
155
+ if (band && !band.rows.some((row) => row.name === name)) {
156
+ expanded.add(band.key);
157
+ store(EXPANDED_KEY, [...expanded]);
158
+ drawMap();
159
+ }
160
+ const station = map.find(name);
161
+ if (!station) return;
162
+ const svgTop = station.ownerSVGElement.getBoundingClientRect().top + window.scrollY;
163
+ const bandTop = map.bandTop(name) ?? 0;
164
+ window.scrollTo({ top: Math.max(0, svgTop + bandTop - 24), behavior: reducedMotion.matches ? "auto" : "smooth" });
165
+ station.focus({ preventScroll: true });
166
+ map.flash(name);
167
+ }
168
+
169
+ function drawMap() {
170
+ if (!topology) return;
171
+ const bands = new Set(topology.bindings.map((binding) => binding.source)).size;
172
+ const limit = mapHost.clientWidth < 700 || bands > 6 ? 4 : 6;
173
+ model = buildModel(topology, memory, { expanded, limit });
174
+ const hasRows = model.bands.some((band) => band.rows.length);
175
+ empty.hidden = hasRows;
176
+ mapHost.hidden = !hasRows;
177
+ if (!hasRows) {
178
+ empty.textContent = topology.management
179
+ ? "No exchange routes to a queue yet. Lines appear as soon as consumers declare and bind their queues."
180
+ : "Nothing to draw until the management API answers or a worker sends a heartbeat.";
181
+ }
182
+ map.set(model);
183
+ renderHeadline(model.totals);
184
+ const notes = [];
185
+ if (model.unbound.length) {
186
+ const waits = new Map(model.bands.filter((band) => band.delayed && band.delayed.messages).map((band) => [band.key, band.delayed.messages]));
187
+ const names = model.unbound.map((name) => (waits.has(name) ? `${name} (${compact(waits.get(name))} delayed)` : name));
188
+ notes.push(`${names.length === 1 ? "Exchange" : "Exchanges"} with no queue bound: ${names.join(", ")}.`);
189
+ }
190
+ notes.push("The queues table below lists every queue with exact numbers.");
191
+ mapNote.textContent = notes.join(" ");
192
+ }
193
+
194
+ async function refresh() {
195
+ const [topologyData, processes, connections] = await Promise.allSettled([api.topology(), api.processes(), api.connections()]);
196
+ if (destroyed) return;
197
+ if (topologyData.status === "fulfilled") {
198
+ topology = topologyData.value;
199
+ status.hidden = topology.management;
200
+ if (!topology.management) {
201
+ status.textContent = `${topology.management_error || "The RabbitMQ management API did not answer"}. Queues and rates are missing; the map shows what worker heartbeats report.`;
202
+ }
203
+ drawMap();
204
+ queueTable.set(topology, model.bands);
205
+ } else {
206
+ status.hidden = false;
207
+ status.textContent = topologyData.reason.message;
208
+ }
209
+ if (processes.status === "fulfilled") processList.set(processes.value);
210
+ if (connections.status === "fulfilled") connectionTable.set(connections.value);
211
+ }
212
+
213
+ async function refreshRates() {
214
+ const asked = range;
215
+ const overviewData = await api.overview(asked.age);
216
+ if (destroyed || asked !== range) return;
217
+ strip.set(overviewData, asked);
218
+ }
219
+
220
+ const stops = [every(pollMs, refresh), every(() => Math.max(pollMs(), 10_000), refreshRates)];
221
+ return {
222
+ destroy() {
223
+ destroyed = true;
224
+ stops.forEach((stop) => stop());
225
+ map.destroy();
226
+ strip.destroy();
227
+ }
228
+ };
229
+ }
@@ -0,0 +1,101 @@
1
+ import { h, refill } from "../dom.js";
2
+ import { ago, bytes, compact, count, duration, plural, toTime } from "../format.js";
3
+
4
+ const WORKERS_SHOWN = 8;
5
+ const CONSUMERS_SHOWN = 3;
6
+
7
+ const byName = (a, b) => String(a.application || a.name).localeCompare(String(b.application || b.name));
8
+
9
+ function health(process, meta, now) {
10
+ const age = now - (toTime(process.last_heartbeat_at) || 0);
11
+ const interval = (meta.heartbeat_interval || 60) * 1000;
12
+ if (age <= interval * 1.5) return "ok";
13
+ if (age <= (meta.alive_threshold || 300) * 1000) return "late";
14
+ return "stale";
15
+ }
16
+
17
+ function counter(n, word, cls) {
18
+ const shown = compact(n);
19
+ return h("span", { class: n ? cls : null, title: `${count(n)} ${word}` },
20
+ h("span", { "aria-hidden": "true" }, `${shown} ${word}`),
21
+ h("span", { class: "visually-hidden" }, `${count(n)} ${word}`));
22
+ }
23
+
24
+ // Workers older than delay! report neither delays nor a delayed count.
25
+ function consumerItem(consumer) {
26
+ const delays = consumer.delays && consumer.delays.length ? `, delay! after ${consumer.delays.map(duration).join(" or ")}` : "";
27
+ return h("li", { class: "con" },
28
+ h("span", { class: "con-name" },
29
+ h("b", {}, consumer.class_name),
30
+ h("span", {}, ` on ${consumer.queue}, ${plural(consumer.threads || 0, "thread")}${delays}`)),
31
+ h("span", { class: "con-counts" },
32
+ counter(consumer.processed, "processed", null),
33
+ consumer.delayed || delays ? counter(consumer.delayed || 0, "delayed", "is-delayed") : null,
34
+ counter(consumer.rejected, "rejected", "is-rejected"),
35
+ counter(consumer.errored, "errored", "is-errored")));
36
+ }
37
+
38
+ function workerItem(worker, meta, now, open) {
39
+ const state = health(worker, meta, now);
40
+ const beat = h("span", { class: state === "ok" ? null : `is-${state}` },
41
+ state === "ok" ? `heartbeat ${ago(worker.last_heartbeat_at, now)}` : `last heartbeat ${ago(worker.last_heartbeat_at, now)}`);
42
+ const consumers = [...(worker.consumers || [])].sort((a, b) => String(a.class_name).localeCompare(String(b.class_name)));
43
+ return h("li", { class: state === "ok" ? "worker" : `worker is-${state}` },
44
+ h("div", { class: "worker-head" },
45
+ h("span", { class: "worker-name" }, worker.name),
46
+ h("span", { class: "worker-meta" },
47
+ `pid ${worker.pid}, ${bytes(worker.rss_memory)}, ${plural(worker.connections || 0, "connection")}, `, beat)),
48
+ consumers.length ? h("ul", { class: "cons" }, consumers.slice(0, CONSUMERS_SHOWN).map(consumerItem)) : null,
49
+ consumers.length > CONSUMERS_SHOWN
50
+ ? h("details", { class: "con-more", open: open.has(worker.id), ontoggle: (event) => { if (event.target.open) open.add(worker.id); else open.delete(worker.id); } },
51
+ h("summary", { "data-focus-key": `${worker.id}|more` }, `${plural(consumers.length - CONSUMERS_SHOWN, "more consumer")}`),
52
+ h("ul", { class: "cons" }, consumers.slice(CONSUMERS_SHOWN).map(consumerItem)))
53
+ : null);
54
+ }
55
+
56
+ // Supervisors with their workers and each worker's consumers, as the
57
+ // heartbeats last reported them.
58
+ export class ProcessList {
59
+ constructor(host, meta) {
60
+ this.meta = meta;
61
+ this.note = h("p", { class: "section-note" });
62
+ this.list = h("ul", { class: "procs" });
63
+ this.all = false;
64
+ this.open = new Set();
65
+ this.more = h("button", { type: "button", class: "btn btn-quiet btn-small", hidden: true, onclick: () => { this.all = !this.all; this.set(this.processes); } });
66
+ host.append(this.note, this.list, h("div", { class: "list-foot" }, this.more));
67
+ }
68
+
69
+ set(processes) {
70
+ this.processes = processes;
71
+ const now = Date.now();
72
+ const supervisors = processes.filter((p) => p.kind === "Supervisor").sort(byName);
73
+ const workers = processes.filter((p) => p.kind !== "Supervisor").sort(byName);
74
+ const known = new Set(supervisors.map((p) => p.id));
75
+ const hosts = new Set(processes.map((p) => p.hostname));
76
+ const interval = this.meta.heartbeat_interval || 60;
77
+ this.note.textContent = processes.length
78
+ ? `${plural(supervisors.length, "supervisor")} running ${plural(workers.length, "worker")} on ${plural(hosts.size, "host")}. Workers report every ${interval} s; counts run from each worker's start, and a message handed back with delay! counts as delayed, not processed.`
79
+ : "";
80
+ if (!processes.length) {
81
+ refill(this.list, h("li", { class: "empty" }, "No Lepus process has sent a heartbeat. Workers started with lepus start show up here within one heartbeat."));
82
+ return;
83
+ }
84
+ const groups = supervisors.map((supervisor) => ({ supervisor, workers: workers.filter((w) => w.supervisor_id === supervisor.id).sort((a, b) => a.name.localeCompare(b.name)) }));
85
+ const orphans = workers.filter((w) => !known.has(w.supervisor_id));
86
+ if (orphans.length) groups.push({ supervisor: null, workers: orphans });
87
+ this.more.hidden = workers.length <= WORKERS_SHOWN;
88
+ this.more.textContent = this.all ? "Show fewer" : `Show all ${count(workers.length)} workers`;
89
+ let budget = this.all ? Infinity : WORKERS_SHOWN;
90
+ for (const group of groups) {
91
+ group.hidden = Math.max(0, group.workers.length - budget);
92
+ group.workers = group.workers.slice(0, Math.max(0, budget));
93
+ budget -= group.workers.length;
94
+ }
95
+ refill(this.list, groups.filter((group) => group.workers.length || !group.hidden).map(({ supervisor, workers: own, hidden }) => h("li", {},
96
+ h("div", { class: "sup" },
97
+ h("span", { class: "sup-name" }, supervisor ? supervisor.application || supervisor.name : "Without a supervisor"),
98
+ supervisor ? h("span", { class: "sup-meta" }, `${supervisor.hostname}, pid ${supervisor.pid}, ${plural(own.length + hidden, "worker")}`) : null),
99
+ own.length ? h("ul", { class: "workers" }, own.map((worker) => workerItem(worker, this.meta, now, this.open))) : null)));
100
+ }
101
+ }