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