solid_queue_web 0.8.0 → 0.9.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: 6897882e9e9b87a677fe9c6af855b33395cbf4bf2d13fb55086f461d26255f7d
4
- data.tar.gz: 1b6882dce3a746811ba9553d0fb2174257c3f13146d5a448fd793c8c60a76661
3
+ metadata.gz: 462f81d84dead7a68833c768775eda6cb0b55b66862d58fa1b49b504fa417644
4
+ data.tar.gz: cc388f882d5709e8d92a778a2c6ccaa39d04022c62c9bc7e89320e416c5148d2
5
5
  SHA512:
6
- metadata.gz: d19bc85fc4350e8ecb194111c87a7b882d1e5929142ec94211bcf6fef23840810ec4eb51ae4d957a3c2288263bba9955aabca9ea1874f4d3b7f6d34590f54a28
7
- data.tar.gz: 3516ec14c55cd3839bf22d721a58d6a68016b0891b09088e5463e8f2e61e611b77d9eaca461ea49c6ffc225873589880b678fb829b53a877683e32de3d75456b
6
+ metadata.gz: '087a8cb99798a1d21df1347008380c58242a98247d48b80cba0865c4bb0e46e800d37a2c21eb887fc516c6c724c5e138ebbc8a517dc62e730b1d2ea08fe6b5d3'
7
+ data.tar.gz: 3885a114fa3f31db4b949a3b397070734b76c6e447d82bc7cd5e67628163760e03a2b70e90c1ef532143170752430de6ba1dd5a18fa0c52ff379462e90dc8d30
data/README.md CHANGED
@@ -33,8 +33,8 @@ SolidQueueWeb surfaces all of this in a browser UI available at any route you ch
33
33
 
34
34
  ## Features
35
35
 
36
- - **Dashboard** — stat cards showing counts for ready, scheduled, running, blocked, and failed jobs, plus queues, recurring tasks, and processes; "Done (1h)" and "Done (24h)" throughput cards; a "Throughput — Last 12 Hours" bar chart showing completed-job counts per hour (pure CSS, no charting library); auto-refreshes every 5 seconds
37
- - **Queues** — all queues sorted by name with size; oldest ready job latency (color-coded, with UTC timestamp tooltip); Done (24h) and Failed (24h) throughput counts; pause/resume controls
36
+ - **Dashboard** — stat cards showing counts for ready, scheduled, running, blocked, and failed jobs, plus queues, recurring tasks, and processes; "Done (1h)" and "Done (24h)" throughput cards; a "Throughput — Last 12 Hours" bar chart (blue) and a "Queue Depth — Last 12 Hours" bar chart (purple) showing hourly snapshots of active job count; pure CSS, no charting library; auto-refreshes every 5 seconds
37
+ - **Queues** — all queues sorted by name with size; oldest ready job latency (color-coded, with UTC timestamp tooltip); Done (24h) and Failed (24h) throughput counts; a mini 12-bar failure rate sparkline per queue showing failure % per hour over the last 12 hours; pause/resume controls
38
38
  - **Jobs** — filterable by status (ready, scheduled, claimed, blocked, failed) and by queue; search by job class name with dynamic auto-submit; time-based period filter (1 h / 24 h / 7 d); discard individual or all jobs; Turbo Frame navigation so only the table updates on filter or search; auto-refreshes every 10 seconds
39
39
  - **Failed jobs** — list of failed executions with error details; search by class name; filter by queue; time-based period filter; retry or discard individually or in bulk
40
40
  - **Job detail** — full arguments, timestamps, blocked-until date, and error backtrace; action buttons based on job status
@@ -47,6 +47,7 @@ SolidQueueWeb surfaces all of this in a browser UI available at any route you ch
47
47
  - **Dark mode** — ☽/☀ toggle in the header; preference persists to `localStorage` and defaults to the OS `prefers-color-scheme` on first visit; zero extra dependencies — implemented via CSS custom properties and a small Stimulus controller
48
48
  - **Dashboard quick actions** — "Retry All Failed" and "Discard All Blocked" cards appear on the dashboard only when the respective count is non-zero; one-click bulk operations with confirm dialogs, keeping the dashboard clean when everything is healthy
49
49
  - **CSV export** — "Export CSV" button on the jobs, failed jobs, and history pages downloads all records matching the current filters; columns are tailored per view
50
+ - **Slow job detection** — when `slow_job_threshold` is configured, claimed jobs running longer than the threshold are flagged with an orange row, a "slow" badge, and a "Running For" duration column on the Running tab; a "Slow Jobs" warning card appears on the dashboard with a link to the Running tab
50
51
 
51
52
  ## Screenshots
52
53
 
@@ -96,6 +97,7 @@ SolidQueueWeb.configure do |config|
96
97
  config.dashboard_refresh_interval = 10_000 # dashboard auto-refresh in ms (default: 5_000)
97
98
  config.default_refresh_interval = 30_000 # jobs/processes/history auto-refresh in ms (default: 10_000)
98
99
  config.search_results_limit = 10 # max results per status in global search (default: 25)
100
+ config.slow_job_threshold = 5.minutes # flag claimed jobs running longer than this (default: nil = disabled)
99
101
  end
100
102
 
101
103
  SolidQueueWeb.authenticate do
@@ -111,11 +113,6 @@ No authentication is enforced by default. When the `authenticate` block returns
111
113
 
112
114
  Planned features, roughly ordered by priority:
113
115
 
114
- **Observability**
115
- - Job failure rate chart — sparkline per queue showing failure percentage over time, mirroring the throughput chart
116
- - Queue depth trend — historical queue size over time, not just the current snapshot
117
- - Slow job detection — flag jobs exceeding a configurable duration threshold
118
-
119
116
  **Operations**
