pg_sql_triggers 1.2.0 → 1.3.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.
Files changed (49) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +144 -0
  3. data/COVERAGE.md +26 -19
  4. data/Goal.md +276 -155
  5. data/README.md +27 -1
  6. data/app/assets/javascripts/pg_sql_triggers/trigger_actions.js +50 -0
  7. data/app/controllers/concerns/pg_sql_triggers/error_handling.rb +56 -0
  8. data/app/controllers/concerns/pg_sql_triggers/kill_switch_protection.rb +66 -0
  9. data/app/controllers/concerns/pg_sql_triggers/permission_checking.rb +117 -0
  10. data/app/controllers/pg_sql_triggers/application_controller.rb +10 -62
  11. data/app/controllers/pg_sql_triggers/audit_logs_controller.rb +102 -0
  12. data/app/controllers/pg_sql_triggers/dashboard_controller.rb +4 -9
  13. data/app/controllers/pg_sql_triggers/tables_controller.rb +30 -4
  14. data/app/controllers/pg_sql_triggers/triggers_controller.rb +3 -21
  15. data/app/helpers/pg_sql_triggers/permissions_helper.rb +43 -0
  16. data/app/models/pg_sql_triggers/audit_log.rb +106 -0
  17. data/app/models/pg_sql_triggers/trigger_registry.rb +178 -9
  18. data/app/views/layouts/pg_sql_triggers/application.html.erb +26 -6
  19. data/app/views/pg_sql_triggers/audit_logs/index.html.erb +177 -0
  20. data/app/views/pg_sql_triggers/dashboard/index.html.erb +33 -8
  21. data/app/views/pg_sql_triggers/tables/index.html.erb +76 -3
  22. data/app/views/pg_sql_triggers/tables/show.html.erb +17 -4
  23. data/app/views/pg_sql_triggers/triggers/_drop_modal.html.erb +16 -7
  24. data/app/views/pg_sql_triggers/triggers/_re_execute_modal.html.erb +16 -7
  25. data/app/views/pg_sql_triggers/triggers/show.html.erb +26 -6
  26. data/config/routes.rb +2 -0
  27. data/db/migrate/20260103000001_create_pg_sql_triggers_audit_log.rb +28 -0
  28. data/docs/README.md +15 -5
  29. data/docs/api-reference.md +191 -0
  30. data/docs/audit-trail.md +413 -0
  31. data/docs/configuration.md +6 -6
  32. data/docs/permissions.md +369 -0
  33. data/docs/troubleshooting.md +486 -0
  34. data/docs/ui-guide.md +211 -0
  35. data/docs/web-ui.md +257 -34
  36. data/lib/pg_sql_triggers/errors.rb +245 -0
  37. data/lib/pg_sql_triggers/generator/service.rb +32 -0
  38. data/lib/pg_sql_triggers/permissions/checker.rb +9 -2
  39. data/lib/pg_sql_triggers/registry.rb +141 -8
  40. data/lib/pg_sql_triggers/sql/kill_switch.rb +33 -5
  41. data/lib/pg_sql_triggers/testing/function_tester.rb +2 -0
  42. data/lib/pg_sql_triggers/version.rb +1 -1
  43. data/lib/pg_sql_triggers.rb +3 -6
  44. metadata +29 -6
  45. data/docs/screenshots/.gitkeep +0 -1
  46. data/docs/screenshots/Generate Trigger.png +0 -0
  47. data/docs/screenshots/Triggers Page.png +0 -0
  48. data/docs/screenshots/kill error.png +0 -0
  49. data/docs/screenshots/kill modal for migration down.png +0 -0
