pg_insights 0.3.1 → 0.4.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.
Files changed (40) hide show
  1. checksums.yaml +4 -4
  2. data/app/assets/javascripts/pg_insights/application.js +91 -24
  3. data/app/assets/javascripts/pg_insights/plan_performance.js +53 -0
  4. data/app/assets/javascripts/pg_insights/query_comparison.js +1129 -0
  5. data/app/assets/javascripts/pg_insights/results/view_toggles.js +26 -5
  6. data/app/assets/javascripts/pg_insights/results.js +231 -2
  7. data/app/assets/stylesheets/pg_insights/analysis.css +2628 -0
  8. data/app/assets/stylesheets/pg_insights/application.css +51 -1
  9. data/app/assets/stylesheets/pg_insights/results.css +12 -1
  10. data/app/controllers/pg_insights/insights_controller.rb +486 -9
  11. data/app/helpers/pg_insights/application_helper.rb +339 -0
  12. data/app/helpers/pg_insights/insights_helper.rb +567 -0
  13. data/app/jobs/pg_insights/query_analysis_job.rb +142 -0
  14. data/app/models/pg_insights/query_execution.rb +198 -0
  15. data/app/services/pg_insights/query_analysis_service.rb +269 -0
  16. data/app/views/layouts/pg_insights/application.html.erb +8 -1
  17. data/app/views/pg_insights/insights/_compare_view.html.erb +264 -0
  18. data/app/views/pg_insights/insights/_empty_state.html.erb +9 -0
  19. data/app/views/pg_insights/insights/_execution_table_view.html.erb +86 -0
  20. data/app/views/pg_insights/insights/_history_bar.html.erb +33 -0
  21. data/app/views/pg_insights/insights/_perf_view.html.erb +244 -0
  22. data/app/views/pg_insights/insights/_plan_nodes.html.erb +12 -0
  23. data/app/views/pg_insights/insights/_plan_tree.html.erb +30 -0
  24. data/app/views/pg_insights/insights/_plan_tree_modern.html.erb +12 -0
  25. data/app/views/pg_insights/insights/_plan_view.html.erb +159 -0
  26. data/app/views/pg_insights/insights/_query_panel.html.erb +3 -2
  27. data/app/views/pg_insights/insights/_result.html.erb +19 -4
  28. data/app/views/pg_insights/insights/_results_info.html.erb +33 -9
  29. data/app/views/pg_insights/insights/_results_info_empty.html.erb +10 -0
  30. data/app/views/pg_insights/insights/_results_panel.html.erb +7 -9
  31. data/app/views/pg_insights/insights/_results_table.html.erb +0 -5
  32. data/app/views/pg_insights/insights/_visual_view.html.erb +212 -0
  33. data/app/views/pg_insights/insights/index.html.erb +4 -1
  34. data/app/views/pg_insights/timeline/compare.html.erb +3 -3
  35. data/config/routes.rb +6 -0
  36. data/lib/generators/pg_insights/install_generator.rb +20 -14
  37. data/lib/generators/pg_insights/templates/db/migrate/create_pg_insights_query_executions.rb +45 -0
  38. data/lib/pg_insights/version.rb +1 -1
  39. data/lib/pg_insights.rb +30 -2
  40. metadata +20 -2
@@ -1,4 +1,343 @@
1
1
  module PgInsights
2
2
  module ApplicationHelper
