roundhouse_ui 0.9.1 → 0.11.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 (63) hide show
  1. checksums.yaml +4 -4
  2. data/README.md +620 -46
  3. data/app/controllers/concerns/roundhouse_ui/job_set_browsing.rb +246 -12
  4. data/app/controllers/roundhouse_ui/application_controller.rb +74 -0
  5. data/app/controllers/roundhouse_ui/audit_controller.rb +1 -0
  6. data/app/controllers/roundhouse_ui/busy_controller.rb +25 -5
  7. data/app/controllers/roundhouse_ui/dashboard_controller.rb +27 -3
  8. data/app/controllers/roundhouse_ui/dead_controller.rb +27 -10
  9. data/app/controllers/roundhouse_ui/errors_controller.rb +60 -1
  10. data/app/controllers/roundhouse_ui/jobs_controller.rb +5 -1
  11. data/app/controllers/roundhouse_ui/queues_controller.rb +57 -6
  12. data/app/controllers/roundhouse_ui/recurring_controller.rb +12 -0
  13. data/app/controllers/roundhouse_ui/retries_controller.rb +34 -10
  14. data/app/controllers/roundhouse_ui/scheduled_controller.rb +4 -7
  15. data/app/controllers/roundhouse_ui/settings_controller.rb +15 -0
  16. data/app/controllers/roundhouse_ui/snapshots_controller.rb +5 -6
  17. data/app/controllers/roundhouse_ui/workers_controller.rb +2 -7
  18. data/app/helpers/roundhouse_ui/application_helper.rb +293 -0
  19. data/app/helpers/roundhouse_ui/nav_helper.rb +3 -3
  20. data/app/helpers/roundhouse_ui/observability_helper.rb +121 -12
  21. data/app/helpers/roundhouse_ui/tags_helper.rb +128 -0
  22. data/app/views/layouts/roundhouse_ui/application.html.erb +1277 -84
  23. data/app/views/roundhouse_ui/busy/index.html.erb +30 -6
  24. data/app/views/roundhouse_ui/dashboard/show.html.erb +62 -17
  25. data/app/views/roundhouse_ui/dead/index.html.erb +54 -32
  26. data/app/views/roundhouse_ui/errors/index.html.erb +51 -11
  27. data/app/views/roundhouse_ui/jobs/show.html.erb +8 -3
  28. data/app/views/roundhouse_ui/metrics/show.html.erb +26 -3
  29. data/app/views/roundhouse_ui/queues/index.html.erb +54 -10
  30. data/app/views/roundhouse_ui/queues/show.html.erb +55 -0
  31. data/app/views/roundhouse_ui/recurring/index.html.erb +71 -0
  32. data/app/views/roundhouse_ui/retries/index.html.erb +30 -17
  33. data/app/views/roundhouse_ui/scheduled/index.html.erb +17 -11
  34. data/app/views/roundhouse_ui/settings/show.html.erb +91 -0
  35. data/app/views/roundhouse_ui/shared/_filter_error.html.erb +23 -0
  36. data/app/views/roundhouse_ui/shared/_pager.html.erb +2 -2
  37. data/app/views/roundhouse_ui/shared/_search_bar.html.erb +55 -0
  38. data/app/views/roundhouse_ui/shared/_search_help.html.erb +37 -0
  39. data/app/views/roundhouse_ui/shared/_tag_filter.html.erb +28 -0
  40. data/app/views/roundhouse_ui/shared/bulk_preview.html.erb +69 -0
  41. data/config/routes.rb +9 -0
  42. data/lib/roundhouse_ui/backends/sidekiq.rb +124 -5
  43. data/lib/roundhouse_ui/backends/solid_queue.rb +54 -3
  44. data/lib/roundhouse_ui/demo.rb +130 -0
  45. data/lib/roundhouse_ui/error_groups.rb +25 -4
  46. data/lib/roundhouse_ui/filter_query.rb +504 -0
  47. data/lib/roundhouse_ui/health.rb +43 -4
  48. data/lib/roundhouse_ui/history.rb +54 -0
  49. data/lib/roundhouse_ui/icons.rb +104 -0
  50. data/lib/roundhouse_ui/marks/datadog-lockup-white.svg +30 -0
  51. data/lib/roundhouse_ui/marks/datadog-lockup.svg +41 -0
  52. data/lib/roundhouse_ui/observability.rb +55 -7
  53. data/lib/roundhouse_ui/pause.rb +6 -11
  54. data/lib/roundhouse_ui/queue_summary.rb +11 -0
  55. data/lib/roundhouse_ui/recurring.rb +137 -0
  56. data/lib/roundhouse_ui/runbooks.rb +75 -0
  57. data/lib/roundhouse_ui/snapshots.rb +13 -2
  58. data/lib/roundhouse_ui/tags.rb +112 -0
  59. data/lib/roundhouse_ui/theme.rb +298 -0
  60. data/lib/roundhouse_ui/version.rb +1 -1
  61. data/lib/roundhouse_ui.rb +235 -5
  62. data/lib/tasks/roundhouse_ui_tasks.rake +84 -4
  63. metadata +32 -7
