sidekiq_queue_manager 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,1836 @@
1
+ /**
2
+ * Sidekiq Queue Manager - shadcn-Inspired Professional Interface
3
+ *
4
+ * A comprehensive JavaScript application for managing Sidekiq queues
5
+ * Features: Real-time updates, live pull, queue operations, custom modals, theme switching
6
+ *
7
+ * Version: 2.0.0 (Redesigned)
8
+ * Design Philosophy: Compact minimalism with perfect dark/light mode
9
+ */
10
+
11
+ class SidekiqQueueManagerUI {
12
+ static instance = null;
13
+
14
+ // ========================================
15
+ // Constants
16
+ // ========================================
17
+ static CONSTANTS = {
18
+ REFRESH_INTERVALS: {
19
+ OFF: 0,
20
+ FIVE_SECONDS: 5000
21
+ },
22
+ DEFAULT_REFRESH_INTERVAL: 5000,
23
+ MAX_RETRIES: 3,
24
+ RETRY_DELAY: 1000,
25
+ ANIMATION_DELAYS: {
26
+ FOCUS: 150,
27
+ LAYOUT_RECALC: 50
28
+ },
29
+ API_ENDPOINTS: {
30
+ metrics: '/metrics',
31
+ pauseAll: '/queues/pause_all',
32
+ resumeAll: '/queues/resume_all',
33
+ queueAction: (queueName, action) => `/queues/${queueName}/${action}`,
34
+ summary: '/queues/summary'
35
+ },
36
+ THEMES: {
37
+ LIGHT: 'light',
38
+ DARK: 'dark',
39
+ AUTO: 'auto'
40
+ }
41
+ };
42
+
43
+ constructor(config = {}) {
44
+ if (SidekiqQueueManagerUI.instance) {
45
+ SidekiqQueueManagerUI.instance.destroy();
46
+ }
47
+ SidekiqQueueManagerUI.instance = this;
48
+
49
+ // Gem configuration (passed from Rails)
50
+ this.gemConfig = {
51
+ mountPath: config.mountPath || '',
52
+ refreshInterval: config.refreshInterval || SidekiqQueueManagerUI.CONSTANTS.DEFAULT_REFRESH_INTERVAL,
53
+ theme: config.theme || 'auto',
54
+ criticalQueues: config.criticalQueues || [],
55
+ ...config
56
+ };
57
+
58
+ this.state = {
59
+ isRefreshing: false,
60
+ refreshInterval: null,
61
+ currentRefreshTime: this.gemConfig.refreshInterval,
62
+ retryCount: 0,
63
+ lastUpdate: null,
64
+ livePullEnabled: false,
65
+ currentTheme: this.getStoredTheme() || this.gemConfig.theme
66
+ };
67
+
68
+ this.currentMenuCloseHandler = null;
69
+ this.currentActionsMenu = null;
70
+ this.elements = new Map();
71
+ this.eventHandlers = new Map();
72
+
73
+ this.init();
74
+ }
75
+
76
+ // ========================================
77
+ // Initialization
78
+ // ========================================
79
+
80
+ init() {
81
+ this.initializeTheme();
82
+ this.cacheElements();
83
+ this.setupEventListeners();
84
+ this.initializeRefreshControl();
85
+ this.loadInitialData();
86
+ this.injectActionsMenuStyles();
87
+ }
88
+
89
+ // ========================================
90
+ // Theme Management
91
+ // ========================================
92
+
93
+ initializeTheme() {
94
+ // Apply stored theme or detect system preference
95
+ this.applyTheme(this.state.currentTheme);
96
+
97
+ // Listen for system theme changes
98
+ if (window.matchMedia) {
99
+ const mediaQuery = window.matchMedia('(prefers-color-scheme: dark)');
100
+ mediaQuery.addEventListener('change', () => {
101
+ if (this.state.currentTheme === SidekiqQueueManagerUI.CONSTANTS.THEMES.AUTO) {
102
+ this.applyTheme(SidekiqQueueManagerUI.CONSTANTS.THEMES.AUTO);
103
+ }
104
+ });
105
+ }
106
+ }
107
+
108
+ getStoredTheme() {
109
+ try {
110
+ return localStorage.getItem('sqm-theme');
111
+ } catch (e) {
112
+ return null;
113
+ }
114
+ }
115
+
116
+ setStoredTheme(theme) {
117
+ try {
118
+ localStorage.setItem('sqm-theme', theme);
119
+ } catch (e) {
120
+ // Ignore storage errors
121
+ }
122
+ }
123
+
124
+ getSystemTheme() {
125
+ if (window.matchMedia && window.matchMedia('(prefers-color-scheme: dark)').matches) {
126
+ return SidekiqQueueManagerUI.CONSTANTS.THEMES.DARK;
127
+ }
128
+ return SidekiqQueueManagerUI.CONSTANTS.THEMES.LIGHT;
129
+ }
130
+
131
+ applyTheme(theme) {
132
+ const root = document.documentElement;
133
+
134
+ // Remove existing theme attributes
135
+ root.removeAttribute('data-theme');
136
+
137
+ if (theme === SidekiqQueueManagerUI.CONSTANTS.THEMES.AUTO) {
138
+ // Let CSS handle auto theme detection
139
+ this.state.currentTheme = theme;
140
+ } else {
141
+ // Explicitly set theme
142
+ root.setAttribute('data-theme', theme);
143
+ this.state.currentTheme = theme;
144
+ }
145
+
146
+ // Update theme toggle button
147
+ this.updateThemeToggleButton();
148
+
149
+ // Store theme preference
150
+ this.setStoredTheme(theme);
151
+ }
152
+
153
+ toggleTheme() {
154
+ const currentEffectiveTheme = this.getCurrentEffectiveTheme();
155
+
156
+ if (currentEffectiveTheme === SidekiqQueueManagerUI.CONSTANTS.THEMES.LIGHT) {
157
+ this.applyTheme(SidekiqQueueManagerUI.CONSTANTS.THEMES.DARK);
158
+ } else {
159
+ this.applyTheme(SidekiqQueueManagerUI.CONSTANTS.THEMES.LIGHT);
160
+ }
161
+
162
+ // Announce theme change for accessibility
163
+ this.announceToScreenReader(`Switched to ${this.getCurrentEffectiveTheme()} mode`);
164
+ }
165
+
166
+ getCurrentEffectiveTheme() {
167
+ if (this.state.currentTheme === SidekiqQueueManagerUI.CONSTANTS.THEMES.AUTO) {
168
+ return this.getSystemTheme();
169
+ }
170
+ return this.state.currentTheme;
171
+ }
172
+
173
+ updateThemeToggleButton() {
174
+ const themeToggle = this.elements.get('themeToggle');
175
+ const lightIcon = themeToggle?.querySelector('.sqm-theme-icon-light');
176
+ const darkIcon = themeToggle?.querySelector('.sqm-theme-icon-dark');
177
+
178
+ if (!themeToggle || !lightIcon || !darkIcon) return;
179
+
180
+ const currentEffectiveTheme = this.getCurrentEffectiveTheme();
181
+
182
+ if (currentEffectiveTheme === SidekiqQueueManagerUI.CONSTANTS.THEMES.DARK) {
183
+ lightIcon.classList.add('sqm-hidden');
184
+ darkIcon.classList.remove('sqm-hidden');
185
+ themeToggle.setAttribute('title', 'Switch to light mode');
186
+ } else {
187
+ lightIcon.classList.remove('sqm-hidden');
188
+ darkIcon.classList.add('sqm-hidden');
189
+ themeToggle.setAttribute('title', 'Switch to dark mode');
190
+ }
191
+ }
192
+
193
+ cacheElements() {
194
+ const selectors = {
195
+ // Main UI elements
196
+ refreshBtn: '#sqm-refresh-btn',
197
+ refreshContainer: '.sqm-refresh-container',
198
+ liveToggleBtn: '#sqm-live-toggle-btn',
199
+ livePullContainer: '.sqm-live-pull-container',
200
+ statusText: '.sqm-status-text',
201
+ themeToggle: '#sqm-theme-toggle',
202
+
203
+ // Action buttons
204
+ pauseAllBtn: '#sqm-pause-all-btn',
205
+ resumeAllBtn: '#sqm-resume-all-btn',
206
+
207
+ // UI state elements
208
+ loading: '#sqm-loading',
209
+ error: '#sqm-error',
210
+ content: '#sqm-content',
211
+ errorMessage: '#sqm-error-message',
212
+
213
+ // Statistics displays
214
+ processed: '#sqm-processed',
215
+ failed: '#sqm-failed',
216
+ busy: '#sqm-busy',
217
+ enqueued: '#sqm-enqueued',
218
+ totalQueues: '#sqm-total-queues',
219
+ pausedQueues: '#sqm-paused-queues',
220
+ totalJobs: '#sqm-total-jobs',
221
+
222
+ // Table elements
223
+ tableBody: '#sqm-table-body',
224
+ table: '#sqm-queues-table',
225
+
226
+ // Status elements
227
+ refreshStatus: '#sqm-refresh-status',
228
+ statusDot: '.sqm-status-dot',
229
+
230
+ // Accessibility
231
+ announcements: '#sqm-announcements'
232
+ };
233
+
234
+ // Cache all elements
235
+ Object.entries(selectors).forEach(([key, selector]) => {
236
+ this.elements.set(key, document.querySelector(selector));
237
+ });
238
+ }
239
+
240
+ setupEventListeners() {
241
+ // Manual refresh
242
+ this.bindEvent('refreshBtn', 'click', () => this.manualRefresh());
243
+
244
+ // Live pull toggle
245
+ this.bindEvent('liveToggleBtn', 'click', () => this.toggleLivePull());
246
+
247
+ // Theme toggle
248
+ this.bindEvent('themeToggle', 'click', () => this.toggleTheme());
249
+
250
+ // Bulk operations
251
+ this.bindEvent('pauseAllBtn', 'click', () => this.pauseAllQueues());
252
+ this.bindEvent('resumeAllBtn', 'click', () => this.resumeAllQueues());
253
+
254
+ // Cleanup on page unload
255
+ window.addEventListener('beforeunload', () => this.destroy());
256
+ }
257
+
258
+ bindEvent(elementKey, event, handler) {
259
+ const element = this.elements.get(elementKey);
260
+ if (element) {
261
+ element.addEventListener(event, handler);
262
+ this.eventHandlers.set(`${elementKey}_${event}`, { element, event, handler });
263
+ }
264
+ }
265
+
266
+ initializeRefreshControl() {
267
+ const liveToggle = this.elements.get('liveToggleBtn');
268
+ const statusText = this.elements.get('statusText');
269
+
270
+ if (liveToggle) {
271
+ liveToggle.setAttribute('data-enabled', 'false');
272
+ }
273
+
274
+ if (statusText) {
275
+ statusText.textContent = 'OFF';
276
+ }
277
+
278
+ this.updateLivePullUI();
279
+ this.updateThemeToggleButton();
280
+ }
281
+
282
+ injectActionsMenuStyles() {
283
+ if (document.getElementById('sqm-actions-menu-styles')) return;
284
+
285
+ const styles = document.createElement('style');
286
+ styles.id = 'sqm-actions-menu-styles';
287
+ styles.textContent = `
288
+ .sqm-actions-menu {
289
+ position: absolute;
290
+ background: var(--sqm-popover);
291
+ border: 1px solid var(--sqm-border);
292
+ border-radius: var(--sqm-radius);
293
+ box-shadow: var(--sqm-shadow-lg);
294
+ z-index: var(--sqm-z-dropdown);
295
+ min-width: 12rem;
296
+ animation: sqm-dropdown-in 0.1s ease-out;
297
+ }
298
+
299
+ .sqm-actions-menu-content {
300
+ padding: 0.25rem;
301
+ }
302
+
303
+ .sqm-actions-menu-header {
304
+ display: flex;
305
+ align-items: center;
306
+ justify-content: space-between;
307
+ padding: 0.5rem 0.75rem;
308
+ border-bottom: 1px solid var(--sqm-border);
309
+ background: var(--sqm-muted);
310
+ margin: -0.25rem -0.25rem 0.25rem;
311
+ border-radius: var(--sqm-radius) var(--sqm-radius) 0 0;
312
+ }
313
+
314
+ .sqm-actions-queue-name {
315
+ font-weight: 600;
316
+ color: var(--sqm-foreground);
317
+ font-family: var(--sqm-font-mono);
318
+ font-size: 0.75rem;
319
+ }
320
+
321
+ .sqm-actions-menu-close {
322
+ background: none;
323
+ border: none;
324
+ cursor: pointer;
325
+ color: var(--sqm-muted-foreground);
326
+ font-size: 1rem;
327
+ padding: 0.25rem;
328
+ border-radius: var(--sqm-radius-sm);
329
+ transition: var(--sqm-transition);
330
+ outline: none;
331
+ }
332
+
333
+ .sqm-actions-menu-close:hover {
334
+ background: var(--sqm-accent);
335
+ color: var(--sqm-accent-foreground);
336
+ }
337
+
338
+ .sqm-actions-menu-close:focus-visible {
339
+ outline: 2px solid var(--sqm-ring);
340
+ outline-offset: 2px;
341
+ }
342
+
343
+ .sqm-actions-menu-body {
344
+ display: flex;
345
+ flex-direction: column;
346
+ gap: 0.125rem;
347
+ }
348
+
349
+ .sqm-actions-menu-item {
350
+ display: flex;
351
+ align-items: center;
352
+ gap: 0.5rem;
353
+ padding: 0.5rem 0.75rem;
354
+ background: none;
355
+ border: none;
356
+ border-radius: var(--sqm-radius-sm);
357
+ cursor: pointer;
358
+ transition: var(--sqm-transition);
359
+ font-size: 0.75rem;
360
+ font-weight: 500;
361
+ color: var(--sqm-foreground);
362
+ text-align: left;
363
+ width: 100%;
364
+ outline: none;
365
+ }
366
+
367
+ .sqm-actions-menu-item:hover {
368
+ background: var(--sqm-accent);
369
+ }
370
+
371
+ .sqm-actions-menu-item:focus-visible {
372
+ outline: 2px solid var(--sqm-ring);
373
+ outline-offset: 2px;
374
+ }
375
+
376
+ .sqm-actions-danger:hover {
377
+ background: hsl(from var(--sqm-destructive) h s l / 0.1);
378
+ color: var(--sqm-destructive);
379
+ }
380
+
381
+ @keyframes sqm-dropdown-in {
382
+ from {
383
+ opacity: 0;
384
+ transform: translateY(-0.25rem);
385
+ }
386
+ to {
387
+ opacity: 1;
388
+ transform: translateY(0);
389
+ }
390
+ }
391
+ `;
392
+
393
+ document.head.appendChild(styles);
394
+ }
395
+
396
+ // ========================================
397
+ // Accessibility Helpers
398
+ // ========================================
399
+
400
+ announceToScreenReader(message) {
401
+ const announcements = this.elements.get('announcements');
402
+ if (announcements) {
403
+ announcements.textContent = message;
404
+ // Clear after announcement to allow for future announcements
405
+ setTimeout(() => {
406
+ announcements.textContent = '';
407
+ }, 1000);
408
+ }
409
+ }
410
+
411
+ // ========================================
412
+ // API Communication
413
+ // ========================================
414
+
415
+ async apiCall(endpoint, options = {}) {
416
+ const url = `${this.gemConfig.mountPath}${endpoint}`;
417
+ const config = {
418
+ method: 'GET',
419
+ headers: {
420
+ 'Accept': 'application/json',
421
+ 'Content-Type': 'application/json',
422
+ 'X-Requested-With': 'XMLHttpRequest'
423
+ },
424
+ ...options
425
+ };
426
+
427
+ try {
428
+ const response = await fetch(url, config);
429
+
430
+ if (!response.ok) {
431
+ throw new Error(`HTTP ${response.status}: ${response.statusText}`);
432
+ }
433
+
434
+ return await response.json();
435
+ } catch (error) {
436
+ console.error('API Error:', error);
437
+ throw error;
438
+ }
439
+ }
440
+
441
+ async refreshQueues() {
442
+ if (this.state.isRefreshing) {
443
+ console.log('Refresh already in progress, skipping');
444
+ return;
445
+ }
446
+
447
+ this.state.isRefreshing = true;
448
+ this.showLoading();
449
+
450
+ try {
451
+ const response = await this.apiCall(SidekiqQueueManagerUI.CONSTANTS.API_ENDPOINTS.metrics);
452
+
453
+ if (response.success !== false) {
454
+ this.updateUI(response.data || response);
455
+ this.state.retryCount = 0;
456
+ this.state.lastUpdate = new Date();
457
+ this.showContent();
458
+ } else {
459
+ throw new Error(response.message || 'Failed to fetch metrics');
460
+ }
461
+ } catch (error) {
462
+ console.error('Failed to refresh queues:', error);
463
+ this.handleError(error);
464
+ } finally {
465
+ this.state.isRefreshing = false;
466
+ }
467
+ }
468
+
469
+ async loadInitialData() {
470
+ // Use initial data if available, otherwise fetch
471
+ if (window.SidekiqQueueManagerInitialData) {
472
+ this.updateUI(window.SidekiqQueueManagerInitialData);
473
+ this.showContent();
474
+ // Clear the initial data
475
+ delete window.SidekiqQueueManagerInitialData;
476
+ } else {
477
+ await this.refreshQueues();
478
+ }
479
+ }
480
+
481
+ // ========================================
482
+ // UI Updates
483
+ // ========================================
484
+
485
+ updateUI(data) {
486
+ if (data.global_stats) {
487
+ this.updateGlobalStats(data.global_stats);
488
+ }
489
+
490
+ if (data.queues) {
491
+ this.updateQueuesTable(data.queues);
492
+ }
493
+
494
+ this.updateTimestamp(data.timestamp);
495
+ }
496
+
497
+ updateGlobalStats(stats) {
498
+ const elements = {
499
+ processed: stats.processed?.toLocaleString() || '0',
500
+ failed: stats.failed?.toLocaleString() || '0',
501
+ busy: stats.busy?.toString() || '0',
502
+ enqueued: stats.enqueued?.toLocaleString() || '0'
503
+ };
504
+
505
+ Object.entries(elements).forEach(([key, value]) => {
506
+ const element = this.elements.get(key);
507
+ if (element) {
508
+ element.textContent = value;
509
+ }
510
+ });
511
+ }
512
+
513
+ updateQueuesTable(queues) {
514
+ const tbody = this.elements.get('tableBody');
515
+ if (!tbody) return;
516
+
517
+ const queueArray = Object.values(queues);
518
+ const totalQueues = queueArray.length;
519
+ const pausedQueues = queueArray.filter(q => q.paused).length;
520
+
521
+ // Update counters
522
+ this.updateElement('totalQueues', totalQueues.toString());
523
+ this.updateElement('pausedQueues', pausedQueues.toString());
524
+ this.updateElement('totalJobs', queueArray.reduce((sum, q) => sum + (q.size || 0), 0).toLocaleString());
525
+
526
+ // Update table rows
527
+ tbody.innerHTML = queueArray.map(queue => this.renderQueueRow(queue)).join('');
528
+
529
+ // Re-attach event listeners for action buttons
530
+ this.attachQueueActionListeners();
531
+ }
532
+
533
+ renderQueueRow(queue) {
534
+ const statusClass = queue.paused ? 'sqm-status-paused' :
535
+ queue.blocked ? 'sqm-status-blocked' : 'sqm-status-active';
536
+ const priorityIcon = this.getPriorityIcon(queue.priority);
537
+ const criticalBadge = queue.critical ? '<span class="sqm-critical-badge">CRITICAL</span>' : '';
538
+ const blockedBadge = queue.blocked ? '<span class="sqm-blocked-badge">BLOCKED</span>' : '';
539
+ const limitInfo = this.renderLimitInfo(queue);
540
+
541
+ return `
542
+ <tr class="sqm-queue-row ${statusClass}" data-queue="${queue.name}">
543
+ <td class="sqm-col-name">
544
+ ${priorityIcon}
545
+ <span class="sqm-queue-name">${queue.name}</span>
546
+ ${criticalBadge}
547
+ ${blockedBadge}
548
+ ${limitInfo}
549
+ </td>
550
+ <td class="sqm-col-size">${queue.size?.toLocaleString() || '0'}</td>
551
+ <td class="sqm-col-workers">${this.renderWorkerLimits(queue)}</td>
552
+ <td class="sqm-col-latency">${this.formatLatency(queue.latency)}</td>
553
+ <td class="sqm-col-actions">
554
+ ${this.renderQueueActions(queue)}
555
+ </td>
556
+ </tr>
557
+ `;
558
+ }
559
+
560
+ renderWorkerLimits(queue) {
561
+ // Format exactly like the existing implementation
562
+ const busy = queue.busy || 0;
563
+ const limit = queue.limit;
564
+ const processLimit = queue.process_limit;
565
+ const isBlocked = queue.blocked;
566
+ const isPaused = queue.paused;
567
+
568
+ let html = `<div class="sqm-worker-info">`;
569
+ html += `<div class="sqm-worker-busy">${busy} busy`;
570
+
571
+ if (limit && limit > 0) {
572
+ html += ` / ${limit} max`;
573
+ } else {
574
+ html += ' / ∞';
575
+ }
576
+
577
+ // Add status indicators
578
+ if (isBlocked) {
579
+ html += ` <span class="sqm-status-indicator" title="Queue is blocked">🚫</span>`;
580
+ } else if (isPaused) {
581
+ html += ` <span class="sqm-status-indicator" title="Queue is paused">⏸️</span>`;
582
+ }
583
+
584
+ html += `</div>`;
585
+
586
+ if (processLimit && processLimit > 0) {
587
+ html += `<div class="sqm-process-limit"><small>(${processLimit} per process)</small></div>`;
588
+ }
589
+
590
+ html += `</div>`;
591
+
592
+ return html;
593
+ }
594
+
595
+ renderLimitInfo(queue) {
596
+ const limits = [];
597
+
598
+ if (queue.limit && queue.limit > 0) {
599
+ limits.push(`Q:${queue.limit}`);
600
+ }
601
+
602
+ if (queue.process_limit && queue.process_limit > 0) {
603
+ limits.push(`P:${queue.process_limit}`);
604
+ }
605
+
606
+ if (limits.length > 0) {
607
+ return `<span class="sqm-limits-info" title="Queue limit: ${queue.limit || 'none'}, Process limit: ${queue.process_limit || 'none'}">[${limits.join(', ')}]</span>`;
608
+ }
609
+
610
+ return '';
611
+ }
612
+
613
+ renderQueueActions(queue) {
614
+ const isPaused = queue.paused;
615
+ const isCritical = queue.critical;
616
+
617
+ if (isPaused) {
618
+ return `
619
+ <button class="sqm-btn sqm-btn-success sqm-btn-sm sqm-action-btn"
620
+ data-action="resume" data-queue="${queue.name}">
621
+ Resume
622
+ </button>
623
+ <button class="sqm-btn sqm-btn-secondary sqm-btn-sm sqm-more-btn"
624
+ data-queue="${queue.name}" title="More actions">⋯</button>
625
+ `;
626
+ } else {
627
+ const pauseBtn = isCritical ?
628
+ `<button class="sqm-btn sqm-btn-warning sqm-btn-sm sqm-action-btn" disabled title="Critical queue - cannot pause">
629
+ Pause
630
+ </button>` :
631
+ `<button class="sqm-btn sqm-btn-warning sqm-btn-sm sqm-action-btn"
632
+ data-action="pause" data-queue="${queue.name}">
633
+ Pause
634
+ </button>`;
635
+
636
+ return `
637
+ ${pauseBtn}
638
+ <button class="sqm-btn sqm-btn-secondary sqm-btn-sm sqm-more-btn"
639
+ data-queue="${queue.name}" title="More actions">⋯</button>
640
+ `;
641
+ }
642
+ }
643
+
644
+ attachQueueActionListeners() {
645
+ // Action buttons (pause/resume)
646
+ document.querySelectorAll('.sqm-action-btn:not([disabled])').forEach(btn => {
647
+ btn.addEventListener('click', (e) => this.handleQueueAction(e));
648
+ });
649
+
650
+ // More actions buttons (for advanced functionality)
651
+ document.querySelectorAll('.sqm-more-btn').forEach(btn => {
652
+ btn.addEventListener('click', (e) => this.handleMoreActions(e));
653
+ });
654
+ }
655
+
656
+ // ========================================
657
+ // Queue Operations
658
+ // ========================================
659
+
660
+ async handleQueueAction(event) {
661
+ const button = event.target;
662
+ const action = button.getAttribute('data-action');
663
+ const queueName = button.getAttribute('data-queue');
664
+
665
+ if (!action || !queueName) return;
666
+
667
+ try {
668
+ button.disabled = true;
669
+ button.textContent = action === 'pause' ? 'Pausing...' : 'Resuming...';
670
+
671
+ const endpoint = SidekiqQueueManagerUI.CONSTANTS.API_ENDPOINTS.queueAction(queueName, action);
672
+ const response = await this.apiCall(endpoint, { method: 'POST' });
673
+
674
+ if (response.success) {
675
+ this.showNotification(`Queue '${queueName}' ${action}d successfully`, 'success');
676
+ this.announceToScreenReader(`Queue ${queueName} ${action}d`);
677
+ await this.refreshQueues();
678
+ } else {
679
+ throw new Error(response.message || `Failed to ${action} queue`);
680
+ }
681
+ } catch (error) {
682
+ this.showNotification(`Failed to ${action} queue: ${error.message}`, 'error');
683
+ console.error(`Failed to ${action} queue:`, error);
684
+ }
685
+ }
686
+
687
+ async handleMoreActions(event) {
688
+ event.preventDefault();
689
+ event.stopPropagation();
690
+
691
+ const button = event.target;
692
+ const queueName = button.getAttribute('data-queue');
693
+
694
+ if (!queueName) return;
695
+
696
+ // Close any existing menu first
697
+ this.closeActionsMenu();
698
+
699
+ // Create and show actions menu
700
+ const menu = this.createActionsMenu(queueName);
701
+ const rect = button.getBoundingClientRect();
702
+
703
+ // Position menu below button
704
+ menu.style.top = `${rect.bottom + window.scrollY + 5}px`;
705
+ menu.style.left = `${rect.left + window.scrollX}px`;
706
+
707
+ document.body.appendChild(menu);
708
+
709
+ // Auto-close menu after 10 seconds
710
+ setTimeout(() => this.closeActionsMenu(), 10000);
711
+
712
+ // Store reference for cleanup
713
+ this.currentActionsMenu = menu;
714
+
715
+ // Setup menu event handlers
716
+ this.setupActionsMenuEvents(menu, queueName);
717
+ }
718
+
719
+ createActionsMenu(queueName) {
720
+ const menu = document.createElement('div');
721
+ menu.className = 'sqm-actions-menu';
722
+ menu.innerHTML = this.generateActionsMenuContent(queueName);
723
+ return menu;
724
+ }
725
+
726
+ generateActionsMenuContent(queueName) {
727
+ return `
728
+ <div class="sqm-actions-menu-content">
729
+ <div class="sqm-actions-menu-header">
730
+ <span class="sqm-actions-queue-name">${queueName}</span>
731
+ <button class="sqm-actions-menu-close" aria-label="Close menu">×</button>
732
+ </div>
733
+ <div class="sqm-actions-menu-body">
734
+ <button class="sqm-actions-menu-item" data-action="view_jobs" data-queue="${queueName}">
735
+ 👀 View Jobs
736
+ </button>
737
+ <button class="sqm-actions-menu-item" data-action="set_limit" data-queue="${queueName}">
738
+ 🔢 Set Queue Limit
739
+ </button>
740
+ <button class="sqm-actions-menu-item" data-action="remove_limit" data-queue="${queueName}">
741
+ ♾️ Remove Limit
742
+ </button>
743
+ <button class="sqm-actions-menu-item" data-action="set_process_limit" data-queue="${queueName}">
744
+ ⚙️ Set Process Limit
745
+ </button>
746
+ <button class="sqm-actions-menu-item" data-action="remove_process_limit" data-queue="${queueName}">
747
+ 🔓 Remove Process Limit
748
+ </button>
749
+ <button class="sqm-actions-menu-item" data-action="block" data-queue="${queueName}">
750
+ 🚫 Block Queue
751
+ </button>
752
+ <button class="sqm-actions-menu-item" data-action="unblock" data-queue="${queueName}">
753
+ ✅ Unblock Queue
754
+ </button>
755
+ <button class="sqm-actions-menu-item sqm-actions-danger" data-action="clear" data-queue="${queueName}">
756
+ 🗑️ Clear All Jobs
757
+ </button>
758
+ </div>
759
+ </div>
760
+ `;
761
+ }
762
+
763
+ setupActionsMenuEvents(menu, queueName) {
764
+ // Close button
765
+ const closeBtn = menu.querySelector('.sqm-actions-menu-close');
766
+ if (closeBtn) {
767
+ closeBtn.addEventListener('click', () => this.closeActionsMenu());
768
+ }
769
+
770
+ // Menu items
771
+ const menuItems = menu.querySelectorAll('.sqm-actions-menu-item');
772
+ menuItems.forEach(item => {
773
+ item.addEventListener('click', async (e) => {
774
+ const action = e.target.getAttribute('data-action');
775
+ const queue = e.target.getAttribute('data-queue');
776
+
777
+ this.closeActionsMenu();
778
+
779
+ try {
780
+ await this.executeQueueAction(action, queue);
781
+ } catch (error) {
782
+ this.showNotification(`Failed to execute ${action}: ${error.message}`, 'error');
783
+ }
784
+ });
785
+ });
786
+
787
+ // Close when clicking outside
788
+ const outsideClickHandler = (e) => {
789
+ if (!menu.contains(e.target)) {
790
+ this.closeActionsMenu();
791
+ document.removeEventListener('click', outsideClickHandler);
792
+ }
793
+ };
794
+
795
+ setTimeout(() => {
796
+ document.addEventListener('click', outsideClickHandler);
797
+ }, 100);
798
+
799
+ // Close on escape key
800
+ const escapeHandler = (e) => {
801
+ if (e.key === 'Escape') {
802
+ this.closeActionsMenu();
803
+ document.removeEventListener('keydown', escapeHandler);
804
+ }
805
+ };
806
+
807
+ document.addEventListener('keydown', escapeHandler);
808
+
809
+ // Store handlers for cleanup
810
+ this.currentMenuCloseHandler = () => {
811
+ document.removeEventListener('click', outsideClickHandler);
812
+ document.removeEventListener('keydown', escapeHandler);
813
+ };
814
+ }
815
+
816
+ closeActionsMenu() {
817
+ if (this.currentActionsMenu) {
818
+ this.currentActionsMenu.remove();
819
+ this.currentActionsMenu = null;
820
+ }
821
+
822
+ if (this.currentMenuCloseHandler) {
823
+ this.currentMenuCloseHandler();
824
+ this.currentMenuCloseHandler = null;
825
+ }
826
+ }
827
+
828
+ // ========================================
829
+ // Advanced Queue Actions
830
+ // ========================================
831
+
832
+ async executeQueueAction(action, queueName) {
833
+ switch (action) {
834
+ case 'view_jobs':
835
+ return this.viewQueueJobs(queueName);
836
+ case 'set_limit':
837
+ return this.setQueueLimit(queueName);
838
+ case 'remove_limit':
839
+ return this.removeQueueLimit(queueName);
840
+ case 'set_process_limit':
841
+ return this.setProcessLimit(queueName);
842
+ case 'remove_process_limit':
843
+ return this.removeProcessLimit(queueName);
844
+ case 'block':
845
+ return this.blockQueue(queueName);
846
+ case 'unblock':
847
+ return this.unblockQueue(queueName);
848
+ case 'clear':
849
+ return this.clearQueue(queueName);
850
+ default:
851
+ throw new Error(`Unknown action: ${action}`);
852
+ }
853
+ }
854
+
855
+ async viewQueueJobs(queueName) {
856
+ try {
857
+ const response = await this.apiCall(`/queues/${queueName}/jobs?page=1&per_page=10`);
858
+
859
+ if (response.success !== false) {
860
+ this.showJobsModal(queueName, response.data || response);
861
+ } else {
862
+ throw new Error(response.message || 'Failed to fetch jobs');
863
+ }
864
+ } catch (error) {
865
+ this.showNotification(`Failed to load jobs for ${queueName}: ${error.message}`, 'error');
866
+ }
867
+ }
868
+
869
+ showJobsModal(queueName, jobsData) {
870
+ const modalHtml = `
871
+ <div class="sqm-custom-modal-content sqm-jobs-modal">
872
+ <div class="sqm-custom-modal-header">
873
+ <h3>Jobs in "${queueName}" Queue</h3>
874
+ <button class="sqm-custom-modal-close" aria-label="Close modal">&times;</button>
875
+ </div>
876
+ <div class="sqm-custom-modal-body">
877
+ ${this.generateJobsListContent(jobsData)}
878
+ </div>
879
+ <div class="sqm-custom-modal-footer">
880
+ <button class="sqm-btn-modal sqm-btn-modal-secondary sqm-modal-close-btn">Close</button>
881
+ </div>
882
+ </div>
883
+ `;
884
+
885
+ const modal = this.createCustomModal(modalHtml);
886
+
887
+ // Setup close handlers
888
+ const closeButtons = modal.querySelectorAll('.sqm-custom-modal-close, .sqm-modal-close-btn');
889
+ closeButtons.forEach(btn => {
890
+ btn.addEventListener('click', () => modal.remove());
891
+ });
892
+
893
+ // Setup job modal handlers (delete and pagination)
894
+ this.setupJobModalHandlers(modal, queueName);
895
+
896
+ // Close on backdrop click
897
+ modal.addEventListener('click', (e) => {
898
+ if (e.target === modal) modal.remove();
899
+ });
900
+ }
901
+
902
+ setupJobModalHandlers(modal, queueName) {
903
+ // Setup delete job handlers
904
+ const deleteButtons = modal.querySelectorAll('.sqm-job-delete');
905
+ deleteButtons.forEach(btn => {
906
+ btn.addEventListener('click', async (e) => {
907
+ const jobId = e.target.getAttribute('data-job-id');
908
+ const deleteButton = e.target;
909
+
910
+ // Add loading state
911
+ const originalContent = deleteButton.innerHTML;
912
+ deleteButton.innerHTML = '⏳';
913
+ deleteButton.disabled = true;
914
+ deleteButton.style.opacity = '0.6';
915
+
916
+ try {
917
+ if (await this.deleteJob(queueName, jobId)) {
918
+ // Show success feedback
919
+ deleteButton.innerHTML = '✅';
920
+ deleteButton.style.opacity = '1';
921
+ deleteButton.classList.add('success');
922
+
923
+ // Refresh the jobs list after a short delay
924
+ setTimeout(async () => {
925
+ const modalBody = modal.querySelector('.sqm-custom-modal-body');
926
+ modalBody.innerHTML = '<div style="text-align: center; padding: 2rem;">🔄 Refreshing jobs...</div>';
927
+
928
+ try {
929
+ const response = await this.apiCall(`/queues/${queueName}/jobs?page=1&per_page=10`);
930
+ if (response.success !== false) {
931
+ modalBody.innerHTML = this.generateJobsListContent(response.data || response);
932
+ this.setupJobModalHandlers(modal, queueName); // Re-setup handlers for new content
933
+ }
934
+ } catch (error) {
935
+ modalBody.innerHTML = '<div style="text-align: center; padding: 2rem; color: var(--sqm-destructive);">❌ Failed to refresh jobs</div>';
936
+ }
937
+ }, 800);
938
+ } else {
939
+ // Reset button if deletion failed
940
+ deleteButton.innerHTML = originalContent;
941
+ deleteButton.disabled = false;
942
+ deleteButton.style.opacity = '1';
943
+ }
944
+ } catch (error) {
945
+ // Reset button on error
946
+ deleteButton.innerHTML = originalContent;
947
+ deleteButton.disabled = false;
948
+ deleteButton.style.opacity = '1';
949
+ }
950
+ });
951
+ });
952
+
953
+ // Setup pagination handlers
954
+ const paginationBtns = modal.querySelectorAll('.sqm-pagination-btn');
955
+ paginationBtns.forEach(btn => {
956
+ btn.addEventListener('click', async (e) => {
957
+ const page = e.target.getAttribute('data-page');
958
+ const response = await this.apiCall(`/queues/${queueName}/jobs?page=${page}&per_page=10`);
959
+ if (response.success !== false) {
960
+ // Update modal content with new page
961
+ const bodyContent = modal.querySelector('.sqm-custom-modal-body');
962
+ bodyContent.innerHTML = this.generateJobsListContent(response.data || response);
963
+ this.setupJobModalHandlers(modal, queueName); // Re-setup handlers for new page
964
+ }
965
+ });
966
+ });
967
+ }
968
+
969
+ generateJobsListContent(jobsData) {
970
+ if (!jobsData.jobs || jobsData.jobs.length === 0) {
971
+ return `
972
+ <div style="text-align: center; padding: 3rem 2rem; color: var(--sqm-muted-foreground);">
973
+ <svg style="width: 3rem; height: 3rem; margin-bottom: 1rem; opacity: 0.5;" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor">
974
+ <path stroke-linecap="round" stroke-linejoin="round" d="M20.25 14.15v4.25c0 1.094-.787 2.036-1.872 2.18-2.087.277-4.216.42-6.378.42s-4.291-.143-6.378-.42c-1.085-.144-1.872-1.086-1.872-2.18v-4.25m16.5 0a2.18 2.18 0 00.75-1.661V8.706c0-1.081-.768-2.015-1.837-2.175a48.114 48.114 0 00-3.413-.387m4.5 8.664V8.706c0 1.081.768 2.015 1.837 2.175a48.114 48.114 0 003.413.387m0 0a48.108 48.108 0 00-3.413-.387m0 0c-.07.003-.141.005-.213.008A4.5 4.5 0 009 10.5V9m4.5-1.206V7.5a2.25 2.25 0 00-4.5 0v1.294"/>
975
+ </svg>
976
+ <h3 style="font-size: 1.125rem; font-weight: 600; margin-bottom: 0.5rem;">No Jobs Found</h3>
977
+ <p style="margin-bottom: 0.5rem;">This queue currently has no jobs.</p>
978
+ <p style="font-size: 0.75rem; opacity: 0.8;">Queue size: ${jobsData.size || 0} jobs</p>
979
+ </div>
980
+ `;
981
+ }
982
+
983
+ const jobsHtml = jobsData.jobs.map(job => {
984
+ // Determine job priority for styling
985
+ const priority = this.getJobPriority(job);
986
+ const status = this.getJobStatus(job);
987
+ const formattedArgs = this.formatJobArgs(job.args);
988
+
989
+ return `
990
+ <div class="sqm-job-item">
991
+ <div class="sqm-job-info">
992
+ <div class="sqm-job-header">
993
+ <div class="sqm-job-class">${job.class || 'Unknown Job'}</div>
994
+ <div style="display: flex; gap: 0.5rem; align-items: center;">
995
+ ${status ? `<span class="sqm-job-status ${status.toLowerCase()}">${status}</span>` : ''}
996
+ <span class="sqm-job-priority ${priority.toLowerCase()}">${priority}</span>
997
+ </div>
998
+ </div>
999
+
1000
+ <div class="sqm-job-args-container">
1001
+ <div class="sqm-job-args-label">Arguments</div>
1002
+ <div class="sqm-job-args">${formattedArgs}</div>
1003
+ </div>
1004
+
1005
+ <div class="sqm-job-meta">
1006
+ <div class="sqm-job-meta-item">
1007
+ <span class="sqm-job-meta-label">Job ID</span>
1008
+ <span class="sqm-job-meta-value job-id">${job.jid || 'N/A'}</span>
1009
+ </div>
1010
+ <div class="sqm-job-meta-item">
1011
+ <span class="sqm-job-meta-label">Created</span>
1012
+ <span class="sqm-job-meta-value">${this.formatJobDate(job.created_at)}</span>
1013
+ </div>
1014
+ <div class="sqm-job-meta-item">
1015
+ <span class="sqm-job-meta-label">Enqueued</span>
1016
+ <span class="sqm-job-meta-value">${this.formatJobDate(job.enqueued_at)}</span>
1017
+ </div>
1018
+ <div class="sqm-job-meta-item">
1019
+ <span class="sqm-job-meta-label">Queue</span>
1020
+ <span class="sqm-job-meta-value">${job.queue || 'default'}</span>
1021
+ </div>
1022
+ <div class="sqm-job-meta-item">
1023
+ <span class="sqm-job-meta-label">Retrying</span>
1024
+ <span class="sqm-job-meta-value">${job.retry_count || 0} times</span>
1025
+ </div>
1026
+ <div class="sqm-job-meta-item">
1027
+ <span class="sqm-job-meta-label">At</span>
1028
+ <span class="sqm-job-meta-value">${job.at ? this.formatJobDate(job.at) : 'Now'}</span>
1029
+ </div>
1030
+ </div>
1031
+ </div>
1032
+ <div class="sqm-job-actions">
1033
+ <button class="sqm-job-delete" data-job-id="${job.jid}" title="Delete this job">
1034
+ 🗑️
1035
+ </button>
1036
+ </div>
1037
+ </div>
1038
+ `;
1039
+ }).join('');
1040
+
1041
+ const paginationHtml = this.generatePaginationHtml(jobsData.pagination);
1042
+
1043
+ return `
1044
+ <div class="sqm-job-list">
1045
+ ${jobsHtml}
1046
+ </div>
1047
+ ${paginationHtml}
1048
+ `;
1049
+ }
1050
+
1051
+ // Helper methods for enhanced job display
1052
+ getJobPriority(job) {
1053
+ // Determine priority based on job class name or other factors
1054
+ const className = (job.class || '').toLowerCase();
1055
+ if (className.includes('urgent') || className.includes('critical') || className.includes('high')) {
1056
+ return 'HIGH';
1057
+ } else if (className.includes('low') || className.includes('background')) {
1058
+ return 'LOW';
1059
+ }
1060
+ return 'NORMAL';
1061
+ }
1062
+
1063
+ getJobStatus(job) {
1064
+ // Determine status based on job properties
1065
+ if (job.retry_count > 0) {
1066
+ return 'RETRY';
1067
+ } else if (job.failed_at) {
1068
+ return 'FAILED';
1069
+ }
1070
+ return 'ENQUEUED';
1071
+ }
1072
+
1073
+ formatJobArgs(args) {
1074
+ if (!args) return 'No arguments';
1075
+
1076
+ try {
1077
+ // If args is already a string, try to parse it
1078
+ let parsedArgs = args;
1079
+ if (typeof args === 'string') {
1080
+ try {
1081
+ parsedArgs = JSON.parse(args);
1082
+ } catch (e) {
1083
+ return args; // Return as-is if not valid JSON
1084
+ }
1085
+ }
1086
+
1087
+ // Pretty print the JSON with proper indentation
1088
+ return JSON.stringify(parsedArgs, null, 2);
1089
+ } catch (e) {
1090
+ return String(args || 'No arguments');
1091
+ }
1092
+ }
1093
+
1094
+ formatJobDate(dateString) {
1095
+ if (!dateString) return 'Unknown';
1096
+
1097
+ try {
1098
+ const date = new Date(dateString);
1099
+ if (isNaN(date.getTime())) return dateString;
1100
+
1101
+ // Format as relative time if recent, otherwise full date
1102
+ const now = new Date();
1103
+ const diffMs = now - date;
1104
+ const diffMinutes = Math.floor(diffMs / (1000 * 60));
1105
+ const diffHours = Math.floor(diffMinutes / 60);
1106
+ const diffDays = Math.floor(diffHours / 24);
1107
+
1108
+ if (diffMinutes < 1) {
1109
+ return 'Just now';
1110
+ } else if (diffMinutes < 60) {
1111
+ return `${diffMinutes}m ago`;
1112
+ } else if (diffHours < 24) {
1113
+ return `${diffHours}h ago`;
1114
+ } else if (diffDays < 7) {
1115
+ return `${diffDays}d ago`;
1116
+ } else {
1117
+ return date.toLocaleDateString() + ' ' + date.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' });
1118
+ }
1119
+ } catch (e) {
1120
+ return dateString;
1121
+ }
1122
+ }
1123
+
1124
+ generatePaginationHtml(pagination) {
1125
+ if (!pagination || pagination.total_pages <= 1) return '';
1126
+
1127
+ const prevDisabled = pagination.current_page <= 1;
1128
+ const nextDisabled = pagination.current_page >= pagination.total_pages;
1129
+
1130
+ return `
1131
+ <div class="sqm-pagination">
1132
+ <div class="sqm-pagination-info">
1133
+ Page ${pagination.current_page} of ${pagination.total_pages}
1134
+ (${pagination.total_jobs || pagination.total_count} jobs)
1135
+ </div>
1136
+ <div class="sqm-pagination-controls">
1137
+ <button class="sqm-pagination-btn"
1138
+ data-page="${pagination.current_page - 1}"
1139
+ ${prevDisabled ? 'disabled' : ''}>
1140
+ Previous
1141
+ </button>
1142
+ <span class="sqm-pagination-current">
1143
+ ${pagination.current_page}
1144
+ </span>
1145
+ <button class="sqm-pagination-btn"
1146
+ data-page="${pagination.current_page + 1}"
1147
+ ${nextDisabled ? 'disabled' : ''}>
1148
+ Next
1149
+ </button>
1150
+ </div>
1151
+ </div>
1152
+ `;
1153
+ }
1154
+
1155
+ async deleteJob(queueName, jobId) {
1156
+ const confirmed = await this.showCustomConfirm(
1157
+ 'Delete Job',
1158
+ `Delete job "${jobId}" from the "${queueName}" queue?`,
1159
+ 'danger',
1160
+ `Job ID: ${jobId}\n\nThis action cannot be undone.`
1161
+ );
1162
+
1163
+ if (!confirmed) return false;
1164
+
1165
+ try {
1166
+ const response = await this.apiCall(`/queues/${queueName}/delete_job`, {
1167
+ method: 'DELETE',
1168
+ body: JSON.stringify({ job_id: jobId })
1169
+ });
1170
+
1171
+ if (response.success) {
1172
+ this.showNotification('Job deleted successfully', 'success');
1173
+ await this.refreshQueues();
1174
+ return true;
1175
+ } else {
1176
+ throw new Error(response.message || 'Failed to delete job');
1177
+ }
1178
+ } catch (error) {
1179
+ this.showNotification(`Failed to delete job: ${error.message}`, 'error');
1180
+ return false;
1181
+ }
1182
+ }
1183
+
1184
+ async setQueueLimit(queueName) {
1185
+ const limit = await this.showCustomPrompt(
1186
+ 'Set Queue Limit',
1187
+ `Enter the maximum number of jobs that can be enqueued in the "${queueName}" queue:`,
1188
+ '100',
1189
+ 'Set to 0 for unlimited. This helps prevent queues from growing too large.',
1190
+ 'number'
1191
+ );
1192
+
1193
+ if (limit === null) return;
1194
+
1195
+ const numLimit = parseInt(limit, 10);
1196
+ if (isNaN(numLimit) || numLimit < 0) {
1197
+ this.showNotification('Please enter a valid number (0 or greater)', 'error');
1198
+ return;
1199
+ }
1200
+
1201
+ try {
1202
+ const response = await this.apiCall(`/queues/${queueName}/set_limit`, {
1203
+ method: 'POST',
1204
+ body: JSON.stringify({ limit: numLimit })
1205
+ });
1206
+
1207
+ if (response.success) {
1208
+ this.showNotification(`Queue limit set to ${numLimit === 0 ? 'unlimited' : numLimit}`, 'success');
1209
+ await this.refreshQueues();
1210
+ } else {
1211
+ throw new Error(response.message || 'Failed to set queue limit');
1212
+ }
1213
+ } catch (error) {
1214
+ this.showNotification(`Failed to set queue limit: ${error.message}`, 'error');
1215
+ }
1216
+ }
1217
+
1218
+ async removeQueueLimit(queueName) {
1219
+ const confirmed = await this.showCustomConfirm(
1220
+ 'Remove Queue Limit',
1221
+ `Remove the limit from queue "${queueName}"?`,
1222
+ 'info',
1223
+ 'This will allow unlimited jobs to be enqueued in this queue.'
1224
+ );
1225
+
1226
+ if (!confirmed) return;
1227
+
1228
+ try {
1229
+ const response = await this.apiCall(`/queues/${queueName}/remove_limit`, { method: 'DELETE' });
1230
+
1231
+ if (response.success) {
1232
+ this.showNotification('Queue limit removed', 'success');
1233
+ await this.refreshQueues();
1234
+ } else {
1235
+ throw new Error(response.message || 'Failed to remove queue limit');
1236
+ }
1237
+ } catch (error) {
1238
+ this.showNotification(`Failed to remove queue limit: ${error.message}`, 'error');
1239
+ }
1240
+ }
1241
+
1242
+ async setProcessLimit(queueName) {
1243
+ const limit = await this.showCustomPrompt(
1244
+ 'Set Process Limit',
1245
+ `Enter the maximum number of processes that can work on the "${queueName}" queue:`,
1246
+ '5',
1247
+ 'This limits how many Sidekiq processes can process jobs from this queue simultaneously.',
1248
+ 'number'
1249
+ );
1250
+
1251
+ if (limit === null) return;
1252
+
1253
+ const numLimit = parseInt(limit, 10);
1254
+ if (isNaN(numLimit) || numLimit < 1) {
1255
+ this.showNotification('Please enter a valid number (1 or greater)', 'error');
1256
+ return;
1257
+ }
1258
+
1259
+ try {
1260
+ const response = await this.apiCall(`/queues/${queueName}/set_process_limit`, {
1261
+ method: 'POST',
1262
+ body: JSON.stringify({ limit: numLimit })
1263
+ });
1264
+
1265
+ if (response.success) {
1266
+ this.showNotification(`Process limit set to ${numLimit}`, 'success');
1267
+ await this.refreshQueues();
1268
+ } else {
1269
+ throw new Error(response.message || 'Failed to set process limit');
1270
+ }
1271
+ } catch (error) {
1272
+ this.showNotification(`Failed to set process limit: ${error.message}`, 'error');
1273
+ }
1274
+ }
1275
+
1276
+ async removeProcessLimit(queueName) {
1277
+ const confirmed = await this.showCustomConfirm(
1278
+ 'Remove Process Limit',
1279
+ `Remove the process limit from queue "${queueName}"?`,
1280
+ 'info',
1281
+ 'This will allow any number of Sidekiq processes to work on this queue.'
1282
+ );
1283
+
1284
+ if (!confirmed) return;
1285
+
1286
+ try {
1287
+ const response = await this.apiCall(`/queues/${queueName}/remove_process_limit`, { method: 'DELETE' });
1288
+
1289
+ if (response.success) {
1290
+ this.showNotification('Process limit removed', 'success');
1291
+ await this.refreshQueues();
1292
+ } else {
1293
+ throw new Error(response.message || 'Failed to remove process limit');
1294
+ }
1295
+ } catch (error) {
1296
+ this.showNotification(`Failed to remove process limit: ${error.message}`, 'error');
1297
+ }
1298
+ }
1299
+
1300
+ async blockQueue(queueName) {
1301
+ const confirmed = await this.showCustomConfirm(
1302
+ 'Block Queue',
1303
+ `Block the "${queueName}" queue?`,
1304
+ 'warning',
1305
+ 'Blocked queues cannot accept new jobs. Existing jobs will remain but cannot be processed until unblocked.'
1306
+ );
1307
+
1308
+ if (!confirmed) return;
1309
+
1310
+ try {
1311
+ const response = await this.apiCall(`/queues/${queueName}/block`, { method: 'POST' });
1312
+
1313
+ if (response.success) {
1314
+ this.showNotification(`Queue "${queueName}" blocked`, 'success');
1315
+ await this.refreshQueues();
1316
+ } else {
1317
+ throw new Error(response.message || 'Failed to block queue');
1318
+ }
1319
+ } catch (error) {
1320
+ this.showNotification(`Failed to block queue: ${error.message}`, 'error');
1321
+ }
1322
+ }
1323
+
1324
+ async unblockQueue(queueName) {
1325
+ const confirmed = await this.showCustomConfirm(
1326
+ 'Unblock Queue',
1327
+ `Unblock the "${queueName}" queue?`,
1328
+ 'info',
1329
+ 'This will allow the queue to accept and process jobs normally again.'
1330
+ );
1331
+
1332
+ if (!confirmed) return;
1333
+
1334
+ try {
1335
+ const response = await this.apiCall(`/queues/${queueName}/unblock`, { method: 'POST' });
1336
+
1337
+ if (response.success) {
1338
+ this.showNotification(`Queue "${queueName}" unblocked`, 'success');
1339
+ await this.refreshQueues();
1340
+ } else {
1341
+ throw new Error(response.message || 'Failed to unblock queue');
1342
+ }
1343
+ } catch (error) {
1344
+ this.showNotification(`Failed to unblock queue: ${error.message}`, 'error');
1345
+ }
1346
+ }
1347
+
1348
+ async clearQueue(queueName) {
1349
+ const confirmed = await this.showCustomConfirm(
1350
+ 'Clear All Jobs',
1351
+ `Are you sure you want to delete ALL jobs in the "${queueName}" queue?`,
1352
+ 'danger',
1353
+ 'This action cannot be undone. All pending jobs in this queue will be permanently deleted.'
1354
+ );
1355
+
1356
+ if (!confirmed) return;
1357
+
1358
+ // Double confirmation for destructive action
1359
+ const doubleConfirmed = await this.showCustomConfirm(
1360
+ 'Final Confirmation',
1361
+ 'This will permanently delete all jobs. Are you absolutely sure?',
1362
+ 'danger',
1363
+ 'Type YES in the next prompt to confirm.'
1364
+ );
1365
+
1366
+ if (!doubleConfirmed) return;
1367
+
1368
+ const confirmation = await this.showCustomPrompt(
1369
+ 'Final Confirmation',
1370
+ 'Type "YES" to confirm deletion of all jobs:',
1371
+ 'YES',
1372
+ 'This is your last chance to cancel.'
1373
+ );
1374
+
1375
+ if (confirmation !== 'YES') {
1376
+ this.showNotification('Queue clear cancelled', 'info');
1377
+ return;
1378
+ }
1379
+
1380
+ try {
1381
+ const response = await this.apiCall(`/queues/${queueName}/clear`, { method: 'POST' });
1382
+
1383
+ if (response.success) {
1384
+ this.showNotification(`All jobs cleared from "${queueName}"`, 'success');
1385
+ await this.refreshQueues();
1386
+ } else {
1387
+ throw new Error(response.message || 'Failed to clear queue');
1388
+ }
1389
+ } catch (error) {
1390
+ this.showNotification(`Failed to clear queue: ${error.message}`, 'error');
1391
+ }
1392
+ }
1393
+
1394
+ async pauseAllQueues() {
1395
+ if (!await this.showCustomConfirm('Pause All Queues', 'Are you sure you want to pause all non-critical queues?')) {
1396
+ return;
1397
+ }
1398
+
1399
+ try {
1400
+ const response = await this.apiCall(SidekiqQueueManagerUI.CONSTANTS.API_ENDPOINTS.pauseAll, {
1401
+ method: 'POST'
1402
+ });
1403
+
1404
+ if (response.success) {
1405
+ this.showNotification('Bulk pause operation completed', 'success');
1406
+ this.announceToScreenReader('All non-critical queues paused');
1407
+ await this.refreshQueues();
1408
+ } else {
1409
+ throw new Error(response.message || 'Bulk pause failed');
1410
+ }
1411
+ } catch (error) {
1412
+ this.showNotification(`Bulk pause failed: ${error.message}`, 'error');
1413
+ console.error('Bulk pause failed:', error);
1414
+ }
1415
+ }
1416
+
1417
+ async resumeAllQueues() {
1418
+ if (!await this.showCustomConfirm('Resume All Queues', 'Are you sure you want to resume all paused queues?')) {
1419
+ return;
1420
+ }
1421
+
1422
+ try {
1423
+ const response = await this.apiCall(SidekiqQueueManagerUI.CONSTANTS.API_ENDPOINTS.resumeAll, {
1424
+ method: 'POST'
1425
+ });
1426
+
1427
+ if (response.success) {
1428
+ this.showNotification('Bulk resume operation completed', 'success');
1429
+ this.announceToScreenReader('All paused queues resumed');
1430
+ await this.refreshQueues();
1431
+ } else {
1432
+ throw new Error(response.message || 'Bulk resume failed');
1433
+ }
1434
+ } catch (error) {
1435
+ this.showNotification(`Bulk resume failed: ${error.message}`, 'error');
1436
+ console.error('Bulk resume failed:', error);
1437
+ }
1438
+ }
1439
+
1440
+ // ========================================
1441
+ // Live Pull Management
1442
+ // ========================================
1443
+
1444
+ toggleLivePull() {
1445
+ this.state.livePullEnabled = !this.state.livePullEnabled;
1446
+
1447
+ if (this.state.livePullEnabled) {
1448
+ this.startLivePull();
1449
+ } else {
1450
+ this.stopLivePull();
1451
+ }
1452
+
1453
+ this.updateLivePullUI();
1454
+ this.announceToScreenReader(`Live pull ${this.state.livePullEnabled ? 'enabled' : 'disabled'}`);
1455
+ }
1456
+
1457
+ startLivePull() {
1458
+ if (this.state.refreshInterval) {
1459
+ clearInterval(this.state.refreshInterval);
1460
+ }
1461
+
1462
+ this.state.refreshInterval = setInterval(() => {
1463
+ this.refreshQueues();
1464
+ }, this.gemConfig.refreshInterval);
1465
+
1466
+ console.log(`🔴 Live Pull started with ${this.gemConfig.refreshInterval}ms interval`);
1467
+ }
1468
+
1469
+ stopLivePull() {
1470
+ if (this.state.refreshInterval) {
1471
+ clearInterval(this.state.refreshInterval);
1472
+ this.state.refreshInterval = null;
1473
+ }
1474
+
1475
+ console.log('⏹️ Live Pull stopped');
1476
+ }
1477
+
1478
+ updateLivePullUI() {
1479
+ const toggleBtn = this.elements.get('liveToggleBtn');
1480
+ const statusText = this.elements.get('statusText');
1481
+ const statusDot = this.elements.get('statusDot');
1482
+
1483
+ if (toggleBtn) {
1484
+ toggleBtn.setAttribute('data-enabled', this.state.livePullEnabled.toString());
1485
+ }
1486
+
1487
+ if (statusText) {
1488
+ statusText.textContent = this.state.livePullEnabled ? 'ON' : 'OFF';
1489
+ }
1490
+
1491
+ if (statusDot) {
1492
+ statusDot.classList.toggle('active', this.state.livePullEnabled);
1493
+ }
1494
+ }
1495
+
1496
+ // ========================================
1497
+ // Manual Refresh
1498
+ // ========================================
1499
+
1500
+ async manualRefresh() {
1501
+ const refreshContainer = this.elements.get('refreshContainer');
1502
+
1503
+ if (refreshContainer) {
1504
+ refreshContainer.dataset.loading = 'true';
1505
+ }
1506
+
1507
+ try {
1508
+ await this.refreshQueues();
1509
+ this.announceToScreenReader('Queue data refreshed');
1510
+ } finally {
1511
+ if (refreshContainer) {
1512
+ refreshContainer.dataset.loading = 'false';
1513
+ }
1514
+ }
1515
+ }
1516
+
1517
+ // ========================================
1518
+ // UI State Management
1519
+ // ========================================
1520
+
1521
+ showLoading() {
1522
+ this.elements.get('loading')?.classList.remove('sqm-hidden');
1523
+ this.elements.get('error')?.classList.add('sqm-hidden');
1524
+ this.elements.get('content')?.classList.add('sqm-hidden');
1525
+ }
1526
+
1527
+ showContent() {
1528
+ this.elements.get('loading')?.classList.add('sqm-hidden');
1529
+ this.elements.get('error')?.classList.add('sqm-hidden');
1530
+ this.elements.get('content')?.classList.remove('sqm-hidden');
1531
+ }
1532
+
1533
+ showError(message) {
1534
+ const errorElement = this.elements.get('error');
1535
+ const errorMessage = this.elements.get('errorMessage');
1536
+
1537
+ if (errorMessage) {
1538
+ errorMessage.textContent = message;
1539
+ }
1540
+
1541
+ this.elements.get('loading')?.classList.add('sqm-hidden');
1542
+ errorElement?.classList.remove('sqm-hidden');
1543
+ this.elements.get('content')?.classList.add('sqm-hidden');
1544
+ }
1545
+
1546
+ // ========================================
1547
+ // Custom Modal System
1548
+ // ========================================
1549
+
1550
+ async showCustomPrompt(title, message, placeholder = '', helpText = '', inputType = 'text') {
1551
+ return new Promise((resolve) => {
1552
+ const modalHtml = this.generatePromptContent(title, message, placeholder, helpText, inputType);
1553
+ const modal = this.createCustomModal(modalHtml);
1554
+
1555
+ const input = modal.querySelector('.sqm-prompt-input');
1556
+ const confirmBtn = modal.querySelector('.sqm-btn-confirm');
1557
+ const cancelBtn = modal.querySelector('.sqm-btn-cancel');
1558
+ const closeBtn = modal.querySelector('.sqm-custom-modal-close');
1559
+
1560
+ const cleanup = () => {
1561
+ modal.remove();
1562
+ };
1563
+
1564
+ const confirm = () => {
1565
+ const value = input.value.trim();
1566
+ cleanup();
1567
+ resolve(value || null);
1568
+ };
1569
+
1570
+ const cancel = () => {
1571
+ cleanup();
1572
+ resolve(null);
1573
+ };
1574
+
1575
+ // Event listeners
1576
+ confirmBtn.addEventListener('click', confirm);
1577
+ cancelBtn.addEventListener('click', cancel);
1578
+ closeBtn.addEventListener('click', cancel);
1579
+
1580
+ // Focus input and handle Enter/Escape
1581
+ input.focus();
1582
+ input.addEventListener('keydown', (e) => {
1583
+ if (e.key === 'Enter') {
1584
+ e.preventDefault();
1585
+ confirm();
1586
+ } else if (e.key === 'Escape') {
1587
+ e.preventDefault();
1588
+ cancel();
1589
+ }
1590
+ });
1591
+
1592
+ // Close on backdrop click
1593
+ modal.addEventListener('click', (e) => {
1594
+ if (e.target === modal) cancel();
1595
+ });
1596
+ });
1597
+ }
1598
+
1599
+ async showCustomConfirm(title, message, type = 'warning', details = '') {
1600
+ return new Promise((resolve) => {
1601
+ const modalHtml = this.generateConfirmContent(title, message, type, details);
1602
+ const modal = this.createCustomModal(modalHtml);
1603
+
1604
+ const confirmBtn = modal.querySelector('.sqm-btn-confirm');
1605
+ const cancelBtn = modal.querySelector('.sqm-btn-cancel');
1606
+ const closeBtn = modal.querySelector('.sqm-custom-modal-close');
1607
+
1608
+ const cleanup = () => {
1609
+ modal.remove();
1610
+ };
1611
+
1612
+ const confirm = () => {
1613
+ cleanup();
1614
+ resolve(true);
1615
+ };
1616
+
1617
+ const cancel = () => {
1618
+ cleanup();
1619
+ resolve(false);
1620
+ };
1621
+
1622
+ // Event listeners
1623
+ confirmBtn.addEventListener('click', confirm);
1624
+ cancelBtn.addEventListener('click', cancel);
1625
+ closeBtn.addEventListener('click', cancel);
1626
+
1627
+ // Handle Enter/Escape
1628
+ document.addEventListener('keydown', function keyHandler(e) {
1629
+ if (e.key === 'Enter') {
1630
+ document.removeEventListener('keydown', keyHandler);
1631
+ confirm();
1632
+ } else if (e.key === 'Escape') {
1633
+ document.removeEventListener('keydown', keyHandler);
1634
+ cancel();
1635
+ }
1636
+ });
1637
+
1638
+ // Close on backdrop click
1639
+ modal.addEventListener('click', (e) => {
1640
+ if (e.target === modal) cancel();
1641
+ });
1642
+ });
1643
+ }
1644
+
1645
+ createCustomModal(content) {
1646
+ const modal = document.createElement('div');
1647
+ modal.className = 'sqm-custom-modal';
1648
+ modal.innerHTML = `
1649
+ <div class="sqm-custom-modal-backdrop"></div>
1650
+ ${content}
1651
+ `;
1652
+
1653
+ document.body.appendChild(modal);
1654
+
1655
+ // Focus management for accessibility
1656
+ setTimeout(() => {
1657
+ const focusableElement = modal.querySelector('input, button:not(.sqm-custom-modal-close)');
1658
+ if (focusableElement) {
1659
+ focusableElement.focus();
1660
+ }
1661
+ }, 150);
1662
+
1663
+ return modal;
1664
+ }
1665
+
1666
+ generatePromptContent(title, message, placeholder, helpText, inputType) {
1667
+ return `
1668
+ <div class="sqm-custom-modal-content">
1669
+ <div class="sqm-custom-modal-header">
1670
+ <h3>${title}</h3>
1671
+ <button class="sqm-custom-modal-close" aria-label="Close modal">&times;</button>
1672
+ </div>
1673
+ <div class="sqm-custom-modal-body">
1674
+ <div class="sqm-prompt-content">
1675
+ <div class="sqm-prompt-message">${message}</div>
1676
+ ${helpText ? `<div class="sqm-prompt-help">${helpText}</div>` : ''}
1677
+ <input
1678
+ type="${inputType}"
1679
+ class="sqm-prompt-input"
1680
+ placeholder="${placeholder}"
1681
+ autocomplete="off"
1682
+ >
1683
+ </div>
1684
+ </div>
1685
+ <div class="sqm-custom-modal-footer">
1686
+ <button class="sqm-btn-modal sqm-btn-modal-secondary sqm-btn-cancel">Cancel</button>
1687
+ <button class="sqm-btn-modal sqm-btn-modal-primary sqm-btn-confirm">Confirm</button>
1688
+ </div>
1689
+ </div>
1690
+ `;
1691
+ }
1692
+
1693
+ generateConfirmContent(title, message, type, details) {
1694
+ const iconMap = {
1695
+ warning: '⚠️',
1696
+ danger: '🚨',
1697
+ info: 'ℹ️'
1698
+ };
1699
+
1700
+ const buttonTypeMap = {
1701
+ warning: 'sqm-btn-modal-warning',
1702
+ danger: 'sqm-btn-modal-danger',
1703
+ info: 'sqm-btn-modal-primary'
1704
+ };
1705
+
1706
+ return `
1707
+ <div class="sqm-custom-modal-content">
1708
+ <div class="sqm-custom-modal-header">
1709
+ <h3>${title}</h3>
1710
+ <button class="sqm-custom-modal-close" aria-label="Close modal">&times;</button>
1711
+ </div>
1712
+ <div class="sqm-custom-modal-body">
1713
+ <div class="sqm-confirm-content">
1714
+ <div class="sqm-confirm-icon ${type}">
1715
+ ${iconMap[type] || '❓'}
1716
+ </div>
1717
+ <div class="sqm-confirm-text">
1718
+ <div class="sqm-confirm-message">${message}</div>
1719
+ ${details ? `<div class="sqm-confirm-details">${details}</div>` : ''}
1720
+ </div>
1721
+ </div>
1722
+ </div>
1723
+ <div class="sqm-custom-modal-footer">
1724
+ <button class="sqm-btn-modal sqm-btn-modal-secondary sqm-btn-cancel">Cancel</button>
1725
+ <button class="sqm-btn-modal ${buttonTypeMap[type]} sqm-btn-confirm">Confirm</button>
1726
+ </div>
1727
+ </div>
1728
+ `;
1729
+ }
1730
+
1731
+ // Legacy method for backward compatibility
1732
+ async showConfirm(title, message) {
1733
+ return this.showCustomConfirm(title, message, 'warning');
1734
+ }
1735
+
1736
+ // ========================================
1737
+ // Utility Methods
1738
+ // ========================================
1739
+
1740
+ updateElement(key, value) {
1741
+ const element = this.elements.get(key);
1742
+ if (element) {
1743
+ element.textContent = value;
1744
+ }
1745
+ }
1746
+
1747
+ updateTimestamp(timestamp) {
1748
+ if (timestamp) {
1749
+ this.state.lastUpdate = new Date(timestamp);
1750
+ const timestampEl = document.getElementById('sqm-timestamp');
1751
+ if (timestampEl) {
1752
+ timestampEl.textContent = new Date(timestamp).toLocaleTimeString();
1753
+ }
1754
+ }
1755
+ }
1756
+
1757
+ formatLatency(latency) {
1758
+ if (!latency || latency === 0) return '0s';
1759
+ if (latency < 60) return `${latency.toFixed(1)}s`;
1760
+ return `${Math.floor(latency / 60)}m ${(latency % 60).toFixed(0)}s`;
1761
+ }
1762
+
1763
+ getPriorityIcon(priority) {
1764
+ if (priority >= 8) return '🔴'; // High priority
1765
+ if (priority >= 5) return '🟡'; // Medium priority
1766
+ return '⚪'; // Low priority
1767
+ }
1768
+
1769
+ handleError(error) {
1770
+ this.state.retryCount++;
1771
+
1772
+ if (this.state.retryCount <= SidekiqQueueManagerUI.CONSTANTS.MAX_RETRIES) {
1773
+ console.log(`Retrying... (${this.state.retryCount}/${SidekiqQueueManagerUI.CONSTANTS.MAX_RETRIES})`);
1774
+ setTimeout(() => this.refreshQueues(), SidekiqQueueManagerUI.CONSTANTS.RETRY_DELAY);
1775
+ } else {
1776
+ this.showError(`Failed to load data: ${error.message}`);
1777
+ this.state.retryCount = 0;
1778
+ }
1779
+ }
1780
+
1781
+ // ========================================
1782
+ // Notification System
1783
+ // ========================================
1784
+
1785
+ showNotification(message, type = 'info') {
1786
+ // Create notification element
1787
+ const notification = document.createElement('div');
1788
+ notification.className = `sqm-notification sqm-notification-${type}`;
1789
+ notification.textContent = message;
1790
+
1791
+ // Add to page
1792
+ document.body.appendChild(notification);
1793
+
1794
+ // Auto remove after 3 seconds
1795
+ setTimeout(() => {
1796
+ notification.remove();
1797
+ }, 3000);
1798
+ }
1799
+
1800
+ // ========================================
1801
+ // Cleanup
1802
+ // ========================================
1803
+
1804
+ destroy() {
1805
+ this.stopLivePull();
1806
+ this.closeActionsMenu();
1807
+
1808
+ // Remove event listeners
1809
+ this.eventHandlers.forEach(({ element, event, handler }) => {
1810
+ element.removeEventListener(event, handler);
1811
+ });
1812
+
1813
+ this.eventHandlers.clear();
1814
+ this.elements.clear();
1815
+
1816
+ if (SidekiqQueueManagerUI.instance === this) {
1817
+ SidekiqQueueManagerUI.instance = null;
1818
+ }
1819
+ }
1820
+ }
1821
+
1822
+ // ========================================
1823
+ // Initialization
1824
+ // ========================================
1825
+
1826
+ // Auto-initialize when DOM is ready
1827
+ document.addEventListener('DOMContentLoaded', () => {
1828
+ // Get configuration from meta tags or data attributes
1829
+ const config = window.SidekiqQueueManagerConfig || {};
1830
+
1831
+ // Initialize the application
1832
+ window.sidekiqQueueManager = new SidekiqQueueManagerUI(config);
1833
+ });
1834
+
1835
+ // Export for manual initialization if needed
1836
+ window.SidekiqQueueManagerUI = SidekiqQueueManagerUI;