solid_stack_web 0.2.0 → 0.4.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 (41) hide show
  1. checksums.yaml +4 -4
  2. data/README.md +104 -22
  3. data/app/assets/stylesheets/solid_stack_web/_02_layout.css +8 -0
  4. data/app/assets/stylesheets/solid_stack_web/_04_table.css +4 -0
  5. data/app/assets/stylesheets/solid_stack_web/_07_dashboard.css +64 -12
  6. data/app/controllers/solid_stack_web/application_controller.rb +1 -1
  7. data/app/controllers/solid_stack_web/dashboard_controller.rb +4 -16
  8. data/app/controllers/solid_stack_web/failed_jobs/selections_controller.rb +10 -4
  9. data/app/controllers/solid_stack_web/history_controller.rb +42 -0
  10. data/app/controllers/solid_stack_web/metrics_controller.rb +15 -0
  11. data/app/controllers/solid_stack_web/queues/pauses_controller.rb +13 -0
  12. data/app/controllers/solid_stack_web/queues_controller.rb +12 -7
  13. data/app/controllers/solid_stack_web/recurring_tasks/runs_controller.rb +18 -0
  14. data/app/controllers/solid_stack_web/recurring_tasks_controller.rb +7 -0
  15. data/app/controllers/solid_stack_web/scheduled_jobs_controller.rb +52 -0
  16. data/app/controllers/solid_stack_web/stats_controller.rb +39 -0
  17. data/app/helpers/solid_stack_web/application_helper.rb +64 -0
  18. data/app/javascript/solid_stack_web/application.js +5 -1
  19. data/app/javascript/solid_stack_web/refresh_controller.js +52 -0
  20. data/app/javascript/solid_stack_web/sparkline_tooltip_controller.js +23 -0
  21. data/app/models/solid_stack_web/alert_webhook.rb +67 -0
  22. data/app/models/solid_stack_web/cable_stats.rb +10 -0
  23. data/app/models/solid_stack_web/cache_stats.rb +10 -0
  24. data/app/models/solid_stack_web/queue_depth_sparkline.rb +30 -0
  25. data/app/models/solid_stack_web/queue_stats.rb +34 -0
  26. data/app/models/solid_stack_web/throughput_sparkline.rb +23 -0
  27. data/app/views/layouts/solid_stack_web/application.html.erb +6 -0
  28. data/app/views/solid_stack_web/dashboard/index.html.erb +37 -2
  29. data/app/views/solid_stack_web/history/index.html.erb +76 -0
  30. data/app/views/solid_stack_web/jobs/index.html.erb +26 -3
  31. data/app/views/solid_stack_web/processes/index.html.erb +3 -0
  32. data/app/views/solid_stack_web/queues/index.html.erb +10 -5
  33. data/app/views/solid_stack_web/queues/show.html.erb +67 -0
  34. data/app/views/solid_stack_web/recurring_tasks/index.html.erb +67 -0
  35. data/app/views/solid_stack_web/scheduled_jobs/update.turbo_stream.erb +9 -0
  36. data/app/views/solid_stack_web/stats/index.html.erb +48 -0
  37. data/config/importmap.rb +4 -0
  38. data/config/routes.rb +15 -5
  39. data/lib/solid_stack_web/version.rb +1 -1
  40. data/lib/solid_stack_web.rb +37 -1
  41. metadata +22 -2
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 0adcafbdcb32ea6e96f982efc8d44502ea31e75e9f25138558ba58a8952719c8
4
- data.tar.gz: a0e5b5695d620e4d616535e5f7b394df10c756ee712584a7de7c7ef489ded759
3
+ metadata.gz: c446164425203124cb715d09c520cfa7b2cc03a6abeefb4eb27ca0514327160a
4
+ data.tar.gz: ef9abaa3fe16c5ffc1e019dfff0ce97d7541950db9f6f25ae5ce034f4780efb5
5
5
  SHA512:
