roundhouse_ui 0.10.0 → 0.11.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +4 -4
- data/README.md +563 -50
- data/app/controllers/concerns/roundhouse_ui/job_set_browsing.rb +190 -23
- data/app/controllers/roundhouse_ui/application_controller.rb +73 -0
- data/app/controllers/roundhouse_ui/audit_controller.rb +1 -0
- data/app/controllers/roundhouse_ui/busy_controller.rb +25 -5
- data/app/controllers/roundhouse_ui/dashboard_controller.rb +23 -4
- data/app/controllers/roundhouse_ui/dead_controller.rb +26 -12
- data/app/controllers/roundhouse_ui/errors_controller.rb +30 -8
- data/app/controllers/roundhouse_ui/jobs_controller.rb +5 -1
- data/app/controllers/roundhouse_ui/queues_controller.rb +56 -7
- data/app/controllers/roundhouse_ui/recurring_controller.rb +12 -0
- data/app/controllers/roundhouse_ui/retries_controller.rb +33 -12
- data/app/controllers/roundhouse_ui/scheduled_controller.rb +3 -8
- data/app/controllers/roundhouse_ui/settings_controller.rb +15 -0
- data/app/controllers/roundhouse_ui/snapshots_controller.rb +5 -6
- data/app/controllers/roundhouse_ui/workers_controller.rb +2 -7
- data/app/helpers/roundhouse_ui/application_helper.rb +253 -10
- data/app/helpers/roundhouse_ui/nav_helper.rb +3 -3
- data/app/helpers/roundhouse_ui/observability_helper.rb +113 -27
- data/app/helpers/roundhouse_ui/tags_helper.rb +30 -3
- data/app/views/layouts/roundhouse_ui/application.html.erb +1181 -92
- data/app/views/roundhouse_ui/busy/index.html.erb +30 -6
- data/app/views/roundhouse_ui/dashboard/show.html.erb +62 -17
- data/app/views/roundhouse_ui/dead/index.html.erb +23 -15
- data/app/views/roundhouse_ui/errors/index.html.erb +28 -9
- data/app/views/roundhouse_ui/jobs/show.html.erb +7 -3
- data/app/views/roundhouse_ui/metrics/show.html.erb +26 -3
- data/app/views/roundhouse_ui/queues/index.html.erb +53 -13
- data/app/views/roundhouse_ui/queues/show.html.erb +55 -0
- data/app/views/roundhouse_ui/recurring/index.html.erb +71 -0
- data/app/views/roundhouse_ui/retries/index.html.erb +17 -15
- data/app/views/roundhouse_ui/scheduled/index.html.erb +10 -10
- data/app/views/roundhouse_ui/settings/show.html.erb +91 -0
- data/app/views/roundhouse_ui/shared/_filter_error.html.erb +23 -0
- data/app/views/roundhouse_ui/shared/_pager.html.erb +2 -2
- data/app/views/roundhouse_ui/shared/_search_bar.html.erb +55 -0
- data/app/views/roundhouse_ui/shared/_search_help.html.erb +37 -0
- data/app/views/roundhouse_ui/shared/_tag_filter.html.erb +2 -4
- data/app/views/roundhouse_ui/shared/bulk_preview.html.erb +69 -0
- data/config/routes.rb +9 -0
- data/lib/roundhouse_ui/backends/sidekiq.rb +124 -5
- data/lib/roundhouse_ui/backends/solid_queue.rb +54 -3
- data/lib/roundhouse_ui/demo.rb +130 -0
- data/lib/roundhouse_ui/error_groups.rb +13 -3
- data/lib/roundhouse_ui/filter_query.rb +504 -0
- data/lib/roundhouse_ui/health.rb +43 -4
- data/lib/roundhouse_ui/history.rb +54 -0
- data/lib/roundhouse_ui/icons.rb +104 -0
- data/lib/roundhouse_ui/marks/datadog-lockup-white.svg +30 -0
- data/lib/roundhouse_ui/marks/datadog-lockup.svg +41 -0
- data/lib/roundhouse_ui/observability.rb +55 -7
- data/lib/roundhouse_ui/pause.rb +6 -11
- data/lib/roundhouse_ui/queue_summary.rb +11 -0
- data/lib/roundhouse_ui/recurring.rb +137 -0
- data/lib/roundhouse_ui/runbooks.rb +75 -0
- data/lib/roundhouse_ui/snapshots.rb +13 -2
- data/lib/roundhouse_ui/tags.rb +8 -11
- data/lib/roundhouse_ui/theme.rb +298 -0
- data/lib/roundhouse_ui/version.rb +1 -1
- data/lib/roundhouse_ui.rb +200 -5
- data/lib/tasks/roundhouse_ui_tasks.rake +84 -4
- metadata +29 -7
|
@@ -1,18 +1,70 @@
|
|
|
1
1
|
module RoundhouseUi
|
|
2
2
|
class QueuesController < ApplicationController
|
|
3
|
-
|
|
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
|
-
|
|
7
|
-
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
33
|
-
|
|
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} #{
|
|
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
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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
|
|
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 =
|
|
184
|
+
filtered = filtered_view?(query, tag)
|
|
30
185
|
count = filtered ? "#{number_with_delimiter showing} of #{number_with_delimiter total}" : number_with_delimiter(total)
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
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 = @
|
|
47
|
-
link_to name,
|
|
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
|
-
|
|
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
|