pg_sql_triggers 1.2.0 → 1.3.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 (49) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +144 -0
  3. data/COVERAGE.md +26 -19
  4. data/Goal.md +276 -155
  5. data/README.md +27 -1
  6. data/app/assets/javascripts/pg_sql_triggers/trigger_actions.js +50 -0
  7. data/app/controllers/concerns/pg_sql_triggers/error_handling.rb +56 -0
  8. data/app/controllers/concerns/pg_sql_triggers/kill_switch_protection.rb +66 -0
  9. data/app/controllers/concerns/pg_sql_triggers/permission_checking.rb +117 -0
  10. data/app/controllers/pg_sql_triggers/application_controller.rb +10 -62
  11. data/app/controllers/pg_sql_triggers/audit_logs_controller.rb +102 -0
  12. data/app/controllers/pg_sql_triggers/dashboard_controller.rb +4 -9
  13. data/app/controllers/pg_sql_triggers/tables_controller.rb +30 -4
  14. data/app/controllers/pg_sql_triggers/triggers_controller.rb +3 -21
  15. data/app/helpers/pg_sql_triggers/permissions_helper.rb +43 -0
  16. data/app/models/pg_sql_triggers/audit_log.rb +106 -0
  17. data/app/models/pg_sql_triggers/trigger_registry.rb +178 -9
  18. data/app/views/layouts/pg_sql_triggers/application.html.erb +26 -6
  19. data/app/views/pg_sql_triggers/audit_logs/index.html.erb +177 -0
  20. data/app/views/pg_sql_triggers/dashboard/index.html.erb +33 -8
  21. data/app/views/pg_sql_triggers/tables/index.html.erb +76 -3
  22. data/app/views/pg_sql_triggers/tables/show.html.erb +17 -4
  23. data/app/views/pg_sql_triggers/triggers/_drop_modal.html.erb +16 -7
  24. data/app/views/pg_sql_triggers/triggers/_re_execute_modal.html.erb +16 -7
  25. data/app/views/pg_sql_triggers/triggers/show.html.erb +26 -6
  26. data/config/routes.rb +2 -0
  27. data/db/migrate/20260103000001_create_pg_sql_triggers_audit_log.rb +28 -0
  28. data/docs/README.md +15 -5
  29. data/docs/api-reference.md +191 -0
  30. data/docs/audit-trail.md +413 -0
  31. data/docs/configuration.md +6 -6
  32. data/docs/permissions.md +369 -0
  33. data/docs/troubleshooting.md +486 -0
  34. data/docs/ui-guide.md +211 -0
  35. data/docs/web-ui.md +257 -34
  36. data/lib/pg_sql_triggers/errors.rb +245 -0
  37. data/lib/pg_sql_triggers/generator/service.rb +32 -0
  38. data/lib/pg_sql_triggers/permissions/checker.rb +9 -2
  39. data/lib/pg_sql_triggers/registry.rb +141 -8
  40. data/lib/pg_sql_triggers/sql/kill_switch.rb +33 -5
  41. data/lib/pg_sql_triggers/testing/function_tester.rb +2 -0
  42. data/lib/pg_sql_triggers/version.rb +1 -1
  43. data/lib/pg_sql_triggers.rb +3 -6
  44. metadata +29 -6
  45. data/docs/screenshots/.gitkeep +0 -1
  46. data/docs/screenshots/Generate Trigger.png +0 -0
  47. data/docs/screenshots/Triggers Page.png +0 -0
  48. data/docs/screenshots/kill error.png +0 -0
  49. data/docs/screenshots/kill modal for migration down.png +0 -0
@@ -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>
@@ -40,6 +40,7 @@
40
40
  <th>Version</th>
41
41
  <th>Status</th>
42
42
  <th>Source</th>
43
+ <th>Last Applied</th>
43
44
  <th>Actions</th>
44
45
  </tr>
45
46
  </thead>
@@ -60,11 +61,20 @@
60
61
  </td>
61
62
  <td><span class="badge badge-info"><%= trigger.source %></span></td>
62
63
  <td>
63
- <% if PgSqlTriggers::Permissions.can?(current_actor, :enable_trigger) %>
64
- <div style="display: flex; gap: 0.5rem; flex-wrap: wrap;">
64
+ <% if trigger.installed_at.present? %>
65
+ <span title="<%= trigger.installed_at.strftime("%Y-%m-%d %H:%M:%S %Z") %>" style="cursor: help;">
66
+ <%= distance_of_time_in_words_to_now(trigger.installed_at) %> ago
67
+ </span>
68
+ <% else %>
69
+ <span style="color: #6c757d;">Never</span>
70
+ <% end %>
71
+ </td>
72
+ <td>
73
+ <div style="display: flex; gap: 0.5rem; flex-wrap: wrap;">
74
+ <% if PgSqlTriggers::Permissions.can?(current_actor, :enable_trigger) %>
65
75
  <% if trigger.enabled %>
