dbwatcher 0.1.5 → 1.0.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 (60) hide show
  1. checksums.yaml +4 -4
  2. data/README.md +2 -2
  3. data/app/controllers/dbwatcher/base_controller.rb +95 -0
  4. data/app/controllers/dbwatcher/dashboard_controller.rb +12 -0
  5. data/app/controllers/dbwatcher/queries_controller.rb +24 -0
  6. data/app/controllers/dbwatcher/sessions_controller.rb +15 -20
  7. data/app/controllers/dbwatcher/tables_controller.rb +38 -0
  8. data/app/helpers/dbwatcher/application_helper.rb +103 -0
  9. data/app/helpers/dbwatcher/formatting_helper.rb +108 -0
  10. data/app/helpers/dbwatcher/session_helper.rb +27 -0
  11. data/app/views/dbwatcher/dashboard/index.html.erb +177 -0
  12. data/app/views/dbwatcher/queries/index.html.erb +240 -0
  13. data/app/views/dbwatcher/sessions/index.html.erb +120 -27
  14. data/app/views/dbwatcher/sessions/show.html.erb +326 -129
  15. data/app/views/dbwatcher/shared/_badge.html.erb +4 -0
  16. data/app/views/dbwatcher/shared/_data_table.html.erb +20 -0
  17. data/app/views/dbwatcher/shared/_header.html.erb +7 -0
  18. data/app/views/dbwatcher/shared/_page_layout.html.erb +20 -0
  19. data/app/views/dbwatcher/shared/_section_panel.html.erb +9 -0
  20. data/app/views/dbwatcher/shared/_stats_card.html.erb +11 -0
  21. data/app/views/dbwatcher/shared/_tab_bar.html.erb +6 -0
  22. data/app/views/dbwatcher/tables/changes.html.erb +225 -0
  23. data/app/views/dbwatcher/tables/index.html.erb +123 -0
  24. data/app/views/dbwatcher/tables/show.html.erb +86 -0
  25. data/app/views/layouts/dbwatcher/application.html.erb +375 -26
  26. data/config/routes.rb +17 -3
  27. data/lib/dbwatcher/configuration.rb +9 -1
  28. data/lib/dbwatcher/engine.rb +12 -7
  29. data/lib/dbwatcher/logging.rb +72 -0
  30. data/lib/dbwatcher/services/dashboard_data_aggregator.rb +121 -0
  31. data/lib/dbwatcher/services/query_filter_processor.rb +114 -0
  32. data/lib/dbwatcher/services/table_statistics_collector.rb +119 -0
  33. data/lib/dbwatcher/sql_logger.rb +107 -0
  34. data/lib/dbwatcher/storage/api/base_api.rb +134 -0
  35. data/lib/dbwatcher/storage/api/concerns/table_analyzer.rb +172 -0
  36. data/lib/dbwatcher/storage/api/query_api.rb +95 -0
  37. data/lib/dbwatcher/storage/api/session_api.rb +134 -0
  38. data/lib/dbwatcher/storage/api/table_api.rb +86 -0
  39. data/lib/dbwatcher/storage/base_storage.rb +113 -0
  40. data/lib/dbwatcher/storage/change_processor.rb +65 -0
  41. data/lib/dbwatcher/storage/concerns/data_normalizer.rb +134 -0
  42. data/lib/dbwatcher/storage/concerns/error_handler.rb +75 -0
  43. data/lib/dbwatcher/storage/concerns/timestampable.rb +74 -0
  44. data/lib/dbwatcher/storage/concerns/validatable.rb +117 -0
  45. data/lib/dbwatcher/storage/date_helper.rb +21 -0
  46. data/lib/dbwatcher/storage/errors.rb +86 -0
  47. data/lib/dbwatcher/storage/file_manager.rb +122 -0
  48. data/lib/dbwatcher/storage/null_session.rb +39 -0
  49. data/lib/dbwatcher/storage/query_storage.rb +338 -0
  50. data/lib/dbwatcher/storage/query_validator.rb +24 -0
  51. data/lib/dbwatcher/storage/session.rb +58 -0
  52. data/lib/dbwatcher/storage/session_operations.rb +37 -0
  53. data/lib/dbwatcher/storage/session_query.rb +71 -0
  54. data/lib/dbwatcher/storage/session_storage.rb +322 -0
  55. data/lib/dbwatcher/storage/table_storage.rb +237 -0
  56. data/lib/dbwatcher/storage.rb +112 -85
  57. data/lib/dbwatcher/tracker.rb +4 -55
  58. data/lib/dbwatcher/version.rb +1 -1
  59. data/lib/dbwatcher.rb +12 -2
  60. metadata +47 -1
