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.
Files changed (63) hide show
  1. checksums.yaml +4 -4
  2. data/README.md +563 -50
  3. data/app/controllers/concerns/roundhouse_ui/job_set_browsing.rb +190 -23
  4. data/app/controllers/roundhouse_ui/application_controller.rb +73 -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 +23 -4
  8. data/app/controllers/roundhouse_ui/dead_controller.rb +26 -12
  9. data/app/controllers/roundhouse_ui/errors_controller.rb +30 -8
  10. data/app/controllers/roundhouse_ui/jobs_controller.rb +5 -1
  11. data/app/controllers/roundhouse_ui/queues_controller.rb +56 -7
  12. data/app/controllers/roundhouse_ui/recurring_controller.rb +12 -0
  13. data/app/controllers/roundhouse_ui/retries_controller.rb +33 -12
  14. data/app/controllers/roundhouse_ui/scheduled_controller.rb +3 -8
  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 +253 -10
  19. data/app/helpers/roundhouse_ui/nav_helper.rb +3 -3
  20. data/app/helpers/roundhouse_ui/observability_helper.rb +113 -27
  21. data/app/helpers/roundhouse_ui/tags_helper.rb +30 -3
  22. data/app/views/layouts/roundhouse_ui/application.html.erb +1181 -92
  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 +23 -15
  26. data/app/views/roundhouse_ui/errors/index.html.erb +28 -9
  27. data/app/views/roundhouse_ui/jobs/show.html.erb +7 -3
  28. data/app/views/roundhouse_ui/metrics/show.html.erb +26 -3
  29. data/app/views/roundhouse_ui/queues/index.html.erb +53 -13
  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 +17 -15
  33. data/app/views/roundhouse_ui/scheduled/index.html.erb +10 -10
  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 +2 -4
  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 +13 -3
  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 +8 -11
  59. data/lib/roundhouse_ui/theme.rb +298 -0
  60. data/lib/roundhouse_ui/version.rb +1 -1
  61. data/lib/roundhouse_ui.rb +200 -5
  62. data/lib/tasks/roundhouse_ui_tasks.rake +84 -4
  63. metadata +29 -7
@@ -1,18 +1,70 @@
1
1
  module RoundhouseUi
2
2
  class QueuesController < ApplicationController
3
- before_action :require_writable!, only: %i[purge pause resume]
3
+ include JobSetBrowsing
4
+
5
+ # Taking a backup writes nothing an operator can lose; restoring one does, and
6
+ # that stays guarded. This was previously expressed by omission from an `only:`
7
+ # list, which is indistinguishable from having forgotten.
8
+ allow_in_read_only :snapshot
9
+ requires_capability :snapshots, only: %i[snapshot]
10
+
11
+ # A queue is not a job, so class= and error= have nothing to apply to here.
12
+ # queue= narrows the LIST to one queue by exact name; free text stays a substring
13
+ # on the name. One grammar, one bar, honest about what it honours on this page.
14
+ INDEX_FILTER_KEYS = %w[queue text].freeze
4
15
 
5
16
  def index
6
- @query = params[:q].to_s.strip
7
- @queues = backend.queues
17
+ # Re-parsed against this page's key subset, not the job-set one: @filter from
18
+ # the concern's before_action honours class/error/tag, none of which can narrow
19
+ # a list of queues, and a pill that filters nothing is the phantom filter the
20
+ # bar exists to prevent.
21
+ @filter = FilterQuery.from_params(params, keys: INDEX_FILTER_KEYS)
22
+ @query = @filter.text
23
+ # queue_summaries reads depth and latency for every queue in one batch.
24
+ # Mapping over backend.queues instead costs a round-trip per queue per
25
+ # column, which on an app with sixty queues is most of a page render.
26
+ @queues = backend.queue_summaries
27
+ @total_size = @queues.sum(&:size)
28
+ @queues = [] if @filter.invalid?
8
29
  @queues = @queues.select { |q| q.name.to_s.downcase.include?(@query.downcase) } if @query.present?
