pg_sql_triggers 1.4.0 → 1.5.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 (107) hide show
  1. checksums.yaml +4 -4
  2. data/.erb_lint.yml +0 -0
  3. data/.rspec +0 -0
  4. data/.rubocop.yml +6 -16
  5. data/AGENTS.md +8 -0
  6. data/CHANGELOG.md +104 -2
  7. data/COVERAGE.md +39 -41
  8. data/LICENSE +0 -0
  9. data/README.md +24 -3
  10. data/RELEASE.md +0 -0
  11. data/Rakefile +5 -0
  12. data/app/assets/javascripts/pg_sql_triggers/application.js +0 -0
  13. data/app/assets/javascripts/pg_sql_triggers/trigger_actions.js +0 -0
  14. data/app/assets/stylesheets/pg_sql_triggers/application.css +0 -0
  15. data/app/controllers/concerns/pg_sql_triggers/error_handling.rb +0 -0
  16. data/app/controllers/concerns/pg_sql_triggers/kill_switch_protection.rb +0 -0
  17. data/app/controllers/concerns/pg_sql_triggers/permission_checking.rb +6 -5
  18. data/app/controllers/pg_sql_triggers/application_controller.rb +0 -0
  19. data/app/controllers/pg_sql_triggers/audit_logs_controller.rb +81 -64
  20. data/app/controllers/pg_sql_triggers/dashboard_controller.rb +111 -34
  21. data/app/controllers/pg_sql_triggers/migrations_controller.rb +13 -14
  22. data/app/controllers/pg_sql_triggers/tables_controller.rb +8 -0
  23. data/app/controllers/pg_sql_triggers/triggers_controller.rb +1 -0
  24. data/app/helpers/pg_sql_triggers/dashboard_helper.rb +19 -0
  25. data/app/helpers/pg_sql_triggers/permissions_helper.rb +3 -2
  26. data/app/models/pg_sql_triggers/application_record.rb +0 -0
  27. data/app/models/pg_sql_triggers/audit_log.rb +29 -47
  28. data/app/models/pg_sql_triggers/trigger_registry.rb +105 -78
  29. data/app/views/layouts/pg_sql_triggers/application.html.erb +0 -0
  30. data/app/views/pg_sql_triggers/audit_logs/index.html.erb +9 -5
  31. data/app/views/pg_sql_triggers/dashboard/index.html.erb +107 -24
  32. data/app/views/pg_sql_triggers/shared/_confirmation_modal.html.erb +0 -0
  33. data/app/views/pg_sql_triggers/shared/_kill_switch_status.html.erb +0 -0
  34. data/app/views/pg_sql_triggers/tables/index.html.erb +26 -14
  35. data/app/views/pg_sql_triggers/tables/show.html.erb +0 -0
  36. data/app/views/pg_sql_triggers/triggers/_drop_modal.html.erb +0 -0
  37. data/app/views/pg_sql_triggers/triggers/_re_execute_modal.html.erb +0 -0
  38. data/app/views/pg_sql_triggers/triggers/show.html.erb +33 -0
  39. data/config/initializers/pg_sql_triggers.rb +0 -0
  40. data/config/routes.rb +0 -0
  41. data/db/migrate/{20251222000001_create_pg_sql_triggers_tables.rb → 20251222104327_create_pg_sql_triggers_tables.rb} +0 -0
  42. data/db/migrate/20251229071916_add_timing_to_pg_sql_triggers_registry.rb +0 -0
  43. data/db/migrate/{20260103000001_create_pg_sql_triggers_audit_log.rb → 20260103114508_create_pg_sql_triggers_audit_log.rb} +0 -0
  44. data/db/migrate/{20260228000001_add_for_each_to_pg_sql_triggers_registry.rb → 20260228162233_add_for_each_to_pg_sql_triggers_registry.rb} +0 -0
  45. data/db/migrate/20260412185841_add_constraint_deferral_to_pg_sql_triggers_registry.rb +9 -0
  46. data/docs/README.md +3 -0
  47. data/docs/api-reference.md +133 -0
  48. data/docs/audit-trail.md +1 -1
  49. data/docs/configuration.md +172 -0
  50. data/docs/getting-started.md +14 -0
  51. data/docs/kill-switch.md +0 -0
  52. data/docs/permissions.md +6 -9
  53. data/docs/troubleshooting.md +0 -0
  54. data/docs/ui-guide.md +0 -0
  55. data/docs/usage-guide.md +74 -0
  56. data/docs/web-ui.md +0 -0
  57. data/lib/generators/pg_sql_triggers/install_generator.rb +0 -0
  58. data/lib/generators/pg_sql_triggers/templates/README +0 -0
  59. data/lib/generators/pg_sql_triggers/templates/create_pg_sql_triggers_tables.rb +0 -0
  60. data/lib/generators/pg_sql_triggers/templates/initializer.rb +14 -0
  61. data/lib/generators/pg_sql_triggers/templates/trigger_dsl.rb.tt +0 -0
  62. data/lib/generators/pg_sql_triggers/templates/trigger_migration.rb.erb +0 -0
  63. data/lib/generators/pg_sql_triggers/templates/trigger_migration_full.rb.tt +0 -0
  64. data/lib/generators/pg_sql_triggers/trigger_generator.rb +0 -0
  65. data/lib/generators/pg_sql_triggers/trigger_migration_generator.rb +0 -0
  66. data/lib/pg_sql_triggers/alerting.rb +77 -0
  67. data/lib/pg_sql_triggers/database_introspection.rb +0 -0
  68. data/lib/pg_sql_triggers/deferral_checksum.rb +54 -0
  69. data/lib/pg_sql_triggers/drift/db_queries.rb +14 -5
  70. data/lib/pg_sql_triggers/drift/detector.rb +9 -1
  71. data/lib/pg_sql_triggers/drift/reporter.rb +0 -0
  72. data/lib/pg_sql_triggers/drift.rb +5 -0
  73. data/lib/pg_sql_triggers/dsl/trigger_definition.rb +56 -2
  74. data/lib/pg_sql_triggers/dsl.rb +0 -0
  75. data/lib/pg_sql_triggers/engine.rb +35 -0
  76. data/lib/pg_sql_triggers/errors.rb +0 -0
  77. data/lib/pg_sql_triggers/events_checksum.rb +114 -0
  78. data/lib/pg_sql_triggers/migration.rb +5 -6
  79. data/lib/pg_sql_triggers/migrator/pre_apply_comparator.rb +77 -73
  80. data/lib/pg_sql_triggers/migrator/pre_apply_diff_reporter.rb +0 -0
  81. data/lib/pg_sql_triggers/migrator/safety_validator.rb +3 -1
  82. data/lib/pg_sql_triggers/migrator.rb +90 -94
  83. data/lib/pg_sql_triggers/permissions/checker.rb +12 -15
  84. data/lib/pg_sql_triggers/permissions.rb +1 -0
  85. data/lib/pg_sql_triggers/rake_development_boot.rb +65 -0
  86. data/lib/pg_sql_triggers/registry/manager.rb +27 -13
  87. data/lib/pg_sql_triggers/registry/validator.rb +226 -2
  88. data/lib/pg_sql_triggers/registry.rb +0 -0
  89. data/lib/pg_sql_triggers/schema_dumper_extension.rb +32 -0
  90. data/lib/pg_sql_triggers/sql/kill_switch.rb +2 -1
  91. data/lib/pg_sql_triggers/sql.rb +0 -0
  92. data/lib/pg_sql_triggers/testing/dry_run.rb +0 -0
  93. data/lib/pg_sql_triggers/testing/function_tester.rb +97 -107
  94. data/lib/pg_sql_triggers/testing/safe_executor.rb +0 -0
  95. data/lib/pg_sql_triggers/testing/syntax_validator.rb +0 -0
  96. data/lib/pg_sql_triggers/testing.rb +0 -0
  97. data/lib/pg_sql_triggers/trigger_structure_dumper.rb +111 -0
  98. data/lib/pg_sql_triggers/version.rb +1 -1
  99. data/lib/pg_sql_triggers.rb +17 -0
  100. data/lib/tasks/trigger_migrations.rake +235 -152
  101. data/rakelib/pg_sql_triggers_environment.rake +9 -0
  102. data/scripts/generate_coverage_report.rb +4 -1
  103. data/sig/pg_sql_triggers.rbs +0 -0
  104. metadata +65 -13
  105. data/GEM_ANALYSIS.md +0 -368
  106. data/Goal.md +0 -742
  107. data/pg_sql_triggers.gemspec +0 -53
