From 1716c1fe995874dc8a9bfc3f5a414882896f2baa Mon Sep 17 00:00:00 2001 From: Chuck Smith Date: Tue, 19 May 2026 12:40:54 -0400 Subject: [PATCH 1/3] feat: time-based period filter on jobs and failed jobs Adds a ?period= param (1h / 24h / 7d) to the jobs and failed jobs indexes that filters results by the job's enqueued timestamp. The active period is preserved across status tab switches, class name search, queue filter links, Discard All / Retry All bulk actions, and single-row Discard redirects. Invalid period values are silently ignored. A small pill-style period filter bar appears between the status tabs and search form on both pages. Co-Authored-By: Claude Sonnet 4.6 --- .../solid_queue_web/application.css | 33 +++++++++++++ .../solid_queue_web/application_controller.rb | 2 + .../solid_queue_web/failed_jobs_controller.rb | 6 ++- .../solid_queue_web/jobs_controller.rb | 19 ++++--- .../failed_jobs/index.html.erb | 19 +++++-- app/views/solid_queue_web/jobs/index.html.erb | 25 +++++++--- spec/rails_helper.rb | 2 +- .../solid_queue_web/failed_jobs_spec.rb | 41 ++++++++++++++++ spec/requests/solid_queue_web/jobs_spec.rb | 49 +++++++++++++++++++ 9 files changed, 173 insertions(+), 23 deletions(-) diff --git a/app/assets/stylesheets/solid_queue_web/application.css b/app/assets/stylesheets/solid_queue_web/application.css index 24a7cd3..da8cc68 100644 --- a/app/assets/stylesheets/solid_queue_web/application.css +++ b/app/assets/stylesheets/solid_queue_web/application.css @@ -359,6 +359,39 @@ tbody tr:hover { background: var(--bg); } color: #fff; } +/* Period filter */ +.sqd-period-filter { + display: flex; + align-items: center; + gap: 0.25rem; + margin-bottom: 0.75rem; + font-size: 12px; + color: var(--muted); +} + +.sqd-period-filter__label { + margin-right: 0.25rem; +} + +.sqd-period-filter a { + padding: 0.2rem 0.55rem; + border-radius: 4px; + font-size: 11px; + font-weight: 500; + text-decoration: none; + border: 1px solid var(--border); + color: var(--muted); + background: var(--surface); + transition: all 0.1s; +} + +.sqd-period-filter a:hover, +.sqd-period-filter a.active { + background: var(--muted); + border-color: var(--muted); + color: #fff; +} + /* Code / monospace */ .sqd-mono { font-family: "SFMono-Regular", Menlo, Monaco, Consolas, monospace; diff --git a/app/controllers/solid_queue_web/application_controller.rb b/app/controllers/solid_queue_web/application_controller.rb index 8b8e5ba..9fb96ea 100644 --- a/app/controllers/solid_queue_web/application_controller.rb +++ b/app/controllers/solid_queue_web/application_controller.rb @@ -2,6 +2,8 @@ module SolidQueueWeb class ApplicationController < ActionController::Base include Pagy::Method + PERIOD_DURATIONS = { "1h" => 1.hour, "24h" => 24.hours, "7d" => 7.days }.freeze + before_action :authenticate! private diff --git a/app/controllers/solid_queue_web/failed_jobs_controller.rb b/app/controllers/solid_queue_web/failed_jobs_controller.rb index 989706f..4067107 100644 --- a/app/controllers/solid_queue_web/failed_jobs_controller.rb +++ b/app/controllers/solid_queue_web/failed_jobs_controller.rb @@ -25,14 +25,14 @@ def destroy def retry_all jobs = filtered_scope.map(&:job) SolidQueue::FailedExecution.retry_all(jobs) - redirect_to failed_jobs_path(queue: @queue, q: @search), + redirect_to failed_jobs_path(queue: @queue, q: @search, period: @period), notice: "#{jobs.size} #{"job".pluralize(jobs.size)} queued for retry." end def discard_all jobs = filtered_scope.map(&:job) SolidQueue::FailedExecution.discard_all_from_jobs(jobs) - redirect_to failed_jobs_path(queue: @queue, q: @search), + redirect_to failed_jobs_path(queue: @queue, q: @search, period: @period), notice: "#{jobs.size} #{"job".pluralize(jobs.size)} discarded." end @@ -41,12 +41,14 @@ def discard_all def set_filter_params @queue = params[:queue].presence @search = params[:q].presence + @period = params[:period].presence_in(PERIOD_DURATIONS.keys) end def filtered_scope scope = SolidQueue::FailedExecution.includes(:job) scope = scope.references(:job).where(solid_queue_jobs: { queue_name: @queue }) if @queue.present? 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 end end diff --git a/app/controllers/solid_queue_web/jobs_controller.rb b/app/controllers/solid_queue_web/jobs_controller.rb index c4fde60..6370401 100644 --- a/app/controllers/solid_queue_web/jobs_controller.rb +++ b/app/controllers/solid_queue_web/jobs_controller.rb @@ -5,8 +5,10 @@ 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) @jobs = Job::EXECUTION_MODELS[@status].includes(:job) @jobs = @jobs.references(:job).where("solid_queue_jobs.class_name LIKE ?", "%#{@search}%") if @search.present? + @jobs = @jobs.references(:job).where("solid_queue_jobs.created_at >= ?", PERIOD_DURATIONS[@period].ago) if @period.present? @pagy, @jobs = pagy(@jobs.order(created_at: :desc)) end @@ -24,24 +26,24 @@ def destroy @remaining_count = filtered_scope(model).count respond_to do |format| format.turbo_stream - format.html { redirect_to jobs_path(status: @status), notice: "Job discarded." } + format.html { redirect_to jobs_path(status: @status, period: @period), notice: "Job discarded." } end rescue ArgumentError => e - redirect_to jobs_path(status: @status), alert: e.message + redirect_to jobs_path(status: @status, period: @period), alert: e.message rescue => e - redirect_to jobs_path(status: @status), alert: "Could not discard job: #{e.message}" + redirect_to jobs_path(status: @status, period: @period), alert: "Could not discard job: #{e.message}" end def discard_all model = execution_model_for!(@status) jobs = filtered_scope(model).map(&:job) model.discard_all_from_jobs(jobs) - redirect_to jobs_path(status: @status), + redirect_to jobs_path(status: @status, period: @period), notice: "#{jobs.size} #{"job".pluralize(jobs.size)} discarded." rescue ArgumentError => e - redirect_to jobs_path(status: @status), alert: e.message + redirect_to jobs_path(status: @status, period: @period), alert: e.message rescue => e - redirect_to jobs_path(status: @status), alert: "Could not discard jobs: #{e.message}" + redirect_to jobs_path(status: @status, period: @period), alert: "Could not discard jobs: #{e.message}" end private @@ -57,10 +59,13 @@ def derive_status(job) def set_status @status = params[:status] + @period = params[:period].presence_in(PERIOD_DURATIONS.keys) end def filtered_scope(model) - model.includes(:job) + scope = model.includes(:job) + scope = scope.references(:job).where("solid_queue_jobs.created_at >= ?", PERIOD_DURATIONS[@period].ago) if @period.present? + scope end def execution_model_for!(status) 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 475501d..cbd3fd1 100644 --- a/app/views/solid_queue_web/failed_jobs/index.html.erb +++ b/app/views/solid_queue_web/failed_jobs/index.html.erb @@ -4,28 +4,37 @@
<%= button_to "Retry All", retry_all_failed_jobs_path, method: :post, - params: { queue: @queue, q: @search }, + params: { queue: @queue, q: @search, period: @period }, class: "sqd-btn sqd-btn--primary", data: { confirm: "Retry all #{@failed_jobs.size} failed jobs?" } %> <%= button_to "Discard All", discard_all_failed_jobs_path, method: :post, - params: { queue: @queue, q: @search }, + params: { queue: @queue, q: @search, period: @period }, class: "sqd-btn sqd-btn--danger", data: { confirm: "Discard all #{@failed_jobs.size} failed jobs? This cannot be undone." } %>
<% end %> +
+ Period: + <%= link_to "All", failed_jobs_path(queue: @queue, q: @search), class: @period.nil? ? "active" : "" %> + <%= link_to "1h", failed_jobs_path(queue: @queue, q: @search, period: "1h"), class: @period == "1h" ? "active" : "" %> + <%= link_to "24h", failed_jobs_path(queue: @queue, q: @search, period: "24h"), class: @period == "24h" ? "active" : "" %> + <%= link_to "7d", failed_jobs_path(queue: @queue, q: @search, period: "7d"), class: @period == "7d" ? "active" : "" %> +
+ @@ -53,7 +62,7 @@ <%= link_to job.class_name, job_path(job) %> - <%= link_to job.queue_name, failed_jobs_path(queue: job.queue_name, q: @search), + <%= link_to job.queue_name, failed_jobs_path(queue: job.queue_name, q: @search, period: @period), class: "sqd-mono", style: "color: inherit;" %> @@ -83,6 +92,6 @@ <% if @queue.present? %>

