solid_queue_web 0.8.0 → 1.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 +106 -17
- data/Rakefile +3 -1
- data/app/assets/stylesheets/solid_queue_web/_04_table.css +8 -1
- data/app/assets/stylesheets/solid_queue_web/_05_badges.css +1 -0
- data/app/assets/stylesheets/solid_queue_web/_07_forms.css +17 -0
- data/app/assets/stylesheets/solid_queue_web/_11_throughput.css +30 -1
- data/app/controllers/solid_queue_web/application_controller.rb +19 -1
- data/app/controllers/solid_queue_web/blocked_jobs_controller.rb +11 -0
- data/app/controllers/solid_queue_web/dashboard_controller.rb +2 -38
- data/app/controllers/solid_queue_web/jobs_controller.rb +16 -27
- data/app/controllers/solid_queue_web/metrics_controller.rb +7 -0
- data/app/controllers/solid_queue_web/performance_controller.rb +12 -0
- data/app/controllers/solid_queue_web/queues/jobs_controller.rb +15 -19
- data/app/controllers/solid_queue_web/queues/pauses_controller.rb +21 -0
- data/app/controllers/solid_queue_web/queues_controller.rb +5 -31
- data/app/controllers/solid_queue_web/recurring_tasks/runs_controller.rb +18 -0
- data/app/controllers/solid_queue_web/retry_failed_jobs_controller.rb +23 -2
- data/app/controllers/solid_queue_web/scheduled_jobs_controller.rb +54 -0
- data/app/models/solid_queue_web/job.rb +17 -1
- data/app/services/solid_queue_web/alert_webhook.rb +58 -0
- data/app/services/solid_queue_web/dashboard_stats.rb +47 -0
- data/app/services/solid_queue_web/job_performance_stats.rb +38 -0
- data/app/services/solid_queue_web/metrics_payload.rb +66 -0
- data/app/services/solid_queue_web/queue_stats.rb +52 -0
- data/app/views/layouts/solid_queue_web/application.html.erb +1 -0
- data/app/views/solid_queue_web/dashboard/index.html.erb +68 -24
- data/app/views/solid_queue_web/failed_jobs/index.html.erb +11 -1
- data/app/views/solid_queue_web/history/index.html.erb +1 -1
- data/app/views/solid_queue_web/jobs/index.html.erb +57 -15
- data/app/views/solid_queue_web/performance/index.html.erb +50 -0
- data/app/views/solid_queue_web/queues/index.html.erb +19 -2
- data/app/views/solid_queue_web/queues/jobs/index.html.erb +1 -1
- data/app/views/solid_queue_web/recurring_tasks/index.html.erb +7 -0
- data/app/views/solid_queue_web/scheduled_jobs/update.turbo_stream.erb +9 -0
- data/app/views/solid_queue_web/search/index.html.erb +1 -1
- data/config/routes.rb +16 -10
- data/lib/solid_queue_web/version.rb +1 -1
- data/lib/solid_queue_web.rb +23 -1
- metadata +14 -1
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 65caaec8c90225e3645116b206b9a3f81c4ff2389b2dd688e2e3fccca8a0121d
|
|
4
|
+
data.tar.gz: 217dd8f2c0dcdaf13fe69cff9f3bf1c0857a416e88de8643248fc54a85efe917
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: ed20d5b71f733c2e7eea80a249ff42c45094507f129dd69899b5938bc9594f575cb0a5958fc3e94bbf7b28fda0c70256257ba1651738d42dfa5c0661e61a7b73
|
|
7
|
+
data.tar.gz: d7ab81704638739b1108e666d87797a0f4d21c690d428ec321ce04ae98a3af15e181d783e5a498fd9e892b8b1e8041648d9666c356c1aebc194dbc76173cfdfe
|
data/README.md
CHANGED
|
@@ -33,13 +33,14 @@ SolidQueueWeb surfaces all of this in a browser UI available at any route you ch
|
|
|
33
33
|
|
|
34
34
|
## Features
|
|
35
35
|
|
|
36
|
-
- **Dashboard** — stat cards showing counts for ready, scheduled, running, blocked, and failed jobs, plus queues, recurring tasks, and processes; "Done (1h)" and "Done (24h)" throughput cards; a "Throughput — Last 12 Hours" bar chart showing
|
|
37
|
-
- **Queues** — all queues sorted by name with size; oldest ready job latency (color-coded, with UTC timestamp tooltip); Done (24h) and Failed (24h) throughput counts; pause/resume controls
|
|
38
|
-
- **Jobs** — filterable by status (ready, scheduled, claimed, blocked, failed) and
|
|
39
|
-
- **
|
|
36
|
+
- **Dashboard** — stat cards showing counts for ready, scheduled, running, blocked, and failed jobs, plus queues, recurring tasks, and processes; "Done (1h)" and "Done (24h)" throughput cards; a "Throughput — Last 12 Hours" bar chart (blue) and a "Queue Depth — Last 12 Hours" bar chart (purple) showing hourly snapshots of active job count; pure CSS, no charting library; auto-refreshes every 5 seconds
|
|
37
|
+
- **Queues** — all queues sorted by name with size; oldest ready job latency (color-coded, with UTC timestamp tooltip); Done (24h) and Failed (24h) throughput counts; a mini 12-bar failure rate sparkline per queue showing failure % per hour over the last 12 hours; pause/resume controls
|
|
38
|
+
- **Jobs** — filterable by status (ready, scheduled, claimed, blocked, failed), queue, and priority; search by job class name with dynamic auto-submit; time-based period filter (1 h / 24 h / 7 d); discard individual or all jobs; Turbo Frame navigation so only the table updates on filter or search; auto-refreshes every 10 seconds
|
|
39
|
+
- **Scheduled job management** — reschedule a scheduled job to run immediately ("Run Now") or push its `scheduled_at` forward by 1 h, 24 h, or 7 d; Turbo Stream responses update the row in place; "Run All Now" bulk action promotes every scheduled job in the current filtered view in a single operation
|
|
40
|
+
- **Failed jobs** — list of failed executions with error details; search by class name; filter by queue; time-based period filter; retry or discard individually or in bulk; bulk retry with configurable stagger (+5s / +10s / +30s / +1m) to avoid thundering herd on recovery
|
|
40
41
|
- **Job detail** — full arguments, timestamps, blocked-until date, and error backtrace; action buttons based on job status
|
|
41
42
|
- **Queue management** — pause and resume individual queues; queue-scoped job list with status filter, search, and discard
|
|
42
|
-
- **Recurring tasks** — all configured recurring tasks with cron schedule, next run time, last run time, and static/dynamic classification
|
|
43
|
+
- **Recurring tasks** — all configured recurring tasks with cron schedule, next run time, last run time, and static/dynamic classification; "Run Now" button enqueues a task immediately without waiting for its next scheduled run
|
|
43
44
|
- **Processes** — workers, dispatchers, and supervisors with heartbeat health status; auto-refreshes every 10 seconds
|
|
44
45
|
- **Global search** — search across all job statuses at once by class name substring; results grouped by status with match count and direct links to filtered views; native datalist autocomplete pre-populated from all known job classes; auto-submits on selection
|
|
45
46
|
- **Targeted bulk actions** — checkboxes on the jobs and failed jobs lists for selecting individual rows; selection bar shows count and action buttons ("Discard Selected" for jobs, "Retry Selected" / "Discard Selected" for failed jobs); select-all checkbox in the table header
|
|
@@ -47,6 +48,10 @@ SolidQueueWeb surfaces all of this in a browser UI available at any route you ch
|
|
|
47
48
|
- **Dark mode** — ☽/☀ toggle in the header; preference persists to `localStorage` and defaults to the OS `prefers-color-scheme` on first visit; zero extra dependencies — implemented via CSS custom properties and a small Stimulus controller
|
|
48
49
|
- **Dashboard quick actions** — "Retry All Failed" and "Discard All Blocked" cards appear on the dashboard only when the respective count is non-zero; one-click bulk operations with confirm dialogs, keeping the dashboard clean when everything is healthy
|
|
49
50
|
- **CSV export** — "Export CSV" button on the jobs, failed jobs, and history pages downloads all records matching the current filters; columns are tailored per view
|
|
51
|
+
- **Slow job detection** — when `slow_job_threshold` is configured, claimed jobs running longer than the threshold are flagged with an orange row, a "slow" badge, and a "Running For" duration column on the Running tab; a "Slow Jobs" warning card appears on the dashboard with a link to the Running tab
|
|
52
|
+
- **Webhook alerts** — set `alert_webhook_url` and `alert_failure_threshold` to receive a POST request whenever the failed job count meets or exceeds the threshold; fires asynchronously so dashboard performance is unaffected; a configurable cooldown (default 1 h) prevents repeated alerts while the count stays elevated
|
|
53
|
+
- **Performance analytics** — per-job-class statistics at `/jobs/performance` showing run count, average, p50, p95, min, and max duration; sorted by p95 descending so the slowest classes surface first; period filter scopes to 1h / 24h / 7d or all time; each class name links to the filtered History view
|
|
54
|
+
- **Metrics / health endpoint** — `GET /jobs/metrics.json` returns a machine-readable JSON document with job counts, throughput, per-queue depth and pause state, and process health summary; suitable for Prometheus scraping, uptime monitors, or external dashboards; `slow_jobs` count included when `slow_job_threshold` is configured
|
|
50
55
|
|
|
51
56
|
## Screenshots
|
|
52
57
|
|
|
@@ -96,6 +101,11 @@ SolidQueueWeb.configure do |config|
|
|
|
96
101
|
config.dashboard_refresh_interval = 10_000 # dashboard auto-refresh in ms (default: 5_000)
|
|
97
102
|
config.default_refresh_interval = 30_000 # jobs/processes/history auto-refresh in ms (default: 10_000)
|
|
98
103
|
config.search_results_limit = 10 # max results per status in global search (default: 25)
|
|
104
|
+
config.slow_job_threshold = 5.minutes # flag claimed jobs running longer than this (default: nil = disabled)
|
|
105
|
+
config.alert_webhook_url = "https://hooks.example.com/solid-queue" # POST target (default: nil = disabled)
|
|
106
|
+
config.alert_failure_threshold = 10 # fire when failed count >= this (default: nil = disabled)
|
|
107
|
+
config.alert_webhook_cooldown = 1800 # seconds between repeated alerts (default: 3600)
|
|
108
|
+
config.connects_to = { reading: :reading, writing: :writing } # read replica (default: nil)
|
|
99
109
|
end
|
|
100
110
|
|
|
101
111
|
SolidQueueWeb.authenticate do
|
|
@@ -107,24 +117,103 @@ end
|
|
|
107
117
|
|
|
108
118
|
No authentication is enforced by default. When the `authenticate` block returns falsy, HTTP Basic auth is used as a fallback.
|
|
109
119
|
|
|
110
|
-
##
|
|
120
|
+
## Webhook alerts
|
|
121
|
+
|
|
122
|
+
Set `alert_webhook_url` and `alert_failure_threshold` to receive a POST request whenever the failed job count meets or exceeds the threshold. This is useful for paging an on-call team or triggering a Slack notification via an incoming webhook.
|
|
111
123
|
|
|
112
|
-
|
|
124
|
+
```ruby
|
|
125
|
+
SolidQueueWeb.configure do |config|
|
|
126
|
+
config.alert_webhook_url = "https://hooks.slack.com/services/..."
|
|
127
|
+
config.alert_failure_threshold = 10 # fire when >= 10 jobs have failed
|
|
128
|
+
config.alert_webhook_cooldown = 1800 # don't re-fire for 30 minutes (default: 3600)
|
|
129
|
+
end
|
|
130
|
+
```
|
|
131
|
+
|
|
132
|
+
The request body is JSON:
|
|
133
|
+
|
|
134
|
+
```json
|
|
135
|
+
{
|
|
136
|
+
"event": "failure_threshold_exceeded",
|
|
137
|
+
"failure_count": 14,
|
|
138
|
+
"threshold": 10,
|
|
139
|
+
"fired_at": "2026-05-21T12:34:56Z"
|
|
140
|
+
}
|
|
141
|
+
```
|
|
142
|
+
|
|
143
|
+
The webhook fires asynchronously in a background thread so dashboard page loads are never delayed. HTTP errors are logged to `Rails.logger` and swallowed. The cooldown window prevents repeated alerts while the count stays elevated — the clock resets on each app restart.
|
|
144
|
+
|
|
145
|
+
## Metrics endpoint
|
|
146
|
+
|
|
147
|
+
`GET /jobs/metrics.json` returns a machine-readable JSON document suitable for Prometheus scraping, uptime monitors, or external dashboards. No configuration is required — the endpoint is available as soon as the engine is mounted.
|
|
148
|
+
|
|
149
|
+
```
|
|
150
|
+
GET /jobs/metrics.json
|
|
151
|
+
```
|
|
152
|
+
|
|
153
|
+
Example response:
|
|
154
|
+
|
|
155
|
+
```json
|
|
156
|
+
{
|
|
157
|
+
"generated_at": "2026-05-21T12:00:00Z",
|
|
158
|
+
"jobs": {
|
|
159
|
+
"ready": 12,
|
|
160
|
+
"scheduled": 8,
|
|
161
|
+
"claimed": 3,
|
|
162
|
+
"blocked": 5,
|
|
163
|
+
"failed": 9
|
|
164
|
+
},
|
|
165
|
+
"throughput": {
|
|
166
|
+
"completed_1h": 15,
|
|
167
|
+
"completed_24h": 87
|
|
168
|
+
},
|
|
169
|
+
"queues": [
|
|
170
|
+
{ "name": "critical", "depth": 2, "paused": false },
|
|
171
|
+
{ "name": "default", "depth": 4, "paused": false },
|
|
172
|
+
{ "name": "mailers", "depth": 3, "paused": true }
|
|
173
|
+
],
|
|
174
|
+
"processes": {
|
|
175
|
+
"total": 4,
|
|
176
|
+
"healthy": 4,
|
|
177
|
+
"stale": 0,
|
|
178
|
+
"by_kind": { "Dispatcher": 1, "Supervisor": 1, "Worker": 2 }
|
|
179
|
+
}
|
|
180
|
+
}
|
|
181
|
+
```
|
|
182
|
+
|
|
183
|
+
When `slow_job_threshold` is configured, a `slow_jobs` integer is also included at the top level.
|
|
184
|
+
|
|
185
|
+
The endpoint respects the same authentication and `connects_to` settings as the rest of the dashboard. A process is counted as **stale** when its `last_heartbeat_at` is older than `SolidQueue.process_alive_threshold` (default: 5 minutes).
|
|
186
|
+
|
|
187
|
+
## Read replica support
|
|
188
|
+
|
|
189
|
+
Set `connects_to` with both `reading:` and `writing:` keys to enable automatic role switching. GET requests are routed to the reading role; POST/DELETE/PATCH requests use the writing role.
|
|
190
|
+
|
|
191
|
+
```ruby
|
|
192
|
+
SolidQueueWeb.configure do |config|
|
|
193
|
+
# Route dashboard reads to the replica, writes to primary:
|
|
194
|
+
config.connects_to = { reading: :reading, writing: :writing }
|
|
195
|
+
end
|
|
196
|
+
```
|
|
197
|
+
|
|
198
|
+
The role names must match what Solid Queue's models are configured with (set via `SolidQueue.connects_to` in your app). To pin all requests to a single role instead (e.g. to bypass automatic read/write splitting middleware), pass a plain `role:` or `shard:` hash:
|
|
199
|
+
|
|
200
|
+
```ruby
|
|
201
|
+
config.connects_to = { role: :writing }
|
|
202
|
+
```
|
|
203
|
+
|
|
204
|
+
When `connects_to` is `nil` (the default), no connection switching occurs and single-database apps are unaffected.
|
|
205
|
+
|
|
206
|
+
## Roadmap
|
|
113
207
|
|
|
114
|
-
|
|
115
|
-
- Job failure rate chart — sparkline per queue showing failure percentage over time, mirroring the throughput chart
|
|
116
|
-
- Queue depth trend — historical queue size over time, not just the current snapshot
|
|
117
|
-
- Slow job detection — flag jobs exceeding a configurable duration threshold
|
|
208
|
+
Post-1.0 planned features:
|
|
118
209
|
|
|
119
210
|
**Operations**
|
|
120
|
-
- Scheduled job management — reschedule a job to run immediately, or push its `scheduled_at` forward
|
|
121
|
-
- Bulk retry with delay — retry all failed jobs with a configurable stagger to avoid thundering herd
|
|
122
211
|
- Admin audit log — record who retried or discarded which jobs and when (requires host-app user identity)
|
|
212
|
+
- Failed job retry with modified arguments — edit the arguments JSON from the job detail page before retrying; useful for correcting bad payloads without redeploying
|
|
123
213
|
|
|
124
|
-
**
|
|
125
|
-
-
|
|
126
|
-
-
|
|
127
|
-
- Read replica support — route dashboard queries to a replica to avoid impacting the primary
|
|
214
|
+
**Notifications**
|
|
215
|
+
- Multiple webhook targets — support an array of `alert_webhook_url` values so alerts can fan out to Slack, PagerDuty, and custom endpoints simultaneously
|
|
216
|
+
- Queue depth alert — fire a webhook when a queue's ready job count exceeds a configurable threshold (complements the existing failure-count alert)
|
|
128
217
|
|
|
129
218
|
Pull requests for any of these are welcome. See [Contributing](#contributing) below.
|
|
130
219
|
|
data/Rakefile
CHANGED
|
@@ -3,11 +3,13 @@ require "bundler/setup"
|
|
|
3
3
|
require "bundler/gem_tasks"
|
|
4
4
|
require "rubocop/rake_task"
|
|
5
5
|
require "rspec/core/rake_task"
|
|
6
|
+
require "bundler/audit/task"
|
|
6
7
|
|
|
7
8
|
RuboCop::RakeTask.new
|
|
8
9
|
RSpec::Core::RakeTask.new(:spec)
|
|
10
|
+
Bundler::Audit::Task.new
|
|
9
11
|
|
|
10
|
-
task default: [:rubocop, :spec]
|
|
12
|
+
task default: ["bundle:audit:update", "bundle:audit:check", :rubocop, :spec]
|
|
11
13
|
|
|
12
14
|
namespace :dev do
|
|
13
15
|
def dummy_env
|
|
@@ -44,9 +44,16 @@ td {
|
|
|
44
44
|
|
|
45
45
|
tr:last-child td { border-bottom: none; }
|
|
46
46
|
tbody tr:hover { background: var(--bg); }
|
|
47
|
+
.sqd-table-link,
|
|
48
|
+
.sqd-table-link:hover,
|
|
49
|
+
.sqd-table-link:visited { text-decoration: none; color: var(--primary); }
|
|
47
50
|
|
|
48
51
|
.sqd-empty {
|
|
49
52
|
text-align: center;
|
|
50
53
|
padding: 3rem 1rem;
|
|
51
54
|
color: var(--muted);
|
|
52
|
-
}
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
.sqd-row--slow { background: rgba(253, 126, 20, 0.07); }
|
|
58
|
+
.sqd-row--slow:hover { background: rgba(253, 126, 20, 0.13); }
|
|
59
|
+
.sqd-slow-duration { color: var(--warning); font-weight: 600; }
|
|
@@ -21,6 +21,7 @@
|
|
|
21
21
|
.sqd-badge--supervisor { background: #e0d7f5; color: #4a2c8a; }
|
|
22
22
|
.sqd-badge--worker { background: #d1e7dd; color: #0f5132; }
|
|
23
23
|
.sqd-badge--dispatcher { background: #cff4fc; color: #055160; }
|
|
24
|
+
.sqd-badge--slow { background: #ffe8cc; color: #7c3d00; }
|
|
24
25
|
|
|
25
26
|
.sqd-process-meta { font-size: 12px; color: var(--muted); }
|
|
26
27
|
.sqd-process-meta span + span::before { content: " · "; }
|
|
@@ -76,6 +76,23 @@
|
|
|
76
76
|
color: #fff;
|
|
77
77
|
}
|
|
78
78
|
|
|
79
|
+
.sqd-select {
|
|
80
|
+
padding: 0.35rem 0.6rem;
|
|
81
|
+
border: 1px solid var(--border);
|
|
82
|
+
border-radius: 5px;
|
|
83
|
+
font-size: 13px;
|
|
84
|
+
background: var(--surface);
|
|
85
|
+
color: var(--text);
|
|
86
|
+
line-height: 1.5;
|
|
87
|
+
cursor: pointer;
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
.sqd-select:focus {
|
|
91
|
+
outline: 2px solid var(--primary);
|
|
92
|
+
outline-offset: -1px;
|
|
93
|
+
border-color: var(--primary);
|
|
94
|
+
}
|
|
95
|
+
|
|
79
96
|
.sqd-period-filter {
|
|
80
97
|
display: flex;
|
|
81
98
|
align-items: center;
|
|
@@ -65,4 +65,33 @@
|
|
|
65
65
|
padding: 1rem 1.25rem;
|
|
66
66
|
}
|
|
67
67
|
|
|
68
|
-
.sqd-stat--done .sqd-stat__value { color: var(--success); }
|
|
68
|
+
.sqd-stat--done .sqd-stat__value { color: var(--success); }
|
|
69
|
+
|
|
70
|
+
.sqd-mini-sparkline {
|
|
71
|
+
display: flex;
|
|
72
|
+
align-items: flex-end;
|
|
73
|
+
gap: 2px;
|
|
74
|
+
height: 28px;
|
|
75
|
+
width: 88px;
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
.sqd-mini-sparkline__bar {
|
|
79
|
+
flex: 1;
|
|
80
|
+
background: var(--danger);
|
|
81
|
+
border-radius: 1px 1px 0 0;
|
|
82
|
+
opacity: 0.7;
|
|
83
|
+
transition: opacity 0.15s;
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
.sqd-mini-sparkline__bar:hover {
|
|
87
|
+
opacity: 1;
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
.sqd-mini-sparkline__bar--empty {
|
|
91
|
+
background: var(--border);
|
|
92
|
+
opacity: 0.5;
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
.sqd-sparkline__bar--depth {
|
|
96
|
+
background: var(--purple);
|
|
97
|
+
}
|
|
@@ -4,12 +4,30 @@ module SolidQueueWeb
|
|
|
4
4
|
class ApplicationController < ActionController::Base
|
|
5
5
|
include Pagy::Method
|
|
6
6
|
|
|
7
|
-
PERIOD_DURATIONS
|
|
7
|
+
PERIOD_DURATIONS = { "1h" => 1.hour, "24h" => 24.hours, "7d" => 7.days }.freeze
|
|
8
|
+
STAGGER_INTERVALS = { "5s" => 5.seconds, "10s" => 10.seconds, "30s" => 30.seconds, "1m" => 1.minute }.freeze
|
|
8
9
|
|
|
9
10
|
before_action :authenticate!
|
|
11
|
+
around_action :with_database_connection
|
|
10
12
|
|
|
11
13
|
private
|
|
12
14
|
|
|
15
|
+
def with_database_connection
|
|
16
|
+
config = SolidQueueWeb.connects_to
|
|
17
|
+
return yield unless config
|
|
18
|
+
|
|
19
|
+
if replica_configured?(config)
|
|
20
|
+
role = request.get? ? config[:reading] : config[:writing]
|
|
21
|
+
ActiveRecord::Base.connected_to(role: role) { yield }
|
|
22
|
+
else
|
|
23
|
+
ActiveRecord::Base.connected_to(**config) { yield }
|
|
24
|
+
end
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
def replica_configured?(config)
|
|
28
|
+
config.key?(:reading) && config.key?(:writing)
|
|
29
|
+
end
|
|
30
|
+
|
|
13
31
|
def authenticate!
|
|
14
32
|
return unless (auth = SolidQueueWeb.authenticate)
|
|
15
33
|
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
module SolidQueueWeb
|
|
2
|
+
class BlockedJobsController < ApplicationController
|
|
3
|
+
def destroy
|
|
4
|
+
jobs = SolidQueue::BlockedExecution.includes(:job).map(&:job)
|
|
5
|
+
SolidQueue::BlockedExecution.discard_all_from_jobs(jobs)
|
|
6
|
+
redirect_to root_path, notice: "#{jobs.size} blocked #{"job".pluralize(jobs.size)} discarded."
|
|
7
|
+
rescue => e
|
|
8
|
+
redirect_to root_path, alert: "Could not discard blocked jobs: #{e.message}"
|
|
9
|
+
end
|
|
10
|
+
end
|
|
11
|
+
end
|
|
@@ -1,44 +1,8 @@
|
|
|
1
1
|
module SolidQueueWeb
|
|
2
2
|
class DashboardController < ApplicationController
|
|
3
3
|
def index
|
|
4
|
-
@stats =
|
|
5
|
-
|
|
6
|
-
scheduled: SolidQueue::ScheduledExecution.count,
|
|
7
|
-
claimed: SolidQueue::ClaimedExecution.count,
|
|
8
|
-
failed: SolidQueue::FailedExecution.count,
|
|
9
|
-
blocked: SolidQueue::BlockedExecution.count,
|
|
10
|
-
queues: SolidQueue::Job.select(:queue_name).distinct.count,
|
|
11
|
-
processes: SolidQueue::Process.count,
|
|
12
|
-
recurring: SolidQueue::RecurringTask.count
|
|
13
|
-
}
|
|
14
|
-
|
|
15
|
-
now = Time.current
|
|
16
|
-
finished_times = SolidQueue::Job.where(finished_at: 24.hours.ago..now).pluck(:finished_at)
|
|
17
|
-
@throughput = {
|
|
18
|
-
completed_1h: finished_times.count { |t| t >= 1.hour.ago },
|
|
19
|
-
completed_24h: finished_times.size
|
|
20
|
-
}
|
|
21
|
-
@sparkline = 12.times.map do |i|
|
|
22
|
-
from = (12 - i).hours.ago
|
|
23
|
-
to = i == 11 ? now : (11 - i).hours.ago
|
|
24
|
-
finished_times.count { |t| t >= from && t < to }
|
|
25
|
-
end
|
|
26
|
-
end
|
|
27
|
-
|
|
28
|
-
def retry_all_failed
|
|
29
|
-
jobs = SolidQueue::FailedExecution.includes(:job).map(&:job)
|
|
30
|
-
SolidQueue::FailedExecution.retry_all(jobs)
|
|
31
|
-
redirect_to root_path, notice: "#{jobs.size} failed #{"job".pluralize(jobs.size)} queued for retry."
|
|
32
|
-
rescue => e
|
|
33
|
-
redirect_to root_path, alert: "Could not retry failed jobs: #{e.message}"
|
|
34
|
-
end
|
|
35
|
-
|
|
36
|
-
def discard_all_blocked
|
|
37
|
-
jobs = SolidQueue::BlockedExecution.includes(:job).map(&:job)
|
|
38
|
-
SolidQueue::BlockedExecution.discard_all_from_jobs(jobs)
|
|
39
|
-
redirect_to root_path, notice: "#{jobs.size} blocked #{"job".pluralize(jobs.size)} discarded."
|
|
40
|
-
rescue => e
|
|
41
|
-
redirect_to root_path, alert: "Could not discard blocked jobs: #{e.message}"
|
|
4
|
+
@stats = DashboardStats.new
|
|
5
|
+
AlertWebhook.call(failure_count: @stats.counts[:failed])
|
|
42
6
|
end
|
|
43
7
|
end
|
|
44
8
|
end
|
|
@@ -1,16 +1,20 @@
|
|
|
1
1
|
module SolidQueueWeb
|
|
2
2
|
class JobsController < ApplicationController
|
|
3
|
-
before_action :set_status, only: [:destroy, :discard_selected]
|
|
4
|
-
|
|
5
3
|
def index
|
|
6
|
-
@status
|
|
7
|
-
@search
|
|
8
|
-
@period
|
|
4
|
+
@status = params[:status].presence_in(Job::STATUSES) || "ready"
|
|
5
|
+
@search = params[:q].presence
|
|
6
|
+
@period = params[:period].presence_in(PERIOD_DURATIONS.keys)
|
|
7
|
+
@priority = params[:priority].presence
|
|
8
|
+
|
|
9
9
|
scope = Job::EXECUTION_MODELS[@status].includes(:job)
|
|
10
|
-
scope = scope.references(:job).where("solid_queue_jobs.class_name LIKE ?", "%#{@search}%")
|
|
10
|
+
scope = scope.references(:job).where("solid_queue_jobs.class_name LIKE ?", "%#{@search}%") if @search.present?
|
|
11
11
|
scope = scope.references(:job).where("solid_queue_jobs.created_at >= ?", PERIOD_DURATIONS[@period].ago) if @period.present?
|
|
12
|
+
scope = scope.references(:job).where("solid_queue_jobs.priority = ?", @priority.to_i) if @priority.present?
|
|
12
13
|
scope = scope.order(created_at: :desc)
|
|
13
14
|
|
|
15
|
+
@priority_options = Job::EXECUTION_MODELS[@status].joins(:job)
|
|
16
|
+
.distinct.pluck("solid_queue_jobs.priority").sort
|
|
17
|
+
|
|
14
18
|
respond_to do |format|
|
|
15
19
|
format.html { @pagy, @jobs = pagy(scope) }
|
|
16
20
|
format.csv do
|
|
@@ -25,11 +29,14 @@ module SolidQueueWeb
|
|
|
25
29
|
@job = SolidQueue::Job
|
|
26
30
|
.includes(:ready_execution, :scheduled_execution, :claimed_execution, :blocked_execution, :failed_execution)
|
|
27
31
|
.find(params[:id])
|
|
28
|
-
@execution_status = derive_status(@job)
|
|
32
|
+
@execution_status = Job.derive_status(@job)
|
|
29
33
|
end
|
|
30
34
|
|
|
31
35
|
def destroy
|
|
32
|
-
|
|
36
|
+
@status = params[:status]
|
|
37
|
+
@period = params[:period].presence_in(PERIOD_DURATIONS.keys)
|
|
38
|
+
@priority = params[:priority].presence
|
|
39
|
+
model = Job.execution_model_for!(@status)
|
|
33
40
|
if params[:id]
|
|
34
41
|
@execution = model.find(params[:id])
|
|
35
42
|
@execution.discard
|
|
@@ -63,29 +70,11 @@ module SolidQueueWeb
|
|
|
63
70
|
end
|
|
64
71
|
end
|
|
65
72
|
|
|
66
|
-
def derive_status(job)
|
|
67
|
-
return "failed" if job.failed_execution.present?
|
|
68
|
-
return "claimed" if job.claimed_execution.present?
|
|
69
|
-
return "blocked" if job.blocked_execution.present?
|
|
70
|
-
return "ready" if job.ready_execution.present?
|
|
71
|
-
return "scheduled" if job.scheduled_execution.present?
|
|
72
|
-
"finished"
|
|
73
|
-
end
|
|
74
|
-
|
|
75
|
-
def set_status
|
|
76
|
-
@status = params[:status]
|
|
77
|
-
@period = params[:period].presence_in(PERIOD_DURATIONS.keys)
|
|
78
|
-
end
|
|
79
|
-
|
|
80
73
|
def filtered_scope(model)
|
|
81
74
|
scope = model.includes(:job)
|
|
82
75
|
scope = scope.references(:job).where("solid_queue_jobs.created_at >= ?", PERIOD_DURATIONS[@period].ago) if @period.present?
|
|
76
|
+
scope = scope.references(:job).where("solid_queue_jobs.priority = ?", @priority.to_i) if @priority.present?
|
|
83
77
|
scope
|
|
84
78
|
end
|
|
85
|
-
|
|
86
|
-
def execution_model_for!(status)
|
|
87
|
-
raise ArgumentError, "Cannot discard #{status} jobs from this page." unless Job::DISCARDABLE.include?(status)
|
|
88
|
-
Job::EXECUTION_MODELS[status]
|
|
89
|
-
end
|
|
90
79
|
end
|
|
91
80
|
end
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
module SolidQueueWeb
|
|
2
|
+
class PerformanceController < ApplicationController
|
|
3
|
+
def index
|
|
4
|
+
@period = params[:period].presence_in(PERIOD_DURATIONS.keys)
|
|
5
|
+
|
|
6
|
+
scope = SolidQueue::Job.where.not(finished_at: nil)
|
|
7
|
+
scope = scope.where("finished_at >= ?", PERIOD_DURATIONS[@period].ago) if @period.present?
|
|
8
|
+
|
|
9
|
+
@rows = JobPerformanceStats.new(scope).rows
|
|
10
|
+
end
|
|
11
|
+
end
|
|
12
|
+
end
|
|
@@ -2,7 +2,7 @@ module SolidQueueWeb
|
|
|
2
2
|
module Queues
|
|
3
3
|
class JobsController < ApplicationController
|
|
4
4
|
before_action :set_queue
|
|
5
|
-
before_action :set_status, only: [:destroy
|
|
5
|
+
before_action :set_status, only: [:destroy]
|
|
6
6
|
|
|
7
7
|
def index
|
|
8
8
|
@status = params[:status].presence_in(Job::STATUSES) || "ready"
|
|
@@ -15,29 +15,25 @@ module SolidQueueWeb
|
|
|
15
15
|
|
|
16
16
|
def destroy
|
|
17
17
|
model = execution_model_for!(@status)
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
format
|
|
23
|
-
|
|
18
|
+
if params[:id]
|
|
19
|
+
@execution = model.find(params[:id])
|
|
20
|
+
@execution.discard
|
|
21
|
+
@remaining_count = filtered_scope(model).count
|
|
22
|
+
respond_to do |format|
|
|
23
|
+
format.turbo_stream
|
|
24
|
+
format.html { redirect_to queue_jobs_path(queue_name: @queue, status: @status), notice: "Job discarded." }
|
|
25
|
+
end
|
|
26
|
+
else
|
|
27
|
+
jobs = filtered_scope(model).map(&:job)
|
|
28
|
+
model.discard_all_from_jobs(jobs)
|
|
29
|
+
redirect_to queue_jobs_path(queue_name: @queue, status: @status),
|
|
30
|
+
notice: "#{jobs.size} #{"job".pluralize(jobs.size)} discarded."
|
|
24
31
|
end
|
|
25
32
|
rescue ArgumentError => e
|
|
26
33
|
redirect_to queue_jobs_path(queue_name: @queue, status: @status), alert: e.message
|
|
27
34
|
rescue => e
|
|
28
|
-
redirect_to queue_jobs_path(queue_name: @queue, status: @status), alert: "Could not discard job: #{e.message}"
|
|
29
|
-
end
|
|
30
|
-
|
|
31
|
-
def discard_all
|
|
32
|
-
model = execution_model_for!(@status)
|
|
33
|
-
jobs = filtered_scope(model).map(&:job)
|
|
34
|
-
model.discard_all_from_jobs(jobs)
|
|
35
35
|
redirect_to queue_jobs_path(queue_name: @queue, status: @status),
|
|
36
|
-
|
|
37
|
-
rescue ArgumentError => e
|
|
38
|
-
redirect_to queue_jobs_path(queue_name: @queue, status: @status), alert: e.message
|
|
39
|
-
rescue => e
|
|
40
|
-
redirect_to queue_jobs_path(queue_name: @queue, status: @status), alert: "Could not discard jobs: #{e.message}"
|
|
36
|
+
alert: "Could not discard #{params[:id] ? "job" : "jobs"}: #{e.message}"
|
|
41
37
|
end
|
|
42
38
|
|
|
43
39
|
private
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
module SolidQueueWeb
|
|
2
|
+
module Queues
|
|
3
|
+
class PausesController < ApplicationController
|
|
4
|
+
def create
|
|
5
|
+
queue = SolidQueue::Queue.find_by_name(params[:queue_name])
|
|
6
|
+
queue.pause
|
|
7
|
+
redirect_to queues_path, notice: "Queue \"#{queue.name}\" paused."
|
|
8
|
+
rescue => e
|
|
9
|
+
redirect_to queues_path, alert: "Could not pause queue: #{e.message}"
|
|
10
|
+
end
|
|
11
|
+
|
|
12
|
+
def destroy
|
|
13
|
+
queue = SolidQueue::Queue.find_by_name(params[:queue_name])
|
|
14
|
+
queue.resume
|
|
15
|
+
redirect_to queues_path, notice: "Queue \"#{queue.name}\" resumed."
|
|
16
|
+
rescue => e
|
|
17
|
+
redirect_to queues_path, alert: "Could not resume queue: #{e.message}"
|
|
18
|
+
end
|
|
19
|
+
end
|
|
20
|
+
end
|
|
21
|
+
end
|
|
@@ -2,37 +2,11 @@ module SolidQueueWeb
|
|
|
2
2
|
class QueuesController < ApplicationController
|
|
3
3
|
def index
|
|
4
4
|
@queues = SolidQueue::Queue.all.sort_by(&:name)
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
@
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
.count
|
|
11
|
-
@failed_24h = SolidQueue::FailedExecution
|
|
12
|
-
.joins(:job)
|
|
13
|
-
.where(created_at: 24.hours.ago..now)
|
|
14
|
-
.group("solid_queue_jobs.queue_name")
|
|
15
|
-
.count
|
|
16
|
-
@oldest_ready = SolidQueue::ReadyExecution
|
|
17
|
-
.joins(:job)
|
|
18
|
-
.group("solid_queue_jobs.queue_name")
|
|
19
|
-
.minimum("solid_queue_jobs.created_at")
|
|
20
|
-
end
|
|
21
|
-
|
|
22
|
-
def pause
|
|
23
|
-
queue = SolidQueue::Queue.find_by_name(params[:name])
|
|
24
|
-
queue.pause
|
|
25
|
-
redirect_to queues_path, notice: "Queue \"#{queue.name}\" paused."
|
|
26
|
-
rescue => e
|
|
27
|
-
redirect_to queues_path, alert: "Could not pause queue: #{e.message}"
|
|
28
|
-
end
|
|
29
|
-
|
|
30
|
-
def resume
|
|
31
|
-
queue = SolidQueue::Queue.find_by_name(params[:name])
|
|
32
|
-
queue.resume
|
|
33
|
-
redirect_to queues_path, notice: "Queue \"#{queue.name}\" resumed."
|
|
34
|
-
rescue => e
|
|
35
|
-
redirect_to queues_path, alert: "Could not resume queue: #{e.message}"
|
|
5
|
+
stats = QueueStats.new(@queues)
|
|
6
|
+
@completed_24h = stats.completed_24h
|
|
7
|
+
@failed_24h = stats.failed_24h
|
|
8
|
+
@oldest_ready = stats.oldest_ready
|
|
9
|
+
@failure_sparklines = stats.failure_sparklines
|
|
36
10
|
end
|
|
37
11
|
end
|
|
38
12
|
end
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
module SolidQueueWeb
|
|
2
|
+
class RecurringTasks::RunsController < ApplicationController
|
|
3
|
+
def create
|
|
4
|
+
task = SolidQueue::RecurringTask.find_by!(key: params[:recurring_task_key])
|
|
5
|
+
result = task.enqueue(at: Time.current)
|
|
6
|
+
|
|
7
|
+
if result
|
|
8
|
+
redirect_to recurring_tasks_path, notice: "\"#{task.key}\" queued for immediate execution."
|
|
9
|
+
else
|
|
10
|
+
redirect_to recurring_tasks_path, alert: "Could not enqueue \"#{task.key}\" — it may have just run."
|
|
11
|
+
end
|
|
12
|
+
rescue ActiveRecord::RecordNotFound
|
|
13
|
+
redirect_to recurring_tasks_path, alert: "Recurring task not found."
|
|
14
|
+
rescue => e
|
|
15
|
+
redirect_to recurring_tasks_path, alert: "Could not run task: #{e.message}"
|
|
16
|
+
end
|
|
17
|
+
end
|
|
18
|
+
end
|
|
@@ -5,15 +5,36 @@ module SolidQueueWeb
|
|
|
5
5
|
def create
|
|
6
6
|
executions = params[:id] ? [SolidQueue::FailedExecution.find(params[:id])] : filtered_scope.to_a
|
|
7
7
|
jobs = executions.map(&:job)
|
|
8
|
-
|
|
8
|
+
|
|
9
|
+
if params[:stagger].present? && executions.size > 1
|
|
10
|
+
interval = STAGGER_INTERVALS[params[:stagger]]
|
|
11
|
+
raise ArgumentError, "Invalid stagger interval." unless interval
|
|
12
|
+
executions.each_with_index do |execution, i|
|
|
13
|
+
execution.job.update!(scheduled_at: i.zero? ? nil : Time.current + (i * interval))
|
|
14
|
+
execution.retry
|
|
15
|
+
end
|
|
16
|
+
else
|
|
17
|
+
SolidQueue::FailedExecution.retry_all(jobs)
|
|
18
|
+
end
|
|
9
19
|
redirect_to failed_jobs_path(queue: @queue, q: @search, period: @period),
|
|
10
|
-
notice:
|
|
20
|
+
notice: retry_notice(jobs.size)
|
|
21
|
+
rescue ArgumentError => e
|
|
22
|
+
redirect_to failed_jobs_path, alert: e.message
|
|
11
23
|
rescue => e
|
|
12
24
|
redirect_to failed_jobs_path, alert: "Could not retry job: #{e.message}"
|
|
13
25
|
end
|
|
14
26
|
|
|
15
27
|
private
|
|
16
28
|
|
|
29
|
+
def retry_notice(count)
|
|
30
|
+
label = "#{count} #{"job".pluralize(count)}"
|
|
31
|
+
if params[:stagger].present? && count > 1
|
|
32
|
+
"#{label} queued for retry, staggered #{params[:stagger]} apart."
|
|
33
|
+
else
|
|
34
|
+
"#{label} queued for retry."
|
|
35
|
+
end
|
|
36
|
+
end
|
|
37
|
+
|
|
17
38
|
def set_filter_params
|
|
18
39
|
@queue = params[:queue].presence
|
|
19
40
|
@search = params[:q].presence
|