diff --git a/CHANGELOG.md b/CHANGELOG.md index 95ef131..28e6012 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 + +- Sortable columns on jobs, failed jobs, and history — server-side `?sort=&direction=` params; jobs sortable by class, queue, priority, enqueued at; failed jobs by class, queue, failed at; history by class, queue, finished at; sort state is preserved across filter and period changes + ## [1.1.0] - 2026-05-27 ### Added diff --git a/README.md b/README.md index 380fa4d..4528be0 100644 --- a/README.md +++ b/README.md @@ -143,17 +143,17 @@ The dashboard is designed to be mounted behind your application's existing authe ### Features - **Overview dashboard** — live counts across all queue statuses; done (1h/24h), healthy/stale process counts, and optionally slow jobs (when `slow_job_threshold` is configured); 12-hour throughput sparkline and a 12-hour failures sparkline (red bars) with per-bar hover tooltips — failure spikes visible before clicking into the failed jobs list -- **Job browser** — browse jobs by status (ready, scheduled, claimed, blocked) with filtering by job class, queue name, priority, and time period; **Discard All** bulk-discards every job matching the current filters in one request; **CSV export** downloads jobs respecting active filters +- **Job browser** — browse jobs by status (ready, scheduled, claimed, blocked) with filtering by job class, queue name, priority, and time period; sortable by class, queue, priority, and enqueued-at; sort state is preserved across filter and period changes; **Discard All** bulk-discards every job matching the current filters in one request; **CSV export** downloads jobs respecting active filters - **Bulk selection** — checkbox-select individual jobs for discard; select-all support - **Per-queue browser** — click any queue name or size to drill into its ready jobs with per-row and bulk discard; pause/resume controls on the queue page - **Queue depth sparklines** — Queues index shows a 12-hour depth chart per queue; each bar is the ready-job count at an hourly snapshot with an instant hover tooltip - **Job detail page** — full arguments (pretty-printed JSON), queue, priority, enqueued time, Active Job ID, concurrency key, scheduled/blocked-until metadata, and a Discard button -- **Failed jobs** — list with retry / discard / bulk retry / bulk discard; **Failed job detail page** — full error, backtrace, and an inline JSON argument editor; submit to update arguments and retry in one action +- **Failed jobs** — list with retry / discard / bulk retry / bulk discard; sortable by class, queue, and failed-at; **Failed job detail page** — full error, backtrace, and an inline JSON argument editor; submit to update arguments and retry in one action - **Error frequency report** — `GET /failed_jobs/errors` groups all failed jobs by exception class and message prefix with a count and expandable sample backtrace; links through to a filtered list for each error group - **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)" 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 - **Performance statistics page** — `GET /stats` aggregates finished jobs by class name with execution count, avg, p50, p95, p99, std dev, min, and max duration; click any column header to sort; defaults to p95 descending; high std dev flags inconsistent jobs worth investigating -- **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 +- **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; sortable by class, queue, and finished-at; CSV export respects active filters - **Auto-refresh** — dashboard, jobs, processes, and history views poll automatically; pauses when the tab is hidden or a checkbox is checked; intervals configurable via `dashboard_refresh_interval` and `default_refresh_interval` - **Turbo Stream** job discard — removes the row inline without a full page reload - **Dark mode** — toggle button in the header switches between light and dark palettes; preference persisted in `localStorage`; respects `prefers-color-scheme` on first visit diff --git a/ROADMAP.md b/ROADMAP.md index e2c8347..6c923c3 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 > _Quality-of-life improvements for teams using the dashboard daily._ -- **Sortable columns on jobs, failed jobs, and history** — server-side `?sort=&dir=` params matching the pattern already used for cache entries and stats - **Sticky filter preferences** — persist last-used status, period, and queue filter to `localStorage` so filter state survives page reloads --- diff --git a/app/controllers/solid_stack_web/failed_jobs_controller.rb b/app/controllers/solid_stack_web/failed_jobs_controller.rb index 11c8c1a..c6f7bae 100644 --- a/app/controllers/solid_stack_web/failed_jobs_controller.rb +++ b/app/controllers/solid_stack_web/failed_jobs_controller.rb @@ -1,9 +1,12 @@ module SolidStackWeb class FailedJobsController < ApplicationController def index + @sort = params[:sort].presence_in(sortable_columns) || "created_at" + @direction = params[:direction] == "asc" ? "asc" : "desc" + respond_to do |format| format.html do - scope = ::SolidQueue::FailedExecution.includes(:job).order(created_at: :desc) + scope = ::SolidQueue::FailedExecution.includes(:job).references(:job).order(sort_expression) @error_class = params[:error_class].presence scope = scope.where(id: ids_for_error_class(@error_class)) if @error_class @pagy, @executions = pagy(scope) @@ -43,6 +46,19 @@ def retry private + def sortable_columns + %w[class_name queue_name created_at] + end + + def sort_expression + sql_col = case @sort + when "class_name" then "solid_queue_jobs.class_name" + when "queue_name" then "solid_queue_jobs.queue_name" + else "solid_queue_failed_executions.created_at" + end + Arel.sql("#{sql_col} #{@direction == 'asc' ? 'ASC' : 'DESC'}") + end + def ids_for_error_class(ec) ::SolidQueue::FailedExecution.pluck(:id, :error).filter_map do |id, raw| error = raw.is_a?(Hash) ? raw : JSON.parse(raw) diff --git a/app/controllers/solid_stack_web/history_controller.rb b/app/controllers/solid_stack_web/history_controller.rb index 1738717..3217d23 100644 --- a/app/controllers/solid_stack_web/history_controller.rb +++ b/app/controllers/solid_stack_web/history_controller.rb @@ -16,13 +16,23 @@ def index private def set_filters - @queue = params[:queue].presence - @search = params[:q].presence - @period = params[:period].presence_in(PERIOD_DURATIONS.keys) + @queue = params[:queue].presence + @search = params[:q].presence + @period = params[:period].presence_in(PERIOD_DURATIONS.keys) + @sort = params[:sort].presence_in(sortable_columns) || "finished_at" + @direction = params[:direction] == "asc" ? "asc" : "desc" + end + + def sortable_columns + %w[class_name queue_name finished_at] + end + + def sort_expression + Arel.sql("#{@sort} #{@direction == 'asc' ? 'ASC' : 'DESC'}") end def filtered_scope - scope = SolidQueue::Job.where.not(finished_at: nil).order(finished_at: :desc) + scope = SolidQueue::Job.where.not(finished_at: nil).order(sort_expression) scope = scope.where(queue_name: @queue) if @queue.present? scope = scope.where("class_name LIKE ?", "%#{@search}%") if @search.present? scope = scope.where("finished_at >= ?", PERIOD_DURATIONS[@period].ago) if @period.present? @@ -32,7 +42,7 @@ def filtered_scope def history_csv(scope) CSV.generate(headers: true) do |csv| csv << %w[id class_name queue_name duration_seconds finished_at] - scope.order(finished_at: :desc).each do |job| + scope.each do |job| duration = job.finished_at && job.created_at ? (job.finished_at - job.created_at).round : nil csv << [job.id, job.class_name, job.queue_name, duration, job.finished_at.iso8601] end diff --git a/app/controllers/solid_stack_web/jobs/selections_controller.rb b/app/controllers/solid_stack_web/jobs/selections_controller.rb index ec98d35..ea2e3af 100644 --- a/app/controllers/solid_stack_web/jobs/selections_controller.rb +++ b/app/controllers/solid_stack_web/jobs/selections_controller.rb @@ -10,11 +10,13 @@ def destroy count = SolidQueue::Job.where(id: job_ids).destroy_all.size redirect_to jobs_path( - status: status, - q: params[:q].presence, - queue: params[:queue].presence, - period: params[:period].presence_in(PERIOD_DURATIONS.keys), - priority: params[:priority].presence + status: status, + q: params[:q].presence, + queue: params[:queue].presence, + period: params[:period].presence_in(PERIOD_DURATIONS.keys), + priority: params[:priority].presence, + sort: params[:sort].presence, + direction: params[:direction].presence ), notice: "#{count} #{count == 1 ? "job" : "jobs"} discarded." rescue ArgumentError => e redirect_to jobs_path(status: params[:status]), alert: e.message diff --git a/app/controllers/solid_stack_web/jobs_controller.rb b/app/controllers/solid_stack_web/jobs_controller.rb index 9216cbe..b443190 100644 --- a/app/controllers/solid_stack_web/jobs_controller.rb +++ b/app/controllers/solid_stack_web/jobs_controller.rb @@ -36,13 +36,13 @@ def destroy @notice = "Job discarded." respond_to do |format| - format.html { redirect_to jobs_path(status: @status, q: @search, queue: @queue, period: @period, priority: @priority) } + format.html { redirect_to jobs_path(status: @status, q: @search, queue: @queue, period: @period, priority: @priority, sort: @sort, direction: @direction) } format.turbo_stream end else job_ids = filtered_scope.pluck(:job_id) count = SolidQueue::Job.where(id: job_ids).destroy_all.size - redirect_to jobs_path(status: @status, q: @search, queue: @queue, period: @period, priority: @priority), + redirect_to jobs_path(status: @status, q: @search, queue: @queue, period: @period, priority: @priority, sort: @sort, direction: @direction), notice: "#{count} #{count == 1 ? "job" : "jobs"} discarded." end end @@ -54,10 +54,26 @@ def set_status end def set_filters - @search = params[:q].presence - @queue = params[:queue].presence - @period = params[:period].presence_in(PERIOD_DURATIONS.keys) - @priority = params[:priority].presence + @search = params[:q].presence + @queue = params[:queue].presence + @period = params[:period].presence_in(PERIOD_DURATIONS.keys) + @priority = params[:priority].presence + @sort = params[:sort].presence_in(sortable_columns) || "created_at" + @direction = params[:direction] == "asc" ? "asc" : "desc" + end + + def sortable_columns + %w[class_name queue_name priority created_at] + end + + def sort_expression + sql_col = case @sort + when "class_name" then "solid_queue_jobs.class_name" + when "queue_name" then "solid_queue_jobs.queue_name" + when "priority" then "solid_queue_jobs.priority" + else "#{Job::EXECUTION_MODELS[@status].quoted_table_name}.created_at" + end + Arel.sql("#{sql_col} #{@direction == 'asc' ? 'ASC' : 'DESC'}") end def require_discardable @@ -75,11 +91,11 @@ def jobs_csv end def filtered_scope - scope = Job::EXECUTION_MODELS[@status].includes(:job).order(created_at: :desc) - scope = scope.references(:job).where("solid_queue_jobs.class_name LIKE ?", "%#{@search}%") if @search.present? - scope = scope.references(:job).where("solid_queue_jobs.queue_name = ?", @queue) if @queue.present? - scope = scope.references(:job).where("solid_queue_jobs.created_at >= ?", PERIOD_DURATIONS[@period].ago) if @period.present? - scope = scope.references(:job).where("solid_queue_jobs.priority = ?", @priority.to_i) if @priority.present? + scope = Job::EXECUTION_MODELS[@status].includes(:job).references(:job).order(sort_expression) + scope = scope.where("solid_queue_jobs.class_name LIKE ?", "%#{@search}%") if @search.present? + scope = scope.where("solid_queue_jobs.queue_name = ?", @queue) if @queue.present? + scope = scope.where("solid_queue_jobs.created_at >= ?", PERIOD_DURATIONS[@period].ago) if @period.present? + scope = scope.where("solid_queue_jobs.priority = ?", @priority.to_i) if @priority.present? scope end end diff --git a/app/helpers/solid_stack_web/application_helper.rb b/app/helpers/solid_stack_web/application_helper.rb index 48ecb99..13d9657 100644 --- a/app/helpers/solid_stack_web/application_helper.rb +++ b/app/helpers/solid_stack_web/application_helper.rb @@ -87,6 +87,19 @@ def queue_depth_sparkline_svg(sparkline) end end + def sort_header_th(label, col, url_proc, current_sort:, current_dir:) + is_active = current_sort == col + next_dir = (is_active && current_dir == "desc") ? "asc" : "desc" + indicator = is_active ? content_tag(:span, current_dir == "desc" ? "↓" : "↑", class: "sqw-sort-indicator") : nil + tag_opts = { scope: "col" } + tag_opts[:"aria-sort"] = is_active ? (current_dir == "asc" ? "ascending" : "descending") : nil if is_active + content_tag(:th, **tag_opts) do + link_to(url_proc.call(sort: col, direction: next_dir)) do + safe_join([label, indicator].compact) + end + 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 diff --git a/app/views/solid_stack_web/failed_jobs/index.html.erb b/app/views/solid_stack_web/failed_jobs/index.html.erb index 29f1f2e..e2a56f8 100644 --- a/app/views/solid_stack_web/failed_jobs/index.html.erb +++ b/app/views/solid_stack_web/failed_jobs/index.html.erb @@ -41,10 +41,11 @@
| Job Class | -Queue | + <%= sort_header_th("Job Class", "class_name", sort_url, current_sort: @sort, current_dir: @direction) %> + <%= sort_header_th("Queue", "queue_name", sort_url, current_sort: @sort, current_dir: @direction) %>Duration | -Finished At | + <%= sort_header_th("Finished At", "finished_at", sort_url, current_sort: @sort, current_dir: @direction) %>
|---|