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 @@ -13,6 +13,10 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
- 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
- Job filter Turbo Frame — filter form and results table wrapped in a `<turbo-frame>` so applying filters reloads only the table without a full page refresh; `data-turbo-action="advance"` keeps the URL in sync; Turbo JS loaded from esm.sh CDN in the engine layout

### Added

- **Discard All** — "Discard All (N)" button on the jobs index header discards every job matching the current filters (class, queue, priority, period) in one request; respects the discardable-status guard so claimed jobs cannot be bulk-discarded; route `POST /jobs/discard_all` merges into the existing `destroy` action branching on `params[:id]`

### Fixed

- `FailedJobsController#destroy` used a local variable instead of `@execution`, making the Turbo Stream row-removal template a no-op
Expand Down
2 changes: 1 addition & 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; cards are clickable and link directly to each section
- **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 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; **Discard All** bulk-discards every job matching the current filters in one request
- **Job detail page** — drill into any job to see full arguments (pretty-printed JSON), queue, priority, enqueued time, Active Job ID, concurrency key, scheduled/blocked-until metadata, and a Discard button
- **Solid Cache** — entry count and total byte size at a glance
- **Solid Cable** — active message count and distinct channel count
Expand Down
1 change: 0 additions & 1 deletion ROADMAP.md
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,6 @@ The path to v1.0.0 is staged: first achieve feature parity with `solid_queue_das
- **Bulk retry (failed jobs)** — retry selected failed jobs with optional stagger interval (5 s / 10 s / 30 s / 1 m) to avoid thundering-herd restarts
- **Edit arguments & retry** — inline argument editor on failed job detail; retry with modified payload
- **CSV export** — download jobs or failed jobs as CSV (class, queue, priority, enqueued_at, error)
- **Discard all** convenience action for ready/scheduled/blocked lists

---

Expand Down
23 changes: 14 additions & 9 deletions app/controllers/solid_stack_web/jobs_controller.rb
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
module SolidStackWeb
class JobsController < ApplicationController
before_action :set_status
before_action :set_filters, only: :index
before_action :set_filters, only: [:index, :destroy]
before_action :require_discardable, only: :destroy

def index
Expand All @@ -13,20 +13,25 @@ def index

def show
@execution = Job::EXECUTION_MODELS[@status].includes(:job).find(params[:id])
@job = @execution.job
@arguments = JSON.parse(@job.arguments) if @job.arguments.present?
@arguments = JSON.parse(@execution.job.arguments) if @execution.job.arguments.present?
rescue JSON::ParserError
@arguments = nil
end

def destroy
@execution = Job::EXECUTION_MODELS[@status].find(params[:id])
@execution.job.destroy!
@executions_remain = Job::EXECUTION_MODELS[@status].exists?
if params[:id]
@execution = Job::EXECUTION_MODELS[@status].find(params[:id])
@execution.job.destroy!
@executions_remain = Job::EXECUTION_MODELS[@status].exists?

respond_to do |format|
format.html { redirect_to jobs_path(status: @status, q: params[:q], queue: params[:queue], period: params[:period], priority: params[:priority]) }
format.turbo_stream
respond_to do |format|
format.html { redirect_to jobs_path(status: @status, q: @search, queue: @queue, period: @period, priority: @priority) }
format.turbo_stream
end
else
job_ids = filtered_scope.pluck(:job_id)
SolidQueue::Job.where(id: job_ids).destroy_all
redirect_to jobs_path(status: @status, q: @search, queue: @queue, period: @period, priority: @priority)
end
end

Expand Down
10 changes: 9 additions & 1 deletion app/views/solid_stack_web/jobs/index.html.erb
Original file line number Diff line number Diff line change
@@ -1,5 +1,13 @@
<div class="sqw-page-header">
<div class="sqw-page-header sqw-page-header--split">
<h1 class="sqw-page-title">Jobs</h1>
<% if SolidStackWeb::Job::DISCARDABLE.include?(@status) && @executions&.any? %>
<%= button_to "Discard All (#{@pagy.count})",
discard_all_jobs_path(status: @status, q: @search, queue: @queue, period: @period, priority: @priority),
method: :post,
class: "sqw-btn sqw-btn--danger sqw-btn--sm",
data: { turbo_confirm: "Discard all #{@pagy.count} jobs? This cannot be undone.",
turbo_frame: "_top" } %>
<% end %>
</div>

<div class="sqw-tabs">
Expand Down
18 changes: 9 additions & 9 deletions app/views/solid_stack_web/jobs/show.html.erb
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@
<div class="sqw-breadcrumb">
<%= link_to "Jobs", jobs_path(status: params[:status]) %> &rsaquo; Detail
</div>
<h1 class="sqw-page-title sqw-monospace"><%= @job.class_name %></h1>
<h1 class="sqw-page-title sqw-monospace"><%= @execution.job.class_name %></h1>
</div>

<% if SolidStackWeb::Job::DISCARDABLE.include?(@status) %>
Expand All @@ -23,35 +23,35 @@
<dd><span class="sqw-badge sqw-badge--<%= @status %>"><%= SolidStackWeb::Job::TAB_LABELS[@status] %></span></dd>

<dt>Queue</dt>
<dd class="sqw-monospace"><%= @job.queue_name %></dd>
<dd class="sqw-monospace"><%= @execution.job.queue_name %></dd>

<dt>Priority</dt>
<dd><%= @job.priority %></dd>
<dd><%= @execution.job.priority %></dd>

<dt>Active Job ID</dt>
<dd class="sqw-monospace sqw-truncate" title="<%= @job.active_job_id %>"><%= @job.active_job_id.presence || "—" %></dd>
<dd class="sqw-monospace sqw-truncate" title="<%= @execution.job.active_job_id %>"><%= @execution.job.active_job_id.presence || "—" %></dd>

<dt>Concurrency Key</dt>
<dd class="sqw-monospace"><%= @job.concurrency_key.presence || "—" %></dd>
<dd class="sqw-monospace"><%= @execution.job.concurrency_key.presence || "—" %></dd>

<% if @status == "blocked" %>
<dt>Blocked Until</dt>
<dd class="sqw-monospace"><%= @execution.expires_at ? @execution.expires_at.strftime("%Y-%m-%d %H:%M:%S UTC") : "—" %></dd>
<% end %>

<dt>Enqueued At</dt>
<dd class="sqw-monospace"><%= @job.created_at.strftime("%Y-%m-%d %H:%M:%S UTC") %></dd>
<dd class="sqw-monospace"><%= @execution.job.created_at.strftime("%Y-%m-%d %H:%M:%S UTC") %></dd>

<dt>Scheduled At</dt>
<dd class="sqw-monospace"><%= @job.scheduled_at ? @job.scheduled_at.strftime("%Y-%m-%d %H:%M:%S UTC") : "—" %></dd>
<dd class="sqw-monospace"><%= @execution.job.scheduled_at ? @execution.job.scheduled_at.strftime("%Y-%m-%d %H:%M:%S UTC") : "—" %></dd>

<dt>Finished At</dt>
<dd class="sqw-monospace"><%= @job.finished_at ? @job.finished_at.strftime("%Y-%m-%d %H:%M:%S UTC") : "—" %></dd>
<dd class="sqw-monospace"><%= @execution.job.finished_at ? @execution.job.finished_at.strftime("%Y-%m-%d %H:%M:%S UTC") : "—" %></dd>
</dl>
</div>

<div class="sqw-detail-card sqw-detail-section">
<h2 class="sqw-section-title">Arguments</h2>
<pre class="sqw-code-block"><%= @arguments ? JSON.pretty_generate(@arguments) : (@job.arguments || "—") %></pre>
<pre class="sqw-code-block"><%= @arguments ? JSON.pretty_generate(@arguments) : (@execution.job.arguments || "—") %></pre>
</div>
</div>
4 changes: 3 additions & 1 deletion config/routes.rb
Original file line number Diff line number Diff line change
@@ -1,7 +1,9 @@
SolidStackWeb::Engine.routes.draw do
root to: "dashboard#index"

resources :jobs, only: [:index, :show, :destroy]
resources :jobs, only: [:index, :show, :destroy] do
collection { post :discard_all, action: :destroy }
end

resources :failed_jobs, only: [:index, :destroy] do
member { post :retry }
Expand Down
39 changes: 39 additions & 0 deletions spec/requests/solid_stack_web/jobs_spec.rb
Original file line number Diff line number Diff line change
Expand Up @@ -260,6 +260,45 @@ def create_ready(class_name: "MyJob", queue_name: "default", priority: 0)
end
end

describe "POST /jobs/discard_all" do
it "destroys all jobs in the current status and redirects" do
create_ready(class_name: "JobA")
create_ready(class_name: "JobB")

post "#{engine_root}/jobs/discard_all", params: { status: "ready" }

expect(response).to redirect_to("#{engine_root}/jobs?status=ready")
expect(SolidQueue::ReadyExecution.count).to eq(0)
end

it "only discards jobs matching active filters" do
create_ready(class_name: "ReportJob", queue_name: "reports")
create_ready(class_name: "CleanupJob", queue_name: "default")

post "#{engine_root}/jobs/discard_all", params: { status: "ready", queue: "reports" }

expect(SolidQueue::Job.where(class_name: "ReportJob").exists?).to be false
expect(SolidQueue::Job.where(class_name: "CleanupJob").exists?).to be true
end

it "preserves filter params in the redirect" do
create_ready(queue_name: "reports")

post "#{engine_root}/jobs/discard_all",
params: { status: "ready", queue: "reports", q: "Report", period: "1h" }

expect(response.location).to include("queue=reports")
expect(response.location).to include("q=Report")
expect(response.location).to include("period=1h")
end

it "returns 422 when status is not discardable" do
post "#{engine_root}/jobs/discard_all", params: { status: "claimed" }

expect(response).to have_http_status(:unprocessable_content)
end
end

describe "combined filters" do
it "applies class and queue filters together" do
create_ready(class_name: "ReportJob", queue_name: "reports")
Expand Down