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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: c0a2428d3806231d3051a64d1ed7f693e51341cbbc12a4a2fe8240f1f1c56d73
4
- data.tar.gz: 59cb3f9a17d3ae3cc0b9ac69adb11ec5c1c27838391d559c2c6f9a23e2a65306
3
+ metadata.gz: bfca59214924a123a70c43a88852ea184782d00ca60107c07808b79cf6c3c3ac
4
+ data.tar.gz: 0e64e1a67b82fb40227e2f9d87729fb647fe2b7d00bfc6bb9cab0ecf331ba3c6
5
5
  SHA512:
6
- metadata.gz: fc936decbae3e1e9457ac3a6d6643f67907e4b0314ee3368e22516040779381f549af59c7b60fffa28f026b1edb88feddfa62cdd32772b9e4571eb413ad1e46f
7
- data.tar.gz: c7f69762ffd1695bb6fc3dd64d59a8248f3d445255cc8f2276d9c7ddfb3a0b2008898c8557988fe4c76455389fbe213e752b36bdf1d4ccbdf56393e5c74ef4b0
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
+ ![Throughput chart with enqueued and completed lines](doc/throughput-0.4.0.png)
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, 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
 
@@ -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
- 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
11
+ def authenticate
12
+ credentials = JobBoard.config.http_basic_auth
13
+ return unless credentials
19
14
 
20
- def stale_threshold
21
- JobBoard.config.stale_process_threshold || SolidQueue.process_alive_threshold
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
@@ -4,7 +4,9 @@ module JobBoard
4
4
 
5
5
  ASSETS = {
6
6
  "application.css" => "text/css",
7
- "application.js" => "text/javascript"
7
+ "throughput_chart.css" => "text/css",
8
+ "application.js" => "text/javascript",
9
+ "throughput_chart.js" => "text/javascript"
8
10
  }.freeze
9
11
 
10
12
  def show
@@ -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
- @query = JobsQuery.new(
61
- status: status,
62
- queue_name: params[:queue_name].presence,
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
- 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
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
- 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
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
- def failed_jobs_path
84
- jobs_path(status: "failed", queue_name: params[:queue_name].presence,
85
- class_name: params[:class_name].presence)
86
- end
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
- @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
@@ -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
- 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
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
- 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
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><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>
@@ -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
+ })();
@@ -1,3 +1,3 @@
1
1
  module JobBoard
2
- VERSION = "0.2.0"
2
+ VERSION = "0.4.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.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