pg_sql_triggers 1.1.0 → 1.2.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 (32) hide show
  1. checksums.yaml +4 -4
  2. data/.rubocop.yml +15 -0
  3. data/CHANGELOG.md +61 -0
  4. data/COVERAGE.md +32 -28
  5. data/README.md +31 -2
  6. data/RELEASE.md +1 -1
  7. data/app/controllers/pg_sql_triggers/application_controller.rb +1 -1
  8. data/app/controllers/pg_sql_triggers/dashboard_controller.rb +10 -0
  9. data/app/controllers/pg_sql_triggers/migrations_controller.rb +62 -10
  10. data/app/controllers/pg_sql_triggers/sql_capsules_controller.rb +161 -0
  11. data/app/controllers/pg_sql_triggers/triggers_controller.rb +165 -0
  12. data/app/models/pg_sql_triggers/trigger_registry.rb +123 -0
  13. data/app/views/pg_sql_triggers/dashboard/index.html.erb +40 -2
  14. data/app/views/pg_sql_triggers/sql_capsules/new.html.erb +81 -0
  15. data/app/views/pg_sql_triggers/sql_capsules/show.html.erb +85 -0
  16. data/app/views/pg_sql_triggers/tables/show.html.erb +36 -2
  17. data/app/views/pg_sql_triggers/triggers/_drop_modal.html.erb +129 -0
  18. data/app/views/pg_sql_triggers/triggers/_re_execute_modal.html.erb +136 -0
  19. data/app/views/pg_sql_triggers/triggers/show.html.erb +186 -0
  20. data/config/routes.rb +9 -0
  21. data/docs/README.md +2 -2
  22. data/docs/api-reference.md +252 -4
  23. data/docs/getting-started.md +1 -1
  24. data/docs/kill-switch.md +3 -3
  25. data/docs/web-ui.md +82 -17
  26. data/lib/generators/pg_sql_triggers/templates/README +1 -1
  27. data/lib/pg_sql_triggers/registry/manager.rb +28 -13
  28. data/lib/pg_sql_triggers/registry.rb +41 -0
  29. data/lib/pg_sql_triggers/sql/capsule.rb +79 -0
  30. data/lib/pg_sql_triggers/sql/executor.rb +200 -0
  31. data/lib/pg_sql_triggers/version.rb +1 -1
  32. metadata +18 -12
