Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
16 commits
Select commit Hold shift + click to select a range
827bd14
feat(css): add CSP-compatible utility classes for state toggles
vishaltps Apr 16, 2026
bd26663
feat(csp): thread nonce through HtmlGenerator and base controller
vishaltps Apr 16, 2026
fd06e65
feat(csp): thread nonce into script-emitting presenters
vishaltps Apr 16, 2026
d0206d4
test(csp): unskip nonce specs — route helpers work at presenter level
vishaltps Apr 16, 2026
e9a5798
refactor(csp): replace .style.* with class toggles in HtmlGenerator s…
vishaltps Apr 16, 2026
aaeda03
refactor(csp): remove onchange= from chart time-range select
vishaltps Apr 16, 2026
9b0805b
refactor(csp): replace onclick= and window.submit* with event delegat…
vishaltps Apr 16, 2026
b5cecc3
refactor(csp): replace onclick/onsubmit with data-action delegation i…
vishaltps Apr 16, 2026
7f32ca1
refactor(csp): replace onsubmit=confirm() with data-confirm across pr…
vishaltps Apr 16, 2026
cf6abf5
refactor(csp): move prune-all inline onclick to data-confirm-submit d…
vishaltps Apr 16, 2026
9899e95
test(csp): request spec for no inline handlers and nonce propagation
vishaltps Apr 16, 2026
b3f5486
chore: release 1.3.0 — CSP nonce support
vishaltps Apr 16, 2026
56d74f1
chore(csp): remove redundant style=display:none; on chart tooltip; bu…
vishaltps Apr 16, 2026
75483de
refactor(csp): replace inline style attrs on chart legend with CSS cl…
vishaltps Apr 20, 2026
8788884
fix(charts): use FLOOR for bucket assignment on PostgreSQL and MySQL
vishaltps Apr 20, 2026
1e6cead
style: fix rubocop offenses on csp-compatibility branch
vishaltps Apr 20, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
16 changes: 16 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -1,5 +1,21 @@
# Changelog

## [1.3.0] - 2026-04-16

### Added

