roundhouse_ui 0.9.0 → 0.10.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 (33) hide show
  1. checksums.yaml +4 -4
  2. data/README.md +124 -19
  3. data/app/controllers/concerns/roundhouse_ui/job_set_browsing.rb +73 -6
  4. data/app/controllers/roundhouse_ui/application_controller.rb +1 -0
  5. data/app/controllers/roundhouse_ui/dashboard_controller.rb +6 -1
  6. data/app/controllers/roundhouse_ui/dead_controller.rb +5 -2
  7. data/app/controllers/roundhouse_ui/errors_controller.rb +37 -0
  8. data/app/controllers/roundhouse_ui/queues_controller.rb +2 -0
  9. data/app/controllers/roundhouse_ui/retries_controller.rb +5 -2
  10. data/app/controllers/roundhouse_ui/scheduled_controller.rb +3 -1
  11. data/app/helpers/roundhouse_ui/application_helper.rb +50 -0
  12. data/app/helpers/roundhouse_ui/observability_helper.rb +23 -0
  13. data/app/helpers/roundhouse_ui/tags_helper.rb +101 -0
  14. data/app/views/layouts/roundhouse_ui/application.html.erb +108 -4
  15. data/app/views/roundhouse_ui/dashboard/show.html.erb +1 -1
  16. data/app/views/roundhouse_ui/dead/index.html.erb +41 -27
  17. data/app/views/roundhouse_ui/errors/index.html.erb +28 -7
  18. data/app/views/roundhouse_ui/jobs/show.html.erb +1 -0
  19. data/app/views/roundhouse_ui/queues/index.html.erb +6 -2
  20. data/app/views/roundhouse_ui/retries/index.html.erb +23 -12
  21. data/app/views/roundhouse_ui/scheduled/index.html.erb +12 -6
  22. data/app/views/roundhouse_ui/shared/_pager.html.erb +2 -2
  23. data/app/views/roundhouse_ui/shared/_tag_filter.html.erb +30 -0
  24. data/lib/roundhouse_ui/backends/sidekiq.rb +10 -3
  25. data/lib/roundhouse_ui/cancel_middleware.rb +7 -1
  26. data/lib/roundhouse_ui/cancellation.rb +43 -0
  27. data/lib/roundhouse_ui/duration_collector.rb +11 -4
  28. data/lib/roundhouse_ui/error_groups.rb +12 -1
  29. data/lib/roundhouse_ui/pause.rb +38 -3
  30. data/lib/roundhouse_ui/tags.rb +115 -0
  31. data/lib/roundhouse_ui/version.rb +1 -1
  32. data/lib/roundhouse_ui.rb +35 -0
  33. metadata +5 -2
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 913330ea311e66bcb5eb717852a73c0c4eb51a281acbd5899fac33f623b609a2
4
- data.tar.gz: 8fdaff5a284e27d7df7a63ade47294f9d5f156a14e9133f7a40a24ca194ad828
3
+ metadata.gz: 709f9a555e5768ad93fbacf4c18ba986bf853f0ccaf877181b8f2ad5336c45d7
4
+ data.tar.gz: f8c74c04bf68a45260fb0a65811b3f33ba010afbe38bb380892c651552ef544f
5
5
  SHA512:
6
- metadata.gz: ae78d8ec53e92eaac58308a7701abfc11f71ad2d89f1ffe05b40611df458a6121a97f831d16770dfb0f9d37a3bdffe50905498bba41e7c86c7b74fe4bbc211d3
7
- data.tar.gz: 446a6fa76c6f2cf70f2b3ec5e83d5eb06dbe6e6a393bebfab7414f3bd1e87d8fa96c6bf9ae180c284e2ae50e63ee1e1f3516a827591ab58d686fccdd8ebaf195
6
+ metadata.gz: cf31556bd89ae68aef4c795e9f5d681884b4ace00ab9b4fa70944d4fb9527cd5a065afa80c125ddfd21721e267918654188d727248e242e0e86d2a6ad3954382
7
+ data.tar.gz: 3721a96a24483cd9def146b5ae10600320ffac4890a662d722bef8bc9ffa9f1e265b6039081ae47e7a8d35d6a0abf4bda3b49c0faade22783deb8766b169794b
data/README.md CHANGED
@@ -107,32 +107,59 @@ RoundhouseUi.configure do |c|
107
107
  # No-op unless the sidekiq-failures gem is loaded. Default: off.
108
108
  c.show_sidekiq_failures = true
109
109
 
