solid_queue_web 0.8.0 → 1.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (40) hide show
  1. checksums.yaml +4 -4
  2. data/README.md +106 -17
  3. data/Rakefile +3 -1
  4. data/app/assets/stylesheets/solid_queue_web/_04_table.css +8 -1
  5. data/app/assets/stylesheets/solid_queue_web/_05_badges.css +1 -0
  6. data/app/assets/stylesheets/solid_queue_web/_07_forms.css +17 -0
  7. data/app/assets/stylesheets/solid_queue_web/_11_throughput.css +30 -1
  8. data/app/controllers/solid_queue_web/application_controller.rb +19 -1
  9. data/app/controllers/solid_queue_web/blocked_jobs_controller.rb +11 -0
  10. data/app/controllers/solid_queue_web/dashboard_controller.rb +2 -38
  11. data/app/controllers/solid_queue_web/jobs_controller.rb +16 -27
  12. data/app/controllers/solid_queue_web/metrics_controller.rb +7 -0
  13. data/app/controllers/solid_queue_web/performance_controller.rb +12 -0
  14. data/app/controllers/solid_queue_web/queues/jobs_controller.rb +15 -19
  15. data/app/controllers/solid_queue_web/queues/pauses_controller.rb +21 -0
  16. data/app/controllers/solid_queue_web/queues_controller.rb +5 -31
  17. data/app/controllers/solid_queue_web/recurring_tasks/runs_controller.rb +18 -0
  18. data/app/controllers/solid_queue_web/retry_failed_jobs_controller.rb +23 -2
  19. data/app/controllers/solid_queue_web/scheduled_jobs_controller.rb +54 -0
  20. data/app/models/solid_queue_web/job.rb +17 -1
  21. data/app/services/solid_queue_web/alert_webhook.rb +58 -0
  22. data/app/services/solid_queue_web/dashboard_stats.rb +47 -0
  23. data/app/services/solid_queue_web/job_performance_stats.rb +38 -0
  24. data/app/services/solid_queue_web/metrics_payload.rb +66 -0
  25. data/app/services/solid_queue_web/queue_stats.rb +52 -0
  26. data/app/views/layouts/solid_queue_web/application.html.erb +1 -0
  27. data/app/views/solid_queue_web/dashboard/index.html.erb +68 -24
  28. data/app/views/solid_queue_web/failed_jobs/index.html.erb +11 -1
  29. data/app/views/solid_queue_web/history/index.html.erb +1 -1
  30. data/app/views/solid_queue_web/jobs/index.html.erb +57 -15
  31. data/app/views/solid_queue_web/performance/index.html.erb +50 -0
  32. data/app/views/solid_queue_web/queues/index.html.erb +19 -2
  33. data/app/views/solid_queue_web/queues/jobs/index.html.erb +1 -1
  34. data/app/views/solid_queue_web/recurring_tasks/index.html.erb +7 -0
  35. data/app/views/solid_queue_web/scheduled_jobs/update.turbo_stream.erb +9 -0
  36. data/app/views/solid_queue_web/search/index.html.erb +1 -1
  37. data/config/routes.rb +16 -10
  38. data/lib/solid_queue_web/version.rb +1 -1
  39. data/lib/solid_queue_web.rb +23 -1
  40. metadata +14 -1
