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.
- checksums.yaml +4 -4
- data/README.md +66 -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 +9 -3
- 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
- data/lib/solid_queue_monitor.rb +8 -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: 92ff7e193202f9653de1b877d2e5d8b2f841c1e50e6c86a9ba4c556782e1fe8b
|
|
4
|
+
data.tar.gz: 2d180ede70f06618f676d167674238e4e7940e02f0b5af56b2667f543ab833f0
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
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', '~>
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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]
|
|
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
|