110
- # Set false to hide queue pause/resume controls and the "not enforced" warning,
111
- # e.g. when you run reliable fetch (super_fetch) instead of RoundhouseUi::Fetch.
112
- # Default: true.
110
+ # Set false to hide queue pause/resume controls entirely. Rarely needed on
111
+ # Sidekiq Pro and Solid Queue pause is enforced natively, and on OSS Sidekiq
112
+ # installing RoundhouseUi::Fetch enforces it. Default: true.
113
113
  # c.pause_enabled = false
114
114
 
115
+ # Surface your own labels (owning team, tenant, …) on job rows, the job page,
116
+ # and grouped errors — and filter by them. See "Job tags" below.
117
+ # c.job_tags = RoundhouseUi::Tags.from_constant(:OWNER, as: :squad)
118
+
115
119
  # Seconds between dashboard stat polls (default 5). Raise it if polling shows
116
120
  # up in your traces — each poll re-runs the host's auth/routing on the mount.
117
121
  # c.poll_interval = 10
118
122
 
119
- # Show the "slowest job classes" table on the Metrics page. Requires the
120
- # DurationCollector middleware (see below). Default: false.
123
+ # Show the "slowest job classes" table on the Metrics page. The flag alone shows
124
+ # nothing — it also needs the DurationCollector middleware (see below).
125
+ # Default: false.
121
126
  # c.collect_durations = true
122
127
  end
123
128
  ```
124
129
 
125
- Every option is independent and has a safe default — set only what you need.
126
- `read_only`, `allow_job_editing`, and `show_sidekiq_failures` default to `false`;
127
- `redact_args` to `[]`; `observability` to a no-op adapter; `snapshot_store` to Redis.
130
+ Every option is independent and has a safe default — **set only what you need**. Nothing
131
+ here is required to mount Roundhouse.
132
+
133
+ ### When to turn each one on
134
+
135
+ | Option | Default | Turn it on when | Leave it alone when |
136
+ |---|---|---|---|
137
+ | `read_only` | `false` | **Production, almost always.** Blocks purge/retry/delete/edit *server-side*, not just in the UI — so it holds even if someone hand-crafts a request. The usual shape is `!Rails.env.development?`. | You need operators to actually fix things from the UI, and you trust everyone behind the mount. |
138
+ | `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. |
139
+ | `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. |
140
+ | `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. |
141
+ | `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. |
142
+ | `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. |
143
+ | `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). |
144
+ | `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. |
145
+ | `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. |
146
+ | `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. |
147
+ | `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. |
148
+ | `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. |
149
+ | `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. |
150
+
151
+ Two that pair with a middleware rather than working alone: `collect_durations`
152
+ (`DurationCollector`) and job cancellation (`CancelMiddleware`) — see
153
+ [Cancelling jobs](#cancelling-jobs) and [Slowest job classes](#slowest-job-classes).
128
154
 
129
155
  ## Pausing queues
130
156
 
131
- > On **Solid Queue**, pause is **native** — it's enforced by the backend, so there's
132
- > nothing to install and no warning. The rest of this section is Sidekiq-only.
157
+ > Pause is **native** — enforced with nothing to install and no warning — on both
158
+ > **Solid Queue** and **Sidekiq Pro/Enterprise** (see below). The fetch strategy
159
+ > below is only needed on **OSS Sidekiq**.
133
160
 
134
- On Sidekiq, pause/resume is pure OSS. To make a pause actually stop a queue from being
135
- worked, install Roundhouse's fetch strategy in your Sidekiq **server** config:
161
+ On OSS Sidekiq, pause/resume is pure OSS. To make a pause actually stop a queue from
162
+ being worked, install Roundhouse's fetch strategy in your Sidekiq **server** config:
136
163
 
137
164
  ```ruby
138
165
  # config/initializers/sidekiq.rb
@@ -146,11 +173,80 @@ all of Sidekiq's weighting/ordering. Until it's installed, the Queues page recor
146
173
  but **warns that they aren't enforced** (worker and web are separate processes, so
147
174
  Roundhouse detects whether a fetcher has reported in).
148
175
 
