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.
- checksums.yaml +4 -4
- data/README.md +620 -46
- data/app/controllers/concerns/roundhouse_ui/job_set_browsing.rb +246 -12
- data/app/controllers/roundhouse_ui/application_controller.rb +74 -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 +27 -3
- data/app/controllers/roundhouse_ui/dead_controller.rb +27 -10
- data/app/controllers/roundhouse_ui/errors_controller.rb +60 -1
- data/app/controllers/roundhouse_ui/jobs_controller.rb +5 -1
- data/app/controllers/roundhouse_ui/queues_controller.rb +57 -6
- data/app/controllers/roundhouse_ui/recurring_controller.rb +12 -0
- data/app/controllers/roundhouse_ui/retries_controller.rb +34 -10
- data/app/controllers/roundhouse_ui/scheduled_controller.rb +4 -7
- 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 +293 -0
- data/app/helpers/roundhouse_ui/nav_helper.rb +3 -3
- data/app/helpers/roundhouse_ui/observability_helper.rb +121 -12
- data/app/helpers/roundhouse_ui/tags_helper.rb +128 -0
- data/app/views/layouts/roundhouse_ui/application.html.erb +1277 -84
- 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 +54 -32
- data/app/views/roundhouse_ui/errors/index.html.erb +51 -11
- data/app/views/roundhouse_ui/jobs/show.html.erb +8 -3
- data/app/views/roundhouse_ui/metrics/show.html.erb +26 -3
- data/app/views/roundhouse_ui/queues/index.html.erb +54 -10
- 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 +30 -17
- data/app/views/roundhouse_ui/scheduled/index.html.erb +17 -11
- 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 +28 -0
- 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 +25 -4
- 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 +112 -0
- data/lib/roundhouse_ui/theme.rb +298 -0
- data/lib/roundhouse_ui/version.rb +1 -1
- data/lib/roundhouse_ui.rb +235 -5
- data/lib/tasks/roundhouse_ui_tasks.rake +84 -4
- metadata +32 -7
|
@@ -0,0 +1,504 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module RoundhouseUi
|
|
4
|
+
# One search box, parsed into the exact filters the scan already understands.
|
|
5
|
+
#
|
|
6
|
+
# class=Billing::SyncWorker error=Timeout::Error stripe
|
|
7
|
+
#
|
|
8
|
+
# `key=value` tokens become structured, EXACT filters; whatever follows them is
|
|
9
|
+
# one verbatim substring needle. That is the whole point: the box becomes the
|
|
10
|
+
# single visible, editable, shareable home for filter state, without a class name
|
|
11
|
+
# in it silently widening the "delete all matching" button below it.
|
|
12
|
+
#
|
|
13
|
+
# Two rules do the work:
|
|
14
|
+
#
|
|
15
|
+
# 1. Split on the FIRST `=` only. Colons are never a delimiter, so
|
|
16
|
+
# `Billing::SyncWorker` and `Timeout::Error` need no escaping — which is why
|
|
17
|
+
# the separator is `=` and not Datadog's `:`. Ruby names are colon-dense.
|
|
18
|
+
# 2. Facets lead. Parsing walks from index 0; the first position where a facet
|
|
19
|
+
# cannot be read ends the facet region, and everything from that byte to the
|
|
20
|
+
# end is the text needle, as one contiguous slice. With no facets the slice
|
|
21
|
+
# starts at 0, so `text` is byte-identical to the old `params[:q].strip`,
|
|
22
|
+
# interior double spaces included. That is not a special case — it falls out
|
|
23
|
+
# of the rule, which is what keeps parse/to_s compositional.
|
|
24
|
+
#
|
|
25
|
+
# Everything invalid is REFUSED, whole. Never "ignore the token I did not
|
|
26
|
+
# understand": discarding is the only resolution that can select MORE than what
|
|
27
|
+
# was typed, and this feeds a destructive action. `clas=Foo` becoming a substring
|
|
28
|
+
# search for the literal text "clas=Foo" would find nothing and read as "no such
|
|
29
|
+
# jobs exist"; worse, dropping it entirely would widen a delete.
|
|
30
|
+
class FilterQuery
|
|
31
|
+
KEYS = %w[class error queue tag text].freeze
|
|
32
|
+
|
|
33
|
+
# Checked before any comparison runs, the same ordering as
|
|
34
|
+
# RoundhouseUi.job_class: a length test first, so a pathological input is
|
|
35
|
+
# bounded before anything walks it. Regexp.timeout is Ruby 3.2+ and the
|
|
36
|
+
# gemspec floor is 3.1, so bounding the input is the only portable defence.
|
|
37
|
+
MAX_LENGTH = 500
|
|
38
|
+
# No facet count cap, deliberately. There are five keys and a conflicting
|
|
39
|
+
# duplicate is refused, so the maximum number of facets in any accepted query
|
|
40
|
+
# is five. A cap of eight was written, could never fire, and its test was
|
|
41
|
+
# actually exercising the duplicate rule — a bound that cannot be reached
|
|
42
|
+
# reads as a risk that does not exist. MAX_LENGTH bounds the total work.
|
|
43
|
+
# Same bound as RoundhouseUi::MAX_JOB_CLASS_NAME, written out because this file
|
|
44
|
+
# is required before that constant is defined — the entry point's require block
|
|
45
|
+
# runs above its own module body.
|
|
46
|
+
MAX_VALUE = 200
|
|
47
|
+
|
|
48
|
+
# Things a Datadog-trained operator will type. Refused BY NAME rather than
|
|
49
|
+
# matched literally: entry_selected? is a pure conjunction, which is exactly
|
|
50
|
+
# what makes a stray token harmless, and every one of these is disjunction or
|
|
51
|
+
# a substring in a facet's clothing.
|
|
52
|
+
# Refused by name, because a Datadog-trained operator will type them and a
|
|
53
|
+
# literal match reads as "no such jobs exist". Narrow on purpose: only a
|
|
54
|
+
# NEGATED KNOWN FACET is unambiguous. A bare "-1" or "*" or "OR" in free text
|
|
55
|
+
# is legitimate text — an error message can contain any of them — and refusing
|
|
56
|
+
# those would break searching for what is actually in the data.
|
|
57
|
+
UNSUPPORTED = [
|
|
58
|
+
[ /\A-(?:#{KEYS.join('|')})=/,
|
|
59
|
+
"Negation is not supported. Every filter narrows; none excludes." ]
|
|
60
|
+
].freeze
|
|
61
|
+
|
|
62
|
+
# `class=Roundhouse%`, `class=%oundhouse%`, `class=%Worker`, `class=A%B%C`.
|
|
63
|
+
#
|
|
64
|
+
# `%` only. NOT `_`: SQL's single-character wildcard would silently turn
|
|
65
|
+
# `queue=default_low` into a pattern matching `defaultXlow`, and every Ruby name
|
|
66
|
+
# in this console is underscore-dense. A wildcard nobody typed is the one thing
|
|
67
|
+
# that must never happen here, because the same predicate scopes a Delete.
|
|
68
|
+
#
|
|
69
|
+
# An index walk rather than a Regexp. The pattern comes from a URL and is matched
|
|
70
|
+
# against every entry in a set that can hold fifty thousand of them, so a
|
|
71
|
+
# backtracking engine is both a performance and a ReDoS question. This is linear
|
|
72
|
+
# in the value and provably terminates.
|
|
73
|
+
class Pattern
|
|
74
|
+
MAX_WILDCARDS = 6
|
|
75
|
+
|
|
76
|
+
def self.for(value)
|
|
77
|
+
value.to_s.include?("%") ? new(value) : nil
|
|
78
|
+
end
|
|
79
|
+
|
|
80
|
+
attr_reader :source
|
|
81
|
+
|
|
82
|
+
def initialize(value)
|
|
83
|
+
@source = value.to_s
|
|
84
|
+
@segments = @source.split("%", -1)
|
|
85
|
+
@lead = @segments.first
|
|
86
|
+
@tail = @segments.last
|
|
87
|
+
@mids = @segments[1..-2] || []
|
|
88
|
+
end
|
|
89
|
+
|
|
90
|
+
# Literal characters the pattern insists on. Zero means it matches every
|
|
91
|
+
# possible value, which is not a filter — see the refusal in the parser.
|
|
92
|
+
def literals = @segments.sum(&:length)
|
|
93
|
+
def wildcards = @segments.length - 1
|
|
94
|
+
def too_many? = wildcards > MAX_WILDCARDS
|
|
95
|
+
|
|
96
|
+
def match?(value)
|
|
97
|
+
v = value.to_s
|
|
98
|
+
return false unless v.start_with?(@lead)
|
|
99
|
+
# The tail must not reuse characters the lead already consumed, or `A%A`
|
|
100
|
+
# would match "A" — one literal doing double duty.
|
|
101
|
+
return false unless v.end_with?(@tail) && v.length >= @lead.length + @tail.length
|
|
102
|
+
|
|
103
|
+
cursor = @lead.length
|
|
104
|
+
stop = v.length - @tail.length
|
|
105
|
+
@mids.each do |mid|
|
|
106
|
+
next if mid.empty?
|
|
107
|
+
|
|
108
|
+
at = v.index(mid, cursor)
|
|
109
|
+
return false if at.nil? || at + mid.length > stop
|
|
110
|
+
|
|
111
|
+
cursor = at + mid.length
|
|
112
|
+
end
|
|
113
|
+
true
|
|
114
|
+
end
|
|
115
|
+
end
|
|
116
|
+
|
|
117
|
+
attr_reader :klass, :error, :queue, :tag, :text, :raw, :message, :ignored
|
|
118
|
+
|
|
119
|
+
class << self
|
|
120
|
+
# Never raises. A refusal is a value, not an exception, because this runs in a
|
|
121
|
+
# before_action on every page including the ones that only browse.
|
|
122
|
+
# `keys` restricts which facets this PAGE honours. Errors groups by class and
|
|
123
|
+
# error, so queue= has nothing to apply to there — and a facet that renders as
|
|
124
|
+
# a pill while filtering nothing is precisely the invisible/phantom filter this
|
|
125
|
+
# whole bar exists to prevent. Out of scope refuses, and says where it works.
|
|
126
|
+
def parse(raw, keys: KEYS)
|
|
127
|
+
return refused(raw, "Search is not a single value.") unless raw.nil? || raw.is_a?(String)
|
|
128
|
+
|
|
129
|
+
str = raw.to_s
|
|
130
|
+
# Encoding first, then length, then anything that inspects characters.
|
|
131
|
+
# String#strip raises Encoding::CompatibilityError on invalid bytes, so
|
|
132
|
+
# checking validity third meant a malformed query crashed the page instead
|
|
133
|
+
# of being refused — the same ordering trap RoundhouseUi.job_class avoids by
|
|
134
|
+
# testing length before it matches.
|
|
135
|
+
return refused("", "That search contains invalid characters.") unless str.valid_encoding?
|
|
136
|
+
return refused(str, "That search is too long (#{str.length} characters, limit #{MAX_LENGTH}).") if str.length > MAX_LENGTH
|
|
137
|
+
return none if str.strip.empty?
|
|
138
|
+
|
|
139
|
+
Parser.new(str, keys).call
|
|
140
|
+
end
|
|
141
|
+
|
|
142
|
+
# Old links and bookmarks. `?class=X&error=Y` predates the grammar, and
|
|
143
|
+
# find_like_link, the tag chips and the queue pills all emitted it — so
|
|
144
|
+
# somebody's saved URL, and somebody's shared one, is that shape.
|
|
145
|
+
#
|
|
146
|
+
# Folded by RE-PARSING rather than by assignment. build() does not validate,
|
|
147
|
+
# so a legacy `?error=A"B` assigned straight in would produce a query whose
|
|
148
|
+
# own to_s the parser then refuses — displayed one filter, applied another.
|
|
149
|
+
# Going back through parse means nothing can enter that the grammar would
|
|
150
|
+
# not accept, and conflicts (`?q=class=A&class=B`) hit the parser's existing
|
|
151
|
+
# refusal instead of a second, differently-written precedence rule.
|
|
152
|
+
LEGACY = { class: "class", error: "error", queue: "queue", tag: "tag" }.freeze
|
|
153
|
+
|
|
154
|
+
def from_params(params, keys: KEYS)
|
|
155
|
+
# `qf` is the search FORM's facet companion. The bar renders each facet as a
|
|
156
|
+
# pill and leaves the visible input holding only the free text, so the facets
|
|
157
|
+
# need a way back — joined here, facets first, at the single parse point.
|
|
158
|
+
#
|
|
159
|
+
# `q` stays the canonical parameter on its own: a bookmark or a shared link
|
|
160
|
+
# carries the whole filter in q and never mentions qf. If both arrive and
|
|
161
|
+
# they disagree, the parser's existing duplicate rule refuses.
|
|
162
|
+
facets = params[:qf].to_s.strip
|
|
163
|
+
typed = params[:q].to_s.strip
|
|
164
|
+
q = parse(facets.empty? ? params[:q] : [ facets, typed ].reject(&:empty?).join(" "), keys: keys)
|
|
165
|
+
return q if q.invalid?
|
|
166
|
+
|
|
167
|
+
extra = LEGACY.filter_map do |param, key|
|
|
168
|
+
next unless keys.include?(key)
|
|
169
|
+
|
|
170
|
+
value = params[param].to_s.strip
|
|
171
|
+
next if value.empty?
|
|
172
|
+
|
|
173
|
+
"#{key}=#{value.match?(/\s/) ? %("#{value}") : value}"
|
|
174
|
+
end
|
|
175
|
+
return q if extra.empty?
|
|
176
|
+
|
|
177
|
+
parse((q.facet_parts + extra + q.text_parts).join(" "), keys: keys)
|
|
178
|
+
end
|
|
179
|
+
|
|
180
|
+
def build(klass: nil, error: nil, queue: nil, tag: nil, text: nil, ignored: [])
|
|
181
|
+
new(klass: klass, error: error, queue: queue, tag: tag, text: text.to_s, ignored: ignored)
|
|
182
|
+
end
|
|
183
|
+
|
|
184
|
+
def none = @none ||= build.freeze
|
|
185
|
+
|
|
186
|
+
def refused(raw, message)
|
|
187
|
+
q = build
|
|
188
|
+
q.instance_variable_set(:@raw, raw.to_s)
|
|
189
|
+
q.instance_variable_set(:@message, message)
|
|
190
|
+
q.freeze
|
|
191
|
+
end
|
|
192
|
+
end
|
|
193
|
+
|
|
194
|
+
def initialize(klass: nil, error: nil, queue: nil, tag: nil, text: "", raw: nil, ignored: [])
|
|
195
|
+
@klass = presence(klass)
|
|
196
|
+
@error = presence(error)
|
|
197
|
+
@queue = presence(queue)
|
|
198
|
+
@tag = tag
|
|
199
|
+
@text = text.to_s
|
|
200
|
+
@raw = raw || to_s
|
|
201
|
+
@ignored = ignored.freeze
|
|
202
|
+
@message = nil
|
|
203
|
+
end
|
|
204
|
+
|
|
205
|
+
def invalid? = !@message.nil?
|
|
206
|
+
|
|
207
|
+
# A matcher per facet, compiled once. `nil` where the value has no `%`, which is
|
|
208
|
+
# the overwhelming case and stays a plain string comparison.
|
|
209
|
+
def pattern_for(field)
|
|
210
|
+
@patterns ||= {}
|
|
211
|
+
return @patterns[field] if @patterns.key?(field)
|
|
212
|
+
|
|
213
|
+
@patterns[field] = Pattern.for(public_send(field))
|
|
214
|
+
end
|
|
215
|
+
|
|
216
|
+
# Does this facet's value match? Exact when there is no wildcard — `queue=default`
|
|
217
|
+
# must never also select `default_low`, which is the invariant the whole facet
|
|
218
|
+
# design rests on.
|
|
219
|
+
def matches_facet?(field, value)
|
|
220
|
+
wanted = public_send(field)
|
|
221
|
+
return true if wanted.nil?
|
|
222
|
+
|
|
223
|
+
pattern = pattern_for(field)
|
|
224
|
+
pattern ? pattern.match?(value) : value.to_s == wanted
|
|
225
|
+
end
|
|
226
|
+
|
|
227
|
+
def wildcard? = %i[klass error queue tag].any? { |f| pattern_for(f) }
|
|
228
|
+
|
|
229
|
+
# Was any facet dropped? A degraded query browses normally — the point of
|
|
230
|
+
# dropping rather than refusing — but authorises no bulk action, because the
|
|
231
|
+
# filter that survived selects a SUPERSET of what was asked for.
|
|
232
|
+
def degraded? = !ignored.empty?
|
|
233
|
+
|
|
234
|
+
# One sentence per dropped facet, for the banner above the table.
|
|
235
|
+
def notes = ignored.map { |token, why| %(Ignored "#{token}" — #{why}.) }
|
|
236
|
+
|
|
237
|
+
# What the search box shows. Canonical when the query parsed, but the RAW input
|
|
238
|
+
# when it did not: a refused query has no canonical form, and rendering the
|
|
239
|
+
# empty one would swallow the typo along with any chance of fixing it.
|
|
240
|
+
def display = invalid? ? raw : to_s
|
|
241
|
+
def any_facets? = !(klass.nil? && error.nil? && queue.nil? && tag.nil?)
|
|
242
|
+
def any? = any_facets? || !text.empty?
|
|
243
|
+
|
|
244
|
+
# The tag filter's existing two-element shape, so entry_tagged? is untouched.
|
|
245
|
+
def tag_pair
|
|
246
|
+
return nil if tag.nil?
|
|
247
|
+
|
|
248
|
+
key, value = tag.split(":", 2)
|
|
249
|
+
return nil if key.to_s.empty? || value.to_s.empty?
|
|
250
|
+
|
|
251
|
+
[ key, value ]
|
|
252
|
+
end
|
|
253
|
+
|
|
254
|
+
# Canonical, and round-trips: parse(q.to_s) == q. Facets in KEYS order so the
|
|
255
|
+
# box does not reshuffle itself when you press Enter.
|
|
256
|
+
def to_s
|
|
257
|
+
@canonical ||= (facet_parts + text_parts).join(" ")
|
|
258
|
+
end
|
|
259
|
+
|
|
260
|
+
# The facet region alone, in KEYS order. Public because from_params splices
|
|
261
|
+
# more facets in here — between the facets and the text, never after it.
|
|
262
|
+
def facet_parts
|
|
263
|
+
parts = []
|
|
264
|
+
parts << "class=#{quoted(klass)}" if klass
|
|
265
|
+
parts << "error=#{quoted(error)}" if error
|
|
266
|
+
parts << "queue=#{quoted(queue)}" if queue
|
|
267
|
+
parts << "tag=#{quoted(tag)}" if tag
|
|
268
|
+
parts
|
|
269
|
+
end
|
|
270
|
+
|
|
271
|
+
def text_parts
|
|
272
|
+
text.empty? ? [] : [ serialized_text ]
|
|
273
|
+
end
|
|
274
|
+
alias to_param to_s
|
|
275
|
+
|
|
276
|
+
def chips
|
|
277
|
+
[ [ :class, klass ], [ :error, error ], [ :queue, queue ], [ :tag, tag ] ]
|
|
278
|
+
.select { |_k, v| v }
|
|
279
|
+
end
|
|
280
|
+
|
|
281
|
+
def merge(**over)
|
|
282
|
+
self.class.build(
|
|
283
|
+
klass: over.key?(:klass) ? over[:klass] : klass,
|
|
284
|
+
error: over.key?(:error) ? over[:error] : error,
|
|
285
|
+
queue: over.key?(:queue) ? over[:queue] : queue,
|
|
286
|
+
tag: over.key?(:tag) ? over[:tag] : tag,
|
|
287
|
+
text: over.key?(:text) ? over[:text].to_s : text
|
|
288
|
+
)
|
|
289
|
+
end
|
|
290
|
+
|
|
291
|
+
def without(*keys)
|
|
292
|
+
over = keys.to_h { |k| [ k, k == :text ? "" : nil ] }
|
|
293
|
+
merge(**over)
|
|
294
|
+
end
|
|
295
|
+
|
|
296
|
+
def ==(other) = other.is_a?(FilterQuery) && to_s == other.to_s && invalid? == other.invalid?
|
|
297
|
+
alias eql? ==
|
|
298
|
+
def hash = [ self.class, to_s, invalid? ].hash
|
|
299
|
+
|
|
300
|
+
private
|
|
301
|
+
|
|
302
|
+
def presence(v)
|
|
303
|
+
s = v.to_s.strip
|
|
304
|
+
s.empty? ? nil : s
|
|
305
|
+
end
|
|
306
|
+
|
|
307
|
+
# A value needs quoting when it holds whitespace, or the box would reparse it
|
|
308
|
+
# as a facet boundary and the next Enter would mean something different.
|
|
309
|
+
def quoted(value)
|
|
310
|
+
value.match?(/\s/) ? %("#{value}") : value
|
|
311
|
+
end
|
|
312
|
+
|
|
313
|
+
# Text whose first token looks like `key=` has to go back out through the
|
|
314
|
+
# `text=` escape hatch, or re-parsing it would read that token as a facet —
|
|
315
|
+
# and since the key would be an unknown one, refuse the whole query. Pressing
|
|
316
|
+
# Enter twice would then mean something different from pressing it once.
|
|
317
|
+
#
|
|
318
|
+
# Always representable, and worth knowing why: text can only BEGIN with a
|
|
319
|
+
# facet-shaped token if it arrived as text="…" in the first place (a known key
|
|
320
|
+
# would have been parsed as a facet, an unknown one refused), and a quoted
|
|
321
|
+
# value cannot contain a quote, because reading one stops at the first closing
|
|
322
|
+
# quote. So there is no text needing the wrapper that also breaks it.
|
|
323
|
+
public def serialized_text
|
|
324
|
+
text.match?(/\A[a-z][a-z0-9_]*=/) ? %(text="#{text}") : text
|
|
325
|
+
end
|
|
326
|
+
|
|
327
|
+
# An index walk. No regex over the input, so there is nothing to backtrack.
|
|
328
|
+
class Parser
|
|
329
|
+
def initialize(str, keys = FilterQuery::KEYS)
|
|
330
|
+
@s = str
|
|
331
|
+
@keys = keys
|
|
332
|
+
@i = 0
|
|
333
|
+
@seen = {}
|
|
334
|
+
@ignored = []
|
|
335
|
+
end
|
|
336
|
+
|
|
337
|
+
def call
|
|
338
|
+
loop do
|
|
339
|
+
skip_space
|
|
340
|
+
break if @i >= @s.length
|
|
341
|
+
|
|
342
|
+
key = read_key
|
|
343
|
+
# An unrecognised key refuses the whole query. Falling through to free
|
|
344
|
+
# text is the one resolution that can select MORE than was typed, and a
|
|
345
|
+
# literal substring search for "clas=Foo" would find nothing and read as
|
|
346
|
+
# "no such jobs exist".
|
|
347
|
+
return refuse(nil) if key == :unknown
|
|
348
|
+
break if key.nil?
|
|
349
|
+
|
|
350
|
+
value = read_value
|
|
351
|
+
return refuse("Unclosed quote in #{key}=. Quote both ends, or drop the quotes.") if value == :unterminated
|
|
352
|
+
return refuse(bad_value_message(key, value)) unless value_ok?(value)
|
|
353
|
+
# A tag is a key:value pair or it is nothing. Rather than refuse the whole
|
|
354
|
+
# query, the facet is DROPPED and recorded — a typo should not stop you
|
|
355
|
+
# browsing. What it does stop is a bulk action: `tag=garbage queue=default`
|
|
356
|
+
# drops to `queue=default`, which selects the whole queue, and the Delete
|
|
357
|
+
# beneath it would then be scoped wider than what was typed. Dropping a
|
|
358
|
+
# facet can only ever widen, so a degraded query authorises nothing.
|
|
359
|
+
# Both halves must be non-empty. `tag=squad:` has a colon, so a
|
|
360
|
+
# colon-only check let it through: the pill rendered, tag_pair came back nil,
|
|
361
|
+
# and entry_selected?'s `return true if tag.nil?` selected everything — with
|
|
362
|
+
# bulk authorised, because any_facets? was true.
|
|
363
|
+
if key == "tag" && value.split(":", 2).reject(&:empty?).size != 2
|
|
364
|
+
@ignored << [ "#{key}=#{value}", "tag= takes key:value, like tag=squad:core" ]
|
|
365
|
+
next
|
|
366
|
+
end
|
|
367
|
+
|
|
368
|
+
if @seen.key?(key) && @seen[key] != value
|
|
369
|
+
return refuse(%(Two different values for #{key}: "#{@seen[key]}" and "#{value}". Pick one.))
|
|
370
|
+
end
|
|
371
|
+
|
|
372
|
+
@seen[key] = value
|
|
373
|
+
end
|
|
374
|
+
|
|
375
|
+
residue = @s[@i..].to_s.strip
|
|
376
|
+
|
|
377
|
+
# text= is the escape hatch for facet-shaped free text. Having BOTH is two
|
|
378
|
+
# ways of saying the same thing, and picking one would discard the other
|
|
379
|
+
# silently — `text=foo bar` would have kept "foo" and thrown "bar" away.
|
|
380
|
+
if @seen.key?("text") && !residue.empty?
|
|
381
|
+
return refuse(%(Text is given twice: text="#{@seen['text']}" and "#{residue}". ) +
|
|
382
|
+
%(Quote the whole thing: text="#{@seen['text']} #{residue}"))
|
|
383
|
+
end
|
|
384
|
+
|
|
385
|
+
unsupported = unsupported_in(residue)
|
|
386
|
+
return refuse(unsupported) if unsupported
|
|
387
|
+
|
|
388
|
+
FilterQuery.new(klass: @seen["class"], error: @seen["error"], queue: @seen["queue"],
|
|
389
|
+
tag: @seen["tag"], text: @seen.fetch("text", residue), raw: @s,
|
|
390
|
+
ignored: @ignored)
|
|
391
|
+
end
|
|
392
|
+
|
|
393
|
+
private
|
|
394
|
+
|
|
395
|
+
def skip_space = (@i += 1 while @i < @s.length && @s[@i].match?(/[ \t\n\r]/))
|
|
396
|
+
|
|
397
|
+
# A facet candidate is a lowercase key followed immediately by `=`. Anything
|
|
398
|
+
# else ends the facet region and begins the text — which is why pasting a
|
|
399
|
+
# whole error message like `SSL_connect returned=1 errno=0` is unaffected:
|
|
400
|
+
# `SSL_connect` fails the charset at `S`, so the residue starts at 0.
|
|
401
|
+
def read_key
|
|
402
|
+
start = @i
|
|
403
|
+
return nil unless @i < @s.length && @s[@i].match?(/[a-z]/)
|
|
404
|
+
|
|
405
|
+
@i += 1 while @i < @s.length && @s[@i].match?(/[a-z0-9_]/)
|
|
406
|
+
unless @i < @s.length && @s[@i] == "="
|
|
407
|
+
@i = start
|
|
408
|
+
return nil
|
|
409
|
+
end
|
|
410
|
+
|
|
411
|
+
key = @s[start...@i]
|
|
412
|
+
unless FilterQuery::KEYS.include?(key)
|
|
413
|
+
@i = start
|
|
414
|
+
@unknown = key
|
|
415
|
+
return :unknown
|
|
416
|
+
end
|
|
417
|
+
# Known everywhere, not honoured here. A distinct refusal from "unknown",
|
|
418
|
+
# because the fix is different: go to the page where it applies.
|
|
419
|
+
unless @keys.include?(key)
|
|
420
|
+
@i = start
|
|
421
|
+
@out_of_scope = key
|
|
422
|
+
return :unknown
|
|
423
|
+
end
|
|
424
|
+
|
|
425
|
+
@i += 1
|
|
426
|
+
key
|
|
427
|
+
end
|
|
428
|
+
|
|
429
|
+
def read_value
|
|
430
|
+
if @s[@i] == '"'
|
|
431
|
+
close = @s.index('"', @i + 1)
|
|
432
|
+
return :unterminated if close.nil?
|
|
433
|
+
|
|
434
|
+
value = @s[(@i + 1)...close]
|
|
435
|
+
@i = close + 1
|
|
436
|
+
return :unterminated unless @i >= @s.length || @s[@i].match?(/[ \t\n\r]/)
|
|
437
|
+
|
|
438
|
+
value
|
|
439
|
+
else
|
|
440
|
+
start = @i
|
|
441
|
+
@i += 1 while @i < @s.length && !@s[@i].match?(/[ \t\n\r]/)
|
|
442
|
+
@s[start...@i]
|
|
443
|
+
end
|
|
444
|
+
end
|
|
445
|
+
|
|
446
|
+
def value_ok?(value)
|
|
447
|
+
v = value.to_s.strip
|
|
448
|
+
return false if v.empty? || v.length > FilterQuery::MAX_VALUE
|
|
449
|
+
return false if v.match?(/["\x00-\x1f]/)
|
|
450
|
+
# `*` and `?` still refuse, and the message points at `%`. Two spellings for
|
|
451
|
+
# one idea is how you end up with one of them silently doing nothing.
|
|
452
|
+
return false if v.match?(/[*?]/)
|
|
453
|
+
# A pattern with no literal characters matches every possible value. It is
|
|
454
|
+
# not a filter, but it renders as a pill that looks like one — the unfiltered
|
|
455
|
+
# bulk hole wearing a facet's clothing. Refused, so it selects nothing.
|
|
456
|
+
pattern = Pattern.for(v)
|
|
457
|
+
return false if pattern && (pattern.literals.zero? || pattern.too_many?)
|
|
458
|
+
|
|
459
|
+
true
|
|
460
|
+
end
|
|
461
|
+
|
|
462
|
+
def bad_value_message(key, value)
|
|
463
|
+
v = value.to_s
|
|
464
|
+
return "#{key}= needs a value. To search for the text instead: text=\"#{key}=\"" if v.strip.empty?
|
|
465
|
+
return "That #{key} filter is too long (limit #{FilterQuery::MAX_VALUE})." if v.length > FilterQuery::MAX_VALUE
|
|
466
|
+
if v.match?(/[*?]/)
|
|
467
|
+
return "Use % for a wildcard, not #{v[/[*?]/]} — for example " \
|
|
468
|
+
"#{key}=#{v.tr('*?', '%%')}."
|
|
469
|
+
end
|
|
470
|
+
if (pattern = Pattern.for(v))
|
|
471
|
+
if pattern.literals.zero?
|
|
472
|
+
return "#{key}=#{v} matches everything, which is not a filter. Give it " \
|
|
473
|
+
"something to match, like #{key}=Something%, or drop it."
|
|
474
|
+
end
|
|
475
|
+
if pattern.too_many?
|
|
476
|
+
return "#{key}= takes at most #{Pattern::MAX_WILDCARDS} wildcards."
|
|
477
|
+
end
|
|
478
|
+
end
|
|
479
|
+
|
|
480
|
+
"That #{key} filter contains invalid characters."
|
|
481
|
+
end
|
|
482
|
+
|
|
483
|
+
def unsupported_in(text)
|
|
484
|
+
text.split(/[ \t\n\r]+/).each do |token|
|
|
485
|
+
FilterQuery::UNSUPPORTED.each do |pattern, why|
|
|
486
|
+
return "#{why} (#{token})" if token.match?(pattern)
|
|
487
|
+
end
|
|
488
|
+
end
|
|
489
|
+
nil
|
|
490
|
+
end
|
|
491
|
+
|
|
492
|
+
def refuse(message)
|
|
493
|
+
if @out_of_scope
|
|
494
|
+
message = %(#{@out_of_scope}= does not narrow anything on this page. ) +
|
|
495
|
+
%(Filters here: #{@keys.reject { |k| k == "text" }.join(', ')}.)
|
|
496
|
+
elsif @unknown
|
|
497
|
+
message = %(Unknown filter "#{@unknown}". Known filters: #{FilterQuery::KEYS.join(', ')}. ) +
|
|
498
|
+
%(To search for it as text: text="#{@unknown}=…")
|
|
499
|
+
end
|
|
500
|
+
FilterQuery.refused(@s, message)
|
|
501
|
+
end
|
|
502
|
+
end
|
|
503
|
+
end
|
|
504
|
+
end
|
data/lib/roundhouse_ui/health.rb
CHANGED
|
@@ -14,6 +14,17 @@ module RoundhouseUi
|
|
|
14
14
|
@metrics = metrics
|
|
15
15
|
end
|
|
16
16
|
|
|
17
|
+
# Signals worst-first, the same rule the Queues page sorts by: the order is
|
|
18
|
+
# information, so a failing check should never sit below a healthy one.
|
|
19
|
+
# Ties break on label, so the order is stable between polls.
|
|
20
|
+
def ranked_signals
|
|
21
|
+
signals.sort_by { |s| [ -RANK.fetch(s.status, 0), s.label.to_s ] }
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
def failing_count
|
|
25
|
+
signals.count { |s| s.status != :ok }
|
|
26
|
+
end
|
|
27
|
+
|
|
17
28
|
def signals
|
|
18
29
|
@signals ||= [ error_rate_signal, latency_signal, utilization_signal ].compact
|
|
19
30
|
end
|
|
@@ -49,17 +60,45 @@ module RoundhouseUi
|
|
|
49
60
|
|
|
50
61
|
lat = worst.latency
|
|
51
62
|
status = if lat > 600 then :crit elsif lat > 60 then :warn else :ok end
|
|
52
|
-
detail = status == :ok ? "all queues fresh (< 60s)" : "#{worst.name}: oldest job #{lat
|
|
63
|
+
detail = status == :ok ? "all queues fresh (< 60s)" : "#{worst.name}: oldest job #{RoundhouseUi.duration(lat)}"
|
|
53
64
|
Signal.new(key: "latency", label: "Queue latency", status: status, detail: detail)
|
|
54
65
|
end
|
|
55
66
|
|
|
67
|
+
# A fully-busy fleet is what you want. It is only a problem when the work is
|
|
68
|
+
# also backing up — so this used to cry wolf: a healthy app running flat out
|
|
69
|
+
# reported Critical, and an alarm that fires when nothing is wrong is how
|
|
70
|
+
# people learn to ignore the banner.
|
|
71
|
+
#
|
|
72
|
+
# What makes saturation bad is that it is *sustained*, and queue latency is
|
|
73
|
+
# already the memory of that: if you have been at capacity long enough to
|
|
74
|
+
# matter, the oldest job is old. So a momentary spike stays a warning and
|
|
75
|
+
# only saturation with a backlog behind it escalates — no stored history
|
|
76
|
+
# needed to tell them apart.
|
|
77
|
+
SATURATED = 1.0
|
|
78
|
+
BUSY = 0.85
|
|
79
|
+
|
|
56
80
|
def utilization_signal
|
|
57
81
|
util = @metrics.utilization
|
|
58
82
|
return nil if util.nil? # no processes reporting in — can't judge
|
|
59
83
|
|
|
60
|
-
|
|
61
|
-
Signal.new(key: "utilization", label: "Worker utilization", status:
|
|
62
|
-
|
|
84
|
+
pct = "#{(util * 100).round}% of worker threads busy"
|
|
85
|
+
return Signal.new(key: "utilization", label: "Worker utilization", status: :ok, detail: pct) if util < BUSY
|
|
86
|
+
return Signal.new(key: "utilization", label: "Worker utilization", status: :warn, detail: pct) if util < SATURATED
|
|
87
|
+
|
|
88
|
+
if falling_behind?
|
|
89
|
+
Signal.new(key: "utilization", label: "Worker utilization", status: :crit,
|
|
90
|
+
detail: "#{pct} and queues are falling behind")
|
|
91
|
+
else
|
|
92
|
+
Signal.new(key: "utilization", label: "Worker utilization", status: :warn,
|
|
93
|
+
detail: "#{pct} — at capacity, keeping up")
|
|
94
|
+
end
|
|
95
|
+
end
|
|
96
|
+
|
|
97
|
+
# Saturation is only critical alongside a queue that is not being kept up
|
|
98
|
+
# with, which is the same threshold the latency signal escalates on.
|
|
99
|
+
def falling_behind?
|
|
100
|
+
worst = @queues.max_by { |q| q.latency.to_f }
|
|
101
|
+
worst && worst.latency.to_f > 60
|
|
63
102
|
end
|
|
64
103
|
end
|
|
65
104
|
end
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
module RoundhouseUi
|
|
2
|
+
# Daily processed and failed counts, for the one question every other number on
|
|
3
|
+
# the dashboard cannot answer: is this normal?
|
|
4
|
+
#
|
|
5
|
+
# 30,655 failures is alarming or unremarkable depending on the day, and a live
|
|
6
|
+
# throughput chart that starts empty on every page load cannot tell you which.
|
|
7
|
+
#
|
|
8
|
+
# Sidekiq already records this — `Sidekiq::Stats::History` keeps a counter per
|
|
9
|
+
# day and Roundhouse ignored it entirely. So this needs no storage of its own,
|
|
10
|
+
# no migration, and it works on every Sidekiq install. See #61.
|
|
11
|
+
Day = Struct.new(:date, :processed, :failed, keyword_init: true) do
|
|
12
|
+
# Share of the day's work that failed. The line worth drawing: counts move
|
|
13
|
+
# with traffic, so a busy Monday looks worse than a quiet Sunday even when
|
|
14
|
+
# nothing changed. A rate does not.
|
|
15
|
+
def failure_rate
|
|
16
|
+
total = processed.to_i + failed.to_i
|
|
17
|
+
return 0.0 if total.zero?
|
|
18
|
+
|
|
19
|
+
failed.to_i / total.to_f
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
def quiet? = processed.to_i.zero? && failed.to_i.zero?
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
module History
|
|
26
|
+
RANGES = { "7" => "1 week", "30" => "1 month", "90" => "3 months", "180" => "6 months" }.freeze
|
|
27
|
+
DEFAULT_DAYS = 30
|
|
28
|
+
MAX_DAYS = 180
|
|
29
|
+
|
|
30
|
+
module_function
|
|
31
|
+
|
|
32
|
+
# Oldest first, so a chart can plot it without reversing.
|
|
33
|
+
def days(count = DEFAULT_DAYS, backend: RoundhouseUi.backend)
|
|
34
|
+
return [] unless backend.respond_to?(:history) && backend.supports?(:history)
|
|
35
|
+
|
|
36
|
+
backend.history(clamp(count))
|
|
37
|
+
rescue StandardError => e
|
|
38
|
+
Rails.logger&.warn("[roundhouse] history unavailable: #{e.message}") if defined?(Rails)
|
|
39
|
+
[]
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
def clamp(count) = count.to_i.clamp(1, MAX_DAYS)
|
|
43
|
+
|
|
44
|
+
# A baseline to compare today against. The median rather than the mean: one
|
|
45
|
+
# incident day would drag a mean up and make the following week look calm.
|
|
46
|
+
def typical_failure_rate(days)
|
|
47
|
+
rates = days.reject(&:quiet?).map(&:failure_rate).sort
|
|
48
|
+
return nil if rates.empty?
|
|
49
|
+
|
|
50
|
+
mid = rates.size / 2
|
|
51
|
+
rates.size.odd? ? rates[mid] : (rates[mid - 1] + rates[mid]) / 2.0
|
|
52
|
+
end
|
|
53
|
+
end
|
|
54
|
+
end
|