From 26775cffca1d8a1a670ffb00771efb9fa257fcfd Mon Sep 17 00:00:00 2001 From: Chuck Smith Date: Wed, 27 May 2026 09:53:51 -0400 Subject: [PATCH 1/3] feat: sortable columns on jobs, failed jobs, and history (#78) Adds server-side ?sort=&direction= params to three queue list views. Sort state is preserved across filter changes, tab switches, and period buttons. A shared sort_header_th helper generates the with aria-sort and direction indicator from a URL proc. Co-Authored-By: Claude Sonnet 4.6 --- CHANGELOG.md | 4 ++ .../solid_stack_web/failed_jobs_controller.rb | 18 ++++++++- .../solid_stack_web/history_controller.rb | 20 +++++++--- .../jobs/selections_controller.rb | 12 +++--- .../solid_stack_web/jobs_controller.rb | 38 ++++++++++++------ .../solid_stack_web/application_helper.rb | 13 +++++++ .../failed_jobs/index.html.erb | 7 ++-- .../solid_stack_web/history/index.html.erb | 9 +++-- app/views/solid_stack_web/jobs/index.html.erb | 39 +++++++++++-------- .../solid_stack_web/failed_jobs_spec.rb | 25 ++++++++++++ spec/requests/solid_stack_web/history_spec.rb | 25 ++++++++++++ spec/requests/solid_stack_web/jobs_spec.rb | 32 ++++++++++++++- 12 files changed, 195 insertions(+), 47 deletions(-) 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/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_url = ->(p) { failed_jobs_path(error_class: @error_class, **p) } %> + <%= 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) %> Error - Failed At + <%= sort_header_th("Failed At", "created_at", sort_url, current_sort: @sort, current_dir: @direction) %> Actions diff --git a/app/views/solid_stack_web/history/index.html.erb b/app/views/solid_stack_web/history/index.html.erb index 3e73bcb..c1e6738 100644 --- a/app/views/solid_stack_web/history/index.html.erb +++ b/app/views/solid_stack_web/history/index.html.erb @@ -15,6 +15,8 @@ <% end %> + + @@ -44,11 +46,12 @@
+ <% sort_url = ->(p) { history_path(queue: @queue, q: @search, period: @period, **p) } %> - - + <%= 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) %> - + <%= sort_header_th("Finished At", "finished_at", sort_url, current_sort: @sort, current_dir: @direction) %> diff --git a/app/views/solid_stack_web/jobs/index.html.erb b/app/views/solid_stack_web/jobs/index.html.erb index 63edb6c..546e41c 100644 --- a/app/views/solid_stack_web/jobs/index.html.erb +++ b/app/views/solid_stack_web/jobs/index.html.erb @@ -1,7 +1,7 @@

Jobs