149
- > ⚠️ **Sidekiq Pro/Enterprise users:** super_fetch / reliable fetch sets its own
150
- > `fetch_class`, and Sidekiq allows only one. Installing `RoundhouseUi::Fetch` would
151
- > **replace reliable fetch and lose its crash-recovery guarantees** don't. On those
152
- > tiers, leave the fetch strategy out and set `RoundhouseUi.pause_enabled = false` to
153
- > hide the pause controls and the (otherwise permanent) "not enforced" warning.
176
+ ### Sidekiq Pro / Enterprise nothing to install
177
+
178
+ Pro ships its own enforced pause, and Roundhouse uses it automatically. Pro reopens
179
+ `Sidekiq::Queue` with `pause!`/`unpause!` and *prepends* pause support onto
180
+ `Sidekiq::BasicFetch` (`super_fetch` honors it too), so **any Pro worker enforces
181
+ pauses** whether or not a fetch strategy is configured.
182
+
183
+ When Roundhouse detects Pro it delegates pause/resume to `Sidekiq::Queue#pause!`,
184
+ reads paused state from Pro's registry, advertises `native_pause`, and drops the
185
+ "not enforced" warning. So on Pro:
186
+
187
+ - **Don't** install `RoundhouseUi::Fetch` — it isn't needed, and on `super_fetch`
188
+ installs it would displace reliable fetch and lose its crash-recovery guarantees.
189
+ - **Don't** set `pause_enabled = false` — pause genuinely works; disabling it only
190
+ hides a feature you already have.
191
+
192
+ Roundhouse always goes through `Sidekiq::Queue#pause!` rather than writing Pro's
193
+ Redis key directly: Pro's fetchers read that set once at startup and afterwards
194
+ only update on the `pro:config` pubsub message `pause!` publishes, so a raw write
195
+ would leave running workers pulling the queue until they restarted.
196
+
197
+ ## Job tags
198
+
199
+ Most apps already know who owns a job — commonly a constant on the class. Point
200
+ Roundhouse at it and that label shows up as a badge on Retries, Dead, Scheduled, the
201
+ job detail page and grouped Errors, and becomes a filter.
202
+
203
+ ```ruby
204
+ # config/initializers/roundhouse.rb
205
+ RoundhouseUi.job_tags = RoundhouseUi::Tags.from_constant(:OWNER, as: :squad)
206
+ ```
207
+
208
+ That's the whole setup for the `OWNER = :growth` convention — every class defining the
209
+ constant (including by inheritance) is tagged. Any callable works if your labels come
210
+ from somewhere else:
211
+
212
+ ```ruby
213
+ RoundhouseUi.job_tags = ->(klass:, item:) {
214
+ { squad: OwnershipMap.for(klass), tier: klass.end_with?("CriticalJob") ? "p1" : "p3" }
215
+ }
216
+ ```
217
+
218
+ Tags are resolved **when a page renders** — no middleware, no enqueue changes, nothing
219
+ stored. They apply retroactively to jobs already sitting in the sets, and work the same
220
+ on Sidekiq and Solid Queue. `klass` is always the real job class: the ActiveJob adapter's
221
+ wrapper is unwrapped before your resolver sees it. See
222
+ [ADR 0002](docs/adr/0002-job-tagging.md).
223
+
224
+ ### Filtering
225
+
226
+ `?tag=key:value` filters Retries, Dead and Scheduled — for example
227
+ `/roundhouse/retries?tag=squad:growth`. It combines with the search box, survives
228
+ pagination, and **applies to bulk actions too**, so "delete all matching" acts on exactly
229
+ the rows shown and never more.
230
+
231
+ Declare a vocabulary to get stable dropdowns instead of relying on the URL:
232
+
233
+ ```ruby
234
+ RoundhouseUi.tag_filters = { squad: %w[core training growth platform ops ai] }
235
+ ```
236
+
237
+ Values may be a callable if the list is dynamic. Once declared, filtering on a key you
238
+ didn't declare matches nothing rather than everything.
239
+
240
+ ### Cost and safety
241
+
242
+ - By default the resolver is treated as a **pure function of the job class** and is called
243
+ once per class per request — a 1,000-row page costs a handful of calls, not 1,000. If
244
+ your resolver reads the payload, set `RoundhouseUi.job_tags_per_job = true`; it will
245
+ then be called once per row, so keep it cheap.
246
+ - Tag values pass through `redact_args`, so a tag keyed `tenant_token` masks itself. This
247
+ is key-based only — a tag *named* `squad` whose *value* is sensitive is not masked.
248
+ - A resolver that raises is caught and logged; the page renders without tags rather than
249
+ failing.
154
250
 
155
251
  ## Surfacing sidekiq-failures
156
252
 
@@ -184,6 +280,15 @@ The **Busy** page's Cancel button flags a job's JID. A queued/scheduled/retrying
184
280
  is then skipped when it would next run; a *currently running* job stops only if it
185
281
  checks in — e.g. a long loop can `break if RoundhouseUi.cancelled?(jid)`.
186
282
 
