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
10 changes: 10 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -75,3 +75,13 @@ jobs:

- name: Build and publish gem
uses: rubygems/release-gem@v1

- name: Create GitHub release
env:
GH_TOKEN: ${{ github.token }}
run: |
VERSION="${GITHUB_REF_NAME#v}"
NOTES=$(awk "/^## \[$VERSION\]/{found=1; next} found && /^## \[/{exit} found{print}" CHANGELOG.md)
gh release create "$GITHUB_REF_NAME" \
--title "v$VERSION" \
--notes "$NOTES"
17 changes: 17 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,23 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0

## [Unreleased]

### Added

- Turbo Streams on the jobs list — discarding a single job removes its row in place; the last job swaps the card to an empty state without a full page reload
- Turbo Frame on the jobs list — status filter tabs, queue filter links, Discard All button, and pagination all update in place without reloading the page header or flash
- Dashboard stat cards are now clickable links to their respective filtered views
- GitHub Releases created automatically with CHANGELOG notes when a version tag is pushed
- `turbo-rails >= 2.0` added as a runtime dependency

### Changed

- `JobsController` refactored: execution model mapping moved to `EXECUTION_MODELS` hash constant, eliminating two `case` statements
- `JobsController#destroy` and `#discard_all` share a `before_action :set_status_and_queue` and a `filtered_scope` helper, removing duplicated param reading and scope building

### Fixed

- Test suite reaches 100% line coverage; rescue paths, `derive_status` branches (scheduled, finished), and the authentication block are all exercised

## [0.3.0] - 2026-05-18

### Added
Expand Down
11 changes: 5 additions & 6 deletions Gemfile.lock
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ PATH
pagy (>= 43.0)
rails (>= 8.1.3)
solid_queue (>= 1.0)
turbo-rails (>= 2.0)

GEM
remote: https://rubygems.org/
Expand Down Expand Up @@ -139,8 +140,6 @@ GEM
net-smtp (0.5.1)
net-protocol
nio4r (2.7.5)
nokogiri (1.19.3-aarch64-linux-gnu)
racc (~> 1.4)
nokogiri (1.19.3-arm64-darwin)
racc (~> 1.4)
nokogiri (1.19.3-x86_64-linux-gnu)
Expand Down Expand Up @@ -271,13 +270,15 @@ GEM
fugit (~> 1.11)
railties (>= 7.1)
thor (>= 1.3.1)
sqlite3 (2.9.4-aarch64-linux-gnu)
sqlite3 (2.9.4-arm64-darwin)
sqlite3 (2.9.4-x86_64-linux-gnu)
stringio (3.2.0)
thor (1.5.0)
timeout (0.6.1)
tsort (0.2.0)
turbo-rails (2.0.23)
actionpack (>= 7.1.0)
railties (>= 7.1.0)
tzinfo (2.0.6)
concurrent-ruby (~> 1.0)
unicode-display_width (3.2.0)
Expand All @@ -293,7 +294,6 @@ GEM
zeitwerk (2.7.5)

PLATFORMS
aarch64-linux
arm64-darwin
x86_64-linux