@@ -0,0 +1,165 @@
1
+ # frozen_string_literal: true
2
+
3
+ module PgSqlTriggers
4
+ # Controller for managing individual triggers via web UI
5
+ # Provides actions to enable and disable triggers
6
+ class TriggersController < ApplicationController
7
+ before_action :set_trigger, only: %i[show enable disable drop re_execute]
8
+ before_action :check_viewer_permission, only: [:show]
9
+ before_action :check_operator_permission, only: %i[enable disable]
10
+ before_action :check_admin_permission, only: %i[drop re_execute]
11
+
12
+ def show
13
+ # Load trigger details and drift information
14
+ @drift_info = calculate_drift_info
15
+ end
16
+
17
+ def enable
18
+ # Check kill switch before enabling trigger
19
+ check_kill_switch(operation: :ui_trigger_enable, confirmation: params[:confirmation_text])
20
+
21
+ @trigger.enable!(confirmation: params[:confirmation_text])
22
+ flash[:success] = "Trigger '#{@trigger.trigger_name}' enabled successfully."
23
+ redirect_to redirect_path
24
+ rescue PgSqlTriggers::KillSwitchError => e
25
+ flash[:error] = e.message
26
+ redirect_to redirect_path
27
+ rescue StandardError => e
28
+ Rails.logger.error("Enable failed: #{e.message}\n#{e.backtrace.join("\n")}")
29
+ flash[:error] = "Failed to enable trigger: #{e.message}"
30
+ redirect_to redirect_path
31
+ end
32
+
33
+ def disable
34
+ # Check kill switch before disabling trigger
35
+ check_kill_switch(operation: :ui_trigger_disable, confirmation: params[:confirmation_text])
36
+
37
+ @trigger.disable!(confirmation: params[:confirmation_text])
38
+ flash[:success] = "Trigger '#{@trigger.trigger_name}' disabled successfully."
39
+ redirect_to redirect_path
40
+ rescue PgSqlTriggers::KillSwitchError => e
41
+ flash[:error] = e.message
42
+ redirect_to redirect_path
43
+ rescue StandardError => e
44
+ Rails.logger.error("Disable failed: #{e.message}\n#{e.backtrace.join("\n")}")
45
+ flash[:error] = "Failed to disable trigger: #{e.message}"
46
+ redirect_to redirect_path
47
+ end
48
+
49
+ def drop
50
+ # Validate required parameters
51
+ if params[:reason].blank?
52
+ flash[:error] = "Reason is required for dropping a trigger."
53
+ redirect_to redirect_path
54
+ return
55
+ end
56
+
57
+ # Check kill switch before dropping trigger
58
+ check_kill_switch(operation: :trigger_drop, confirmation: params[:confirmation_text])
59
+
60
+ # Drop the trigger
61
+ @trigger.drop!(
62
+ reason: params[:reason],
63
+ confirmation: params[:confirmation_text],
64
+ actor: current_actor
65
+ )
66
+
67
+ flash[:success] = "Trigger '#{@trigger.trigger_name}' dropped successfully."
68
+ redirect_to dashboard_path
69
+ rescue PgSqlTriggers::KillSwitchError => e
70
+ flash[:error] = e.message
71
+ redirect_to redirect_path
72
+ rescue ArgumentError => e
73
+ flash[:error] = "Invalid request: #{e.message}"
74
+ redirect_to redirect_path
75
+ rescue StandardError => e
76
+ Rails.logger.error("Drop failed: #{e.message}\n#{e.backtrace.join("\n")}")
77
+ flash[:error] = "Failed to drop trigger: #{e.message}"
78
+ redirect_to redirect_path
79
+ end
80
+
81
+ def re_execute
82
+ # Validate required parameters
83
+ if params[:reason].blank?
84
+ flash[:error] = "Reason is required for re-executing a trigger."
85
+ redirect_to redirect_path
86
+ return
87
+ end
88
+
89
+ # Check kill switch before re-executing trigger
90
+ check_kill_switch(operation: :trigger_re_execute, confirmation: params[:confirmation_text])
91
+
92
+ # Re-execute the trigger
93
+ @trigger.re_execute!(
94
+ reason: params[:reason],
95
+ confirmation: params[:confirmation_text],
96
+ actor: current_actor
97
+ )
98
+
99
+ flash[:success] = "Trigger '#{@trigger.trigger_name}' re-executed successfully."
100
+ redirect_to redirect_path
101
+ rescue PgSqlTriggers::KillSwitchError => e
102
+ flash[:error] = e.message
103
+ redirect_to redirect_path
104
+ rescue ArgumentError => e
105
+ flash[:error] = "Invalid request: #{e.message}"
106
+ redirect_to redirect_path
107
+ rescue StandardError => e
108
+ Rails.logger.error("Re-execute failed: #{e.message}\n#{e.backtrace.join("\n")}")
109
+ flash[:error] = "Failed to re-execute trigger: #{e.message}"
110
+ redirect_to redirect_path
111
+ end
112
+
113
+ private
114
+
115
+ def set_trigger
116
+ @trigger = PgSqlTriggers::TriggerRegistry.find(params[:id])
117
+ rescue ActiveRecord::RecordNotFound
118
+ flash[:error] = "Trigger not found."
119
+ redirect_to root_path
120
+ end
121
+
122
+ def check_viewer_permission
123
+ return if PgSqlTriggers::Permissions.can?(current_actor, :view_triggers)
124
+
125
+ redirect_to root_path, alert: "Insufficient permissions. Viewer role required."
126
+ end
127
+
128
+ def check_operator_permission
129
+ return if PgSqlTriggers::Permissions.can?(current_actor, :enable_trigger)
130
+
131
+ redirect_to root_path, alert: "Insufficient permissions. Operator role required."
132
+ end
133
+
134
+ def check_admin_permission
135
+ return if PgSqlTriggers::Permissions.can?(current_actor, :drop_trigger)
136
+
137
+ redirect_to root_path, alert: "Insufficient permissions. Admin role required."
138
+ end
139
+
140
+ def redirect_path
141
+ # Redirect back to the referring page if possible, otherwise to dashboard
142
+ params[:redirect_to].presence || request.referer || root_path
143
+ end
144
+
145
+ def calculate_drift_info
146
+ # Get drift information for this trigger
147
+ drift_reporter = PgSqlTriggers::Drift::Reporter.new
148
+ drift_summary = drift_reporter.summary
149
+
150
+ # Find this trigger in the drift summary
151
+ drifted_triggers = drift_summary[:triggers] || []
152
+ drift_data = drifted_triggers.find { |t| t[:trigger_name] == @trigger.trigger_name }
153
+
154
+ {
155
+ has_drift: drift_data.present?,
156
+ drift_type: drift_data&.dig(:drift_type),
157
+ expected_sql: drift_data&.dig(:expected_sql),
158
+ actual_sql: drift_data&.dig(:actual_sql)
159
+ }
160
+ rescue StandardError => e
161
+ Rails.logger.error("Failed to calculate drift: #{e.message}")
162
+ { has_drift: false, drift_type: nil, expected_sql: nil, actual_sql: nil }
163
+ end
164
+ end
165
+ end
@@ -1,6 +1,7 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module PgSqlTriggers
4
+ # rubocop:disable Metrics/ClassLength
4
5
  class TriggerRegistry < PgSqlTriggers::ApplicationRecord
