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
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0

### Added

- **Bulk selection and discard** — checkbox column on the jobs list for ready, scheduled, and blocked statuses; "Discard Selected" submits only the checked jobs via `DELETE /jobs/selection` (`Jobs::SelectionsController#destroy`); "Select All" header checkbox toggles all rows; filter state is preserved in the redirect after a bulk discard
- **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]`
- **CSV export** — "Export CSV" button on jobs and failed-jobs index pages; export respects active filters so operators download exactly what they see on screen; columns: `id, class_name, queue_name, status, priority, enqueued_at` for jobs and `id, class_name, queue_name, error_class, error_message, failed_at` for failed jobs

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; **Discard All** bulk-discards every job matching the current filters in one request; **CSV export** downloads jobs or failed jobs as a CSV file respecting active filters
- **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; **Bulk selection** checkbox-selects individual jobs for discard; **Discard All** bulk-discards every job matching the current filters in one request; **CSV export** downloads jobs or failed jobs as a CSV file respecting active filters
- **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
2 changes: 0 additions & 2 deletions ROADMAP.md
Original file line number Diff line number Diff line change
Expand Up @@ -11,8 +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
- **Bulk selection** — checkbox-driven multi-select on the jobs and failed-jobs lists
- **Bulk discard** — discard all selected jobs in a single request
- **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

Expand Down
24 changes: 24 additions & 0 deletions app/controllers/solid_stack_web/jobs/selections_controller.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
module SolidStackWeb
module Jobs
class SelectionsController < ApplicationController
def destroy
status = params[:status].presence_in(Job::STATUSES) || "ready"
raise ArgumentError, "Cannot discard #{status} jobs." unless Job::DISCARDABLE.include?(status)

ids = Array(params[:job_ids]).map(&:to_i).reject(&:zero?)
job_ids = Job::EXECUTION_MODELS[status].where(id: ids).pluck(:job_id)
SolidQueue::Job.where(id: job_ids).destroy_all

redirect_to jobs_path(
status: status,
q: params[:q].presence,
queue: params[:queue].presence,
period: params[:period].presence_in(PERIOD_DURATIONS.keys),
priority: params[:priority].presence
)
rescue ArgumentError => e
redirect_to jobs_path(status: params[:status]), alert: e.message
end
end
end
end
2 changes: 1 addition & 1 deletion app/controllers/solid_stack_web/jobs_controller.rb
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@ module SolidStackWeb
class JobsController < ApplicationController
before_action :set_status
before_action :set_filters, only: [:index, :destroy]
before_action :require_discardable, only: :destroy
before_action :require_discardable, only: [:destroy]

def index
@queue_options = Job::EXECUTION_MODELS[@status].joins(:job).distinct.pluck("solid_queue_jobs.queue_name").sort
Expand Down
88 changes: 57 additions & 31 deletions app/views/solid_stack_web/jobs/index.html.erb
Original file line number Diff line number Diff line change
Expand Up @@ -61,39 +61,65 @@

<div id="sqw-jobs-table">
<% if @executions.any? %>
<table class="sqw-table">
<thead>
<tr>
<th>Job Class</th>
<th>Queue</th>
<th>Priority</th>
<th>Enqueued At</th>
<% if @status == "scheduled" %><th>Scheduled At</th><% end %>
<th></th>
</tr>
</thead>
<tbody>
<% @executions.each do |execution| %>
<tr id="execution_<%= execution.id %>">
<td class="sqw-monospace"><%= link_to execution.job.class_name, job_path(execution.id, status: @status), data: { turbo_frame: "_top" } %></td>
<td><span class="sqw-badge sqw-badge--queue"><%= execution.job.queue_name %></span></td>
<td><%= execution.job.priority %></td>
<td class="sqw-muted"><%= execution.created_at.strftime("%b %d %H:%M") %></td>
<% if @status == "scheduled" %>
<td class="sqw-muted"><%= execution.scheduled_at&.strftime("%b %d %H:%M") %></td>
<%= form_with url: job_selection_path,
method: :delete,
data: { turbo_frame: "_top" } do |f| %>
<%= f.hidden_field :status, value: @status %>
<%= f.hidden_field :q, value: @search %>
<%= f.hidden_field :queue, value: @queue %>
<%= f.hidden_field :period, value: @period %>
<%= f.hidden_field :priority, value: @priority %>

<% if SolidStackWeb::Job::DISCARDABLE.include?(@status) %>
<div class="sqw-selection-bar">
<%= f.submit "Discard Selected",
class: "sqw-btn sqw-btn--danger sqw-btn--sm",
data: { turbo_confirm: "Discard selected jobs? This cannot be undone." } %>
</div>
<% end %>

<table class="sqw-table">
<thead>
<tr>
<% if SolidStackWeb::Job::DISCARDABLE.include?(@status) %>
<th><input type="checkbox" class="sqw-checkbox" aria-label="Select all"
onclick="this.closest('form').querySelectorAll('.sqw-checkbox-row').forEach(cb => cb.checked = this.checked)"></th>
<% end %>
<td class="sqw-actions">
<% if %w[ready scheduled blocked].include?(@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 %>
</td>
<th>Job Class</th>
<th>Queue</th>
<th>Priority</th>
<th>Enqueued At</th>
<% if @status == "scheduled" %><th>Scheduled At</th><% end %>
<th></th>
</tr>
<% end %>
</tbody>
</table>
<%== pagy_nav(@pagy) if @pagy.pages > 1 %>
</thead>
<tbody>
<% @executions.each do |execution| %>
<tr id="execution_<%= execution.id %>">
<% if SolidStackWeb::Job::DISCARDABLE.include?(@status) %>
<td><input type="checkbox" name="job_ids[]" value="<%= execution.id %>"
class="sqw-checkbox sqw-checkbox-row" aria-label="Select <%= execution.job.class_name %>"></td>
<% end %>
<td class="sqw-monospace"><%= link_to execution.job.class_name, job_path(execution.id, status: @status), data: { turbo_frame: "_top" } %></td>
<td><span class="sqw-badge sqw-badge--queue"><%= execution.job.queue_name %></span></td>
<td><%= execution.job.priority %></td>
<td class="sqw-muted"><%= execution.created_at.strftime("%b %d %H:%M") %></td>
<% if @status == "scheduled" %>
<td class="sqw-muted"><%= execution.scheduled_at&.strftime("%b %d %H:%M") %></td>
<% end %>
<td class="sqw-actions">
<% if %w[ready scheduled blocked].include?(@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 %>
</td>
</tr>
<% end %>
</tbody>
</table>
<%== pagy_nav(@pagy) if @pagy.pages > 1 %>
<% end %>
<% else %>
<%= render "empty" %>
<% end %>
Expand Down
6 changes: 5 additions & 1 deletion config/routes.rb
Original file line number Diff line number Diff line change
@@ -1,8 +1,12 @@
SolidStackWeb::Engine.routes.draw do
root to: "dashboard#index"

resource :job_selection, path: "jobs/selection", only: [:destroy], controller: "jobs/selections"

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

resources :failed_jobs, only: [:index, :destroy] do
Expand Down
46 changes: 43 additions & 3 deletions spec/requests/solid_stack_web/jobs_spec.rb
Original file line number Diff line number Diff line change
Expand Up @@ -86,7 +86,7 @@ def create_ready(class_name: "MyJob", queue_name: "default", priority: 0)

get "#{engine_root}/jobs"

expect(response.body).not_to include('name="queue"')
expect(response.body).not_to include('aria-label="Filter by queue"')
end
end

Expand Down Expand Up @@ -260,6 +260,46 @@ def create_ready(class_name: "MyJob", queue_name: "default", priority: 0)
end
end

describe "DELETE /jobs/selection" do
it "destroys only the selected jobs and redirects" do
job_a = create_ready(class_name: "JobA")
job_b = create_ready(class_name: "JobB")

delete "#{engine_root}/jobs/selection",
params: { status: "ready", job_ids: [job_a.ready_execution.id] }

expect(response).to redirect_to("#{engine_root}/jobs?status=ready")
expect(SolidQueue::Job.exists?(job_a.id)).to be false
expect(SolidQueue::Job.exists?(job_b.id)).to be true
end

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

delete "#{engine_root}/jobs/selection",
params: { status: "ready", queue: "reports", q: "Report", period: "1h",
job_ids: [job.ready_execution.id] }

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

it "is a no-op when no job_ids are submitted" do
create_ready(class_name: "JobA")

delete "#{engine_root}/jobs/selection", params: { status: "ready", job_ids: [] }

expect(SolidQueue::ReadyExecution.count).to eq(1)
end

it "redirects when status is not discardable" do
delete "#{engine_root}/jobs/selection", params: { status: "claimed" }

expect(response).to redirect_to("#{engine_root}/jobs?status=claimed")
end
end

describe "POST /jobs/discard_all" do
it "destroys all jobs in the current status and redirects" do
create_ready(class_name: "JobA")
Expand Down Expand Up @@ -349,8 +389,8 @@ def create_ready(class_name: "MyJob", queue_name: "default", priority: 0)

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)
# Exactly one job row — only the reports-queue ReportJob
expect(response.body.scan('<td class="sqw-monospace">').length).to eq(1)
end
end

Expand Down