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"> - Job Class - Queue + <% 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) %> 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_queue_web/history/index.html.erb b/app/views/solid_queue_web/history/index.html.erb index 553f146..306f46c 100644 --- a/app/views/solid_queue_web/history/index.html.erb +++ b/app/views/solid_queue_web/history/index.html.erb @@ -14,6 +14,8 @@ <% end %> + + @@ -39,11 +41,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_queue_web/jobs/index.html.erb b/app/views/solid_queue_web/jobs/index.html.erb index 0b6afc5..7390be3 100644 --- a/app/views/solid_queue_web/jobs/index.html.erb +++ b/app/views/solid_queue_web/jobs/index.html.erb @@ -5,15 +5,15 @@
- <%= link_to "Ready", jobs_path(status: "ready", q: @search, period: @period, priority: @priority), class: @status == "ready" ? "active" : "" %> - <%= link_to "Scheduled", jobs_path(status: "scheduled", q: @search, period: @period, priority: @priority), class: @status == "scheduled" ? "active" : "" %> - <%= link_to "Running", jobs_path(status: "claimed", q: @search, period: @period, priority: @priority), class: @status == "claimed" ? "active" : "" %> - <%= link_to "Blocked", jobs_path(status: "blocked", q: @search, period: @period, priority: @priority), class: @status == "blocked" ? "active" : "" %> - <%= link_to "Failed", jobs_path(status: "failed", q: @search, period: @period, priority: @priority), class: @status == "failed" ? "active" : "" %> + <%= link_to "Ready", jobs_path(status: "ready", q: @search, period: @period, priority: @priority, sort: @sort, direction: @direction), class: @status == "ready" ? "active" : "" %> + <%= link_to "Scheduled", jobs_path(status: "scheduled", q: @search, period: @period, priority: @priority, sort: @sort, direction: @direction), class: @status == "scheduled" ? "active" : "" %> + <%= link_to "Running", jobs_path(status: "claimed", q: @search, period: @period, priority: @priority, sort: @sort, direction: @direction), class: @status == "claimed" ? "active" : "" %> + <%= link_to "Blocked", jobs_path(status: "blocked", q: @search, period: @period, priority: @priority, sort: @sort, direction: @direction), class: @status == "blocked" ? "active" : "" %> + <%= link_to "Failed", jobs_path(status: "failed", q: @search, period: @period, priority: @priority, sort: @sort, direction: @direction), class: @status == "failed" ? "active" : "" %>
<% if @jobs.any? %>
- <%= link_to "Export CSV", jobs_path(format: :csv, status: @status, q: @search, period: @period), + <%= link_to "Export CSV", jobs_path(format: :csv, status: @status, q: @search, period: @period, sort: @sort, direction: @direction), class: "sqd-btn sqd-btn--muted", data: { turbo: false } %> <% if @status == "scheduled" %> <%= button_to "Run All Now", run_all_now_scheduled_jobs_path, @@ -36,6 +36,8 @@ + + @@ -49,13 +51,13 @@ <% end %> <% if @search.present? || @priority.present? %> - <%= link_to "Clear", jobs_path(status: @status, period: @period), class: "sqd-btn sqd-btn--muted" %> + <%= link_to "Clear", jobs_path(status: @status, period: @period, sort: @sort, direction: @direction), class: "sqd-btn sqd-btn--muted" %> <% end %>
- <%= link_to "All", jobs_path(status: @status, q: @search, priority: @priority), class: @period.nil? ? "active" : "", aria: { current: @period.nil? ? "true" : nil }, aria_label: "All time" %> - <%= link_to "1h", jobs_path(status: @status, q: @search, priority: @priority, period: "1h"), class: @period == "1h" ? "active" : "", aria: { current: @period == "1h" ? "true" : nil }, aria_label: "Last 1 hour" %> - <%= link_to "24h", jobs_path(status: @status, q: @search, priority: @priority, period: "24h"), class: @period == "24h" ? "active" : "", aria: { current: @period == "24h" ? "true" : nil }, aria_label: "Last 24 hours" %> - <%= link_to "7d", jobs_path(status: @status, q: @search, priority: @priority, period: "7d"), class: @period == "7d" ? "active" : "", aria: { current: @period == "7d" ? "true" : nil }, aria_label: "Last 7 days" %> + <%= link_to "All", jobs_path(status: @status, q: @search, priority: @priority, sort: @sort, direction: @direction), class: @period.nil? ? "active" : "", aria: { current: @period.nil? ? "true" : nil }, aria_label: "All time" %> + <%= link_to "1h", jobs_path(status: @status, q: @search, priority: @priority, period: "1h", sort: @sort, direction: @direction), class: @period == "1h" ? "active" : "", aria: { current: @period == "1h" ? "true" : nil }, aria_label: "Last 1 hour" %> + <%= link_to "24h", jobs_path(status: @status, q: @search, priority: @priority, period: "24h", sort: @sort, direction: @direction), class: @period == "24h" ? "active" : "", aria: { current: @period == "24h" ? "true" : nil }, aria_label: "Last 24 hours" %> + <%= link_to "7d", jobs_path(status: @status, q: @search, priority: @priority, period: "7d", sort: @sort, direction: @direction), class: @period == "7d" ? "active" : "", aria: { current: @period == "7d" ? "true" : nil }, aria_label: "Last 7 days" %>
@@ -63,8 +65,10 @@
<%= form_tag job_selection_path, method: :delete, id: "job-selection-form", data: { turbo_confirm: "Discard selected jobs? This cannot be undone." } do %> - <%= hidden_field_tag :status, @status %> - <%= hidden_field_tag :period, @period %> + <%= hidden_field_tag :status, @status %> + <%= hidden_field_tag :period, @period %> + <%= hidden_field_tag :sort, @sort %> + <%= hidden_field_tag :direction, @direction %> <% end %>
- - + <% sort_url = ->(p) { jobs_path(status: @status, q: @search, 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) %> @@ -130,7 +135,7 @@ <% end %> <%= button_to "Discard", job_path(execution), method: :delete, - params: { status: @status, period: @period }, + params: { status: @status, period: @period, sort: @sort, direction: @direction }, class: "sqd-btn sqd-btn--danger sqd-btn--sm", data: { confirm: "Discard this job?" } %> @@ -149,11 +154,12 @@
Job ClassQueueDurationFinished At
Job ClassQueuePriorityScheduled AtEnqueued AtActions
- - - + <% sort_url = ->(p) { jobs_path(status: @status, q: @search, 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 == "claimed" %> <% end %> diff --git a/spec/requests/solid_queue_web/failed_jobs_spec.rb b/spec/requests/solid_queue_web/failed_jobs_spec.rb index 02f8e71..a94f6cc 100644 --- a/spec/requests/solid_queue_web/failed_jobs_spec.rb +++ b/spec/requests/solid_queue_web/failed_jobs_spec.rb @@ -324,4 +324,26 @@ expect(response).to redirect_to("/jobs/failed_jobs?queue=default") end end + + describe "GET /jobs/failed_jobs?sort=" do + it "accepts sort by class_name" do + get "/jobs/failed_jobs", params: { sort: "class_name", direction: "asc" } + expect(response).to have_http_status(:ok) + end + + it "accepts sort by queue_name" do + get "/jobs/failed_jobs", params: { sort: "queue_name", direction: "desc" } + expect(response).to have_http_status(:ok) + end + + it "accepts sort by created_at" do + get "/jobs/failed_jobs", params: { sort: "created_at", direction: "asc" } + expect(response).to have_http_status(:ok) + end + + it "ignores invalid sort params" do + get "/jobs/failed_jobs", params: { sort: "DROP TABLE", direction: "evil" } + expect(response).to have_http_status(:ok) + end + end end diff --git a/spec/requests/solid_queue_web/history_spec.rb b/spec/requests/solid_queue_web/history_spec.rb index 223ab1f..8809683 100644 --- a/spec/requests/solid_queue_web/history_spec.rb +++ b/spec/requests/solid_queue_web/history_spec.rb @@ -110,4 +110,26 @@ def finished_job(attrs = {}) expect(response.body).to include("finished_at") end end + + describe "GET /jobs/history?sort=" do + it "accepts sort by class_name" do + get "/jobs/history", params: { sort: "class_name", direction: "asc" } + expect(response).to have_http_status(:ok) + end + + it "accepts sort by queue_name" do + get "/jobs/history", params: { sort: "queue_name", direction: "desc" } + expect(response).to have_http_status(:ok) + end + + it "accepts sort by finished_at" do + get "/jobs/history", params: { sort: "finished_at", direction: "asc" } + expect(response).to have_http_status(:ok) + end + + it "ignores invalid sort params" do + get "/jobs/history", params: { sort: "DROP TABLE", direction: "evil" } + expect(response).to have_http_status(:ok) + end + end end diff --git a/spec/requests/solid_queue_web/jobs_spec.rb b/spec/requests/solid_queue_web/jobs_spec.rb index 7d8ca55..04557a0 100644 --- a/spec/requests/solid_queue_web/jobs_spec.rb +++ b/spec/requests/solid_queue_web/jobs_spec.rb @@ -169,7 +169,7 @@ describe "DELETE /jobs/list/:id (discard single)" do it "discards the job and redirects (HTML)" do delete "/jobs/list/#{ready_execution.id}", params: { status: "ready" } - expect(response).to redirect_to("/jobs/list?status=ready") + expect(response).to redirect_to("/jobs/list?direction=desc&sort=created_at&status=ready") follow_redirect! expect(response.body).to include("discarded") end @@ -206,7 +206,7 @@ it "rejects discard for claimed status" do delete "/jobs/list/#{ready_execution.id}", params: { status: "claimed" } - expect(response).to redirect_to("/jobs/list?status=claimed") + expect(response).to redirect_to("/jobs/list?direction=desc&sort=created_at&status=claimed") follow_redirect! expect(response.body).to include("Cannot discard") end @@ -214,7 +214,7 @@ it "handles unexpected errors gracefully" do allow_any_instance_of(SolidQueue::ReadyExecution).to receive(:discard).and_raise(RuntimeError, "disk full") delete "/jobs/list/#{ready_execution.id}", params: { status: "ready" } - expect(response).to redirect_to("/jobs/list?status=ready") + expect(response).to redirect_to("/jobs/list?direction=desc&sort=created_at&status=ready") follow_redirect! expect(response.body).to include("Could not discard job") end @@ -243,7 +243,7 @@ describe "POST /jobs/list/discard_all" do it "discards all ready jobs and redirects" do post "/jobs/list/discard_all", params: { status: "ready" } - expect(response).to redirect_to("/jobs/list?status=ready") + expect(response).to redirect_to("/jobs/list?direction=desc&sort=created_at&status=ready") follow_redirect! expect(response.body).to include("discarded") end @@ -256,7 +256,7 @@ it "rejects discard_all for claimed status" do post "/jobs/list/discard_all", params: { status: "claimed" } - expect(response).to redirect_to("/jobs/list?status=claimed") + expect(response).to redirect_to("/jobs/list?direction=desc&sort=created_at&status=claimed") follow_redirect! expect(response.body).to include("Cannot discard") end @@ -264,12 +264,42 @@ it "handles unexpected errors gracefully" do allow(SolidQueue::ReadyExecution).to receive(:discard_all_from_jobs).and_raise(RuntimeError, "disk full") post "/jobs/list/discard_all", params: { status: "ready" } - expect(response).to redirect_to("/jobs/list?status=ready") + expect(response).to redirect_to("/jobs/list?direction=desc&sort=created_at&status=ready") follow_redirect! expect(response.body).to include("Could not discard jobs") end end + describe "GET /jobs/list?sort=" do + it "accepts sort by class_name" do + get "/jobs/list", params: { sort: "class_name", direction: "asc" } + expect(response).to have_http_status(:ok) + expect(response.body).to include("TestJob") + end + + it "accepts sort by queue_name" do + get "/jobs/list", params: { sort: "queue_name", direction: "desc" } + expect(response).to have_http_status(:ok) + end + + it "accepts sort by priority" do + get "/jobs/list", params: { sort: "priority", direction: "asc" } + expect(response).to have_http_status(:ok) + end + + it "defaults to created_at desc for invalid sort column" do + get "/jobs/list", params: { sort: "DROP TABLE", direction: "evil" } + expect(response).to have_http_status(:ok) + expect(response.body).to include("TestJob") + end + + it "preserves sort across status tab links" do + get "/jobs/list", params: { sort: "class_name", direction: "asc" } + expect(response.body).to include("sort=class_name") + expect(response.body).to include("direction=asc") + end + end + describe "slow job detection (claimed tab)" do let!(:worker_process) do SolidQueue::Process.create!(
Job ClassQueuePriorityScheduled AtEnqueued AtRunning For