diff --git a/CHANGELOG.md b/CHANGELOG.md index 6e89245..fc99b58 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 + +- Job filtering — filter the jobs list by queue name, job class (substring), priority, and time period (1h / 24h / 7d / all) via query-param driven scopes; active filters are preserved across status tabs + ## [0.1.0] - 2026-05-24 ### Added diff --git a/README.md b/README.md index 8e332ef..d69649a 100644 --- a/README.md +++ b/README.md @@ -10,7 +10,7 @@ A mountable Rails engine that provides a unified web dashboard for the full [Sol ## Features - **Overview dashboard** with live counts across all three Solid Stack components -- **Solid Queue** — browse jobs by status (ready, scheduled, claimed, blocked), manage failed jobs (retry / discard), pause/resume queues, and inspect worker processes +- **Solid Queue** — browse jobs by status (ready, scheduled, claimed, blocked) with filtering by job class, queue name, priority, and time period; manage failed jobs (retry / discard), pause/resume queues, and inspect worker processes - **Solid Cache** — entry count and total byte size at a glance - **Solid Cable** — active message count and distinct channel count - **Turbo Stream** job discard — removes the row inline without a full page reload @@ -56,6 +56,19 @@ SolidStackWeb.configure do |config| end ``` +### Job Filtering + +The jobs list supports four independent filters, all driven by query params: + +| Param | Description | +|-------|-------------| +| `q` | Substring match against the job class name (e.g. `q=Report`) | +| `queue` | Exact queue name match; select appears only when multiple queues exist | +| `priority` | Exact priority value match; select appears only when multiple priorities exist | +| `period` | Enqueued-at window — `1h`, `24h`, `7d`, or omit for all time | + +Filters are preserved when switching between status tabs (Ready / Scheduled / Running / Blocked) and when discarding a job. They can be combined freely. + ### Authentication The `authenticate` block is evaluated in the context of each request's controller instance, so any helper method available to controllers (e.g. `current_user` from Devise) works directly. If the block returns `false` or `nil`, the engine falls back to HTTP Basic authentication. If no `authenticate` block is configured, the dashboard is open. diff --git a/ROADMAP.md b/ROADMAP.md index 5583476..1e68d52 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -11,7 +11,6 @@ The path to v1.0.0 is staged: first achieve feature parity with `solid_queue_das > _Make the job management layer genuinely useful for operators._ ### Added -- **Job filtering** — filter the job list by queue name, job class (substring), priority, and time period (1 h / 24 h / 7 d / all) via query-param driven scopes - **Job detail page** — `jobs/:id` show view with full arguments, queue, priority, enqueued time, and execution metadata - **Bulk selection** — checkbox-driven multi-select on the jobs and failed-jobs lists - **Bulk discard** — discard all selected jobs in a single request diff --git a/app/assets/stylesheets/solid_stack_web/_08_filters.css b/app/assets/stylesheets/solid_stack_web/_08_filters.css new file mode 100644 index 0000000..3b4dfd4 --- /dev/null +++ b/app/assets/stylesheets/solid_stack_web/_08_filters.css @@ -0,0 +1,59 @@ +.sqw-filters { + display: flex; + align-items: center; + gap: 0.5rem; + flex-wrap: wrap; + margin-bottom: 1rem; +} + +.sqw-search-input { + padding: 0.35rem 0.75rem; + font-size: 13px; + border: 1px solid var(--border); + border-radius: var(--radius); + background: var(--surface); + color: var(--text); + min-width: 200px; +} +.sqw-search-input:focus { + outline: 2px solid var(--primary); + outline-offset: -1px; +} + +.sqw-select { + padding: 0.35rem 0.6rem; + font-size: 13px; + border: 1px solid var(--border); + border-radius: var(--radius); + background: var(--surface); + color: var(--text); + cursor: pointer; +} + +.sqw-period-filter { + display: flex; + border: 1px solid var(--border); + border-radius: var(--radius); + overflow: hidden; + margin-left: auto; +} + +.sqw-period-btn { + padding: 0.35rem 0.65rem; + font-size: 13px; + font-weight: 500; + color: var(--muted); + background: var(--surface); +} +.sqw-period-btn + .sqw-period-btn { + border-left: 1px solid var(--border); +} +.sqw-period-btn:hover:not(.sqw-period-btn--active) { + background: var(--bg); + color: var(--text); + text-decoration: none; +} +.sqw-period-btn--active { + background: var(--primary); + color: #fff; +} \ No newline at end of file diff --git a/app/controllers/solid_stack_web/application_controller.rb b/app/controllers/solid_stack_web/application_controller.rb index 2e4b5c4..0542a74 100644 --- a/app/controllers/solid_stack_web/application_controller.rb +++ b/app/controllers/solid_stack_web/application_controller.rb @@ -2,6 +2,8 @@ module SolidStackWeb class ApplicationController < ActionController::Base include Pagy::Method + PERIOD_DURATIONS = { "1h" => 1.hour, "24h" => 24.hours, "7d" => 7.days }.freeze + before_action :authenticate! around_action :with_database_connection diff --git a/app/controllers/solid_stack_web/jobs_controller.rb b/app/controllers/solid_stack_web/jobs_controller.rb index 3cb4bea..c86fb25 100644 --- a/app/controllers/solid_stack_web/jobs_controller.rb +++ b/app/controllers/solid_stack_web/jobs_controller.rb @@ -1,30 +1,23 @@ module SolidStackWeb class JobsController < ApplicationController - EXECUTION_MODELS = { - "ready" => ::SolidQueue::ReadyExecution, - "scheduled" => ::SolidQueue::ScheduledExecution, - "claimed" => ::SolidQueue::ClaimedExecution, - "blocked" => ::SolidQueue::BlockedExecution - }.freeze - - DISCARDABLE = %w[ready scheduled blocked].freeze - before_action :set_status + before_action :set_filters, only: :index before_action :require_discardable, only: :destroy def index - scope = EXECUTION_MODELS[@status].includes(:job).order(created_at: :desc) - @pagy, @executions = pagy(scope) + @queue_options = Job::EXECUTION_MODELS[@status].joins(:job).distinct.pluck("solid_queue_jobs.queue_name").sort + @priority_options = Job::EXECUTION_MODELS[@status].joins(:job).distinct.pluck("solid_queue_jobs.priority").sort + + @pagy, @executions = pagy(filtered_scope) end def destroy - model = EXECUTION_MODELS[@status] - execution = model.find(params[:id]) + execution = Job::EXECUTION_MODELS[@status].find(params[:id]) execution.job.destroy! - @executions_remain = model.exists? + @executions_remain = Job::EXECUTION_MODELS[@status].exists? respond_to do |format| - format.html { redirect_to jobs_path(status: @status) } + format.html { redirect_to jobs_path(status: @status, q: params[:q], queue: params[:queue], period: params[:period], priority: params[:priority]) } format.turbo_stream end end @@ -32,11 +25,27 @@ def destroy private def set_status - @status = params[:status].presence_in(EXECUTION_MODELS.keys) || "ready" + @status = params[:status].presence_in(Job::STATUSES) || "ready" + end + + def set_filters + @search = params[:q].presence + @queue = params[:queue].presence + @period = params[:period].presence_in(PERIOD_DURATIONS.keys) + @priority = params[:priority].presence end def require_discardable - head :unprocessable_entity unless DISCARDABLE.include?(@status) + head :unprocessable_entity unless Job::DISCARDABLE.include?(@status) + 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 end end end diff --git a/app/models/solid_stack_web/job.rb b/app/models/solid_stack_web/job.rb new file mode 100644 index 0000000..028ab77 --- /dev/null +++ b/app/models/solid_stack_web/job.rb @@ -0,0 +1,21 @@ +module SolidStackWeb + class Job + STATUSES = %w[ready scheduled claimed blocked].freeze + + EXECUTION_MODELS = { + "ready" => SolidQueue::ReadyExecution, + "scheduled" => SolidQueue::ScheduledExecution, + "claimed" => SolidQueue::ClaimedExecution, + "blocked" => SolidQueue::BlockedExecution + }.freeze + + DISCARDABLE = %w[ready scheduled blocked].freeze + + TAB_LABELS = { + "ready" => "Ready", + "scheduled" => "Scheduled", + "claimed" => "Running", + "blocked" => "Blocked" + }.freeze + end +end diff --git a/app/views/solid_stack_web/jobs/index.html.erb b/app/views/solid_stack_web/jobs/index.html.erb index 59f7a3c..c9938eb 100644 --- a/app/views/solid_stack_web/jobs/index.html.erb +++ b/app/views/solid_stack_web/jobs/index.html.erb @@ -3,12 +3,49 @@
- <% [["ready", "Ready"], ["scheduled", "Scheduled"], ["claimed", "Running"], ["blocked", "Blocked"]].each do |status, label| %> - <%= link_to label, jobs_path(status: status), + <% SolidStackWeb::Job::TAB_LABELS.each do |status, label| %> + <%= link_to label, jobs_path(status: status, q: @search, queue: @queue, period: @period, priority: @priority), class: "sqw-tab #{"sqw-tab--active" if @status == status}" %> <% end %>
+
+ <%= hidden_field_tag :status, @status %> + <%= hidden_field_tag :period, @period %> + + <% if @queue_options.size > 1 %> + + <% end %> + <% if @priority_options.size > 1 %> + + <% end %> + + <% if @search.present? || @queue.present? || @priority.present? %> + <%= link_to "Clear", jobs_path(status: @status, period: @period), class: "sqw-btn sqw-btn--muted sqw-btn--sm" %> + <% end %> +
+ <%= link_to "All", jobs_path(status: @status, q: @search, queue: @queue, priority: @priority), + class: "sqw-period-btn #{"sqw-period-btn--active" if @period.nil?}" %> + <%= link_to "1h", jobs_path(status: @status, q: @search, queue: @queue, priority: @priority, period: "1h"), + class: "sqw-period-btn #{"sqw-period-btn--active" if @period == "1h"}" %> + <%= link_to "24h", jobs_path(status: @status, q: @search, queue: @queue, priority: @priority, period: "24h"), + class: "sqw-period-btn #{"sqw-period-btn--active" if @period == "24h"}" %> + <%= link_to "7d", jobs_path(status: @status, q: @search, queue: @queue, priority: @priority, period: "7d"), + class: "sqw-period-btn #{"sqw-period-btn--active" if @period == "7d"}" %> +
+
+
<% if @executions.any? %> @@ -34,7 +71,7 @@ <% end %>
<% if %w[ready scheduled blocked].include?(@status) %> - <%= button_to "Discard", job_path(execution, status: @status), + <%= button_to "Discard", job_path(execution, status: @status, q: @search, queue: @queue, period: @period, priority: @priority), method: :delete, class: "sqw-btn sqw-btn--danger sqw-btn--sm", data: { turbo_confirm: "Discard this job?" } %> <% end %> @@ -47,4 +84,4 @@ <% else %> <%= render "empty" %> <% end %> - + \ No newline at end of file diff --git a/spec/requests/solid_stack_web/jobs_spec.rb b/spec/requests/solid_stack_web/jobs_spec.rb new file mode 100644 index 0000000..426c26e --- /dev/null +++ b/spec/requests/solid_stack_web/jobs_spec.rb @@ -0,0 +1,161 @@ +require "rails_helper" + +RSpec.describe "Jobs", type: :request do + let(:engine_root) { "/solid_stack" } + + def create_ready(class_name: "MyJob", queue_name: "default", priority: 0) + SolidQueue::Job.create!(class_name:, queue_name:, priority:) + end + + describe "GET /jobs" do + it "returns 200" do + get "#{engine_root}/jobs" + expect(response).to have_http_status(:ok) + end + + it "defaults to ready status" do + get "#{engine_root}/jobs" + expect(response.body).to include("sqw-tab--active") + end + + it "shows ready jobs" do + create_ready(class_name: "ReportJob") + get "#{engine_root}/jobs" + expect(response.body).to include("ReportJob") + end + + it "renders the filter form" do + get "#{engine_root}/jobs" + expect(response.body).to include('name="q"') + expect(response.body).to include("sqw-period-filter") + end + end + + describe "GET /jobs?q=" do + it "returns only jobs whose class name matches the substring" do + create_ready(class_name: "ReportJob") + create_ready(class_name: "CleanupJob") + + get "#{engine_root}/jobs", params: { q: "Report" } + + expect(response.body).to include("ReportJob") + expect(response.body).not_to include("CleanupJob") + end + + it "is case-insensitive on SQLite" do + create_ready(class_name: "ReportJob") + + get "#{engine_root}/jobs", params: { q: "report" } + + expect(response.body).to include("ReportJob") + end + + it "returns all jobs when q is blank" do + create_ready(class_name: "ReportJob") + create_ready(class_name: "CleanupJob") + + get "#{engine_root}/jobs", params: { q: "" } + + expect(response.body).to include("ReportJob") + expect(response.body).to include("CleanupJob") + end + end + + describe "GET /jobs?queue=" do + it "returns only jobs in the specified queue" do + create_ready(class_name: "ReportJob", queue_name: "reports") + create_ready(class_name: "CleanupJob", queue_name: "maintenance") + + get "#{engine_root}/jobs", params: { queue: "reports" } + + expect(response.body).to include("ReportJob") + expect(response.body).not_to include("CleanupJob") + end + + it "shows the queue select when multiple queues exist" do + create_ready(queue_name: "alpha") + create_ready(queue_name: "beta") + + get "#{engine_root}/jobs" + + expect(response.body).to include('name="queue"') + end + + it "does not show the queue select when only one queue exists" do + create_ready(queue_name: "default") + + get "#{engine_root}/jobs" + + expect(response.body).not_to include('name="queue"') + end + end + + describe "GET /jobs?priority=" do + it "returns only jobs with the specified priority" do + create_ready(class_name: "HighPriJob", priority: 0) + create_ready(class_name: "LowPriJob", priority: 10) + + get "#{engine_root}/jobs", params: { priority: "10" } + + expect(response.body).to include("LowPriJob") + expect(response.body).not_to include("HighPriJob") + end + end + + describe "GET /jobs?period=" do + it "returns only jobs enqueued within the given period" do + old_job = create_ready(class_name: "OldJob") + old_job.ready_execution.update_columns(created_at: 2.days.ago) + old_job.update_columns(created_at: 2.days.ago) + + create_ready(class_name: "NewJob") + + get "#{engine_root}/jobs", params: { period: "1h" } + + expect(response.body).to include("NewJob") + expect(response.body).not_to include("OldJob") + end + + it "returns all jobs when no period is specified" do + old_job = create_ready(class_name: "OldJob") + old_job.ready_execution.update_columns(created_at: 2.days.ago) + old_job.update_columns(created_at: 2.days.ago) + + create_ready(class_name: "NewJob") + + get "#{engine_root}/jobs" + + expect(response.body).to include("OldJob") + expect(response.body).to include("NewJob") + end + end + + describe "combined filters" do + it "applies class and queue filters together" do + create_ready(class_name: "ReportJob", queue_name: "reports") + create_ready(class_name: "CleanupJob", queue_name: "reports") + create_ready(class_name: "ReportJob", queue_name: "default") + + get "#{engine_root}/jobs", params: { q: "Report", queue: "reports" } + + expect(response.body).to include("ReportJob") + expect(response.body).not_to include("CleanupJob") + # The default-queue ReportJob should be excluded — only one match in the reports queue + expect(response.body.scan("ReportJob").length).to eq(1) + end + end + + describe "filter persistence across tabs" do + it "preserves q param in tab links" do + get "#{engine_root}/jobs", params: { q: "Report" } + + expect(response.body).to include("q=Report") + end + + it "preserves period param in tab links" do + get "#{engine_root}/jobs", params: { period: "24h" } + + expect(response.body).to include("period=24h") + end + end +end