@@ -5,19 +5,100 @@ module RoundhouseUi
5
5
  extend ActiveSupport::Concern
6
6
 
7
7
  PER_PAGE = 25
8
+ # The longest needle worth honouring. A megabyte of `q` against twenty thousand
9
+ # entries took five seconds in one request — the substring scan is linear in
10
+ # both, so the needle is a free multiplier on someone else's CPU. Longer than
11
+ # any error message anyone searches for, and checked before any comparison
12
+ # runs (the same ordering as MAX_JOB_CLASS_NAME in lib/roundhouse_ui.rb).
13
+ # The parser owns the bound now — one definition, checked before any character
14
+ # of the input is inspected. Kept as a name because tests and the read-only
15
+ # guard refer to it.
16
+ MAX_QUERY_LENGTH = FilterQuery::MAX_LENGTH
8
17
  BULK_CAP = 1_000 # safety ceiling on a single match-set action
18
+ # `unfiltered` means "this action was not authorised". `reason` says why, in the
19
+ # words the operator should read — it was duplicated verbatim in two controllers.
20
+ Matched = Struct.new(:entries, :capped, :unfiltered, :reason, keyword_init: true)
21
+
22
+ NO_FILTER = "Refused: a bulk action needs a filter. Without one it would act on " \
23
+ "every job in the set, which is not what this control is for.".freeze
24
+
25
+ # Every filter, once per request, rather than re-derived in each action. The
26
+ # queue filter was already assigned in seven places across three controllers;
27
+ # class and error would have made that twenty-one, and a browse that read one
28
+ # filter while its bulk counterpart read another is the failure this whole
29
+ # file is arranged to prevent.
30
+ # Every filter this concern understands. One list, so "all of them" is a thing
31
+ # the code can say.
32
+ #
33
+ # Read from all five, written back as one. `?q=` is now the whole filter —
34
+ # `q=class=EmbeddingWorker error=KeyError stripe` — so a link, a form and a
35
+ # bookmark carry a single value that either travels or doesn't. It used to be
36
+ # five, and "the confirm form carried four of the five" is precisely how a dry
37
+ # run listing two jobs deleted five. The other four survive as a read-only
38
+ # legacy shape (FilterQuery.from_params), because somebody has them bookmarked.
39
+ FILTER_KEYS = %i[q tag queue class error].freeze
40
+
41
+ included do
42
+ before_action :load_filters
43
+ helper_method :active_filters
44
+ end
45
+
46
+ # The filters currently in force, as URL params. THE single serialization
47
+ # point: every URL and form that must preserve the filter starts from all of it
48
+ # and names only what it changes, so dropping one is not expressible.
49
+ #
50
+ # This was hand-enumerated at six sites. Adding the class/error pair updated
51
+ # three of them, and the confirm form was one of the three that were missed —
52
+ # so a dry run listing two jobs POSTed a request that deleted five, and
53
+ # reported "Deleted 5 matching job(s)" as if that had been approved. The
54
+ # comment above bulk_matches promises the dry run and the action "cannot
55
+ # disagree about what matching means". They could, because they were handed
56
+ # different filters.
57
+ def active_filters
58
+ { q: filter.to_s.presence }.compact
59
+ end
60
+
61
+ # One parse per request, and everything else reads off it. The ivars below are
62
+ # kept because entry_selected? and TagsHelper are written against them; they are
63
+ # now views onto @filter rather than five independent reads of params, so
64
+ # "the browse read one filter while its bulk counterpart read another" is no
65
+ # longer a shape this code can take.
66
+ def load_filters
67
+ @filter = FilterQuery.from_params(params)
68
+ @query = @filter.text
69
+ @tag = @filter.tag_pair
70
+ end
71
+
72
+ # The parsed filter, never nil. entry_selected? is reachable without going
73
+ # through load_filters — the real-Redis tests drive bulk_apply directly, and so
74
+ # could a future action — and a NoMethodError inside the scan predicate is a 500
75
+ # on a page that was only browsing. FilterQuery.none matches everything, which is
76
+ # the same thing "no filter" has always meant here; bulk stays gated because
77
+ # bulk_filter_present? finds nothing to narrow on.
78
+ def filter = @filter ||= FilterQuery.none
79
+
80
+ # A refused query selects nothing, rather than being truncated to something
81
+ # shorter that would select MORE. Truncation is the tempting fix and the wrong
82
+ # one: it silently widens, and this predicate drives Delete.
83
+ def query_refused? = filter.invalid?
9
84
 
10
85
  # Returns [entries_for_page, has_next?]. Scans only far enough to fill the
11
86
  # requested page plus one (to know if a next page exists) — never loads the
12
87
  # whole set, so a 50k dead set stays cheap to page through.
