job_board 0.1.0 → 0.2.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: b9383d573ca40430673ec3e2ad7436edec021a856b72ebfcd45c3e557b8b4ac7
4
- data.tar.gz: 6d0849f4f83e665364ea8769483adbbc53f9189ff8ddd50de6fb957d1adc76ae
3
+ metadata.gz: c0a2428d3806231d3051a64d1ed7f693e51341cbbc12a4a2fe8240f1f1c56d73
4
+ data.tar.gz: 59cb3f9a17d3ae3cc0b9ac69adb11ec5c1c27838391d559c2c6f9a23e2a65306
5
5
  SHA512:
6
- metadata.gz: 544bcb5aa2cbddefa3d4ee47f708398e7fcda871addfbf95ac7184d5c5e48b332da2a34aec62ebb60158461c62c637d664df3ee0177336afa51b8d8cd93a1f39
7
- data.tar.gz: 28f552a8ed1840b90b7984806dc2794674819ea442d9e1b6da439bc0fd4cbf465c6900544e39a3f9fe5b5ebc65dbf6771b4207f06f0f2807f25763f0ba2de0c4
6
+ metadata.gz: fc936decbae3e1e9457ac3a6d6643f67907e4b0314ee3368e22516040779381f549af59c7b60fffa28f026b1edb88feddfa62cdd32772b9e4571eb413ad1e46f
7
+ data.tar.gz: c7f69762ffd1695bb6fc3dd64d59a8248f3d445255cc8f2276d9c7ddfb3a0b2008898c8557988fe4c76455389fbe213e752b36bdf1d4ccbdf56393e5c74ef4b0
data/CHANGELOG.md CHANGED
@@ -1,5 +1,14 @@
1
1
  # Changelog
2
2
 
3
+ ## 0.2.0
4
+
5
+ - Queues page: new **Last enqueued** column showing when each queue last received a job.
6
+ - Queues with nothing enqueued in the last 30 days collapse into a one-click
7
+ "Inactive queues" section. Configurable via `config.queue_activity_window`
8
+ (`nil` shows everything in one table); paused queues always stay in the main table.
9
+ - The auto-refresh poller now preserves the open/closed state of collapsible sections
10
+ across refreshes.
11
+
3
12
  ## 0.1.0
4
13
 
5
14
  Initial release.
data/README.md CHANGED
@@ -69,6 +69,9 @@ JobBoard.configure do |config|
69
69
  config.stale_process_threshold = nil # heartbeat age before a process is flagged stale;
70
70
  # nil uses SolidQueue.process_alive_threshold (5 min)
71
71
  config.http_basic_auth = nil # see Authentication above
72
+ config.queue_activity_window = 30.days # collapse queues with nothing enqueued in
73
+ # that window into "Inactive queues";
74
+ # nil shows everything in one table
72
75
 
73
76
  # Latency (seconds) above which a queue is highlighted as breaching:
74
77
  config.latency_warning_threshold = 60 # global default
@@ -94,6 +97,30 @@ Explicit `latency_warning_thresholds` entries still win over the naming conventi
94
97
  The queues page also sorts by SLA: queues with a detectable latency target come first
95
98
  (strictest at the top), and everything else follows alphabetically.
96
99
 
100
+ ### Hiding inactive queues
101
+
102
+ Because Solid Queue derives the queue list from the jobs table, a queue keeps showing
103
+ up as long as *any* job row with its name exists — including queues you retired long
104
+ ago. Each queue's **Last enqueued** column shows when it last received a job, and
105
+ queues with nothing enqueued in the last **30 days** (configurable) collapse into a
106
+ one-click **Inactive queues** section at the bottom of the page — out of the way but
107
+ never invisible, since a quiet queue can be a symptom rather than noise. Paused
108
+ queues always stay in the main table regardless of idleness, because a pause is
109
+ deliberate state someone needs to see.
110
+
111
+ ```ruby
112
+ JobBoard.configure do |config|
113
+ config.queue_activity_window = 7.days # tighter window
114
+ # or nil to always show every queue in one table
115
+ end
116
+ ```
117
+
118
+ Also worth knowing: if the lingering rows are preserved finished jobs, Solid Queue's
119
+ dispatcher normally clears them after `SolidQueue.clear_finished_jobs_after` (default
120
+ 1 day) — stale names often mean that cleanup isn't running, or that old failed jobs
121
+ are still waiting to be retried or discarded. Dealing with those makes retired queues
122
+ disappear entirely.
123
+
97
124
  ## Notes