@@ -0,0 +1,106 @@
1
+ # frozen_string_literal: true
2
+
3
+ module PgSqlTriggers
4
+ # Audit log model for tracking all trigger operations
5
+ class AuditLog < PgSqlTriggers::ApplicationRecord
6
+ self.table_name = "pg_sql_triggers_audit_log"
7
+
8
+ # Scopes
9
+ scope :for_trigger, ->(trigger_name) { where(trigger_name: trigger_name) }
10
+ scope :for_operation, ->(operation) { where(operation: operation) }
11
+ scope :for_environment, ->(env) { where(environment: env) }
12
+ scope :successful, -> { where(status: "success") }
13
+ scope :failed, -> { where(status: "failure") }
14
+ scope :recent, -> { order(created_at: :desc) }
15
+
16
+ # Validations
17
+ validates :operation, presence: true
18
+ validates :status, presence: true, inclusion: { in: %w[success failure] }
19
+
20
+ # Class methods for logging operations
21
+ class << self
22
+ # Log a successful operation
23
+ #
24
+ # @param operation [Symbol, String] The operation being performed
25
+ # @param trigger_name [String, nil] The trigger name (if applicable)
26
+ # @param actor [Hash] Information about who performed the action
27
+ # @param environment [String, nil] The environment
28
+ # @param reason [String, nil] Reason for the operation
29
+ # @param confirmation_text [String, nil] Confirmation text used
30
+ # @param before_state [Hash, nil] State before operation
31
+ # @param after_state [Hash, nil] State after operation
32
+ # @param diff [String, nil] Diff information
33
+ # rubocop:disable Metrics/ParameterLists
34
+ def log_success(operation:, trigger_name: nil, actor: nil, environment: nil,
35
+ reason: nil, confirmation_text: nil, before_state: nil,
36
+ after_state: nil, diff: nil)
37
+ create!(
38
+ trigger_name: trigger_name,
39
+ operation: operation.to_s,
40
+ actor: serialize_actor(actor),
41
+ environment: environment,
42
+ status: "success",
43
+ reason: reason,
44
+ confirmation_text: confirmation_text,
45
+ before_state: before_state,
46
+ after_state: after_state,
47
+ diff: diff
48
+ )
49
+ rescue StandardError => e
50
+ Rails.logger.error("Failed to log audit entry: #{e.message}") if defined?(Rails.logger)
51
+ nil
52
+ end
53
+ # rubocop:enable Metrics/ParameterLists
54
+
55
+ # Log a failed operation
56
+ #
57
+ # @param operation [Symbol, String] The operation being performed
58
+ # @param trigger_name [String, nil] The trigger name (if applicable)
59
+ # @param actor [Hash] Information about who performed the action
60
+ # @param environment [String, nil] The environment
61
+ # @param error_message [String] Error message
62
+ # @param reason [String, nil] Reason for the operation (if provided before failure)
63
+ # @param confirmation_text [String, nil] Confirmation text used
64
+ # @param before_state [Hash, nil] State before operation
65
+ # rubocop:disable Metrics/ParameterLists
66
+ def log_failure(operation:, error_message:, trigger_name: nil, actor: nil, environment: nil, reason: nil,
67
+ confirmation_text: nil, before_state: nil)
68
+ create!(
69
+ trigger_name: trigger_name,
70
+ operation: operation.to_s,
71
+ actor: serialize_actor(actor),
72
+ environment: environment,
73
+ status: "failure",
74
+ error_message: error_message,
75
+ reason: reason,
76
+ confirmation_text: confirmation_text,
77
+ before_state: before_state
78
+ )
79
+ rescue StandardError => e
80
+ Rails.logger.error("Failed to log audit entry: #{e.message}") if defined?(Rails.logger)
81
+ nil
82
+ end
83
+ # rubocop:enable Metrics/ParameterLists
84
+
85
+ # Get audit log entries for a specific trigger
86
+ #
87
+ # @param trigger_name [String] The trigger name
88
+ # @return [ActiveRecord::Relation] Audit log entries for the trigger
89
+ def for_trigger_name(trigger_name)
90
+ for_trigger(trigger_name).recent
91
+ end
92
+
93
+ private
94
+
95
+ def serialize_actor(actor)
96
+ return nil if actor.nil?
97
+
98
+ if actor.is_a?(Hash)
99
+ actor
100
+ else
101
+ { type: actor.class.name, id: actor.id.to_s }
102
+ end
103
+ end
104
+ end
105
+ end
106
+ end
@@ -1,6 +1,26 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module PgSqlTriggers
4
+ # ActiveRecord model representing a trigger in the registry.
5
+ #
6
+ # This model tracks all triggers managed by pg_sql_triggers, including their
7
+ # state, version, checksum, and drift status.
8
+ #
9
+ # @example Query triggers
10
+ # # Find a trigger
11
+ # trigger = PgSqlTriggers::TriggerRegistry.find_by(trigger_name: "users_email_validation")
12
+ #
13
+ # # Check drift status
14
+ # trigger.drifted? # => true/false
15
+ # trigger.in_sync? # => true/false
16
+ #
17
+ # @example Enable/disable triggers
18
+ # trigger.enable!(actor: current_user, confirmation: "EXECUTE TRIGGER_ENABLE")
19
+ # trigger.disable!(actor: current_user, confirmation: "EXECUTE TRIGGER_DISABLE")
20
+ #
21
+ # @example Drop and re-execute triggers
22
+ # trigger.drop!(reason: "No longer needed", actor: current_user, confirmation: "EXECUTE TRIGGER_DROP")
23
+ # trigger.re_execute!(reason: "Fix drift", actor: current_user, confirmation: "EXECUTE TRIGGER_RE_EXECUTE")
4
24
  # rubocop:disable Metrics/ClassLength