@@ -0,0 +1,54 @@
1
+ module SolidQueueWeb
2
+ class ScheduledJobsController < ApplicationController
3
+ def create
4
+ @period = params[:period].presence_in(PERIOD_DURATIONS.keys)
5
+ job_ids = scheduled_scope.pluck("solid_queue_jobs.id")
6
+
7
+ SolidQueue::ScheduledExecution.where(job_id: job_ids).update_all(scheduled_at: 1.second.ago)
8
+ SolidQueue::Job.where(id: job_ids).update_all(scheduled_at: 1.second.ago)
9
+
10
+ redirect_to jobs_path(status: "scheduled", period: @period),
11
+ notice: "#{job_ids.size} #{"job".pluralize(job_ids.size)} scheduled to run immediately."
12
+ rescue => e
13
+ redirect_to jobs_path(status: "scheduled", period: @period),
14
+ alert: "Could not run jobs: #{e.message}"
15
+ end
16
+
17
+ def update
18
+ @execution = SolidQueue::ScheduledExecution.find(params[:id])
19
+ @period = params[:period].presence_in(PERIOD_DURATIONS.keys)
20
+ @run_now = params[:offset] == "now"
21
+ new_time = resolve_new_time(@execution, params[:offset])
22
+
23
+ @execution.update!(scheduled_at: new_time)
24
+ @execution.job.update!(scheduled_at: new_time)
25
+
26
+ respond_to do |format|
27
+ format.turbo_stream
28
+ format.html do
29
+ notice = @run_now ? "Job scheduled to run immediately." : "Job rescheduled by +#{params[:offset]}."
30
+ redirect_to jobs_path(status: "scheduled", period: @period), notice: notice
31
+ end
32
+ end
33
+ rescue ArgumentError => e
34
+ redirect_to jobs_path(status: "scheduled"), alert: e.message
35
+ rescue => e
36
+ redirect_to jobs_path(status: "scheduled"), alert: "Could not reschedule job: #{e.message}"
37
+ end
38
+
39
+ private
40
+
41
+ def scheduled_scope
42
+ scope = SolidQueue::ScheduledExecution.joins(:job)
43
+ scope = scope.where("solid_queue_jobs.created_at >= ?", PERIOD_DURATIONS[@period].ago) if @period.present?
44
+ scope
45
+ end
46
+
47
+ def resolve_new_time(execution, offset)
48
+ return 1.second.ago if offset == "now"
49
+ raise ArgumentError, "Invalid offset." unless PERIOD_DURATIONS.key?(offset)
50
+
51
+ execution.scheduled_at + PERIOD_DURATIONS[offset]
52
+ end
53
+ end
54
+ end
@@ -1,6 +1,6 @@
1
1
  module SolidQueueWeb
2
2
  class Job
3
- STATUSES = %w[ready scheduled claimed blocked failed].freeze
3
+ STATUSES = %w[ready scheduled claimed blocked failed].freeze
4
4
  DISCARDABLE = %w[ready scheduled blocked].freeze
5
5
  EXECUTION_MODELS = {
6
6
  "ready" => SolidQueue::ReadyExecution,
@@ -9,5 +9,21 @@ module SolidQueueWeb
9
9
  "blocked" => SolidQueue::BlockedExecution,
10
10
  "failed" => SolidQueue::FailedExecution
11
11
  }.freeze
12
+
13
+ def self.execution_model_for!(status)
14
+ raise ArgumentError, "Cannot discard #{status} jobs from this page." unless DISCARDABLE.include?(status)
15
+
16
+ EXECUTION_MODELS[status]
17
+ end
18
+
19
+ def self.derive_status(job)
20
+ return "failed" if job.failed_execution.present?
21
+ return "claimed" if job.claimed_execution.present?
22
+ return "blocked" if job.blocked_execution.present?
23
+ return "ready" if job.ready_execution.present?
24
+ return "scheduled" if job.scheduled_execution.present?
25
+
26
+ "finished"
27
+ end
12
28
  end
13
29
  end
