@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/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
+ })();