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 @@
<%= link_to jobs_path(status: "ready"), class: "sqd-stat sqd-stat--ready sqd-stat--link" do %> -
<%= @stats[:ready] %>
+
<%= @stats.counts[:ready] %>
Ready
<% end %> <%= link_to jobs_path(status: "scheduled"), class: "sqd-stat sqd-stat--scheduled sqd-stat--link" do %> -
<%= @stats[:scheduled] %>
+
<%= @stats.counts[:scheduled] %>
Scheduled
<% end %> <%= link_to jobs_path(status: "claimed"), class: "sqd-stat sqd-stat--claimed sqd-stat--link" do %> -
<%= @stats[:claimed] %>
+
<%= @stats.counts[:claimed] %>
Running
<% end %> <%= link_to jobs_path(status: "blocked"), class: "sqd-stat sqd-stat--blocked sqd-stat--link" do %> -
<%= @stats[:blocked] %>
+
<%= @stats.counts[:blocked] %>
Blocked
<% end %> <%= link_to failed_jobs_path, class: "sqd-stat sqd-stat--failed sqd-stat--link" do %> -
<%= @stats[:failed] %>
+
<%= @stats.counts[:failed] %>
Failed
<% end %> <%= link_to queues_path, class: "sqd-stat sqd-stat--queues sqd-stat--link" do %> -
<%= @stats[:queues] %>
+
<%= @stats.counts[:queues] %>
Queues
<% end %> <%= link_to recurring_tasks_path, class: "sqd-stat sqd-stat--recurring sqd-stat--link" do %> -
<%= @stats[:recurring] %>
+
<%= @stats.counts[:recurring] %>
Recurring
<% end %> <%= link_to processes_path, class: "sqd-stat sqd-stat--processes sqd-stat--link" do %> -
<%= @stats[:processes] %>
+
<%= @stats.counts[:processes] %>
Processes
<% end %> <%= link_to history_path(period: "1h"), class: "sqd-stat sqd-stat--done sqd-stat--link" do %> -
<%= @throughput[:completed_1h] %>
+
<%= @stats.throughput[:completed_1h] %>
Done (1h)
<% end %> <%= link_to history_path(period: "24h"), class: "sqd-stat sqd-stat--done sqd-stat--link" do %> -
<%= @throughput[:completed_24h] %>
+
<%= @stats.throughput[:completed_24h] %>
Done (24h)
<% end %>
-<% max_val = [@sparkline.max, 1].max %> +<% max_val = [@stats.sparkline.max, 1].max %>
Throughput — Last 12 Hours
- 1h: <%= @throughput[:completed_1h] %> - 24h: <%= @throughput[:completed_24h] %> + 1h: <%= @stats.throughput[:completed_1h] %> + 24h: <%= @stats.throughput[:completed_24h] %>
- <% if @throughput[:completed_24h] == 0 %> + <% if @stats.throughput[:completed_24h] == 0 %>
No completed jobs in the last 24 hours
<% else %>
- <% @sparkline.each_with_index do |count, i| %> + <% @stats.sparkline.each_with_index do |count, i| %> <% pct = (count.to_f / max_val * 100).round %> <% hour_start = (12 - i).hours.ago %> <% show_tick = [0, 3, 6, 9, 11].include?(i) %> @@ -74,6 +74,36 @@ <% end %>
+<% current_depth = @stats.counts[:ready] + @stats.counts[:scheduled] + @stats.counts[:claimed] + @stats.counts[:blocked] + @stats.counts[:failed] %> +<% max_depth = [@stats.depth_sparkline.max, 1].max %> +
+
+ Queue Depth — Last 12 Hours +
+ Now: <%= current_depth %> +
+
+ <% if @stats.depth_sparkline.all?(&:zero?) %> +
No active jobs in the last 12 hours
+ <% else %> +
+ <% @stats.depth_sparkline.each_with_index do |depth, i| %> + <% pct = (depth.to_f / max_depth * 100).round %> + <% t = i == 11 ? Time.current : (12 - i).hours.ago %> + <% show_tick = [0, 3, 6, 9, 11].include?(i) %> +
+
+
: <%= depth %> <%= "job".pluralize(depth) %> in queue">
+
+
<%= show_tick ? (i == 11 ? "now" : t.strftime("%-I%p").downcase) : "" %>
+
+ <% end %> +
+ <% end %> +
+
@@ -88,37 +118,37 @@
- <% if @stats[:failed] > 0 %> + <% if @stats.counts[:failed] > 0 %>
Failed Jobs

- <%= 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" %>
<% end %> - <% if @stats[:blocked] > 0 %> + <% if @stats.counts[:blocked] > 0 %>
Blocked Jobs

