solid_queue_monitor 1.2.2 → 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 (90) hide show
  1. checksums.yaml +4 -4
  2. data/README.md +36 -1
  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} +80 -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 -28
  8. data/app/controllers/solid_queue_monitor/failed_jobs_controller.rb +3 -6
  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 -6
  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 -6
  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/services/solid_queue_monitor/chart_data_service.rb +2 -2
  27. data/app/views/layouts/solid_queue_monitor/application.html.erb +25 -0
  28. data/app/views/solid_queue_monitor/failed_jobs/_row.html.erb +26 -0
  29. data/app/views/solid_queue_monitor/failed_jobs/index.html.erb +38 -0
  30. data/app/views/solid_queue_monitor/in_progress_jobs/_row.html.erb +13 -0
  31. data/app/views/solid_queue_monitor/in_progress_jobs/index.html.erb +25 -0
  32. data/app/views/solid_queue_monitor/jobs/_arguments.html.erb +9 -0
  33. data/app/views/solid_queue_monitor/jobs/_error.html.erb +26 -0
  34. data/app/views/solid_queue_monitor/jobs/_execution_history.html.erb +25 -0
  35. data/app/views/solid_queue_monitor/jobs/_header.html.erb +37 -0
  36. data/app/views/solid_queue_monitor/jobs/_metadata.html.erb +22 -0
  37. data/app/views/solid_queue_monitor/jobs/_raw_data.html.erb +11 -0
  38. data/app/views/solid_queue_monitor/jobs/_timeline.html.erb +29 -0
  39. data/app/views/solid_queue_monitor/jobs/_timing.html.erb +9 -0
  40. data/app/views/solid_queue_monitor/jobs/_worker.html.erb +12 -0
  41. data/app/views/solid_queue_monitor/jobs/show.html.erb +22 -0
  42. data/app/views/solid_queue_monitor/overview/_chart.html.erb +1 -0
  43. data/app/views/solid_queue_monitor/overview/_recent_job_row.html.erb +26 -0
  44. data/app/views/solid_queue_monitor/overview/_recent_jobs.html.erb +31 -0
  45. data/app/views/solid_queue_monitor/overview/_stat_card.html.erb +4 -0
  46. data/app/views/solid_queue_monitor/overview/_stats.html.erb +11 -0
  47. data/app/views/solid_queue_monitor/overview/index.html.erb +9 -0
  48. data/app/views/solid_queue_monitor/queues/_job_row.html.erb +26 -0
  49. data/app/views/solid_queue_monitor/queues/_row.html.erb +33 -0
  50. data/app/views/solid_queue_monitor/queues/index.html.erb +18 -0
  51. data/app/views/solid_queue_monitor/queues/show.html.erb +63 -0
  52. data/app/views/solid_queue_monitor/ready_jobs/_row.html.erb +7 -0
  53. data/app/views/solid_queue_monitor/ready_jobs/index.html.erb +25 -0
  54. data/app/views/solid_queue_monitor/recurring_jobs/_row.html.erb +8 -0
  55. data/app/views/solid_queue_monitor/recurring_jobs/index.html.erb +26 -0
  56. data/app/views/solid_queue_monitor/scheduled_jobs/_row.html.erb +7 -0
  57. data/app/views/solid_queue_monitor/scheduled_jobs/index.html.erb +36 -0
  58. data/app/views/solid_queue_monitor/search/_completed_row.html.erb +6 -0
  59. data/app/views/solid_queue_monitor/search/_failed_row.html.erb +6 -0
  60. data/app/views/solid_queue_monitor/search/_job_row.html.erb +9 -0
  61. data/app/views/solid_queue_monitor/search/_recurring_row.html.erb +6 -0
  62. data/app/views/solid_queue_monitor/search/_section.html.erb +25 -0
  63. data/app/views/solid_queue_monitor/search/index.html.erb +23 -0
  64. data/app/views/solid_queue_monitor/shared/_filters.html.erb +48 -0
  65. data/app/views/solid_queue_monitor/shared/_flash.html.erb +17 -0
  66. data/app/views/solid_queue_monitor/shared/_footer.html.erb +3 -0
  67. data/app/views/solid_queue_monitor/shared/_header.html.erb +81 -0
  68. data/app/views/solid_queue_monitor/shared/_jobs_table.html.erb +20 -0
  69. data/app/views/solid_queue_monitor/shared/_pagination.html.erb +25 -0
  70. data/app/views/solid_queue_monitor/workers/_row.html.erb +22 -0
  71. data/app/views/solid_queue_monitor/workers/index.html.erb +82 -0
  72. data/config/routes.rb +6 -1
  73. data/lib/solid_queue_monitor/engine.rb +2 -0
  74. data/lib/solid_queue_monitor/version.rb +1 -1
  75. metadata +57 -17
  76. data/app/presenters/solid_queue_monitor/base_presenter.rb +0 -211
  77. data/app/presenters/solid_queue_monitor/failed_jobs_presenter.rb +0 -312
  78. data/app/presenters/solid_queue_monitor/in_progress_jobs_presenter.rb +0 -84
  79. data/app/presenters/solid_queue_monitor/job_details_presenter.rb +0 -696
  80. data/app/presenters/solid_queue_monitor/jobs_presenter.rb +0 -144
  81. data/app/presenters/solid_queue_monitor/queue_details_presenter.rb +0 -195
  82. data/app/presenters/solid_queue_monitor/queues_presenter.rb +0 -89
  83. data/app/presenters/solid_queue_monitor/ready_jobs_presenter.rb +0 -81
  84. data/app/presenters/solid_queue_monitor/recurring_jobs_presenter.rb +0 -81
  85. data/app/presenters/solid_queue_monitor/scheduled_jobs_presenter.rb +0 -173
  86. data/app/presenters/solid_queue_monitor/search_results_presenter.rb +0 -190
  87. data/app/presenters/solid_queue_monitor/stats_presenter.rb +0 -36
  88. data/app/presenters/solid_queue_monitor/workers_presenter.rb +0 -320
  89. data/app/services/solid_queue_monitor/chart_presenter.rb +0 -239
  90. data/app/services/solid_queue_monitor/html_generator.rb +0 -401
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: e1f5b752f844b9054655de2fd804eb5c91a42dc605dff161d648c0562136ca1c
4
- data.tar.gz: 880bd130bf11db839f500561ee8f7f2c46338227fdc34ed89699874660b07d6a
3
+ metadata.gz: 83ef4d9fb6a37088b8af586bdbc8af9a46018984918e58b912857a0df0ef910b
4
+ data.tar.gz: b0ceea99892250d40b763725fbc0dc0428f2a19123d34476f06cc86f22418ea0
5
5
  SHA512:
