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/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 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 @@