roundhouse_ui 0.1.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 (60) hide show
  1. checksums.yaml +7 -0
  2. data/MIT-LICENSE +20 -0
  3. data/README.md +166 -0
  4. data/Rakefile +3 -0
  5. data/app/assets/javascripts/roundhouse_ui/turbo.min.js +35 -0
  6. data/app/assets/stylesheets/roundhouse_ui/application.css +15 -0
  7. data/app/controllers/concerns/roundhouse_ui/job_set_browsing.rb +41 -0
  8. data/app/controllers/roundhouse_ui/application_controller.rb +75 -0
  9. data/app/controllers/roundhouse_ui/assets_controller.rb +16 -0
  10. data/app/controllers/roundhouse_ui/audit_controller.rb +7 -0
  11. data/app/controllers/roundhouse_ui/busy_controller.rb +29 -0
  12. data/app/controllers/roundhouse_ui/capsules_controller.rb +27 -0
  13. data/app/controllers/roundhouse_ui/dashboard_controller.rb +26 -0
  14. data/app/controllers/roundhouse_ui/dead_controller.rb +46 -0
  15. data/app/controllers/roundhouse_ui/errors_controller.rb +50 -0
  16. data/app/controllers/roundhouse_ui/jobs_controller.rb +94 -0
  17. data/app/controllers/roundhouse_ui/metrics_controller.rb +8 -0
  18. data/app/controllers/roundhouse_ui/queues_controller.rb +40 -0
  19. data/app/controllers/roundhouse_ui/redis_controller.rb +21 -0
  20. data/app/controllers/roundhouse_ui/retries_controller.rb +34 -0
  21. data/app/controllers/roundhouse_ui/scheduled_controller.rb +34 -0
  22. data/app/controllers/roundhouse_ui/snapshots_controller.rb +26 -0
  23. data/app/controllers/roundhouse_ui/workers_controller.rb +33 -0
  24. data/app/helpers/roundhouse_ui/application_helper.rb +4 -0
  25. data/app/helpers/roundhouse_ui/nav_helper.rb +24 -0
  26. data/app/helpers/roundhouse_ui/observability_helper.rb +13 -0
  27. data/app/views/layouts/roundhouse_ui/application.html.erb +365 -0
  28. data/app/views/roundhouse_ui/audit/index.html.erb +21 -0
  29. data/app/views/roundhouse_ui/busy/index.html.erb +23 -0
  30. data/app/views/roundhouse_ui/capsules/index.html.erb +22 -0
  31. data/app/views/roundhouse_ui/dashboard/show.html.erb +68 -0
  32. data/app/views/roundhouse_ui/dead/index.html.erb +46 -0
  33. data/app/views/roundhouse_ui/errors/index.html.erb +28 -0
  34. data/app/views/roundhouse_ui/jobs/_form.html.erb +24 -0
  35. data/app/views/roundhouse_ui/jobs/edit.html.erb +2 -0
  36. data/app/views/roundhouse_ui/jobs/new.html.erb +2 -0
  37. data/app/views/roundhouse_ui/jobs/show.html.erb +33 -0
  38. data/app/views/roundhouse_ui/metrics/show.html.erb +49 -0
  39. data/app/views/roundhouse_ui/queues/index.html.erb +45 -0
  40. data/app/views/roundhouse_ui/redis/show.html.erb +59 -0
  41. data/app/views/roundhouse_ui/retries/index.html.erb +33 -0
  42. data/app/views/roundhouse_ui/scheduled/index.html.erb +29 -0
  43. data/app/views/roundhouse_ui/shared/_pager.html.erb +15 -0
  44. data/app/views/roundhouse_ui/snapshots/index.html.erb +25 -0
  45. data/app/views/roundhouse_ui/workers/index.html.erb +39 -0
  46. data/config/routes.rb +54 -0
  47. data/lib/roundhouse_ui/audit.rb +25 -0
  48. data/lib/roundhouse_ui/cancel_middleware.rb +19 -0
  49. data/lib/roundhouse_ui/cancellation.rb +37 -0
  50. data/lib/roundhouse_ui/engine.rb +5 -0
  51. data/lib/roundhouse_ui/fetch.rb +36 -0
  52. data/lib/roundhouse_ui/metrics.rb +51 -0
  53. data/lib/roundhouse_ui/observability.rb +46 -0
  54. data/lib/roundhouse_ui/pause.rb +59 -0
  55. data/lib/roundhouse_ui/redaction.rb +33 -0
  56. data/lib/roundhouse_ui/snapshots.rb +90 -0
  57. data/lib/roundhouse_ui/version.rb +3 -0
  58. data/lib/roundhouse_ui.rb +73 -0
  59. data/lib/tasks/roundhouse_ui_tasks.rake +4 -0
  60. metadata +131 -0
