@zintrust/queue-monitor 0.4.49 → 0.4.60

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.
@@ -239,7 +239,9 @@ const getDashboardBody = () => `
239
239
  const getDashboardScriptState = (options) => String.raw `
240
240
  const AUTO_REFRESH = ${options.autoRefresh ? 'true' : 'false'};
241
241
  const REFRESH_INTERVAL = ${Math.max(1000, Math.floor(options.refreshIntervalMs || 0))};
242
+ const STREAM_RESET_MS = Math.max(15000, REFRESH_INTERVAL * 4);
242
243
  const API_BASE = ${JSON.stringify(options.basePath)};
244
+ const ALL_QUEUES = '__all__';
243
245
  const THEME_KEY = 'zintrust-queue-monitor-theme';
244
246
  const AUTO_REFRESH_KEY = 'zintrust-queue-monitor-auto-refresh';
245
247
  const QUEUE_KEY = 'zintrust-queue-monitor-selected-queue';
@@ -250,6 +252,10 @@ const getDashboardScriptState = (options) => String.raw `
250
252
  let sseActive = false;
251
253
  let lastSseQueue = null;
252
254
  let lastSsePattern = null;
255
+ let reconnectTimer = null;
256
+ let streamWatchdogTimer = null;
257
+ let dashboardResetTimer = null;
258
+ let reconnectAttempts = 0;
253
259
  let currentTheme = null;
254
260
  `;
255
261
  const getDashboardScriptTheme = () => `
@@ -308,6 +314,8 @@ const getDashboardScriptAutoRefresh = () => `
308
314
  eventSource = null;
309
315
  }
310
316
  sseActive = false;
317
+ clearSseTimers();
318
+ clearError();
311
319
  }
312
320
 
