pg_sql_triggers 1.2.0 → 1.4.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 (78) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +397 -1
  3. data/COVERAGE.md +26 -19
  4. data/GEM_ANALYSIS.md +368 -0
  5. data/Goal.md +276 -155
  6. data/README.md +45 -22
  7. data/app/assets/javascripts/pg_sql_triggers/trigger_actions.js +50 -0
  8. data/app/controllers/concerns/pg_sql_triggers/error_handling.rb +56 -0
  9. data/app/controllers/concerns/pg_sql_triggers/kill_switch_protection.rb +66 -0
  10. data/app/controllers/concerns/pg_sql_triggers/permission_checking.rb +117 -0
  11. data/app/controllers/pg_sql_triggers/application_controller.rb +10 -62
  12. data/app/controllers/pg_sql_triggers/audit_logs_controller.rb +102 -0
  13. data/app/controllers/pg_sql_triggers/dashboard_controller.rb +4 -9
  14. data/app/controllers/pg_sql_triggers/tables_controller.rb +30 -4
  15. data/app/controllers/pg_sql_triggers/triggers_controller.rb +3 -21
  16. data/app/helpers/pg_sql_triggers/permissions_helper.rb +43 -0
  17. data/app/models/pg_sql_triggers/audit_log.rb +106 -0
  18. data/app/models/pg_sql_triggers/trigger_registry.rb +218 -13
  19. data/app/views/layouts/pg_sql_triggers/application.html.erb +25 -6
  20. data/app/views/pg_sql_triggers/audit_logs/index.html.erb +177 -0
  21. data/app/views/pg_sql_triggers/dashboard/index.html.erb +34 -12
  22. data/app/views/pg_sql_triggers/tables/index.html.erb +75 -5
  23. data/app/views/pg_sql_triggers/tables/show.html.erb +17 -6
  24. data/app/views/pg_sql_triggers/triggers/_drop_modal.html.erb +16 -7
  25. data/app/views/pg_sql_triggers/triggers/_re_execute_modal.html.erb +16 -7
  26. data/app/views/pg_sql_triggers/triggers/show.html.erb +26 -6
  27. data/config/routes.rb +2 -14
  28. data/db/migrate/20260103000001_create_pg_sql_triggers_audit_log.rb +28 -0
  29. data/db/migrate/20260228000001_add_for_each_to_pg_sql_triggers_registry.rb +8 -0
  30. data/docs/README.md +15 -5
  31. data/docs/api-reference.md +233 -151
  32. data/docs/audit-trail.md +413 -0
  33. data/docs/configuration.md +28 -7
  34. data/docs/getting-started.md +17 -16
  35. data/docs/permissions.md +369 -0
  36. data/docs/troubleshooting.md +486 -0
  37. data/docs/ui-guide.md +211 -0
  38. data/docs/usage-guide.md +38 -67
  39. data/docs/web-ui.md +251 -128
  40. data/lib/generators/pg_sql_triggers/templates/trigger_dsl.rb.tt +11 -0
  41. data/lib/generators/pg_sql_triggers/templates/trigger_migration_full.rb.tt +29 -0
  42. data/lib/generators/pg_sql_triggers/trigger_generator.rb +83 -0
  43. data/lib/pg_sql_triggers/drift/db_queries.rb +12 -8
  44. data/lib/pg_sql_triggers/drift/detector.rb +51 -38
  45. data/lib/pg_sql_triggers/dsl/trigger_definition.rb +17 -23
  46. data/lib/pg_sql_triggers/engine.rb +14 -0
  47. data/lib/pg_sql_triggers/errors.rb +245 -0
  48. data/lib/pg_sql_triggers/migrator/pre_apply_comparator.rb +8 -9
  49. data/lib/pg_sql_triggers/migrator/safety_validator.rb +32 -12
  50. data/lib/pg_sql_triggers/migrator.rb +53 -6
  51. data/lib/pg_sql_triggers/permissions/checker.rb +9 -2
  52. data/lib/pg_sql_triggers/registry/manager.rb +36 -11
  53. data/lib/pg_sql_triggers/registry/validator.rb +62 -5
  54. data/lib/pg_sql_triggers/registry.rb +141 -8
  55. data/lib/pg_sql_triggers/sql/kill_switch.rb +153 -247
  56. data/lib/pg_sql_triggers/sql.rb +0 -6
  57. data/lib/pg_sql_triggers/testing/function_tester.rb +2 -0
  58. data/lib/pg_sql_triggers/version.rb +1 -1
  59. data/lib/pg_sql_triggers.rb +7 -7
  60. data/pg_sql_triggers.gemspec +53 -0
  61. metadata +35 -18
  62. data/app/controllers/pg_sql_triggers/generator_controller.rb +0 -213
  63. data/app/controllers/pg_sql_triggers/sql_capsules_controller.rb +0 -161
  64. data/app/views/pg_sql_triggers/generator/new.html.erb +0 -388
  65. data/app/views/pg_sql_triggers/generator/preview.html.erb +0 -305
  66. data/app/views/pg_sql_triggers/sql_capsules/new.html.erb +0 -81
  67. data/app/views/pg_sql_triggers/sql_capsules/show.html.erb +0 -85
  68. data/docs/screenshots/.gitkeep +0 -1
  69. data/docs/screenshots/Generate Trigger.png +0 -0
  70. data/docs/screenshots/Triggers Page.png +0 -0
  71. data/docs/screenshots/kill error.png +0 -0
  72. data/docs/screenshots/kill modal for migration down.png +0 -0
  73. data/lib/generators/trigger/migration_generator.rb +0 -60
  74. data/lib/pg_sql_triggers/generator/form.rb +0 -80
  75. data/lib/pg_sql_triggers/generator/service.rb +0 -307
  76. data/lib/pg_sql_triggers/generator.rb +0 -8
  77. data/lib/pg_sql_triggers/sql/capsule.rb +0 -79
  78. data/lib/pg_sql_triggers/sql/executor.rb +0 -200
