catpm 0.10.3 → 0.11.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 +4 -4
- data/README.md +1 -1
- data/app/helpers/catpm/application_helper.rb +5 -3
- data/app/views/catpm/errors/show.html.erb +1 -1
- data/app/views/catpm/samples/show.html.erb +17 -3
- data/app/views/catpm/shared/_segments_timeline.html.erb +155 -0
- data/app/views/catpm/shared/_segments_waterfall.html.erb +15 -14
- data/app/views/catpm/status/index.html.erb +4 -4
- data/app/views/catpm/system/pipeline.html.erb +1 -1
- data/app/views/layouts/catpm/application.html.erb +147 -6
- data/db/migrate/20250601000001_create_catpm_tables.rb +34 -32
- data/lib/catpm/call_tracer.rb +2 -1
- data/lib/catpm/collector.rb +434 -46
- data/lib/catpm/configuration.rb +6 -2
- data/lib/catpm/engine.rb +9 -0
- data/lib/catpm/fingerprint.rb +6 -0
- data/lib/catpm/middleware.rb +3 -4
- data/lib/catpm/request_segments.rb +18 -1
- data/lib/catpm/segment_subscribers.rb +1 -1
- data/lib/catpm/sidekiq_server_middleware.rb +90 -0
- data/lib/catpm/stack_sampler.rb +3 -3
- data/lib/catpm/version.rb +1 -1
- data/lib/catpm.rb +5 -0
- data/lib/generators/catpm/templates/initializer.rb.tt +2 -2
- metadata +3 -1
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: cbcb0cbcfd4662600eb1cda7d1c0021eeef517a6793f5d0fcd661a56791b3703
|
|
4
|
+
data.tar.gz: 5dd4eb9d6ed12d8188efd6b1e7c70d87e412bbbe86a309bd174c7df54f4f3bad
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: 8c72142f20072efae7ea12647c284fca8d264a38ac7241d6bec1c85c435adf4ffabf57a70022039f1451d96dcd8fd7273183f59aa217003b1089e38e41c9494f
|
|
7
|
+
data.tar.gz: 83a87862d28da06b7fe10c3306663a9e6b7ee117a4020e75b7f9c0ed9a13fe8239ce138a5ebd537d030aa4f9ec72e193edd11ee1cfd4de3e09a353b36bd01ad8
|
data/README.md
CHANGED
|
@@ -7,21 +7,23 @@ module Catpm
|
|
|
7
7
|
'sql' => '#b8e4c6', 'view' => '#e4d4f4', 'cache' => '#fdd8b5',
|
|
8
8
|
'http' => '#f9c4c0', 'mailer' => '#e4d4f4', 'storage' => '#fdd8b5',
|
|
9
9
|
'custom' => '#dde2e8', 'code' => '#c8daf0', 'gem' => '#f0e0f0', 'other' => '#e8e8e8', 'controller' => '#b6d9f7',
|
|
10
|
-
'middleware' => '#f0dfa0', 'request' => '#b6d9f7', 'error' => '#fca5a5'
|
|
10
|
+
'middleware' => '#f0dfa0', 'request' => '#b6d9f7', 'job' => '#d4c5f9', 'error' => '#fca5a5'
|
|
11
11
|
}.freeze
|
|
12
12
|
|
|
13
13
|
SEGMENT_TEXT_COLORS = {
|
|
14
14
|
'sql' => '#1a7f37', 'view' => '#6639a6', 'cache' => '#953800',
|
|
15
15
|
'http' => '#a1110a', 'mailer' => '#6639a6', 'storage' => '#953800',
|
|
16
16
|
'custom' => '#4b5563', 'code' => '#3b5998', 'gem' => '#7b3f9e', 'other' => '#9ca3af', 'controller' => '#0550ae',
|
|
17
|
-
'middleware' => '#7c5c00', 'request' => '#0550ae', 'error' => '#991b1b'
|
|
17
|
+
'middleware' => '#7c5c00', 'request' => '#0550ae', 'job' => '#6639a6', 'error' => '#991b1b'
|
|
18
18
|
}.freeze
|
|
19
19
|
|
|
20
20
|
BADGE_CLASSES = {
|
|
21
21
|
'http' => 'badge-http', 'job' => 'badge-job', 'custom' => 'badge-custom',
|
|
22
22
|
'sql' => 'badge-sql', 'view' => 'badge-view', 'cache' => 'badge-cache',
|
|
23
23
|
'mailer' => 'badge-mailer', 'storage' => 'badge-storage',
|
|
24
|
-
'error' => 'badge-error', 'slow' => 'badge-slow', 'random' => 'badge-random'
|
|
24
|
+
'error' => 'badge-error', 'slow' => 'badge-slow', 'random' => 'badge-random',
|
|
25
|
+
'controller' => 'badge-controller', 'middleware' => 'badge-middleware',
|
|
26
|
+
'request' => 'badge-request', 'code' => 'badge-code', 'gem' => 'badge-gem'
|
|
25
27
|
}.freeze
|
|
26
28
|
|
|
27
29
|
SAMPLE_TYPE_LABELS = {
|
|
@@ -72,7 +72,7 @@
|
|
|
72
72
|
<h2>Backtrace</h2>
|
|
73
73
|
<%= section_description("All occurrences share the same fingerprint and backtrace.") %>
|
|
74
74
|
<div style="border:1px solid var(--border); border-radius:var(--radius); padding:14px; margin-bottom:12px; position:relative">
|
|
75
|
-
<button class="copy-btn" style="position:absolute; top:8px; right:8px"
|
|
75
|
+
<button class="copy-btn" style="position:absolute; top:8px; right:8px">Copy</button>
|
|
76
76
|
<% preview_lines = first_bt.first(10) %>
|
|
77
77
|
<% remaining_lines = first_bt.drop(10) %>
|
|
78
78
|
<pre class="mono" style="font-size:12px; white-space:pre-wrap; margin:0; line-height:1.8"><% preview_lines.each do |line| %><span class="<%= line.match?(%r{/(gems|ruby|vendor|bundle)/}) ? 'backtrace-lib' : 'backtrace-app' %>"><%= line %></span>
|
|
@@ -88,7 +88,7 @@
|
|
|
88
88
|
<div style="display:flex; height:32px; border-radius:4px; overflow:hidden; margin-bottom:4px; border:1px solid var(--border)">
|
|
89
89
|
<% breakdown.each do |label, dur, color, _, text| %>
|
|
90
90
|
<% if dur > 0 && total > 0 %>
|
|
91
|
-
<div title="<%= label %> — <%= format_duration(dur) %> (<%= "%.0f" % (dur/total*100) %>%)" style="flex:<%= dur %>; background:<%= color %>; display:flex; align-items:center; justify-content:center; color:<%= text %>; font-size:11px; font-weight:500; overflow:hidden; white-space:nowrap; padding:0 4px;">
|
|
91
|
+
<div title="<%= label %> — <%= format_duration(dur) %> (<%= "%.0f" % (dur/total*100) %>%)" style="flex:<%= dur / total * 100 %>; background:<%= color %>; display:flex; align-items:center; justify-content:center; color:<%= text %>; font-size:11px; font-weight:500; overflow:hidden; white-space:nowrap; padding:0 4px;">
|
|
92
92
|
<% if dur / total > 0.08 %><%= label %><% end %>
|
|
93
93
|
</div>
|
|
94
94
|
<% end %>
|
|
@@ -101,7 +101,21 @@
|
|
|
101
101
|
</div>
|
|
102
102
|
<% end %>
|
|
103
103
|
|
|
104
|
-
<%# ─── Segments
|
|
104
|
+
<%# ─── Segments View Toggle ─── %>
|
|
105
105
|
<% if @segments.any? %>
|
|
106
|
-
|
|
106
|
+
<div style="display:flex; align-items:center; justify-content:space-between; margin-top:28px; margin-bottom:8px">
|
|
107
|
+
<h2 style="margin:0">Segments</h2>
|
|
108
|
+
<div class="seg-view-tabs">
|
|
109
|
+
<button type="button" class="seg-view-tab active" data-view="table">Table</button>
|
|
110
|
+
<button type="button" class="seg-view-tab" data-view="timeline">Timeline</button>
|
|
111
|
+
</div>
|
|
112
|
+
</div>
|
|
113
|
+
|
|
114
|
+
<div id="seg-view-table">
|
|
115
|
+
<%= render "catpm/shared/segments_waterfall", segments: @segments, total_duration: @sample.duration, segments_capped: @context["segments_capped"] || @context[:segments_capped], segments_filtered: @context["segments_filtered"] || @context[:segments_filtered] || 0, table_id: "segments-table" %>
|
|
116
|
+
</div>
|
|
117
|
+
|
|
118
|
+
<div id="seg-view-timeline" style="display:none">
|
|
119
|
+
<%= render "catpm/shared/segments_timeline", segments: @segments, total_duration: @sample.duration, segments_capped: @context["segments_capped"] || @context[:segments_capped], segments_filtered: @context["segments_filtered"] || @context[:segments_filtered] || 0, container_id: "tl2-container" %>
|
|
120
|
+
</div>
|
|
107
121
|
<% end %>
|
|
@@ -0,0 +1,155 @@
|
|
|
1
|
+
<%# Locals: segments, total_duration, segments_capped, segments_filtered, container_id %>
|
|
2
|
+
|
|
3
|
+
<% if segments.any? %>
|
|
4
|
+
<% total_dur = total_duration.to_f %>
|
|
5
|
+
<% total_dur = 1.0 if total_dur == 0 %>
|
|
6
|
+
|
|
7
|
+
<%
|
|
8
|
+
# Build tree structure
|
|
9
|
+
children = Hash.new { |h, k| h[k] = [] }
|
|
10
|
+
roots = []
|
|
11
|
+
segments.each_with_index do |seg, i|
|
|
12
|
+
pi = seg["parent_index"] || seg[:parent_index]
|
|
13
|
+
if pi.nil?
|
|
14
|
+
roots << i
|
|
15
|
+
else
|
|
16
|
+
children[pi.to_i] << i
|
|
17
|
+
end
|
|
18
|
+
end
|
|
19
|
+
|
|
20
|
+
children.each_value do |kids|
|
|
21
|
+
kids.sort_by! { |i| (segments[i]["offset"] || segments[i][:offset] || 0).to_f }
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
depth_map = {}
|
|
25
|
+
ordered = []
|
|
26
|
+
build_order = ->(indices, depth) {
|
|
27
|
+
indices.each do |i|
|
|
28
|
+
depth_map[i] = depth
|
|
29
|
+
ordered << i
|
|
30
|
+
build_order.call(children.fetch(i, []), depth + 1)
|
|
31
|
+
end
|
|
32
|
+
}
|
|
33
|
+
build_order.call(roots, 0)
|
|
34
|
+
segments.each_index { |i| unless depth_map.key?(i); depth_map[i] = 0; ordered << i; end }
|
|
35
|
+
|
|
36
|
+
has_children = Set.new
|
|
37
|
+
children.each { |pi, _| has_children << pi }
|
|
38
|
+
|
|
39
|
+
collapsed_set = Set.new
|
|
40
|
+
starts_collapsed = Set.new
|
|
41
|
+
segments.each_with_index do |s, i|
|
|
42
|
+
if s["collapsed"] || s[:collapsed]
|
|
43
|
+
collapsed_set << i
|
|
44
|
+
pi = s["parent_index"] || s[:parent_index]
|
|
45
|
+
starts_collapsed << pi.to_i if pi
|
|
46
|
+
end
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
# Count descendant depth for each node
|
|
50
|
+
max_descendant_depth = {}
|
|
51
|
+
calc_max_depth = ->(idx, base_depth) {
|
|
52
|
+
kids = children.fetch(idx, [])
|
|
53
|
+
return base_depth if kids.empty?
|
|
54
|
+
kids.map { |k| calc_max_depth.call(k, base_depth + 1) }.max
|
|
55
|
+
}
|
|
56
|
+
ordered.each do |idx|
|
|
57
|
+
d = depth_map[idx]
|
|
58
|
+
max_descendant_depth[idx] = calc_max_depth.call(idx, d) - d
|
|
59
|
+
end
|
|
60
|
+
%>
|
|
61
|
+
|
|
62
|
+
<div class="tl2" id="<%= container_id %>">
|
|
63
|
+
<div class="tl2-inner">
|
|
64
|
+
<% ordered.each do |seg_idx| %>
|
|
65
|
+
<% seg = segments[seg_idx] %>
|
|
66
|
+
<% type = (seg["type"] || seg[:type]).to_s %>
|
|
67
|
+
<% dur = (seg["duration"] || seg[:duration] || 0).to_f %>
|
|
68
|
+
<% offset = (seg["offset"] || seg[:offset] || 0).to_f %>
|
|
69
|
+
<% detail = (seg["detail"] || seg[:detail]).to_s %>
|
|
70
|
+
<% source = seg["source"] || seg[:source] %>
|
|
71
|
+
<% bar_color = segment_colors[type] || "#484f58" %>
|
|
72
|
+
<% left_pct = (offset / total_dur * 100).clamp(0, 99) %>
|
|
73
|
+
<% width_pct = (dur / total_dur * 100).clamp(0.3, 100 - left_pct) %>
|
|
74
|
+
<% depth = depth_map[seg_idx] %>
|
|
75
|
+
<% is_parent = has_children.include?(seg_idx) %>
|
|
76
|
+
<% is_collapsed = collapsed_set.include?(seg_idx) %>
|
|
77
|
+
<% parent_starts_collapsed = starts_collapsed.include?(seg_idx) %>
|
|
78
|
+
<% desc_depth = max_descendant_depth[seg_idx] || 0 %>
|
|
79
|
+
<% indent_px = depth * 16 %>
|
|
80
|
+
<% pi = seg["parent_index"] || seg[:parent_index] %>
|
|
81
|
+
|
|
82
|
+
<div class="tl2-row"
|
|
83
|
+
data-seg="<%= seg_idx %>"
|
|
84
|
+
data-depth="<%= depth %>"
|
|
85
|
+
<%= " data-parent=\"#{pi}\"" if pi %>
|
|
86
|
+
<%= " data-has-children" if is_parent %>
|
|
87
|
+
style="<%= is_collapsed ? "display:none;" : "" %>">
|
|
88
|
+
|
|
89
|
+
<%# Line 1: toggle + badge + duration + @offset + depth badge %>
|
|
90
|
+
<div class="tl2-meta" style="padding-left:<%= 12 + indent_px %>px">
|
|
91
|
+
<% if is_parent %>
|
|
92
|
+
<span class="tl2-toggle" data-seg="<%= seg_idx %>"><%= parent_starts_collapsed ? "▸" : "▾" %></span>
|
|
93
|
+
<% end %>
|
|
94
|
+
<%= type_badge(type) %>
|
|
95
|
+
<span class="tl2-dur"><%= "%.1f" % dur %>ms</span>
|
|
96
|
+
<span class="tl2-offset">@ <%= format_duration(offset) %></span>
|
|
97
|
+
<% if is_parent && desc_depth > 2 %>
|
|
98
|
+
<span class="tl2-depth-badge"><%= desc_depth %> deep</span>
|
|
99
|
+
<% end %>
|
|
100
|
+
</div>
|
|
101
|
+
|
|
102
|
+
<%# Line 2: detail text %>
|
|
103
|
+
<div class="tl2-detail-line" style="padding-left:<%= 12 + indent_px %>px">
|
|
104
|
+
<% if type == "sql" && detail.length > 60 %>
|
|
105
|
+
<span class="tl2-sql-wrap"><span class="tl2-sql-toggle">▸</span><span class="tl2-detail"><%= detail %></span></span>
|
|
106
|
+
<% else %>
|
|
107
|
+
<span class="tl2-detail <%= 'tl2-detail-capped' unless type == 'sql' %>"><%= detail %></span>
|
|
108
|
+
<% end %>
|
|
109
|
+
</div>
|
|
110
|
+
|
|
111
|
+
<% if source %>
|
|
112
|
+
<%# Line 3: source location %>
|
|
113
|
+
<div class="tl2-source-line" style="padding-left:<%= 12 + indent_px %>px">
|
|
114
|
+
<span class="tl2-source"><%= source %></span>
|
|
115
|
+
</div>
|
|
116
|
+
<% end %>
|
|
117
|
+
|
|
118
|
+
<%# Timeline bar — same width for all rows %>
|
|
119
|
+
<div class="tl2-bar-wrap">
|
|
120
|
+
<% if pi %>
|
|
121
|
+
<% parent_seg = segments[pi.to_i] %>
|
|
122
|
+
<% if parent_seg %>
|
|
123
|
+
<% p_type = (parent_seg["type"] || parent_seg[:type]).to_s %>
|
|
124
|
+
<% p_color = segment_text_colors[p_type] || "#4b5563" %>
|
|
125
|
+
<% p_offset = (parent_seg["offset"] || parent_seg[:offset] || 0).to_f %>
|
|
126
|
+
<% p_dur = (parent_seg["duration"] || parent_seg[:duration] || 0).to_f %>
|
|
127
|
+
<% p_left = (p_offset / total_dur * 100).clamp(0, 99) %>
|
|
128
|
+
<% p_right = ((p_offset + p_dur) / total_dur * 100).clamp(0, 100) %>
|
|
129
|
+
<div class="tl2-bar-parent-edge" style="left:<%= p_left %>%;border-color:<%= p_color %>"></div>
|
|
130
|
+
<div class="tl2-bar-parent-edge" style="left:<%= p_right %>%;border-color:<%= p_color %>"></div>
|
|
131
|
+
<% end %>
|
|
132
|
+
<% end %>
|
|
133
|
+
<% if type == 'error' %>
|
|
134
|
+
<div class="tl2-error-marker" style="left:<%= left_pct %>%"></div>
|
|
135
|
+
<% else %>
|
|
136
|
+
<div class="tl2-bar" style="left:<%= left_pct %>%;width:<%= width_pct %>%;background:<%= bar_color %>"></div>
|
|
137
|
+
<% end %>
|
|
138
|
+
</div>
|
|
139
|
+
</div>
|
|
140
|
+
<% end %>
|
|
141
|
+
|
|
142
|
+
<% filtered = segments_filtered.to_i %>
|
|
143
|
+
<% if filtered > 0 %>
|
|
144
|
+
<div class="tl2-footer">
|
|
145
|
+
<%= filtered %> segment<%= filtered == 1 ? '' : 's' %> below <%= Catpm.config.min_segment_duration %>ms not shown (counted in Time Breakdown)
|
|
146
|
+
</div>
|
|
147
|
+
<% end %>
|
|
148
|
+
</div>
|
|
149
|
+
</div>
|
|
150
|
+
<% else %>
|
|
151
|
+
<div class="empty-state">
|
|
152
|
+
<div class="empty-title">No segments captured</div>
|
|
153
|
+
<div class="empty-hint">Segments appear when SQL queries, view renders, or HTTP calls are instrumented.</div>
|
|
154
|
+
</div>
|
|
155
|
+
<% end %>
|
|
@@ -81,15 +81,15 @@
|
|
|
81
81
|
end
|
|
82
82
|
%>
|
|
83
83
|
|
|
84
|
-
<style
|
|
85
|
-
|
|
86
|
-
<table
|
|
84
|
+
<div style="border:1px solid var(--border); border-radius:8px; overflow:hidden" id="<%= table_id %>">
|
|
85
|
+
<div class="wf-scroll">
|
|
86
|
+
<table class="wf-table">
|
|
87
87
|
<thead>
|
|
88
88
|
<tr>
|
|
89
|
-
<th
|
|
90
|
-
<th
|
|
89
|
+
<th class="wf-sticky-l0">Type</th>
|
|
90
|
+
<th class="wf-sticky-l1">Duration</th>
|
|
91
91
|
<th>Detail</th>
|
|
92
|
-
<th
|
|
92
|
+
<th class="wf-sticky-r">Timeline</th>
|
|
93
93
|
</tr>
|
|
94
94
|
</thead>
|
|
95
95
|
<tbody>
|
|
@@ -110,18 +110,18 @@
|
|
|
110
110
|
<% is_collapsed = collapsed_set.include?(seg_idx) %>
|
|
111
111
|
<% parent_starts_collapsed = starts_collapsed.include?(seg_idx) %>
|
|
112
112
|
|
|
113
|
-
<tr data-seg="<%= seg_idx %>" data-depth="<%= depth %>"<%= " data-parent=\"#{pi}\"" if pi %><%= " data-has-children" if is_parent %><%= "
|
|
114
|
-
<td><%= type_badge(type) %></td>
|
|
115
|
-
<td class="mono" style="text-align:right"><%= "%.2f" % dur %>ms</td>
|
|
113
|
+
<tr data-seg="<%= seg_idx %>" data-depth="<%= depth %>"<%= " data-parent=\"#{pi}\"" if pi %><%= " data-has-children" if is_parent %><%= is_collapsed ? " style=\"display:none\"" : "" %>>
|
|
114
|
+
<td class="wf-sticky-l0"><%= type_badge(type) %></td>
|
|
115
|
+
<td class="wf-sticky-l1 mono" style="text-align:right"><%= "%.2f" % dur %>ms</td>
|
|
116
116
|
<td>
|
|
117
|
-
<div style="padding-left:<%= depth *
|
|
117
|
+
<div class="wf-detail" style="padding-left:<%= depth * 16 %>px">
|
|
118
118
|
<% if is_parent %>
|
|
119
|
-
<span class="seg-toggle" data-seg="<%= seg_idx %>"
|
|
119
|
+
<span class="seg-toggle" data-seg="<%= seg_idx %>" title="Collapse/Expand"><%= parent_starts_collapsed ? "▸" : "▾" %></span>
|
|
120
120
|
<% end %>
|
|
121
121
|
<% if depth > 0 %><span class="tree-indent"><%= prefix %></span><% end %>
|
|
122
122
|
<% if type == "sql" && detail.length > 60 %>
|
|
123
|
-
<div class="sql-wrap"
|
|
124
|
-
<span class="sql-toggle"
|
|
123
|
+
<div class="sql-wrap">
|
|
124
|
+
<span class="sql-toggle">▸</span>
|
|
125
125
|
<span class="sql-text"><%= detail %></span>
|
|
126
126
|
</div>
|
|
127
127
|
<% elsif type == "sql" %>
|
|
@@ -134,7 +134,7 @@
|
|
|
134
134
|
<% end %>
|
|
135
135
|
</div>
|
|
136
136
|
</td>
|
|
137
|
-
<td>
|
|
137
|
+
<td class="wf-sticky-r">
|
|
138
138
|
<div class="bar-container">
|
|
139
139
|
<% if type == 'error' %>
|
|
140
140
|
<div style="position:absolute; left:<%= left_pct %>%; top:2px; bottom:2px; width:3px; background:#dc2626; border-radius:2px"></div>
|
|
@@ -147,6 +147,7 @@
|
|
|
147
147
|
<% end %>
|
|
148
148
|
</tbody>
|
|
149
149
|
</table>
|
|
150
|
+
</div>
|
|
150
151
|
<% filtered = segments_filtered.to_i %>
|
|
151
152
|
<% if filtered > 0 %>
|
|
152
153
|
<div style="padding:6px 12px; font-size:12px; color:var(--text-2); border-top:1px solid var(--border)">
|
|
@@ -16,11 +16,11 @@
|
|
|
16
16
|
<span style="margin-left:auto; font-size:12px; color:var(--text-2)">
|
|
17
17
|
Updated <%= Time.current.strftime("%H:%M:%S") %> ·
|
|
18
18
|
<span id="refresh-status">Refreshing in <span id="refresh-countdown">30</span>s</span>
|
|
19
|
-
<a href="#" id="refresh-toggle"
|
|
19
|
+
<a href="#" id="refresh-toggle" style="margin-left:4px; color:var(--text-2)">Pause</a>
|
|
20
20
|
</span>
|
|
21
21
|
</div>
|
|
22
22
|
|
|
23
|
-
<script
|
|
23
|
+
<script<%= " nonce=\"#{content_security_policy_nonce}\"".html_safe if content_security_policy_nonce %>>
|
|
24
24
|
(function() {
|
|
25
25
|
var seconds = 30, paused = false, timer;
|
|
26
26
|
var countdownEl = document.getElementById('refresh-countdown');
|
|
@@ -34,7 +34,7 @@
|
|
|
34
34
|
timer = setTimeout(tick, 1000);
|
|
35
35
|
}
|
|
36
36
|
timer = setTimeout(tick, 1000);
|
|
37
|
-
|
|
37
|
+
toggleEl.addEventListener('click', function(e) {
|
|
38
38
|
e.preventDefault();
|
|
39
39
|
paused = !paused;
|
|
40
40
|
if (paused) {
|
|
@@ -47,7 +47,7 @@
|
|
|
47
47
|
toggleEl.textContent = 'Pause';
|
|
48
48
|
timer = setTimeout(tick, 1000);
|
|
49
49
|
}
|
|
50
|
-
};
|
|
50
|
+
});
|
|
51
51
|
})();
|
|
52
52
|
</script>
|
|
53
53
|
|
|
@@ -137,7 +137,12 @@
|
|
|
137
137
|
.badge-error { background: #ffebe9; color: var(--red); }
|
|
138
138
|
.badge-slow { background: #fff8c5; color: var(--yellow); }
|
|
139
139
|
.badge-random { background: var(--bg-2); color: var(--text-1); }
|
|
140
|
-
.badge-event
|
|
140
|
+
.badge-event { background: #e8f0fe; color: #1967d2; }
|
|
141
|
+
.badge-controller { background: #ddf4ff; color: #0550ae; }
|
|
142
|
+
.badge-middleware { background: #fff8c5; color: #7c5c00; }
|
|
143
|
+
.badge-request { background: #ddf4ff; color: #0550ae; }
|
|
144
|
+
.badge-code { background: #dae5f4; color: #3b5998; }
|
|
145
|
+
.badge-gem { background: #f5e6f8; color: #7b3f9e; }
|
|
141
146
|
|
|
142
147
|
/* ─── Status Dot ─── */
|
|
143
148
|
.status-dot { font-size: 13px; color: var(--text-1); display: inline-flex; align-items: center; gap: 6px; }
|
|
@@ -249,11 +254,55 @@
|
|
|
249
254
|
/* ─── Waterfall ─── */
|
|
250
255
|
.bar-container { position: relative; height: 18px; background: var(--bg-2); border-radius: 3px; overflow: hidden; margin: 3px 0; }
|
|
251
256
|
.bar-fill { height: 100%; border-radius: 3px; display: flex; align-items: center; padding-left: 6px; font-size: 10px; color: var(--text-0); min-width: fit-content; }
|
|
257
|
+
.wf-scroll { overflow-x: auto; -webkit-overflow-scrolling: touch; scrollbar-width: thin; }
|
|
258
|
+
.wf-table { margin: 0; white-space: nowrap; table-layout: auto; }
|
|
259
|
+
.wf-table td, .wf-table th { vertical-align: middle; }
|
|
260
|
+
.wf-table thead th { background: var(--bg-1); }
|
|
261
|
+
.wf-sticky-l0 { position: sticky; left: 0; z-index: 2; background: var(--bg-0); }
|
|
262
|
+
.wf-sticky-l1 { position: sticky; left: 85px; z-index: 2; background: var(--bg-0); }
|
|
263
|
+
.wf-sticky-r { position: sticky; right: 0; z-index: 2; background: var(--bg-1); min-width: 180px; }
|
|
264
|
+
thead .wf-sticky-l0, thead .wf-sticky-l1 { background: var(--bg-1); }
|
|
265
|
+
thead .wf-sticky-r { background: var(--bg-1); }
|
|
252
266
|
.tree-indent { display: inline-block; color: var(--border); font-family: monospace; white-space: pre; user-select: none; }
|
|
253
267
|
.seg-toggle { display: inline-block; width: 16px; cursor: pointer; color: var(--text-2); font-size: 12px; user-select: none; text-align: center; vertical-align: middle; }
|
|
254
268
|
.seg-toggle:hover { color: var(--text-0); }
|
|
255
269
|
.seg-leaf { display: inline-block; width: 16px; }
|
|
256
270
|
|
|
271
|
+
/* ─── Timeline v2 ─── */
|
|
272
|
+
.tl2 { border: 1px solid var(--border); border-radius: 8px; overflow-x: auto; scrollbar-width: thin; -webkit-overflow-scrolling: touch; }
|
|
273
|
+
.tl2-row { border-bottom: 1px solid var(--bg-2); padding: 0; transition: background 0.1s; }
|
|
274
|
+
.tl2-row:last-child { border-bottom: none; }
|
|
275
|
+
.tl2-row[data-depth="0"] { background: var(--bg-1); }
|
|
276
|
+
.tl2-meta { display: flex; align-items: baseline; gap: 6px; padding: 6px 12px 0; }
|
|
277
|
+
.tl2-toggle { flex-shrink: 0; width: 14px; cursor: pointer; color: var(--text-2); font-size: 11px; user-select: none; text-align: center; }
|
|
278
|
+
.tl2-toggle:hover { color: var(--text-0); }
|
|
279
|
+
.tl2-dur { font-family: var(--font-mono); font-size: 11px; color: var(--text-0); font-weight: 500; white-space: nowrap; }
|
|
280
|
+
.tl2-offset { font-family: var(--font-mono); font-size: 10px; color: var(--text-2); white-space: nowrap; }
|
|
281
|
+
.tl2-depth-badge { font-size: 10px; color: var(--text-2); background: var(--bg-2); border-radius: 3px; padding: 0 4px; flex-shrink: 0; }
|
|
282
|
+
.tl2-inner { min-width: 100%; display: inline-block; }
|
|
283
|
+
.tl2-detail-line { padding: 2px 12px 0; white-space: nowrap; }
|
|
284
|
+
.tl2-detail { font-family: var(--font-mono); font-size: 12px; color: var(--text-0); }
|
|
285
|
+
.tl2-detail-capped { display: inline-block; max-width: 700px; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; vertical-align: bottom; }
|
|
286
|
+
.tl2-sql-wrap { display: inline; }
|
|
287
|
+
.tl2-sql-toggle { cursor: pointer; color: var(--text-2); font-size: 11px; user-select: none; }
|
|
288
|
+
.tl2-sql-toggle:hover { color: var(--text-0); }
|
|
289
|
+
.tl2-sql-wrap .tl2-detail { display: inline-block; max-width: 700px; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; vertical-align: bottom; }
|
|
290
|
+
.tl2-sql-wrap.open .tl2-detail { max-width: none; white-space: pre-wrap; word-break: break-all; overflow: visible; }
|
|
291
|
+
.tl2-source-line { padding: 0 12px 0; white-space: nowrap; }
|
|
292
|
+
.tl2-source { font-family: var(--font-mono); font-size: 10px; color: var(--text-2); }
|
|
293
|
+
.tl2-bar-wrap { position: relative; height: 20px; margin: 3px 12px 6px; background: var(--bg-2); border-radius: 3px; overflow: visible; }
|
|
294
|
+
.tl2-bar-wrap > .tl2-bar { overflow: hidden; }
|
|
295
|
+
.tl2-bar-parent-edge { position: absolute; top: -2px; bottom: -2px; width: 0; border-left: 1px dashed; opacity: 0.5; z-index: 2; }
|
|
296
|
+
.tl2-bar { position: absolute; top: 0; height: 100%; border-radius: 3px; min-width: 2px; z-index: 1; }
|
|
297
|
+
.tl2-error-marker { position: absolute; top: 2px; bottom: 2px; width: 3px; background: #dc2626; border-radius: 2px; }
|
|
298
|
+
.tl2-footer { padding: 6px 12px; font-size: 12px; color: var(--text-2); border-top: 1px solid var(--border); }
|
|
299
|
+
|
|
300
|
+
/* ─── Segment View Tabs ─── */
|
|
301
|
+
.seg-view-tabs { display: flex; gap: 2px; background: var(--bg-2); border-radius: 5px; padding: 2px; }
|
|
302
|
+
.seg-view-tab { background: none; border: none; padding: 4px 12px; font-size: 12px; font-weight: 500; color: var(--text-2); border-radius: 4px; cursor: pointer; font-family: var(--font-sans); }
|
|
303
|
+
.seg-view-tab:hover { color: var(--text-1); }
|
|
304
|
+
.seg-view-tab.active { background: var(--bg-0); color: var(--text-0); box-shadow: 0 1px 2px rgba(0,0,0,0.06); }
|
|
305
|
+
|
|
257
306
|
/* ─── Bar Chart ─── */
|
|
258
307
|
.bar-chart-wrap { position: relative; }
|
|
259
308
|
.bar-chart-max { position: absolute; top: 2px; left: 4px; font-size: 11px; color: var(--text-2); font-family: var(--font-sans); pointer-events: none; line-height: 1; }
|
|
@@ -372,7 +421,7 @@
|
|
|
372
421
|
|
|
373
422
|
<div class="footer">catpm v<%= Catpm::VERSION %></div>
|
|
374
423
|
|
|
375
|
-
<script
|
|
424
|
+
<script<%= " nonce=\"#{content_security_policy_nonce}\"".html_safe if content_security_policy_nonce %>>
|
|
376
425
|
function toggleSegment(btn) {
|
|
377
426
|
var segId = btn.getAttribute('data-seg');
|
|
378
427
|
var isCollapsed = btn.textContent.trim() === '\u25B8';
|
|
@@ -388,15 +437,13 @@
|
|
|
388
437
|
var d = parseInt(row.getAttribute('data-depth'));
|
|
389
438
|
if (d <= segDepth) break;
|
|
390
439
|
if (isCollapsed) {
|
|
391
|
-
// Expanding: show direct children, skip nested collapsed subtrees
|
|
392
440
|
if (skipBelow >= 0 && d > skipBelow) continue;
|
|
393
441
|
skipBelow = -1;
|
|
394
|
-
row.
|
|
442
|
+
row.style.display = '';
|
|
395
443
|
var t = row.querySelector('.seg-toggle');
|
|
396
444
|
if (t && t.textContent.trim() === '\u25B8') skipBelow = d;
|
|
397
445
|
} else {
|
|
398
|
-
|
|
399
|
-
row.setAttribute('data-collapsed', '');
|
|
446
|
+
row.style.display = 'none';
|
|
400
447
|
}
|
|
401
448
|
}
|
|
402
449
|
}
|
|
@@ -646,6 +693,100 @@
|
|
|
646
693
|
});
|
|
647
694
|
});
|
|
648
695
|
});
|
|
696
|
+
|
|
697
|
+
/* ─── Timeline v2 toggle ─── */
|
|
698
|
+
function tl2Toggle(btn) {
|
|
699
|
+
var segId = btn.getAttribute('data-seg');
|
|
700
|
+
var isCollapsed = btn.textContent.trim() === '\u25B8';
|
|
701
|
+
btn.textContent = isCollapsed ? '\u25BE' : '\u25B8';
|
|
702
|
+
var container = btn.closest('.tl2');
|
|
703
|
+
var rows = container.querySelectorAll('.tl2-row');
|
|
704
|
+
var segDepth = null, found = false;
|
|
705
|
+
var skipBelow = -1;
|
|
706
|
+
for (var i = 0; i < rows.length; i++) {
|
|
707
|
+
var row = rows[i];
|
|
708
|
+
if (row.getAttribute('data-seg') === segId) { found = true; segDepth = parseInt(row.getAttribute('data-depth')); continue; }
|
|
709
|
+
if (!found) continue;
|
|
710
|
+
var d = parseInt(row.getAttribute('data-depth'));
|
|
711
|
+
if (d <= segDepth) break;
|
|
712
|
+
if (isCollapsed) {
|
|
713
|
+
if (skipBelow >= 0 && d > skipBelow) continue;
|
|
714
|
+
skipBelow = -1;
|
|
715
|
+
row.style.display = '';
|
|
716
|
+
var t = row.querySelector('.tl2-toggle');
|
|
717
|
+
if (t && t.textContent.trim() === '\u25B8') skipBelow = d;
|
|
718
|
+
} else {
|
|
719
|
+
row.style.display = 'none';
|
|
720
|
+
}
|
|
721
|
+
}
|
|
722
|
+
}
|
|
723
|
+
|
|
724
|
+
/* ─── Segment view tab switcher ─── */
|
|
725
|
+
function switchSegView(tab) {
|
|
726
|
+
var view = tab.dataset.view;
|
|
727
|
+
var tabs = tab.parentElement.querySelectorAll('.seg-view-tab');
|
|
728
|
+
tabs.forEach(function(t) { t.classList.toggle('active', t === tab); });
|
|
729
|
+
var timeline = document.getElementById('seg-view-timeline');
|
|
730
|
+
var table = document.getElementById('seg-view-table');
|
|
731
|
+
if (timeline) timeline.style.display = view === 'timeline' ? '' : 'none';
|
|
732
|
+
if (table) table.style.display = view === 'table' ? '' : 'none';
|
|
733
|
+
try { localStorage.setItem('catpm:seg-view', view); } catch(e) {}
|
|
734
|
+
}
|
|
735
|
+
|
|
736
|
+
/* ─── Restore preferred view on load ─── */
|
|
737
|
+
document.addEventListener('DOMContentLoaded', function() {
|
|
738
|
+
try {
|
|
739
|
+
var saved = localStorage.getItem('catpm:seg-view');
|
|
740
|
+
if (saved) {
|
|
741
|
+
var tab = document.querySelector('.seg-view-tab[data-view="' + saved + '"]');
|
|
742
|
+
if (tab && !tab.classList.contains('active')) switchSegView(tab);
|
|
743
|
+
}
|
|
744
|
+
} catch(e) {}
|
|
745
|
+
});
|
|
746
|
+
|
|
747
|
+
/* ─── Delegated handlers (CSP-safe, replaces inline onclick) ─── */
|
|
748
|
+
document.addEventListener('click', function(e) {
|
|
749
|
+
/* Segment tree toggle (v1 waterfall) */
|
|
750
|
+
var segToggle = e.target.closest('.seg-toggle');
|
|
751
|
+
if (segToggle) { e.stopPropagation(); toggleSegment(segToggle); return; }
|
|
752
|
+
|
|
753
|
+
/* Timeline v2 tree toggle */
|
|
754
|
+
var tl2ToggleBtn = e.target.closest('.tl2-toggle');
|
|
755
|
+
if (tl2ToggleBtn) { e.stopPropagation(); tl2Toggle(tl2ToggleBtn); return; }
|
|
756
|
+
|
|
757
|
+
/* Timeline v2 SQL expand */
|
|
758
|
+
var tl2SqlToggle = e.target.closest('.tl2-sql-toggle');
|
|
759
|
+
if (tl2SqlToggle) {
|
|
760
|
+
e.stopPropagation();
|
|
761
|
+
var w = tl2SqlToggle.parentElement;
|
|
762
|
+
w.classList.toggle('open');
|
|
763
|
+
tl2SqlToggle.innerHTML = w.classList.contains('open') ? '\u25BE' : '\u25B8';
|
|
764
|
+
return;
|
|
765
|
+
}
|
|
766
|
+
|
|
767
|
+
/* Segment view tab switch */
|
|
768
|
+
var viewTab = e.target.closest('.seg-view-tab');
|
|
769
|
+
if (viewTab) { e.preventDefault(); e.stopPropagation(); switchSegView(viewTab); return; }
|
|
770
|
+
|
|
771
|
+
/* SQL expand/collapse */
|
|
772
|
+
var sqlToggle = e.target.closest('.sql-toggle');
|
|
773
|
+
if (sqlToggle) {
|
|
774
|
+
e.stopPropagation();
|
|
775
|
+
var w = sqlToggle.parentElement;
|
|
776
|
+
w.classList.toggle('open');
|
|
777
|
+
sqlToggle.textContent = w.classList.contains('open') ? '\u25BE' : '\u25B8';
|
|
778
|
+
return;
|
|
779
|
+
}
|
|
780
|
+
|
|
781
|
+
/* Prevent sql-wrap clicks from bubbling to row */
|
|
782
|
+
if (e.target.closest('.sql-wrap')) { e.stopPropagation(); }
|
|
783
|
+
|
|
784
|
+
/* Copy button */
|
|
785
|
+
var copyBtn = e.target.closest('.copy-btn');
|
|
786
|
+
if (copyBtn) { e.stopPropagation(); copyText(copyBtn); return; }
|
|
787
|
+
});
|
|
788
|
+
|
|
789
|
+
|
|
649
790
|
</script>
|
|
650
791
|
</body>
|
|
651
792
|
</html>
|