rails_vitals 0.4.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 (32) hide show
  1. checksums.yaml +4 -4
  2. data/README.md +10 -1
  3. data/app/assets/stylesheets/rails_vitals/application.css +43 -0
  4. data/app/helpers/rails_vitals/application_helper.rb +23 -1
  5. data/app/views/rails_vitals/dashboard/index.html.erb +8 -16
  6. data/app/views/rails_vitals/explains/show.html.erb +9 -40
  7. data/app/views/rails_vitals/heatmap/index.html.erb +3 -4
  8. data/app/views/rails_vitals/models/index.html.erb +2 -3
  9. data/app/views/rails_vitals/n_plus_ones/index.html.erb +1 -3
  10. data/app/views/rails_vitals/n_plus_ones/show.html.erb +7 -8
  11. data/app/views/rails_vitals/requests/index.html.erb +6 -12
  12. data/app/views/rails_vitals/requests/show.html.erb +23 -25
  13. data/app/views/rails_vitals/shared/_empty_state.html.erb +3 -0
  14. data/app/views/rails_vitals/shared/_n1_indicator.html.erb +9 -0
  15. data/app/views/rails_vitals/shared/_page_header.html.erb +15 -0
  16. data/app/views/rails_vitals/shared/_score_badge.html.erb +3 -0
  17. data/lib/rails_vitals/analyzers/association_mapper.rb +1 -1
  18. data/lib/rails_vitals/analyzers/explain_analyzer.rb +59 -57
  19. data/lib/rails_vitals/analyzers/n_plus_one_aggregator.rb +13 -13
  20. data/lib/rails_vitals/analyzers/sql_tokenizer.rb +81 -81
  21. data/lib/rails_vitals/collector.rb +18 -18
  22. data/lib/rails_vitals/instrumentation/callback_instrumentation.rb +3 -3
  23. data/lib/rails_vitals/notifications/subscriber.rb +3 -3
  24. data/lib/rails_vitals/panel_renderer.rb +7 -11
  25. data/lib/rails_vitals/playground/sandbox.rb +2 -1
  26. data/lib/rails_vitals/request_record.rb +12 -12
  27. data/lib/rails_vitals/scorers/base_scorer.rb +3 -3
  28. data/lib/rails_vitals/scorers/composite_scorer.rb +3 -3
  29. data/lib/rails_vitals/scorers/query_scorer.rb +3 -3
  30. data/lib/rails_vitals/store.rb +2 -2
  31. data/lib/rails_vitals/version.rb +1 -1
  32. metadata +5 -1
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 8fc3f44a23831df5f94973a04dfd928519ac7b14adf763147cc7b4d6e6ee2702
4
- data.tar.gz: 39be2ad3de1a6569e42e5c22cea38a04db5c933910014de9c5963652c6a15451
3
+ metadata.gz: 0e310ba646b2f5168dec1765754ead900d1341fcfb229820afc9c75e4e6be8fb
4
+ data.tar.gz: a38f8d572a5b5a00a45d21239d9b07669c9dca818f7d75bcd9d7d9cfeea06e79
5
5
  SHA512:
6
- metadata.gz: f95974f80db2e2975a0b7ccd77aef6d0335a6c89202a31186b6631de09fb25938e0f0f5a2f55806c4cafd7d9b469ff589593a7805a4769cdff9eaaa08ef72e96
7
- data.tar.gz: d5698c85c0a72ac0f5f44f8db1c16c8b6b51c9c3aa0533ea70e774005f87d2e323a06c903f192d646e70ad1efa204560e94cbdbe122e2f6522412d50fbfbcfab
6
+ metadata.gz: c5708c964e928d6400375f2e1d98024a05f9e089066fb3fb65c32ae61c39cbb69a4589d4ed901cbb6a8e491657ec438ec60b419dbff27bbdfbef9d8517c789b3
7
+ data.tar.gz: 88155cc28f07138987841604882c728068c9031fb25566ac8fc00efc824e3139a77352fd109a42b1d6861db466d1aae9dab2920412a860947de651456fb0d486
data/README.md CHANGED
@@ -282,12 +282,21 @@ rails_vitals/
282
282
  ├── models/