@@ -1,150 +1,347 @@
1
- <div x-data="sessionViewer(<%= @session.to_h.to_json %>)" class="space-y-6">
2
- <div class="flex justify-between items-center">
3
- <h2 class="text-2xl font-bold" x-text="session.name"></h2>
4
- <button @click="showFullRecords = !showFullRecords"
5
- class="bg-gray-100 hover:bg-gray-200 px-4 py-2 rounded flex items-center gap-2">
6
- <span x-text="showFullRecords ? 'Show Changes Only' : 'Show Full Records'"></span>
7
- </button>
1
+ <div class="h-full flex flex-col" x-data="sessionView()">
2
+ <!-- Compact Header -->
3
+ <div class="h-10 bg-navy-dark text-white flex items-center px-4">
4
+ <svg class="w-4 h-4 mr-2" fill="currentColor" viewBox="0 0 20 20">
5
+ <path fill-rule="evenodd" d="M10 18a8 8 0 100-16 8 8 0 000 16zm1-12a1 1 0 10-2 0v4a1 1 0 00.293.707l2.828 2.829a1 1 0 101.415-1.415L11 9.586V6z" clip-rule="evenodd"/>
6
+ </svg>
7
+ <h1 class="text-sm font-medium truncate"><%= @session.name %></h1>
8
+ <span class="ml-auto text-xs text-blue-light">
9
+ <%= Time.parse(@session.started_at.to_s).strftime("%H:%M:%S") rescue @session.started_at %> -
10
+ <%= Time.parse(@session.ended_at.to_s).strftime("%H:%M:%S") rescue @session.ended_at %>
11
+ </span>
8
12
  </div>
9
-
10
- <!-- Summary -->
11
- <div class="bg-white shadow rounded-lg p-6">
12
- <h3 class="text-lg font-semibold mb-4">Summary</h3>
13
- <div class="grid grid-cols-3 gap-4">
14
- <template x-for="[key, count] in Object.entries(session.summary)" :key="key">
15
- <div class="text-sm">
16
- <span x-text="key.split(',')[0]" class="font-medium"></span>
17
- <span x-text="key.split(',')[1]" :class="getOperationColor(key.split(',')[1])"></span>:
18
- <span x-text="count"></span>
19
- </div>
20
- </template>
13
+
14
+ <!-- Tab Bar -->
15
+ <div class="tab-bar">
16
+ <div class="tab-item" :class="{ active: activeTab === 'changes' }" @click="activeTab = 'changes'">Changes</div>
17
+ <div class="tab-item" :class="{ active: activeTab === 'summary' }" @click="activeTab = 'summary'">Summary</div>
18
+ </div>
19
+
20
+ <!-- Compact Toolbar -->
21
+ <div class="bg-gray-100 border-b border-gray-300 px-3 py-1 flex items-center gap-3">
22
+
23
+ <div class="ml-auto flex items-center gap-2">
24
+ <button @click="exportData()"
25
+ class="compact-button bg-white border border-gray-300 hover:bg-gray-50">
26
+ <svg class="w-3 h-3 inline mr-1" fill="currentColor" viewBox="0 0 20 20">
27
+ <path fill-rule="evenodd" d="M3 17a1 1 0 011-1h12a1 1 0 110 2H4a1 1 0 01-1-1zm3.293-7.707a1 1 0 011.414 0L9 10.586V3a1 1 0 112 0v7.586l1.293-1.293a1 1 0 111.414 1.414l-3 3a1 1 0 01-1.414 0l-3-3a1 1 0 010-1.414z" clip-rule="evenodd"/>
28
+ </svg>
29
+ Export
30
+ </button>
21
31
  </div>
22
32
  </div>
