diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 41be777..a538caa 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -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" diff --git a/CHANGELOG.md b/CHANGELOG.md index b9a55a3..6157b38 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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 diff --git a/Gemfile.lock b/Gemfile.lock index 67e00eb..466263c 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -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/ @@ -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) @@ -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) @@ -293,7 +294,6 @@ GEM zeitwerk (2.7.5) PLATFORMS - aarch64-linux arm64-darwin x86_64-linux @@ -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 @@ -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 diff --git a/app/assets/stylesheets/solid_queue_web/application.css b/app/assets/stylesheets/solid_queue_web/application.css index cae07e5..72cf657 100644 --- a/app/assets/stylesheets/solid_queue_web/application.css +++ b/app/assets/stylesheets/solid_queue_web/application.css @@ -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); diff --git a/app/controllers/solid_queue_web/jobs_controller.rb b/app/controllers/solid_queue_web/jobs_controller.rb index 8b34186..aea74c4 100644 --- a/app/controllers/solid_queue_web/jobs_controller.rb +++ b/app/controllers/solid_queue_web/jobs_controller.rb @@ -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 @@ -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 @@ -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 diff --git a/app/views/solid_queue_web/dashboard/index.html.erb b/app/views/solid_queue_web/dashboard/index.html.erb index ba25592..2caf25f 100644 --- a/app/views/solid_queue_web/dashboard/index.html.erb +++ b/app/views/solid_queue_web/dashboard/index.html.erb @@ -1,34 +1,34 @@