- <%= 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" %>
diff --git a/config/routes.rb b/config/routes.rb index 713b71a..fbf7be9 100644 --- a/config/routes.rb +++ b/config/routes.rb @@ -1,7 +1,6 @@ SolidQueueWeb::Engine.routes.draw do root to: "dashboard#index" - post "retry_all_failed", to: "dashboard#retry_all_failed", as: :retry_all_failed - post "discard_all_blocked", to: "dashboard#discard_all_blocked", as: :discard_all_blocked + resource :blocked_jobs, only: [:destroy] get "search", to: "search#index", as: :search get "history", to: "history#index", as: :history diff --git a/spec/requests/solid_queue_web/blocked_jobs_spec.rb b/spec/requests/solid_queue_web/blocked_jobs_spec.rb new file mode 100644 index 0000000..b45a001 --- /dev/null +++ b/spec/requests/solid_queue_web/blocked_jobs_spec.rb @@ -0,0 +1,38 @@ +require "rails_helper" + +RSpec.describe "BlockedJobs", type: :request do + let!(:blocked_job) do + j = SolidQueue::Job.create!( + queue_name: "default", class_name: "TestJob", + arguments: {}.to_json, active_job_id: SecureRandom.uuid + ) + j.ready_execution&.destroy + j.update!(concurrency_key: "TestJob/1") + allow_any_instance_of(SolidQueue::BlockedExecution).to receive(:set_expires_at) + SolidQueue::BlockedExecution.create!( + job: j, queue_name: j.queue_name, priority: j.priority, expires_at: 1.hour.from_now + ) + j + end + + describe "DELETE /jobs/blocked_jobs" do + it "discards all blocked jobs and redirects to the dashboard" do + delete "/jobs/blocked_jobs" + expect(response).to redirect_to("/jobs/") + follow_redirect! + expect(response.body).to include("discarded") + end + + it "removes the blocked execution" do + expect { delete "/jobs/blocked_jobs" }.to change(SolidQueue::BlockedExecution, :count).by(-1) + end + + it "handles failure gracefully" do + allow(SolidQueue::BlockedExecution).to receive(:discard_all_from_jobs).and_raise(RuntimeError, "boom") + delete "/jobs/blocked_jobs" + expect(response).to redirect_to("/jobs/") + follow_redirect! + expect(response.body).to include("Could not discard blocked jobs") + end + end +end diff --git a/spec/requests/solid_queue_web/dashboard_spec.rb b/spec/requests/solid_queue_web/dashboard_spec.rb index 87a3d77..088ad0d 100644 --- a/spec/requests/solid_queue_web/dashboard_spec.rb +++ b/spec/requests/solid_queue_web/dashboard_spec.rb @@ -46,74 +46,6 @@ end end - describe "POST /jobs/retry_all_failed" do - let!(:failed_job) do - j = SolidQueue::Job.create!( - queue_name: "default", class_name: "TestJob", - arguments: {}.to_json, active_job_id: SecureRandom.uuid - ) - SolidQueue::FailedExecution.create!( - job: j, - error: { exception_class: "RuntimeError", message: "boom", backtrace: [] } - ) - j - end - - it "retries all failed jobs and redirects to the dashboard" do - post "/jobs/retry_all_failed" - expect(response).to redirect_to("/jobs/") - follow_redirect! - expect(response.body).to include("queued for retry") - end - - it "removes the failed execution" do - expect { post "/jobs/retry_all_failed" }.to change(SolidQueue::FailedExecution, :count).by(-1) - end - - it "handles failure gracefully" do - allow(SolidQueue::FailedExecution).to receive(:retry_all).and_raise(RuntimeError, "boom") - post "/jobs/retry_all_failed" - expect(response).to redirect_to("/jobs/") - follow_redirect! - expect(response.body).to include("Could not retry failed jobs") - end - end - - describe "POST /jobs/discard_all_blocked" do - let!(:blocked_job) do - j = SolidQueue::Job.create!( - queue_name: "default", class_name: "TestJob", - arguments: {}.to_json, active_job_id: SecureRandom.uuid - ) - j.ready_execution&.destroy - j.update!(concurrency_key: "TestJob/1") - allow_any_instance_of(SolidQueue::BlockedExecution).to receive(:set_expires_at) - SolidQueue::BlockedExecution.create!( - job: j, queue_name: j.queue_name, priority: j.priority, expires_at: 1.hour.from_now - ) - j - end - - it "discards all blocked jobs and redirects to the dashboard" do - post "/jobs/discard_all_blocked" - expect(response).to redirect_to("/jobs/") - follow_redirect! - expect(response.body).to include("discarded") - end - - it "removes the blocked execution" do - expect { post "/jobs/discard_all_blocked" }.to change(SolidQueue::BlockedExecution, :count).by(-1) - end - - it "handles failure gracefully" do - allow(SolidQueue::BlockedExecution).to receive(:discard_all_from_jobs).and_raise(RuntimeError, "boom") - post "/jobs/discard_all_blocked" - expect(response).to redirect_to("/jobs/") - follow_redirect! - expect(response.body).to include("Could not discard blocked jobs") - end - end - describe "authentication" do after { SolidQueueWeb.instance_variable_set(:@authenticate, nil) }