23
-
24
- <!-- Changes by Table -->
25
- <div class="bg-white shadow rounded-lg p-6">
26
- <h3 class="text-lg font-semibold mb-4">Database Changes</h3>
27
-
28
- <div class="space-y-2">
29
- <template x-for="table in groupedChanges" :key="table.name">
30
- <div class="border rounded">
31
- <button @click="table.expanded = !table.expanded"
32
- class="w-full px-4 py-3 flex items-center justify-between hover:bg-gray-50">
33
- <div class="flex items-center gap-2">
34
- <svg class="w-4 h-4 transition-transform" :class="{'rotate-90': table.expanded}" fill="currentColor" viewBox="0 0 20 20">
35
- <path fill-rule="evenodd" d="M7.293 14.707a1 1 0 010-1.414L10.586 10 7.293 6.707a1 1 0 011.414-1.414l4 4a1 1 0 010 1.414l-4 4a1 1 0 01-1.414 0z" clip-rule="evenodd" />
36
- </svg>
37
- <span class="font-medium" x-text="table.name"></span>
38
- <span class="text-sm text-gray-500" x-text="`(${table.changes.length} changes)`"></span>
33
+
34
+
35
+ <!-- Content -->
36
+ <div class="flex-1 overflow-auto">
37
+ <!-- Summary Tab -->
38
+ <div x-show="activeTab === 'summary'" class="p-4">
39
+ <div class="grid grid-cols-3 gap-3 mb-4">
40
+ <% @tables_summary.each do |table_name, summary| %>
41
+ <div class="border border-gray-300 rounded p-3 hover:shadow-md cursor-pointer bg-white"
42
+ @click="activeTab = 'changes'; scrollToTable('<%= table_name %>')">
43
+ <h4 class="text-sm font-medium text-navy-dark mb-2"><%= table_name %></h4>
44
+ <div class="space-y-1 text-xs">
45
+ <div class="flex justify-between">
46
+ <span class="badge badge-insert">INSERT</span>
47
+ <span><%= summary[:operations]['INSERT'] %></span>
48
+ </div>
49
+ <div class="flex justify-between">
50
+ <span class="badge badge-update">UPDATE</span>
51
+ <span><%= summary[:operations]['UPDATE'] %></span>
52
+ </div>
53
+ <div class="flex justify-between">
54
+ <span class="badge badge-delete">DELETE</span>
55
+ <span><%= summary[:operations]['DELETE'] %></span>
56
+ </div>
39
57
  </div>
