dbwatcher 1.1.2 → 1.1.3
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.
- checksums.yaml +4 -4
- data/app/assets/config/dbwatcher_manifest.js +1 -0
- data/app/assets/javascripts/dbwatcher/components/changes_table_hybrid.js +184 -97
- data/app/assets/javascripts/dbwatcher/components/timeline.js +211 -0
- data/app/assets/javascripts/dbwatcher/dbwatcher.js +5 -0
- data/app/assets/stylesheets/dbwatcher/application.css +298 -1
- data/app/assets/stylesheets/dbwatcher/application.scss +1 -0
- data/app/assets/stylesheets/dbwatcher/components/_timeline.scss +326 -0
- data/app/controllers/dbwatcher/api/v1/sessions_controller.rb +18 -4
- data/app/controllers/dbwatcher/sessions_controller.rb +1 -1
- data/app/views/dbwatcher/sessions/_layout.html.erb +5 -2
- data/app/views/dbwatcher/sessions/_summary.html.erb +1 -1
- data/app/views/dbwatcher/sessions/{_changes.html.erb → _tables.html.erb} +84 -5
- data/app/views/dbwatcher/sessions/_timeline.html.erb +260 -0
- data/app/views/dbwatcher/sessions/show.html.erb +3 -1
- data/app/views/layouts/dbwatcher/application.html.erb +1 -0
- data/config/routes.rb +2 -1
- data/lib/dbwatcher/services/analyzers/table_summary_builder.rb +102 -1
- data/lib/dbwatcher/services/api/{changes_data_service.rb → tables_data_service.rb} +6 -6
- data/lib/dbwatcher/services/timeline_data_service/enhancement_utilities.rb +100 -0
- data/lib/dbwatcher/services/timeline_data_service/entry_builder.rb +125 -0
- data/lib/dbwatcher/services/timeline_data_service/metadata_builder.rb +93 -0
- data/lib/dbwatcher/services/timeline_data_service.rb +130 -0
- data/lib/dbwatcher/storage/api/concerns/table_analyzer.rb +1 -1
- data/lib/dbwatcher/version.rb +1 -1
- data/lib/dbwatcher.rb +1 -1
- metadata +11 -4
@@ -0,0 +1,260 @@
|
|
1
|
+
<%# Timeline View - Simple chronological list of changes %>
|
2
|
+
<div class="h-full"
|
3
|
+
x-data="DBWatcher.getComponent('timeline', { sessionId: '<%= @session.id %>' })"
|
4
|
+
x-init="init()">
|
5
|
+
|
6
|
+
<!-- Timeline Header -->
|
7
|
+
<div class="p-3 border-b border-gray-300 bg-gray-50">
|
8
|
+
<div class="flex items-center justify-between">
|
9
|
+
<h3 class="text-sm font-medium text-gray-900">Timeline View</h3>
|
10
|
+
<div class="flex items-center gap-3 text-xs">
|
11
|
+
<template x-if="!loading && filteredData.length > 0">
|
12
|
+
<span class="text-gray-600">
|
13
|
+
<span x-text="filteredData.length"></span> operations
|
14
|
+
<template x-if="filteredData.length !== timelineData.length">
|
15
|
+
<span x-text="`of ${timelineData.length} total`"></span>
|
16
|
+
</template>
|
17
|
+
</span>
|
18
|
+
</template>
|
19
|
+
</div>
|
20
|
+
</div>
|
21
|
+
</div>
|
22
|
+
|
23
|
+
<!-- Loading State -->
|
24
|
+
<div x-show="loading" class="flex items-center justify-center h-64">
|
25
|
+
<div class="animate-spin rounded-full h-8 w-8 border-b-2 border-blue-medium"></div>
|
26
|
+
<span class="ml-2 text-gray-600">Loading timeline...</span>
|
27
|
+
</div>
|
28
|
+
|
29
|
+
<!-- Error State -->
|
30
|
+
<div x-show="error" class="p-4 bg-red-50 border border-red-200 rounded m-4">
|
31
|
+
<p class="text-red-700" x-text="error"></p>
|
32
|
+
<button @click="loadTimelineData()" class="mt-2 text-red-600 hover:text-red-800 underline">Retry</button>
|
33
|
+
</div>
|
34
|
+
|
35
|
+
<!-- Timeline Content -->
|
36
|
+
<div x-show="!loading && !error" class="h-full flex">
|
37
|
+
<!-- Sidebar Filters -->
|
38
|
+
<div class="w-64 bg-gray-50 border-r border-gray-200 p-3 overflow-auto">
|
39
|
+
<!-- Table Filter -->
|
40
|
+
<div class="mb-4">
|
41
|
+
<h4 class="text-sm font-medium text-gray-900 mb-2">Filter by Tables</h4>
|
42
|
+
|
43
|
+
<!-- Select All / Clear All -->
|
44
|
+
<div class="mb-2 flex gap-2">
|
45
|
+
<button @click="filters.tables = getAvailableTables(); applyFilters()"
|
46
|
+
class="text-xs text-blue-medium hover:text-navy-dark underline"
|
47
|
+
:disabled="loading">
|
48
|
+
Select All
|
49
|
+
</button>
|
50
|
+
<button @click="clearFilters()"
|
51
|
+
class="text-xs text-blue-medium hover:text-navy-dark underline"
|
52
|
+
:disabled="loading">
|
53
|
+
Clear All
|
54
|
+
</button>
|
55
|
+
</div>
|
56
|
+
|
57
|
+
<!-- Search tables -->
|
58
|
+
<input type="text"
|
59
|
+
x-model="tableSearch"
|
60
|
+
placeholder="Search tables..."
|
61
|
+
class="w-full px-2 py-1 mb-2 border border-gray-300 rounded text-xs">
|
62
|
+
|
63
|
+
<!-- Tables list with max height and scrolling -->
|
64
|
+
<div class="max-h-32 overflow-y-auto space-y-1 border border-gray-200 rounded p-2 bg-white">
|
65
|
+
<template x-for="table in getAvailableTables().filter(t => !tableSearch || t.toLowerCase().includes(tableSearch.toLowerCase()))" :key="table">
|
66
|
+
<label class="flex items-center gap-2 p-1 rounded hover:bg-gray-100 cursor-pointer">
|
67
|
+
<input type="checkbox"
|
68
|
+
:value="table"
|
69
|
+
x-model="filters.tables"
|
70
|
+
@change="applyFilters()"
|
71
|
+
class="form-checkbox h-3 w-3 text-blue-medium">
|
72
|
+
<span class="text-xs text-gray-700" x-text="table"></span>
|
73
|
+
</label>
|
74
|
+
</template>
|
75
|
+
</div>
|
76
|
+
|
77
|
+
<!-- Selected count -->
|
78
|
+
<div class="mt-2 text-xs text-gray-500" x-show="filters.tables.length > 0">
|
79
|
+
<span x-text="filters.tables.length"></span> table(s) selected
|
80
|
+
</div>
|
81
|
+
</div>
|
82
|
+
|
83
|
+
<!-- Search -->
|
84
|
+
<div class="mb-4">
|
85
|
+
<h4 class="text-sm font-medium text-gray-900 mb-2">Search</h4>
|
86
|
+
<input type="text"
|
87
|
+
x-model="filters.searchText"
|
88
|
+
@input="applyFilters()"
|
89
|
+
placeholder="Table, operation, or record ID"
|
90
|
+
class="w-full px-2 py-1 border border-gray-300 rounded text-sm">
|
91
|
+
</div>
|
92
|
+
|
93
|
+
<!-- Analytics Summary -->
|
94
|
+
<template x-if="!loading && timelineData.length > 0">
|
95
|
+
<div class="bg-white border border-gray-200 rounded p-3">
|
96
|
+
<h4 class="text-sm font-medium text-gray-900 mb-3">Session Analytics</h4>
|
97
|
+
<div class="space-y-2 text-xs">
|
98
|
+
<div class="flex justify-between">
|
99
|
+
<span class="text-gray-600">Operations:</span>
|
100
|
+
<span class="font-medium" x-text="`${filteredData.length}/${timelineData.length}`"></span>
|
101
|
+
</div>
|
102
|
+
<div class="flex justify-between">
|
103
|
+
<span class="text-gray-600">Tables:</span>
|
104
|
+
<span class="font-medium" x-text="getAvailableTables().length"></span>
|
105
|
+
</div>
|
106
|
+
<div class="flex justify-between">
|
107
|
+
<span class="text-gray-600">Duration:</span>
|
108
|
+
<span class="font-medium" x-text="metadata.session_duration || 'N/A'"></span>
|
109
|
+
</div>
|
110
|
+
|
111
|
+
<!-- Operation Counts -->
|
112
|
+
<div class="mt-3 space-y-1">
|
113
|
+
<template x-for="[operation, count] in Object.entries(metadata.operation_counts || {})" :key="operation">
|
114
|
+
<div class="flex justify-between items-center">
|
115
|
+
<span class="badge badge-sm" :class="`badge-${operation.toLowerCase()}`" x-text="operation"></span>
|
116
|
+
<span class="text-xs font-medium" x-text="count"></span>
|
117
|
+
</div>
|
118
|
+
</template>
|
119
|
+
</div>
|
120
|
+
</div>
|
121
|
+
</div>
|
122
|
+
</template>
|
123
|
+
</div>
|
124
|
+
|
125
|
+
<!-- Main Timeline Content -->
|
126
|
+
<div class="flex-1 overflow-auto">
|
127
|
+
<!-- Empty State -->
|
128
|
+
<div x-show="filteredData.length === 0" class="flex flex-col items-center justify-center h-64">
|
129
|
+
<svg class="w-12 h-12 text-gray-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
130
|
+
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 8v4l3 3m6-3a9 9 0 11-18 0 9 9 0 0118 0z"/>
|
131
|
+
</svg>
|
132
|
+
<p class="mt-2 text-gray-500">No operations found for the current filters</p>
|
133
|
+
<button @click="clearFilters()" class="mt-2 text-blue-medium hover:text-navy-dark underline">Clear filters</button>
|
134
|
+
</div>
|
135
|
+
|
136
|
+
<!-- Operations List -->
|
137
|
+
<template x-if="filteredData.length > 0">
|
138
|
+
<div class="divide-y divide-gray-200">
|
139
|
+
<template x-for="operation in filteredData" :key="operation.id">
|
140
|
+
<div class="transition-colors" x-data="{ expanded: false }">
|
141
|
+
<!-- Main Operation Row (Clickable) -->
|
142
|
+
<div class="p-4 hover:bg-gray-50 cursor-pointer" @click="expanded = !expanded">
|
143
|
+
<div class="flex items-start justify-between">
|
144
|
+
<!-- Operation Info -->
|
145
|
+
<div class="flex items-start gap-3 flex-1">
|
146
|
+
<div class="flex-1 min-w-0">
|
147
|
+
<div class="flex items-center gap-2 mb-1 flex-wrap">
|
148
|
+
<span class="badge badge-sm flex-shrink-0" :class="`badge-${operation.operation.toLowerCase()}`" x-text="operation.operation"></span>
|
149
|
+
<span class="text-sm font-medium text-gray-900 truncate" x-text="operation.table_name"></span>
|
150
|
+
<span x-show="operation.record_id" class="text-xs text-gray-500 flex-shrink-0">
|
151
|
+
ID: <span x-text="operation.record_id"></span>
|
152
|
+
</span>
|
153
|
+
</div>
|
154
|
+
<div class="text-xs text-gray-500">
|
155
|
+
<span x-text="formatTimestamp(operation.timestamp)"></span>
|
156
|
+
<span class="ml-2">•</span>
|
157
|
+
<span class="ml-2" x-text="formatRelativeTime(operation)"></span>
|
158
|
+
</div>
|
159
|
+
|
160
|
+
<!-- Changes Preview (if any) -->
|
161
|
+
<template x-if="operation.changes && Object.keys(operation.changes).length > 0 && !expanded">
|
162
|
+
<div class="mt-2 text-xs">
|
163
|
+
<span class="text-gray-400">Changes:</span>
|
164
|
+
<span class="text-gray-600" x-text="Object.keys(operation.changes).slice(0, 3).join(', ') + (Object.keys(operation.changes).length > 3 ? '...' : '')"></span>
|
165
|
+
</div>
|
166
|
+
</template>
|
167
|
+
</div>
|
168
|
+
</div>
|
169
|
+
|
170
|
+
<!-- Expand/Collapse Icon and Sequence -->
|
171
|
+
<div class="flex items-center gap-2 ml-4 flex-shrink-0">
|
172
|
+
<div class="text-xs text-gray-400">
|
173
|
+
#<span x-text="operation.sequence + 1"></span>
|
174
|
+
</div>
|
175
|
+
<svg class="w-4 h-4 text-gray-400 transition-transform" :class="{ 'rotate-180': expanded }" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
176
|
+
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 9l-7 7-7-7"></path>
|
177
|
+
</svg>
|
178
|
+
</div>
|
179
|
+
</div>
|
180
|
+
</div>
|
181
|
+
|
182
|
+
<!-- Expanded Details -->
|
183
|
+
<div x-show="expanded" x-collapse class="bg-gray-50 border-t border-gray-200">
|
184
|
+
<div class="p-4 pl-10">
|
185
|
+
<!-- Operation Details -->
|
186
|
+
<div class="grid grid-cols-1 md:grid-cols-2 gap-4 text-sm mb-4">
|
187
|
+
<div>
|
188
|
+
<span class="font-medium text-gray-700">Table:</span>
|
189
|
+
<span class="ml-2 text-gray-900" x-text="operation.table_name"></span>
|
190
|
+
</div>
|
191
|
+
<div>
|
192
|
+
<span class="font-medium text-gray-700">Operation:</span>
|
193
|
+
<span class="ml-2 badge badge-sm" :class="`badge-${operation.operation.toLowerCase()}`" x-text="operation.operation"></span>
|
194
|
+
</div>
|
195
|
+
<div x-show="operation.record_id">
|
196
|
+
<span class="font-medium text-gray-700">Record ID:</span>
|
197
|
+
<span class="ml-2 text-gray-900" x-text="operation.record_id"></span>
|
198
|
+
</div>
|
199
|
+
<div>
|
200
|
+
<span class="font-medium text-gray-700">Timestamp:</span>
|
201
|
+
<span class="ml-2 text-gray-900" x-text="formatTimestamp(operation.timestamp)"></span>
|
202
|
+
</div>
|
203
|
+
<div>
|
204
|
+
<span class="font-medium text-gray-700">Relative Time:</span>
|
205
|
+
<span class="ml-2 text-gray-900" x-text="formatRelativeTime(operation)"></span>
|
206
|
+
</div>
|
207
|
+
<div>
|
208
|
+
<span class="font-medium text-gray-700">Sequence:</span>
|
209
|
+
<span class="ml-2 text-gray-900" x-text="operation.sequence + 1"></span>
|
210
|
+
</div>
|
211
|
+
</div>
|
212
|
+
|
213
|
+
<!-- Changes Details -->
|
214
|
+
<template x-if="operation.changes && Object.keys(operation.changes).length > 0">
|
215
|
+
<div class="mt-4">
|
216
|
+
<h5 class="font-medium text-gray-700 mb-2">Changes:</h5>
|
217
|
+
<div class="bg-white border border-gray-200 rounded p-3 text-xs overflow-auto max-h-32">
|
218
|
+
<template x-for="[field, change] in Object.entries(operation.changes)" :key="field">
|
219
|
+
<div class="mb-2 last:mb-0">
|
220
|
+
<span class="font-medium text-gray-700" x-text="field"></span>:
|
221
|
+
<template x-if="change.from !== undefined">
|
222
|
+
<span>
|
223
|
+
<span class="text-red-600" x-text="change.from"></span>
|
224
|
+
→
|
225
|
+
<span class="text-green-600" x-text="change.to"></span>
|
226
|
+
</span>
|
227
|
+
</template>
|
228
|
+
<template x-if="change.from === undefined">
|
229
|
+
<span class="text-green-600" x-text="change.to || change"></span>
|
230
|
+
</template>
|
231
|
+
</div>
|
232
|
+
</template>
|
233
|
+
</div>
|
234
|
+
</div>
|
235
|
+
</template>
|
236
|
+
|
237
|
+
<!-- Metadata -->
|
238
|
+
<template x-if="operation.metadata && Object.keys(operation.metadata).length > 0">
|
239
|
+
<div class="mt-4">
|
240
|
+
<h5 class="font-medium text-gray-700 mb-2">Metadata:</h5>
|
241
|
+
<div class="grid grid-cols-1 md:grid-cols-2 gap-2 text-xs">
|
242
|
+
<template x-for="[key, value] in Object.entries(operation.metadata).filter(([k, v]) => v !== null && v !== undefined)" :key="key">
|
243
|
+
<div>
|
244
|
+
<span class="font-medium text-gray-600 capitalize" x-text="key.replace('_', ' ')"></span>:
|
245
|
+
<span class="ml-1 text-gray-900" x-text="value"></span>
|
246
|
+
</div>
|
247
|
+
</template>
|
248
|
+
</div>
|
249
|
+
</div>
|
250
|
+
</template>
|
251
|
+
</div>
|
252
|
+
</div>
|
253
|
+
</div>
|
254
|
+
</template>
|
255
|
+
</div>
|
256
|
+
</template>
|
257
|
+
</div>
|
258
|
+
</div>
|
259
|
+
|
260
|
+
</div>
|
@@ -1,10 +1,12 @@
|
|
1
1
|
<%= render layout: 'layout', locals: { active_tab: @active_tab, session: @session } do %>
|
2
2
|
<% case @active_tab %>
|
3
|
+
<% when 'timeline' %>
|
4
|
+
<%= render partial: 'timeline' %>
|
3
5
|
<% when 'summary' %>
|
4
6
|
<%= render partial: 'summary' %>
|
5
7
|
<% when 'diagrams' %>
|
6
8
|
<%= render partial: 'diagrams' %>
|
7
9
|
<% else %>
|
8
|
-
<%= render partial: '
|
10
|
+
<%= render partial: 'tables' %>
|
9
11
|
<% end %>
|
10
12
|
<% end %>
|
@@ -30,6 +30,7 @@
|
|
30
30
|
<%= javascript_include_tag "dbwatcher/components/diagrams" %>
|
31
31
|
<%= javascript_include_tag "dbwatcher/components/summary" %>
|
32
32
|
<%= javascript_include_tag "dbwatcher/components/dashboard" %>
|
33
|
+
<%= javascript_include_tag "dbwatcher/components/timeline" %>
|
33
34
|
|
34
35
|
<!-- DBWatcher Services -->
|
35
36
|
<%= javascript_include_tag "dbwatcher/core/alpine_store" %>
|
data/config/routes.rb
CHANGED
@@ -51,7 +51,8 @@ module Dbwatcher
|
|
51
51
|
sample_record: nil,
|
52
52
|
total_operations: 0,
|
53
53
|
operations: { insert: 0, update: 0, delete: 0 },
|
54
|
-
changes: []
|
54
|
+
changes: [],
|
55
|
+
model_class: find_model_class(table_name)
|
55
56
|
}
|
56
57
|
end
|
57
58
|
|
@@ -196,6 +197,106 @@ module Dbwatcher
|
|
196
197
|
total_operations: tables.values.sum { |t| t[:total_operations] }
|
197
198
|
}
|
198
199
|
end
|
200
|
+
|
201
|
+
# Find the actual Rails model class for a table name
|
202
|
+
#
|
203
|
+
# @param table_name [String] database table name
|
204
|
+
# @return [String, nil] model class name or nil if not found
|
205
|
+
def find_model_class(table_name)
|
206
|
+
return nil unless table_name.is_a?(String)
|
207
|
+
|
208
|
+
Rails.logger.debug "Finding model class for table: #{table_name}"
|
209
|
+
ensure_models_loaded
|
210
|
+
|
211
|
+
# Try conventional naming first
|
212
|
+
model_name = find_by_convention(table_name)
|
213
|
+
return model_name if model_name
|
214
|
+
|
215
|
+
# Fallback to searching all ActiveRecord descendants
|
216
|
+
find_by_table_name_search(table_name)
|
217
|
+
rescue StandardError => e
|
218
|
+
log_model_finding_error(table_name, e)
|
219
|
+
nil
|
220
|
+
end
|
221
|
+
|
222
|
+
# Ensure all models are loaded in development
|
223
|
+
def ensure_models_loaded
|
224
|
+
Rails.application.eager_load! if Rails.env.development?
|
225
|
+
end
|
226
|
+
|
227
|
+
# Find model by conventional naming (table_name.classify)
|
228
|
+
#
|
229
|
+
# @param table_name [String] database table name
|
230
|
+
# @return [String, nil] model class name or nil
|
231
|
+
def find_by_convention(table_name)
|
232
|
+
model_name = table_name.classify
|
233
|
+
Rails.logger.debug "Expected model name: #{model_name}"
|
234
|
+
|
235
|
+
return nil unless Object.const_defined?(model_name)
|
236
|
+
|
237
|
+
model_class = Object.const_get(model_name)
|
238
|
+
Rails.logger.debug "Found model class: #{model_class}"
|
239
|
+
|
240
|
+
validate_and_return_model(model_class, table_name)
|
241
|
+
end
|
242
|
+
|
243
|
+
# Validate model class and return name if valid
|
244
|
+
#
|
245
|
+
# @param model_class [Class] the model class to validate
|
246
|
+
# @param table_name [String] expected table name
|
247
|
+
# @return [String, nil] model name or nil
|
248
|
+
def validate_and_return_model(model_class, table_name)
|
249
|
+
unless active_record_model?(model_class)
|
250
|
+
Rails.logger.debug "#{model_class} is not an ActiveRecord model"
|
251
|
+
return nil
|
252
|
+
end
|
253
|
+
|
254
|
+
if model_class.table_name == table_name
|
255
|
+
Rails.logger.debug "Model #{model_class.name} matches table #{table_name}"
|
256
|
+
model_class.name
|
257
|
+
else
|
258
|
+
log_table_name_mismatch(model_class, table_name)
|
259
|
+
nil
|
260
|
+
end
|
261
|
+
end
|
262
|
+
|
263
|
+
# Check if class is an ActiveRecord model
|
264
|
+
#
|
265
|
+
# @param model_class [Class] class to check
|
266
|
+
# @return [Boolean] true if ActiveRecord model
|
267
|
+
def active_record_model?(model_class)
|
268
|
+
model_class.respond_to?(:ancestors) && model_class.ancestors.include?(ActiveRecord::Base)
|
269
|
+
end
|
270
|
+
|
271
|
+
# Find model by searching all ActiveRecord descendants
|
272
|
+
#
|
273
|
+
# @param table_name [String] database table name
|
274
|
+
# @return [String, nil] model class name or nil
|
275
|
+
def find_by_table_name_search(table_name)
|
276
|
+
Rails.logger.debug "Checking all ActiveRecord descendants..."
|
277
|
+
|
278
|
+
ActiveRecord::Base.descendants.each do |model|
|
279
|
+
if model.table_name == table_name
|
280
|
+
Rails.logger.debug "Found matching model: #{model.name} for table #{table_name}"
|
281
|
+
return model.name
|
282
|
+
end
|
283
|
+
end
|
284
|
+
|
285
|
+
Rails.logger.debug "No model found for table: #{table_name}"
|
286
|
+
nil
|
287
|
+
end
|
288
|
+
|
289
|
+
# Log table name mismatch
|
290
|
+
def log_table_name_mismatch(model_class, table_name)
|
291
|
+
message = "Model #{model_class.name} table_name (#{model_class.table_name}) doesn't match #{table_name}"
|
292
|
+
Rails.logger.debug message
|
293
|
+
end
|
294
|
+
|
295
|
+
# Log model finding error
|
296
|
+
def log_model_finding_error(table_name, error)
|
297
|
+
Rails.logger.debug "Error finding model class for table #{table_name}: #{error.message}"
|
298
|
+
Rails.logger.debug error.backtrace.first(5).join("\n")
|
299
|
+
end
|
199
300
|
end
|
200
301
|
end
|
201
302
|
end
|
@@ -3,25 +3,25 @@
|
|
3
3
|
module Dbwatcher
|
4
4
|
module Services
|
5
5
|
module Api
|
6
|
-
# Service for handling filtered
|
6
|
+
# Service for handling filtered tables data
|
7
7
|
#
|
8
|
-
# Provides
|
8
|
+
# Provides tables data for the sessions tables view and API endpoints
|
9
9
|
# with filtering and caching support.
|
10
|
-
class
|
10
|
+
class TablesDataService < BaseApiService
|
11
11
|
def call
|
12
12
|
start_time = Time.now
|
13
13
|
|
14
14
|
# Check for nil session first
|
15
15
|
return { error: "Session not found" } unless session
|
16
16
|
|
17
|
-
log_service_start("Getting
|
17
|
+
log_service_start("Getting tables data for session #{session.id}")
|
18
18
|
|
19
19
|
validation_error = validate_session
|
20
20
|
return validation_error if validation_error
|
21
21
|
|
22
22
|
begin
|
23
23
|
result = with_cache(cache_suffix) do
|
24
|
-
|
24
|
+
build_tables_response
|
25
25
|
end
|
26
26
|
|
27
27
|
log_service_completion(start_time, session_id: session.id, filters: filter_params)
|
@@ -33,7 +33,7 @@ module Dbwatcher
|
|
33
33
|
|
34
34
|
private
|
35
35
|
|
36
|
-
def
|
36
|
+
def build_tables_response
|
37
37
|
{
|
38
38
|
tables_summary: build_filtered_summary,
|
39
39
|
filters: filter_params || {},
|
@@ -0,0 +1,100 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
module Dbwatcher
|
4
|
+
module Services
|
5
|
+
class TimelineDataService
|
6
|
+
# Module for enhancing timeline entries and utility methods
|
7
|
+
module EnhancementUtilities
|
8
|
+
private
|
9
|
+
|
10
|
+
# Enhance timeline entries with additional metadata
|
11
|
+
#
|
12
|
+
# @return [void]
|
13
|
+
def enhance_with_metadata
|
14
|
+
return if @timeline_entries.empty?
|
15
|
+
|
16
|
+
session_start_time = @timeline_entries.first[:raw_timestamp]
|
17
|
+
|
18
|
+
@timeline_entries.each_with_index do |entry, index|
|
19
|
+
entry[:relative_time] = calculate_relative_time(entry[:raw_timestamp], session_start_time)
|
20
|
+
entry[:duration_from_previous] = calculate_duration_from_previous(entry, index)
|
21
|
+
entry[:operation_group] = determine_operation_group(entry, index)
|
22
|
+
end
|
23
|
+
end
|
24
|
+
|
25
|
+
# Calculate relative time from session start
|
26
|
+
#
|
27
|
+
# @param timestamp [Float] entry timestamp
|
28
|
+
# @param session_start [Float] session start timestamp
|
29
|
+
# @return [String] formatted relative time
|
30
|
+
def calculate_relative_time(timestamp, session_start)
|
31
|
+
seconds = timestamp - session_start
|
32
|
+
format_duration(seconds)
|
33
|
+
end
|
34
|
+
|
35
|
+
# Calculate duration from previous operation
|
36
|
+
#
|
37
|
+
# @param entry [Hash] current entry
|
38
|
+
# @param index [Integer] entry index
|
39
|
+
# @return [Integer] duration in milliseconds
|
40
|
+
def calculate_duration_from_previous(entry, index)
|
41
|
+
return 0 if index.zero?
|
42
|
+
|
43
|
+
previous_entry = @timeline_entries[index - 1]
|
44
|
+
((entry[:raw_timestamp] - previous_entry[:raw_timestamp]) * 1000).round
|
45
|
+
end
|
46
|
+
|
47
|
+
# Determine operation group for related operations
|
48
|
+
#
|
49
|
+
# @param entry [Hash] current entry
|
50
|
+
# @param index [Integer] entry index
|
51
|
+
# @return [String] operation group identifier
|
52
|
+
def determine_operation_group(entry, index)
|
53
|
+
# Group operations on same table within 1 second
|
54
|
+
return "single_op" if index.zero?
|
55
|
+
|
56
|
+
previous_entry = @timeline_entries[index - 1]
|
57
|
+
time_diff = entry[:raw_timestamp] - previous_entry[:raw_timestamp]
|
58
|
+
|
59
|
+
if time_diff <= 1.0 && entry[:table_name] == previous_entry[:table_name]
|
60
|
+
"#{entry[:table_name]}_batch_#{index / 10}" # Group every 10 operations
|
61
|
+
else
|
62
|
+
"single_op"
|
63
|
+
end
|
64
|
+
end
|
65
|
+
|
66
|
+
# Format duration in human-readable format
|
67
|
+
#
|
68
|
+
# @param seconds [Float] duration in seconds
|
69
|
+
# @return [String] formatted duration
|
70
|
+
def format_duration(seconds)
|
71
|
+
if seconds < 60
|
72
|
+
format("%<minutes>02d:%<seconds>02d", minutes: 0, seconds: seconds.to_i)
|
73
|
+
elsif seconds < 3600
|
74
|
+
minutes = seconds / 60
|
75
|
+
format("%<minutes>02d:%<seconds>02d", minutes: minutes.to_i, seconds: (seconds % 60).to_i)
|
76
|
+
else
|
77
|
+
hours = seconds / 3600
|
78
|
+
minutes = (seconds % 3600) / 60
|
79
|
+
format("%<hours>02d:%<minutes>02d:%<seconds>02d",
|
80
|
+
hours: hours.to_i, minutes: minutes.to_i, seconds: (seconds % 60).to_i)
|
81
|
+
end
|
82
|
+
end
|
83
|
+
|
84
|
+
# Get model class for a table using the TableSummaryBuilder service
|
85
|
+
#
|
86
|
+
# @param table_name [String] database table name
|
87
|
+
# @return [String, nil] model class name or nil if not found
|
88
|
+
def get_model_class_for_table(table_name)
|
89
|
+
# Use cache to avoid repeated lookups
|
90
|
+
@model_class_cache ||= {}
|
91
|
+
return @model_class_cache[table_name] if @model_class_cache.key?(table_name)
|
92
|
+
|
93
|
+
# Delegate to TableSummaryBuilder for model class lookup
|
94
|
+
builder = Dbwatcher::Services::Analyzers::TableSummaryBuilder.new(@session)
|
95
|
+
@model_class_cache[table_name] = builder.send(:find_model_class, table_name)
|
96
|
+
end
|
97
|
+
end
|
98
|
+
end
|
99
|
+
end
|
100
|
+
end
|
@@ -0,0 +1,125 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
require "digest"
|
4
|
+
|
5
|
+
module Dbwatcher
|
6
|
+
module Services
|
7
|
+
class TimelineDataService
|
8
|
+
# Module for building timeline entries
|
9
|
+
module EntryBuilder
|
10
|
+
private
|
11
|
+
|
12
|
+
# Create a timeline entry from change data
|
13
|
+
#
|
14
|
+
# @param change [Hash] change data
|
15
|
+
# @param sequence [Integer] sequence number
|
16
|
+
# @return [Hash] timeline entry
|
17
|
+
def create_timeline_entry(change, sequence)
|
18
|
+
timestamp = parse_timestamp(change[:timestamp])
|
19
|
+
|
20
|
+
{
|
21
|
+
id: generate_entry_id(change, sequence),
|
22
|
+
timestamp: timestamp,
|
23
|
+
sequence: sequence,
|
24
|
+
table_name: change[:table_name],
|
25
|
+
operation: change[:operation],
|
26
|
+
record_id: extract_record_id(change),
|
27
|
+
changes: format_changes(change),
|
28
|
+
metadata: extract_metadata(change),
|
29
|
+
model_class: get_model_class_for_table(change[:table_name]),
|
30
|
+
raw_timestamp: timestamp.to_f
|
31
|
+
}
|
32
|
+
end
|
33
|
+
|
34
|
+
# Generate unique ID for timeline entry
|
35
|
+
#
|
36
|
+
# @param change [Hash] change data
|
37
|
+
# @param sequence [Integer] sequence number
|
38
|
+
# @return [String] unique entry ID
|
39
|
+
def generate_entry_id(change, sequence)
|
40
|
+
data = "#{change[:table_name]}_#{change[:operation]}_#{sequence}"
|
41
|
+
hash = Digest::SHA1.hexdigest(data)[0..7]
|
42
|
+
"#{@session.id}_entry_#{sequence}_#{hash}"
|
43
|
+
end
|
44
|
+
|
45
|
+
# Parse timestamp from various formats
|
46
|
+
#
|
47
|
+
# @param timestamp [String, Time, Integer] timestamp value
|
48
|
+
# @return [Time] parsed timestamp
|
49
|
+
def parse_timestamp(timestamp)
|
50
|
+
case timestamp
|
51
|
+
when Time
|
52
|
+
timestamp
|
53
|
+
when String
|
54
|
+
Time.parse(timestamp)
|
55
|
+
when Integer, Float
|
56
|
+
Time.at(timestamp)
|
57
|
+
else
|
58
|
+
Time.current
|
59
|
+
end
|
60
|
+
rescue ArgumentError
|
61
|
+
Time.current
|
62
|
+
end
|
63
|
+
|
64
|
+
# Extract record ID from change data
|
65
|
+
#
|
66
|
+
# @param change [Hash] change data
|
67
|
+
# @return [String, nil] record ID if available
|
68
|
+
def extract_record_id(change)
|
69
|
+
change[:record_id] || change[:id] || change.dig(:changes, :id)
|
70
|
+
end
|
71
|
+
|
72
|
+
# Format changes for timeline display
|
73
|
+
#
|
74
|
+
# @param change [Hash] change data
|
75
|
+
# @return [Hash] formatted changes
|
76
|
+
def format_changes(change)
|
77
|
+
raw_changes = change[:changes] || change[:data] || {}
|
78
|
+
return {} unless raw_changes.is_a?(Hash)
|
79
|
+
|
80
|
+
raw_changes.transform_values do |value|
|
81
|
+
case value
|
82
|
+
when Hash
|
83
|
+
value # Already formatted as { from: x, to: y }
|
84
|
+
else
|
85
|
+
{ to: value } # Simple value change
|
86
|
+
end
|
87
|
+
end
|
88
|
+
end
|
89
|
+
|
90
|
+
# Extract metadata from change data
|
91
|
+
#
|
92
|
+
# @param change [Hash] change data
|
93
|
+
# @return [Hash] metadata hash
|
94
|
+
def extract_metadata(change)
|
95
|
+
{
|
96
|
+
duration_ms: change[:duration_ms] || change[:duration],
|
97
|
+
affected_rows: change[:affected_rows] || change[:rows_affected] || 1,
|
98
|
+
query_fingerprint: change[:query_fingerprint] || change[:sql_fingerprint],
|
99
|
+
connection_id: change[:connection_id] || change[:connection],
|
100
|
+
query_type: determine_query_type(change[:operation])
|
101
|
+
}.compact
|
102
|
+
end
|
103
|
+
|
104
|
+
# Determine query type from operation
|
105
|
+
#
|
106
|
+
# @param operation [String] database operation
|
107
|
+
# @return [String] query type
|
108
|
+
def determine_query_type(operation)
|
109
|
+
case operation&.upcase
|
110
|
+
when "INSERT", "CREATE"
|
111
|
+
"write"
|
112
|
+
when "UPDATE", "MODIFY"
|
113
|
+
"update"
|
114
|
+
when "DELETE", "DROP"
|
115
|
+
"delete"
|
116
|
+
when "SELECT", "SHOW"
|
117
|
+
"read"
|
118
|
+
else
|
119
|
+
"unknown"
|
120
|
+
end
|
121
|
+
end
|
122
|
+
end
|
123
|
+
end
|
124
|
+
end
|
125
|
+
end
|