proteum 2.1.3-1 → 2.1.7

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.
Files changed (95) hide show
  1. package/AGENTS.md +22 -14
  2. package/README.md +109 -17
  3. package/agents/project/AGENTS.md +188 -25
  4. package/agents/project/CODING_STYLE.md +1 -0
  5. package/agents/project/client/AGENTS.md +13 -8
  6. package/agents/project/client/pages/AGENTS.md +17 -9
  7. package/agents/project/diagnostics.md +52 -0
  8. package/agents/project/optimizations.md +48 -0
  9. package/agents/project/server/routes/AGENTS.md +9 -6
  10. package/agents/project/server/services/AGENTS.md +10 -6
  11. package/agents/project/tests/AGENTS.md +11 -5
  12. package/cli/app/config.ts +13 -14
  13. package/cli/app/index.ts +58 -0
  14. package/cli/commands/connect.ts +45 -0
  15. package/cli/commands/dev.ts +37 -13
  16. package/cli/commands/diagnose.ts +286 -0
  17. package/cli/commands/doctor.ts +18 -5
  18. package/cli/commands/explain.ts +25 -0
  19. package/cli/commands/perf.ts +243 -0
  20. package/cli/commands/trace.ts +9 -1
  21. package/cli/commands/verify.ts +281 -0
  22. package/cli/compiler/artifacts/connectedProjects.ts +453 -0
  23. package/cli/compiler/artifacts/controllers.ts +198 -49
  24. package/cli/compiler/artifacts/discovery.ts +0 -34
  25. package/cli/compiler/artifacts/manifest.ts +95 -6
  26. package/cli/compiler/artifacts/routing.ts +2 -2
  27. package/cli/compiler/artifacts/services.ts +277 -130
  28. package/cli/compiler/client/index.ts +3 -0
  29. package/cli/compiler/common/files/style.ts +52 -0
  30. package/cli/compiler/common/generatedRouteModules.ts +34 -5
  31. package/cli/compiler/common/scripts.ts +11 -5
  32. package/cli/compiler/index.ts +2 -1
  33. package/cli/compiler/server/index.ts +3 -0
  34. package/cli/presentation/commands.ts +110 -7
  35. package/cli/presentation/devSession.ts +32 -7
  36. package/cli/runtime/commands.ts +165 -6
  37. package/cli/scaffold/index.ts +18 -27
  38. package/cli/scaffold/templates.ts +48 -28
  39. package/cli/utils/agents.ts +106 -13
  40. package/cli/utils/keyboard.ts +8 -0
  41. package/client/dev/profiler/ApexChart.tsx +66 -0
  42. package/client/dev/profiler/index.tsx +2508 -302
  43. package/client/dev/profiler/runtime.noop.ts +12 -0
  44. package/client/dev/profiler/runtime.ts +195 -4
  45. package/client/services/router/request/api.ts +6 -1
  46. package/common/applicationConfig.ts +173 -0
  47. package/common/applicationConfigLoader.ts +102 -0
  48. package/common/connectedProjects.ts +113 -0
  49. package/common/dev/connect.ts +267 -0
  50. package/common/dev/console.ts +31 -0
  51. package/common/dev/contractsDoctor.ts +128 -0
  52. package/common/dev/diagnostics.ts +59 -15
  53. package/common/dev/inspection.ts +491 -0
  54. package/common/dev/performance.ts +809 -0
  55. package/common/dev/profiler.ts +3 -0
  56. package/common/dev/proteumManifest.ts +31 -6
  57. package/common/dev/requestTrace.ts +52 -1
  58. package/common/env/proteumEnv.ts +176 -50
  59. package/common/router/index.ts +1 -0
  60. package/common/router/request/api.ts +2 -0
  61. package/config.ts +5 -0
  62. package/docs/dev-commands.md +5 -1
  63. package/docs/dev-sessions.md +90 -0
  64. package/docs/diagnostics.md +74 -11
  65. package/docs/request-tracing.md +50 -3
  66. package/package.json +1 -1
  67. package/server/app/container/config.ts +16 -87
  68. package/server/app/container/console/index.ts +42 -8
  69. package/server/app/container/index.ts +10 -2
  70. package/server/app/container/trace/index.ts +105 -0
  71. package/server/app/devDiagnostics.ts +138 -0
  72. package/server/app/index.ts +18 -8
  73. package/server/app/service/container.ts +0 -12
  74. package/server/app/service/index.ts +0 -2
  75. package/server/services/prisma/index.ts +121 -4
  76. package/server/services/router/http/index.ts +305 -11
  77. package/server/services/router/index.ts +116 -57
  78. package/server/services/router/request/api.ts +160 -19
  79. package/server/services/router/request/index.ts +8 -0
  80. package/server/services/router/response/index.ts +23 -1
  81. package/server/services/router/response/page/document.tsx +31 -14
  82. package/server/services/router/response/page/index.tsx +10 -0
  83. package/agents/framework/AGENTS.md +0 -177
  84. package/server/services/auth/router/service.json +0 -6
  85. package/server/services/auth/service.json +0 -6
  86. package/server/services/cron/service.json +0 -6
  87. package/server/services/disks/drivers/local/service.json +0 -6
  88. package/server/services/disks/drivers/s3/service.json +0 -6
  89. package/server/services/disks/service.json +0 -6
  90. package/server/services/fetch/service.json +0 -7
  91. package/server/services/prisma/service.json +0 -6
  92. package/server/services/router/service.json +0 -6
  93. package/server/services/schema/router/service.json +0 -6
  94. package/server/services/schema/service.json +0 -6
  95. package/server/services/security/encrypt/aes/service.json +0 -6
@@ -1,4 +1,5 @@
1
1
  import React from 'react';
2
+ import type { ApexOptions } from 'apexcharts';
2
3
 
3
4
  import {
4
5
  buildDoctorBlocks,
@@ -9,15 +10,23 @@ import {
9
10
  type THumanTextBlock,
10
11
  } from '@common/dev/diagnostics';
11
12
  import type { TDevCommandDefinition, TDevCommandExecution } from '@common/dev/commands';
13
+ import { summarizeTraceForDiagnose, type TExplainOwnerMatch } from '@common/dev/inspection';
14
+ import {
15
+ buildRequestPerformance,
16
+ perfGroupByValues,
17
+ perfWindowPresets,
18
+ type TRequestPerformance,
19
+ } from '@common/dev/performance';
12
20
  import type {
13
21
  TProfilerCronTask,
14
22
  TProfilerNavigationSession,
15
23
  TProfilerPanel,
16
24
  TProfilerSessionTrace,
17
25
  } from '@common/dev/profiler';
18
- import type { TRequestTrace, TTraceCall, TTraceEventType, TTraceSummaryValue } from '@common/dev/requestTrace';
26
+ import type { TRequestTrace, TTraceCall, TTraceEventType, TTraceSqlQuery, TTraceSummaryValue } from '@common/dev/requestTrace';
19
27
 
20
28
  import { profilerRuntime } from './runtime';
29
+ import ApexChart from './ApexChart';
21
30
 
22
31
  const profilerStyles = `
23
32
  .proteum-profiler {
@@ -257,6 +266,10 @@ const profilerStyles = `
257
266
  background: transparent;
258
267
  }
259
268
 
269
+ .proteum-profiler__panelBody--split {
270
+ overflow: hidden;
271
+ }
272
+
260
273
  .proteum-profiler__metrics {
261
274
  display: grid;
262
275
  gap: 0;
@@ -508,6 +521,7 @@ const profilerStyles = `
508
521
  align-items: stretch;
509
522
  min-height: 100%;
510
523
  height: 100%;
524
+ max-height: 100%;
511
525
  }
512
526
 
513
527
  .proteum-profiler__splitView {
@@ -517,6 +531,7 @@ const profilerStyles = `
517
531
  align-items: stretch;
518
532
  min-height: 100%;
519
533
  height: 100%;
534
+ max-height: 100%;
520
535
  }
521
536
 
522
537
  .proteum-profiler__splitView--stacked {
@@ -528,7 +543,12 @@ const profilerStyles = `
528
543
  display: grid;
529
544
  gap: 0;
530
545
  min-width: 0;
546
+ min-height: 0;
547
+ height: 100%;
531
548
  align-content: start;
549
+ overflow: auto;
550
+ overscroll-behavior: contain;
551
+ scrollbar-width: thin;
532
552
  }
533
553
 
534
554
  .proteum-profiler__requestGroups {
@@ -559,8 +579,6 @@ const profilerStyles = `
559
579
  }
560
580
 
561
581
  .proteum-profiler__sidebar {
562
- position: sticky;
563
- top: 0;
564
582
  display: flex;
565
583
  align-self: stretch;
566
584
  height: 100%;
@@ -571,6 +589,7 @@ const profilerStyles = `
571
589
  border-radius: 0;
572
590
  background: transparent;
573
591
  box-shadow: none;
592
+ overflow: hidden;
574
593
  }
575
594
 
576
595
  .proteum-profiler__sidebarScroller {
@@ -660,6 +679,44 @@ const profilerStyles = `
660
679
  background: transparent !important;
661
680
  }
662
681
 
682
+ .proteum-profiler__chartGrid {
683
+ display: grid;
684
+ grid-template-columns: repeat(2, minmax(0, 1fr));
685
+ gap: 0;
686
+ border-top: 1px solid var(--profiler-line);
687
+ }
688
+
689
+ .proteum-profiler__chartCard {
690
+ display: grid;
691
+ align-content: start;
692
+ min-width: 0;
693
+ border-top: 1px solid var(--profiler-line);
694
+ border-left: 1px solid var(--profiler-line);
695
+ }
696
+
697
+ .proteum-profiler__chartCard:nth-child(2n + 1) {
698
+ border-left: none;
699
+ }
700
+
701
+ .proteum-profiler__chartHeader {
702
+ display: grid;
703
+ gap: 4px;
704
+ padding: 8px 10px;
705
+ background: var(--profiler-title-row-bg);
706
+ }
707
+
708
+ .proteum-profiler__chartSubtitle {
709
+ color: var(--profiler-muted);
710
+ font-size: 11px;
711
+ line-height: 1.5;
712
+ }
713
+
714
+ .proteum-profiler__chartMount {
715
+ min-width: 0;
716
+ padding: 8px 8px 2px;
717
+ background: transparent;
718
+ }
719
+
663
720
  .proteum-profiler__traceEventRow {
664
721
  --profiler-trace-depth: 0;
665
722
  --profiler-trace-guide-opacity: 0;
@@ -710,6 +767,18 @@ const profilerStyles = `
710
767
  gap: 10px;
711
768
  }
712
769
 
770
+ .proteum-profiler__panelBody--split {
771
+ overflow: auto;
772
+ }
773
+
774
+ .proteum-profiler__chartGrid {
775
+ grid-template-columns: 1fr;
776
+ }
777
+
778
+ .proteum-profiler__chartCard {
779
+ border-left: none;
780
+ }
781
+
713
782
  .proteum-profiler__metricRow {
714
783
  grid-template-columns: minmax(90px, 110px) 1fr;
715
784
  }
@@ -722,12 +791,19 @@ const profilerStyles = `
722
791
  grid-template-columns: 1fr;
723
792
  min-height: 0;
724
793
  height: auto;
794
+ max-height: none;
725
795
  }
726
796
 
727
797
  .proteum-profiler__splitView {
728
798
  grid-template-columns: 1fr;
729
799
  min-height: 0;
730
800
  height: auto;
801
+ max-height: none;
802
+ }
803
+
804
+ .proteum-profiler__splitColumn {
805
+ height: auto;
806
+ overflow: visible;
731
807
  }
732
808
 
