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

### Added

- Job selection and targeted bulk actions — checkboxes on the jobs index (ready/scheduled/blocked) and failed jobs index; a selection bar appears above the table showing the count and action buttons ("Discard Selected" for jobs, "Retry Selected" / "Discard Selected" for failed jobs); select-all checkbox in the table header; powered by a new `selection` Stimulus controller that injects checked IDs into a hidden form on submit; uses nested singular resources (`Jobs::SelectionsController`, `FailedJobs::SelectionsController`) following Rails conventions
- Auto-refresh for dashboard, jobs list, and processes — a `refresh` Stimulus controller polls the current page at a configurable interval (5 s for dashboard, 10 s for jobs/processes) and swaps the matching `<turbo-frame>` content in place; polling pauses when the browser tab is hidden and resumes with an immediate refresh when the tab becomes visible again
- Time-based period filter (`?period=1h|24h|7d`) on the jobs and failed jobs indexes — filters results by enqueued timestamp; period pills render inline with the search bar, right-justified; active period is preserved across status tab switches, search, queue filters, and bulk actions
- Global job search at `/jobs/search` — queries all execution models (ready, scheduled, claimed, blocked, failed) by class name substring; results grouped by status with match count and a "View all →" link; native `<datalist>` autocomplete pre-populated from all known job class names; auto-submits on datalist selection via the `search` Stimulus controller
Expand Down
1 change: 1 addition & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -42,6 +42,7 @@ SolidQueueWeb surfaces all of this in a browser UI available at any route you ch
- **Recurring tasks** — all configured recurring tasks with cron schedule, next run time, last run time, and static/dynamic classification
- **Processes** — workers, dispatchers, and supervisors with heartbeat health status; auto-refreshes every 10 seconds
- **Global search** — search across all job statuses at once by class name substring; results grouped by status with match count and direct links to filtered views; native datalist autocomplete pre-populated from all known job classes; auto-submits on selection
- **Targeted bulk actions** — checkboxes on the jobs and failed jobs lists for selecting individual rows; selection bar shows count and action buttons ("Discard Selected" for jobs, "Retry Selected" / "Discard Selected" for failed jobs); select-all checkbox in the table header

## Screenshots

Expand Down
19 changes: 19 additions & 0 deletions app/assets/stylesheets/solid_queue_web/application.css
Original file line number Diff line number Diff line change
Expand Up @@ -302,6 +302,25 @@ tbody tr:hover { background: var(--bg); }
.sqd-row-actions { white-space: nowrap; text-align: right; width: 1%; }
.sqd-row-actions form { display: inline; margin-left: 0.25rem; }

/* Selection bar */
.sqd-selection-bar {
display: flex;
align-items: center;
gap: 0.75rem;
padding: 0.5rem 1rem;
background: var(--bg);
border-bottom: 1px solid var(--border);
font-size: 13px;
}

table th input[type="checkbox"],
table td input[type="checkbox"] {
width: 15px;
height: 15px;
cursor: pointer;
accent-color: var(--primary);
}

/* Search */
.sqd-search {
display: flex;
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
module SolidQueueWeb
module FailedJobs
class SelectionsController < ApplicationController
def create
ids = Array(params[:ids]).map(&:to_i).reject(&:zero?)
executions = SolidQueue::FailedExecution.where(id: ids)
jobs = executions.includes(:job).map(&:job)
SolidQueue::FailedExecution.retry_all(jobs)
redirect_to failed_jobs_path,
notice: "#{jobs.size} #{"job".pluralize(jobs.size)} queued for retry."
rescue => e
redirect_to failed_jobs_path, alert: "Could not retry jobs: #{e.message}"
end

def destroy
ids = Array(params[:ids]).map(&:to_i).reject(&:zero?)
executions = SolidQueue::FailedExecution.where(id: ids)
jobs = executions.includes(:job).map(&:job)
SolidQueue::FailedExecution.discard_all_from_jobs(jobs)
redirect_to failed_jobs_path,
notice: "#{jobs.size} #{"job".pluralize(jobs.size)} discarded."
rescue => e
redirect_to failed_jobs_path, alert: "Could not discard jobs: #{e.message}"
end
end
end
end
21 changes: 21 additions & 0 deletions app/controllers/solid_queue_web/jobs/selections_controller.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
module SolidQueueWeb
module Jobs
class SelectionsController < ApplicationController
def destroy
status = params[:status]
period = params[:period].presence_in(PERIOD_DURATIONS.keys)
raise ArgumentError, "Cannot discard #{status} jobs." unless Job::DISCARDABLE.include?(status)
model = Job::EXECUTION_MODELS[status]
ids = Array(params[:ids]).map(&:to_i).reject(&:zero?)
jobs = model.where(id: ids).includes(:job).map(&:job)
model.discard_all_from_jobs(jobs)
redirect_to jobs_path(status: status, period: period),
notice: "#{jobs.size} #{"job".pluralize(jobs.size)} discarded."
rescue ArgumentError => e
redirect_to jobs_path(status: status), alert: e.message
rescue => e
redirect_to jobs_path(status: status), alert: "Could not discard jobs: #{e.message}"
end
end
end
end
2 changes: 1 addition & 1 deletion app/controllers/solid_queue_web/jobs_controller.rb
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
module SolidQueueWeb
class JobsController < ApplicationController
before_action :set_status, only: [ :destroy, :discard_all ]
before_action :set_status, only: [ :destroy, :discard_all, :discard_selected ]

def index
@status = params[:status].presence_in(Job::STATUSES) || "ready"
Expand Down
2 changes: 2 additions & 0 deletions app/javascript/solid_queue_web/application.js
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,9 @@ import "@hotwired/turbo"
import { Application } from "@hotwired/stimulus"
import SearchController from "solid_queue_web/search_controller"
import RefreshController from "solid_queue_web/refresh_controller"
import SelectionController from "solid_queue_web/selection_controller"

const application = Application.start()
application.register("search", SearchController)
application.register("refresh", RefreshController)
application.register("selection", SelectionController)
42 changes: 42 additions & 0 deletions app/javascript/solid_queue_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 = "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
}
}
}
126 changes: 82 additions & 44 deletions app/views/solid_queue_web/failed_jobs/index.html.erb
Original file line number Diff line number Diff line change
Expand Up @@ -40,52 +40,90 @@
<%= @pagy.series_nav.html_safe %>
<% end %>

