dbwatcher 0.1.5 → 1.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (60) hide show
  1. checksums.yaml +4 -4
  2. data/README.md +2 -2
  3. data/app/controllers/dbwatcher/base_controller.rb +95 -0
  4. data/app/controllers/dbwatcher/dashboard_controller.rb +12 -0
  5. data/app/controllers/dbwatcher/queries_controller.rb +24 -0
  6. data/app/controllers/dbwatcher/sessions_controller.rb +15 -20
  7. data/app/controllers/dbwatcher/tables_controller.rb +38 -0
  8. data/app/helpers/dbwatcher/application_helper.rb +103 -0
  9. data/app/helpers/dbwatcher/formatting_helper.rb +108 -0
  10. data/app/helpers/dbwatcher/session_helper.rb +27 -0
  11. data/app/views/dbwatcher/dashboard/index.html.erb +177 -0
  12. data/app/views/dbwatcher/queries/index.html.erb +240 -0
  13. data/app/views/dbwatcher/sessions/index.html.erb +120 -27
  14. data/app/views/dbwatcher/sessions/show.html.erb +326 -129
  15. data/app/views/dbwatcher/shared/_badge.html.erb +4 -0
  16. data/app/views/dbwatcher/shared/_data_table.html.erb +20 -0
  17. data/app/views/dbwatcher/shared/_header.html.erb +7 -0
  18. data/app/views/dbwatcher/shared/_page_layout.html.erb +20 -0
  19. data/app/views/dbwatcher/shared/_section_panel.html.erb +9 -0
  20. data/app/views/dbwatcher/shared/_stats_card.html.erb +11 -0
  21. data/app/views/dbwatcher/shared/_tab_bar.html.erb +6 -0
  22. data/app/views/dbwatcher/tables/changes.html.erb +225 -0
  23. data/app/views/dbwatcher/tables/index.html.erb +123 -0
  24. data/app/views/dbwatcher/tables/show.html.erb +86 -0
  25. data/app/views/layouts/dbwatcher/application.html.erb +375 -26
  26. data/config/routes.rb +17 -3
  27. data/lib/dbwatcher/configuration.rb +9 -1
  28. data/lib/dbwatcher/engine.rb +12 -7
  29. data/lib/dbwatcher/logging.rb +72 -0
  30. data/lib/dbwatcher/services/dashboard_data_aggregator.rb +121 -0
  31. data/lib/dbwatcher/services/query_filter_processor.rb +114 -0
  32. data/lib/dbwatcher/services/table_statistics_collector.rb +119 -0
  33. data/lib/dbwatcher/sql_logger.rb +107 -0
  34. data/lib/dbwatcher/storage/api/base_api.rb +134 -0
  35. data/lib/dbwatcher/storage/api/concerns/table_analyzer.rb +172 -0
  36. data/lib/dbwatcher/storage/api/query_api.rb +95 -0
  37. data/lib/dbwatcher/storage/api/session_api.rb +134 -0
  38. data/lib/dbwatcher/storage/api/table_api.rb +86 -0
  39. data/lib/dbwatcher/storage/base_storage.rb +113 -0
  40. data/lib/dbwatcher/storage/change_processor.rb +65 -0
  41. data/lib/dbwatcher/storage/concerns/data_normalizer.rb +134 -0
  42. data/lib/dbwatcher/storage/concerns/error_handler.rb +75 -0
  43. data/lib/dbwatcher/storage/concerns/timestampable.rb +74 -0
  44. data/lib/dbwatcher/storage/concerns/validatable.rb +117 -0
  45. data/lib/dbwatcher/storage/date_helper.rb +21 -0
  46. data/lib/dbwatcher/storage/errors.rb +86 -0
  47. data/lib/dbwatcher/storage/file_manager.rb +122 -0
  48. data/lib/dbwatcher/storage/null_session.rb +39 -0
  49. data/lib/dbwatcher/storage/query_storage.rb +338 -0
  50. data/lib/dbwatcher/storage/query_validator.rb +24 -0
  51. data/lib/dbwatcher/storage/session.rb +58 -0
  52. data/lib/dbwatcher/storage/session_operations.rb +37 -0
  53. data/lib/dbwatcher/storage/session_query.rb +71 -0
  54. data/lib/dbwatcher/storage/session_storage.rb +322 -0
  55. data/lib/dbwatcher/storage/table_storage.rb +237 -0
  56. data/lib/dbwatcher/storage.rb +112 -85
  57. data/lib/dbwatcher/tracker.rb +4 -55
  58. data/lib/dbwatcher/version.rb +1 -1
  59. data/lib/dbwatcher.rb +12 -2
  60. metadata +47 -1