283
283
  ├── n_plus_ones/
284
284
  ├── associations/
285
- └── 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
286
292
  ```
287
293
 
288
294
  **Key architectural decisions:**
289
295
 
290
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.
291
300
  - **Thread-local Collector** — instrumentation state is stored per-thread, never shared between concurrent requests.
292
301
  - **In-memory ring buffer** — the Store keeps the last N requests in memory. No database writes, no schema migrations.
293
302
  - **Module prepend for callbacks** — callback instrumentation wraps `ActiveRecord::Base#run_callbacks` via `Module#prepend`. No TracePoint, no monkey-patching.
@@ -364,6 +364,13 @@ tr:hover td { background: #1e2535; }
364
364
  .gap-24 { gap: 24px; }
365
365
 
366
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
+
367
374
  .grid-4 {
368
375
  display: grid;
369
376
  grid-template-columns: repeat(4, 1fr);
@@ -499,3 +506,39 @@ tr:hover td { background: #1e2535; }
499
506
  padding: 12px 16px;
500
507
  margin-bottom: 20px;
501
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
+ }
@@ -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"
@@ -107,5 +113,21 @@ module RailsVitals
107
113
  else COLOR_LIGHT_RED
108
114
  end
109
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
110
132
  end
111
133
  end
@@ -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 %>
@@ -51,7 +51,5 @@
51
51
  </table>
52
52
  </div>
53
53
  <% else %>
54
- <div class="card empty-state">
55
- No N+1 patterns detected yet.
56
- </div>
54
+ <%= render "rails_vitals/shared/empty_state", message: "No N+1 patterns detected yet." %>
57
55
  <% end %>
@@ -11,14 +11,14 @@
11
11
  <div class="card-title">Detected Pattern</div>
12
12
  <pre class="code-block text-muted mb-16"><%= @pattern[:pattern] %></pre>
13
13
 
14
- <div style="display:grid;grid-template-columns:repeat(3,1fr);gap:16px;font-size:13px;">
14
+ <div class="grid-3 mb-16" style="font-size:13px;">
15
15
  <div>
16
16
  <div class="text-muted mb-4">Total Occurrences</div>
17
17
  <div class="text-danger stat-value-lg"><%= @pattern[:occurrences] %>x</div>
18
18
  </div>
19
19
  <div>
20
20
  <div class="text-muted mb-4">Estimated Time Wasted</div>
21
- <div class="text-danger stat-value-lg"><%= @estimated_saving_ms %>ms</div>
21
+ <div class="text-danger stat-value-lg"><%= format_ms(@estimated_saving_ms) %></div>
22
22
  </div>
23
23
  <div>
24
24
  <div class="text-muted mb-4">Affected Requests</div>
@@ -48,11 +48,11 @@
48
48
  <div style="display:grid;grid-template-columns:repeat(3,1fr);gap:16px;" class="mb-24">
49
49
  <div class="impact-box">
50
50
  <div class="section-label">Est. Saving Per Request</div>
51
- <div class="text-healthy stat-value-xl"><%= @avg_saving_per_request %>ms</div>
51
+ <div class="text-healthy stat-value-xl"><%= format_ms(@avg_saving_per_request) %></div>
52
52
  </div>
53
53
  <div class="impact-box">
54
54
  <div class="section-label">Total Time Recovered</div>
55
- <div class="text-healthy stat-value-xl"><%= @estimated_saving_ms %>ms</div>
55
+ <div class="text-healthy stat-value-xl"><%= format_ms(@estimated_saving_ms) %></div>
56
56
  </div>
57
57
  <div class="impact-box">
58
58
  <div class="section-label">Requests Affected</div>
@@ -77,12 +77,11 @@
77
77
  <tr>
78
78
  <td><%= r.endpoint %></td>
79
79
  <td>
80
- <span class="badge badge-<%= score_label_to_color(r.score) %>">
81
- <%= r.score %>
82
- </span>
80
+ <%= render "rails_vitals/shared/score_badge",
81
+ color: score_label_to_color(r.score), score: r.score %>
83
82
  </td>
84
83
  <td><%= r.total_query_count %></td>
85
- <td class="text-muted"><%= r.duration_ms.round(1) %>ms</td>
84
+ <td class="text-muted"><%= format_ms(r.duration_ms) %></td>
86
85
  <td>
87
86
  <%= link_to "→", request_path(r.id), class: "text-accent" %>
88
87
  </td>
@@ -1,4 +1,4 @@
1
- <div class="page-title">Request History</div>
1
+ <%= render "rails_vitals/shared/page_header", title: "Request History" %>
2
2
 
3
3
  <div class="filter-bar">
4
4
  <%= link_to "All", rails_vitals.requests_path, class: params[:score].blank? && params[:n_plus_one].blank? ? "active" : "" %>
@@ -10,7 +10,7 @@
10
10
  </div>
11
11
 
12
12
  <% if params[:endpoint].present? %>
13
- <div class="text-muted" style="margin:20px 0;">
13
+ <div class="text-muted mb-16">
14
14
  Filtering by endpoint:
15
15
  <span class="text-accent"><%= params[:endpoint] %></span>
16
16
  &nbsp;
@@ -38,20 +38,14 @@
38
38
  <td class="text-muted text-sm"><%= r.recorded_at.strftime("%H:%M:%S") %></td>
39
39
  <td><%= r.endpoint %></td>
40
40
  <td>
41
- <span class="badge badge-<%= r.color %>">
42
- <%= r.score %>
43
- </span>
41
+ <%= render "rails_vitals/shared/score_badge", color: r.color, score: r.score %>
44
42
  </td>
45
43
  <td><%= r.total_query_count %></td>
46
- <td><%= r.total_db_time_ms.round(1) %>ms</td>
44
+ <td><%= format_ms(r.total_db_time_ms) %></td>
47
45
  <td>
48
- <% if r.n_plus_one_patterns.any? %>
49
- <span class="n1-badge"><%= r.n_plus_one_patterns.size %></span>
50
- <% else %>
51
- <span class="text-healthy">—</span>
52
- <% end %>
46
+ <%= render "rails_vitals/shared/n1_indicator", patterns: r.n_plus_one_patterns, none_label: "—" %>
53
47
  </td>
54
- <td><%= r.duration_ms&.round(1) %>ms</td>
48
+ <td><%= format_ms(r.duration_ms) %></td>
55
49
  <td><%= link_to "→", rails_vitals.request_path(r.id) %></td>
56
50
  </tr>
57
51
  <% end %>
@@ -1,18 +1,20 @@
1
- <div class="flex-center mb-20" style="gap:16px;">
2
- <%= link_to "← Back", rails_vitals.requests_path, class: "back-link" %>
3
- <div class="page-title" style="margin:0;">Request Detail</div>
1
+ <div class="page-header">
2
+ <div class="mb-8">
3
+ <%= link_to "← Back", rails_vitals.requests_path, class: "back-link" %>
4
+ </div>
5
+ <h1 class="page-heading">Request Detail</h1>
4
6
  </div>
5
7
 
6
8
  <%# Header %>
7
9
  <div class="card flex-between">
8
10
  <div>
9
- <div class="bold" style="font-size:42px;color:<%= score_color(@record.color) %>;">
11
+ <div class="score-display" style="color:<%= score_color(@record.color) %>;">
10
12
  <%= @record.score %>
11
13
  <span class="text-muted" style="font-size:16px;">/ 100</span>
12
14
  </div>
13
15
  </div>
14
16
  <div class="text-right">
15
- <span class="<%= "badge badge-#{@record.color}" %>" style="font-size:13px;padding:4px 14px;">
17
+ <span class="<%= badge_class(@record.color) %>" style="font-size:13px;padding:4px 14px;">
16
18
  <%= @record.label %>
17
19
  </span>
18
20
  <div class="text-muted text-sm mt-6"><%= @record.recorded_at.strftime("%Y-%m-%d %H:%M:%S") %></div>
@@ -27,7 +29,7 @@
27
29
  <tr><td class="text-muted" style="width:140px;">Endpoint</td><td><%= @record.endpoint %></td></tr>
28
30
  <tr><td class="text-muted">Method</td> <td><%= @record.http_method %></td></tr>
29
31
  <tr><td class="text-muted">Status</td> <td><%= @record.response_status %></td></tr>
30
- <tr><td class="text-muted">Duration</td><td><%= @record.duration_ms&.round(1) %>ms</td></tr>
32
+ <tr><td class="text-muted">Duration</td><td><%= format_ms(@record.duration_ms) %></td></tr>
31
33
  </tbody>
32
34
  </table>
33
35
  </div>
@@ -38,15 +40,14 @@
38
40
  <table>
39
41
  <tbody>
40
42
  <tr><td class="text-muted" style="width:140px;">Total Queries</td><td><%= @record.total_query_count %></td></tr>
41
- <tr><td class="text-muted">DB Time</td> <td><%= @record.total_db_time_ms.round(1) %>ms</td></tr>
43
+ <tr><td class="text-muted">DB Time</td> <td><%= format_ms(@record.total_db_time_ms) %></td></tr>
42
44
  <tr>
43
45
  <td class="text-muted">N+1 Patterns</td>
44
46
  <td>
45
- <% if @record.n_plus_one_patterns.any? %>
46
- <span class="n1-badge"><%= @record.n_plus_one_patterns.size %> detected</span>
47
- <% else %>
48
- <span class="text-healthy">None detected</span>
49
- <% end %>
47
+ <%= render "rails_vitals/shared/n1_indicator",
48
+ patterns: @record.n_plus_one_patterns,
49
+ suffix: "detected",
50
+ none_label: "None detected" %>
50
51
  </td>
51
52
  </tr>
52
53
  </tbody>
@@ -95,7 +96,7 @@
95
96
  <% @record.queries.sort_by { |q| -q[:duration_ms] }.each do |q| %>
96
97
  <tr>
97
98
  <td class="sql"><%= q[:sql] %></td>
98
- <td class="text-nowrap"><%= q[:duration_ms].round(1) %>ms</td>
99
+ <td class="text-nowrap"><%= format_ms(q[:duration_ms]) %></td>
99
100
  </tr>
100
101
  <% end %>
101
102
  </tbody>
@@ -109,7 +110,7 @@
109
110
  Callback Map
110
111
  <span class="text-muted ml-8" style="font-weight:normal;">
111
112
  <%= @record.callbacks.size %> callbacks —
112
- <%= @record.total_callback_time_ms.round(1) %>ms total
113
+ <%= format_ms(@record.total_callback_time_ms) %> total
113
114
  </span>
114
115
  </div>
115
116
  <% @record.callbacks.group_by { |c| c[:model] }.each do |model, callbacks| %>
@@ -117,8 +118,9 @@
117
118
  <div class="text-accent bold text-12 mb-6" style="padding-bottom:4px;border-bottom:1px solid #2d3748;">
118
119
  <%= model %>
119
120
  <span class="text-muted" style="font-weight:normal;">
121
+ <td class="text-muted ml-6">
120
122
  (<%= callbacks.size %> callbacks,
121
- <%= callbacks.sum { |c| c[:duration_ms] }.round(1) %>ms)
123
+ <%= format_ms(callbacks.sum { |c| c[:duration_ms] }) %>)
122
124
  </span>
123
125
  </div>
124
126
  <table>
@@ -137,11 +139,9 @@
137
139
  <%= cb[:kind] %>
138
140
  </span>
139
141
  </td>
140
- <td><%= cb[:duration_ms] %>ms</td>
142
+ <td><%= format_ms(cb[:duration_ms]) %></td>
141
143
  <td class="text-muted">
142
- <% pct = @record.total_callback_time_ms > 0 ?
143
- ((cb[:duration_ms] / @record.total_callback_time_ms) * 100).round(1) : 0 %>
144
- <%= pct %>%
144
+ <%= percentage(cb[:duration_ms], @record.total_callback_time_ms) %>%
145
145
  </td>
146
146
  </tr>
147
147
  <% end %>
@@ -180,7 +180,7 @@
180
180
  <% dna_id = "dna_#{i}" %>
181
181
 
182
182
  <%# Query row — clickable %>
183
- <tr onclick="toggleDna('<%= dna_id %>')" class="cursor-pointer">
183
+ <tr onclick="toggleDna(<%= dna_id.to_json %>)" class="cursor-pointer">
184
184
  <td class="text-muted"><%= i + 1 %></td>
185
185
  <td class="mono text-sm text-primary truncate" style="max-width:400px;">
186
186
  <%= q[:sql] %>
@@ -195,8 +195,7 @@
195
195
  </span>
196
196
  </td>
197
197
  <td>
198
- <% risk_colors = { healthy: "#68d391", neutral: "#a0aec0", warning: "#f6ad55", danger: "#fc8181" } %>
199
- <span class="text-12" style="color:<%= risk_colors[dna.risk] %>;text-transform:capitalize;">
198
+ <span class="text-12" style="color:<%= risk_color(dna.risk) %>;text-transform:capitalize;">
200
199
  <%= dna.risk %>
201
200
  </span>
202
201
  </td>
@@ -234,7 +233,7 @@
234
233
  <div class="flex flex-wrap" style="gap:6px;">
235
234
  <% dna.tokens.each do |token| %>
236
235
  <span
237
- onclick="toggleCard('<%= "card_#{dna_id}_#{token[:type]}" %>'); event.stopPropagation();"
236
+ onclick="toggleCard(<%= "card_#{dna_id}_#{token[:type]}".to_json %>); event.stopPropagation();"
238
237
  class="mono cursor-pointer"
239
238
  style="background:<%= token[:color] %>22;color:<%= token[:color] %>;border:1px solid <%= token[:color] %>66;padding:3px 10px;border-radius:4px;font-size:12px;"
240
239
  >
@@ -270,8 +269,7 @@
270
269
  </div>
271
270
  <div>
272
271
  <span class="text-muted">Risk</span>
273
- <% risk_colors = { healthy: "#68d391", neutral: "#a0aec0", warning: "#f6ad55", danger: "#fc8181" } %>
274
- <span class="bold ml-6" style="color:<%= risk_colors[dna.risk] %>;text-transform:capitalize;">
272
+ <span class="bold ml-6" style="color:<%= risk_color(dna.risk) %>;text-transform:capitalize;">
275
273
  <%= dna.risk %>
276
274
  </span>
277
275
  </div>
@@ -0,0 +1,3 @@
1
+ <div class="card empty-state">
2
+ <%= message %>
3
+ </div>
@@ -0,0 +1,9 @@
1
+ <% if patterns.any? %>
2
+ <span class="n1-badge">
3
+ <%= patterns.size %><% if local_assigns[:suffix] %> <%= suffix %><% end %>
4
+ </span>
5
+ <% else %>
6
+ <span class="text-healthy">
7
+ <%= local_assigns.fetch(:none_label, "None") %>
8
+ </span>
9
+ <% end %>
@@ -0,0 +1,15 @@
1
+ <div class="page-header">
2
+ <% if local_assigns[:back_path] %>
3
+ <div class="mb-8">
4
+ <%= link_to local_assigns.fetch(:back_label, "← Back"), back_path, class: "back-link" %>
5
+ </div>
6
+ <% end %>
7
+ <h1 class="page-heading">
8
+ <%= title %>
9
+ </h1>
10
+ <% if local_assigns[:subtitle] %>
11
+ <p class="page-subtitle">
12
+ <%= subtitle %>
13
+ </p>
14
+ <% end %>
15
+ </div>
@@ -0,0 +1,3 @@
1
+ <span class="<%= badge_class(color) %>">
2
+ <%= score %><% if local_assigns[:label] %> <%= label %><% end %>
3
+ </span>
@@ -104,7 +104,7 @@ module RailsVitals
104
104
  canvas_h = 120 + (by_depth.keys.max || 0) * 160
105
105
 
106
106
  by_depth.each do |depth, layer_nodes|
107
- count = layer_nodes.size
107
+ count = layer_nodes.size
108
108
  x_step = canvas_w / (count + 1)
109
109
  layer_nodes.each_with_index do |node, i|
110
110
  node.position = {