solid_queue_web 0.9.0 → 1.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 +137 -18
- data/Rakefile +3 -1
- data/app/assets/stylesheets/solid_queue_web/_07_forms.css +41 -0
- data/app/controllers/solid_queue_web/application_controller.rb +19 -1
- data/app/controllers/solid_queue_web/dashboard_controller.rb +2 -0
- data/app/controllers/solid_queue_web/failed_jobs/arguments_controller.rb +15 -0
- 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/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 +63 -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_depth_alert.rb +74 -0
- data/app/views/layouts/solid_queue_web/application.html.erb +1 -0
- data/app/views/solid_queue_web/failed_jobs/index.html.erb +10 -0
- data/app/views/solid_queue_web/jobs/index.html.erb +41 -12
- data/app/views/solid_queue_web/jobs/show.html.erb +13 -1
- data/app/views/solid_queue_web/performance/index.html.erb +50 -0
- 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/config/routes.rb +14 -3
- data/lib/solid_queue_web/version.rb +1 -1
- data/lib/solid_queue_web.rb +22 -1
- metadata +18 -4
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: e28c1cc2c32722a5b876083166f98549f60c2788cb5e225d6d6fa1122a2964ae
|
|
4
|
+
data.tar.gz: b68d4c0cf42091242b1816956e217ae64f471142df5ce974f71772ae8b454d83
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: ba9a31d372ecf83a97b0ed708ce8209ea5b24e9e042d11f7ff61ce54925bef988d0a6e12a7337c9757051268210279bd46eb6e011b6008d602832d340448b0db
|
|
7
|
+
data.tar.gz: 0c41966b6dab2b47e4049b0afbf884d016d62fb78feb424934738900d4f676978c05c0129c7b02b31cc2eb47caf7770e090ad2921f8fec56e8854bc307b7f1df
|
data/README.md
CHANGED
|
@@ -8,6 +8,8 @@
|
|
|
8
8
|
|
|
9
9
|
A monitoring and management dashboard for [Solid Queue](https://github.com/rails/solid_queue), mountable as a Rails engine in any app.
|
|
10
10
|
|
|
11
|
+

|
|
12
|
+
|
|
11
13
|
## The problem
|
|
12
14
|
|
|
13
15
|
Solid Queue ships without a web interface. When jobs fail, queues back up, or workers go silent in production, the only options are `rails console` or raw SQL queries. SolidQueueWeb gives your team a real-time dashboard to inspect, retry, and discard jobs without leaving the browser — and without standing up any additional infrastructure.
|
|
@@ -35,11 +37,12 @@ SolidQueueWeb surfaces all of this in a browser UI available at any route you ch
|
|
|
35
37
|
|
|
36
38
|
- **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
39
|
- **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) and
|
|
39
|
-
- **
|
|
40
|
-
- **
|
|
40
|
+
- **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
|
|
41
|
+
- **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
|
|
42
|
+
- **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
|
|
43
|
+
- **Job detail** — full arguments, timestamps, blocked-until date, and error backtrace; action buttons based on job status; failed jobs show an editable arguments textarea so you can correct a bad payload and retry in one step without redeploying
|
|
41
44
|
- **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
|
|
45
|
+
- **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
46
|
- **Processes** — workers, dispatchers, and supervisors with heartbeat health status; auto-refreshes every 10 seconds
|
|
44
47
|
- **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
48
|
- **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
|
|
@@ -48,10 +51,9 @@ SolidQueueWeb surfaces all of this in a browser UI available at any route you ch
|
|
|
48
51
|
- **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
52
|
- **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
|
|
50
53
|
- **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
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-

