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

## [Unreleased]

### Added

- Scheduled job management — "Run Now" promotes a scheduled job to run immediately by back-dating its `scheduled_at`; "+1h", "+24h", and "+7d" buttons push `scheduled_at` forward by the chosen offset; both actions update the execution and the underlying job record; Turbo Stream responses remove the row on "Run Now" and update the `scheduled_at` cell in place on postpone

## [0.9.0] - 2026-05-20

### Added
Expand Down
1 change: 1 addition & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,7 @@ SolidQueueWeb surfaces all of this in a browser UI available at any route you ch
- **Dashboard** — stat cards showing counts for ready, scheduled, running, blocked, and failed jobs, plus queues, recurring tasks, and processes; "Done (1h)" and "Done (24h)" throughput cards; a "Throughput — Last 12 Hours" bar chart (blue) and a "Queue Depth — Last 12 Hours" bar chart (purple) showing hourly snapshots of active job count; pure CSS, no charting library; auto-refreshes every 5 seconds
- **Queues** — all queues sorted by name with size; oldest ready job latency (color-coded, with UTC timestamp tooltip); Done (24h) and Failed (24h) throughput counts; a mini 12-bar failure rate sparkline per queue showing failure % per hour over the last 12 hours; pause/resume controls
- **Jobs** — filterable by status (ready, scheduled, claimed, blocked, failed) and by queue; search by job class name with dynamic auto-submit; time-based period filter (1 h / 24 h / 7 d); discard individual or all jobs; Turbo Frame navigation so only the table updates on filter or search; auto-refreshes every 10 seconds
- **Scheduled job management** — reschedule a scheduled job to run immediately ("Run Now") or push its `scheduled_at` forward by 1 h, 24 h, or 7 d; Turbo Stream responses update the row in place
- **Failed jobs** — list of failed executions with error details; search by class name; filter by queue; time-based period filter; retry or discard individually or in bulk
- **Job detail** — full arguments, timestamps, blocked-until date, and error backtrace; action buttons based on job status
- **Queue management** — pause and resume individual queues; queue-scoped job list with status filter, search, and discard
Expand Down
34 changes: 34 additions & 0 deletions app/controllers/solid_queue_web/scheduled_jobs_controller.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,34 @@
module SolidQueueWeb
class ScheduledJobsController < ApplicationController
OFFSETS = { "1h" => 1.hour, "24h" => 24.hours, "7d" => 7.days }.freeze

def update
@execution = SolidQueue::ScheduledExecution.find(params[:id])
@period = params[:period].presence_in(PERIOD_DURATIONS.keys)
@run_now = params[:offset] == "now"

new_time = if @run_now
1.second.ago
elsif OFFSETS.key?(params[:offset])
@execution.scheduled_at + OFFSETS[params[:offset]]
else
raise ArgumentError, "Invalid offset."
end

@execution.update!(scheduled_at: new_time)
@execution.job.update!(scheduled_at: new_time)

respond_to do |format|
format.turbo_stream
format.html do
notice = @run_now ? "Job scheduled to run immediately." : "Job rescheduled by +#{params[:offset]}."
redirect_to jobs_path(status: "scheduled", period: @period), notice: notice
end
end
rescue ArgumentError => e
redirect_to jobs_path(status: "scheduled"), alert: e.message
rescue => e
redirect_to jobs_path(status: "scheduled"), alert: "Could not reschedule job: #{e.message}"
end
end
end
15 changes: 14 additions & 1 deletion app/views/solid_queue_web/jobs/index.html.erb
Original file line number Diff line number Diff line change
Expand Up @@ -94,11 +94,24 @@
class: "sqd-mono", style: "color: inherit;" %>
</td>
<td><%= job.priority %></td>
<td class="sqd-mono">
<td id="scheduled_at_<%= execution.id %>" class="sqd-mono">
<%= job.scheduled_at ? job.scheduled_at.strftime("%Y-%m-%d %H:%M:%S") : "—" %>
</td>
<td class="sqd-mono"><%= job.created_at.strftime("%Y-%m-%d %H:%M:%S") %></td>
<td class="sqd-row-actions">
<% if @status == "scheduled" %>
<%= button_to "Run Now", scheduled_job_path(execution),
method: :patch,
params: { offset: "now", period: @period },
class: "sqd-btn sqd-btn--primary sqd-btn--sm",
data: { confirm: "Run this job immediately?" } %>
<% %w[1h 24h 7d].each do |offset| %>
<%= button_to "+#{offset}", scheduled_job_path(execution),
method: :patch,
params: { offset: offset, period: @period },
class: "sqd-btn sqd-btn--muted sqd-btn--sm" %>
<% end %>
<% end %>
<%= button_to "Discard", job_path(execution),
method: :delete,
params: { status: @status, period: @period },
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
<% if @run_now %>
<%= turbo_stream.remove "execution_#{@execution.id}" %>
<% else %>
<%= turbo_stream.replace "scheduled_at_#{@execution.id}" do %>
<td id="scheduled_at_<%= @execution.id %>" class="sqd-mono">
<%= @execution.scheduled_at.strftime("%Y-%m-%d %H:%M:%S") %>
</td>
<% end %>
<% end %>
2 changes: 2 additions & 0 deletions config/routes.rb
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,8 @@

