diff --git a/CHANGELOG.md b/CHANGELOG.md index e8c6b6e..cba330c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -13,6 +13,12 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - Job filtering — filter the jobs list by queue name, job class (substring), priority, and time period (1h / 24h / 7d / all) via query-param driven scopes; active filters are preserved across status tabs - Job filter Turbo Frame — filter form and results table wrapped in a `` so applying filters reloads only the table without a full page refresh; `data-turbo-action="advance"` keeps the URL in sync; Turbo JS loaded from esm.sh CDN in the engine layout +### Fixed + +- `FailedJobsController#destroy` used a local variable instead of `@execution`, making the Turbo Stream row-removal template a no-op +- Failed jobs index rendered error as a string via `.lines` — SolidQueue serializes `error` as JSON; now uses `execution.exception_class` +- Replaced deprecated Rack status `:unprocessable_entity` with `:unprocessable_content` + ## [0.1.0] - 2026-05-24 ### Added diff --git a/app/controllers/solid_stack_web/failed_jobs_controller.rb b/app/controllers/solid_stack_web/failed_jobs_controller.rb index ac8da23..66b1f60 100644 --- a/app/controllers/solid_stack_web/failed_jobs_controller.rb +++ b/app/controllers/solid_stack_web/failed_jobs_controller.rb @@ -6,8 +6,8 @@ def index end def destroy - execution = ::SolidQueue::FailedExecution.find(params[:id]) - execution.job.destroy! + @execution = ::SolidQueue::FailedExecution.find(params[:id]) + @execution.job.destroy! @executions_remain = ::SolidQueue::FailedExecution.exists? respond_to do |format| diff --git a/app/controllers/solid_stack_web/jobs_controller.rb b/app/controllers/solid_stack_web/jobs_controller.rb index 2a4d0e6..6c8ad1c 100644 --- a/app/controllers/solid_stack_web/jobs_controller.rb +++ b/app/controllers/solid_stack_web/jobs_controller.rb @@ -44,7 +44,7 @@ def set_filters end def require_discardable - head :unprocessable_entity unless Job::DISCARDABLE.include?(@status) + head :unprocessable_content unless Job::DISCARDABLE.include?(@status) end def filtered_scope diff --git a/app/views/solid_stack_web/failed_jobs/destroy.turbo_stream.erb b/app/views/solid_stack_web/failed_jobs/destroy.turbo_stream.erb new file mode 100644 index 0000000..0efe4b3 --- /dev/null +++ b/app/views/solid_stack_web/failed_jobs/destroy.turbo_stream.erb @@ -0,0 +1,9 @@ +<% if @executions_remain %> + <%= turbo_stream.remove "execution_#{@execution.id}" %> +<% else %> + <%= turbo_stream.replace "sqw-jobs-table" do %> +
+

No failed jobs.

