solid_queue_monitor 1.3.0 → 2.1.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 +66 -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 +9 -3
  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. data/lib/solid_queue_monitor.rb +8 -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 -225
  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 -707
  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 -178
  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 -325
  89. data/app/services/solid_queue_monitor/chart_presenter.rb +0 -239
  90. 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: 92ff7e193202f9653de1b877d2e5d8b2f841c1e50e6c86a9ba4c556782e1fe8b
4
+ data.tar.gz: 2d180ede70f06618f676d167674238e4e7940e02f0b5af56b2667f543ab833f0
5
5
  SHA512:
6
- metadata.gz: 9b973471c7ac561b9b527dbb710610b41d452325fa7edfbd58f57f0454cd1d78a932e645854b12cbfc37b02b56f36006a1fe4fc243e23b03635bd6fef2e0baff
7
- data.tar.gz: 56b6b3f15cbccedbee9782cd70efe8c1c2b75b47fba7d5b4e38535a3c17386a7f355a295a44de7305efd84c2aeefc39516535d885e3f9361960564202cd2dfba
6
+ metadata.gz: 203a85cfcb427b2d21faf15c0203bea8c06ee8a01296ad94d318aae018ad83cd8a7c86b9ac633a300588df9a7dc012a1ba42c2a0bcc91e1b97829816e527bba2
7
+ data.tar.gz: 4a3904c1f727a72d47d960cfc0e20c70ffd0f0f5c62463c652d31123653956e73874661d67b15a7cdbd14b6d36aed322b946548f2906d73860f6ef12d79c4ce2
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:
@@ -124,6 +124,10 @@ SolidQueueMonitor.setup do |config|
124
124
  # Disable the chart on the overview page to skip chart queries entirely
125
125
  # config.show_chart = true
126
126
  end
127
+
128
+ # Optional: inherit from a host-app controller to plug into your existing auth.
129
+ # See "Custom Authentication" below. Defaults to "ActionController::Base".
130
+ # SolidQueueMonitor.base_controller_class = 'AdminController'
127
131
  ```
128
132
 
129
133
  ### Performance at Scale
@@ -159,6 +163,48 @@ config.username = -> { Rails.application.credentials.dig(:solid_queue_monitor, :
159
163
  config.password = -> { Rails.application.credentials.dig(:solid_queue_monitor, :password) }
160
164
  ```
161
165
 
166
+ ### Custom Authentication
167
+
168
+ By default, Solid Queue Monitor uses HTTP Basic auth with the username/password from `SolidQueueMonitor.setup`. To integrate with your app's existing auth (Devise, Pundit, OmniAuth, custom sessions, etc.), point the engine at a base controller from your host app:
169
+
170
+ ```ruby
171
+ # config/initializers/solid_queue_monitor.rb
172
+ SolidQueueMonitor.setup do |config|
173
+ config.authentication_enabled = false # disable HTTP Basic
174
+ end
175
+
176
+ # Inherit from your own controller so its before_actions, rescue_froms,
177
+ # layout, and current_user helper cascade into the engine.
178
+ SolidQueueMonitor.base_controller_class = 'AdminController'
179
+ ```
180
+
181
+ **Minimal example — just authenticate:**
182
+
183
+ ```ruby
184
+ class AdminController < ApplicationController
185
+ before_action :authenticate_user! # Devise (or your equivalent)
186
+ end
187
+ ```
188
+
189
+ **Richer example — require an admin role:**
190
+
191
+ ```ruby
192
+ class AdminController < ApplicationController
193
+ before_action :authenticate_user!
194
+ before_action :require_admin
195
+
196
+ private
197
+
198
+ def require_admin
199
+ redirect_to root_path, alert: 'Not authorized' unless current_user&.admin?
200
+ end
201
+ end
202
+ ```
203
+
204
+ Leave `authentication_enabled = true` if you want HTTP Basic to run *on top of* your host auth (host runs first, HTTP Basic second). Most adopters disable it.
205
+
206
+ Restart your server after changing this config — the class hierarchy is set at load time, so config changes won't take effect on a live process.
207
+
162
208
  ## Usage
163
209
 
164
210
  After installation, visit `/solid_queue` in your browser to access the dashboard.
@@ -208,9 +254,23 @@ This makes it easy to find specific jobs when debugging issues in your applicati
208
254
 
209
255
  ## Content Security Policy
210
256
 
211
- Solid Queue Monitor is compatible with strict Content Security Policy as of v1.3.0.
257
+ 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.
258
+
259
+ ### Strict CSP (v2.0.0+)
260
+
261
+ 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:
262
+
263
+ ```ruby
264
+ # config/initializers/content_security_policy.rb
265
+ Rails.application.config.content_security_policy do |policy|
266
+ policy.script_src :self
267
+ policy.style_src :self
268
+ end
269
+ ```
212
270
 
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`:
271
+ ### Nonce-based CSP
272
+
273
+ 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
274
 
215
275
  ```ruby
216
276
  # config/initializers/content_security_policy.rb
@@ -223,7 +283,9 @@ Rails.application.config.content_security_policy_nonce_generator = ->(req) { Sec
223
283
  Rails.application.config.content_security_policy_nonce_directives = %w[script-src style-src]
224
284
  ```
225
285
 
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.
286
+ ### Upgrading from v1.x
287
+
288
+ 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
289
 
228
290
  ## Contributing
229
291
 
@@ -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
@@ -1,12 +1,18 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module SolidQueueMonitor
4
- class ApplicationController < ActionController::Base
4
+ class ApplicationController < SolidQueueMonitor.base_controller_class.safe_constantize || ActionController::Base
5
5
  include ActionController::HttpAuthentication::Basic::ControllerMethods
6
6
  include ActionController::Flash
7
7
 
8
+ # Explicitly include the engine's helpers so they remain available when the
9
+ # host configures a custom base_controller_class. Rails auto-includes engine
10
+ # helpers only when the parent is ActionController::Base; inheriting from a
11
+ # host controller short-circuits that, breaking view methods like render_chart.
12
+ helper SolidQueueMonitor::Engine.helpers
13
+
8
14
  before_action :authenticate, if: -> { SolidQueueMonitor::AuthenticationService.authentication_required? }
9
- layout false
15
+ layout 'solid_queue_monitor/application'
10
16
  skip_before_action :verify_authenticity_token
11
17
 
12
18
  def set_flash_message(message, type)
@@ -17,7 +23,7 @@ module SolidQueueMonitor
17
23
  # Try to use Rails flash if available
18
24
  begin
19
25
  flash[:notice] = message if type == :success
20
- flash[:alert] = message if type == :error
26
+ flash[:alert] = message if type == :error
21
27
  rescue StandardError
22
28
  # Flash not available (e.g., no session middleware)
23
29
  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