40
- </button>
41
-
42
- <div x-show="table.expanded" x-collapse class="border-t">
43
- <template x-for="record in table.records" :key="record.id">
44
- <div class="border-b last:border-0">
45
- <button @click="record.expanded = !record.expanded"
46
- class="w-full px-6 py-2 flex items-center gap-2 hover:bg-gray-50 text-left">
47
- <svg class="w-4 h-4 transition-transform" :class="{'rotate-90': record.expanded}" fill="currentColor" viewBox="0 0 20 20">
48
- <path fill-rule="evenodd" d="M7.293 14.707a1 1 0 010-1.414L10.586 10 7.293 6.707a1 1 0 011.414-1.414l4 4a1 1 0 010 1.414l-4 4a1 1 0 01-1.414 0z" clip-rule="evenodd" />
49
- </svg>
50
- <span x-text="`Record #${record.id}`"></span>
51
- </button>
52
-
53
- <div x-show="record.expanded" x-collapse class="px-8 pb-4">
54
- <template x-for="change in record.changes" :key="change.timestamp">
55
- <div class="mt-3">
56
- <div class="font-medium mb-2" :class="getOperationColor(change.operation)">
57
- <span x-text="change.operation"></span> at
58
- <span x-text="new Date(change.timestamp).toLocaleTimeString()"></span>
59
- </div>
60
-
61
- <div x-show="!showFullRecords" class="space-y-1">
62
- <template x-for="col in change.changes" :key="col.column">
63
- <div class="text-sm">
64
- <span class="font-medium" x-text="col.column + ':'"></span>
65
- <template x-if="col.old_value">
66
- <span>
67
- <span class="text-red-600 line-through" x-text="col.old_value"></span>
68
- <span class="mx-1">→</span>
58
+ </div>
59
+ <% end %>
60
+ </div>
61
+ </div>
62
+
63
+ <!-- Changes Tab -->
64
+ <div x-show="activeTab === 'changes'" class="h-full">
65
+ <% @tables_summary.each do |table_name, summary| %>
66
+ <div class="border-b border-gray-300" x-data="{ expanded: true }">
67
+ <!-- Table Header with Column Controls -->
68
+ <div class="bg-gray-100 px-3 py-2 flex items-center cursor-pointer"
69
+ @click="expanded = !expanded"
70
+ :id="`table-${table_name}`">
71
+ <svg class="w-3 h-3 mr-2 transition-transform"
72
+ :class="{ 'rotate-90': expanded }"
73
+ fill="currentColor" viewBox="0 0 20 20">
74
+ <path fill-rule="evenodd" d="M7.293 14.707a1 1 0 010-1.414L10.586 10 7.293 6.707a1 1 0 111.414-1.414l4 4a1 1 0 010 1.414l-4 4a1 1 0 01-1.414 0z" clip-rule="evenodd"/>
75
+ </svg>
76
+ <h3 class="text-sm font-medium text-gray-900 flex-1"><%= table_name %></h3>
77
+ <div class="flex gap-2 mr-4">
78
+ <% summary[:operations].each do |op, count| %>
79
+ <% next if count == 0 %>
80
+ <span class="badge badge-<%= op.downcase %>"><%= count %></span>
81
+ <% end %>
82
+ </div>
83
+ <!-- Column Visibility Button -->
84
+ <button @click.stop="toggleColumnSelector('<%= table_name %>')"
85
+ class="text-xs bg-white border border-gray-300 px-2 py-1 rounded hover:bg-gray-50 relative">
86
+ Columns
87
+ </button>
88
+ </div>
89
+
90
+ <!-- Column Selector Dropdown -->
91
+ <div x-show="showColumnSelector === '<%= table_name %>'"
92
+ x-transition
93
+ @click.away="showColumnSelector = null"
94
+ class="absolute z-50 bg-white border border-gray-300 rounded shadow-lg p-3 max-h-64 overflow-auto"
95
+ style="right: 1rem; margin-top: -2px;">
96
+ <div class="text-xs font-medium mb-2">Select Visible Columns:</div>
97
+ <div class="space-y-1 min-w-48">
98
+ <% if summary[:sample_record] %>
99
+ <% summary[:sample_record].keys.each do |column| %>
100
+ <label class="flex items-center text-xs hover:bg-gray-50 p-1 rounded">
101
+ <input type="checkbox"
102
+ x-model="tableColumns['<%= table_name %>']['<%= column %>']"
103
+ class="mr-2">
104
+ <span class="flex-1"><%= column.to_s.humanize %></span>
105
+ </label>
106
+ <% end %>
107
+ <% end %>
108
+ </div>
109
+ <div class="mt-2 pt-2 border-t border-gray-200 flex gap-1">
110
+ <button @click="selectAllColumns('<%= table_name %>')"
111
+ class="text-xs bg-blue-600 text-white px-2 py-1 rounded hover:bg-blue-700">All</button>
112
+ <button @click="selectNoneColumns('<%= table_name %>')"
113
+ class="text-xs bg-gray-600 text-white px-2 py-1 rounded hover:bg-gray-700">None</button>
114
+ </div>
115
+ </div>
116
+
117
+ <!-- Table Content with Horizontal Scroll -->
118
+ <div x-show="expanded" x-collapse class="overflow-auto">
119
+ <div class="min-w-full">
120
+ <table class="compact-table w-full">
121
+ <thead>
122
+ <tr class="sticky top-0 bg-gray-100 z-10">
123
+ <th class="text-center w-16 sticky left-0 bg-gray-100 z-20 border-r border-gray-300">
124
+ <span class="text-xs">Op</span>
125
+ </th>
126
+ <th class="text-left w-24 sticky bg-gray-100 z-20 border-r border-gray-300">Time</th>
127
+ <% if summary[:sample_record] %>
128
+ <% summary[:sample_record].keys.each do |column| %>
129
+ <th class="text-left min-w-32 px-2"
130
+ x-show="tableColumns['<%= table_name %>']['<%= column %>']">
131
+ <%= column.to_s.humanize %>
132
+ </th>
133
+ <% end %>
134
+ <% end %>
135
+ </tr>
136
+ </thead>
137
+ <tbody>
138
+ <% summary[:changes].each_with_index do |change, index| %>
139
+ <% row_id = "#{table_name}_row_#{index}" %>
140
+ <% operation = change['operation'] || change[:operation] %>
141
+ <% timestamp = change['timestamp'] || change[:timestamp] %>
142
+ <% snapshot = change['record_snapshot'] || change[:record_snapshot] || {} %>
143
+ <% column_changes = change['changes'] || change[:changes] %>
144
+
145
+ <tbody x-data="{ expanded: false }">
146
+ <tr class="hover:bg-blue-50">
147
+ <td class="text-center sticky left-0 bg-white z-10 border-r border-gray-200 w-16">
148
+ <div class="flex items-center justify-center gap-1">
149
+ <!-- Expand/Collapse Button -->
150
+ <button @click="expanded = !expanded"
151
+ class="text-gray-400 hover:text-gray-600 transition-colors p-1 rounded hover:bg-gray-100">
152
+ <svg class="w-3 h-3 transition-transform"
153
+ :class="{ 'rotate-90': expanded }"
154
+ fill="currentColor" viewBox="0 0 20 20">
155
+ <path fill-rule="evenodd" d="M7.293 14.707a1 1 0 010-1.414L10.586 10 7.293 6.707a1 1 0 111.414-1.414l4 4a1 1 0 010 1.414l-4 4a1 1 0 01-1.414 0z" clip-rule="evenodd"/>
156
+ </svg>
157
+ </button>
158
+ <!-- Operation Badge -->
159
+ <% if operation %>
160
+ <span class="badge badge-<%= operation.downcase %>">
161
+ <%= operation[0] %>
69
162
  </span>
