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.
- checksums.yaml +7 -0
- data/CHANGELOG.md +22 -0
- data/LICENSE.txt +21 -0
- data/README.md +335 -0
- data/app/controllers/pg_reports/dashboard_controller.rb +133 -0
- data/app/views/layouts/pg_reports/application.html.erb +594 -0
- data/app/views/pg_reports/dashboard/index.html.erb +435 -0
- data/app/views/pg_reports/dashboard/show.html.erb +481 -0
- data/config/routes.rb +13 -0
- data/lib/pg_reports/annotation_parser.rb +114 -0
- data/lib/pg_reports/configuration.rb +83 -0
- data/lib/pg_reports/dashboard/reports_registry.rb +89 -0
- data/lib/pg_reports/engine.rb +22 -0
- data/lib/pg_reports/error.rb +15 -0
- data/lib/pg_reports/executor.rb +51 -0
- data/lib/pg_reports/modules/connections.rb +106 -0
- data/lib/pg_reports/modules/indexes.rb +111 -0
- data/lib/pg_reports/modules/queries.rb +140 -0
- data/lib/pg_reports/modules/system.rb +148 -0
- data/lib/pg_reports/modules/tables.rb +113 -0
- data/lib/pg_reports/report.rb +228 -0
- data/lib/pg_reports/sql/connections/active_connections.sql +20 -0
- data/lib/pg_reports/sql/connections/blocking_queries.sql +35 -0
- data/lib/pg_reports/sql/connections/connection_stats.sql +13 -0
- data/lib/pg_reports/sql/connections/idle_connections.sql +19 -0
- data/lib/pg_reports/sql/connections/locks.sql +20 -0
- data/lib/pg_reports/sql/connections/long_running_queries.sql +21 -0
- data/lib/pg_reports/sql/indexes/bloated_indexes.sql +36 -0
- data/lib/pg_reports/sql/indexes/duplicate_indexes.sql +38 -0
- data/lib/pg_reports/sql/indexes/index_sizes.sql +14 -0
- data/lib/pg_reports/sql/indexes/index_usage.sql +19 -0
- data/lib/pg_reports/sql/indexes/invalid_indexes.sql +15 -0
- data/lib/pg_reports/sql/indexes/missing_indexes.sql +27 -0
- data/lib/pg_reports/sql/indexes/unused_indexes.sql +18 -0
- data/lib/pg_reports/sql/queries/all_queries.sql +20 -0
- data/lib/pg_reports/sql/queries/expensive_queries.sql +22 -0
- data/lib/pg_reports/sql/queries/heavy_queries.sql +17 -0
- data/lib/pg_reports/sql/queries/low_cache_hit_queries.sql +19 -0
- data/lib/pg_reports/sql/queries/missing_index_queries.sql +25 -0
- data/lib/pg_reports/sql/queries/slow_queries.sql +17 -0
- data/lib/pg_reports/sql/system/activity_overview.sql +29 -0
- data/lib/pg_reports/sql/system/cache_stats.sql +19 -0
- data/lib/pg_reports/sql/system/database_sizes.sql +10 -0
- data/lib/pg_reports/sql/system/extensions.sql +12 -0
- data/lib/pg_reports/sql/system/settings.sql +33 -0
- data/lib/pg_reports/sql/tables/bloated_tables.sql +23 -0
- data/lib/pg_reports/sql/tables/cache_hit_ratios.sql +24 -0
- data/lib/pg_reports/sql/tables/recently_modified.sql +20 -0
- data/lib/pg_reports/sql/tables/row_counts.sql +18 -0
- data/lib/pg_reports/sql/tables/seq_scans.sql +26 -0
- data/lib/pg_reports/sql/tables/table_sizes.sql +16 -0
- data/lib/pg_reports/sql/tables/vacuum_needed.sql +22 -0
- data/lib/pg_reports/sql_loader.rb +35 -0
- data/lib/pg_reports/telegram_sender.rb +83 -0
- data/lib/pg_reports/version.rb +5 -0
- data/lib/pg_reports.rb +114 -0
- 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
|