30
+ # queue= is exact, the same as everywhere else, so a queue pill clicked on a
31
+ # job row and one typed here mean the same thing.
32
+ @queues = @queues.select { |q| @filter.matches_facet?(:queue, q.name) } if @filter.queue
33
+ # Worst first, matching the dashboard and Sidekiq's own convention. Sorted
34
+ # by name the one backed-up queue sits wherever the alphabet puts it,
35
+ # behind however many rows of zeros.
36
+ @queues = @queues.sort_by { |q| [ -q.latency.to_f, -q.size.to_i, q.name.to_s ] }
9
37
  @paused = backend.paused_queues
38
+ # State filter. Counts come from the unfiltered set so the chips still say
39
+ # how many are on the other side of the filter — a chip that reads "0" once
40
+ # you have selected it is useless for getting back.
41
+ @state = params[:state].to_s
42
+ @state_counts = { "paused" => @queues.count { |q| @paused.include?(q.name) } }
43
+ @state_counts["active"] = @queues.size - @state_counts["paused"]
44
+ case @state
45
+ when "paused" then @queues = @queues.select { |q| @paused.include?(q.name) }
46
+ when "active" then @queues = @queues.reject { |q| @paused.include?(q.name) }
47
+ end
10
48
  # Native-pause backends (Solid Queue) enforce pauses without a fetcher, so
11
49
  # they never trigger the "not enforced" warning.
12
50
  @fetch_installed = backend.supports?(:native_pause) ||
13
51
  (backend.respond_to?(:fetch_installed?) && backend.fetch_installed?)
14
52
  end
15
53
 
54
+ # The jobs waiting on one queue — what Sidekiq Web shows and we did not.
55
+ # Goes through the shared browse path, so it pages rather than loading a
56
+ # queue that could hold hundreds of thousands of jobs, and inherits search
57
+ # and the tag filter for free.
58
+ def show
59
+ @name = params[:name]
60
+ @page = [ params[:page].to_i, 1 ].max
61
+ summary = backend.queue_summaries.find { |q| q.name == @name }
62
+ @total = summary&.size.to_i
63
+ @latency = summary&.latency
64
+ @paused = backend.paused_queues.include?(@name)
65
+ @jobs, @has_next = browse(backend.queue_jobs(@name), @query, @page, PER_PAGE, tag: @tag)
66
+ end
67
+
16
68
  # Real, OSS-supported destructive action: empties the queue in Redis.
17
69
  def purge
18
70
  backend.queue(params[:name]).clear
@@ -37,9 +89,6 @@ module RoundhouseUi
37
89
 
38
90
  private
39
91
 
40
- def require_writable!
41
- return unless RoundhouseUi.read_only
42
- redirect_to queues_path, alert: "Roundhouse is in read-only mode — queue actions are disabled."
43
- end
92
+ def read_only_redirect_path = queues_path
44
93
  end
45
94
  end
@@ -0,0 +1,12 @@
1
+ module RoundhouseUi
2
+ # Periodic work, whichever scheduler defines it. Read-only: the schedule
3
+ # belongs in the code that declares it, and a UI that silently changes a
4
+ # production schedule is a different risk conversation. See #62.
5
+ class RecurringController < ApplicationController
6
+ def index
7
+ @tasks = Recurring.tasks
8
+ @sources = @tasks.map(&:source).uniq
9
+ @overdue = @tasks.count { |t| t.status == :overdue }
10
+ end
11
+ end
12
+ end
@@ -2,14 +2,11 @@ module RoundhouseUi
2
2
  class RetriesController < ApplicationController
3
3
  include JobSetBrowsing
4
4
 
5
- before_action :require_writable!, only: %i[requeue destroy bulk_all]
5
+ guard_in_read_only :preview
6
6
 
7
7
  def index
8
- @query = params[:q].to_s.strip
9
8
  @page = [ params[:page].to_i, 1 ].max
10
9
  @total = backend.retry_set.size
11
- @tag = tag_filter
12
- @queue_filter = queue_filter
13
10
  @jobs, @has_next = browse(backend.retry_set, @query, @page, PER_PAGE, tag: @tag)
