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 @@ -9,6 +9,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0

### Added

- Per-queue job browser — queue names and sizes on the Queues index are now links to `GET /queues/:id`, which shows a paginated list of ready jobs for that queue with job class, priority, and enqueued-at; individual "Discard" buttons remove a single job; a "Discard All Ready (N)" header button discards every ready job in the queue in one request; pause/resume controls are present on the show page so operators never need to leave the queue context
- Recurring task list — `GET /recurring_tasks` enumerates all `SolidQueue::RecurringTask` records with key, cron schedule, job class or command, queue, next-run time, last-run time, and static/dynamic badge; each row has a "Run Now" button that immediately enqueues the task via `RecurringTasks::RunsController`; "Recurring" link added to the queue subnav
- Job history view — `GET /history` lists all finished jobs with class name, queue, duration, and finished-at time; filterable by queue, class substring, and time period (1h / 24h / 7d); clicking a queue badge filters the history to that queue; CSV export respects active filters; "History" link added to the queue subnav
- Scheduled job management — "Run Now" and offset buttons (+1h / +24h / +7d) on each scheduled job row; Turbo Stream removes the row on run-now and updates the scheduled-at cell on offset reschedule; "Run All Now (N)" header button back-dates all matching scheduled executions; backed by `ScheduledJobsController` using standard CRUD (`update` for single, `create` for bulk via `run_all_now` collection route)
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 / bulk retry / bulk discard), pause/resume queues, and inspect worker processes; **Bulk selection** checkbox-selects individual jobs for discard or retry; **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 / bulk retry / bulk discard), pause/resume queues, and inspect worker processes; **Bulk selection** checkbox-selects individual jobs for discard or retry; **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; **Per-queue browser** — click any queue name or size to drill into its ready jobs with per-row and bulk discard
- **Job history view** — paginated list of all finished jobs with class name, queue, duration, and finished-at time; filterable by queue (click a badge), class substring, and time period; CSV export respects active filters
- **Scheduled job management** — "Run Now" and offset buttons (+1h / +24h / +7d) per row update the scheduled time inline via Turbo Stream; "Run All Now (N)" in the header back-dates all matching executions at once
- **Recurring task list** — enumerates all `SolidQueue::RecurringTask` records with cron schedule, job class or command, queue, next-run and last-run times, and a static/dynamic badge; each row has a "Run Now" button that immediately enqueues the task
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
> _Close the remaining Solid Queue feature gaps._

### Added
- **Per-queue job browser** — drill into any queue from the Queues list to see its ready jobs and discard them
- **Blocked job bulk discard** — "Discard all blocked" action on the blocked jobs view

---
Expand Down
1 change: 1 addition & 0 deletions app/assets/stylesheets/solid_stack_web/_02_layout.css
Original file line number Diff line number Diff line change
Expand Up @@ -72,6 +72,7 @@

.sqw-page-header { margin-bottom: 1.25rem; }
.sqw-page-title { font-size: 20px; font-weight: 600; }
.sqw-page-title-row { display: flex; align-items: center; gap: 0.5rem; }