@@ -21,7 +21,9 @@ module PgSqlTriggers
21
21
  # @example Drop and re-execute triggers
22
22
  # trigger.drop!(reason: "No longer needed", actor: current_user, confirmation: "EXECUTE TRIGGER_DROP")
23
23
  # trigger.re_execute!(reason: "Fix drift", actor: current_user, confirmation: "EXECUTE TRIGGER_RE_EXECUTE")
24
- # rubocop:disable Metrics/ClassLength
24
+ # rubocop:disable Metrics/ClassLength -- core AR model: groups lifecycle operations
25
+ # (enable!/disable!/drop!/re_execute!), drift helpers, audit hooks, and SQL builders.
26
+ # Splitting further would fragment tightly-coupled state and audit concerns.
25
27
  class TriggerRegistry < PgSqlTriggers::ApplicationRecord
26
28
  self.table_name = "pg_sql_triggers_registry"
27
29
 
@@ -39,6 +41,16 @@ module PgSqlTriggers
39
41
  scope :for_environment, ->(env) { where(environment: [env, nil]) }
40
42
  scope :by_source, ->(source) { where(source: source) }
41
43
 
44
+ # Case-insensitive search on trigger name and table name (used by web dashboard).
45
+ scope :matching_search, lambda { |raw|
46
+ query = raw.to_s.strip
47
+ next all if query.blank?
48
+
49
+ sanitized = ActiveRecord::Base.sanitize_sql_like(query)
50
+ term = "%#{sanitized}%"
51
+ where("trigger_name ILIKE :term OR table_name ILIKE :term", term: term)
52
+ }
53
+
42
54
  # Returns the current drift state of this trigger.
43
55
  #
44
56
  # @return [String] One of: "in_sync", "drifted", "manual_override", "disabled", "dropped", "unknown"
@@ -119,32 +131,12 @@ module PgSqlTriggers
119
131
  end
120
132
  end
121
133
 
122
- # Update the registry record (always update, even if trigger doesn't exist)
123
- begin
124
- update!(enabled: true)
125
- after_state = capture_state
126
- log_audit_success(:trigger_enable, actor, before_state: before_state, after_state: after_state)
127
- rescue ActiveRecord::StatementInvalid, StandardError => e
128
- # If update! fails, try update_column which bypasses validations and callbacks
129
- # and might not use execute in the same way
130
- Rails.logger.warn("Could not update registry via update!: #{e.message}") if defined?(Rails.logger)
131
- begin
132
- # rubocop:disable Rails/SkipsModelValidations
133
- update_column(:enabled, true)
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)
137
- rescue StandardError => update_error
138
- # If update_column also fails, just set the in-memory attribute
139
- # The test might reload, but we've done our best
140
- # rubocop:disable Layout/LineLength
141
- Rails.logger.warn("Could not update registry via update_column: #{update_error.message}") if defined?(Rails.logger)
142
- # rubocop:enable Layout/LineLength
143
- self.enabled = true
144
- after_state = capture_state
145
- log_audit_success(:trigger_enable, actor, before_state: before_state, after_state: after_state)
146
- end
147
- end
134
+ # Update the registry record (always update, even if trigger doesn't exist).
135
+ # If persistence fails for any reason, fall back to the in-memory attribute so
136
+ # callers/observers still see a consistent state for this request.
137
+ persist_enabled_state(true)
138
+ after_state = capture_state
139
+ log_audit_success(:trigger_enable, actor, before_state: before_state, after_state: after_state)
148
140
  end
149
141
 
150
142
  # Disables this trigger in the database and updates the registry.
@@ -191,32 +183,12 @@ module PgSqlTriggers
191
183
  end
192
184
  end
193
185
 
