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
@@ -0,0 +1,240 @@
1
+ <div class="h-full flex flex-col" x-data="queryLogs()">
2
+ <!-- Header with embedded stats -->
3
+ <div class="h-10 bg-navy-dark text-white flex items-center px-4">
4
+ <svg class="w-4 h-4 mr-2" fill="currentColor" viewBox="0 0 20 20">
5
+ <path fill-rule="evenodd" d="M2 5a2 2 0 012-2h12a2 2 0 012 2v10a2 2 0 01-2 2H4a2 2 0 01-2-2V5zm3.293 1.293a1 1 0 011.414 0l3 3a1 1 0 010 1.414l-3 3a1 1 0 01-1.414-1.414L7.586 10 5.293 7.707a1 1 0 010-1.414zM11 12a1 1 0 100 2h3a1 1 0 100-2h-3z" clip-rule="evenodd"/>
6
+ </svg>
7
+ <h1 class="text-sm font-medium">SQL Query Logs</h1>
8
+
9
+ <!-- Quick Stats -->
10
+ <div class="ml-auto flex items-center gap-4 text-xs">
11
+ <span x-text="`${filteredQueries.length} queries`"></span>
12
+ <span class="text-gold-light" x-text="`${slowQueries.length} slow`"></span>
13
+ </div>
14
+ </div>
15
+
16
+ <%= render 'dbwatcher/shared/tab_bar', tabs: [
17
+ { name: 'All Queries', active: true },
18
+ { name: 'Slow Queries', active: false },
19
+ { name: 'Recent', active: false }
20
+ ] %>
21
+
22
+ <!-- Compact Toolbar -->
23
+ <div class="bg-gray-100 border-b border-gray-300 px-3 py-1 flex items-center gap-3">
24
+ <!-- Filter Controls -->
25
+ <select x-model="filters.operation"
26
+ @change="applyFilters()"
27
+ class="compact-select">
28
+ <option value="">All Operations</option>
29
+ <option value="SELECT">SELECT</option>
30
+ <option value="INSERT">INSERT</option>
31
+ <option value="UPDATE">UPDATE</option>
32
+ <option value="DELETE">DELETE</option>
33
+ </select>
34
+
35
+ <input type="text"
36
+ x-model="filters.table"
37
+ @input="applyFilters()"
38
+ placeholder="Filter by table..."
39
+ class="compact-input flex-1 max-w-xs">
40
+
41
+ <input type="number"
42
+ x-model="filters.minDuration"
43
+ @input="applyFilters()"
44
+ placeholder="Min ms"
45
+ class="compact-input w-20">
46
+
47
+ <input type="date"
48
+ value="<%= @date %>"
49
+ @change="changeDate($event.target.value)"
50
+ class="compact-input">
51
+
52
+ <!-- Time Range Filters -->
53
+ <input type="time"
54
+ x-model="filters.startTime"
55
+ @input="applyFilters()"
56
+ placeholder="Start time"
57
+ title="Start time"
58
+ class="compact-input w-24">
59
+
60
+ <input type="time"
61
+ x-model="filters.endTime"
62
+ @input="applyFilters()"
63
+ placeholder="End time"
64
+ title="End time"
65
+ class="compact-input w-24">
66
+
67
+ <button @click="clearFilters()"
68
+ title="Clear all filters"
69
+ class="compact-button bg-gray-500 text-white hover:bg-gray-600">
70
+ Clear
71
+ </button>
72
+
73
+ <div class="ml-auto flex items-center gap-2">
74
+ <button @click="exportQueries()"
75
+ class="compact-button bg-white border border-gray-300 hover:bg-gray-50">
76
+ <svg class="w-3 h-3 inline mr-1" fill="currentColor" viewBox="0 0 20 20">
77
+ <path fill-rule="evenodd" d="M3 17a1 1 0 011-1h12a1 1 0 110 2H4a1 1 0 01-1-1zm3.293-7.707a1 1 0 011.414 0L9 10.586V3a1 1 0 112 0v7.586l1.293-1.293a1 1 0 111.414 1.414l-3 3a1 1 0 01-1.414 0l-3-3a1 1 0 010-1.414z" clip-rule="evenodd"/>
78
+ </svg>
79
+ Export
80
+ </button>
81
+
82
+ <button @click="refreshData()"
83
+ class="compact-button bg-blue-medium text-white hover:bg-blue-700">
84
+ <svg class="w-3 h-3 inline mr-1" fill="currentColor" viewBox="0 0 20 20">
85
+ <path fill-rule="evenodd" d="M4 2a1 1 0 011 1v2.101a7.002 7.002 0 0111.601 2.566 1 1 0 11-1.885.666A5.002 5.002 0 005.999 7H9a1 1 0 010 2H4a1 1 0 01-1-1V3a1 1 0 011-1zm.008 9.057a1 1 0 011.276.61A5.002 5.002 0 0014.001 13H11a1 1 0 110-2h5a1 1 0 011 1v5a1 1 0 11-2 0v-2.101a7.002 7.002 0 01-11.601-2.566 1 1 0 01.61-1.276z" clip-rule="evenodd"/>
86
+ </svg>
87
+ Refresh
88
+ </button>
89
+
90
+ <%= button_to clear_queries_path,
91
+ method: :delete,
92
+ class: "compact-button bg-red-600 text-white hover:bg-red-700",
93
+ data: {
94
+ confirm: "Are you sure you want to clear all SQL query logs? This action cannot be undone."
95
+ } do %>
96
+ <svg class="w-3 h-3 inline mr-1" fill="currentColor" viewBox="0 0 20 20">
97
+ <path fill-rule="evenodd" d="M9 2a1 1 0 000 2h2a1 1 0 100-2H9z"/>
98
+ <path fill-rule="evenodd" d="M10 18a8 8 0 100-16 8 8 0 000 16zM8 7a1 1 0 012 0v4a1 1 0 11-2 0V7zm5-1a1 1 0 00-1 1v4a1 1 0 102 0V7a1 1 0 00-1-1z"/>
99
+ </svg>
100
+ Clear Logs
101
+ <% end %>
102
+ </div>
103
+ </div>
104
+
105
+ <!-- Content Area -->
106
+ <div class="flex-1 overflow-auto">
107
+ <div class="h-full">
108
+ <table class="compact-table w-full">
109
+ <thead>
110
+ <tr>
111
+ <th class="text-left w-20">Time</th>
112
+ <th class="text-center w-16">Op</th>
113
+ <th class="text-left w-32">Tables</th>
114
+ <th class="text-right w-16">Duration</th>
115
+ <th class="text-left">SQL</th>
116
+ </tr>
117
+ </thead>
118
+ <tbody>
119
+ <template x-for="query in filteredQueries" :key="query.id">
120
+ <tr class="cursor-pointer hover:bg-blue-50"
121
+ @click="selectQuery(query)"
122
+ :class="{ 'selected': selectedQuery?.id === query.id }">
123
+ <td class="text-xs text-gray-600" x-text="formatTime(query.timestamp)"></td>
124
+ <td class="text-center">
125
+ <span class="badge"
126
+ :class="`badge-${query.operation.toLowerCase()}`"
127
+ x-text="query.operation.charAt(0)"></span>
128
+ </td>
129
+ <td class="font-medium text-navy-dark truncate"
130
+ :title="query.tables.join(', ')"
131
+ x-text="query.tables.join(', ')"></td>
132
+ <td class="text-right text-xs"
133
+ :class="query.duration > 100 ? 'text-red-600 font-medium' : 'text-gray-600'"
134
+ x-text="`${query.duration}ms`"></td>
135
+ <td class="font-mono text-xs truncate max-w-md"
136
+ :title="query.sql"
137
+ x-text="query.sql"></td>
138
+ </tr>
139
+ </template>
140
+ </tbody>
141
+ </table>
142
+ </div>
143
+ </div>
144
+ </div>
145
+ <script>
146
+ function queryLogs() {
147
+ return {
148
+ filters: {
149
+ operation: '',
150
+ table: '',
151
+ minDuration: '',
152
+ startTime: '',
153
+ endTime: ''
154
+ },
155
+ selectedQuery: null,
156
+ queries: <%= @queries.to_json.html_safe %>,
157
+
158
+ get filteredQueries() {
159
+ return this.queries.filter(query => {
160
+ if (this.filters.operation && query.operation !== this.filters.operation) return false;
161
+ if (this.filters.table && !query.tables.some(t => t.toLowerCase().includes(this.filters.table.toLowerCase()))) return false;
162
+ if (this.filters.minDuration && query.duration < parseFloat(this.filters.minDuration)) return false;
163
+
164
+ // Time filtering
165
+ if (this.filters.startTime || this.filters.endTime) {
166
+ const queryTime = new Date(query.timestamp);
167
+ const queryTimeStr = queryTime.toTimeString().substr(0, 5); // HH:MM format
168
+
169
+ if (this.filters.startTime && queryTimeStr < this.filters.startTime) return false;
170
+ if (this.filters.endTime && queryTimeStr > this.filters.endTime) return false;
171
+ }
172
+
173
+ return true;
174
+ });
175
+ },
176
+
177
+ get slowQueries() {
178
+ return this.queries.filter(q => q.duration > 100);
179
+ },
180
+
181
+ selectQuery(query) {
182
+ this.selectedQuery = this.selectedQuery?.id === query.id ? null : query;
183
+ },
184
+
185
+ formatTime(timestamp) {
186
+ return new Date(timestamp).toLocaleTimeString('en-US', {
187
+ hour12: false,
188
+ hour: '2-digit',
189
+ minute: '2-digit',
190
+ second: '2-digit'
191
+ });
192
+ },
193
+
194
+ applyFilters() {
195
+ // Filters are applied via computed property
196
+ },
197
+
198
+ exportQueries() {
199
+ const data = this.filteredQueries.map(q => ({
200
+ time: this.formatTime(q.timestamp),
201
+ operation: q.operation,
202
+ tables: q.tables.join(', '),
203
+ duration: q.duration,
204
+ sql: q.sql
205
+ }));
206
+
207
+ const csv = [
208
+ ['Time', 'Operation', 'Tables', 'Duration (ms)', 'SQL'],
209
+ ...data.map(row => [row.time, row.operation, row.tables, row.duration, row.sql])
210
+ ].map(row => row.map(cell => `"${cell}"`).join(',')).join('\n');
211
+
212
+ const blob = new Blob([csv], { type: 'text/csv' });
213
+ const url = URL.createObjectURL(blob);
214
+ const a = document.createElement('a');
215
+ a.href = url;
216
+ a.download = `queries-${new Date().toISOString().split('T')[0]}.csv`;
217
+ a.click();
218
+ URL.revokeObjectURL(url);
219
+ },
220
+
221
+ refreshData() {
222
+ window.location.reload();
223
+ },
224
+
225
+ changeDate(date) {
226
+ const params = new URLSearchParams(window.location.search);
227
+ params.set('date', date);
228
+ window.location.search = params.toString();
229
+ },
230
+
231
+ clearFilters() {
232
+ this.filters.operation = '';
233
+ this.filters.table = '';
234
+ this.filters.minDuration = '';
235
+ this.filters.startTime = '';
236
+ this.filters.endTime = '';
237
+ }
238
+ }
239
+ }
240
+ </script>
@@ -1,37 +1,130 @@
1
- <div class="px-4 sm:px-0">
2
- <h2 class="text-2xl font-bold mb-6">Tracking Sessions</h2>
1
+ <%# Sessions Index Page %>
2
+ <div class="h-full flex flex-col">
3
+ <%= render 'dbwatcher/shared/header', title: 'Tracking Sessions', subtitle: "#{@sessions.count} sessions" %>
4
+
5
+ <%= render 'dbwatcher/shared/tab_bar', tabs: [
6
+ { name: 'All Sessions', active: true },
7
+ { name: 'Active', active: false },
8
+ { name: 'Recent', active: false }
9
+ ] %>
10
+
11
+ <!-- Toolbar -->
12
+ <div class="h-8 bg-gray-100 border-b border-gray-300 flex items-center px-4 gap-2">
13
+ <input type="text" placeholder="Filter sessions..."
14
+ class="compact-input flex-1 max-w-xs">
15
+ <select class="compact-select">
16
+ <option>Last 24 hours</option>
17
+ <option>Last week</option>
18
+ <option>All time</option>
19
+ </select>
20
+
21
+ <div class="ml-auto flex items-center gap-2">
22
+ <button class="compact-button bg-blue-medium text-white hover:bg-blue-700">
23
+ <svg class="w-3 h-3 inline mr-1" fill="currentColor" viewBox="0 0 20 20">
24
+ <path fill-rule="evenodd" d="M4 2a1 1 0 011 1v2.101a7.002 7.002 0 0111.601 2.566 1 1 0 11-1.885.666A5.002 5.002 0 005.999 7H9a1 1 0 010 2H4a1 1 0 01-1-1V3a1 1 0 011-1zm.008 9.057a1 1 0 011.276.61A5.002 5.002 0 0014.001 13H11a1 1 0 110-2h5a1 1 0 011 1v5a1 1 0 11-2 0v-2.101a7.002 7.002 0 01-11.601-2.566 1 1 0 01.61-1.276z" clip-rule="evenodd"/>
25
+ </svg>
26
+ Refresh
27
+ </button>
28
+
29
+ <%= button_to clear_sessions_path,
30
+ method: :delete,
31
+ class: "compact-button bg-red-600 text-white hover:bg-red-700",
32
+ data: {
33
+ confirm: "Are you sure you want to clear all sessions? This action cannot be undone."
34
+ } do %>
35
+ <svg class="w-3 h-3 inline mr-1" fill="currentColor" viewBox="0 0 20 20">
36
+ <path fill-rule="evenodd" d="M9 2a1 1 0 000 2h2a1 1 0 100-2H9z"/>
37
+ <path fill-rule="evenodd" d="M10 18a8 8 0 100-16 8 8 0 000 16zM8 7a1 1 0 012 0v4a1 1 0 11-2 0V7zm5-1a1 1 0 00-1 1v4a1 1 0 102 0V7a1 1 0 00-1-1z"/>
38
+ </svg>
39
+ Clear Sessions
40
+ <% end %>
41
+ </div>
42
+ </div>
43
+
44
+ <!-- Content Area -->
45
+ <div class="flex-1 overflow-auto p-4">
3
46
 
