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.
Files changed (63) hide show
  1. checksums.yaml +4 -4
  2. data/README.md +620 -46
  3. data/app/controllers/concerns/roundhouse_ui/job_set_browsing.rb +246 -12
  4. data/app/controllers/roundhouse_ui/application_controller.rb +74 -0
  5. data/app/controllers/roundhouse_ui/audit_controller.rb +1 -0
  6. data/app/controllers/roundhouse_ui/busy_controller.rb +25 -5
  7. data/app/controllers/roundhouse_ui/dashboard_controller.rb +27 -3
  8. data/app/controllers/roundhouse_ui/dead_controller.rb +27 -10
  9. data/app/controllers/roundhouse_ui/errors_controller.rb +60 -1
  10. data/app/controllers/roundhouse_ui/jobs_controller.rb +5 -1
  11. data/app/controllers/roundhouse_ui/queues_controller.rb +57 -6
  12. data/app/controllers/roundhouse_ui/recurring_controller.rb +12 -0
  13. data/app/controllers/roundhouse_ui/retries_controller.rb +34 -10
  14. data/app/controllers/roundhouse_ui/scheduled_controller.rb +4 -7
  15. data/app/controllers/roundhouse_ui/settings_controller.rb +15 -0
  16. data/app/controllers/roundhouse_ui/snapshots_controller.rb +5 -6
  17. data/app/controllers/roundhouse_ui/workers_controller.rb +2 -7
  18. data/app/helpers/roundhouse_ui/application_helper.rb +293 -0
  19. data/app/helpers/roundhouse_ui/nav_helper.rb +3 -3
  20. data/app/helpers/roundhouse_ui/observability_helper.rb +121 -12
  21. data/app/helpers/roundhouse_ui/tags_helper.rb +128 -0
  22. data/app/views/layouts/roundhouse_ui/application.html.erb +1277 -84
  23. data/app/views/roundhouse_ui/busy/index.html.erb +30 -6
  24. data/app/views/roundhouse_ui/dashboard/show.html.erb +62 -17
  25. data/app/views/roundhouse_ui/dead/index.html.erb +54 -32
  26. data/app/views/roundhouse_ui/errors/index.html.erb +51 -11
  27. data/app/views/roundhouse_ui/jobs/show.html.erb +8 -3
  28. data/app/views/roundhouse_ui/metrics/show.html.erb +26 -3
  29. data/app/views/roundhouse_ui/queues/index.html.erb +54 -10
  30. data/app/views/roundhouse_ui/queues/show.html.erb +55 -0
  31. data/app/views/roundhouse_ui/recurring/index.html.erb +71 -0
  32. data/app/views/roundhouse_ui/retries/index.html.erb +30 -17
  33. data/app/views/roundhouse_ui/scheduled/index.html.erb +17 -11
  34. data/app/views/roundhouse_ui/settings/show.html.erb +91 -0
  35. data/app/views/roundhouse_ui/shared/_filter_error.html.erb +23 -0
  36. data/app/views/roundhouse_ui/shared/_pager.html.erb +2 -2
  37. data/app/views/roundhouse_ui/shared/_search_bar.html.erb +55 -0
  38. data/app/views/roundhouse_ui/shared/_search_help.html.erb +37 -0
  39. data/app/views/roundhouse_ui/shared/_tag_filter.html.erb +28 -0
  40. data/app/views/roundhouse_ui/shared/bulk_preview.html.erb +69 -0
  41. data/config/routes.rb +9 -0
  42. data/lib/roundhouse_ui/backends/sidekiq.rb +124 -5
  43. data/lib/roundhouse_ui/backends/solid_queue.rb +54 -3
  44. data/lib/roundhouse_ui/demo.rb +130 -0
  45. data/lib/roundhouse_ui/error_groups.rb +25 -4
  46. data/lib/roundhouse_ui/filter_query.rb +504 -0
  47. data/lib/roundhouse_ui/health.rb +43 -4
  48. data/lib/roundhouse_ui/history.rb +54 -0
  49. data/lib/roundhouse_ui/icons.rb +104 -0
  50. data/lib/roundhouse_ui/marks/datadog-lockup-white.svg +30 -0
  51. data/lib/roundhouse_ui/marks/datadog-lockup.svg +41 -0
  52. data/lib/roundhouse_ui/observability.rb +55 -7
  53. data/lib/roundhouse_ui/pause.rb +6 -11
  54. data/lib/roundhouse_ui/queue_summary.rb +11 -0
  55. data/lib/roundhouse_ui/recurring.rb +137 -0
  56. data/lib/roundhouse_ui/runbooks.rb +75 -0
  57. data/lib/roundhouse_ui/snapshots.rb +13 -2
  58. data/lib/roundhouse_ui/tags.rb +112 -0
  59. data/lib/roundhouse_ui/theme.rb +298 -0
  60. data/lib/roundhouse_ui/version.rb +1 -1
  61. data/lib/roundhouse_ui.rb +235 -5
  62. data/lib/tasks/roundhouse_ui_tasks.rake +84 -4
  63. metadata +32 -7
