job_board 0.3.0 → 0.5.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/CHANGELOG.md +18 -0
- data/app/controllers/job_board/application_controller.rb +13 -10
- data/app/controllers/job_board/assets_controller.rb +6 -2
- data/app/controllers/job_board/jobs_controller.rb +31 -28
- data/app/controllers/job_board/metrics_controller.rb +17 -0
- data/app/controllers/job_board/processes_controller.rb +2 -0
- data/app/controllers/job_board/queues/pauses_controller.rb +2 -0
- data/app/controllers/job_board/queues_controller.rb +5 -1
- data/app/controllers/job_board/recurring_tasks/runs_controller.rb +5 -2
- data/app/controllers/job_board/recurring_tasks_controller.rb +2 -0
- data/app/helpers/job_board/application_helper.rb +4 -2
- data/app/models/job_board/cron_description.rb +12 -7
- data/app/models/job_board/job_presenter.rb +2 -0
- data/app/models/job_board/job_row.rb +2 -0
- data/app/models/job_board/jobs_query.rb +35 -29
- data/app/models/job_board/latency_sla.rb +4 -2
- data/app/models/job_board/page.rb +4 -2
- data/app/models/job_board/process_tree.rb +4 -2
- data/app/models/job_board/queue_list.rb +4 -2
- data/app/models/job_board/throughput.rb +24 -0
- data/app/views/job_board/queues/_table.html.erb +9 -0
- data/app/views/job_board/queues/index.html.erb +18 -0
- data/app/views/layouts/job_board/application.html.erb +2 -0
- data/config/routes.rb +7 -3
- data/lib/job_board/assets/application.css +1 -0
- data/lib/job_board/assets/throughput_chart.css +64 -0
- data/lib/job_board/assets/throughput_chart.js +89 -0
- data/lib/job_board/configuration.rb +2 -0
- data/lib/job_board/engine.rb +2 -0
- data/lib/job_board/version.rb +3 -1
- data/lib/job_board.rb +2 -0
- metadata +5 -1
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 18b3b4aa806ccbda04e2bd2100e4561eefec8b1ec665975b3d34d22d9ababadf
|
|
4
|
+
data.tar.gz: 31ba104259fe986f5c2eceee2f335d153246d7d21f6c9baf22764420091fbfa2
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: 746ddd882343564e9cec4dfe8a621b84e8410dc5aaac35adbce9a6adc8223fdddbf3a392ed6a9486fa5208268829a6daa470304dea9a27ec06aec300d2d9f8a3
|
|
7
|
+
data.tar.gz: 13bae2092fa89294028c00c03bbd528fbab80c2cef3156bfb1d6a09177dc1629b5898b58db93df75091508bef54e0d1adecd7a64e2b83b3aa7861bdbcbea35ef
|
data/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,23 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## 0.5.0
|
|
4
|
+
|
|
5
|
+
- Queues (home) page: new **Running** column showing how many jobs are currently
|
|
6
|
+
claimed by a worker and in progress for each queue. Positive counts link to that
|
|
7
|
+
queue's in-progress jobs; the count refreshes live with the page's auto-refresh.
|
|
8
|
+
|
|
9
|
+

|
|
10
|
+
|
|
11
|
+
## 0.4.0
|
|
12
|
+
|
|
13
|
+
- Queues (home) page: a real-time **Throughput** line chart at the top, plotting jobs
|
|
14
|
+
that became ready to run against jobs that finished, one point per poll interval.
|
|
15
|
+
It tracks only what happens after the page loads (no history), keeps a rolling
|
|
16
|
+
10-minute window scrolling right-to-left, and is fed by a small JSON `metrics`
|
|
17
|
+
endpoint so the SVG state survives the auto-refresh swap.
|
|
18
|
+
|
|
19
|
+