# Singular selection resources must be defined before the member routes of their
# parent resources, otherwise DELETE /list/selection matches /list/:id first.
resources :scheduled_jobs, only: [:update]

resource :job_selection, path: "list/selection", only: [:destroy], controller: "jobs/selections"
resources :jobs, path: "list", only: [:index, :show, :destroy] do
collection do
Expand Down
110 changes: 110 additions & 0 deletions spec/requests/solid_queue_web/scheduled_jobs_spec.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,110 @@
require "rails_helper"

RSpec.describe "ScheduledJobs", type: :request do
let!(:job) do
SolidQueue::Job.create!(
queue_name: "default",
class_name: "TestJob",
arguments: {},
active_job_id: SecureRandom.uuid,
scheduled_at: 2.hours.from_now
)
end

let(:execution) { job.scheduled_execution }

describe "PATCH /scheduled_jobs/:id" do
context "with offset=now" do
it "sets scheduled_at to the past and redirects" do
patch "/jobs/scheduled_jobs/#{execution.id}", params: { offset: "now" }
expect(response).to redirect_to("/jobs/list?status=scheduled")
execution.reload
expect(execution.scheduled_at).to be <= Time.current
end

it "also updates the job's scheduled_at" do
patch "/jobs/scheduled_jobs/#{execution.id}", params: { offset: "now" }
job.reload
expect(job.scheduled_at).to be <= Time.current
end

it "removes the row via turbo stream" do
patch "/jobs/scheduled_jobs/#{execution.id}",
params: { offset: "now" },
headers: { "Accept" => "text/vnd.turbo-stream.html, text/html" }
expect(response.content_type).to include("text/vnd.turbo-stream.html")
expect(response.body).to include("execution_#{execution.id}")
expect(response.body).to include("remove")
end
end

context "with offset=1h" do
it "postpones scheduled_at by 1 hour and redirects" do
original = execution.scheduled_at
patch "/jobs/scheduled_jobs/#{execution.id}", params: { offset: "1h" }
expect(response).to redirect_to("/jobs/list?status=scheduled")
execution.reload
expect(execution.scheduled_at).to be_within(5.seconds).of(original + 1.hour)
end

it "updates the cell via turbo stream" do
patch "/jobs/scheduled_jobs/#{execution.id}",
params: { offset: "1h" },
headers: { "Accept" => "text/vnd.turbo-stream.html, text/html" }
expect(response.content_type).to include("text/vnd.turbo-stream.html")
expect(response.body).to include("scheduled_at_#{execution.id}")
expect(response.body).to include("replace")
end
end

context "with offset=24h" do
it "postpones scheduled_at by 24 hours" do
original = execution.scheduled_at
patch "/jobs/scheduled_jobs/#{execution.id}", params: { offset: "24h" }
execution.reload
expect(execution.scheduled_at).to be_within(5.seconds).of(original + 24.hours)
end
end

context "with offset=7d" do
it "postpones scheduled_at by 7 days" do
original = execution.scheduled_at
patch "/jobs/scheduled_jobs/#{execution.id}", params: { offset: "7d" }
execution.reload
expect(execution.scheduled_at).to be_within(5.seconds).of(original + 7.days)
end
end

context "with an invalid offset" do
it "redirects with an alert" do
patch "/jobs/scheduled_jobs/#{execution.id}", params: { offset: "bogus" }
expect(response).to redirect_to("/jobs/list?status=scheduled")
follow_redirect!
expect(response.body).to include("Invalid offset")
end

it "does not modify scheduled_at" do
original = execution.scheduled_at
patch "/jobs/scheduled_jobs/#{execution.id}", params: { offset: "bogus" }
execution.reload
expect(execution.scheduled_at).to be_within(1.second).of(original)
end
end

context "with a missing execution" do
it "redirects with an alert" do
patch "/jobs/scheduled_jobs/0", params: { offset: "now" }
expect(response).to redirect_to("/jobs/list?status=scheduled")
follow_redirect!
expect(response.body).to include("Could not reschedule job")
end
end

context "when period param is present" do
it "preserves period in the redirect" do
patch "/jobs/scheduled_jobs/#{execution.id}", params: { offset: "now", period: "24h" }
expect(response).to redirect_to("/jobs/list?period=24h&status=scheduled")
end
end
end
end