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.
Files changed (33) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +18 -0
  3. data/app/controllers/job_board/application_controller.rb +13 -10
  4. data/app/controllers/job_board/assets_controller.rb +6 -2
  5. data/app/controllers/job_board/jobs_controller.rb +31 -28
  6. data/app/controllers/job_board/metrics_controller.rb +17 -0
  7. data/app/controllers/job_board/processes_controller.rb +2 -0
  8. data/app/controllers/job_board/queues/pauses_controller.rb +2 -0
  9. data/app/controllers/job_board/queues_controller.rb +5 -1
  10. data/app/controllers/job_board/recurring_tasks/runs_controller.rb +5 -2
  11. data/app/controllers/job_board/recurring_tasks_controller.rb +2 -0
  12. data/app/helpers/job_board/application_helper.rb +4 -2
  13. data/app/models/job_board/cron_description.rb +12 -7
  14. data/app/models/job_board/job_presenter.rb +2 -0
  15. data/app/models/job_board/job_row.rb +2 -0
  16. data/app/models/job_board/jobs_query.rb +35 -29
  17. data/app/models/job_board/latency_sla.rb +4 -2
  18. data/app/models/job_board/page.rb +4 -2
  19. data/app/models/job_board/process_tree.rb +4 -2
  20. data/app/models/job_board/queue_list.rb +4 -2
  21. data/app/models/job_board/throughput.rb +24 -0
  22. data/app/views/job_board/queues/_table.html.erb +9 -0
  23. data/app/views/job_board/queues/index.html.erb +18 -0
  24. data/app/views/layouts/job_board/application.html.erb +2 -0
  25. data/config/routes.rb +7 -3
  26. data/lib/job_board/assets/application.css +1 -0
  27. data/lib/job_board/assets/throughput_chart.css +64 -0
  28. data/lib/job_board/assets/throughput_chart.js +89 -0
  29. data/lib/job_board/configuration.rb +2 -0
  30. data/lib/job_board/engine.rb +2 -0
  31. data/lib/job_board/version.rb +3 -1
  32. data/lib/job_board.rb +2 -0
  33. metadata +5 -1
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: bea67d509d907e2592ab52afa1b5416ddec7493cbdab455d67b9f9ac4aa3e66e
4
- data.tar.gz: 38f1c58ef928b9d40253aa5f9e37f1b5bd4073899cd3a641a1d467455f8c541c
3
+ metadata.gz: 18b3b4aa806ccbda04e2bd2100e4561eefec8b1ec665975b3d34d22d9ababadf
4
+ data.tar.gz: 31ba104259fe986f5c2eceee2f335d153246d7d21f6c9baf22764420091fbfa2
5
5
  SHA512:
6
- metadata.gz: 688e4fd719017cad90493adcfcbe9e8629c06d939a7f58d28cc9a32b119ec4a01ba90942604dbfb9fb218f6bb931dfc44ee760f0b0d6448f664a9d7eb27f2663
7
- data.tar.gz: 0436d97db91c3ac1071145936ea3c41f4396fca772407324c8349369af7343da0cb4783552f9bca4d8b2786c7ce1e22a1cc9256364c05836c8a850a9e1debb0f
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
+ ![Queues table with a Running column](doc/running-count-0.5.0.png)
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
+ ![Throughput chart with enqueued and completed lines](doc/throughput-0.4.0.png)
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
- authenticate_or_request_with_http_basic("JobBoard") do |name, password|
15
- ActiveSupport::SecurityUtils.secure_compare(name, credentials[:name].to_s) &
16
- ActiveSupport::SecurityUtils.secure_compare(password, credentials[:password].to_s)
17
- end
18
- end
13
+ def authenticate
14
+ credentials = JobBoard.config.http_basic_auth
15
+ return unless credentials
19
16
 
20
- def stale_threshold
21
- JobBoard.config.stale_process_threshold || SolidQueue.process_alive_threshold
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
- "application.js" => "text/javascript"
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
- type: content_type, disposition: :inline
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: [: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)
@@ -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
- @query = JobsQuery.new(
61
- status: status,
62
- queue_name: params[:queue_name].presence,
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
- def find_job
68
- SolidQueue::Job
69
- .includes(:ready_execution, :scheduled_execution, :claimed_execution,
70
- :blocked_execution, :failed_execution)
71
- .find(params[:id])
72
- end
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
- def failed_jobs_in_batches(&block)
75
- query = JobsQuery.new(
76
- status: "failed",
77
- queue_name: params[:queue_name].presence,
78
- class_name: params[:class_name].presence
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
- def failed_jobs_path
84
- jobs_path(status: "failed", queue_name: params[:queue_name].presence,
85
- class_name: params[:class_name].presence)
86
- end
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,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).
@@ -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
- def filtered(scope, own_queue_column:)
72
- if queue_name
73
- scope = own_queue_column ? scope.where(queue_name: queue_name)
74
- : scope.joins(:job).where(solid_queue_jobs: { queue_name: queue_name })
75
- end
76
- scope = scope.joins(:job).where(solid_queue_jobs: { class_name: class_name }) if class_name
77
- scope
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
- def filtered_jobs(scope)
81
- scope = scope.where(queue_name: queue_name) if queue_name
82
- scope = scope.where(class_name: class_name) if class_name
83
- scope
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" => 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)
@@ -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: /[^\/]+/ }, 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,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: /[^\/]+/ }, 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
 
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
@@ -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 {
@@ -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
+ })();
@@ -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.3.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.3.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