job_board 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 (37) hide show
  1. checksums.yaml +7 -0
  2. data/CHANGELOG.md +16 -0
  3. data/MIT-LICENSE +20 -0
  4. data/README.md +129 -0
  5. data/app/controllers/job_board/application_controller.rb +24 -0
  6. data/app/controllers/job_board/assets_controller.rb +19 -0
  7. data/app/controllers/job_board/jobs_controller.rb +88 -0
  8. data/app/controllers/job_board/processes_controller.rb +7 -0
  9. data/app/controllers/job_board/queues/pauses_controller.rb +15 -0
  10. data/app/controllers/job_board/queues_controller.rb +9 -0
  11. data/app/controllers/job_board/recurring_tasks_controller.rb +7 -0
  12. data/app/helpers/job_board/application_helper.rb +40 -0
  13. data/app/models/job_board/job_presenter.rb +37 -0
  14. data/app/models/job_board/job_row.rb +48 -0
  15. data/app/models/job_board/jobs_query.rb +86 -0
  16. data/app/models/job_board/latency_sla.rb +31 -0
  17. data/app/models/job_board/page.rb +35 -0
  18. data/app/models/job_board/process_tree.rb +49 -0
  19. data/app/views/job_board/jobs/_filters.html.erb +16 -0
  20. data/app/views/job_board/jobs/_job_row.html.erb +31 -0
  21. data/app/views/job_board/jobs/index.html.erb +63 -0
  22. data/app/views/job_board/jobs/show.html.erb +56 -0
  23. data/app/views/job_board/processes/_process.html.erb +28 -0
  24. data/app/views/job_board/processes/index.html.erb +32 -0
  25. data/app/views/job_board/queues/index.html.erb +50 -0
  26. data/app/views/job_board/recurring_tasks/index.html.erb +37 -0
  27. data/app/views/job_board/shared/_nav.html.erb +9 -0
  28. data/app/views/job_board/shared/_pagination.html.erb +5 -0
  29. data/app/views/layouts/job_board/application.html.erb +18 -0
  30. data/config/routes.rb +25 -0
  31. data/lib/job_board/assets/application.css +288 -0
  32. data/lib/job_board/assets/application.js +39 -0
  33. data/lib/job_board/configuration.rb +32 -0
  34. data/lib/job_board/engine.rb +5 -0
  35. data/lib/job_board/version.rb +3 -0
  36. data/lib/job_board.rb +17 -0
  37. metadata +108 -0
