roundhouse_ui 0.9.0 → 0.10.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (33) hide show
  1. checksums.yaml +4 -4
  2. data/README.md +124 -19
  3. data/app/controllers/concerns/roundhouse_ui/job_set_browsing.rb +73 -6
  4. data/app/controllers/roundhouse_ui/application_controller.rb +1 -0
  5. data/app/controllers/roundhouse_ui/dashboard_controller.rb +6 -1
  6. data/app/controllers/roundhouse_ui/dead_controller.rb +5 -2
  7. data/app/controllers/roundhouse_ui/errors_controller.rb +37 -0
  8. data/app/controllers/roundhouse_ui/queues_controller.rb +2 -0
  9. data/app/controllers/roundhouse_ui/retries_controller.rb +5 -2
  10. data/app/controllers/roundhouse_ui/scheduled_controller.rb +3 -1
  11. data/app/helpers/roundhouse_ui/application_helper.rb +50 -0
  12. data/app/helpers/roundhouse_ui/observability_helper.rb +23 -0
  13. data/app/helpers/roundhouse_ui/tags_helper.rb +101 -0
  14. data/app/views/layouts/roundhouse_ui/application.html.erb +108 -4
  15. data/app/views/roundhouse_ui/dashboard/show.html.erb +1 -1
  16. data/app/views/roundhouse_ui/dead/index.html.erb +41 -27
  17. data/app/views/roundhouse_ui/errors/index.html.erb +28 -7
  18. data/app/views/roundhouse_ui/jobs/show.html.erb +1 -0
  19. data/app/views/roundhouse_ui/queues/index.html.erb +6 -2
  20. data/app/views/roundhouse_ui/retries/index.html.erb +23 -12
  21. data/app/views/roundhouse_ui/scheduled/index.html.erb +12 -6
  22. data/app/views/roundhouse_ui/shared/_pager.html.erb +2 -2
  23. data/app/views/roundhouse_ui/shared/_tag_filter.html.erb +30 -0
  24. data/lib/roundhouse_ui/backends/sidekiq.rb +10 -3
  25. data/lib/roundhouse_ui/cancel_middleware.rb +7 -1
  26. data/lib/roundhouse_ui/cancellation.rb +43 -0
  27. data/lib/roundhouse_ui/duration_collector.rb +11 -4
  28. data/lib/roundhouse_ui/error_groups.rb +12 -1
  29. data/lib/roundhouse_ui/pause.rb +38 -3
  30. data/lib/roundhouse_ui/tags.rb +115 -0
  31. data/lib/roundhouse_ui/version.rb +1 -1
  32. data/lib/roundhouse_ui.rb +35 -0
  33. metadata +5 -2
@@ -1,8 +1,8 @@
1
1
  module RoundhouseUi
2
2
  # Opt-in server middleware that records per-class execution time, so the UI can
3
3
  # answer "which job classes are slow?" — something Sidekiq doesn't track. Two
4
- # cheap Redis writes per job (a counter + a summed-ms float). Off by default;
5
- # enable in your Sidekiq server config:
4
+ # cheap Redis writes per job (a counter + a summed-ms float), pipelined into a
5
+ # single round-trip. Off by default; enable in your Sidekiq server config:
6
6
  #
7
7
  # Sidekiq.configure_server do |config|
8
8
  # config.server_middleware { |chain| chain.add RoundhouseUi::DurationCollector }
@@ -22,9 +22,16 @@ module RoundhouseUi
22
22
  def record(klass, elapsed_ms)
23
23
  return unless klass
24
24
 
25
+ commands = [
26
+ [ "HINCRBY", KEY, "#{klass}\x00count", 1 ],
27
+ [ "HINCRBYFLOAT", KEY, "#{klass}\x00ms", elapsed_ms ]
28
+ ]
25
29
  Sidekiq.redis do |conn|
26
- conn.call("HINCRBY", KEY, "#{klass}\x00count", 1)
27
- conn.call("HINCRBYFLOAT", KEY, "#{klass}\x00ms", elapsed_ms)
30
+ if conn.respond_to?(:pipelined) # redis-client and redis-rb 4.5+: one round-trip
31
+ conn.pipelined { |pipe| commands.each { |c| pipe.call(*c) } }
32
+ else
33
+ commands.each { |c| conn.call(*c) }
34
+ end
28
35
  end
29
36
  rescue => e
30
37
  # Metrics collection must never break a job.
@@ -14,6 +14,7 @@ module RoundhouseUi
14
14
  def initialize(query: nil, limit: DEFAULT_SCAN_LIMIT)
15
15
  @query = query.to_s.strip
16
16
  @limit = limit
17
+ @tag_cache = {}
17
18
  end
18
19
 
19
20
  def call
@@ -34,12 +35,22 @@ module RoundhouseUi
34
35
  end
35
36
 
36
37
  list = groups.values.sort_by { |g| -g[:count] }
37
- list = list.select { |g| "#{g[:klass]} #{g[:error]}".downcase.include?(@query.downcase) } if @query.present?
38
+ list = list.select { |g| matches?(g) } if @query.present?
38
39
  Result.new(groups: list, scanned: scanned, truncated: truncated)
