diff --git a/CHANGELOG.md b/CHANGELOG.md index bceac14..ce80c95 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,6 +9,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Added +- CSV export on the jobs, failed jobs, and history pages — an "Export CSV" button downloads all records matching the current filters (status, queue, search term, period) as a named `.csv` file; columns are tailored per view (enqueued_at for jobs, error details for failed jobs, duration and finished_at for history) - Dashboard quick actions — "Retry All Failed" and "Discard All Blocked" buttons appear as cards on the dashboard when the respective count is non-zero; each includes a confirm dialog and redirects back to the dashboard with a count notice; cards are hidden when everything is healthy - Dark mode — a ☽/☀ toggle button in the header switches between light and dark themes; preference persists to `localStorage` and falls back to the OS `prefers-color-scheme` on first visit; implemented via a `[data-theme="dark"]` attribute on `` so all CSS custom properties inherit the new palette automatically; badge and flash hardcoded hex colors get explicit dark-mode overrides in a new `_12_dark_mode.css` partial; powered by a new Stimulus `theme_controller` - Configurable settings via `SolidQueueWeb.configure` — `page_size` (Pagy limit, default 25), `dashboard_refresh_interval` (ms, default 5 000), `default_refresh_interval` (ms, default 10 000 — applies to jobs, processes, and history pages), `search_results_limit` (max results per status in global search, default 25); all settings have safe defaults so zero host-app configuration is required diff --git a/Gemfile.lock b/Gemfile.lock index 9834872..7c46b04 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -2,6 +2,7 @@ PATH remote: . specs: solid_queue_web (0.7.0) + csv (>= 3.0) importmap-rails (>= 1.2) pagy (>= 43.0) rails (>= 8.1.3) @@ -92,6 +93,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) @@ -130,7 +132,7 @@ GEM net-imap net-pop net-smtp - marcel (1.1.0) + marcel (1.2.1) mini_mime (1.1.5) minitest (6.0.6) drb (~> 2.0) @@ -236,7 +238,7 @@ GEM rspec-mocks (>= 3.13.0, < 5.0.0) rspec-support (>= 3.13.0, < 5.0.0) rspec-support (3.13.7) - rubocop (1.86.1) + rubocop (1.86.2) json (~> 2.3) language_server-protocol (~> 3.17.0.2) lint_roller (~> 1.1.0) @@ -254,7 +256,7 @@ GEM lint_roller (~> 1.1) rubocop (>= 1.75.0, < 2.0) rubocop-ast (>= 1.47.1, < 2.0) - rubocop-rails (2.35.0) + rubocop-rails (2.35.2) activesupport (>= 4.2.0) lint_roller (~> 1.1) rack (>= 1.1) @@ -303,7 +305,7 @@ GEM websocket-extensions (>= 0.1.0) websocket-extensions (0.1.5) yaml (0.4.0) - zeitwerk (2.7.5) + zeitwerk (2.8.1) PLATFORMS arm64-darwin @@ -339,6 +341,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 @@ -358,7 +361,7 @@ CHECKSUMS logger (1.7.0) sha256=196edec7cc44b66cfb40f9755ce11b392f21f7967696af15d274dde7edff0203 loofah (2.25.1) sha256=d436c73dbd0c1147b16c4a41db097942d217303e1f7728704b37e4df9f6d2e04 mail (2.9.0) sha256=6fa6673ecd71c60c2d996260f9ee3dd387d4673b8169b502134659ece6d34941 - marcel (1.1.0) sha256=fdcfcfa33cc52e93c4308d40e4090a5d4ea279e160a7f6af988260fa970e0bee + marcel (1.2.1) sha256=1678e9360e32f9eafa917c80029e2f6d10b2715c66a4b87b6d0da9b9cd1f859f mini_mime (1.1.5) sha256=8681b7e2e4215f2a159f9400b5816d85e9d8c6c6b491e96a12797e798f8bccef minitest (6.0.6) sha256=153ea36d1d987a62942382b61075745042a2b3123b1cd48f4c3675af9cc7d6f1 net-imap (0.6.4) sha256=9a5598c67a3022c284d98430ef1d4948e7dbdb62596f61081ea8ca933270a02b @@ -397,10 +400,10 @@ CHECKSUMS rspec-mocks (3.13.8) sha256=086ad3d3d17533f4237643de0b5c42f04b66348c28bf6b9c2d3f4a3b01af1d47 rspec-rails (8.0.4) sha256=06235692fc0892683d3d34977e081db867434b3a24ae0dd0c6f3516bad4e22df rspec-support (3.13.7) sha256=0640e5570872aafefd79867901deeeeb40b0c9875a36b983d85f54fb7381c47c - rubocop (1.86.1) sha256=44415f3f01d01a21e01132248d2fd0867572475b566ca188a0a42133a08d4531 + rubocop (1.86.2) sha256=bb2e97f635eda42c448f2588f4a6ff78f221b8bdfdf65b1e9b07fbd57521b45d rubocop-ast (1.49.1) sha256=4412f3ee70f6fe4546cc489548e0f6fcf76cafcfa80fa03af67098ffed755035 rubocop-performance (1.26.1) sha256=cd19b936ff196df85829d264b522fd4f98b6c89ad271fa52744a8c11b8f71834 - rubocop-rails (2.35.0) sha256=a5d9f0f6c6d9b73d9ddd181c4c0b6d2e00dd17107480828d31c7b369ebfcd49c + rubocop-rails (2.35.2) sha256=088865be9675922a5c8f13c00055a71ab768ea5eed211437cffd2a8b46b64ac2 rubocop-rails-omakase (1.1.0) sha256=2af73ac8ee5852de2919abbd2618af9c15c19b512c4cfc1f9a5d3b6ef009109d ruby-progressbar (1.13.0) sha256=80fc9c47a9b640d6834e0dc7b3c94c9df37f08cb072b7761e4a71e22cff29b33 securerandom (0.4.1) sha256=cc5193d414a4341b6e225f0cb4446aceca8e50d5e1888743fac16987638ea0b1 @@ -425,7 +428,7 @@ CHECKSUMS websocket-driver (0.8.0) sha256=ed0dba4b943c22f17f9a734817e808bc84cdce6a7e22045f5315aa57676d4962 websocket-extensions (0.1.5) sha256=1c6ba63092cda343eb53fc657110c71c754c56484aad42578495227d717a8241 yaml (0.4.0) sha256=240e69d1e6ce3584d6085978719a0faa6218ae426e034d8f9b02fb54d3471942 - zeitwerk (2.7.5) sha256=d8da92128c09ea6ec62c949011b00ed4a20242b255293dd66bf41545398f73dd + zeitwerk (2.8.1) sha256=1c85e0f28954d68cd16e575da37f26846f609b68d80b5942ccfd31030c2449d5 BUNDLED WITH 4.0.11 diff --git a/README.md b/README.md index 6f6f566..f6010bb 100644 --- a/README.md +++ b/README.md @@ -46,6 +46,7 @@ SolidQueueWeb surfaces all of this in a browser UI available at any route you ch - **Job history** — browsable list of all finished jobs with class name, queue, duration, and finished timestamp; filterable by period (1h / 24h / 7d), queue, and class name search; Done (1h) / Done (24h) dashboard cards link directly to the filtered history view; auto-refreshes every 10 seconds - **Dark mode** — ☽/☀ toggle in the header; preference persists to `localStorage` and defaults to the OS `prefers-color-scheme` on first visit; zero extra dependencies — implemented via CSS custom properties and a small Stimulus controller - **Dashboard quick actions** — "Retry All Failed" and "Discard All Blocked" cards appear on the dashboard only when the respective count is non-zero; one-click bulk operations with confirm dialogs, keeping the dashboard clean when everything is healthy +- **CSV export** — "Export CSV" button on the jobs, failed jobs, and history pages downloads all records matching the current filters; columns are tailored per view ## Screenshots @@ -111,7 +112,6 @@ No authentication is enforced by default. When the `authenticate` block returns Planned features, roughly ordered by priority: **Larger scope** -- CSV export of any filtered view (jobs, failed jobs, history) - Webhook / alert config — POST to a URL when the failure count exceeds a threshold Pull requests for any of these are welcome. See [Contributing](#contributing) below. diff --git a/app/controllers/solid_queue_web/application_controller.rb b/app/controllers/solid_queue_web/application_controller.rb index 9fb96ea..30aab2e 100644 --- a/app/controllers/solid_queue_web/application_controller.rb +++ b/app/controllers/solid_queue_web/application_controller.rb @@ -1,3 +1,5 @@ +require "csv" + module SolidQueueWeb class ApplicationController < ActionController::Base include Pagy::Method diff --git a/app/controllers/solid_queue_web/failed_jobs_controller.rb b/app/controllers/solid_queue_web/failed_jobs_controller.rb index 9144f48..03072a3 100644 --- a/app/controllers/solid_queue_web/failed_jobs_controller.rb +++ b/app/controllers/solid_queue_web/failed_jobs_controller.rb @@ -3,7 +3,14 @@ class FailedJobsController < ApplicationController before_action :set_filter_params, only: [:index, :retry_all, :discard_all] def index - @pagy, @failed_jobs = pagy(filtered_scope.order(created_at: :desc)) + respond_to do |format| + format.html { @pagy, @failed_jobs = pagy(filtered_scope.order(created_at: :desc)) } + format.csv do + send_data failed_jobs_csv, + filename: "failed-jobs-#{Date.today}.csv", + type: "text/csv", disposition: "attachment" + end + end end def retry @@ -38,6 +45,19 @@ def discard_all private + def failed_jobs_csv + CSV.generate(headers: true) do |csv| + csv << %w[id class_name queue_name error_class error_message failed_at] + filtered_scope.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 + def set_filter_params @queue = params[:queue].presence @search = params[:q].presence diff --git a/app/controllers/solid_queue_web/history_controller.rb b/app/controllers/solid_queue_web/history_controller.rb index a5c972a..2dec639 100644 --- a/app/controllers/solid_queue_web/history_controller.rb +++ b/app/controllers/solid_queue_web/history_controller.rb @@ -10,7 +10,26 @@ def index scope = scope.where("class_name LIKE ?", "%#{@search}%") if @search.present? scope = scope.where("finished_at >= ?", PERIOD_DURATIONS[@period].ago) if @period.present? - @pagy, @jobs = pagy(scope.order(finished_at: :desc)) + respond_to do |format| + format.html { @pagy, @jobs = pagy(scope.order(finished_at: :desc)) } + format.csv do + send_data history_csv(scope), + filename: "job-history-#{Date.today}.csv", + type: "text/csv", disposition: "attachment" + end + end + end + + private + + def history_csv(scope) + CSV.generate(headers: true) do |csv| + csv << %w[id class_name queue_name duration_seconds finished_at] + scope.order(finished_at: :desc).each do |job| + duration = job.finished_at && job.created_at ? (job.finished_at - job.created_at).round : nil + csv << [job.id, job.class_name, job.queue_name, duration, job.finished_at.iso8601] + end + end end end end diff --git a/app/controllers/solid_queue_web/jobs_controller.rb b/app/controllers/solid_queue_web/jobs_controller.rb index 24ab5ab..3bf6c48 100644 --- a/app/controllers/solid_queue_web/jobs_controller.rb +++ b/app/controllers/solid_queue_web/jobs_controller.rb @@ -6,10 +6,19 @@ def index @status = params[:status].presence_in(Job::STATUSES) || "ready" @search = params[:q].presence @period = params[:period].presence_in(PERIOD_DURATIONS.keys) - @jobs = Job::EXECUTION_MODELS[@status].includes(:job) - @jobs = @jobs.references(:job).where("solid_queue_jobs.class_name LIKE ?", "%#{@search}%") if @search.present? - @jobs = @jobs.references(:job).where("solid_queue_jobs.created_at >= ?", PERIOD_DURATIONS[@period].ago) if @period.present? - @pagy, @jobs = pagy(@jobs.order(created_at: :desc)) + scope = Job::EXECUTION_MODELS[@status].includes(:job) + scope = scope.references(:job).where("solid_queue_jobs.class_name LIKE ?", "%#{@search}%") if @search.present? + scope = scope.references(:job).where("solid_queue_jobs.created_at >= ?", PERIOD_DURATIONS[@period].ago) if @period.present? + scope = scope.order(created_at: :desc) + + respond_to do |format| + format.html { @pagy, @jobs = pagy(scope) } + format.csv do + send_data jobs_csv(scope), + filename: "jobs-#{@status}-#{Date.today}.csv", + type: "text/csv", disposition: "attachment" + end + end end def show @@ -48,6 +57,16 @@ def discard_all private + def jobs_csv(scope) + CSV.generate(headers: true) do |csv| + csv << %w[id class_name queue_name status priority enqueued_at] + 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 derive_status(job) return "failed" if job.failed_execution.present? return "claimed" if job.claimed_execution.present? diff --git a/app/views/solid_queue_web/failed_jobs/index.html.erb b/app/views/solid_queue_web/failed_jobs/index.html.erb index 047fe74..d44f63b 100644 --- a/app/views/solid_queue_web/failed_jobs/index.html.erb +++ b/app/views/solid_queue_web/failed_jobs/index.html.erb @@ -2,6 +2,8 @@