|
|
20
|
+
|
|
3
21
|
## 0.3.0
|
|
4
22
|
|
|
5
23
|
- Recurring tasks page: **Run now** button to trigger a task outside its schedule
|
|
@@ -1,3 +1,5 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
1
3
|
module JobBoard
|
|
2
4
|
class ApplicationController < ActionController::Base
|
|
3
5
|
protect_from_forgery with: :exception
|
|
@@ -7,18 +9,19 @@ module JobBoard
|
|
|
7
9
|
layout "job_board/application"
|
|
8
10
|
|
|
9
11
|
private
|
|
10
|
-
def authenticate
|
|
11
|
-
credentials = JobBoard.config.http_basic_auth
|
|
12
|
-
return unless credentials
|
|
13
12
|
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
end
|
|
18
|
-
end
|
|
13
|
+
def authenticate
|
|
14
|
+
credentials = JobBoard.config.http_basic_auth
|
|
15
|
+
return unless credentials
|
|
19
16
|
|
|
20
|
-
|
|
21
|
-
|
|
17
|
+
authenticate_or_request_with_http_basic("JobBoard") do |name, password|
|
|
18
|
+
ActiveSupport::SecurityUtils.secure_compare(name, credentials[:name].to_s) &
|
|
19
|
+
ActiveSupport::SecurityUtils.secure_compare(password, credentials[:password].to_s)
|
|
22
20
|
end
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
def stale_threshold
|
|
24
|
+
JobBoard.config.stale_process_threshold || SolidQueue.process_alive_threshold
|
|
25
|
+
end
|
|
23
26
|
end
|
|
24
27
|
end
|
|
@@ -1,10 +1,14 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
1
3
|
module JobBoard
|
|
2
4
|
class AssetsController < ApplicationController
|
|
3
5
|
skip_forgery_protection
|
|
4
6
|
|
|
5
7
|
ASSETS = {
|
|
6
8
|
"application.css" => "text/css",
|
|
7
|
-
"
|
|
9
|
+
"throughput_chart.css" => "text/css",
|
|
10
|
+
"application.js" => "text/javascript",
|
|
11
|
+
"throughput_chart.js" => "text/javascript"
|
|
8
12
|
}.freeze
|
|
9
13
|
|
|
10
14
|
def show
|
|
@@ -13,7 +17,7 @@ module JobBoard
|
|
|
13
17
|
|
|
14
18
|
expires_in 1.year, public: true
|
|
15
19
|
send_file JobBoard::Engine.root.join("lib/job_board/assets", name),
|
|
16
|
-
|
|
20
|
+
type: content_type, disposition: :inline
|
|
17
21
|
end
|
|
18
22
|
end
|
|
19
23
|
end
|
|
@@ -1,6 +1,8 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
1
3
|
module JobBoard
|
|
2
4
|
class JobsController < ApplicationController
|
|
3
|
-
before_action :set_query, only: [
|
|
5
|
+
before_action :set_query, only: %i[index retry_all discard_all]
|
|
4
6
|
|
|
5
7
|
def index
|
|
6
8
|
@page = @query.page(before: params[:before], limit: JobBoard.config.per_page)
|
|
@@ -53,36 +55,37 @@ module JobBoard
|
|
|
53
55
|
end
|
|
54
56
|
|
|
55
57
|
private
|
|
56
|
-
def set_query
|
|
57
|
-
status = params[:status].presence || "ready"
|
|
58
|
-
head :not_found and return unless JobsQuery::STATUSES.include?(status)
|
|
59
58
|
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
class_name: params[:class_name].presence
|
|
64
|
-
)
|
|
65
|
-
end
|
|
59
|
+
def set_query
|
|
60
|
+
status = params[:status].presence || "ready"
|
|
61
|
+
head :not_found and return unless JobsQuery::STATUSES.include?(status)
|
|
66
62
|
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
63
|
+
@query = JobsQuery.new(
|
|
64
|
+
status: status,
|
|
65
|
+
queue_name: params[:queue_name].presence,
|
|
66
|
+
class_name: params[:class_name].presence
|
|
67
|
+
)
|
|
68
|
+
end
|
|
73
69
|
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
query.failed_jobs_relation.find_in_batches(batch_size: 500, &block)
|
|
81
|
-
end
|
|
70
|
+
def find_job
|
|
71
|
+
SolidQueue::Job
|
|
72
|
+
.includes(:ready_execution, :scheduled_execution, :claimed_execution,
|
|
73
|
+
:blocked_execution, :failed_execution)
|
|
74
|
+
.find(params[:id])
|
|
75
|
+
end
|
|
82
76
|
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
77
|
+
def failed_jobs_in_batches(&)
|
|
78
|
+
query = JobsQuery.new(
|
|
79
|
+
status: "failed",
|
|
80
|
+
queue_name: params[:queue_name].presence,
|
|
81
|
+
class_name: params[:class_name].presence
|
|
82
|
+
)
|
|
83
|
+
query.failed_jobs_relation.find_in_batches(batch_size: 500, &)
|
|
84
|
+
end
|
|
85
|
+
|
|
86
|
+
def failed_jobs_path
|
|
87
|
+
jobs_path(status: "failed", queue_name: params[:queue_name].presence,
|
|
88
|
+
class_name: params[:class_name].presence)
|
|
89
|
+
end
|
|
87
90
|
end
|
|
88
91
|
end
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module JobBoard
|
|
4
|
+
class MetricsController < ApplicationController
|
|
5
|
+
def show
|
|
6
|
+
render json: Throughput.snapshot(since: parse_time(params[:since]))
|
|
7
|
+
end
|
|
8
|
+
|
|
9
|
+
private
|
|
10
|
+
|
|
11
|
+
def parse_time(value)
|
|
12
|
+
Time.iso8601(value.to_s)
|
|
13
|
+
rescue ArgumentError
|
|
14
|
+
nil # blank or malformed cursor → treat as a fresh baseline
|
|
15
|
+
end
|
|
16
|
+
end
|
|
17
|
+
end
|
|
@@ -1,9 +1,13 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
1
3
|
module JobBoard
|
|
2
4
|
class QueuesController < ApplicationController
|
|
3
5
|
def index
|
|
4
6
|
@queue_list = QueueList.build
|
|
5
7
|
@failed_counts = SolidQueue::FailedExecution.joins(:job)
|
|
6
|
-
|
|
8
|
+
.group("solid_queue_jobs.queue_name").count
|
|
9
|
+
@running_counts = SolidQueue::ClaimedExecution.joins(:job)
|
|
10
|
+
.group("solid_queue_jobs.queue_name").count
|
|
7
11
|
end
|
|
8
12
|
end
|
|
9
13
|
end
|
|
@@ -1,3 +1,5 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
1
3
|
module JobBoard
|
|
2
4
|
module RecurringTasks
|
|
3
5
|
class RunsController < ApplicationController
|
|
@@ -9,8 +11,9 @@ module JobBoard
|
|
|
9
11
|
elsif task.enqueue(at: Time.current)
|
|
10
12
|
redirect_to recurring_tasks_path, notice: "Enqueued \"#{task.key}\"."
|
|
11
13
|
else
|
|
12
|
-
|
|
13
|
-
|
|
14
|
+
alert = "\"#{task.key}\" was not enqueued — it already ran at " \
|
|
15
|
+
"this exact time or the enqueue failed."
|
|
16
|
+
redirect_to recurring_tasks_path, alert: alert
|
|
14
17
|
end
|
|
15
18
|
end
|
|
16
19
|
end
|
|
@@ -1,3 +1,5 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
1
3
|
module JobBoard
|
|
2
4
|
module ApplicationHelper
|
|
3
5
|
def status_badge(status)
|
|
@@ -9,14 +11,14 @@ module JobBoard
|
|
|
9
11
|
|
|
10
12
|
time = Time.zone.parse(time) if time.is_a?(String)
|
|
11
13
|
tag.time("#{duration((Time.current - time).abs)} #{time.future? ? "from now" : "ago"}",
|
|
12
|
-
|
|
14
|
+
title: time.iso8601, datetime: time.iso8601)
|
|
13
15
|
end
|
|
14
16
|
|
|
15
17
|
def duration(seconds)
|
|
16
18
|
seconds = seconds.to_i
|
|
17
19
|
return "0s" if seconds <= 0
|
|
18
20
|
|
|
19
|
-
parts = { "d" =>
|
|
21
|
+
parts = { "d" => 86_400, "h" => 3600, "m" => 60, "s" => 1 }.filter_map do |unit, size|
|
|
20
22
|
value, seconds = seconds.divmod(size)
|
|
21
23
|
"#{value}#{unit}" if value.positive?
|
|
22
24
|
end
|
|
@@ -1,3 +1,5 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
1
3
|
require "fugit"
|
|
2
4
|
|
|
3
5
|
module JobBoard
|
|
@@ -44,6 +46,7 @@ module JobBoard
|
|
|
44
46
|
if (step = seconds_step)
|
|
45
47
|
# Sub-minute schedules only describe cleanly when nothing else is constrained.
|
|
46
48
|
return nil unless cron.minutes.nil? && cron.hours.nil?
|
|
49
|
+
|
|
47
50
|
"Every #{step} seconds"
|
|
48
51
|
elsif cron.seconds && cron.seconds != [0]
|
|
49
52
|
nil
|
|
@@ -60,7 +63,7 @@ module JobBoard
|
|
|
60
63
|
else
|
|
61
64
|
times = cron.hours.product(cron.minutes || [0]).sort
|
|
62
65
|
if times.size <= 4
|
|
63
|
-
"At #{join_and(times.map { |h, m| format("
|
|
66
|
+
"At #{join_and(times.map { |h, m| format("%<h>02d:%<m>02d", h: h, m: m) })}"
|
|
64
67
|
else
|
|
65
68
|
"At #{numbers("minute", cron.minutes)} past #{numbers("hour", cron.hours)}"
|
|
66
69
|
end
|
|
@@ -79,17 +82,19 @@ module JobBoard
|
|
|
79
82
|
return nil if cron.weekdays.nil?
|
|
80
83
|
|
|
81
84
|
plain = cron.weekdays.select { |_, nth| nth.nil? }.map { |day,| day % 7 }.sort.uniq
|
|
82
|
-
nth = cron.weekdays.
|
|
85
|
+
nth = cron.weekdays.filter_map do |day, n|
|
|
86
|
+
next if n.nil?
|
|
87
|
+
|
|
83
88
|
"the #{n == -1 ? "last" : n.ordinalize} #{DAYS[day % 7]} of the month"
|
|
84
89
|
end
|
|
85
90
|
|
|
86
91
|
phrases = []
|
|
87
92
|
if plain.any?
|
|
88
93
|
phrases << if plain.size > 2 && consecutive?(plain)
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
94
|
+
"#{DAYS[plain.first]} through #{DAYS[plain.last]}"
|
|
95
|
+
else
|
|
96
|
+
join_and(plain.map { |day| DAYS[day] })
|
|
97
|
+
end
|
|
93
98
|
end
|
|
94
99
|
"on #{join_and(phrases + nth)}"
|
|
95
100
|
end
|
|
@@ -99,7 +104,7 @@ module JobBoard
|
|
|
99
104
|
|
|
100
105
|
# "0 0 1 1 *" reads better as "on January 1" than "on day 1 of the month in January".
|
|
101
106
|
if cron.monthdays.size == 1 && cron.monthdays.first.positive? &&
|
|
102
|
-
|
|
107
|
+
cron.months&.size == 1 && cron.weekdays.nil?
|
|
103
108
|
@month_consumed = true
|
|
104
109
|
return "on #{MONTHS[cron.months.first - 1]} #{cron.monthdays.first}"
|
|
105
110
|
end
|
|
@@ -1,3 +1,5 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
1
3
|
module JobBoard
|
|
2
4
|
# Status-scoped job finder. Each status is driven by its execution table so a
|
|
3
5
|
# row's status is known without calling Job#status (which needs 5 preloads).
|
|
@@ -49,38 +51,42 @@ module JobBoard
|
|
|
49
51
|
end
|
|
50
52
|
|
|
51
53
|
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
54
|
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
55
|
+
def relation
|
|
56
|
+
case status
|
|
57
|
+
when "ready"
|
|
58
|
+
filtered(SolidQueue::ReadyExecution.includes(:job), own_queue_column: true)
|
|
59
|
+
when "scheduled"
|
|
60
|
+
filtered(SolidQueue::ScheduledExecution.includes(:job), own_queue_column: true)
|
|
61
|
+
when "in_progress"
|
|
62
|
+
filtered(SolidQueue::ClaimedExecution.includes(:process, :job), own_queue_column: false)
|
|
63
|
+
when "blocked"
|
|
64
|
+
filtered(SolidQueue::BlockedExecution.includes(:job), own_queue_column: true)
|
|
65
|
+
when "failed"
|
|
66
|
+
filtered(SolidQueue::FailedExecution.includes(:job), own_queue_column: false)
|
|
67
|
+
when "finished"
|
|
68
|
+
filtered_jobs(SolidQueue::Job.finished)
|
|
69
|
+
else
|
|
70
|
+
raise ArgumentError, "unknown status #{status.inspect}"
|
|
78
71
|
end
|
|
72
|
+
end
|
|
79
73
|
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
scope =
|
|
83
|
-
|
|
74
|
+
def filtered(scope, own_queue_column:)
|
|
75
|
+
if queue_name
|
|
76
|
+
scope = if own_queue_column
|
|
77
|
+
scope.where(queue_name: queue_name)
|
|
78
|
+
else
|
|
79
|
+
scope.joins(:job).where(solid_queue_jobs: { queue_name: queue_name })
|
|
80
|
+
end
|
|
84
81
|
end
|
|
82
|
+
scope = scope.joins(:job).where(solid_queue_jobs: { class_name: class_name }) if class_name
|
|
83
|
+
scope
|
|
84
|
+
end
|
|
85
|
+
|
|
86
|
+
def filtered_jobs(scope)
|
|
87
|
+
scope = scope.where(queue_name: queue_name) if queue_name
|
|
88
|
+
scope = scope.where(class_name: class_name) if class_name
|
|
89
|
+
scope
|
|
90
|
+
end
|
|
85
91
|
end
|
|
86
92
|
end
|
|
@@ -1,8 +1,10 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
1
3
|
module JobBoard
|
|
2
4
|
# Detects a queue's latency SLA from explicit configuration or from the
|
|
3
5
|
# within_* naming convention (e.g. "within_5_minutes" => 300).
|
|
4
6
|
module LatencySla
|
|
5
|
-
UNITS = { "second" => 1, "minute" => 60, "hour" => 3600, "day" =>
|
|
7
|
+
UNITS = { "second" => 1, "minute" => 60, "hour" => 3600, "day" => 86_400 }.freeze
|
|
6
8
|
|
|
7
9
|
# Seconds, or nil when the queue has no detectable SLA.
|
|
8
10
|
def self.detect(queue_name)
|
|
@@ -24,7 +26,7 @@ module JobBoard
|
|
|
24
26
|
|
|
25
27
|
def self.from_name(queue_name)
|
|
26
28
|
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)
|
|
29
|
+
match && (match[1].to_i * UNITS.fetch(match[2].downcase))
|
|
28
30
|
end
|
|
29
31
|
private_class_method :from_name
|
|
30
32
|
end
|
|
@@ -1,3 +1,5 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
1
3
|
module JobBoard
|
|
2
4
|
# Keyset pagination over a relation ordered by id DESC.
|
|
3
5
|
# Fetches limit + 1 records to detect whether an older page exists.
|
|
@@ -24,8 +26,8 @@ module JobBoard
|
|
|
24
26
|
records.last&.id
|
|
25
27
|
end
|
|
26
28
|
|
|
27
|
-
def each(&
|
|
28
|
-
rows.each(&
|
|
29
|
+
def each(&)
|
|
30
|
+
rows.each(&)
|
|
29
31
|
end
|
|
30
32
|
|
|
31
33
|
def empty?
|
|
@@ -1,3 +1,5 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
1
3
|
module JobBoard
|
|
2
4
|
# Supervisor -> supervisee tree of SolidQueue processes with staleness flags,
|
|
3
5
|
# claimed-job counts, and the in-progress jobs each worker holds.
|
|
@@ -12,11 +14,11 @@ module JobBoard
|
|
|
12
14
|
|
|
13
15
|
def initialize(stale_threshold:)
|
|
14
16
|
processes = SolidQueue::Process.order(:id).to_a
|
|
15
|
-
ids = processes.
|
|
17
|
+
ids = processes.to_set(&:id)
|
|
16
18
|
counts = SolidQueue::ClaimedExecution.group(:process_id).count
|
|
17
19
|
@claimed_by_process =
|
|
18
20
|
SolidQueue::ClaimedExecution.where(process_id: processes.map(&:id))
|
|
19
|
-
|
|
21
|
+
.includes(:job).group_by(&:process_id)
|
|
20
22
|
|
|
21
23
|
cutoff = stale_threshold.ago
|
|
22
24
|
children = processes.group_by(&:supervisor_id)
|
|
@@ -1,3 +1,5 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
1
3
|
module JobBoard
|
|
2
4
|
# Partitions queues into active and inactive based on when each queue last
|
|
3
5
|
# had a job enqueued. A queue is inactive when its newest job is older than
|
|
@@ -9,8 +11,8 @@ module JobBoard
|
|
|
9
11
|
|
|
10
12
|
def self.build(window: JobBoard.config.queue_activity_window)
|
|
11
13
|
new(LatencySla.sort(SolidQueue::Queue.all),
|
|
12
|
-
|
|
13
|
-
|
|
14
|
+
last_enqueued_at: SolidQueue::Job.group(:queue_name).maximum(:created_at),
|
|
15
|
+
window: window)
|
|
14
16
|
end
|
|
15
17
|
|
|
16
18
|
def initialize(queues, last_enqueued_at:, window: nil)
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module JobBoard
|
|
4
|
+
class Throughput
|
|
5
|
+
READY_AT = "COALESCE(scheduled_at, created_at)"
|
|
6
|
+
|
|
7
|
+
class << self
|
|
8
|
+
def snapshot(since:)
|
|
9
|
+
now = Time.current
|
|
10
|
+
{
|
|
11
|
+
now: now.iso8601(3),
|
|
12
|
+
enqueued: since ? count_between(READY_AT, since, now) : 0,
|
|
13
|
+
completed: since ? count_between("finished_at", since, now) : 0
|
|
14
|
+
}
|
|
15
|
+
end
|
|
16
|
+
|
|
17
|
+
private
|
|
18
|
+
|
|
19
|
+
def count_between(expression, since, now)
|
|
20
|
+
SolidQueue::Job.where("#{expression} > ? AND #{expression} <= ?", since, now).count
|
|
21
|
+
end
|
|
22
|
+
end
|
|
23
|
+
end
|
|
24
|
+
end
|
|
@@ -3,6 +3,7 @@
|
|
|
3
3
|
<tr>
|
|
4
4
|
<th>Queue</th>
|
|
5
5
|
<th class="num">Ready jobs</th>
|
|
6
|
+
<th class="num">Running</th>
|
|
6
7
|
<th class="num">Failed</th>
|
|
7
8
|
<th class="num">Latency</th>
|
|
8
9
|
<th>Last enqueued</th>
|
|
@@ -15,6 +16,14 @@
|
|
|
15
16
|
<tr>
|
|
16
17
|
<td><%= link_to queue.name, jobs_path(status: "ready", queue_name: queue.name) %></td>
|
|
17
18
|
<td class="num"><%= queue.size %></td>
|
|
19
|
+
<td class="num">
|
|
20
|
+
<% running = @running_counts[queue.name] || 0 %>
|
|
21
|
+
<% if running.positive? %>
|
|
22
|
+
<%= link_to running, jobs_path(status: "in_progress", queue_name: queue.name), class: "running-count" %>
|
|
23
|
+
<% else %>
|
|
24
|
+
<span class="muted">0</span>
|
|
25
|
+
<% end %>
|
|
26
|
+
</td>
|
|
18
27
|
<td class="num">
|
|
19
28
|
<% failed = @failed_counts[queue.name] || 0 %>
|
|
20
29
|
<% if failed.positive? %>
|
|
@@ -1,5 +1,23 @@
|
|
|
1
1
|
<h1>Queues</h1>
|
|
2
2
|
|
|
3
|
+
<section id="throughput-chart" class="throughput-chart"
|
|
4
|
+
data-metrics-url="<%= metrics_path %>"
|
|
5
|
+
data-poll-interval="<%= JobBoard.config.poll_interval.to_i %>"
|
|
6
|
+
data-max-points="120">
|
|
7
|
+
<div class="throughput-chart__header">
|
|
8
|
+
<h2>Throughput</h2>
|
|
9
|
+
<div class="throughput-chart__legend">
|
|
10
|
+
<span class="tp-key tp-key--enqueued">Enqueued <b data-tp-latest="enqueued">0</b></span>
|
|
11
|
+
<span class="tp-key tp-key--completed">Completed <b data-tp-latest="completed">0</b></span>
|
|
12
|
+
</div>
|
|
13
|
+
</div>
|
|
14
|
+
<svg class="throughput-chart__svg" viewBox="0 0 600 160" preserveAspectRatio="none"
|
|
15
|
+
role="img" aria-label="Enqueued and completed jobs per interval">
|
|
16
|
+
<polyline class="tp-line tp-line--enqueued" fill="none" points=""></polyline>
|
|
17
|
+
<polyline class="tp-line tp-line--completed" fill="none" points=""></polyline>
|
|
18
|
+
</svg>
|
|
19
|
+
</section>
|
|
20
|
+
|
|
3
21
|
<div data-poll-region="queues">
|
|
4
22
|
<% if @queue_list.active.empty? && @queue_list.inactive.empty? %>
|
|
5
23
|
<p class="empty-state">No queues yet — queues appear once jobs are enqueued.</p>
|
|
@@ -5,7 +5,9 @@
|
|
|
5
5
|
<meta name="viewport" content="width=device-width, initial-scale=1">
|
|
6
6
|
<%= csrf_meta_tags %>
|
|
7
7
|
<link rel="stylesheet" href="<%= static_asset_path("application.css", v: JobBoard::VERSION) %>">
|
|
8
|
+
<link rel="stylesheet" href="<%= static_asset_path("throughput_chart.css", v: JobBoard::VERSION) %>">
|
|
8
9
|
<script src="<%= static_asset_path("application.js", v: JobBoard::VERSION) %>" defer></script>
|
|
10
|
+
<script src="<%= static_asset_path("throughput_chart.js", v: JobBoard::VERSION) %>" defer></script>
|
|
9
11
|
</head>
|
|
10
12
|
<body data-poll-interval="<%= JobBoard.config.poll_interval.to_i %>">
|
|
11
13
|
<%= render "job_board/shared/nav" %>
|
data/config/routes.rb
CHANGED
|
@@ -1,13 +1,15 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
1
3
|
JobBoard::Engine.routes.draw do
|
|
2
4
|
root to: "queues#index"
|
|
3
5
|
|
|
4
6
|
get "queues", to: "queues#index", as: :queues
|
|
5
|
-
scope "queues/:queue_name", constraints: { queue_name:
|
|
7
|
+
scope "queues/:queue_name", constraints: { queue_name: %r{[^/]+} }, format: false do
|
|
6
8
|
post "pause", to: "queues/pauses#create", as: :queue_pause
|
|
7
9
|
delete "pause", to: "queues/pauses#destroy"
|
|
8
10
|
end
|
|
9
11
|
|
|
10
|
-
resources :jobs, only: [
|
|
12
|
+
resources :jobs, only: %i[index show] do
|
|
11
13
|
member do
|
|
12
14
|
post :retry
|
|
13
15
|
delete :discard
|
|
@@ -20,9 +22,11 @@ JobBoard::Engine.routes.draw do
|
|
|
20
22
|
|
|
21
23
|
resources :processes, only: :index
|
|
22
24
|
resources :recurring_tasks, only: :index
|
|
23
|
-
scope "recurring_tasks/:key", constraints: { key:
|
|
25
|
+
scope "recurring_tasks/:key", constraints: { key: %r{[^/]+} }, format: false do
|
|
24
26
|
post "run", to: "recurring_tasks/runs#create", as: :recurring_task_run
|
|
25
27
|
end
|
|
26
28
|
|
|
29
|
+
get "metrics", to: "metrics#show", as: :metrics
|
|
30
|
+
|
|
27
31
|
get "assets/:name", to: "assets#show", as: :static_asset, constraints: { name: /[a-z_]+\.(css|js)/ }
|
|
28
32
|
end
|
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
/* Home-page throughput chart. Relies on the :root custom properties defined in
|
|
2
|
+
application.css, which the layout always loads alongside this file. */
|
|
3
|
+
.throughput-chart {
|
|
4
|
+
background: var(--surface);
|
|
5
|
+
border: 1px solid var(--border);
|
|
6
|
+
border-radius: 10px;
|
|
7
|
+
padding: 0.75rem 1rem 1rem;
|
|
8
|
+
margin-bottom: 1.5rem;
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
.throughput-chart__header {
|
|
12
|
+
display: flex;
|
|
13
|
+
align-items: baseline;
|
|
14
|
+
justify-content: space-between;
|
|
15
|
+
gap: 1rem;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
.throughput-chart__header h2 {
|
|
19
|
+
font-size: 0.95rem;
|
|
20
|
+
margin: 0;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
.throughput-chart__legend {
|
|
24
|
+
display: flex;
|
|
25
|
+
gap: 1rem;
|
|
26
|
+
font-size: 0.82rem;
|
|
27
|
+
color: var(--text-muted);
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
.tp-key {
|
|
31
|
+
display: inline-flex;
|
|
32
|
+
align-items: center;
|
|
33
|
+
gap: 0.4rem;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
.tp-key b {
|
|
37
|
+
color: var(--text);
|
|
38
|
+
font-variant-numeric: tabular-nums;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
.tp-key::before {
|
|
42
|
+
content: "";
|
|
43
|
+
width: 0.75rem;
|
|
44
|
+
height: 0.2rem;
|
|
45
|
+
border-radius: 2px;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
.tp-key--enqueued::before { background: var(--accent); }
|
|
49
|
+
.tp-key--completed::before { background: var(--success); }
|
|
50
|
+
|
|
51
|
+
.throughput-chart__svg {
|
|
52
|
+
display: block;
|
|
53
|
+
width: 100%;
|
|
54
|
+
height: 160px;
|
|
55
|
+
margin-top: 0.5rem;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
.tp-line {
|
|
59
|
+
stroke-width: 2;
|
|
60
|
+
vector-effect: non-scaling-stroke;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
.tp-line--enqueued { stroke: var(--accent); }
|
|
64
|
+
.tp-line--completed { stroke: var(--success); }
|
|
@@ -0,0 +1,89 @@
|
|
|
1
|
+
(function () {
|
|
2
|
+
"use strict";
|
|
3
|
+
|
|
4
|
+
var chart = document.getElementById("throughput-chart");
|
|
5
|
+
if (!chart) return;
|
|
6
|
+
|
|
7
|
+
var metricsUrl = chart.dataset.metricsUrl;
|
|
8
|
+
var pollIntervalMs = parseInt(chart.dataset.pollInterval, 10) * 1000;
|
|
9
|
+
var maxVisiblePoints = parseInt(chart.dataset.maxPoints, 10) || 120;
|
|
10
|
+
if (!metricsUrl || !pollIntervalMs || pollIntervalMs <= 0) return;
|
|
11
|
+
|
|
12
|
+
var viewBox = chart.querySelector(".throughput-chart__svg").viewBox.baseVal;
|
|
13
|
+
var enqueuedLine = chart.querySelector(".tp-line--enqueued");
|
|
14
|
+
var completedLine = chart.querySelector(".tp-line--completed");
|
|
15
|
+
var latestLabels = {
|
|
16
|
+
enqueued: chart.querySelector('[data-tp-latest="enqueued"]'),
|
|
17
|
+
completed: chart.querySelector('[data-tp-latest="completed"]')
|
|
18
|
+
};
|
|
19
|
+
|
|
20
|
+
var samplesOldestToNewest = [];
|
|
21
|
+
var previousServerTime = null;
|
|
22
|
+
|
|
23
|
+
function ignoreTransientError() {}
|
|
24
|
+
|
|
25
|
+
function fetchMetrics(query) {
|
|
26
|
+
return fetch(metricsUrl + query, {
|
|
27
|
+
headers: { "Accept": "application/json" },
|
|
28
|
+
credentials: "same-origin"
|
|
29
|
+
}).then(function (response) {
|
|
30
|
+
if (!response.ok) throw new Error("metrics fetch failed");
|
|
31
|
+
return response.json();
|
|
32
|
+
});
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
function resetBaselineWithoutPlotting() {
|
|
36
|
+
return fetchMetrics("").then(function (data) { previousServerTime = data.now; });
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
function highestValue() {
|
|
40
|
+
var peak = 1;
|
|
41
|
+
samplesOldestToNewest.forEach(function (sample) {
|
|
42
|
+
if (sample.enqueued > peak) peak = sample.enqueued;
|
|
43
|
+
if (sample.completed > peak) peak = sample.completed;
|
|
44
|
+
});
|
|
45
|
+
return peak;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
function pointsFor(series) {
|
|
49
|
+
if (samplesOldestToNewest.length === 0) return "";
|
|
50
|
+
var peak = highestValue();
|
|
51
|
+
var horizontalStep = samplesOldestToNewest.length > 1 ? viewBox.width / (samplesOldestToNewest.length - 1) : 0;
|
|
52
|
+
return samplesOldestToNewest.map(function (sample, index) {
|
|
53
|
+
var stepsFromRightEdge = samplesOldestToNewest.length - 1 - index;
|
|
54
|
+
var x = viewBox.width - stepsFromRightEdge * horizontalStep;
|
|
55
|
+
var y = viewBox.height - (sample[series] / peak) * viewBox.height;
|
|
56
|
+
return x.toFixed(1) + "," + y.toFixed(1);
|
|
57
|
+
}).join(" ");
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
function redraw() {
|
|
61
|
+
enqueuedLine.setAttribute("points", pointsFor("enqueued"));
|
|
62
|
+
completedLine.setAttribute("points", pointsFor("completed"));
|
|
63
|
+
var newestSample = samplesOldestToNewest[samplesOldestToNewest.length - 1];
|
|
64
|
+
if (newestSample) {
|
|
65
|
+
latestLabels.enqueued.textContent = newestSample.enqueued;
|
|
66
|
+
latestLabels.completed.textContent = newestSample.completed;
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
function collectSample() {
|
|
71
|
+
var baselineReady = previousServerTime !== null;
|
|
72
|
+
if (document.hidden || !baselineReady) return;
|
|
73
|
+
fetchMetrics("?since=" + encodeURIComponent(previousServerTime)).then(function (data) {
|
|
74
|
+
samplesOldestToNewest.push({ enqueued: data.enqueued, completed: data.completed });
|
|
75
|
+
if (samplesOldestToNewest.length > maxVisiblePoints) samplesOldestToNewest.shift();
|
|
76
|
+
previousServerTime = data.now;
|
|
77
|
+
redraw();
|
|
78
|
+
}).catch(ignoreTransientError);
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
function reBaselineWhenTabBecomesVisible() {
|
|
82
|
+
if (!document.hidden) resetBaselineWithoutPlotting();
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
document.addEventListener("visibilitychange", reBaselineWhenTabBecomesVisible);
|
|
86
|
+
|
|
87
|
+
resetBaselineWithoutPlotting().catch(ignoreTransientError);
|
|
88
|
+
setInterval(collectSample, pollIntervalMs);
|
|
89
|
+
})();
|
data/lib/job_board/engine.rb
CHANGED
data/lib/job_board/version.rb
CHANGED
data/lib/job_board.rb
CHANGED
metadata
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
--- !ruby/object:Gem::Specification
|
|
2
2
|
name: job_board
|
|
3
3
|
version: !ruby/object:Gem::Version
|
|
4
|
-
version: 0.
|
|
4
|
+
version: 0.5.0
|
|
5
5
|
platform: ruby
|
|
6
6
|
authors:
|
|
7
7
|
- Mike
|
|
@@ -51,6 +51,7 @@ files:
|
|
|
51
51
|
- app/controllers/job_board/application_controller.rb
|
|
52
52
|
- app/controllers/job_board/assets_controller.rb
|
|
53
53
|
- app/controllers/job_board/jobs_controller.rb
|
|
54
|
+
- app/controllers/job_board/metrics_controller.rb
|
|
54
55
|
- app/controllers/job_board/processes_controller.rb
|
|
55
56
|
- app/controllers/job_board/queues/pauses_controller.rb
|
|
56
57
|
- app/controllers/job_board/queues_controller.rb
|
|
@@ -65,6 +66,7 @@ files:
|
|
|
65
66
|
- app/models/job_board/page.rb
|
|
66
67
|
- app/models/job_board/process_tree.rb
|
|
67
68
|
- app/models/job_board/queue_list.rb
|
|
69
|
+
- app/models/job_board/throughput.rb
|
|
68
70
|
- app/views/job_board/jobs/_filters.html.erb
|
|
69
71
|
- app/views/job_board/jobs/_job_row.html.erb
|
|
70
72
|
- app/views/job_board/jobs/index.html.erb
|
|
@@ -81,6 +83,8 @@ files:
|
|
|
81
83
|
- lib/job_board.rb
|
|
82
84
|
- lib/job_board/assets/application.css
|
|
83
85
|
- lib/job_board/assets/application.js
|
|
86
|
+
- lib/job_board/assets/throughput_chart.css
|
|
87
|
+
- lib/job_board/assets/throughput_chart.js
|
|
84
88
|
- lib/job_board/configuration.rb
|
|
85
89
|
- lib/job_board/engine.rb
|
|
86
90
|
- lib/job_board/version.rb
|