rails_vitals 0.3.0 โ 0.4.1
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 +16 -1
- data/app/assets/javascripts/rails_vitals/application.js +29 -0
- data/app/assets/stylesheets/rails_vitals/application.css +189 -0
- data/app/controllers/rails_vitals/playgrounds_controller.rb +120 -0
- data/app/helpers/rails_vitals/application_helper.rb +43 -1
- data/app/views/layouts/rails_vitals/application.html.erb +1 -0
- data/app/views/rails_vitals/dashboard/index.html.erb +8 -16
- data/app/views/rails_vitals/explains/show.html.erb +9 -40
- data/app/views/rails_vitals/heatmap/index.html.erb +3 -4
- data/app/views/rails_vitals/models/index.html.erb +2 -3
- data/app/views/rails_vitals/n_plus_ones/index.html.erb +9 -3
- data/app/views/rails_vitals/n_plus_ones/show.html.erb +7 -8
- data/app/views/rails_vitals/playgrounds/index.html.erb +289 -0
- data/app/views/rails_vitals/requests/index.html.erb +6 -12
- data/app/views/rails_vitals/requests/show.html.erb +23 -25
- data/app/views/rails_vitals/shared/_empty_state.html.erb +3 -0
- data/app/views/rails_vitals/shared/_n1_indicator.html.erb +9 -0
- data/app/views/rails_vitals/shared/_page_header.html.erb +15 -0
- data/app/views/rails_vitals/shared/_score_badge.html.erb +3 -0
- data/config/routes.rb +1 -0
- data/lib/rails_vitals/analyzers/association_mapper.rb +1 -1
- data/lib/rails_vitals/analyzers/explain_analyzer.rb +59 -57
- data/lib/rails_vitals/analyzers/n_plus_one_aggregator.rb +13 -13
- data/lib/rails_vitals/analyzers/sql_tokenizer.rb +81 -81
- data/lib/rails_vitals/collector.rb +18 -18
- data/lib/rails_vitals/instrumentation/callback_instrumentation.rb +3 -3
- data/lib/rails_vitals/notifications/subscriber.rb +17 -17
- data/lib/rails_vitals/panel_renderer.rb +7 -11
- data/lib/rails_vitals/playground/sandbox.rb +223 -0
- data/lib/rails_vitals/request_record.rb +12 -12
- data/lib/rails_vitals/scorers/base_scorer.rb +3 -3
- data/lib/rails_vitals/scorers/composite_scorer.rb +3 -3
- data/lib/rails_vitals/scorers/query_scorer.rb +3 -3
- data/lib/rails_vitals/store.rb +2 -2
- data/lib/rails_vitals/version.rb +1 -1
- data/lib/rails_vitals.rb +1 -0
- metadata +8 -1
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 0e310ba646b2f5168dec1765754ead900d1341fcfb229820afc9c75e4e6be8fb
|
|
4
|
+
data.tar.gz: a38f8d572a5b5a00a45d21239d9b07669c9dca818f7d75bcd9d7d9cfeea06e79
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: c5708c964e928d6400375f2e1d98024a05f9e089066fb3fb65c32ae61c39cbb69a4589d4ed901cbb6a8e491657ec438ec60b419dbff27bbdfbef9d8517c789b3
|
|
7
|
+
data.tar.gz: 88155cc28f07138987841604882c728068c9031fb25566ac8fc00efc824e3139a77352fd109a42b1d6861db466d1aae9dab2920412a860947de651456fb0d486
|
data/README.md
CHANGED
|
@@ -61,6 +61,11 @@ Any `SELECT` query in Request Detail can be sent directly to PostgreSQL's `EXPLA
|
|
|
61
61
|
|
|
62
62
|

