solid_queue_monitor 1.2.1 → 1.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 6de5c6ef734c44e24c72e13d194fa9e6243fa5cefce591f430daa6f6dc1f175d
4
- data.tar.gz: 7065afd18960ef8f61b7131fa96fd7c46120b75c52c2b633a34f63b686b8ca2c
3
+ metadata.gz: 03dcf6baf00328ad18dadaaf499e82d178d7e62c7cc01433f48a1e222c245742
4
+ data.tar.gz: 15172ad03509c92f8d86b1c06cc78695203f125576838796d15c644c3b4a7eb0
5
5
  SHA512:
6
- metadata.gz: 6e899fddd4e1b08cf84beeff8b93f8ca67c199ce98d795983335300bf15fc087fe8ab0009d2a75746ae4d90b68f0b609f03c7ca44fb50ac0784551fc1e111a6b
7
- data.tar.gz: eecbd91a4eee221f13431810542e92c3fa06403d2279a00e447e186c00a4d34479c134461864c80274171493c985d83d4ea7ac351e93dc09d13238d00ee825d6
6
+ metadata.gz: 9b973471c7ac561b9b527dbb710610b41d452325fa7edfbd58f57f0454cd1d78a932e645854b12cbfc37b02b56f36006a1fe4fc243e23b03635bd6fef2e0baff
7
+ data.tar.gz: 56b6b3f15cbccedbee9782cd70efe8c1c2b75b47fba7d5b4e38535a3c17386a7f355a295a44de7305efd84c2aeefc39516535d885e3f9361960564202cd2dfba
data/README.md CHANGED
@@ -206,6 +206,25 @@ 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 compatible with strict Content Security Policy as of v1.3.0.
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`:
214
+
215
+ ```ruby
216
+ # config/initializers/content_security_policy.rb
217
+ Rails.application.config.content_security_policy do |policy|
218
+ policy.script_src :self
219
+ policy.style_src :self
220
+ end
221
+
222
+ Rails.application.config.content_security_policy_nonce_generator = ->(req) { SecureRandom.base64(16) }
223
+ Rails.application.config.content_security_policy_nonce_directives = %w[script-src style-src]
224
+ ```
225
+
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.
227
+
209
228
  ## Contributing
210
229
 
211
230
  Contributions are welcome! Here's how you can contribute:
@@ -28,7 +28,8 @@ module SolidQueueMonitor
28
28
  content: content,
29
29
  message: message,
30
30
  message_type: message_type,
31
- search_query: search_query
31
+ search_query: search_query,
32
+ nonce: content_security_policy_nonce
32
33
  ).generate
33
34
 
34
35
  render html: html.html_safe
@@ -13,7 +13,8 @@ module SolidQueueMonitor
13
13
  current_page: @failed_jobs[:current_page],
14
14
  total_pages: @failed_jobs[:total_pages],
15
15
  filters: filter_params,
16
- sort: sort_params).render)
16
+ sort: sort_params,
17
+ nonce: content_security_policy_nonce).render)
17
18
  end
18
19
 
19
20
  def retry
@@ -15,7 +15,8 @@ module SolidQueueMonitor
15
15
 
16
16
  render_page("Job ##{@job.id}", SolidQueueMonitor::JobDetailsPresenter.new(
17
17
  @job,
18
- **job_data
18
+ **job_data,
19
+ nonce: content_security_policy_nonce
19
20
  ).render)
20
21
  end
21
22
 
@@ -13,7 +13,8 @@ module SolidQueueMonitor
13
13
  current_page: @scheduled_jobs[:current_page],
14
14
  total_pages: @scheduled_jobs[:total_pages],
15
15
  filters: filter_params,
16
- sort: sort_params).render)
16
+ sort: sort_params,
17
+ nonce: content_security_policy_nonce).render)
17
18
  end
18
19
 
19
20
  def create
@@ -5,12 +5,13 @@ module SolidQueueMonitor
5
5
  include Rails.application.routes.url_helpers
6
6
  include SolidQueueMonitor::Engine.routes.url_helpers
7
7
 
8
- def initialize(jobs, current_page: 1, total_pages: 1, filters: {}, sort: {})
8
+ def initialize(jobs, current_page: 1, total_pages: 1, filters: {}, sort: {}, nonce: nil)
9
9
  @jobs = jobs
10
10
  @current_page = current_page
11
11
  @total_pages = total_pages
12
12
  @filters = filters
13
13
  @sort = sort
14
+ @nonce = nonce
14
15
  end
15
16
 
16
17
  def render
@@ -20,6 +21,10 @@ module SolidQueueMonitor
20
21
 
21
22
  private
22
23
 
24
+ def script_tag_open
25
+ @nonce ? %(<script nonce="#{@nonce}">) : '<script>'
26
+ end
27
+
23
28
  def generate_filter_form
24
29
  <<-HTML
25
30
  <div class="filter-form-container">
@@ -76,170 +81,83 @@ module SolidQueueMonitor
76
81
  </div>
77
82
  </form>
78
83
 
79
- <script>
84
+ #{script_tag_open}
80
85
  document.addEventListener('DOMContentLoaded', function() {
81
- // Handle select all checkboxes
82
- const selectAllHeader = document.getElementById('select-all');
83
- const checkboxes = document.querySelectorAll('.job-checkbox');
84
- const retrySelectedBtn = document.getElementById('retry-selected-top');
85
- const discardSelectedBtn = document.getElementById('discard-selected-top');
86
- const form = document.getElementById('failed-jobs-form');
87
- #{' '}
86
+ var selectAllHeader = document.getElementById('select-all');
87
+ var checkboxes = document.querySelectorAll('.job-checkbox');
88
+ var retrySelectedBtn = document.getElementById('retry-selected-top');
89
+ var discardSelectedBtn = document.getElementById('discard-selected-top');
90
+ var form = document.getElementById('failed-jobs-form');
91
+
88
92
  function updateButtonState() {
89
- const checkedBoxes = document.querySelectorAll('.job-checkbox:checked');
93
+ var checkedBoxes = document.querySelectorAll('.job-checkbox:checked');
90
94
  retrySelectedBtn.disabled = checkedBoxes.length === 0;
91
95
  discardSelectedBtn.disabled = checkedBoxes.length === 0;
92
96
  }
93
- #{' '}
94
- function toggleAll(checked) {
95
- checkboxes.forEach(checkbox => {
96
- checkbox.checked = checked;
97
- });
98
- selectAllHeader.checked = checked;
99
- updateButtonState();
100
- }
101
- #{' '}
97
+
102
98
  selectAllHeader.addEventListener('change', function() {
103
- toggleAll(this.checked);
99
+ checkboxes.forEach(function(cb) { cb.checked = selectAllHeader.checked; });
100
+ updateButtonState();
104
101
  });
105
- #{' '}
106
- checkboxes.forEach(checkbox => {
107
- checkbox.addEventListener('change', function() {
102
+
103
+ checkboxes.forEach(function(cb) {
104
+ cb.addEventListener('change', function() {
108
105
  updateButtonState();
109
- #{' '}
110
- // Update select all checkboxes if needed
111
- const allChecked = document.querySelectorAll('.job-checkbox:checked').length === checkboxes.length;
106
+ var allChecked = document.querySelectorAll('.job-checkbox:checked').length === checkboxes.length;
112
107
  selectAllHeader.checked = allChecked;
113
108
  });
114
109
  });
115
- #{' '}
116
- // Handle bulk actions
110
+
111
+ function bulkSubmit(action, promptMsg) {
112
+ var ids = Array.from(document.querySelectorAll('.job-checkbox:checked')).map(function(cb) { return cb.value; });
113
+ if (ids.length === 0) return;
114
+ if (!confirm(promptMsg)) return;
115
+ form.action = action;
116
+ appendHidden(form, 'redirect_cleanly', 'true');
117
+ ids.forEach(function(id) { appendHidden(form, 'job_ids[]', id); });
118
+ form.submit();
119
+ setTimeout(function() { window.history.replaceState({}, '', window.location.pathname); }, 100);
120
+ }
121
+
122
+ function appendHidden(f, name, value) {
123
+ var input = document.createElement('input');
124
+ input.type = 'hidden';
125
+ input.name = name;
126
+ input.value = value;
127
+ f.appendChild(input);
128
+ }
129
+
117
130
  retrySelectedBtn.addEventListener('click', function() {
118
- const selectedIds = Array.from(document.querySelectorAll('.job-checkbox:checked')).map(cb => cb.value);
119
- if (selectedIds.length === 0) return;
120
- #{' '}
121
- if (confirm('Are you sure you want to retry the selected jobs?')) {
122
- form.action = '#{retry_failed_jobs_path}';
123
- #{' '}
124
- // Add a special flag to indicate this should redirect properly
125
- const redirectInput = document.createElement('input');
126
- redirectInput.type = 'hidden';
127
- redirectInput.name = 'redirect_cleanly';
128
- redirectInput.value = 'true';
129
- form.appendChild(redirectInput);
130
- #{' '}
131
- // Add selected IDs as hidden inputs
132
- selectedIds.forEach(id => {
133
- const input = document.createElement('input');
134
- input.type = 'hidden';
135
- input.name = 'job_ids[]';
136
- input.value = id;
137
- form.appendChild(input);
138
- });
139
- #{' '}
140
- // Submit the form and then replace the URL location immediately after
141
- form.submit();
142
- #{' '}
143
- // Delay the redirect to give the form time to submit
144
- setTimeout(function() {
145
- // Reset to the clean URL without query parameters
146
- window.history.replaceState({}, '', window.location.pathname);
147
- }, 100);
148
- }
131
+ bulkSubmit('#{retry_failed_jobs_path}', 'Are you sure you want to retry the selected jobs?');
149
132
  });
150
- #{' '}
151
133
  discardSelectedBtn.addEventListener('click', function() {
152
- const selectedIds = Array.from(document.querySelectorAll('.job-checkbox:checked')).map(cb => cb.value);
153
- if (selectedIds.length === 0) return;
154
- #{' '}
155
- if (confirm('Are you sure you want to discard the selected jobs?')) {
156
- form.action = '#{discard_failed_jobs_path}';
157
- #{' '}
158
- // Add a special flag to indicate this should redirect properly
159
- const redirectInput = document.createElement('input');
160
- redirectInput.type = 'hidden';
161
- redirectInput.name = 'redirect_cleanly';
162
- redirectInput.value = 'true';
163
- form.appendChild(redirectInput);
164
- #{' '}
165
- // Add selected IDs as hidden inputs
166
- selectedIds.forEach(id => {
167
- const input = document.createElement('input');
168
- input.type = 'hidden';
169
- input.name = 'job_ids[]';
170
- input.value = id;
171
- form.appendChild(input);
172
- });
173
- #{' '}
174
- // Submit the form and then replace the URL location immediately after
175
- form.submit();
176
- #{' '}
177
- // Delay the redirect to give the form time to submit
178
- setTimeout(function() {
179
- // Reset to the clean URL without query parameters
180
- window.history.replaceState({}, '', window.location.pathname);
181
- }, 100);
134
+ bulkSubmit('#{discard_failed_jobs_path}', 'Are you sure you want to discard the selected jobs?');
135
+ });
136
+
137
+ function submitRowAction(action, id) {
138
+ var f = document.createElement('form');
139
+ f.method = 'post';
140
+ f.action = action.replace('PLACEHOLDER', id);
141
+ appendHidden(f, 'redirect_cleanly', 'true');
142
+ document.body.appendChild(f);
143
+ f.submit();
144
+ setTimeout(function() { window.history.replaceState({}, '', window.location.pathname); }, 100);
145
+ }
146
+
147
+ document.addEventListener('click', function(e) {
148
+ var btn = e.target.closest('[data-action]');
149
+ if (!btn) return;
150
+ var id = btn.dataset.jobId;
151
+ if (btn.dataset.action === 'retry-failed-job') {
152
+ submitRowAction('#{retry_failed_job_path(id: 'PLACEHOLDER')}', id);
153
+ } else if (btn.dataset.action === 'discard-failed-job') {
154
+ if (confirm('Are you sure you want to discard this job?')) {
155
+ submitRowAction('#{discard_failed_job_path(id: 'PLACEHOLDER')}', id);
156
+ }
182
157
  }
183
158
  });
184
- #{' '}
185
- // Initialize button state
159
+
186
160
  updateButtonState();
187
- #{' '}
188
- // Global function for retry action
189
- window.submitRetryForm = function(id) {
190
- const form = document.createElement('form');
191
- form.method = 'post';
192
- form.action = '#{retry_failed_job_path(id: 'PLACEHOLDER')}';
193
- form.action = form.action.replace('PLACEHOLDER', id);
194
- form.style.display = 'none';
195
- #{' '}
196
- // Add a special flag to indicate this should redirect properly
197
- const redirectInput = document.createElement('input');
198
- redirectInput.type = 'hidden';
199
- redirectInput.name = 'redirect_cleanly';
200
- redirectInput.value = 'true';
201
- form.appendChild(redirectInput);
202
- #{' '}
203
- document.body.appendChild(form);
204
- #{' '}
205
- // Submit the form and then replace the URL location immediately after
206
- form.submit();
207
- #{' '}
208
- // Delay the redirect to give the form time to submit
209
- setTimeout(function() {
210
- // Reset to the clean URL without query parameters
211
- window.history.replaceState({}, '', window.location.pathname);
212
- }, 100);
213
- };
214
- #{' '}
215
- // Global function for discard action
216
- window.submitDiscardForm = function(id) {
217
- if (confirm('Are you sure you want to discard this job?')) {
218
- const form = document.createElement('form');
219
- form.method = 'post';
220
- form.action = '#{discard_failed_job_path(id: 'PLACEHOLDER')}';
221
- form.action = form.action.replace('PLACEHOLDER', id);
222
- form.style.display = 'none';
223
- #{' '}
224
- // Add a special flag to indicate this should redirect properly
225
- const redirectInput = document.createElement('input');
226
- redirectInput.type = 'hidden';
227
- redirectInput.name = 'redirect_cleanly';
228
- redirectInput.value = 'true';
229
- form.appendChild(redirectInput);
230
- #{' '}
231
- document.body.appendChild(form);
232
- #{' '}
233
- // Submit the form and then replace the URL location immediately after
234
- form.submit();
235
- #{' '}
236
- // Delay the redirect to give the form time to submit
237
- setTimeout(function() {
238
- // Reset to the clean URL without query parameters
239
- window.history.replaceState({}, '', window.location.pathname);
240
- }, 100);
241
- }
242
- };
243
161
  });
244
162
  </script>
245
163
  HTML
@@ -268,13 +186,8 @@ module SolidQueueMonitor
268
186
  <td>#{format_datetime(failed_execution.created_at)}</td>
269
187
  <td class="actions-cell">
270
188
  <div class="job-actions">
271
- <a href="javascript:void(0)"#{' '}
272
- onclick="submitRetryForm(#{failed_execution.id})"#{' '}
273
- class="action-button retry-button">Retry</a>
274
- #{' '}
275
- <a href="javascript:void(0)"#{' '}
276
- onclick="submitDiscardForm(#{failed_execution.id})"#{' '}
277
- class="action-button discard-button">Discard</a>
189
+ <button type="button" data-action="retry-failed-job" data-job-id="#{failed_execution.id}" class="action-button retry-button">Retry</button>
190
+ <button type="button" data-action="discard-failed-job" data-job-id="#{failed_execution.id}" class="action-button discard-button">Discard</button>
278
191
  </div>
279
192
  </td>
280
193
  </tr>
@@ -3,13 +3,14 @@
3
3
  module SolidQueueMonitor
4
4
  class JobDetailsPresenter < BasePresenter
5
5
  def initialize(job, failed_execution: nil, claimed_execution: nil, scheduled_execution: nil,
6
- recent_executions: [], back_path: nil)
6
+ recent_executions: [], back_path: nil, nonce: nil)
7
7
  @job = job
8
8
  @failed_execution = failed_execution
9
9
  @claimed_execution = claimed_execution
10
10
  @scheduled_execution = scheduled_execution
11
11
  @recent_executions = recent_executions
12
12
  @back_path = back_path
13
+ @nonce = nonce
13
14
  calculate_timing
14
15
  end
15
16
 
@@ -32,6 +33,10 @@ module SolidQueueMonitor
32
33
 
33
34
  private
34
35
 
36
+ def script_tag_open
37
+ @nonce ? %(<script nonce="#{@nonce}">) : '<script>'
38
+ end
39
+
35
40
  def calculate_timing
36
41
  @created_at = @job.created_at
37
42
  @scheduled_at = @job.scheduled_at || @scheduled_execution&.scheduled_at
@@ -139,7 +144,7 @@ module SolidQueueMonitor
139
144
 
140
145
  actions << <<-HTML
141
146
  <form action="#{discard_failed_job_path(id: @failed_execution.id)}" method="post" class="inline-form"
142
- onsubmit="return confirm('Are you sure you want to discard this job?');">
147
+ data-confirm="Are you sure you want to discard this job?">
143
148
  <input type="hidden" name="redirect_to" value="#{failed_jobs_path}">
144
149
  <button type="submit" class="action-button discard-button">Discard</button>
145
150
  </form>
@@ -301,7 +306,7 @@ module SolidQueueMonitor
301
306
  <div class="job-section error-section">
302
307
  <div class="section-header">
303
308
  <h3 class="section-title">Error</h3>
304
- <button class="copy-button" onclick="copyToClipboard('error-content')">
309
+ <button class="copy-button" data-action="copy" data-target="error-content">
305
310
  <svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
306
311
  <rect x="9" y="9" width="13" height="13" rx="2" ry="2"></rect>
307
312
  <path d="M5 15H4a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2h9a2 2 0 0 1 2 2v1"></path>
@@ -329,22 +334,13 @@ module SolidQueueMonitor
329
334
  <div class="backtrace-header">
330
335
  <span class="backtrace-title">Backtrace</span>
331
336
  <div class="backtrace-toggle">
332
- <button class="toggle-btn active" data-target="app-backtrace" onclick="showBacktrace('app')">App Only</button>
333
- <button class="toggle-btn" data-target="full-backtrace" onclick="showBacktrace('full')">Full</button>
337
+ <button class="toggle-btn active" data-action="show-backtrace" data-backtrace="app">App Only</button>
338
+ <button class="toggle-btn" data-action="show-backtrace" data-backtrace="full">Full</button>
334
339
  </div>
335
340
  </div>
336
341
  <pre class="backtrace-content" id="app-backtrace">#{format_backtrace_lines(app_lines.presence || lines.first(5))}</pre>
337
- <pre class="backtrace-content" id="full-backtrace" style="display: none;">#{format_backtrace_lines(lines)}</pre>
342
+ <pre class="backtrace-content is-hidden" id="full-backtrace">#{format_backtrace_lines(lines)}</pre>
338
343
  </div>
339
- <script>
340
- function showBacktrace(type) {
341
- document.getElementById('app-backtrace').style.display = type === 'app' ? 'block' : 'none';
342
- document.getElementById('full-backtrace').style.display = type === 'full' ? 'block' : 'none';
343
- document.querySelectorAll('.backtrace-toggle .toggle-btn').forEach(btn => {
344
- btn.classList.toggle('active', btn.dataset.target === type + '-backtrace');
345
- });
346
- }
347
- </script>
348
344
  HTML
349
345
  end
350
346
 
@@ -426,7 +422,7 @@ module SolidQueueMonitor
426
422
  <div class="section-header">
427
423
  <h3 class="section-title">Arguments</h3>
428
424
  <div class="section-actions">
429
- <button class="copy-button" onclick="copyToClipboard('arguments-content')">
425
+ <button class="copy-button" data-action="copy" data-target="arguments-content">
430
426
  <svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
431
427
  <rect x="9" y="9" width="13" height="13" rx="2" ry="2"></rect>
432
428
  <path d="M5 15H4a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2h9a2 2 0 0 1 2 2v1"></path>
@@ -647,14 +643,14 @@ module SolidQueueMonitor
647
643
  def render_raw_data_section
648
644
  <<-HTML
649
645
  <div class="job-section collapsible-section">
650
- <div class="section-header collapsible-header" onclick="toggleSection(this)">
646
+ <div class="section-header collapsible-header" data-action="toggle-section">
651
647
  <div class="collapsible-title">
652
648
  <svg class="collapse-icon" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
653
649
  <polyline points="9 18 15 12 9 6"></polyline>
654
650
  </svg>
655
651
  <h3 class="section-title">Raw Data</h3>
656
652
  </div>
657
- <button class="copy-button" onclick="event.stopPropagation(); copyToClipboard('raw-data-content')">
653
+ <button class="copy-button" data-action="copy" data-target="raw-data-content" data-stop-propagation="true">
658
654
  <svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
659
655
  <rect x="9" y="9" width="13" height="13" rx="2" ry="2"></rect>
660
656
  <path d="M5 15H4a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2h9a2 2 0 0 1 2 2v1"></path>
@@ -662,33 +658,48 @@ module SolidQueueMonitor
662
658
  Copy
663
659
  </button>
664
660
  </div>
665
- <div class="collapsible-content" style="display: none;">
661
+ <div class="collapsible-content">
666
662
  <pre class="raw-data-content" id="raw-data-content">#{CGI.escapeHTML(JSON.pretty_generate(@job.attributes))}</pre>
667
663
  </div>
668
664
  </div>
669
- <script>
670
- function toggleSection(header) {
671
- const content = header.nextElementSibling;
672
- const icon = header.querySelector('.collapse-icon');
673
- if (content.style.display === 'none') {
674
- content.style.display = 'block';
675
- icon.style.transform = 'rotate(90deg)';
676
- } else {
677
- content.style.display = 'none';
678
- icon.style.transform = 'rotate(0deg)';
679
- }
680
- }
681
-
682
- function copyToClipboard(elementId) {
683
- const element = document.getElementById(elementId);
684
- const text = element.innerText || element.textContent;
685
- navigator.clipboard.writeText(text).then(() => {
686
- const btn = event.target.closest('.copy-button');
687
- const originalText = btn.innerHTML;
688
- btn.innerHTML = '<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><polyline points="20 6 9 17 4 12"></polyline></svg> Copied!';
689
- setTimeout(() => { btn.innerHTML = originalText; }, 2000);
665
+ #{script_tag_open}
666
+ (function() {
667
+ document.addEventListener('click', function(e) {
668
+ var el = e.target.closest('[data-action]');
669
+ if (!el) return;
670
+
671
+ if (el.dataset.stopPropagation === 'true') e.stopPropagation();
672
+
673
+ var action = el.dataset.action;
674
+
675
+ if (action === 'copy') {
676
+ var target = document.getElementById(el.dataset.target);
677
+ if (!target) return;
678
+ var text = target.innerText || target.textContent;
679
+ navigator.clipboard.writeText(text).then(function() {
680
+ var original = el.innerHTML;
681
+ el.innerHTML = '<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><polyline points="20 6 9 17 4 12"></polyline></svg> Copied!';
682
+ setTimeout(function() { el.innerHTML = original; }, 2000);
683
+ });
684
+ }
685
+
686
+ if (action === 'show-backtrace') {
687
+ var which = el.dataset.backtrace;
688
+ var appEl = document.getElementById('app-backtrace');
689
+ var fullEl = document.getElementById('full-backtrace');
690
+ if (appEl) appEl.classList.toggle('is-hidden', which !== 'app');
691
+ if (fullEl) fullEl.classList.toggle('is-hidden', which !== 'full');
692
+ document.querySelectorAll('[data-action="show-backtrace"]').forEach(function(btn) {
693
+ btn.classList.toggle('active', btn.dataset.backtrace === which);
694
+ });
695
+ }
696
+
697
+ if (action === 'toggle-section') {
698
+ var section = el.closest('.collapsible-section');
699
+ if (section) section.classList.toggle('is-expanded');
700
+ }
690
701
  });
691
- }
702
+ })();
692
703
  </script>
693
704
  HTML
694
705
  end
@@ -119,7 +119,7 @@ module SolidQueueMonitor
119
119
  </form>
120
120
 
121
121
  <form method="post" action="#{discard_failed_job_path(id: failed_execution.id)}" class="inline-form"
122
- onsubmit="return confirm('Are you sure you want to discard this job?');">
122
+ data-confirm="Are you sure you want to discard this job?">
123
123
  <input type="hidden" name="redirect_to" value="#{root_path}">
124
124
  <button type="submit" class="action-button discard-button">Discard</button>
125
125
  </form>
@@ -53,7 +53,7 @@ module SolidQueueMonitor
53
53
  else
54
54
  <<-HTML
55
55
  <form action="#{pause_queue_path}" method="post" class="inline-form"
56
- onsubmit="return confirm('Are you sure you want to pause this queue?');">
56
+ data-confirm="Are you sure you want to pause this queue?">
57
57
  <input type="hidden" name="queue_name" value="#{@queue_name}">
58
58
  <input type="hidden" name="redirect_to" value="#{queue_details_path(queue_name: @queue_name)}">
59
59
  <button type="submit" class="action-button pause-button">Pause Queue</button>
@@ -170,7 +170,7 @@ module SolidQueueMonitor
170
170
  <button type="submit" class="action-button retry-button">Retry</button>
171
171
  </form>
172
172
  <form method="post" action="#{discard_failed_job_path(id: failed_execution.id)}" class="inline-form"
173
- onsubmit="return confirm('Are you sure you want to discard this job?');">
173
+ data-confirm="Are you sure you want to discard this job?">
174
174
  <input type="hidden" name="redirect_to" value="#{queue_details_path(queue_name: @queue_name)}">
175
175
  <button type="submit" class="action-button discard-button">Discard</button>
176
176
  </form>
@@ -76,7 +76,7 @@ module SolidQueueMonitor
76
76
  else
77
77
  <<-HTML
78
78
  <form action="#{pause_queue_path}" method="post" class="inline-form"
79
- onsubmit="return confirm('Are you sure you want to pause the #{queue_name} queue? Workers will stop processing jobs from this queue.');">
79
+ data-confirm="Are you sure you want to pause the #{queue_name} queue? Workers will stop processing jobs from this queue.">
80
80
  <input type="hidden" name="queue_name" value="#{queue_name}">
81
81
  <button type="submit" class="action-button pause-button" title="Pause queue processing">
82
82
  Pause
@@ -5,12 +5,13 @@ module SolidQueueMonitor
5
5
  include Rails.application.routes.url_helpers
6
6
  include SolidQueueMonitor::Engine.routes.url_helpers
7
7
 
8
- def initialize(jobs, current_page: 1, total_pages: 1, filters: {}, sort: {})
8
+ def initialize(jobs, current_page: 1, total_pages: 1, filters: {}, sort: {}, nonce: nil)
9
9
  @jobs = jobs
10
10
  @current_page = current_page
11
11
  @total_pages = total_pages
12
12
  @filters = filters
13
13
  @sort = sort
14
+ @nonce = nonce
14
15
  end
15
16
 
16
17
  def render
@@ -19,6 +20,10 @@ module SolidQueueMonitor
19
20
 
20
21
  private
21
22
 
23
+ def script_tag_open
24
+ @nonce ? %(<script nonce="#{@nonce}">) : '<script>'
25
+ end
26
+
22
27
  def generate_filter_form
23
28
  <<-HTML
24
29
  <div class="filter-form-container">
@@ -57,7 +62,7 @@ module SolidQueueMonitor
57
62
  <form id="scheduled-jobs-form" method="POST">
58
63
  #{generate_table}
59
64
  </form>
60
- <script>
65
+ #{script_tag_open}
61
66
  document.addEventListener('DOMContentLoaded', function() {
62
67
  const selectAllCheckbox = document.querySelector('th input[type="checkbox"]');
63
68
  const jobCheckboxes = document.getElementsByName('job_ids[]');
@@ -104,12 +104,17 @@ module SolidQueueMonitor
104
104
  def prune_all_link
105
105
  return '' if @dead_count.zero?
106
106
 
107
+ suffix = @dead_count > 1 ? 'es' : ''
108
+ message = "Remove all #{@dead_count} dead process#{suffix}? " \
109
+ 'This will clean up processes that have stopped sending heartbeats.'
110
+
107
111
  <<-HTML
108
112
  <a href="#" class="summary-action"
109
- onclick="if(confirm('Remove all #{@dead_count} dead process#{@dead_count > 1 ? 'es' : ''}? This will clean up processes that have stopped sending heartbeats.')) { document.getElementById('prune-all-form').submit(); } return false;">
113
+ data-confirm-submit="prune-all-form"
114
+ data-confirm="#{CGI.escapeHTML(message)}">
110
115
  Prune all
111
116
  </a>
112
- <form id="prune-all-form" action="#{prune_workers_path}" method="post" style="display: none;"></form>
117
+ <form id="prune-all-form" action="#{prune_workers_path}" method="post" class="is-hidden"></form>
113
118
  HTML
114
119
  end
115
120
 
@@ -185,7 +190,7 @@ module SolidQueueMonitor
185
190
 
186
191
  <<-HTML
187
192
  <form action="#{remove_worker_path(id: process.id)}" method="post" class="inline-form"
188
- onsubmit="return confirm('Remove this dead process from the registry?');">
193
+ data-confirm="Remove this dead process from the registry?">
189
194
  <button type="submit" class="action-button discard-button" title="Remove dead process">
190
195
  Remove
191
196
  </button>
@@ -85,10 +85,10 @@ module SolidQueueMonitor
85
85
  def bucket_index_expr(column, start_epoch, interval_seconds)
86
86
  if adapter?('sqlite')
87
87
  "CAST((CAST(strftime('%s', #{column}) AS INTEGER) - #{start_epoch}) / #{interval_seconds} AS INTEGER)"
88
- elsif adapter?('mysql')
89
- "CAST((UNIX_TIMESTAMP(#{column}) - #{start_epoch}) / #{interval_seconds} AS SIGNED)"
88
+ elsif adapter?('mysql') || adapter?('trilogy')
89
+ "FLOOR((UNIX_TIMESTAMP(#{column}) - #{start_epoch}) / #{interval_seconds})"
90
90
  else
91
- "CAST((EXTRACT(EPOCH FROM #{column}) - #{start_epoch}) / #{interval_seconds} AS INTEGER)"
91
+ "FLOOR((EXTRACT(EPOCH FROM #{column}) - #{start_epoch}) / #{interval_seconds})::integer"
92
92
  end
93
93
  end
94
94
 
@@ -66,7 +66,7 @@ module SolidQueueMonitor
66
66
 
67
67
  <<-HTML
68
68
  <div class="chart-time-select-wrapper">
69
- <select class="chart-time-select" id="chart-time-select" onchange="window.location.href='?time_range=' + this.value">
69
+ <select class="chart-time-select" id="chart-time-select">
70
70
  #{options}
71
71
  </select>
72
72
  </div>
@@ -212,15 +212,15 @@ module SolidQueueMonitor
212
212
  <<-HTML
213
213
  <div class="chart-legend">
214
214
  <span class="legend-item">
215
- <span class="legend-color" style="background-color: #{COLORS[:created]}"></span>
215
+ <span class="legend-color legend-color-created"></span>
216
216
  Created
217
217
  </span>
218
218
  <span class="legend-item">
219
- <span class="legend-color" style="background-color: #{COLORS[:completed]}"></span>
219
+ <span class="legend-color legend-color-completed"></span>
220
220
  Completed
221
221
  </span>
222
222
  <span class="legend-item">
223
- <span class="legend-color" style="background-color: #{COLORS[:failed]}"></span>
223
+ <span class="legend-color legend-color-failed"></span>
224
224
  Failed
225
225
  </span>
226
226
  </div>
@@ -229,7 +229,7 @@ module SolidQueueMonitor
229
229
 
230
230
  def render_tooltip
231
231
  <<-HTML
232
- <div id="chart-tooltip" class="chart-tooltip" style="display: none;">
232
+ <div id="chart-tooltip" class="chart-tooltip">
233
233
  <div class="tooltip-label"></div>
234
234
  <div class="tooltip-value"></div>
235
235
  </div>
@@ -5,12 +5,13 @@ module SolidQueueMonitor
5
5
  include Rails.application.routes.url_helpers
6
6
  include SolidQueueMonitor::Engine.routes.url_helpers
7
7
 
8
- def initialize(title:, content:, message: nil, message_type: nil, search_query: nil)
8
+ def initialize(title:, content:, message: nil, message_type: nil, search_query: nil, nonce: nil)
9
9
  @title = title
10
10
  @content = content
11
11
  @message = message
12
12
  @message_type = message_type
13
13
  @search_query = search_query
14
+ @nonce = nonce
14
15
  end
15
16
 
16
17
  def generate
@@ -34,7 +35,7 @@ module SolidQueueMonitor
34
35
  <<-HTML
35
36
  <meta charset="UTF-8">
36
37
  <meta name="viewport" content="width=device-width, initial-scale=1.0">
37
- <style>
38
+ #{style_tag_open}
38
39
  #{SolidQueueMonitor::StylesheetGenerator.new.generate}
39
40
  </style>
40
41
  HTML
@@ -62,27 +63,14 @@ module SolidQueueMonitor
62
63
  class_name = @message_type == 'success' ? 'message-success' : 'message-error'
63
64
  <<-HTML
64
65
  <div id="flash-message" class="message #{class_name}">#{@message}</div>
65
- <script>
66
- // Automatically hide the flash message after 5 seconds
66
+ #{script_tag_open}
67
67
  document.addEventListener('DOMContentLoaded', function() {
68
- var flashMessage = document.getElementById('flash-message');
69
- if (flashMessage) {
70
- setTimeout(function() {
71
- flashMessage.style.opacity = '1';
72
- // Fade out animation
73
- var fadeEffect = setInterval(function() {
74
- if (!flashMessage.style.opacity) {
75
- flashMessage.style.opacity = 1;
76
- }
77
- if (flashMessage.style.opacity > 0) {
78
- flashMessage.style.opacity -= 0.1;
79
- } else {
80
- clearInterval(fadeEffect);
81
- flashMessage.style.display = 'none';
82
- }
83
- }, 50);
84
- }, 5000); // 5 seconds
85
- }
68
+ var el = document.getElementById('flash-message');
69
+ if (!el) return;
70
+ setTimeout(function() {
71
+ el.classList.add('is-fading');
72
+ setTimeout(function() { el.classList.add('is-hidden'); }, 500);
73
+ }, 5000);
86
74
  });
87
75
  </script>
88
76
  HTML
@@ -149,6 +137,14 @@ module SolidQueueMonitor
149
137
  text.to_s.gsub('&', '&amp;').gsub('<', '&lt;').gsub('>', '&gt;').gsub('"', '&quot;')
150
138
  end
151
139
 
140
+ def style_tag_open
141
+ @nonce ? %(<style nonce="#{@nonce}">) : '<style>'
142
+ end
143
+
144
+ def script_tag_open
145
+ @nonce ? %(<script nonce="#{@nonce}">) : '<script>'
146
+ end
147
+
152
148
  def generate_auto_refresh_controls
153
149
  return '' unless SolidQueueMonitor.auto_refresh_enabled
154
150
 
@@ -194,7 +190,7 @@ module SolidQueueMonitor
194
190
  def generate_auto_refresh_script
195
191
  return '' unless SolidQueueMonitor.auto_refresh_enabled
196
192
 
197
- "<script>#{auto_refresh_javascript}</script>"
193
+ "#{script_tag_open}#{auto_refresh_javascript}</script>"
198
194
  end
199
195
 
200
196
  def auto_refresh_javascript
@@ -229,7 +225,7 @@ module SolidQueueMonitor
229
225
  if (indicator) indicator.classList.toggle('active', isEnabled);
230
226
  if (countdownEl) {
231
227
  countdownEl.textContent = countdown + 's';
232
- countdownEl.style.opacity = isEnabled ? '1' : '0.4';
228
+ countdownEl.classList.toggle('countdown-paused', !isEnabled);
233
229
  }
234
230
  }
235
231
  function tick() {
@@ -270,9 +266,10 @@ module SolidQueueMonitor
270
266
 
271
267
  def generate_chart_script
272
268
  <<-HTML
273
- <script>
269
+ #{script_tag_open}
274
270
  #{theme_toggle_javascript}
275
271
  #{chart_tooltip_javascript}
272
+ #{global_behaviors_javascript}
276
273
  </script>
277
274
  HTML
278
275
  end
@@ -363,7 +360,7 @@ module SolidQueueMonitor
363
360
 
364
361
  tooltip.querySelector('.tooltip-label').textContent = label;
365
362
  tooltip.querySelector('.tooltip-value').textContent = seriesNames[series] + ': ' + value;
366
- tooltip.style.display = 'block';
363
+ tooltip.classList.add('tooltip-visible');
367
364
  positionTooltip(e);
368
365
  });
369
366
 
@@ -372,7 +369,7 @@ module SolidQueueMonitor
372
369
  });
373
370
 
374
371
  point.addEventListener('mouseleave', function() {
375
- tooltip.style.display = 'none';
372
+ tooltip.classList.remove('tooltip-visible');
376
373
  });
377
374
  });
378
375
 
@@ -387,6 +384,7 @@ module SolidQueueMonitor
387
384
  y = e.clientY + 10;
388
385
  }
389
386
 
387
+ // Dynamic cursor-tracked position, not CSP-restricted.
390
388
  tooltip.style.left = x + 'px';
391
389
  tooltip.style.top = y + 'px';
392
390
  }
@@ -394,6 +392,34 @@ module SolidQueueMonitor
394
392
  JS
395
393
  end
396
394
 
395
+ def global_behaviors_javascript
396
+ <<-JS
397
+ document.addEventListener('submit', function(e) {
398
+ var form = e.target;
399
+ var msg = form.dataset && form.dataset.confirm;
400
+ if (msg && !window.confirm(msg)) { e.preventDefault(); }
401
+ }, true);
402
+
403
+ document.addEventListener('click', function(e) {
404
+ var el = e.target.closest('[data-confirm-submit]');
405
+ if (!el) return;
406
+ e.preventDefault();
407
+ var msg = el.dataset.confirm || 'Are you sure?';
408
+ if (!window.confirm(msg)) return;
409
+ var formId = el.dataset.confirmSubmit;
410
+ var form = document.getElementById(formId);
411
+ if (form) form.submit();
412
+ });
413
+
414
+ var timeRangeSelect = document.getElementById('chart-time-select');
415
+ if (timeRangeSelect) {
416
+ timeRangeSelect.addEventListener('change', function() {
417
+ window.location.href = '?time_range=' + this.value;
418
+ });
419
+ }
420
+ JS
421
+ end
422
+
397
423
  def default_url_options
398
424
  { only_path: true }
399
425
  end
@@ -1088,6 +1088,18 @@ module SolidQueueMonitor
1088
1088
  border-radius: 2px;
1089
1089
  }
1090
1090
 
1091
+ .solid_queue_monitor .legend-color-created {
1092
+ background-color: #3b82f6;
1093
+ }
1094
+
1095
+ .solid_queue_monitor .legend-color-completed {
1096
+ background-color: #10b981;
1097
+ }
1098
+
1099
+ .solid_queue_monitor .legend-color-failed {
1100
+ background-color: #ef4444;
1101
+ }
1102
+
1091
1103
  .solid_queue_monitor .chart-tooltip {
1092
1104
  position: fixed;
1093
1105
  background: #1f2937;
@@ -2017,6 +2029,51 @@ module SolidQueueMonitor
2017
2029
  grid-template-columns: 1fr;
2018
2030
  }
2019
2031
  }
2032
+
2033
+ /* ===== CSP Phase 1 utility classes (replace runtime style mutations) ===== */
2034
+
2035
+ .is-hidden {
2036
+ display: none !important;
2037
+ }
2038
+
2039
+ .countdown-paused {
2040
+ opacity: 0.4;
2041
+ }
2042
+
2043
+ .is-expanded .collapse-icon {
2044
+ transform: rotate(90deg);
2045
+ }
2046
+
2047
+ .collapse-icon {
2048
+ transition: transform 150ms ease;
2049
+ }
2050
+
2051
+ .collapsible-content {
2052
+ display: none;
2053
+ }
2054
+
2055
+ .is-expanded .collapsible-content {
2056
+ display: block;
2057
+ }
2058
+
2059
+ .chart-tooltip {
2060
+ display: none;
2061
+ position: fixed;
2062
+ pointer-events: none;
2063
+ }
2064
+
2065
+ .chart-tooltip.tooltip-visible {
2066
+ display: block;
2067
+ }
2068
+
2069
+ #flash-message {
2070
+ opacity: 1;
2071
+ transition: opacity 500ms ease;
2072
+ }
2073
+
2074
+ #flash-message.is-fading {
2075
+ opacity: 0;
2076
+ }
2020
2077
  CSS
2021
2078
  end
2022
2079
  end
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module SolidQueueMonitor
4
- VERSION = '1.2.1'
4
+ VERSION = '1.3.0'
5
5
  end
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: solid_queue_monitor
3
3
  version: !ruby/object:Gem::Version
4
- version: 1.2.1
4
+ version: 1.3.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Vishal Sadriya