Expand Down Expand Up @@ -351,7 +351,6 @@ CHECKSUMS
net-protocol (0.2.2) sha256=aa73e0cba6a125369de9837b8d8ef82a61849360eba0521900e2c3713aa162a8
net-smtp (0.5.1) sha256=ed96a0af63c524fceb4b29b0d352195c30d82dd916a42f03c62a3a70e5b70736
nio4r (2.7.5) sha256=6c90168e48fb5f8e768419c93abb94ba2b892a1d0602cb06eef16d8b7df1dca1
nokogiri (1.19.3-aarch64-linux-gnu) sha256=46b89e5d7b9e844c2ee360794240c6ea2a4e6fa0c5892a4ed487db621224b639
nokogiri (1.19.3-arm64-darwin) sha256=71b9bd424b1b7abc18b05052a1a3cfd3627abdca62be280854cc411791357e42
nokogiri (1.19.3-x86_64-linux-gnu) sha256=2f5078620fe12e83669b5b17311b32532a8153d02eee7ad06948b926d6080976
pagy (43.5.4) sha256=2bdf3fa6b1e0cac5bbafe5d077fb24eb971f72f3194f8c6863a0f3867261ce59
Expand Down Expand Up @@ -394,13 +393,13 @@ CHECKSUMS
simplecov_json_formatter (0.1.4) sha256=529418fbe8de1713ac2b2d612aa3daa56d316975d307244399fa4838c601b428
solid_queue (1.4.0) sha256=e6a18d196f0b27cb6e3c77c5b31258b05fb634f8ed64fb1866ed164047216c2a
solid_queue_web (0.3.0)
sqlite3 (2.9.4-aarch64-linux-gnu) sha256=ecabed721e6eaad54601d2685f09029d90025efc8d931040dc89cb3f8a2080ec
sqlite3 (2.9.4-arm64-darwin) sha256=1d5aad413a815d236e96d43f05a1acc600b6cd086800770342a3f9c2877499ff
sqlite3 (2.9.4-x86_64-linux-gnu) sha256=537a3eda71b1df1336d0055cbebe55a7317c34870c192c7b6b9d8d0be6871847
stringio (3.2.0) sha256=c37cb2e58b4ffbd33fe5cd948c05934af997b36e0b6ca6fdf43afa234cf222e1
thor (1.5.0) sha256=e3a9e55fe857e44859ce104a84675ab6e8cd59c650a49106a05f55f136425e73
timeout (0.6.1) sha256=78f57368a7e7bbadec56971f78a3f5ecbcfb59b7fcbb0a3ed6ddc08a5094accb
tsort (0.2.0) sha256=9650a793f6859a43b6641671278f79cfead60ac714148aabe4e3f0060480089f
turbo-rails (2.0.23) sha256=ee0d90733aafff056cf51ff11e803d65e43cae258cc55f6492020ec1f9f9315f
tzinfo (2.0.6) sha256=8daf828cc77bcf7d63b0e3bdb6caa47e2272dcfaf4fbfe46f8c3a9df087a829b
unicode-display_width (3.2.0) sha256=0cdd96b5681a5949cdbc2c55e7b420facae74c4aaf9a9815eee1087cb1853c42
unicode-emoji (4.2.0) sha256=519e69150f75652e40bf736106cfbc8f0f73aa3fb6a65afe62fefa7f80b0f80f
Expand Down
12 changes: 12 additions & 0 deletions app/assets/stylesheets/solid_queue_web/application.css
Original file line number Diff line number Diff line change
Expand Up @@ -139,6 +139,18 @@ body {
.sqd-stat--queues .sqd-stat__value { color: var(--purple); }
.sqd-stat--processes .sqd-stat__value { color: var(--muted); }

.sqd-stat--link {
display: block;
text-decoration: none;
color: inherit;
transition: border-color 0.15s, box-shadow 0.15s, transform 0.15s;
}
.sqd-stat--link:hover {
border-color: var(--primary);
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.08);
transform: translateY(-2px);
}