<div class="sqd-card">
<% if @failed_jobs.empty? %>
<div class="sqd-empty">No failed jobs. All clear!</div>
<% else %>
<table>
<thead>
<tr>
<th scope="col">Job Class</th>
<th scope="col">Queue</th>
<th scope="col">Error</th>
<th scope="col">Failed At</th>
<th scope="col"><span class="sqd-sr-only">Actions</span></th>
</tr>
</thead>
<tbody>
<% @failed_jobs.each do |execution| %>
<% job = execution.job %>
<% if @failed_jobs.any? %>
<div data-controller="selection">
<%= form_tag failed_job_selection_path, method: :post, id: "retry-selection-form" do %>
<%= hidden_field_tag :queue, @queue %>
<%= hidden_field_tag :q, @search %>
<%= hidden_field_tag :period, @period %>
<% end %>

<%= form_tag failed_job_selection_path, method: :delete, id: "discard-selection-form",
data: { turbo_confirm: "Discard selected jobs? This cannot be undone." } do %>
<%= hidden_field_tag :queue, @queue %>
<%= hidden_field_tag :q, @search %>
<%= hidden_field_tag :period, @period %>
<% end %>

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

<div class="sqd-card">
<table>
<thead>
<tr>
<td><%= link_to job.class_name, job_path(job) %></td>
<td>
<%= link_to job.queue_name, failed_jobs_path(queue: job.queue_name, q: @search, period: @period),
class: "sqd-mono", style: "color: inherit;" %>
</td>
<td>
<% if execution.exception_class.present? %>
<div class="sqd-error-msg sqd-truncate" title="<%= execution.exception_class %>: <%= execution.message %>">
<strong><%= execution.exception_class %></strong>: <%= execution.message %>
</div>
<% else %>
<span style="color:var(--muted)">—</span>
<% end %>
</td>
<td class="sqd-mono"><%= execution.created_at.strftime("%Y-%m-%d %H:%M:%S") %></td>
<td class="sqd-row-actions">
<%= button_to "Retry", retry_failed_job_path(execution), method: :post,
class: "sqd-btn sqd-btn--primary sqd-btn--sm" %>
<%= button_to "Discard", failed_job_path(execution), method: :delete,
class: "sqd-btn sqd-btn--danger sqd-btn--sm",
data: { confirm: "Discard this job?" } %>
</td>
<th scope="col">
<input type="checkbox" data-selection-target="selectAll"
data-action="change->selection#selectAll"
aria-label="Select all failed jobs">
</th>
<th scope="col">Job Class</th>
<th scope="col">Queue</th>
<th scope="col">Error</th>
<th scope="col">Failed At</th>
<th scope="col"><span class="sqd-sr-only">Actions</span></th>
</tr>
<% end %>
</tbody>
</table>
<% end %>
</div>
</thead>
<tbody>
<% @failed_jobs.each do |execution| %>
<% job = execution.job %>
<tr>
<td>
<input type="checkbox" value="<%= execution.id %>"
data-selection-target="checkbox"
data-action="change->selection#toggle"
aria-label="Select job <%= job.class_name %>">
</td>
<td><%= link_to job.class_name, job_path(job) %></td>
<td>
<%= link_to job.queue_name, failed_jobs_path(queue: job.queue_name, q: @search, period: @period),
class: "sqd-mono", style: "color: inherit;" %>
</td>
<td>
<% if execution.exception_class.present? %>
<div class="sqd-error-msg sqd-truncate" title="<%= execution.exception_class %>: <%= execution.message %>">
<strong><%= execution.exception_class %></strong>: <%= execution.message %>
</div>
<% else %>
<span style="color:var(--muted)">—</span>
<% end %>
</td>
<td class="sqd-mono"><%= execution.created_at.strftime("%Y-%m-%d %H:%M:%S") %></td>
<td class="sqd-row-actions">
<%= button_to "Retry", retry_failed_job_path(execution), method: :post,
class: "sqd-btn sqd-btn--primary sqd-btn--sm" %>
<%= button_to "Discard", failed_job_path(execution), method: :delete,
class: "sqd-btn sqd-btn--danger sqd-btn--sm",
data: { confirm: "Discard this job?" } %>
</td>
</tr>
<% end %>
</tbody>
</table>
</div>
</div>
<% else %>
<div class="sqd-card">
<div class="sqd-empty">No failed jobs. All clear!</div>
</div>
<% end %>

<% if @queue.present? %>
<p style="margin-top: 0.75rem; font-size: 13px; color: var(--muted);">
Expand Down
Loading
Loading