14
11
  end
15
12
 
@@ -29,19 +26,43 @@ module RoundhouseUi
29
26
  # Smart bulk: retry/delete EVERY job matching the current filter, capped for
30
27
  # safety. Offered only when a filter is active.
31
28
  def bulk_all
32
- @queue_filter = queue_filter
33
- count, capped = bulk_apply(backend.retry_set, params[:q].to_s.strip, params[:op], BULK_CAP, tag: tag_filter)
29
+ found = bulk_apply(backend.retry_set, @query, params[:op], BULK_CAP, tag: @tag)
30
+
31
+ # An unfiltered bulk_all selected every entry and reported it as a match.
32
+ # The comment above claimed this was "only offered when a filter is active" —
33
+ # and it was only OFFERED that way; the route had no gate. bulk_matches now
34
+ # refuses at the chokepoint, and this says so out loud rather than reporting
35
+ # "Deleted 0 matching job(s)", which would read like an empty set.
36
+ if found.unfiltered
37
+ return redirect_to retries_path, alert: found.reason
38
+ end
39
+
34
40
  verb = params[:op] == "delete" ? "Deleted" : "Re-enqueued"
35
- note = "#{verb} #{count} matching job(s)."
36
- note += " Stopped at the #{JobSetBrowsing::BULK_CAP} cap — run again for more." if capped
41
+ note = "#{verb} #{found.entries.size} matching job(s)."
42
+ note += " Stopped at the #{JobSetBrowsing::BULK_CAP} cap — run again for more." if found.capped
37
43
  redirect_to retries_path, notice: note
38
44
  end
39
45
 
46
+ # A dry run: the count tells you how many match, this tells you which (#37).
47
+ def preview
48
+ @op = params[:op] == "delete" ? "delete" : "retry"
49
+ @matched = bulk_matches(backend.retry_set, @query, JobSetBrowsing::BULK_CAP, tag: @tag)
50
+ @confirm_path = bulk_all_retries_path
51
+ @back_path = retries_path
52
+ # "retry", not "retries". bulk_preview renders job_path(set: @set) per row, and
53
+ # routes constrain set to /dead|retry|scheduled/ — so this 500'd the dry run
54
+ # before every bulk delete on this page, but ONLY when at least one job
55
+ # matched, because the template touches job_path inside the row loop. An empty
56
+ # preview rendered fine, which is why nothing noticed. Copied from
57
+ # dead_controller with the noun substituted; see also the concern this pair
58
+ # should share.
59
+ @set = "retry"
60
+ @noun = "retry"
61
+ render "roundhouse_ui/shared/bulk_preview"
62
+ end
63
+
40
64
  private
41
65
 
42
- def require_writable!
43
- return unless RoundhouseUi.read_only
44
- redirect_to retries_path, alert: "Roundhouse is in read-only mode — retry and delete are disabled."
45
- end
66
+ def read_only_redirect_path = retries_path
46
67
  end
47
68
  end
@@ -2,14 +2,12 @@ module RoundhouseUi
2
2
  class ScheduledController < ApplicationController
3
3
  include JobSetBrowsing
4
4
 
5
- before_action :require_writable!, only: %i[enqueue destroy]
5
+ # add_to_queue is unimplemented outside Sidekiq.
6
+ requires_capability :enqueue_now, only: %i[enqueue]
6
7
 
7
8
  def index
8
- @query = params[:q].to_s.strip
9
9
  @page = [ params[:page].to_i, 1 ].max
10
10
  @total = backend.scheduled_set.size
11
- @tag = tag_filter
12
- @queue_filter = queue_filter
13
11
  @jobs, @has_next = browse(backend.scheduled_set, @query, @page, PER_PAGE, tag: @tag)
14
12
  end
15
13
 
@@ -28,9 +26,6 @@ module RoundhouseUi
28
26
 
29
27
  private
30
28
 
