@mayurbhusare/resilient-queue-ui 0.0.0-stage → 1.0.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.
- package/LICENSE +21 -0
- package/README.md +205 -3
- package/package.json +55 -4
- package/src/adapters/express.js +39 -0
- package/src/adapters/fastify.js +192 -0
- package/src/dispatcher.js +936 -0
- package/src/handlers/dlq.js +51 -0
- package/src/handlers/job.js +87 -0
- package/src/handlers/metrics.js +60 -0
- package/src/handlers/queues.js +58 -0
- package/src/index.js +44 -0
- package/src/registry.js +102 -0
- package/src/ui/api-client.js +228 -0
- package/src/ui/app.js +1303 -0
- package/src/ui/index.html +141 -0
- package/src/ui/styles.css +1197 -0
- package/src/ui/views/dlq.js +501 -0
- package/src/ui/views/job.js +549 -0
- package/src/ui/views/overview.js +670 -0
package/src/ui/app.js
ADDED
|
@@ -0,0 +1,1303 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* ResilientQueue Dashboard Application Controller
|
|
5
|
+
* Handles routing, state management, API consumption, and view rendering.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
(function () {
|
|
9
|
+
const ApiClientClass =
|
|
10
|
+
(typeof ResilientQueueApiClient !== "undefined" && ResilientQueueApiClient.ApiClient) ||
|
|
11
|
+
(typeof require === "function" ? require("./api-client").ApiClient : null);
|
|
12
|
+
|
|
13
|
+
const OverviewView =
|
|
14
|
+
(typeof ResilientQueueOverviewView !== "undefined" && ResilientQueueOverviewView) ||
|
|
15
|
+
(typeof require === "function" ? require("./views/overview") : null);
|
|
16
|
+
|
|
17
|
+
const DLQView =
|
|
18
|
+
(typeof ResilientQueueDLQView !== "undefined" && ResilientQueueDLQView) ||
|
|
19
|
+
(typeof require === "function" ? require("./views/dlq") : null);
|
|
20
|
+
|
|
21
|
+
const JobView =
|
|
22
|
+
(typeof ResilientQueueJobView !== "undefined" && ResilientQueueJobView) ||
|
|
23
|
+
(typeof require === "function" ? require("./views/job") : null);
|
|
24
|
+
|
|
25
|
+
class DashboardApp {
|
|
26
|
+
constructor(options = {}) {
|
|
27
|
+
this.containerId = options.containerId || "content-container";
|
|
28
|
+
this.titleId = options.titleId || "view-title";
|
|
29
|
+
this.statusDotId = options.statusDotId || "status-dot";
|
|
30
|
+
this.statusTextId = options.statusTextId || "status-text";
|
|
31
|
+
|
|
32
|
+
// Detect base URL from options, meta tag, or current URL
|
|
33
|
+
let baseUrl = options.baseUrl || "";
|
|
34
|
+
if (!baseUrl && typeof document !== "undefined" && typeof document.querySelector === "function") {
|
|
35
|
+
const metaTag = document.querySelector('meta[name="api-base-url"]');
|
|
36
|
+
if (metaTag && metaTag.content) {
|
|
37
|
+
baseUrl = metaTag.content;
|
|
38
|
+
} else if (typeof window !== "undefined" && window.location && typeof window.location.pathname === "string") {
|
|
39
|
+
// If mounted under /admin/queues, detect basePath
|
|
40
|
+
const pathname = window.location.pathname.replace(/\/ui(?:\/.*)?$/, "").replace(/\/index\.html$/, "");
|
|
41
|
+
baseUrl = pathname.replace(/\/+$/, "");
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
this.apiClient = options.apiClient || (ApiClientClass ? new ApiClientClass({ baseUrl }) : null);
|
|
46
|
+
|
|
47
|
+
this.state = {
|
|
48
|
+
activeView: "overview",
|
|
49
|
+
queues: [],
|
|
50
|
+
loading: false,
|
|
51
|
+
error: null,
|
|
52
|
+
syncError: null,
|
|
53
|
+
lastUpdated: null,
|
|
54
|
+
|
|
55
|
+
// Overview Management, Search, and Sorting State (Milestone 9)
|
|
56
|
+
overview: {
|
|
57
|
+
searchQuery: typeof options.searchQuery === "string" ? options.searchQuery : "",
|
|
58
|
+
statusFilter: typeof options.statusFilter === "string" ? options.statusFilter : "all",
|
|
59
|
+
sortKey: typeof options.sortKey === "string" ? options.sortKey : "name",
|
|
60
|
+
sortOrder: typeof options.sortOrder === "string" ? options.sortOrder : "asc"
|
|
61
|
+
},
|
|
62
|
+
|
|
63
|
+
// Dead Letter Queue (DLQ) Explorer State
|
|
64
|
+
dlq: {
|
|
65
|
+
selectedQueue: null,
|
|
66
|
+
start: 0,
|
|
67
|
+
limit: 50,
|
|
68
|
+
order: "desc",
|
|
69
|
+
total: 0,
|
|
70
|
+
jobs: [],
|
|
71
|
+
loading: false,
|
|
72
|
+
error: null,
|
|
73
|
+
expandedJobIndex: null
|
|
74
|
+
},
|
|
75
|
+
|
|
76
|
+
// Job Inspection State
|
|
77
|
+
job: {
|
|
78
|
+
selectedQueue: null,
|
|
79
|
+
jobId: "",
|
|
80
|
+
job: null,
|
|
81
|
+
loading: false,
|
|
82
|
+
error: null
|
|
83
|
+
}
|
|
84
|
+
};
|
|
85
|
+
|
|
86
|
+
this._queuesRequestId = 0;
|
|
87
|
+
this._dlqRequestId = 0;
|
|
88
|
+
this._jobRequestId = 0;
|
|
89
|
+
|
|
90
|
+
this.pollInterval =
|
|
91
|
+
options.pollInterval !== undefined
|
|
92
|
+
? options.pollInterval
|
|
93
|
+
: options.pollingInterval !== undefined
|
|
94
|
+
? options.pollingInterval
|
|
95
|
+
: 5000;
|
|
96
|
+
if (options.autoRefresh === false) {
|
|
97
|
+
this.pollInterval = 0;
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
this._pollTimer = null;
|
|
101
|
+
this._isPolling = false;
|
|
102
|
+
this._isFetchingQueues = false;
|
|
103
|
+
this._inFlightQueuesCount = 0;
|
|
104
|
+
this._currentQueuesPromise = null;
|
|
105
|
+
this._boundBeforeUnload = null;
|
|
106
|
+
this._boundMobileKeydown = null;
|
|
107
|
+
this._announceTimer = null;
|
|
108
|
+
this._isDestroyed = false;
|
|
109
|
+
|
|
110
|
+
this.viewTitles = {
|
|
111
|
+
overview: "Queue Overview",
|
|
112
|
+
dlq: "Dead Letter Queue (DLQ)",
|
|
113
|
+
job: "Job Inspection"
|
|
114
|
+
};
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
/**
|
|
118
|
+
* Initializes DOM bindings and loads initial data.
|
|
119
|
+
*/
|
|
120
|
+
init() {
|
|
121
|
+
this.bindNavigation();
|
|
122
|
+
this.bindMobileToggle();
|
|
123
|
+
this.bindRefresh();
|
|
124
|
+
this.bindPollingControls();
|
|
125
|
+
this.bindHashRouting();
|
|
126
|
+
this.bindUnload();
|
|
127
|
+
|
|
128
|
+
// Initial route from URL hash if present
|
|
129
|
+
if (typeof window !== "undefined" && window.location.hash) {
|
|
130
|
+
this.handleHashChange();
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
this.fetchQueues();
|
|
134
|
+
|
|
135
|
+
// Start auto-refresh polling if on overview view and interval > 0
|
|
136
|
+
if (this.state.activeView === "overview" && this.pollInterval > 0) {
|
|
137
|
+
this.startPolling();
|
|
138
|
+
}
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
/**
|
|
142
|
+
* Starts background polling for overview metrics at the configured or specified interval.
|
|
143
|
+
*
|
|
144
|
+
* @param {number} [intervalMs]
|
|
145
|
+
*/
|
|
146
|
+
startPolling(intervalMs) {
|
|
147
|
+
if (typeof intervalMs === "number" && !Number.isNaN(intervalMs)) {
|
|
148
|
+
this.pollInterval = Math.max(0, intervalMs);
|
|
149
|
+
}
|
|
150
|
+
if (this._isDestroyed || this.pollInterval <= 0 || this.state.activeView !== "overview") {
|
|
151
|
+
this.stopPolling();
|
|
152
|
+
return;
|
|
153
|
+
}
|
|
154
|
+
this._isPolling = true;
|
|
155
|
+
this._scheduleNextPoll();
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
/**
|
|
159
|
+
* Stops active background polling and clears any pending timers.
|
|
160
|
+
*/
|
|
161
|
+
stopPolling() {
|
|
162
|
+
this._isPolling = false;
|
|
163
|
+
if (this._pollTimer) {
|
|
164
|
+
clearTimeout(this._pollTimer);
|
|
165
|
+
this._pollTimer = null;
|
|
166
|
+
}
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
/**
|
|
170
|
+
* Updates polling interval and adjusts timer state accordingly.
|
|
171
|
+
*
|
|
172
|
+
* @param {number|string} intervalMs
|
|
173
|
+
*/
|
|
174
|
+
setPollInterval(intervalMs) {
|
|
175
|
+
const num = parseInt(intervalMs, 10);
|
|
176
|
+
this.pollInterval = !Number.isNaN(num) && num >= 0 ? num : 0;
|
|
177
|
+
|
|
178
|
+
if (typeof document !== "undefined") {
|
|
179
|
+
const select = document.getElementById("poll-interval-select");
|
|
180
|
+
if (select && select.value !== String(this.pollInterval)) {
|
|
181
|
+
select.value = String(this.pollInterval);
|
|
182
|
+
}
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
if (this.pollInterval <= 0 || this.state.activeView !== "overview") {
|
|
186
|
+
this.stopPolling();
|
|
187
|
+
} else {
|
|
188
|
+
this.startPolling();
|
|
189
|
+
}
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
/**
|
|
193
|
+
* Schedules the next polling tick using setTimeout to guarantee zero overlapping requests.
|
|
194
|
+
* @private
|
|
195
|
+
*/
|
|
196
|
+
_scheduleNextPoll() {
|
|
197
|
+
if (this._pollTimer) {
|
|
198
|
+
clearTimeout(this._pollTimer);
|
|
199
|
+
this._pollTimer = null;
|
|
200
|
+
}
|
|
201
|
+
if (this._isDestroyed || !this._isPolling || this.pollInterval <= 0 || this.state.activeView !== "overview") {
|
|
202
|
+
return;
|
|
203
|
+
}
|
|
204
|
+
this._pollTimer = setTimeout(async () => {
|
|
205
|
+
this._pollTimer = null;
|
|
206
|
+
await this._pollTick();
|
|
207
|
+
}, this.pollInterval);
|
|
208
|
+
|
|
209
|
+
if (this._pollTimer && typeof this._pollTimer.unref === "function") {
|
|
210
|
+
this._pollTimer.unref();
|
|
211
|
+
}
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
/**
|
|
215
|
+
* Executes a single polling tick with overlap prevention.
|
|
216
|
+
* @private
|
|
217
|
+
*/
|
|
218
|
+
async _pollTick() {
|
|
219
|
+
if (this._isDestroyed || !this._isPolling || this.state.activeView !== "overview") {
|
|
220
|
+
return;
|
|
221
|
+
}
|
|
222
|
+
if (this._isFetchingQueues) {
|
|
223
|
+
// Avoid overlapping requests if previous fetch is still running
|
|
224
|
+
this._scheduleNextPoll();
|
|
225
|
+
return;
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
try {
|
|
229
|
+
await this.fetchQueues({ isAutoRefresh: true });
|
|
230
|
+
} catch {
|
|
231
|
+
// Handled inside fetchQueues
|
|
232
|
+
} finally {
|
|
233
|
+
if (!this._isDestroyed && this._isPolling && this.state.activeView === "overview") {
|
|
234
|
+
this._scheduleNextPoll();
|
|
235
|
+
}
|
|
236
|
+
}
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
/**
|
|
240
|
+
* Cleans up timers and event listeners when view or app is destroyed.
|
|
241
|
+
*/
|
|
242
|
+
destroy() {
|
|
243
|
+
this._isDestroyed = true;
|
|
244
|
+
this.stopPolling();
|
|
245
|
+
if (this._announceTimer) {
|
|
246
|
+
clearTimeout(this._announceTimer);
|
|
247
|
+
this._announceTimer = null;
|
|
248
|
+
}
|
|
249
|
+
if (typeof window !== "undefined") {
|
|
250
|
+
if (this._boundBeforeUnload) {
|
|
251
|
+
window.removeEventListener("beforeunload", this._boundBeforeUnload);
|
|
252
|
+
window.removeEventListener("pagehide", this._boundBeforeUnload);
|
|
253
|
+
this._boundBeforeUnload = null;
|
|
254
|
+
}
|
|
255
|
+
}
|
|
256
|
+
if (typeof document !== "undefined" && typeof document.removeEventListener === "function" && this._boundMobileKeydown) {
|
|
257
|
+
document.removeEventListener("keydown", this._boundMobileKeydown);
|
|
258
|
+
this._boundMobileKeydown = null;
|
|
259
|
+
}
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
/**
|
|
263
|
+
* Emits accessible, discrete screen-reader announcements to #live-announcer.
|
|
264
|
+
*
|
|
265
|
+
* @param {string} message Text to announce to assistive technologies
|
|
266
|
+
*/
|
|
267
|
+
announce(message) {
|
|
268
|
+
if (typeof document === "undefined" || !message || this._isDestroyed) return;
|
|
269
|
+
const announcer = document.getElementById("live-announcer");
|
|
270
|
+
if (!announcer) return;
|
|
271
|
+
|
|
272
|
+
if (this._announceTimer) {
|
|
273
|
+
clearTimeout(this._announceTimer);
|
|
274
|
+
this._announceTimer = null;
|
|
275
|
+
}
|
|
276
|
+
|
|
277
|
+
announcer.textContent = "";
|
|
278
|
+
this._announceTimer = setTimeout(() => {
|
|
279
|
+
this._announceTimer = null;
|
|
280
|
+
if (!this._isDestroyed && announcer) {
|
|
281
|
+
announcer.textContent = message;
|
|
282
|
+
}
|
|
283
|
+
}, 50);
|
|
284
|
+
|
|
285
|
+
if (this._announceTimer && typeof this._announceTimer.unref === "function") {
|
|
286
|
+
this._announceTimer.unref();
|
|
287
|
+
}
|
|
288
|
+
}
|
|
289
|
+
|
|
290
|
+
/**
|
|
291
|
+
* Fetches queue metrics from GET /api/queues and updates view.
|
|
292
|
+
* Supports graceful error handling, stale response discarding, and overlap prevention.
|
|
293
|
+
*
|
|
294
|
+
* @param {object} [options={}]
|
|
295
|
+
* @param {boolean} [options.isAutoRefresh=false]
|
|
296
|
+
* @param {boolean} [options.skipIfBusy=false]
|
|
297
|
+
* @param {boolean} [options.preventOverlap=false]
|
|
298
|
+
*/
|
|
299
|
+
fetchQueues(options = {}) {
|
|
300
|
+
if (!this.apiClient) {
|
|
301
|
+
return Promise.resolve();
|
|
302
|
+
}
|
|
303
|
+
|
|
304
|
+
if ((options.skipIfBusy || options.preventOverlap) && this._isFetchingQueues && this._currentQueuesPromise) {
|
|
305
|
+
return this._currentQueuesPromise;
|
|
306
|
+
}
|
|
307
|
+
|
|
308
|
+
this._isFetchingQueues = true;
|
|
309
|
+
this._inFlightQueuesCount = (this._inFlightQueuesCount || 0) + 1;
|
|
310
|
+
const requestId = ++this._queuesRequestId;
|
|
311
|
+
const isAutoRefresh = Boolean(options.isAutoRefresh);
|
|
312
|
+
|
|
313
|
+
this.state.loading = true;
|
|
314
|
+
if (this.state.queues.length === 0) {
|
|
315
|
+
this.state.error = null;
|
|
316
|
+
}
|
|
317
|
+
this.updateStatusIndicator("loading", isAutoRefresh ? "Refreshing metrics..." : "Fetching metrics...");
|
|
318
|
+
|
|
319
|
+
if (this.state.activeView === "overview" && this.state.queues.length === 0) {
|
|
320
|
+
this.renderView();
|
|
321
|
+
}
|
|
322
|
+
|
|
323
|
+
const promise = (async () => {
|
|
324
|
+
try {
|
|
325
|
+
const response = await this.apiClient.getQueues();
|
|
326
|
+
if (this._isDestroyed || requestId !== this._queuesRequestId) {
|
|
327
|
+
return; // Discard stale response or post-destruction
|
|
328
|
+
}
|
|
329
|
+
|
|
330
|
+
this.state.queues = Array.isArray(response?.queues) ? response.queues : [];
|
|
331
|
+
this.state.loading = false;
|
|
332
|
+
this.state.error = null;
|
|
333
|
+
this.state.syncError = null;
|
|
334
|
+
this.state.lastUpdated = new Date();
|
|
335
|
+
this.updateStatusIndicator("online", `Connected (${this.state.queues.length} queues)`);
|
|
336
|
+
this.announce(isAutoRefresh ? "Queue metrics updated" : `Loaded ${this.state.queues.length} registered queues`);
|
|
337
|
+
|
|
338
|
+
// If on DLQ view without selected queue, pick a candidate (initial or manual load only, not auto-refresh)
|
|
339
|
+
if (!isAutoRefresh && this.state.activeView === "dlq" && !this.state.dlq.selectedQueue && this.state.queues.length > 0) {
|
|
340
|
+
const candidate = this.state.queues.find((q) => (q?.metrics?.dlq || 0) > 0) || this.state.queues[0];
|
|
341
|
+
if (candidate?.name) {
|
|
342
|
+
this.state.dlq.selectedQueue = candidate.name;
|
|
343
|
+
this.fetchDLQ();
|
|
344
|
+
}
|
|
345
|
+
}
|
|
346
|
+
|
|
347
|
+
// If on Job view without selected queue, pick a candidate (initial or manual load only)
|
|
348
|
+
if (!isAutoRefresh && this.state.activeView === "job" && !this.state.job.selectedQueue && this.state.queues.length > 0) {
|
|
349
|
+
this.state.job.selectedQueue = this.state.queues[0].name;
|
|
350
|
+
}
|
|
351
|
+
} catch (err) {
|
|
352
|
+
if (this._isDestroyed || requestId !== this._queuesRequestId) {
|
|
353
|
+
return; // Discard stale error or post-destruction
|
|
354
|
+
}
|
|
355
|
+
|
|
356
|
+
this.state.loading = false;
|
|
357
|
+
this.state.error = err;
|
|
358
|
+
|
|
359
|
+
if (this.state.queues.length > 0) {
|
|
360
|
+
// Gracefully preserve existing data and report sync error
|
|
361
|
+
this.state.syncError = err;
|
|
362
|
+
this.updateStatusIndicator("offline", `Sync error: ${err.message || "Failed to refresh"}`);
|
|
363
|
+
} else {
|
|
364
|
+
this.updateStatusIndicator("offline", "API Error");
|
|
365
|
+
}
|
|
366
|
+
this.announce(`Failed to retrieve queues: ${err.message || "API Error"}`);
|
|
367
|
+
} finally {
|
|
368
|
+
this._inFlightQueuesCount = Math.max(0, (this._inFlightQueuesCount || 1) - 1);
|
|
369
|
+
if (this._inFlightQueuesCount === 0) {
|
|
370
|
+
this._isFetchingQueues = false;
|
|
371
|
+
this._currentQueuesPromise = null;
|
|
372
|
+
}
|
|
373
|
+
}
|
|
374
|
+
|
|
375
|
+
// If an auto-refresh finishes after navigating away from overview, do not re-render the other view
|
|
376
|
+
if (!this._isDestroyed && (!isAutoRefresh || this.state.activeView === "overview")) {
|
|
377
|
+
this.renderView();
|
|
378
|
+
}
|
|
379
|
+
})();
|
|
380
|
+
|
|
381
|
+
this._currentQueuesPromise = promise;
|
|
382
|
+
return promise;
|
|
383
|
+
}
|
|
384
|
+
|
|
385
|
+
|
|
386
|
+
/**
|
|
387
|
+
* Fetches Dead Letter Queue items for the currently selected queue.
|
|
388
|
+
*/
|
|
389
|
+
async fetchDLQ() {
|
|
390
|
+
if (this._isDestroyed || !this.apiClient || !this.state.dlq.selectedQueue) {
|
|
391
|
+
return;
|
|
392
|
+
}
|
|
393
|
+
|
|
394
|
+
const requestId = ++this._dlqRequestId;
|
|
395
|
+
const requestedQueue = this.state.dlq.selectedQueue;
|
|
396
|
+
|
|
397
|
+
this.state.dlq.loading = true;
|
|
398
|
+
this.state.dlq.error = null;
|
|
399
|
+
if (!this._isDestroyed && this.state.activeView === "dlq") {
|
|
400
|
+
this.renderView();
|
|
401
|
+
}
|
|
402
|
+
|
|
403
|
+
try {
|
|
404
|
+
const response = await this.apiClient.getQueueDLQ(requestedQueue, {
|
|
405
|
+
start: this.state.dlq.start,
|
|
406
|
+
limit: this.state.dlq.limit,
|
|
407
|
+
order: this.state.dlq.order
|
|
408
|
+
});
|
|
409
|
+
|
|
410
|
+
if (
|
|
411
|
+
this._isDestroyed ||
|
|
412
|
+
requestId !== this._dlqRequestId ||
|
|
413
|
+
this.state.dlq.selectedQueue !== requestedQueue
|
|
414
|
+
) {
|
|
415
|
+
return;
|
|
416
|
+
}
|
|
417
|
+
|
|
418
|
+
this.state.dlq.loading = false;
|
|
419
|
+
this.state.dlq.jobs = Array.isArray(response?.jobs) ? response.jobs : [];
|
|
420
|
+
this.state.dlq.total = typeof response?.total === "number" ? response.total : this.state.dlq.jobs.length;
|
|
421
|
+
this.state.dlq.start = typeof response?.start === "number" ? response.start : this.state.dlq.start;
|
|
422
|
+
this.state.dlq.limit = typeof response?.limit === "number" ? response.limit : this.state.dlq.limit;
|
|
423
|
+
this.state.dlq.order = response?.order || this.state.dlq.order;
|
|
424
|
+
this.announce(`Loaded ${this.state.dlq.jobs.length} failed jobs for queue ${requestedQueue}`);
|
|
425
|
+
} catch (err) {
|
|
426
|
+
if (
|
|
427
|
+
this._isDestroyed ||
|
|
428
|
+
requestId !== this._dlqRequestId ||
|
|
429
|
+
this.state.dlq.selectedQueue !== requestedQueue
|
|
430
|
+
) {
|
|
431
|
+
return;
|
|
432
|
+
}
|
|
433
|
+
|
|
434
|
+
this.state.dlq.loading = false;
|
|
435
|
+
this.state.dlq.error = err;
|
|
436
|
+
this.state.dlq.jobs = [];
|
|
437
|
+
this.announce(`Failed to retrieve DLQ jobs for queue ${requestedQueue}`);
|
|
438
|
+
}
|
|
439
|
+
|
|
440
|
+
if (!this._isDestroyed && this.state.activeView === "dlq") {
|
|
441
|
+
this.renderView();
|
|
442
|
+
}
|
|
443
|
+
}
|
|
444
|
+
|
|
445
|
+
/**
|
|
446
|
+
* Fetches job descriptor by queue and jobId from GET /api/queues/:queueName/jobs/:jobId.
|
|
447
|
+
*
|
|
448
|
+
* @param {string} [queueName]
|
|
449
|
+
* @param {string} [jobId]
|
|
450
|
+
*/
|
|
451
|
+
async fetchJob(queueName, jobId) {
|
|
452
|
+
if (this._isDestroyed || !this.apiClient) return;
|
|
453
|
+
|
|
454
|
+
const trimmedQueue = (typeof queueName === "string" ? queueName : (this.state.job.selectedQueue || "")).trim();
|
|
455
|
+
const trimmedJobId = (typeof jobId === "string" ? jobId : (this.state.job.jobId || "")).trim();
|
|
456
|
+
|
|
457
|
+
if (!trimmedQueue || !trimmedJobId) {
|
|
458
|
+
this.state.job.selectedQueue = trimmedQueue || null;
|
|
459
|
+
this.state.job.jobId = trimmedJobId;
|
|
460
|
+
this.state.job.job = null;
|
|
461
|
+
this.state.job.error = {
|
|
462
|
+
code: "VALIDATION_ERROR",
|
|
463
|
+
message: !trimmedQueue ? "Please select a registered queue." : "jobId must be a non-empty string.",
|
|
464
|
+
status: 400
|
|
465
|
+
};
|
|
466
|
+
if (!this._isDestroyed && this.state.activeView === "job") {
|
|
467
|
+
this.renderView();
|
|
468
|
+
}
|
|
469
|
+
return;
|
|
470
|
+
}
|
|
471
|
+
|
|
472
|
+
this.state.job.selectedQueue = trimmedQueue;
|
|
473
|
+
this.state.job.jobId = trimmedJobId;
|
|
474
|
+
this.state.job.loading = true;
|
|
475
|
+
this.state.job.error = null;
|
|
476
|
+
if (!this._isDestroyed && this.state.activeView === "job") {
|
|
477
|
+
this.renderView();
|
|
478
|
+
}
|
|
479
|
+
|
|
480
|
+
const requestId = ++this._jobRequestId;
|
|
481
|
+
|
|
482
|
+
try {
|
|
483
|
+
const response = await this.apiClient.getJob(trimmedQueue, trimmedJobId);
|
|
484
|
+
|
|
485
|
+
if (
|
|
486
|
+
this._isDestroyed ||
|
|
487
|
+
requestId !== this._jobRequestId ||
|
|
488
|
+
this.state.job.selectedQueue !== trimmedQueue ||
|
|
489
|
+
this.state.job.jobId !== trimmedJobId
|
|
490
|
+
) {
|
|
491
|
+
return; // Discard stale response or post-destruction
|
|
492
|
+
}
|
|
493
|
+
|
|
494
|
+
this.state.job.loading = false;
|
|
495
|
+
this.state.job.job = response?.job || null;
|
|
496
|
+
this.state.job.error = null;
|
|
497
|
+
this.announce(`Loaded job details for ${trimmedJobId}`);
|
|
498
|
+
} catch (err) {
|
|
499
|
+
if (
|
|
500
|
+
this._isDestroyed ||
|
|
501
|
+
requestId !== this._jobRequestId ||
|
|
502
|
+
this.state.job.selectedQueue !== trimmedQueue ||
|
|
503
|
+
this.state.job.jobId !== trimmedJobId
|
|
504
|
+
) {
|
|
505
|
+
return; // Discard stale error or post-destruction
|
|
506
|
+
}
|
|
507
|
+
|
|
508
|
+
this.state.job.loading = false;
|
|
509
|
+
this.state.job.job = null;
|
|
510
|
+
this.state.job.error = err;
|
|
511
|
+
this.announce(`Job lookup failed for ${trimmedJobId}`);
|
|
512
|
+
}
|
|
513
|
+
|
|
514
|
+
if (!this._isDestroyed && this.state.activeView === "job") {
|
|
515
|
+
this.renderView();
|
|
516
|
+
}
|
|
517
|
+
}
|
|
518
|
+
|
|
519
|
+
/**
|
|
520
|
+
* Updates top-bar status dot and text.
|
|
521
|
+
*/
|
|
522
|
+
updateStatusIndicator(state, text) {
|
|
523
|
+
if (typeof document === "undefined") return;
|
|
524
|
+
|
|
525
|
+
const dot = document.getElementById(this.statusDotId);
|
|
526
|
+
const label = document.getElementById(this.statusTextId);
|
|
527
|
+
|
|
528
|
+
if (dot) {
|
|
529
|
+
dot.className = "status-dot";
|
|
530
|
+
if (state === "loading") dot.classList.add("loading");
|
|
531
|
+
if (state === "offline") dot.classList.add("offline");
|
|
532
|
+
}
|
|
533
|
+
|
|
534
|
+
if (label && text) {
|
|
535
|
+
label.textContent = text;
|
|
536
|
+
}
|
|
537
|
+
}
|
|
538
|
+
|
|
539
|
+
/**
|
|
540
|
+
* Switches the active navigation view.
|
|
541
|
+
*
|
|
542
|
+
* @param {"overview"|"dlq"|"job"} viewName
|
|
543
|
+
* @param {object} [params={}] Optional routing parameters (e.g. { queue: "emails" })
|
|
544
|
+
*/
|
|
545
|
+
navigate(viewName, params = {}) {
|
|
546
|
+
if (!this.viewTitles[viewName]) {
|
|
547
|
+
viewName = "overview";
|
|
548
|
+
}
|
|
549
|
+
|
|
550
|
+
const previousView = this.state.activeView;
|
|
551
|
+
this.state.activeView = viewName;
|
|
552
|
+
|
|
553
|
+
// Timer cleanup when navigating away from overview monitoring view
|
|
554
|
+
if (previousView === "overview" && viewName !== "overview") {
|
|
555
|
+
this.stopPolling();
|
|
556
|
+
} else if (viewName === "overview" && previousView !== "overview" && this.pollInterval > 0) {
|
|
557
|
+
this.startPolling();
|
|
558
|
+
}
|
|
559
|
+
|
|
560
|
+
if (viewName === "dlq") {
|
|
561
|
+
if (params.queue) {
|
|
562
|
+
this.state.dlq.selectedQueue = params.queue;
|
|
563
|
+
this.state.dlq.start = 0;
|
|
564
|
+
this.state.dlq.expandedJobIndex = null;
|
|
565
|
+
this.fetchDLQ();
|
|
566
|
+
} else if (!this.state.dlq.selectedQueue && this.state.queues.length > 0) {
|
|
567
|
+
const candidate = this.state.queues.find((q) => (q?.metrics?.dlq || 0) > 0) || this.state.queues[0];
|
|
568
|
+
if (candidate?.name) {
|
|
569
|
+
this.state.dlq.selectedQueue = candidate.name;
|
|
570
|
+
this.state.dlq.start = 0;
|
|
571
|
+
this.state.dlq.expandedJobIndex = null;
|
|
572
|
+
this.fetchDLQ();
|
|
573
|
+
}
|
|
574
|
+
}
|
|
575
|
+
}
|
|
576
|
+
|
|
577
|
+
if (viewName === "job") {
|
|
578
|
+
if (params.queue) {
|
|
579
|
+
this.state.job.selectedQueue = params.queue;
|
|
580
|
+
} else if (!this.state.job.selectedQueue && this.state.queues.length > 0) {
|
|
581
|
+
this.state.job.selectedQueue = this.state.queues[0].name;
|
|
582
|
+
}
|
|
583
|
+
|
|
584
|
+
if (params.jobId) {
|
|
585
|
+
this.state.job.jobId = params.jobId;
|
|
586
|
+
this.fetchJob(this.state.job.selectedQueue, params.jobId);
|
|
587
|
+
}
|
|
588
|
+
}
|
|
589
|
+
|
|
590
|
+
if (typeof window !== "undefined" && window.location) {
|
|
591
|
+
let hash = `#/${viewName}`;
|
|
592
|
+
if (viewName === "dlq" && this.state.dlq.selectedQueue) {
|
|
593
|
+
hash += `?queue=${encodeURIComponent(this.state.dlq.selectedQueue)}`;
|
|
594
|
+
} else if (viewName === "job" && this.state.job.selectedQueue) {
|
|
595
|
+
hash += `?queue=${encodeURIComponent(this.state.job.selectedQueue)}`;
|
|
596
|
+
if (this.state.job.jobId) {
|
|
597
|
+
hash += `&jobId=${encodeURIComponent(this.state.job.jobId)}`;
|
|
598
|
+
}
|
|
599
|
+
}
|
|
600
|
+
window.location.hash = hash;
|
|
601
|
+
}
|
|
602
|
+
|
|
603
|
+
this.updateNavigationUI();
|
|
604
|
+
this.renderView();
|
|
605
|
+
this.closeMobileSidebar(false);
|
|
606
|
+
|
|
607
|
+
if (typeof document !== "undefined") {
|
|
608
|
+
const container = document.getElementById(this.containerId);
|
|
609
|
+
if (container && typeof container.focus === "function") {
|
|
610
|
+
container.focus();
|
|
611
|
+
}
|
|
612
|
+
}
|
|
613
|
+
|
|
614
|
+
this.announce(`Navigated to ${this.viewTitles[this.state.activeView] || this.state.activeView}`);
|
|
615
|
+
}
|
|
616
|
+
|
|
617
|
+
/**
|
|
618
|
+
* Updates active styling and ARIA attributes on sidebar links.
|
|
619
|
+
*/
|
|
620
|
+
updateNavigationUI() {
|
|
621
|
+
if (typeof document === "undefined") return;
|
|
622
|
+
|
|
623
|
+
const navButtons = document.querySelectorAll(".sidebar-nav .nav-link");
|
|
624
|
+
navButtons.forEach((btn) => {
|
|
625
|
+
const targetView = btn.getAttribute("data-view");
|
|
626
|
+
if (targetView === this.state.activeView) {
|
|
627
|
+
btn.classList.add("active");
|
|
628
|
+
btn.setAttribute("aria-current", "page");
|
|
629
|
+
} else {
|
|
630
|
+
btn.classList.remove("active");
|
|
631
|
+
btn.removeAttribute("aria-current");
|
|
632
|
+
}
|
|
633
|
+
});
|
|
634
|
+
|
|
635
|
+
const titleEl = document.getElementById(this.titleId);
|
|
636
|
+
if (titleEl) {
|
|
637
|
+
titleEl.textContent = this.viewTitles[this.state.activeView] || "Dashboard";
|
|
638
|
+
}
|
|
639
|
+
}
|
|
640
|
+
|
|
641
|
+
/**
|
|
642
|
+
* Renders active view into content container.
|
|
643
|
+
*/
|
|
644
|
+
renderView() {
|
|
645
|
+
if (this._isDestroyed || typeof document === "undefined") return;
|
|
646
|
+
|
|
647
|
+
const container = document.getElementById(this.containerId);
|
|
648
|
+
if (!container) return;
|
|
649
|
+
|
|
650
|
+
const wasInsideContainer = Boolean(
|
|
651
|
+
container &&
|
|
652
|
+
document.activeElement &&
|
|
653
|
+
document.activeElement !== document.body &&
|
|
654
|
+
document.activeElement !== container &&
|
|
655
|
+
(typeof container.contains === "function" ? container.contains(document.activeElement) : true)
|
|
656
|
+
);
|
|
657
|
+
|
|
658
|
+
const activeElementId = wasInsideContainer && document.activeElement ? document.activeElement.id : null;
|
|
659
|
+
const cursorStart =
|
|
660
|
+
wasInsideContainer &&
|
|
661
|
+
activeElementId === "overview-search-input" &&
|
|
662
|
+
typeof document.activeElement.selectionStart === "number"
|
|
663
|
+
? document.activeElement.selectionStart
|
|
664
|
+
: null;
|
|
665
|
+
const cursorEnd =
|
|
666
|
+
wasInsideContainer &&
|
|
667
|
+
activeElementId === "overview-search-input" &&
|
|
668
|
+
typeof document.activeElement.selectionEnd === "number"
|
|
669
|
+
? document.activeElement.selectionEnd
|
|
670
|
+
: null;
|
|
671
|
+
const activeSortKey =
|
|
672
|
+
wasInsideContainer &&
|
|
673
|
+
document.activeElement &&
|
|
674
|
+
document.activeElement.classList?.contains("sort-header-btn")
|
|
675
|
+
? document.activeElement.getAttribute("data-sort-key")
|
|
676
|
+
: null;
|
|
677
|
+
const activeJobIndex =
|
|
678
|
+
wasInsideContainer &&
|
|
679
|
+
document.activeElement &&
|
|
680
|
+
document.activeElement.classList?.contains("dlq-inspect-btn")
|
|
681
|
+
? document.activeElement.getAttribute("data-job-index")
|
|
682
|
+
: null;
|
|
683
|
+
const activeQueueDlqBtn =
|
|
684
|
+
wasInsideContainer &&
|
|
685
|
+
document.activeElement &&
|
|
686
|
+
document.activeElement.classList?.contains("overview-dlq-btn")
|
|
687
|
+
? document.activeElement.getAttribute("data-queue")
|
|
688
|
+
: null;
|
|
689
|
+
const activeQueueJobBtn =
|
|
690
|
+
wasInsideContainer &&
|
|
691
|
+
document.activeElement &&
|
|
692
|
+
document.activeElement.classList?.contains("overview-job-btn")
|
|
693
|
+
? document.activeElement.getAttribute("data-queue")
|
|
694
|
+
: null;
|
|
695
|
+
|
|
696
|
+
let html = "";
|
|
697
|
+
|
|
698
|
+
if (this.state.activeView === "overview") {
|
|
699
|
+
if (this.state.loading && this.state.queues.length === 0) {
|
|
700
|
+
html = OverviewView.renderLoading();
|
|
701
|
+
} else if (this.state.error && this.state.queues.length === 0) {
|
|
702
|
+
html = OverviewView.renderError(this.state.error);
|
|
703
|
+
} else {
|
|
704
|
+
html = OverviewView.renderData(this.state.queues, {
|
|
705
|
+
lastUpdated: this.state.lastUpdated,
|
|
706
|
+
syncError: this.state.syncError,
|
|
707
|
+
pollInterval: this.pollInterval,
|
|
708
|
+
isPolling: this._isPolling,
|
|
709
|
+
loading: this.state.loading,
|
|
710
|
+
searchQuery: this.state.overview?.searchQuery || "",
|
|
711
|
+
statusFilter: this.state.overview?.statusFilter || "all",
|
|
712
|
+
sortKey: this.state.overview?.sortKey || "name",
|
|
713
|
+
sortOrder: this.state.overview?.sortOrder || "asc"
|
|
714
|
+
});
|
|
715
|
+
}
|
|
716
|
+
} else if (this.state.activeView === "dlq") {
|
|
717
|
+
html = DLQView.render(this.state.dlq, this.state.queues);
|
|
718
|
+
} else if (this.state.activeView === "job") {
|
|
719
|
+
html = JobView.render(this.state.job, this.state.queues);
|
|
720
|
+
}
|
|
721
|
+
|
|
722
|
+
container.innerHTML = html;
|
|
723
|
+
this.bindViewEvents();
|
|
724
|
+
|
|
725
|
+
// Restore active focus and cursor position if the element was re-rendered inside container
|
|
726
|
+
if (wasInsideContainer) {
|
|
727
|
+
if (activeElementId) {
|
|
728
|
+
const el = document.getElementById(activeElementId);
|
|
729
|
+
if (el && typeof el.focus === "function") {
|
|
730
|
+
try {
|
|
731
|
+
el.focus({ preventScroll: true });
|
|
732
|
+
} catch {
|
|
733
|
+
el.focus();
|
|
734
|
+
}
|
|
735
|
+
if (cursorStart !== null && typeof el.setSelectionRange === "function") {
|
|
736
|
+
try {
|
|
737
|
+
el.setSelectionRange(cursorStart, cursorEnd);
|
|
738
|
+
} catch {
|
|
739
|
+
// ignore
|
|
740
|
+
}
|
|
741
|
+
}
|
|
742
|
+
}
|
|
743
|
+
} else if (activeSortKey) {
|
|
744
|
+
const el = document.querySelector(`.sort-header-btn[data-sort-key="${activeSortKey}"]`);
|
|
745
|
+
if (el && typeof el.focus === "function") {
|
|
746
|
+
try {
|
|
747
|
+
el.focus({ preventScroll: true });
|
|
748
|
+
} catch {
|
|
749
|
+
el.focus();
|
|
750
|
+
}
|
|
751
|
+
}
|
|
752
|
+
} else if (activeJobIndex !== null) {
|
|
753
|
+
const el = document.querySelector(`.dlq-inspect-btn[data-job-index="${activeJobIndex}"]`);
|
|
754
|
+
if (el && typeof el.focus === "function") {
|
|
755
|
+
try {
|
|
756
|
+
el.focus({ preventScroll: true });
|
|
757
|
+
} catch {
|
|
758
|
+
el.focus();
|
|
759
|
+
}
|
|
760
|
+
}
|
|
761
|
+
} else if (activeQueueDlqBtn) {
|
|
762
|
+
const el = document.querySelector(`.overview-dlq-btn[data-queue="${activeQueueDlqBtn}"]`);
|
|
763
|
+
if (el && typeof el.focus === "function") {
|
|
764
|
+
try {
|
|
765
|
+
el.focus({ preventScroll: true });
|
|
766
|
+
} catch {
|
|
767
|
+
el.focus();
|
|
768
|
+
}
|
|
769
|
+
}
|
|
770
|
+
} else if (activeQueueJobBtn) {
|
|
771
|
+
const el = document.querySelector(`.overview-job-btn[data-queue="${activeQueueJobBtn}"]`);
|
|
772
|
+
if (el && typeof el.focus === "function") {
|
|
773
|
+
try {
|
|
774
|
+
el.focus({ preventScroll: true });
|
|
775
|
+
} catch {
|
|
776
|
+
el.focus();
|
|
777
|
+
}
|
|
778
|
+
}
|
|
779
|
+
}
|
|
780
|
+
}
|
|
781
|
+
}
|
|
782
|
+
|
|
783
|
+
/**
|
|
784
|
+
* Attaches interactive event handlers to currently rendered view elements.
|
|
785
|
+
*/
|
|
786
|
+
bindViewEvents() {
|
|
787
|
+
if (typeof document === "undefined") return;
|
|
788
|
+
|
|
789
|
+
// Overview search input
|
|
790
|
+
const searchInput = document.getElementById("overview-search-input");
|
|
791
|
+
if (searchInput) {
|
|
792
|
+
searchInput.addEventListener("input", (e) => {
|
|
793
|
+
this.state.overview.searchQuery = e.target.value;
|
|
794
|
+
this.renderView();
|
|
795
|
+
});
|
|
796
|
+
}
|
|
797
|
+
|
|
798
|
+
// Overview status select
|
|
799
|
+
const statusSelect = document.getElementById("overview-status-select");
|
|
800
|
+
if (statusSelect) {
|
|
801
|
+
statusSelect.addEventListener("change", (e) => {
|
|
802
|
+
this.state.overview.statusFilter = e.target.value;
|
|
803
|
+
this.renderView();
|
|
804
|
+
});
|
|
805
|
+
}
|
|
806
|
+
|
|
807
|
+
// Overview clear filters buttons (toolbar and empty-filter state card)
|
|
808
|
+
const clearFilterBtns = document.querySelectorAll(
|
|
809
|
+
".overview-clear-btn, #overview-clear-filters-btn, #overview-reset-filters-btn"
|
|
810
|
+
);
|
|
811
|
+
clearFilterBtns.forEach((btn) => {
|
|
812
|
+
btn.addEventListener("click", () => {
|
|
813
|
+
this.state.overview.searchQuery = "";
|
|
814
|
+
this.state.overview.statusFilter = "all";
|
|
815
|
+
this.renderView();
|
|
816
|
+
});
|
|
817
|
+
});
|
|
818
|
+
|
|
819
|
+
// Overview sort header buttons
|
|
820
|
+
const sortBtns = document.querySelectorAll(".sort-header-btn");
|
|
821
|
+
sortBtns.forEach((btn) => {
|
|
822
|
+
btn.addEventListener("click", () => {
|
|
823
|
+
const key = btn.getAttribute("data-sort-key");
|
|
824
|
+
if (!key) return;
|
|
825
|
+
if (this.state.overview.sortKey === key) {
|
|
826
|
+
this.state.overview.sortOrder = this.state.overview.sortOrder === "asc" ? "desc" : "asc";
|
|
827
|
+
} else {
|
|
828
|
+
this.state.overview.sortKey = key;
|
|
829
|
+
this.state.overview.sortOrder = (key === "waiting" || key === "active" || key === "delayed" || key === "dlq") ? "desc" : "asc";
|
|
830
|
+
}
|
|
831
|
+
this.renderView();
|
|
832
|
+
});
|
|
833
|
+
});
|
|
834
|
+
|
|
835
|
+
// Overview table direct navigation action buttons
|
|
836
|
+
const overviewDlqBtns = document.querySelectorAll(".overview-dlq-btn");
|
|
837
|
+
overviewDlqBtns.forEach((btn) => {
|
|
838
|
+
btn.addEventListener("click", (e) => {
|
|
839
|
+
e.preventDefault();
|
|
840
|
+
const queueName = btn.getAttribute("data-queue");
|
|
841
|
+
if (queueName) {
|
|
842
|
+
this.navigate("dlq", { queue: queueName });
|
|
843
|
+
}
|
|
844
|
+
});
|
|
845
|
+
});
|
|
846
|
+
|
|
847
|
+
const overviewJobBtns = document.querySelectorAll(".overview-job-btn");
|
|
848
|
+
overviewJobBtns.forEach((btn) => {
|
|
849
|
+
btn.addEventListener("click", (e) => {
|
|
850
|
+
e.preventDefault();
|
|
851
|
+
const queueName = btn.getAttribute("data-queue");
|
|
852
|
+
if (queueName) {
|
|
853
|
+
this.navigate("job", { queue: queueName });
|
|
854
|
+
}
|
|
855
|
+
});
|
|
856
|
+
});
|
|
857
|
+
|
|
858
|
+
// Retry overview button (initial error state)
|
|
859
|
+
const retryOverviewBtn = document.getElementById("retry-overview-btn");
|
|
860
|
+
if (retryOverviewBtn) {
|
|
861
|
+
retryOverviewBtn.addEventListener("click", () => {
|
|
862
|
+
this.fetchQueues();
|
|
863
|
+
});
|
|
864
|
+
}
|
|
865
|
+
|
|
866
|
+
// Retry overview sync button (temporary failure banner)
|
|
867
|
+
const retryOverviewSyncBtn = document.getElementById("retry-overview-sync-btn");
|
|
868
|
+
if (retryOverviewSyncBtn) {
|
|
869
|
+
retryOverviewSyncBtn.addEventListener("click", () => {
|
|
870
|
+
this.fetchQueues();
|
|
871
|
+
});
|
|
872
|
+
}
|
|
873
|
+
|
|
874
|
+
// Overview table "View DLQ" badges
|
|
875
|
+
const dlqLinks = document.querySelectorAll(".view-dlq-link");
|
|
876
|
+
dlqLinks.forEach((link) => {
|
|
877
|
+
link.addEventListener("click", (e) => {
|
|
878
|
+
e.preventDefault();
|
|
879
|
+
const queueName = link.getAttribute("data-queue");
|
|
880
|
+
if (queueName) {
|
|
881
|
+
this.navigate("dlq", { queue: queueName });
|
|
882
|
+
}
|
|
883
|
+
});
|
|
884
|
+
});
|
|
885
|
+
|
|
886
|
+
// DLQ Explorer Queue Select
|
|
887
|
+
const queueSelect = document.getElementById("dlq-queue-select");
|
|
888
|
+
if (queueSelect) {
|
|
889
|
+
queueSelect.addEventListener("change", (e) => {
|
|
890
|
+
const val = e.target.value.trim();
|
|
891
|
+
this.state.dlq.selectedQueue = val || null;
|
|
892
|
+
this.state.dlq.start = 0;
|
|
893
|
+
this.state.dlq.expandedJobIndex = null;
|
|
894
|
+
if (typeof window !== "undefined" && window.location) {
|
|
895
|
+
window.location.hash = this.state.dlq.selectedQueue
|
|
896
|
+
? `#/dlq?queue=${encodeURIComponent(this.state.dlq.selectedQueue)}`
|
|
897
|
+
: "#/dlq";
|
|
898
|
+
}
|
|
899
|
+
if (this.state.dlq.selectedQueue) {
|
|
900
|
+
this.fetchDLQ();
|
|
901
|
+
} else {
|
|
902
|
+
this.renderView();
|
|
903
|
+
}
|
|
904
|
+
});
|
|
905
|
+
}
|
|
906
|
+
|
|
907
|
+
// DLQ Explorer Order Select
|
|
908
|
+
const orderSelect = document.getElementById("dlq-order-select");
|
|
909
|
+
if (orderSelect) {
|
|
910
|
+
orderSelect.addEventListener("change", (e) => {
|
|
911
|
+
this.state.dlq.order = e.target.value;
|
|
912
|
+
this.state.dlq.start = 0;
|
|
913
|
+
this.state.dlq.expandedJobIndex = null;
|
|
914
|
+
this.fetchDLQ();
|
|
915
|
+
});
|
|
916
|
+
}
|
|
917
|
+
|
|
918
|
+
// DLQ Explorer Limit Select
|
|
919
|
+
const limitSelect = document.getElementById("dlq-limit-select");
|
|
920
|
+
if (limitSelect) {
|
|
921
|
+
limitSelect.addEventListener("change", (e) => {
|
|
922
|
+
this.state.dlq.limit = parseInt(e.target.value, 10) || 50;
|
|
923
|
+
this.state.dlq.start = 0;
|
|
924
|
+
this.state.dlq.expandedJobIndex = null;
|
|
925
|
+
this.fetchDLQ();
|
|
926
|
+
});
|
|
927
|
+
}
|
|
928
|
+
|
|
929
|
+
// DLQ Explorer Refresh Button
|
|
930
|
+
const dlqRefreshBtn = document.getElementById("dlq-refresh-btn");
|
|
931
|
+
if (dlqRefreshBtn) {
|
|
932
|
+
dlqRefreshBtn.addEventListener("click", () => {
|
|
933
|
+
this.fetchDLQ();
|
|
934
|
+
});
|
|
935
|
+
}
|
|
936
|
+
|
|
937
|
+
// DLQ Explorer Retry Button
|
|
938
|
+
const dlqRetryBtn = document.getElementById("dlq-retry-btn");
|
|
939
|
+
if (dlqRetryBtn) {
|
|
940
|
+
dlqRetryBtn.addEventListener("click", () => {
|
|
941
|
+
this.fetchDLQ();
|
|
942
|
+
});
|
|
943
|
+
}
|
|
944
|
+
|
|
945
|
+
// DLQ Explorer Previous Page Button
|
|
946
|
+
const dlqPrevBtn = document.getElementById("dlq-prev-btn");
|
|
947
|
+
if (dlqPrevBtn) {
|
|
948
|
+
dlqPrevBtn.addEventListener("click", () => {
|
|
949
|
+
if (this.state.dlq.start > 0) {
|
|
950
|
+
this.state.dlq.start = Math.max(0, this.state.dlq.start - this.state.dlq.limit);
|
|
951
|
+
this.state.dlq.expandedJobIndex = null;
|
|
952
|
+
this.fetchDLQ();
|
|
953
|
+
}
|
|
954
|
+
});
|
|
955
|
+
}
|
|
956
|
+
|
|
957
|
+
// DLQ Explorer Next Page Button
|
|
958
|
+
const dlqNextBtn = document.getElementById("dlq-next-btn");
|
|
959
|
+
if (dlqNextBtn) {
|
|
960
|
+
dlqNextBtn.addEventListener("click", () => {
|
|
961
|
+
this.state.dlq.start = this.state.dlq.start + this.state.dlq.limit;
|
|
962
|
+
this.state.dlq.expandedJobIndex = null;
|
|
963
|
+
this.fetchDLQ();
|
|
964
|
+
});
|
|
965
|
+
}
|
|
966
|
+
|
|
967
|
+
// DLQ Explorer Job Detail Toggle Buttons
|
|
968
|
+
const inspectBtns = document.querySelectorAll(".dlq-inspect-btn");
|
|
969
|
+
inspectBtns.forEach((btn) => {
|
|
970
|
+
btn.addEventListener("click", () => {
|
|
971
|
+
const idxStr = btn.getAttribute("data-job-index");
|
|
972
|
+
const idx = parseInt(idxStr, 10);
|
|
973
|
+
if (!Number.isNaN(idx)) {
|
|
974
|
+
this.state.dlq.expandedJobIndex = this.state.dlq.expandedJobIndex === idx ? null : idx;
|
|
975
|
+
this.renderView();
|
|
976
|
+
}
|
|
977
|
+
});
|
|
978
|
+
});
|
|
979
|
+
|
|
980
|
+
// Job Inspector Search Form
|
|
981
|
+
const jobSearchForm = document.getElementById("job-search-form");
|
|
982
|
+
if (jobSearchForm) {
|
|
983
|
+
jobSearchForm.addEventListener("submit", (e) => {
|
|
984
|
+
e.preventDefault();
|
|
985
|
+
const queueSelect = document.getElementById("job-queue-select");
|
|
986
|
+
const jobIdInput = document.getElementById("job-id-input");
|
|
987
|
+
const queueName = queueSelect ? queueSelect.value.trim() : (this.state.job.selectedQueue || "");
|
|
988
|
+
const jobId = jobIdInput ? jobIdInput.value.trim() : "";
|
|
989
|
+
if (typeof window !== "undefined" && window.location && queueName && jobId) {
|
|
990
|
+
window.location.hash = `#/job?queue=${encodeURIComponent(queueName)}&jobId=${encodeURIComponent(jobId)}`;
|
|
991
|
+
}
|
|
992
|
+
this.fetchJob(queueName, jobId);
|
|
993
|
+
});
|
|
994
|
+
}
|
|
995
|
+
|
|
996
|
+
// Job Inspector Queue Select change
|
|
997
|
+
const jobQueueSelect = document.getElementById("job-queue-select");
|
|
998
|
+
if (jobQueueSelect) {
|
|
999
|
+
jobQueueSelect.addEventListener("change", (e) => {
|
|
1000
|
+
this.state.job.selectedQueue = e.target.value.trim() || null;
|
|
1001
|
+
this.state.job.job = null;
|
|
1002
|
+
this.state.job.error = null;
|
|
1003
|
+
if (typeof window !== "undefined" && window.location) {
|
|
1004
|
+
window.location.hash = this.state.job.selectedQueue
|
|
1005
|
+
? `#/job?queue=${encodeURIComponent(this.state.job.selectedQueue)}`
|
|
1006
|
+
: `#/job`;
|
|
1007
|
+
}
|
|
1008
|
+
this.renderView();
|
|
1009
|
+
});
|
|
1010
|
+
}
|
|
1011
|
+
|
|
1012
|
+
// Job Inspector Retry Button
|
|
1013
|
+
const jobRetryBtn = document.getElementById("job-retry-btn");
|
|
1014
|
+
if (jobRetryBtn) {
|
|
1015
|
+
jobRetryBtn.addEventListener("click", () => {
|
|
1016
|
+
this.fetchJob(this.state.job.selectedQueue, this.state.job.jobId);
|
|
1017
|
+
});
|
|
1018
|
+
}
|
|
1019
|
+
|
|
1020
|
+
// Job Inspector Clear Search Button
|
|
1021
|
+
const jobClearBtn = document.getElementById("job-clear-btn");
|
|
1022
|
+
if (jobClearBtn) {
|
|
1023
|
+
jobClearBtn.addEventListener("click", () => {
|
|
1024
|
+
this.state.job.jobId = "";
|
|
1025
|
+
this.state.job.job = null;
|
|
1026
|
+
this.state.job.error = null;
|
|
1027
|
+
if (typeof window !== "undefined" && window.location) {
|
|
1028
|
+
window.location.hash = `#/job?queue=${encodeURIComponent(this.state.job.selectedQueue || "")}`;
|
|
1029
|
+
}
|
|
1030
|
+
this.renderView();
|
|
1031
|
+
});
|
|
1032
|
+
}
|
|
1033
|
+
|
|
1034
|
+
// Cross-link from DLQ Explorer to Job Inspector
|
|
1035
|
+
const inspectInJobBtns = document.querySelectorAll(".inspect-in-job-view-btn");
|
|
1036
|
+
inspectInJobBtns.forEach((btn) => {
|
|
1037
|
+
btn.addEventListener("click", (e) => {
|
|
1038
|
+
e.preventDefault();
|
|
1039
|
+
const qName = btn.getAttribute("data-queue");
|
|
1040
|
+
const jId = btn.getAttribute("data-job-id");
|
|
1041
|
+
if (qName && jId) {
|
|
1042
|
+
this.navigate("job", { queue: qName, jobId: jId });
|
|
1043
|
+
}
|
|
1044
|
+
});
|
|
1045
|
+
});
|
|
1046
|
+
}
|
|
1047
|
+
|
|
1048
|
+
/**
|
|
1049
|
+
* Binds click events on sidebar navigation links.
|
|
1050
|
+
*/
|
|
1051
|
+
bindNavigation() {
|
|
1052
|
+
if (typeof document === "undefined") return;
|
|
1053
|
+
|
|
1054
|
+
const navLinks = document.querySelectorAll(".sidebar-nav .nav-link");
|
|
1055
|
+
navLinks.forEach((link) => {
|
|
1056
|
+
link.addEventListener("click", (e) => {
|
|
1057
|
+
e.preventDefault();
|
|
1058
|
+
const view = link.getAttribute("data-view");
|
|
1059
|
+
if (view) {
|
|
1060
|
+
this.navigate(view);
|
|
1061
|
+
}
|
|
1062
|
+
});
|
|
1063
|
+
});
|
|
1064
|
+
}
|
|
1065
|
+
|
|
1066
|
+
/**
|
|
1067
|
+
* Binds mobile hamburger toggle and backdrop overlay.
|
|
1068
|
+
*/
|
|
1069
|
+
bindMobileToggle() {
|
|
1070
|
+
if (typeof document === "undefined") return;
|
|
1071
|
+
|
|
1072
|
+
const toggle = document.getElementById("mobile-toggle-btn");
|
|
1073
|
+
const sidebar = document.getElementById("sidebar");
|
|
1074
|
+
const backdrop = document.getElementById("mobile-backdrop");
|
|
1075
|
+
|
|
1076
|
+
if (toggle && sidebar) {
|
|
1077
|
+
toggle.addEventListener("click", () => {
|
|
1078
|
+
const isOpen = sidebar.classList.contains("open");
|
|
1079
|
+
if (isOpen) {
|
|
1080
|
+
this.closeMobileSidebar(true);
|
|
1081
|
+
} else {
|
|
1082
|
+
sidebar.classList.add("open");
|
|
1083
|
+
toggle.setAttribute("aria-expanded", "true");
|
|
1084
|
+
if (backdrop) backdrop.classList.add("open");
|
|
1085
|
+
const firstNav = sidebar.querySelector(".nav-link");
|
|
1086
|
+
if (firstNav && typeof firstNav.focus === "function") {
|
|
1087
|
+
firstNav.focus();
|
|
1088
|
+
}
|
|
1089
|
+
}
|
|
1090
|
+
});
|
|
1091
|
+
}
|
|
1092
|
+
|
|
1093
|
+
if (backdrop) {
|
|
1094
|
+
backdrop.addEventListener("click", () => {
|
|
1095
|
+
this.closeMobileSidebar(true);
|
|
1096
|
+
});
|
|
1097
|
+
}
|
|
1098
|
+
|
|
1099
|
+
// Escape key to close mobile menu, and Tab trap while open
|
|
1100
|
+
if (typeof document !== "undefined") {
|
|
1101
|
+
if (this._boundMobileKeydown && typeof document.removeEventListener === "function") {
|
|
1102
|
+
document.removeEventListener("keydown", this._boundMobileKeydown);
|
|
1103
|
+
this._boundMobileKeydown = null;
|
|
1104
|
+
}
|
|
1105
|
+
|
|
1106
|
+
this._boundMobileKeydown = (e) => {
|
|
1107
|
+
if (this._isDestroyed) return;
|
|
1108
|
+
const isOpen = sidebar && sidebar.classList.contains("open");
|
|
1109
|
+
if (!isOpen) return;
|
|
1110
|
+
|
|
1111
|
+
if (e.key === "Escape") {
|
|
1112
|
+
e.preventDefault();
|
|
1113
|
+
this.closeMobileSidebar(true);
|
|
1114
|
+
} else if (e.key === "Tab") {
|
|
1115
|
+
const focusable = sidebar.querySelectorAll(
|
|
1116
|
+
'button:not([disabled]), [href], input:not([disabled]), select:not([disabled]), [tabindex]:not([tabindex="-1"])'
|
|
1117
|
+
);
|
|
1118
|
+
if (!focusable || focusable.length === 0) return;
|
|
1119
|
+
const firstEl = focusable[0];
|
|
1120
|
+
const lastEl = focusable[focusable.length - 1];
|
|
1121
|
+
|
|
1122
|
+
if (e.shiftKey) {
|
|
1123
|
+
if (document.activeElement === firstEl || !sidebar.contains(document.activeElement)) {
|
|
1124
|
+
e.preventDefault();
|
|
1125
|
+
if (lastEl && typeof lastEl.focus === "function") lastEl.focus();
|
|
1126
|
+
}
|
|
1127
|
+
} else {
|
|
1128
|
+
if (document.activeElement === lastEl || !sidebar.contains(document.activeElement)) {
|
|
1129
|
+
e.preventDefault();
|
|
1130
|
+
if (firstEl && typeof firstEl.focus === "function") firstEl.focus();
|
|
1131
|
+
}
|
|
1132
|
+
}
|
|
1133
|
+
}
|
|
1134
|
+
};
|
|
1135
|
+
|
|
1136
|
+
if (typeof document.addEventListener === "function") {
|
|
1137
|
+
document.addEventListener("keydown", this._boundMobileKeydown);
|
|
1138
|
+
}
|
|
1139
|
+
}
|
|
1140
|
+
}
|
|
1141
|
+
|
|
1142
|
+
closeMobileSidebar(restoreFocus = false) {
|
|
1143
|
+
if (typeof document === "undefined") return;
|
|
1144
|
+
const sidebar = document.getElementById("sidebar");
|
|
1145
|
+
const toggle = document.getElementById("mobile-toggle-btn");
|
|
1146
|
+
const backdrop = document.getElementById("mobile-backdrop");
|
|
1147
|
+
|
|
1148
|
+
const wasOpen = sidebar && sidebar.classList.contains("open");
|
|
1149
|
+
if (sidebar) sidebar.classList.remove("open");
|
|
1150
|
+
if (toggle) toggle.setAttribute("aria-expanded", "false");
|
|
1151
|
+
if (backdrop) backdrop.classList.remove("open");
|
|
1152
|
+
|
|
1153
|
+
if (wasOpen && restoreFocus && toggle && typeof toggle.focus === "function") {
|
|
1154
|
+
try {
|
|
1155
|
+
toggle.focus({ preventScroll: true });
|
|
1156
|
+
} catch {
|
|
1157
|
+
toggle.focus();
|
|
1158
|
+
}
|
|
1159
|
+
}
|
|
1160
|
+
}
|
|
1161
|
+
|
|
1162
|
+
/**
|
|
1163
|
+
* Binds top-bar refresh button.
|
|
1164
|
+
*/
|
|
1165
|
+
bindRefresh() {
|
|
1166
|
+
if (typeof document === "undefined") return;
|
|
1167
|
+
|
|
1168
|
+
const refreshBtn = document.getElementById("refresh-btn");
|
|
1169
|
+
if (refreshBtn) {
|
|
1170
|
+
refreshBtn.addEventListener("click", () => {
|
|
1171
|
+
this.fetchQueues();
|
|
1172
|
+
if (this.state.activeView === "dlq" && this.state.dlq.selectedQueue) {
|
|
1173
|
+
this.fetchDLQ();
|
|
1174
|
+
} else if (this.state.activeView === "job" && this.state.job.selectedQueue && this.state.job.jobId) {
|
|
1175
|
+
this.fetchJob(this.state.job.selectedQueue, this.state.job.jobId);
|
|
1176
|
+
}
|
|
1177
|
+
});
|
|
1178
|
+
}
|
|
1179
|
+
}
|
|
1180
|
+
|
|
1181
|
+
/**
|
|
1182
|
+
* Parses and handles browser URL hash navigation (e.g. #/dlq?queue=emails or #/job?queue=emails&jobId=...).
|
|
1183
|
+
*/
|
|
1184
|
+
handleHashChange() {
|
|
1185
|
+
if (typeof window === "undefined" || !window.location.hash) return;
|
|
1186
|
+
|
|
1187
|
+
const raw = window.location.hash.replace(/^#\/?/, "");
|
|
1188
|
+
let view = raw;
|
|
1189
|
+
let queueParam = null;
|
|
1190
|
+
let jobIdParam = null;
|
|
1191
|
+
|
|
1192
|
+
if (raw.startsWith("dlq/")) {
|
|
1193
|
+
view = "dlq";
|
|
1194
|
+
try {
|
|
1195
|
+
queueParam = decodeURIComponent(raw.slice(4));
|
|
1196
|
+
} catch {
|
|
1197
|
+
queueParam = raw.slice(4);
|
|
1198
|
+
}
|
|
1199
|
+
} else if (raw.startsWith("job/")) {
|
|
1200
|
+
view = "job";
|
|
1201
|
+
const parts = raw.slice(4).split("/");
|
|
1202
|
+
try {
|
|
1203
|
+
queueParam = decodeURIComponent(parts[0] || "");
|
|
1204
|
+
} catch {
|
|
1205
|
+
queueParam = parts[0] || "";
|
|
1206
|
+
}
|
|
1207
|
+
try {
|
|
1208
|
+
jobIdParam = decodeURIComponent(parts.slice(1).join("/") || "");
|
|
1209
|
+
} catch {
|
|
1210
|
+
jobIdParam = parts.slice(1).join("/") || "";
|
|
1211
|
+
}
|
|
1212
|
+
} else if (raw.includes("?")) {
|
|
1213
|
+
const [viewPart, queryPart] = raw.split("?");
|
|
1214
|
+
view = viewPart;
|
|
1215
|
+
try {
|
|
1216
|
+
const params = new URLSearchParams(queryPart);
|
|
1217
|
+
queueParam = params.get("queue");
|
|
1218
|
+
jobIdParam = params.get("jobId");
|
|
1219
|
+
} catch {
|
|
1220
|
+
// ignore malformed query
|
|
1221
|
+
}
|
|
1222
|
+
}
|
|
1223
|
+
|
|
1224
|
+
if (view && this.viewTitles[view]) {
|
|
1225
|
+
// Avoid redundant navigations if view and queue/jobId already match current state
|
|
1226
|
+
if (
|
|
1227
|
+
this.state.activeView === view &&
|
|
1228
|
+
(view === "overview" ||
|
|
1229
|
+
(view === "dlq" && (!queueParam || this.state.dlq.selectedQueue === queueParam)) ||
|
|
1230
|
+
(view === "job" &&
|
|
1231
|
+
(!queueParam || this.state.job.selectedQueue === queueParam) &&
|
|
1232
|
+
(!jobIdParam || this.state.job.jobId === jobIdParam)))
|
|
1233
|
+
) {
|
|
1234
|
+
return;
|
|
1235
|
+
}
|
|
1236
|
+
this.navigate(view, { queue: queueParam, jobId: jobIdParam });
|
|
1237
|
+
}
|
|
1238
|
+
}
|
|
1239
|
+
|
|
1240
|
+
/**
|
|
1241
|
+
* Binds change events on the auto-refresh interval select element.
|
|
1242
|
+
*/
|
|
1243
|
+
bindPollingControls() {
|
|
1244
|
+
if (typeof document === "undefined") return;
|
|
1245
|
+
|
|
1246
|
+
const select = document.getElementById("poll-interval-select");
|
|
1247
|
+
if (select) {
|
|
1248
|
+
select.value = String(this.pollInterval);
|
|
1249
|
+
select.addEventListener("change", (e) => {
|
|
1250
|
+
this.setPollInterval(e.target.value);
|
|
1251
|
+
});
|
|
1252
|
+
}
|
|
1253
|
+
}
|
|
1254
|
+
|
|
1255
|
+
/**
|
|
1256
|
+
* Listens for browser URL hash changes for lightweight SPA routing.
|
|
1257
|
+
*/
|
|
1258
|
+
bindHashRouting() {
|
|
1259
|
+
if (typeof window === "undefined") return;
|
|
1260
|
+
|
|
1261
|
+
window.addEventListener("hashchange", () => {
|
|
1262
|
+
this.handleHashChange();
|
|
1263
|
+
});
|
|
1264
|
+
}
|
|
1265
|
+
|
|
1266
|
+
/**
|
|
1267
|
+
* Binds beforeunload and pagehide events to ensure timers are cleared on teardown.
|
|
1268
|
+
*/
|
|
1269
|
+
bindUnload() {
|
|
1270
|
+
if (typeof window === "undefined") return;
|
|
1271
|
+
|
|
1272
|
+
this._boundBeforeUnload = () => {
|
|
1273
|
+
this.destroy();
|
|
1274
|
+
};
|
|
1275
|
+
window.addEventListener("beforeunload", this._boundBeforeUnload);
|
|
1276
|
+
window.addEventListener("pagehide", this._boundBeforeUnload);
|
|
1277
|
+
}
|
|
1278
|
+
}
|
|
1279
|
+
|
|
1280
|
+
// Universal export
|
|
1281
|
+
if (typeof module !== "undefined" && module.exports) {
|
|
1282
|
+
module.exports = { DashboardApp };
|
|
1283
|
+
}
|
|
1284
|
+
|
|
1285
|
+
if (typeof globalThis !== "undefined") {
|
|
1286
|
+
globalThis.ResilientQueueDashboardApp = DashboardApp;
|
|
1287
|
+
}
|
|
1288
|
+
|
|
1289
|
+
// Auto-initialize when running in browser document
|
|
1290
|
+
if (typeof document !== "undefined") {
|
|
1291
|
+
if (document.readyState === "loading") {
|
|
1292
|
+
document.addEventListener("DOMContentLoaded", () => {
|
|
1293
|
+
const app = new DashboardApp();
|
|
1294
|
+
app.init();
|
|
1295
|
+
globalThis.__rqDashboardApp = app;
|
|
1296
|
+
});
|
|
1297
|
+
} else {
|
|
1298
|
+
const app = new DashboardApp();
|
|
1299
|
+
app.init();
|
|
1300
|
+
globalThis.__rqDashboardApp = app;
|
|
1301
|
+
}
|
|
1302
|
+
}
|
|
1303
|
+
})();
|