job_board 0.2.0 → 0.4.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/README.md +3 -1
- data/app/controllers/job_board/application_controller.rb +11 -10
- data/app/controllers/job_board/assets_controller.rb +3 -1
- data/app/controllers/job_board/jobs_controller.rb +28 -27
- data/app/controllers/job_board/metrics_controller.rb +15 -0
- data/app/controllers/job_board/recurring_tasks/runs_controller.rb +18 -0
- data/app/controllers/job_board/recurring_tasks_controller.rb +2 -1
- data/app/helpers/job_board/application_helper.rb +7 -0
- data/app/models/job_board/cron_description.rb +154 -0
- data/app/models/job_board/jobs_query.rb +30 -29
- data/app/models/job_board/throughput.rb +22 -0
- data/app/views/job_board/queues/index.html.erb +18 -0
- data/app/views/job_board/recurring_tasks/index.html.erb +6 -1
- data/app/views/layouts/job_board/application.html.erb +2 -0
- data/config/routes.rb +5 -0
- data/lib/job_board/assets/application.css +7 -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/version.rb +1 -1
- metadata +7 -1
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: bfca59214924a123a70c43a88852ea184782d00ca60107c07808b79cf6c3c3ac
|
|
4
|
+
data.tar.gz: 0e64e1a67b82fb40227e2f9d87729fb647fe2b7d00bfc6bb9cab0ecf331ba3c6
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: da74ce333cc798a40a50f3ee1cafae15d7e882713eb7a8e2afa6b80773d1f0a31bcd47ad88a88e43fb321053a0ca8a8242642a3a2bc90282fee16c447d2e8224
|
|
7
|
+
data.tar.gz: 7e979f3d406ef8038fddc553e47cb72a0af08320a3da0a936d5319e929f8177fa067ec2323093273fa0fee9adb4d24d28886e11b0cace363f6591636039f6360
|
data/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,23 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## 0.4.0
|
|
4
|
+
|
|
5
|
+
- Queues (home) page: a real-time **Throughput** line chart at the top, plotting jobs
|
|
6
|
+
that became ready to run against jobs that finished, one point per poll interval.
|
|
7
|
+
It tracks only what happens after the page loads (no history), keeps a rolling
|
|
8
|
+
10-minute window scrolling right-to-left, and is fed by a small JSON `metrics`
|
|
9
|
+
endpoint so the SVG state survives the auto-refresh swap.
|
|
10
|
+
|
|
11
|
+