66
76
  <% 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| %>
77
+ <%= form_with url: disable_trigger_path(trigger), method: :post, local: false, id: form_id, style: "margin: 0;" do |f| %>
68
78
  <%= f.hidden_field :redirect_to, value: dashboard_path %>
69
79
  <button type="button" onclick="showKillSwitchModal('<%= form_id %>')"
70
80
  style="padding: 0.25rem 0.75rem; background: #dc3545; color: white; border: none; border-radius: 3px; cursor: pointer; font-size: 0.75rem;">
@@ -78,7 +88,7 @@
78
88
  message: "Are you sure you want to disable trigger '#{trigger.trigger_name}'?" %>
79
89
  <% else %>
80
90
  <% 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| %>
91
+ <%= form_with url: enable_trigger_path(trigger), method: :post, local: false, id: form_id, style: "margin: 0;" do |f| %>
82
92
  <%= f.hidden_field :redirect_to, value: dashboard_path %>
83
93
  <button type="button" onclick="showKillSwitchModal('<%= form_id %>')"
84
94
  style="padding: 0.25rem 0.75rem; background: #28a745; color: white; border: none; border-radius: 3px; cursor: pointer; font-size: 0.75rem;">
@@ -91,10 +101,25 @@
91
101
  title: 'Enable Trigger',
92
102
  message: "Are you sure you want to enable trigger '#{trigger.trigger_name}'?" %>
93
103
  <% end %>
94
- </div>
95
- <% else %>
96
-
97
- <% end %>
104
+ <% end %>
105
+
106
+ <% if PgSqlTriggers::Permissions.can?(current_actor, :drop_trigger) %>
107
+ <% begin %>
108
+ <% drift_info = trigger.drift_result %>
109
+ <% if drift_info && drift_info[:state] == 'drifted' %>
110
+ <%= render 'pg_sql_triggers/triggers/re_execute_modal', trigger: trigger, drift_info: drift_info, redirect_to: dashboard_path, button_size: :small %>
111
+ <% end %>
112
+ <% rescue StandardError %>
113
+ <%# Skip if drift detection fails %>
114
+ <% end %>
115
+
116
+ <%= render 'pg_sql_triggers/triggers/drop_modal', trigger: trigger, redirect_to: dashboard_path, button_size: :small %>
117
+ <% end %>
118
+
119
+ <% unless PgSqlTriggers::Permissions.can?(current_actor, :enable_trigger) || PgSqlTriggers::Permissions.can?(current_actor, :drop_trigger) %>
120
+ <span style="color: #6c757d;">—</span>
121
+ <% end %>
122
+ </div>
98
123
  </td>
99
124
  </tr>
100
125
  <% end %>
@@ -9,11 +9,37 @@
9
9
  <div style="font-size: 2rem; font-weight: 600; color: #28a745;"><%= @tables_with_trigger_count %></div>
10
10
  <div style="color: #6c757d;">Tables with Triggers</div>
11
11
  </div>
12
+ <div style="background: white; padding: 1.5rem; border-radius: 4px; box-shadow: 0 1px 3px rgba(0,0,0,0.1);">
13
+ <div style="font-size: 2rem; font-weight: 600; color: #6c757d;"><%= @tables_without_trigger_count %></div>
14
+ <div style="color: #6c757d;">Tables without Triggers</div>
15
+ </div>
16
+ <div style="background: white; padding: 1.5rem; border-radius: 4px; box-shadow: 0 1px 3px rgba(0,0,0,0.1);">
17
+ <div style="font-size: 2rem; font-weight: 600; color: #007bff;"><%= @total_tables_count %></div>
18
+ <div style="color: #6c757d;">Total Tables</div>
19
+ </div>
20
+ </div>
21
+
22
+ <!-- Filter Controls -->
23
+ <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;">
24
+ <div style="display: flex; align-items: center; gap: 1.5rem; flex-wrap: wrap;">
25
+ <label style="color: #495057; font-weight: 600; font-size: 0.875rem;">Filter:</label>
26
+ <div style="display: flex; gap: 1rem; flex-wrap: wrap;">
27
+ <%= link_to "All Tables", tables_path(filter: 'all', page: 1, per_page: @per_page),
28
+ 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'};" %>
29
+ <%= link_to "With Triggers", tables_path(filter: 'with_triggers', page: 1, per_page: @per_page),
30
+ 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'};" %>
31
+ <%= link_to "Without Triggers", tables_path(filter: 'without_triggers', page: 1, per_page: @per_page),
32
+ 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'};" %>
33
+ </div>
34
+ </div>
12
35
  </div>
