Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
6 changes: 4 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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**
Expand All @@ -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
Expand Down
4 changes: 4 additions & 0 deletions app/assets/stylesheets/solid_queue_web/_11_throughput.css
Original file line number Diff line number Diff line change
Expand Up @@ -90,4 +90,8 @@
.sqd-mini-sparkline__bar--empty {
background: var(--border);
opacity: 0.5;
}

.sqd-sparkline__bar--depth {
background: var(--purple);
}
11 changes: 11 additions & 0 deletions app/controllers/solid_queue_web/blocked_jobs_controller.rb
Original file line number Diff line number Diff line change
@@ -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
39 changes: 1 addition & 38 deletions app/controllers/solid_queue_web/dashboard_controller.rb
Original file line number Diff line number Diff line change
@@ -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
44 changes: 44 additions & 0 deletions app/services/solid_queue_web/dashboard_stats.rb
Original file line number Diff line number Diff line change
@@ -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
78 changes: 54 additions & 24 deletions app/views/solid_queue_web/dashboard/index.html.erb
Original file line number Diff line number Diff line change
Expand Up @@ -3,61 +3,61 @@

<div class="sqd-stats">
<%= link_to jobs_path(status: "ready"), class: "sqd-stat sqd-stat--ready sqd-stat--link" do %>
<div class="sqd-stat__value"><%= @stats[:ready] %></div>
<div class="sqd-stat__value"><%= @stats.counts[:ready] %></div>
<div class="sqd-stat__label">Ready</div>
<% end %>
<%= link_to jobs_path(status: "scheduled"), class: "sqd-stat sqd-stat--scheduled sqd-stat--link" do %>
<div class="sqd-stat__value"><%= @stats[:scheduled] %></div>
<div class="sqd-stat__value"><%= @stats.counts[:scheduled] %></div>
<div class="sqd-stat__label">Scheduled</div>
<% end %>
<%= link_to jobs_path(status: "claimed"), class: "sqd-stat sqd-stat--claimed sqd-stat--link" do %>
<div class="sqd-stat__value"><%= @stats[:claimed] %></div>
<div class="sqd-stat__value"><%= @stats.counts[:claimed] %></div>
<div class="sqd-stat__label">Running</div>
<% end %>
<%= link_to jobs_path(status: "blocked"), class: "sqd-stat sqd-stat--blocked sqd-stat--link" do %>
<div class="sqd-stat__value"><%= @stats[:blocked] %></div>
<div class="sqd-stat__value"><%= @stats.counts[:blocked] %></div>
<div class="sqd-stat__label">Blocked</div>
<% end %>
<%= link_to failed_jobs_path, class: "sqd-stat sqd-stat--failed sqd-stat--link" do %>
<div class="sqd-stat__value"><%= @stats[:failed] %></div>
<div class="sqd-stat__value"><%= @stats.counts[:failed] %></div>
<div class="sqd-stat__label">Failed</div>
<% end %>
<%= link_to queues_path, class: "sqd-stat sqd-stat--queues sqd-stat--link" do %>
<div class="sqd-stat__value"><%= @stats[:queues] %></div>
<div class="sqd-stat__value"><%= @stats.counts[:queues] %></div>
<div class="sqd-stat__label">Queues</div>
<% end %>
<%= link_to recurring_tasks_path, class: "sqd-stat sqd-stat--recurring sqd-stat--link" do %>
<div class="sqd-stat__value"><%= @stats[:recurring] %></div>
<div class="sqd-stat__value"><%= @stats.counts[:recurring] %></div>
<div class="sqd-stat__label">Recurring</div>
<% end %>
<%= link_to processes_path, class: "sqd-stat sqd-stat--processes sqd-stat--link" do %>
<div class="sqd-stat__value"><%= @stats[:processes] %></div>
<div class="sqd-stat__value"><%= @stats.counts[:processes] %></div>
<div class="sqd-stat__label">Processes</div>
<% end %>
<%= link_to history_path(period: "1h"), class: "sqd-stat sqd-stat--done sqd-stat--link" do %>
<div class="sqd-stat__value"><%= @throughput[:completed_1h] %></div>
<div class="sqd-stat__value"><%= @stats.throughput[:completed_1h] %></div>
<div class="sqd-stat__label">Done (1h)</div>
<% end %>
<%= link_to history_path(period: "24h"), class: "sqd-stat sqd-stat--done sqd-stat--link" do %>
<div class="sqd-stat__value"><%= @throughput[:completed_24h] %></div>
<div class="sqd-stat__value"><%= @stats.throughput[:completed_24h] %></div>
<div class="sqd-stat__label">Done (24h)</div>
<% end %>
</div>

