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
@@ -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
@@ -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.round}s"
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
- status = if util >= 1.0 then :crit elsif util >= 0.85 then :warn else :ok end
61
- Signal.new(key: "utilization", label: "Worker utilization", status: status,
62
- detail: "#{(util * 100).round}% of worker threads busy")
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