5
6
  self.table_name = "pg_sql_triggers_registry"
6
7
 
@@ -150,6 +151,51 @@ module PgSqlTriggers
150
151
  end
151
152
  end
152
153
 
154
+ def drop!(reason:, confirmation: nil, actor: nil)
155
+ # Check kill switch before dropping trigger
156
+ PgSqlTriggers::SQL::KillSwitch.check!(
157
+ operation: :trigger_drop,
158
+ environment: Rails.env,
159
+ confirmation: confirmation,
160
+ actor: actor || { type: "Console", id: "TriggerRegistry#drop!" }
161
+ )
162
+
163
+ # Validate reason is provided
164
+ raise ArgumentError, "Reason is required" if reason.nil? || reason.to_s.strip.empty?
165
+
166
+ log_drop_attempt(reason)
167
+
168
+ # Execute DROP TRIGGER in transaction
169
+ ActiveRecord::Base.transaction do
170
+ drop_trigger_from_database
171
+ destroy!
172
+ log_drop_success
173
+ end
174
+ end
175
+
176
+ def re_execute!(reason:, confirmation: nil, actor: nil)
177
+ # Check kill switch before re-executing trigger
178
+ PgSqlTriggers::SQL::KillSwitch.check!(
179
+ operation: :trigger_re_execute,
180
+ environment: Rails.env,
181
+ confirmation: confirmation,
182
+ actor: actor || { type: "Console", id: "TriggerRegistry#re_execute!" }
183
+ )
184
+
185
+ # Validate reason is provided
186
+ raise ArgumentError, "Reason is required" if reason.nil? || reason.to_s.strip.empty?
187
+ raise StandardError, "Cannot re-execute: missing function_body" if function_body.blank?
188
+
189
+ log_re_execute_attempt(reason)
190
+
191
+ # Execute the trigger creation/update in transaction
192
+ ActiveRecord::Base.transaction do
193
+ drop_existing_trigger_for_re_execute
194
+ recreate_trigger
195
+ update_registry_after_re_execute
196
+ end
197
+ end
198
+
153
199
  private
154
200
 
155
201
  def quote_identifier(identifier)
@@ -170,5 +216,82 @@ module PgSqlTriggers
170
216
  def verify!
171
217
  update!(last_verified_at: Time.current)
172
218
  end
