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.
@@ -0,0 +1,1148 @@
1
+ <!DOCTYPE html>
2
+ <html lang="en">
3
+ <head>
4
+ <meta charset="utf-8">
5
+ <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'">
6
+ <title>rperf Viewer</title>
7
+ <link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/d3-flame-graph@4/dist/d3-flamegraph.css" integrity="sha384-DgAQSBzzhv8bu6Qc6Lq08THluOr+kO5qLMHt1yv8A3my7Jz2OQv6aq/WSZRYIQkG" crossorigin="anonymous">
8
+ <style>
9
+ * { box-sizing: border-box; margin: 0; padding: 0; }
10
+ body { font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, monospace; background: #fafafa; color: #333; }
11
+
12
+ /* Layout: optional snapshot sidebar + main */
13
+ #layout { display: flex; align-items: stretch; min-height: 100vh; }
14
+ #main { flex: 1; min-width: 0; }
15
+
16
+ /* Sidebar (time-travel mode) */
17
+ #sidebar {
18
+ display: none; width: 280px; min-width: 280px;
19
+ border-right: 1px solid #ddd; background: #fff;
20
+ flex-direction: column; max-height: 100vh; position: sticky; top: 0;
21
+ }
22
+ .pin-panel { padding: 10px 14px; border-bottom: 1px solid #eee; }
23
+ .pin-head { display: flex; justify-content: space-between; align-items: baseline; }
24
+ .pin-name { font-family: ui-monospace, SFMono-Regular, Menlo, monospace; font-size: 12px; color: #cc342d; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
25
+ .pin-close { color: #999; cursor: pointer; font-size: 12px; padding-left: 6px; }
26
+ .pin-share { color: #888; font-size: 11px; }
27
+ .snapshot-list { overflow-y: auto; flex: 1; }
28
+ .snap-group-header {
29
+ padding: 8px 14px 4px; color: #888; font-size: 11px;
30
+ letter-spacing: .06em; cursor: pointer; user-select: none; text-transform: uppercase;
31
+ }
32
+ .snap-group-header:hover { color: #555; }
33
+ .snap-row { padding: 7px 10px 7px 14px; cursor: pointer; border-left: 3px solid transparent; }
34
+ .snap-row:hover { background: #faf5f0; }
35
+ .snap-row.current { background: #f5ece4; border-left-color: #cc342d; }
36
+ .snap-row.diff-base { border-left-color: #4a9de8; }
37
+ .snap-line1 { display: flex; align-items: center; gap: 6px; }
38
+ .snap-sha { font-family: ui-monospace, SFMono-Regular, Menlo, monospace; font-size: 12px; color: #333; }
39
+ .snap-row.current .snap-sha { color: #cc342d; font-weight: 600; }
40
+ .snap-date { color: #999; font-size: 11px; }
41
+ .snap-warn { font-size: 11px; }
42
+ .snap-spacer { flex: 1; }
43
+ .diff-btn {
44
+ font-size: 12px; padding: 0 5px; border-radius: 4px; color: #999;
45
+ border: 1px solid #ddd; cursor: pointer; background: transparent; line-height: 16px;
46
+ }
47
+ .diff-btn:hover { border-color: #4a9de8; color: #4a9de8; }
48
+ .diff-btn.active { background: #4a9de8; border-color: #4a9de8; color: #fff; }
49
+ .snap-msg { color: #777; font-size: 11px; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; margin-top: 1px; }
50
+ .snap-badge { font-size: 10.5px; margin-top: 2px; font-family: ui-monospace, SFMono-Regular, Menlo, monospace; color: #999; }
51
+ .alloc-up { color: #c0392b; }
52
+ .alloc-down { color: #2980b9; }
53
+ .sidebar-footer { padding: 8px 14px; border-top: 1px solid #eee; color: #aaa; font-size: 11px; }
54
+
55
+ /* Header */
56
+ .header { background: #fff; padding: 10px 20px; display: flex; align-items: center; gap: 16px; flex-wrap: wrap; border-bottom: 1px solid #ddd; }
57
+ .controls { display: flex; align-items: center; gap: 10px; flex-wrap: wrap; }
58
+ .controls label { font-size: 13px; color: #555; }
59
+ .controls select, .controls input[type="text"] {
60
+ background: #fff; color: #333; border: 1px solid #ccc; border-radius: 4px;
61
+ padding: 4px 8px; font-size: 13px; font-family: inherit;
62
+ }
63
+ .controls input[type="text"] { width: 120px; }
64
+ .dropdown-cb { position: relative; display: inline-block; vertical-align: middle; }
65
+ .dropdown-cb-btn {
66
+ background: #fff; color: #888; border: 1px solid #ccc; border-radius: 4px;
67
+ padding: 4px 8px; font-size: 13px; font-family: inherit; cursor: pointer; min-width: 60px; text-align: left;
68
+ }
69
+ .dropdown-cb-btn.has-selection { color: #333; }
70
+ .dropdown-cb-btn:hover { border-color: #999; }
71
+ .dropdown-cb-list {
72
+ display: none; position: absolute; top: 100%; left: 0; z-index: 100;
73
+ background: #fff; border: 1px solid #ccc; border-radius: 4px;
74
+ padding: 4px 0; min-width: 180px; max-height: 240px; overflow-y: auto;
75
+ box-shadow: 0 4px 12px rgba(0,0,0,0.15);
76
+ }
77
+ .dropdown-cb-list.open { display: block; }
78
+ .dropdown-cb-list label {
79
+ display: block; padding: 4px 10px; font-size: 12px; cursor: pointer; white-space: nowrap;
80
+ color: #333; background: none; border: none; border-radius: 0;
81
+ }
82
+ .dropdown-cb-list label:hover { background: #f0e8e0; }
83
+ .controls input[type="text"]::placeholder { color: #aaa; }
84
+
85
+ /* Tabs */
86
+ .tabs { display: flex; background: #fff; border-bottom: 1px solid #ddd; padding: 0 20px; }
87
+ .tab {
88
+ padding: 8px 20px; font-size: 13px; color: #888; cursor: pointer;
89
+ border-bottom: 2px solid transparent; transition: color 0.15s;
90
+ }
91
+ .tab:hover { color: #555; }
92
+ .tab.active { color: #cc342d; border-bottom-color: #cc342d; }
93
+
94
+ /* Snapshot toolbar (time-travel mode) */
95
+ #snap-toolbar {
96
+ display: none; align-items: center; gap: 12px; padding: 6px 20px;
97
+ border-bottom: 1px solid #eee; background: #fff; font-size: 12px; flex-wrap: wrap;
98
+ }
99
+ .tb-sha { font-family: ui-monospace, SFMono-Regular, Menlo, monospace; font-size: 12px; }
100
+ .tb-sha.cur { color: #cc342d; }
101
+ .tb-sha.base { color: #4a9de8; }
102
+ .tb-msg { color: #888; }
103
+ .tb-stat { color: #888; font-family: ui-monospace, SFMono-Regular, Menlo, monospace; font-size: 11.5px; }
104
+ .tb-stat b { color: #333; font-weight: 600; }
105
+ .tb-spacer { flex: 1; }
106
+ .diff-clear {
107
+ font-size: 11px; color: #888; border: 1px solid #ddd; border-radius: 10px;
108
+ padding: 2px 8px; cursor: pointer; background: #fff;
109
+ }
110
+ .diff-clear:hover { color: #555; border-color: #aaa; }
111
+
112
+ /* Info bar */
113
+ .info-bar { background: #f5f5f5; padding: 6px 20px; font-size: 12px; color: #888; border-bottom: 1px solid #eee; }
114
+
115
+ /* Tab content */
116
+ .tab-content { display: none; }
117
+ .tab-content.active { display: block; }
118
+ #panel-flamegraph { background: #fff; min-height: 300px; }
119
+ .empty-state { display: flex; align-items: center; justify-content: center; height: 400px; color: #aaa; font-size: 16px; }
120
+ #panel-flamegraph .d3-flame-graph rect { stroke: #fff; stroke-width: 0.5px; }
121
+ .diff-legend { padding: 8px 20px 12px; font-size: 11px; color: #888; display: flex; gap: 14px; background: #fff; }
122
+
123
+ /* Top table */
124
+ #panel-top { padding: 16px 20px; background: #fff; }
125
+ #panel-top table { width: 100%; border-collapse: collapse; font-size: 13px; }
126
+ #panel-top th { text-align: left; color: #cc342d; border-bottom: 2px solid #eee; padding: 6px 8px; cursor: pointer; }
127
+ #panel-top th:hover { color: #a82a24; }
128
+ #panel-top td { padding: 5px 8px; border-bottom: 1px solid #f0f0f0; }
129
+ #panel-top tr:hover td { background: #faf5f0; }
130
+ .num { text-align: right; font-variant-numeric: tabular-nums; }
131
+
132
+ /* Tags panel */
133
+ #panel-tags { padding: 16px 20px; background: #fff; }
134
+ .tag-group { margin-bottom: 20px; }
135
+ .tag-group h3 { font-size: 14px; color: #cc342d; margin-bottom: 8px; }
136
+ .tag-group table { width: 100%; max-width: 600px; border-collapse: collapse; font-size: 13px; }
137
+ .tag-group th { text-align: left; color: #888; border-bottom: 2px solid #eee; padding: 5px 8px; }
138
+ .tag-group td { padding: 5px 8px; border-bottom: 1px solid #f0f0f0; }
139
+ .tag-group tr:hover td { background: #faf5f0; }
140
+ .tag-group tr { cursor: pointer; }
141
+ .tag-bar { display: inline-block; height: 12px; background: #cc342d; border-radius: 2px; vertical-align: middle; }
142
+ </style>
143
+ </head>
144
+ <body>
145
+ <div id="layout">
146
+ <div id="sidebar">
147
+ <div id="pin-panel" class="pin-panel" style="display:none"></div>
148
+ <div id="snapshot-list" class="snapshot-list"></div>
149
+ <div class="sidebar-footer">j / k: newer / older snapshot · Shift+click: pin method</div>
150
+ </div>
151
+ <div id="main">
152
+ <div class="header">
153
+ <a href="https://github.com/ko1/rperf" target="_blank" rel="noopener" title="rperf on GitHub" style="display:flex;align-items:center;text-decoration:none;">
154
+ <!-- LOGO -->
155
+ </a>
156
+ <div class="controls">
157
+ <label id="lbl-snapshot">Snapshot:
158
+ <select id="sel-snapshot"><option value="">Loading...</option></select>
159
+ </label>
160
+ <label>tagfocus: <input type="text" id="in-tagfocus" placeholder="value regex"></label>
161
+ <label>tagignore:
162
+ <span class="dropdown-cb">
163
+ <button type="button" id="btn-tagignore" class="dropdown-cb-btn">none</button>
164
+ <div id="cb-tagignore" class="dropdown-cb-list"></div>
165
+ </span>
166
+ </label>
167
+ <label>tagroot:
168
+ <span class="dropdown-cb">
169
+ <button type="button" id="btn-tagroot" class="dropdown-cb-btn">none</button>
170
+ <div id="cb-tagroot" class="dropdown-cb-list"></div>
171
+ </span>
172
+ </label>
173
+ <label>tagleaf:
174
+ <span class="dropdown-cb">
175
+ <button type="button" id="btn-tagleaf" class="dropdown-cb-btn">none</button>
176
+ <div id="cb-tagleaf" class="dropdown-cb-list"></div>
177
+ </span>
178
+ </label>
179
+ </div>
180
+ </div>
181
+ <div class="tabs">
182
+ <div class="tab active" data-tab="flamegraph">Flamegraph</div>
183
+ <div class="tab" data-tab="top">Top</div>
184
+ <div class="tab" data-tab="tags">Tags</div>
185
+ </div>
186
+ <div id="snap-toolbar"></div>
187
+ <div id="info-bar" class="info-bar"></div>
188
+ <div id="panel-flamegraph" class="tab-content active"></div>
189
+ <div id="diff-legend" class="diff-legend" style="display:none">
190
+ <span><span style="color:#e85d4a">■</span> share increased</span>
191
+ <span><span style="color:#4a9de8">■</span> share decreased</span>
192
+ <span><span style="color:#ccc">■</span> neutral (&lt;0.4pt)</span>
193
+ </div>
194
+ <div id="panel-top" class="tab-content"></div>
195
+ <div id="panel-tags" class="tab-content"></div>
196
+ </div>
197
+ </div>
198
+
199
+ <script src="https://cdnjs.cloudflare.com/ajax/libs/d3/7.9.0/d3.min.js" integrity="sha384-CjloA8y00+1SDAUkjs099PVfnY2KmDC2BZnws9kh8D/lX1s46w6EPhpXdqMfjK6i" crossorigin="anonymous"></script>
200
+ <script src="https://cdn.jsdelivr.net/npm/d3-flame-graph@4/dist/d3-flamegraph.min.js" integrity="sha384-p4NaVVE+k6MT/enE0MtQ8B15rM9BGzHCnx8DizawPGks1ssZUeNdw6bAPpH2gp2w" crossorigin="anonymous"></script>
201
+ <script>
202
+ "use strict";
203
+
204
+ var BASE = location.pathname.replace(/\/$/, "");
205
+
206
+ // Data source is replaceable at runtime (e.g., signed URLs from a hosting
207
+ // service): define window.RPERF_DATA_SOURCE before this script, with
208
+ // listUrl()/snapshotUrl(id) and optionally onAuthError(url) — an async hook
209
+ // called on HTTP 403 that may return a fresh URL to retry once.
210
+ var dataSource = Object.assign({
211
+ listUrl: function() { return BASE + "/snapshots"; },
212
+ snapshotUrl: function(id) { return BASE + "/snapshots/" + id; },
213
+ onAuthError: null
214
+ }, window.RPERF_DATA_SOURCE || {});
215
+
216
+ // Diff / badge thresholds (design constants, tuned after real-world use)
217
+ var DIFF_NEUTRAL_PT = 0.4; // |delta| below this renders neutral
218
+ var DIFF_FULL_SCALE_PT = 12; // |delta| at full color intensity
219
+ var ALLOC_WARN_PCT = 15; // sidebar warning badge threshold
220
+
221
+ var snapshotList = []; // server order: oldest -> newest
222
+ var currentData = null;
223
+ var curId = null;
224
+ var baseId = null; // diff base snapshot id (null = single view)
225
+ var baseShares = null; // name->cumulative-share map of diff base
226
+ var pinnedName = null;
227
+ var sharesCache = {}; // id -> {total:, shares:{name: weight}}
228
+ var sparkToken = 0;
229
+ var groupCollapsed = {}; // branch -> bool (explicit user toggles)
230
+ var currentTab = "flamegraph";
231
+ var filteredSamples = null; // cached after filter
232
+ var totalFilteredNs = 0;
233
+
234
+ // --- Helpers ---
235
+
236
+ function fmtMs(ns) { return (ns / 1e6).toFixed(2); }
237
+ function fmtPct(ns, total) { return total > 0 ? (ns / total * 100).toFixed(1) : "0.0"; }
238
+ function escHtml(s) {
239
+ return String(s).replace(/&/g,"&amp;").replace(/</g,"&lt;").replace(/>/g,"&gt;");
240
+ }
241
+ function escAttr(s) { return String(s).replace(/&/g,"&amp;").replace(/"/g,"&quot;").replace(/</g,"&lt;"); }
242
+ function escRegex(s) { return s.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"); }
243
+ function findSnap(id) {
244
+ for (var i = 0; i < snapshotList.length; i++) if (snapshotList[i].id === id) return snapshotList[i];
245
+ return null;
246
+ }
247
+ function indexOfId(id) {
248
+ for (var i = 0; i < snapshotList.length; i++) if (snapshotList[i].id === id) return i;
249
+ return -1;
250
+ }
251
+
252
+ // --- Data fetching ---
253
+
254
+ async function fetchJSON(url) {
255
+ var res = await fetch(url);
256
+ if (res.status === 403 && dataSource.onAuthError) {
257
+ // URL may have expired (e.g., signed URL): ask the hook for a fresh one
258
+ var newUrl = await dataSource.onAuthError(url);
259
+ if (newUrl) res = await fetch(newUrl);
260
+ }
261
+ if (!res.ok) throw new Error(res.status + " " + res.statusText);
262
+ return res.json();
263
+ }
264
+
265
+ async function loadSnapshotList() {
266
+ snapshotList = await fetchJSON(dataSource.listUrl());
267
+ var sel = document.getElementById("sel-snapshot");
268
+ sel.innerHTML = "";
269
+ if (snapshotList.length === 0) {
270
+ sel.innerHTML = '<option value="">No snapshots</option>';
271
+ return;
272
+ }
273
+ var reversed = snapshotList.slice().reverse();
274
+ reversed.forEach(function(s) {
275
+ var opt = document.createElement("option");
276
+ opt.value = s.id;
277
+ var t = new Date(s.taken_at);
278
+ var durS = s.duration_ns != null ? (s.duration_ns / 1e9).toFixed(1)
279
+ : (s.summary && s.summary.total_ms != null ? (s.summary.total_ms / 1000).toFixed(1) : "?");
280
+ var cnt = s.sampling_count != null ? s.sampling_count
281
+ : (s.summary && s.summary.samples != null ? s.summary.samples : "?");
282
+ opt.textContent = "#" + s.id + " " + t.toLocaleTimeString() +
283
+ " (" + (s.mode || "?") + ", " + durS + "s, " + cnt + " samples)";
284
+ sel.appendChild(opt);
285
+ });
286
+ if (snapshotList.length > 1) {
287
+ // Time-travel mode: sidebar replaces the dropdown
288
+ document.getElementById("sidebar").style.display = "flex";
289
+ sel.parentElement.style.display = "none";
290
+ renderSidebar();
291
+ }
292
+ await selectSnapshot(snapshotList[snapshotList.length - 1].id);
293
+ }
294
+
295
+ async function selectSnapshot(id) {
296
+ curId = id;
297
+ var sel = document.getElementById("sel-snapshot");
298
+ if (sel) sel.value = String(id);
299
+ var data = await fetchJSON(dataSource.snapshotUrl(id));
300
+ // Stale-response guard: with rapid j/k navigation an older, slower fetch
301
+ // can resolve after a newer one — drop it instead of overwriting the UI
302
+ if (curId !== id) return;
303
+ currentData = data;
304
+ cacheShares(id, currentData);
305
+ if (baseId === curId) { baseId = null; baseShares = null; }
306
+ renderSidebar();
307
+ updateToolbar();
308
+ updateTagDropdowns();
309
+ applyAndRender();
310
+ updateSparkline();
311
+ }
312
+
313
+ // Kept for backwards compatibility with older integrations
314
+ var loadSnapshot = selectSnapshot;
315
+
316
+ // --- Cumulative share per method name (for diff coloring and sparkline) ---
317
+
318
+ function computeShares(data) {
319
+ var shares = {}, total = 0;
320
+ (data.samples || []).forEach(function(s) {
321
+ total += s.weight;
322
+ var seen = {};
323
+ for (var i = 0; i < s.stack.length; i++) {
324
+ var n = s.stack[i];
325
+ if (!seen[n]) { seen[n] = true; shares[n] = (shares[n] || 0) + s.weight; }
326
+ }
327
+ });
328
+ return { total: total, shares: shares };
329
+ }
330
+
331
+ function cacheShares(id, data) {
332
+ if (!sharesCache[id]) sharesCache[id] = computeShares(data);
333
+ }
334
+
335
+ function shareOf(entry, name) {
336
+ return entry && entry.total > 0 ? (entry.shares[name] || 0) / entry.total : 0;
337
+ }
338
+
339
+ // --- Diff mode ---
340
+
341
+ async function setDiffBase(id) {
342
+ if (id === null || id === baseId || id === curId) {
343
+ baseId = null;
344
+ baseShares = null;
345
+ } else {
346
+ baseId = id;
347
+ if (!sharesCache[id]) {
348
+ try {
349
+ var data = await fetchJSON(dataSource.snapshotUrl(id));
350
+ cacheShares(id, data);
351
+ } catch (e) {
352
+ baseId = null;
353
+ }
354
+ }
355
+ baseShares = baseId !== null ? sharesCache[baseId] : null;
356
+ }
357
+ renderSidebar();
358
+ updateToolbar();
359
+ renderCurrentTab();
360
+ }
361
+
362
+ function diffColor(deltaPt) {
363
+ if (Math.abs(deltaPt) < DIFF_NEUTRAL_PT) return "#ececec";
364
+ var t = Math.min(Math.abs(deltaPt) / DIFF_FULL_SCALE_PT, 1);
365
+ var base = deltaPt > 0 ? [232, 93, 74] : [74, 157, 232];
366
+ // blend from near-white toward the full color as |delta| grows
367
+ var mix = base.map(function(c) { return Math.round(255 - (255 - c) * (0.3 + 0.7 * t)); });
368
+ return "rgb(" + mix[0] + "," + mix[1] + "," + mix[2] + ")";
369
+ }
370
+
371
+ // --- Snapshot sidebar ---
372
+
373
+ function snapshotTitle(s) {
374
+ if (s.meta && s.meta.git && s.meta.git.sha) {
375
+ return s.meta.git.sha.slice(0, 7) + (s.meta.git.dirty ? "*" : "");
376
+ }
377
+ return "#" + s.id;
378
+ }
379
+
380
+ function snapshotSubject(s) {
381
+ if (s.meta && s.meta.git && s.meta.git.subject) return s.meta.git.subject;
382
+ if (s.file) return s.file;
383
+ if (!s.meta) return "(unknown snapshot)";
384
+ return "";
385
+ }
386
+
387
+ function snapshotDateStr(s) {
388
+ var t = (s.meta && s.meta.git && s.meta.git.committed_at) ||
389
+ (s.meta && s.meta.created_at) || s.taken_at;
390
+ var d = new Date(t);
391
+ return isNaN(d.getTime()) ? "" : (d.getMonth() + 1) + "/" + d.getDate();
392
+ }
393
+
394
+ function gcCount(sum) {
395
+ if (!sum) return null;
396
+ if (sum.gc_count_minor == null && sum.gc_count_major == null) return null;
397
+ return (sum.gc_count_minor || 0) + (sum.gc_count_major || 0);
398
+ }
399
+
400
+ function allocDeltaPct(s, prev) {
401
+ if (!s.summary || !prev || !prev.summary) return null;
402
+ var a = s.summary.allocated_objects, pa = prev.summary.allocated_objects;
403
+ if (a == null || pa == null || pa <= 0) return null;
404
+ return (a - pa) / pa * 100;
405
+ }
406
+
407
+ function groupSnapshots() {
408
+ var groups = [], idx = {};
409
+ snapshotList.forEach(function(s) {
410
+ var br = (s.meta && s.meta.git && s.meta.git.branch) || "(no branch)";
411
+ if (!(br in idx)) { idx[br] = groups.length; groups.push({ branch: br, items: [] }); }
412
+ groups[idx[br]].items.push(s);
413
+ });
414
+ return groups;
415
+ }
416
+
417
+ function isGroupCollapsed(g, groupCount) {
418
+ if (g.branch in groupCollapsed) return groupCollapsed[g.branch];
419
+ if (groupCount === 1) return false;
420
+ if (g.branch === "main" || g.branch === "master") return false;
421
+ // also expand the group holding the current snapshot
422
+ return !g.items.some(function(s) { return s.id === curId; });
423
+ }
424
+
425
+ function renderSidebar() {
426
+ var listEl = document.getElementById("snapshot-list");
427
+ if (snapshotList.length <= 1) return;
428
+ listEl.innerHTML = "";
429
+ var groups = groupSnapshots();
430
+ groups.forEach(function(g) {
431
+ var collapsed = isGroupCollapsed(g, groups.length);
432
+ var header = document.createElement("div");
433
+ header.className = "snap-group-header";
434
+ header.textContent = (collapsed ? "▸ " : "▾ ") + g.branch + " — " + g.items.length;
435
+ header.addEventListener("click", function() {
436
+ groupCollapsed[g.branch] = !isGroupCollapsed(g, groups.length);
437
+ renderSidebar();
438
+ });
439
+ listEl.appendChild(header);
440
+ if (collapsed) return;
441
+ g.items.slice().reverse().forEach(function(s) {
442
+ listEl.appendChild(buildSnapshotRow(s));
443
+ });
444
+ });
445
+ }
446
+
447
+ function buildSnapshotRow(s) {
448
+ var i = indexOfId(s.id);
449
+ var prev = i > 0 ? snapshotList[i - 1] : null;
450
+ var row = document.createElement("div");
451
+ row.className = "snap-row" + (s.id === curId ? " current" : "") + (s.id === baseId ? " diff-base" : "");
452
+
453
+ var dAlloc = allocDeltaPct(s, prev);
454
+ var warn = dAlloc !== null && Math.abs(dAlloc) > ALLOC_WARN_PCT;
455
+
456
+ var line1 = '<div class="snap-line1">' +
457
+ '<span class="snap-sha">' + escHtml(snapshotTitle(s)) + '</span>' +
458
+ '<span class="snap-date">' + escHtml(snapshotDateStr(s)) + '</span>' +
459
+ (warn ? '<span class="snap-warn" title="alloc changed >' + ALLOC_WARN_PCT + '%">⚠️</span>' : '') +
460
+ '<span class="snap-spacer"></span>' +
461
+ '<button type="button" class="diff-btn' + (s.id === baseId ? " active" : "") +
462
+ '" title="Diff against this snapshot">⇄</button>' +
463
+ '</div>';
464
+ var subject = snapshotSubject(s);
465
+ var line2 = subject ? '<div class="snap-msg">' + escHtml(subject) + '</div>' : "";
466
+ var badge = "";
467
+ if (prev && (dAlloc !== null || gcCount(s.summary) !== null)) {
468
+ var parts = [];
469
+ if (dAlloc !== null) {
470
+ var cls = dAlloc > 2 ? "alloc-up" : dAlloc < -2 ? "alloc-down" : "";
471
+ parts.push('<span class="' + cls + '">alloc ' + (dAlloc >= 0 ? "+" : "") + dAlloc.toFixed(0) + '%</span>');
472
+ }
473
+ var gc = gcCount(s.summary);
474
+ if (gc !== null) parts.push("GC " + gc);
475
+ badge = '<div class="snap-badge">' + parts.join(" · ") + '</div>';
476
+ }
477
+ row.innerHTML = line1 + line2 + badge;
478
+
479
+ row.addEventListener("click", function() { selectSnapshot(s.id); });
480
+ row.querySelector(".diff-btn").addEventListener("click", function(e) {
481
+ e.stopPropagation();
482
+ setDiffBase(s.id === baseId ? null : s.id);
483
+ });
484
+ return row;
485
+ }
486
+
487
+ // --- Snapshot toolbar (current snapshot / diff header + summary stats) ---
488
+
489
+ function updateToolbar() {
490
+ var el = document.getElementById("snap-toolbar");
491
+ if (snapshotList.length <= 1) { el.style.display = "none"; return; }
492
+ el.style.display = "flex";
493
+ var cur = findSnap(curId);
494
+ var base = baseId !== null ? findSnap(baseId) : null;
495
+ if (!cur) { el.innerHTML = ""; return; }
496
+
497
+ var html = "";
498
+ if (base) {
499
+ html += '<span class="tb-sha base">' + escHtml(snapshotTitle(base)) + '</span>' +
500
+ '<span style="color:#aaa">→</span>' +
501
+ '<span class="tb-sha cur">' + escHtml(snapshotTitle(cur)) + '</span>';
502
+ } else {
503
+ html += '<span class="tb-sha cur">' + escHtml(snapshotTitle(cur)) + '</span>';
504
+ }
505
+ var subject = snapshotSubject(cur);
506
+ if (subject) html += '<span class="tb-msg">' + escHtml(subject) + '</span>';
507
+
508
+ var sum = cur.summary || {};
509
+ if (sum.total_ms != null) html += '<span class="tb-stat">total <b>' + sum.total_ms.toFixed(0) + 'ms</b></span>';
510
+ if (sum.allocated_objects != null) html += '<span class="tb-stat">alloc <b>' + sum.allocated_objects.toLocaleString() + '</b></span>';
511
+ var gc = gcCount(sum);
512
+ if (gc !== null) html += '<span class="tb-stat">GC <b>' + gc + '</b></span>';
513
+
514
+ if (base && base.summary && cur.summary &&
515
+ base.summary.allocated_objects != null && cur.summary.allocated_objects != null &&
516
+ base.summary.allocated_objects > 0) {
517
+ var d = (cur.summary.allocated_objects - base.summary.allocated_objects) / base.summary.allocated_objects * 100;
518
+ var color = d > 0 ? "#c0392b" : "#2980b9";
519
+ html += '<span class="tb-stat">Δalloc <b style="color:' + color + '">' +
520
+ (d >= 0 ? "+" : "") + d.toFixed(1) + '%</b></span>';
521
+ }
522
+ html += '<span class="tb-spacer"></span>';
523
+ if (base) html += '<button type="button" class="diff-clear" id="btn-diff-clear">clear diff ✕</button>';
524
+ el.innerHTML = html;
525
+ var clearBtn = document.getElementById("btn-diff-clear");
526
+ if (clearBtn) clearBtn.addEventListener("click", function() { setDiffBase(null); });
527
+ }
528
+
529
+ // --- Pin + sparkline ---
530
+
531
+ function togglePin(name) {
532
+ pinnedName = (pinnedName === name) ? null : name;
533
+ updatePinPanel();
534
+ applyPinStyles();
535
+ updateSparkline();
536
+ }
537
+
538
+ function updatePinPanel() {
539
+ var panel = document.getElementById("pin-panel");
540
+ if (!pinnedName || snapshotList.length <= 1) { panel.style.display = "none"; return; }
541
+ panel.style.display = "block";
542
+ panel.innerHTML =
543
+ '<div class="pin-head">' +
544
+ '<span class="pin-name" title="' + escAttr(pinnedName) + '">📌 ' + escHtml(pinnedName) + '</span>' +
545
+ '<span class="pin-close" id="pin-close">✕</span>' +
546
+ '</div>' +
547
+ '<svg id="sparkline" width="100%" viewBox="0 0 240 40"></svg>' +
548
+ '<div class="pin-share" id="pin-share"></div>';
549
+ document.getElementById("pin-close").addEventListener("click", function() { togglePin(pinnedName); });
550
+ }
551
+
552
+ // Progressively fetch snapshot bodies to fill the sparkline (shares are
553
+ // cached per snapshot; a stale token aborts when the pin changes).
554
+ async function updateSparkline() {
555
+ if (!pinnedName || snapshotList.length <= 1) return;
556
+ drawSparkline();
557
+ var token = ++sparkToken;
558
+ for (var i = 0; i < snapshotList.length; i++) {
559
+ var s = snapshotList[i];
560
+ if (sharesCache[s.id]) continue;
561
+ var data = null;
562
+ try { data = await fetchJSON(dataSource.snapshotUrl(s.id)); } catch (e) { /* skip */ }
563
+ if (token !== sparkToken || !pinnedName) return;
564
+ if (data) { cacheShares(s.id, data); drawSparkline(); }
565
+ }
566
+ }
567
+
568
+ function drawSparkline() {
569
+ var svg = document.getElementById("sparkline");
570
+ if (!svg || !pinnedName) return;
571
+ var W = 240, H = 40, n = snapshotList.length;
572
+ var vals = snapshotList.map(function(s) {
573
+ return sharesCache[s.id] ? shareOf(sharesCache[s.id], pinnedName) : null;
574
+ });
575
+ var known = vals.filter(function(v) { return v !== null; });
576
+ var max = Math.max.apply(null, known.concat([0.0001]));
577
+ var pts = vals.map(function(v, i) {
578
+ if (v === null) return null;
579
+ var x = n > 1 ? 8 + (i / (n - 1)) * (W - 16) : W / 2;
580
+ return [x, H - 5 - (v / max) * (H - 12), i];
581
+ });
582
+ var poly = pts.filter(Boolean).map(function(p) { return p[0] + "," + p[1]; }).join(" ");
583
+ var html = '<polyline points="' + poly + '" fill="none" stroke="#cc342d" stroke-width="1.5"/>';
584
+ pts.forEach(function(p) {
585
+ if (!p) return;
586
+ var s = snapshotList[p[2]];
587
+ var isCur = s.id === curId;
588
+ html += '<circle data-id="' + s.id + '" cx="' + p[0] + '" cy="' + p[1] + '" r="' + (isCur ? 4 : 2.5) +
589
+ '" fill="' + (isCur ? "#cc342d" : "#fff") + '" stroke="#cc342d" stroke-width="1" style="cursor:pointer">' +
590
+ '<title>' + escHtml(snapshotTitle(s)) + ' — ' + (shareOf(sharesCache[s.id], pinnedName) * 100).toFixed(1) + '%</title>' +
591
+ '</circle>';
592
+ });
593
+ svg.innerHTML = html;
594
+ svg.querySelectorAll("circle").forEach(function(c) {
595
+ c.addEventListener("click", function() { selectSnapshot(parseInt(c.getAttribute("data-id"), 10)); });
596
+ });
597
+ var shareEl = document.getElementById("pin-share");
598
+ if (shareEl && sharesCache[curId]) {
599
+ var cur = findSnap(curId);
600
+ shareEl.textContent = "share " + (shareOf(sharesCache[curId], pinnedName) * 100).toFixed(1) + "% @ " +
601
+ (cur ? snapshotTitle(cur) : "");
602
+ }
603
+ }
604
+
605
+ function applyPinStyles() {
606
+ if (typeof d3 === "undefined") return;
607
+ var sel = d3.select("#panel-flamegraph").selectAll("rect");
608
+ if (!pinnedName) {
609
+ sel.style("opacity", 1).style("stroke", null).style("stroke-width", null);
610
+ return;
611
+ }
612
+ sel.style("opacity", function(d) {
613
+ return d && d.data && d.data.name === pinnedName ? 1 : 0.45;
614
+ }).style("stroke", function(d) {
615
+ return d && d.data && d.data.name === pinnedName ? "#f2b134" : null;
616
+ }).style("stroke-width", function(d) {
617
+ return d && d.data && d.data.name === pinnedName ? "2px" : null;
618
+ });
619
+ }
620
+
621
+ // --- Update tag key/value dropdowns from current snapshot ---
622
+
623
+ function updateTagDropdowns() {
624
+ if (!currentData || !currentData.label_sets) return;
625
+ var labelSets = currentData.label_sets;
626
+
627
+ // Collect all keys and all key:value pairs
628
+ var keys = {};
629
+ var vals = {};
630
+ labelSets.forEach(function(ls) {
631
+ if (!ls) return;
632
+ Object.keys(ls).forEach(function(k) {
633
+ keys[k] = true;
634
+ var compound = k + " = " + ls[k];
635
+ vals[compound] = true;
636
+ });
637
+ });
638
+
639
+ var sortedKeys = Object.keys(keys).sort();
640
+ // Group by key: for each key, (none) first, then values sorted
641
+ var sortedVals = [];
642
+ sortedKeys.forEach(function(k) {
643
+ sortedVals.push(k + " = (none)");
644
+ Object.keys(vals).sort().forEach(function(v) {
645
+ if (v.substring(0, k.length + 3) === k + " = ") sortedVals.push(v);
646
+ });
647
+ });
648
+
649
+ // tagroot / tagleaf: dropdown checkboxes for label keys
650
+ ["tagroot", "tagleaf"].forEach(function(name) {
651
+ var container = document.getElementById("cb-" + name);
652
+ var prev = getCheckedValues(container);
653
+ container.innerHTML = "";
654
+ sortedKeys.forEach(function(k) {
655
+ var lbl = document.createElement("label");
656
+ var cb = document.createElement("input");
657
+ cb.type = "checkbox";
658
+ cb.value = k;
659
+ if (prev.indexOf(k) >= 0) cb.checked = true;
660
+ cb.addEventListener("change", function() {
661
+ updateDropdownButton("btn-" + name, "cb-" + name, "none");
662
+ applyAndRender();
663
+ });
664
+ lbl.appendChild(cb);
665
+ lbl.appendChild(document.createTextNode(" " + k));
666
+ container.appendChild(lbl);
667
+ });
668
+ updateDropdownButton("btn-" + name, "cb-" + name, "none");
669
+ });
670
+
671
+ // tagignore: dropdown with checkboxes for key=value pairs
672
+ var container = document.getElementById("cb-tagignore");
673
+ var prev = getCheckedValues(container);
674
+ container.innerHTML = "";
675
+ sortedVals.forEach(function(display) {
676
+ var lbl = document.createElement("label");
677
+ var cb = document.createElement("input");
678
+ cb.type = "checkbox";
679
+ cb.value = display;
680
+ if (prev.indexOf(display) >= 0) cb.checked = true;
681
+ cb.addEventListener("change", function() {
682
+ updateDropdownButton("btn-tagignore", "cb-tagignore", "none");
683
+ applyAndRender();
684
+ });
685
+ lbl.appendChild(cb);
686
+ lbl.appendChild(document.createTextNode(" " + display));
687
+ container.appendChild(lbl);
688
+ });
689
+ updateDropdownButton("btn-tagignore", "cb-tagignore", "none");
690
+ }
691
+
692
+ function updateDropdownButton(btnId, containerId, emptyText) {
693
+ var vals = getCheckedValues(document.getElementById(containerId));
694
+ var btn = document.getElementById(btnId);
695
+ if (vals.length === 0) {
696
+ btn.textContent = emptyText;
697
+ btn.classList.remove("has-selection");
698
+ } else {
699
+ btn.textContent = vals.join(", ");
700
+ btn.classList.add("has-selection");
701
+ }
702
+ }
703
+
704
+ function getCheckedValues(container) {
705
+ var result = [];
706
+ var cbs = container.querySelectorAll("input[type=checkbox]:checked");
707
+ for (var i = 0; i < cbs.length; i++) result.push(cbs[i].value);
708
+ return result;
709
+ }
710
+
711
+ // --- Tag filtering ---
712
+
713
+ function getFilteredSamples() {
714
+ if (!currentData) return [];
715
+ var samples = currentData.samples;
716
+ var labelSets = currentData.label_sets || [];
717
+ var tagfocus = document.getElementById("in-tagfocus").value.trim();
718
+ var tagignoreVals = getCheckedValues(document.getElementById("cb-tagignore"));
719
+ var tagroots = getCheckedValues(document.getElementById("cb-tagroot"));
720
+ var tagleaves = getCheckedValues(document.getElementById("cb-tagleaf"));
721
+
722
+ var filtered = samples;
723
+
724
+ // tagfocus: keep only samples whose label values match the regex
725
+ if (tagfocus) {
726
+ var re = null;
727
+ try { re = new RegExp(tagfocus); } catch(e) { /* invalid regex */ }
728
+ var tfInput = document.getElementById("in-tagfocus");
729
+ tfInput.style.outline = re ? "" : "2px solid #d33";
730
+ tfInput.title = re ? "" : "Invalid regular expression";
731
+ // Invalid regex skips only the tagfocus stage — tagignore/tagroot/
732
+ // tagleaf below must still apply
733
+ if (re) {
734
+ filtered = filtered.filter(function(s) {
735
+ if (s.label_set_id === 0) return false;
736
+ var ls = labelSets[s.label_set_id];
737
+ if (!ls) return false;
738
+ return Object.values(ls).some(function(v) { return re.test(String(v)); });
739
+ });
740
+ }
741
+ } else {
742
+ var tfInput2 = document.getElementById("in-tagfocus");
743
+ tfInput2.style.outline = "";
744
+ tfInput2.title = "";
745
+ }
746
+
747
+ // tagignore: remove samples matching selected key=value pairs (or missing key for "(none)")
748
+ if (tagignoreVals.length > 0) {
749
+ var ignores = tagignoreVals.map(function(s) {
750
+ var idx = s.indexOf(" = ");
751
+ return { key: s.substring(0, idx), val: s.substring(idx + 3) };
752
+ });
753
+ filtered = filtered.filter(function(s) {
754
+ var ls = (s.label_set_id > 0) ? labelSets[s.label_set_id] : null;
755
+ return !ignores.some(function(ig) {
756
+ if (ig.val === "(none)") {
757
+ // Match samples that do NOT have this key
758
+ return !ls || !(ig.key in ls);
759
+ }
760
+ return ls && ls[ig.key] !== undefined && String(ls[ig.key]) === ig.val;
761
+ });
762
+ });
763
+ }
764
+
765
+ // tagroot: prepend label values as root frames (outermost first)
766
+ if (tagroots.length > 0) {
767
+ filtered = filtered.map(function(s) {
768
+ if (s.label_set_id === 0) return s;
769
+ var ls = labelSets[s.label_set_id];
770
+ if (!ls) return s;
771
+ var extra = [];
772
+ for (var i = 0; i < tagroots.length; i++) {
773
+ var k = tagroots[i];
774
+ if (k in ls) extra.push("[" + k + ": " + ls[k] + "]");
775
+ }
776
+ if (extra.length === 0) return s;
777
+ return Object.assign({}, s, { stack: extra.concat(s.stack) });
778
+ });
779
+ }
780
+
781
+ // tagleaf: append label values as leaf frames (innermost first)
782
+ if (tagleaves.length > 0) {
783
+ filtered = filtered.map(function(s) {
784
+ if (s.label_set_id === 0) return s;
785
+ var ls = labelSets[s.label_set_id];
786
+ if (!ls) return s;
787
+ var extra = [];
788
+ for (var i = 0; i < tagleaves.length; i++) {
789
+ var k = tagleaves[i];
790
+ if (k in ls) extra.push("[" + k + ": " + ls[k] + "]");
791
+ }
792
+ if (extra.length === 0) return s;
793
+ return Object.assign({}, s, { stack: s.stack.concat(extra) });
794
+ });
795
+ }
796
+
797
+ return filtered;
798
+ }
799
+
800
+ function applyAndRender() {
801
+ filteredSamples = getFilteredSamples();
802
+ totalFilteredNs = 0;
803
+ for (var i = 0; i < filteredSamples.length; i++) totalFilteredNs += filteredSamples[i].weight;
804
+
805
+ // Update info bar
806
+ if (!currentData) return;
807
+ var dur = (currentData.duration_ns / 1e9).toFixed(2);
808
+ document.getElementById("info-bar").textContent =
809
+ "Mode: " + currentData.mode + " | Freq: " + currentData.frequency + "Hz | Duration: " + dur + "s" +
810
+ " | Stacks: " + filteredSamples.length + " | Total weight: " + fmtMs(totalFilteredNs) + "ms";
811
+
812
+ renderCurrentTab();
813
+ }
814
+
815
+ function renderCurrentTab() {
816
+ if (currentTab === "flamegraph") renderFlamegraph();
817
+ else if (currentTab === "top") renderTop();
818
+ else if (currentTab === "tags") renderTags();
819
+ }
820
+
821
+ // ==================== Flamegraph ====================
822
+
823
+ function buildTree(samples) {
824
+ var root = { name: "root", value: 0, children: [] };
825
+ for (var si = 0; si < samples.length; si++) {
826
+ var sample = samples[si];
827
+ var node = root;
828
+ for (var i = 0; i < sample.stack.length; i++) {
829
+ var name = sample.stack[i];
830
+ var child = null;
831
+ for (var j = 0; j < node.children.length; j++) {
832
+ if (node.children[j].name === name) { child = node.children[j]; break; }
833
+ }
834
+ if (!child) {
835
+ child = { name: name, value: 0, children: [] };
836
+ node.children.push(child);
837
+ }
838
+ node = child;
839
+ }
840
+ node.value += sample.weight;
841
+ }
842
+ computeSubtreeTotals(root);
843
+ return root;
844
+ }
845
+
846
+ function computeSubtreeTotals(node) {
847
+ var t = node.value || 0;
848
+ for (var i = 0; i < node.children.length; i++) t += computeSubtreeTotals(node.children[i]);
849
+ node.total = t;
850
+ return t;
851
+ }
852
+
853
+ function renderFlamegraph() {
854
+ var el = document.getElementById("panel-flamegraph");
855
+ el.innerHTML = "";
856
+ var legend = document.getElementById("diff-legend");
857
+ legend.style.display = (baseShares && currentTab === "flamegraph") ? "flex" : "none";
858
+ if (!filteredSamples || filteredSamples.length === 0) {
859
+ el.innerHTML = '<div class="empty-state">No matching samples</div>';
860
+ return;
861
+ }
862
+ var tree = buildTree(filteredSamples);
863
+ var total = totalFilteredNs;
864
+ var width = el.clientWidth || document.body.clientWidth;
865
+ var chart = flamegraph()
866
+ .width(width)
867
+ .cellHeight(20)
868
+ .selfValue(true)
869
+ .getName(function(d) {
870
+ return d.data.name + " (" + fmtMs(d.data.value) + "ms, " + fmtPct(d.data.value, total) + "%)";
871
+ });
872
+ if (baseShares && typeof chart.setColorMapper === "function") {
873
+ // Diff coloring: current subtree share vs the method's cumulative share
874
+ // in the base snapshot (name-based comparison, as in the design mockup)
875
+ chart.setColorMapper(function(d) {
876
+ var cur = total > 0 ? (d.data.total || 0) / total : 0;
877
+ var base = shareOf(baseShares, d.data.name);
878
+ return diffColor((cur - base) * 100);
879
+ });
880
+ }
881
+ d3.select("#panel-flamegraph").datum(tree).call(chart);
882
+ applyPinStyles();
883
+ }
884
+
885
+ // ==================== Top ====================
886
+
887
+ var topSortKey = "flat";
888
+ var topSortAsc = false;
889
+
890
+ function renderTop() {
891
+ var el = document.getElementById("panel-top");
892
+ if (!filteredSamples || filteredSamples.length === 0) {
893
+ el.innerHTML = '<div class="empty-state">No matching samples</div>';
894
+ return;
895
+ }
896
+
897
+ // Compute flat (leaf) and cumulative (any position) per function
898
+ var flatMap = {};
899
+ var cumMap = {};
900
+ for (var si = 0; si < filteredSamples.length; si++) {
901
+ var s = filteredSamples[si];
902
+ var stack = s.stack;
903
+ var w = s.weight;
904
+ var leaf = stack[stack.length - 1];
905
+ flatMap[leaf] = (flatMap[leaf] || 0) + w;
906
+ var seen = {};
907
+ for (var i = 0; i < stack.length; i++) {
908
+ if (!seen[stack[i]]) {
909
+ seen[stack[i]] = true;
910
+ cumMap[stack[i]] = (cumMap[stack[i]] || 0) + w;
911
+ }
912
+ }
913
+ }
914
+
915
+ var rows = [];
916
+ var allNames = {};
917
+ Object.keys(flatMap).forEach(function(k) { allNames[k] = true; });
918
+ Object.keys(cumMap).forEach(function(k) { allNames[k] = true; });
919
+ Object.keys(allNames).forEach(function(name) {
920
+ rows.push({ name: name, flat: flatMap[name] || 0, cum: cumMap[name] || 0 });
921
+ });
922
+
923
+ // Sort
924
+ var key = topSortKey;
925
+ var asc = topSortAsc;
926
+ rows.sort(function(a, b) {
927
+ var va = (key === "name") ? a.name : a[key];
928
+ var vb = (key === "name") ? b.name : b[key];
929
+ if (key === "name") {
930
+ return asc ? va.localeCompare(vb) : vb.localeCompare(va);
931
+ }
932
+ return asc ? va - vb : vb - va;
933
+ });
934
+
935
+ var total = totalFilteredNs;
936
+ var arrow = function(k) { return (topSortKey === k) ? (topSortAsc ? " ▲" : " ▼") : ""; };
937
+ var html = '<table><thead><tr>' +
938
+ '<th class="num" data-sort="flat">Flat' + arrow("flat") + '</th>' +
939
+ '<th class="num" data-sort="cum">Cum' + arrow("cum") + '</th>' +
940
+ '<th data-sort="name">Function' + arrow("name") + '</th>' +
941
+ '</tr></thead><tbody>';
942
+ var limit = Math.min(rows.length, 50);
943
+ for (var ri = 0; ri < limit; ri++) {
944
+ var r = rows[ri];
945
+ html += '<tr>' +
946
+ '<td class="num">' + fmtMs(r.flat) + 'ms (' + fmtPct(r.flat, total) + '%)</td>' +
947
+ '<td class="num">' + fmtMs(r.cum) + 'ms (' + fmtPct(r.cum, total) + '%)</td>' +
948
+ '<td>' + escHtml(r.name) + '</td>' +
949
+ '</tr>';
950
+ }
951
+ html += '</tbody></table>';
952
+ if (rows.length > 50) {
953
+ html += '<p style="color:#888;margin-top:8px;font-size:12px;">Showing top 50 of ' + rows.length + ' functions</p>';
954
+ }
955
+ el.innerHTML = html;
956
+
957
+ // Attach sort handlers
958
+ el.querySelectorAll("th[data-sort]").forEach(function(th) {
959
+ th.addEventListener("click", function() {
960
+ var newKey = th.getAttribute("data-sort");
961
+ if (topSortKey === newKey) {
962
+ topSortAsc = !topSortAsc;
963
+ } else {
964
+ topSortKey = newKey;
965
+ topSortAsc = (newKey === "name");
966
+ }
967
+ renderTop();
968
+ });
969
+ });
970
+ }
971
+
972
+ // ==================== Tags ====================
973
+
974
+ function renderTags() {
975
+ var el = document.getElementById("panel-tags");
976
+ if (!currentData) { el.innerHTML = '<div class="empty-state">No data</div>'; return; }
977
+
978
+ var samples = filteredSamples || [];
979
+ var labelSets = currentData.label_sets || [];
980
+ if (labelSets.length === 0) {
981
+ el.innerHTML = '<div class="empty-state">No tags in this snapshot</div>';
982
+ return;
983
+ }
984
+
985
+ // Collect all tag keys
986
+ var tagKeys = {};
987
+ labelSets.forEach(function(ls) {
988
+ if (!ls) return;
989
+ Object.keys(ls).forEach(function(k) { tagKeys[k] = true; });
990
+ });
991
+
992
+ var keys = Object.keys(tagKeys);
993
+ if (keys.length === 0) {
994
+ el.innerHTML = '<div class="empty-state">No tags in this snapshot</div>';
995
+ return;
996
+ }
997
+
998
+ // For each key, aggregate weight per value
999
+ var html = "";
1000
+ keys.forEach(function(key) {
1001
+ var byVal = {}; // value -> weight
1002
+ var untagged = 0; // weight without this key
1003
+ for (var i = 0; i < samples.length; i++) {
1004
+ var s = samples[i];
1005
+ var ls = (s.label_set_id > 0) ? labelSets[s.label_set_id] : null;
1006
+ if (ls && key in ls) {
1007
+ var v = String(ls[key]);
1008
+ byVal[v] = (byVal[v] || 0) + s.weight;
1009
+ } else {
1010
+ untagged += s.weight;
1011
+ }
1012
+ }
1013
+
1014
+ var entries = [];
1015
+ Object.keys(byVal).forEach(function(v) { entries.push({ val: v, weight: byVal[v] }); });
1016
+ entries.sort(function(a, b) { return b.weight - a.weight; });
1017
+ var maxWeight = entries.length > 0 ? entries[0].weight : 0;
1018
+ var total = totalFilteredNs;
1019
+
1020
+ html += '<div class="tag-group"><h3>' + escHtml(key) +
1021
+ ' <span style="color:#666;font-weight:normal;">(' + entries.length + ' values)</span></h3>';
1022
+ html += '<table><thead><tr><th>Value</th><th class="num">Weight</th><th class="num">%</th><th style="width:200px"></th></tr></thead><tbody>';
1023
+ entries.forEach(function(e) {
1024
+ var barW = maxWeight > 0 ? Math.max(1, Math.round(e.weight / maxWeight * 180)) : 0;
1025
+ html += '<tr data-tagfocus="' + escAttr(key) + ':' + escAttr(e.val) + '">' +
1026
+ '<td>' + escHtml(e.val) + '</td>' +
1027
+ '<td class="num">' + fmtMs(e.weight) + 'ms</td>' +
1028
+ '<td class="num">' + fmtPct(e.weight, total) + '%</td>' +
1029
+ '<td><span class="tag-bar" style="width:' + barW + 'px"></span></td>' +
1030
+ '</tr>';
1031
+ });
1032
+ if (untagged > 0) {
1033
+ html += '<tr style="color:#666"><td>(untagged)</td>' +
1034
+ '<td class="num">' + fmtMs(untagged) + 'ms</td>' +
1035
+ '<td class="num">' + fmtPct(untagged, total) + '%</td>' +
1036
+ '<td></td></tr>';
1037
+ }
1038
+ html += '</tbody></table></div>';
1039
+ });
1040
+
1041
+ el.innerHTML = html;
1042
+
1043
+ // Click on a tag value row -> set tagfocus and switch to flamegraph
1044
+ el.querySelectorAll("tr[data-tagfocus]").forEach(function(tr) {
1045
+ tr.addEventListener("click", function() {
1046
+ var parts = tr.getAttribute("data-tagfocus").split(":");
1047
+ var val = parts.slice(1).join(":");
1048
+ document.getElementById("in-tagfocus").value = "^" + escRegex(val) + "$";
1049
+ switchTab("flamegraph");
1050
+ applyAndRender();
1051
+ });
1052
+ });
1053
+ }
1054
+
1055
+ // ==================== Tab switching ====================
1056
+
1057
+ function switchTab(name) {
1058
+ currentTab = name;
1059
+ document.querySelectorAll(".tab").forEach(function(t) {
1060
+ t.classList.toggle("active", t.getAttribute("data-tab") === name);
1061
+ });
1062
+ document.querySelectorAll(".tab-content").forEach(function(c) {
1063
+ c.classList.toggle("active", c.id === "panel-" + name);
1064
+ });
1065
+ document.getElementById("diff-legend").style.display =
1066
+ (baseShares && name === "flamegraph") ? "flex" : "none";
1067
+ renderCurrentTab();
1068
+ }
1069
+
1070
+ // ==================== Events ====================
1071
+
1072
+ document.getElementById("sel-snapshot").addEventListener("change", function(e) {
1073
+ if (e.target.value) selectSnapshot(parseInt(e.target.value, 10));
1074
+ });
1075
+
1076
+ // Shift+click on a flamegraph frame pins that method (capture phase so the
1077
+ // chart's own zoom-on-click does not fire)
1078
+ document.getElementById("panel-flamegraph").addEventListener("click", function(e) {
1079
+ if (!e.shiftKey || typeof d3 === "undefined") return;
1080
+ var t = e.target, datum = null;
1081
+ while (t && t !== this) {
1082
+ datum = d3.select(t).datum();
1083
+ if (datum && datum.data && datum.data.name) break;
1084
+ datum = null;
1085
+ t = t.parentNode;
1086
+ }
1087
+ if (!datum) return;
1088
+ e.stopPropagation();
1089
+ e.preventDefault();
1090
+ togglePin(datum.data.name);
1091
+ }, true);
1092
+
1093
+ // j / k: move to newer / older snapshot (time-travel mode)
1094
+ document.addEventListener("keydown", function(e) {
1095
+ if (snapshotList.length <= 1) return;
1096
+ var tag = (e.target.tagName || "").toLowerCase();
1097
+ if (tag === "input" || tag === "select" || tag === "textarea") return;
1098
+ if (e.key !== "j" && e.key !== "k") return;
1099
+ var idx = indexOfId(curId);
1100
+ if (idx < 0) return;
1101
+ if (e.key === "j" && idx < snapshotList.length - 1) selectSnapshot(snapshotList[idx + 1].id);
1102
+ if (e.key === "k" && idx > 0) selectSnapshot(snapshotList[idx - 1].id);
1103
+ });
1104
+
1105
+ // Dropdown toggles for tagignore, tagroot, tagleaf
1106
+ ["tagignore", "tagroot", "tagleaf"].forEach(function(name) {
1107
+ document.getElementById("btn-" + name).addEventListener("click", function(e) {
1108
+ e.stopPropagation();
1109
+ // Close other dropdowns first
1110
+ ["tagignore", "tagroot", "tagleaf"].forEach(function(other) {
1111
+ if (other !== name) document.getElementById("cb-" + other).classList.remove("open");
1112
+ });
1113
+ document.getElementById("cb-" + name).classList.toggle("open");
1114
+ });
1115
+ });
1116
+ document.addEventListener("click", function(e) {
1117
+ ["tagignore", "tagroot", "tagleaf"].forEach(function(name) {
1118
+ var list = document.getElementById("cb-" + name);
1119
+ if (!list.contains(e.target) && e.target.id !== "btn-" + name) {
1120
+ list.classList.remove("open");
1121
+ }
1122
+ });
1123
+ });
1124
+
1125
+ document.querySelectorAll(".tab").forEach(function(t) {
1126
+ t.addEventListener("click", function() { switchTab(t.getAttribute("data-tab")); });
1127
+ });
1128
+
1129
+ var inputs = document.querySelectorAll(".controls input[type=text]");
1130
+ for (var i = 0; i < inputs.length; i++) {
1131
+ inputs[i].addEventListener("keydown", function(e) {
1132
+ if (e.key === "Enter") applyAndRender();
1133
+ });
1134
+ }
1135
+
1136
+ // --- Init ---
1137
+ // A failed list fetch must not leave the UI stuck at "Loading..." with only
1138
+ // a console rejection
1139
+ function showLoadError(e) {
1140
+ var chart = document.getElementById("chart");
1141
+ if (chart) chart.textContent = "Failed to load snapshots: " + (e && e.message ? e.message : e);
1142
+ }
1143
+ // NOTE: the literal next line is the replacement marker for
1144
+ // Rperf::Viewer.render_static_html — keep it byte-identical
1145
+ loadSnapshotList().catch(showLoadError);
1146
+ </script>
1147
+ </body>
1148
+ </html>