31
- def require_writable!
32
- return unless RoundhouseUi.read_only
33
- redirect_to scheduled_path, alert: "Roundhouse is in read-only mode — enqueue and delete are disabled."
34
- end
29
+ def read_only_redirect_path = scheduled_path
35
30
  end
36
31
  end
@@ -0,0 +1,15 @@
1
+ module RoundhouseUi
2
+ # Per-viewer preferences. Everything here is stored in the browser and applied
3
+ # client-side — nothing is written server-side, so one operator's choices never
4
+ # change what anyone else sees, and there is no state to migrate or clean up.
5
+ #
6
+ # The server's only job is to say what is on offer: which palettes exist, and
7
+ # what the host defaults are when a viewer has expressed no preference.
8
+ class SettingsController < ApplicationController
9
+ def show
10
+ @palettes = Theme.selectable
11
+ @host_poll = RoundhouseUi.poll_interval
12
+ @host_theme = RoundhouseUi.theme.present?
13
+ end
14
+ end
15
+ end
@@ -1,7 +1,9 @@
1
1
  module RoundhouseUi
2
2
  class SnapshotsController < ApplicationController
3
- before_action :require_writable!, only: %i[restore destroy]
4
-
3
+ # Snapshots read Sidekiq's queues through Redis. On a backend without either they
4
+ # captured zero jobs and said "Snapshot saved" — beside a Purge telling you to
5
+ # snapshot first.
6
+ requires_capability :snapshots, only: %i[index show restore destroy create]
5
7
  def index
6
8
  @snapshots = RoundhouseUi::Snapshots.all
7
9
  end
@@ -18,9 +20,6 @@ module RoundhouseUi
18
20
 
19
21
  private
20
22
 
21
- def require_writable!
22
- return unless RoundhouseUi.read_only
23
- redirect_to snapshots_path, alert: "Roundhouse is in read-only mode — restore and delete are disabled."
24
- end
23
+ def read_only_redirect_path = snapshots_path
25
24
  end
26
25
  end
@@ -2,8 +2,6 @@ module RoundhouseUi
2
2
  # The running Sidekiq process fleet, straight from Sidekiq::ProcessSet.
3
3
  # "Quiet" stops a process from pulling new work; "Stop" begins shutdown.
4
4
  class WorkersController < ApplicationController
5
- before_action :require_writable!, only: %i[quiet stop]
6
-
7
5
  def index
8
6
  @processes = backend.process_set.to_a
9
7
  @fetch_active = RoundhouseUi::Pause.fetch_installed?
@@ -21,13 +19,10 @@ module RoundhouseUi
21
19
 
22
20
  private
23
21
 
22
+ def read_only_redirect_path = workers_path
23
+
24
24
  def find_process(identity)
25
25
  backend.process_set.find { |process| process.identity == identity }
26
26
  end
27
-
28
- def require_writable!
29
- return unless RoundhouseUi.read_only
30
- redirect_to workers_path, alert: "Roundhouse is in read-only mode — quiet and stop are disabled."
31
- end
32
27
  end
33
28
  end
@@ -1,5 +1,13 @@
1
1
  module RoundhouseUi
2
2
  module ApplicationHelper
3
+ # The class name a row should show: the real job class, not the ActiveJob
4
+ # adapter's wrapper. Resolved here rather than in each template so every
5
+ # surface prints the string the Errors page groups on and the APM link
6
+ # searches for.
7
+ def job_display_class(entry)
8
+ RoundhouseUi.unwrapped_class(entry.klass, entry.item)
9
+ end
10
+
3
11
  # A job row's identity cell. Class on the first line where the eye lands,
4
12
  # jid on a second, dimmer line — one long line of class + hex + link is
5
13
  # unreadable once the row also carries squad, queue and error.
@@ -10,6 +18,85 @@ module RoundhouseUi
10
18
  ])
11
19
  end
12
20
 