@@ -0,0 +1,177 @@
1
+ <%# Audit Log Index Page %>
2
+ <div style="max-width: 1400px; margin: 0 auto; padding: 2rem;">
3
+ <div style="display: flex; justify-content: space-between; align-items: center; margin-bottom: 2rem;">
4
+ <h1 style="margin: 0;">Audit Log</h1>
5
+ <div style="display: flex; gap: 1rem;">
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 %>
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
+ </div>
10
+ </div>
11
+
12
+ <!-- Filters -->
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
+ <h3 style="margin-top: 0; margin-bottom: 1rem;">Filters</h3>
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>
17
+ <%= f.label :trigger_name, "Trigger Name", style: "display: block; margin-bottom: 0.5rem; font-weight: 600;" %>
18
+ <%= 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;" } %>
19
+ </div>
20
+
21
+ <div>
22
+ <%= f.label :operation, "Operation", style: "display: block; margin-bottom: 0.5rem; font-weight: 600;" %>
23
+ <%= f.select :operation, options_for_select([["All", ""]] + @available_operations.map { |o| [o.humanize, o] }, params[:operation]), {}, { style: "width: 100%; padding: 0.5rem; border: 1px solid #ced4da; border-radius: 4px;" } %>
24
+ </div>
25
+
26
+ <div>
27
+ <%= f.label :status, "Status", style: "display: block; margin-bottom: 0.5rem; font-weight: 600;" %>
28
+ <%= f.select :status, options_for_select([["All", ""], ["Success", "success"], ["Failure", "failure"]], params[:status]), {}, { style: "width: 100%; padding: 0.5rem; border: 1px solid #ced4da; border-radius: 4px;" } %>
29
+ </div>
30
+
31
+ <div>
32
+ <%= f.label :environment, "Environment", style: "display: block; margin-bottom: 0.5rem; font-weight: 600;" %>
33
+ <%= f.select :environment, options_for_select([["All", ""]] + @available_environments.map { |e| [e.humanize, e] }, params[:environment]), {}, { style: "width: 100%; padding: 0.5rem; border: 1px solid #ced4da; border-radius: 4px;" } %>
34
+ </div>
35
+
36
+ <div>
37
+ <%= f.label :sort, "Sort Order", style: "display: block; margin-bottom: 0.5rem; font-weight: 600;" %>
38
+ <%= f.select :sort, options_for_select([["Newest First", "desc"], ["Oldest First", "asc"]], params[:sort] || "desc"), {}, { style: "width: 100%; padding: 0.5rem; border: 1px solid #ced4da; border-radius: 4px;" } %>
39
+ </div>
40
+
41
+ <div style="display: flex; align-items: flex-end; gap: 0.5rem;">
42
+ <%= f.submit "Apply Filters", style: "padding: 0.5rem 1rem; background: #007bff; color: white; border: none; border-radius: 4px; cursor: pointer; font-weight: 600;" %>
43
+ <%= link_to "Clear", audit_logs_path, style: "padding: 0.5rem 1rem; background: #6c757d; color: white; text-decoration: none; border-radius: 4px; display: inline-block;" %>
44
+ </div>
45
+ <% end %>
46
+ </div>
47
+
48
+ <!-- Results Summary -->
49
+ <div style="background: #f8f9fa; padding: 1rem; border-radius: 4px; margin-bottom: 1rem;">
50
+ <strong>Total Results:</strong> <%= @total_count %> entries
51
+ <% if params[:trigger_name].present? || params[:operation].present? || params[:status].present? || params[:environment].present? %>
52
+ <span style="color: #6c757d;">(filtered)</span>
53
+ <% end %>
54
+ </div>
55
+
56
+ <!-- Audit Log Table -->
57
+ <% if @audit_logs.any? %>
58
+ <div style="background: white; border-radius: 4px; box-shadow: 0 1px 3px rgba(0,0,0,0.1); overflow-x: auto;">
59
+ <table style="width: 100%; border-collapse: collapse;">
60
+ <thead>
61
+ <tr style="background: #f8f9fa; border-bottom: 2px solid #dee2e6;">
62
+ <th style="padding: 0.75rem; text-align: left; font-weight: 600;">Time</th>
63
+ <th style="padding: 0.75rem; text-align: left; font-weight: 600;">Trigger</th>
64
+ <th style="padding: 0.75rem; text-align: left; font-weight: 600;">Operation</th>
65
+ <th style="padding: 0.75rem; text-align: left; font-weight: 600;">Status</th>
66
+ <th style="padding: 0.75rem; text-align: left; font-weight: 600;">Environment</th>
67
+ <th style="padding: 0.75rem; text-align: left; font-weight: 600;">Actor</th>
68
+ <th style="padding: 0.75rem; text-align: left; font-weight: 600;">Reason</th>
69
+ <th style="padding: 0.75rem; text-align: left; font-weight: 600;">Error</th>
70
+ </tr>
71
+ </thead>
72
+ <tbody>
73
+ <% @audit_logs.each do |log| %>
74
+ <tr style="border-bottom: 1px solid #dee2e6;">
75
+ <td style="padding: 0.75rem;">
76
+ <span title="<%= log.created_at.strftime("%Y-%m-%d %H:%M:%S %Z") %>" style="cursor: help;">
77
+ <%= distance_of_time_in_words_to_now(log.created_at) %> ago
78
+ </span>
79
+ <br>
80
+ <small style="color: #6c757d;"><%= log.created_at.strftime("%Y-%m-%d %H:%M:%S") %></small>
81
+ </td>
82
+ <td style="padding: 0.75rem;">
83
+ <% if log.trigger_name.present? %>
84
+ <%= link_to log.trigger_name, trigger_path(PgSqlTriggers::TriggerRegistry.find_by(trigger_name: log.trigger_name)), style: "color: #007bff; text-decoration: none;" rescue log.trigger_name %>
85
+ <% else %>
86
+ <span style="color: #6c757d;">—</span>
87
+ <% end %>
88
+ </td>
89
+ <td style="padding: 0.75rem;">
90
+ <code style="background: #f8f9fa; padding: 0.25rem 0.5rem; border-radius: 3px; font-size: 0.875rem;"><%= log.operation %></code>
91
+ </td>
92
+ <td style="padding: 0.75rem;">
93
+ <% if log.status == "success" %>
94
+ <span style="background: #d4edda; color: #155724; padding: 0.25rem 0.5rem; border-radius: 3px; font-size: 0.875rem; font-weight: 600;">Success</span>
95
+ <% else %>
96
+ <span style="background: #f8d7da; color: #721c24; padding: 0.25rem 0.5rem; border-radius: 3px; font-size: 0.875rem; font-weight: 600;">Failure</span>
97
+ <% end %>
98
+ </td>
99
+ <td style="padding: 0.75rem;">
100
+ <% if log.environment.present? %>
101
+ <span class="badge badge-info"><%= log.environment %></span>
102
+ <% else %>
103
+ <span style="color: #6c757d;">—</span>
104
+ <% end %>
105
+ </td>
106
+ <td style="padding: 0.75rem;">
107
+ <% if log.actor.present? %>
108
+ <% actor_type = log.actor.is_a?(Hash) ? (log.actor["type"] || log.actor[:type]) : nil %>
109
+ <% actor_id = log.actor.is_a?(Hash) ? (log.actor["id"] || log.actor[:id]) : nil %>
110
+ <% if actor_type && actor_id %>
111
+ <code style="background: #f8f9fa; padding: 0.25rem 0.5rem; border-radius: 3px; font-size: 0.875rem;"><%= "#{actor_type}:#{actor_id}" %></code>
112
+ <% else %>
113
+ <span style="color: #6c757d;">—</span>
114
+ <% end %>
115
+ <% else %>
116
+ <span style="color: #6c757d;">—</span>
117
+ <% end %>
118
+ </td>
119
+ <td style="padding: 0.75rem;">
120
+ <% if log.reason.present? %>
121
+ <span title="<%= log.reason %>" style="cursor: help;">
122
+ <%= truncate(log.reason, length: 50) %>
123
+ </span>
124
+ <% else %>
125
+ <span style="color: #6c757d;">—</span>
126
+ <% end %>
127
+ </td>
128
+ <td style="padding: 0.75rem;">
129
+ <% if log.error_message.present? %>
130
+ <span style="color: #dc3545; cursor: help;" title="<%= log.error_message %>">
131
+ <%= truncate(log.error_message, length: 50) %>
132
+ </span>
133
+ <% else %>
134
+ <span style="color: #6c757d;">—</span>
135
+ <% end %>
136
+ </td>
137
+ </tr>
138
+ <% end %>
139
+ </tbody>
140
+ </table>
141
+ </div>
142
+
143
+ <!-- Pagination -->
144
+ <% if @total_pages > 1 %>
145
+ <div style="display: flex; justify-content: center; align-items: center; gap: 1rem; margin-top: 2rem;">
146
+ <% if @page > 1 %>
147
+ <% prev_params = params.except(:page).permit(:trigger_name, :operation, :status, :environment, :sort, :per_page).to_h %>
148
+ <%= 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
+ <% else %>
150
+ <span style="padding: 0.5rem 1rem; background: #e9ecef; color: #6c757d; border-radius: 4px; cursor: not-allowed;">« Previous</span>
151
+ <% end %>
152
+
153
+ <span style="color: #6c757d;">
154
+ Page <%= @page %> of <%= @total_pages %>
155
+ </span>
156
+
157
+ <% if @page < @total_pages %>
158
+ <% next_params = params.except(:page).permit(:trigger_name, :operation, :status, :environment, :sort, :per_page).to_h %>
159
+ <%= 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
+ <% else %>
161
+ <span style="padding: 0.5rem 1rem; background: #e9ecef; color: #6c757d; border-radius: 4px; cursor: not-allowed;">Next »</span>
162
+ <% end %>
163
+ </div>
164
+ <% end %>
165
+ <% else %>
166
+ <div style="background: white; padding: 3rem; border-radius: 4px; box-shadow: 0 1px 3px rgba(0,0,0,0.1); text-align: center;">
167
+ <h3 style="margin-top: 0; color: #6c757d;">No audit log entries found</h3>
168
+ <p style="color: #6c757d;">
169
+ <% if params[:trigger_name].present? || params[:operation].present? || params[:status].present? || params[:environment].present? %>
170
+ Try adjusting your filters or <%= link_to "clear filters", audit_logs_path, style: "color: #007bff;" %>.
171
+ <% else %>
172
+ Audit log entries will appear here as operations are performed.
173
+ <% end %>
174
+ </p>
175
+ </div>
176
+ <% end %>
177
+ </div>
@@ -2,9 +2,6 @@
2
2
  <h2 style="margin: 0;">Trigger Dashboard</h2>
