@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,501 @@
1
+ "use strict";
2
+
3
+ /**
4
+ * Dead Letter Queue (DLQ) View Component (Milestone 6)
5
+ *
6
+ * Full functional, read-only Dead Letter Queue Explorer.
7
+ * Supports:
8
+ * - Queue selection among registered queues
9
+ * - Real-time DLQ browsing via GET /api/queues/:queueName/dlq
10
+ * - Pagination via start, limit, and order parameters
11
+ * - Safe handling of corrupted records with id: null
12
+ * - Inline safe payload inspection
13
+ * - Loading, empty, error, retry, and queue-not-found states
14
+ */
15
+
16
+ function escapeHtml(str) {
17
+ if (str === null || str === undefined) return "";
18
+ return String(str)
19
+ .replace(/&/g, "&")
20
+ .replace(/</g, "&lt;")
21
+ .replace(/>/g, "&gt;")
22
+ .replace(/"/g, "&quot;")
23
+ .replace(/'/g, "&#039;");
24
+ }
25
+
26
+ function sanitizeUiErrorMessage(msg) {
27
+ if (typeof msg !== "string" || msg.trim().length === 0) {
28
+ return "Failed to retrieve DLQ jobs.";
29
+ }
30
+ let cleaned = msg.replace(
31
+ /\b([a-zA-Z][a-zA-Z0-9+.-]*:\/\/)(?:[^:\s/@]+:)?([^@\s/]+)@/g,
32
+ "$1[redacted]@"
33
+ );
34
+ cleaned = cleaned.replace(
35
+ /\b(AUTH|Bearer)\s+['"]?[^\s'";&]+['"]?/gi,
36
+ "$1 [redacted]"
37
+ );
38
+ cleaned = cleaned.replace(
39
+ /\b(password|passwd|pwd|token|secret|apikey|api_key)(\s*[:=]\s*)['"]?[^\s'";&]+['"]?/gi,
40
+ "$1$2[redacted]"
41
+ );
42
+ return cleaned;
43
+ }
44
+
45
+ function formatTimestamp(ts) {
46
+ if (typeof ts !== "number" || !Number.isFinite(ts) || ts <= 0) {
47
+ return "-";
48
+ }
49
+ try {
50
+ const d = new Date(ts);
51
+ if (Number.isNaN(d.getTime())) return "-";
52
+ return d.toLocaleString(undefined, {
53
+ year: "numeric",
54
+ month: "short",
55
+ day: "2-digit",
56
+ hour: "2-digit",
57
+ minute: "2-digit",
58
+ second: "2-digit"
59
+ });
60
+ } catch {
61
+ return "-";
62
+ }
63
+ }
64
+
65
+ function renderDLQLoading(queueName) {
66
+ return `
67
+ <div class="state-container loading-state" role="status" aria-live="polite">
68
+ <div class="spinner" aria-hidden="true"></div>
69
+ <h3 class="state-title">Loading Dead-Letter Jobs...</h3>
70
+ <p class="state-description">Fetching failed jobs for queue <code>${escapeHtml(queueName)}</code>.</p>
71
+ </div>
72
+ `;
73
+ }
74
+
75
+ function renderDLQEmpty(queueName) {
76
+ return `
77
+ <div class="state-container empty-state" role="status">
78
+ <div class="state-icon" aria-hidden="true">✨</div>
79
+ <h3 class="state-title">No Dead-Letter Jobs</h3>
80
+ <p class="state-description">
81
+ Queue <code>${escapeHtml(queueName)}</code> has 0 dead-letter jobs.
82
+ All enqueued tasks have either completed successfully or have not exceeded retry limits.
83
+ </p>
84
+ </div>
85
+ `;
86
+ }
87
+
88
+ function renderDLQNoQueues() {
89
+ return `
90
+ <div class="state-container empty-state" role="status">
91
+ <div class="state-icon" aria-hidden="true">📭</div>
92
+ <h3 class="state-title">No Queues Registered</h3>
93
+ <p class="state-description">
94
+ No queues were found in the adapter registry. Register queue instances in your server configuration:
95
+ </p>
96
+ <div class="code-snippet">
97
+ <code>createExpressAdapter({ queues: { "emails": emailQueue } })</code>
98
+ </div>
99
+ </div>
100
+ `;
101
+ }
102
+
103
+ function renderDLQPromptSelect() {
104
+ return `
105
+ <div class="state-container" role="status">
106
+ <div class="state-icon" aria-hidden="true">📋</div>
107
+ <h3 class="state-title">Select a Queue</h3>
108
+ <p class="state-description">
109
+ Choose a registered queue from the dropdown above to inspect its Dead Letter Queue (DLQ).
110
+ </p>
111
+ </div>
112
+ `;
113
+ }
114
+
115
+ function getDLQDiagnosticInfo(error, queueName) {
116
+ const code = error?.code || (error?.status === 404 ? "QUEUE_NOT_FOUND" : error?.status === 503 ? "QUEUE_CLOSED" : "INTERNAL_ERROR");
117
+ let guidance = "Check host application logs and verify Redis connectivity.";
118
+
119
+ switch (code) {
120
+ case "QUEUE_CLOSED":
121
+ guidance = "The target ResilientQueue instance has been closed via queue.close(). DLQ inspection cannot proceed until the queue is restarted.";
122
+ break;
123
+ case "QUEUE_NOT_FOUND":
124
+ guidance = `Queue '${queueName || "unknown"}' is not registered with the dashboard adapter. Ensure it is included in the 'queues' configuration object during adapter initialization.`;
125
+ break;
126
+ case "INTERNAL_ERROR":
127
+ guidance = "An internal error occurred while querying Redis for dead-letter jobs. Check Redis status and server logs.";
128
+ break;
129
+ case "NETWORK_ERROR":
130
+ guidance = "Unable to reach the dashboard API. Check your network connection and server status.";
131
+ break;
132
+ }
133
+
134
+ return { code, guidance };
135
+ }
136
+
137
+ function renderDLQError(error, queueName) {
138
+ const code = error?.code || "INTERNAL_ERROR";
139
+ const message = sanitizeUiErrorMessage(error?.message || "Failed to retrieve DLQ jobs.");
140
+ const isNotFound = code === "QUEUE_NOT_FOUND" || error?.status === 404;
141
+ const diag = getDLQDiagnosticInfo(error, queueName);
142
+
143
+ return `
144
+ <div class="state-container error-state" role="alert" aria-live="assertive">
145
+ <div class="state-icon" aria-hidden="true">⚠️</div>
146
+ <span class="error-code-badge">${escapeHtml(code)}</span>
147
+ <h3 class="state-title error-title">${isNotFound ? "Queue Not Found" : "Failed to Load DLQ"}</h3>
148
+ <p class="state-description">
149
+ ${isNotFound ? `Queue <code>${escapeHtml(queueName)}</code> is not registered with the dashboard adapter.` : escapeHtml(message)}
150
+ </p>
151
+ <div class="diagnostic-guidance" style="margin-top: 0.75rem; max-width: 540px; text-align: left;">
152
+ <div class="callout callout-warning">
153
+ <div class="callout-header"><span>Operational Diagnostic</span></div>
154
+ <div class="callout-body">${escapeHtml(diag.guidance)}</div>
155
+ </div>
156
+ </div>
157
+ <button type="button" class="btn btn-primary" id="dlq-retry-btn" aria-label="Retry loading DLQ" style="margin-top: 1rem;">
158
+ <span>Retry</span>
159
+ </button>
160
+ </div>
161
+ `;
162
+ }
163
+
164
+ function renderJobDetail(job, isCorrupted, queueName = null, index = 0) {
165
+ const detailId = `dlq-job-detail-${index}`;
166
+ if (isCorrupted) {
167
+ return `
168
+ <div id="${detailId}" class="job-detail-box" role="region" aria-label="Corrupted Job Details">
169
+ <div class="callout callout-warning" style="margin-bottom: 0.75rem;">
170
+ <div class="callout-header">
171
+ <span>⚠️ Corrupted Payload Record</span>
172
+ </div>
173
+ <div class="callout-body">
174
+ This entry in Redis could not be parsed as valid JSON: <code>${escapeHtml(job?.error || "Invalid JSON")}</code>.
175
+ The raw string payload has been preserved safely below.
176
+ </div>
177
+ </div>
178
+ <div class="form-label" style="margin-bottom: 0.35rem;">Raw Unparsed Content:</div>
179
+ <pre class="job-payload-box" tabindex="0" role="region" aria-label="Raw Unparsed Content"><code>${escapeHtml(job?.raw || "")}</code></pre>
180
+ </div>
181
+ `;
182
+ }
183
+
184
+ let dataFormatted = "null";
185
+ try {
186
+ dataFormatted = job?.data !== undefined ? JSON.stringify(job.data, null, 2) : "null";
187
+ if (dataFormatted === undefined) {
188
+ dataFormatted = "undefined";
189
+ }
190
+ } catch {
191
+ dataFormatted = String(job?.data);
192
+ }
193
+
194
+ return `
195
+ <div id="${detailId}" class="job-detail-box" role="region" aria-label="Job Diagnostics">
196
+ <div class="job-detail-grid">
197
+ <div class="detail-item">
198
+ <span class="detail-label">Job ID</span>
199
+ <span class="detail-value font-mono">${escapeHtml(job?.id || "-")}</span>
200
+ </div>
201
+ <div class="detail-item">
202
+ <span class="detail-label">Attempts Made</span>
203
+ <span class="detail-value">${typeof job?.attempt === "number" ? job.attempt : "-"}</span>
204
+ </div>
205
+ <div class="detail-item">
206
+ <span class="detail-label">Stalled Count</span>
207
+ <span class="detail-value">${typeof job?.stalledCount === "number" ? job.stalledCount : "0"}</span>
208
+ </div>
209
+ <div class="detail-item">
210
+ <span class="detail-label">Created At</span>
211
+ <span class="detail-value font-mono">${formatTimestamp(job?.createdAt)}</span>
212
+ </div>
213
+ <div class="detail-item">
214
+ <span class="detail-label">Failed At</span>
215
+ <span class="detail-value font-mono">${formatTimestamp(job?.failedAt)}</span>
216
+ </div>
217
+ <div class="detail-item">
218
+ <span class="detail-label">Last Retried At</span>
219
+ <span class="detail-value font-mono">${formatTimestamp(job?.retriedAt)}</span>
220
+ </div>
221
+ </div>
222
+
223
+ ${
224
+ job?.failureReason
225
+ ? `
226
+ <div style="margin-top: 0.75rem;">
227
+ <div class="detail-label">Failure Diagnostics:</div>
228
+ <div class="failure-callout">${escapeHtml(job.failureReason)}</div>
229
+ </div>
230
+ `
231
+ : ""
232
+ }
233
+
234
+ <div style="margin-top: 0.75rem;">
235
+ <div class="detail-label">Job Data Payload:</div>
236
+ <pre class="job-payload-box" tabindex="0" role="region" aria-label="Job Data Payload"><code>${escapeHtml(dataFormatted)}</code></pre>
237
+ </div>
238
+
239
+ ${
240
+ !isCorrupted && job?.id && queueName
241
+ ? `
242
+ <div style="margin-top: 0.75rem; display: flex; align-items: center; justify-content: space-between; gap: 0.75rem; flex-wrap: wrap;">
243
+ <span class="text-muted" style="font-size: 11px;">Record diagnostics loaded from Dead Letter Queue payload.</span>
244
+ <a
245
+ href="#/job?queue=${encodeURIComponent(queueName)}&jobId=${encodeURIComponent(job.id)}"
246
+ class="btn btn-secondary btn-sm inspect-in-job-view-btn"
247
+ data-queue="${escapeHtml(queueName)}"
248
+ data-job-id="${escapeHtml(job.id)}"
249
+ style="font-size: 12px;"
250
+ >
251
+ Inspect in Job Inspector &rarr;
252
+ </a>
253
+ </div>
254
+ `
255
+ : `
256
+ <div class="callout callout-info" style="margin-top: 0.75rem; margin-bottom: 0;">
257
+ <div class="callout-body" style="font-size: 11px;">
258
+ All diagnostics above are retrieved directly from the Dead Letter Queue record.
259
+ </div>
260
+ </div>
261
+ `
262
+ }
263
+ </div>
264
+ `;
265
+ }
266
+
267
+ function renderDLQTable(state) {
268
+ const { jobs = [], total = 0, start = 0, limit = 50, order = "desc", expandedJobIndex = null } = state;
269
+
270
+ const rows = jobs.map((job, index) => {
271
+ const isCorrupted = Boolean(!job || typeof job !== "object" || job.corrupted || job.id == null);
272
+ const isExpanded = expandedJobIndex === index;
273
+
274
+ let idHtml = "";
275
+ let reasonHtml = "";
276
+ let attemptsHtml = "";
277
+ let failedAtHtml = "";
278
+
279
+ if (isCorrupted) {
280
+ idHtml = `<span class="badge badge-corrupted" title="Corrupted JSON entry">CORRUPTED</span>`;
281
+ reasonHtml = `<span class="failure-reason-text text-danger" title="${escapeHtml(job?.error)}">${escapeHtml(job?.error || "Corrupted JSON")}</span>`;
282
+ attemptsHtml = `<span class="text-muted">-</span>`;
283
+ failedAtHtml = `<span class="text-muted">-</span>`;
284
+ } else {
285
+ idHtml = `<span class="font-mono text-primary" title="${escapeHtml(job?.id)}">${escapeHtml(job?.id)}</span>`;
286
+ reasonHtml = `<span class="failure-reason-text" title="${escapeHtml(job?.failureReason)}">${escapeHtml(job?.failureReason || "-")}</span>`;
287
+ const stalls = typeof job?.stalledCount === "number" && job.stalledCount > 0 ? ` (${job.stalledCount} stalls)` : "";
288
+ const attempts = typeof job?.attempt === "number" ? job.attempt : "-";
289
+ attemptsHtml = `<span class="metric-number">${attempts}${stalls}</span>`;
290
+ failedAtHtml = `<span class="font-mono text-muted">${formatTimestamp(job?.failedAt)}</span>`;
291
+ }
292
+
293
+ const actionText = isExpanded ? "Hide Details" : isCorrupted ? "View Raw" : "Inspect";
294
+
295
+ return `
296
+ <tr>
297
+ <td class="queue-name-cell">${idHtml}</td>
298
+ <td>${attemptsHtml}</td>
299
+ <td>${failedAtHtml}</td>
300
+ <td>${reasonHtml}</td>
301
+ <td style="text-align: right;">
302
+ <button
303
+ type="button"
304
+ class="btn btn-secondary btn-sm dlq-inspect-btn"
305
+ data-job-index="${index}"
306
+ aria-expanded="${isExpanded ? "true" : "false"}"
307
+ aria-controls="dlq-job-detail-${index}"
308
+ aria-label="${isExpanded ? "Collapse job details" : "Inspect job details for " + escapeHtml(job?.id || "corrupted job")}"
309
+ >
310
+ ${actionText}
311
+ </button>
312
+ </td>
313
+ </tr>
314
+ ${
315
+ isExpanded
316
+ ? `
317
+ <tr class="job-expanded-row">
318
+ <td colspan="5" style="padding: 0;">
319
+ ${renderJobDetail(job, isCorrupted, state.selectedQueue, index)}
320
+ </td>
321
+ </tr>
322
+ `
323
+ : ""
324
+ }
325
+ `;
326
+ }).join("");
327
+
328
+ const safeTotal = typeof total === "number" && Number.isFinite(total) && total >= 0 ? total : jobs.length;
329
+ const safeLimit = Math.max(1, parseInt(limit, 10) || 50);
330
+ const safeStart = Math.max(0, parseInt(start, 10) || 0);
331
+
332
+ const totalPages = Math.max(1, Math.ceil(safeTotal / safeLimit));
333
+ const currentPage = Math.floor(safeStart / safeLimit) + 1;
334
+ const startItem = safeTotal === 0 ? 0 : Math.min(safeStart + 1, safeTotal);
335
+ const endItem = Math.min(safeStart + jobs.length, safeTotal);
336
+
337
+ const prevDisabled = safeStart <= 0;
338
+ const nextDisabled = safeStart + safeLimit >= safeTotal || jobs.length < safeLimit;
339
+
340
+ return `
341
+ <div class="table-card">
342
+ <div class="table-header">
343
+ <h2 class="table-title">Dead-Letter Jobs</h2>
344
+ <span class="nav-badge badge-amber">${total} total failed</span>
345
+ </div>
346
+ <div class="table-responsive">
347
+ <table class="data-table" role="table" aria-label="Dead Letter Queue Jobs">
348
+ <thead>
349
+ <tr>
350
+ <th scope="col" style="min-width: 180px;">Job ID</th>
351
+ <th scope="col">Attempts</th>
352
+ <th scope="col">Failed At</th>
353
+ <th scope="col">Failure Reason</th>
354
+ <th scope="col" style="text-align: right;">Inspection</th>
355
+ </tr>
356
+ </thead>
357
+ <tbody>
358
+ ${rows}
359
+ </tbody>
360
+ </table>
361
+ </div>
362
+
363
+ <!-- Pagination Bar -->
364
+ <nav class="pagination-container" aria-label="DLQ Pagination Navigation">
365
+ <div class="pagination-summary">
366
+ Showing <strong>${startItem}–${endItem}</strong> of <strong>${total}</strong> jobs
367
+ </div>
368
+ <div class="pagination-actions">
369
+ <button
370
+ type="button"
371
+ class="btn btn-secondary btn-sm"
372
+ id="dlq-prev-btn"
373
+ ${prevDisabled ? "disabled" : ""}
374
+ aria-label="Go to previous page of DLQ jobs"
375
+ >
376
+ &larr; Previous
377
+ </button>
378
+ <span class="pagination-page-indicator font-mono">
379
+ Page ${currentPage} of ${totalPages}
380
+ </span>
381
+ <button
382
+ type="button"
383
+ class="btn btn-secondary btn-sm"
384
+ id="dlq-next-btn"
385
+ ${nextDisabled ? "disabled" : ""}
386
+ aria-label="Go to next page of DLQ jobs"
387
+ >
388
+ Next &rarr;
389
+ </button>
390
+ </div>
391
+ </nav>
392
+ </div>
393
+ `;
394
+ }
395
+
396
+ function renderDLQView(dlqState = {}, queues = []) {
397
+ if (!Array.isArray(queues) || queues.length === 0) {
398
+ return renderDLQNoQueues();
399
+ }
400
+
401
+ const selectedQueue = dlqState.selectedQueue || "";
402
+ const order = dlqState.order || "desc";
403
+ const limit = dlqState.limit || 50;
404
+
405
+ // Build queue options
406
+ const isSelectedInQueues = queues.some((q) => q?.name === selectedQueue);
407
+ let queueOptions = queues
408
+ .map((q) => {
409
+ const name = q?.name || "unknown";
410
+ const count = q?.metrics?.dlq || 0;
411
+ const isSelected = name === selectedQueue;
412
+ return `<option value="${escapeHtml(name)}" ${isSelected ? "selected" : ""}>${escapeHtml(name)} (DLQ: ${count})</option>`;
413
+ })
414
+ .join("");
415
+
416
+ if (selectedQueue && !isSelectedInQueues) {
417
+ queueOptions = `<option value="${escapeHtml(selectedQueue)}" selected>${escapeHtml(selectedQueue)} (Not in registry)</option>` + queueOptions;
418
+ }
419
+
420
+ const toolbarHtml = `
421
+ <section class="dlq-toolbar" aria-label="DLQ Explorer Controls">
422
+ <div class="form-group">
423
+ <label for="dlq-queue-select" class="form-label">Queue:</label>
424
+ <select id="dlq-queue-select" class="form-select" aria-label="Select registered queue">
425
+ <option value="" ${!selectedQueue ? "selected" : ""}>-- Select a Queue --</option>
426
+ ${queueOptions}
427
+ </select>
428
+ </div>
429
+
430
+ <div class="form-group">
431
+ <label for="dlq-order-select" class="form-label">Order:</label>
432
+ <select id="dlq-order-select" class="form-select" aria-label="Sort Order" ${!selectedQueue ? "disabled" : ""}>
433
+ <option value="desc" ${order === "desc" ? "selected" : ""}>Newest First (desc)</option>
434
+ <option value="asc" ${order === "asc" ? "selected" : ""}>Oldest First (asc)</option>
435
+ </select>
436
+ </div>
437
+
438
+ <div class="form-group">
439
+ <label for="dlq-limit-select" class="form-label">Per Page:</label>
440
+ <select id="dlq-limit-select" class="form-select" aria-label="Page Size" ${!selectedQueue ? "disabled" : ""}>
441
+ <option value="10" ${limit === 10 ? "selected" : ""}>10</option>
442
+ <option value="25" ${limit === 25 ? "selected" : ""}>25</option>
443
+ <option value="50" ${limit === 50 ? "selected" : ""}>50</option>
444
+ <option value="100" ${limit === 100 ? "selected" : ""}>100</option>
445
+ </select>
446
+ </div>
447
+
448
+ <div style="margin-left: auto;">
449
+ <button
450
+ type="button"
451
+ class="btn btn-secondary btn-sm"
452
+ id="dlq-refresh-btn"
453
+ ${!selectedQueue ? "disabled" : ""}
454
+ aria-label="Refresh current DLQ"
455
+ >
456
+ <span>↻ Refresh</span>
457
+ </button>
458
+ </div>
459
+ </section>
460
+ `;
461
+
462
+ let contentHtml = "";
463
+
464
+ if (!selectedQueue) {
465
+ contentHtml = renderDLQPromptSelect();
466
+ } else if (dlqState.loading) {
467
+ contentHtml = renderDLQLoading(selectedQueue);
468
+ } else if (dlqState.error) {
469
+ contentHtml = renderDLQError(dlqState.error, selectedQueue);
470
+ } else if (!Array.isArray(dlqState.jobs) || dlqState.jobs.length === 0) {
471
+ contentHtml = renderDLQEmpty(selectedQueue);
472
+ } else {
473
+ contentHtml = renderDLQTable(dlqState);
474
+ }
475
+
476
+ return `
477
+ ${toolbarHtml}
478
+ ${contentHtml}
479
+ `;
480
+ }
481
+
482
+ const DLQView = {
483
+ render: renderDLQView,
484
+ renderLoading: renderDLQLoading,
485
+ renderEmpty: renderDLQEmpty,
486
+ renderError: renderDLQError,
487
+ renderNoQueues: renderDLQNoQueues,
488
+ renderPromptSelect: renderDLQPromptSelect,
489
+ renderTable: renderDLQTable,
490
+ renderJobDetail,
491
+ getDLQDiagnosticInfo,
492
+ sanitizeUiErrorMessage
493
+ };
494
+
495
+ if (typeof module !== "undefined" && module.exports) {
496
+ module.exports = DLQView;
497
+ }
498
+
499
+ if (typeof globalThis !== "undefined") {
500
+ globalThis.ResilientQueueDLQView = DLQView;
501
+ }