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 @@ -9,6 +9,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0

### Added

- Failed job trend chart — "Failures — last 12 hours" sparkline added to the Solid Queue dashboard card below the throughput sparkline; bars render in the danger color to make failure spikes immediately visible
- Error frequency report — `GET /failed_jobs/errors` groups all failed jobs by exception class and message prefix, showing count and an expandable sample backtrace per group; links through to a filtered failed jobs list via `?error_class=`; the failed jobs index gains an "Error Summary" button and shows an active-filter breadcrumb with a clear link

## [1.0.0] - 2026-05-27
Expand Down
4 changes: 4 additions & 0 deletions app/assets/stylesheets/solid_stack_web/_07_dashboard.css
Original file line number Diff line number Diff line change
Expand Up @@ -86,6 +86,10 @@ a.sqw-inline-stat:hover { opacity: 0.7; text-decoration: none; }
color: var(--primary);
}

.sqw-sparkline-wrap--failures {
color: var(--danger);
}

.sqw-sparkline-label {
display: block;
font-size: 10px;
Expand Down
1 change: 1 addition & 0 deletions app/controllers/solid_stack_web/dashboard_controller.rb
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ def index
@cache_stats = CacheStats.new.to_h
@cable_stats = CableStats.new.to_h
@throughput = ThroughputSparkline.new
@failures = FailedJobSparkline.new
end
end
end
11 changes: 11 additions & 0 deletions app/helpers/solid_stack_web/application_helper.rb
Original file line number Diff line number Diff line change
Expand Up @@ -87,6 +87,17 @@ def queue_depth_sparkline_svg(sparkline)
end
end

def failed_job_sparkline_svg(sparkline)
build_sparkline_svg(sparkline, aria_label: "Failed jobs over the last 12 hours") do |count, i|
hours_ago = SolidStackWeb::FailedJobSparkline::HOURS - i
if hours_ago == 1
"#{count} #{count == 1 ? "failure" : "failures"} in the last hour"
else
"#{count} #{count == 1 ? "failure" : "failures"} (#{hours_ago}h–#{hours_ago - 1}h ago)"
end
end
end

private

def build_sparkline_svg(sparkline, css_class: "sqw-sparkline", aria_label: nil, &tooltip_text)
Expand Down
23 changes: 23 additions & 0 deletions app/models/solid_stack_web/failed_job_sparkline.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
module SolidStackWeb
class FailedJobSparkline
HOURS = 12

def buckets
@buckets ||= begin
now = Time.current
origin = now - HOURS.hours
times = ::SolidQueue::FailedExecution.where(created_at: origin..now).pluck(:created_at)

HOURS.times.map do |i|
from = origin + i.hours
to = origin + (i + 1).hours
times.count { |t| t >= from && t < to }
end
end
end

def max
buckets.max || 0
end
end
end
12 changes: 12 additions & 0 deletions app/views/solid_stack_web/dashboard/index.html.erb
Original file line number Diff line number Diff line change
Expand Up @@ -68,6 +68,18 @@
<span>now</span>
</div>
</div>
<div class="sqw-sparkline-wrap sqw-sparkline-wrap--failures" data-controller="sparkline-tooltip">
<span class="sqw-sparkline-label">Failures — last 12 hours</span>
<div class="sqw-sparkline-positioner">
<%= failed_job_sparkline_svg(@failures) %>
<div class="sqw-sparkline-tip" data-sparkline-tooltip-target="tip" hidden></div>
</div>
<div class="sqw-sparkline-axis">
<span>12h ago</span>
<span>6h ago</span>
<span>now</span>
</div>
</div>
</div>

<div class="sqw-gem-card sqw-gem-card--cache">
Expand Down
50 changes: 50 additions & 0 deletions spec/models/solid_stack_web/failed_job_sparkline_spec.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,50 @@
require "rails_helper"

RSpec.describe SolidStackWeb::FailedJobSparkline do
def create_failed(created_at:)
SolidQueue::Job.skip_callback(:create, :after, :prepare_for_execution)
job = SolidQueue::Job.create!(class_name: "FailingJob", queue_name: "default",
priority: 0, arguments: {})
SolidQueue::FailedExecution.create!(
job: job,
error: { exception_class: "RuntimeError", message: "boom", backtrace: [] },
created_at: created_at
)
ensure
SolidQueue::Job.set_callback(:create, :after, :prepare_for_execution)
end

describe "#buckets" do
it "returns 12 buckets" do
expect(described_class.new.buckets.size).to eq(12)
end

it "returns all zeros when there are no failed executions" do
expect(described_class.new.buckets).to all(eq(0))
end

it "counts a failure within the window in the correct bucket" do
create_failed(created_at: 2.hours.ago)
buckets = described_class.new.buckets
expect(buckets[9]).to eq(1)
expect(buckets.sum).to eq(1)
end

it "excludes failures outside the 12-hour window" do
create_failed(created_at: 13.hours.ago)
expect(described_class.new.buckets).to all(eq(0))
end
end

describe "#max" do
it "returns 0 when there are no failures" do
expect(described_class.new.max).to eq(0)
end

it "returns the highest bucket count" do
3.times { create_failed(created_at: 1.hour.ago) }
create_failed(created_at: 5.hours.ago)
expect(described_class.new.max).to eq(3)
end
end
end