733
809
  .proteum-profiler__sidebar {
@@ -754,6 +830,7 @@ type TSessionSummary = {
754
830
  primaryTrace?: TProfilerSessionTrace;
755
831
  renderMs?: number;
756
832
  routeLabel: string;
833
+ sqlCount: number;
757
834
  ssrPayloadBytes?: number;
758
835
  statusLabel: string;
759
836
  totalMs?: number;
@@ -766,6 +843,7 @@ type TApiRequestItem = {
766
843
  finishedAt?: string;
767
844
  label?: string;
768
845
  method: string;
846
+ originLabel?: string;
769
847
  path: string;
770
848
  requestData?: TTraceSummaryValue;
771
849
  requestDataJson?: unknown;
@@ -776,6 +854,26 @@ type TApiRequestItem = {
776
854
  statusLabel?: string;
777
855
  tags: string[];
778
856
  };
857
+ type TSqlQueryItem = {
858
+ callerLabel: string;
859
+ durationMs: number;
860
+ finishedAt: string;
861
+ id: string;
862
+ kind: TTraceSqlQuery['kind'];
863
+ model?: string;
864
+ operation: string;
865
+ paramsJson?: unknown;
866
+ paramsText?: string;
867
+ query: string;
868
+ startedAt: string;
869
+ tags: string[];
870
+ target?: string;
871
+ };
872
+ type TSqlQueryGroup = {
873
+ id: string;
874
+ items: TSqlQueryItem[];
875
+ label: string;
876
+ };
779
877
  type TWaterfallChartItem = {
780
878
  barLabel: string;
781
879
  color: string;
@@ -791,11 +889,14 @@ type TProfilerState = ReturnType<typeof profilerRuntime.getState>;
791
889
  const panelLabels: Record<TProfilerPanel, string> = {
792
890
  summary: 'Summary',
793
891
  timeline: 'Timeline',
892
+ perf: 'Perf',
794
893
  routing: 'Routing',
795
894
  auth: 'Auth',
796
895
  controller: 'Controller',
797
896
  ssr: 'SSR',
798
897
  api: 'API',
898
+ sql: 'SQL',
899
+ diagnose: 'Diagnose',
799
900
  errors: 'Errors',
800
901
  explain: 'Explain',
801
902
  doctor: 'Doctor',
@@ -814,11 +915,24 @@ const readNumber = (value: TTraceSummaryValue | undefined) => (typeof value ===
814
915
  const readString = (value: TTraceSummaryValue | undefined) => (typeof value === 'string' ? value : undefined);
815
916
  const formatDuration = (value?: number) => (value === undefined ? 'pending' : `${Math.round(value)} ms`);
816
917
  const formatBytes = (value?: number) => (value === undefined ? 'n/a' : `${(value / 1024).toFixed(value >= 1024 ? 1 : 2)} KB`);
918
+ const formatSignedBytes = (value?: number) =>
919
+ value === undefined ? 'n/a' : `${value >= 0 ? '+' : '-'}${formatBytes(Math.abs(value))}`;
920
+ const formatSignedPercent = (value?: number) => (value === undefined ? 'n/a' : `${value >= 0 ? '+' : ''}${value.toFixed(0)}%`);
817
921
  const formatTimestamp = (value?: string) => {
818
922
  if (!value) return 'never';
819
923
  const date = new Date(value);
820
924
  return Number.isNaN(date.valueOf()) ? value : date.toLocaleString();
821
925
  };
926
+ const formatTimeLabel = (value?: string) => {
927
+ if (!value) return 'n/a';
928
+ const date = new Date(value);
929
+ return Number.isNaN(date.valueOf())
930
+ ? value
931
+ : date.toLocaleTimeString([], {
932
+ hour: '2-digit',
933
+ minute: '2-digit',
934
+ });
935
+ };
822
936
  const formatCronFrequency = (task: TProfilerCronTask) =>
823
937
  task.frequency.kind === 'cron' ? task.frequency.value : `once at ${formatTimestamp(task.frequency.value)}`;
824
938
  const formatStructuredValue = (value: unknown) => {
@@ -828,6 +942,29 @@ const formatStructuredValue = (value: unknown) => {
828
942
  return String(value);
829
943
  }
830
944
  };
945
+ const formatOwnerSource = (match: TExplainOwnerMatch) =>
946
+ `${match.source.filepath}${formatManifestLocation(match.source.line, match.source.column)}`;
947
+ const toRoundedNumber = (value?: number, precision = 1) => {
948
+ if (value === undefined || Number.isNaN(value)) return 0;
949
+ return Number(value.toFixed(precision));
950
+ };
951
+ const toKilobytes = (value?: number, precision = 1) => toRoundedNumber((value || 0) / 1024, precision);
952
+ const buildChartHeight = (rowCount: number, options?: { max?: number; min?: number; rowHeight?: number }) =>
953
+ Math.max(options?.min || 240, Math.min(options?.max || 460, 112 + rowCount * (options?.rowHeight || 34)));
954
+
955
+ const profilerChartTheme = {
956
+ amber: '#f59e0b',
957
+ blue: '#175fe6',
958
+ cyan: '#0ea5e9',
959
+ green: '#15803d',
960
+ indigo: '#6366f1',
961
+ line: 'rgba(19, 32, 51, 0.1)',
962
+ muted: '#627186',
963
+ orange: '#ea580c',
964
+ red: '#b91c1c',
965
+ teal: '#0f766e',
966
+ text: '#132033',
967
+ };
831
968
 
832
969
  const renderSummaryValue = (value: TTraceSummaryValue | undefined): string => {
833
970
  if (value === undefined) return '';
@@ -985,6 +1122,11 @@ const formatProfilerRequestReference = ({
985
1122
  return rawReference || fallbackLabel || 'request';
986
1123
  };
987
1124
 
1125
+ const formatConnectedTraceCallReference = (call: TTraceCall) =>
1126
+ call.connectedProjectNamespace && call.connectedControllerAccessor
1127
+ ? `${call.connectedProjectNamespace}.${call.connectedControllerAccessor}`
1128
+ : undefined;
1129
+
988
1130
  const getTraceRequestData = (trace: TRequestTrace | undefined) =>
989
1131
  trace?.events.find((event) => event.type === 'request.start')?.details.data;
990
1132
 
@@ -1060,6 +1202,7 @@ const getSummary = (session: TProfilerNavigationSession): TSessionSummary => {
1060
1202
  session.steps.filter((step) => step.status === 'error').length +
1061
1203
  session.traces.filter((traceItem) => traceItem.status === 'error').length +
1062
1204
  syncCalls.filter((call) => call.errorMessage || (call.statusCode !== undefined && call.statusCode >= 400)).length;
1205
+ const sqlCount = session.traces.reduce((count, traceItem) => count + (traceItem.trace?.sqlQueries.length || 0), 0);
1063
1206
  const renderStart = trace?.events.find((event) => event.type === 'render.start');
1064
1207
  const renderEnd = trace?.events.find((event) => event.type === 'render.end');
1065
1208
  const localRender = [...session.steps].reverse().find((step) => step.label === 'Render' && step.durationMs !== undefined);
@@ -1076,6 +1219,7 @@ const getSummary = (session: TProfilerNavigationSession): TSessionSummary => {
1076
1219
  ? Math.max(0, renderEnd.elapsedMs - renderStart.elapsedMs)
1077
1220
  : localRender?.durationMs,
1078
1221
  routeLabel,
1222
+ sqlCount,
1079
1223
  ssrPayloadBytes: readNumber(ssrPayload?.details.serializedBytes),
1080
1224
  statusLabel: session.kind === 'client-navigation' ? 'NAV' : trace ? `${trace.statusCode || 'pending'} ${trace.method}` : 'SSR',
1081
1225
  totalMs: session.kind === 'client-navigation' ? session.durationMs : trace?.durationMs ?? session.durationMs,
@@ -1099,7 +1243,12 @@ const JsonCodeBlock = ({ value }: { value: string }) => (
1099
1243
  <pre className="proteum-profiler__mono proteum-profiler__pre">{renderHighlightedJson(value)}</pre>
1100
1244
  );
1101
1245
 
1246
+ const PlainCodeBlock = ({ value }: { value: string }) => <pre className="proteum-profiler__mono proteum-profiler__pre">{value}</pre>;
1247
+
1102
1248
  const formatTraceCallDisplay = (call: TTraceCall) => {
1249
+ const connectedReference = formatConnectedTraceCallReference(call);
1250
+ if (connectedReference) return connectedReference;
1251
+
1103
1252
  if (call.path.startsWith('/api/')) {
1104
1253
  return formatProfilerRequestReference({
1105
1254
  fallbackLabel: call.label,
@@ -1127,6 +1276,83 @@ const formatSessionTraceDisplay = (traceItem: TProfilerSessionTrace) => {
1127
1276
  return traceItem.label || formatProfilerRequestReference({ method: traceItem.method, path: traceItem.path });
1128
1277
  };
1129
1278
 
1279
+ const formatSqlCallerReference = ({
1280
+ callerLabel,
1281
+ callerMethod,
1282
+ callerPath,
1283
+ }: Pick<TTraceSqlQuery, 'callerLabel' | 'callerMethod' | 'callerPath'>) =>
1284
+ formatProfilerRequestReference({
1285
+ fallbackLabel: callerLabel,
1286
+ method: callerMethod,
1287
+ path: callerPath,
1288
+ });
1289
+
1290
+ const formatSqlQueryTitle = (query: string) => truncate(query.replace(/\s+/g, ' ').trim() || 'SQL query', 160);
1291
+
1292
+ const formatSqlParams = (item: TSqlQueryItem) =>
1293
+ item.paramsJson !== undefined ? formatStructuredValue(item.paramsJson) : item.paramsText || '[]';
1294
+
1295
+ const buildSqlQueryWorkspace = (session: TProfilerNavigationSession) => {
1296
+ const groups = new Map<string, TSqlQueryGroup>();
1297
+ const queryItems: TSqlQueryItem[] = [];
1298
+ const sortItems = (left: { id: string; startedAt: string }, right: { id: string; startedAt: string }) =>
1299
+ (readDateMs(left.startedAt) ?? 0) - (readDateMs(right.startedAt) ?? 0) || left.id.localeCompare(right.id);
1300
+
1301
+ for (const traceItem of session.traces) {
1302
+ const trace = traceItem.trace;
1303
+ if (!trace) continue;
1304
+
1305
+ for (const query of trace.sqlQueries || []) {
1306
+ const callerLabel = formatSqlCallerReference(query);
1307
+ const item: TSqlQueryItem = {
1308
+ callerLabel,
1309
+ durationMs: query.durationMs,
1310
+ finishedAt: query.finishedAt,
1311
+ id: query.id,
1312
+ kind: query.kind,
1313
+ model: query.model,
1314
+ operation: query.operation,
1315
+ paramsJson: query.paramsJson,
1316
+ paramsText: query.paramsText,
1317
+ query: query.query,
1318
+ startedAt: query.startedAt,
1319
+ tags: [
1320
+ query.kind,
1321
+ `op:${query.operation}`,
1322
+ ...(query.model ? [`model:${query.model}`] : []),
1323
+ ...(query.target ? [`target:${query.target}`] : []),
1324
+ ...(query.callerOrigin !== 'request' ? [query.callerOrigin] : []),
1325
+ ...(query.callerFetcherId ? [`fetcher:${query.callerFetcherId}`] : []),
1326
+ ...(query.callerLabel &&
1327
+ query.callerLabel !== query.callerFetcherId &&
1328
+ query.callerLabel !== callerLabel
1329
+ ? [`label:${query.callerLabel}`]
1330
+ : []),
1331
+ ],
1332
+ target: query.target,
1333
+ };
1334
+
1335
+ queryItems.push(item);
1336
+
1337
+ const groupId = `${traceItem.id}:${query.callerCallId || 'request'}`;
1338
+ const group = groups.get(groupId);
1339
+ if (group) group.items.push(item);
1340
+ else groups.set(groupId, { id: groupId, items: [item], label: callerLabel });
1341
+ }
1342
+ }
1343
+
1344
+ return {
1345
+ groups: [...groups.values()]
1346
+ .map((group) => ({ ...group, items: [...group.items].sort(sortItems) }))
1347
+ .sort(
1348
+ (left, right) =>
1349
+ sortItems(left.items[0] || { id: left.id, startedAt: '' }, right.items[0] || { id: right.id, startedAt: '' }) ||
1350
+ left.label.localeCompare(right.label),
1351
+ ),
1352
+ queryItems: [...queryItems].sort(sortItems),
1353
+ };
1354
+ };
1355
+
1130
1356
  const ApiRequestListEntry = ({
1131
1357
  isSelected,
1132
1358
  item,
@@ -1245,6 +1471,106 @@ const ApiRequestSidebar = ({ item }: { item?: TApiRequestItem }) => {
1245
1471
  );
1246
1472
  };
1247
1473
 
1474
+ const SqlQueryListEntry = ({
1475
+ isSelected,
1476
+ item,
1477
+ onSelect,
1478
+ }: {
1479
+ isSelected: boolean;
1480
+ item: TSqlQueryItem;
1481
+ onSelect: () => void;
1482
+ }) => (
1483
+ <button
1484
+ aria-pressed={isSelected}
1485
+ className={`proteum-profiler__row proteum-profiler__row--interactive ${isSelected ? 'proteum-profiler__row--selected' : ''}`}
1486
+ onClick={onSelect}
1487
+ type="button"
1488
+ >
1489
+ <div className="proteum-profiler__rowHeader">
1490
+ <strong className="proteum-profiler__rowTitle">{formatSqlQueryTitle(item.query)}</strong>
1491
+ <span className="proteum-profiler__rowMeta">
1492
+ <span className="proteum-profiler__mono proteum-profiler__muted">{formatDuration(item.durationMs)}</span>
1493
+ </span>
1494
+ </div>
1495
+ <div className="proteum-profiler__tags">
1496
+ {item.tags.map((tag) => (
1497
+ <span className="proteum-profiler__tag" key={`${item.id}:tag:${tag}`}>
1498
+ {tag}
1499
+ </span>
1500
+ ))}
1501
+ </div>
1502
+ </button>
1503
+ );
1504
+
1505
+ const SqlQuerySidebar = ({ item }: { item?: TSqlQueryItem }) => {
1506
+ if (!item) {
1507
+ return (
1508
+ <aside className="proteum-profiler__sidebar">
1509
+ <div className="proteum-profiler__sidebarScroller">
1510
+ <div className="proteum-profiler__sidebarHeader">
1511
+ <div className="proteum-profiler__sidebarEyebrow">SQL details</div>
1512
+ <div className="proteum-profiler__sidebarEmpty">
1513
+ Select a query to inspect its caller, SQL text, bound params, and timing.
1514
+ </div>
1515
+ </div>
1516
+ </div>
1517
+ </aside>
1518
+ );
1519
+ }
1520
+
1521
+ return (
1522
+ <aside className="proteum-profiler__sidebar">
1523
+ <div className="proteum-profiler__sidebarScroller">
1524
+ <div className="proteum-profiler__sidebarHeader">
1525
+ <div className="proteum-profiler__sidebarEyebrow">SQL query</div>
1526
+ <div className="proteum-profiler__sidebarTitle">
1527
+ <strong>{formatSqlQueryTitle(item.query)}</strong>
1528
+ </div>
1529
+ <div className="proteum-profiler__mono proteum-profiler__muted">{item.callerLabel}</div>
1530
+ </div>
1531
+
1532
+ <div className="proteum-profiler__metrics">
1533
+ <SummaryRow label="Caller" value={item.callerLabel} />
1534
+ <SummaryRow label="Duration" value={formatDuration(item.durationMs)} />
1535
+ <SummaryRow label="Started" value={formatTimestamp(item.startedAt)} />
1536
+ <SummaryRow label="Finished" value={formatTimestamp(item.finishedAt)} />
1537
+ <SummaryRow label="Kind" value={item.kind} />
1538
+ <SummaryRow label="Operation" value={item.operation} />
1539
+ <SummaryRow label="Model" value={item.model || 'n/a'} />
1540
+ <SummaryRow label="Target" value={item.target || 'n/a'} />
1541
+ </div>
1542
+
1543
+ {item.tags.length > 0 ? (
1544
+ <div className="proteum-profiler__sidebarSection">
1545
+ <div className="proteum-profiler__sidebarSectionTitle">Tags</div>
1546
+ <div className="proteum-profiler__tags">
1547
+ {item.tags.map((tag) => (
1548
+ <span className="proteum-profiler__tag" key={`${item.id}:detail:${tag}`}>
1549
+ {tag}
1550
+ </span>
1551
+ ))}
1552
+ </div>
1553
+ </div>
1554
+ ) : null}
1555
+
1556
+ <div className="proteum-profiler__sidebarSection">
1557
+ <div className="proteum-profiler__sidebarSectionTitle">SQL</div>
1558
+ <PlainCodeBlock value={item.query} />
1559
+ </div>
1560
+
1561
+ <div className="proteum-profiler__sidebarSection">
1562
+ <div className="proteum-profiler__sidebarSectionTitle">Parameters</div>
1563
+ {item.paramsJson !== undefined ? (
1564
+ <JsonCodeBlock value={formatStructuredValue(item.paramsJson)} />
1565
+ ) : (
1566
+ <PlainCodeBlock value={formatSqlParams(item)} />
1567
+ )}
1568
+ </div>
1569
+ </div>
1570
+ </aside>
1571
+ );
1572
+ };
1573
+
1248
1574
  const ApiPanel = ({ session }: { session: TProfilerNavigationSession }) => {
1249
1575
  const syncItems: TApiRequestItem[] = session.traces
1250
1576
  .flatMap((trace) => trace.trace?.calls.filter((call) => call.origin !== 'client-async') || [])
@@ -1256,6 +1582,7 @@ const ApiPanel = ({ session }: { session: TProfilerNavigationSession }) => {
1256
1582
  finishedAt: call.finishedAt,
1257
1583
  label: call.label,
1258
1584
  method: call.method,
1585
+ originLabel: call.origin,
1259
1586
  path: call.path,
1260
1587
  requestData: call.requestData,
1261
1588
  requestDataJson: call.requestDataJson,
@@ -1265,6 +1592,8 @@ const ApiPanel = ({ session }: { session: TProfilerNavigationSession }) => {
1265
1592
  statusCode: call.statusCode,
1266
1593
  tags: [
1267
1594
  call.origin,
1595
+ ...(call.connectedProjectNamespace ? [`connected:${call.connectedProjectNamespace}`] : []),
1596
+ ...(call.connectedControllerAccessor ? [`target:${call.connectedControllerAccessor}`] : []),
1268
1597
  ...(call.fetcherId ? [`fetcher:${call.fetcherId}`] : []),
1269
1598
  ...call.requestDataKeys.map((key) => `arg:${key}`),
1270
1599
  ...call.resultKeys.map((key) => `res:${key}`),
@@ -1280,6 +1609,7 @@ const ApiPanel = ({ session }: { session: TProfilerNavigationSession }) => {
1280
1609
  finishedAt: trace.finishedAt,
1281
1610
  label: trace.label,
1282
1611
  method: trace.method,
1612
+ originLabel: 'client-async',
1283
1613
  path: trace.path,
1284
1614
  requestData: getTraceRequestData(trace.trace),
1285
1615
  requestDataJson: trace.trace?.requestDataJson,
@@ -1292,6 +1622,33 @@ const ApiPanel = ({ session }: { session: TProfilerNavigationSession }) => {
1292
1622
  }));
1293
1623
  const requestItems = [...syncItems, ...asyncItems];
1294
1624
  const [selectedRequestId, setSelectedRequestId] = React.useState<string | undefined>(() => requestItems[0]?.id);
1625
+ const endpointDurationChart = buildHorizontalBarChartOptions({
1626
+ color: profilerChartTheme.blue,
1627
+ entries: buildDurationEntries(
1628
+ requestItems,
1629
+ (item) => formatApiReference(item.method, item.path, item.requestData, item.label),
1630
+ (item) => item.durationMs,
1631
+ ),
1632
+ title: 'Endpoint workload',
1633
+ valueUnit: 'Milliseconds',
1634
+ });
1635
+ const originWorkloadChart = buildColumnChartOptions({
1636
+ colors: [profilerChartTheme.indigo],
1637
+ entries: buildDurationEntries(
1638
+ requestItems,
1639
+ (item) => getApiOriginLabel(item),
1640
+ (item) => item.durationMs,
1641
+ 6,
1642
+ ),
1643
+ title: 'Origin time share',
1644
+ valueUnit: 'Milliseconds',
1645
+ });
1646
+ const statusCountChart = buildColumnChartOptions({
1647
+ colors: [profilerChartTheme.amber],
1648
+ entries: buildCountEntries(requestItems.map((item) => getRequestStatusGroup(item.statusCode, item.statusLabel))),
1649
+ title: 'Status groups',
1650
+ valueUnit: 'Requests',
1651
+ });
1295
1652
 
1296
1653
  React.useEffect(() => {
1297
1654
  if (requestItems.some((item) => item.id === selectedRequestId)) return;
@@ -1304,6 +1661,27 @@ const ApiPanel = ({ session }: { session: TProfilerNavigationSession }) => {
1304
1661
  return (
1305
1662
  <div className="proteum-profiler__requestWorkspace">
1306
1663
  <div className="proteum-profiler__splitColumn">
1664
+ <div className="proteum-profiler__chartGrid">
1665
+ <ChartSection
1666
+ emptyLabel="No API request timings were captured for this session."
1667
+ options={endpointDurationChart}
1668
+ subtitle="Rank the most expensive endpoints across synchronous and async requests."
1669
+ title="Hot Endpoints"
1670
+ />
1671
+ <ChartSection
1672
+ emptyLabel="No API origin timings were captured for this session."
1673
+ options={originWorkloadChart}
1674
+ subtitle="Compare time spent in SSR fetchers, batch fetchers, and client async calls."
1675
+ title="Origin Mix"
1676
+ />
1677
+ <ChartSection
1678
+ emptyLabel="No API status information was captured for this session."
1679
+ options={statusCountChart}
1680
+ subtitle="Spot failures or pending requests without scanning every row."
1681
+ title="Status Spread"
1682
+ />
1683
+ </div>
1684
+
1307
1685
  <WaterfallChart
1308
1686
  emptyLabel="No API requests were captured for this session."
1309
1687
  itemLabel="request"
@@ -1367,6 +1745,113 @@ const ApiPanel = ({ session }: { session: TProfilerNavigationSession }) => {
1367
1745
  );
1368
1746
  };
1369
1747
 
1748
+ const SqlPanel = ({ session }: { session: TProfilerNavigationSession }) => {
1749
+ const { groups, queryItems } = buildSqlQueryWorkspace(session);
1750
+ const [selectedQueryId, setSelectedQueryId] = React.useState<string | undefined>(() => queryItems[0]?.id);
1751
+ const callerDurationChart = buildHorizontalBarChartOptions({
1752
+ color: profilerChartTheme.teal,
1753
+ entries: buildDurationEntries(queryItems, (item) => item.callerLabel, (item) => item.durationMs),
1754
+ title: 'Hot SQL callers',
1755
+ valueUnit: 'Milliseconds',
1756
+ });
1757
+ const operationCountChart = buildColumnChartOptions({
1758
+ colors: [profilerChartTheme.amber],
1759
+ entries: buildCountEntries(queryItems.map((item) => `${item.operation}${item.model ? `:${item.model}` : ''}`)),
1760
+ title: 'Operation volume',
1761
+ valueUnit: 'Queries',
1762
+ });
1763
+ const selectedCallers = buildDurationEntries(queryItems, (item) => item.callerLabel, (item) => item.durationMs, 6).map((entry) => entry.label);
1764
+ const selectedOperations = buildCountEntries(
1765
+ queryItems.map((item) => `${item.operation}${item.model ? `:${item.model}` : ''}`),
1766
+ 6,
1767
+ ).map((entry) => entry.label);
1768
+ const callerOperationHeatmap = buildHeatmapChartOptions({
1769
+ rows: selectedCallers.map((callerLabel) => ({
1770
+ data: selectedOperations.map((operationLabel) => ({
1771
+ x: truncate(operationLabel, 22),
1772
+ y: queryItems.filter(
1773
+ (item) => item.callerLabel === callerLabel && `${item.operation}${item.model ? `:${item.model}` : ''}` === operationLabel,
1774
+ ).length,
1775
+ })),
1776
+ name: truncate(callerLabel, 28),
1777
+ })),
1778
+ title: 'Caller x operation density',
1779
+ valueUnit: 'Operation',
1780
+ });
1781
+
1782
+ React.useEffect(() => {
1783
+ if (queryItems.some((item) => item.id === selectedQueryId)) return;
1784
+ setSelectedQueryId(queryItems[0]?.id);
1785
+ }, [queryItems, selectedQueryId]);
1786
+
1787
+ const waterfallItems = buildSqlWaterfallItems(queryItems);
1788
+ const selectedItem = queryItems.find((item) => item.id === selectedQueryId) || queryItems[0];
1789
+
1790
+ return (
1791
+ <div className="proteum-profiler__requestWorkspace">
1792
+ <div className="proteum-profiler__splitColumn">
1793
+ <div className="proteum-profiler__chartGrid">
1794
+ <ChartSection
1795
+ emptyLabel="No SQL timings were captured for this session."
1796
+ options={callerDurationChart}
1797
+ subtitle="Show which callers are driving the most database time."
1798
+ title="Hot Callers"
1799
+ />
1800
+ <ChartSection
1801
+ emptyLabel="No SQL operation counts were captured for this session."
1802
+ options={operationCountChart}
1803
+ subtitle="Highlight whether reads, writes, or raw queries dominate the request."
1804
+ title="Operation Mix"
1805
+ />
1806
+ <ChartSection
1807
+ emptyLabel="No caller or operation overlap was captured for this session."
1808
+ options={callerOperationHeatmap}
1809
+ subtitle="Surface dense caller and operation combinations at a glance."
1810
+ title="Caller Heatmap"
1811
+ />
1812
+ </div>
1813
+
1814
+ <WaterfallChart
1815
+ emptyLabel="No SQL queries were captured for this session."
1816
+ itemLabel="query"
1817
+ items={waterfallItems}
1818
+ onSelect={setSelectedQueryId}
1819
+ />
1820
+
1821
+ <div className="proteum-profiler__requestGroups">
1822
+ {groups.length === 0 ? (
1823
+ <div className="proteum-profiler__empty">No SQL queries were captured for this session.</div>
1824
+ ) : (
1825
+ groups.map((group) => (
1826
+ <div className="proteum-profiler__requestGroup" key={group.id}>
1827
+ <div className="proteum-profiler__requestGroupHeader">
1828
+ <div className="proteum-profiler__sectionTitle">{group.label}</div>
1829
+ <div className="proteum-profiler__requestGroupCount">
1830
+ {group.items.length} item{group.items.length === 1 ? '' : 's'}
1831
+ </div>
1832
+ </div>
1833
+
1834
+ <div className="proteum-profiler__list">
1835
+ {group.items.map((item) => (
1836
+ <SqlQueryListEntry
1837
+ isSelected={item.id === selectedItem?.id}
1838
+ item={item}
1839
+ key={item.id}
1840
+ onSelect={() => setSelectedQueryId(item.id)}
1841
+ />
1842
+ ))}
1843
+ </div>
1844
+ </div>
1845
+ ))
1846
+ )}
1847
+ </div>
1848
+ </div>
1849
+
1850
+ <SqlQuerySidebar item={selectedItem} />
1851
+ </div>
1852
+ );
1853
+ };
1854
+
1370
1855
  const getTraceEventKey = (traceId: string, event: TRequestTrace['events'][number]) => `${traceId}:${event.index}`;
1371
1856
 
1372
1857
  const TraceEventSidebar = ({
@@ -1476,6 +1961,12 @@ const TraceRows = ({
1476
1961
  </div>
1477
1962
  <div className="proteum-profiler__tags">
1478
1963
  <span className="proteum-profiler__tag">{call.origin}</span>
1964
+ {call.connectedProjectNamespace ? (
1965
+ <span className="proteum-profiler__tag">connected:{call.connectedProjectNamespace}</span>
1966
+ ) : null}
1967
+ {call.connectedControllerAccessor ? (
1968
+ <span className="proteum-profiler__tag">target:{call.connectedControllerAccessor}</span>
1969
+ ) : null}
1479
1970
  {call.fetcherId ? <span className="proteum-profiler__tag">fetcher:{call.fetcherId}</span> : null}
1480
1971
  {call.requestDataKeys.map((key) => (
1481
1972
  <span className="proteum-profiler__tag" key={`${call.id}:req:${key}`}>
@@ -1750,6 +2241,79 @@ const buildApiWaterfallItems = (requestItems: TApiRequestItem[]): TWaterfallChar
1750
2241
  });
1751
2242
  };
1752
2243
 
2244
+ const buildSqlWaterfallItems = (queryItems: TSqlQueryItem[]): TWaterfallChartItem[] => {
2245
+ const rawItems = queryItems.map((item) => {
2246
+ const startMs = readDateMs(item.startedAt) ?? 0;
2247
+ const endMs = buildWaterfallEndMs({
2248
+ durationMs: item.durationMs,
2249
+ finishedAt: item.finishedAt,
2250
+ startMs,
2251
+ });
2252
+
2253
+ return {
2254
+ endMs,
2255
+ item,
2256
+ startMs,
2257
+ title: formatSqlQueryTitle(item.query),
2258
+ };
2259
+ });
2260
+
2261
+ const sortedItems = [...rawItems].sort((left, right) => left.startMs - right.startMs || left.item.id.localeCompare(right.item.id));
2262
+ const chartStartMs = sortedItems.length > 0 ? Math.min(...sortedItems.map((item) => item.startMs)) : 0;
2263
+
2264
+ return sortedItems.map(({ endMs, item, startMs, title }) => {
2265
+ const startOffsetMs = startMs - chartStartMs;
2266
+ const endOffsetMs = endMs - chartStartMs;
2267
+
2268
+ return {
2269
+ barLabel: truncate(title, 84),
2270
+ color: getTimelineDurationColor(item.durationMs),
2271
+ detailLines: [
2272
+ `Caller: ${item.callerLabel}`,
2273
+ `Operation: ${item.operation}${item.model ? ` (${item.model})` : ''}`,
2274
+ `Duration: ${formatDuration(item.durationMs)}`,
2275
+ `Start: +${Math.round(startOffsetMs)} ms`,
2276
+ `End: +${Math.round(endOffsetMs)} ms`,
2277
+ ],
2278
+ endOffsetMs,
2279
+ id: item.id,
2280
+ startOffsetMs,
2281
+ subtitle: item.callerLabel,
2282
+ title,
2283
+ };
2284
+ });
2285
+ };
2286
+
2287
+ const getPerfStageColor = (stageId: TRequestPerformance['stages'][number]['id']) => {
2288
+ if (stageId === 'auth') return '#0ea5e9';
2289
+ if (stageId === 'routing') return '#3b82f6';
2290
+ if (stageId === 'controller') return '#6366f1';
2291
+ if (stageId === 'page-data') return '#14b8a6';
2292
+ if (stageId === 'render') return '#f59e0b';
2293
+ return '#22c55e';
2294
+ };
2295
+
2296
+ const buildPerfWaterfallItems = (request: TRequestPerformance): TWaterfallChartItem[] =>
2297
+ request.stages.map((stage) => ({
2298
+ barLabel: `${stage.label} ${formatDuration(stage.durationMs)}`,
2299
+ color: getPerfStageColor(stage.id),
2300
+ detailLines: [
2301
+ `Start: +${Math.round(stage.startOffsetMs)} ms`,
2302
+ `End: +${Math.round(stage.endOffsetMs)} ms`,
2303
+ `Duration: ${formatDuration(stage.durationMs)}`,
2304
+ ],
2305
+ endOffsetMs: stage.endOffsetMs,
2306
+ id: stage.id,
2307
+ startOffsetMs: stage.startOffsetMs,
2308
+ title: stage.label,
2309
+ }));
2310
+
2311
+ const pluralizeCountLabel = (label: string, count: number) => {
2312
+ if (count === 1) return label;
2313
+ if (/[bcdfghjklmnpqrstvwxyz]y$/i.test(label)) return `${label.slice(0, -1)}ies`;
2314
+ return `${label}s`;
2315
+ };
2316
+
1753
2317
  const WaterfallChart = ({
1754
2318
  emptyLabel,
1755
2319
  itemLabel,
@@ -1761,152 +2325,138 @@ const WaterfallChart = ({
1761
2325
  items: TWaterfallChartItem[];
1762
2326
  onSelect?: (itemId: string) => void;
1763
2327
  }) => {
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
2328
  const totalDurationMs = Math.max(items.length > 0 ? Math.max(...items.map((item) => item.endOffsetMs)) : 1, 1);
1780
2329
  const chartHeight = Math.max(260, items.length * waterfallRowHeight + 24);
1781
- const ChartComponent = ApexChartComponent as any;
1782
-
1783
- const series = [
1784
- {
1785
- data: items.map((item) => ({
1786
- fillColor: item.color,
1787
- x: item.barLabel,
1788
- y: [item.startOffsetMs, item.endOffsetMs],
1789
- })),
1790
- name: itemLabel,
1791
- },
1792
- ];
1793
-
1794
- const options = {
1795
- chart: {
1796
- animations: { enabled: false },
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);
2330
+ const options: ApexOptions | undefined =
2331
+ items.length === 0
2332
+ ? undefined
2333
+ : ({
2334
+ chart: {
2335
+ animations: { enabled: false },
2336
+ background: 'transparent',
2337
+ ...(onSelect
2338
+ ? {
2339
+ events: {
2340
+ dataPointSelection: (
2341
+ _event: unknown,
2342
+ _chartContext: unknown,
2343
+ config: { dataPointIndex: number },
2344
+ ) => {
2345
+ const item = items[config.dataPointIndex];
2346
+ if (item) onSelect(item.id);
2347
+ },
2348
+ },
2349
+ }
2350
+ : {}),
2351
+ foreColor: profilerChartTheme.muted,
2352
+ fontFamily: 'ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", monospace',
2353
+ height: chartHeight,
2354
+ parentHeightOffset: 0,
2355
+ toolbar: { show: false },
2356
+ type: 'rangeBar',
2357
+ zoom: { enabled: false },
2358
+ },
2359
+ dataLabels: {
2360
+ enabled: false,
2361
+ },
2362
+ fill: {
2363
+ opacity: 1,
2364
+ },
2365
+ grid: {
2366
+ borderColor: profilerChartTheme.line,
2367
+ padding: { bottom: 0, left: 0, right: 0, top: 4 },
2368
+ xaxis: { lines: { show: true } },
2369
+ yaxis: { lines: { show: false } },
2370
+ },
2371
+ legend: {
2372
+ show: false,
2373
+ },
2374
+ noData: {
2375
+ style: {
2376
+ color: profilerChartTheme.muted,
2377
+ fontFamily: 'ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", monospace',
2378
+ fontSize: '11px',
1807
2379
  },
1808
- }
1809
- : undefined,
1810
- foreColor: '#627186',
1811
- fontFamily: 'ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", monospace',
1812
- toolbar: { show: false },
1813
- type: 'rangeBar',
1814
- zoom: { enabled: false },
1815
- },
1816
- dataLabels: {
1817
- enabled: false,
1818
- },
1819
- fill: {
1820
- opacity: 1,
1821
- },
1822
- grid: {
1823
- borderColor: 'rgba(19, 32, 51, 0.08)',
1824
- padding: { bottom: 0, left: 0, right: 0, top: 4 },
1825
- xaxis: { lines: { show: true } },
1826
- yaxis: { lines: { show: false } },
1827
- },
1828
- legend: {
1829
- show: false,
1830
- },
1831
- noData: {
1832
- style: {
1833
- color: '#627186',
1834
- fontFamily: 'ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", monospace',
1835
- fontSize: '11px',
1836
- },
1837
- text: emptyLabel,
1838
- },
1839
- plotOptions: {
1840
- bar: {
1841
- barHeight: waterfallBarHeight,
1842
- borderRadius: 2,
1843
- horizontal: true,
1844
- rangeBarGroupRows: false,
1845
- },
1846
- },
1847
- stroke: {
1848
- colors: ['#ffffff'],
1849
- width: 1,
1850
- },
1851
- tooltip: {
1852
- custom: ({ dataPointIndex }: { dataPointIndex: number }) => {
1853
- const item = items[dataPointIndex];
1854
- if (!item) return '';
1855
-
1856
- return `
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;">
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('')}
1866
- </div>
1867
- `;
1868
- },
1869
- },
1870
- xaxis: {
1871
- axisBorder: { show: false },
1872
- axisTicks: { show: false },
1873
- labels: {
1874
- formatter: (value: string | number) => `${Math.round(Number(value))} ms`,
1875
- style: {
1876
- colors: '#627186',
1877
- fontFamily: 'ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", monospace',
1878
- fontSize: '10px',
1879
- },
1880
- },
1881
- max: totalDurationMs,
1882
- min: 0,
1883
- tickAmount: Math.min(6, Math.max(2, items.length > 0 ? 6 : 2)),
1884
- type: 'numeric',
1885
- },
1886
- yaxis: {
1887
- show: false,
1888
- labels: {
1889
- show: false,
1890
- },
1891
- },
1892
- };
2380
+ text: emptyLabel,
2381
+ },
2382
+ plotOptions: {
2383
+ bar: {
2384
+ barHeight: waterfallBarHeight,
2385
+ borderRadius: 2,
2386
+ horizontal: true,
2387
+ rangeBarGroupRows: false,
2388
+ },
2389
+ },
2390
+ series: [
2391
+ {
2392
+ data: items.map((item) => ({
2393
+ fillColor: item.color,
2394
+ x: item.barLabel,
2395
+ y: [item.startOffsetMs, item.endOffsetMs],
2396
+ })),
2397
+ name: itemLabel,
2398
+ },
2399
+ ],
2400
+ stroke: {
2401
+ colors: ['#ffffff'],
2402
+ width: 1,
2403
+ },
2404
+ tooltip: {
2405
+ custom: ({ dataPointIndex }: { dataPointIndex: number }) => {
2406
+ const item = items[dataPointIndex];
2407
+ if (!item) return '';
2408
+
2409
+ return `
2410
+ <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;">
2411
+ <div style="font-weight:700;">${escapeHtml(item.title)}</div>
2412
+ ${item.subtitle ? `<div style="color:#627186;">${escapeHtml(item.subtitle)}</div>` : ''}
2413
+ ${item.detailLines
2414
+ .map(
2415
+ (line, index) =>
2416
+ `<div style="${index === 0 ? 'margin-top:6px;' : ''} color:#627186;">${escapeHtml(line)}</div>`,
2417
+ )
2418
+ .join('')}
2419
+ </div>
2420
+ `;
2421
+ },
2422
+ },
2423
+ xaxis: {
2424
+ axisBorder: { show: false },
2425
+ axisTicks: { show: false },
2426
+ labels: {
2427
+ formatter: (value: string | number) => `${Math.round(Number(value))} ms`,
2428
+ style: {
2429
+ colors: profilerChartTheme.muted,
2430
+ fontFamily: 'ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", monospace',
2431
+ fontSize: '10px',
2432
+ },
2433
+ },
2434
+ max: totalDurationMs,
2435
+ min: 0,
2436
+ tickAmount: Math.min(6, Math.max(2, items.length > 0 ? 6 : 2)),
2437
+ type: 'numeric',
2438
+ },
2439
+ yaxis: {
2440
+ show: false,
2441
+ labels: {
2442
+ show: false,
2443
+ },
2444
+ },
2445
+ }) as ApexOptions;
1893
2446
 
1894
2447
  return (
1895
2448
  <div className="proteum-profiler__section">
1896
2449
  <div className="proteum-profiler__timelineChart">
1897
2450
  <div className="proteum-profiler__timelineChartMeta">
1898
2451
  <span className="proteum-profiler__mono proteum-profiler__muted">
1899
- {items.length} {itemLabel}
1900
- {items.length === 1 ? '' : 's'}
2452
+ {items.length} {pluralizeCountLabel(itemLabel, items.length)}
1901
2453
  </span>
1902
2454
  <span className="proteum-profiler__mono proteum-profiler__muted">{formatDuration(totalDurationMs)}</span>
1903
2455
  </div>
1904
2456
 
1905
2457
  <div className="proteum-profiler__timelineChartCanvas" style={{ height: `${chartHeight}px` }}>
1906
- {ChartComponent && items.length > 0 ? (
1907
- <ChartComponent height={chartHeight} options={options} series={series} type="rangeBar" width="100%" />
1908
- ) : items.length > 0 ? (
1909
- <div className="proteum-profiler__empty">Loading waterfall chart...</div>
2458
+ {options ? (
2459
+ <ApexChart emptyLabel={emptyLabel} options={options} />
1910
2460
  ) : (
1911
2461
  <div className="proteum-profiler__empty">{emptyLabel}</div>
1912
2462
  )}
@@ -1916,6 +2466,693 @@ const WaterfallChart = ({
1916
2466
  );
1917
2467
  };
1918
2468
 
2469
+ const createProfilerBarChartOptions = ({
2470
+ categories,
2471
+ colors,
2472
+ height,
2473
+ series,
2474
+ stacked = false,
2475
+ title,
2476
+ valueUnit,
2477
+ }: {
2478
+ categories: string[];
2479
+ colors: string[];
2480
+ height: number;
2481
+ series: Array<{ data: number[]; name: string }>;
2482
+ stacked?: boolean;
2483
+ title: string;
2484
+ valueUnit: string;
2485
+ }): ApexOptions => ({
2486
+ chart: {
2487
+ animations: { enabled: false },
2488
+ background: 'transparent',
2489
+ fontFamily: 'ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", monospace',
2490
+ foreColor: profilerChartTheme.muted,
2491
+ height,
2492
+ parentHeightOffset: 0,
2493
+ stacked,
2494
+ toolbar: { show: false },
2495
+ type: 'bar',
2496
+ zoom: { enabled: false },
2497
+ },
2498
+ colors,
2499
+ dataLabels: { enabled: false },
2500
+ grid: {
2501
+ borderColor: profilerChartTheme.line,
2502
+ padding: { bottom: 4, left: 8, right: 8, top: 0 },
2503
+ strokeDashArray: 2,
2504
+ },
2505
+ legend: {
2506
+ fontSize: '11px',
2507
+ horizontalAlign: 'left',
2508
+ position: 'top',
2509
+ },
2510
+ noData: { text: 'No chart data.' },
2511
+ plotOptions: {
2512
+ bar: {
2513
+ barHeight: '68%',
2514
+ horizontal: true,
2515
+ },
2516
+ },
2517
+ series,
2518
+ stroke: { width: 1 },
2519
+ title: {
2520
+ style: {
2521
+ color: profilerChartTheme.text,
2522
+ fontSize: '12px',
2523
+ fontWeight: 700,
2524
+ },
2525
+ text: title,
2526
+ },
2527
+ tooltip: { intersect: false, shared: stacked },
2528
+ xaxis: {
2529
+ axisBorder: { show: false },
2530
+ axisTicks: { show: false },
2531
+ categories,
2532
+ labels: { style: { colors: profilerChartTheme.muted, fontSize: '11px' } },
2533
+ title: {
2534
+ style: {
2535
+ color: profilerChartTheme.muted,
2536
+ fontSize: '10px',
2537
+ fontWeight: 600,
2538
+ },
2539
+ text: valueUnit,
2540
+ },
2541
+ },
2542
+ yaxis: {
2543
+ labels: {
2544
+ style: { colors: profilerChartTheme.text, fontSize: '11px' },
2545
+ },
2546
+ },
2547
+ });
2548
+
2549
+ const createProfilerColumnChartOptions = ({
2550
+ categories,
2551
+ colors,
2552
+ height,
2553
+ series,
2554
+ stacked = false,
2555
+ title,
2556
+ valueUnit,
2557
+ }: {
2558
+ categories: string[];
2559
+ colors: string[];
2560
+ height: number;
2561
+ series: Array<{ data: number[]; name: string }>;
2562
+ stacked?: boolean;
2563
+ title: string;
2564
+ valueUnit: string;
2565
+ }): ApexOptions => ({
2566
+ chart: {
2567
+ animations: { enabled: false },
2568
+ background: 'transparent',
2569
+ fontFamily: 'ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", monospace',
2570
+ foreColor: profilerChartTheme.muted,
2571
+ height,
2572
+ parentHeightOffset: 0,
2573
+ stacked,
2574
+ toolbar: { show: false },
2575
+ type: 'bar',
2576
+ zoom: { enabled: false },
2577
+ },
2578
+ colors,
2579
+ dataLabels: { enabled: false },
2580
+ grid: {
2581
+ borderColor: profilerChartTheme.line,
2582
+ padding: { bottom: 4, left: 8, right: 8, top: 0 },
2583
+ strokeDashArray: 2,
2584
+ },
2585
+ legend: {
2586
+ fontSize: '11px',
2587
+ horizontalAlign: 'left',
2588
+ position: 'top',
2589
+ },
2590
+ noData: { text: 'No chart data.' },
2591
+ plotOptions: {
2592
+ bar: {
2593
+ borderRadius: 2,
2594
+ columnWidth: '58%',
2595
+ horizontal: false,
2596
+ },
2597
+ },
2598
+ series,
2599
+ stroke: { width: 1 },
2600
+ title: {
2601
+ style: {
2602
+ color: profilerChartTheme.text,
2603
+ fontSize: '12px',
2604
+ fontWeight: 700,
2605
+ },
2606
+ text: title,
2607
+ },
2608
+ tooltip: { intersect: false, shared: stacked },
2609
+ xaxis: {
2610
+ axisBorder: { show: false },
2611
+ axisTicks: { show: false },
2612
+ categories,
2613
+ labels: {
2614
+ rotate: -28,
2615
+ style: { colors: profilerChartTheme.muted, fontSize: '11px' },
2616
+ trim: true,
2617
+ },
2618
+ },
2619
+ yaxis: {
2620
+ labels: { style: { colors: profilerChartTheme.muted, fontSize: '11px' } },
2621
+ title: {
2622
+ style: {
2623
+ color: profilerChartTheme.muted,
2624
+ fontSize: '10px',
2625
+ fontWeight: 600,
2626
+ },
2627
+ text: valueUnit,
2628
+ },
2629
+ },
2630
+ });
2631
+
2632
+ const createProfilerLineChartOptions = ({
2633
+ categories,
2634
+ colors,
2635
+ height,
2636
+ series,
2637
+ title,
2638
+ valueUnit,
2639
+ }: {
2640
+ categories: string[];
2641
+ colors: string[];
2642
+ height: number;
2643
+ series: Array<{ data: number[]; name: string }>;
2644
+ title: string;
2645
+ valueUnit: string;
2646
+ }): ApexOptions => ({
2647
+ chart: {
2648
+ animations: { enabled: false },
2649
+ background: 'transparent',
2650
+ fontFamily: 'ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", monospace',
2651
+ foreColor: profilerChartTheme.muted,
2652
+ height,
2653
+ parentHeightOffset: 0,
2654
+ toolbar: { show: false },
2655
+ type: 'line',
2656
+ zoom: { enabled: false },
2657
+ },
2658
+ colors,
2659
+ dataLabels: { enabled: false },
2660
+ grid: {
2661
+ borderColor: profilerChartTheme.line,
2662
+ padding: { bottom: 4, left: 8, right: 8, top: 0 },
2663
+ strokeDashArray: 2,
2664
+ },
2665
+ legend: {
2666
+ fontSize: '11px',
2667
+ horizontalAlign: 'left',
2668
+ position: 'top',
2669
+ },
2670
+ markers: { size: 4, strokeWidth: 0 },
2671
+ noData: { text: 'No chart data.' },
2672
+ series,
2673
+ stroke: { curve: 'smooth', width: 2 },
2674
+ title: {
2675
+ style: {
2676
+ color: profilerChartTheme.text,
2677
+ fontSize: '12px',
2678
+ fontWeight: 700,
2679
+ },
2680
+ text: title,
2681
+ },
2682
+ tooltip: { intersect: false, shared: true },
2683
+ xaxis: {
2684
+ axisBorder: { show: false },
2685
+ axisTicks: { show: false },
2686
+ categories,
2687
+ labels: { rotate: -24, style: { colors: profilerChartTheme.muted, fontSize: '11px' }, trim: true },
2688
+ },
2689
+ yaxis: {
2690
+ labels: { style: { colors: profilerChartTheme.muted, fontSize: '11px' } },
2691
+ title: {
2692
+ style: {
2693
+ color: profilerChartTheme.muted,
2694
+ fontSize: '10px',
2695
+ fontWeight: 600,
2696
+ },
2697
+ text: valueUnit,
2698
+ },
2699
+ },
2700
+ });
2701
+
2702
+ const createProfilerScatterChartOptions = ({
2703
+ colors,
2704
+ height,
2705
+ series,
2706
+ title,
2707
+ xaxisTitle,
2708
+ yaxisTitle,
2709
+ }: {
2710
+ colors: string[];
2711
+ height: number;
2712
+ series: Array<{ data: Array<{ x: number; y: number }>; name: string }>;
2713
+ title: string;
2714
+ xaxisTitle: string;
2715
+ yaxisTitle: string;
2716
+ }): ApexOptions => ({
2717
+ chart: {
2718
+ animations: { enabled: false },
2719
+ background: 'transparent',
2720
+ fontFamily: 'ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", monospace',
2721
+ foreColor: profilerChartTheme.muted,
2722
+ height,
2723
+ parentHeightOffset: 0,
2724
+ toolbar: { show: false },
2725
+ type: 'scatter',
2726
+ zoom: { enabled: false },
2727
+ },
2728
+ colors,
2729
+ dataLabels: { enabled: false },
2730
+ grid: {
2731
+ borderColor: profilerChartTheme.line,
2732
+ padding: { bottom: 4, left: 8, right: 8, top: 0 },
2733
+ strokeDashArray: 2,
2734
+ },
2735
+ legend: {
2736
+ fontSize: '11px',
2737
+ horizontalAlign: 'left',
2738
+ position: 'top',
2739
+ },
2740
+ markers: { size: 6, strokeWidth: 0 },
2741
+ noData: { text: 'No chart data.' },
2742
+ series,
2743
+ title: {
2744
+ style: {
2745
+ color: profilerChartTheme.text,
2746
+ fontSize: '12px',
2747
+ fontWeight: 700,
2748
+ },
2749
+ text: title,
2750
+ },
2751
+ tooltip: { intersect: false, shared: false },
2752
+ xaxis: {
2753
+ axisBorder: { show: false },
2754
+ axisTicks: { show: false },
2755
+ labels: { style: { colors: profilerChartTheme.muted, fontSize: '11px' } },
2756
+ title: {
2757
+ style: {
2758
+ color: profilerChartTheme.muted,
2759
+ fontSize: '10px',
2760
+ fontWeight: 600,
2761
+ },
2762
+ text: xaxisTitle,
2763
+ },
2764
+ tickAmount: 6,
2765
+ },
2766
+ yaxis: {
2767
+ labels: { style: { colors: profilerChartTheme.muted, fontSize: '11px' } },
2768
+ title: {
2769
+ style: {
2770
+ color: profilerChartTheme.muted,
2771
+ fontSize: '10px',
2772
+ fontWeight: 600,
2773
+ },
2774
+ text: yaxisTitle,
2775
+ },
2776
+ tickAmount: 6,
2777
+ },
2778
+ });
2779
+
2780
+ const createProfilerHeatmapOptions = ({
2781
+ height,
2782
+ series,
2783
+ title,
2784
+ valueUnit,
2785
+ }: {
2786
+ height: number;
2787
+ series: Array<{ data: Array<{ x: string; y: number }>; name: string }>;
2788
+ title: string;
2789
+ valueUnit: string;
2790
+ }): ApexOptions => ({
2791
+ chart: {
2792
+ animations: { enabled: false },
2793
+ background: 'transparent',
2794
+ fontFamily: 'ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", monospace',
2795
+ foreColor: profilerChartTheme.muted,
2796
+ height,
2797
+ parentHeightOffset: 0,
2798
+ toolbar: { show: false },
2799
+ type: 'heatmap',
2800
+ zoom: { enabled: false },
2801
+ },
2802
+ colors: [profilerChartTheme.blue],
2803
+ dataLabels: { enabled: false },
2804
+ grid: {
2805
+ borderColor: profilerChartTheme.line,
2806
+ padding: { bottom: 4, left: 8, right: 8, top: 0 },
2807
+ strokeDashArray: 2,
2808
+ },
2809
+ legend: { show: false },
2810
+ noData: { text: 'No chart data.' },
2811
+ plotOptions: {
2812
+ heatmap: {
2813
+ colorScale: {
2814
+ ranges: [
2815
+ { color: '#eef4ff', from: 0, to: 0 },
2816
+ { color: '#bfdbfe', from: 0.01, to: 5 },
2817
+ { color: '#60a5fa', from: 5.01, to: 25 },
2818
+ { color: '#2563eb', from: 25.01, to: 1000000 },
2819
+ ],
2820
+ },
2821
+ radius: 0,
2822
+ },
2823
+ },
2824
+ series,
2825
+ stroke: { width: 1 },
2826
+ title: {
2827
+ style: {
2828
+ color: profilerChartTheme.text,
2829
+ fontSize: '12px',
2830
+ fontWeight: 700,
2831
+ },
2832
+ text: title,
2833
+ },
2834
+ tooltip: { intersect: false, shared: false },
2835
+ xaxis: {
2836
+ axisBorder: { show: false },
2837
+ axisTicks: { show: false },
2838
+ labels: { rotate: -24, style: { colors: profilerChartTheme.muted, fontSize: '11px' } },
2839
+ title: {
2840
+ style: {
2841
+ color: profilerChartTheme.muted,
2842
+ fontSize: '10px',
2843
+ fontWeight: 600,
2844
+ },
2845
+ text: valueUnit,
2846
+ },
2847
+ },
2848
+ yaxis: {
2849
+ labels: { style: { colors: profilerChartTheme.text, fontSize: '11px' } },
2850
+ },
2851
+ });
2852
+
2853
+ const buildTopEntries = (entries: Array<{ label: string; value: number }>, limit = 8) =>
2854
+ entries
2855
+ .filter((entry) => entry.label && entry.value > 0)
2856
+ .sort((left, right) => right.value - left.value || left.label.localeCompare(right.label))
2857
+ .slice(0, limit);
2858
+
2859
+ const buildCountEntries = (labels: string[], limit = 8) => {
2860
+ const counts = new Map<string, number>();
2861
+ for (const label of labels) counts.set(label, (counts.get(label) || 0) + 1);
2862
+ return buildTopEntries(
2863
+ [...counts.entries()].map(([label, value]) => ({ label, value })),
2864
+ limit,
2865
+ );
2866
+ };
2867
+
2868
+ const buildDurationEntries = <T,>(items: T[], readLabel: (item: T) => string | undefined, readValue: (item: T) => number | undefined, limit = 8) => {
2869
+ const totals = new Map<string, number>();
2870
+ for (const item of items) {
2871
+ const label = readLabel(item);
2872
+ const value = readValue(item);
2873
+ if (!label || value === undefined) continue;
2874
+ totals.set(label, (totals.get(label) || 0) + value);
2875
+ }
2876
+
2877
+ return buildTopEntries(
2878
+ [...totals.entries()].map(([label, value]) => ({ label, value })),
2879
+ limit,
2880
+ );
2881
+ };
2882
+
2883
+ const buildHorizontalBarChartOptions = ({
2884
+ color,
2885
+ entries,
2886
+ title,
2887
+ valueUnit,
2888
+ }: {
2889
+ color: string;
2890
+ entries: Array<{ label: string; value: number }>;
2891
+ title: string;
2892
+ valueUnit: string;
2893
+ }) =>
2894
+ entries.length === 0
2895
+ ? undefined
2896
+ : createProfilerBarChartOptions({
2897
+ categories: entries.map((entry) => truncate(entry.label, 44)),
2898
+ colors: [color],
2899
+ height: buildChartHeight(entries.length),
2900
+ series: [{ data: entries.map((entry) => toRoundedNumber(entry.value)), name: valueUnit }],
2901
+ title,
2902
+ valueUnit,
2903
+ });
2904
+
2905
+ const buildColumnChartOptions = ({
2906
+ colors,
2907
+ entries,
2908
+ title,
2909
+ valueUnit,
2910
+ }: {
2911
+ colors: string[];
2912
+ entries: Array<{ label: string; value: number }>;
2913
+ title: string;
2914
+ valueUnit: string;
2915
+ }) =>
2916
+ entries.length === 0
2917
+ ? undefined
2918
+ : createProfilerColumnChartOptions({
2919
+ categories: entries.map((entry) => truncate(entry.label, 28)),
2920
+ colors,
2921
+ height: 280,
2922
+ series: [{ data: entries.map((entry) => toRoundedNumber(entry.value)), name: valueUnit }],
2923
+ title,
2924
+ valueUnit,
2925
+ });
2926
+
2927
+ const buildLineChartOptions = ({
2928
+ color,
2929
+ entries,
2930
+ title,
2931
+ valueUnit,
2932
+ }: {
2933
+ color: string;
2934
+ entries: Array<{ label: string; value: number }>;
2935
+ title: string;
2936
+ valueUnit: string;
2937
+ }) =>
2938
+ entries.length === 0
2939
+ ? undefined
2940
+ : createProfilerLineChartOptions({
2941
+ categories: entries.map((entry) => truncate(entry.label, 28)),
2942
+ colors: [color],
2943
+ height: 280,
2944
+ series: [{ data: entries.map((entry) => toRoundedNumber(entry.value)), name: valueUnit }],
2945
+ title,
2946
+ valueUnit,
2947
+ });
2948
+
2949
+ const buildScatterChartOptions = ({
2950
+ color,
2951
+ points,
2952
+ seriesName,
2953
+ title,
2954
+ xaxisTitle,
2955
+ yaxisTitle,
2956
+ }: {
2957
+ color: string;
2958
+ points: Array<{ x: number; y: number }>;
2959
+ seriesName: string;
2960
+ title: string;
2961
+ xaxisTitle: string;
2962
+ yaxisTitle: string;
2963
+ }) =>
2964
+ points.length === 0
2965
+ ? undefined
2966
+ : createProfilerScatterChartOptions({
2967
+ colors: [color],
2968
+ height: 300,
2969
+ series: [{ data: points, name: seriesName }],
2970
+ title,
2971
+ xaxisTitle,
2972
+ yaxisTitle,
2973
+ });
2974
+
2975
+ const buildStackedColumnChartOptions = ({
2976
+ categories,
2977
+ colors,
2978
+ series,
2979
+ title,
2980
+ valueUnit,
2981
+ }: {
2982
+ categories: string[];
2983
+ colors: string[];
2984
+ series: Array<{ data: number[]; name: string }>;
2985
+ title: string;
2986
+ valueUnit: string;
2987
+ }) =>
2988
+ categories.length === 0 || series.length === 0 || series.every((entry) => entry.data.every((value) => value === 0))
2989
+ ? undefined
2990
+ : createProfilerColumnChartOptions({
2991
+ categories,
2992
+ colors,
2993
+ height: 300,
2994
+ series,
2995
+ stacked: true,
2996
+ title,
2997
+ valueUnit,
2998
+ });
2999
+
3000
+ const buildHeatmapChartOptions = ({
3001
+ rows,
3002
+ title,
3003
+ valueUnit,
3004
+ }: {
3005
+ rows: Array<{ data: Array<{ x: string; y: number }>; name: string }>;
3006
+ title: string;
3007
+ valueUnit: string;
3008
+ }) =>
3009
+ rows.length === 0 || rows.every((row) => row.data.every((entry) => entry.y === 0))
3010
+ ? undefined
3011
+ : createProfilerHeatmapOptions({
3012
+ height: buildChartHeight(rows.length, { max: 360, min: 240, rowHeight: 36 }),
3013
+ series: rows,
3014
+ title,
3015
+ valueUnit,
3016
+ });
3017
+
3018
+ const getSessionChartLabel = (candidate: TProfilerNavigationSession, summary: TSessionSummary) =>
3019
+ truncate(`${formatTimeLabel(candidate.startedAt)} ${summary.routeLabel}`, 28);
3020
+
3021
+ const getApiOriginLabel = (item: TApiRequestItem) => item.originLabel || item.groupLabel;
3022
+
3023
+ const getRequestStatusGroup = (statusCode?: number, statusLabel?: string) => {
3024
+ if (statusCode === undefined) return statusLabel || 'pending';
3025
+ if (statusCode >= 500) return '5xx';
3026
+ if (statusCode >= 400) return '4xx';
3027
+ if (statusCode >= 300) return '3xx';
3028
+ if (statusCode >= 200) return '2xx';
3029
+ return String(statusCode);
3030
+ };
3031
+
3032
+ const buildPerfTopLatencyChartOptions = (rows: NonNullable<TProfilerState['perf']['top']>['rows']): ApexOptions | undefined => {
3033
+ if (rows.length === 0) return undefined;
3034
+
3035
+ return createProfilerBarChartOptions({
3036
+ categories: rows.map((row) => truncate(row.label, 40)),
3037
+ colors: [profilerChartTheme.blue, profilerChartTheme.indigo],
3038
+ height: buildChartHeight(rows.length),
3039
+ series: [
3040
+ { data: rows.map((row) => toRoundedNumber(row.avgDurationMs)), name: 'Avg ms' },
3041
+ { data: rows.map((row) => toRoundedNumber(row.p95DurationMs)), name: 'P95 ms' },
3042
+ ],
3043
+ title: 'Latency by hot path',
3044
+ valueUnit: 'Milliseconds',
3045
+ });
3046
+ };
3047
+
3048
+ const buildPerfBreakdownChartOptions = (rows: NonNullable<TProfilerState['perf']['top']>['rows']): ApexOptions | undefined => {
3049
+ if (rows.length === 0) return undefined;
3050
+
3051
+ return createProfilerBarChartOptions({
3052
+ categories: rows.map((row) => truncate(row.label, 40)),
3053
+ colors: [profilerChartTheme.blue, profilerChartTheme.amber, profilerChartTheme.teal, profilerChartTheme.green],
3054
+ height: buildChartHeight(rows.length),
3055
+ series: [
3056
+ { data: rows.map((row) => toRoundedNumber(row.avgSelfDurationMs)), name: 'Self ms' },
3057
+ { data: rows.map((row) => toRoundedNumber(row.avgSqlDurationMs)), name: 'SQL ms' },
3058
+ { data: rows.map((row) => toRoundedNumber(row.avgCallDurationMs)), name: 'Calls ms' },
3059
+ { data: rows.map((row) => toRoundedNumber(row.avgRenderDurationMs)), name: 'Render ms' },
3060
+ ],
3061
+ stacked: true,
3062
+ title: 'Average time breakdown',
3063
+ valueUnit: 'Milliseconds',
3064
+ });
3065
+ };
3066
+
3067
+ const buildPerfCompareChartOptions = (rows: NonNullable<TProfilerState['perf']['compare']>['rows']): ApexOptions | undefined => {
3068
+ if (rows.length === 0) return undefined;
3069
+
3070
+ const values = rows.map((row) => toRoundedNumber(row.p95DurationMs.deltaPercent));
3071
+ const minValue = Math.min(...values, 0);
3072
+ const maxValue = Math.max(...values, 0);
3073
+
3074
+ return {
3075
+ ...createProfilerBarChartOptions({
3076
+ categories: rows.map((row) => truncate(row.label, 40)),
3077
+ colors: rows.map((row) =>
3078
+ row.change === 'improved'
3079
+ ? profilerChartTheme.green
3080
+ : row.change === 'regressed'
3081
+ ? profilerChartTheme.red
3082
+ : row.change === 'new'
3083
+ ? profilerChartTheme.blue
3084
+ : row.change === 'removed'
3085
+ ? profilerChartTheme.muted
3086
+ : profilerChartTheme.orange,
3087
+ ),
3088
+ height: buildChartHeight(rows.length, { max: 420 }),
3089
+ series: [{ data: values, name: 'P95 delta %' }],
3090
+ title: 'Regression pressure',
3091
+ valueUnit: 'Percent vs baseline',
3092
+ }),
3093
+ plotOptions: {
3094
+ bar: {
3095
+ barHeight: '68%',
3096
+ distributed: true,
3097
+ horizontal: true,
3098
+ },
3099
+ },
3100
+ xaxis: {
3101
+ axisBorder: { show: false },
3102
+ axisTicks: { show: false },
3103
+ categories: rows.map((row) => truncate(row.label, 40)),
3104
+ labels: { style: { colors: profilerChartTheme.muted, fontSize: '11px' } },
3105
+ max: Math.max(10, Math.ceil(maxValue / 10) * 10),
3106
+ min: Math.min(-10, Math.floor(minValue / 10) * 10),
3107
+ title: {
3108
+ style: {
3109
+ color: profilerChartTheme.muted,
3110
+ fontSize: '10px',
3111
+ fontWeight: 600,
3112
+ },
3113
+ text: 'Percent vs baseline',
3114
+ },
3115
+ },
3116
+ };
3117
+ };
3118
+
3119
+ const buildPerfMemoryChartOptions = (rows: NonNullable<TProfilerState['perf']['memory']>['rows']): ApexOptions | undefined => {
3120
+ if (rows.length === 0) return undefined;
3121
+
3122
+ return createProfilerBarChartOptions({
3123
+ categories: rows.map((row) => truncate(row.label, 40)),
3124
+ colors: [profilerChartTheme.amber, profilerChartTheme.red, profilerChartTheme.cyan],
3125
+ height: buildChartHeight(rows.length),
3126
+ series: [
3127
+ { data: rows.map((row) => toKilobytes(row.avgHeapDeltaBytes)), name: 'Avg heap KB' },
3128
+ { data: rows.map((row) => toKilobytes(row.maxHeapDeltaBytes)), name: 'Max heap KB' },
3129
+ { data: rows.map((row) => toKilobytes(row.avgRssDeltaBytes)), name: 'Avg RSS KB' },
3130
+ ],
3131
+ title: 'Memory drift by group',
3132
+ valueUnit: 'Kilobytes',
3133
+ });
3134
+ };
3135
+
3136
+ const ChartSection = ({
3137
+ emptyLabel,
3138
+ options,
3139
+ subtitle,
3140
+ title,
3141
+ }: {
3142
+ emptyLabel: string;
3143
+ options?: ApexOptions;
3144
+ subtitle: string;
3145
+ title: string;
3146
+ }) => (
3147
+ <div className="proteum-profiler__chartCard">
3148
+ <div className="proteum-profiler__chartHeader">
3149
+ <div className="proteum-profiler__sectionTitle">{title}</div>
3150
+ <div className="proteum-profiler__chartSubtitle">{subtitle}</div>
3151
+ </div>
3152
+ <ApexChart emptyLabel={emptyLabel} options={options} />
3153
+ </div>
3154
+ );
3155
+
1919
3156
  const TimelinePanel = ({ session }: { session: TProfilerNavigationSession }) => {
1920
3157
  const selections: TTraceEventInspectorSelection[] = session.traces.flatMap((traceItem) =>
1921
3158
  traceItem.trace
@@ -2011,6 +3248,46 @@ const AuthPanel = ({ session }: { session: TProfilerNavigationSession }) => {
2011
3248
  ? [{ authEvents, id: traceItem.id, label: formatSessionTraceDisplay(traceItem), trace: traceItem.trace }]
2012
3249
  : [];
2013
3250
  });
3251
+ const allAuthEvents = authSections.flatMap((section) => section.authEvents);
3252
+ const authEventTypeChart = buildHorizontalBarChartOptions({
3253
+ color: profilerChartTheme.cyan,
3254
+ entries: buildCountEntries(allAuthEvents.map((event) => event.type.replace(/^auth\./, ''))),
3255
+ title: 'Auth event frequency',
3256
+ valueUnit: 'Events',
3257
+ });
3258
+ const authRuleChart = buildColumnChartOptions({
3259
+ colors: [profilerChartTheme.indigo],
3260
+ entries: buildCountEntries(
3261
+ allAuthEvents
3262
+ .filter((event) => event.type === 'auth.check.rule')
3263
+ .map(
3264
+ (event) =>
3265
+ readString(event.details.rule) ||
3266
+ readString(event.details.name) ||
3267
+ readString(event.details.label) ||
3268
+ `rule ${event.index}`,
3269
+ ),
3270
+ ),
3271
+ title: 'Rule hits',
3272
+ valueUnit: 'Checks',
3273
+ });
3274
+ const authResultChart = buildColumnChartOptions({
3275
+ colors: [profilerChartTheme.green],
3276
+ entries: buildCountEntries(
3277
+ allAuthEvents
3278
+ .filter((event) => event.type === 'auth.check.result' || event.type === 'auth.route')
3279
+ .map(
3280
+ (event) =>
3281
+ readString(event.details.result) ||
3282
+ readString(event.details.status) ||
3283
+ readString(event.details.mode) ||
3284
+ renderSummaryValue(event.details.allowed) ||
3285
+ event.type,
3286
+ ),
3287
+ ),
3288
+ title: 'Auth outcomes',
3289
+ valueUnit: 'Events',
3290
+ });
2014
3291
  const selections: TTraceEventInspectorSelection[] = authSections.flatMap((section) =>
2015
3292
  section.authEvents.map((event) => ({
2016
3293
  event,
@@ -2033,6 +3310,27 @@ const AuthPanel = ({ session }: { session: TProfilerNavigationSession }) => {
2033
3310
  return (
2034
3311
  <div className="proteum-profiler__splitView">
2035
3312
  <div className="proteum-profiler__splitColumn">
3313
+ <div className="proteum-profiler__chartGrid">
3314
+ <ChartSection
3315
+ emptyLabel="No auth events were captured for this session."
3316
+ options={authEventTypeChart}
3317
+ subtitle="See which auth phases are actually active for the selected navigation."
3318
+ title="Auth Flow"
3319
+ />
3320
+ <ChartSection
3321
+ emptyLabel="No rule checks were captured for this session."
3322
+ options={authRuleChart}
3323
+ subtitle="Highlight the rules that are firing most often in the current auth flow."
3324
+ title="Rule Pressure"
3325
+ />
3326
+ <ChartSection
3327
+ emptyLabel="No auth outcome events were captured for this session."
3328
+ options={authResultChart}
3329
+ subtitle="Summarize allow, deny, and routing outcomes without reading every trace row."
3330
+ title="Outcomes"
3331
+ />
3332
+ </div>
3333
+
2036
3334
  {authSections.map((section) => (
2037
3335
  <AuthTraceSection
2038
3336
  authEvents={section.authEvents}
@@ -2111,24 +3409,101 @@ const renderPanel = (panel: TProfilerPanel, session: TProfilerNavigationSession,
2111
3409
  const primaryTrace = summary.primaryTrace?.trace;
2112
3410
 
2113
3411
  if (panel === 'summary') {
3412
+ const recentSessionRows = state.sessions.slice(-8).map((candidate) => ({
3413
+ session: candidate,
3414
+ summary: getSummary(candidate),
3415
+ }));
3416
+ const recentSessionLabels = recentSessionRows.map(({ session: candidate, summary: candidateSummary }) =>
3417
+ getSessionChartLabel(candidate, candidateSummary),
3418
+ );
3419
+ const durationTrendChart = buildLineChartOptions({
3420
+ color: profilerChartTheme.blue,
3421
+ entries: recentSessionRows.map(({ session: candidate, summary: candidateSummary }) => ({
3422
+ label: getSessionChartLabel(candidate, candidateSummary),
3423
+ value: candidateSummary.totalMs || 0,
3424
+ })),
3425
+ title: 'Recent navigation duration',
3426
+ valueUnit: 'Milliseconds',
3427
+ });
3428
+ const workloadChart =
3429
+ recentSessionLabels.length === 0
3430
+ ? undefined
3431
+ : createProfilerColumnChartOptions({
3432
+ categories: recentSessionLabels,
3433
+ colors: [profilerChartTheme.indigo, profilerChartTheme.teal, profilerChartTheme.red],
3434
+ height: 300,
3435
+ series: [
3436
+ {
3437
+ data: recentSessionRows.map(({ summary: candidateSummary }) => candidateSummary.apiSyncCount + candidateSummary.apiAsyncCount),
3438
+ name: 'API',
3439
+ },
3440
+ { data: recentSessionRows.map(({ summary: candidateSummary }) => candidateSummary.sqlCount), name: 'SQL' },
3441
+ { data: recentSessionRows.map(({ summary: candidateSummary }) => candidateSummary.errorCount), name: 'Errors' },
3442
+ ],
3443
+ title: 'Recent workload mix',
3444
+ valueUnit: 'Count',
3445
+ });
3446
+ const routeFrequencyChart = buildHorizontalBarChartOptions({
3447
+ color: profilerChartTheme.amber,
3448
+ entries: buildCountEntries(recentSessionRows.map(({ summary: candidateSummary }) => candidateSummary.routeLabel)),
3449
+ title: 'Hot recent routes',
3450
+ valueUnit: 'Sessions',
3451
+ });
3452
+ const statusSpreadChart = buildColumnChartOptions({
3453
+ colors: [profilerChartTheme.green],
3454
+ entries: buildCountEntries(recentSessionRows.map(({ summary: candidateSummary }) => candidateSummary.statusLabel)),
3455
+ title: 'Recent status spread',
3456
+ valueUnit: 'Sessions',
3457
+ });
3458
+
2114
3459
  return (
2115
- <div className="proteum-profiler__metrics">
2116
- <SummaryRow label="Session" value={session.label} />
2117
- <SummaryRow label="Status" value={summary.statusLabel} />
2118
- <SummaryRow label="Duration" value={formatDuration(summary.totalMs)} />
2119
- <SummaryRow label="Route" value={summary.routeLabel} />
2120
- <SummaryRow
2121
- label="SSR"
2122
- value={
2123
- summary.ssrPayloadBytes !== undefined
2124
- ? `${formatDuration(summary.renderMs)} | ${formatBytes(summary.ssrPayloadBytes)}`
2125
- : formatDuration(summary.renderMs)
2126
- }
2127
- />
2128
- <SummaryRow label="API" value={`sync ${summary.apiSyncCount} / async ${summary.apiAsyncCount}`} />
2129
- <SummaryRow label="Errors" value={String(summary.errorCount)} />
2130
- <SummaryRow label="Request" value={session.requestId || 'client-only'} />
2131
- </div>
3460
+ <>
3461
+ <div className="proteum-profiler__chartGrid">
3462
+ <ChartSection
3463
+ emptyLabel="No recent session durations were captured yet."
3464
+ options={durationTrendChart}
3465
+ subtitle="Track how the last few navigations are trending instead of reading one request in isolation."
3466
+ title="Duration Trend"
3467
+ />
3468
+ <ChartSection
3469
+ emptyLabel="No recent workload data was captured yet."
3470
+ options={workloadChart}
3471
+ subtitle="Compare API, SQL, and error volume across the most recent sessions."
3472
+ title="Workload Mix"
3473
+ />
3474
+ <ChartSection
3475
+ emptyLabel="No recent route frequency data was captured yet."
3476
+ options={routeFrequencyChart}
3477
+ subtitle="See which routes are dominating the recent debugging session."
3478
+ title="Route Frequency"
3479
+ />
3480
+ <ChartSection
3481
+ emptyLabel="No recent session statuses were captured yet."
3482
+ options={statusSpreadChart}
3483
+ subtitle="Quick view of SSR, navigation, and request status patterns."
3484
+ title="Status Spread"
3485
+ />
3486
+ </div>
3487
+
3488
+ <div className="proteum-profiler__metrics">
3489
+ <SummaryRow label="Session" value={session.label} />
3490
+ <SummaryRow label="Status" value={summary.statusLabel} />
3491
+ <SummaryRow label="Duration" value={formatDuration(summary.totalMs)} />
3492
+ <SummaryRow label="Route" value={summary.routeLabel} />
3493
+ <SummaryRow
3494
+ label="SSR"
3495
+ value={
3496
+ summary.ssrPayloadBytes !== undefined
3497
+ ? `${formatDuration(summary.renderMs)} | ${formatBytes(summary.ssrPayloadBytes)}`
3498
+ : formatDuration(summary.renderMs)
3499
+ }
3500
+ />
3501
+ <SummaryRow label="API" value={`sync ${summary.apiSyncCount} / async ${summary.apiAsyncCount}`} />
3502
+ <SummaryRow label="SQL" value={String(summary.sqlCount)} />
3503
+ <SummaryRow label="Errors" value={String(summary.errorCount)} />
3504
+ <SummaryRow label="Request" value={session.requestId || 'client-only'} />
3505
+ </div>
3506
+ </>
2132
3507
  );
2133
3508
  }
2134
3509
 
@@ -2136,66 +3511,435 @@ const renderPanel = (panel: TProfilerPanel, session: TProfilerNavigationSession,
2136
3511
  return <TimelinePanel session={session} />;
2137
3512
  }
2138
3513
 
3514
+ if (panel === 'perf') {
3515
+ const perf = state.perf;
3516
+ const currentRequest = primaryTrace ? buildRequestPerformance(primaryTrace) : undefined;
3517
+ const waterfallItems = currentRequest ? buildPerfWaterfallItems(currentRequest) : [];
3518
+ const topRows =
3519
+ perf.top?.rows.map((row) => ({
3520
+ key: `top:${row.key}`,
3521
+ title: `${row.label} · ${formatDuration(row.avgDurationMs)}`,
3522
+ value: `requests=${row.requestCount} p95=${formatDuration(row.p95DurationMs)} cpu=${formatDuration(row.avgCpuMs)} sql=${formatDuration(row.avgSqlDurationMs)} heap=${formatSignedBytes(row.avgHeapDeltaBytes)} slowest=${row.slowestRequestId || 'n/a'}`,
3523
+ })) || [];
3524
+ const compareRows =
3525
+ perf.compare?.rows.map((row) => ({
3526
+ key: `compare:${row.key}`,
3527
+ title: `[${row.change}] ${row.label}`,
3528
+ value: `p95=${formatSignedPercent(row.p95DurationMs.deltaPercent)} avg=${formatSignedPercent(row.avgDurationMs.deltaPercent)} cpu=${formatSignedPercent(row.avgCpuMs.deltaPercent)} heap=${formatSignedBytes(row.avgHeapDeltaBytes.delta)} sql=${formatSignedPercent(row.avgSqlDurationMs.deltaPercent)}`,
3529
+ })) || [];
3530
+ const memoryRows =
3531
+ perf.memory?.rows.map((row) => ({
3532
+ key: `memory:${row.key}`,
3533
+ title: `[${row.trend}] ${row.label}`,
3534
+ value: `requests=${row.requestCount} heap avg=${formatSignedBytes(row.avgHeapDeltaBytes)} max=${formatSignedBytes(row.maxHeapDeltaBytes)} rss avg=${formatSignedBytes(row.avgRssDeltaBytes)} drift=${formatSignedPercent(row.positiveHeapDriftRatio * 100)}`,
3535
+ })) || [];
3536
+ const callRows =
3537
+ currentRequest?.hottestCalls.map((call) => ({
3538
+ key: `call:${call.id}`,
3539
+ title: call.label,
3540
+ value: `duration=${formatDuration(call.durationMs)} status=${call.statusCode ?? 'pending'} origin=${call.origin}${call.errorMessage ? ` error=${call.errorMessage}` : ''}`,
3541
+ })) || [];
3542
+ const sqlRows =
3543
+ currentRequest?.hottestSqlQueries.map((query) => ({
3544
+ key: `sql:${query.id}`,
3545
+ title: `${query.callerLabel} · ${formatDuration(query.durationMs)}`,
3546
+ value: `${query.operation}${query.model ? ` ${query.model}` : ''} | ${truncate(query.query, 160)}`,
3547
+ })) || [];
3548
+ const topLatencyChart = perf.top ? buildPerfTopLatencyChartOptions(perf.top.rows) : undefined;
3549
+ const topBreakdownChart = perf.top ? buildPerfBreakdownChartOptions(perf.top.rows) : undefined;
3550
+ const compareChart = perf.compare ? buildPerfCompareChartOptions(perf.compare.rows) : undefined;
3551
+ const memoryChart = perf.memory ? buildPerfMemoryChartOptions(perf.memory.rows) : undefined;
3552
+
3553
+ return (
3554
+ <div className="proteum-profiler__section">
3555
+ <div className="proteum-profiler__sectionHeader">
3556
+ <div className="proteum-profiler__sectionTitle">Performance</div>
3557
+ <div className="proteum-profiler__actions">
3558
+ <select
3559
+ aria-label="Performance top window"
3560
+ className="proteum-profiler__select"
3561
+ onChange={(event) => void profilerRuntime.refreshPerf({ since: event.currentTarget.value })}
3562
+ value={perf.since}
3563
+ >
3564
+ {perfWindowPresets.map((windowPreset) => (
3565
+ <option key={`since:${windowPreset}`} value={windowPreset}>
3566
+ since {windowPreset}
3567
+ </option>
3568
+ ))}
3569
+ </select>
3570
+ <select
3571
+ aria-label="Performance baseline window"
3572
+ className="proteum-profiler__select"
3573
+ onChange={(event) => void profilerRuntime.refreshPerf({ baseline: event.currentTarget.value })}
3574
+ value={perf.baseline}
3575
+ >
3576
+ {perfWindowPresets.map((windowPreset) => (
3577
+ <option key={`baseline:${windowPreset}`} value={windowPreset}>
3578
+ baseline {windowPreset}
3579
+ </option>
3580
+ ))}
3581
+ </select>
3582
+ <select
3583
+ aria-label="Performance target window"
3584
+ className="proteum-profiler__select"
3585
+ onChange={(event) => void profilerRuntime.refreshPerf({ target: event.currentTarget.value })}
3586
+ value={perf.target}
3587
+ >
3588
+ {perfWindowPresets.map((windowPreset) => (
3589
+ <option key={`target:${windowPreset}`} value={windowPreset}>
3590
+ target {windowPreset}
3591
+ </option>
3592
+ ))}
3593
+ </select>
3594
+ <select
3595
+ aria-label="Performance grouping"
3596
+ className="proteum-profiler__select"
3597
+ onChange={(event) => void profilerRuntime.refreshPerf({ groupBy: event.currentTarget.value as (typeof perfGroupByValues)[number] })}
3598
+ value={perf.groupBy}
3599
+ >
3600
+ {perfGroupByValues.map((groupBy) => (
3601
+ <option key={`group:${groupBy}`} value={groupBy}>
3602
+ group {groupBy}
3603
+ </option>
3604
+ ))}
3605
+ </select>
3606
+ <button className="proteum-profiler__pill" onClick={() => void profilerRuntime.refreshPerf()} type="button">
3607
+ Refresh
3608
+ </button>
3609
+ </div>
3610
+ </div>
3611
+
3612
+ {perf.errorMessage ? (
3613
+ <div className="proteum-profiler__row">
3614
+ <div className="proteum-profiler__rowHeader">
3615
+ <strong>Last perf panel error</strong>
3616
+ </div>
3617
+ <div className="proteum-profiler__mono">{perf.errorMessage}</div>
3618
+ </div>
3619
+ ) : null}
3620
+
3621
+ {perf.status === 'loading' && !perf.top && !currentRequest ? (
3622
+ <div className="proteum-profiler__empty">Loading performance data...</div>
3623
+ ) : (
3624
+ <>
3625
+ <div className="proteum-profiler__metrics">
3626
+ <SummaryRow
3627
+ label="Window"
3628
+ value={
3629
+ perf.top
3630
+ ? `${perf.top.window.label} (${perf.top.window.requestCount}/${perf.top.window.availableRequestCount})`
3631
+ : perf.since
3632
+ }
3633
+ />
3634
+ <SummaryRow label="Group By" value={perf.groupBy} />
3635
+ <SummaryRow label="Avg" value={perf.top ? formatDuration(perf.top.summary.avgDurationMs) : 'n/a'} />
3636
+ <SummaryRow label="P95" value={perf.top ? formatDuration(perf.top.summary.p95DurationMs) : 'n/a'} />
3637
+ <SummaryRow label="CPU" value={perf.top ? formatDuration(perf.top.summary.avgCpuMs) : 'n/a'} />
3638
+ <SummaryRow label="Heap" value={perf.top ? formatSignedBytes(perf.top.summary.avgHeapDeltaBytes) : 'n/a'} />
3639
+ <SummaryRow
3640
+ label="Current Request"
3641
+ value={currentRequest ? `${currentRequest.requestId} ${formatDuration(currentRequest.totalDurationMs)}` : 'No request'}
3642
+ />
3643
+ <SummaryRow label="Refreshed" value={perf.lastLoadedAt ? formatTimestamp(perf.lastLoadedAt) : 'Not loaded'} />
3644
+ </div>
3645
+
3646
+ {currentRequest ? (
3647
+ <>
3648
+ <WaterfallChart
3649
+ emptyLabel="No request stages were captured."
3650
+ itemLabel="stage"
3651
+ items={waterfallItems}
3652
+ />
3653
+ <div className="proteum-profiler__metrics">
3654
+ <SummaryRow label="Route" value={currentRequest.routeLabel} />
3655
+ <SummaryRow label="Controller" value={currentRequest.controllerLabel} />
3656
+ <SummaryRow label="Total" value={formatDuration(currentRequest.totalDurationMs)} />
3657
+ <SummaryRow label="SQL" value={`${currentRequest.sqlCount} / ${formatDuration(currentRequest.sqlDurationMs)}`} />
3658
+ <SummaryRow label="Calls" value={`${currentRequest.callCount} / ${formatDuration(currentRequest.callDurationMs)}`} />
3659
+ <SummaryRow
3660
+ label="Render"
3661
+ value={`${formatDuration(currentRequest.renderDurationMs)} / ${formatBytes(currentRequest.ssrPayloadBytes)}`}
3662
+ />
3663
+ <SummaryRow label="CPU" value={formatDuration(currentRequest.cpuTotalMs)} />
3664
+ <SummaryRow label="Heap" value={formatSignedBytes(currentRequest.heapDeltaBytes)} />
3665
+ </div>
3666
+ </>
3667
+ ) : (
3668
+ <div className="proteum-profiler__empty">No traced request is attached to this session yet.</div>
3669
+ )}
3670
+
3671
+ <div className="proteum-profiler__chartGrid">
3672
+ <ChartSection
3673
+ emptyLabel="No hot-path latency data matched this window."
3674
+ options={topLatencyChart}
3675
+ subtitle={`Compare average and p95 latency across the hottest ${perf.groupBy}s.`}
3676
+ title={`Hot ${perf.groupBy}s`}
3677
+ />
3678
+ <ChartSection
3679
+ emptyLabel="No breakdown data matched this window."
3680
+ options={topBreakdownChart}
3681
+ subtitle="See whether self time, SQL, external calls, or render work dominates the response."
3682
+ title="Time Breakdown"
3683
+ />
3684
+ <ChartSection
3685
+ emptyLabel="No compare data matched these windows."
3686
+ options={compareChart}
3687
+ subtitle={`Track p95 regression pressure between ${perf.baseline} and ${perf.target}.`}
3688
+ title="Regression Delta"
3689
+ />
3690
+ <ChartSection
3691
+ emptyLabel="No memory drift data matched this window."
3692
+ options={memoryChart}
3693
+ subtitle="Compare average heap growth, peak heap growth, and average RSS drift per group."
3694
+ title="Memory Drift"
3695
+ />
3696
+ </div>
3697
+
3698
+ <SimpleSection empty="No hot calls captured for this request." rows={callRows} title="Current Request Calls" />
3699
+ <SimpleSection empty="No hot SQL captured for this request." rows={sqlRows} title="Current Request SQL" />
3700
+ <SimpleSection empty="No perf rollups matched this window." rows={topRows} title={`Hot ${perf.groupBy}s`} />
3701
+ <SimpleSection empty="No compare deltas matched these windows." rows={compareRows} title="Compare" />
3702
+ <SimpleSection empty="No memory drift data matched this window." rows={memoryRows} title="Memory" />
3703
+ </>
3704
+ )}
3705
+ </div>
3706
+ );
3707
+ }
3708
+
2139
3709
  if (panel === 'auth') {
2140
3710
  return <AuthPanel session={session} />;
2141
3711
  }
2142
3712
 
2143
3713
  if (panel === 'routing') {
3714
+ const routingEvents = findTraceEvents(primaryTrace, [
3715
+ 'resolve.start',
3716
+ 'resolve.controller-route',
3717
+ 'resolve.route-match',
3718
+ 'resolve.route-skip',
3719
+ 'resolve.routes-evaluated',
3720
+ 'resolve.not-found',
3721
+ ]);
3722
+ const routingFlowChart = buildHorizontalBarChartOptions({
3723
+ color: profilerChartTheme.blue,
3724
+ entries: buildCountEntries(routingEvents.map((event) => event.type.replace(/^resolve\./, ''))),
3725
+ title: 'Resolve event flow',
3726
+ valueUnit: 'Events',
3727
+ });
3728
+ const routingTimelineChart =
3729
+ routingEvents.length === 0
3730
+ ? undefined
3731
+ : createProfilerColumnChartOptions({
3732
+ categories: routingEvents.map((event) => truncate(event.type.replace(/^resolve\./, ''), 22)),
3733
+ colors: [profilerChartTheme.indigo],
3734
+ height: 300,
3735
+ series: [{ data: routingEvents.map((event) => toRoundedNumber(event.elapsedMs)), name: 'Elapsed ms' }],
3736
+ title: 'Resolve milestone timing',
3737
+ valueUnit: 'Milliseconds',
3738
+ });
3739
+ const routingDecisionChart = buildHorizontalBarChartOptions({
3740
+ color: profilerChartTheme.amber,
3741
+ entries: buildCountEntries(
3742
+ routingEvents.map((event) => {
3743
+ if (event.type === 'resolve.route-skip') {
3744
+ return `skip:${readString(event.details.reason) || readString(event.details.code) || 'unknown'}`;
3745
+ }
3746
+
3747
+ if (event.type === 'resolve.route-match') {
3748
+ return `match:${readString(event.details.path) || readString(event.details.routePath) || 'route'}`;
3749
+ }
3750
+
3751
+ if (event.type === 'resolve.controller-route') {
3752
+ return `controller:${readString(event.details.httpPath) || readString(event.details.routePath) || 'route'}`;
3753
+ }
3754
+
3755
+ return event.type.replace(/^resolve\./, '');
3756
+ }),
3757
+ ),
3758
+ title: 'Resolve decisions',
3759
+ valueUnit: 'Events',
3760
+ });
3761
+
2144
3762
  return (
2145
- <SimpleSection
2146
- empty="No routing data captured yet."
2147
- rows={findTraceEvents(primaryTrace, [
2148
- 'resolve.start',
2149
- 'resolve.controller-route',
2150
- 'resolve.route-match',
2151
- 'resolve.routes-evaluated',
2152
- 'resolve.not-found',
2153
- ]).map((event) => ({
2154
- key: `${event.index}:${event.type}`,
2155
- title: event.type,
2156
- value: Object.entries(event.details)
2157
- .map(([key, value]) => `${key}=${renderSummaryValue(value)}`)
2158
- .join(' '),
2159
- }))}
2160
- showTitle={false}
2161
- title="Routing"
2162
- />
3763
+ <>
3764
+ <div className="proteum-profiler__chartGrid">
3765
+ <ChartSection
3766
+ emptyLabel="No routing events were captured yet."
3767
+ options={routingFlowChart}
3768
+ subtitle="Summarize the current request’s resolve flow without reading each trace event."
3769
+ title="Resolve Flow"
3770
+ />
3771
+ <ChartSection
3772
+ emptyLabel="No routing milestones were captured yet."
3773
+ options={routingTimelineChart}
3774
+ subtitle="Order the resolve milestones by elapsed time to spot slow route decisions."
3775
+ title="Resolve Timing"
3776
+ />
3777
+ <ChartSection
3778
+ emptyLabel="No routing decisions were captured yet."
3779
+ options={routingDecisionChart}
3780
+ subtitle="Highlight skip reasons, matched routes, and controller routing outcomes."
3781
+ title="Decisions"
3782
+ />
3783
+ </div>
3784
+
3785
+ <SimpleSection
3786
+ empty="No routing data captured yet."
3787
+ rows={routingEvents.map((event) => ({
3788
+ key: `${event.index}:${event.type}`,
3789
+ title: event.type,
3790
+ value: Object.entries(event.details)
3791
+ .map(([key, value]) => `${key}=${renderSummaryValue(value)}`)
3792
+ .join(' '),
3793
+ }))}
3794
+ showTitle={false}
3795
+ title="Routing"
3796
+ />
3797
+ </>
2163
3798
  );
2164
3799
  }
2165
3800
 
2166
3801
  if (panel === 'controller') {
3802
+ const controllerEvents = findTraceEvents(primaryTrace, ['controller.start', 'controller.result', 'setup.options', 'context.create']);
3803
+ const controllerFlowChart = buildHorizontalBarChartOptions({
3804
+ color: profilerChartTheme.indigo,
3805
+ entries: buildCountEntries(controllerEvents.map((event) => event.type.replace(/\./g, ' '))),
3806
+ title: 'Controller lifecycle',
3807
+ valueUnit: 'Events',
3808
+ });
3809
+ const controllerTimelineChart =
3810
+ controllerEvents.length === 0
3811
+ ? undefined
3812
+ : createProfilerColumnChartOptions({
3813
+ categories: controllerEvents.map((event) => truncate(event.type.replace(/\./g, ' '), 22)),
3814
+ colors: [profilerChartTheme.teal],
3815
+ height: 300,
3816
+ series: [{ data: controllerEvents.map((event) => toRoundedNumber(event.elapsedMs)), name: 'Elapsed ms' }],
3817
+ title: 'Lifecycle timing',
3818
+ valueUnit: 'Milliseconds',
3819
+ });
3820
+ const controllerDetailChart = buildHorizontalBarChartOptions({
3821
+ color: profilerChartTheme.cyan,
3822
+ entries: buildCountEntries(
3823
+ controllerEvents.flatMap((event) => Object.keys(event.details).map((key) => `${event.type}:${key}`)),
3824
+ ),
3825
+ title: 'Detail coverage',
3826
+ valueUnit: 'Fields',
3827
+ });
3828
+
2167
3829
  return (
2168
- <SimpleSection
2169
- empty="No controller data captured yet."
2170
- rows={findTraceEvents(primaryTrace, ['controller.start', 'controller.result', 'setup.options', 'context.create']).map(
2171
- (event) => ({
3830
+ <>
3831
+ <div className="proteum-profiler__chartGrid">
3832
+ <ChartSection
3833
+ emptyLabel="No controller events were captured yet."
3834
+ options={controllerFlowChart}
3835
+ subtitle="See which controller phases are present in the traced request."
3836
+ title="Lifecycle"
3837
+ />
3838
+ <ChartSection
3839
+ emptyLabel="No controller timing data was captured yet."
3840
+ options={controllerTimelineChart}
3841
+ subtitle="Compare elapsed time at each controller milestone."
3842
+ title="Timing"
3843
+ />
3844
+ <ChartSection
3845
+ emptyLabel="No controller event details were captured yet."
3846
+ options={controllerDetailChart}
3847
+ subtitle="Highlight which detail fields are most common across controller events."
3848
+ title="Detail Keys"
3849
+ />
3850
+ </div>
3851
+
3852
+ <SimpleSection
3853
+ empty="No controller data captured yet."
3854
+ rows={controllerEvents.map((event) => ({
2172
3855
  key: `${event.index}:${event.type}`,
2173
3856
  title: event.type,
2174
3857
  value: Object.entries(event.details)
2175
3858
  .map(([key, value]) => `${key}=${renderSummaryValue(value)}`)
2176
3859
  .join(' '),
2177
- }),
2178
- )}
2179
- showTitle={false}
2180
- title="Controller"
2181
- />
3860
+ }))}
3861
+ showTitle={false}
3862
+ title="Controller"
3863
+ />
3864
+ </>
2182
3865
  );
2183
3866
  }
2184
3867
 
2185
3868
  if (panel === 'ssr') {
3869
+ const ssrEvents = findTraceEvents(primaryTrace, ['page.data', 'ssr.payload', 'render.start', 'render.end']);
3870
+ const recentSsrRows = state.sessions
3871
+ .slice(-10)
3872
+ .map((candidate) => ({ session: candidate, summary: getSummary(candidate) }))
3873
+ .filter(({ summary: candidateSummary }) => candidateSummary.renderMs !== undefined || candidateSummary.ssrPayloadBytes !== undefined);
3874
+ const ssrScatterChart = buildScatterChartOptions({
3875
+ color: profilerChartTheme.amber,
3876
+ points: recentSsrRows.map(({ summary: candidateSummary }) => ({
3877
+ x: toRoundedNumber(candidateSummary.renderMs),
3878
+ y: toKilobytes(candidateSummary.ssrPayloadBytes, 2),
3879
+ })),
3880
+ seriesName: 'Session',
3881
+ title: 'Render vs payload',
3882
+ xaxisTitle: 'Render ms',
3883
+ yaxisTitle: 'Payload KB',
3884
+ });
3885
+ const payloadChart = buildHorizontalBarChartOptions({
3886
+ color: profilerChartTheme.teal,
3887
+ entries: buildTopEntries(
3888
+ recentSsrRows.map(({ summary: candidateSummary }) => ({
3889
+ label: candidateSummary.routeLabel,
3890
+ value: toKilobytes(candidateSummary.ssrPayloadBytes, 2),
3891
+ })),
3892
+ 8,
3893
+ ),
3894
+ title: 'Largest payloads',
3895
+ valueUnit: 'KB',
3896
+ });
3897
+ const renderTrendChart = buildLineChartOptions({
3898
+ color: profilerChartTheme.orange,
3899
+ entries: recentSsrRows.map(({ session: candidate, summary: candidateSummary }) => ({
3900
+ label: getSessionChartLabel(candidate, candidateSummary),
3901
+ value: candidateSummary.renderMs || 0,
3902
+ })),
3903
+ title: 'Recent render time',
3904
+ valueUnit: 'Milliseconds',
3905
+ });
3906
+
2186
3907
  return (
2187
- <SimpleSection
2188
- empty="No SSR data captured for this session."
2189
- rows={findTraceEvents(primaryTrace, ['page.data', 'ssr.payload', 'render.start', 'render.end']).map((event) => ({
2190
- key: `${event.index}:${event.type}`,
2191
- title: event.type,
2192
- value: Object.entries(event.details)
2193
- .map(([key, value]) => `${key}=${renderSummaryValue(value)}`)
2194
- .join(' '),
2195
- }))}
2196
- showTitle={false}
2197
- title="SSR"
2198
- />
3908
+ <>
3909
+ <div className="proteum-profiler__chartGrid">
3910
+ <ChartSection
3911
+ emptyLabel="No SSR timing and payload data was captured yet."
3912
+ options={ssrScatterChart}
3913
+ subtitle="Correlate render cost with payload size across recent navigations."
3914
+ title="Render vs Payload"
3915
+ />
3916
+ <ChartSection
3917
+ emptyLabel="No SSR payload sizes were captured yet."
3918
+ options={payloadChart}
3919
+ subtitle="Rank the largest payload producers from the recent session window."
3920
+ title="Payload Pressure"
3921
+ />
3922
+ <ChartSection
3923
+ emptyLabel="No SSR render timings were captured yet."
3924
+ options={renderTrendChart}
3925
+ subtitle="Track render time across the last few SSR sessions."
3926
+ title="Render Trend"
3927
+ />
3928
+ </div>
3929
+
3930
+ <SimpleSection
3931
+ empty="No SSR data captured for this session."
3932
+ rows={ssrEvents.map((event) => ({
3933
+ key: `${event.index}:${event.type}`,
3934
+ title: event.type,
3935
+ value: Object.entries(event.details)
3936
+ .map(([key, value]) => `${key}=${renderSummaryValue(value)}`)
3937
+ .join(' '),
3938
+ }))}
3939
+ showTitle={false}
3940
+ title="SSR"
3941
+ />
3942
+ </>
2199
3943
  );
2200
3944
  }
2201
3945
 
@@ -2203,6 +3947,147 @@ const renderPanel = (panel: TProfilerPanel, session: TProfilerNavigationSession,
2203
3947
  return <ApiPanel session={session} />;
2204
3948
  }
2205
3949
 
3950
+ if (panel === 'sql') {
3951
+ return <SqlPanel session={session} />;
3952
+ }
3953
+
3954
+ if (panel === 'diagnose') {
3955
+ const diagnose = state.diagnose;
3956
+ const response = diagnose.response;
3957
+ const suspectScoreChart = buildHorizontalBarChartOptions({
3958
+ color: profilerChartTheme.red,
3959
+ entries:
3960
+ response?.suspects.map((suspect) => ({
3961
+ label: suspect.label,
3962
+ value: suspect.score,
3963
+ })) || [],
3964
+ title: 'Suspect scoring',
3965
+ valueUnit: 'Score',
3966
+ });
3967
+ const ownerScoreChart = buildHorizontalBarChartOptions({
3968
+ color: profilerChartTheme.indigo,
3969
+ entries:
3970
+ response?.owner.matches.map((match) => ({
3971
+ label: `[${match.kind}] ${match.label}`,
3972
+ value: match.score,
3973
+ })) || [],
3974
+ title: 'Owner confidence',
3975
+ valueUnit: 'Score',
3976
+ });
3977
+ const diagnoseSeverityChart =
3978
+ response === undefined
3979
+ ? undefined
3980
+ : createProfilerColumnChartOptions({
3981
+ categories: ['Errors', 'Warnings'],
3982
+ colors: [profilerChartTheme.red, profilerChartTheme.amber],
3983
+ height: 300,
3984
+ series: [
3985
+ {
3986
+ data: [response.doctor.summary.errors, response.doctor.summary.warnings],
3987
+ name: 'Doctor',
3988
+ },
3989
+ {
3990
+ data: [response.contracts.summary.errors, response.contracts.summary.warnings],
3991
+ name: 'Contracts',
3992
+ },
3993
+ ],
3994
+ title: 'Diagnostic severity',
3995
+ valueUnit: 'Count',
3996
+ });
3997
+ const suspectRows =
3998
+ response?.suspects.map((suspect, index) => ({
3999
+ key: `suspect:${index}`,
4000
+ title: `${suspect.score} · ${suspect.label}`,
4001
+ value: `${suspect.filepath}${formatManifestLocation(suspect.line, suspect.column)} reasons=${suspect.reasons.join(', ')}`,
4002
+ })) || [];
4003
+ const ownerRows =
4004
+ response?.owner.matches.map((match, index) => ({
4005
+ key: `owner:${index}`,
4006
+ title: `[${match.kind}] ${match.label}`,
4007
+ value: `score=${match.score} source=${formatOwnerSource(match)} matchedOn=${match.matchedOn.join(', ') || 'n/a'}`,
4008
+ })) || [];
4009
+ const contractRows =
4010
+ response?.contracts.diagnostics.map((diagnostic, index) => ({
4011
+ key: `contract:${diagnostic.code}:${index}`,
4012
+ title: `[${diagnostic.level}] ${diagnostic.code}`,
4013
+ value: `${diagnostic.message} source=${diagnostic.filepath}${formatManifestLocation(
4014
+ diagnostic.sourceLocation?.line,
4015
+ diagnostic.sourceLocation?.column,
4016
+ )}`,
4017
+ })) || [];
4018
+ const logRows =
4019
+ response?.serverLogs.logs.map((entry, index) => ({
4020
+ key: `log:${index}`,
4021
+ title: `[${entry.level}] ${formatTimestamp(entry.time)}`,
4022
+ value: truncate(entry.text, 220),
4023
+ })) || [];
4024
+
4025
+ return (
4026
+ <div className="proteum-profiler__section">
4027
+ <div className="proteum-profiler__sectionHeader">
4028
+ <div className="proteum-profiler__sectionTitle">Diagnose</div>
4029
+ <div className="proteum-profiler__actions">
4030
+ <button className="proteum-profiler__pill" onClick={() => void profilerRuntime.refreshDiagnose(session.id)} type="button">
4031
+ Refresh
4032
+ </button>
4033
+ </div>
4034
+ </div>
4035
+
4036
+ {diagnose.errorMessage ? (
4037
+ <div className="proteum-profiler__row">
4038
+ <div className="proteum-profiler__rowHeader">
4039
+ <strong>Last diagnose panel error</strong>
4040
+ </div>
4041
+ <div className="proteum-profiler__mono">{diagnose.errorMessage}</div>
4042
+ </div>
4043
+ ) : null}
4044
+
4045
+ {diagnose.status === 'loading' && !response ? (
4046
+ <div className="proteum-profiler__empty">Loading diagnose data...</div>
4047
+ ) : !response ? (
4048
+ <div className="proteum-profiler__empty">No diagnose data is available for this session yet.</div>
4049
+ ) : (
4050
+ <>
4051
+ <div className="proteum-profiler__metrics">
4052
+ <SummaryRow label="Query" value={response.query} />
4053
+ <SummaryRow label="Request" value={response.request ? summarizeTraceForDiagnose(response.request) : 'No trace'} />
4054
+ <SummaryRow label="Doctor" value={`${response.doctor.summary.errors} errors / ${response.doctor.summary.warnings} warnings`} />
4055
+ <SummaryRow label="Contracts" value={`${response.contracts.summary.errors} errors / ${response.contracts.summary.warnings} warnings`} />
4056
+ <SummaryRow
4057
+ label="Refreshed"
4058
+ value={diagnose.lastLoadedAt ? formatTimestamp(diagnose.lastLoadedAt) : 'Not loaded'}
4059
+ />
4060
+ </div>
4061
+ <div className="proteum-profiler__chartGrid">
4062
+ <ChartSection
4063
+ emptyLabel="No suspect scores were returned for this diagnose run."
4064
+ options={suspectScoreChart}
4065
+ subtitle="Rank the files Proteum currently believes are most likely involved."
4066
+ title="Suspects"
4067
+ />
4068
+ <ChartSection
4069
+ emptyLabel="No owner matches were returned for this diagnose run."
4070
+ options={ownerScoreChart}
4071
+ subtitle="Compare owner candidates and their confidence scores."
4072
+ title="Owner Matches"
4073
+ />
4074
+ <ChartSection
4075
+ emptyLabel="No diagnose severity data was returned for this run."
4076
+ options={diagnoseSeverityChart}
4077
+ subtitle="Compare doctor diagnostics against contract diagnostics in one view."
4078
+ title="Severity"
4079
+ />
4080
+ </div>
4081
+ <SimpleSection empty="No likely suspect files were found." rows={suspectRows} title="Suspects" />
4082
+ <SimpleSection empty="No owner matches were found." rows={ownerRows} title="Owner Matches" />
4083
+ <SimpleSection empty="No contract diagnostics were found." rows={contractRows} title="Contracts" />
4084
+ <SimpleSection empty="No recent server logs were captured." rows={logRows} title="Server Logs" />
4085
+ </>
4086
+ )}
4087
+ </div>
4088
+ );
4089
+ }
4090
+
2206
4091
  if (panel === 'explain') {
2207
4092
  const explain = state.explain;
2208
4093
  const blocks = explain.manifest
@@ -2211,6 +4096,74 @@ const renderPanel = (panel: TProfilerPanel, session: TProfilerNavigationSession,
2211
4096
  ...buildExplainBlocks(explain.manifest, [...explainSectionNames]),
2212
4097
  ]
2213
4098
  : [];
4099
+ const manifest = explain.manifest;
4100
+ const structureChart = manifest
4101
+ ? buildColumnChartOptions({
4102
+ colors: [profilerChartTheme.blue],
4103
+ entries: [
4104
+ { label: 'app services', value: manifest.services.app.length },
4105
+ { label: 'router plugins', value: manifest.services.routerPlugins.length },
4106
+ { label: 'controllers', value: manifest.controllers.length },
4107
+ { label: 'commands', value: manifest.commands.length },
4108
+ { label: 'client routes', value: manifest.routes.client.length },
4109
+ { label: 'server routes', value: manifest.routes.server.length },
4110
+ { label: 'layouts', value: manifest.layouts.length },
4111
+ { label: 'diagnostics', value: manifest.diagnostics.length },
4112
+ ],
4113
+ title: 'Manifest structure',
4114
+ valueUnit: 'Count',
4115
+ })
4116
+ : undefined;
4117
+ const manifestScopeChart = manifest
4118
+ ? buildColumnChartOptions({
4119
+ colors: [profilerChartTheme.indigo],
4120
+ entries: [
4121
+ {
4122
+ label: 'app',
4123
+ value: [
4124
+ ...manifest.services.app,
4125
+ ...manifest.services.routerPlugins,
4126
+ ...manifest.controllers,
4127
+ ...manifest.commands,
4128
+ ...manifest.routes.client,
4129
+ ...manifest.routes.server,
4130
+ ...manifest.layouts,
4131
+ ].filter((entry) => entry.scope === 'app').length,
4132
+ },
4133
+ {
4134
+ label: 'framework',
4135
+ value: [
4136
+ ...manifest.services.app,
4137
+ ...manifest.services.routerPlugins,
4138
+ ...manifest.controllers,
4139
+ ...manifest.commands,
4140
+ ...manifest.routes.client,
4141
+ ...manifest.routes.server,
4142
+ ...manifest.layouts,
4143
+ ].filter((entry) => entry.scope === 'framework').length,
4144
+ },
4145
+ ],
4146
+ title: 'Scope split',
4147
+ valueUnit: 'Entries',
4148
+ })
4149
+ : undefined;
4150
+ const envReadinessChart = manifest
4151
+ ? buildColumnChartOptions({
4152
+ colors: [profilerChartTheme.green, profilerChartTheme.red],
4153
+ entries: [
4154
+ {
4155
+ label: 'provided',
4156
+ value: manifest.env.requiredVariables.filter((variable) => variable.provided).length,
4157
+ },
4158
+ {
4159
+ label: 'missing',
4160
+ value: manifest.env.requiredVariables.filter((variable) => !variable.provided).length,
4161
+ },
4162
+ ],
4163
+ title: 'Env readiness',
4164
+ valueUnit: 'Variables',
4165
+ })
4166
+ : undefined;
2214
4167
 
2215
4168
  return (
2216
4169
  <div className="proteum-profiler__section">
@@ -2238,6 +4191,27 @@ const renderPanel = (panel: TProfilerPanel, session: TProfilerNavigationSession,
2238
4191
  <div className="proteum-profiler__empty">No explain manifest is available.</div>
2239
4192
  ) : (
2240
4193
  <>
4194
+ <div className="proteum-profiler__chartGrid">
4195
+ <ChartSection
4196
+ emptyLabel="No manifest structure data is available."
4197
+ options={structureChart}
4198
+ subtitle="Summarize the main object counts in the current Proteum manifest."
4199
+ title="Structure"
4200
+ />
4201
+ <ChartSection
4202
+ emptyLabel="No manifest scope data is available."
4203
+ options={manifestScopeChart}
4204
+ subtitle="Show how much of the current manifest comes from app code vs framework code."
4205
+ title="Scope Split"
4206
+ />
4207
+ <ChartSection
4208
+ emptyLabel="No manifest env readiness data is available."
4209
+ options={envReadinessChart}
4210
+ subtitle="Check required variable coverage before digging through the raw manifest."
4211
+ title="Env Ready"
4212
+ />
4213
+ </div>
4214
+
2241
4215
  <div className="proteum-profiler__row">
2242
4216
  <div className="proteum-profiler__rowHeader">
2243
4217
  <strong>Manifest snapshot</strong>
@@ -2258,6 +4232,50 @@ const renderPanel = (panel: TProfilerPanel, session: TProfilerNavigationSession,
2258
4232
 
2259
4233
  if (panel === 'doctor') {
2260
4234
  const doctor = state.doctor;
4235
+ const doctorSeverityChart =
4236
+ doctor.response === undefined
4237
+ ? undefined
4238
+ : createProfilerColumnChartOptions({
4239
+ categories: ['Errors', 'Warnings'],
4240
+ colors: [profilerChartTheme.red, profilerChartTheme.amber],
4241
+ height: 300,
4242
+ series: [
4243
+ {
4244
+ data: [doctor.response.summary.errors, doctor.response.summary.warnings],
4245
+ name: 'Doctor',
4246
+ },
4247
+ {
4248
+ data: [
4249
+ doctor.contracts?.summary.errors || 0,
4250
+ doctor.contracts?.summary.warnings || 0,
4251
+ ],
4252
+ name: 'Contracts',
4253
+ },
4254
+ ],
4255
+ title: 'Severity overview',
4256
+ valueUnit: 'Count',
4257
+ });
4258
+ const doctorCodeChart = buildHorizontalBarChartOptions({
4259
+ color: profilerChartTheme.orange,
4260
+ entries: buildCountEntries(doctor.response?.diagnostics.map((diagnostic) => diagnostic.code) || []),
4261
+ title: 'Diagnostic codes',
4262
+ valueUnit: 'Hits',
4263
+ });
4264
+ const doctorFileChart = buildHorizontalBarChartOptions({
4265
+ color: profilerChartTheme.cyan,
4266
+ entries: buildCountEntries(doctor.response?.diagnostics.map((diagnostic) => diagnostic.filepath) || []),
4267
+ title: 'Hot files',
4268
+ valueUnit: 'Diagnostics',
4269
+ });
4270
+ const contractRows =
4271
+ doctor.contracts?.diagnostics.map((diagnostic, index) => ({
4272
+ key: `contract:${diagnostic.code}:${index}`,
4273
+ title: `[${diagnostic.level}] ${diagnostic.code}`,
4274
+ value: `${diagnostic.message} source=${diagnostic.filepath}${formatManifestLocation(
4275
+ diagnostic.sourceLocation?.line,
4276
+ diagnostic.sourceLocation?.column,
4277
+ )}${diagnostic.relatedFilepaths?.length ? ` related=${diagnostic.relatedFilepaths.join(',')}` : ''}`,
4278
+ })) || [];
2261
4279
  const doctorRows =
2262
4280
  doctor.response?.diagnostics.map((diagnostic, index) => ({
2263
4281
  key: `${diagnostic.code}:${index}`,
@@ -2298,17 +4316,46 @@ const renderPanel = (panel: TProfilerPanel, session: TProfilerNavigationSession,
2298
4316
  <div className="proteum-profiler__metrics">
2299
4317
  <SummaryRow label="Errors" value={String(doctor.response.summary.errors)} />
2300
4318
  <SummaryRow label="Warnings" value={String(doctor.response.summary.warnings)} />
4319
+ <SummaryRow
4320
+ label="Contracts"
4321
+ value={
4322
+ doctor.contracts
4323
+ ? `${doctor.contracts.summary.errors} errors / ${doctor.contracts.summary.warnings} warnings`
4324
+ : 'not loaded'
4325
+ }
4326
+ />
2301
4327
  <SummaryRow label="Strict" value={doctor.response.summary.strictFailed ? 'failed' : 'ok'} />
2302
4328
  <SummaryRow
2303
4329
  label="Refreshed"
2304
4330
  value={doctor.lastLoadedAt ? formatTimestamp(doctor.lastLoadedAt) : 'Not loaded'}
2305
4331
  />
2306
4332
  </div>
4333
+ <div className="proteum-profiler__chartGrid">
4334
+ <ChartSection
4335
+ emptyLabel="No doctor severity data is available."
4336
+ options={doctorSeverityChart}
4337
+ subtitle="Compare doctor and contract diagnostics across errors and warnings."
4338
+ title="Severity"
4339
+ />
4340
+ <ChartSection
4341
+ emptyLabel="No diagnostic codes are available."
4342
+ options={doctorCodeChart}
4343
+ subtitle="See which diagnostic families are dominating the current manifest."
4344
+ title="Codes"
4345
+ />
4346
+ <ChartSection
4347
+ emptyLabel="No diagnostic file hotspots are available."
4348
+ options={doctorFileChart}
4349
+ subtitle="Highlight the files attracting the most diagnostics."
4350
+ title="Files"
4351
+ />
4352
+ </div>
2307
4353
  {doctorBlocks.length > 0 ? (
2308
4354
  <TextBlocks blocks={doctorBlocks} />
2309
4355
  ) : (
2310
4356
  <SimpleSection empty="No manifest diagnostics were found." rows={doctorRows} title="Diagnostics" />
2311
4357
  )}
4358
+ <SimpleSection empty="No contract diagnostics were found." rows={contractRows} title="Contracts" />
2312
4359
  </>
2313
4360
  )}
2314
4361
  </div>
@@ -2317,6 +4364,33 @@ const renderPanel = (panel: TProfilerPanel, session: TProfilerNavigationSession,
2317
4364
 
2318
4365
  if (panel === 'commands') {
2319
4366
  const commandsState = state.commands;
4367
+ const commandScopeChart = buildColumnChartOptions({
4368
+ colors: [profilerChartTheme.indigo],
4369
+ entries: buildCountEntries(commandsState.commands.map((command) => command.scope)),
4370
+ title: 'Command scope split',
4371
+ valueUnit: 'Commands',
4372
+ });
4373
+ const commandDurationChart = buildHorizontalBarChartOptions({
4374
+ color: profilerChartTheme.blue,
4375
+ entries: buildTopEntries(
4376
+ commandsState.commands
4377
+ .map((command) => ({
4378
+ label: command.path,
4379
+ value: commandsState.executions[command.path]?.durationMs || 0,
4380
+ }))
4381
+ .filter((entry) => entry.value > 0),
4382
+ ),
4383
+ title: 'Latest execution duration',
4384
+ valueUnit: 'Milliseconds',
4385
+ });
4386
+ const commandStatusChart = buildColumnChartOptions({
4387
+ colors: [profilerChartTheme.green],
4388
+ entries: buildCountEntries(
4389
+ commandsState.commands.map((command) => commandsState.executions[command.path]?.status || 'never-run'),
4390
+ ),
4391
+ title: 'Execution status',
4392
+ valueUnit: 'Commands',
4393
+ });
2320
4394
 
2321
4395
  return (
2322
4396
  <div className="proteum-profiler__section">
@@ -2355,65 +4429,88 @@ const renderPanel = (panel: TProfilerPanel, session: TProfilerNavigationSession,
2355
4429
  ) : commandsState.commands.length === 0 ? (
2356
4430
  <div className="proteum-profiler__empty">No commands are registered for this app.</div>
2357
4431
  ) : (
2358
- <div className="proteum-profiler__list">
2359
- {commandsState.commands.map((command: TDevCommandDefinition) => {
2360
- const execution = commandsState.executions[command.path] as TDevCommandExecution | undefined;
2361
- return (
2362
- <div className="proteum-profiler__row" key={command.path}>
2363
- <div className="proteum-profiler__rowHeader">
2364
- <strong>{command.path}</strong>
2365
- <div className="proteum-profiler__actions">
2366
- <span className="proteum-profiler__mono proteum-profiler__muted">
2367
- {execution ? formatTimestamp(execution.finishedAt) : 'Never run'}
2368
- </span>
2369
- <button
2370
- className="proteum-profiler__pill"
2371
- onClick={() => void profilerRuntime.runCommand(command.path)}
2372
- type="button"
2373
- >
2374
- Run now
2375
- </button>
4432
+ <>
4433
+ <div className="proteum-profiler__chartGrid">
4434
+ <ChartSection
4435
+ emptyLabel="No command scope data is available."
4436
+ options={commandScopeChart}
4437
+ subtitle="Show how the registered dev commands split between app and framework scopes."
4438
+ title="Scope"
4439
+ />
4440
+ <ChartSection
4441
+ emptyLabel="No command execution durations have been captured yet."
4442
+ options={commandDurationChart}
4443
+ subtitle="Highlight the commands with the slowest latest execution."
4444
+ title="Latest Duration"
4445
+ />
4446
+ <ChartSection
4447
+ emptyLabel="No command execution statuses have been captured yet."
4448
+ options={commandStatusChart}
4449
+ subtitle="Separate commands that have never run from completed or failed commands."
4450
+ title="Status"
4451
+ />
4452
+ </div>
4453
+
4454
+ <div className="proteum-profiler__list">
4455
+ {commandsState.commands.map((command: TDevCommandDefinition) => {
4456
+ const execution = commandsState.executions[command.path] as TDevCommandExecution | undefined;
4457
+ return (
4458
+ <div className="proteum-profiler__row" key={command.path}>
4459
+ <div className="proteum-profiler__rowHeader">
4460
+ <strong>{command.path}</strong>
4461
+ <div className="proteum-profiler__actions">
4462
+ <span className="proteum-profiler__mono proteum-profiler__muted">
4463
+ {execution ? formatTimestamp(execution.finishedAt) : 'Never run'}
4464
+ </span>
4465
+ <button
4466
+ className="proteum-profiler__pill"
4467
+ onClick={() => void profilerRuntime.runCommand(command.path)}
4468
+ type="button"
4469
+ >
4470
+ Run now
4471
+ </button>
4472
+ </div>
4473
+ </div>
4474
+
4475
+ <div className="proteum-profiler__tags">
4476
+ <span className="proteum-profiler__tag">{command.className}</span>
4477
+ <span className="proteum-profiler__tag">{command.methodName}</span>
4478
+ <span className="proteum-profiler__tag">{command.scope}</span>
4479
+ {execution ? <span className="proteum-profiler__tag">{execution.status}</span> : null}
4480
+ {execution ? (
4481
+ <span className="proteum-profiler__tag">{formatDuration(execution.durationMs)}</span>
4482
+ ) : null}
4483
+ {execution?.errorMessage ? (
4484
+ <span className="proteum-profiler__tag">{truncate(execution.errorMessage, 72)}</span>
4485
+ ) : null}
4486
+ </div>
4487
+
4488
+ <div className="proteum-profiler__mono proteum-profiler__muted">
4489
+ source {command.filepath}:{command.sourceLocation.line}:{command.sourceLocation.column}
4490
+ {commandsState.lastLoadedAt
4491
+ ? ` | refreshed ${formatTimestamp(commandsState.lastLoadedAt)}`
4492
+ : ''}
2376
4493
  </div>
2377
- </div>
2378
4494
 
2379
- <div className="proteum-profiler__tags">
2380
- <span className="proteum-profiler__tag">{command.className}</span>
2381
- <span className="proteum-profiler__tag">{command.methodName}</span>
2382
- <span className="proteum-profiler__tag">{command.scope}</span>
2383
- {execution ? <span className="proteum-profiler__tag">{execution.status}</span> : null}
2384
4495
  {execution ? (
2385
- <span className="proteum-profiler__tag">{formatDuration(execution.durationMs)}</span>
2386
- ) : null}
2387
- {execution?.errorMessage ? (
2388
- <span className="proteum-profiler__tag">{truncate(execution.errorMessage, 72)}</span>
4496
+ <div className="proteum-profiler__section">
4497
+ <div className="proteum-profiler__sectionTitle">Last result</div>
4498
+ <JsonCodeBlock
4499
+ value={
4500
+ execution.result?.json !== undefined
4501
+ ? formatStructuredValue(execution.result.json)
4502
+ : execution.result
4503
+ ? formatStructuredValue(execution.result.summary)
4504
+ : execution.errorMessage || 'undefined'
4505
+ }
4506
+ />
4507
+ </div>
2389
4508
  ) : null}
2390
4509
  </div>
2391
-
2392
- <div className="proteum-profiler__mono proteum-profiler__muted">
2393
- source {command.filepath}:{command.sourceLocation.line}:{command.sourceLocation.column}
2394
- {commandsState.lastLoadedAt
2395
- ? ` | refreshed ${formatTimestamp(commandsState.lastLoadedAt)}`
2396
- : ''}
2397
- </div>
2398
-
2399
- {execution ? (
2400
- <div className="proteum-profiler__section">
2401
- <div className="proteum-profiler__sectionTitle">Last result</div>
2402
- <JsonCodeBlock
2403
- value={
2404
- execution.result?.json !== undefined
2405
- ? formatStructuredValue(execution.result.json)
2406
- : execution.result
2407
- ? formatStructuredValue(execution.result.summary)
2408
- : execution.errorMessage || 'undefined'
2409
- }
2410
- />
2411
- </div>
2412
- ) : null}
2413
- </div>
2414
- );
2415
- })}
2416
- </div>
4510
+ );
4511
+ })}
4512
+ </div>
4513
+ </>
2417
4514
  )}
2418
4515
  </div>
2419
4516
  );
@@ -2421,6 +4518,30 @@ const renderPanel = (panel: TProfilerPanel, session: TProfilerNavigationSession,
2421
4518
 
2422
4519
  if (panel === 'cron') {
2423
4520
  const cron = state.cron;
4521
+ const cronRunCountChart = buildHorizontalBarChartOptions({
4522
+ color: profilerChartTheme.blue,
4523
+ entries: buildTopEntries(cron.tasks.map((task) => ({ label: task.name, value: task.runCount }))),
4524
+ title: 'Run counts',
4525
+ valueUnit: 'Runs',
4526
+ });
4527
+ const cronDurationChart = buildHorizontalBarChartOptions({
4528
+ color: profilerChartTheme.orange,
4529
+ entries: buildTopEntries(
4530
+ cron.tasks
4531
+ .map((task) => ({ label: task.name, value: task.lastRunDurationMs || 0 }))
4532
+ .filter((entry) => entry.value > 0),
4533
+ ),
4534
+ title: 'Last run duration',
4535
+ valueUnit: 'Milliseconds',
4536
+ });
4537
+ const cronStatusChart = buildColumnChartOptions({
4538
+ colors: [profilerChartTheme.teal],
4539
+ entries: buildCountEntries(
4540
+ cron.tasks.map((task) => (task.running ? 'running' : task.lastRunStatus || 'never-run')),
4541
+ ),
4542
+ title: 'Task status',
4543
+ valueUnit: 'Tasks',
4544
+ });
2424
4545
 
2425
4546
  return (
2426
4547
  <div className="proteum-profiler__section">
@@ -2460,81 +4581,140 @@ const renderPanel = (panel: TProfilerPanel, session: TProfilerNavigationSession,
2460
4581
  ) : cron.tasks.length === 0 ? (
2461
4582
  <div className="proteum-profiler__empty">No cron tasks are registered for this app.</div>
2462
4583
  ) : (
2463
- <div className="proteum-profiler__list">
2464
- {cron.tasks.map((task) => (
2465
- <div className="proteum-profiler__row" key={task.name}>
2466
- <div className="proteum-profiler__rowHeader">
2467
- <strong>{task.name}</strong>
2468
- <div className="proteum-profiler__actions">
2469
- <span className="proteum-profiler__mono proteum-profiler__muted">
2470
- {task.running
2471
- ? 'Running...'
2472
- : task.lastRunFinishedAt
2473
- ? formatTimestamp(task.lastRunFinishedAt)
2474
- : 'Never run'}
2475
- </span>
2476
- <button
2477
- className="proteum-profiler__pill"
2478
- disabled={task.running}
2479
- onClick={() => void profilerRuntime.runCronTask(task.name)}
2480
- type="button"
2481
- >
2482
- {task.running ? 'Running...' : 'Run now'}
2483
- </button>
4584
+ <>
4585
+ <div className="proteum-profiler__chartGrid">
4586
+ <ChartSection
4587
+ emptyLabel="No cron run counts are available."
4588
+ options={cronRunCountChart}
4589
+ subtitle="Highlight which tasks have been exercised the most in the current dev session."
4590
+ title="Runs"
4591
+ />
4592
+ <ChartSection
4593
+ emptyLabel="No cron duration data is available."
4594
+ options={cronDurationChart}
4595
+ subtitle="Compare the latest duration of tasks that have been executed."
4596
+ title="Duration"
4597
+ />
4598
+ <ChartSection
4599
+ emptyLabel="No cron status data is available."
4600
+ options={cronStatusChart}
4601
+ subtitle="Separate currently running, completed, failed, and never-run tasks."
4602
+ title="Status"
4603
+ />
4604
+ </div>
4605
+
4606
+ <div className="proteum-profiler__list">
4607
+ {cron.tasks.map((task) => (
4608
+ <div className="proteum-profiler__row" key={task.name}>
4609
+ <div className="proteum-profiler__rowHeader">
4610
+ <strong>{task.name}</strong>
4611
+ <div className="proteum-profiler__actions">
4612
+ <span className="proteum-profiler__mono proteum-profiler__muted">
4613
+ {task.running
4614
+ ? 'Running...'
4615
+ : task.lastRunFinishedAt
4616
+ ? formatTimestamp(task.lastRunFinishedAt)
4617
+ : 'Never run'}
4618
+ </span>
4619
+ <button
4620
+ className="proteum-profiler__pill"
4621
+ disabled={task.running}
4622
+ onClick={() => void profilerRuntime.runCronTask(task.name)}
4623
+ type="button"
4624
+ >
4625
+ {task.running ? 'Running...' : 'Run now'}
4626
+ </button>
4627
+ </div>
2484
4628
  </div>
2485
- </div>
2486
4629
 
2487
- <div className="proteum-profiler__tags">
2488
- <span className="proteum-profiler__tag">schedule:{truncate(formatCronFrequency(task), 64)}</span>
2489
- <span className="proteum-profiler__tag">
2490
- next:{task.nextInvocation ? formatTimestamp(task.nextInvocation) : 'none'}
2491
- </span>
2492
- <span className="proteum-profiler__tag">autoexec:{task.autoexec ? 'yes' : 'no'}</span>
2493
- <span className="proteum-profiler__tag">
2494
- automatic:{task.automaticExecution ? 'enabled' : 'disabled in dev'}
2495
- </span>
2496
- <span className="proteum-profiler__tag">runs:{task.runCount}</span>
2497
- {task.lastTrigger ? <span className="proteum-profiler__tag">trigger:{task.lastTrigger}</span> : null}
2498
- {task.lastRunStatus ? <span className="proteum-profiler__tag">{task.lastRunStatus}</span> : null}
2499
- {task.lastRunDurationMs !== undefined ? (
2500
- <span className="proteum-profiler__tag">{formatDuration(task.lastRunDurationMs)}</span>
2501
- ) : null}
2502
- {task.lastErrorMessage ? (
2503
- <span className="proteum-profiler__tag">{truncate(task.lastErrorMessage, 72)}</span>
2504
- ) : null}
2505
- </div>
4630
+ <div className="proteum-profiler__tags">
4631
+ <span className="proteum-profiler__tag">schedule:{truncate(formatCronFrequency(task), 64)}</span>
4632
+ <span className="proteum-profiler__tag">
4633
+ next:{task.nextInvocation ? formatTimestamp(task.nextInvocation) : 'none'}
4634
+ </span>
4635
+ <span className="proteum-profiler__tag">autoexec:{task.autoexec ? 'yes' : 'no'}</span>
4636
+ <span className="proteum-profiler__tag">
4637
+ automatic:{task.automaticExecution ? 'enabled' : 'disabled in dev'}
4638
+ </span>
4639
+ <span className="proteum-profiler__tag">runs:{task.runCount}</span>
4640
+ {task.lastTrigger ? <span className="proteum-profiler__tag">trigger:{task.lastTrigger}</span> : null}
4641
+ {task.lastRunStatus ? <span className="proteum-profiler__tag">{task.lastRunStatus}</span> : null}
4642
+ {task.lastRunDurationMs !== undefined ? (
4643
+ <span className="proteum-profiler__tag">{formatDuration(task.lastRunDurationMs)}</span>
4644
+ ) : null}
4645
+ {task.lastErrorMessage ? (
4646
+ <span className="proteum-profiler__tag">{truncate(task.lastErrorMessage, 72)}</span>
4647
+ ) : null}
4648
+ </div>
2506
4649
 
2507
- <div className="proteum-profiler__mono proteum-profiler__muted">
2508
- registered {formatTimestamp(task.registeredAt)}
2509
- {cron.lastLoadedAt ? ` | refreshed ${formatTimestamp(cron.lastLoadedAt)}` : ''}
4650
+ <div className="proteum-profiler__mono proteum-profiler__muted">
4651
+ registered {formatTimestamp(task.registeredAt)}
4652
+ {cron.lastLoadedAt ? ` | refreshed ${formatTimestamp(cron.lastLoadedAt)}` : ''}
4653
+ </div>
2510
4654
  </div>
2511
- </div>
2512
- ))}
2513
- </div>
4655
+ ))}
4656
+ </div>
4657
+ </>
2514
4658
  )}
2515
4659
  </div>
2516
4660
  );
2517
4661
  }
2518
4662
 
2519
- const errorRows = [
2520
- ...session.steps
4663
+ const stepErrors = session.steps
2521
4664
  .filter((step) => step.status === 'error')
2522
- .map((step) => ({ key: step.id, title: step.label, value: step.errorMessage || 'Step failed' })),
2523
- ...session.traces
4665
+ .map((step) => ({ key: step.id, title: step.label, value: step.errorMessage || 'Step failed' }));
4666
+ const traceErrors = session.traces
2524
4667
  .filter((trace) => trace.status === 'error')
2525
- .map((trace) => ({ key: trace.id, title: trace.label, value: trace.errorMessage || 'Request failed' })),
2526
- ...findTraceEvents(primaryTrace, ['error']).map((event) => ({
4668
+ .map((trace) => ({ key: trace.id, title: trace.label, value: trace.errorMessage || 'Request failed' }));
4669
+ const eventErrors = findTraceEvents(primaryTrace, ['error']).map((event) => ({
2527
4670
  key: `${event.index}:error`,
2528
4671
  title: event.type,
2529
4672
  value: Object.entries(event.details)
2530
4673
  .map(([key, value]) => `${key}=${renderSummaryValue(value)}`)
2531
4674
  .join(' '),
2532
- })),
2533
- ];
4675
+ }));
4676
+ const errorRows = [...stepErrors, ...traceErrors, ...eventErrors];
4677
+ const errorSourceChart = buildColumnChartOptions({
4678
+ colors: [profilerChartTheme.red],
4679
+ entries: [
4680
+ { label: 'steps', value: stepErrors.length },
4681
+ { label: 'traces', value: traceErrors.length },
4682
+ { label: 'events', value: eventErrors.length },
4683
+ ],
4684
+ title: 'Error sources',
4685
+ valueUnit: 'Errors',
4686
+ });
4687
+ const errorLabelChart = buildHorizontalBarChartOptions({
4688
+ color: profilerChartTheme.orange,
4689
+ entries: buildCountEntries(errorRows.map((row) => row.title)),
4690
+ title: 'Error groups',
4691
+ valueUnit: 'Errors',
4692
+ });
2534
4693
 
2535
- return <SimpleSection empty="No errors captured." rows={errorRows} showTitle={false} title="Errors" />;
4694
+ return (
4695
+ <>
4696
+ <div className="proteum-profiler__chartGrid">
4697
+ <ChartSection
4698
+ emptyLabel="No errors were captured for this session."
4699
+ options={errorSourceChart}
4700
+ subtitle="Split captured failures between navigation steps, requests, and trace events."
4701
+ title="Sources"
4702
+ />
4703
+ <ChartSection
4704
+ emptyLabel="No grouped error labels were captured for this session."
4705
+ options={errorLabelChart}
4706
+ subtitle="Highlight the error families that are repeating in the selected session."
4707
+ title="Groups"
4708
+ />
4709
+ </div>
4710
+
4711
+ <SimpleSection empty="No errors captured." rows={errorRows} showTitle={false} title="Errors" />
4712
+ </>
4713
+ );
2536
4714
  };
2537
4715
 
4716
+ const splitScrollPanels = new Set<TProfilerPanel>(['timeline', 'auth', 'api', 'sql']);
4717
+
2538
4718
  export default function DevProfiler() {
2539
4719
  const [state, setState] = React.useState(() => profilerRuntime.getState());
2540
4720
 
@@ -2563,6 +4743,7 @@ export default function DevProfiler() {
2563
4743
  const summary = getSummary(session);
2564
4744
  const tone = summary.errorCount > 0 ? 'error' : (summary.totalMs || 0) > 500 ? 'warn' : 'ok';
2565
4745
  const primaryTrace = summary.primaryTrace?.trace;
4746
+ const currentPerfRequest = primaryTrace ? buildRequestPerformance(primaryTrace) : undefined;
2566
4747
  const minimizedLabel =
2567
4748
  session.kind === 'client-navigation'
2568
4749
  ? session.label
@@ -2630,7 +4811,13 @@ export default function DevProfiler() {
2630
4811
  </div>
2631
4812
  </div>
2632
4813
 
2633
- <div className="proteum-profiler__panelBody">{renderPanel(state.activePanel, session, summary, state)}</div>
4814
+ <div
4815
+ className={`proteum-profiler__panelBody ${
4816
+ splitScrollPanels.has(state.activePanel) ? 'proteum-profiler__panelBody--split' : ''
4817
+ }`}
4818
+ >
4819
+ {renderPanel(state.activePanel, session, summary, state)}
4820
+ </div>
2634
4821
  </div>
2635
4822
  ) : null}
2636
4823
 
@@ -2667,6 +4854,20 @@ export default function DevProfiler() {
2667
4854
  onClick={() => profilerRuntime.openPanel('api')}
2668
4855
  tone={summary.apiAsyncCount > 0 || summary.apiSyncCount > 0 ? 'ok' : 'warn'}
2669
4856
  />
4857
+ <StatusToken
4858
+ label={`SQL ${summary.sqlCount}`}
4859
+ onClick={() => profilerRuntime.openPanel('sql')}
4860
+ tone={summary.sqlCount > 0 ? 'ok' : 'warn'}
4861
+ />
4862
+ <StatusToken
4863
+ label={
4864
+ currentPerfRequest
4865
+ ? `Perf ${formatDuration(currentPerfRequest.cpuTotalMs)} ${formatSignedBytes(currentPerfRequest.heapDeltaBytes)}`
4866
+ : 'Perf'
4867
+ }
4868
+ onClick={() => profilerRuntime.openPanel('perf')}
4869
+ tone={currentPerfRequest?.heapDeltaBytes && currentPerfRequest.heapDeltaBytes > 0 ? 'warn' : 'ok'}
4870
+ />
2670
4871
  {summary.errorCount > 0 ? (
2671
4872
  <StatusToken
2672
4873
  label={`${summary.errorCount} error${summary.errorCount === 1 ? '' : 's'}`}
@@ -2674,6 +4875,11 @@ export default function DevProfiler() {
2674
4875
  tone="error"
2675
4876
  />
2676
4877
  ) : null}
4878
+ <StatusToken
4879
+ label="Diagnose"
4880
+ onClick={() => profilerRuntime.openPanel('diagnose')}
4881
+ tone={summary.errorCount > 0 ? 'error' : 'warn'}
4882
+ />
2677
4883
  <div className="proteum-profiler__spacer" />
2678
4884
  <button className="proteum-profiler__token" onClick={() => profilerRuntime.setUiState('pinned-handle')} type="button">
2679
4885
  Hide