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 +4 -4
- data/README.md +81 -22
- data/app/controllers/concerns/roundhouse_ui/job_set_browsing.rb +20 -0
- 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 +17 -6
- data/app/controllers/roundhouse_ui/jobs_controller.rb +4 -9
- data/app/controllers/roundhouse_ui/metrics_controller.rb +1 -0
- data/app/controllers/roundhouse_ui/queues_controller.rb +9 -6
- data/app/controllers/roundhouse_ui/retries_controller.rb +15 -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 +45 -11
- data/app/views/roundhouse_ui/dead/index.html.erb +8 -0
- data/app/views/roundhouse_ui/metrics/show.html.erb +21 -0
- data/app/views/roundhouse_ui/retries/index.html.erb +9 -0
- data/config/routes.rb +5 -3
- data/lib/roundhouse_ui/backends/sidekiq.rb +63 -0
- data/lib/roundhouse_ui/backends/solid_queue.rb +133 -0
- data/lib/roundhouse_ui/duration_collector.rb +53 -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 +19 -0
- metadata +5 -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.
|
|
@@ -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
|
-
|
|
101
|
-
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:
|
|
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
|
-
-
|
|
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 =
|
|
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
|
|
@@ -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 =
|
|
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
|
|
@@ -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
|
-
|
|
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
|
|
|
@@ -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 =
|
|
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
|
|
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 =
|
|
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!
|
|
@@ -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
|
-
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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",
|
|
38
|
-
post "dead
|
|
39
|
-
post "dead/:jid/
|
|
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" =>
|
|
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
|
@@ -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.
|
|
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-
|
|
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
|