|
|
12
|
+
|
|
13
|
+
## 0.3.0
|
|
14
|
+
|
|
15
|
+
- Recurring tasks page: **Run now** button to trigger a task outside its schedule
|
|
16
|
+
(recorded as a run, so "Last run" reflects it), and tasks are now sorted by next
|
|
17
|
+
run time instead of by key.
|
|
18
|
+
- Hovering a cron schedule shows a plain-English description of when it runs
|
|
19
|
+
(e.g. `30 9 * * 1-5` → "At 09:30 on Monday through Friday").
|
|
20
|
+
|
|
3
21
|
## 0.2.0
|
|
4
22
|
|
|
5
23
|
- Queues page: new **Last enqueued** column showing when each queue last received a job.
|
data/README.md
CHANGED
|
@@ -15,7 +15,9 @@ queue latency at a glance, jobs by status, failed-job management, worker health,
|
|
|
15
15
|
- **Workers** — the supervisor → worker/dispatcher/scheduler tree with heartbeat freshness,
|
|
16
16
|
stale-process badges, per-worker in-progress jobs, and a warning when claimed jobs have been
|
|
17
17
|
orphaned by a dead process.
|
|
18
|
-
- **Recurring tasks** — each task's cron schedule
|
|
18
|
+
- **Recurring tasks** — each task's cron schedule (hover for a plain-English description),
|
|
19
|
+
last run, and next run, sorted soonest-first, with a **Run now** button to trigger any
|
|
20
|
+
task outside its schedule.
|
|
19
21
|
- **Zero dependencies** — the engine serves its own CSS and ~40 lines of vanilla JS. No
|
|
20
22
|
importmap, sprockets, propshaft, or Node requirement. Pages auto-refresh every few seconds.
|
|
21
23
|
|
|
@@ -7,18 +7,19 @@ module JobBoard
|
|
|
7
7
|
layout "job_board/application"
|
|
8
8
|
|
|
9
9
|
private
|
|
10
|
-
def authenticate
|
|
11
|
-
credentials = JobBoard.config.http_basic_auth
|
|
12
|
-
return unless credentials
|
|
13
10
|
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
end
|
|
18
|
-
end
|
|
11
|
+
def authenticate
|
|
12
|
+
credentials = JobBoard.config.http_basic_auth
|
|
13
|
+
return unless credentials
|
|
19
14
|
|
|
20
|
-
|
|
21
|
-
|
|
15
|
+
authenticate_or_request_with_http_basic("JobBoard") do |name, password|
|
|
16
|
+
ActiveSupport::SecurityUtils.secure_compare(name, credentials[:name].to_s) &
|
|
17
|
+
ActiveSupport::SecurityUtils.secure_compare(password, credentials[:password].to_s)
|
|
22
18
|
end
|
|
19
|
+
end
|
|
20
|
+
|
|
21
|
+
def stale_threshold
|
|
22
|
+
JobBoard.config.stale_process_threshold || SolidQueue.process_alive_threshold
|
|
23
|
+
end
|
|
23
24
|
end
|
|
24
25
|
end
|
|
@@ -53,36 +53,37 @@ module JobBoard
|
|
|
53
53
|
end
|
|
54
54
|
|
|
55
55
|
private
|
|
56
|
-
def set_query
|
|
57
|
-
status = params[:status].presence || "ready"
|
|
58
|
-
head :not_found and return unless JobsQuery::STATUSES.include?(status)
|
|
59
56
|
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
class_name: params[:class_name].presence
|
|
64
|
-
)
|
|
65
|
-
end
|
|
57
|
+
def set_query
|
|
58
|
+
status = params[:status].presence || "ready"
|
|
59
|
+
head :not_found and return unless JobsQuery::STATUSES.include?(status)
|
|
66
60
|
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
61
|
+
@query = JobsQuery.new(
|
|
62
|
+
status: status,
|
|
63
|
+
queue_name: params[:queue_name].presence,
|
|
64
|
+
class_name: params[:class_name].presence
|
|
65
|
+
)
|
|
66
|
+
end
|
|
73
67
|
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
query.failed_jobs_relation.find_in_batches(batch_size: 500, &block)
|
|
81
|
-
end
|
|
68
|
+
def find_job
|
|
69
|
+
SolidQueue::Job
|
|
70
|
+
.includes(:ready_execution, :scheduled_execution, :claimed_execution,
|
|
71
|
+
:blocked_execution, :failed_execution)
|
|
72
|
+
.find(params[:id])
|
|
73
|
+
end
|
|
82
74
|
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
75
|
+
def failed_jobs_in_batches(&block)
|
|
76
|
+
query = JobsQuery.new(
|
|
77
|
+
status: "failed",
|
|
78
|
+
queue_name: params[:queue_name].presence,
|
|
79
|
+
class_name: params[:class_name].presence
|
|
80
|
+
)
|
|
81
|
+
query.failed_jobs_relation.find_in_batches(batch_size: 500, &block)
|
|
82
|
+
end
|
|
83
|
+
|
|
84
|
+
def failed_jobs_path
|
|
85
|
+
jobs_path(status: "failed", queue_name: params[:queue_name].presence,
|
|
86
|
+
class_name: params[:class_name].presence)
|
|
87
|
+
end
|
|
87
88
|
end
|
|
88
89
|
end
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
module JobBoard
|
|
2
|
+
class MetricsController < ApplicationController
|
|
3
|
+
def show
|
|
4
|
+
render json: Throughput.snapshot(since: parse_time(params[:since]))
|
|
5
|
+
end
|
|
6
|
+
|
|
7
|
+
private
|
|
8
|
+
|
|
9
|
+
def parse_time(value)
|
|
10
|
+
Time.iso8601(value.to_s)
|
|
11
|
+
rescue ArgumentError
|
|
12
|
+
nil # blank or malformed cursor → treat as a fresh baseline
|
|
13
|
+
end
|
|
14
|
+
end
|
|
15
|
+
end
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
module JobBoard
|
|
2
|
+
module RecurringTasks
|
|
3
|
+
class RunsController < ApplicationController
|
|
4
|
+
def create
|
|
5
|
+
task = SolidQueue::RecurringTask.find_by(key: params[:key])
|
|
6
|
+
|
|
7
|
+
if task.nil?
|
|
8
|
+
redirect_to recurring_tasks_path, alert: "Recurring task \"#{params[:key]}\" no longer exists."
|
|
9
|
+
elsif task.enqueue(at: Time.current)
|
|
10
|
+
redirect_to recurring_tasks_path, notice: "Enqueued \"#{task.key}\"."
|
|
11
|
+
else
|
|
12
|
+
redirect_to recurring_tasks_path,
|
|
13
|
+
alert: "\"#{task.key}\" was not enqueued — it already ran at this exact time or the enqueue failed."
|
|
14
|
+
end
|
|
15
|
+
end
|
|
16
|
+
end
|
|
17
|
+
end
|
|
18
|
+
end
|
|
@@ -1,7 +1,8 @@
|
|
|
1
1
|
module JobBoard
|
|
2
2
|
class RecurringTasksController < ApplicationController
|
|
3
3
|
def index
|
|
4
|
-
|
|
4
|
+
# next_time is derived from the cron schedule, not stored, so sort in Ruby.
|
|
5
|
+
@tasks = SolidQueue::RecurringTask.all.sort_by { |task| [task.next_time, task.key] }
|
|
5
6
|
end
|
|
6
7
|
end
|
|
7
8
|
end
|
|
@@ -30,6 +30,13 @@ module JobBoard
|
|
|
30
30
|
LatencySla.threshold_for(queue_name)
|
|
31
31
|
end
|
|
32
32
|
|
|
33
|
+
# <code> tag for a cron schedule, with an English description of the
|
|
34
|
+
# schedule as a hover tooltip when we can produce one.
|
|
35
|
+
def cron_schedule(schedule)
|
|
36
|
+
description = CronDescription.describe(schedule)
|
|
37
|
+
tag.code(schedule, title: description, class: ("cron" if description))
|
|
38
|
+
end
|
|
39
|
+
|
|
33
40
|
def format_json(value)
|
|
34
41
|
value = JSON.parse(value) if value.is_a?(String)
|
|
35
42
|
JSON.pretty_generate(value)
|
|
@@ -0,0 +1,154 @@
|
|
|
1
|
+
require "fugit"
|
|
2
|
+
|
|
3
|
+
module JobBoard
|
|
4
|
+
# Turns a cron expression into an English frequency description,
|
|
5
|
+
# e.g. "30 9 * * 1-5" => "At 09:30 on Monday through Friday".
|
|
6
|
+
# Returns nil for anything it can't describe faithfully.
|
|
7
|
+
class CronDescription
|
|
8
|
+
DAYS = %w[Sunday Monday Tuesday Wednesday Thursday Friday Saturday].freeze
|
|
9
|
+
MONTHS = %w[January February March April May June
|
|
10
|
+
July August September October November December].freeze
|
|
11
|
+
|
|
12
|
+
def self.describe(schedule)
|
|
13
|
+
cron = Fugit.parse(schedule.to_s)
|
|
14
|
+
new(cron).to_s if cron.instance_of?(Fugit::Cron)
|
|
15
|
+
rescue StandardError
|
|
16
|
+
nil
|
|
17
|
+
end
|
|
18
|
+
|
|
19
|
+
def initialize(cron)
|
|
20
|
+
@cron = cron
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
def to_s
|
|
24
|
+
time = time_clause
|
|
25
|
+
return nil if time.nil?
|
|
26
|
+
|
|
27
|
+
days = day_clause
|
|
28
|
+
description =
|
|
29
|
+
if days
|
|
30
|
+
"#{time} #{days}"
|
|
31
|
+
elsif time.start_with?("At ")
|
|
32
|
+
"Every day #{time.sub(/\AAt /, "at ")}"
|
|
33
|
+
else
|
|
34
|
+
time
|
|
35
|
+
end
|
|
36
|
+
cron.zone ? "#{description} (#{cron.zone})" : description
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
private
|
|
40
|
+
|
|
41
|
+
attr_reader :cron
|
|
42
|
+
|
|
43
|
+
def time_clause
|
|
44
|
+
if (step = seconds_step)
|
|
45
|
+
# Sub-minute schedules only describe cleanly when nothing else is constrained.
|
|
46
|
+
return nil unless cron.minutes.nil? && cron.hours.nil?
|
|
47
|
+
"Every #{step} seconds"
|
|
48
|
+
elsif cron.seconds && cron.seconds != [0]
|
|
49
|
+
nil
|
|
50
|
+
elsif cron.minutes.nil? && cron.hours.nil?
|
|
51
|
+
"Every minute"
|
|
52
|
+
elsif (step = minutes_step) && cron.hours.nil?
|
|
53
|
+
"Every #{step} minutes"
|
|
54
|
+
elsif cron.minutes == [0] && cron.hours.nil?
|
|
55
|
+
"Every hour"
|
|
56
|
+
elsif cron.hours.nil?
|
|
57
|
+
"Every hour at #{numbers("minute", cron.minutes)}"
|
|
58
|
+
elsif (step = hours_step)
|
|
59
|
+
cron.minutes == [0] ? "Every #{step} hours" : "Every #{step} hours at #{numbers("minute", cron.minutes)}"
|
|
60
|
+
else
|
|
61
|
+
times = cron.hours.product(cron.minutes || [0]).sort
|
|
62
|
+
if times.size <= 4
|
|
63
|
+
"At #{join_and(times.map { |h, m| format("%02d:%02d", h, m) })}"
|
|
64
|
+
else
|
|
65
|
+
"At #{numbers("minute", cron.minutes)} past #{numbers("hour", cron.hours)}"
|
|
66
|
+
end
|
|
67
|
+
end
|
|
68
|
+
end
|
|
69
|
+
|
|
70
|
+
# nil when there is no day-of-week/month restriction (i.e. runs every day).
|
|
71
|
+
def day_clause
|
|
72
|
+
day_parts = [weekday_clause, monthday_clause].compact
|
|
73
|
+
# Cron treats day-of-month and day-of-week as OR when both are restricted.
|
|
74
|
+
clause = day_parts.join(" or ").presence
|
|
75
|
+
[clause, month_clause].compact.join(" ").presence
|
|
76
|
+
end
|
|
77
|
+
|
|
78
|
+
def weekday_clause
|
|
79
|
+
return nil if cron.weekdays.nil?
|
|
80
|
+
|
|
81
|
+
plain = cron.weekdays.select { |_, nth| nth.nil? }.map { |day,| day % 7 }.sort.uniq
|
|
82
|
+
nth = cron.weekdays.reject { |_, nth| nth.nil? }.map do |day, n|
|
|
83
|
+
"the #{n == -1 ? "last" : n.ordinalize} #{DAYS[day % 7]} of the month"
|
|
84
|
+
end
|
|
85
|
+
|
|
86
|
+
phrases = []
|
|
87
|
+
if plain.any?
|
|
88
|
+
phrases << if plain.size > 2 && consecutive?(plain)
|
|
89
|
+
"#{DAYS[plain.first]} through #{DAYS[plain.last]}"
|
|
90
|
+
else
|
|
91
|
+
join_and(plain.map { |day| DAYS[day] })
|
|
92
|
+
end
|
|
93
|
+
end
|
|
94
|
+
"on #{join_and(phrases + nth)}"
|
|
95
|
+
end
|
|
96
|
+
|
|
97
|
+
def monthday_clause
|
|
98
|
+
return nil if cron.monthdays.nil?
|
|
99
|
+
|
|
100
|
+
# "0 0 1 1 *" reads better as "on January 1" than "on day 1 of the month in January".
|
|
101
|
+
if cron.monthdays.size == 1 && cron.monthdays.first.positive? &&
|
|
102
|
+
cron.months&.size == 1 && cron.weekdays.nil?
|
|
103
|
+
@month_consumed = true
|
|
104
|
+
return "on #{MONTHS[cron.months.first - 1]} #{cron.monthdays.first}"
|
|
105
|
+
end
|
|
106
|
+
|
|
107
|
+
positive, negative = cron.monthdays.partition(&:positive?)
|
|
108
|
+
phrases = []
|
|
109
|
+
phrases << numbers("day", positive) if positive.any?
|
|
110
|
+
phrases += negative.sort.reverse.map do |day|
|
|
111
|
+
day == -1 ? "the last day" : "the #{(-day).ordinalize}-to-last day"
|
|
112
|
+
end
|
|
113
|
+
"on #{join_and(phrases)} of the month"
|
|
114
|
+
end
|
|
115
|
+
|
|
116
|
+
def month_clause
|
|
117
|
+
return nil if cron.months.nil? || @month_consumed
|
|
118
|
+
|
|
119
|
+
names = cron.months.sort.map { |m| MONTHS[m - 1] }
|
|
120
|
+
if names.size > 2 && consecutive?(cron.months.sort)
|
|
121
|
+
"in #{names.first} through #{names.last}"
|
|
122
|
+
else
|
|
123
|
+
"in #{join_and(names)}"
|
|
124
|
+
end
|
|
125
|
+
end
|
|
126
|
+
|
|
127
|
+
def seconds_step = step_of(cron.seconds, 60)
|
|
128
|
+
def minutes_step = step_of(cron.minutes, 60)
|
|
129
|
+
def hours_step = step_of(cron.hours, 24)
|
|
130
|
+
|
|
131
|
+
# [0, 15, 30, 45] within 60 => 15; anything not starting at 0 or unevenly spaced => nil.
|
|
132
|
+
def step_of(values, span)
|
|
133
|
+
return nil if values.nil? || values.size < 2 || values.first != 0
|
|
134
|
+
|
|
135
|
+
gap = values[1] - values[0]
|
|
136
|
+
values == (0...span).step(gap).to_a ? gap : nil
|
|
137
|
+
end
|
|
138
|
+
|
|
139
|
+
def consecutive?(values)
|
|
140
|
+
values.each_cons(2).all? { |a, b| b == a + 1 }
|
|
141
|
+
end
|
|
142
|
+
|
|
143
|
+
def numbers(noun, values)
|
|
144
|
+
"#{noun.pluralize(values.size)} #{join_and(values.sort)}"
|
|
145
|
+
end
|
|
146
|
+
|
|
147
|
+
def join_and(items)
|
|
148
|
+
items = items.map(&:to_s)
|
|
149
|
+
return items.join(" and ") if items.size <= 2
|
|
150
|
+
|
|
151
|
+
"#{items[0..-2].join(", ")}, and #{items.last}"
|
|
152
|
+
end
|
|
153
|
+
end
|
|
154
|
+
end
|
|
@@ -49,38 +49,39 @@ module JobBoard
|
|
|
49
49
|
end
|
|
50
50
|
|
|
51
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
52
|
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
53
|
+
def relation
|
|
54
|
+
case status
|
|
55
|
+
when "ready"
|
|
56
|
+
filtered(SolidQueue::ReadyExecution.includes(:job), own_queue_column: true)
|
|
57
|
+
when "scheduled"
|
|
58
|
+
filtered(SolidQueue::ScheduledExecution.includes(:job), own_queue_column: true)
|
|
59
|
+
when "in_progress"
|
|
60
|
+
filtered(SolidQueue::ClaimedExecution.includes(:process, :job), own_queue_column: false)
|
|
61
|
+
when "blocked"
|
|
62
|
+
filtered(SolidQueue::BlockedExecution.includes(:job), own_queue_column: true)
|
|
63
|
+
when "failed"
|
|
64
|
+
filtered(SolidQueue::FailedExecution.includes(:job), own_queue_column: false)
|
|
65
|
+
when "finished"
|
|
66
|
+
filtered_jobs(SolidQueue::Job.finished)
|
|
67
|
+
else
|
|
68
|
+
raise ArgumentError, "unknown status #{status.inspect}"
|
|
78
69
|
end
|
|
70
|
+
end
|
|
79
71
|
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
scope = scope.where(
|
|
83
|
-
|
|
72
|
+
def filtered(scope, own_queue_column:)
|
|
73
|
+
if queue_name
|
|
74
|
+
scope = own_queue_column ? scope.where(queue_name: queue_name)
|
|
75
|
+
: scope.joins(:job).where(solid_queue_jobs: { queue_name: queue_name })
|
|
84
76
|
end
|
|
77
|
+
scope = scope.joins(:job).where(solid_queue_jobs: { class_name: class_name }) if class_name
|
|
78
|
+
scope
|
|
79
|
+
end
|
|
80
|
+
|
|
81
|
+
def filtered_jobs(scope)
|
|
82
|
+
scope = scope.where(queue_name: queue_name) if queue_name
|
|
83
|
+
scope = scope.where(class_name: class_name) if class_name
|
|
84
|
+
scope
|
|
85
|
+
end
|
|
85
86
|
end
|
|
86
87
|
end
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
module JobBoard
|
|
2
|
+
class Throughput
|
|
3
|
+
READY_AT = "COALESCE(scheduled_at, created_at)".freeze
|
|
4
|
+
|
|
5
|
+
class << self
|
|
6
|
+
def snapshot(since:)
|
|
7
|
+
now = Time.current
|
|
8
|
+
{
|
|
9
|
+
now: now.iso8601(3),
|
|
10
|
+
enqueued: since ? count_between(READY_AT, since, now) : 0,
|
|
11
|
+
completed: since ? count_between("finished_at", since, now) : 0
|
|
12
|
+
}
|
|
13
|
+
end
|
|
14
|
+
|
|
15
|
+
private
|
|
16
|
+
|
|
17
|
+
def count_between(expression, since, now)
|
|
18
|
+
SolidQueue::Job.where("#{expression} > ? AND #{expression} <= ?", since, now).count
|
|
19
|
+
end
|
|
20
|
+
end
|
|
21
|
+
end
|
|
22
|
+
end
|
|
@@ -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>
|
|
@@ -15,6 +15,7 @@
|
|
|
15
15
|
<th>Queue</th>
|
|
16
16
|
<th>Last run</th>
|
|
17
17
|
<th>Next run</th>
|
|
18
|
+
<th class="actions"></th>
|
|
18
19
|
</tr>
|
|
19
20
|
</thead>
|
|
20
21
|
<tbody>
|
|
@@ -24,11 +25,15 @@
|
|
|
24
25
|
<%= task.key %>
|
|
25
26
|
<% unless task.static %><span class="badge badge--kind">dynamic</span><% end %>
|
|
26
27
|
</td>
|
|
27
|
-
<td
|
|
28
|
+
<td><%= cron_schedule(task.schedule) %></td>
|
|
28
29
|
<td><%= task.class_name.presence || task.command %></td>
|
|
29
30
|
<td><%= task.queue_name.presence || "default" %></td>
|
|
30
31
|
<td><%= time_ago(task.last_enqueued_time) %></td>
|
|
31
32
|
<td><%= time_ago(task.next_time) %></td>
|
|
33
|
+
<td class="actions">
|
|
34
|
+
<%= button_to "Run now", recurring_task_run_path(task.key), class: "btn",
|
|
35
|
+
form: { data: { confirm: "Enqueue \"#{task.key}\" now, outside its schedule?" } } %>
|
|
36
|
+
</td>
|
|
32
37
|
</tr>
|
|
33
38
|
<% end %>
|
|
34
39
|
</tbody>
|
|
@@ -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
|
@@ -20,6 +20,11 @@ JobBoard::Engine.routes.draw do
|
|
|
20
20
|
|
|
21
21
|
resources :processes, only: :index
|
|
22
22
|
resources :recurring_tasks, only: :index
|
|
23
|
+
scope "recurring_tasks/:key", constraints: { key: /[^\/]+/ }, format: false do
|
|
24
|
+
post "run", to: "recurring_tasks/runs#create", as: :recurring_task_run
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
get "metrics", to: "metrics#show", as: :metrics
|
|
23
28
|
|
|
24
29
|
get "assets/:name", to: "assets#show", as: :static_asset, constraints: { name: /[a-z_]+\.(css|js)/ }
|
|
25
30
|
end
|
|
@@ -70,6 +70,13 @@ code {
|
|
|
70
70
|
|
|
71
71
|
.muted { color: var(--text-muted); }
|
|
72
72
|
|
|
73
|
+
/* Cron schedules with a hover description */
|
|
74
|
+
code.cron {
|
|
75
|
+
cursor: help;
|
|
76
|
+
text-decoration: underline dotted var(--text-muted);
|
|
77
|
+
text-underline-offset: 2px;
|
|
78
|
+
}
|
|
79
|
+
|
|
73
80
|
/* Nav */
|
|
74
81
|
.nav {
|
|
75
82
|
display: flex;
|
|
@@ -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/version.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.4.0
|
|
5
5
|
platform: ruby
|
|
6
6
|
authors:
|
|
7
7
|
- Mike
|
|
@@ -51,11 +51,14 @@ 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
|
|
58
|
+
- app/controllers/job_board/recurring_tasks/runs_controller.rb
|
|
57
59
|
- app/controllers/job_board/recurring_tasks_controller.rb
|
|
58
60
|
- app/helpers/job_board/application_helper.rb
|
|
61
|
+
- app/models/job_board/cron_description.rb
|
|
59
62
|
- app/models/job_board/job_presenter.rb
|
|
60
63
|
- app/models/job_board/job_row.rb
|
|
61
64
|
- app/models/job_board/jobs_query.rb
|
|
@@ -63,6 +66,7 @@ files:
|
|
|
63
66
|
- app/models/job_board/page.rb
|
|
64
67
|
- app/models/job_board/process_tree.rb
|
|
65
68
|
- app/models/job_board/queue_list.rb
|
|
69
|
+
- app/models/job_board/throughput.rb
|
|
66
70
|
- app/views/job_board/jobs/_filters.html.erb
|
|
67
71
|
- app/views/job_board/jobs/_job_row.html.erb
|
|
68
72
|
- app/views/job_board/jobs/index.html.erb
|
|
@@ -79,6 +83,8 @@ files:
|
|
|
79
83
|
- lib/job_board.rb
|
|
80
84
|
- lib/job_board/assets/application.css
|
|
81
85
|
- lib/job_board/assets/application.js
|
|
86
|
+
- lib/job_board/assets/throughput_chart.css
|
|
87
|
+
- lib/job_board/assets/throughput_chart.js
|
|
82
88
|
- lib/job_board/configuration.rb
|
|
83
89
|
- lib/job_board/engine.rb
|
|
84
90
|
- lib/job_board/version.rb
|