3
3
  <div style="display: flex; gap: 1rem;">
4
4
  <%= link_to "View Tables", tables_path, class: "btn btn-primary", style: "font-size: 1rem; padding: 0.75rem 1.5rem; text-decoration: none;" %>
5
- <%= link_to "Generate New Trigger", new_generator_path,
6
- class: "btn btn-success",
7
- style: "font-size: 1rem; padding: 0.75rem 1.5rem; text-decoration: none;" %>
8
5
  </div>
9
6
  </div>
10
7
 
@@ -40,6 +37,7 @@
40
37
  <th>Version</th>
41
38
  <th>Status</th>
42
39
  <th>Source</th>
40
+ <th>Last Applied</th>
43
41
  <th>Actions</th>
44
42
  </tr>
45
43
  </thead>
@@ -60,11 +58,20 @@
60
58
  </td>
61
59
  <td><span class="badge badge-info"><%= trigger.source %></span></td>
62
60
  <td>
63
- <% if PgSqlTriggers::Permissions.can?(current_actor, :enable_trigger) %>
64
- <div style="display: flex; gap: 0.5rem; flex-wrap: wrap;">
61
+ <% if trigger.installed_at.present? %>
62
+ <span title="<%= trigger.installed_at.strftime("%Y-%m-%d %H:%M:%S %Z") %>" style="cursor: help;">
63
+ <%= distance_of_time_in_words_to_now(trigger.installed_at) %> ago
64
+ </span>
65
+ <% else %>
66
+ <span style="color: #6c757d;">Never</span>
67
+ <% end %>
68
+ </td>
69
+ <td>
70
+ <div style="display: flex; gap: 0.5rem; flex-wrap: wrap;">
71
+ <% if PgSqlTriggers::Permissions.can?(current_actor, :enable_trigger) %>
65
72
  <% if trigger.enabled %>