Failed Jobs

<% if @failed_jobs.any? %>
+ <%= link_to "Export CSV", failed_jobs_path(format: :csv, queue: @queue, q: @search, period: @period), + class: "sqd-btn sqd-btn--muted", data: { turbo: false } %> <%= button_to "Retry All", retry_all_failed_jobs_path, method: :post, params: { queue: @queue, q: @search, period: @period }, diff --git a/app/views/solid_queue_web/history/index.html.erb b/app/views/solid_queue_web/history/index.html.erb index b099e11..25049ce 100644 --- a/app/views/solid_queue_web/history/index.html.erb +++ b/app/views/solid_queue_web/history/index.html.erb @@ -1,6 +1,12 @@ <%= turbo_frame_tag "history-table", data: { controller: "refresh", refresh_interval_value: SolidQueueWeb.default_refresh_interval } do %>

Job History

+ <% if @jobs.any? %> +
+ <%= link_to "Export CSV", history_path(format: :csv, queue: @queue, q: @search, period: @period), + class: "sqd-btn sqd-btn--muted", data: { turbo: false } %> +
+ <% end %>
- <% if discardable && @jobs.any? %> + <% if @jobs.any? %>
- <%= button_to "Discard All", discard_all_jobs_path, - method: :post, - params: { status: @status, period: @period }, - class: "sqd-btn sqd-btn--danger", - data: { confirm: "Discard all #{@jobs.size} #{@status} jobs? This cannot be undone." } %> + <%= link_to "Export CSV", jobs_path(format: :csv, status: @status, q: @search, period: @period), + class: "sqd-btn sqd-btn--muted", data: { turbo: false } %> + <% if discardable %> + <%= button_to "Discard All", discard_all_jobs_path, + method: :post, + params: { status: @status, period: @period }, + class: "sqd-btn sqd-btn--danger", + data: { confirm: "Discard all #{@jobs.size} #{@status} jobs? This cannot be undone." } %> + <% end %>
<% end %> diff --git a/solid_queue_web.gemspec b/solid_queue_web.gemspec index 031f91d..72ac803 100644 --- a/solid_queue_web.gemspec +++ b/solid_queue_web.gemspec @@ -24,6 +24,7 @@ Gem::Specification.new do |spec| spec.required_ruby_version = ">= 3.3" spec.add_dependency "rails", ">= 8.1.3" + spec.add_dependency "csv", ">= 3.0" spec.add_dependency "solid_queue", ">= 1.0" spec.add_dependency "pagy", ">= 43.0" spec.add_dependency "turbo-rails", ">= 2.0" diff --git a/spec/requests/solid_queue_web/failed_jobs_spec.rb b/spec/requests/solid_queue_web/failed_jobs_spec.rb index c8d41ba..d5cef9c 100644 --- a/spec/requests/solid_queue_web/failed_jobs_spec.rb +++ b/spec/requests/solid_queue_web/failed_jobs_spec.rb @@ -221,6 +221,27 @@ end end + describe "GET /jobs/failed_jobs.csv (CSV export)" do + it "returns a CSV file" do + get "/jobs/failed_jobs", params: { format: :csv } + expect(response).to have_http_status(:ok) + expect(response.content_type).to include("text/csv") + end + + it "includes the job class name, queue, and error details" do + get "/jobs/failed_jobs", params: { format: :csv } + expect(response.body).to include("TestJob") + expect(response.body).to include("default") + expect(response.body).to include("RuntimeError") + end + + it "includes CSV headers" do + get "/jobs/failed_jobs", params: { format: :csv } + expect(response.body).to include("class_name") + expect(response.body).to include("error_class") + end + end + describe "POST /jobs/failed_jobs/discard_all" do it "discards all failed jobs and redirects" do post "/jobs/failed_jobs/discard_all" diff --git a/spec/requests/solid_queue_web/history_spec.rb b/spec/requests/solid_queue_web/history_spec.rb index 4efe6ba..223ab1f 100644 --- a/spec/requests/solid_queue_web/history_spec.rb +++ b/spec/requests/solid_queue_web/history_spec.rb @@ -89,4 +89,25 @@ def finished_job(attrs = {}) expect(response.body).not_to include("CleanupJob") end end + + describe "GET /jobs/history.csv (CSV export)" do + it "returns a CSV file" do + get "/jobs/history", params: { format: :csv } + expect(response).to have_http_status(:ok) + expect(response.content_type).to include("text/csv") + end + + it "includes job class, queue, duration, and finished_at" do + finished_job(class_name: "ExportJob", queue_name: "default") + get "/jobs/history", params: { format: :csv } + expect(response.body).to include("ExportJob") + expect(response.body).to include("default") + end + + it "includes CSV headers" do + get "/jobs/history", params: { format: :csv } + expect(response.body).to include("class_name") + expect(response.body).to include("finished_at") + end + end end diff --git a/spec/requests/solid_queue_web/jobs_spec.rb b/spec/requests/solid_queue_web/jobs_spec.rb index 914f75f..3165857 100644 --- a/spec/requests/solid_queue_web/jobs_spec.rb +++ b/spec/requests/solid_queue_web/jobs_spec.rb @@ -220,6 +220,26 @@ end end + describe "GET /jobs/list.csv (CSV export)" do + it "returns a CSV file" do + get "/jobs/list", params: { status: "ready" }, headers: { "Accept" => "text/csv" } + expect(response).to have_http_status(:ok) + expect(response.content_type).to include("text/csv") + end + + it "includes the job class name and queue" do + get "/jobs/list", params: { status: "ready", format: :csv } + expect(response.body).to include("TestJob") + expect(response.body).to include("default") + end + + it "includes CSV headers" do + get "/jobs/list", params: { status: "ready", format: :csv } + expect(response.body).to include("class_name") + expect(response.body).to include("queue_name") + end + end + describe "POST /jobs/list/discard_all" do it "discards all ready jobs and redirects" do post "/jobs/list/discard_all", params: { status: "ready" }