@zintrust/queue-monitor 0.4.48 → 0.4.50

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) {
@@ -389,18 +397,40 @@ const getRenderStatsFunction = () => `
389
397
  const getUpdateQueueSelectFunction = () => `
390
398
  function updateQueueSelect(queues) {
391
399
  const select = document.getElementById('queue-select');
392
- const currentSelection = select.value || currentQueue;
400
+ const storedQueue = localStorage.getItem(QUEUE_KEY);
401
+ const preferredQueue = currentQueue || select.value || storedQueue || '';
393
402
  select.innerHTML = '';
394
403
 
395
- if (queues.length === 0) return
404
+ if (queues.length === 0) {
405
+ select.disabled = true;
406
+ select.innerHTML = '<option value="">No queues</option>';
407
+ return '';
408
+ }
409
+
410
+ const queueNames = queues.map(q => q.name);
411
+ const totalWaiting = queues.reduce((acc, queue) => acc + queue.counts.waiting, 0);
412
+ 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)';
416
+
417
+ const nextQueue = preferredQueue === ALL_QUEUES || queueNames.includes(preferredQueue)
418
+ ? preferredQueue
419
+ : queueNames[0];
420
+ select.disabled = false;
421
+ allOption.selected = nextQueue === ALL_QUEUES;
422
+ select.appendChild(allOption);
396
423
 
397
424
  queues.forEach(q => {
398
425
  const opt = document.createElement('option');
399
426
  opt.value = q.name;
400
427
  opt.textContent = q.name + ' (' + q.counts.waiting + ' waiting, ' + q.counts.failed + ' failed)';
401
- opt.selected = q.name === currentSelection;
428
+ opt.selected = q.name === nextQueue;
402
429
  select.appendChild(opt);
403
430
  });
431
+
432
+ select.value = nextQueue;
433
+ return nextQueue;
404
434
  }`;
405
435
  const getRenderJobsFunction = () => `
406
436
  // Track expanded job IDs to preserve state during SSE updates
