pg_insights 0.3.2 → 0.4.1
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/app/assets/javascripts/pg_insights/application.js +91 -21
- data/app/assets/javascripts/pg_insights/plan_performance.js +53 -0
- data/app/assets/javascripts/pg_insights/query_comparison.js +1129 -0
- data/app/assets/javascripts/pg_insights/results/view_toggles.js +26 -5
- data/app/assets/javascripts/pg_insights/results.js +231 -1
- data/app/assets/stylesheets/pg_insights/analysis.css +2628 -0
- data/app/assets/stylesheets/pg_insights/application.css +51 -1
- data/app/assets/stylesheets/pg_insights/results.css +12 -1
- data/app/controllers/pg_insights/insights_controller.rb +486 -9
- data/app/helpers/pg_insights/application_helper.rb +339 -0
- data/app/helpers/pg_insights/insights_helper.rb +567 -0
- data/app/jobs/pg_insights/query_analysis_job.rb +142 -0
- data/app/models/pg_insights/query_execution.rb +198 -0
- data/app/services/pg_insights/query_analysis_service.rb +269 -0
- data/app/views/layouts/pg_insights/application.html.erb +2 -0
- data/app/views/pg_insights/insights/_compare_view.html.erb +264 -0
- data/app/views/pg_insights/insights/_empty_state.html.erb +9 -0
- data/app/views/pg_insights/insights/_execution_table_view.html.erb +86 -0
- data/app/views/pg_insights/insights/_history_bar.html.erb +33 -0
- data/app/views/pg_insights/insights/_perf_view.html.erb +244 -0
- data/app/views/pg_insights/insights/_plan_nodes.html.erb +12 -0
- data/app/views/pg_insights/insights/_plan_tree.html.erb +30 -0
- data/app/views/pg_insights/insights/_plan_tree_modern.html.erb +12 -0
- data/app/views/pg_insights/insights/_plan_view.html.erb +159 -0
- data/app/views/pg_insights/insights/_query_panel.html.erb +3 -2
- data/app/views/pg_insights/insights/_result.html.erb +19 -4
- data/app/views/pg_insights/insights/_results_info.html.erb +33 -9
- data/app/views/pg_insights/insights/_results_info_empty.html.erb +10 -0
- data/app/views/pg_insights/insights/_results_panel.html.erb +7 -9
- data/app/views/pg_insights/insights/_results_table.html.erb +0 -5
- data/app/views/pg_insights/insights/_visual_view.html.erb +212 -0
- data/app/views/pg_insights/insights/index.html.erb +4 -1
- data/app/views/pg_insights/timeline/compare.html.erb +3 -3
- data/config/routes.rb +6 -0
- data/lib/generators/pg_insights/install_generator.rb +20 -14
- data/lib/generators/pg_insights/templates/db/migrate/create_pg_insights_query_executions.rb +45 -0
- data/lib/pg_insights/engine.rb +8 -0
- data/lib/pg_insights/version.rb +1 -1
- data/lib/pg_insights.rb +30 -2
- 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
|