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.
- checksums.yaml +4 -4
- data/.github/workflows/linter.yml +21 -0
- data/.github/workflows/specs.yml +93 -13
- data/.gitignore +2 -0
- data/.rubocop.yml +10 -0
- data/.tool-versions +1 -1
- data/Gemfile +7 -0
- data/Gemfile.lock +36 -9
- data/Makefile +19 -0
- data/README.md +562 -7
- data/bin/setup +5 -2
- data/config.ru +14 -0
- data/docker-compose.yml +5 -3
- data/docs/README.md +80 -0
- data/docs/cli.md +108 -0
- data/docs/configuration.md +171 -0
- data/docs/consumers.md +168 -0
- data/docs/getting-started.md +136 -0
- data/docs/images/lepus-web.png +0 -0
- data/docs/middleware.md +240 -0
- data/docs/producers.md +173 -0
- data/docs/prometheus.md +112 -0
- data/docs/rails.md +161 -0
- data/docs/supervisor.md +112 -0
- data/docs/testing.md +141 -0
- data/docs/web.md +85 -0
- data/examples/grafana-dashboard.json +450 -0
- data/gemfiles/Gemfile.rails-5.2 +7 -0
- data/gemfiles/{rails52.gemfile.lock → Gemfile.rails-5.2.lock} +102 -69
- data/gemfiles/Gemfile.rails-6.1 +7 -0
- data/gemfiles/{rails61.gemfile.lock → Gemfile.rails-6.1.lock} +113 -79
- data/gemfiles/{rails52.gemfile → Gemfile.rails-7.2} +1 -1
- data/gemfiles/Gemfile.rails-7.2.lock +321 -0
- data/gemfiles/{rails61.gemfile → Gemfile.rails-8.0} +1 -1
- data/gemfiles/Gemfile.rails-8.0.lock +322 -0
- data/lepus.gemspec +7 -1
- data/lib/lepus/cli.rb +35 -4
- data/lib/lepus/configuration.rb +107 -0
- data/lib/lepus/connection_pool.rb +135 -0
- data/lib/lepus/consumer.rb +59 -41
- data/lib/lepus/consumers/config.rb +183 -0
- data/lib/lepus/consumers/handler.rb +56 -0
- data/lib/lepus/consumers/middleware_chain.rb +22 -0
- data/lib/lepus/consumers/middlewares/exception_logger.rb +27 -0
- data/lib/lepus/consumers/middlewares/honeybadger.rb +33 -0
- data/lib/lepus/consumers/middlewares/json.rb +37 -0
- data/lib/lepus/consumers/middlewares/max_retry.rb +83 -0
- data/lib/lepus/consumers/middlewares/unique.rb +65 -0
- data/lib/lepus/consumers/stats.rb +70 -0
- data/lib/lepus/consumers/stats_registry.rb +29 -0
- data/lib/lepus/consumers/worker.rb +141 -0
- data/lib/lepus/consumers/worker_factory.rb +124 -0
- data/lib/lepus/consumers.rb +6 -0
- data/lib/lepus/message/delivery_info.rb +72 -0
- data/lib/lepus/message/metadata.rb +99 -0
- data/lib/lepus/message.rb +88 -5
- data/lib/lepus/middleware_chain.rb +83 -0
- data/lib/lepus/primitive/hash.rb +29 -0
- data/lib/lepus/process.rb +24 -24
- data/lib/lepus/process_registry/backend.rb +49 -0
- data/lib/lepus/process_registry/file_backend.rb +108 -0
- data/lib/lepus/process_registry/message_builder.rb +72 -0
- data/lib/lepus/process_registry/rabbitmq_backend.rb +153 -0
- data/lib/lepus/process_registry.rb +56 -23
- data/lib/lepus/processes/base.rb +0 -5
- data/lib/lepus/processes/callbacks.rb +3 -0
- data/lib/lepus/processes/interruptible.rb +4 -8
- data/lib/lepus/processes/procline.rb +1 -1
- data/lib/lepus/processes/registrable.rb +1 -1
- data/lib/lepus/processes/runnable.rb +1 -1
- data/lib/lepus/processes.rb +15 -0
- data/lib/lepus/producer.rb +141 -30
- data/lib/lepus/producers/config.rb +46 -0
- data/lib/lepus/producers/definition.rb +48 -0
- data/lib/lepus/producers/hooks.rb +170 -0
- data/lib/lepus/producers/middleware_chain.rb +22 -0
- data/lib/lepus/producers/middlewares/correlation_id.rb +37 -0
- data/lib/lepus/producers/middlewares/header.rb +47 -0
- data/lib/lepus/producers/middlewares/instrumentation.rb +30 -0
- data/lib/lepus/producers/middlewares/json.rb +47 -0
- data/lib/lepus/producers/middlewares/unique.rb +67 -0
- data/lib/lepus/producers.rb +7 -0
- data/lib/lepus/prometheus/collector.rb +149 -0
- data/lib/lepus/prometheus/instrumentation.rb +168 -0
- data/lib/lepus/prometheus.rb +48 -0
- data/lib/lepus/publisher.rb +67 -0
- data/lib/lepus/supervisor/children_pipes.rb +25 -0
- data/lib/lepus/supervisor/lifecycle_hooks.rb +50 -0
- data/lib/lepus/supervisor/pidfiled.rb +1 -1
- data/lib/lepus/supervisor/registry_cleaner.rb +22 -0
- data/lib/lepus/supervisor.rb +129 -25
- data/lib/lepus/testing/exchange.rb +95 -0
- data/lib/lepus/testing/message_builder.rb +177 -0
- data/lib/lepus/testing/rspec_matchers.rb +258 -0
- data/lib/lepus/testing.rb +210 -0
- data/lib/lepus/unique.rb +18 -0
- data/lib/lepus/version.rb +1 -1
- data/lib/lepus/web/aggregator.rb +154 -0
- data/lib/lepus/web/api.rb +132 -0
- data/lib/lepus/web/app.rb +37 -0
- data/lib/lepus/web/management_api.rb +192 -0
- data/lib/lepus/web/respond_with.rb +28 -0
- data/lib/lepus/web.rb +238 -0
- data/lib/lepus.rb +39 -28
- data/test_offline.html +189 -0
- data/web/assets/css/styles.css +635 -0
- data/web/assets/js/app.js +6 -0
- data/web/assets/js/bootstrap.js +20 -0
- data/web/assets/js/controllers/connection_controller.js +44 -0
- data/web/assets/js/controllers/dashboard_controller.js +499 -0
- data/web/assets/js/controllers/queue_controller.js +17 -0
- data/web/assets/js/controllers/theme_controller.js +31 -0
- data/web/assets/js/offline-manager.js +233 -0
- data/web/assets/js/service-worker-manager.js +65 -0
- data/web/index.html +159 -0
- data/web/sw.js +144 -0
- metadata +177 -18
- data/lib/lepus/consumer_config.rb +0 -149
- data/lib/lepus/consumer_wrapper.rb +0 -46
- data/lib/lepus/lifecycle_hooks.rb +0 -49
- data/lib/lepus/middlewares/honeybadger.rb +0 -23
- data/lib/lepus/middlewares/json.rb +0 -35
- data/lib/lepus/middlewares/max_retry.rb +0 -57
- data/lib/lepus/processes/consumer.rb +0 -113
- 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 => ({'&':'&','<':'<','>':'>'}[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
|
+
|