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
@@ -0,0 +1,115 @@
1
+ module RoundhouseUi
2
+ # Resolves host-defined tags for a job at read time (no middleware, no
3
+ # storage — works on every backend and applies retroactively to jobs already
4
+ # in the sets). The host supplies the resolver; see ADR 0002.
5
+ #
6
+ # RoundhouseUi.job_tags = RoundhouseUi::Tags.from_constant(:OWNER, as: :squad)
7
+ # # or any callable:
8
+ # RoundhouseUi.job_tags = ->(klass:, item:) { { squad: :growth } }
9
+ #
10
+ # Contract: resolver output is normalized to string keys/values, masked via
11
+ # Redaction (redact_args patterns apply to tag keys), and a raising resolver
12
+ # yields no tags — tagging must never break a page.
13
+ module Tags
14
+ EMPTY = {}.freeze
15
+
16
+ module_function
17
+
18
+ # Tags for one job entry, as a { "key" => "value" } Hash (EMPTY when no
19
+ # resolver is configured, the resolver declines, or it raises).
20
+ #
21
+ # `klass`/`item` come from the backend entry (entry.klass / entry.item);
22
+ # the ActiveJob adapter wrapper is unwrapped here, so resolvers always see
23
+ # the real job class. Pass a Hash as `cache` to memoize per class across a
24
+ # request — unused (and unneeded) in per-job mode.
25
+ def for(klass:, item:, cache: nil)
26
+ resolver = RoundhouseUi.job_tags
27
+ return EMPTY unless resolver
28
+
29
+ effective = effective_klass(klass, item)
30
+ return EMPTY unless effective
31
+
32
+ if RoundhouseUi.job_tags_per_job
33
+ # Per-job mode still memoizes, keyed by jid rather than class: a job's
34
+ # tags cannot change within one request, and the same entry is resolved
35
+ # twice otherwise — once while scanning, once when its badge renders.
36
+ jid = item["jid"] if item.is_a?(Hash)
37
+ return resolve(resolver, effective, item) unless cache && jid
38
+
39
+ cache.key?(jid) ? cache[jid] : cache[jid] = resolve(resolver, effective, item)
40
+ elsif cache
41
+ # Class-cached mode: item is withheld (deliberately — see resolve).
42
+ cache.key?(effective) ? cache[effective] : cache[effective] = resolve(resolver, effective, nil)
43
+ else
44
+ resolve(resolver, effective, nil)
45
+ end
46
+ end
47
+
48
+ # The class-constant convention (Trainual's OWNER pattern) as a resolver:
49
+ #
50
+ # RoundhouseUi.job_tags = RoundhouseUi::Tags.from_constant(:OWNER, as: :squad)
51
+ #
52
+ # Tags every job whose class (or ancestor — inherited constants count, so a
53
+ # base-class OWNER covers subclasses) defines the constant.
54
+ def from_constant(const_name, as: const_name.to_s.downcase)
55
+ lambda do |klass:, item:|
56
+ k = klass.to_s.safe_constantize
57
+ { as => k.const_get(const_name) } if k&.const_defined?(const_name)
58
+ end
59
+ end
60
+
61
+ # The declared filter vocabulary, normalized to { "key" => ["value", ...] },
62
+ # or nil when the host declared none (the filter UI then discovers values
63
+ # from the entries it scans). Both the whole setting and individual values
64
+ # may be callables, so vocabularies can be dynamic.
65
+ def filters
66
+ declared = RoundhouseUi.tag_filters
67
+ declared = declared.call if declared.respond_to?(:call)
68
+ return nil unless declared.is_a?(Hash)
69
+
70
+ declared.each_with_object({}) do |(key, values), out|
71
+ values = values.call if values.respond_to?(:call)
72
+ out[key.to_s] = Array(values).map(&:to_s)
73
+ end
74
+ rescue => e
75
+ warn_once("tag_filters failed: #{e.message}")
76
+ nil
77
+ end
78
+
79
+ # Does a resolved tag Hash match a key/value filter? Exact match on the
80
+ # normalized (post-redaction) value — so a redacted tag matches only its
81
+ # mask, and the filter can't be used to probe redacted values.
82
+ def match?(tags, key, value)
83
+ tags[key.to_s] == value.to_s
84
+ end
85
+
86
+ # -- internals ----------------------------------------------------------
87
+
88
+ # ActiveJob-on-Sidekiq stores the adapter's JobWrapper in item["class"] and
89
+ # the real job class in item["wrapped"]; Solid Queue and raw Sidekiq
90
+ # workers put the real class in klass. Resolvers always get the real one.
91
+ def effective_klass(klass, item)
92
+ wrapped = item["wrapped"] if item.is_a?(Hash)
93
+ (wrapped || klass)&.to_s
94
+ end
95
+
96
+ # `item` is nil in class-cached mode: an args-reading resolver cached by
97
+ # class would poison the cache with first-job-wins values — withholding the
98
+ # payload makes it fail deterministically (rescued → no tags) instead.
99
+ # Hosts that need the payload set RoundhouseUi.job_tags_per_job = true.
100
+ def resolve(resolver, klass, item)
101
+ tags = resolver.call(klass: klass, item: item)
102
+ return EMPTY unless tags.is_a?(Hash)
103
+
104
+ normalized = tags.each_with_object({}) { |(k, v), out| out[k.to_s] = v.to_s }
105
+ Redaction.apply(normalized)
106
+ rescue => e
107
+ warn_once("job_tags resolver failed for #{klass}: #{e.message}")
108
+ EMPTY
109
+ end
110
+
111
+ def warn_once(message)
112
+ Rails.logger&.warn("[roundhouse] #{message}") if defined?(Rails)
113
+ end
114
+ end
115
+ end
@@ -1,3 +1,3 @@
1
1
  module RoundhouseUi
