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

- Recurring task list — `GET /recurring_tasks` enumerates all `SolidQueue::RecurringTask` records with key, cron schedule, job class or command, queue, next-run time, last-run time, and static/dynamic badge; each row has a "Run Now" button that immediately enqueues the task via `RecurringTasks::RunsController`; "Recurring" link added to the queue subnav
- Job history view — `GET /history` lists all finished jobs with class name, queue, duration, and finished-at time; filterable by queue, class substring, and time period (1h / 24h / 7d); clicking a queue badge filters the history to that queue; CSV export respects active filters; "History" link added to the queue subnav
- Scheduled job management — "Run Now" and offset buttons (+1h / +24h / +7d) on each scheduled job row; Turbo Stream removes the row on run-now and updates the scheduled-at cell on offset reschedule; "Run All Now (N)" header button back-dates all matching scheduled executions; backed by `ScheduledJobsController` using standard CRUD (`update` for single, `create` for bulk via `run_all_now` collection route)

Expand Down
1 change: 1 addition & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@ A mountable Rails engine that provides a unified web dashboard for the full [Sol
- **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 history view** — paginated list of all finished jobs with class name, queue, duration, and finished-at time; filterable by queue (click a badge), class substring, and time period; CSV export respects active filters
- **Scheduled job management** — "Run Now" and offset buttons (+1h / +24h / +7d) per row update the scheduled time inline via Turbo Stream; "Run All Now (N)" in the header back-dates all matching executions at once
- **Recurring task list** — enumerates all `SolidQueue::RecurringTask` records with cron schedule, job class or command, queue, next-run and last-run times, and a static/dynamic badge; each row has a "Run Now" button that immediately enqueues the task
- **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
- **Failed job detail page** — drill into any failed job to see the full error, backtrace, and an inline JSON argument editor; submit to update arguments and retry in one action
- **Solid Cache** — entry count and total byte size at a glance
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
> _Close the remaining Solid Queue feature gaps._

### Added
- **Recurring task list** — enumerate tasks defined in `config/recurring.yml` with last-run time, next-run time, and a "run now" action per task
- **Per-queue job browser** — drill into any queue from the Queues list to see its ready jobs and discard them
- **Blocked job bulk discard** — "Discard all blocked" action on the blocked jobs view

Expand Down
7 changes: 7 additions & 0 deletions app/assets/stylesheets/solid_stack_web/_02_layout.css
Original file line number Diff line number Diff line change
Expand Up @@ -73,11 +73,18 @@
.sqw-page-header { margin-bottom: 1.25rem; }
.sqw-page-title { font-size: 20px; font-weight: 600; }

@keyframes sqw-flash-dismiss {
0%, 86% { opacity: 1; max-height: 200px; margin-bottom: 1rem; }
100% { opacity: 0; max-height: 0; margin-bottom: 0; padding: 0; }
}

.sqw-flash {
padding: 0.75rem 1rem;
border-radius: var(--radius);
margin-bottom: 1rem;
font-size: 13px;
animation: sqw-flash-dismiss 7s ease forwards;
overflow: hidden;
}
.sqw-flash--notice { background: #d1e7dd; color: #0a3622; border: 1px solid #a3cfbb; }
.sqw-flash--alert { background: #f8d7da; color: #58151c; border: 1px solid #f1aeb5; }
2 changes: 1 addition & 1 deletion app/controllers/solid_stack_web/application_controller.rb
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,7 @@ class ApplicationController < ActionController::Base

def current_section
case controller_name
when "jobs", "failed_jobs", "queues", "processes", "history", "scheduled_jobs" then :queue
when "jobs", "failed_jobs", "queues", "processes", "history", "scheduled_jobs", "recurring_tasks" then :queue
when "cache" then :cache
when "cable" then :cable
else :overview
Expand Down
18 changes: 18 additions & 0 deletions app/controllers/solid_stack_web/recurring_tasks/runs_controller.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
module SolidStackWeb
class RecurringTasks::RunsController < ApplicationController
def create
task = SolidQueue::RecurringTask.find_by!(key: params[:recurring_task_key])
result = task.enqueue(at: Time.current)

if result
redirect_to recurring_tasks_path, notice: "\"#{task.key}\" queued for immediate execution."
else
redirect_to recurring_tasks_path, alert: "Could not enqueue \"#{task.key}\" — it may have just run."
end
rescue ActiveRecord::RecordNotFound
redirect_to recurring_tasks_path, alert: "Recurring task not found."
rescue => e
redirect_to recurring_tasks_path, alert: "Could not run task: #{e.message}"
end
end
end
7 changes: 7 additions & 0 deletions app/controllers/solid_stack_web/recurring_tasks_controller.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
module SolidStackWeb
class RecurringTasksController < ApplicationController
def index
@recurring_tasks = SolidQueue::RecurringTask.includes(:recurring_executions).order(:key)
end
end
end
2 changes: 2 additions & 0 deletions app/views/layouts/solid_stack_web/application.html.erb
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,8 @@
class: "sqw-subnav__link#{" sqw-subnav__link--active" if controller_name == "failed_jobs"}" %>
<%= link_to "Queues", queues_path,
class: "sqw-subnav__link#{" sqw-subnav__link--active" if controller_name == "queues"}" %>
<%= link_to "Recurring", recurring_tasks_path,
class: "sqw-subnav__link#{" sqw-subnav__link--active" if controller_name == "recurring_tasks"}" %>
<%= link_to "History", history_path,
class: "sqw-subnav__link#{" sqw-subnav__link--active" if controller_name == "history"}" %>
<%= link_to "Processes", processes_path,
Expand Down
67 changes: 67 additions & 0 deletions app/views/solid_stack_web/recurring_tasks/index.html.erb
Original file line number Diff line number Diff line change
@@ -0,0 +1,67 @@
<div class="sqw-page-header">
<h1 class="sqw-page-title">Recurring Tasks</h1>
</div>

<% if @recurring_tasks.any? %>
<table class="sqw-table">
<thead>
<tr>
<th>Key</th>
<th>Schedule</th>
<th>Job / Command</th>
<th>Queue</th>
<th>Next Run</th>
<th>Last Run</th>
<th>Type</th>
<th></th>
</tr>
</thead>
<tbody>
<% @recurring_tasks.each do |task| %>
<tr>
<td class="sqw-monospace"><%= task.key %></td>
<td class="sqw-monospace"><%= task.schedule %></td>
<td>
<% if task.class_name.present? %>
<span class="sqw-monospace"><%= task.class_name %></span>
<% if task.arguments.present? %>
<div class="sqw-muted sqw-monospace" style="font-size: 11px;"><%= task.arguments.inspect %></div>
<% end %>
<% else %>
<span class="sqw-monospace sqw-muted"><%= task.command %></span>
<% end %>
<% if task.description.present? %>
<div class="sqw-muted" style="font-size: 12px; margin-top: 0.2rem;"><%= task.description %></div>
<% end %>
</td>
<td><span class="sqw-badge sqw-badge--queue"><%= task.queue_name.presence || "default" %></span></td>
<td class="sqw-muted sqw-monospace">
<% next_run = begin; task.next_time.strftime("%b %d %H:%M"); rescue; nil; end %>
<%= next_run || "—" %>
</td>
<td class="sqw-muted sqw-monospace">
<% last_run = task.last_enqueued_time %>
<%= last_run ? last_run.strftime("%b %d %H:%M") : "—" %>
</td>
<td>
<% if task.static? %>
<span class="sqw-badge sqw-badge--scheduled">Static</span>
<% else %>
<span class="sqw-badge sqw-badge--blocked">Dynamic</span>
<% end %>
</td>
<td class="sqw-actions">
<%= button_to "Run Now", recurring_task_run_path(task.key),
method: :post,
class: "sqw-btn sqw-btn--sm",
data: { turbo_confirm: "Run \"#{task.key}\" immediately?" } %>
</td>
</tr>
<% end %>
</tbody>
</table>
<% else %>
<div class="sqw-empty">
<p>No recurring tasks configured.</p>
</div>
<% end %>
4 changes: 4 additions & 0 deletions config/routes.rb
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,10 @@
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 :recurring_tasks, only: [:index], param: :key do
resource :run, only: [:create], controller: "recurring_tasks/runs"
end

resources :scheduled_jobs, only: [:update] do
collection do
post :run_all_now, action: :create
Expand Down
16 changes: 15 additions & 1 deletion spec/dummy/db/seeds.rb
Original file line number Diff line number Diff line change
Expand Up @@ -181,6 +181,19 @@
job.save!(validate: false)
end

# ── Solid Queue: Recurring tasks ─────────────────────────────────────────────

puts " recurring tasks..."

[
{ key: "nightly-cleanup", schedule: "0 2 * * *", command: "DailyCleanupJob.perform_later", queue_name: "default", description: "Nightly database cleanup", static: true },
{ key: "daily-user-report", schedule: "0 8 * * *", command: "UserReportJob.perform_later", queue_name: "mailers", description: "Send daily user summary", static: true },
{ key: "hourly-sync", schedule: "0 * * * *", command: "SyncInventoryJob.perform_later", queue_name: "default", description: nil, static: true },
{ key: "weekly-digest", schedule: "0 9 * * 1", command: "SendDigestJob.perform_later", queue_name: "mailers", description: "Weekly digest email", static: false },
].each do |attrs|
SolidQueue::RecurringTask.create!(attrs)
end

# ── Solid Cache ───────────────────────────────────────────────────────────────

puts " cache entries..."
Expand Down Expand Up @@ -235,6 +248,7 @@
"#{SolidQueue::BlockedExecution.count} blocked, " \
"#{SolidQueue::FailedExecution.count} failed, " \
"#{SolidQueue::Job.where.not(finished_at: nil).count} finished), " \
"#{SolidQueue::Process.count} processes"
"#{SolidQueue::Process.count} processes, " \
"#{SolidQueue::RecurringTask.count} recurring tasks"
puts " Solid Cache — #{SolidCache::Entry.count} entries"
puts " Solid Cable — #{SolidCable::Message.count} messages across #{SolidCable::Message.distinct.count(:channel)} channels"
25 changes: 25 additions & 0 deletions spec/helpers/solid_stack_web/application_helper_spec.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
require "rails_helper"

RSpec.describe SolidStackWeb::ApplicationHelper, type: :helper do
describe "#format_duration" do
it "returns seconds for values under 60" do
expect(helper.format_duration(45)).to eq("45s")
end

it "returns minutes and seconds for values between 60 and 3599" do
expect(helper.format_duration(90)).to eq("1m 30s")
end

it "returns zero seconds in the minutes branch at an exact minute boundary" do
expect(helper.format_duration(120)).to eq("2m 0s")
end

it "returns hours and minutes for values 3600 and above" do
expect(helper.format_duration(3661)).to eq("1h 1m")
end

it "returns hours and zero minutes at an exact hour boundary" do
expect(helper.format_duration(7200)).to eq("2h 0m")
end
end
end
55 changes: 55 additions & 0 deletions spec/requests/solid_stack_web/recurring_task_runs_spec.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,55 @@
require "rails_helper"

RSpec.describe "RecurringTasks::Runs", type: :request do
let(:engine_root) { "/solid_stack" }

let!(:task) do
SolidQueue::RecurringTask.create!(
key: "nightly-cleanup",
schedule: "0 2 * * *",
command: "DailyCleanupJob.perform_later"
)
end

before do
allow_any_instance_of(SolidQueue::RecurringTask).to receive(:enqueue)
.with(at: instance_of(ActiveSupport::TimeWithZone))
.and_return(double("job", job_id: SecureRandom.uuid))
end

describe "POST /recurring_tasks/:key/run" do
it "redirects to the recurring tasks page" do
post "#{engine_root}/recurring_tasks/nightly-cleanup/run"
expect(response).to redirect_to("#{engine_root}/recurring_tasks")
end

it "shows a success notice with the task key" do
post "#{engine_root}/recurring_tasks/nightly-cleanup/run"
follow_redirect!
expect(response.body).to include("nightly-cleanup")
expect(response.body).to include("queued for immediate execution")
end

it "redirects with alert for unknown task key" do
post "#{engine_root}/recurring_tasks/nonexistent/run"
expect(response).to redirect_to("#{engine_root}/recurring_tasks")
follow_redirect!
expect(response.body).to include("Recurring task not found")
end

it "shows an alert when enqueue returns false" do
allow_any_instance_of(SolidQueue::RecurringTask).to receive(:enqueue).and_return(false)
post "#{engine_root}/recurring_tasks/nightly-cleanup/run"
follow_redirect!
expect(response.body).to include("Could not enqueue")
end

it "handles unexpected errors gracefully" do
allow_any_instance_of(SolidQueue::RecurringTask).to receive(:enqueue).and_raise(RuntimeError, "boom")
post "#{engine_root}/recurring_tasks/nightly-cleanup/run"
expect(response).to redirect_to("#{engine_root}/recurring_tasks")
follow_redirect!
expect(response.body).to include("Could not run task")
end
end
end
65 changes: 65 additions & 0 deletions spec/requests/solid_stack_web/recurring_tasks_spec.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,65 @@
require "rails_helper"

RSpec.describe "RecurringTasks", type: :request do
let(:engine_root) { "/solid_stack" }

let!(:static_task) do
SolidQueue::RecurringTask.create!(
key: "nightly-cleanup",
schedule: "0 2 * * *",
command: "DailyCleanupJob.perform_later",
queue_name: "default",
description: "Nightly database cleanup",
static: true
)
end

let!(:dynamic_task) do
SolidQueue::RecurringTask.create!(
key: "weekly-report",
schedule: "0 8 * * 1",
command: "ReportJob.perform_later",
static: false
)
end

describe "GET /recurring_tasks" do
it "returns HTTP success" do
get "#{engine_root}/recurring_tasks"
expect(response).to have_http_status(:ok)
end

it "displays task keys" do
get "#{engine_root}/recurring_tasks"
expect(response.body).to include("nightly-cleanup")
expect(response.body).to include("weekly-report")
end

it "displays the schedule" do
get "#{engine_root}/recurring_tasks"
expect(response.body).to include("0 2 * * *")
end

it "displays the description" do
get "#{engine_root}/recurring_tasks"
expect(response.body).to include("Nightly database cleanup")
end

it "distinguishes static and dynamic tasks" do
get "#{engine_root}/recurring_tasks"
expect(response.body).to include("Static")
expect(response.body).to include("Dynamic")
end

it "shows a Run Now button for each task" do
get "#{engine_root}/recurring_tasks"
expect(response.body).to include("Run Now")
end

it "shows empty state when no tasks exist" do
SolidQueue::RecurringTask.delete_all
get "#{engine_root}/recurring_tasks"
expect(response.body).to include("No recurring tasks")
end
end
end