rails_vitals 0.2.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 (29) hide show
  1. checksums.yaml +4 -4
  2. data/README.md +12 -0
  3. data/app/assets/javascripts/rails_vitals/application.js +190 -0
  4. data/app/assets/stylesheets/rails_vitals/application.css +321 -0
  5. data/app/controllers/rails_vitals/explains_controller.rb +16 -0
  6. data/app/controllers/rails_vitals/n_plus_ones_controller.rb +0 -1
  7. data/app/controllers/rails_vitals/playgrounds_controller.rb +120 -0
  8. data/app/controllers/rails_vitals/requests_controller.rb +1 -1
  9. data/app/helpers/rails_vitals/application_helper.rb +48 -0
  10. data/app/views/layouts/rails_vitals/application.html.erb +2 -0
  11. data/app/views/rails_vitals/associations/index.html.erb +41 -189
  12. data/app/views/rails_vitals/dashboard/index.html.erb +6 -6
  13. data/app/views/rails_vitals/explains/_plan_node.html.erb +137 -0
  14. data/app/views/rails_vitals/explains/show.html.erb +186 -0
  15. data/app/views/rails_vitals/heatmap/index.html.erb +7 -7
  16. data/app/views/rails_vitals/models/index.html.erb +19 -36
  17. data/app/views/rails_vitals/n_plus_ones/index.html.erb +17 -9
  18. data/app/views/rails_vitals/n_plus_ones/show.html.erb +30 -76
  19. data/app/views/rails_vitals/playgrounds/index.html.erb +289 -0
  20. data/app/views/rails_vitals/requests/index.html.erb +5 -5
  21. data/app/views/rails_vitals/requests/show.html.erb +83 -166
  22. data/config/routes.rb +2 -0
  23. data/lib/rails_vitals/analyzers/explain_analyzer.rb +347 -0
  24. data/lib/rails_vitals/collector.rb +2 -1
  25. data/lib/rails_vitals/notifications/subscriber.rb +16 -15
  26. data/lib/rails_vitals/playground/sandbox.rb +222 -0
  27. data/lib/rails_vitals/version.rb +1 -1
  28. data/lib/rails_vitals.rb +2 -0
  29. metadata +9 -1
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: e106e4c67b570f045fc8a13e815c52c4b2a7cfc6c5892fcd6dd9fed7224e1386
4
- data.tar.gz: c35b00516c19bc1bcb5759c244750c00ac73552de560cf95b61e2946244c7072
3
+ metadata.gz: 8fc3f44a23831df5f94973a04dfd928519ac7b14adf763147cc7b4d6e6ee2702
4
+ data.tar.gz: 39be2ad3de1a6569e42e5c22cea38a04db5c933910014de9c5963652c6a15451
5
5
  SHA512:
6
- metadata.gz: 31fb3b7bba580f46c3883c343fec0e62e257a5111e3e8c27335791385c4a09a514915c7f4e2dbda932ced34779e28ece3b08131997308ccb84c7262b8cc02586
7
- data.tar.gz: 4ad0d94df8c40e2eba897541ad34a5c40e81a9a97bd5a92ef588480677ed60040a22c267685f3c2b100679826f6b441ea2a6e1757222a546e5e5e2ec3e1edd73
6
+ metadata.gz: f95974f80db2e2975a0b7ccd77aef6d0335a6c89202a31186b6631de09fb25938e0f0f5a2f55806c4cafd7d9b469ff589593a7805a4769cdff9eaaa08ef72e96
7
+ data.tar.gz: d5698c85c0a72ac0f5f44f8db1c16c8b6b51c9c3aa0533ea70e774005f87d2e323a06c903f192d646e70ad1efa204560e94cbdbe122e2f6522412d50fbfbcfab
data/README.md CHANGED
@@ -56,6 +56,16 @@ 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
+
64
+ ### ๐Ÿงช N+1 Fix Playground
65
+ Try eager-loading fixes against your real app data before changing application code. Enter any ActiveRecord expression such as `Post.includes(:likes)`, optionally simulate association access to reproduce N+1 behavior, and RailsVitals runs it in a read-only sandbox with a 100-record cap and 2s timeout. Each run compares before vs. after score, query count, duration, and N+1 count, then shows the exact SQL fired with full Query DNA so you can verify that the fix actually batches queries.
66
+
67
+ ![Playground](https://github.com/user-attachments/assets/7df1bfbe-1576-4048-810d-465e52115d19)
68
+
59
69
  ### ๐ŸŽญ Callback Map
60
70
  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
71
 
@@ -143,6 +153,8 @@ Navigate to `/rails_vitals` to access the full admin interface.
143
153
  | Models | `/rails_vitals/models` | Per-model query breakdown |
144
154
  | N+1 Patterns | `/rails_vitals/n_plus_ones` | Cross-request N+1 aggregation with fix suggestions |
145
155
  | Association Map | `/rails_vitals/associations` | Live SVG model graph with N+1 and index annotations |
156
+ | EXPLAIN Visualizer | `/rails_vitals/requests/:request_id/explain/:query_index` | Interactive PostgreSQL EXPLAIN ANALYZE tree with warnings and fix suggestions |
157
+ | Playground | `/rails_vitals/playgrounds` | Read-only sandbox for testing eager-loading fixes against real app data |
146
158
 
147
159
  ---
148
160
 
@@ -0,0 +1,190 @@
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
+ }
162
+
163
+ // โ”€โ”€โ”€ playgrounds/index โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€
164
+ (function () {
165
+ var form = document.getElementById('playground-form');
166
+ if (!form) return;
167
+
168
+ // Disable the run button on submit to prevent double-runs
169
+ form.addEventListener('submit', function () {
170
+ var btn = document.getElementById('run-btn');
171
+ if (btn) {
172
+ btn.disabled = true;
173
+ btn.textContent = 'โณ Running...';
174
+ }
175
+ });
176
+
177
+ // Pre-fill expression textarea from ?expression= URL param (deep-link from N+1 page)
178
+ var params = new URLSearchParams(window.location.search);
179
+ var expr = params.get('expression');
180
+ if (expr) {
181
+ var ta = document.querySelector("textarea[name='expression']");
182
+ if (ta) ta.value = decodeURIComponent(expr);
183
+ }
184
+ }());
185
+
186
+ function toggleAssocLabel(assoc, checked) {
187
+ var label = document.getElementById('label_' + assoc);
188
+ if (!label) return;
189
+ label.classList.toggle('assoc-tag-active', checked);
190
+ }
@@ -178,3 +178,324 @@ 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
+ display: block;
280
+ color: #a0aec0;
281
+ font-size: 11px;
282
+ font-weight: bold;
283
+ text-transform: uppercase;
284
+ letter-spacing: 0.05em;
285
+ margin-bottom: 8px;
286
+ }
287
+
288
+ .mini-label {
289
+ color: #718096;
290
+ font-size: 10px;
291
+ text-transform: uppercase;
292
+ letter-spacing: 0.05em;
293
+ margin-bottom: 4px;
294
+ }
295
+
296
+ .empty-state { color: #a0aec0; text-align: center; padding: 48px; }
297
+
298
+ .code-block {
299
+ background: #1a202c;
300
+ padding: 12px;
301
+ border-radius: 6px;
302
+ font-size: 12px;
303
+ font-family: ui-monospace, monospace;
304
+ white-space: pre-wrap;
305
+ word-break: break-all;
306
+ }
307
+
308
+ .code-block-dark {
309
+ background: #2d3748;
310
+ border-radius: 4px;
311
+ padding: 8px 12px;
312
+ font-family: ui-monospace, monospace;
313
+ font-size: 12px;
314
+ }
315
+
316
+ .stat-value-lg {
317
+ font-size: 20px;
318
+ font-weight: bold;
319
+ font-family: ui-monospace, monospace;
320
+ }
321
+
322
+ .stat-value-xl {
323
+ font-size: 28px;
324
+ font-weight: bold;
325
+ }
326
+
327
+ .stat-box {
328
+ flex: 1;
329
+ background: #1a202c;
330
+ border-radius: 4px;
331
+ padding: 10px;
332
+ text-align: center;
333
+ }
334
+
335
+ .impact-box {
336
+ background: #1a202c;
337
+ border-radius: 8px;
338
+ padding: 16px;
339
+ text-align: center;
340
+ }
341
+
342
+ .callback-badge {
343
+ color: #fff;
344
+ padding: 1px 6px;
345
+ border-radius: 3px;
346
+ font-size: 11px;
347
+ }
348
+
349
+ .legend-dot {
350
+ display: inline-block;
351
+ width: 12px;
352
+ height: 12px;
353
+ border-radius: 2px;
354
+ margin-right: 4px;
355
+ vertical-align: middle;
356
+ }
357
+
358
+ /* โ”€โ”€โ”€ Gap utilities โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ */
359
+ .gap-4 { gap: 4px; }
360
+ .gap-6 { gap: 6px; }
361
+ .gap-8 { gap: 8px; }
362
+ .gap-12 { gap: 12px; }
363
+ .gap-16 { gap: 16px; }
364
+ .gap-24 { gap: 24px; }
365
+
366
+ /* โ”€โ”€โ”€ Grid utilities โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ */
367
+ .grid-4 {
368
+ display: grid;
369
+ grid-template-columns: repeat(4, 1fr);
370
+ gap: 12px;
371
+ margin-bottom: 20px;
372
+ }
373
+
374
+ /* โ”€โ”€โ”€ Stat box variant (dark surface) โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ */
375
+ .stat-grid-box {
376
+ background: #2d3748;
377
+ border-radius: 6px;
378
+ padding: 16px;
379
+ text-align: center;
380
+ }
381
+
382
+ /* โ”€โ”€โ”€ Playground โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ */
383
+ .run-btn {
384
+ background: #2d6a2d;
385
+ border: 1px solid #68d391;
386
+ color: #68d391;
387
+ padding: 10px 24px;
388
+ border-radius: 6px;
389
+ font-size: 14px;
390
+ font-weight: bold;
391
+ cursor: pointer;
392
+ transition: background 0.15s;
393
+ }
394
+ .run-btn:hover { background: #3a8a3a; }
395
+ .run-btn:disabled { opacity: 0.5; cursor: not-allowed; }
396
+
397
+ @keyframes fadeIn {
398
+ from { opacity: 0; transform: translateY(6px); }
399
+ to { opacity: 1; transform: translateY(0); }
400
+ }
401
+ .result-fade { animation: fadeIn 0.3s ease forwards; }
402
+
403
+ .playground-textarea {
404
+ width: 100%;
405
+ background: #1a202c;
406
+ border: 1px solid #4a5568;
407
+ color: #e2e8f0;
408
+ padding: 12px;
409
+ border-radius: 4px;
410
+ font-family: ui-monospace, monospace;
411
+ font-size: 13px;
412
+ resize: vertical;
413
+ box-sizing: border-box;
414
+ }
415
+
416
+ .assoc-tag {
417
+ display: flex;
418
+ align-items: center;
419
+ gap: 6px;
420
+ background: #2d3748;
421
+ border: 1px solid #4a5568;
422
+ border-radius: 4px;
423
+ padding: 6px 12px;
424
+ cursor: pointer;
425
+ font-size: 12px;
426
+ font-family: ui-monospace, monospace;
427
+ color: #e2e8f0;
428
+ transition: background 0.15s;
429
+ }
430
+ .assoc-tag-active {
431
+ background: #1a2d1a;
432
+ border-color: rgba(104, 211, 145, 0.4);
433
+ }
434
+
435
+ .assoc-pill {
436
+ background: #2d3748;
437
+ color: #a0aec0;
438
+ font-size: 10px;
439
+ font-family: ui-monospace, monospace;
440
+ padding: 2px 6px;
441
+ border-radius: 3px;
442
+ }
443
+ .assoc-pill-active {
444
+ background: #1a3a1a;
445
+ color: #68d391;
446
+ font-size: 10px;
447
+ font-family: ui-monospace, monospace;
448
+ padding: 2px 6px;
449
+ border-radius: 3px;
450
+ }
451
+
452
+ .compare-box {
453
+ background: #1a202c;
454
+ border-radius: 6px;
455
+ padding: 12px 16px;
456
+ }
457
+ .compare-box-after {
458
+ background: #1a2d1a;
459
+ border-radius: 6px;
460
+ padding: 12px 16px;
461
+ }
462
+
463
+ .compare-arrow { text-align: center; }
464
+ .compare-delta {
465
+ font-size: 12px;
466
+ font-family: ui-monospace, monospace;
467
+ font-weight: bold;
468
+ }
469
+
470
+ .stat-value-22 {
471
+ font-size: 22px;
472
+ font-weight: bold;
473
+ font-family: ui-monospace, monospace;
474
+ }
475
+
476
+ .summary-line {
477
+ margin-top: 12px;
478
+ padding: 10px 16px;
479
+ background: #1a202c;
480
+ border-radius: 4px;
481
+ font-size: 13px;
482
+ font-family: ui-monospace, monospace;
483
+ }
484
+
485
+ .error-card {
486
+ background: #2d1515;
487
+ border: 1px solid rgba(252, 129, 129, 0.4);
488
+ border-radius: 6px;
489
+ padding: 16px;
490
+ color: #fc8181;
491
+ font-size: 13px;
492
+ margin-bottom: 20px;
493
+ }
494
+
495
+ .n1-alert {
496
+ background: #2d1515;
497
+ border: 1px solid rgba(252, 129, 129, 0.27);
498
+ border-radius: 6px;
499
+ padding: 12px 16px;
500
+ margin-bottom: 20px;
501
+ }
@@ -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
@@ -0,0 +1,120 @@
1
+ module RailsVitals
2
+ class PlaygroundsController < ApplicationController
3
+ def index
4
+ @default_query = default_query
5
+ @default_model = default_model_name
6
+ @available_assocs = associations_for_model(@default_model)
7
+ @prechecked_assocs = prechecked_associations
8
+ end
9
+
10
+ def create
11
+ expression = params[:expression].to_s.strip
12
+ clean_expr = clean_expression(expression)
13
+ access_associations = Array(params[:access_associations]).reject(&:blank?)
14
+
15
+ result = Playground::Sandbox.run(
16
+ expression,
17
+ access_associations: access_associations
18
+ )
19
+
20
+ model_name = Playground::Sandbox.extract_model_name(expression)
21
+ @available_assocs = associations_for_model(model_name)
22
+ @prechecked_assocs = access_associations
23
+
24
+ @expression = clean_expr
25
+ @result = result
26
+ @previous = session_previous
27
+ @query_dna = build_dna(result.queries)
28
+
29
+ session[:playground_previous] = serialize_result(result, clean_expr, access_associations)
30
+
31
+ render :index
32
+ end
33
+
34
+ private
35
+
36
+ def discover_models
37
+ Analyzers::AssociationMapper.discover_models.map(&:name)
38
+ end
39
+
40
+ def worst_n1_pattern
41
+ records = RailsVitals.store.all
42
+ return nil if records.empty?
43
+
44
+ Analyzers::NPlusOneAggregator.aggregate(records).first
45
+ end
46
+
47
+ def session_previous
48
+ raw = session[:playground_previous]
49
+ return nil unless raw
50
+
51
+ JSON.parse(raw, symbolize_names: true)
52
+ rescue
53
+ nil
54
+ end
55
+
56
+ def default_model_name
57
+ pattern = worst_n1_pattern
58
+ return discover_models.first unless pattern
59
+
60
+ pattern[:fix_suggestion]&.dig(:owner_model) || discover_models.first
61
+ end
62
+
63
+ def associations_for_model(model_name)
64
+ return [] unless model_name
65
+
66
+ Playground::Sandbox.associations_for(model_name)
67
+ end
68
+
69
+ def prechecked_associations
70
+ pattern = worst_n1_pattern
71
+ return [] unless pattern
72
+
73
+ # Pre-check the association from the worst N+1 pattern
74
+ table = pattern[:table]
75
+ return [] unless table
76
+
77
+ assoc_name = table # table name is usually the association name
78
+ [ @available_assocs.find { |a| a == assoc_name || a == assoc_name.singularize } ].compact
79
+ end
80
+
81
+ def default_query
82
+ pattern = worst_n1_pattern
83
+ return "" unless pattern
84
+
85
+ fix = pattern[:fix_suggestion]&.dig(:code)
86
+ return "" unless fix
87
+
88
+ "# Worst N+1 detected in your app:\n# Fix: #{fix}\n\n#{fix.split('.').first}.all"
89
+ end
90
+
91
+ def serialize_result(result, expression, access_associations = [])
92
+ {
93
+ expression: expression,
94
+ query_count: result.query_count,
95
+ score: result.score,
96
+ duration_ms: result.duration_ms,
97
+ n1_count: result.n1_patterns.size,
98
+ access_associations: access_associations,
99
+ error: result.error
100
+ }.to_json
101
+ end
102
+
103
+ def clean_expression(expression)
104
+ expression
105
+ .lines
106
+ .reject { |l| l.strip.start_with?("#") }
107
+ .join
108
+ .strip
109
+ end
110
+
111
+ def build_dna(queries)
112
+ queries.map do |q|
113
+ {
114
+ query: q,
115
+ dna: Analyzers::SqlTokenizer.tokenize(q[:sql], all_queries: queries)
116
+ }
117
+ end
118
+ end
119
+ end
120
+ end