@@ -0,0 +1,58 @@
1
+ require "net/http"
2
+ require "json"
3
+ require "uri"
4
+
5
+ module SolidQueueWeb
6
+ class AlertWebhook
7
+ MUTEX = Mutex.new
8
+
9
+ class << self
10
+ def call(failure_count:)
11
+ return unless configured?
12
+ return if failure_count < SolidQueueWeb.alert_failure_threshold
13
+ return unless should_fire?
14
+
15
+ Thread.new { post(SolidQueueWeb.alert_webhook_url, failure_count) }
16
+ end
17
+
18
+ def reset!
19
+ MUTEX.synchronize { @last_fired_at = nil }
20
+ end
21
+
22
+ private
23
+
24
+ def configured?
25
+ SolidQueueWeb.alert_webhook_url.present? && SolidQueueWeb.alert_failure_threshold.present?
26
+ end
27
+
28
+ def should_fire?
29
+ MUTEX.synchronize do
30
+ cooldown = SolidQueueWeb.alert_webhook_cooldown
31
+ return false if @last_fired_at && Time.current - @last_fired_at < cooldown
32
+
33
+ @last_fired_at = Time.current
34
+ true
35
+ end
36
+ end
37
+
38
+ def post(url_string, failure_count)
39
+ uri = URI.parse(url_string)
40
+ payload = JSON.generate(
41
+ event: "failure_threshold_exceeded",
42
+ failure_count: failure_count,
43
+ threshold: SolidQueueWeb.alert_failure_threshold,
44
+ fired_at: Time.current.iso8601
45
+ )
46
+ http = Net::HTTP.new(uri.host, uri.port)
47
+ http.use_ssl = uri.scheme == "https"
48
+ http.open_timeout = 5
49
+ http.read_timeout = 10
50
+ request = Net::HTTP::Post.new(uri.path.presence || "/", "Content-Type" => "application/json")
51
+ request.body = payload
52
+ http.request(request)
53
+ rescue => e
54
+ Rails.logger.error("[SolidQueueWeb] Alert webhook failed: #{e.message}")
55
+ end
56
+ end
57
+ end
58
+ 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,38 @@
1
+ module SolidQueueWeb
2
+ class JobPerformanceStats
3
+ Row = Struct.new(:class_name, :count, :avg, :p50, :p95, :min, :max, keyword_init: true)
4
+
5
+ def initialize(scope)
6
+ @scope = scope
7
+ end
8
+
9
+ def rows
10
+ grouped = @scope.pluck(:class_name, :created_at, :finished_at)
11
+ .group_by(&:first)
12
+
13
+ grouped.map do |class_name, records|
14
+ durations = records.map { |_, created, finished| (finished - created).to_f }.sort
15
+ Row.new(
16
+ class_name: class_name,
17
+ count: durations.size,
18
+ avg: mean(durations),
19
+ p50: percentile(durations, 50),
20
+ p95: percentile(durations, 95),
21
+ min: durations.first,
22
+ max: durations.last
23
+ )
24
+ end.sort_by { |r| -r.p95 }
25
+ end
26
+
27
+ private
28
+
29
+ def mean(sorted)
30
+ sorted.sum / sorted.size
31
+ end
32
+
33
+ def percentile(sorted, pct)
34
+ idx = [(pct / 100.0 * sorted.size).ceil - 1, 0].max
35
+ sorted[idx]
36
+ end
37
+ end
38
+ end
@@ -0,0 +1,66 @@
1
+ module SolidQueueWeb
2
+ class MetricsPayload
3
+ def initialize
4
+ @now = Time.current
5
+ end
6
+
7
+ def to_h
8
+ payload = {
9
+ generated_at: @now.iso8601,
10
+ jobs: job_counts,
11
+ throughput: throughput,
12
+ queues: queue_list,
13
+ processes: process_summary
14
+ }
15
+ slow = slow_jobs_count
16
+ payload[:slow_jobs] = slow unless slow.nil?
17
+ payload
18
+ end
19
+
20
+ private
21
+
22
+ def job_counts
23
+ {
24
+ ready: SolidQueue::ReadyExecution.count,
25
+ scheduled: SolidQueue::ScheduledExecution.count,
26
+ claimed: SolidQueue::ClaimedExecution.count,
27
+ blocked: SolidQueue::BlockedExecution.count,
28
+ failed: SolidQueue::FailedExecution.count
29
+ }
30
+ end
31
+
32
+ def throughput
33
+ finished_times = SolidQueue::Job.where(finished_at: 24.hours.ago..@now).pluck(:finished_at)
34
+ {
35
+ completed_1h: finished_times.count { |t| t >= 1.hour.ago },
36
+ completed_24h: finished_times.size
37
+ }
38
+ end
39
+
40
+ def queue_list
41
+ depths = SolidQueue::ReadyExecution
42
+ .joins(:job)
43
+ .group("solid_queue_jobs.queue_name")
44
+ .count
45
+ SolidQueue::Queue.all.sort_by(&:name).map do |q|
46
+ { name: q.name, depth: depths[q.name] || 0, paused: q.paused? }
47
+ end
48
+ end
49
+
50
+ def process_summary
51
+ processes = SolidQueue::Process.all.to_a
52
+ threshold = SolidQueue.process_alive_threshold.ago
53
+ {
54
+ total: processes.size,
55
+ healthy: processes.count { |p| p.last_heartbeat_at >= threshold },
56
+ stale: processes.count { |p| p.last_heartbeat_at < threshold },
57
+ by_kind: processes.group_by(&:kind).transform_values(&:size)
58
+ }
59
+ end
60
+
61
+ def slow_jobs_count
62
+ threshold = SolidQueueWeb.slow_job_threshold
63
+ SolidQueue::ClaimedExecution.where("created_at <= ?", threshold.ago).count if threshold
64
+ end
65
+ end
66
+ 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
@@ -21,6 +21,7 @@
21
21
  <li><%= link_to "Queues", queues_path, class: current_page?(queues_path) ? "active" : "", aria: { current: current_page?(queues_path) ? "page" : nil } %></li>
