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 @audit_events.empty? %>
+
No audit events recorded.
+ <% else %>
+
+
+
+ | Time |
+ Action |
+ Actor |
+ Job Class |
+ Queue |
+ Count |
+
+
+
+ <% @audit_events.each do |event| %>
+
+ | <%= 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 %>
+
+
+ <% 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