solid_queue_monitor 1.3.0 → 2.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.
Files changed (89) hide show
  1. checksums.yaml +4 -4
  2. data/README.md +20 -4
  3. data/app/assets/javascripts/solid_queue_monitor/application.js +393 -0
  4. data/app/{services/solid_queue_monitor/stylesheet_generator.rb → assets/stylesheets/solid_queue_monitor/application.css} +23 -12
  5. data/app/controllers/solid_queue_monitor/application_controller.rb +2 -2
  6. data/app/controllers/solid_queue_monitor/assets_controller.rb +52 -0
  7. data/app/controllers/solid_queue_monitor/base_controller.rb +0 -29
  8. data/app/controllers/solid_queue_monitor/failed_jobs_controller.rb +3 -7
  9. data/app/controllers/solid_queue_monitor/in_progress_jobs_controller.rb +3 -6
  10. data/app/controllers/solid_queue_monitor/jobs_controller.rb +3 -7
  11. data/app/controllers/solid_queue_monitor/overview_controller.rb +3 -12
  12. data/app/controllers/solid_queue_monitor/queues_controller.rb +4 -18
  13. data/app/controllers/solid_queue_monitor/ready_jobs_controller.rb +3 -6
  14. data/app/controllers/solid_queue_monitor/recurring_jobs_controller.rb +3 -6
  15. data/app/controllers/solid_queue_monitor/scheduled_jobs_controller.rb +3 -7
  16. data/app/controllers/solid_queue_monitor/search_controller.rb +3 -4
  17. data/app/controllers/solid_queue_monitor/workers_controller.rb +24 -8
  18. data/app/helpers/solid_queue_monitor/application_helper.rb +46 -0
  19. data/app/helpers/solid_queue_monitor/chart_helper.rb +293 -0
  20. data/app/helpers/solid_queue_monitor/job_details_helper.rb +66 -0
  21. data/app/helpers/solid_queue_monitor/jobs_helper.rb +134 -0
  22. data/app/helpers/solid_queue_monitor/pagination_helper.rb +23 -0
  23. data/app/helpers/solid_queue_monitor/sort_helper.rb +30 -0
  24. data/app/helpers/solid_queue_monitor/workers_helper.rb +88 -0
  25. data/app/services/solid_queue_monitor/asset_cache.rb +56 -0
  26. data/app/views/layouts/solid_queue_monitor/application.html.erb +25 -0
  27. data/app/views/solid_queue_monitor/failed_jobs/_row.html.erb +26 -0
  28. data/app/views/solid_queue_monitor/failed_jobs/index.html.erb +38 -0
  29. data/app/views/solid_queue_monitor/in_progress_jobs/_row.html.erb +13 -0
  30. data/app/views/solid_queue_monitor/in_progress_jobs/index.html.erb +25 -0
  31. data/app/views/solid_queue_monitor/jobs/_arguments.html.erb +9 -0
  32. data/app/views/solid_queue_monitor/jobs/_error.html.erb +26 -0
  33. data/app/views/solid_queue_monitor/jobs/_execution_history.html.erb +25 -0
  34. data/app/views/solid_queue_monitor/jobs/_header.html.erb +37 -0
  35. data/app/views/solid_queue_monitor/jobs/_metadata.html.erb +22 -0
  36. data/app/views/solid_queue_monitor/jobs/_raw_data.html.erb +11 -0
  37. data/app/views/solid_queue_monitor/jobs/_timeline.html.erb +29 -0
  38. data/app/views/solid_queue_monitor/jobs/_timing.html.erb +9 -0
  39. data/app/views/solid_queue_monitor/jobs/_worker.html.erb +12 -0
  40. data/app/views/solid_queue_monitor/jobs/show.html.erb +22 -0
  41. data/app/views/solid_queue_monitor/overview/_chart.html.erb +1 -0
  42. data/app/views/solid_queue_monitor/overview/_recent_job_row.html.erb +26 -0
  43. data/app/views/solid_queue_monitor/overview/_recent_jobs.html.erb +31 -0
  44. data/app/views/solid_queue_monitor/overview/_stat_card.html.erb +4 -0
  45. data/app/views/solid_queue_monitor/overview/_stats.html.erb +11 -0
  46. data/app/views/solid_queue_monitor/overview/index.html.erb +9 -0
  47. data/app/views/solid_queue_monitor/queues/_job_row.html.erb +26 -0
  48. data/app/views/solid_queue_monitor/queues/_row.html.erb +33 -0
  49. data/app/views/solid_queue_monitor/queues/index.html.erb +18 -0
  50. data/app/views/solid_queue_monitor/queues/show.html.erb +63 -0
  51. data/app/views/solid_queue_monitor/ready_jobs/_row.html.erb +7 -0
  52. data/app/views/solid_queue_monitor/ready_jobs/index.html.erb +25 -0
  53. data/app/views/solid_queue_monitor/recurring_jobs/_row.html.erb +8 -0
  54. data/app/views/solid_queue_monitor/recurring_jobs/index.html.erb +26 -0
  55. data/app/views/solid_queue_monitor/scheduled_jobs/_row.html.erb +7 -0
  56. data/app/views/solid_queue_monitor/scheduled_jobs/index.html.erb +36 -0
  57. data/app/views/solid_queue_monitor/search/_completed_row.html.erb +6 -0
  58. data/app/views/solid_queue_monitor/search/_failed_row.html.erb +6 -0
  59. data/app/views/solid_queue_monitor/search/_job_row.html.erb +9 -0
  60. data/app/views/solid_queue_monitor/search/_recurring_row.html.erb +6 -0
  61. data/app/views/solid_queue_monitor/search/_section.html.erb +25 -0
  62. data/app/views/solid_queue_monitor/search/index.html.erb +23 -0
  63. data/app/views/solid_queue_monitor/shared/_filters.html.erb +48 -0
  64. data/app/views/solid_queue_monitor/shared/_flash.html.erb +17 -0
  65. data/app/views/solid_queue_monitor/shared/_footer.html.erb +3 -0
  66. data/app/views/solid_queue_monitor/shared/_header.html.erb +81 -0
  67. data/app/views/solid_queue_monitor/shared/_jobs_table.html.erb +20 -0
  68. data/app/views/solid_queue_monitor/shared/_pagination.html.erb +25 -0
  69. data/app/views/solid_queue_monitor/workers/_row.html.erb +22 -0
  70. data/app/views/solid_queue_monitor/workers/index.html.erb +82 -0
  71. data/config/routes.rb +6 -1
  72. data/lib/solid_queue_monitor/engine.rb +2 -0
  73. data/lib/solid_queue_monitor/version.rb +1 -1
  74. metadata +57 -17
  75. data/app/presenters/solid_queue_monitor/base_presenter.rb +0 -211
  76. data/app/presenters/solid_queue_monitor/failed_jobs_presenter.rb +0 -225
  77. data/app/presenters/solid_queue_monitor/in_progress_jobs_presenter.rb +0 -84
  78. data/app/presenters/solid_queue_monitor/job_details_presenter.rb +0 -707
  79. data/app/presenters/solid_queue_monitor/jobs_presenter.rb +0 -144
  80. data/app/presenters/solid_queue_monitor/queue_details_presenter.rb +0 -195
  81. data/app/presenters/solid_queue_monitor/queues_presenter.rb +0 -89
  82. data/app/presenters/solid_queue_monitor/ready_jobs_presenter.rb +0 -81
  83. data/app/presenters/solid_queue_monitor/recurring_jobs_presenter.rb +0 -81
  84. data/app/presenters/solid_queue_monitor/scheduled_jobs_presenter.rb +0 -178
  85. data/app/presenters/solid_queue_monitor/search_results_presenter.rb +0 -190
  86. data/app/presenters/solid_queue_monitor/stats_presenter.rb +0 -36
  87. data/app/presenters/solid_queue_monitor/workers_presenter.rb +0 -325
  88. data/app/services/solid_queue_monitor/chart_presenter.rb +0 -239
  89. data/app/services/solid_queue_monitor/html_generator.rb +0 -427
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 03dcf6baf00328ad18dadaaf499e82d178d7e62c7cc01433f48a1e222c245742
4
- data.tar.gz: 15172ad03509c92f8d86b1c06cc78695203f125576838796d15c644c3b4a7eb0
3
+ metadata.gz: 83ef4d9fb6a37088b8af586bdbc8af9a46018984918e58b912857a0df0ef910b
4
+ data.tar.gz: b0ceea99892250d40b763725fbc0dc0428f2a19123d34476f06cc86f22418ea0
5
5
  SHA512:
