proteum 2.1.2 → 2.1.3-1

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.
@@ -367,6 +367,47 @@ const profilerStyles = `
367
367
  line-height: 1.45;
368
368
  }
369
369
 
370
+ .proteum-profiler__rowTitle {
371
+ min-width: 0;
372
+ word-break: break-word;
373
+ }
374
+
375
+ .proteum-profiler__rowMeta {
376
+ display: inline-flex;
377
+ align-items: center;
378
+ justify-content: flex-end;
379
+ gap: 8px;
380
+ margin-left: auto;
381
+ white-space: nowrap;
382
+ }
383
+
384
+ .proteum-profiler__statusBadge {
385
+ display: inline-flex;
386
+ align-items: center;
387
+ justify-content: center;
388
+ min-height: 18px;
389
+ padding: 0 8px;
390
+ border: 1px solid currentColor;
391
+ color: var(--profiler-muted);
392
+ background: transparent;
393
+ font-size: 10px;
394
+ font-weight: 700;
395
+ letter-spacing: 0.08em;
396
+ text-transform: uppercase;
397
+ }
398
+
399
+ .proteum-profiler__statusBadge--ok {
400
+ color: var(--profiler-ok);
401
+ }
402
+
403
+ .proteum-profiler__statusBadge--warn {
404
+ color: var(--profiler-warn);
405
+ }
406
+
407
+ .proteum-profiler__statusBadge--error {
408
+ color: var(--profiler-error);
409
+ }
410
+
370
411
  .proteum-profiler__mono {
371
412
  font-family: inherit;
372
413
  font-size: 11px;
@@ -727,22 +768,23 @@ type TApiRequestItem = {
727
768
  method: string;
728
769
  path: string;
729
770
  requestData?: TTraceSummaryValue;
771
+ requestDataJson?: unknown;
730
772
  result?: TTraceSummaryValue;
773
+ resultJson?: unknown;
731
774
  startedAt: string;
732
775
  statusCode?: number;
733
776
  statusLabel?: string;
734
777
  tags: string[];
735
778
  };
736
- type TTimelineWaterfallEventItem = {
737
- chartLabel: string;
779
+ type TWaterfallChartItem = {
780
+ barLabel: string;
738
781
  color: string;
739
- durationMs: number;
740
- endMs: number;
782
+ detailLines: string[];
741
783
  endOffsetMs: number;
742
- event: TRequestTrace['events'][number];
743
- startMs: number;
784
+ id: string;
744
785
  startOffsetMs: number;
745
- traceLabel: string;
786
+ subtitle?: string;
787
+ title: string;
746
788
  };
747
789
  type TProfilerState = ReturnType<typeof profilerRuntime.getState>;
748
790
 
@@ -835,6 +877,9 @@ const formatSummaryJson = (value: TTraceSummaryValue | undefined) => {
835
877
  return JSON.stringify(toSummaryJsonValue(value), null, 2);
836
878
  };
837
879
 
880
+ const formatApiPanelJson = (jsonValue: unknown, summaryValue: TTraceSummaryValue | undefined) =>
881
+ jsonValue !== undefined ? formatStructuredValue(jsonValue) : formatSummaryJson(summaryValue);
882
+
838
883
  const formatTraceEventDetailsJson = (details: Record<string, TTraceSummaryValue>) =>
839
884
  JSON.stringify(
840
885
  Object.fromEntries(Object.entries(details).map(([key, value]) => [key, toSummaryJsonValue(value)])),
@@ -951,6 +996,14 @@ const getTraceResultData = (trace: TRequestTrace | undefined) =>
951
996
  const getRequestStatusText = (statusCode?: number, statusLabel?: string) =>
952
997
  statusCode !== undefined ? String(statusCode) : statusLabel || 'pending';
953
998
 
999
+ const getRequestStatusTone = (statusCode?: number, statusLabel?: string): 'ok' | 'warn' | 'error' => {
1000
+ if (statusCode === undefined) return statusLabel === 'pending' ? 'warn' : 'ok';
1001
+ if (statusCode >= 500) return 'error';
1002
+ if (statusCode >= 400) return 'error';
1003
+ if (statusCode >= 300) return 'warn';
1004
+ return 'ok';
1005
+ };
1006
+
954
1007
  const findTraceEvents = (trace: TRequestTrace | undefined, eventTypes: string[]) =>
955
1008
  trace?.events.filter((event) => eventTypes.includes(event.type)) || [];
956
1009
 
@@ -1084,6 +1137,7 @@ const ApiRequestListEntry = ({
1084
1137
  onSelect: () => void;
1085
1138
  }) => {
1086
1139
  const statusText = getRequestStatusText(item.statusCode, item.statusLabel);
1140
+ const statusTone = getRequestStatusTone(item.statusCode, item.statusLabel);
1087
1141
 
1088
1142
  return (
1089
1143
  <button
@@ -1093,19 +1147,14 @@ const ApiRequestListEntry = ({
1093
1147
  type="button"
1094
1148
  >
1095
1149
  <div className="proteum-profiler__rowHeader">
1096
- <strong>{formatApiReference(item.method, item.path, item.requestData, item.label)}</strong>
1097
- <span className="proteum-profiler__mono proteum-profiler__muted">
1098
- {formatDuration(item.durationMs)} | {statusText}
1150
+ <strong className="proteum-profiler__rowTitle">
1151
+ {formatApiReference(item.method, item.path, item.requestData, item.label)}
1152
+ </strong>
1153
+ <span className="proteum-profiler__rowMeta">
1154
+ <span className="proteum-profiler__mono proteum-profiler__muted">{formatDuration(item.durationMs)}</span>
1155
+ <span className={`proteum-profiler__statusBadge proteum-profiler__statusBadge--${statusTone}`}>{statusText}</span>
1099
1156
  </span>
1100
1157
  </div>
1101
- <div className="proteum-profiler__tags">
1102
- {item.tags.map((tag) => (
1103
- <span className="proteum-profiler__tag" key={`${item.id}:${tag}`}>
1104
- {tag}
1105
- </span>
1106
- ))}
1107
- {item.errorMessage ? <span className="proteum-profiler__tag">{truncate(item.errorMessage, 72)}</span> : null}
1108
- </div>
1109
1158
  </button>
1110
1159
  );
1111
1160
  };
@@ -1177,12 +1226,12 @@ const ApiRequestSidebar = ({ item }: { item?: TApiRequestItem }) => {
1177
1226
 
1178
1227
  <div className="proteum-profiler__sidebarSection">
1179
1228
  <div className="proteum-profiler__sidebarSectionTitle">Arguments</div>
1180
- <JsonCodeBlock value={formatSummaryJson(item.requestData)} />
1229
+ <JsonCodeBlock value={formatApiPanelJson(item.requestDataJson, item.requestData)} />
1181
1230
  </div>
1182
1231
 
1183
1232
  <div className="proteum-profiler__sidebarSection">
1184
1233
  <div className="proteum-profiler__sidebarSectionTitle">Result</div>
1185
- <JsonCodeBlock value={formatSummaryJson(item.result)} />
1234
+ <JsonCodeBlock value={formatApiPanelJson(item.resultJson, item.result)} />
1186
1235
  </div>
1187
1236
 
1188
1237
  {item.errorMessage ? (
@@ -1209,7 +1258,9 @@ const ApiPanel = ({ session }: { session: TProfilerNavigationSession }) => {
1209
1258
  method: call.method,
1210
1259
  path: call.path,
1211
1260
  requestData: call.requestData,
1261
+ requestDataJson: call.requestDataJson,
1212
1262
  result: call.result,
1263
+ resultJson: call.resultJson,
1213
1264
  startedAt: call.startedAt,
1214
1265
  statusCode: call.statusCode,
1215
1266
  tags: [
@@ -1231,7 +1282,9 @@ const ApiPanel = ({ session }: { session: TProfilerNavigationSession }) => {
1231
1282
  method: trace.method,
1232
1283
  path: trace.path,
1233
1284
  requestData: getTraceRequestData(trace.trace),
1285
+ requestDataJson: trace.trace?.requestDataJson,
1234
1286
  result: getTraceResultData(trace.trace),
1287
+ resultJson: trace.trace?.resultJson,
1235
1288
  startedAt: trace.startedAt,
1236
1289
  statusCode: trace.trace?.statusCode,
1237
1290
  statusLabel: trace.status,
@@ -1245,57 +1298,67 @@ const ApiPanel = ({ session }: { session: TProfilerNavigationSession }) => {
1245
1298
  setSelectedRequestId(requestItems[0]?.id);
1246
1299
  }, [requestItems, selectedRequestId]);
1247
1300
 
1301
+ const waterfallItems = buildApiWaterfallItems(requestItems);
1248
1302
  const selectedItem = requestItems.find((item) => item.id === selectedRequestId) || requestItems[0];
1249
1303
 
1250
1304
  return (
1251
1305
  <div className="proteum-profiler__requestWorkspace">
1252
- <div className="proteum-profiler__requestGroups">
1253
- <div className="proteum-profiler__requestGroup">
1254
- <div className="proteum-profiler__requestGroupHeader">
1255
- <div className="proteum-profiler__sectionTitle">Synchronous calls</div>
1256
- <div className="proteum-profiler__requestGroupCount">
1257
- {syncItems.length} item{syncItems.length === 1 ? '' : 's'}
1258
- </div>
1259
- </div>
1306
+ <div className="proteum-profiler__splitColumn">
1307
+ <WaterfallChart
1308
+ emptyLabel="No API requests were captured for this session."
1309
+ itemLabel="request"
1310
+ items={waterfallItems}
1311
+ onSelect={setSelectedRequestId}
1312
+ />
1260
1313
 
1261
- {syncItems.length === 0 ? (
1262
- <div className="proteum-profiler__empty">No synchronous SSR or batched API calls captured.</div>
1263
- ) : (
1264
- <div className="proteum-profiler__list">
1265
- {syncItems.map((item) => (
1266
- <ApiRequestListEntry
1267
- isSelected={item.id === selectedItem?.id}
1268
- item={item}
1269
- key={item.id}
1270
- onSelect={() => setSelectedRequestId(item.id)}
1271
- />
1272
- ))}
1314
+ <div className="proteum-profiler__requestGroups">
1315
+ <div className="proteum-profiler__requestGroup">
1316
+ <div className="proteum-profiler__requestGroupHeader">
1317
+ <div className="proteum-profiler__sectionTitle">Synchronous calls</div>
1318
+ <div className="proteum-profiler__requestGroupCount">
1319
+ {syncItems.length} item{syncItems.length === 1 ? '' : 's'}
1320
+ </div>
1273
1321
  </div>
1274
- )}
1275
- </div>
1276
1322
 
1277
- <div className="proteum-profiler__requestGroup">
1278
- <div className="proteum-profiler__requestGroupHeader">
1279
- <div className="proteum-profiler__sectionTitle">Async requests</div>
1280
- <div className="proteum-profiler__requestGroupCount">
1281
- {asyncItems.length} item{asyncItems.length === 1 ? '' : 's'}
1282
- </div>
1323
+ {syncItems.length === 0 ? (
1324
+ <div className="proteum-profiler__empty">No synchronous SSR or batched API calls captured.</div>
1325
+ ) : (
1326
+ <div className="proteum-profiler__list">
1327
+ {syncItems.map((item) => (
1328
+ <ApiRequestListEntry
1329
+ isSelected={item.id === selectedItem?.id}
1330
+ item={item}
1331
+ key={item.id}
1332
+ onSelect={() => setSelectedRequestId(item.id)}
1333
+ />
1334
+ ))}
1335
+ </div>
1336
+ )}
1283
1337
  </div>
1284
1338
 
1285
- {asyncItems.length === 0 ? (
1286
- <div className="proteum-profiler__empty">No async API calls captured.</div>
1287
- ) : (
1288
- <div className="proteum-profiler__list">
1289
- {asyncItems.map((item) => (
1290
- <ApiRequestListEntry
1291
- isSelected={item.id === selectedItem?.id}
1292
- item={item}
1293
- key={item.id}
1294
- onSelect={() => setSelectedRequestId(item.id)}
1295
- />
1296
- ))}
1339
+ <div className="proteum-profiler__requestGroup">
1340
+ <div className="proteum-profiler__requestGroupHeader">
1341
+ <div className="proteum-profiler__sectionTitle">Async requests</div>
1342
+ <div className="proteum-profiler__requestGroupCount">
1343
+ {asyncItems.length} item{asyncItems.length === 1 ? '' : 's'}
1344
+ </div>
1297
1345
  </div>
1298
- )}
1346
+
1347
+ {asyncItems.length === 0 ? (
1348
+ <div className="proteum-profiler__empty">No async API calls captured.</div>
1349
+ ) : (
1350
+ <div className="proteum-profiler__list">
1351
+ {asyncItems.map((item) => (
1352
+ <ApiRequestListEntry
1353
+ isSelected={item.id === selectedItem?.id}
1354
+ item={item}
1355
+ key={item.id}
1356
+ onSelect={() => setSelectedRequestId(item.id)}
1357
+ />
1358
+ ))}
1359
+ </div>
1360
+ )}
1361
+ </div>
1299
1362
  </div>
1300
1363
  </div>
1301
1364
 
@@ -1315,6 +1378,8 @@ const TraceEventSidebar = ({
1315
1378
  label: string;
1316
1379
  trace?: TRequestTrace;
1317
1380
  }) => {
1381
+ const detailEntries = Object.entries(event?.details || {});
1382
+
1318
1383
  if (!event) {
1319
1384
  return (
1320
1385
  <aside className="proteum-profiler__sidebar">
@@ -1353,19 +1418,23 @@ const TraceEventSidebar = ({
1353
1418
  <SummaryRow label="Trace" value={trace?.id || 'n/a'} />
1354
1419
  </div>
1355
1420
 
1356
- <div className="proteum-profiler__sidebarSection">
1357
- <div className="proteum-profiler__sidebarSectionTitle">Summary</div>
1358
- <div className="proteum-profiler__tags">
1359
- {Object.entries(event.details).map(([key, value]) => (
1360
- <span className="proteum-profiler__tag" key={`${trace?.id || 'trace'}:${event.index}:detail:${key}`}>
1361
- {key}:{truncate(renderSummaryValue(value), 72)}
1362
- </span>
1363
- ))}
1421
+ {detailEntries.length > 0 ? (
1422
+ <div className="proteum-profiler__sidebarSection">
1423
+ <div className="proteum-profiler__sidebarSectionTitle">Summary</div>
1424
+ <div>
1425
+ {detailEntries.map(([key, value]) => (
1426
+ <SummaryRow
1427
+ key={`${trace?.id || 'trace'}:${event.index}:detail:${key}`}
1428
+ label={key}
1429
+ value={<span className="proteum-profiler__mono">{truncate(renderSummaryValue(value), 120)}</span>}
1430
+ />
1431
+ ))}
1432
+ </div>
1364
1433
  </div>
1365
- </div>
1434
+ ) : null}
1366
1435
 
1367
1436
  <div className="proteum-profiler__sidebarSection">
1368
- <div className="proteum-profiler__sidebarSectionTitle">Details</div>
1437
+ <div className="proteum-profiler__sidebarSectionTitle">Raw JSON</div>
1369
1438
  <JsonCodeBlock value={formatTraceEventDetailsJson(event.details)} />
1370
1439
  </div>
1371
1440
  </div>
@@ -1565,26 +1634,22 @@ const escapeHtml = (value: string) =>
1565
1634
  .replace(/'/g, '&#39;');
1566
1635
 
1567
1636
  const timelineWaterfallMinDurationMs = 6;
1568
- const timelineWaterfallBarHeight = 15;
1569
- const timelineWaterfallRowGap = 1;
1570
- const timelineWaterfallRowHeight = timelineWaterfallBarHeight + timelineWaterfallRowGap;
1571
-
1572
- const TimelineChart = ({ session }: { session: TProfilerNavigationSession }) => {
1573
- const [ApexChartComponent, setApexChartComponent] = React.useState<unknown>(null);
1574
-
1575
- React.useEffect(() => {
1576
- let isDisposed = false;
1637
+ const waterfallBarHeight = 15;
1638
+ const waterfallRowGap = 1;
1639
+ const waterfallRowHeight = waterfallBarHeight + waterfallRowGap;
1577
1640
 
1578
- void import('react-apexcharts').then((module) => {
1579
- if (isDisposed) return;
1580
- setApexChartComponent(() => module.default);
1581
- });
1582
-
1583
- return () => {
1584
- isDisposed = true;
1585
- };
1586
- }, []);
1641
+ const buildWaterfallEndMs = ({ durationMs, fallbackEndMs, finishedAt, startMs }: {
1642
+ durationMs?: number;
1643
+ fallbackEndMs?: number;
1644
+ finishedAt?: string;
1645
+ startMs: number;
1646
+ }) => {
1647
+ const finishedMs = readDateMs(finishedAt);
1648
+ const durationEndMs = durationMs !== undefined ? startMs + Math.max(durationMs, 1) : undefined;
1649
+ return Math.max(startMs + 1, fallbackEndMs ?? finishedMs ?? durationEndMs ?? startMs + 1);
1650
+ };
1587
1651
 
1652
+ const buildTimelineWaterfallItems = (session: TProfilerNavigationSession): TWaterfallChartItem[] => {
1588
1653
  const sessionStartMs = readDateMs(session.startedAt) ?? 0;
1589
1654
  const rawItems = session.traces.flatMap((traceItem) => {
1590
1655
  const trace = traceItem.trace;
@@ -1594,17 +1659,21 @@ const TimelineChart = ({ session }: { session: TProfilerNavigationSession }) =>
1594
1659
  const traceFinishedMs = readDateMs(trace.finishedAt) ?? (trace.durationMs !== undefined ? traceStartMs + trace.durationMs : undefined);
1595
1660
  const traceLabel = formatSessionTraceDisplay(traceItem);
1596
1661
 
1597
- return trace.events.map((event, index): Omit<TTimelineWaterfallEventItem, 'chartLabel' | 'color' | 'endOffsetMs' | 'startOffsetMs'> => {
1662
+ return trace.events.map((event, index) => {
1598
1663
  const nextEvent = trace.events[index + 1];
1599
1664
  const startMs = readDateMs(event.at) ?? traceStartMs + event.elapsedMs;
1600
1665
  const nextStartMs = nextEvent ? readDateMs(nextEvent.at) ?? traceStartMs + nextEvent.elapsedMs : undefined;
1601
- const endMs = Math.max(startMs + 1, nextStartMs ?? traceFinishedMs ?? startMs + 1);
1666
+ const endMs = buildWaterfallEndMs({
1667
+ fallbackEndMs: nextStartMs ?? traceFinishedMs,
1668
+ startMs,
1669
+ });
1602
1670
 
1603
1671
  return {
1604
1672
  durationMs: Math.max(1, endMs - startMs),
1605
1673
  endMs,
1606
1674
  event,
1607
1675
  startMs,
1676
+ trace,
1608
1677
  traceLabel,
1609
1678
  };
1610
1679
  });
@@ -1612,28 +1681,113 @@ const TimelineChart = ({ session }: { session: TProfilerNavigationSession }) =>
1612
1681
 
1613
1682
  const sortedItems = [...rawItems].sort((left, right) => left.startMs - right.startMs || left.event.index - right.event.index);
1614
1683
  const chartStartMs = sortedItems.length > 0 ? Math.min(...sortedItems.map((item) => item.startMs)) : 0;
1615
- const chartEndMs = sortedItems.length > 0 ? Math.max(...sortedItems.map((item) => item.endMs)) : chartStartMs + 1;
1616
- const totalDurationMs = Math.max(chartEndMs - chartStartMs, 1);
1617
- const items: TTimelineWaterfallEventItem[] = sortedItems
1684
+
1685
+ return sortedItems
1618
1686
  .filter((item) => item.durationMs >= timelineWaterfallMinDurationMs)
1619
- .map((item) => ({
1620
- ...item,
1621
- chartLabel: truncate(`${item.event.type} | ${item.traceLabel}`, 84),
1687
+ .map((item) => {
1688
+ const startOffsetMs = item.startMs - chartStartMs;
1689
+ const endOffsetMs = item.endMs - chartStartMs;
1690
+
1691
+ return {
1692
+ barLabel: truncate(`${item.event.type} | ${item.traceLabel}`, 84),
1693
+ color: getTimelineDurationColor(item.durationMs),
1694
+ detailLines: [
1695
+ `Start: +${Math.round(startOffsetMs)} ms`,
1696
+ `End: +${Math.round(endOffsetMs)} ms`,
1697
+ `Span: ${formatDuration(item.durationMs)}`,
1698
+ ],
1699
+ endOffsetMs,
1700
+ id: getTraceEventKey(item.trace.id, item.event),
1701
+ startOffsetMs,
1702
+ subtitle: item.traceLabel,
1703
+ title: item.event.type,
1704
+ };
1705
+ });
1706
+ };
1707
+
1708
+ const buildApiWaterfallItems = (requestItems: TApiRequestItem[]): TWaterfallChartItem[] => {
1709
+ const rawItems = requestItems.map((item) => {
1710
+ const startMs = readDateMs(item.startedAt) ?? 0;
1711
+ const endMs = buildWaterfallEndMs({
1712
+ durationMs: item.durationMs,
1713
+ finishedAt: item.finishedAt,
1714
+ startMs,
1715
+ });
1716
+ const statusText = getRequestStatusText(item.statusCode, item.statusLabel);
1717
+ const reference = formatApiReference(item.method, item.path, item.requestData, item.label);
1718
+
1719
+ return {
1720
+ endMs,
1721
+ item,
1722
+ reference,
1723
+ startMs,
1724
+ statusText,
1725
+ };
1726
+ });
1727
+
1728
+ const sortedItems = [...rawItems].sort((left, right) => left.startMs - right.startMs || left.reference.localeCompare(right.reference));
1729
+ const chartStartMs = sortedItems.length > 0 ? Math.min(...sortedItems.map((item) => item.startMs)) : 0;
1730
+
1731
+ return sortedItems.map(({ endMs, item, reference, startMs, statusText }) => {
1732
+ const startOffsetMs = startMs - chartStartMs;
1733
+ const endOffsetMs = endMs - chartStartMs;
1734
+
1735
+ return {
1736
+ barLabel: truncate(reference, 84),
1622
1737
  color: getTimelineDurationColor(item.durationMs),
1623
- endOffsetMs: item.endMs - chartStartMs,
1624
- startOffsetMs: item.startMs - chartStartMs,
1625
- }));
1626
- const chartHeight = Math.max(260, items.length * timelineWaterfallRowHeight + 24);
1738
+ detailLines: [
1739
+ `Status: ${statusText}`,
1740
+ `Duration: ${formatDuration(item.durationMs)}`,
1741
+ `Start: +${Math.round(startOffsetMs)} ms`,
1742
+ `End: +${Math.round(endOffsetMs)} ms`,
1743
+ ],
1744
+ endOffsetMs,
1745
+ id: item.id,
1746
+ startOffsetMs,
1747
+ subtitle: item.groupLabel,
1748
+ title: reference,
1749
+ };
1750
+ });
1751
+ };
1752
+
1753
+ const WaterfallChart = ({
1754
+ emptyLabel,
1755
+ itemLabel,
1756
+ items,
1757
+ onSelect,
1758
+ }: {
1759
+ emptyLabel: string;
1760
+ itemLabel: string;
1761
+ items: TWaterfallChartItem[];
1762
+ onSelect?: (itemId: string) => void;
1763
+ }) => {
1764
+ const [ApexChartComponent, setApexChartComponent] = React.useState<unknown>(null);
1765
+
1766
+ React.useEffect(() => {
1767
+ let isDisposed = false;
1768
+
1769
+ void import('react-apexcharts').then((module) => {
1770
+ if (isDisposed) return;
1771
+ setApexChartComponent(() => module.default);
1772
+ });
1773
+
1774
+ return () => {
1775
+ isDisposed = true;
1776
+ };
1777
+ }, []);
1778
+
1779
+ const totalDurationMs = Math.max(items.length > 0 ? Math.max(...items.map((item) => item.endOffsetMs)) : 1, 1);
1780
+ const chartHeight = Math.max(260, items.length * waterfallRowHeight + 24);
1627
1781
  const ChartComponent = ApexChartComponent as any;
1628
1782
 
1629
1783
  const series = [
1630
1784
  {
1631
1785
  data: items.map((item) => ({
1632
1786
  fillColor: item.color,
1633
- x: item.chartLabel,
1787
+ x: item.barLabel,
1634
1788
  y: [item.startOffsetMs, item.endOffsetMs],
1635
1789
  })),
1636
- name: 'Timeline events',
1790
+ name: itemLabel,
1637
1791
  },
1638
1792
  ];
1639
1793
 
@@ -1641,6 +1795,18 @@ const TimelineChart = ({ session }: { session: TProfilerNavigationSession }) =>
1641
1795
  chart: {
1642
1796
  animations: { enabled: false },
1643
1797
  background: 'transparent',
1798
+ events: onSelect
1799
+ ? {
1800
+ dataPointSelection: (
1801
+ _event: unknown,
1802
+ _chartContext: unknown,
1803
+ config: { dataPointIndex: number },
1804
+ ) => {
1805
+ const item = items[config.dataPointIndex];
1806
+ if (item) onSelect(item.id);
1807
+ },
1808
+ }
1809
+ : undefined,
1644
1810
  foreColor: '#627186',
1645
1811
  fontFamily: 'ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", monospace',
1646
1812
  toolbar: { show: false },
@@ -1668,11 +1834,11 @@ const TimelineChart = ({ session }: { session: TProfilerNavigationSession }) =>
1668
1834
  fontFamily: 'ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", monospace',
1669
1835
  fontSize: '11px',
1670
1836
  },
1671
- text: 'No timeline events captured.',
1837
+ text: emptyLabel,
1672
1838
  },
1673
1839
  plotOptions: {
1674
1840
  bar: {
1675
- barHeight: timelineWaterfallBarHeight,
1841
+ barHeight: waterfallBarHeight,
1676
1842
  borderRadius: 2,
1677
1843
  horizontal: true,
1678
1844
  rangeBarGroupRows: false,
@@ -1689,11 +1855,14 @@ const TimelineChart = ({ session }: { session: TProfilerNavigationSession }) =>
1689
1855
 
1690
1856
  return `