13
- def browse(set, query, page, per = PER_PAGE)
88
+ # `tag=key:value` inside ?q= — exact, against a host-defined tag (ADR 0002).
89
+ # Tag values are also in the free-text haystack; safe only because browse and
90
+ # bulk_apply share this one predicate.
91
+ def tag_filter = filter.tag_pair
92
+
93
+ def browse(set, query, page, per = PER_PAGE, tag: nil)
14
94
  start = (page - 1) * per
15
95
  jobs = []
16
96
  has_next = false
17
97
  matched = 0
98
+ cache = tag_cache_for(tag)
18
99
 
19
100
  set.each do |entry|
20
- next if query.present? && !entry_matches?(entry, query)
101
+ next unless entry_selected?(entry, query, tag, cache)
21
102
 
22
103
  if matched < start
23
104
  matched += 1
@@ -36,26 +117,179 @@ module RoundhouseUi
36
117
  # Apply an op ("retry"/"delete") to every entry matching the query, capped at
37
118
  # BULK_CAP. Entries are collected first, then acted on — mutating a Sidekiq set
38
119
  # mid-iteration skips entries. Returns [count_acted_on, capped?].
39
- def bulk_apply(set, query, op, cap = BULK_CAP)
40
- matches = []
120
+ # The same scan the action runs, stopped one step early, so a dry run and the
121
+ # action it confirms cannot disagree about what "matching" means.
122
+ # Is any filter active? The single source of truth for "this bulk action has a
123
+ # scope". tags_helper's any_filter? delegates here rather than recomputing it —
124
+ # the view and the route disagreeing is exactly how the hole below happened.
125
+ def bulk_filter_present?(query, tag)
126
+ return false if query_refused?
127
+ # A DEGRADED query dropped a facet it could not use. Browse proceeds on what
128
+ # survived — that is the point of dropping rather than refusing — but what
129
+ # survived selects a SUPERSET of what was typed. `tag=garbage queue=default`
130
+ # becomes `queue=default`, so a Delete here would take the whole queue while
131
+ # the operator believed the tag narrowed it too. Browse and bulk still read
132
+ # one identical filter; bulk just declines to act on a widened one.
133
+ return false if filter.degraded?
134
+
135
+ # Reads the SAME parse entry_selected? reads. It used to read @class_filter and
136
+ # friends while the predicate read the FilterQuery, and the two diverged the
137
+ # moment wildcards moved the predicate over: a class-filtered bulk delete found
138
+ # a filter here, found none in the predicate, and took every row in the set.
139
+ # Caught by test_the_class_filter_alone_still_spares_a_longer_name. Those ivars
140
+ # are gone now, so the two cannot be given different answers.
141
+ query.present? || !tag.nil? || filter.any_facets?
142
+ end
143
+
144
+ # Why a bulk action was not authorised, in the words to show the operator.
145
+ def bulk_refusal_reason
146
+ return "Refused: that search was not understood, so it selects nothing. #{filter.message}" if query_refused?
147
+ return "Refused: #{filter.notes.join(' ')} Fix the search and the bulk actions come back — " \
148
+ "acting now would touch every job the dropped filter would have excluded." if filter.degraded?
149
+
150
+ NO_FILTER
151
+ end
152
+
153
+ def bulk_matches(set, query, cap = BULK_CAP, tag: nil)
154
+ # An unfiltered bulk action selects EVERY entry: entry_selected? finds no
155
+ # filter to fail, `"".present?` is false, and `return true if tag.nil?` does
156
+ # the rest. So POST /dead/bulk_all with nothing but op=delete emptied the set,
157
+ # up to the cap, and reported "Deleted 50 matching job(s)" as if that were
158
+ # the request. Verified against a real Redis before this guard existed.
159
+ #
160
+ # The comment above bulk_all claimed it was "only offered when a filter is
161
+ # active" — and it was only OFFERED that way. The button was hidden by the
162
+ # view while the route stayed open. The guard belongs here, at the one place
163
+ # both the dry run and the action pass through, not in a before_action that
164
+ # the next destructive action can forget to add.
165
+ unless bulk_filter_present?(query, tag)
166
+ return Matched.new(entries: [], capped: false, unfiltered: true, reason: bulk_refusal_reason)
167
+ end
168
+
169
+ entries = []
41
170
  capped = false
171
+ cache = tag_cache_for(tag)
42
172
  set.each do |entry|
43
- next if query.present? && !entry_matches?(entry, query)
173
+ next unless entry_selected?(entry, query, tag, cache)
44
174
 
45
- matches << entry
46
- if matches.size >= cap
175
+ entries << entry
176
+ if entries.size >= cap
47
177
  capped = true
48
178
  break
49
179
  end
50
180
  end