194
- # Update the registry record (always update, even if trigger doesn't exist)
195
- begin
196
- update!(enabled: false)
197
- after_state = capture_state
198
- log_audit_success(:trigger_disable, actor, before_state: before_state, after_state: after_state)
199
- rescue ActiveRecord::StatementInvalid, StandardError => e
200
- # If update! fails, try update_column which bypasses validations and callbacks
201
- # and might not use execute in the same way
202
- Rails.logger.warn("Could not update registry via update!: #{e.message}") if defined?(Rails.logger)
203
- begin
204
- # rubocop:disable Rails/SkipsModelValidations
205
- update_column(:enabled, false)
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)
209
- rescue StandardError => update_error
210
- # If update_column also fails, just set the in-memory attribute
211
- # The test might reload, but we've done our best
212
- # rubocop:disable Layout/LineLength
213
- Rails.logger.warn("Could not update registry via update_column: #{update_error.message}") if defined?(Rails.logger)
214
- # rubocop:enable Layout/LineLength
215
- self.enabled = false
216
- after_state = capture_state
217
- log_audit_success(:trigger_disable, actor, before_state: before_state, after_state: after_state)
218
- end
219
- end
186
+ # Update the registry record (always update, even if trigger doesn't exist).
187
+ # If persistence fails for any reason, fall back to the in-memory attribute so
188
+ # callers/observers still see a consistent state for this request.
189
+ persist_enabled_state(false)
190
+ after_state = capture_state
191
+ log_audit_success(:trigger_disable, actor, before_state: before_state, after_state: after_state)
220
192
  end
221
193
 
222
194
  # Drops this trigger from the database and removes it from the registry.
@@ -263,8 +235,9 @@ module PgSqlTriggers
263
235
  # @param reason [String] Required reason for re-executing the trigger
264
236
  # @param confirmation [String, nil] Optional confirmation text for kill switch protection
265
237
  # @param actor [Hash, nil] Information about who is performing the action (must have :type and :id keys)
266
- # @raise [ArgumentError] If reason is missing or empty, or if function_body is blank
238
+ # @raise [ArgumentError] If reason is missing or empty
267
239
  # @raise [PgSqlTriggers::KillSwitchError] If kill switch blocks the operation
240
+ # @raise [StandardError] If SQL cannot be generated to recreate the trigger
268
241
  # @return [PgSqlTriggers::TriggerRegistry] self
269
242
  def re_execute!(reason:, confirmation: nil, actor: nil)
270
243
  actor ||= { type: "Console", id: "TriggerRegistry#re_execute!" }
@@ -314,6 +287,12 @@ module PgSqlTriggers
314
287
  end
315
288
 
316
289
  def calculate_checksum
290
+ deferral = PgSqlTriggers::DeferralChecksum.parts(
291
+ constraint_trigger: constraint_trigger,
292
+ deferrable: deferrable,
293
+ initially: initially
294
+ )
295
+ events_segment = PgSqlTriggers::EventsChecksum.segment_from_definition_json(definition)
317
296
  Digest::SHA256.hexdigest([
318
297
  trigger_name,
319
298
  table_name,
@@ -321,7 +300,9 @@ module PgSqlTriggers
321
300
  function_body || "",
322
301
  condition || "",
323
302
  timing || "before",
324
- for_each || "row"
303
+ for_each || "row",
304
+ events_segment,
305
+ *deferral
325
306
  ].join)
326
307
  end
327
308
 
@@ -393,8 +374,15 @@ module PgSqlTriggers
393
374
  end
394
375
 
395
376
  def recreate_trigger
396
- sql = function_body.presence || build_trigger_sql_from_definition
397
- raise StandardError, "Cannot re-execute: missing function_body" if sql.blank?
377
+ # DSL triggers are recreated from the stored JSON definition (+build_trigger_sql_from_definition+).
378
+ # Other sources may persist a full trigger SQL payload in +function_body+.
379
+ sql = if source == "dsl"
380
+ build_trigger_sql_from_definition
381
+ else
382
+ function_body.presence || build_trigger_sql_from_definition
383
+ end
384
+
385
+ raise StandardError, "Cannot re-execute: no SQL could be generated" if sql.blank?
398
386
 
399
387
  ActiveRecord::Base.connection.execute(sql)
400
388
  if defined?(Rails) && Rails.respond_to?(:logger) && Rails.logger
@@ -416,15 +404,25 @@ module PgSqlTriggers
416
404
  fn_name = defn["function_name"]
417
405
  return nil if fn_name.blank?
418
406
 
419
- t_name = table_name
420
- timing_kw = (defn["timing"] || timing || "BEFORE").upcase
421
- events = Array(defn["events"]).map { |e| e.to_s.upcase }.join(" OR ")
422
- events = "INSERT" if events.blank?
423
- cond = defn["condition"] || condition
407
+ t_name = table_name
408
+ constraint = ActiveModel::Type::Boolean.new.cast(defn["constraint_trigger"])
409
+ timing_kw = if constraint
410
+ "AFTER"
411
+ else
412
+ (defn["timing"] || timing || "before").to_s.upcase
413
+ end
414
+ events_sql = PgSqlTriggers::EventsChecksum.events_sql_fragment(
415
+ defn,
416
+ quote_column: ->(col) { quote_identifier(col) }
417
+ )
418
+ cond = defn["condition"] || condition
424
419
  for_each_kw = (defn["for_each"] || for_each || "row").upcase
425
420
 
426
- sql = "CREATE TRIGGER #{quote_identifier(trigger_name)} "
427
- sql += "#{timing_kw} #{events} ON #{quote_identifier(t_name)} "
421
+ create_kw = constraint ? "CREATE CONSTRAINT TRIGGER" : "CREATE TRIGGER"
422
+ sql = "#{create_kw} #{quote_identifier(trigger_name)} "
423
+ sql += "#{timing_kw} #{events_sql} ON #{quote_identifier(t_name)} "
424
+ deferral = deferral_sql_fragment(defn)
425
+ sql += "#{deferral} " if deferral.present?
428
426
  sql += "FOR EACH #{for_each_kw} "
429
427
  sql += "WHEN (#{cond}) " if cond.present?
430
428
  sql += "EXECUTE FUNCTION #{fn_name}();"