6
- metadata.gz: 9b973471c7ac561b9b527dbb710610b41d452325fa7edfbd58f57f0454cd1d78a932e645854b12cbfc37b02b56f36006a1fe4fc243e23b03635bd6fef2e0baff
7
- data.tar.gz: 56b6b3f15cbccedbee9782cd70efe8c1c2b75b47fba7d5b4e38535a3c17386a7f355a295a44de7305efd84c2aeefc39516535d885e3f9361960564202cd2dfba
6
+ metadata.gz: f704b7eadd79058dc24f384bd2051e6ee0ceac7ae0a4315395122f9b381d2765ebaba2671e71560ec37cc49a1c6d0250a15bc6b61fc09599a4a58be1518c0881
7
+ data.tar.gz: 2bf9b9351dbc680180e31ce5f347cb480022159a1f4a2d4bfbd2e3b7f9fc6fab91cbfd044964b28ef426e59baa7c03da4079ad80e5440c219492a4aa89de5f8e
data/README.md CHANGED
@@ -72,7 +72,7 @@ A lightweight, zero-dependency web interface for monitoring Solid Queue backgrou
72
72
  Add this line to your application's Gemfile:
73
73
 
74
74
  ```ruby
75
- gem 'solid_queue_monitor', '~> 1.2'
75
+ gem 'solid_queue_monitor', '~> 2.0'
76
76
  ```