5
25
  class TriggerRegistry < PgSqlTriggers::ApplicationRecord
6
26
  self.table_name = "pg_sql_triggers_registry"
@@ -19,36 +39,59 @@ module PgSqlTriggers
19
39
  scope :for_environment, ->(env) { where(environment: [env, nil]) }
20
40
  scope :by_source, ->(source) { where(source: source) }
21
41
 
22
- # Drift detection methods
42
+ # Returns the current drift state of this trigger.
43
+ #
44
+ # @return [String] One of: "in_sync", "drifted", "manual_override", "disabled", "dropped", "unknown"
23
45
  def drift_state
24
46
  result = PgSqlTriggers::Drift.detect(trigger_name)
25
47
  result[:state]
26
48
  end
27
49
 
50
+ # Returns detailed drift detection result for this trigger.
51
+ #
52
+ # @return [Hash] Drift result with keys: :state, :trigger_name, :expected_sql, :actual_sql, etc.
28
53
  def drift_result
29
54
  PgSqlTriggers::Drift::Detector.detect(trigger_name)
30
55
  end
31
56
 
57
+ # Checks if this trigger has drifted from its expected state.
58
+ #
59
+ # @return [Boolean] true if trigger has drifted, false otherwise
32
60
  def drifted?
33
61
  drift_state == PgSqlTriggers::DRIFT_STATE_DRIFTED
34
62
  end
35
63
 
64
+ # Checks if this trigger is in sync with its expected state.
65
+ #
66
+ # @return [Boolean] true if trigger is in sync, false otherwise
36
67
  def in_sync?
37
68
  drift_state == PgSqlTriggers::DRIFT_STATE_IN_SYNC
38
69
  end
39
70
 
71
+ # Checks if this trigger has been dropped from the database.
72
+ #
73
+ # @return [Boolean] true if trigger has been dropped, false otherwise
40
74
  def dropped?
41
75
  drift_state == PgSqlTriggers::DRIFT_STATE_DROPPED
42
76
  end
43
77
 
44
- def enable!(confirmation: nil)
78
+ # Enables this trigger in the database and updates the registry.
79
+ #
80
+ # @param confirmation [String, nil] Optional confirmation text for kill switch protection
81
+ # @param actor [Hash, nil] Information about who is performing the action (must have :type and :id keys)
82
+ # @raise [PgSqlTriggers::KillSwitchError] If kill switch blocks the operation
83
+ # @return [PgSqlTriggers::TriggerRegistry] self
84
+ def enable!(confirmation: nil, actor: nil)
85
+ actor ||= { type: "Console", id: "TriggerRegistry#enable!" }
86
+ before_state = capture_state
87
+
45
88
  # Check kill switch before enabling trigger
46
89
  # Use Rails.env for kill switch check, not the trigger's environment field
47
90
  PgSqlTriggers::SQL::KillSwitch.check!(
48
91
  operation: :trigger_enable,
49
92
  environment: Rails.env,
50
93
  confirmation: confirmation,
51
- actor: { type: "Console", id: "TriggerRegistry#enable!" }
94
+ actor: actor
52
95
  )