6
- metadata.gz: 42c74a1cbc74acf3485ce0649e7f894bbbc1fdb4172037dbac4edb6e6805c8571c3731efb5acaa774763ae979ae8bb632e81e16f7f36e51faed447c8eb3bd81d
7
- data.tar.gz: e35fb81b13d2f4cfa2d43f2e9ee223ef32c9605a69c98cf38e94f193d037f284efae651922ace035e04ad0ac1129361825345d5a64b97d91809caad1201351b9
6
+ metadata.gz: 30b738bcc2e57c4f81fb03d7b4a74bf88e18a1b69d4b71bc646357703006d6a1b639090a77a11d258427b7ad080f0b93b317cd949ff9b3b3353f77caa3e69066
7
+ data.tar.gz: 37f9a4d50e42360411c4213be03ce5c343874314a048b5f09b13b450bfc2ccf3568a2033cf5d4d137ee59723658f9a3cccbcd6353cf7f2f677159bd2b04fba73
data/README.md CHANGED
@@ -7,17 +7,6 @@
7
7
 
8
8
  A mountable Rails engine that provides a unified web dashboard for the full [Solid Stack](https://github.com/rails/solid_queue) — **Solid Queue**, **Solid Cache**, and **Solid Cable** — in a single interface with no asset pipeline dependency and no JavaScript runtime requirement.
9
9
 
10
- ## Features
11
-
12
- - **Overview dashboard** with live counts across all three Solid Stack components; cards are clickable and link directly to each section
13
- - **Solid Queue** — browse jobs by status (ready, scheduled, claimed, blocked) with filtering by job class, queue name, priority, and time period; manage failed jobs (retry / discard / bulk retry / bulk discard), pause/resume queues, and inspect worker processes; **Bulk selection** checkbox-selects individual jobs for discard or retry; **Discard All** bulk-discards every job matching the current filters in one request; **CSV export** downloads jobs or failed jobs as a CSV file respecting active filters
14
- - **Job detail page** — drill into any job to see full arguments (pretty-printed JSON), queue, priority, enqueued time, Active Job ID, concurrency key, scheduled/blocked-until metadata, and a Discard button
15
- - **Solid Cache** — entry count and total byte size at a glance
16
- - **Solid Cable** — active message count and distinct channel count
17
- - **Turbo Stream** job discard — removes the row inline without a full page reload
18
- - **Authentication hook** — plug in your own auth logic (Devise, Basic Auth, custom) via a one-line initializer
19
- - **Zero asset pipeline coupling** — CSS is injected inline; safe to mount in any host app
20
-
21
10
  ## Installation
22
11
 
23
12
  Add the gem to your application's `Gemfile`:
@@ -40,24 +29,55 @@ mount SolidStackWeb::Engine, at: "/solid_stack"
40
29
 
41
30
  The dashboard will be available at `/solid_stack` (or whatever path you choose).
42
31
 
43
- ## Configuration
32
+ ---
44
33
 
45
- Create an initializer at `config/initializers/solid_stack_web.rb`:
34
+ ## Solid Queue
35
+
36
+ ### Features
37
+
38
+ - **Overview dashboard** — live counts across all queue statuses; done (1h/24h), healthy/stale process counts, and optionally slow jobs (when `slow_job_threshold` is configured); 12-hour throughput sparkline with per-bar hover tooltips
39
+ - **Job browser** — browse jobs by status (ready, scheduled, claimed, blocked) with filtering by job class, queue name, priority, and time period; **Discard All** bulk-discards every job matching the current filters in one request; **CSV export** downloads jobs respecting active filters
40
+ - **Bulk selection** — checkbox-select individual jobs for discard; select-all support
41
+ - **Per-queue browser** — click any queue name or size to drill into its ready jobs with per-row and bulk discard; pause/resume controls on the queue page
42
+ - **Queue depth sparklines** — Queues index shows a 12-hour depth chart per queue; each bar is the ready-job count at an hourly snapshot with an instant hover tooltip
43
+ - **Job detail page** — full arguments (pretty-printed JSON), queue, priority, enqueued time, Active Job ID, concurrency key, scheduled/blocked-until metadata, and a Discard button
44
+ - **Failed jobs** — list with retry / discard / bulk retry / bulk discard; **Failed job detail page** — full error, backtrace, and an inline JSON argument editor; submit to update arguments and retry in one action
45
+ - **Scheduled job management** — "Run Now" and offset buttons (+1h / +24h / +7d) per row update the scheduled time inline via Turbo Stream; "Run All Now (N)" back-dates all matching executions at once
46
+ - **Recurring task list** — enumerates all `SolidQueue::RecurringTask` records with cron schedule, job class or command, queue, next-run and last-run times, and a static/dynamic badge; each row has a "Run Now" button
47
+ - **Performance statistics page** — `GET /stats` aggregates finished jobs by class name with execution count, avg, p50, p95, min, and max duration; click any column header to sort; defaults to p95 descending
48
+ - **Job history view** — paginated list of all finished jobs with class name, queue, duration, and finished-at time; filterable by queue (click a badge), class substring, and time period; CSV export respects active filters
49
+ - **Auto-refresh** — dashboard, jobs, processes, and history views poll automatically; pauses when the tab is hidden or a checkbox is checked; intervals configurable via `dashboard_refresh_interval` and `default_refresh_interval`
50
+ - **Turbo Stream** job discard — removes the row inline without a full page reload
51
+
52
+ ### Configuration
46
53
 
47
54
  ```ruby
48
55
  SolidStackWeb.configure do |config|
49
- # Number of items per paginated page (default: 25)
50
- config.page_size = 50
51
-
52
- # Authentication — block runs in controller context.
53
- # Return a truthy value to allow access; falsy falls back to HTTP Basic.
54
- config.authenticate do
55
- current_user&.admin?
56
- end
56
+ # Slow job threshold in seconds (default: nil — stat hidden).
57
+ # When set, the dashboard shows a "Slow (24h)" count of finished jobs
58
+ # whose wall time exceeded this value. Links to the Stats page.
59
+ config.slow_job_threshold = 30
60
+
61
+ # Alert webhooks — fired on every GET /metrics poll when a threshold is met.
62
+ # Delivery failures are silently swallowed; cooldown prevents alert storms.
63
+ config.alert_webhook_url = "https://hooks.example.com/my-alert"
64
+ config.alert_failure_threshold = 10 # POST when failed jobs >= this
65
+ config.alert_queue_thresholds = { # POST when a queue's ready depth >= value
66
+ "critical" => 50,
67
+ "default" => 500
68
+ }
69
+ config.alert_webhook_cooldown = 3600 # seconds between alerts (default: 3600)
70
+
71
+ # Auto-refresh intervals in milliseconds.
72
+ config.dashboard_refresh_interval = 5_000 # overview dashboard (default: 5000)
73
+ config.default_refresh_interval = 10_000 # jobs, processes, history (default: 10000)
74
+
75
+ # Maximum results shown by the search feature (default: 25).
76
+ config.search_results_limit = 25
57
77
  end
58
78
  ```
59
79
 
60
- ### Job Filtering
80
+ #### Job Filtering
61
81
 
62
82
  The jobs list supports four independent filters, all driven by query params:
63
83
 
@@ -70,10 +90,71 @@ The jobs list supports four independent filters, all driven by query params:
70
90
 
71
91
  Filters are preserved when switching between status tabs (Ready / Scheduled / Running / Blocked) and when discarding a job. They can be combined freely.
72
92
 
93
+ ---
94
+
95
+ ## Solid Cache
96
+
97
+ _Deep cache monitoring coming in v0.5.0. Currently shows entry count and total byte size on the overview dashboard._
98
+
99
+ ---
100
+
101
+ ## Solid Cable
102
+
103
+ _Channel monitoring coming in v0.6.0. Currently shows active message count and distinct channel count on the overview dashboard._
104
+
105
+ ---
106
+
107
+ ## Metrics endpoint
108
+
109
+ `GET /metrics` (relative to your mount path) returns a JSON payload suitable for external monitoring tools, uptime checkers, or custom alerting:
110
+
111
+ ```json
112
+ {
113
+ "queue": {
114
+ "ready": 4,
115
+ "scheduled": 1,
116
+ "claimed": 2,
117
+ "blocked": 0,
118
+ "failed": 3,
119
+ "done_1h": 45,
120
+ "done_24h": 312,
121
+ "processes_healthy": 2,
122
+ "processes_stale": 0,
123
+ "slow_jobs": 7
124
+ },
125
+ "cache": { "entries": 1024, "byte_size": 2097152 },
126
+ "cable": { "messages": 50, "channels": 3 },
127
+ "generated_at": "2026-05-26T10:00:00Z"
128
+ }
129
+ ```
130
+
131
+ `slow_jobs` is only present when `slow_job_threshold` is configured. The endpoint is protected by the same authentication as the rest of the dashboard.
132
+
133
+ ---
134
+
135
+ ## General configuration
136
+
137
+ Create an initializer at `config/initializers/solid_stack_web.rb`:
138
+
139
+ ```ruby
140
+ SolidStackWeb.configure do |config|
141
+ # Number of items per paginated page (default: 25)
142
+ config.page_size = 50
143
+
144
+ # Authentication — block runs in controller context.
145
+ # Return a truthy value to allow access; falsy falls back to HTTP Basic.
146
+ config.authenticate do
147
+ current_user&.admin?
148
+ end
149
+ end
150
+ ```
151
+
73
152
  ### Authentication
74
153
 
75
154
  The `authenticate` block is evaluated in the context of each request's controller instance, so any helper method available to controllers (e.g. `current_user` from Devise) works directly. If the block returns `false` or `nil`, the engine falls back to HTTP Basic authentication. If no `authenticate` block is configured, the dashboard is open.
76
155
 
156
+ ---
157
+
77
158
  ## Requirements
78
159
 
79
160
  - Ruby >= 3.3
@@ -82,6 +163,7 @@ The `authenticate` block is evaluated in the context of each request's controlle
82
163
  - [solid_cache](https://github.com/rails/solid_cache) >= 1.0
83
164
  - [solid_cable](https://github.com/rails/solid_cable) >= 1.0
84
165
  - [turbo-rails](https://github.com/hotwired/turbo-rails) >= 2.0
166
+ - [importmap-rails](https://github.com/rails/importmap-rails) >= 1.2
85
167
 
86
168
  ## Contributing
87
169
 
@@ -72,12 +72,20 @@
72
72
 
73
73
  .sqw-page-header { margin-bottom: 1.25rem; }
74
74
  .sqw-page-title { font-size: 20px; font-weight: 600; }
75
+ .sqw-page-title-row { display: flex; align-items: center; gap: 0.5rem; }
76
+
77
+ @keyframes sqw-flash-dismiss {
78
+ 0%, 86% { opacity: 1; max-height: 200px; margin-bottom: 1rem; }
79
+ 100% { opacity: 0; max-height: 0; margin-bottom: 0; padding: 0; }
80
+ }
75
81
 
76
82
  .sqw-flash {
77
83
  padding: 0.75rem 1rem;
78
84
  border-radius: var(--radius);
79
85
  margin-bottom: 1rem;
80
86
  font-size: 13px;
87
+ animation: sqw-flash-dismiss 7s ease forwards;
88
+ overflow: hidden;
81
89
  }
82
90
  .sqw-flash--notice { background: #d1e7dd; color: #0a3622; border: 1px solid #a3cfbb; }
83
91
  .sqw-flash--alert { background: #f8d7da; color: #58151c; border: 1px solid #f1aeb5; }
@@ -30,6 +30,10 @@
30
30
  .sqw-actions { text-align: right; white-space: nowrap; }
31
31
  .sqw-actions form { display: inline; }
32
32
 
33
+ .sqw-table th a { color: inherit; text-decoration: none; }
34
+ .sqw-table th a:hover { color: var(--text); }
35
+ .sqw-sort-indicator { margin-left: 0.2rem; }
36
+
33
37
  .sqw-empty {
34
38
  background: var(--surface);
35
39
  border: 1px solid var(--border);
@@ -12,7 +12,6 @@
12
12
  border-radius: var(--radius);
13
13
  box-shadow: var(--shadow);
14
14
  overflow: hidden;
15
- position: relative;
16
15
  transition: box-shadow 0.15s;
17
16
  }
18
17
  .sqw-gem-card:hover { box-shadow: 0 3px 8px rgba(0,0,0,.12); }
@@ -44,16 +43,6 @@
44
43
  }
45
44
  .sqw-gem-card__link:hover { color: var(--primary); text-decoration: none; }
46
45
 
47
- /* Stretch the header link to cover the whole card */
48
- .sqw-gem-card__link::after {
49
- content: "";
50
- position: absolute;
51
- inset: 0;
52
- }
53
-
54
- /* Stat links and other interactive elements sit above the overlay */
55
- .sqw-inline-stat { position: relative; z-index: 1; }
56
-
57
46
  .sqw-gem-card__body {
58
47
  display: grid;
59
48
  grid-template-columns: repeat(auto-fill, minmax(90px, 1fr));
@@ -68,6 +57,7 @@
68
57
  color: var(--text);
69
58
  transition: opacity 0.15s;
70
59
  }
60
+
71
61
  a.sqw-inline-stat:hover { opacity: 0.7; text-decoration: none; }
72
62
 
73
63
  .sqw-inline-stat__label {
@@ -87,4 +77,66 @@ a.sqw-inline-stat:hover { opacity: 0.7; text-decoration: none; }
87
77
  .sqw-inline-stat--failed .sqw-inline-stat__value { color: var(--danger); }
88
78
  .sqw-inline-stat--cache .sqw-inline-stat__value { color: var(--purple); }
89
79
  .sqw-inline-stat--cable .sqw-inline-stat__value { color: var(--info); }
90
- .sqw-inline-stat--neutral .sqw-inline-stat__value { color: var(--muted); }
80
+ .sqw-inline-stat--neutral .sqw-inline-stat__value { color: var(--muted); }
81
+
82
+ .sqw-sparkline-wrap {
83
+ padding: 0.75rem 1.25rem 1rem;
84
+ border-top: 1px solid var(--border);
85
+ color: var(--primary);
86
+ }
87
+
88
+ .sqw-sparkline-label {
89
+ display: block;
90
+ font-size: 10px;
91
+ font-weight: 500;
92
+ text-transform: uppercase;
93
+ letter-spacing: .06em;
94
+ color: var(--muted);
95
+ margin-bottom: 0.5rem;
96
+ }
97
+
98
+ .sqw-sparkline-positioner {
99
+ position: relative;
100
+ }
101
+
102
+ .sqw-sparkline {
103
+ display: block;
104
+ width: 100%;
105
+ height: 40px;
106
+ }
107
+
108
+ .sqw-sparkline--sm {
109
+ height: 24px;
110
+ }
111
+
112
+ .sqw-queue-sparkline {
113
+ width: 120px;
114
+ color: var(--primary);
115
+ }
116
+
117
+ .sqw-sparkline rect {
118
+ cursor: pointer;
119
+ }
120
+
121
+ .sqw-sparkline-tip {
122
+ position: fixed;
123
+ transform: translate(-50%, -100%);
124
+ background: var(--text);
125
+ color: var(--surface);
126
+ font-size: 11px;
127
+ font-weight: 500;
128
+ white-space: nowrap;
129
+ padding: 3px 7px;
130
+ border-radius: 4px;
131
+ pointer-events: none;
132
+ z-index: 100;
133
+ }
134
+
135
+ .sqw-sparkline-axis {
136
+ display: flex;
137
+ justify-content: space-between;
138
+ font-size: 9px;
139
+ color: var(--muted);
140
+ margin-top: 3px;
141
+ opacity: 0.7;
142
+ }
@@ -15,7 +15,7 @@ module SolidStackWeb
15
15
 
16
16
  def current_section
17
17
  case controller_name
18
- when "jobs", "failed_jobs", "queues", "processes" then :queue
18
+ when "jobs", "failed_jobs", "queues", "processes", "history", "scheduled_jobs", "recurring_tasks" then :queue
19
19
  when "cache" then :cache
20
20
  when "cable" then :cable
21
21
  else :overview
@@ -1,22 +1,10 @@
1
1
  module SolidStackWeb
2
2
  class DashboardController < ApplicationController
3
3
  def index
4
- @queue_stats = {
5
- ready: ::SolidQueue::ReadyExecution.count,
6
- scheduled: ::SolidQueue::ScheduledExecution.count,
7
- claimed: ::SolidQueue::ClaimedExecution.count,
8
- blocked: ::SolidQueue::BlockedExecution.count,
9
- failed: ::SolidQueue::FailedExecution.count,
10
- processes: ::SolidQueue::Process.count
11
- }
12
- @cache_stats = {
13
- entries: ::SolidCache::Entry.count,
14
- byte_size: ::SolidCache::Entry.sum(:byte_size)
15
- }
16
- @cable_stats = {
17
- messages: ::SolidCable::Message.count,
18
- channels: ::SolidCable::Message.distinct.count(:channel)
19
- }
4
+ @queue_stats = QueueStats.new.to_h
5
+ @cache_stats = CacheStats.new.to_h
6
+ @cable_stats = CableStats.new.to_h
7
+ @throughput = ThroughputSparkline.new
20
8
  end
21
9
  end
22
10
  end
@@ -1,22 +1,28 @@
1
1
  module SolidStackWeb
2
2
  module FailedJobs
3
3
  class SelectionsController < ApplicationController
4
+ before_action :set_ids
5
+
4
6
  def create
5
- ids = Array(params[:job_ids]).map(&:to_i).reject(&:zero?)
6
- SolidQueue::FailedExecution.where(id: ids).each(&:retry)
7
+ SolidQueue::FailedExecution.where(id: @ids).each(&:retry)
7
8
  redirect_to failed_jobs_path
8
9
  rescue => e
9
10
  redirect_to failed_jobs_path, alert: "Could not retry jobs: #{e.message}"
10
11
  end
11
12
 
12
13
  def destroy
13
- ids = Array(params[:job_ids]).map(&:to_i).reject(&:zero?)
14
- job_ids = SolidQueue::FailedExecution.where(id: ids).pluck(:job_id)
14
+ job_ids = SolidQueue::FailedExecution.where(id: @ids).pluck(:job_id)
15
15
  SolidQueue::Job.where(id: job_ids).destroy_all
16
16
  redirect_to failed_jobs_path
17
17
  rescue => e
18
18
  redirect_to failed_jobs_path, alert: "Could not discard jobs: #{e.message}"
19
19
  end
20
+
21
+ private
22
+
23
+ def set_ids
24
+ @ids = Array(params[:job_ids]).map(&:to_i).reject(&:zero?)
25
+ end
20
26
  end
21
27
  end
22
28
  end
@@ -0,0 +1,42 @@
1
+ module SolidStackWeb
2
+ class HistoryController < ApplicationController
3
+ before_action :set_filters
4
+
5
+ def index
6
+ respond_to do |format|
7
+ format.html { @pagy, @jobs = pagy(filtered_scope) }
8
+ format.csv do
9
+ send_data history_csv(filtered_scope),
10
+ filename: "job-history-#{Date.today}.csv",
11
+ type: "text/csv", disposition: "attachment"
12
+ end
13
+ end
14
+ end
15
+
16
+ private
17
+
18
+ def set_filters
19
+ @queue = params[:queue].presence
20
+ @search = params[:q].presence
21
+ @period = params[:period].presence_in(PERIOD_DURATIONS.keys)
22
+ end
23
+
24
+ def filtered_scope
25
+ scope = SolidQueue::Job.where.not(finished_at: nil).order(finished_at: :desc)
26
+ scope = scope.where(queue_name: @queue) if @queue.present?
27
+ scope = scope.where("class_name LIKE ?", "%#{@search}%") if @search.present?
28
+ scope = scope.where("finished_at >= ?", PERIOD_DURATIONS[@period].ago) if @period.present?
29
+ scope
30
+ end
31
+
32
+ def history_csv(scope)
33
+ CSV.generate(headers: true) do |csv|
34
+ csv << %w[id class_name queue_name duration_seconds finished_at]
35
+ scope.order(finished_at: :desc).each do |job|
36
+ duration = job.finished_at && job.created_at ? (job.finished_at - job.created_at).round : nil
37
+ csv << [job.id, job.class_name, job.queue_name, duration, job.finished_at.iso8601]
38
+ end
39
+ end
40
+ end
41
+ end
42
+ end
@@ -0,0 +1,15 @@
1
+ module SolidStackWeb
2
+ class MetricsController < ApplicationController
3
+ def index
4
+ queue_stats = QueueStats.new.to_h
5
+ AlertWebhook.check(queue_stats)
6
+
7
+ render json: {
8
+ queue: queue_stats,
9
+ cache: CacheStats.new.to_h,
10
+ cable: CableStats.new.to_h,
11
+ generated_at: Time.current.iso8601
12
+ }
13
+ end
14
+ end
15
+ end
@@ -0,0 +1,13 @@
1
+ module SolidStackWeb
2
+ class Queues::PausesController < ApplicationController
3
+ def create
4
+ ::SolidQueue::Pause.find_or_create_by!(queue_name: params[:queue_id])
5
+ redirect_back_or_to queues_path
6
+ end
7
+
8
+ def destroy
9
+ ::SolidQueue::Pause.find_by(queue_name: params[:queue_id])&.destroy
10
+ redirect_back_or_to queues_path
11
+ end
12
+ end
13
+ end
@@ -11,16 +11,21 @@ module SolidStackWeb
11
11
  paused: paused.include?(name)
12
12
  }
13
13
  end
14
- end
15
14
 
16
- def pause
17
- ::SolidQueue::Pause.find_or_create_by!(queue_name: params[:id])
18
- redirect_to queues_path
15
+ @sparklines = @queues.each_with_object({}) do |queue, h|
16
+ h[queue[:name]] = QueueDepthSparkline.new(queue[:name])
17
+ end
19
18
  end
20
19
 
21
- def resume
22
- ::SolidQueue::Pause.find_by(queue_name: params[:id])&.destroy
23
- redirect_to queues_path
20
+ def show
21
+ @queue_name = params[:id]
22
+ @paused = ::SolidQueue::Pause.exists?(queue_name: @queue_name)
23
+ @pagy, @executions = pagy(
24
+ ::SolidQueue::ReadyExecution
25
+ .where(queue_name: @queue_name)
26
+ .includes(:job)
27
+ .order(created_at: :desc)
28
+ )
24
29
  end
25
30
  end
26
31
  end
@@ -0,0 +1,18 @@
1
+ module SolidStackWeb
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
@@ -0,0 +1,7 @@
1
+ module SolidStackWeb
2
+ class RecurringTasksController < ApplicationController
3
+ def index
4
+ @recurring_tasks = SolidQueue::RecurringTask.includes(:recurring_executions).order(:key)
5
+ end
6
+ end
7
+ end
@@ -0,0 +1,52 @@
1
+ module SolidStackWeb
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
+ SolidQueue::ScheduledExecution.where(job_id: job_ids).update_all(scheduled_at: 1.second.ago)
7
+ SolidQueue::Job.where(id: job_ids).update_all(scheduled_at: 1.second.ago)
8
+ redirect_to jobs_path(status: "scheduled", period: @period),
9
+ notice: "#{job_ids.size} #{"job".pluralize(job_ids.size)} scheduled to run immediately."
10
+ rescue => e
11
+ redirect_to jobs_path(status: "scheduled", period: @period),
12
+ alert: "Could not run jobs: #{e.message}"
13
+ end
14
+
15
+ def update
16
+ @execution = SolidQueue::ScheduledExecution.find(params[:id])
17
+ @period = params[:period].presence_in(PERIOD_DURATIONS.keys)
18
+ @run_now = params[:offset] == "now"
19
+ new_time = resolve_new_time(@execution, params[:offset])
20
+
21
+ @execution.update!(scheduled_at: new_time)
22
+ @execution.job.update!(scheduled_at: new_time)
23
+
24
+ respond_to do |format|
25
+ format.turbo_stream
26
+ format.html do
27
+ notice = @run_now ? "Job scheduled to run immediately." : "Job rescheduled by +#{params[:offset]}."
28
+ redirect_to jobs_path(status: "scheduled", period: @period), notice: notice
29
+ end
30
+ end
31
+ rescue ArgumentError => e
32
+ redirect_to jobs_path(status: "scheduled"), alert: e.message
33
+ rescue => e
34
+ redirect_to jobs_path(status: "scheduled"), alert: "Could not reschedule job: #{e.message}"
35
+ end
36
+
37
+ private
38
+
39
+ def scheduled_scope
40
+ scope = SolidQueue::ScheduledExecution.joins(:job)
41
+ scope = scope.where("solid_queue_jobs.created_at >= ?", PERIOD_DURATIONS[@period].ago) if @period.present?
42
+ scope
43
+ end
44
+
45
+ def resolve_new_time(execution, offset)
46
+ return 1.second.ago if offset == "now"
47
+ raise ArgumentError, "Invalid offset." unless PERIOD_DURATIONS.key?(offset)
48
+
49
+ execution.scheduled_at + PERIOD_DURATIONS[offset]
50
+ end
51
+ end
52
+ end
@@ -0,0 +1,39 @@
1
+ module SolidStackWeb
2
+ class StatsController < ApplicationController
3
+ SORTABLE_COLUMNS = %w[class_name count avg p50 p95 min max].freeze
4
+
5
+ def index
6
+ @sort = params[:sort].presence_in(SORTABLE_COLUMNS) || "p95"
7
+ @direction = params[:direction] == "asc" ? "asc" : "desc"
8
+
9
+ jobs = SolidQueue::Job.where.not(finished_at: nil).select(:class_name, :created_at, :finished_at)
10
+ @stats = build_stats(jobs)
11
+ @stats.sort_by! { |row| row[@sort.to_sym] || 0 }
12
+ @stats.reverse! if @direction == "desc"
13
+ end
14
+
15
+ private
16
+
17
+ def build_stats(jobs)
18
+ jobs.group_by(&:class_name).map do |class_name, group|
19
+ durations = group.map { |j| (j.finished_at - j.created_at).to_f }.sort
20
+ count = durations.size
21
+ {
22
+ class_name: class_name,
23
+ count: count,
24
+ avg: durations.sum / count,
25
+ min: durations.first,
26
+ max: durations.last,
27
+ p50: percentile(durations, 50),
28
+ p95: percentile(durations, 95)
29
+ }
30
+ end
31
+ end
32
+
33
+ def percentile(sorted, pct)
34
+ return 0.0 if sorted.empty?
35
+ k = (sorted.size - 1) * pct / 100.0
36
+ sorted[k.floor] + (sorted[k.ceil] - sorted[k.floor]) * (k - k.floor)
37
+ end
38
+ end
39
+ end