diff --git a/README.md b/README.md
index 2999ca7..bc2be4a 100644
--- a/README.md
+++ b/README.md
@@ -238,7 +238,7 @@ The dashboard provides several views:
- **Overview**: Shows statistics, recent jobs, and job activity chart
- **Ready Jobs**: Jobs that are ready to be executed
- **Scheduled Jobs**: Jobs scheduled for future execution with execute and reject actions
-- **Recurring Jobs**: Jobs that run on a recurring schedule
+- **Recurring Jobs**: Jobs that run on a recurring schedule, with a Run now action that enqueues one task immediately without changing its schedule
- **Failed Jobs**: Jobs that have failed with error details and retry/discard actions
- **Queues**: Distribution of jobs across different queues with pause/resume controls
- **Workers**: Real-time monitoring of all Solid Queue processes with health status
diff --git a/app/controllers/solid_queue_monitor/recurring_jobs_controller.rb b/app/controllers/solid_queue_monitor/recurring_jobs_controller.rb
index 21aaf7c..75cf5e6 100644
--- a/app/controllers/solid_queue_monitor/recurring_jobs_controller.rb
+++ b/app/controllers/solid_queue_monitor/recurring_jobs_controller.rb
@@ -12,5 +12,11 @@ def index
@sort = sort_params
@action_path = recurring_jobs_path
end
+
+ def enqueue
+ result = SolidQueueMonitor::EnqueueRecurringTaskService.call(params[:id])
+ set_flash_message(result[:message], result[:success] ? 'success' : 'error')
+ redirect_to recurring_jobs_path
+ end
end
end
diff --git a/app/services/solid_queue_monitor/enqueue_recurring_task_service.rb b/app/services/solid_queue_monitor/enqueue_recurring_task_service.rb
new file mode 100644
index 0000000..221064b
--- /dev/null
+++ b/app/services/solid_queue_monitor/enqueue_recurring_task_service.rb
@@ -0,0 +1,28 @@
+# frozen_string_literal: true
+
+module SolidQueueMonitor
+ class EnqueueRecurringTaskService
+ class << self
+ def call(id)
+ task = SolidQueue::RecurringTask.find(id)
+ enqueued_job = task.enqueue(at: Time.current)
+
+ if enqueued?(enqueued_job)
+ { success: true, message: "Recurring task '#{task.key}' has been queued to run now" }
+ else
+ { success: false, message: "Could not enqueue recurring task '#{task.key}'. It may already be queued for this time" }
+ end
+ rescue ActiveRecord::RecordNotFound
+ { success: false, message: 'Recurring task not found' }
+ rescue StandardError => e
+ { success: false, message: "Failed to enqueue recurring task: #{e.message}" }
+ end
+
+ private
+
+ def enqueued?(enqueued_job)
+ enqueued_job.respond_to?(:successfully_enqueued?) && enqueued_job.successfully_enqueued?
+ end
+ end
+ end
+end
diff --git a/app/views/solid_queue_monitor/recurring_jobs/_row.html.erb b/app/views/solid_queue_monitor/recurring_jobs/_row.html.erb
index b1856dc..e267a9d 100644
--- a/app/views/solid_queue_monitor/recurring_jobs/_row.html.erb
+++ b/app/views/solid_queue_monitor/recurring_jobs/_row.html.erb
@@ -5,4 +5,13 @@
<%= queue_link(job.queue_name) %> |
<%= job.priority || 'Default' %> |
<%= format_datetime(job.updated_at) %> |
+
+
+ |
diff --git a/app/views/solid_queue_monitor/recurring_jobs/index.html.erb b/app/views/solid_queue_monitor/recurring_jobs/index.html.erb
index 21ffcdb..c065aed 100644
--- a/app/views/solid_queue_monitor/recurring_jobs/index.html.erb
+++ b/app/views/solid_queue_monitor/recurring_jobs/index.html.erb
@@ -11,7 +11,8 @@
{ sort_key: nil, label: 'Schedule' },
{ sort_key: :queue_name, label: 'Queue' },
{ sort_key: :priority, label: 'Priority' },
- { sort_key: nil, label: 'Last Updated' }
+ { sort_key: nil, label: 'Last Updated' },
+ { sort_key: nil, label: 'Actions' }
] %>
<%= render 'solid_queue_monitor/shared/jobs_table',
diff --git a/config/routes.rb b/config/routes.rb
index 7c2866b..1e35c6b 100644
--- a/config/routes.rb
+++ b/config/routes.rb
@@ -11,7 +11,9 @@
resources :ready_jobs, only: [:index]
resources :scheduled_jobs, only: [:index]
- resources :recurring_jobs, only: [:index]
+ resources :recurring_jobs, only: [:index] do
+ post :enqueue, on: :member
+ end
resources :failed_jobs, only: [:index]
resources :in_progress_jobs, only: [:index]
resources :queues, only: [:index]
diff --git a/spec/requests/solid_queue_monitor/recurring_jobs_spec.rb b/spec/requests/solid_queue_monitor/recurring_jobs_spec.rb
index d0a912e..3dd884b 100644
--- a/spec/requests/solid_queue_monitor/recurring_jobs_spec.rb
+++ b/spec/requests/solid_queue_monitor/recurring_jobs_spec.rb
@@ -4,13 +4,18 @@
RSpec.describe 'Recurring Jobs' do
describe 'GET /recurring_jobs' do
- let!(:recurring_task1) do
+ let(:recurring_task1) do
create(:solid_queue_recurring_task, key: 'daily_cleanup', class_name: 'DailyCleanupJob')
end
- let!(:recurring_task2) do
+ let(:recurring_task2) do
create(:solid_queue_recurring_task, key: 'hourly_metrics', class_name: 'HourlyMetricsJob')
end
+ before do
+ recurring_task1
+ recurring_task2
+ end
+
it 'returns a successful response' do
get '/recurring_jobs'
@@ -28,6 +33,7 @@
expect(response.body).to include('daily_cleanup')
expect(response.body).to include('DailyCleanupJob')
+ expect(response.body).to include('Run now')
end
context 'with no recurring tasks' do
@@ -51,4 +57,48 @@
end
end
end
+
+ describe 'POST /recurring_jobs/:id/enqueue' do
+ include ActiveSupport::Testing::TimeHelpers
+
+ let(:recurring_task) do
+ create(:solid_queue_recurring_task, key: 'daily_cleanup', class_name: 'SolidQueueMonitorEnqueueProbeJob')
+ end
+
+ context 'when the task exists' do
+ before { recurring_task }
+
+ it 'enqueues the task immediately and redirects' do
+ expect do
+ post enqueue_recurring_job_path(recurring_task)
+ end.to change(SolidQueue::Job, :count).by(1)
+ .and change(SolidQueue::ReadyExecution, :count).by(1)
+
+ expect(response).to redirect_to('/recurring_jobs')
+
+ job = SolidQueue::Job.last
+ expect(job.class_name).to eq('SolidQueueMonitorEnqueueProbeJob')
+ expect(job.queue_name).to eq(recurring_task.queue_name)
+ expect(recurring_task.reload.schedule).to eq('0 * * * *')
+ end
+
+ it 'does not enqueue a second copy for the same timestamp' do
+ travel_to Time.zone.local(2026, 10, 7, 12, 0, 0) do
+ post enqueue_recurring_job_path(recurring_task)
+
+ expect do
+ post enqueue_recurring_job_path(recurring_task)
+ end.not_to change(SolidQueue::Job, :count)
+ end
+
+ expect(response).to redirect_to('/recurring_jobs')
+ end
+ end
+
+ it 'redirects with an error when the task does not exist' do
+ post enqueue_recurring_job_path(0)
+
+ expect(response).to redirect_to('/recurring_jobs')
+ end
+ end
end
diff --git a/spec/services/solid_queue_monitor/enqueue_recurring_task_service_spec.rb b/spec/services/solid_queue_monitor/enqueue_recurring_task_service_spec.rb
new file mode 100644
index 0000000..7c736a6
--- /dev/null
+++ b/spec/services/solid_queue_monitor/enqueue_recurring_task_service_spec.rb
@@ -0,0 +1,53 @@
+# frozen_string_literal: true
+
+require 'spec_helper'
+
+RSpec.describe SolidQueueMonitor::EnqueueRecurringTaskService do
+ describe '.call' do
+ let(:recurring_task) do
+ create(:solid_queue_recurring_task,
+ key: 'hourly_metrics',
+ class_name: 'SolidQueueMonitorEnqueueProbeJob',
+ queue_name: 'metrics',
+ priority: 3)
+ end
+
+ context 'when the task exists' do
+ before { recurring_task }
+
+ it 'enqueues the recurring task into the ready queue' do
+ result = nil
+
+ expect do
+ result = described_class.call(recurring_task.id)
+ end.to change(SolidQueue::ReadyExecution, :count).by(1)
+
+ expect(result[:success]).to be(true)
+ expect(result[:message]).to include('hourly_metrics')
+
+ ready_execution = SolidQueue::ReadyExecution.last
+ expect(ready_execution.queue_name).to eq('metrics')
+ expect(ready_execution.priority).to eq(3)
+ expect(ready_execution.job.class_name).to eq('SolidQueueMonitorEnqueueProbeJob')
+ end
+
+ it 'returns an error when enqueue fails' do
+ task = SolidQueue::RecurringTask.find(recurring_task.id)
+ allow(SolidQueue::RecurringTask).to receive(:find).and_return(task)
+ allow(task).to receive(:enqueue).and_raise(StandardError, 'missing job class')
+
+ result = described_class.call(recurring_task.id)
+
+ expect(result[:success]).to be(false)
+ expect(result[:message]).to include('missing job class')
+ end
+ end
+
+ it 'returns an error when the task does not exist' do
+ result = described_class.call(0)
+
+ expect(result[:success]).to be(false)
+ expect(result[:message]).to eq('Recurring task not found')
+ end
+ end
+end
diff --git a/spec/spec_helper.rb b/spec/spec_helper.rb
index 6003b04..61eacd6 100644
--- a/spec/spec_helper.rb
+++ b/spec/spec_helper.rb
@@ -64,6 +64,16 @@
t.datetime :created_at, null: false
end
+ create_table :solid_queue_recurring_executions, force: true do |t|
+ t.integer :job_id, null: false
+ t.string :task_key, null: false
+ t.datetime :run_at, null: false
+ t.datetime :created_at, null: false
+ end
+
+ add_index :solid_queue_recurring_executions, :job_id, unique: true
+ add_index :solid_queue_recurring_executions, %i[task_key run_at], unique: true
+
create_table :solid_queue_recurring_tasks, force: true do |t|
t.string :key, null: false
t.string :schedule, null: false
diff --git a/spec/support/solid_queue_stubs.rb b/spec/support/solid_queue_stubs.rb
index 9a8d4fe..5458488 100644
--- a/spec/support/solid_queue_stubs.rb
+++ b/spec/support/solid_queue_stubs.rb
@@ -60,8 +60,31 @@ class Pause < ApplicationRecord
self.table_name = 'solid_queue_pauses'
end
+ class RecurringExecution < ApplicationRecord
+ self.table_name = 'solid_queue_recurring_executions'
+ end
+
class RecurringTask < ApplicationRecord
self.table_name = 'solid_queue_recurring_tasks'
+
+ # Mirrors SolidQueue::RecurringTask#enqueue for the test double.
+ # The real model records one execution per task and timestamp, then
+ # places the job on the ready queue immediately.
+ def enqueue(at:)
+ return false if RecurringExecution.exists?(task_key: key, run_at: at)
+
+ job = Job.create!(
+ queue_name: queue_name.presence || 'default',
+ class_name: class_name.presence || 'SolidQueue::RecurringJob',
+ arguments: arguments,
+ priority: priority || 0,
+ active_job_id: SecureRandom.uuid
+ )
+ ReadyExecution.create!(job: job, queue_name: job.queue_name, priority: job.priority)
+ RecurringExecution.create!(job_id: job.id, task_key: key, run_at: at)
+
+ Struct.new(:successfully_enqueued?).new(true)
+ end
end
class Process < ApplicationRecord