53
96
 
54
97
  # Check if trigger exists in database before trying to enable it
@@ -71,12 +114,16 @@ module PgSqlTriggers
71
114
  rescue ActiveRecord::StatementInvalid, StandardError => e
72
115
  # If trigger doesn't exist or can't be enabled, continue to update registry
73
116
  Rails.logger.warn("Could not enable trigger: #{e.message}") if defined?(Rails.logger)
117
+ log_audit_failure(:trigger_enable, actor, e.message, before_state: before_state)
118
+ raise
74
119
  end
75
120
  end
76
121
 
77
122
  # Update the registry record (always update, even if trigger doesn't exist)
78
123
  begin
79
124
  update!(enabled: true)
125
+ after_state = capture_state
126
+ log_audit_success(:trigger_enable, actor, before_state: before_state, after_state: after_state)
80
127
  rescue ActiveRecord::StatementInvalid, StandardError => e
81
128
  # If update! fails, try update_column which bypasses validations and callbacks
82
129
  # and might not use execute in the same way
@@ -85,6 +132,8 @@ module PgSqlTriggers
85
132
  # rubocop:disable Rails/SkipsModelValidations
86
133
  update_column(:enabled, true)
87
134
  # rubocop:enable Rails/SkipsModelValidations
135
+ after_state = capture_state
136
+ log_audit_success(:trigger_enable, actor, before_state: before_state, after_state: after_state)
88
137
  rescue StandardError => update_error
89
138
  # If update_column also fails, just set the in-memory attribute
90
139
  # The test might reload, but we've done our best
@@ -92,18 +141,29 @@ module PgSqlTriggers
92
141
  Rails.logger.warn("Could not update registry via update_column: #{update_error.message}") if defined?(Rails.logger)
93
142
  # rubocop:enable Layout/LineLength
94
143
  self.enabled = true
144
+ after_state = capture_state
145
+ log_audit_success(:trigger_enable, actor, before_state: before_state, after_state: after_state)
95
146
  end
96
147
  end
97
148
  end
98
149
 
99
- def disable!(confirmation: nil)
150
+ # Disables this trigger in the database and updates the registry.
151
+ #
152
+ # @param confirmation [String, nil] Optional confirmation text for kill switch protection
153
+ # @param actor [Hash, nil] Information about who is performing the action (must have :type and :id keys)
154
+ # @raise [PgSqlTriggers::KillSwitchError] If kill switch blocks the operation
155
+ # @return [PgSqlTriggers::TriggerRegistry] self
156
+ def disable!(confirmation: nil, actor: nil)
157
+ actor ||= { type: "Console", id: "TriggerRegistry#disable!" }
158
+ before_state = capture_state
159
+
100
160
  # Check kill switch before disabling trigger
101
161
  # Use Rails.env for kill switch check, not the trigger's environment field
102
162
  PgSqlTriggers::SQL::KillSwitch.check!(
103
163
  operation: :trigger_disable,
104
164
  environment: Rails.env,
105
165
  confirmation: confirmation,
106
- actor: { type: "Console", id: "TriggerRegistry#disable!" }
166
+ actor: actor
107
167
  )
108
168
 
109
169
  # Check if trigger exists in database before trying to disable it
@@ -126,12 +186,16 @@ module PgSqlTriggers
126
186
  rescue ActiveRecord::StatementInvalid, StandardError => e
127
187
  # If trigger doesn't exist or can't be disabled, continue to update registry
128
188
  Rails.logger.warn("Could not disable trigger: #{e.message}") if defined?(Rails.logger)
189
+ log_audit_failure(:trigger_disable, actor, e.message, before_state: before_state)
190
+ raise
129
191
  end
130
192
  end
131
193
 
132
194
  # Update the registry record (always update, even if trigger doesn't exist)
133
195
  begin
134
196
  update!(enabled: false)
197
+ after_state = capture_state
198
+ log_audit_success(:trigger_disable, actor, before_state: before_state, after_state: after_state)
135
199
  rescue ActiveRecord::StatementInvalid, StandardError => e