51
- matches.each { |entry| op == "delete" ? entry.delete : entry.retry }
52
- [ matches.size, capped ]
181
+ Matched.new(entries: entries, capped: capped)
182
+ end
183
+
184
+ def bulk_apply(set, query, op, cap = BULK_CAP, tag: nil)
185
+ found = bulk_matches(set, query, cap, tag: tag)
186
+ return found if found.unfiltered
187
+
188
+ found.entries.each { |entry| op == "delete" ? entry.delete : entry.retry }
189
+ found
190
+ end
191
+
192
+ # Both the browse and bulk paths run every candidate through this, so the
193
+ # rows an operator sees are exactly the rows a bulk action will touch —
194
+ # including when a tag value is what matched the free-text search.
195
+ def entry_selected?(entry, query, tag, cache)
196
+ return false if query_refused?
197
+ # Compared through the filter, not with ==, so `class=Roundhouse%` narrows here
198
+ # exactly as it does on the dry run and the bulk action — all three read this
199
+ # one predicate. Without a `%` it is still a plain equality: `queue=default`
200
+ # must never also select `default_low`.
201
+ return false unless filter.matches_facet?(:queue, entry.queue)
202
+ return false unless filter.matches_facet?(:klass, RoundhouseUi.unwrapped_class(entry.klass, entry.item))
203
+ return false unless filter.matches_facet?(:error, entry.item["error_class"])
204
+
205
+ tags = entry_tags(entry, cache)
206
+ return false if query.present? && !entry_matches?(entry, query, tags)
207
+ return true if tag.nil?
208
+
209
+ entry_tagged?(tags, tag)
210
+ end
211
+
212
+ # `?queue=name` — exact match, so clicking a queue pill or picking one from
213
+ # the palette narrows to that queue. Exact rather than substring because
214
+ # this feeds bulk_apply too, and "default" must never also select
215
+ # "default_low".
216
+ def queue_filter = filter.queue
217
+
218
+ # `class=` and `error=` — the pair behind "find more like this", and typeable.
219
+ # Exact unless you add a %: these render the "delete all matching" buttons
220
+ # beneath them, and a bare substring would select jobs whose ARGUMENTS merely
221
+ # mention the class.
222
+ #
223
+ # Class is compared unwrapped, so the filter means the same string the row
224
+ # displays and the same one the Errors page groups by — one definition of
225
+ # "the same problem" across the console.
226
+ def class_filter = filter.klass
227
+
228
+ def error_filter = filter.error
229
+
230
+ def entry_tagged?(tags, (key, value))
231
+ # A declared vocabulary is authoritative: filtering on a key the host
232
+ # never declared matches nothing rather than everything.
233
+ declared = Tags.filters
234
+ return false if declared && !declared.key?(key)
235
+
236
+ # `tag=squad:plat%` wildcards the VALUE only. The key stays exact, because it
237
+ # is checked against the declared vocabulary above and a wildcarded key would
238
+ # walk straight past that check.
239
+ pattern = FilterQuery::Pattern.for(value)
240
+ return pattern.match?(tags[key.to_s]) if pattern
241
+
242
+ Tags.match?(tags, key, value)
243
+ end
244
+
245
+ def entry_tags(entry, cache)
246
+ return Tags::EMPTY unless RoundhouseUi.job_tags
247
+
248
+ Tags.for(klass: entry.klass, item: entry.item, cache: cache)
249
+ end
250
+
251
+ # Shares the request memo with TagsHelper — controller ivars carry into the
252
+ # view, so an entry resolved while scanning is not resolved again when its
253
+ # badge renders. Tags.for picks the key: class name normally, jid in per-job
254
+ # mode.
255
+ def tag_cache_for(_tag)
256
+ @rh_tag_cache ||= {}
53
257
  end
54
258
 
55
- def entry_matches?(entry, query)
259
+ # Tag values are part of the haystack, so typing a squad name finds its jobs
260
+ # without reaching for the structured filter. Safe to widen here only because
261
+ # browse and bulk_apply share this predicate — if they diverged, a search
262
+ # would show one set of rows and "delete all matching" would act on another.
263
+ def entry_matches?(entry, query, tags = Tags::EMPTY)
56
264
  needle = query.downcase
57
- [ entry.klass, entry.jid, entry.item["error_class"], entry.item["error_message"], entry.args.to_s ]
58
- .any? { |hay| hay.to_s.downcase.include?(needle) }
265
+ # Queue matches on equality, not substring: typing a queue name should
266
+ # find its jobs, but this predicate also drives bulk_apply, so "default"
267
+ # must never additionally select "default_low".
268
+ return true if entry.queue.to_s.downcase == needle
269
+
270
+ # The unwrapped class is added rather than substituted. Since bulk actions
271
+ # run through this same predicate, replacing would silently empty a saved
272
+ # or habitual "JobWrapper" query — safe in direction, but a bulk query
273
+ # that used to select thousands would quietly select none. Appending only
274
+ # ever widens, and the real class name starts working.
275
+ item = entry.item
276
+ cheap = [ entry.klass, RoundhouseUi.unwrapped_class(entry.klass, item), entry.jid,
277
+ item["error_class"], item["error_message"], *tags.values ]
278
+ return true if cheap.any? { |hay| hay.to_s.downcase.include?(needle) }
279
+
280
+ # Arguments are searched REDACTED — exactly as they are displayed.
281
+ #
282
+ # Searching the raw values turned this box into an oracle. The UI masks
283
+ # api_token, but `q=sk_live_S` matched and `q=sk_live_X` did not, so a secret
284
+ # could be read out one character at a time by someone who can see the console
285
+ # and not the secrets — which is the whole population redact_args exists for.
286
+ # A sixteen-character token falls in a couple of hundred queries, and the same
287
+ # needle scopes a bulk delete, so the oracle worked through the dry run too.
288
+ #
289
+ # Also computed last, and only if the cheap fields missed. It used to be built
290
+ # eagerly into the array above, so every entry paid for stringifying its
291
+ # arguments whether or not anything else had already matched.
292
+ Redaction.apply(entry.args).to_s.downcase.include?(needle)
59
293
  end