- Content Security Policy compatibility for nonce-based strict CSP. The monitor now reads `content_security_policy_nonce` from the host controller and stamps it onto every inline `<style>` and `<script>` tag emitted by the engine. When no nonce is configured, HTML is byte-identical to prior versions. ([#36](https://github.com/vishaltps/solid_queue_monitor/issues/36))

### Changed

- Removed all inline `onclick`, `onchange`, and `onsubmit` attributes. Behavior is now wired via `addEventListener` and `data-*` attributes.
- Replaced runtime `element.style.display|opacity|transform` mutations with CSS class toggles (`.is-hidden`, `.is-fading`, `.is-expanded`, `.tooltip-visible`, `.countdown-paused`). Tooltip cursor-tracking position is unchanged.
- Consolidated confirm-on-submit dialogs into a single `data-confirm="…"` pattern.

### Notes

No host-app changes required. Apps already using nonce-based CSP will see the UI start working once they upgrade. Apps without CSP continue to receive identical output.

## [1.2.2] - 2026-03-30

### Added
Expand Down
2 changes: 1 addition & 1 deletion Gemfile.lock
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
PATH
remote: .
specs:
solid_queue_monitor (1.2.1)
solid_queue_monitor (1.3.0)
rails (>= 7.0)
solid_queue (>= 0.1.0)

Expand Down
19 changes: 19 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -206,6 +206,25 @@ This makes it easy to find specific jobs when debugging issues in your applicati
- **Rails**: 7.1 or higher
- **Solid Queue**: 0.1.0 or higher

## Content Security Policy

Solid Queue Monitor is compatible with strict Content Security Policy as of v1.3.0.

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`:

```ruby
# config/initializers/content_security_policy.rb
Rails.application.config.content_security_policy do |policy|
policy.script_src :self
policy.style_src :self
end

Rails.application.config.content_security_policy_nonce_generator = ->(req) { SecureRandom.base64(16) }
Rails.application.config.content_security_policy_nonce_directives = %w[script-src style-src]
```

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.

## Contributing

Contributions are welcome! Here's how you can contribute:
Expand Down
3 changes: 2 additions & 1 deletion app/controllers/solid_queue_monitor/base_controller.rb
Original file line number Diff line number Diff line change
Expand Up @@ -28,7 +28,8 @@ def render_page(title, content, search_query: nil)
content: content,
message: message,
message_type: message_type,
search_query: search_query
search_query: search_query,
nonce: content_security_policy_nonce
).generate

render html: html.html_safe
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,8 @@ def index
current_page: @failed_jobs[:current_page],
total_pages: @failed_jobs[:total_pages],
filters: filter_params,
sort: sort_params).render)
sort: sort_params,
nonce: content_security_policy_nonce).render)
end

def retry
Expand Down
3 changes: 2 additions & 1 deletion app/controllers/solid_queue_monitor/jobs_controller.rb
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,8 @@ def show

render_page("Job ##{@job.id}", SolidQueueMonitor::JobDetailsPresenter.new(
@job,
**job_data
**job_data,
nonce: content_security_policy_nonce
).render)
end

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,8 @@ def index
current_page: @scheduled_jobs[:current_page],
total_pages: @scheduled_jobs[:total_pages],
filters: filter_params,
sort: sort_params).render)
sort: sort_params,
nonce: content_security_policy_nonce).render)
end

def create
Expand Down
223 changes: 68 additions & 155 deletions app/presenters/solid_queue_monitor/failed_jobs_presenter.rb
Original file line number Diff line number Diff line change
Expand Up @@ -5,12 +5,13 @@ class FailedJobsPresenter < BasePresenter
include Rails.application.routes.url_helpers
include SolidQueueMonitor::Engine.routes.url_helpers

def initialize(jobs, current_page: 1, total_pages: 1, filters: {}, sort: {})
def initialize(jobs, current_page: 1, total_pages: 1, filters: {}, sort: {}, nonce: nil)
@jobs = jobs
@current_page = current_page
@total_pages = total_pages
@filters = filters
@sort = sort
@nonce = nonce
end

def render
Expand All @@ -20,6 +21,10 @@ def render

private

def script_tag_open
@nonce ? %(<script nonce="#{@nonce}">) : '<script>'
end

def generate_filter_form
<<-HTML
<div class="filter-form-container">
Expand Down Expand Up @@ -76,170 +81,83 @@ def generate_table
</div>
</form>

<script>
#{script_tag_open}
document.addEventListener('DOMContentLoaded', function() {
// Handle select all checkboxes
const selectAllHeader = document.getElementById('select-all');
const checkboxes = document.querySelectorAll('.job-checkbox');
const retrySelectedBtn = document.getElementById('retry-selected-top');
const discardSelectedBtn = document.getElementById('discard-selected-top');
const form = document.getElementById('failed-jobs-form');
#{' '}
var selectAllHeader = document.getElementById('select-all');
var checkboxes = document.querySelectorAll('.job-checkbox');
var retrySelectedBtn = document.getElementById('retry-selected-top');
var discardSelectedBtn = document.getElementById('discard-selected-top');
var form = document.getElementById('failed-jobs-form');

function updateButtonState() {
const checkedBoxes = document.querySelectorAll('.job-checkbox:checked');
var checkedBoxes = document.querySelectorAll('.job-checkbox:checked');
retrySelectedBtn.disabled = checkedBoxes.length === 0;
discardSelectedBtn.disabled = checkedBoxes.length === 0;
}
#{' '}
function toggleAll(checked) {
checkboxes.forEach(checkbox => {
checkbox.checked = checked;
});
selectAllHeader.checked = checked;
updateButtonState();
}
#{' '}

selectAllHeader.addEventListener('change', function() {
toggleAll(this.checked);
checkboxes.forEach(function(cb) { cb.checked = selectAllHeader.checked; });
updateButtonState();
});
#{' '}
checkboxes.forEach(checkbox => {
checkbox.addEventListener('change', function() {

checkboxes.forEach(function(cb) {
cb.addEventListener('change', function() {
updateButtonState();
#{' '}
// Update select all checkboxes if needed
const allChecked = document.querySelectorAll('.job-checkbox:checked').length === checkboxes.length;
var allChecked = document.querySelectorAll('.job-checkbox:checked').length === checkboxes.length;
selectAllHeader.checked = allChecked;
});
});
#{' '}
// Handle bulk actions

function bulkSubmit(action, promptMsg) {
var ids = Array.from(document.querySelectorAll('.job-checkbox:checked')).map(function(cb) { return cb.value; });
if (ids.length === 0) return;
if (!confirm(promptMsg)) return;
form.action = action;
appendHidden(form, 'redirect_cleanly', 'true');
ids.forEach(function(id) { appendHidden(form, 'job_ids[]', id); });
form.submit();
setTimeout(function() { window.history.replaceState({}, '', window.location.pathname); }, 100);
}

function appendHidden(f, name, value) {
var input = document.createElement('input');
input.type = 'hidden';
input.name = name;
input.value = value;
f.appendChild(input);
}

retrySelectedBtn.addEventListener('click', function() {
const selectedIds = Array.from(document.querySelectorAll('.job-checkbox:checked')).map(cb => cb.value);
if (selectedIds.length === 0) return;
#{' '}
if (confirm('Are you sure you want to retry the selected jobs?')) {
form.action = '#{retry_failed_jobs_path}';
#{' '}
// Add a special flag to indicate this should redirect properly
const redirectInput = document.createElement('input');
redirectInput.type = 'hidden';
redirectInput.name = 'redirect_cleanly';
redirectInput.value = 'true';
form.appendChild(redirectInput);
#{' '}
// Add selected IDs as hidden inputs
selectedIds.forEach(id => {
const input = document.createElement('input');
input.type = 'hidden';
input.name = 'job_ids[]';
input.value = id;
form.appendChild(input);
});
#{' '}
// Submit the form and then replace the URL location immediately after
form.submit();
#{' '}
// Delay the redirect to give the form time to submit
setTimeout(function() {
// Reset to the clean URL without query parameters
window.history.replaceState({}, '', window.location.pathname);
}, 100);
}
bulkSubmit('#{retry_failed_jobs_path}', 'Are you sure you want to retry the selected jobs?');
});
#{' '}
discardSelectedBtn.addEventListener('click', function() {
const selectedIds = Array.from(document.querySelectorAll('.job-checkbox:checked')).map(cb => cb.value);
if (selectedIds.length === 0) return;
#{' '}
if (confirm('Are you sure you want to discard the selected jobs?')) {
form.action = '#{discard_failed_jobs_path}';
#{' '}
// Add a special flag to indicate this should redirect properly
const redirectInput = document.createElement('input');
redirectInput.type = 'hidden';
redirectInput.name = 'redirect_cleanly';
redirectInput.value = 'true';
form.appendChild(redirectInput);
#{' '}
// Add selected IDs as hidden inputs
selectedIds.forEach(id => {
const input = document.createElement('input');
input.type = 'hidden';
input.name = 'job_ids[]';
input.value = id;
form.appendChild(input);
});
#{' '}
// Submit the form and then replace the URL location immediately after
form.submit();
#{' '}
// Delay the redirect to give the form time to submit
setTimeout(function() {
// Reset to the clean URL without query parameters
window.history.replaceState({}, '', window.location.pathname);
}, 100);
bulkSubmit('#{discard_failed_jobs_path}', 'Are you sure you want to discard the selected jobs?');
});

function submitRowAction(action, id) {
var f = document.createElement('form');
f.method = 'post';
f.action = action.replace('PLACEHOLDER', id);
appendHidden(f, 'redirect_cleanly', 'true');
document.body.appendChild(f);
f.submit();
setTimeout(function() { window.history.replaceState({}, '', window.location.pathname); }, 100);
}

document.addEventListener('click', function(e) {
var btn = e.target.closest('[data-action]');
if (!btn) return;
var id = btn.dataset.jobId;
if (btn.dataset.action === 'retry-failed-job') {
submitRowAction('#{retry_failed_job_path(id: 'PLACEHOLDER')}', id);
} else if (btn.dataset.action === 'discard-failed-job') {
if (confirm('Are you sure you want to discard this job?')) {
submitRowAction('#{discard_failed_job_path(id: 'PLACEHOLDER')}', id);
}
}
});
#{' '}
// Initialize button state

updateButtonState();
#{' '}
// Global function for retry action
window.submitRetryForm = function(id) {
const form = document.createElement('form');
form.method = 'post';
form.action = '#{retry_failed_job_path(id: 'PLACEHOLDER')}';
form.action = form.action.replace('PLACEHOLDER', id);
form.style.display = 'none';
#{' '}
// Add a special flag to indicate this should redirect properly
const redirectInput = document.createElement('input');
redirectInput.type = 'hidden';
redirectInput.name = 'redirect_cleanly';
redirectInput.value = 'true';
form.appendChild(redirectInput);
#{' '}
document.body.appendChild(form);
#{' '}
// Submit the form and then replace the URL location immediately after
form.submit();
#{' '}
// Delay the redirect to give the form time to submit
setTimeout(function() {
// Reset to the clean URL without query parameters
window.history.replaceState({}, '', window.location.pathname);
}, 100);
};
#{' '}
// Global function for discard action
window.submitDiscardForm = function(id) {
if (confirm('Are you sure you want to discard this job?')) {
const form = document.createElement('form');
form.method = 'post';
form.action = '#{discard_failed_job_path(id: 'PLACEHOLDER')}';
form.action = form.action.replace('PLACEHOLDER', id);
form.style.display = 'none';
#{' '}
// Add a special flag to indicate this should redirect properly
const redirectInput = document.createElement('input');
redirectInput.type = 'hidden';
redirectInput.name = 'redirect_cleanly';
redirectInput.value = 'true';
form.appendChild(redirectInput);
#{' '}
document.body.appendChild(form);
#{' '}
// Submit the form and then replace the URL location immediately after
form.submit();
#{' '}
// Delay the redirect to give the form time to submit
setTimeout(function() {
// Reset to the clean URL without query parameters
window.history.replaceState({}, '', window.location.pathname);
}, 100);
}
};
});
</script>
HTML
Expand Down Expand Up @@ -268,13 +186,8 @@ def generate_row(failed_execution)
<td>#{format_datetime(failed_execution.created_at)}</td>
<td class="actions-cell">
<div class="job-actions">
<a href="javascript:void(0)"#{' '}
onclick="submitRetryForm(#{failed_execution.id})"#{' '}
class="action-button retry-button">Retry</a>
#{' '}
<a href="javascript:void(0)"#{' '}
onclick="submitDiscardForm(#{failed_execution.id})"#{' '}
class="action-button discard-button">Discard</a>
<button type="button" data-action="retry-failed-job" data-job-id="#{failed_execution.id}" class="action-button retry-button">Retry</button>
<button type="button" data-action="discard-failed-job" data-job-id="#{failed_execution.id}" class="action-button discard-button">Discard</button>
</div>
</td>
</tr>
Expand Down
Loading
Loading