77
77
 
78
78
  Then execute:
@@ -208,9 +208,23 @@ This makes it easy to find specific jobs when debugging issues in your applicati
208
208
 
209
209
  ## Content Security Policy
210
210
 
211
- Solid Queue Monitor is compatible with strict Content Security Policy as of v1.3.0.
211
+ Solid Queue Monitor is fully CSP-compatible as of v2.0.0. The dashboard works out of the box under strict policies — no nonce configuration is required.
212
212
 
213
- If your application uses nonce-based CSP (the Rails default when `content_security_policy_nonce_generator` is set), Solid Queue Monitor will automatically stamp the per-request nonce onto every inline `<style>` and `<script>` tag it emits. Ensure your nonce directives include both `script-src` and `style-src`:
213
+ ### Strict CSP (v2.0.0+)
214
+
215
+ As of v2.0 the dashboard's CSS and JavaScript are served as external, content-hashed assets (e.g. `/solid_queue/assets/application-a1b2c3d4.css`) with `Cache-Control: immutable`. The dashboard emits zero inline `<style>` or `<script>` blocks. A strict policy that only allows `'self'` for both directives is sufficient:
216
+
217
+ ```ruby
218
+ # config/initializers/content_security_policy.rb
219
+ Rails.application.config.content_security_policy do |policy|
220
+ policy.script_src :self
221
+ policy.style_src :self
222
+ end
223
+ ```
224
+
225
+ ### Nonce-based CSP
226
+
227
+ Nonce-based CSP is also supported. When `content_security_policy_nonce_generator` is configured, Solid Queue Monitor stamps the per-request nonce onto the `<link rel="stylesheet">` and `<script src="...">` tags it emits — so policies that exclude `'self'` and only allow nonces still work:
214
228
 
215
229
  ```ruby
216
230
  # config/initializers/content_security_policy.rb
@@ -223,7 +237,9 @@ Rails.application.config.content_security_policy_nonce_generator = ->(req) { Sec
223
237
  Rails.application.config.content_security_policy_nonce_directives = %w[script-src style-src]
224
238
  ```
225
239
 
226
- No other configuration is required. If your application runs CSP without nonces (e.g., strict `script-src 'self'` only), the monitor UI will not function — asset-extraction support is tracked for a future release.
240
+ ### Upgrading from v1.x
241
+
242
+ v1.x emitted inline `<style nonce>` and `<script nonce>` blocks, so a nonce generator was effectively required for strict policies. v2.0 removes all inline blocks. If you added a nonce generator only to make the monitor work, you can keep it (no harm) or remove it.
227
243
 
228
244
  ## Contributing
229
245
 
