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
15 changes: 8 additions & 7 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -9,18 +9,19 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0

### Added

- Stimulus via importmap-rails — `importmap-rails` added as an engine dependency; a `selection_controller.js` manages checkbox state, select-all toggling, and form injection at submit time; JS is delivered via the host app's importmap with no asset pipeline coupling
- Bulk retry and discard for failed jobs — checkbox column on the failed jobs list; "Retry Selected" and "Discard Selected" buttons appear in a selection bar when one or more jobs are checked; backed by `FailedJobs::SelectionsController` with `POST /failed_jobs/selection` (retry) and `DELETE /failed_jobs/selection` (discard)
- Bulk selection and discard — checkbox column on the jobs list for ready, scheduled, and blocked statuses; "Discard Selected" submits only the checked jobs via `DELETE /jobs/selection` (`Jobs::SelectionsController#destroy`); "Select All" header checkbox toggles all rows; filter state is preserved in the redirect after a bulk discard
- Discard All — "Discard All (N)" button on the jobs index header discards every job matching the current filters (class, queue, priority, period) in one request; respects the discardable-status guard so claimed jobs cannot be bulk-discarded; route `POST /jobs/discard_all` merges into the existing `destroy` action branching on `params[:id]`
- CSV export — "Export CSV" button on jobs and failed-jobs index pages; export respects active filters so operators download exactly what they see on screen; columns: `id, class_name, queue_name, status, priority, enqueued_at` for jobs and `id, class_name, queue_name, error_class, error_message, failed_at` for failed jobs
- Job detail page — `jobs/:id` show view with full arguments (pretty-printed JSON), queue, priority, enqueued time, status badge, Active Job ID, and status-specific metadata (scheduled_at, concurrency key, blocked-until); job class in the list is now a link to the detail page; Discard button available on the detail page for ready, scheduled, and blocked jobs
- 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

### Added

- **Bulk selection and discard** — checkbox column on the jobs list for ready, scheduled, and blocked statuses; "Discard Selected" submits only the checked jobs via `DELETE /jobs/selection` (`Jobs::SelectionsController#destroy`); "Select All" header checkbox toggles all rows; filter state is preserved in the redirect after a bulk discard
- **Discard All** — "Discard All (N)" button on the jobs index header discards every job matching the current filters (class, queue, priority, period) in one request; respects the discardable-status guard so claimed jobs cannot be bulk-discarded; route `POST /jobs/discard_all` merges into the existing `destroy` action branching on `params[:id]`
- **CSV export** — "Export CSV" button on jobs and failed-jobs index pages; export respects active filters so operators download exactly what they see on screen; columns: `id, class_name, queue_name, status, priority, enqueued_at` for jobs and `id, class_name, queue_name, error_class, error_message, failed_at` for failed jobs
- 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

### Fixed

