flare 0.1.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 (42) hide show
  1. checksums.yaml +7 -0
  2. data/CHANGELOG.md +5 -0
  3. data/LICENSE.txt +21 -0
  4. data/README.md +148 -0
  5. data/app/controllers/flare/application_controller.rb +22 -0
  6. data/app/controllers/flare/jobs_controller.rb +55 -0
  7. data/app/controllers/flare/requests_controller.rb +73 -0
  8. data/app/controllers/flare/spans_controller.rb +101 -0
  9. data/app/helpers/flare/application_helper.rb +168 -0
  10. data/app/views/flare/jobs/index.html.erb +69 -0
  11. data/app/views/flare/jobs/show.html.erb +323 -0
  12. data/app/views/flare/requests/index.html.erb +120 -0
  13. data/app/views/flare/requests/show.html.erb +498 -0
  14. data/app/views/flare/spans/index.html.erb +112 -0
  15. data/app/views/flare/spans/show.html.erb +184 -0
  16. data/app/views/layouts/flare/application.html.erb +126 -0
  17. data/config/routes.rb +20 -0
  18. data/exe/flare +9 -0
  19. data/lib/flare/backoff_policy.rb +73 -0
  20. data/lib/flare/cli/doctor_command.rb +129 -0
  21. data/lib/flare/cli/output.rb +45 -0
  22. data/lib/flare/cli/setup_command.rb +404 -0
  23. data/lib/flare/cli/status_command.rb +47 -0
  24. data/lib/flare/cli.rb +50 -0
  25. data/lib/flare/configuration.rb +121 -0
  26. data/lib/flare/engine.rb +43 -0
  27. data/lib/flare/http_metrics_config.rb +101 -0
  28. data/lib/flare/metric_counter.rb +45 -0
  29. data/lib/flare/metric_flusher.rb +124 -0
  30. data/lib/flare/metric_key.rb +42 -0
  31. data/lib/flare/metric_span_processor.rb +470 -0
  32. data/lib/flare/metric_storage.rb +42 -0
  33. data/lib/flare/metric_submitter.rb +221 -0
  34. data/lib/flare/source_location.rb +113 -0
  35. data/lib/flare/sqlite_exporter.rb +279 -0
  36. data/lib/flare/storage/sqlite.rb +789 -0
  37. data/lib/flare/storage.rb +54 -0
  38. data/lib/flare/version.rb +5 -0
  39. data/lib/flare.rb +411 -0
  40. data/public/flare-assets/flare.css +1245 -0
  41. data/public/flare-assets/images/flipper.png +0 -0
  42. metadata +240 -0