219
+
220
+ # Drop trigger helpers
221
+ def log_drop_attempt(reason)
222
+ return unless defined?(Rails.logger)
223
+
224
+ Rails.logger.info "[TRIGGER_DROP] Dropping: #{trigger_name} on #{table_name}"
225
+ Rails.logger.info "[TRIGGER_DROP] Reason: #{reason}"
226
+ end
227
+
228
+ def log_drop_success
229
+ return unless defined?(Rails.logger)
230
+
231
+ Rails.logger.info "[TRIGGER_DROP] Successfully removed from registry"
232
+ end
233
+
234
+ def drop_trigger_from_database
235
+ trigger_exists = check_trigger_exists
236
+ return unless trigger_exists
237
+
238
+ execute_drop_sql
239
+ end
240
+
241
+ def check_trigger_exists
242
+ introspection = PgSqlTriggers::DatabaseIntrospection.new
243
+ introspection.trigger_exists?(trigger_name)
244
+ rescue StandardError => e
245
+ Rails.logger.warn("Could not check trigger existence: #{e.message}") if defined?(Rails.logger)
246
+ false
247
+ end
248
+
249
+ def execute_drop_sql
250
+ quoted_table = quote_identifier(table_name)
251
+ quoted_trigger = quote_identifier(trigger_name)
252
+ sql = "DROP TRIGGER IF EXISTS #{quoted_trigger} ON #{quoted_table};"
253
+ ActiveRecord::Base.connection.execute(sql)
254
+ Rails.logger.info "[TRIGGER_DROP] Dropped from database" if defined?(Rails.logger)
255
+ rescue ActiveRecord::StatementInvalid, StandardError => e
256
+ Rails.logger.error("[TRIGGER_DROP] Failed: #{e.message}") if defined?(Rails.logger)
257
+ raise
258
+ end
259
+
260
+ # Re-execute trigger helpers
261
+ def log_re_execute_attempt(reason)
262
+ return unless defined?(Rails.logger)
263
+
264
+ Rails.logger.info "[TRIGGER_RE_EXECUTE] Re-executing: #{trigger_name} on #{table_name}"
265
+ Rails.logger.info "[TRIGGER_RE_EXECUTE] Reason: #{reason}"
266
+ drift = drift_result
267
+ Rails.logger.info "[TRIGGER_RE_EXECUTE] Current state: #{drift[:state]}"
268
+ end
269
+
270
+ def drop_existing_trigger_for_re_execute
271
+ introspection = PgSqlTriggers::DatabaseIntrospection.new
272
+ return unless introspection.trigger_exists?(trigger_name)
273
+
274
+ quoted_table = quote_identifier(table_name)
275
+ quoted_trigger = quote_identifier(trigger_name)
276
+ drop_sql = "DROP TRIGGER IF EXISTS #{quoted_trigger} ON #{quoted_table};"
277
+ ActiveRecord::Base.connection.execute(drop_sql)
278
+ Rails.logger.info "[TRIGGER_RE_EXECUTE] Dropped existing" if defined?(Rails.logger)
279
+ rescue StandardError => e
280
+ Rails.logger.warn("[TRIGGER_RE_EXECUTE] Drop failed: #{e.message}") if defined?(Rails.logger)
281
+ end
282
+
283
+ def recreate_trigger
284
+ ActiveRecord::Base.connection.execute(function_body)
285
+ Rails.logger.info "[TRIGGER_RE_EXECUTE] Re-created trigger" if defined?(Rails.logger)
286
+ rescue ActiveRecord::StatementInvalid, StandardError => e
287
+ Rails.logger.error("[TRIGGER_RE_EXECUTE] Failed: #{e.message}") if defined?(Rails.logger)
288
+ raise
289
+ end
290
+
291
+ def update_registry_after_re_execute
292
+ update!(enabled: true, last_executed_at: Time.current)
293
+ Rails.logger.info "[TRIGGER_RE_EXECUTE] Updated registry" if defined?(Rails.logger)
294
+ end
173
295
  end
296
+ # rubocop:enable Metrics/ClassLength
174
297
  end
@@ -46,7 +46,9 @@
46
46
  <tbody>
47
47
  <% @triggers.limit(10).each do |trigger| %>
48
48
  <tr>
