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

- 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 `<html>` 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
Expand Down
19 changes: 11 additions & 8 deletions Gemfile.lock
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down Expand Up @@ -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)
Expand Down Expand Up @@ -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)
Expand Down Expand Up @@ -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)
Expand All @@ -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)
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand All @@ -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
Expand Down Expand Up @@ -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
Expand All @@ -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
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down Expand Up @@ -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.
Expand Down
2 changes: 2 additions & 0 deletions app/controllers/solid_queue_web/application_controller.rb
Original file line number Diff line number Diff line change
@@ -1,3 +1,5 @@
require "csv"

module SolidQueueWeb
class ApplicationController < ActionController::Base
include Pagy::Method
Expand Down
22 changes: 21 additions & 1 deletion app/controllers/solid_queue_web/failed_jobs_controller.rb
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down
21 changes: 20 additions & 1 deletion app/controllers/solid_queue_web/history_controller.rb
Original file line number Diff line number Diff line change
Expand Up @@ -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
27 changes: 23 additions & 4 deletions app/controllers/solid_queue_web/jobs_controller.rb
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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?
Expand Down
2 changes: 2 additions & 0 deletions app/views/solid_queue_web/failed_jobs/index.html.erb
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,8 @@
<h1 class="sqd-page-title">Failed Jobs</h1>
<% if @failed_jobs.any? %>
<div class="sqd-actions">
<%= 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 },
Expand Down
6 changes: 6 additions & 0 deletions app/views/solid_queue_web/history/index.html.erb
Original file line number Diff line number Diff line change
@@ -1,6 +1,12 @@
<%= turbo_frame_tag "history-table", data: { controller: "refresh", refresh_interval_value: SolidQueueWeb.default_refresh_interval } do %>
<div class="sqd-page-header">
<h1 class="sqd-page-title">Job History</h1>
<% if @jobs.any? %>
<div class="sqd-actions">
<%= link_to "Export CSV", history_path(format: :csv, queue: @queue, q: @search, period: @period),
class: "sqd-btn sqd-btn--muted", data: { turbo: false } %>
</div>
<% end %>
</div>

<form class="sqd-search" action="<%= history_path %>" method="get" data-controller="search">
Expand Down
16 changes: 10 additions & 6 deletions app/views/solid_queue_web/jobs/index.html.erb
Original file line number Diff line number Diff line change
Expand Up @@ -11,13 +11,17 @@
<%= link_to "Blocked", jobs_path(status: "blocked", q: @search, period: @period), class: @status == "blocked" ? "active" : "" %>
<%= link_to "Failed", jobs_path(status: "failed", q: @search, period: @period), class: @status == "failed" ? "active" : "" %>
</div>
<% if discardable && @jobs.any? %>
<% if @jobs.any? %>
<div class="sqd-actions">
<%= 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 %>
</div>
<% end %>
</div>
Expand Down
1 change: 1 addition & 0 deletions solid_queue_web.gemspec
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down
21 changes: 21 additions & 0 deletions spec/requests/solid_queue_web/failed_jobs_spec.rb
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down
21 changes: 21 additions & 0 deletions spec/requests/solid_queue_web/history_spec.rb
Original file line number Diff line number Diff line change
Expand Up @@ -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
20 changes: 20 additions & 0 deletions spec/requests/solid_queue_web/jobs_spec.rb
Original file line number Diff line number Diff line change
Expand Up @@ -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" }
Expand Down
Loading