60
294
  end
61
295
  end
@@ -5,14 +5,58 @@ 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
 
11
+ # Forgery protection, shipped rather than inherited. ActionController::Base
12
+ # only carries this because the host's `config.load_defaults` (5.2+) put it
13
+ # there, so an app on older defaults mounts a console where every
14
+ # destructive POST is forgeable — while our own README promises the
15
+ # opposite. Same argument as the CSP below: the engine states its security
16
+ # posture instead of hoping the host set one. AssetsController opts out,
17
+ # deliberately and in writing.
18
+ protect_from_forgery with: :exception
19
+
10
20
  # Self-contained CSP, set per-request on our own responses so Roundhouse is
11
21
  # safe to mount even when the host sets no policy — and never weakens one it
12
22
  # does (this header only applies to engine responses). Strict default; we
13
23
  # enumerate exactly what our pages use (same-origin only, nonce'd inline JS).
14
24
  after_action :set_content_security_policy
15
25
 
26
+ # Read-only enforcement, fail closed. Every POST is treated as a write unless
27
+ # its controller says otherwise, so a destructive action added tomorrow is
28
+ # guarded the moment it exists rather than the moment someone remembers a
29
+ # before_action. This used to be seven near-identical `require_writable!`
30
+ # methods, each wired to a hand-maintained `only:` list — all seven correct,
31
+ # and all seven one omission away from silently not being.
32
+ #
33
+ # `guard_in_read_only` covers the dry-run GETs: a preview shows what a bulk
34
+ # action would do, and is gated with the action it previews.
35
+ class_attribute :read_only_exempt_actions, default: [].freeze, instance_writer: false
36
+ class_attribute :read_only_extra_actions, default: [].freeze, instance_writer: false
37
+
38
+ def self.allow_in_read_only(*actions)
39
+ self.read_only_exempt_actions = actions.map(&:to_s).freeze
40
+ end
41
+
42
+ def self.guard_in_read_only(*actions)
43
+ self.read_only_extra_actions = actions.map(&:to_s).freeze
44
+ end
45
+
46
+ before_action :require_writable!, if: :read_only_guarded_action?
47
+
48
+ # Capability gating, same shape as the read-only guard above. A control the
49
+ # backend cannot deliver is hidden in the view AND refused at the route —
50
+ # hiding the button while leaving the route open is how an ungated bulk_all
51
+ # emptied a set once already.
52
+ class_attribute :capability_gates, default: {}.freeze, instance_writer: false
53
+
54
+ def self.requires_capability(capability, only:)
55
+ self.capability_gates = capability_gates.merge(capability => Array(only).map(&:to_s)).freeze
56
+ end
57
+
58
+ before_action :require_capability!, unless: -> { self.class.capability_gates.empty? }
59
+
16
60
  # Record every state-changing (POST) action. Actions halted by a
17
61
  # before_action (e.g. read-only mode) never reach here, so we only log what
18
62
  # actually ran.
@@ -42,6 +86,36 @@ module RoundhouseUi
42
86
  RoundhouseUi.backend
43
87
  end
44
88
 
89
+ def read_only_guarded_action?
90
+ return false if self.class.read_only_exempt_actions.include?(action_name)
91
+
92
+ request.post? || self.class.read_only_extra_actions.include?(action_name)
93
+ end
94
+
95
+ def require_capability!
96
+ missing = self.class.capability_gates.find do |capability, actions|
97
+ actions.include?(action_name) && !backend.supports?(capability)
98
+ end
99
+ return if missing.nil?
100
+
101
+ redirect_to root_path,
102
+ alert: "#{missing.first.to_s.tr('_', ' ').capitalize} is not available on this backend."
103
+ end
104
+
105
+ def require_writable!
106
+ return unless RoundhouseUi.read_only
107
+
108
+ redirect_to read_only_redirect_path,
109
+ alert: "Roundhouse is in read-only mode — this action is disabled."
110
+ end
111
+
112
+ # Where someone lands when a write is refused. The buttons still render in
113
+ # read-only mode, so this fires on an ordinary click and the destination is
114
+ # worth getting right; sections override it to send you back where you were.
115
+ def read_only_redirect_path
116
+ root_path
117
+ end
118
+
45
119
  def record_audit_event
