pg_reports 0.1.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 (57) hide show
  1. checksums.yaml +7 -0
  2. data/CHANGELOG.md +22 -0
  3. data/LICENSE.txt +21 -0
  4. data/README.md +335 -0
  5. data/app/controllers/pg_reports/dashboard_controller.rb +133 -0
  6. data/app/views/layouts/pg_reports/application.html.erb +594 -0
  7. data/app/views/pg_reports/dashboard/index.html.erb +435 -0
  8. data/app/views/pg_reports/dashboard/show.html.erb +481 -0
  9. data/config/routes.rb +13 -0
  10. data/lib/pg_reports/annotation_parser.rb +114 -0
  11. data/lib/pg_reports/configuration.rb +83 -0
  12. data/lib/pg_reports/dashboard/reports_registry.rb +89 -0
  13. data/lib/pg_reports/engine.rb +22 -0
  14. data/lib/pg_reports/error.rb +15 -0
  15. data/lib/pg_reports/executor.rb +51 -0
  16. data/lib/pg_reports/modules/connections.rb +106 -0
  17. data/lib/pg_reports/modules/indexes.rb +111 -0
  18. data/lib/pg_reports/modules/queries.rb +140 -0
  19. data/lib/pg_reports/modules/system.rb +148 -0
  20. data/lib/pg_reports/modules/tables.rb +113 -0
  21. data/lib/pg_reports/report.rb +228 -0
  22. data/lib/pg_reports/sql/connections/active_connections.sql +20 -0
  23. data/lib/pg_reports/sql/connections/blocking_queries.sql +35 -0
  24. data/lib/pg_reports/sql/connections/connection_stats.sql +13 -0
  25. data/lib/pg_reports/sql/connections/idle_connections.sql +19 -0
  26. data/lib/pg_reports/sql/connections/locks.sql +20 -0
  27. data/lib/pg_reports/sql/connections/long_running_queries.sql +21 -0
  28. data/lib/pg_reports/sql/indexes/bloated_indexes.sql +36 -0
  29. data/lib/pg_reports/sql/indexes/duplicate_indexes.sql +38 -0
  30. data/lib/pg_reports/sql/indexes/index_sizes.sql +14 -0
  31. data/lib/pg_reports/sql/indexes/index_usage.sql +19 -0
  32. data/lib/pg_reports/sql/indexes/invalid_indexes.sql +15 -0
  33. data/lib/pg_reports/sql/indexes/missing_indexes.sql +27 -0
  34. data/lib/pg_reports/sql/indexes/unused_indexes.sql +18 -0
  35. data/lib/pg_reports/sql/queries/all_queries.sql +20 -0
  36. data/lib/pg_reports/sql/queries/expensive_queries.sql +22 -0
  37. data/lib/pg_reports/sql/queries/heavy_queries.sql +17 -0
  38. data/lib/pg_reports/sql/queries/low_cache_hit_queries.sql +19 -0
  39. data/lib/pg_reports/sql/queries/missing_index_queries.sql +25 -0
  40. data/lib/pg_reports/sql/queries/slow_queries.sql +17 -0
  41. data/lib/pg_reports/sql/system/activity_overview.sql +29 -0
  42. data/lib/pg_reports/sql/system/cache_stats.sql +19 -0
  43. data/lib/pg_reports/sql/system/database_sizes.sql +10 -0
  44. data/lib/pg_reports/sql/system/extensions.sql +12 -0
  45. data/lib/pg_reports/sql/system/settings.sql +33 -0
  46. data/lib/pg_reports/sql/tables/bloated_tables.sql +23 -0
  47. data/lib/pg_reports/sql/tables/cache_hit_ratios.sql +24 -0
  48. data/lib/pg_reports/sql/tables/recently_modified.sql +20 -0
  49. data/lib/pg_reports/sql/tables/row_counts.sql +18 -0
  50. data/lib/pg_reports/sql/tables/seq_scans.sql +26 -0
  51. data/lib/pg_reports/sql/tables/table_sizes.sql +16 -0
  52. data/lib/pg_reports/sql/tables/vacuum_needed.sql +22 -0
  53. data/lib/pg_reports/sql_loader.rb +35 -0
  54. data/lib/pg_reports/telegram_sender.rb +83 -0
  55. data/lib/pg_reports/version.rb +5 -0
  56. data/lib/pg_reports.rb +114 -0
  57. metadata +184 -0
