diff --git a/CHANGELOG.md b/CHANGELOG.md index 7889f01..275dc7c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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 diff --git a/Gemfile.lock b/Gemfile.lock index 812b68d..600a1dd 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -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) @@ -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) @@ -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 diff --git a/README.md b/README.md index 463fe47..1e8f85b 100644 --- a/README.md +++ b/README.md @@ -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 diff --git a/ROADMAP.md b/ROADMAP.md index c377912..d5b3f9f 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -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) --- diff --git a/app/assets/stylesheets/solid_stack_web/_09_detail.css b/app/assets/stylesheets/solid_stack_web/_09_detail.css index 0320ddb..e897dda 100644 --- a/app/assets/stylesheets/solid_stack_web/_09_detail.css +++ b/app/assets/stylesheets/solid_stack_web/_09_detail.css @@ -20,7 +20,8 @@ justify-content: space-between; } -.sqw-detail-actions { +.sqw-detail-actions, +.sqw-header-actions { display: flex; gap: 0.5rem; } diff --git a/app/controllers/solid_stack_web/application_controller.rb b/app/controllers/solid_stack_web/application_controller.rb index 0542a74..12051f4 100644 --- a/app/controllers/solid_stack_web/application_controller.rb +++ b/app/controllers/solid_stack_web/application_controller.rb @@ -1,3 +1,5 @@ +require "csv" + module SolidStackWeb class ApplicationController < ActionController::Base include Pagy::Method diff --git a/app/controllers/solid_stack_web/failed_jobs_controller.rb b/app/controllers/solid_stack_web/failed_jobs_controller.rb index 66b1f60..1982a86 100644 --- a/app/controllers/solid_stack_web/failed_jobs_controller.rb +++ b/app/controllers/solid_stack_web/failed_jobs_controller.rb @@ -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 @@ -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 diff --git a/app/controllers/solid_stack_web/jobs_controller.rb b/app/controllers/solid_stack_web/jobs_controller.rb index f21743f..08eea37 100644 --- a/app/controllers/solid_stack_web/jobs_controller.rb +++ b/app/controllers/solid_stack_web/jobs_controller.rb @@ -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 @@ -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? diff --git a/app/views/solid_stack_web/failed_jobs/index.html.erb b/app/views/solid_stack_web/failed_jobs/index.html.erb index fc1c39b..cd8e220 100644 --- a/app/views/solid_stack_web/failed_jobs/index.html.erb +++ b/app/views/solid_stack_web/failed_jobs/index.html.erb @@ -1,5 +1,9 @@ -
+

Failed Jobs

+
+ <%= link_to "Export CSV", failed_jobs_path(format: :csv), + class: "sqw-btn sqw-btn--muted sqw-btn--sm", data: { turbo: false } %> +
diff --git a/app/views/solid_stack_web/jobs/index.html.erb b/app/views/solid_stack_web/jobs/index.html.erb index 5c0843c..2c1a66a 100644 --- a/app/views/solid_stack_web/jobs/index.html.erb +++ b/app/views/solid_stack_web/jobs/index.html.erb @@ -1,13 +1,17 @@

Jobs

- <% 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 %> +
+ <%= 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 %> +
diff --git a/solid_stack_web.gemspec b/solid_stack_web.gemspec index 2241689..3f12988 100644 --- a/solid_stack_web.gemspec +++ b/solid_stack_web.gemspec @@ -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 diff --git a/spec/requests/solid_stack_web/failed_jobs_spec.rb b/spec/requests/solid_stack_web/failed_jobs_spec.rb index faa8e83..9aa3f5e 100644 --- a/spec/requests/solid_stack_web/failed_jobs_spec.rb +++ b/spec/requests/solid_stack_web/failed_jobs_spec.rb @@ -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 diff --git a/spec/requests/solid_stack_web/jobs_spec.rb b/spec/requests/solid_stack_web/jobs_spec.rb index 8543b5f..de3b85d 100644 --- a/spec/requests/solid_stack_web/jobs_spec.rb +++ b/spec/requests/solid_stack_web/jobs_spec.rb @@ -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")