39
40
  end
40
41
 
41
42
  private
42
43
 
44
+ # Tag values are part of the haystack here for the same reason they are on
45
+ # the job sets: typing a squad name should find that squad's failures. A
46
+ # group's entries all share a class, so the tag is constant for the group
47
+ # and resolves once per group rather than per scanned entry.
48
+ def matches?(group)
49
+ haystack = [ group[:klass], group[:error] ]
50
+ haystack.concat(Tags.for(klass: group[:klass], item: {}, cache: @tag_cache).values) if RoundhouseUi.job_tags
51
+ haystack.join(" ").downcase.include?(@query.downcase)
52
+ end
53
+
43
54
  # Sidekiq's native sets, plus the sidekiq-failures `failed` set when opted in
44
55
  # and loaded. Its FailureSet is a Sidekiq::JobSet, so it iterates like the rest.
45
56
  def sources
@@ -7,30 +7,59 @@ module RoundhouseUi
7
7
  # Paused queue names live in a Redis set. RoundhouseUi::Fetch consults this set
8
8
  # and skips paused queues when pulling work, so a paused queue stops being
9
9
  # consumed without stopping the worker process.
10
+ #
11
+ # When Sidekiq Pro is loaded we defer to *its* registry instead (see .native?).
12
+ # Pro reopens Sidekiq::Queue with pause!/unpause!/paused? and prepends pause
13
+ # support onto Sidekiq::BasicFetch, so pausing is already enforced with no
14
+ # Roundhouse fetcher installed — and Pro's key ("paused") is not ours
15
+ # ("roundhouse:paused"), so writing our own set there would do nothing.
10
16
  module Pause
11
17
  KEY = "roundhouse:paused"
12
18
  FETCH_FLAG = "roundhouse:fetch_alive" # liveness beacon set by the fetcher
19
+ PRO_KEY = "paused" # Sidekiq Pro's own registry
13
20
 
14
21
  module_function
15
22
 
23
+ # True when Sidekiq Pro's queue-pause API is available. Feature-detected on
24
+ # the method rather than `defined?(Sidekiq::Pro)` so it tracks the actual
25
+ # capability across Pro versions. Cheap (no Redis), so it isn't memoized —
26
+ # loading Pro mid-process would otherwise be missed.
27
+ def native?
28
+ defined?(::Sidekiq::Queue) && ::Sidekiq::Queue.method_defined?(:pause!)
29
+ end
30
+
31
+ # Under Pro, go through Sidekiq::Queue#pause! rather than writing PRO_KEY
32
+ # ourselves: Pro's fetchers read that set once at startup and afterwards only
33
+ # update on the "pro:config" pubsub message that pause! publishes. A bare
34
+ # SADD would leave running workers pulling the queue until they restarted.
16
35
  def pause!(queue)
36
+ return ::Sidekiq::Queue.new(queue.to_s).pause! if native?
37
+
17
38
  Sidekiq.redis { |conn| conn.call("SADD", KEY, queue.to_s) }
18
39
  end
19
40
 
20
41
  def unpause!(queue)
42
+ return ::Sidekiq::Queue.new(queue.to_s).unpause! if native?
43
+
21
44
  Sidekiq.redis { |conn| conn.call("SREM", KEY, queue.to_s) }
22
45
  end
23
46
 
24
47
  def paused?(queue)
25
- Sidekiq.redis { |conn| conn.call("SISMEMBER", KEY, queue.to_s) } == 1
48
+ Sidekiq.redis { |conn| conn.call("SISMEMBER", key, queue.to_s) } == 1
26
49
  end
27
50
 
28
51
  def paused_queues
29
- Sidekiq.redis { |conn| conn.call("SMEMBERS", KEY) }.sort
52
+ Sidekiq.redis { |conn| conn.call("SMEMBERS", key) }.sort
30
53
  end
31
54
 
32
55
  def paused_set
33
- Set.new(Sidekiq.redis { |conn| conn.call("SMEMBERS", KEY) })
56
+ Set.new(Sidekiq.redis { |conn| conn.call("SMEMBERS", key) })
57
+ end
58
+
59
+ # Which registry reads come from. Reads are plain set lookups in both cases
60
+ # (no pubsub involved), so they can share one implementation.
61
+ def key
62
+ native? ? PRO_KEY : KEY
34
63
  end
35
64
 
36
65
  # Given the redis queue keys BasicFetch would poll (e.g. "queue:default"),
@@ -52,7 +81,13 @@ module RoundhouseUi
52
81
 
53
82
  # True when a RoundhouseUi::Fetch has reported in recently — i.e. pausing
54
83
  # will take effect. When false, the UI warns instead of pretending.
84
+ #
85
+ # Under Pro no beacon is needed: Pro prepends pause support onto
86
+ # Sidekiq::BasicFetch (and SuperFetch honors it too), so any Pro worker
87
+ # enforces pauses whether or not our fetcher is installed.
55
88
  def fetch_installed?
89
+ return true if native?
90
+
56
91
  Sidekiq.redis { |conn| conn.call("EXISTS", FETCH_FLAG) } == 1
57
92
  end
58
93
  end
@@ -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.0"
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.0
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-24 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