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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: c4147059f707d6d81dfc82021c4c56344701560054f5f11b1fb5411b58d94f2e
4
- data.tar.gz: ce4180ae0fde1b5300d67d4919ff7883204ea61bcc2fe4b13d23fc8471abef2a
3
+ metadata.gz: cbcb0cbcfd4662600eb1cda7d1c0021eeef517a6793f5d0fcd661a56791b3703
4
+ data.tar.gz: 5dd4eb9d6ed12d8188efd6b1e7c70d87e412bbbe86a309bd174c7df54f4f3bad
5
5
  SHA512:
6
- metadata.gz: 39314b333fefeab949b3cb10948e8efa5dd09fd51bf77194e7cc0542e159d1a693d83fea4fdbd7272e0968915154d68387a0e49bc0c28f10e0eb33d2934ae3db
7
- data.tar.gz: ff02750df626ada2511ba6ead16311d8ea5afe42835b25a031328e765a432c7839dabdacc336e4a07ba0d69fffcac07b0795b767ca7905de4b8cc36cc2f81a11
6
+ metadata.gz: 8c72142f20072efae7ea12647c284fca8d264a38ac7241d6bec1c85c435adf4ffabf57a70022039f1451d96dcd8fd7273183f59aa217003b1089e38e41c9494f
7
+ data.tar.gz: 83a87862d28da06b7fe10c3306663a9e6b7ee117a4020e75b7f9c0ed9a13fe8239ce138a5ebd537d030aa4f9ec72e193edd11ee1cfd4de3e09a353b36bd01ad8
data/README.md CHANGED
@@ -1,5 +1,5 @@
1
1
  gem build catpm.gemspec
2
- gem push catpm-0.10.2.gem
2
+ gem push catpm-0.10.4.gem
3
3
 
4
4
  # Catpm
5
5
 
@@ -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" onclick="copyText(this)">Copy</button>
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 Waterfall (full width, no title) ─── %>
104
+ <%# ─── Segments View Toggle ─── %>
105
105
  <% if @segments.any? %>
106
- <%= 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" %>
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">&#x25B8;</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>tr[data-collapsed] { display: none; }</style>
85
- <div style="background:var(--bg-1); border:1px solid var(--border); border-radius:8px; overflow:hidden">
86
- <table style="margin:0" id="<%= table_id %>">
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 style="width:55px">Type</th>
90
- <th style="width:70px">Duration</th>
89
+ <th class="wf-sticky-l0">Type</th>
90
+ <th class="wf-sticky-l1">Duration</th>
91
91
  <th>Detail</th>
92
- <th style="width:35%">Timeline</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 %><%= " data-collapsed" if is_collapsed %>>
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 * 20 %>px">
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 %>" onclick="toggleSegment(this)" title="Collapse/Expand"><%= parent_starts_collapsed ? "▸" : "▾" %></span>
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" onclick="event.stopPropagation()">
124
- <span class="sql-toggle" onclick="var w=this.parentElement;w.classList.toggle('open');this.textContent=w.classList.contains('open')?'\u25BE':'\u25B8'">&#x25B8;</span>
123
+ <div class="sql-wrap">
124
+ <span class="sql-toggle">&#x25B8;</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" onclick="toggleAutoRefresh(event)" style="margin-left:4px; color:var(--text-2)">Pause</a>
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
- window.toggleAutoRefresh = function(e) {
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
 
@@ -32,7 +32,7 @@
32
32
  </div>
33
33
  </div>
34
34
 
35
- <script>
35
+ <script<%= " nonce=\"#{content_security_policy_nonce}\"".html_safe if content_security_policy_nonce %>>
36
36
  (function() {
37
37
  var el = document.querySelector('.pipeline');
38
38
  if (!el) return;
@@ -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 { background: #e8f0fe; color: #1967d2; }
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.removeAttribute('data-collapsed');
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
- // Collapsing: hide all descendants, preserve their toggles
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>