roundhouse_ui 0.9.1 → 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 +61 -0
- 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/error_groups.rb +12 -1
- 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
|
@@ -112,6 +112,10 @@ RoundhouseUi.configure do |c|
|
|
|
112
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
|
|
@@ -139,6 +143,9 @@ here is required to mount Roundhouse.
|
|
|
139
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). |
|
|
140
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. |
|
|
141
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. |
|
|
142
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. |
|
|
143
150
|
|
|
144
151
|
Two that pair with a middleware rather than working alone: `collect_durations`
|
|
@@ -187,6 +194,60 @@ Redis key directly: Pro's fetchers read that set once at startup and afterwards
|
|
|
187
194
|
only update on the `pro:config` pubsub message `pause!` publishes, so a raw write
|
|
188
195
|
would leave running workers pulling the queue until they restarted.
|
|
189
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.
|
|
250
|
+
|
|
190
251
|
## Surfacing sidekiq-failures
|
|
191
252
|
|
|
192
253
|
If you use [`sidekiq-failures`](https://github.com/mhfs/sidekiq-failures), failures it
|
|
@@ -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
|