diff --git a/app/assets/stylesheets/solid_queue_web/application.css b/app/assets/stylesheets/solid_queue_web/application.css index 24a7cd3..5a06bea 100644 --- a/app/assets/stylesheets/solid_queue_web/application.css +++ b/app/assets/stylesheets/solid_queue_web/application.css @@ -359,6 +359,33 @@ tbody tr:hover { background: var(--bg); } color: #fff; } +/* Period filter */ +.sqd-period-filter { + display: flex; + align-items: center; + gap: 0.25rem; + margin-left: auto; +} + +.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..250330c 100644 --- a/app/views/solid_queue_web/failed_jobs/index.html.erb +++ b/app/views/solid_queue_web/failed_jobs/index.html.erb @@ -4,12 +4,12 @@
<%= 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." } %>
@@ -20,13 +20,20 @@ <% if @queue.present? %> <% end %> + <% if @search.present? %> - <%= link_to "Clear", failed_jobs_path(queue: @queue), class: "sqd-btn sqd-btn--muted" %> + <%= 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" : "", 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" %> +
<% if @pagy.last > 1 %> @@ -53,7 +60,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 +90,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..8194148 100644 --- a/app/views/solid_queue_web/jobs/index.html.erb +++ b/app/views/solid_queue_web/jobs/index.html.erb @@ -5,17 +5,17 @@
- <%= 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." } %>
@@ -24,13 +24,20 @@
@@ -69,7 +76,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" }