/* Tables */
.sqd-card {
background: var(--surface);
Expand Down
67 changes: 39 additions & 28 deletions app/controllers/solid_queue_web/jobs_controller.rb
Original file line number Diff line number Diff line change
Expand Up @@ -3,19 +3,21 @@ class JobsController < ApplicationController
STATUSES = %w[ready scheduled claimed blocked failed].freeze
DISCARDABLE = %w[ready scheduled blocked].freeze

before_action :set_status_and_queue, only: [ :destroy, :discard_all ]

EXECUTION_MODELS = {
"ready" => SolidQueue::ReadyExecution,
"scheduled" => SolidQueue::ScheduledExecution,
"claimed" => SolidQueue::ClaimedExecution,
"blocked" => SolidQueue::BlockedExecution,
"failed" => SolidQueue::FailedExecution
}.freeze

def index
@status = params[:status].presence_in(STATUSES) || "ready"
@queue = params[:queue].presence

@jobs = case @status
when "ready" then SolidQueue::ReadyExecution.includes(:job)
when "scheduled" then SolidQueue::ScheduledExecution.includes(:job)
when "claimed" then SolidQueue::ClaimedExecution.includes(:job)
when "blocked" then SolidQueue::BlockedExecution.includes(:job)
when "failed" then SolidQueue::FailedExecution.includes(:job)
end

@jobs = @jobs.where(jobs: { queue_name: @queue }) if @queue.present?
@jobs = EXECUTION_MODELS[@status].includes(:job)
@jobs = @jobs.where(jobs: { queue_name: @queue }) if @queue.present?
@pagy, @jobs = pagy(@jobs.order(created_at: :desc))
end

Expand All @@ -28,27 +30,30 @@ def show
end

def destroy
execution = execution_model_for!(params[:status]).find(params[:id])
execution.discard
redirect_to jobs_path(status: params[:status], queue: params[:queue]), notice: "Job discarded."
model = execution_model_for!(@status)
@execution = model.find(params[:id])
@execution.discard
@remaining_count = filtered_scope(model).count
respond_to do |format|
format.turbo_stream
format.html { redirect_to jobs_path(status: @status, queue: @queue), notice: "Job discarded." }
end
rescue ArgumentError => e
redirect_to jobs_path(status: params[:status], queue: params[:queue]), alert: e.message
redirect_to jobs_path(status: @status, queue: @queue), alert: e.message
rescue => e
redirect_to jobs_path(status: params[:status], queue: params[:queue]), alert: "Could not discard job: #{e.message}"
redirect_to jobs_path(status: @status, queue: @queue), alert: "Could not discard job: #{e.message}"
end

def discard_all
model = execution_model_for!(params[:status])
scope = model.includes(:job)
scope = scope.where(jobs: { queue_name: params[:queue] }) if params[:queue].present?
jobs = scope.map(&:job)
model = execution_model_for!(@status)
jobs = filtered_scope(model).map(&:job)
model.discard_all_from_jobs(jobs)
redirect_to jobs_path(status: params[:status], queue: params[:queue]),
redirect_to jobs_path(status: @status, queue: @queue),
notice: "#{jobs.size} #{"job".pluralize(jobs.size)} discarded."
rescue ArgumentError => e
redirect_to jobs_path(status: params[:status], queue: params[:queue]), alert: e.message
redirect_to jobs_path(status: @status, queue: @queue), alert: e.message
rescue => e
redirect_to jobs_path(status: params[:status], queue: params[:queue]), alert: "Could not discard jobs: #{e.message}"
redirect_to jobs_path(status: @status, queue: @queue), alert: "Could not discard jobs: #{e.message}"
end

private
Expand All @@ -62,13 +67,19 @@ def derive_status(job)
"finished"
end

def set_status_and_queue
@status = params[:status]
@queue = params[:queue].presence
end

def filtered_scope(model)
scope = model.includes(:job)
@queue.present? ? scope.where(jobs: { queue_name: @queue }) : scope
end

def execution_model_for!(status)
case status
when "ready" then SolidQueue::ReadyExecution
when "scheduled" then SolidQueue::ScheduledExecution
when "blocked" then SolidQueue::BlockedExecution
else raise ArgumentError, "Cannot discard #{status} jobs from this page."
end
raise ArgumentError, "Cannot discard #{status} jobs from this page." unless DISCARDABLE.include?(status)
EXECUTION_MODELS[status]
end
end
end
28 changes: 14 additions & 14 deletions app/views/solid_queue_web/dashboard/index.html.erb
Original file line number Diff line number Diff line change
@@ -1,34 +1,34 @@
<h1 class="sqd-page-title">Dashboard</h1>

<div class="sqd-stats">
<div class="sqd-stat sqd-stat--ready">
<%= link_to jobs_path(status: "ready"), class: "sqd-stat sqd-stat--ready sqd-stat--link" do %>
<div class="sqd-stat__value"><%= @stats[:ready] %></div>
<div class="sqd-stat__label">Ready</div>
</div>
<div class="sqd-stat sqd-stat--scheduled">
<% end %>
<%= link_to jobs_path(status: "scheduled"), class: "sqd-stat sqd-stat--scheduled sqd-stat--link" do %>
<div class="sqd-stat__value"><%= @stats[:scheduled] %></div>
<div class="sqd-stat__label">Scheduled</div>
</div>
<div class="sqd-stat sqd-stat--claimed">
<% end %>
<%= link_to jobs_path(status: "claimed"), class: "sqd-stat sqd-stat--claimed sqd-stat--link" do %>
<div class="sqd-stat__value"><%= @stats[:claimed] %></div>
<div class="sqd-stat__label">Running</div>
</div>
<div class="sqd-stat sqd-stat--blocked">
<% end %>
<%= link_to jobs_path(status: "blocked"), class: "sqd-stat sqd-stat--blocked sqd-stat--link" do %>
<div class="sqd-stat__value"><%= @stats[:blocked] %></div>
<div class="sqd-stat__label">Blocked</div>
</div>
<div class="sqd-stat sqd-stat--failed">
<% end %>
<%= link_to failed_jobs_path, class: "sqd-stat sqd-stat--failed sqd-stat--link" do %>
<div class="sqd-stat__value"><%= @stats[:failed] %></div>
<div class="sqd-stat__label">Failed</div>
</div>
<div class="sqd-stat sqd-stat--queues">
<% end %>
<%= link_to queues_path, class: "sqd-stat sqd-stat--queues sqd-stat--link" do %>
<div class="sqd-stat__value"><%= @stats[:queues] %></div>
<div class="sqd-stat__label">Queues</div>
</div>
<div class="sqd-stat sqd-stat--processes">
<% end %>
<%= link_to processes_path, class: "sqd-stat sqd-stat--processes sqd-stat--link" do %>
<div class="sqd-stat__value"><%= @stats[:processes] %></div>
<div class="sqd-stat__label">Processes</div>
</div>
<% end %>
</div>

<div style="display:grid; grid-template-columns: 1fr 1fr; gap: 1rem;">
Expand Down
9 changes: 9 additions & 0 deletions app/views/solid_queue_web/jobs/destroy.turbo_stream.erb
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
<% if @remaining_count == 0 %>
<%= turbo_stream.replace "jobs-list" do %>
<div class="sqd-card" id="jobs-list">
<div class="sqd-empty">No <%= @status %> jobs.</div>
</div>
<% end %>
<% else %>
<%= turbo_stream.remove "execution_#{@execution.id}" %>
<% end %>
24 changes: 13 additions & 11 deletions app/views/solid_queue_web/jobs/index.html.erb
Original file line number Diff line number Diff line change
@@ -1,7 +1,16 @@
<h1 class="sqd-page-title" style="margin-bottom: 1.5rem;">Jobs</h1>

<%= turbo_frame_tag "jobs-table" do %>
<% discardable = SolidQueueWeb::JobsController::DISCARDABLE.include?(@status) %>

<div class="sqd-page-header">
<h1 class="sqd-page-title">Jobs</h1>
<div class="sqd-filters">
<%= link_to "Ready", jobs_path(status: "ready", queue: @queue), class: @status == "ready" ? "active" : "" %>
<%= link_to "Scheduled", jobs_path(status: "scheduled", queue: @queue), class: @status == "scheduled" ? "active" : "" %>
<%= link_to "Running", jobs_path(status: "claimed", queue: @queue), class: @status == "claimed" ? "active" : "" %>
<%= link_to "Blocked", jobs_path(status: "blocked", queue: @queue), class: @status == "blocked" ? "active" : "" %>
<%= link_to "Failed", jobs_path(status: "failed", queue: @queue), class: @status == "failed" ? "active" : "" %>
</div>
<% if discardable && @jobs.any? %>
<div class="sqd-actions">
<%= button_to "Discard All", discard_all_jobs_path,
Expand All @@ -13,15 +22,7 @@
<% end %>
</div>

<div class="sqd-filters">
<%= link_to "Ready", jobs_path(status: "ready", queue: @queue), class: @status == "ready" ? "active" : "" %>
<%= link_to "Scheduled", jobs_path(status: "scheduled", queue: @queue), class: @status == "scheduled" ? "active" : "" %>
<%= link_to "Running", jobs_path(status: "claimed", queue: @queue), class: @status == "claimed" ? "active" : "" %>
<%= link_to "Blocked", jobs_path(status: "blocked", queue: @queue), class: @status == "blocked" ? "active" : "" %>
<%= link_to "Failed", jobs_path(status: "failed", queue: @queue), class: @status == "failed" ? "active" : "" %>
</div>

<div class="sqd-card">
<div class="sqd-card" id="jobs-list">
<% if @jobs.empty? %>
<div class="sqd-empty">No <%= @status %> jobs.</div>
<% else %>
Expand All @@ -39,7 +40,7 @@
<tbody>
<% @jobs.each do |execution| %>
<% job = execution.job %>
<tr>
<tr id="execution_<%= execution.id %>">
<td>
<span class="sqd-badge sqd-badge--<%= @status %>"><%= @status %></span>
<%= link_to job.class_name, job_path(job), style: "margin-left: 0.5rem;" %>
Expand Down Expand Up @@ -78,4 +79,5 @@
Filtering by queue: <strong><%= @queue %></strong> &mdash;
<%= link_to "Clear filter", jobs_path(status: @status) %>
</p>
<% end %>
<% end %>
1 change: 1 addition & 0 deletions lib/solid_queue_web/engine.rb
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
require "solid_queue"
require "pagy"
require "pagy/toolbox/paginators/method"
require "turbo-rails"

module SolidQueueWeb
class Engine < ::Rails::Engine
Expand Down
1 change: 1 addition & 0 deletions solid_queue_web.gemspec
Original file line number Diff line number Diff line change
Expand Up @@ -26,4 +26,5 @@ Gem::Specification.new do |spec|
spec.add_dependency "rails", ">= 8.1.3"
spec.add_dependency "solid_queue", ">= 1.0"
spec.add_dependency "pagy", ">= 43.0"
spec.add_dependency "turbo-rails", ">= 2.0"
end
16 changes: 16 additions & 0 deletions spec/requests/solid_queue_web/dashboard_spec.rb
Original file line number Diff line number Diff line change
Expand Up @@ -12,4 +12,20 @@
expect(response.body).to include("Dashboard")
end
end

describe "authentication" do
after { SolidQueueWeb.instance_variable_set(:@authenticate, nil) }

it "allows access when the auth block returns truthy" do
SolidQueueWeb.authenticate { true }
get "/jobs"
expect(response).to have_http_status(:ok)
end

it "returns 401 when the auth block returns falsy" do
SolidQueueWeb.authenticate { false }
get "/jobs"
expect(response).to have_http_status(:unauthorized)
end
end
end
16 changes: 16 additions & 0 deletions spec/requests/solid_queue_web/failed_jobs_spec.rb
Original file line number Diff line number Diff line change
Expand Up @@ -43,6 +43,14 @@
post "/jobs/failed_jobs/#{execution.id}/retry"
}.to change(SolidQueue::FailedExecution, :count).by(-1)
end

it "handles retry failure gracefully" do
allow_any_instance_of(SolidQueue::FailedExecution).to receive(:retry).and_raise(RuntimeError, "boom")
post "/jobs/failed_jobs/#{execution.id}/retry"
expect(response).to redirect_to("/jobs/failed_jobs")
follow_redirect!
expect(response.body).to include("Could not retry job")
end
end

describe "DELETE /jobs/failed_jobs/:id" do
Expand All @@ -59,6 +67,14 @@
}.to change(SolidQueue::FailedExecution, :count).by(-1)
.and change(SolidQueue::Job, :count).by(-1)
end

it "handles discard failure gracefully" do
allow_any_instance_of(SolidQueue::FailedExecution).to receive(:discard).and_raise(RuntimeError, "boom")
delete "/jobs/failed_jobs/#{execution.id}"
expect(response).to redirect_to("/jobs/failed_jobs")
follow_redirect!
expect(response.body).to include("Could not discard job")
end
end

describe "POST /jobs/failed_jobs/retry_all" do
Expand Down
Loading