@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.
@@ -0,0 +1,670 @@
1
+ "use strict";
2
+
3
+ /**
4
+ * Queue Overview View Component (Milestones 8 & 9)
5
+ * Renders loading, empty, error, and live queues table with aggregate telemetry cards,
6
+ * client-side search/filtering, multi-column sorting, operational diagnostics,
7
+ * and direct navigation to DLQ Explorer and Job Inspector.
8
+ */
9
+
10
+ function escapeHtml(str) {
11
+ if (str === null || str === undefined) return "";
12
+ return String(str)
13
+ .replace(/&/g, "&")
14
+ .replace(/</g, "&lt;")
15
+ .replace(/>/g, "&gt;")
16
+ .replace(/"/g, "&quot;")
17
+ .replace(/'/g, "&#039;");
18
+ }
19
+
20
+ function parseDate(ts) {
21
+ if (!ts) return null;
22
+ try {
23
+ const d = ts instanceof Date ? ts : new Date(ts);
24
+ return Number.isNaN(d.getTime()) ? null : d;
25
+ } catch {
26
+ return null;
27
+ }
28
+ }
29
+
30
+ function formatTimestamp(ts) {
31
+ const d = parseDate(ts);
32
+ if (!d) return "—";
33
+ return d.toLocaleTimeString([], { hour: "2-digit", minute: "2-digit", second: "2-digit" });
34
+ }
35
+
36
+ function formatIso(ts) {
37
+ const d = parseDate(ts);
38
+ if (!d) return "";
39
+ return d.toISOString();
40
+ }
41
+
42
+ /**
43
+ * Derives the operational status of a queue.
44
+ * Never claims healthy based solely on zero counts.
45
+ *
46
+ * @param {object} q
47
+ * @returns {"active"|"paused"|"closed"|"unavailable"|"error"}
48
+ */
49
+ function getQueueStatus(q) {
50
+ if (Boolean(q?.isClosed) || q?.status === "closed") {
51
+ return "closed";
52
+ }
53
+ if (Boolean(q?.error) || q?.status === "error") {
54
+ return "error";
55
+ }
56
+ if (q?.metrics == null || q?.status === "unavailable") {
57
+ return "unavailable";
58
+ }
59
+ if (Boolean(q?.isPaused)) {
60
+ return "paused";
61
+ }
62
+ return "active";
63
+ }
64
+
65
+ function sanitizeUiErrorMessage(msg) {
66
+ if (typeof msg !== "string" || msg.trim().length === 0) {
67
+ return "Failed to communicate with dashboard API.";
68
+ }
69
+ let cleaned = msg.replace(
70
+ /\b([a-zA-Z][a-zA-Z0-9+.-]*:\/\/)(?:[^:\s/@]+:)?([^@\s/]+)@/g,
71
+ "$1[redacted]@"
72
+ );
73
+ cleaned = cleaned.replace(
74
+ /\b(AUTH|Bearer)\s+['"]?[^\s'";&]+['"]?/gi,
75
+ "$1 [redacted]"
76
+ );
77
+ cleaned = cleaned.replace(
78
+ /\b(password|passwd|pwd|token|secret|apikey|api_key)(\s*[:=]\s*)['"]?[^\s'";&]+['"]?/gi,
79
+ "$1$2[redacted]"
80
+ );
81
+ return cleaned;
82
+ }
83
+
84
+ /**
85
+ * Produces actionable operational diagnostics for API error responses.
86
+ *
87
+ * @param {object} error
88
+ * @returns {{ code: string, status: number, title: string, description: string, guidance: string, category: string }}
89
+ */
90
+ function getDiagnosticInfo(error) {
91
+ const code = error?.code || (error?.status === 404 ? "NOT_FOUND" : error?.status === 503 ? "SERVICE_UNAVAILABLE" : error?.status === 501 ? "NOT_SUPPORTED" : "INTERNAL_ERROR");
92
+ const status = typeof error?.status === "number" ? error.status : typeof error?.statusCode === "number" ? error.statusCode : 0;
93
+ const rawMessage = error?.message || "Failed to communicate with dashboard API.";
94
+
95
+ let title = "Failed to Load Queues";
96
+ let description = sanitizeUiErrorMessage(rawMessage);
97
+ let guidance = "Check the host application logs and verify that the dashboard adapter is properly configured.";
98
+ let category = "error";
99
+
100
+ switch (code) {
101
+ case "QUEUE_CLOSED":
102
+ title = "Queue Closed";
103
+ guidance = "The target ResilientQueue instance has been closed via queue.close(). Operations are suspended until the host application restarts or reinitializes the queue.";
104
+ category = "warning";
105
+ break;
106
+ case "QUEUE_NOT_FOUND":
107
+ title = "Queue Not Registered";
108
+ guidance = "The requested queue is not registered with the dashboard adapter. Verify that it is included in the 'queues' configuration object during adapter initialization.";
109
+ category = "warning";
110
+ break;
111
+ case "NOT_SUPPORTED":
112
+ title = "Core Feature Not Supported";
113
+ guidance = "The installed version of @mayurbhusare/resilient-queue does not expose this method. Live job inspection requires an updated core release or a compatible queue provider.";
114
+ category = "warning";
115
+ break;
116
+ case "VALIDATION_ERROR":
117
+ title = "Invalid Request Parameters";
118
+ guidance = "One or more parameters were rejected by the adapter validation layer. Review requested query parameters and identifiers.";
119
+ break;
120
+ case "NETWORK_ERROR":
121
+ title = "Network Communication Failure";
122
+ guidance = "Unable to reach the dashboard API endpoint. Verify that the server is running, the host port is open, and no firewall is blocking HTTP requests.";
123
+ break;
124
+ case "INTERNAL_ERROR":
125
+ title = "Internal Server Error";
126
+ guidance = "An internal error occurred while communicating with Redis or executing queue commands. Check Redis connection health and host logs.";
127
+ break;
128
+ default:
129
+ if (status === 503 || /queue is closed/i.test(rawMessage)) {
130
+ title = "Queue Closed";
131
+ guidance = "The target queue instance has been closed via queue.close().";
132
+ category = "warning";
133
+ } else if (status === 501) {
134
+ title = "Core Feature Not Supported";
135
+ guidance = "This capability is not supported by the currently installed core package release.";
136
+ category = "warning";
137
+ } else if (status === 404) {
138
+ title = "Resource Not Found";
139
+ guidance = "The requested queue or resource could not be found in the adapter registry.";
140
+ }
141
+ break;
142
+ }
143
+
144
+ return { code, status, title, description, guidance, category };
145
+ }
146
+
147
+ function renderMetricCell(val, label) {
148
+ if (typeof val === "number" && !Number.isNaN(val)) {
149
+ return `<td class="metric-number">${val}</td>`;
150
+ }
151
+ return `<td class="metric-number"><span class="metric-unavailable" title="${label || "Metric"} unavailable">—</span></td>`;
152
+ }
153
+
154
+ function renderDLQCell(dlq, name) {
155
+ const encodedName = encodeURIComponent(name);
156
+ if (typeof dlq !== "number" || Number.isNaN(dlq)) {
157
+ return `<td><span class="metric-unavailable" title="DLQ count unavailable">—</span></td>`;
158
+ }
159
+ if (dlq > 0) {
160
+ return `<td><a href="#/dlq?queue=${encodedName}" class="badge badge-dlq-alert view-dlq-link" data-queue="${escapeHtml(name)}" aria-label="View ${dlq} failed jobs in DLQ for queue ${escapeHtml(name)}">${dlq}</a></td>`;
161
+ }
162
+ return `<td><a href="#/dlq?queue=${encodedName}" class="view-dlq-link" data-queue="${escapeHtml(name)}" aria-label="View 0 failed jobs in DLQ for queue ${escapeHtml(name)}"><span class="metric-number badge-dlq-clean">0</span></a></td>`;
163
+ }
164
+
165
+ function renderStatusBadge(q) {
166
+ const status = getQueueStatus(q);
167
+ switch (status) {
168
+ case "closed":
169
+ return `<span class="badge badge-closed" role="status" aria-label="Status: Closed"><span aria-hidden="true">■</span> Closed</span>`;
170
+ case "error":
171
+ return `<span class="badge badge-error" role="status" aria-label="Status: Error"><span aria-hidden="true">⚠️</span> Error</span>`;
172
+ case "unavailable":
173
+ return `<span class="badge badge-unavailable" role="status" aria-label="Status: Unavailable"><span aria-hidden="true">—</span> Unavailable</span>`;
174
+ case "paused":
175
+ return `<span class="badge badge-paused" role="status" aria-label="Status: Paused"><span aria-hidden="true">⏸</span> Paused</span>`;
176
+ case "active":
177
+ default:
178
+ return `<span class="badge badge-active" role="status" aria-label="Status: Active"><span aria-hidden="true">●</span> Active</span>`;
179
+ }
180
+ }
181
+
182
+ function renderOverviewLoading() {
183
+ return `
184
+ <div class="state-container loading-state" role="status" aria-live="polite">
185
+ <div class="spinner" aria-hidden="true"></div>
186
+ <h3 class="state-title">Loading Queues...</h3>
187
+ <p class="state-description">Fetching real-time queue telemetry from the adapter API.</p>
188
+ </div>
189
+ `;
190
+ }
191
+
192
+ function renderOverviewEmpty() {
193
+ return `
194
+ <div class="state-container empty-state" role="status">
195
+ <div class="state-icon" aria-hidden="true">📭</div>
196
+ <h3 class="state-title">No Queues Registered</h3>
197
+ <p class="state-description">
198
+ No <code>ResilientQueue</code> instances have been registered with the dashboard adapter yet.
199
+ Register queues in your server configuration:
200
+ </p>
201
+ <div class="code-snippet">
202
+ <code>createExpressAdapter({ queues: { "my-queue": myQueueInstance } })</code>
203
+ </div>
204
+ </div>
205
+ `;
206
+ }
207
+
208
+ function renderOverviewError(error) {
209
+ const diag = getDiagnosticInfo(error);
210
+
211
+ return `
212
+ <div class="state-container error-state" role="alert" aria-live="assertive">
213
+ <div class="state-icon" aria-hidden="true">⚠️</div>
214
+ <span class="error-code-badge">${escapeHtml(diag.code)}</span>
215
+ <h3 class="state-title error-title">${escapeHtml(diag.title)}</h3>
216
+ <p class="state-description">${escapeHtml(diag.description)}</p>
217
+ <div class="diagnostic-guidance" style="margin-top: 0.75rem; max-width: 540px; text-align: left;">
218
+ <div class="callout callout-warning">
219
+ <div class="callout-header"><span>Operational Diagnostic</span></div>
220
+ <div class="callout-body">${escapeHtml(diag.guidance)}</div>
221
+ </div>
222
+ </div>
223
+ <button type="button" class="btn btn-primary" id="retry-overview-btn" aria-label="Retry loading queue metrics" style="margin-top: 1rem;">
224
+ <span>Retry</span>
225
+ </button>
226
+ </div>
227
+ `;
228
+ }
229
+
230
+ /**
231
+ * Filters registered queues by client-side search query and status filter.
232
+ *
233
+ * @param {Array<object>} queues
234
+ * @param {object} [options={}]
235
+ * @returns {Array<object>}
236
+ */
237
+ function filterQueues(queues, options = {}) {
238
+ if (!Array.isArray(queues) || queues.length === 0) return [];
239
+
240
+ const searchQuery = String(options.searchQuery || "").trim().toLowerCase();
241
+ const statusFilter = String(options.statusFilter || "all").trim().toLowerCase();
242
+
243
+ return queues.filter((q) => {
244
+ // Search query matching against queue name
245
+ if (searchQuery) {
246
+ const name = String(q?.name || "").toLowerCase();
247
+ if (!name.includes(searchQuery)) {
248
+ return false;
249
+ }
250
+ }
251
+
252
+ // Status filter matching
253
+ if (statusFilter && statusFilter !== "all") {
254
+ const status = getQueueStatus(q);
255
+ if (status !== statusFilter) {
256
+ return false;
257
+ }
258
+ }
259
+
260
+ return true;
261
+ });
262
+ }
263
+
264
+ /**
265
+ * Extracts a numeric metric value or null. Unavailable metrics are never treated as zero.
266
+ *
267
+ * @param {object} q
268
+ * @param {string} key
269
+ * @returns {number|null}
270
+ */
271
+ function getMetricValue(q, key) {
272
+ const metrics = q?.metrics;
273
+ if (!metrics || typeof metrics !== "object") return null;
274
+ const val = metrics[key];
275
+ if (typeof val === "number" && !Number.isNaN(val)) {
276
+ return val;
277
+ }
278
+ return null;
279
+ }
280
+
281
+ /**
282
+ * Sorts registered queues.
283
+ * Critically, unavailable metrics are handled without treating them as zero;
284
+ * null/unavailable metrics are pushed to the end in both ascending and descending order.
285
+ *
286
+ * @param {Array<object>} queues
287
+ * @param {object} [options={}]
288
+ * @returns {Array<object>}
289
+ */
290
+ function sortQueues(queues, options = {}) {
291
+ if (!Array.isArray(queues) || queues.length <= 1) return Array.isArray(queues) ? [...queues] : [];
292
+
293
+ const sortKey = options.sortKey || "name";
294
+ const sortOrder = options.sortOrder === "desc" ? "desc" : "asc";
295
+
296
+ return [...queues].sort((a, b) => {
297
+ // Metric columns
298
+ if (sortKey === "waiting" || sortKey === "active" || sortKey === "delayed" || sortKey === "dlq") {
299
+ const valA = getMetricValue(a, sortKey);
300
+ const valB = getMetricValue(b, sortKey);
301
+
302
+ const aIsNull = valA === null;
303
+ const bIsNull = valB === null;
304
+
305
+ if (aIsNull && bIsNull) {
306
+ return String(a?.name || "").localeCompare(String(b?.name || ""));
307
+ }
308
+ if (aIsNull) return 1; // unavailable always sorted last
309
+ if (bIsNull) return -1; // unavailable always sorted last
310
+
311
+ const diff = valA - valB;
312
+ if (diff !== 0) {
313
+ return sortOrder === "desc" ? -diff : diff;
314
+ }
315
+ return String(a?.name || "").localeCompare(String(b?.name || ""));
316
+ }
317
+
318
+ // Status column
319
+ if (sortKey === "status") {
320
+ const statusA = getQueueStatus(a);
321
+ const statusB = getQueueStatus(b);
322
+ const cmp = statusA.localeCompare(statusB);
323
+ if (cmp !== 0) {
324
+ return sortOrder === "desc" ? -cmp : cmp;
325
+ }
326
+ return String(a?.name || "").localeCompare(String(b?.name || ""));
327
+ }
328
+
329
+ // Last updated column
330
+ if (sortKey === "lastUpdated") {
331
+ const dateA = parseDate(a?.lastUpdated);
332
+ const dateB = parseDate(b?.lastUpdated);
333
+ const tsA = dateA ? dateA.getTime() : null;
334
+ const tsB = dateB ? dateB.getTime() : null;
335
+
336
+ const aIsNull = tsA === null;
337
+ const bIsNull = tsB === null;
338
+
339
+ if (aIsNull && bIsNull) {
340
+ return String(a?.name || "").localeCompare(String(b?.name || ""));
341
+ }
342
+ if (aIsNull) return 1; // unavailable or invalid timestamps always sorted last
343
+ if (bIsNull) return -1;
344
+
345
+ const diff = tsA - tsB;
346
+ if (diff !== 0) {
347
+ return sortOrder === "desc" ? -diff : diff;
348
+ }
349
+ return String(a?.name || "").localeCompare(String(b?.name || ""));
350
+ }
351
+
352
+ // Default: Queue name
353
+ const nameA = String(a?.name || "").toLowerCase();
354
+ const nameB = String(b?.name || "").toLowerCase();
355
+ const cmp = nameA.localeCompare(nameB);
356
+ return sortOrder === "desc" ? -cmp : cmp;
357
+ });
358
+ }
359
+
360
+ function getAriaSort(colKey, currentKey, currentOrder) {
361
+ if (colKey !== currentKey) return "none";
362
+ return currentOrder === "asc" ? "ascending" : "descending";
363
+ }
364
+
365
+ function getSortIndicator(colKey, currentKey, currentOrder) {
366
+ if (colKey !== currentKey) {
367
+ return `<span class="sort-icon-idle" aria-hidden="true">↕</span>`;
368
+ }
369
+ return currentOrder === "asc" ? "▲" : "▼";
370
+ }
371
+
372
+ function renderOverviewData(queues, options = {}) {
373
+ if (!Array.isArray(queues) || queues.length === 0) {
374
+ return renderOverviewEmpty();
375
+ }
376
+
377
+ const searchQuery = typeof options.searchQuery === "string" ? options.searchQuery : "";
378
+ const statusFilter = typeof options.statusFilter === "string" ? options.statusFilter : "all";
379
+ const sortKey = options.sortKey || "name";
380
+ const sortOrder = options.sortOrder || "asc";
381
+
382
+ // Filter and sort queues
383
+ const filteredQueues = filterQueues(queues, { searchQuery, statusFilter });
384
+ const sortedQueues = sortQueues(filteredQueues, { sortKey, sortOrder });
385
+
386
+ const hasActiveFilters = Boolean(searchQuery.trim().length > 0 || (statusFilter && statusFilter !== "all"));
387
+
388
+ // Calculate totals across ALL registered queues
389
+ let totalWaiting = 0;
390
+ let totalActive = 0;
391
+ let totalDelayed = 0;
392
+ let totalDLQ = 0;
393
+
394
+ for (const q of queues) {
395
+ const metrics = q?.metrics;
396
+ if (metrics && typeof metrics === "object") {
397
+ if (typeof metrics.waiting === "number" && !Number.isNaN(metrics.waiting)) {
398
+ totalWaiting += metrics.waiting;
399
+ }
400
+ if (typeof metrics.active === "number" && !Number.isNaN(metrics.active)) {
401
+ totalActive += metrics.active;
402
+ }
403
+ if (typeof metrics.delayed === "number" && !Number.isNaN(metrics.delayed)) {
404
+ totalDelayed += metrics.delayed;
405
+ }
406
+ if (typeof metrics.dlq === "number" && !Number.isNaN(metrics.dlq)) {
407
+ totalDLQ += metrics.dlq;
408
+ }
409
+ }
410
+ }
411
+
412
+ const syncError = options?.syncError;
413
+ let syncErrorBanner = "";
414
+ if (syncError) {
415
+ const diag = getDiagnosticInfo(syncError);
416
+ const lastSyncTime = options?.lastUpdated ? formatTimestamp(options.lastUpdated) : null;
417
+ syncErrorBanner = `
418
+ <div class="sync-error-banner" role="alert" aria-live="polite">
419
+ <div class="sync-error-content">
420
+ <span class="sync-error-icon" aria-hidden="true">⚠️</span>
421
+ <div class="sync-error-text">
422
+ <span class="sync-error-code">${escapeHtml(diag.code)}:</span>
423
+ <strong>Temporary Sync Failure:</strong> ${escapeHtml(diag.description)}
424
+ <span class="sync-error-guidance" style="font-size: 12px; opacity: 0.9;">${escapeHtml(diag.guidance)}</span>
425
+ ${lastSyncTime ? `<span class="sync-error-sub">Displaying data from last successful update at ${escapeHtml(lastSyncTime)}.</span>` : ""}
426
+ </div>
427
+ </div>
428
+ <button type="button" class="btn btn-secondary btn-sm" id="retry-overview-sync-btn" aria-label="Retry syncing queue metrics">
429
+ <span>Retry Now</span>
430
+ </button>
431
+ </div>
432
+ `;
433
+ }
434
+
435
+ let tableContent = "";
436
+
437
+ if (sortedQueues.length === 0) {
438
+ tableContent = `
439
+ <div class="state-container empty-filter-state" role="status" style="padding: 2.5rem 1rem;">
440
+ <div class="state-icon" aria-hidden="true">🔍</div>
441
+ <h3 class="state-title">No Matching Queues</h3>
442
+ <p class="state-description">
443
+ No registered queues match the current filter criteria${searchQuery ? ` ("${escapeHtml(searchQuery)}")` : ""}.
444
+ </p>
445
+ <button type="button" class="btn btn-secondary btn-sm overview-clear-btn" id="overview-reset-filters-btn" aria-label="Clear all queue filters">
446
+ <span>Clear Filter</span>
447
+ </button>
448
+ </div>
449
+ `;
450
+ } else {
451
+ const rows = sortedQueues
452
+ .map((q) => {
453
+ const name = q?.name || "unknown";
454
+ const metrics = q?.metrics;
455
+ const statusBadge = renderStatusBadge(q);
456
+ const encodedName = encodeURIComponent(name);
457
+
458
+ const waiting = metrics && typeof metrics.waiting === "number" && !Number.isNaN(metrics.waiting) ? metrics.waiting : (metrics?.waiting ?? null);
459
+ const active = metrics && typeof metrics.active === "number" && !Number.isNaN(metrics.active) ? metrics.active : (metrics?.active ?? null);
460
+ const delayed = metrics && typeof metrics.delayed === "number" && !Number.isNaN(metrics.delayed) ? metrics.delayed : (metrics?.delayed ?? null);
461
+ const dlq = metrics && typeof metrics.dlq === "number" && !Number.isNaN(metrics.dlq) ? metrics.dlq : (metrics?.dlq ?? null);
462
+
463
+ const queueLastUpdated = q?.lastUpdated || (options?.queueLastUpdated && options.queueLastUpdated[name]) || options?.lastUpdated;
464
+ const lastUpdatedFormatted = formatTimestamp(queueLastUpdated);
465
+ const lastUpdatedIso = formatIso(queueLastUpdated);
466
+ const lastUpdatedCell = lastUpdatedIso
467
+ ? `<td class="last-updated-cell"><time datetime="${escapeHtml(lastUpdatedIso)}" title="${escapeHtml(lastUpdatedIso)}">${escapeHtml(lastUpdatedFormatted)}</time></td>`
468
+ : `<td class="last-updated-cell text-muted">—</td>`;
469
+
470
+ return `
471
+ <tr>
472
+ <td class="queue-name-cell">${escapeHtml(name)}</td>
473
+ <td>${statusBadge}</td>
474
+ ${renderMetricCell(waiting, "Waiting")}
475
+ ${renderMetricCell(active, "Active")}
476
+ ${renderMetricCell(delayed, "Delayed")}
477
+ ${renderDLQCell(dlq, name)}
478
+ ${lastUpdatedCell}
479
+ <td class="queue-actions-cell" style="text-align: right; white-space: nowrap;">
480
+ <a
481
+ href="#/dlq?queue=${encodedName}"
482
+ class="btn btn-secondary btn-xs action-nav-btn overview-dlq-btn"
483
+ data-queue="${escapeHtml(name)}"
484
+ aria-label="Open DLQ Explorer for queue ${escapeHtml(name)}"
485
+ >DLQ</a>
486
+ <a
487
+ href="#/job?queue=${encodedName}"
488
+ class="btn btn-secondary btn-xs action-nav-btn overview-job-btn"
489
+ data-queue="${escapeHtml(name)}"
490
+ aria-label="Inspect jobs in queue ${escapeHtml(name)}"
491
+ >Inspect</a>
492
+ </td>
493
+ </tr>
494
+ `;
495
+ })
496
+ .join("");
497
+
498
+ tableContent = `
499
+ <div class="table-responsive">
500
+ <table class="data-table" role="table" aria-label="Registered Queues Telemetry">
501
+ <thead>
502
+ <tr>
503
+ <th scope="col" aria-sort="${getAriaSort("name", sortKey, sortOrder)}">
504
+ <button type="button" id="sort-btn-name" class="sort-header-btn" data-sort-key="name" aria-label="Sort by Queue Name">
505
+ <span>Queue Name</span>
506
+ <span class="sort-indicator">${getSortIndicator("name", sortKey, sortOrder)}</span>
507
+ </button>
508
+ </th>
509
+ <th scope="col" aria-sort="${getAriaSort("status", sortKey, sortOrder)}">
510
+ <button type="button" id="sort-btn-status" class="sort-header-btn" data-sort-key="status" aria-label="Sort by Status">
511
+ <span>Status</span>
512
+ <span class="sort-indicator">${getSortIndicator("status", sortKey, sortOrder)}</span>
513
+ </button>
514
+ </th>
515
+ <th scope="col" aria-sort="${getAriaSort("waiting", sortKey, sortOrder)}">
516
+ <button type="button" id="sort-btn-waiting" class="sort-header-btn" data-sort-key="waiting" aria-label="Sort by Waiting Count">
517
+ <span>Waiting</span>
518
+ <span class="sort-indicator">${getSortIndicator("waiting", sortKey, sortOrder)}</span>
519
+ </button>
520
+ </th>
521
+ <th scope="col" aria-sort="${getAriaSort("active", sortKey, sortOrder)}">
522
+ <button type="button" id="sort-btn-active" class="sort-header-btn" data-sort-key="active" aria-label="Sort by Active Count">
523
+ <span>Active</span>
524
+ <span class="sort-indicator">${getSortIndicator("active", sortKey, sortOrder)}</span>
525
+ </button>
526
+ </th>
527
+ <th scope="col" aria-sort="${getAriaSort("delayed", sortKey, sortOrder)}">
528
+ <button type="button" id="sort-btn-delayed" class="sort-header-btn" data-sort-key="delayed" aria-label="Sort by Delayed Count">
529
+ <span>Delayed</span>
530
+ <span class="sort-indicator">${getSortIndicator("delayed", sortKey, sortOrder)}</span>
531
+ </button>
532
+ </th>
533
+ <th scope="col" aria-sort="${getAriaSort("dlq", sortKey, sortOrder)}">
534
+ <button type="button" id="sort-btn-dlq" class="sort-header-btn" data-sort-key="dlq" aria-label="Sort by DLQ Count">
535
+ <span>DLQ</span>
536
+ <span class="sort-indicator">${getSortIndicator("dlq", sortKey, sortOrder)}</span>
537
+ </button>
538
+ </th>
539
+ <th scope="col" aria-sort="${getAriaSort("lastUpdated", sortKey, sortOrder)}">
540
+ <button type="button" id="sort-btn-lastUpdated" class="sort-header-btn" data-sort-key="lastUpdated" aria-label="Sort by Last Updated">
541
+ <span>Last Updated</span>
542
+ <span class="sort-indicator">${getSortIndicator("lastUpdated", sortKey, sortOrder)}</span>
543
+ </button>
544
+ </th>
545
+ <th scope="col" style="text-align: right;">Actions</th>
546
+ </tr>
547
+ </thead>
548
+ <tbody>
549
+ ${rows}
550
+ </tbody>
551
+ </table>
552
+ </div>
553
+ `;
554
+ }
555
+
556
+ const overallLastUpdated = options?.lastUpdated;
557
+ const overallFormatted = formatTimestamp(overallLastUpdated);
558
+ const overallIso = formatIso(overallLastUpdated);
559
+
560
+ const filterSummaryBadge = hasActiveFilters
561
+ ? `<span class="nav-badge">${filteredQueues.length} of ${queues.length} matching</span>`
562
+ : `<span class="nav-badge">${queues.length} registered</span>`;
563
+
564
+ return `
565
+ ${syncErrorBanner}
566
+ <section class="metrics-grid" aria-label="Aggregated Telemetry">
567
+ <div class="metric-card">
568
+ <div class="metric-label">Queues</div>
569
+ <div class="metric-value">${queues.length}</div>
570
+ </div>
571
+ <div class="metric-card">
572
+ <div class="metric-label">Waiting</div>
573
+ <div class="metric-value">${totalWaiting}</div>
574
+ </div>
575
+ <div class="metric-card">
576
+ <div class="metric-label">Active</div>
577
+ <div class="metric-value">${totalActive}</div>
578
+ </div>
579
+ <div class="metric-card">
580
+ <div class="metric-label">Delayed</div>
581
+ <div class="metric-value">${totalDelayed}</div>
582
+ </div>
583
+ <div class="metric-card">
584
+ <div class="metric-label">Dead-Letter</div>
585
+ <div class="metric-value ${totalDLQ > 0 ? "has-dlq" : ""}">${totalDLQ}</div>
586
+ </div>
587
+ </section>
588
+
589
+ <!-- Queue Management Controls: Search, Filter, and Sort -->
590
+ <section class="dlq-toolbar overview-toolbar" aria-label="Queue Search and Filter Controls">
591
+ <div class="form-group" style="flex: 1; min-width: 220px;">
592
+ <label for="overview-search-input" class="form-label">Search:</label>
593
+ <input
594
+ type="text"
595
+ id="overview-search-input"
596
+ class="form-input"
597
+ placeholder="Filter queues by name..."
598
+ value="${escapeHtml(searchQuery)}"
599
+ aria-label="Filter queues by name"
600
+ />
601
+ </div>
602
+
603
+ <div class="form-group">
604
+ <label for="overview-status-select" class="form-label">Status:</label>
605
+ <select id="overview-status-select" class="form-select" aria-label="Filter by queue status">
606
+ <option value="all" ${statusFilter === "all" ? "selected" : ""}>All Statuses</option>
607
+ <option value="active" ${statusFilter === "active" ? "selected" : ""}>Active</option>
608
+ <option value="paused" ${statusFilter === "paused" ? "selected" : ""}>Paused</option>
609
+ <option value="closed" ${statusFilter === "closed" ? "selected" : ""}>Closed</option>
610
+ <option value="unavailable" ${statusFilter === "unavailable" ? "selected" : ""}>Unavailable</option>
611
+ <option value="error" ${statusFilter === "error" ? "selected" : ""}>Error</option>
612
+ </select>
613
+ </div>
614
+
615
+ ${
616
+ hasActiveFilters
617
+ ? `
618
+ <div style="display: flex; align-items: center;">
619
+ <button
620
+ type="button"
621
+ id="overview-clear-filters-btn"
622
+ class="btn btn-secondary btn-sm overview-clear-btn"
623
+ aria-label="Clear active queue filters"
624
+ >
625
+ <span>Clear Filter</span>
626
+ </button>
627
+ </div>
628
+ `
629
+ : ""
630
+ }
631
+ </section>
632
+
633
+ <div class="table-card">
634
+ <div class="table-header">
635
+ <div class="table-title-group">
636
+ <h2 class="table-title">Registered Queues</h2>
637
+ ${filterSummaryBadge}
638
+ </div>
639
+ ${overallIso ? `<div class="table-meta"><span class="last-sync-label">Last updated: <time datetime="${escapeHtml(overallIso)}">${escapeHtml(overallFormatted)}</time></span></div>` : ""}
640
+ </div>
641
+ ${tableContent}
642
+ </div>
643
+ `;
644
+ }
645
+
646
+ const OverviewView = {
647
+ renderLoading: renderOverviewLoading,
648
+ renderEmpty: renderOverviewEmpty,
649
+ renderError: renderOverviewError,
650
+ renderData: renderOverviewData,
651
+ renderStatusBadge,
652
+ renderMetricCell,
653
+ renderDLQCell,
654
+ formatTimestamp,
655
+ formatIso,
656
+ getQueueStatus,
657
+ getDiagnosticInfo,
658
+ filterQueues,
659
+ sortQueues,
660
+ getMetricValue,
661
+ sanitizeUiErrorMessage
662
+ };
663
+
664
+ if (typeof module !== "undefined" && module.exports) {
665
+ module.exports = OverviewView;
666
+ }
667
+
668
+ if (typeof globalThis !== "undefined") {
669
+ globalThis.ResilientQueueOverviewView = OverviewView;
670
+ }