diff --git a/CHANGELOG.md b/CHANGELOG.md index df4e318..fc0aa1c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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) diff --git a/README.md b/README.md index 950789f..94c753a 100644 --- a/README.md +++ b/README.md @@ -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 diff --git a/ROADMAP.md b/ROADMAP.md index 8a537d2..f3f64e5 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -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 diff --git a/app/assets/stylesheets/solid_stack_web/_02_layout.css b/app/assets/stylesheets/solid_stack_web/_02_layout.css index b1a77ec..e206515 100644 --- a/app/assets/stylesheets/solid_stack_web/_02_layout.css +++ b/app/assets/stylesheets/solid_stack_web/_02_layout.css @@ -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; } diff --git a/app/controllers/solid_stack_web/application_controller.rb b/app/controllers/solid_stack_web/application_controller.rb index 57da95a..5110276 100644 --- a/app/controllers/solid_stack_web/application_controller.rb +++ b/app/controllers/solid_stack_web/application_controller.rb @@ -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 diff --git a/app/controllers/solid_stack_web/recurring_tasks/runs_controller.rb b/app/controllers/solid_stack_web/recurring_tasks/runs_controller.rb new file mode 100644 index 0000000..a1f7d7a --- /dev/null +++ b/app/controllers/solid_stack_web/recurring_tasks/runs_controller.rb @@ -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 diff --git a/app/controllers/solid_stack_web/recurring_tasks_controller.rb b/app/controllers/solid_stack_web/recurring_tasks_controller.rb new file mode 100644 index 0000000..141ac8f --- /dev/null +++ b/app/controllers/solid_stack_web/recurring_tasks_controller.rb @@ -0,0 +1,7 @@ +module SolidStackWeb + class RecurringTasksController < ApplicationController + def index + @recurring_tasks = SolidQueue::RecurringTask.includes(:recurring_executions).order(:key) + end + end +end diff --git a/app/views/layouts/solid_stack_web/application.html.erb b/app/views/layouts/solid_stack_web/application.html.erb index 40dbdb0..672c61a 100644 --- a/app/views/layouts/solid_stack_web/application.html.erb +++ b/app/views/layouts/solid_stack_web/application.html.erb @@ -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, diff --git a/app/views/solid_stack_web/recurring_tasks/index.html.erb b/app/views/solid_stack_web/recurring_tasks/index.html.erb new file mode 100644 index 0000000..cce359f --- /dev/null +++ b/app/views/solid_stack_web/recurring_tasks/index.html.erb @@ -0,0 +1,67 @@ +
+

Recurring Tasks

+
+ +<% if @recurring_tasks.any? %> + + + + + + + + + + + + + + + <% @recurring_tasks.each do |task| %> + + + + + + + + + + + <% end %> + +
KeyScheduleJob / CommandQueueNext RunLast RunType
<%= task.key %><%= task.schedule %> + <% if task.class_name.present? %> + <%= task.class_name %> + <% if task.arguments.present? %> +
<%= task.arguments.inspect %>
+ <% end %> + <% else %> + <%= task.command %> + <% end %> + <% if task.description.present? %> +
<%= task.description %>
+ <% end %> +
<%= task.queue_name.presence || "default" %> + <% next_run = begin; task.next_time.strftime("%b %d %H:%M"); rescue; nil; end %> + <%= next_run || "—" %> + + <% last_run = task.last_enqueued_time %> + <%= last_run ? last_run.strftime("%b %d %H:%M") : "—" %> + + <% if task.static? %> + Static + <% else %> + Dynamic + <% end %> + + <%= 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?" } %> +
+<% else %> +
+

No recurring tasks configured.

+
+<% end %> \ No newline at end of file diff --git a/config/routes.rb b/config/routes.rb index 83c6ada..94d3054 100644 --- a/config/routes.rb +++ b/config/routes.rb @@ -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 diff --git a/spec/dummy/db/seeds.rb b/spec/dummy/db/seeds.rb index 5f7d280..84d3b57 100644 --- a/spec/dummy/db/seeds.rb +++ b/spec/dummy/db/seeds.rb @@ -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..." @@ -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" \ No newline at end of file diff --git a/spec/helpers/solid_stack_web/application_helper_spec.rb b/spec/helpers/solid_stack_web/application_helper_spec.rb new file mode 100644 index 0000000..cfcf60e --- /dev/null +++ b/spec/helpers/solid_stack_web/application_helper_spec.rb @@ -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 diff --git a/spec/requests/solid_stack_web/recurring_task_runs_spec.rb b/spec/requests/solid_stack_web/recurring_task_runs_spec.rb new file mode 100644 index 0000000..5d5befa --- /dev/null +++ b/spec/requests/solid_stack_web/recurring_task_runs_spec.rb @@ -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 diff --git a/spec/requests/solid_stack_web/recurring_tasks_spec.rb b/spec/requests/solid_stack_web/recurring_tasks_spec.rb new file mode 100644 index 0000000..a98afd0 --- /dev/null +++ b/spec/requests/solid_stack_web/recurring_tasks_spec.rb @@ -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