@zintrust/queue-monitor 0.4.50 → 0.4.63

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.
@@ -2,7 +2,7 @@ import { Logger } from '@zintrust/core';
2
2
  export const ALL_QUEUES = '__all__';
3
3
  const subscriptions = new Map();
4
4
  const isAllQueuesSelection = (queue) => queue === ALL_QUEUES;
5
- const sortJobsByTimestamp = (jobs) => jobs.sort((left, right) => right.timestamp - left.timestamp);
5
+ const sortJobsByTimestamp = (jobs) => jobs.toSorted((left, right) => right.timestamp - left.timestamp);
6
6
  export async function getRecentJobsForSelection(queueName, metrics, driver, queueNames) {
7
7
  if (!isAllQueuesSelection(queueName)) {
8
8
  return getRecentJobsForQueue(queueName, metrics, driver);
@@ -47,10 +47,6 @@ const pushSnapshot = async (subscription) => {
47
47
  const startPolling = (subscription) => {
48
48
  if (subscription.interval)
49
49
  return;
50
- Logger.debug('Starting QueueMonitoringService polling', {
51
- queue: subscription.config.queue || null,
52
- pattern: subscription.config.pattern,
53
- });
54
50
  void pushSnapshot(subscription);
55
51
  subscription.interval = setInterval(() => {
56
52
  void pushSnapshot(subscription);
@@ -59,10 +55,6 @@ const startPolling = (subscription) => {
59
55
  const stopPolling = (subscription) => {
60
56
  if (!subscription.interval)
61
57
  return;
62
- Logger.debug('Stopping QueueMonitoringService polling', {
63
- queue: subscription.config.queue || null,
64
- pattern: subscription.config.pattern,
65
- });
66
58
  clearInterval(subscription.interval);
67
59
  subscription.interval = null;
68
60
  };
@@ -1,14 +1,14 @@
1
1
  {
2
2
  "name": "@zintrust/queue-monitor",
3
- "version": "0.4.43",
4
- "buildDate": "2026-04-01T18:15:53.560Z",
3
+ "version": "0.4.63",
4
+ "buildDate": "2026-04-05T13:12:54.428Z",
5
5
  "buildEnvironment": {
6
6
  "node": "v22.22.1",
7
7
  "platform": "darwin",
8
8
  "arch": "arm64"
9
9
  },
10
10
  "git": {
11
- "commit": "57e4d1b5",
11
+ "commit": "533afc24",
12
12
  "branch": "release"
13
13
  },
14
14
  "package": {
@@ -25,12 +25,12 @@
25
25
  },
26
26
  "files": {
27
27
  "QueueMonitoringService.d.ts": {
28
- "size": 1492,
29
- "sha256": "a87d7ab795606e7b238c01a657306bea805f63eb4c155951d31c191bfd8582e1"
28
+ "size": 1684,
29
+ "sha256": "26a91f1b41d8a976a9e8fabaedc3fe6dc955c700499f0afc1a16fb91c9848665"
30
30
  },
31
31
  "QueueMonitoringService.js": {
32
- "size": 6190,
33
- "sha256": "068477c0b545686c9c35d6b42960b5ecf8d19ee4f3f5adec8ddeeb21113258b3"
32
+ "size": 6679,
33
+ "sha256": "edd069c1229071078223cd23cbb7bdae9cbb2431ae603ab35f0cfcb7cd1f6ffe"
34
34
  },
35
35
  "api/workerClient.d.ts": {
36
36
  "size": 597,
@@ -41,8 +41,8 @@
41
41
  "sha256": "60f993a42f4a9dd5000b01dae3b0f105a8f4348da8433a735b5cc1929a8f64ce"
42
42
  },
43
43
  "build-manifest.json": {
44
- "size": 3865,
45
- "sha256": "6b9eece186e37f5ed657a014c799e3783f30d0e16e0c2fb4e9fa8f2ffcbdd673"
44
+ "size": 3866,
45
+ "sha256": "0419f514ce1ea9da7a111680fc03af009afce9a11c61bb909f53e42a09b61690"
46
46
  },
47
47
  "config/queueMonitor.d.ts": {
48
48
  "size": 407,
@@ -73,8 +73,8 @@
73
73
  "sha256": "1b487b6e9cedbcb00cde7e920fb128724cb21aa154160e0bd02b02e2a0bd7334"
74
74
  },
75
75
  "dashboard-ui.js": {
76
- "size": 42243,
77
- "sha256": "04f5bf91f2f283db1149b3cea7ea74a17147d13df4f5e83ca3ec4ed79cedd03e"
76
+ "size": 53771,
77
+ "sha256": "530bdfba8deed3b881ab2b7ea6190b9a521bf4bdcc5a08a386a71b4c031ccb56"
78
78
  },
79
79
  "driver.d.ts": {
80
80
  "size": 707,
@@ -85,16 +85,16 @@
85
85
  "sha256": "50229d05df9ca8bf5528b923e2799a4719e5a32ad9fa30bf3e2f36ecc91cf00c"
86
86
  },
87
87
  "index.d.ts": {
88
- "size": 1965,
89
- "sha256": "be25d5e56f1c0ce0387c67ef7ce7544074e0d90698ad93826bb4d557d1a0b26f"
88
+ "size": 2161,
89
+ "sha256": "2556c136641769f4e7cd81abd6ecae8663433aba74b09bc507b8fba01e3fc6f5"
90
90
  },
91
91
  "index.js": {
92
- "size": 11889,
93
- "sha256": "19a7bdc71cec34fe732dd0d7177ad366d271e31462f402911d4a2700d0ea8b2c"
92
+ "size": 12811,
93
+ "sha256": "eed486a0813866e41d75bac10fdbaba27091d6a07b32561c6176663e9a7dc510"
94
94
  },
95
95
  "metrics.d.ts": {
96
- "size": 848,
97
- "sha256": "3ed092d97ff3ce81b43aa85dbded2898479d5c4d46860f22f21415085dce6229"
96
+ "size": 868,
97
+ "sha256": "cbae21100c770e0960f4ae8c96bfaa2015162042122e04f00812817f04306512"
98
98
  },
99
99
  "metrics.js": {
100
100
  "size": 3448,
@@ -337,8 +337,6 @@ const getDashboardScriptAutoRefresh = () => `
337
337
  const getRenderStatsFunction = () => `
338
338
  function renderStats(data) {
339
339
  const grid = document.getElementById('stats-grid');
340
- grid.innerHTML = '';
341
-
342
340
  const totalActive = data.queues.reduce((acc, q) => acc + q.counts.active, 0);
343
341
  const totalFailed = data.queues.reduce((acc, q) => acc + q.counts.failed, 0);
344
342
  const totalDelayed = data.queues.reduce((acc, q) => acc + q.counts.delayed, 0);
@@ -374,144 +372,359 @@ const getRenderStatsFunction = () => `
374
372
  }
375
373
  ];
376
374
 
377
- cards.forEach(card => {
378
- const div = document.createElement('div');
379
- div.className = 'tile';
380
- const infoIcon = '<span class="info-icon" data-info="' + card.info + '">i</span>';
381
- div.innerHTML =
382
- '<div class="stat-header">' +
383
- '<div class="stat-label">' + card.label + '</div>' +
384
- infoIcon +
385
- '</div>' +
386
- '<div class="stat-value" style="' + (card.color ? 'color:' + card.color : '') + '">' +
387
- card.value +
388
- '</div>';
389
- grid.appendChild(div);
390
- });
375
+ cards.forEach((card, index) => {
376
+ let div = grid.children[index];
377
+ let labelEl;
378
+ let valueEl;
379
+ let iconEl;
380
+
381
+ if (!div) {
382
+ div = document.createElement('div');
383
+ div.className = 'tile';
384
+
385
+ const header = document.createElement('div');
386
+ header.className = 'stat-header';
387
+
388
+ labelEl = document.createElement('div');
389
+ labelEl.className = 'stat-label';
390
+
391
+ iconEl = document.createElement('span');
392
+ iconEl.className = 'info-icon';
393
+ iconEl.textContent = 'i';
394
+ iconEl.addEventListener('mouseenter', showTooltip);
395
+ iconEl.addEventListener('mouseleave', hideTooltip);
396
+
397
+ valueEl = document.createElement('div');
398
+ valueEl.className = 'stat-value';
399
+
400
+ header.appendChild(labelEl);
401
+ header.appendChild(iconEl);
402
+ div.appendChild(header);
403
+ div.appendChild(valueEl);
404
+ grid.appendChild(div);
405
+ } else {
406
+ labelEl = div.querySelector('.stat-label');
407
+ valueEl = div.querySelector('.stat-value');
408
+ iconEl = div.querySelector('.info-icon');
409
+ }
391
410
 
392
- document.querySelectorAll('.info-icon').forEach(icon => {
393
- icon.addEventListener('mouseenter', showTooltip);
394
- icon.addEventListener('mouseleave', hideTooltip);
411
+ if (labelEl) labelEl.textContent = card.label;
412
+ if (valueEl) {
413
+ valueEl.textContent = String(card.value);
414
+ valueEl.style.color = card.color || '';
415
+ }
416
+ if (iconEl) iconEl.setAttribute('data-info', card.info);
395
417
  });
418
+
419
+ while (grid.children.length > cards.length) {
420
+ grid.removeChild(grid.lastElementChild);
421
+ }
396
422
  }`;
397
- const getUpdateQueueSelectFunction = () => `
398
- function updateQueueSelect(queues) {
399
- const select = document.getElementById('queue-select');
423
+ const getUpdateQueueSelectHelpersFunction = () => `
424
+ function formatQueueOptionCounts(counts) {
425
+ return counts.waiting + ' waiting, ' + counts.failed + ' failed, ' + counts.completed + ' completed';
426
+ }
427
+
428
+ function getPreferredQueue(select) {
400
429
  const storedQueue = localStorage.getItem(QUEUE_KEY);
401
- const preferredQueue = currentQueue || select.value || storedQueue || '';
402
- select.innerHTML = '';
430
+ return currentQueue || select.value || storedQueue || '';
431
+ }
403
432
 
404
- if (queues.length === 0) {
405
- select.disabled = true;
433
+ function getEmptyQueueSelection(select) {
434
+ select.disabled = true;
435
+ if (select.options.length !== 1 || select.options[0].value !== '' || select.options[0].textContent !== 'No queues') {
406
436
  select.innerHTML = '<option value="">No queues</option>';
407
- return '';
408
437
  }
438
+ return '';
439
+ }
409
440
 
410
- const queueNames = queues.map(q => q.name);
441
+ function getDesiredQueueOptions(queues) {
411
442
  const totalWaiting = queues.reduce((acc, queue) => acc + queue.counts.waiting, 0);
412
443
  const totalFailed = queues.reduce((acc, queue) => acc + queue.counts.failed, 0);
413
- const allOption = document.createElement('option');
414
- allOption.value = ALL_QUEUES;
415
- allOption.textContent = 'All queues (' + totalWaiting + ' waiting, ' + totalFailed + ' failed)';
444
+ const totalCompleted = queues.reduce((acc, queue) => acc + queue.counts.completed, 0);
445
+
446
+ return [
447
+ {
448
+ value: ALL_QUEUES,
449
+ text: 'All queues (' + totalWaiting + ' waiting, ' + totalFailed + ' failed, ' + totalCompleted + ' completed)'
450
+ },
451
+ ...queues.map(q => ({
452
+ value: q.name,
453
+ text: q.name + ' (' + formatQueueOptionCounts(q.counts) + ')'
454
+ }))
455
+ ];
456
+ }
457
+
458
+ function syncQueueSelectOptions(select, desiredOptions) {
459
+ const existingOptions = new Map();
460
+ Array.from(select.options).forEach(option => {
461
+ existingOptions.set(option.value, option);
462
+ });
463
+ const existingValues = Array.from(select.options).map(option => option.value);
464
+ const desiredValues = desiredOptions.map(option => option.value);
465
+ const needsStructuralSync = existingValues.length !== desiredValues.length || desiredValues.some((value, index) => existingValues[index] !== value);
466
+
467
+ select.disabled = false;
416
468
 
469
+ desiredOptions.forEach((item, index) => {
470
+ let option = existingOptions.get(item.value);
471
+ if (!option) {
472
+ option = document.createElement('option');
473
+ option.value = item.value;
474
+ }
475
+
476
+ if (option.textContent !== item.text) {
477
+ option.textContent = item.text;
478
+ }
479
+
480
+ if (needsStructuralSync) {
481
+ const currentAtIndex = select.options[index];
482
+ if (currentAtIndex !== option) {
483
+ select.insertBefore(option, currentAtIndex || null);
484
+ }
485
+ }
486
+ });
487
+
488
+ if (needsStructuralSync) {
489
+ Array.from(select.options).forEach(option => {
490
+ if (!desiredOptions.some(item => item.value === option.value)) {
491
+ option.remove();
492
+ }
493
+ });
494
+ }
495
+ }
496
+ `;
497
+ const getUpdateQueueSelectFunction = () => `
498
+
499
+ function updateQueueSelect(queues) {
500
+ const select = document.getElementById('queue-select');
501
+ const preferredQueue = getPreferredQueue(select);
502
+
503
+ if (queues.length === 0) {
504
+ return getEmptyQueueSelection(select);
505
+ }
506
+
507
+ const queueNames = queues.map(q => q.name);
417
508
  const nextQueue = preferredQueue === ALL_QUEUES || queueNames.includes(preferredQueue)
418
509
  ? preferredQueue
419
510
  : queueNames[0];
420
- select.disabled = false;
421
- allOption.selected = nextQueue === ALL_QUEUES;
422
- select.appendChild(allOption);
423
-
424
- queues.forEach(q => {
425
- const opt = document.createElement('option');
426
- opt.value = q.name;
427
- opt.textContent = q.name + ' (' + q.counts.waiting + ' waiting, ' + q.counts.failed + ' failed)';
428
- opt.selected = q.name === nextQueue;
429
- select.appendChild(opt);
430
- });
511
+ const desiredOptions = getDesiredQueueOptions(queues);
512
+
513
+ if (document.activeElement === select) {
514
+ return nextQueue;
515
+ }
431
516
 
432
- select.value = nextQueue;
517
+ syncQueueSelectOptions(select, desiredOptions);
518
+
519
+ if (select.value !== nextQueue) {
520
+ select.value = nextQueue;
521
+ }
433
522
  return nextQueue;
434
523
  }`;
435
- const getRenderJobsFunction = () => `
524
+ const getRenderJobsStateFunction = () => `
436
525
  // Track expanded job IDs to preserve state during SSE updates
437
526
  let expandedJobIds = new Set();
527
+ `;
528
+ const getRenderJobsIdentityHelpersFunction = () => `
529
+ function getJobId(job) {
530
+ return String(job.id);
531
+ }
438
532
 
439
- function renderJobs(jobs) {
440
- const tbody = document.querySelector('#jobs-table tbody');
533
+ function getAdjacentJobDetailRow(row) {
534
+ const existingDetail = row.nextElementSibling;
535
+ return existingDetail && existingDetail.classList.contains('detail-row')
536
+ ? existingDetail
537
+ : null;
538
+ }
441
539
 
442
- // Store currently expanded job IDs before clearing
443
- const currentExpanded = document.querySelectorAll('.expand-icon.expanded');
444
- currentExpanded.forEach(icon => {
445
- const row = icon.closest('tr');
446
- if (row) {
447
- const jobId = row.querySelector('code')?.textContent;
448
- if (jobId) expandedJobIds.add(jobId);
449
- }
540
+ function getJobStatusInfo(job) {
541
+ const status = (job.status || (job.failedReason ? 'failed' : 'completed')).toLowerCase();
542
+ const statusMap = {
543
+ failed: { label: 'Failed', cls: 'status-failed' },
544
+ completed: { label: 'Completed', cls: 'status-completed' },
545
+ active: { label: 'Active', cls: 'status-active' },
546
+ waiting: { label: 'Waiting', cls: 'status-waiting' },
547
+ delayed: { label: 'Delayed', cls: 'status-delayed' },
548
+ paused: { label: 'Paused', cls: 'status-paused' }
549
+ };
550
+
551
+ return statusMap[status] || statusMap.completed;
552
+ }
553
+
554
+ function getJobRetryMarkup(job) {
555
+ const status = (job.status || (job.failedReason ? 'failed' : 'completed')).toLowerCase();
556
+ if (status === 'failed') {
557
+ return '<button class="retry-btn" onclick="retryJob(' + "'" + job.id + "'" + ', ' + "'" + (job.queue || currentQueue) + "'" + ')" title="Retry this job">↻ Retry</button>';
558
+ }
559
+ return '<span style="color: var(--muted); font-size: 11px;">—</span>';
560
+ }
561
+ `;
562
+ const getRenderJobsDetailRowHelpersFunction = () => `
563
+
564
+ function buildJobDetailMarkup(job) {
565
+ const jobData = {
566
+ id: job.id,
567
+ name: job.name,
568
+ queue: job.queue || currentQueue,
569
+ status: job.status || (job.failedReason ? 'failed' : 'completed'),
570
+ attempts: job.attempts,
571
+ timestamp: new Date(job.timestamp).toISOString(),
572
+ data: job.data || {},
573
+ failedReason: job.failedReason || null,
574
+ processedOn: job.processedOn ? new Date(job.processedOn).toISOString() : null,
575
+ finishedOn: job.finishedOn ? new Date(job.finishedOn).toISOString() : null,
576
+ returnvalue: job.returnvalue
577
+ };
578
+
579
+ return '<td colspan="7" class="detail-cell">' +
580
+ '<div class="detail-content">' +
581
+ '<strong style="color: var(--accent); display: block; margin-bottom: 8px;">Job Details:</strong>' +
582
+ '<pre>' + JSON.stringify(jobData, null, 2) + '</pre>' +
583
+ '</div>' +
584
+ '</td>';
585
+ }
586
+
587
+ function removeJobDetailRow(row) {
588
+ const existingDetail = getAdjacentJobDetailRow(row);
589
+ if (existingDetail) {
590
+ existingDetail.remove();
591
+ }
592
+ }
593
+
594
+ function buildOrUpdateJobDetailRow(job, existingDetail) {
595
+ let detailRow = existingDetail;
596
+ if (!detailRow) {
597
+ detailRow = document.createElement('tr');
598
+ detailRow.className = 'detail-row';
599
+ }
600
+
601
+ detailRow.dataset.jobId = getJobId(job);
602
+ detailRow.innerHTML = buildJobDetailMarkup(job);
603
+ return detailRow;
604
+ }
605
+
606
+ function upsertJobDetailRow(row, job, parent) {
607
+ const detailRow = buildOrUpdateJobDetailRow(job, getAdjacentJobDetailRow(row));
608
+
609
+ if (parent) {
610
+ parent.appendChild(detailRow);
611
+ } else {
612
+ row.parentNode.insertBefore(detailRow, row.nextSibling);
613
+ }
614
+
615
+ return detailRow;
616
+ }
617
+ `;
618
+ const getRenderJobsEmptyStateFunction = () => `
619
+
620
+ function ensureEmptyJobsState(tbody) {
621
+ if (
622
+ tbody.children.length === 1 &&
623
+ tbody.children[0].textContent.includes('No recent jobs found')
624
+ ) {
625
+ return;
626
+ }
627
+
628
+ tbody.innerHTML = '<tr><td colspan="7" style="text-align:center; color: var(--muted)">No recent jobs found</td></tr>';
629
+ }
630
+ `;
631
+ const getRenderJobsRowHelpersFunction = () => `
632
+ function createJobRow(job, idx) {
633
+ const tr = document.createElement('tr');
634
+ tr.className = 'expandable-row';
635
+ tr.addEventListener('click', (e) => {
636
+ if (e.target.classList.contains('retry-btn')) return;
637
+ toggleJobDetails(tr, tr.__jobData);
638
+ });
639
+ updateExistingJobRow(tr, job, idx);
640
+ return tr;
641
+ }
642
+
643
+ function updateExistingJobRow(tr, job, idx) {
644
+ const jobId = getJobId(job);
645
+ const statusInfo = getJobStatusInfo(job);
646
+ const isExpanded = expandedJobIds.has(jobId);
647
+
648
+ tr.__jobData = job;
649
+ tr.dataset.jobId = jobId;
650
+ tr.dataset.jobIndex = idx;
651
+ tr.innerHTML =
652
+ '<td><span class="expand-icon' + (isExpanded ? ' expanded' : '') + '">▶</span><code>' + job.id + '</code></td>' +
653
+ '<td>' + job.name + '</td>' +
654
+ '<td>' + (job.queue || currentQueue) + '</td>' +
655
+ '<td><span class="status-badge ' + statusInfo.cls + '">' + statusInfo.label + '</span></td>' +
656
+ '<td>' + job.attempts + '</td>' +
657
+ '<td>' + new Date(job.timestamp).toLocaleTimeString() + '</td>' +
658
+ '<td>' + getJobRetryMarkup(job) + '</td>';
659
+
660
+ if (job.failedReason) {
661
+ tr.title = 'Click to see error details';
662
+ } else {
663
+ tr.removeAttribute('title');
664
+ }
665
+ }
666
+
667
+ function getExistingJobRows(tbody) {
668
+ const rows = new Map();
669
+ Array.from(tbody.querySelectorAll('tr.expandable-row')).forEach(row => {
670
+ const jobId = row.dataset.jobId || row.querySelector('code')?.textContent;
671
+ if (jobId) rows.set(jobId, row);
450
672
  });
673
+ return rows;
674
+ }
451
675
 
452
- tbody.innerHTML = '';
676
+ function getExistingJobDetailRows(tbody) {
677
+ const rows = new Map();
678
+ Array.from(tbody.querySelectorAll('tr.detail-row')).forEach(row => {
679
+ const jobId = row.dataset.jobId;
680
+ if (jobId) rows.set(jobId, row);
681
+ });
682
+ return rows;
683
+ }
453
684
 
454
- if (!jobs || jobs.length === 0) {
455
- tbody.innerHTML = '<tr><td colspan="7" style="text-align:center; color: var(--muted)">No recent jobs found</td></tr>';
685
+ function removeObsoleteJobRows(tbody, currentJobIds) {
686
+ Array.from(tbody.querySelectorAll('tr.expandable-row')).forEach(row => {
687
+ const jobId = row.dataset.jobId || row.querySelector('code')?.textContent;
688
+ if (!jobId || currentJobIds.has(jobId)) return;
689
+ removeJobDetailRow(row);
690
+ row.remove();
691
+ expandedJobIds.delete(jobId);
692
+ });
693
+ }
694
+ `;
695
+ const getRenderJobsFunction = () => `
696
+ function renderJobs(jobs) {
697
+ const tbody = document.querySelector('#jobs-table tbody');
698
+ const jobList = Array.isArray(jobs) ? jobs : [];
699
+
700
+ if (jobList.length === 0) {
701
+ ensureEmptyJobsState(tbody);
702
+ expandedJobIds.clear();
456
703
  return;
457
704
  }
458
705
 
459
- jobs.forEach((job, idx) => {
460
- const tr = document.createElement('tr');
461
- tr.className = 'expandable-row';
462
- tr.dataset.jobIndex = idx;
463
- const status = (job.status || (job.failedReason ? 'failed' : 'completed')).toLowerCase();
464
- const statusMap = {
465
- failed: { label: 'Failed', cls: 'status-failed' },
466
- completed: { label: 'Completed', cls: 'status-completed' },
467
- active: { label: 'Active', cls: 'status-active' },
468
- waiting: { label: 'Waiting', cls: 'status-waiting' },
469
- delayed: { label: 'Delayed', cls: 'status-delayed' },
470
- paused: { label: 'Paused', cls: 'status-paused' }
471
- };
472
- const statusInfo = statusMap[status] || statusMap.completed;
473
-
474
- const retryBtn = status === 'failed'
475
- ? '<button class="retry-btn" onclick="retryJob(' + "'" + job.id + "'" + ', ' + "'" + (job.queue || currentQueue) + "'" + ')" title="Retry this job">↻ Retry</button>'
476
- : '<span style="color: var(--muted); font-size: 11px;">—</span>';
477
-
478
- // Check if this job was previously expanded
479
- const isExpanded = expandedJobIds.has(job.id);
480
-
481
- tr.innerHTML =
482
- '<td><span class="expand-icon' + (isExpanded ? ' expanded' : '') + '">▶</span><code>' + job.id + '</code></td>' +
483
- '<td>' + job.name + '</td>' +
484
- '<td>' + (job.queue || currentQueue) + '</td>' +
485
- '<td><span class="status-badge ' +
486
- statusInfo.cls +
487
- '">' +
488
- statusInfo.label +
489
- '</span></td>' +
490
- '<td>' + job.attempts + '</td>' +
491
- '<td>' + new Date(job.timestamp).toLocaleTimeString() + '</td>' +
492
- '<td>' + retryBtn + '</td>';
493
- if (job.failedReason) {
494
- tr.title = 'Click to see error details';
495
- }
706
+ const currentJobIds = new Set(jobList.map(job => getJobId(job)));
707
+ const existingRows = getExistingJobRows(tbody);
708
+ const existingDetailRows = getExistingJobDetailRows(tbody);
709
+ const fragment = document.createDocumentFragment();
496
710
 
497
- tr.addEventListener('click', (e) => {
498
- if (e.target.classList.contains('retry-btn')) return;
499
- toggleJobDetails(tr, job);
500
- });
711
+ jobList.forEach((job, idx) => {
712
+ const jobId = getJobId(job);
713
+ const existingRow = existingRows.get(jobId);
714
+ const row = existingRow || createJobRow(job, idx);
501
715
 
502
- tbody.appendChild(tr);
716
+ if (existingRow) {
717
+ updateExistingJobRow(row, job, idx);
718
+ }
503
719
 
504
- // Auto-expand if this job was previously expanded
505
- if (isExpanded) {
506
- setTimeout(() => {
507
- toggleJobDetails(tr, job);
508
- }, 10); // Small delay to ensure DOM is ready
720
+ fragment.appendChild(row);
721
+ if (expandedJobIds.has(jobId)) {
722
+ fragment.appendChild(buildOrUpdateJobDetailRow(job, existingDetailRows.get(jobId)));
509
723
  }
510
724
  });
511
725
 
512
- // Clean up expanded job IDs that are no longer in the current jobs list
513
- const currentJobIds = new Set(jobs.map(job => job.id));
514
726
  expandedJobIds = new Set([...expandedJobIds].filter(id => currentJobIds.has(id)));
727
+ tbody.replaceChildren(fragment);
515
728
  }`;
516
729
  const getRenderLocksFunction = () => `
517
730
  // Track expanded lock keys to preserve state during SSE updates
@@ -698,45 +911,20 @@ const getToggleDetailsFunctions = () => `
698
911
  function toggleJobDetails(row, job) {
699
912
  const expandIcon = row.querySelector('.expand-icon');
700
913
  const existingDetail = row.nextElementSibling;
914
+ const jobId = getJobId(job);
701
915
 
702
916
  if (existingDetail && existingDetail.classList.contains('detail-row')) {
703
917
  expandIcon.classList.remove('expanded');
704
918
  existingDetail.remove();
705
919
  // Remove from expanded set
706
- expandedJobIds.delete(job.id);
920
+ expandedJobIds.delete(jobId);
707
921
  return;
708
922
  }
709
923
 
710
924
  expandIcon.classList.add('expanded');
711
925
  // Add to expanded set
712
- expandedJobIds.add(job.id);
713
-
714
- const detailRow = document.createElement('tr');
715
- detailRow.className = 'detail-row';
716
-
717
- const jobData = {
718
- id: job.id,
719
- name: job.name,
720
- queue: job.queue || currentQueue,
721
- status: job.status || (job.failedReason ? 'failed' : 'completed'),
722
- attempts: job.attempts,
723
- timestamp: new Date(job.timestamp).toISOString(),
724
- data: job.data || {},
725
- failedReason: job.failedReason || null,
726
- processedOn: job.processedOn ? new Date(job.processedOn).toISOString() : null,
727
- finishedOn: job.finishedOn ? new Date(job.finishedOn).toISOString() : null,
728
- returnvalue: job.returnvalue
729
- };
730
-
731
- detailRow.innerHTML =
732
- '<td colspan="7" class="detail-cell">' +
733
- '<div class="detail-content">' +
734
- '<strong style="color: var(--accent); display: block; margin-bottom: 8px;">Job Details:</strong>' +
735
- '<pre>' + JSON.stringify(jobData, null, 2) + '</pre>' +
736
- '</div>' +
737
- '</td>';
738
-
739
- row.parentNode.insertBefore(detailRow, row.nextSibling);
926
+ expandedJobIds.add(jobId);
927
+ upsertJobDetailRow(row, job);
740
928
  }
741
929
 
742
930
  function toggleLockDetails(row, lock) {
@@ -1030,7 +1218,13 @@ const getDashboardScriptFetch = () => `
1030
1218
  `;
1031
1219
  const getDashboardScriptRender = () => [
1032
1220
  getRenderStatsFunction(),
1221
+ getUpdateQueueSelectHelpersFunction(),
1033
1222
  getUpdateQueueSelectFunction(),
1223
+ getRenderJobsStateFunction(),
1224
+ getRenderJobsIdentityHelpersFunction(),
1225
+ getRenderJobsDetailRowHelpersFunction(),
1226
+ getRenderJobsEmptyStateFunction(),
1227
+ getRenderJobsRowHelpersFunction(),
1034
1228
  getRenderJobsFunction(),
1035
1229
  getRenderLocksFunction(),
1036
1230
  getLockHelperFunctions(),
package/dist/index.d.ts CHANGED
@@ -3,6 +3,7 @@ import { type RedisConfig } from './connection';
3
3
  import { type QueueDriver } from './driver';
4
4
  import { type Metrics } from './metrics';
5
5
  export type { JobPayload } from './driver';
6
+ export { createMetrics, type JobStatus, type JobSummary, type Metrics } from './metrics';
6
7
  export { createWorker as createQueueWorker, type QueueWorker } from './worker';
7
8
  export type QueueMonitorConfig = {
8
9
  enabled?: boolean;
package/dist/index.js CHANGED
@@ -1,9 +1,10 @@
1
- import { isNonEmptyString, Logger, queueConfig, resolveLockPrefix, Router, } from '@zintrust/core';
1
+ import { isArray, isNonEmptyString, Logger, queueConfig, resolveLockPrefix, Router, } from '@zintrust/core';
2
2
  import { createRedisConnection } from './connection';
3
3
  import { getDashboardHtml } from './dashboard-ui';
4
4
  import { createBullMQDriver } from './driver';
5
5
  import { createMetrics } from './metrics';
6
6
  import { getRecentJobsForSelection, QueueMonitoringStream } from './QueueMonitoringService';
7
+ export { createMetrics } from './metrics';
7
8
  export { createWorker as createQueueWorker } from './worker';
8
9
  const DEFAULTS = {
9
10
  enabled: true,
@@ -46,7 +47,7 @@ async function resolveKnownQueues(knownQueues) {
46
47
  if (typeof knownQueues === 'function') {
47
48
  return normalizeQueueNames(await knownQueues());
48
49
  }
49
- if (Array.isArray(knownQueues)) {
50
+ if (isArray(knownQueues)) {
50
51
  return normalizeQueueNames(knownQueues);
51
52
  }
52
53
  return [];
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@zintrust/queue-monitor",
3
- "version": "0.4.50",
3
+ "version": "0.4.63",
4
4
  "description": "Queue monitoring package for ZinTrust with BullMQ and Redis.",
5
5
  "private": false,
6
6
  "type": "module",
@@ -20,7 +20,7 @@
20
20
  "node": ">=20.0.0"
21
21
  },
22
22
  "peerDependencies": {
23
- "@zintrust/core": "^0.4.50"
23
+ "@zintrust/core": "^0.4.63"
24
24
  },
25
25
  "publishConfig": {
26
26
  "access": "public"
@@ -40,4 +40,4 @@
40
40
  "bullmq": "^5.71.1",
41
41
  "ioredis": "^5.10.1"
42
42
  }
43
- }
43
+ }