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 +4 -4
- data/README.md +50 -22
- data/app/controllers/roundhouse_ui/application_controller.rb +6 -0
- data/app/controllers/roundhouse_ui/busy_controller.rb +1 -13
- data/app/controllers/roundhouse_ui/capsules_controller.rb +1 -1
- data/app/controllers/roundhouse_ui/dashboard_controller.rb +4 -4
- data/app/controllers/roundhouse_ui/dead_controller.rb +6 -6
- data/app/controllers/roundhouse_ui/jobs_controller.rb +4 -9
- data/app/controllers/roundhouse_ui/queues_controller.rb +9 -6
- data/app/controllers/roundhouse_ui/retries_controller.rb +5 -5
- data/app/controllers/roundhouse_ui/scheduled_controller.rb +4 -4
- data/app/controllers/roundhouse_ui/workers_controller.rb +2 -2
- data/app/views/layouts/roundhouse_ui/application.html.erb +15 -9
- data/lib/roundhouse_ui/backends/sidekiq.rb +63 -0
- data/lib/roundhouse_ui/backends/solid_queue.rb +133 -0
- data/lib/roundhouse_ui/error_groups.rb +1 -1
- data/lib/roundhouse_ui/metrics.rb +1 -1
- data/lib/roundhouse_ui/version.rb +1 -1
- data/lib/roundhouse_ui.rb +11 -0
- metadata +4 -2
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 913330ea311e66bcb5eb717852a73c0c4eb51a281acbd5899fac33f623b609a2
|
|
4
|
+
data.tar.gz: 8fdaff5a284e27d7df7a63ade47294f9d5f156a14e9133f7a40a24ca194ad828
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
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
|
[](https://github.com/rjrobinson/roundhouse_ui/actions/workflows/ci.yml)
|
|
7
6
|
[](https://rubygems.org/gems/roundhouse_ui)
|
|
@@ -9,32 +8,34 @@
|
|
|
9
8
|
[](https://rubyonrails.org)
|
|
10
9
|
[](MIT-LICENSE)
|
|
11
10
|
|
|
12
|
-
Roundhouse is a mountable Rails engine
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
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
|
-
- **
|
|
23
|
-
- **
|
|
24
|
-
- **
|
|
25
|
-
- **
|
|
26
|
-
- **Queue management** — pause/resume
|
|
27
|
-
- **Job inspection & editing** —
|
|
28
|
-
- **
|
|
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
|
-
|
|
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 >=
|
|
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
|
-
|
|
109
|
-
install
|
|
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
|
-
-
|
|
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 =
|
|
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
|
-
|
|
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 =
|
|
7
|
-
@queues =
|
|
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 =
|
|
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:
|
|
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 =
|
|
11
|
-
@jobs, @has_next = browse(
|
|
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 =
|
|
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 =
|
|
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 =
|
|
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(
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
70
|
-
|
|
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 =
|
|
7
|
-
@paused =
|
|
8
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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 =
|
|
11
|
-
@jobs, @has_next = browse(
|
|
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 =
|
|
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 =
|
|
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(
|
|
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 =
|
|
11
|
-
@jobs, @has_next = browse(
|
|
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 =
|
|
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 =
|
|
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 =
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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" =>
|
|
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:
|
|
10
|
+
def initialize(stats: RoundhouseUi.backend.stats, processes: RoundhouseUi.backend.process_set)
|
|
11
11
|
@stats = stats
|
|
12
12
|
@processes = processes
|
|
13
13
|
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.
|
|
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-
|
|
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
|