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.
@@ -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" style="margin-bottom:20px;">
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 style="display:flex;gap:20px;margin-bottom:20px;font-size:12px;flex-wrap:wrap;">
12
+ <div class="flex flex-wrap mb-20 text-12" style="gap:20px;">
15
13
  <span>
16
- <span style="display:inline-block;width:12px;height:12px;background:#68d391;border-radius:2px;margin-right:4px;"></span>
14
+ <span class="legend-dot" style="background:#68d391;"></span>
17
15
  Healthy
18
16
  </span>
19
17
  <span>
20
- <span style="display:inline-block;width:12px;height:12px;background:#fc8181;border-radius:2px;margin-right:4px;"></span>
18
+ <span class="legend-dot" style="background:#fc8181;"></span>
21
19
  N+1 Detected
22
20
  </span>
23
21
  <span>
24
- <span style="display:inline-block;width:12px;height:12px;background:#4a5568;border-radius:2px;margin-right:4px;"></span>
22
+ <span class="legend-dot" style="background:#4a5568;"></span>
25
23
  Not Queried
26
24
  </span>
27
- <span style="color:#a0aec0;">——</span>
28
- <span style="color:#f6ad55;">has_many / has_one</span>
29
- <span style="color:#a0aec0;">——</span>
30
- <span style="color:#9f7aea;">belongs_to</span>
31
- <span style="color:#a0aec0;margin-left:8px;">⚠ missing index on FK</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
- x="<%= x %>" y="<%= y + 20 %>"
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 style="display:flex;justify-content:space-between;align-items:center;margin-bottom:16px;">
192
- <span id="panel-model-name"
193
- style="font-size:16px;font-weight:bold;color:#e2e8f0;font-family:monospace;">
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="display:flex;gap:12px;margin-bottom:20px;">
203
- <div style="flex:1;background:#1a202c;border-radius:4px;padding:10px;text-align:center;">
204
- <div id="panel-query-count"
205
- style="font-size:20px;font-weight:bold;color:#4299e1;font-family:monospace;">
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 style="flex:1;background:#1a202c;border-radius:4px;padding:10px;text-align:center;">
210
- <div id="panel-avg-time"
211
- style="font-size:20px;font-weight:bold;color:#68d391;font-family:monospace;">
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 style="flex:1;background:#1a202c;border-radius:4px;padding:10px;text-align:center;">
216
- <div id="panel-n1-count"
217
- style="font-size:20px;font-weight:bold;font-family:monospace;">
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 style="color:#a0aec0;font-size:10px;text-transform:uppercase;
225
- letter-spacing:0.05em;margin-bottom:8px;">
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" style="display:none;">
232
- <div style="color:#fc8181;font-size:10px;text-transform:uppercase;
233
- letter-spacing:0.05em;margin-bottom:8px;">
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
- <%# Node data for JS embedded as JSON %>
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 = "<%= rails_vitals.n_plus_ones_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 style="color:#68d391;">None</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 style="display:grid;grid-template-columns:repeat(3,1fr);gap:16px;margin-bottom:16px;">
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 style="color:#a0aec0;">
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 style="color:#a0aec0;font-size:11px;"><%= endpoint %></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 style="color:#a0aec0;font-size:11px;"><%= label %></td>
148
+ <td class="text-muted text-sm"><%= label %></td>
149
149
  <td><%= stats[:queries] %></td>
150
- <td style="color:#a0aec0;"><%= stats[:db_time] %>ms</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>