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
4 changes: 4 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
3 changes: 1 addition & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
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**
- 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

Expand Down
27 changes: 26 additions & 1 deletion app/assets/stylesheets/solid_queue_web/_11_throughput.css
Original file line number Diff line number Diff line change
Expand Up @@ -65,4 +65,29 @@
padding: 1rem 1.25rem;
}

.sqd-stat--done .sqd-stat__value { color: var(--success); }
.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;
}
20 changes: 5 additions & 15 deletions app/controllers/solid_queue_web/queues_controller.rb
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
52 changes: 52 additions & 0 deletions app/services/solid_queue_web/queue_stats.rb
Original file line number Diff line number Diff line change
@@ -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
17 changes: 17 additions & 0 deletions app/views/solid_queue_web/queues/index.html.erb
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@
<th scope="col">Latency</th>
<th scope="col">Done (24h)</th>
<th scope="col">Failed (24h)</th>
<th scope="col">Failure Rate (12h)</th>
<th scope="col">Status</th>
<th scope="col"><span class="sqd-sr-only">Actions</span></th>
</tr>
Expand All @@ -34,6 +35,22 @@
</td>
<td style="color: var(--success);"><%= @completed_24h[queue.name] || 0 %></td>
<td style="color: <%= (@failed_24h[queue.name] || 0) > 0 ? "var(--danger)" : "inherit" %>;"><%= @failed_24h[queue.name] || 0 %></td>
<td>
<% sparkline = @failure_sparklines[queue.name] %>
<% if sparkline.any? %>
<div class="sqd-mini-sparkline" aria-label="Failure rate last 12 hours for <%= queue.name %>">
<% sparkline.each_with_index do |rate, i| %>
<% pct = rate || 0 %>
<% hour_label = (12 - i).hours.ago.strftime("%-I%p").downcase %>
<div class="sqd-mini-sparkline__bar sqd-mini-sparkline__bar--<%= rate ? "data" : "empty" %>"
style="height: <%= [pct, 2].max %>%"
title="<%= hour_label %>: <%= rate ? "#{rate}% failure rate" : "no data" %>"></div>
<% end %>
</div>
<% else %>
<span style="color: var(--muted)">—</span>
<% end %>
</td>
<td>
<% if queue.paused? %>
<span class="sqd-badge sqd-badge--paused">Paused</span>
Expand Down
20 changes: 20 additions & 0 deletions spec/dummy/db/seeds.rb
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
Loading