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.
Files changed (28) hide show
  1. checksums.yaml +4 -4
  2. data/README.md +61 -0
  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/error_groups.rb +12 -1
  25. data/lib/roundhouse_ui/tags.rb +115 -0
  26. data/lib/roundhouse_ui/version.rb +1 -1
  27. data/lib/roundhouse_ui.rb +35 -0
  28. metadata +5 -2
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 6ee71201b4424c59d9450b7eea1fec5b7447e9e40ee419a4ebc03c39bf8189d6
4
- data.tar.gz: 8a33d51ba920fd11b64ae9a28160ce40e87ff0b8c315a04136b64c7aa8427bb7
3
+ metadata.gz: 709f9a555e5768ad93fbacf4c18ba986bf853f0ccaf877181b8f2ad5336c45d7
4
+ data.tar.gz: f8c74c04bf68a45260fb0a65811b3f33ba010afbe38bb380892c651552ef544f
5
5
  SHA512:
6
- metadata.gz: 7494f64a2e7cea227f13ac6037e390775f068726f81e3962ecf0bf62a87d6dffaa7c613bef9c0d2b47bf4bdc5d410e6a2073e35c3a67723485af99fd30b43691
7
- data.tar.gz: 2ff606ac7ca4797069b7cbb875313fd7988a962845f4c738765a8816a48280f5c889167a31d43452342389c3d122d798b15b782ac2219ba0cfb99d4125431bc0
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
- 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