46
120
  target = params[:name] || params[:jid] || params[:id] || params[:job_class] ||
47
121
  (params[:jids].presence && "#{Array(params[:jids]).size} jobs") || params[:op]
@@ -1,5 +1,6 @@
1
1
  module RoundhouseUi
2
2
  class AuditController < ApplicationController
3
+ requires_capability :redis, only: %i[index]
3
4
  def index
4
5
  @entries = RoundhouseUi::Audit.recent
5
6
  end
@@ -1,15 +1,20 @@
1
1
  module RoundhouseUi
2
- # What's executing right now, from Sidekiq::WorkSet — the live in-flight jobs
2
+ # What's executing right now, via the backend port — the live in-flight jobs
3
3
  # Sidekiq Web calls "Busy". Surfaces long-running (possibly hung) jobs, which
4
4
  # the stock UI makes you eyeball.
5
5
  class BusyController < ApplicationController
6
6
  LONG_RUNNING = 60 # seconds
7
7
 
8
- before_action :require_writable!, only: :cancel
8
+ before_action :require_cancellable!, only: :cancel
9
9
 
10
10
  def index
11
11
  @threshold = LONG_RUNNING
12
12
  @work = backend.busy
13
+ # "4m 12s" means nothing on its own — fine for a nightly rollup, a hang for
14
+ # a webhook. DurationCollector already records the average per class, so
15
+ # where it is enabled the page can say "×8 typical" instead. Empty
16
+ # otherwise, and the view degrades to elapsed with no ceiling.
17
+ @typical = typical_durations
13
18
  end
14
19
 
15
20
  def cancel
@@ -19,9 +24,24 @@ module RoundhouseUi
19
24
 
20
25
  private
21
26
 
22
- def require_writable!
23
- return unless RoundhouseUi.read_only
24
- redirect_to busy_path, alert: "Roundhouse is in read-only mode — cancellation is disabled."
27
+ def read_only_redirect_path = busy_path
28
+
29
+ # Average seconds per class, keyed by the real job class so an ActiveJob
30
+ # wrapper does not collapse every mailer into one baseline.
31
+ def typical_durations
32
+ return {} unless RoundhouseUi.collect_durations
33
+
34
+ DurationCollector.summary.to_h { |d| [ d[:klass].to_s, d[:avg_ms].to_f / 1000.0 ] }
35
+ rescue StandardError
36
+ {}
37
+ end
38
+
39
+ # Hiding the button is presentation; this is the control. Without a backend
40
+ # that can cancel and a host-side check that reads the flag, cancel! only
41
+ # writes a JID nobody consumes.
42
+ def require_cancellable!
43
+ return if RoundhouseUi.cancel_enabled && backend.supports?(:cancel)
44
+ redirect_to busy_path, alert: "Cancellation is not enabled — see RoundhouseUi.cancel_enabled."
25
45
  end
26
46
  end
27
47
  end
@@ -1,21 +1,37 @@
1
1
  module RoundhouseUi
2
- # The dashboard reads straight from Sidekiq's API — no database, no models.
2
+ # The dashboard reads through the backend port — no models of ours, no storage of ours.
3
3
  # Everything here comes out of Redis via Sidekiq::Stats / Sidekiq::Queue.
4
4
  class DashboardController < ApplicationController
5
5
  def show
6
6
  @stats = backend.stats
7
- @queues = backend.queues
7
+ # queue_summaries, not queues: the raw objects differ per backend and older
8
+ # SolidQueue::Queue has no #latency, so the dashboard 500'd on solid_queue 1.0.0
9
+ # as soon as one queue existed. Summaries are ours, batched, and uniform.
10
+ @queues = backend.queue_summaries
8
11
  @metrics = Metrics.new(stats: @stats)
9
12
  @health = Health.new(stats: @stats, queues: @queues, metrics: @metrics)
10
13
  # Highest-signal slices for the overview, from data we already read.
11
14
  @top_errors = ErrorGroups.new(limit: 200).call.groups.first(5)
12
15
  @problem_queues = @queues.select { |q| q.latency > 5 }.sort_by { |q| -q.latency }.first(5)
16
+ # Daily counts Sidekiq already keeps (#61). One read on render, not on the
17
+ # poll — a six-month window is not something to re-fetch every few seconds.
18
+ @history_days = History.clamp(params[:history].presence || History::DEFAULT_DAYS)
19
+ @history = History.days(@history_days)
20
+ @typical_failure_rate = History.typical_failure_rate(@history)
13
21
  end
14
22
 
15
23
  # Polled by the dashboard for live counts (same approach Sidekiq Web uses —
16
24
  # cheap JSON, no WebSocket/build step required).
17
25
  def stats
18
26
  s = backend.stats
