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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: c0a2428d3806231d3051a64d1ed7f693e51341cbbc12a4a2fe8240f1f1c56d73
4
- data.tar.gz: 59cb3f9a17d3ae3cc0b9ac69adb11ec5c1c27838391d559c2c6f9a23e2a65306
3
+ metadata.gz: bea67d509d907e2592ab52afa1b5416ddec7493cbdab455d67b9f9ac4aa3e66e
4
+ data.tar.gz: 38f1c58ef928b9d40253aa5f9e37f1b5bd4073899cd3a641a1d467455f8c541c
5
5
  SHA512:
6
- metadata.gz: fc936decbae3e1e9457ac3a6d6643f67907e4b0314ee3368e22516040779381f549af59c7b60fffa28f026b1edb88feddfa62cdd32772b9e4571eb413ad1e46f
7
- data.tar.gz: c7f69762ffd1695bb6fc3dd64d59a8248f3d445255cc8f2276d9c7ddfb3a0b2008898c8557988fe4c76455389fbe213e752b36bdf1d4ccbdf56393e5c74ef4b0
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, last run, and next run.
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
- @tasks = SolidQueue::RecurringTask.order(:key)
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><code><%= task.schedule %></code></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;
@@ -1,3 +1,3 @@
1
1
  module JobBoard
2
- VERSION = "0.2.0"
2
+ VERSION = "0.3.0"
3
3
  end
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.2.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