98
125
 
99
126
  - **Separate queue database**: works automatically. Job Board reads and writes exclusively
@@ -1,7 +1,7 @@
1
1
  module JobBoard
2
2
  class QueuesController < ApplicationController
3
3
  def index
4
- @queues = LatencySla.sort(SolidQueue::Queue.all)
4
+ @queue_list = QueueList.build
5
5
  @failed_counts = SolidQueue::FailedExecution.joins(:job)
6
6
  .group("solid_queue_jobs.queue_name").count
7
7
  end
@@ -0,0 +1,35 @@
1
+ module JobBoard
2
+ # Partitions queues into active and inactive based on when each queue last
3
+ # had a job enqueued. A queue is inactive when its newest job is older than
4
+ # the activity window; paused queues always count as active, since a pause
5
+ # is deliberate state someone needs to see. Without a window, every queue
6
+ # is active.
7
+ class QueueList
8
+ attr_reader :active, :inactive, :last_enqueued_at, :window
9
+
10
+ def self.build(window: JobBoard.config.queue_activity_window)
11
+ new(LatencySla.sort(SolidQueue::Queue.all),
12
+ last_enqueued_at: SolidQueue::Job.group(:queue_name).maximum(:created_at),
13
+ window: window)
14
+ end
15
+
16
+ def initialize(queues, last_enqueued_at:, window: nil)
17
+ @last_enqueued_at = last_enqueued_at
18
+ @window = window
19
+ @active, @inactive = partition(queues)
20
+ end
21
+
22
+ private
23
+
24
+ def partition(queues)
25
+ return [queues, []] unless window
26
+
27
+ cutoff = Time.current - window
28
+ paused = SolidQueue::Pause.where(queue_name: queues.map(&:name)).pluck(:queue_name).to_set
29
+ queues.partition do |queue|
30
+ newest = last_enqueued_at[queue.name]
31
+ paused.include?(queue.name) || (newest && newest >= cutoff)
32
+ end
33
+ end
34
+ end
35
+ end
@@ -0,0 +1,44 @@
1
+ <table class="data-table">
2
+ <thead>
3
+ <tr>
4
+ <th>Queue</th>
5
+ <th class="num">Ready jobs</th>
6
+ <th class="num">Failed</th>
7
+ <th class="num">Latency</th>
8
+ <th>Last enqueued</th>
9
+ <th>Status</th>
10
+ <th class="actions"></th>
11
+ </tr>
12
+ </thead>
13
+ <tbody>
14
+ <% queues.each do |queue| %>
15
+ <tr>
16
+ <td><%= link_to queue.name, jobs_path(status: "ready", queue_name: queue.name) %></td>
17
+ <td class="num"><%= queue.size %></td>
18
+ <td class="num">
19
+ <% failed = @failed_counts[queue.name] || 0 %>
20
+ <% if failed.positive? %>
21
+ <%= link_to failed, jobs_path(status: "failed", queue_name: queue.name), class: "failed-count" %>
22
+ <% else %>
23
+ <span class="muted">0</span>
24
+ <% end %>
25
+ </td>
26
+ <td class="num">
27
+ <span class="latency <%= "latency--high" if queue.latency > latency_threshold_for(queue.name) %>" title="<%= queue.latency %> seconds">
28
+ <%= duration(queue.latency) %>
29
+ </span>
30
+ </td>
31
+ <td><%= time_ago(@queue_list.last_enqueued_at[queue.name]) %></td>
32
+ <td><%= queue.paused? ? status_badge(:paused) : status_badge(:active) %></td>
33
+ <td class="actions">
34
+ <% if queue.paused? %>
35
+ <%= button_to "Resume", queue_pause_path(queue.name), method: :delete, class: "btn" %>
36
+ <% else %>
37
+ <%= button_to "Pause", queue_pause_path(queue.name), method: :post, class: "btn",
38
+ form: { data: { confirm: "Pause queue \"#{queue.name}\"? Workers will stop picking up its jobs." } } %>
39
+ <% end %>
40
+ </td>
41
+ </tr>
42
+ <% end %>
43
+ </tbody>
44
+ </table>
@@ -1,50 +1,23 @@
1
1
  <h1>Queues</h1>
