@mtharrison/pkg-profiler 1.1.0 → 2.0.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; }
@@ -239,10 +302,231 @@ function generateCss(): string {
239
302
  `;
240
303
  }
241
304
 
305
+ function generateJs(): string {
306
+ return `
307
+ (function() {
308
+ var DATA = window.__REPORT_DATA__;
309
+ if (!DATA) return;
310
+ var HAS_ASYNC = !!(DATA.totalAsyncTimeUs && DATA.totalAsyncTimeUs > 0);
311
+
312
+ function formatTime(us) {
313
+ if (us === 0) return '0ms';
314
+ var ms = us / 1000;
315
+ if (ms >= 1000) return (ms / 1000).toFixed(2) + 's';
316
+ var rounded = Math.round(ms);
317
+ return (rounded < 1 ? 1 : rounded) + 'ms';
318
+ }
319
+
320
+ function formatPct(us, totalUs) {
321
+ if (totalUs === 0) return '0.0%';
322
+ return ((us / totalUs) * 100).toFixed(1) + '%';
323
+ }
324
+
325
+ function escapeHtml(str) {
326
+ return str
327
+ .replace(/&/g, '&amp;')
328
+ .replace(/</g, '&lt;')
329
+ .replace(/>/g, '&gt;')
330
+ .replace(/"/g, '&quot;')
331
+ .replace(/'/g, '&#39;');
332
+ }
333
+
334
+ function applyThreshold(data, pct) {
335
+ var threshold = data.totalTimeUs * (pct / 100);
336
+ var filtered = [];
337
+ var otherCount = 0;
338
+
339
+ for (var i = 0; i < data.packages.length; i++) {
340
+ var pkg = data.packages[i];
341
+ if (pkg.timeUs < threshold) {
342
+ otherCount++;
343
+ continue;
344
+ }
345
+
346
+ var files = [];
347
+ var fileOtherCount = 0;
348
+
349
+ for (var j = 0; j < pkg.files.length; j++) {
350
+ var file = pkg.files[j];
351
+ if (file.timeUs < threshold) {
352
+ fileOtherCount++;
353
+ continue;
354
+ }
355
+
356
+ var functions = [];
357
+ var funcOtherCount = 0;
358
+
359
+ for (var k = 0; k < file.functions.length; k++) {
360
+ var fn = file.functions[k];
361
+ if (fn.timeUs < threshold) {
362
+ funcOtherCount++;
363
+ continue;
364
+ }
365
+ functions.push(fn);
366
+ }
367
+
368
+ files.push({
369
+ name: file.name,
370
+ timeUs: file.timeUs,
371
+ pct: file.pct,
372
+ sampleCount: file.sampleCount,
373
+ asyncTimeUs: file.asyncTimeUs,
374
+ asyncPct: file.asyncPct,
375
+ asyncOpCount: file.asyncOpCount,
376
+ functions: functions,
377
+ otherCount: funcOtherCount
378
+ });
379
+ }
380
+
381
+ filtered.push({
382
+ name: pkg.name,
383
+ timeUs: pkg.timeUs,
384
+ pct: pkg.pct,
385
+ isFirstParty: pkg.isFirstParty,
386
+ sampleCount: pkg.sampleCount,
387
+ asyncTimeUs: pkg.asyncTimeUs,
388
+ asyncPct: pkg.asyncPct,
389
+ asyncOpCount: pkg.asyncOpCount,
390
+ files: files,
391
+ otherCount: fileOtherCount
392
+ });
393
+ }
394
+
395
+ return { packages: filtered, otherCount: otherCount };
396
+ }
397
+
398
+ function renderTable(packages, otherCount, totalTimeUs) {
399
+ var rows = '';
400
+ for (var i = 0; i < packages.length; i++) {
401
+ var pkg = packages[i];
402
+ var cls = pkg.isFirstParty ? 'first-party' : 'dependency';
403
+ var pctVal = totalTimeUs > 0 ? (pkg.timeUs / totalTimeUs) * 100 : 0;
404
+ rows += '<tr class="' + cls + '">' +
405
+ '<td class="pkg-name">' + escapeHtml(pkg.name) + '</td>' +
406
+ '<td class="numeric">' + escapeHtml(formatTime(pkg.timeUs)) + '</td>' +
407
+ '<td class="bar-cell"><div class="bar-container">' +
408
+ '<div class="bar-track"><div class="bar-fill" style="width:' + pctVal.toFixed(1) + '%"></div></div>' +
409
+ '<span class="bar-pct">' + escapeHtml(formatPct(pkg.timeUs, totalTimeUs)) + '</span>' +
410
+ '</div></td>' +
411
+ '<td class="numeric">' + pkg.sampleCount + '</td>';
412
+ if (HAS_ASYNC) {
413
+ rows += '<td class="numeric async-col">' + escapeHtml(formatTime(pkg.asyncTimeUs || 0)) + '</td>' +
414
+ '<td class="numeric async-col">' + (pkg.asyncOpCount || 0) + '</td>';
415
+ }
416
+ rows += '</tr>';
417
+ }
418
+
419
+ if (otherCount > 0) {
420
+ rows += '<tr class="other-row">' +
421
+ '<td class="pkg-name">Other (' + otherCount + ' items)</td>' +
422
+ '<td class="numeric"></td>' +
423
+ '<td class="bar-cell"></td>' +
424
+ '<td class="numeric"></td>';
425
+ if (HAS_ASYNC) {
426
+ rows += '<td class="numeric"></td><td class="numeric"></td>';
427
+ }
428
+ rows += '</tr>';
429
+ }
430
+
431
+ var headers = '<th>Package</th><th>Wall Time</th><th>% of Total</th><th>Samples</th>';
432
+ if (HAS_ASYNC) {
433
+ headers += '<th>Async Wait</th><th>Async Ops</th>';
434
+ }
435
+
436
+ return '<table><thead><tr>' + headers + '</tr></thead><tbody>' + rows + '</tbody></table>';
437
+ }
438
+
439
+ function asyncStats(entry) {
440
+ if (!HAS_ASYNC) return '';
441
+ var at = entry.asyncTimeUs || 0;
442
+ var ac = entry.asyncOpCount || 0;
443
+ if (at === 0 && ac === 0) return '';
444
+ return ' <span class="tree-async">| ' + escapeHtml(formatTime(at)) + ' async &middot; ' + ac + ' ops</span>';
445
+ }
446
+
447
+ function renderTree(packages, otherCount, totalTimeUs) {
448
+ var html = '<div class="tree">';
449
+
450
+ for (var i = 0; i < packages.length; i++) {
451
+ var pkg = packages[i];
452
+ var fpCls = pkg.isFirstParty ? ' fp-pkg' : '';
453
+ html += '<details class="level-0' + fpCls + '"><summary>';
454
+ html += '<span class="tree-label pkg">pkg</span>';
455
+ html += '<span class="tree-name">' + escapeHtml(pkg.name) + '</span>';
456
+ html += '<span class="tree-stats">' + escapeHtml(formatTime(pkg.timeUs)) + ' &middot; ' + escapeHtml(formatPct(pkg.timeUs, totalTimeUs)) + ' &middot; ' + pkg.sampleCount + ' samples</span>';
457
+ html += asyncStats(pkg);
458
+ html += '</summary>';
459
+
460
+ for (var j = 0; j < pkg.files.length; j++) {
461
+ var file = pkg.files[j];
462
+ html += '<details class="level-1"><summary>';
463
+ html += '<span class="tree-label file">file</span>';
464
+ html += '<span class="tree-name">' + escapeHtml(file.name) + '</span>';
465
+ html += '<span class="tree-stats">' + escapeHtml(formatTime(file.timeUs)) + ' &middot; ' + escapeHtml(formatPct(file.timeUs, totalTimeUs)) + ' &middot; ' + file.sampleCount + ' samples</span>';
466
+ html += asyncStats(file);
467
+ html += '</summary>';
468
+
469
+ for (var k = 0; k < file.functions.length; k++) {
470
+ var fn = file.functions[k];
471
+ html += '<div class="level-2">';
472
+ html += '<span class="tree-label fn">fn</span> ';
473
+ html += '<span class="tree-name">' + escapeHtml(fn.name) + '</span>';
474
+ html += ' <span class="tree-stats">' + escapeHtml(formatTime(fn.timeUs)) + ' &middot; ' + escapeHtml(formatPct(fn.timeUs, totalTimeUs)) + ' &middot; ' + fn.sampleCount + ' samples</span>';
475
+ html += asyncStats(fn);
476
+ html += '</div>';
477
+ }
478
+
479
+ if (file.otherCount > 0) {
480
+ html += '<div class="other-item indent-2">Other (' + file.otherCount + ' items)</div>';
481
+ }
482
+
483
+ html += '</details>';
484
+ }
485
+
486
+ if (pkg.otherCount > 0) {
487
+ html += '<div class="other-item indent-1">Other (' + pkg.otherCount + ' items)</div>';
488
+ }
489
+
490
+ html += '</details>';
491
+ }
492
+
493
+ if (otherCount > 0) {
494
+ html += '<div class="other-item">Other (' + otherCount + ' packages)</div>';
495
+ }
496
+
497
+ html += '</div>';
498
+ return html;
499
+ }
500
+
501
+ function update(pct) {
502
+ var result = applyThreshold(DATA, pct);
503
+ var summaryEl = document.getElementById('summary-container');
504
+ var treeEl = document.getElementById('tree-container');
505
+ if (summaryEl) summaryEl.innerHTML = renderTable(result.packages, result.otherCount, DATA.totalTimeUs);
506
+ if (treeEl) treeEl.innerHTML = renderTree(result.packages, result.otherCount, DATA.totalTimeUs);
507
+ }
508
+
509
+ document.addEventListener('DOMContentLoaded', function() {
510
+ update(5);
511
+ var slider = document.getElementById('threshold-slider');
512
+ var label = document.getElementById('threshold-value');
513
+ if (slider) {
514
+ slider.addEventListener('input', function() {
515
+ var val = parseFloat(slider.value);
516
+ if (label) label.textContent = val.toFixed(1) + '%';
517
+ update(val);
518
+ });
519
+ }
520
+ });
521
+ })();
522
+ `;
523
+ }
524
+
242
525
  function renderSummaryTable(
243
526
  packages: PackageEntry[],
244
527
  otherCount: number,
245
528
  totalTimeUs: number,
529
+ hasAsync: boolean,
246
530
  ): string {
247
531
  let rows = '';
248
532
 
@@ -259,7 +543,9 @@ function renderSummaryTable(
259
543
  <span class="bar-pct">${escapeHtml(formatPct(pkg.timeUs, totalTimeUs))}</span>
260
544
  </div>
261
545
  </td>
262
- <td class="numeric">${pkg.sampleCount}</td>
546
+ <td class="numeric">${pkg.sampleCount}</td>${hasAsync ? `
547
+ <td class="numeric async-col">${escapeHtml(formatTime(pkg.asyncTimeUs ?? 0))}</td>
548
+ <td class="numeric async-col">${pkg.asyncOpCount ?? 0}</td>` : ''}
263
549
  </tr>`;
264
550
  }
265
551
 
@@ -269,7 +555,9 @@ function renderSummaryTable(
269
555
  <td class="pkg-name">Other (${otherCount} items)</td>
270
556
  <td class="numeric"></td>
271
557
  <td class="bar-cell"></td>
558
+ <td class="numeric"></td>${hasAsync ? `
272
559
  <td class="numeric"></td>
560
+ <td class="numeric"></td>` : ''}
273
561
  </tr>`;
274
562
  }
275
563
 
@@ -280,7 +568,9 @@ function renderSummaryTable(
280
568
  <th>Package</th>
281
569
  <th>Wall Time</th>
282
570
  <th>% of Total</th>
283
- <th>Samples</th>
571
+ <th>Samples</th>${hasAsync ? `
572
+ <th>Async Wait</th>
573
+ <th>Async Ops</th>` : ''}
284
574
  </tr>
285
575
  </thead>
286
576
  <tbody>${rows}
@@ -288,10 +578,18 @@ function renderSummaryTable(
288
578
  </table>`;
289
579
  }
290
580
 
581
+ function formatAsyncStats(entry: { asyncTimeUs?: number; asyncOpCount?: number }): string {
582
+ const at = entry.asyncTimeUs ?? 0;
583
+ const ac = entry.asyncOpCount ?? 0;
584
+ if (at === 0 && ac === 0) return '';
585
+ return ` <span class="tree-async">| ${escapeHtml(formatTime(at))} async &middot; ${ac} ops</span>`;
586
+ }
587
+
291
588
  function renderTree(
292
589
  packages: PackageEntry[],
293
590
  otherCount: number,
294
591
  totalTimeUs: number,
592
+ hasAsync: boolean,
295
593
  ): string {
296
594
  let html = '<div class="tree">';
297
595
 
@@ -302,6 +600,7 @@ function renderTree(
302
600
  html += `<span class="tree-label pkg">pkg</span>`;
303
601
  html += `<span class="tree-name">${escapeHtml(pkg.name)}</span>`;
304
602
  html += `<span class="tree-stats">${escapeHtml(formatTime(pkg.timeUs))} &middot; ${escapeHtml(formatPct(pkg.timeUs, totalTimeUs))} &middot; ${pkg.sampleCount} samples</span>`;
603
+ if (hasAsync) html += formatAsyncStats(pkg);
305
604
  html += `</summary>`;
306
605
 
307
606
  for (const file of pkg.files) {
@@ -310,6 +609,7 @@ function renderTree(
310
609
  html += `<span class="tree-label file">file</span>`;
311
610
  html += `<span class="tree-name">${escapeHtml(file.name)}</span>`;
312
611
  html += `<span class="tree-stats">${escapeHtml(formatTime(file.timeUs))} &middot; ${escapeHtml(formatPct(file.timeUs, totalTimeUs))} &middot; ${file.sampleCount} samples</span>`;
612
+ if (hasAsync) html += formatAsyncStats(file);
313
613
  html += `</summary>`;
314
614
 
315
615
  for (const fn of file.functions) {
@@ -317,6 +617,7 @@ function renderTree(
317
617
  html += `<span class="tree-label fn">fn</span> `;
318
618
  html += `<span class="tree-name">${escapeHtml(fn.name)}</span>`;
319
619
  html += ` <span class="tree-stats">${escapeHtml(formatTime(fn.timeUs))} &middot; ${escapeHtml(formatPct(fn.timeUs, totalTimeUs))} &middot; ${fn.sampleCount} samples</span>`;
620
+ if (hasAsync) html += formatAsyncStats(fn);
320
621
  html += `</div>`;
321
622
  }
322
623
 
@@ -344,14 +645,26 @@ function renderTree(
344
645
 
345
646
  /**
346
647
  * Render a complete self-contained HTML report from aggregated profiling data.
648
+ *
649
+ * @param data - Aggregated report data (packages, timing, project name).
650
+ * @returns A full HTML document string with inline CSS/JS and no external dependencies.
347
651
  */
348
652
  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);
653
+ const hasAsync = !!(data.totalAsyncTimeUs && data.totalAsyncTimeUs > 0);
654
+ const summaryTable = renderSummaryTable(data.packages, data.otherCount, data.totalTimeUs, hasAsync);
655
+ const tree = renderTree(data.packages, data.otherCount, data.totalTimeUs, hasAsync);
351
656
  const totalFormatted = escapeHtml(formatTime(data.totalTimeUs));
352
657
 
353
658
  const titleName = escapeHtml(data.projectName);
354
659
 
660
+ let metaLine = `Generated ${escapeHtml(data.timestamp)} &middot; Total wall time: ${totalFormatted}`;
661
+ if (hasAsync) {
662
+ metaLine += ` &middot; Total async wait: ${escapeHtml(formatTime(data.totalAsyncTimeUs!))}`;
663
+ }
664
+
665
+ // Sanitize JSON for safe embedding in <script> — replace < to prevent </script> injection
666
+ const safeJson = JSON.stringify(data).replace(/</g, '\\u003c');
667
+
355
668
  return `<!DOCTYPE html>
356
669
  <html lang="en">
357
670
  <head>
@@ -363,13 +676,21 @@ export function renderHtml(data: ReportData): string {
363
676
  </head>
364
677
  <body>
365
678
  <h1>${titleName}</h1>
366
- <div class="meta">Generated ${escapeHtml(data.timestamp)} &middot; Total wall time: ${totalFormatted}</div>
679
+ <div class="meta">${metaLine}</div>
367
680
 
368
681
  <h2>Summary</h2>
369
- ${summaryTable}
682
+ <div class="threshold-control">
683
+ <label>Threshold</label>
684
+ <input type="range" id="threshold-slider" min="0" max="20" step="0.5" value="5">
685
+ <span id="threshold-value">5.0%</span>
686
+ </div>
687
+ <div id="summary-container">${summaryTable}</div>
370
688
 
371
689
  <h2>Details</h2>
372
- ${tree}
690
+ <div id="tree-container">${tree}</div>
691
+
692
+ <script>var __REPORT_DATA__ = ${safeJson};</script>
693
+ <script>${generateJs()}</script>
373
694
  </body>
374
695
  </html>`;
375
696
  }