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

---
Expand Down
18 changes: 17 additions & 1 deletion app/controllers/solid_stack_web/failed_jobs_controller.rb
Original file line number Diff line number Diff line change
@@ -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)
Expand Down Expand Up @@ -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)
Expand Down
20 changes: 15 additions & 5 deletions app/controllers/solid_stack_web/history_controller.rb
Original file line number Diff line number Diff line change
Expand Up @@ -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?
Expand All @@ -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
Expand Down
12 changes: 7 additions & 5 deletions app/controllers/solid_stack_web/jobs/selections_controller.rb
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
38 changes: 27 additions & 11 deletions app/controllers/solid_stack_web/jobs_controller.rb
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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
Expand All @@ -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
Expand Down
13 changes: 13 additions & 0 deletions app/helpers/solid_stack_web/application_helper.rb
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
7 changes: 4 additions & 3 deletions app/views/solid_stack_web/failed_jobs/index.html.erb
Original file line number Diff line number Diff line change
Expand Up @@ -41,10 +41,11 @@
<th scope="col"><input type="checkbox" class="sqw-checkbox" aria-label="Select all"
data-selection-target="selectAll"
data-action="change->selection#selectAll"></th>
<th scope="col">Job Class</th>
<th scope="col">Queue</th>
<% 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) %>
<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="sqw-sr-only">Actions</span></th>
</tr>
</thead>
Expand Down
9 changes: 6 additions & 3 deletions app/views/solid_stack_web/history/index.html.erb
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,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="sqw-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 @@ -44,11 +46,12 @@
<div class="sqw-detail-card">
<table class="sqw-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