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

- Performance statistics page — `GET /stats` aggregates all finished jobs by class name and shows execution count, average duration, p50, p95, min, and max; each column header is a sort link; defaults to p95 descending so the slowest outliers appear first; duration formatting handles ms, seconds (with one decimal place), minutes, and hours; "Stats" link added to the queue subnav

## [0.3.0] - 2026-05-25

### Added
Expand Down
1 change: 1 addition & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ A mountable Rails engine that provides a unified web dashboard for the full [Sol

- **Overview dashboard** with live counts across all three Solid Stack components; cards are clickable and link directly to each section
- **Solid Queue** — browse jobs by status (ready, scheduled, claimed, blocked) with filtering by job class, queue name, priority, and time period; manage failed jobs (retry / discard / bulk retry / bulk discard), pause/resume queues, and inspect worker processes; **Bulk selection** checkbox-selects individual jobs for discard or retry; **Discard All** bulk-discards every job matching the current filters in one request; **CSV export** downloads jobs or failed jobs as a CSV file respecting active filters; **Per-queue browser** — click any queue name or size to drill into its ready jobs with per-row and bulk discard
- **Performance statistics page** — `GET /stats` aggregates finished jobs by class name with execution count, avg, p50, p95, min, and max duration; click any column header to sort; defaults to p95 descending
- **Job history view** — paginated list of all finished jobs with class name, queue, duration, and finished-at time; filterable by queue (click a badge), class substring, and time period; CSV export respects active filters
- **Scheduled job management** — "Run Now" and offset buttons (+1h / +24h / +7d) per row update the scheduled time inline via Turbo Stream; "Run All Now (N)" in the header back-dates all matching executions at once
- **Recurring task list** — enumerates all `SolidQueue::RecurringTask` records with cron schedule, job class or command, queue, next-run and last-run times, and a static/dynamic badge; each row has a "Run Now" button that immediately enqueues the task
Expand Down
1 change: 0 additions & 1 deletion ROADMAP.md
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,6 @@ The path to v1.0.0 is staged: first achieve feature parity with `solid_queue_das
> _Give operators the data they need to detect problems before users do._

### Added
- **Performance statistics page** — per-class aggregates: execution count, average duration, p50, p95, min, max; sortable by p95
- **Slow job detection** — configurable threshold (`slow_job_threshold`); slow jobs surfaced on the dashboard and performance page
- **Dashboard stats** — add "done (1 h)", "done (24 h)", slow job count, and process health (healthy / stale) to the overview cards
- **Throughput sparkline** — 12-hour rolling bar chart of completed jobs on the dashboard
Expand Down
4 changes: 4 additions & 0 deletions app/assets/stylesheets/solid_stack_web/_04_table.css
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,10 @@
.sqw-actions { text-align: right; white-space: nowrap; }
.sqw-actions form { display: inline; }

.sqw-table th a { color: inherit; text-decoration: none; }
.sqw-table th a:hover { color: var(--text); }
.sqw-sort-indicator { margin-left: 0.2rem; }

.sqw-empty {
background: var(--surface);
border: 1px solid var(--border);
Expand Down
39 changes: 39 additions & 0 deletions app/controllers/solid_stack_web/stats_controller.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,39 @@
module SolidStackWeb
class StatsController < ApplicationController
SORTABLE_COLUMNS = %w[class_name count avg p50 p95 min max].freeze

def index
@sort = params[:sort].presence_in(SORTABLE_COLUMNS) || "p95"
@direction = params[:direction] == "asc" ? "asc" : "desc"

jobs = SolidQueue::Job.where.not(finished_at: nil).select(:class_name, :created_at, :finished_at)
@stats = build_stats(jobs)
@stats.sort_by! { |row| row[@sort.to_sym] || 0 }
@stats.reverse! if @direction == "desc"
end

private

def build_stats(jobs)
jobs.group_by(&:class_name).map do |class_name, group|
durations = group.map { |j| (j.finished_at - j.created_at).to_f }.sort
count = durations.size
{
class_name: class_name,
count: count,
avg: durations.sum / count,
min: durations.first,
max: durations.last,
p50: percentile(durations, 50),
p95: percentile(durations, 95)
}
end
end

def percentile(sorted, pct)
return 0.0 if sorted.empty?
k = (sorted.size - 1) * pct / 100.0
sorted[k.floor] + (sorted[k.ceil] - sorted[k.floor]) * (k - k.floor)
end
end
end
6 changes: 4 additions & 2 deletions app/helpers/solid_stack_web/application_helper.rb
Original file line number Diff line number Diff line change
@@ -1,9 +1,11 @@
module SolidStackWeb
module ApplicationHelper
def format_duration(seconds)
return "—" if seconds.nil?
return "#{(seconds * 1000).round}ms" if seconds < 1
s = seconds.to_i
return "#{s}s" if s < 60
return "#{s / 60}m #{s % 60}s" if s < 3600
return "#{sprintf("%g", seconds.round(1))}s" if s < 60
return "#{s / 60}m #{s % 60}s" if s < 3600

"#{s / 3600}h #{(s % 3600) / 60}m"
end
Expand Down
2 changes: 2 additions & 0 deletions app/views/layouts/solid_stack_web/application.html.erb
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,8 @@
class: "sqw-subnav__link#{" sqw-subnav__link--active" if controller_name == "queues"}" %>
<%= link_to "Recurring", recurring_tasks_path,
class: "sqw-subnav__link#{" sqw-subnav__link--active" if controller_name == "recurring_tasks"}" %>
<%= link_to "Stats", stats_path,
class: "sqw-subnav__link#{" sqw-subnav__link--active" if controller_name == "stats"}" %>
<%= link_to "History", history_path,
class: "sqw-subnav__link#{" sqw-subnav__link--active" if controller_name == "history"}" %>
<%= link_to "Processes", processes_path,
Expand Down
48 changes: 48 additions & 0 deletions app/views/solid_stack_web/stats/index.html.erb
Original file line number Diff line number Diff line change
@@ -0,0 +1,48 @@
<div class="sqw-page-header">
<h1 class="sqw-page-title">Performance Stats</h1>
</div>

<% if @stats.any? %>
<table class="sqw-table">
<thead>
<tr>
<% [
["class_name", "Job Class"],
["count", "Executions"],
["avg", "Avg"],
["p50", "p50"],
["p95", "p95"],
["min", "Min"],
["max", "Max"]
].each do |col, label| %>
<th>
<% next_dir = (@sort == col && @direction == "desc") ? "asc" : "desc" %>
<%= link_to stats_path(sort: col, direction: next_dir) do %>
<%= label %>
<% if @sort == col %>
<span class="sqw-sort-indicator"><%= @direction == "desc" ? "↓" : "↑" %></span>
<% end %>
<% end %>
</th>
<% end %>
</tr>
</thead>
<tbody>
<% @stats.each do |row| %>
<tr>
<td class="sqw-monospace"><%= row[:class_name] %></td>
<td><%= row[:count] %></td>
<td class="sqw-muted"><%= format_duration(row[:avg]) %></td>
<td class="sqw-muted"><%= format_duration(row[:p50]) %></td>
<td><strong><%= format_duration(row[:p95]) %></strong></td>
<td class="sqw-muted"><%= format_duration(row[:min]) %></td>
<td class="sqw-muted"><%= format_duration(row[:max]) %></td>
</tr>
<% end %>
</tbody>
</table>
<% else %>
<div class="sqw-empty">
<p>No finished jobs yet.</p>
</div>
<% end %>
1 change: 1 addition & 0 deletions config/routes.rb
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,7 @@

resources :processes, only: [:index]

get "stats", to: "stats#index", as: :stats
get "history", to: "history#index", as: :history
get "cache", to: "cache#index", as: :cache
get "cable", to: "cable#index", as: :cable
Expand Down
64 changes: 64 additions & 0 deletions spec/requests/solid_stack_web/stats_spec.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,64 @@
require "rails_helper"

RSpec.describe "Stats", type: :request do
let(:engine_root) { "/solid_stack" }

def create_finished(class_name: "MyJob", duration: 10)
SolidQueue::Job.create!(
class_name:,
queue_name: "default",
priority: 0,
created_at: duration.seconds.ago,
finished_at: Time.current
)
end

describe "GET /stats" do
it "returns 200" do
get "#{engine_root}/stats"
expect(response).to have_http_status(:ok)
end

it "shows an empty state when no finished jobs exist" do
get "#{engine_root}/stats"
expect(response.body).to include("No finished jobs")
end

it "aggregates finished jobs by class name" do
3.times { create_finished(class_name: "SlowJob", duration: 30) }
create_finished(class_name: "FastJob", duration: 1)
get "#{engine_root}/stats"
expect(response.body).to include("SlowJob")
expect(response.body).to include("FastJob")
end

it "does not include unfinished jobs" do
SolidQueue::Job.create!(class_name: "PendingJob", queue_name: "default", priority: 0)
get "#{engine_root}/stats"
expect(response.body).not_to include("PendingJob")
end

it "defaults to sorting by p95 descending" do
create_finished(class_name: "MyJob")
get "#{engine_root}/stats"
expect(response.body).to include("p95")
end

it "accepts a sort param" do
create_finished(class_name: "MyJob")
get "#{engine_root}/stats", params: { sort: "count", direction: "asc" }
expect(response).to have_http_status(:ok)
end

it "ignores invalid sort params" do
get "#{engine_root}/stats", params: { sort: "DROP TABLE", direction: "evil" }
expect(response).to have_http_status(:ok)
end

it "shows formatted durations" do
create_finished(class_name: "MyJob", duration: 10)
get "#{engine_root}/stats"
expect(response.body).to match(/\d+(\.\d+)?s|ms/)
end
end
end