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.
Files changed (46) hide show
  1. checksums.yaml +7 -0
  2. data/MIT-LICENSE +20 -0
  3. data/README.md +340 -0
  4. data/Rakefile +6 -0
  5. data/app/assets/stylesheets/rails_vitals/application.css +180 -0
  6. data/app/controllers/rails_vitals/application_controller.rb +30 -0
  7. data/app/controllers/rails_vitals/associations_controller.rb +8 -0
  8. data/app/controllers/rails_vitals/dashboard_controller.rb +59 -0
  9. data/app/controllers/rails_vitals/heatmap_controller.rb +39 -0
  10. data/app/controllers/rails_vitals/models_controller.rb +65 -0
  11. data/app/controllers/rails_vitals/n_plus_ones_controller.rb +43 -0
  12. data/app/controllers/rails_vitals/requests_controller.rb +44 -0
  13. data/app/helpers/rails_vitals/application_helper.rb +63 -0
  14. data/app/jobs/rails_vitals/application_job.rb +4 -0
  15. data/app/mailers/rails_vitals/application_mailer.rb +6 -0
  16. data/app/models/rails_vitals/application_record.rb +5 -0
  17. data/app/views/layouts/rails_vitals/application.html.erb +27 -0
  18. data/app/views/rails_vitals/associations/index.html.erb +370 -0
  19. data/app/views/rails_vitals/dashboard/index.html.erb +158 -0
  20. data/app/views/rails_vitals/heatmap/index.html.erb +66 -0
  21. data/app/views/rails_vitals/models/index.html.erb +117 -0
  22. data/app/views/rails_vitals/n_plus_ones/index.html.erb +49 -0
  23. data/app/views/rails_vitals/n_plus_ones/show.html.erb +139 -0
  24. data/app/views/rails_vitals/requests/index.html.erb +60 -0
  25. data/app/views/rails_vitals/requests/show.html.erb +396 -0
  26. data/config/routes.rb +9 -0
  27. data/lib/rails_vitals/analyzers/association_mapper.rb +121 -0
  28. data/lib/rails_vitals/analyzers/n_plus_one_aggregator.rb +116 -0
  29. data/lib/rails_vitals/analyzers/sql_tokenizer.rb +240 -0
  30. data/lib/rails_vitals/collector.rb +78 -0
  31. data/lib/rails_vitals/configuration.rb +27 -0
  32. data/lib/rails_vitals/engine.rb +25 -0
  33. data/lib/rails_vitals/instrumentation/callback_instrumentation.rb +30 -0
  34. data/lib/rails_vitals/middleware/panel_injector.rb +75 -0
  35. data/lib/rails_vitals/notifications/subscriber.rb +59 -0
  36. data/lib/rails_vitals/panel_renderer.rb +233 -0
  37. data/lib/rails_vitals/request_record.rb +51 -0
  38. data/lib/rails_vitals/scorers/base_scorer.rb +25 -0
  39. data/lib/rails_vitals/scorers/composite_scorer.rb +36 -0
  40. data/lib/rails_vitals/scorers/n_plus_one_scorer.rb +43 -0
  41. data/lib/rails_vitals/scorers/query_scorer.rb +42 -0
  42. data/lib/rails_vitals/store.rb +34 -0
  43. data/lib/rails_vitals/version.rb +3 -0
  44. data/lib/rails_vitals.rb +33 -0
  45. data/lib/tasks/rails_vitals_tasks.rake +4 -0
  46. 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,4 @@
1
+ module RailsVitals
2
+ class ApplicationJob < ActiveJob::Base
3
+ end
4
+ end
@@ -0,0 +1,6 @@
1
+ module RailsVitals
2
+ class ApplicationMailer < ActionMailer::Base
3
+ default from: "from@example.com"
4
+ layout "mailer"
5
+ end
6
+ end
@@ -0,0 +1,5 @@
1
+ module RailsVitals
2
+ class ApplicationRecord < ActiveRecord::Base
3
+ self.abstract_class = true
4
+ end
5
+ 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>