136
200
  # If update! fails, try update_column which bypasses validations and callbacks
137
201
  # and might not use execute in the same way
@@ -140,6 +204,8 @@ module PgSqlTriggers
140
204
  # rubocop:disable Rails/SkipsModelValidations
141
205
  update_column(:enabled, false)
142
206
  # rubocop:enable Rails/SkipsModelValidations
207
+ after_state = capture_state
208
+ log_audit_success(:trigger_disable, actor, before_state: before_state, after_state: after_state)
143
209
  rescue StandardError => update_error
144
210
  # If update_column also fails, just set the in-memory attribute
145
211
  # The test might reload, but we've done our best
@@ -147,17 +213,30 @@ module PgSqlTriggers
147
213
  Rails.logger.warn("Could not update registry via update_column: #{update_error.message}") if defined?(Rails.logger)
148
214
  # rubocop:enable Layout/LineLength
149
215
  self.enabled = false
216
+ after_state = capture_state
217
+ log_audit_success(:trigger_disable, actor, before_state: before_state, after_state: after_state)
150
218
  end
151
219
  end
152
220
  end
153
221
 
222
+ # Drops this trigger from the database and removes it from the registry.
223
+ #
224
+ # @param reason [String] Required reason for dropping the trigger
225
+ # @param confirmation [String, nil] Optional confirmation text for kill switch protection
226
+ # @param actor [Hash, nil] Information about who is performing the action (must have :type and :id keys)
227
+ # @raise [ArgumentError] If reason is missing or empty
228
+ # @raise [PgSqlTriggers::KillSwitchError] If kill switch blocks the operation
229
+ # @return [true] If drop succeeds
154
230
  def drop!(reason:, confirmation: nil, actor: nil)
231
+ actor ||= { type: "Console", id: "TriggerRegistry#drop!" }
232
+ before_state = capture_state
233
+
155
234
  # Check kill switch before dropping trigger
156
235
  PgSqlTriggers::SQL::KillSwitch.check!(
157
236
  operation: :trigger_drop,
158
237
  environment: Rails.env,
159
238
  confirmation: confirmation,
160
- actor: actor || { type: "Console", id: "TriggerRegistry#drop!" }
239
+ actor: actor
161
240
  )
162
241
 
163
242
  # Validate reason is provided
@@ -168,18 +247,41 @@ module PgSqlTriggers
168
247
  # Execute DROP TRIGGER in transaction
169
248
  ActiveRecord::Base.transaction do
170
249
  drop_trigger_from_database
250
+ trigger_name
171
251
  destroy!
172
252
  log_drop_success
253
+ log_audit_success(:trigger_drop, actor, reason: reason, confirmation_text: confirmation,
254
+ before_state: before_state, after_state: { status: "dropped" })
173
255
  end
256
+ rescue StandardError => e
257
+ log_audit_failure(:trigger_drop, actor, e.message, reason: reason,
258
+ confirmation_text: confirmation, before_state: before_state)
259
+ raise
174
260
  end
175
261
 
262
+ # Re-executes this trigger by dropping and recreating it.
263
+ #
264
+ # @param reason [String] Required reason for re-executing the trigger
265
+ # @param confirmation [String, nil] Optional confirmation text for kill switch protection
266
+ # @param actor [Hash, nil] Information about who is performing the action (must have :type and :id keys)
267
+ # @raise [ArgumentError] If reason is missing or empty, or if function_body is blank
268
+ # @raise [PgSqlTriggers::KillSwitchError] If kill switch blocks the operation
269
+ # @return [PgSqlTriggers::TriggerRegistry] self
176
270
  def re_execute!(reason:, confirmation: nil, actor: nil)
271
+ actor ||= { type: "Console", id: "TriggerRegistry#re_execute!" }
272
+ before_state = capture_state
273
+ drift_info = begin
274
+ drift_result
275
+ rescue StandardError
276
+ nil
277
+ end
278
+
177
279
  # Check kill switch before re-executing trigger
178
280
  PgSqlTriggers::SQL::KillSwitch.check!(
179
281
  operation: :trigger_re_execute,
180
282
  environment: Rails.env,
181
283
  confirmation: confirmation,
182
- actor: actor || { type: "Console", id: "TriggerRegistry#re_execute!" }
284
+ actor: actor
183
285
  )
