roundhouse_ui 0.7.0 → 0.9.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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 5064ee74f5aea444f5762d2f466f0b4c05d9c7af6c44a48045e6703d48b72587
4
- data.tar.gz: 3b5a08f1769a5bd5ee66bdbe5ccd586842373b45530b95cbc4ff6340fe17796e
3
+ metadata.gz: 913330ea311e66bcb5eb717852a73c0c4eb51a281acbd5899fac33f623b609a2
4
+ data.tar.gz: 8fdaff5a284e27d7df7a63ade47294f9d5f156a14e9133f7a40a24ca194ad828
5
5
  SHA512:
6
- metadata.gz: 6d3cf1e161baec06cb4b899d9f97065fb216341716719756c062a140a811bf51997748f9bcc322dc78aaa8ec36d56311decc511b70eea8d53a07a57a5b317b8c
7
- data.tar.gz: 15a01ddaa5fa81c37c67516c369d1dac2b795b011e5dad1927b3b70aeed47b365a4938765e78f9eae910da0a39e36fe174d6170ba0226d1eeab404a700f4c218
6
+ metadata.gz: ae78d8ec53e92eaac58308a7701abfc11f71ad2d89f1ffe05b40611df458a6121a97f831d16770dfb0f9d37a3bdffe50905498bba41e7c86c7b74fe4bbc211d3
7
+ data.tar.gz: 446a6fa76c6f2cf70f2b3ec5e83d5eb06dbe6e6a393bebfab7414f3bd1e87d8fa96c6bf9ae180c284e2ae50e63ee1e1f3516a827591ab58d686fccdd8ebaf195
data/README.md CHANGED
@@ -1,7 +1,6 @@
1
- <img width="3532" height="1956" alt="CleanShot 2026-06-27 at 21 07 23@2x" src="https://github.com/user-attachments/assets/7c27c997-d8d1-4e8e-b982-94c624ebfa2a" />
2
1
  # Roundhouse
3
-
4
- **A modern, real-time web UI for Sidekiq.**
2
+ <img width="4460" height="3152" alt="CleanShot 2026-07-01 at 09 42 17@2x" src="https://github.com/user-attachments/assets/3484709b-9c4f-449e-8776-53ad2de4781f" />
3
+ **A modern, real-time web UI for Sidekiq and Solid Queue.**
5
4
 