@keyframes sqw-flash-dismiss {
0%, 86% { opacity: 1; max-height: 200px; margin-bottom: 1rem; }
Expand Down
Original file line number Diff line number Diff line change
@@ -1,22 +1,28 @@
module SolidStackWeb
module FailedJobs
class SelectionsController < ApplicationController
before_action :set_ids

def create
ids = Array(params[:job_ids]).map(&:to_i).reject(&:zero?)
SolidQueue::FailedExecution.where(id: ids).each(&:retry)
SolidQueue::FailedExecution.where(id: @ids).each(&:retry)
redirect_to failed_jobs_path
rescue => e
redirect_to failed_jobs_path, alert: "Could not retry jobs: #{e.message}"
end

def destroy
ids = Array(params[:job_ids]).map(&:to_i).reject(&:zero?)
job_ids = SolidQueue::FailedExecution.where(id: ids).pluck(:job_id)
job_ids = SolidQueue::FailedExecution.where(id: @ids).pluck(:job_id)
SolidQueue::Job.where(id: job_ids).destroy_all
redirect_to failed_jobs_path
rescue => e
redirect_to failed_jobs_path, alert: "Could not discard jobs: #{e.message}"
end

private

def set_ids
@ids = Array(params[:job_ids]).map(&:to_i).reject(&:zero?)
end
end
end
end
13 changes: 13 additions & 0 deletions app/controllers/solid_stack_web/queues/pauses_controller.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
module SolidStackWeb
class Queues::PausesController < ApplicationController
def create
::SolidQueue::Pause.find_or_create_by!(queue_name: params[:queue_id])
redirect_back_or_to queues_path
end

def destroy
::SolidQueue::Pause.find_by(queue_name: params[:queue_id])&.destroy
redirect_back_or_to queues_path
end
end
end
17 changes: 9 additions & 8 deletions app/controllers/solid_stack_web/queues_controller.rb
Original file line number Diff line number Diff line change
Expand Up @@ -13,14 +13,15 @@ def index
end
end

def pause
::SolidQueue::Pause.find_or_create_by!(queue_name: params[:id])
redirect_to queues_path
end

def resume
::SolidQueue::Pause.find_by(queue_name: params[:id])&.destroy
redirect_to queues_path
def show
@queue_name = params[:id]
@paused = ::SolidQueue::Pause.exists?(queue_name: @queue_name)
@pagy, @executions = pagy(
::SolidQueue::ReadyExecution
.where(queue_name: @queue_name)
.includes(:job)
.order(created_at: :desc)
)
end
end
end
8 changes: 4 additions & 4 deletions app/views/solid_stack_web/queues/index.html.erb
Original file line number Diff line number Diff line change
Expand Up @@ -15,8 +15,8 @@
<tbody>
<% @queues.each do |queue| %>
<tr>
<td class="sqw-monospace"><%= queue[:name] %></td>
<td><%= queue[:size] %></td>
<td class="sqw-monospace"><%= link_to queue[:name], queue_path(queue[:name]) %></td>
<td><%= link_to queue[:size], queue_path(queue[:name]) %></td>
<td>
<% if queue[:paused] %>
<span class="sqw-badge sqw-badge--paused">Paused</span>
Expand All @@ -26,10 +26,10 @@
</td>
<td class="sqw-actions">
<% if queue[:paused] %>
<%= button_to "Resume", resume_queue_path(queue[:name]),
<%= button_to "Resume", queue_pause_path(queue[:name]),
method: :delete, class: "sqw-btn sqw-btn--sm" %>
<% else %>
<%= button_to "Pause", pause_queue_path(queue[:name]),
<%= button_to "Pause", queue_pause_path(queue[:name]),
method: :post, class: "sqw-btn sqw-btn--sm" %>
<% end %>
</td>
Expand Down
67 changes: 67 additions & 0 deletions app/views/solid_stack_web/queues/show.html.erb
Original file line number Diff line number Diff line change
@@ -0,0 +1,67 @@
<div class="sqw-page-header sqw-page-header--split">
<div>
<div class="sqw-breadcrumb">
<%= link_to "Queues", queues_path %> &rsaquo; <%= @queue_name %>
</div>
<div class="sqw-page-title-row">
<h1 class="sqw-page-title sqw-monospace"><%= @queue_name %></h1>
<% if @paused %>
<span class="sqw-badge sqw-badge--paused">Paused</span>
<% else %>
<span class="sqw-badge sqw-badge--ready">Running</span>
<% end %>
</div>
</div>
<div class="sqw-header-actions">
<% if @paused %>
<%= button_to "Resume", queue_pause_path(@queue_name),
method: :delete, class: "sqw-btn sqw-btn--sm" %>
<% else %>
<%= button_to "Pause", queue_pause_path(@queue_name),
method: :post, class: "sqw-btn sqw-btn--sm" %>
<% end %>
<% if @executions.any? %>
<%= button_to "Discard All Ready (#{@pagy.count})",
discard_all_jobs_path(status: "ready", queue: @queue_name),
method: :post,
class: "sqw-btn sqw-btn--danger sqw-btn--sm",
data: { turbo_confirm: "Discard all #{@pagy.count} ready jobs in #{@queue_name}? This cannot be undone.",
turbo_frame: "_top" } %>
<% end %>
</div>
</div>

<% if @executions.any? %>
<table class="sqw-table">
<thead>
<tr>
<th>Job Class</th>
<th>Priority</th>
<th>Enqueued At</th>
<th></th>
</tr>
</thead>
<tbody>
<% @executions.each do |execution| %>
<tr>
<td class="sqw-monospace">
<%= link_to execution.job.class_name, job_path(execution.id, status: "ready"),
data: { turbo_frame: "_top" } %>
</td>
<td><%= execution.job.priority %></td>
<td class="sqw-muted"><%= execution.created_at.strftime("%b %d %H:%M") %></td>
<td class="sqw-actions">
<%= button_to "Discard", job_path(execution, status: "ready", queue: @queue_name),
method: :delete, class: "sqw-btn sqw-btn--danger sqw-btn--sm",
data: { turbo_confirm: "Discard this job?" } %>
</td>
</tr>
<% end %>
</tbody>
</table>
<%== pagy_nav(@pagy) if @pagy.pages > 1 %>
<% else %>
<div class="sqw-empty">
<p>No ready jobs in <strong><%= @queue_name %></strong>.</p>
</div>
<% end %>
7 changes: 2 additions & 5 deletions config/routes.rb
Original file line number Diff line number Diff line change
Expand Up @@ -25,11 +25,8 @@
resource :arguments, only: [:update], controller: "failed_jobs/arguments"
end

resources :queues, only: [:index] do
member do
post :pause
delete :resume
end
resources :queues, only: [:index, :show] do
resource :pause, only: [:create, :destroy], controller: "queues/pauses"
end

resources :processes, only: [:index]
Expand Down
55 changes: 51 additions & 4 deletions spec/requests/solid_stack_web/queues_spec.rb
Original file line number Diff line number Diff line change
Expand Up @@ -35,7 +35,7 @@ def create_ready(queue_name: "default")
end
end

describe "POST /queues/:id/pause" do
describe "POST /queues/:queue_id/pause" do
it "pauses the queue and redirects" do
create_ready(queue_name: "default")
post "#{engine_root}/queues/default/pause"
Expand All @@ -50,16 +50,63 @@ def create_ready(queue_name: "default")
end
end

describe "DELETE /queues/:id/resume" do
describe "GET /queues/:id" do
it "returns 200 and lists ready jobs for the queue" do
create_ready(queue_name: "urgent")
get "#{engine_root}/queues/urgent"
expect(response).to have_http_status(:ok)
expect(response.body).to include("urgent")
expect(response.body).to include("MyJob")
end

it "shows the running badge when the queue is not paused" do
create_ready(queue_name: "urgent")
get "#{engine_root}/queues/urgent"
expect(response.body).to include("Running")
end

it "shows the paused badge when the queue is paused" do
create_ready(queue_name: "urgent")
SolidQueue::Pause.create!(queue_name: "urgent")
get "#{engine_root}/queues/urgent"
expect(response.body).to include("Paused")
end

it "shows an empty state when the queue has no ready jobs" do
get "#{engine_root}/queues/ghost"
expect(response).to have_http_status(:ok)
expect(response.body).to include("No ready jobs")
end

it "does not show jobs from other queues" do
create_ready(queue_name: "alpha")
create_ready(queue_name: "beta")
get "#{engine_root}/queues/alpha"
expect(response.body).not_to include("beta")
end

it "includes a breadcrumb link back to queues" do
get "#{engine_root}/queues/urgent"
expect(response.body).to include("#{engine_root}/queues")
end

it "includes a discard all button when jobs exist" do
create_ready(queue_name: "urgent")
get "#{engine_root}/queues/urgent"
expect(response.body).to include("Discard All Ready")
end
end

describe "DELETE /queues/:queue_id/pause" do
it "resumes a paused queue and redirects" do
SolidQueue::Pause.create!(queue_name: "default")
delete "#{engine_root}/queues/default/resume"
delete "#{engine_root}/queues/default/pause"
expect(response).to redirect_to("#{engine_root}/queues")
expect(SolidQueue::Pause.exists?(queue_name: "default")).to be false
end

it "is a no-op when the queue is not paused" do
expect { delete "#{engine_root}/queues/default/resume" }.not_to raise_error
expect { delete "#{engine_root}/queues/default/pause" }.not_to raise_error
expect(response).to redirect_to("#{engine_root}/queues")
end
end
Expand Down