2
- VERSION = "0.9.1"
2
+ VERSION = "0.10.0"
3
3
  end
data/lib/roundhouse_ui.rb CHANGED
@@ -7,6 +7,7 @@ require "roundhouse_ui/snapshots"
7
7
  require "roundhouse_ui/observability"
8
8
  require "roundhouse_ui/audit"
9
9
  require "roundhouse_ui/redaction"
10
+ require "roundhouse_ui/tags"
10
11
  require "roundhouse_ui/cancellation"
11
12
  require "roundhouse_ui/cancel_middleware"
12
13
  require "roundhouse_ui/metrics"
@@ -56,6 +57,39 @@ module RoundhouseUi
56
57
  # e.g. RoundhouseUi.redact_args = %w[password token secret]. Default: none.
57
58
  attr_accessor :redact_args
58
59
 
60
+ # Host-defined job tags, resolved at read time (see ADR 0002): a callable
61
+ # given the job's class name and payload, returning a Hash of tags or nil.
62
+ # `klass` is always the real job class (the ActiveJob adapter wrapper is
63
+ # unwrapped first). For the class-constant convention there's a shorthand:
64
+ #
65
+ # RoundhouseUi.job_tags = RoundhouseUi::Tags.from_constant(:OWNER, as: :squad)
66
+ # # equivalent to:
67
+ # RoundhouseUi.job_tags = ->(klass:, item:) {
68
+ # k = klass.safe_constantize
69
+ # { squad: k.const_get(:OWNER) } if k&.const_defined?(:OWNER)
70
+ # }
71
+ #
72
+ # Tag values render in the UI and pass through redact_args masking (by tag
73
+ # key). Must be cheap: by default it's memoized per class per request and
74
+ # called with item: nil. Default: nil (no tags anywhere).
75
+ attr_accessor :job_tags
76
+
77
+ # Set true when job_tags derives tags from the payload (args, tenant, …):
78
+ # the resolver is then called once per job with the full item, and nothing
79
+ # is cached — a 1,000-entry scan means 1,000 calls, so keep it fast. Leave
80
+ # false for class-derived tags (OWNER-style constants). Default: false.
81
+ attr_accessor :job_tags_per_job
82
+
83
+ # Optional declared filter vocabulary, so tag filter dropdowns are stable
84
+ # instead of discovered from whatever jobs happen to be visible:
85
+ #
86
+ # RoundhouseUi.tag_filters = { squad: %w[core training growth platform ops ai] }
87
+ #
88
+ # Values (or the whole setting) may be callables for dynamic vocabularies.
89
+ # When set, filtering by an undeclared key matches nothing (fail-closed).
90
+ # Default: nil — the filter UI discovers values from the entries it scans.
91
+ attr_accessor :tag_filters
92
+
59
93
  # Opt-in: fold failures recorded by the `sidekiq-failures` gem (its `failed`
60
94
  # sorted set) into the grouped Errors view. Off by default, and a no-op
61
95
  # unless sidekiq-failures is loaded. Jobs with `retry: false` never enter
@@ -112,4 +146,5 @@ module RoundhouseUi
112
146
  self.pause_enabled = true
113
147
  self.poll_interval = 5
114
148
  self.collect_durations = false
149
+ self.job_tags_per_job = false
115
150
  end
metadata CHANGED
@@ -1,14 +1,14 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: roundhouse_ui
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.9.1
4
+ version: 0.10.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - R.J. Robinson
8
8
  autorequire:
9
9
  bindir: bin
10
10
  cert_chain: []
11
- date: 2026-07-31 00:00:00.000000000 Z
11
+ date: 2026-08-17 00:00:00.000000000 Z
12
12
  dependencies:
13
13
  - !ruby/object:Gem::Dependency
14
14
  name: rails
@@ -72,6 +72,7 @@ files:
72
72
  - app/helpers/roundhouse_ui/application_helper.rb
73
73
  - app/helpers/roundhouse_ui/nav_helper.rb
74
74
  - app/helpers/roundhouse_ui/observability_helper.rb
75
+ - app/helpers/roundhouse_ui/tags_helper.rb
75
76
  - app/views/layouts/roundhouse_ui/application.html.erb
76
77
  - app/views/roundhouse_ui/audit/index.html.erb
77
78
  - app/views/roundhouse_ui/busy/index.html.erb
@@ -89,6 +90,7 @@ files:
89
90
  - app/views/roundhouse_ui/retries/index.html.erb
90
91
  - app/views/roundhouse_ui/scheduled/index.html.erb
91
92
  - app/views/roundhouse_ui/shared/_pager.html.erb
93
+ - app/views/roundhouse_ui/shared/_tag_filter.html.erb
92
94
  - app/views/roundhouse_ui/snapshots/index.html.erb
93
95
  - app/views/roundhouse_ui/workers/index.html.erb
94
96
  - config/routes.rb
@@ -108,6 +110,7 @@ files:
108
110
  - lib/roundhouse_ui/pause.rb
109
111
  - lib/roundhouse_ui/redaction.rb
110
112
  - lib/roundhouse_ui/snapshots.rb
113
+ - lib/roundhouse_ui/tags.rb
111
114
  - lib/roundhouse_ui/version.rb
112
115
  - lib/tasks/roundhouse_ui_tasks.rake
113
116
  homepage: https://github.com/rjrobinson/roundhouse_ui