184
286
 
185
287
  # Validate reason is provided
@@ -193,7 +295,18 @@ module PgSqlTriggers
193
295
  drop_existing_trigger_for_re_execute
194
296
  recreate_trigger
195
297
  update_registry_after_re_execute
298
+ after_state = capture_state
299
+ diff = drift_info ? "#{drift_info[:expected_sql]} -> #{after_state[:function_body]}" : nil
300
+ log_audit_success(:trigger_re_execute, actor, reason: reason, confirmation_text: confirmation,
301
+ before_state: before_state, after_state: after_state, diff: diff)
196
302
  end
303
+ rescue StandardError => e
304
+ log_audit_failure(
305
+ :trigger_re_execute, actor, e.message, reason: reason,
306
+ confirmation_text: confirmation,
307
+ before_state: before_state
308
+ )
309
+ raise
197
310
  end
198
311
 
199
312
  private
@@ -282,9 +395,13 @@ module PgSqlTriggers
282
395
 
283
396
  def recreate_trigger
284
397
  ActiveRecord::Base.connection.execute(function_body)
285
- Rails.logger.info "[TRIGGER_RE_EXECUTE] Re-created trigger" if defined?(Rails.logger)
398
+ if defined?(Rails) && Rails.respond_to?(:logger) && Rails.logger
399
+ Rails.logger.info "[TRIGGER_RE_EXECUTE] Re-created trigger"
400
+ end
286
401
  rescue ActiveRecord::StatementInvalid, StandardError => e
287
- Rails.logger.error("[TRIGGER_RE_EXECUTE] Failed: #{e.message}") if defined?(Rails.logger)
402
+ if defined?(Rails) && Rails.respond_to?(:logger) && Rails.logger
403
+ Rails.logger.error("[TRIGGER_RE_EXECUTE] Failed: #{e.message}")
404
+ end
288
405
  raise
289
406
  end
290
407
 
@@ -292,6 +409,58 @@ module PgSqlTriggers
292
409
  update!(enabled: true, last_executed_at: Time.current)
293
410
  Rails.logger.info "[TRIGGER_RE_EXECUTE] Updated registry" if defined?(Rails.logger)
294
411
  end
412
+
413
+ # Audit logging helpers
414
+ def capture_state
415
+ {
416
+ enabled: enabled,
417
+ version: version,
418
+ checksum: checksum,
419
+ table_name: table_name,
420
+ source: source,
421
+ environment: environment,
422
+ installed_at: installed_at&.iso8601
423
+ }
424
+ end
425
+
426
+ # rubocop:disable Metrics/ParameterLists
427
+ def log_audit_success(operation, actor, reason: nil, confirmation_text: nil,
428
+ before_state: nil, after_state: nil, diff: nil)
429
+ return unless defined?(PgSqlTriggers::AuditLog)
430
+
431
+ PgSqlTriggers::AuditLog.log_success(
432
+ operation: operation,
433
+ trigger_name: trigger_name,
434
+ actor: actor,
435
+ environment: Rails.env,
436
+ reason: reason,
437
+ confirmation_text: confirmation_text,
438
+ before_state: before_state,
439
+ after_state: after_state,
440
+ diff: diff
441
+ )
442
+ rescue StandardError => e
443
+ Rails.logger.error("Failed to log audit entry: #{e.message}") if defined?(Rails.logger)
444
+ end
445
+
446
+ def log_audit_failure(operation, actor, error_message, reason: nil,
447
+ confirmation_text: nil, before_state: nil)
448
+ return unless defined?(PgSqlTriggers::AuditLog)
449
+
450
+ PgSqlTriggers::AuditLog.log_failure(
451
+ operation: operation,
452
+ trigger_name: trigger_name,
453
+ actor: actor,
454
+ environment: Rails.env,
455
+ error_message: error_message,
456
+ reason: reason,
457
+ confirmation_text: confirmation_text,
458
+ before_state: before_state
459
+ )
460
+ rescue StandardError => e
461
+ Rails.logger.error("Failed to log audit entry: #{e.message}") if defined?(Rails.logger)
462
+ end
463
+ # rubocop:enable Metrics/ParameterLists
295
464
  end