21
+ # A URL that keeps every active filter, naming only what changes. Pass a key
22
+ # as nil to clear just that one.
23
+ #
24
+ # Never enumerate filter params by hand in a view. That is what let the pager
25
+ # drop the class filter on page two, and the confirm form drop it between the
26
+ # dry run and the deletion.
27
+ # Editing needs BOTH the host's opt-in and a backend that can push. Six views
28
+ # read the config flag directly; only this reads the capability too.
29
+ def job_editing?
30
+ RoundhouseUi.allow_job_editing && RoundhouseUi.backend.supports?(:enqueue)
31
+ end
32
+
33
+ # Override keys that name a facet, and the FilterQuery field each one sets.
34
+ # Anything not listed here is transport (page, op) and passes straight through.
35
+ FACET_OVERRIDES = { class: :klass, error: :error, queue: :queue, tag: :tag }.freeze
36
+
37
+ def filter_params(overrides = {})
38
+ return overrides.compact unless respond_to?(:active_filters)
39
+
40
+ # A facet override edits the QUERY, it does not add a parameter. The filter
41
+ # now travels as one `?q=` string, so `filter_url(class: nil)` had to stop
42
+ # meaning "send class=nil alongside q" — which would have left the class
43
+ # sitting inside q, untouched, while the link claimed to clear it. Every ×
44
+ # in the console is one of these calls.
45
+ query = @filter || FilterQuery.none
46
+ transport = {}
47
+ overrides.each do |key, value|
48
+ # `q:` replaces the whole query — the one override that is not a facet edit.
49
+ # Left in transport it would have emitted a second q beside the real one.
50
+ next query = FilterQuery.parse(value) if key == :q
51
+
52
+ field = FACET_OVERRIDES[key]
53
+ field ? query = query.merge(field => value) : transport[key] = value
54
+ end
55
+
56
+ { q: query.to_s.presence }.compact.merge(transport.compact)
57
+ end
58
+
59
+ def filter_url(overrides = {})
60
+ url_for(filter_params(overrides).merge(only_path: true))
61
+ end
62
+
63
+ # Where "find more like this" goes, per set.
64
+ LIKE_PATHS = { "dead" => :dead_set_path, "retry" => :retries_path,
65
+ "scheduled" => :scheduled_path }.freeze
66
+
67
+ # "Find more like this": the same class, and where the set records one, the
68
+ # same error — the pair the Errors page already treats as one issue, so a row
69
+ # here and a row there mean the same thing.
70
+ #
71
+ # Emitted as exact class=/error= facets in ?q= — the same string you could type.
72
+ # No %, because this button reveals "delete all matching".
73
+ def find_like_link(set, job)
74
+ helper = LIKE_PATHS[set.to_s]
75
+ klass = job_display_class(job)
76
+ return nil if helper.nil? || klass.blank?
77
+
78
+ error = job.item["error_class"].presence
79
+ label = error ? "Find more #{klass} failing with #{error}" : "Find more #{klass}"
80
+ # Built through from_params, not build: build does not validate, so a class
81
+ # name carrying a quote would produce a link whose own q= the next request
82
+ # refuses — a control that visibly does nothing. No representable filter,
83
+ # no button.
84
+ # Braced: from_params takes a keyword now, so a bare `class:` would be read as
85
+ # one and leave the positional hash empty.
86
+ filter = FilterQuery.from_params({ class: klass, error: error })
87
+ return nil if filter.invalid?
88
+
89
+ query = { q: filter.to_s }
90
+
91
+ # No size modifier: it takes the page scale, like every other control in the
92
+ # Actions column it sits in. Asking for --sm here was picking a scale step by
93
+ # hand next to 30px siblings — the scale stopped arbitrary pixels, it could
94
+ # not stop me choosing the wrong one of the two it offers.
95
+ link_to icon(:filter), send(helper, **query),
96
+ class: "rh-btn rh-btn--icon", title: label,
97
+ aria: { label: label }
98
+ end
99
+
13
100
  # Relative time answers "is this soon?", the clock time answers "does that
14
101
  # land inside the maintenance window?". Operators need both, so show both
15
102
  # rather than making them hover or do the arithmetic.
@@ -23,17 +110,175 @@ module RoundhouseUi
23
110
  ], " · ")
