lepus 0.0.1.beta2 → 0.1.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 (125) hide show
  1. checksums.yaml +4 -4
  2. data/.github/workflows/linter.yml +21 -0
  3. data/.github/workflows/specs.yml +93 -13
  4. data/.gitignore +2 -0
  5. data/.rubocop.yml +10 -0
  6. data/.tool-versions +1 -1
  7. data/Gemfile +7 -0
  8. data/Gemfile.lock +36 -9
  9. data/Makefile +19 -0
  10. data/README.md +562 -7
  11. data/bin/setup +5 -2
  12. data/config.ru +14 -0
  13. data/docker-compose.yml +5 -3
  14. data/docs/README.md +80 -0
  15. data/docs/cli.md +108 -0
  16. data/docs/configuration.md +171 -0
  17. data/docs/consumers.md +168 -0
  18. data/docs/getting-started.md +136 -0
  19. data/docs/images/lepus-web.png +0 -0
  20. data/docs/middleware.md +240 -0
  21. data/docs/producers.md +173 -0
  22. data/docs/prometheus.md +112 -0
  23. data/docs/rails.md +161 -0
  24. data/docs/supervisor.md +112 -0
  25. data/docs/testing.md +141 -0
  26. data/docs/web.md +85 -0
  27. data/examples/grafana-dashboard.json +450 -0
  28. data/gemfiles/Gemfile.rails-5.2 +7 -0
  29. data/gemfiles/{rails52.gemfile.lock → Gemfile.rails-5.2.lock} +102 -69
  30. data/gemfiles/Gemfile.rails-6.1 +7 -0
  31. data/gemfiles/{rails61.gemfile.lock → Gemfile.rails-6.1.lock} +113 -79
  32. data/gemfiles/{rails52.gemfile → Gemfile.rails-7.2} +1 -1
  33. data/gemfiles/Gemfile.rails-7.2.lock +321 -0
  34. data/gemfiles/{rails61.gemfile → Gemfile.rails-8.0} +1 -1
  35. data/gemfiles/Gemfile.rails-8.0.lock +322 -0
  36. data/lepus.gemspec +7 -1
  37. data/lib/lepus/cli.rb +35 -4
  38. data/lib/lepus/configuration.rb +107 -0
  39. data/lib/lepus/connection_pool.rb +135 -0
  40. data/lib/lepus/consumer.rb +59 -41
  41. data/lib/lepus/consumers/config.rb +183 -0
  42. data/lib/lepus/consumers/handler.rb +56 -0
  43. data/lib/lepus/consumers/middleware_chain.rb +22 -0
  44. data/lib/lepus/consumers/middlewares/exception_logger.rb +27 -0
  45. data/lib/lepus/consumers/middlewares/honeybadger.rb +33 -0
  46. data/lib/lepus/consumers/middlewares/json.rb +37 -0
  47. data/lib/lepus/consumers/middlewares/max_retry.rb +83 -0
  48. data/lib/lepus/consumers/middlewares/unique.rb +65 -0
  49. data/lib/lepus/consumers/stats.rb +70 -0
  50. data/lib/lepus/consumers/stats_registry.rb +29 -0
  51. data/lib/lepus/consumers/worker.rb +141 -0
  52. data/lib/lepus/consumers/worker_factory.rb +124 -0
  53. data/lib/lepus/consumers.rb +6 -0
  54. data/lib/lepus/message/delivery_info.rb +72 -0
  55. data/lib/lepus/message/metadata.rb +99 -0
  56. data/lib/lepus/message.rb +88 -5
  57. data/lib/lepus/middleware_chain.rb +83 -0
  58. data/lib/lepus/primitive/hash.rb +29 -0
  59. data/lib/lepus/process.rb +24 -24
  60. data/lib/lepus/process_registry/backend.rb +49 -0
  61. data/lib/lepus/process_registry/file_backend.rb +108 -0
  62. data/lib/lepus/process_registry/message_builder.rb +72 -0
  63. data/lib/lepus/process_registry/rabbitmq_backend.rb +153 -0
  64. data/lib/lepus/process_registry.rb +56 -23
  65. data/lib/lepus/processes/base.rb +0 -5
  66. data/lib/lepus/processes/callbacks.rb +3 -0
  67. data/lib/lepus/processes/interruptible.rb +4 -8
  68. data/lib/lepus/processes/procline.rb +1 -1
  69. data/lib/lepus/processes/registrable.rb +1 -1
  70. data/lib/lepus/processes/runnable.rb +1 -1
  71. data/lib/lepus/processes.rb +15 -0
  72. data/lib/lepus/producer.rb +141 -30
  73. data/lib/lepus/producers/config.rb +46 -0
  74. data/lib/lepus/producers/definition.rb +48 -0
  75. data/lib/lepus/producers/hooks.rb +170 -0
  76. data/lib/lepus/producers/middleware_chain.rb +22 -0
  77. data/lib/lepus/producers/middlewares/correlation_id.rb +37 -0
  78. data/lib/lepus/producers/middlewares/header.rb +47 -0
  79. data/lib/lepus/producers/middlewares/instrumentation.rb +30 -0
  80. data/lib/lepus/producers/middlewares/json.rb +47 -0
  81. data/lib/lepus/producers/middlewares/unique.rb +67 -0
  82. data/lib/lepus/producers.rb +7 -0
  83. data/lib/lepus/prometheus/collector.rb +149 -0
  84. data/lib/lepus/prometheus/instrumentation.rb +168 -0
  85. data/lib/lepus/prometheus.rb +48 -0
  86. data/lib/lepus/publisher.rb +67 -0
  87. data/lib/lepus/supervisor/children_pipes.rb +25 -0
  88. data/lib/lepus/supervisor/lifecycle_hooks.rb +50 -0
  89. data/lib/lepus/supervisor/pidfiled.rb +1 -1
  90. data/lib/lepus/supervisor/registry_cleaner.rb +22 -0
  91. data/lib/lepus/supervisor.rb +129 -25
  92. data/lib/lepus/testing/exchange.rb +95 -0
  93. data/lib/lepus/testing/message_builder.rb +177 -0
  94. data/lib/lepus/testing/rspec_matchers.rb +258 -0
  95. data/lib/lepus/testing.rb +210 -0
  96. data/lib/lepus/unique.rb +18 -0
  97. data/lib/lepus/version.rb +1 -1
  98. data/lib/lepus/web/aggregator.rb +154 -0
  99. data/lib/lepus/web/api.rb +132 -0
  100. data/lib/lepus/web/app.rb +37 -0
  101. data/lib/lepus/web/management_api.rb +192 -0
  102. data/lib/lepus/web/respond_with.rb +28 -0
  103. data/lib/lepus/web.rb +238 -0
  104. data/lib/lepus.rb +39 -28
  105. data/test_offline.html +189 -0
  106. data/web/assets/css/styles.css +635 -0
  107. data/web/assets/js/app.js +6 -0
  108. data/web/assets/js/bootstrap.js +20 -0
  109. data/web/assets/js/controllers/connection_controller.js +44 -0
  110. data/web/assets/js/controllers/dashboard_controller.js +499 -0
  111. data/web/assets/js/controllers/queue_controller.js +17 -0
  112. data/web/assets/js/controllers/theme_controller.js +31 -0
  113. data/web/assets/js/offline-manager.js +233 -0
  114. data/web/assets/js/service-worker-manager.js +65 -0
  115. data/web/index.html +159 -0
  116. data/web/sw.js +144 -0
  117. metadata +177 -18
  118. data/lib/lepus/consumer_config.rb +0 -149
  119. data/lib/lepus/consumer_wrapper.rb +0 -46
  120. data/lib/lepus/lifecycle_hooks.rb +0 -49
  121. data/lib/lepus/middlewares/honeybadger.rb +0 -23
  122. data/lib/lepus/middlewares/json.rb +0 -35
  123. data/lib/lepus/middlewares/max_retry.rb +0 -57
  124. data/lib/lepus/processes/consumer.rb +0 -113
  125. data/lib/lepus/supervisor/config.rb +0 -45
