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.
- checksums.yaml +4 -4
- data/.rubocop.yml +15 -0
- data/CHANGELOG.md +61 -0
- data/COVERAGE.md +32 -28
- data/README.md +31 -2
- data/RELEASE.md +1 -1
- data/app/controllers/pg_sql_triggers/application_controller.rb +1 -1
- data/app/controllers/pg_sql_triggers/dashboard_controller.rb +10 -0
- data/app/controllers/pg_sql_triggers/migrations_controller.rb +62 -10
- data/app/controllers/pg_sql_triggers/sql_capsules_controller.rb +161 -0
- data/app/controllers/pg_sql_triggers/triggers_controller.rb +165 -0
- data/app/models/pg_sql_triggers/trigger_registry.rb +123 -0
- data/app/views/pg_sql_triggers/dashboard/index.html.erb +40 -2
- data/app/views/pg_sql_triggers/sql_capsules/new.html.erb +81 -0
- data/app/views/pg_sql_triggers/sql_capsules/show.html.erb +85 -0
- data/app/views/pg_sql_triggers/tables/show.html.erb +36 -2
- data/app/views/pg_sql_triggers/triggers/_drop_modal.html.erb +129 -0
- data/app/views/pg_sql_triggers/triggers/_re_execute_modal.html.erb +136 -0
- data/app/views/pg_sql_triggers/triggers/show.html.erb +186 -0
- data/config/routes.rb +9 -0
- data/docs/README.md +2 -2
- data/docs/api-reference.md +252 -4
- data/docs/getting-started.md +1 -1
- data/docs/kill-switch.md +3 -3
- data/docs/web-ui.md +82 -17
- data/lib/generators/pg_sql_triggers/templates/README +1 -1
- data/lib/pg_sql_triggers/registry/manager.rb +28 -13
- data/lib/pg_sql_triggers/registry.rb +41 -0
- data/lib/pg_sql_triggers/sql/capsule.rb +79 -0
- data/lib/pg_sql_triggers/sql/executor.rb +200 -0
- data/lib/pg_sql_triggers/version.rb +1 -1
- 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
|
|
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
|
|
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;
|
|
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;">
|