27
+ # queues was already loaded here for the count, so the names ride along
28
+ # free — the command palette uses them to tell a queue name from a plain
29
+ # search term without adding a Redis call to every page render.
30
+ # queue_summaries, not queues: Sidekiq::Queue#size issues its own LLEN, so
31
+ # reading a depth per queue off `queues` would put one round-trip per queue
32
+ # on the endpoint every open tab hits every few seconds — the same N+1 that
33
+ # was removed from the Queues page. This is two round-trips at any queue count.
34
+ qs = backend.queue_summaries
19
35
  render json: {
20
36
  processed: s.processed,
21
37
  failed: s.failed,
@@ -24,7 +40,15 @@ module RoundhouseUi
24
40
  scheduled: s.scheduled_size,
25
41
  retries: s.retry_size,
26
42
  dead: s.dead_size,
27
- queues: backend.queues.size
43
+ queues: qs.size,
44
+ # Names ride along as the keys. They used to be sent separately as well,
45
+ # which meant every queue name appeared twice in one response — 40KB per
46
+ # poll per open tab on a 500-queue app, half of it duplicated.
47
+ queue_depths: qs.to_h { |q| [ q.name, q.size ] },
48
+ # Total worker threads, for the capacity figure (#36). workers_size is
49
+ # threads *busy right now*, which is a different number and goes to zero
50
+ # on an idle fleet — dividing by it would claim infinite capacity.
51
+ concurrency: backend.respond_to?(:concurrency) ? backend.concurrency : nil
28
52
  }
29
53
  end
30
54
  end
@@ -2,13 +2,12 @@ module RoundhouseUi
2
2
  class DeadController < ApplicationController
3
3
  include JobSetBrowsing
4
4
 
5
- before_action :require_writable!, only: %i[requeue destroy bulk bulk_all]
5
+ guard_in_read_only :preview
6
6
 
7
7
  def index
8
- @query = params[:q].to_s.strip
9
8
  @page = [ params[:page].to_i, 1 ].max
10
9
  @total = backend.dead_set.size
11
- @jobs, @has_next = browse(backend.dead_set, @query, @page)
10
+ @jobs, @has_next = browse(backend.dead_set, @query, @page, PER_PAGE, tag: @tag)
12
11
  end
13
12
 
14
13
  def requeue
@@ -40,18 +39,36 @@ module RoundhouseUi
40
39
  # selected/visible ones), capped for safety. Only offered when a filter is
41
40
  # active, so it can't become "retry the entire dead set" by accident.
42
41
  def bulk_all
43
- count, capped = bulk_apply(backend.dead_set, params[:q].to_s.strip, params[:op])
42
+ found = bulk_apply(backend.dead_set, @query, params[:op], BULK_CAP, tag: @tag)
43
+
44
+ # An unfiltered bulk_all selected every entry and reported it as a match.
45
+ # The comment above claimed this was "only offered when a filter is active" —
46
+ # and it was only OFFERED that way; the route had no gate. bulk_matches now
47
+ # refuses at the chokepoint, and this says so out loud rather than reporting
48
+ # "Deleted 0 matching job(s)", which would read like an empty set.
49
+ if found.unfiltered
50
+ return redirect_to dead_set_path, alert: found.reason
51
+ end
52
+
44
53
  verb = params[:op] == "delete" ? "Deleted" : "Re-enqueued"
45
- note = "#{verb} #{count} matching job(s)."
46
- note += " Stopped at the #{JobSetBrowsing::BULK_CAP} cap — run again for more." if capped
54
+ note = "#{verb} #{found.entries.size} matching job(s)."
55
+ note += " Stopped at the #{JobSetBrowsing::BULK_CAP} cap — run again for more." if found.capped
47
56
  redirect_to dead_set_path, notice: note
48
57
  end
49
58
 
59
+ # A dry run: the count tells you how many match, this tells you which (#37).
60
+ def preview
61
+ @op = params[:op] == "delete" ? "delete" : "retry"
62
+ @matched = bulk_matches(backend.dead_set, @query, JobSetBrowsing::BULK_CAP, tag: @tag)
63
+ @confirm_path = bulk_all_dead_path
64
+ @back_path = dead_set_path
65
+ @set = "dead"
66
+ @noun = "dead job"
67
+ render "roundhouse_ui/shared/bulk_preview"
68
+ end
69
+
50
70
  private
51
71
 
52
- def require_writable!
53
- return unless RoundhouseUi.read_only
54
- redirect_to dead_set_path, alert: "Roundhouse is in read-only mode — retry and delete are disabled."
55
- end
72
+ def read_only_redirect_path = dead_set_path
56
73
  end
57
74
  end
@@ -3,11 +3,70 @@ module RoundhouseUi
3
3
  # (job class + error class) — so one bad deploy reads as a single issue with
4
4
  # a count, not five thousand identical rows. The aggregation Sidekiq Web lacks.
5
5
  class ErrorsController < ApplicationController
