solid_stack_web 1.3.0 → 1.4.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: d97689a002516820708b4d0fcd852eadb94d4c24cd67b2d6d9760cc842df2033
4
- data.tar.gz: 14bb1935a9b57b09768ddfd32677b5b16a447d13c96ac605c8bd4f7491412240
3
+ metadata.gz: f5c82dfa829278df1d7fe7b59637d2b7c7b2ef31ea8ec9fb7ff2fe1234133658
4
+ data.tar.gz: eb1e7519de019b3921c5ef3536b806b902790a51eac340d94084e59e1ef4650c
5
5
  SHA512:
6
- metadata.gz: e2f8057a998ccc88c642f5805fd63e850b9e23e60f3b02b48685a55f64d30958e1e2775caae4dc59f58ca68f58e40539df0c282b844c2f196b18bcaa06999887
7
- data.tar.gz: e4ecd5a93a76121efc58e5f66ae50ceaa9590b065af359b840c0d6f7622ee8f0417ad3ff397152672828c645b37826f8e51c7a3a4cf3da32d8c50a62d81c3d58
6
+ metadata.gz: 33821254738d6f8c2ee5cc177ef01f600988d905ccf7020285b4a3368b29db7c5f19811aabaf45ade463a433304e70639047bf8120bc0718ee2c9442a93686a7
7
+ data.tar.gz: 780cbf594e35533f5ee0374e41945a6a1319c22151c6fb2b9b7a82f1aaf256651b6e836777e3939b305bcff510144a5d3d84de014f6e8bc67cab67959f761725
@@ -22,7 +22,7 @@ module SolidStackWeb
22
22
 
23
23
  def current_section
24
24
  case controller_name
25
- when "jobs", "failed_jobs", "queues", "processes", "history", "scheduled_jobs", "recurring_tasks" then :queue
25
+ when "jobs", "failed_jobs", "queues", "processes", "history", "scheduled_jobs", "recurring_tasks", "audit" then :queue
26
26
  when "cache", "cache_entries" then :cache
27
27
  when "cable" then :cable
28
28
  else :overview
@@ -51,6 +51,28 @@ module SolidStackWeb
51
51
  request_http_basic_authentication("Solid Stack Dashboard")
52
52
  end
53
53
 
54
+ def record_audit(action, job_class: nil, queue_name: nil, item_count: 1)
55
+ AuditEvent.create!(
56
+ action: action,
57
+ actor: resolve_current_actor,
58
+ job_class: job_class,
59
+ queue_name: queue_name,
60
+ item_count: item_count
61
+ )
62
+ rescue => e
63
+ Rails.logger.error("[SolidStackWeb] Audit log failed: #{e.message}")
64
+ end
65
+
66
+ def resolve_current_actor
67
+ block = SolidStackWeb.current_actor
68
+ return nil unless block
69
+
70
+ instance_exec(&block)
71
+ rescue => e
72
+ Rails.logger.error("[SolidStackWeb] current_actor resolution failed: #{e.message}")
73
+ nil
74
+ end
75
+
54
76
  def render_not_found
55
77
  render "solid_stack_web/errors/not_found", status: :not_found
56
78
  end
@@ -0,0 +1,49 @@
1
+ module SolidStackWeb
2
+ class AuditController < ApplicationController
3
+ def index
4
+ unless AuditEvent.table_exists?
5
+ redirect_to root_path,
6
+ alert: "Audit log requires running `rails solid_stack_web:install:migrations && rails db:migrate`."
7
+ return
8
+ end
9
+
10
+ set_filters
11
+ scope = audit_scope
12
+
13
+ respond_to do |format|
14
+ format.html { @pagy, @events = pagy(scope) }
15
+ format.csv do
16
+ send_data audit_csv(scope),
17
+ filename: "audit-log-#{Date.today}.csv",
18
+ type: "text/csv", disposition: "attachment"
19
+ end
20
+ end
21
+ end
22
+
23
+ private
24
+
25
+ def set_filters
26
+ @action_filter = params[:audit_action].presence_in(AuditEvent::ACTIONS)
27
+ @actor_filter = params[:actor].presence
28
+ @queue_filter = params[:queue].presence
29
+ end
30
+
31
+ def audit_scope
32
+ scope = AuditEvent.recent
33
+ scope = scope.where(action: @action_filter) if @action_filter
34
+ scope = scope.where(actor: @actor_filter) if @actor_filter
35
+ scope = scope.where(queue_name: @queue_filter) if @queue_filter
36
+ scope
37
+ end
38
+
39
+ def audit_csv(scope)
40
+ CSV.generate(headers: true) do |csv|
41
+ csv << %w[id action actor job_class queue_name item_count created_at]
42
+ scope.each do |event|
43
+ csv << [event.id, event.action, event.actor, event.job_class,
44
+ event.queue_name, event.item_count, event.created_at.iso8601]
45
+ end
46
+ end
47
+ end
48
+ end
49
+ end
@@ -6,6 +6,7 @@ module SolidStackWeb
6
6
  def create