@@ -0,0 +1,393 @@
1
+ // Solid Queue Monitor - main script
2
+ //
3
+ // Runtime config is read from <body data-*> attributes set by the layout.
4
+ // CSP-safe: no eval, no inline handlers, no string-to-code.
5
+
6
+ (function () {
7
+ 'use strict';
8
+
9
+ document.addEventListener('DOMContentLoaded', function () {
10
+ initFlashMessage();
11
+ initAutoRefresh();
12
+ initThemeToggle();
13
+ initChartCollapse();
14
+ initChartTooltip();
15
+ initScheduledBulkActions();
16
+ initFailedJobsBulkActions();
17
+ initJobDetailsBehaviors();
18
+ initGlobalBehaviors();
19
+ });
20
+
21
+ function initFlashMessage() {
22
+ var el = document.getElementById('flash-message');
23
+ if (!el) return;
24
+
25
+ setTimeout(function () {
26
+ el.classList.add('is-fading');
27
+ setTimeout(function () { el.classList.add('is-hidden'); }, 500);
28
+ }, 5000);
29
+ }
30
+
31
+ function initAutoRefresh() {
32
+ var cfg = document.body.dataset;
33
+ if (cfg.autoRefreshEnabled !== 'true') return;
34
+
35
+ var refreshInterval = parseInt(cfg.autoRefreshInterval, 10) || 30;
36
+ var countdown = refreshInterval;
37
+ var timerId = null;
38
+ var isEnabled = localStorage.getItem('sqm_auto_refresh') !== 'false';
39
+
40
+ var toggle = document.getElementById('auto-refresh-toggle');
41
+ var indicator = document.getElementById('auto-refresh-indicator');
42
+ var countdownEl = document.getElementById('auto-refresh-countdown');
43
+ var refreshBtn = document.getElementById('refresh-now-btn');
44
+
45
+ function updateUI() {
46
+ if (toggle) toggle.checked = isEnabled;
47
+ if (indicator) indicator.classList.toggle('active', isEnabled);
48
+ if (countdownEl) {
49
+ countdownEl.textContent = countdown + 's';
50
+ countdownEl.classList.toggle('countdown-paused', !isEnabled);
51
+ }
52
+ }
53
+
54
+ function tick() {
55
+ countdown -= 1;
56
+ if (countdown <= 0) {
57
+ window.location.reload();
58
+ } else {
59
+ updateUI();
60
+ }
61
+ }
62
+
63
+ function stopTimer() {
64
+ if (timerId) {
65
+ clearInterval(timerId);
66
+ timerId = null;
67
+ }
68
+ }
69
+
70
+ function startTimer() {
71
+ stopTimer();
72
+ countdown = refreshInterval;
73
+ updateUI();
74
+ timerId = setInterval(tick, 1000);
75
+ }
76
+
77
+ function setEnabled(enabled) {
78
+ isEnabled = enabled;
79
+ localStorage.setItem('sqm_auto_refresh', enabled ? 'true' : 'false');
80
+
81
+ if (enabled) {
82
+ startTimer();
83
+ } else {
84
+ stopTimer();
85
+ countdown = refreshInterval;
86
+ updateUI();
87
+ }
88
+ }
89
+
90
+ if (toggle) {
91
+ toggle.addEventListener('change', function () { setEnabled(this.checked); });
92
+ }
93
+ if (refreshBtn) {
94
+ refreshBtn.addEventListener('click', function () { window.location.reload(); });
95
+ }
96
+
97
+ updateUI();
98
+ if (isEnabled) startTimer();
99
+ }
100
+
101
+ function initThemeToggle() {
102
+ var body = document.body;
103
+ var themeBtn = document.getElementById('theme-toggle-btn');
104
+ var storageKey = 'sqm_dark_theme';
105
+
106
+ function getPreferredTheme() {
107
+ var saved = localStorage.getItem(storageKey);
108
+ if (saved !== null) return saved === 'true';
109
+ return window.matchMedia && window.matchMedia('(prefers-color-scheme: dark)').matches;
110
+ }
111
+
112
+ function setTheme(isDark) {
113
+ body.classList.toggle('dark-theme', isDark);
114
+ localStorage.setItem(storageKey, isDark ? 'true' : 'false');
115
+ }
116
+
117
+ setTheme(getPreferredTheme());
118
+
119
+ if (themeBtn) {
120
+ themeBtn.addEventListener('click', function () {
121
+ setTheme(!body.classList.contains('dark-theme'));
122
+ });
123
+ }
124
+
125
+ if (window.matchMedia) {
126
+ window.matchMedia('(prefers-color-scheme: dark)').addEventListener('change', function (e) {
127
+ if (localStorage.getItem(storageKey) === null) setTheme(e.matches);
128
+ });
129
+ }
130
+ }
131
+
132
+ function initChartCollapse() {
133
+ var chartSection = document.getElementById('chart-section');
134
+ var toggleBtn = document.getElementById('chart-toggle-btn');
135
+ if (!chartSection || !toggleBtn) return;
136
+
137
+ if (localStorage.getItem('sqm_chart_collapsed') === 'true') {
138
+ chartSection.classList.add('collapsed');
139
+ }
140
+
141
+ toggleBtn.addEventListener('click', function () {
142
+ chartSection.classList.toggle('collapsed');
143
+ var collapsed = chartSection.classList.contains('collapsed');
144
+ localStorage.setItem('sqm_chart_collapsed', collapsed ? 'true' : 'false');
145
+ });
146
+ }
147
+
148
+ function initChartTooltip() {
149
+ var tooltip = document.getElementById('chart-tooltip');
150
+ if (!tooltip) return;
151
+
152
+ var dataPoints = document.querySelectorAll('.data-point');
153
+ var seriesNames = { created: 'Created', completed: 'Completed', failed: 'Failed' };
154
+
155
+ function positionTooltip(e) {
156
+ var x = e.clientX + 10;
157
+ var y = e.clientY - 30;
158
+
159
+ if (x + tooltip.offsetWidth > window.innerWidth) {
160
+ x = e.clientX - tooltip.offsetWidth - 10;
161
+ }
162
+ if (y < 0) {
163
+ y = e.clientY + 10;
164
+ }
165
+
166
+ tooltip.style.left = x + 'px';
167
+ tooltip.style.top = y + 'px';
168
+ }
169
+
170
+ dataPoints.forEach(function (point) {
171
+ point.addEventListener('mouseenter', function (e) {
172
+ var series = this.getAttribute('data-series');
173
+ var label = this.getAttribute('data-label');
174
+ var value = this.getAttribute('data-value');
175
+
176
+ tooltip.querySelector('.tooltip-label').textContent = label;
177
+ tooltip.querySelector('.tooltip-value').textContent = seriesNames[series] + ': ' + value;
178
+ tooltip.classList.add('tooltip-visible');
179
+ positionTooltip(e);
180
+ });
181
+
182
+ point.addEventListener('mousemove', positionTooltip);
183
+ point.addEventListener('mouseleave', function () {
184
+ tooltip.classList.remove('tooltip-visible');
185
+ });
186
+ });
187
+ }
188
+
189
+ function initGlobalBehaviors() {
190
+ document.addEventListener('submit', function (e) {
191
+ var form = e.target;
192
+ var msg = form.dataset && form.dataset.confirm;
193
+ if (msg && !window.confirm(msg)) e.preventDefault();
194
+ }, true);
195
+
196
+ document.addEventListener('click', function (e) {
197
+ var el = e.target.closest('[data-confirm-submit]');
198
+ if (!el) return;
199
+
200
+ e.preventDefault();
201
+ var msg = el.dataset.confirm || 'Are you sure?';
202
+ if (!window.confirm(msg)) return;
203
+
204
+ var formId = el.dataset.confirmSubmit;
205
+ var form = document.getElementById(formId);
206
+ if (form) form.submit();
207
+ });
208
+
209
+ var timeRangeSelect = document.getElementById('chart-time-select');
210
+ if (timeRangeSelect) {
211
+ timeRangeSelect.addEventListener('change', function () {
212
+ window.location.href = '?time_range=' + this.value;
213
+ });
214
+ }
215
+ }
216
+
217
+ function initScheduledBulkActions() {
218
+ var form = document.getElementById('scheduled-jobs-form');
219
+ if (!form) return;
220
+
221
+ var selectAllCheckbox = document.getElementById('scheduled-jobs-select-all');
222
+ var executeButton = document.getElementById('execute-selected-top');
223
+ var rejectButton = document.getElementById('reject-selected-top');
224
+
225
+ function selectedCheckboxes() {
226
+ return Array.prototype.slice.call(document.querySelectorAll('input[name="job_ids[]"]:checked'));
227
+ }
228
+
229
+ function allJobCheckboxes() {
230
+ return Array.prototype.slice.call(document.getElementsByName('job_ids[]'));
231
+ }
232
+
233
+ function updateButtonStates() {
234
+ var checked = selectedCheckboxes().length > 0;
235
+ if (executeButton) executeButton.disabled = !checked;
236
+ if (rejectButton) rejectButton.disabled = !checked;
237
+ }
238
+
239
+ function submitForm(actionUrl, selectedIds) {
240
+ allJobCheckboxes().forEach(function (checkbox) { checkbox.checked = false; });
241
+ Array.prototype.slice.call(form.querySelectorAll('input[type="hidden"][name="job_ids[]"]')).forEach(function (input) {
242
+ input.remove();
243
+ });
244
+
245
+ form.action = actionUrl;
246
+ selectedIds.forEach(function (id) {
247
+ var input = document.createElement('input');
248
+ input.type = 'hidden';
249
+ input.name = 'job_ids[]';
250
+ input.value = id;
251
+ form.appendChild(input);
252
+ });
253
+ form.submit();
254
+ }
255
+
256
+ if (selectAllCheckbox) {
257
+ selectAllCheckbox.addEventListener('change', function () {
258
+ allJobCheckboxes().forEach(function (checkbox) { checkbox.checked = selectAllCheckbox.checked; });
259
+ updateButtonStates();
260
+ });
261
+ }
262
+
263
+ allJobCheckboxes().forEach(function (checkbox) {
264
+ checkbox.addEventListener('change', function () {
265
+ if (selectAllCheckbox) {
266
+ selectAllCheckbox.checked = allJobCheckboxes().every(function (item) { return item.checked; });
267
+ }
268
+ updateButtonStates();
269
+ });
270
+ });
271
+
272
+ if (executeButton) {
273
+ executeButton.addEventListener('click', function () {
274
+ var selectedIds = selectedCheckboxes().map(function (checkbox) { return checkbox.value; });
275
+ if (selectedIds.length > 0) submitForm(executeButton.dataset.actionUrl, selectedIds);
276
+ });
277
+ }
278
+
279
+ if (rejectButton) {
280
+ rejectButton.addEventListener('click', function () {
281
+ var selectedIds = selectedCheckboxes().map(function (checkbox) { return checkbox.value; });
282
+ if (selectedIds.length === 0) return;
283
+ if (window.confirm('Are you sure you want to reject the selected jobs? This action cannot be undone.')) {
284
+ submitForm(rejectButton.dataset.actionUrl, selectedIds);
285
+ }
286
+ });
287
+ }
288
+
289
+ updateButtonStates();
290
+ }
291
+
292
+ function initFailedJobsBulkActions() {
293
+ var form = document.getElementById('failed-jobs-form');
294
+ if (!form) return;
295
+
296
+ var selectAll = document.getElementById('select-all');
297
+ var retryButton = document.getElementById('retry-selected-top');
298
+ var discardButton = document.getElementById('discard-selected-top');
299
+
300
+ function checkboxes() {
301
+ return Array.prototype.slice.call(document.querySelectorAll('.job-checkbox'));
302
+ }
303
+
304
+ function checkedBoxes() {
305
+ return checkboxes().filter(function (checkbox) { return checkbox.checked; });
306
+ }
307
+
308
+ function updateButtonState() {
309
+ var anyChecked = checkedBoxes().length > 0;
310
+ if (retryButton) retryButton.disabled = !anyChecked;
311
+ if (discardButton) discardButton.disabled = !anyChecked;
312
+ }
313
+
314
+ function appendHidden(name, value) {
315
+ var input = document.createElement('input');
316
+ input.type = 'hidden';
317
+ input.name = name;
318
+ input.value = value;
319
+ form.appendChild(input);
320
+ }
321
+
322
+ function bulkSubmit(action, promptMsg) {
323
+ var ids = checkedBoxes().map(function (checkbox) { return checkbox.value; });
324
+ if (ids.length === 0 || !window.confirm(promptMsg)) return;
325
+ Array.prototype.slice.call(form.querySelectorAll('input[type="hidden"]')).forEach(function (input) { input.remove(); });
326
+ form.action = action;
327
+ ids.forEach(function (id) { appendHidden('job_ids[]', id); });
328
+ form.submit();
329
+ }
330
+
331
+ if (selectAll) {
332
+ selectAll.addEventListener('change', function () {
333
+ checkboxes().forEach(function (checkbox) { checkbox.checked = selectAll.checked; });
334
+ updateButtonState();
335
+ });
336
+ }
337
+
338
+ checkboxes().forEach(function (checkbox) {
339
+ checkbox.addEventListener('change', function () {
340
+ if (selectAll) selectAll.checked = checkedBoxes().length === checkboxes().length;
341
+ updateButtonState();
342
+ });
343
+ });
344
+
345
+ if (retryButton) {
346
+ retryButton.addEventListener('click', function () {
347
+ bulkSubmit(retryButton.dataset.actionUrl, 'Are you sure you want to retry the selected jobs?');
348
+ });
349
+ }
350
+ if (discardButton) {
351
+ discardButton.addEventListener('click', function () {
352
+ bulkSubmit(discardButton.dataset.actionUrl, 'Are you sure you want to discard the selected jobs?');
353
+ });
354
+ }
355
+
356
+ updateButtonState();
357
+ }
358
+
359
+ function initJobDetailsBehaviors() {
360
+ document.addEventListener('click', function (e) {
361
+ var el = e.target.closest('[data-action]');
362
+ if (!el) return;
363
+
364
+ if (el.dataset.stopPropagation === 'true') e.stopPropagation();
365
+
366
+ if (el.dataset.action === 'copy') {
367
+ var target = document.getElementById(el.dataset.target);
368
+ if (!target || !navigator.clipboard) return;
369
+ var original = el.innerHTML;
370
+ navigator.clipboard.writeText(target.innerText || target.textContent).then(function () {
371
+ el.innerHTML = 'Copied!';
372
+ setTimeout(function () { el.innerHTML = original; }, 2000);
373
+ });
374
+ }
375
+
376
+ if (el.dataset.action === 'show-backtrace') {
377
+ var which = el.dataset.backtrace;
378
+ var appEl = document.getElementById('app-backtrace');
379
+ var fullEl = document.getElementById('full-backtrace');
380
+ if (appEl) appEl.classList.toggle('is-hidden', which !== 'app');
381
+ if (fullEl) fullEl.classList.toggle('is-hidden', which !== 'full');
382
+ document.querySelectorAll('[data-action="show-backtrace"]').forEach(function (btn) {
383
+ btn.classList.toggle('active', btn.dataset.backtrace === which);
384
+ });
385
+ }
386
+
387
+ if (el.dataset.action === 'toggle-section') {
388
+ var section = el.closest('.collapsible-section');
389
+ if (section) section.classList.toggle('is-expanded');
390
+ }
391
+ });
392
+ }
393
+ }());
@@ -1,9 +1,19 @@
1
- # frozen_string_literal: true
1
+ /*
2
+ * Solid Queue Monitor - main stylesheet
3
+ *
4
+ * Section index:
5
+ * - Resets and base typography
6
+ * - Layout (container, section, header, nav)
7
+ * - Stats and charts
8
+ * - Tables (jobs lists, sortable headers, pagination)
9
+ * - Forms (filters, search, auth-refresh toggle)
10
+ * - Messages (flash, tooltips)
11
+ * - Theme overrides (dark-theme)
12
+ *
13
+ * Edit this file directly. Do NOT regenerate from stylesheet_generator.rb -
14
+ * that class is being removed in v2.0.
15
+ */
2
16
 
