From 60a8e449f9ceaf343456a7fa8afe993f7aa30aea Mon Sep 17 00:00:00 2001 From: Chuck Smith Date: Thu, 28 May 2026 08:31:18 -0400 Subject: [PATCH 1/4] feat: admin audit log (v1.4) Records who retried, discarded, or paused what and when. Requires opt-in migration via `rails solid_stack_web:install:migrations`. - AuditEvent model (solid_stack_web_audit_events table) with 8 actions - record_audit / resolve_current_actor helpers in ApplicationController - new config.current_actor block for actor identity - Recording wired into JobsController, FailedJobsController, Queues::PausesController, FailedJobs::SelectionsController, Jobs::SelectionsController - AuditController with HTML + CSV responses, filters by action/actor/queue - Audit view with badge-coloured actions, actor/queue filter links - MigrationsGenerator for host-app migration installation - Audit nav link added to Queue subnav - 17 new request specs + dummy schema updated Co-Authored-By: Claude Sonnet 4.6 --- CHANGELOG.md | 4 + ROADMAP.md | 8 - .../solid_stack_web/application_controller.rb | 24 ++- .../solid_stack_web/audit_controller.rb | 49 ++++++ .../failed_jobs/selections_controller.rb | 2 + .../solid_stack_web/failed_jobs_controller.rb | 4 + .../jobs/selections_controller.rb | 1 + .../solid_stack_web/jobs_controller.rb | 4 + .../queues/pauses_controller.rb | 2 + .../solid_stack_web/application_helper.rb | 9 + app/models/solid_stack_web/audit_event.rb | 17 ++ .../solid_stack_web/application.html.erb | 2 + .../solid_stack_web/audit/index.html.erb | 86 ++++++++++ config/routes.rb | 1 + .../install/migrations_generator.rb | 24 +++ .../create_solid_stack_web_audit_events.rb.tt | 16 ++ .../install/templates/initializer.rb | 7 + lib/solid_stack_web.rb | 5 + spec/dummy/db/schema.rb | 13 ++ spec/requests/solid_stack_web/audit_spec.rb | 159 ++++++++++++++++++ 20 files changed, 428 insertions(+), 9 deletions(-) create mode 100644 app/controllers/solid_stack_web/audit_controller.rb create mode 100644 app/models/solid_stack_web/audit_event.rb create mode 100644 app/views/solid_stack_web/audit/index.html.erb create mode 100644 lib/generators/solid_stack_web/install/migrations_generator.rb create mode 100644 lib/generators/solid_stack_web/install/templates/create_solid_stack_web_audit_events.rb.tt create mode 100644 spec/requests/solid_stack_web/audit_spec.rb 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 @@ +
+

Audit Log

+ <% if @events&.any? %> + <%= link_to "Export CSV", + audit_path(format: :csv, audit_action: @action_filter, actor: @actor_filter, queue: @queue_filter), + class: "sqw-btn sqw-btn--muted sqw-btn--sm", data: { turbo: false } %> + <% end %> +
+ +
+ + + <% if @actor_filter.present? %> + + Actor: <%= @actor_filter %> + <%= link_to "×", audit_path(audit_action: @action_filter, queue: @queue_filter), class: "sqw-muted", aria: { label: "Clear actor filter" } %> + + <% end %> + + <% if @queue_filter.present? %> + + Queue: <%= @queue_filter %> + <%= link_to "×", audit_path(audit_action: @action_filter, actor: @actor_filter), class: "sqw-muted", aria: { label: "Clear queue filter" } %> + + <% end %> + + <% if @action_filter.present? || @actor_filter.present? || @queue_filter.present? %> + <%= link_to "Clear all", audit_path, class: "sqw-btn sqw-btn--muted sqw-btn--sm" %> + <% end %> +
+ +<% if @events.any? %> + + + + + + + + + + + + + <% @events.each do |event| %> + + + + + + + + + <% end %> + +
TimeActionActorJob ClassQueueCount
<%= 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 %>
+ <%== @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/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/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 From c832bc1da2b9d3246b4998ef3d3c2e0d88a5e715 Mon Sep 17 00:00:00 2001 From: Chuck Smith Date: Thu, 28 May 2026 08:32:26 -0400 Subject: [PATCH 2/4] fix: pin filter_persist_controller in importmap filter_persist_controller was imported in application.js but never pinned, causing a browser module resolution error in the dummy app. Co-Authored-By: Claude Sonnet 4.6 --- config/importmap.rb | 1 + 1 file changed, 1 insertion(+) 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" From e19ea9f8517884a48802bb8baaeb5550d43555bb Mon Sep 17 00:00:00 2001 From: Chuck Smith Date: Thu, 28 May 2026 08:35:19 -0400 Subject: [PATCH 3/4] chore: seed dummy app with audit log data Adds 12 audit events covering all 8 action types across multiple actors and queues, with realistic timestamps for manual testing. Co-Authored-By: Claude Sonnet 4.6 --- spec/dummy/db/seeds.rb | 26 +++++++++++++++++++++++++- 1 file changed, 25 insertions(+), 1 deletion(-) 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 From d08933af4669c7249509ff421852687ce261e89b Mon Sep 17 00:00:00 2001 From: Chuck Smith Date: Thu, 28 May 2026 08:37:13 -0400 Subject: [PATCH 4/4] fix: ignore all files under spec/dummy/log/ including rotated logs Co-Authored-By: Claude Sonnet 4.6 --- .gitignore | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) 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/