pg_reports 0.2.2 → 0.3.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 +4 -4
- data/CHANGELOG.md +43 -0
- data/app/controllers/pg_reports/dashboard_controller.rb +131 -7
- data/app/views/pg_reports/dashboard/_show_modals.html.erb +105 -0
- data/app/views/pg_reports/dashboard/_show_scripts.html.erb +1301 -0
- data/app/views/pg_reports/dashboard/_show_styles.html.erb +1146 -0
- data/app/views/pg_reports/dashboard/index.html.erb +538 -0
- data/app/views/pg_reports/dashboard/show.html.erb +23 -2243
- data/config/routes.rb +3 -0
- data/lib/pg_reports/modules/system.rb +30 -0
- data/lib/pg_reports/sql/system/live_metrics.sql +62 -0
- data/lib/pg_reports/version.rb +1 -1
- metadata +5 -1
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 10a498c16d57884f8f551449454e670e1f62e8cfb73a7259198ed6634672c9c3
|
|
4
|
+
data.tar.gz: 4c80eeebe20506f1210a1a8c543d1f903853f7a21d4e2714f8da614326d417ac
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: d91cc17427845a2287bd5cda856536c27f16c1d32155417688aa0c7883d3e0b0c7867ca5d2e4f884457034f3c868d21795d64f6695ab895247b2e3eb83920ab8
|
|
7
|
+
data.tar.gz: fd07c5ed63a60a4b477bdbb91cdb07a7288c04ac4029c24a4dceaf5fd182168bfdf878f7b6a313b9aa43a23cb4b66340d996b0b21179e96b3bbca368a6060d34
|
data/CHANGELOG.md
CHANGED
|
@@ -5,6 +5,49 @@ All notable changes to this project will be documented in this file.
|
|
|
5
5
|
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
|
|
6
6
|
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
|
|
7
7
|
|
|
8
|
+
## [0.3.0] - 2026-01-28
|
|
9
|
+
|
|
10
|
+
### Added
|
|
11
|
+
|
|
12
|
+
- **Live Monitoring Panel** on the main dashboard with real-time metrics:
|
|
13
|
+
- Connections (active/idle/total, % of max_connections)
|
|
14
|
+
- TPS (transactions per second, calculated from pg_stat_database)
|
|
15
|
+
- Cache Hit Ratio (heap blocks from cache)
|
|
16
|
+
- Long Running Queries (count of queries > 60s)
|
|
17
|
+
- Blocked Processes (waiting for locks)
|
|
18
|
+
- SVG sparkline charts showing 2.5 minutes of history (30 data points)
|
|
19
|
+
- Color-coded status indicators (green/yellow/red) based on thresholds
|
|
20
|
+
- Pause/resume button for live monitoring (state saved to localStorage)
|
|
21
|
+
- Auto-refresh every 5 seconds
|
|
22
|
+
- Responsive grid layout for metric cards
|
|
23
|
+
- New `/live_metrics` API endpoint
|
|
24
|
+
|
|
25
|
+
### Changed
|
|
26
|
+
|
|
27
|
+
- Dashboard now shows live monitoring panel above the categories grid
|
|
28
|
+
|
|
29
|
+
## [0.2.3] - 2026-01-28
|
|
30
|
+
|
|
31
|
+
### Added
|
|
32
|
+
|
|
33
|
+
- Query Analyzer modal with parameter input fields for `$1`, `$2`, etc. placeholders
|
|
34
|
+
- Execute Query button to run queries and view results (alongside EXPLAIN ANALYZE)
|
|
35
|
+
- Parameter syntax highlighting in Query Analyzer (rose color for `$1`, `$2`, etc.)
|
|
36
|
+
|
|
37
|
+
### Changed
|
|
38
|
+
|
|
39
|
+
- Split `show.html.erb` into partials for better maintainability:
|
|
40
|
+
- `_show_styles.html.erb` - CSS styles
|
|
41
|
+
- `_show_scripts.html.erb` - JavaScript
|
|
42
|
+
- `_show_modals.html.erb` - Modal dialogs
|
|
43
|
+
- EXPLAIN ANALYZE button now only shown for SELECT queries
|
|
44
|
+
- Query encoding uses base64 to handle special characters (newlines, quotes)
|
|
45
|
+
|
|
46
|
+
### Fixed
|
|
47
|
+
|
|
48
|
+
- EXPLAIN ANALYZE button not working for queries with special characters
|
|
49
|
+
- Security: Only SELECT queries allowed for EXPLAIN ANALYZE, SELECT/SHOW for Execute Query
|
|
50
|
+
|
|
8
51
|
## [0.2.2] - 2026-01-28
|
|
9
52
|
|
|
10
53
|
### Added
|
|
@@ -23,6 +23,19 @@ module PgReports
|
|
|
23
23
|
render json: {success: false, error: e.message}, status: :unprocessable_entity
|
|
24
24
|
end
|
|
25
25
|
|
|
26
|
+
def live_metrics
|
|
27
|
+
threshold = params[:long_query_threshold]&.to_i || 60
|
|
28
|
+
data = Modules::System.live_metrics(long_query_threshold: threshold)
|
|
29
|
+
|
|
30
|
+
render json: {
|
|
31
|
+
success: true,
|
|
32
|
+
metrics: data,
|
|
33
|
+
timestamp: Time.current.to_i
|
|
34
|
+
}
|
|
35
|
+
rescue => e
|
|
36
|
+
render json: {success: false, error: e.message}, status: :unprocessable_entity
|
|
37
|
+
end
|
|
38
|
+
|
|
26
39
|
def show
|
|
27
40
|
@category = params[:category].to_sym
|
|
28
41
|
@report_key = params[:report].to_sym
|
|
@@ -114,31 +127,33 @@ module PgReports
|
|
|
114
127
|
|
|
115
128
|
def explain_analyze
|
|
116
129
|
query = params[:query]
|
|
130
|
+
query_params = params[:params] || {}
|
|
117
131
|
|
|
118
132
|
if query.blank?
|
|
119
133
|
render json: {success: false, error: "Query is required"}, status: :unprocessable_entity
|
|
120
134
|
return
|
|
121
135
|
end
|
|
122
136
|
|
|
123
|
-
# Security: Only allow SELECT queries for EXPLAIN ANALYZE
|
|
137
|
+
# Security: Only allow SELECT queries for EXPLAIN ANALYZE (SHOW not supported by EXPLAIN)
|
|
124
138
|
normalized = query.strip.gsub(/\s+/, " ").downcase
|
|
125
139
|
unless normalized.start_with?("select")
|
|
126
140
|
render json: {success: false, error: "Only SELECT queries are allowed for EXPLAIN ANALYZE"}, status: :unprocessable_entity
|
|
127
141
|
return
|
|
128
142
|
end
|
|
129
143
|
|
|
130
|
-
#
|
|
131
|
-
|
|
144
|
+
# Substitute parameters if provided
|
|
145
|
+
final_query = substitute_params(query, query_params)
|
|
146
|
+
|
|
147
|
+
# Check for remaining unsubstituted parameters
|
|
148
|
+
if final_query.match?(/\$\d+/)
|
|
132
149
|
render json: {
|
|
133
150
|
success: false,
|
|
134
|
-
error: "
|
|
135
|
-
"EXPLAIN ANALYZE cannot be run on parameterized queries without actual values. " \
|
|
136
|
-
"Copy the query and replace parameters with real values to analyze it manually."
|
|
151
|
+
error: "Please provide values for all parameter placeholders ($1, $2, etc.)"
|
|
137
152
|
}, status: :unprocessable_entity
|
|
138
153
|
return
|
|
139
154
|
end
|
|
140
155
|
|
|
141
|
-
result = ActiveRecord::Base.connection.execute("EXPLAIN (ANALYZE, BUFFERS, FORMAT TEXT) #{
|
|
156
|
+
result = ActiveRecord::Base.connection.execute("EXPLAIN (ANALYZE, BUFFERS, FORMAT TEXT) #{final_query}")
|
|
142
157
|
explain_output = result.map { |r| r["QUERY PLAN"] }.join("\n")
|
|
143
158
|
|
|
144
159
|
# Extract stats from the output
|
|
@@ -161,6 +176,69 @@ module PgReports
|
|
|
161
176
|
render json: {success: false, error: e.message}, status: :unprocessable_entity
|
|
162
177
|
end
|
|
163
178
|
|
|
179
|
+
def execute_query
|
|
180
|
+
query = params[:query]
|
|
181
|
+
query_params = params[:params] || {}
|
|
182
|
+
|
|
183
|
+
if query.blank?
|
|
184
|
+
render json: {success: false, error: "Query is required"}, status: :unprocessable_entity
|
|
185
|
+
return
|
|
186
|
+
end
|
|
187
|
+
|
|
188
|
+
# Security: Only allow SELECT and SHOW queries
|
|
189
|
+
normalized = query.strip.gsub(/\s+/, " ").downcase
|
|
190
|
+
unless normalized.start_with?("select") || normalized.start_with?("show")
|
|
191
|
+
render json: {success: false, error: "Only SELECT and SHOW queries are allowed"}, status: :unprocessable_entity
|
|
192
|
+
return
|
|
193
|
+
end
|
|
194
|
+
|
|
195
|
+
# Substitute parameters if provided
|
|
196
|
+
final_query = substitute_params(query, query_params)
|
|
197
|
+
|
|
198
|
+
# Check for remaining unsubstituted parameters
|
|
199
|
+
if final_query.match?(/\$\d+/)
|
|
200
|
+
render json: {
|
|
201
|
+
success: false,
|
|
202
|
+
error: "Please provide values for all parameter placeholders ($1, $2, etc.)"
|
|
203
|
+
}, status: :unprocessable_entity
|
|
204
|
+
return
|
|
205
|
+
end
|
|
206
|
+
|
|
207
|
+
# Execute with LIMIT to prevent huge result sets
|
|
208
|
+
limited_query = add_limit_if_missing(final_query, 100)
|
|
209
|
+
|
|
210
|
+
start_time = Process.clock_gettime(Process::CLOCK_MONOTONIC)
|
|
211
|
+
result = ActiveRecord::Base.connection.execute(limited_query)
|
|
212
|
+
end_time = Process.clock_gettime(Process::CLOCK_MONOTONIC)
|
|
213
|
+
execution_time = ((end_time - start_time) * 1000).round(2)
|
|
214
|
+
|
|
215
|
+
rows = result.to_a
|
|
216
|
+
columns = rows.first&.keys || []
|
|
217
|
+
|
|
218
|
+
# Check if we need to get total count
|
|
219
|
+
total_count = rows.size
|
|
220
|
+
truncated = false
|
|
221
|
+
|
|
222
|
+
if rows.size >= 100
|
|
223
|
+
# Check if there are more rows
|
|
224
|
+
count_result = ActiveRecord::Base.connection.execute("SELECT COUNT(*) FROM (#{final_query}) AS count_query")
|
|
225
|
+
total_count = count_result.first["count"].to_i
|
|
226
|
+
truncated = total_count > 100
|
|
227
|
+
end
|
|
228
|
+
|
|
229
|
+
render json: {
|
|
230
|
+
success: true,
|
|
231
|
+
columns: columns,
|
|
232
|
+
rows: rows,
|
|
233
|
+
count: rows.size,
|
|
234
|
+
total_count: total_count,
|
|
235
|
+
truncated: truncated,
|
|
236
|
+
execution_time: execution_time
|
|
237
|
+
}
|
|
238
|
+
rescue => e
|
|
239
|
+
render json: {success: false, error: e.message}, status: :unprocessable_entity
|
|
240
|
+
end
|
|
241
|
+
|
|
164
242
|
def create_migration
|
|
165
243
|
file_name = params[:file_name]
|
|
166
244
|
code = params[:code]
|
|
@@ -218,5 +296,51 @@ module PgReports
|
|
|
218
296
|
|
|
219
297
|
mod.public_send(report_key)
|
|
220
298
|
end
|
|
299
|
+
|
|
300
|
+
def substitute_params(query, params_hash)
|
|
301
|
+
result = query.dup
|
|
302
|
+
|
|
303
|
+
# Sort by param number descending to replace $10 before $1
|
|
304
|
+
params_hash.keys.map(&:to_i).sort.reverse.each do |num|
|
|
305
|
+
value = params_hash[num.to_s] || params_hash[num]
|
|
306
|
+
next if value.nil? || value.to_s.empty?
|
|
307
|
+
|
|
308
|
+
# Quote the value appropriately
|
|
309
|
+
quoted_value = quote_param_value(value)
|
|
310
|
+
result = result.gsub("$#{num}", quoted_value)
|
|
311
|
+
end
|
|
312
|
+
|
|
313
|
+
result
|
|
314
|
+
end
|
|
315
|
+
|
|
316
|
+
def quote_param_value(value)
|
|
317
|
+
str = value.to_s
|
|
318
|
+
|
|
319
|
+
# Check if it looks like a number
|
|
320
|
+
if str.match?(/\A-?\d+(\.\d+)?\z/)
|
|
321
|
+
str
|
|
322
|
+
# Check if it looks like a boolean
|
|
323
|
+
elsif str.downcase.in?(["true", "false"])
|
|
324
|
+
str.downcase
|
|
325
|
+
# Check if it looks like NULL
|
|
326
|
+
elsif str.downcase == "null"
|
|
327
|
+
"NULL"
|
|
328
|
+
else
|
|
329
|
+
# Quote as string, escape single quotes
|
|
330
|
+
"'#{str.gsub("'", "''")}'"
|
|
331
|
+
end
|
|
332
|
+
end
|
|
333
|
+
|
|
334
|
+
def add_limit_if_missing(query, limit)
|
|
335
|
+
# Simple check - if query doesn't end with LIMIT clause, add one
|
|
336
|
+
normalized = query.strip.gsub(/\s+/, " ").downcase
|
|
337
|
+
|
|
338
|
+
if normalized.match?(/\blimit\s+\d+\s*(?:offset\s+\d+\s*)?\z/i)
|
|
339
|
+
# Already has LIMIT
|
|
340
|
+
query
|
|
341
|
+
else
|
|
342
|
+
"#{query} LIMIT #{limit}"
|
|
343
|
+
end
|
|
344
|
+
end
|
|
221
345
|
end
|
|
222
346
|
end
|
|
@@ -0,0 +1,105 @@
|
|
|
1
|
+
<!-- IDE Settings Modal -->
|
|
2
|
+
<div id="ide-settings-modal" class="modal" style="display: none;">
|
|
3
|
+
<div class="modal-content modal-small">
|
|
4
|
+
<div class="modal-header">
|
|
5
|
+
<h3>⚙️ IDE Settings</h3>
|
|
6
|
+
<button class="modal-close" onclick="closeIdeSettingsModal()">×</button>
|
|
7
|
+
</div>
|
|
8
|
+
<div class="modal-body">
|
|
9
|
+
<p class="settings-label">Default IDE for source links:</p>
|
|
10
|
+
<div class="ide-options">
|
|
11
|
+
<label class="ide-option">
|
|
12
|
+
<input type="radio" name="default-ide" value="" onchange="setDefaultIde('')">
|
|
13
|
+
<span>Show menu (default)</span>
|
|
14
|
+
</label>
|
|
15
|
+
<label class="ide-option">
|
|
16
|
+
<input type="radio" name="default-ide" value="vscode-wsl" onchange="setDefaultIde('vscode-wsl')">
|
|
17
|
+
<span>VS Code (WSL)</span>
|
|
18
|
+
</label>
|
|
19
|
+
<label class="ide-option">
|
|
20
|
+
<input type="radio" name="default-ide" value="vscode" onchange="setDefaultIde('vscode')">
|
|
21
|
+
<span>VS Code</span>
|
|
22
|
+
</label>
|
|
23
|
+
<label class="ide-option">
|
|
24
|
+
<input type="radio" name="default-ide" value="rubymine" onchange="setDefaultIde('rubymine')">
|
|
25
|
+
<span>RubyMine</span>
|
|
26
|
+
</label>
|
|
27
|
+
<label class="ide-option">
|
|
28
|
+
<input type="radio" name="default-ide" value="intellij" onchange="setDefaultIde('intellij')">
|
|
29
|
+
<span>IntelliJ IDEA</span>
|
|
30
|
+
</label>
|
|
31
|
+
<label class="ide-option">
|
|
32
|
+
<input type="radio" name="default-ide" value="cursor-wsl" onchange="setDefaultIde('cursor-wsl')">
|
|
33
|
+
<span>Cursor (WSL)</span>
|
|
34
|
+
</label>
|
|
35
|
+
<label class="ide-option">
|
|
36
|
+
<input type="radio" name="default-ide" value="cursor" onchange="setDefaultIde('cursor')">
|
|
37
|
+
<span>Cursor</span>
|
|
38
|
+
</label>
|
|
39
|
+
</div>
|
|
40
|
+
</div>
|
|
41
|
+
</div>
|
|
42
|
+
</div>
|
|
43
|
+
|
|
44
|
+
<!-- Problem Explanation Modal -->
|
|
45
|
+
<div id="problem-modal" class="problem-modal" style="display: none;">
|
|
46
|
+
<div class="problem-modal-content">
|
|
47
|
+
<div class="problem-modal-header">
|
|
48
|
+
<h3>⚠️ Problem Detected</h3>
|
|
49
|
+
<button class="modal-close" onclick="closeProblemModal()">×</button>
|
|
50
|
+
</div>
|
|
51
|
+
<div class="problem-modal-body" id="problem-modal-body">
|
|
52
|
+
<!-- Content will be filled dynamically -->
|
|
53
|
+
</div>
|
|
54
|
+
</div>
|
|
55
|
+
</div>
|
|
56
|
+
|
|
57
|
+
<!-- EXPLAIN ANALYZE Modal -->
|
|
58
|
+
<div id="explain-modal" class="modal" style="display: none;">
|
|
59
|
+
<div class="modal-content modal-wide">
|
|
60
|
+
<div class="modal-header">
|
|
61
|
+
<h3>📊 Query Analyzer</h3>
|
|
62
|
+
<button class="modal-close" onclick="closeExplainModal()">×</button>
|
|
63
|
+
</div>
|
|
64
|
+
<div class="modal-body" id="explain-modal-body">
|
|
65
|
+
<div class="explain-query-section">
|
|
66
|
+
<label class="explain-label">Query:</label>
|
|
67
|
+
<pre class="explain-query" id="explain-query-display"></pre>
|
|
68
|
+
</div>
|
|
69
|
+
<div id="explain-params-section" class="explain-params-section" style="display: none;">
|
|
70
|
+
<label class="explain-label">Parameters:</label>
|
|
71
|
+
<div id="explain-params-inputs"></div>
|
|
72
|
+
</div>
|
|
73
|
+
<div class="explain-actions">
|
|
74
|
+
<button class="btn btn-primary" onclick="executeExplainAnalyze()" id="btn-explain">
|
|
75
|
+
📊 EXPLAIN ANALYZE
|
|
76
|
+
</button>
|
|
77
|
+
<button class="btn btn-secondary" onclick="executeQuery()" id="btn-execute">
|
|
78
|
+
▶ Execute Query
|
|
79
|
+
</button>
|
|
80
|
+
</div>
|
|
81
|
+
<div id="explain-loading" class="loading" style="display: none;">
|
|
82
|
+
<div class="spinner"></div>
|
|
83
|
+
</div>
|
|
84
|
+
<div id="explain-content"></div>
|
|
85
|
+
</div>
|
|
86
|
+
</div>
|
|
87
|
+
</div>
|
|
88
|
+
|
|
89
|
+
<!-- Migration Modal -->
|
|
90
|
+
<div id="migration-modal" class="modal" style="display: none;">
|
|
91
|
+
<div class="modal-content">
|
|
92
|
+
<div class="modal-header">
|
|
93
|
+
<h3>🗑️ Drop Index Migration</h3>
|
|
94
|
+
<button class="modal-close" onclick="closeMigrationModal()">×</button>
|
|
95
|
+
</div>
|
|
96
|
+
<div class="modal-body" id="migration-modal-body">
|
|
97
|
+
<p class="settings-label">Generated migration to remove the index:</p>
|
|
98
|
+
<div id="migration-code" class="migration-code"></div>
|
|
99
|
+
<div class="migration-actions">
|
|
100
|
+
<button class="btn btn-secondary" onclick="copyMigrationCode()">📋 Copy Code</button>
|
|
101
|
+
<button class="btn btn-primary" onclick="createMigrationFile()">📁 Create File & Open in IDE</button>
|
|
102
|
+
</div>
|
|
103
|
+
</div>
|
|
104
|
+
</div>
|
|
105
|
+
</div>
|