66
73
  <% 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| %>
74
+ <%= form_with url: disable_trigger_path(trigger), method: :post, local: false, id: form_id, style: "margin: 0;" do |f| %>
68
75
  <%= f.hidden_field :redirect_to, value: dashboard_path %>
69
76
  <button type="button" onclick="showKillSwitchModal('<%= form_id %>')"
70
77
  style="padding: 0.25rem 0.75rem; background: #dc3545; color: white; border: none; border-radius: 3px; cursor: pointer; font-size: 0.75rem;">
@@ -78,7 +85,7 @@
78
85
  message: "Are you sure you want to disable trigger '#{trigger.trigger_name}'?" %>
79
86
  <% else %>
80
87
  <% 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| %>
88
+ <%= form_with url: enable_trigger_path(trigger), method: :post, local: false, id: form_id, style: "margin: 0;" do |f| %>
82
89
  <%= f.hidden_field :redirect_to, value: dashboard_path %>
83
90
  <button type="button" onclick="showKillSwitchModal('<%= form_id %>')"
84
91
  style="padding: 0.25rem 0.75rem; background: #28a745; color: white; border: none; border-radius: 3px; cursor: pointer; font-size: 0.75rem;">
@@ -91,10 +98,25 @@
91
98
  title: 'Enable Trigger',
92
99
  message: "Are you sure you want to enable trigger '#{trigger.trigger_name}'?" %>
93
100
  <% end %>
