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

- 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
Expand Down
15 changes: 14 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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.
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
> _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
Expand Down
59 changes: 59 additions & 0 deletions app/assets/stylesheets/solid_stack_web/_08_filters.css
Original file line number Diff line number Diff line change
@@ -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;
}
2 changes: 2 additions & 0 deletions app/controllers/solid_stack_web/application_controller.rb
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
43 changes: 26 additions & 17 deletions app/controllers/solid_stack_web/jobs_controller.rb
Original file line number Diff line number Diff line change
@@ -1,42 +1,51 @@
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

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
21 changes: 21 additions & 0 deletions app/models/solid_stack_web/job.rb
Original file line number Diff line number Diff line change
@@ -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
45 changes: 41 additions & 4 deletions app/views/solid_stack_web/jobs/index.html.erb
Original file line number Diff line number Diff line change
Expand Up @@ -3,12 +3,49 @@
</div>

<div class="sqw-tabs">
<% [["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 %>
</div>

<form class="sqw-filters" action="<%= jobs_path %>" method="get">
<%= hidden_field_tag :status, @status %>
<%= hidden_field_tag :period, @period %>
<input class="sqw-search-input" type="search" name="q" value="<%= @search %>"
placeholder="Filter by job class…" autocomplete="off" aria-label="Filter by job class">
<% if @queue_options.size > 1 %>
<select name="queue" class="sqw-select" aria-label="Filter by queue" onchange="this.form.submit()">
<option value="">All queues</option>
<% @queue_options.each do |q| %>
<option value="<%= q %>" <%= "selected" if @queue == q %>><%= q %></option>
<% end %>
</select>
<% end %>
<% if @priority_options.size > 1 %>
<select name="priority" class="sqw-select" aria-label="Filter by priority" onchange="this.form.submit()">
<option value="">All priorities</option>
<% @priority_options.each do |p| %>
<option value="<%= p %>" <%= "selected" if @priority.to_s == p.to_s %>>Priority <%= p %></option>
<% end %>
</select>
<% end %>
<button type="submit" class="sqw-btn sqw-btn--muted sqw-btn--sm">Search</button>
<% 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 %>
<div class="sqw-period-filter" role="group" aria-label="Time period">
<%= 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"}" %>
</div>
</form>

<div id="sqw-jobs-table">
<% if @executions.any? %>
<table class="sqw-table">
Expand All @@ -34,7 +71,7 @@
<% end %>
<td class="sqw-actions">
<% 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 %>
Expand All @@ -47,4 +84,4 @@
<% else %>
<%= render "empty" %>
<% end %>
</div>
</div>
Loading
Loading