@@ -0,0 +1,29 @@
1
+ module RoundhouseUi
2
+ # What's executing right now, from Sidekiq::WorkSet — the live in-flight jobs
3
+ # Sidekiq Web calls "Busy". Surfaces long-running (possibly hung) jobs, which
4
+ # the stock UI makes you eyeball.
5
+ class BusyController < ApplicationController
6
+ LONG_RUNNING = 60 # seconds
7
+
8
+ before_action :require_writable!, only: :cancel
9
+
10
+ def index
11
+ @threshold = LONG_RUNNING
12
+ @work = Sidekiq::WorkSet.new.map do |process_id, tid, work|
13
+ { process: process_id, tid: tid, queue: work.queue, run_at: work.run_at, job: work.job }
14
+ end
15
+ end
16
+
17
+ def cancel
18
+ RoundhouseUi::Cancellation.cancel!(params[:jid])
19
+ redirect_to busy_path, notice: "Cancellation requested for #{params[:jid]}."
20
+ end
21
+
22
+ private
23
+
24
+ def require_writable!
25
+ return unless RoundhouseUi.read_only
26
+ redirect_to busy_path, alert: "Roundhouse is in read-only mode — cancellation is disabled."
27
+ end
28
+ end
29
+ end
@@ -0,0 +1,27 @@
1
+ module RoundhouseUi
2
+ # Sidekiq 7+ capsules — isolated concurrency pools, each with its own queues.
3
+ # Aggregated across the running processes so you can see which capsule serves
4
+ # which queues (and answer "why isn't this queue draining?").
5
+ class CapsulesController < ApplicationController
6
+ def index
7
+ @capsules = aggregate
8
+ end
9
+
10
+ private
11
+
12
+ def aggregate
13
+ capsules = Hash.new { |h, k| h[k] = { name: k, concurrency: 0, processes: 0, queues: {} } }
14
+
15
+ Sidekiq::ProcessSet.new.each do |process|
16
+ (process.capsules || {}).each do |name, data|
17
+ cap = capsules[name]
18
+ cap[:concurrency] += data["concurrency"].to_i
19
+ cap[:processes] += 1
20
+ (data["weights"] || {}).each { |queue, weight| cap[:queues][queue] = weight }
21
+ end
22
+ end
23
+
24
+ capsules.values.sort_by { |c| c[:name] }
25
+ end
26
+ end
27
+ end
@@ -0,0 +1,26 @@
1
+ module RoundhouseUi
2
+ # The dashboard reads straight from Sidekiq's API — no database, no models.
3
+ # Everything here comes out of Redis via Sidekiq::Stats / Sidekiq::Queue.
4
+ class DashboardController < ApplicationController
5
+ def show
6
+ @stats = Sidekiq::Stats.new
7
+ @queues = Sidekiq::Queue.all
8
+ end
9
+
10
+ # Polled by the dashboard for live counts (same approach Sidekiq Web uses —
11
+ # cheap JSON, no WebSocket/build step required).
12
+ def stats
13
+ s = Sidekiq::Stats.new
14
+ render json: {
15
+ processed: s.processed,
16
+ failed: s.failed,
17
+ enqueued: s.enqueued,
18
+ busy: s.workers_size,
19
+ scheduled: s.scheduled_size,
20
+ retries: s.retry_size,
21
+ dead: s.dead_size,
22
+ queues: Sidekiq::Queue.all.size
23
+ }
24
+ end
25
+ end
26
+ end
@@ -0,0 +1,46 @@
1
+ module RoundhouseUi
2
+ class DeadController < ApplicationController
3
+ include JobSetBrowsing
4
+
5
+ before_action :require_writable!, only: %i[requeue destroy bulk]
6
+
7
+ def index
8
+ @query = params[:q].to_s.strip
9
+ @page = [ params[:page].to_i, 1 ].max
10
+ @total = Sidekiq::DeadSet.new.size
11
+ @jobs, @has_next = browse(Sidekiq::DeadSet.new, @query, @page)
12
+ end
13
+
14
+ def requeue
15
+ entry = Sidekiq::DeadSet.new.find_job(params[:jid])
16
+ entry&.retry
17
+ redirect_to dead_set_path, notice: entry ? "Re-enqueued #{params[:jid]}." : "Job is no longer in the dead set."
18
+ end
19
+
20
+ def destroy
21
+ entry = Sidekiq::DeadSet.new.find_job(params[:jid])
22
+ entry&.delete
23
+ redirect_to dead_set_path, notice: entry ? "Deleted #{params[:jid]}." : "Job is no longer in the dead set."
24
+ end
25
+
26
+ # Act on many at once: retry or delete every selected job in one request.
27
+ def bulk
28
+ set = Sidekiq::DeadSet.new
29
+ count = 0
30
+ Array(params[:jids]).each do |jid|
31
+ entry = set.find_job(jid) or next
32
+ params[:op] == "delete" ? entry.delete : entry.retry
33
+ count += 1
34
+ end
35
+ verb = params[:op] == "delete" ? "Deleted" : "Re-enqueued"
36
+ redirect_to dead_set_path, notice: "#{verb} #{count} job(s)."
37
+ end
38
+
39
+ private
40
+
41
+ def require_writable!
42
+ return unless RoundhouseUi.read_only
43
+ redirect_to dead_set_path, alert: "Roundhouse is in read-only mode — retry and delete are disabled."
44
+ end
45
+ end
46
+ end
@@ -0,0 +1,50 @@
1
+ module RoundhouseUi
2
+ # Groups failing jobs across the retry + dead sets by a fingerprint of
3
+ # (job class + error class) — so one bad deploy reads as a single issue with
4
+ # a count, not five thousand identical rows. The aggregation Sidekiq Web lacks.
5
+ class ErrorsController < ApplicationController
6
+ SCAN_LIMIT = 1_000 # cap entries scanned per pass; shown honestly in the view
7
+
8
+ def index
9
+ @query = params[:q].to_s.strip
10
+ @scan_limit = SCAN_LIMIT
11
+ @groups, @scanned, @truncated = aggregate
12
+ end
13
+
14
+ private
15
+
16
+ def aggregate
17
+ groups = {}
18
+ scanned = 0
19
+ truncated = false
20
+
21
+ { "retry" => Sidekiq::RetrySet.new, "dead" => Sidekiq::DeadSet.new }.each do |source, set|
22
+ set.each do |entry|
23
+ scanned += 1
24
+ if scanned > SCAN_LIMIT
25
+ truncated = true
26
+ break
27
+ end
28
+ record(groups, source, entry)
29
+ end
30
+ break if truncated
31
+ end
32
+
33
+ list = groups.values.sort_by { |g| -g[:count] }
34
+ list = list.select { |g| "#{g[:klass]} #{g[:error]}".downcase.include?(@query.downcase) } if @query.present?
35
+ [ list, scanned, truncated ]
36
+ end
37
+
38
+ def record(groups, source, entry)
39
+ error = entry.item["error_class"] || "UnknownError"
40
+ group = (groups["#{entry.klass}|#{error}"] ||= {
41
+ klass: entry.klass, error: error, count: 0, last_at: nil, queues: [], sources: []
42
+ })
43
+ group[:count] += 1
44
+ group[:queues] |= [ entry.queue ]
45
+ group[:sources] |= [ source ]
46
+ at = entry.at
47
+ group[:last_at] = at if at && (group[:last_at].nil? || at > group[:last_at])
48
+ end
49
+ end
50
+ end
@@ -0,0 +1,94 @@
1
+ module RoundhouseUi
2
+ # Enqueue new jobs and edit/re-enqueue existing ones. Opt-in via
3
+ # RoundhouseUi.allow_job_editing (off by default — a sharp tool).
4
+ #
5
+ # Sidekiq has no in-place edit: a job in a set is keyed by its payload, so an
6
+ # "edit" is delete-the-old + push-the-modified.
7
+ class JobsController < ApplicationController
8
+ SET_BUILDERS = {
9
+ "dead" => -> { Sidekiq::DeadSet.new },
10
+ "retry" => -> { Sidekiq::RetrySet.new },
11
+ "scheduled" => -> { Sidekiq::ScheduledSet.new }
12
+ }.freeze
13
+ REDIRECTS = { "dead" => :dead_set_path, "retry" => :retries_path, "scheduled" => :scheduled_path }.freeze
14
+
15
+ before_action :require_editing_enabled!, except: :show
16
+
17
+ # Read-only inspection — available without allow_job_editing.
18
+ def show
19
+ @set = params[:set]
20
+ @entry = find_entry or return redirect_to(root_path, alert: "Job not found.")
21
+ end
22
+
23
+ def new
24
+ @mode = :new
25
+ @job = { "class" => "", "queue" => "default", "args" => "[]" }
26
+ @action_path = jobs_path
27
+ end
28
+
29
+ def create
30
+ args = parse_args!(params[:args])
31
+ klass = params[:job_class].to_s.strip
32
+ raise ArgumentError, "Job class is required" if klass.empty?
33
+
34
+ queue = params[:queue].presence || "default"
35
+ Sidekiq::Client.push("class" => klass, "queue" => queue, "args" => args)
36
+ redirect_to queues_path, notice: "Enqueued #{klass} → #{queue}."
37
+ rescue ArgumentError => e
38
+ render_form_error(:new, jobs_path, e)
39
+ end
40
+
41
+ def edit
42
+ @entry = find_entry or return redirect_to(root_path, alert: "Job not found.")
43
+ @mode = :edit
44
+ @job = {
45
+ "class" => @entry.item["class"],
46
+ "queue" => @entry.queue,
47
+ "args" => JSON.pretty_generate(@entry.item["args"] || [])
48
+ }
49
+ @action_path = job_path(set: params[:set], jid: params[:jid])
50
+ end
51
+
52
+ def update
53
+ entry = find_entry or return redirect_to(root_path, alert: "Job not found.")
54
+ args = parse_args!(params[:args])
55
+ klass = params[:job_class].presence || entry.item["class"]
56
+ queue = params[:queue].presence || entry.queue
57
+
58
+ entry.delete
59
+ Sidekiq::Client.push("class" => klass, "queue" => queue, "args" => args)
60
+ redirect_to send(REDIRECTS[params[:set]]), notice: "Edited & re-enqueued #{klass} → #{queue}."
61
+ rescue ArgumentError => e
62
+ @action_path = job_path(set: params[:set], jid: params[:jid])
63
+ render_form_error(:edit, @action_path, e)
64
+ end
65
+
66
+ private
67
+
68
+ def find_entry
69
+ builder = SET_BUILDERS[params[:set]] or return nil
70
+ builder.call.find_job(params[:jid])
71
+ end
72
+
73
+ def parse_args!(raw)
74
+ parsed = JSON.parse(raw.to_s)
75
+ raise ArgumentError, 'Arguments must be a JSON array, e.g. [123, "abc"]' unless parsed.is_a?(Array)
76
+ parsed
77
+ rescue JSON::ParserError
78
+ raise ArgumentError, "Arguments must be valid JSON"
79
+ end
80
+
81
+ def render_form_error(mode, action_path, error)
82
+ flash.now[:alert] = error.message
83
+ @mode = mode
84
+ @action_path = action_path
85
+ @job = { "class" => params[:job_class], "queue" => params[:queue], "args" => params[:args] }
86
+ render mode, status: :unprocessable_entity
87
+ end
88
+
89
+ def require_editing_enabled!
90
+ return if RoundhouseUi.allow_job_editing && !RoundhouseUi.read_only
91
+ redirect_to root_path, alert: "Job editing is off — set RoundhouseUi.allow_job_editing = true (and disable read-only)."
92
+ end
93
+ end
94
+ end
@@ -0,0 +1,8 @@
1
+ module RoundhouseUi
2
+ # Derived metrics, kept off the dashboard so the at-a-glance view stays lean.
3
+ class MetricsController < ApplicationController
4
+ def show
5
+ @metrics = Metrics.new
6
+ end
7
+ end
8
+ end
@@ -0,0 +1,40 @@
1
+ module RoundhouseUi
2
+ class QueuesController < ApplicationController
3
+ before_action :require_writable!, only: %i[purge pause resume]
4
+
5
+ def index
6
+ @queues = Sidekiq::Queue.all
7
+ @paused = RoundhouseUi::Pause.paused_set
8
+ @fetch_installed = RoundhouseUi::Pause.fetch_installed?
9
+ end
10
+
11
+ # Real, OSS-supported destructive action: empties the queue in Redis.
12
+ def purge
13
+ Sidekiq::Queue.new(params[:name]).clear
14
+ redirect_to queues_path, notice: "Purged queue “#{params[:name]}”."
15
+ end
16
+
17
+ def pause
18
+ RoundhouseUi::Pause.pause!(params[:name])
19
+ redirect_to queues_path, notice: "Paused “#{params[:name]}”."
20
+ end
21
+
22
+ def resume
23
+ RoundhouseUi::Pause.unpause!(params[:name])
24
+ redirect_to queues_path, notice: "Resumed “#{params[:name]}”."
25
+ end
26
+
27
+ # Non-destructive backup — allowed even in read-only mode.
28
+ def snapshot
29
+ snap = RoundhouseUi::Snapshots.take(params[:name])
30
+ redirect_to queues_path, notice: "Snapshot saved — #{snap[:count]} job(s) from “#{params[:name]}”."
31
+ end
32
+
33
+ private
34
+
35
+ def require_writable!
36
+ return unless RoundhouseUi.read_only
37
+ redirect_to queues_path, alert: "Roundhouse is in read-only mode — queue actions are disabled."
38
+ end
39
+ end
40
+ end
@@ -0,0 +1,21 @@
1
+ module RoundhouseUi
2
+ # Redis health from INFO — the "is Redis about to silently drop my jobs?" view
3
+ # that Sidekiq Web doesn't have. The headline signal: an eviction policy other
4
+ # than noeviction (with a memory cap) means Sidekiq data can be evicted.
5
+ class RedisController < ApplicationController
6
+ def show
7
+ @info = parse_info(Sidekiq.redis { |conn| conn.call("INFO") })
8
+ end
9
+
10
+ private
11
+
12
+ def parse_info(raw)
13
+ raw.to_s.each_line.each_with_object({}) do |line, info|
14
+ line = line.strip
15
+ next if line.empty? || line.start_with?("#")
16
+ key, value = line.split(":", 2)
17
+ info[key] = value if key && value
18
+ end
19
+ end
20
+ end
21
+ end
@@ -0,0 +1,34 @@
1
+ module RoundhouseUi
2
+ class RetriesController < ApplicationController
3
+ include JobSetBrowsing
4
+
5
+ before_action :require_writable!, only: %i[requeue destroy]
6
+
7
+ def index
8
+ @query = params[:q].to_s.strip
9
+ @page = [ params[:page].to_i, 1 ].max
10
+ @total = Sidekiq::RetrySet.new.size
11
+ @jobs, @has_next = browse(Sidekiq::RetrySet.new, @query, @page)
12
+ end
13
+
14
+ # Retry now — moves the job back to its queue immediately.
15
+ def requeue
16
+ entry = Sidekiq::RetrySet.new.find_job(params[:jid])
17
+ entry&.retry
18
+ redirect_to retries_path, notice: entry ? "Re-enqueued #{params[:jid]}." : "Job is no longer in the retry set."
19
+ end
20
+
21
+ def destroy
22
+ entry = Sidekiq::RetrySet.new.find_job(params[:jid])
23
+ entry&.delete
24
+ redirect_to retries_path, notice: entry ? "Deleted #{params[:jid]}." : "Job is no longer in the retry set."
25
+ end
26
+
27
+ private
28
+
29
+ def require_writable!
30
+ return unless RoundhouseUi.read_only
31
+ redirect_to retries_path, alert: "Roundhouse is in read-only mode — retry and delete are disabled."
32
+ end
33
+ end
34
+ end
@@ -0,0 +1,34 @@
1
+ module RoundhouseUi
2
+ class ScheduledController < ApplicationController
3
+ include JobSetBrowsing
4
+
5
+ before_action :require_writable!, only: %i[enqueue destroy]
6
+
7
+ def index
8
+ @query = params[:q].to_s.strip
9
+ @page = [ params[:page].to_i, 1 ].max
10
+ @total = Sidekiq::ScheduledSet.new.size
11
+ @jobs, @has_next = browse(Sidekiq::ScheduledSet.new, @query, @page)
12
+ end
13
+
14
+ # Enqueue now — pulls the job out of the schedule and onto its queue.
15
+ def enqueue
16
+ entry = Sidekiq::ScheduledSet.new.find_job(params[:jid])
17
+ entry&.add_to_queue
18
+ redirect_to scheduled_path, notice: entry ? "Enqueued #{params[:jid]} now." : "Job is no longer scheduled."
19
+ end
20
+
21
+ def destroy
22
+ entry = Sidekiq::ScheduledSet.new.find_job(params[:jid])
23
+ entry&.delete
24
+ redirect_to scheduled_path, notice: entry ? "Deleted #{params[:jid]}." : "Job is no longer scheduled."
25
+ end
26
+
27
+ private
28
+
29
+ def require_writable!
30
+ return unless RoundhouseUi.read_only
31
+ redirect_to scheduled_path, alert: "Roundhouse is in read-only mode — enqueue and delete are disabled."
32
+ end
33
+ end
34
+ end
@@ -0,0 +1,26 @@
1
+ module RoundhouseUi
2
+ class SnapshotsController < ApplicationController
3
+ before_action :require_writable!, only: %i[restore destroy]
4
+
5
+ def index
6
+ @snapshots = RoundhouseUi::Snapshots.all
7
+ end
8
+
9
+ def restore
10
+ count = RoundhouseUi::Snapshots.restore(params[:id])
11
+ redirect_to snapshots_path, notice: "Restored #{count} job(s) to their queue."
12
+ end
13
+
14
+ def destroy
15
+ RoundhouseUi::Snapshots.delete(params[:id])
16
+ redirect_to snapshots_path, notice: "Snapshot deleted."
17
+ end
18
+
19
+ private
20
+
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
25
+ end
26
+ end
@@ -0,0 +1,33 @@
1
+ module RoundhouseUi
2
+ # The running Sidekiq process fleet, straight from Sidekiq::ProcessSet.
3
+ # "Quiet" stops a process from pulling new work; "Stop" begins shutdown.
4
+ class WorkersController < ApplicationController
5
+ before_action :require_writable!, only: %i[quiet stop]
6
+
7
+ def index
8
+ @processes = Sidekiq::ProcessSet.new.to_a
9
+ @fetch_active = RoundhouseUi::Pause.fetch_installed?
10
+ end
11
+
12
+ def quiet
13
+ find_process(params[:identity])&.quiet!
14
+ redirect_to workers_path, notice: "Sent quiet to #{params[:identity]}."
15
+ end
16
+
17
+ def stop
18
+ find_process(params[:identity])&.stop!
19
+ redirect_to workers_path, notice: "Sent stop to #{params[:identity]}."
20
+ end
21
+
22
+ private
23
+
24
+ def find_process(identity)
25
+ Sidekiq::ProcessSet.new.find { |process| process.identity == identity }
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
+ end
33
+ end
@@ -0,0 +1,4 @@
1
+ module RoundhouseUi
2
+ module ApplicationHelper
3
+ end
4
+ end
@@ -0,0 +1,24 @@
1
+ module RoundhouseUi
2
+ module NavHelper
3
+ # A sidebar nav item with active state and an optional live-updating badge
4
+ # (filled client-side from /stats via the poll in the layout).
5
+ def nav_link(label, path, icon:, badge: nil, badge_class: nil)
6
+ here = request.path == path || (path != root_path && request.path.start_with?(path))
7
+ link_to path, class: "rh-nav#{' is-active' if here}" do
8
+ safe_join([
9
+ content_tag(:span, icon, class: "rh-ico"),
10
+ content_tag(:span, label, class: "rh-lbl"),
11
+ badge ? content_tag(:span, "", class: "rh-badge #{badge_class}", data: { nav: badge }) : "".html_safe
12
+ ])
13
+ end
14
+ end
15
+
16
+ # Health label for a queue from its latency (oldest-job age, in seconds).
17
+ def queue_state(latency, paused: false)
18
+ return [ "Paused", "rh-st-paused" ] if paused
19
+ return [ "Stuck", "rh-st-crit" ] if latency > 600
20
+ return [ "At risk", "rh-st-warn" ] if latency > 60
21
+ [ "Healthy", "rh-st-ok" ]
22
+ end
23
+ end
24
+ end
@@ -0,0 +1,13 @@
1
+ module RoundhouseUi
2
+ module ObservabilityHelper
3
+ # Renders a deep-link to the configured observability tool for a job, or
4
+ # nothing when no adapter is configured (the default).
5
+ def trace_link(klass:, jid:, queue: nil)
6
+ adapter = RoundhouseUi.observability
7
+ url = adapter.job_url(klass: klass, jid: jid, queue: queue)
8
+ return unless url
9
+
10
+ link_to "↗ #{adapter.label}", url, target: "_blank", rel: "noopener", class: "rh-trace"
11
+ end
12
+ end
13
+ end