94
- </div>
95
- <% else %>
96
-
97
- <% end %>
101
+ <% end %>
102
+
103
+ <% if PgSqlTriggers::Permissions.can?(current_actor, :drop_trigger) %>
104
+ <% begin %>
105
+ <% drift_info = trigger.drift_result %>
106
+ <% 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 %>
108
+ <% end %>
109
+ <% rescue StandardError %>
110
+ <%# Skip if drift detection fails %>
111
+ <% end %>
112
+
113
+ <%= render 'pg_sql_triggers/triggers/drop_modal', trigger: trigger, redirect_to: dashboard_path, button_size: :small %>
114
+ <% end %>
115
+
116
+ <% unless PgSqlTriggers::Permissions.can?(current_actor, :enable_trigger) || PgSqlTriggers::Permissions.can?(current_actor, :drop_trigger) %>
117
+ <span style="color: #6c757d;">—</span>
118
+ <% end %>
119
+ </div>
98
120
  </td>
99
121
  </tr>
100
122
  <% end %>
@@ -104,7 +126,7 @@
104
126
  <div style="background: #e7f3ff; border-left: 4px solid #007bff; padding: 1.5rem; border-radius: 4px;">
105
127
  <h3 style="margin-top: 0;">No triggers yet</h3>
106
128
  <p style="margin-bottom: 1rem;">Get started by generating your first trigger using the form-based wizard.</p>
107
- <%= link_to "Generate New Trigger", new_generator_path, class: "btn btn-primary" %>
129
+ <%= link_to "View Tables", tables_path, class: "btn btn-primary" %>
108
130
  </div>
109
131
  <% end %>
110
132
 
@@ -1,6 +1,5 @@
1
1
  <div style="display: flex; justify-content: space-between; align-items: center; margin-bottom: 2rem;">
2
2
  <h2 style="margin: 0;">Database Tables & Triggers</h2>
3
- <%= link_to "Generate New Trigger", new_generator_path, class: "btn btn-success" %>
4
3
  </div>
5
4
 
6
5
  <!-- Statistics -->
@@ -9,11 +8,37 @@
9
8
  <div style="font-size: 2rem; font-weight: 600; color: #28a745;"><%= @tables_with_trigger_count %></div>
10
9
  <div style="color: #6c757d;">Tables with Triggers</div>
11
10
  </div>
11
+ <div style="background: white; padding: 1.5rem; border-radius: 4px; box-shadow: 0 1px 3px rgba(0,0,0,0.1);">
12
+ <div style="font-size: 2rem; font-weight: 600; color: #6c757d;"><%= @tables_without_trigger_count %></div>
13
+ <div style="color: #6c757d;">Tables without Triggers</div>
14
+ </div>
15
+ <div style="background: white; padding: 1.5rem; border-radius: 4px; box-shadow: 0 1px 3px rgba(0,0,0,0.1);">
16
+ <div style="font-size: 2rem; font-weight: 600; color: #007bff;"><%= @total_tables_count %></div>
17
+ <div style="color: #6c757d;">Total Tables</div>
18
+ </div>
19
+ </div>
20
+
21
+ <!-- Filter Controls -->
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
+ <div style="display: flex; align-items: center; gap: 1.5rem; flex-wrap: wrap;">
24
+ <label style="color: #495057; font-weight: 600; font-size: 0.875rem;">Filter:</label>
25
+ <div style="display: flex; gap: 1rem; flex-wrap: wrap;">
26
+ <%= link_to "All Tables", tables_path(filter: 'all', page: 1, per_page: @per_page),
27
+ 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),
29
+ 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),
31
+ 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
+ </div>
33
+ </div>
12
34
  </div>
13
35
 
14
36
  <!-- Tables List -->
15
37
  <% if @tables_with_triggers.any? %>
16
38
  <div style="background: white; border-radius: 4px; box-shadow: 0 1px 3px rgba(0,0,0,0.1); overflow: hidden;">
39
+ <div style="margin-bottom: 1rem; color: #6c757d; font-size: 0.875rem; padding: 1rem 1rem 0 1rem;">
40
+ Showing <%= (@page - 1) * @per_page + 1 %>-<%= [@page * @per_page, @total_tables].min %> of <%= @total_tables %> tables
41
+ </div>
17
42
  <table style="margin: 0;">
18
43
  <thead>
19
44
  <tr>
@@ -87,17 +112,62 @@
87
112
  </td>
88
113
  <td>
89
114
  <%= link_to "View Details", table_path(table[:table_name]), class: "btn btn-primary", style: "padding: 0.25rem 0.5rem; font-size: 0.875rem;" %>
90
- <%= link_to "Create Trigger", new_generator_path(pg_sql_triggers_generator_form: { table_name: table[:table_name] }), class: "btn btn-success", style: "padding: 0.25rem 0.5rem; font-size: 0.875rem; margin-top: 0.25rem; display: block;" %>
91
115
  </td>
92
116
  </tr>
93
117
  <% end %>
94
118
  </tbody>
95
119
  </table>
