@mtharrison/pkg-profiler 1.1.0 → 2.1.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.
@@ -1,8 +1,9 @@
1
1
  /**
2
2
  * HTML renderer for the profiling report.
3
3
  *
4
- * Generates a self-contained HTML file (inline CSS, no external dependencies)
5
- * with a summary table and expandable Package > File > Function tree.
4
+ * Generates a self-contained HTML file (inline CSS/JS, no external dependencies)
5
+ * with a summary table, expandable Package > File > Function tree, and an
6
+ * interactive threshold slider that filters data client-side.
6
7
  */
7
8
 
8
9
  import type { ReportData, PackageEntry, FileEntry } from '../types.js';
@@ -21,6 +22,7 @@ function generateCss(): string {
21
22
  --bar-track: #e8eaed;
22
23
  --bar-fill: #5b8def;
23
24
  --bar-fill-fp: #3b6cf5;
25
+ --bar-fill-async: #f5943b;
24
26
  --other-text: #a0a4b8;
25
27
  --table-header-bg: #f4f5f7;
26
28
  --shadow: 0 1px 3px rgba(0,0,0,0.06);
@@ -60,6 +62,59 @@ function generateCss(): string {
60
62
  margin-top: 2rem;
61
63
  }
62
64
 
65
+ /* Threshold slider */
66
+ .threshold-control {
67
+ display: flex;
68
+ align-items: center;
69
+ gap: 0.75rem;
70
+ margin-bottom: 1rem;
71
+ font-size: 0.85rem;
72
+ }
73
+
74
+ .threshold-control label {
75
+ font-weight: 600;
76
+ color: var(--muted);
77
+ text-transform: uppercase;
78
+ letter-spacing: 0.04em;
79
+ font-size: 0.8rem;
80
+ }
81
+
82
+ .threshold-control input[type="range"] {
83
+ flex: 1;
84
+ max-width: 240px;
85
+ height: 8px;
86
+ appearance: none;
87
+ -webkit-appearance: none;
88
+ background: var(--bar-track);
89
+ border-radius: 4px;
90
+ outline: none;
91
+ }
92
+
93
+ .threshold-control input[type="range"]::-webkit-slider-thumb {
94
+ appearance: none;
95
+ -webkit-appearance: none;
96
+ width: 16px;
97
+ height: 16px;
98
+ border-radius: 50%;
99
+ background: var(--bar-fill);
100
+ cursor: pointer;
101
+ }
102
+
103
+ .threshold-control input[type="range"]::-moz-range-thumb {
104
+ width: 16px;
105
+ height: 16px;
106
+ border-radius: 50%;
107
+ background: var(--bar-fill);
108
+ cursor: pointer;
109
+ border: none;
110
+ }
111
+
112
+ .threshold-control span {
113
+ font-family: var(--font-mono);
114
+ font-size: 0.85rem;
115
+ min-width: 3.5em;
116
+ }
117
+
63
118
  /* Summary table */
64
119
  table {
65
120
  width: 100%;
@@ -98,6 +153,7 @@ function generateCss(): string {
98
153
 
99
154
  td.pkg-name { font-family: var(--font-mono); font-size: 0.85rem; }
100
155
  td.numeric { text-align: right; font-family: var(--font-mono); font-size: 0.85rem; }
156
+ td.async-col { color: var(--bar-fill-async); }
101
157
 
102
158
  .bar-cell {
103
159
  width: 30%;
@@ -211,6 +267,13 @@ function generateCss(): string {
211
267
  flex-shrink: 0;
212
268
  }
213
269
 
270
+ .tree-async {
271
+ font-family: var(--font-mono);
272
+ font-size: 0.8rem;
273
+ color: var(--bar-fill-async);
274
+ flex-shrink: 0;
275
+ }
276
+
214
277
  /* Level indentation */
215
278
  .level-0 > summary { padding-left: 0.75rem; }
216
279
  .level-1 > summary { padding-left: 2rem; }
@@ -232,17 +295,336 @@ function generateCss(): string {
232
295
  .other-item.indent-1 { padding-left: 2rem; }
233
296
  .other-item.indent-2 { padding-left: 3.25rem; }
234
297
 
298
+ /* Sort control */
299
+ .sort-control {
300
+ display: inline-flex;
301
+ align-items: center;
302
+ gap: 0.5rem;
303
+ margin-left: 1.5rem;
304
+ font-size: 0.85rem;
305
+ }
306
+
307
+ .sort-control label {
308
+ font-weight: 600;
309
+ color: var(--muted);
310
+ text-transform: uppercase;
311
+ letter-spacing: 0.04em;
312
+ font-size: 0.8rem;
313
+ }
314
+
315
+ .sort-toggle {
316
+ display: inline-flex;
317
+ border: 1px solid var(--border);
318
+ border-radius: 4px;
319
+ overflow: hidden;
320
+ }
321
+
322
+ .sort-toggle button {
323
+ font-family: var(--font-sans);
324
+ font-size: 0.8rem;
325
+ padding: 0.25rem 0.6rem;
326
+ border: none;
327
+ background: #fff;
328
+ color: var(--muted);
329
+ cursor: pointer;
330
+ transition: background 0.15s, color 0.15s;
331
+ }
332
+
333
+ .sort-toggle button + button {
334
+ border-left: 1px solid var(--border);
335
+ }
336
+
337
+ .sort-toggle button.active {
338
+ background: var(--bar-fill);
339
+ color: #fff;
340
+ }
341
+
342
+ .sort-toggle button.active-async {
343
+ background: var(--bar-fill-async);
344
+ color: #fff;
345
+ }
346
+
235
347
  @media (max-width: 600px) {
236
348
  body { padding: 1rem; }
237
349
  .bar-cell { width: 25%; }
350
+ .sort-control { margin-left: 0; margin-top: 0.5rem; }
238
351
  }
239
352
  `;
240
353
  }
241
354
 
355
+ function generateJs(): string {
356
+ return `
357
+ (function() {
358
+ var DATA = window.__REPORT_DATA__;
359
+ if (!DATA) return;
360
+ var HAS_ASYNC = !!(DATA.totalAsyncTimeUs && DATA.totalAsyncTimeUs > 0);
361
+
362
+ function formatTime(us) {
363
+ if (us === 0) return '0ms';
364
+ var ms = us / 1000;
365
+ if (ms >= 1000) return (ms / 1000).toFixed(2) + 's';
366
+ var rounded = Math.round(ms);
367
+ return (rounded < 1 ? 1 : rounded) + 'ms';
368
+ }
369
+
370
+ function formatPct(us, totalUs) {
371
+ if (totalUs === 0) return '0.0%';
372
+ return ((us / totalUs) * 100).toFixed(1) + '%';
373
+ }
374
+
375
+ function escapeHtml(str) {
376
+ return str
377
+ .replace(/&/g, '&amp;')
378
+ .replace(/</g, '&lt;')
379
+ .replace(/>/g, '&gt;')
380
+ .replace(/"/g, '&quot;')
381
+ .replace(/'/g, '&#39;');
382
+ }
383
+
384
+ var sortBy = 'cpu';
385
+
386
+ function metricTime(entry) {
387
+ return sortBy === 'async' ? (entry.asyncTimeUs || 0) : entry.timeUs;
388
+ }
389
+
390
+ function sortDesc(arr) {
391
+ return arr.slice().sort(function(a, b) { return metricTime(b) - metricTime(a); });
392
+ }
393
+
394
+ function applyThreshold(data, pct) {
395
+ var totalBase = sortBy === 'async' ? (data.totalAsyncTimeUs || 0) : data.totalTimeUs;
396
+ var threshold = totalBase * (pct / 100);
397
+ var filtered = [];
398
+ var otherCount = 0;
399
+
400
+ var pkgs = sortDesc(data.packages);
401
+
402
+ for (var i = 0; i < pkgs.length; i++) {
403
+ var pkg = pkgs[i];
404
+ if (metricTime(pkg) < threshold) {
405
+ otherCount++;
406
+ continue;
407
+ }
408
+
409
+ var files = [];
410
+ var fileOtherCount = 0;
411
+
412
+ var sortedFiles = sortDesc(pkg.files);
413
+
414
+ for (var j = 0; j < sortedFiles.length; j++) {
415
+ var file = sortedFiles[j];
416
+ if (metricTime(file) < threshold) {
417
+ fileOtherCount++;
418
+ continue;
419
+ }
420
+
421
+ var functions = [];
422
+ var funcOtherCount = 0;
423
+
424
+ var sortedFns = sortDesc(file.functions);
425
+
426
+ for (var k = 0; k < sortedFns.length; k++) {
427
+ var fn = sortedFns[k];
428
+ if (metricTime(fn) < threshold) {
429
+ funcOtherCount++;
430
+ continue;
431
+ }
432
+ functions.push(fn);
433
+ }
434
+
435
+ files.push({
436
+ name: file.name,
437
+ timeUs: file.timeUs,
438
+ pct: file.pct,
439
+ sampleCount: file.sampleCount,
440
+ asyncTimeUs: file.asyncTimeUs,
441
+ asyncPct: file.asyncPct,
442
+ asyncOpCount: file.asyncOpCount,
443
+ functions: functions,
444
+ otherCount: funcOtherCount
445
+ });
446
+ }
447
+
448
+ filtered.push({
449
+ name: pkg.name,
450
+ timeUs: pkg.timeUs,
451
+ pct: pkg.pct,
452
+ isFirstParty: pkg.isFirstParty,
453
+ sampleCount: pkg.sampleCount,
454
+ asyncTimeUs: pkg.asyncTimeUs,
455
+ asyncPct: pkg.asyncPct,
456
+ asyncOpCount: pkg.asyncOpCount,
457
+ files: files,
458
+ otherCount: fileOtherCount
459
+ });
460
+ }
461
+
462
+ return { packages: filtered, otherCount: otherCount };
463
+ }
464
+
465
+ function renderTable(packages, otherCount, totalTimeUs, totalAsyncTimeUs) {
466
+ var rows = '';
467
+ var isAsync = sortBy === 'async';
468
+ var barTotal = isAsync ? (totalAsyncTimeUs || 0) : totalTimeUs;
469
+ for (var i = 0; i < packages.length; i++) {
470
+ var pkg = packages[i];
471
+ var cls = pkg.isFirstParty ? 'first-party' : 'dependency';
472
+ var barVal = isAsync ? (pkg.asyncTimeUs || 0) : pkg.timeUs;
473
+ var pctVal = barTotal > 0 ? (barVal / barTotal) * 100 : 0;
474
+ rows += '<tr class="' + cls + '">' +
475
+ '<td class="pkg-name">' + escapeHtml(pkg.name) + '</td>' +
476
+ '<td class="numeric">' + escapeHtml(formatTime(pkg.timeUs)) + '</td>' +
477
+ '<td class="bar-cell"><div class="bar-container">' +
478
+ '<div class="bar-track"><div class="bar-fill" style="width:' + pctVal.toFixed(1) + '%"></div></div>' +
479
+ '<span class="bar-pct">' + escapeHtml(formatPct(barVal, barTotal)) + '</span>' +
480
+ '</div></td>' +
481
+ '<td class="numeric">' + pkg.sampleCount + '</td>';
482
+ if (HAS_ASYNC) {
483
+ rows += '<td class="numeric async-col">' + escapeHtml(formatTime(pkg.asyncTimeUs || 0)) + '</td>' +
484
+ '<td class="numeric async-col">' + (pkg.asyncOpCount || 0) + '</td>';
485
+ }
486
+ rows += '</tr>';
487
+ }
488
+
489
+ if (otherCount > 0) {
490
+ rows += '<tr class="other-row">' +
491
+ '<td class="pkg-name">Other (' + otherCount + ' items)</td>' +
492
+ '<td class="numeric"></td>' +
493
+ '<td class="bar-cell"></td>' +
494
+ '<td class="numeric"></td>';
495
+ if (HAS_ASYNC) {
496
+ rows += '<td class="numeric"></td><td class="numeric"></td>';
497
+ }
498
+ rows += '</tr>';
499
+ }
500
+
501
+ var headers = '<th>Package</th><th>CPU Time</th><th>% of Total</th><th>Samples</th>';
502
+ if (HAS_ASYNC) {
503
+ headers += '<th>Async I/O Wait</th><th>Async Ops</th>';
504
+ }
505
+
506
+ return '<table><thead><tr>' + headers + '</tr></thead><tbody>' + rows + '</tbody></table>';
507
+ }
508
+
509
+ function asyncStats(entry) {
510
+ if (!HAS_ASYNC) return '';
511
+ var at = entry.asyncTimeUs || 0;
512
+ var ac = entry.asyncOpCount || 0;
513
+ if (at === 0 && ac === 0) return '';
514
+ return ' <span class="tree-async">| ' + escapeHtml(formatTime(at)) + ' async &middot; ' + ac + ' ops</span>';
515
+ }
516
+
517
+ function renderTree(packages, otherCount, totalTimeUs, totalAsyncTimeUs) {
518
+ var html = '<div class="tree">';
519
+ var isAsync = sortBy === 'async';
520
+ var pctTotal = isAsync ? (totalAsyncTimeUs || 0) : totalTimeUs;
521
+
522
+ for (var i = 0; i < packages.length; i++) {
523
+ var pkg = packages[i];
524
+ var fpCls = pkg.isFirstParty ? ' fp-pkg' : '';
525
+ var pkgTime = isAsync ? (pkg.asyncTimeUs || 0) : pkg.timeUs;
526
+ html += '<details class="level-0' + fpCls + '"><summary>';
527
+ html += '<span class="tree-label pkg">pkg</span>';
528
+ html += '<span class="tree-name">' + escapeHtml(pkg.name) + '</span>';
529
+ html += '<span class="tree-stats">' + escapeHtml(formatTime(pkgTime)) + ' &middot; ' + escapeHtml(formatPct(pkgTime, pctTotal)) + ' &middot; ' + pkg.sampleCount + ' samples</span>';
530
+ html += asyncStats(pkg);
531
+ html += '</summary>';
532
+
533
+ for (var j = 0; j < pkg.files.length; j++) {
534
+ var file = pkg.files[j];
535
+ var fileTime = isAsync ? (file.asyncTimeUs || 0) : file.timeUs;
536
+ html += '<details class="level-1"><summary>';
537
+ html += '<span class="tree-label file">file</span>';
538
+ html += '<span class="tree-name">' + escapeHtml(file.name) + '</span>';
539
+ html += '<span class="tree-stats">' + escapeHtml(formatTime(fileTime)) + ' &middot; ' + escapeHtml(formatPct(fileTime, pctTotal)) + ' &middot; ' + file.sampleCount + ' samples</span>';
540
+ html += asyncStats(file);
541
+ html += '</summary>';
542
+
543
+ for (var k = 0; k < file.functions.length; k++) {
544
+ var fn = file.functions[k];
545
+ var fnTime = isAsync ? (fn.asyncTimeUs || 0) : fn.timeUs;
546
+ html += '<div class="level-2">';
547
+ html += '<span class="tree-label fn">fn</span> ';
548
+ html += '<span class="tree-name">' + escapeHtml(fn.name) + '</span>';
549
+ html += ' <span class="tree-stats">' + escapeHtml(formatTime(fnTime)) + ' &middot; ' + escapeHtml(formatPct(fnTime, pctTotal)) + ' &middot; ' + fn.sampleCount + ' samples</span>';
550
+ html += asyncStats(fn);
551
+ html += '</div>';
552
+ }
553
+
554
+ if (file.otherCount > 0) {
555
+ html += '<div class="other-item indent-2">Other (' + file.otherCount + ' items)</div>';
556
+ }
557
+
558
+ html += '</details>';
559
+ }
560
+
561
+ if (pkg.otherCount > 0) {
562
+ html += '<div class="other-item indent-1">Other (' + pkg.otherCount + ' items)</div>';
563
+ }
564
+
565
+ html += '</details>';
566
+ }
567
+
568
+ if (otherCount > 0) {
569
+ html += '<div class="other-item">Other (' + otherCount + ' packages)</div>';
570
+ }
571
+
572
+ html += '</div>';
573
+ return html;
574
+ }
575
+
576
+ var currentThreshold = 5;
577
+
578
+ function update(pct) {
579
+ currentThreshold = pct;
580
+ var result = applyThreshold(DATA, pct);
581
+ var summaryEl = document.getElementById('summary-container');
582
+ var treeEl = document.getElementById('tree-container');
583
+ if (summaryEl) summaryEl.innerHTML = renderTable(result.packages, result.otherCount, DATA.totalTimeUs, DATA.totalAsyncTimeUs);
584
+ if (treeEl) treeEl.innerHTML = renderTree(result.packages, result.otherCount, DATA.totalTimeUs, DATA.totalAsyncTimeUs);
585
+ }
586
+
587
+ function updateSortButtons() {
588
+ var btns = document.querySelectorAll('.sort-toggle button');
589
+ for (var i = 0; i < btns.length; i++) {
590
+ var btn = btns[i];
591
+ btn.className = '';
592
+ if (btn.getAttribute('data-sort') === sortBy) {
593
+ btn.className = sortBy === 'async' ? 'active-async' : 'active';
594
+ }
595
+ }
596
+ }
597
+
598
+ document.addEventListener('DOMContentLoaded', function() {
599
+ update(5);
600
+ var slider = document.getElementById('threshold-slider');
601
+ var label = document.getElementById('threshold-value');
602
+ if (slider) {
603
+ slider.addEventListener('input', function() {
604
+ var val = parseFloat(slider.value);
605
+ if (label) label.textContent = val.toFixed(1) + '%';
606
+ update(val);
607
+ });
608
+ }
609
+
610
+ var sortBtns = document.querySelectorAll('.sort-toggle button');
611
+ for (var i = 0; i < sortBtns.length; i++) {
612
+ sortBtns[i].addEventListener('click', function() {
613
+ sortBy = this.getAttribute('data-sort') || 'cpu';
614
+ updateSortButtons();
615
+ update(currentThreshold);
616
+ });
617
+ }
618
+ });
619
+ })();
620
+ `;
621
+ }
622
+
242
623
  function renderSummaryTable(
243
624
  packages: PackageEntry[],
244
625
  otherCount: number,
245
626
  totalTimeUs: number,
627
+ hasAsync: boolean,
246
628
  ): string {
247
629
  let rows = '';
248
630
 
@@ -259,7 +641,9 @@ function renderSummaryTable(
259
641
  <span class="bar-pct">${escapeHtml(formatPct(pkg.timeUs, totalTimeUs))}</span>
260
642
  </div>
261
643
  </td>
262
- <td class="numeric">${pkg.sampleCount}</td>
644
+ <td class="numeric">${pkg.sampleCount}</td>${hasAsync ? `
645
+ <td class="numeric async-col">${escapeHtml(formatTime(pkg.asyncTimeUs ?? 0))}</td>
646
+ <td class="numeric async-col">${pkg.asyncOpCount ?? 0}</td>` : ''}
263
647
  </tr>`;
264
648
  }
265
649
 
@@ -269,7 +653,9 @@ function renderSummaryTable(
269
653
  <td class="pkg-name">Other (${otherCount} items)</td>
270
654
  <td class="numeric"></td>
271
655
  <td class="bar-cell"></td>
656
+ <td class="numeric"></td>${hasAsync ? `
272
657
  <td class="numeric"></td>
658
+ <td class="numeric"></td>` : ''}
273
659
  </tr>`;
274
660
  }
275
661
 
@@ -278,9 +664,11 @@ function renderSummaryTable(
278
664
  <thead>
279
665
  <tr>
280
666
  <th>Package</th>
281
- <th>Wall Time</th>
667
+ <th>CPU Time</th>
282
668
  <th>% of Total</th>
283
- <th>Samples</th>
669
+ <th>Samples</th>${hasAsync ? `
670
+ <th>Async I/O Wait</th>
671
+ <th>Async Ops</th>` : ''}
284
672
  </tr>
285
673
  </thead>
286
674
  <tbody>${rows}
@@ -288,10 +676,18 @@ function renderSummaryTable(
288
676
  </table>`;
289
677
  }
290
678
 
679
+ function formatAsyncStats(entry: { asyncTimeUs?: number; asyncOpCount?: number }): string {
680
+ const at = entry.asyncTimeUs ?? 0;
681
+ const ac = entry.asyncOpCount ?? 0;
682
+ if (at === 0 && ac === 0) return '';
683
+ return ` <span class="tree-async">| ${escapeHtml(formatTime(at))} async &middot; ${ac} ops</span>`;
684
+ }
685
+
291
686
  function renderTree(
292
687
  packages: PackageEntry[],
293
688
  otherCount: number,
294
689
  totalTimeUs: number,
690
+ hasAsync: boolean,
295
691
  ): string {
296
692
  let html = '<div class="tree">';
297
693
 
@@ -302,6 +698,7 @@ function renderTree(
302
698
  html += `<span class="tree-label pkg">pkg</span>`;
303
699
  html += `<span class="tree-name">${escapeHtml(pkg.name)}</span>`;
304
700
  html += `<span class="tree-stats">${escapeHtml(formatTime(pkg.timeUs))} &middot; ${escapeHtml(formatPct(pkg.timeUs, totalTimeUs))} &middot; ${pkg.sampleCount} samples</span>`;
701
+ if (hasAsync) html += formatAsyncStats(pkg);
305
702
  html += `</summary>`;
306
703
 
307
704
  for (const file of pkg.files) {
@@ -310,6 +707,7 @@ function renderTree(
310
707
  html += `<span class="tree-label file">file</span>`;
311
708
  html += `<span class="tree-name">${escapeHtml(file.name)}</span>`;
312
709
  html += `<span class="tree-stats">${escapeHtml(formatTime(file.timeUs))} &middot; ${escapeHtml(formatPct(file.timeUs, totalTimeUs))} &middot; ${file.sampleCount} samples</span>`;
710
+ if (hasAsync) html += formatAsyncStats(file);
313
711
  html += `</summary>`;
314
712
 
315
713
  for (const fn of file.functions) {
@@ -317,6 +715,7 @@ function renderTree(
317
715
  html += `<span class="tree-label fn">fn</span> `;
318
716
  html += `<span class="tree-name">${escapeHtml(fn.name)}</span>`;
319
717
  html += ` <span class="tree-stats">${escapeHtml(formatTime(fn.timeUs))} &middot; ${escapeHtml(formatPct(fn.timeUs, totalTimeUs))} &middot; ${fn.sampleCount} samples</span>`;
718
+ if (hasAsync) html += formatAsyncStats(fn);
320
719
  html += `</div>`;
321
720
  }
322
721
 
@@ -344,14 +743,31 @@ function renderTree(
344
743
 
345
744
  /**
346
745
  * Render a complete self-contained HTML report from aggregated profiling data.
746
+ *
747
+ * @param data - Aggregated report data (packages, timing, project name).
748
+ * @returns A full HTML document string with inline CSS/JS and no external dependencies.
347
749
  */
348
750
  export function renderHtml(data: ReportData): string {
349
- const summaryTable = renderSummaryTable(data.packages, data.otherCount, data.totalTimeUs);
350
- const tree = renderTree(data.packages, data.otherCount, data.totalTimeUs);
751
+ const hasAsync = !!(data.totalAsyncTimeUs && data.totalAsyncTimeUs > 0);
752
+ const summaryTable = renderSummaryTable(data.packages, data.otherCount, data.totalTimeUs, hasAsync);
753
+ const tree = renderTree(data.packages, data.otherCount, data.totalTimeUs, hasAsync);
351
754
  const totalFormatted = escapeHtml(formatTime(data.totalTimeUs));
352
755
 
353
756
  const titleName = escapeHtml(data.projectName);
354
757
 
758
+ const wallFormatted = data.wallTimeUs ? escapeHtml(formatTime(data.wallTimeUs)) : null;
759
+ let metaLine = `Generated ${escapeHtml(data.timestamp)}`;
760
+ if (wallFormatted) {
761
+ metaLine += ` &middot; Wall time: ${wallFormatted}`;
762
+ }
763
+ metaLine += ` &middot; CPU time: ${totalFormatted}`;
764
+ if (hasAsync) {
765
+ metaLine += ` &middot; Async I/O wait: ${escapeHtml(formatTime(data.totalAsyncTimeUs!))}`;
766
+ }
767
+
768
+ // Sanitize JSON for safe embedding in <script> — replace < to prevent </script> injection
769
+ const safeJson = JSON.stringify(data).replace(/</g, '\\u003c');
770
+
355
771
  return `<!DOCTYPE html>
356
772
  <html lang="en">
357
773
  <head>
@@ -363,13 +779,28 @@ export function renderHtml(data: ReportData): string {
363
779
  </head>
364
780
  <body>
365
781
  <h1>${titleName}</h1>
366
- <div class="meta">Generated ${escapeHtml(data.timestamp)} &middot; Total wall time: ${totalFormatted}</div>
782
+ <div class="meta">${metaLine}</div>
367
783
 
368
784
  <h2>Summary</h2>
369
- ${summaryTable}
785
+ <div class="threshold-control">
786
+ <label>Threshold</label>
787
+ <input type="range" id="threshold-slider" min="0" max="20" step="0.5" value="5">
788
+ <span id="threshold-value">5.0%</span>${hasAsync ? `
789
+ <span class="sort-control">
790
+ <label>Sort by</label>
791
+ <span class="sort-toggle">
792
+ <button data-sort="cpu" class="active">CPU Time</button>
793
+ <button data-sort="async">Async I/O Wait</button>
794
+ </span>
795
+ </span>` : ''}
796
+ </div>
797
+ <div id="summary-container">${summaryTable}</div>
370
798
 
371
799
  <h2>Details</h2>
372
- ${tree}
800
+ <div id="tree-container">${tree}</div>
801
+
802
+ <script>var __REPORT_DATA__ = ${safeJson};</script>
803
+ <script>${generateJs()}</script>
373
804
  </body>
374
805
  </html>`;
375
806
  }