roundhouse_ui 0.10.0 → 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.
- checksums.yaml +4 -4
- data/README.md +563 -50
- data/app/controllers/concerns/roundhouse_ui/job_set_browsing.rb +190 -23
- data/app/controllers/roundhouse_ui/application_controller.rb +73 -0
- data/app/controllers/roundhouse_ui/audit_controller.rb +1 -0
- data/app/controllers/roundhouse_ui/busy_controller.rb +25 -5
- data/app/controllers/roundhouse_ui/dashboard_controller.rb +23 -4
- data/app/controllers/roundhouse_ui/dead_controller.rb +26 -12
- data/app/controllers/roundhouse_ui/errors_controller.rb +30 -8
- data/app/controllers/roundhouse_ui/jobs_controller.rb +5 -1
- data/app/controllers/roundhouse_ui/queues_controller.rb +56 -7
- data/app/controllers/roundhouse_ui/recurring_controller.rb +12 -0
- data/app/controllers/roundhouse_ui/retries_controller.rb +33 -12
- data/app/controllers/roundhouse_ui/scheduled_controller.rb +3 -8
- data/app/controllers/roundhouse_ui/settings_controller.rb +15 -0
- data/app/controllers/roundhouse_ui/snapshots_controller.rb +5 -6
- data/app/controllers/roundhouse_ui/workers_controller.rb +2 -7
- data/app/helpers/roundhouse_ui/application_helper.rb +253 -10
- data/app/helpers/roundhouse_ui/nav_helper.rb +3 -3
- data/app/helpers/roundhouse_ui/observability_helper.rb +113 -27
- data/app/helpers/roundhouse_ui/tags_helper.rb +30 -3
- data/app/views/layouts/roundhouse_ui/application.html.erb +1181 -92
- data/app/views/roundhouse_ui/busy/index.html.erb +30 -6
- data/app/views/roundhouse_ui/dashboard/show.html.erb +62 -17
- data/app/views/roundhouse_ui/dead/index.html.erb +23 -15
- data/app/views/roundhouse_ui/errors/index.html.erb +28 -9
- data/app/views/roundhouse_ui/jobs/show.html.erb +7 -3
- data/app/views/roundhouse_ui/metrics/show.html.erb +26 -3
- data/app/views/roundhouse_ui/queues/index.html.erb +53 -13
- data/app/views/roundhouse_ui/queues/show.html.erb +55 -0
- data/app/views/roundhouse_ui/recurring/index.html.erb +71 -0
- data/app/views/roundhouse_ui/retries/index.html.erb +17 -15
- data/app/views/roundhouse_ui/scheduled/index.html.erb +10 -10
- data/app/views/roundhouse_ui/settings/show.html.erb +91 -0
- data/app/views/roundhouse_ui/shared/_filter_error.html.erb +23 -0
- data/app/views/roundhouse_ui/shared/_pager.html.erb +2 -2
- data/app/views/roundhouse_ui/shared/_search_bar.html.erb +55 -0
- data/app/views/roundhouse_ui/shared/_search_help.html.erb +37 -0
- data/app/views/roundhouse_ui/shared/_tag_filter.html.erb +2 -4
- data/app/views/roundhouse_ui/shared/bulk_preview.html.erb +69 -0
- data/config/routes.rb +9 -0
- data/lib/roundhouse_ui/backends/sidekiq.rb +124 -5
- data/lib/roundhouse_ui/backends/solid_queue.rb +54 -3
- data/lib/roundhouse_ui/demo.rb +130 -0
- data/lib/roundhouse_ui/error_groups.rb +13 -3
- data/lib/roundhouse_ui/filter_query.rb +504 -0
- data/lib/roundhouse_ui/health.rb +43 -4
- data/lib/roundhouse_ui/history.rb +54 -0
- data/lib/roundhouse_ui/icons.rb +104 -0
- data/lib/roundhouse_ui/marks/datadog-lockup-white.svg +30 -0
- data/lib/roundhouse_ui/marks/datadog-lockup.svg +41 -0
- data/lib/roundhouse_ui/observability.rb +55 -7
- data/lib/roundhouse_ui/pause.rb +6 -11
- data/lib/roundhouse_ui/queue_summary.rb +11 -0
- data/lib/roundhouse_ui/recurring.rb +137 -0
- data/lib/roundhouse_ui/runbooks.rb +75 -0
- data/lib/roundhouse_ui/snapshots.rb +13 -2
- data/lib/roundhouse_ui/tags.rb +8 -11
- data/lib/roundhouse_ui/theme.rb +298 -0
- data/lib/roundhouse_ui/version.rb +1 -1
- data/lib/roundhouse_ui.rb +200 -5
- data/lib/tasks/roundhouse_ui_tasks.rake +84 -4
- metadata +29 -7
|
@@ -5,21 +5,90 @@ 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
|
-
#
|
|
14
|
-
#
|
|
15
|
-
#
|
|
16
|
-
|
|
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
|
|
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
|
|
23
92
|
|
|
24
93
|
def browse(set, query, page, per = PER_PAGE, tag: nil)
|
|
25
94
|
start = (page - 1) * per
|
|
@@ -48,28 +117,90 @@ module RoundhouseUi
|
|
|
48
117
|
# Apply an op ("retry"/"delete") to every entry matching the query, capped at
|
|
49
118
|
# BULK_CAP. Entries are collected first, then acted on — mutating a Sidekiq set
|
|
50
119
|
# mid-iteration skips entries. Returns [count_acted_on, capped?].
|
|
51
|
-
|
|
52
|
-
|
|
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 = []
|
|
53
170
|
capped = false
|
|
54
171
|
cache = tag_cache_for(tag)
|
|
55
172
|
set.each do |entry|
|
|
56
173
|
next unless entry_selected?(entry, query, tag, cache)
|
|
57
174
|
|
|
58
|
-
|
|
59
|
-
if
|
|
175
|
+
entries << entry
|
|
176
|
+
if entries.size >= cap
|
|
60
177
|
capped = true
|
|
61
178
|
break
|
|
62
179
|
end
|
|
63
180
|
end
|
|
64
|
-
|
|
65
|
-
|
|
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
|
|
66
190
|
end
|
|
67
191
|
|
|
68
192
|
# Both the browse and bulk paths run every candidate through this, so the
|
|
69
193
|
# rows an operator sees are exactly the rows a bulk action will touch —
|
|
70
194
|
# including when a tag value is what matched the free-text search.
|
|
71
195
|
def entry_selected?(entry, query, tag, cache)
|
|
72
|
-
return false if
|
|
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"])
|
|
73
204
|
|
|
74
205
|
tags = entry_tags(entry, cache)
|
|
75
206
|
return false if query.present? && !entry_matches?(entry, query, tags)
|
|
@@ -82,9 +213,19 @@ module RoundhouseUi
|
|
|
82
213
|
# the palette narrows to that queue. Exact rather than substring because
|
|
83
214
|
# this feeds bulk_apply too, and "default" must never also select
|
|
84
215
|
# "default_low".
|
|
85
|
-
def queue_filter
|
|
86
|
-
|
|
87
|
-
|
|
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
|
|
88
229
|
|
|
89
230
|
def entry_tagged?(tags, (key, value))
|
|
90
231
|
# A declared vocabulary is authoritative: filtering on a key the host
|
|
@@ -92,6 +233,12 @@ module RoundhouseUi
|
|
|
92
233
|
declared = Tags.filters
|
|
93
234
|
return false if declared && !declared.key?(key)
|
|
94
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
|
+
|
|
95
242
|
Tags.match?(tags, key, value)
|
|
96
243
|
end
|
|
97
244
|
|
|
@@ -120,9 +267,29 @@ module RoundhouseUi
|
|
|
120
267
|
# must never additionally select "default_low".
|
|
121
268
|
return true if entry.queue.to_s.downcase == needle
|
|
122
269
|
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
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)
|
|
126
293
|
end
|
|
127
294
|
end
|
|
128
295
|
end
|
|
@@ -8,12 +8,55 @@ module RoundhouseUi
|
|
|
8
8
|
helper TagsHelper
|
|
9
9
|
helper_method :content_nonce
|
|
10
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
|
+
|
|
11
20
|
# Self-contained CSP, set per-request on our own responses so Roundhouse is
|
|
12
21
|
# safe to mount even when the host sets no policy — and never weakens one it
|
|
13
22
|
# does (this header only applies to engine responses). Strict default; we
|
|
14
23
|
# enumerate exactly what our pages use (same-origin only, nonce'd inline JS).
|
|
15
24
|
after_action :set_content_security_policy
|
|
16
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
|
+
|
|
17
60
|
# Record every state-changing (POST) action. Actions halted by a
|
|
18
61
|
# before_action (e.g. read-only mode) never reach here, so we only log what
|
|
19
62
|
# actually ran.
|
|
@@ -43,6 +86,36 @@ module RoundhouseUi
|
|
|
43
86
|
RoundhouseUi.backend
|
|
44
87
|
end
|
|
45
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
|
+
|
|
46
119
|
def record_audit_event
|
|
47
120
|
target = params[:name] || params[:jid] || params[:id] || params[:job_class] ||
|
|
48
121
|
(params[:jids].presence && "#{Array(params[:jids]).size} jobs") || params[:op]
|
|
@@ -1,15 +1,20 @@
|
|
|
1
1
|
module RoundhouseUi
|
|
2
|
-
# What's executing right now,
|
|
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 :
|
|
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
|
|
23
|
-
|
|
24
|
-
|
|
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,15 +1,23 @@
|
|
|
1
1
|
module RoundhouseUi
|
|
2
|
-
# The dashboard reads
|
|
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
|
-
|
|
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 —
|
|
@@ -19,7 +27,11 @@ module RoundhouseUi
|
|
|
19
27
|
# queues was already loaded here for the count, so the names ride along
|
|
20
28
|
# free — the command palette uses them to tell a queue name from a plain
|
|
21
29
|
# search term without adding a Redis call to every page render.
|
|
22
|
-
|
|
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
|
|
23
35
|
render json: {
|
|
24
36
|
processed: s.processed,
|
|
25
37
|
failed: s.failed,
|
|
@@ -29,7 +41,14 @@ module RoundhouseUi
|
|
|
29
41
|
retries: s.retry_size,
|
|
30
42
|
dead: s.dead_size,
|
|
31
43
|
queues: qs.size,
|
|
32
|
-
|
|
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
|
|
33
52
|
}
|
|
34
53
|
end
|
|
35
54
|
end
|
|
@@ -2,14 +2,11 @@ module RoundhouseUi
|
|
|
2
2
|
class DeadController < ApplicationController
|
|
3
3
|
include JobSetBrowsing
|
|
4
4
|
|
|
5
|
-
|
|
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
|
-
@tag = tag_filter
|
|
12
|
-
@queue_filter = queue_filter
|
|
13
10
|
@jobs, @has_next = browse(backend.dead_set, @query, @page, PER_PAGE, tag: @tag)
|
|
14
11
|
end
|
|
15
12
|
|
|
@@ -42,19 +39,36 @@ module RoundhouseUi
|
|
|
42
39
|
# selected/visible ones), capped for safety. Only offered when a filter is
|
|
43
40
|
# active, so it can't become "retry the entire dead set" by accident.
|
|
44
41
|
def bulk_all
|
|
45
|
-
|
|
46
|
-
|
|
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
|
+
|
|
47
53
|
verb = params[:op] == "delete" ? "Deleted" : "Re-enqueued"
|
|
48
|
-
note = "#{verb} #{
|
|
49
|
-
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
|
|
50
56
|
redirect_to dead_set_path, notice: note
|
|
51
57
|
end
|
|
52
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
|
+
|
|
53
70
|
private
|
|
54
71
|
|
|
55
|
-
def
|
|
56
|
-
return unless RoundhouseUi.read_only
|
|
57
|
-
redirect_to dead_set_path, alert: "Roundhouse is in read-only mode — retry and delete are disabled."
|
|
58
|
-
end
|
|
72
|
+
def read_only_redirect_path = dead_set_path
|
|
59
73
|
end
|
|
60
74
|
end
|
|
@@ -3,8 +3,15 @@ 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
|
-
@
|
|
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
|
|
@@ -16,20 +23,35 @@ module RoundhouseUi
|
|
|
16
23
|
@group_tags = @groups.to_h { |g| [ g[:klass], Tags.for(klass: g[:klass], item: {}, cache: tag_cache) ] }
|
|
17
24
|
@tag_counts = tag_counts(@groups)
|
|
18
25
|
@tag = tag_filter
|
|
19
|
-
@groups = @groups.select { |g|
|
|
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]) }
|
|
20
38
|
end
|
|
21
39
|
|
|
22
40
|
private
|
|
23
41
|
|
|
24
|
-
# `?tag=key:value`,
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
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
|
|
28
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)
|
|
29
51
|
declared = Tags.filters
|
|
30
|
-
return
|
|
52
|
+
return false if declared && !declared.key?(key)
|
|
31
53
|
|
|
32
|
-
[ key, value
|
|
54
|
+
Tags.match?(@group_tags[group[:klass]], key, value)
|
|
33
55
|
end
|
|
34
56
|
|
|
35
57
|
# { "squad" => { "core" => 4, "training" => 5 } } — the counts behind the
|
|
@@ -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
|
-
|
|
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])
|