diff --git a/CHANGELOG.md b/CHANGELOG.md index 72c4ac4..d7f98b7 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 + +- Admin audit log — every discard, retry, pause, and resume action is recorded to a `solid_queue_web_audit_events` table; viewable at `/jobs/audit` with action/actor/queue filters and CSV export; identity captured via the optional `SolidQueueWeb.current_actor` config block; table created via `rails generate solid_queue_web:install:migrations` + ## [1.4.0] - 2026-05-28 ### Added diff --git a/README.md b/README.md index 4316116..df0e8b6 100644 --- a/README.md +++ b/README.md @@ -54,6 +54,7 @@ SolidQueueWeb surfaces all of this in a browser UI available at any route you ch - **CSV export** — "Export CSV" button on the jobs, failed jobs, and history pages downloads all records matching the current filters; columns are tailored per view - **Slow job detection** — when `slow_job_threshold` is configured, claimed jobs running longer than the threshold are flagged with an orange row, a "slow" badge, and a "Running For" duration column on the Running tab; a "Slow Jobs" warning card appears on the dashboard with a link to the Running tab - **Job wait time** — the Running tab shows a "Wait Time" column with how long each job waited in the queue from enqueue to pickup; also exported as `wait_time_seconds` in the claimed-status CSV +- **Admin audit log** — every discard, retry, queue pause, and resume is recorded to a `solid_queue_web_audit_events` table and viewable at `/jobs/audit` with action/actor/queue filters and CSV export; actor identity captured via the optional `current_actor` config block; requires running the install generator to create the table - **Webhook alerts** — set `alert_webhook_url` and `alert_failure_threshold` to receive a POST request whenever the failed job count meets or exceeds the threshold; set `alert_queue_thresholds` for per-queue depth alerts; set `alert_slow_job_count_threshold` (requires `slow_job_threshold`) for slow-job count alerts; set `alert_stale_process_threshold` for stale-worker alerts; all fire asynchronously with a configurable cooldown (default 1 h) to prevent repeated alerts - **Performance analytics** — per-job-class statistics at `/jobs/performance` showing run count, average, p50, p95, p99, standard deviation, min, and max duration; sorted by p95 descending so the slowest classes surface first; high std dev surfaces inconsistent jobs worth investigating; period filter scopes to 1h / 24h / 7d or all time; each class name links to the filtered History view - **Failed job trend chart** — a "Failures — Last 12 Hours" bar chart on the dashboard shows failures per hour over the last 12 hours; bars are red, making failure spikes visible before clicking into the failed jobs list @@ -111,6 +112,7 @@ SolidQueueWeb.configure do |config| config.alert_slow_job_count_threshold = 5 # fire when slow job count >= this (default: nil = disabled) config.alert_stale_process_threshold = 1 # fire when stale process count >= this (default: nil = disabled) config.alert_webhook_cooldown = 1800 # seconds between repeated alerts per alert type (default: 3600) + config.current_actor = -> { current_user&.email } # identity for audit log (default: nil) config.connects_to = { reading: :reading, writing: :writing } # read replica (default: nil) config.time_zone = "America/New_York" # display timezone for all timestamps (default: nil = UTC) end @@ -236,6 +238,46 @@ The same `alert_webhook_url` endpoint(s) receive the payload with a distinct eve The alert fires on every dashboard page load while the condition persists, subject to the cooldown window. +## Admin audit log + +Every discard, retry, queue pause, and resume action is recorded to a `solid_queue_web_audit_events` table and viewable at `/jobs/audit`. + +### Installation + +The audit log requires an opt-in migration. Run the install generator to copy it to your application: + +```bash +rails generate solid_queue_web:install:migrations +rails db:migrate +``` + +### Identity + +Set `SolidQueueWeb.current_actor` to a block that returns the current user's identity as a string. The block is evaluated in controller context, so you have access to helpers like `current_user`: + +```ruby +SolidQueueWeb.configure do |config| + config.current_actor = -> { current_user&.email } +end +``` + +If not configured, the actor column is left `nil`. + +### Audited actions + +| Action | Trigger | +|---|---| +| `job_discarded` | Single job discarded from the jobs list | +| `jobs_discarded` | Bulk or selection discard from the jobs list | +| `failed_job_retried` | Single failed job retried | +| `failed_jobs_retried` | Bulk or selection retry of failed jobs | +| `failed_job_discarded` | Single failed job discarded | +| `failed_jobs_discarded` | Bulk or selection discard of failed jobs | +| `queue_paused` | Queue paused | +| `queue_resumed` | Queue resumed | + +The audit log page at `/jobs/audit` supports filtering by action, actor, and queue name. All records can be exported as CSV. + ## Metrics endpoint `GET /jobs/metrics.json` returns a machine-readable JSON document suitable for Prometheus scraping, uptime monitors, or external dashboards. No configuration is required — the endpoint is available as soon as the engine is mounted. diff --git a/ROADMAP.md b/ROADMAP.md index 00ec0d9..d33778d 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -15,7 +15,7 @@ Pull requests for any of these are welcome. See [Contributing](README.md#contrib | Feature | Notes | |---|---| -| **Admin audit log** | Record who retried, discarded, or paused what and when. Needs a `solid_queue_web_audit_events` table via an engine-provided migration (`rails solid_queue_web:install:migrations`). Identity comes from the `authenticate` block. CSV export included. | +| ~~**Admin audit log**~~ | ✅ Shipped in v1.5 — `solid_queue_web_audit_events` table via `rails generate solid_queue_web:install:migrations`; `/jobs/audit` page with action/actor/queue filters and CSV export; identity from the `current_actor` config block. | --- diff --git a/app/controllers/solid_queue_web/application_controller.rb b/app/controllers/solid_queue_web/application_controller.rb index 8352160..54b94a5 100644 --- a/app/controllers/solid_queue_web/application_controller.rb +++ b/app/controllers/solid_queue_web/application_controller.rb @@ -37,5 +37,25 @@ def authenticate! def request_basic_auth request_http_basic_authentication("Solid Queue Dashboard") end + + def record_audit(action, job_class: nil, queue_name: nil, item_count: 1) + AuditEvent.create!( + action: action, + actor: resolve_current_actor, + job_class: job_class, + queue_name: queue_name, + item_count: item_count + ) + rescue => e + Rails.logger.error("[SolidQueueWeb] Audit log failed: #{e.message}") + end + + def resolve_current_actor + block = SolidQueueWeb.current_actor + instance_exec(&block) if block + rescue => e + Rails.logger.error("[SolidQueueWeb] current_actor block failed: #{e.message}") + nil + end end end diff --git a/app/controllers/solid_queue_web/audit_controller.rb b/app/controllers/solid_queue_web/audit_controller.rb new file mode 100644 index 0000000..40c1c14 --- /dev/null +++ b/app/controllers/solid_queue_web/audit_controller.rb @@ -0,0 +1,43 @@ +module SolidQueueWeb + class AuditController < ApplicationController + before_action :set_filters + + def index + scope = audit_scope + respond_to do |format| + format.html { @pagy, @audit_events = pagy(scope) } + format.csv do + send_data audit_csv(scope), + filename: "audit-log-#{Date.today}.csv", + type: "text/csv", disposition: "attachment" + end + end + end + + private + + def set_filters + @action_filter = params[:action_filter].presence_in(AuditEvent::ACTIONS) + @actor_filter = params[:actor].presence + @queue_filter = params[:queue].presence + end + + def audit_scope + scope = AuditEvent.recent + scope = scope.where(action: @action_filter) if @action_filter + scope = scope.where(actor: @actor_filter) if @actor_filter + scope = scope.where(queue_name: @queue_filter) if @queue_filter + scope + end + + def audit_csv(scope) + CSV.generate(headers: true) do |csv| + csv << %w[id action actor job_class queue_name item_count created_at] + scope.each do |event| + csv << [event.id, event.action, event.actor, event.job_class, + event.queue_name, event.item_count, event.created_at.iso8601] + end + end + end + end +end diff --git a/app/controllers/solid_queue_web/failed_jobs/selections_controller.rb b/app/controllers/solid_queue_web/failed_jobs/selections_controller.rb index 9f237f1..ad96599 100644 --- a/app/controllers/solid_queue_web/failed_jobs/selections_controller.rb +++ b/app/controllers/solid_queue_web/failed_jobs/selections_controller.rb @@ -6,6 +6,7 @@ def create executions = SolidQueue::FailedExecution.where(id: ids) jobs = executions.includes(:job).map(&:job) SolidQueue::FailedExecution.retry_all(jobs) + record_audit("failed_jobs_retried", item_count: jobs.size) redirect_to failed_jobs_path, notice: "#{jobs.size} #{"job".pluralize(jobs.size)} queued for retry." rescue => e @@ -17,6 +18,7 @@ def destroy executions = SolidQueue::FailedExecution.where(id: ids) jobs = executions.includes(:job).map(&:job) SolidQueue::FailedExecution.discard_all_from_jobs(jobs) + record_audit("failed_jobs_discarded", item_count: jobs.size) redirect_to failed_jobs_path, notice: "#{jobs.size} #{"job".pluralize(jobs.size)} discarded." rescue => e diff --git a/app/controllers/solid_queue_web/failed_jobs_controller.rb b/app/controllers/solid_queue_web/failed_jobs_controller.rb index 656fe3d..d867ce7 100644 --- a/app/controllers/solid_queue_web/failed_jobs_controller.rb +++ b/app/controllers/solid_queue_web/failed_jobs_controller.rb @@ -37,7 +37,9 @@ def failed_jobs_csv def perform_discard(executions) jobs = executions.map(&:job) + action = params[:id] ? "failed_job_discarded" : "failed_jobs_discarded" SolidQueue::FailedExecution.discard_all_from_jobs(jobs) + record_audit(action, job_class: jobs.first&.class_name, queue_name: jobs.first&.queue_name, item_count: jobs.size) redirect_to failed_jobs_path(queue: @queue, q: @search, period: @period), notice: "#{jobs.size} #{"job".pluralize(jobs.size)} discarded." end diff --git a/app/controllers/solid_queue_web/jobs/selections_controller.rb b/app/controllers/solid_queue_web/jobs/selections_controller.rb index 8522d77..3b4ed78 100644 --- a/app/controllers/solid_queue_web/jobs/selections_controller.rb +++ b/app/controllers/solid_queue_web/jobs/selections_controller.rb @@ -9,6 +9,7 @@ def destroy ids = Array(params[:ids]).map(&:to_i).reject(&:zero?) jobs = model.where(id: ids).includes(:job).map(&:job) model.discard_all_from_jobs(jobs) + record_audit("jobs_discarded", item_count: jobs.size) redirect_to jobs_path(status: status, period: period), notice: "#{jobs.size} #{"job".pluralize(jobs.size)} discarded." rescue ArgumentError => e diff --git a/app/controllers/solid_queue_web/jobs_controller.rb b/app/controllers/solid_queue_web/jobs_controller.rb index 78eddd5..ac3bbf5 100644 --- a/app/controllers/solid_queue_web/jobs_controller.rb +++ b/app/controllers/solid_queue_web/jobs_controller.rb @@ -30,7 +30,9 @@ def destroy model = Job.execution_model_for!(@status) if params[:id] @execution = model.find(params[:id]) + discarded_job = @execution.job @execution.discard + record_audit("job_discarded", job_class: discarded_job&.class_name, queue_name: discarded_job&.queue_name) @remaining_count = filtered_scope(model).count respond_to do |format| format.turbo_stream @@ -39,6 +41,7 @@ def destroy else jobs = filtered_scope(model).map(&:job) model.discard_all_from_jobs(jobs) + record_audit("jobs_discarded", item_count: jobs.size) redirect_to jobs_return_path, notice: "#{jobs.size} #{"job".pluralize(jobs.size)} discarded." end rescue ArgumentError => e diff --git a/app/controllers/solid_queue_web/queues/pauses_controller.rb b/app/controllers/solid_queue_web/queues/pauses_controller.rb index 216b6b1..bc6f196 100644 --- a/app/controllers/solid_queue_web/queues/pauses_controller.rb +++ b/app/controllers/solid_queue_web/queues/pauses_controller.rb @@ -4,6 +4,7 @@ class PausesController < ApplicationController def create queue = SolidQueue::Queue.find_by_name(params[:queue_name]) queue.pause + record_audit("queue_paused", queue_name: queue.name) redirect_to queues_path, notice: "Queue \"#{queue.name}\" paused." rescue => e redirect_to queues_path, alert: "Could not pause queue: #{e.message}" @@ -12,6 +13,7 @@ def create def destroy queue = SolidQueue::Queue.find_by_name(params[:queue_name]) queue.resume + record_audit("queue_resumed", queue_name: queue.name) redirect_to queues_path, notice: "Queue \"#{queue.name}\" resumed." rescue => e redirect_to queues_path, alert: "Could not resume queue: #{e.message}" diff --git a/app/controllers/solid_queue_web/retry_failed_jobs_controller.rb b/app/controllers/solid_queue_web/retry_failed_jobs_controller.rb index d5c8eee..36a9f44 100644 --- a/app/controllers/solid_queue_web/retry_failed_jobs_controller.rb +++ b/app/controllers/solid_queue_web/retry_failed_jobs_controller.rb @@ -16,6 +16,8 @@ def create else SolidQueue::FailedExecution.retry_all(jobs) end + action = params[:id] ? "failed_job_retried" : "failed_jobs_retried" + record_audit(action, job_class: jobs.first&.class_name, queue_name: jobs.first&.queue_name, item_count: jobs.size) redirect_to failed_jobs_path(queue: @queue, q: @search, period: @period), notice: retry_notice(jobs.size) rescue ArgumentError => e diff --git a/app/models/solid_queue_web/audit_event.rb b/app/models/solid_queue_web/audit_event.rb new file mode 100644 index 0000000..0a20821 --- /dev/null +++ b/app/models/solid_queue_web/audit_event.rb @@ -0,0 +1,17 @@ +module SolidQueueWeb + class AuditEvent < ApplicationRecord + self.table_name = "solid_queue_web_audit_events" + + ACTIONS = %w[ + job_discarded jobs_discarded + failed_job_retried failed_jobs_retried + failed_job_discarded failed_jobs_discarded + queue_paused queue_resumed + ].freeze + + validates :action, presence: true, inclusion: { in: ACTIONS } + validates :item_count, numericality: { greater_than: 0 } + + scope :recent, -> { order(created_at: :desc) } + end +end diff --git a/app/views/layouts/solid_queue_web/application.html.erb b/app/views/layouts/solid_queue_web/application.html.erb index 4c1d881..412d74a 100644 --- a/app/views/layouts/solid_queue_web/application.html.erb +++ b/app/views/layouts/solid_queue_web/application.html.erb @@ -26,6 +26,7 @@
  • <%= link_to "Recurring", recurring_tasks_path, class: current_page?(recurring_tasks_path) ? "active" : "", aria: { current: current_page?(recurring_tasks_path) ? "page" : nil } %>
  • <%= link_to "Processes", processes_path, class: current_page?(processes_path) ? "active" : "", aria: { current: current_page?(processes_path) ? "page" : nil } %>
  • <%= link_to "Search", search_path, class: current_page?(search_path) ? "active" : "", aria: { current: current_page?(search_path) ? "page" : nil } %>
  • +
  • <%= link_to "Audit", audit_path, class: current_page?(audit_path) ? "active" : "", aria: { current: current_page?(audit_path) ? "page" : nil } %>
  • diff --git a/app/views/solid_queue_web/audit/index.html.erb b/app/views/solid_queue_web/audit/index.html.erb new file mode 100644 index 0000000..e89c868 --- /dev/null +++ b/app/views/solid_queue_web/audit/index.html.erb @@ -0,0 +1,78 @@ +

    Audit Log

    + +
    +
    +
    + + <% if @actor_filter.present? %> + Actor: <%= @actor_filter %> + <%= link_to "×", audit_path(action_filter: @action_filter, queue: @queue_filter), class: "sqd-btn sqd-btn--muted sqd-btn--sm" %> + <% end %> + <% if @queue_filter.present? %> + Queue: <%= @queue_filter %> + <%= link_to "×", audit_path(action_filter: @action_filter, actor: @actor_filter), class: "sqd-btn sqd-btn--muted sqd-btn--sm" %> + <% end %> + <% if @action_filter.present? || @actor_filter.present? || @queue_filter.present? %> + <%= link_to "Clear", audit_path, class: "sqd-btn sqd-btn--muted sqd-btn--sm" %> + <% end %> +
    +
    + <% if @audit_events.any? %> +
    + <%= link_to "Export CSV", audit_path(format: :csv, action_filter: @action_filter, actor: @actor_filter, queue: @queue_filter), + class: "sqd-btn sqd-btn--muted", data: { turbo: false } %> +
    + <% end %> +
    + +
    + <% if @audit_events.empty? %> +
    No audit events recorded.
    + <% else %> + + + + + + + + + + + + + <% @audit_events.each do |event| %> + + + + + + + + + <% end %> + +
    TimeActionActorJob ClassQueueCount
    <%= format_timestamp(event.created_at) %>"><%= event.action.tr("_", " ") %> + <% if event.actor.present? %> + <%= link_to event.actor, audit_path(action_filter: @action_filter, queue: @queue_filter, actor: event.actor), style: "color: inherit;" %> + <% else %> + + <% end %> + <%= event.job_class || "—" %> + <% if event.queue_name.present? %> + <%= link_to event.queue_name, audit_path(action_filter: @action_filter, actor: @actor_filter, queue: event.queue_name), style: "color: inherit;" %> + <% else %> + + <% end %> + <%= event.item_count %>
    + <% end %> +
    + +<% if @pagy.last > 1 %> + <%= @pagy.series_nav.html_safe %> +<% end %> \ No newline at end of file diff --git a/config/routes.rb b/config/routes.rb index 282e6bb..771ba3f 100644 --- a/config/routes.rb +++ b/config/routes.rb @@ -3,6 +3,7 @@ resource :blocked_jobs, only: [:destroy] get "metrics", to: "metrics#index", as: :metrics, defaults: { format: :json } + get "audit", to: "audit#index", as: :audit get "search", to: "search#index", as: :search get "history", to: "history#index", as: :history get "performance", to: "performance#index", as: :performance diff --git a/db/migrate/01_create_solid_queue_web_audit_events.rb b/db/migrate/01_create_solid_queue_web_audit_events.rb new file mode 100644 index 0000000..65de2c7 --- /dev/null +++ b/db/migrate/01_create_solid_queue_web_audit_events.rb @@ -0,0 +1,16 @@ +class CreateSolidQueueWebAuditEvents < ActiveRecord::Migration[7.1] + def change + create_table :solid_queue_web_audit_events do |t| + t.string :action, null: false + t.string :actor + t.string :job_class + t.string :queue_name + t.integer :item_count, null: false, default: 1 + t.datetime :created_at, null: false + end + + add_index :solid_queue_web_audit_events, :created_at + add_index :solid_queue_web_audit_events, :action + add_index :solid_queue_web_audit_events, :actor + end +end diff --git a/lib/generators/solid_queue_web/install/migrations_generator.rb b/lib/generators/solid_queue_web/install/migrations_generator.rb new file mode 100644 index 0000000..7e0acbd --- /dev/null +++ b/lib/generators/solid_queue_web/install/migrations_generator.rb @@ -0,0 +1,24 @@ +require "rails/generators" +require "rails/generators/active_record" + +module SolidQueueWeb + module Install + class MigrationsGenerator < Rails::Generators::Base + include Rails::Generators::Migration + + source_root File.expand_path("templates", __dir__) + desc "Copy SolidQueueWeb migrations to your application." + + def self.next_migration_number(path) + ActiveRecord::Generators::Base.next_migration_number(path) + end + + def create_migration_file + migration_template( + "create_solid_queue_web_audit_events.rb.tt", + "db/migrate/create_solid_queue_web_audit_events.rb" + ) + end + end + end +end diff --git a/lib/generators/solid_queue_web/install/templates/create_solid_queue_web_audit_events.rb.tt b/lib/generators/solid_queue_web/install/templates/create_solid_queue_web_audit_events.rb.tt new file mode 100644 index 0000000..2e14c02 --- /dev/null +++ b/lib/generators/solid_queue_web/install/templates/create_solid_queue_web_audit_events.rb.tt @@ -0,0 +1,16 @@ +class CreateSolidQueueWebAuditEvents < ActiveRecord::Migration[<%= ActiveRecord::Migration.current_version %>] + def change + create_table :solid_queue_web_audit_events do |t| + t.string :action, null: false + t.string :actor + t.string :job_class + t.string :queue_name + t.integer :item_count, null: false, default: 1 + t.datetime :created_at, null: false + end + + add_index :solid_queue_web_audit_events, :created_at + add_index :solid_queue_web_audit_events, :action + add_index :solid_queue_web_audit_events, :actor + end +end \ No newline at end of file diff --git a/lib/solid_queue_web.rb b/lib/solid_queue_web.rb index 0ea65c1..ed7cb7f 100644 --- a/lib/solid_queue_web.rb +++ b/lib/solid_queue_web.rb @@ -69,5 +69,10 @@ def authenticate(&block) @authenticate = block if block_given? @authenticate end + + def current_actor(&block) + @current_actor = block if block_given? + @current_actor + end end end diff --git a/spec/dummy/db/schema.rb b/spec/dummy/db/schema.rb index 79b9fb9..e8009c1 100644 --- a/spec/dummy/db/schema.rb +++ b/spec/dummy/db/schema.rb @@ -1,4 +1,16 @@ ActiveRecord::Schema[8.0].define(version: 1) do + create_table "solid_queue_web_audit_events", force: :cascade do |t| + t.string "action", null: false + t.string "actor" + t.string "job_class" + t.string "queue_name" + t.integer "item_count", null: false, default: 1 + t.datetime "created_at", null: false + t.index ["action"], name: "index_solid_queue_web_audit_events_on_action" + t.index ["actor"], name: "index_solid_queue_web_audit_events_on_actor" + t.index ["created_at"], name: "index_solid_queue_web_audit_events_on_created_at" + end + create_table "solid_queue_blocked_executions", force: :cascade do |t| t.bigint "job_id", null: false t.string "queue_name", null: false diff --git a/spec/dummy/db/seeds.rb b/spec/dummy/db/seeds.rb index 8fcaf24..57b527e 100644 --- a/spec/dummy/db/seeds.rb +++ b/spec/dummy/db/seeds.rb @@ -25,7 +25,9 @@ solid_queue_processes solid_queue_semaphores ].each { |t| conn.execute("DELETE FROM #{t}") } +conn.execute("DELETE FROM solid_queue_web_audit_events") conn.execute("DELETE FROM sqlite_sequence WHERE name LIKE 'solid_queue%'") rescue nil +conn.execute("DELETE FROM sqlite_sequence WHERE name = 'solid_queue_web_audit_events'") rescue nil puts "Seeding processes..." supervisor = SolidQueue::Process.create!(kind: "Supervisor", pid: 12_345, hostname: "web-1.local", name: "supervisor-web-1", last_heartbeat_at: 10.seconds.ago, metadata: { queues: queues }.to_json) @@ -222,6 +224,34 @@ recurring_tasks_data.each { |attrs| SolidQueue::RecurringTask.create!(attrs) } +puts "Seeding audit events..." +actors = ["alice@example.com", "bob@example.com", "carol@example.com", nil, nil] +audit_actions = [ + { action: "job_discarded", job_class: "CleanupJob", queue_name: "default" }, + { action: "jobs_discarded", job_class: nil, queue_name: nil, item_count: 14 }, + { action: "failed_job_retried", job_class: "ReportGeneratorJob", queue_name: "low_priority" }, + { action: "failed_jobs_retried", job_class: nil, queue_name: nil, item_count: 6 }, + { action: "failed_job_discarded", job_class: "DataSyncJob", queue_name: "critical" }, + { action: "failed_jobs_discarded", job_class: nil, queue_name: nil, item_count: 3 }, + { action: "queue_paused", job_class: nil, queue_name: "critical" }, + { action: "queue_resumed", job_class: nil, queue_name: "critical" }, + { action: "queue_paused", job_class: nil, queue_name: "mailers" }, + { action: "job_discarded", job_class: "NotificationJob", queue_name: "mailers" }, + { action: "failed_job_retried", job_class: "ExportJob", queue_name: "default" }, + { action: "queue_resumed", job_class: nil, queue_name: "mailers" } +] + +audit_actions.each_with_index do |attrs, i| + SolidQueueWeb::AuditEvent.create!( + action: attrs[:action], + actor: actors.sample, + job_class: attrs[:job_class], + queue_name: attrs[:queue_name], + item_count: attrs.fetch(:item_count, 1), + created_at: rand(1..72).hours.ago + ) +end + puts "Done! Created:" puts " #{SolidQueue::ReadyExecution.count} ready jobs" puts " #{SolidQueue::ScheduledExecution.count} scheduled jobs" @@ -231,3 +261,4 @@ puts " #{SolidQueue::Process.count} processes" puts " #{SolidQueue::Job.where.not(finished_at: nil).count} finished jobs" puts " #{SolidQueue::RecurringTask.count} recurring tasks" +puts " #{SolidQueueWeb::AuditEvent.count} audit events" diff --git a/spec/requests/solid_queue_web/audit_spec.rb b/spec/requests/solid_queue_web/audit_spec.rb new file mode 100644 index 0000000..5724492 --- /dev/null +++ b/spec/requests/solid_queue_web/audit_spec.rb @@ -0,0 +1,111 @@ +require "rails_helper" + +RSpec.describe "Audit", type: :request do + describe "GET /jobs/audit" do + it "returns HTTP success" do + get "/jobs/audit" + expect(response).to have_http_status(:ok) + end + + it "shows empty state when no events exist" do + get "/jobs/audit" + expect(response.body).to include("No audit events recorded") + end + + it "displays the Audit Log heading" do + get "/jobs/audit" + expect(response.body).to include("Audit Log") + end + + it "renders a row for each audit event" do + SolidQueueWeb::AuditEvent.create!(action: "queue_paused", queue_name: "default", created_at: 1.minute.ago) + SolidQueueWeb::AuditEvent.create!(action: "failed_job_retried", created_at: 2.minutes.ago) + + get "/jobs/audit" + expect(response.body).to include("queue paused") + expect(response.body).to include("failed job retried") + end + + it "renders actor as a filter link when present" do + SolidQueueWeb::AuditEvent.create!(action: "queue_paused", actor: "admin@example.com", created_at: 1.minute.ago) + get "/jobs/audit" + expect(response.body).to include("admin@example.com") + end + + it "filters by action" do + SolidQueueWeb::AuditEvent.create!(action: "queue_paused", actor: "pauser", created_at: 1.minute.ago) + SolidQueueWeb::AuditEvent.create!(action: "failed_job_retried", actor: "retrier", created_at: 2.minutes.ago) + + get "/jobs/audit", params: { action_filter: "queue_paused" } + expect(response.body).to include("pauser") + expect(response.body).not_to include("retrier") + end + + it "filters by actor" do + SolidQueueWeb::AuditEvent.create!(action: "queue_paused", actor: "alice", created_at: 1.minute.ago) + SolidQueueWeb::AuditEvent.create!(action: "queue_paused", actor: "bob", created_at: 2.minutes.ago) + + get "/jobs/audit", params: { actor: "alice" } + expect(response.body).to include("alice") + expect(response.body).not_to include("bob") + end + + it "filters by queue" do + SolidQueueWeb::AuditEvent.create!(action: "queue_paused", queue_name: "critical", created_at: 1.minute.ago) + SolidQueueWeb::AuditEvent.create!(action: "queue_paused", queue_name: "default-only", created_at: 2.minutes.ago) + + get "/jobs/audit", params: { queue: "critical" } + expect(response.body).to include("critical") + expect(response.body).not_to include("default-only") + end + + it "shows a Clear link when filters are active" do + get "/jobs/audit", params: { action_filter: "queue_paused" } + expect(response.body).to include("Clear") + end + + it "ignores invalid action filter values" do + SolidQueueWeb::AuditEvent.create!(action: "queue_paused", created_at: 1.minute.ago) + get "/jobs/audit", params: { action_filter: "inject_something_bad" } + expect(response).to have_http_status(:ok) + expect(response.body).to include("queue paused") + end + + context "CSV export" do + it "returns a CSV file" do + get "/jobs/audit.csv" + expect(response.headers["Content-Type"]).to include("text/csv") + end + + it "includes CSV headers" do + get "/jobs/audit.csv" + expect(response.body).to include("action") + expect(response.body).to include("actor") + expect(response.body).to include("item_count") + end + + it "includes audit event rows" do + SolidQueueWeb::AuditEvent.create!(action: "queue_paused", queue_name: "default", created_at: 1.minute.ago) + get "/jobs/audit.csv" + expect(response.body).to include("queue_paused") + expect(response.body).to include("default") + end + end + + context "authentication" do + after { SolidQueueWeb.instance_variable_set(:@authenticate, nil) } + + it "allows access when auth block returns truthy" do + SolidQueueWeb.authenticate { true } + get "/jobs/audit" + expect(response).to have_http_status(:ok) + end + + it "returns 401 when auth block returns falsy" do + SolidQueueWeb.authenticate { false } + get "/jobs/audit" + expect(response).to have_http_status(:unauthorized) + end + end + end +end diff --git a/spec/services/solid_queue_web/audit_recording_spec.rb b/spec/services/solid_queue_web/audit_recording_spec.rb new file mode 100644 index 0000000..b682668 --- /dev/null +++ b/spec/services/solid_queue_web/audit_recording_spec.rb @@ -0,0 +1,149 @@ +require "rails_helper" + +RSpec.describe "Audit recording", type: :request do + def last_audit + SolidQueueWeb::AuditEvent.order(:created_at).last + end + + describe "job discard" do + let!(:job) do + SolidQueue::Job.create!(queue_name: "default", class_name: "MyJob", + arguments: {}, active_job_id: SecureRandom.uuid) + end + let!(:execution) { job.ready_execution } + + it "records job_discarded when a single job is discarded" do + expect { + delete "/jobs/list/#{execution.id}", params: { status: "ready" } + }.to change(SolidQueueWeb::AuditEvent, :count).by(1) + + expect(last_audit.action).to eq("job_discarded") + expect(last_audit.job_class).to eq("MyJob") + expect(last_audit.queue_name).to eq("default") + expect(last_audit.item_count).to eq(1) + end + + it "records jobs_discarded for bulk discard" do + expect { + post "/jobs/list/discard_all", params: { status: "ready" } + }.to change(SolidQueueWeb::AuditEvent, :count).by(1) + + expect(last_audit.action).to eq("jobs_discarded") + end + end + + describe "failed job retry" do + let!(:job) do + SolidQueue::Job.create!(queue_name: "default", class_name: "FailingJob", + arguments: {}, active_job_id: SecureRandom.uuid) + end + let!(:execution) do + SolidQueue::FailedExecution.create!( + job: job, + error: { exception_class: "RuntimeError", message: "boom", backtrace: [] } + ) + end + + it "records failed_job_retried on single retry" do + expect { + post "/jobs/failed_jobs/#{execution.id}/retry" + }.to change(SolidQueueWeb::AuditEvent, :count).by(1) + + expect(last_audit.action).to eq("failed_job_retried") + expect(last_audit.job_class).to eq("FailingJob") + expect(last_audit.item_count).to eq(1) + end + + it "records failed_jobs_retried on retry all" do + expect { + post "/jobs/failed_jobs/retry_all" + }.to change(SolidQueueWeb::AuditEvent, :count).by(1) + + expect(last_audit.action).to eq("failed_jobs_retried") + end + end + + describe "failed job discard" do + let!(:job) do + SolidQueue::Job.create!(queue_name: "default", class_name: "FailingJob", + arguments: {}, active_job_id: SecureRandom.uuid) + end + let!(:execution) do + SolidQueue::FailedExecution.create!( + job: job, + error: { exception_class: "RuntimeError", message: "boom", backtrace: [] } + ) + end + + it "records failed_job_discarded on single discard" do + expect { + delete "/jobs/failed_jobs/#{execution.id}" + }.to change(SolidQueueWeb::AuditEvent, :count).by(1) + + expect(last_audit.action).to eq("failed_job_discarded") + end + + it "records failed_jobs_discarded on discard all" do + expect { + post "/jobs/failed_jobs/discard_all" + }.to change(SolidQueueWeb::AuditEvent, :count).by(1) + + expect(last_audit.action).to eq("failed_jobs_discarded") + end + end + + describe "queue pause / resume" do + before do + SolidQueue::Job.create!(queue_name: "default", class_name: "TestJob", + arguments: {}, active_job_id: SecureRandom.uuid) + end + + it "records queue_paused when a queue is paused" do + expect { + post "/jobs/queues/default/pause" + }.to change(SolidQueueWeb::AuditEvent, :count).by(1) + + expect(last_audit.action).to eq("queue_paused") + expect(last_audit.queue_name).to eq("default") + end + + it "records queue_resumed when a queue is resumed" do + SolidQueue::Pause.create!(queue_name: "default") + expect { + delete "/jobs/queues/default/pause" + }.to change(SolidQueueWeb::AuditEvent, :count).by(1) + + expect(last_audit.action).to eq("queue_resumed") + expect(last_audit.queue_name).to eq("default") + end + end + + describe "current_actor" do + after { SolidQueueWeb.instance_variable_set(:@current_actor, nil) } + + it "stores the actor from the current_actor block" do + SolidQueueWeb.current_actor { "admin@example.com" } + SolidQueue::Job.create!(queue_name: "default", class_name: "TestJob", + arguments: {}, active_job_id: SecureRandom.uuid).tap do |j| + j.ready_execution + end + job = SolidQueue::Job.last + exec = job.ready_execution + + delete "/jobs/list/#{exec.id}", params: { status: "ready" } + expect(last_audit.actor).to eq("admin@example.com") + end + + it "stores nil actor when current_actor is not configured" do + SolidQueue::Job.create!(queue_name: "default", class_name: "TestJob", + arguments: {}, active_job_id: SecureRandom.uuid).tap do |j| + j.ready_execution + end + job = SolidQueue::Job.last + exec = job.ready_execution + + delete "/jobs/list/#{exec.id}", params: { status: "ready" } + expect(last_audit.actor).to be_nil + end + end +end