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.
- checksums.yaml +4 -4
- data/README.md +12 -0
- data/app/assets/javascripts/rails_vitals/application.js +190 -0
- data/app/assets/stylesheets/rails_vitals/application.css +321 -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/playgrounds_controller.rb +120 -0
- data/app/controllers/rails_vitals/requests_controller.rb +1 -1
- data/app/helpers/rails_vitals/application_helper.rb +48 -0
- data/app/views/layouts/rails_vitals/application.html.erb +2 -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 +17 -9
- data/app/views/rails_vitals/n_plus_ones/show.html.erb +30 -76
- data/app/views/rails_vitals/playgrounds/index.html.erb +289 -0
- data/app/views/rails_vitals/requests/index.html.erb +5 -5
- data/app/views/rails_vitals/requests/show.html.erb +83 -166
- data/config/routes.rb +2 -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 +16 -15
- data/lib/rails_vitals/playground/sandbox.rb +222 -0
- data/lib/rails_vitals/version.rb +1 -1
- data/lib/rails_vitals.rb +2 -0
- metadata +9 -1
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 8fc3f44a23831df5f94973a04dfd928519ac7b14adf763147cc7b4d6e6ee2702
|
|
4
|
+
data.tar.gz: 39be2ad3de1a6569e42e5c22cea38a04db5c933910014de9c5963652c6a15451
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
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
|

|
|
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
|
+

|
|
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
|
+

|
|
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
|
|
@@ -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
|