rperf 0.9.0 → 0.10.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 +15 -6
- data/docs/help.md +179 -10
- data/exe/rperf +247 -53
- data/ext/rperf/rperf.c +96 -43
- data/lib/rperf/meta.rb +343 -0
- data/lib/rperf/rack.rb +7 -2
- data/lib/rperf/table.rb +156 -0
- data/lib/rperf/version.rb +1 -1
- data/lib/rperf/viewer/viewer.html +1148 -0
- data/lib/rperf/viewer.rb +101 -653
- data/lib/rperf.rb +208 -69
- metadata +4 -1
data/lib/rperf/viewer.rb
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
require_relative "../rperf"
|
|
2
2
|
require "json"
|
|
3
|
+
require "time"
|
|
3
4
|
|
|
4
5
|
# Rack middleware that serves flamegraph visualizations of rperf snapshots.
|
|
5
6
|
#
|
|
@@ -19,6 +20,12 @@ require "json"
|
|
|
19
20
|
# viewer.take_snapshot! # snapshot with clear: true
|
|
20
21
|
# viewer.add_snapshot(data) # or add pre-taken snapshot data
|
|
21
22
|
#
|
|
23
|
+
# Time-travel mode (multiple snapshots from a directory):
|
|
24
|
+
# viewer.add_snapshot_dir("./profiles") # *.json(.gz) files; lazy-loaded
|
|
25
|
+
#
|
|
26
|
+
# The UI fetches data from /snapshots (list with meta/summary only) and
|
|
27
|
+
# /snapshots/:id (full body). When more than one snapshot is present, a
|
|
28
|
+
# sidebar lists them with commit info and diff/pin/sparkline support.
|
|
22
29
|
class Rperf::Viewer
|
|
23
30
|
@instance = nil
|
|
24
31
|
|
|
@@ -34,7 +41,9 @@ class Rperf::Viewer
|
|
|
34
41
|
@app = app
|
|
35
42
|
@path = path.chomp("/")
|
|
36
43
|
@max_snapshots = max_snapshots
|
|
37
|
-
|
|
44
|
+
# In-memory entries: {id:, taken_at:, data:}
|
|
45
|
+
# Directory entries: {id:, taken_at:, path:, meta:, summary:} — body lazy-loaded
|
|
46
|
+
@snapshots = []
|
|
38
47
|
@mutex = Mutex.new
|
|
39
48
|
@next_id = 0
|
|
40
49
|
self.class.instance_variable_set(:@instance, self)
|
|
@@ -49,16 +58,58 @@ class Rperf::Viewer
|
|
|
49
58
|
end
|
|
50
59
|
|
|
51
60
|
# Add a pre-taken snapshot hash to the history.
|
|
61
|
+
# Attaches meta/summary (phase-1 profile format) unless already present,
|
|
62
|
+
# so in-memory snapshots and directory profiles share the same list UI.
|
|
52
63
|
def add_snapshot(data)
|
|
64
|
+
data[:meta] ||= Rperf::Meta.build_meta(data)
|
|
65
|
+
data[:summary] ||= Rperf::Meta.build_summary(data)
|
|
53
66
|
@mutex.synchronize do
|
|
54
67
|
@next_id += 1
|
|
55
68
|
entry = { id: @next_id, taken_at: Time.now, data: data }
|
|
56
69
|
@snapshots << entry
|
|
57
|
-
|
|
70
|
+
# Evict only in-memory snapshots: directory entries (time-travel mode)
|
|
71
|
+
# are exempt from max_snapshots and hold no body memory anyway
|
|
72
|
+
while @snapshots.count { |s| s[:data] } > @max_snapshots
|
|
73
|
+
idx = @snapshots.index { |s| s[:data] }
|
|
74
|
+
@snapshots.delete_at(idx)
|
|
75
|
+
end
|
|
58
76
|
entry
|
|
59
77
|
end
|
|
60
78
|
end
|
|
61
79
|
|
|
80
|
+
# Add all *.json(.gz) profiles in dir as lazy-loaded snapshots
|
|
81
|
+
# (time-travel mode). Only meta/summary are read up front (Rperf.read_meta);
|
|
82
|
+
# bodies are loaded on selection. Files without meta (older rperf) are
|
|
83
|
+
# listed as unknown snapshots. Entries are sorted by
|
|
84
|
+
# meta.git.committed_at → meta.created_at → file mtime.
|
|
85
|
+
# max_snapshots does not apply. Returns the number of files added.
|
|
86
|
+
def add_snapshot_dir(dir)
|
|
87
|
+
files = Dir.glob(File.join(dir, "*.json.gz")) + Dir.glob(File.join(dir, "*.json"))
|
|
88
|
+
entries = files.filter_map do |file|
|
|
89
|
+
head = Rperf.read_meta(file)
|
|
90
|
+
meta = head && head[:meta]
|
|
91
|
+
summary = head && head[:summary]
|
|
92
|
+
# File may vanish between glob and stat — skip instead of aborting the
|
|
93
|
+
# whole listing
|
|
94
|
+
mtime = begin
|
|
95
|
+
File.mtime(file)
|
|
96
|
+
rescue SystemCallError
|
|
97
|
+
next
|
|
98
|
+
end
|
|
99
|
+
{ path: file, meta: meta, summary: summary, taken_at: mtime,
|
|
100
|
+
sort_time: snapshot_sort_time(meta, mtime) }
|
|
101
|
+
end
|
|
102
|
+
entries.sort_by! { |e| e[:sort_time] }
|
|
103
|
+
@mutex.synchronize do
|
|
104
|
+
entries.each do |e|
|
|
105
|
+
@next_id += 1
|
|
106
|
+
@snapshots << { id: @next_id, taken_at: e[:taken_at], path: e[:path],
|
|
107
|
+
meta: e[:meta], summary: e[:summary] }
|
|
108
|
+
end
|
|
109
|
+
end
|
|
110
|
+
entries.size
|
|
111
|
+
end
|
|
112
|
+
|
|
62
113
|
# Rack interface
|
|
63
114
|
def call(env)
|
|
64
115
|
req_path = env["PATH_INFO"] || "/"
|
|
@@ -91,11 +142,12 @@ class Rperf::Viewer
|
|
|
91
142
|
# Stack is stored top-to-bottom (leaf first) in C; reverse to root-first for flamegraph.
|
|
92
143
|
# Label set keys are converted from symbols to strings for JSON.
|
|
93
144
|
def self.samples_to_json(samples, label_sets)
|
|
94
|
-
json_samples = samples.map do |frames, weight,
|
|
145
|
+
json_samples = samples.map do |frames, weight, _thread_seq, label_set_id|
|
|
146
|
+
# thread_seq is intentionally omitted: the viewer UI never reads it,
|
|
147
|
+
# and it would bloat the largest responses the viewer serves
|
|
95
148
|
{
|
|
96
149
|
stack: frames.reverse.map { |_, label| label },
|
|
97
150
|
weight: weight,
|
|
98
|
-
thread_seq: thread_seq || 0,
|
|
99
151
|
label_set_id: label_set_id || 0,
|
|
100
152
|
}
|
|
101
153
|
end
|
|
@@ -107,6 +159,8 @@ class Rperf::Viewer
|
|
|
107
159
|
|
|
108
160
|
# Generate a self-contained static HTML file with inline snapshot data.
|
|
109
161
|
# The HTML loads d3/d3-flamegraph from CDN but requires no server.
|
|
162
|
+
# This is the one intentional exception to fetch-based data loading:
|
|
163
|
+
# a static file has no server to fetch from.
|
|
110
164
|
def self.render_static_html(data)
|
|
111
165
|
samples = data[:aggregated_samples] || []
|
|
112
166
|
label_sets = data[:label_sets] || []
|
|
@@ -119,6 +173,8 @@ class Rperf::Viewer
|
|
|
119
173
|
frequency: data[:frequency],
|
|
120
174
|
duration_ns: data[:duration_ns],
|
|
121
175
|
sampling_count: data[:sampling_count],
|
|
176
|
+
meta: data[:meta],
|
|
177
|
+
summary: data[:summary],
|
|
122
178
|
samples: json_samples,
|
|
123
179
|
label_sets: json_label_sets,
|
|
124
180
|
})
|
|
@@ -127,8 +183,9 @@ class Rperf::Viewer
|
|
|
127
183
|
|
|
128
184
|
html = VIEWER_HTML.sub("<!-- LOGO -->") { logo }
|
|
129
185
|
|
|
130
|
-
# Hide snapshot selector
|
|
131
|
-
|
|
186
|
+
# Hide the snapshot selector including its "Snapshot:" label text
|
|
187
|
+
# (single snapshot, no server)
|
|
188
|
+
html = html.sub('<label id="lbl-snapshot">', '<label id="lbl-snapshot" style="display:none">')
|
|
132
189
|
|
|
133
190
|
# Replace dynamic loading with inline data.
|
|
134
191
|
# Escape for safe embedding in <script>:
|
|
@@ -136,21 +193,36 @@ class Rperf::Viewer
|
|
|
136
193
|
# - U+2028/U+2029 are line terminators in JS but valid in JSON
|
|
137
194
|
json_safe = json_snapshot
|
|
138
195
|
.gsub("</", "<\\/")
|
|
139
|
-
.gsub("
|
|
140
|
-
.gsub("
|
|
141
|
-
|
|
142
|
-
|
|
196
|
+
.gsub("
", "\\u2028")
|
|
197
|
+
.gsub("
", "\\u2029")
|
|
198
|
+
# Block form: the String-replacement form of sub interprets \\ and \&
|
|
199
|
+
# in the replacement, corrupting JSON that contains backslashes
|
|
200
|
+
html = html.sub("loadSnapshotList().catch(showLoadError);") {
|
|
201
|
+
"currentData = #{json_safe}; updateTagDropdowns(); applyAndRender();"
|
|
202
|
+
}
|
|
143
203
|
|
|
144
204
|
html
|
|
145
205
|
end
|
|
146
206
|
|
|
147
207
|
private
|
|
148
208
|
|
|
209
|
+
def snapshot_sort_time(meta, mtime)
|
|
210
|
+
str = meta&.dig(:git, :committed_at) || meta&.dig(:created_at)
|
|
211
|
+
return mtime unless str
|
|
212
|
+
begin
|
|
213
|
+
Time.iso8601(str)
|
|
214
|
+
rescue ArgumentError
|
|
215
|
+
mtime
|
|
216
|
+
end
|
|
217
|
+
end
|
|
218
|
+
|
|
149
219
|
LOGO_SVG = begin
|
|
150
220
|
path = File.expand_path("../../docs/logo.svg", __dir__)
|
|
151
221
|
File.exist?(path) ? File.read(path).freeze : ""
|
|
152
222
|
end
|
|
153
223
|
|
|
224
|
+
VIEWER_HTML = File.read(File.expand_path("viewer/viewer.html", __dir__)).freeze
|
|
225
|
+
|
|
154
226
|
def serve_html
|
|
155
227
|
logo = LOGO_SVG
|
|
156
228
|
.sub("<svg ", '<svg style="height:36px;width:auto" ')
|
|
@@ -165,12 +237,18 @@ class Rperf::Viewer
|
|
|
165
237
|
def serve_snapshot_list
|
|
166
238
|
list = @mutex.synchronize do
|
|
167
239
|
@snapshots.map do |s|
|
|
240
|
+
data = s[:data]
|
|
168
241
|
{
|
|
169
242
|
id: s[:id],
|
|
170
243
|
taken_at: s[:taken_at].iso8601,
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
244
|
+
# scrub: one non-UTF8 filename must not make JSON.generate raise
|
|
245
|
+
# and 500 the whole snapshot list
|
|
246
|
+
file: s[:path] ? File.basename(s[:path]).dup.force_encoding(Encoding::UTF_8).scrub : nil,
|
|
247
|
+
mode: data ? data[:mode] : s.dig(:meta, :mode),
|
|
248
|
+
duration_ns: data && data[:duration_ns],
|
|
249
|
+
sampling_count: data && data[:sampling_count],
|
|
250
|
+
meta: data ? data[:meta] : s[:meta],
|
|
251
|
+
summary: data ? data[:summary] : s[:summary],
|
|
174
252
|
}
|
|
175
253
|
end
|
|
176
254
|
end
|
|
@@ -182,6 +260,14 @@ class Rperf::Viewer
|
|
|
182
260
|
return [404, { "content-type" => "text/plain" }, ["Snapshot not found"]] unless entry
|
|
183
261
|
|
|
184
262
|
data = entry[:data]
|
|
263
|
+
if data.nil? && entry[:path]
|
|
264
|
+
# Lazy-load directory entry; body is not retained server-side
|
|
265
|
+
begin
|
|
266
|
+
data = Rperf.load(entry[:path])
|
|
267
|
+
rescue StandardError => e
|
|
268
|
+
return [500, { "content-type" => "text/plain" }, ["Failed to load #{File.basename(entry[:path])}: #{e.message}"]]
|
|
269
|
+
end
|
|
270
|
+
end
|
|
185
271
|
samples = data[:aggregated_samples] || []
|
|
186
272
|
label_sets = data[:label_sets] || []
|
|
187
273
|
json_samples, json_label_sets = self.class.samples_to_json(samples, label_sets)
|
|
@@ -193,6 +279,8 @@ class Rperf::Viewer
|
|
|
193
279
|
frequency: data[:frequency],
|
|
194
280
|
duration_ns: data[:duration_ns],
|
|
195
281
|
sampling_count: data[:sampling_count],
|
|
282
|
+
meta: data[:meta] || entry[:meta],
|
|
283
|
+
summary: data[:summary] || entry[:summary],
|
|
196
284
|
samples: json_samples,
|
|
197
285
|
label_sets: json_label_sets,
|
|
198
286
|
})
|
|
@@ -204,644 +292,4 @@ class Rperf::Viewer
|
|
|
204
292
|
"x-content-type-options" => "nosniff",
|
|
205
293
|
}, [JSON.generate(obj)]]
|
|
206
294
|
end
|
|
207
|
-
|
|
208
|
-
VIEWER_HTML = <<~'HTML'
|
|
209
|
-
<!DOCTYPE html>
|
|
210
|
-
<html lang="en">
|
|
211
|
-
<head>
|
|
212
|
-
<meta charset="utf-8">
|
|
213
|
-
<meta http-equiv="Content-Security-Policy" content="default-src 'none'; script-src 'unsafe-inline' https://cdnjs.cloudflare.com https://cdn.jsdelivr.net; style-src 'unsafe-inline' https://cdn.jsdelivr.net; connect-src 'self'; img-src data:; frame-ancestors 'none'">
|
|
214
|
-
<title>rperf Viewer</title>
|
|
215
|
-
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/d3-flame-graph@4/dist/d3-flamegraph.css" integrity="sha384-DgAQSBzzhv8bu6Qc6Lq08THluOr+kO5qLMHt1yv8A3my7Jz2OQv6aq/WSZRYIQkG" crossorigin="anonymous">
|
|
216
|
-
<style>
|
|
217
|
-
* { box-sizing: border-box; margin: 0; padding: 0; }
|
|
218
|
-
body { font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, monospace; background: #fafafa; color: #333; }
|
|
219
|
-
|
|
220
|
-
/* Header */
|
|
221
|
-
.header { background: #fff; padding: 10px 20px; display: flex; align-items: center; gap: 16px; flex-wrap: wrap; border-bottom: 1px solid #ddd; }
|
|
222
|
-
.controls { display: flex; align-items: center; gap: 10px; flex-wrap: wrap; }
|
|
223
|
-
.controls label { font-size: 13px; color: #555; }
|
|
224
|
-
.controls select, .controls input[type="text"] {
|
|
225
|
-
background: #fff; color: #333; border: 1px solid #ccc; border-radius: 4px;
|
|
226
|
-
padding: 4px 8px; font-size: 13px; font-family: inherit;
|
|
227
|
-
}
|
|
228
|
-
.controls input[type="text"] { width: 120px; }
|
|
229
|
-
.dropdown-cb { position: relative; display: inline-block; vertical-align: middle; }
|
|
230
|
-
.dropdown-cb-btn {
|
|
231
|
-
background: #fff; color: #888; border: 1px solid #ccc; border-radius: 4px;
|
|
232
|
-
padding: 4px 8px; font-size: 13px; font-family: inherit; cursor: pointer; min-width: 60px; text-align: left;
|
|
233
|
-
}
|
|
234
|
-
.dropdown-cb-btn.has-selection { color: #333; }
|
|
235
|
-
.dropdown-cb-btn:hover { border-color: #999; }
|
|
236
|
-
.dropdown-cb-list {
|
|
237
|
-
display: none; position: absolute; top: 100%; left: 0; z-index: 100;
|
|
238
|
-
background: #fff; border: 1px solid #ccc; border-radius: 4px;
|
|
239
|
-
padding: 4px 0; min-width: 180px; max-height: 240px; overflow-y: auto;
|
|
240
|
-
box-shadow: 0 4px 12px rgba(0,0,0,0.15);
|
|
241
|
-
}
|
|
242
|
-
.dropdown-cb-list.open { display: block; }
|
|
243
|
-
.dropdown-cb-list label {
|
|
244
|
-
display: block; padding: 4px 10px; font-size: 12px; cursor: pointer; white-space: nowrap;
|
|
245
|
-
color: #333; background: none; border: none; border-radius: 0;
|
|
246
|
-
}
|
|
247
|
-
.dropdown-cb-list label:hover { background: #f0e8e0; }
|
|
248
|
-
.controls input[type="text"]::placeholder { color: #aaa; }
|
|
249
|
-
|
|
250
|
-
/* Tabs */
|
|
251
|
-
.tabs { display: flex; background: #fff; border-bottom: 1px solid #ddd; padding: 0 20px; }
|
|
252
|
-
.tab {
|
|
253
|
-
padding: 8px 20px; font-size: 13px; color: #888; cursor: pointer;
|
|
254
|
-
border-bottom: 2px solid transparent; transition: color 0.15s;
|
|
255
|
-
}
|
|
256
|
-
.tab:hover { color: #555; }
|
|
257
|
-
.tab.active { color: #cc342d; border-bottom-color: #cc342d; }
|
|
258
|
-
|
|
259
|
-
/* Info bar */
|
|
260
|
-
.info-bar { background: #f5f5f5; padding: 6px 20px; font-size: 12px; color: #888; border-bottom: 1px solid #eee; }
|
|
261
|
-
|
|
262
|
-
/* Tab content */
|
|
263
|
-
.tab-content { display: none; }
|
|
264
|
-
.tab-content.active { display: block; }
|
|
265
|
-
#panel-flamegraph { background: #fff; min-height: 300px; }
|
|
266
|
-
.empty-state { display: flex; align-items: center; justify-content: center; height: 400px; color: #aaa; font-size: 16px; }
|
|
267
|
-
#panel-flamegraph .d3-flame-graph rect { stroke: #fff; stroke-width: 0.5px; }
|
|
268
|
-
|
|
269
|
-
/* Top table */
|
|
270
|
-
#panel-top { padding: 16px 20px; background: #fff; }
|
|
271
|
-
#panel-top table { width: 100%; border-collapse: collapse; font-size: 13px; }
|
|
272
|
-
#panel-top th { text-align: left; color: #cc342d; border-bottom: 2px solid #eee; padding: 6px 8px; cursor: pointer; }
|
|
273
|
-
#panel-top th:hover { color: #a82a24; }
|
|
274
|
-
#panel-top td { padding: 5px 8px; border-bottom: 1px solid #f0f0f0; }
|
|
275
|
-
#panel-top tr:hover td { background: #faf5f0; }
|
|
276
|
-
.num { text-align: right; font-variant-numeric: tabular-nums; }
|
|
277
|
-
|
|
278
|
-
/* Tags panel */
|
|
279
|
-
#panel-tags { padding: 16px 20px; background: #fff; }
|
|
280
|
-
.tag-group { margin-bottom: 20px; }
|
|
281
|
-
.tag-group h3 { font-size: 14px; color: #cc342d; margin-bottom: 8px; }
|
|
282
|
-
.tag-group table { width: 100%; max-width: 600px; border-collapse: collapse; font-size: 13px; }
|
|
283
|
-
.tag-group th { text-align: left; color: #888; border-bottom: 2px solid #eee; padding: 5px 8px; }
|
|
284
|
-
.tag-group td { padding: 5px 8px; border-bottom: 1px solid #f0f0f0; }
|
|
285
|
-
.tag-group tr:hover td { background: #faf5f0; }
|
|
286
|
-
.tag-group tr { cursor: pointer; }
|
|
287
|
-
.tag-bar { display: inline-block; height: 12px; background: #cc342d; border-radius: 2px; vertical-align: middle; }
|
|
288
|
-
</style>
|
|
289
|
-
</head>
|
|
290
|
-
<body>
|
|
291
|
-
<div class="header">
|
|
292
|
-
<a href="https://github.com/ko1/rperf" target="_blank" rel="noopener" title="rperf on GitHub" style="display:flex;align-items:center;text-decoration:none;">
|
|
293
|
-
<!-- LOGO -->
|
|
294
|
-
</a>
|
|
295
|
-
<div class="controls">
|
|
296
|
-
<label>Snapshot:
|
|
297
|
-
<select id="sel-snapshot"><option value="">Loading...</option></select>
|
|
298
|
-
</label>
|
|
299
|
-
<label>tagfocus: <input type="text" id="in-tagfocus" placeholder="value regex"></label>
|
|
300
|
-
<label>tagignore:
|
|
301
|
-
<span class="dropdown-cb">
|
|
302
|
-
<button type="button" id="btn-tagignore" class="dropdown-cb-btn">none</button>
|
|
303
|
-
<div id="cb-tagignore" class="dropdown-cb-list"></div>
|
|
304
|
-
</span>
|
|
305
|
-
</label>
|
|
306
|
-
<label>tagroot:
|
|
307
|
-
<span class="dropdown-cb">
|
|
308
|
-
<button type="button" id="btn-tagroot" class="dropdown-cb-btn">none</button>
|
|
309
|
-
<div id="cb-tagroot" class="dropdown-cb-list"></div>
|
|
310
|
-
</span>
|
|
311
|
-
</label>
|
|
312
|
-
<label>tagleaf:
|
|
313
|
-
<span class="dropdown-cb">
|
|
314
|
-
<button type="button" id="btn-tagleaf" class="dropdown-cb-btn">none</button>
|
|
315
|
-
<div id="cb-tagleaf" class="dropdown-cb-list"></div>
|
|
316
|
-
</span>
|
|
317
|
-
</label>
|
|
318
|
-
</div>
|
|
319
|
-
</div>
|
|
320
|
-
<div class="tabs">
|
|
321
|
-
<div class="tab active" data-tab="flamegraph">Flamegraph</div>
|
|
322
|
-
<div class="tab" data-tab="top">Top</div>
|
|
323
|
-
<div class="tab" data-tab="tags">Tags</div>
|
|
324
|
-
</div>
|
|
325
|
-
<div id="info-bar" class="info-bar"></div>
|
|
326
|
-
<div id="panel-flamegraph" class="tab-content active"></div>
|
|
327
|
-
<div id="panel-top" class="tab-content"></div>
|
|
328
|
-
<div id="panel-tags" class="tab-content"></div>
|
|
329
|
-
|
|
330
|
-
<script src="https://cdnjs.cloudflare.com/ajax/libs/d3/7.9.0/d3.min.js" integrity="sha384-CjloA8y00+1SDAUkjs099PVfnY2KmDC2BZnws9kh8D/lX1s46w6EPhpXdqMfjK6i" crossorigin="anonymous"></script>
|
|
331
|
-
<script src="https://cdn.jsdelivr.net/npm/d3-flame-graph@4/dist/d3-flamegraph.min.js" integrity="sha384-p4NaVVE+k6MT/enE0MtQ8B15rM9BGzHCnx8DizawPGks1ssZUeNdw6bAPpH2gp2w" crossorigin="anonymous"></script>
|
|
332
|
-
<script>
|
|
333
|
-
"use strict";
|
|
334
|
-
|
|
335
|
-
var BASE = location.pathname.replace(/\/$/, "");
|
|
336
|
-
var currentData = null;
|
|
337
|
-
var currentTab = "flamegraph";
|
|
338
|
-
var filteredSamples = null; // cached after filter
|
|
339
|
-
var totalFilteredNs = 0;
|
|
340
|
-
|
|
341
|
-
// --- Helpers ---
|
|
342
|
-
|
|
343
|
-
function fmtMs(ns) { return (ns / 1e6).toFixed(2); }
|
|
344
|
-
function fmtPct(ns, total) { return total > 0 ? (ns / total * 100).toFixed(1) : "0.0"; }
|
|
345
|
-
|
|
346
|
-
// --- Data fetching ---
|
|
347
|
-
|
|
348
|
-
async function fetchJSON(path) {
|
|
349
|
-
var res = await fetch(BASE + path);
|
|
350
|
-
if (!res.ok) throw new Error(res.status + " " + res.statusText);
|
|
351
|
-
return res.json();
|
|
352
|
-
}
|
|
353
|
-
|
|
354
|
-
async function loadSnapshotList() {
|
|
355
|
-
var list = await fetchJSON("/snapshots");
|
|
356
|
-
var sel = document.getElementById("sel-snapshot");
|
|
357
|
-
sel.innerHTML = "";
|
|
358
|
-
if (list.length === 0) {
|
|
359
|
-
sel.innerHTML = '<option value="">No snapshots</option>';
|
|
360
|
-
return;
|
|
361
|
-
}
|
|
362
|
-
var reversed = list.slice().reverse();
|
|
363
|
-
reversed.forEach(function(s) {
|
|
364
|
-
var opt = document.createElement("option");
|
|
365
|
-
opt.value = s.id;
|
|
366
|
-
var t = new Date(s.taken_at);
|
|
367
|
-
var dur = (s.duration_ns / 1e9).toFixed(1);
|
|
368
|
-
opt.textContent = "#" + s.id + " " + t.toLocaleTimeString() +
|
|
369
|
-
" (" + s.mode + ", " + dur + "s, " + s.sampling_count + " samples)";
|
|
370
|
-
sel.appendChild(opt);
|
|
371
|
-
});
|
|
372
|
-
await loadSnapshot(reversed[0].id);
|
|
373
|
-
}
|
|
374
|
-
|
|
375
|
-
async function loadSnapshot(id) {
|
|
376
|
-
currentData = await fetchJSON("/snapshots/" + id);
|
|
377
|
-
updateTagDropdowns();
|
|
378
|
-
applyAndRender();
|
|
379
|
-
}
|
|
380
|
-
|
|
381
|
-
// --- Update tag key/value dropdowns from current snapshot ---
|
|
382
|
-
|
|
383
|
-
function updateTagDropdowns() {
|
|
384
|
-
if (!currentData || !currentData.label_sets) return;
|
|
385
|
-
var labelSets = currentData.label_sets;
|
|
386
|
-
|
|
387
|
-
// Collect all keys and all key:value pairs
|
|
388
|
-
var keys = {};
|
|
389
|
-
var vals = {};
|
|
390
|
-
labelSets.forEach(function(ls) {
|
|
391
|
-
if (!ls) return;
|
|
392
|
-
Object.keys(ls).forEach(function(k) {
|
|
393
|
-
keys[k] = true;
|
|
394
|
-
var compound = k + " = " + ls[k];
|
|
395
|
-
vals[compound] = true;
|
|
396
|
-
});
|
|
397
|
-
});
|
|
398
|
-
|
|
399
|
-
var sortedKeys = Object.keys(keys).sort();
|
|
400
|
-
// Group by key: for each key, (none) first, then values sorted
|
|
401
|
-
var sortedVals = [];
|
|
402
|
-
sortedKeys.forEach(function(k) {
|
|
403
|
-
sortedVals.push(k + " = (none)");
|
|
404
|
-
Object.keys(vals).sort().forEach(function(v) {
|
|
405
|
-
if (v.substring(0, k.length + 3) === k + " = ") sortedVals.push(v);
|
|
406
|
-
});
|
|
407
|
-
});
|
|
408
|
-
|
|
409
|
-
// tagroot / tagleaf: dropdown checkboxes for label keys
|
|
410
|
-
["tagroot", "tagleaf"].forEach(function(name) {
|
|
411
|
-
var container = document.getElementById("cb-" + name);
|
|
412
|
-
var prev = getCheckedValues(container);
|
|
413
|
-
container.innerHTML = "";
|
|
414
|
-
sortedKeys.forEach(function(k) {
|
|
415
|
-
var lbl = document.createElement("label");
|
|
416
|
-
var cb = document.createElement("input");
|
|
417
|
-
cb.type = "checkbox";
|
|
418
|
-
cb.value = k;
|
|
419
|
-
if (prev.indexOf(k) >= 0) cb.checked = true;
|
|
420
|
-
cb.addEventListener("change", function() {
|
|
421
|
-
updateDropdownButton("btn-" + name, "cb-" + name, "none");
|
|
422
|
-
applyAndRender();
|
|
423
|
-
});
|
|
424
|
-
lbl.appendChild(cb);
|
|
425
|
-
lbl.appendChild(document.createTextNode(" " + k));
|
|
426
|
-
container.appendChild(lbl);
|
|
427
|
-
});
|
|
428
|
-
updateDropdownButton("btn-" + name, "cb-" + name, "none");
|
|
429
|
-
});
|
|
430
|
-
|
|
431
|
-
// tagignore: dropdown with checkboxes for key=value pairs
|
|
432
|
-
var container = document.getElementById("cb-tagignore");
|
|
433
|
-
var prev = getCheckedValues(container);
|
|
434
|
-
container.innerHTML = "";
|
|
435
|
-
sortedVals.forEach(function(display) {
|
|
436
|
-
var lbl = document.createElement("label");
|
|
437
|
-
var cb = document.createElement("input");
|
|
438
|
-
cb.type = "checkbox";
|
|
439
|
-
cb.value = display;
|
|
440
|
-
if (prev.indexOf(display) >= 0) cb.checked = true;
|
|
441
|
-
cb.addEventListener("change", function() {
|
|
442
|
-
updateDropdownButton("btn-tagignore", "cb-tagignore", "none");
|
|
443
|
-
applyAndRender();
|
|
444
|
-
});
|
|
445
|
-
lbl.appendChild(cb);
|
|
446
|
-
lbl.appendChild(document.createTextNode(" " + display));
|
|
447
|
-
container.appendChild(lbl);
|
|
448
|
-
});
|
|
449
|
-
updateDropdownButton("btn-tagignore", "cb-tagignore", "none");
|
|
450
|
-
}
|
|
451
|
-
|
|
452
|
-
function updateDropdownButton(btnId, containerId, emptyText) {
|
|
453
|
-
var vals = getCheckedValues(document.getElementById(containerId));
|
|
454
|
-
var btn = document.getElementById(btnId);
|
|
455
|
-
if (vals.length === 0) {
|
|
456
|
-
btn.textContent = emptyText;
|
|
457
|
-
btn.classList.remove("has-selection");
|
|
458
|
-
} else {
|
|
459
|
-
btn.textContent = vals.join(", ");
|
|
460
|
-
btn.classList.add("has-selection");
|
|
461
|
-
}
|
|
462
|
-
}
|
|
463
|
-
|
|
464
|
-
function getCheckedValues(container) {
|
|
465
|
-
var result = [];
|
|
466
|
-
var cbs = container.querySelectorAll("input[type=checkbox]:checked");
|
|
467
|
-
for (var i = 0; i < cbs.length; i++) result.push(cbs[i].value);
|
|
468
|
-
return result;
|
|
469
|
-
}
|
|
470
|
-
|
|
471
|
-
// --- Tag filtering ---
|
|
472
|
-
|
|
473
|
-
function getFilteredSamples() {
|
|
474
|
-
if (!currentData) return [];
|
|
475
|
-
var samples = currentData.samples;
|
|
476
|
-
var labelSets = currentData.label_sets || [];
|
|
477
|
-
var tagfocus = document.getElementById("in-tagfocus").value.trim();
|
|
478
|
-
var tagignoreVals = getCheckedValues(document.getElementById("cb-tagignore"));
|
|
479
|
-
var tagroots = getCheckedValues(document.getElementById("cb-tagroot"));
|
|
480
|
-
var tagleaves = getCheckedValues(document.getElementById("cb-tagleaf"));
|
|
481
|
-
|
|
482
|
-
var filtered = samples;
|
|
483
|
-
|
|
484
|
-
// tagfocus: keep only samples whose label values match the regex
|
|
485
|
-
if (tagfocus) {
|
|
486
|
-
try { var re = new RegExp(tagfocus); } catch(e) { /* invalid regex, skip filter */ }
|
|
487
|
-
if (!re) return filtered;
|
|
488
|
-
filtered = filtered.filter(function(s) {
|
|
489
|
-
if (s.label_set_id === 0) return false;
|
|
490
|
-
var ls = labelSets[s.label_set_id];
|
|
491
|
-
if (!ls) return false;
|
|
492
|
-
return Object.values(ls).some(function(v) { return re.test(String(v)); });
|
|
493
|
-
});
|
|
494
|
-
}
|
|
495
|
-
|
|
496
|
-
// tagignore: remove samples matching selected key=value pairs (or missing key for "(none)")
|
|
497
|
-
if (tagignoreVals.length > 0) {
|
|
498
|
-
var ignores = tagignoreVals.map(function(s) {
|
|
499
|
-
var idx = s.indexOf(" = ");
|
|
500
|
-
return { key: s.substring(0, idx), val: s.substring(idx + 3) };
|
|
501
|
-
});
|
|
502
|
-
filtered = filtered.filter(function(s) {
|
|
503
|
-
var ls = (s.label_set_id > 0) ? labelSets[s.label_set_id] : null;
|
|
504
|
-
return !ignores.some(function(ig) {
|
|
505
|
-
if (ig.val === "(none)") {
|
|
506
|
-
// Match samples that do NOT have this key
|
|
507
|
-
return !ls || !(ig.key in ls);
|
|
508
|
-
}
|
|
509
|
-
return ls && ls[ig.key] !== undefined && String(ls[ig.key]) === ig.val;
|
|
510
|
-
});
|
|
511
|
-
});
|
|
512
|
-
}
|
|
513
|
-
|
|
514
|
-
// tagroot: prepend label values as root frames (outermost first)
|
|
515
|
-
if (tagroots.length > 0) {
|
|
516
|
-
filtered = filtered.map(function(s) {
|
|
517
|
-
if (s.label_set_id === 0) return s;
|
|
518
|
-
var ls = labelSets[s.label_set_id];
|
|
519
|
-
if (!ls) return s;
|
|
520
|
-
var extra = [];
|
|
521
|
-
for (var i = 0; i < tagroots.length; i++) {
|
|
522
|
-
var k = tagroots[i];
|
|
523
|
-
if (k in ls) extra.push("[" + k + ": " + ls[k] + "]");
|
|
524
|
-
}
|
|
525
|
-
if (extra.length === 0) return s;
|
|
526
|
-
return Object.assign({}, s, { stack: extra.concat(s.stack) });
|
|
527
|
-
});
|
|
528
|
-
}
|
|
529
|
-
|
|
530
|
-
// tagleaf: append label values as leaf frames (innermost first)
|
|
531
|
-
if (tagleaves.length > 0) {
|
|
532
|
-
filtered = filtered.map(function(s) {
|
|
533
|
-
if (s.label_set_id === 0) return s;
|
|
534
|
-
var ls = labelSets[s.label_set_id];
|
|
535
|
-
if (!ls) return s;
|
|
536
|
-
var extra = [];
|
|
537
|
-
for (var i = 0; i < tagleaves.length; i++) {
|
|
538
|
-
var k = tagleaves[i];
|
|
539
|
-
if (k in ls) extra.push("[" + k + ": " + ls[k] + "]");
|
|
540
|
-
}
|
|
541
|
-
if (extra.length === 0) return s;
|
|
542
|
-
return Object.assign({}, s, { stack: s.stack.concat(extra) });
|
|
543
|
-
});
|
|
544
|
-
}
|
|
545
|
-
|
|
546
|
-
return filtered;
|
|
547
|
-
}
|
|
548
|
-
|
|
549
|
-
function applyAndRender() {
|
|
550
|
-
filteredSamples = getFilteredSamples();
|
|
551
|
-
totalFilteredNs = 0;
|
|
552
|
-
for (var i = 0; i < filteredSamples.length; i++) totalFilteredNs += filteredSamples[i].weight;
|
|
553
|
-
|
|
554
|
-
// Update info bar
|
|
555
|
-
if (!currentData) return;
|
|
556
|
-
var dur = (currentData.duration_ns / 1e9).toFixed(2);
|
|
557
|
-
document.getElementById("info-bar").textContent =
|
|
558
|
-
"Mode: " + currentData.mode + " | Freq: " + currentData.frequency + "Hz | Duration: " + dur + "s" +
|
|
559
|
-
" | Stacks: " + filteredSamples.length + " | Total weight: " + fmtMs(totalFilteredNs) + "ms";
|
|
560
|
-
|
|
561
|
-
renderCurrentTab();
|
|
562
|
-
}
|
|
563
|
-
|
|
564
|
-
function renderCurrentTab() {
|
|
565
|
-
if (currentTab === "flamegraph") renderFlamegraph();
|
|
566
|
-
else if (currentTab === "top") renderTop();
|
|
567
|
-
else if (currentTab === "tags") renderTags();
|
|
568
|
-
}
|
|
569
|
-
|
|
570
|
-
// ==================== Flamegraph ====================
|
|
571
|
-
|
|
572
|
-
function buildTree(samples) {
|
|
573
|
-
var root = { name: "root", value: 0, children: [] };
|
|
574
|
-
for (var si = 0; si < samples.length; si++) {
|
|
575
|
-
var sample = samples[si];
|
|
576
|
-
var node = root;
|
|
577
|
-
for (var i = 0; i < sample.stack.length; i++) {
|
|
578
|
-
var name = sample.stack[i];
|
|
579
|
-
var child = null;
|
|
580
|
-
for (var j = 0; j < node.children.length; j++) {
|
|
581
|
-
if (node.children[j].name === name) { child = node.children[j]; break; }
|
|
582
|
-
}
|
|
583
|
-
if (!child) {
|
|
584
|
-
child = { name: name, value: 0, children: [] };
|
|
585
|
-
node.children.push(child);
|
|
586
|
-
}
|
|
587
|
-
node = child;
|
|
588
|
-
}
|
|
589
|
-
node.value += sample.weight;
|
|
590
|
-
}
|
|
591
|
-
return root;
|
|
592
|
-
}
|
|
593
|
-
|
|
594
|
-
function renderFlamegraph() {
|
|
595
|
-
var el = document.getElementById("panel-flamegraph");
|
|
596
|
-
el.innerHTML = "";
|
|
597
|
-
if (!filteredSamples || filteredSamples.length === 0) {
|
|
598
|
-
el.innerHTML = '<div class="empty-state">No matching samples</div>';
|
|
599
|
-
return;
|
|
600
|
-
}
|
|
601
|
-
var tree = buildTree(filteredSamples);
|
|
602
|
-
var total = totalFilteredNs;
|
|
603
|
-
var width = el.clientWidth || document.body.clientWidth;
|
|
604
|
-
var chart = flamegraph()
|
|
605
|
-
.width(width)
|
|
606
|
-
.cellHeight(20)
|
|
607
|
-
.selfValue(true)
|
|
608
|
-
.getName(function(d) {
|
|
609
|
-
return d.data.name + " (" + fmtMs(d.data.value) + "ms, " + fmtPct(d.data.value, total) + "%)";
|
|
610
|
-
});
|
|
611
|
-
d3.select("#panel-flamegraph").datum(tree).call(chart);
|
|
612
|
-
}
|
|
613
|
-
|
|
614
|
-
// ==================== Top ====================
|
|
615
|
-
|
|
616
|
-
var topSortKey = "flat";
|
|
617
|
-
var topSortAsc = false;
|
|
618
|
-
|
|
619
|
-
function renderTop() {
|
|
620
|
-
var el = document.getElementById("panel-top");
|
|
621
|
-
if (!filteredSamples || filteredSamples.length === 0) {
|
|
622
|
-
el.innerHTML = '<div class="empty-state">No matching samples</div>';
|
|
623
|
-
return;
|
|
624
|
-
}
|
|
625
|
-
|
|
626
|
-
// Compute flat (leaf) and cumulative (any position) per function
|
|
627
|
-
var flatMap = {};
|
|
628
|
-
var cumMap = {};
|
|
629
|
-
for (var si = 0; si < filteredSamples.length; si++) {
|
|
630
|
-
var s = filteredSamples[si];
|
|
631
|
-
var stack = s.stack;
|
|
632
|
-
var w = s.weight;
|
|
633
|
-
var leaf = stack[stack.length - 1];
|
|
634
|
-
flatMap[leaf] = (flatMap[leaf] || 0) + w;
|
|
635
|
-
var seen = {};
|
|
636
|
-
for (var i = 0; i < stack.length; i++) {
|
|
637
|
-
if (!seen[stack[i]]) {
|
|
638
|
-
seen[stack[i]] = true;
|
|
639
|
-
cumMap[stack[i]] = (cumMap[stack[i]] || 0) + w;
|
|
640
|
-
}
|
|
641
|
-
}
|
|
642
|
-
}
|
|
643
|
-
|
|
644
|
-
var rows = [];
|
|
645
|
-
var allNames = {};
|
|
646
|
-
Object.keys(flatMap).forEach(function(k) { allNames[k] = true; });
|
|
647
|
-
Object.keys(cumMap).forEach(function(k) { allNames[k] = true; });
|
|
648
|
-
Object.keys(allNames).forEach(function(name) {
|
|
649
|
-
rows.push({ name: name, flat: flatMap[name] || 0, cum: cumMap[name] || 0 });
|
|
650
|
-
});
|
|
651
|
-
|
|
652
|
-
// Sort
|
|
653
|
-
var key = topSortKey;
|
|
654
|
-
var asc = topSortAsc;
|
|
655
|
-
rows.sort(function(a, b) {
|
|
656
|
-
var va = (key === "name") ? a.name : a[key];
|
|
657
|
-
var vb = (key === "name") ? b.name : b[key];
|
|
658
|
-
if (key === "name") {
|
|
659
|
-
return asc ? va.localeCompare(vb) : vb.localeCompare(va);
|
|
660
|
-
}
|
|
661
|
-
return asc ? va - vb : vb - va;
|
|
662
|
-
});
|
|
663
|
-
|
|
664
|
-
var total = totalFilteredNs;
|
|
665
|
-
var arrow = function(k) { return (topSortKey === k) ? (topSortAsc ? " \u25b2" : " \u25bc") : ""; };
|
|
666
|
-
var html = '<table><thead><tr>' +
|
|
667
|
-
'<th class="num" data-sort="flat">Flat' + arrow("flat") + '</th>' +
|
|
668
|
-
'<th class="num" data-sort="cum">Cum' + arrow("cum") + '</th>' +
|
|
669
|
-
'<th data-sort="name">Function' + arrow("name") + '</th>' +
|
|
670
|
-
'</tr></thead><tbody>';
|
|
671
|
-
var limit = Math.min(rows.length, 50);
|
|
672
|
-
for (var ri = 0; ri < limit; ri++) {
|
|
673
|
-
var r = rows[ri];
|
|
674
|
-
html += '<tr>' +
|
|
675
|
-
'<td class="num">' + fmtMs(r.flat) + 'ms (' + fmtPct(r.flat, total) + '%)</td>' +
|
|
676
|
-
'<td class="num">' + fmtMs(r.cum) + 'ms (' + fmtPct(r.cum, total) + '%)</td>' +
|
|
677
|
-
'<td>' + escHtml(r.name) + '</td>' +
|
|
678
|
-
'</tr>';
|
|
679
|
-
}
|
|
680
|
-
html += '</tbody></table>';
|
|
681
|
-
if (rows.length > 50) {
|
|
682
|
-
html += '<p style="color:#888;margin-top:8px;font-size:12px;">Showing top 50 of ' + rows.length + ' functions</p>';
|
|
683
|
-
}
|
|
684
|
-
el.innerHTML = html;
|
|
685
|
-
|
|
686
|
-
// Attach sort handlers
|
|
687
|
-
el.querySelectorAll("th[data-sort]").forEach(function(th) {
|
|
688
|
-
th.addEventListener("click", function() {
|
|
689
|
-
var newKey = th.getAttribute("data-sort");
|
|
690
|
-
if (topSortKey === newKey) {
|
|
691
|
-
topSortAsc = !topSortAsc;
|
|
692
|
-
} else {
|
|
693
|
-
topSortKey = newKey;
|
|
694
|
-
topSortAsc = (newKey === "name");
|
|
695
|
-
}
|
|
696
|
-
renderTop();
|
|
697
|
-
});
|
|
698
|
-
});
|
|
699
|
-
}
|
|
700
|
-
|
|
701
|
-
function escHtml(s) {
|
|
702
|
-
return s.replace(/&/g,"&").replace(/</g,"<").replace(/>/g,">");
|
|
703
|
-
}
|
|
704
|
-
|
|
705
|
-
// ==================== Tags ====================
|
|
706
|
-
|
|
707
|
-
function renderTags() {
|
|
708
|
-
var el = document.getElementById("panel-tags");
|
|
709
|
-
if (!currentData) { el.innerHTML = '<div class="empty-state">No data</div>'; return; }
|
|
710
|
-
|
|
711
|
-
var samples = filteredSamples || [];
|
|
712
|
-
var labelSets = currentData.label_sets || [];
|
|
713
|
-
if (labelSets.length === 0) {
|
|
714
|
-
el.innerHTML = '<div class="empty-state">No tags in this snapshot</div>';
|
|
715
|
-
return;
|
|
716
|
-
}
|
|
717
|
-
|
|
718
|
-
// Collect all tag keys
|
|
719
|
-
var tagKeys = {};
|
|
720
|
-
labelSets.forEach(function(ls) {
|
|
721
|
-
if (!ls) return;
|
|
722
|
-
Object.keys(ls).forEach(function(k) { tagKeys[k] = true; });
|
|
723
|
-
});
|
|
724
|
-
|
|
725
|
-
var keys = Object.keys(tagKeys);
|
|
726
|
-
if (keys.length === 0) {
|
|
727
|
-
el.innerHTML = '<div class="empty-state">No tags in this snapshot</div>';
|
|
728
|
-
return;
|
|
729
|
-
}
|
|
730
|
-
|
|
731
|
-
// For each key, aggregate weight per value
|
|
732
|
-
var html = "";
|
|
733
|
-
keys.forEach(function(key) {
|
|
734
|
-
var byVal = {}; // value -> weight
|
|
735
|
-
var untagged = 0; // weight without this key
|
|
736
|
-
for (var i = 0; i < samples.length; i++) {
|
|
737
|
-
var s = samples[i];
|
|
738
|
-
var ls = (s.label_set_id > 0) ? labelSets[s.label_set_id] : null;
|
|
739
|
-
if (ls && key in ls) {
|
|
740
|
-
var v = String(ls[key]);
|
|
741
|
-
byVal[v] = (byVal[v] || 0) + s.weight;
|
|
742
|
-
} else {
|
|
743
|
-
untagged += s.weight;
|
|
744
|
-
}
|
|
745
|
-
}
|
|
746
|
-
|
|
747
|
-
var entries = [];
|
|
748
|
-
Object.keys(byVal).forEach(function(v) { entries.push({ val: v, weight: byVal[v] }); });
|
|
749
|
-
entries.sort(function(a, b) { return b.weight - a.weight; });
|
|
750
|
-
var maxWeight = entries.length > 0 ? entries[0].weight : 0;
|
|
751
|
-
var total = totalFilteredNs;
|
|
752
|
-
|
|
753
|
-
html += '<div class="tag-group"><h3>' + escHtml(key) +
|
|
754
|
-
' <span style="color:#666;font-weight:normal;">(' + entries.length + ' values)</span></h3>';
|
|
755
|
-
html += '<table><thead><tr><th>Value</th><th class="num">Weight</th><th class="num">%</th><th style="width:200px"></th></tr></thead><tbody>';
|
|
756
|
-
entries.forEach(function(e) {
|
|
757
|
-
var barW = maxWeight > 0 ? Math.max(1, Math.round(e.weight / maxWeight * 180)) : 0;
|
|
758
|
-
html += '<tr data-tagfocus="' + escAttr(key) + ':' + escAttr(e.val) + '">' +
|
|
759
|
-
'<td>' + escHtml(e.val) + '</td>' +
|
|
760
|
-
'<td class="num">' + fmtMs(e.weight) + 'ms</td>' +
|
|
761
|
-
'<td class="num">' + fmtPct(e.weight, total) + '%</td>' +
|
|
762
|
-
'<td><span class="tag-bar" style="width:' + barW + 'px"></span></td>' +
|
|
763
|
-
'</tr>';
|
|
764
|
-
});
|
|
765
|
-
if (untagged > 0) {
|
|
766
|
-
html += '<tr style="color:#666"><td>(untagged)</td>' +
|
|
767
|
-
'<td class="num">' + fmtMs(untagged) + 'ms</td>' +
|
|
768
|
-
'<td class="num">' + fmtPct(untagged, total) + '%</td>' +
|
|
769
|
-
'<td></td></tr>';
|
|
770
|
-
}
|
|
771
|
-
html += '</tbody></table></div>';
|
|
772
|
-
});
|
|
773
|
-
|
|
774
|
-
el.innerHTML = html;
|
|
775
|
-
|
|
776
|
-
// Click on a tag value row -> set tagfocus and switch to flamegraph
|
|
777
|
-
el.querySelectorAll("tr[data-tagfocus]").forEach(function(tr) {
|
|
778
|
-
tr.addEventListener("click", function() {
|
|
779
|
-
var parts = tr.getAttribute("data-tagfocus").split(":");
|
|
780
|
-
var val = parts.slice(1).join(":");
|
|
781
|
-
document.getElementById("in-tagfocus").value = "^" + escRegex(val) + "$";
|
|
782
|
-
switchTab("flamegraph");
|
|
783
|
-
applyAndRender();
|
|
784
|
-
});
|
|
785
|
-
});
|
|
786
|
-
}
|
|
787
|
-
|
|
788
|
-
function escAttr(s) { return s.replace(/&/g,"&").replace(/"/g,""").replace(/</g,"<"); }
|
|
789
|
-
function escRegex(s) { return s.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"); }
|
|
790
|
-
|
|
791
|
-
// ==================== Tab switching ====================
|
|
792
|
-
|
|
793
|
-
function switchTab(name) {
|
|
794
|
-
currentTab = name;
|
|
795
|
-
document.querySelectorAll(".tab").forEach(function(t) {
|
|
796
|
-
t.classList.toggle("active", t.getAttribute("data-tab") === name);
|
|
797
|
-
});
|
|
798
|
-
document.querySelectorAll(".tab-content").forEach(function(c) {
|
|
799
|
-
c.classList.toggle("active", c.id === "panel-" + name);
|
|
800
|
-
});
|
|
801
|
-
renderCurrentTab();
|
|
802
|
-
}
|
|
803
|
-
|
|
804
|
-
// ==================== Events ====================
|
|
805
|
-
|
|
806
|
-
document.getElementById("sel-snapshot").addEventListener("change", function(e) {
|
|
807
|
-
if (e.target.value) loadSnapshot(e.target.value);
|
|
808
|
-
});
|
|
809
|
-
|
|
810
|
-
// Dropdown toggles for tagignore, tagroot, tagleaf
|
|
811
|
-
["tagignore", "tagroot", "tagleaf"].forEach(function(name) {
|
|
812
|
-
document.getElementById("btn-" + name).addEventListener("click", function(e) {
|
|
813
|
-
e.stopPropagation();
|
|
814
|
-
// Close other dropdowns first
|
|
815
|
-
["tagignore", "tagroot", "tagleaf"].forEach(function(other) {
|
|
816
|
-
if (other !== name) document.getElementById("cb-" + other).classList.remove("open");
|
|
817
|
-
});
|
|
818
|
-
document.getElementById("cb-" + name).classList.toggle("open");
|
|
819
|
-
});
|
|
820
|
-
});
|
|
821
|
-
document.addEventListener("click", function(e) {
|
|
822
|
-
["tagignore", "tagroot", "tagleaf"].forEach(function(name) {
|
|
823
|
-
var list = document.getElementById("cb-" + name);
|
|
824
|
-
if (!list.contains(e.target) && e.target.id !== "btn-" + name) {
|
|
825
|
-
list.classList.remove("open");
|
|
826
|
-
}
|
|
827
|
-
});
|
|
828
|
-
});
|
|
829
|
-
|
|
830
|
-
document.querySelectorAll(".tab").forEach(function(t) {
|
|
831
|
-
t.addEventListener("click", function() { switchTab(t.getAttribute("data-tab")); });
|
|
832
|
-
});
|
|
833
|
-
|
|
834
|
-
var inputs = document.querySelectorAll(".controls input[type=text]");
|
|
835
|
-
for (var i = 0; i < inputs.length; i++) {
|
|
836
|
-
inputs[i].addEventListener("keydown", function(e) {
|
|
837
|
-
if (e.key === "Enter") applyAndRender();
|
|
838
|
-
});
|
|
839
|
-
}
|
|
840
|
-
|
|
841
|
-
// --- Init ---
|
|
842
|
-
loadSnapshotList();
|
|
843
|
-
</script>
|
|
844
|
-
</body>
|
|
845
|
-
</html>
|
|
846
|
-
HTML
|
|
847
295
|
end
|