@@ -433,6 +431,26 @@ module PgSqlTriggers
433
431
  nil
434
432
  end
435
433
 
434
+ def deferral_sql_fragment(defn)
435
+ return "" unless ActiveModel::Type::Boolean.new.cast(defn["constraint_trigger"])
436
+
437
+ case defn["deferrable"].to_s
438
+ when "not_deferrable"
439
+ "NOT DEFERRABLE"
440
+ when "deferrable"
441
+ case defn["initially"].to_s
442
+ when "deferred"
443
+ "DEFERRABLE INITIALLY DEFERRED"
444
+ when "immediate"
445
+ "DEFERRABLE INITIALLY IMMEDIATE"
446
+ else
447
+ "DEFERRABLE"
448
+ end
449
+ else
450
+ ""
451
+ end
452
+ end
453
+
436
454
  def update_registry_after_re_execute
437
455
  update!(last_executed_at: Time.current)
438
456
  if !enabled && ActiveRecord::Base.connection.table_exists?(table_name)
@@ -459,9 +477,7 @@ module PgSqlTriggers
459
477
  }
460
478
  end
461
479
 
462
- # rubocop:disable Metrics/ParameterLists
463
- def log_audit_success(operation, actor, reason: nil, confirmation_text: nil,
464
- before_state: nil, after_state: nil, diff: nil)
480
+ def log_audit_success(operation, actor, **options)
465
481
  return unless defined?(PgSqlTriggers::AuditLog)
466
482
 
467
483
  PgSqlTriggers::AuditLog.log_success(
@@ -469,18 +485,13 @@ module PgSqlTriggers
469
485
  trigger_name: trigger_name,
470
486
  actor: actor,
471
487
  environment: Rails.env,
472
- reason: reason,
473
- confirmation_text: confirmation_text,
474
- before_state: before_state,
475
- after_state: after_state,
476
- diff: diff
488
+ **options
477
489
  )
478
490
  rescue StandardError => e
479
491
  Rails.logger.error("Failed to log audit entry: #{e.message}") if defined?(Rails.logger)
480
492
  end
481
493
 
482
- def log_audit_failure(operation, actor, error_message, reason: nil,
483
- confirmation_text: nil, before_state: nil)
494
+ def log_audit_failure(operation, actor, error_message, **options)
484
495
  return unless defined?(PgSqlTriggers::AuditLog)
485
496
 
486
497
  PgSqlTriggers::AuditLog.log_failure(
@@ -489,14 +500,30 @@ module PgSqlTriggers
489
500
  actor: actor,
490
501
  environment: Rails.env,
491
502
  error_message: error_message,
492
- reason: reason,
493
- confirmation_text: confirmation_text,
494
- before_state: before_state
503
+ **options
495
504
  )
496
505
  rescue StandardError => e
497
506
  Rails.logger.error("Failed to log audit entry: #{e.message}") if defined?(Rails.logger)
498
507
  end
499
- # rubocop:enable Metrics/ParameterLists
508
+
509
+ # Persist the +enabled+ flag: +update!+, then +update_column+ if needed, then in-memory.
510
+ # Returns true when a DB write succeeded, false when only the in-memory value changed.
511
+ def persist_enabled_state(value)
512
+ update!(enabled: value)
513
+ true
514
+ rescue ActiveRecord::StatementInvalid, StandardError => e
515
+ Rails.logger.warn("Could not update registry via update!: #{e.message}") if defined?(Rails.logger)
516
+ begin
517
+ # rubocop:disable Rails/SkipsModelValidations -- fallback when update! fails (callbacks/validations/locks)
518
+ update_column(:enabled, value)
519
+ # rubocop:enable Rails/SkipsModelValidations
520
+ true
521
+ rescue ActiveRecord::StatementInvalid, StandardError => e2
522
+ Rails.logger.warn("Could not update registry via update_column: #{e2.message}") if defined?(Rails.logger)
523
+ self.enabled = value
524
+ false
525
+ end
526
+ end
500
527
  end
501
528
  # rubocop:enable Metrics/ClassLength
502
529
  end
@@ -4,7 +4,7 @@
4
4
  <h1 style="margin: 0;">Audit Log</h1>
5
5
  <div style="display: flex; gap: 1rem;">
6
6
  <%= link_to "Dashboard", dashboard_path, style: "padding: 0.5rem 1rem; background: #6c757d; color: white; text-decoration: none; border-radius: 4px; display: inline-block;" %>
7
- <% csv_params = params.permit(:trigger_name, :operation, :status, :environment, :actor_id).to_h %>
7
+ <% csv_params = params.permit(:trigger_name, :operation, :status, :environment, :actor_id, :q).to_h %>
8
8
  <%= link_to "Export CSV", audit_logs_path(csv_params.merge(format: :csv)), style: "padding: 0.5rem 1rem; background: #28a745; color: white; text-decoration: none; border-radius: 4px; display: inline-block;" %>
9
9
  </div>
10
10
  </div>
@@ -13,6 +13,10 @@
13
13
  <div style="background: white; padding: 1.5rem; border-radius: 4px; box-shadow: 0 1px 3px rgba(0,0,0,0.1); margin-bottom: 2rem;">
14
14
  <h3 style="margin-top: 0; margin-bottom: 1rem;">Filters</h3>
15
15
  <%= form_with url: audit_logs_path, method: :get, local: true, style: "display: grid; grid-template-columns: repeat(auto-fit, minmax(200px, 1fr)); gap: 1rem;" do |f| %>
16
+ <div style="grid-column: 1 / -1;">
17
+ <%= label_tag :q, "Search", style: "display: block; margin-bottom: 0.5rem; font-weight: 600;" %>
18
+ <%= text_field_tag :q, params[:q], placeholder: "Trigger, operation, reason, error…", style: "width: 100%; padding: 0.5rem; border: 1px solid #ced4da; border-radius: 4px;" %>
19
+ </div>
16
20
  <div>
17
21
  <%= f.label :trigger_name, "Trigger Name", style: "display: block; margin-bottom: 0.5rem; font-weight: 600;" %>
18
22
  <%= f.select :trigger_name, options_for_select([["All", ""]] + @available_trigger_names.map { |n| [n, n] }, params[:trigger_name]), {}, { style: "width: 100%; padding: 0.5rem; border: 1px solid #ced4da; border-radius: 4px;" } %>
