pg_sql_triggers 1.0.0 → 1.1.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/.erb_lint.yml +47 -0
- data/.rubocop.yml +4 -1
- data/CHANGELOG.md +112 -1
- data/COVERAGE.md +58 -0
- data/Goal.md +450 -123
- data/README.md +53 -215
- data/app/controllers/pg_sql_triggers/application_controller.rb +46 -0
- data/app/controllers/pg_sql_triggers/dashboard_controller.rb +4 -1
- data/app/controllers/pg_sql_triggers/generator_controller.rb +76 -8
- data/app/controllers/pg_sql_triggers/migrations_controller.rb +18 -0
- data/app/models/pg_sql_triggers/trigger_registry.rb +93 -12
- data/app/views/layouts/pg_sql_triggers/application.html.erb +34 -1
- data/app/views/pg_sql_triggers/dashboard/index.html.erb +70 -30
- data/app/views/pg_sql_triggers/generator/new.html.erb +22 -4
- data/app/views/pg_sql_triggers/generator/preview.html.erb +244 -16
- data/app/views/pg_sql_triggers/shared/_confirmation_modal.html.erb +221 -0
- data/app/views/pg_sql_triggers/shared/_kill_switch_status.html.erb +40 -0
- data/app/views/pg_sql_triggers/tables/index.html.erb +0 -2
- data/app/views/pg_sql_triggers/tables/show.html.erb +3 -4
- data/config/initializers/pg_sql_triggers.rb +69 -0
- data/db/migrate/20251222000001_create_pg_sql_triggers_tables.rb +3 -1
- data/db/migrate/20251229071916_add_timing_to_pg_sql_triggers_registry.rb +8 -0
- data/docs/README.md +66 -0
- data/docs/api-reference.md +681 -0
- data/docs/configuration.md +541 -0
- data/docs/getting-started.md +135 -0
- data/docs/kill-switch.md +586 -0
- data/docs/screenshots/.gitkeep +1 -0
- data/docs/screenshots/Generate Trigger.png +0 -0
- data/docs/screenshots/Triggers Page.png +0 -0
- data/docs/screenshots/kill error.png +0 -0
- data/docs/screenshots/kill modal for migration down.png +0 -0
- data/docs/usage-guide.md +493 -0
- data/docs/web-ui.md +353 -0
- data/lib/generators/pg_sql_triggers/templates/create_pg_sql_triggers_tables.rb +3 -1
- data/lib/generators/pg_sql_triggers/templates/initializer.rb +44 -2
- data/lib/pg_sql_triggers/drift/db_queries.rb +116 -0
- data/lib/pg_sql_triggers/drift/detector.rb +187 -0
- data/lib/pg_sql_triggers/drift/reporter.rb +179 -0
- data/lib/pg_sql_triggers/drift.rb +14 -11
- data/lib/pg_sql_triggers/dsl/trigger_definition.rb +15 -1
- data/lib/pg_sql_triggers/generator/form.rb +3 -1
- data/lib/pg_sql_triggers/generator/service.rb +82 -26
- data/lib/pg_sql_triggers/migration.rb +1 -1
- data/lib/pg_sql_triggers/migrator/pre_apply_comparator.rb +344 -0
- data/lib/pg_sql_triggers/migrator/pre_apply_diff_reporter.rb +143 -0
- data/lib/pg_sql_triggers/migrator/safety_validator.rb +258 -0
- data/lib/pg_sql_triggers/migrator.rb +85 -3
- data/lib/pg_sql_triggers/registry/manager.rb +100 -13
- data/lib/pg_sql_triggers/sql/kill_switch.rb +300 -0
- data/lib/pg_sql_triggers/testing/dry_run.rb +5 -7
- data/lib/pg_sql_triggers/testing/function_tester.rb +66 -24
- data/lib/pg_sql_triggers/testing/safe_executor.rb +23 -11
- data/lib/pg_sql_triggers/testing/syntax_validator.rb +24 -1
- data/lib/pg_sql_triggers/version.rb +1 -1
- data/lib/pg_sql_triggers.rb +24 -0
- data/lib/tasks/trigger_migrations.rake +33 -0
- data/scripts/generate_coverage_report.rb +129 -0
- metadata +45 -5
|
@@ -9,17 +9,81 @@
|
|
|
9
9
|
</ul>
|
|
10
10
|
</div>
|
|
11
11
|
|
|
12
|
-
<!-- DSL Preview -->
|
|
13
|
-
<div style="margin-bottom: 2rem;">
|
|
14
|
-
<h3 style="display: flex; align-items: center; gap: 0.5rem;">
|
|
15
|
-
<span>DSL Definition</span>
|
|
16
|
-
<span class="badge badge-info">Ruby</span>
|
|
17
|
-
</h3>
|
|
18
|
-
<pre style="background: #f8f9fa; padding: 1rem; border-radius: 4px; overflow-x: auto; border: 1px solid #dee2e6;"><code><%= @dsl_content %></code></pre>
|
|
19
|
-
</div>
|
|
20
|
-
|
|
21
12
|
<!-- Actions -->
|
|
22
|
-
<%= form_with url: generator_index_path, method: :post, scope: :pg_sql_triggers_generator_form do |f| %>
|
|
13
|
+
<%= form_with url: generator_index_path, method: :post, scope: :pg_sql_triggers_generator_form, id: "generator-create-form", local: true do |f| %>
|
|
14
|
+
<!-- DSL Preview -->
|
|
15
|
+
<div style="margin-bottom: 2rem;">
|
|
16
|
+
<h3 style="display: flex; align-items: center; gap: 0.5rem;">
|
|
17
|
+
<span>DSL Definition</span>
|
|
18
|
+
<span class="badge badge-info">Ruby</span>
|
|
19
|
+
<small style="color: #6c757d; font-weight: normal;">(Updates automatically when timing or condition changes)</small>
|
|
20
|
+
</h3>
|
|
21
|
+
<pre id="dsl-preview" style="background: #f8f9fa; padding: 1rem; border-radius: 4px; overflow-x: auto; border: 1px solid #dee2e6; min-height: 150px;"><code><%= @dsl_content %></code></pre>
|
|
22
|
+
</div>
|
|
23
|
+
|
|
24
|
+
<!-- Trigger Configuration Summary (Editable) -->
|
|
25
|
+
<div style="margin-bottom: 2rem; padding: 1.5rem; background: #f8f9fa; border-radius: 4px; border: 1px solid #dee2e6;">
|
|
26
|
+
<h3 style="margin-top: 0; margin-bottom: 1.5rem; display: flex; align-items: center; gap: 0.5rem;">
|
|
27
|
+
<span>Trigger Configuration</span>
|
|
28
|
+
<small style="color: #6c757d; font-weight: normal;">(Editable - changes will update preview)</small>
|
|
29
|
+
</h3>
|
|
30
|
+
|
|
31
|
+
<div style="display: grid; grid-template-columns: repeat(auto-fit, minmax(250px, 1fr)); gap: 1.5rem;">
|
|
32
|
+
<!-- Timing Field -->
|
|
33
|
+
<div>
|
|
34
|
+
<%= f.label :timing, "Trigger Timing *", style: "display: block; font-weight: 500; margin-bottom: 0.5rem; color: #495057;" %>
|
|
35
|
+
<%= f.select :timing,
|
|
36
|
+
options_for_select([["Before", "before"], ["After", "after"]], @form.timing || "before"),
|
|
37
|
+
{},
|
|
38
|
+
{
|
|
39
|
+
required: true,
|
|
40
|
+
id: "preview-timing-select",
|
|
41
|
+
style: "width: 100%; padding: 0.5rem; border: 1px solid #ced4da; border-radius: 4px; background: white; font-size: 0.875rem;",
|
|
42
|
+
onchange: "updatePreview()"
|
|
43
|
+
} %>
|
|
44
|
+
<small style="color: #6c757d; display: block; margin-top: 0.25rem;">
|
|
45
|
+
When the trigger should fire relative to the event
|
|
46
|
+
</small>
|
|
47
|
+
</div>
|
|
48
|
+
|
|
49
|
+
<!-- Read-only fields -->
|
|
50
|
+
<div>
|
|
51
|
+
<strong style="color: #495057; display: block; margin-bottom: 0.5rem;">Events:</strong>
|
|
52
|
+
<div style="padding: 0.5rem; background: white; border-radius: 4px; border: 1px solid #dee2e6;">
|
|
53
|
+
<span style="color: #495057; font-weight: 500;"><%= Array(@form.events).compact_blank.map(&:upcase).join(", ") %></span>
|
|
54
|
+
</div>
|
|
55
|
+
</div>
|
|
56
|
+
|
|
57
|
+
<div>
|
|
58
|
+
<strong style="color: #495057; display: block; margin-bottom: 0.5rem;">Table:</strong>
|
|
59
|
+
<div style="padding: 0.5rem; background: white; border-radius: 4px; border: 1px solid #dee2e6;">
|
|
60
|
+
<span style="color: #495057; font-weight: 500;"><%= @form.table_name %></span>
|
|
61
|
+
</div>
|
|
62
|
+
</div>
|
|
63
|
+
|
|
64
|
+
<div>
|
|
65
|
+
<strong style="color: #495057; display: block; margin-bottom: 0.5rem;">Function:</strong>
|
|
66
|
+
<div style="padding: 0.5rem; background: white; border-radius: 4px; border: 1px solid #dee2e6;">
|
|
67
|
+
<span style="color: #495057; font-weight: 500;"><%= @form.function_name %></span>
|
|
68
|
+
</div>
|
|
69
|
+
</div>
|
|
70
|
+
|
|
71
|
+
<!-- Condition Field (Editable) -->
|
|
72
|
+
<div style="grid-column: 1 / -1;">
|
|
73
|
+
<%= f.label :condition, "WHEN Condition (Optional)", style: "display: block; font-weight: 500; margin-bottom: 0.5rem; color: #495057;" %>
|
|
74
|
+
<%= f.text_area :condition,
|
|
75
|
+
value: @form.condition,
|
|
76
|
+
placeholder: "e.g., NEW.email IS NOT NULL OR NEW.status = 'active'",
|
|
77
|
+
rows: 3,
|
|
78
|
+
id: "preview-condition-textarea",
|
|
79
|
+
style: "width: 100%; padding: 0.75rem; border: 1px solid #ced4da; border-radius: 4px; font-family: 'Monaco', 'Menlo', 'Ubuntu Mono', 'Consolas', 'source-code-pro', monospace; font-size: 0.875rem; background: white; resize: vertical;",
|
|
80
|
+
oninput: "updatePreview()" %>
|
|
81
|
+
<small style="color: #6c757d; display: block; margin-top: 0.25rem;">
|
|
82
|
+
Optional SQL condition. Leave empty to fire on all rows. Changes update the DSL preview above.
|
|
83
|
+
</small>
|
|
84
|
+
</div>
|
|
85
|
+
</div>
|
|
86
|
+
</div>
|
|
23
87
|
<!-- SQL Validation Result -->
|
|
24
88
|
<% if @sql_validation %>
|
|
25
89
|
<div style="margin-bottom: 2rem; padding: 1rem; border-radius: 4px; <%= @sql_validation[:valid] ? 'background: #d4edda; border-left: 4px solid #28a745;' : 'background: #f8d7da; border-left: 4px solid #dc3545;' %>">
|
|
@@ -48,7 +112,7 @@
|
|
|
48
112
|
<p style="color: #6c757d; margin-bottom: 0.5rem; font-size: 0.875rem;">
|
|
49
113
|
You can edit the function body below before generating the trigger files.
|
|
50
114
|
</p>
|
|
51
|
-
<%= f.text_area :function_body,
|
|
115
|
+
<%= f.text_area :function_body,
|
|
52
116
|
value: @function_content,
|
|
53
117
|
rows: 20,
|
|
54
118
|
required: true,
|
|
@@ -59,7 +123,6 @@
|
|
|
59
123
|
<%= f.hidden_field :function_name, value: @form.function_name %>
|
|
60
124
|
<%= f.hidden_field :version, value: @form.version %>
|
|
61
125
|
<%= f.hidden_field :enabled, value: @form.enabled %>
|
|
62
|
-
<%= f.hidden_field :condition, value: @form.condition %>
|
|
63
126
|
<%= f.hidden_field :generate_function_stub, value: @form.generate_function_stub %>
|
|
64
127
|
<% Array(@form.events).reject(&:blank?).each do |event| %>
|
|
65
128
|
<%= hidden_field_tag "pg_sql_triggers_generator_form[events][]", event %>
|
|
@@ -69,9 +132,174 @@
|
|
|
69
132
|
<% end %>
|
|
70
133
|
|
|
71
134
|
<div style="display: flex; gap: 1rem;">
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
135
|
+
<button type="button" onclick="showKillSwitchModal('generator-create-form')" class="btn btn-success"
|
|
136
|
+
style="padding: 0.75rem 1.5rem; background: #28a745; color: white; border: none; border-radius: 4px; cursor: pointer; font-weight: 600;">
|
|
137
|
+
Generate Files
|
|
138
|
+
</button>
|
|
76
139
|
</div>
|
|
77
140
|
<% end %>
|
|
141
|
+
|
|
142
|
+
<!-- Separate form for "Back to Edit" to avoid nested forms -->
|
|
143
|
+
<div style="display: flex; gap: 1rem; margin-top: 1rem;">
|
|
144
|
+
<%= form_with url: preview_generator_index_path, method: :post, scope: :pg_sql_triggers_generator_form, id: "back-to-edit-form", local: true, style: "margin: 0;" do |f| %>
|
|
145
|
+
<%= f.hidden_field :trigger_name, value: @form.trigger_name %>
|
|
146
|
+
<%= f.hidden_field :table_name, value: @form.table_name %>
|
|
147
|
+
<%= f.hidden_field :function_name, value: @form.function_name %>
|
|
148
|
+
<%= f.hidden_field :version, value: @form.version %>
|
|
149
|
+
<%= f.hidden_field :enabled, value: @form.enabled %>
|
|
150
|
+
<%= f.hidden_field :generate_function_stub, value: @form.generate_function_stub %>
|
|
151
|
+
<%= f.hidden_field :timing, id: "back-to-edit-timing" %>
|
|
152
|
+
<%= f.hidden_field :condition, id: "back-to-edit-condition" %>
|
|
153
|
+
<%= f.hidden_field :function_body, id: "back-to-edit-function-body" %>
|
|
154
|
+
<% Array(@form.events).reject(&:blank?).each do |event| %>
|
|
155
|
+
<%= hidden_field_tag "pg_sql_triggers_generator_form[events][]", event %>
|
|
156
|
+
<% end %>
|
|
157
|
+
<% Array(@form.environments).reject(&:blank?).each do |env| %>
|
|
158
|
+
<%= hidden_field_tag "pg_sql_triggers_generator_form[environments][]", env %>
|
|
159
|
+
<% end %>
|
|
160
|
+
<%= hidden_field_tag :back_to_edit, "1" %>
|
|
161
|
+
<button type="submit" class="btn" style="background: #6c757d; color: white; border: none; padding: 0.75rem 1.5rem; border-radius: 4px; cursor: pointer;">
|
|
162
|
+
Back to Edit
|
|
163
|
+
</button>
|
|
164
|
+
<% end %>
|
|
165
|
+
<%= link_to "Cancel", root_path, class: "btn", style: "background: #6c757d; color: white; text-decoration: none; padding: 0.75rem 1.5rem; border-radius: 4px;" %>
|
|
166
|
+
</div>
|
|
167
|
+
|
|
168
|
+
<%= render 'pg_sql_triggers/shared/confirmation_modal',
|
|
169
|
+
operation: :ui_trigger_generate,
|
|
170
|
+
form_id: 'generator-create-form',
|
|
171
|
+
title: 'Generate Trigger Files',
|
|
172
|
+
message: 'This will create files on disk and register the trigger. Continue?' %>
|
|
173
|
+
|
|
174
|
+
<script>
|
|
175
|
+
(function() {
|
|
176
|
+
'use strict';
|
|
177
|
+
|
|
178
|
+
// Store the original DSL content
|
|
179
|
+
const originalDsl = <%= raw @dsl_content.to_json %>;
|
|
180
|
+
|
|
181
|
+
// Extract base DSL parts
|
|
182
|
+
const triggerName = '<%= @form.trigger_name %>';
|
|
183
|
+
const tableName = '<%= @form.table_name %>';
|
|
184
|
+
const functionName = '<%= @form.function_name %>';
|
|
185
|
+
const eventsList = '<%= Array(@form.events).compact_blank.map { |e| ":#{e}" }.join(", ") %>';
|
|
186
|
+
const version = <%= @form.version %>;
|
|
187
|
+
const enabled = <%= @form.enabled %>;
|
|
188
|
+
const environmentsList = '<%= @form.environments.compact_blank.map { |e| ":#{e}" }.join(", ") %>';
|
|
189
|
+
const functionRef = /^[a-z0-9_]+$/.test(functionName) ? `:${functionName}` : `"${functionName}"`;
|
|
190
|
+
|
|
191
|
+
function updatePreview() {
|
|
192
|
+
const timingSelect = document.getElementById('preview-timing-select');
|
|
193
|
+
const conditionTextarea = document.getElementById('preview-condition-textarea');
|
|
194
|
+
const dslPreview = document.getElementById('dsl-preview');
|
|
195
|
+
|
|
196
|
+
if (!timingSelect || !conditionTextarea || !dslPreview) return;
|
|
197
|
+
|
|
198
|
+
const timing = timingSelect.value || 'before';
|
|
199
|
+
const condition = conditionTextarea.value.trim();
|
|
200
|
+
|
|
201
|
+
// Build DSL content
|
|
202
|
+
const now = new Date();
|
|
203
|
+
const timestamp = now.toISOString().slice(0, 19).replace('T', ' ');
|
|
204
|
+
|
|
205
|
+
let dslContent = `# frozen_string_literal: true
|
|
206
|
+
|
|
207
|
+
# Generated by pg_sql_triggers on ${timestamp}
|
|
208
|
+
PgSqlTriggers::DSL.pg_sql_trigger "${triggerName}" do
|
|
209
|
+
table :${tableName}
|
|
210
|
+
on ${eventsList}
|
|
211
|
+
function ${functionRef}
|
|
212
|
+
|
|
213
|
+
version ${version}
|
|
214
|
+
enabled ${enabled}
|
|
215
|
+
timing :${timing}`;
|
|
216
|
+
|
|
217
|
+
if (environmentsList && environmentsList.length > 0) {
|
|
218
|
+
dslContent += `\n when_env ${environmentsList}`;
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
if (condition) {
|
|
222
|
+
// Escape quotes in condition
|
|
223
|
+
const escapedCondition = condition.replace(/"/g, '\\"');
|
|
224
|
+
dslContent += `\n when_condition "${escapedCondition}"`;
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
dslContent += `\nend\n`;
|
|
228
|
+
|
|
229
|
+
// Update the preview
|
|
230
|
+
const codeElement = dslPreview.querySelector('code');
|
|
231
|
+
if (codeElement) {
|
|
232
|
+
codeElement.textContent = dslContent;
|
|
233
|
+
}
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
// Sync form values to back-to-edit form before submission
|
|
237
|
+
function syncBackToEditForm() {
|
|
238
|
+
const timingSelect = document.getElementById('preview-timing-select');
|
|
239
|
+
const conditionTextarea = document.getElementById('preview-condition-textarea');
|
|
240
|
+
const functionBodyTextarea = document.querySelector('#generator-create-form textarea[name="pg_sql_triggers_generator_form[function_body]"]');
|
|
241
|
+
const backToEditForm = document.getElementById('back-to-edit-form');
|
|
242
|
+
|
|
243
|
+
if (!backToEditForm) return;
|
|
244
|
+
|
|
245
|
+
// Update timing
|
|
246
|
+
const backToEditTiming = document.getElementById('back-to-edit-timing');
|
|
247
|
+
if (backToEditTiming && timingSelect) {
|
|
248
|
+
backToEditTiming.value = timingSelect.value || 'before';
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
// Update condition
|
|
252
|
+
const backToEditCondition = document.getElementById('back-to-edit-condition');
|
|
253
|
+
if (backToEditCondition && conditionTextarea) {
|
|
254
|
+
backToEditCondition.value = conditionTextarea.value || '';
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
// Update function_body - get from the create form textarea
|
|
258
|
+
const backToEditFunctionBody = document.getElementById('back-to-edit-function-body');
|
|
259
|
+
if (backToEditFunctionBody) {
|
|
260
|
+
if (functionBodyTextarea) {
|
|
261
|
+
backToEditFunctionBody.value = functionBodyTextarea.value || '';
|
|
262
|
+
} else {
|
|
263
|
+
// Fallback: try to find by name attribute
|
|
264
|
+
const createFormFunctionBody = document.querySelector('textarea[name="pg_sql_triggers_generator_form[function_body]"]');
|
|
265
|
+
if (createFormFunctionBody) {
|
|
266
|
+
backToEditFunctionBody.value = createFormFunctionBody.value || '';
|
|
267
|
+
}
|
|
268
|
+
}
|
|
269
|
+
}
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
// Initialize on DOM ready
|
|
273
|
+
document.addEventListener('DOMContentLoaded', function() {
|
|
274
|
+
const timingSelect = document.getElementById('preview-timing-select');
|
|
275
|
+
const conditionTextarea = document.getElementById('preview-condition-textarea');
|
|
276
|
+
const backToEditForm = document.getElementById('back-to-edit-form');
|
|
277
|
+
|
|
278
|
+
if (timingSelect) {
|
|
279
|
+
timingSelect.addEventListener('change', updatePreview);
|
|
280
|
+
}
|
|
281
|
+
|
|
282
|
+
if (conditionTextarea) {
|
|
283
|
+
conditionTextarea.addEventListener('input', updatePreview);
|
|
284
|
+
// Also update on paste
|
|
285
|
+
conditionTextarea.addEventListener('paste', function() {
|
|
286
|
+
setTimeout(updatePreview, 10);
|
|
287
|
+
});
|
|
288
|
+
}
|
|
289
|
+
|
|
290
|
+
// Sync form values before submitting back-to-edit form
|
|
291
|
+
if (backToEditForm) {
|
|
292
|
+
backToEditForm.addEventListener('submit', function(e) {
|
|
293
|
+
syncBackToEditForm();
|
|
294
|
+
// Ensure CSRF token is included
|
|
295
|
+
if (typeof window.ensureCsrfToken === 'function') {
|
|
296
|
+
window.ensureCsrfToken(backToEditForm);
|
|
297
|
+
}
|
|
298
|
+
});
|
|
299
|
+
}
|
|
300
|
+
|
|
301
|
+
// Initial update
|
|
302
|
+
updatePreview();
|
|
303
|
+
});
|
|
304
|
+
})();
|
|
305
|
+
</script>
|
|
@@ -0,0 +1,221 @@
|
|
|
1
|
+
<%# Confirmation Modal for Dangerous Operations %>
|
|
2
|
+
<%#
|
|
3
|
+
Usage:
|
|
4
|
+
<%= render 'pg_sql_triggers/shared/confirmation_modal',
|
|
5
|
+
operation: :trigger_migrate_up,
|
|
6
|
+
form_id: 'migration-form',
|
|
7
|
+
title: 'Confirm Migration',
|
|
8
|
+
message: 'Are you sure you want to run this migration?'
|
|
9
|
+
%>
|
|
10
|
+
|
|
11
|
+
<%
|
|
12
|
+
operation = local_assigns[:operation] || :unknown_operation
|
|
13
|
+
form_id = local_assigns[:form_id] || 'confirmation-form'
|
|
14
|
+
title = local_assigns[:title] || 'Confirm Action'
|
|
15
|
+
message = local_assigns[:message] || 'This action requires confirmation.'
|
|
16
|
+
|
|
17
|
+
# Generate expected confirmation text
|
|
18
|
+
if PgSqlTriggers.respond_to?(:kill_switch_confirmation_pattern) &&
|
|
19
|
+
PgSqlTriggers.kill_switch_confirmation_pattern.respond_to?(:call)
|
|
20
|
+
expected_confirmation = PgSqlTriggers.kill_switch_confirmation_pattern.call(operation)
|
|
21
|
+
else
|
|
22
|
+
expected_confirmation = "EXECUTE #{operation.to_s.upcase}"
|
|
23
|
+
end
|
|
24
|
+
%>
|
|
25
|
+
|
|
26
|
+
<div id="kill-switch-modal-<%= form_id %>" class="kill-switch-modal" style="display: none; position: fixed; z-index: 1000; left: 0; top: 0; width: 100%; height: 100%; overflow: auto; background-color: rgba(0,0,0,0.4);">
|
|
27
|
+
<div class="modal-content" style="background-color: #fefefe; margin: 10% auto; padding: 20px; border: 1px solid #888; border-radius: 8px; width: 80%; max-width: 600px; box-shadow: 0 4px 6px rgba(0,0,0,0.1); box-sizing: border-box;">
|
|
28
|
+
<div class="modal-header" style="border-bottom: 1px solid #dee2e6; padding-bottom: 12px; margin-bottom: 16px;">
|
|
29
|
+
<h3 style="margin: 0; color: #dc3545; display: flex; align-items: center; gap: 8px;">
|
|
30
|
+
<span style="font-size: 24px;">⚠️</span>
|
|
31
|
+
<%= title %>
|
|
32
|
+
</h3>
|
|
33
|
+
</div>
|
|
34
|
+
|
|
35
|
+
<div class="modal-body" style="margin-bottom: 20px; box-sizing: border-box;">
|
|
36
|
+
<p style="color: #495057; line-height: 1.6;"><%= message %></p>
|
|
37
|
+
|
|
38
|
+
<% if kill_switch_active? %>
|
|
39
|
+
<div style="background-color: #fff3cd; border: 1px solid #ffc107; border-radius: 4px; padding: 12px; margin: 16px 0; box-sizing: border-box;">
|
|
40
|
+
<p style="margin: 0 0 8px 0; font-weight: bold; color: #856404;">
|
|
41
|
+
🛡️ Kill Switch Protection Active
|
|
42
|
+
</p>
|
|
43
|
+
<p style="margin: 0 0 12px 0; color: #856404; font-size: 14px;">
|
|
44
|
+
You must enter the exact confirmation text below to proceed:
|
|
45
|
+
</p>
|
|
46
|
+
<div style="background-color: #f8f9fa; border: 2px solid #ffc107; border-radius: 4px; padding: 8px; font-family: monospace; font-size: 14px; font-weight: bold; text-align: center; color: #856404; word-break: break-word; box-sizing: border-box;">
|
|
47
|
+
<%= expected_confirmation %>
|
|
48
|
+
</div>
|
|
49
|
+
</div>
|
|
50
|
+
|
|
51
|
+
<div style="margin-top: 16px; box-sizing: border-box;">
|
|
52
|
+
<label for="confirmation-text-<%= form_id %>" style="display: block; margin-bottom: 8px; font-weight: bold; color: #495057;">
|
|
53
|
+
Confirmation Text:
|
|
54
|
+
</label>
|
|
55
|
+
<input
|
|
56
|
+
type="text"
|
|
57
|
+
id="confirmation-text-<%= form_id %>"
|
|
58
|
+
name="confirmation_text"
|
|
59
|
+
class="form-control"
|
|
60
|
+
placeholder="Type the confirmation text above"
|
|
61
|
+
style="width: 100%; max-width: 100%; padding: 8px 12px; border: 1px solid #ced4da; border-radius: 4px; font-family: monospace; box-sizing: border-box;"
|
|
62
|
+
autocomplete="off">
|
|
63
|
+
<small style="display: block; margin-top: 4px; color: #6c757d;">
|
|
64
|
+
Case-sensitive. Must match exactly.
|
|
65
|
+
</small>
|
|
66
|
+
</div>
|
|
67
|
+
<% end %>
|
|
68
|
+
</div>
|
|
69
|
+
|
|
70
|
+
<div class="modal-footer" style="border-top: 1px solid #dee2e6; padding-top: 12px; display: flex; gap: 8px; justify-content: flex-end;">
|
|
71
|
+
<button
|
|
72
|
+
type="button"
|
|
73
|
+
class="btn-cancel"
|
|
74
|
+
onclick="closeKillSwitchModal('<%= form_id %>')"
|
|
75
|
+
style="padding: 8px 16px; border: 1px solid #6c757d; background-color: #6c757d; color: white; border-radius: 4px; cursor: pointer;">
|
|
76
|
+
Cancel
|
|
77
|
+
</button>
|
|
78
|
+
|
|
79
|
+
<% if kill_switch_active? %>
|
|
80
|
+
<button
|
|
81
|
+
type="button"
|
|
82
|
+
class="btn-confirm"
|
|
83
|
+
onclick="submitWithConfirmation('<%= form_id %>', '<%= expected_confirmation %>')"
|
|
84
|
+
style="padding: 8px 16px; border: 1px solid #dc3545; background-color: #dc3545; color: white; border-radius: 4px; cursor: pointer;">
|
|
85
|
+
Confirm & Proceed
|
|
86
|
+
</button>
|
|
87
|
+
<% else %>
|
|
88
|
+
<button
|
|
89
|
+
type="button"
|
|
90
|
+
class="btn-confirm"
|
|
91
|
+
onclick="submitWithoutConfirmation('<%= form_id %>')"
|
|
92
|
+
style="padding: 8px 16px; border: 1px solid #007bff; background-color: #007bff; color: white; border-radius: 4px; cursor: pointer;">
|
|
93
|
+
Proceed
|
|
94
|
+
</button>
|
|
95
|
+
<% end %>
|
|
96
|
+
</div>
|
|
97
|
+
</div>
|
|
98
|
+
</div>
|
|
99
|
+
|
|
100
|
+
<script>
|
|
101
|
+
// Only define functions once
|
|
102
|
+
if (typeof window.showKillSwitchModal === 'undefined') {
|
|
103
|
+
window.showKillSwitchModal = function(formId) {
|
|
104
|
+
const modal = document.getElementById('kill-switch-modal-' + formId);
|
|
105
|
+
if (modal) {
|
|
106
|
+
modal.style.display = 'block';
|
|
107
|
+
// Focus on confirmation input if it exists
|
|
108
|
+
const confirmationInput = document.getElementById('confirmation-text-' + formId);
|
|
109
|
+
if (confirmationInput) {
|
|
110
|
+
setTimeout(() => confirmationInput.focus(), 100);
|
|
111
|
+
}
|
|
112
|
+
}
|
|
113
|
+
};
|
|
114
|
+
|
|
115
|
+
window.closeKillSwitchModal = function(formId) {
|
|
116
|
+
const modal = document.getElementById('kill-switch-modal-' + formId);
|
|
117
|
+
if (modal) {
|
|
118
|
+
modal.style.display = 'none';
|
|
119
|
+
// Clear confirmation input
|
|
120
|
+
const confirmationInput = document.getElementById('confirmation-text-' + formId);
|
|
121
|
+
if (confirmationInput) {
|
|
122
|
+
confirmationInput.value = '';
|
|
123
|
+
}
|
|
124
|
+
}
|
|
125
|
+
};
|
|
126
|
+
|
|
127
|
+
window.submitWithConfirmation = function(formId, expectedConfirmation) {
|
|
128
|
+
const confirmationInput = document.getElementById('confirmation-text-' + formId);
|
|
129
|
+
const confirmationText = confirmationInput ? confirmationInput.value.trim() : '';
|
|
130
|
+
|
|
131
|
+
if (confirmationText !== expectedConfirmation) {
|
|
132
|
+
alert('Invalid confirmation text. Please type exactly: ' + expectedConfirmation);
|
|
133
|
+
return;
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
// Add confirmation text to the form
|
|
137
|
+
const form = document.getElementById(formId);
|
|
138
|
+
if (form) {
|
|
139
|
+
// Ensure CSRF token is included
|
|
140
|
+
if (typeof window.ensureCsrfToken === 'function') {
|
|
141
|
+
window.ensureCsrfToken(form);
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
// Remove any existing confirmation_text input
|
|
145
|
+
const existingInput = form.querySelector('input[name="confirmation_text"]');
|
|
146
|
+
if (existingInput && existingInput !== confirmationInput) {
|
|
147
|
+
existingInput.remove();
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
// Create a hidden input with the confirmation text
|
|
151
|
+
const hiddenInput = document.createElement('input');
|
|
152
|
+
hiddenInput.type = 'hidden';
|
|
153
|
+
hiddenInput.name = 'confirmation_text';
|
|
154
|
+
hiddenInput.value = confirmationText;
|
|
155
|
+
form.appendChild(hiddenInput);
|
|
156
|
+
|
|
157
|
+
form.submit();
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
window.closeKillSwitchModal(formId);
|
|
161
|
+
};
|
|
162
|
+
|
|
163
|
+
window.submitWithoutConfirmation = function(formId) {
|
|
164
|
+
const form = document.getElementById(formId);
|
|
165
|
+
if (form) {
|
|
166
|
+
// Ensure CSRF token is included
|
|
167
|
+
if (typeof window.ensureCsrfToken === 'function') {
|
|
168
|
+
window.ensureCsrfToken(form);
|
|
169
|
+
}
|
|
170
|
+
form.submit();
|
|
171
|
+
}
|
|
172
|
+
window.closeKillSwitchModal(formId);
|
|
173
|
+
};
|
|
174
|
+
|
|
175
|
+
// Helper function to ensure CSRF token is included in form (globally available)
|
|
176
|
+
window.ensureCsrfToken = function(form) {
|
|
177
|
+
// Check if form already has a CSRF token
|
|
178
|
+
const existingToken = form.querySelector('input[name="authenticity_token"]');
|
|
179
|
+
if (existingToken) {
|
|
180
|
+
return; // Token already exists
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
// Get CSRF token from meta tag
|
|
184
|
+
const csrfMetaTag = document.querySelector('meta[name="csrf-token"]');
|
|
185
|
+
if (csrfMetaTag) {
|
|
186
|
+
const token = csrfMetaTag.getAttribute('content');
|
|
187
|
+
if (token) {
|
|
188
|
+
// Create hidden input with CSRF token
|
|
189
|
+
const tokenInput = document.createElement('input');
|
|
190
|
+
tokenInput.type = 'hidden';
|
|
191
|
+
tokenInput.name = 'authenticity_token';
|
|
192
|
+
tokenInput.value = token;
|
|
193
|
+
form.appendChild(tokenInput);
|
|
194
|
+
}
|
|
195
|
+
}
|
|
196
|
+
};
|
|
197
|
+
|
|
198
|
+
// Close modal when clicking outside of it (only add listener once)
|
|
199
|
+
if (!window._killSwitchModalListenersAttached) {
|
|
200
|
+
window.onclick = function(event) {
|
|
201
|
+
if (event.target.className === 'kill-switch-modal') {
|
|
202
|
+
event.target.style.display = 'none';
|
|
203
|
+
}
|
|
204
|
+
};
|
|
205
|
+
|
|
206
|
+
// Close modal on Escape key
|
|
207
|
+
document.addEventListener('keydown', function(event) {
|
|
208
|
+
if (event.key === 'Escape') {
|
|
209
|
+
const modals = document.querySelectorAll('.kill-switch-modal');
|
|
210
|
+
modals.forEach(modal => {
|
|
211
|
+
if (modal.style.display === 'block') {
|
|
212
|
+
modal.style.display = 'none';
|
|
213
|
+
}
|
|
214
|
+
});
|
|
215
|
+
}
|
|
216
|
+
});
|
|
217
|
+
|
|
218
|
+
window._killSwitchModalListenersAttached = true;
|
|
219
|
+
}
|
|
220
|
+
}
|
|
221
|
+
</script>
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
<%# Kill Switch Status Indicator %>
|
|
2
|
+
<%# Displays the current kill switch status and environment warning if applicable %>
|
|
3
|
+
|
|
4
|
+
<%
|
|
5
|
+
# Debug info (can be removed in production)
|
|
6
|
+
env = current_environment
|
|
7
|
+
is_active = kill_switch_active?
|
|
8
|
+
protected_envs = PgSqlTriggers.respond_to?(:kill_switch_environments) ? PgSqlTriggers.kill_switch_environments : %i[production staging]
|
|
9
|
+
|
|
10
|
+
# Log for debugging (remove in production)
|
|
11
|
+
Rails.logger.debug "[KILL_SWITCH_VIEW] is_active=#{is_active} env=#{env} protected_envs=#{Array(protected_envs).map(&:to_s).join(',')}"
|
|
12
|
+
%>
|
|
13
|
+
|
|
14
|
+
<% if is_active %>
|
|
15
|
+
<div class="kill-switch-status active" style="background-color: #fff3cd; border: 1px solid #ffc107; border-radius: 4px; padding: 12px; margin-bottom: 16px;">
|
|
16
|
+
<div style="display: flex; align-items: center; gap: 8px;">
|
|
17
|
+
<span style="font-size: 20px;">🛡️</span>
|
|
18
|
+
<div>
|
|
19
|
+
<strong style="color: #856404;">Kill Switch Active</strong>
|
|
20
|
+
<p style="margin: 4px 0 0 0; font-size: 14px; color: #856404;">
|
|
21
|
+
Dangerous operations in <strong><%= env %></strong> require confirmation.
|
|
22
|
+
All operations are logged and audited.
|
|
23
|
+
</p>
|
|
24
|
+
</div>
|
|
25
|
+
</div>
|
|
26
|
+
</div>
|
|
27
|
+
<% else %>
|
|
28
|
+
<div class="kill-switch-status inactive" style="background-color: #d1ecf1; border: 1px solid #bee5eb; border-radius: 4px; padding: 12px; margin-bottom: 16px;">
|
|
29
|
+
<div style="display: flex; align-items: center; gap: 8px;">
|
|
30
|
+
<span style="font-size: 20px;">ℹ️</span>
|
|
31
|
+
<div>
|
|
32
|
+
<strong style="color: #0c5460;">Kill Switch Inactive</strong>
|
|
33
|
+
<p style="margin: 4px 0 0 0; font-size: 14px; color: #0c5460;">
|
|
34
|
+
Kill switch is disabled in <strong><%= env %></strong>.
|
|
35
|
+
Operations can be performed without confirmation.
|
|
36
|
+
</p>
|
|
37
|
+
</div>
|
|
38
|
+
</div>
|
|
39
|
+
</div>
|
|
40
|
+
<% end %>
|
|
@@ -63,14 +63,14 @@
|
|
|
63
63
|
</p>
|
|
64
64
|
</div>
|
|
65
65
|
</div>
|
|
66
|
-
|
|
66
|
+
|
|
67
67
|
<% if trigger.definition.present? %>
|
|
68
68
|
<% definition = JSON.parse(trigger.definition) rescue {} %>
|
|
69
69
|
<div style="margin-bottom: 1rem;">
|
|
70
70
|
<strong>Function:</strong> <code style="background: #e9ecef; padding: 0.25rem 0.5rem; border-radius: 2px;"><%= definition["function_name"] || "N/A" %></code>
|
|
71
71
|
<% if definition["events"].present? %>
|
|
72
72
|
<span style="margin-left: 1rem;">
|
|
73
|
-
<strong>Events:</strong>
|
|
73
|
+
<strong>Events:</strong>
|
|
74
74
|
<% definition["events"].each do |event| %>
|
|
75
75
|
<span class="badge badge-info" style="margin-left: 0.25rem;"><%= event %></span>
|
|
76
76
|
<% end %>
|
|
@@ -78,7 +78,7 @@
|
|
|
78
78
|
<% end %>
|
|
79
79
|
</div>
|
|
80
80
|
<% end %>
|
|
81
|
-
|
|
81
|
+
|
|
82
82
|
<% if trigger.function_body.present? %>
|
|
83
83
|
<details style="margin-top: 1rem;">
|
|
84
84
|
<summary style="cursor: pointer; font-weight: 600; color: #495057;">View Function Body</summary>
|
|
@@ -123,4 +123,3 @@
|
|
|
123
123
|
</div>
|
|
124
124
|
</div>
|
|
125
125
|
<% end %>
|
|
126
|
-
|
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
PgSqlTriggers.configure do |config|
|
|
4
|
+
# ========== Kill Switch Configuration ==========
|
|
5
|
+
# The Kill Switch is a safety mechanism that prevents accidental destructive operations
|
|
6
|
+
# in protected environments (production, staging, etc.)
|
|
7
|
+
|
|
8
|
+
# Enable or disable the kill switch globally
|
|
9
|
+
# Default: true (recommended for safety)
|
|
10
|
+
config.kill_switch_enabled = true
|
|
11
|
+
|
|
12
|
+
# Specify which environments should be protected by the kill switch
|
|
13
|
+
# Default: %i[production staging]
|
|
14
|
+
config.kill_switch_environments = %i[production staging]
|
|
15
|
+
|
|
16
|
+
# Require confirmation text for kill switch overrides
|
|
17
|
+
# When true, users must type a specific confirmation text to proceed
|
|
18
|
+
# Default: true (recommended for maximum safety)
|
|
19
|
+
config.kill_switch_confirmation_required = true
|
|
20
|
+
|
|
21
|
+
# Custom confirmation pattern generator
|
|
22
|
+
# Takes an operation symbol and returns the required confirmation text
|
|
23
|
+
# Default: "EXECUTE <OPERATION_NAME>"
|
|
24
|
+
config.kill_switch_confirmation_pattern = ->(operation) { "EXECUTE #{operation.to_s.upcase}" }
|
|
25
|
+
|
|
26
|
+
# Logger for kill switch events
|
|
27
|
+
# Default: Rails.logger
|
|
28
|
+
config.kill_switch_logger = Rails.logger
|
|
29
|
+
|
|
30
|
+
# Enable audit trail for kill switch events (optional enhancement)
|
|
31
|
+
# When enabled, all kill switch events are logged to a database table
|
|
32
|
+
# Default: false (can be enabled later)
|
|
33
|
+
# config.kill_switch_audit_trail_enabled = false
|
|
34
|
+
|
|
35
|
+
# Time-window auto-lock configuration (optional enhancement)
|
|
36
|
+
# Automatically enable kill switch during specific time windows
|
|
37
|
+
# Default: false
|
|
38
|
+
# config.kill_switch_auto_lock_enabled = false
|
|
39
|
+
# config.kill_switch_auto_lock_window = 30.minutes
|
|
40
|
+
# config.kill_switch_auto_lock_after = -> { Time.current.hour.between?(22, 6) } # Night hours
|
|
41
|
+
|
|
42
|
+
# Set the default environment detection
|
|
43
|
+
# By default, uses Rails.env
|
|
44
|
+
config.default_environment = -> { Rails.env }
|
|
45
|
+
|
|
46
|
+
# Set a custom permission checker
|
|
47
|
+
# This should return true/false based on the actor, action, and environment
|
|
48
|
+
# Example:
|
|
49
|
+
# config.permission_checker = ->(actor, action, environment) {
|
|
50
|
+
# # Your custom permission logic here
|
|
51
|
+
# # e.g., check if actor has required role for the action
|
|
52
|
+
# true
|
|
53
|
+
# }
|
|
54
|
+
config.permission_checker = nil
|
|
55
|
+
|
|
56
|
+
# Tables to exclude from listing in the UI
|
|
57
|
+
# Default excluded tables: ar_internal_metadata, schema_migrations, pg_sql_triggers_registry, trigger_migrations
|
|
58
|
+
# Add additional tables you want to exclude:
|
|
59
|
+
# config.excluded_tables = %w[audit_logs temporary_data]
|
|
60
|
+
config.excluded_tables = []
|
|
61
|
+
|
|
62
|
+
# ========== Migration Safety Configuration ==========
|
|
63
|
+
# Prevent unsafe DROP + CREATE operations in migrations
|
|
64
|
+
# When false (default), migrations with DROP + CREATE patterns will be blocked
|
|
65
|
+
# Set to true to allow unsafe operations (not recommended)
|
|
66
|
+
# You can also override per-migration with ALLOW_UNSAFE_MIGRATIONS=true environment variable
|
|
67
|
+
# Default: false (recommended for safety)
|
|
68
|
+
config.allow_unsafe_migrations = false
|
|
69
|
+
end
|