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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: f17a9a533950167bfbd68780d388d56e95ec38fb442958ddf7228d323190eb24
4
- data.tar.gz: 165535e4b833e3f622965142d6605a7423a85a0a607131eed1a3240c4cea8fe7
3
+ metadata.gz: 85f8d6772f3f10badb2a598143172c1e11375eb017affe72007fe893e2f6d241
4
+ data.tar.gz: 072320c0d3826677abacc3d36cbfd22843be687a955e41c9f924cb07bba640f0
5
5
  SHA512:
6
- metadata.gz: 0d7b5af657229031b4189605e661028ccda2f169f585acc48188d7a34a22aa0ed7422bee6291b79edf52d7cee46aedc403c0a823fa8c1f6139a4925b8a4dd4dc
7
- data.tar.gz: 49d029d3b489e21656b1f750066d759611785b077f9e32c1a42a2d2d784c2abe3efa31728100250d17c651f765072b11cf9c151bdb9998f06d6677bc5752dd05
6
+ metadata.gz: d6648831e36ed7b41615af2636939d1f1dace58b270a311486820ee5d9e48581a5d617d590622855dbf6430dfdbc3ed1716721bb6d03f58e1af8dda8a2fcd969
7
+ data.tar.gz: 204fab1e081f960f358295b56f42666800867133c14eafdcc6074524694110a5de0cef994e65647f504ffea3d90aab42620b861ce1c00e3de31b32e929c40278
data/README.md CHANGED
@@ -56,6 +56,11 @@ Each N+1 pattern has a detail page showing affected requests, estimated query sa
56
56
 