3
- module SolidQueueMonitor
4
- class StylesheetGenerator
5
- def generate
6
- <<-CSS
7
17
  .solid_queue_monitor {
8
18
  --primary-color: #3b82f6;
9
19
  --success-color: #10b981;
@@ -189,7 +199,7 @@ module SolidQueueMonitor
189
199
  white-space: nowrap;
190
200
  }
191
201
 
192
- .solid_queue_monitor th,#{' '}
202
+ .solid_queue_monitor th,
193
203
  .solid_queue_monitor td {
194
204
  padding: 0.75rem 1rem;
195
205
  text-align: left;
@@ -266,6 +276,11 @@ module SolidQueueMonitor
266
276
  background-color: #fffbeb;
267
277
  }
268
278
 
279
+ .solid_queue_monitor.dark-theme .queue-paused {
280
+ background-color: #3a2410;
281
+ color: #fde68a;
282
+ }
283
+
269
284
  .solid_queue_monitor .pause-button {
270
285
  background: #f59e0b;
271
286
  color: white;
@@ -334,7 +349,7 @@ module SolidQueueMonitor
334
349
  padding: 0.5rem 1rem;
335
350
  font-size: 0.875rem;
336
351
  }
337
- #{' '}
352
+
338
353
  .solid_queue_monitor .pagination-gap {
339
354
  display: inline-flex;
340
355
  align-items: center;
@@ -507,7 +522,7 @@ module SolidQueueMonitor
507
522
  .solid_queue_monitor .filter-and-actions-container {
508
523
  flex-direction: column;
509
524
  }
510
- #{' '}
525
+
511
526
  .solid_queue_monitor .bulk-actions-container {
512
527
  width: 100%;
513
528
  }
@@ -2074,7 +2089,3 @@ module SolidQueueMonitor
2074
2089
  #flash-message.is-fading {
2075
2090
  opacity: 0;
2076
2091
  }