70
- </template>
71
- <span class="text-green-600" x-text="col.new_value || 'null'"></span>
163
+ <% else %>
164
+ <span class="badge">?</span>
165
+ <% end %>
72
166
  </div>
73
- </template>
74
- </div>
75
-
76
- <div x-show="showFullRecords" class="bg-gray-50 p-3 rounded text-sm font-mono">
77
- <pre x-text="JSON.stringify(change.record_snapshot, null, 2)"></pre>
78
- </div>
79
- </div>
80
- </template>
81
- </div>
82
- </div>
83
- </template>
167
+ </td>
168
+ <td class="text-xs text-gray-600 sticky bg-white z-10 border-r border-gray-200 w-24">
169
+ <% if timestamp %>
170
+ <%= Time.parse(timestamp.to_s).strftime("%H:%M:%S") rescue timestamp %>
171
+ <% else %>
172
+ <span class="text-gray-400">--</span>
173
+ <% end %>
174
+ </td>
175
+
176
+ <% if snapshot %>
177
+ <% snapshot.each do |key, value| %>
178
+ <td x-show="tableColumns['<%= table_name %>']['<%= key %>']"
179
+ class="relative px-2"
180
+ @click.stop="">
181
+ <% changed_column = column_changes&.find { |c| (c['column'] || c[:column]) == key.to_s } %>
182
+ <div class="min-w-32 max-w-48 truncate">
183
+ <% if operation == 'UPDATE' && changed_column %>
184
+ <div class="text-xs">
185
+ <div class="text-red-600 line-through">
186
+ <%= truncate_cell_value(changed_column['old_value'] || changed_column[:old_value]) %>
187
+ </div>
188
+ <div class="text-green-600 font-medium">
189
+ <%= truncate_cell_value(changed_column['new_value'] || changed_column[:new_value]) %>
190
+ </div>
191
+ </div>
192
+ <% elsif operation == 'INSERT' %>
193
+ <span class="text-green-600 font-medium">
194
+ <%= truncate_cell_value(value) %>
195
+ </span>
196
+ <% elsif operation == 'DELETE' %>
197
+ <span class="text-red-600 line-through">
198
+ <%= truncate_cell_value(value) %>
199
+ </span>
200
+ <% else %>
201
+ <span class="text-gray-700">
202
+ <%= truncate_cell_value(value) %>
203
+ </span>
204
+ <% end %>
205
+ </div>
206
+ </td>
207
+ <% end %>
208
+ <% end %>
209
+ </tr>
210
+
211
+ <!-- Expanded Row Content -->
212
+ <tr x-show="expanded"
213
+ x-collapse
214
+ class="bg-gray-50 border-t border-gray-200">
215
+ <td colspan="2" class="sticky left-0 bg-gray-50 z-10 border-r border-gray-200 p-3 w-40">
216
+ <div class="text-xs font-medium text-gray-700 mb-2">Record Details</div>
217
+ <div class="text-xs text-gray-600">
218
+ Operation: <span class="badge badge-<%= operation&.downcase %>"><%= operation %></span>
219
+ <% if timestamp %>
220
+ <br>Time: <%= Time.parse(timestamp.to_s).strftime("%H:%M:%S.%L") rescue timestamp %>
221
+ <% end %>
222
+ </div>
223
+ </td>
224
+ <% if snapshot %>
225
+ <% snapshot.each do |key, value| %>
226
+ <td x-show="tableColumns['<%= table_name %>']['<%= key %>']"
227
+ class="p-3 border-r border-gray-200 align-top">
228
+ <% changed_column = column_changes&.find { |c| (c['column'] || c[:column]) == key.to_s } %>
229
+ <div class="text-xs font-medium text-gray-600 mb-1"><%= key.to_s.humanize %></div>
230
+ <div class="max-w-md">
231
+ <% if operation == 'UPDATE' && changed_column %>
232
+ <div class="space-y-2">
233
+ <div class="text-red-600 bg-red-50 p-2 rounded">
234
+ <div class="text-xs font-medium mb-1">Old Value:</div>
235
+ <pre class="text-xs whitespace-pre-wrap"><%= format_cell_value(changed_column['old_value'] || changed_column[:old_value]) %></pre>
236
+ </div>
237
+ <div class="text-green-600 bg-green-50 p-2 rounded">
238
+ <div class="text-xs font-medium mb-1">New Value:</div>
239
+ <pre class="text-xs whitespace-pre-wrap"><%= format_cell_value(changed_column['new_value'] || changed_column[:new_value]) %></pre>
240
+ </div>
241
+ </div>
242
+ <% elsif operation == 'INSERT' %>
243
+ <div class="text-green-600 bg-green-50 p-2 rounded">
244
+ <pre class="text-xs whitespace-pre-wrap"><%= format_cell_value(value) %></pre>
245
+ </div>
246
+ <% elsif operation == 'DELETE' %>
247
+ <div class="text-red-600 bg-red-50 p-2 rounded">
248
+ <pre class="text-xs whitespace-pre-wrap"><%= format_cell_value(value) %></pre>
249
+ </div>
250
+ <% else %>
251
+ <div class="text-gray-700 bg-gray-100 p-2 rounded">
252
+ <pre class="text-xs whitespace-pre-wrap"><%= format_cell_value(value) %></pre>
253
+ </div>
254
+ <% end %>
255
+ </div>
256
+ </td>
257
+ <% end %>
258
+ <% end %>
259
+ </tr>
260
+ </tbody>
261
+ <% end %>
262
+ </tbody>
263
+ </table>
264
+ </div>
84
265
  </div>
