diff --git a/CHANGELOG.md b/CHANGELOG.md index 97c3472..3ff37ff 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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 diff --git a/README.md b/README.md index 7e5e8fc..206974f 100644 --- a/README.md +++ b/README.md @@ -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 diff --git a/ROADMAP.md b/ROADMAP.md index ca31c9b..4244fa8 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -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 diff --git a/app/assets/stylesheets/solid_stack_web/_04_table.css b/app/assets/stylesheets/solid_stack_web/_04_table.css index 5381d07..9130597 100644 --- a/app/assets/stylesheets/solid_stack_web/_04_table.css +++ b/app/assets/stylesheets/solid_stack_web/_04_table.css @@ -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); diff --git a/app/controllers/solid_stack_web/stats_controller.rb b/app/controllers/solid_stack_web/stats_controller.rb new file mode 100644 index 0000000..4aabd68 --- /dev/null +++ b/app/controllers/solid_stack_web/stats_controller.rb @@ -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 diff --git a/app/helpers/solid_stack_web/application_helper.rb b/app/helpers/solid_stack_web/application_helper.rb index 8ade74f..8cf9135 100644 --- a/app/helpers/solid_stack_web/application_helper.rb +++ b/app/helpers/solid_stack_web/application_helper.rb @@ -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 diff --git a/app/views/layouts/solid_stack_web/application.html.erb b/app/views/layouts/solid_stack_web/application.html.erb index 672c61a..1df5f2e 100644 --- a/app/views/layouts/solid_stack_web/application.html.erb +++ b/app/views/layouts/solid_stack_web/application.html.erb @@ -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, diff --git a/app/views/solid_stack_web/stats/index.html.erb b/app/views/solid_stack_web/stats/index.html.erb new file mode 100644 index 0000000..00de830 --- /dev/null +++ b/app/views/solid_stack_web/stats/index.html.erb @@ -0,0 +1,48 @@ +
+

Performance Stats

+
+ +<% if @stats.any? %> + + + + <% [ + ["class_name", "Job Class"], + ["count", "Executions"], + ["avg", "Avg"], + ["p50", "p50"], + ["p95", "p95"], + ["min", "Min"], + ["max", "Max"] + ].each do |col, label| %> + + <% end %> + + + + <% @stats.each do |row| %> + + + + + + + + + + <% end %> + +
+ <% next_dir = (@sort == col && @direction == "desc") ? "asc" : "desc" %> + <%= link_to stats_path(sort: col, direction: next_dir) do %> + <%= label %> + <% if @sort == col %> + <%= @direction == "desc" ? "↓" : "↑" %> + <% end %> + <% end %> +
<%= row[:class_name] %><%= row[:count] %><%= format_duration(row[:avg]) %><%= format_duration(row[:p50]) %><%= format_duration(row[:p95]) %><%= format_duration(row[:min]) %><%= format_duration(row[:max]) %>
+<% else %> +
+

No finished jobs yet.

+
+<% end %> \ No newline at end of file diff --git a/config/routes.rb b/config/routes.rb index aef6aba..aa4d1ef 100644 --- a/config/routes.rb +++ b/config/routes.rb @@ -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 diff --git a/spec/requests/solid_stack_web/stats_spec.rb b/spec/requests/solid_stack_web/stats_spec.rb new file mode 100644 index 0000000..b9b44a5 --- /dev/null +++ b/spec/requests/solid_stack_web/stats_spec.rb @@ -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