job_board 0.2.0 → 0.3.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 +8 -0
- data/README.md +3 -1
- 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/views/job_board/recurring_tasks/index.html.erb +6 -1
- data/config/routes.rb +3 -0
- data/lib/job_board/assets/application.css +7 -0
- data/lib/job_board/version.rb +1 -1
- metadata +3 -1
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: bea67d509d907e2592ab52afa1b5416ddec7493cbdab455d67b9f9ac4aa3e66e
|
|
4
|
+
data.tar.gz: 38f1c58ef928b9d40253aa5f9e37f1b5bd4073899cd3a641a1d467455f8c541c
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: 688e4fd719017cad90493adcfcbe9e8629c06d939a7f58d28cc9a32b119ec4a01ba90942604dbfb9fb218f6bb931dfc44ee760f0b0d6448f664a9d7eb27f2663
|
|
7
|
+
data.tar.gz: 0436d97db91c3ac1071145936ea3c41f4396fca772407324c8349369af7343da0cb4783552f9bca4d8b2786c7ce1e22a1cc9256364c05836c8a850a9e1debb0f
|
data/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,13 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## 0.3.0
|
|
4
|
+
|
|
5
|
+
- Recurring tasks page: **Run now** button to trigger a task outside its schedule
|
|
6
|
+
(recorded as a run, so "Last run" reflects it), and tasks are now sorted by next
|
|
7
|
+
run time instead of by key.
|
|
8
|
+
- Hovering a cron schedule shows a plain-English description of when it runs
|
|
9
|
+
(e.g. `30 9 * * 1-5` → "At 09:30 on Monday through Friday").
|
|
10
|
+
|
|
3
11
|
## 0.2.0
|
|
4
12
|
|
|
5
13
|
- 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
|
|
|
@@ -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
|
|
@@ -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>
|
data/config/routes.rb
CHANGED
|
@@ -20,6 +20,9 @@ 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
|
|
23
26
|
|
|
24
27
|
get "assets/:name", to: "assets#show", as: :static_asset, constraints: { name: /[a-z_]+\.(css|js)/ }
|
|
25
28
|
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;
|
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.3.0
|
|
5
5
|
platform: ruby
|
|
6
6
|
authors:
|
|
7
7
|
- Mike
|
|
@@ -54,8 +54,10 @@ files:
|
|
|
54
54
|
- app/controllers/job_board/processes_controller.rb
|
|
55
55
|
- app/controllers/job_board/queues/pauses_controller.rb
|
|
56
56
|
- app/controllers/job_board/queues_controller.rb
|
|
57
|
+
- app/controllers/job_board/recurring_tasks/runs_controller.rb
|
|
57
58
|
- app/controllers/job_board/recurring_tasks_controller.rb
|
|
58
59
|
- app/helpers/job_board/application_helper.rb
|
|
60
|
+
- app/models/job_board/cron_description.rb
|
|
59
61
|
- app/models/job_board/job_presenter.rb
|
|
60
62
|
- app/models/job_board/job_row.rb
|
|
61
63
|
- app/models/job_board/jobs_query.rb
|