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.
Files changed (27) hide show
  1. checksums.yaml +4 -4
  2. data/app/assets/config/dbwatcher_manifest.js +1 -0
  3. data/app/assets/javascripts/dbwatcher/components/changes_table_hybrid.js +184 -97
  4. data/app/assets/javascripts/dbwatcher/components/timeline.js +211 -0
  5. data/app/assets/javascripts/dbwatcher/dbwatcher.js +5 -0
  6. data/app/assets/stylesheets/dbwatcher/application.css +298 -1
  7. data/app/assets/stylesheets/dbwatcher/application.scss +1 -0
  8. data/app/assets/stylesheets/dbwatcher/components/_timeline.scss +326 -0
  9. data/app/controllers/dbwatcher/api/v1/sessions_controller.rb +18 -4
  10. data/app/controllers/dbwatcher/sessions_controller.rb +1 -1
  11. data/app/views/dbwatcher/sessions/_layout.html.erb +5 -2
  12. data/app/views/dbwatcher/sessions/_summary.html.erb +1 -1
  13. data/app/views/dbwatcher/sessions/{_changes.html.erb → _tables.html.erb} +84 -5
  14. data/app/views/dbwatcher/sessions/_timeline.html.erb +260 -0
  15. data/app/views/dbwatcher/sessions/show.html.erb +3 -1
  16. data/app/views/layouts/dbwatcher/application.html.erb +1 -0
  17. data/config/routes.rb +2 -1
  18. data/lib/dbwatcher/services/analyzers/table_summary_builder.rb +102 -1
  19. data/lib/dbwatcher/services/api/{changes_data_service.rb → tables_data_service.rb} +6 -6
  20. data/lib/dbwatcher/services/timeline_data_service/enhancement_utilities.rb +100 -0
  21. data/lib/dbwatcher/services/timeline_data_service/entry_builder.rb +125 -0
  22. data/lib/dbwatcher/services/timeline_data_service/metadata_builder.rb +93 -0
  23. data/lib/dbwatcher/services/timeline_data_service.rb +130 -0
  24. data/lib/dbwatcher/storage/api/concerns/table_analyzer.rb +1 -1
  25. data/lib/dbwatcher/version.rb +1 -1
  26. data/lib/dbwatcher.rb +1 -1
  27. 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: 'changes' %>
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
@@ -27,9 +27,10 @@ Dbwatcher::Engine.routes.draw do
27
27
  namespace :v1 do
28
28
  resources :sessions, only: [] do
29
29
  member do
30
- get :changes_data
30
+ get :tables_data
31
31
  get :summary_data
32
32
  get :diagram_data
33
+ get :timeline_data
33
34
  end
34
35
 
35
36
  collection do
@@ -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 changes data
6
+ # Service for handling filtered tables data
7
7
  #
8
- # Provides changes data for the sessions changes view and API endpoints
8
+ # Provides tables data for the sessions tables view and API endpoints
9
9
  # with filtering and caching support.
10
- class ChangesDataService < BaseApiService
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 changes data for session #{session.id}")
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
- build_changes_response
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 build_changes_response
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