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