+
+ <% end %> +<% end %> \ No newline at end of file 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 38ec1dd..fc1c39b 100644 --- a/app/views/solid_stack_web/failed_jobs/index.html.erb +++ b/app/views/solid_stack_web/failed_jobs/index.html.erb @@ -19,7 +19,7 @@ <%= execution.job.class_name %> <%= execution.job.queue_name %> - <%= execution.error&.lines&.first&.strip %> + <%= execution.exception_class %> <%= execution.created_at.strftime("%b %d %H:%M") %> <%= button_to "Retry", retry_failed_job_path(execution), diff --git a/spec/requests/solid_stack_web/authentication_spec.rb b/spec/requests/solid_stack_web/authentication_spec.rb new file mode 100644 index 0000000..9230ef9 --- /dev/null +++ b/spec/requests/solid_stack_web/authentication_spec.rb @@ -0,0 +1,33 @@ +require "rails_helper" + +RSpec.describe "Authentication", type: :request do + let(:engine_root) { "/solid_stack" } + + around do |example| + original = SolidStackWeb.authenticate + example.run + ensure + SolidStackWeb.instance_variable_set(:@authenticate, original) + end + + context "when the auth block returns truthy" do + it "allows the request through" do + SolidStackWeb.authenticate { true } + + get "#{engine_root}/jobs" + + expect(response).to have_http_status(:ok) + end + end + + context "when the auth block returns falsy" do + it "falls back to HTTP Basic auth and returns 401" do + SolidStackWeb.authenticate { false } + + get "#{engine_root}/jobs" + + expect(response).to have_http_status(:unauthorized) + expect(response.headers["WWW-Authenticate"]).to include("Basic") + end + end +end diff --git a/spec/requests/solid_stack_web/cable_spec.rb b/spec/requests/solid_stack_web/cable_spec.rb new file mode 100644 index 0000000..a3e94e7 --- /dev/null +++ b/spec/requests/solid_stack_web/cable_spec.rb @@ -0,0 +1,50 @@ +require "rails_helper" + +RSpec.describe "Cable", type: :request do + let(:engine_root) { "/solid_stack" } + + describe "GET /cable" do + it "returns 200" do + get "#{engine_root}/cable" + expect(response).to have_http_status(:ok) + end + + it "shows zero counts when no messages exist" do + get "#{engine_root}/cable" + expect(response.body).to include("Total Messages") + expect(response.body).to include("Channels") + end + + it "reflects live message count" do + SolidCable::Message.broadcast("room:1", { text: "hello" }.to_json) + SolidCable::Message.broadcast("room:2", { text: "world" }.to_json) + + get "#{engine_root}/cable" + + expect(response.body).to match(/class="sqw-stat__value">\s*2\s*\s*2\s*\s*2\s* 0, "exception_executions" => {} } + ) + execution = SolidQueue::FailedExecution.create!( + job: job, + error: { exception_class: "RuntimeError", message: "something went wrong", + backtrace: ["app/jobs/failing_job.rb:5"] } + ) + SolidQueue::Job.set_callback(:create, :after, :prepare_for_execution) + execution + end + + describe "GET /failed_jobs" do + it "returns 200" do + get "#{engine_root}/failed_jobs" + expect(response).to have_http_status(:ok) + end + + it "shows an empty state when there are no failed jobs" do + get "#{engine_root}/failed_jobs" + expect(response.body).to include("No failed jobs") + end + + it "lists failed job class names" do + create_failed(class_name: "FailingJob") + get "#{engine_root}/failed_jobs" + expect(response.body).to include("FailingJob") + end + + it "shows the queue name" do + create_failed(queue_name: "critical") + get "#{engine_root}/failed_jobs" + expect(response.body).to include("critical") + end + + it "shows the exception class" do + create_failed + get "#{engine_root}/failed_jobs" + expect(response.body).to include("RuntimeError") + end + + it "renders Retry and Discard buttons" do + create_failed + get "#{engine_root}/failed_jobs" + expect(response.body).to include("Retry") + expect(response.body).to include("Discard") + end + end + + describe "DELETE /failed_jobs/:id" do + context "with HTML format" do + it "destroys the job and redirects" do + execution = create_failed + delete "#{engine_root}/failed_jobs/#{execution.id}" + expect(response).to redirect_to("#{engine_root}/failed_jobs") + expect(SolidQueue::FailedExecution.exists?(execution.id)).to be false + end + end + + context "with turbo_stream format" do + it "returns a turbo stream response" do + execution = create_failed + delete "#{engine_root}/failed_jobs/#{execution.id}", + headers: { "Accept" => "text/vnd.turbo-stream.html" } + expect(response).to have_http_status(:ok) + expect(response.media_type).to eq("text/vnd.turbo-stream.html") + end + end + end + + describe "POST /failed_jobs/:id/retry" do + it "re-enqueues the job and redirects" do + execution = create_failed + post "#{engine_root}/failed_jobs/#{execution.id}/retry" + expect(response).to redirect_to("#{engine_root}/failed_jobs") + expect(SolidQueue::FailedExecution.exists?(execution.id)).to be false + end + end +end diff --git a/spec/requests/solid_stack_web/jobs_spec.rb b/spec/requests/solid_stack_web/jobs_spec.rb index 0e100e2..f106d7a 100644 --- a/spec/requests/solid_stack_web/jobs_spec.rb +++ b/spec/requests/solid_stack_web/jobs_spec.rb @@ -156,6 +156,14 @@ def create_ready(class_name: "MyJob", queue_name: "default", priority: 0) expect(response.body).to include("42") end + it "still renders when arguments contain invalid JSON" do + job = SolidQueue::Job.create!(class_name: "MyJob", queue_name: "default", priority: 0, + arguments: "not valid json {{{") + get "#{engine_root}/jobs/#{job.ready_execution.id}", params: { status: "ready" } + + expect(response).to have_http_status(:ok) + end + it "shows a discard button for ready jobs" do job = create_ready get "#{engine_root}/jobs/#{job.ready_execution.id}", params: { status: "ready" } @@ -188,6 +196,70 @@ def create_ready(class_name: "MyJob", queue_name: "default", priority: 0) end end + describe "DELETE /jobs/:id" do + context "with HTML format" do + it "destroys the job and redirects to the jobs list" do + job = create_ready + delete "#{engine_root}/jobs/#{job.ready_execution.id}", params: { status: "ready" } + + expect(response).to redirect_to("#{engine_root}/jobs?status=ready") + expect(SolidQueue::Job.exists?(job.id)).to be false + end + + it "preserves filter params in the redirect" do + job = create_ready(queue_name: "reports") + delete "#{engine_root}/jobs/#{job.ready_execution.id}", + params: { status: "ready", queue: "reports", q: "Report", period: "1h" } + + expect(response.location).to include("queue=reports") + expect(response.location).to include("q=Report") + expect(response.location).to include("period=1h") + end + end + + context "with turbo_stream format" do + it "removes the row when other jobs remain" do + job1 = create_ready + create_ready + + delete "#{engine_root}/jobs/#{job1.ready_execution.id}", + params: { status: "ready" }, + headers: { "Accept" => "text/vnd.turbo-stream.html" } + + expect(response).to have_http_status(:ok) + expect(response.media_type).to eq("text/vnd.turbo-stream.html") + expect(response.body).to include("execution_#{job1.ready_execution.id}") + expect(response.body).to include("remove") + end + + it "replaces the table with empty state when it was the last job" do + job = create_ready + + delete "#{engine_root}/jobs/#{job.ready_execution.id}", + params: { status: "ready" }, + headers: { "Accept" => "text/vnd.turbo-stream.html" } + + expect(response.body).to include("sqw-jobs-table") + expect(response.body).to include("replace") + end + end + + it "returns 422 when status is not discardable" do + SolidQueue::Job.skip_callback(:create, :after, :prepare_for_execution) + job = SolidQueue::Job.create!(class_name: "WorkJob", queue_name: "default", priority: 0) + process = SolidQueue::Process.create!( + kind: "Worker", name: "worker-spec-del", pid: 88_888, + hostname: "test", last_heartbeat_at: Time.current + ) + execution = SolidQueue::ClaimedExecution.create!(job: job, process_id: process.id) + SolidQueue::Job.set_callback(:create, :after, :prepare_for_execution) + + delete "#{engine_root}/jobs/#{execution.id}", params: { status: "claimed" } + + expect(response).to have_http_status(:unprocessable_content) + end + end + describe "combined filters" do it "applies class and queue filters together" do create_ready(class_name: "ReportJob", queue_name: "reports") diff --git a/spec/requests/solid_stack_web/processes_spec.rb b/spec/requests/solid_stack_web/processes_spec.rb new file mode 100644 index 0000000..8b32744 --- /dev/null +++ b/spec/requests/solid_stack_web/processes_spec.rb @@ -0,0 +1,39 @@ +require "rails_helper" + +RSpec.describe "Processes", type: :request do + let(:engine_root) { "/solid_stack" } + + def create_process(kind: "Worker", name: "worker-1") + SolidQueue::Process.create!( + kind:, name:, pid: rand(10_000..99_999), + hostname: "localhost", last_heartbeat_at: Time.current + ) + end + + describe "GET /processes" do + it "returns 200" do + get "#{engine_root}/processes" + expect(response).to have_http_status(:ok) + end + + it "shows an empty state when no processes are running" do + get "#{engine_root}/processes" + expect(response.body).to include("No active processes") + end + + it "lists running processes" do + create_process(kind: "Worker", name: "worker-1") + get "#{engine_root}/processes" + expect(response.body).to include("Worker") + expect(response.body).to include("worker-1") + end + + it "shows multiple processes" do + create_process(kind: "Worker", name: "worker-1") + create_process(kind: "Dispatcher", name: "dispatcher-1") + get "#{engine_root}/processes" + expect(response.body).to include("Worker") + expect(response.body).to include("Dispatcher") + end + end +end diff --git a/spec/requests/solid_stack_web/queues_spec.rb b/spec/requests/solid_stack_web/queues_spec.rb new file mode 100644 index 0000000..0f797d6 --- /dev/null +++ b/spec/requests/solid_stack_web/queues_spec.rb @@ -0,0 +1,66 @@ +require "rails_helper" + +RSpec.describe "Queues", type: :request do + let(:engine_root) { "/solid_stack" } + + def create_ready(queue_name: "default") + SolidQueue::Job.create!(class_name: "MyJob", queue_name:, priority: 0) + end + + describe "GET /queues" do + it "returns 200" do + get "#{engine_root}/queues" + expect(response).to have_http_status(:ok) + end + + it "lists distinct queue names" do + create_ready(queue_name: "alpha") + create_ready(queue_name: "beta") + get "#{engine_root}/queues" + expect(response.body).to include("alpha") + expect(response.body).to include("beta") + end + + it "shows the ready job count per queue" do + 2.times { create_ready(queue_name: "reports") } + get "#{engine_root}/queues" + expect(response.body).to include("reports") + end + + it "shows paused state for paused queues" do + create_ready(queue_name: "low") + SolidQueue::Pause.create!(queue_name: "low") + get "#{engine_root}/queues" + expect(response.body).to include("Paused") + end + end + + describe "POST /queues/:id/pause" do + it "pauses the queue and redirects" do + create_ready(queue_name: "default") + post "#{engine_root}/queues/default/pause" + expect(response).to redirect_to("#{engine_root}/queues") + expect(SolidQueue::Pause.exists?(queue_name: "default")).to be true + end + + it "is idempotent when the queue is already paused" do + SolidQueue::Pause.create!(queue_name: "default") + expect { post "#{engine_root}/queues/default/pause" }.not_to raise_error + expect(response).to redirect_to("#{engine_root}/queues") + end + end + + describe "DELETE /queues/:id/resume" do + it "resumes a paused queue and redirects" do + SolidQueue::Pause.create!(queue_name: "default") + delete "#{engine_root}/queues/default/resume" + 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(response).to redirect_to("#{engine_root}/queues") + end + end +end diff --git a/spec/solid_stack_web_spec.rb b/spec/solid_stack_web_spec.rb new file mode 100644 index 0000000..c457477 --- /dev/null +++ b/spec/solid_stack_web_spec.rb @@ -0,0 +1,15 @@ +require "rails_helper" + +RSpec.describe SolidStackWeb do + describe ".configure" do + it "yields self so callers can set attributes in a block" do + SolidStackWeb.configure do |config| + config.page_size = 50 + end + + expect(SolidStackWeb.page_size).to eq(50) + ensure + SolidStackWeb.page_size = 25 + end + end +end