Filtering by queue: <%= @queue %> — - <%= link_to "Clear filter", failed_jobs_path(q: @search) %> + <%= link_to "Clear filter", failed_jobs_path(q: @search, period: @period) %>

<% end %> \ No newline at end of file diff --git a/app/views/solid_queue_web/jobs/index.html.erb b/app/views/solid_queue_web/jobs/index.html.erb index c095a4d..9379912 100644 --- a/app/views/solid_queue_web/jobs/index.html.erb +++ b/app/views/solid_queue_web/jobs/index.html.erb @@ -5,31 +5,40 @@
- <%= link_to "Ready", jobs_path(status: "ready", q: @search), class: @status == "ready" ? "active" : "" %> - <%= link_to "Scheduled", jobs_path(status: "scheduled", q: @search), class: @status == "scheduled" ? "active" : "" %> - <%= link_to "Running", jobs_path(status: "claimed", q: @search), class: @status == "claimed" ? "active" : "" %> - <%= link_to "Blocked", jobs_path(status: "blocked", q: @search), class: @status == "blocked" ? "active" : "" %> - <%= link_to "Failed", jobs_path(status: "failed", q: @search), class: @status == "failed" ? "active" : "" %> + <%= link_to "Ready", jobs_path(status: "ready", q: @search, period: @period), class: @status == "ready" ? "active" : "" %> + <%= link_to "Scheduled", jobs_path(status: "scheduled", q: @search, period: @period), class: @status == "scheduled" ? "active" : "" %> + <%= link_to "Running", jobs_path(status: "claimed", q: @search, period: @period), class: @status == "claimed" ? "active" : "" %> + <%= link_to "Blocked", jobs_path(status: "blocked", q: @search, period: @period), class: @status == "blocked" ? "active" : "" %> + <%= link_to "Failed", jobs_path(status: "failed", q: @search, period: @period), class: @status == "failed" ? "active" : "" %>
<% if discardable && @jobs.any? %>
<%= button_to "Discard All", discard_all_jobs_path, method: :post, - params: { status: @status }, + params: { status: @status, period: @period }, class: "sqd-btn sqd-btn--danger", data: { confirm: "Discard all #{@jobs.size} #{@status} jobs? This cannot be undone." } %>
<% end %>
+
+ Period: + <%= link_to "All", jobs_path(status: @status, q: @search), class: @period.nil? ? "active" : "" %> + <%= link_to "1h", jobs_path(status: @status, q: @search, period: "1h"), class: @period == "1h" ? "active" : "" %> + <%= link_to "24h", jobs_path(status: @status, q: @search, period: "24h"), class: @period == "24h" ? "active" : "" %> + <%= link_to "7d", jobs_path(status: @status, q: @search, period: "7d"), class: @period == "7d" ? "active" : "" %> +
+ @@ -69,7 +78,7 @@ <%= button_to "Discard", job_path(execution), method: :delete, - params: { status: @status }, + params: { status: @status, period: @period }, class: "sqd-btn sqd-btn--danger sqd-btn--sm", data: { confirm: "Discard this job?" } %> diff --git a/spec/rails_helper.rb b/spec/rails_helper.rb index 2afeced..5240dc5 100644 --- a/spec/rails_helper.rb +++ b/spec/rails_helper.rb @@ -16,5 +16,5 @@ config.use_transactional_fixtures = true config.infer_spec_type_from_file_location! config.filter_rails_from_backtrace! - config.filter_gems_from_backtrace("turbo-rails") + config.backtrace_exclusion_patterns << /\/turbo-rails\// end diff --git a/spec/requests/solid_queue_web/failed_jobs_spec.rb b/spec/requests/solid_queue_web/failed_jobs_spec.rb index f4ef3eb..c8d41ba 100644 --- a/spec/requests/solid_queue_web/failed_jobs_spec.rb +++ b/spec/requests/solid_queue_web/failed_jobs_spec.rb @@ -96,6 +96,47 @@ end end + describe "GET /jobs/failed_jobs?period= (time-based filter)" do + let!(:old_execution) do + j = SolidQueue::Job.create!( + queue_name: "default", + class_name: "OldFailedJob", + arguments: {}, + active_job_id: SecureRandom.uuid + ) + j.ready_execution&.destroy + exec = SolidQueue::FailedExecution.create!( + job: j, + error: { exception_class: "RuntimeError", message: "old", backtrace: [] } + ) + j.update_columns(created_at: 2.days.ago) + exec + end + + it "shows all failed jobs when no period is set" do + get "/jobs/failed_jobs" + expect(response.body).to include("TestJob") + expect(response.body).to include("OldFailedJob") + end + + it "filters to jobs failed within the last hour" do + get "/jobs/failed_jobs", params: { period: "1h" } + expect(response.body).to include("TestJob") + expect(response.body).not_to include("OldFailedJob") + end + + it "ignores invalid period values" do + get "/jobs/failed_jobs", params: { period: "bogus" } + expect(response).to have_http_status(:ok) + expect(response.body).to include("TestJob") + end + + it "persists period across bulk action params" do + get "/jobs/failed_jobs", params: { period: "1h" } + expect(response.body).to include("period=1h") + end + end + describe "POST /jobs/failed_jobs/:id/retry" do it "retries the job and redirects" do post "/jobs/failed_jobs/#{execution.id}/retry" diff --git a/spec/requests/solid_queue_web/jobs_spec.rb b/spec/requests/solid_queue_web/jobs_spec.rb index 3a2cb70..aa844bb 100644 --- a/spec/requests/solid_queue_web/jobs_spec.rb +++ b/spec/requests/solid_queue_web/jobs_spec.rb @@ -117,6 +117,55 @@ end end + describe "GET /jobs/list?period= (time-based filter)" do + let!(:old_job) do + job = SolidQueue::Job.create!( + queue_name: "default", + class_name: "OldJob", + arguments: {}, + active_job_id: SecureRandom.uuid + ) + job.update_columns(created_at: 2.days.ago) + job.ready_execution.update_columns(created_at: 2.days.ago) + job + end + + it "shows all jobs when no period is set" do + get "/jobs/list" + expect(response.body).to include("TestJob") + expect(response.body).to include("OldJob") + end + + it "filters to jobs enqueued within the last hour" do + get "/jobs/list", params: { period: "1h" } + expect(response.body).to include("TestJob") + expect(response.body).not_to include("OldJob") + end + + it "filters to jobs enqueued within the last 24 hours" do + get "/jobs/list", params: { period: "24h" } + expect(response.body).to include("TestJob") + expect(response.body).not_to include("OldJob") + end + + it "includes all jobs within 7 days" do + get "/jobs/list", params: { period: "7d" } + expect(response.body).to include("TestJob") + expect(response.body).to include("OldJob") + end + + it "ignores invalid period values" do + get "/jobs/list", params: { period: "bogus" } + expect(response).to have_http_status(:ok) + expect(response.body).to include("TestJob") + end + + it "persists period across status tab links" do + get "/jobs/list", params: { period: "1h" } + expect(response.body).to include("period=1h") + end + end + 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" } From 8c7f37b91f55c885be9cb99ffacaf49946f2acfc Mon Sep 17 00:00:00 2001 From: Chuck Smith Date: Tue, 19 May 2026 12:43:11 -0400 Subject: [PATCH 2/3] refactor: move period filter inline with search bar, right-justified Uses margin-left: auto on .sqd-period-filter inside the flex search form to push the period pills to the far right of the search row. Co-Authored-By: Claude Sonnet 4.6 --- .../stylesheets/solid_queue_web/application.css | 8 +------- .../solid_queue_web/failed_jobs/index.html.erb | 14 ++++++-------- app/views/solid_queue_web/jobs/index.html.erb | 14 ++++++-------- 3 files changed, 13 insertions(+), 23 deletions(-) diff --git a/app/assets/stylesheets/solid_queue_web/application.css b/app/assets/stylesheets/solid_queue_web/application.css index da8cc68..5a06bea 100644 --- a/app/assets/stylesheets/solid_queue_web/application.css +++ b/app/assets/stylesheets/solid_queue_web/application.css @@ -364,13 +364,7 @@ tbody tr:hover { background: var(--bg); } display: flex; align-items: center; gap: 0.25rem; - margin-bottom: 0.75rem; - font-size: 12px; - color: var(--muted); -} - -.sqd-period-filter__label { - margin-right: 0.25rem; + margin-left: auto; } .sqd-period-filter a { 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 cbd3fd1..9f1bbe6 100644 --- a/app/views/solid_queue_web/failed_jobs/index.html.erb +++ b/app/views/solid_queue_web/failed_jobs/index.html.erb @@ -16,14 +16,6 @@ <% end %> -
- Period: - <%= link_to "All", failed_jobs_path(queue: @queue, q: @search), class: @period.nil? ? "active" : "" %> - <%= link_to "1h", failed_jobs_path(queue: @queue, q: @search, period: "1h"), class: @period == "1h" ? "active" : "" %> - <%= link_to "24h", failed_jobs_path(queue: @queue, q: @search, period: "24h"), class: @period == "24h" ? "active" : "" %> - <%= link_to "7d", failed_jobs_path(queue: @queue, q: @search, period: "7d"), class: @period == "7d" ? "active" : "" %> -
- <% if @pagy.last > 1 %> diff --git a/app/views/solid_queue_web/jobs/index.html.erb b/app/views/solid_queue_web/jobs/index.html.erb index 9379912..f90bacf 100644 --- a/app/views/solid_queue_web/jobs/index.html.erb +++ b/app/views/solid_queue_web/jobs/index.html.erb @@ -22,14 +22,6 @@ <% end %> -
- Period: - <%= link_to "All", jobs_path(status: @status, q: @search), class: @period.nil? ? "active" : "" %> - <%= link_to "1h", jobs_path(status: @status, q: @search, period: "1h"), class: @period == "1h" ? "active" : "" %> - <%= link_to "24h", jobs_path(status: @status, q: @search, period: "24h"), class: @period == "24h" ? "active" : "" %> - <%= link_to "7d", jobs_path(status: @status, q: @search, period: "7d"), class: @period == "7d" ? "active" : "" %> -
-
From 7f20e9a90971913ea0c5f914547b648dd7695f5a Mon Sep 17 00:00:00 2001 From: Chuck Smith Date: Tue, 19 May 2026 12:47:46 -0400 Subject: [PATCH 3/3] fix: improve period filter accessibility - role="group" + aria-label="Time period" on the container so screen readers announce the group purpose before reading the links - aria-current="true" on the active period link so AT announces the current selection without relying on visual styling alone - aria-label on each link with natural language descriptions ("Last 1 hour" etc.) so terse labels like "1h" are not read literally Co-Authored-By: Claude Sonnet 4.6 --- app/views/solid_queue_web/failed_jobs/index.html.erb | 10 +++++----- app/views/solid_queue_web/jobs/index.html.erb | 10 +++++----- 2 files changed, 10 insertions(+), 10 deletions(-) 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 9f1bbe6..250330c 100644 --- a/app/views/solid_queue_web/failed_jobs/index.html.erb +++ b/app/views/solid_queue_web/failed_jobs/index.html.erb @@ -28,11 +28,11 @@ <% if @search.present? %> <%= link_to "Clear", failed_jobs_path(queue: @queue, period: @period), class: "sqd-btn sqd-btn--muted" %> <% end %> -
- <%= link_to "All", failed_jobs_path(queue: @queue, q: @search), class: @period.nil? ? "active" : "" %> - <%= link_to "1h", failed_jobs_path(queue: @queue, q: @search, period: "1h"), class: @period == "1h" ? "active" : "" %> - <%= link_to "24h", failed_jobs_path(queue: @queue, q: @search, period: "24h"), class: @period == "24h" ? "active" : "" %> - <%= link_to "7d", failed_jobs_path(queue: @queue, q: @search, period: "7d"), class: @period == "7d" ? "active" : "" %> +
+ <%= link_to "All", failed_jobs_path(queue: @queue, q: @search), class: @period.nil? ? "active" : "", aria: { current: @period.nil? ? "true" : nil }, aria_label: "All time" %> + <%= link_to "1h", failed_jobs_path(queue: @queue, q: @search, period: "1h"), class: @period == "1h" ? "active" : "", aria: { current: @period == "1h" ? "true" : nil }, aria_label: "Last 1 hour" %> + <%= link_to "24h", failed_jobs_path(queue: @queue, q: @search, period: "24h"), class: @period == "24h" ? "active" : "", aria: { current: @period == "24h" ? "true" : nil }, aria_label: "Last 24 hours" %> + <%= link_to "7d", failed_jobs_path(queue: @queue, q: @search, period: "7d"), class: @period == "7d" ? "active" : "", aria: { current: @period == "7d" ? "true" : nil }, aria_label: "Last 7 days" %>
diff --git a/app/views/solid_queue_web/jobs/index.html.erb b/app/views/solid_queue_web/jobs/index.html.erb index f90bacf..8194148 100644 --- a/app/views/solid_queue_web/jobs/index.html.erb +++ b/app/views/solid_queue_web/jobs/index.html.erb @@ -32,11 +32,11 @@ <% if @search.present? %> <%= link_to "Clear", jobs_path(status: @status, period: @period), class: "sqd-btn sqd-btn--muted" %> <% end %> -
- <%= link_to "All", jobs_path(status: @status, q: @search), class: @period.nil? ? "active" : "" %> - <%= link_to "1h", jobs_path(status: @status, q: @search, period: "1h"), class: @period == "1h" ? "active" : "" %> - <%= link_to "24h", jobs_path(status: @status, q: @search, period: "24h"), class: @period == "24h" ? "active" : "" %> - <%= link_to "7d", jobs_path(status: @status, q: @search, period: "7d"), class: @period == "7d" ? "active" : "" %> +
+ <%= link_to "All", jobs_path(status: @status, q: @search), class: @period.nil? ? "active" : "", aria: { current: @period.nil? ? "true" : nil }, aria_label: "All time" %> + <%= link_to "1h", jobs_path(status: @status, q: @search, 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, 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, period: "7d"), class: @period == "7d" ? "active" : "", aria: { current: @period == "7d" ? "true" : nil }, aria_label: "Last 7 days" %>