@@ -634,6 +664,12 @@ const getErrorAndTooltipFunctions = () => `
634
664
  el.style.display = 'block';
635
665
  }
636
666
 
667
+ function clearError() {
668
+ const el = document.getElementById('error-container');
669
+ el.textContent = '';
670
+ el.style.display = 'none';
671
+ }
672
+
637
673
  let tooltipEl = null;
638
674
  function showTooltip(e) {
639
675
  const info = e.target.getAttribute('data-info');
@@ -681,7 +717,7 @@ const getToggleDetailsFunctions = () => `
681
717
  const jobData = {
682
718
  id: job.id,
683
719
  name: job.name,
684
- queue: currentQueue,
720
+ queue: job.queue || currentQueue,
685
721
  status: job.status || (job.failedReason ? 'failed' : 'completed'),
686
722
  attempts: job.attempts,
687
723
  timestamp: new Date(job.timestamp).toISOString(),
@@ -776,6 +812,77 @@ const getDashboardScriptHelpers = () => `
776
812
  return patternInput && patternInput.value ? patternInput.value : '*';
777
813
  }
778
814
 
815
+ function clearSseTimers() {
816
+ if (reconnectTimer !== null) {
817
+ clearTimeout(reconnectTimer);
818
+ reconnectTimer = null;
819
+ }
820
+
821
+ if (streamWatchdogTimer !== null) {
822
+ clearTimeout(streamWatchdogTimer);
823
+ streamWatchdogTimer = null;
824
+ }
825
+
826
+ if (dashboardResetTimer !== null) {
827
+ clearTimeout(dashboardResetTimer);
828
+ dashboardResetTimer = null;
829
+ }
830
+ }
831
+
832
+ function scheduleDashboardReset(message) {
833
+ if (dashboardResetTimer !== null) return;
834
+
835
+ showError(message || 'Live updates stalled. Resetting dashboard...');
836
+ dashboardResetTimer = window.setTimeout(() => {
837
+ window.location.reload();
838
+ }, STREAM_RESET_MS);
839
+ }
840
+
841
+ function armStreamWatchdog() {
842
+ if (!autoRefreshEnabled) return;
843
+
844
+ if (streamWatchdogTimer !== null) {
845
+ clearTimeout(streamWatchdogTimer);
846
+ }
847
+
848
+ streamWatchdogTimer = window.setTimeout(() => {
849
+ if (eventSource) {
850
+ eventSource.close();
851
+ eventSource = null;
852
+ }
853
+ sseActive = false;
854
+ streamWatchdogTimer = null;
855
+ scheduleDashboardReset('Live updates stalled. Resetting dashboard...');
856
+ }, STREAM_RESET_MS);
857
+ }
858
+
859
+ function markStreamHealthy() {
860
+ reconnectAttempts = 0;
861
+ if (reconnectTimer !== null) {
862
+ clearTimeout(reconnectTimer);
863
+ reconnectTimer = null;
864
+ }
865
+ clearError();
866
+ armStreamWatchdog();
867
+ }
868
+
869
+ function scheduleReconnect() {
870
+ if (!autoRefreshEnabled || reconnectTimer !== null) return;
871
+
872
+ reconnectAttempts += 1;
873
+ const delay = Math.min(1000 * reconnectAttempts, 5000);
874
+ showError('Live updates disconnected. Reconnecting...');
875
+
876
+ reconnectTimer = window.setTimeout(() => {
877
+ reconnectTimer = null;
878
+ setupEventStream(currentQueue);
879
+ }, delay);
880
+
881
+ if (reconnectAttempts >= 4) {
882
+ scheduleDashboardReset('Live updates could not reconnect. Resetting dashboard...');
883
+ }
884
+ }
885
+
779
886
  function buildEventsUrl(queue, pattern) {
780
887
  const q = queue || '';
781
888
  const p = pattern || '*';
@@ -785,11 +892,12 @@ const getDashboardScriptHelpers = () => `
785
892
  const getDashboardScriptEventStream = () => `
786
893
  function setupEventStream(queueOverride) {
787
894
  if (!window.EventSource) return;
895
+ if (!autoRefreshEnabled) return;
788
896
 
789
- const queue = queueOverride || currentQueue;
897
+ const queue = queueOverride === undefined ? currentQueue : queueOverride;
790
898
  const pattern = getLockPattern();
791
899
 
792
- if (eventSource && queue === lastSseQueue && pattern === lastSsePattern) return;
900
+ if (eventSource && sseActive && queue === lastSseQueue && pattern === lastSsePattern) return;
793
901
 
794
902
  if (eventSource) {
795
903
  eventSource.close();
@@ -799,10 +907,12 @@ const getDashboardScriptEventStream = () => `
799
907
  lastSseQueue = queue;
800
908
  lastSsePattern = pattern;
801
909
  eventSource = new EventSource(buildEventsUrl(queue, pattern));
910
+ armStreamWatchdog();
802
911
 
803
912
  eventSource.onopen = () => {
804
913
  sseActive = true;
805
914
  stopAutoRefresh();
915
+ markStreamHealthy();
806
916
  };
807
917
 
808
918
  eventSource.onmessage = (evt) => {
@@ -813,20 +923,31 @@ const getDashboardScriptEventStream = () => `
813
923
  if (payload.type === 'snapshot') {
814
924
  if (payload.snapshot) {
815
925
  renderStats(payload.snapshot);
816
- updateQueueSelect(payload.snapshot.queues || []);
926
+ const nextQueue = updateQueueSelect(payload.snapshot.queues || []);
927
+ if (nextQueue !== currentQueue) {
928
+ currentQueue = nextQueue;
929
+ if (currentQueue) {
930
+ localStorage.setItem(QUEUE_KEY, currentQueue);
931
+ } else {
932
+ localStorage.removeItem(QUEUE_KEY);
933
+ }
934
+ }
817
935
  }
818
936
 
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;
937
+ if (!currentQueue) {
938
+ renderJobs([]);
824
939
  }
825
940
 
826
- if (payload.jobs) renderJobs(payload.jobs);
941
+ if (payload.jobs && (!payload.queue || payload.queue === currentQueue)) {
942
+ renderJobs(payload.jobs);
943
+ }
827
944
  if (payload.locks) renderLocks(payload.locks);
828
945
 
829
- document.getElementById('last-updated').textContent = new Date().toLocaleTimeString();
946
+ const lastUpdated = document.getElementById('last-updated');
947
+ if (lastUpdated) {
948
+ lastUpdated.textContent = new Date().toLocaleTimeString();
949
+ }
950
+ markStreamHealthy();
830
951
  }
831
952
  } catch (err) {
832
953
  console.error('Failed to parse SSE payload', err);
@@ -839,7 +960,7 @@ const getDashboardScriptEventStream = () => `
839
960
  eventSource = null;
840
961
  }
841
962
  sseActive = false;
842
- if (autoRefreshEnabled) startAutoRefresh();
963
+ scheduleReconnect();
843
964
  };