6
5
  [![CI](https://github.com/rjrobinson/roundhouse_ui/actions/workflows/ci.yml/badge.svg)](https://github.com/rjrobinson/roundhouse_ui/actions/workflows/ci.yml)
7
6
  [![Gem Version](https://img.shields.io/gem/v/roundhouse_ui)](https://rubygems.org/gems/roundhouse_ui)
@@ -9,32 +8,34 @@
9
8
  [![Rails](https://img.shields.io/badge/rails-%3E%3D%207.0-D30001?logo=rubyonrails&logoColor=white)](https://rubyonrails.org)
10
9
  [![License: MIT](https://img.shields.io/badge/license-MIT-blue.svg)](MIT-LICENSE)
11
10
 
12
- Roundhouse is a mountable Rails engine that replaces the stock Sidekiq Web UI with a
13
- control plane built for the way you actually operate background jobs: live stats,
14
- searchable sets, grouped errors, safe queue management, job inspection/editing, and
15
- Redis health all server-rendered with Turbo (no build step), and **no Sidekiq Pro
16
- required**.
11
+ Roundhouse is a mountable Rails engine a control plane built for the way you
12
+ actually operate background jobs: a high-signal dashboard, searchable sets, grouped
13
+ errors, smart bulk actions, safe queue management, and job inspection/editing. It
14
+ reads through a **backend port**, so the same UI drives **Sidekiq** or **Solid
15
+ Queue** (see [Backends](#backends)). All server-rendered with Turbo — **no build
16
+ step, no frontend dependency** — and **no Sidekiq Pro required**.
17
17
 
18
18
  > Gem name is `roundhouse_ui`; the brand and mount path are **Roundhouse**.
19
19
 
20
20
  ## Features
21
21
 
22
- - **Live dashboard** — throughput chart + stat cards refresh in place (no reload); polling pauses when the tab is hidden.
23
- - **Search** — find a job across the dead/retry/scheduled sets by class, JID, error, or argument value.
24
- - **Bulk actions** — select many dead jobs and retry/delete them at once (not 25-at-a-time).
25
- - **Grouped errors** — failures fingerprinted by `class + error`, so one bad deploy is a single issue with a count.
26
- - **Queue management** — pause/resume (OSS, see below), purge with an impact count, and **snapshot → restore**.
27
- - **Job inspection & editing** — view a job's full args (with redaction), error, and backtrace; edit & re-enqueue, or enqueue a brand-new job (opt-in).
28
- - **Workers** — process fleet with quiet/stop, threads, queues, and heartbeat.
29
- - **Redis pressure** — memory, ops/sec, and the eviction-policy check that flags silent job loss.
22
+ - **High-signal dashboard** — a composite health verdict (error rate + queue latency + utilization, with a "why"), *top failing job classes* and *problem queues* panels, and a live throughput chart with a configurable interval — all refreshing in place (polling pauses when the tab is hidden).
23
+ - **Grouped errors** — failures fingerprinted by `class + error`, so one bad deploy is a single issue with a count, not thousands of rows.
24
+ - **Smart bulk actions** — retry/delete every job matching a filter (not just the visible page), plus select-and-act on Dead.
25
+ - **Search** — across the dead/retry/scheduled sets by class, JID, error, or argument value.
26
+ - **Queue management** — pause/resume, purge with an impact count, and **snapshot → restore**.
27
+ - **Job inspection & editing** — full args (with redaction), error, and collapsible backtrace; edit & re-enqueue, or enqueue a new job (opt-in).
28
+ - **Per-class durations** (opt-in) the slowest job classes, which Sidekiq doesn't track.
30
29
  - **Audit log** — every state-changing action recorded and attributable.
31
- - **⌘K command palette**, light/dark themes, read-only mode, and a strict self-contained CSP.
30
+ - **⌘K command palette**, light/dark themes, compact/full-width toggle, read-only mode, and a strict self-contained CSP.
31
+
32
+ Sidekiq-specific extras: **Workers** (quiet/stop, threads, heartbeat), **Redis pressure** (eviction-policy check for silent job loss), and **Capsules**.
32
33
 
33
- Everything reads through Sidekiq's public API **no database**.
34
+ There's **no database of its own** — Roundhouse reads your job backend directly (Sidekiq via its API, Solid Queue via its tables).
34
35
 
35
36
  ## Requirements
36
37
 
37
- - Ruby >= 3.1 · Rails >= 7.0 · Sidekiq >= 7.0
38
+ - Ruby >= 3.1 · Rails >= 7.0 · Sidekiq >= 6.5 (or Solid Queue — see Backends)
38
39
 
39
40
  ## Installation
40
41
 
@@ -43,6 +44,28 @@ Everything reads through Sidekiq's public API — **no database**.
43
44
  gem "roundhouse_ui"
44
45
  ```
45
46
 
47
+ ## Backends
48
+
49
+ Roundhouse reads through a **backend port**, so the same UI can drive different
50
+ job systems. It defaults to **Sidekiq**; point it at **Solid Queue** in an
51
+ initializer:
52
+
53
+ ```ruby
54
+ # config/initializers/roundhouse.rb
55
+ RoundhouseUi.backend = RoundhouseUi::Backends::SolidQueue.new
56
+ ```
57
+
58
+ The UI adapts to each backend's capabilities — on Solid Queue, queue **pause is
59
+ native** (no fetcher, no warning), and the **Retries / Redis / Capsules / Workers**
60
+ sections hide (Solid Queue has no distinct retry set, isn't Redis-backed, and
61
+ processes are a follow-up). Dashboard, Queues, Scheduled, Dead, Busy, and the
62
+ grouped Errors view all work on both. See
63
+ [docs/adr/0001](docs/adr/0001-backend-port-multi-queue.md).
64
+
65
+ > Running **both** Sidekiq and Solid Queue in one app (e.g. mid-migration)? That's
66
+ > [#17](https://github.com/rjrobinson/roundhouse_ui/issues/17) — for now, one
67
+ > backend per Roundhouse instance.
68
+
46
69
  ## Mounting
47
70
 
48
71
  Roundhouse is auth-agnostic — wrap the mount in whatever your app already uses.
@@ -88,6 +111,14 @@ RoundhouseUi.configure do |c|
88
111
  # e.g. when you run reliable fetch (super_fetch) instead of RoundhouseUi::Fetch.
89
112
  # Default: true.
90
113
  # c.pause_enabled = false
114
+
115
+ # Seconds between dashboard stat polls (default 5). Raise it if polling shows
116
+ # up in your traces — each poll re-runs the host's auth/routing on the mount.
117
+ # c.poll_interval = 10
118
+
119
+ # Show the "slowest job classes" table on the Metrics page. Requires the
120
+ # DurationCollector middleware (see below). Default: false.
121
+ # c.collect_durations = true
91
122
  end
92
123
  ```
93
124
 
@@ -97,8 +128,11 @@ Every option is independent and has a safe default — set only what you need.
97
128
 
98
129
  ## Pausing queues
99
130
 
100
- Pause/resume is pure OSS. To make a pause actually stop a queue from being worked,
101
- install Roundhouse's fetch strategy in your Sidekiq **server** config:
131
+ > On **Solid Queue**, pause is **native** it's enforced by the backend, so there's
132
+ > nothing to install and no warning. The rest of this section is Sidekiq-only.
133
+
134
+ On Sidekiq, pause/resume is pure OSS. To make a pause actually stop a queue from being
135
+ worked, install Roundhouse's fetch strategy in your Sidekiq **server** config:
102
136
 
103
137
  ```ruby
104
138
  # config/initializers/sidekiq.rb
@@ -150,6 +184,29 @@ The **Busy** page's Cancel button flags a job's JID. A queued/scheduled/retrying
150
184
  is then skipped when it would next run; a *currently running* job stops only if it
151
185
  checks in — e.g. a long loop can `break if RoundhouseUi.cancelled?(jid)`.
152
186
 
187
+ ## Slowest job classes
188
+
189
+ Sidekiq doesn't track per-class durations, so Roundhouse can record them itself.
190
+ Install the opt-in server middleware and set `collect_durations = true`; the
191
+ Metrics page then lists the slowest classes by total time (count + average).
192
+
193
+ ```ruby
194
+ # config/initializers/sidekiq.rb
195
+ Sidekiq.configure_server do |config|
196
+ config.server_middleware { |chain| chain.add RoundhouseUi::DurationCollector }
197
+ end
198
+ ```
199
+
200
+ It's two cheap Redis writes per job (a counter + a summed-ms float) into a single
201
+ hash, and a job failure never propagates from the collector.
202
+
203
+ ## Bulk actions on a filter
204
+
205
+ On **Retries** and **Dead**, searching narrows the set; with a filter active you
206
+ can retry or delete **every** matching job in one action (not just the visible
207
+ page), capped at 1,000 per run. Gated to when a filter is present so it can't
208
+ become "retry everything", `read_only`-aware, and audit-logged.
209
+
153
210
  ## Observability deep-links
154
211
 
155
212
  The core depends on nothing — it asks the configured adapter for a URL and renders a link
@@ -189,8 +246,10 @@ Redis and run `bin/rails server` to click around.
189
246
 
190
247
  ## Roadmap
191
248
 
249
+ - Solid Queue: Workers view + enqueue, and the multi-DB (separate queue database) case.
250
+ - Watch Sidekiq **and** Solid Queue from one install ([#17](https://github.com/rjrobinson/roundhouse_ui/issues/17)).
192
251
  - Multi-Redis / multi-cluster view (one pane across shards).
193
- - Capsules and cron/periodic views.
252
+ - Cron/periodic (recurring) views.
194
253
 
195
254
  ## Contributing
196
255
 
@@ -5,6 +5,7 @@ module RoundhouseUi
5
5
  extend ActiveSupport::Concern
6
6
 
7
7
  PER_PAGE = 25
8
+ BULK_CAP = 1_000 # safety ceiling on a single match-set action
8
9
 
9
10
  # Returns [entries_for_page, has_next?]. Scans only far enough to fill the
10
11
  # requested page plus one (to know if a next page exists) — never loads the
@@ -32,6 +33,25 @@ module RoundhouseUi
32
33
  [ jobs, has_next ]
33
34
  end
34
35
 
36
+ # Apply an op ("retry"/"delete") to every entry matching the query, capped at
37
+ # BULK_CAP. Entries are collected first, then acted on — mutating a Sidekiq set
38
+ # mid-iteration skips entries. Returns [count_acted_on, capped?].
39
+ def bulk_apply(set, query, op, cap = BULK_CAP)
40
+ matches = []
41
+ capped = false
42
+ set.each do |entry|
43
+ next if query.present? && !entry_matches?(entry, query)
44
+
45
+ matches << entry
46
+ if matches.size >= cap
47
+ capped = true
48
+ break
49
+ end
50
+ end
51
+ matches.each { |entry| op == "delete" ? entry.delete : entry.retry }
52
+ [ matches.size, capped ]
53
+ end
54
+
35
55
  def entry_matches?(entry, query)
36
56
  needle = query.downcase
37
57
  [ entry.klass, entry.jid, entry.item["error_class"], entry.item["error_message"], entry.args.to_s ]
@@ -36,6 +36,12 @@ module RoundhouseUi
36
36
 
37
37
  private
38
38
 
39
+ # The queue backend (Sidekiq by default). Controllers read through this
40
+ # rather than naming Sidekiq directly. See ADR 0001.
41
+ def backend
42
+ RoundhouseUi.backend
43
+ end
44
+
39
45
  def record_audit_event
40
46
  target = params[:name] || params[:jid] || params[:id] || params[:job_class] ||
41
47
  (params[:jids].presence && "#{Array(params[:jids]).size} jobs") || params[:op]
@@ -9,7 +9,7 @@ module RoundhouseUi
9
9
 
10
10
  def index
11
11
  @threshold = LONG_RUNNING
12
- @work = Sidekiq::WorkSet.new.map { |process_id, tid, work| normalize(process_id, tid, work) }
12
+ @work = backend.busy
13
13
  end
14
14
 
15
15
  def cancel
@@ -19,18 +19,6 @@ module RoundhouseUi
19
19
 
20
20
  private
21
21
 
22
- # Sidekiq 7+ yields a Sidekiq::Work (queue/run_at/job methods); Sidekiq 6.x
23
- # yields a plain Hash (string keys, an epoch run_at, a JSON payload). Normalize
24
- # both to the same shape the view expects (run_at as a Time, job as a JobRecord).
25
- def normalize(process_id, tid, work)
26
- if work.respond_to?(:queue)
27
- { process: process_id, tid: tid, queue: work.queue, run_at: work.run_at, job: work.job }
28
- else
29
- { process: process_id, tid: tid, queue: work["queue"],
30
- run_at: Time.at(work["run_at"]), job: Sidekiq::JobRecord.new(work["payload"]) }
31
- end
32
- end
33
-
34
22
  def require_writable!
35
23
  return unless RoundhouseUi.read_only
36
24
  redirect_to busy_path, alert: "Roundhouse is in read-only mode — cancellation is disabled."
@@ -12,7 +12,7 @@ module RoundhouseUi
12
12
  def aggregate
13
13
  capsules = Hash.new { |h, k| h[k] = { name: k, concurrency: 0, processes: 0, queues: {} } }
14
14
 
15
- Sidekiq::ProcessSet.new.each do |process|
15
+ backend.process_set.each do |process|
16
16
  # `capsules` is a Sidekiq 8.0.8+ Process attribute; absent on 6.x/early 7,
17
17
  # where calling it would raise NoMethodError. Skip → view shows empty state.
18
18
  next unless process.respond_to?(:capsules)
@@ -3,8 +3,8 @@ module RoundhouseUi
3
3
  # Everything here comes out of Redis via Sidekiq::Stats / Sidekiq::Queue.
4
4
  class DashboardController < ApplicationController
5
5
  def show
6
- @stats = Sidekiq::Stats.new
7
- @queues = Sidekiq::Queue.all
6
+ @stats = backend.stats
7
+ @queues = backend.queues
8
8
  @metrics = Metrics.new(stats: @stats)
9
9
  @health = Health.new(stats: @stats, queues: @queues, metrics: @metrics)
10
10
  # Highest-signal slices for the overview, from data we already read.
@@ -15,7 +15,7 @@ module RoundhouseUi
15
15
  # Polled by the dashboard for live counts (same approach Sidekiq Web uses —
16
16
  # cheap JSON, no WebSocket/build step required).
17
17
  def stats
18
- s = Sidekiq::Stats.new
18
+ s = backend.stats
19
19
  render json: {
20
20
  processed: s.processed,
21
21
  failed: s.failed,
@@ -24,7 +24,7 @@ module RoundhouseUi
24
24
  scheduled: s.scheduled_size,
25
25
  retries: s.retry_size,
26
26
  dead: s.dead_size,
27
- queues: Sidekiq::Queue.all.size
27
+ queues: backend.queues.size
28
28
  }
29
29
  end
30
30
  end
@@ -2,30 +2,30 @@ module RoundhouseUi
2
2
  class DeadController < ApplicationController
3
3
  include JobSetBrowsing
4
4
 
5
- before_action :require_writable!, only: %i[requeue destroy bulk]
5
+ before_action :require_writable!, only: %i[requeue destroy bulk bulk_all]
6
6
 
7
7
  def index
8
8
  @query = params[:q].to_s.strip
9
9
  @page = [ params[:page].to_i, 1 ].max
10
- @total = Sidekiq::DeadSet.new.size
11
- @jobs, @has_next = browse(Sidekiq::DeadSet.new, @query, @page)
10
+ @total = backend.dead_set.size
11
+ @jobs, @has_next = browse(backend.dead_set, @query, @page)
12
12
  end
13
13
 
14
14
  def requeue
15
- entry = Sidekiq::DeadSet.new.find_job(params[:jid])
15
+ entry = backend.dead_set.find_job(params[:jid])
16
16
  entry&.retry
17
17
  redirect_to dead_set_path, notice: entry ? "Re-enqueued #{params[:jid]}." : "Job is no longer in the dead set."
18
18
  end
19
19
 
20
20
  def destroy
21
- entry = Sidekiq::DeadSet.new.find_job(params[:jid])
21
+ entry = backend.dead_set.find_job(params[:jid])
22
22
  entry&.delete
23
23
  redirect_to dead_set_path, notice: entry ? "Deleted #{params[:jid]}." : "Job is no longer in the dead set."
24
24
  end
25
25
 
26
26
  # Act on many at once: retry or delete every selected job in one request.
27
27
  def bulk
28
- set = Sidekiq::DeadSet.new
28
+ set = backend.dead_set
29
29
  count = 0
30
30
  Array(params[:jids]).each do |jid|
31
31
  entry = set.find_job(jid) or next
@@ -36,6 +36,17 @@ module RoundhouseUi
36
36
  redirect_to dead_set_path, notice: "#{verb} #{count} job(s)."
37
37
  end
38
38
 
39
+ # Smart bulk: act on EVERY job matching the current filter (not just the
40
+ # selected/visible ones), capped for safety. Only offered when a filter is
41
+ # active, so it can't become "retry the entire dead set" by accident.
42
+ def bulk_all
43
+ count, capped = bulk_apply(backend.dead_set, params[:q].to_s.strip, params[:op])
44
+ verb = params[:op] == "delete" ? "Deleted" : "Re-enqueued"
45
+ note = "#{verb} #{count} matching job(s)."
46
+ note += " Stopped at the #{JobSetBrowsing::BULK_CAP} cap — run again for more." if capped
47
+ redirect_to dead_set_path, notice: note
48
+ end
49
+
39
50
  private
40
51
 
41
52
  def require_writable!
@@ -5,11 +5,6 @@ module RoundhouseUi
5
5
  # Sidekiq has no in-place edit: a job in a set is keyed by its payload, so an
6
6
  # "edit" is delete-the-old + push-the-modified.
7
7
  class JobsController < ApplicationController
8
- SET_BUILDERS = {
9
- "dead" => -> { Sidekiq::DeadSet.new },
10
- "retry" => -> { Sidekiq::RetrySet.new },
11
- "scheduled" => -> { Sidekiq::ScheduledSet.new }
12
- }.freeze
13
8
  REDIRECTS = { "dead" => :dead_set_path, "retry" => :retries_path, "scheduled" => :scheduled_path }.freeze
14
9
 
15
10
  before_action :require_editing_enabled!, except: :show
@@ -32,7 +27,7 @@ module RoundhouseUi
32
27
  raise ArgumentError, "Job class is required" if klass.empty?
33
28
 
34
29
  queue = params[:queue].presence || "default"
35
- Sidekiq::Client.push("class" => klass, "queue" => queue, "args" => args)
30
+ backend.push("class" => klass, "queue" => queue, "args" => args)
36
31
  redirect_to queues_path, notice: "Enqueued #{klass} → #{queue}."
37
32
  rescue ArgumentError => e
38
33
  render_form_error(:new, jobs_path, e)
@@ -56,7 +51,7 @@ module RoundhouseUi
56
51
  queue = params[:queue].presence || entry.queue
57
52
 
58
53
  entry.delete
59
- Sidekiq::Client.push("class" => klass, "queue" => queue, "args" => args)
54
+ backend.push("class" => klass, "queue" => queue, "args" => args)
60
55
  redirect_to send(REDIRECTS[params[:set]]), notice: "Edited & re-enqueued #{klass} → #{queue}."
61
56
  rescue ArgumentError => e
62
57
  @action_path = job_path(set: params[:set], jid: params[:jid])
@@ -66,8 +61,8 @@ module RoundhouseUi
66
61
  private
67
62
 
68
63
  def find_entry
69
- builder = SET_BUILDERS[params[:set]] or return nil
70
- builder.call.find_job(params[:jid])
64
+ set = backend.set(params[:set]) or return nil
65
+ set.find_job(params[:jid])
71
66
  end
72
67
 
73
68
  def parse_args!(raw)
@@ -3,6 +3,7 @@ module RoundhouseUi
3
3
  class MetricsController < ApplicationController
4
4
  def show
5
5
  @metrics = Metrics.new
6
+ @durations = DurationCollector.summary if RoundhouseUi.collect_durations
6
7
  end
7
8
  end
8
9
  end
@@ -3,24 +3,27 @@ module RoundhouseUi
3
3
  before_action :require_writable!, only: %i[purge pause resume]
4
4
 
5
5
  def index
6
- @queues = Sidekiq::Queue.all
7
- @paused = RoundhouseUi::Pause.paused_set
8
- @fetch_installed = RoundhouseUi::Pause.fetch_installed?
6
+ @queues = backend.queues
7
+ @paused = backend.paused_queues
8
+ # Native-pause backends (Solid Queue) enforce pauses without a fetcher, so
9
+ # they never trigger the "not enforced" warning.
10
+ @fetch_installed = backend.supports?(:native_pause) ||
11
+ (backend.respond_to?(:fetch_installed?) && backend.fetch_installed?)
9
12
  end
10
13
 
11
14
  # Real, OSS-supported destructive action: empties the queue in Redis.
12
15
  def purge
13
- Sidekiq::Queue.new(params[:name]).clear
16
+ backend.queue(params[:name]).clear
14
17
  redirect_to queues_path, notice: "Purged queue “#{params[:name]}”."
15
18
  end
16
19
 
17
20
  def pause
18
- RoundhouseUi::Pause.pause!(params[:name])
21
+ backend.pause(params[:name])
19
22
  redirect_to queues_path, notice: "Paused “#{params[:name]}”."
20
23
  end
21
24
 
22
25
  def resume
23
- RoundhouseUi::Pause.unpause!(params[:name])
26
+ backend.resume(params[:name])
24
27
  redirect_to queues_path, notice: "Resumed “#{params[:name]}”."
25
28
  end
26
29
 
@@ -2,28 +2,38 @@ module RoundhouseUi
2
2
  class RetriesController < ApplicationController
3
3
  include JobSetBrowsing
4
4
 
5
- before_action :require_writable!, only: %i[requeue destroy]
5
+ before_action :require_writable!, only: %i[requeue destroy bulk_all]
6
6
 
7
7
  def index
8
8
  @query = params[:q].to_s.strip
9
9
  @page = [ params[:page].to_i, 1 ].max
10
- @total = Sidekiq::RetrySet.new.size
11
- @jobs, @has_next = browse(Sidekiq::RetrySet.new, @query, @page)
10
+ @total = backend.retry_set.size
11
+ @jobs, @has_next = browse(backend.retry_set, @query, @page)
12
12
  end
13
13
 
14
14
  # Retry now — moves the job back to its queue immediately.
15
15
  def requeue
16
- entry = Sidekiq::RetrySet.new.find_job(params[:jid])
16
+ entry = backend.retry_set.find_job(params[:jid])
17
17
  entry&.retry
18
18
  redirect_to retries_path, notice: entry ? "Re-enqueued #{params[:jid]}." : "Job is no longer in the retry set."
19
19
  end
20
20
 
21
21
  def destroy
22
- entry = Sidekiq::RetrySet.new.find_job(params[:jid])
22
+ entry = backend.retry_set.find_job(params[:jid])
23
23
  entry&.delete
24
24
  redirect_to retries_path, notice: entry ? "Deleted #{params[:jid]}." : "Job is no longer in the retry set."
25
25
  end
26
26
 
27
+ # Smart bulk: retry/delete EVERY job matching the current filter, capped for
28
+ # safety. Offered only when a filter is active.
29
+ def bulk_all
30
+ count, capped = bulk_apply(backend.retry_set, params[:q].to_s.strip, params[:op])
31
+ verb = params[:op] == "delete" ? "Deleted" : "Re-enqueued"
32
+ note = "#{verb} #{count} matching job(s)."
33
+ note += " Stopped at the #{JobSetBrowsing::BULK_CAP} cap — run again for more." if capped
34
+ redirect_to retries_path, notice: note
35
+ end
36
+
27
37
  private
28
38
 
29
39
  def require_writable!
@@ -7,19 +7,19 @@ module RoundhouseUi
7
7
  def index
8
8
  @query = params[:q].to_s.strip
9
9
  @page = [ params[:page].to_i, 1 ].max
10
- @total = Sidekiq::ScheduledSet.new.size
11
- @jobs, @has_next = browse(Sidekiq::ScheduledSet.new, @query, @page)
10
+ @total = backend.scheduled_set.size
11
+ @jobs, @has_next = browse(backend.scheduled_set, @query, @page)
12
12
  end
13
13
 
14
14
  # Enqueue now — pulls the job out of the schedule and onto its queue.
15
15
  def enqueue
16
- entry = Sidekiq::ScheduledSet.new.find_job(params[:jid])
16
+ entry = backend.scheduled_set.find_job(params[:jid])
17
17
  entry&.add_to_queue
18
18
  redirect_to scheduled_path, notice: entry ? "Enqueued #{params[:jid]} now." : "Job is no longer scheduled."
19
19
  end
20
20
 
21
21
  def destroy
22
- entry = Sidekiq::ScheduledSet.new.find_job(params[:jid])
22
+ entry = backend.scheduled_set.find_job(params[:jid])
23
23
  entry&.delete
24
24
  redirect_to scheduled_path, notice: entry ? "Deleted #{params[:jid]}." : "Job is no longer scheduled."
25
25
  end
@@ -5,7 +5,7 @@ module RoundhouseUi
5
5
  before_action :require_writable!, only: %i[quiet stop]
6
6
 
7
7
  def index
8
- @processes = Sidekiq::ProcessSet.new.to_a
8
+ @processes = backend.process_set.to_a
9
9
  @fetch_active = RoundhouseUi::Pause.fetch_installed?
10
10
  end
11
11
 
@@ -22,7 +22,7 @@ module RoundhouseUi
22
22
  private
23
23
 
24
24
  def find_process(identity)
25
- Sidekiq::ProcessSet.new.find { |process| process.identity == identity }
25
+ backend.process_set.find { |process| process.identity == identity }
26
26
  end
27
27
 
28
28
  def require_writable!
@@ -163,6 +163,27 @@
163
163
  .rh-disclose summary::-webkit-details-marker { display:none; }
164
164
  .rh-disclose summary::before { content:"▸ "; }
165
165
  .rh-disclose[open] summary::before { content:"▾ "; }
166
+
167
+ /* connection state — poll failures surface here instead of silently going stale */
168
+ .rh-live.is-stale .rh-dot { background:var(--crit); }
169
+ .rh-live.is-stale .rh-dot::after { animation:none; }
170
+
171
+ /* keyboard focus — visible on every interactive control */
172
+ a:focus-visible, button:focus-visible, input:focus-visible, select:focus-visible,
173
+ summary:focus-visible, .rh-nav:focus-visible { outline:2px solid var(--accent); outline-offset:2px; border-radius:6px; }
174
+
175
+ /* on-call / mobile: stack the rail on top as a horizontal nav, collapse the grid */
176
+ @media (max-width:760px) {
177
+ .rh-app { grid-template-columns:1fr; }
178
+ .rh-rail { position:static; height:auto; flex-direction:row; flex-wrap:wrap; gap:6px 14px; align-items:center; border-right:none; border-bottom:1px solid var(--line-soft); }
179
+ .rh-rail .rh-grp { flex-direction:row; flex-wrap:wrap; gap:4px; }
180
+ .rh-rail .rh-grp-label { display:none; }
181
+ .rh-brand { width:100%; }
182
+ .rh-main { padding:16px 16px 60px; }
183
+ .rh-cards { grid-template-columns:1fr 1fr; }
184
+ .rh-top { flex-wrap:wrap; }
185
+ }
186
+ @media (max-width:440px) { .rh-cards { grid-template-columns:1fr; } }
166
187
  .rh-field { margin-bottom:16px; max-width:640px; }
167
188
  .rh-field label { display:block; font-size:12px; color:var(--muted); margin-bottom:6px; }
168
189
  .rh-field input, .rh-field textarea { width:100%; background:var(--panel); border:1px solid var(--line); border-radius:9px; padding:9px 12px; color:var(--text); font:13px var(--mono); }
@@ -254,6 +275,7 @@
254
275
  fetch("<%= dashboard_stats_path %>", { headers: { Accept: "application/json" } })
255
276
  .then(function (r) { return r.json(); })
256
277
  .then(function (d) {
278
+ setConn(true);
257
279
  apply(d);
258
280
  var now = Date.now();
259
281
  var backlog = d.enqueued + d.scheduled + d.retries;
@@ -285,7 +307,13 @@
285
307
  }
286
308
  lastProcessed = d.processed; lastFailed = d.failed; lastBacklog = backlog; lastT = now;
287
309
  })
288
- .catch(function () {});
310
+ .catch(function () { setConn(false); });
311
+ }
312
+ // Surface a failed poll instead of letting the numbers silently go stale.
313
+ function setConn(ok) {
314
+ var el = document.getElementById("rh-live"), lbl = document.getElementById("rh-live-label");
315
+ if (el) el.classList.toggle("is-stale", !ok);
316
+ if (lbl) lbl.textContent = ok ? "live" : "reconnecting…";
289
317
  }
290
318
  function startOnce() { if (started) return; started = true; poll(); setInterval(poll, POLL_MS); }
291
319
  function wireChart() {
@@ -329,14 +357,14 @@
329
357
  { label: "Dashboard", path: "<%= root_path %>", icon: "▦" },
330
358
  { label: "Metrics", path: "<%= metrics_path %>", icon: "▤" },
331
359
  { label: "Busy", path: "<%= busy_path %>", icon: "◐" },
332
- { label: "Workers", path: "<%= workers_path %>", icon: "◷" },
360
+ <% if RoundhouseUi.backend.supports?(:workers) %>{ label: "Workers", path: "<%= workers_path %>", icon: "◷" },<% end %>
333
361
  { label: "Queues", path: "<%= queues_path %>", icon: "≡" },
334
362
  { label: "Scheduled", path: "<%= scheduled_path %>", icon: "◔" },
335
- { label: "Retries", path: "<%= retries_path %>", icon: "↻" },
363
+ <% if RoundhouseUi.backend.supports?(:retries) %>{ label: "Retries", path: "<%= retries_path %>", icon: "↻" },<% end %>
336
364
  { label: "Errors", path: "<%= errors_path %>", icon: "⚠" },
337
365
  { label: "Dead", path: "<%= dead_set_path %>", icon: "✕" },
338
- <% if defined?(Sidekiq::Capsule) %>{ label: "Capsules", path: "<%= capsules_path %>", icon: "⊞" },<% end %>
339
- { label: "Redis", path: "<%= redis_info_path %>", icon: "◈" },
366
+ <% if defined?(Sidekiq::Capsule) && RoundhouseUi.backend.supports?(:capsules) %>{ label: "Capsules", path: "<%= capsules_path %>", icon: "⊞" },<% end %>
367
+ <% if RoundhouseUi.backend.supports?(:redis) %>{ label: "Redis", path: "<%= redis_info_path %>", icon: "◈" },<% end %>
340
368
  { label: "Snapshots", path: "<%= snapshots_path %>", icon: "⛁" },
341
369
  { label: "Audit log", path: "<%= audit_log_path %>", icon: "☷" },
342
370
  <% if RoundhouseUi.allow_job_editing %>{ label: "Enqueue job", path: "<%= new_job_path %>", icon: "+" },<% end %>
@@ -420,13 +448,17 @@
420
448
  <%= nav_link "Dashboard", root_path, icon: "▦" %>
421
449
  <%= nav_link "Metrics", metrics_path, icon: "▤" %>
422
450
  <%= nav_link "Busy", busy_path, icon: "◐", badge: "busy" %>
423
- <%= nav_link "Workers", workers_path, icon: "◷" %>
451
+ <% if RoundhouseUi.backend.supports?(:workers) %>
452
+ <%= nav_link "Workers", workers_path, icon: "◷" %>
453
+ <% end %>
424
454
  </nav>
425
455
  <nav class="rh-grp">
426
456
  <div class="rh-grp-label">Work</div>
427
457
  <%= nav_link "Queues", queues_path, icon: "≡", badge: "queues" %>
428
458
  <%= nav_link "Scheduled", scheduled_path, icon: "◔", badge: "scheduled" %>
429
- <%= nav_link "Retries", retries_path, icon: "↻", badge: "retries", badge_class: "warn" %>
459
+ <% if RoundhouseUi.backend.supports?(:retries) %>
460
+ <%= nav_link "Retries", retries_path, icon: "↻", badge: "retries", badge_class: "warn" %>
461
+ <% end %>
430
462
  <%= nav_link "Errors", errors_path, icon: "⚠" %>
431
463
  <%= nav_link "Dead", dead_set_path, icon: "✕", badge: "dead", badge_class: "crit" %>
432
464
  <% if RoundhouseUi.allow_job_editing %>
@@ -435,11 +467,13 @@
435
467
  </nav>
436
468
  <nav class="rh-grp">
437
469
  <div class="rh-grp-label">Infrastructure</div>
438
- <%# Capsules are a Sidekiq 7+ concept — hide the nav entirely on 6.x. %>
439
- <% if defined?(Sidekiq::Capsule) %>
470
+ <%# Capsules are a Sidekiq 7+ concept — hide on 6.x and on non-Sidekiq backends. %>
471
+ <% if defined?(Sidekiq::Capsule) && RoundhouseUi.backend.supports?(:capsules) %>
440
472
  <%= nav_link "Capsules", capsules_path, icon: "⊞" %>
441
473
  <% end %>
442
- <%= nav_link "Redis", redis_info_path, icon: "◈" %>
474
+ <% if RoundhouseUi.backend.supports?(:redis) %>
475
+ <%= nav_link "Redis", redis_info_path, icon: "◈" %>
476
+ <% end %>
443
477
  <%= nav_link "Snapshots", snapshots_path, icon: "⛁" %>
444
478
  <%= nav_link "Audit log", audit_log_path, icon: "☷" %>
445
479
  </nav>
@@ -450,7 +484,7 @@
450
484
  <h1><%= yield :title %></h1>
451
485
  <span class="rh-crumb"><%= yield :crumb %></span>
452
486
  <span class="rh-spacer"></span>
453
- <span class="rh-live"><span class="rh-dot"></span> live · <span class="num" data-stat="rate">—</span>/min</span>
487
+ <span class="rh-live" id="rh-live"><span class="rh-dot"></span> <span id="rh-live-label">live</span> · <span class="num" data-stat="rate">—</span>/min</span>
454
488
  <% if RoundhouseUi.read_only %><span class="rh-ro">read-only</span><% end %>
455
489
  <button class="rh-kbd" id="rh-palette-open" type="button" title="Command palette (⌘K)">⌘K</button>
456
490
  <button class="rh-iconbtn" id="rh-width" type="button" title="Toggle full width" aria-label="Toggle full width">⟷</button>
@@ -6,6 +6,14 @@
6
6
 
7
7
  <h2 class="rh-h2">Dead set · <%= number_with_delimiter @total %> jobs</h2>
8
8
 
9
+ <% if @query.present? %>
10
+ <div class="rh-bulkbar">
11
+ <%= button_to "↻ Retry all matching", bulk_all_dead_path, method: :post, params: { op: "retry", q: @query }, class: "rh-btn", form_class: "rh-inline", data: { turbo_confirm: "Retry every dead job matching “#{@query}” (up to #{RoundhouseUi::JobSetBrowsing::BULK_CAP})?" } %>
12
+ <%= button_to "✕ Delete all matching", bulk_all_dead_path, method: :post, params: { op: "delete", q: @query }, class: "rh-btn rh-btn-danger", form_class: "rh-inline", data: { turbo_confirm: "Permanently delete every dead job matching “#{@query}” (up to #{RoundhouseUi::JobSetBrowsing::BULK_CAP})?" } %>
13
+ <span class="rh-sub">acts on every match for “<%= @query %>”, not just this page</span>
14
+ </div>
15
+ <% end %>
16
+
9
17
  <%= form_with url: bulk_dead_path, method: :post, data: { turbo: false } do %>
10
18
  <div class="rh-bulkbar">
11
19
  <button type="submit" name="op" value="retry" class="rh-btn">↻ Retry selected</button>
@@ -47,3 +47,24 @@
47
47
  <div class="d">backlog clears in, at current rate</div>
48
48
  </div>
49
49
  </div>
50
+
51
+ <% if RoundhouseUi.collect_durations %>
52
+ <h2 class="rh-h2">Slowest job classes <span class="hint">by total time · from RoundhouseUi::DurationCollector</span></h2>
53
+ <% if @durations.blank? %>
54
+ <div class="rh-panel"><div class="rh-empty">No durations recorded yet — jobs need to run with the collector middleware installed.</div></div>
55
+ <% else %>
56
+ <table class="rh-table">
57
+ <thead><tr><th>Job class</th><th class="r">Runs</th><th class="r">Avg</th><th class="r">Total time</th></tr></thead>
58
+ <tbody>
59
+ <% @durations.each do |d| %>
60
+ <tr>
61
+ <td class="rh-mono"><%= d[:klass] %></td>
62
+ <td class="r rh-mono"><%= number_with_delimiter d[:count] %></td>
63
+ <td class="r rh-mono <%= "rh-lat-warn" if d[:avg_ms] >= 1000 %>"><%= d[:avg_ms] >= 1000 ? "#{(d[:avg_ms] / 1000).round(1)}s" : "#{d[:avg_ms].round}ms" %></td>
64
+ <td class="r rh-mono"><%= d[:total_ms] >= 1000 ? "#{(d[:total_ms] / 1000).round(1)}s" : "#{d[:total_ms].round}ms" %></td>
65
+ </tr>
66
+ <% end %>
67
+ </tbody>
68
+ </table>
69
+ <% end %>
70
+ <% end %>
@@ -5,6 +5,15 @@
5
5
  </form>
6
6
 
7
7
  <h2 class="rh-h2">Retries · <%= number_with_delimiter @total %> jobs</h2>
8
+
9
+ <% if @query.present? %>
10
+ <div class="rh-bulkbar">
11
+ <%= button_to "↻ Run all matching now", bulk_all_retries_path, method: :post, params: { op: "retry", q: @query }, class: "rh-btn", form_class: "rh-inline", data: { turbo_confirm: "Run every retry matching “#{@query}” now (up to #{RoundhouseUi::JobSetBrowsing::BULK_CAP})?" } %>
12
+ <%= button_to "✕ Delete all matching", bulk_all_retries_path, method: :post, params: { op: "delete", q: @query }, class: "rh-btn rh-btn-danger", form_class: "rh-inline", data: { turbo_confirm: "Permanently delete every retry matching “#{@query}” (up to #{RoundhouseUi::JobSetBrowsing::BULK_CAP})?" } %>
13
+ <span class="rh-sub">acts on every match for “<%= @query %>”, not just this page</span>
14
+ </div>
15
+ <% end %>
16
+
8
17
  <table class="rh-table">
9
18
  <thead><tr><th>Job</th><th>Last error</th><th class="r">Attempt</th><th class="r">Next try</th><th class="r">Actions</th></tr></thead>
10
19
  <tbody>
data/config/routes.rb CHANGED
@@ -30,13 +30,15 @@ RoundhouseUi::Engine.routes.draw do
30
30
  post "scheduled/:jid/delete" => "scheduled#destroy", as: :delete_scheduled
31
31
 
32
32
  get "retries" => "retries#index", as: :retries
33
+ post "retries/bulk_all" => "retries#bulk_all", as: :bulk_all_retries
33
34
  post "retries/:jid/run" => "retries#requeue", as: :run_retry
34
35
  post "retries/:jid/delete" => "retries#destroy", as: :delete_retry
35
36
 
36
37
  get "dead" => "dead#index", as: :dead_set
37
- post "dead/bulk" => "dead#bulk", as: :bulk_dead
38
- post "dead/:jid/retry" => "dead#requeue", as: :retry_dead_job
39
- post "dead/:jid/delete" => "dead#destroy", as: :delete_dead_job
38
+ post "dead/bulk" => "dead#bulk", as: :bulk_dead
39
+ post "dead/bulk_all" => "dead#bulk_all", as: :bulk_all_dead
40
+ post "dead/:jid/retry" => "dead#requeue", as: :retry_dead_job
41
+ post "dead/:jid/delete" => "dead#destroy", as: :delete_dead_job
40
42
 
41
43
  get "errors" => "errors#index", as: :errors
42
44
  get "capsules" => "capsules#index", as: :capsules
@@ -0,0 +1,63 @@
1
+ module RoundhouseUi
2
+ module Backends
3
+ # The Sidekiq implementation of the Roundhouse backend port. It is a thin
4
+ # seam over Sidekiq's own API objects — controllers ask the backend for a
5
+ # "set" or "stats" instead of naming `Sidekiq::*` directly, so a second
6
+ # backend (Solid Queue) can supply duck-typed equivalents without touching
7
+ # controllers or views.
8
+ #
9
+ # Deliberately thin for now: each method returns the same Sidekiq object the
10
+ # controllers used before, so this refactor changes no behavior. The contract
11
+ # (what a "set" / "entry" must respond to) will be firmed up as the Solid
12
+ # Queue backend is built against it.
13
+ class Sidekiq
14
+ def name = "Sidekiq"
15
+
16
+ # Capabilities let the UI hide what a backend can't do. Sidekiq supports
17
+ # all the sets/views; pause is NOT native (needs the fetcher), so it does
18
+ # not advertise :native_pause — the "not enforced" warning still applies.
19
+ CAPABILITIES = %i[retries dead scheduled busy workers redis capsules].freeze
20
+ def supports?(capability) = CAPABILITIES.include?(capability)
21
+
22
+ def stats = ::Sidekiq::Stats.new
23
+ def queues = ::Sidekiq::Queue.all
24
+ def queue(name) = ::Sidekiq::Queue.new(name)
25
+ def retry_set = ::Sidekiq::RetrySet.new
26
+ def dead_set = ::Sidekiq::DeadSet.new
27
+ def scheduled_set = ::Sidekiq::ScheduledSet.new
28
+ def work_set = ::Sidekiq::WorkSet.new
29
+ def process_set = ::Sidekiq::ProcessSet.new
30
+
31
+ # The job set a given UI section maps to (used by the job-detail page).
32
+ # Lazy — only builds the requested set.
33
+ def set(kind)
34
+ case kind.to_s
35
+ when "dead" then dead_set
36
+ when "retry" then retry_set
37
+ when "scheduled" then scheduled_set
38
+ end
39
+ end
40
+
41
+ def push(payload) = ::Sidekiq::Client.push(payload)
42
+
43
+ # Currently-executing jobs, normalized to the Busy view's shape. (This
44
+ # normalization used to live in BusyController; it belongs to the backend.)
45
+ def busy
46
+ ::Sidekiq::WorkSet.new.map do |process_id, tid, work|
47
+ if work.respond_to?(:queue) # Sidekiq 7+: Sidekiq::Work struct
48
+ { process: process_id, tid: tid, queue: work.queue, run_at: work.run_at, job: work.job }
49
+ else # Sidekiq 6.x: a plain Hash
50
+ { process: process_id, tid: tid, queue: work["queue"],
51
+ run_at: Time.at(work["run_at"]), job: ::Sidekiq::JobRecord.new(work["payload"]) }
52
+ end
53
+ end
54
+ end
55
+
56
+ # --- pause (via the opt-in Roundhouse fetcher; not native) ---
57
+ def paused_queues = RoundhouseUi::Pause.paused_set
58
+ def pause(name) = RoundhouseUi::Pause.pause!(name)
59
+ def resume(name) = RoundhouseUi::Pause.unpause!(name)
60
+ def fetch_installed? = RoundhouseUi::Pause.fetch_installed?
61
+ end
62
+ end
63
+ end
@@ -0,0 +1,133 @@
1
+ require "json"
2
+
3
+ module RoundhouseUi
4
+ module Backends
5
+ # Solid Queue implementation of the backend port. Reads Solid Queue's
6
+ # ActiveRecord tables and normalizes them to the same shape the Sidekiq
7
+ # backend exposes, so controllers/views don't change.
8
+ #
9
+ # Solid Queue has no "retry set" (retries are just re-scheduled executions),
10
+ # no Redis, and no capsules — the UI hides those via #supports?. Queue pause
11
+ # is native, so there's no fetcher to warn about.
12
+ class SolidQueue
13
+ def name = "Solid Queue"
14
+
15
+ # No retries/redis/capsules/workers (deferred); pause is native (no fetcher
16
+ # warning). dead/scheduled/busy are supported.
17
+ CAPABILITIES = %i[dead scheduled busy native_pause].freeze
18
+ def supports?(capability) = CAPABILITIES.include?(capability)
19
+
20
+ def stats
21
+ Stats.new(
22
+ processed: sq(:Job).where.not(finished_at: nil).count,
23
+ failed: sq(:FailedExecution).count,
24
+ enqueued: sq(:ReadyExecution).count,
25
+ scheduled: sq(:ScheduledExecution).count,
26
+ busy: sq(:ClaimedExecution).count,
27
+ dead: sq(:FailedExecution).count,
28
+ retries: 0
29
+ )
30
+ end
31
+
32
+ def queues = ::SolidQueue::Queue.all
33
+ def queue(name) = ::SolidQueue::Queue.new(name)
34
+
35
+ # No retry set in Solid Queue — surfaced via supports?(:retries) => false.
36
+ def retry_set = EMPTY_SET
37
+
38
+ # Workers view is deferred (supports?(:workers) == false), and Solid Queue
39
+ # processes don't report thread concurrency the way Metrics expects, so the
40
+ # utilization signal reads as "no workers reporting" rather than crashing.
41
+ def process_set = []
42
+
43
+ def dead_set = JobSet.new(sq(:FailedExecution).includes(:job).order(created_at: :desc))
44
+ def scheduled_set = JobSet.new(sq(:ScheduledExecution).includes(:job).order(:scheduled_at))
45
+
46
+ # Currently-executing jobs, normalized to the Busy view's shape.
47
+ def busy
48
+ sq(:ClaimedExecution).includes(:job, :process).map do |claim|
49
+ e = Entry.new(claim)
50
+ { process: claim.process&.name, tid: claim.id, queue: e.queue, run_at: claim.created_at, job: e }
51
+ end
52
+ end
53
+
54
+ def set(kind)
55
+ case kind.to_s
56
+ when "dead" then dead_set
57
+ when "scheduled" then scheduled_set
58
+ end
59
+ end
60
+
61
+ # --- pause (native) ---
62
+ def paused_queues = ::SolidQueue::Pause.pluck(:queue_name)
63
+ def pause(name) = ::SolidQueue::Queue.new(name).pause
64
+ def resume(name) = ::SolidQueue::Queue.new(name).resume
65
+
66
+ # ActiveJob enqueues via perform_later; a raw push has no direct analogue.
67
+ def push(_payload) = raise NotImplementedError, "enqueue is Sidekiq-only for now"
68
+
69
+ private
70
+
71
+ def sq(model) = ::SolidQueue.const_get(model)
72
+
73
+ # A collection matching the JobSet contract (each / find_job / size).
74
+ class JobSet
75
+ include Enumerable
76
+ def initialize(relation) = @relation = relation
77
+ def each(&blk) = @relation.each { |exec| blk.call(Entry.new(exec)) }
78
+ def size = @relation.count
79
+ def find_job(jid) = (exec = @relation.find_by(job_id: jid)) && Entry.new(exec)
80
+ end
81
+
82
+ EMPTY_SET = JobSet.new([]).freeze
83
+
84
+ # Wraps a Solid Queue execution + its job in the SortedEntry-like interface
85
+ # the views expect (klass / jid / args / item / at / queue / retry / delete).
86
+ class Entry
87
+ def initialize(execution)
88
+ @execution = execution
89
+ @job = execution.job
90
+ end
91
+
92
+ def jid = @execution.job_id.to_s
93
+ def klass = @job&.class_name
94
+ def queue = @job&.queue_name
95
+ def args = active_job_args
96
+ def at = @execution.try(:scheduled_at) || @execution.try(:created_at)
97
+
98
+ def item
99
+ err = (@execution.respond_to?(:error) && @execution.error) || {}
100
+ {
101
+ "class" => klass, "args" => args, "queue" => queue,
102
+ "error_class" => err["exception_class"],
103
+ "error_message" => err["message"],
104
+ "error_backtrace" => err["backtrace"]
105
+ }
106
+ end
107
+
108
+ def retry = @execution.retry if @execution.respond_to?(:retry)
109
+ def delete = @execution.destroy
110
+
111
+ private
112
+
113
+ # Solid Queue stores the full ActiveJob payload; the caller-facing args
114
+ # live under "arguments".
115
+ def active_job_args
116
+ raw = @job&.arguments
117
+ data = raw.is_a?(String) ? JSON.parse(raw) : raw
118
+ data.is_a?(Hash) ? (data["arguments"] || []) : Array(data)
119
+ rescue JSON::ParserError
120
+ []
121
+ end
122
+ end
123
+
124
+ # Value object matching Sidekiq::Stats' reader surface.
125
+ Stats = Struct.new(:processed, :failed, :enqueued, :scheduled, :busy, :dead, :retries, keyword_init: true) do
126
+ def scheduled_size = scheduled
127
+ def retry_size = retries
128
+ def dead_size = dead
129
+ def workers_size = busy
130
+ end
131
+ end
132
+ end
133
+ end
@@ -0,0 +1,53 @@
1
+ module RoundhouseUi
2
+ # Opt-in server middleware that records per-class execution time, so the UI can
3
+ # answer "which job classes are slow?" — something Sidekiq doesn't track. Two
4
+ # cheap Redis writes per job (a counter + a summed-ms float). Off by default;
5
+ # enable in your Sidekiq server config:
6
+ #
7
+ # Sidekiq.configure_server do |config|
8
+ # config.server_middleware { |chain| chain.add RoundhouseUi::DurationCollector }
9
+ # end
10
+ class DurationCollector
11
+ KEY = "roundhouse:durations".freeze
12
+
13
+ def call(_worker, job, _queue)
14
+ start = Process.clock_gettime(Process::CLOCK_MONOTONIC)
15
+ yield
16
+ ensure
17
+ record(job["class"], (Process.clock_gettime(Process::CLOCK_MONOTONIC) - start) * 1000.0)
18
+ end
19
+
20
+ private
21
+
22
+ def record(klass, elapsed_ms)
23
+ return unless klass
24
+
25
+ Sidekiq.redis do |conn|
26
+ conn.call("HINCRBY", KEY, "#{klass}\x00count", 1)
27
+ conn.call("HINCRBYFLOAT", KEY, "#{klass}\x00ms", elapsed_ms)
28
+ end
29
+ rescue => e
30
+ # Metrics collection must never break a job.
31
+ Sidekiq.logger&.warn("[roundhouse] duration collect failed: #{e.message}")
32
+ end
33
+
34
+ # [{ klass:, count:, total_ms:, avg_ms: }], slowest (by total time) first.
35
+ def self.summary(limit: 20)
36
+ raw = Sidekiq.redis { |conn| conn.call("HGETALL", KEY) }
37
+ pairs = raw.is_a?(Hash) ? raw : raw.each_slice(2).to_a # redis-client: flat array; redis-rb: hash
38
+
39
+ by_class = Hash.new { |h, k| h[k] = { klass: k, count: 0, total_ms: 0.0 } }
40
+ pairs.each do |field, value|
41
+ klass, kind = field.split("\x00", 2)
42
+ next unless kind
43
+
44
+ kind == "count" ? by_class[klass][:count] = value.to_i : by_class[klass][:total_ms] = value.to_f
45
+ end
46
+
47
+ by_class.values
48
+ .map { |r| r.merge(avg_ms: r[:count].positive? ? r[:total_ms] / r[:count] : 0.0) }
49
+ .sort_by { |r| -r[:total_ms] }
50
+ .first(limit)
51
+ end
52
+ end
53
+ end
@@ -43,7 +43,7 @@ module RoundhouseUi
43
43
  # Sidekiq's native sets, plus the sidekiq-failures `failed` set when opted in
44
44
  # and loaded. Its FailureSet is a Sidekiq::JobSet, so it iterates like the rest.
45
45
  def sources
46
- sets = { "retry" => Sidekiq::RetrySet.new, "dead" => Sidekiq::DeadSet.new }
46
+ sets = { "retry" => RoundhouseUi.backend.retry_set, "dead" => RoundhouseUi.backend.dead_set }
47
47
  if RoundhouseUi.show_sidekiq_failures && defined?(Sidekiq::Failures::FailureSet)
48
48
  sets["failed"] = Sidekiq::Failures::FailureSet.new
49
49
  end
@@ -7,7 +7,7 @@ module RoundhouseUi
7
7
  # client-side from the dashboard's poll stream; per-class durations need the
8
8
  # collector (a separate, opt-in piece).
9
9
  class Metrics
10
- def initialize(stats: Sidekiq::Stats.new, processes: Sidekiq::ProcessSet.new)
10
+ def initialize(stats: RoundhouseUi.backend.stats, processes: RoundhouseUi.backend.process_set)
11
11
  @stats = stats
12
12
  @processes = processes
13
13
  end
@@ -1,3 +1,3 @@
1
1
  module RoundhouseUi
2
- VERSION = "0.7.0"
2
+ VERSION = "0.9.0"
3
3
  end
data/lib/roundhouse_ui.rb CHANGED
@@ -12,6 +12,9 @@ require "roundhouse_ui/cancel_middleware"
12
12
  require "roundhouse_ui/metrics"
13
13
  require "roundhouse_ui/error_groups"
14
14
  require "roundhouse_ui/health"
15
+ require "roundhouse_ui/duration_collector"
16
+ require "roundhouse_ui/backends/sidekiq"
17
+ require "roundhouse_ui/backends/solid_queue"
15
18
 
16
19
  # Brand name is "Roundhouse"; the gem and Ruby namespace are RoundhouseUi
17
20
  # (matching the published gem name `roundhouse_ui`).
@@ -59,6 +62,12 @@ module RoundhouseUi
59
62
  # Sidekiq's retry/dead sets, so this is the only way to surface them here.
60
63
  attr_accessor :show_sidekiq_failures
61
64
 
65
+ # Opt-in: record per-class job durations (via RoundhouseUi::DurationCollector
66
+ # server middleware) so the Metrics page can show the slowest job classes.
67
+ # Default false; reads/writes a single Redis hash. The flag gates the UI; the
68
+ # collection itself is enabled by installing the middleware.
69
+ attr_accessor :collect_durations
70
+
62
71
  # Seconds between dashboard stat polls. Lower = livelier, but each poll also
63
72
  # re-runs the host's auth/routing on the mount, so a busy console can add DB
64
73
  # load. Default 5s; raise it if polling shows up in your traces.
@@ -80,6 +89,15 @@ module RoundhouseUi
80
89
  yield self
81
90
  end
82
91
 
92
+ # The queue backend the UI reads through. Defaults to Sidekiq; assign a
93
+ # different adapter (e.g. Solid Queue) to point Roundhouse at another system.
94
+ # See docs/adr/0001-backend-port-multi-queue.md.
95
+ attr_writer :backend
96
+
97
+ def backend
98
+ @backend ||= Backends::Sidekiq.new
99
+ end
100
+
83
101
  # Cooperative cancellation check for long-running jobs:
84
102
  # raise SomeStop if RoundhouseUi.cancelled?(jid)
85
103
  def cancelled?(jid)
@@ -93,4 +111,5 @@ module RoundhouseUi
93
111
  self.show_sidekiq_failures = false
94
112
  self.pause_enabled = true
95
113
  self.poll_interval = 5
114
+ self.collect_durations = false
96
115
  end
metadata CHANGED
@@ -1,14 +1,14 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: roundhouse_ui
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.7.0
4
+ version: 0.9.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - R.J. Robinson
8
8
  autorequire:
9
9
  bindir: bin
10
10
  cert_chain: []
11
- date: 2026-06-30 00:00:00.000000000 Z
11
+ date: 2026-07-24 00:00:00.000000000 Z
12
12
  dependencies:
13
13
  - !ruby/object:Gem::Dependency
14
14
  name: rails
@@ -94,8 +94,11 @@ files:
94
94
  - config/routes.rb
95
95
  - lib/roundhouse_ui.rb
96
96
  - lib/roundhouse_ui/audit.rb
97
+ - lib/roundhouse_ui/backends/sidekiq.rb
98
+ - lib/roundhouse_ui/backends/solid_queue.rb
97
99
  - lib/roundhouse_ui/cancel_middleware.rb
98
100
  - lib/roundhouse_ui/cancellation.rb
101
+ - lib/roundhouse_ui/duration_collector.rb
99
102
  - lib/roundhouse_ui/engine.rb
100
103
  - lib/roundhouse_ui/error_groups.rb
101
104
  - lib/roundhouse_ui/fetch.rb