120
117
  - Scheduled job management — reschedule a job to run immediately, or push its `scheduled_at` forward
121
118
  - Bulk retry with delay — retry all failed jobs with a configurable stagger to avoid thundering herd
@@ -44,9 +44,16 @@ td {
44
44
 
45
45
  tr:last-child td { border-bottom: none; }
46
46
  tbody tr:hover { background: var(--bg); }
47
+ .sqd-table-link,
48
+ .sqd-table-link:hover,
49
+ .sqd-table-link:visited { text-decoration: none; color: var(--primary); }
47
50
 
48
51
  .sqd-empty {
49
52
  text-align: center;
50
53
  padding: 3rem 1rem;
51
54
  color: var(--muted);
52
- }
55
+ }
56
+
57
+ .sqd-row--slow { background: rgba(253, 126, 20, 0.07); }
58
+ .sqd-row--slow:hover { background: rgba(253, 126, 20, 0.13); }
59
+ .sqd-slow-duration { color: var(--warning); font-weight: 600; }
@@ -21,6 +21,7 @@
21
21
  .sqd-badge--supervisor { background: #e0d7f5; color: #4a2c8a; }
22
22
  .sqd-badge--worker { background: #d1e7dd; color: #0f5132; }
23
23
  .sqd-badge--dispatcher { background: #cff4fc; color: #055160; }
24
+ .sqd-badge--slow { background: #ffe8cc; color: #7c3d00; }
24
25
 
25
26
  .sqd-process-meta { font-size: 12px; color: var(--muted); }
26
27
  .sqd-process-meta span + span::before { content: " · "; }
@@ -65,4 +65,33 @@
65
65
  padding: 1rem 1.25rem;
66
66
  }
67
67
 
68
- .sqd-stat--done .sqd-stat__value { color: var(--success); }
68
+ .sqd-stat--done .sqd-stat__value { color: var(--success); }
69
+
70
+ .sqd-mini-sparkline {
71
+ display: flex;
72
+ align-items: flex-end;
73
+ gap: 2px;
74
+ height: 28px;
75
+ width: 88px;
76
+ }
77
+
78
+ .sqd-mini-sparkline__bar {
79
+ flex: 1;
80
+ background: var(--danger);
81
+ border-radius: 1px 1px 0 0;
82
+ opacity: 0.7;
83
+ transition: opacity 0.15s;
84
+ }
85
+
86
+ .sqd-mini-sparkline__bar:hover {
87
+ opacity: 1;
88
+ }
89
+
90
+ .sqd-mini-sparkline__bar--empty {
91
+ background: var(--border);
92
+ opacity: 0.5;
93
+ }
94
+
95
+ .sqd-sparkline__bar--depth {
96
+ background: var(--purple);
97
+ }
@@ -0,0 +1,11 @@
1
+ module SolidQueueWeb
2
+ class BlockedJobsController < ApplicationController
3
+ def destroy
4
+ jobs = SolidQueue::BlockedExecution.includes(:job).map(&:job)
5
+ SolidQueue::BlockedExecution.discard_all_from_jobs(jobs)
6
+ redirect_to root_path, notice: "#{jobs.size} blocked #{"job".pluralize(jobs.size)} discarded."
7
+ rescue => e
8
+ redirect_to root_path, alert: "Could not discard blocked jobs: #{e.message}"
9
+ end
10
+ end
11
+ end
@@ -1,44 +1,7 @@
1
1
  module SolidQueueWeb
2
2
  class DashboardController < ApplicationController
3
3
  def index
4
- @stats = {
5
- ready: SolidQueue::ReadyExecution.count,
6
- scheduled: SolidQueue::ScheduledExecution.count,
7
- claimed: SolidQueue::ClaimedExecution.count,
8
- failed: SolidQueue::FailedExecution.count,
9
- blocked: SolidQueue::BlockedExecution.count,
10
- queues: SolidQueue::Job.select(:queue_name).distinct.count,
11
- processes: SolidQueue::Process.count,
12
- recurring: SolidQueue::RecurringTask.count
13
- }
14
-
15
- now = Time.current
16
- finished_times = SolidQueue::Job.where(finished_at: 24.hours.ago..now).pluck(:finished_at)
17
- @throughput = {
18
- completed_1h: finished_times.count { |t| t >= 1.hour.ago },
19
- completed_24h: finished_times.size
20
- }
21
- @sparkline = 12.times.map do |i|
22
- from = (12 - i).hours.ago
23
- to = i == 11 ? now : (11 - i).hours.ago
24
- finished_times.count { |t| t >= from && t < to }
25
- end
26
- end
27
-
28
- def retry_all_failed
29
- jobs = SolidQueue::FailedExecution.includes(:job).map(&:job)
30
- SolidQueue::FailedExecution.retry_all(jobs)
31
- redirect_to root_path, notice: "#{jobs.size} failed #{"job".pluralize(jobs.size)} queued for retry."
32
- rescue => e
33
- redirect_to root_path, alert: "Could not retry failed jobs: #{e.message}"
34
- end
35
-
36
- def discard_all_blocked
37
- jobs = SolidQueue::BlockedExecution.includes(:job).map(&:job)
38
- SolidQueue::BlockedExecution.discard_all_from_jobs(jobs)
39
- redirect_to root_path, notice: "#{jobs.size} blocked #{"job".pluralize(jobs.size)} discarded."
40
- rescue => e
41
- redirect_to root_path, alert: "Could not discard blocked jobs: #{e.message}"
4
+ @stats = DashboardStats.new
42
5
  end
43
6
  end
44
7
  end
@@ -2,7 +2,7 @@ module SolidQueueWeb
2
2
  module Queues
3
3
  class JobsController < ApplicationController
4
4
  before_action :set_queue
5
- before_action :set_status, only: [:destroy, :discard_all]
5
+ before_action :set_status, only: [:destroy]
6
6
 
7
7
  def index
8
8
  @status = params[:status].presence_in(Job::STATUSES) || "ready"
@@ -15,29 +15,25 @@ module SolidQueueWeb
15
15
 
16
16
  def destroy
17
17
  model = execution_model_for!(@status)
18
- @execution = model.find(params[:id])
19
- @execution.discard
20
- @remaining_count = filtered_scope(model).count
21
- respond_to do |format|
22
- format.turbo_stream
23
- format.html { redirect_to queue_jobs_path(queue_name: @queue, status: @status), notice: "Job discarded." }
18
+ if params[:id]
19
+ @execution = model.find(params[:id])
20
+ @execution.discard
21
+ @remaining_count = filtered_scope(model).count
22
+ respond_to do |format|
23
+ format.turbo_stream
24
+ format.html { redirect_to queue_jobs_path(queue_name: @queue, status: @status), notice: "Job discarded." }
25
+ end
26
+ else
27
+ jobs = filtered_scope(model).map(&:job)
28
+ model.discard_all_from_jobs(jobs)
29
+ redirect_to queue_jobs_path(queue_name: @queue, status: @status),
30
+ notice: "#{jobs.size} #{"job".pluralize(jobs.size)} discarded."
24
31
  end
25
32
  rescue ArgumentError => e
26
33
  redirect_to queue_jobs_path(queue_name: @queue, status: @status), alert: e.message
27
34
  rescue => e
28
- redirect_to queue_jobs_path(queue_name: @queue, status: @status), alert: "Could not discard job: #{e.message}"
29
- end
30
-
31
- def discard_all
32
- model = execution_model_for!(@status)
33
- jobs = filtered_scope(model).map(&:job)
34
- model.discard_all_from_jobs(jobs)
35
35
  redirect_to queue_jobs_path(queue_name: @queue, status: @status),
36
- notice: "#{jobs.size} #{"job".pluralize(jobs.size)} discarded."
37
- rescue ArgumentError => e
38
- redirect_to queue_jobs_path(queue_name: @queue, status: @status), alert: e.message
39
- rescue => e
40
- redirect_to queue_jobs_path(queue_name: @queue, status: @status), alert: "Could not discard jobs: #{e.message}"
36
+ alert: "Could not discard #{params[:id] ? "job" : "jobs"}: #{e.message}"
41
37
  end
42
38
 
43
39
  private
@@ -0,0 +1,21 @@
1
+ module SolidQueueWeb
2
+ module Queues
3
+ class PausesController < ApplicationController
4
+ def create
5
+ queue = SolidQueue::Queue.find_by_name(params[:queue_name])
6
+ queue.pause
7
+ redirect_to queues_path, notice: "Queue \"#{queue.name}\" paused."
8
+ rescue => e
9
+ redirect_to queues_path, alert: "Could not pause queue: #{e.message}"
10
+ end
11
+
12
+ def destroy
13
+ queue = SolidQueue::Queue.find_by_name(params[:queue_name])
14
+ queue.resume
15
+ redirect_to queues_path, notice: "Queue \"#{queue.name}\" resumed."
16
+ rescue => e
17
+ redirect_to queues_path, alert: "Could not resume queue: #{e.message}"
18
+ end
19
+ end
20
+ end
21
+ end
@@ -2,37 +2,11 @@ module SolidQueueWeb
2
2
  class QueuesController < ApplicationController
3
3
  def index
4
4
  @queues = SolidQueue::Queue.all.sort_by(&:name)
5
-
6
- now = Time.current
7
- @completed_24h = SolidQueue::Job
8
- .where(finished_at: 24.hours.ago..now)
9
- .group(:queue_name)
10
- .count
11
- @failed_24h = SolidQueue::FailedExecution
12
- .joins(:job)
13
- .where(created_at: 24.hours.ago..now)
14
- .group("solid_queue_jobs.queue_name")
15
- .count
16
- @oldest_ready = SolidQueue::ReadyExecution
17
- .joins(:job)
18
- .group("solid_queue_jobs.queue_name")
19
- .minimum("solid_queue_jobs.created_at")
20
- end
21
-
22
- def pause
23
- queue = SolidQueue::Queue.find_by_name(params[:name])
24
- queue.pause
25
- redirect_to queues_path, notice: "Queue \"#{queue.name}\" paused."
26
- rescue => e
27
- redirect_to queues_path, alert: "Could not pause queue: #{e.message}"
28
- end
29
-
30
- def resume
31
- queue = SolidQueue::Queue.find_by_name(params[:name])
32
- queue.resume
33
- redirect_to queues_path, notice: "Queue \"#{queue.name}\" resumed."
34
- rescue => e
35
- redirect_to queues_path, alert: "Could not resume queue: #{e.message}"
5
+ stats = QueueStats.new(@queues)
6
+ @completed_24h = stats.completed_24h
7
+ @failed_24h = stats.failed_24h
8
+ @oldest_ready = stats.oldest_ready
9
+ @failure_sparklines = stats.failure_sparklines
36
10
  end
37
11
  end
38
12
  end
@@ -0,0 +1,47 @@
1
+ module SolidQueueWeb
2
+ class DashboardStats
3
+ attr_reader :counts, :throughput, :sparkline, :depth_sparkline, :slow_jobs_count
4
+
5
+ def initialize
6
+ @now = Time.current
7
+ compute
8
+ end
9
+
10
+ private
11
+
12
+ def compute
13
+ @counts = {
14
+ ready: SolidQueue::ReadyExecution.count,
15
+ scheduled: SolidQueue::ScheduledExecution.count,
16
+ claimed: SolidQueue::ClaimedExecution.count,
17
+ failed: SolidQueue::FailedExecution.count,
18
+ blocked: SolidQueue::BlockedExecution.count,
19
+ queues: SolidQueue::Job.select(:queue_name).distinct.count,
20
+ processes: SolidQueue::Process.count,
21
+ recurring: SolidQueue::RecurringTask.count
22
+ }
23
+
24
+ finished_times = SolidQueue::Job.where(finished_at: 24.hours.ago..@now).pluck(:finished_at)
25
+ @throughput = {
26
+ completed_1h: finished_times.count { |t| t >= 1.hour.ago },
27
+ completed_24h: finished_times.size
28
+ }
29
+ @sparkline = 12.times.map do |i|
30
+ from = (12 - i).hours.ago
31
+ to = i == 11 ? @now : (11 - i).hours.ago
32
+ finished_times.count { |t| t >= from && t < to }
33
+ end
34
+
35
+ threshold = SolidQueueWeb.slow_job_threshold
36
+ @slow_jobs_count = threshold ? SolidQueue::ClaimedExecution.where("created_at <= ?", threshold.ago).count : 0
37
+
38
+ job_timestamps = SolidQueue::Job
39
+ .where("created_at >= ? OR finished_at IS NULL", 72.hours.ago)
40
+ .pluck(:created_at, :finished_at)
41
+ @depth_sparkline = 12.times.map do |i|
42
+ t = i == 11 ? @now : (12 - i).hours.ago
43
+ job_timestamps.count { |created, finished| created <= t && (finished.nil? || finished > t) }
44
+ end
45
+ end
46
+ end
47
+ end
@@ -0,0 +1,52 @@
1
+ module SolidQueueWeb
2
+ class QueueStats
3
+ attr_reader :completed_24h, :failed_24h, :oldest_ready, :failure_sparklines
4
+
5
+ def initialize(queues)
6
+ @queues = queues
7
+ @now = Time.current
8
+ compute
9
+ end
10
+
11
+ private
12
+
13
+ def compute
14
+ @completed_24h = SolidQueue::Job
15
+ .where(finished_at: 24.hours.ago..@now)
16
+ .group(:queue_name)
17
+ .count
18
+
19
+ @failed_24h = SolidQueue::FailedExecution
20
+ .joins(:job)
21
+ .where(created_at: 24.hours.ago..@now)
22
+ .group("solid_queue_jobs.queue_name")
23
+ .count
24
+
25
+ @oldest_ready = SolidQueue::ReadyExecution
26
+ .joins(:job)
27
+ .group("solid_queue_jobs.queue_name")
28
+ .minimum("solid_queue_jobs.created_at")
29
+
30
+ failed_raw = SolidQueue::FailedExecution
31
+ .joins(:job)
32
+ .where(created_at: 12.hours.ago..@now)
33
+ .pluck("solid_queue_jobs.queue_name", "solid_queue_failed_executions.created_at")
34
+ done_raw = SolidQueue::Job
35
+ .where(finished_at: 12.hours.ago..@now)
36
+ .pluck(:queue_name, :finished_at)
37
+
38
+ @failure_sparklines = @queues.each_with_object({}) do |queue, h|
39
+ failed_times = failed_raw.filter_map { |q, t| t if q == queue.name }
40
+ done_times = done_raw.filter_map { |q, t| t if q == queue.name }
41
+ h[queue.name] = 12.times.map do |i|
42
+ from = (12 - i).hours.ago
43
+ to = i == 11 ? @now : (11 - i).hours.ago
44
+ f = failed_times.count { |t| t >= from && t < to }
45
+ d = done_times.count { |t| t >= from && t < to }
46
+ total = f + d
47
+ total > 0 ? (f.to_f / total * 100).round : nil
48
+ end
49
+ end
50
+ end
51
+ end
52
+ end
@@ -3,61 +3,61 @@
3
3
 
4
4
  <div class="sqd-stats">
5
5
  <%= link_to jobs_path(status: "ready"), class: "sqd-stat sqd-stat--ready sqd-stat--link" do %>
6
- <div class="sqd-stat__value"><%= @stats[:ready] %></div>
6
+ <div class="sqd-stat__value"><%= @stats.counts[:ready] %></div>
7
7
  <div class="sqd-stat__label">Ready</div>
8
8
  <% end %>
9
9
  <%= link_to jobs_path(status: "scheduled"), class: "sqd-stat sqd-stat--scheduled sqd-stat--link" do %>
10
- <div class="sqd-stat__value"><%= @stats[:scheduled] %></div>
10
+ <div class="sqd-stat__value"><%= @stats.counts[:scheduled] %></div>
11
11
  <div class="sqd-stat__label">Scheduled</div>
12
12
  <% end %>
13
13
  <%= link_to jobs_path(status: "claimed"), class: "sqd-stat sqd-stat--claimed sqd-stat--link" do %>
14
- <div class="sqd-stat__value"><%= @stats[:claimed] %></div>
14
+ <div class="sqd-stat__value"><%= @stats.counts[:claimed] %></div>
15
15
  <div class="sqd-stat__label">Running</div>
16
16
  <% end %>
17
17
  <%= link_to jobs_path(status: "blocked"), class: "sqd-stat sqd-stat--blocked sqd-stat--link" do %>
18
- <div class="sqd-stat__value"><%= @stats[:blocked] %></div>
18
+ <div class="sqd-stat__value"><%= @stats.counts[:blocked] %></div>
19
19
  <div class="sqd-stat__label">Blocked</div>
20
20
  <% end %>
21
21
  <%= link_to failed_jobs_path, class: "sqd-stat sqd-stat--failed sqd-stat--link" do %>
22
- <div class="sqd-stat__value"><%= @stats[:failed] %></div>
22
+ <div class="sqd-stat__value"><%= @stats.counts[:failed] %></div>
23
23
  <div class="sqd-stat__label">Failed</div>
24
24
  <% end %>
25
25
  <%= link_to queues_path, class: "sqd-stat sqd-stat--queues sqd-stat--link" do %>
26
- <div class="sqd-stat__value"><%= @stats[:queues] %></div>
26
+ <div class="sqd-stat__value"><%= @stats.counts[:queues] %></div>
27
27
  <div class="sqd-stat__label">Queues</div>
28
28
  <% end %>
29
29
  <%= link_to recurring_tasks_path, class: "sqd-stat sqd-stat--recurring sqd-stat--link" do %>
30
- <div class="sqd-stat__value"><%= @stats[:recurring] %></div>
30
+ <div class="sqd-stat__value"><%= @stats.counts[:recurring] %></div>
31
31
  <div class="sqd-stat__label">Recurring</div>
32
32
  <% end %>
33
33
  <%= link_to processes_path, class: "sqd-stat sqd-stat--processes sqd-stat--link" do %>
34
- <div class="sqd-stat__value"><%= @stats[:processes] %></div>
34
+ <div class="sqd-stat__value"><%= @stats.counts[:processes] %></div>
35
35
  <div class="sqd-stat__label">Processes</div>
36
36
  <% end %>
37
37
  <%= link_to history_path(period: "1h"), class: "sqd-stat sqd-stat--done sqd-stat--link" do %>
38
- <div class="sqd-stat__value"><%= @throughput[:completed_1h] %></div>
38
+ <div class="sqd-stat__value"><%= @stats.throughput[:completed_1h] %></div>
39
39
  <div class="sqd-stat__label">Done (1h)</div>
40
40
  <% end %>
41
41
  <%= link_to history_path(period: "24h"), class: "sqd-stat sqd-stat--done sqd-stat--link" do %>
42
- <div class="sqd-stat__value"><%= @throughput[:completed_24h] %></div>
42
+ <div class="sqd-stat__value"><%= @stats.throughput[:completed_24h] %></div>
43
43
  <div class="sqd-stat__label">Done (24h)</div>
44
44
  <% end %>
45
45
  </div>
46
46
 
47
- <% max_val = [@sparkline.max, 1].max %>
47
+ <% max_val = [@stats.sparkline.max, 1].max %>
48
48
  <div class="sqd-card" style="margin-bottom: 1rem;">
49
49
  <div class="sqd-card__header">
50
50
  <span class="sqd-card__title">Throughput &mdash; Last 12 Hours</span>
51
51
  <div class="sqd-throughput__summary">
52
- <span>1h: <strong><%= @throughput[:completed_1h] %></strong></span>
53
- <span>24h: <strong><%= @throughput[:completed_24h] %></strong></span>
52
+ <span>1h: <strong><%= @stats.throughput[:completed_1h] %></strong></span>
53
+ <span>24h: <strong><%= @stats.throughput[:completed_24h] %></strong></span>
54
54
  </div>
55
55
  </div>
56
- <% if @throughput[:completed_24h] == 0 %>
56
+ <% if @stats.throughput[:completed_24h] == 0 %>
57
57
  <div class="sqd-sparkline__empty">No completed jobs in the last 24 hours</div>
58
58
  <% else %>
59
59
  <div class="sqd-sparkline" aria-label="Jobs completed per hour over the last 12 hours">
60
- <% @sparkline.each_with_index do |count, i| %>
60
+ <% @stats.sparkline.each_with_index do |count, i| %>
61
61
  <% pct = (count.to_f / max_val * 100).round %>
62
62
  <% hour_start = (12 - i).hours.ago %>
63
63
  <% show_tick = [0, 3, 6, 9, 11].include?(i) %>
@@ -74,6 +74,36 @@
74
74
  <% end %>
75
75
  </div>
76
76
 
77
+ <% current_depth = @stats.counts[:ready] + @stats.counts[:scheduled] + @stats.counts[:claimed] + @stats.counts[:blocked] + @stats.counts[:failed] %>
78
+ <% max_depth = [@stats.depth_sparkline.max, 1].max %>
79
+ <div class="sqd-card" style="margin-bottom: 1rem;">
80
+ <div class="sqd-card__header">
81
+ <span class="sqd-card__title">Queue Depth &mdash; Last 12 Hours</span>
82
+ <div class="sqd-throughput__summary">
83
+ <span>Now: <strong><%= current_depth %></strong></span>
84
+ </div>
85
+ </div>
86
+ <% if @stats.depth_sparkline.all?(&:zero?) %>
87
+ <div class="sqd-sparkline__empty">No active jobs in the last 12 hours</div>
88
+ <% else %>
89
+ <div class="sqd-sparkline" aria-label="Queue depth over the last 12 hours">
90
+ <% @stats.depth_sparkline.each_with_index do |depth, i| %>
91
+ <% pct = (depth.to_f / max_depth * 100).round %>
92
+ <% t = i == 11 ? Time.current : (12 - i).hours.ago %>
93
+ <% show_tick = [0, 3, 6, 9, 11].include?(i) %>
94
+ <div class="sqd-sparkline__col">
95
+ <div class="sqd-sparkline__bar-wrap">
96
+ <div class="sqd-sparkline__bar sqd-sparkline__bar--depth"
97
+ style="height: <%= [pct, 3].max %>%"
98
+ title="<%= i == 11 ? "now" : t.strftime("%-I%p").downcase %>: <%= depth %> <%= "job".pluralize(depth) %> in queue"></div>
99
+ </div>
100
+ <div class="sqd-sparkline__tick"><%= show_tick ? (i == 11 ? "now" : t.strftime("%-I%p").downcase) : "" %></div>
101
+ </div>
102
+ <% end %>
103
+ </div>
104
+ <% end %>
105
+ </div>
106
+
77
107
  <div style="display:grid; grid-template-columns: repeat(auto-fit, minmax(240px, 1fr)); gap: 1rem;">
78
108
  <div class="sqd-card">
79
109
  <div class="sqd-card__header">
@@ -88,37 +118,51 @@
88
118
  </div>
89
119
  </div>
90
120
 
91
- <% if @stats[:failed] > 0 %>
121
+ <% if @stats.counts[:failed] > 0 %>
92
122
  <div class="sqd-card">
93
123
  <div class="sqd-card__header">
94
124
  <span class="sqd-card__title">Failed Jobs</span>
95
125
  </div>
96
126
  <div style="padding: 1rem; display: flex; flex-direction: column; gap: 0.5rem;">
97
127
  <p style="color: var(--danger); font-size: 13px;">
98
- <%= pluralize(@stats[:failed], "failed job") %> need attention.
128
+ <%= pluralize(@stats.counts[:failed], "failed job") %> need attention.
99
129
  </p>
100
- <%= button_to "Retry All Failed", retry_all_failed_path,
130
+ <%= button_to "Retry All Failed", retry_all_failed_jobs_path,
101
131
  method: :post,
102
132
  class: "sqd-btn sqd-btn--primary",
103
- data: { confirm: "Retry all #{@stats[:failed]} failed #{"job".pluralize(@stats[:failed])}?" } %>
133
+ data: { confirm: "Retry all #{@stats.counts[:failed]} failed #{"job".pluralize(@stats.counts[:failed])}?" } %>
104
134
  <%= link_to "Review →", failed_jobs_path, class: "sqd-btn sqd-btn--muted" %>
105
135
  </div>
106
136
  </div>
107
137
  <% end %>
108
138
 
109
- <% if @stats[:blocked] > 0 %>
139
+ <% if SolidQueueWeb.slow_job_threshold && @stats.slow_jobs_count > 0 %>
140
+ <div class="sqd-card">
141
+ <div class="sqd-card__header">
142
+ <span class="sqd-card__title">Slow Jobs</span>
143
+ </div>
144
+ <div style="padding: 1rem; display: flex; flex-direction: column; gap: 0.5rem;">
145
+ <p style="color: var(--warning); font-size: 13px;">
146
+ <%= pluralize(@stats.slow_jobs_count, "job") %> running longer than <%= distance_of_time_in_words(SolidQueueWeb.slow_job_threshold) %>.
147
+ </p>
148
+ <%= link_to "Review →", jobs_path(status: "claimed"), class: "sqd-btn sqd-btn--muted" %>
149
+ </div>
150
+ </div>
151
+ <% end %>
152
+
153
+ <% if @stats.counts[:blocked] > 0 %>
110
154
  <div class="sqd-card">
111
155
  <div class="sqd-card__header">
112
156
  <span class="sqd-card__title">Blocked Jobs</span>
113
157
  </div>
114
158
  <div style="padding: 1rem; display: flex; flex-direction: column; gap: 0.5rem;">
115
159
  <p style="color: var(--warning); font-size: 13px;">
116
- <%= pluralize(@stats[:blocked], "blocked job") %>.
160
+ <%= pluralize(@stats.counts[:blocked], "blocked job") %>.
117
161
  </p>
118
- <%= button_to "Discard All Blocked", discard_all_blocked_path,
119
- method: :post,
162
+ <%= button_to "Discard All Blocked", blocked_jobs_path,
163
+ method: :delete,
120
164
  class: "sqd-btn sqd-btn--danger",
121
- data: { confirm: "Discard all #{@stats[:blocked]} blocked #{"job".pluralize(@stats[:blocked])}? This cannot be undone." } %>
165
+ data: { confirm: "Discard all #{@stats.counts[:blocked]} blocked #{"job".pluralize(@stats.counts[:blocked])}? This cannot be undone." } %>
122
166
  <%= link_to "Review →", jobs_path(status: "blocked"), class: "sqd-btn sqd-btn--muted" %>
123
167
  </div>
124
168
  </div>
@@ -92,7 +92,7 @@
92
92
  data-action="change->selection#toggle"
93
93
  aria-label="Select job <%= job.class_name %>">
94
94
  </td>
95
- <td><%= link_to job.class_name, job_path(job) %></td>
95
+ <td><%= link_to job.class_name, job_path(job), class: "sqd-table-link" %></td>
96
96
  <td>
97
97
  <%= link_to job.queue_name, failed_jobs_path(queue: job.queue_name, q: @search, period: @period),
98
98
  class: "sqd-mono", style: "color: inherit;" %>
@@ -49,7 +49,7 @@
49
49
  <tbody>
50
50
  <% @jobs.each do |job| %>
51
51
  <tr>
52
- <td><%= link_to job.class_name, job_path(job) %></td>
52
+ <td><%= link_to job.class_name, job_path(job), class: "sqd-table-link" %></td>
53
53
  <td>
54
54
  <%= link_to job.queue_name, history_path(queue: job.queue_name, q: @search, period: @period),
55
55
  class: "sqd-mono", style: "color: inherit;" %>
@@ -87,7 +87,7 @@
87
87
  </td>
88
88
  <td>
89
89
  <span class="sqd-badge sqd-badge--<%= @status %>"><%= @status %></span>
90
- <%= link_to job.class_name, job_path(job), style: "margin-left: 0.5rem;", data: { turbo_frame: "_top" } %>
90
+ <%= link_to job.class_name, job_path(job), class: "sqd-table-link", style: "margin-left: 0.5rem;", data: { turbo_frame: "_top" } %>
91
91
  </td>
92
92
  <td>
93
93
  <%= link_to job.queue_name, queue_jobs_path(queue_name: job.queue_name, status: @status),
@@ -116,6 +116,7 @@
116
116
  <% if @jobs.empty? %>
117
117
  <div class="sqd-empty">No <%= @status %> jobs.</div>
118
118
  <% else %>
119
+ <% slow_threshold = @status == "claimed" ? SolidQueueWeb.slow_job_threshold : nil %>
119
120
  <table>
120
121
  <thead>
121
122
  <tr>
@@ -124,15 +125,22 @@
124
125
  <th scope="col">Priority</th>
125
126
  <th scope="col">Scheduled At</th>
126
127
  <th scope="col">Enqueued At</th>
128
+ <% if @status == "claimed" %>
129
+ <th scope="col">Running For</th>
130
+ <% end %>
127
131
  </tr>
128
132
  </thead>
129
133
  <tbody>
130
134
  <% @jobs.each do |execution| %>
131
135
  <% job = execution.job %>
132
- <tr id="execution_<%= execution.id %>">
136
+ <% slow = slow_threshold && execution.created_at <= slow_threshold.ago %>
137
+ <tr id="execution_<%= execution.id %>"<%= slow ? ' class="sqd-row--slow"'.html_safe : "" %>>
133
138
  <td>
134
139
  <span class="sqd-badge sqd-badge--<%= @status %>"><%= @status %></span>
135
- <%= link_to job.class_name, job_path(job), style: "margin-left: 0.5rem;", data: { turbo_frame: "_top" } %>
140
+ <% if slow %>
141
+ <span class="sqd-badge sqd-badge--slow">slow</span>
142
+ <% end %>
143
+ <%= link_to job.class_name, job_path(job), class: "sqd-table-link", style: "margin-left: 0.5rem;", data: { turbo_frame: "_top" } %>
136
144
  </td>
137
145
  <td>
138
146
  <%= link_to job.queue_name, queue_jobs_path(queue_name: job.queue_name, status: @status),
@@ -143,6 +151,11 @@
143
151
  <%= job.scheduled_at ? job.scheduled_at.strftime("%Y-%m-%d %H:%M:%S") : "—" %>
144
152
  </td>
145
153
  <td class="sqd-mono"><%= job.created_at.strftime("%Y-%m-%d %H:%M:%S") %></td>
154
+ <% if @status == "claimed" %>
155
+ <td class="sqd-mono<%= slow ? " sqd-slow-duration" : "" %>">
156
+ <%= time_ago_in_words(execution.created_at) %>
157
+ </td>
158
+ <% end %>
146
159
  </tr>
147
160
  <% end %>
148
161
  </tbody>
@@ -12,6 +12,7 @@
12
12
  <th scope="col">Latency</th>
13
13
  <th scope="col">Done (24h)</th>
14
14
  <th scope="col">Failed (24h)</th>
15
+ <th scope="col">Failure Rate (12h)</th>
15
16
  <th scope="col">Status</th>
16
17
  <th scope="col"><span class="sqd-sr-only">Actions</span></th>
17
18
  </tr>
@@ -34,6 +35,22 @@
34
35
  </td>
35
36
  <td style="color: var(--success);"><%= @completed_24h[queue.name] || 0 %></td>
36
37
  <td style="color: <%= (@failed_24h[queue.name] || 0) > 0 ? "var(--danger)" : "inherit" %>;"><%= @failed_24h[queue.name] || 0 %></td>
38
+ <td>
39
+ <% sparkline = @failure_sparklines[queue.name] %>
40
+ <% if sparkline.any? %>
41
+ <div class="sqd-mini-sparkline" aria-label="Failure rate last 12 hours for <%= queue.name %>">
42
+ <% sparkline.each_with_index do |rate, i| %>
43
+ <% pct = rate || 0 %>
44
+ <% hour_label = (12 - i).hours.ago.strftime("%-I%p").downcase %>
45
+ <div class="sqd-mini-sparkline__bar sqd-mini-sparkline__bar--<%= rate ? "data" : "empty" %>"
46
+ style="height: <%= [pct, 2].max %>%"
47
+ title="<%= hour_label %>: <%= rate ? "#{rate}% failure rate" : "no data" %>"></div>
48
+ <% end %>
49
+ </div>
50
+ <% else %>
51
+ <span style="color: var(--muted)">—</span>
52
+ <% end %>
53
+ </td>
37
54
  <td>
38
55
  <% if queue.paused? %>
39
56
  <span class="sqd-badge sqd-badge--paused">Paused</span>
@@ -43,10 +60,10 @@
43
60
  </td>
44
61
  <td class="sqd-row-actions">
45
62
  <% if queue.paused? %>
46
- <%= button_to "Resume", resume_queue_path(queue.name), method: :post,
63
+ <%= button_to "Resume", queue_pause_path(queue.name), method: :delete,
47
64
  class: "sqd-btn sqd-btn--primary sqd-btn--sm" %>
48
65
  <% else %>
49
- <%= button_to "Pause", pause_queue_path(queue.name), method: :post,
66
+ <%= button_to "Pause", queue_pause_path(queue.name), method: :post,
50
67
  class: "sqd-btn sqd-btn--muted sqd-btn--sm",
51
68
  data: { confirm: "Pause queue \"#{queue.name}\"?" } %>
52
69
  <% end %>
@@ -60,7 +60,7 @@
60
60
  <tr id="execution_<%= execution.id %>">
61
61
  <td>
62
62
  <span class="sqd-badge sqd-badge--<%= @status %>"><%= @status %></span>
63
- <%= link_to job.class_name, job_path(job), style: "margin-left: 0.5rem;", data: { turbo_frame: "_top" } %>
63
+ <%= link_to job.class_name, job_path(job), class: "sqd-table-link", style: "margin-left: 0.5rem;", data: { turbo_frame: "_top" } %>
64
64
  </td>
65
65
  <td><%= job.priority %></td>
66
66
  <td class="sqd-mono">
@@ -50,7 +50,7 @@
50
50
  <% data[:executions].each do |execution| %>
51
51
  <% job = execution.job %>
52
52
  <tr>
53
- <td><%= link_to job.class_name, job_path(job) %></td>
53
+ <td><%= link_to job.class_name, job_path(job), class: "sqd-table-link" %></td>
54
54
  <td class="sqd-mono"><%= job.queue_name %></td>
55
55
  <td class="sqd-mono"><%= job.created_at.strftime("%Y-%m-%d %H:%M:%S") %></td>
56
56
  </tr>
data/config/routes.rb CHANGED
@@ -1,7 +1,6 @@
1
1
  SolidQueueWeb::Engine.routes.draw do
2
2
  root to: "dashboard#index"
3
- post "retry_all_failed", to: "dashboard#retry_all_failed", as: :retry_all_failed
4
- post "discard_all_blocked", to: "dashboard#discard_all_blocked", as: :discard_all_blocked
3
+ resource :blocked_jobs, only: [:destroy]
5
4
 
6
5
  get "search", to: "search#index", as: :search
7
6
  get "history", to: "history#index", as: :history
@@ -9,13 +8,10 @@ SolidQueueWeb::Engine.routes.draw do
9
8
  resources :recurring_tasks, only: [:index]
10
9
  resources :processes, only: [:index]
11
10
  resources :queues, only: [:index], param: :name do
12
- member do
13
- post :pause
14
- post :resume
15
- end
11
+ resource :pause, only: [:create, :destroy], controller: "queues/pauses"
16
12
  resources :jobs, path: "list", only: [:index, :destroy], controller: "queues/jobs" do
17
13
  collection do
18
- post :discard_all
14
+ post :discard_all, action: :destroy
19
15
  end
20
16
  end
21
17
  end
@@ -1,3 +1,3 @@
1
1
  module SolidQueueWeb
2
- VERSION = "0.8.0"
2
+ VERSION = "0.9.0"
3
3
  end
@@ -4,7 +4,8 @@ require "solid_queue_web/engine"
4
4
 
5
5
  module SolidQueueWeb
6
6
  class << self
7
- attr_writer :page_size, :dashboard_refresh_interval, :default_refresh_interval, :search_results_limit
7
+ attr_writer :page_size, :dashboard_refresh_interval, :default_refresh_interval, :search_results_limit,
8
+ :slow_job_threshold
8
9
 
9
10
  def page_size
10
11
  @page_size || 25
@@ -22,6 +23,10 @@ module SolidQueueWeb
22
23
  @search_results_limit || 25
23
24
  end
24
25
 
26
+ def slow_job_threshold
27
+ @slow_job_threshold
28
+ end
29
+
25
30
  def configure
26
31
  yield self
27
32
  end
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: solid_queue_web
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.8.0
4
+ version: 0.9.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Chuck Smith
@@ -119,6 +119,7 @@ files:
119
119
  - app/assets/stylesheets/solid_queue_web/_12_dark_mode.css
120
120
  - app/assets/stylesheets/solid_queue_web/application.css
121
121
  - app/controllers/solid_queue_web/application_controller.rb
122
+ - app/controllers/solid_queue_web/blocked_jobs_controller.rb
122
123
  - app/controllers/solid_queue_web/dashboard_controller.rb
123
124
  - app/controllers/solid_queue_web/failed_jobs/selections_controller.rb
124
125
  - app/controllers/solid_queue_web/failed_jobs_controller.rb
@@ -127,6 +128,7 @@ files:
127
128
  - app/controllers/solid_queue_web/jobs_controller.rb
128
129
  - app/controllers/solid_queue_web/processes_controller.rb
129
130
  - app/controllers/solid_queue_web/queues/jobs_controller.rb
131
+ - app/controllers/solid_queue_web/queues/pauses_controller.rb
130
132
  - app/controllers/solid_queue_web/queues_controller.rb
131
133
  - app/controllers/solid_queue_web/recurring_tasks_controller.rb
132
134
  - app/controllers/solid_queue_web/retry_failed_jobs_controller.rb
@@ -140,6 +142,8 @@ files:
140
142
  - app/jobs/solid_queue_web/application_job.rb
141
143
  - app/models/solid_queue_web/application_record.rb
142
144
  - app/models/solid_queue_web/job.rb
145
+ - app/services/solid_queue_web/dashboard_stats.rb
146
+ - app/services/solid_queue_web/queue_stats.rb
143
147
  - app/views/layouts/solid_queue_web/application.html.erb
144
148
  - app/views/solid_queue_web/dashboard/index.html.erb
145
149
  - app/views/solid_queue_web/failed_jobs/index.html.erb