<% max_val = [@sparkline.max, 1].max %>
<% max_val = [@stats.sparkline.max, 1].max %>
<div class="sqd-card" style="margin-bottom: 1rem;">
<div class="sqd-card__header">
<span class="sqd-card__title">Throughput &mdash; Last 12 Hours</span>
<div class="sqd-throughput__summary">
<span>1h: <strong><%= @throughput[:completed_1h] %></strong></span>
<span>24h: <strong><%= @throughput[:completed_24h] %></strong></span>
<span>1h: <strong><%= @stats.throughput[:completed_1h] %></strong></span>
<span>24h: <strong><%= @stats.throughput[:completed_24h] %></strong></span>
</div>
</div>
<% if @throughput[:completed_24h] == 0 %>
<% if @stats.throughput[:completed_24h] == 0 %>
<div class="sqd-sparkline__empty">No completed jobs in the last 24 hours</div>
<% else %>
<div class="sqd-sparkline" aria-label="Jobs completed per hour over the last 12 hours">
<% @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) %>
Expand All @@ -74,6 +74,36 @@
<% end %>
</div>

<% 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 %>
<div class="sqd-card" style="margin-bottom: 1rem;">
<div class="sqd-card__header">
<span class="sqd-card__title">Queue Depth &mdash; Last 12 Hours</span>
<div class="sqd-throughput__summary">
<span>Now: <strong><%= current_depth %></strong></span>
</div>
</div>
<% if @stats.depth_sparkline.all?(&:zero?) %>
<div class="sqd-sparkline__empty">No active jobs in the last 12 hours</div>
<% else %>
<div class="sqd-sparkline" aria-label="Queue depth over the last 12 hours">
<% @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) %>
<div class="sqd-sparkline__col">
<div class="sqd-sparkline__bar-wrap">
<div class="sqd-sparkline__bar sqd-sparkline__bar--depth"
style="height: <%= [pct, 3].max %>%"
title="<%= i == 11 ? "now" : t.strftime("%-I%p").downcase %>: <%= depth %> <%= "job".pluralize(depth) %> in queue"></div>
</div>
<div class="sqd-sparkline__tick"><%= show_tick ? (i == 11 ? "now" : t.strftime("%-I%p").downcase) : "" %></div>
</div>
<% end %>
</div>
<% end %>
</div>

<div style="display:grid; grid-template-columns: repeat(auto-fit, minmax(240px, 1fr)); gap: 1rem;">
<div class="sqd-card">
<div class="sqd-card__header">
Expand All @@ -88,37 +118,37 @@
</div>
</div>

<% if @stats[:failed] > 0 %>
<% if @stats.counts[:failed] > 0 %>
<div class="sqd-card">
<div class="sqd-card__header">
<span class="sqd-card__title">Failed Jobs</span>
</div>
<div style="padding: 1rem; display: flex; flex-direction: column; gap: 0.5rem;">
<p style="color: var(--danger); font-size: 13px;">
<%= pluralize(@stats[:failed], "failed job") %> need attention.
<%= pluralize(@stats.counts[:failed], "failed job") %> need attention.
</p>
<%= 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" %>
</div>
</div>
<% end %>

<% if @stats[:blocked] > 0 %>
<% if @stats.counts[:blocked] > 0 %>
<div class="sqd-card">
<div class="sqd-card__header">
<span class="sqd-card__title">Blocked Jobs</span>
</div>
<div style="padding: 1rem; display: flex; flex-direction: column; gap: 0.5rem;">
<p style="color: var(--warning); font-size: 13px;">
<%= pluralize(@stats[:blocked], "blocked job") %>.
<%= pluralize(@stats.counts[:blocked], "blocked job") %>.
</p>
<%= 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" %>
</div>
</div>
Expand Down
3 changes: 1 addition & 2 deletions config/routes.rb
Original file line number Diff line number Diff line change
@@ -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
Expand Down
38 changes: 38 additions & 0 deletions spec/requests/solid_queue_web/blocked_jobs_spec.rb
Original file line number Diff line number Diff line change
@@ -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
Loading
Loading