@@ -0,0 +1,499 @@
1
+ (function() {
2
+ StimulusApp.register("dashboard", class extends Stimulus.Controller {
3
+ static targets = [
4
+ "refreshRange", "refreshLabel",
5
+ "processCount", "queueCount", "totalMessages", "memoryUsage",
6
+ "connectionCount", "publishRate", "consumeRate",
7
+ "processDetail", "queueDetail", "messageDetail", "memoryDetail", "connectionDetail", "rateDetail",
8
+ "processesRoot", "queuesRoot"
9
+ ]
10
+
11
+ connect() {
12
+ this.intervalSec = parseInt(this.refreshRangeTarget.value || '15', 10);
13
+ this.refreshLabelTarget.textContent = this.intervalSec;
14
+ this.consumerStates = {}; // Store expanded/collapsed states
15
+ this.queueStates = {}; // Store expanded/collapsed states for queues
16
+ this.setupCharts();
17
+ this.poll();
18
+ this.startTimer();
19
+ }
20
+
21
+ disconnect() {
22
+ if (this.timer) clearInterval(this.timer);
23
+ }
24
+
25
+ updateRefresh() {
26
+ this.intervalSec = parseInt(this.refreshRangeTarget.value, 10);
27
+ this.refreshLabelTarget.textContent = this.intervalSec;
28
+ this.startTimer();
29
+ }
30
+
31
+ startTimer() {
32
+ if (this.timer) clearInterval(this.timer);
33
+ this.timer = setInterval(() => this.poll(), this.intervalSec * 1000);
34
+ }
35
+
36
+ async poll() {
37
+ // Check if we're offline
38
+ if (!navigator.onLine) {
39
+ this.showOfflineMessage();
40
+ return;
41
+ }
42
+
43
+ try {
44
+ const [processes, queues, connections] = await Promise.all([
45
+ this.fetchLepusProcesses(),
46
+ this.fetchRabbitQueues(),
47
+ this.fetchRabbitConnections()
48
+ ]);
49
+ this.renderStats(processes, queues, connections);
50
+ this.renderProcesses(processes);
51
+ this.renderQueues(queues);
52
+ this.updateCharts(queues);
53
+ this.restoreConsumerStates();
54
+ this.restoreQueueStates();
55
+ this.hideOfflineMessage();
56
+ } catch (error) {
57
+ console.warn('Dashboard poll failed:', error);
58
+ this.showOfflineMessage();
59
+ }
60
+ }
61
+
62
+ async fetchLepusProcesses() {
63
+ const r = await fetch('api/processes');
64
+ if (!r.ok) throw new Error(`processes: ${r.status}`);
65
+ return await r.json();
66
+ }
67
+
68
+ async fetchRabbitQueues() {
69
+ const r = await fetch('api/queues');
70
+ if (!r.ok) throw new Error(`queues: ${r.status}`);
71
+ return await r.json();
72
+ }
73
+
74
+ async fetchRabbitConnections() {
75
+ const r = await fetch('api/connections');
76
+ if (!r.ok) throw new Error(`connections: ${r.status}`);
77
+ return await r.json();
78
+ }
79
+
80
+ renderStats(processes, queues, connections) {
81
+ const processCount = processes.length;
82
+ const queueCount = queues.filter(q => !q.name.endsWith('.retry') && !q.name.endsWith('.error')).length;
83
+ const totalMessages = queues.reduce((sum, q) => sum + (q.messages || 0), 0);
84
+ const memory = processes.reduce((sum, p) => sum + (p.rss_memory || 0), 0);
85
+
86
+ // Process details
87
+ const supervisors = processes.filter(p => String(p.kind).toLowerCase() === 'supervisor').length;
88
+ const workers = processes.filter(p => String(p.kind).toLowerCase() === 'worker').length;
89
+ this.processCountTarget.textContent = processCount;
90
+ this.processDetailTarget.textContent = `Supervisors ${supervisors}, Workers ${workers}`;
91
+
92
+ // Queue details
93
+ const runningQueues = queues.filter(q => !q.name.endsWith('.retry') && !q.name.endsWith('.error') && (q.consumers || 0) > 0).length;
94
+ const pausedQueues = queueCount - runningQueues;
95
+ this.queueCountTarget.textContent = queueCount;
96
+ this.queueDetailTarget.textContent = `${runningQueues} running, ${pausedQueues} paused`;
97
+
98
+ // Message details
99
+ const readyMessages = queues.reduce((sum, q) => sum + (q.messages_ready || 0), 0);
100
+ const unackedMessages = queues.reduce((sum, q) => sum + (q.messages_unacknowledged || 0), 0);
101
+ this.totalMessagesTarget.textContent = totalMessages.toLocaleString();
102
+ this.messageDetailTarget.textContent = `${readyMessages.toLocaleString()} ready, ${unackedMessages.toLocaleString()} unacked`;
103
+
104
+ // Memory details
105
+ this.memoryUsageTarget.textContent = (memory / (1024*1024)).toFixed(1) + ' MB';
106
+ this.memoryDetailTarget.textContent = `RSS ${(memory / (1024*1024)).toFixed(1)} MB`;
107
+
108
+ // Connection details
109
+ const activeConnections = connections.filter(c => c.state === 'running' || !c.state).length;
110
+ const idleConnections = connections.length - activeConnections;
111
+ this.connectionCountTarget.textContent = connections.length;
112
+ this.connectionDetailTarget.textContent = `${activeConnections} active, ${idleConnections} idle`;
113
+
114
+ // Rate details - use real data from queue message_stats
115
+ const publishRate = queues.reduce((sum, q) => {
116
+ const stats = q.message_stats || {};
117
+ return sum + (stats.publish_rate || 0);
118
+ }, 0);
119
+ const consumeRate = queues.reduce((sum, q) => {
120
+ const stats = q.message_stats || {};
121
+ return sum + (stats.deliver_get_rate || 0);
122
+ }, 0);
123
+ this.publishRateTarget.textContent = publishRate.toFixed(1);
124
+ this.consumeRateTarget.textContent = consumeRate.toFixed(1);
125
+ this.rateDetailTarget.textContent = `Pub ${publishRate.toFixed(1)} / Con ${consumeRate.toFixed(1)} msg/s`;
126
+ }
127
+
128
+ renderProcesses(processes) {
129
+ // Group by application
130
+ const byApplication = new Map();
131
+
132
+ // First, group supervisors by application
133
+ processes.forEach(p => {
134
+ if (String(p.kind).toLowerCase() === 'supervisor') {
135
+ const app = p.application || 'DefaultApp';
136
+ if (!byApplication.has(app)) {
137
+ byApplication.set(app, { application: app, supervisors: [] });
138
+ }
139
+ byApplication.get(app).supervisors.push(p);
140
+ }
141
+ });
142
+
143
+ // Then, group workers by supervisor
144
+ processes.forEach(p => {
145
+ if (String(p.kind).toLowerCase() === 'worker' && p.supervisor_id) {
146
+ for (const appData of byApplication.values()) {
147
+ const supervisor = appData.supervisors.find(s => s.id === p.supervisor_id);
148
+ if (supervisor) {
149
+ if (!supervisor.workers) supervisor.workers = [];
150
+ supervisor.workers.push(p);
151
+ break;
152
+ }
153
+ }
154
+ }
155
+ });
156
+
157
+ this.processesRootTarget.innerHTML = '';
158
+
159
+ for (const {application, supervisors} of byApplication.values()) {
160
+ // Application Card
161
+ const appCard = document.createElement('div');
162
+ appCard.className = 'card application-card';
163
+ appCard.innerHTML = `
164
+ <div class="card-header">
165
+ <h2><span class="level-label">Application</span> ${this.escape(application)}</h2>
166
+ </div>
167
+ <div class="card-body">
168
+ <div class="supervisors-container">
169
+ ${supervisors.map(supervisor => this.renderSupervisor(supervisor)).join('')}
170
+ </div>
171
+ </div>
172
+ `;
173
+ this.processesRootTarget.appendChild(appCard);
174
+ }
175
+ }
176
+
177
+ renderSupervisor(supervisor) {
178
+ const healthy = this.isHealthy(supervisor.last_heartbeat_at);
179
+ const workers = supervisor.workers || [];
180
+
181
+ return `
182
+ <div class="supervisor-card">
183
+ <div class="card-header">
184
+ <div class="supervisor-info">
185
+ <h3><span class="level-label">Supervisor</span> ${this.escape(supervisor.name)} <span class="badge ${healthy ? 'ok' : 'err'}">${healthy ? 'healthy' : 'unreachable'}</span></h3>
186
+ </div>
187
+ <div class="supervisor-meta">
188
+ <span class="meta-text">PID ${supervisor.pid} • ${this.escape(supervisor.hostname)} • ${(supervisor.rss_memory/(1024*1024)).toFixed(1)} MB</span>
189
+ </div>
190
+ </div>
191
+ <div class="card-body">
192
+ <div class="workers-container">
193
+ ${workers.map(worker => this.renderWorker(worker)).join('')}
194
+ </div>
195
+ </div>
196
+ </div>
197
+ `;
198
+ }
199
+
200
+ renderWorker(worker) {
201
+ const healthy = this.isHealthy(worker.last_heartbeat_at);
202
+ const consumers = worker.consumers || [];
203
+
204
+ return `
205
+ <div class="worker-card">
206
+ <div class="card-header">
207
+ <div class="worker-info">
208
+ <h4><span class="level-label">Worker</span> ${this.escape(worker.name)} <span class="badge ${healthy ? 'ok' : 'err'}">${healthy ? 'healthy' : 'unreachable'}</span></h4>
209
+ </div>
210
+ <div class="worker-meta">
211
+ <span class="meta-text">PID ${worker.pid} • ${worker.connections || 0} connections • ${(worker.rss_memory/(1024*1024)).toFixed(1)} MB</span>
212
+ </div>
213
+ </div>
214
+ <div class="card-body">
215
+ <div class="consumers-container">
216
+ ${consumers.map(consumer => this.renderConsumer(consumer)).join('')}
217
+ </div>
218
+ </div>
219
+ </div>
220
+ `;
221
+ }
222
+
223
+ renderConsumer(consumer) {
224
+ const totalConcurrent = consumer.threads || 1;
225
+ const processed = consumer.processed || 0;
226
+ const rejected = consumer.rejected || 0;
227
+ const errored = consumer.errored || 0;
228
+
229
+ const subscriptionText = totalConcurrent === 1 ? 'subscription' : 'subscriptions';
230
+
231
+ return `
232
+ <div class="consumer-card" data-consumer-id="${consumer.class_name}">
233
+ <div class="card-header consumer-header" data-action="click->dashboard#toggleConsumer">
234
+ <div class="consumer-info">
235
+ <h5><span class="level-label">Consumer</span> ${this.escape(consumer.class_name)} <span class="badge concurrent">${totalConcurrent} ${subscriptionText}</span></h5>
236
+ </div>
237
+ <div class="consumer-meta">
238
+ <div class="consumer-stats-preview">
239
+ <span class="stat-mini processed">${processed}</span>
240
+ <span class="stat-mini rejected">${rejected}</span>
241
+ <span class="stat-mini errored">${errored}</span>
242
+ </div>
243
+ <span class="expand-icon">▼</span>
244
+ </div>
245
+ </div>
246
+ <div class="card-body consumer-details" style="display: none;">
247
+ <div class="consumer-details-content">
248
+ <table class="consumer-table">
249
+ <tr>
250
+ <td class="label">Exchange:</td>
251
+ <td>${this.escape(consumer.exchange)}</td>
252
+ </tr>
253
+ <tr>
254
+ <td class="label">Queue:</td>
255
+ <td>${this.escape(consumer.queue)}</td>
256
+ </tr>
257
+ ${consumer.route ? `
258
+ <tr>
259
+ <td class="label">Route:</td>
260
+ <td>${this.escape(consumer.route)}</td>
261
+ </tr>
262
+ ` : ''}
263
+ <tr>
264
+ <td class="label">Subscriptions:</td>
265
+ <td>${totalConcurrent} ${subscriptionText}</td>
266
+ </tr>
267
+ <tr>
268
+ <td class="label">Stats:</td>
269
+ <td>
270
+ <span class="stat processed">${processed} processed</span>
271
+ <span class="stat rejected">${rejected} rejected</span>
272
+ <span class="stat errored">${errored} errored</span>
273
+ </td>
274
+ </tr>
275
+ </table>
276
+ </div>
277
+ </div>
278
+ </div>
279
+ `;
280
+ }
281
+
282
+ renderQueues(queues) {
283
+ const tbody = this.queuesRootTarget;
284
+ tbody.innerHTML = '';
285
+
286
+ // Group main->(retry,error)
287
+ const map = new Map();
288
+ queues.forEach(q => {
289
+ const base = q.name.replace(/\.(retry|error)$/,'');
290
+ if (!map.has(base)) map.set(base, { main: null, retry: null, error: null });
291
+ const bucket = map.get(base);
292
+ if (q.name.endsWith('.retry')) bucket.retry = q; else if (q.name.endsWith('.error')) bucket.error = q; else bucket.main = q;
293
+ });
294
+
295
+ for (const [base, g] of map.entries()) {
296
+ const q = g.main || {name: base, type: 'classic', messages_ready: 0, messages_unacknowledged: 0, messages: 0, consumers: 0};
297
+ const tr = document.createElement('tr');
298
+ tr.className = 'queue-row';
299
+ tr.dataset.queue = base;
300
+ tr.setAttribute('data-action', 'click->dashboard#toggleQueue');
301
+ tr.innerHTML = this.queueCells(q, true);
302
+ tbody.appendChild(tr);
303
+
304
+ const sub = document.createElement('tr');
305
+ sub.className = 'sub-row';
306
+ const td = document.createElement('td');
307
+ td.colSpan = 8;
308
+ td.innerHTML = `
309
+ <div class="grid" style="grid-template-columns: repeat(auto-fit, minmax(260px, 1fr)); gap: 8px;">
310
+ ${g.retry ? `<div class="metric"><div class="metric-label">Retry</div>${this.queueInline(g.retry)}</div>` : ''}
311
+ ${g.error ? `<div class="metric"><div class="metric-label">Error</div>${this.queueInline(g.error)}</div>` : ''}
312
+ ${!g.retry && !g.error ? '<div class="metric"><div class="metric-label">No extra queues</div><div class="metric-value">—</div></div>' : ''}
313
+ </div>`;
314
+ sub.appendChild(td);
315
+ sub.hidden = true;
316
+ tbody.appendChild(sub);
317
+ }
318
+ }
319
+
320
+ queueCells(q, showToggle) {
321
+ const total = (q.messages != null) ? q.messages : (q.messages_ready || 0) + (q.messages_unacknowledged || 0);
322
+ return `
323
+ <td>${this.escape(q.name)}</td>
324
+ <td><span class="badge">${this.escape(q.type || 'classic')}</span></td>
325
+ <td>${q.messages_ready ?? 0}</td>
326
+ <td>${q.messages_unacknowledged ?? 0}</td>
327
+ <td>${total}</td>
328
+ <td>${q.consumers ?? 0}</td>
329
+ <td>${q.memory ? (q.memory/(1024*1024)).toFixed(1) + ' MB' : '—'}</td>
330
+ <td>${showToggle ? '<button class="btn">Details</button>' : ''}</td>
331
+ `;
332
+ }
333
+
334
+ queueInline(q) {
335
+ const total = (q.messages != null) ? q.messages : (q.messages_ready || 0) + (q.messages_unacknowledged || 0);
336
+ return `
337
+ <div class="queue-detail-content">
338
+ <div class="queue-name">${this.escape(q.name)}</div>
339
+ <div class="queue-stats">
340
+ <div class="stat-item">
341
+ <span class="stat-label">Ready:</span>
342
+ <span class="stat-value">${q.messages_ready ?? 0}</span>
343
+ </div>
344
+ <div class="stat-item">
345
+ <span class="stat-label">Unacked:</span>
346
+ <span class="stat-value">${q.messages_unacknowledged ?? 0}</span>
347
+ </div>
348
+ <div class="stat-item">
349
+ <span class="stat-label">Total:</span>
350
+ <span class="stat-value">${total}</span>
351
+ </div>
352
+ </div>
353
+ </div>
354
+ `;
355
+ }
356
+
357
+ setupCharts() {
358
+ const rateCtx = document.getElementById('rateChart');
359
+ const queueCtx = document.getElementById('queueChart');
360
+
361
+ this.rateChart = new Chart(rateCtx, {
362
+ type: 'line',
363
+ data: {
364
+ labels: [],
365
+ datasets: [
366
+ { label: 'Publish', data: [], borderColor: '#6ea8fe', backgroundColor: 'rgba(110,168,254,0.15)', tension: 0.3, fill: true },
367
+ { label: 'Consume', data: [], borderColor: '#7ee787', backgroundColor: 'rgba(126,231,135,0.15)', tension: 0.3, fill: true }
368
+ ]
369
+ },
370
+ options: {
371
+ responsive: true, maintainAspectRatio: false,
372
+ scales: { x: { ticks: { color: getComputedStyle(document.documentElement).getPropertyValue('--muted') } }, y: { ticks: { color: getComputedStyle(document.documentElement).getPropertyValue('--muted') } } },
373
+ plugins: { legend: { labels: { color: getComputedStyle(document.documentElement).getPropertyValue('--muted') } } }
374
+ }
375
+ });
376
+
377
+ this.queueChart = new Chart(queueCtx, {
378
+ type: 'doughnut',
379
+ data: { labels: [], datasets: [{ data: [], backgroundColor: ['#6ea8fe', '#7ee787', '#f7c948', '#ff6b6b', '#8b5cf6', '#fb923c'] }] },
380
+ options: { responsive: true, maintainAspectRatio: false, plugins: { legend: { position: 'bottom', labels: { color: getComputedStyle(document.documentElement).getPropertyValue('--muted') } } } }
381
+ });
382
+ }
383
+
384
+ updateCharts(queues) {
385
+ const nowLabel = new Date().toLocaleTimeString();
386
+ const pub = queues.reduce((sum, q) => sum + ((q.message_stats || {}).publish_rate || 0), 0);
387
+ const con = queues.reduce((sum, q) => sum + ((q.message_stats || {}).deliver_get_rate || 0), 0);
388
+
389
+ const maxPoints = 20;
390
+ const labels = this.rateChart.data.labels;
391
+ if (labels.length >= maxPoints) { labels.shift(); this.rateChart.data.datasets.forEach(d => d.data.shift()); }
392
+ labels.push(nowLabel);
393
+ this.rateChart.data.datasets[0].data.push(parseFloat(pub.toFixed(2)));
394
+ this.rateChart.data.datasets[1].data.push(parseFloat(con.toFixed(2)));
395
+ this.rateChart.update('none');
396
+
397
+ const main = queues.filter(q => !q.name.endsWith('.retry') && !q.name.endsWith('.error'));
398
+ this.queueChart.data.labels = main.map(q => q.name);
399
+ this.queueChart.data.datasets[0].data = main.map(q => q.messages || 0);
400
+ this.queueChart.update('none');
401
+ }
402
+
403
+ toggleConsumer(event) {
404
+ const header = event.currentTarget;
405
+ const consumerCard = header.closest('.consumer-card');
406
+ const details = consumerCard.querySelector('.consumer-details');
407
+ const expandIcon = header.querySelector('.expand-icon');
408
+ const consumerId = consumerCard.dataset.consumerId;
409
+
410
+ if (details.style.display === 'none') {
411
+ details.style.display = 'block';
412
+ expandIcon.textContent = '▲';
413
+ consumerCard.classList.add('expanded');
414
+ this.consumerStates[consumerId] = true;
415
+ } else {
416
+ details.style.display = 'none';
417
+ expandIcon.textContent = '▼';
418
+ consumerCard.classList.remove('expanded');
419
+ this.consumerStates[consumerId] = false;
420
+ }
421
+ }
422
+
423
+ restoreConsumerStates() {
424
+ Object.keys(this.consumerStates).forEach(consumerId => {
425
+ const consumerCard = document.querySelector(`[data-consumer-id="${consumerId}"]`);
426
+ if (consumerCard && this.consumerStates[consumerId]) {
427
+ const details = consumerCard.querySelector('.consumer-details');
428
+ const expandIcon = consumerCard.querySelector('.expand-icon');
429
+ if (details && expandIcon) {
430
+ details.style.display = 'block';
431
+ expandIcon.textContent = '▲';
432
+ consumerCard.classList.add('expanded');
433
+ }
434
+ }
435
+ });
436
+ }
437
+
438
+ toggleQueue(event) {
439
+ const row = event.currentTarget.closest('tr');
440
+ const queueName = row && row.dataset && row.dataset.queue;
441
+ if (!queueName) return;
442
+
443
+ const sub = row.nextElementSibling;
444
+ if (sub && sub.classList.contains('sub-row')) {
445
+ const isHidden = sub.hidden;
446
+ sub.hidden = !isHidden;
447
+ this.queueStates[queueName] = !isHidden;
448
+ }
449
+ }
450
+
451
+ restoreQueueStates() {
452
+ Object.keys(this.queueStates).forEach(queueName => {
453
+ const queueRow = document.querySelector(`[data-queue="${queueName}"]`);
454
+ if (queueRow && this.queueStates[queueName]) {
455
+ const sub = queueRow.nextElementSibling;
456
+ if (sub && sub.classList.contains('sub-row')) {
457
+ sub.hidden = false;
458
+ }
459
+ }
460
+ });
461
+ }
462
+
463
+ showOfflineMessage() {
464
+ // Update stats to show offline state
465
+ this.processCountTarget.textContent = '—';
466
+ this.queueCountTarget.textContent = '—';
467
+ this.totalMessagesTarget.textContent = '—';
468
+ this.memoryUsageTarget.textContent = '—';
469
+ this.connectionCountTarget.textContent = '—';
470
+ this.publishRateTarget.textContent = '—';
471
+ this.consumeRateTarget.textContent = '—';
472
+
473
+ // Update detail texts
474
+ this.processDetailTarget.textContent = 'Offline';
475
+ this.queueDetailTarget.textContent = 'Offline';
476
+ this.messageDetailTarget.textContent = 'Offline';
477
+ this.memoryDetailTarget.textContent = 'Offline';
478
+ this.connectionDetailTarget.textContent = 'Offline';
479
+ this.rateDetailTarget.textContent = 'Offline';
480
+ }
481
+
482
+ hideOfflineMessage() {
483
+ // Clear any offline indicators - normal operation will update these
484
+ }
485
+
486
+ escape(s) { return String(s).replace(/[&<>]/g, c => ({'&':'&amp;','<':'&lt;','>':'&gt;'}[c])); }
487
+
488
+ // Heartbeats arrive as ISO 8601 strings; Date.now() - <string> is NaN, so
489
+ // parse first and treat an unparseable/missing value as "never seen."
490
+ isHealthy(timestamp, staleMs = 60_000) {
491
+ if (!timestamp) return false;
492
+ const ms = new Date(timestamp).getTime();
493
+ if (Number.isNaN(ms)) return false;
494
+ return (Date.now() - ms) < staleMs;
495
+ }
496
+ });
497
+ })();
498
+
499
+
@@ -0,0 +1,17 @@
1
+ (function() {
2
+ StimulusApp.register("queue", class extends Stimulus.Controller {
3
+ static targets = []
4
+
5
+ toggle(event) {
6
+ const row = event.currentTarget.closest('tr');
7
+ const name = row && row.dataset && row.dataset.queue;
8
+ if (!name) return;
9
+ const sub = row.nextElementSibling;
10
+ if (sub && sub.classList.contains('sub-row')) {
11
+ sub.hidden = !sub.hidden;
12
+ }
13
+ }
14
+ });
15
+ })();
16
+
17
+
@@ -0,0 +1,31 @@
1
+ (function() {
2
+ StimulusApp.register("theme", class extends Stimulus.Controller {
3
+ static targets = ["icon"]
4
+
5
+ connect() {
6
+ const stored = localStorage.getItem("lepus:theme");
7
+ const prefersDark = window.matchMedia && window.matchMedia('(prefers-color-scheme: dark)').matches;
8
+ const theme = stored || (prefersDark ? 'dark' : 'light');
9
+ this.applyTheme(theme);
10
+ }
11
+
12
+ toggle() {
13
+ const current = document.documentElement.getAttribute('data-theme') || 'dark';
14
+ const next = current === 'dark' ? 'light' : 'dark';
15
+ this.applyTheme(next);
16
+ try { localStorage.setItem('lepus:theme', next); } catch (_) {}
17
+ }
18
+
19
+ applyTheme(theme) {
20
+ if (theme === 'light') {
21
+ document.documentElement.setAttribute('data-theme', 'light');
22
+ this.iconTarget.textContent = '🌞';
23
+ } else {
24
+ document.documentElement.setAttribute('data-theme', 'dark');
25
+ this.iconTarget.textContent = '🌙';
26
+ }
27
+ }
28
+ });
29
+ })();
30
+
31
+