pg_reports 0.2.1 → 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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 76df6e762c2183af15f4409d137e5705f97249922d6c5c5521ba6a7903d3e3f9
4
- data.tar.gz: 7e52e17a4fa3b3961a1b3d9c5c64174376e38e0d97732017d3a248df8c19528f
3
+ metadata.gz: 72b693934d81353991eadfbfaf0a17a9f6c73e33048270435cb6ad917d3bee1c
4
+ data.tar.gz: 56159c5cd3315d96cff002af0ab3328c3ce4345f463bbf22c3ddfca3f3180ae0
5
5
  SHA512:
6
- metadata.gz: eed06fbb5e836b8ecd1452319e4a0af13ffe750525953fec4ee4aed49e9d04e47df6f8404afe29162950885b41cccdaf4cd1e0ec22f2b87917ee64d931245fc4
7
- data.tar.gz: 3c6d3b1f7c04781428eaf8d8460177ef4e243d114ad88c83b4419ab15817214804184247a0074dd5f69775d7484205969830dd77169b6de52dc35cda8bda7e24
6
+ metadata.gz: 9d71b455e90c577a21a0793899d4d6cfddd6afc73c2bad4d5f681e4c45fcf23380a0b2e76159d4b5d42ee198b75c64ebdb3b8048761f3af141c004a48676f159
7
+ data.tar.gz: a96c4dd926bb28c041283d3a3c025111545fa46995290791785ab43d6c976f4b39d3f009929b829376ef4b2dbcb8704d9966e349183210a5f6619d01fd681fd8
data/CHANGELOG.md CHANGED
@@ -5,6 +5,45 @@ 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
+
30
+ ## [0.2.2] - 2026-01-28
31
+
32
+ ### Added
33
+
34
+ - `fake_source_data` configuration option - enable via `PG_REPORTS_FAKE_SOURCE_DATA=true` env variable or in initializer
35
+ - Support for short controller#action format in source links (e.g., `posts#index` → `app/controllers/posts_controller.rb`)
36
+
37
+ ### Changed
38
+
39
+ - Fake source data moved to separate partial file for cleaner code organization
40
+ - IDE link click handling improved with event delegation in capture phase
41
+
42
+ ### Fixed
43
+
44
+ - Source badge clicks now work correctly without triggering row expansion
45
+ - Fallback fonts now use proper sans-serif system font stack when `load_external_fonts` is disabled
46
+
8
47
  ## [0.2.1] - 2026-01-28
9
48
 
10
49
  ### Added
data/README.md CHANGED
@@ -116,6 +116,12 @@ PgReports.configure do |config|
116
116
  end
117
117
  }
118
118
 
119
+ # External fonts (Google Fonts)
120
+ # Default: false (no external requests)
121
+ config.load_external_fonts = ENV["PG_REPORTS_LOAD_EXTERNAL_FONTS"] == "true"
122
+ # or simply:
123
+ # config.load_external_fonts = true
124
+
119
125
  end
120
126
  ```
121
127
 
@@ -333,6 +339,17 @@ PgReports.configure do |config|
333
339
  end
334
340
  ```
335
341
 
342
+ ### External Fonts
343
+
344
+ By default, PgReports does **not** load external fonts.
345
+
346
+ ```ruby
347
+ PgReports.configure do |config|
348
+ # Enable loading Google Fonts (optional)
349
+ config.load_external_fonts = true
350
+ end
351
+ ```
352
+
336
353
  ## Telegram Integration
337
354
 
338
355
  1. Create a bot via [@BotFather](https://t.me/BotFather)
@@ -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
- # Check for parameterized queries (from pg_stat_statements normalization)
131
- if query.match?(/\$\d+/)
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: "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."
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) #{query}")
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
@@ -6,9 +6,11 @@
6
6
  <meta name="pg-reports-root" content="<%= request.script_name.presence || PgReports::Engine.routes.url_helpers.root_path %>">
7
7
  <title>PgReports Dashboard</title>