2077
- CSS
2078
- end
2079
- end
2080
- end
@@ -6,7 +6,7 @@ module SolidQueueMonitor
6
6
  include ActionController::Flash
7
7
 
8
8
  before_action :authenticate, if: -> { SolidQueueMonitor::AuthenticationService.authentication_required? }
9
- layout false
9
+ layout 'solid_queue_monitor/application'
10
10
  skip_before_action :verify_authenticity_token
11
11
 
12
12
  def set_flash_message(message, type)
@@ -17,7 +17,7 @@ module SolidQueueMonitor
17
17
  # Try to use Rails flash if available
18
18
  begin
19
19
  flash[:notice] = message if type == :success
20
- flash[:alert] = message if type == :error
20
+ flash[:alert] = message if type == :error
21
21
  rescue StandardError
22
22
  # Flash not available (e.g., no session middleware)
23
23
  end
@@ -0,0 +1,52 @@
1
+ # frozen_string_literal: true
2
+
3
+ module SolidQueueMonitor
4
+ class AssetsController < ApplicationController
5
+ skip_before_action :authenticate, raise: false
6
+
7
+ MIME_TYPES = { '.css' => 'text/css', '.js' => 'application/javascript' }.freeze
8
+ FINGERPRINT_PATTERN = /\A(?<base>[A-Za-z0-9_]+)-(?<hash>[a-f0-9]+)(?<ext>\.css|\.js)\z/
9
+
10
+ def show
11
+ asset_request = parse_asset_request
12
+ return head(:not_found) unless asset_request
13
+
14
+ asset = SolidQueueMonitor::AssetCache.fetch_by_name(asset_request[:file_name])
15
+ return head(:not_found) unless asset
16
+ return head(:not_found) unless fingerprint_matches?(asset[:etag], asset_request[:hash])
17
+
18
+ assign_asset_headers(asset)
19
+ return head(:not_modified) if etag_matches?
20
+
21
+ render plain: asset[:content], content_type: MIME_TYPES[asset_request[:ext]]
22
+ end
23
+
24
+ private
25
+
26
+ def parse_asset_request
27
+ match = FINGERPRINT_PATTERN.match(params[:file])
28
+ return nil unless match
29
+
30
+ {
31
+ ext: match[:ext],
32
+ file_name: "#{match[:base]}#{match[:ext]}",
33
+ hash: match[:hash]
34
+ }
35
+ end
36
+
37
+ def fingerprint_matches?(expected, actual)
38
+ expected.bytesize == actual.bytesize && Rack::Utils.secure_compare(expected, actual)
39
+ end
40
+
41
+ def assign_asset_headers(asset)
42
+ response.headers['Cache-Control'] = "public, max-age=#{1.year.to_i}, immutable"
43
+ response.headers['ETag'] = %("#{asset[:etag]}")
44
+ response.headers['Last-Modified'] = asset[:mtime].httpdate
45
+ response.headers['Vary'] = 'Accept-Encoding'
46
+ end
47
+
48
+ def etag_matches?
49
+ request.headers['If-None-Match'].to_s.split(',').map(&:strip).include?(response.headers['ETag'])
50
+ end
51
+ end
52
+ end
@@ -6,35 +6,6 @@ module SolidQueueMonitor
6
6
  PaginationService.new(relation, current_page, per_page).paginate