283
+ **Timing and cost.** The middleware is close to free when nothing is cancelled: rather
284
+ than checking each job's JID against Redis, it asks "is *anything* cancelled?" from a
285
+ process-local gate refreshed at most every 2s, and only does the exact per-job lookup
286
+ while cancellations are pending. The tradeoff is that a cancellation takes **up to ~2s
287
+ to reach a worker process** — the UI and your workers are separate processes, so expect
288
+ a brief lag after clicking Cancel. Jobs already in flight are unaffected either way
289
+ (cancellation is cooperative), and `RoundhouseUi.cancelled?(jid)` — what a long-running
290
+ job polls — is never gated, so it always reads current state.
291
+
187
292
  ## Slowest job classes
188
293
 
189
294
  Sidekiq doesn't track per-class durations, so Roundhouse can record them itself.
@@ -197,8 +302,8 @@ Sidekiq.configure_server do |config|
197
302
  end
198
303
  ```
199
304
 
200
- It's two cheap Redis writes per job (a counter + a summed-ms float) into a single
201
- hash, and a job failure never propagates from the collector.
305
+ It's two cheap Redis writes per job (a counter + a summed-ms float) into a single hash,
306
+ pipelined into **one round-trip**, and a job failure never propagates from the collector.
202
307
 
203
308
  ## Bulk actions on a filter
204
309
 
@@ -10,14 +10,26 @@ module RoundhouseUi
10
10
  # Returns [entries_for_page, has_next?]. Scans only far enough to fill the
11
11
  # requested page plus one (to know if a next page exists) — never loads the
12
12
  # whole set, so a 50k dead set stays cheap to page through.
13
- def browse(set, query, page, per = PER_PAGE)
13
+ # `?tag=key:value` an exact match against a host-defined tag (ADR 0002),
14
+ # parsed once per request. Deliberately structured rather than folded into
15
+ # the free-text query: substring search feeding bulk_apply would silently
16
+ # widen destructive bulk actions.
17
+ def tag_filter
18
+ key, value = params[:tag].to_s.split(":", 2)
19
+ return nil if key.blank? || value.blank?
20
+
21
+ [ key, value ]
22
+ end
23
+
24
+ def browse(set, query, page, per = PER_PAGE, tag: nil)
14
25
  start = (page - 1) * per
15
26
  jobs = []
16
27
  has_next = false
17
28
  matched = 0
29
+ cache = tag_cache_for(tag)
18
30
 
19
31
  set.each do |entry|
20
- next if query.present? && !entry_matches?(entry, query)
32
+ next unless entry_selected?(entry, query, tag, cache)
21
33
 
22
34
  if matched < start
23
35
  matched += 1
@@ -36,11 +48,12 @@ module RoundhouseUi
36
48
  # Apply an op ("retry"/"delete") to every entry matching the query, capped at
37
49
  # BULK_CAP. Entries are collected first, then acted on — mutating a Sidekiq set
38
50
  # mid-iteration skips entries. Returns [count_acted_on, capped?].
39
- def bulk_apply(set, query, op, cap = BULK_CAP)
51
+ def bulk_apply(set, query, op, cap = BULK_CAP, tag: nil)
40
52
  matches = []
41
53
  capped = false
54
+ cache = tag_cache_for(tag)
42
55
  set.each do |entry|
43
- next if query.present? && !entry_matches?(entry, query)
56
+ next unless entry_selected?(entry, query, tag, cache)
44
57
 
45
58
  matches << entry
46
59
  if matches.size >= cap
@@ -52,9 +65,63 @@ module RoundhouseUi
52
65
  [ matches.size, capped ]
53
66
  end
54
67
 
55
- def entry_matches?(entry, query)
68
+ # Both the browse and bulk paths run every candidate through this, so the
69
+ # rows an operator sees are exactly the rows a bulk action will touch —
70
+ # including when a tag value is what matched the free-text search.
71
+ def entry_selected?(entry, query, tag, cache)
72
+ return false if @queue_filter.present? && entry.queue.to_s != @queue_filter
73
+
74
+ tags = entry_tags(entry, cache)
75
+ return false if query.present? && !entry_matches?(entry, query, tags)
76
+ return true if tag.nil?
77
+
78
+ entry_tagged?(tags, tag)
79
+ end
80
+
81
+ # `?queue=name` — exact match, so clicking a queue pill or picking one from
82
+ # the palette narrows to that queue. Exact rather than substring because
83
+ # this feeds bulk_apply too, and "default" must never also select
84
+ # "default_low".
85
+ def queue_filter
86
+ params[:queue].to_s.strip.presence
87
+ end
88
+
89
+ def entry_tagged?(tags, (key, value))
90
+ # A declared vocabulary is authoritative: filtering on a key the host
91
+ # never declared matches nothing rather than everything.
92
+ declared = Tags.filters
93
+ return false if declared && !declared.key?(key)
94
+
95
+ Tags.match?(tags, key, value)
96
+ end
97
+
98
+ def entry_tags(entry, cache)
99
+ return Tags::EMPTY unless RoundhouseUi.job_tags
100
+
101
+ Tags.for(klass: entry.klass, item: entry.item, cache: cache)
102
+ end
103
+
104
+ # Shares the request memo with TagsHelper — controller ivars carry into the
105
+ # view, so an entry resolved while scanning is not resolved again when its
106
+ # badge renders. Tags.for picks the key: class name normally, jid in per-job
107
+ # mode.
108
+ def tag_cache_for(_tag)
109
+ @rh_tag_cache ||= {}
110
+ end
111
+
112
+ # Tag values are part of the haystack, so typing a squad name finds its jobs
113
+ # without reaching for the structured filter. Safe to widen here only because
114
+ # browse and bulk_apply share this predicate — if they diverged, a search
115
+ # would show one set of rows and "delete all matching" would act on another.
116
+ def entry_matches?(entry, query, tags = Tags::EMPTY)
56
117
  needle = query.downcase
57
- [ entry.klass, entry.jid, entry.item["error_class"], entry.item["error_message"], entry.args.to_s ]
118
+ # Queue matches on equality, not substring: typing a queue name should
119
+ # find its jobs, but this predicate also drives bulk_apply, so "default"
120
+ # must never additionally select "default_low".
121
+ return true if entry.queue.to_s.downcase == needle
122
+
123
+ [ entry.klass, entry.jid, entry.item["error_class"], entry.item["error_message"], entry.args.to_s,
124
+ *tags.values ]
58
125
  .any? { |hay| hay.to_s.downcase.include?(needle) }
59
126
  end
60
127
  end
@@ -5,6 +5,7 @@ module RoundhouseUi
5
5
  # Isolated engines don't auto-include the host's helpers; include ours.
6
6
  helper ObservabilityHelper
7
7
  helper NavHelper
8
+ helper TagsHelper
8
9
  helper_method :content_nonce
9
10
 
10
11
  # Self-contained CSP, set per-request on our own responses so Roundhouse is
@@ -16,6 +16,10 @@ module RoundhouseUi
16
16
  # cheap JSON, no WebSocket/build step required).
17
17
  def stats
18
18
  s = backend.stats
19
+ # queues was already loaded here for the count, so the names ride along
20
+ # free — the command palette uses them to tell a queue name from a plain
21
+ # search term without adding a Redis call to every page render.
22
+ qs = backend.queues
19
23
  render json: {
20
24
  processed: s.processed,
21
25
  failed: s.failed,
@@ -24,7 +28,8 @@ module RoundhouseUi
24
28
  scheduled: s.scheduled_size,
25
29
  retries: s.retry_size,
26
30
  dead: s.dead_size,
27
- queues: backend.queues.size
31
+ queues: qs.size,
32
+ queue_names: qs.map(&:name).sort
28
33
  }
29
34
  end
30
35
  end
@@ -8,7 +8,9 @@ module RoundhouseUi
8
8
  @query = params[:q].to_s.strip
9
9
  @page = [ params[:page].to_i, 1 ].max
10
10
  @total = backend.dead_set.size
11
- @jobs, @has_next = browse(backend.dead_set, @query, @page)
11
+ @tag = tag_filter
12
+ @queue_filter = queue_filter
13
+ @jobs, @has_next = browse(backend.dead_set, @query, @page, PER_PAGE, tag: @tag)
12
14
  end
13
15
 
14
16
  def requeue
@@ -40,7 +42,8 @@ module RoundhouseUi
40
42
  # selected/visible ones), capped for safety. Only offered when a filter is
41
43
  # active, so it can't become "retry the entire dead set" by accident.
42
44
  def bulk_all
43
- count, capped = bulk_apply(backend.dead_set, params[:q].to_s.strip, params[:op])
45
+ @queue_filter = queue_filter
46
+ count, capped = bulk_apply(backend.dead_set, params[:q].to_s.strip, params[:op], BULK_CAP, tag: tag_filter)
44
47
  verb = params[:op] == "delete" ? "Deleted" : "Re-enqueued"
45
48
  note = "#{verb} #{count} matching job(s)."
46
49
  note += " Stopped at the #{JobSetBrowsing::BULK_CAP} cap — run again for more." if capped
@@ -8,6 +8,43 @@ module RoundhouseUi
8
8
  @scan_limit = ErrorGroups::DEFAULT_SCAN_LIMIT
9
9
  result = ErrorGroups.new(query: @query).call
10
10
  @groups, @scanned, @truncated = result.groups, result.scanned, result.truncated
11
+
12
+ # Tags resolve per group rather than per entry: every row in a
13
+ # klass|error group shares a class, so a class-derived tag is constant
14
+ # for the group. Counted before filtering, so the quick-filter strip
15
+ # keeps showing every squad's total while one of them is selected.
16
+ @group_tags = @groups.to_h { |g| [ g[:klass], Tags.for(klass: g[:klass], item: {}, cache: tag_cache) ] }
17
+ @tag_counts = tag_counts(@groups)
18
+ @tag = tag_filter
19
+ @groups = @groups.select { |g| Tags.match?(@group_tags[g[:klass]], *@tag) } if @tag
20
+ end
21
+
22
+ private
23
+
24
+ # `?tag=key:value`, shared shape with the job sets.
25
+ def tag_filter
26
+ key, value = params[:tag].to_s.split(":", 2)
27
+ return nil if key.blank? || value.blank?
28
+
29
+ declared = Tags.filters
30
+ return nil if declared && !declared.key?(key)
31
+
32
+ [ key, value ]
33
+ end
34
+
35
+ # { "squad" => { "core" => 4, "training" => 5 } } — the counts behind the
36
+ # quick-filter strip, from groups already scanned, so this costs no extra
37
+ # Redis work.
38
+ def tag_counts(groups)
39
+ counts = Hash.new { |h, k| h[k] = Hash.new(0) }
40
+ groups.each do |g|
41
+ @group_tags[g[:klass]].each { |key, value| counts[key][value] += g[:count] }
42
+ end
43
+ counts
44
+ end
45
+
46
+ def tag_cache
47
+ @rh_tag_cache ||= {}
11
48
  end
12
49
  end
13
50
  end
@@ -3,7 +3,9 @@ module RoundhouseUi
3
3
  before_action :require_writable!, only: %i[purge pause resume]
4
4
 
5
5
  def index
6
+ @query = params[:q].to_s.strip
6
7
  @queues = backend.queues
8
+ @queues = @queues.select { |q| q.name.to_s.downcase.include?(@query.downcase) } if @query.present?
7
9
  @paused = backend.paused_queues
8
10
  # Native-pause backends (Solid Queue) enforce pauses without a fetcher, so
9
11
  # they never trigger the "not enforced" warning.
@@ -8,7 +8,9 @@ module RoundhouseUi
8
8
  @query = params[:q].to_s.strip
9
9
  @page = [ params[:page].to_i, 1 ].max
10
10
  @total = backend.retry_set.size
11
- @jobs, @has_next = browse(backend.retry_set, @query, @page)
11
+ @tag = tag_filter
12
+ @queue_filter = queue_filter
13
+ @jobs, @has_next = browse(backend.retry_set, @query, @page, PER_PAGE, tag: @tag)
12
14
  end
13
15
 
14
16
  # Retry now — moves the job back to its queue immediately.
@@ -27,7 +29,8 @@ module RoundhouseUi
27
29
  # Smart bulk: retry/delete EVERY job matching the current filter, capped for
28
30
  # safety. Offered only when a filter is active.
29
31
  def bulk_all
30
- count, capped = bulk_apply(backend.retry_set, params[:q].to_s.strip, params[:op])
32
+ @queue_filter = queue_filter
33
+ count, capped = bulk_apply(backend.retry_set, params[:q].to_s.strip, params[:op], BULK_CAP, tag: tag_filter)
31
34
  verb = params[:op] == "delete" ? "Deleted" : "Re-enqueued"
32
35
  note = "#{verb} #{count} matching job(s)."
33
36
  note += " Stopped at the #{JobSetBrowsing::BULK_CAP} cap — run again for more." if capped
@@ -8,7 +8,9 @@ module RoundhouseUi
8
8
  @query = params[:q].to_s.strip
9
9
  @page = [ params[:page].to_i, 1 ].max
10
10
  @total = backend.scheduled_set.size
11
- @jobs, @has_next = browse(backend.scheduled_set, @query, @page)
11
+ @tag = tag_filter
12
+ @queue_filter = queue_filter
13
+ @jobs, @has_next = browse(backend.scheduled_set, @query, @page, PER_PAGE, tag: @tag)
12
14
  end
13
15
 
14
16
  # Enqueue now — pulls the job out of the schedule and onto its queue.
@@ -1,4 +1,54 @@
1
1
  module RoundhouseUi
2
2
  module ApplicationHelper
3
+ # A job row's identity cell. Class on the first line where the eye lands,
4
+ # jid on a second, dimmer line — one long line of class + hex + link is
5
+ # unreadable once the row also carries squad, queue and error.
6
+ def job_identity(klass, jid, path)
7
+ safe_join([
8
+ link_to(klass, path, class: "rh-joblink"),
9
+ content_tag(:div, jid, class: "rh-sub rh-mono rh-jid", title: jid)
10
+ ])
11
+ end
12
+
13
+ # Relative time answers "is this soon?", the clock time answers "does that
14
+ # land inside the maintenance window?". Operators need both, so show both
15
+ # rather than making them hover or do the arithmetic.
16
+ def job_time(at, overdue: "now (overdue)")
17
+ return content_tag(:span, "—", class: "rh-sub") if at.nil?
18
+
19
+ relative = at > Time.now ? "in #{distance_of_time_in_words(Time.now, at)}" : overdue
20
+ safe_join([
21
+ content_tag(:span, relative),
22
+ content_tag(:span, at.strftime("%b %-d, %H:%M"), class: "rh-sub rh-mono")
23
+ ], " · ")
24
+ end
25
+
26
+ # Set heading that tells the truth under a filter. It used to always print
27
+ # the whole-set size, so "Dead set · 19 jobs" sat above four filtered rows.
28
+ def set_heading(label, showing:, total:, query: nil, tag: nil)
29
+ filtered = any_filter?(query, tag)
30
+ count = filtered ? "#{number_with_delimiter showing} of #{number_with_delimiter total}" : number_with_delimiter(total)
31
+ content_tag(:h2, class: "rh-h2") do
32
+ safe_join([
33
+ "#{label} · #{count} jobs",
34
+ (filtered ? content_tag(:span, filter_description(query, tag), class: "hint") : nil)
35
+ ].compact, " ")
36
+ end
37
+ end
38
+
39
+ # Queues carry meaning at a glance (critical vs low), so render them as a
40
+ # pill rather than grey text lost between two columns. On the job sets the
41
+ # pill filters to that queue; `link:` is off where there's nothing to filter
42
+ # (the Queues index itself, grouped Errors rows).
43
+ def queue_pill(name, link: false)
44
+ return content_tag(:span, name, class: "rh-pill rh-mono") unless link
45
+
46
+ active = @queue_filter == name.to_s
47
+ link_to name, url_for(only_path: true, page: nil, q: @query.presence,
48
+ tag: params[:tag].presence,
49
+ queue: (active ? nil : name)),
50
+ class: "rh-pill rh-mono rh-pill-link#{' is-on' if active}",
51
+ title: active ? "Clear queue filter" : "Show only #{name}"
52
+ end
3
53
  end
4
54
  end
@@ -10,6 +10,29 @@ module RoundhouseUi
10
10
  link_to "↗ #{adapter.label}", url, target: "_blank", rel: "noopener", class: "rh-trace"
11
11
  end
12
12
 
13
+ # Icon-only variant for table rows. The adapter's name is worth reading once
14
+ # in a header or a detail page, not once per row — there it is width spent
15
+ # repeating a word the operator already knows.
16
+ def trace_icon(klass:, jid:, queue: nil)
17
+ adapter = RoundhouseUi.observability
18
+ url = adapter.job_url(klass: klass, jid: jid, queue: queue)
19
+ return unless url
20
+
21
+ link_to "↗", url, target: "_blank", rel: "noopener", class: "rh-trace rh-trace-ico",
22
+ title: "Open in #{adapter.label}", "aria-label": "Open in #{adapter.label}"
23
+ end
24
+
25
+ def error_trace_icon(klass:, error: nil)
26
+ adapter = RoundhouseUi.observability
27
+ return unless adapter.respond_to?(:error_url)
28
+
29
+ url = adapter.error_url(klass: klass, error: error)
30
+ return unless url
31
+
32
+ link_to "↗", url, target: "_blank", rel: "noopener", class: "rh-trace rh-trace-ico",
33
+ title: "Open in #{adapter.label}", "aria-label": "Open in #{adapter.label}"
34
+ end
35
+
13
36
  # Deep-link for a grouped error row (no single JID) — a class-wide search.
14
37
  # respond_to? keeps older/custom adapters that lack error_url working.
15
38
  def error_trace_link(klass:, error: nil)
@@ -0,0 +1,101 @@
1
+ module RoundhouseUi
2
+ # Renders host-defined job tags (see ADR 0002). Resolution is memoized for the
3
+ # life of the request: a page can render hundreds of rows, and ErrorGroups
4
+ # scans up to DEFAULT_SCAN_LIMIT entries, so the host's resolver must not be
5
+ # called once per row when it only varies by class.
6
+ module TagsHelper
7
+ # The per-request memo handed to Tags.for, shared with JobSetBrowsing so a
8
+ # class (or job) resolved while scanning is not resolved again when its
9
+ # badge renders. Tags.for chooses the key: class name normally, jid in
10
+ # per-job mode.
11
+ def tag_cache
12
+ @rh_tag_cache ||= {}
13
+ end
14
+
15
+ # Tags for one job entry, as a { "key" => "value" } Hash.
16
+ def tags_for(entry)
17
+ return Tags::EMPTY unless RoundhouseUi.job_tags
18
+
19
+ Tags.for(klass: entry.klass, item: entry.item, cache: tag_cache)
20
+ end
21
+
22
+ # Tags for a class name alone — used by grouped Errors, where every entry in
23
+ # a group shares a class, so a class-derived tag is constant for the group.
24
+ # Passes no payload, so a per-job resolver correctly declines rather than
25
+ # attributing one job's tags to the whole group.
26
+ def tags_for_class(klass)
27
+ return Tags::EMPTY unless RoundhouseUi.job_tags
28
+
29
+ Tags.for(klass: klass, item: {}, cache: tag_cache)
30
+ end
31
+
32
+ # Pills for a resolved tag Hash. Renders nothing when there are no tags, so
33
+ # every call site can be unconditional.
34
+ def tag_badges(tags)
35
+ return if tags.blank?
36
+
37
+ safe_join(tags.map { |key, value| tag_badge(key, value) }, " ")
38
+ end
39
+
40
+ # Tags get their own table column rather than an inline badge: class names
41
+ # vary wildly in length, so inline badges land at ragged x-positions and
42
+ # can't be scanned down. Only worth a column when a host configured tags.
43
+ def tag_column?
44
+ RoundhouseUi.job_tags.present?
45
+ end
46
+
47
+ # Header for that column. With the usual single dimension this is the tag's
48
+ # own name ("squad"); with several there's no one right label.
49
+ def tag_column_label
50
+ keys = Tags.filters&.keys
51
+ keys&.one? ? keys.first.titleize : "Tags"
52
+ end
53
+
54
+ # Cell contents. Stacks when a host defines more than one dimension.
55
+ def tag_cell(tags)
56
+ return content_tag(:span, "—", class: "rh-sub") if tags.blank?
57
+
58
+ tag_badges(tags)
59
+ end
60
+
61
+ # Values offered as filter chips where no counts are available. Prefers the
62
+ # host's declared vocabulary, so the chips stay put as you filter; falls back
63
+ # to whatever the visible rows happen to carry, which at least beats nothing
64
+ # but shifts as you page.
65
+ def tag_vocabulary
66
+ declared = Tags.filters
67
+ return declared if declared.present?
68
+
69
+ seen = Hash.new { |h, k| h[k] = [] }
70
+ Array(@jobs).each do |job|
71
+ tags_for(job).each { |key, value| seen[key] << value unless seen[key].include?(value) }
72
+ end
73
+ seen.transform_values(&:sort)
74
+ end
75
+
76
+ # Human description of the active filter set, so a bulk confirm names every
77
+ # constraint that will be applied — not just the text query.
78
+ # Every active constraint must appear here. A confirm that names only the
79
+ # text query while a queue or tag also narrows the set understates what is
80
+ # about to be destroyed.
81
+ def filter_description(query, tag, queue = @queue_filter)
82
+ parts = []
83
+ parts << "matching “#{query}”" if query.present?
84
+ parts << "tagged #{tag[0]}: #{tag[1]}" if tag
85
+ parts << "in queue #{queue}" if queue.present?
86
+ parts.join(" and ")
87
+ end
88
+
89
+ # Any filter active? Bulk-on-match is filter-gated, so this decides whether
90
+ # the bulk bar renders at all — and which empty-state copy is honest.
91
+ def any_filter?(query, tag, queue = @queue_filter)
92
+ query.present? || !tag.nil? || queue.present?
93
+ end
94
+
95
+ # Value only — the key is near-constant down a column, so repeating it is
96
+ # noise. It stays in the tooltip for hosts with more than one dimension.
97
+ def tag_badge(key, value)
98
+ content_tag(:span, value, class: "rh-pill rh-pill-tag", title: "#{key}: #{value}")
99
+ end
100
+ end
101
+ end