rails_vitals 0.2.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 +7 -0
- data/MIT-LICENSE +20 -0
- data/README.md +340 -0
- data/Rakefile +6 -0
- data/app/assets/stylesheets/rails_vitals/application.css +180 -0
- data/app/controllers/rails_vitals/application_controller.rb +30 -0
- data/app/controllers/rails_vitals/associations_controller.rb +8 -0
- data/app/controllers/rails_vitals/dashboard_controller.rb +59 -0
- data/app/controllers/rails_vitals/heatmap_controller.rb +39 -0
- data/app/controllers/rails_vitals/models_controller.rb +65 -0
- data/app/controllers/rails_vitals/n_plus_ones_controller.rb +43 -0
- data/app/controllers/rails_vitals/requests_controller.rb +44 -0
- data/app/helpers/rails_vitals/application_helper.rb +63 -0
- data/app/jobs/rails_vitals/application_job.rb +4 -0
- data/app/mailers/rails_vitals/application_mailer.rb +6 -0
- data/app/models/rails_vitals/application_record.rb +5 -0
- data/app/views/layouts/rails_vitals/application.html.erb +27 -0
- data/app/views/rails_vitals/associations/index.html.erb +370 -0
- data/app/views/rails_vitals/dashboard/index.html.erb +158 -0
- data/app/views/rails_vitals/heatmap/index.html.erb +66 -0
- data/app/views/rails_vitals/models/index.html.erb +117 -0
- data/app/views/rails_vitals/n_plus_ones/index.html.erb +49 -0
- data/app/views/rails_vitals/n_plus_ones/show.html.erb +139 -0
- data/app/views/rails_vitals/requests/index.html.erb +60 -0
- data/app/views/rails_vitals/requests/show.html.erb +396 -0
- data/config/routes.rb +9 -0
- data/lib/rails_vitals/analyzers/association_mapper.rb +121 -0
- data/lib/rails_vitals/analyzers/n_plus_one_aggregator.rb +116 -0
- data/lib/rails_vitals/analyzers/sql_tokenizer.rb +240 -0
- data/lib/rails_vitals/collector.rb +78 -0
- data/lib/rails_vitals/configuration.rb +27 -0
- data/lib/rails_vitals/engine.rb +25 -0
- data/lib/rails_vitals/instrumentation/callback_instrumentation.rb +30 -0
- data/lib/rails_vitals/middleware/panel_injector.rb +75 -0
- data/lib/rails_vitals/notifications/subscriber.rb +59 -0
- data/lib/rails_vitals/panel_renderer.rb +233 -0
- data/lib/rails_vitals/request_record.rb +51 -0
- data/lib/rails_vitals/scorers/base_scorer.rb +25 -0
- data/lib/rails_vitals/scorers/composite_scorer.rb +36 -0
- data/lib/rails_vitals/scorers/n_plus_one_scorer.rb +43 -0
- data/lib/rails_vitals/scorers/query_scorer.rb +42 -0
- data/lib/rails_vitals/store.rb +34 -0
- data/lib/rails_vitals/version.rb +3 -0
- data/lib/rails_vitals.rb +33 -0
- data/lib/tasks/rails_vitals_tasks.rake +4 -0
- metadata +113 -0
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
module RailsVitals
|
|
2
|
+
class ModelsController < ApplicationController
|
|
3
|
+
def index
|
|
4
|
+
records = RailsVitals.store.all
|
|
5
|
+
@breakdown = build_breakdown(records)
|
|
6
|
+
@total_requests = records.size
|
|
7
|
+
end
|
|
8
|
+
|
|
9
|
+
private
|
|
10
|
+
|
|
11
|
+
def build_breakdown(records)
|
|
12
|
+
model_data = Hash.new do |h, k|
|
|
13
|
+
h[k] = {
|
|
14
|
+
query_count: 0,
|
|
15
|
+
total_time_ms: 0.0,
|
|
16
|
+
endpoints: Hash.new(0),
|
|
17
|
+
callbacks: Hash.new(0)
|
|
18
|
+
}
|
|
19
|
+
end
|
|
20
|
+
|
|
21
|
+
records.each do |record|
|
|
22
|
+
# Query-based data
|
|
23
|
+
record.queries.each do |q|
|
|
24
|
+
model = extract_model(q[:sql])
|
|
25
|
+
next unless model
|
|
26
|
+
|
|
27
|
+
model_data[model][:query_count] += 1
|
|
28
|
+
model_data[model][:total_time_ms] += q[:duration_ms]
|
|
29
|
+
model_data[model][:endpoints][record.endpoint] += 1
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
# Callback data as secondary signal
|
|
33
|
+
record.callbacks.each do |cb|
|
|
34
|
+
model_data[cb[:model]][:callbacks][cb[:kind].to_s] += 1
|
|
35
|
+
end
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
model_data
|
|
39
|
+
.map do |model, data|
|
|
40
|
+
count = data[:query_count]
|
|
41
|
+
{
|
|
42
|
+
model: model,
|
|
43
|
+
query_count: count,
|
|
44
|
+
total_time_ms: data[:total_time_ms].round(1),
|
|
45
|
+
avg_time_ms: count > 0 ? (data[:total_time_ms] / count).round(2) : 0,
|
|
46
|
+
endpoints: data[:endpoints].sort_by { |_, v| -v }.to_h,
|
|
47
|
+
callbacks: data[:callbacks].sort_by { |_, v| -v }.to_h
|
|
48
|
+
}
|
|
49
|
+
end
|
|
50
|
+
.sort_by { |row| -row[:total_time_ms] }
|
|
51
|
+
end
|
|
52
|
+
|
|
53
|
+
def extract_model(sql)
|
|
54
|
+
# Extract table name from common SQL patterns
|
|
55
|
+
match = sql.match(/(?:FROM|INTO|UPDATE|JOIN)\s+"?(\w+)"?/i)
|
|
56
|
+
return nil unless match
|
|
57
|
+
|
|
58
|
+
table = match[1]
|
|
59
|
+
return nil if table.start_with?("pg_", "information_schema")
|
|
60
|
+
|
|
61
|
+
# Convert table name to model name
|
|
62
|
+
table.classify rescue nil
|
|
63
|
+
end
|
|
64
|
+
end
|
|
65
|
+
end
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
module RailsVitals
|
|
2
|
+
class NPlusOnesController < ApplicationController
|
|
3
|
+
def index
|
|
4
|
+
records = RailsVitals.store.all
|
|
5
|
+
Rails.logger.debug "ALL RECORDS: #{records}"
|
|
6
|
+
@patterns = Analyzers::NPlusOneAggregator.aggregate(records)
|
|
7
|
+
@total_requests = records.size
|
|
8
|
+
end
|
|
9
|
+
|
|
10
|
+
def show
|
|
11
|
+
records = RailsVitals.store.all
|
|
12
|
+
@patterns = Analyzers::NPlusOneAggregator.aggregate(records)
|
|
13
|
+
@pattern = @patterns.find { |p| pattern_id(p) == params[:id] }
|
|
14
|
+
|
|
15
|
+
return render plain: "Pattern not found", status: :not_found unless @pattern
|
|
16
|
+
|
|
17
|
+
@affected_requests = records.select do |r|
|
|
18
|
+
r.n_plus_one_patterns.any? do |sql, count|
|
|
19
|
+
normalize_sql(sql) == @pattern[:pattern]
|
|
20
|
+
end
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
@estimated_saving_ms = (@pattern[:occurrences] * 0.5).round(1)
|
|
24
|
+
@avg_saving_per_request = @affected_requests.size > 0 ?
|
|
25
|
+
(@estimated_saving_ms / @affected_requests.size).round(1) : 0
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
private
|
|
29
|
+
|
|
30
|
+
def pattern_id(pattern)
|
|
31
|
+
Digest::MD5.hexdigest(pattern[:pattern])[0..7]
|
|
32
|
+
end
|
|
33
|
+
helper_method :pattern_id
|
|
34
|
+
|
|
35
|
+
def normalize_sql(sql)
|
|
36
|
+
sql
|
|
37
|
+
.gsub(/\b\d+\b/, "?")
|
|
38
|
+
.gsub(/'[^']*'/, "?")
|
|
39
|
+
.gsub(/\s+/, " ")
|
|
40
|
+
.strip
|
|
41
|
+
end
|
|
42
|
+
end
|
|
43
|
+
end
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
module RailsVitals
|
|
2
|
+
class RequestsController < ApplicationController
|
|
3
|
+
def index
|
|
4
|
+
@records = RailsVitals.store.all.reverse
|
|
5
|
+
@records = filter(@records)
|
|
6
|
+
end
|
|
7
|
+
|
|
8
|
+
def show
|
|
9
|
+
@record = RailsVitals.store.find(params[:id])
|
|
10
|
+
render plain: "Request not found", status: :not_found unless @record
|
|
11
|
+
|
|
12
|
+
@query_dna = @record.queries.map do |q|
|
|
13
|
+
{
|
|
14
|
+
query: q,
|
|
15
|
+
dna: Analyzers::SqlTokenizer.tokenize(q[:sql], all_queries: @record.queries)
|
|
16
|
+
}
|
|
17
|
+
end
|
|
18
|
+
end
|
|
19
|
+
|
|
20
|
+
private
|
|
21
|
+
|
|
22
|
+
def filter(records)
|
|
23
|
+
if params[:endpoint].present?
|
|
24
|
+
records = records.select { |r| r.endpoint == params[:endpoint] }
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
if params[:score].present?
|
|
28
|
+
records = case params[:score]
|
|
29
|
+
when "critical" then records.select { |r| r.score < 50 }
|
|
30
|
+
when "warning" then records.select { |r| (50..69).include?(r.score) }
|
|
31
|
+
when "acceptable" then records.select { |r| (70..89).include?(r.score) }
|
|
32
|
+
when "healthy" then records.select { |r| r.score >= 90 }
|
|
33
|
+
else records
|
|
34
|
+
end
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
if params[:n_plus_one].present?
|
|
38
|
+
records = records.select { |r| r.n_plus_one_patterns.any? }
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
records
|
|
42
|
+
end
|
|
43
|
+
end
|
|
44
|
+
end
|
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
module RailsVitals
|
|
2
|
+
module ApplicationHelper
|
|
3
|
+
COLOR_GREEN = "#276749"
|
|
4
|
+
COLOR_BLUE = "#2b6cb0"
|
|
5
|
+
COLOR_AMBER = "#b7791f"
|
|
6
|
+
COLOR_RED = "#c53030"
|
|
7
|
+
COLOR_DARK_RED = "#742a2a"
|
|
8
|
+
COLOR_GRAY = "#4a5568"
|
|
9
|
+
COLOR_LIGHT_RED = "#fc8181"
|
|
10
|
+
COLOR_ORANGE = "#f6ad55"
|
|
11
|
+
COLOR_LIGHT_GREEN = "#68d391"
|
|
12
|
+
|
|
13
|
+
def score_color(color)
|
|
14
|
+
case color
|
|
15
|
+
when "green" then COLOR_GREEN
|
|
16
|
+
when "blue" then COLOR_BLUE
|
|
17
|
+
when "amber" then COLOR_AMBER
|
|
18
|
+
else COLOR_RED
|
|
19
|
+
end
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
def score_label_to_color(score)
|
|
23
|
+
case score
|
|
24
|
+
when 90..100 then "healthy"
|
|
25
|
+
when 70..89 then "acceptable"
|
|
26
|
+
when 50..69 then "warning"
|
|
27
|
+
else "critical"
|
|
28
|
+
end
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
def callback_color(kind)
|
|
32
|
+
case kind.to_sym
|
|
33
|
+
when :validation, :save then COLOR_BLUE
|
|
34
|
+
when :create, :update then COLOR_GREEN
|
|
35
|
+
when :destroy then COLOR_RED
|
|
36
|
+
when :commit then COLOR_AMBER
|
|
37
|
+
when :rollback then COLOR_DARK_RED
|
|
38
|
+
else COLOR_GRAY
|
|
39
|
+
end
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
def query_heat_color(count)
|
|
43
|
+
if count >= 25 then COLOR_LIGHT_RED
|
|
44
|
+
elsif count >= 10 then COLOR_ORANGE
|
|
45
|
+
else COLOR_LIGHT_GREEN
|
|
46
|
+
end
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
def time_heat_color(ms)
|
|
50
|
+
if ms >= 500 then COLOR_LIGHT_RED
|
|
51
|
+
elsif ms >= 100 then COLOR_ORANGE
|
|
52
|
+
else COLOR_LIGHT_GREEN
|
|
53
|
+
end
|
|
54
|
+
end
|
|
55
|
+
|
|
56
|
+
def n1_heat_color(pct)
|
|
57
|
+
if pct >= 75 then COLOR_LIGHT_RED
|
|
58
|
+
elsif pct >= 25 then COLOR_ORANGE
|
|
59
|
+
else COLOR_LIGHT_GREEN
|
|
60
|
+
end
|
|
61
|
+
end
|
|
62
|
+
end
|
|
63
|
+
end
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
<!DOCTYPE html>
|
|
2
|
+
<html>
|
|
3
|
+
<head>
|
|
4
|
+
<title>Rails vitals</title>
|
|
5
|
+
<%= csrf_meta_tags %>
|
|
6
|
+
<%= csp_meta_tag %>
|
|
7
|
+
<meta name="viewport" content="width=device-width, initial-scale=1">
|
|
8
|
+
|
|
9
|
+
<%= yield :head %>
|
|
10
|
+
|
|
11
|
+
<%= stylesheet_link_tag "rails_vitals/application", media: "all" %>
|
|
12
|
+
</head>
|
|
13
|
+
<body>
|
|
14
|
+
<nav class="nav">
|
|
15
|
+
<span class="nav-brand">⚡ RailsVitals</span>
|
|
16
|
+
<%= link_to "Dashboard", rails_vitals.root_path %>
|
|
17
|
+
<%= link_to "Requests", rails_vitals.requests_path %>
|
|
18
|
+
<%= link_to "Heatmap", rails_vitals.heatmap_path %>
|
|
19
|
+
<%= link_to "Models", rails_vitals.models_path %>
|
|
20
|
+
<%= link_to "N+1 Patterns", rails_vitals.n_plus_ones_path %>
|
|
21
|
+
<%= link_to "Association Map", rails_vitals.associations_path %>
|
|
22
|
+
</nav>
|
|
23
|
+
<div class="container">
|
|
24
|
+
<%= yield %>
|
|
25
|
+
</div>
|
|
26
|
+
</body>
|
|
27
|
+
</html>
|
|
@@ -0,0 +1,370 @@
|
|
|
1
|
+
<div style="display:flex;gap:0;height:calc(100vh - 60px);">
|
|
2
|
+
|
|
3
|
+
<%# Left — SVG diagram %>
|
|
4
|
+
<div style="flex:1;overflow:auto;padding:24px;background:#1a202c;">
|
|
5
|
+
|
|
6
|
+
<div class="card-title" style="margin-bottom:20px;">
|
|
7
|
+
Association Map
|
|
8
|
+
<span class="card-title-description">
|
|
9
|
+
click any node to inspect
|
|
10
|
+
</span>
|
|
11
|
+
</div>
|
|
12
|
+
|
|
13
|
+
<%# Legend %>
|
|
14
|
+
<div style="display:flex;gap:20px;margin-bottom:20px;font-size:12px;flex-wrap:wrap;">
|
|
15
|
+
<span>
|
|
16
|
+
<span style="display:inline-block;width:12px;height:12px;background:#68d391;border-radius:2px;margin-right:4px;"></span>
|
|
17
|
+
Healthy
|
|
18
|
+
</span>
|
|
19
|
+
<span>
|
|
20
|
+
<span style="display:inline-block;width:12px;height:12px;background:#fc8181;border-radius:2px;margin-right:4px;"></span>
|
|
21
|
+
N+1 Detected
|
|
22
|
+
</span>
|
|
23
|
+
<span>
|
|
24
|
+
<span style="display:inline-block;width:12px;height:12px;background:#4a5568;border-radius:2px;margin-right:4px;"></span>
|
|
25
|
+
Not Queried
|
|
26
|
+
</span>
|
|
27
|
+
<span style="color:#a0aec0;">——</span>
|
|
28
|
+
<span style="color:#f6ad55;">has_many / has_one</span>
|
|
29
|
+
<span style="color:#a0aec0;">——</span>
|
|
30
|
+
<span style="color:#9f7aea;">belongs_to</span>
|
|
31
|
+
<span style="color:#a0aec0;margin-left:8px;">⚠ missing index on FK</span>
|
|
32
|
+
</div>
|
|
33
|
+
|
|
34
|
+
<svg
|
|
35
|
+
id="assoc-map"
|
|
36
|
+
width="900"
|
|
37
|
+
height="<%= @canvas_height %>"
|
|
38
|
+
style="display:block;"
|
|
39
|
+
>
|
|
40
|
+
<defs>
|
|
41
|
+
<%# Arrowhead markers %>
|
|
42
|
+
<marker id="arrow-hasmany" markerWidth="8" markerHeight="8"
|
|
43
|
+
refX="6" refY="3" orient="auto">
|
|
44
|
+
<path d="M0,0 L0,6 L8,3 z" fill="#f6ad55" opacity="0.7"/>
|
|
45
|
+
</marker>
|
|
46
|
+
<marker id="arrow-belongsto" markerWidth="8" markerHeight="8"
|
|
47
|
+
refX="6" refY="3" orient="auto">
|
|
48
|
+
<path d="M0,0 L0,6 L8,3 z" fill="#9f7aea" opacity="0.7"/>
|
|
49
|
+
</marker>
|
|
50
|
+
<marker id="arrow-n1" markerWidth="8" markerHeight="8"
|
|
51
|
+
refX="6" refY="3" orient="auto">
|
|
52
|
+
<path d="M0,0 L0,6 L8,3 z" fill="#fc8181" opacity="0.9"/>
|
|
53
|
+
</marker>
|
|
54
|
+
</defs>
|
|
55
|
+
|
|
56
|
+
<%# Draw edges first (behind nodes) %>
|
|
57
|
+
<% @nodes.each do |node| %>
|
|
58
|
+
<% node.associations.each do |edge| %>
|
|
59
|
+
<% target = @node_map[edge.to_model] %>
|
|
60
|
+
<% next unless target&.position && node.position %>
|
|
61
|
+
<% x1 = node.position[:x] %>
|
|
62
|
+
<% y1 = node.position[:y] %>
|
|
63
|
+
<% x2 = target.position[:x] %>
|
|
64
|
+
<% y2 = target.position[:y] %>
|
|
65
|
+
|
|
66
|
+
<%# Edge color and marker %>
|
|
67
|
+
<% if edge.has_n1 %>
|
|
68
|
+
<% stroke = "#fc8181"; marker = "arrow-n1" %>
|
|
69
|
+
<% elsif edge.macro == :belongs_to %>
|
|
70
|
+
<% stroke = "#9f7aea44"; marker = "arrow-belongsto" %>
|
|
71
|
+
<% else %>
|
|
72
|
+
<% stroke = "#f6ad5544"; marker = "arrow-hasmany" %>
|
|
73
|
+
<% end %>
|
|
74
|
+
|
|
75
|
+
<line
|
|
76
|
+
x1="<%= x1 %>" y1="<%= y1 %>"
|
|
77
|
+
x2="<%= x2 %>" y2="<%= y2 %>"
|
|
78
|
+
stroke="<%= stroke %>"
|
|
79
|
+
stroke-width="<%= edge.has_n1 ? 2 : 1.5 %>"
|
|
80
|
+
stroke-dasharray="<%= edge.indexed ? 'none' : '4,3' %>"
|
|
81
|
+
marker-end="url(#<%= marker %>)"
|
|
82
|
+
/>
|
|
83
|
+
|
|
84
|
+
<%# FK label on edge midpoint %>
|
|
85
|
+
<% mid_x = (x1 + x2) / 2 %>
|
|
86
|
+
<% mid_y = (y1 + y2) / 2 %>
|
|
87
|
+
<text
|
|
88
|
+
x="<%= mid_x %>" y="<%= mid_y - 4 %>"
|
|
89
|
+
font-size="9"
|
|
90
|
+
fill="<%= edge.indexed ? '#718096' : '#f6ad55' %>"
|
|
91
|
+
text-anchor="middle"
|
|
92
|
+
font-family="monospace"
|
|
93
|
+
>
|
|
94
|
+
<%= edge.foreign_key %>
|
|
95
|
+
<%= "⚠" unless edge.indexed %>
|
|
96
|
+
</text>
|
|
97
|
+
<% end %>
|
|
98
|
+
<% end %>
|
|
99
|
+
|
|
100
|
+
<%# Draw nodes %>
|
|
101
|
+
<% @nodes.each do |node| %>
|
|
102
|
+
<% next unless node.position %>
|
|
103
|
+
<% x = node.position[:x] %>
|
|
104
|
+
<% y = node.position[:y] %>
|
|
105
|
+
|
|
106
|
+
<%# Node color %>
|
|
107
|
+
<% if node.has_n1 %>
|
|
108
|
+
<% fill = "#2d1515"; stroke = "#fc8181" %>
|
|
109
|
+
<% elsif node.query_count > 0 %>
|
|
110
|
+
<% fill = "#1a2d1a"; stroke = "#68d391" %>
|
|
111
|
+
<% else %>
|
|
112
|
+
<% fill = "#2d3748"; stroke = "#4a5568" %>
|
|
113
|
+
<% end %>
|
|
114
|
+
|
|
115
|
+
<%# Clickable group %>
|
|
116
|
+
<g
|
|
117
|
+
onclick="selectNode('<%= node.name.to_json %>')"
|
|
118
|
+
style="cursor:pointer;"
|
|
119
|
+
id="node-<%= node.name %>"
|
|
120
|
+
>
|
|
121
|
+
<%# Node box %>
|
|
122
|
+
<rect
|
|
123
|
+
x="<%= x - 54 %>" y="<%= y - 28 %>"
|
|
124
|
+
width="108" height="56"
|
|
125
|
+
rx="6"
|
|
126
|
+
fill="<%= fill %>"
|
|
127
|
+
stroke="<%= stroke %>"
|
|
128
|
+
stroke-width="1.5"
|
|
129
|
+
/>
|
|
130
|
+
|
|
131
|
+
<%# Model name %>
|
|
132
|
+
<text
|
|
133
|
+
x="<%= x %>" y="<%= y - 8 %>"
|
|
134
|
+
text-anchor="middle"
|
|
135
|
+
font-size="13"
|
|
136
|
+
font-weight="bold"
|
|
137
|
+
font-family="Arial, sans-serif"
|
|
138
|
+
fill="<%= node.has_n1 ? '#fc8181' : '#e2e8f0' %>"
|
|
139
|
+
>
|
|
140
|
+
<%= node.name %>
|
|
141
|
+
</text>
|
|
142
|
+
|
|
143
|
+
<%# Query count %>
|
|
144
|
+
<text
|
|
145
|
+
x="<%= x %>" y="<%= y + 8 %>"
|
|
146
|
+
text-anchor="middle"
|
|
147
|
+
font-size="10"
|
|
148
|
+
font-family="monospace"
|
|
149
|
+
fill="#a0aec0"
|
|
150
|
+
>
|
|
151
|
+
<%= node.query_count %> queries
|
|
152
|
+
</text>
|
|
153
|
+
|
|
154
|
+
<%# Avg time %>
|
|
155
|
+
<text
|
|
156
|
+
x="<%= x %>" y="<%= y + 20 %>"
|
|
157
|
+
text-anchor="middle"
|
|
158
|
+
font-size="10"
|
|
159
|
+
font-family="monospace"
|
|
160
|
+
fill="<%= node.avg_query_time_ms > 10 ? '#f6ad55' : '#718096' %>"
|
|
161
|
+
>
|
|
162
|
+
avg <%= node.avg_query_time_ms %>ms
|
|
163
|
+
</text>
|
|
164
|
+
|
|
165
|
+
<%# N+1 badge %>
|
|
166
|
+
<% if node.has_n1 %>
|
|
167
|
+
<circle cx="<%= x + 50 %>" cy="<%= y - 24 %>" r="7" fill="#fc8181"/>
|
|
168
|
+
<text x="<%= x + 50 %>" y="<%= y - 20 %>"
|
|
169
|
+
text-anchor="middle" font-size="9"
|
|
170
|
+
font-weight="bold" fill="#1a202c">N+1</text>
|
|
171
|
+
<% end %>
|
|
172
|
+
</g>
|
|
173
|
+
<% end %>
|
|
174
|
+
</svg>
|
|
175
|
+
</div>
|
|
176
|
+
|
|
177
|
+
<%# Right — slide-in detail panel %>
|
|
178
|
+
<div
|
|
179
|
+
id="assoc-panel"
|
|
180
|
+
style="
|
|
181
|
+
width:0;
|
|
182
|
+
overflow:hidden;
|
|
183
|
+
background:#2d3748;
|
|
184
|
+
border-left:1px solid #4a5568;
|
|
185
|
+
transition:width 0.2s ease;
|
|
186
|
+
flex-shrink:0;
|
|
187
|
+
"
|
|
188
|
+
>
|
|
189
|
+
<div id="assoc-panel-inner" style="width:320px;padding:20px;display:none;">
|
|
190
|
+
|
|
191
|
+
<div style="display:flex;justify-content:space-between;align-items:center;margin-bottom:16px;">
|
|
192
|
+
<span id="panel-model-name"
|
|
193
|
+
style="font-size:16px;font-weight:bold;color:#e2e8f0;font-family:monospace;">
|
|
194
|
+
</span>
|
|
195
|
+
<button
|
|
196
|
+
onclick="closePanel()"
|
|
197
|
+
style="background:none;border:none;color:#a0aec0;font-size:18px;cursor:pointer;padding:0;"
|
|
198
|
+
>✕</button>
|
|
199
|
+
</div>
|
|
200
|
+
|
|
201
|
+
<%# Stats row %>
|
|
202
|
+
<div style="display:flex;gap:12px;margin-bottom:20px;">
|
|
203
|
+
<div style="flex:1;background:#1a202c;border-radius:4px;padding:10px;text-align:center;">
|
|
204
|
+
<div id="panel-query-count"
|
|
205
|
+
style="font-size:20px;font-weight:bold;color:#4299e1;font-family:monospace;">
|
|
206
|
+
</div>
|
|
207
|
+
<div style="font-size:10px;color:#a0aec0;margin-top:2px;">queries</div>
|
|
208
|
+
</div>
|
|
209
|
+
<div style="flex:1;background:#1a202c;border-radius:4px;padding:10px;text-align:center;">
|
|
210
|
+
<div id="panel-avg-time"
|
|
211
|
+
style="font-size:20px;font-weight:bold;color:#68d391;font-family:monospace;">
|
|
212
|
+
</div>
|
|
213
|
+
<div style="font-size:10px;color:#a0aec0;margin-top:2px;">avg ms</div>
|
|
214
|
+
</div>
|
|
215
|
+
<div style="flex:1;background:#1a202c;border-radius:4px;padding:10px;text-align:center;">
|
|
216
|
+
<div id="panel-n1-count"
|
|
217
|
+
style="font-size:20px;font-weight:bold;font-family:monospace;">
|
|
218
|
+
</div>
|
|
219
|
+
<div style="font-size:10px;color:#a0aec0;margin-top:2px;">N+1 patterns</div>
|
|
220
|
+
</div>
|
|
221
|
+
</div>
|
|
222
|
+
|
|
223
|
+
<%# Associations list %>
|
|
224
|
+
<div style="color:#a0aec0;font-size:10px;text-transform:uppercase;
|
|
225
|
+
letter-spacing:0.05em;margin-bottom:8px;">
|
|
226
|
+
Associations
|
|
227
|
+
</div>
|
|
228
|
+
<div id="panel-associations" style="margin-bottom:20px;"></div>
|
|
229
|
+
|
|
230
|
+
<%# N+1 patterns %>
|
|
231
|
+
<div id="panel-n1-section" style="display:none;">
|
|
232
|
+
<div style="color:#fc8181;font-size:10px;text-transform:uppercase;
|
|
233
|
+
letter-spacing:0.05em;margin-bottom:8px;">
|
|
234
|
+
N+1 Patterns
|
|
235
|
+
</div>
|
|
236
|
+
<div id="panel-n1-list" style="margin-bottom:20px;"></div>
|
|
237
|
+
</div>
|
|
238
|
+
|
|
239
|
+
<%# Action links %>
|
|
240
|
+
<div id="panel-links" style="display:flex;flex-direction:column;gap:8px;"></div>
|
|
241
|
+
|
|
242
|
+
</div>
|
|
243
|
+
</div>
|
|
244
|
+
</div>
|
|
245
|
+
|
|
246
|
+
<%# Node data for JS — embedded as JSON %>
|
|
247
|
+
<script>
|
|
248
|
+
var NODE_DATA = <%= raw @nodes.map { |n| [
|
|
249
|
+
n.name,
|
|
250
|
+
{
|
|
251
|
+
name: n.name,
|
|
252
|
+
table: n.table,
|
|
253
|
+
query_count: n.query_count,
|
|
254
|
+
avg_query_time_ms: n.avg_query_time_ms,
|
|
255
|
+
has_n1: n.has_n1,
|
|
256
|
+
n1_patterns: n.n1_patterns.map { |p| {
|
|
257
|
+
pattern: p[:pattern],
|
|
258
|
+
occurrences: p[:occurrences],
|
|
259
|
+
fix_suggestion: p[:fix_suggestion]&.dig(:code)
|
|
260
|
+
}},
|
|
261
|
+
associations: n.associations.map { |e| {
|
|
262
|
+
to_model: e.to_model,
|
|
263
|
+
macro: e.macro,
|
|
264
|
+
foreign_key: e.foreign_key,
|
|
265
|
+
indexed: e.indexed,
|
|
266
|
+
has_n1: e.has_n1
|
|
267
|
+
}}
|
|
268
|
+
}
|
|
269
|
+
] }.to_h.to_json %>;
|
|
270
|
+
|
|
271
|
+
var N1_PATH = "<%= rails_vitals.n_plus_ones_path %>";
|
|
272
|
+
var REQUEST_PATH = "<%= rails_vitals.requests_path %>";
|
|
273
|
+
|
|
274
|
+
function selectNode(nameJson) {
|
|
275
|
+
var name = JSON.parse(nameJson);
|
|
276
|
+
var node = NODE_DATA[name];
|
|
277
|
+
if (!node) return;
|
|
278
|
+
|
|
279
|
+
// Highlight selected node
|
|
280
|
+
document.querySelectorAll('[id^="node-"]').forEach(function(el) {
|
|
281
|
+
el.style.opacity = '0.4';
|
|
282
|
+
});
|
|
283
|
+
var el = document.getElementById('node-' + name);
|
|
284
|
+
if (el) el.style.opacity = '1';
|
|
285
|
+
|
|
286
|
+
// Populate panel
|
|
287
|
+
document.getElementById('panel-model-name').textContent = node.name;
|
|
288
|
+
document.getElementById('panel-query-count').textContent = node.query_count;
|
|
289
|
+
document.getElementById('panel-avg-time').textContent = node.avg_query_time_ms;
|
|
290
|
+
|
|
291
|
+
var n1Count = node.n1_patterns.length;
|
|
292
|
+
var n1El = document.getElementById('panel-n1-count');
|
|
293
|
+
n1El.textContent = n1Count;
|
|
294
|
+
n1El.style.color = n1Count > 0 ? '#fc8181' : '#68d391';
|
|
295
|
+
|
|
296
|
+
// Associations list
|
|
297
|
+
var assocHtml = '';
|
|
298
|
+
node.associations.forEach(function(a) {
|
|
299
|
+
var macroColor = a.macro === 'belongs_to' ? '#9f7aea' : '#f6ad55';
|
|
300
|
+
var n1Badge = a.has_n1
|
|
301
|
+
? '<span style="background:#fc818133;color:#fc8181;font-size:9px;padding:1px 5px;border-radius:3px;margin-left:4px;">N+1</span>'
|
|
302
|
+
: '';
|
|
303
|
+
var indexBadge = a.indexed
|
|
304
|
+
? '<span style="background:#68d39133;color:#68d391;font-size:9px;padding:1px 5px;border-radius:3px;margin-left:4px;">indexed</span>'
|
|
305
|
+
: '<span style="background:#f6ad5533;color:#f6ad55;font-size:9px;padding:1px 5px;border-radius:3px;margin-left:4px;">⚠ no index</span>';
|
|
306
|
+
|
|
307
|
+
assocHtml +=
|
|
308
|
+
'<div style="padding:8px;background:#1a202c;border-radius:4px;margin-bottom:6px;font-size:12px;">' +
|
|
309
|
+
'<span style="color:' + macroColor + ';font-family:monospace;">' + a.macro + '</span>' +
|
|
310
|
+
' <span style="color:#e2e8f0;font-family:monospace;">:' + a.to_model.toLowerCase() + '</span>' +
|
|
311
|
+
n1Badge +
|
|
312
|
+
'<div style="color:#718096;font-size:10px;margin-top:4px;font-family:monospace;">' +
|
|
313
|
+
'fk: ' + a.foreign_key + indexBadge +
|
|
314
|
+
'</div>' +
|
|
315
|
+
'</div>';
|
|
316
|
+
});
|
|
317
|
+
document.getElementById('panel-associations').innerHTML =
|
|
318
|
+
assocHtml || '<div style="color:#718096;font-size:12px;">No associations</div>';
|
|
319
|
+
|
|
320
|
+
// N+1 section
|
|
321
|
+
var n1Section = document.getElementById('panel-n1-section');
|
|
322
|
+
if (n1Count > 0) {
|
|
323
|
+
n1Section.style.display = 'block';
|
|
324
|
+
var n1Html = '';
|
|
325
|
+
node.n1_patterns.forEach(function(p) {
|
|
326
|
+
n1Html +=
|
|
327
|
+
'<div style="padding:8px;background:#2d1515;border:1px solid #fc818144;' +
|
|
328
|
+
'border-radius:4px;margin-bottom:6px;font-size:11px;">' +
|
|
329
|
+
'<div style="color:#fc8181;font-family:monospace;margin-bottom:4px;">' +
|
|
330
|
+
p.occurrences + 'x detected' +
|
|
331
|
+
'</div>' +
|
|
332
|
+
(p.fix_suggestion
|
|
333
|
+
? '<div style="color:#68d391;font-family:monospace;">Fix: ' + p.fix_suggestion + '</div>'
|
|
334
|
+
: '') +
|
|
335
|
+
'</div>';
|
|
336
|
+
});
|
|
337
|
+
document.getElementById('panel-n1-list').innerHTML = n1Html;
|
|
338
|
+
} else {
|
|
339
|
+
n1Section.style.display = 'none';
|
|
340
|
+
}
|
|
341
|
+
|
|
342
|
+
// Action links
|
|
343
|
+
var linksHtml = ""
|
|
344
|
+
|
|
345
|
+
if (n1Count > 0) {
|
|
346
|
+
linksHtml +=
|
|
347
|
+
'<a href="' + N1_PATH + '" ' +
|
|
348
|
+
'style="display:block;background:#2d1515;border:1px solid #fc818166;' +
|
|
349
|
+
'color:#fc8181;padding:8px 12px;border-radius:4px;font-size:12px;' +
|
|
350
|
+
'text-decoration:none;text-align:center;margin-top:8px;">' +
|
|
351
|
+
'View N+1 patterns →' +
|
|
352
|
+
'</a>';
|
|
353
|
+
}
|
|
354
|
+
document.getElementById('panel-links').innerHTML = linksHtml;
|
|
355
|
+
|
|
356
|
+
// Open panel
|
|
357
|
+
var panel = document.getElementById('assoc-panel');
|
|
358
|
+
var inner = document.getElementById('assoc-panel-inner');
|
|
359
|
+
panel.style.width = '320px';
|
|
360
|
+
inner.style.display = 'block';
|
|
361
|
+
}
|
|
362
|
+
|
|
363
|
+
function closePanel() {
|
|
364
|
+
document.getElementById('assoc-panel').style.width = '0';
|
|
365
|
+
document.getElementById('assoc-panel-inner').style.display = 'none';
|
|
366
|
+
document.querySelectorAll('[id^="node-"]').forEach(function(el) {
|
|
367
|
+
el.style.opacity = '1';
|
|
368
|
+
});
|
|
369
|
+
}
|
|
370
|
+
</script>
|