4
47
  <% if @sessions.empty? %>
5
- <div class="bg-white overflow-hidden shadow rounded-lg p-6 text-center text-gray-500">
6
- No tracking sessions yet. Start tracking with <code class="bg-gray-100 px-2 py-1 rounded">Dbwatcher.track { ... }</code>
48
+ <div class="flex items-center justify-center h-full text-gray-500">
49
+ <div class="text-center">
50
+ <svg class="mx-auto h-8 w-8 text-gray-400 mb-2" fill="none" stroke="currentColor" viewBox="0 0 24 24">
51
+ <path stroke-linecap="round" stroke-linejoin="round" stroke-width="1"
52
+ d="M9 5H7a2 2 0 00-2 2v12a2 2 0 002 2h10a2 2 0 002-2V7a2 2 0 00-2-2h-2M9 5a2 2 0 002 2h2a2 2 0 002-2M9 5a2 2 0 012-2h2a2 2 0 012 2"/>
53
+ </svg>
54
+ <p class="text-xs">No tracking sessions yet</p>
55
+ <p class="text-xs text-gray-400">Start tracking with <code class="bg-gray-200 px-1 rounded">Dbwatcher.track { ... }</code></p>
56
+ </div>
7
57
  </div>
8
58
  <% else %>
9
- <div class="bg-white shadow overflow-hidden sm:rounded-md">
10
- <ul class="divide-y divide-gray-200">
59
+ <table class="compact-table w-full">
60
+ <thead>
61
+ <tr>
62
+ <th class="text-left">Session ID</th>
63
+ <th class="text-left">Name</th>
64
+ <th class="text-center">Status</th>
65
+ <th class="text-center">Changes</th>
66
+ <th class="text-right">Started</th>
67
+ <th class="text-right">Duration</th>
68
+ <th class="text-center">Actions</th>
69
+ </tr>
70
+ </thead>
71
+ <tbody>
11
72
  <% @sessions.each do |session| %>