@@ -0,0 +1,498 @@
1
+ <%
2
+ # Get root span properties
3
+ root_props = @root_span ? @root_span[:properties] : {}
4
+ http_method = root_props["http.method"]
5
+ http_status = root_props["http.status_code"]
6
+ http_target = root_props["http.target"]
7
+ controller = root_props["code.namespace"]
8
+ action = root_props["code.function"]
9
+
10
+ # Calculate total duration from root span
11
+ total_duration_ms = @root_span ? @root_span[:duration_ms] : 1
12
+ root_start = @root_span ? @root_span[:start_timestamp] : 0
13
+ started_at = @root_span ? Time.at(@root_span[:start_timestamp] / 1_000_000_000.0) : nil
14
+
15
+ # Count span categories
16
+ span_counts = @child_spans.each_with_object(Hash.new(0)) do |span, counts|
17
+ counts[span_category(span)] += 1
18
+ end
19
+ %>
20
+
21
+ <div class="page-header">
22
+ <div class="detail-header">
23
+ <div class="detail-header-left">
24
+ <div class="detail-title">
25
+ <span class="badge badge-<%= http_method.to_s.downcase %>"><%= http_method || "GET" %></span>
26
+ <% status_class = case http_status.to_s
27
+ when /^2/ then "success"
28
+ when /^[45]/ then "error"
29
+ when /^3/ then "warning"
30
+ else "warning"
31
+ end %>
32
+ <span class="badge badge-status badge-<%= status_class %>"><%= http_status %></span>
33
+ <h1><%= controller && action ? "#{controller}##{action}" : @request[:name] %></h1>
34
+ </div>
35
+ </div>
36
+ </div>
37
+ </div>
38
+
39
+ <div class="page-body">
40
+ <div class="card">
41
+ <div class="tabs">
42
+ <a class="tab active" onclick="showTab('timeline')">Timeline (<%= @child_spans.size %>)</a>
43
+ <a class="tab" onclick="showTab('details')">Details</a>
44
+ </div>
45
+
46
+ <div id="tab-details" class="tab-content">
47
+ <%
48
+ # Group properties for cleaner display
49
+ user_agent = root_props["http.user_agent"]
50
+ route_pattern = root_props["http.route"]
51
+
52
+ # Properties to exclude from "other" section (already shown elsewhere)
53
+ shown_keys = %w[
54
+ http.method http.status_code http.target http.scheme http.host
55
+ code.namespace code.function http.route http.user_agent
56
+ ]
57
+ other_props = root_props.reject { |k, v| shown_keys.include?(k) || v.is_a?(Hash) || v.is_a?(Array) }
58
+ %>
59
+ <div class="details-sections">
60
+ <!-- Request Section -->
61
+ <div class="details-section">
62
+ <div class="section-header">
63
+ <div class="section-icon request">
64
+ <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
65
+ <path d="M21 12a9 9 0 0 1-9 9m9-9a9 9 0 0 0-9-9m9 9H3m9 9a9 9 0 0 1-9-9m9 9c1.66 0 3-4.03 3-9s-1.34-9-3-9m0 18c-1.66 0-3-4.03-3-9s1.34-9 3-9"/>
66
+ </svg>
67
+ </div>
68
+ <span class="section-title">Request</span>
69
+ </div>
70
+ <div class="section-grid">
71
+ <div class="detail-item">
72
+ <span class="detail-label">Duration</span>
73
+ <span class="detail-value prominent"><%= format_duration(total_duration_ms) %></span>
74
+ </div>
75
+ <div class="detail-item">
76
+ <span class="detail-label">Status</span>
77
+ <span class="detail-value">
78
+ <% status_dot_class = case http_status.to_s
79
+ when /^2/ then "success"
80
+ when /^[45]/ then "error"
81
+ else "warning"
82
+ end %>
83
+ <span class="status-dot <%= status_dot_class %>"></span><%= http_status %>
84
+ </span>
85
+ </div>
86
+ <div class="detail-item">
87
+ <span class="detail-label">Method</span>
88
+ <span class="detail-value"><%= http_method || "GET" %></span>
89
+ </div>
90
+ <div class="detail-item">
91
+ <span class="detail-label">Time</span>
92
+ <span class="detail-value"><%= started_at&.strftime("%b %d, %Y at %H:%M:%S") %></span>
93
+ </div>
94
+ <div class="detail-item full-width">
95
+ <span class="detail-label">Path</span>
96
+ <span class="detail-value mono"><%= http_target %></span>
97
+ </div>
98
+ <% if root_props["http.host"] %>
99
+ <div class="detail-item">
100
+ <span class="detail-label">Host</span>
101
+ <span class="detail-value mono"><%= root_props["http.host"] %></span>
102
+ </div>
103
+ <% end %>
104
+ <% if root_props["http.scheme"] %>
105
+ <div class="detail-item">
106
+ <span class="detail-label">Scheme</span>
107
+ <span class="detail-value"><%= root_props["http.scheme"] %></span>
108
+ </div>
109
+ <% end %>
110
+ </div>
111
+ </div>
112
+
113
+ <!-- Route Section -->
114
+ <% if controller || action || route_pattern %>
115
+ <div class="details-section">
116
+ <div class="section-header">
117
+ <div class="section-icon route">
118
+ <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
119
+ <polyline points="16 18 22 12 16 6"></polyline>
120
+ <polyline points="8 6 2 12 8 18"></polyline>
121
+ </svg>
122
+ </div>
123
+ <span class="section-title">Route</span>
124
+ </div>
125
+ <div class="section-grid">
126
+ <% if controller %>
127
+ <div class="detail-item">
128
+ <span class="detail-label">Controller</span>
129
+ <span class="detail-value mono"><%= controller %></span>
130
+ </div>
131
+ <% end %>
132
+ <% if action %>
133
+ <div class="detail-item">
134
+ <span class="detail-label">Action</span>
135
+ <span class="detail-value mono"><%= action %></span>
136
+ </div>
137
+ <% end %>
138
+ <% if route_pattern %>
139
+ <div class="detail-item full-width">
140
+ <span class="detail-label">Route Pattern</span>
141
+ <span class="detail-value mono secondary"><%= route_pattern %></span>
142
+ </div>
143
+ <% end %>
144
+ </div>
145
+ </div>
146
+ <% end %>
147
+
148
+ <!-- Client Section -->
149
+ <% if user_agent %>
150
+ <div class="details-section">
151
+ <div class="section-header">
152
+ <div class="section-icon client">
153
+ <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
154
+ <rect x="2" y="3" width="20" height="14" rx="2" ry="2"></rect>
155
+ <line x1="8" y1="21" x2="16" y2="21"></line>
156
+ <line x1="12" y1="17" x2="12" y2="21"></line>
157
+ </svg>
158
+ </div>
159
+ <span class="section-title">Client</span>
160
+ </div>
161
+ <div class="section-grid">
162
+ <div class="detail-item full-width">
163
+ <span class="detail-label">User Agent</span>
164
+ <span class="detail-value secondary" style="font-size: 0.75rem;"><%= user_agent %></span>
165
+ </div>
166
+ </div>
167
+ </div>
168
+ <% end %>
169
+
170
+ <!-- Other Properties Section -->
171
+ <% if other_props.any? %>
172
+ <div class="details-section">
173
+ <div class="section-header">
174
+ <div class="section-icon other">
175
+ <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
176
+ <circle cx="12" cy="12" r="1"></circle>
177
+ <circle cx="19" cy="12" r="1"></circle>
178
+ <circle cx="5" cy="12" r="1"></circle>
179
+ </svg>
180
+ </div>
181
+ <span class="section-title">Additional Properties</span>
182
+ </div>
183
+ <div class="section-grid">
184
+ <% other_props.each do |key, value| %>
185
+ <div class="detail-item">
186
+ <span class="detail-label"><%= key.to_s.split(".").last.titleize %></span>
187
+ <span class="detail-value mono truncate" title="<%= value %>"><%= value %></span>
188
+ </div>
189
+ <% end %>
190
+ </div>
191
+ </div>
192
+ <% end %>
193
+ </div>
194
+ </div>
195
+
196
+ <div id="tab-timeline" class="tab-content active">
197
+ <% if @child_spans.blank? %>
198
+ <div class="empty-clues">
199
+ <i class="bi bi-clock-history" style="font-size: 2rem; color: var(--cb-warm-gray-light);"></i>
200
+ <p>No timeline events recorded for this request.</p>
201
+ </div>
202
+ <% else %>
203
+ <div class="legend">
204
+ <div class="legend-item clickable active" data-filter="all" onclick="filterSpans('all')"><div class="legend-color all"></div> All (<%= @child_spans.size %>)</div>
205
+ <% if span_counts["sql"] > 0 %><div class="legend-item clickable" data-filter="sql" onclick="filterSpans('sql')"><div class="legend-color sql"></div> SQL (<%= span_counts["sql"] %>)</div><% end %>
206
+ <% if span_counts["cache"] > 0 %><div class="legend-item clickable" data-filter="cache" onclick="filterSpans('cache')"><div class="legend-color cache"></div> Cache (<%= span_counts["cache"] %>)</div><% end %>
207
+ <% if span_counts["view"] > 0 %><div class="legend-item clickable" data-filter="view" onclick="filterSpans('view')"><div class="legend-color view"></div> View (<%= span_counts["view"] %>)</div><% end %>
208
+ <% if span_counts["http"] > 0 %><div class="legend-item clickable" data-filter="http" onclick="filterSpans('http')"><div class="legend-color http"></div> HTTP (<%= span_counts["http"] %>)</div><% end %>
209
+ <% if span_counts["mailer"] > 0 %><div class="legend-item clickable" data-filter="mailer" onclick="filterSpans('mailer')"><div class="legend-color mailer"></div> Mailer (<%= span_counts["mailer"] %>)</div><% end %>
210
+ <% if span_counts["job"] > 0 %><div class="legend-item clickable" data-filter="job" onclick="filterSpans('job')"><div class="legend-color job"></div> Job (<%= span_counts["job"] %>)</div><% end %>
211
+ <% if span_counts["controller"] > 0 %><div class="legend-item clickable" data-filter="controller" onclick="filterSpans('controller')"><div class="legend-color controller"></div> Controller (<%= span_counts["controller"] %>)</div><% end %>
212
+ <% if span_counts["other"] > 0 %><div class="legend-item clickable" data-filter="other" onclick="filterSpans('other')"><div class="legend-color other"></div> Other (<%= span_counts["other"] %>)</div><% end %>
213
+ <div style="margin-left: auto; display: flex; gap: 0.5rem;">
214
+ <button type="button" onclick="collapseAll()" class="copy-ai-btn">
215
+ <i class="bi bi-arrows-collapse"></i>
216
+ Collapse All
217
+ </button>
218
+ <button type="button" onclick="copyForAI()" class="copy-ai-btn" id="copy-ai-btn">
219
+ <i class="bi bi-clipboard" id="copy-ai-icon"></i>
220
+ <span id="copy-ai-text">Copy for AI</span>
221
+ </button>
222
+ </div>
223
+ </div>
224
+
225
+ <div style="padding: 0.75rem;">
226
+ <div class="waterfall-header">
227
+ <div>Event</div>
228
+ <div>Duration</div>
229
+ </div>
230
+
231
+ <div class="waterfall">
232
+ <% @child_spans.each_with_index do |span, index| %>
233
+ <%
234
+ offset_ns = span[:start_timestamp] - root_start
235
+ offset_ms = offset_ns / 1_000_000.0
236
+ left = (offset_ms / [total_duration_ms, 1].max * 100).clamp(0, 99.5)
237
+ width = (span[:duration_ms] / [total_duration_ms, 1].max * 100).clamp(0.5, [0.5, 100 - left].max)
238
+ category = span_category(span)
239
+
240
+ # Generate display name
241
+ props = span[:properties] || {}
242
+ sql_statement = nil
243
+ display_name = if span[:name] == "instantiation.active_record"
244
+ # Show "User Instantiation (23)" format
245
+ class_name = props["class_name"] || "Record"
246
+ count = props["record_count"]
247
+ count ? "#{class_name} Instantiation (#{count})" : "#{class_name} Instantiation"
248
+ elsif category == "sql"
249
+ sql_statement = props["db.statement"]
250
+ props["name"] || span[:name]
251
+ elsif category == "cache"
252
+ key = props["key"]
253
+ op = span[:name].to_s.sub(".active_support", "").sub("cache_", "")
254
+ key ? "#{op}: #{key.to_s.truncate(80)}" : span[:name]
255
+ elsif category == "view"
256
+ identifier = props["identifier"] || props["code.filepath"]
257
+ if identifier
258
+ identifier.to_s.sub(/^.*\/app\/views\//, "")
259
+ else
260
+ span[:name]
261
+ end
262
+ elsif category == "http"
263
+ url = props["http.url"] || props["http.target"]
264
+ method = props["http.method"]
265
+ "#{method} #{url}".strip.presence || span[:name]
266
+ elsif category == "controller"
267
+ ns = props["code.namespace"]
268
+ fn = props["code.function"]
269
+ ns && fn ? "#{ns}##{fn}" : span[:name]
270
+ else
271
+ span[:name]
272
+ end
273
+ %>
274
+ <div class="waterfall-row" onclick="toggleSpan(<%= index %>)" id="row-<%= index %>" data-span-type="<%= category %>" <% if sql_statement.present? %>data-sql="<%= sql_statement.truncate(500).gsub('"', "'") %>"<% end %>>
275
+ <div class="waterfall-bar-container">
276
+ <div class="waterfall-bar <%= category %>" style="left: <%= left %>%; width: <%= width %>%;"></div>
277
+ <span class="waterfall-label"><%= display_name %></span>
278
+ <span class="waterfall-duration"><%= format_duration(span[:duration_ms]) %></span>
279
+ </div>
280
+ </div>
281
+ <div class="span-detail <%= category %>" id="span-<%= index %>">
282
+ <pre><code><%= format_content(span[:properties]) %></code></pre>
283
+ </div>
284
+ <% end %>
285
+ </div>
286
+ </div>
287
+ <% end %>
288
+ </div>
289
+ </div>
290
+ </div>
291
+
292
+ <script>
293
+ // Data for Copy for AI feature
294
+ const requestData = <%= {
295
+ name: @request[:name],
296
+ duration_ms: total_duration_ms,
297
+ http_method: http_method,
298
+ http_status: http_status,
299
+ http_target: http_target,
300
+ controller: controller,
301
+ action: action
302
+ }.to_json.html_safe %>;
303
+
304
+ const spansData = <%= @child_spans.map { |span|
305
+ {
306
+ name: span[:name],
307
+ duration_ms: span[:duration_ms],
308
+ offset_ms: (span[:start_timestamp] - root_start) / 1_000_000.0,
309
+ properties: span[:properties]
310
+ }
311
+ }.to_json.html_safe %>;
312
+
313
+ function formatDuration(ms) {
314
+ if (ms === null || ms === undefined) return '0ms';
315
+ if (ms >= 1000) return (ms / 1000).toFixed(2) + 's';
316
+ return ms.toFixed(2) + 'ms';
317
+ }
318
+
319
+ function getSpanCategory(name) {
320
+ name = (name || '').toLowerCase();
321
+ if (name.includes('sql') || name.includes('mysql') || name.includes('postgres') || name.includes('sqlite')) return 'sql';
322
+ if (name.includes('cache')) return 'cache';
323
+ if (name.includes('render') || name.includes('view')) return 'view';
324
+ if (name.includes('http') || name.includes('net_http') || name.includes('faraday')) return 'http';
325
+ if (name.includes('mail')) return 'mailer';
326
+ if (name.includes('job') || name.includes('active_job')) return 'job';
327
+ if (name.includes('action_controller') || name.includes('process_action')) return 'controller';
328
+ return 'other';
329
+ }
330
+
331
+ function copyForAI() {
332
+ let output = [];
333
+
334
+ output.push('> Given this Rails request trace, how would you improve performance? What information is missing that would help diagnose issues?');
335
+ output.push('');
336
+
337
+ const method = requestData.http_method || 'GET';
338
+ const status = requestData.http_status || '200';
339
+ output.push('# ' + method + ' ' + requestData.name + ' [' + status + '] - ' + formatDuration(requestData.duration_ms));
340
+ if (requestData.controller && requestData.action) {
341
+ output.push('Controller: ' + requestData.controller + '#' + requestData.action);
342
+ }
343
+ output.push('');
344
+
345
+ output.push('## Performance Summary');
346
+ output.push('');
347
+ output.push('- **Total:** ' + formatDuration(requestData.duration_ms));
348
+
349
+ const sqlSpans = spansData.filter(function(s) { return getSpanCategory(s.name) === 'sql'; });
350
+ if (sqlSpans.length > 0) {
351
+ const sqlTotalMs = sqlSpans.reduce(function(sum, s) { return sum + (s.duration_ms || 0); }, 0);
352
+ output.push('- **SQL:** ' + sqlSpans.length + ' queries, ' + formatDuration(sqlTotalMs) + ' total');
353
+
354
+ const slowest = sqlSpans.slice().sort(function(a, b) { return (b.duration_ms || 0) - (a.duration_ms || 0); }).slice(0, 3);
355
+ if (slowest.length > 0 && slowest[0].duration_ms > 1) {
356
+ const slowList = slowest.map(function(s) {
357
+ var stmt = (s.properties && s.properties['db.statement']) ? s.properties['db.statement'].substring(0, 50) : s.name;
358
+ return stmt + ' (' + formatDuration(s.duration_ms) + ')';
359
+ }).join(', ');
360
+ output.push('- **Slowest queries:** ' + slowList);
361
+ }
362
+ }
363
+
364
+ const viewSpans = spansData.filter(function(s) { return getSpanCategory(s.name) === 'view'; });
365
+ if (viewSpans.length > 0) {
366
+ const viewTotalMs = viewSpans.reduce(function(sum, s) { return sum + (s.duration_ms || 0); }, 0);
367
+ output.push('- **Views:** ' + viewSpans.length + ' renders, ' + formatDuration(viewTotalMs) + ' total');
368
+ }
369
+
370
+ output.push('');
371
+ output.push('## Timeline');
372
+ output.push('');
373
+
374
+ spansData.forEach(function(span) {
375
+ const offset = formatDuration(span.offset_ms);
376
+ const duration = formatDuration(span.duration_ms);
377
+ const props = span.properties || {};
378
+ let name = span.name;
379
+ let extra = '';
380
+
381
+ const category = getSpanCategory(span.name);
382
+ if (category === 'sql' && props['db.statement']) {
383
+ name = 'SQL';
384
+ extra = props['db.statement'];
385
+ } else if (category === 'view' && props['identifier']) {
386
+ name = 'View';
387
+ extra = props['identifier'].replace(/.*\/app\/views\//, '');
388
+ } else if (category === 'http') {
389
+ name = 'HTTP';
390
+ extra = ((props['http.method'] || '') + ' ' + (props['http.url'] || props['http.target'] || '')).trim();
391
+ }
392
+
393
+ let line = offset.padStart(8) + ' | ' + duration.padStart(8) + ' | ' + name;
394
+ if (extra) {
395
+ line += ': ' + extra;
396
+ }
397
+ output.push(line);
398
+ });
399
+
400
+ const text = output.join('\n');
401
+
402
+ navigator.clipboard.writeText(text).then(function() {
403
+ const btn = document.getElementById('copy-ai-btn');
404
+ const icon = document.getElementById('copy-ai-icon');
405
+ const label = document.getElementById('copy-ai-text');
406
+ btn.classList.add('copied');
407
+ icon.className = 'bi bi-check-lg';
408
+ label.textContent = 'Copied!';
409
+ setTimeout(function() {
410
+ btn.classList.remove('copied');
411
+ icon.className = 'bi bi-clipboard';
412
+ label.textContent = 'Copy for AI';
413
+ }, 2000);
414
+ });
415
+ }
416
+
417
+ function showTab(tabName) {
418
+ document.querySelectorAll('.tab-content').forEach(function(el) { el.classList.remove('active'); });
419
+ document.querySelectorAll('.tab').forEach(function(el) { el.classList.remove('active'); });
420
+ document.getElementById('tab-' + tabName).classList.add('active');
421
+ event.target.classList.add('active');
422
+ }
423
+
424
+ function toggleSpan(index) {
425
+ const detail = document.getElementById('span-' + index);
426
+ const row = document.getElementById('row-' + index);
427
+
428
+ if (detail.classList.contains('visible')) {
429
+ detail.classList.remove('visible');
430
+ row.classList.remove('selected');
431
+ } else {
432
+ detail.classList.add('visible');
433
+ row.classList.add('selected');
434
+ }
435
+ }
436
+
437
+ function collapseAll() {
438
+ document.querySelectorAll('.span-detail.visible').forEach(function(el) {
439
+ el.classList.remove('visible');
440
+ });
441
+ document.querySelectorAll('.waterfall-row.selected').forEach(function(el) {
442
+ el.classList.remove('selected');
443
+ });
444
+ }
445
+
446
+ function filterSpans(filterType) {
447
+ document.querySelectorAll('.legend-item.clickable').forEach(function(el) {
448
+ el.classList.remove('active');
449
+ });
450
+ document.querySelector('.legend-item[data-filter="' + filterType + '"]').classList.add('active');
451
+
452
+ document.querySelectorAll('.waterfall-row').forEach(function(row) {
453
+ const spanType = row.getAttribute('data-span-type');
454
+ if (filterType === 'all' || spanType === filterType) {
455
+ row.classList.remove('hidden');
456
+ } else {
457
+ row.classList.add('hidden');
458
+ row.classList.remove('selected');
459
+ }
460
+ });
461
+
462
+ document.querySelectorAll('.span-detail').forEach(function(detail) {
463
+ const index = detail.id.replace('span-', '');
464
+ const row = document.getElementById('row-' + index);
465
+ if (row.classList.contains('hidden')) {
466
+ detail.classList.add('hidden-by-filter');
467
+ detail.classList.remove('visible');
468
+ } else {
469
+ detail.classList.remove('hidden-by-filter');
470
+ }
471
+ });
472
+ }
473
+
474
+ // SQL tooltip
475
+ document.addEventListener('DOMContentLoaded', function() {
476
+ const sqlTooltip = document.getElementById('sql-tooltip');
477
+ let tooltipTimeout;
478
+
479
+ document.querySelectorAll('.waterfall-row[data-sql]').forEach(function(row) {
480
+ row.addEventListener('mouseenter', function() {
481
+ const sql = row.getAttribute('data-sql');
482
+ if (sql && !row.classList.contains('selected')) {
483
+ tooltipTimeout = setTimeout(function() {
484
+ sqlTooltip.textContent = sql;
485
+ sqlTooltip.classList.add('visible');
486
+ }, 200);
487
+ }
488
+ });
489
+
490
+ row.addEventListener('mouseleave', function() {
491
+ clearTimeout(tooltipTimeout);
492
+ sqlTooltip.classList.remove('visible');
493
+ });
494
+ });
495
+ });
496
+ </script>
497
+
498
+ <div id="sql-tooltip"></div>
@@ -0,0 +1,112 @@
1
+ <%
2
+ current_path = send("#{@category}_spans_path")
3
+ %>
4
+
5
+ <div class="page-header">
6
+ <h1 class="page-title"><%= page_title %></h1>
7
+ <div class="filters">
8
+ <%= form_tag(current_path, method: :get, id: "filter-form") do %>
9
+ <%= text_field_tag :name, params[:name], placeholder: "Search...", class: "filter-select", style: "min-width: 200px;", onchange: "this.form.submit()" %>
10
+ <% end %>
11
+ </div>
12
+ </div>
13
+
14
+ <div class="page-body">
15
+ <div class="card">
16
+ <div class="card-body">
17
+ <% if @spans.blank? %>
18
+ <div class="empty-state">
19
+ <div class="empty-state-icon">
20
+ <i class="bi bi-search" style="font-size: 1.5rem;"></i>
21
+ </div>
22
+ <h3>No <%= page_title.downcase %> found</h3>
23
+ <p>Data will appear here as your application processes requests.</p>
24
+ </div>
25
+ <% else %>
26
+ <table class="data-table">
27
+ <thead>
28
+ <tr>
29
+ <th><%= page_title.singularize %></th>
30
+ <% if @category == "http" %>
31
+ <th style="width: 70px; text-align: right;">Verb</th>
32
+ <th style="width: 70px; text-align: right;">Status</th>
33
+ <% elsif @category == "cache" %>
34
+ <th style="width: 100px; text-align: right;">Operation</th>
35
+ <% end %>
36
+ <th style="width: 100px; text-align: right;">Duration</th>
37
+ <th style="width: 120px; text-align: right;">Happened</th>
38
+ </tr>
39
+ </thead>
40
+ <tbody>
41
+ <% @spans.each do |span| %>
42
+ <% started_at = Time.at(span[:start_timestamp] / 1_000_000_000.0) rescue nil %>
43
+ <% display_info = span_display_info(span, @category) %>
44
+ <tr onclick="navigateIfNotSelecting('<%= span_path(span[:id], category: @category) %>')" style="cursor: pointer;">
45
+ <td class="path-cell">
46
+ <span class="span-name path-text" title="<%= display_info[:primary] %>"><%= display_info[:primary] %></span>
47
+ <% if display_info[:secondary].present? %>
48
+ <span class="path-subtext"><%= display_info[:secondary] %></span>
49
+ <% end %>
50
+ </td>
51
+ <% if @category == "http" %>
52
+ <td style="text-align: right;">
53
+ <% if display_info[:http_method] %>
54
+ <span class="badge badge-<%= display_info[:http_method].to_s.downcase %>"><%= display_info[:http_method] %></span>
55
+ <% end %>
56
+ </td>
57
+ <td style="text-align: right;">
58
+ <% if display_info[:http_status] %>
59
+ <%
60
+ status_class = case display_info[:http_status].to_s
61
+ when /^2/ then "success"
62
+ when /^[45]/ then "error"
63
+ when /^3/ then "warning"
64
+ else "warning"
65
+ end
66
+ %>
67
+ <span class="badge badge-status badge-<%= status_class %>"><%= display_info[:http_status] %></span>
68
+ <% end %>
69
+ </td>
70
+ <% elsif @category == "cache" %>
71
+ <td style="text-align: right;">
72
+ <% if display_info[:cache_op] %>
73
+ <%
74
+ cache_class = case display_info[:cache_op].to_s
75
+ when /hit/ then "success"
76
+ when /write/ then "warning"
77
+ when /delete/ then "error"
78
+ else "other"
79
+ end
80
+ %>
81
+ <span class="badge badge-status badge-<%= cache_class %>"><%= display_info[:cache_op] %></span>
82
+ <% end %>
83
+ </td>
84
+ <% end %>
85
+ <td class="duration" style="text-align: right;"><%= format_duration(span[:duration_ms]) %></td>
86
+ <td class="timestamp" style="text-align: right;"><%= started_at ? time_ago_in_words(started_at) : "-" %></td>
87
+ </tr>
88
+ <% end %>
89
+ </tbody>
90
+ </table>
91
+
92
+ <% if @total_count > 0 %>
93
+ <div class="pagination">
94
+ <div class="pagination-side">
95
+ <% if @has_prev %>
96
+ <%= link_to "Previous", send("#{@category}_spans_path", request.query_parameters.merge(offset: [@offset - 50, 0].max)), class: "pagination-link" %>
97
+ <% end %>
98
+ </div>
99
+ <div class="pagination-info">
100
+ <%= number_with_delimiter(@offset + 1) %> - <%= number_with_delimiter([@offset + @spans.size, @total_count].min) %> of <%= number_with_delimiter(@total_count) %>
101
+ </div>
102
+ <div class="pagination-side" style="text-align: right;">
103
+ <% if @has_next %>
104
+ <%= link_to "Next", send("#{@category}_spans_path", request.query_parameters.merge(offset: @offset + 50)), class: "pagination-link" %>
105
+ <% end %>
106
+ </div>
107
+ </div>
108
+ <% end %>
109
+ <% end %>
110
+ </div>
111
+ </div>
112
+ </div>