844
965
  }
845
966
  `;
@@ -932,8 +1053,13 @@ const getDashboardScriptBootstrap = () => `
932
1053
  if (queueSelect) {
933
1054
  queueSelect.addEventListener('change', (e) => {
934
1055
  currentQueue = e.target.value;
935
- localStorage.setItem(QUEUE_KEY, currentQueue);
1056
+ if (currentQueue) {
1057
+ localStorage.setItem(QUEUE_KEY, currentQueue);
1058
+ } else {
1059
+ localStorage.removeItem(QUEUE_KEY);
1060
+ }
936
1061
  console.log('Queue changed - SSE will update automatically');
1062
+ clearError();
937
1063
 
938
1064
  setupEventStream(currentQueue);
939
1065
  });
@@ -961,6 +1087,7 @@ const getDashboardScriptBootstrap = () => `
961
1087
  setupEventStream(currentQueue);
962
1088
 
963
1089
  window.addEventListener('beforeunload', () => {
1090
+ clearSseTimers();
964
1091
  if (eventSource) {
965
1092
  eventSource.close();
966
1093
  }
package/dist/index.d.ts CHANGED
@@ -11,6 +11,7 @@ export type QueueMonitorConfig = {
11
11
  autoRefresh?: boolean;
12
12
  refreshIntervalMs?: number;
13
13
  redis?: RedisConfig;
14
+ knownQueues?: ReadonlyArray<string> | (() => Promise<ReadonlyArray<string>> | ReadonlyArray<string>);
14
15
  };
15
16
  export type QueueCounts = {
16
17
  waiting: number;
package/dist/index.js CHANGED
@@ -1,9 +1,9 @@
1
- import { Logger, queueConfig, resolveLockPrefix, Router, } from '@zintrust/core';
1
+ import { 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
- import { getRecentJobsForQueue, QueueMonitoringStream } from './QueueMonitoringService';
6
+ import { getRecentJobsForSelection, QueueMonitoringStream } from './QueueMonitoringService';
7
7
  export { createWorker as createQueueWorker } from './worker';
8
8
  const DEFAULTS = {
9
9
  enabled: true,
@@ -34,6 +34,23 @@ const HISTOGRAM_BUCKETS = [
34
34
  { label: '>60m', min: 3_600_000 },
35
35
  ];
36
36
  const MAX_LOCK_KEYS = 10_000;
37
+ function normalizeQueueNames(queueNames) {
38
+ return Array.from(new Set(queueNames
39
+ .map((queueName) => queueName)
40
+ .filter(isNonEmptyString)
41
+ .map((name) => name.trim())))
42
+ .filter((name) => name.length > 0)
43
+ .sort((left, right) => left.localeCompare(right));
44
+ }
45
+ async function resolveKnownQueues(knownQueues) {
46
+ if (typeof knownQueues === 'function') {
47
+ return normalizeQueueNames(await knownQueues());
48
+ }
49
+ if (Array.isArray(knownQueues)) {
50
+ return normalizeQueueNames(knownQueues);
51
+ }
52
+ return [];
53
+ }
37
54
  // Helper function to scan lock keys with pagination
38
55
  const scanLockKeys = async (client, searchPattern, maxKeys) => {
39
56
  const keys = [];
@@ -148,7 +165,7 @@ async function handleJobsEndpoint(req, res, metrics, driver) {
148
165
  res.status(400).json(fieldError('queue_name', 'Queue name must be provided'));
149
166
  return;
150
167
  }
151
- const jobs = await getRecentJobsForQueue(queueName, metrics, driver);
168
+ const jobs = await getRecentJobsForSelection(queueName, metrics, driver);
152
169
  res.json(jobs);
153
170
  }
154
171
  async function handleRetryEndpoint(req, res, driver) {
@@ -177,9 +194,13 @@ function buildSettings(config) {
177
194
  : DEFAULTS.refreshIntervalMs,
178
195
  };
179
196
  }
180
- function createGetSnapshot(driver, startedAt) {
197
+ function createGetSnapshot(driver, startedAt, knownQueues) {
181
198
  return async () => {
182
- const queues = await driver.getQueues();
199
+ const [discoveredQueues, persistedQueues] = await Promise.all([
200
+ driver.getQueues(),
201
+ resolveKnownQueues(knownQueues),
202
+ ]);
203
+ const queues = Array.from(new Set([...persistedQueues, ...discoveredQueues])).sort((left, right) => left.localeCompare(right));
183
204
  const stats = await Promise.all(queues.map(async (name) => {
184
205
  const counts = await driver.getJobCounts(name);
185
206
  return { name, counts: counts };
@@ -270,7 +291,7 @@ export const QueueMonitor = Object.freeze({
270
291
  const driver = createBullMQDriver(redisConfig);
271
292
  const metrics = createMetrics(redisConfig);
272
293
  const startedAt = new Date().toISOString();
273
- const getSnapshot = createGetSnapshot(driver, startedAt);
294
+ const getSnapshot = createGetSnapshot(driver, startedAt, config.knownQueues);
274
295
  const getLocks = createGetLocks(redisConfig);
275
296
  const registerRoutes = createRegisterRoutes(settings, metrics, driver, getSnapshot, getLocks);
276
297
  const close = async () => {
package/dist/metrics.d.ts CHANGED
@@ -4,6 +4,7 @@ export type JobStatus = 'completed' | 'failed';
4
4
  export type JobSummary = {
5
5
  id: string | undefined;
6
6
  name: string;
7
+ queue?: string;
7
8
  data: unknown;
8
9
  attempts: number;
9
10
  status?: string;
@@ -0,0 +1,10 @@
1
+ import { type IRouter } from '@zintrust/core';
2
+ import { type WorkerUiOptions } from '../workers-ui';
3
+ type RouteOptions = {
4
+ middleware?: ReadonlyArray<string>;
5
+ } | undefined;
6
+ export declare const registerWorkerUiRoutes: (router: IRouter, options: WorkerUiOptions, routeOptions: RouteOptions) => void;
7
+ declare const _default: Readonly<{
8
+ registerWorkerUiRoutes: (router: IRouter, options: WorkerUiOptions, routeOptions: RouteOptions) => void;
9
+ }>;
10
+ export default _default;
@@ -0,0 +1,20 @@
1
+ import { Router } from '@zintrust/core';
2
+ import { WorkerConfig } from '../config/workerConfig.js';
3
+ import { getWorkersHtml } from '../workers-ui.js';
4
+ const registerWorkerUiPage = (router, options, routeOptions) => {
5
+ const handler = (_req, res) => {
6
+ res.html(getWorkersHtml({
7
+ basePath: options.basePath,
8
+ apiBaseUrl: WorkerConfig.getWorkerBaseUrl(),
9
+ autoRefresh: options.autoRefresh,
10
+ refreshIntervalMs: options.refreshIntervalMs,
11
+ }));
12
+ };
13
+ Router.get(router, `${options.basePath}/workers`, handler, routeOptions);
14
+ Router.get(router, '/workers', handler, routeOptions);
15
+ Router.get(router, '/workers/', handler, routeOptions);
16
+ };
17
+ export const registerWorkerUiRoutes = (router, options, routeOptions) => {
18
+ registerWorkerUiPage(router, options, routeOptions);
19
+ };
20
+ export default Object.freeze({ registerWorkerUiRoutes });
@@ -0,0 +1,7 @@
1
+ export type WorkerUiOptions = {
2
+ basePath: string;
3
+ apiBaseUrl?: string;
4
+ autoRefresh: boolean;
5
+ refreshIntervalMs: number;
6
+ };
7
+ export declare const getWorkersHtml: (options: WorkerUiOptions) => string;