3
+ def render_plan_node(node, level = 0)
4
+ return "".html_safe unless node
5
+
6
+ # Build the node display text
7
+ node_text = []
8
+ node_text << "#{node['Node Type']}"
9
+ node_text << "on #{node['Relation Name']}" if node["Relation Name"]
10
+
11
+ # Cost information
12
+ cost_info = []
13
+ if node["Startup Cost"] && node["Total Cost"]
14
+ cost_info << "cost=#{node['Startup Cost']}..#{node['Total Cost']}"
15
+ elsif node["Total Cost"]
16
+ cost_info << "cost=#{node['Total Cost']}"
17
+ end
18
+
19
+ if node["Plan Rows"]
20
+ cost_info << "rows=#{node['Plan Rows']}"
21
+ end
22
+
23
+ if node["Plan Width"]
24
+ cost_info << "width=#{node['Plan Width']}"
25
+ end
26
+
27
+ node_text << "(#{cost_info.join(' ')})" if cost_info.any?
28
+
29
+ # Actual execution stats
30
+ actual_info = []
31
+ if node["Actual Total Time"]
32
+ actual_info << "actual time=#{node['Actual Total Time']}ms"
33
+ end
34
+ if node["Actual Rows"]
35
+ actual_info << "rows=#{node['Actual Rows']}"
36
+ end
37
+ if node["Actual Loops"]
38
+ actual_info << "loops=#{node['Actual Loops']}"
39
+ end
40
+
41
+ node_text << "[#{actual_info.join(' ')}]" if actual_info.any?
42
+
43
+ # Build the HTML for this node
44
+ indent = " " * level
45
+ prefix = level == 0 ? "" : "├─ "
46
+
47
+ result = content_tag(:div, class: "plan-node level-#{level}") do
48
+ content = content_tag(:span, "#{indent}#{prefix}", class: "plan-indent")
49
+ content += content_tag(:span, node_text.join(" "), class: "plan-node-text")
50
+
51
+ # Add filter information if present
52
+ if node["Filter"]
53
+ content += content_tag(:div, class: "plan-filter") do
54
+ content_tag(:span, "#{indent} Filter: #{node['Filter']}", class: "filter-text")
55
+ end
56
+ end
57
+
58
+ # Add index condition if present
59
+ if node["Index Cond"]
60
+ content += content_tag(:div, class: "plan-condition") do
61
+ content_tag(:span, "#{indent} Index Cond: #{node['Index Cond']}", class: "condition-text")
62
+ end
63
+ end
64
+
65
+ # Add other important fields
66
+ %w[Sort Key Hash Cond Join Filter].each do |field|
67
+ if node[field]
68
+ content += content_tag(:div, class: "plan-detail") do
69
+ content_tag(:span, "#{indent} #{field}: #{node[field]}", class: "detail-text")
70
+ end
71
+ end
72
+ end
73
+
74
+ content
75
+ end
76
+
77
+ # Recursively render child nodes
78
+ if node["Plans"] && node["Plans"].any?
79
+ node["Plans"].each do |child_plan|
80
+ result += render_plan_node(child_plan, level + 1)
81
+ end
82
+ end
83
+
84
+ result
85
+ end
86
+
87
+ def render_plan_node_modern(node, level = 0)
88
+ return "".html_safe unless node
89
+
90
+ # Get node type and determine color/icon
91
+ node_type = node["Node Type"] || "Unknown"
92
+ operation_class = get_operation_class(node_type)
93
+ operation_icon = get_operation_icon(node_type)
94
+
95
+ # Build timing info
96
+ timing_info = []
97
+ if node["Actual Total Time"]
98
+ timing_info << "#{node['Actual Total Time']}ms"
99
+ end
100
+ if node["Actual Rows"]
101
+ timing_info << "#{node['Actual Rows']} rows"
102
+ end
103
+
104
+ # Build cost info
105
+ cost_info = node["Total Cost"] ? node["Total Cost"].round(2) : nil
106
+
107
+ result = content_tag(:div, class: "plan-node-modern level-#{level} #{operation_class}") do
108
+ content = ""
109
+
110
+ # Tree connector
111
+ if level > 0
112
+ content += content_tag(:div, class: "tree-connector") do
113
+ "├─".html_safe
114
+ end
115
+ end
116
+
117
+ # Node card
118
+ content += content_tag(:div, class: "node-card") do
119
+ card_content = ""
120
+
121
+ # Header row
122
+ card_content += content_tag(:div, class: "node-header") do
123
+ header_content = content_tag(:span, operation_icon, class: "node-icon")
124
+ header_content += content_tag(:span, node_type, class: "node-type")
125
+
126
+ if node["Relation Name"]
127
+ header_content += content_tag(:span, "#{node['Relation Name']}", class: "relation-name")
128
+ end
129
+
130
+ if timing_info.any?
131
+ header_content += content_tag(:div, timing_info.join(" • "), class: "timing-badge")
132
+ end
133
+
134
+ header_content
135
+ end
136
+
137
+ # Details row (if present)
138
+ details = []
139
+ if cost_info
140
+ details << "Cost: #{cost_info}"
141
+ end
142
+ if node["Filter"]
143
+ details << "Filter: #{truncate_filter(node['Filter'])}"
144
+ end
145
+ if node["Sort Key"]
146
+ details << "Sort: #{node['Sort Key']}"
147
+ end
148
+ if node["Hash Cond"]
149
+ details << "Join: #{truncate_filter(node['Hash Cond'])}"
150
+ end
151
+
152
+ if details.any?
153
+ card_content += content_tag(:div, class: "node-details") do
154
+ details.map { |detail| content_tag(:span, detail, class: "detail-item") }.join(" • ").html_safe
155
+ end
156
+ end
157
+
158
+ card_content.html_safe
159
+ end
160
+
161
+ content.html_safe
162
+ end
163
+
164
+ # Recursively render child nodes
165
+ if node["Plans"] && node["Plans"].any?
166
+ node["Plans"].each do |child_plan|
167
+ result += render_plan_node_modern(child_plan, level + 1)
168
+ end
169
+ end
170
+
171
+ result
172
+ end
173
+
174
+ private
175
+
176
+ def get_operation_class(node_type)
177
+ case node_type.downcase
178
+ when /seq scan/ then "op-seq-scan"
179
+ when /index.*scan/ then "op-index-scan"
180
+ when /hash/ then "op-hash"
181
+ when /sort/ then "op-sort"
182
+ when /aggregate/ then "op-aggregate"
183
+ when /limit/ then "op-limit"
184
+ when /join/ then "op-join"
185
+ else "op-other"
186
+ end
187
+ end
188
+
189
+ def get_operation_icon(node_type)
190
+ case node_type.downcase
191
+ when /seq scan/ then "\u{1F50D}"
192
+ when /index.*scan/ then "\u{1F3F7}\uFE0F"
193
+ when /hash.*join/ then "\u{1F517}"
194
+ when /hash/ then "#\uFE0F\u20E3"
195
+ when /sort/ then "\u2195\uFE0F"
196
+ when /aggregate/ then "\u{1F4CA}"
197
+ when /limit/ then "\u2702\uFE0F"
198
+ when /join/ then "\u{1F517}"
199
+ else "\u2699\uFE0F"
200
+ end
201
+ end
202
+
203
+ def truncate_filter(text)
204
+ return text unless text
205
+ text.length > 50 ? "#{text[0..47]}..." : text
206
+ end
207
+
208
+ def get_performance_rating(total_time_ms)
209
+ return "Unknown" unless total_time_ms
210
+
211
+ case total_time_ms
212
+ when 0..50
213
+ "\u{1F680} Excellent"
214
+ when 51..200
215
+ "\u2705 Good"
216
+ when 201..1000
217
+ "\u26A0\uFE0F Fair"
218
+ else
219
+ "\u{1F40C} Slow"
220
+ end
221
+ end
222
+
223
+ def render_plan_node_compact(node, level = 0)
224
+ return "".html_safe unless node
225
+
226
+ node_type = node["Node Type"] || "Unknown"
227
+ relation = node["Relation Name"]
228
+ timing = node["Actual Total Time"] ? "#{node['Actual Total Time']}ms" : nil
229
+ cost = node["Total Cost"] ? node["Total Cost"].round(1) : nil
230
+ rows = node["Actual Rows"]
231
+
232
+ # Build compact display
233
+ display_parts = [ node_type ]
234
+ display_parts << relation if relation
235
+
236
+ metrics = []
237
+ metrics << "#{timing}" if timing
238
+ metrics << "#{rows} rows" if rows
239
+ metrics << "cost: #{cost}" if cost
240
+
241
+ # Get visual styling
242
+ node_class = get_node_visual_class(node_type)
243
+ icon = get_node_compact_icon(node_type)
244
+
245
+ result = content_tag(:div, class: "plan-node-compact #{node_class} level-#{level}") do
246
+ content = ""
247
+
248
+ # Flow connector for child nodes
249
+ if level > 0
250
+ content += content_tag(:div, "└─", class: "flow-connector")
251
+ end
252
+
253
+ # Node content
254
+ content += content_tag(:div, class: "node-content") do
255
+ node_content = content_tag(:div, class: "node-header") do
256
+ header = content_tag(:span, icon, class: "node-icon")
257
+ header += content_tag(:span, display_parts.join(" "), class: "node-title")
258
+ header
259
+ end
260
+
261
+ if metrics.any?
262
+ node_content += content_tag(:div, metrics.join(" • "), class: "node-metrics")
263
+ end
264
+
265
+ # Show important details compactly
266
+ details = []
267
+ details << "Filter: #{truncate_text(node['Filter'], 30)}" if node["Filter"]
268
+ details << "Sort: #{node['Sort Key']}" if node["Sort Key"]
269
+ details << "Join: #{truncate_text(node['Hash Cond'], 30)}" if node["Hash Cond"]
270
+
271
+ if details.any?
272
+ node_content += content_tag(:div, details.join(" | "), class: "node-details-compact")
273
+ end
274
+
275
+ node_content
276
+ end
277
+
278
+ content.html_safe
279
+ end
280
+
281
+ # Render children with indentation
282
+ if node["Plans"] && node["Plans"].any?
283
+ node["Plans"].each do |child_plan|
284
+ result += render_plan_node_compact(child_plan, level + 1)
285
+ end
286
+ end
287
+
288
+ result
289
+ end
290
+
291
+ def get_node_visual_class(node_type)
292
+ case node_type.downcase
293
+ when /seq scan/ then "node-scan-seq"
294
+ when /index.*scan/ then "node-scan-index"
295
+ when /hash join/ then "node-join"
296
+ when /sort/ then "node-sort"
297
+ when /aggregate/ then "node-aggregate"
298
+ when /limit/ then "node-limit"
299
+ else "node-other"
300
+ end
301
+ end
302
+
303
+ def get_node_compact_icon(node_type)
304
+ case node_type.downcase
305
+ when /seq scan/ then "\u{1F50D}"
306
+ when /index.*scan/ then "\u{1F3F7}\uFE0F"
307
+ when /hash join/ then "\u{1F517}"
308
+ when /sort/ then "\u2195\uFE0F"
309
+ when /aggregate/ then "\u{1F4CA}"
310
+ when /limit/ then "\u2702\uFE0F"
311
+ else "\u2699\uFE0F"
312
+ end
313
+ end
314
+
315
+ def truncate_text(text, max_length)
316
+ return text unless text
317
+ text.length > max_length ? "#{text[0..max_length-3]}..." : text
318
+ end
319
+
320
+ def timing_percentage(time, total_time)
321
+ return 0 unless time && total_time && total_time > 0
322
+ [ (time / total_time * 100).round, 100 ].min
323
+ end
324
+
325
+ def calculate_efficiency_score(execution)
326
+ rows = execution.result_rows_count
327
+
328
+ # If no result rows, try to get from execution plan
329
+ if !rows || rows == 0
330
+ if execution.execution_plan.present?
331
+ plan_data = execution.execution_plan.is_a?(Array) ? execution.execution_plan[0] : execution.execution_plan
332
+ if plan_data && plan_data["Plan"]
333
+ rows = plan_data["Plan"]["Actual Rows"]
334
+ end
335
+ end
336
+ end
337
+
338
+ return "N/A" unless execution.query_cost && rows && rows > 0
339
+ score = execution.query_cost / rows
340
+ score.round(2)
341
+ end
3
342
  end
4
343
  end