dbwatcher 1.1.1 → 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 (69) hide show
  1. checksums.yaml +4 -4
  2. data/README.md +24 -2
  3. data/app/assets/config/dbwatcher_manifest.js +1 -0
  4. data/app/assets/javascripts/dbwatcher/components/changes_table_hybrid.js +196 -119
  5. data/app/assets/javascripts/dbwatcher/components/dashboard.js +325 -0
  6. data/app/assets/javascripts/dbwatcher/components/timeline.js +211 -0
  7. data/app/assets/javascripts/dbwatcher/dbwatcher.js +5 -0
  8. data/app/assets/stylesheets/dbwatcher/application.css +691 -41
  9. data/app/assets/stylesheets/dbwatcher/application.scss +5 -0
  10. data/app/assets/stylesheets/dbwatcher/components/_badges.scss +68 -23
  11. data/app/assets/stylesheets/dbwatcher/components/_compact_table.scss +83 -26
  12. data/app/assets/stylesheets/dbwatcher/components/_diagrams.scss +3 -3
  13. data/app/assets/stylesheets/dbwatcher/components/_navigation.scss +9 -0
  14. data/app/assets/stylesheets/dbwatcher/components/_tabulator.scss +248 -0
  15. data/app/assets/stylesheets/dbwatcher/components/_timeline.scss +326 -0
  16. data/app/assets/stylesheets/dbwatcher/vendor/_tabulator_overrides.scss +37 -0
  17. data/app/controllers/dbwatcher/api/v1/sessions_controller.rb +18 -4
  18. data/app/controllers/dbwatcher/api/v1/system_info_controller.rb +180 -0
  19. data/app/controllers/dbwatcher/dashboard/system_info_controller.rb +64 -0
  20. data/app/controllers/dbwatcher/dashboard_controller.rb +17 -0
  21. data/app/controllers/dbwatcher/sessions_controller.rb +3 -19
  22. data/app/helpers/dbwatcher/application_helper.rb +43 -11
  23. data/app/helpers/dbwatcher/diagram_helper.rb +0 -88
  24. data/app/views/dbwatcher/dashboard/_layout.html.erb +27 -0
  25. data/app/views/dbwatcher/dashboard/_overview.html.erb +188 -0
  26. data/app/views/dbwatcher/dashboard/_system_info.html.erb +22 -0
  27. data/app/views/dbwatcher/dashboard/_system_info_content.html.erb +389 -0
  28. data/app/views/dbwatcher/dashboard/index.html.erb +8 -177
  29. data/app/views/dbwatcher/sessions/_layout.html.erb +26 -0
  30. data/app/views/dbwatcher/sessions/{_summary_tab.html.erb → _summary.html.erb} +1 -1
  31. data/app/views/dbwatcher/sessions/_tables.html.erb +170 -0
  32. data/app/views/dbwatcher/sessions/_timeline.html.erb +260 -0
  33. data/app/views/dbwatcher/sessions/index.html.erb +107 -87
  34. data/app/views/dbwatcher/sessions/show.html.erb +12 -4
  35. data/app/views/dbwatcher/tables/index.html.erb +32 -40
  36. data/app/views/layouts/dbwatcher/application.html.erb +101 -48
  37. data/config/routes.rb +25 -7
  38. data/lib/dbwatcher/configuration.rb +18 -1
  39. data/lib/dbwatcher/services/analyzers/table_summary_builder.rb +102 -1
  40. data/lib/dbwatcher/services/api/{changes_data_service.rb → tables_data_service.rb} +6 -6
  41. data/lib/dbwatcher/services/base_service.rb +2 -0
  42. data/lib/dbwatcher/services/system_info/database_info_collector.rb +263 -0
  43. data/lib/dbwatcher/services/system_info/machine_info_collector.rb +387 -0
  44. data/lib/dbwatcher/services/system_info/runtime_info_collector.rb +328 -0
  45. data/lib/dbwatcher/services/system_info/system_info_collector.rb +114 -0
  46. data/lib/dbwatcher/services/timeline_data_service/enhancement_utilities.rb +100 -0
  47. data/lib/dbwatcher/services/timeline_data_service/entry_builder.rb +125 -0
  48. data/lib/dbwatcher/services/timeline_data_service/metadata_builder.rb +93 -0
  49. data/lib/dbwatcher/services/timeline_data_service.rb +130 -0
  50. data/lib/dbwatcher/storage/api/concerns/table_analyzer.rb +1 -1
  51. data/lib/dbwatcher/storage/concerns/error_handler.rb +6 -6
  52. data/lib/dbwatcher/storage/session.rb +5 -0
  53. data/lib/dbwatcher/storage/system_info_storage.rb +242 -0
  54. data/lib/dbwatcher/storage.rb +12 -0
  55. data/lib/dbwatcher/version.rb +1 -1
  56. data/lib/dbwatcher.rb +16 -2
  57. metadata +28 -16
  58. data/app/helpers/dbwatcher/component_helper.rb +0 -29
  59. data/app/views/dbwatcher/sessions/_changes_tab.html.erb +0 -265
  60. data/app/views/dbwatcher/sessions/_tab_navigation.html.erb +0 -12
  61. data/app/views/dbwatcher/sessions/changes.html.erb +0 -21
  62. data/app/views/dbwatcher/sessions/components/changes/_filters.html.erb +0 -44
  63. data/app/views/dbwatcher/sessions/components/changes/_table_list.html.erb +0 -96
  64. data/app/views/dbwatcher/sessions/diagrams.html.erb +0 -21
  65. data/app/views/dbwatcher/sessions/shared/_layout.html.erb +0 -8
  66. data/app/views/dbwatcher/sessions/shared/_navigation.html.erb +0 -35
  67. data/app/views/dbwatcher/sessions/shared/_session_header.html.erb +0 -25
  68. data/app/views/dbwatcher/sessions/summary.html.erb +0 -21
  69. /data/app/views/dbwatcher/sessions/{_diagrams_tab.html.erb → _diagrams.html.erb} +0 -0
