job_board 0.3.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: bea67d509d907e2592ab52afa1b5416ddec7493cbdab455d67b9f9ac4aa3e66e
4
- data.tar.gz: 38f1c58ef928b9d40253aa5f9e37f1b5bd4073899cd3a641a1d467455f8c541c
3
+ metadata.gz: bfca59214924a123a70c43a88852ea184782d00ca60107c07808b79cf6c3c3ac
4
+ data.tar.gz: 0e64e1a67b82fb40227e2f9d87729fb647fe2b7d00bfc6bb9cab0ecf331ba3c6
5
5
  SHA512:
6
- metadata.gz: 688e4fd719017cad90493adcfcbe9e8629c06d939a7f58d28cc9a32b119ec4a01ba90942604dbfb9fb218f6bb931dfc44ee760f0b0d6448f664a9d7eb27f2663
7
- data.tar.gz: 0436d97db91c3ac1071145936ea3c41f4396fca772407324c8349369af7343da0cb4783552f9bca4d8b2786c7ce1e22a1cc9256364c05836c8a850a9e1debb0f
6
+ metadata.gz: da74ce333cc798a40a50f3ee1cafae15d7e882713eb7a8e2afa6b80773d1f0a31bcd47ad88a88e43fb321053a0ca8a8242642a3a2bc90282fee16c447d2e8224
7
+ data.tar.gz: 7e979f3d406ef8038fddc553e47cb72a0af08320a3da0a936d5319e929f8177fa067ec2323093273fa0fee9adb4d24d28886e11b0cace363f6591636039f6360
data/CHANGELOG.md CHANGED
@@ -1,5 +1,15 @@
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
+
3
13
  ## 0.3.0
4
14
 
5
15
  - Recurring tasks page: **Run now** button to trigger a task outside its schedule
@@ -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
@@ -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>
@@ -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
@@ -24,5 +24,7 @@ JobBoard::Engine.routes.draw do
24
24
  post "run", to: "recurring_tasks/runs#create", as: :recurring_task_run
25
25
  end
26
26
 
27
+ get "metrics", to: "metrics#show", as: :metrics
28
+
27
29
  get "assets/:name", to: "assets#show", as: :static_asset, constraints: { name: /[a-z_]+\.(css|js)/ }
28
30
  end
@@ -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.3.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.3.0
4
+ version: 0.4.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