12
- <li>
13
- <%= link_to session_path(session[:id]), class: "block hover:bg-gray-50 px-4 py-4 sm:px-6" do %>
14
- <div class="flex items-center justify-between">
15
- <div class="flex-1">
16
- <p class="text-sm font-medium text-indigo-600 truncate">
17
- <%= session[:name] %>
18
- </p>
19
- <p class="mt-1 text-sm text-gray-600">
20
- <%= Time.parse(session[:started_at]).strftime("%Y-%m-%d %H:%M:%S") %>
21
- <span class="text-gray-400">•</span>
22
- <%= session[:change_count] %> changes
23
- </p>
24
- </div>
25
- <div class="ml-2 flex-shrink-0">
26
- <svg class="h-5 w-5 text-gray-400" fill="currentColor" viewBox="0 0 20 20">
27
- <path fill-rule="evenodd" d="M7.293 14.707a1 1 0 010-1.414L10.586 10 7.293 6.707a1 1 0 011.414-1.414l4 4a1 1 0 010 1.414l-4 4a1 1 0 01-1.414 0z" clip-rule="evenodd" />
73
+ <tr class="hover:bg-blue-50">
74
+ <td class="font-mono text-xs">
75
+ <%= display_session_id(safe_value(session, :id)) %>
76
+ </td>
77
+ <td class="max-w-xs truncate" title="<%= safe_value(session, :name) %>">
78
+ <%= link_to display_session_name(safe_value(session, :name)),
79
+ session_path(safe_value(session, :id)),
80
+ class: "text-navy-dark hover:text-blue-medium" %>
81
+ </td>
82
+ <td class="text-center">
83
+ <%= render 'dbwatcher/shared/badge',
84
+ content: (session_active?(session) ? 'Active' : 'Completed'),
85
+ badge_class: (session_active?(session) ? 'badge-success' : 'badge-primary') %>
86
+ </td>
87
+ <td class="text-center">
88
+ <%= render 'dbwatcher/shared/badge',
89
+ content: session_change_count(session),
90
+ badge_class: 'bg-gray-600 text-white' %>
91
+ </td>
92
+ <td class="text-right text-xs">
93
+ <%= format_timestamp(safe_value(session, :started_at)) %>
94
+ </td>
95
+ <td class="text-right text-xs">
96
+ <% if session_active?(session) %>
97
+ <span class="text-blue-600">Active</span>
98
+ <% else %>
99
+ <%= distance_of_time_in_words(
100
+ Time.parse(safe_value(session, :started_at)),
101
+ Time.parse(safe_value(session, :ended_at))
102
+ ) rescue 'N/A' %>
103
+ <% end %>
104
+ </td>
105
+ <td class="text-center">
106
+ <div class="flex gap-1 justify-end">
107
+ <%= link_to session_path(safe_value(session, :id)),
108
+ class: "compact-button bg-navy-dark text-white hover:bg-blue-medium" do %>
109
+ <svg class="w-3 h-3" fill="none" stroke="currentColor" viewBox="0 0 24 24">
110
+ <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2"
111
+ d="M15 12a3 3 0 11-6 0 3 3 0 016 0z"/>
112
+ <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2"
113
+ d="M2.458 12C3.732 7.943 7.523 5 12 5c4.478 0 8.268 2.943 9.542 7-1.274 4.057-5.064 7-9.542 7-4.477 0-8.268-2.943-9.542-7z"/>
28
114
  </svg>
29
- </div>
115
+ <% end %>
30
116
  </div>
31
- <% end %>
32
- </li>
117
+ </td>
118
+ </tr>
33
119
  <% end %>
34
- </ul>
35
- </div>
120
+ </tbody>
121
+ </table>
36
122
  <% end %>
123
+
124
+ <!-- Status Bar -->
125
+ <div class="h-6 bg-gray-100 border-t border-gray-300 flex items-center px-4 text-xs text-gray-600">
126
+ <%= @sessions.count %> sessions total •
127
+ <%= @sessions.count { |s| session_active?(s) } %> active •
128
+ Last updated: <%= Time.current.strftime("%H:%M:%S") %>
129
+ </div>
37
130
  </div>