13
36
 
14
37
  <!-- Tables List -->
15
38
  <% if @tables_with_triggers.any? %>
16
39
  <div style="background: white; border-radius: 4px; box-shadow: 0 1px 3px rgba(0,0,0,0.1); overflow: hidden;">
40
+ <div style="margin-bottom: 1rem; color: #6c757d; font-size: 0.875rem; padding: 1rem 1rem 0 1rem;">
41
+ Showing <%= (@page - 1) * @per_page + 1 %>-<%= [@page * @per_page, @total_tables].min %> of <%= @total_tables %> tables
42
+ </div>
17
43
  <table style="margin: 0;">
18
44
  <thead>
19
45
  <tr>
@@ -93,11 +119,58 @@
93
119
  <% end %>
94
120
  </tbody>
95
121
  </table>
122
+
123
+ <!-- Pagination Controls -->
124
+ <% if @total_pages > 1 %>
125
+ <div style="display: flex; justify-content: space-between; align-items: center; margin-top: 1rem; padding: 1rem; border-top: 1px solid #dee2e6;">
126
+ <div>
127
+ <% if @page > 1 %>
128
+ <%= link_to "← Previous", tables_path(filter: @filter, page: @page - 1, per_page: @per_page),
129
+ style: "padding: 0.5rem 1rem; background: #007bff; color: white; text-decoration: none; border-radius: 4px; margin-right: 0.5rem;" %>
130
+ <% end %>
131
+ <% if @page < @total_pages %>
132
+ <%= link_to "Next →", tables_path(filter: @filter, page: @page + 1, per_page: @per_page),
133
+ style: "padding: 0.5rem 1rem; background: #007bff; color: white; text-decoration: none; border-radius: 4px;" %>
134
+ <% end %>
135
+ </div>
136
+ <div style="color: #6c757d; font-size: 0.875rem;">
137
+ Page <%= @page %> of <%= @total_pages %>
138
+ </div>
139
+ <div style="display: flex; align-items: center; gap: 0.5rem;">
140
+ <label style="color: #6c757d; font-size: 0.875rem;">Per page:</label>
141
+ <select onchange="window.location.href='<%= tables_path %>?filter=<%= @filter %>&page=1&per_page=' + this.value"
142
+ style="padding: 0.25rem 0.5rem; border: 1px solid #ced4da; border-radius: 4px;">
143
+ <option value="10" <%= 'selected' if @per_page == 10 %>>10</option>
144
+ <option value="20" <%= 'selected' if @per_page == 20 %>>20</option>
145
+ <option value="50" <%= 'selected' if @per_page == 50 %>>50</option>
146
+ <option value="100" <%= 'selected' if @per_page == 100 %>>100</option>
147
+ </select>
148
+ </div>
149
+ </div>
150
+ <% end %>
96
151
  </div>
97
152
  <% else %>
98
153
  <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" %>
154
+ <h3 style="margin-top: 0;">
155
+ <% if @filter == 'with_triggers' %>
156
+ No tables with triggers found
157
+ <% elsif @filter == 'without_triggers' %>
158
+ No tables without triggers found
159
+ <% else %>
160
+ No tables found
161
+ <% end %>
162
+ </h3>
163
+ <p style="margin-bottom: 1rem;">
164
+ <% if @filter == 'with_triggers' %>
165
+ No tables with triggers were found in the database. Create your first trigger to get started.
166
+ <% elsif @filter == 'without_triggers' %>
167
+ All tables in the database have triggers.
168
+ <% else %>
169
+ No tables were found in the database.
170
+ <% end %>
171
+ </p>
172
+ <% if @filter == 'with_triggers' || @filter == 'all' %>
173
+ <%= link_to "Generate New Trigger", new_generator_path, class: "btn btn-primary" %>
174
+ <% end %>
102
175
  </div>
103
176
  <% end %>
@@ -64,8 +64,8 @@
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;">
67
+ <div style="margin-bottom: 1rem; display: flex; gap: 0.5rem; flex-wrap: wrap;">
68
+ <% if PgSqlTriggers::Permissions.can?(current_actor, :enable_trigger) %>
69
69
  <% if trigger.enabled %>