2
2
 
3
3
  <div data-poll-region="queues">
4
- <% if @queues.empty? %>
4
+ <% if @queue_list.active.empty? && @queue_list.inactive.empty? %>
5
5
  <p class="empty-state">No queues yet — queues appear once jobs are enqueued.</p>
6
6
  <% else %>
7
- <table class="data-table">
8
- <thead>
9
- <tr>
10
- <th>Queue</th>
11
- <th class="num">Ready jobs</th>
12
- <th class="num">Failed</th>
13
- <th class="num">Latency</th>
14
- <th>Status</th>
15
- <th class="actions"></th>
16
- </tr>
17
- </thead>
18
- <tbody>
19
- <% @queues.each do |queue| %>
20
- <tr>
21
- <td><%= link_to queue.name, jobs_path(status: "ready", queue_name: queue.name) %></td>
22
- <td class="num"><%= queue.size %></td>
23
- <td class="num">
24
- <% failed = @failed_counts[queue.name] || 0 %>
25
- <% if failed.positive? %>
26
- <%= link_to failed, jobs_path(status: "failed", queue_name: queue.name), class: "failed-count" %>
27
- <% else %>
28
- <span class="muted">0</span>
29
- <% end %>
30
- </td>
31
- <td class="num">
32
- <span class="latency <%= "latency--high" if queue.latency > latency_threshold_for(queue.name) %>" title="<%= queue.latency %> seconds">
33
- <%= duration(queue.latency) %>
34
- </span>
35
- </td>
36
- <td><%= queue.paused? ? status_badge(:paused) : status_badge(:active) %></td>
37
- <td class="actions">
38
- <% if queue.paused? %>
39
- <%= button_to "Resume", queue_pause_path(queue.name), method: :delete, class: "btn" %>
40
- <% else %>
41
- <%= button_to "Pause", queue_pause_path(queue.name), method: :post, class: "btn",
42
- form: { data: { confirm: "Pause queue \"#{queue.name}\"? Workers will stop picking up its jobs." } } %>
43
- <% end %>
44
- </td>
45
- </tr>
46
- <% end %>
47
- </tbody>
48
- </table>
7
+ <% if @queue_list.active.any? %>
8
+ <%= render "table", queues: @queue_list.active %>
9
+ <% else %>
10
+ <p class="empty-state">No queues have had jobs enqueued in the last <%= duration(@queue_list.window.to_i) %>.</p>
11
+ <% end %>
12
+
13
+ <% if @queue_list.inactive.any? %>
14
+ <details class="inactive-queues" data-persist="inactive-queues">
15
+ <summary>
16
+ Inactive queues (<%= @queue_list.inactive.size %>)
17
+ <span class="muted">— nothing enqueued in the last <%= duration(@queue_list.window.to_i) %></span>
18
+ </summary>
19
+ <%= render "table", queues: @queue_list.inactive %>
20
+ </details>
21
+ <% end %>
49
22
  <% end %>
50
23
  </div>
@@ -52,9 +52,7 @@ body {
52
52
  }
53
53
 
54
54
  main {
55
- max-width: 1100px;
56
- margin: 0 auto;
57
- padding: 1.5rem 1rem 4rem;
55
+ padding: 1.5rem 1.5rem 4rem;
58
56
  }
59
57
 
60
58
  h1 { font-size: 1.4rem; margin: 0.5rem 0 1rem; }
