@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,549 @@
1
+ "use strict";
2
+
3
+ /**
4
+ * Job Inspection View Component (Milestone 7)
5
+ *
6
+ * Full functional, read-only Job Inspector for querying individual job descriptors,
7
+ * lifecycle state, execution progress percentage, timing metadata, failure diagnostics,
8
+ * user payload, and return value.
9
+ *
10
+ * Conforms to `GET /api/queues/:queueName/jobs/:jobId` in docs/api-contract.md.
11
+ */
12
+
13
+ function escapeHtml(str) {
14
+ if (str === null || str === undefined) return "";
15
+ return String(str)
16
+ .replace(/&/g, "&")
17
+ .replace(/</g, "&lt;")
18
+ .replace(/>/g, "&gt;")
19
+ .replace(/"/g, "&quot;")
20
+ .replace(/'/g, "&#039;");
21
+ }
22
+
23
+ function sanitizeUiErrorMessage(msg) {
24
+ if (typeof msg !== "string" || msg.trim().length === 0) {
25
+ return "Failed to retrieve job descriptor.";
26
+ }
27
+ let cleaned = msg.replace(
28
+ /\b([a-zA-Z][a-zA-Z0-9+.-]*:\/\/)(?:[^:\s/@]+:)?([^@\s/]+)@/g,
29
+ "$1[redacted]@"
30
+ );
31
+ cleaned = cleaned.replace(
32
+ /\b(AUTH|Bearer)\s+['"]?[^\s'";&]+['"]?/gi,
33
+ "$1 [redacted]"
34
+ );
35
+ cleaned = cleaned.replace(
36
+ /\b(password|passwd|pwd|token|secret|apikey|api_key)(\s*[:=]\s*)['"]?[^\s'";&]+['"]?/gi,
37
+ "$1$2[redacted]"
38
+ );
39
+ return cleaned;
40
+ }
41
+
42
+ function formatTimestamp(ts) {
43
+ if (!ts) return "-";
44
+ const num = typeof ts === "number" ? ts : (ts instanceof Date ? ts.getTime() : Date.parse(ts));
45
+ if (typeof num !== "number" || !Number.isFinite(num) || num <= 0) {
46
+ return "-";
47
+ }
48
+ try {
49
+ const d = new Date(num);
50
+ if (Number.isNaN(d.getTime())) return "-";
51
+ return d.toLocaleString(undefined, {
52
+ year: "numeric",
53
+ month: "short",
54
+ day: "2-digit",
55
+ hour: "2-digit",
56
+ minute: "2-digit",
57
+ second: "2-digit"
58
+ });
59
+ } catch {
60
+ return "-";
61
+ }
62
+ }
63
+
64
+ function formatJson(val) {
65
+ if (val === undefined) return "undefined";
66
+ try {
67
+ return JSON.stringify(val, null, 2);
68
+ } catch {
69
+ return String(val);
70
+ }
71
+ }
72
+
73
+ function formatRetention(retention) {
74
+ if (retention === true) return "Remove immediately";
75
+ if (retention === false) return "Keep indefinitely";
76
+ if (typeof retention === "number") return `Keep latest ${retention} jobs`;
77
+ if (typeof retention === "object" && retention !== null) {
78
+ try {
79
+ return JSON.stringify(retention);
80
+ } catch {
81
+ return String(retention);
82
+ }
83
+ }
84
+ return "-";
85
+ }
86
+
87
+ function renderJobLoading(queueName, jobId) {
88
+ return `
89
+ <div class="state-container loading-state" role="status" aria-live="polite">
90
+ <div class="spinner" aria-hidden="true"></div>
91
+ <h3 class="state-title">Inspecting Job...</h3>
92
+ <p class="state-description">
93
+ Fetching lifecycle descriptor and telemetry for job <code>${escapeHtml(jobId)}</code> from queue <code>${escapeHtml(queueName)}</code>.
94
+ </p>
95
+ </div>
96
+ `;
97
+ }
98
+
99
+ function renderJobPromptSelect() {
100
+ return `
101
+ <div class="state-container" role="status">
102
+ <div class="state-icon" aria-hidden="true">🔍</div>
103
+ <h3 class="state-title">Inspect a Job</h3>
104
+ <p class="state-description">
105
+ Select a registered queue and enter a unique Job ID above to inspect its execution lifecycle, progress, timing, and payload.
106
+ </p>
107
+ </div>
108
+ `;
109
+ }
110
+
111
+ function renderJobNoQueues() {
112
+ return `
113
+ <div class="state-container empty-state" role="status">
114
+ <div class="state-icon" aria-hidden="true">📭</div>
115
+ <h3 class="state-title">No Queues Registered</h3>
116
+ <p class="state-description">
117
+ No queues were found in the adapter registry. Register queue instances in your server configuration:
118
+ </p>
119
+ <div class="code-snippet">
120
+ <code>createExpressAdapter({ queues: { "emails": emailQueue } })</code>
121
+ </div>
122
+ </div>
123
+ `;
124
+ }
125
+
126
+ function renderJobNotFound(queueName, jobId) {
127
+ return `
128
+ <div class="state-container" role="status" aria-live="polite">
129
+ <div class="state-icon" aria-hidden="true">🔎</div>
130
+ <span class="error-code-badge badge-amber">JOB_NOT_FOUND</span>
131
+ <h3 class="state-title">Job Not Found</h3>
132
+ <p class="state-description">
133
+ Job <code>${escapeHtml(jobId)}</code> was not found in queue <code>${escapeHtml(queueName)}</code>.
134
+ It may never have existed or has expired under retention policy (<code>removeOnComplete</code> / <code>removeOnFail</code>).
135
+ </p>
136
+ </div>
137
+ `;
138
+ }
139
+
140
+ function renderJobQueueNotFound(queueName) {
141
+ return `
142
+ <div class="state-container error-state" role="alert" aria-live="assertive">
143
+ <div class="state-icon" aria-hidden="true">⚠️</div>
144
+ <span class="error-code-badge">QUEUE_NOT_FOUND</span>
145
+ <h3 class="state-title error-title">Queue Not Registered</h3>
146
+ <p class="state-description">
147
+ Queue <code>${escapeHtml(queueName)}</code> is not registered with the dashboard adapter.
148
+ </p>
149
+ </div>
150
+ `;
151
+ }
152
+
153
+ function renderJobValidationError(error, queueName, jobId) {
154
+ return `
155
+ <div class="state-container error-state" role="alert" aria-live="assertive">
156
+ <div class="state-icon" aria-hidden="true">⚠️</div>
157
+ <span class="error-code-badge">VALIDATION_ERROR</span>
158
+ <h3 class="state-title error-title">Invalid Search Parameter</h3>
159
+ <p class="state-description">${escapeHtml(error?.message || "Please provide a valid queue and non-empty jobId.")}</p>
160
+ </div>
161
+ `;
162
+ }
163
+
164
+ function renderJobCompatibilityWarning(queueName) {
165
+ return `
166
+ <div class="callout callout-warning" role="alert">
167
+ <div class="callout-header">
168
+ <span>⚠️ Core Library Compatibility: getJob() Not Supported</span>
169
+ </div>
170
+ <div class="callout-body">
171
+ Queue <code>${escapeHtml(queueName)}</code> does not implement the public <code>getJob(id)</code> API.
172
+ Core library releases prior to <code>v2.2.0</code> (such as <code>v2.0.2</code>) do not expose individual job lookups; live job inspection requires <code>@mayurbhusare/resilient-queue@^2.2.0</code> or a compatible queue provider.
173
+ </div>
174
+ </div>
175
+ `;
176
+ }
177
+
178
+ function getJobDiagnosticInfo(error, queueName, jobId) {
179
+ const code = error?.code || (error?.status === 404 ? "JOB_NOT_FOUND" : error?.status === 503 ? "QUEUE_CLOSED" : error?.status === 501 ? "NOT_SUPPORTED" : "INTERNAL_ERROR");
180
+ let guidance = "Check host server logs and verify Redis connectivity.";
181
+
182
+ switch (code) {
183
+ case "QUEUE_CLOSED":
184
+ guidance = "The target queue instance has been closed via queue.close(). Operations are suspended until the application restarts the queue.";
185
+ break;
186
+ case "NOT_SUPPORTED":
187
+ guidance = "The installed version of @mayurbhusare/resilient-queue does not expose getJob(). Upgrade to a compatible core package release.";
188
+ break;
189
+ case "JOB_NOT_FOUND":
190
+ guidance = "Verify that the Job ID is correct. If the job was recently completed or failed, verify retention settings (removeOnComplete / removeOnFail).";
191
+ break;
192
+ case "QUEUE_NOT_FOUND":
193
+ guidance = `Queue '${queueName || "unknown"}' is not registered with the dashboard adapter. Check your server adapter configuration.`;
194
+ break;
195
+ case "VALIDATION_ERROR":
196
+ guidance = "Job ID must be a non-empty string. Verify search input parameters.";
197
+ break;
198
+ case "INTERNAL_ERROR":
199
+ guidance = "An internal error occurred while retrieving the job from Redis. Check Redis connectivity and server logs.";
200
+ break;
201
+ }
202
+
203
+ return { code, guidance };
204
+ }
205
+
206
+ function renderJobError(error, queueName, jobId) {
207
+ const code = error?.code || "INTERNAL_ERROR";
208
+ const message = sanitizeUiErrorMessage(error?.message || "Failed to retrieve job descriptor.");
209
+ const diag = getJobDiagnosticInfo(error, queueName, jobId);
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(code)}</span>
215
+ <h3 class="state-title error-title">Failed to Inspect Job</h3>
216
+ <p class="state-description">${escapeHtml(message)}</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="job-retry-btn" aria-label="Retry inspecting job" style="margin-top: 1rem;">
224
+ <span>Retry</span>
225
+ </button>
226
+ </div>
227
+ `;
228
+ }
229
+
230
+ function getStateBadgeClass(state) {
231
+ switch (state) {
232
+ case "completed":
233
+ return "badge-completed";
234
+ case "failed":
235
+ return "badge-failed";
236
+ case "active":
237
+ return "badge-active";
238
+ case "delayed":
239
+ return "badge-delayed";
240
+ case "waiting":
241
+ default:
242
+ return "badge-waiting";
243
+ }
244
+ }
245
+
246
+ function renderJobDetails(job, queueName) {
247
+ const state = job?.state || "unknown";
248
+ const stateBadgeClass = getStateBadgeClass(state);
249
+ const hasProgress = typeof job?.progress === "number" && Number.isFinite(job.progress) && job.progress >= 0;
250
+ const progressClamped = hasProgress ? Math.min(100, Math.max(0, Math.round(job.progress))) : 0;
251
+
252
+ const failureMessage = job?.failureReason || job?.lastError;
253
+ const hasResult = job?.result !== undefined || job?.returnValue !== undefined;
254
+ const resultValue = job?.result !== undefined ? job.result : job?.returnValue;
255
+
256
+ return `
257
+ <div class="table-card" role="region" aria-label="Job Inspection Details">
258
+ <div class="table-header" style="flex-wrap: wrap; gap: 0.5rem;">
259
+ <div style="display: flex; align-items: center; gap: 0.75rem; flex-wrap: wrap;">
260
+ <h2 class="table-title font-mono" style="margin: 0; font-size: 15px;">${escapeHtml(job?.id)}</h2>
261
+ <span class="badge ${stateBadgeClass}" aria-label="State: ${escapeHtml(state)}">${escapeHtml(state)}</span>
262
+ </div>
263
+ <span class="nav-badge font-mono">Queue: ${escapeHtml(queueName)}</span>
264
+ </div>
265
+
266
+ <div style="padding: 1.25rem;">
267
+ ${
268
+ hasProgress
269
+ ? `
270
+ <div class="progress-container" aria-label="Job Execution Progress">
271
+ <div style="display: flex; justify-content: space-between; font-size: 12px; margin-bottom: 0.35rem;">
272
+ <span class="detail-label">Execution Progress</span>
273
+ <span class="font-mono" style="font-weight: 600; color: var(--text-primary);">${progressClamped}%</span>
274
+ </div>
275
+ <div class="progress-bar" role="progressbar" aria-valuenow="${progressClamped}" aria-valuemin="0" aria-valuemax="100" aria-label="Job execution progress: ${progressClamped}%">
276
+ <div class="progress-fill" style="width: ${progressClamped}%;"></div>
277
+ </div>
278
+ </div>
279
+ `
280
+ : ""
281
+ }
282
+
283
+ ${
284
+ failureMessage
285
+ ? `
286
+ <div style="margin-bottom: 1.25rem;">
287
+ <div class="detail-label" style="color: var(--status-error);">Failure Diagnostics:</div>
288
+ <div class="failure-callout">${escapeHtml(failureMessage)}</div>
289
+ </div>
290
+ `
291
+ : ""
292
+ }
293
+
294
+ <div class="detail-label" style="margin-bottom: 0.5rem;">Job Lifecycle & Timing Metadata</div>
295
+ <div class="job-detail-grid">
296
+ <div class="detail-item">
297
+ <span class="detail-label">State</span>
298
+ <span class="detail-value font-mono">${escapeHtml(state)}</span>
299
+ </div>
300
+ <div class="detail-item">
301
+ <span class="detail-label">Attempts Made</span>
302
+ <span class="detail-value font-mono">${typeof job?.attempt === "number" ? job.attempt : "-"}</span>
303
+ </div>
304
+ <div class="detail-item">
305
+ <span class="detail-label">Stalled Count</span>
306
+ <span class="detail-value font-mono">${typeof job?.stalledCount === "number" ? job.stalledCount : "0"}</span>
307
+ </div>
308
+ <div class="detail-item">
309
+ <span class="detail-label">Created At</span>
310
+ <span class="detail-value font-mono">${formatTimestamp(job?.createdAt)}</span>
311
+ </div>
312
+ <div class="detail-item">
313
+ <span class="detail-label">Started At</span>
314
+ <span class="detail-value font-mono">${formatTimestamp(job?.startedAt)}</span>
315
+ </div>
316
+ <div class="detail-item">
317
+ <span class="detail-label">Completed At</span>
318
+ <span class="detail-value font-mono">${formatTimestamp(job?.completedAt)}</span>
319
+ </div>
320
+ <div class="detail-item">
321
+ <span class="detail-label">Failed At</span>
322
+ <span class="detail-value font-mono">${formatTimestamp(job?.failedAt)}</span>
323
+ </div>
324
+ <div class="detail-item">
325
+ <span class="detail-label">Last Retried At</span>
326
+ <span class="detail-value font-mono">${formatTimestamp(job?.retriedAt)}</span>
327
+ </div>
328
+ <div class="detail-item">
329
+ <span class="detail-label">Last Stalled At</span>
330
+ <span class="detail-value font-mono">${formatTimestamp(job?.lastStalledAt)}</span>
331
+ </div>
332
+ ${
333
+ job?.runAt
334
+ ? `
335
+ <div class="detail-item">
336
+ <span class="detail-label">Scheduled Run At</span>
337
+ <span class="detail-value font-mono">${formatTimestamp(job.runAt)}</span>
338
+ </div>
339
+ `
340
+ : ""
341
+ }
342
+ ${
343
+ typeof job?.delay === "number"
344
+ ? `
345
+ <div class="detail-item">
346
+ <span class="detail-label">Configured Delay</span>
347
+ <span class="detail-value font-mono">${job.delay}ms</span>
348
+ </div>
349
+ `
350
+ : ""
351
+ }
352
+ ${
353
+ job?.idempotencyKey
354
+ ? `
355
+ <div class="detail-item">
356
+ <span class="detail-label">Idempotency Key</span>
357
+ <span class="detail-value font-mono">${escapeHtml(job.idempotencyKey)}</span>
358
+ </div>
359
+ `
360
+ : ""
361
+ }
362
+ ${
363
+ typeof job?.ttlSeconds === "number"
364
+ ? `
365
+ <div class="detail-item">
366
+ <span class="detail-label">Key TTL</span>
367
+ <span class="detail-value font-mono">${job.ttlSeconds}s</span>
368
+ </div>
369
+ `
370
+ : ""
371
+ }
372
+ ${
373
+ job?.removeOnComplete !== undefined
374
+ ? `
375
+ <div class="detail-item">
376
+ <span class="detail-label">Retention (Complete)</span>
377
+ <span class="detail-value font-mono">${escapeHtml(formatRetention(job.removeOnComplete))}</span>
378
+ </div>
379
+ `
380
+ : ""
381
+ }
382
+ ${
383
+ job?.removeOnFail !== undefined
384
+ ? `
385
+ <div class="detail-item">
386
+ <span class="detail-label">Retention (Fail)</span>
387
+ <span class="detail-value font-mono">${escapeHtml(formatRetention(job.removeOnFail))}</span>
388
+ </div>
389
+ `
390
+ : ""
391
+ }
392
+ </div>
393
+
394
+ <div style="margin-top: 1.25rem;">
395
+ <div class="detail-label">Job Payload (Data):</div>
396
+ <pre class="job-payload-box" tabindex="0" role="region" aria-label="Job Payload Data"><code>${escapeHtml(formatJson(job?.data))}</code></pre>
397
+ </div>
398
+
399
+ ${
400
+ hasResult
401
+ ? `
402
+ <div style="margin-top: 1.25rem;">
403
+ <div class="detail-label">Result (Return Value):</div>
404
+ <pre class="job-payload-box" tabindex="0" role="region" aria-label="Job Return Value"><code>${escapeHtml(formatJson(resultValue))}</code></pre>
405
+ </div>
406
+ `
407
+ : ""
408
+ }
409
+ </div>
410
+ </div>
411
+ `;
412
+ }
413
+
414
+ function renderJobSearchToolbar(state = {}, queues = [], selectedQueue = "") {
415
+ const isSelectedInQueues = queues.some((q) => q?.name === selectedQueue);
416
+ let queueOptions = queues
417
+ .map((q) => {
418
+ const name = q?.name || "unknown";
419
+ const isSelected = name === selectedQueue;
420
+ return `<option value="${escapeHtml(name)}" ${isSelected ? "selected" : ""}>${escapeHtml(name)}</option>`;
421
+ })
422
+ .join("");
423
+
424
+ if (selectedQueue && !isSelectedInQueues) {
425
+ queueOptions = `<option value="${escapeHtml(selectedQueue)}" selected>${escapeHtml(selectedQueue)} (Not in registry)</option>` + queueOptions;
426
+ }
427
+
428
+ const hasActiveSearch = Boolean(state.job || state.error || (state.jobId && state.jobId.trim().length > 0));
429
+
430
+ return `
431
+ <section class="dlq-toolbar job-search-toolbar" aria-label="Job Inspector Controls">
432
+ <form id="job-search-form" class="job-search-form" role="search" aria-label="Job Search Form">
433
+ <div class="form-group">
434
+ <label for="job-queue-select" class="form-label">Queue:</label>
435
+ <select id="job-queue-select" class="form-select" aria-label="Select registered queue">
436
+ ${queueOptions}
437
+ </select>
438
+ </div>
439
+
440
+ <div class="form-group" style="flex: 1; min-width: 240px;">
441
+ <label for="job-id-input" class="form-label">Job ID:</label>
442
+ <input
443
+ type="text"
444
+ id="job-id-input"
445
+ class="form-input font-mono"
446
+ placeholder="Enter Job ID (e.g. 550e8400-e29b-41d4...)"
447
+ value="${escapeHtml(state.jobId || "")}"
448
+ aria-label="Job ID to inspect"
449
+ required
450
+ />
451
+ </div>
452
+
453
+ <div style="display: flex; gap: 0.5rem; align-items: center;">
454
+ <button
455
+ type="submit"
456
+ id="job-search-btn"
457
+ class="btn btn-primary btn-sm"
458
+ aria-label="Inspect Job"
459
+ >
460
+ <span>Inspect</span>
461
+ </button>
462
+ ${
463
+ hasActiveSearch
464
+ ? `
465
+ <button
466
+ type="button"
467
+ id="job-clear-btn"
468
+ class="btn btn-secondary btn-sm"
469
+ aria-label="Clear active job search"
470
+ >
471
+ <span>Clear</span>
472
+ </button>
473
+ `
474
+ : ""
475
+ }
476
+ </div>
477
+ </form>
478
+ </section>
479
+ `;
480
+ }
481
+
482
+ function renderJobView(jobState = {}, queues = []) {
483
+ if (!Array.isArray(queues) || queues.length === 0) {
484
+ return renderJobNoQueues();
485
+ }
486
+
487
+ const selectedQueue = jobState.selectedQueue || (queues[0]?.name || "");
488
+ const jobId = jobState.jobId || "";
489
+
490
+ const toolbarHtml = renderJobSearchToolbar(jobState, queues, selectedQueue);
491
+
492
+ let contentHtml = "";
493
+
494
+ if (jobState.loading) {
495
+ contentHtml = renderJobLoading(selectedQueue, jobId);
496
+ } else if (jobState.error) {
497
+ const error = jobState.error;
498
+ const code = error?.code || "";
499
+ const status = error?.status || error?.statusCode;
500
+
501
+ if (code === "NOT_SUPPORTED" || status === 501) {
502
+ contentHtml = renderJobCompatibilityWarning(selectedQueue);
503
+ } else if (
504
+ code === "QUEUE_NOT_FOUND" ||
505
+ (typeof error?.message === "string" && /queue.*not registered/i.test(error.message))
506
+ ) {
507
+ contentHtml = renderJobQueueNotFound(selectedQueue);
508
+ } else if (code === "JOB_NOT_FOUND" || status === 404) {
509
+ contentHtml = renderJobNotFound(selectedQueue, jobId);
510
+ } else if (code === "VALIDATION_ERROR" || status === 400) {
511
+ contentHtml = renderJobValidationError(error, selectedQueue, jobId);
512
+ } else {
513
+ contentHtml = renderJobError(error, selectedQueue, jobId);
514
+ }
515
+ } else if (jobState.job) {
516
+ contentHtml = renderJobDetails(jobState.job, selectedQueue);
517
+ } else {
518
+ contentHtml = renderJobPromptSelect();
519
+ }
520
+
521
+ return `
522
+ ${toolbarHtml}
523
+ ${contentHtml}
524
+ `;
525
+ }
526
+
527
+ const JobView = {
528
+ render: renderJobView,
529
+ renderLoading: renderJobLoading,
530
+ renderPromptSelect: renderJobPromptSelect,
531
+ renderNoQueues: renderJobNoQueues,
532
+ renderNotFound: renderJobNotFound,
533
+ renderQueueNotFound: renderJobQueueNotFound,
534
+ renderValidationError: renderJobValidationError,
535
+ renderCompatibilityWarning: renderJobCompatibilityWarning,
536
+ renderError: renderJobError,
537
+ renderJobDetails,
538
+ renderSearchToolbar: renderJobSearchToolbar,
539
+ getJobDiagnosticInfo,
540
+ sanitizeUiErrorMessage
541
+ };
542
+
543
+ if (typeof module !== "undefined" && module.exports) {
544
+ module.exports = JobView;
545
+ }
546
+
547
+ if (typeof globalThis !== "undefined") {
548
+ globalThis.ResilientQueueJobView = JobView;
549
+ }