dbwatcher 1.0.0 → 1.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (95) hide show
  1. checksums.yaml +4 -4
  2. data/README.md +81 -210
  3. data/app/assets/config/dbwatcher_manifest.js +15 -0
  4. data/app/assets/javascripts/dbwatcher/alpine_registrations.js +39 -0
  5. data/app/assets/javascripts/dbwatcher/auto_init.js +23 -0
  6. data/app/assets/javascripts/dbwatcher/components/base.js +141 -0
  7. data/app/assets/javascripts/dbwatcher/components/changes_table_hybrid.js +1008 -0
  8. data/app/assets/javascripts/dbwatcher/components/diagrams.js +449 -0
  9. data/app/assets/javascripts/dbwatcher/components/summary.js +234 -0
  10. data/app/assets/javascripts/dbwatcher/core/alpine_store.js +138 -0
  11. data/app/assets/javascripts/dbwatcher/core/api_client.js +162 -0
  12. data/app/assets/javascripts/dbwatcher/core/component_loader.js +70 -0
  13. data/app/assets/javascripts/dbwatcher/core/component_registry.js +94 -0
  14. data/app/assets/javascripts/dbwatcher/dbwatcher.js +120 -0
  15. data/app/assets/javascripts/dbwatcher/services/mermaid.js +315 -0
  16. data/app/assets/javascripts/dbwatcher/services/mermaid_service.js +199 -0
  17. data/app/assets/javascripts/dbwatcher/vendor/date-fns-browser.js +99 -0
  18. data/app/assets/javascripts/dbwatcher/vendor/lodash.min.js +140 -0
  19. data/app/assets/javascripts/dbwatcher/vendor/tabulator.min.js +3 -0
  20. data/app/assets/stylesheets/dbwatcher/application.css +423 -0
  21. data/app/assets/stylesheets/dbwatcher/application.scss +15 -0
  22. data/app/assets/stylesheets/dbwatcher/components/_badges.scss +38 -0
  23. data/app/assets/stylesheets/dbwatcher/components/_compact_table.scss +162 -0
  24. data/app/assets/stylesheets/dbwatcher/components/_diagrams.scss +51 -0
  25. data/app/assets/stylesheets/dbwatcher/components/_forms.scss +27 -0
  26. data/app/assets/stylesheets/dbwatcher/components/_navigation.scss +55 -0
  27. data/app/assets/stylesheets/dbwatcher/core/_base.scss +34 -0
  28. data/app/assets/stylesheets/dbwatcher/core/_variables.scss +47 -0
  29. data/app/assets/stylesheets/dbwatcher/vendor/tabulator.min.css +2 -0
  30. data/app/controllers/dbwatcher/api/v1/sessions_controller.rb +64 -0
  31. data/app/controllers/dbwatcher/base_controller.rb +8 -2
  32. data/app/controllers/dbwatcher/dashboard_controller.rb +8 -0
  33. data/app/controllers/dbwatcher/sessions_controller.rb +25 -10
  34. data/app/helpers/dbwatcher/component_helper.rb +29 -0
  35. data/app/helpers/dbwatcher/diagram_helper.rb +110 -0
  36. data/app/helpers/dbwatcher/session_helper.rb +3 -2
  37. data/app/views/dbwatcher/sessions/_changes_tab.html.erb +265 -0
  38. data/app/views/dbwatcher/sessions/_diagrams_tab.html.erb +166 -0
  39. data/app/views/dbwatcher/sessions/_session_header.html.erb +11 -0
  40. data/app/views/dbwatcher/sessions/_summary_tab.html.erb +88 -0
  41. data/app/views/dbwatcher/sessions/_tab_navigation.html.erb +12 -0
  42. data/app/views/dbwatcher/sessions/changes.html.erb +21 -0
  43. data/app/views/dbwatcher/sessions/components/changes/_filters.html.erb +44 -0
  44. data/app/views/dbwatcher/sessions/components/changes/_table_list.html.erb +96 -0
  45. data/app/views/dbwatcher/sessions/diagrams.html.erb +21 -0
  46. data/app/views/dbwatcher/sessions/index.html.erb +14 -10
  47. data/app/views/dbwatcher/sessions/shared/_layout.html.erb +8 -0
  48. data/app/views/dbwatcher/sessions/shared/_navigation.html.erb +35 -0
  49. data/app/views/dbwatcher/sessions/shared/_session_header.html.erb +25 -0
  50. data/app/views/dbwatcher/sessions/show.html.erb +3 -346
  51. data/app/views/dbwatcher/sessions/summary.html.erb +21 -0
  52. data/app/views/layouts/dbwatcher/application.html.erb +125 -247
  53. data/bin/compile_scss +49 -0
  54. data/config/routes.rb +26 -0
  55. data/lib/dbwatcher/configuration.rb +102 -8
  56. data/lib/dbwatcher/engine.rb +17 -7
  57. data/lib/dbwatcher/services/analyzers/session_data_processor.rb +98 -0
  58. data/lib/dbwatcher/services/analyzers/table_summary_builder.rb +202 -0
  59. data/lib/dbwatcher/services/api/base_api_service.rb +100 -0
  60. data/lib/dbwatcher/services/api/changes_data_service.rb +112 -0
  61. data/lib/dbwatcher/services/api/diagram_data_service.rb +145 -0
  62. data/lib/dbwatcher/services/api/summary_data_service.rb +158 -0
  63. data/lib/dbwatcher/services/base_service.rb +64 -0
  64. data/lib/dbwatcher/services/diagram_analyzers/base_analyzer.rb +162 -0
  65. data/lib/dbwatcher/services/diagram_analyzers/foreign_key_analyzer.rb +354 -0
  66. data/lib/dbwatcher/services/diagram_analyzers/inferred_relationship_analyzer.rb +502 -0
  67. data/lib/dbwatcher/services/diagram_analyzers/model_association_analyzer.rb +564 -0
  68. data/lib/dbwatcher/services/diagram_data/attribute.rb +154 -0
  69. data/lib/dbwatcher/services/diagram_data/dataset.rb +278 -0
  70. data/lib/dbwatcher/services/diagram_data/entity.rb +180 -0
  71. data/lib/dbwatcher/services/diagram_data/relationship.rb +188 -0
  72. data/lib/dbwatcher/services/diagram_data/relationship_params.rb +55 -0
  73. data/lib/dbwatcher/services/diagram_data.rb +65 -0
  74. data/lib/dbwatcher/services/diagram_error_handler.rb +239 -0
  75. data/lib/dbwatcher/services/diagram_generator.rb +154 -0
  76. data/lib/dbwatcher/services/diagram_strategies/base_diagram_strategy.rb +149 -0
  77. data/lib/dbwatcher/services/diagram_strategies/class_diagram_strategy.rb +49 -0
  78. data/lib/dbwatcher/services/diagram_strategies/erd_diagram_strategy.rb +52 -0
  79. data/lib/dbwatcher/services/diagram_strategies/flowchart_diagram_strategy.rb +52 -0
  80. data/lib/dbwatcher/services/diagram_system.rb +69 -0
  81. data/lib/dbwatcher/services/diagram_type_registry.rb +164 -0
  82. data/lib/dbwatcher/services/mermaid_syntax/base_builder.rb +127 -0
  83. data/lib/dbwatcher/services/mermaid_syntax/cardinality_mapper.rb +90 -0
  84. data/lib/dbwatcher/services/mermaid_syntax/class_diagram_builder.rb +136 -0
  85. data/lib/dbwatcher/services/mermaid_syntax/class_diagram_helper.rb +46 -0
  86. data/lib/dbwatcher/services/mermaid_syntax/erd_builder.rb +116 -0
  87. data/lib/dbwatcher/services/mermaid_syntax/flowchart_builder.rb +109 -0
  88. data/lib/dbwatcher/services/mermaid_syntax/sanitizer.rb +102 -0
  89. data/lib/dbwatcher/services/mermaid_syntax_builder.rb +155 -0
  90. data/lib/dbwatcher/storage/api/concerns/table_analyzer.rb +15 -128
  91. data/lib/dbwatcher/storage/api/session_api.rb +47 -0
  92. data/lib/dbwatcher/storage/base_storage.rb +7 -0
  93. data/lib/dbwatcher/version.rb +1 -1
  94. data/lib/dbwatcher.rb +58 -1
  95. metadata +94 -2