24
111
  end
25
112
 
113
+ # The placeholder, BUILT from the facets the page honours rather than written out
114
+ # per page. Five pages had five hand-written strings — "search class, jid, error,
115
+ # or arg value…", "filter by job class, error, or squad…", "filter queues by
116
+ # name…" — none of which mentioned that `class=` was a thing you could type. A
117
+ # hand-written hint next to a grammar is a hint that goes stale the day the
118
+ # grammar changes, and it went stale the day the grammar shipped.
119
+ def filter_placeholder(keys = FilterQuery::KEYS, noun: "text")
120
+ facets = keys.reject { |k| k == "text" }
121
+ facets -= [ "tag" ] unless RoundhouseUi.job_tags
122
+ return "search #{noun}…" if facets.empty?
123
+
124
+ "#{facets.map { |k| "#{k}=" }.join(" ")} or just type to search #{noun}"
125
+ end
126
+
127
+ # What the bar can complete. Keys always; tag values from the host's DECLARED
128
+ # vocabulary and queue names from the set the page already knows about — both
129
+ # free, no extra scan.
130
+ #
131
+ # `class=` and `error=` values are deliberately absent: enumerating them means
132
+ # reading every entry in the set on every render, and the whole reason browse
133
+ # reads only one page is that a 50k dead set must stay cheap to open. Type them;
134
+ # the funnel on each row fills them in for you.
135
+ def filter_vocabulary
136
+ vocab = {}
137
+ declared = RoundhouseUi::Tags.filters
138
+ if declared.present?
139
+ pairs = declared.flat_map { |key, values| Array(values).map { |v| "#{key}:#{v}" } }
140
+ vocab["tag"] = pairs if pairs.any?
141
+ end
142
+ names = Array(@queues).map { |q| q.respond_to?(:name) ? q.name.to_s : q.to_s }.reject(&:empty?)
143
+ names = [ @name.to_s ] if names.empty? && @name.present?
144
+ vocab["queue"] = names.uniq.sort if names.any?
145
+ vocab
146
+ end
147
+
148
+ # Is the page showing a SUBSET of the set? A different question from
149
+ # any_filter?, which asks whether a filter may authorise a bulk action — and
150
+ # answers no for a refused query, deliberately and correctly.
151
+ #
152
+ # Reusing the authorisation predicate for display made a refusal render as
153
+ # "nothing is filtered". Every typo now refuses, where once only a 500-character
154
+ # query did, so `clas=Foo` in the box produced the heading "Dead set · 24 jobs"
155
+ # and the cell "Dead set is empty 🎉" — above twenty-four dead jobs, with a
156
+ # tick of congratulation. Found by looking at the running page; no test asked.
157
+ # Asks the FILTER, never the bulk gate. Routing display through any_filter?
158
+ # produced the same bug twice: a refused query rendered as "nothing filtered"
159
+ # (heading "Dead set · 24 jobs" over "Dead set is empty 🎉"), and then a degraded
160
+ # one did too — `tag=garbage queue=ai` drops the tag, applies the queue, and
161
+ # still claimed the whole set. any_filter? answers "may this authorise a bulk
162
+ # action" and correctly says no in both cases; it is not a display predicate and
163
+ # is no longer used as one.
164
+ def filtered_view?(query, tag)
165
+ return true if @filter&.invalid? # selects nothing, which is a subset
166
+ return @filter.any? if @filter # whatever SURVIVED parsing, dropped facets and all
167
+
168
+ any_filter?(query, tag)
169
+ end
170
+
171
+ # What an empty table says. Three cases, not two: the search was refused, the
172
+ # surviving filter matched nothing, or the set really is empty. Only the third
173
+ # gets the 🎉 — it used to get it in all three.
174
+ def empty_state(query, tag, all_clear:, noun: "jobs")
175
+ return "Nothing matched — the search above was not understood." if @filter&.invalid?
176
+ return "No #{noun} #{filter_description(query, tag)}." if filtered_view?(query, tag)
177
+
178
+ all_clear
179
+ end
180
+
26
181
  # Set heading that tells the truth under a filter. It used to always print