- <%= link_to "Export CSV", jobs_path(format: :csv, status: @status, q: @search, queue: @queue, period: @period, priority: @priority), + <%= link_to "Export CSV", jobs_path(format: :csv, status: @status, q: @search, queue: @queue, period: @period, priority: @priority, sort: @sort, direction: @direction), class: "sqw-btn sqw-btn--muted sqw-btn--sm", data: { turbo: false } %> <% if @status == "scheduled" && @executions&.any? %> <%= button_to "Run All Now (#{@pagy.count})", @@ -24,7 +24,7 @@
<% SolidStackWeb::Job::TAB_LABELS.each do |status, label| %> - <%= link_to label, jobs_path(status: status, q: @search, queue: @queue, period: @period, priority: @priority), + <%= link_to label, jobs_path(status: status, q: @search, queue: @queue, period: @period, priority: @priority, sort: @sort, direction: @direction), class: "sqw-tab #{"sqw-tab--active" if @status == status}" %> <% end %>
@@ -35,6 +35,8 @@
<%= hidden_field_tag :status, @status %> <%= hidden_field_tag :period, @period %> + <%= hidden_field_tag :sort, @sort %> + <%= hidden_field_tag :direction, @direction %> @@ -57,16 +59,16 @@ <% end %> <% if @search.present? || @queue.present? || @priority.present? %> - <%= link_to "Clear", jobs_path(status: @status, period: @period), class: "sqw-btn sqw-btn--muted sqw-btn--sm" %> + <%= link_to "Clear", jobs_path(status: @status, period: @period, sort: @sort, direction: @direction), class: "sqw-btn sqw-btn--muted sqw-btn--sm" %> <% end %>
- <%= link_to "All", jobs_path(status: @status, q: @search, queue: @queue, priority: @priority), + <%= link_to "All", jobs_path(status: @status, q: @search, queue: @queue, priority: @priority, sort: @sort, direction: @direction), class: "sqw-period-btn #{"sqw-period-btn--active" if @period.nil?}" %> - <%= link_to "1h", jobs_path(status: @status, q: @search, queue: @queue, priority: @priority, period: "1h"), + <%= link_to "1h", jobs_path(status: @status, q: @search, queue: @queue, priority: @priority, period: "1h", sort: @sort, direction: @direction), class: "sqw-period-btn #{"sqw-period-btn--active" if @period == "1h"}" %> - <%= link_to "24h", jobs_path(status: @status, q: @search, queue: @queue, priority: @priority, period: "24h"), + <%= link_to "24h", jobs_path(status: @status, q: @search, queue: @queue, priority: @priority, period: "24h", sort: @sort, direction: @direction), class: "sqw-period-btn #{"sqw-period-btn--active" if @period == "24h"}" %> - <%= link_to "7d", jobs_path(status: @status, q: @search, queue: @queue, priority: @priority, period: "7d"), + <%= link_to "7d", jobs_path(status: @status, q: @search, queue: @queue, priority: @priority, period: "7d", sort: @sort, direction: @direction), class: "sqw-period-btn #{"sqw-period-btn--active" if @period == "7d"}" %>
@@ -77,11 +79,13 @@ <% if SolidStackWeb::Job::DISCARDABLE.include?(@status) %> <%= form_with url: job_selection_path, method: :delete, id: "job-selection-form", data: { turbo_confirm: "Discard selected jobs? This cannot be undone." } do |f| %> - <%= f.hidden_field :status, value: @status %> - <%= f.hidden_field :q, value: @search %> - <%= f.hidden_field :queue, value: @queue %> - <%= f.hidden_field :period, value: @period %> - <%= f.hidden_field :priority, value: @priority %> + <%= f.hidden_field :status, value: @status %> + <%= f.hidden_field :q, value: @search %> + <%= f.hidden_field :queue, value: @queue %> + <%= f.hidden_field :period, value: @period %> + <%= f.hidden_field :priority, value: @priority %> + <%= f.hidden_field :sort, value: @sort %> + <%= f.hidden_field :direction, value: @direction %> <% end %>
- - - + <% sort_url = ->(p) { jobs_path(status: @status, q: @search, queue: @queue, period: @period, priority: @priority, **p) } %> + <%= 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) %> + <%= sort_header_th("Priority", "priority", sort_url, current_sort: @sort, current_dir: @direction) %> + <%= sort_header_th("Enqueued At", "created_at", sort_url, current_sort: @sort, current_dir: @direction) %> <% if @status == "scheduled" %><% end %> @@ -140,7 +145,7 @@ <% end %> <% end %> <% if %w[ready scheduled blocked].include?(@status) %> - <%= button_to "Discard", job_path(execution, status: @status, q: @search, queue: @queue, period: @period, priority: @priority), + <%= button_to "Discard", job_path(execution, status: @status, q: @search, queue: @queue, period: @period, priority: @priority, sort: @sort, direction: @direction), method: :delete, class: "sqw-btn sqw-btn--danger sqw-btn--sm", data: { turbo_confirm: "Discard this job?" } %> <% end %> diff --git a/spec/requests/solid_stack_web/failed_jobs_spec.rb b/spec/requests/solid_stack_web/failed_jobs_spec.rb index 0c1eda3..65eed69 100644 --- a/spec/requests/solid_stack_web/failed_jobs_spec.rb +++ b/spec/requests/solid_stack_web/failed_jobs_spec.rb @@ -224,4 +224,29 @@ def create_failed(class_name: "FailingJob", queue_name: "default") expect(response).to have_http_status(:ok) end end + + describe "GET /failed_jobs?sort=" do + it "accepts sort by class_name" do + create_failed + get "#{engine_root}/failed_jobs", params: { sort: "class_name", direction: "asc" } + expect(response).to have_http_status(:ok) + end + + it "accepts sort by queue_name" do + create_failed + get "#{engine_root}/failed_jobs", params: { sort: "queue_name", direction: "desc" } + expect(response).to have_http_status(:ok) + end + + it "accepts sort by created_at" do + create_failed + get "#{engine_root}/failed_jobs", params: { sort: "created_at", direction: "asc" } + expect(response).to have_http_status(:ok) + end + + it "ignores invalid sort params" do + get "#{engine_root}/failed_jobs", params: { sort: "DROP TABLE", direction: "evil" } + expect(response).to have_http_status(:ok) + end + end end diff --git a/spec/requests/solid_stack_web/history_spec.rb b/spec/requests/solid_stack_web/history_spec.rb index f8b378c..06108f9 100644 --- a/spec/requests/solid_stack_web/history_spec.rb +++ b/spec/requests/solid_stack_web/history_spec.rb @@ -157,4 +157,29 @@ def finished_job(class_name: "TestJob", queue_name: "default", duration: 30, fin expect(response).to have_http_status(:ok) end end + + describe "GET /history?sort=" do + it "accepts sort by class_name" do + finished_job + get "#{engine_root}/history", params: { sort: "class_name", direction: "asc" } + expect(response).to have_http_status(:ok) + end + + it "accepts sort by queue_name" do + finished_job + get "#{engine_root}/history", params: { sort: "queue_name", direction: "desc" } + expect(response).to have_http_status(:ok) + end + + it "accepts sort by finished_at" do + finished_job + get "#{engine_root}/history", params: { sort: "finished_at", direction: "asc" } + expect(response).to have_http_status(:ok) + end + + it "ignores invalid sort params" do + get "#{engine_root}/history", params: { sort: "DROP TABLE", direction: "evil" } + expect(response).to have_http_status(:ok) + end + end end diff --git a/spec/requests/solid_stack_web/jobs_spec.rb b/spec/requests/solid_stack_web/jobs_spec.rb index a9bc2b4..08854ef 100644 --- a/spec/requests/solid_stack_web/jobs_spec.rb +++ b/spec/requests/solid_stack_web/jobs_spec.rb @@ -130,6 +130,34 @@ def create_ready(class_name: "MyJob", queue_name: "default", priority: 0) end end + describe "GET /jobs?sort=" do + it "accepts sort by class_name" do + create_ready(class_name: "MyJob") + get "#{engine_root}/jobs", params: { sort: "class_name", direction: "asc" } + expect(response).to have_http_status(:ok) + expect(response.body).to include("MyJob") + end + + it "accepts sort by queue_name" do + create_ready + get "#{engine_root}/jobs", params: { sort: "queue_name", direction: "desc" } + expect(response).to have_http_status(:ok) + end + + it "accepts sort by priority" do + create_ready + get "#{engine_root}/jobs", params: { sort: "priority", direction: "asc" } + expect(response).to have_http_status(:ok) + end + + it "defaults to created_at desc for invalid sort column" do + create_ready(class_name: "MyJob") + get "#{engine_root}/jobs", params: { sort: "DROP TABLE", direction: "evil" } + expect(response).to have_http_status(:ok) + expect(response.body).to include("MyJob") + end + end + describe "GET /jobs/:id" do it "returns 200 and shows the job class" do job = create_ready(class_name: "ReportJob") @@ -202,7 +230,7 @@ def create_ready(class_name: "MyJob", queue_name: "default", priority: 0) job = create_ready delete "#{engine_root}/jobs/#{job.ready_execution.id}", params: { status: "ready" } - expect(response).to redirect_to("#{engine_root}/jobs?status=ready") + expect(response).to redirect_to("#{engine_root}/jobs?direction=desc&sort=created_at&status=ready") expect(SolidQueue::Job.exists?(job.id)).to be false end @@ -313,7 +341,7 @@ def create_ready(class_name: "MyJob", queue_name: "default", priority: 0) post "#{engine_root}/jobs/discard_all", params: { status: "ready" } - expect(response).to redirect_to("#{engine_root}/jobs?status=ready") + expect(response).to redirect_to("#{engine_root}/jobs?direction=desc&sort=created_at&status=ready") expect(SolidQueue::ReadyExecution.count).to eq(0) end From 00edc3a66fc74a296c6edc2c844cf50727081805 Mon Sep 17 00:00:00 2001 From: Chuck Smith Date: Wed, 27 May 2026 09:54:57 -0400 Subject: [PATCH 2/3] docs: update README for sortable columns on jobs, failed jobs, history Co-Authored-By: Claude Sonnet 4.6 --- README.md | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) 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 From 590478b213cdf51697cd234aeec898aa2c5832c3 Mon Sep 17 00:00:00 2001 From: Chuck Smith Date: Wed, 27 May 2026 09:55:33 -0400 Subject: [PATCH 3/3] chore: remove completed sortable columns item from ROADMAP v1.2 Co-Authored-By: Claude Sonnet 4.6 --- ROADMAP.md | 1 - 1 file changed, 1 deletion(-) 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 ---
Job ClassQueueDurationFinished At
Job ClassQueuePriorityEnqueued AtScheduled AtActions