diff --git a/CHANGELOG.md b/CHANGELOG.md
index 88b7509..33882ef 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 table columns — Jobs, Failed Jobs, and History tables now support server-side sorting via `?sort=` and `?direction=` params; click any column header to sort ascending or descending; sort state is preserved across filter changes, status tab switches, and period buttons; a `sort_header_th` helper generates accessible `
` elements with `aria-sort` and direction indicators (↑/↓)
+
## [1.2.0] - 2026-05-27
### Added
diff --git a/README.md b/README.md
index 5cedbc7..b793036 100644
--- a/README.md
+++ b/README.md
@@ -39,16 +39,16 @@ SolidQueueWeb surfaces all of this in a browser UI available at any route you ch
- **Dashboard** — stat cards showing counts for ready, scheduled, running, blocked, and failed jobs, plus queues, recurring tasks, and processes; "Done (1h)" and "Done (24h)" throughput cards; a "Throughput — Last 12 Hours" bar chart (blue) and a "Queue Depth — Last 12 Hours" bar chart (purple) showing hourly snapshots of active job count; pure CSS, no charting library; auto-refreshes every 5 seconds
- **Queues** — all queues sorted by name with size; oldest ready job latency (color-coded, with UTC timestamp tooltip); Done (24h) and Failed (24h) throughput counts; a mini 12-bar failure rate sparkline per queue showing failure % per hour over the last 12 hours; pause/resume controls
-- **Jobs** — filterable by status (ready, scheduled, claimed, blocked, failed), queue, and priority; search by job class name with dynamic auto-submit; time-based period filter (1 h / 24 h / 7 d); discard individual or all jobs; Turbo Frame navigation so only the table updates on filter or search; auto-refreshes every 10 seconds
+- **Jobs** — filterable by status (ready, scheduled, claimed, blocked, failed), queue, and priority; search by job class name with dynamic auto-submit; time-based period filter (1 h / 24 h / 7 d); sortable by class, queue, priority, and enqueued-at; sort state is preserved across filter, period, and status tab changes; discard individual or all jobs; Turbo Frame navigation so only the table updates on filter or search; auto-refreshes every 10 seconds
- **Scheduled job management** — reschedule a scheduled job to run immediately ("Run Now") or push its `scheduled_at` forward by 1 h, 24 h, or 7 d; Turbo Stream responses update the row in place; "Run All Now" bulk action promotes every scheduled job in the current filtered view in a single operation
-- **Failed jobs** — list of failed executions with error details; search by class name; filter by queue; time-based period filter; retry or discard individually or in bulk; bulk retry with configurable stagger (+5s / +10s / +30s / +1m) to avoid thundering herd on recovery
+- **Failed jobs** — list of failed executions with error details; search by class name; filter by queue; time-based period filter; sortable by class, queue, and failed-at; retry or discard individually or in bulk; bulk retry with configurable stagger (+5s / +10s / +30s / +1m) to avoid thundering herd on recovery
- **Job detail** — full arguments, timestamps, blocked-until date, and error backtrace; action buttons based on job status; failed jobs show an editable arguments textarea so you can correct a bad payload and retry in one step without redeploying
- **Queue management** — pause and resume individual queues; queue-scoped job list with status filter, search, and discard
- **Recurring tasks** — all configured recurring tasks with cron schedule, next run time, last run time, and static/dynamic classification; "Run Now" button enqueues a task immediately without waiting for its next scheduled run
- **Processes** — workers, dispatchers, and supervisors with heartbeat health status; auto-refreshes every 10 seconds
- **Global search** — search across all job statuses at once by class name substring; results grouped by status with match count and direct links to filtered views; native datalist autocomplete pre-populated from all known job classes; auto-submits on selection
- **Targeted bulk actions** — checkboxes on the jobs and failed jobs lists for selecting individual rows; selection bar shows count and action buttons ("Discard Selected" for jobs, "Retry Selected" / "Discard Selected" for failed jobs); select-all checkbox in the table header
-- **Job history** — browsable list of all finished jobs with class name, queue, duration, and finished timestamp; filterable by period (1h / 24h / 7d), queue, and class name search; Done (1h) / Done (24h) dashboard cards link directly to the filtered history view; auto-refreshes every 10 seconds
+- **Job history** — browsable list of all finished jobs with class name, queue, duration, and finished timestamp; filterable by period (1h / 24h / 7d), queue, and class name search; sortable by class, queue, and finished-at; Done (1h) / Done (24h) dashboard cards link directly to the filtered history view; auto-refreshes every 10 seconds
- **Dark mode** — ☽/☀ toggle in the header; preference persists to `localStorage` and defaults to the OS `prefers-color-scheme` on first visit; zero extra dependencies — implemented via CSS custom properties and a small Stimulus controller
- **Dashboard quick actions** — "Retry All Failed" and "Discard All Blocked" cards appear on the dashboard only when the respective count is non-zero; one-click bulk operations with confirm dialogs, keeping the dashboard clean when everything is healthy
- **CSV export** — "Export CSV" button on the jobs, failed jobs, and history pages downloads all records matching the current filters; columns are tailored per view
diff --git a/ROADMAP.md b/ROADMAP.md
index a9c6b3a..4a11a0d 100644
--- a/ROADMAP.md
+++ b/ROADMAP.md
@@ -13,7 +13,6 @@ Pull requests for any of these are welcome. See [Contributing](README.md#contrib
| Feature | Notes |
|---|---|
-| **Sortable table columns** | Server-side `?sort=class_name&dir=asc` on jobs, failed jobs, and history. |
| **Configurable display timezone** | `config.time_zone = "America/New_York"` — all timestamps rendered in the configured zone rather than UTC. |
| **Sticky filter preferences** | Persist last-used status/period to `localStorage` so filters survive page reloads. |
diff --git a/app/assets/stylesheets/solid_queue_web/_04_table.css b/app/assets/stylesheets/solid_queue_web/_04_table.css
index f54c226..49743fc 100644
--- a/app/assets/stylesheets/solid_queue_web/_04_table.css
+++ b/app/assets/stylesheets/solid_queue_web/_04_table.css
@@ -56,4 +56,8 @@ tbody tr:hover { background: var(--bg); }
.sqd-row--slow { background: rgba(253, 126, 20, 0.07); }
.sqd-row--slow:hover { background: rgba(253, 126, 20, 0.13); }
-.sqd-slow-duration { color: var(--warning); font-weight: 600; }
\ No newline at end of file
+.sqd-slow-duration { color: var(--warning); font-weight: 600; }
+
+th a { color: inherit; text-decoration: none; }
+th a:hover { color: var(--primary); }
+.sqd-sort-indicator { margin-left: 0.2rem; }
\ No newline at end of file
diff --git a/app/controllers/solid_queue_web/failed_jobs_controller.rb b/app/controllers/solid_queue_web/failed_jobs_controller.rb
index 9a4634f..656fe3d 100644
--- a/app/controllers/solid_queue_web/failed_jobs_controller.rb
+++ b/app/controllers/solid_queue_web/failed_jobs_controller.rb
@@ -4,7 +4,7 @@ class FailedJobsController < ApplicationController
def index
respond_to do |format|
- format.html { @pagy, @failed_jobs = pagy(filtered_scope.order(created_at: :desc)) }
+ format.html { @pagy, @failed_jobs = pagy(filtered_scope.order(sort_expression)) }
format.csv do
send_data failed_jobs_csv,
filename: "failed-jobs-#{Date.today}.csv",
@@ -25,7 +25,7 @@ def destroy
def failed_jobs_csv
CSV.generate(headers: true) do |csv|
csv << %w[id class_name queue_name error_class error_message failed_at]
- filtered_scope.order(created_at: :desc).each do |execution|
+ filtered_scope.order(sort_expression).each do |execution|
job = execution.job
error = execution.error || {}
csv << [job.id, job.class_name, job.queue_name,
@@ -42,10 +42,25 @@ def perform_discard(executions)
notice: "#{jobs.size} #{"job".pluralize(jobs.size)} discarded."
end
+ 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 set_filter_params
- @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) || "created_at"
+ @direction = params[:direction] == "asc" ? "asc" : "desc"
end
def filtered_scope
diff --git a/app/controllers/solid_queue_web/history_controller.rb b/app/controllers/solid_queue_web/history_controller.rb
index 2dec639..d279633 100644
--- a/app/controllers/solid_queue_web/history_controller.rb
+++ b/app/controllers/solid_queue_web/history_controller.rb
@@ -1,9 +1,11 @@
module SolidQueueWeb
class HistoryController < ApplicationController
def index
- @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"
scope = SolidQueue::Job.where.not(finished_at: nil)
scope = scope.where(queue_name: @queue) if @queue.present?
@@ -11,7 +13,7 @@ def index
scope = scope.where("finished_at >= ?", PERIOD_DURATIONS[@period].ago) if @period.present?
respond_to do |format|
- format.html { @pagy, @jobs = pagy(scope.order(finished_at: :desc)) }
+ format.html { @pagy, @jobs = pagy(scope.order(sort_expression)) }
format.csv do
send_data history_csv(scope),
filename: "job-history-#{Date.today}.csv",
@@ -22,10 +24,18 @@ def index
private
+ def sortable_columns
+ %w[class_name queue_name finished_at]
+ end
+
+ def sort_expression
+ Arel.sql("#{@sort} #{@direction == 'asc' ? 'ASC' : 'DESC'}")
+ end
+
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.order(sort_expression).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_queue_web/jobs_controller.rb b/app/controllers/solid_queue_web/jobs_controller.rb
index d824ab5..1a3a635 100644
--- a/app/controllers/solid_queue_web/jobs_controller.rb
+++ b/app/controllers/solid_queue_web/jobs_controller.rb
@@ -1,22 +1,16 @@
module SolidQueueWeb
class JobsController < ApplicationController
- def index
- @status = params[:status].presence_in(Job::STATUSES) || "ready"
- @search = params[:q].presence
- @period = params[:period].presence_in(PERIOD_DURATIONS.keys)
- @priority = params[:priority].presence
-
- scope = Job::EXECUTION_MODELS[@status].includes(:job)
- scope = scope.references(:job).where("solid_queue_jobs.class_name LIKE ?", "%#{@search}%") if @search.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 = scope.order(created_at: :desc)
+ before_action :set_filters, only: [:index, :destroy]
- @priority_options = Job::EXECUTION_MODELS[@status].joins(:job)
- .distinct.pluck("solid_queue_jobs.priority").sort
+ def index
+ scope = job_scope
respond_to do |format|
- format.html { @pagy, @jobs = pagy(scope) }
+ format.html do
+ @priority_options = Job::EXECUTION_MODELS[@status].joins(:job)
+ .distinct.pluck("solid_queue_jobs.priority").sort
+ @pagy, @jobs = pagy(scope)
+ end
format.csv do
send_data jobs_csv(scope),
filename: "jobs-#{@status}-#{Date.today}.csv",
@@ -33,9 +27,6 @@ def show
end
def destroy
- @status = params[:status]
- @period = params[:period].presence_in(PERIOD_DURATIONS.keys)
- @priority = params[:priority].presence
model = Job.execution_model_for!(@status)
if params[:id]
@execution = model.find(params[:id])
@@ -43,23 +34,52 @@ def destroy
@remaining_count = filtered_scope(model).count
respond_to do |format|
format.turbo_stream
- format.html { redirect_to jobs_path(status: @status, period: @period), notice: "Job discarded." }
+ format.html { redirect_to jobs_return_path, notice: "Job discarded." }
end
else
jobs = filtered_scope(model).map(&:job)
model.discard_all_from_jobs(jobs)
- redirect_to jobs_path(status: @status, period: @period),
- notice: "#{jobs.size} #{"job".pluralize(jobs.size)} discarded."
+ redirect_to jobs_return_path, notice: "#{jobs.size} #{"job".pluralize(jobs.size)} discarded."
end
rescue ArgumentError => e
- redirect_to jobs_path(status: @status, period: @period), alert: e.message
+ redirect_to jobs_return_path, alert: e.message
rescue => e
- redirect_to jobs_path(status: @status, period: @period),
- alert: "Could not discard #{params[:id] ? "job" : "jobs"}: #{e.message}"
+ redirect_to jobs_return_path, alert: "Could not discard #{params[:id] ? "job" : "jobs"}: #{e.message}"
end
private
+ def set_filters
+ @status = params[:status].presence_in(Job::STATUSES) || "ready"
+ @search = params[:q].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 job_scope
+ 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.created_at >= ?", PERIOD_DURATIONS[@period].ago) if @period.present?
+ scope = scope.where("solid_queue_jobs.priority = ?", @priority.to_i) if @priority.present?
+ scope
+ 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 jobs_csv(scope)
CSV.generate(headers: true) do |csv|
csv << %w[id class_name queue_name status priority enqueued_at]
@@ -70,6 +90,10 @@ def jobs_csv(scope)
end
end
+ def jobs_return_path
+ jobs_path(status: @status, period: @period, sort: @sort, direction: @direction)
+ end
+
def filtered_scope(model)
scope = model.includes(:job)
scope = scope.references(:job).where("solid_queue_jobs.created_at >= ?", PERIOD_DURATIONS[@period].ago) if @period.present?
diff --git a/app/helpers/solid_queue_web/application_helper.rb b/app/helpers/solid_queue_web/application_helper.rb
index 8826e23..e4e1f2b 100644
--- a/app/helpers/solid_queue_web/application_helper.rb
+++ b/app/helpers/solid_queue_web/application_helper.rb
@@ -1,5 +1,18 @@
module SolidQueueWeb
module ApplicationHelper
+ 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: "sqd-sort-indicator") : nil
+ tag_opts = { scope: "col" }
+ tag_opts[:"aria-sort"] = current_dir == "asc" ? "ascending" : "descending" 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 inline_styles
dir = SolidQueueWeb::Engine.root.join("app/assets/stylesheets/solid_queue_web")
css = dir.glob("_*.css").sort.map(&:read).join("\n")
diff --git a/app/views/solid_queue_web/failed_jobs/index.html.erb b/app/views/solid_queue_web/failed_jobs/index.html.erb
index 7a82e37..3ebf2c6 100644
--- a/app/views/solid_queue_web/failed_jobs/index.html.erb
+++ b/app/views/solid_queue_web/failed_jobs/index.html.erb
@@ -34,6 +34,8 @@
<% end %>
+
+
@@ -86,10 +88,11 @@
data-action="change->selection#selectAll"
aria-label="Select all failed jobs">