@@ -48,7 +52,7 @@
48
52
  <!-- Results Summary -->
49
53
  <div style="background: #f8f9fa; padding: 1rem; border-radius: 4px; margin-bottom: 1rem;">
50
54
  <strong>Total Results:</strong> <%= @total_count %> entries
51
- <% if params[:trigger_name].present? || params[:operation].present? || params[:status].present? || params[:environment].present? %>
55
+ <% if params[:trigger_name].present? || params[:operation].present? || params[:status].present? || params[:environment].present? || params[:q].present? %>
52
56
  <span style="color: #6c757d;">(filtered)</span>
53
57
  <% end %>
54
58
  </div>
@@ -144,7 +148,7 @@
144
148
  <% if @total_pages > 1 %>
145
149
  <div style="display: flex; justify-content: center; align-items: center; gap: 1rem; margin-top: 2rem;">
146
150
  <% if @page > 1 %>
147
- <% prev_params = params.except(:page).permit(:trigger_name, :operation, :status, :environment, :sort, :per_page).to_h %>
151
+ <% prev_params = params.except(:page).permit(:trigger_name, :operation, :status, :environment, :sort, :per_page, :q).to_h %>
148
152
  <%= link_to "« Previous", audit_logs_path(prev_params.merge(page: @page - 1)), style: "padding: 0.5rem 1rem; background: #007bff; color: white; text-decoration: none; border-radius: 4px;" %>
149
153
  <% else %>
150
154
  <span style="padding: 0.5rem 1rem; background: #e9ecef; color: #6c757d; border-radius: 4px; cursor: not-allowed;">« Previous</span>
@@ -155,7 +159,7 @@
155
159
  </span>
156
160
 
157
161
  <% if @page < @total_pages %>
158
- <% next_params = params.except(:page).permit(:trigger_name, :operation, :status, :environment, :sort, :per_page).to_h %>
162
+ <% next_params = params.except(:page).permit(:trigger_name, :operation, :status, :environment, :sort, :per_page, :q).to_h %>
159
163
  <%= link_to "Next »", audit_logs_path(next_params.merge(page: @page + 1)), style: "padding: 0.5rem 1rem; background: #007bff; color: white; text-decoration: none; border-radius: 4px;" %>
160
164
  <% else %>
161
165
  <span style="padding: 0.5rem 1rem; background: #e9ecef; color: #6c757d; border-radius: 4px; cursor: not-allowed;">Next »</span>
@@ -166,7 +170,7 @@
166
170
  <div style="background: white; padding: 3rem; border-radius: 4px; box-shadow: 0 1px 3px rgba(0,0,0,0.1); text-align: center;">
167
171
  <h3 style="margin-top: 0; color: #6c757d;">No audit log entries found</h3>
168
172
  <p style="color: #6c757d;">
169
- <% if params[:trigger_name].present? || params[:operation].present? || params[:status].present? || params[:environment].present? %>
173
+ <% if params[:trigger_name].present? || params[:operation].present? || params[:status].present? || params[:environment].present? || params[:q].present? %>
170
174
  Try adjusting your filters or <%= link_to "clear filters", audit_logs_path, style: "color: #007bff;" %>.
171
175
  <% else %>
172
176
  Audit log entries will appear here as operations are performed.
@@ -27,8 +27,67 @@
27
27
  </div>
28
28
  </div>
29
29
 
30
- <% if @triggers.any? %>
31
- <h3>Recent Triggers</h3>
30
+ <% if @stats[:total].positive? %>
31
+ <div style="background: white; padding: 1rem 1.5rem; border-radius: 4px; box-shadow: 0 1px 3px rgba(0,0,0,0.1); margin-bottom: 2rem;">
32
+ <h3 style="margin-top: 0; margin-bottom: 1rem;">Search &amp; filters</h3>
33
+ <%= form_with url: dashboard_path, method: :get, local: true, style: "display: grid; grid-template-columns: repeat(auto-fit, minmax(180px, 1fr)); gap: 1rem; align-items: end;" do %>
34
+ <%= hidden_field_tag :page, params[:page] if params[:page].present? %>
35
+ <%= hidden_field_tag :per_page, params[:per_page] if params[:per_page].present? %>
36
+ <%= hidden_field_tag :trigger_page, 1 %>
37
+ <%= hidden_field_tag :trigger_per_page, @trigger_per_page %>
38
+ <div>
39
+ <label style="display: block; margin-bottom: 0.35rem; font-weight: 600; font-size: 0.875rem; color: #495057;">Search</label>
40
+ <%= text_field_tag :q, @filter_query, placeholder: "Trigger or table name…", style: "width: 100%; padding: 0.5rem; border: 1px solid #ced4da; border-radius: 4px;" %>
41
+ </div>
42
+ <div>
43
+ <label style="display: block; margin-bottom: 0.35rem; font-weight: 600; font-size: 0.875rem; color: #495057;">Table</label>
44
+ <%= select_tag :table, options_for_select([["All tables", ""]] + @filter_table_names.map { |t| [t, t] }, @filter_table), style: "width: 100%; padding: 0.5rem; border: 1px solid #ced4da; border-radius: 4px;" %>
45
+ </div>
46
+ <div>
47
+ <label style="display: block; margin-bottom: 0.35rem; font-weight: 600; font-size: 0.875rem; color: #495057;">Drift state</label>
48
+ <%= select_tag :state, options_for_select([
49
+ ["Any state", ""],
50
+ ["In sync", "in_sync"],
51
+ ["Drifted", "drifted"],
52
+ ["Disabled", "disabled"],
53
+ ["Dropped", "dropped"],
54
+ ["Unknown (external)", "unknown"],
55
+ ["Manual override", "manual_override"]
56
+ ], @filter_state), style: "width: 100%; padding: 0.5rem; border: 1px solid #ced4da; border-radius: 4px;" %>
57
+ </div>
58
+ <div>
59
+ <label style="display: block; margin-bottom: 0.35rem; font-weight: 600; font-size: 0.875rem; color: #495057;">Source</label>
60
+ <%= select_tag :source, options_for_select([
61
+ ["Any source", ""],
62
+ ["DSL", "dsl"],
63
+ ["Generated", "generated"],
64
+ ["Manual SQL", "manual_sql"]
65
+ ], @filter_source), style: "width: 100%; padding: 0.5rem; border: 1px solid #ced4da; border-radius: 4px;" %>
66
+ </div>
67
+ <div style="display: flex; gap: 0.5rem; flex-wrap: wrap;">
68
+ <%= submit_tag "Apply", style: "padding: 0.5rem 1rem; background: #007bff; color: white; border: none; border-radius: 4px; cursor: pointer; font-weight: 600;" %>
69
+ <%= link_to "Clear", dashboard_path(page: @page, per_page: @per_page), style: "padding: 0.5rem 1rem; background: #6c757d; color: white; text-decoration: none; border-radius: 4px; display: inline-block; line-height: 1.25;" %>
70
+ </div>
71
+ <% end %>
72
+ </div>
73
+ <% end %>
74
+
75
+ <% if @stats[:total].zero? %>
76
+ <div style="background: #e7f3ff; border-left: 4px solid #007bff; padding: 1.5rem; border-radius: 4px; margin-bottom: 2rem;">
77
+ <h3 style="margin-top: 0;">No triggers yet</h3>
78
+ <p style="margin-bottom: 1rem;">Get started by generating your first trigger using the form-based wizard.</p>
79
+ <%= link_to "View Tables", tables_path, class: "btn btn-primary" %>
80
+ </div>
81
+ <% elsif @trigger_list_total.zero? %>
82
+ <div style="background: #fff3cd; border-left: 4px solid #ffc107; padding: 1.5rem; border-radius: 4px; margin-bottom: 2rem;">
83
+ <h3 style="margin-top: 0;">No triggers match your filters</h3>
84
+ <p style="margin-bottom: 0;">Try adjusting search or filters, or <%= link_to "clear filters", dashboard_path(page: @page, per_page: @per_page), style: "color: #007bff;" %>.</p>
85
+ </div>
86
+ <% else %>
87
+ <h3>Triggers</h3>
88
+ <div style="margin-bottom: 1rem; color: #6c757d; font-size: 0.875rem;">
89
+ Showing <%= (@trigger_page - 1) * @trigger_per_page + 1 %>–<%= [@trigger_page * @trigger_per_page, @trigger_list_total].min %> of <%= @trigger_list_total %> trigger<%= @trigger_list_total == 1 ? "" : "s" %>
90
+ </div>
32
91
  <table>
