From e43be718b55b46f7ab3cfad2e257557ada39df59 Mon Sep 17 00:00:00 2001 From: Chuck Smith Date: Wed, 20 May 2026 14:24:00 -0400 Subject: [PATCH 1/2] feat: failure rate sparkline per queue + QueueStats service object MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add a mini 12-bar failure-rate sparkline to each row of the Queues table showing the percentage of jobs that failed (vs. completed) in each of the last 12 hours. Bars are red and proportional to the rate (0–100 %); hovering shows the hour and exact percentage; queues with no activity show "—". Extract all five index queries (completed_24h, failed_24h, oldest_ready, failure_sparklines) from QueuesController into a QueueStats service object so the action stays thin. Co-Authored-By: Claude Sonnet 4.6 --- CHANGELOG.md | 4 ++ .../solid_queue_web/_11_throughput.css | 27 +++++++++- .../solid_queue_web/queues_controller.rb | 20 ++----- app/services/solid_queue_web/queue_stats.rb | 52 +++++++++++++++++++ .../solid_queue_web/queues/index.html.erb | 17 ++++++ spec/dummy/db/seeds.rb | 20 +++++++ 6 files changed, 124 insertions(+), 16 deletions(-) create mode 100644 app/services/solid_queue_web/queue_stats.rb diff --git a/CHANGELOG.md b/CHANGELOG.md index d9fa026..f3fec14 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,10 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +### Added + +- Failure rate sparkline per queue — a mini 12-bar chart in the Queues table shows the percentage of jobs that failed (vs. completed) in each of the last 12 hours; bars are red, sized proportionally to the failure rate (0–100 %), and include a tooltip with the hour label and exact percentage; empty hours render as a faint border-colored bar; queues with no activity in the last 12 hours show "—" + ## [0.8.0] - 2026-05-20 ### Added diff --git a/app/assets/stylesheets/solid_queue_web/_11_throughput.css b/app/assets/stylesheets/solid_queue_web/_11_throughput.css index a6f19dd..8fa054b 100644 --- a/app/assets/stylesheets/solid_queue_web/_11_throughput.css +++ b/app/assets/stylesheets/solid_queue_web/_11_throughput.css @@ -65,4 +65,29 @@ padding: 1rem 1.25rem; } -.sqd-stat--done .sqd-stat__value { color: var(--success); } \ No newline at end of file +.sqd-stat--done .sqd-stat__value { color: var(--success); } + +.sqd-mini-sparkline { + display: flex; + align-items: flex-end; + gap: 2px; + height: 28px; + width: 88px; +} + +.sqd-mini-sparkline__bar { + flex: 1; + background: var(--danger); + border-radius: 1px 1px 0 0; + opacity: 0.7; + transition: opacity 0.15s; +} + +.sqd-mini-sparkline__bar:hover { + opacity: 1; +} + +.sqd-mini-sparkline__bar--empty { + background: var(--border); + opacity: 0.5; +} \ No newline at end of file diff --git a/app/controllers/solid_queue_web/queues_controller.rb b/app/controllers/solid_queue_web/queues_controller.rb index fbbe337..208232a 100644 --- a/app/controllers/solid_queue_web/queues_controller.rb +++ b/app/controllers/solid_queue_web/queues_controller.rb @@ -2,21 +2,11 @@ module SolidQueueWeb class QueuesController < ApplicationController def index @queues = SolidQueue::Queue.all.sort_by(&:name) - - now = Time.current - @completed_24h = SolidQueue::Job - .where(finished_at: 24.hours.ago..now) - .group(:queue_name) - .count - @failed_24h = SolidQueue::FailedExecution - .joins(:job) - .where(created_at: 24.hours.ago..now) - .group("solid_queue_jobs.queue_name") - .count - @oldest_ready = SolidQueue::ReadyExecution - .joins(:job) - .group("solid_queue_jobs.queue_name") - .minimum("solid_queue_jobs.created_at") + stats = QueueStats.new(@queues) + @completed_24h = stats.completed_24h + @failed_24h = stats.failed_24h + @oldest_ready = stats.oldest_ready + @failure_sparklines = stats.failure_sparklines end def pause diff --git a/app/services/solid_queue_web/queue_stats.rb b/app/services/solid_queue_web/queue_stats.rb new file mode 100644 index 0000000..8cb67e3 --- /dev/null +++ b/app/services/solid_queue_web/queue_stats.rb @@ -0,0 +1,52 @@ +module SolidQueueWeb + class QueueStats + attr_reader :completed_24h, :failed_24h, :oldest_ready, :failure_sparklines + + def initialize(queues) + @queues = queues + @now = Time.current + compute + end + + private + + def compute + @completed_24h = SolidQueue::Job + .where(finished_at: 24.hours.ago..@now) + .group(:queue_name) + .count + + @failed_24h = SolidQueue::FailedExecution + .joins(:job) + .where(created_at: 24.hours.ago..@now) + .group("solid_queue_jobs.queue_name") + .count + + @oldest_ready = SolidQueue::ReadyExecution + .joins(:job) + .group("solid_queue_jobs.queue_name") + .minimum("solid_queue_jobs.created_at") + + failed_raw = SolidQueue::FailedExecution + .joins(:job) + .where(created_at: 12.hours.ago..@now) + .pluck("solid_queue_jobs.queue_name", "solid_queue_failed_executions.created_at") + done_raw = SolidQueue::Job + .where(finished_at: 12.hours.ago..@now) + .pluck(:queue_name, :finished_at) + + @failure_sparklines = @queues.each_with_object({}) do |queue, h| + failed_times = failed_raw.filter_map { |q, t| t if q == queue.name } + done_times = done_raw.filter_map { |q, t| t if q == queue.name } + h[queue.name] = 12.times.map do |i| + from = (12 - i).hours.ago + to = i == 11 ? @now : (11 - i).hours.ago + f = failed_times.count { |t| t >= from && t < to } + d = done_times.count { |t| t >= from && t < to } + total = f + d + total > 0 ? (f.to_f / total * 100).round : nil + end + end + end + end +end diff --git a/app/views/solid_queue_web/queues/index.html.erb b/app/views/solid_queue_web/queues/index.html.erb index bcd4293..da960bf 100644 --- a/app/views/solid_queue_web/queues/index.html.erb +++ b/app/views/solid_queue_web/queues/index.html.erb @@ -12,6 +12,7 @@ Latency Done (24h) Failed (24h) + Failure Rate (12h) Status Actions @@ -34,6 +35,22 @@ <%= @completed_24h[queue.name] || 0 %> ;"><%= @failed_24h[queue.name] || 0 %> + + <% sparkline = @failure_sparklines[queue.name] %> + <% if sparkline.any? %> +
+ <% sparkline.each_with_index do |rate, i| %> + <% pct = rate || 0 %> + <% hour_label = (12 - i).hours.ago.strftime("%-I%p").downcase %> +
" + style="height: <%= [pct, 2].max %>%" + title="<%= hour_label %>: <%= rate ? "#{rate}% failure rate" : "no data" %>">
+ <% end %> +
+ <% else %> + + <% end %> + <% if queue.paused? %> Paused diff --git a/spec/dummy/db/seeds.rb b/spec/dummy/db/seeds.rb index 00a5d49..cf12aa4 100644 --- a/spec/dummy/db/seeds.rb +++ b/spec/dummy/db/seeds.rb @@ -106,6 +106,26 @@ ) end +puts "Seeding recent failed jobs (for failure rate sparkline)..." +3.times do |i| + err = errors[i % errors.size] + job = SolidQueue::Job.create!( + queue_name: queues.sample, + class_name: job_classes.sample, + arguments: { recent_fail: true, idx: i }.to_json, + priority: 0, + active_job_id: SecureRandom.uuid, + created_at: rand(1..10).hours.ago, + updated_at: rand(1..10).hours.ago + ) + job.ready_execution&.destroy + SolidQueue::FailedExecution.create!( + job: job, + error: { exception_class: err[:class], message: err[:message], backtrace: ["app/jobs/#{job.class_name.underscore}.rb:42"] }, + created_at: job.created_at + ) +end + puts "Seeding blocked jobs..." # Skip set_expires_at callback which requires a real ActiveJob class to exist SolidQueue::BlockedExecution.skip_callback(:create, :before, :set_expires_at) From 2a3446b2f4db839c6e0342d8034ac6913b2b3331 Mon Sep 17 00:00:00 2001 From: Chuck Smith Date: Wed, 20 May 2026 14:27:40 -0400 Subject: [PATCH 2/2] docs: update README for failure rate sparkline Add sparkline to Queues feature bullet; remove from roadmap now that it is shipped. Co-Authored-By: Claude Sonnet 4.6 --- README.md | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/README.md b/README.md index ff9dc86..aa95736 100644 --- a/README.md +++ b/README.md @@ -34,7 +34,7 @@ SolidQueueWeb surfaces all of this in a browser UI available at any route you ch ## Features - **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 -- **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 +- **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 - **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 - **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 - **Job detail** — full arguments, timestamps, blocked-until date, and error backtrace; action buttons based on job status @@ -112,7 +112,6 @@ No authentication is enforced by default. When the `authenticate` block returns Planned features, roughly ordered by priority: **Observability** -- Job failure rate chart — sparkline per queue showing failure percentage over time, mirroring the throughput chart - Queue depth trend — historical queue size over time, not just the current snapshot - Slow job detection — flag jobs exceeding a configurable duration threshold