70
70
  <% form_id = "table-trigger-disable-#{trigger.id}-form" %>
71
71
  <%= form_with url: disable_trigger_path(trigger), method: :post, local: true, id: form_id, style: "margin: 0;" do |f| %>
@@ -95,8 +95,21 @@
95
95
  title: 'Enable Trigger',
96
96
  message: "Are you sure you want to enable trigger '#{trigger.trigger_name}' on table '#{@table_info[:table_name]}'?" %>
97
97
  <% end %>
98
- </div>
99
- <% end %>
98
+ <% end %>
99
+
100
+ <% if PgSqlTriggers::Permissions.can?(current_actor, :drop_trigger) %>
101
+ <% begin %>
102
+ <% drift_info = trigger.drift_result %>
103
+ <% if drift_info && drift_info[:state] == 'drifted' %>
104
+ <%= render 'pg_sql_triggers/triggers/re_execute_modal', trigger: trigger, drift_info: drift_info, redirect_to: table_path(@table_info[:table_name]) %>
105
+ <% end %>
106
+ <% rescue StandardError %>
107
+ <%# Skip if drift detection fails %>
108
+ <% end %>
109
+
110
+ <%= render 'pg_sql_triggers/triggers/drop_modal', trigger: trigger, redirect_to: table_path(@table_info[:table_name]) %>
111
+ <% end %>
112
+ </div>
100
113
 
101
114
  <% if trigger.definition.present? %>
102
115
  <% definition = JSON.parse(trigger.definition) rescue {} %>
@@ -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
@@ -37,6 +37,8 @@ begin
37
37
  post :re_execute
38
38
  end
39
39
  end
40
+
41
+ resources :audit_logs, only: [:index]
40
42
  end
41
43
  rescue ArgumentError => e
42
44
  # 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
data/docs/README.md CHANGED
@@ -9,12 +9,16 @@ Welcome to the PgSqlTriggers documentation. This directory contains comprehensiv
9
9
 
10
10
  ### Core Guides
11
11
  - **[Usage Guide](usage-guide.md)** - DSL syntax, migrations, and drift detection
12
- - **[Web UI Guide](web-ui.md)** - Using the web dashboard
12
+ - **[UI Guide](ui-guide.md)** - Quick start guide for the web interface
13
+ - **[Web UI Guide](web-ui.md)** - Comprehensive web dashboard documentation
13
14
  - **[Kill Switch Guide](kill-switch.md)** - Production safety features
15
+ - **[Permissions Guide](permissions.md)** - Configuring and using permissions
16
+ - **[Audit Trail Guide](audit-trail.md)** - Viewing and exporting audit logs
14
17
 
15
18
  ### Reference
16
19
  - **[Configuration Reference](configuration.md)** - Complete configuration options
17
20
  - **[API Reference](api-reference.md)** - Console API and programmatic usage
21
+ - **[Troubleshooting Guide](troubleshooting.md)** - Common issues and solutions
18
22
 
19
23
  ## Quick Links
20
24
 
@@ -30,20 +34,26 @@ See [Usage Guide - Declaring Triggers](usage-guide.md#declaring-triggers)
30
34
  See [Usage Guide - Trigger Migrations](usage-guide.md#trigger-migrations)
31
35
 
32
36
  #### Use the web dashboard
33
- See [Web UI Documentation](web-ui.md)
37
+ Start with [UI Guide](ui-guide.md) or see [Web UI Documentation](web-ui.md) for comprehensive details
38
+
39
+ #### Configure permissions
40
+ See [Permissions Guide](permissions.md) or [Configuration - Permission System](configuration.md#permission-system)
41
+
42
+ #### View audit logs
43
+ See [Audit Trail Guide](audit-trail.md)
34
44
 
35
45
  #### Protect production
36
46
  See [Kill Switch Documentation](kill-switch.md)
37
47
 
38
- #### Configure permissions
39
- See [Configuration - Permission System](configuration.md#permission-system)
40
-
41
48
  #### Use the console API
42
49
  See [API Reference](api-reference.md)
43
50
 
44
51
  #### Handle drift detection
45
52
  See [Usage Guide - Drift Detection](usage-guide.md#drift-detection)
46
53
 
54
+ #### Troubleshoot issues
55
+ See [Troubleshooting Guide](troubleshooting.md)
56
+
47
57
  ## Screenshots
48
58
 
49
59
  Screenshots referenced in the documentation are stored in the [screenshots](screenshots/) directory.