57
57
  ![Impact Simulator](https://github.com/user-attachments/assets/c6eaf195-7652-4319-bd15-476033e64896)
58
58
 
59
+ ### 🔬 EXPLAIN Visualizer
60
+ Any `SELECT` query in Request Detail can be sent directly to PostgreSQL's `EXPLAIN ANALYZE`. The result is rendered as an interactive tree, each node shows its operation type, estimated cost, actual time, row estimates vs. reality, and loop count. Nodes are color-coded by cost. Click any node to expand an education card explaining what the operation does and when it becomes a problem.
61
+
62
+ ![EXPLAIN Visualizer](https://github.com/user-attachments/assets/e3547822-17ce-40e8-a468-714bbff01cd9)
63
+
59
64
  ### 🎭 Callback Map
60
65
  Every ActiveRecord callback (`before_save`, `after_create`, `before_validation`, etc.) is timed and grouped by model in the Request Detail view. Expensive callbacks surface immediately — including hidden side effects like callbacks that trigger additional queries.
61
66
 
@@ -143,6 +148,7 @@ Navigate to `/rails_vitals` to access the full admin interface.
143
148
  | Models | `/rails_vitals/models` | Per-model query breakdown |
144
149
  | N+1 Patterns | `/rails_vitals/n_plus_ones` | Cross-request N+1 aggregation with fix suggestions |
145
150
  | Association Map | `/rails_vitals/associations` | Live SVG model graph with N+1 and index annotations |
151
+ | EXPLAIN Visualizer | `/rails_vitals/requests/:request_id/explain/:query_index` | Interactive PostgreSQL EXPLAIN ANALYZE tree with warnings and fix suggestions |
146
152
 
147
153
  ---
148
154
 
@@ -0,0 +1,161 @@
1
+ // ─── Palette ────────────────────────────────────────────────────────────────
2
+ var COLOR_N1 = '#fc8181'; // red — N+1 warnings
3
+ var COLOR_HEALTHY = '#68d391'; // green — healthy / no issues
4
+ var COLOR_BELONGS = '#9f7aea'; // purple — belongs_to macro
5
+ var COLOR_HAS_MANY = '#f6ad55'; // orange — has_many / has_one macro
6
+ var COLOR_MUTED = '#718096'; // grey — secondary text
7
+ var COLOR_TEXT = '#e2e8f0'; // white-ish — primary text
8
+ var COLOR_SURFACE = '#1a202c'; // dark — card surface
9
+ var COLOR_N1_BG = '#2d1515'; // dark red — N+1 card background
10
+
11
+ // ─── Panel element IDs (shared by selectNode / closePanel) ──────────────────
12
+ var ID_PANEL = 'assoc-panel';
13
+ var ID_PANEL_INNER = 'assoc-panel-inner';
14
+ var ID_MODEL_NAME = 'panel-model-name';
15
+ var ID_QUERY_COUNT = 'panel-query-count';
16
+ var ID_AVG_TIME = 'panel-avg-time';
17
+ var ID_N1_COUNT = 'panel-n1-count';
18
+ var ID_ASSOCIATIONS = 'panel-associations';
19
+ var ID_N1_SECTION = 'panel-n1-section';
20
+ var ID_N1_LIST = 'panel-n1-list';
21
+ var ID_LINKS = 'panel-links';
22
+
23
+ // ─── requests/show ──────────────────────────────────────────────────────────
24
+ function toggleDna(id) {
25
+ var row = document.getElementById(id);
26
+ if (row) {
27
+ var isHidden = window.getComputedStyle(row).display === 'none';
28
+ row.classList.remove('d-none');
29
+ row.style.display = isHidden ? 'table-row' : 'none';
30
+ }
31
+ }
32
+
33
+ function toggleCard(id, chevronId) {
34
+ var card = document.getElementById(id);
35
+ if (card) {
36
+ var isHidden = window.getComputedStyle(card).display === 'none';
37
+ card.classList.remove('d-none');
38
+ card.style.display = isHidden ? (card.tagName === 'TABLE' ? 'table' : 'block') : 'none';
39
+ }
40
+
41
+ if (chevronId) {
42
+ var chevron = document.getElementById(chevronId);
43
+ if (chevron) {
44
+ chevron.textContent = chevron.textContent === '▼' ? '▶' : '▼';
45
+ }
46
+ }
47
+ }
48
+
49
+ // ─── explains/show ──────────────────────────────────────────────────────────
50
+ function toggleExplanation(id) {
51
+ var el = document.getElementById(id);
52
+ if (el) {
53
+ var isHidden = window.getComputedStyle(el).display === 'none';
54
+ el.classList.remove('d-none');
55
+ el.style.display = isHidden ? 'block' : 'none';
56
+ }
57
+ }
58
+
59
+ // ─── associations/index ─────────────────────────────────────────────────────
60
+ // NODE_DATA, N1_PATH, and REQUEST_PATH are injected inline by the view as a data bridge.
61
+
62
+ function selectNode(nameJson) {
63
+ var name = JSON.parse(nameJson);
64
+ var node = NODE_DATA[name];
65
+ if (!node) return;
66
+
67
+ // Highlight selected node
68
+ document.querySelectorAll('[id^="node-"]').forEach(function(el) {
69
+ el.style.opacity = '0.4';
70
+ });
71
+ var el = document.getElementById('node-' + name);
72
+ if (el) el.style.opacity = '1';
73
+
74
+ // Populate panel header
75
+ document.getElementById(ID_MODEL_NAME).textContent = node.name;
76
+ document.getElementById(ID_QUERY_COUNT).textContent = node.query_count;
77
+ document.getElementById(ID_AVG_TIME).textContent = node.avg_query_time_ms;
78
+
79
+ var n1Count = node.n1_patterns.length;
80
+ var n1El = document.getElementById(ID_N1_COUNT);
81
+ n1El.textContent = n1Count;
82
+ n1El.style.color = n1Count > 0 ? COLOR_N1 : COLOR_HEALTHY;
83
+
84
+ // Associations list
85
+ var assocHtml = '';
86
+ node.associations.forEach(function(a) {
87
+ var macroColor = a.macro === 'belongs_to' ? COLOR_BELONGS : COLOR_HAS_MANY;
88
+ var n1Badge = a.has_n1
89
+ ? badge(COLOR_N1, 'N+1')
90
+ : '';
91
+ var indexBadge = a.indexed
92
+ ? badge(COLOR_HEALTHY, 'indexed')
93
+ : badge(COLOR_HAS_MANY, '⚠ no index');
94
+
95
+ assocHtml +=
96
+ '<div style="padding:8px;background:' + COLOR_SURFACE + ';border-radius:4px;margin-bottom:6px;font-size:12px;">' +
97
+ '<span style="color:' + macroColor + ';font-family:monospace;">' + a.macro + '</span>' +
98
+ ' <span style="color:' + COLOR_TEXT + ';font-family:monospace;">:' + a.to_model.toLowerCase() + '</span>' +
99
+ n1Badge +
100
+ '<div style="color:' + COLOR_MUTED + ';font-size:10px;margin-top:4px;font-family:monospace;">' +
101
+ 'fk: ' + a.foreign_key + indexBadge +
102
+ '</div>' +
103
+ '</div>';
104
+ });
105
+ document.getElementById(ID_ASSOCIATIONS).innerHTML =
106
+ assocHtml || '<div style="color:' + COLOR_MUTED + ';font-size:12px;">No associations</div>';
107
+
108
+ // N+1 section
109
+ var n1Section = document.getElementById(ID_N1_SECTION);
110
+ if (n1Count > 0) {
111
+ n1Section.style.display = 'block';
112
+ var n1Html = '';
113
+ node.n1_patterns.forEach(function(p) {
114
+ n1Html +=
115
+ '<div style="padding:8px;background:' + COLOR_N1_BG + ';border:1px solid ' + COLOR_N1 + '44;' +
116
+ 'border-radius:4px;margin-bottom:6px;font-size:11px;">' +
117
+ '<div style="color:' + COLOR_N1 + ';font-family:monospace;margin-bottom:4px;">' +
118
+ p.occurrences + 'x detected' +
119
+ '</div>' +
120
+ (p.fix_suggestion
121
+ ? '<div style="color:' + COLOR_HEALTHY + ';font-family:monospace;">Fix: ' + p.fix_suggestion + '</div>'
122
+ : '') +
123
+ '</div>';
124
+ });
125
+ document.getElementById(ID_N1_LIST).innerHTML = n1Html;
126
+ } else {
127
+ n1Section.style.display = 'none';
128
+ }
129
+
130
+ // Action links
131
+ var linksHtml = '';
132
+ if (n1Count > 0) {
133
+ linksHtml +=
134
+ '<a href="' + N1_PATH + '" ' +
135
+ 'style="display:block;background:' + COLOR_N1_BG + ';border:1px solid ' + COLOR_N1 + '66;' +
136
+ 'color:' + COLOR_N1 + ';padding:8px 12px;border-radius:4px;font-size:12px;' +
137
+ 'text-decoration:none;text-align:center;margin-top:8px;">' +
138
+ 'View N+1 patterns →' +
139
+ '</a>';
140
+ }
141
+ document.getElementById(ID_LINKS).innerHTML = linksHtml;
142
+
143
+ // Open panel
144
+ document.getElementById(ID_PANEL).style.width = '320px';
145
+ document.getElementById(ID_PANEL_INNER).style.display = 'block';
146
+ }
147
+
148
+ function closePanel() {
149
+ document.getElementById(ID_PANEL).style.width = '0';
150
+ document.getElementById(ID_PANEL_INNER).style.display = 'none';
151
+ document.querySelectorAll('[id^="node-"]').forEach(function(el) {
152
+ el.style.opacity = '1';
153
+ });
154
+ }
155
+
156
+ // ─── Helpers ─────────────────────────────────────────────────────────────────
157
+ function badge(color, text) {
158
+ return '<span style="background:' + color + '33;color:' + color + ';' +
159
+ 'font-size:9px;padding:1px 5px;border-radius:3px;margin-left:4px;">' +
160
+ text + '</span>';
161
+ }
@@ -178,3 +178,178 @@ tr:hover td { background: #1e2535; }
178
178
  border-radius: 3px;
179
179
  font-size: 10px;
180
180
  }
181
+
182
+ /* ─── Spacing — margin ─────────────────────────────────────────────────────── */
183
+ .mb-0 { margin-bottom: 0; }
184
+ .mb-4 { margin-bottom: 4px; }
185
+ .mb-6 { margin-bottom: 6px; }
186
+ .mb-8 { margin-bottom: 8px; }
187
+ .mb-12 { margin-bottom: 12px; }
188
+ .mb-16 { margin-bottom: 16px; }
189
+ .mb-20 { margin-bottom: 20px; }
190
+ .mb-24 { margin-bottom: 24px; }
191
+ .mt-2 { margin-top: 2px; }
192
+ .mt-4 { margin-top: 4px; }
193
+ .mt-6 { margin-top: 6px; }
194
+ .ml-6 { margin-left: 6px; }
195
+ .ml-8 { margin-left: 8px; }
196
+
197
+ /* ─── Font size ────────────────────────────────────────────────────────────── */
198
+ .text-10 { font-size: 10px; }
199
+ .text-12 { font-size: 12px; }
200
+ .text-16 { font-size: 16px; }
201
+ .text-24 { font-size: 24px; }
202
+
203
+ /* ─── Misc ─────────────────────────────────────────────────────────────────── */
204
+ .text-right { text-align: right; }
205
+ .line-relaxed { line-height: 1.6; }
206
+ .word-break { white-space: pre-wrap; word-break: break-all; }
207
+
208
+ /* ─── Structural components ────────────────────────────────────────────────── */
209
+ .section-divider {
210
+ padding-bottom: 12px;
211
+ border-bottom: 1px solid #2d3748;
212
+ }
213
+
214
+ .info-block {
215
+ background: #1a202c;
216
+ border-radius: 6px;
217
+ padding: 16px;
218
+ }
219
+
220
+ .stat-card-dark {
221
+ background: #2d3748;
222
+ border-radius: 6px;
223
+ padding: 16px;
224
+ text-align: center;
225
+ }
226
+
227
+ .dna-panel {
228
+ background: #1a202c;
229
+ border-left: 3px solid #4299e1;
230
+ padding: 16px;
231
+ margin: 4px 0;
232
+ }
233
+
234
+ .explain-btn {
235
+ font-size: 10px;
236
+ background: #1a2d3a;
237
+ border: 1px solid #4299e144;
238
+ padding: 2px 8px;
239
+ border-radius: 3px;
240
+ }
241
+
242
+ /* ─── Color utilities ──────────────────────────────────────────────────────── */
243
+ .text-muted { color: #a0aec0; }
244
+ .text-primary { color: #e2e8f0; }
245
+ .text-grey { color: #718096; }
246
+ .text-healthy { color: #68d391; }
247
+ .text-danger { color: #fc8181; }
248
+ .text-accent { color: #90cdf4; }
249
+ .text-blue { color: #4299e1; }
250
+ .text-orange { color: #f6ad55; }
251
+ .text-purple { color: #9f7aea; }
252
+
253
+ /* ─── Typography utilities ─────────────────────────────────────────────────── */
254
+ .mono { font-family: ui-monospace, monospace; }
255
+ .bold { font-weight: bold; }
256
+ .text-upper { text-transform: uppercase; letter-spacing: 0.05em; }
257
+ .text-nowrap { white-space: nowrap; }
258
+ .truncate { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
259
+ .text-sm { font-size: 11px; }
260
+ .text-center { text-align: center; }
261
+
262
+ /* ─── Layout utilities ─────────────────────────────────────────────────────── */
263
+ .flex { display: flex; }
264
+ .flex-center { display: flex; align-items: center; }
265
+ .flex-between { display: flex; justify-content: space-between; align-items: center; }
266
+ .flex-wrap { flex-wrap: wrap; }
267
+ .flex-1 { flex: 1; }
268
+ .d-none { display: none; }
269
+ .cursor-pointer { cursor: pointer; }
270
+
271
+ /* ─── Page structure ───────────────────────────────────────────────────────── */
272
+ .page-header { margin-bottom: 24px; }
273
+ .page-heading { font-size: 20px; font-weight: bold; color: #e2e8f0; }
274
+ .page-subtitle { color: #a0aec0; font-size: 13px; margin-top: 4px; }
275
+ .back-link { color: #a0aec0; font-size: 13px; }
276
+
277
+ /* ─── Component utilities ──────────────────────────────────────────────────── */
278
+ .section-label {
279
+ color: #a0aec0;
280
+ font-size: 11px;
281
+ font-weight: bold;
282
+ text-transform: uppercase;
283
+ letter-spacing: 0.05em;
284
+ margin-bottom: 8px;
285
+ }
286
+
287
+ .mini-label {
288
+ color: #718096;
289
+ font-size: 10px;
290
+ text-transform: uppercase;
291
+ letter-spacing: 0.05em;
292
+ margin-bottom: 4px;
293
+ }
294
+
295
+ .empty-state { color: #a0aec0; text-align: center; padding: 48px; }
296
+
297
+ .code-block {
298
+ background: #1a202c;
299
+ padding: 12px;
300
+ border-radius: 6px;
301
+ font-size: 12px;
302
+ font-family: ui-monospace, monospace;
303
+ white-space: pre-wrap;
304
+ word-break: break-all;
305
+ }
306
+
307
+ .code-block-dark {
308
+ background: #2d3748;
309
+ border-radius: 4px;
310
+ padding: 8px 12px;
311
+ font-family: ui-monospace, monospace;
312
+ font-size: 12px;
313
+ }
314
+
315
+ .stat-value-lg {
316
+ font-size: 20px;
317
+ font-weight: bold;
318
+ font-family: ui-monospace, monospace;
319
+ }
320
+
321
+ .stat-value-xl {
322
+ font-size: 28px;
323
+ font-weight: bold;
324
+ }
325
+
326
+ .stat-box {
327
+ flex: 1;
328
+ background: #1a202c;
329
+ border-radius: 4px;
330
+ padding: 10px;
331
+ text-align: center;
332
+ }
333
+
334
+ .impact-box {
335
+ background: #1a202c;
336
+ border-radius: 8px;
337
+ padding: 16px;
338
+ text-align: center;
339
+ }
340
+
341
+ .callback-badge {
342
+ color: #fff;
343
+ padding: 1px 6px;
344
+ border-radius: 3px;
345
+ font-size: 11px;
346
+ }
347
+
348
+ .legend-dot {
349
+ display: inline-block;
350
+ width: 12px;
351
+ height: 12px;
352
+ border-radius: 2px;
353
+ margin-right: 4px;
354
+ vertical-align: middle;
355
+ }
@@ -0,0 +1,16 @@
1
+ module RailsVitals
2
+ class ExplainsController < ApplicationController
3
+ def show
4
+ @record = RailsVitals.store.find(params[:request_id])
5
+ return render plain: "Request not found", status: :not_found unless @record
6
+
7
+ @query_index = params[:query_index].to_i
8
+ query = @record.queries[@query_index]
9
+ return render plain: "Query not found", status: :not_found unless query
10
+
11
+ @sql = query[:sql]
12
+ @binds = query[:binds] || []
13
+ @result = Analyzers::ExplainAnalyzer.analyze(@sql, binds: @binds)
14
+ end
15
+ end
16
+ end
@@ -2,7 +2,6 @@ module RailsVitals
2
2
  class NPlusOnesController < ApplicationController
3
3
  def index
4
4
  records = RailsVitals.store.all
5
- Rails.logger.debug "ALL RECORDS: #{records}"
6
5
  @patterns = Analyzers::NPlusOneAggregator.aggregate(records)
7
6
  @total_requests = records.size
8
7
  end
@@ -7,7 +7,7 @@ module RailsVitals
7
7
 
8
8
  def show
9
9
  @record = RailsVitals.store.find(params[:id])
10
- render plain: "Request not found", status: :not_found unless @record
10
+ return render plain: "Request not found", status: :not_found unless @record
11
11
 
12
12
  @query_dna = @record.queries.map do |q|
13
13
  {
@@ -6,6 +6,7 @@ module RailsVitals
6
6
  COLOR_RED = "#c53030"
7
7
  COLOR_DARK_RED = "#742a2a"
8
8
  COLOR_GRAY = "#4a5568"
9
+ COLOR_NEUTRAL = "#a0aec0"
9
10
  COLOR_LIGHT_RED = "#fc8181"
10
11
  COLOR_ORANGE = "#f6ad55"
11
12
  COLOR_LIGHT_GREEN = "#68d391"
@@ -59,5 +60,32 @@ module RailsVitals
59
60
  else COLOR_LIGHT_GREEN
60
61
  end
61
62
  end
63
+
64
+ def cost_color(cost)
65
+ return COLOR_NEUTRAL unless cost
66
+ cost = cost.to_f
67
+ if cost < 100 then COLOR_LIGHT_GREEN
68
+ elsif cost < 1000 then COLOR_ORANGE
69
+ else COLOR_LIGHT_RED
70
+ end
71
+ end
72
+
73
+ def time_color(ms)
74
+ return COLOR_NEUTRAL unless ms
75
+ ms = ms.to_f
76
+ if ms < 10 then COLOR_LIGHT_GREEN
77
+ elsif ms < 100 then COLOR_ORANGE
78
+ else COLOR_LIGHT_RED
79
+ end
80
+ end
81
+
82
+ def rows_color(rows)
83
+ return COLOR_NEUTRAL unless rows
84
+ rows = rows.to_i
85
+ if rows < 1_000 then COLOR_LIGHT_GREEN
86
+ elsif rows < 10_000 then COLOR_ORANGE
87
+ else COLOR_LIGHT_RED
88
+ end
89
+ end
62
90
  end
63
91
  end
@@ -9,6 +9,7 @@
9
9
  <%= yield :head %>
10
10
 
11
11
  <%= stylesheet_link_tag "rails_vitals/application", media: "all" %>
12
+ <%= javascript_include_tag "rails_vitals/application" %>
12
13
  </head>
13
14
  <body>
14
15
  <nav class="nav">