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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: ebdc855958933d677530a6bee64fe5534e3b13244e4b851ee3cd06b37e1df10b
4
- data.tar.gz: 1fae6baba2619b57b10ad86ef37678a34b279c49a7cf0f014810f4b167c2ae80
3
+ metadata.gz: 10a498c16d57884f8f551449454e670e1f62e8cfb73a7259198ed6634672c9c3
4
+ data.tar.gz: 4c80eeebe20506f1210a1a8c543d1f903853f7a21d4e2714f8da614326d417ac
5
5
  SHA512:
6
- metadata.gz: 7b2960a2f1df123331f8cd8d41d258fcd61f45b118451bac7859e890a5fde822266e7ab77e60a5820bf1856548ebf3ec2fff77a3f7938dc8913eca53cb087582
7
- data.tar.gz: 8bfeeff753903a9a29329221108628ae2119759f859dd01e2c1395a9ae317a956556fb115bfa28b046b859e784ea42fc0b51df7fee56b7a6a6aef1674335a96a
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
- # Check for parameterized queries (from pg_stat_statements normalization)
131
- if query.match?(/\$\d+/)
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: "This query contains parameter placeholders ($1, $2, etc.) from pg_stat_statements normalization. " \
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) #{query}")
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()">&times;</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()">&times;</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()">&times;</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()">&times;</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>