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.
- checksums.yaml +4 -4
- data/README.md +124 -19
- data/app/controllers/concerns/roundhouse_ui/job_set_browsing.rb +73 -6
- data/app/controllers/roundhouse_ui/application_controller.rb +1 -0
- data/app/controllers/roundhouse_ui/dashboard_controller.rb +6 -1
- data/app/controllers/roundhouse_ui/dead_controller.rb +5 -2
- data/app/controllers/roundhouse_ui/errors_controller.rb +37 -0
- data/app/controllers/roundhouse_ui/queues_controller.rb +2 -0
- data/app/controllers/roundhouse_ui/retries_controller.rb +5 -2
- data/app/controllers/roundhouse_ui/scheduled_controller.rb +3 -1
- data/app/helpers/roundhouse_ui/application_helper.rb +50 -0
- data/app/helpers/roundhouse_ui/observability_helper.rb +23 -0
- data/app/helpers/roundhouse_ui/tags_helper.rb +101 -0
- data/app/views/layouts/roundhouse_ui/application.html.erb +108 -4
- data/app/views/roundhouse_ui/dashboard/show.html.erb +1 -1
- data/app/views/roundhouse_ui/dead/index.html.erb +41 -27
- data/app/views/roundhouse_ui/errors/index.html.erb +28 -7
- data/app/views/roundhouse_ui/jobs/show.html.erb +1 -0
- data/app/views/roundhouse_ui/queues/index.html.erb +6 -2
- data/app/views/roundhouse_ui/retries/index.html.erb +23 -12
- data/app/views/roundhouse_ui/scheduled/index.html.erb +12 -6
- data/app/views/roundhouse_ui/shared/_pager.html.erb +2 -2
- data/app/views/roundhouse_ui/shared/_tag_filter.html.erb +30 -0
- data/lib/roundhouse_ui/backends/sidekiq.rb +10 -3
- data/lib/roundhouse_ui/cancel_middleware.rb +7 -1
- data/lib/roundhouse_ui/cancellation.rb +43 -0
- data/lib/roundhouse_ui/duration_collector.rb +11 -4
- data/lib/roundhouse_ui/error_groups.rb +12 -1
- data/lib/roundhouse_ui/pause.rb +38 -3
- data/lib/roundhouse_ui/tags.rb +115 -0
- data/lib/roundhouse_ui/version.rb +1 -1
- data/lib/roundhouse_ui.rb +35 -0
- metadata +5 -2
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 709f9a555e5768ad93fbacf4c18ba986bf853f0ccaf877181b8f2ad5336c45d7
|
|
4
|
+
data.tar.gz: f8c74c04bf68a45260fb0a65811b3f33ba010afbe38bb380892c651552ef544f
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
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
|
|
111
|
-
#
|
|
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.
|
|
120
|
-
# DurationCollector middleware (see below).
|
|
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
|
-
|
|
127
|
-
|
|
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
|
-
>
|
|
132
|
-
>
|
|
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
|
|
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
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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:
|
|
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
|
-
@
|
|
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
|
-
|
|
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
|
-
@
|
|
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
|
-
|
|
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
|
-
@
|
|
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
|