@@ -0,0 +1,328 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "../../logging"
4
+
5
+ module Dbwatcher
6
+ module Services
7
+ module SystemInfo
8
+ # Runtime information collector service
9
+ #
10
+ # Collects runtime-specific information including Ruby version, Rails version,
11
+ # loaded gems, environment variables, and application configuration.
12
+ #
13
+ # @example
14
+ # info = SystemInfo::RuntimeInfoCollector.call
15
+ # puts info[:ruby_version]
16
+ # puts info[:rails_version]
17
+ # puts info[:gem_count]
18
+ #
19
+ # This class is necessarily complex due to the comprehensive runtime information
20
+ # it needs to collect across different Ruby and Rails environments.
21
+ # rubocop:disable Metrics/ClassLength
22
+ class RuntimeInfoCollector
23
+ include Dbwatcher::Logging
24
+
25
+ # Class method to create instance and call
26
+ #
27
+ # @return [Hash] runtime information
28
+ def self.call
29
+ new.call
30
+ end
31
+
32
+ # Collect runtime information
33
+ #
34
+ # @return [Hash] collected runtime information
35
+ # rubocop:disable Metrics/MethodLength
36
+ def call
37
+ log_info "#{self.class.name}: Collecting runtime information"
38
+
39
+ {
40
+ ruby_version: collect_ruby_version,
41
+ ruby_engine: collect_ruby_engine,
42
+ ruby_patchlevel: collect_ruby_patchlevel,
43
+ rails_version: collect_rails_version,
44
+ rails_env: collect_rails_env,
45
+ environment: collect_environment,
46
+ pid: Process.pid,
47
+ gem_count: collect_gem_count,
48
+ loaded_gems: collect_loaded_gems,
49
+ load_path: collect_load_path_info,
50
+ environment_variables: collect_environment_variables,
51
+ application_config: collect_application_config
52
+ }
53
+ rescue StandardError => e
54
+ log_error "Runtime info collection failed: #{e.message}"
55
+ { error: e.message }
56
+ end
57
+ # rubocop:enable Metrics/MethodLength
58
+
59
+ private
60
+
61
+ # Collect Ruby version information
62
+ #
63
+ # @return [String] Ruby version
64
+ def collect_ruby_version
65
+ RUBY_VERSION
66
+ rescue StandardError => e
67
+ log_error "Failed to get Ruby version: #{e.message}"
68
+ "unknown"
69
+ end
70
+
71
+ # Collect Ruby engine information
72
+ #
73
+ # @return [String] Ruby engine (e.g., ruby, jruby, rbx)
74
+ def collect_ruby_engine
75
+ defined?(RUBY_ENGINE) ? RUBY_ENGINE : "ruby"
76
+ rescue StandardError => e
77
+ log_error "Failed to get Ruby engine: #{e.message}"
78
+ "unknown"
79
+ end
80
+
81
+ # Collect Ruby patchlevel
82
+ #
83
+ # @return [String] Ruby patchlevel
84
+ def collect_ruby_patchlevel
85
+ RUBY_PATCHLEVEL.to_s
86
+ rescue StandardError => e
87
+ log_error "Failed to get Ruby patchlevel: #{e.message}"
88
+ "unknown"
89
+ end
90
+
91
+ # Collect Rails version if available
92
+ #
93
+ # @return [String] Rails version or nil
94
+ def collect_rails_version
95
+ return nil unless defined?(Rails)
96
+
97
+ Rails.version
98
+ rescue StandardError => e
99
+ log_error "Failed to get Rails version: #{e.message}"
100
+ nil
101
+ end
102
+
103
+ # Collect Rails environment if available
104
+ #
105
+ # @return [String] Rails environment or nil
106
+ def collect_rails_env
107
+ return nil unless defined?(Rails)
108
+
109
+ Rails.env
110
+ rescue StandardError => e
111
+ log_error "Failed to get Rails environment: #{e.message}"
112
+ nil
113
+ end
114
+
115
+ # Collect current environment
116
+ #
117
+ # @return [String] current environment
118
+ def collect_environment
119
+ ENV["RAILS_ENV"] || ENV["RACK_ENV"] || "development"
120
+ rescue StandardError => e
121
+ log_error "Failed to get environment: #{e.message}"
122
+ "unknown"
123
+ end
124
+
125
+ # Collect loaded gems count
126
+ #
127
+ # @return [Integer] number of loaded gems
128
+ def collect_gem_count
129
+ Gem.loaded_specs.size
130
+ rescue StandardError => e
131
+ log_error "Failed to get gem count: #{e.message}"
132
+ 0
133
+ end
134
+
135
+ # Collect loaded gems information
136
+ #
137
+ # @return [Hash] loaded gems with versions
138
+ def collect_loaded_gems
139
+ return {} unless Dbwatcher.configuration.system_info_include_performance_metrics
140
+
141
+ gems = {}
142
+ Gem.loaded_specs.each do |name, spec|
143
+ gems[name] = spec.version.to_s
144
+ end
145
+ gems
146
+ rescue StandardError => e
147
+ log_error "Failed to get loaded gems: #{e.message}"
148
+ {}
149
+ end
150
+
151
+ # Collect load path information
152
+ #
153
+ # @return [Hash] load path statistics
154
+ def collect_load_path_info
155
+ {
156
+ size: $LOAD_PATH.size,
157
+ paths: Dbwatcher.configuration.system_info_include_performance_metrics ? $LOAD_PATH.first(10) : []
158
+ }
159
+ rescue StandardError => e
160
+ log_error "Failed to get load path info: #{e.message}"
161
+ { size: 0, paths: [] }
162
+ end
163
+
164
+ # Collect environment variables (filtered for security)
165
+ #
166
+ # @return [Hash] filtered environment variables
167
+ # rubocop:disable Metrics/MethodLength
168
+ def collect_environment_variables
169
+ return {} unless Dbwatcher.configuration.collect_sensitive_env_vars
170
+
171
+ env_vars = {}
172
+
173
+ # Safe environment variables to include
174
+ safe_vars = %w[
175
+ RAILS_ENV
176
+ RACK_ENV
177
+ RUBY_VERSION
178
+ PATH
179
+ HOME
180
+ USER
181
+ SHELL
182
+ TERM
183
+ LANG
184
+ LC_ALL
185
+ TZ
186
+ RAILS_LOG_LEVEL
187
+ RAILS_SERVE_STATIC_FILES
188
+ RAILS_CACHE_ID
189
+ RAILS_RELATIVE_URL_ROOT
190
+ BUNDLE_GEMFILE
191
+ BUNDLE_PATH
192
+ GEM_HOME
193
+ GEM_PATH
194
+ ]
195
+
196
+ safe_vars.each do |var|
197
+ env_vars[var] = ENV[var] if ENV[var]
198
+ end
199
+
200
+ env_vars
201
+ rescue StandardError => e
202
+ log_error "Failed to get environment variables: #{e.message}"
203
+ {}
204
+ end
205
+ # rubocop:enable Metrics/MethodLength
206
+
207
+ # Collect application configuration
208
+ #
209
+ # @return [Hash] application configuration
210
+ def collect_application_config
211
+ config = {}
212
+
213
+ # Collect Rails configuration if available
214
+ config[:rails] = collect_rails_config if defined?(Rails)
215
+
216
+ # Collect DBWatcher configuration
217
+ config[:dbwatcher] = collect_dbwatcher_config if defined?(Dbwatcher)
218
+
219
+ config
220
+ rescue StandardError => e
221
+ log_error "Failed to get application config: #{e.message}"
222
+ {}
223
+ end
224
+
225
+ # Collect Rails configuration if available
226
+ #
227
+ # @return [Hash] Rails configuration
228
+ # rubocop:disable Metrics/MethodLength, Metrics/AbcSize, Metrics/CyclomaticComplexity, Metrics/PerceivedComplexity
229
+ def collect_rails_config
230
+ rails_config = {}
231
+
232
+ return rails_config unless defined?(Rails)
233
+
234
+ # Basic Rails configuration
235
+ rails_config[:environment] = Rails.env
236
+ rails_config[:version] = Rails.version
237
+ rails_config[:root] = Rails.root.to_s
238
+
239
+ # Rails application configuration
240
+ if Rails.application
241
+ app_config = Rails.application.config
242
+ rails_config[:app_name] = Rails.application.class.name.split("::").first
243
+ rails_config[:autoload_paths] =
244
+ app_config.respond_to?(:autoload_paths) ? app_config.autoload_paths.size : nil
245
+ rails_config[:eager_load_paths] =
246
+ app_config.respond_to?(:eager_load_paths) ? app_config.eager_load_paths.size : nil
247
+ rails_config[:eager_load] = app_config.respond_to?(:eager_load) ? app_config.eager_load : nil
248
+ rails_config[:cache_classes] = app_config.respond_to?(:cache_classes) ? app_config.cache_classes : nil
249
+ rails_config[:consider_all_requests_local] =
250
+ app_config.respond_to?(:consider_all_requests_local) ? app_config.consider_all_requests_local : nil
251
+
252
+ # Fix long line by breaking it up and removing redundant else
253
+ perform_caching = if app_config.action_controller.respond_to?(:perform_caching)
254
+ app_config.action_controller.perform_caching
255
+ end
256
+ rails_config[:action_controller] = { perform_caching: perform_caching }
257
+ end
258
+
259
+ rails_config
260
+ rescue StandardError => e
261
+ log_error "Failed to get Rails config: #{e.message}"
262
+ {}
263
+ end
264
+ # rubocop:enable Metrics/MethodLength, Metrics/AbcSize, Metrics/CyclomaticComplexity, Metrics/PerceivedComplexity
265
+
266
+ # Collect DBWatcher configuration
267
+ #
268
+ # @return [Hash] DBWatcher configuration
269
+ # rubocop:disable Metrics/MethodLength
270
+ def collect_dbwatcher_config
271
+ config = Dbwatcher.configuration
272
+
273
+ # Only include non-sensitive configuration options
274
+ # Split long lines to avoid line length issues
275
+ {
276
+ system_info_refresh_interval:
277
+ if config.respond_to?(:system_info_refresh_interval)
278
+ config.system_info_refresh_interval
279
+ else
280
+ 300
281
+ end,
282
+ collect_sensitive_env_vars:
283
+ if config.respond_to?(:collect_sensitive_env_vars)
284
+ config.collect_sensitive_env_vars
285
+ else
286
+ false
287
+ end,
288
+ system_info_include_performance_metrics:
289
+ if config.respond_to?(:system_info_include_performance_metrics)
290
+ config.system_info_include_performance_metrics
291
+ else
292
+ true
293
+ end
294
+ }
295
+ rescue StandardError => e
296
+ log_error "Failed to get DBWatcher config: #{e.message}"
297
+ {}
298
+ end
299
+ # rubocop:enable Metrics/MethodLength
300
+
301
+ # Sanitize database configuration to remove sensitive information
302
+ #
303
+ # @param db_config [Hash] database configuration
304
+ # @return [Hash] sanitized database configuration
305
+ def sanitize_database_config(db_config)
306
+ return {} unless db_config.is_a?(Hash)
307
+
308
+ # Create a copy to avoid modifying the original
309
+ sanitized = db_config.dup
310
+
311
+ # Remove sensitive information
312
+ %w[password username user].each do |key|
313
+ sanitized.delete(key)
314
+ sanitized.delete(key.to_sym)
315
+ end
316
+
317
+ # Keep only basic connection info
318
+ safe_keys = %w[adapter host port database pool timeout]
319
+ sanitized.select { |k, _| safe_keys.include?(k.to_s) }
320
+ rescue StandardError => e
321
+ log_error "Failed to sanitize database config: #{e.message}"
322
+ {}
323
+ end
324
+ end
325
+ # rubocop:enable Metrics/ClassLength
326
+ end
327
+ end
328
+ end
@@ -0,0 +1,114 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "machine_info_collector"
4
+ require_relative "database_info_collector"
5
+ require_relative "runtime_info_collector"
6
+ require_relative "../../logging"
7
+
8
+ module Dbwatcher
9
+ module Services
10
+ module SystemInfo
11
+ # Main system information collector service
12
+ #
13
+ # Orchestrates the collection of system information from various sources
14
+ # including machine, database, and runtime information.
15
+ #
16
+ # @example
17
+ # info = SystemInfo::SystemInfoCollector.call
18
+ # puts info[:machine][:hostname]
19
+ # puts info[:database][:adapter]
20
+ # puts info[:runtime][:ruby_version]
21
+ class SystemInfoCollector
22
+ include Dbwatcher::Logging
23
+
24
+ # Class method to create instance and call
25
+ #
26
+ # @return [Hash] system information
27
+ def self.call
28
+ new.call
29
+ end
30
+
31
+ # Collect system information from all sources
32
+ #
33
+ # This method needs to be longer to properly handle all the collection
34
+ # steps and error handling in a consistent way.
35
+ #
36
+ # @return [Hash] collected system information
37
+ # rubocop:disable Metrics/MethodLength
38
+ def call
39
+ start_time = current_time
40
+ log_info "#{self.class.name}: Starting system information collection"
41
+
42
+ info = {
43
+ machine: collect_machine_info,
44
+ database: collect_database_info,
45
+ runtime: collect_runtime_info,
46
+ collected_at: current_time.iso8601,
47
+ collection_duration: nil
48
+ }
49
+
50
+ info[:collection_duration] = (current_time - start_time).round(3)
51
+ log_info "#{self.class.name}: Completed system information collection in #{info[:collection_duration]}s"
52
+
53
+ info
54
+ rescue StandardError => e
55
+ log_error "System information collection failed: #{e.message}"
56
+ {
57
+ machine: {},
58
+ database: {},
59
+ runtime: {},
60
+ collected_at: current_time.iso8601,
61
+ collection_duration: nil,
62
+ error: e.message
63
+ }
64
+ end
65
+ # rubocop:enable Metrics/MethodLength
66
+
67
+ private
68
+
69
+ # Get current time, using Rails Time.current if available, otherwise Time.now
70
+ #
71
+ # @return [Time] current time
72
+ def current_time
73
+ defined?(Time.current) ? Time.current : Time.now
74
+ end
75
+
76
+ # Collect machine information safely
77
+ #
78
+ # @return [Hash] machine information or empty hash on error
79
+ def collect_machine_info
80
+ return {} unless Dbwatcher.configuration.collect_system_info
81
+
82
+ MachineInfoCollector.call
83
+ rescue StandardError => e
84
+ log_error "Machine info collection failed: #{e.message}"
85
+ { error: e.message }
86
+ end
87
+
88
+ # Collect database information safely
89
+ #
90
+ # @return [Hash] database information or empty hash on error
91
+ def collect_database_info
92
+ return {} unless Dbwatcher.configuration.collect_system_info
93
+
94
+ DatabaseInfoCollector.call
95
+ rescue StandardError => e
96
+ log_error "Database info collection failed: #{e.message}"
97
+ { error: e.message }
98
+ end
99
+
100
+ # Collect runtime information safely
101
+ #
102
+ # @return [Hash] runtime information or empty hash on error
103
+ def collect_runtime_info
104
+ return {} unless Dbwatcher.configuration.collect_system_info
105
+
106
+ RuntimeInfoCollector.call
107
+ rescue StandardError => e
108
+ log_error "Runtime info collection failed: #{e.message}"
109
+ { error: e.message }
110
+ end
111
+ end
112
+ end
113
+ end
114
+ end
@@ -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