313
321
  if (autoRefreshEnabled && !sseActive) {
@@ -329,8 +337,6 @@ const getDashboardScriptAutoRefresh = () => `
329
337
  const getRenderStatsFunction = () => `
330
338
  function renderStats(data) {
331
339
  const grid = document.getElementById('stats-grid');
332
- grid.innerHTML = '';
333
-
334
340
  const totalActive = data.queues.reduce((acc, q) => acc + q.counts.active, 0);
335
341
  const totalFailed = data.queues.reduce((acc, q) => acc + q.counts.failed, 0);
336
342
  const totalDelayed = data.queues.reduce((acc, q) => acc + q.counts.delayed, 0);
@@ -366,121 +372,299 @@ const getRenderStatsFunction = () => `
366
372
  }
367
373
  ];
368
374
 
369
- cards.forEach(card => {
370
- const div = document.createElement('div');
371
- div.className = 'tile';
372
- const infoIcon = '<span class="info-icon" data-info="' + card.info + '">i</span>';
373
- div.innerHTML =
374
- '<div class="stat-header">' +
375
- '<div class="stat-label">' + card.label + '</div>' +
376
- infoIcon +
377
- '</div>' +
378
- '<div class="stat-value" style="' + (card.color ? 'color:' + card.color : '') + '">' +
379
- card.value +
380
- '</div>';
381
- grid.appendChild(div);
382
- });
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
+ }
383
410
 
384
- document.querySelectorAll('.info-icon').forEach(icon => {
385
- icon.addEventListener('mouseenter', showTooltip);
386
- 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);
387
417
  });
418
+
419
+ while (grid.children.length > cards.length) {
420
+ grid.removeChild(grid.lastElementChild);
421
+ }
388
422
  }`;
389
423
  const getUpdateQueueSelectFunction = () => `
390
424
  function updateQueueSelect(queues) {
391
425
  const select = document.getElementById('queue-select');
392
- const currentSelection = select.value || currentQueue;
393
- select.innerHTML = '';
426
+ const storedQueue = localStorage.getItem(QUEUE_KEY);
427
+ const preferredQueue = currentQueue || select.value || storedQueue || '';
428
+
429
+ if (queues.length === 0) {
430
+ select.disabled = true;
431
+ if (select.options.length !== 1 || select.options[0].value !== '' || select.options[0].textContent !== 'No queues') {
432
+ select.innerHTML = '<option value="">No queues</option>';
433
+ }
434
+ return '';
435
+ }
436
+
437
+ const queueNames = queues.map(q => q.name);
438
+ const totalWaiting = queues.reduce((acc, queue) => acc + queue.counts.waiting, 0);
439
+ const totalFailed = queues.reduce((acc, queue) => acc + queue.counts.failed, 0);
440
+
441
+ const nextQueue = preferredQueue === ALL_QUEUES || queueNames.includes(preferredQueue)
442
+ ? preferredQueue
443
+ : queueNames[0];
444
+ const desiredOptions = [
445
+ {
446
+ value: ALL_QUEUES,
447
+ text: 'All queues (' + totalWaiting + ' waiting, ' + totalFailed + ' failed)'
448
+ },
449
+ ...queues.map(q => ({
450
+ value: q.name,
451
+ text: q.name + ' (' + q.counts.waiting + ' waiting, ' + q.counts.failed + ' failed)'
452
+ }))
453
+ ];
454
+
455
+ const existingOptions = new Map();
456
+ Array.from(select.options).forEach(option => {
457
+ existingOptions.set(option.value, option);
458
+ });
459
+
460
+ select.disabled = false;
461
+
462
+ desiredOptions.forEach((item, index) => {
463
+ let option = existingOptions.get(item.value);
464
+ if (!option) {
465
+ option = document.createElement('option');
466
+ option.value = item.value;
467
+ }
394
468
 
395
- if (queues.length === 0) return
469
+ option.textContent = item.text;
470
+ option.selected = item.value === nextQueue;
396
471
 
397
- queues.forEach(q => {
398
- const opt = document.createElement('option');
399
- opt.value = q.name;
400
- opt.textContent = q.name + ' (' + q.counts.waiting + ' waiting, ' + q.counts.failed + ' failed)';
401
- opt.selected = q.name === currentSelection;
402
- select.appendChild(opt);
472
+ const currentAtIndex = select.options[index];
473
+ if (currentAtIndex !== option) {
474
+ select.appendChild(option);
475
+ }
403
476
  });
477
+
478
+ Array.from(select.options).forEach(option => {
479
+ if (!desiredOptions.some(item => item.value === option.value)) {
480
+ option.remove();
481
+ }
482
+ });
483
+
484
+ select.value = nextQueue;
485
+ return nextQueue;
404
486
  }`;
405
- const getRenderJobsFunction = () => `
487
+ const getRenderJobsStateFunction = () => `
406
488
  // Track expanded job IDs to preserve state during SSE updates
407
489
  let expandedJobIds = new Set();
490
+ `;
491
+ const getRenderJobsDetailHelpersFunction = () => `
492
+ function getJobId(job) {
493
+ return String(job.id);
494
+ }
408
495
 
409
- function renderJobs(jobs) {
410
- const tbody = document.querySelector('#jobs-table tbody');
496
+ function getJobStatusInfo(job) {
497
+ const status = (job.status || (job.failedReason ? 'failed' : 'completed')).toLowerCase();
498
+ const statusMap = {
499
+ failed: { label: 'Failed', cls: 'status-failed' },
500
+ completed: { label: 'Completed', cls: 'status-completed' },
501
+ active: { label: 'Active', cls: 'status-active' },
502
+ waiting: { label: 'Waiting', cls: 'status-waiting' },
503
+ delayed: { label: 'Delayed', cls: 'status-delayed' },
504
+ paused: { label: 'Paused', cls: 'status-paused' }
505
+ };
411
506
 
412
- // Store currently expanded job IDs before clearing
413
- const currentExpanded = document.querySelectorAll('.expand-icon.expanded');
414
- currentExpanded.forEach(icon => {
415
- const row = icon.closest('tr');
416
- if (row) {
417
- const jobId = row.querySelector('code')?.textContent;
418
- if (jobId) expandedJobIds.add(jobId);
419
- }
507
+ return statusMap[status] || statusMap.completed;
508
+ }
509
+
510
+ function getJobRetryMarkup(job) {
511
+ const status = (job.status || (job.failedReason ? 'failed' : 'completed')).toLowerCase();
512
+ if (status === 'failed') {
513
+ return '<button class="retry-btn" onclick="retryJob(' + "'" + job.id + "'" + ', ' + "'" + (job.queue || currentQueue) + "'" + ')" title="Retry this job">↻ Retry</button>';
514
+ }
515
+ return '<span style="color: var(--muted); font-size: 11px;">—</span>';
516
+ }
517
+
518
+ function buildJobDetailMarkup(job) {
519
+ const jobData = {
520
+ id: job.id,
521
+ name: job.name,
522
+ queue: job.queue || currentQueue,
523
+ status: job.status || (job.failedReason ? 'failed' : 'completed'),
524
+ attempts: job.attempts,
525
+ timestamp: new Date(job.timestamp).toISOString(),
526
+ data: job.data || {},
527
+ failedReason: job.failedReason || null,
528
+ processedOn: job.processedOn ? new Date(job.processedOn).toISOString() : null,
529
+ finishedOn: job.finishedOn ? new Date(job.finishedOn).toISOString() : null,
530
+ returnvalue: job.returnvalue
531
+ };
532
+
533
+ return '<td colspan="7" class="detail-cell">' +
534
+ '<div class="detail-content">' +
535
+ '<strong style="color: var(--accent); display: block; margin-bottom: 8px;">Job Details:</strong>' +
536
+ '<pre>' + JSON.stringify(jobData, null, 2) + '</pre>' +
537
+ '</div>' +
538
+ '</td>';
539
+ }
540
+
541
+ function removeJobDetailRow(row) {
542
+ const existingDetail = row.nextElementSibling;
543
+ if (existingDetail && existingDetail.classList.contains('detail-row')) {
544
+ existingDetail.remove();
545
+ }
546
+ }
547
+
548
+ function upsertJobDetailRow(row, job, parent) {
549
+ const existingDetail = row.nextElementSibling && row.nextElementSibling.classList.contains('detail-row')
550
+ ? row.nextElementSibling
551
+ : null;
552
+
553
+ let detailRow = existingDetail;
554
+ if (!detailRow) {
555
+ detailRow = document.createElement('tr');
556
+ detailRow.className = 'detail-row';
557
+ }
558
+
559
+ detailRow.dataset.jobId = getJobId(job);
560
+ detailRow.innerHTML = buildJobDetailMarkup(job);
561
+
562
+ if (parent) {
563
+ parent.appendChild(detailRow);
564
+ } else {
565
+ row.parentNode.insertBefore(detailRow, row.nextSibling);
566
+ }
567
+ }
568
+
569
+ function ensureEmptyJobsState(tbody) {
570
+ if (
571
+ tbody.children.length === 1 &&
572
+ tbody.children[0].textContent.includes('No recent jobs found')
573
+ ) {
574
+ return;
575
+ }
576
+
577
+ tbody.innerHTML = '<tr><td colspan="7" style="text-align:center; color: var(--muted)">No recent jobs found</td></tr>';
578
+ }
579
+ `;
580
+ const getRenderJobsRowHelpersFunction = () => `
581
+ function createJobRow(job, idx) {
582
+ const tr = document.createElement('tr');
583
+ tr.className = 'expandable-row';
584
+ tr.addEventListener('click', (e) => {
585
+ if (e.target.classList.contains('retry-btn')) return;
586
+ toggleJobDetails(tr, tr.__jobData);
420
587
  });
588
+ updateExistingJobRow(tr, job, idx);
589
+ return tr;
590
+ }
421
591
 
422
- tbody.innerHTML = '';
592
+ function updateExistingJobRow(tr, job, idx) {
593
+ const jobId = getJobId(job);
594
+ const statusInfo = getJobStatusInfo(job);
595
+ const isExpanded = expandedJobIds.has(jobId);
596
+
597
+ tr.__jobData = job;
598
+ tr.dataset.jobId = jobId;
599
+ tr.dataset.jobIndex = idx;
600
+ tr.innerHTML =
601
+ '<td><span class="expand-icon' + (isExpanded ? ' expanded' : '') + '">▶</span><code>' + job.id + '</code></td>' +
602
+ '<td>' + job.name + '</td>' +
603
+ '<td>' + (job.queue || currentQueue) + '</td>' +
604
+ '<td><span class="status-badge ' + statusInfo.cls + '">' + statusInfo.label + '</span></td>' +
605
+ '<td>' + job.attempts + '</td>' +
606
+ '<td>' + new Date(job.timestamp).toLocaleTimeString() + '</td>' +
607
+ '<td>' + getJobRetryMarkup(job) + '</td>';
608
+
609
+ if (job.failedReason) {
610
+ tr.title = 'Click to see error details';
611
+ } else {
612
+ tr.removeAttribute('title');
613
+ }
614
+ }
423
615
 
424
- if (!jobs || jobs.length === 0) {
425
- tbody.innerHTML = '<tr><td colspan="7" style="text-align:center; color: var(--muted)">No recent jobs found</td></tr>';
616
+ function getExistingJobRows(tbody) {
617
+ const rows = new Map();
618
+ Array.from(tbody.querySelectorAll('tr.expandable-row')).forEach(row => {
619
+ const jobId = row.dataset.jobId || row.querySelector('code')?.textContent;
620
+ if (jobId) rows.set(jobId, row);
621
+ });
622
+ return rows;
623
+ }
624
+
625
+ function removeObsoleteJobRows(tbody, currentJobIds) {
626
+ Array.from(tbody.querySelectorAll('tr.expandable-row')).forEach(row => {
627
+ const jobId = row.dataset.jobId || row.querySelector('code')?.textContent;
628
+ if (!jobId || currentJobIds.has(jobId)) return;
629
+ removeJobDetailRow(row);
630
+ row.remove();
631
+ expandedJobIds.delete(jobId);
632
+ });
633
+ }
634
+ `;
635
+ const getRenderJobsFunction = () => `
636
+ function renderJobs(jobs) {
637
+ const tbody = document.querySelector('#jobs-table tbody');
638
+ const jobList = Array.isArray(jobs) ? jobs : [];
639
+
640
+ if (jobList.length === 0) {
641
+ ensureEmptyJobsState(tbody);
642
+ expandedJobIds.clear();
426
643
  return;
427
644
  }
428
645
 
429
- jobs.forEach((job, idx) => {
430
- const tr = document.createElement('tr');
431
- tr.className = 'expandable-row';
432
- tr.dataset.jobIndex = idx;
433
- const status = (job.status || (job.failedReason ? 'failed' : 'completed')).toLowerCase();
434
- const statusMap = {
435
- failed: { label: 'Failed', cls: 'status-failed' },
436
- completed: { label: 'Completed', cls: 'status-completed' },
437
- active: { label: 'Active', cls: 'status-active' },
438
- waiting: { label: 'Waiting', cls: 'status-waiting' },
439
- delayed: { label: 'Delayed', cls: 'status-delayed' },
440
- paused: { label: 'Paused', cls: 'status-paused' }
441
- };
442
- const statusInfo = statusMap[status] || statusMap.completed;
443
-
444
- const retryBtn = status === 'failed'
445
- ? '<button class="retry-btn" onclick="retryJob(' + "'" + job.id + "'" + ', ' + "'" + (job.queue || currentQueue) + "'" + ')" title="Retry this job">↻ Retry</button>'
446
- : '<span style="color: var(--muted); font-size: 11px;">—</span>';
447
-
448
- // Check if this job was previously expanded
449
- const isExpanded = expandedJobIds.has(job.id);
450
-
451
- tr.innerHTML =
452
- '<td><span class="expand-icon' + (isExpanded ? ' expanded' : '') + '">▶</span><code>' + job.id + '</code></td>' +
453
- '<td>' + job.name + '</td>' +
454
- '<td>' + (job.queue || currentQueue) + '</td>' +
455
- '<td><span class="status-badge ' +
456
- statusInfo.cls +
457
- '">' +
458
- statusInfo.label +
459
- '</span></td>' +
460
- '<td>' + job.attempts + '</td>' +
461
- '<td>' + new Date(job.timestamp).toLocaleTimeString() + '</td>' +
462
- '<td>' + retryBtn + '</td>';
463
- if (job.failedReason) {
464
- tr.title = 'Click to see error details';
465
- }
646
+ const currentJobIds = new Set(jobList.map(job => getJobId(job)));
647
+ removeObsoleteJobRows(tbody, currentJobIds);
466
648
 
467
- tr.addEventListener('click', (e) => {
468
- if (e.target.classList.contains('retry-btn')) return;
469
- toggleJobDetails(tr, job);
470
- });
649
+ const existingRows = getExistingJobRows(tbody);
471
650
 
472
- tbody.appendChild(tr);
651
+ jobList.forEach((job, idx) => {
652
+ const jobId = getJobId(job);
653
+ const existingRow = existingRows.get(jobId);
654
+ const row = existingRow || createJobRow(job, idx);
473
655
 
474
- // Auto-expand if this job was previously expanded
475
- if (isExpanded) {
476
- setTimeout(() => {
477
- toggleJobDetails(tr, job);
478
- }, 10); // Small delay to ensure DOM is ready
656
+ if (existingRow) {
657
+ updateExistingJobRow(row, job, idx);
658
+ }
659
+
660
+ tbody.appendChild(row);
661
+ if (expandedJobIds.has(jobId)) {
662
+ upsertJobDetailRow(row, job, tbody);
663
+ } else {
664
+ removeJobDetailRow(row);
479
665
  }
480
666
  });
481
667
 
482
- // Clean up expanded job IDs that are no longer in the current jobs list
483
- const currentJobIds = new Set(jobs.map(job => job.id));
484
668
  expandedJobIds = new Set([...expandedJobIds].filter(id => currentJobIds.has(id)));
485
669
  }`;
486
670
  const getRenderLocksFunction = () => `
@@ -634,6 +818,12 @@ const getErrorAndTooltipFunctions = () => `
634
818
  el.style.display = 'block';
635
819
  }
636
820
 
821
+ function clearError() {
822
+ const el = document.getElementById('error-container');
823
+ el.textContent = '';
824
+ el.style.display = 'none';
825
+ }
826
+
637
827
  let tooltipEl = null;
638
828
  function showTooltip(e) {
639
829
  const info = e.target.getAttribute('data-info');
@@ -662,45 +852,20 @@ const getToggleDetailsFunctions = () => `
662
852
  function toggleJobDetails(row, job) {
663
853
  const expandIcon = row.querySelector('.expand-icon');
664
854
  const existingDetail = row.nextElementSibling;
855
+ const jobId = getJobId(job);
665
856
 
666
857
  if (existingDetail && existingDetail.classList.contains('detail-row')) {
667
858
  expandIcon.classList.remove('expanded');
668
859
  existingDetail.remove();
669
860
  // Remove from expanded set
670
- expandedJobIds.delete(job.id);
861
+ expandedJobIds.delete(jobId);
671
862
  return;
672
863
  }
673
864
 
674
865
  expandIcon.classList.add('expanded');
675
866
  // Add to expanded set
676
- expandedJobIds.add(job.id);
677
-
678
- const detailRow = document.createElement('tr');
679
- detailRow.className = 'detail-row';
680
-
681
- const jobData = {
682
- id: job.id,
683
- name: job.name,
684
- queue: currentQueue,
685
- status: job.status || (job.failedReason ? 'failed' : 'completed'),
686
- attempts: job.attempts,
687
- timestamp: new Date(job.timestamp).toISOString(),
688
- data: job.data || {},
689
- failedReason: job.failedReason || null,
690
- processedOn: job.processedOn ? new Date(job.processedOn).toISOString() : null,
691
- finishedOn: job.finishedOn ? new Date(job.finishedOn).toISOString() : null,
692
- returnvalue: job.returnvalue
693
- };
694
-
695
- detailRow.innerHTML =
696
- '<td colspan="7" class="detail-cell">' +
697
- '<div class="detail-content">' +
698
- '<strong style="color: var(--accent); display: block; margin-bottom: 8px;">Job Details:</strong>' +
699
- '<pre>' + JSON.stringify(jobData, null, 2) + '</pre>' +
700
- '</div>' +
701
- '</td>';
702
-
703
- row.parentNode.insertBefore(detailRow, row.nextSibling);
867
+ expandedJobIds.add(jobId);
868
+ upsertJobDetailRow(row, job);
704
869
  }
705
870
 
706
871
  function toggleLockDetails(row, lock) {
@@ -776,6 +941,77 @@ const getDashboardScriptHelpers = () => `
776
941
  return patternInput && patternInput.value ? patternInput.value : '*';
777
942
  }
778
943
 
944
+ function clearSseTimers() {
945
+ if (reconnectTimer !== null) {
946
+ clearTimeout(reconnectTimer);
947
+ reconnectTimer = null;
948
+ }
949
+
950
+ if (streamWatchdogTimer !== null) {
951
+ clearTimeout(streamWatchdogTimer);
952
+ streamWatchdogTimer = null;
953
+ }
954
+
955
+ if (dashboardResetTimer !== null) {
956
+ clearTimeout(dashboardResetTimer);
957
+ dashboardResetTimer = null;
958
+ }
959
+ }
960
+
961
+ function scheduleDashboardReset(message) {
962
+ if (dashboardResetTimer !== null) return;
963
+
964
+ showError(message || 'Live updates stalled. Resetting dashboard...');
965
+ dashboardResetTimer = window.setTimeout(() => {
966
+ window.location.reload();
967
+ }, STREAM_RESET_MS);
968
+ }
969
+
970
+ function armStreamWatchdog() {
971
+ if (!autoRefreshEnabled) return;
972
+
973
+ if (streamWatchdogTimer !== null) {
974
+ clearTimeout(streamWatchdogTimer);
975
+ }
976
+
977
+ streamWatchdogTimer = window.setTimeout(() => {
978
+ if (eventSource) {
979
+ eventSource.close();
980
+ eventSource = null;
981
+ }
982
+ sseActive = false;
983
+ streamWatchdogTimer = null;
984
+ scheduleDashboardReset('Live updates stalled. Resetting dashboard...');
985
+ }, STREAM_RESET_MS);
986
+ }
987
+
988
+ function markStreamHealthy() {
989
+ reconnectAttempts = 0;
990
+ if (reconnectTimer !== null) {
991
+ clearTimeout(reconnectTimer);
992
+ reconnectTimer = null;
993
+ }
994
+ clearError();
995
+ armStreamWatchdog();
996
+ }
997
+
998
+ function scheduleReconnect() {
999
+ if (!autoRefreshEnabled || reconnectTimer !== null) return;
1000
+
1001
+ reconnectAttempts += 1;
1002
+ const delay = Math.min(1000 * reconnectAttempts, 5000);
1003
+ showError('Live updates disconnected. Reconnecting...');
1004
+
1005
+ reconnectTimer = window.setTimeout(() => {
1006
+ reconnectTimer = null;
1007
+ setupEventStream(currentQueue);
1008
+ }, delay);
1009
+
1010
+ if (reconnectAttempts >= 4) {
1011
+ scheduleDashboardReset('Live updates could not reconnect. Resetting dashboard...');
1012
+ }
1013
+ }
1014
+
779
1015
  function buildEventsUrl(queue, pattern) {
780
1016
  const q = queue || '';
781
1017
  const p = pattern || '*';
@@ -785,11 +1021,12 @@ const getDashboardScriptHelpers = () => `
785
1021
  const getDashboardScriptEventStream = () => `
786
1022
  function setupEventStream(queueOverride) {
787
1023
  if (!window.EventSource) return;
1024
+ if (!autoRefreshEnabled) return;
788
1025
 
789
- const queue = queueOverride || currentQueue;
1026
+ const queue = queueOverride === undefined ? currentQueue : queueOverride;
790
1027
  const pattern = getLockPattern();
791
1028
 
792
- if (eventSource && queue === lastSseQueue && pattern === lastSsePattern) return;
1029
+ if (eventSource && sseActive && queue === lastSseQueue && pattern === lastSsePattern) return;
793
1030
 
794
1031
  if (eventSource) {
795
1032
  eventSource.close();
@@ -799,10 +1036,12 @@ const getDashboardScriptEventStream = () => `
799
1036
  lastSseQueue = queue;
800
1037
  lastSsePattern = pattern;
801
1038
  eventSource = new EventSource(buildEventsUrl(queue, pattern));
1039
+ armStreamWatchdog();
802
1040
 
803
1041
  eventSource.onopen = () => {
804
1042
  sseActive = true;
805
1043
  stopAutoRefresh();
1044
+ markStreamHealthy();
806
1045
  };
807
1046
 
808
1047
  eventSource.onmessage = (evt) => {
@@ -813,20 +1052,31 @@ const getDashboardScriptEventStream = () => `
813
1052
  if (payload.type === 'snapshot') {
814
1053
  if (payload.snapshot) {
815
1054
  renderStats(payload.snapshot);
816
- updateQueueSelect(payload.snapshot.queues || []);
1055
+ const nextQueue = updateQueueSelect(payload.snapshot.queues || []);
1056
+ if (nextQueue !== currentQueue) {
1057
+ currentQueue = nextQueue;
1058
+ if (currentQueue) {
1059
+ localStorage.setItem(QUEUE_KEY, currentQueue);
1060
+ } else {
1061
+ localStorage.removeItem(QUEUE_KEY);
1062
+ }
1063
+ }
817
1064
  }
818
1065
 
819
- if (payload.queue && payload.queue !== currentQueue) {
820
- currentQueue = payload.queue;
821
- localStorage.setItem(QUEUE_KEY, currentQueue);
822
- const select = document.getElementById('queue-select');
823
- if (select) select.value = currentQueue;
1066
+ if (!currentQueue) {
1067
+ renderJobs([]);
824
1068
  }
825
1069
 
826
- if (payload.jobs) renderJobs(payload.jobs);
1070
+ if (payload.jobs && (!payload.queue || payload.queue === currentQueue)) {
1071
+ renderJobs(payload.jobs);
1072
+ }
827
1073
  if (payload.locks) renderLocks(payload.locks);
828
1074
 
829
- document.getElementById('last-updated').textContent = new Date().toLocaleTimeString();
1075
+ const lastUpdated = document.getElementById('last-updated');
1076
+ if (lastUpdated) {
1077
+ lastUpdated.textContent = new Date().toLocaleTimeString();
1078
+ }
1079
+ markStreamHealthy();
830
1080
  }
831
1081
  } catch (err) {
832
1082
  console.error('Failed to parse SSE payload', err);
@@ -839,7 +1089,7 @@ const getDashboardScriptEventStream = () => `
839
1089
  eventSource = null;
840
1090
  }
841
1091
  sseActive = false;
842
- if (autoRefreshEnabled) startAutoRefresh();
1092
+ scheduleReconnect();
843
1093
  };
844
1094
  }
845
1095
  `;
@@ -910,6 +1160,9 @@ const getDashboardScriptFetch = () => `
910
1160
  const getDashboardScriptRender = () => [
911
1161
  getRenderStatsFunction(),
912
1162
  getUpdateQueueSelectFunction(),
1163
+ getRenderJobsStateFunction(),
1164
+ getRenderJobsDetailHelpersFunction(),
1165
+ getRenderJobsRowHelpersFunction(),
913
1166
  getRenderJobsFunction(),
914
1167
  getRenderLocksFunction(),
915
1168
  getLockHelperFunctions(),
@@ -932,8 +1185,13 @@ const getDashboardScriptBootstrap = () => `
932
1185
  if (queueSelect) {
933
1186
  queueSelect.addEventListener('change', (e) => {
934
1187
  currentQueue = e.target.value;
935
- localStorage.setItem(QUEUE_KEY, currentQueue);
1188
+ if (currentQueue) {
1189
+ localStorage.setItem(QUEUE_KEY, currentQueue);
1190
+ } else {
1191
+ localStorage.removeItem(QUEUE_KEY);
1192
+ }
936
1193
  console.log('Queue changed - SSE will update automatically');
1194
+ clearError();
937
1195
 
938
1196
  setupEventStream(currentQueue);
939
1197
  });
@@ -961,6 +1219,7 @@ const getDashboardScriptBootstrap = () => `
961
1219
  setupEventStream(currentQueue);
962
1220
 
963
1221
  window.addEventListener('beforeunload', () => {
1222
+ clearSseTimers();
964
1223
  if (eventSource) {
965
1224
  eventSource.close();
966
1225
  }