85
266
  </div>
86
- </template>
267
+ <% end %>
87
268
  </div>
88
269
  </div>
89
270
  </div>
90
271
 
91
272
  <script>
92
- function sessionViewer(sessionData) {
273
+ function sessionView() {
93
274
  return {
94
- session: sessionData,
95
- showFullRecords: false,
96
- groupedChanges: [],
97
-
275
+ activeTab: 'changes',
276
+ showColumnSelector: null,
277
+ tableColumns: {},
278
+
98
279
  init() {
99
- // Calculate summary from changes
100
- const summary = {};
101
- this.session.changes.forEach(change => {
102
- const key = `${change.table_name},${change.operation}`;
103
- summary[key] = (summary[key] || 0) + 1;
104
- });
105
- this.session.summary = summary;
106
-
107
- // Group changes by table and record
108
- const tables = {};
109
-
110
- this.session.changes.forEach(change => {
111
- if (!tables[change.table_name]) {
112
- tables[change.table_name] = {
113
- name: change.table_name,
114
- expanded: false,
115
- changes: [],
116
- records: {}
117
- };
118
- }
119
-
120
- tables[change.table_name].changes.push(change);
121
-
122
- if (!tables[change.table_name].records[change.record_id]) {
123
- tables[change.table_name].records[change.record_id] = {
124
- id: change.record_id,
125
- expanded: false,
126
- changes: []
127
- };
280
+ // Initialize column visibility for each table
281
+ <% @tables_summary.each do |table_name, summary| %>
282
+ this.tableColumns['<%= table_name %>'] = {};
283
+ <% if summary[:sample_record] %>
284
+ <% summary[:sample_record].keys.each do |column| %>
285
+ this.tableColumns['<%= table_name %>']['<%= column %>'] = true;
286
+ <% end %>
287
+ <% end %>
288
+ <% end %>
289
+ },
290
+
291
+ scrollToTable(tableName) {
292
+ this.activeTab = 'changes';
293
+ setTimeout(() => {
294
+ const element = document.getElementById(`table-${tableName}`);
295
+ if (element) {
296
+ element.scrollIntoView({ behavior: 'smooth' });
128
297
  }
129
-
130
- tables[change.table_name].records[change.record_id].changes.push(change);
298
+ }, 100);
299
+ },
300
+
301
+ toggleColumnSelector(tableName) {
302
+ this.showColumnSelector = this.showColumnSelector === tableName ? null : tableName;
303
+ },
304
+
305
+ selectAllColumns(tableName) {
306
+ Object.keys(this.tableColumns[tableName]).forEach(column => {
307
+ this.tableColumns[tableName][column] = true;
131
308
  });
132
-
133
- // Convert to array format
134
- this.groupedChanges = Object.values(tables).map(table => ({
135
- ...table,
136
- records: Object.values(table.records)
137
- }));
138
309
  },
139
-
140
- getOperationColor(operation) {
141
- const colors = {
142
- 'INSERT': 'text-green-600',
143
- 'UPDATE': 'text-blue-600',
144
- 'DELETE': 'text-red-600'
145
- };
146
- return colors[operation] || 'text-gray-600';
310
+
311
+ selectNoneColumns(tableName) {
312
+ Object.keys(this.tableColumns[tableName]).forEach(column => {
313
+ this.tableColumns[tableName][column] = false;
314
+ });
315
+ },
316
+
317
+ exportData() {
318
+ const data = [];
319
+ <% @tables_summary.each do |table_name, summary| %>
320
+ <% summary[:changes].each do |change| %>
321
+ data.push({
322
+ table: '<%= table_name %>',
323
+ operation: '<%= change['operation'] || change[:operation] || 'UNKNOWN' %>',
324
+ timestamp: '<%= change['timestamp'] || change[:timestamp] %>',
325
+ record_id: '<%= change['record_id'] || change[:record_id] %>',
326
+ changes: '<%= change['changes'].to_json if change['changes'] %>'
327
+ });
328
+ <% end %>
329
+ <% end %>
330
+
331
+ const csv = [
332
+ ['Table', 'Operation', 'Timestamp', 'Record ID', 'Changes'],
333
+ ...data.map(row => [row.table, row.operation, row.timestamp, row.record_id, row.changes])
334
+ ].map(row => row.map(cell => `"${cell}"`).join(',')).join('\n');
335
+
336
+ const blob = new Blob([csv], { type: 'text/csv' });
337
+ const url = URL.createObjectURL(blob);
338
+ const a = document.createElement('a');
339
+ a.href = url;
340
+ a.download = `session-<%= @session.id %>-changes.csv`;
341
+ a.click();
342
+ URL.revokeObjectURL(url);
147
343
  }
148
- };
344
+ }
149
345
  }
150
346
  </script>
347
+
@@ -0,0 +1,4 @@
1
+ <%# Reusable badge component %>
2
+ <span class="badge <%= local_assigns[:badge_class] || 'bg-gray-600 text-white' %>" <%= "title=\"#{local_assigns[:tooltip]}\"".html_safe if local_assigns[:tooltip] %>>
3
+ <%= local_assigns[:content] %>
4
+ </span>
@@ -0,0 +1,20 @@
1
+ <%# Reusable data table component %>
2
+ <div class="border border-gray-300 rounded">
3
+ <div class="bg-gray-100 px-3 py-2 border-b border-gray-300">
4
+ <h3 class="text-xs font-medium text-gray-700"><%= local_assigns[:title] %></h3>
5
+ </div>
6
+ <div class="max-h-64 overflow-auto">
7
+ <table class="compact-table w-full">
8
+ <thead>
9
+ <tr>
10
+ <% (local_assigns[:headers] || []).each do |header| %>
11
+ <th class="<%= header[:class] || 'text-left' %>"><%= header[:label] %></th>
12
+ <% end %>
13
+ </tr>
14
+ </thead>
15
+ <tbody>
16
+ <%= yield %>
17
+ </tbody>
18
+ </table>
19
+ </div>
20
+ </div>
@@ -0,0 +1,7 @@
1
+ <%# Reusable header component for all pages %>
2
+ <div class="h-10 bg-navy-dark text-white flex items-center px-4">
3
+ <h1 class="text-sm font-medium"><%= local_assigns[:title] %></h1>
4
+ <span class="ml-auto text-xs text-blue-light">
5
+ <%= local_assigns[:subtitle] %>
6
+ </span>
7
+ </div>
@@ -0,0 +1,20 @@
1
+ <%# Standard page layout wrapper %>
2
+ <div class="h-full flex flex-col">
3
+ <%= render 'dbwatcher/shared/header', title: local_assigns[:page_title], subtitle: local_assigns[:page_subtitle] %>
4
+
5
+ <% if local_assigns[:tabs] %>
6
+ <%= render 'dbwatcher/shared/tab_bar', tabs: local_assigns[:tabs] %>
7
+ <% end %>
8
+
9
+ <% if local_assigns[:toolbar] %>
10
+ <!-- Toolbar -->
11
+ <div class="h-8 bg-gray-100 border-b border-gray-300 flex items-center px-4 gap-2">
12
+ <%= local_assigns[:toolbar] %>
13
+ </div>
14
+ <% end %>
15
+
16
+ <!-- Content Area -->
17
+ <div class="flex-1 overflow-auto p-4">
18
+ <%= yield %>
19
+ </div>
20
+ </div>
@@ -0,0 +1,9 @@
1
+ <%# Reusable section panel component %>
2
+ <div class="<%= local_assigns[:classes] || 'mt-4' %> border border-gray-300 rounded">
3
+ <div class="bg-gray-100 px-3 py-2 border-b border-gray-300">
4
+ <h3 class="text-xs font-medium text-gray-700"><%= local_assigns[:title] %></h3>
5
+ </div>
6
+ <div class="<%= local_assigns[:content_classes] || 'p-4' %>">
7
+ <%= yield %>
8
+ </div>
9
+ </div>
@@ -0,0 +1,11 @@
1
+ <%# Enhanced stats card component %>
2
+ <div class="border border-gray-300 rounded bg-white p-3">
3
+ <div class="flex items-center justify-between mb-2">
4
+ <span class="text-xs text-gray-600"><%= local_assigns[:label] %></span>
5
+ <%= local_assigns[:icon_html]&.html_safe if local_assigns[:icon_html] %>
6
+ </div>
7
+ <div class="text-2xl font-bold <%= local_assigns[:value_class] || 'text-navy-dark' %>">
8
+ <%= local_assigns[:value] || 0 %>
9
+ </div>
10
+ <div class="text-xs text-gray-500 mt-1"><%= local_assigns[:description] %></div>
11
+ </div>
@@ -0,0 +1,6 @@
1
+ <%# Reusable tab bar component %>
2
+ <div class="tab-bar">
3
+ <% (local_assigns[:tabs] || []).each do |tab| %>
4
+ <div class="tab-item <%= 'active' if tab[:active] %>"><%= tab[:name] %></div>
5
+ <% end %>
6
+ </div>