Skip to content
Merged
6 changes: 6 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 `<turbo-frame>` 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
Expand Down
4 changes: 2 additions & 2 deletions app/controllers/solid_stack_web/failed_jobs_controller.rb
Original file line number Diff line number Diff line change
Expand Up @@ -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|
Expand Down
2 changes: 1 addition & 1 deletion app/controllers/solid_stack_web/jobs_controller.rb
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
<% if @executions_remain %>
<%= turbo_stream.remove "execution_#{@execution.id}" %>
<% else %>
<%= turbo_stream.replace "sqw-jobs-table" do %>
<div class="sqw-empty">
<p>No failed jobs.</p>
</div>
<% end %>
<% end %>
2 changes: 1 addition & 1 deletion app/views/solid_stack_web/failed_jobs/index.html.erb
Original file line number Diff line number Diff line change
Expand Up @@ -19,7 +19,7 @@
<tr id="execution_<%= execution.id %>">
<td class="sqw-monospace"><%= execution.job.class_name %></td>
<td><span class="sqw-badge sqw-badge--queue"><%= execution.job.queue_name %></span></td>
<td class="sqw-muted sqw-truncate"><%= execution.error&.lines&.first&.strip %></td>
<td class="sqw-muted sqw-truncate" title="<%= execution.exception_class %>: <%= execution.message %>"><%= execution.exception_class %></td>
<td class="sqw-muted"><%= execution.created_at.strftime("%b %d %H:%M") %></td>
<td class="sqw-actions">
<%= button_to "Retry", retry_failed_job_path(execution),
Expand Down
33 changes: 33 additions & 0 deletions spec/requests/solid_stack_web/authentication_spec.rb
Original file line number Diff line number Diff line change
@@ -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
50 changes: 50 additions & 0 deletions spec/requests/solid_stack_web/cable_spec.rb
Original file line number Diff line number Diff line change
@@ -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*</)
end

it "lists distinct channels" do
SolidCable::Message.broadcast("notifications", "msg1")
SolidCable::Message.broadcast("notifications", "msg2")
SolidCable::Message.broadcast("chat", "msg3")

get "#{engine_root}/cable"

expect(response.body).to include("notifications")
expect(response.body).to include("chat")
end

it "shows channel count not message count in the Channels stat" do
SolidCable::Message.broadcast("room:1", "a")
SolidCable::Message.broadcast("room:1", "b")
SolidCable::Message.broadcast("room:2", "c")

get "#{engine_root}/cable"

# 2 distinct channels, stat value should be 2 (not 3 messages)
expect(response.body).to include("Channels")
expect(response.body).to match(/class="sqw-stat__value">\s*2\s*</)
end
end
end
35 changes: 35 additions & 0 deletions spec/requests/solid_stack_web/cache_spec.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,35 @@
require "rails_helper"

RSpec.describe "Cache", type: :request do
let(:engine_root) { "/solid_stack" }

describe "GET /cache" do
it "returns 200" do
get "#{engine_root}/cache"
expect(response).to have_http_status(:ok)
end

it "shows zero counts when no entries exist" do
get "#{engine_root}/cache"
expect(response.body).to include("Total Entries")
expect(response.body).to include("Total Size")
end

it "reflects live entry count" do
SolidCache::Entry.write("key1", "value1")
SolidCache::Entry.write("key2", "value2")

get "#{engine_root}/cache"

expect(response.body).to match(/class="sqw-stat__value">\s*2\s*</)
end

it "shows human-readable total size" do
SolidCache::Entry.write("sizekey", "hello")

get "#{engine_root}/cache"

expect(response.body).to include("Bytes").or include("KB").or include("MB")
end
end
end
87 changes: 87 additions & 0 deletions spec/requests/solid_stack_web/failed_jobs_spec.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,87 @@
require "rails_helper"

RSpec.describe "FailedJobs", type: :request do
let(:engine_root) { "/solid_stack" }

def create_failed(class_name: "FailingJob", queue_name: "default")
SolidQueue::Job.skip_callback(:create, :after, :prepare_for_execution)
job = SolidQueue::Job.create!(
class_name:, queue_name:, priority: 0,
arguments: { "executions" => 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
72 changes: 72 additions & 0 deletions spec/requests/solid_stack_web/jobs_spec.rb
Original file line number Diff line number Diff line change
Expand Up @@ -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" }
Expand Down Expand Up @@ -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")
Expand Down
39 changes: 39 additions & 0 deletions spec/requests/solid_stack_web/processes_spec.rb
Original file line number Diff line number Diff line change
@@ -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
Loading