33
92
  <thead>
34
93
  <tr>
@@ -42,7 +101,7 @@
42
101
  </tr>
43
102
  </thead>
44
103
  <tbody>
45
- <% @triggers.limit(10).each do |trigger| %>
104
+ <% @triggers.each do |trigger| %>
46
105
  <tr>
47
106
  <td>
48
107
  <%= link_to trigger.trigger_name, trigger_path(trigger), style: "color: #007bff; text-decoration: none; font-weight: 600;" %>
@@ -72,7 +131,7 @@
72
131
  <% if trigger.enabled %>
73
132
  <% form_id = "trigger-disable-#{trigger.id}-form" %>
74
133
  <%= form_with url: disable_trigger_path(trigger), method: :post, local: false, id: form_id, style: "margin: 0;" do |f| %>
75
- <%= f.hidden_field :redirect_to, value: dashboard_path %>
134
+ <%= f.hidden_field :redirect_to, value: request.fullpath %>
76
135
  <button type="button" onclick="showKillSwitchModal('<%= form_id %>')"
77
136
  style="padding: 0.25rem 0.75rem; background: #dc3545; color: white; border: none; border-radius: 3px; cursor: pointer; font-size: 0.75rem;">
78
137
  Disable
@@ -86,7 +145,7 @@
86
145
  <% else %>
87
146
  <% form_id = "trigger-enable-#{trigger.id}-form" %>
88
147
  <%= form_with url: enable_trigger_path(trigger), method: :post, local: false, id: form_id, style: "margin: 0;" do |f| %>
89
- <%= f.hidden_field :redirect_to, value: dashboard_path %>
148
+ <%= f.hidden_field :redirect_to, value: request.fullpath %>
90
149
  <button type="button" onclick="showKillSwitchModal('<%= form_id %>')"
91
150
  style="padding: 0.25rem 0.75rem; background: #28a745; color: white; border: none; border-radius: 3px; cursor: pointer; font-size: 0.75rem;">
92
151
  Enable
@@ -104,13 +163,13 @@
104
163
  <% begin %>
105
164
  <% drift_info = trigger.drift_result %>
106
165
  <% if drift_info && drift_info[:state] == 'drifted' %>
107
- <%= render 'pg_sql_triggers/triggers/re_execute_modal', trigger: trigger, drift_info: drift_info, redirect_to: dashboard_path, button_size: :small %>
166
+ <%= render 'pg_sql_triggers/triggers/re_execute_modal', trigger: trigger, drift_info: drift_info, redirect_to: request.fullpath, button_size: :small %>
108
167
  <% end %>
109
168
  <% rescue StandardError %>
110
169
  <%# Skip if drift detection fails %>
111
170
  <% end %>
112
171
 
113
- <%= render 'pg_sql_triggers/triggers/drop_modal', trigger: trigger, redirect_to: dashboard_path, button_size: :small %>
172
+ <%= render 'pg_sql_triggers/triggers/drop_modal', trigger: trigger, redirect_to: request.fullpath, button_size: :small %>
114
173
  <% end %>
115
174
 
116
175
  <% unless PgSqlTriggers::Permissions.can?(current_actor, :enable_trigger) || PgSqlTriggers::Permissions.can?(current_actor, :drop_trigger) %>
@@ -122,11 +181,35 @@
122
181
  <% end %>
123
182
  </tbody>
124
183
  </table>