@@ -0,0 +1,481 @@
1
+ <%= csrf_meta_tags %>
2
+
3
+ <div class="report-page">
4
+ <nav class="breadcrumb">
5
+ <%= link_to "Dashboard", root_path %>
6
+ <span>/</span>
7
+ <span><%= @categories[@category][:name] %></span>
8
+ <span>/</span>
9
+ <span><%= @report_info[:name] %></span>
10
+ </nav>
11
+
12
+ <div class="report-header">
13
+ <div class="report-info">
14
+ <h2><%= @report_info[:name] %></h2>
15
+ <p><%= @report_info[:description] %></p>
16
+ </div>
17
+
18
+ <div class="report-actions">
19
+ <button class="btn btn-primary" onclick="runReport('<%= @category %>', '<%= @report_key %>', this)">
20
+ ▶ Run Report
21
+ </button>
22
+
23
+ <!-- Download dropdown -->
24
+ <div class="dropdown" id="download-dropdown" style="display: none;">
25
+ <button class="btn btn-secondary" onclick="toggleDropdown()">
26
+ ⬇ Download
27
+ </button>
28
+ <div class="dropdown-menu" id="dropdown-menu">
29
+ <a href="#" onclick="downloadReport('txt'); return false;">📄 Text (.txt)</a>
30
+ <a href="#" onclick="downloadReport('csv'); return false;">📊 CSV (.csv)</a>
31
+ <a href="#" onclick="downloadReport('json'); return false;">📋 JSON (.json)</a>
32
+ </div>
33
+ </div>
34
+
35
+ <% if PgReports.config.telegram_configured? %>
36
+ <button class="btn btn-telegram" onclick="sendToTelegram('<%= @category %>', '<%= @report_key %>', this)" id="telegram-btn" style="display: none;">
37
+ 📨 Telegram
38
+ </button>
39
+ <% end %>
40
+ <%= link_to "← Back", root_path, class: "btn btn-secondary" %>
41
+ </div>
42
+ </div>
43
+
44
+ <% if @error %>
45
+ <div class="error-message">
46
+ <strong>Error:</strong> <%= @error %>
47
+ </div>
48
+ <% end %>
49
+
50
+ <div class="results-container" id="results-container">
51
+ <div class="results-header">
52
+ <span class="results-title">Results</span>
53
+ <div class="results-meta" id="results-meta">
54
+ <span>Click "Run Report" to fetch data</span>
55
+ </div>
56
+ </div>
57
+
58
+ <div id="loading" class="loading" style="display: none;">
59
+ <div class="spinner"></div>
60
+ </div>
61
+
62
+ <div id="empty-state" class="empty-state" style="display: none;">
63
+ <div class="empty-state-icon">✓</div>
64
+ <p>No issues found. Everything looks good!</p>
65
+ </div>
66
+
67
+ <div class="results-table-wrapper">
68
+ <table class="results-table" id="results-table">
69
+ <thead id="results-head"></thead>
70
+ <tbody id="results-body"></tbody>
71
+ </table>
72
+ </div>
73
+ </div>
74
+ </div>
75
+
76
+ <style>
77
+ .dropdown {
78
+ position: relative;
79
+ display: inline-block;
80
+ }
81
+
82
+ .dropdown-menu {
83
+ display: none;
84
+ position: absolute;
85
+ top: 100%;
86
+ left: 0;
87
+ margin-top: 4px;
88
+ background: var(--bg-card);
89
+ border: 1px solid var(--border-color);
90
+ border-radius: 10px;
91
+ min-width: 160px;
92
+ box-shadow: 0 10px 40px rgba(0, 0, 0, 0.3);
93
+ z-index: 100;
94
+ overflow: hidden;
95
+ }
96
+
97
+ .dropdown-menu.show {
98
+ display: block;
99
+ }
100
+
101
+ .dropdown-menu a {
102
+ display: block;
103
+ padding: 0.75rem 1rem;
104
+ color: var(--text-secondary);
105
+ text-decoration: none;
106
+ font-size: 0.875rem;
107
+ transition: all 0.15s;
108
+ }
109
+
110
+ .dropdown-menu a:hover {
111
+ background: var(--bg-tertiary);
112
+ color: var(--text-primary);
113
+ }
114
+
115
+ .dropdown-menu a:not(:last-child) {
116
+ border-bottom: 1px solid var(--border-color);
117
+ }
118
+
119
+ /* Clickable rows */
120
+ .results-table tbody tr.data-row {
121
+ cursor: pointer;
122
+ transition: all 0.15s;
123
+ }
124
+
125
+ .results-table tbody tr.data-row:hover {
126
+ background: var(--bg-secondary);
127
+ }
128
+
129
+ .results-table tbody tr.data-row.expanded {
130
+ background: var(--bg-tertiary);
131
+ }
132
+
133
+ .results-table tbody tr.data-row td:first-child::before {
134
+ content: '▸';
135
+ display: inline-block;
136
+ margin-right: 0.5rem;
137
+ color: var(--text-muted);
138
+ transition: transform 0.2s;
139
+ }
140
+
141
+ .results-table tbody tr.data-row.expanded td:first-child::before {
142
+ content: '▾';
143
+ color: var(--accent-purple);
144
+ }
145
+
146
+ /* Expanded row detail */
147
+ .results-table tbody tr.detail-row {
148
+ display: none;
149
+ }
150
+
151
+ .results-table tbody tr.detail-row.show {
152
+ display: table-row;
153
+ }
154
+
155
+ .results-table tbody tr.detail-row td {
156
+ padding: 0;
157
+ background: var(--bg-primary);
158
+ border-bottom: 2px solid var(--accent-purple);
159
+ }
160
+
161
+ .row-detail {
162
+ padding: 1.25rem;
163
+ display: grid;
164
+ grid-template-columns: repeat(auto-fit, minmax(300px, 1fr));
165
+ gap: 1rem;
166
+ }
167
+
168
+ .row-detail-item {
169
+ display: flex;
170
+ flex-direction: column;
171
+ gap: 0.375rem;
172
+ }
173
+
174
+ .row-detail-item.full-width {
175
+ grid-column: 1 / -1;
176
+ }
177
+
178
+ .row-detail-label {
179
+ font-size: 0.7rem;
180
+ font-weight: 600;
181
+ color: var(--text-muted);
182
+ text-transform: uppercase;
183
+ letter-spacing: 0.05em;
184
+ }
185
+
186
+ .row-detail-value {
187
+ padding: 0.75rem 1rem;
188
+ background: var(--bg-card);
189
+ border: 1px solid var(--border-color);
190
+ border-radius: 8px;
191
+ font-family: 'JetBrains Mono', monospace;
192
+ font-size: 0.8rem;
193
+ color: var(--text-primary);
194
+ white-space: pre-wrap;
195
+ word-break: break-word;
196
+ max-height: 200px;
197
+ overflow-y: auto;
198
+ }
199
+
200
+ .row-detail-value.query {
201
+ color: var(--accent-green);
202
+ max-height: 300px;
203
+ }
204
+
205
+ .row-detail-value.number {
206
+ color: var(--accent-blue);
207
+ }
208
+
209
+ .row-detail-value.source {
210
+ color: var(--accent-amber);
211
+ font-size: 0.75rem;
212
+ }
213
+
214
+ /* Source location badge in table */
215
+ .source-badge {
216
+ display: inline-block;
217
+ padding: 0.25rem 0.5rem;
218
+ background: rgba(245, 158, 11, 0.15);
219
+ border: 1px solid rgba(245, 158, 11, 0.3);
220
+ border-radius: 6px;
221
+ font-family: 'JetBrains Mono', monospace;
222
+ font-size: 0.7rem;
223
+ color: var(--accent-amber);
224
+ white-space: nowrap;
225
+ max-width: 200px;
226
+ overflow: hidden;
227
+ text-overflow: ellipsis;
228
+ }
229
+
230
+ .source-badge.empty {
231
+ background: var(--bg-tertiary);
232
+ border-color: var(--border-color);
233
+ color: var(--text-muted);
234
+ font-style: italic;
235
+ }
236
+
237
+ /* Copy button */
238
+ .copy-btn {
239
+ align-self: flex-start;
240
+ margin-top: 0.375rem;
241
+ padding: 0.25rem 0.625rem;
242
+ background: var(--bg-tertiary);
243
+ border: 1px solid var(--border-color);
244
+ border-radius: 6px;
245
+ color: var(--text-muted);
246
+ font-size: 0.7rem;
247
+ cursor: pointer;
248
+ transition: all 0.15s;
249
+ }
250
+
251
+ .copy-btn:hover {
252
+ background: var(--accent-purple);
253
+ border-color: var(--accent-purple);
254
+ color: white;
255
+ }
256
+
257
+ /* Row hint */
258
+ .row-hint {
259
+ display: inline-block;
260
+ margin-left: 0.5rem;
261
+ padding: 0.125rem 0.5rem;
262
+ background: var(--bg-tertiary);
263
+ border-radius: 4px;
264
+ font-size: 0.7rem;
265
+ color: var(--text-muted);
266
+ }
267
+ </style>
268
+
269
+ <script>
270
+ let currentReportData = null;
271
+ const category = '<%= @category %>';
272
+ const reportKey = '<%= @report_key %>';
273
+
274
+ function toggleDropdown() {
275
+ document.getElementById('dropdown-menu').classList.toggle('show');
276
+ }
277
+
278
+ // Close dropdown when clicking outside
279
+ document.addEventListener('click', function(e) {
280
+ const dropdown = document.getElementById('download-dropdown');
281
+ if (dropdown && !dropdown.contains(e.target)) {
282
+ document.getElementById('dropdown-menu')?.classList.remove('show');
283
+ }
284
+ });
285
+
286
+ function downloadReport(format) {
287
+ document.getElementById('dropdown-menu').classList.remove('show');
288
+ window.location.href = `${pgReportsRoot}/${category}/${reportKey}/download?format=${format}`;
289
+ }
290
+
291
+ function toggleRow(rowIndex) {
292
+ const dataRow = document.getElementById(`data-row-${rowIndex}`);
293
+ const detailRow = document.getElementById(`detail-row-${rowIndex}`);
294
+
295
+ if (!dataRow || !detailRow) return;
296
+
297
+ const isExpanded = dataRow.classList.contains('expanded');
298
+
299
+ // Collapse all other rows
300
+ document.querySelectorAll('.data-row.expanded').forEach(row => {
301
+ row.classList.remove('expanded');
302
+ });
303
+ document.querySelectorAll('.detail-row.show').forEach(row => {
304
+ row.classList.remove('show');
305
+ });
306
+
307
+ // Toggle current row
308
+ if (!isExpanded) {
309
+ dataRow.classList.add('expanded');
310
+ detailRow.classList.add('show');
311
+ }
312
+ }
313
+
314
+ function escapeHtml(text) {
315
+ const div = document.createElement('div');
316
+ div.textContent = text;
317
+ return div.innerHTML;
318
+ }
319
+
320
+ function copyToClipboard(text, btn) {
321
+ navigator.clipboard.writeText(text).then(() => {
322
+ const originalText = btn.textContent;
323
+ btn.textContent = '✓ Copied!';
324
+ btn.style.background = 'var(--accent-green)';
325
+ btn.style.borderColor = 'var(--accent-green)';
326
+ btn.style.color = 'white';
327
+ setTimeout(() => {
328
+ btn.textContent = originalText;
329
+ btn.style.background = '';
330
+ btn.style.borderColor = '';
331
+ btn.style.color = '';
332
+ }, 1500);
333
+ }).catch(() => {
334
+ showToast('Failed to copy', 'error');
335
+ });
336
+ }
337
+
338
+ function buildDetailRow(row, columns, rowIndex) {
339
+ let html = '<div class="row-detail">';
340
+
341
+ columns.forEach(col => {
342
+ const value = row[col] ?? '';
343
+ const strValue = String(value);
344
+ const isQuery = col === 'query';
345
+ const isSource = col === 'source';
346
+ const isNumber = typeof value === 'number' || (!isNaN(parseFloat(value)) && isFinite(value));
347
+
348
+ let valueClass = '';
349
+ if (isQuery) valueClass = 'query';
350
+ else if (isSource) valueClass = 'source';
351
+ else if (isNumber) valueClass = 'number';
352
+
353
+ const isLongText = strValue.length > 100 || isQuery;
354
+
355
+ // Skip empty source in detail view
356
+ if (isSource && (!strValue || strValue === 'null' || strValue === '')) {
357
+ return;
358
+ }
359
+
360
+ html += `
361
+ <div class="row-detail-item${isLongText ? ' full-width' : ''}">
362
+ <span class="row-detail-label">${escapeHtml(col)}</span>
363
+ <div class="row-detail-value ${valueClass}">${escapeHtml(strValue)}</div>
364
+ ${isQuery ? `<button class="copy-btn" onclick="event.stopPropagation(); copyToClipboard(\`${strValue.replace(/`/g, '\\`').replace(/\\/g, '\\\\')}\`, this)">📋 Copy Query</button>` : ''}
365
+ </div>
366
+ `;
367
+ });
368
+
369
+ html += '</div>';
370
+ return html;
371
+ }
372
+
373
+ async function runReport(cat, report, button) {
374
+ const tableBody = document.getElementById('results-body');
375
+ const tableHead = document.getElementById('results-head');
376
+ const loadingEl = document.getElementById('loading');
377
+ const emptyEl = document.getElementById('empty-state');
378
+ const metaEl = document.getElementById('results-meta');
379
+ const downloadDropdown = document.getElementById('download-dropdown');
380
+ const telegramBtn = document.getElementById('telegram-btn');
381
+
382
+ if (button) {
383
+ button.disabled = true;
384
+ button.innerHTML = '<span class="spinner" style="width:16px;height:16px;border-width:2px;display:inline-block;vertical-align:middle;"></span> Running...';
385
+ }
386
+
387
+ if (loadingEl) loadingEl.style.display = 'flex';
388
+ if (emptyEl) emptyEl.style.display = 'none';
389
+ if (tableBody) tableBody.innerHTML = '';
390
+ if (downloadDropdown) downloadDropdown.style.display = 'none';
391
+ if (telegramBtn) telegramBtn.style.display = 'none';
392
+
393
+ try {
394
+ const response = await fetch(`${pgReportsRoot}/${cat}/${report}/run`, {
395
+ method: 'POST',
396
+ headers: {
397
+ 'Content-Type': 'application/json',
398
+ 'X-CSRF-Token': document.querySelector('meta[name="csrf-token"]')?.content || ''
399
+ }
400
+ });
401
+
402
+ const data = await response.json();
403
+
404
+ if (loadingEl) loadingEl.style.display = 'none';
405
+
406
+ if (data.success) {
407
+ currentReportData = data;
408
+
409
+ if (metaEl) {
410
+ metaEl.innerHTML = `
411
+ <span>Total: ${data.total} rows</span>
412
+ <span>Generated: ${data.generated_at}</span>
413
+ <span class="row-hint">Click row to expand</span>
414
+ `;
415
+ }
416
+
417
+ // Show download and telegram buttons
418
+ if (downloadDropdown) downloadDropdown.style.display = 'inline-block';
419
+ if (telegramBtn) telegramBtn.style.display = 'inline-flex';
420
+
421
+ if (data.data.length === 0) {
422
+ if (emptyEl) emptyEl.style.display = 'block';
423
+ } else {
424
+ // Build table header
425
+ if (tableHead) {
426
+ tableHead.innerHTML = '<tr>' + data.columns.map(col =>
427
+ `<th>${escapeHtml(col)}</th>`
428
+ ).join('') + '</tr>';
429
+ }
430
+
431
+ // Build table body with expandable rows
432
+ if (tableBody) {
433
+ let rowsHtml = '';
434
+
435
+ data.data.forEach((row, idx) => {
436
+ // Data row
437
+ rowsHtml += `<tr id="data-row-${idx}" class="data-row" onclick="toggleRow(${idx})">`;
438
+ rowsHtml += data.columns.map(col => {
439
+ const value = row[col] ?? '';
440
+ const strValue = String(value);
441
+ const isQuery = col === 'query';
442
+ const isSource = col === 'source';
443
+
444
+ if (isSource) {
445
+ if (strValue && strValue !== 'null' && strValue !== '') {
446
+ return `<td><span class="source-badge" title="${escapeHtml(strValue)}">${escapeHtml(strValue)}</span></td>`;
447
+ } else {
448
+ return `<td><span class="source-badge empty">—</span></td>`;
449
+ }
450
+ }
451
+
452
+ const displayValue = strValue.length > 80 ? strValue.substring(0, 80) + '...' : strValue;
453
+ return `<td class="${isQuery ? 'query-cell' : ''}">${escapeHtml(displayValue)}</td>`;
454
+ }).join('');
455
+ rowsHtml += '</tr>';
456
+
457
+ // Detail row (hidden by default)
458
+ rowsHtml += `<tr id="detail-row-${idx}" class="detail-row">`;
459
+ rowsHtml += `<td colspan="${data.columns.length}">${buildDetailRow(row, data.columns, idx)}</td>`;
460
+ rowsHtml += '</tr>';
461
+ });
462
+
463
+ tableBody.innerHTML = rowsHtml;
464
+ }
465
+ }
466
+
467
+ showToast('Report generated successfully');
468
+ } else {
469
+ showToast(data.error || 'Failed to run report', 'error');
470
+ }
471
+ } catch (error) {
472
+ if (loadingEl) loadingEl.style.display = 'none';
473
+ showToast('Network error: ' + error.message, 'error');
474
+ }
475
+
476
+ if (button) {
477
+ button.disabled = false;
478
+ button.innerHTML = '▶ Run Report';
479
+ }
480
+ }
481
+ </script>
data/config/routes.rb ADDED
@@ -0,0 +1,13 @@
1
+ # frozen_string_literal: true
2
+
3
+ PgReports::Engine.routes.draw do
4
+ root to: "dashboard#index"
5
+
6
+ post "enable_pg_stat_statements", to: "dashboard#enable_pg_stat_statements", as: :enable_pg_stat_statements
7
+ post "reset_statistics", to: "dashboard#reset_statistics", as: :reset_statistics
8
+
9
+ get ":category/:report", to: "dashboard#show", as: :report
10
+ post ":category/:report/run", to: "dashboard#run", as: :run_report
11
+ post ":category/:report/telegram", to: "dashboard#send_to_telegram", as: :telegram_report
12
+ get ":category/:report/download", to: "dashboard#download", as: :download_report
13
+ end
@@ -0,0 +1,114 @@
1
+ # frozen_string_literal: true
2
+
3
+ module PgReports
4
+ # Parses SQL query comments to extract source location and metadata
5
+ # Supports:
6
+ # - Marginalia format: /*application:myapp,controller:users,action:index*/
7
+ # - Rails QueryLogs: /*action='index',controller='users'*/
8
+ #
9
+ module AnnotationParser
10
+ class << self
11
+ # Parse annotation from query text
12
+ # @param query [String] SQL query text
13
+ # @return [Hash] Parsed annotation data
14
+ def parse(query)
15
+ return {} if query.nil? || query.empty?
16
+
17
+ # Extract all comments from query
18
+ comments = query.scan(%r{/\*(.+?)\*/}).flatten
19
+
20
+ return {} if comments.empty?
21
+
22
+ result = {}
23
+
24
+ comments.each do |comment|
25
+ # Try short format first: "path/to/file.rb:42"
26
+ if (match = comment.match(%r{^(.+):(\d+)$}))
27
+ result[:file] = match[1]
28
+ result[:line] = match[2]
29
+ else
30
+ parsed = parse_comment(comment)
31
+ result.merge!(parsed)
32
+ end
33
+ end
34
+
35
+ result
36
+ end
37
+
38
+ # Extract clean query without annotations
39
+ # @param query [String] SQL query with annotations
40
+ # @return [String] Clean query
41
+ def strip_annotations(query)
42
+ return query if query.nil?
43
+
44
+ query.gsub(%r{/\*.+?\*/\s*}, "").strip
45
+ end
46
+
47
+ # Format annotation for display
48
+ # @param annotation [Hash] Parsed annotation
49
+ # @return [String] Human-readable string
50
+ def format_for_display(annotation)
51
+ return nil if annotation.empty?
52
+
53
+ parts = []
54
+
55
+ # Source location
56
+ if annotation[:file]
57
+ loc = annotation[:file].to_s
58
+ loc += ":#{annotation[:line]}" if annotation[:line]
59
+ parts << loc
60
+ end
61
+
62
+ # Method
63
+ parts << "##{annotation[:method]}" if annotation[:method]
64
+
65
+ # Controller/action
66
+ if annotation[:controller]
67
+ ca = annotation[:controller].to_s
68
+ ca += "##{annotation[:action]}" if annotation[:action]
69
+ parts << ca
70
+ end
71
+
72
+ parts.join(" ")
73
+ end
74
+
75
+ private
76
+
77
+ def parse_comment(comment)
78
+ result = {}
79
+
80
+ # Try different formats
81
+
82
+ # Format 1: key:value,key:value (Marginalia/PgReports style)
83
+ if comment.include?(":")
84
+ comment.split(",").each do |pair|
85
+ key, value = pair.split(":", 2)
86
+ next unless key && value
87
+
88
+ key = normalize_key(key.strip)
89
+ result[key] = value.strip
90
+ end
91
+ end
92
+
93
+ # Format 2: key='value',key='value' (Rails QueryLogs style)
94
+ if comment.include?("=")
95
+ comment.scan(/(\w+)='([^']*)'/).each do |key, value|
96
+ key = normalize_key(key)
97
+ result[key] = value
98
+ end
99
+
100
+ comment.scan(/(\w+)="([^"]*)"/).each do |key, value|
101
+ key = normalize_key(key)
102
+ result[key] = value
103
+ end
104
+ end
105
+
106
+ result
107
+ end
108
+
109
+ def normalize_key(key)
110
+ key.downcase.gsub(/[-\s]/, "_").to_sym
111
+ end
112
+ end
113
+ end
114
+ end
@@ -0,0 +1,83 @@
1
+ # frozen_string_literal: true
2
+
3
+ module PgReports
4
+ class Configuration
5
+ # Telegram settings
6
+ attr_accessor :telegram_bot_token
7
+ attr_accessor :telegram_chat_id
8
+
9
+ # Query analysis thresholds
10
+ attr_accessor :slow_query_threshold_ms # Queries slower than this are considered slow
11
+ attr_accessor :heavy_query_threshold_calls # Queries with more calls than this are heavy
12
+ attr_accessor :expensive_query_threshold_ms # Total time threshold for expensive queries
13
+
14
+ # Index analysis thresholds
15
+ attr_accessor :unused_index_threshold_scans # Index with fewer scans is unused
16
+
17
+ # Table analysis thresholds
18
+ attr_accessor :bloat_threshold_percent # Tables with more bloat are problematic
19
+ attr_accessor :dead_rows_threshold # Tables with more dead rows need vacuum
20
+
21
+ # Connection settings
22
+ attr_accessor :connection_pool # Custom connection pool (optional)
23
+
24
+ # Output settings
25
+ attr_accessor :max_query_length # Truncate query text to this length
26
+
27
+ # Dashboard settings
28
+ attr_accessor :dashboard_auth # Proc for dashboard authentication
29
+
30
+ def initialize
31
+ # Telegram
32
+ @telegram_bot_token = ENV.fetch("PG_REPORTS_TELEGRAM_TOKEN", nil)
33
+ @telegram_chat_id = ENV.fetch("PG_REPORTS_TELEGRAM_CHAT_ID", nil)
34
+
35
+ # Query thresholds
36
+ @slow_query_threshold_ms = 100
37
+ @heavy_query_threshold_calls = 1000
38
+ @expensive_query_threshold_ms = 10_000
39
+
40
+ # Index thresholds
41
+ @unused_index_threshold_scans = 50
42
+
43
+ # Table thresholds
44
+ @bloat_threshold_percent = 20
45
+ @dead_rows_threshold = 10_000
46
+
47
+ # Connection
48
+ @connection_pool = nil
49
+
50
+ # Output
51
+ @max_query_length = 200
52
+
53
+ # Dashboard
54
+ @dashboard_auth = nil
55
+ end
56
+
57
+ def connection
58
+ @connection_pool || ActiveRecord::Base.connection
59
+ end
60
+
61
+ def telegram_configured?
62
+ telegram_bot_token.present? && telegram_chat_id.present?
63
+ end
64
+ end
65
+
66
+ class << self
67
+ def configuration
68
+ @configuration ||= Configuration.new
69
+ end
70
+
71
+ def configure
72
+ yield(configuration)
73
+ end
74
+
75
+ def config
76
+ configuration
77
+ end
78
+
79
+ def reset_configuration!
80
+ @configuration = Configuration.new
81
+ end
82
+ end
83
+ end