|
|
54
|
+
- **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
|
|
55
|
+
- **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
|
|
56
|
+
- **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
|
|
55
57
|
|
|
56
58
|
## Compatibility
|
|
57
59
|
|
|
@@ -98,6 +100,11 @@ SolidQueueWeb.configure do |config|
|
|
|
98
100
|
config.default_refresh_interval = 30_000 # jobs/processes/history auto-refresh in ms (default: 10_000)
|
|
99
101
|
config.search_results_limit = 10 # max results per status in global search (default: 25)
|
|
100
102
|
config.slow_job_threshold = 5.minutes # flag claimed jobs running longer than this (default: nil = disabled)
|
|
103
|
+
config.alert_webhook_url = "https://hooks.example.com/solid-queue" # POST target — string or array (default: nil = disabled)
|
|
104
|
+
config.alert_failure_threshold = 10 # fire when failed count >= this (default: nil = disabled)
|
|
105
|
+
config.alert_queue_thresholds = { "critical" => 50, "default" => 200 } # fire when queue depth >= threshold (default: {})
|
|
106
|
+
config.alert_webhook_cooldown = 1800 # seconds between repeated alerts per alert type (default: 3600)
|
|
107
|
+
config.connects_to = { reading: :reading, writing: :writing } # read replica (default: nil)
|
|
101
108
|
end
|
|
102
109
|
|
|
103
110
|
SolidQueueWeb.authenticate do
|
|
@@ -109,19 +116,131 @@ end
|
|
|
109
116
|
|
|
110
117
|
No authentication is enforced by default. When the `authenticate` block returns falsy, HTTP Basic auth is used as a fallback.
|
|
111
118
|
|
|
112
|
-
##
|
|
119
|
+
## Webhook alerts
|
|
120
|
+
|
|
121
|
+
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.
|
|
113
122
|
|
|
114
|
-
|
|
123
|
+
```ruby
|
|
124
|
+
SolidQueueWeb.configure do |config|
|
|
125
|
+
config.alert_webhook_url = "https://hooks.slack.com/services/..."
|
|
126
|
+
config.alert_failure_threshold = 10 # fire when >= 10 jobs have failed
|
|
127
|
+
config.alert_webhook_cooldown = 1800 # don't re-fire for 30 minutes (default: 3600)
|
|
128
|
+
end
|
|
129
|
+
```
|
|
130
|
+
|
|
131
|
+
To fan out to multiple endpoints (e.g. Slack and PagerDuty simultaneously), pass an array:
|
|
132
|
+
|
|
133
|
+
```ruby
|
|
134
|
+
config.alert_webhook_url = [
|
|
135
|
+
"https://hooks.slack.com/services/...",
|
|
136
|
+
"https://events.pagerduty.com/..."
|
|
137
|
+
]
|
|
138
|
+
```
|
|
139
|
+
|
|
140
|
+
All configured URLs receive the same payload. A failure posting to one URL is logged and skipped without blocking the remaining targets.
|
|
141
|
+
|
|
142
|
+
The request body is JSON:
|
|
143
|
+
|
|
144
|
+
```json
|
|
145
|
+
{
|
|
146
|
+
"event": "failure_threshold_exceeded",
|
|
147
|
+
"failure_count": 14,
|
|
148
|
+
"threshold": 10,
|
|
149
|
+
"fired_at": "2026-05-21T12:34:56Z"
|
|
150
|
+
}
|
|
151
|
+
```
|
|
152
|
+
|
|
153
|
+
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.
|
|
154
|
+
|
|
155
|
+
## Queue depth alerts
|
|
156
|
+
|
|
157
|
+
Set `alert_queue_thresholds` to fire a webhook when any queue's ready job count meets or exceeds a per-queue limit:
|
|
158
|
+
|
|
159
|
+
```ruby
|
|
160
|
+
SolidQueueWeb.configure do |config|
|
|
161
|
+
config.alert_webhook_url = "https://hooks.example.com/solid-queue"
|
|
162
|
+
config.alert_queue_thresholds = { "critical" => 50, "default" => 200 }
|
|
163
|
+
end
|
|
164
|
+
```
|
|
115
165
|
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
166
|
+
The same `alert_webhook_url` endpoint(s) receive the payload, with a distinct event type so you can route it differently:
|
|
167
|
+
|
|
168
|
+
```json
|
|
169
|
+
{
|
|
170
|
+
"event": "queue_depth_threshold_exceeded",
|
|
171
|
+
"queue_name": "critical",
|
|
172
|
+
"depth": 63,
|
|
173
|
+
"threshold": 50,
|
|
174
|
+
"fired_at": "2026-05-21T12:34:56Z"
|
|
175
|
+
}
|
|
176
|
+
```
|
|
177
|
+
|
|
178
|
+
Cooldown is tracked independently per queue, so a persistently deep "critical" queue does not suppress alerts for "default". The shared `alert_webhook_cooldown` setting applies to each queue separately.
|
|
179
|
+
|
|
180
|
+
## Metrics endpoint
|
|
181
|
+
|
|
182
|
+
`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.
|
|
183
|
+
|
|
184
|
+
```
|
|
185
|
+
GET /jobs/metrics.json
|
|
186
|
+
```
|
|
187
|
+
|
|
188
|
+
Example response:
|
|
189
|
+
|
|
190
|
+
```json
|
|
191
|
+
{
|
|
192
|
+
"generated_at": "2026-05-21T12:00:00Z",
|
|
193
|
+
"jobs": {
|
|
194
|
+
"ready": 12,
|
|
195
|
+
"scheduled": 8,
|
|
196
|
+
"claimed": 3,
|
|
197
|
+
"blocked": 5,
|
|
198
|
+
"failed": 9
|
|
199
|
+
},
|
|
200
|
+
"throughput": {
|
|
201
|
+
"completed_1h": 15,
|
|
202
|
+
"completed_24h": 87
|
|
203
|
+
},
|
|
204
|
+
"queues": [
|
|
205
|
+
{ "name": "critical", "depth": 2, "paused": false },
|
|
206
|
+
{ "name": "default", "depth": 4, "paused": false },
|
|
207
|
+
{ "name": "mailers", "depth": 3, "paused": true }
|
|
208
|
+
],
|
|
209
|
+
"processes": {
|
|
210
|
+
"total": 4,
|
|
211
|
+
"healthy": 4,
|
|
212
|
+
"stale": 0,
|
|
213
|
+
"by_kind": { "Dispatcher": 1, "Supervisor": 1, "Worker": 2 }
|
|
214
|
+
}
|
|
215
|
+
}
|
|
216
|
+
```
|
|
217
|
+
|
|
218
|
+
When `slow_job_threshold` is configured, a `slow_jobs` integer is also included at the top level.
|
|
219
|
+
|
|
220
|
+
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).
|
|
221
|
+
|
|
222
|
+
## Read replica support
|
|
223
|
+
|
|
224
|
+
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.
|
|
225
|
+
|
|
226
|
+
```ruby
|
|
227
|
+
SolidQueueWeb.configure do |config|
|
|
228
|
+
# Route dashboard reads to the replica, writes to primary:
|
|
229
|
+
config.connects_to = { reading: :reading, writing: :writing }
|
|
230
|
+
end
|
|
231
|
+
```
|
|
232
|
+
|
|
233
|
+
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:
|
|
234
|
+
|
|
235
|
+
```ruby
|
|
236
|
+
config.connects_to = { role: :writing }
|
|
237
|
+
```
|
|
238
|
+
|
|
239
|
+
When `connects_to` is `nil` (the default), no connection switching occurs and single-database apps are unaffected.
|
|
240
|
+
|
|
241
|
+
## Roadmap
|
|
120
242
|
|
|
121
|
-
|
|
122
|
-
- Webhook / alert config — POST to a URL when the failure count exceeds a threshold
|
|
123
|
-
- Multi-database support — when Solid Queue runs on a separate database from the host app
|
|
124
|
-
- Read replica support — route dashboard queries to a replica to avoid impacting the primary
|
|
243
|
+
See [ROADMAP.md](ROADMAP.md) for the full post-1.0 feature plan, organized by release milestone.
|
|
125
244
|
|
|
126
245
|
Pull requests for any of these are welcome. See [Contributing](#contributing) below.
|
|
127
246
|
|
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
|
|
@@ -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;
|
|
@@ -100,4 +117,28 @@
|
|
|
100
117
|
background: var(--muted);
|
|
101
118
|
border-color: var(--muted);
|
|
102
119
|
color: #fff;
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
.sqd-textarea {
|
|
123
|
+
width: 100%;
|
|
124
|
+
padding: 0.5rem 0.75rem;
|
|
125
|
+
border: 1px solid var(--border);
|
|
126
|
+
border-radius: 5px;
|
|
127
|
+
font-size: 13px;
|
|
128
|
+
background: var(--surface);
|
|
129
|
+
color: var(--text);
|
|
130
|
+
line-height: 1.6;
|
|
131
|
+
resize: vertical;
|
|
132
|
+
box-sizing: border-box;
|
|
133
|
+
display: block;
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
.sqd-textarea:focus {
|
|
137
|
+
outline: 2px solid var(--primary);
|
|
138
|
+
outline-offset: -1px;
|
|
139
|
+
border-color: var(--primary);
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
.sqd-args-form__submit {
|
|
143
|
+
margin-top: 0.75rem;
|
|
103
144
|
}
|
|
@@ -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,15 @@
|
|
|
1
|
+
module SolidQueueWeb
|
|
2
|
+
class FailedJobs::ArgumentsController < ApplicationController
|
|
3
|
+
def update
|
|
4
|
+
execution = SolidQueue::FailedExecution.find(params[:failed_job_id])
|
|
5
|
+
new_arguments = JSON.parse(params[:arguments])
|
|
6
|
+
execution.job.update!(arguments: new_arguments)
|
|
7
|
+
execution.retry
|
|
8
|
+
redirect_to failed_jobs_path, notice: "Job arguments updated and queued for retry."
|
|
9
|
+
rescue JSON::ParserError
|
|
10
|
+
redirect_to job_path(execution.job), alert: "Invalid JSON: could not parse arguments."
|
|
11
|
+
rescue => e
|
|
12
|
+
redirect_to failed_jobs_path, alert: "Could not update job: #{e.message}"
|
|
13
|
+
end
|
|
14
|
+
end
|
|
15
|
+
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
|
|
@@ -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
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
module SolidQueueWeb
|
|
2
|
+
class ScheduledJobsController < ApplicationController
|
|
3
|
+
def create
|
|
4
|
+
@period = params[:period].presence_in(PERIOD_DURATIONS.keys)
|
|
5
|
+
job_ids = scheduled_scope.pluck("solid_queue_jobs.id")
|
|
6
|
+
|
|
7
|
+
SolidQueue::ScheduledExecution.where(job_id: job_ids).update_all(scheduled_at: 1.second.ago)
|
|
8
|
+
SolidQueue::Job.where(id: job_ids).update_all(scheduled_at: 1.second.ago)
|
|
9
|
+
|
|
10
|
+
redirect_to jobs_path(status: "scheduled", period: @period),
|
|
11
|
+
notice: "#{job_ids.size} #{"job".pluralize(job_ids.size)} scheduled to run immediately."
|
|
12
|
+
rescue => e
|
|
13
|
+
redirect_to jobs_path(status: "scheduled", period: @period),
|
|
14
|
+
alert: "Could not run jobs: #{e.message}"
|
|
15
|
+
end
|
|
16
|
+
|
|
17
|
+
def update
|
|
18
|
+
@execution = SolidQueue::ScheduledExecution.find(params[:id])
|
|
19
|
+
@period = params[:period].presence_in(PERIOD_DURATIONS.keys)
|
|
20
|
+
@run_now = params[:offset] == "now"
|
|
21
|
+
new_time = resolve_new_time(@execution, params[:offset])
|
|
22
|
+
|
|
23
|
+
@execution.update!(scheduled_at: new_time)
|
|
24
|
+
@execution.job.update!(scheduled_at: new_time)
|
|
25
|
+
|
|
26
|
+
respond_to do |format|
|
|
27
|
+
format.turbo_stream
|
|
28
|
+
format.html do
|
|
29
|
+
notice = @run_now ? "Job scheduled to run immediately." : "Job rescheduled by +#{params[:offset]}."
|
|
30
|
+
redirect_to jobs_path(status: "scheduled", period: @period), notice: notice
|
|
31
|
+
end
|
|
32
|
+
end
|
|
33
|
+
rescue ArgumentError => e
|
|
34
|
+
redirect_to jobs_path(status: "scheduled"), alert: e.message
|
|
35
|
+
rescue => e
|
|
36
|
+
redirect_to jobs_path(status: "scheduled"), alert: "Could not reschedule job: #{e.message}"
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
private
|
|
40
|
+
|
|
41
|
+
def scheduled_scope
|
|
42
|
+
scope = SolidQueue::ScheduledExecution.joins(:job)
|
|
43
|
+
scope = scope.where("solid_queue_jobs.created_at >= ?", PERIOD_DURATIONS[@period].ago) if @period.present?
|
|
44
|
+
scope
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
def resolve_new_time(execution, offset)
|
|
48
|
+
return 1.second.ago if offset == "now"
|
|
49
|
+
raise ArgumentError, "Invalid offset." unless PERIOD_DURATIONS.key?(offset)
|
|
50
|
+
|
|
51
|
+
execution.scheduled_at + PERIOD_DURATIONS[offset]
|
|
52
|
+
end
|
|
53
|
+
end
|
|
54
|
+
end
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
module SolidQueueWeb
|
|
2
2
|
class Job
|
|
3
|
-
STATUSES
|
|
3
|
+
STATUSES = %w[ready scheduled claimed blocked failed].freeze
|
|
4
4
|
DISCARDABLE = %w[ready scheduled blocked].freeze
|
|
5
5
|
EXECUTION_MODELS = {
|
|
6
6
|
"ready" => SolidQueue::ReadyExecution,
|
|
@@ -9,5 +9,21 @@ module SolidQueueWeb
|
|
|
9
9
|
"blocked" => SolidQueue::BlockedExecution,
|
|
10
10
|
"failed" => SolidQueue::FailedExecution
|
|
11
11
|
}.freeze
|
|
12
|
+
|
|
13
|
+
def self.execution_model_for!(status)
|
|
14
|
+
raise ArgumentError, "Cannot discard #{status} jobs from this page." unless DISCARDABLE.include?(status)
|
|
15
|
+
|
|
16
|
+
EXECUTION_MODELS[status]
|
|
17
|
+
end
|
|
18
|
+
|
|
19
|
+
def self.derive_status(job)
|
|
20
|
+
return "failed" if job.failed_execution.present?
|
|
21
|
+
return "claimed" if job.claimed_execution.present?
|
|
22
|
+
return "blocked" if job.blocked_execution.present?
|
|
23
|
+
return "ready" if job.ready_execution.present?
|
|
24
|
+
return "scheduled" if job.scheduled_execution.present?
|
|
25
|
+
|
|
26
|
+
"finished"
|
|
27
|
+
end
|
|
12
28
|
end
|
|
13
29
|
end
|
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
require "net/http"
|
|
2
|
+
require "json"
|
|
3
|
+
require "uri"
|
|
4
|
+
|
|
5
|
+
module SolidQueueWeb
|
|
6
|
+
class AlertWebhook
|
|
7
|
+
MUTEX = Mutex.new
|
|
8
|
+
|
|
9
|
+
class << self
|
|
10
|
+
def call(failure_count:)
|
|
11
|
+
return unless configured?
|
|
12
|
+
return if failure_count < SolidQueueWeb.alert_failure_threshold
|
|
13
|
+
return unless should_fire?
|
|
14
|
+
|
|
15
|
+
urls = webhook_urls
|
|
16
|
+
Thread.new { urls.each { |url| post(url, failure_count) } }
|
|
17
|
+
end
|
|
18
|
+
|
|
19
|
+
def reset!
|
|
20
|
+
MUTEX.synchronize { @last_fired_at = nil }
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
private
|
|
24
|
+
|
|
25
|
+
def configured?
|
|
26
|
+
webhook_urls.any? && SolidQueueWeb.alert_failure_threshold.present?
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
def webhook_urls
|
|
30
|
+
Array(SolidQueueWeb.alert_webhook_url).flatten.compact.select(&:present?)
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
def should_fire?
|
|
34
|
+
MUTEX.synchronize do
|
|
35
|
+
cooldown = SolidQueueWeb.alert_webhook_cooldown
|
|
36
|
+
return false if @last_fired_at && Time.current - @last_fired_at < cooldown
|
|
37
|
+
|
|
38
|
+
@last_fired_at = Time.current
|
|
39
|
+
true
|
|
40
|
+
end
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
def post(url_string, failure_count)
|
|
44
|
+
uri = URI.parse(url_string)
|
|
45
|
+
payload = JSON.generate(
|
|
46
|
+
event: "failure_threshold_exceeded",
|
|
47
|
+
failure_count: failure_count,
|
|
48
|
+
threshold: SolidQueueWeb.alert_failure_threshold,
|
|
49
|
+
fired_at: Time.current.iso8601
|
|
50
|
+
)
|
|
51
|
+
http = Net::HTTP.new(uri.host, uri.port)
|
|
52
|
+
http.use_ssl = uri.scheme == "https"
|
|
53
|
+
http.open_timeout = 5
|
|
54
|
+
http.read_timeout = 10
|
|
55
|
+
request = Net::HTTP::Post.new(uri.path.presence || "/", "Content-Type" => "application/json")
|
|
56
|
+
request.body = payload
|
|
57
|
+
http.request(request)
|
|
58
|
+
rescue => e
|
|
59
|
+
Rails.logger.error("[SolidQueueWeb] Alert webhook failed: #{e.message}")
|
|
60
|
+
end
|
|
61
|
+
end
|
|
62
|
+
end
|
|
63
|
+
end
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
module SolidQueueWeb
|
|
2
|
+
class JobPerformanceStats
|
|
3
|
+
Row = Struct.new(:class_name, :count, :avg, :p50, :p95, :min, :max, keyword_init: true)
|
|
4
|
+
|
|
5
|
+
def initialize(scope)
|
|
6
|
+
@scope = scope
|
|
7
|
+
end
|
|
8
|
+
|
|
9
|
+
def rows
|
|
10
|
+
grouped = @scope.pluck(:class_name, :created_at, :finished_at)
|
|
11
|
+
.group_by(&:first)
|
|
12
|
+
|
|
13
|
+
grouped.map do |class_name, records|
|
|
14
|
+
durations = records.map { |_, created, finished| (finished - created).to_f }.sort
|
|
15
|
+
Row.new(
|
|
16
|
+
class_name: class_name,
|
|
17
|
+
count: durations.size,
|
|
18
|
+
avg: mean(durations),
|
|
19
|
+
p50: percentile(durations, 50),
|
|
20
|
+
p95: percentile(durations, 95),
|
|
21
|
+
min: durations.first,
|
|
22
|
+
max: durations.last
|
|
23
|
+
)
|
|
24
|
+
end.sort_by { |r| -r.p95 }
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
private
|
|
28
|
+
|
|
29
|
+
def mean(sorted)
|
|
30
|
+
sorted.sum / sorted.size
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
def percentile(sorted, pct)
|
|
34
|
+
idx = [(pct / 100.0 * sorted.size).ceil - 1, 0].max
|
|
35
|
+
sorted[idx]
|
|
36
|
+
end
|
|
37
|
+
end
|
|
38
|
+
end
|
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
module SolidQueueWeb
|
|
2
|
+
class MetricsPayload
|
|
3
|
+
def initialize
|
|
4
|
+
@now = Time.current
|
|
5
|
+
end
|
|
6
|
+
|
|
7
|
+
def to_h
|
|
8
|
+
payload = {
|
|
9
|
+
generated_at: @now.iso8601,
|
|
10
|
+
jobs: job_counts,
|
|
11
|
+
throughput: throughput,
|
|
12
|
+
queues: queue_list,
|
|
13
|
+
processes: process_summary
|
|
14
|
+
}
|
|
15
|
+
slow = slow_jobs_count
|
|
16
|
+
payload[:slow_jobs] = slow unless slow.nil?
|
|
17
|
+
payload
|
|
18
|
+
end
|
|
19
|
+
|
|
20
|
+
private
|
|
21
|
+
|
|
22
|
+
def job_counts
|
|
23
|
+
{
|
|
24
|
+
ready: SolidQueue::ReadyExecution.count,
|
|
25
|
+
scheduled: SolidQueue::ScheduledExecution.count,
|
|
26
|
+
claimed: SolidQueue::ClaimedExecution.count,
|
|
27
|
+
blocked: SolidQueue::BlockedExecution.count,
|
|
28
|
+
failed: SolidQueue::FailedExecution.count
|
|
29
|
+
}
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
def throughput
|
|
33
|
+
finished_times = SolidQueue::Job.where(finished_at: 24.hours.ago..@now).pluck(:finished_at)
|
|
34
|
+
{
|
|
35
|
+
completed_1h: finished_times.count { |t| t >= 1.hour.ago },
|
|
36
|
+
completed_24h: finished_times.size
|
|
37
|
+
}
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
def queue_list
|
|
41
|
+
depths = SolidQueue::ReadyExecution
|
|
42
|
+
.joins(:job)
|
|
43
|
+
.group("solid_queue_jobs.queue_name")
|
|
44
|
+
.count
|
|
45
|
+
SolidQueue::Queue.all.sort_by(&:name).map do |q|
|
|
46
|
+
{ name: q.name, depth: depths[q.name] || 0, paused: q.paused? }
|
|
47
|
+
end
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
def process_summary
|
|
51
|
+
processes = SolidQueue::Process.all.to_a
|
|
52
|
+
threshold = SolidQueue.process_alive_threshold.ago
|
|
53
|
+
{
|
|
54
|
+
total: processes.size,
|
|
55
|
+
healthy: processes.count { |p| p.last_heartbeat_at >= threshold },
|
|
56
|
+
stale: processes.count { |p| p.last_heartbeat_at < threshold },
|
|
57
|
+
by_kind: processes.group_by(&:kind).transform_values(&:size)
|
|
58
|
+
}
|
|
59
|
+
end
|
|
60
|
+
|
|
61
|
+
def slow_jobs_count
|
|
62
|
+
threshold = SolidQueueWeb.slow_job_threshold
|
|
63
|
+
SolidQueue::ClaimedExecution.where("created_at <= ?", threshold.ago).count if threshold
|
|
64
|
+
end
|
|
65
|
+
end
|
|
66
|
+
end
|
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
require "net/http"
|
|
2
|
+
require "json"
|
|
3
|
+
require "uri"
|
|
4
|
+
|
|
5
|
+
module SolidQueueWeb
|
|
6
|
+
class QueueDepthAlert
|
|
7
|
+
MUTEX = Mutex.new
|
|
8
|
+
|
|
9
|
+
class << self
|
|
10
|
+
def call
|
|
11
|
+
return unless configured?
|
|
12
|
+
|
|
13
|
+
queue_depths = SolidQueue::ReadyExecution
|
|
14
|
+
.joins(:job)
|
|
15
|
+
.group("solid_queue_jobs.queue_name")
|
|
16
|
+
.count
|
|
17
|
+
|
|
18
|
+
queue_depths.each do |queue_name, depth|
|
|
19
|
+
threshold = SolidQueueWeb.alert_queue_thresholds[queue_name.to_s]
|
|
20
|
+
next unless threshold && depth >= threshold
|
|
21
|
+
next unless should_fire?(queue_name)
|
|
22
|
+
|
|
23
|
+
urls = webhook_urls
|
|
24
|
+
Thread.new { urls.each { |url| post(url, queue_name, depth, threshold) } }
|
|
25
|
+
end
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
def reset!
|
|
29
|
+
MUTEX.synchronize { @last_fired_at = {} }
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
private
|
|
33
|
+
|
|
34
|
+
def configured?
|
|
35
|
+
SolidQueueWeb.alert_queue_thresholds.any? && webhook_urls.any?
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
def webhook_urls
|
|
39
|
+
Array(SolidQueueWeb.alert_webhook_url).flatten.compact.select(&:present?)
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
def should_fire?(queue_name)
|
|
43
|
+
MUTEX.synchronize do
|
|
44
|
+
@last_fired_at ||= {}
|
|
45
|
+
cooldown = SolidQueueWeb.alert_webhook_cooldown
|
|
46
|
+
return false if @last_fired_at[queue_name] && Time.current - @last_fired_at[queue_name] < cooldown
|
|
47
|
+
|
|
48
|
+
@last_fired_at[queue_name] = Time.current
|
|
49
|
+
true
|
|
50
|
+
end
|
|
51
|
+
end
|
|
52
|
+
|
|
53
|
+
def post(url_string, queue_name, depth, threshold)
|
|
54
|
+
uri = URI.parse(url_string)
|
|
55
|
+
payload = JSON.generate(
|
|
56
|
+
event: "queue_depth_threshold_exceeded",
|
|
57
|
+
queue_name: queue_name,
|
|
58
|
+
depth: depth,
|
|
59
|
+
threshold: threshold,
|
|
60
|
+
fired_at: Time.current.iso8601
|
|
61
|
+
)
|
|
62
|
+
http = Net::HTTP.new(uri.host, uri.port)
|
|
63
|
+
http.use_ssl = uri.scheme == "https"
|
|
64
|
+
http.open_timeout = 5
|
|
65
|
+
http.read_timeout = 10
|
|
66
|
+
request = Net::HTTP::Post.new(uri.path.presence || "/", "Content-Type" => "application/json")
|
|
67
|
+
request.body = payload
|
|
68
|
+
http.request(request)
|
|
69
|
+
rescue => e
|
|
70
|
+
Rails.logger.error("[SolidQueueWeb] Queue depth alert webhook failed: #{e.message}")
|
|
71
|
+
end
|
|
72
|
+
end
|
|
73
|
+
end
|
|
74
|
+
end
|
|
@@ -21,6 +21,7 @@
|
|
|
21
21
|
<li><%= link_to "Queues", queues_path, class: current_page?(queues_path) ? "active" : "", aria: { current: current_page?(queues_path) ? "page" : nil } %></li>
|
|
22
22
|
<li><%= link_to "Jobs", jobs_path, class: current_page?(jobs_path) ? "active" : "", aria: { current: current_page?(jobs_path) ? "page" : nil } %></li>
|
|
23
23
|
<li><%= link_to "History", history_path, class: current_page?(history_path) ? "active" : "", aria: { current: current_page?(history_path) ? "page" : nil } %></li>
|
|
24
|
+
<li><%= link_to "Performance", performance_path, class: current_page?(performance_path) ? "active" : "", aria: { current: current_page?(performance_path) ? "page" : nil } %></li>
|
|
24
25
|
<li><%= link_to "Failed", failed_jobs_path, class: current_page?(failed_jobs_path) ? "active" : "", aria: { current: current_page?(failed_jobs_path) ? "page" : nil } %></li>
|
|
25
26
|
<li><%= link_to "Recurring", recurring_tasks_path, class: current_page?(recurring_tasks_path) ? "active" : "", aria: { current: current_page?(recurring_tasks_path) ? "page" : nil } %></li>
|
|
26
27
|
<li><%= link_to "Processes", processes_path, class: current_page?(processes_path) ? "active" : "", aria: { current: current_page?(processes_path) ? "page" : nil } %></li>
|
|
@@ -9,6 +9,16 @@
|
|
|
9
9
|
params: { queue: @queue, q: @search, period: @period },
|
|
10
10
|
class: "sqd-btn sqd-btn--primary",
|
|
11
11
|
data: { confirm: "Retry all #{@failed_jobs.size} failed jobs?" } %>
|
|
12
|
+
<% if @failed_jobs.size > 1 %>
|
|
13
|
+
<% %w[5s 10s 30s 1m].each do |interval| %>
|
|
14
|
+
<%= button_to "+#{interval}", retry_all_failed_jobs_path,
|
|
15
|
+
method: :post,
|
|
16
|
+
params: { stagger: interval, queue: @queue, q: @search, period: @period },
|
|
17
|
+
class: "sqd-btn sqd-btn--muted sqd-btn--sm",
|
|
18
|
+
title: "Retry all, staggered #{interval} apart",
|
|
19
|
+
data: { confirm: "Retry #{@failed_jobs.size} failed jobs staggered #{interval} apart?" } %>
|
|
20
|
+
<% end %>
|
|
21
|
+
<% end %>
|
|
12
22
|
<%= button_to "Discard All", discard_all_failed_jobs_path,
|
|
13
23
|
method: :post,
|
|
14
24
|
params: { queue: @queue, q: @search, period: @period },
|
|
@@ -5,22 +5,29 @@
|
|
|
5
5
|
|
|
6
6
|
<div class="sqd-page-header">
|
|
7
7
|
<div class="sqd-filters">
|
|
8
|
-
<%= link_to "Ready", jobs_path(status: "ready", q: @search, period: @period), class: @status == "ready" ? "active" : "" %>
|
|
9
|
-
<%= link_to "Scheduled", jobs_path(status: "scheduled", q: @search, period: @period), class: @status == "scheduled" ? "active" : "" %>
|
|
10
|
-
<%= link_to "Running", jobs_path(status: "claimed", q: @search, period: @period), class: @status == "claimed" ? "active" : "" %>
|
|
11
|
-
<%= link_to "Blocked", jobs_path(status: "blocked", q: @search, period: @period), class: @status == "blocked" ? "active" : "" %>
|
|
12
|
-
<%= link_to "Failed", jobs_path(status: "failed", q: @search, period: @period), class: @status == "failed" ? "active" : "" %>
|
|
8
|
+
<%= link_to "Ready", jobs_path(status: "ready", q: @search, period: @period, priority: @priority), class: @status == "ready" ? "active" : "" %>
|
|
9
|
+
<%= link_to "Scheduled", jobs_path(status: "scheduled", q: @search, period: @period, priority: @priority), class: @status == "scheduled" ? "active" : "" %>
|
|
10
|
+
<%= link_to "Running", jobs_path(status: "claimed", q: @search, period: @period, priority: @priority), class: @status == "claimed" ? "active" : "" %>
|
|
11
|
+
<%= link_to "Blocked", jobs_path(status: "blocked", q: @search, period: @period, priority: @priority), class: @status == "blocked" ? "active" : "" %>
|
|
12
|
+
<%= link_to "Failed", jobs_path(status: "failed", q: @search, period: @period, priority: @priority), class: @status == "failed" ? "active" : "" %>
|
|
13
13
|
</div>
|
|
14
14
|
<% if @jobs.any? %>
|
|
15
15
|
<div class="sqd-actions">
|
|
16
16
|
<%= link_to "Export CSV", jobs_path(format: :csv, status: @status, q: @search, period: @period),
|
|
17
17
|
class: "sqd-btn sqd-btn--muted", data: { turbo: false } %>
|
|
18
|
+
<% if @status == "scheduled" %>
|
|
19
|
+
<%= button_to "Run All Now", run_all_now_scheduled_jobs_path,
|
|
20
|
+
method: :post,
|
|
21
|
+
params: { period: @period },
|
|
22
|
+
class: "sqd-btn sqd-btn--primary",
|
|
23
|
+
data: { confirm: "Run all #{@pagy.count} scheduled jobs immediately?" } %>
|
|
24
|
+
<% end %>
|
|
18
25
|
<% if discardable %>
|
|
19
26
|
<%= button_to "Discard All", discard_all_jobs_path,
|
|
20
27
|
method: :post,
|
|
21
28
|
params: { status: @status, period: @period },
|
|
22
29
|
class: "sqd-btn sqd-btn--danger",
|
|
23
|
-
data: { confirm: "Discard all #{@
|
|
30
|
+
data: { confirm: "Discard all #{@pagy.count} #{@status} jobs? This cannot be undone." } %>
|
|
24
31
|
<% end %>
|
|
25
32
|
</div>
|
|
26
33
|
<% end %>
|
|
@@ -32,14 +39,23 @@
|
|
|
32
39
|
<input class="sqd-search__input" type="search" name="q" value="<%= @search %>"
|
|
33
40
|
placeholder="Filter by job class…" autocomplete="off" aria-label="Filter by job class"
|
|
34
41
|
data-action="input->search#filter">
|
|
35
|
-
<% if @
|
|
42
|
+
<% if @priority_options.size > 1 %>
|
|
43
|
+
<select name="priority" class="sqd-select" aria-label="Filter by priority"
|
|
44
|
+
onchange="this.form.submit()">
|
|
45
|
+
<option value="" <%= @priority.nil? ? "selected" : "" %>>All priorities</option>
|
|
46
|
+
<% @priority_options.each do |p| %>
|
|
47
|
+
<option value="<%= p %>" <%= @priority.to_s == p.to_s ? "selected" : "" %>>Priority <%= p %></option>
|
|
48
|
+
<% end %>
|
|
49
|
+
</select>
|
|
50
|
+
<% end %>
|
|
51
|
+
<% if @search.present? || @priority.present? %>
|
|
36
52
|
<%= link_to "Clear", jobs_path(status: @status, period: @period), class: "sqd-btn sqd-btn--muted" %>
|
|
37
53
|
<% end %>
|
|
38
54
|
<div class="sqd-period-filter" role="group" aria-label="Time period">
|
|
39
|
-
<%= link_to "All", jobs_path(status: @status, q: @search), class: @period.nil? ? "active" : "", aria: { current: @period.nil? ? "true" : nil }, aria_label: "All time" %>
|
|
40
|
-
<%= link_to "1h", jobs_path(status: @status, q: @search, period: "1h"), class: @period == "1h" ? "active" : "", aria: { current: @period == "1h" ? "true" : nil }, aria_label: "Last 1 hour" %>
|
|
41
|
-
<%= link_to "24h", jobs_path(status: @status, q: @search, period: "24h"), class: @period == "24h" ? "active" : "", aria: { current: @period == "24h" ? "true" : nil }, aria_label: "Last 24 hours" %>
|
|
42
|
-
<%= link_to "7d", jobs_path(status: @status, q: @search, period: "7d"), class: @period == "7d" ? "active" : "", aria: { current: @period == "7d" ? "true" : nil }, aria_label: "Last 7 days" %>
|
|
55
|
+
<%= link_to "All", jobs_path(status: @status, q: @search, priority: @priority), class: @period.nil? ? "active" : "", aria: { current: @period.nil? ? "true" : nil }, aria_label: "All time" %>
|
|
56
|
+
<%= link_to "1h", jobs_path(status: @status, q: @search, priority: @priority, period: "1h"), class: @period == "1h" ? "active" : "", aria: { current: @period == "1h" ? "true" : nil }, aria_label: "Last 1 hour" %>
|
|
57
|
+
<%= link_to "24h", jobs_path(status: @status, q: @search, priority: @priority, period: "24h"), class: @period == "24h" ? "active" : "", aria: { current: @period == "24h" ? "true" : nil }, aria_label: "Last 24 hours" %>
|
|
58
|
+
<%= link_to "7d", jobs_path(status: @status, q: @search, priority: @priority, period: "7d"), class: @period == "7d" ? "active" : "", aria: { current: @period == "7d" ? "true" : nil }, aria_label: "Last 7 days" %>
|
|
43
59
|
</div>
|
|
44
60
|
</form>
|
|
45
61
|
|
|
@@ -94,11 +110,24 @@
|
|
|
94
110
|
class: "sqd-mono", style: "color: inherit;" %>
|
|
95
111
|
</td>
|
|
96
112
|
<td><%= job.priority %></td>
|
|
97
|
-
<td class="sqd-mono">
|
|
113
|
+
<td id="scheduled_at_<%= execution.id %>" class="sqd-mono">
|
|
98
114
|
<%= job.scheduled_at ? job.scheduled_at.strftime("%Y-%m-%d %H:%M:%S") : "—" %>
|
|
99
115
|
</td>
|
|
100
116
|
<td class="sqd-mono"><%= job.created_at.strftime("%Y-%m-%d %H:%M:%S") %></td>
|
|
101
117
|
<td class="sqd-row-actions">
|
|
118
|
+
<% if @status == "scheduled" %>
|
|
119
|
+
<%= button_to "Run Now", scheduled_job_path(execution),
|
|
120
|
+
method: :patch,
|
|
121
|
+
params: { offset: "now", period: @period },
|
|
122
|
+
class: "sqd-btn sqd-btn--primary sqd-btn--sm",
|
|
123
|
+
data: { confirm: "Run this job immediately?" } %>
|
|
124
|
+
<% %w[1h 24h 7d].each do |offset| %>
|
|
125
|
+
<%= button_to "+#{offset}", scheduled_job_path(execution),
|
|
126
|
+
method: :patch,
|
|
127
|
+
params: { offset: offset, period: @period },
|
|
128
|
+
class: "sqd-btn sqd-btn--muted sqd-btn--sm" %>
|
|
129
|
+
<% end %>
|
|
130
|
+
<% end %>
|
|
102
131
|
<%= button_to "Discard", job_path(execution),
|
|
103
132
|
method: :delete,
|
|
104
133
|
params: { status: @status, period: @period },
|
|
@@ -63,7 +63,19 @@
|
|
|
63
63
|
|
|
64
64
|
<div class="sqd-card sqd-detail-section">
|
|
65
65
|
<h2 class="sqd-section-title">Arguments</h2>
|
|
66
|
-
|
|
66
|
+
<% if @execution_status == "failed" && @job.failed_execution %>
|
|
67
|
+
<% args_json = begin; JSON.pretty_generate(@job.arguments); rescue; @job.arguments.inspect; end %>
|
|
68
|
+
<%= form_with url: failed_job_arguments_path(@job.failed_execution), method: :patch do |f| %>
|
|
69
|
+
<%= f.text_area :arguments,
|
|
70
|
+
value: args_json,
|
|
71
|
+
class: "sqd-textarea sqd-mono",
|
|
72
|
+
rows: [args_json.lines.count + 1, 6].max,
|
|
73
|
+
"aria-label": "Job arguments JSON" %>
|
|
74
|
+
<%= f.submit "Retry with these arguments", class: "sqd-btn sqd-btn--primary sqd-args-form__submit" %>
|
|
75
|
+
<% end %>
|
|
76
|
+
<% else %>
|
|
77
|
+
<pre class="sqd-pre"><%= JSON.pretty_generate(@job.arguments) rescue @job.arguments.inspect %></pre>
|
|
78
|
+
<% end %>
|
|
67
79
|
</div>
|
|
68
80
|
</div>
|
|
69
81
|
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
<div class="sqd-page-header">
|
|
2
|
+
<h1 class="sqd-page-title">Performance</h1>
|
|
3
|
+
</div>
|
|
4
|
+
|
|
5
|
+
<form class="sqd-search" action="<%= performance_path %>" method="get">
|
|
6
|
+
<div class="sqd-period-filter" role="group" aria-label="Time period">
|
|
7
|
+
<%= link_to "All", performance_path, class: @period.nil? ? "active" : "", aria: { current: @period.nil? ? "true" : nil } %>
|
|
8
|
+
<%= link_to "1h", performance_path(period: "1h"), class: @period == "1h" ? "active" : "", aria: { current: @period == "1h" ? "true" : nil } %>
|
|
9
|
+
<%= link_to "24h", performance_path(period: "24h"), class: @period == "24h" ? "active" : "", aria: { current: @period == "24h" ? "true" : nil } %>
|
|
10
|
+
<%= link_to "7d", performance_path(period: "7d"), class: @period == "7d" ? "active" : "", aria: { current: @period == "7d" ? "true" : nil } %>
|
|
11
|
+
</div>
|
|
12
|
+
</form>
|
|
13
|
+
|
|
14
|
+
<% if @rows.any? %>
|
|
15
|
+
<div class="sqd-card" style="margin-top: 1rem;">
|
|
16
|
+
<table>
|
|
17
|
+
<thead>
|
|
18
|
+
<tr>
|
|
19
|
+
<th scope="col">Job Class</th>
|
|
20
|
+
<th scope="col" style="text-align: right;">Runs</th>
|
|
21
|
+
<th scope="col" style="text-align: right;">Avg</th>
|
|
22
|
+
<th scope="col" style="text-align: right;">p50</th>
|
|
23
|
+
<th scope="col" style="text-align: right;">p95</th>
|
|
24
|
+
<th scope="col" style="text-align: right;">Min</th>
|
|
25
|
+
<th scope="col" style="text-align: right;">Max</th>
|
|
26
|
+
</tr>
|
|
27
|
+
</thead>
|
|
28
|
+
<tbody>
|
|
29
|
+
<% @rows.each do |row| %>
|
|
30
|
+
<tr>
|
|
31
|
+
<td>
|
|
32
|
+
<%= link_to row.class_name, history_path(q: row.class_name, period: @period),
|
|
33
|
+
class: "sqd-table-link" %>
|
|
34
|
+
</td>
|
|
35
|
+
<td class="sqd-mono" style="text-align: right;"><%= row.count %></td>
|
|
36
|
+
<td class="sqd-mono" style="text-align: right;"><%= format_duration(row.avg) %></td>
|
|
37
|
+
<td class="sqd-mono" style="text-align: right;"><%= format_duration(row.p50) %></td>
|
|
38
|
+
<td class="sqd-mono" style="text-align: right;"><%= format_duration(row.p95) %></td>
|
|
39
|
+
<td class="sqd-mono" style="text-align: right;"><%= format_duration(row.min) %></td>
|
|
40
|
+
<td class="sqd-mono" style="text-align: right;"><%= format_duration(row.max) %></td>
|
|
41
|
+
</tr>
|
|
42
|
+
<% end %>
|
|
43
|
+
</tbody>
|
|
44
|
+
</table>
|
|
45
|
+
</div>
|
|
46
|
+
<% else %>
|
|
47
|
+
<div class="sqd-card" style="margin-top: 1rem;">
|
|
48
|
+
<div class="sqd-empty">No finished jobs found<%= " in the last #{@period}" if @period %>.</div>
|
|
49
|
+
</div>
|
|
50
|
+
<% end %>
|
|
@@ -14,6 +14,7 @@
|
|
|
14
14
|
<th scope="col">Next Run</th>
|
|
15
15
|
<th scope="col">Last Run</th>
|
|
16
16
|
<th scope="col">Type</th>
|
|
17
|
+
<th scope="col"><span class="sqd-sr-only">Actions</span></th>
|
|
17
18
|
</tr>
|
|
18
19
|
</thead>
|
|
19
20
|
<tbody>
|
|
@@ -60,6 +61,12 @@
|
|
|
60
61
|
<span class="sqd-badge sqd-badge--dynamic">Dynamic</span>
|
|
61
62
|
<% end %>
|
|
62
63
|
</td>
|
|
64
|
+
<td class="sqd-row-actions">
|
|
65
|
+
<%= button_to "Run Now", recurring_task_run_path(task.key),
|
|
66
|
+
method: :post,
|
|
67
|
+
class: "sqd-btn sqd-btn--primary sqd-btn--sm",
|
|
68
|
+
data: { confirm: "Run \"#{task.key}\" immediately?" } %>
|
|
69
|
+
</td>
|
|
63
70
|
</tr>
|
|
64
71
|
<% end %>
|
|
65
72
|
</tbody>
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
<% if @run_now %>
|
|
2
|
+
<%= turbo_stream.remove "execution_#{@execution.id}" %>
|
|
3
|
+
<% else %>
|
|
4
|
+
<%= turbo_stream.replace "scheduled_at_#{@execution.id}" do %>
|
|
5
|
+
<td id="scheduled_at_<%= @execution.id %>" class="sqd-mono">
|
|
6
|
+
<%= @execution.scheduled_at.strftime("%Y-%m-%d %H:%M:%S") %>
|
|
7
|
+
</td>
|
|
8
|
+
<% end %>
|
|
9
|
+
<% end %>
|
data/config/routes.rb
CHANGED
|
@@ -2,10 +2,14 @@ SolidQueueWeb::Engine.routes.draw do
|
|
|
2
2
|
root to: "dashboard#index"
|
|
3
3
|
resource :blocked_jobs, only: [:destroy]
|
|
4
4
|
|
|
5
|
-
get "
|
|
6
|
-
get "
|
|
5
|
+
get "metrics", to: "metrics#index", as: :metrics, defaults: { format: :json }
|
|
6
|
+
get "search", to: "search#index", as: :search
|
|
7
|
+
get "history", to: "history#index", as: :history
|
|
8
|
+
get "performance", to: "performance#index", as: :performance
|
|
7
9
|
|
|
8
|
-
resources :recurring_tasks, only: [:index]
|
|
10
|
+
resources :recurring_tasks, only: [:index], param: :key do
|
|
11
|
+
resource :run, only: [:create], controller: "recurring_tasks/runs"
|
|
12
|
+
end
|
|
9
13
|
resources :processes, only: [:index]
|
|
10
14
|
resources :queues, only: [:index], param: :name do
|
|
11
15
|
resource :pause, only: [:create, :destroy], controller: "queues/pauses"
|
|
@@ -18,6 +22,12 @@ SolidQueueWeb::Engine.routes.draw do
|
|
|
18
22
|
|
|
19
23
|
# Singular selection resources must be defined before the member routes of their
|
|
20
24
|
# parent resources, otherwise DELETE /list/selection matches /list/:id first.
|
|
25
|
+
resources :scheduled_jobs, only: [:update] do
|
|
26
|
+
collection do
|
|
27
|
+
post :run_all_now, action: :create
|
|
28
|
+
end
|
|
29
|
+
end
|
|
30
|
+
|
|
21
31
|
resource :job_selection, path: "list/selection", only: [:destroy], controller: "jobs/selections"
|
|
22
32
|
resources :jobs, path: "list", only: [:index, :show, :destroy] do
|
|
23
33
|
collection do
|
|
@@ -28,6 +38,7 @@ SolidQueueWeb::Engine.routes.draw do
|
|
|
28
38
|
resource :failed_job_selection, path: "failed_jobs/selection", only: [:create, :destroy],
|
|
29
39
|
controller: "failed_jobs/selections"
|
|
30
40
|
resources :failed_jobs, only: [:index, :destroy] do
|
|
41
|
+
resource :arguments, only: [:update], controller: "failed_jobs/arguments"
|
|
31
42
|
collection do
|
|
32
43
|
post :retry_all, to: "retry_failed_jobs#create"
|
|
33
44
|
post :discard_all, action: :destroy
|
data/lib/solid_queue_web.rb
CHANGED
|
@@ -5,7 +5,8 @@ require "solid_queue_web/engine"
|
|
|
5
5
|
module SolidQueueWeb
|
|
6
6
|
class << self
|
|
7
7
|
attr_writer :page_size, :dashboard_refresh_interval, :default_refresh_interval, :search_results_limit,
|
|
8
|
-
:slow_job_threshold
|
|
8
|
+
:slow_job_threshold, :alert_webhook_url, :alert_failure_threshold, :alert_webhook_cooldown,
|
|
9
|
+
:alert_queue_thresholds, :connects_to
|
|
9
10
|
|
|
10
11
|
def page_size
|
|
11
12
|
@page_size || 25
|
|
@@ -27,6 +28,26 @@ module SolidQueueWeb
|
|
|
27
28
|
@slow_job_threshold
|
|
28
29
|
end
|
|
29
30
|
|
|
31
|
+
def alert_webhook_url
|
|
32
|
+
@alert_webhook_url
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
def alert_failure_threshold
|
|
36
|
+
@alert_failure_threshold
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
def alert_webhook_cooldown
|
|
40
|
+
@alert_webhook_cooldown || 3600
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
def alert_queue_thresholds
|
|
44
|
+
@alert_queue_thresholds || {}
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
def connects_to
|
|
48
|
+
@connects_to
|
|
49
|
+
end
|
|
50
|
+
|
|
30
51
|
def configure
|
|
31
52
|
yield self
|
|
32
53
|
end
|
metadata
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
--- !ruby/object:Gem::Specification
|
|
2
2
|
name: solid_queue_web
|
|
3
3
|
version: !ruby/object:Gem::Version
|
|
4
|
-
version:
|
|
4
|
+
version: 1.1.0
|
|
5
5
|
platform: ruby
|
|
6
6
|
authors:
|
|
7
7
|
- Chuck Smith
|
|
@@ -93,9 +93,12 @@ dependencies:
|
|
|
93
93
|
- - ">="
|
|
94
94
|
- !ruby/object:Gem::Version
|
|
95
95
|
version: '1.2'
|
|
96
|
-
description: Mount SolidQueueWeb in any Rails app using Solid Queue to get a
|
|
97
|
-
|
|
98
|
-
|
|
96
|
+
description: 'Mount SolidQueueWeb in any Rails app using Solid Queue to get a full-featured
|
|
97
|
+
job dashboard: inspect jobs by status (ready, scheduled, running, blocked, failed),
|
|
98
|
+
retry or discard failed jobs, reschedule or run scheduled jobs immediately, manage
|
|
99
|
+
recurring tasks, filter by queue/priority/period, export to CSV, detect slow jobs,
|
|
100
|
+
view queue depth sparklines, track job performance (p50/p95), and scrape a /metrics
|
|
101
|
+
JSON endpoint for external monitoring — all without leaving your app.'
|
|
99
102
|
email:
|
|
100
103
|
- eclectic-coding@users.noreply.github.com
|
|
101
104
|
executables: []
|
|
@@ -121,17 +124,22 @@ files:
|
|
|
121
124
|
- app/controllers/solid_queue_web/application_controller.rb
|
|
122
125
|
- app/controllers/solid_queue_web/blocked_jobs_controller.rb
|
|
123
126
|
- app/controllers/solid_queue_web/dashboard_controller.rb
|
|
127
|
+
- app/controllers/solid_queue_web/failed_jobs/arguments_controller.rb
|
|
124
128
|
- app/controllers/solid_queue_web/failed_jobs/selections_controller.rb
|
|
125
129
|
- app/controllers/solid_queue_web/failed_jobs_controller.rb
|
|
126
130
|
- app/controllers/solid_queue_web/history_controller.rb
|
|
127
131
|
- app/controllers/solid_queue_web/jobs/selections_controller.rb
|
|
128
132
|
- app/controllers/solid_queue_web/jobs_controller.rb
|
|
133
|
+
- app/controllers/solid_queue_web/metrics_controller.rb
|
|
134
|
+
- app/controllers/solid_queue_web/performance_controller.rb
|
|
129
135
|
- app/controllers/solid_queue_web/processes_controller.rb
|
|
130
136
|
- app/controllers/solid_queue_web/queues/jobs_controller.rb
|
|
131
137
|
- app/controllers/solid_queue_web/queues/pauses_controller.rb
|
|
132
138
|
- app/controllers/solid_queue_web/queues_controller.rb
|
|
139
|
+
- app/controllers/solid_queue_web/recurring_tasks/runs_controller.rb
|
|
133
140
|
- app/controllers/solid_queue_web/recurring_tasks_controller.rb
|
|
134
141
|
- app/controllers/solid_queue_web/retry_failed_jobs_controller.rb
|
|
142
|
+
- app/controllers/solid_queue_web/scheduled_jobs_controller.rb
|
|
135
143
|
- app/controllers/solid_queue_web/search_controller.rb
|
|
136
144
|
- app/helpers/solid_queue_web/application_helper.rb
|
|
137
145
|
- app/javascript/solid_queue_web/application.js
|
|
@@ -142,7 +150,11 @@ files:
|
|
|
142
150
|
- app/jobs/solid_queue_web/application_job.rb
|
|
143
151
|
- app/models/solid_queue_web/application_record.rb
|
|
144
152
|
- app/models/solid_queue_web/job.rb
|
|
153
|
+
- app/services/solid_queue_web/alert_webhook.rb
|
|
145
154
|
- app/services/solid_queue_web/dashboard_stats.rb
|
|
155
|
+
- app/services/solid_queue_web/job_performance_stats.rb
|
|
156
|
+
- app/services/solid_queue_web/metrics_payload.rb
|
|
157
|
+
- app/services/solid_queue_web/queue_depth_alert.rb
|
|
146
158
|
- app/services/solid_queue_web/queue_stats.rb
|
|
147
159
|
- app/views/layouts/solid_queue_web/application.html.erb
|
|
148
160
|
- app/views/solid_queue_web/dashboard/index.html.erb
|
|
@@ -151,11 +163,13 @@ files:
|
|
|
151
163
|
- app/views/solid_queue_web/jobs/destroy.turbo_stream.erb
|
|
152
164
|
- app/views/solid_queue_web/jobs/index.html.erb
|
|
153
165
|
- app/views/solid_queue_web/jobs/show.html.erb
|
|
166
|
+
- app/views/solid_queue_web/performance/index.html.erb
|
|
154
167
|
- app/views/solid_queue_web/processes/index.html.erb
|
|
155
168
|
- app/views/solid_queue_web/queues/index.html.erb
|
|
156
169
|
- app/views/solid_queue_web/queues/jobs/destroy.turbo_stream.erb
|
|
157
170
|
- app/views/solid_queue_web/queues/jobs/index.html.erb
|
|
158
171
|
- app/views/solid_queue_web/recurring_tasks/index.html.erb
|
|
172
|
+
- app/views/solid_queue_web/scheduled_jobs/update.turbo_stream.erb
|
|
159
173
|
- app/views/solid_queue_web/search/index.html.erb
|
|
160
174
|
- config/importmap.rb
|
|
161
175
|
- config/routes.rb
|