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

### Fixed

Expand Down
3 changes: 3 additions & 0 deletions Gemfile.lock
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ PATH
remote: .
specs:
solid_stack_web (0.1.0)
csv (>= 3.0)
pagy (>= 43.0)
rails (>= 8.1.3)
solid_cable (>= 1.0)
Expand Down Expand Up @@ -96,6 +97,7 @@ GEM
concurrent-ruby (1.3.6)
connection_pool (3.0.2)
crass (1.0.6)
csv (3.3.5)
date (3.5.1)
diff-lcs (1.6.2)
docile (1.4.1)
Expand Down Expand Up @@ -350,6 +352,7 @@ CHECKSUMS
concurrent-ruby (1.3.6) sha256=6b56837e1e7e5292f9864f34b69c5a2cbc75c0cf5338f1ce9903d10fa762d5ab
connection_pool (3.0.2) sha256=33fff5ba71a12d2aa26cb72b1db8bba2a1a01823559fb01d29eb74c286e62e0a
crass (1.0.6) sha256=dc516022a56e7b3b156099abc81b6d2b08ea1ed12676ac7a5657617f012bd45d
csv (3.3.5) sha256=6e5134ac3383ef728b7f02725d9872934f523cb40b961479f69cf3afa6c8e73f
date (3.5.1) sha256=750d06384d7b9c15d562c76291407d89e368dda4d4fff957eb94962d325a0dc0
diff-lcs (1.6.2) sha256=9ae0d2cba7d4df3075fe8cd8602a8604993efc0dfa934cff568969efb1909962
docile (1.4.1) sha256=96159be799bfa73cdb721b840e9802126e4e03dfc26863db73647204c727f21e
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
- **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
- **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 @@ -15,7 +15,6 @@ The path to v1.0.0 is staged: first achieve feature parity with `solid_queue_das
- **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
- **CSV export** — download jobs or failed jobs as CSV (class, queue, priority, enqueued_at, error)

---

Expand Down
3 changes: 2 additions & 1 deletion app/assets/stylesheets/solid_stack_web/_09_detail.css
Original file line number Diff line number Diff line change
Expand Up @@ -20,7 +20,8 @@
justify-content: space-between;
}

.sqw-detail-actions {
.sqw-detail-actions,
.sqw-header-actions {
display: flex;
gap: 0.5rem;
}
Expand Down
2 changes: 2 additions & 0 deletions app/controllers/solid_stack_web/application_controller.rb
Original file line number Diff line number Diff line change
@@ -1,3 +1,5 @@
require "csv"

module SolidStackWeb
class ApplicationController < ActionController::Base
include Pagy::Method
Expand Down
28 changes: 26 additions & 2 deletions app/controllers/solid_stack_web/failed_jobs_controller.rb
Original file line number Diff line number Diff line change
@@ -1,8 +1,17 @@
module SolidStackWeb
class FailedJobsController < ApplicationController
def index
scope = ::SolidQueue::FailedExecution.includes(:job).order(created_at: :desc)
@pagy, @executions = pagy(scope)
respond_to do |format|
format.html do
scope = ::SolidQueue::FailedExecution.includes(:job).order(created_at: :desc)
@pagy, @executions = pagy(scope)
end
format.csv do
send_data failed_jobs_csv,
filename: "failed-jobs-#{Date.today}.csv",
type: "text/csv", disposition: "attachment"
end
end
end

def destroy
Expand All @@ -21,5 +30,20 @@ def retry
execution.retry
redirect_to failed_jobs_path
end

private

def failed_jobs_csv
CSV.generate(headers: true) do |csv|
csv << %w[id class_name queue_name error_class error_message failed_at]
::SolidQueue::FailedExecution.includes(:job).order(created_at: :desc).each do |execution|
job = execution.job
error = execution.error || {}
csv << [job.id, job.class_name, job.queue_name,
error["exception_class"], error["message"],
execution.created_at.iso8601]
end
end
end
end
end
19 changes: 18 additions & 1 deletion app/controllers/solid_stack_web/jobs_controller.rb
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,14 @@ def index
@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)
respond_to do |format|
format.html { @pagy, @executions = pagy(filtered_scope) }
format.csv do
send_data jobs_csv,
filename: "jobs-#{@status}-#{Date.today}.csv",
type: "text/csv", disposition: "attachment"
end
end
end

def show
Expand Down Expand Up @@ -52,6 +59,16 @@ def require_discardable
head :unprocessable_content unless Job::DISCARDABLE.include?(@status)
end

def jobs_csv
CSV.generate(headers: true) do |csv|
csv << %w[id class_name queue_name status priority enqueued_at]
filtered_scope.each do |execution|
job = execution.job
csv << [job.id, job.class_name, job.queue_name, @status, job.priority, job.created_at.iso8601]
end
end
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?
Expand Down
6 changes: 5 additions & 1 deletion app/views/solid_stack_web/failed_jobs/index.html.erb
Original file line number Diff line number Diff line change
@@ -1,5 +1,9 @@
<div class="sqw-page-header">
<div class="sqw-page-header sqw-page-header--split">
<h1 class="sqw-page-title">Failed Jobs</h1>
<div class="sqw-header-actions">
<%= link_to "Export CSV", failed_jobs_path(format: :csv),
class: "sqw-btn sqw-btn--muted sqw-btn--sm", data: { turbo: false } %>
</div>
</div>

<div id="sqw-jobs-table">
Expand Down
20 changes: 12 additions & 8 deletions app/views/solid_stack_web/jobs/index.html.erb
Original file line number Diff line number Diff line change
@@ -1,13 +1,17 @@
<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 class="sqw-header-actions">
<%= link_to "Export CSV", jobs_path(format: :csv, status: @status, q: @search, queue: @queue, period: @period, priority: @priority),
class: "sqw-btn sqw-btn--muted sqw-btn--sm", data: { turbo: false } %>
<% 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>

<div class="sqw-tabs">
Expand Down
1 change: 1 addition & 0 deletions solid_stack_web.gemspec
Original file line number Diff line number Diff line change
Expand Up @@ -28,4 +28,5 @@ Gem::Specification.new do |spec|
spec.add_dependency "solid_cache", ">= 1.0"
spec.add_dependency "solid_cable", ">= 1.0"
spec.add_dependency "turbo-rails", ">= 2.0"
spec.add_dependency "csv", ">= 3.0"
end
30 changes: 30 additions & 0 deletions spec/requests/solid_stack_web/failed_jobs_spec.rb
Original file line number Diff line number Diff line change
Expand Up @@ -76,6 +76,36 @@ def create_failed(class_name: "FailingJob", queue_name: "default")
end
end

describe "GET /failed_jobs.csv" do
it "returns a CSV attachment" do
get "#{engine_root}/failed_jobs.csv"

expect(response).to have_http_status(:ok)
expect(response.media_type).to eq("text/csv")
expect(response.headers["Content-Disposition"]).to include("attachment")
expect(response.headers["Content-Disposition"]).to include(".csv")
end

it "includes the correct headers" do
get "#{engine_root}/failed_jobs.csv"

headers = response.body.lines.first.chomp
expect(headers).to eq("id,class_name,queue_name,error_class,error_message,failed_at")
end

it "includes one row per failed job with error details" do
create_failed(class_name: "BrokenJob")

get "#{engine_root}/failed_jobs.csv"

rows = CSV.parse(response.body, headers: true)
expect(rows.length).to eq(1)
expect(rows.first["class_name"]).to eq("BrokenJob")
expect(rows.first["error_class"]).to eq("RuntimeError")
expect(rows.first["error_message"]).to eq("something went wrong")
end
end

describe "POST /failed_jobs/:id/retry" do
it "re-enqueues the job and redirects" do
execution = create_failed
Expand Down
40 changes: 40 additions & 0 deletions spec/requests/solid_stack_web/jobs_spec.rb
Original file line number Diff line number Diff line change
Expand Up @@ -299,6 +299,46 @@ def create_ready(class_name: "MyJob", queue_name: "default", priority: 0)
end
end

describe "GET /jobs.csv" do
it "returns a CSV attachment" do
get "#{engine_root}/jobs.csv", params: { status: "ready" }

expect(response).to have_http_status(:ok)
expect(response.media_type).to eq("text/csv")
expect(response.headers["Content-Disposition"]).to include("attachment")
expect(response.headers["Content-Disposition"]).to include(".csv")
end

it "includes the correct headers" do
get "#{engine_root}/jobs.csv", params: { status: "ready" }

headers = response.body.lines.first.chomp
expect(headers).to eq("id,class_name,queue_name,status,priority,enqueued_at")
end

it "includes one row per job" do
create_ready(class_name: "ReportJob", queue_name: "reports")
create_ready(class_name: "CleanupJob", queue_name: "default")

get "#{engine_root}/jobs.csv", params: { status: "ready" }

rows = CSV.parse(response.body, headers: true)
expect(rows.length).to eq(2)
expect(rows.map { |r| r["class_name"] }).to contain_exactly("ReportJob", "CleanupJob")
end

it "applies active filters to the export" do
create_ready(class_name: "ReportJob", queue_name: "reports")
create_ready(class_name: "CleanupJob", queue_name: "default")

get "#{engine_root}/jobs.csv", params: { status: "ready", queue: "reports" }

rows = CSV.parse(response.body, headers: true)
expect(rows.length).to eq(1)
expect(rows.first["class_name"]).to eq("ReportJob")
end
end

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