27
182
  # the whole-set size, so "Dead set · 19 jobs" sat above four filtered rows.
28
183
  def set_heading(label, showing:, total:, query: nil, tag: nil)
29
- filtered = any_filter?(query, tag)
184
+ filtered = filtered_view?(query, tag)
30
185
  count = filtered ? "#{number_with_delimiter showing} of #{number_with_delimiter total}" : number_with_delimiter(total)
31
- content_tag(:h2, class: "rh-h2") do
32
- safe_join([
33
- "#{label} · #{count} jobs",
34
- (filtered ? content_tag(:span, filter_description(query, tag), class: "hint") : nil)
35
- ].compact, " ")
186
+ # No prose restatement. The pills in the bar say which filters are on, in the
187
+ # same words you would type to reproduce them; the heading's job is the count.
188
+ # Saying it a second time in prose ("tagged squad: platform and failing with
189
+ # KeyError") was one of the four places one filter was rendered, and the one
190
+ # that could not be clicked to change it. filter_description still earns its
191
+ # place on the bulk confirm, where the sentence IS the thing being approved.
192
+ content_tag(:h2, "#{label} · #{count} jobs", class: "rh-h2")
193
+ end
194
+
195
+ # A one-line, redacted preview of a job's arguments. Sidekiq Web shows args
196
+ # in its queue listing and we did not, which made a queue of one class
197
+ # indistinguishable row to row — the whole question being "which of these is
198
+ # the one I care about".
199
+ #
200
+ # Truncated on purpose: the job page is where the full payload belongs, and a
201
+ # queue listing left open on a second monitor is the wrong place for a long
202
+ # arguments dump. Redaction still applies, but it is key-based, so a bare
203
+ # positional secret is not masked — same caveat as everywhere else args show.
204
+ def job_args_preview(entry, length: 70)
205
+ args = entry.args
206
+ return content_tag(:span, "—", class: "rh-sub") if args.blank?
207
+
208
+ full = RoundhouseUi::Redaction.apply(args).map { |a| a.is_a?(String) ? a : a.inspect }.join(", ")
209
+ content_tag(:span, truncate(full, length: length), class: "rh-sub rh-mono", title: full)
210
+ end
211
+
212
+ # Compact duration. Raw seconds stop being readable somewhere around a minute
213
+ # and are actively useless by four figures — "10058.0s" is nearly three hours
214
+ # and nobody reads it as that. Two units is all anyone acts on. See #31.
215
+ # Both delegate to RoundhouseUi so views and lib/ cannot drift apart again.
216
+ def duration(seconds) = RoundhouseUi.duration(seconds)
217
+ def duration_ms(ms) = RoundhouseUi.duration_ms(ms)
218
+
219
+ # Retry attempt as a ladder rather than a number. "retry 21" and "retry 3"
220
+ # are the same shape of text; twenty-one filled rungs going red is not. The
221
+ # count stays beside it, because a ladder answers "how bad" and not "how many".
222
+ def attempt_ladder(count, max = 25)
223
+ filled = count.to_i.clamp(0, max)
224
+ rungs = Array.new(max) do |i|
225
+ state = if i >= filled then nil
226
+ elsif filled > (max * 0.6) then "is-hot"
227
+ else "is-on"
228
+ end
229
+ content_tag(:i, "", class: state)
230
+ end
231
+ safe_join([
232
+ content_tag(:span, safe_join(rungs), class: "rh-ladder",
233
+ title: "attempt #{filled} of #{max}", aria: { hidden: true }),
234
+ content_tag(:span, "#{filled}/#{max}", class: "rh-sub rh-ladder-n")
235
+ ])
236
+ end
237
+
238
+ # How far through the wait a scheduled or retrying job is, as a ring. A job
239
+ # about to fire should not look like one parked for six hours.
240
+ #
241
+ # `since` is when the wait started; without it the ring cannot know the
242
+ # fraction and renders empty rather than guessing.
243
+ def countdown(at, since: nil, label: nil)
244
+ return content_tag(:span, "—", class: "rh-sub") if at.nil?
245
+
246
+ remaining = at.to_f - Time.now.to_f
247
+ turn = if since && (total = at.to_f - since.to_f) > 0
248
+ (1.0 - (remaining / total)).clamp(0.0, 1.0)
249
+ else
250
+ remaining <= 0 ? 1.0 : 0.0
36
251
  end