8
8
  <link rel="icon" type="image/svg+xml" href="data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 32 32'%3E%3Crect width='32' height='32' rx='6' fill='%234f46e5'/%3E%3Crect x='5' y='18' width='5' height='9' rx='1' fill='%23fff'/%3E%3Crect x='13.5' y='12' width='5' height='15' rx='1' fill='%23fff'/%3E%3Crect x='22' y='6' width='5' height='21' rx='1' fill='%23fff'/%3E%3C/svg%3E">
9
- <link rel="preconnect" href="https://fonts.googleapis.com">
10
- <link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
11
- <link href="https://fonts.googleapis.com/css2?family=JetBrains+Mono:wght@400;500;600&family=Plus+Jakarta+Sans:wght@400;500;600;700&display=swap" rel="stylesheet">
9
+ <% if PgReports.config.load_external_fonts %>
10
+ <link rel="preconnect" href="https://fonts.googleapis.com">
11
+ <link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
12
+ <link href="https://fonts.googleapis.com/css2?family=JetBrains+Mono:wght@400;500;600&family=Plus+Jakarta+Sans:wght@400;500;600;700&display=swap" rel="stylesheet">
13
+ <% end %>
12
14
  <style>
13
15
  :root {
14
16
  --bg-primary: #0f1114;
@@ -36,7 +38,7 @@
36
38
  }
37
39
 
38
40
  body {
39
- font-family: 'Plus Jakarta Sans', -apple-system, BlinkMacSystemFont, sans-serif;
41
+ font-family: <%= PgReports.config.load_external_fonts ? "'Plus Jakarta Sans', " : "" %>-apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", Arial, "Noto Sans", sans-serif;
40
42
  background: var(--bg-primary);
41
43
  color: var(--text-primary);
42
44
  min-height: 100vh;
@@ -344,7 +346,7 @@
344
346
  .results-table {
345
347
  width: 100%;
346
348
  border-collapse: collapse;
347
- font-family: 'JetBrains Mono', monospace;
349
+ font-family: <%= PgReports.config.load_external_fonts ? "'JetBrains Mono', " : "" %>SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", "Courier New", monospace;
348
350
  font-size: 0.8rem;
349
351
  }
350
352
 
@@ -0,0 +1,43 @@
1
+ <%# Fake source data for IDE link testing - enable via PG_REPORTS_FAKE_SOURCE_DATA=true %>
2
+ <script>
3
+ // Known controller methods with line numbers (for IDE link testing)
4
+ const knownControllerMethods = {
5
+ 'PostsController#show': { file: 'app/controllers/posts_controller.rb', line: 33 },
6
+ 'PostsController#index': { file: 'app/controllers/posts_controller.rb', line: 19 },
7
+ 'PostsController#create': { file: 'app/controllers/posts_controller.rb', line: 43 },
8
+ 'PostsController#update': { file: 'app/controllers/posts_controller.rb', line: 62 },
9
+ 'PostsController#destroy': { file: 'app/controllers/posts_controller.rb', line: 76 }
10
+ };
11
+
12
+ // Fake source values for testing IDE links
13
+ const fakeSourceValues = [
14
+ 'PostsController#show',
15
+ 'PostsController#index',
16
+ 'PostsController#create',
17
+ 'PostsController#update',
18
+ 'PostsController#destroy',
19
+ 'app/controllers/posts_controller.rb:33',
20
+ 'app/controllers/posts_controller.rb:19',
21
+ 'app/models/post.rb:45:in `find_by_slug`'
22
+ ];
23
+
24
+ // Inject fake source values into data for testing
25
+ function injectFakeSourceData(data) {
26
+ if (!data.data || data.data.length === 0) return;
27
+
28
+ // Add 'source' column if not present
29
+ if (!data.columns.includes('source')) {
30
+ data.columns.push('source');
31
+ }
32
+
33
+ // Inject fake source values
34
+ data.data.forEach((row, idx) => {
35
+ if (!row.source || row.source === '' || row.source === null) {
36
+ row.source = fakeSourceValues[idx % fakeSourceValues.length];
37
+ }
38
+ });
39
+ }
40
+
41
+ // Flag to indicate fake source data is enabled
42
+ window.PG_REPORTS_FAKE_SOURCE_DATA = true;
43
+ </script>
@@ -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>