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.
- checksums.yaml +7 -0
- data/.github/workflows/ci.yml +41 -0
- data/INSTALLATION.md +191 -0
- data/README.md +376 -0
- data/app/assets/javascripts/sidekiq_queue_manager/application.js +1836 -0
- data/app/assets/stylesheets/sidekiq_queue_manager/application.css +1018 -0
- data/app/assets/stylesheets/sidekiq_queue_manager/modals.css +838 -0
- data/app/controllers/sidekiq_queue_manager/application_controller.rb +190 -0
- data/app/controllers/sidekiq_queue_manager/assets_controller.rb +87 -0
- data/app/controllers/sidekiq_queue_manager/dashboard_controller.rb +373 -0
- data/app/services/sidekiq_queue_manager/queue_service.rb +475 -0
- data/app/views/layouts/sidekiq_queue_manager/application.html.erb +132 -0
- data/app/views/sidekiq_queue_manager/dashboard/index.html.erb +208 -0
- data/config/routes.rb +48 -0
- data/lib/sidekiq_queue_manager/configuration.rb +157 -0
- data/lib/sidekiq_queue_manager/engine.rb +151 -0
- data/lib/sidekiq_queue_manager/logging_middleware.rb +29 -0
- data/lib/sidekiq_queue_manager/version.rb +12 -0
- data/lib/sidekiq_queue_manager.rb +122 -0
- metadata +227 -0
@@ -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">×</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">×</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">×</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;
|