296
465
  # rubocop:enable Metrics/ClassLength
297
466
  end
@@ -18,7 +18,8 @@
18
18
  <div>
19
19
  <%= link_to "Dashboard", root_path, style: "color: white; margin-right: 1rem;" %>
20
20
  <%= link_to "Tables", tables_path, style: "color: white; margin-right: 1rem;" %>
21
- <%= link_to "Generator", new_generator_path, style: "color: white;" %>
21
+ <%= link_to "Generator", new_generator_path, style: "color: white; margin-right: 1rem;" %>
22
+ <%= link_to "Audit Log", audit_logs_path, style: "color: white;" %>
22
23
  </div>
23
24
  </div>
24
25
  </nav>
@@ -35,7 +36,7 @@
35
36
  <% if flash[:error] %>
36
37
  <div style="background: #f8d7da; color: #721c24; padding: 1rem; margin-bottom: 1rem; border-radius: 4px; border-left: 4px solid #dc3545;">
37
38
  <% error_message = flash[:error].to_s %>
38
- <% if error_message.include?("Kill switch is active") %>
39
+ <% if error_message.include?("Kill switch is active") || error_message.include?("KILL_SWITCH") %>
39
40
  <%# Format kill switch error messages nicely %>
40
41
  <% lines = error_message.split("\n") %>
41
42
 
@@ -55,16 +56,35 @@
55
56
 
56
57
  <%# Instructions section with preserved formatting %>
57
58
  <div style="margin-top: 0.5rem;">
58
- <%# Extract and format the instructions portion %>
59
- <% instructions_start = error_message.index("To override") || 0 %>
60
- <% instructions_text = error_message[instructions_start..-1] %>
59
+ <%# Extract and format the instructions/recovery portion %>
60
+ <% recovery_start = error_message.index("Recovery:") || error_message.index("To override") || 0 %>
61
+ <% recovery_text = error_message[recovery_start..-1] %>
62
+ <% if recovery_text.start_with?("Recovery:") %>
63
+ <% recovery_text = recovery_text.sub("Recovery:", "").strip %>
64
+ <% end %>
61
65
  <div style="white-space: pre-wrap; word-wrap: break-word; font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, 'Helvetica Neue', Arial, sans-serif; line-height: 1.6;">
62
- <%= instructions_text.strip %>
66
+ <%= recovery_text.strip %>
67
+ </div>
68
+ </div>
69
+ <% elsif error_message.include?("Permission denied") || error_message.include?("PERMISSION_DENIED") %>
70
+ <%# Format permission error messages %>
71
+ <div style="display: flex; align-items: flex-start; margin-bottom: 0.5rem;">
72
+ <span style="font-size: 1.5em; margin-right: 0.75rem;">🔒</span>
73
+ <div style="flex: 1;">
74
+ <div style="font-weight: bold; font-size: 1.1em; margin-bottom: 0.5rem;">Permission Denied</div>
75
+ <div style="white-space: pre-wrap; word-wrap: break-word;"><%= error_message %></div>
63
76
  </div>
64
77
  </div>
65
78
  <% else %>
66
79
  <%# Regular error message - preserve formatting %>
67
80
  <div style="white-space: pre-wrap; word-wrap: break-word;"><%= error_message %></div>
81
+ <% if Rails.env.development? && defined?(exception) && exception %>
82
+ <%# Show stack trace in development %>
83
+ <details style="margin-top: 1rem; padding-top: 1rem; border-top: 1px solid #f5c6cb;">
84
+ <summary style="cursor: pointer; font-weight: bold; color: #721c24;">Stack Trace (Development Only)</summary>
85
+ <pre style="background: rgba(0,0,0,0.05); padding: 0.5rem; margin-top: 0.5rem; border-radius: 4px; overflow-x: auto; font-size: 0.85em;"><%= exception.backtrace.first(10).join("\n") %></pre>
86
+ </details>
87
+ <% end %>
68
88
  <% end %>
69
89
  </div>
70
90
  <% end %>