Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -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/

Expand Down
4 changes: 4 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
8 changes: 0 additions & 8 deletions ROADMAP.md
Original file line number Diff line number Diff line change
Expand Up @@ -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._
Expand Down
24 changes: 23 additions & 1 deletion app/controllers/solid_stack_web/application_controller.rb
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down
49 changes: 49 additions & 0 deletions app/controllers/solid_stack_web/audit_controller.rb
Original file line number Diff line number Diff line change
@@ -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
Original file line number Diff line number Diff line change
Expand Up @@ -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}"
Expand All @@ -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}"
Expand Down
4 changes: 4 additions & 0 deletions app/controllers/solid_stack_web/failed_jobs_controller.rb
Original file line number Diff line number Diff line change
Expand Up @@ -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."

Expand All @@ -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
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
4 changes: 4 additions & 0 deletions app/controllers/solid_stack_web/jobs_controller.rb
Original file line number Diff line number Diff line change
Expand Up @@ -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."

Expand All @@ -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
Expand Down
2 changes: 2 additions & 0 deletions app/controllers/solid_stack_web/queues/pauses_controller.rb
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
9 changes: 9 additions & 0 deletions app/helpers/solid_stack_web/application_helper.rb
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down
17 changes: 17 additions & 0 deletions app/models/solid_stack_web/audit_event.rb
Original file line number Diff line number Diff line change
@@ -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
2 changes: 2 additions & 0 deletions app/views/layouts/solid_stack_web/application.html.erb
Original file line number Diff line number Diff line change
Expand Up @@ -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"}" %>
</div>
</nav>
<% end %>
Expand Down
86 changes: 86 additions & 0 deletions app/views/solid_stack_web/audit/index.html.erb
Original file line number Diff line number Diff line change
@@ -0,0 +1,86 @@
<div class="sqw-page-header sqw-page-header--split">
<h1 class="sqw-page-title">Audit Log</h1>
<% 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 %>
</div>

<form class="sqw-filters" action="<%= audit_path %>" method="get">
<select name="audit_action" class="sqw-select" aria-label="Filter by action"
onchange="this.form.submit()">
<option value="">All actions</option>
<% SolidStackWeb::AuditEvent::ACTIONS.each do |action| %>
<option value="<%= action %>" <%= "selected" if @action_filter == action %>><%= action.tr("_", " ") %></option>
<% end %>
</select>

<% if @actor_filter.present? %>
<span class="sqw-badge">
Actor: <%= @actor_filter %>
<%= link_to "×", audit_path(audit_action: @action_filter, queue: @queue_filter), class: "sqw-muted", aria: { label: "Clear actor filter" } %>
</span>
<% end %>

<% if @queue_filter.present? %>
<span class="sqw-badge">
Queue: <%= @queue_filter %>
<%= link_to "×", audit_path(audit_action: @action_filter, actor: @actor_filter), class: "sqw-muted", aria: { label: "Clear queue filter" } %>
</span>
<% 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 %>
</form>

<% if @events.any? %>
<table class="sqw-table">
<thead>
<tr>
<th scope="col">Time</th>
<th scope="col">Action</th>
<th scope="col">Actor</th>
<th scope="col">Job Class</th>
<th scope="col">Queue</th>
<th scope="col">Count</th>
</tr>
</thead>
<tbody>
<% @events.each do |event| %>
<tr>
<td class="sqw-muted"><%= local_time(event.created_at, format: :long) %></td>
<td>
<span class="sqw-badge <%= audit_action_badge_class(event.action) %>">
<%= event.action.tr("_", " ") %>
</span>
</td>
<td>
<% if event.actor.present? %>
<%= link_to event.actor, audit_path(audit_action: @action_filter, actor: event.actor, queue: @queue_filter) %>
<% else %>
<span class="sqw-muted">—</span>
<% end %>
</td>
<td class="sqw-monospace"><%= event.job_class.presence || content_tag(:span, "—", class: "sqw-muted") %></td>
<td>
<% if event.queue_name.present? %>
<span class="sqw-badge sqw-badge--queue">
<%= link_to event.queue_name, audit_path(audit_action: @action_filter, actor: @actor_filter, queue: event.queue_name) %>
</span>
<% else %>
<span class="sqw-muted">—</span>
<% end %>
</td>
<td><%= event.item_count %></td>
</tr>
<% end %>
</tbody>
</table>
<%== @pagy.series_nav(aria_label: "Pagination") if @pagy.pages > 1 %>
<% else %>
<div class="sqw-empty" id="sqd-empty">
<p class="sqw-muted">No audit events recorded yet.</p>
</div>
<% end %>
1 change: 1 addition & 0 deletions config/importmap.rb
Original file line number Diff line number Diff line change
@@ -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"
Expand Down
1 change: 1 addition & 0 deletions config/routes.rb
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down
24 changes: 24 additions & 0 deletions lib/generators/solid_stack_web/install/migrations_generator.rb
Original file line number Diff line number Diff line change
@@ -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
Original file line number Diff line number Diff line change
@@ -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
Loading