120
+
121
+ <!-- Pagination Controls -->
122
+ <% if @total_pages > 1 %>
123
+ <div style="display: flex; justify-content: space-between; align-items: center; margin-top: 1rem; padding: 1rem; border-top: 1px solid #dee2e6;">
124
+ <div>
125
+ <% if @page > 1 %>
126
+ <%= link_to "← Previous", tables_path(filter: @filter, page: @page - 1, per_page: @per_page),
127
+ style: "padding: 0.5rem 1rem; background: #007bff; color: white; text-decoration: none; border-radius: 4px; margin-right: 0.5rem;" %>
128
+ <% end %>
129
+ <% if @page < @total_pages %>
130
+ <%= link_to "Next →", tables_path(filter: @filter, page: @page + 1, per_page: @per_page),
131
+ style: "padding: 0.5rem 1rem; background: #007bff; color: white; text-decoration: none; border-radius: 4px;" %>
132
+ <% end %>
133
+ </div>
134
+ <div style="color: #6c757d; font-size: 0.875rem;">
135
+ Page <%= @page %> of <%= @total_pages %>
136
+ </div>
137
+ <div style="display: flex; align-items: center; gap: 0.5rem;">
138
+ <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>
147
+ </div>
148
+ <% end %>
96
149
  </div>
97
150
  <% else %>
98
151
  <div style="background: #e7f3ff; border-left: 4px solid #007bff; padding: 1.5rem; border-radius: 4px;">
99
- <h3 style="margin-top: 0;">No tables with triggers found</h3>
100
- <p style="margin-bottom: 1rem;">No tables with triggers were found in the database. Create your first trigger to get started.</p>
101
- <%= link_to "Generate New Trigger", new_generator_path, class: "btn btn-primary" %>
152
+ <h3 style="margin-top: 0;">
153
+ <% if @filter == 'with_triggers' %>
154
+ No tables with triggers found
155
+ <% elsif @filter == 'without_triggers' %>
156
+ No tables without triggers found
157
+ <% else %>
158
+ No tables found
159
+ <% end %>
160
+ </h3>
161
+ <p style="margin-bottom: 1rem;">
162
+ <% if @filter == 'with_triggers' %>
163
+ No tables with triggers were found in the database. Create your first trigger to get started.
164
+ <% elsif @filter == 'without_triggers' %>
165
+ All tables in the database have triggers.
166
+ <% else %>
167
+ No tables were found in the database.
168
+ <% end %>
169
+ </p>
170
+ <% if @filter == 'with_triggers' || @filter == 'all' %>
171
+ <% end %>
102
172
  </div>
103
173
  <% end %>
@@ -1,7 +1,6 @@
1
1
  <div style="margin-bottom: 2rem;">
2
2
  <h2>Table: <%= @table_info[:table_name] %></h2>
3
3
  <%= link_to "← Back to Tables", tables_path, class: "btn", style: "background: #6c757d; color: white; text-decoration: none; margin-right: 1rem;" %>
4
- <%= link_to "Create Trigger for this Table", new_generator_path(pg_sql_triggers_generator_form: { table_name: @table_info[:table_name] }), class: "btn btn-success" %>
5
4
  </div>
6
5
 
7
6
  <!-- Table Information -->
@@ -64,8 +63,8 @@
64
63
  </div>
65
64
  </div>
66
65
 
67
- <% if PgSqlTriggers::Permissions.can?(current_actor, :enable_trigger) %>
68
- <div style="margin-bottom: 1rem; display: flex; gap: 0.5rem;">
66
+ <div style="margin-bottom: 1rem; display: flex; gap: 0.5rem; flex-wrap: wrap;">
67
+ <% if PgSqlTriggers::Permissions.can?(current_actor, :enable_trigger) %>
69
68
  <% if trigger.enabled %>
70
69
  <% form_id = "table-trigger-disable-#{trigger.id}-form" %>
71
70
  <%= form_with url: disable_trigger_path(trigger), method: :post, local: true, id: form_id, style: "margin: 0;" do |f| %>
@@ -95,8 +94,21 @@
95
94
  title: 'Enable Trigger',
96
95
  message: "Are you sure you want to enable trigger '#{trigger.trigger_name}' on table '#{@table_info[:table_name]}'?" %>
97
96
  <% end %>
98
- </div>
99
- <% end %>
97
+ <% end %>
98
+
99
+ <% if PgSqlTriggers::Permissions.can?(current_actor, :drop_trigger) %>
100
+ <% begin %>
101
+ <% drift_info = trigger.drift_result %>
102
+ <% if drift_info && drift_info[:state] == 'drifted' %>
103
+ <%= render 'pg_sql_triggers/triggers/re_execute_modal', trigger: trigger, drift_info: drift_info, redirect_to: table_path(@table_info[:table_name]) %>
104
+ <% end %>
105
+ <% rescue StandardError %>
106
+ <%# Skip if drift detection fails %>
107
+ <% end %>
108
+
109
+ <%= render 'pg_sql_triggers/triggers/drop_modal', trigger: trigger, redirect_to: table_path(@table_info[:table_name]) %>
110
+ <% end %>
111
+ </div>
100
112
 
101
113
  <% if trigger.definition.present? %>
102
114
  <% definition = JSON.parse(trigger.definition) rescue {} %>
@@ -125,7 +137,6 @@
125
137
  <% else %>
126
138
  <div style="background: #e7f3ff; border-left: 4px solid #007bff; padding: 1rem; border-radius: 4px;">