49
- <td><strong><%= trigger.trigger_name %></strong></td>
49
+ <td>
50
+ <%= link_to trigger.trigger_name, trigger_path(trigger), style: "color: #007bff; text-decoration: none; font-weight: 600;" %>
51
+ </td>
50
52
  <td><%= trigger.table_name %></td>
51
53
  <td><%= trigger.version %></td>
52
54
  <td>
@@ -57,7 +59,43 @@
57
59
  <% end %>
58
60
  </td>
59
61
  <td><span class="badge badge-info"><%= trigger.source %></span></td>
60
- <td>—</td>
62
+ <td>
63
+ <% if PgSqlTriggers::Permissions.can?(current_actor, :enable_trigger) %>
64
+ <div style="display: flex; gap: 0.5rem; flex-wrap: wrap;">
65
+ <% if trigger.enabled %>
66
+ <% form_id = "trigger-disable-#{trigger.id}-form" %>
67
+ <%= form_with url: disable_trigger_path(trigger), method: :post, local: true, id: form_id, style: "margin: 0;" do |f| %>
68
+ <%= f.hidden_field :redirect_to, value: dashboard_path %>
69
+ <button type="button" onclick="showKillSwitchModal('<%= form_id %>')"
70
+ style="padding: 0.25rem 0.75rem; background: #dc3545; color: white; border: none; border-radius: 3px; cursor: pointer; font-size: 0.75rem;">
71
+ Disable
72
+ </button>
73
+ <% end %>
74
+ <%= render 'pg_sql_triggers/shared/confirmation_modal',
75
+ operation: :ui_trigger_disable,
76
+ form_id: form_id,
77
+ title: 'Disable Trigger',
78
+ message: "Are you sure you want to disable trigger '#{trigger.trigger_name}'?" %>
79
+ <% else %>
80
+ <% form_id = "trigger-enable-#{trigger.id}-form" %>
81
+ <%= form_with url: enable_trigger_path(trigger), method: :post, local: true, id: form_id, style: "margin: 0;" do |f| %>
82
+ <%= f.hidden_field :redirect_to, value: dashboard_path %>
83
+ <button type="button" onclick="showKillSwitchModal('<%= form_id %>')"
84
+ style="padding: 0.25rem 0.75rem; background: #28a745; color: white; border: none; border-radius: 3px; cursor: pointer; font-size: 0.75rem;">
85
+ Enable
86
+ </button>
87
+ <% end %>
88
+ <%= render 'pg_sql_triggers/shared/confirmation_modal',
89
+ operation: :ui_trigger_enable,
90
+ form_id: form_id,
91
+ title: 'Enable Trigger',
92
+ message: "Are you sure you want to enable trigger '#{trigger.trigger_name}'?" %>
93
+ <% end %>
94
+ </div>
95
+ <% else %>
96
+
97
+ <% end %>
98
+ </td>
61
99
  </tr>
62
100
  <% end %>
63
101
  </tbody>