1691
1857
  <div style="padding:8px 10px; color:#132033; font-family:ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, Liberation Mono, monospace; font-size:11px; line-height:1.5;">
1692
- <div style="font-weight:700;">${escapeHtml(item.event.type)}</div>
1693
- <div style="color:#627186;">${escapeHtml(item.traceLabel)}</div>
1694
- <div style="margin-top:6px; color:#627186;">Start: +${Math.round(item.startOffsetMs)} ms</div>
1695
- <div style="color:#627186;">End: +${Math.round(item.endOffsetMs)} ms</div>
1696
- <div style="color:#627186;">Span: ${escapeHtml(formatDuration(item.durationMs))}</div>
1858
+ <div style="font-weight:700;">${escapeHtml(item.title)}</div>
1859
+ ${item.subtitle ? `<div style="color:#627186;">${escapeHtml(item.subtitle)}</div>` : ''}
1860
+ ${item.detailLines
1861
+ .map(
1862
+ (line, index) =>
1863
+ `<div style="${index === 0 ? 'margin-top:6px;' : ''} color:#627186;">${escapeHtml(line)}</div>`,
1864
+ )
1865
+ .join('')}
1697
1866
  </div>
1698
1867
  `;
1699
1868
  },
@@ -1727,7 +1896,8 @@ const TimelineChart = ({ session }: { session: TProfilerNavigationSession }) =>
1727
1896
  <div className="proteum-profiler__timelineChart">
1728
1897
  <div className="proteum-profiler__timelineChartMeta">
1729
1898
  <span className="proteum-profiler__mono proteum-profiler__muted">
1730
- {items.length} event{items.length === 1 ? '' : 's'}
1899
+ {items.length} {itemLabel}
1900
+ {items.length === 1 ? '' : 's'}
1731
1901
  </span>
1732
1902
  <span className="proteum-profiler__mono proteum-profiler__muted">{formatDuration(totalDurationMs)}</span>
1733
1903
  </div>
@@ -1738,7 +1908,7 @@ const TimelineChart = ({ session }: { session: TProfilerNavigationSession }) =>
1738
1908
  ) : items.length > 0 ? (
1739
1909
  <div className="proteum-profiler__empty">Loading waterfall chart...</div>
1740
1910
  ) : (
1741
- <div className="proteum-profiler__empty">No timeline events were captured for this session.</div>
1911
+ <div className="proteum-profiler__empty">{emptyLabel}</div>
1742
1912
  )}
1743
1913
  </div>
1744
1914
  </div>
@@ -1764,67 +1934,72 @@ const TimelinePanel = ({ session }: { session: TProfilerNavigationSession }) =>
1764
1934
  setSelectedEventKey(selections[0]?.key);
1765
1935
  }, [selectedEventKey, selections]);
1766
1936
 
1937
+ const waterfallItems = buildTimelineWaterfallItems(session);
1767
1938
  const selected = selections.find((selection) => selection.key === selectedEventKey) || selections[0];
1768
1939
 
1769
1940
  return (
1770
- <div className="proteum-profiler__splitColumn">
1771
- <TimelineChart session={session} />
1772
- <div className="proteum-profiler__splitView proteum-profiler__splitView--stacked">
1773
- <div className="proteum-profiler__splitColumn">
1774
- <div className="proteum-profiler__section">
1775
- <div className="proteum-profiler__titleRow">
1776
- <div className="proteum-profiler__sectionTitle">Navigation steps</div>
1777
- </div>
1778
- <div className="proteum-profiler__list">
1779
- {session.steps.map((step) => (
1780
- <div className="proteum-profiler__row" key={step.id}>
1781
- <div className="proteum-profiler__rowHeader">
1782
- <strong>{step.label}</strong>
1783
- <span className="proteum-profiler__mono proteum-profiler__muted">{formatDuration(step.durationMs)}</span>
1784
- </div>
1785
- <div className="proteum-profiler__tags">
1786
- <span className="proteum-profiler__tag">{step.status}</span>
1787
- {Object.entries(step.details || {}).map(([key, value]) => (
1788
- <span className="proteum-profiler__tag" key={`${step.id}:${key}`}>
1789
- {key}:{String(value)}
1790
- </span>
1791
- ))}
1792
- {step.errorMessage ? <span className="proteum-profiler__tag">{truncate(step.errorMessage, 72)}</span> : null}
1793
- </div>
1794
- </div>
1795
- ))}
1796
- </div>
1797
- </div>
1941
+ <div className="proteum-profiler__splitView">
1942
+ <div className="proteum-profiler__splitColumn">
1943
+ <WaterfallChart
1944
+ emptyLabel="No timeline events were captured for this session."
1945
+ itemLabel="event"
1946
+ items={waterfallItems}
1947
+ onSelect={setSelectedEventKey}
1948
+ />
1798
1949
 
1799
- {session.traces.map((traceItem) =>
1800
- traceItem.trace ? (
1801
- <TraceRows
1802
- key={traceItem.id}
1803
- onSelect={setSelectedEventKey}
1804
- selectedEventKey={selectedEventKey}
1805
- trace={traceItem.trace}
1806
- />
1807
- ) : (
1808
- <div className="proteum-profiler__row" key={traceItem.id}>
1809
- <div className="proteum-profiler__rowHeader">
1810
- <strong>{formatSessionTraceDisplay(traceItem)}</strong>
1811
- <span className="proteum-profiler__mono proteum-profiler__muted">{traceItem.status}</span>
1950
+ <div className="proteum-profiler__section">
1951
+ <div className="proteum-profiler__titleRow">
1952
+ <div className="proteum-profiler__sectionTitle">Navigation steps</div>
1953
+ </div>
1954
+ <div className="proteum-profiler__list">
1955
+ {session.steps.map((step) => (
1956
+ <div className="proteum-profiler__row" key={step.id}>
1957
+ <div className="proteum-profiler__rowHeader">
1958
+ <strong>{step.label}</strong>
1959
+ <span className="proteum-profiler__mono proteum-profiler__muted">{formatDuration(step.durationMs)}</span>
1812
1960
  </div>
1813
- <div className="proteum-profiler__mono">
1814
- {formatProfilerRequestReference({
1815
- fallbackLabel: traceItem.label,
1816
- method: traceItem.method,
1817
- path: traceItem.path,
1818
- requestData: getTraceRequestData(traceItem.trace),
1819
- })}
1961
+ <div className="proteum-profiler__tags">
1962
+ <span className="proteum-profiler__tag">{step.status}</span>
1963
+ {Object.entries(step.details || {}).map(([key, value]) => (
1964
+ <span className="proteum-profiler__tag" key={`${step.id}:${key}`}>
1965
+ {key}:{String(value)}
1966
+ </span>
1967
+ ))}
1968
+ {step.errorMessage ? <span className="proteum-profiler__tag">{truncate(step.errorMessage, 72)}</span> : null}
1820
1969
  </div>
1821
1970
  </div>
1822
- ),
1823
- )}
1971
+ ))}
1972
+ </div>
1824
1973
  </div>
1825
1974
 
1826
- <TraceEventSidebar event={selected?.event} label={selected?.label || 'Trace event'} trace={selected?.trace} />
1975
+ {session.traces.map((traceItem) =>
1976
+ traceItem.trace ? (
1977
+ <TraceRows
1978
+ key={traceItem.id}
1979
+ onSelect={setSelectedEventKey}
1980
+ selectedEventKey={selectedEventKey}
1981
+ trace={traceItem.trace}
1982
+ />
1983
+ ) : (
1984
+ <div className="proteum-profiler__row" key={traceItem.id}>
1985
+ <div className="proteum-profiler__rowHeader">
1986
+ <strong>{formatSessionTraceDisplay(traceItem)}</strong>
1987
+ <span className="proteum-profiler__mono proteum-profiler__muted">{traceItem.status}</span>
1988
+ </div>
1989
+ <div className="proteum-profiler__mono">
1990
+ {formatProfilerRequestReference({
1991
+ fallbackLabel: traceItem.label,
1992
+ method: traceItem.method,
1993
+ path: traceItem.path,
1994
+ requestData: getTraceRequestData(traceItem.trace),
1995
+ })}
1996
+ </div>
1997
+ </div>
1998
+ ),
1999
+ )}
1827
2000
  </div>
2001
+
2002
+ <TraceEventSidebar event={selected?.event} label={selected?.label || 'Trace event'} trace={selected?.trace} />
1828
2003
  </div>
1829
2004
  );
1830
2005
  };