job_board 0.4.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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: bfca59214924a123a70c43a88852ea184782d00ca60107c07808b79cf6c3c3ac
4
- data.tar.gz: 0e64e1a67b82fb40227e2f9d87729fb647fe2b7d00bfc6bb9cab0ecf331ba3c6
3
+ metadata.gz: 18b3b4aa806ccbda04e2bd2100e4561eefec8b1ec665975b3d34d22d9ababadf
4
+ data.tar.gz: 31ba104259fe986f5c2eceee2f335d153246d7d21f6c9baf22764420091fbfa2
5
5
  SHA512:
6
- metadata.gz: da74ce333cc798a40a50f3ee1cafae15d7e882713eb7a8e2afa6b80773d1f0a31bcd47ad88a88e43fb321053a0ca8a8242642a3a2bc90282fee16c447d2e8224
7
- data.tar.gz: 7e979f3d406ef8038fddc553e47cb72a0af08320a3da0a936d5319e929f8177fa067ec2323093273fa0fee9adb4d24d28886e11b0cace363f6591636039f6360
6
+ metadata.gz: 746ddd882343564e9cec4dfe8a621b84e8410dc5aaac35adbce9a6adc8223fdddbf3a392ed6a9486fa5208268829a6daa470304dea9a27ec06aec300d2d9f8a3
7
+ data.tar.gz: 13bae2092fa89294028c00c03bbd528fbab80c2cef3156bfb1d6a09177dc1629b5898b58db93df75091508bef54e0d1adecd7a64e2b83b3aa7861bdbcbea35ef
data/CHANGELOG.md CHANGED
@@ -1,5 +1,13 @@
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
+ ![Queues table with a Running column](doc/running-count-0.5.0.png)
10
+
3
11
  ## 0.4.0
4
12
 
5
13
  - Queues (home) page: a real-time **Throughput** line chart at the top, plotting jobs
@@ -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
@@ -1,3 +1,5 @@
1
+ # frozen_string_literal: true
2
+
1
3
  module JobBoard
2
4
  class AssetsController < ApplicationController
3
5
  skip_forgery_protection
@@ -15,7 +17,7 @@ module JobBoard
15
17
 
16
18
  expires_in 1.year, public: true
17
19
  send_file JobBoard::Engine.root.join("lib/job_board/assets", name),
18
- type: content_type, disposition: :inline
20
+ type: content_type, disposition: :inline
19
21
  end
20
22
  end
21
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: [:index, :retry_all, :discard_all]
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)
@@ -72,13 +74,13 @@ module JobBoard
72
74
  .find(params[:id])
73
75
  end
74
76
 
75
- def failed_jobs_in_batches(&block)
77
+ def failed_jobs_in_batches(&)
76
78
  query = JobsQuery.new(
77
79
  status: "failed",
78
80
  queue_name: params[:queue_name].presence,
79
81
  class_name: params[:class_name].presence
80
82
  )
81
- query.failed_jobs_relation.find_in_batches(batch_size: 500, &block)
83
+ query.failed_jobs_relation.find_in_batches(batch_size: 500, &)
82
84
  end
83
85
 
84
86
  def failed_jobs_path
@@ -1,3 +1,5 @@
1
+ # frozen_string_literal: true
2
+
1
3
  module JobBoard
2
4
  class MetricsController < ApplicationController
3
5
  def show
@@ -1,3 +1,5 @@
1
+ # frozen_string_literal: true
2
+
1
3
  module JobBoard
2
4
  class ProcessesController < ApplicationController
3
5
  def index
@@ -1,3 +1,5 @@
1
+ # frozen_string_literal: true
2
+
1
3
  module JobBoard
2
4
  module Queues
3
5
  class PausesController < ApplicationController
@@ -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
- .group("solid_queue_jobs.queue_name").count
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
- redirect_to recurring_tasks_path,
13
- alert: "\"#{task.key}\" was not enqueued — it already ran at this exact time or the enqueue failed."
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
  class RecurringTasksController < ApplicationController
3
5
  def index
@@ -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
- title: time.iso8601, datetime: time.iso8601)
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" => 86400, "h" => 3600, "m" => 60, "s" => 1 }.filter_map do |unit, size|
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("%02d:%02d", h, m) })}"
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.reject { |_, nth| nth.nil? }.map do |day, n|
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
- "#{DAYS[plain.first]} through #{DAYS[plain.last]}"
90
- else
91
- join_and(plain.map { |day| DAYS[day] })
92
- end
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
- cron.months&.size == 1 && cron.weekdays.nil?
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
  # Detail-page wrapper around a SolidQueue::Job loaded with all five
3
5
  # has_one execution associations preloaded.
@@ -1,3 +1,5 @@
1
+ # frozen_string_literal: true
2
+
1
3
  module JobBoard
2
4
  # Uniform row facade so the jobs table doesn't care whether a row came from
3
5
  # an execution record or a finished job.
@@ -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).
@@ -71,8 +73,11 @@ module JobBoard
71
73
 
72
74
  def filtered(scope, own_queue_column:)
73
75
  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 })
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
76
81
  end
77
82
  scope = scope.joins(:job).where(solid_queue_jobs: { class_name: class_name }) if class_name
78
83
  scope
@@ -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" => 86400 }.freeze
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(&block)
28
- rows.each(&block)
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.map(&:id).to_set
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
- .includes(:job).group_by(&:process_id)
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
- last_enqueued_at: SolidQueue::Job.group(:queue_name).maximum(:created_at),
13
- window: window)
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)
@@ -1,6 +1,8 @@
1
+ # frozen_string_literal: true
2
+
1
3
  module JobBoard
2
4
  class Throughput
3
- READY_AT = "COALESCE(scheduled_at, created_at)".freeze
5
+ READY_AT = "COALESCE(scheduled_at, created_at)"
4
6
 
5
7
  class << self
6
8
  def snapshot(since:)
@@ -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? %>
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: /[^\/]+/ }, format: false do
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: [:index, :show] do
12
+ resources :jobs, only: %i[index show] do
11
13
  member do
12
14
  post :retry
13
15
  delete :discard
@@ -20,7 +22,7 @@ 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: /[^\/]+/ }, format: false do
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
 
@@ -196,6 +196,7 @@ code.cron {
196
196
  .latency { font-weight: 600; }
197
197
  .latency--high { color: var(--danger); }
198
198
  .failed-count { color: var(--danger); font-weight: 600; }
199
+ .running-count { color: var(--warning); font-weight: 600; }
199
200
 
200
201
  /* Tabs */
201
202
  .tabs {
@@ -1,3 +1,5 @@
1
+ # frozen_string_literal: true
2
+
1
3
  module JobBoard
2
4
  class Configuration
3
5
  # nil, or { name: "...", password: "..." } to protect the UI with HTTP Basic auth.
@@ -1,3 +1,5 @@
1
+ # frozen_string_literal: true
2
+
1
3
  module JobBoard
2
4
  class Engine < ::Rails::Engine
3
5
  isolate_namespace JobBoard
@@ -1,3 +1,5 @@
1
+ # frozen_string_literal: true
2
+
1
3
  module JobBoard
2
- VERSION = "0.4.0"
4
+ VERSION = "0.5.0"
3
5
  end
data/lib/job_board.rb CHANGED
@@ -1,3 +1,5 @@
1
+ # frozen_string_literal: true
2
+
1
3
  require "solid_queue"
2
4
 
3
5
  require "job_board/version"
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.0
4
+ version: 0.5.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Mike