diff --git a/CHANGELOG.md b/CHANGELOG.md index 0d6b012..05ba8bc 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -10,6 +10,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### 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 "—" +- Queue depth trend — a "Queue Depth — Last 12 Hours" card on the dashboard shows estimated queue depth at 12 hourly snapshots; depth at time T is the count of jobs created before T that had not yet finished by T; bars are purple (distinct from the blue throughput and red failure rate charts); the card header shows current depth; empty state shown when no active jobs exist in the window ## [0.8.0] - 2026-05-20 diff --git a/README.md b/README.md index aa95736..2a8c285 100644 --- a/README.md +++ b/README.md @@ -33,7 +33,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 +- **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 - **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 @@ -112,7 +112,6 @@ No authentication is enforced by default. When the `authenticate` block returns Planned features, roughly ordered by priority: **Observability** -- Queue depth trend — historical queue size over time, not just the current snapshot - Slow job detection — flag jobs exceeding a configurable duration threshold **Operations** @@ -125,6 +124,9 @@ Planned features, roughly ordered by priority: - Multi-database support — when Solid Queue runs on a separate database from the host app - Read replica support — route dashboard queries to a replica to avoid impacting the primary +**Code quality** +- Rails controller conventions — complete the 7-action refactor: `QueuesController#pause` / `#resume` → `Queues::PausesController#create` / `#destroy`; `Queues::JobsController#discard_all` → map to `#destroy` matching the pattern already applied to `JobsController` + Pull requests for any of these are welcome. See [Contributing](#contributing) below. ## Contributing diff --git a/app/assets/stylesheets/solid_queue_web/_11_throughput.css b/app/assets/stylesheets/solid_queue_web/_11_throughput.css index 8fa054b..b302e59 100644 --- a/app/assets/stylesheets/solid_queue_web/_11_throughput.css +++ b/app/assets/stylesheets/solid_queue_web/_11_throughput.css @@ -90,4 +90,8 @@ .sqd-mini-sparkline__bar--empty { background: var(--border); opacity: 0.5; +} + +.sqd-sparkline__bar--depth { + background: var(--purple); } \ No newline at end of file diff --git a/app/controllers/solid_queue_web/blocked_jobs_controller.rb b/app/controllers/solid_queue_web/blocked_jobs_controller.rb new file mode 100644 index 0000000..4a25db6 --- /dev/null +++ b/app/controllers/solid_queue_web/blocked_jobs_controller.rb @@ -0,0 +1,11 @@ +module SolidQueueWeb + class BlockedJobsController < ApplicationController + def destroy + jobs = SolidQueue::BlockedExecution.includes(:job).map(&:job) + SolidQueue::BlockedExecution.discard_all_from_jobs(jobs) + redirect_to root_path, notice: "#{jobs.size} blocked #{"job".pluralize(jobs.size)} discarded." + rescue => e + redirect_to root_path, alert: "Could not discard blocked jobs: #{e.message}" + end + end +end diff --git a/app/controllers/solid_queue_web/dashboard_controller.rb b/app/controllers/solid_queue_web/dashboard_controller.rb index dc6753e..d4ed830 100644 --- a/app/controllers/solid_queue_web/dashboard_controller.rb +++ b/app/controllers/solid_queue_web/dashboard_controller.rb @@ -1,44 +1,7 @@ module SolidQueueWeb class DashboardController < ApplicationController def index - @stats = { - ready: SolidQueue::ReadyExecution.count, - scheduled: SolidQueue::ScheduledExecution.count, - claimed: SolidQueue::ClaimedExecution.count, - failed: SolidQueue::FailedExecution.count, - blocked: SolidQueue::BlockedExecution.count, - queues: SolidQueue::Job.select(:queue_name).distinct.count, - processes: SolidQueue::Process.count, - recurring: SolidQueue::RecurringTask.count - } - - now = Time.current - finished_times = SolidQueue::Job.where(finished_at: 24.hours.ago..now).pluck(:finished_at) - @throughput = { - completed_1h: finished_times.count { |t| t >= 1.hour.ago }, - completed_24h: finished_times.size - } - @sparkline = 12.times.map do |i| - from = (12 - i).hours.ago - to = i == 11 ? now : (11 - i).hours.ago - finished_times.count { |t| t >= from && t < to } - end - end - - def retry_all_failed - jobs = SolidQueue::FailedExecution.includes(:job).map(&:job) - SolidQueue::FailedExecution.retry_all(jobs) - redirect_to root_path, notice: "#{jobs.size} failed #{"job".pluralize(jobs.size)} queued for retry." - rescue => e - redirect_to root_path, alert: "Could not retry failed jobs: #{e.message}" - end - - def discard_all_blocked - jobs = SolidQueue::BlockedExecution.includes(:job).map(&:job) - SolidQueue::BlockedExecution.discard_all_from_jobs(jobs) - redirect_to root_path, notice: "#{jobs.size} blocked #{"job".pluralize(jobs.size)} discarded." - rescue => e - redirect_to root_path, alert: "Could not discard blocked jobs: #{e.message}" + @stats = DashboardStats.new end end end diff --git a/app/services/solid_queue_web/dashboard_stats.rb b/app/services/solid_queue_web/dashboard_stats.rb new file mode 100644 index 0000000..1621792 --- /dev/null +++ b/app/services/solid_queue_web/dashboard_stats.rb @@ -0,0 +1,44 @@ +module SolidQueueWeb + class DashboardStats + attr_reader :counts, :throughput, :sparkline, :depth_sparkline + + def initialize + @now = Time.current + compute + end + + private + + def compute + @counts = { + ready: SolidQueue::ReadyExecution.count, + scheduled: SolidQueue::ScheduledExecution.count, + claimed: SolidQueue::ClaimedExecution.count, + failed: SolidQueue::FailedExecution.count, + blocked: SolidQueue::BlockedExecution.count, + queues: SolidQueue::Job.select(:queue_name).distinct.count, + processes: SolidQueue::Process.count, + recurring: SolidQueue::RecurringTask.count + } + + finished_times = SolidQueue::Job.where(finished_at: 24.hours.ago..@now).pluck(:finished_at) + @throughput = { + completed_1h: finished_times.count { |t| t >= 1.hour.ago }, + completed_24h: finished_times.size + } + @sparkline = 12.times.map do |i| + from = (12 - i).hours.ago + to = i == 11 ? @now : (11 - i).hours.ago + finished_times.count { |t| t >= from && t < to } + end + + job_timestamps = SolidQueue::Job + .where("created_at >= ? OR finished_at IS NULL", 72.hours.ago) + .pluck(:created_at, :finished_at) + @depth_sparkline = 12.times.map do |i| + t = i == 11 ? @now : (12 - i).hours.ago + job_timestamps.count { |created, finished| created <= t && (finished.nil? || finished > t) } + end + end + end +end diff --git a/app/views/solid_queue_web/dashboard/index.html.erb b/app/views/solid_queue_web/dashboard/index.html.erb index f5d9942..3b63cbc 100644 --- a/app/views/solid_queue_web/dashboard/index.html.erb +++ b/app/views/solid_queue_web/dashboard/index.html.erb @@ -3,61 +3,61 @@
- <%= pluralize(@stats[:failed], "failed job") %> need attention. + <%= pluralize(@stats.counts[:failed], "failed job") %> need attention.
- <%= button_to "Retry All Failed", retry_all_failed_path, + <%= button_to "Retry All Failed", retry_all_failed_jobs_path, method: :post, class: "sqd-btn sqd-btn--primary", - data: { confirm: "Retry all #{@stats[:failed]} failed #{"job".pluralize(@stats[:failed])}?" } %> + data: { confirm: "Retry all #{@stats.counts[:failed]} failed #{"job".pluralize(@stats.counts[:failed])}?" } %> <%= link_to "Review →", failed_jobs_path, class: "sqd-btn sqd-btn--muted" %>- <%= pluralize(@stats[:blocked], "blocked job") %>. + <%= pluralize(@stats.counts[:blocked], "blocked job") %>.
- <%= button_to "Discard All Blocked", discard_all_blocked_path, - method: :post, + <%= button_to "Discard All Blocked", blocked_jobs_path, + method: :delete, class: "sqd-btn sqd-btn--danger", - data: { confirm: "Discard all #{@stats[:blocked]} blocked #{"job".pluralize(@stats[:blocked])}? This cannot be undone." } %> + data: { confirm: "Discard all #{@stats.counts[:blocked]} blocked #{"job".pluralize(@stats.counts[:blocked])}? This cannot be undone." } %> <%= link_to "Review →", jobs_path(status: "blocked"), class: "sqd-btn sqd-btn--muted" %>