data/README.md CHANGED
@@ -1,37 +1,128 @@
1
1
  # Roundhouse
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.**
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
  [![CI](https://github.com/rjrobinson/roundhouse_ui/actions/workflows/ci.yml/badge.svg)](https://github.com/rjrobinson/roundhouse_ui/actions/workflows/ci.yml)
6
7
  [![Gem Version](https://img.shields.io/gem/v/roundhouse_ui)](https://rubygems.org/gems/roundhouse_ui)
7
8
  [![Ruby](https://img.shields.io/badge/ruby-%3E%3D%203.1-CC342D?logo=ruby&logoColor=white)](https://www.ruby-lang.org)
8
9
  [![Rails](https://img.shields.io/badge/rails-%3E%3D%207.0-D30001?logo=rubyonrails&logoColor=white)](https://rubyonrails.org)
9
10
  [![License: MIT](https://img.shields.io/badge/license-MIT-blue.svg)](MIT-LICENSE)
11
+ [![Buy Me A Coffee](https://img.shields.io/badge/buy%20me%20a%20coffee-ffdd00?logo=buymeacoffee&logoColor=black)](https://buymeacoffee.com/rjrobinson)
10
12
 
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**.
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
- > Gem name is `roundhouse_ui`; the brand and mount path are **Roundhouse**.
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
- ## Features
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
- - **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.
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
- Sidekiq-specific extras: **Workers** (quiet/stop, threads, heartbeat), **Redis pressure** (eviction-policy check for silent job loss), and **Capsules**.
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
- There's **no database of its own** — Roundhouse reads your job backend directly (Sidekiq via its API, Solid Queue via its tables).
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 — 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
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: "my-app")
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`/`queue_url`/`error_url` for anything else. | No APM, or you'd rather not add links that only some people can open. |
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 job cancellation (`CancelMiddleware`) — see
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. Pro reopens
172
- `Sidekiq::Queue` with `pause!`/`unpause!` and *prepends* pause support onto
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 genuinely works; disabling it only
183
- hides a feature you already have.
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: Pro's fetchers read that set once at startup and afterwards
187
- only update on the `pro:config` pubsub message `pause!` publishes, so a raw write
188
- would leave running workers pulling the queue until they restarted.
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. Install the
209
- middleware so a cancelled job is dropped before it runs:
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` / `queue_url` / `label`:
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: "my-app")
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 (the safety net for clearing a stuck queue), then restore.
267
- Storage is pluggable via `RoundhouseUi.snapshot_store` (default: Redis). For large/stuck
268
- queues use a file or S3 store so the backup doesn't sit in the Redis you're trying to relieve.
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/PII out of the UI; the audit log records who did what.
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.