Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 `<th>` elements with `aria-sort` and direction indicators (↑/↓)

## [1.2.0] - 2026-05-27

### Added
Expand Down
6 changes: 3 additions & 3 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
1 change: 0 additions & 1 deletion ROADMAP.md
Original file line number Diff line number Diff line change
Expand Up @@ -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. |

Expand Down
6 changes: 5 additions & 1 deletion app/assets/stylesheets/solid_queue_web/_04_table.css
Original file line number Diff line number Diff line change
Expand Up @@ -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; }
.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; }
25 changes: 20 additions & 5 deletions app/controllers/solid_queue_web/failed_jobs_controller.rb
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand All @@ -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,
Expand All @@ -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
Expand Down
20 changes: 15 additions & 5 deletions app/controllers/solid_queue_web/history_controller.rb
Original file line number Diff line number Diff line change
@@ -1,17 +1,19 @@
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?
scope = scope.where("class_name LIKE ?", "%#{@search}%") if @search.present?
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",
Expand All @@ -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
Expand Down
70 changes: 47 additions & 23 deletions app/controllers/solid_queue_web/jobs_controller.rb
Original file line number Diff line number Diff line change
@@ -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",
Expand All @@ -33,33 +27,59 @@ 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])
@execution.discard
@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]
Expand All @@ -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?
Expand Down
13 changes: 13 additions & 0 deletions app/helpers/solid_queue_web/application_helper.rb
Original file line number Diff line number Diff line change
@@ -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")
Expand Down
9 changes: 6 additions & 3 deletions app/views/solid_queue_web/failed_jobs/index.html.erb
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,8 @@
<input type="hidden" name="queue" value="<%= @queue %>">
<% end %>
<input type="hidden" name="period" value="<%= @period %>">
<input type="hidden" name="sort" value="<%= @sort %>">
<input type="hidden" name="direction" value="<%= @direction %>">
<input class="sqd-search__input" type="search" name="q" value="<%= @search %>"
placeholder="Filter by job class…" autocomplete="off" aria-label="Filter by job class"
data-action="input->search#filter">
Expand Down Expand Up @@ -86,10 +88,11 @@
data-action="change->selection#selectAll"
aria-label="Select all failed jobs">
</th>
<th scope="col">Job Class</th>
<th scope="col">Queue</th>
<% sort_url = ->(p) { failed_jobs_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) %>
<th scope="col">Error</th>
<th scope="col">Failed At</th>
<%= sort_header_th("Failed At", "created_at", sort_url, current_sort: @sort, current_dir: @direction) %>
<th scope="col"><span class="sqd-sr-only">Actions</span></th>
</tr>
</thead>
Expand Down
9 changes: 6 additions & 3 deletions app/views/solid_queue_web/history/index.html.erb
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,8 @@
<input type="hidden" name="queue" value="<%= @queue %>">
<% end %>
<input type="hidden" name="period" value="<%= @period %>">
<input type="hidden" name="sort" value="<%= @sort %>">
<input type="hidden" name="direction" value="<%= @direction %>">
<input class="sqd-search__input" type="search" name="q" value="<%= @search %>"
placeholder="Filter by job class…" autocomplete="off" aria-label="Filter by job class"
data-action="input->search#filter">
Expand All @@ -39,11 +41,12 @@
<div class="sqd-card">
<table>
<thead>
<% sort_url = ->(p) { history_path(queue: @queue, q: @search, period: @period, **p) } %>
<tr>
<th scope="col">Job Class</th>
<th scope="col">Queue</th>
<%= 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) %>
<th scope="col">Duration</th>
<th scope="col">Finished At</th>
<%= sort_header_th("Finished At", "finished_at", sort_url, current_sort: @sort, current_dir: @direction) %>
</tr>
</thead>
<tbody>
Expand Down
Loading