rails_vitals 0.2.0 → 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 +4 -4
- data/README.md +6 -0
- data/app/assets/javascripts/rails_vitals/application.js +161 -0
- data/app/assets/stylesheets/rails_vitals/application.css +175 -0
- data/app/controllers/rails_vitals/explains_controller.rb +16 -0
- data/app/controllers/rails_vitals/n_plus_ones_controller.rb +0 -1
- data/app/controllers/rails_vitals/requests_controller.rb +1 -1
- data/app/helpers/rails_vitals/application_helper.rb +28 -0
- data/app/views/layouts/rails_vitals/application.html.erb +1 -0
- data/app/views/rails_vitals/associations/index.html.erb +41 -189
- data/app/views/rails_vitals/dashboard/index.html.erb +6 -6
- data/app/views/rails_vitals/explains/_plan_node.html.erb +137 -0
- data/app/views/rails_vitals/explains/show.html.erb +186 -0
- data/app/views/rails_vitals/heatmap/index.html.erb +7 -7
- data/app/views/rails_vitals/models/index.html.erb +19 -36
- data/app/views/rails_vitals/n_plus_ones/index.html.erb +9 -9
- data/app/views/rails_vitals/n_plus_ones/show.html.erb +30 -76
- data/app/views/rails_vitals/requests/index.html.erb +5 -5
- data/app/views/rails_vitals/requests/show.html.erb +82 -165
- data/config/routes.rb +1 -0
- data/lib/rails_vitals/analyzers/explain_analyzer.rb +347 -0
- data/lib/rails_vitals/collector.rb +2 -1
- data/lib/rails_vitals/notifications/subscriber.rb +2 -1
- data/lib/rails_vitals/version.rb +1 -1
- data/lib/rails_vitals.rb +1 -0
- metadata +8 -9
|
@@ -3,52 +3,42 @@
|
|
|
3
3
|
<%# Left — SVG diagram %>
|
|
4
4
|
<div style="flex:1;overflow:auto;padding:24px;background:#1a202c;">
|
|
5
5
|
|
|
6
|
-
<div class="card-title
|
|
6
|
+
<div class="card-title mb-20">
|
|
7
7
|
Association Map
|
|
8
|
-
<span class="card-title-description">
|
|
9
|
-
click any node to inspect
|
|
10
|
-
</span>
|
|
8
|
+
<span class="card-title-description">click any node to inspect</span>
|
|
11
9
|
</div>
|
|
12
10
|
|
|
13
11
|
<%# Legend %>
|
|
14
|
-
<div
|
|
12
|
+
<div class="flex flex-wrap mb-20 text-12" style="gap:20px;">
|
|
15
13
|
<span>
|
|
16
|
-
<span style="
|
|
14
|
+
<span class="legend-dot" style="background:#68d391;"></span>
|
|
17
15
|
Healthy
|
|
18
16
|
</span>
|
|
19
17
|
<span>
|
|
20
|
-
<span style="
|
|
18
|
+
<span class="legend-dot" style="background:#fc8181;"></span>
|
|
21
19
|
N+1 Detected
|
|
22
20
|
</span>
|
|
23
21
|
<span>
|
|
24
|
-
<span style="
|
|
22
|
+
<span class="legend-dot" style="background:#4a5568;"></span>
|
|
25
23
|
Not Queried
|
|
26
24
|
</span>
|
|
27
|
-
<span
|
|
28
|
-
<span
|
|
29
|
-
<span
|
|
30
|
-
<span
|
|
31
|
-
<span
|
|
25
|
+
<span class="text-muted">——</span>
|
|
26
|
+
<span class="text-orange">has_many / has_one</span>
|
|
27
|
+
<span class="text-muted">——</span>
|
|
28
|
+
<span class="text-purple">belongs_to</span>
|
|
29
|
+
<span class="text-muted ml-8">⚠ missing index on FK</span>
|
|
32
30
|
</div>
|
|
33
31
|
|
|
34
|
-
<svg
|
|
35
|
-
id="assoc-map"
|
|
36
|
-
width="900"
|
|
37
|
-
height="<%= @canvas_height %>"
|
|
38
|
-
style="display:block;"
|
|
39
|
-
>
|
|
32
|
+
<svg id="assoc-map" width="900" height="<%= @canvas_height %>" style="display:block;">
|
|
40
33
|
<defs>
|
|
41
34
|
<%# Arrowhead markers %>
|
|
42
|
-
<marker id="arrow-hasmany" markerWidth="8" markerHeight="8"
|
|
43
|
-
refX="6" refY="3" orient="auto">
|
|
35
|
+
<marker id="arrow-hasmany" markerWidth="8" markerHeight="8" refX="6" refY="3" orient="auto">
|
|
44
36
|
<path d="M0,0 L0,6 L8,3 z" fill="#f6ad55" opacity="0.7"/>
|
|
45
37
|
</marker>
|
|
46
|
-
<marker id="arrow-belongsto" markerWidth="8" markerHeight="8"
|
|
47
|
-
refX="6" refY="3" orient="auto">
|
|
38
|
+
<marker id="arrow-belongsto" markerWidth="8" markerHeight="8" refX="6" refY="3" orient="auto">
|
|
48
39
|
<path d="M0,0 L0,6 L8,3 z" fill="#9f7aea" opacity="0.7"/>
|
|
49
40
|
</marker>
|
|
50
|
-
<marker id="arrow-n1" markerWidth="8" markerHeight="8"
|
|
51
|
-
refX="6" refY="3" orient="auto">
|
|
41
|
+
<marker id="arrow-n1" markerWidth="8" markerHeight="8" refX="6" refY="3" orient="auto">
|
|
52
42
|
<path d="M0,0 L0,6 L8,3 z" fill="#fc8181" opacity="0.9"/>
|
|
53
43
|
</marker>
|
|
54
44
|
</defs>
|
|
@@ -113,11 +103,7 @@
|
|
|
113
103
|
<% end %>
|
|
114
104
|
|
|
115
105
|
<%# Clickable group %>
|
|
116
|
-
<g
|
|
117
|
-
onclick="selectNode('<%= node.name.to_json %>')"
|
|
118
|
-
style="cursor:pointer;"
|
|
119
|
-
id="node-<%= node.name %>"
|
|
120
|
-
>
|
|
106
|
+
<g onclick="selectNode('<%= node.name.to_json %>')" class="cursor-pointer" id="node-<%= node.name %>">
|
|
121
107
|
<%# Node box %>
|
|
122
108
|
<rect
|
|
123
109
|
x="<%= x - 54 %>" y="<%= y - 28 %>"
|
|
@@ -141,33 +127,20 @@
|
|
|
141
127
|
</text>
|
|
142
128
|
|
|
143
129
|
<%# Query count %>
|
|
144
|
-
<text
|
|
145
|
-
x="<%= x %>" y="<%= y + 8 %>"
|
|
146
|
-
text-anchor="middle"
|
|
147
|
-
font-size="10"
|
|
148
|
-
font-family="monospace"
|
|
149
|
-
fill="#a0aec0"
|
|
150
|
-
>
|
|
130
|
+
<text x="<%= x %>" y="<%= y + 8 %>" text-anchor="middle" font-size="10" font-family="monospace" fill="#a0aec0">
|
|
151
131
|
<%= node.query_count %> queries
|
|
152
132
|
</text>
|
|
153
133
|
|
|
154
134
|
<%# Avg time %>
|
|
155
|
-
<text
|
|
156
|
-
|
|
157
|
-
text-anchor="middle"
|
|
158
|
-
font-size="10"
|
|
159
|
-
font-family="monospace"
|
|
160
|
-
fill="<%= node.avg_query_time_ms > 10 ? '#f6ad55' : '#718096' %>"
|
|
161
|
-
>
|
|
135
|
+
<text x="<%= x %>" y="<%= y + 20 %>" text-anchor="middle" font-size="10" font-family="monospace"
|
|
136
|
+
fill="<%= node.avg_query_time_ms > 10 ? '#f6ad55' : '#718096' %>">
|
|
162
137
|
avg <%= node.avg_query_time_ms %>ms
|
|
163
138
|
</text>
|
|
164
139
|
|
|
165
140
|
<%# N+1 badge %>
|
|
166
141
|
<% if node.has_n1 %>
|
|
167
142
|
<circle cx="<%= x + 50 %>" cy="<%= y - 24 %>" r="7" fill="#fc8181"/>
|
|
168
|
-
<text x="<%= x + 50 %>" y="<%= y - 20 %>"
|
|
169
|
-
text-anchor="middle" font-size="9"
|
|
170
|
-
font-weight="bold" fill="#1a202c">N+1</text>
|
|
143
|
+
<text x="<%= x + 50 %>" y="<%= y - 20 %>" text-anchor="middle" font-size="9" font-weight="bold" fill="#1a202c">N+1</text>
|
|
171
144
|
<% end %>
|
|
172
145
|
</g>
|
|
173
146
|
<% end %>
|
|
@@ -177,63 +150,39 @@
|
|
|
177
150
|
<%# Right — slide-in detail panel %>
|
|
178
151
|
<div
|
|
179
152
|
id="assoc-panel"
|
|
180
|
-
style="
|
|
181
|
-
width:0;
|
|
182
|
-
overflow:hidden;
|
|
183
|
-
background:#2d3748;
|
|
184
|
-
border-left:1px solid #4a5568;
|
|
185
|
-
transition:width 0.2s ease;
|
|
186
|
-
flex-shrink:0;
|
|
187
|
-
"
|
|
153
|
+
style="width:0;overflow:hidden;background:#2d3748;border-left:1px solid #4a5568;transition:width 0.2s ease;flex-shrink:0;"
|
|
188
154
|
>
|
|
189
155
|
<div id="assoc-panel-inner" style="width:320px;padding:20px;display:none;">
|
|
190
156
|
|
|
191
|
-
<div
|
|
192
|
-
<span id="panel-model-name"
|
|
193
|
-
|
|
194
|
-
</span>
|
|
195
|
-
<button
|
|
196
|
-
onclick="closePanel()"
|
|
197
|
-
style="background:none;border:none;color:#a0aec0;font-size:18px;cursor:pointer;padding:0;"
|
|
198
|
-
>✕</button>
|
|
157
|
+
<div class="flex-between mb-16">
|
|
158
|
+
<span id="panel-model-name" class="text-primary bold mono text-16"></span>
|
|
159
|
+
<button onclick="closePanel()" class="text-muted cursor-pointer" style="background:none;border:none;font-size:18px;padding:0;">✕</button>
|
|
199
160
|
</div>
|
|
200
161
|
|
|
201
162
|
<%# Stats row %>
|
|
202
|
-
<div style="
|
|
203
|
-
<div
|
|
204
|
-
<div id="panel-query-count"
|
|
205
|
-
|
|
206
|
-
</div>
|
|
207
|
-
<div style="font-size:10px;color:#a0aec0;margin-top:2px;">queries</div>
|
|
163
|
+
<div class="flex mb-20" style="gap:12px;">
|
|
164
|
+
<div class="stat-box">
|
|
165
|
+
<div id="panel-query-count" class="stat-value-lg text-blue"></div>
|
|
166
|
+
<div class="text-muted text-10 mt-2">queries</div>
|
|
208
167
|
</div>
|
|
209
|
-
<div
|
|
210
|
-
<div id="panel-avg-time"
|
|
211
|
-
|
|
212
|
-
</div>
|
|
213
|
-
<div style="font-size:10px;color:#a0aec0;margin-top:2px;">avg ms</div>
|
|
168
|
+
<div class="stat-box">
|
|
169
|
+
<div id="panel-avg-time" class="stat-value-lg text-healthy"></div>
|
|
170
|
+
<div class="text-muted text-10 mt-2">avg ms</div>
|
|
214
171
|
</div>
|
|
215
|
-
<div
|
|
216
|
-
<div id="panel-n1-count"
|
|
217
|
-
|
|
218
|
-
</div>
|
|
219
|
-
<div style="font-size:10px;color:#a0aec0;margin-top:2px;">N+1 patterns</div>
|
|
172
|
+
<div class="stat-box">
|
|
173
|
+
<div id="panel-n1-count" class="stat-value-lg"></div>
|
|
174
|
+
<div class="text-muted text-10 mt-2">N+1 patterns</div>
|
|
220
175
|
</div>
|
|
221
176
|
</div>
|
|
222
177
|
|
|
223
178
|
<%# Associations list %>
|
|
224
|
-
<div
|
|
225
|
-
|
|
226
|
-
Associations
|
|
227
|
-
</div>
|
|
228
|
-
<div id="panel-associations" style="margin-bottom:20px;"></div>
|
|
179
|
+
<div class="text-muted text-upper text-sm mb-8">Associations</div>
|
|
180
|
+
<div id="panel-associations" class="mb-20"></div>
|
|
229
181
|
|
|
230
182
|
<%# N+1 patterns %>
|
|
231
|
-
<div id="panel-n1-section"
|
|
232
|
-
<div
|
|
233
|
-
|
|
234
|
-
N+1 Patterns
|
|
235
|
-
</div>
|
|
236
|
-
<div id="panel-n1-list" style="margin-bottom:20px;"></div>
|
|
183
|
+
<div id="panel-n1-section" class="d-none">
|
|
184
|
+
<div class="text-danger text-upper text-sm mb-8">N+1 Patterns</div>
|
|
185
|
+
<div id="panel-n1-list" class="mb-20"></div>
|
|
237
186
|
</div>
|
|
238
187
|
|
|
239
188
|
<%# Action links %>
|
|
@@ -243,7 +192,7 @@
|
|
|
243
192
|
</div>
|
|
244
193
|
</div>
|
|
245
194
|
|
|
246
|
-
<%#
|
|
195
|
+
<%# Data bridge — JSON for selectNode() / closePanel() defined in rails_vitals/application.js %>
|
|
247
196
|
<script>
|
|
248
197
|
var NODE_DATA = <%= raw @nodes.map { |n| [
|
|
249
198
|
n.name,
|
|
@@ -268,103 +217,6 @@
|
|
|
268
217
|
}
|
|
269
218
|
] }.to_h.to_json %>;
|
|
270
219
|
|
|
271
|
-
var N1_PATH
|
|
220
|
+
var N1_PATH = "<%= rails_vitals.n_plus_ones_path %>";
|
|
272
221
|
var REQUEST_PATH = "<%= rails_vitals.requests_path %>";
|
|
273
|
-
|
|
274
|
-
function selectNode(nameJson) {
|
|
275
|
-
var name = JSON.parse(nameJson);
|
|
276
|
-
var node = NODE_DATA[name];
|
|
277
|
-
if (!node) return;
|
|
278
|
-
|
|
279
|
-
// Highlight selected node
|
|
280
|
-
document.querySelectorAll('[id^="node-"]').forEach(function(el) {
|
|
281
|
-
el.style.opacity = '0.4';
|
|
282
|
-
});
|
|
283
|
-
var el = document.getElementById('node-' + name);
|
|
284
|
-
if (el) el.style.opacity = '1';
|
|
285
|
-
|
|
286
|
-
// Populate panel
|
|
287
|
-
document.getElementById('panel-model-name').textContent = node.name;
|
|
288
|
-
document.getElementById('panel-query-count').textContent = node.query_count;
|
|
289
|
-
document.getElementById('panel-avg-time').textContent = node.avg_query_time_ms;
|
|
290
|
-
|
|
291
|
-
var n1Count = node.n1_patterns.length;
|
|
292
|
-
var n1El = document.getElementById('panel-n1-count');
|
|
293
|
-
n1El.textContent = n1Count;
|
|
294
|
-
n1El.style.color = n1Count > 0 ? '#fc8181' : '#68d391';
|
|
295
|
-
|
|
296
|
-
// Associations list
|
|
297
|
-
var assocHtml = '';
|
|
298
|
-
node.associations.forEach(function(a) {
|
|
299
|
-
var macroColor = a.macro === 'belongs_to' ? '#9f7aea' : '#f6ad55';
|
|
300
|
-
var n1Badge = a.has_n1
|
|
301
|
-
? '<span style="background:#fc818133;color:#fc8181;font-size:9px;padding:1px 5px;border-radius:3px;margin-left:4px;">N+1</span>'
|
|
302
|
-
: '';
|
|
303
|
-
var indexBadge = a.indexed
|
|
304
|
-
? '<span style="background:#68d39133;color:#68d391;font-size:9px;padding:1px 5px;border-radius:3px;margin-left:4px;">indexed</span>'
|
|
305
|
-
: '<span style="background:#f6ad5533;color:#f6ad55;font-size:9px;padding:1px 5px;border-radius:3px;margin-left:4px;">⚠ no index</span>';
|
|
306
|
-
|
|
307
|
-
assocHtml +=
|
|
308
|
-
'<div style="padding:8px;background:#1a202c;border-radius:4px;margin-bottom:6px;font-size:12px;">' +
|
|
309
|
-
'<span style="color:' + macroColor + ';font-family:monospace;">' + a.macro + '</span>' +
|
|
310
|
-
' <span style="color:#e2e8f0;font-family:monospace;">:' + a.to_model.toLowerCase() + '</span>' +
|
|
311
|
-
n1Badge +
|
|
312
|
-
'<div style="color:#718096;font-size:10px;margin-top:4px;font-family:monospace;">' +
|
|
313
|
-
'fk: ' + a.foreign_key + indexBadge +
|
|
314
|
-
'</div>' +
|
|
315
|
-
'</div>';
|
|
316
|
-
});
|
|
317
|
-
document.getElementById('panel-associations').innerHTML =
|
|
318
|
-
assocHtml || '<div style="color:#718096;font-size:12px;">No associations</div>';
|
|
319
|
-
|
|
320
|
-
// N+1 section
|
|
321
|
-
var n1Section = document.getElementById('panel-n1-section');
|
|
322
|
-
if (n1Count > 0) {
|
|
323
|
-
n1Section.style.display = 'block';
|
|
324
|
-
var n1Html = '';
|
|
325
|
-
node.n1_patterns.forEach(function(p) {
|
|
326
|
-
n1Html +=
|
|
327
|
-
'<div style="padding:8px;background:#2d1515;border:1px solid #fc818144;' +
|
|
328
|
-
'border-radius:4px;margin-bottom:6px;font-size:11px;">' +
|
|
329
|
-
'<div style="color:#fc8181;font-family:monospace;margin-bottom:4px;">' +
|
|
330
|
-
p.occurrences + 'x detected' +
|
|
331
|
-
'</div>' +
|
|
332
|
-
(p.fix_suggestion
|
|
333
|
-
? '<div style="color:#68d391;font-family:monospace;">Fix: ' + p.fix_suggestion + '</div>'
|
|
334
|
-
: '') +
|
|
335
|
-
'</div>';
|
|
336
|
-
});
|
|
337
|
-
document.getElementById('panel-n1-list').innerHTML = n1Html;
|
|
338
|
-
} else {
|
|
339
|
-
n1Section.style.display = 'none';
|
|
340
|
-
}
|
|
341
|
-
|
|
342
|
-
// Action links
|
|
343
|
-
var linksHtml = ""
|
|
344
|
-
|
|
345
|
-
if (n1Count > 0) {
|
|
346
|
-
linksHtml +=
|
|
347
|
-
'<a href="' + N1_PATH + '" ' +
|
|
348
|
-
'style="display:block;background:#2d1515;border:1px solid #fc818166;' +
|
|
349
|
-
'color:#fc8181;padding:8px 12px;border-radius:4px;font-size:12px;' +
|
|
350
|
-
'text-decoration:none;text-align:center;margin-top:8px;">' +
|
|
351
|
-
'View N+1 patterns →' +
|
|
352
|
-
'</a>';
|
|
353
|
-
}
|
|
354
|
-
document.getElementById('panel-links').innerHTML = linksHtml;
|
|
355
|
-
|
|
356
|
-
// Open panel
|
|
357
|
-
var panel = document.getElementById('assoc-panel');
|
|
358
|
-
var inner = document.getElementById('assoc-panel-inner');
|
|
359
|
-
panel.style.width = '320px';
|
|
360
|
-
inner.style.display = 'block';
|
|
361
|
-
}
|
|
362
|
-
|
|
363
|
-
function closePanel() {
|
|
364
|
-
document.getElementById('assoc-panel').style.width = '0';
|
|
365
|
-
document.getElementById('assoc-panel-inner').style.display = 'none';
|
|
366
|
-
document.querySelectorAll('[id^="node-"]').forEach(function(el) {
|
|
367
|
-
el.style.opacity = '1';
|
|
368
|
-
});
|
|
369
|
-
}
|
|
370
222
|
</script>
|
|
@@ -44,7 +44,7 @@
|
|
|
44
44
|
<% if r.n_plus_one_patterns.any? %>
|
|
45
45
|
<span class="n1-badge"><%= r.n_plus_one_patterns.size %></span>
|
|
46
46
|
<% else %>
|
|
47
|
-
<span
|
|
47
|
+
<span class="text-healthy">None</span>
|
|
48
48
|
<% end %>
|
|
49
49
|
</td>
|
|
50
50
|
<td><%= r.duration_ms&.round(1) %>ms</td>
|
|
@@ -82,7 +82,7 @@
|
|
|
82
82
|
</div>
|
|
83
83
|
|
|
84
84
|
<% if @records.any? %>
|
|
85
|
-
<div
|
|
85
|
+
<div class="grid-3">
|
|
86
86
|
|
|
87
87
|
<div class="card">
|
|
88
88
|
<div class="card-title">Score Distribution</div>
|
|
@@ -99,7 +99,7 @@
|
|
|
99
99
|
<tr>
|
|
100
100
|
<td><%= label %></td>
|
|
101
101
|
<td><%= count %></td>
|
|
102
|
-
<td
|
|
102
|
+
<td class="text-muted">
|
|
103
103
|
<%= @total > 0 ? ((count.to_f / @total) * 100).round(1) : 0 %>%
|
|
104
104
|
</td>
|
|
105
105
|
</tr>
|
|
@@ -120,7 +120,7 @@
|
|
|
120
120
|
<tbody>
|
|
121
121
|
<% @health_trend.each do |endpoint, score| %>
|
|
122
122
|
<tr>
|
|
123
|
-
<td
|
|
123
|
+
<td class="text-muted text-sm"><%= endpoint %></td>
|
|
124
124
|
<td>
|
|
125
125
|
<span class="badge badge-<%= score_label_to_color(score) %>">
|
|
126
126
|
<%= score %>
|
|
@@ -145,9 +145,9 @@
|
|
|
145
145
|
<tbody>
|
|
146
146
|
<% @query_volume.each do |label, stats| %>
|
|
147
147
|
<tr>
|
|
148
|
-
<td
|
|
148
|
+
<td class="text-muted text-sm"><%= label %></td>
|
|
149
149
|
<td><%= stats[:queries] %></td>
|
|
150
|
-
<td
|
|
150
|
+
<td class="text-muted"><%= stats[:db_time] %>ms</td>
|
|
151
151
|
</tr>
|
|
152
152
|
<% end %>
|
|
153
153
|
</tbody>
|
|
@@ -0,0 +1,137 @@
|
|
|
1
|
+
<% meta = node.metadata %>
|
|
2
|
+
<% node_id = "node_#{SecureRandom.hex(4)}" %>
|
|
3
|
+
|
|
4
|
+
<div class="<%= depth > 0 ? 'plan-node' : '' %>">
|
|
5
|
+
<%# Node box — clickable %>
|
|
6
|
+
<div
|
|
7
|
+
class="node-box"
|
|
8
|
+
onclick="toggleExplanation(<%= j(node_id).to_json %>)"
|
|
9
|
+
style="background:<%= meta[:color] %>18;border:1px solid <%= meta[:color] %>44;"
|
|
10
|
+
>
|
|
11
|
+
<div class="flex-between flex-wrap" style="gap:8px;">
|
|
12
|
+
<%# Left — type + relation %>
|
|
13
|
+
<div class="flex-center" style="gap:10px;">
|
|
14
|
+
<span
|
|
15
|
+
class="mono bold text-12"
|
|
16
|
+
style="background:<%= meta[:color] %>33;color:<%= meta[:color] %>;padding:3px 10px;border-radius:4px;"
|
|
17
|
+
>
|
|
18
|
+
<%= meta[:label] %>
|
|
19
|
+
</span>
|
|
20
|
+
|
|
21
|
+
<% if node.relation %>
|
|
22
|
+
<span class="text-primary mono text-12">
|
|
23
|
+
<%= node.relation %>
|
|
24
|
+
|
|
25
|
+
<% if node.alias_name && node.alias_name != node.relation %>
|
|
26
|
+
<span class="text-grey">(<%= node.alias_name %>)</span>
|
|
27
|
+
<% end %>
|
|
28
|
+
</span>
|
|
29
|
+
<% end %>
|
|
30
|
+
|
|
31
|
+
<% if node.index_name %>
|
|
32
|
+
<span class="text-purple mono text-sm">
|
|
33
|
+
via <%= node.index_name %>
|
|
34
|
+
</span>
|
|
35
|
+
<% end %>
|
|
36
|
+
</div>
|
|
37
|
+
|
|
38
|
+
<%# Right — cost + timing + rows %>
|
|
39
|
+
<div class="flex mono text-sm" style="gap:16px;">
|
|
40
|
+
<div class="text-right">
|
|
41
|
+
<div class="text-muted">cost</div>
|
|
42
|
+
<div style="color:<%= cost_color(node.total_cost) %>;"><%= node.total_cost %></div>
|
|
43
|
+
</div>
|
|
44
|
+
|
|
45
|
+
<% if node.actual_total_ms %>
|
|
46
|
+
<div class="text-right">
|
|
47
|
+
<div class="text-muted">actual</div>
|
|
48
|
+
<div style="color:<%= time_color(node.actual_total_ms) %>;"><%= node.actual_total_ms %>ms</div>
|
|
49
|
+
</div>
|
|
50
|
+
<% end %>
|
|
51
|
+
|
|
52
|
+
<div class="text-right">
|
|
53
|
+
<% if node.children.empty? %>
|
|
54
|
+
<%# Leaf node — show planner accuracy %>
|
|
55
|
+
<div class="text-muted">est → actual</div>
|
|
56
|
+
<div class="mono">
|
|
57
|
+
<span class="text-grey"><%= node.plan_rows %></span>
|
|
58
|
+
<span style="color:#4a5568;"> → </span>
|
|
59
|
+
<% accuracy_color = node.plan_rows.to_i > 0 &&
|
|
60
|
+
(node.actual_rows.to_f / node.plan_rows.to_f) > 10 ? "#fc8181" : "#68d391" %>
|
|
61
|
+
<span style="color:<%= accuracy_color %>;"><%= node.actual_rows %></span>
|
|
62
|
+
</div>
|
|
63
|
+
<% else %>
|
|
64
|
+
<%# Intermediate node — show loops %>
|
|
65
|
+
<div class="text-muted">loops</div>
|
|
66
|
+
<div class="mono" style="color:<%= node.loops.to_i > 10 ? '#fc8181' : '#e2e8f0' %>;">
|
|
67
|
+
<%= node.loops || 1 %>
|
|
68
|
+
</div>
|
|
69
|
+
<% end %>
|
|
70
|
+
</div>
|
|
71
|
+
|
|
72
|
+
<div class="text-right">
|
|
73
|
+
<div class="text-muted">width</div>
|
|
74
|
+
<div class="mono" style="color:<%= node.plan_width.to_i > 200 ? '#f6ad55' : '#718096' %>;">
|
|
75
|
+
<%= node.plan_width %>B
|
|
76
|
+
</div>
|
|
77
|
+
</div>
|
|
78
|
+
</div>
|
|
79
|
+
</div>
|
|
80
|
+
|
|
81
|
+
<%# Index condition or filter %>
|
|
82
|
+
<% if node.index_condition || node.filter %>
|
|
83
|
+
<div class="mono text-grey mt-6 text-10">
|
|
84
|
+
<% if node.index_condition %>
|
|
85
|
+
<span class="text-purple">Index Cond:</span>
|
|
86
|
+
<%= node.index_condition %>
|
|
87
|
+
<% end %>
|
|
88
|
+
|
|
89
|
+
<% if node.filter %>
|
|
90
|
+
<span class="text-orange ml-8">Filter:</span>
|
|
91
|
+
<%= node.filter %>
|
|
92
|
+
<% end %>
|
|
93
|
+
</div>
|
|
94
|
+
<% end %>
|
|
95
|
+
</div>
|
|
96
|
+
|
|
97
|
+
<%# Stale statistics warning, only on leaf nodes with bad estimates %>
|
|
98
|
+
<% if node.children.empty? && node.relation &&
|
|
99
|
+
node.plan_rows.to_i > 0 &&
|
|
100
|
+
(node.actual_rows.to_f / node.plan_rows.to_f) > 10 %>
|
|
101
|
+
<div
|
|
102
|
+
class="text-orange text-sm mt-4"
|
|
103
|
+
style="padding:6px 12px;background:#2d2010;border-radius:4px;"
|
|
104
|
+
>
|
|
105
|
+
⚠ Planner estimated <%= node.plan_rows %> rows, got
|
|
106
|
+
<%= node.actual_rows %>. Run
|
|
107
|
+
<span class="mono">ANALYZE <%= node.relation %></span> to
|
|
108
|
+
refresh table statistics.
|
|
109
|
+
</div>
|
|
110
|
+
<% end %>
|
|
111
|
+
|
|
112
|
+
<%# Education card — hidden by default %>
|
|
113
|
+
<% if meta[:explanation] %>
|
|
114
|
+
<div
|
|
115
|
+
id="<%= node_id %>"
|
|
116
|
+
class="d-none text-primary mb-8 line-relaxed"
|
|
117
|
+
style="background:#2d3748;border-left:3px solid <%= meta[:color] %>;
|
|
118
|
+
border-radius:4px;padding:12px 16px;
|
|
119
|
+
margin-top:-4px;font-size:13px;"
|
|
120
|
+
>
|
|
121
|
+
<div
|
|
122
|
+
class="bold text-upper text-sm mb-6"
|
|
123
|
+
style="color:<%= meta[:color] %>;"
|
|
124
|
+
>
|
|
125
|
+
💡 <%= meta[:label] %>
|
|
126
|
+
</div>
|
|
127
|
+
|
|
128
|
+
<%= meta[:explanation] %>
|
|
129
|
+
</div>
|
|
130
|
+
<% end %>
|
|
131
|
+
|
|
132
|
+
<%# Recurse into children %>
|
|
133
|
+
<% node.children.each do |child| %>
|
|
134
|
+
<%= render partial: "rails_vitals/explains/plan_node",
|
|
135
|
+
locals: { node: child, depth: depth + 1 } %>
|
|
136
|
+
<% end %>
|
|
137
|
+
</div>
|