252
+ safe_join([
253
+ content_tag(:span, "", class: "rh-ring", style: "--rh-turn:#{turn.round(3)}turn",
254
+ aria: { hidden: true }),
255
+ content_tag(:span, label || job_time(at, overdue: "now"), class: "rh-sub")
256
+ ])
257
+ end
258
+
259
+ # An icon by name. Inline SVG by default; a class name when the host has its
260
+ # own icon font. Only our own SVG constants are marked html_safe — a
261
+ # host-supplied class name goes through content_tag and is escaped.
262
+ def icon(name, extra_class: nil)
263
+ markup = RoundhouseUi::Icons.markup(name)
264
+ return "".html_safe unless markup
265
+
266
+ if markup.start_with?("<svg")
267
+ content_tag(:span, markup.html_safe, class: [ "rh-ico", extra_class ].compact.join(" "))
268
+ else
269
+ content_tag(:i, "", class: [ markup, "rh-ico", extra_class ].compact.join(" "), aria: { hidden: true })
270
+ end
271
+ end
272
+
273
+ # The runbook for a job, as a link, or nil when the host declared none (#39).
274
+ # Cached per class per request like tags — the same page can ask for the same
275
+ # class dozens of times.
276
+ def runbook_link(klass, item = nil, label: "Runbook")
277
+ url = RoundhouseUi::Runbooks.for(klass, item, cache: (@rh_runbook_cache ||= {}))
278
+ return nil unless url
279
+
280
+ link_to label, url, class: "rh-runbook", target: "_blank", rel: "noopener noreferrer",
281
+ title: "Open the runbook for #{RoundhouseUi.unwrapped_class(klass, item)}"
37
282
  end
38
283
 
39
284
  # Queues carry meaning at a glance (critical vs low), so render them as a
@@ -43,10 +288,8 @@ module RoundhouseUi
43
288
  def queue_pill(name, link: false)
44
289
  return content_tag(:span, name, class: "rh-pill rh-mono") unless link
45
290
 
46
- active = @queue_filter == name.to_s
47
- link_to name, url_for(only_path: true, page: nil, q: @query.presence,
48
- tag: params[:tag].presence,
49
- queue: (active ? nil : name)),
291
+ active = (@filter&.queue) == name.to_s
292
+ link_to name, filter_url(page: nil, queue: (active ? nil : name)),
50
293
  class: "rh-pill rh-mono rh-pill-link#{' is-on' if active}",
51
294
  title: active ? "Clear queue filter" : "Show only #{name}"
52
295
  end
@@ -2,13 +2,13 @@ module RoundhouseUi
2
2
  module NavHelper
3
3
  # A sidebar nav item with active state and an optional live-updating badge
4
4
  # (filled client-side from /stats via the poll in the layout).
5
- def nav_link(label, path, icon:, badge: nil, badge_class: nil)
5
+ def nav_link(label, path, icon:, badge: nil, badge_class: nil, badge_title: nil)
6
6
  here = request.path == path || (path != root_path && request.path.start_with?(path))
7
7
  link_to path, class: "rh-nav#{' is-active' if here}" do
8
8
  safe_join([
9
- content_tag(:span, icon, class: "rh-ico"),
9
+ icon(icon),
10
10
  content_tag(:span, label, class: "rh-lbl"),
11
- badge ? content_tag(:span, "", class: "rh-badge #{badge_class}", data: { nav: badge }) : "".html_safe
11
+ badge ? content_tag(:span, "", class: "rh-badge #{badge_class}", title: badge_title, data: { nav: badge }) : "".html_safe
12
12
  ])
13
13
  end
14
14
  end