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
@@ -0,0 +1,225 @@
1
+ <div class="h-full flex flex-col" x-data="tableView()">
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="M3 3a1 1 0 000 2v8a2 2 0 002 2h2.586l-1.293 1.293a1 1 0 101.414 1.414L10 15.414l2.293 2.293a1 1 0 001.414-1.414L12.414 15H15a2 2 0 002-2V5a1 1 0 100-2H3zm11 4a1 1 0 10-2 0v4a1 1 0 102 0V7zm-3 1a1 1 0 10-2 0v3a1 1 0 102 0V8zM8 9a1 1 0 00-2 0v2a1 1 0 102 0V9z" clip-rule="evenodd"/>
6
+ </svg>
7
+ <h1 class="text-sm font-medium">Table: <%= @table_name %></h1>
8
+
9
+ <!-- Quick Stats -->
10
+ <div class="ml-auto flex items-center gap-4 text-xs">
11
+ <span x-text="`${filteredRecords.length} records`"></span>
12
+ <span class="text-gold-light" x-text="`${totalChanges} changes`"></span>
13
+ </div>
14
+ </div>
15
+
16
+ <!-- Tab Bar -->
17
+ <div class="tab-bar">
18
+ <div class="tab-item active">Changes</div>
19
+ <div class="tab-item">Schema</div>
20
+ <div class="tab-item">Statistics</div>
21
+ </div>
22
+
23
+ <!-- Compact Toolbar -->
24
+ <div class="bg-gray-100 border-b border-gray-300 px-3 py-1 flex items-center gap-3">
25
+ <input type="text"
26
+ x-model="searchTerm"
27
+ placeholder="Filter records..."
28
+ class="compact-input flex-1 max-w-xs">
29
+
30
+ <select x-model="selectedSession"
31
+ class="compact-select">
32
+ <option value="">All Sessions</option>
33
+ <% @sessions.each do |session_id| %>
34
+ <% session = Dbwatcher::Storage.sessions.find(session_id) %>
35
+ <% if session %>
36
+ <option value="<%= session_id %>"><%= session.name %></option>
37
+ <% end %>
38
+ <% end %>
39
+ </select>
40
+
41
+ <select x-model="selectedOperation"
42
+ class="compact-select">
43
+ <option value="">All Operations</option>
44
+ <option value="INSERT">INSERT</option>
45
+ <option value="UPDATE">UPDATE</option>
46
+ <option value="DELETE">DELETE</option>
47
+ </select>
48
+
49
+ <div class="ml-auto flex items-center gap-2">
50
+ <button @click="exportData()"
51
+ class="compact-button bg-white border border-gray-300 hover:bg-gray-50">
52
+ <svg class="w-3 h-3 inline mr-1" fill="currentColor" viewBox="0 0 20 20">
53
+ <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"/>
54
+ </svg>
55
+ Export
56
+ </button>
57
+
58
+ <button @click="refreshData()"
59
+ class="compact-button bg-blue-medium text-white hover:bg-blue-700">
60
+ <svg class="w-3 h-3 inline mr-1" fill="currentColor" viewBox="0 0 20 20">
61
+ <path fill-rule="evenodd" d="M4 2a1 1 0 011 1v2.101a7.002 7.002 0 0111.601 2.566 1 1 0 11-1.885.666A5.002 5.002 0 005.999 7H9a1 1 0 010 2H4a1 1 0 01-1-1V3a1 1 0 011-1zm.008 9.057a1 1 0 011.276.61A5.002 5.002 0 0014.001 13H11a1 1 0 110-2h5a1 1 0 011 1v5a1 1 0 11-2 0v-2.101a7.002 7.002 0 01-11.601-2.566 1 1 0 01.61-1.276z" clip-rule="evenodd"/>
62
+ </svg>
63
+ Refresh
64
+ </button>
65
+ </div>
66
+ </div>
67
+
68
+
69
+ <!-- Content -->
70
+ <div class="flex-1 overflow-auto">
71
+ <% if @records&.any? %>
72
+ <table class="compact-table w-full">
73
+ <thead>
74
+ <tr>
75
+ <th class="text-center w-16">Op</th>
76
+ <th class="text-left w-24">Session</th>
77
+ <th class="text-left w-20">Time</th>
78
+ <% if @records&.any? %>
79
+ <% sample_record = @records.values.first.first[:record_snapshot] %>
80
+ <% if sample_record %>
81
+ <% sample_record.keys.each do |column| %>
82
+ <th class="text-left"><%= column.to_s.humanize %></th>
83
+ <% end %>
84
+ <% end %>
85
+ <% end %>
86
+ </tr>
87
+ </thead>
88
+ <tbody>
89
+ <template x-for="record in filteredRecords" :key="record.id">
90
+ <tr class="cursor-pointer hover:bg-blue-50"
91
+ @click="selectRecord(record)"
92
+ :class="{ 'selected': selectedRecord?.id === record.id }">
93
+ <td class="text-center">
94
+ <span class="badge"
95
+ :class="`badge-${record.operation.toLowerCase()}`"
96
+ x-text="record.operation.charAt(0)"></span>
97
+ </td>
98
+ <td class="truncate text-xs"
99
+ :title="record.session_name"
100
+ x-text="record.session_name"></td>
101
+ <td class="text-xs text-gray-600"
102
+ x-text="formatTime(record.timestamp)"></td>
103
+ <template x-for="[key, value] in Object.entries(record.record_snapshot || {})" :key="key">
104
+ <td class="truncate max-w-32"
105
+ :title="value"
106
+ :class="{
107
+ 'highlight-change': record.operation === 'UPDATE' && record.previous_values && record.previous_values[key] !== value,
108
+ 'highlight-new': record.operation === 'INSERT',
109
+ 'highlight-deleted': record.operation === 'DELETE'
110
+ }"
111
+ x-text="value"></td>
112
+ </template>
113
+ </tr>
114
+ </template>
115
+ </tbody>
116
+ </table>
117
+ <% else %>
118
+ <div class="flex items-center justify-center h-full text-gray-500">
119
+ <div class="text-center">
120
+ <svg class="mx-auto h-8 w-8 text-gray-400 mb-2" fill="none" stroke="currentColor" viewBox="0 0 24 24">
121
+ <path stroke-linecap="round" stroke-linejoin="round" stroke-width="1"
122
+ d="M9 5H7a2 2 0 00-2 2v12a2 2 0 002 2h10a2 2 0 002-2V7a2 2 0 00-2-2h-2M9 5a2 2 0 002 2h2a2 2 0 002-2M9 5a2 2 0 012-2h2a2 2 0 012 2"/>
123
+ </svg>
124
+ <p class="text-sm">No changes recorded for this table</p>
125
+ <p class="text-xs text-gray-400 mt-1">Changes will appear here when database operations are tracked</p>
126
+ </div>
127
+ </div>
128
+ <% end %>
129
+ </div>
130
+ </div>
131
+ <script>
132
+ function tableView() {
133
+ return {
134
+ searchTerm: '',
135
+ selectedSession: '',
136
+ selectedOperation: '',
137
+ selectedRecord: null,
138
+ records: <%= (@records || {}).to_json.html_safe %>,
139
+
140
+ get flatRecords() {
141
+ const flat = [];
142
+ Object.entries(this.records).forEach(([recordId, changes]) => {
143
+ changes.forEach(change => {
144
+ flat.push({
145
+ id: `${recordId}-${change.timestamp}`,
146
+ record_id: recordId,
147
+ operation: change.operation,
148
+ timestamp: change.timestamp,
149
+ session_name: change.session_name,
150
+ record_snapshot: change.record_snapshot,
151
+ previous_values: change.previous_values,
152
+ changes: change.changes
153
+ });
154
+ });
155
+ });
156
+ return flat.sort((a, b) => new Date(b.timestamp) - new Date(a.timestamp));
157
+ },
158
+
159
+ get filteredRecords() {
160
+ return this.flatRecords.filter(record => {
161
+ if (this.searchTerm) {
162
+ const searchLower = this.searchTerm.toLowerCase();
163
+ const recordText = JSON.stringify(record.record_snapshot || {}).toLowerCase();
164
+ if (!recordText.includes(searchLower)) return false;
165
+ }
166
+
167
+ if (this.selectedSession && !record.session_name.includes(this.selectedSession)) return false;
168
+ if (this.selectedOperation && record.operation !== this.selectedOperation) return false;
169
+
170
+ return true;
171
+ });
172
+ },
173
+
174
+ get totalChanges() {
175
+ return this.flatRecords.length;
176
+ },
177
+
178
+ selectRecord(record) {
179
+ this.selectedRecord = this.selectedRecord?.id === record.id ? null : record;
180
+ },
181
+
182
+ formatTime(timestamp) {
183
+ return new Date(timestamp).toLocaleTimeString('en-US', {
184
+ hour12: false,
185
+ hour: '2-digit',
186
+ minute: '2-digit',
187
+ second: '2-digit'
188
+ });
189
+ },
190
+
191
+ exportData() {
192
+ const data = this.filteredRecords.map(record => ({
193
+ record_id: record.record_id,
194
+ operation: record.operation,
195
+ timestamp: record.timestamp,
196
+ session: record.session_name,
197
+ ...record.record_snapshot
198
+ }));
199
+
200
+ if (data.length === 0) return;
201
+
202
+ const headers = ['Record ID', 'Operation', 'Timestamp', 'Session', ...Object.keys(data[0]).filter(k => !['record_id', 'operation', 'timestamp', 'session'].includes(k))];
203
+ const csv = [
204
+ headers,
205
+ ...data.map(row => headers.map(h => {
206
+ const key = h.toLowerCase().replace(' ', '_');
207
+ return row[key] || row[h] || '';
208
+ }))
209
+ ].map(row => row.map(cell => `"${cell}"`).join(',')).join('\n');
210
+
211
+ const blob = new Blob([csv], { type: 'text/csv' });
212
+ const url = URL.createObjectURL(blob);
213
+ const a = document.createElement('a');
214
+ a.href = url;
215
+ a.download = `table-<%= @table_name %>-changes.csv`;
216
+ a.click();
217
+ URL.revokeObjectURL(url);
218
+ },
219
+
220
+ refreshData() {
221
+ window.location.reload();
222
+ }
223
+ }
224
+ }
225
+ </script>
@@ -0,0 +1,123 @@
1
+ <%# Tables Index Page %>
2
+ <div class="h-full flex flex-col">
3
+ <%= render 'dbwatcher/shared/header', title: 'Database Tables', subtitle: "#{@tables.count} tables" %>
4
+
5
+ <%= render 'dbwatcher/shared/tab_bar', tabs: [
6
+ { name: 'All Tables', active: true },
7
+ { name: 'Modified', active: false },
8
+ { name: 'Recent', active: false }
9
+ ] %>
10
+
11
+ <!-- Toolbar -->
12
+ <div class="h-8 bg-gray-100 border-b border-gray-300 flex items-center px-4 gap-2">
13
+ <input type="text" placeholder="Filter tables..."
14
+ class="compact-input flex-1 max-w-xs"
15
+ x-data="" x-model="searchTerm"
16
+ @input="filterTables()">
17
+ <select class="compact-select">
18
+ <option>All Types</option>
19
+ <option>User Tables</option>
20
+ <option>System Tables</option>
21
+ </select>
22
+ <button class="compact-button bg-blue-medium text-white hover:bg-blue-dark">
23
+ Refresh
24
+ </button>
25
+ </div>
26
+
27
+ <!-- Content Area -->
28
+ <div class="flex-1 overflow-auto">
29
+ <table class="compact-table w-full">
30
+ <thead>
31
+ <tr>
32
+ <th class="text-left">Table Name</th>
33
+ <th class="text-center">Changes</th>
34
+ <th class="text-center">Last Modified</th>
35
+ <th class="text-center">Operations</th>
36
+ <th class="text-right">Actions</th>
37
+ </tr>
38
+ </thead>
39
+ <tbody>
40
+ <% @tables.each do |table| %>
41
+ <% next if table[:name].nil? || table[:name].empty? %>
42
+ <tr class="hover:bg-gray-50">
43
+ <td class="font-medium text-navy-dark">
44
+ <div class="flex items-center gap-2">
45
+ <svg class="w-3 h-3 text-gray-500" fill="currentColor" viewBox="0 0 20 20">
46
+ <path fill-rule="evenodd" d="M3 3a1 1 0 000 2v8a2 2 0 002 2h2.586l-1.293 1.293a1 1 0 101.414 1.414L10 15.414l2.293 2.293a1 1 0 001.414-1.414L12.414 15H15a2 2 0 002-2V5a1 1 0 100-2H3zm11 4a1 1 0 10-2 0v4a1 1 0 102 0V7zm-3 1a1 1 0 10-2 0v3a1 1 0 102 0V8zM8 9a1 1 0 00-2 0v2a1 1 0 102 0V9z" clip-rule="evenodd"/>
47
+ </svg>
48
+ <%= link_to table[:name], table_path(table[:name]), class: "text-navy-dark hover:text-blue-medium" %>
49
+ </div>
50
+ </td>
51
+ <td class="text-center">
52
+ <% if table[:change_count] > 0 %>
53
+ <span class="badge bg-blue-medium text-white"><%= table[:change_count] %></span>
54
+ <% else %>
55
+ <span class="text-gray-400">-</span>
56
+ <% end %>
57
+ </td>
58
+ <td class="text-center text-gray-600">
59
+ <% if table[:last_change] %>
60
+ <%= Time.parse(table[:last_change].to_s).strftime("%m/%d %H:%M") rescue table[:last_change] %>
61
+ <% else %>
62
+ <span class="text-gray-400">No changes</span>
63
+ <% end %>
64
+ </td>
65
+ <td class="text-center">
66
+ <div class="flex gap-1 justify-center">
67
+ <span class="badge badge-insert text-xs" title="Inserts">I</span>
68
+ <span class="badge badge-update text-xs" title="Updates">U</span>
69
+ <span class="badge badge-delete text-xs" title="Deletes">D</span>
70
+ </div>
71
+ </td>
72
+ <td class="text-right">
73
+ <div class="flex gap-1 justify-end">
74
+ <%= link_to changes_table_path(table[:name]),
75
+ class: "compact-button bg-gray-500 text-white hover:bg-gray-600",
76
+ title: "View Changes" do %>
77
+ <svg class="w-3 h-3" fill="none" stroke="currentColor" viewBox="0 0 24 24">
78
+ <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2"
79
+ d="M15 12a3 3 0 11-6 0 3 3 0 016 0z"/>
80
+ <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2"
81
+ d="M2.458 12C3.732 7.943 7.523 5 12 5c4.478 0 8.268 2.943 9.542 7-1.274 4.057-5.064 7-9.542 7-4.477 0-8.268-2.943-9.542-7z"/>
82
+ </svg>
83
+ <% end %>
84
+ <%= link_to table_path(table[:name]),
85
+ class: "compact-button bg-navy-dark text-white hover:bg-blue-medium",
86
+ title: "Table Details" do %>
87
+ <svg class="w-3 h-3" fill="none" stroke="currentColor" viewBox="0 0 24 24">
88
+ <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2"
89
+ d="M13 16h-1v-4h-1m1-4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z"/>
90
+ </svg>
91
+ <% end %>
92
+ </div>
93
+ </td>
94
+ </tr>
95
+ <% end %>
96
+ </tbody>
97
+ </table>
98
+ </div>
99
+
100
+ <!-- Status Bar -->
101
+ <div class="h-6 bg-gray-100 border-t border-gray-300 flex items-center px-4 text-xs text-gray-600">
102
+ <%= @tables.count %> tables total •
103
+ <%= @tables.count { |t| t[:change_count] > 0 } %> with changes •
104
+ Last updated: <%= Time.current.strftime("%H:%M:%S") %>
105
+ </div>
106
+ </div>
107
+
108
+ <script>
109
+ function filterTables() {
110
+ // Simple table filtering functionality
111
+ const searchTerm = document.querySelector('input[x-model="searchTerm"]').value.toLowerCase();
112
+ const rows = document.querySelectorAll('tbody tr');
113
+
114
+ rows.forEach(row => {
115
+ const tableName = row.querySelector('td').textContent.toLowerCase();
116
+ if (tableName.includes(searchTerm)) {
117
+ row.style.display = '';
118
+ } else {
119
+ row.style.display = 'none';
120
+ }
121
+ });
122
+ }
123
+ </script>
@@ -0,0 +1,86 @@
1
+ <!-- DBWatcher Table Detail View -->
2
+ <div class="h-full flex flex-col">
3
+ <!-- Compact Header -->
4
+ <div class="h-10 bg-navy-dark text-white flex items-center px-4">
5
+ <svg class="w-4 h-4 mr-2" fill="currentColor" viewBox="0 0 20 20">
6
+ <path fill-rule="evenodd" d="M3 3a1 1 0 000 2v8a2 2 0 002 2h2.586l-1.293 1.293a1 1 0 101.414 1.414L10 15.414l2.293 2.293a1 1 0 001.414-1.414L12.414 15H15a2 2 0 002-2V5a1 1 0 100-2H3zm11 4a1 1 0 10-2 0v4a1 1 0 102 0V7zm-3 1a1 1 0 10-2 0v3a1 1 0 102 0V8zM8 9a1 1 0 00-2 0v2a1 1 0 102 0V9z" clip-rule="evenodd"/>
7
+ </svg>
8
+ <h1 class="text-sm font-medium">Table: <%= @table_name %></h1>
9
+
10
+ <!-- Quick Stats -->
11
+ <div class="ml-auto flex items-center gap-4 text-xs">
12
+ <span class="text-blue-light"><%= @changes.count %> changes</span>
13
+ <span class="text-gold-light"><%= @sessions.count %> sessions</span>
14
+ </div>
15
+ </div>
16
+
17
+ <!-- Tab Bar -->
18
+ <div class="tab-bar">
19
+ <div class="tab-item active">Changes</div>
20
+ <div class="tab-item">Schema</div>
21
+ <div class="tab-item">Statistics</div>
22
+ </div>
23
+
24
+ <!-- Content -->
25
+ <div class="flex-1 overflow-auto p-4">
26
+ <% if @changes.any? %>
27
+ <!-- Changes Table -->
28
+ <div class="bg-white rounded border border-gray-300">
29
+ <table class="compact-table w-full">
30
+ <thead>
31
+ <tr>
32
+ <th class="text-left">Record ID</th>
33
+ <th class="text-left">Operation</th>
34
+ <th class="text-left">Session</th>
35
+ <th class="text-left">Timestamp</th>
36
+ <th class="text-right">Actions</th>
37
+ </tr>
38
+ </thead>
39
+ <tbody>
40
+ <% @changes.each do |change| %>
41
+ <tr class="hover:bg-gray-50">
42
+ <td class="font-mono text-xs"><%= change["record_id"] || change[:record_id] %></td>
43
+ <td>
44
+ <% operation = change[:operation] %>
45
+ <span class="badge <%= case operation
46
+ when 'INSERT' then 'badge-insert'
47
+ when 'UPDATE' then 'badge-update'
48
+ when 'DELETE' then 'badge-delete'
49
+ else 'bg-gray-500 text-white'
50
+ end %>">
51
+ <%= operation %>
52
+ </span>
53
+ </td>
54
+ <td class="text-xs text-gray-600 truncate max-w-xs">
55
+ <%= change[:session_name] || change[:session_id] %>
56
+ </td>
57
+ <td class="text-xs text-gray-500">
58
+ <% timestamp = change[:timestamp] %>
59
+ <%= Time.parse(timestamp.to_s).strftime("%Y-%m-%d %H:%M:%S") rescue timestamp %>
60
+ </td>
61
+ <td class="text-right">
62
+ <%= link_to "View Details", changes_table_path(@table_name),
63
+ class: "text-xs text-indigo-600 hover:text-indigo-900" %>
64
+ </td>
65
+ </tr>
66
+ <% end %>
67
+ </tbody>
68
+ </table>
69
+ </div>
70
+
71
+ <% else %>
72
+ <!-- Empty State -->
73
+ <div class="text-center py-12">
74
+ <svg class="w-16 h-16 mx-auto text-gray-300 mb-4" fill="currentColor" viewBox="0 0 20 20">
75
+ <path fill-rule="evenodd" d="M3 3a1 1 0 000 2v8a2 2 0 002 2h2.586l-1.293 1.293a1 1 0 101.414 1.414L10 15.414l2.293 2.293a1 1 0 001.414-1.414L12.414 15H15a2 2 0 002-2V5a1 1 0 100-2H3zm11 4a1 1 0 10-2 0v4a1 1 0 102 0V7zm-3 1a1 1 0 10-2 0v3a1 1 0 102 0V8zM8 9a1 1 0 00-2 0v2a1 1 0 102 0V9z" clip-rule="evenodd"/>
76
+ </svg>
77
+ <h3 class="text-lg font-medium text-gray-900 mb-2">No Changes Found</h3>
78
+ <p class="text-gray-500">This table hasn't been modified in any tracked sessions.</p>
79
+ <div class="mt-4">
80
+ <%= link_to "Back to Tables", tables_path,
81
+ class: "text-indigo-600 hover:text-indigo-900" %>
82
+ </div>
83
+ </div>
84
+ <% end %>
85
+ </div>
86
+ </div>