roundhouse_ui 0.8.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: 11537f41a49c350dbd057961b82994fdc7524afdf3285961a28fa13701daaf57
4
- data.tar.gz: aedef27c3202b3d2eaf25a2b9fefb9290df222c66d1fde4ae8414152f3a477cf
3
+ metadata.gz: 913330ea311e66bcb5eb717852a73c0c4eb51a281acbd5899fac33f623b609a2
4
+ data.tar.gz: 8fdaff5a284e27d7df7a63ade47294f9d5f156a14e9133f7a40a24ca194ad828
5
5
  SHA512:
6
- metadata.gz: f4b22e1a716bbbdfdbab62cc5b0dcb10094a3cefbefd7ad1f1a24367e1f383f4c4c1467d1aa840600f860b1bc65b433fbdbdc56da5e1f1c1545f1756a99ed83c
7
- data.tar.gz: cbe28c835c0e6413ee88658242e455e39a47cbd124a1e06ee0a9d40106ea9eb3ba49e2491f8dbd6f3a757ad71c0a24f5ead03b0b263b8a96237db3d2f8732b74
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.
@@ -105,8 +128,11 @@ Every option is independent and has a safe default — set only what you need.
105
128
 
106
129
  ## Pausing queues
107
130
 
108
- Pause/resume is pure OSS. To make a pause actually stop a queue from being worked,
109
- 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:
110
136
 
