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.
Files changed (29) hide show
  1. checksums.yaml +4 -4
  2. data/README.md +137 -18
  3. data/Rakefile +3 -1
  4. data/app/assets/stylesheets/solid_queue_web/_07_forms.css +41 -0
  5. data/app/controllers/solid_queue_web/application_controller.rb +19 -1
  6. data/app/controllers/solid_queue_web/dashboard_controller.rb +2 -0
  7. data/app/controllers/solid_queue_web/failed_jobs/arguments_controller.rb +15 -0
  8. data/app/controllers/solid_queue_web/jobs_controller.rb +16 -27
  9. data/app/controllers/solid_queue_web/metrics_controller.rb +7 -0
  10. data/app/controllers/solid_queue_web/performance_controller.rb +12 -0
  11. data/app/controllers/solid_queue_web/recurring_tasks/runs_controller.rb +18 -0
  12. data/app/controllers/solid_queue_web/retry_failed_jobs_controller.rb +23 -2
  13. data/app/controllers/solid_queue_web/scheduled_jobs_controller.rb +54 -0
  14. data/app/models/solid_queue_web/job.rb +17 -1
  15. data/app/services/solid_queue_web/alert_webhook.rb +63 -0
  16. data/app/services/solid_queue_web/job_performance_stats.rb +38 -0
  17. data/app/services/solid_queue_web/metrics_payload.rb +66 -0
  18. data/app/services/solid_queue_web/queue_depth_alert.rb +74 -0
  19. data/app/views/layouts/solid_queue_web/application.html.erb +1 -0
  20. data/app/views/solid_queue_web/failed_jobs/index.html.erb +10 -0
  21. data/app/views/solid_queue_web/jobs/index.html.erb +41 -12
  22. data/app/views/solid_queue_web/jobs/show.html.erb +13 -1
  23. data/app/views/solid_queue_web/performance/index.html.erb +50 -0
  24. data/app/views/solid_queue_web/recurring_tasks/index.html.erb +7 -0
  25. data/app/views/solid_queue_web/scheduled_jobs/update.turbo_stream.erb +9 -0
  26. data/config/routes.rb +14 -3
  27. data/lib/solid_queue_web/version.rb +1 -1
  28. data/lib/solid_queue_web.rb +22 -1
  29. metadata +18 -4
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 462f81d84dead7a68833c768775eda6cb0b55b66862d58fa1b49b504fa417644
4
- data.tar.gz: cc388f882d5709e8d92a778a2c6ccaa39d04022c62c9bc7e89320e416c5148d2
3
+ metadata.gz: e28c1cc2c32722a5b876083166f98549f60c2788cb5e225d6d6fa1122a2964ae
4
+ data.tar.gz: b68d4c0cf42091242b1816956e217ae64f471142df5ce974f71772ae8b454d83
5
5
  SHA512:
6
- metadata.gz: '087a8cb99798a1d21df1347008380c58242a98247d48b80cba0865c4bb0e46e800d37a2c21eb887fc516c6c724c5e138ebbc8a517dc62e730b1d2ea08fe6b5d3'
7
- data.tar.gz: 3885a114fa3f31db4b949a3b397070734b76c6e447d82bc7cd5e67628163760e03a2b70e90c1ef532143170752430de6ba1dd5a18fa0c52ff379462e90dc8d30
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
+ ![SolidQueueWeb dashboard](docs/solid-queue-web.png)
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 by queue; 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
- - **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
40
- - **Job detail** — full arguments, timestamps, blocked-until date, and error backtrace; action buttons based on job status
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
- ## Screenshots
53
-
54
- ![SolidQueueWeb dashboard](docs/solid-queue-web.png)
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
- ## Roadmap
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
- Planned features, roughly ordered by priority:
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
- **Operations**
117
- - Scheduled job management — reschedule a job to run immediately, or push its `scheduled_at` forward
118
- - Bulk retry with delay — retry all failed jobs with a configurable stagger to avoid thundering herd
119
- - Admin audit log — record who retried or discarded which jobs and when (requires host-app user identity)
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
- **Infrastructure**
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 = { "1h" => 1.hour, "24h" => 24.hours, "7d" => 7.days }.freeze
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
 
@@ -2,6 +2,8 @@ module SolidQueueWeb
2
2
  class DashboardController < ApplicationController
3
3
  def index
4
4
  @stats = DashboardStats.new
5
+ AlertWebhook.call(failure_count: @stats.counts[:failed])
6
+ QueueDepthAlert.call
5
7
  end
6
8
  end
7
9
  end
@@ -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 = params[:status].presence_in(Job::STATUSES) || "ready"
7
- @search = params[:q].presence
8
- @period = params[:period].presence_in(PERIOD_DURATIONS.keys)
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}%") if @search.present?
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
- model = execution_model_for!(@status)
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,7 @@
1
+ module SolidQueueWeb
2
+ class MetricsController < ApplicationController
3
+ def index
4
+ render json: MetricsPayload.new.to_h
5
+ end
6
+ end
7
+ 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
- SolidQueue::FailedExecution.retry_all(jobs)
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: "#{jobs.size} #{"job".pluralize(jobs.size)} queued for retry."
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 = %w[ready scheduled claimed blocked failed].freeze
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 #{@jobs.size} #{@status} jobs? This cannot be undone." } %>
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 @search.present? %>
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
- <pre class="sqd-pre"><%= JSON.pretty_generate(@job.arguments) rescue @job.arguments.inspect %></pre>
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 "search", to: "search#index", as: :search
6
- get "history", to: "history#index", as: :history
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
@@ -1,3 +1,3 @@
1
1
  module SolidQueueWeb
2
- VERSION = "0.9.0"
2
+ VERSION = "1.1.0"
3
3
  end
@@ -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: 0.9.0
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 dashboard
97
- for your queues, jobs by status, failed executions, and job actions (retry, discard)
98
- all without leaving your app.
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