7
7
  end
8
8
 
9
- def render_page(title, content, search_query: nil)
10
- # Get flash message from instance variable (set by set_flash_message) or session
11
- message = @flash_message
12
- message_type = @flash_type
13
-
14
- # Try to get from session as fallback, but don't fail if session unavailable
15
- begin
16
- message ||= session[:flash_message]
17
- message_type ||= session[:flash_type]
18
-
19
- # Clear the flash message from session after using it
20
- session.delete(:flash_message) if message
21
- session.delete(:flash_type) if message_type
22
- rescue StandardError
23
- # Session not available (e.g., no session middleware in tests)
24
- end
25
-
26
- html = SolidQueueMonitor::HtmlGenerator.new(
27
- title: title,
28
- content: content,
29
- message: message,
30
- message_type: message_type,
31
- search_query: search_query,
32
- nonce: content_security_policy_nonce
33
- ).generate
34
-
35
- render html: html.html_safe
36
- end
37
-
38
9
  def current_page
39
10
  (params[:page] || 1).to_i
40
11
  end
@@ -8,13 +8,9 @@ module SolidQueueMonitor
8
8
  base_query = SolidQueue::FailedExecution.includes(:job)
9
9
  sorted_query = apply_execution_sorting(filter_failed_jobs(base_query), SORTABLE_COLUMNS, 'created_at', :desc)
