pg_reports 0.2.2 → 0.2.3
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 +22 -0
- data/app/controllers/pg_reports/dashboard_controller.rb +118 -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/show.html.erb +23 -2243
- data/config/routes.rb +1 -0
- data/lib/pg_reports/version.rb +1 -1
- metadata +4 -1
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 72b693934d81353991eadfbfaf0a17a9f6c73e33048270435cb6ad917d3bee1c
|
|
4
|
+
data.tar.gz: 56159c5cd3315d96cff002af0ab3328c3ce4345f463bbf22c3ddfca3f3180ae0
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: 9d71b455e90c577a21a0793899d4d6cfddd6afc73c2bad4d5f681e4c45fcf23380a0b2e76159d4b5d42ee198b75c64ebdb3b8048761f3af141c004a48676f159
|
|
7
|
+
data.tar.gz: a96c4dd926bb28c041283d3a3c025111545fa46995290791785ab43d6c976f4b39d3f009929b829376ef4b2dbcb8704d9966e349183210a5f6619d01fd681fd8
|
data/CHANGELOG.md
CHANGED
|
@@ -5,6 +5,28 @@ 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.2.3] - 2026-01-28
|
|
9
|
+
|
|
10
|
+
### Added
|
|
11
|
+
|
|
12
|
+
- Query Analyzer modal with parameter input fields for `$1`, `$2`, etc. placeholders
|
|
13
|
+
- Execute Query button to run queries and view results (alongside EXPLAIN ANALYZE)
|
|
14
|
+
- Parameter syntax highlighting in Query Analyzer (rose color for `$1`, `$2`, etc.)
|
|
15
|
+
|
|
16
|
+
### Changed
|
|
17
|
+
|
|
18
|
+
- Split `show.html.erb` into partials for better maintainability:
|
|
19
|
+
- `_show_styles.html.erb` - CSS styles
|
|
20
|
+
- `_show_scripts.html.erb` - JavaScript
|
|
21
|
+
- `_show_modals.html.erb` - Modal dialogs
|
|
22
|
+
- EXPLAIN ANALYZE button now only shown for SELECT queries
|
|
23
|
+
- Query encoding uses base64 to handle special characters (newlines, quotes)
|
|
24
|
+
|
|
25
|
+
### Fixed
|
|
26
|
+
|
|
27
|
+
- EXPLAIN ANALYZE button not working for queries with special characters
|
|
28
|
+
- Security: Only SELECT queries allowed for EXPLAIN ANALYZE, SELECT/SHOW for Execute Query
|
|
29
|
+
|
|
8
30
|
## [0.2.2] - 2026-01-28
|
|
9
31
|
|
|
10
32
|
### Added
|
|
@@ -114,31 +114,33 @@ module PgReports
|
|
|
114
114
|
|
|
115
115
|
def explain_analyze
|
|
116
116
|
query = params[:query]
|
|
117
|
+
query_params = params[:params] || {}
|
|
117
118
|
|
|
118
119
|
if query.blank?
|
|
119
120
|
render json: {success: false, error: "Query is required"}, status: :unprocessable_entity
|
|
120
121
|
return
|
|
121
122
|
end
|
|
122
123
|
|
|
123
|
-
# Security: Only allow SELECT queries for EXPLAIN ANALYZE
|
|
124
|
+
# Security: Only allow SELECT queries for EXPLAIN ANALYZE (SHOW not supported by EXPLAIN)
|
|
124
125
|
normalized = query.strip.gsub(/\s+/, " ").downcase
|
|
125
126
|
unless normalized.start_with?("select")
|
|
126
127
|
render json: {success: false, error: "Only SELECT queries are allowed for EXPLAIN ANALYZE"}, status: :unprocessable_entity
|
|
127
128
|
return
|
|
128
129
|
end
|
|
129
130
|
|
|
130
|
-
#
|
|
131
|
-
|
|
131
|
+
# Substitute parameters if provided
|
|
132
|
+
final_query = substitute_params(query, query_params)
|
|
133
|
+
|
|
134
|
+
# Check for remaining unsubstituted parameters
|
|
135
|
+
if final_query.match?(/\$\d+/)
|
|
132
136
|
render json: {
|
|
133
137
|
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."
|
|
138
|
+
error: "Please provide values for all parameter placeholders ($1, $2, etc.)"
|
|
137
139
|
}, status: :unprocessable_entity
|
|
138
140
|
return
|
|
139
141
|
end
|
|
140
142
|
|
|
141
|
-
result = ActiveRecord::Base.connection.execute("EXPLAIN (ANALYZE, BUFFERS, FORMAT TEXT) #{
|
|
143
|
+
result = ActiveRecord::Base.connection.execute("EXPLAIN (ANALYZE, BUFFERS, FORMAT TEXT) #{final_query}")
|
|
142
144
|
explain_output = result.map { |r| r["QUERY PLAN"] }.join("\n")
|
|
143
145
|
|
|
144
146
|
# Extract stats from the output
|
|
@@ -161,6 +163,69 @@ module PgReports
|
|
|
161
163
|
render json: {success: false, error: e.message}, status: :unprocessable_entity
|
|
162
164
|
end
|
|
163
165
|
|
|
166
|
+
def execute_query
|
|
167
|
+
query = params[:query]
|
|
168
|
+
query_params = params[:params] || {}
|
|
169
|
+
|
|
170
|
+
if query.blank?
|
|
171
|
+
render json: {success: false, error: "Query is required"}, status: :unprocessable_entity
|
|
172
|
+
return
|
|
173
|
+
end
|
|
174
|
+
|
|
175
|
+
# Security: Only allow SELECT and SHOW queries
|
|
176
|
+
normalized = query.strip.gsub(/\s+/, " ").downcase
|
|
177
|
+
unless normalized.start_with?("select") || normalized.start_with?("show")
|
|
178
|
+
render json: {success: false, error: "Only SELECT and SHOW queries are allowed"}, status: :unprocessable_entity
|
|
179
|
+
return
|
|
180
|
+
end
|
|
181
|
+
|
|
182
|
+
# Substitute parameters if provided
|
|
183
|
+
final_query = substitute_params(query, query_params)
|
|
184
|
+
|
|
185
|
+
# Check for remaining unsubstituted parameters
|
|
186
|
+
if final_query.match?(/\$\d+/)
|
|
187
|
+
render json: {
|
|
188
|
+
success: false,
|
|
189
|
+
error: "Please provide values for all parameter placeholders ($1, $2, etc.)"
|
|
190
|
+
}, status: :unprocessable_entity
|
|
191
|
+
return
|
|
192
|
+
end
|
|
193
|
+
|
|
194
|
+
# Execute with LIMIT to prevent huge result sets
|
|
195
|
+
limited_query = add_limit_if_missing(final_query, 100)
|
|
196
|
+
|
|
197
|
+
start_time = Process.clock_gettime(Process::CLOCK_MONOTONIC)
|
|
198
|
+
result = ActiveRecord::Base.connection.execute(limited_query)
|
|
199
|
+
end_time = Process.clock_gettime(Process::CLOCK_MONOTONIC)
|
|
200
|
+
execution_time = ((end_time - start_time) * 1000).round(2)
|
|
201
|
+
|
|
202
|
+
rows = result.to_a
|
|
203
|
+
columns = rows.first&.keys || []
|
|
204
|
+
|
|
205
|
+
# Check if we need to get total count
|
|
206
|
+
total_count = rows.size
|
|
207
|
+
truncated = false
|
|
208
|
+
|
|
209
|
+
if rows.size >= 100
|
|
210
|
+
# Check if there are more rows
|
|
211
|
+
count_result = ActiveRecord::Base.connection.execute("SELECT COUNT(*) FROM (#{final_query}) AS count_query")
|
|
212
|
+
total_count = count_result.first["count"].to_i
|
|
213
|
+
truncated = total_count > 100
|
|
214
|
+
end
|
|
215
|
+
|
|
216
|
+
render json: {
|
|
217
|
+
success: true,
|
|
218
|
+
columns: columns,
|
|
219
|
+
rows: rows,
|
|
220
|
+
count: rows.size,
|
|
221
|
+
total_count: total_count,
|
|
222
|
+
truncated: truncated,
|
|
223
|
+
execution_time: execution_time
|
|
224
|
+
}
|
|
225
|
+
rescue => e
|
|
226
|
+
render json: {success: false, error: e.message}, status: :unprocessable_entity
|
|
227
|
+
end
|
|
228
|
+
|
|
164
229
|
def create_migration
|
|
165
230
|
file_name = params[:file_name]
|
|
166
231
|
code = params[:code]
|
|
@@ -218,5 +283,51 @@ module PgReports
|
|
|
218
283
|
|
|
219
284
|
mod.public_send(report_key)
|
|
220
285
|
end
|
|
286
|
+
|
|
287
|
+
def substitute_params(query, params_hash)
|
|
288
|
+
result = query.dup
|
|
289
|
+
|
|
290
|
+
# Sort by param number descending to replace $10 before $1
|
|
291
|
+
params_hash.keys.map(&:to_i).sort.reverse.each do |num|
|
|
292
|
+
value = params_hash[num.to_s] || params_hash[num]
|
|
293
|
+
next if value.nil? || value.to_s.empty?
|
|
294
|
+
|
|
295
|
+
# Quote the value appropriately
|
|
296
|
+
quoted_value = quote_param_value(value)
|
|
297
|
+
result = result.gsub("$#{num}", quoted_value)
|
|
298
|
+
end
|
|
299
|
+
|
|
300
|
+
result
|
|
301
|
+
end
|
|
302
|
+
|
|
303
|
+
def quote_param_value(value)
|
|
304
|
+
str = value.to_s
|
|
305
|
+
|
|
306
|
+
# Check if it looks like a number
|
|
307
|
+
if str.match?(/\A-?\d+(\.\d+)?\z/)
|
|
308
|
+
str
|
|
309
|
+
# Check if it looks like a boolean
|
|
310
|
+
elsif str.downcase.in?(["true", "false"])
|
|
311
|
+
str.downcase
|
|
312
|
+
# Check if it looks like NULL
|
|
313
|
+
elsif str.downcase == "null"
|
|
314
|
+
"NULL"
|
|
315
|
+
else
|
|
316
|
+
# Quote as string, escape single quotes
|
|
317
|
+
"'#{str.gsub("'", "''")}'"
|
|
318
|
+
end
|
|
319
|
+
end
|
|
320
|
+
|
|
321
|
+
def add_limit_if_missing(query, limit)
|
|
322
|
+
# Simple check - if query doesn't end with LIMIT clause, add one
|
|
323
|
+
normalized = query.strip.gsub(/\s+/, " ").downcase
|
|
324
|
+
|
|
325
|
+
if normalized.match?(/\blimit\s+\d+\s*(?:offset\s+\d+\s*)?\z/i)
|
|
326
|
+
# Already has LIMIT
|
|
327
|
+
query
|
|
328
|
+
else
|
|
329
|
+
"#{query} LIMIT #{limit}"
|
|
330
|
+
end
|
|
331
|
+
end
|
|
221
332
|
end
|
|
222
333
|
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>
|