diff --git a/CHANGELOG.md b/CHANGELOG.md index 349068c..8f584d1 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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 diff --git a/README.md b/README.md index de8efd9..034e2d4 100644 --- a/README.md +++ b/README.md @@ -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 diff --git a/app/controllers/solid_queue_web/scheduled_jobs_controller.rb b/app/controllers/solid_queue_web/scheduled_jobs_controller.rb new file mode 100644 index 0000000..b48dc5e --- /dev/null +++ b/app/controllers/solid_queue_web/scheduled_jobs_controller.rb @@ -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 diff --git a/app/views/solid_queue_web/jobs/index.html.erb b/app/views/solid_queue_web/jobs/index.html.erb index e8e1967..e9c8f43 100644 --- a/app/views/solid_queue_web/jobs/index.html.erb +++ b/app/views/solid_queue_web/jobs/index.html.erb @@ -94,11 +94,24 @@ class: "sqd-mono", style: "color: inherit;" %> <%= job.priority %> - + <%= job.scheduled_at ? job.scheduled_at.strftime("%Y-%m-%d %H:%M:%S") : "—" %> <%= job.created_at.strftime("%Y-%m-%d %H:%M:%S") %> + <% 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 }, diff --git a/app/views/solid_queue_web/scheduled_jobs/update.turbo_stream.erb b/app/views/solid_queue_web/scheduled_jobs/update.turbo_stream.erb new file mode 100644 index 0000000..bc870e6 --- /dev/null +++ b/app/views/solid_queue_web/scheduled_jobs/update.turbo_stream.erb @@ -0,0 +1,9 @@ +<% if @run_now %> + <%= turbo_stream.remove "execution_#{@execution.id}" %> +<% else %> + <%= turbo_stream.replace "scheduled_at_#{@execution.id}" do %> + + <%= @execution.scheduled_at.strftime("%Y-%m-%d %H:%M:%S") %> + + <% end %> +<% end %> \ No newline at end of file diff --git a/config/routes.rb b/config/routes.rb index a78c2a1..325b428 100644 --- a/config/routes.rb +++ b/config/routes.rb @@ -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 diff --git a/spec/requests/solid_queue_web/scheduled_jobs_spec.rb b/spec/requests/solid_queue_web/scheduled_jobs_spec.rb new file mode 100644 index 0000000..3a0960e --- /dev/null +++ b/spec/requests/solid_queue_web/scheduled_jobs_spec.rb @@ -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