10
10
  @failed_jobs = paginate(sorted_query)
11
-
12
- render_page('Failed Jobs', SolidQueueMonitor::FailedJobsPresenter.new(@failed_jobs[:records],
13
- current_page: @failed_jobs[:current_page],
14
- total_pages: @failed_jobs[:total_pages],
15
- filters: filter_params,
16
- sort: sort_params,
17
- nonce: content_security_policy_nonce).render)
11
+ @filters = filter_params
12
+ @sort = sort_params
13
+ @action_path = failed_jobs_path
18
14
  end
19
15
 
20
16
  def retry
@@ -8,12 +8,9 @@ module SolidQueueMonitor
8
8
  base_query = SolidQueue::ClaimedExecution.includes(:job)
9
9
  sorted_query = apply_execution_sorting(filter_in_progress_jobs(base_query), SORTABLE_COLUMNS, 'created_at', :desc)
10
10
  @in_progress_jobs = paginate(sorted_query)
11
-
12
- render_page('In Progress Jobs', SolidQueueMonitor::InProgressJobsPresenter.new(@in_progress_jobs[:records],
13
- current_page: @in_progress_jobs[:current_page],
14
- total_pages: @in_progress_jobs[:total_pages],
15
- filters: filter_params,
16
- sort: sort_params).render)
11
+ @filters = filter_params
12
+ @sort = sort_params
13
+ @action_path = in_progress_jobs_path
17
14
  end
18
15
 
19
16
  private
@@ -11,13 +11,9 @@ module SolidQueueMonitor
11
11
  return
12
12
  end
13
13
 
14
- job_data = load_job_data(@job)
15
-
16
- render_page("Job ##{@job.id}", SolidQueueMonitor::JobDetailsPresenter.new(
17
- @job,
18
- **job_data,
19
- nonce: content_security_policy_nonce
20
- ).render)
14
+ load_job_data(@job).each do |name, value|
15
+ instance_variable_set("@#{name}", value)
16
+ end
21
17
  end
22
18
 
23
19
  private