125
- <% else %>
126
- <div style="background: #e7f3ff; border-left: 4px solid #007bff; padding: 1.5rem; border-radius: 4px;">
127
- <h3 style="margin-top: 0;">No triggers yet</h3>
128
- <p style="margin-bottom: 1rem;">Get started by generating your first trigger using the form-based wizard.</p>
129
- <%= link_to "View Tables", tables_path, class: "btn btn-primary" %>
184
+
185
+ <div style="display: flex; justify-content: space-between; align-items: center; margin-top: 1rem; padding-top: 1rem; border-top: 1px solid #dee2e6; flex-wrap: wrap; gap: 0.75rem;">
186
+ <div>
187
+ <% if @trigger_total_pages > 1 %>
188
+ <% if @trigger_page > 1 %>
189
+ <%= link_to "← Previous", dashboard_path(dashboard_list_params(trigger_page: @trigger_page - 1)),
190
+ style: "padding: 0.5rem 1rem; background: #007bff; color: white; text-decoration: none; border-radius: 4px; margin-right: 0.5rem;" %>
191
+ <% end %>
192
+ <% if @trigger_page < @trigger_total_pages %>
193
+ <%= link_to "Next →", dashboard_path(dashboard_list_params(trigger_page: @trigger_page + 1)),
194
+ style: "padding: 0.5rem 1rem; background: #007bff; color: white; text-decoration: none; border-radius: 4px;" %>
195
+ <% end %>
196
+ <% end %>
197
+ </div>
198
+ <% if @trigger_total_pages > 1 %>
199
+ <div style="color: #6c757d; font-size: 0.875rem;">
200
+ Page <%= @trigger_page %> of <%= @trigger_total_pages %>
201
+ </div>
202
+ <% end %>
203
+ <%= form_with url: dashboard_path, method: :get, local: true, style: "display: flex; align-items: center; gap: 0.5rem; margin: 0;" do %>
204
+ <% dashboard_list_params.except(:trigger_page, :trigger_per_page).each do |key, value| %>
205
+ <%= hidden_field_tag key, value %>
206
+ <% end %>
207
+ <%= hidden_field_tag :trigger_page, 1 %>
208
+ <label style="color: #6c757d; font-size: 0.875rem;">Per page:</label>
209
+ <%= select_tag :trigger_per_page, options_for_select([10, 20, 50, 100], @trigger_per_page),
210
+ onchange: "this.form.submit()",
211
+ style: "padding: 0.25rem 0.5rem; border: 1px solid #ced4da; border-radius: 4px;" %>
212
+ <% end %>
130
213
  </div>
131
214
  <% end %>
132
215
 
@@ -290,30 +373,30 @@
290
373
 
291
374
  <!-- Pagination Controls -->
292
375
  <% if @total_pages > 1 %>
293
- <div style="display: flex; justify-content: space-between; align-items: center; margin-top: 1rem; padding-top: 1rem; border-top: 1px solid #dee2e6;">
376
+ <div style="display: flex; justify-content: space-between; align-items: center; margin-top: 1rem; padding-top: 1rem; border-top: 1px solid #dee2e6; flex-wrap: wrap; gap: 0.75rem;">
294
377
  <div>
295
378
  <% if @page > 1 %>
296
- <%= link_to "← Previous", dashboard_path(page: @page - 1, per_page: @per_page),
379
+ <%= link_to "← Previous", dashboard_path(dashboard_list_params(page: @page - 1, per_page: @per_page)),
297
380
  style: "padding: 0.5rem 1rem; background: #007bff; color: white; text-decoration: none; border-radius: 4px; margin-right: 0.5rem;" %>
298
381
  <% end %>
299
382
  <% if @page < @total_pages %>
300
- <%= link_to "Next →", dashboard_path(page: @page + 1, per_page: @per_page),
383
+ <%= link_to "Next →", dashboard_path(dashboard_list_params(page: @page + 1, per_page: @per_page)),
301
384
  style: "padding: 0.5rem 1rem; background: #007bff; color: white; text-decoration: none; border-radius: 4px;" %>
302
385
  <% end %>
303
386
  </div>
304
387
  <div style="color: #6c757d; font-size: 0.875rem;">
305
388
  Page <%= @page %> of <%= @total_pages %>
306
389
  </div>
307
- <div style="display: flex; align-items: center; gap: 0.5rem;">
390
+ <%= form_with url: dashboard_path, method: :get, local: true, style: "display: flex; align-items: center; gap: 0.5rem; margin: 0;" do %>
391
+ <% dashboard_list_params.except(:page, :per_page).each do |key, value| %>
392
+ <%= hidden_field_tag key, value %>
393
+ <% end %>
394
+ <%= hidden_field_tag :page, 1 %>
308
395
  <label style="color: #6c757d; font-size: 0.875rem;">Per page:</label>
309
- <select onchange="window.location.href='<%= dashboard_path %>?page=1&per_page=' + this.value"
310
- style="padding: 0.25rem 0.5rem; border: 1px solid #ced4da; border-radius: 4px;">
311
- <option value="10" <%= 'selected' if @per_page == 10 %>>10</option>
312
- <option value="20" <%= 'selected' if @per_page == 20 %>>20</option>
313
- <option value="50" <%= 'selected' if @per_page == 50 %>>50</option>
314
- <option value="100" <%= 'selected' if @per_page == 100 %>>100</option>
315
- </select>
316
- </div>
396
+ <%= select_tag :per_page, options_for_select([10, 20, 50, 100], @per_page),
397
+ onchange: "this.form.submit()",
398
+ style: "padding: 0.25rem 0.5rem; border: 1px solid #ced4da; border-radius: 4px;" %>
399
+ <% end %>
317
400
  </div>
318
401
  <% end %>
319
402
  <% else %>
@@ -20,14 +20,27 @@
20
20
 
21
21
  <!-- Filter Controls -->
22
22
  <div style="background: white; padding: 1rem 1.5rem; border-radius: 4px; box-shadow: 0 1px 3px rgba(0,0,0,0.1); margin-bottom: 2rem;">