127
139
  <p style="margin: 0;">No registered triggers for this table.</p>
128
- <%= link_to "Create a trigger", new_generator_path(pg_sql_triggers_generator_form: { table_name: @table_info[:table_name] }), class: "btn btn-primary", style: "margin-top: 0.5rem;" %>
129
140
  </div>
130
141
  <% end %>
131
142
  </div>
@@ -1,12 +1,21 @@
1
- <% form_id = "trigger-drop-#{trigger.id}-form" %>
1
+ <% form_id = local_assigns[:form_id] || "trigger-drop-#{trigger.id}-form" %>
2
+ <% redirect_path = local_assigns[:redirect_to] || params[:redirect_to] || trigger_path(trigger) %>
3
+ <% button_size = local_assigns[:button_size] || :medium %>
2
4
 
3
5
  <%= form_with url: drop_trigger_path(trigger), method: :post, local: true, id: form_id, style: "margin: 0; display: inline-block;" do |f| %>
4
- <%= f.hidden_field :redirect_to, value: params[:redirect_to] || trigger_path(trigger) %>
5
-
6
- <button type="button" onclick="showDropModal('<%= form_id %>')"
7
- style="padding: 0.75rem 1.5rem; background: #6c757d; color: white; border: none; border-radius: 4px; cursor: pointer; font-size: 0.875rem; font-weight: 600;">
8
- Drop Trigger
9
- </button>
6
+ <%= f.hidden_field :redirect_to, value: redirect_path %>
7
+
8
+ <% if button_size == :small %>
9
+ <button type="button" onclick="showDropModal('<%= form_id %>')"
10
+ style="padding: 0.25rem 0.75rem; background: #6c757d; color: white; border: none; border-radius: 3px; cursor: pointer; font-size: 0.75rem;">
11
+ Drop
12
+ </button>
13
+ <% else %>
14
+ <button type="button" onclick="showDropModal('<%= form_id %>')"
15
+ style="padding: 0.75rem 1.5rem; background: #6c757d; color: white; border: none; border-radius: 4px; cursor: pointer; font-size: 0.875rem; font-weight: 600;">
16
+ Drop Trigger
17
+ </button>
18
+ <% end %>
10
19
  <% end %>
11
20
 
12
21
  <!-- Drop Modal -->
@@ -1,12 +1,21 @@
1
- <% form_id = "trigger-re-execute-#{trigger.id}-form" %>
1
+ <% form_id = local_assigns[:form_id] || "trigger-re-execute-#{trigger.id}-form" %>
2
+ <% redirect_path = local_assigns[:redirect_to] || params[:redirect_to] || trigger_path(trigger) %>
3
+ <% button_size = local_assigns[:button_size] || :medium %>
2
4
 
3
5
  <%= form_with url: re_execute_trigger_path(trigger), method: :post, local: true, id: form_id, style: "margin: 0; display: inline-block;" do |f| %>
4
- <%= f.hidden_field :redirect_to, value: params[:redirect_to] || trigger_path(trigger) %>
5
-
6
- <button type="button" onclick="showReExecuteModal('<%= form_id %>')"
7
- style="padding: 0.75rem 1.5rem; background: #ffc107; color: #000; border: none; border-radius: 4px; cursor: pointer; font-size: 0.875rem; font-weight: 600;">
8
- Re-Execute Trigger
9
- </button>
6
+ <%= f.hidden_field :redirect_to, value: redirect_path %>
7
+
8
+ <% if button_size == :small %>
9
+ <button type="button" onclick="showReExecuteModal('<%= form_id %>')"
10
+ style="padding: 0.25rem 0.75rem; background: #ffc107; color: #000; border: none; border-radius: 3px; cursor: pointer; font-size: 0.75rem;">
11
+ Re-Execute
12
+ </button>
13
+ <% else %>
14
+ <button type="button" onclick="showReExecuteModal('<%= form_id %>')"
15
+ style="padding: 0.75rem 1.5rem; background: #ffc107; color: #000; border: none; border-radius: 4px; cursor: pointer; font-size: 0.875rem; font-weight: 600;">
16
+ Re-Execute Trigger
17
+ </button>
18
+ <% end %>
10
19
  <% end %>
11
20
 
12
21
  <!-- Re-Execute Modal -->