- Retry button on failed jobs raised `NoMethodError` when dev seed data stored arguments as a raw JSON string instead of a Hash; seeds now use the Active Job arguments format (`executions`, `exception_executions` keys) that `SolidQueue::Job#reset_execution_counters` requires
- Action buttons in table rows were stacking vertically because `button_to` wraps each button in a block-level `<form>`; fixed with `.sqw-actions form { display: inline }`
- `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`
Expand Down
6 changes: 6 additions & 0 deletions Gemfile.lock
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ PATH
specs:
solid_stack_web (0.1.0)
csv (>= 3.0)
importmap-rails (>= 1.2)
pagy (>= 43.0)
rails (>= 8.1.3)
solid_cable (>= 1.0)
Expand Down Expand Up @@ -113,6 +114,10 @@ GEM
activesupport (>= 6.1)
i18n (1.14.8)
concurrent-ruby (~> 1.0)
importmap-rails (2.2.3)
actionpack (>= 6.0.0)
activesupport (>= 6.0.0)
railties (>= 6.0.0)
io-console (0.8.2)
irb (1.18.0)
pp (>= 0.6.0)
Expand Down Expand Up @@ -363,6 +368,7 @@ CHECKSUMS
fugit (1.12.1) sha256=5898f478ede9b415f0804e42b8f3fd53f814bd85eebffceebdbc34e1107aaf68
globalid (1.3.0) sha256=05c639ad6eb4594522a0b07983022f04aa7254626ab69445a0e493aa3786ff11
i18n (1.14.8) sha256=285778639134865c5e0f6269e0b818256017e8cde89993fdfcbfb64d088824a5
importmap-rails (2.2.3) sha256=7101be2a4dc97cf1558fb8f573a718404c5f6bcfe94f304bf1f39e444feeb16a
io-console (0.8.2) sha256=d6e3ae7a7cc7574f4b8893b4fca2162e57a825b223a177b7afa236c5ef9814cc
irb (1.18.0) sha256=de9454a0703a54704b9811a5ef31a60c86949fbf4013fcf244fabc7c775248e3
json (2.19.5) sha256=218a18553e4801d579ca7e0f5bc72bafd776d7397238a1fb4e74db5b0a812c59
Expand Down
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,7 @@ A mountable Rails engine that provides a unified web dashboard for the full [Sol
## Features

- **Overview dashboard** with live counts across all three Solid Stack components; cards are clickable and link directly to each section
- **Solid Queue** — browse jobs by status (ready, scheduled, claimed, blocked) with filtering by job class, queue name, priority, and time period; manage failed jobs (retry / discard), pause/resume queues, and inspect worker processes; **Bulk selection** checkbox-selects individual jobs for discard; **Discard All** bulk-discards every job matching the current filters in one request; **CSV export** downloads jobs or failed jobs as a CSV file respecting active filters
- **Solid Queue** — browse jobs by status (ready, scheduled, claimed, blocked) with filtering by job class, queue name, priority, and time period; manage failed jobs (retry / discard / bulk retry / bulk discard), pause/resume queues, and inspect worker processes; **Bulk selection** checkbox-selects individual jobs for discard or retry; **Discard All** bulk-discards every job matching the current filters in one request; **CSV export** downloads jobs or failed jobs as a CSV file respecting active filters
- **Job detail page** — drill into any job to see full arguments (pretty-printed JSON), queue, priority, enqueued time, Active Job ID, concurrency key, scheduled/blocked-until metadata, and a Discard button
- **Solid Cache** — entry count and total byte size at a glance
- **Solid Cable** — active message count and distinct channel count
Expand Down
1 change: 0 additions & 1 deletion ROADMAP.md
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,6 @@ The path to v1.0.0 is staged: first achieve feature parity with `solid_queue_das
> _Make the job management layer genuinely useful for operators._

### Added
- **Bulk retry (failed jobs)** — retry selected failed jobs with optional stagger interval (5 s / 10 s / 30 s / 1 m) to avoid thundering-herd restarts
- **Edit arguments & retry** — inline argument editor on failed job detail; retry with modified payload

---
Expand Down
1 change: 1 addition & 0 deletions app/assets/stylesheets/solid_stack_web/_04_table.css
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,7 @@
.sqw-table tbody tr:hover { background: #f9fafb; }

.sqw-actions { text-align: right; white-space: nowrap; }
.sqw-actions form { display: inline; }

.sqw-empty {
background: var(--surface);
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
module SolidStackWeb
module FailedJobs
class SelectionsController < ApplicationController
def create
ids = Array(params[:job_ids]).map(&:to_i).reject(&:zero?)
SolidQueue::FailedExecution.where(id: ids).each(&:retry)
redirect_to failed_jobs_path
rescue => e
redirect_to failed_jobs_path, alert: "Could not retry jobs: #{e.message}"
end

def destroy
ids = Array(params[:job_ids]).map(&:to_i).reject(&:zero?)
job_ids = SolidQueue::FailedExecution.where(id: ids).pluck(:job_id)
SolidQueue::Job.where(id: job_ids).destroy_all
redirect_to failed_jobs_path
rescue => e
redirect_to failed_jobs_path, alert: "Could not discard jobs: #{e.message}"
end
end
end
end
6 changes: 6 additions & 0 deletions app/javascript/solid_stack_web/application.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
import "@hotwired/turbo"
import { Application } from "@hotwired/stimulus"
import SelectionController from "solid_stack_web/selection_controller"

const application = Application.start()
application.register("selection", SelectionController)
42 changes: 42 additions & 0 deletions app/javascript/solid_stack_web/selection_controller.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,42 @@
import { Controller } from "@hotwired/stimulus"

export default class extends Controller {
static targets = ["checkbox", "selectAll", "bar", "count"]

toggle() {
this._update()
}

selectAll({ target }) {
this.checkboxTargets.forEach(cb => cb.checked = target.checked)
this._update()
}

submit({ params: { formId } }) {
const form = document.getElementById(formId)
if (!form) return
form.querySelectorAll("[data-injected-id]").forEach(el => el.remove())
this.checkboxTargets
.filter(cb => cb.checked)
.forEach(cb => {
const input = document.createElement("input")
input.type = "hidden"
input.name = "job_ids[]"
input.value = cb.value
input.dataset.injectedId = true
form.appendChild(input)
})
form.requestSubmit()
}

_update() {
const checked = this.checkboxTargets.filter(cb => cb.checked).length
const total = this.checkboxTargets.length
if (this.hasBarTarget) this.barTarget.style.display = checked > 0 ? "" : "none"
if (this.hasCountTarget) this.countTarget.textContent = checked
if (this.hasSelectAllTarget) {
this.selectAllTarget.indeterminate = checked > 0 && checked < total
this.selectAllTarget.checked = total > 0 && checked === total
}
}
}
2 changes: 1 addition & 1 deletion app/views/layouts/solid_stack_web/application.html.erb
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@
<%= csrf_meta_tags %>
<%= csp_meta_tag %>
<%= inline_styles %>
<script type="module" src="https://esm.sh/@hotwired/turbo@8"></script>
<%= javascript_importmap_tags "solid_stack_web" %>
</head>
<body>
<header class="sqw-header">
Expand Down
85 changes: 56 additions & 29 deletions app/views/solid_stack_web/failed_jobs/index.html.erb
Original file line number Diff line number Diff line change
Expand Up @@ -8,38 +8,65 @@

<div id="sqw-jobs-table">
<% if @executions.any? %>
<table class="sqw-table">
<thead>
<tr>
<th>Job Class</th>
<th>Queue</th>
<th>Error</th>
<th>Failed At</th>
<th></th>
</tr>
</thead>
<tbody>
<% @executions.each do |execution| %>
<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" 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),
method: :post, class: "sqw-btn sqw-btn--sm" %>
<%= button_to "Discard", failed_job_path(execution),
method: :delete, class: "sqw-btn sqw-btn--danger sqw-btn--sm",
data: { turbo_confirm: "Discard this job?" } %>
</td>
<div data-controller="selection">
<%= form_with url: failed_job_selection_path, method: :post, id: "retry-selection-form" do |f| %>
<% end %>

<%= form_with url: failed_job_selection_path, method: :delete, id: "discard-selection-form",
data: { turbo_confirm: "Discard selected failed jobs? This cannot be undone." } do |f| %>
<% end %>

<div class="sqw-selection-bar" data-selection-target="bar" style="display: none;">
<span class="sqw-muted"><span data-selection-target="count">0</span> selected</span>
<button type="button" class="sqw-btn sqw-btn--sm"
data-action="click->selection#submit"
data-selection-form-id-param="retry-selection-form">Retry Selected</button>
<button type="button" class="sqw-btn sqw-btn--danger sqw-btn--sm"
data-action="click->selection#submit"
data-selection-form-id-param="discard-selection-form">Discard Selected</button>
</div>

<table class="sqw-table">
<thead>
<tr>
<th><input type="checkbox" class="sqw-checkbox" aria-label="Select all"
data-selection-target="selectAll"
data-action="change->selection#selectAll"></th>
<th>Job Class</th>
<th>Queue</th>
<th>Error</th>
<th>Failed At</th>
<th></th>
</tr>
<% end %>
</tbody>
</table>
<%== pagy_nav(@pagy) if @pagy.pages > 1 %>
</thead>
<tbody>
<% @executions.each do |execution| %>
<tr id="execution_<%= execution.id %>">
<td><input type="checkbox" value="<%= execution.id %>"
class="sqw-checkbox"
aria-label="Select <%= execution.job.class_name %>"
data-selection-target="checkbox"
data-action="change->selection#toggle"></td>
<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" 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),
method: :post, class: "sqw-btn sqw-btn--sm" %>
<%= button_to "Discard", failed_job_path(execution),
method: :delete, class: "sqw-btn sqw-btn--danger sqw-btn--sm",
data: { turbo_confirm: "Discard this job?" } %>
</td>
</tr>
<% end %>
</tbody>
</table>
<%== pagy_nav(@pagy) if @pagy.pages > 1 %>
</div>
<% else %>
<div class="sqw-empty">
<p>No failed jobs.</p>
</div>
<% end %>
</div>
</div>
40 changes: 23 additions & 17 deletions app/views/solid_stack_web/jobs/index.html.erb
Original file line number Diff line number Diff line change
Expand Up @@ -61,20 +61,22 @@

<div id="sqw-jobs-table">
<% if @executions.any? %>
<%= form_with url: job_selection_path,
method: :delete,
data: { turbo_frame: "_top" } do |f| %>
<%= f.hidden_field :status, value: @status %>
<%= f.hidden_field :q, value: @search %>
<%= f.hidden_field :queue, value: @queue %>
<%= f.hidden_field :period, value: @period %>
<%= f.hidden_field :priority, value: @priority %>

<div data-controller="<%= "selection" if SolidStackWeb::Job::DISCARDABLE.include?(@status) %>">
<% if SolidStackWeb::Job::DISCARDABLE.include?(@status) %>
<div class="sqw-selection-bar">
<%= f.submit "Discard Selected",
class: "sqw-btn sqw-btn--danger sqw-btn--sm",
data: { turbo_confirm: "Discard selected jobs? This cannot be undone." } %>
<%= form_with url: job_selection_path, method: :delete, id: "job-selection-form",
data: { turbo_confirm: "Discard selected jobs? This cannot be undone." } do |f| %>
<%= f.hidden_field :status, value: @status %>
<%= f.hidden_field :q, value: @search %>
<%= f.hidden_field :queue, value: @queue %>
<%= f.hidden_field :period, value: @period %>
<%= f.hidden_field :priority, value: @priority %>
<% end %>

<div class="sqw-selection-bar" data-selection-target="bar" style="display: none;">
<span class="sqw-muted"><span data-selection-target="count">0</span> selected</span>
<button type="button" class="sqw-btn sqw-btn--danger sqw-btn--sm"
data-action="click->selection#submit"
data-selection-form-id-param="job-selection-form">Discard Selected</button>
</div>
<% end %>

Expand All @@ -83,7 +85,8 @@
<tr>
<% if SolidStackWeb::Job::DISCARDABLE.include?(@status) %>
<th><input type="checkbox" class="sqw-checkbox" aria-label="Select all"
onclick="this.closest('form').querySelectorAll('.sqw-checkbox-row').forEach(cb => cb.checked = this.checked)"></th>
data-selection-target="selectAll"
data-action="change->selection#selectAll"></th>
<% end %>
<th>Job Class</th>
<th>Queue</th>
Expand All @@ -97,8 +100,11 @@
<% @executions.each do |execution| %>
<tr id="execution_<%= execution.id %>">
<% if SolidStackWeb::Job::DISCARDABLE.include?(@status) %>
<td><input type="checkbox" name="job_ids[]" value="<%= execution.id %>"
class="sqw-checkbox sqw-checkbox-row" aria-label="Select <%= execution.job.class_name %>"></td>
<td><input type="checkbox" value="<%= execution.id %>"
class="sqw-checkbox"
aria-label="Select <%= execution.job.class_name %>"
data-selection-target="checkbox"
data-action="change->selection#toggle"></td>
<% end %>
<td class="sqw-monospace"><%= link_to execution.job.class_name, job_path(execution.id, status: @status), data: { turbo_frame: "_top" } %></td>
<td><span class="sqw-badge sqw-badge--queue"><%= execution.job.queue_name %></span></td>
Expand All @@ -119,7 +125,7 @@
</tbody>
</table>
<%== pagy_nav(@pagy) if @pagy.pages > 1 %>
<% end %>
</div>
<% else %>
<%= render "empty" %>
<% end %>
Expand Down
2 changes: 2 additions & 0 deletions config/importmap.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
pin "solid_stack_web", to: "solid_stack_web/application.js"
pin "solid_stack_web/selection_controller", to: "solid_stack_web/selection_controller.js"
3 changes: 2 additions & 1 deletion config/routes.rb
Original file line number Diff line number Diff line change
@@ -1,7 +1,8 @@
SolidStackWeb::Engine.routes.draw do
root to: "dashboard#index"

resource :job_selection, path: "jobs/selection", only: [:destroy], controller: "jobs/selections"
resource :job_selection, path: "jobs/selection", only: [:destroy], controller: "jobs/selections"
resource :failed_job_selection, path: "failed_jobs/selection", only: [:create, :destroy], controller: "failed_jobs/selections"

resources :jobs, only: [:index, :show, :destroy] do
collection do
Expand Down
14 changes: 14 additions & 0 deletions lib/solid_stack_web/engine.rb
Original file line number Diff line number Diff line change
Expand Up @@ -4,13 +4,27 @@
require "solid_cache"
require "solid_cable"
require "turbo-rails"
require "importmap-rails"

module SolidStackWeb
class Engine < ::Rails::Engine
isolate_namespace SolidStackWeb

config.i18n.load_path += Gem.find_files("pagy/locales/en.yml")

initializer "solid_stack_web.assets" do |app|
if app.config.respond_to?(:assets)
app.config.assets.paths << root.join("app/javascript")
end
end

initializer "solid_stack_web.importmap", before: "importmap" do |app|
if app.config.respond_to?(:importmap)
app.config.importmap.paths << root.join("config/importmap.rb")
app.config.importmap.cache_sweepers << root.join("app/javascript")
end
end

initializer "solid_stack_web.pagy" do |app|
app.config.after_initialize do
Pagy::OPTIONS[:limit] = SolidStackWeb.page_size
Expand Down
Loading