|
|
63
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
|
+
|
|
64
69
|
### ๐ญ Callback Map
|
|
65
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.
|
|
66
71
|
|
|
@@ -149,6 +154,7 @@ Navigate to `/rails_vitals` to access the full admin interface.
|
|
|
149
154
|
| N+1 Patterns | `/rails_vitals/n_plus_ones` | Cross-request N+1 aggregation with fix suggestions |
|
|
150
155
|
| Association Map | `/rails_vitals/associations` | Live SVG model graph with N+1 and index annotations |
|
|
151
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 |
|
|
152
158
|
|
|
153
159
|
---
|
|
154
160
|
|
|
@@ -276,12 +282,21 @@ rails_vitals/
|
|
|
276
282
|
โโโ models/
|
|
277
283
|
โโโ n_plus_ones/
|
|
278
284
|
โโโ associations/
|
|
279
|
-
|
|
285
|
+
โโโ explains/
|
|
286
|
+
โโโ playgrounds/
|
|
287
|
+
โโโ shared/ # Reusable partials
|
|
288
|
+
โโโ _page_header.html.erb
|
|
289
|
+
โโโ _empty_state.html.erb
|
|
290
|
+
โโโ _score_badge.html.erb
|
|
291
|
+
โโโ _n1_indicator.html.erb
|
|
280
292
|
```
|
|
281
293
|
|
|
282
294
|
**Key architectural decisions:**
|
|
283
295
|
|
|
284
296
|
- **Zero JS dependencies** โ no Chartkick, no D3, no Chart.js. Tables for data, SVG for diagrams, vanilla JS for interactions.
|
|
297
|
+
- **Single CSS/JS asset file** โ all styles in `app/assets/stylesheets/rails_vitals/application.css`, all behaviour in `app/assets/javascripts/rails_vitals/application.js`. No inline `<style>` or `<script>` blocks in views.
|
|
298
|
+
- **Shared view partials** โ common UI patterns (`_page_header`, `_empty_state`, `_score_badge`, `_n1_indicator`) live in `app/views/rails_vitals/shared/` and are reused across all views.
|
|
299
|
+
- **Centralised helpers** โ all color logic, heat colors, and formatting live in `ApplicationHelper`. Views never define inline color hashes or call `.round(1).to_s + "ms"` directly; they use `format_ms()`, `risk_color()`, `badge_class()`, etc.
|
|
285
300
|
- **Thread-local Collector** โ instrumentation state is stored per-thread, never shared between concurrent requests.
|
|
286
301
|
- **In-memory ring buffer** โ the Store keeps the last N requests in memory. No database writes, no schema migrations.
|
|
287
302
|
- **Module prepend for callbacks** โ callback instrumentation wraps `ActiveRecord::Base#run_callbacks` via `Module#prepend`. No TracePoint, no monkey-patching.
|
|
@@ -159,3 +159,32 @@ function badge(color, text) {
|
|
|
159
159
|
'font-size:9px;padding:1px 5px;border-radius:3px;margin-left:4px;">' +
|
|
160
160
|
text + '</span>';
|
|
161
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
|
+
}
|
|
@@ -276,6 +276,7 @@ tr:hover td { background: #1e2535; }
|
|
|
276
276
|
|
|
277
277
|
/* โโโ Component utilities โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ */
|
|
278
278
|
.section-label {
|
|
279
|
+
display: block;
|
|
279
280
|
color: #a0aec0;
|
|
280
281
|
font-size: 11px;
|
|
281
282
|
font-weight: bold;
|
|
@@ -353,3 +354,191 @@ tr:hover td { background: #1e2535; }
|
|
|
353
354
|
margin-right: 4px;
|
|
354
355
|
vertical-align: middle;
|
|
355
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-5 {
|
|
368
|
+
display: grid;
|
|
369
|
+
grid-template-columns: repeat(5, 1fr);
|
|
370
|
+
gap: 12px;
|
|
371
|
+
margin-bottom: 24px;
|
|
372
|
+
}
|
|
373
|
+
|
|
374
|
+
.grid-4 {
|
|
375
|
+
display: grid;
|
|
376
|
+
grid-template-columns: repeat(4, 1fr);
|
|
377
|
+
gap: 12px;
|
|
378
|
+
margin-bottom: 20px;
|
|
379
|
+
}
|
|
380
|
+
|
|
381
|
+
/* โโโ Stat box variant (dark surface) โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ */
|
|
382
|
+
.stat-grid-box {
|
|
383
|
+
background: #2d3748;
|
|
384
|
+
border-radius: 6px;
|
|
385
|
+
padding: 16px;
|
|
386
|
+
text-align: center;
|
|
387
|
+
}
|
|
388
|
+
|
|
389
|
+
/* โโโ Playground โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ */
|
|
390
|
+
.run-btn {
|
|
391
|
+
background: #2d6a2d;
|
|
392
|
+
border: 1px solid #68d391;
|
|
393
|
+
color: #68d391;
|
|
394
|
+
padding: 10px 24px;
|
|
395
|
+
border-radius: 6px;
|
|
396
|
+
font-size: 14px;
|
|
397
|
+
font-weight: bold;
|
|
398
|
+
cursor: pointer;
|
|
399
|
+
transition: background 0.15s;
|
|
400
|
+
}
|
|
401
|
+
.run-btn:hover { background: #3a8a3a; }
|
|
402
|
+
.run-btn:disabled { opacity: 0.5; cursor: not-allowed; }
|
|
403
|
+
|
|
404
|
+
@keyframes fadeIn {
|
|
405
|
+
from { opacity: 0; transform: translateY(6px); }
|
|
406
|
+
to { opacity: 1; transform: translateY(0); }
|
|
407
|
+
}
|
|
408
|
+
.result-fade { animation: fadeIn 0.3s ease forwards; }
|
|
409
|
+
|
|
410
|
+
.playground-textarea {
|
|
411
|
+
width: 100%;
|
|
412
|
+
background: #1a202c;
|
|
413
|
+
border: 1px solid #4a5568;
|
|
414
|
+
color: #e2e8f0;
|
|
415
|
+
padding: 12px;
|
|
416
|
+
border-radius: 4px;
|
|
417
|
+
font-family: ui-monospace, monospace;
|
|
418
|
+
font-size: 13px;
|
|
419
|
+
resize: vertical;
|
|
420
|
+
box-sizing: border-box;
|
|
421
|
+
}
|
|
422
|
+
|
|
423
|
+
.assoc-tag {
|
|
424
|
+
display: flex;
|
|
425
|
+
align-items: center;
|
|
426
|
+
gap: 6px;
|
|
427
|
+
background: #2d3748;
|
|
428
|
+
border: 1px solid #4a5568;
|
|
429
|
+
border-radius: 4px;
|
|
430
|
+
padding: 6px 12px;
|
|
431
|
+
cursor: pointer;
|
|
432
|
+
font-size: 12px;
|
|
433
|
+
font-family: ui-monospace, monospace;
|
|
434
|
+
color: #e2e8f0;
|
|
435
|
+
transition: background 0.15s;
|
|
436
|
+
}
|
|
437
|
+
.assoc-tag-active {
|
|
438
|
+
background: #1a2d1a;
|
|
439
|
+
border-color: rgba(104, 211, 145, 0.4);
|
|
440
|
+
}
|
|
441
|
+
|
|
442
|
+
.assoc-pill {
|
|
443
|
+
background: #2d3748;
|
|
444
|
+
color: #a0aec0;
|
|
445
|
+
font-size: 10px;
|
|
446
|
+
font-family: ui-monospace, monospace;
|
|
447
|
+
padding: 2px 6px;
|
|
448
|
+
border-radius: 3px;
|
|
449
|
+
}
|
|
450
|
+
.assoc-pill-active {
|
|
451
|
+
background: #1a3a1a;
|
|
452
|
+
color: #68d391;
|
|
453
|
+
font-size: 10px;
|
|
454
|
+
font-family: ui-monospace, monospace;
|
|
455
|
+
padding: 2px 6px;
|
|
456
|
+
border-radius: 3px;
|
|
457
|
+
}
|
|
458
|
+
|
|
459
|
+
.compare-box {
|
|
460
|
+
background: #1a202c;
|
|
461
|
+
border-radius: 6px;
|
|
462
|
+
padding: 12px 16px;
|
|
463
|
+
}
|
|
464
|
+
.compare-box-after {
|
|
465
|
+
background: #1a2d1a;
|
|
466
|
+
border-radius: 6px;
|
|
467
|
+
padding: 12px 16px;
|
|
468
|
+
}
|
|
469
|
+
|
|
470
|
+
.compare-arrow { text-align: center; }
|
|
471
|
+
.compare-delta {
|
|
472
|
+
font-size: 12px;
|
|
473
|
+
font-family: ui-monospace, monospace;
|
|
474
|
+
font-weight: bold;
|
|
475
|
+
}
|
|
476
|
+
|
|
477
|
+
.stat-value-22 {
|
|
478
|
+
font-size: 22px;
|
|
479
|
+
font-weight: bold;
|
|
480
|
+
font-family: ui-monospace, monospace;
|
|
481
|
+
}
|
|
482
|
+
|
|
483
|
+
.summary-line {
|
|
484
|
+
margin-top: 12px;
|
|
485
|
+
padding: 10px 16px;
|
|
486
|
+
background: #1a202c;
|
|
487
|
+
border-radius: 4px;
|
|
488
|
+
font-size: 13px;
|
|
489
|
+
font-family: ui-monospace, monospace;
|
|
490
|
+
}
|
|
491
|
+
|
|
492
|
+
.error-card {
|
|
493
|
+
background: #2d1515;
|
|
494
|
+
border: 1px solid rgba(252, 129, 129, 0.4);
|
|
495
|
+
border-radius: 6px;
|
|
496
|
+
padding: 16px;
|
|
497
|
+
color: #fc8181;
|
|
498
|
+
font-size: 13px;
|
|
499
|
+
margin-bottom: 20px;
|
|
500
|
+
}
|
|
501
|
+
|
|
502
|
+
.n1-alert {
|
|
503
|
+
background: #2d1515;
|
|
504
|
+
border: 1px solid rgba(252, 129, 129, 0.27);
|
|
505
|
+
border-radius: 6px;
|
|
506
|
+
padding: 12px 16px;
|
|
507
|
+
margin-bottom: 20px;
|
|
508
|
+
}
|
|
509
|
+
|
|
510
|
+
/* โโโ Score display (big number on request detail) โโโโโโโโโโโโโโโโโโโโโโโโโโโโโ */
|
|
511
|
+
.score-display {
|
|
512
|
+
font-size: 42px;
|
|
513
|
+
font-weight: bold;
|
|
514
|
+
}
|
|
515
|
+
|
|
516
|
+
/* โโโ EXPLAIN plan tree โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ */
|
|
517
|
+
.plan-node {
|
|
518
|
+
border-left: 2px solid #4a5568;
|
|
519
|
+
margin-left: 16px;
|
|
520
|
+
padding-left: 16px;
|
|
521
|
+
margin-top: 8px;
|
|
522
|
+
}
|
|
523
|
+
.plan-node:first-child {
|
|
524
|
+
margin-left: 0;
|
|
525
|
+
padding-left: 0;
|
|
526
|
+
border-left: none;
|
|
527
|
+
}
|
|
528
|
+
.node-box {
|
|
529
|
+
border-radius: 6px;
|
|
530
|
+
padding: 12px 16px;
|
|
531
|
+
margin-bottom: 4px;
|
|
532
|
+
cursor: pointer;
|
|
533
|
+
transition: opacity 0.15s;
|
|
534
|
+
}
|
|
535
|
+
.node-box:hover { opacity: 0.85; }
|
|
536
|
+
|
|
537
|
+
/* โโโ EXPLAIN warning/interpretation block โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ */
|
|
538
|
+
.interpret-block {
|
|
539
|
+
border-radius: 6px;
|
|
540
|
+
padding: 12px 16px;
|
|
541
|
+
font-size: 13px;
|
|
542
|
+
background: #1a202c;
|
|
543
|
+
margin-bottom: 16px;
|
|
544
|
+
}
|
|
@@ -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
|
|
@@ -11,7 +11,9 @@ module RailsVitals
|
|
|
11
11
|
COLOR_ORANGE = "#f6ad55"
|
|
12
12
|
COLOR_LIGHT_GREEN = "#68d391"
|
|
13
13
|
|
|
14
|
-
|
|
14
|
+
# Module-level helper so plain Ruby classes (e.g. PanelRenderer) can resolve
|
|
15
|
+
# a score color without needing a helper instance.
|
|
16
|
+
def self.score_color_for(color)
|
|
15
17
|
case color
|
|
16
18
|
when "green" then COLOR_GREEN
|
|
17
19
|
when "blue" then COLOR_BLUE
|
|
@@ -20,6 +22,10 @@ module RailsVitals
|
|
|
20
22
|
end
|
|
21
23
|
end
|
|
22
24
|
|
|
25
|
+
def score_color(color)
|
|
26
|
+
RailsVitals::ApplicationHelper.score_color_for(color)
|
|
27
|
+
end
|
|
28
|
+
|
|
23
29
|
def score_label_to_color(score)
|
|
24
30
|
case score
|
|
25
31
|
when 90..100 then "healthy"
|
|
@@ -87,5 +93,41 @@ module RailsVitals
|
|
|
87
93
|
else COLOR_LIGHT_RED
|
|
88
94
|
end
|
|
89
95
|
end
|
|
96
|
+
|
|
97
|
+
# Returns a hex color for a DNA risk level symbol (:healthy, :neutral, :warning, :danger)
|
|
98
|
+
def risk_color(risk)
|
|
99
|
+
{
|
|
100
|
+
healthy: COLOR_LIGHT_GREEN,
|
|
101
|
+
neutral: COLOR_NEUTRAL,
|
|
102
|
+
warning: COLOR_ORANGE,
|
|
103
|
+
danger: COLOR_LIGHT_RED
|
|
104
|
+
}[risk.to_sym] || COLOR_NEUTRAL
|
|
105
|
+
end
|
|
106
|
+
|
|
107
|
+
# Returns a readable hex text color for a numeric health score (0-100)
|
|
108
|
+
def score_text_color(score)
|
|
109
|
+
case score.to_i
|
|
110
|
+
when 90..100 then COLOR_LIGHT_GREEN
|
|
111
|
+
when 70..89 then "#4299e1"
|
|
112
|
+
when 50..69 then COLOR_ORANGE
|
|
113
|
+
else COLOR_LIGHT_RED
|
|
114
|
+
end
|
|
115
|
+
end
|
|
116
|
+
|
|
117
|
+
# Returns the full badge CSS class string for a score color label.
|
|
118
|
+
def badge_class(color)
|
|
119
|
+
"badge badge-#{color}"
|
|
120
|
+
end
|
|
121
|
+
|
|
122
|
+
def format_ms(value)
|
|
123
|
+
return "0ms" unless value
|
|
124
|
+
|
|
125
|
+
"#{value.to_f.round(1)}ms"
|
|
126
|
+
end
|
|
127
|
+
|
|
128
|
+
# Calculates a percentage of count over total, returning 0 when total is zero.
|
|
129
|
+
def percentage(count, total)
|
|
130
|
+
total.to_f > 0 ? ((count.to_f / total) * 100).round(1) : 0
|
|
131
|
+
end
|
|
90
132
|
end
|
|
91
133
|
end
|
|
@@ -20,6 +20,7 @@
|
|
|
20
20
|
<%= link_to "Models", rails_vitals.models_path %>
|
|
21
21
|
<%= link_to "N+1 Patterns", rails_vitals.n_plus_ones_path %>
|
|
22
22
|
<%= link_to "Association Map", rails_vitals.associations_path %>
|
|
23
|
+
<%= link_to "Playground", rails_vitals.playgrounds_path %>
|
|
23
24
|
</nav>
|
|
24
25
|
<div class="container">
|
|
25
26
|
<%= yield %>
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
|
|
1
|
+
<%= render "rails_vitals/shared/page_header", title: "Dashboard" %>
|
|
2
2
|
|
|
3
3
|
<div class="grid-3">
|
|
4
4
|
<div class="stat-card">
|
|
@@ -34,20 +34,14 @@
|
|
|
34
34
|
<tr>
|
|
35
35
|
<td><%= r.endpoint %></td>
|
|
36
36
|
<td>
|
|
37
|
-
|
|
38
|
-
<%= r.score %> <%= r.label %>
|
|
39
|
-
</span>
|
|
37
|
+
<%= render "rails_vitals/shared/score_badge", color: r.color, score: r.score, label: r.label %>
|
|
40
38
|
</td>
|
|
41
39
|
<td><%= r.total_query_count %></td>
|
|
42
|
-
<td><%= r.total_db_time_ms
|
|
40
|
+
<td><%= format_ms(r.total_db_time_ms) %></td>
|
|
43
41
|
<td>
|
|
44
|
-
|
|
45
|
-
<span class="n1-badge"><%= r.n_plus_one_patterns.size %></span>
|
|
46
|
-
<% else %>
|
|
47
|
-
<span class="text-healthy">None</span>
|
|
48
|
-
<% end %>
|
|
42
|
+
<%= render "rails_vitals/shared/n1_indicator", patterns: r.n_plus_one_patterns %>
|
|
49
43
|
</td>
|
|
50
|
-
<td><%= r.duration_ms
|
|
44
|
+
<td><%= format_ms(r.duration_ms) %></td>
|
|
51
45
|
<td><%= link_to "โ", rails_vitals.request_path(r.id) %></td>
|
|
52
46
|
</tr>
|
|
53
47
|
<% end %>
|
|
@@ -74,7 +68,7 @@
|
|
|
74
68
|
<td><%= stats[:count] %></td>
|
|
75
69
|
<td><%= stats[:avg_score] %></td>
|
|
76
70
|
<td><%= stats[:avg_queries] %></td>
|
|
77
|
-
<td><%= stats[:avg_db_time_ms]
|
|
71
|
+
<td><%= format_ms(stats[:avg_db_time_ms]) %></td>
|
|
78
72
|
</tr>
|
|
79
73
|
<% end %>
|
|
80
74
|
</tbody>
|
|
@@ -100,7 +94,7 @@
|
|
|
100
94
|
<td><%= label %></td>
|
|
101
95
|
<td><%= count %></td>
|
|
102
96
|
<td class="text-muted">
|
|
103
|
-
<%=
|
|
97
|
+
<%= percentage(count, @total) %>%
|
|
104
98
|
</td>
|
|
105
99
|
</tr>
|
|
106
100
|
<% end %>
|
|
@@ -122,9 +116,7 @@
|
|
|
122
116
|
<tr>
|
|
123
117
|
<td class="text-muted text-sm"><%= endpoint %></td>
|
|
124
118
|
<td>
|
|
125
|
-
|
|
126
|
-
<%= score %>
|
|
127
|
-
</span>
|
|
119
|
+
<%= render "rails_vitals/shared/score_badge", color: score_label_to_color(score), score: score %>
|
|
128
120
|
</td>
|
|
129
121
|
</tr>
|
|
130
122
|
<% end %>
|
|
@@ -1,34 +1,12 @@
|
|
|
1
|
-
<style>
|
|
2
|
-
.plan-node {
|
|
3
|
-
border-left: 2px solid #4a5568;
|
|
4
|
-
margin-left: 16px;
|
|
5
|
-
padding-left: 16px;
|
|
6
|
-
margin-top: 8px;
|
|
7
|
-
}
|
|
8
|
-
.plan-node:first-child {
|
|
9
|
-
margin-left: 0;
|
|
10
|
-
padding-left: 0;
|
|
11
|
-
border-left: none;
|
|
12
|
-
}
|
|
13
|
-
.node-box {
|
|
14
|
-
border-radius: 6px;
|
|
15
|
-
padding: 12px 16px;
|
|
16
|
-
margin-bottom: 4px;
|
|
17
|
-
cursor: pointer;
|
|
18
|
-
transition: opacity 0.15s;
|
|
19
|
-
}
|
|
20
|
-
.node-box:hover { opacity: 0.85; }
|
|
21
|
-
</style>
|
|
22
|
-
|
|
23
1
|
<%# Header %>
|
|
24
2
|
<div class="mb-20">
|
|
25
3
|
<% if @record %>
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
4
|
+
<%= link_to "โ Back to Request Detail",
|
|
5
|
+
rails_vitals.request_path(@record.id),
|
|
6
|
+
class: "back-link text-sm" %>
|
|
29
7
|
<% end %>
|
|
30
8
|
|
|
31
|
-
<h2 class="page-heading
|
|
9
|
+
<h2 class="page-heading mb-8">
|
|
32
10
|
EXPLAIN Visualizer
|
|
33
11
|
</h2>
|
|
34
12
|
|
|
@@ -38,15 +16,12 @@
|
|
|
38
16
|
</div>
|
|
39
17
|
|
|
40
18
|
<% if @result.error %>
|
|
41
|
-
<div
|
|
42
|
-
class="text-danger"
|
|
43
|
-
style="background:#2d1515;border:1px solid #fc818166;border-radius:6px;padding:16px;font-size:13px;"
|
|
44
|
-
>
|
|
19
|
+
<div class="error-card">
|
|
45
20
|
<%= @result.error %>
|
|
46
21
|
</div>
|
|
47
22
|
<% else %>
|
|
48
23
|
<%# Summary stats row %>
|
|
49
|
-
<div
|
|
24
|
+
<div class="grid-5">
|
|
50
25
|
<% stats = [
|
|
51
26
|
{ label: "Total Cost", value: @result.total_cost, color: cost_color(@result.total_cost) },
|
|
52
27
|
{ label: "Actual Time", value: "#{@result.actual_time_ms}ms", color: time_color(@result.actual_time_ms) },
|
|
@@ -149,16 +124,10 @@
|
|
|
149
124
|
|
|
150
125
|
<%# Interpretation summary %>
|
|
151
126
|
<% if @result.interpretation %>
|
|
127
|
+
<% interp_color = @result.warnings.any? ? "#f6ad55" : "#68d391" %>
|
|
152
128
|
<div
|
|
153
|
-
class="mb-16 line-relaxed"
|
|
154
|
-
style="
|
|
155
|
-
background:#1a202c;
|
|
156
|
-
border-radius:6px;
|
|
157
|
-
padding:12px 16px;
|
|
158
|
-
font-size:13px;
|
|
159
|
-
color:<%= @result.warnings.any? ? '#f6ad55' : '#68d391' %>;
|
|
160
|
-
border-left:3px solid <%= @result.warnings.any? ? '#f6ad55' : '#68d391' %>;
|
|
161
|
-
"
|
|
129
|
+
class="interpret-block mb-16 line-relaxed"
|
|
130
|
+
style="color:<%= interp_color %>;border-left:3px solid <%= interp_color %>;"
|
|
162
131
|
>
|
|
163
132
|
<%= @result.interpretation %>
|
|
164
133
|
</div>
|
|
@@ -29,7 +29,7 @@
|
|
|
29
29
|
class: "text-accent" %>
|
|
30
30
|
</td>
|
|
31
31
|
<td>
|
|
32
|
-
<span class="
|
|
32
|
+
<span class="<%= badge_class(color) %>">
|
|
33
33
|
<%= row[:avg_score] %>
|
|
34
34
|
</span>
|
|
35
35
|
</td>
|
|
@@ -60,7 +60,6 @@
|
|
|
60
60
|
</table>
|
|
61
61
|
</div>
|
|
62
62
|
<% else %>
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
</div>
|
|
63
|
+
<%= render "rails_vitals/shared/empty_state",
|
|
64
|
+
message: "No requests recorded yet. Visit your app to generate data." %>
|
|
66
65
|
<% end %>
|
|
@@ -94,7 +94,6 @@
|
|
|
94
94
|
</div>
|
|
95
95
|
<% end %>
|
|
96
96
|
<% else %>
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
</div>
|
|
97
|
+
<%= render "rails_vitals/shared/empty_state",
|
|
98
|
+
message: "No data recorded yet. Visit your app to generate data." %>
|
|
100
99
|
<% end %>
|
|
@@ -15,6 +15,7 @@
|
|
|
15
15
|
<th>Affected Endpoints</th>
|
|
16
16
|
<th>Fix</th>
|
|
17
17
|
<th></th>
|
|
18
|
+
<th></th>
|
|
18
19
|
</tr>
|
|
19
20
|
</thead>
|
|
20
21
|
<tbody>
|
|
@@ -37,13 +38,18 @@
|
|
|
37
38
|
n_plus_one_path(pattern_id(p)),
|
|
38
39
|
class: "text-accent" %>
|
|
39
40
|
</td>
|
|
41
|
+
<td>
|
|
42
|
+
<%= link_to "Try in Playground โ",
|
|
43
|
+
rails_vitals.playgrounds_path(
|
|
44
|
+
expression: p[:fix_suggestion]&.dig(:code)
|
|
45
|
+
),
|
|
46
|
+
class: "text-accent" %>
|
|
47
|
+
</td>
|
|
40
48
|
</tr>
|
|
41
49
|
<% end %>
|
|
42
50
|
</tbody>
|
|
43
51
|
</table>
|
|
44
52
|
</div>
|
|
45
53
|
<% else %>
|
|
46
|
-
|
|
47
|
-
No N+1 patterns detected yet.
|
|
48
|
-
</div>
|
|
54
|
+
<%= render "rails_vitals/shared/empty_state", message: "No N+1 patterns detected yet." %>
|
|
49
55
|
<% end %>
|