7
7
  count = @ids.size
8
8
  SolidQueue::FailedExecution.where(id: @ids).each(&:retry)
9
+ record_audit("failed_jobs_retried", item_count: count)
9
10
  redirect_to failed_jobs_path, notice: "#{count} #{count == 1 ? "job" : "jobs"} retried."
10
11
  rescue => e
11
12
  redirect_to failed_jobs_path, alert: "Could not retry jobs: #{e.message}"
@@ -14,6 +15,7 @@ module SolidStackWeb
14
15
  def destroy
15
16
  job_ids = SolidQueue::FailedExecution.where(id: @ids).pluck(:job_id)
16
17
  count = SolidQueue::Job.where(id: job_ids).destroy_all.size
18
+ record_audit("failed_jobs_discarded", item_count: count)
17
19
  redirect_to failed_jobs_path, notice: "#{count} #{count == 1 ? "job" : "jobs"} discarded."
18
20
  rescue => e
19
21
  redirect_to failed_jobs_path, alert: "Could not discard jobs: #{e.message}"
@@ -28,7 +28,10 @@ module SolidStackWeb
28
28
 
29
29
  def destroy
30
30
  @execution = ::SolidQueue::FailedExecution.find(params[:id])
31
+ job_class = @execution.job.class_name
32
+ queue_name = @execution.job.queue_name
31
33
  @execution.job.destroy!
34
+ record_audit("failed_job_discarded", job_class: job_class, queue_name: queue_name)
32
35
  @executions_remain = ::SolidQueue::FailedExecution.exists?
33
36
  @notice = "Job discarded."
34
37
 
@@ -40,6 +43,7 @@ module SolidStackWeb
40
43
 
41
44
  def retry
42
45
  execution = ::SolidQueue::FailedExecution.find(params[:id])
46
+ record_audit("failed_job_retried", job_class: execution.job.class_name, queue_name: execution.job.queue_name)
43
47
  execution.retry
44
48
  redirect_to failed_jobs_path, notice: "Job retried."
45
49
  end
@@ -8,6 +8,7 @@ module SolidStackWeb
8
8
  ids = Array(params[:job_ids]).map(&:to_i).reject(&:zero?)
9
9
  job_ids = Job::EXECUTION_MODELS[status].where(id: ids).pluck(:job_id)
10
10
  count = SolidQueue::Job.where(id: job_ids).destroy_all.size
11
+ record_audit("jobs_discarded", item_count: count)
11
12
 
