rperf 0.8.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.
data/lib/rperf/viewer.rb CHANGED
@@ -1,8 +1,14 @@
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
  #
7
+ # *Security note*: This middleware exposes profiling data without
8
+ # authentication. It is intended for development and staging environments.
9
+ # In production, place it behind an authenticated reverse proxy or restrict
10
+ # access by IP / VPN.
11
+ #
6
12
  # Usage:
7
13
  # require "rperf/viewer"
8
14
  # use Rperf::Viewer # mount at /rperf (default)
@@ -14,6 +20,12 @@ require "json"
14
20
  # viewer.take_snapshot! # snapshot with clear: true
15
21
  # viewer.add_snapshot(data) # or add pre-taken snapshot data
16
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.
17
29
  class Rperf::Viewer
18
30
  @instance = nil
19
31
 
@@ -25,10 +37,13 @@ class Rperf::Viewer
25
37
  attr_reader :max_snapshots, :path
26
38
 
27
39
  def initialize(app, path: "/rperf", max_snapshots: 24)
40
+ raise ArgumentError, "max_snapshots must be a positive integer, got #{max_snapshots.inspect}" unless max_snapshots.is_a?(Integer) && max_snapshots > 0
28
41
  @app = app
29
42
  @path = path.chomp("/")
30
43
  @max_snapshots = max_snapshots
31
- @snapshots = [] # [{id:, taken_at:, data:}, ...]
44
+ # In-memory entries: {id:, taken_at:, data:}
45
+ # Directory entries: {id:, taken_at:, path:, meta:, summary:} — body lazy-loaded
46
+ @snapshots = []
32
47
  @mutex = Mutex.new
33
48
  @next_id = 0
34
49
  self.class.instance_variable_set(:@instance, self)
@@ -43,16 +58,58 @@ class Rperf::Viewer
43
58
  end
44
59
 
45
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.
46
63
  def add_snapshot(data)
64
+ data[:meta] ||= Rperf::Meta.build_meta(data)
65
+ data[:summary] ||= Rperf::Meta.build_summary(data)
47
66
  @mutex.synchronize do
48
67
  @next_id += 1
49
68
  entry = { id: @next_id, taken_at: Time.now, data: data }
50
69
  @snapshots << entry
51
- @snapshots.shift while @snapshots.size > @max_snapshots
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
52
76
  entry
53
77
  end
54
78
  end
55
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
+
56
113
  # Rack interface
57
114
  def call(env)
58
115
  req_path = env["PATH_INFO"] || "/"
@@ -81,13 +138,91 @@ class Rperf::Viewer
81
138
  end
82
139
  end
83
140
 
141
+ # Convert aggregated samples to JSON-friendly format.
142
+ # Stack is stored top-to-bottom (leaf first) in C; reverse to root-first for flamegraph.
143
+ # Label set keys are converted from symbols to strings for JSON.
144
+ def self.samples_to_json(samples, label_sets)
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
148
+ {
149
+ stack: frames.reverse.map { |_, label| label },
150
+ weight: weight,
151
+ label_set_id: label_set_id || 0,
152
+ }
153
+ end
154
+ json_label_sets = label_sets.map do |ls|
155
+ ls.is_a?(Hash) ? ls.transform_keys(&:to_s) : ls
156
+ end
157
+ [json_samples, json_label_sets]
158
+ end
159
+
160
+ # Generate a self-contained static HTML file with inline snapshot data.
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.
164
+ def self.render_static_html(data)
165
+ samples = data[:aggregated_samples] || []
166
+ label_sets = data[:label_sets] || []
167
+ json_samples, json_label_sets = samples_to_json(samples, label_sets)
168
+
169
+ json_snapshot = JSON.generate({
170
+ id: 1,
171
+ taken_at: Time.now.iso8601,
172
+ mode: data[:mode],
173
+ frequency: data[:frequency],
174
+ duration_ns: data[:duration_ns],
175
+ sampling_count: data[:sampling_count],
176
+ meta: data[:meta],
177
+ summary: data[:summary],
178
+ samples: json_samples,
179
+ label_sets: json_label_sets,
180
+ })
181
+
182
+ logo = LOGO_SVG.sub("<svg ", '<svg style="height:36px;width:auto" ')
183
+
184
+ html = VIEWER_HTML.sub("<!-- LOGO -->") { logo }
185
+
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">')
189
+
190
+ # Replace dynamic loading with inline data.
191
+ # Escape for safe embedding in <script>:
192
+ # - "</" prevents closing </script> tag injection
193
+ # - U+2028/U+2029 are line terminators in JS but valid in JSON
194
+ json_safe = json_snapshot
195
+ .gsub("</", "<\\/")
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
+ }
203
+
204
+ html
205
+ end
206
+
84
207
  private
85
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
+
86
219
  LOGO_SVG = begin
87
220
  path = File.expand_path("../../docs/logo.svg", __dir__)
88
221
  File.exist?(path) ? File.read(path).freeze : ""
89
222
  end
90
223
 
224
+ VIEWER_HTML = File.read(File.expand_path("viewer/viewer.html", __dir__)).freeze
225
+
91
226
  def serve_html
92
227
  logo = LOGO_SVG
93
228
  .sub("<svg ", '<svg style="height:36px;width:auto" ')
@@ -102,12 +237,18 @@ class Rperf::Viewer
102
237
  def serve_snapshot_list
103
238
  list = @mutex.synchronize do
104
239
  @snapshots.map do |s|
240
+ data = s[:data]
105
241
  {
106
242
  id: s[:id],
107
243
  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],
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],
111
252
  }
112
253
  end
113
254
  end
@@ -119,24 +260,17 @@ class Rperf::Viewer
119
260
  return [404, { "content-type" => "text/plain" }, ["Snapshot not found"]] unless entry
120
261
 
121
262
  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
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
139
270
  end
271
+ samples = data[:aggregated_samples] || []
272
+ label_sets = data[:label_sets] || []
273
+ json_samples, json_label_sets = self.class.samples_to_json(samples, label_sets)
140
274
 
141
275
  json_response({
142
276
  id: entry[:id],
@@ -145,6 +279,8 @@ class Rperf::Viewer
145
279
  frequency: data[:frequency],
146
280
  duration_ns: data[:duration_ns],
147
281
  sampling_count: data[:sampling_count],
282
+ meta: data[:meta] || entry[:meta],
283
+ summary: data[:summary] || entry[:summary],
148
284
  samples: json_samples,
149
285
  label_sets: json_label_sets,
150
286
  })
@@ -156,643 +292,4 @@ class Rperf::Viewer
156
292
  "x-content-type-options" => "nosniff",
157
293
  }, [JSON.generate(obj)]]
158
294
  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
295
  end