22
22
  <li><%= link_to "Jobs", jobs_path, class: current_page?(jobs_path) ? "active" : "", aria: { current: current_page?(jobs_path) ? "page" : nil } %></li>
23
23
  <li><%= link_to "History", history_path, class: current_page?(history_path) ? "active" : "", aria: { current: current_page?(history_path) ? "page" : nil } %></li>
24
+ <li><%= link_to "Performance", performance_path, class: current_page?(performance_path) ? "active" : "", aria: { current: current_page?(performance_path) ? "page" : nil } %></li>
24
25
  <li><%= link_to "Failed", failed_jobs_path, class: current_page?(failed_jobs_path) ? "active" : "", aria: { current: current_page?(failed_jobs_path) ? "page" : nil } %></li>
25
26
  <li><%= link_to "Recurring", recurring_tasks_path, class: current_page?(recurring_tasks_path) ? "active" : "", aria: { current: current_page?(recurring_tasks_path) ? "page" : nil } %></li>
26
27
  <li><%= link_to "Processes", processes_path, class: current_page?(processes_path) ? "active" : "", aria: { current: current_page?(processes_path) ? "page" : nil } %></li>
@@ -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>
@@ -9,6 +9,16 @@
9
9
  params: { queue: @queue, q: @search, period: @period },
10
10
  class: "sqd-btn sqd-btn--primary",
11
11
  data: { confirm: "Retry all #{@failed_jobs.size} failed jobs?" } %>
12
+ <% if @failed_jobs.size > 1 %>
13
+ <% %w[5s 10s 30s 1m].each do |interval| %>
14
+ <%= button_to "+#{interval}", retry_all_failed_jobs_path,
15
+ method: :post,
16
+ params: { stagger: interval, queue: @queue, q: @search, period: @period },
17
+ class: "sqd-btn sqd-btn--muted sqd-btn--sm",
18
+ title: "Retry all, staggered #{interval} apart",
19
+ data: { confirm: "Retry #{@failed_jobs.size} failed jobs staggered #{interval} apart?" } %>
20
+ <% end %>
21
+ <% end %>
12
22
  <%= button_to "Discard All", discard_all_failed_jobs_path,
13
23
  method: :post,
14
24
  params: { queue: @queue, q: @search, period: @period },
@@ -92,7 +102,7 @@
92
102
  data-action="change->selection#toggle"
93
103
  aria-label="Select job <%= job.class_name %>">
94
104
  </td>
95
- <td><%= link_to job.class_name, job_path(job) %></td>
105
+ <td><%= link_to job.class_name, job_path(job), class: "sqd-table-link" %></td>
96
106
  <td>
97
107
  <%= link_to job.queue_name, failed_jobs_path(queue: job.queue_name, q: @search, period: @period),
98
108
  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;" %>