12
13
  redirect_to jobs_path(
13
14
  status: status,
@@ -31,7 +31,10 @@ module SolidStackWeb
31
31
  def destroy
32
32
  if params[:id]
33
33
  @execution = Job::EXECUTION_MODELS[@status].find(params[:id])
34
+ job_class = @execution.job.class_name
35
+ queue_name = @execution.job.queue_name
34
36
  @execution.job.destroy!
37
+ record_audit("job_discarded", job_class: job_class, queue_name: queue_name)
35
38
  @executions_remain = Job::EXECUTION_MODELS[@status].exists?
36
39
  @notice = "Job discarded."
37
40
 
@@ -42,6 +45,7 @@ module SolidStackWeb
42
45
  else
43
46
  job_ids = filtered_scope.pluck(:job_id)
44
47
  count = SolidQueue::Job.where(id: job_ids).destroy_all.size
48
+ record_audit("jobs_discarded", item_count: count)
45
49
  redirect_to jobs_path(status: @status, q: @search, queue: @queue, period: @period, priority: @priority, sort: @sort, direction: @direction),
46
50
  notice: "#{count} #{count == 1 ? "job" : "jobs"} discarded."
47
51
  end
@@ -2,11 +2,13 @@ module SolidStackWeb
2
2
  class Queues::PausesController < ApplicationController
3
3
  def create
4
4
  ::SolidQueue::Pause.find_or_create_by!(queue_name: params[:queue_id])
5
+ record_audit("queue_paused", queue_name: params[:queue_id])
5
6
  redirect_back_or_to queues_path
6
7
  end
7
8
 
8
9
  def destroy
9
10
  ::SolidQueue::Pause.find_by(queue_name: params[:queue_id])&.destroy
11
+ record_audit("queue_resumed", queue_name: params[:queue_id])
10
12
  redirect_back_or_to queues_path
11
13
  end
12
14
  end
@@ -87,6 +87,15 @@ module SolidStackWeb
87
87
  end
88
88
  end
89
89
 
90
+ def audit_action_badge_class(action)
91
+ case action
92
+ when /discard/ then "sqw-badge--failed"
93
+ when /retry/ then "sqw-badge--scheduled"
94
+ when "queue_paused" then "sqw-badge--blocked"
95
+ when "queue_resumed" then "sqw-badge--ready"
96
+ end
97
+ end
98
+
90
99
  def sort_header_th(label, col, url_proc, current_sort:, current_dir:)
91
100
  is_active = current_sort == col
92
101
  next_dir = (is_active && current_dir == "desc") ? "asc" : "desc"
@@ -0,0 +1,17 @@
1
+ module SolidStackWeb
2
+ class AuditEvent < ActiveRecord::Base
3
+ self.table_name = "solid_stack_web_audit_events"
4
+
5
+ ACTIONS = %w[
6
+ job_discarded jobs_discarded
7
+ failed_job_retried failed_jobs_retried
8
+ failed_job_discarded failed_jobs_discarded
9
+ queue_paused queue_resumed
10
+ ].freeze
11
+
12
+ validates :action, presence: true, inclusion: { in: ACTIONS }
13
+ validates :item_count, numericality: { greater_than: 0 }
14
+
15
+ scope :recent, -> { order(created_at: :desc) }
16
+ end
17
+ end
@@ -65,6 +65,8 @@
65
65
  class: "sqw-subnav__link#{" sqw-subnav__link--active" if controller_name == "history"}" %>
66
66
  <%= link_to "Processes", processes_path,
67
67
  class: "sqw-subnav__link#{" sqw-subnav__link--active" if controller_name == "processes"}" %>
68
+ <%= link_to "Audit", audit_path,
69
+ class: "sqw-subnav__link#{" sqw-subnav__link--active" if controller_name == "audit"}" %>
68
70
  </div>
69
71
  </nav>
70
72
  <% end %>
@@ -0,0 +1,86 @@
1
+ <div class="sqw-page-header sqw-page-header--split">
2
+ <h1 class="sqw-page-title">Audit Log</h1>
3
+ <% if @events&.any? %>
4
+ <%= link_to "Export CSV",
5
+ audit_path(format: :csv, audit_action: @action_filter, actor: @actor_filter, queue: @queue_filter),
6
+ class: "sqw-btn sqw-btn--muted sqw-btn--sm", data: { turbo: false } %>
7
+ <% end %>
8
+ </div>
9
+
10
+ <form class="sqw-filters" action="<%= audit_path %>" method="get">
11
+ <select name="audit_action" class="sqw-select" aria-label="Filter by action"
12
+ onchange="this.form.submit()">
13
+ <option value="">All actions</option>
14
+ <% SolidStackWeb::AuditEvent::ACTIONS.each do |action| %>
15
+ <option value="<%= action %>" <%= "selected" if @action_filter == action %>><%= action.tr("_", " ") %></option>
16
+ <% end %>
17
+ </select>
18
+
19
+ <% if @actor_filter.present? %>
20
+ <span class="sqw-badge">
21
+ Actor: <%= @actor_filter %>
22
+ <%= link_to "×", audit_path(audit_action: @action_filter, queue: @queue_filter), class: "sqw-muted", aria: { label: "Clear actor filter" } %>
23
+ </span>
24
+ <% end %>
25
+
26
+ <% if @queue_filter.present? %>
27
+ <span class="sqw-badge">
28
+ Queue: <%= @queue_filter %>
29
+ <%= link_to "×", audit_path(audit_action: @action_filter, actor: @actor_filter), class: "sqw-muted", aria: { label: "Clear queue filter" } %>
30
+ </span>
31
+ <% end %>
32
+
33
+ <% if @action_filter.present? || @actor_filter.present? || @queue_filter.present? %>
34
+ <%= link_to "Clear all", audit_path, class: "sqw-btn sqw-btn--muted sqw-btn--sm" %>
35
+ <% end %>
36
+ </form>
37
+
38
+ <% if @events.any? %>
39
+ <table class="sqw-table">
40
+ <thead>
41
+ <tr>
42
+ <th scope="col">Time</th>
43
+ <th scope="col">Action</th>
44
+ <th scope="col">Actor</th>
45
+ <th scope="col">Job Class</th>
46
+ <th scope="col">Queue</th>
47
+ <th scope="col">Count</th>
48
+ </tr>
49
+ </thead>
50
+ <tbody>
51
+ <% @events.each do |event| %>
52
+ <tr>
53
+ <td class="sqw-muted"><%= local_time(event.created_at, format: :long) %></td>
54
+ <td>
55
+ <span class="sqw-badge <%= audit_action_badge_class(event.action) %>">
56
+ <%= event.action.tr("_", " ") %>
57
+ </span>
58
+ </td>
59
+ <td>
60
+ <% if event.actor.present? %>
61
+ <%= link_to event.actor, audit_path(audit_action: @action_filter, actor: event.actor, queue: @queue_filter) %>
62
+ <% else %>
63
+ <span class="sqw-muted">—</span>
64
+ <% end %>
65
+ </td>
66
+ <td class="sqw-monospace"><%= event.job_class.presence || content_tag(:span, "—", class: "sqw-muted") %></td>
67
+ <td>
68
+ <% if event.queue_name.present? %>
69
+ <span class="sqw-badge sqw-badge--queue">
70
+ <%= link_to event.queue_name, audit_path(audit_action: @action_filter, actor: @actor_filter, queue: event.queue_name) %>
71
+ </span>
72
+ <% else %>
73
+ <span class="sqw-muted">—</span>
74
+ <% end %>
75
+ </td>
76
+ <td><%= event.item_count %></td>
77
+ </tr>
78
+ <% end %>
79
+ </tbody>
80
+ </table>
81
+ <%== @pagy.series_nav(aria_label: "Pagination") if @pagy.pages > 1 %>
82
+ <% else %>
83
+ <div class="sqw-empty" id="sqd-empty">
84
+ <p class="sqw-muted">No audit events recorded yet.</p>
85
+ </div>
86
+ <% end %>
data/config/importmap.rb CHANGED
@@ -1,6 +1,7 @@
1
1
  pin "@hotwired/turbo", to: "https://cdn.jsdelivr.net/npm/@hotwired/turbo@8.0.23/dist/turbo.es2017-esm.js"
2
2
  pin "@hotwired/stimulus", to: "https://cdn.jsdelivr.net/npm/@hotwired/stimulus@3.2.2/dist/stimulus.js"
3
3
  pin "solid_stack_web", to: "solid_stack_web/application.js"
4
+ pin "solid_stack_web/filter_persist_controller", to: "solid_stack_web/filter_persist_controller.js"
4
5
  pin "solid_stack_web/refresh_controller", to: "solid_stack_web/refresh_controller.js"
5
6
  pin "solid_stack_web/search_controller", to: "solid_stack_web/search_controller.js"
6
7
  pin "solid_stack_web/selection_controller", to: "solid_stack_web/selection_controller.js"
data/config/routes.rb CHANGED
@@ -36,6 +36,7 @@ SolidStackWeb::Engine.routes.draw do
36
36
  get "metrics", to: "metrics#index", as: :metrics
37
37
  get "stats", to: "stats#index", as: :stats
38
38
  get "history", to: "history#index", as: :history
39
+ get "audit", to: "audit#index", as: :audit
39
40
  get "cache", to: "cache#index", as: :cache
40
41
  resources :cache_entries, only: [:index, :show, :destroy], path: "cache/entries"
41
42
  resource :cache_flush, only: [:destroy], path: "cache/flush", controller: "cache/flushes"
@@ -0,0 +1,24 @@
1
+ require "rails/generators/base"
2
+ require "rails/generators/migration"
3
+
4
+ module SolidStackWeb
5
+ module Generators
6
+ class MigrationsGenerator < Rails::Generators::Base
7
+ include Rails::Generators::Migration
8
+
9
+ source_root File.expand_path("templates", __dir__)
10
+
11
+ desc "Generates the solid_stack_web_audit_events migration"
12
+
13
+ def self.next_migration_number(dirname)
14
+ next_migration_number = current_migration_number(dirname) + 1
15
+ ActiveRecord::Migration.next_migration_number(next_migration_number)
16
+ end
17
+
18
+ def create_migration_file
19
+ migration_template "create_solid_stack_web_audit_events.rb.tt",
20
+ "db/migrate/create_solid_stack_web_audit_events.rb"
21
+ end
22
+ end
23
+ end
24
+ end
@@ -0,0 +1,16 @@
1
+ class CreateSolidStackWebAuditEvents < ActiveRecord::Migration[<%= ActiveRecord::Migration.current_version %>]
2
+ def change
3
+ create_table :solid_stack_web_audit_events do |t|
4
+ t.string :action, null: false
5
+ t.string :actor
6
+ t.string :job_class
7
+ t.string :queue_name
8
+ t.integer :item_count, null: false, default: 1
9
+ t.datetime :created_at, null: false
10
+ end
11
+
12
+ add_index :solid_stack_web_audit_events, :created_at
13
+ add_index :solid_stack_web_audit_events, :action
14
+ add_index :solid_stack_web_audit_events, :actor
15
+ end
16
+ end
@@ -47,6 +47,13 @@ SolidStackWeb.configure do |config|
47
47
  # "critical" => 50,
48
48
  # "default" => 500
49
49
  # }
50
+ # Audit log actor — block runs in controller context, return a string identifying the current user.
51
+ # Requires running `rails solid_stack_web:install:migrations && rails db:migrate` first.
52
+ #
53
+ # config.current_actor do
54
+ # current_user&.email
55
+ # end
56
+
50
57
  # config.alert_slow_job_count_threshold = 3 # fire when N+ claimed jobs exceed slow_job_threshold duration
51
58
  # config.alert_stale_process_threshold = 1 # fire when N+ workers have a stale heartbeat (>5 min old)
52
59
  # config.alert_webhook_cooldown = 3600 # seconds between repeat alerts
@@ -1,3 +1,3 @@
1
1
  module SolidStackWeb
2
- VERSION = "1.3.0"
2
+ VERSION = "1.4.0"
3
3
  end
@@ -84,6 +84,11 @@ module SolidStackWeb
84
84
  @authenticate
85
85
  end
86
86
 
87
+ def current_actor(&block)
88
+ @current_actor = block if block_given?
89
+ @current_actor
90
+ end
91
+
87
92
  def deprecator
88
93
  @deprecator ||= ActiveSupport::Deprecation.new("1.0", "SolidStackWeb")
89
94
  end
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: solid_stack_web
3
3
  version: !ruby/object:Gem::Version
4
- version: 1.3.0
4
+ version: 1.4.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Chuck Smith
@@ -151,6 +151,7 @@ files:
151
151
  - app/assets/stylesheets/solid_stack_web/_12_dark_mode.css
152
152
  - app/assets/stylesheets/solid_stack_web/application.css
153
153
  - app/controllers/solid_stack_web/application_controller.rb
154
+ - app/controllers/solid_stack_web/audit_controller.rb
154
155
  - app/controllers/solid_stack_web/cable/channel_purges_controller.rb
155
156
  - app/controllers/solid_stack_web/cable/purges_controller.rb
156
157
  - app/controllers/solid_stack_web/cable_controller.rb
@@ -184,6 +185,7 @@ files:
184
185
  - app/javascript/solid_stack_web/theme_controller.js
185
186
  - app/javascript/solid_stack_web/timestamp_controller.js
186
187
  - app/models/solid_stack_web/alert_webhook.rb
188
+ - app/models/solid_stack_web/audit_event.rb
187
189
  - app/models/solid_stack_web/cable_stats.rb
188
190
  - app/models/solid_stack_web/cable_timeline.rb
189
191
  - app/models/solid_stack_web/cache_size_stats.rb
@@ -196,6 +198,7 @@ files:
196
198
  - app/models/solid_stack_web/queue_stats.rb
197
199
  - app/models/solid_stack_web/throughput_sparkline.rb
198
200
  - app/views/layouts/solid_stack_web/application.html.erb
201
+ - app/views/solid_stack_web/audit/index.html.erb
199
202
  - app/views/solid_stack_web/cable/index.html.erb
200
203
  - app/views/solid_stack_web/cable_messages/index.html.erb
201
204
  - app/views/solid_stack_web/cache/index.html.erb
@@ -224,6 +227,8 @@ files:
224
227
  - config/routes.rb
225
228
  - docs/screenshots/demo.gif
226
229
  - lib/generators/solid_stack_web/install/install_generator.rb
230
+ - lib/generators/solid_stack_web/install/migrations_generator.rb
231
+ - lib/generators/solid_stack_web/install/templates/create_solid_stack_web_audit_events.rb.tt
227
232
  - lib/generators/solid_stack_web/install/templates/initializer.rb
228
233
  - lib/solid_stack_web.rb
229
234
  - lib/solid_stack_web/engine.rb