diff --git a/.gitignore b/.gitignore
index a11dbc4..ac02541 100644
--- a/.gitignore
+++ b/.gitignore
@@ -6,7 +6,7 @@
/tmp/
/spec/dummy/db/*.sqlite3
/spec/dummy/db/*.sqlite3-*
-/spec/dummy/log/*.log
+/spec/dummy/log/
/spec/dummy/storage/
/spec/dummy/tmp/
diff --git a/CHANGELOG.md b/CHANGELOG.md
index e61581f..b64658e 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 — records who retried, discarded, or paused what and when; requires opt-in migration (`rails solid_stack_web:install:migrations`); actor identity via new `config.current_actor` block; filterable by action, actor, and queue; CSV export included; accessible at `/audit` under the Queue subnav
+
## [1.3.0] - 2026-05-28
### Added
diff --git a/ROADMAP.md b/ROADMAP.md
index 29f5a45..e5b4b35 100644
--- a/ROADMAP.md
+++ b/ROADMAP.md
@@ -5,14 +5,6 @@
---
-## v1.4 — Audit & Compliance
-
-> _Requires an opt-in migration — kept separate from the no-migration-required releases above._
-
-- **Admin audit log** — record who retried, discarded, or paused what and when; needs a `solid_stack_web_audit_events` table via an engine-provided migration (`rails solid_stack_web:install:migrations`); identity comes from the `authenticate` block; CSV export included
-
----
-
## v2.0 — Extensibility
> _Breaking changes or large architectural additions._
diff --git a/app/controllers/solid_stack_web/application_controller.rb b/app/controllers/solid_stack_web/application_controller.rb
index 6b9810b..1969b35 100644
--- a/app/controllers/solid_stack_web/application_controller.rb
+++ b/app/controllers/solid_stack_web/application_controller.rb
@@ -22,7 +22,7 @@ class ApplicationController < ActionController::Base
def current_section
case controller_name
- when "jobs", "failed_jobs", "queues", "processes", "history", "scheduled_jobs", "recurring_tasks" then :queue
+ when "jobs", "failed_jobs", "queues", "processes", "history", "scheduled_jobs", "recurring_tasks", "audit" then :queue
when "cache", "cache_entries" then :cache
when "cable" then :cable
else :overview
@@ -51,6 +51,28 @@ def request_basic_auth
request_http_basic_authentication("Solid Stack 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("[SolidStackWeb] Audit log failed: #{e.message}")
+ end
+
+ def resolve_current_actor
+ block = SolidStackWeb.current_actor
+ return nil unless block
+
+ instance_exec(&block)
+ rescue => e
+ Rails.logger.error("[SolidStackWeb] current_actor resolution failed: #{e.message}")
+ nil
+ end
+
def render_not_found
render "solid_stack_web/errors/not_found", status: :not_found
end
diff --git a/app/controllers/solid_stack_web/audit_controller.rb b/app/controllers/solid_stack_web/audit_controller.rb
new file mode 100644
index 0000000..29f659d
--- /dev/null
+++ b/app/controllers/solid_stack_web/audit_controller.rb
@@ -0,0 +1,49 @@
+module SolidStackWeb
+ class AuditController < ApplicationController
+ def index
+ unless AuditEvent.table_exists?
+ redirect_to root_path,
+ alert: "Audit log requires running `rails solid_stack_web:install:migrations && rails db:migrate`."
+ return
+ end
+
+ set_filters
+ scope = audit_scope
+
+ respond_to do |format|
+ format.html { @pagy, @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[:audit_action].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_stack_web/failed_jobs/selections_controller.rb b/app/controllers/solid_stack_web/failed_jobs/selections_controller.rb
index 15b86fd..f75d340 100644
--- a/app/controllers/solid_stack_web/failed_jobs/selections_controller.rb
+++ b/app/controllers/solid_stack_web/failed_jobs/selections_controller.rb
@@ -6,6 +6,7 @@ class SelectionsController < ApplicationController
def create
count = @ids.size
SolidQueue::FailedExecution.where(id: @ids).each(&:retry)
+ record_audit("failed_jobs_retried", item_count: count)
redirect_to failed_jobs_path, notice: "#{count} #{count == 1 ? "job" : "jobs"} retried."
rescue => e
redirect_to failed_jobs_path, alert: "Could not retry jobs: #{e.message}"
@@ -14,6 +15,7 @@ def create
def destroy
job_ids = SolidQueue::FailedExecution.where(id: @ids).pluck(:job_id)
count = SolidQueue::Job.where(id: job_ids).destroy_all.size
+ record_audit("failed_jobs_discarded", item_count: count)
redirect_to failed_jobs_path, notice: "#{count} #{count == 1 ? "job" : "jobs"} discarded."
rescue => e
redirect_to failed_jobs_path, alert: "Could not discard jobs: #{e.message}"
diff --git a/app/controllers/solid_stack_web/failed_jobs_controller.rb b/app/controllers/solid_stack_web/failed_jobs_controller.rb
index c6f7bae..e6fdf65 100644
--- a/app/controllers/solid_stack_web/failed_jobs_controller.rb
+++ b/app/controllers/solid_stack_web/failed_jobs_controller.rb
@@ -28,7 +28,10 @@ def show
def destroy
@execution = ::SolidQueue::FailedExecution.find(params[:id])
+ job_class = @execution.job.class_name
+ queue_name = @execution.job.queue_name
@execution.job.destroy!
+ record_audit("failed_job_discarded", job_class: job_class, queue_name: queue_name)
@executions_remain = ::SolidQueue::FailedExecution.exists?
@notice = "Job discarded."
@@ -40,6 +43,7 @@ def destroy
def retry
execution = ::SolidQueue::FailedExecution.find(params[:id])
+ record_audit("failed_job_retried", job_class: execution.job.class_name, queue_name: execution.job.queue_name)
execution.retry
redirect_to failed_jobs_path, notice: "Job retried."
end
diff --git a/app/controllers/solid_stack_web/jobs/selections_controller.rb b/app/controllers/solid_stack_web/jobs/selections_controller.rb
index ea2e3af..18a8eac 100644
--- a/app/controllers/solid_stack_web/jobs/selections_controller.rb
+++ b/app/controllers/solid_stack_web/jobs/selections_controller.rb
@@ -8,6 +8,7 @@ def destroy
ids = Array(params[:job_ids]).map(&:to_i).reject(&:zero?)
job_ids = Job::EXECUTION_MODELS[status].where(id: ids).pluck(:job_id)
count = SolidQueue::Job.where(id: job_ids).destroy_all.size
+ record_audit("jobs_discarded", item_count: count)
redirect_to jobs_path(
status: status,
diff --git a/app/controllers/solid_stack_web/jobs_controller.rb b/app/controllers/solid_stack_web/jobs_controller.rb
index b250ff1..279937a 100644
--- a/app/controllers/solid_stack_web/jobs_controller.rb
+++ b/app/controllers/solid_stack_web/jobs_controller.rb
@@ -31,7 +31,10 @@ def show
def destroy
if params[:id]
@execution = Job::EXECUTION_MODELS[@status].find(params[:id])
+ job_class = @execution.job.class_name
+ queue_name = @execution.job.queue_name
@execution.job.destroy!
+ record_audit("job_discarded", job_class: job_class, queue_name: queue_name)
@executions_remain = Job::EXECUTION_MODELS[@status].exists?
@notice = "Job discarded."
@@ -42,6 +45,7 @@ def destroy
else
job_ids = filtered_scope.pluck(:job_id)
count = SolidQueue::Job.where(id: job_ids).destroy_all.size
+ record_audit("jobs_discarded", item_count: count)
redirect_to jobs_path(status: @status, q: @search, queue: @queue, period: @period, priority: @priority, sort: @sort, direction: @direction),
notice: "#{count} #{count == 1 ? "job" : "jobs"} discarded."
end
diff --git a/app/controllers/solid_stack_web/queues/pauses_controller.rb b/app/controllers/solid_stack_web/queues/pauses_controller.rb
index 67d3028..ec424e8 100644
--- a/app/controllers/solid_stack_web/queues/pauses_controller.rb
+++ b/app/controllers/solid_stack_web/queues/pauses_controller.rb
@@ -2,11 +2,13 @@ module SolidStackWeb
class Queues::PausesController < ApplicationController
def create
::SolidQueue::Pause.find_or_create_by!(queue_name: params[:queue_id])
+ record_audit("queue_paused", queue_name: params[:queue_id])
redirect_back_or_to queues_path
end
def destroy
::SolidQueue::Pause.find_by(queue_name: params[:queue_id])&.destroy
+ record_audit("queue_resumed", queue_name: params[:queue_id])
redirect_back_or_to queues_path
end
end
diff --git a/app/helpers/solid_stack_web/application_helper.rb b/app/helpers/solid_stack_web/application_helper.rb
index 13d9657..ad4def6 100644
--- a/app/helpers/solid_stack_web/application_helper.rb
+++ b/app/helpers/solid_stack_web/application_helper.rb
@@ -87,6 +87,15 @@ def queue_depth_sparkline_svg(sparkline)
end
end
+ def audit_action_badge_class(action)
+ case action
+ when /discard/ then "sqw-badge--failed"
+ when /retry/ then "sqw-badge--scheduled"
+ when "queue_paused" then "sqw-badge--blocked"
+ when "queue_resumed" then "sqw-badge--ready"
+ end
+ end
+
def sort_header_th(label, col, url_proc, current_sort:, current_dir:)
is_active = current_sort == col
next_dir = (is_active && current_dir == "desc") ? "asc" : "desc"
diff --git a/app/models/solid_stack_web/audit_event.rb b/app/models/solid_stack_web/audit_event.rb
new file mode 100644
index 0000000..4691033
--- /dev/null
+++ b/app/models/solid_stack_web/audit_event.rb
@@ -0,0 +1,17 @@
+module SolidStackWeb
+ class AuditEvent < ActiveRecord::Base
+ self.table_name = "solid_stack_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_stack_web/application.html.erb b/app/views/layouts/solid_stack_web/application.html.erb
index 6b2a469..cbd998d 100644
--- a/app/views/layouts/solid_stack_web/application.html.erb
+++ b/app/views/layouts/solid_stack_web/application.html.erb
@@ -65,6 +65,8 @@
class: "sqw-subnav__link#{" sqw-subnav__link--active" if controller_name == "history"}" %>
<%= link_to "Processes", processes_path,
class: "sqw-subnav__link#{" sqw-subnav__link--active" if controller_name == "processes"}" %>
+ <%= link_to "Audit", audit_path,
+ class: "sqw-subnav__link#{" sqw-subnav__link--active" if controller_name == "audit"}" %>
<% end %>
diff --git a/app/views/solid_stack_web/audit/index.html.erb b/app/views/solid_stack_web/audit/index.html.erb
new file mode 100644
index 0000000..513222a
--- /dev/null
+++ b/app/views/solid_stack_web/audit/index.html.erb
@@ -0,0 +1,86 @@
+
+
+
+
+<% if @events.any? %>
+
+
+
+ | Time |
+ Action |
+ Actor |
+ Job Class |
+ Queue |
+ Count |
+
+
+
+ <% @events.each do |event| %>
+
+ | <%= local_time(event.created_at, format: :long) %> |
+
+
+ <%= event.action.tr("_", " ") %>
+
+ |
+
+ <% if event.actor.present? %>
+ <%= link_to event.actor, audit_path(audit_action: @action_filter, actor: event.actor, queue: @queue_filter) %>
+ <% else %>
+ —
+ <% end %>
+ |
+ <%= event.job_class.presence || content_tag(:span, "—", class: "sqw-muted") %> |
+
+ <% if event.queue_name.present? %>
+
+ <%= link_to event.queue_name, audit_path(audit_action: @action_filter, actor: @actor_filter, queue: event.queue_name) %>
+
+ <% else %>
+ —
+ <% end %>
+ |
+ <%= event.item_count %> |
+
+ <% end %>
+
+
+ <%== @pagy.series_nav(aria_label: "Pagination") if @pagy.pages > 1 %>
+<% else %>
+
+
No audit events recorded yet.
+
+<% end %>
\ No newline at end of file
diff --git a/config/importmap.rb b/config/importmap.rb
index 4ae8f87..322d29b 100644
--- a/config/importmap.rb
+++ b/config/importmap.rb
@@ -1,6 +1,7 @@
pin "@hotwired/turbo", to: "https://cdn.jsdelivr.net/npm/@hotwired/turbo@8.0.23/dist/turbo.es2017-esm.js"
pin "@hotwired/stimulus", to: "https://cdn.jsdelivr.net/npm/@hotwired/stimulus@3.2.2/dist/stimulus.js"
pin "solid_stack_web", to: "solid_stack_web/application.js"
+pin "solid_stack_web/filter_persist_controller", to: "solid_stack_web/filter_persist_controller.js"
pin "solid_stack_web/refresh_controller", to: "solid_stack_web/refresh_controller.js"
pin "solid_stack_web/search_controller", to: "solid_stack_web/search_controller.js"
pin "solid_stack_web/selection_controller", to: "solid_stack_web/selection_controller.js"
diff --git a/config/routes.rb b/config/routes.rb
index 7d832b3..d06b737 100644
--- a/config/routes.rb
+++ b/config/routes.rb
@@ -36,6 +36,7 @@
get "metrics", to: "metrics#index", as: :metrics
get "stats", to: "stats#index", as: :stats
get "history", to: "history#index", as: :history
+ get "audit", to: "audit#index", as: :audit
get "cache", to: "cache#index", as: :cache
resources :cache_entries, only: [:index, :show, :destroy], path: "cache/entries"
resource :cache_flush, only: [:destroy], path: "cache/flush", controller: "cache/flushes"
diff --git a/lib/generators/solid_stack_web/install/migrations_generator.rb b/lib/generators/solid_stack_web/install/migrations_generator.rb
new file mode 100644
index 0000000..3b1196d
--- /dev/null
+++ b/lib/generators/solid_stack_web/install/migrations_generator.rb
@@ -0,0 +1,24 @@
+require "rails/generators/base"
+require "rails/generators/migration"
+
+module SolidStackWeb
+ module Generators
+ class MigrationsGenerator < Rails::Generators::Base
+ include Rails::Generators::Migration
+
+ source_root File.expand_path("templates", __dir__)
+
+ desc "Generates the solid_stack_web_audit_events migration"
+
+ def self.next_migration_number(dirname)
+ next_migration_number = current_migration_number(dirname) + 1
+ ActiveRecord::Migration.next_migration_number(next_migration_number)
+ end
+
+ def create_migration_file
+ migration_template "create_solid_stack_web_audit_events.rb.tt",
+ "db/migrate/create_solid_stack_web_audit_events.rb"
+ end
+ end
+ end
+end
diff --git a/lib/generators/solid_stack_web/install/templates/create_solid_stack_web_audit_events.rb.tt b/lib/generators/solid_stack_web/install/templates/create_solid_stack_web_audit_events.rb.tt
new file mode 100644
index 0000000..296a774
--- /dev/null
+++ b/lib/generators/solid_stack_web/install/templates/create_solid_stack_web_audit_events.rb.tt
@@ -0,0 +1,16 @@
+class CreateSolidStackWebAuditEvents < ActiveRecord::Migration[<%= ActiveRecord::Migration.current_version %>]
+ def change
+ create_table :solid_stack_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_stack_web_audit_events, :created_at
+ add_index :solid_stack_web_audit_events, :action
+ add_index :solid_stack_web_audit_events, :actor
+ end
+end
\ No newline at end of file
diff --git a/lib/generators/solid_stack_web/install/templates/initializer.rb b/lib/generators/solid_stack_web/install/templates/initializer.rb
index ef8e6cf..17d5551 100644
--- a/lib/generators/solid_stack_web/install/templates/initializer.rb
+++ b/lib/generators/solid_stack_web/install/templates/initializer.rb
@@ -47,6 +47,13 @@
# "critical" => 50,
# "default" => 500
# }
+ # Audit log actor — block runs in controller context, return a string identifying the current user.
+ # Requires running `rails solid_stack_web:install:migrations && rails db:migrate` first.
+ #
+ # config.current_actor do
+ # current_user&.email
+ # end
+
# config.alert_slow_job_count_threshold = 3 # fire when N+ claimed jobs exceed slow_job_threshold duration
# config.alert_stale_process_threshold = 1 # fire when N+ workers have a stale heartbeat (>5 min old)
# config.alert_webhook_cooldown = 3600 # seconds between repeat alerts
diff --git a/lib/solid_stack_web.rb b/lib/solid_stack_web.rb
index 2f9b813..fb8cbba 100644
--- a/lib/solid_stack_web.rb
+++ b/lib/solid_stack_web.rb
@@ -84,6 +84,11 @@ def authenticate(&block)
@authenticate
end
+ def current_actor(&block)
+ @current_actor = block if block_given?
+ @current_actor
+ end
+
def deprecator
@deprecator ||= ActiveSupport::Deprecation.new("1.0", "SolidStackWeb")
end
diff --git a/spec/dummy/db/schema.rb b/spec/dummy/db/schema.rb
index 5249a19..37e104c 100644
--- a/spec/dummy/db/schema.rb
+++ b/spec/dummy/db/schema.rb
@@ -1,4 +1,17 @@
ActiveRecord::Schema[8.1].define(version: 1) do
+ # SolidStackWeb audit table
+ create_table "solid_stack_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 ["created_at"], name: "index_solid_stack_web_audit_events_on_created_at"
+ t.index ["action"], name: "index_solid_stack_web_audit_events_on_action"
+ t.index ["actor"], name: "index_solid_stack_web_audit_events_on_actor"
+ end
+
# Solid Queue tables
create_table "solid_queue_blocked_executions", force: :cascade do |t|
t.bigint "job_id", null: false
diff --git a/spec/dummy/db/seeds.rb b/spec/dummy/db/seeds.rb
index 84d3b57..5faac4b 100644
--- a/spec/dummy/db/seeds.rb
+++ b/spec/dummy/db/seeds.rb
@@ -237,6 +237,29 @@
messages.each { |payload| SolidCable::Message.broadcast(channel, payload.to_json) }
end
+# ── Audit log ────────────────────────────────────────────────────────────────
+
+puts " audit events..."
+
+ACTORS = %w[alice@example.com bob@example.com carol@example.com].freeze
+
+[
+ { action: "job_discarded", actor: ACTORS.sample, job_class: "WebhookDeliveryJob", queue_name: "default", created_at: 2.minutes.ago },
+ { action: "job_discarded", actor: ACTORS.sample, job_class: "DataImportJob", queue_name: "low_priority", created_at: 15.minutes.ago },
+ { action: "jobs_discarded", actor: ACTORS.sample, job_class: nil, queue_name: nil, item_count: 7, created_at: 1.hour.ago },
+ { action: "failed_job_retried", actor: ACTORS.sample, job_class: "UserReportJob", queue_name: "mailers", created_at: 30.minutes.ago },
+ { action: "failed_job_retried", actor: ACTORS.sample, job_class: "SyncInventoryJob", queue_name: "default", created_at: 3.hours.ago },
+ { action: "failed_jobs_retried", actor: ACTORS.sample, job_class: nil, queue_name: nil, item_count: 3, created_at: 5.hours.ago },
+ { action: "failed_job_discarded", actor: ACTORS.sample, job_class: "GeneratePdfJob", queue_name: "critical", created_at: 45.minutes.ago },
+ { action: "failed_jobs_discarded",actor: ACTORS.sample, job_class: nil, queue_name: nil, item_count: 2, created_at: 2.hours.ago },
+ { action: "queue_paused", actor: ACTORS.sample, job_class: nil, queue_name: "critical", created_at: 20.minutes.ago },
+ { action: "queue_resumed", actor: ACTORS.sample, job_class: nil, queue_name: "critical", created_at: 10.minutes.ago },
+ { action: "queue_paused", actor: nil, job_class: nil, queue_name: "low_priority", created_at: 4.days.ago },
+ { action: "job_discarded", actor: nil, job_class: "RecalculateScoresJob", queue_name: "default", created_at: 3.days.ago },
+].each do |attrs|
+ SolidStackWeb::AuditEvent.create!(attrs)
+end
+
# ── Summary ───────────────────────────────────────────────────────────────────
puts ""
@@ -251,4 +274,5 @@
"#{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
+puts " Solid Cable — #{SolidCable::Message.count} messages across #{SolidCable::Message.distinct.count(:channel)} channels"
+puts " Audit log — #{SolidStackWeb::AuditEvent.count} events"
\ No newline at end of file
diff --git a/spec/requests/solid_stack_web/audit_spec.rb b/spec/requests/solid_stack_web/audit_spec.rb
new file mode 100644
index 0000000..be1b9fa
--- /dev/null
+++ b/spec/requests/solid_stack_web/audit_spec.rb
@@ -0,0 +1,159 @@
+require "rails_helper"
+
+RSpec.describe "Audit log", type: :request do
+ let(:engine_root) { "/solid_stack" }
+
+ def create_event(action: "job_discarded", actor: nil, job_class: "MyJob", queue_name: "default", item_count: 1)
+ SolidStackWeb::AuditEvent.create!(
+ action: action, actor: actor, job_class: job_class,
+ queue_name: queue_name, item_count: item_count
+ )
+ end
+
+ describe "GET /audit" do
+ it "returns 200" do
+ get "#{engine_root}/audit"
+ expect(response).to have_http_status(:ok)
+ end
+
+ it "shows the page heading" do
+ get "#{engine_root}/audit"
+ expect(response.body).to include("Audit Log")
+ end
+
+ it "shows empty state when no events exist" do
+ get "#{engine_root}/audit"
+ expect(response.body).to include("No audit events recorded yet")
+ end
+
+ it "renders a row for each audit event" do
+ create_event(action: "queue_paused", queue_name: "critical")
+ create_event(action: "failed_job_retried", job_class: "BillingJob")
+
+ get "#{engine_root}/audit"
+
+ expect(response.body).to include("queue paused")
+ expect(response.body).to include("failed job retried")
+ end
+
+ it "renders the action as a badge" do
+ create_event(action: "job_discarded")
+ get "#{engine_root}/audit"
+ expect(response.body).to include("sqw-badge--failed")
+ end
+
+ it "shows actor as a filter link when present" do
+ create_event(actor: "admin@example.com")
+ get "#{engine_root}/audit"
+ expect(response.body).to include("admin@example.com")
+ end
+
+ it "shows a dash when actor is nil" do
+ create_event(actor: nil)
+ get "#{engine_root}/audit"
+ expect(response.body).to include("—")
+ end
+ end
+
+ describe "GET /audit?audit_action=" do
+ it "filters events by action" do
+ create_event(action: "job_discarded", job_class: "DiscardedJob")
+ create_event(action: "queue_paused", queue_name: "unique-queue-xyz")
+
+ get "#{engine_root}/audit", params: { audit_action: "job_discarded" }
+
+ expect(response.body).to include("DiscardedJob")
+ expect(response.body).not_to include("unique-queue-xyz")
+ end
+
+ it "ignores invalid action values" do
+ get "#{engine_root}/audit", params: { audit_action: "drop_table--users" }
+ expect(response).to have_http_status(:ok)
+ end
+ end
+
+ describe "GET /audit?actor=" do
+ it "filters events by actor" do
+ create_event(actor: "alice@example.com", job_class: "AliceJob")
+ create_event(actor: "bob@example.com", job_class: "BobJob")
+
+ get "#{engine_root}/audit", params: { actor: "alice@example.com" }
+
+ expect(response.body).to include("AliceJob")
+ expect(response.body).not_to include("BobJob")
+ end
+ end
+
+ describe "GET /audit?queue=" do
+ it "filters events by queue" do
+ create_event(queue_name: "critical", job_class: "CriticalJob")
+ create_event(queue_name: "default", job_class: "DefaultJob")
+
+ get "#{engine_root}/audit", params: { queue: "critical" }
+
+ expect(response.body).to include("CriticalJob")
+ expect(response.body).not_to include("DefaultJob")
+ end
+ end
+
+ describe "GET /audit.csv" do
+ it "returns a CSV attachment" do
+ get "#{engine_root}/audit.csv"
+ expect(response).to have_http_status(:ok)
+ expect(response.media_type).to eq("text/csv")
+ expect(response.headers["Content-Disposition"]).to include("attachment")
+ end
+
+ it "includes the correct headers" do
+ get "#{engine_root}/audit.csv"
+ headers = response.body.lines.first.chomp
+ expect(headers).to eq("id,action,actor,job_class,queue_name,item_count,created_at")
+ end
+
+ it "includes one row per event" do
+ create_event(action: "job_discarded", job_class: "AlphaJob")
+ create_event(action: "queue_paused", job_class: nil)
+
+ get "#{engine_root}/audit.csv"
+ rows = CSV.parse(response.body, headers: true)
+ expect(rows.length).to eq(2)
+ end
+
+ it "applies active filters to the export" do
+ create_event(action: "job_discarded", job_class: "AlphaJob")
+ create_event(action: "queue_paused", job_class: nil)
+
+ get "#{engine_root}/audit.csv", params: { audit_action: "job_discarded" }
+ rows = CSV.parse(response.body, headers: true)
+ expect(rows.length).to eq(1)
+ expect(rows.first["action"]).to eq("job_discarded")
+ end
+ end
+
+ describe "audit recording" do
+ it "records a job_discarded event when a ready job is discarded" do
+ job = SolidQueue::Job.create!(class_name: "MyJob", queue_name: "default")
+
+ expect {
+ delete "#{engine_root}/jobs/#{job.ready_execution.id}",
+ params: { status: "ready" }
+ }.to change(SolidStackWeb::AuditEvent, :count).by(1)
+
+ event = SolidStackWeb::AuditEvent.last
+ expect(event.action).to eq("job_discarded")
+ expect(event.job_class).to eq("MyJob")
+ expect(event.queue_name).to eq("default")
+ end
+
+ it "records a queue_paused event" do
+ SolidQueue::Job.create!(class_name: "MyJob", queue_name: "default")
+
+ expect {
+ post "#{engine_root}/queues/default/pause"
+ }.to change(SolidStackWeb::AuditEvent, :count).by(1)
+
+ expect(SolidStackWeb::AuditEvent.last.action).to eq("queue_paused")
+ expect(SolidStackWeb::AuditEvent.last.queue_name).to eq("default")
+ end
+ end
+end