@@ -0,0 +1,81 @@
1
+ <div style="max-width: 900px; margin: 0 auto;">
2
+ <div style="display: flex; justify-content: space-between; align-items: center; margin-bottom: 2rem;">
3
+ <h2 style="margin: 0;">Create SQL Capsule</h2>
4
+ <%= link_to "← Back to Dashboard", dashboard_path, style: "color: #007bff; text-decoration: none;" %>
5
+ </div>
6
+
7
+ <div style="background: #fff3cd; border: 1px solid #ffc107; padding: 1rem; border-radius: 4px; margin-bottom: 2rem;">
8
+ <strong>⚠️ Warning:</strong> SQL Capsules allow direct SQL execution. Use with extreme caution.
9
+ This feature is intended for emergency operations only.
10
+ </div>
11
+
12
+ <%= form_with url: sql_capsules_path, method: :post, local: true do |f| %>
13
+ <div style="background: white; padding: 2rem; border-radius: 4px; box-shadow: 0 1px 3px rgba(0,0,0,0.1);">
14
+
15
+ <div style="margin-bottom: 1.5rem;">
16
+ <label for="name" style="display: block; font-weight: 600; margin-bottom: 0.5rem;">
17
+ Capsule Name <span style="color: #dc3545;">*</span>
18
+ </label>
19
+ <%= text_field_tag :name, @capsule_name,
20
+ placeholder: "e.g., fix_user_permissions",
21
+ required: true,
22
+ pattern: "[a-zA-Z0-9_-]+",
23
+ autocomplete: "off",
24
+ style: "width: 100%; padding: 0.5rem; border: 1px solid #ced4da; border-radius: 4px;" %>
25
+ <small style="color: #6c757d; display: block; margin-top: 0.25rem;">
26
+ Letters, numbers, underscores, and hyphens only
27
+ </small>
28
+ </div>
29
+
30
+ <div style="margin-bottom: 1.5rem;">
31
+ <label for="environment" style="display: block; font-weight: 600; margin-bottom: 0.5rem;">
32
+ Environment <span style="color: #dc3545;">*</span>
33
+ </label>
34
+ <%= text_field_tag :environment, @environment,
35
+ placeholder: "e.g., production",
36
+ required: true,
37
+ autocomplete: "off",
38
+ style: "width: 100%; padding: 0.5rem; border: 1px solid #ced4da; border-radius: 4px;" %>
39
+ <small style="color: #6c757d; display: block; margin-top: 0.25rem;">
40
+ The environment this capsule is intended for
41
+ </small>
42
+ </div>
43
+
44
+ <div style="margin-bottom: 1.5rem;">
45
+ <label for="purpose" style="display: block; font-weight: 600; margin-bottom: 0.5rem;">
46
+ Purpose <span style="color: #dc3545;">*</span>
47
+ </label>
48
+ <%= text_area_tag :purpose, @purpose,
49
+ placeholder: "Describe what this SQL does and why it's needed...",
50
+ required: true,
51
+ rows: 3,
52
+ style: "width: 100%; padding: 0.5rem; border: 1px solid #ced4da; border-radius: 4px; font-family: inherit;" %>
53
+ <small style="color: #6c757d; display: block; margin-top: 0.25rem;">
54
+ Detailed description of the operation and its purpose
55
+ </small>
56
+ </div>
57
+
58
+ <div style="margin-bottom: 1.5rem;">
59
+ <label for="sql" style="display: block; font-weight: 600; margin-bottom: 0.5rem;">
60
+ SQL Statement <span style="color: #dc3545;">*</span>
61
+ </label>
62
+ <%= text_area_tag :sql, @sql,
63
+ placeholder: "-- Enter your SQL statement here\nSELECT * FROM users WHERE ...",
64
+ required: true,
65
+ rows: 12,
66
+ style: "width: 100%; padding: 0.5rem; border: 1px solid #ced4da; border-radius: 4px; font-family: 'Monaco', 'Menlo', 'Courier New', monospace; font-size: 0.9rem;" %>
67
+ <small style="color: #6c757d; display: block; margin-top: 0.25rem;">
68
+ The SQL to execute. Review carefully before saving.
69
+ </small>
70
+ </div>
71
+
72
+ <div style="display: flex; gap: 1rem; justify-content: flex-end;">
73
+ <%= link_to "Cancel", dashboard_path,
74
+ style: "padding: 0.75rem 1.5rem; background: #6c757d; color: white; text-decoration: none; border-radius: 4px;" %>
75
+ <%= button_tag "Create Capsule",
76
+ type: "submit",
77
+ style: "padding: 0.75rem 1.5rem; background: #007bff; color: white; border: none; border-radius: 4px; cursor: pointer; font-size: 1rem;" %>
78
+ </div>
79
+ </div>
80
+ <% end %>
81
+ </div>
@@ -0,0 +1,85 @@
1
+ <div style="max-width: 900px; margin: 0 auto;">
2
+ <div style="display: flex; justify-content: space-between; align-items: center; margin-bottom: 2rem;">
3
+ <h2 style="margin: 0;">SQL Capsule: <%= @capsule.name %></h2>
4
+ <%= link_to "← Back to Dashboard", dashboard_path, style: "color: #007bff; text-decoration: none;" %>
5
+ </div>
6
+
7
+ <div style="background: white; padding: 2rem; border-radius: 4px; box-shadow: 0 1px 3px rgba(0,0,0,0.1); margin-bottom: 2rem;">
8
+ <h3 style="margin-top: 0; margin-bottom: 1rem; color: #495057;">Capsule Details</h3>
9
+
10
+ <div style="display: grid; grid-template-columns: 200px 1fr; gap: 1rem; margin-bottom: 1rem;">
11
+ <div style="font-weight: 600; color: #6c757d;">Name:</div>
12
+ <div><%= @capsule.name %></div>
13
+
14
+ <div style="font-weight: 600; color: #6c757d;">Environment:</div>
15
+ <div><span class="badge badge-info"><%= @capsule.environment %></span></div>
16
+
17
+ <div style="font-weight: 600; color: #6c757d;">Created:</div>
18
+ <div><%= @capsule.created_at.strftime("%Y-%m-%d %H:%M:%S %Z") %></div>
19
+
20
+ <div style="font-weight: 600; color: #6c757d;">Checksum:</div>
21
+ <div style="font-family: 'Monaco', 'Menlo', 'Courier New', monospace; font-size: 0.85rem; color: #6c757d;">
22
+ <%= @checksum %>
23
+ </div>
24
+ </div>
25
+
26
+ <div style="margin-top: 1.5rem;">
27
+ <div style="font-weight: 600; color: #6c757d; margin-bottom: 0.5rem;">Purpose:</div>
28
+ <div style="background: #f8f9fa; padding: 1rem; border-radius: 4px; border-left: 3px solid #007bff;">
29
+ <%= @capsule.purpose %>
30
+ </div>
31
+ </div>
32
+
33
+ <div style="margin-top: 1.5rem;">
34
+ <div style="font-weight: 600; color: #6c757d; margin-bottom: 0.5rem;">SQL Statement:</div>
35
+ <div style="background: #f8f9fa; padding: 1rem; border-radius: 4px; border-left: 3px solid #28a745; overflow-x: auto;">
36
+ <pre style="margin: 0; font-family: 'Monaco', 'Menlo', 'Courier New', monospace; font-size: 0.9rem; white-space: pre-wrap; word-wrap: break-word;"><%= @capsule.sql %></pre>
37
+ </div>
38
+ </div>
39
+ </div>
40
+
41
+ <% if @can_execute %>
42
+ <div style="background: #fff3cd; border: 1px solid #ffc107; padding: 1.5rem; border-radius: 4px; margin-bottom: 2rem;">
43
+ <h4 style="margin-top: 0; color: #856404;">⚠️ Execution Warning</h4>
44
+ <p style="margin-bottom: 1rem; color: #856404;">
45
+ This will execute the SQL statement directly against the database.
46
+ This operation cannot be undone. Make sure you understand the implications.
47
+ </p>
48
+
49
+ <% if kill_switch_active? %>
50
+ <p style="margin-bottom: 1rem; color: #856404;">
51
+ <strong>Kill Switch is ACTIVE for <%= current_environment %> environment.</strong><br>
52
+ You must provide confirmation text to proceed.
53
+ </p>
54
+ <% end %>
55
+
56
+ <% form_id = "execute-capsule-form" %>
57
+ <%= form_with url: execute_sql_capsule_path(@capsule.name), method: :post, local: true, id: form_id do |f| %>
58
+ <div style="display: flex; gap: 1rem; align-items: flex-end;">
59
+ <div style="flex: 1;">
60
+ <button type="button" onclick="showKillSwitchModal('<%= form_id %>')"
61
+ style="width: 100%; padding: 0.75rem 1.5rem; background: #dc3545; color: white; border: none; border-radius: 4px; cursor: pointer; font-size: 1rem; font-weight: 600;">
62
+ Execute SQL Capsule
63
+ </button>
64
+ </div>
65
+ </div>
66
+ <% end %>
67
+
68
+ <%= render 'pg_sql_triggers/shared/confirmation_modal',
69
+ operation: :execute_sql_capsule,
70
+ form_id: form_id,
71
+ title: 'Execute SQL Capsule',
72
+ message: "Are you sure you want to execute SQL capsule '#{@capsule.name}'? This will run the SQL statement directly against the database." %>
73
+ </div>
74
+ <% else %>
75
+ <div style="background: #f8d7da; border: 1px solid #f5c6cb; padding: 1rem; border-radius: 4px; color: #721c24;">
76
+ <strong>Insufficient Permissions:</strong> You need Admin role to execute SQL capsules.
77
+ </div>
78
+ <% end %>
79
+
80
+ <div style="margin-top: 2rem;">
81
+ <%= link_to "Create Another Capsule", new_sql_capsule_path,
82
+ class: "btn btn-primary",
83
+ style: "padding: 0.75rem 1.5rem; background: #007bff; color: white; text-decoration: none; border-radius: 4px; display: inline-block;" %>
84
+ </div>
85
+ </div>
@@ -46,8 +46,8 @@
46
46
  <div style="border: 1px solid #dee2e6; border-radius: 4px; padding: 1rem; background: #f8f9fa;">