6
- metadata.gz: 98c99f43a01ea201576c6de6e1081f303ced5dc9abcd1d957fdf7a7e8b2768a0d6c8d1fa5025eea96c774d62e1e11407c5581812a022fee76cb76378bded541e
7
- data.tar.gz: 9e63e38db5dc81b9ef98d0f0d14b723d18ed186c452f677388a030c16f39e5b416eb351fb36be4cc4f070c8d52c9b2c78283fcf2293ba09b6a1e1fab8975e4e0
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:
@@ -206,6 +206,41 @@ This makes it easy to find specific jobs when debugging issues in your applicati
206
206
  - **Rails**: 7.1 or higher
207
207
  - **Solid Queue**: 0.1.0 or higher
208
208
 
209
+ ## Content Security Policy
210
+
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
+
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:
228
+
229
+ ```ruby
230
+ # config/initializers/content_security_policy.rb
231
+ Rails.application.config.content_security_policy do |policy|
232
+ policy.script_src :self
233
+ policy.style_src :self
234
+ end
235
+
236
+ Rails.application.config.content_security_policy_nonce_generator = ->(req) { SecureRandom.base64(16) }
237
+ Rails.application.config.content_security_policy_nonce_directives = %w[script-src style-src]
238
+ ```
239
+
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.
243
+
209
244
  ## Contributing
210
245
 
211
246
  Contributions are welcome! Here's how you can contribute:
@@ -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
  }
@@ -1088,6 +1103,18 @@ module SolidQueueMonitor
1088
1103
  border-radius: 2px;
1089
1104
  }
1090
1105
 
1106
+ .solid_queue_monitor .legend-color-created {
1107
+ background-color: #3b82f6;
1108
+ }
1109
+
1110
+ .solid_queue_monitor .legend-color-completed {
1111
+ background-color: #10b981;
1112
+ }
1113
+
1114
+ .solid_queue_monitor .legend-color-failed {
1115
+ background-color: #ef4444;
1116
+ }
1117
+
1091
1118
  .solid_queue_monitor .chart-tooltip {
1092
1119
  position: fixed;
1093
1120
  background: #1f2937;
@@ -2017,7 +2044,48 @@ module SolidQueueMonitor
2017
2044
  grid-template-columns: 1fr;
2018
2045
  }
2019
2046
  }
2020
- CSS
2021
- end
2022
- end
2023
- end
2047
+
2048
+ /* ===== CSP Phase 1 utility classes (replace runtime style mutations) ===== */
2049
+
2050
+ .is-hidden {
2051
+ display: none !important;
2052
+ }
2053
+
2054
+ .countdown-paused {
2055
+ opacity: 0.4;
2056
+ }
2057
+
2058
+ .is-expanded .collapse-icon {
2059
+ transform: rotate(90deg);
2060
+ }
2061
+
2062
+ .collapse-icon {
2063
+ transition: transform 150ms ease;
2064
+ }
2065
+
2066
+ .collapsible-content {
2067
+ display: none;
2068
+ }
2069
+
2070
+ .is-expanded .collapsible-content {
2071
+ display: block;
2072
+ }
2073
+
2074
+ .chart-tooltip {
2075
+ display: none;
2076
+ position: fixed;
2077
+ pointer-events: none;
2078
+ }
2079
+
2080
+ .chart-tooltip.tooltip-visible {
2081
+ display: block;
2082
+ }
2083
+
2084
+ #flash-message {
2085
+ opacity: 1;
2086
+ transition: opacity 500ms ease;
2087
+ }
2088
+
2089
+ #flash-message.is-fading {
2090
+ opacity: 0;
2091
+ }
@@ -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,34 +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
- ).generate
33
-
34
- render html: html.html_safe
35
- end
36
-
37
9
  def current_page
38
10
  (params[:page] || 1).to_i
39
11
  end