6
+ # The facets a GROUP can be narrowed by. No queue=: a klass|error group spans
7
+ # every queue its jobs were enqueued on, so there is nothing here for it to
8
+ # apply to — and a pill that filters nothing is the phantom filter the bar
9
+ # exists to prevent. The parser refuses it and says which page it works on.
10
+ FILTER_KEYS = %w[class error tag text].freeze
11
+
6
12
  def index
7
- @query = params[:q].to_s.strip
13
+ @filter = FilterQuery.from_params(params, keys: FILTER_KEYS)
14
+ @query = @filter.text
8
15
  @scan_limit = ErrorGroups::DEFAULT_SCAN_LIMIT
9
16
  result = ErrorGroups.new(query: @query).call
10
17
  @groups, @scanned, @truncated = result.groups, result.scanned, result.truncated
18
+
19
+ # Tags resolve per group rather than per entry: every row in a
20
+ # klass|error group shares a class, so a class-derived tag is constant
21
+ # for the group. Counted before filtering, so the quick-filter strip
22
+ # keeps showing every squad's total while one of them is selected.
23
+ @group_tags = @groups.to_h { |g| [ g[:klass], Tags.for(klass: g[:klass], item: {}, cache: tag_cache) ] }
24
+ @tag_counts = tag_counts(@groups)
25
+ @tag = tag_filter
26
+ @groups = @groups.select { |g| group_tagged?(g, *@tag) } if @tag
27
+
28
+ # class= and error= match EXACTLY, the same as on the job sets, so a funnel
29
+ # clicked there and a facet typed here mean one thing. Filtered in memory:
30
+ # grouping has already read the entries, so this costs no extra Redis work.
31
+ #
32
+ # A refused query selects nothing rather than everything — the same direction
33
+ # the job sets fail in. Errors has no destructive action, but "nothing matched"
34
+ # must not read as "no such failures exist", which is why the banner renders.
35
+ @groups = [] if @filter.invalid?
36
+ @groups = @groups.select { |g| @filter.matches_facet?(:klass, g[:klass]) }
37
+ @groups = @groups.select { |g| @filter.matches_facet?(:error, g[:error]) }
38
+ end
39
+
40
+ private
41
+
42
+ # `?tag=key:value`, read off the one parse like everywhere else rather than
43
+ # re-split from params — two readings of one filter is how a page comes to show
44
+ # one scope and act on another.
45
+ def tag_filter = @filter.tag_pair
46
+
47
+ # Fail CLOSED on an undeclared key, like the job sets do. Returning nil here meant
48
+ # no filter applied, so `?tag=nosuchkey` listed every issue while the bar showed a
49
+ # tag pill — the README promises the opposite.
50
+ def group_tagged?(group, key, value)
51
+ declared = Tags.filters
52
+ return false if declared && !declared.key?(key)
53
+
54
+ Tags.match?(@group_tags[group[:klass]], key, value)
55
+ end
56
+
57
+ # { "squad" => { "core" => 4, "training" => 5 } } — the counts behind the
58
+ # quick-filter strip, from groups already scanned, so this costs no extra
59
+ # Redis work.
60
+ def tag_counts(groups)
61
+ counts = Hash.new { |h, k| h[k] = Hash.new(0) }
62
+ groups.each do |g|
63
+ @group_tags[g[:klass]].each { |key, value| counts[key][value] += g[:count] }
64
+ end
65
+ counts
66
+ end
67
+
68
+ def tag_cache
69
+ @rh_tag_cache ||= {}
11
70
  end
12
71
  end
13
72
  end
@@ -5,6 +5,8 @@ module RoundhouseUi
5
5
  # Sidekiq has no in-place edit: a job in a set is keyed by its payload, so an
6
6
  # "edit" is delete-the-old + push-the-modified.
7
7
  class JobsController < ApplicationController
8
+ # backend.push is Sidekiq-only; without it Edit had nothing to re-enqueue into.
9
+ requires_capability :enqueue, only: %i[new create edit update]
8
10
  REDIRECTS = { "dead" => :dead_set_path, "retry" => :retries_path, "scheduled" => :scheduled_path }.freeze
9
11
 
10
12
  before_action :require_editing_enabled!, except: :show
@@ -50,8 +52,10 @@ module RoundhouseUi
50
52
  klass = params[:job_class].presence || entry.item["class"]
51
53
  queue = params[:queue].presence || entry.queue
52
54
 
53
- entry.delete
55
+ # Push BEFORE delete. Reversed, a backend that cannot push (Solid Queue raises
56
+ # NotImplementedError) left the job deleted and gone.
54
57
  backend.push("class" => klass, "queue" => queue, "args" => args)
58
+ entry.delete
55
59
  redirect_to send(REDIRECTS[params[:set]]), notice: "Edited & re-enqueued #{klass} → #{queue}."
56
60
  rescue ArgumentError => e
57
61
  @action_path = job_path(set: params[:set], jid: params[:jid])