@@ -1,34 +1,383 @@
1
1
  <!DOCTYPE html>
2
2
  <html>
3
- <head>
4
- <title>DB Watcher</title>
5
- <meta name="viewport" content="width=device-width,initial-scale=1">
6
- <%= csrf_meta_tags %>
7
- <script src="https://cdn.jsdelivr.net/npm/alpinejs@3.x.x/dist/cdn.min.js" defer></script>
8
- <script src="https://cdn.tailwindcss.com"></script>
9
- </head>
10
- <body class="bg-gray-50">
11
- <div class="min-h-screen">
12
- <nav class="bg-white shadow">
13
- <div class="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8">
14
- <div class="flex justify-between h-16">
15
- <div class="flex items-center">
16
- <h1 class="text-xl font-semibold">🔍 DB Watcher</h1>
3
+ <head>
4
+ <title>DB Watcher</title>
5
+ <meta name="viewport" content="width=device-width,initial-scale=1">
6
+ <%= csrf_meta_tags %>
7
+
8
+ <!-- Alpine.js -->
9
+ <script defer src="https://unpkg.com/alpinejs@3.x.x/dist/cdn.min.js"></script>
10
+
11
+ <!-- Tailwind CSS -->
12
+ <script src="https://cdn.tailwindcss.com"></script>
13
+
14
+ <!-- Custom styles for compact theme -->
15
+ <style>
16
+ :root {
17
+ --navy-dark: #00285D;
18
+ --blue-light: #96C1E7;
19
+ --blue-medium: #6CADDF;
20
+ --gold-dark: #D4A11E;
21
+ --gold-light: #FFC758;
22
+ }
23
+
24
+ /* Compact table styles */
25
+ .compact-table {
26
+ font-family: 'Consolas', 'Monaco', 'Lucida Console', monospace;
27
+ font-size: 12px;
28
+ line-height: 1.2;
29
+ }
30
+
31
+ .compact-table th {
32
+ padding: 4px 8px;
33
+ font-weight: 500;
34
+ text-transform: none;
35
+ font-size: 11px;
36
+ background: #f3f3f3;
37
+ border-bottom: 2px solid #e8e8e8;
38
+ border-right: 1px solid #e8e8e8;
39
+ position: sticky;
40
+ top: 0;
41
+ z-index: 10;
42
+ }
43
+
44
+ .compact-table td {
45
+ padding: 2px 8px;
46
+ border-right: 1px solid #f0f0f0;
47
+ border-bottom: 1px solid #f0f0f0;
48
+ max-width: 200px;
49
+ overflow: hidden;
50
+ text-overflow: ellipsis;
51
+ white-space: nowrap;
52
+ }
53
+
54
+ .compact-table tr:hover {
55
+ background: #f8f8f8;
56
+ }
57
+
58
+ .compact-table tr.selected {
59
+ background: #e6f3ff;
60
+ }
61
+
62
+ /* Sidebar styles */
63
+ .sidebar-item {
64
+ font-size: 13px;
65
+ padding: 6px 12px;
66
+ display: flex;
67
+ align-items: center;
68
+ gap: 8px;
69
+ border-radius: 3px;
70
+ transition: all 0.15s;
71
+ }
72
+
73
+ .sidebar-item:hover {
74
+ background: rgba(108, 173, 223, 0.1);
75
+ color: #6CADDF;
76
+ }
77
+
78
+ .sidebar-item.active {
79
+ background: #00285D;
80
+ color: white;
81
+ }
82
+
83
+ /* Compact form controls */
84
+ .compact-input {
85
+ padding: 3px 8px;
86
+ font-size: 12px;
87
+ border: 1px solid #d1d5db;
88
+ border-radius: 3px;
89
+ }
90
+
91
+ .compact-select {
92
+ padding: 3px 24px 3px 8px;
93
+ font-size: 12px;
94
+ border: 1px solid #d1d5db;
95
+ border-radius: 3px;
96
+ background-size: 16px;
97
+ }
98
+
99
+ .compact-button {
100
+ padding: 4px 12px;
101
+ font-size: 12px;
102
+ border-radius: 3px;
103
+ font-weight: 500;
104
+ }
105
+
106
+ /* Tab styles */
107
+ .tab-bar {
108
+ background: #f3f3f3;
109
+ border-bottom: 1px solid #e8e8e8;
110
+ display: flex;
111
+ align-items: center;
112
+ height: 32px;
113
+ font-size: 12px;
114
+ }
115
+
116
+ .tab-item {
117
+ padding: 0 16px;
118
+ height: 100%;
119
+ display: flex;
120
+ align-items: center;
121
+ border-right: 1px solid #e8e8e8;
122
+ cursor: pointer;
123
+ transition: all 0.15s;
124
+ }
125
+
126
+ .tab-item:hover {
127
+ background: #e8e8e8;
128
+ }
129
+
130
+ .tab-item.active {
131
+ background: white;
132
+ color: #00285D;
133
+ font-weight: 500;
134
+ }
135
+
136
+ /* Status badges */
137
+ .badge-insert { background: #10b981; color: white; }
138
+ .badge-update { background: #6CADDF; color: white; }
139
+ .badge-delete { background: #ef4444; color: white; }
140
+ .badge-select { background: #6b7280; color: white; }
141
+
142
+ .badge {
143
+ padding: 1px 6px;
144
+ font-size: 10px;
145
+ border-radius: 2px;
146
+ font-weight: 500;
147
+ text-transform: uppercase;
148
+ }
149
+
150
+ /* Highlight colors */
151
+ .highlight-change { background: rgba(255, 199, 88, 0.3); }
152
+ .highlight-new { background: rgba(16, 185, 129, 0.2); }
153
+ .highlight-deleted { background: rgba(239, 68, 68, 0.2); }
154
+
155
+ /* Splitter */
156
+ .splitter {
157
+ width: 4px;
158
+ background: #e8e8e8;
159
+ cursor: col-resize;
160
+ }
161
+
162
+ .splitter:hover {
163
+ background: #6CADDF;
164
+ }
165
+
166
+ /* Scrollbar styling */
167
+ ::-webkit-scrollbar {
168
+ width: 8px;
169
+ height: 8px;
170
+ }
171
+
172
+ ::-webkit-scrollbar-track {
173
+ background: #f3f3f3;
174
+ }
175
+
176
+ ::-webkit-scrollbar-thumb {
177
+ background: #c8c8c8;
178
+ border-radius: 4px;
179
+ }
180
+
181
+ ::-webkit-scrollbar-thumb:hover {
182
+ background: #6CADDF;
183
+ }
184
+
185
+ /* Enhanced table styles for data readability */
186
+ .table-detailed .compact-table td {
187
+ max-width: 300px;
188
+ padding: 4px 8px;
189
+ }
190
+
191
+ .cell-content {
192
+ position: relative;
193
+ display: inline-block;
194
+ max-width: 100%;
195
+ }
196
+
197
+ .cell-compact {
198
+ max-width: 150px;
199
+ overflow: hidden;
200
+ text-overflow: ellipsis;
201
+ white-space: nowrap;
202
+ }
203
+
204
+ .cell-detailed {
205
+ max-width: 300px;
206
+ white-space: pre-wrap;
207
+ word-break: break-word;
208
+ }
209
+
210
+ .cell-value {
211
+ cursor: help;
212
+ }
213
+
214
+ /* Column type styling */
215
+ .column-meta {
216
+ background-color: rgba(156, 163, 175, 0.1);
217
+ }
218
+
219
+ .column-timestamp {
220
+ background-color: rgba(59, 130, 246, 0.1);
221
+ font-family: monospace;
222
+ }
223
+
224
+ .column-id {
225
+ background-color: rgba(245, 158, 11, 0.1);
226
+ font-family: monospace;
227
+ }
228
+
229
+ /* Tooltip improvements */
230
+ .tooltip-content {
231
+ max-height: 200px;
232
+ overflow-y: auto;
233
+ font-family: 'Consolas', 'Monaco', 'Lucida Console', monospace;
234
+ }
235
+
236
+ /* Essential mode adjustments */
237
+ .view-essential .compact-table th,
238
+ .view-essential .compact-table td {
239
+ font-size: 11px;
240
+ padding: 2px 6px;
241
+ }
242
+
243
+ /* JSON/Array content indicators */
244
+ .json-indicator {
245
+ color: #6366f1;
246
+ font-style: italic;
247
+ font-size: 10px;
248
+ }
249
+
250
+ .array-indicator {
251
+ color: #059669;
252
+ font-style: italic;
253
+ font-size: 10px;
254
+ }
255
+ </style>
256
+
257
+ <script>
258
+ tailwind.config = {
259
+ theme: {
260
+ extend: {
261
+ colors: {
262
+ 'navy-dark': '#00285D',
263
+ 'blue-light': '#96C1E7',
264
+ 'blue-medium': '#6CADDF',
265
+ 'gold-dark': '#D4A11E',
266
+ 'gold-light': '#FFC758',
267
+ }
268
+ }
269
+ }
270
+ }
271
+ </script>
272
+ </head>
273
+
274
+ <body class="bg-gray-50 h-screen overflow-hidden text-gray-800">
275
+ <div class="flex h-full" x-data="{ sidebarWidth: 200, sidebarCollapsed: false }">
276
+ <!-- Compact Sidebar -->
277
+ <aside class="bg-gray-900 text-gray-300 flex-shrink-0 transition-all duration-200"
278
+ :style="{ width: sidebarCollapsed ? '48px' : sidebarWidth + 'px' }">
279
+ <div class="flex flex-col h-full">
280
+ <!-- Logo -->
281
+ <div class="h-10 flex items-center justify-between px-3 border-b border-gray-700">
282
+ <div class="flex items-center gap-2" x-show="!sidebarCollapsed">
283
+ <svg class="w-5 h-5 text-gold-light" fill="none" stroke="currentColor" viewBox="0 0 24 24">
284
+ <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0z"/>
285
+ </svg>
286
+ <span class="text-sm font-medium text-white">DB Watcher</span>
287
+ </div>
288
+ <button @click="sidebarCollapsed = !sidebarCollapsed"
289
+ class="p-1 hover:bg-gray-800 rounded text-gray-400">
290
+ <svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
291
+ <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2"
292
+ :d="sidebarCollapsed ? 'M13 5l7 7-7 7M5 5l7 7-7 7' : 'M11 19l-7-7 7-7m8 14l-7-7 7-7'"/>
293
+ </svg>
294
+ </button>
17
295
  </div>
18
- <div class="flex items-center space-x-4">
19
- <%= link_to "Sessions", sessions_index_path, class: "text-gray-700 hover:text-gray-900" %>
20
- <%= button_to "Reset All Sessions", destroy_all_sessions_path,
21
- method: :delete,
22
- data: { confirm: "Are you sure? This will delete all tracking data." },
23
- class: "bg-red-500 text-white px-4 py-2 rounded hover:bg-red-600" %>
296
+
297
+ <!-- Navigation -->
298
+ <nav class="flex-1 py-2 overflow-y-auto">
299
+ <%= link_to root_path, class: "sidebar-item #{current_page?(root_path) ? 'active' : ''}" do %>
300
+ <svg class="w-4 h-4 flex-shrink-0" fill="none" stroke="currentColor" viewBox="0 0 24 24">
301
+ <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2"
302
+ d="M4 5a1 1 0 011-1h14a1 1 0 011 1v2a1 1 0 01-1 1H5a1 1 0 01-1-1V5zM4 13a1 1 0 011-1h6a1 1 0 011 1v6a1 1 0 01-1 1H5a1 1 0 01-1-1v-6zM16 13a1 1 0 011-1h2a1 1 0 011 1v6a1 1 0 01-1 1h-2a1 1 0 01-1-1v-6z"/>
303
+ </svg>
304
+ <span x-show="!sidebarCollapsed">Dashboard</span>
305
+ <% end %>
306
+
307
+ <%= link_to sessions_path, class: "sidebar-item #{current_page?(sessions_path) ? 'active' : ''}" do %>
308
+ <svg class="w-4 h-4 flex-shrink-0" fill="none" stroke="currentColor" viewBox="0 0 24 24">
309
+ <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2"
310
+ d="M12 8v4l3 3m6-3a9 9 0 11-18 0 9 9 0 0118 0z"/>
311
+ </svg>
312
+ <span x-show="!sidebarCollapsed">Sessions</span>
313
+ <% end %>
314
+
315
+ <%= link_to tables_path, class: "sidebar-item #{current_page?(tables_path) ? 'active' : ''}" do %>
316
+ <svg class="w-4 h-4 flex-shrink-0" fill="none" stroke="currentColor" viewBox="0 0 24 24">
317
+ <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2"
318
+ d="M3 10h18M3 14h18m-9-4v8m-7 0h14a2 2 0 002-2V8a2 2 0 00-2-2H5a2 2 0 00-2 2v8a2 2 0 002 2z"/>
319
+ </svg>
320
+ <span x-show="!sidebarCollapsed">Tables</span>
321
+ <% end %>
322
+
323
+ <%= link_to queries_path, class: "sidebar-item #{current_page?(queries_path) ? 'active' : ''}" do %>
324
+ <svg class="w-4 h-4 flex-shrink-0" fill="none" stroke="currentColor" viewBox="0 0 24 24">
325
+ <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2"
326
+ d="M8 9l3 3-3 3m5 0h3M5 20h14a2 2 0 002-2V6a2 2 0 00-2-2H5a2 2 0 00-2 2v12a2 2 0 002 2z"/>
327
+ </svg>
328
+ <span x-show="!sidebarCollapsed">SQL Logs</span>
329
+ <% end %>
330
+ </nav>
331
+
332
+ <!-- Actions -->
333
+ <div class="p-2 border-t border-gray-700">
334
+ <!-- TODO: Add more action later -->
24
335
  </div>
25
336
  </div>
337
+ </aside>
338
+
339
+ <!-- Splitter -->
340
+ <div class="splitter" x-show="!sidebarCollapsed"
341
+ @mousedown="startResize($event)"
342
+ x-data="{
343
+ startResize(e) {
344
+ const startX = e.pageX;
345
+ const startWidth = sidebarWidth;
346
+
347
+ const doDrag = (e) => {
348
+ sidebarWidth = Math.max(150, Math.min(400, startWidth + e.pageX - startX));
349
+ };
350
+
351
+ const stopDrag = () => {
352
+ document.removeEventListener('mousemove', doDrag);
353
+ document.removeEventListener('mouseup', stopDrag);
354
+ };
355
+
356
+ document.addEventListener('mousemove', doDrag);
357
+ document.addEventListener('mouseup', stopDrag);
358
+ }
359
+ }">
26
360
  </div>
27
- </nav>
28
361
 
29
- <main class="max-w-7xl mx-auto py-6 sm:px-6 lg:px-8">
30
- <%= yield %>
31
- </main>
32
- </div>
33
- </body>
362
+ <!-- Main Content -->
363
+ <main class="flex-1 overflow-hidden bg-white">
364
+ <%= yield %>
365
+ </main>
366
+ </div>
367
+
368
+ <script>
369
+ mermaid.initialize({
370
+ startOnLoad: true,
371
+ theme: 'neutral',
372
+ themeVariables: {
373
+ primaryColor: '#00285D',
374
+ primaryTextColor: '#fff',
375
+ primaryBorderColor: '#6CADDF',
376
+ lineColor: '#96C1E7',
377
+ secondaryColor: '#FFC758',
378
+ tertiaryColor: '#D4A11E'
379
+ }
380
+ });
381
+ </script>
382
+ </body>
34
383
  </html>
data/config/routes.rb CHANGED
@@ -1,10 +1,24 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  Dbwatcher::Engine.routes.draw do
4
- resources :sessions, only: %i[index show] do
4
+ root to: "dashboard#index"
5
+
6
+ resources :sessions do
7
+ collection do
8
+ delete :clear
9
+ end
10
+ end
11
+
12
+ resources :tables, only: %i[index show] do
13
+ member do
14
+ get :changes
15
+ end
16
+ end
17
+
18
+ resources :queries, only: [:index] do
5
19
  collection do
6
- delete :destroy_all
20
+ get :filter
21
+ delete :clear
7
22
  end
8
23
  end
9
- root to: "sessions#index"
10
24
  end
@@ -2,13 +2,21 @@
2
2
 
3
3
  module Dbwatcher
4
4
  class Configuration
5
- attr_accessor :storage_path, :enabled, :max_sessions, :auto_clean_after_days
5
+ attr_accessor :storage_path, :enabled, :max_sessions, :auto_clean_after_days,
6
+ :track_queries, :slow_query_threshold, :max_query_logs_per_day,
7
+ :mount_path
6
8
 
7
9
  def initialize
8
10
  @storage_path = default_storage_path
9
11
  @enabled = true
10
12
  @max_sessions = 100
11
13
  @auto_clean_after_days = 7
14
+ @mount_path = "/dbwatcher"
15
+
16
+ # SQL Query tracking
17
+ @track_queries = true
18
+ @slow_query_threshold = 100.0 # milliseconds
19
+ @max_query_logs_per_day = 10_000
12
20
  end
13
21
 
14
22
  private
@@ -5,18 +5,23 @@ module Dbwatcher
5
5
  isolate_namespace Dbwatcher
6
6
 
7
7
  initializer "dbwatcher.setup" do |app|
8
- # Auto-include in all models
9
- ActiveSupport.on_load(:active_record) do
10
- include Dbwatcher::ModelExtension
11
- end
8
+ if Dbwatcher.configuration.enabled && !Rails.env.production?
9
+ # Auto-include in all models
10
+ ActiveSupport.on_load(:active_record) do
11
+ include Dbwatcher::ModelExtension
12
+ end
13
+
14
+ # Add middleware
15
+ app.middleware.use Dbwatcher::Middleware
12
16
 
13
- # Add middleware
14
- app.middleware.use Dbwatcher::Middleware
17
+ # Setup SQL logging if enabled
18
+ Dbwatcher::SqlLogger.instance if Dbwatcher.configuration.track_queries
19
+ end
15
20
  end
16
21
 
17
22
  # Mount the engine routes automatically
18
23
  initializer "dbwatcher.routes", after: :add_routing_paths do |app|
19
- app.routes.append do
24
+ app.routes.prepend do
20
25
  mount Dbwatcher::Engine => "/dbwatcher", as: :dbwatcher
21
26
  end
22
27
  end
@@ -0,0 +1,72 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Dbwatcher
4
+ # Logging module for DBWatcher components
5
+ # Provides consistent logging interface across all services and components
6
+ module Logging
7
+ extend ActiveSupport::Concern if defined?(ActiveSupport)
8
+
9
+ # Log an informational message with optional context
10
+ # @param message [String] the log message
11
+ # @param context [Hash] additional context data
12
+ def log_info(message, context = {})
13
+ log_with_level(:info, message, context)
14
+ end
15
+
16
+ # Log a debug message with optional context
17
+ # @param message [String] the log message
18
+ # @param context [Hash] additional context data
19
+ def log_debug(message, context = {})
20
+ log_with_level(:debug, message, context)
21
+ end
22
+
23
+ # Log a warning message with optional context
24
+ # @param message [String] the log message
25
+ # @param context [Hash] additional context data
26
+ def log_warn(message, context = {})
27
+ log_with_level(:warn, message, context)
28
+ end
29
+
30
+ # Log an error message with optional context
31
+ # @param message [String] the log message
32
+ # @param context [Hash] additional context data
33
+ def log_error(message, context = {})
34
+ log_with_level(:error, message, context)
35
+ end
36
+
37
+ private
38
+
39
+ def log_with_level(level, message, context)
40
+ logger = rails_logger || fallback_logger
41
+ formatted_message = format_log_message(message, context)
42
+ logger.public_send(level, formatted_message)
43
+ end
44
+
45
+ def format_log_message(message, context)
46
+ base_message = "[DBWatcher:#{component_name}] #{message}"
47
+ return base_message if context.empty?
48
+
49
+ context_string = context.map { |k, v| "#{k}=#{v}" }.join(" ")
50
+ "#{base_message} | #{context_string}"
51
+ end
52
+
53
+ def component_name
54
+ self.class.name.split("::").last
55
+ end
56
+
57
+ def rails_logger
58
+ return nil unless defined?(Rails)
59
+
60
+ Rails.logger
61
+ end
62
+
63
+ def fallback_logger
64
+ @fallback_logger ||= Logger.new($stdout).tap do |logger|
65
+ logger.level = Logger::INFO
66
+ logger.formatter = proc do |severity, datetime, _progname, msg|
67
+ "#{datetime.strftime("%Y-%m-%d %H:%M:%S")} [#{severity}] #{msg}\n"
68
+ end
69
+ end
70
+ end
71
+ end
72
+ end
@@ -0,0 +1,121 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Dbwatcher
4
+ module Services
5
+ # Service object for aggregating dashboard statistics
6
+ # Provides recent sessions, active tables, and query metrics
7
+ class DashboardDataAggregator
8
+ include Dbwatcher::Logging
9
+
10
+ # @return [Hash] dashboard statistics with recent_sessions, active_tables, query_stats
11
+ def self.call
12
+ new.call
13
+ end
14
+
15
+ def call
16
+ log_info "Starting dashboard data aggregation"
17
+ start_time = Time.current
18
+
19
+ result = {
20
+ recent_sessions: fetch_recent_sessions,
21
+ active_tables: calculate_active_tables,
22
+ query_stats: aggregate_query_statistics
23
+ }
24
+
25
+ duration = Time.current - start_time
26
+ log_info "Completed dashboard aggregation in #{duration.round(3)}s", {
27
+ recent_sessions_count: result[:recent_sessions].length,
28
+ active_tables_count: result[:active_tables].length,
29
+ total_queries: result[:query_stats][:total]
30
+ }
31
+
32
+ result
33
+ end
34
+
35
+ private
36
+
37
+ # @return [Array<Hash>] most recent 5 sessions
38
+ def fetch_recent_sessions
39
+ Storage.sessions.all.first(5)
40
+ end
41
+
42
+ # @return [Array<Array>] top 10 most active tables with change counts
43
+ def calculate_active_tables
44
+ table_activity_counts = Hash.new(0)
45
+
46
+ Storage.sessions.all.first(10).each do |session_info|
47
+ session = Storage.sessions.find(session_info[:id])
48
+ next unless session
49
+
50
+ session.changes.each do |change|
51
+ table_name = change[:table_name]
52
+ table_activity_counts[table_name] += 1 if table_name
53
+ end
54
+ end
55
+
56
+ table_activity_counts
57
+ .sort_by { |_table, count| -count }
58
+ .first(10)
59
+ end
60
+
61
+ # @return [Hash] query statistics including totals and breakdowns
62
+ def aggregate_query_statistics
63
+ date = Date.current.strftime("%Y-%m-%d")
64
+ log_debug "Aggregating query statistics for date: #{date}"
65
+
66
+ queries = fetch_queries_for_date(date)
67
+ build_query_statistics(queries)
68
+ rescue StandardError => e
69
+ log_error "Failed to aggregate query statistics: #{e.message}"
70
+ default_query_statistics
71
+ end
72
+
73
+ def fetch_queries_for_date(date)
74
+ Storage.queries.for_date(date).all
75
+ end
76
+
77
+ def build_query_statistics(queries)
78
+ slow_queries_count = count_slow_queries(queries)
79
+ operations_breakdown = group_queries_by_operation(queries)
80
+
81
+ log_query_statistics_summary(queries, slow_queries_count, operations_breakdown)
82
+
83
+ {
84
+ total: queries.count,
85
+ slow_queries: slow_queries_count,
86
+ by_operation: operations_breakdown
87
+ }
88
+ end
89
+
90
+ def log_query_statistics_summary(queries, slow_queries_count, operations_breakdown)
91
+ log_debug "Query stats aggregated", {
92
+ total_queries: queries.count,
93
+ slow_queries: slow_queries_count,
94
+ operations: operations_breakdown.keys.join(", ")
95
+ }
96
+ end
97
+
98
+ def count_slow_queries(queries)
99
+ queries.count { |query| query_is_slow?(query) }
100
+ end
101
+
102
+ def query_is_slow?(query)
103
+ query["duration"] && query["duration"] > 100
104
+ end
105
+
106
+ def group_queries_by_operation(queries)
107
+ queries
108
+ .group_by { |query| query[:operation] || "UNKNOWN" }
109
+ .transform_values(&:count)
110
+ end
111
+
112
+ def default_query_statistics
113
+ {
114
+ total: 0,
115
+ slow_queries: 0,
116
+ by_operation: {}
117
+ }
118
+ end
119
+ end
120
+ end
121
+ end