roundhouse_ui 0.9.1 → 0.11.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 +620 -46
- data/app/controllers/concerns/roundhouse_ui/job_set_browsing.rb +246 -12
- data/app/controllers/roundhouse_ui/application_controller.rb +74 -0
- data/app/controllers/roundhouse_ui/audit_controller.rb +1 -0
- data/app/controllers/roundhouse_ui/busy_controller.rb +25 -5
- data/app/controllers/roundhouse_ui/dashboard_controller.rb +27 -3
- data/app/controllers/roundhouse_ui/dead_controller.rb +27 -10
- data/app/controllers/roundhouse_ui/errors_controller.rb +60 -1
- data/app/controllers/roundhouse_ui/jobs_controller.rb +5 -1
- data/app/controllers/roundhouse_ui/queues_controller.rb +57 -6
- data/app/controllers/roundhouse_ui/recurring_controller.rb +12 -0
- data/app/controllers/roundhouse_ui/retries_controller.rb +34 -10
- data/app/controllers/roundhouse_ui/scheduled_controller.rb +4 -7
- data/app/controllers/roundhouse_ui/settings_controller.rb +15 -0
- data/app/controllers/roundhouse_ui/snapshots_controller.rb +5 -6
- data/app/controllers/roundhouse_ui/workers_controller.rb +2 -7
- data/app/helpers/roundhouse_ui/application_helper.rb +293 -0
- data/app/helpers/roundhouse_ui/nav_helper.rb +3 -3
- data/app/helpers/roundhouse_ui/observability_helper.rb +121 -12
- data/app/helpers/roundhouse_ui/tags_helper.rb +128 -0
- data/app/views/layouts/roundhouse_ui/application.html.erb +1277 -84
- data/app/views/roundhouse_ui/busy/index.html.erb +30 -6
- data/app/views/roundhouse_ui/dashboard/show.html.erb +62 -17
- data/app/views/roundhouse_ui/dead/index.html.erb +54 -32
- data/app/views/roundhouse_ui/errors/index.html.erb +51 -11
- data/app/views/roundhouse_ui/jobs/show.html.erb +8 -3
- data/app/views/roundhouse_ui/metrics/show.html.erb +26 -3
- data/app/views/roundhouse_ui/queues/index.html.erb +54 -10
- data/app/views/roundhouse_ui/queues/show.html.erb +55 -0
- data/app/views/roundhouse_ui/recurring/index.html.erb +71 -0
- data/app/views/roundhouse_ui/retries/index.html.erb +30 -17
- data/app/views/roundhouse_ui/scheduled/index.html.erb +17 -11
- data/app/views/roundhouse_ui/settings/show.html.erb +91 -0
- data/app/views/roundhouse_ui/shared/_filter_error.html.erb +23 -0
- data/app/views/roundhouse_ui/shared/_pager.html.erb +2 -2
- data/app/views/roundhouse_ui/shared/_search_bar.html.erb +55 -0
- data/app/views/roundhouse_ui/shared/_search_help.html.erb +37 -0
- data/app/views/roundhouse_ui/shared/_tag_filter.html.erb +28 -0
- data/app/views/roundhouse_ui/shared/bulk_preview.html.erb +69 -0
- data/config/routes.rb +9 -0
- data/lib/roundhouse_ui/backends/sidekiq.rb +124 -5
- data/lib/roundhouse_ui/backends/solid_queue.rb +54 -3
- data/lib/roundhouse_ui/demo.rb +130 -0
- data/lib/roundhouse_ui/error_groups.rb +25 -4
- data/lib/roundhouse_ui/filter_query.rb +504 -0
- data/lib/roundhouse_ui/health.rb +43 -4
- data/lib/roundhouse_ui/history.rb +54 -0
- data/lib/roundhouse_ui/icons.rb +104 -0
- data/lib/roundhouse_ui/marks/datadog-lockup-white.svg +30 -0
- data/lib/roundhouse_ui/marks/datadog-lockup.svg +41 -0
- data/lib/roundhouse_ui/observability.rb +55 -7
- data/lib/roundhouse_ui/pause.rb +6 -11
- data/lib/roundhouse_ui/queue_summary.rb +11 -0
- data/lib/roundhouse_ui/recurring.rb +137 -0
- data/lib/roundhouse_ui/runbooks.rb +75 -0
- data/lib/roundhouse_ui/snapshots.rb +13 -2
- data/lib/roundhouse_ui/tags.rb +112 -0
- data/lib/roundhouse_ui/theme.rb +298 -0
- data/lib/roundhouse_ui/version.rb +1 -1
- data/lib/roundhouse_ui.rb +235 -5
- data/lib/tasks/roundhouse_ui_tasks.rake +84 -4
- metadata +32 -7
data/README.md
CHANGED
|
@@ -1,37 +1,128 @@
|
|
|
1
1
|
# Roundhouse
|
|
2
|
-
<img width="
|
|
3
|
-
|
|
2
|
+
<img width="4446" height="3022" alt="CleanShot 2026-08-27 at 07 43 42@2x" src="https://github.com/user-attachments/assets/cc737416-bf90-4303-85a9-0dcd1316939d" />
|
|
3
|
+
|
|
4
|
+
<!-- TODO: demo GIF -->
|
|
4
5
|
|
|
5
6
|
[](https://github.com/rjrobinson/roundhouse_ui/actions/workflows/ci.yml)
|
|
6
7
|
[](https://rubygems.org/gems/roundhouse_ui)
|
|
7
8
|
[](https://www.ruby-lang.org)
|
|
8
9
|
[](https://rubyonrails.org)
|
|
9
10
|
[](MIT-LICENSE)
|
|
11
|
+
[](https://buymeacoffee.com/rjrobinson)
|
|
10
12
|
|
|
11
|
-
Roundhouse is a
|
|
12
|
-
|
|
13
|
-
|
|
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**.
|
|
13
|
+
Roundhouse is a real-time ops UI for Sidekiq and Solid Queue — grouped errors,
|
|
14
|
+
argument search, bulk actions on a filter, enforced pause, snapshots, and an
|
|
15
|
+
audit log — in one mountable engine with no build step.
|
|
17
16
|
|
|
18
|
-
|
|
17
|
+
It works on OSS Sidekiq, and it works better on [Sidekiq Pro and
|
|
18
|
+
Enterprise](#buy-sidekiq-pro-and-enterprise) — which you should buy.
|
|
19
|
+
|
|
20
|
+
## Buy Sidekiq Pro and Enterprise
|
|
21
|
+
|
|
22
|
+
[**Sidekiq Pro and Enterprise**](https://sidekiq.org/products/pro.html) are worth the
|
|
23
|
+
money. Buy them.
|
|
24
|
+
|
|
25
|
+
Sidekiq is the reason any of this exists, and the commercial tiers are what keep it
|
|
26
|
+
maintained. Pro gives you reliable fetch (jobs survive a hard crash), batches, expiring
|
|
27
|
+
jobs and native queue pause; Enterprise adds rate limiting, unique jobs, periodic jobs,
|
|
28
|
+
multi-process and historical metrics. Roundhouse detects all of it and gets better when
|
|
29
|
+
it is there — native pause with no fetch strategy to install, Enterprise periodic jobs
|
|
30
|
+
on the Recurring page.
|
|
31
|
+
|
|
32
|
+
Roundhouse is not a way to avoid paying for Sidekiq. It is a UI. If you are running
|
|
33
|
+
Sidekiq seriously enough to want this, you are running it seriously enough to buy Pro.
|
|
34
|
+
|
|
35
|
+
> Roundhouse is not affiliated with or endorsed by Contributed Systems LLC. Sidekiq,
|
|
36
|
+
> Sidekiq Pro and Sidekiq Enterprise are their trademarks.
|
|
37
|
+
|
|
38
|
+
## Support this project
|
|
39
|
+
|
|
40
|
+
If Roundhouse saved you an incident, [buy me a coffee](https://buymeacoffee.com/rjrobinson)
|
|
41
|
+
— buy Sidekiq Pro first.
|
|
42
|
+
|
|
43
|
+
I'd rather hear the story, though. Tell me what broke and what you were trying to find
|
|
44
|
+
out: [@_AwesomeRob](https://x.com/_AwesomeRob) on X, or open an
|
|
45
|
+
[issue](https://github.com/rjrobinson/roundhouse_ui/issues).
|
|
46
|
+
|
|
47
|
+
## Why
|
|
48
|
+
|
|
49
|
+
I wrote this during an incident where I needed to know which jobs for one
|
|
50
|
+
customer had failed, and whether I could retry only those. Sidekiq::Web gave me
|
|
51
|
+
a retry set of forty thousand rows, twenty-five at a time, with no search. So I
|
|
52
|
+
opened a Rails console at 2am and started writing `Sidekiq::RetrySet.new.select`
|
|
53
|
+
against production — which is not where anyone should be deciding what to retry.
|
|
54
|
+
|
|
55
|
+
Roundhouse answers that question in the browser, and records who answered it.
|
|
56
|
+
|
|
57
|
+
## Install
|
|
58
|
+
|
|
59
|
+
```ruby
|
|
60
|
+
# Gemfile
|
|
61
|
+
gem "roundhouse_ui"
|
|
62
|
+
|
|
63
|
+
# config/routes.rb — mount behind your own auth; Roundhouse ships none
|
|
64
|
+
authenticate :user, ->(u) { u.admin? } do
|
|
65
|
+
mount RoundhouseUi::Engine => "/roundhouse"
|
|
66
|
+
end
|
|
67
|
+
|
|
68
|
+
# config/initializers/roundhouse.rb — only if you're on Solid Queue
|
|
69
|
+
RoundhouseUi.backend = RoundhouseUi::Backends::SolidQueue.new
|
|
70
|
+
```
|
|
71
|
+
|
|
72
|
+
## What you get
|
|
19
73
|
|
|
20
|
-
|
|
74
|
+
- **Grouped errors** — failures fingerprinted by class + error, so one bad deploy is one row with a count, not thousands.
|
|
75
|
+
- **One filter bar** — `class=BillingWorker error=Timeout::Error stripe` in a single box. Facets match exactly, `%` wildcards, free text searches class, JID, error and redacted arguments. The whole filter is one `?q=` parameter, so a filtered view is a URL you can bookmark and share.
|
|
76
|
+
- **Bulk retry or delete scoped to a filter** — every job matching your search, not just the page you can see.
|
|
77
|
+
- **Enforced pause** — a paused queue actually stops being worked, on OSS Sidekiq too.
|
|
78
|
+
- **Snapshot → restore** — back a queue up before you purge it, and put it back if you were wrong.
|
|
79
|
+
- **Audit log** — every state-changing action, with who did it.
|
|
21
80
|
|
|
22
|
-
|
|
23
|
-
|
|
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.
|
|
29
|
-
- **Audit log** — every state-changing action recorded and attributable.
|
|
30
|
-
- **⌘K command palette**, light/dark themes, compact/full-width toggle, read-only mode, and a strict self-contained CSP.
|
|
81
|
+
The same UI drives **Sidekiq** or **Solid Queue** — see [Backends](#backends);
|
|
82
|
+
running both at once is [#17](https://github.com/rjrobinson/roundhouse_ui/issues/17).
|
|
31
83
|
|
|
32
|
-
|
|
84
|
+
Roundhouse ships no authentication, so mount it behind yours. `read_only`
|
|
85
|
+
disables every mutating action, `redact_args` masks sensitive arguments, and
|
|
86
|
+
`job_class_namespaces` bounds which constants a job payload can make Roundhouse resolve —
|
|
87
|
+
see [Security](#security).
|
|
33
88
|
|
|
34
|
-
|
|
89
|
+
> Gem name is `roundhouse_ui`; the brand and mount path are **Roundhouse**.
|
|
90
|
+
|
|
91
|
+
## Contents
|
|
92
|
+
|
|
93
|
+
**Setting up** ·
|
|
94
|
+
[Requirements](#requirements) ·
|
|
95
|
+
[Installation](#installation) ·
|
|
96
|
+
[Backends](#backends) ·
|
|
97
|
+
[Mounting](#mounting) ·
|
|
98
|
+
[Configuration](#configuration) ·
|
|
99
|
+
[Security](#security)
|
|
100
|
+
|
|
101
|
+
**Operating** ·
|
|
102
|
+
[Pausing queues](#pausing-queues) ·
|
|
103
|
+
[Snapshots](#snapshots) ·
|
|
104
|
+
[Cancelling jobs](#cancelling-jobs) ·
|
|
105
|
+
[Search](#search) ·
|
|
106
|
+
[Bulk actions on a filter](#bulk-actions-on-a-filter) ·
|
|
107
|
+
[Slowest job classes](#slowest-job-classes)
|
|
108
|
+
|
|
109
|
+
**Labelling and links** ·
|
|
110
|
+
[Job tags](#job-tags) ·
|
|
111
|
+
[Runbooks](#runbooks) ·
|
|
112
|
+
[Observability deep-links](#observability-deep-links) ·
|
|
113
|
+
[Surfacing sidekiq-failures](#surfacing-sidekiq-failures)
|
|
114
|
+
|
|
115
|
+
**Appearance** ·
|
|
116
|
+
[Theming](#theming) ·
|
|
117
|
+
[Settings](#settings) ·
|
|
118
|
+
[Keyboard](#keyboard)
|
|
119
|
+
|
|
120
|
+
**Project** ·
|
|
121
|
+
[Stability](#stability) ·
|
|
122
|
+
[Development](#development) ·
|
|
123
|
+
[Roadmap](#roadmap) ·
|
|
124
|
+
[Contributing](#contributing) ·
|
|
125
|
+
[License](#license)
|
|
35
126
|
|
|
36
127
|
## Requirements
|
|
37
128
|
|
|
@@ -55,11 +146,22 @@ initializer:
|
|
|
55
146
|
RoundhouseUi.backend = RoundhouseUi::Backends::SolidQueue.new
|
|
56
147
|
```
|
|
57
148
|
|
|
58
|
-
The UI adapts to each backend's capabilities
|
|
59
|
-
native** (no fetcher, no warning), and
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
149
|
+
The UI adapts to each backend's capabilities. On Solid Queue, queue **pause is
|
|
150
|
+
native** (no fetcher, no warning), and these hide — and refuse at the route, not just
|
|
151
|
+
in the view:
|
|
152
|
+
|
|
153
|
+
| Hidden on Solid Queue | Why |
|
|
154
|
+
|---|---|
|
|
155
|
+
| Retries | no distinct retry set |
|
|
156
|
+
| Redis pressure | not Redis-backed |
|
|
157
|
+
| Capsules, Workers | processes are a follow-up |
|
|
158
|
+
| Snapshots | reads Sidekiq's queues through Redis |
|
|
159
|
+
| Audit log | needs Redis |
|
|
160
|
+
| Enqueue and Edit | no `push` |
|
|
161
|
+
| Scheduled → "Enqueue now" | no `add_to_queue` |
|
|
162
|
+
|
|
163
|
+
Dashboard, Queues, Scheduled, Dead, Busy and the grouped Errors view all work on both,
|
|
164
|
+
as do pause, resume, purge, delete, bulk-on-a-filter and Retry. See
|
|
63
165
|
[docs/adr/0001](docs/adr/0001-backend-port-multi-queue.md).
|
|
64
166
|
|
|
65
167
|
> Running **both** Sidekiq and Solid Queue in one app (e.g. mid-migration)? That's
|
|
@@ -98,7 +200,7 @@ RoundhouseUi.configure do |c|
|
|
|
98
200
|
c.actor_resolver = ->(controller) { controller.current_user&.email }
|
|
99
201
|
|
|
100
202
|
# Deep-link jobs out to your APM (see Observability).
|
|
101
|
-
c.observability = RoundhouseUi::Observability::DatadogAdapter.new(service: "
|
|
203
|
+
c.observability = RoundhouseUi::Observability::DatadogAdapter.new(service: "sidekiq")
|
|
102
204
|
|
|
103
205
|
# Where queue snapshots are stored (default: Redis). Swap for a file/S3 store.
|
|
104
206
|
# c.snapshot_store = MyS3SnapshotStore.new
|
|
@@ -112,6 +214,21 @@ RoundhouseUi.configure do |c|
|
|
|
112
214
|
# installing RoundhouseUi::Fetch enforces it. Default: true.
|
|
113
215
|
# c.pause_enabled = false
|
|
114
216
|
|
|
217
|
+
# Show the Busy page's Cancel button. Off by default because cancellation only
|
|
218
|
+
# does something once you install CancelMiddleware, or have long jobs poll
|
|
219
|
+
# RoundhouseUi.cancelled?(jid) themselves. See "Cancelling jobs".
|
|
220
|
+
# c.cancel_enabled = true
|
|
221
|
+
|
|
222
|
+
# Surface your own labels (owning team, tenant, …) on job rows, the job page,
|
|
223
|
+
# and grouped errors — and filter by them. See "Job tags" below.
|
|
224
|
+
# c.job_tags = RoundhouseUi::Tags.from_constant(:OWNER, as: :squad)
|
|
225
|
+
# c.job_runbooks = RoundhouseUi::Runbooks.from_constant(:RUNBOOK)
|
|
226
|
+
|
|
227
|
+
# Recolour the UI — pure CSS custom properties, no build step. See "Theming".
|
|
228
|
+
# c.theme = { accent: "#FF2BD1", accent_2: "#00E5FF" }
|
|
229
|
+
# c.themes = RoundhouseUi::Theme::PRESETS.slice(:catppuccin, :nord, :gruvbox)
|
|
230
|
+
# c.allow_theme_selection = false
|
|
231
|
+
|
|
115
232
|
# Seconds between dashboard stat polls (default 5). Raise it if polling shows
|
|
116
233
|
# up in your traces — each poll re-runs the host's auth/routing on the mount.
|
|
117
234
|
# c.poll_interval = 10
|
|
@@ -134,15 +251,27 @@ here is required to mount Roundhouse.
|
|
|
134
251
|
| `redact_args` | `[]` | **Any app whose job args carry secrets or PII** — args render in full on the job page. Matches keys case-insensitively as substrings, and walks nested hashes/arrays. | Args are all IDs and enum values. |
|
|
135
252
|
| `actor_resolver` | `"anonymous"` | You want the audit log to name *who* did something. One line: `->(c) { c.current_user&.email }`. | Single-operator app, or you already audit at another layer. |
|
|
136
253
|
| `allow_job_editing` | `false` | Development and debugging. **Sharp tool** — a bad edit creates an unrunnable job, and it lets the UI enqueue arbitrary classes. | Production, unless you specifically want that power and have `read_only` off anyway. |
|
|
137
|
-
| `observability` | no-op | You run an APM and want per-job deep links out to it. Ships a Datadog adapter; duck-type `job_url
|
|
254
|
+
| `observability` | no-op | You run an APM and want per-job deep links out to it. Ships a Datadog adapter; duck-type `job_url` and `label` for anything else — `error_url`, `icon` and `wordmark?` are optional. | No APM, or you'd rather not add links that only some people can open. |
|
|
138
255
|
| `snapshot_store` | Redis | Your snapshots are large or need to outlive Redis (S3/disk). Duck-type `write`/`read`/`delete`/`ids`. | Redis is fine — which it usually is for occasional queue snapshots. |
|
|
139
256
|
| `show_sidekiq_failures` | `false` | You use the `sidekiq-failures` gem **and** run jobs with `retry: false` — those never enter Sidekiq's retry/dead sets, so this is the only way to see them. | You don't have the gem (it's a no-op then anyway). |
|
|
140
257
|
| `poll_interval` | `5` | **Raise it** if dashboard polling shows up in your traces — every poll re-runs your app's auth and routing on the mount, so a busy console adds real load. Lower it only for a livelier demo. | Default is fine for most apps. |
|
|
141
258
|
| `collect_durations` | `false` | You want "slowest job classes" on Metrics, which Sidekiq doesn't track. **Also requires installing the `DurationCollector` middleware** — the flag alone shows nothing. Costs one pipelined Redis round-trip per job. | You already get per-job timing from your APM. |
|
|
259
|
+
| `backend` | Sidekiq | You run Solid Queue, or you want to point the UI at your own adapter. `RoundhouseUi::Backends::SolidQueue.new`, or duck-type the port. See [Backends](#backends). | You run Sidekiq — it is the default. |
|
|
260
|
+
| `job_tags` | `nil` | You already know which team, tenant or product area owns a job — usually as a constant on the class — and want that visible and filterable in the UI. See [Job tags](#job-tags). | Every job belongs to the same team. |
|
|
261
|
+
| `job_tags_per_job` | `false` | **Only** when `job_tags` reads the payload (tagging by tenant, account, …). Costs one resolver call per row rather than one per class. | Tags derive from the job class, which is the common case. |
|
|
262
|
+
| `tag_filters` | `nil` | You want stable filter dropdowns instead of ones that only list what happens to be on screen — and want filtering on an unknown key to match nothing. | The `?tag=` URL is enough. |
|
|
263
|
+
| `job_runbooks` | `nil` | Your jobs have runbooks and you'd rather not make someone find them at 3am. | There's nothing to link to yet. |
|
|
264
|
+
| `job_class_namespaces` | `nil` | You want to bound which constants a job payload can cause Roundhouse to resolve. See [Security](#security). | Your job payloads come only from your own app, which is the normal case. |
|
|
265
|
+
| `history` | — | Not a setting. Sidekiq records daily processed and failed counts itself, so the Dashboard shows a History chart with no configuration and no storage. Hidden on Solid Queue, which has no equivalent. | |
|
|
266
|
+
| `theme` | `nil` | You want Roundhouse to match your own admin's palette, or you just want it to look different. Partial themes are fine — unset tokens keep their shipped values. See [Theming](#theming). | The shipped light/dark pair is fine. |
|
|
267
|
+
| `icons` | `:svg` | You already ship FontAwesome and would rather Roundhouse used it — `:font_awesome`, or a Hash of `{ name => "class names" }`. Roundhouse never loads a font itself either way. | You want the shipped inline SVG, which needs nothing installed. |
|
|
268
|
+
| `themes` | shipped presets | You want people to pick their own palette on the Settings page. | Everyone should see the same thing — set `theme` instead, or `allow_theme_selection = false`. |
|
|
269
|
+
| `allow_theme_selection` | `true` | Leave it on. | Recolouring a production console isn't something you want an operator doing. |
|
|
142
270
|
| `pause_enabled` | `true` | Leave it on. | **Rarely set this to `false`.** Pause is enforced natively on Sidekiq Pro and Solid Queue, and on OSS Sidekiq by installing `RoundhouseUi::Fetch` — so turning it off usually just hides a working feature. Only useful if you want the controls gone entirely. |
|
|
271
|
+
| `cancel_enabled` | `false` | You installed `CancelMiddleware`, or your long-running jobs poll `RoundhouseUi.cancelled?(jid)`. **The flag alone cancels nothing** — without one of those, `cancel!` writes a JID nothing reads, which is why the button is hidden by default. Sidekiq only; Solid Queue has no cancellation path. | You haven't wired either check up yet. |
|
|
143
272
|
|
|
144
273
|
Two that pair with a middleware rather than working alone: `collect_durations`
|
|
145
|
-
(`DurationCollector`) and
|
|
274
|
+
(`DurationCollector`) and `cancel_enabled` (`CancelMiddleware`) — see
|
|
146
275
|
[Cancelling jobs](#cancelling-jobs) and [Slowest job classes](#slowest-job-classes).
|
|
147
276
|
|
|
148
277
|
## Pausing queues
|
|
@@ -168,10 +297,8 @@ Roundhouse detects whether a fetcher has reported in).
|
|
|
168
297
|
|
|
169
298
|
### Sidekiq Pro / Enterprise — nothing to install
|
|
170
299
|
|
|
171
|
-
Pro ships its own enforced pause, and Roundhouse uses it automatically
|
|
172
|
-
|
|
173
|
-
`Sidekiq::BasicFetch` (`super_fetch` honors it too), so **any Pro worker enforces
|
|
174
|
-
pauses** whether or not a fetch strategy is configured.
|
|
300
|
+
Pro ships its own enforced pause, and Roundhouse uses it automatically — **any Pro
|
|
301
|
+
worker enforces pauses** whether or not a fetch strategy is configured.
|
|
175
302
|
|
|
176
303
|
When Roundhouse detects Pro it delegates pause/resume to `Sidekiq::Queue#pause!`,
|
|
177
304
|
reads paused state from Pro's registry, advertises `native_pause`, and drops the
|
|
@@ -179,13 +306,224 @@ reads paused state from Pro's registry, advertises `native_pause`, and drops the
|
|
|
179
306
|
|
|
180
307
|
- **Don't** install `RoundhouseUi::Fetch` — it isn't needed, and on `super_fetch`
|
|
181
308
|
installs it would displace reliable fetch and lose its crash-recovery guarantees.
|
|
182
|
-
- **Don't** set `pause_enabled = false` — pause
|
|
183
|
-
|
|
309
|
+
- **Don't** set `pause_enabled = false` — pause works; disabling it only hides a
|
|
310
|
+
feature you already have.
|
|
184
311
|
|
|
185
312
|
Roundhouse always goes through `Sidekiq::Queue#pause!` rather than writing Pro's
|
|
186
|
-
Redis key directly
|
|
187
|
-
|
|
188
|
-
|
|
313
|
+
Redis key directly — a raw write does not reach already-running workers.
|
|
314
|
+
|
|
315
|
+
Pro's own behaviour here is described from its public API, and Roundhouse has no Pro
|
|
316
|
+
dependency and runs no Pro in CI. Treat it as our integration contract, not as Pro
|
|
317
|
+
documentation; [Sidekiq's own docs](https://github.com/sidekiq/sidekiq/wiki) are
|
|
318
|
+
authoritative.
|
|
319
|
+
|
|
320
|
+
## Icons and motion
|
|
321
|
+
|
|
322
|
+
Icons are inline SVG — no font, no request, no CSP change, and the same shape on
|
|
323
|
+
every platform. If you already ship an icon font, use it instead:
|
|
324
|
+
|
|
325
|
+
```ruby
|
|
326
|
+
RoundhouseUi.icons = :font_awesome
|
|
327
|
+
RoundhouseUi.icons = { dashboard: "fa-solid fa-gauge-high", queues: "my-icon" }
|
|
328
|
+
```
|
|
329
|
+
|
|
330
|
+
Roundhouse never loads a font itself in either mode — it emits class names and
|
|
331
|
+
your pipeline supplies the glyphs, which is what keeps the self-contained CSP
|
|
332
|
+
intact. An unknown name renders nothing rather than raising.
|
|
333
|
+
|
|
334
|
+
Motion is limited to effects that carry information: a polled value flashes when
|
|
335
|
+
it actually changes, a queue that will not drain pulses slowly, rows settle in on
|
|
336
|
+
navigation, and the refresh arc depletes. All of it is dropped under
|
|
337
|
+
`prefers-reduced-motion`.
|
|
338
|
+
|
|
339
|
+
## Theming
|
|
340
|
+
|
|
341
|
+
The UI's colours are CSS custom properties. Override any of them from an
|
|
342
|
+
initializer — pure CSS, no build step, no stylesheet to fork:
|
|
343
|
+
|
|
344
|
+
```ruby
|
|
345
|
+
RoundhouseUi.theme = { accent: "#FF2BD1", accent_2: "#00E5FF" }
|
|
346
|
+
```
|
|
347
|
+
|
|
348
|
+
A colour that reads well on near-black rarely reads well on near-white, so you
|
|
349
|
+
can speak to each mode separately:
|
|
350
|
+
|
|
351
|
+
```ruby
|
|
352
|
+
RoundhouseUi.theme = {
|
|
353
|
+
dark: { bg: "#0A0511", panel: "#140A24", accent: "#FF2BD1" },
|
|
354
|
+
light: { bg: "#FFF7FB", panel: "#FFFFFF", accent: "#B3009E" }
|
|
355
|
+
}
|
|
356
|
+
```
|
|
357
|
+
|
|
358
|
+
Anything you leave unset keeps its shipped value, so partial themes are fine.
|
|
359
|
+
Keys are token names with underscores for dashes — `accent_2` sets `--accent-2`.
|
|
360
|
+
|
|
361
|
+
Available tokens: `bg`, `panel`, `panel_2`, `panel_3`, `line`, `line_soft`,
|
|
362
|
+
`text`, `muted`, `faint`, `accent`, `accent_2`, `good`, `warn`, `crit`, `mono`,
|
|
363
|
+
`sans`.
|
|
364
|
+
|
|
365
|
+
### What's in the box
|
|
366
|
+
|
|
367
|
+
Eleven presets. Ten of them ship the light **and** dark variant their own
|
|
368
|
+
authors designed, so choosing a palette is never a choice to give up light mode:
|
|
369
|
+
|
|
370
|
+
| Preset | Dark | Light |
|
|
371
|
+
|---|---|---|
|
|
372
|
+
| `catppuccin` | [Catppuccin](https://github.com/catppuccin/catppuccin) Mocha | Latte |
|
|
373
|
+
| `catppuccin_macchiato` | Catppuccin Macchiato | Latte |
|
|
374
|
+
| `catppuccin_frappe` | Catppuccin Frappé | Latte |
|
|
375
|
+
| `rose_pine` | [Rosé Pine](https://github.com/rose-pine/rose-pine-theme) Main | Dawn |
|
|
376
|
+
| `rose_pine_moon` | Rosé Pine Moon | Dawn |
|
|
377
|
+
| `nord` | [Nord](https://github.com/nordtheme/nord) Polar Night | Snow Storm |
|
|
378
|
+
| `gruvbox` | [Gruvbox](https://github.com/morhetz/gruvbox) Dark | Light |
|
|
379
|
+
| `everforest` | [Everforest](https://github.com/sainnhe/everforest) Dark | Light |
|
|
380
|
+
| `kanagawa` | [Kanagawa](https://github.com/rebelot/kanagawa.nvim) Wave | Lotus |
|
|
381
|
+
| `solarized` | [Solarized](https://github.com/altercation/solarized) Dark | Light |
|
|
382
|
+
|
|
383
|
+
The eleventh is `cyberpunk` — loud, and dark-only, which Settings labels, since
|
|
384
|
+
a dark-only palette is inert in light mode.
|
|
385
|
+
|
|
386
|
+
Catppuccin and Rosé Pine each ship one light flavour and several dark ones, so
|
|
387
|
+
their entries share a light half. That's upstream's own design rather than a
|
|
388
|
+
shortcut here, which is why the preset name says which dark flavour you get.
|
|
389
|
+
|
|
390
|
+
```ruby
|
|
391
|
+
RoundhouseUi.theme = RoundhouseUi::Theme::PRESETS[:kanagawa]
|
|
392
|
+
```
|
|
393
|
+
|
|
394
|
+
> All 280 values come from each project's own palette file — `palette.json`,
|
|
395
|
+
> `gruvbox.vim`, `nord.css`, `colors.lua` — rather than transcribed by eye. The
|
|
396
|
+
> mapping onto our tokens is what can be wrong while every colour is right:
|
|
397
|
+
> `panel` must lift off `bg`, `panel_2` must carry `muted` text, and `line` must
|
|
398
|
+
> be soft — the shipped theme draws borders at 1.20:1 against their own panel.
|
|
399
|
+
> One surface step too far doesn't read as a colour bug, it reads as a broken
|
|
400
|
+
> theme: it put Nord's light border at 6.4:1 and Rosé Pine's dark at 3.2:1, a
|
|
401
|
+
> hard outline around every button and input. Tests hold every palette to
|
|
402
|
+
> contrast floors and to those structural rules, so a new one can't regress it.
|
|
403
|
+
|
|
404
|
+
### Letting people pick
|
|
405
|
+
|
|
406
|
+
`theme` is what everyone sees. If you'd rather offer a menu, name the palettes
|
|
407
|
+
and each person picks one on the Settings page:
|
|
408
|
+
|
|
409
|
+
```ruby
|
|
410
|
+
RoundhouseUi.themes = {
|
|
411
|
+
cyberpunk: RoundhouseUi::Theme::PRESETS[:cyberpunk],
|
|
412
|
+
midnight: { dark: { bg: "#000000", panel: "#0A0A0A" } }
|
|
413
|
+
}
|
|
414
|
+
```
|
|
415
|
+
|
|
416
|
+
All eleven shipped presets are on offer by default — trim the list if that's
|
|
417
|
+
more choice than you want in a production console. A palette beats `theme`, and
|
|
418
|
+
"Default" on that page means whatever `theme` you configured, so a host palette
|
|
419
|
+
is the floor rather than something a viewer can be stranded away from.
|
|
420
|
+
|
|
421
|
+
Every offered palette is emitted as CSS on every page: all eleven cost about
|
|
422
|
+
1.5 KB gzipped.
|
|
423
|
+
|
|
424
|
+
Withdraw the control entirely where recolouring a production console isn't
|
|
425
|
+
something an operator should be doing:
|
|
426
|
+
|
|
427
|
+
```ruby
|
|
428
|
+
RoundhouseUi.allow_theme_selection = false
|
|
429
|
+
```
|
|
430
|
+
|
|
431
|
+
The browser stores which palette by name, never the colours, so a tampered
|
|
432
|
+
`localStorage` value can only select a palette you already configured.
|
|
433
|
+
|
|
434
|
+
## Settings
|
|
435
|
+
|
|
436
|
+
`/settings` holds the per-person preferences: light or dark, palette, content
|
|
437
|
+
width, and how often pages refresh. Everything there lives in that browser's
|
|
438
|
+
local storage — nothing is written server-side, so one person's choices never
|
|
439
|
+
change what anyone else sees, and there's no state to migrate or clean up. A
|
|
440
|
+
private window starts fresh.
|
|
441
|
+
|
|
442
|
+
Every refresh tick runs your app's own authentication and routing, so a faster
|
|
443
|
+
interval isn't free. Whatever you set for
|
|
444
|
+
`poll_interval` is the default and is named on the page; a viewer can go faster
|
|
445
|
+
or slower within 2–300 seconds.
|
|
446
|
+
|
|
447
|
+
## Runbooks
|
|
448
|
+
|
|
449
|
+
Whoever wrote the job knows what to do when it fails. The person paged at 3am
|
|
450
|
+
usually does not. Point Roundhouse at whatever you already have:
|
|
451
|
+
|
|
452
|
+
```ruby
|
|
453
|
+
# a constant on the class, same convention as job tags
|
|
454
|
+
RoundhouseUi.job_runbooks = RoundhouseUi::Runbooks.from_constant(:RUNBOOK)
|
|
455
|
+
|
|
456
|
+
# or a plain map
|
|
457
|
+
RoundhouseUi.job_runbooks = { "Billing::SyncWorker" => "https://wiki/billing" }
|
|
458
|
+
|
|
459
|
+
# or any callable
|
|
460
|
+
RoundhouseUi.job_runbooks = ->(klass:, item:) { "https://wiki/jobs/#{klass}" }
|
|
461
|
+
```
|
|
462
|
+
|
|
463
|
+
A **Runbook** link appears on the job page and on each grouped error row — the
|
|
464
|
+
two places someone lands during an incident. Resolution happens at read time
|
|
465
|
+
like tags, so it covers jobs already in the sets, with no middleware and nothing
|
|
466
|
+
stored. Inherited constants count, so one base class carries a runbook for a
|
|
467
|
+
whole family, and ActiveJob-wrapped jobs resolve by their real class.
|
|
468
|
+
|
|
469
|
+
> Only `http`/`https` URLs render. The value lands in an `href`, where no
|
|
470
|
+
> escaping makes `javascript:` safe, so the scheme is checked instead — a
|
|
471
|
+
> misconfigured host gets no link rather than a link that runs. Links open in a
|
|
472
|
+
> new tab with `rel="noopener noreferrer"`.
|
|
473
|
+
|
|
474
|
+
## Job tags
|
|
475
|
+
|
|
476
|
+
Most apps already know who owns a job — commonly a constant on the class. Point
|
|
477
|
+
Roundhouse at it and that label shows up as a badge on Retries, Dead, Scheduled, the
|
|
478
|
+
job detail page and grouped Errors, and becomes a filter.
|
|
479
|
+
|
|
480
|
+
```ruby
|
|
481
|
+
# config/initializers/roundhouse.rb
|
|
482
|
+
RoundhouseUi.job_tags = RoundhouseUi::Tags.from_constant(:OWNER, as: :squad)
|
|
483
|
+
```
|
|
484
|
+
|
|
485
|
+
That's the whole setup for the `OWNER = :growth` convention — every class defining the
|
|
486
|
+
constant (including by inheritance) is tagged. Any callable works if your labels come
|
|
487
|
+
from somewhere else:
|
|
488
|
+
|
|
489
|
+
```ruby
|
|
490
|
+
RoundhouseUi.job_tags = ->(klass:, item:) {
|
|
491
|
+
{ squad: OwnershipMap.for(klass), tier: klass.end_with?("CriticalJob") ? "p1" : "p3" }
|
|
492
|
+
}
|
|
493
|
+
```
|
|
494
|
+
|
|
495
|
+
Tags are resolved **when a page renders** — no middleware, no enqueue changes, nothing
|
|
496
|
+
stored. They apply retroactively to jobs already sitting in the sets, and work the same
|
|
497
|
+
on Sidekiq and Solid Queue. `klass` is always the real job class: the ActiveJob adapter's
|
|
498
|
+
wrapper is unwrapped before your resolver sees it. See
|
|
499
|
+
[ADR 0002](docs/adr/0002-job-tagging.md).
|
|
500
|
+
|
|
501
|
+
### Filtering
|
|
502
|
+
|
|
503
|
+
Type `tag=squad:growth` into the search box on Retries, Dead, Scheduled, a queue's job
|
|
504
|
+
list, or Errors. It combines with every other filter, survives pagination, and **applies
|
|
505
|
+
to bulk actions too**, so "delete all matching" acts on exactly the rows shown and never
|
|
506
|
+
more. `?tag=squad:growth` still works as a URL — see [Search](#search).
|
|
507
|
+
|
|
508
|
+
Declare a vocabulary to get stable dropdowns instead of relying on the URL:
|
|
509
|
+
|
|
510
|
+
```ruby
|
|
511
|
+
RoundhouseUi.tag_filters = { squad: %w[core training growth platform ops ai] }
|
|
512
|
+
```
|
|
513
|
+
|
|
514
|
+
Values may be a callable if the list is dynamic. Once declared, filtering on a key you
|
|
515
|
+
didn't declare matches nothing rather than everything.
|
|
516
|
+
|
|
517
|
+
### Cost and safety
|
|
518
|
+
|
|
519
|
+
- By default the resolver is treated as a **pure function of the job class** and is called
|
|
520
|
+
once per class per request — a 1,000-row page costs a handful of calls, not 1,000. If
|
|
521
|
+
your resolver reads the payload, set `RoundhouseUi.job_tags_per_job = true`; it will
|
|
522
|
+
then be called once per row, so keep it cheap.
|
|
523
|
+
- Tag values pass through `redact_args`, so a tag keyed `tenant_token` masks itself. This
|
|
524
|
+
is key-based only — a tag *named* `squad` whose *value* is sensitive is not masked.
|
|
525
|
+
- A resolver that raises is caught and logged; the page renders without tags rather than
|
|
526
|
+
failing.
|
|
189
527
|
|
|
190
528
|
## Surfacing sidekiq-failures
|
|
191
529
|
|
|
@@ -205,8 +543,10 @@ job class + error (not yet as an individual-job list with per-row actions).
|
|
|
205
543
|
|
|
206
544
|
## Cancelling jobs
|
|
207
545
|
|
|
208
|
-
Cancellation is cooperative — Ruby can't safely kill a running thread
|
|
209
|
-
|
|
546
|
+
Cancellation is cooperative — Ruby can't safely kill a running thread, so something
|
|
547
|
+
has to check. Nothing checks by default, which is why the Cancel button is hidden until
|
|
548
|
+
you set `cancel_enabled = true`. Install the middleware so a cancelled job is dropped
|
|
549
|
+
before it runs:
|
|
210
550
|
|
|
211
551
|
```ruby
|
|
212
552
|
# config/initializers/sidekiq.rb
|
|
@@ -215,6 +555,8 @@ Sidekiq.configure_server do |config|
|
|
|
215
555
|
end
|
|
216
556
|
```
|
|
217
557
|
|
|
558
|
+
…then turn the button on with `c.cancel_enabled = true`.
|
|
559
|
+
|
|
218
560
|
The **Busy** page's Cancel button flags a job's JID. A queued/scheduled/retrying job
|
|
219
561
|
is then skipped when it would next run; a *currently running* job stops only if it
|
|
220
562
|
checks in — e.g. a long loop can `break if RoundhouseUi.cancelled?(jid)`.
|
|
@@ -244,6 +586,46 @@ end
|
|
|
244
586
|
It's two cheap Redis writes per job (a counter + a summed-ms float) into a single hash,
|
|
245
587
|
pipelined into **one round-trip**, and a job failure never propagates from the collector.
|
|
246
588
|
|
|
589
|
+
## Search
|
|
590
|
+
|
|
591
|
+
One box per page, and everything in it travels as a single `?q=` parameter:
|
|
592
|
+
|
|
593
|
+
```
|
|
594
|
+
/roundhouse/dead?q=class%3DBillingWorker+error%3DTimeout%3A%3AError+stripe
|
|
595
|
+
```
|
|
596
|
+
|
|
597
|
+
| | |
|
|
598
|
+
|---|---|
|
|
599
|
+
| `class=` | exact job class (the real class, not the ActiveJob wrapper) |
|
|
600
|
+
| `error=` | exact error class |
|
|
601
|
+
| `queue=` | exact queue name |
|
|
602
|
+
| `tag=` | a declared tag, as `key:value` |
|
|
603
|
+
| `%` | wildcard — `class=Roundhouse%`, `class=%Worker`, `class=%oundhouse%` |
|
|
604
|
+
| anything else | substring across class, JID, error message and **redacted** arguments |
|
|
605
|
+
|
|
606
|
+
Facets match exactly unless you use `%`, so `queue=default` never also selects
|
|
607
|
+
`default_low`. `_` is a literal, not a wildcard. Quote values with spaces
|
|
608
|
+
(`error="Net::ReadTimeout with body"`), and use `text="account_id=1234"` for free text
|
|
609
|
+
that looks like a filter.
|
|
610
|
+
|
|
611
|
+
Anything the parser does not understand is **refused whole**, with the offending token
|
|
612
|
+
named — never dropped and never silently widened, because this box sits directly above
|
|
613
|
+
"delete all matching". `class=%` is refused for the same reason: a pattern with no
|
|
614
|
+
literal characters matches everything.
|
|
615
|
+
|
|
616
|
+
Each active facet shows as a pill in the bar with its own ×. Tab completes a key or
|
|
617
|
+
value; Enter applies. The `?` beside the box lists the vocabulary for that page — Errors
|
|
618
|
+
has no `queue=` (a class+error group spans every queue), and the Queues index honours
|
|
619
|
+
only `queue=` and free text, because a queue is not a job.
|
|
620
|
+
|
|
621
|
+
One documented exception to "refused whole": a `tag=` that is not `key:value` is
|
|
622
|
+
**dropped** rather than refused, so a typo doesn't stop you browsing. The drop is named
|
|
623
|
+
in a banner above the table and the bulk controls are withdrawn until you fix it —
|
|
624
|
+
what survived the drop selects a superset of what you asked for.
|
|
625
|
+
|
|
626
|
+
Arguments are searched **as they are displayed**, i.e. redacted. Searching the raw values
|
|
627
|
+
would turn the box into an oracle for the secrets `redact_args` exists to hide.
|
|
628
|
+
|
|
247
629
|
## Bulk actions on a filter
|
|
248
630
|
|
|
249
631
|
On **Retries** and **Dead**, searching narrows the set; with a filter active you
|
|
@@ -251,33 +633,183 @@ can retry or delete **every** matching job in one action (not just the visible
|
|
|
251
633
|
page), capped at 1,000 per run. Gated to when a filter is present so it can't
|
|
252
634
|
become "retry everything", `read_only`-aware, and audit-logged.
|
|
253
635
|
|
|
636
|
+
Both go through a **dry run** first: the matched jobs are listed, with their
|
|
637
|
+
arguments and errors, and nothing is touched until you confirm. The count in the
|
|
638
|
+
toolbar tells you how many jobs match; only the dry run tells you which.
|
|
639
|
+
|
|
640
|
+
### Find more like this
|
|
641
|
+
|
|
642
|
+
Every row on **Retries**, **Dead** and **Scheduled** carries a 🔍 that narrows the
|
|
643
|
+
set to that job's class and, where the set records one, that job's error — the
|
|
644
|
+
same pair the Errors page treats as a single issue. One click turns "this one row
|
|
645
|
+
looks wrong" into "here are all 7,546 of them, and here are the bulk controls".
|
|
646
|
+
|
|
647
|
+
It sets `class=` and `error=` facets in the bar, and they match **exactly** — no
|
|
648
|
+
`%`. That matters because the button's whole purpose is to reveal
|
|
649
|
+
`Delete all matching`: a substring would also select jobs whose *arguments* merely
|
|
650
|
+
mention the class you clicked, and you would never see the difference. Add a `%`
|
|
651
|
+
yourself if you want the family rather than the class — see [Search](#search).
|
|
652
|
+
|
|
254
653
|
## Observability deep-links
|
|
255
654
|
|
|
256
655
|
The core depends on nothing — it asks the configured adapter for a URL and renders a link
|
|
257
656
|
only if one comes back. A Datadog adapter ships in the box; write your own by duck-typing
|
|
258
|
-
`job_url`
|
|
657
|
+
`job_url` and `label` (`error_url`, `icon` and `wordmark?` are optional):
|
|
259
658
|
|
|
260
659
|
```ruby
|
|
261
|
-
RoundhouseUi.observability = RoundhouseUi::Observability::DatadogAdapter.new(site: "datadoghq.com", service: "
|
|
660
|
+
RoundhouseUi.observability = RoundhouseUi::Observability::DatadogAdapter.new(site: "datadoghq.com", service: "sidekiq")
|
|
262
661
|
```
|
|
263
662
|
|
|
663
|
+
`service:` is the service your **Sidekiq spans** carry, which is frequently *not* your
|
|
664
|
+
app name — apps commonly set `c.tracing.instrument :sidekiq, service_name: "sidekiq"`,
|
|
665
|
+
and dd-trace has no default of its own. Passing your app name when the spans say
|
|
666
|
+
something else produces links that silently match nothing. Omit it if you're unsure:
|
|
667
|
+
the term is left out of the query entirely when nil.
|
|
668
|
+
|
|
264
669
|
## Snapshots
|
|
265
670
|
|
|
266
|
-
Back up a queue before purging it
|
|
267
|
-
|
|
268
|
-
|
|
671
|
+
Back up a queue before purging it, then restore if you were wrong. Both actions are
|
|
672
|
+
audit-logged.
|
|
673
|
+
|
|
674
|
+
Only a Redis store ships, and it is the default. A snapshot of a stuck queue then lives in
|
|
675
|
+
the Redis you are trying to relieve — and under `allkeys-lru` it is itself evictable, so a
|
|
676
|
+
large backup can disappear. Point `RoundhouseUi.snapshot_store` at your own store to put it
|
|
677
|
+
somewhere else; the contract is four methods:
|
|
678
|
+
|
|
679
|
+
```ruby
|
|
680
|
+
class S3SnapshotStore
|
|
681
|
+
def write(id, blob) = # persist it
|
|
682
|
+
def read(id) = # → the blob, or nil
|
|
683
|
+
def delete(id) = # remove it
|
|
684
|
+
def ids = # → array of snapshot ids
|
|
685
|
+
end
|
|
686
|
+
|
|
687
|
+
RoundhouseUi.snapshot_store = S3SnapshotStore.new
|
|
688
|
+
```
|
|
689
|
+
|
|
690
|
+
Restore is not idempotent — restoring twice enqueues everything twice — and it issues one
|
|
691
|
+
push per job, so a very large snapshot is slow to put back.
|
|
692
|
+
|
|
693
|
+
## Recurring jobs
|
|
694
|
+
|
|
695
|
+
Periodic work, whichever scheduler defines it. Nothing to configure — Roundhouse
|
|
696
|
+
detects what is loaded:
|
|
697
|
+
|
|
698
|
+
| Source | Read via |
|
|
699
|
+
|---|---|
|
|
700
|
+
| [sidekiq-cron](https://github.com/sidekiq-cron/sidekiq-cron) | `Sidekiq::Cron::Job.all` |
|
|
701
|
+
| [sidekiq-scheduler](https://github.com/sidekiq-scheduler/sidekiq-scheduler) | `Sidekiq.schedule` |
|
|
702
|
+
| Sidekiq Enterprise periodic | `Sidekiq::Periodic::LoopSet` |
|
|
703
|
+
| Solid Queue | `SolidQueue::RecurringTask` |
|
|
704
|
+
|
|
705
|
+
More than one can be active at once — an app mid-migration genuinely runs two —
|
|
706
|
+
and all of them show. The nav item hides when none is present.
|
|
707
|
+
|
|
708
|
+
The useful part is not the crontab. It is **"this says hourly and has not run in
|
|
709
|
+
three days"**, which needs the schedule's interval, which needs a cron parser.
|
|
710
|
+
`fugit` ships with both sidekiq-cron and sidekiq-scheduler, so it is there
|
|
711
|
+
wherever this feature is; without it, staleness reads as unknown rather than
|
|
712
|
+
being guessed. A task is flagged overdue only after missing **two** intervals — a
|
|
713
|
+
job due at :00 that runs at :00:07 is not late, and a page that says otherwise
|
|
714
|
+
gets ignored.
|
|
715
|
+
|
|
716
|
+
**Read-only.** Schedules belong in the code that declares them, and a UI that
|
|
717
|
+
silently changes a production schedule is a different risk conversation.
|
|
718
|
+
|
|
719
|
+
## History
|
|
720
|
+
|
|
721
|
+
The Dashboard carries a **History** chart — daily processed counts and the daily
|
|
722
|
+
**failure rate**, over 1 week to 6 months.
|
|
723
|
+
|
|
724
|
+
This needs no configuration and stores nothing. Sidekiq already keeps a counter
|
|
725
|
+
per day; Roundhouse just reads it. The rate is the line worth watching: counts
|
|
726
|
+
move with traffic, so a busy Monday looks worse than a quiet Sunday even when
|
|
727
|
+
nothing changed.
|
|
728
|
+
|
|
729
|
+
A dashed baseline marks the **typical** failure rate — the median across days
|
|
730
|
+
that had traffic, so one incident cannot become the new normal and quiet
|
|
731
|
+
weekends cannot drag it to zero.
|
|
732
|
+
|
|
733
|
+
Sidekiq only. Solid Queue has no equivalent counter, so the section hides rather
|
|
734
|
+
than drawing an empty chart.
|
|
269
735
|
|
|
270
736
|
## Security
|
|
271
737
|
|
|
738
|
+
**Constant resolution from job payloads.** `Tags.from_constant` and
|
|
739
|
+
`Runbooks.from_constant` read a constant off the job class, which means turning
|
|
740
|
+
`item["class"]` — a string out of Redis — into a Class:
|
|
741
|
+
|
|
742
|
+
- Malformed names never reach a lookup. Ruby rejects `"../../etc/passwd"` as a
|
|
743
|
+
constant path before attempting to resolve anything, so no autoload occurs.
|
|
744
|
+
- A **well-formed name of a class that really exists** does resolve, and
|
|
745
|
+
resolving a class loads it. Production Rails sets `eager_load = true`, so every
|
|
746
|
+
app constant is already loaded and no new file is executed.
|
|
747
|
+
- Writing a crafted payload requires Redis write access — which, on a Sidekiq
|
|
748
|
+
install, already permits enqueuing a real job, a more serious compromise than
|
|
749
|
+
this.
|
|
750
|
+
|
|
751
|
+
Two ways to tighten it if your payloads aren't fully trusted:
|
|
752
|
+
|
|
753
|
+
```ruby
|
|
754
|
+
# Bound which constants may be resolved at all
|
|
755
|
+
c.job_class_namespaces = %w[Workers Jobs Billing]
|
|
756
|
+
|
|
757
|
+
# Or resolve nothing: a Hash never constantizes
|
|
758
|
+
c.job_tags = ->(klass:, item:) { { squad: OWNER_MAP[klass] } }
|
|
759
|
+
c.job_runbooks = { "Billing::SyncWorker" => "https://wiki/billing" }
|
|
760
|
+
```
|
|
761
|
+
|
|
762
|
+
|
|
272
763
|
- All destructive actions are CSRF-protected `POST`s — never GET — and gated by `read_only`.
|
|
764
|
+
The engine asks for forgery protection itself rather than relying on your
|
|
765
|
+
`config.load_defaults`, so this holds on an app whose defaults predate Rails 5.2.
|
|
273
766
|
- Roundhouse sets its own strict, self-contained Content-Security-Policy on its responses
|
|
274
767
|
(nonce'd inline script, same-origin only), so it's safe even if the host sets no policy.
|
|
275
|
-
- Configure `redact_args` to keep tokens
|
|
768
|
+
- Configure `redact_args` to keep tokens and PII out of the UI, and set `actor_resolver` so
|
|
769
|
+
the audit log records who did what. Redaction is key-based, so a secret nested under an
|
|
770
|
+
unlisted key is not redacted — audit what your payloads actually carry.
|
|
276
771
|
|
|
277
772
|
## Keyboard
|
|
278
773
|
|
|
279
774
|
`⌘K` (or `Ctrl+K`) opens the command palette — jump to any view or action.
|
|
280
775
|
|
|
776
|
+
## Stability
|
|
777
|
+
|
|
778
|
+
Roundhouse follows [Semantic Versioning](https://semver.org). From 1.0 the surfaces
|
|
779
|
+
below are the ones you can build against; a breaking change to any of them needs a
|
|
780
|
+
major version, and anything scheduled for removal is deprecated for at least one
|
|
781
|
+
minor release first.
|
|
782
|
+
|
|
783
|
+
**Public — covered by semver:**
|
|
784
|
+
|
|
785
|
+
| Surface | Why it's here |
|
|
786
|
+
|---|---|
|
|
787
|
+
| Everything set in `RoundhouseUi.configure` | The whole configuration surface, documented above |
|
|
788
|
+
| `RoundhouseUi.cancelled?(jid)` | Your own jobs call it, so breaking it breaks your code |
|
|
789
|
+
| `RoundhouseUi::Fetch` | Named in your Sidekiq server config |
|
|
790
|
+
| `RoundhouseUi::CancelMiddleware`, `RoundhouseUi::DurationCollector` | Installed into your middleware chain |
|
|
791
|
+
| `Tags.from_constant`, `Runbooks.from_constant` | Documented resolver shorthands |
|
|
792
|
+
| The mounted paths (`/queues`, `/retries`, …) | People bookmark and link to them |
|
|
793
|
+
| Theme token names | You override them by name |
|
|
794
|
+
| The `roundhouse:*` Redis keys | Renaming one silently loses pause state or snapshots on upgrade |
|
|
795
|
+
|
|
796
|
+
**Not public — may change in any release:**
|
|
797
|
+
|
|
798
|
+
- **The backend port.** `RoundhouseUi::Backends::*`, `supports?`, and the shapes a
|
|
799
|
+
"set" and an "entry" must answer to. Writing your own backend is possible today
|
|
800
|
+
and genuinely useful, but the contract is still being worked out against
|
|
801
|
+
[#17](https://github.com/rjrobinson/roundhouse_ui/issues/17) and
|
|
802
|
+
[#41](https://github.com/rjrobinson/roundhouse_ui/issues/41) — pinning it now
|
|
803
|
+
would freeze it before it is right. It will be promoted when those land.
|
|
804
|
+
- Anything under `lib/` not listed above: `Health`, `Metrics`, `ErrorGroups`,
|
|
805
|
+
`History`, `QueueSummary`, and the internals of `Snapshots` and `Audit`.
|
|
806
|
+
- The rendered HTML and its `rh-` class names. Theme tokens are the supported way
|
|
807
|
+
to change how Roundhouse looks; CSS written against our markup will break.
|
|
808
|
+
- The JSON from `/stats`. It exists for our own poller and is shaped for it.
|
|
809
|
+
|
|
810
|
+
If you depend on something in the second list, open an issue — that is how things
|
|
811
|
+
move to the first.
|
|
812
|
+
|
|
281
813
|
## Development
|
|
282
814
|
|
|
283
815
|
```bash
|
|
@@ -285,9 +817,51 @@ bin/rails test # full suite, ~1s, no Redis required (Sidekiq's API is stubb
|
|
|
285
817
|
bundle exec rubocop # lint
|
|
286
818
|
```
|
|
287
819
|
|
|
820
|
+
Most of the suite runs against an in-memory stand-in for Redis, which is why it
|
|
821
|
+
finishes in about a second. The destructive paths — enforced pause, snapshot →
|
|
822
|
+
restore, and bulk-on-a-filter — also have tests that run against a **real** Redis,
|
|
823
|
+
because those features are made of Redis semantics and a fake can only confirm the
|
|
824
|
+
fake. They are opt-in and have no default target, since they `FLUSHDB` whatever they
|
|
825
|
+
are pointed at and your local Redis probably belongs to something else:
|
|
826
|
+
|
|
827
|
+
```bash
|
|
828
|
+
ROUNDHOUSE_TEST_REDIS_URL=redis://localhost:6379/10 bin/rails test
|
|
829
|
+
```
|
|
830
|
+
|
|
831
|
+
Pick an empty database. They refuse to run against database 0, and they verify which
|
|
832
|
+
database the connection is actually on before deleting anything. CI additionally sets
|
|
833
|
+
`ROUNDHOUSE_REQUIRE_REAL_REDIS=1`, which turns a skip into a failure — without it an
|
|
834
|
+
unreachable Redis would skip them silently and the coverage would be imaginary.
|
|
835
|
+
|
|
288
836
|
The dummy app under `test/dummy` mounts the engine at `/roundhouse`; point it at a local
|
|
289
837
|
Redis and run `bin/rails server` to click around.
|
|
290
838
|
|
|
839
|
+
## Seeing it work
|
|
840
|
+
|
|
841
|
+
The gem ships workers that do real work and really fail, so a console has
|
|
842
|
+
something to show. They are not loaded by `require "roundhouse_ui"` — ask for them
|
|
843
|
+
explicitly, in an initializer you would not ship:
|
|
844
|
+
|
|
845
|
+
```ruby
|
|
846
|
+
# config/initializers/roundhouse.rb
|
|
847
|
+
require "roundhouse_ui/demo" if Rails.env.development?
|
|
848
|
+
```
|
|
849
|
+
|
|
850
|
+
```bash
|
|
851
|
+
bin/rails roundhouse_ui:demo:load[15] # enqueue for 15 minutes, hard cap 20
|
|
852
|
+
bin/rails roundhouse_ui:demo:clean # remove everything it left behind
|
|
853
|
+
```
|
|
854
|
+
|
|
855
|
+
Six classes across six queues with different durations and failure rates — one
|
|
856
|
+
long enough to always be mid-flight on **Busy**, one flaky enough to dominate
|
|
857
|
+
**Errors** — so throughput moves, retries accumulate, and jobs reach the dead set
|
|
858
|
+
on their own. The rate rises and falls, so the dashboard's trend and drain
|
|
859
|
+
forecast have something to say.
|
|
860
|
+
|
|
861
|
+
Each worker refuses to run outside development, the task refuses any environment
|
|
862
|
+
but development, and it refuses Redis database 0 — checked by asking the
|
|
863
|
+
connection where it is, not by reading configuration.
|
|
864
|
+
|
|
291
865
|
## Roadmap
|
|
292
866
|
|
|
293
867
|
- Solid Queue: Workers view + enqueue, and the multi-DB (separate queue database) case.
|