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.
Files changed (38) hide show
  1. checksums.yaml +4 -4
  2. data/README.md +16 -1
  3. data/app/assets/javascripts/rails_vitals/application.js +29 -0
  4. data/app/assets/stylesheets/rails_vitals/application.css +189 -0
  5. data/app/controllers/rails_vitals/playgrounds_controller.rb +120 -0
  6. data/app/helpers/rails_vitals/application_helper.rb +43 -1
  7. data/app/views/layouts/rails_vitals/application.html.erb +1 -0
  8. data/app/views/rails_vitals/dashboard/index.html.erb +8 -16
  9. data/app/views/rails_vitals/explains/show.html.erb +9 -40
  10. data/app/views/rails_vitals/heatmap/index.html.erb +3 -4
  11. data/app/views/rails_vitals/models/index.html.erb +2 -3
  12. data/app/views/rails_vitals/n_plus_ones/index.html.erb +9 -3
  13. data/app/views/rails_vitals/n_plus_ones/show.html.erb +7 -8
  14. data/app/views/rails_vitals/playgrounds/index.html.erb +289 -0
  15. data/app/views/rails_vitals/requests/index.html.erb +6 -12
  16. data/app/views/rails_vitals/requests/show.html.erb +23 -25
  17. data/app/views/rails_vitals/shared/_empty_state.html.erb +3 -0
  18. data/app/views/rails_vitals/shared/_n1_indicator.html.erb +9 -0
  19. data/app/views/rails_vitals/shared/_page_header.html.erb +15 -0
  20. data/app/views/rails_vitals/shared/_score_badge.html.erb +3 -0
  21. data/config/routes.rb +1 -0
  22. data/lib/rails_vitals/analyzers/association_mapper.rb +1 -1
  23. data/lib/rails_vitals/analyzers/explain_analyzer.rb +59 -57
  24. data/lib/rails_vitals/analyzers/n_plus_one_aggregator.rb +13 -13
  25. data/lib/rails_vitals/analyzers/sql_tokenizer.rb +81 -81
  26. data/lib/rails_vitals/collector.rb +18 -18
  27. data/lib/rails_vitals/instrumentation/callback_instrumentation.rb +3 -3
  28. data/lib/rails_vitals/notifications/subscriber.rb +17 -17
  29. data/lib/rails_vitals/panel_renderer.rb +7 -11
  30. data/lib/rails_vitals/playground/sandbox.rb +223 -0
  31. data/lib/rails_vitals/request_record.rb +12 -12
  32. data/lib/rails_vitals/scorers/base_scorer.rb +3 -3
  33. data/lib/rails_vitals/scorers/composite_scorer.rb +3 -3
  34. data/lib/rails_vitals/scorers/query_scorer.rb +3 -3
  35. data/lib/rails_vitals/store.rb +2 -2
  36. data/lib/rails_vitals/version.rb +1 -1
  37. data/lib/rails_vitals.rb +1 -0
  38. metadata +8 -1
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 85f8d6772f3f10badb2a598143172c1e11375eb017affe72007fe893e2f6d241
4
- data.tar.gz: 072320c0d3826677abacc3d36cbfd22843be687a955e41c9f924cb07bba640f0
3
+ metadata.gz: 0e310ba646b2f5168dec1765754ead900d1341fcfb229820afc9c75e4e6be8fb
4
+ data.tar.gz: a38f8d572a5b5a00a45d21239d9b07669c9dca818f7d75bcd9d7d9cfeea06e79
5
5
  SHA512:
6
- metadata.gz: d6648831e36ed7b41615af2636939d1f1dace58b270a311486820ee5d9e48581a5d617d590622855dbf6430dfdbc3ed1716721bb6d03f58e1af8dda8a2fcd969
7
- data.tar.gz: 204fab1e081f960f358295b56f42666800867133c14eafdcc6074524694110a5de0cef994e65647f504ffea3d90aab42620b861ce1c00e3de31b32e929c40278
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
  ![EXPLAIN Visualizer](https://github.com/user-attachments/assets/e3547822-17ce-40e8-a468-714bbff01cd9)
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
+ ![Playground](https://github.com/user-attachments/assets/7df1bfbe-1576-4048-810d-465e52115d19)
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
- โ””โ”€โ”€ live/
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
- def score_color(color)
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
- <div class="page-title">Dashboard</div>
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
- <span class="badge badge-<%= r.color %>">
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.round(1) %>ms</td>
40
+ <td><%= format_ms(r.total_db_time_ms) %></td>
43
41
  <td>
44
- <% if r.n_plus_one_patterns.any? %>
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&.round(1) %>ms</td>
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].round(1) %>ms</td>
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
- <%= @total > 0 ? ((count.to_f / @total) * 100).round(1) : 0 %>%
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
- <span class="badge badge-<%= score_label_to_color(score) %>">
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
- <a href="<%= rails_vitals.request_path(@record.id) %>" class="text-blue" style="font-size:13px;">
27
- โ† Back to Request Detail
28
- </a>
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" style="margin:8px 0;">
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 style="display:grid;grid-template-columns:repeat(5,1fr);gap:12px;margin-bottom:24px;">
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="badge badge-<%= color %>">
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
- <div class="card empty-state">
64
- No requests recorded yet. Visit your app to generate data.
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
- <div class="card empty-state">
98
- No data recorded yet. Visit your app to generate data.
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
- <div class="card empty-state">
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 %>