111
137
  ```ruby
112
138
  # config/initializers/sidekiq.rb
@@ -220,8 +246,10 @@ Redis and run `bin/rails server` to click around.
220
246
 
221
247
  ## Roadmap
222
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)).
223
251
  - Multi-Redis / multi-cluster view (one pane across shards).
224
- - Capsules and cron/periodic views.
252
+ - Cron/periodic (recurring) views.
225
253
 
226
254
  ## Contributing
227
255
 
@@ -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
@@ -7,25 +7,25 @@ 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::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
@@ -40,7 +40,7 @@ module RoundhouseUi
40
40
  # selected/visible ones), capped for safety. Only offered when a filter is
41
41
  # active, so it can't become "retry the entire dead set" by accident.
42
42
  def bulk_all
43
- count, capped = bulk_apply(Sidekiq::DeadSet.new, params[:q].to_s.strip, params[:op])
43
+ count, capped = bulk_apply(backend.dead_set, params[:q].to_s.strip, params[:op])
44
44
  verb = params[:op] == "delete" ? "Deleted" : "Re-enqueued"
45
45
  note = "#{verb} #{count} matching job(s)."
46
46
  note += " Stopped at the #{JobSetBrowsing::BULK_CAP} cap — run again for more." if capped
@@ -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,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
 
@@ -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::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
@@ -27,7 +27,7 @@ module RoundhouseUi
27
27
  # Smart bulk: retry/delete EVERY job matching the current filter, capped for
28
28
  # safety. Offered only when a filter is active.
29
29
  def bulk_all
30
- count, capped = bulk_apply(Sidekiq::RetrySet.new, params[:q].to_s.strip, params[:op])
30
+ count, capped = bulk_apply(backend.retry_set, params[:q].to_s.strip, params[:op])
31
31
  verb = params[:op] == "delete" ? "Deleted" : "Re-enqueued"
32
32
  note = "#{verb} #{count} matching job(s)."
33
33
  note += " Stopped at the #{JobSetBrowsing::BULK_CAP} cap — run again for more." if capped
@@ -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!
@@ -357,14 +357,14 @@
357
357
  { label: "Dashboard", path: "<%= root_path %>", icon: "▦" },
358
358
  { label: "Metrics", path: "<%= metrics_path %>", icon: "▤" },
359
359
  { label: "Busy", path: "<%= busy_path %>", icon: "◐" },
360
- { label: "Workers", path: "<%= workers_path %>", icon: "◷" },
360
+ <% if RoundhouseUi.backend.supports?(:workers) %>{ label: "Workers", path: "<%= workers_path %>", icon: "◷" },<% end %>
361
361
  { label: "Queues", path: "<%= queues_path %>", icon: "≡" },
362
362
  { label: "Scheduled", path: "<%= scheduled_path %>", icon: "◔" },
363
- { label: "Retries", path: "<%= retries_path %>", icon: "↻" },
363
+ <% if RoundhouseUi.backend.supports?(:retries) %>{ label: "Retries", path: "<%= retries_path %>", icon: "↻" },<% end %>
364
364
  { label: "Errors", path: "<%= errors_path %>", icon: "⚠" },
365
365
  { label: "Dead", path: "<%= dead_set_path %>", icon: "✕" },
366
- <% if defined?(Sidekiq::Capsule) %>{ label: "Capsules", path: "<%= capsules_path %>", icon: "⊞" },<% end %>
367
- { 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 %>
368
368
  { label: "Snapshots", path: "<%= snapshots_path %>", icon: "⛁" },
369
369
  { label: "Audit log", path: "<%= audit_log_path %>", icon: "☷" },
370
370
  <% if RoundhouseUi.allow_job_editing %>{ label: "Enqueue job", path: "<%= new_job_path %>", icon: "+" },<% end %>
@@ -448,13 +448,17 @@
448
448
  <%= nav_link "Dashboard", root_path, icon: "▦" %>
449
449
  <%= nav_link "Metrics", metrics_path, icon: "▤" %>
450
450
  <%= nav_link "Busy", busy_path, icon: "◐", badge: "busy" %>
451
- <%= nav_link "Workers", workers_path, icon: "◷" %>
451
+ <% if RoundhouseUi.backend.supports?(:workers) %>
452
+ <%= nav_link "Workers", workers_path, icon: "◷" %>
453
+ <% end %>
452
454
  </nav>
453
455
  <nav class="rh-grp">
454
456
  <div class="rh-grp-label">Work</div>
455
457
  <%= nav_link "Queues", queues_path, icon: "≡", badge: "queues" %>
456
458
  <%= nav_link "Scheduled", scheduled_path, icon: "◔", badge: "scheduled" %>
457
- <%= 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 %>
458
462
  <%= nav_link "Errors", errors_path, icon: "⚠" %>
459
463
  <%= nav_link "Dead", dead_set_path, icon: "✕", badge: "dead", badge_class: "crit" %>
460
464
  <% if RoundhouseUi.allow_job_editing %>
@@ -463,11 +467,13 @@
463
467
  </nav>
464
468
  <nav class="rh-grp">
465
469
  <div class="rh-grp-label">Infrastructure</div>
466
- <%# Capsules are a Sidekiq 7+ concept — hide the nav entirely on 6.x. %>
467
- <% 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) %>
468
472
  <%= nav_link "Capsules", capsules_path, icon: "⊞" %>
469
473
  <% end %>
470
- <%= nav_link "Redis", redis_info_path, icon: "◈" %>
474
+ <% if RoundhouseUi.backend.supports?(:redis) %>
475
+ <%= nav_link "Redis", redis_info_path, icon: "◈" %>
476
+ <% end %>
471
477
  <%= nav_link "Snapshots", snapshots_path, icon: "⛁" %>
472
478
  <%= nav_link "Audit log", audit_log_path, icon: "☷" %>
473
479
  </nav>
@@ -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
@@ -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.8.0"
2
+ VERSION = "0.9.0"
3
3
  end
data/lib/roundhouse_ui.rb CHANGED
@@ -13,6 +13,8 @@ require "roundhouse_ui/metrics"
13
13
  require "roundhouse_ui/error_groups"
14
14
  require "roundhouse_ui/health"
15
15
  require "roundhouse_ui/duration_collector"
16
+ require "roundhouse_ui/backends/sidekiq"
17
+ require "roundhouse_ui/backends/solid_queue"
16
18
 
17
19
  # Brand name is "Roundhouse"; the gem and Ruby namespace are RoundhouseUi
18
20
  # (matching the published gem name `roundhouse_ui`).
@@ -87,6 +89,15 @@ module RoundhouseUi
87
89
  yield self
88
90
  end
89
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
+
90
101
  # Cooperative cancellation check for long-running jobs:
91
102
  # raise SomeStop if RoundhouseUi.cancelled?(jid)
92
103
  def cancelled?(jid)
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.8.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-07-01 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,6 +94,8 @@ 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
99
101
  - lib/roundhouse_ui/duration_collector.rb