checksums.yaml ADDED
@@ -0,0 +1,7 @@
1
+ ---
2
+ SHA256:
3
+ metadata.gz: b9383d573ca40430673ec3e2ad7436edec021a856b72ebfcd45c3e557b8b4ac7
4
+ data.tar.gz: 6d0849f4f83e665364ea8769483adbbc53f9189ff8ddd50de6fb957d1adc76ae
5
+ SHA512:
6
+ metadata.gz: 544bcb5aa2cbddefa3d4ee47f708398e7fcda871addfbf95ac7184d5c5e48b332da2a34aec62ebb60158461c62c637d664df3ee0177336afa51b8d8cd93a1f39
7
+ data.tar.gz: 28f552a8ed1840b90b7984806dc2794674819ea442d9e1b6da439bc0fd4cbf465c6900544e39a3f9fe5b5ebc65dbf6771b4207f06f0f2807f25763f0ba2de0c4
data/CHANGELOG.md ADDED
@@ -0,0 +1,16 @@
1
+ # Changelog
2
+
3
+ ## 0.1.0
4
+
5
+ Initial release.
6
+
7
+ - Queues overview with per-queue ready count, latency, failed count, and pause/resume.
8
+ - Latency-SLA aware: `within_*` queue names (e.g. `within_5_minutes`) set their own
9
+ warning threshold and sort order; configurable per queue or globally.
10
+ - Jobs browsing by status (ready, scheduled, in progress, blocked, failed, finished)
11
+ with queue/class filters, keyset pagination, and a job detail page.
12
+ - Failed job management: retry/discard individually or in bulk.
13
+ - Workers page: supervisor tree, heartbeat freshness, stale detection, orphaned-job warning.
14
+ - Recurring tasks page with last/next run times.
15
+ - Zero-dependency UI served by the engine itself, with polling auto-refresh.
16
+ - Optional HTTP Basic auth via `JobBoard.configure`.
data/MIT-LICENSE ADDED
@@ -0,0 +1,20 @@
1
+ Copyright 2026 Mike
2
+
3
+ Permission is hereby granted, free of charge, to any person obtaining
4
+ a copy of this software and associated documentation files (the
5
+ "Software"), to deal in the Software without restriction, including
6
+ without limitation the rights to use, copy, modify, merge, publish,
7
+ distribute, sublicense, and/or sell copies of the Software, and to
8
+ permit persons to whom the Software is furnished to do so, subject to
9
+ the following conditions:
10
+
11
+ The above copyright notice and this permission notice shall be
12
+ included in all copies or substantial portions of the Software.
13
+
14
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
15
+ EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF
16
+ MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND
17
+ NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE
18
+ LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION
19
+ OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
20
+ WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
data/README.md ADDED
@@ -0,0 +1,129 @@
1
+ # Job Board
2
+
3
+ A mountable Rails engine that gives you a dashboard for [Solid Queue](https://github.com/rails/solid_queue):
4
+ queue latency at a glance, jobs by status, failed-job management, worker health, and recurring tasks.
5
+
6
+ ## Features
7
+
8
+ - **Queues** — every queue with its ready-job count, **current latency** (how long the oldest
9
+ ready job has been waiting), and pause/resume controls.
10
+ - **Jobs** — browse by status (ready, scheduled, in progress, blocked, failed, finished) with
11
+ queue and class filters, keyset pagination that stays fast on huge tables, and a detail page
12
+ showing arguments, timestamps, and the full error with backtrace.
13
+ - **Failed job management** — retry or discard individual jobs, or all failed jobs matching the
14
+ current filters (batched, safe for large sets).
15
+ - **Workers** — the supervisor → worker/dispatcher/scheduler tree with heartbeat freshness,
16
+ stale-process badges, per-worker in-progress jobs, and a warning when claimed jobs have been
17
+ orphaned by a dead process.
18
+ - **Recurring tasks** — each task's cron schedule, last run, and next run.
19
+ - **Zero dependencies** — the engine serves its own CSS and ~40 lines of vanilla JS. No
20
+ importmap, sprockets, propshaft, or Node requirement. Pages auto-refresh every few seconds.
21
+
22
+ ## Installation
23
+
24
+ Add the gem:
25
+
26
+ ```ruby
27
+ gem "job_board"
28
+ ```
29
+
30
+ Mount the engine:
31
+
32
+ ```ruby
33
+ # config/routes.rb
34
+ mount JobBoard::Engine => "/job_board"
35
+ ```
36
+
37
+ That's it — visit `/job_board`.
38
+
39
+ ## Authentication
40
+
41
+ The dashboard ships with no forced authentication. Two options:
42
+
43
+ **Constraint-based mounting** (recommended when you have app auth, e.g. Devise):
44
+
45
+ ```ruby
46
+ authenticate :user, ->(user) { user.admin? } do
47
+ mount JobBoard::Engine => "/job_board"
48
+ end
49
+ ```
50
+
51
+ **Built-in HTTP Basic auth:**
52
+
53
+ ```ruby
54
+ # config/initializers/job_board.rb
55
+ JobBoard.configure do |config|
56
+ config.http_basic_auth = {
57
+ name: Rails.application.credentials.dig(:job_board, :user),
58
+ password: Rails.application.credentials.dig(:job_board, :password)
59
+ }
60
+ end
61
+ ```
62
+
63
+ ## Configuration
64
+
65
+ ```ruby
66
+ JobBoard.configure do |config|
67
+ config.poll_interval = 5 # seconds between auto-refreshes; nil or 0 disables
68
+ config.per_page = 25 # rows per page on job lists
69
+ config.stale_process_threshold = nil # heartbeat age before a process is flagged stale;
70
+ # nil uses SolidQueue.process_alive_threshold (5 min)
71
+ config.http_basic_auth = nil # see Authentication above
72
+
73
+ # Latency (seconds) above which a queue is highlighted as breaching:
74
+ config.latency_warning_threshold = 60 # global default
75
+ config.latency_warning_thresholds = { "critical" => 10 } # per-queue overrides
76
+ end
77
+ ```
78
+
79
+ ### Latency-SLA queue names
80
+
81
+ If you name queues after their latency target — the `within_*` convention from
82
+ [Gusto's Sidekiq scaling write-up](https://engineering.gusto.com/scaling-sidekiq-at-gusto-3f9e3279e63) —
83
+ Job Board parses the threshold straight from the name, no configuration needed:
84
+
85
+ | Queue | Highlighted red when latency exceeds |
86
+ |---|---|
87
+ | `within_30_seconds` | 30 seconds |
88
+ | `within_5_minutes` | 5 minutes |
89
+ | `within_1_hour` | 1 hour |
90
+ | `within_24_hours` | 24 hours |
91
+
92
+ Explicit `latency_warning_thresholds` entries still win over the naming convention.
93
+
94
+ The queues page also sorts by SLA: queues with a detectable latency target come first
95
+ (strictest at the top), and everything else follows alphabetically.
96
+
97
+ ## Notes
98
+
99
+ - **Separate queue database**: works automatically. Job Board reads and writes exclusively
100
+ through the `SolidQueue::*` models, which connect to whatever database
101
+ `config.solid_queue.connects_to` points at.
102
+ - **Latency** is computed the same way Solid Queue itself defines it: seconds since the oldest
103
+ ready execution in the queue was enqueued (0 for an empty queue). Scheduled and failed jobs
104
+ don't count against latency.
105
+ - **`preserve_finished_jobs = false`** hosts will see an explanatory empty state on the
106
+ Finished tab, since finished jobs are deleted immediately.
107
+ - **Discarding is permanent** — Solid Queue deletes the job row entirely; there is no trash.
108
+ In-progress jobs can't be discarded.
109
+ - Queue names containing `/` can't be paused through the UI (route limitation).
110
+
111
+ ## Development
112
+
113
+ ```sh
114
+ bundle install
115
+ bundle exec rake test
116
+
117
+ # Run the demo app with seeded data:
118
+ cd test/dummy
119
+ bin/rails db:prepare db:seed
120
+ bin/rails server
121
+ # open http://localhost:3000/job_board
122
+
123
+ # Optionally start real workers against the seeded jobs:
124
+ bin/rails solid_queue:start
125
+ ```
126
+
127
+ ## License
128
+
129
+ MIT.
@@ -0,0 +1,24 @@
1
+ module JobBoard
2
+ class ApplicationController < ActionController::Base
3
+ protect_from_forgery with: :exception
4
+
5
+ before_action :authenticate
6
+
7
+ layout "job_board/application"
8
+
9
+ private
10
+ def authenticate
11
+ credentials = JobBoard.config.http_basic_auth
12
+ return unless credentials
13
+
14
+ authenticate_or_request_with_http_basic("JobBoard") do |name, password|
15
+ ActiveSupport::SecurityUtils.secure_compare(name, credentials[:name].to_s) &
16
+ ActiveSupport::SecurityUtils.secure_compare(password, credentials[:password].to_s)
17
+ end
18
+ end
19
+
20
+ def stale_threshold
21
+ JobBoard.config.stale_process_threshold || SolidQueue.process_alive_threshold
22
+ end
23
+ end
24
+ end
@@ -0,0 +1,19 @@
1
+ module JobBoard
2
+ class AssetsController < ApplicationController
3
+ skip_forgery_protection
4
+
5
+ ASSETS = {
6
+ "application.css" => "text/css",
7
+ "application.js" => "text/javascript"
8
+ }.freeze
9
+
10
+ def show
11
+ name = params[:name]
12
+ content_type = ASSETS[name] or return head(:not_found)
13
+
14
+ expires_in 1.year, public: true
15
+ send_file JobBoard::Engine.root.join("lib/job_board/assets", name),
16
+ type: content_type, disposition: :inline
17
+ end
18
+ end
19
+ end
@@ -0,0 +1,88 @@
1
+ module JobBoard
2
+ class JobsController < ApplicationController
3
+ before_action :set_query, only: [:index, :retry_all, :discard_all]
4
+
5
+ def index
6
+ @page = @query.page(before: params[:before], limit: JobBoard.config.per_page)
7
+ @counts = @query.counts
8
+ @queues = LatencySla.sort(SolidQueue::Queue.all)
9
+ end
10
+
11
+ def show
12
+ @job = JobPresenter.new(find_job)
13
+ end
14
+
15
+ def retry
16
+ job = find_job
17
+ if job.failed_execution.present?
18
+ job.retry
19
+ redirect_back_or_to jobs_path(status: "failed"), notice: "Job ##{job.id} queued for retry."
20
+ else
21
+ redirect_back_or_to jobs_path, alert: "Job ##{job.id} is not failed, so it can't be retried."
22
+ end
23
+ end
24
+
25
+ def discard
26
+ job = find_job
27
+ if job.status == :claimed
28
+ redirect_back_or_to jobs_path, alert: "Job ##{job.id} is in progress and can't be discarded."
29
+ elsif job.finished?
30
+ redirect_back_or_to jobs_path, alert: "Job ##{job.id} is already finished."
31
+ else
32
+ job.discard
33
+ redirect_to jobs_path(status: params[:status] || "failed"), notice: "Job ##{job.id} discarded."
34
+ end
35
+ end
36
+
37
+ def retry_all
38
+ count = 0
39
+ failed_jobs_in_batches do |batch|
40
+ SolidQueue::FailedExecution.retry_all(batch)
41
+ count += batch.size
42
+ end
43
+ redirect_to failed_jobs_path, notice: "#{count} #{"job".pluralize(count)} queued for retry."
44
+ end
45
+
46
+ def discard_all
47
+ count = 0
48
+ failed_jobs_in_batches do |batch|
49
+ SolidQueue::FailedExecution.discard_all_from_jobs(batch)
50
+ count += batch.size
51
+ end
52
+ redirect_to failed_jobs_path, notice: "#{count} #{"job".pluralize(count)} discarded."
53
+ end
54
+
55
+ private
56
+ def set_query
57
+ status = params[:status].presence || "ready"
58
+ head :not_found and return unless JobsQuery::STATUSES.include?(status)
59
+
60
+ @query = JobsQuery.new(
61
+ status: status,
62
+ queue_name: params[:queue_name].presence,
63
+ class_name: params[:class_name].presence
64
+ )
65
+ end
66
+
67
+ def find_job
68
+ SolidQueue::Job
69
+ .includes(:ready_execution, :scheduled_execution, :claimed_execution,
70
+ :blocked_execution, :failed_execution)
71
+ .find(params[:id])
72
+ end
73
+
74
+ def failed_jobs_in_batches(&block)
75
+ query = JobsQuery.new(
76
+ status: "failed",
77
+ queue_name: params[:queue_name].presence,
78
+ class_name: params[:class_name].presence
79
+ )
80
+ query.failed_jobs_relation.find_in_batches(batch_size: 500, &block)
81
+ end
82
+
83
+ def failed_jobs_path
84
+ jobs_path(status: "failed", queue_name: params[:queue_name].presence,
85
+ class_name: params[:class_name].presence)
86
+ end
87
+ end
88
+ end
@@ -0,0 +1,7 @@
1
+ module JobBoard
2
+ class ProcessesController < ApplicationController
3
+ def index
4
+ @tree = ProcessTree.build(stale_threshold: stale_threshold)
5
+ end
6
+ end
7
+ end
@@ -0,0 +1,15 @@
1
+ module JobBoard
2
+ module Queues
3
+ class PausesController < ApplicationController
4
+ def create
5
+ SolidQueue::Queue.find_by_name(params[:queue_name]).pause
6
+ redirect_to queues_path, notice: "Queue \"#{params[:queue_name]}\" paused."
7
+ end
8
+
9
+ def destroy
10
+ SolidQueue::Queue.find_by_name(params[:queue_name]).resume
11
+ redirect_to queues_path, notice: "Queue \"#{params[:queue_name]}\" resumed."
12
+ end
13
+ end
14
+ end
15
+ end
@@ -0,0 +1,9 @@
1
+ module JobBoard
2
+ class QueuesController < ApplicationController
3
+ def index
4
+ @queues = LatencySla.sort(SolidQueue::Queue.all)
5
+ @failed_counts = SolidQueue::FailedExecution.joins(:job)
6
+ .group("solid_queue_jobs.queue_name").count
7
+ end
8
+ end
9
+ end
@@ -0,0 +1,7 @@
1
+ module JobBoard
2
+ class RecurringTasksController < ApplicationController
3
+ def index
4
+ @tasks = SolidQueue::RecurringTask.order(:key)
5
+ end
6
+ end
7
+ end
@@ -0,0 +1,40 @@
1
+ module JobBoard
2
+ module ApplicationHelper
3
+ def status_badge(status)
4
+ tag.span(status.to_s.humanize, class: "badge badge--#{status}")
5
+ end
6
+
7
+ def time_ago(time)
8
+ return tag.span("–", class: "muted") if time.blank?
9
+
10
+ time = Time.zone.parse(time) if time.is_a?(String)
11
+ tag.time("#{duration((Time.current - time).abs)} #{time.future? ? "from now" : "ago"}",
12
+ title: time.iso8601, datetime: time.iso8601)
13
+ end
14
+
15
+ def duration(seconds)
16
+ seconds = seconds.to_i
17
+ return "0s" if seconds <= 0
18
+
19
+ parts = { "d" => 86400, "h" => 3600, "m" => 60, "s" => 1 }.filter_map do |unit, size|
20
+ value, seconds = seconds.divmod(size)
21
+ "#{value}#{unit}" if value.positive?
22
+ end
23
+ parts.first(2).join(" ")
24
+ end
25
+
26
+ # Latency (seconds) above which a queue counts as breaching. Resolution order:
27
+ # explicit per-queue config, a threshold parsed from a within_* style name,
28
+ # then the global default.
29
+ def latency_threshold_for(queue_name)
30
+ LatencySla.threshold_for(queue_name)
31
+ end
32
+
33
+ def format_json(value)
34
+ value = JSON.parse(value) if value.is_a?(String)
35
+ JSON.pretty_generate(value)
36
+ rescue JSON::ParserError, JSON::GeneratorError
37
+ value.to_s
38
+ end
39
+ end
40
+ end
@@ -0,0 +1,37 @@
1
+ module JobBoard
2
+ # Detail-page wrapper around a SolidQueue::Job loaded with all five
3
+ # has_one execution associations preloaded.
4
+ class JobPresenter
5
+ attr_reader :job
6
+
7
+ delegate :id, :queue_name, :class_name, :priority, :active_job_id,
8
+ :concurrency_key, :created_at, :finished_at, :finished?,
9
+ :failed_execution, :status, to: :job
10
+
11
+ def initialize(job)
12
+ @job = job
13
+ end
14
+
15
+ # The serialized ActiveJob payload (Job#arguments is JSON-coded by SolidQueue).
16
+ def payload
17
+ @payload ||= job.arguments || {}
18
+ rescue StandardError
19
+ @payload = {}
20
+ end
21
+
22
+ def arguments = payload["arguments"]
23
+ def executions_count = payload["executions"]
24
+ def exception_executions = payload["exception_executions"]
25
+ def enqueued_at = payload["enqueued_at"]
26
+
27
+ def scheduled_at
28
+ job.scheduled_execution&.scheduled_at || job.scheduled_at
29
+ end
30
+
31
+ def error = failed_execution
32
+
33
+ def process
34
+ job.claimed_execution&.process
35
+ end
36
+ end
37
+ end
@@ -0,0 +1,48 @@
1
+ module JobBoard
2
+ # Uniform row facade so the jobs table doesn't care whether a row came from
3
+ # an execution record or a finished job.
4
+ class JobRow
5
+ attr_reader :job, :status, :execution
6
+
7
+ def self.from_execution(execution, status:)
8
+ new(job: execution.job, status: status, execution: execution)
9
+ end
10
+
11
+ def self.from_job(job, status: :finished)
12
+ new(job: job, status: status)
13
+ end
14
+
15
+ def initialize(job:, status:, execution: nil)
16
+ @job = job
17
+ @status = status.to_sym
18
+ @execution = execution
19
+ end
20
+
21
+ def id = job.id
22
+ def queue_name = job.queue_name
23
+ def class_name = job.class_name
24
+ def priority = job.priority
25
+ def enqueued_at = job.created_at
26
+ def finished_at = job.finished_at
27
+
28
+ def scheduled_at
29
+ status == :scheduled ? execution.scheduled_at : job.scheduled_at
30
+ end
31
+
32
+ def process
33
+ execution.process if status == :claimed && execution
34
+ end
35
+
36
+ def concurrency_key
37
+ execution.concurrency_key if status == :blocked && execution
38
+ end
39
+
40
+ def error_class
41
+ execution.exception_class if status == :failed && execution
42
+ end
43
+
44
+ def error_message
45
+ execution.message if status == :failed && execution
46
+ end
47
+ end
48
+ end
@@ -0,0 +1,86 @@
1
+ module JobBoard
2
+ # Status-scoped job finder. Each status is driven by its execution table so a
3
+ # row's status is known without calling Job#status (which needs 5 preloads).
4
+ class JobsQuery
5
+ STATUSES = %w[ready scheduled in_progress blocked failed finished].freeze
6
+
7
+ ROW_STATUS = {
8
+ "ready" => :ready, "scheduled" => :scheduled, "in_progress" => :claimed,
9
+ "blocked" => :blocked, "failed" => :failed, "finished" => :finished
10
+ }.freeze
11
+
12
+ attr_reader :status, :queue_name, :class_name
13
+
14
+ def initialize(status:, queue_name: nil, class_name: nil)
15
+ @status = status.to_s
16
+ @queue_name = queue_name
17
+ @class_name = class_name
18
+ end
19
+
20
+ def page(before: nil, limit: 25)
21
+ row_status = ROW_STATUS.fetch(status)
22
+ builder =
23
+ if status == "finished"
24
+ ->(job) { JobRow.from_job(job) }
25
+ else
26
+ ->(execution) { JobRow.from_execution(execution, status: row_status) }
27
+ end
28
+ Page.new(relation.order(id: :desc), before: before, limit: limit, &builder)
29
+ end
30
+
31
+ # One COUNT per status, with the current filters applied — for tab badges.
32
+ def counts
33
+ STATUSES.index_with do |s|
34
+ self.class.new(status: s, queue_name: queue_name, class_name: class_name).count
35
+ end
36
+ end
37
+
38
+ def count
39
+ relation.count(:all)
40
+ end
41
+
42
+ # Job relation backing bulk retry/discard. Only meaningful for the failed set,
43
+ # so it is always scoped to jobs with a failed execution.
44
+ def failed_jobs_relation
45
+ scope = SolidQueue::Job.joins(:failed_execution)
46
+ scope = scope.where(queue_name: queue_name) if queue_name
47
+ scope = scope.where(class_name: class_name) if class_name
48
+ scope
49
+ end
50
+
51
+ private
52
+ def relation
53
+ case status
54
+ when "ready"
55
+ filtered(SolidQueue::ReadyExecution.includes(:job), own_queue_column: true)
56
+ when "scheduled"
57
+ filtered(SolidQueue::ScheduledExecution.includes(:job), own_queue_column: true)
58
+ when "in_progress"
59
+ filtered(SolidQueue::ClaimedExecution.includes(:process, :job), own_queue_column: false)
60
+ when "blocked"
61
+ filtered(SolidQueue::BlockedExecution.includes(:job), own_queue_column: true)
62
+ when "failed"
63
+ filtered(SolidQueue::FailedExecution.includes(:job), own_queue_column: false)
64
+ when "finished"
65
+ filtered_jobs(SolidQueue::Job.finished)
66
+ else
67
+ raise ArgumentError, "unknown status #{status.inspect}"
68
+ end
69
+ end
70
+
71
+ def filtered(scope, own_queue_column:)
72
+ if queue_name
73
+ scope = own_queue_column ? scope.where(queue_name: queue_name)
74
+ : scope.joins(:job).where(solid_queue_jobs: { queue_name: queue_name })
75
+ end
76
+ scope = scope.joins(:job).where(solid_queue_jobs: { class_name: class_name }) if class_name
77
+ scope
78
+ end
79
+
80
+ def filtered_jobs(scope)
81
+ scope = scope.where(queue_name: queue_name) if queue_name
82
+ scope = scope.where(class_name: class_name) if class_name
83
+ scope
84
+ end
85
+ end
86
+ end
@@ -0,0 +1,31 @@
1
+ module JobBoard
2
+ # Detects a queue's latency SLA from explicit configuration or from the
3
+ # within_* naming convention (e.g. "within_5_minutes" => 300).
4
+ module LatencySla
5
+ UNITS = { "second" => 1, "minute" => 60, "hour" => 3600, "day" => 86400 }.freeze
6
+
7
+ # Seconds, or nil when the queue has no detectable SLA.
8
+ def self.detect(queue_name)
9
+ JobBoard.config.latency_warning_thresholds[queue_name.to_s] || from_name(queue_name)
10
+ end
11
+
12
+ # The latency-warning threshold: the detected SLA, else the global default.
13
+ def self.threshold_for(queue_name)
14
+ detect(queue_name) || JobBoard.config.latency_warning_threshold
15
+ end
16
+
17
+ # Queues with a detectable SLA first, strictest first; the rest alphabetical.
18
+ def self.sort(queues)
19
+ queues.sort_by do |queue|
20
+ sla = detect(queue.name)
21
+ [sla ? 0 : 1, sla || 0, queue.name]
22
+ end
23
+ end
24
+
25
+ def self.from_name(queue_name)
26
+ match = /\Awithin_(\d+)_(second|minute|hour|day)s?\z/i.match(queue_name.to_s)
27
+ match && match[1].to_i * UNITS.fetch(match[2].downcase)
28
+ end
29
+ private_class_method :from_name
30
+ end
31
+ end
@@ -0,0 +1,35 @@
1
+ module JobBoard
2
+ # Keyset pagination over a relation ordered by id DESC.
3
+ # Fetches limit + 1 records to detect whether an older page exists.
4
+ class Page
5
+ include Enumerable
6
+
7
+ attr_reader :records, :rows
8
+
9
+ def initialize(relation, before: nil, limit: 25, &row_builder)
10
+ relation = relation.where(id: ...before.to_i) if before.present?
11
+ fetched = relation.limit(limit + 1).to_a
12
+ @more = fetched.size > limit
13
+ @records = fetched.first(limit)
14
+ @rows = row_builder ? @records.map(&row_builder) : @records
15
+ end
16
+
17
+ def more?
18
+ @more
19
+ end
20
+
21
+ # Keyset cursor for the next (older) page — the id of the raw record,
22
+ # which is an execution id for execution-backed lists and a job id for finished.
23
+ def next_before
24
+ records.last&.id
25
+ end
26
+
27
+ def each(&block)
28
+ rows.each(&block)
29
+ end
30
+
31
+ def empty?
32
+ records.empty?
33
+ end
34
+ end
35
+ end
@@ -0,0 +1,49 @@
1
+ module JobBoard
2
+ # Supervisor -> supervisee tree of SolidQueue processes with staleness flags,
3
+ # claimed-job counts, and the in-progress jobs each worker holds.
4
+ class ProcessTree
5
+ Node = Data.define(:process, :children, :claimed_count, :stale)
6
+
7
+ attr_reader :roots, :orphaned_claimed_count
8
+
9
+ def self.build(stale_threshold:)
10
+ new(stale_threshold: stale_threshold)
11
+ end
12
+
13
+ def initialize(stale_threshold:)
14
+ processes = SolidQueue::Process.order(:id).to_a
15
+ ids = processes.map(&:id).to_set
16
+ counts = SolidQueue::ClaimedExecution.group(:process_id).count
17
+ @claimed_by_process =
18
+ SolidQueue::ClaimedExecution.where(process_id: processes.map(&:id))
19
+ .includes(:job).group_by(&:process_id)
20
+
21
+ cutoff = stale_threshold.ago
22
+ children = processes.group_by(&:supervisor_id)
23
+ build_node = nil
24
+ build_node = lambda do |process|
25
+ Node.new(
26
+ process: process,
27
+ children: (children[process.id] || []).map(&build_node),
28
+ claimed_count: counts[process.id] || 0,
29
+ stale: process.last_heartbeat_at < cutoff
30
+ )
31
+ end
32
+
33
+ # Roots: no supervisor, or a supervisor that no longer exists.
34
+ root_processes = processes.select { |p| p.supervisor_id.nil? || !ids.include?(p.supervisor_id) }
35
+ supervisors, others = root_processes.partition { |p| p.kind.to_s.start_with?("Supervisor") }
36
+ @roots = (supervisors + others).map(&build_node)
37
+
38
+ @orphaned_claimed_count = SolidQueue::ClaimedExecution.orphaned.count
39
+ end
40
+
41
+ def claimed_executions_for(process)
42
+ @claimed_by_process[process.id] || []
43
+ end
44
+
45
+ def empty?
46
+ roots.empty?
47
+ end
48
+ end
49
+ end