23
+ <%= form_with url: tables_path, method: :get, local: true, style: "margin-bottom: 1rem;" do %>
24
+ <%= hidden_field_tag :filter, @filter %>
25
+ <%= hidden_field_tag :page, 1 %>
26
+ <%= hidden_field_tag :per_page, @per_page %>
27
+ <div style="display: flex; align-items: center; gap: 0.75rem; flex-wrap: wrap;">
28
+ <label style="color: #495057; font-weight: 600; font-size: 0.875rem;">Search tables:</label>
29
+ <%= text_field_tag :q, @search_query, placeholder: "Table name…", style: "flex: 1; min-width: 200px; padding: 0.5rem; border: 1px solid #ced4da; border-radius: 4px;" %>
30
+ <%= submit_tag "Search", style: "padding: 0.5rem 1rem; background: #007bff; color: white; border: none; border-radius: 4px; cursor: pointer; font-weight: 600;" %>
31
+ <% if @search_query.present? %>
32
+ <%= link_to "Clear search", tables_path(filter: @filter, page: 1, per_page: @per_page), style: "padding: 0.5rem 1rem; background: #6c757d; color: white; text-decoration: none; border-radius: 4px;" %>
33
+ <% end %>
34
+ </div>
35
+ <% end %>
23
36
  <div style="display: flex; align-items: center; gap: 1.5rem; flex-wrap: wrap;">
24
37
  <label style="color: #495057; font-weight: 600; font-size: 0.875rem;">Filter:</label>
25
38
  <div style="display: flex; gap: 1rem; flex-wrap: wrap;">
26
- <%= link_to "All Tables", tables_path(filter: 'all', page: 1, per_page: @per_page),
39
+ <%= link_to "All Tables", tables_path(filter: 'all', page: 1, per_page: @per_page, q: @search_query),
27
40
  style: "padding: 0.5rem 1rem; background: #{@filter == 'all' ? '#007bff' : '#f8f9fa'}; color: #{@filter == 'all' ? 'white' : '#495057'}; text-decoration: none; border-radius: 4px; border: 1px solid #{@filter == 'all' ? '#007bff' : '#dee2e6'}; font-size: 0.875rem; font-weight: #{@filter == 'all' ? '600' : '400'};" %>
28
- <%= link_to "With Triggers", tables_path(filter: 'with_triggers', page: 1, per_page: @per_page),
41
+ <%= link_to "With Triggers", tables_path(filter: 'with_triggers', page: 1, per_page: @per_page, q: @search_query),
29
42
  style: "padding: 0.5rem 1rem; background: #{@filter == 'with_triggers' ? '#28a745' : '#f8f9fa'}; color: #{@filter == 'with_triggers' ? 'white' : '#495057'}; text-decoration: none; border-radius: 4px; border: 1px solid #{@filter == 'with_triggers' ? '#28a745' : '#dee2e6'}; font-size: 0.875rem; font-weight: #{@filter == 'with_triggers' ? '600' : '400'};" %>
30
- <%= link_to "Without Triggers", tables_path(filter: 'without_triggers', page: 1, per_page: @per_page),
43
+ <%= link_to "Without Triggers", tables_path(filter: 'without_triggers', page: 1, per_page: @per_page, q: @search_query),
31
44
  style: "padding: 0.5rem 1rem; background: #{@filter == 'without_triggers' ? '#6c757d' : '#f8f9fa'}; color: #{@filter == 'without_triggers' ? 'white' : '#495057'}; text-decoration: none; border-radius: 4px; border: 1px solid #{@filter == 'without_triggers' ? '#6c757d' : '#dee2e6'}; font-size: 0.875rem; font-weight: #{@filter == 'without_triggers' ? '600' : '400'};" %>
32
45
  </div>
33
46
  </div>
@@ -123,27 +136,26 @@
123
136
  <div style="display: flex; justify-content: space-between; align-items: center; margin-top: 1rem; padding: 1rem; border-top: 1px solid #dee2e6;">
124
137
  <div>
125
138
  <% if @page > 1 %>
126
- <%= link_to "← Previous", tables_path(filter: @filter, page: @page - 1, per_page: @per_page),
139
+ <%= link_to "← Previous", tables_path(filter: @filter, page: @page - 1, per_page: @per_page, q: @search_query),
127
140
  style: "padding: 0.5rem 1rem; background: #007bff; color: white; text-decoration: none; border-radius: 4px; margin-right: 0.5rem;" %>
128
141
  <% end %>
129
142
  <% if @page < @total_pages %>
130
- <%= link_to "Next →", tables_path(filter: @filter, page: @page + 1, per_page: @per_page),
143
+ <%= link_to "Next →", tables_path(filter: @filter, page: @page + 1, per_page: @per_page, q: @search_query),
131
144
  style: "padding: 0.5rem 1rem; background: #007bff; color: white; text-decoration: none; border-radius: 4px;" %>
132
145
  <% end %>
133
146
  </div>
134
147
  <div style="color: #6c757d; font-size: 0.875rem;">
135
148
  Page <%= @page %> of <%= @total_pages %>
136
149
  </div>
137
- <div style="display: flex; align-items: center; gap: 0.5rem;">
150
+ <%= form_with url: tables_path, method: :get, local: true, style: "display: flex; align-items: center; gap: 0.5rem; margin: 0;" do %>
151
+ <%= hidden_field_tag :filter, @filter %>
152
+ <%= hidden_field_tag :page, 1 %>
153
+ <%= hidden_field_tag :q, @search_query if @search_query.present? %>
138
154
  <label style="color: #6c757d; font-size: 0.875rem;">Per page:</label>
139
- <select onchange="window.location.href='<%= tables_path %>?filter=<%= @filter %>&page=1&per_page=' + this.value"
140
- style="padding: 0.25rem 0.5rem; border: 1px solid #ced4da; border-radius: 4px;">
141
- <option value="10" <%= 'selected' if @per_page == 10 %>>10</option>
142
- <option value="20" <%= 'selected' if @per_page == 20 %>>20</option>
143
- <option value="50" <%= 'selected' if @per_page == 50 %>>50</option>
144
- <option value="100" <%= 'selected' if @per_page == 100 %>>100</option>
145
- </select>
146
- </div>
155
+ <%= select_tag :per_page, options_for_select([10, 20, 50, 100], @per_page),
156
+ onchange: "this.form.submit()",
157
+ style: "padding: 0.25rem 0.5rem; border: 1px solid #ced4da; border-radius: 4px;" %>
158
+ <% end %>
147
159
  </div>
148
160
  <% end %>
149
161
  </div>
File without changes