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 @@ +
| + <% 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 %> + | + <% 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]) %> | +
No finished jobs yet.
+