@@ -176,6 +174,18 @@ code {
176
174
  color: var(--text-muted);
177
175
  }
178
176
 
177
+ /* Queues page: collapsed section for idle queues */
178
+ .inactive-queues { margin-top: 1.25rem; }
179
+ .inactive-queues > summary {
180
+ cursor: pointer;
181
+ color: var(--text-muted);
182
+ font-size: 0.85rem;
183
+ padding: 0.35rem 0.25rem;
184
+ user-select: none;
185
+ }
186
+ .inactive-queues > summary:hover { color: var(--text); }
187
+ .inactive-queues[open] > summary { margin-bottom: 0.5rem; }
188
+
179
189
  .latency { font-weight: 600; }
180
190
  .latency--high { color: var(--danger); }
181
191
  .failed-count { color: var(--danger); font-weight: 600; }
@@ -31,7 +31,20 @@
31
31
  document.querySelectorAll("[data-poll-region]").forEach(function (region) {
32
32
  var key = region.getAttribute("data-poll-region");
33
33
  var replacement = fresh.querySelector('[data-poll-region="' + key + '"]');
34
- if (replacement) region.innerHTML = replacement.innerHTML;
34
+ if (!replacement) return;
35
+
36
+ // Remember which <details data-persist> are open so the swap
37
+ // doesn't collapse them.
38
+ var open = {};
39
+ region.querySelectorAll("details[data-persist]").forEach(function (details) {
40
+ open[details.dataset.persist] = details.open;
41
+ });
42
+
43
+ region.innerHTML = replacement.innerHTML;
44
+
45
+ region.querySelectorAll("details[data-persist]").forEach(function (details) {
46
+ if (details.dataset.persist in open) details.open = open[details.dataset.persist];
47
+ });
35
48
  });
36
49
  })
37
50
  .catch(function () { /* transient network errors are fine; try again next tick */ });
@@ -13,6 +13,12 @@ module JobBoard
13
13
  # nil falls back to SolidQueue.process_alive_threshold.
14
14
  attr_accessor :stale_process_threshold
15
15
 
16
+ # Queues with no job enqueued inside this window (seconds or an
17
+ # ActiveSupport::Duration) are collapsed into an "Inactive queues"
18
+ # section on the queues page. Paused queues always stay visible.
19
+ # nil shows every queue in one table.
20
+ attr_accessor :queue_activity_window
21
+
16
22
  # Seconds of latency after which a queue is highlighted as breaching.
17
23
  # Per-queue override: latency_warning_thresholds["queue_name"] = seconds.
18
24
  # Queues named in the within_* convention (e.g. "within_5_minutes") get
@@ -25,6 +31,7 @@ module JobBoard
25
31
  @poll_interval = 5
26
32
  @per_page = 25
27
33
  @stale_process_threshold = nil
34
+ @queue_activity_window = 30.days
28
35
  @latency_warning_threshold = 60
29
36
  @latency_warning_thresholds = {}
30
37
  end
@@ -1,3 +1,3 @@
1
1
  module JobBoard
2
- VERSION = "0.1.0"
2
+ VERSION = "0.2.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.1.0
4
+ version: 0.2.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Mike
@@ -62,12 +62,14 @@ files:
62
62
  - app/models/job_board/latency_sla.rb
63
63
  - app/models/job_board/page.rb
64
64
  - app/models/job_board/process_tree.rb
65
+ - app/models/job_board/queue_list.rb
65
66
  - app/views/job_board/jobs/_filters.html.erb
66
67
  - app/views/job_board/jobs/_job_row.html.erb
67
68
  - app/views/job_board/jobs/index.html.erb
68
69
  - app/views/job_board/jobs/show.html.erb
69
70
  - app/views/job_board/processes/_process.html.erb
70
71
  - app/views/job_board/processes/index.html.erb
72
+ - app/views/job_board/queues/_table.html.erb
71
73
  - app/views/job_board/queues/index.html.erb
72
74
  - app/views/job_board/recurring_tasks/index.html.erb
73
75
  - app/views/job_board/shared/_nav.html.erb