@@ -0,0 +1,1008 @@
1
+ /**
2
+ * Changes Table Hybrid Component v3.0
3
+ * Alpine.js + Tabulator.js implementation for DBWatcher changes tab
4
+ * Production version - all issues fixed, debugging removed
5
+ */
6
+
7
+ // Register component with DBWatcher
8
+ DBWatcher.registerComponent('changesTableHybrid', function(config) {
9
+ const baseComponent = DBWatcher.BaseComponent ? DBWatcher.BaseComponent(config) : {};
10
+
11
+ return {
12
+ // Include BaseComponent
13
+ ...baseComponent,
14
+
15
+ // Component-specific state
16
+ sessionId: config.sessionId || null,
17
+ tableData: {},
18
+ filters: {
19
+ search: '',
20
+ operation: '',
21
+ table: ''
22
+ },
23
+ showColumnSelector: null,
24
+ expandedRows: {},
25
+ tabulators: {}, // Multiple tabulator instances (one per table)
26
+ tableColumns: {}, // Per-table column visibility
27
+
28
+ // Alpine init hook (auto-called by Alpine.js)
29
+ init() {
30
+ // Initialize empty state
31
+ this.tableColumns = {};
32
+ this.expandedRows = {};
33
+
34
+ // Setup filtering
35
+ this.setupFiltering();
36
+
37
+ // Load data from API
38
+ this.loadChangesData();
39
+
40
+ // Setup URL state sync
41
+ this.setupURLStateSync();
42
+
43
+ // Call base init if it exists
44
+ if (baseComponent.init) {
45
+ baseComponent.init.call(this);
46
+ }
47
+ },
48
+
49
+ // Setup filtering with debouncing
50
+ setupFiltering() {
51
+ // Use library debouncing from BaseComponent
52
+ this.applyFilters = this.debounce(() => {
53
+ this.applyTabulatorFilters();
54
+ this.updateURL();
55
+ }, 300);
56
+ },
57
+
58
+ // Apply filters directly to Tabulator (no server reload needed)
59
+ applyTabulatorFilters() {
60
+ if (!this.tabulator) return;
61
+
62
+ // Clear existing filters
63
+ this.tabulator.clearFilter();
64
+
65
+ // Apply filters based on current state
66
+ const filters = [];
67
+
68
+ // Search filter (searches across multiple fields)
69
+ if (this.filters.search && this.filters.search.trim()) {
70
+ const searchTerm = this.filters.search.trim().toLowerCase();
71
+ filters.push({
72
+ field: 'searchable_content',
73
+ type: 'like',
74
+ value: searchTerm
75
+ });
76
+ }
77
+
78
+ // Operation filter
79
+ if (this.filters.operation) {
80
+ filters.push({
81
+ field: 'operation',
82
+ type: '=',
83
+ value: this.filters.operation
84
+ });
85
+ }
86
+
87
+ // Table filter
88
+ if (this.filters.table) {
89
+ filters.push({
90
+ field: 'table_name',
91
+ type: '=',
92
+ value: this.filters.table
93
+ });
94
+ }
95
+
96
+ // Apply all filters
97
+ if (filters.length > 0) {
98
+ this.tabulator.setFilter(filters);
99
+ }
100
+ },
101
+
102
+ // Load data from API
103
+ async loadChangesData() {
104
+ if (!this.sessionId) {
105
+ console.error('No session ID provided to changes table hybrid component');
106
+ this.handleError(new Error('No session ID provided'));
107
+ return;
108
+ }
109
+
110
+ this.setLoading(true);
111
+ this.clearError();
112
+
113
+ try {
114
+ // Build query parameters from filters
115
+ const params = new URLSearchParams();
116
+ if (this.filters.table) params.append('table', this.filters.table);
117
+ if (this.filters.operation) params.append('operation', this.filters.operation);
118
+ if (this.filters.search) params.append('search', this.filters.search);
119
+
120
+ const url = `/dbwatcher/api/v1/sessions/${this.sessionId}/changes_data?${params.toString()}`;
121
+ const data = await this.fetchData(url);
122
+
123
+ if (data.tables_summary) {
124
+ this.tableData = data.tables_summary;
125
+ this.initializeColumnVisibility();
126
+ this.initializeTabulators();
127
+ } else {
128
+ throw new Error('No changes data received');
129
+ }
130
+ } catch (error) {
131
+ this.handleError(error);
132
+ } finally {
133
+ this.setLoading(false);
134
+ }
135
+ },
136
+
137
+ // Initialize Tabulator tables (one per table)
138
+ initializeTabulators() {
139
+ this.$nextTick(() => {
140
+ // Destroy existing tables
141
+ Object.values(this.tabulators).forEach(tabulator => {
142
+ if (tabulator) tabulator.destroy();
143
+ });
144
+ this.tabulators = {};
145
+
146
+ // Create one Tabulator instance per table
147
+ Object.keys(this.tableData).forEach(tableName => {
148
+ this.initializeTableTabulator(tableName);
149
+ });
150
+ });
151
+ },
152
+
153
+ // Initialize Tabulator for a specific table
154
+ initializeTableTabulator(tableName) {
155
+ const container = document.getElementById(`changes-table-${tableName}`);
156
+ if (!container) {
157
+ console.warn(`Table container not found for ${tableName}`);
158
+ return;
159
+ }
160
+
161
+ const tableInfo = this.tableData[tableName];
162
+ if (!tableInfo || !tableInfo.changes || tableInfo.changes.length === 0) {
163
+ container.innerHTML = '<div class="p-4 text-gray-500 text-center">No changes for this table</div>';
164
+ return;
165
+ }
166
+
167
+ // Transform data for this specific table
168
+ const tabulatorData = this.transformTableDataForTabulator(tableName, tableInfo);
169
+
170
+ // Create Tabulator instance for this table
171
+ this.tabulators[tableName] = new Tabulator(container, {
172
+ data: tabulatorData,
173
+ layout: 'fitDataFill',
174
+ responsiveLayout: false,
175
+ height: Math.max(200, Math.min(400, (tabulatorData.length * 35) + 80)), // Minimum 200px, expand based on content
176
+
177
+ // Force Tabulator to use our custom ID field
178
+ index: 'id', // Tell Tabulator to use the 'id' field as the row identifier
179
+
180
+ // Performance optimizations - disable virtual DOM to ensure all rows render
181
+ virtualDom: false,
182
+ pagination: false, // Ensure no pagination
183
+
184
+ // Column configuration for this table
185
+ columns: this.buildColumnsForTable(tableName, tableInfo),
186
+
187
+ // Row formatting
188
+ rowFormatter: this.customRowFormatter.bind(this),
189
+
190
+ // No initial sorting - data should be in correct order from API
191
+ // initialSort: [],
192
+
193
+ // Enable header sorting
194
+ headerSortTristate: true,
195
+
196
+ // Callbacks
197
+ headerBuilt: () => this.applyHeaderClasses(tableName),
198
+ rowBuilt: (row) => this.applyRowClasses(tableName, row)
199
+ });
200
+
201
+ },
202
+
203
+ // Transform data for a specific table
204
+ transformTableDataForTabulator(tableName, tableInfo) {
205
+ const rows = [];
206
+ const changes = tableInfo.changes || [];
207
+
208
+ changes.forEach((change, index) => {
209
+ const columnData = this.extractColumnData(change, tableInfo.columns);
210
+
211
+ // Create truly unique row ID using table name and index only (more reliable)
212
+ const uniqueId = `${tableName}_row_${index}`;
213
+
214
+ // Clean column data to remove any 'id' field that might conflict
215
+ const cleanColumnData = { ...columnData };
216
+ delete cleanColumnData.id; // Remove any id field from column data
217
+
218
+ const row = {
219
+ id: uniqueId, // Force this to be the ID used by Tabulator
220
+ index: index + 1, // Display index (1-based) - should maintain API order
221
+ operation: change.operation,
222
+ timestamp: change.timestamp,
223
+ table_name: tableName,
224
+ change_data: change, // Keep original change data with ID intact
225
+ original_change_id: change.id, // Store original ID separately
226
+ ...cleanColumnData // Use cleaned column data
227
+ };
228
+
229
+ // Ensure ID is correct
230
+ if (row.id !== uniqueId) {
231
+ row.id = uniqueId; // Force it back
232
+ }
233
+
234
+ rows.push(row);
235
+ });
236
+
237
+ return rows;
238
+ },
239
+
240
+ // Build columns for a specific table
241
+ buildColumnsForTable(tableName, tableInfo) {
242
+ const columns = [
243
+ {
244
+ title: '#',
245
+ field: 'index',
246
+ width: 60,
247
+ frozen: true,
248
+ headerSort: false,
249
+ cssClass: 'sticky-left-0',
250
+ titleFormatter: () => '<span class="text-xs">#</span>',
251
+ formatter: (cell) => {
252
+ const rowData = cell.getRow().getData();
253
+ return `<div class="flex items-center justify-center gap-1">
254
+ <button class="expand-btn text-gray-400 hover:text-gray-600 transition-colors p-1 rounded hover:bg-gray-100"
255
+ data-row-id="${rowData.id}">
256
+ <svg class="w-3 h-3 transition-transform" fill="currentColor" viewBox="0 0 20 20">
257
+ <path fill-rule="evenodd" d="M7.293 14.707a1 1 0 010-1.414L10.586 10 7.293 5.707a1 1 0 011.414-1.414l4 4a1 1 0 010 1.414l-4 4a1 1 0 01-1.414 0z" clip-rule="evenodd"/>
258
+ </svg>
259
+ </button>
260
+ <span class="text-xs text-gray-500">${rowData.index}</span>
261
+ </div>`;
262
+ },
263
+ cellClick: (e, _cell) => {
264
+ e.stopPropagation();
265
+ const target = e.target.closest('.expand-btn');
266
+ if (target) {
267
+ const rowId = target.getAttribute('data-row-id');
268
+ this.toggleRowExpansion(rowId);
269
+ }
270
+ }
271
+ },
272
+ {
273
+ title: 'Op',
274
+ field: 'operation',
275
+ width: 48,
276
+ frozen: true,
277
+ headerSort: false,
278
+ cssClass: 'sticky-left-1',
279
+ titleFormatter: () => '<span class="text-xs">Op</span>',
280
+ formatter: (cell) => {
281
+ const op = cell.getValue();
282
+ return `<span class="badge badge-${op.toLowerCase()}">${op.charAt(0)}</span>`;
283
+ }
284
+ },
285
+ {
286
+ title: 'Timestamp',
287
+ field: 'timestamp',
288
+ width: 160,
289
+ frozen: true,
290
+ cssClass: 'sticky-left-2',
291
+ titleFormatter: () => '<span class="text-xs">Timestamp</span>',
292
+ sorter: 'string', // Changed from 'datetime' to 'string'
293
+ formatter: (cell) => {
294
+ return `<span class="text-xs text-gray-600">${this.formatTimestamp(cell.getValue())}</span>`;
295
+ }
296
+ }
297
+ ];
298
+
299
+ // Add columns specific to this table
300
+ if (tableInfo.columns) {
301
+ tableInfo.columns.forEach(col => {
302
+ // Only add column if it's visible
303
+ if (this.isColumnVisible(tableName, col)) {
304
+ columns.push({
305
+ title: col,
306
+ field: col,
307
+ minWidth: 100,
308
+ titleFormatter: () => `<span class="text-xs">${col}</span>`,
309
+ headerSort: true,
310
+ sorter: this.getColumnSorter(col),
311
+ formatter: (cell) => {
312
+ const value = cell.getValue();
313
+ const rowData = cell.getRow().getData();
314
+ const change = rowData.change_data;
315
+
316
+ // Handle different operations with appropriate styling
317
+ if (change.operation === 'UPDATE' && change.changes) {
318
+ const columnChange = change.changes.find(c => c.column === col);
319
+ if (columnChange) {
320
+ return `<div class="text-xs space-y-1">
321
+ <div class="text-red-600 line-through">${this.formatCellValue(columnChange.old_value)}</div>
322
+ <div class="text-green-600 font-medium">${this.formatCellValue(columnChange.new_value)}</div>
323
+ </div>`;
324
+ } else {
325
+ return `<span class="text-xs text-gray-700">${this.formatCellValue(value)}</span>`;
326
+ }
327
+ } else if (change.operation === 'INSERT') {
328
+ return `<span class="text-xs text-green-600 font-medium">${this.formatCellValue(value)}</span>`;
329
+ } else if (change.operation === 'DELETE') {
330
+ return `<span class="text-xs text-red-600 line-through">${this.formatCellValue(value)}</span>`;
331
+ } else {
332
+ return `<span class="text-xs text-gray-700">${this.formatCellValue(value)}</span>`;
333
+ }
334
+ }
335
+ });
336
+ }
337
+ });
338
+ }
339
+
340
+ return columns;
341
+ },
342
+
343
+
344
+ // Extract column data from change record
345
+ extractColumnData(change, columns) {
346
+ const data = {};
347
+
348
+ columns.forEach(col => {
349
+ // Get value from record snapshot or change data
350
+ if (change.record_snapshot && change.record_snapshot[col] !== undefined) {
351
+ data[col] = change.record_snapshot[col];
352
+ } else if (change.operation === 'UPDATE' && change.changes) {
353
+ const columnChange = change.changes.find(c => c.column === col);
354
+ if (columnChange) {
355
+ data[col] = columnChange.new_value;
356
+ }
357
+ }
358
+ });
359
+
360
+ return data;
361
+ },
362
+
363
+ // Build column configuration for Tabulator
364
+ buildColumns() {
365
+ const columns = [
366
+ {
367
+ title: '#',
368
+ field: 'index',
369
+ width: 60,
370
+ frozen: true,
371
+ headerSort: false,
372
+ cssClass: 'sticky-left-0',
373
+ titleFormatter: () => '<span class="text-xs">#</span>',
374
+ formatter: (cell) => {
375
+ const rowData = cell.getRow().getData();
376
+ return `<div class="flex items-center justify-center gap-1">
377
+ <button class="expand-btn text-gray-400 hover:text-gray-600 transition-colors p-1 rounded hover:bg-gray-100"
378
+ data-row-id="${rowData.id}">
379
+ <svg class="w-3 h-3 transition-transform" fill="currentColor" viewBox="0 0 20 20">
380
+ <path fill-rule="evenodd" d="M7.293 14.707a1 1 0 010-1.414L10.586 10 7.293 5.707a1 1 0 011.414-1.414l4 4a1 1 0 010 1.414l-4 4a1 1 0 01-1.414 0z" clip-rule="evenodd"/>
381
+ </svg>
382
+ </button>
383
+ <span class="text-xs text-gray-500">${rowData.index}</span>
384
+ </div>`;
385
+ },
386
+ cellClick: (e, _cell) => {
387
+ e.stopPropagation();
388
+ const target = e.target.closest('.expand-btn');
389
+ if (target) {
390
+ const rowId = target.getAttribute('data-row-id');
391
+ this.toggleRowExpansion(rowId);
392
+ }
393
+ }
394
+ },
395
+ {
396
+ title: 'Op',
397
+ field: 'operation',
398
+ width: 48,
399
+ frozen: true,
400
+ headerSort: false,
401
+ cssClass: 'sticky-left-1',
402
+ titleFormatter: () => '<span class="text-xs">Op</span>',
403
+ formatter: (cell) => {
404
+ const op = cell.getValue();
405
+ return `<span class="badge badge-${op.toLowerCase()}">${op.charAt(0)}</span>`;
406
+ }
407
+ },
408
+ {
409
+ title: 'Timestamp',
410
+ field: 'timestamp',
411
+ width: 160,
412
+ frozen: true,
413
+ cssClass: 'sticky-left-2',
414
+ titleFormatter: () => '<span class="text-xs">Timestamp</span>',
415
+ sorter: 'string', // Changed from 'datetime' to 'string'
416
+ formatter: (cell) => {
417
+ return `<span class="text-xs text-gray-600">${this.formatTimestamp(cell.getValue())}</span>`;
418
+ }
419
+ }
420
+ ];
421
+
422
+ // Add dynamic columns based on table data
423
+ const allColumns = new Set();
424
+ Object.keys(this.tableData).forEach(tableName => {
425
+ const tableInfo = this.tableData[tableName];
426
+ if (tableInfo.columns) {
427
+ tableInfo.columns.forEach(col => allColumns.add(col));
428
+ }
429
+ });
430
+
431
+ // Add each column
432
+ allColumns.forEach(col => {
433
+ columns.push({
434
+ title: col,
435
+ field: col,
436
+ minWidth: 100,
437
+ titleFormatter: () => `<span class="text-xs">${col}</span>`,
438
+ headerSort: true,
439
+ sorter: this.getColumnSorter(col),
440
+ formatter: (cell) => {
441
+ const value = cell.getValue();
442
+ const rowData = cell.getRow().getData();
443
+ const change = rowData.change_data;
444
+
445
+ // Handle different operations with appropriate styling
446
+ if (change.operation === 'UPDATE' && change.changes) {
447
+ const columnChange = change.changes.find(c => c.column === col);
448
+ if (columnChange) {
449
+ return `<div class="text-xs space-y-1">
450
+ <div class="text-red-600 line-through">${this.formatCellValue(columnChange.old_value)}</div>
451
+ <div class="text-green-600 font-medium">${this.formatCellValue(columnChange.new_value)}</div>
452
+ </div>`;
453
+ } else {
454
+ return `<span class="text-xs text-gray-700">${this.formatCellValue(value)}</span>`;
455
+ }
456
+ } else if (change.operation === 'INSERT') {
457
+ return `<span class="text-xs text-green-600 font-medium">${this.formatCellValue(value)}</span>`;
458
+ } else if (change.operation === 'DELETE') {
459
+ return `<span class="text-xs text-red-600 line-through">${this.formatCellValue(value)}</span>`;
460
+ } else {
461
+ return `<span class="text-xs text-gray-700">${this.formatCellValue(value)}</span>`;
462
+ }
463
+ }
464
+ });
465
+ });
466
+
467
+ return columns;
468
+ },
469
+
470
+ // Get appropriate sorter for column based on data type
471
+ getColumnSorter(columnName) {
472
+ // Common ID columns
473
+ if (columnName.toLowerCase().includes('id') || columnName.toLowerCase().includes('uuid')) {
474
+ return 'string';
475
+ }
476
+
477
+ // Timestamp columns - use string sorting to avoid Luxon dependency
478
+ if (columnName.toLowerCase().includes('time') ||
479
+ columnName.toLowerCase().includes('date') ||
480
+ columnName.toLowerCase().includes('created') ||
481
+ columnName.toLowerCase().includes('updated')) {
482
+ return 'string'; // Changed from 'datetime' to 'string'
483
+ }
484
+
485
+ // Numeric columns
486
+ if (columnName.toLowerCase().includes('count') ||
487
+ columnName.toLowerCase().includes('amount') ||
488
+ columnName.toLowerCase().includes('price') ||
489
+ columnName.toLowerCase().includes('quantity')) {
490
+ return 'number';
491
+ }
492
+
493
+ // Default to alphanum for mixed content
494
+ return 'alphanum';
495
+ },
496
+
497
+ // Apply header classes after Tabulator builds headers
498
+ applyHeaderClasses(tableName) {
499
+ const tabulator = this.tabulators[tableName];
500
+ if (!tabulator) return;
501
+
502
+ const headers = tabulator.getHeaderElements();
503
+ headers.forEach((header) => {
504
+ const field = header.getAttribute('tabulator-field');
505
+
506
+ if (field === 'index') {
507
+ header.classList.add('sticky-left-0');
508
+ } else if (field === 'operation') {
509
+ header.classList.add('sticky-left-1');
510
+ } else if (field === 'timestamp') {
511
+ header.classList.add('sticky-left-2');
512
+ }
513
+ });
514
+ },
515
+
516
+ // Apply row classes after Tabulator builds rows
517
+ applyRowClasses(tableName, row) {
518
+ const element = row.getElement();
519
+ const cells = element.querySelectorAll('.tabulator-cell');
520
+
521
+ cells.forEach((cell) => {
522
+ const field = cell.getAttribute('tabulator-field');
523
+
524
+ if (field === 'index') {
525
+ cell.classList.add('sticky-left-0');
526
+ } else if (field === 'operation') {
527
+ cell.classList.add('sticky-left-1');
528
+ } else if (field === 'timestamp') {
529
+ cell.classList.add('sticky-left-2');
530
+ }
531
+ });
532
+ },
533
+
534
+ // Custom row formatter
535
+ customRowFormatter(row) {
536
+ const rowData = row.getData();
537
+ const element = row.getElement();
538
+
539
+ // Add classes based on operation
540
+ const operation = rowData.operation;
541
+ if (operation) {
542
+ element.classList.add(`operation-${operation.toLowerCase()}`);
543
+ }
544
+
545
+ // Add hover effects
546
+ element.addEventListener('mouseenter', () => {
547
+ element.style.backgroundColor = '#f3f4f6';
548
+ });
549
+
550
+ element.addEventListener('mouseleave', () => {
551
+ element.style.backgroundColor = '';
552
+ });
553
+ },
554
+
555
+
556
+ // Toggle row expansion
557
+ toggleRowExpansion(rowId) {
558
+ this.expandedRows[rowId] = !this.expandedRows[rowId];
559
+
560
+ // Find the row across all tabulator instances
561
+ let targetRow = null;
562
+ let foundInTable = null;
563
+
564
+ Object.keys(this.tabulators).forEach(tableName => {
565
+ const tabulator = this.tabulators[tableName];
566
+ if (tabulator && !targetRow) {
567
+ try {
568
+ // Search by row ID directly
569
+ const row = tabulator.getRow(rowId);
570
+ if (row) {
571
+ targetRow = row;
572
+ foundInTable = tableName;
573
+ return; // Found it, stop searching
574
+ }
575
+ } catch (e) {
576
+ // Try searching through data if direct lookup fails
577
+ try {
578
+ const data = tabulator.getData();
579
+ const matchingData = data.find(d => d.id === rowId);
580
+ if (matchingData) {
581
+ targetRow = tabulator.getRow(rowId);
582
+ foundInTable = tableName;
583
+ }
584
+ } catch (e2) {
585
+ // Row not found in this tabulator, continue searching
586
+ }
587
+ }
588
+ }
589
+ });
590
+
591
+ if (targetRow) {
592
+ if (this.expandedRows[rowId]) {
593
+ this.showRowDetails(targetRow);
594
+ } else {
595
+ this.hideRowDetails(targetRow);
596
+ }
597
+ } else {
598
+ console.warn(`Row ${rowId} not found in any table`);
599
+ }
600
+ },
601
+
602
+ // Show row details
603
+ showRowDetails(row) {
604
+ const rowData = row.getData();
605
+ const element = row.getElement();
606
+
607
+
608
+ // Check if detail row already exists
609
+ const existingDetail = element.nextElementSibling;
610
+ if (existingDetail && existingDetail.classList.contains('row-detail')) {
611
+ return; // Already expanded
612
+ }
613
+
614
+ // Create detail row as a proper table row
615
+ const detailRow = document.createElement('tr');
616
+ detailRow.className = 'row-detail bg-gray-50';
617
+ detailRow.setAttribute('data-parent-id', rowData.id);
618
+
619
+ // Create full-width cell
620
+ const detailCell = document.createElement('td');
621
+ detailCell.colSpan = 1000; // Span all columns
622
+ detailCell.className = 'p-0 border-t border-gray-200';
623
+
624
+ try {
625
+ detailCell.innerHTML = this.generateExpandedContent(rowData);
626
+ detailRow.appendChild(detailCell);
627
+
628
+ // Insert after the current row
629
+ element.parentNode.insertBefore(detailRow, element.nextSibling);
630
+
631
+ // Update expand button
632
+ this.updateExpandButton(element, true);
633
+
634
+ // Dynamically increase table height when expanded
635
+ const tabulator = this.findTabulatorForRow(rowData.table_name);
636
+ if (tabulator) {
637
+ setTimeout(() => {
638
+ const currentHeight = tabulator.getElement().offsetHeight;
639
+ const expandedHeight = Math.max(currentHeight + 200, 300); // Add 200px for expanded content
640
+ tabulator.setHeight(expandedHeight);
641
+ }, 50);
642
+ }
643
+ } catch (error) {
644
+ console.error(`Error creating detail row for ${rowData.id}:`, error);
645
+ }
646
+ },
647
+
648
+ // Hide row details
649
+ hideRowDetails(row) {
650
+ const element = row.getElement();
651
+ const rowData = row.getData();
652
+
653
+
654
+ // Find and remove the detail row
655
+ const detailRow = element.parentNode.querySelector(`tr.row-detail[data-parent-id="${rowData.id}"]`);
656
+ if (detailRow) {
657
+ detailRow.remove();
658
+ } else {
659
+ }
660
+
661
+ // Update expand button
662
+ this.updateExpandButton(element, false);
663
+
664
+ // Shrink table height back when collapsed
665
+ const tabulator = this.findTabulatorForRow(rowData.table_name);
666
+ if (tabulator) {
667
+ setTimeout(() => {
668
+ const tableData = tabulator.getData();
669
+ const baseHeight = Math.max(200, Math.min(400, (tableData.length * 35) + 80));
670
+ tabulator.setHeight(baseHeight);
671
+ }, 50);
672
+ }
673
+ },
674
+
675
+ // Helper method to find tabulator instance for a table
676
+ findTabulatorForRow(tableName) {
677
+ return this.tabulators[tableName] || null;
678
+ },
679
+
680
+ // Update expand button state
681
+ updateExpandButton(rowElement, expanded) {
682
+ const expandBtn = rowElement.querySelector('.expand-btn svg');
683
+ if (expandBtn) {
684
+ expandBtn.style.transform = expanded ? 'rotate(90deg)' : 'rotate(0deg)';
685
+ }
686
+ },
687
+
688
+ // Generate expanded content for a row - inline table format
689
+ generateExpandedContent(rowData) {
690
+ const change = rowData.change_data;
691
+ const tableInfo = this.tableData[rowData.table_name];
692
+ const columns = tableInfo ? tableInfo.columns : [];
693
+
694
+ // Create a table row that matches the column structure
695
+ let content = `
696
+ <div class="bg-gray-50 border-t border-gray-200">
697
+ <table class="w-full" style="table-layout: fixed;">
698
+ <tbody>
699
+ <tr>
700
+ <!-- Combined details for first 3 columns (index + op + timestamp) -->
701
+ <td class="sticky-left-0 bg-gray-100 border-r border-gray-300 p-2 align-top" style="width: 268px; min-width: 268px;">
702
+ <div class="text-xs font-medium text-gray-600 mb-2">Change Details</div>
703
+ <div class="space-y-2 text-xs">
704
+ <div class="flex items-center justify-between">
705
+ <span class="text-gray-500">Row #${rowData.index}</span>
706
+ <span class="badge badge-${change.operation.toLowerCase()}">${change.operation}</span>
707
+ </div>
708
+ <div class="text-gray-700">
709
+ <div class="font-medium">${this.formatDate(change.timestamp)}</div>
710
+ <div class="text-gray-500 mt-1">${rowData.table_name}</div>
711
+ </div>
712
+ `;
713
+
714
+ if (change.operation === 'UPDATE' && change.changes) {
715
+ content += `<div class="text-blue-600 font-medium">${change.changes.length} columns modified</div>`;
716
+ }
717
+
718
+ if (change.record_snapshot && (change.record_snapshot.id || change.record_snapshot.uuid)) {
719
+ const fullId = change.record_snapshot.id || change.record_snapshot.uuid;
720
+ content += `<div class="text-gray-500 font-mono text-xs bg-gray-200 p-1 rounded truncate" title="${fullId}">ID: ${String(fullId).substring(0, 12)}...</div>`;
721
+ }
722
+
723
+ content += `
724
+ </div>
725
+ </td>
726
+ `;
727
+
728
+ // Add detail cells for each column that matches the table structure
729
+ columns.forEach(col => {
730
+ // Only show if column is visible in the main table
731
+ if (this.isColumnVisible(rowData.table_name, col)) {
732
+ content += `
733
+ <td class="border-r border-gray-300 p-2 align-top" style="min-width: 100px;">
734
+ <div class="text-xs font-medium text-gray-600 mb-1">${col}</div>
735
+ <div class="text-xs">
736
+ `;
737
+
738
+ if (change.operation === 'UPDATE' && change.changes) {
739
+ const columnChange = change.changes.find(c => c.column === col);
740
+ if (columnChange) {
741
+ // Show old -> new for changed columns in compact format
742
+ content += `
743
+ <div class="space-y-1">
744
+ <div class="text-xs text-red-700 bg-red-50 p-1 rounded border-l-2 border-red-400">
745
+ <div class="font-medium text-red-800 mb-1">Old:</div>
746
+ <div class="break-all max-h-12 overflow-auto">${this.formatDetailValue(columnChange.old_value)}</div>
747
+ </div>
748
+ <div class="text-xs text-green-700 bg-green-50 p-1 rounded border-l-2 border-green-400">
749
+ <div class="font-medium text-green-800 mb-1">New:</div>
750
+ <div class="break-all max-h-12 overflow-auto">${this.formatDetailValue(columnChange.new_value)}</div>
751
+ </div>
752
+ </div>
753
+ `;
754
+ } else {
755
+ // Show unchanged value
756
+ content += `
757
+ <div class="bg-gray-50 p-1 rounded border-l-2 border-gray-300">
758
+ <div class="text-xs text-gray-700 break-all max-h-12 overflow-auto">
759
+ ${this.formatDetailValue(change.record_snapshot?.[col])}
760
+ </div>
761
+ </div>
762
+ `;
763
+ }
764
+ } else if (change.operation === 'INSERT') {
765
+ // Show new value for INSERT
766
+ content += `
767
+ <div class="bg-green-50 p-1 rounded border-l-2 border-green-400">
768
+ <div class="text-xs text-green-700 break-all max-h-12 overflow-auto">
769
+ ${this.formatDetailValue(change.record_snapshot?.[col])}
770
+ </div>
771
+ </div>
772
+ `;
773
+ } else if (change.operation === 'DELETE') {
774
+ // Show deleted value for DELETE
775
+ content += `
776
+ <div class="bg-red-50 p-1 rounded border-l-2 border-red-400">
777
+ <div class="text-xs text-red-700 break-all max-h-12 overflow-auto">
778
+ ${this.formatDetailValue(change.record_snapshot?.[col])}
779
+ </div>
780
+ </div>
781
+ `;
782
+ } else {
783
+ // Show value for other operations
784
+ content += `
785
+ <div class="bg-gray-50 p-1 rounded border-l-2 border-gray-300">
786
+ <div class="text-xs text-gray-700 break-all max-h-12 overflow-auto">
787
+ ${this.formatDetailValue(change.record_snapshot?.[col])}
788
+ </div>
789
+ </div>
790
+ `;
791
+ }
792
+
793
+ content += `
794
+ </div>
795
+ </td>
796
+ `;
797
+ }
798
+ });
799
+
800
+ content += `
801
+ </tr>
802
+ </tbody>
803
+ </table>
804
+ </div>
805
+ `;
806
+
807
+ return content;
808
+ },
809
+
810
+ // Format value for detailed display
811
+ formatDetailValue(value) {
812
+ if (value === null || value === undefined) {
813
+ return '<span class="text-gray-400 italic">NULL</span>';
814
+ }
815
+
816
+ if (typeof value === 'string' && this.isJsonValue(value)) {
817
+ try {
818
+ const parsed = JSON.parse(value);
819
+ return JSON.stringify(parsed, null, 2);
820
+ } catch {
821
+ return String(value);
822
+ }
823
+ }
824
+
825
+ return String(value);
826
+ },
827
+
828
+ // Check if value is JSON
829
+ isJsonValue(value) {
830
+ if (typeof value !== 'string') return false;
831
+ try {
832
+ const parsed = JSON.parse(value);
833
+ return typeof parsed === 'object' && parsed !== null;
834
+ } catch {
835
+ return false;
836
+ }
837
+ },
838
+
839
+ // Format cell value for display
840
+ formatCellValue(value) {
841
+ if (value === null || value === undefined) {
842
+ return '<span class="text-gray-400">NULL</span>';
843
+ }
844
+
845
+ if (typeof value === 'string' && value.length > 50) {
846
+ return `<span title="${value}">${value.substring(0, 50)}...</span>`;
847
+ }
848
+
849
+ return String(value);
850
+ },
851
+
852
+ // Setup URL state synchronization
853
+ setupURLStateSync() {
854
+ const urlParams = new URLSearchParams(window.location.search);
855
+ const tableParam = urlParams.get('table');
856
+ const operationParam = urlParams.get('operation');
857
+
858
+ if (tableParam) this.filters.table = tableParam;
859
+ if (operationParam) this.filters.operation = operationParam;
860
+ },
861
+
862
+ // Update URL with current filters
863
+ updateURL() {
864
+ const url = new URL(window.location.href);
865
+ const params = new URLSearchParams(url.search);
866
+
867
+ if (this.filters.table) {
868
+ params.set('table', this.filters.table);
869
+ } else {
870
+ params.delete('table');
871
+ }
872
+
873
+ if (this.filters.operation) {
874
+ params.set('operation', this.filters.operation);
875
+ } else {
876
+ params.delete('operation');
877
+ }
878
+
879
+ url.search = params.toString();
880
+ window.history.replaceState({}, '', url.toString());
881
+ },
882
+
883
+ // Clear all filters
884
+ clearFilters() {
885
+ this.filters = {
886
+ search: '',
887
+ operation: '',
888
+ table: ''
889
+ };
890
+ this.applyFilters();
891
+ },
892
+
893
+ // Column management methods
894
+ toggleColumnSelector(tableName) {
895
+ this.showColumnSelector = this.showColumnSelector === tableName ? null : tableName;
896
+ },
897
+
898
+ // Initialize column visibility state (per table)
899
+ initializeColumnVisibility() {
900
+ this.tableColumns = {};
901
+
902
+ Object.keys(this.tableData).forEach(tableName => {
903
+ const tableInfo = this.tableData[tableName];
904
+ if (tableInfo && tableInfo.columns) {
905
+ this.tableColumns[tableName] = {};
906
+ tableInfo.columns.forEach(col => {
907
+ this.tableColumns[tableName][col] = true; // All columns visible by default
908
+ });
909
+ }
910
+ });
911
+ },
912
+
913
+ // Toggle column visibility for a specific table
914
+ toggleColumnVisibility(tableName, columnName) {
915
+ if (this.tableColumns[tableName] && this.tableColumns[tableName][columnName] !== undefined) {
916
+ this.tableColumns[tableName][columnName] = !this.tableColumns[tableName][columnName];
917
+ this.updateTableColumns(tableName);
918
+ }
919
+ },
920
+
921
+ // Update Tabulator column visibility for a specific table
922
+ updateTableColumns(tableName) {
923
+ const tabulator = this.tabulators[tableName];
924
+ if (!tabulator) return;
925
+
926
+ // Rebuild columns for this table
927
+ const tableInfo = this.tableData[tableName];
928
+ const newColumns = this.buildColumnsForTable(tableName, tableInfo);
929
+
930
+ // Update the tabulator columns
931
+ tabulator.setColumns(newColumns);
932
+ },
933
+
934
+ // Select all columns for a table
935
+ selectAllColumns(tableName) {
936
+ if (!this.tableColumns[tableName]) return;
937
+
938
+ Object.keys(this.tableColumns[tableName]).forEach(col => {
939
+ this.tableColumns[tableName][col] = true;
940
+ });
941
+ this.updateTableColumns(tableName);
942
+ },
943
+
944
+ // Deselect all columns for a table
945
+ selectNoneColumns(tableName) {
946
+ if (!this.tableColumns[tableName]) return;
947
+
948
+ Object.keys(this.tableColumns[tableName]).forEach(col => {
949
+ this.tableColumns[tableName][col] = false;
950
+ });
951
+ this.updateTableColumns(tableName);
952
+ },
953
+
954
+ // Check if column is visible for a specific table
955
+ isColumnVisible(tableName, columnName) {
956
+ return this.tableColumns[tableName] && this.tableColumns[tableName][columnName] === true;
957
+ },
958
+
959
+ // Format timestamp for display
960
+ formatTimestamp(timestamp) {
961
+ if (!timestamp) return '--';
962
+ const date = new Date(timestamp);
963
+ return date.toLocaleString('en-AU', {
964
+ year: '2-digit',
965
+ month: '2-digit',
966
+ day: '2-digit',
967
+ hour: '2-digit',
968
+ minute: '2-digit',
969
+ second: '2-digit',
970
+ hour12: false
971
+ }).replace(/\//g, '-');
972
+ },
973
+
974
+ // Format date for expanded view
975
+ formatDate(timestamp) {
976
+ if (!timestamp) return '--';
977
+ const date = new Date(timestamp);
978
+ return date.toLocaleString('en-AU', {
979
+ year: 'numeric',
980
+ month: '2-digit',
981
+ day: '2-digit',
982
+ hour: '2-digit',
983
+ minute: '2-digit',
984
+ second: '2-digit',
985
+ hour12: false
986
+ });
987
+ },
988
+
989
+ // Check if there are any visible changes
990
+ hasVisibleChanges() {
991
+ return Object.keys(this.tableData).some(tableName => {
992
+ const data = this.tableData[tableName];
993
+ return data.changes && data.changes.length > 0;
994
+ });
995
+ },
996
+
997
+
998
+ // Component cleanup
999
+ componentDestroy() {
1000
+ Object.values(this.tabulators).forEach(tabulator => {
1001
+ if (tabulator) {
1002
+ tabulator.destroy();
1003
+ }
1004
+ });
1005
+ this.tabulators = {};
1006
+ }
1007
+ };
1008
+ });