@@ -1,7 +1,17 @@
1
- <div style="margin-bottom: 2rem;">
2
- <h2>Trigger Details: <%= @trigger.trigger_name %></h2>
3
- <%= link_to "← Back to Dashboard", dashboard_path, class: "btn", style: "background: #6c757d; color: white; text-decoration: none; margin-right: 1rem;" %>
4
- <%= link_to "View Table", table_path(@trigger.table_name), class: "btn", style: "background: #007bff; color: white; text-decoration: none;" %>
1
+ <!-- Breadcrumb Navigation -->
2
+ <div style="margin-bottom: 1rem; color: #6c757d; font-size: 0.875rem;">
3
+ <%= link_to "Dashboard", dashboard_path, style: "color: #007bff; text-decoration: none;" %> /
4
+ <%= link_to "Tables", tables_path, style: "color: #007bff; text-decoration: none;" %> /
5
+ <%= link_to @trigger.table_name, table_path(@trigger.table_name), style: "color: #007bff; text-decoration: none;" %> /
6
+ <span style="color: #495057; font-weight: 600;"><%= @trigger.trigger_name %></span>
7
+ </div>
8
+
9
+ <div style="display: flex; justify-content: space-between; align-items: center; margin-bottom: 2rem;">
10
+ <h2 style="margin: 0;">Trigger Details: <%= @trigger.trigger_name %></h2>
11
+ <div style="display: flex; gap: 0.5rem;">
12
+ <%= link_to "← Back to Dashboard", dashboard_path, class: "btn", style: "background: #6c757d; color: white; text-decoration: none; padding: 0.5rem 1rem;" %>
13
+ <%= link_to "View Table", table_path(@trigger.table_name), class: "btn", style: "background: #007bff; color: white; text-decoration: none; padding: 0.5rem 1rem;" %>
14
+ </div>
5
15
  </div>
6
16
 
7
17
  <!-- Drift Warning -->
@@ -55,8 +65,18 @@
55
65
 
56
66
  <% if @trigger.installed_at.present? %>
57
67
  <div style="margin-bottom: 1rem;">
58
- <div style="color: #6c757d; font-size: 0.875rem; margin-bottom: 0.25rem;">Installed At</div>
59
- <div><%= @trigger.installed_at.strftime("%Y-%m-%d %H:%M:%S %Z") %></div>
68
+ <div style="color: #6c757d; font-size: 0.875rem; margin-bottom: 0.25rem;">Last Applied</div>
69
+ <div title="<%= @trigger.installed_at.strftime("%Y-%m-%d %H:%M:%S %Z") %>">
70
+ <%= distance_of_time_in_words_to_now(@trigger.installed_at) %> ago
71
+ <small style="color: #6c757d;">(<%= @trigger.installed_at.strftime("%Y-%m-%d %H:%M:%S") %>)</small>
72
+ </div>
73
+ </div>
74
+ <% end %>
75
+
76
+ <% if @trigger.last_verified_at.present? %>
77
+ <div style="margin-bottom: 1rem;">
78
+ <div style="color: #6c757d; font-size: 0.875rem; margin-bottom: 0.25rem;">Last Verified</div>
79
+ <div><%= @trigger.last_verified_at.strftime("%Y-%m-%d %H:%M:%S %Z") %></div>
60
80
  </div>
61
81
  <% end %>
62
82
 
data/config/routes.rb CHANGED
@@ -7,20 +7,6 @@ begin
7
7
 
8
8
  resources :tables, only: %i[index show]
9
9
 
10
- resources :generator, only: %i[new create] do
11
- collection do
12
- post :preview
13
- post :validate_table
14
- get :tables
15
- end
16
- end
17
-
18
- resources :sql_capsules, only: %i[new create show] do
19
- member do
20
- post :execute
21
- end
22
- end
23
-
24
10
  resources :migrations, only: [] do
25
11
  collection do
26
12
  post :up
@@ -37,6 +23,8 @@ begin
37
23
  post :re_execute
38
24
  end
39
25
  end
26
+
27
+ resources :audit_logs, only: [:index]
40
28
  end
41
29
  rescue ArgumentError => e
42
30
  # Ignore duplicate route errors (routes may already be drawn in tests)
@@ -0,0 +1,28 @@
1
+ # frozen_string_literal: true
2
+
3
+ class CreatePgSqlTriggersAuditLog < ActiveRecord::Migration[6.1]
4
+ def change
5
+ create_table :pg_sql_triggers_audit_log do |t|
6
+ t.string :trigger_name
7
+ t.string :operation, null: false
8
+ t.jsonb :actor # Store actor information (type, id)
9
+ t.string :environment
10
+ t.string :status, null: false # success, failure
11
+ t.text :reason
12
+ t.string :confirmation_text
13
+ t.jsonb :before_state # Store state before operation
14
+ t.jsonb :after_state # Store state after operation
15
+ t.text :diff # Store diff if applicable
16
+ t.text :error_message # Store error message if operation failed
17
+
18
+ t.timestamps
19
+ end
20
+
21
+ add_index :pg_sql_triggers_audit_log, :trigger_name
22
+ add_index :pg_sql_triggers_audit_log, :operation
23
+ add_index :pg_sql_triggers_audit_log, :status
24
+ add_index :pg_sql_triggers_audit_log, :environment
25
+ add_index :pg_sql_triggers_audit_log, :created_at
26
+ add_index :pg_sql_triggers_audit_log, %i[trigger_name created_at]
27
+ end
28
+ end
@@ -0,0 +1,8 @@
1
+ # frozen_string_literal: true
2
+
3
+ class AddForEachToPgSqlTriggersRegistry < ActiveRecord::Migration[6.1]
4
+ def change
5
+ add_column :pg_sql_triggers_registry, :for_each, :string, default: "row", null: false
6
+ add_index :pg_sql_triggers_registry, :for_each
7
+ end
8
+ end