47
47
  <div style="display: flex; justify-content: space-between; align-items: start; margin-bottom: 1rem;">
48
48
  <div>
49
- <h4 style="margin: 0; color: #007bff;">
50
- <%= trigger.trigger_name %>
49
+ <h4 style="margin: 0;">
50
+ <%= link_to trigger.trigger_name, trigger_path(trigger), style: "color: #007bff; text-decoration: none;" %>
51
51
  <% if trigger.enabled %>
52
52
  <span class="badge badge-success">Enabled</span>
53
53
  <% else %>
@@ -64,6 +64,40 @@
64
64
  </div>
65
65
  </div>
66
66
 
67
+ <% if PgSqlTriggers::Permissions.can?(current_actor, :enable_trigger) %>
68
+ <div style="margin-bottom: 1rem; display: flex; gap: 0.5rem;">
69
+ <% if trigger.enabled %>
70
+ <% form_id = "table-trigger-disable-#{trigger.id}-form" %>
71
+ <%= form_with url: disable_trigger_path(trigger), method: :post, local: true, id: form_id, style: "margin: 0;" do |f| %>
72
+ <%= f.hidden_field :redirect_to, value: table_path(@table_info[:table_name]) %>
73
+ <button type="button" onclick="showKillSwitchModal('<%= form_id %>')"
74
+ style="padding: 0.5rem 1rem; background: #dc3545; color: white; border: none; border-radius: 4px; cursor: pointer; font-size: 0.875rem; font-weight: 600;">
75
+ Disable Trigger
76
+ </button>
77
+ <% end %>
78
+ <%= render 'pg_sql_triggers/shared/confirmation_modal',
79
+ operation: :ui_trigger_disable,
80
+ form_id: form_id,
81
+ title: 'Disable Trigger',
82
+ message: "Are you sure you want to disable trigger '#{trigger.trigger_name}' on table '#{@table_info[:table_name]}'?" %>
83
+ <% else %>
84
+ <% form_id = "table-trigger-enable-#{trigger.id}-form" %>
85
+ <%= form_with url: enable_trigger_path(trigger), method: :post, local: true, id: form_id, style: "margin: 0;" do |f| %>
86
+ <%= f.hidden_field :redirect_to, value: table_path(@table_info[:table_name]) %>
87
+ <button type="button" onclick="showKillSwitchModal('<%= form_id %>')"
88
+ style="padding: 0.5rem 1rem; background: #28a745; color: white; border: none; border-radius: 4px; cursor: pointer; font-size: 0.875rem; font-weight: 600;">
89
+ Enable Trigger
90
+ </button>
91
+ <% end %>
92
+ <%= render 'pg_sql_triggers/shared/confirmation_modal',
93
+ operation: :ui_trigger_enable,
94
+ form_id: form_id,
95
+ title: 'Enable Trigger',
96
+ message: "Are you sure you want to enable trigger '#{trigger.trigger_name}' on table '#{@table_info[:table_name]}'?" %>
97
+ <% end %>
98
+ </div>
99
+ <% end %>
100
+
67
101
  <% if trigger.definition.present? %>
68
102
  <% definition = JSON.parse(trigger.definition) rescue {} %>
69
103
  <div style="margin-bottom: 1rem;">