proteum 2.1.2 → 2.1.6

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 (99) hide show
  1. package/AGENTS.md +22 -14
  2. package/README.md +112 -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/command.ts +8 -0
  15. package/cli/commands/connect.ts +45 -0
  16. package/cli/commands/dev.ts +26 -11
  17. package/cli/commands/diagnose.ts +286 -0
  18. package/cli/commands/doctor.ts +18 -5
  19. package/cli/commands/explain.ts +25 -0
  20. package/cli/commands/perf.ts +243 -0
  21. package/cli/commands/session.ts +254 -0
  22. package/cli/commands/sessionLocalRunner.js +188 -0
  23. package/cli/commands/trace.ts +17 -1
  24. package/cli/commands/verify.ts +281 -0
  25. package/cli/compiler/artifacts/connectedProjects.ts +453 -0
  26. package/cli/compiler/artifacts/controllers.ts +198 -49
  27. package/cli/compiler/artifacts/discovery.ts +0 -34
  28. package/cli/compiler/artifacts/manifest.ts +90 -6
  29. package/cli/compiler/artifacts/routing.ts +2 -2
  30. package/cli/compiler/artifacts/services.ts +277 -130
  31. package/cli/compiler/client/index.ts +3 -0
  32. package/cli/compiler/common/files/style.ts +52 -0
  33. package/cli/compiler/common/generatedRouteModules.ts +34 -5
  34. package/cli/compiler/common/scripts.ts +11 -5
  35. package/cli/compiler/index.ts +2 -1
  36. package/cli/compiler/server/index.ts +3 -0
  37. package/cli/presentation/commands.ts +136 -7
  38. package/cli/presentation/devSession.ts +32 -7
  39. package/cli/runtime/commands.ts +193 -6
  40. package/cli/scaffold/index.ts +14 -25
  41. package/cli/scaffold/templates.ts +41 -27
  42. package/cli/utils/agents.ts +4 -2
  43. package/cli/utils/keyboard.ts +8 -0
  44. package/client/dev/profiler/ApexChart.tsx +66 -0
  45. package/client/dev/profiler/index.tsx +2798 -417
  46. package/client/dev/profiler/runtime.noop.ts +12 -0
  47. package/client/dev/profiler/runtime.ts +195 -4
  48. package/client/services/router/request/api.ts +6 -1
  49. package/common/applicationConfig.ts +173 -0
  50. package/common/applicationConfigLoader.ts +102 -0
  51. package/common/connectedProjects.ts +113 -0
  52. package/common/dev/connect.ts +267 -0
  53. package/common/dev/console.ts +31 -0
  54. package/common/dev/contractsDoctor.ts +128 -0
  55. package/common/dev/diagnostics.ts +59 -15
  56. package/common/dev/inspection.ts +491 -0
  57. package/common/dev/performance.ts +809 -0
  58. package/common/dev/profiler.ts +3 -0
  59. package/common/dev/proteumManifest.ts +31 -6
  60. package/common/dev/requestTrace.ts +56 -1
  61. package/common/dev/session.ts +24 -0
  62. package/common/env/proteumEnv.ts +176 -50
  63. package/common/router/index.ts +1 -0
  64. package/common/router/request/api.ts +2 -0
  65. package/config.ts +5 -0
  66. package/docs/dev-commands.md +5 -1
  67. package/docs/dev-sessions.md +90 -0
  68. package/docs/diagnostics.md +74 -11
  69. package/docs/request-tracing.md +50 -3
  70. package/package.json +1 -1
  71. package/server/app/container/config.ts +16 -87
  72. package/server/app/container/console/index.ts +42 -8
  73. package/server/app/container/index.ts +3 -1
  74. package/server/app/container/trace/index.ts +153 -0
  75. package/server/app/devDiagnostics.ts +138 -0
  76. package/server/app/index.ts +18 -8
  77. package/server/app/service/container.ts +0 -12
  78. package/server/app/service/index.ts +0 -2
  79. package/server/services/prisma/index.ts +121 -4
  80. package/server/services/router/http/index.ts +352 -0
  81. package/server/services/router/index.ts +50 -47
  82. package/server/services/router/request/api.ts +160 -19
  83. package/server/services/router/request/index.ts +8 -0
  84. package/server/services/router/response/index.ts +24 -1
  85. package/server/services/router/response/page/document.tsx +5 -0
  86. package/server/services/router/response/page/index.tsx +10 -0
  87. package/agents/framework/AGENTS.md +0 -177
  88. package/server/services/auth/router/service.json +0 -6
  89. package/server/services/auth/service.json +0 -6
  90. package/server/services/cron/service.json +0 -6
  91. package/server/services/disks/drivers/local/service.json +0 -6
  92. package/server/services/disks/drivers/s3/service.json +0 -6
  93. package/server/services/disks/service.json +0 -6
  94. package/server/services/fetch/service.json +0 -7
  95. package/server/services/prisma/service.json +0 -6
  96. package/server/services/router/service.json +0 -6
  97. package/server/services/schema/router/service.json +0 -6
  98. package/server/services/schema/service.json +0 -6
  99. 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;
@@ -367,6 +380,47 @@ const profilerStyles = `
367
380
  line-height: 1.45;
368
381
  }
369
382
 
383
+ .proteum-profiler__rowTitle {
384
+ min-width: 0;
385
+ word-break: break-word;
386
+ }
387
+
388
+ .proteum-profiler__rowMeta {
389
+ display: inline-flex;
390
+ align-items: center;
391
+ justify-content: flex-end;
392
+ gap: 8px;
393
+ margin-left: auto;
394
+ white-space: nowrap;
395
+ }
396
+
397
+ .proteum-profiler__statusBadge {
398
+ display: inline-flex;
399
+ align-items: center;
400
+ justify-content: center;
401
+ min-height: 18px;
402
+ padding: 0 8px;
403
+ border: 1px solid currentColor;
404
+ color: var(--profiler-muted);
405
+ background: transparent;
406
+ font-size: 10px;
407
+ font-weight: 700;
408
+ letter-spacing: 0.08em;
409
+ text-transform: uppercase;
410
+ }
411
+
412
+ .proteum-profiler__statusBadge--ok {
413
+ color: var(--profiler-ok);
414
+ }
415
+
416
+ .proteum-profiler__statusBadge--warn {
417
+ color: var(--profiler-warn);
418
+ }
419
+
420
+ .proteum-profiler__statusBadge--error {
421
+ color: var(--profiler-error);
422
+ }
423
+
370
424
  .proteum-profiler__mono {
371
425
  font-family: inherit;
372
426
  font-size: 11px;
@@ -467,6 +521,7 @@ const profilerStyles = `
467
521
  align-items: stretch;
468
522
  min-height: 100%;
469
523
  height: 100%;
524
+ max-height: 100%;
470
525
  }
471
526
 
472
527
  .proteum-profiler__splitView {
@@ -476,6 +531,7 @@ const profilerStyles = `
476
531
  align-items: stretch;
477
532
  min-height: 100%;
478
533
  height: 100%;
534
+ max-height: 100%;
479
535
  }
480
536
 
481
537
  .proteum-profiler__splitView--stacked {
@@ -487,7 +543,12 @@ const profilerStyles = `
487
543
  display: grid;
488
544
  gap: 0;
489
545
  min-width: 0;
546
+ min-height: 0;
547
+ height: 100%;
490
548
  align-content: start;
549
+ overflow: auto;
550
+ overscroll-behavior: contain;
551
+ scrollbar-width: thin;
491
552
  }
492
553
 
493
554
  .proteum-profiler__requestGroups {
@@ -518,8 +579,6 @@ const profilerStyles = `
518
579
  }
519
580
 
520
581
  .proteum-profiler__sidebar {
521
- position: sticky;
522
- top: 0;
523
582
  display: flex;
524
583
  align-self: stretch;
525
584
  height: 100%;
@@ -530,6 +589,7 @@ const profilerStyles = `
530
589
  border-radius: 0;
531
590
  background: transparent;
532
591
  box-shadow: none;
592
+ overflow: hidden;
533
593
  }
534
594
 
535
595
  .proteum-profiler__sidebarScroller {
@@ -619,6 +679,44 @@ const profilerStyles = `
619
679
  background: transparent !important;
620
680
  }
621
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
+
622
720
  .proteum-profiler__traceEventRow {
623
721
  --profiler-trace-depth: 0;
624
722
  --profiler-trace-guide-opacity: 0;
@@ -669,6 +767,18 @@ const profilerStyles = `
669
767
  gap: 10px;
670
768
  }
671
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
+
672
782
  .proteum-profiler__metricRow {
673
783
  grid-template-columns: minmax(90px, 110px) 1fr;
674
784
  }
@@ -681,12 +791,19 @@ const profilerStyles = `
681
791
  grid-template-columns: 1fr;
682
792
  min-height: 0;
683
793
  height: auto;
794
+ max-height: none;
684
795
  }
685
796
 
686
797
  .proteum-profiler__splitView {
687
798
  grid-template-columns: 1fr;
688
799
  min-height: 0;
689
800
  height: auto;
801
+ max-height: none;
802
+ }
803
+
804
+ .proteum-profiler__splitColumn {
805
+ height: auto;
806
+ overflow: visible;
690
807
  }
691
808
 
692
809
  .proteum-profiler__sidebar {
@@ -713,6 +830,7 @@ type TSessionSummary = {
713
830
  primaryTrace?: TProfilerSessionTrace;
714
831
  renderMs?: number;
715
832
  routeLabel: string;
833
+ sqlCount: number;
716
834
  ssrPayloadBytes?: number;
717
835
  statusLabel: string;
718
836
  totalMs?: number;
@@ -725,35 +843,60 @@ type TApiRequestItem = {
725
843
  finishedAt?: string;
726
844
  label?: string;
727
845
  method: string;
846
+ originLabel?: string;
728
847
  path: string;
729
848
  requestData?: TTraceSummaryValue;
849
+ requestDataJson?: unknown;
730
850
  result?: TTraceSummaryValue;
851
+ resultJson?: unknown;
731
852
  startedAt: string;
732
853
  statusCode?: number;
733
854
  statusLabel?: string;
734
855
  tags: string[];
735
856
  };
736
- type TTimelineWaterfallEventItem = {
737
- chartLabel: string;
738
- color: string;
857
+ type TSqlQueryItem = {
858
+ callerLabel: string;
739
859
  durationMs: number;
740
- endMs: 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
+ };
877
+ type TWaterfallChartItem = {
878
+ barLabel: string;
879
+ color: string;
880
+ detailLines: string[];
741
881
  endOffsetMs: number;
742
- event: TRequestTrace['events'][number];
743
- startMs: number;
882
+ id: string;
744
883
  startOffsetMs: number;
745
- traceLabel: string;
884
+ subtitle?: string;
885
+ title: string;
746
886
  };
747
887
  type TProfilerState = ReturnType<typeof profilerRuntime.getState>;
748
888
 
749
889
  const panelLabels: Record<TProfilerPanel, string> = {
750
890
  summary: 'Summary',
751
891
  timeline: 'Timeline',
892
+ perf: 'Perf',
752
893
  routing: 'Routing',
753
894
  auth: 'Auth',
754
895
  controller: 'Controller',
755
896
  ssr: 'SSR',
756
897
  api: 'API',
898
+ sql: 'SQL',
899
+ diagnose: 'Diagnose',
757
900
  errors: 'Errors',
758
901
  explain: 'Explain',
759
902
  doctor: 'Doctor',
@@ -772,11 +915,24 @@ const readNumber = (value: TTraceSummaryValue | undefined) => (typeof value ===
772
915
  const readString = (value: TTraceSummaryValue | undefined) => (typeof value === 'string' ? value : undefined);
773
916
  const formatDuration = (value?: number) => (value === undefined ? 'pending' : `${Math.round(value)} ms`);
774
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)}%`);
775
921
  const formatTimestamp = (value?: string) => {
776
922
  if (!value) return 'never';
777
923
  const date = new Date(value);
778
924
  return Number.isNaN(date.valueOf()) ? value : date.toLocaleString();
779
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
+ };
780
936
  const formatCronFrequency = (task: TProfilerCronTask) =>
781
937
  task.frequency.kind === 'cron' ? task.frequency.value : `once at ${formatTimestamp(task.frequency.value)}`;
782
938
  const formatStructuredValue = (value: unknown) => {
@@ -786,6 +942,29 @@ const formatStructuredValue = (value: unknown) => {
786
942
  return String(value);
787
943
  }
788
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
+ };
789
968
 
790
969
  const renderSummaryValue = (value: TTraceSummaryValue | undefined): string => {
791
970
  if (value === undefined) return '';
@@ -835,6 +1014,9 @@ const formatSummaryJson = (value: TTraceSummaryValue | undefined) => {
835
1014
  return JSON.stringify(toSummaryJsonValue(value), null, 2);
836
1015
  };
837
1016
 
1017
+ const formatApiPanelJson = (jsonValue: unknown, summaryValue: TTraceSummaryValue | undefined) =>
1018
+ jsonValue !== undefined ? formatStructuredValue(jsonValue) : formatSummaryJson(summaryValue);
1019
+
838
1020
  const formatTraceEventDetailsJson = (details: Record<string, TTraceSummaryValue>) =>
839
1021
  JSON.stringify(
840
1022
  Object.fromEntries(Object.entries(details).map(([key, value]) => [key, toSummaryJsonValue(value)])),
@@ -940,6 +1122,11 @@ const formatProfilerRequestReference = ({
940
1122
  return rawReference || fallbackLabel || 'request';
941
1123
  };
942
1124
 
1125
+ const formatConnectedTraceCallReference = (call: TTraceCall) =>
1126
+ call.connectedProjectNamespace && call.connectedControllerAccessor
1127
+ ? `${call.connectedProjectNamespace}.${call.connectedControllerAccessor}`
1128
+ : undefined;
1129
+
943
1130
  const getTraceRequestData = (trace: TRequestTrace | undefined) =>
944
1131
  trace?.events.find((event) => event.type === 'request.start')?.details.data;
945
1132
 
@@ -951,6 +1138,14 @@ const getTraceResultData = (trace: TRequestTrace | undefined) =>
951
1138
  const getRequestStatusText = (statusCode?: number, statusLabel?: string) =>
952
1139
  statusCode !== undefined ? String(statusCode) : statusLabel || 'pending';
953
1140
 
1141
+ const getRequestStatusTone = (statusCode?: number, statusLabel?: string): 'ok' | 'warn' | 'error' => {
1142
+ if (statusCode === undefined) return statusLabel === 'pending' ? 'warn' : 'ok';
1143
+ if (statusCode >= 500) return 'error';
1144
+ if (statusCode >= 400) return 'error';
1145
+ if (statusCode >= 300) return 'warn';
1146
+ return 'ok';
1147
+ };
1148
+
954
1149
  const findTraceEvents = (trace: TRequestTrace | undefined, eventTypes: string[]) =>
955
1150
  trace?.events.filter((event) => eventTypes.includes(event.type)) || [];
956
1151
 
@@ -1007,6 +1202,7 @@ const getSummary = (session: TProfilerNavigationSession): TSessionSummary => {
1007
1202
  session.steps.filter((step) => step.status === 'error').length +
1008
1203
  session.traces.filter((traceItem) => traceItem.status === 'error').length +
1009
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);
1010
1206
  const renderStart = trace?.events.find((event) => event.type === 'render.start');
1011
1207
  const renderEnd = trace?.events.find((event) => event.type === 'render.end');
1012
1208
  const localRender = [...session.steps].reverse().find((step) => step.label === 'Render' && step.durationMs !== undefined);
@@ -1023,6 +1219,7 @@ const getSummary = (session: TProfilerNavigationSession): TSessionSummary => {
1023
1219
  ? Math.max(0, renderEnd.elapsedMs - renderStart.elapsedMs)
1024
1220
  : localRender?.durationMs,
1025
1221
  routeLabel,
1222
+ sqlCount,
1026
1223
  ssrPayloadBytes: readNumber(ssrPayload?.details.serializedBytes),
1027
1224
  statusLabel: session.kind === 'client-navigation' ? 'NAV' : trace ? `${trace.statusCode || 'pending'} ${trace.method}` : 'SSR',
1028
1225
  totalMs: session.kind === 'client-navigation' ? session.durationMs : trace?.durationMs ?? session.durationMs,
@@ -1046,7 +1243,12 @@ const JsonCodeBlock = ({ value }: { value: string }) => (
1046
1243
  <pre className="proteum-profiler__mono proteum-profiler__pre">{renderHighlightedJson(value)}</pre>
1047
1244
  );
1048
1245
 
1246
+ const PlainCodeBlock = ({ value }: { value: string }) => <pre className="proteum-profiler__mono proteum-profiler__pre">{value}</pre>;
1247
+
1049
1248
  const formatTraceCallDisplay = (call: TTraceCall) => {
1249
+ const connectedReference = formatConnectedTraceCallReference(call);
1250
+ if (connectedReference) return connectedReference;
1251
+
1050
1252
  if (call.path.startsWith('/api/')) {
1051
1253
  return formatProfilerRequestReference({
1052
1254
  fallbackLabel: call.label,
@@ -1074,6 +1276,83 @@ const formatSessionTraceDisplay = (traceItem: TProfilerSessionTrace) => {
1074
1276
  return traceItem.label || formatProfilerRequestReference({ method: traceItem.method, path: traceItem.path });
1075
1277
  };
1076
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
+
1077
1356
  const ApiRequestListEntry = ({
1078
1357
  isSelected,
1079
1358
  item,
@@ -1084,6 +1363,7 @@ const ApiRequestListEntry = ({
1084
1363
  onSelect: () => void;
1085
1364
  }) => {
1086
1365
  const statusText = getRequestStatusText(item.statusCode, item.statusLabel);
1366
+ const statusTone = getRequestStatusTone(item.statusCode, item.statusLabel);
1087
1367
 
1088
1368
  return (
1089
1369
  <button
@@ -1093,19 +1373,14 @@ const ApiRequestListEntry = ({
1093
1373
  type="button"
1094
1374
  >
1095
1375
  <div className="proteum-profiler__rowHeader">
1096
- <strong>{formatApiReference(item.method, item.path, item.requestData, item.label)}</strong>
1097
- <span className="proteum-profiler__mono proteum-profiler__muted">
1098
- {formatDuration(item.durationMs)} | {statusText}
1376
+ <strong className="proteum-profiler__rowTitle">
1377
+ {formatApiReference(item.method, item.path, item.requestData, item.label)}
1378
+ </strong>
1379
+ <span className="proteum-profiler__rowMeta">
1380
+ <span className="proteum-profiler__mono proteum-profiler__muted">{formatDuration(item.durationMs)}</span>
1381
+ <span className={`proteum-profiler__statusBadge proteum-profiler__statusBadge--${statusTone}`}>{statusText}</span>
1099
1382
  </span>
1100
1383
  </div>
1101
- <div className="proteum-profiler__tags">
1102
- {item.tags.map((tag) => (
1103
- <span className="proteum-profiler__tag" key={`${item.id}:${tag}`}>
1104
- {tag}
1105
- </span>
1106
- ))}
1107
- {item.errorMessage ? <span className="proteum-profiler__tag">{truncate(item.errorMessage, 72)}</span> : null}
1108
- </div>
1109
1384
  </button>
1110
1385
  );
1111
1386
  };
@@ -1177,12 +1452,12 @@ const ApiRequestSidebar = ({ item }: { item?: TApiRequestItem }) => {
1177
1452
 
1178
1453
  <div className="proteum-profiler__sidebarSection">
1179
1454
  <div className="proteum-profiler__sidebarSectionTitle">Arguments</div>
1180
- <JsonCodeBlock value={formatSummaryJson(item.requestData)} />
1455
+ <JsonCodeBlock value={formatApiPanelJson(item.requestDataJson, item.requestData)} />
1181
1456
  </div>
1182
1457
 
1183
1458
  <div className="proteum-profiler__sidebarSection">
1184
1459
  <div className="proteum-profiler__sidebarSectionTitle">Result</div>
1185
- <JsonCodeBlock value={formatSummaryJson(item.result)} />
1460
+ <JsonCodeBlock value={formatApiPanelJson(item.resultJson, item.result)} />
1186
1461
  </div>
1187
1462
 
1188
1463
  {item.errorMessage ? (
@@ -1196,6 +1471,106 @@ const ApiRequestSidebar = ({ item }: { item?: TApiRequestItem }) => {
1196
1471
  );
1197
1472
  };
1198
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
+
1199
1574
  const ApiPanel = ({ session }: { session: TProfilerNavigationSession }) => {
1200
1575
  const syncItems: TApiRequestItem[] = session.traces
1201
1576
  .flatMap((trace) => trace.trace?.calls.filter((call) => call.origin !== 'client-async') || [])
@@ -1207,13 +1582,18 @@ const ApiPanel = ({ session }: { session: TProfilerNavigationSession }) => {
1207
1582
  finishedAt: call.finishedAt,
1208
1583
  label: call.label,
1209
1584
  method: call.method,
1585
+ originLabel: call.origin,
1210
1586
  path: call.path,
1211
1587
  requestData: call.requestData,
1588
+ requestDataJson: call.requestDataJson,
1212
1589
  result: call.result,
1590
+ resultJson: call.resultJson,
1213
1591
  startedAt: call.startedAt,
1214
1592
  statusCode: call.statusCode,
1215
1593
  tags: [
1216
1594
  call.origin,
1595
+ ...(call.connectedProjectNamespace ? [`connected:${call.connectedProjectNamespace}`] : []),
1596
+ ...(call.connectedControllerAccessor ? [`target:${call.connectedControllerAccessor}`] : []),
1217
1597
  ...(call.fetcherId ? [`fetcher:${call.fetcherId}`] : []),
1218
1598
  ...call.requestDataKeys.map((key) => `arg:${key}`),
1219
1599
  ...call.resultKeys.map((key) => `res:${key}`),
@@ -1229,9 +1609,12 @@ const ApiPanel = ({ session }: { session: TProfilerNavigationSession }) => {
1229
1609
  finishedAt: trace.finishedAt,
1230
1610
  label: trace.label,
1231
1611
  method: trace.method,
1612
+ originLabel: 'client-async',
1232
1613
  path: trace.path,
1233
1614
  requestData: getTraceRequestData(trace.trace),
1615
+ requestDataJson: trace.trace?.requestDataJson,
1234
1616
  result: getTraceResultData(trace.trace),
1617
+ resultJson: trace.trace?.resultJson,
1235
1618
  startedAt: trace.startedAt,
1236
1619
  statusCode: trace.trace?.statusCode,
1237
1620
  statusLabel: trace.status,
@@ -1239,67 +1622,232 @@ const ApiPanel = ({ session }: { session: TProfilerNavigationSession }) => {
1239
1622
  }));
1240
1623
  const requestItems = [...syncItems, ...asyncItems];
1241
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
+ });
1242
1652
 
1243
1653
  React.useEffect(() => {
1244
1654
  if (requestItems.some((item) => item.id === selectedRequestId)) return;
1245
1655
  setSelectedRequestId(requestItems[0]?.id);
1246
1656
  }, [requestItems, selectedRequestId]);
1247
1657
 
1658
+ const waterfallItems = buildApiWaterfallItems(requestItems);
1248
1659
  const selectedItem = requestItems.find((item) => item.id === selectedRequestId) || requestItems[0];
1249
1660
 
1250
1661
  return (
1251
1662
  <div className="proteum-profiler__requestWorkspace">
1252
- <div className="proteum-profiler__requestGroups">
1253
- <div className="proteum-profiler__requestGroup">
1254
- <div className="proteum-profiler__requestGroupHeader">
1255
- <div className="proteum-profiler__sectionTitle">Synchronous calls</div>
1256
- <div className="proteum-profiler__requestGroupCount">
1257
- {syncItems.length} item{syncItems.length === 1 ? '' : 's'}
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
+
1685
+ <WaterfallChart
1686
+ emptyLabel="No API requests were captured for this session."
1687
+ itemLabel="request"
1688
+ items={waterfallItems}
1689
+ onSelect={setSelectedRequestId}
1690
+ />
1691
+
1692
+ <div className="proteum-profiler__requestGroups">
1693
+ <div className="proteum-profiler__requestGroup">
1694
+ <div className="proteum-profiler__requestGroupHeader">
1695
+ <div className="proteum-profiler__sectionTitle">Synchronous calls</div>
1696
+ <div className="proteum-profiler__requestGroupCount">
1697
+ {syncItems.length} item{syncItems.length === 1 ? '' : 's'}
1698
+ </div>
1258
1699
  </div>
1700
+
1701
+ {syncItems.length === 0 ? (
1702
+ <div className="proteum-profiler__empty">No synchronous SSR or batched API calls captured.</div>
1703
+ ) : (
1704
+ <div className="proteum-profiler__list">
1705
+ {syncItems.map((item) => (
1706
+ <ApiRequestListEntry
1707
+ isSelected={item.id === selectedItem?.id}
1708
+ item={item}
1709
+ key={item.id}
1710
+ onSelect={() => setSelectedRequestId(item.id)}
1711
+ />
1712
+ ))}
1713
+ </div>
1714
+ )}
1259
1715
  </div>
1260
1716
 
1261
- {syncItems.length === 0 ? (
1262
- <div className="proteum-profiler__empty">No synchronous SSR or batched API calls captured.</div>
1263
- ) : (
1264
- <div className="proteum-profiler__list">
1265
- {syncItems.map((item) => (
1266
- <ApiRequestListEntry
1267
- isSelected={item.id === selectedItem?.id}
1268
- item={item}
1269
- key={item.id}
1270
- onSelect={() => setSelectedRequestId(item.id)}
1271
- />
1272
- ))}
1717
+ <div className="proteum-profiler__requestGroup">
1718
+ <div className="proteum-profiler__requestGroupHeader">
1719
+ <div className="proteum-profiler__sectionTitle">Async requests</div>
1720
+ <div className="proteum-profiler__requestGroupCount">
1721
+ {asyncItems.length} item{asyncItems.length === 1 ? '' : 's'}
1722
+ </div>
1273
1723
  </div>
1274
- )}
1275
- </div>
1276
1724
 
1277
- <div className="proteum-profiler__requestGroup">
1278
- <div className="proteum-profiler__requestGroupHeader">
1279
- <div className="proteum-profiler__sectionTitle">Async requests</div>
1280
- <div className="proteum-profiler__requestGroupCount">
1281
- {asyncItems.length} item{asyncItems.length === 1 ? '' : 's'}
1282
- </div>
1725
+ {asyncItems.length === 0 ? (
1726
+ <div className="proteum-profiler__empty">No async API calls captured.</div>
1727
+ ) : (
1728
+ <div className="proteum-profiler__list">
1729
+ {asyncItems.map((item) => (
1730
+ <ApiRequestListEntry
1731
+ isSelected={item.id === selectedItem?.id}
1732
+ item={item}
1733
+ key={item.id}
1734
+ onSelect={() => setSelectedRequestId(item.id)}
1735
+ />
1736
+ ))}
1737
+ </div>
1738
+ )}
1283
1739
  </div>
1740
+ </div>
1741
+ </div>
1742
+
1743
+ <ApiRequestSidebar item={selectedItem} />
1744
+ </div>
1745
+ );
1746
+ };
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>
1284
1813
 
1285
- {asyncItems.length === 0 ? (
1286
- <div className="proteum-profiler__empty">No async API calls captured.</div>
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>
1287
1824
  ) : (
1288
- <div className="proteum-profiler__list">
1289
- {asyncItems.map((item) => (
1290
- <ApiRequestListEntry
1291
- isSelected={item.id === selectedItem?.id}
1292
- item={item}
1293
- key={item.id}
1294
- onSelect={() => setSelectedRequestId(item.id)}
1295
- />
1296
- ))}
1297
- </div>
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
+ ))
1298
1846
  )}
1299
1847
  </div>
1300
1848
  </div>
1301
1849
 
1302
- <ApiRequestSidebar item={selectedItem} />
1850
+ <SqlQuerySidebar item={selectedItem} />
1303
1851
  </div>
1304
1852
  );
1305
1853
  };
@@ -1315,6 +1863,8 @@ const TraceEventSidebar = ({
1315
1863
  label: string;
1316
1864
  trace?: TRequestTrace;
1317
1865
  }) => {
1866
+ const detailEntries = Object.entries(event?.details || {});
1867
+
1318
1868
  if (!event) {
1319
1869
  return (
1320
1870
  <aside className="proteum-profiler__sidebar">
@@ -1353,19 +1903,23 @@ const TraceEventSidebar = ({
1353
1903
  <SummaryRow label="Trace" value={trace?.id || 'n/a'} />
1354
1904
  </div>
1355
1905
 
1356
- <div className="proteum-profiler__sidebarSection">
1357
- <div className="proteum-profiler__sidebarSectionTitle">Summary</div>
1358
- <div className="proteum-profiler__tags">
1359
- {Object.entries(event.details).map(([key, value]) => (
1360
- <span className="proteum-profiler__tag" key={`${trace?.id || 'trace'}:${event.index}:detail:${key}`}>
1361
- {key}:{truncate(renderSummaryValue(value), 72)}
1362
- </span>
1363
- ))}
1906
+ {detailEntries.length > 0 ? (
1907
+ <div className="proteum-profiler__sidebarSection">
1908
+ <div className="proteum-profiler__sidebarSectionTitle">Summary</div>
1909
+ <div>
1910
+ {detailEntries.map(([key, value]) => (
1911
+ <SummaryRow
1912
+ key={`${trace?.id || 'trace'}:${event.index}:detail:${key}`}
1913
+ label={key}
1914
+ value={<span className="proteum-profiler__mono">{truncate(renderSummaryValue(value), 120)}</span>}
1915
+ />
1916
+ ))}
1917
+ </div>
1364
1918
  </div>
1365
- </div>
1919
+ ) : null}
1366
1920
 
1367
1921
  <div className="proteum-profiler__sidebarSection">
1368
- <div className="proteum-profiler__sidebarSectionTitle">Details</div>
1922
+ <div className="proteum-profiler__sidebarSectionTitle">Raw JSON</div>
1369
1923
  <JsonCodeBlock value={formatTraceEventDetailsJson(event.details)} />
1370
1924
  </div>
1371
1925
  </div>
@@ -1407,6 +1961,12 @@ const TraceRows = ({
1407
1961
  </div>
1408
1962
  <div className="proteum-profiler__tags">
1409
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}
1410
1970
  {call.fetcherId ? <span className="proteum-profiler__tag">fetcher:{call.fetcherId}</span> : null}
1411
1971
  {call.requestDataKeys.map((key) => (
1412
1972
  <span className="proteum-profiler__tag" key={`${call.id}:req:${key}`}>
@@ -1565,26 +2125,22 @@ const escapeHtml = (value: string) =>
1565
2125
  .replace(/'/g, '&#39;');
1566
2126
 
1567
2127
  const timelineWaterfallMinDurationMs = 6;
1568
- const timelineWaterfallBarHeight = 15;
1569
- const timelineWaterfallRowGap = 1;
1570
- const timelineWaterfallRowHeight = timelineWaterfallBarHeight + timelineWaterfallRowGap;
1571
-
1572
- const TimelineChart = ({ session }: { session: TProfilerNavigationSession }) => {
1573
- const [ApexChartComponent, setApexChartComponent] = React.useState<unknown>(null);
2128
+ const waterfallBarHeight = 15;
2129
+ const waterfallRowGap = 1;
2130
+ const waterfallRowHeight = waterfallBarHeight + waterfallRowGap;
1574
2131
 
1575
- React.useEffect(() => {
1576
- let isDisposed = false;
1577
-
1578
- void import('react-apexcharts').then((module) => {
1579
- if (isDisposed) return;
1580
- setApexChartComponent(() => module.default);
1581
- });
1582
-
1583
- return () => {
1584
- isDisposed = true;
1585
- };
1586
- }, []);
2132
+ const buildWaterfallEndMs = ({ durationMs, fallbackEndMs, finishedAt, startMs }: {
2133
+ durationMs?: number;
2134
+ fallbackEndMs?: number;
2135
+ finishedAt?: string;
2136
+ startMs: number;
2137
+ }) => {
2138
+ const finishedMs = readDateMs(finishedAt);
2139
+ const durationEndMs = durationMs !== undefined ? startMs + Math.max(durationMs, 1) : undefined;
2140
+ return Math.max(startMs + 1, fallbackEndMs ?? finishedMs ?? durationEndMs ?? startMs + 1);
2141
+ };
1587
2142
 
2143
+ const buildTimelineWaterfallItems = (session: TProfilerNavigationSession): TWaterfallChartItem[] => {
1588
2144
  const sessionStartMs = readDateMs(session.startedAt) ?? 0;
1589
2145
  const rawItems = session.traces.flatMap((traceItem) => {
1590
2146
  const trace = traceItem.trace;
@@ -1594,17 +2150,21 @@ const TimelineChart = ({ session }: { session: TProfilerNavigationSession }) =>
1594
2150
  const traceFinishedMs = readDateMs(trace.finishedAt) ?? (trace.durationMs !== undefined ? traceStartMs + trace.durationMs : undefined);
1595
2151
  const traceLabel = formatSessionTraceDisplay(traceItem);
1596
2152
 
1597
- return trace.events.map((event, index): Omit<TTimelineWaterfallEventItem, 'chartLabel' | 'color' | 'endOffsetMs' | 'startOffsetMs'> => {
2153
+ return trace.events.map((event, index) => {
1598
2154
  const nextEvent = trace.events[index + 1];
1599
2155
  const startMs = readDateMs(event.at) ?? traceStartMs + event.elapsedMs;
1600
2156
  const nextStartMs = nextEvent ? readDateMs(nextEvent.at) ?? traceStartMs + nextEvent.elapsedMs : undefined;
1601
- const endMs = Math.max(startMs + 1, nextStartMs ?? traceFinishedMs ?? startMs + 1);
2157
+ const endMs = buildWaterfallEndMs({
2158
+ fallbackEndMs: nextStartMs ?? traceFinishedMs,
2159
+ startMs,
2160
+ });
1602
2161
 
1603
2162
  return {
1604
2163
  durationMs: Math.max(1, endMs - startMs),
1605
2164
  endMs,
1606
2165
  event,
1607
2166
  startMs,
2167
+ trace,
1608
2168
  traceLabel,
1609
2169
  };
1610
2170
  });
@@ -1612,139 +2172,986 @@ const TimelineChart = ({ session }: { session: TProfilerNavigationSession }) =>
1612
2172
 
1613
2173
  const sortedItems = [...rawItems].sort((left, right) => left.startMs - right.startMs || left.event.index - right.event.index);
1614
2174
  const chartStartMs = sortedItems.length > 0 ? Math.min(...sortedItems.map((item) => item.startMs)) : 0;
1615
- const chartEndMs = sortedItems.length > 0 ? Math.max(...sortedItems.map((item) => item.endMs)) : chartStartMs + 1;
1616
- const totalDurationMs = Math.max(chartEndMs - chartStartMs, 1);
1617
- const items: TTimelineWaterfallEventItem[] = sortedItems
2175
+
2176
+ return sortedItems
1618
2177
  .filter((item) => item.durationMs >= timelineWaterfallMinDurationMs)
1619
- .map((item) => ({
1620
- ...item,
1621
- chartLabel: truncate(`${item.event.type} | ${item.traceLabel}`, 84),
2178
+ .map((item) => {
2179
+ const startOffsetMs = item.startMs - chartStartMs;
2180
+ const endOffsetMs = item.endMs - chartStartMs;
2181
+
2182
+ return {
2183
+ barLabel: truncate(`${item.event.type} | ${item.traceLabel}`, 84),
2184
+ color: getTimelineDurationColor(item.durationMs),
2185
+ detailLines: [
2186
+ `Start: +${Math.round(startOffsetMs)} ms`,
2187
+ `End: +${Math.round(endOffsetMs)} ms`,
2188
+ `Span: ${formatDuration(item.durationMs)}`,
2189
+ ],
2190
+ endOffsetMs,
2191
+ id: getTraceEventKey(item.trace.id, item.event),
2192
+ startOffsetMs,
2193
+ subtitle: item.traceLabel,
2194
+ title: item.event.type,
2195
+ };
2196
+ });
2197
+ };
2198
+
2199
+ const buildApiWaterfallItems = (requestItems: TApiRequestItem[]): TWaterfallChartItem[] => {
2200
+ const rawItems = requestItems.map((item) => {
2201
+ const startMs = readDateMs(item.startedAt) ?? 0;
2202
+ const endMs = buildWaterfallEndMs({
2203
+ durationMs: item.durationMs,
2204
+ finishedAt: item.finishedAt,
2205
+ startMs,
2206
+ });
2207
+ const statusText = getRequestStatusText(item.statusCode, item.statusLabel);
2208
+ const reference = formatApiReference(item.method, item.path, item.requestData, item.label);
2209
+
2210
+ return {
2211
+ endMs,
2212
+ item,
2213
+ reference,
2214
+ startMs,
2215
+ statusText,
2216
+ };
2217
+ });
2218
+
2219
+ const sortedItems = [...rawItems].sort((left, right) => left.startMs - right.startMs || left.reference.localeCompare(right.reference));
2220
+ const chartStartMs = sortedItems.length > 0 ? Math.min(...sortedItems.map((item) => item.startMs)) : 0;
2221
+
2222
+ return sortedItems.map(({ endMs, item, reference, startMs, statusText }) => {
2223
+ const startOffsetMs = startMs - chartStartMs;
2224
+ const endOffsetMs = endMs - chartStartMs;
2225
+
2226
+ return {
2227
+ barLabel: truncate(reference, 84),
1622
2228
  color: getTimelineDurationColor(item.durationMs),
1623
- endOffsetMs: item.endMs - chartStartMs,
1624
- startOffsetMs: item.startMs - chartStartMs,
1625
- }));
1626
- const chartHeight = Math.max(260, items.length * timelineWaterfallRowHeight + 24);
1627
- const ChartComponent = ApexChartComponent as any;
1628
-
1629
- const series = [
1630
- {
1631
- data: items.map((item) => ({
1632
- fillColor: item.color,
1633
- x: item.chartLabel,
1634
- y: [item.startOffsetMs, item.endOffsetMs],
1635
- })),
1636
- name: 'Timeline events',
2229
+ detailLines: [
2230
+ `Status: ${statusText}`,
2231
+ `Duration: ${formatDuration(item.durationMs)}`,
2232
+ `Start: +${Math.round(startOffsetMs)} ms`,
2233
+ `End: +${Math.round(endOffsetMs)} ms`,
2234
+ ],
2235
+ endOffsetMs,
2236
+ id: item.id,
2237
+ startOffsetMs,
2238
+ subtitle: item.groupLabel,
2239
+ title: reference,
2240
+ };
2241
+ });
2242
+ };
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
+
2317
+ const WaterfallChart = ({
2318
+ emptyLabel,
2319
+ itemLabel,
2320
+ items,
2321
+ onSelect,
2322
+ }: {
2323
+ emptyLabel: string;
2324
+ itemLabel: string;
2325
+ items: TWaterfallChartItem[];
2326
+ onSelect?: (itemId: string) => void;
2327
+ }) => {
2328
+ const totalDurationMs = Math.max(items.length > 0 ? Math.max(...items.map((item) => item.endOffsetMs)) : 1, 1);
2329
+ const chartHeight = Math.max(260, items.length * waterfallRowHeight + 24);
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',
2379
+ },
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;
2446
+
2447
+ return (
2448
+ <div className="proteum-profiler__section">
2449
+ <div className="proteum-profiler__timelineChart">
2450
+ <div className="proteum-profiler__timelineChartMeta">
2451
+ <span className="proteum-profiler__mono proteum-profiler__muted">
2452
+ {items.length} {pluralizeCountLabel(itemLabel, items.length)}
2453
+ </span>
2454
+ <span className="proteum-profiler__mono proteum-profiler__muted">{formatDuration(totalDurationMs)}</span>
2455
+ </div>
2456
+
2457
+ <div className="proteum-profiler__timelineChartCanvas" style={{ height: `${chartHeight}px` }}>
2458
+ {options ? (
2459
+ <ApexChart emptyLabel={emptyLabel} options={options} />
2460
+ ) : (
2461
+ <div className="proteum-profiler__empty">{emptyLabel}</div>
2462
+ )}
2463
+ </div>
2464
+ </div>
2465
+ </div>
2466
+ );
2467
+ };
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,
1637
2515
  },
1638
- ];
1639
-
1640
- const options = {
1641
- chart: {
1642
- animations: { enabled: false },
1643
- background: 'transparent',
1644
- foreColor: '#627186',
1645
- fontFamily: 'ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", monospace',
1646
- toolbar: { show: false },
1647
- type: 'rangeBar',
1648
- zoom: { enabled: false },
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,
1649
2540
  },
1650
- dataLabels: {
1651
- enabled: false,
2541
+ },
2542
+ yaxis: {
2543
+ labels: {
2544
+ style: { colors: profilerChartTheme.text, fontSize: '11px' },
1652
2545
  },
1653
- fill: {
1654
- opacity: 1,
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,
1655
2596
  },
1656
- grid: {
1657
- borderColor: 'rgba(19, 32, 51, 0.08)',
1658
- padding: { bottom: 0, left: 0, right: 0, top: 4 },
1659
- xaxis: { lines: { show: true } },
1660
- yaxis: { lines: { show: false } },
2597
+ },
2598
+ series,
2599
+ stroke: { width: 1 },
2600
+ title: {
2601
+ style: {
2602
+ color: profilerChartTheme.text,
2603
+ fontSize: '12px',
2604
+ fontWeight: 700,
1661
2605
  },
1662
- legend: {
1663
- show: false,
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,
1664
2617
  },
1665
- noData: {
2618
+ },
2619
+ yaxis: {
2620
+ labels: { style: { colors: profilerChartTheme.muted, fontSize: '11px' } },
2621
+ title: {
1666
2622
  style: {
1667
- color: '#627186',
1668
- fontFamily: 'ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", monospace',
1669
- fontSize: '11px',
2623
+ color: profilerChartTheme.muted,
2624
+ fontSize: '10px',
2625
+ fontWeight: 600,
1670
2626
  },
1671
- text: 'No timeline events captured.',
2627
+ text: valueUnit,
1672
2628
  },
1673
- plotOptions: {
1674
- bar: {
1675
- barHeight: timelineWaterfallBarHeight,
1676
- borderRadius: 2,
1677
- horizontal: true,
1678
- rangeBarGroupRows: false,
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,
1679
2761
  },
2762
+ text: xaxisTitle,
1680
2763
  },
1681
- stroke: {
1682
- colors: ['#ffffff'],
1683
- width: 1,
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,
1684
2775
  },
1685
- tooltip: {
1686
- custom: ({ dataPointIndex }: { dataPointIndex: number }) => {
1687
- const item = items[dataPointIndex];
1688
- if (!item) return '';
1689
-
1690
- return `
1691
- <div style="padding:8px 10px; color:#132033; font-family:ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, Liberation Mono, monospace; font-size:11px; line-height:1.5;">
1692
- <div style="font-weight:700;">${escapeHtml(item.event.type)}</div>
1693
- <div style="color:#627186;">${escapeHtml(item.traceLabel)}</div>
1694
- <div style="margin-top:6px; color:#627186;">Start: +${Math.round(item.startOffsetMs)} ms</div>
1695
- <div style="color:#627186;">End: +${Math.round(item.endOffsetMs)} ms</div>
1696
- <div style="color:#627186;">Span: ${escapeHtml(formatDuration(item.durationMs))}</div>
1697
- </div>
1698
- `;
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,
1699
3098
  },
1700
3099
  },
1701
3100
  xaxis: {
1702
3101
  axisBorder: { show: false },
1703
3102
  axisTicks: { show: false },
1704
- labels: {
1705
- formatter: (value: string | number) => `${Math.round(Number(value))} ms`,
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: {
1706
3108
  style: {
1707
- colors: '#627186',
1708
- fontFamily: 'ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", monospace',
3109
+ color: profilerChartTheme.muted,
1709
3110
  fontSize: '10px',
3111
+ fontWeight: 600,
1710
3112
  },
1711
- },
1712
- max: totalDurationMs,
1713
- min: 0,
1714
- tickAmount: Math.min(6, Math.max(2, items.length > 0 ? 6 : 2)),
1715
- type: 'numeric',
1716
- },
1717
- yaxis: {
1718
- show: false,
1719
- labels: {
1720
- show: false,
3113
+ text: 'Percent vs baseline',
1721
3114
  },
1722
3115
  },
1723
3116
  };
3117
+ };
1724
3118
 
1725
- return (
1726
- <div className="proteum-profiler__section">
1727
- <div className="proteum-profiler__timelineChart">
1728
- <div className="proteum-profiler__timelineChartMeta">
1729
- <span className="proteum-profiler__mono proteum-profiler__muted">
1730
- {items.length} event{items.length === 1 ? '' : 's'}
1731
- </span>
1732
- <span className="proteum-profiler__mono proteum-profiler__muted">{formatDuration(totalDurationMs)}</span>
1733
- </div>
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
+ };
1734
3135
 
1735
- <div className="proteum-profiler__timelineChartCanvas" style={{ height: `${chartHeight}px` }}>
1736
- {ChartComponent && items.length > 0 ? (
1737
- <ChartComponent height={chartHeight} options={options} series={series} type="rangeBar" width="100%" />
1738
- ) : items.length > 0 ? (
1739
- <div className="proteum-profiler__empty">Loading waterfall chart...</div>
1740
- ) : (
1741
- <div className="proteum-profiler__empty">No timeline events were captured for this session.</div>
1742
- )}
1743
- </div>
1744
- </div>
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>
1745
3151
  </div>
1746
- );
1747
- };
3152
+ <ApexChart emptyLabel={emptyLabel} options={options} />
3153
+ </div>
3154
+ );
1748
3155
 
1749
3156
  const TimelinePanel = ({ session }: { session: TProfilerNavigationSession }) => {
1750
3157
  const selections: TTraceEventInspectorSelection[] = session.traces.flatMap((traceItem) =>
@@ -1764,67 +3171,72 @@ const TimelinePanel = ({ session }: { session: TProfilerNavigationSession }) =>
1764
3171
  setSelectedEventKey(selections[0]?.key);
1765
3172
  }, [selectedEventKey, selections]);
1766
3173
 
3174
+ const waterfallItems = buildTimelineWaterfallItems(session);
1767
3175
  const selected = selections.find((selection) => selection.key === selectedEventKey) || selections[0];
1768
3176
 
1769
3177
  return (
1770
- <div className="proteum-profiler__splitColumn">
1771
- <TimelineChart session={session} />
1772
- <div className="proteum-profiler__splitView proteum-profiler__splitView--stacked">
1773
- <div className="proteum-profiler__splitColumn">
1774
- <div className="proteum-profiler__section">
1775
- <div className="proteum-profiler__titleRow">
1776
- <div className="proteum-profiler__sectionTitle">Navigation steps</div>
1777
- </div>
1778
- <div className="proteum-profiler__list">
1779
- {session.steps.map((step) => (
1780
- <div className="proteum-profiler__row" key={step.id}>
1781
- <div className="proteum-profiler__rowHeader">
1782
- <strong>{step.label}</strong>
1783
- <span className="proteum-profiler__mono proteum-profiler__muted">{formatDuration(step.durationMs)}</span>
1784
- </div>
1785
- <div className="proteum-profiler__tags">
1786
- <span className="proteum-profiler__tag">{step.status}</span>
1787
- {Object.entries(step.details || {}).map(([key, value]) => (
1788
- <span className="proteum-profiler__tag" key={`${step.id}:${key}`}>
1789
- {key}:{String(value)}
1790
- </span>
1791
- ))}
1792
- {step.errorMessage ? <span className="proteum-profiler__tag">{truncate(step.errorMessage, 72)}</span> : null}
1793
- </div>
1794
- </div>
1795
- ))}
1796
- </div>
1797
- </div>
3178
+ <div className="proteum-profiler__splitView">
3179
+ <div className="proteum-profiler__splitColumn">
3180
+ <WaterfallChart
3181
+ emptyLabel="No timeline events were captured for this session."
3182
+ itemLabel="event"
3183
+ items={waterfallItems}
3184
+ onSelect={setSelectedEventKey}
3185
+ />
1798
3186
 
1799
- {session.traces.map((traceItem) =>
1800
- traceItem.trace ? (
1801
- <TraceRows
1802
- key={traceItem.id}
1803
- onSelect={setSelectedEventKey}
1804
- selectedEventKey={selectedEventKey}
1805
- trace={traceItem.trace}
1806
- />
1807
- ) : (
1808
- <div className="proteum-profiler__row" key={traceItem.id}>
1809
- <div className="proteum-profiler__rowHeader">
1810
- <strong>{formatSessionTraceDisplay(traceItem)}</strong>
1811
- <span className="proteum-profiler__mono proteum-profiler__muted">{traceItem.status}</span>
3187
+ <div className="proteum-profiler__section">
3188
+ <div className="proteum-profiler__titleRow">
3189
+ <div className="proteum-profiler__sectionTitle">Navigation steps</div>
3190
+ </div>
3191
+ <div className="proteum-profiler__list">
3192
+ {session.steps.map((step) => (
3193
+ <div className="proteum-profiler__row" key={step.id}>
3194
+ <div className="proteum-profiler__rowHeader">
3195
+ <strong>{step.label}</strong>
3196
+ <span className="proteum-profiler__mono proteum-profiler__muted">{formatDuration(step.durationMs)}</span>
1812
3197
  </div>
1813
- <div className="proteum-profiler__mono">
1814
- {formatProfilerRequestReference({
1815
- fallbackLabel: traceItem.label,
1816
- method: traceItem.method,
1817
- path: traceItem.path,
1818
- requestData: getTraceRequestData(traceItem.trace),
1819
- })}
3198
+ <div className="proteum-profiler__tags">
3199
+ <span className="proteum-profiler__tag">{step.status}</span>
3200
+ {Object.entries(step.details || {}).map(([key, value]) => (
3201
+ <span className="proteum-profiler__tag" key={`${step.id}:${key}`}>
3202
+ {key}:{String(value)}
3203
+ </span>
3204
+ ))}
3205
+ {step.errorMessage ? <span className="proteum-profiler__tag">{truncate(step.errorMessage, 72)}</span> : null}
1820
3206
  </div>
1821
3207
  </div>
1822
- ),
1823
- )}
3208
+ ))}
3209
+ </div>
1824
3210
  </div>
1825
3211
 
1826
- <TraceEventSidebar event={selected?.event} label={selected?.label || 'Trace event'} trace={selected?.trace} />
3212
+ {session.traces.map((traceItem) =>
3213
+ traceItem.trace ? (
3214
+ <TraceRows
3215
+ key={traceItem.id}
3216
+ onSelect={setSelectedEventKey}
3217
+ selectedEventKey={selectedEventKey}
3218
+ trace={traceItem.trace}
3219
+ />
3220
+ ) : (
3221
+ <div className="proteum-profiler__row" key={traceItem.id}>
3222
+ <div className="proteum-profiler__rowHeader">
3223
+ <strong>{formatSessionTraceDisplay(traceItem)}</strong>
3224
+ <span className="proteum-profiler__mono proteum-profiler__muted">{traceItem.status}</span>
3225
+ </div>
3226
+ <div className="proteum-profiler__mono">
3227
+ {formatProfilerRequestReference({
3228
+ fallbackLabel: traceItem.label,
3229
+ method: traceItem.method,
3230
+ path: traceItem.path,
3231
+ requestData: getTraceRequestData(traceItem.trace),
3232
+ })}
3233
+ </div>
3234
+ </div>
3235
+ ),
3236
+ )}
1827
3237
  </div>
3238
+
3239
+ <TraceEventSidebar event={selected?.event} label={selected?.label || 'Trace event'} trace={selected?.trace} />
1828
3240
  </div>
1829
3241
  );
1830
3242
  };
@@ -1836,6 +3248,46 @@ const AuthPanel = ({ session }: { session: TProfilerNavigationSession }) => {
1836
3248
  ? [{ authEvents, id: traceItem.id, label: formatSessionTraceDisplay(traceItem), trace: traceItem.trace }]
1837
3249
  : [];
1838
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
+ });
1839
3291
  const selections: TTraceEventInspectorSelection[] = authSections.flatMap((section) =>
1840
3292
  section.authEvents.map((event) => ({
1841
3293
  event,
@@ -1858,6 +3310,27 @@ const AuthPanel = ({ session }: { session: TProfilerNavigationSession }) => {
1858
3310
  return (
1859
3311
  <div className="proteum-profiler__splitView">
1860
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
+
1861
3334
  {authSections.map((section) => (
1862
3335
  <AuthTraceSection
1863
3336
  authEvents={section.authEvents}
@@ -1936,24 +3409,101 @@ const renderPanel = (panel: TProfilerPanel, session: TProfilerNavigationSession,
1936
3409
  const primaryTrace = summary.primaryTrace?.trace;
1937
3410
 
1938
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
+
1939
3459
  return (
1940
- <div className="proteum-profiler__metrics">
1941
- <SummaryRow label="Session" value={session.label} />
1942
- <SummaryRow label="Status" value={summary.statusLabel} />
1943
- <SummaryRow label="Duration" value={formatDuration(summary.totalMs)} />
1944
- <SummaryRow label="Route" value={summary.routeLabel} />
1945
- <SummaryRow
1946
- label="SSR"
1947
- value={
1948
- summary.ssrPayloadBytes !== undefined
1949
- ? `${formatDuration(summary.renderMs)} | ${formatBytes(summary.ssrPayloadBytes)}`
1950
- : formatDuration(summary.renderMs)
1951
- }
1952
- />
1953
- <SummaryRow label="API" value={`sync ${summary.apiSyncCount} / async ${summary.apiAsyncCount}`} />
1954
- <SummaryRow label="Errors" value={String(summary.errorCount)} />
1955
- <SummaryRow label="Request" value={session.requestId || 'client-only'} />
1956
- </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
+ </>
1957
3507
  );
1958
3508
  }
1959
3509
 
@@ -1961,66 +3511,435 @@ const renderPanel = (panel: TProfilerPanel, session: TProfilerNavigationSession,
1961
3511
  return <TimelinePanel session={session} />;
1962
3512
  }
1963
3513
 
1964
- if (panel === 'auth') {
1965
- return <AuthPanel session={session} />;
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
+
3709
+ if (panel === 'auth') {
3710
+ return <AuthPanel session={session} />;
1966
3711
  }
1967
3712
 
1968
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
+
1969
3762
  return (
1970
- <SimpleSection
1971
- empty="No routing data captured yet."
1972
- rows={findTraceEvents(primaryTrace, [
1973
- 'resolve.start',
1974
- 'resolve.controller-route',
1975
- 'resolve.route-match',
1976
- 'resolve.routes-evaluated',
1977
- 'resolve.not-found',
1978
- ]).map((event) => ({
1979
- key: `${event.index}:${event.type}`,
1980
- title: event.type,
1981
- value: Object.entries(event.details)
1982
- .map(([key, value]) => `${key}=${renderSummaryValue(value)}`)
1983
- .join(' '),
1984
- }))}
1985
- showTitle={false}
1986
- title="Routing"
1987
- />
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
+ </>
1988
3798
  );
1989
3799
  }
1990
3800
 
1991
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
+
1992
3829
  return (
1993
- <SimpleSection
1994
- empty="No controller data captured yet."
1995
- rows={findTraceEvents(primaryTrace, ['controller.start', 'controller.result', 'setup.options', 'context.create']).map(
1996
- (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) => ({
1997
3855
  key: `${event.index}:${event.type}`,
1998
3856
  title: event.type,
1999
3857
  value: Object.entries(event.details)
2000
3858
  .map(([key, value]) => `${key}=${renderSummaryValue(value)}`)
2001
3859
  .join(' '),
2002
- }),
2003
- )}
2004
- showTitle={false}
2005
- title="Controller"
2006
- />
3860
+ }))}
3861
+ showTitle={false}
3862
+ title="Controller"
3863
+ />
3864
+ </>
2007
3865
  );
2008
3866
  }
2009
3867
 
2010
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
+
2011
3907
  return (
2012
- <SimpleSection
2013
- empty="No SSR data captured for this session."
2014
- rows={findTraceEvents(primaryTrace, ['page.data', 'ssr.payload', 'render.start', 'render.end']).map((event) => ({
2015
- key: `${event.index}:${event.type}`,
2016
- title: event.type,
2017
- value: Object.entries(event.details)
2018
- .map(([key, value]) => `${key}=${renderSummaryValue(value)}`)
2019
- .join(' '),
2020
- }))}
2021
- showTitle={false}
2022
- title="SSR"
2023
- />
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
+ </>
2024
3943
  );
2025
3944
  }
2026
3945
 
@@ -2028,6 +3947,147 @@ const renderPanel = (panel: TProfilerPanel, session: TProfilerNavigationSession,
2028
3947
  return <ApiPanel session={session} />;
2029
3948
  }
2030
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
+
2031
4091
  if (panel === 'explain') {
2032
4092
  const explain = state.explain;
2033
4093
  const blocks = explain.manifest
@@ -2036,6 +4096,74 @@ const renderPanel = (panel: TProfilerPanel, session: TProfilerNavigationSession,
2036
4096
  ...buildExplainBlocks(explain.manifest, [...explainSectionNames]),
2037
4097
  ]
2038
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;
2039
4167
 
2040
4168
  return (
2041
4169
  <div className="proteum-profiler__section">
@@ -2063,6 +4191,27 @@ const renderPanel = (panel: TProfilerPanel, session: TProfilerNavigationSession,
2063
4191
  <div className="proteum-profiler__empty">No explain manifest is available.</div>
2064
4192
  ) : (
2065
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
+
2066
4215
  <div className="proteum-profiler__row">
2067
4216
  <div className="proteum-profiler__rowHeader">
2068
4217
  <strong>Manifest snapshot</strong>
@@ -2083,6 +4232,50 @@ const renderPanel = (panel: TProfilerPanel, session: TProfilerNavigationSession,
2083
4232
 
2084
4233
  if (panel === 'doctor') {
2085
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
+ })) || [];
2086
4279
  const doctorRows =
2087
4280
  doctor.response?.diagnostics.map((diagnostic, index) => ({
2088
4281
  key: `${diagnostic.code}:${index}`,
@@ -2123,17 +4316,46 @@ const renderPanel = (panel: TProfilerPanel, session: TProfilerNavigationSession,
2123
4316
  <div className="proteum-profiler__metrics">
2124
4317
  <SummaryRow label="Errors" value={String(doctor.response.summary.errors)} />
2125
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
+ />
2126
4327
  <SummaryRow label="Strict" value={doctor.response.summary.strictFailed ? 'failed' : 'ok'} />
2127
4328
  <SummaryRow
2128
4329
  label="Refreshed"
2129
4330
  value={doctor.lastLoadedAt ? formatTimestamp(doctor.lastLoadedAt) : 'Not loaded'}
2130
4331
  />
2131
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>
2132
4353
  {doctorBlocks.length > 0 ? (
2133
4354
  <TextBlocks blocks={doctorBlocks} />
2134
4355
  ) : (
2135
4356
  <SimpleSection empty="No manifest diagnostics were found." rows={doctorRows} title="Diagnostics" />
2136
4357
  )}
4358
+ <SimpleSection empty="No contract diagnostics were found." rows={contractRows} title="Contracts" />
2137
4359
  </>
2138
4360
  )}
2139
4361
  </div>
@@ -2142,6 +4364,33 @@ const renderPanel = (panel: TProfilerPanel, session: TProfilerNavigationSession,
2142
4364
 
2143
4365
  if (panel === 'commands') {
2144
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
+ });
2145
4394
 
2146
4395
  return (
2147
4396
  <div className="proteum-profiler__section">
@@ -2180,65 +4429,88 @@ const renderPanel = (panel: TProfilerPanel, session: TProfilerNavigationSession,
2180
4429
  ) : commandsState.commands.length === 0 ? (
2181
4430
  <div className="proteum-profiler__empty">No commands are registered for this app.</div>
2182
4431
  ) : (
2183
- <div className="proteum-profiler__list">
2184
- {commandsState.commands.map((command: TDevCommandDefinition) => {
2185
- const execution = commandsState.executions[command.path] as TDevCommandExecution | undefined;
2186
- return (
2187
- <div className="proteum-profiler__row" key={command.path}>
2188
- <div className="proteum-profiler__rowHeader">
2189
- <strong>{command.path}</strong>
2190
- <div className="proteum-profiler__actions">
2191
- <span className="proteum-profiler__mono proteum-profiler__muted">
2192
- {execution ? formatTimestamp(execution.finishedAt) : 'Never run'}
2193
- </span>
2194
- <button
2195
- className="proteum-profiler__pill"
2196
- onClick={() => void profilerRuntime.runCommand(command.path)}
2197
- type="button"
2198
- >
2199
- Run now
2200
- </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
+ : ''}
2201
4493
  </div>
2202
- </div>
2203
4494
 
2204
- <div className="proteum-profiler__tags">
2205
- <span className="proteum-profiler__tag">{command.className}</span>
2206
- <span className="proteum-profiler__tag">{command.methodName}</span>
2207
- <span className="proteum-profiler__tag">{command.scope}</span>
2208
- {execution ? <span className="proteum-profiler__tag">{execution.status}</span> : null}
2209
4495
  {execution ? (
2210
- <span className="proteum-profiler__tag">{formatDuration(execution.durationMs)}</span>
2211
- ) : null}
2212
- {execution?.errorMessage ? (
2213
- <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>
2214
4508
  ) : null}
2215
4509
  </div>
2216
-
2217
- <div className="proteum-profiler__mono proteum-profiler__muted">
2218
- source {command.filepath}:{command.sourceLocation.line}:{command.sourceLocation.column}
2219
- {commandsState.lastLoadedAt
2220
- ? ` | refreshed ${formatTimestamp(commandsState.lastLoadedAt)}`
2221
- : ''}
2222
- </div>
2223
-
2224
- {execution ? (
2225
- <div className="proteum-profiler__section">
2226
- <div className="proteum-profiler__sectionTitle">Last result</div>
2227
- <JsonCodeBlock
2228
- value={
2229
- execution.result?.json !== undefined
2230
- ? formatStructuredValue(execution.result.json)
2231
- : execution.result
2232
- ? formatStructuredValue(execution.result.summary)
2233
- : execution.errorMessage || 'undefined'
2234
- }
2235
- />
2236
- </div>
2237
- ) : null}
2238
- </div>
2239
- );
2240
- })}
2241
- </div>
4510
+ );
4511
+ })}
4512
+ </div>
4513
+ </>
2242
4514
  )}
2243
4515
  </div>
2244
4516
  );
@@ -2246,6 +4518,30 @@ const renderPanel = (panel: TProfilerPanel, session: TProfilerNavigationSession,
2246
4518
 
2247
4519
  if (panel === 'cron') {
2248
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
+ });
2249
4545
 
2250
4546
  return (
2251
4547
  <div className="proteum-profiler__section">
@@ -2285,81 +4581,140 @@ const renderPanel = (panel: TProfilerPanel, session: TProfilerNavigationSession,
2285
4581
  ) : cron.tasks.length === 0 ? (
2286
4582
  <div className="proteum-profiler__empty">No cron tasks are registered for this app.</div>
2287
4583
  ) : (
2288
- <div className="proteum-profiler__list">
2289
- {cron.tasks.map((task) => (
2290
- <div className="proteum-profiler__row" key={task.name}>
2291
- <div className="proteum-profiler__rowHeader">
2292
- <strong>{task.name}</strong>
2293
- <div className="proteum-profiler__actions">
2294
- <span className="proteum-profiler__mono proteum-profiler__muted">
2295
- {task.running
2296
- ? 'Running...'
2297
- : task.lastRunFinishedAt
2298
- ? formatTimestamp(task.lastRunFinishedAt)
2299
- : 'Never run'}
2300
- </span>
2301
- <button
2302
- className="proteum-profiler__pill"
2303
- disabled={task.running}
2304
- onClick={() => void profilerRuntime.runCronTask(task.name)}
2305
- type="button"
2306
- >
2307
- {task.running ? 'Running...' : 'Run now'}
2308
- </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>
2309
4628
  </div>
2310
- </div>
2311
4629
 
2312
- <div className="proteum-profiler__tags">
2313
- <span className="proteum-profiler__tag">schedule:{truncate(formatCronFrequency(task), 64)}</span>
2314
- <span className="proteum-profiler__tag">
2315
- next:{task.nextInvocation ? formatTimestamp(task.nextInvocation) : 'none'}
2316
- </span>
2317
- <span className="proteum-profiler__tag">autoexec:{task.autoexec ? 'yes' : 'no'}</span>
2318
- <span className="proteum-profiler__tag">
2319
- automatic:{task.automaticExecution ? 'enabled' : 'disabled in dev'}
2320
- </span>
2321
- <span className="proteum-profiler__tag">runs:{task.runCount}</span>
2322
- {task.lastTrigger ? <span className="proteum-profiler__tag">trigger:{task.lastTrigger}</span> : null}
2323
- {task.lastRunStatus ? <span className="proteum-profiler__tag">{task.lastRunStatus}</span> : null}
2324
- {task.lastRunDurationMs !== undefined ? (
2325
- <span className="proteum-profiler__tag">{formatDuration(task.lastRunDurationMs)}</span>
2326
- ) : null}
2327
- {task.lastErrorMessage ? (
2328
- <span className="proteum-profiler__tag">{truncate(task.lastErrorMessage, 72)}</span>
2329
- ) : null}
2330
- </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>
2331
4649
 
2332
- <div className="proteum-profiler__mono proteum-profiler__muted">
2333
- registered {formatTimestamp(task.registeredAt)}
2334
- {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>
2335
4654
  </div>
2336
- </div>
2337
- ))}
2338
- </div>
4655
+ ))}
4656
+ </div>
4657
+ </>
2339
4658
  )}
2340
4659
  </div>
2341
4660
  );
2342
4661
  }
2343
4662
 
2344
- const errorRows = [
2345
- ...session.steps
4663
+ const stepErrors = session.steps
2346
4664
  .filter((step) => step.status === 'error')
2347
- .map((step) => ({ key: step.id, title: step.label, value: step.errorMessage || 'Step failed' })),
2348
- ...session.traces
4665
+ .map((step) => ({ key: step.id, title: step.label, value: step.errorMessage || 'Step failed' }));
4666
+ const traceErrors = session.traces
2349
4667
  .filter((trace) => trace.status === 'error')
2350
- .map((trace) => ({ key: trace.id, title: trace.label, value: trace.errorMessage || 'Request failed' })),
2351
- ...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) => ({
2352
4670
  key: `${event.index}:error`,
2353
4671
  title: event.type,
2354
4672
  value: Object.entries(event.details)
2355
4673
  .map(([key, value]) => `${key}=${renderSummaryValue(value)}`)
2356
4674
  .join(' '),
2357
- })),
2358
- ];
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
+ });
2359
4693
 
2360
- 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
+ );
2361
4714
  };
2362
4715
 
4716
+ const splitScrollPanels = new Set<TProfilerPanel>(['timeline', 'auth', 'api', 'sql']);
4717
+
2363
4718
  export default function DevProfiler() {
2364
4719
  const [state, setState] = React.useState(() => profilerRuntime.getState());
2365
4720
 
@@ -2388,6 +4743,7 @@ export default function DevProfiler() {
2388
4743
  const summary = getSummary(session);
2389
4744
  const tone = summary.errorCount > 0 ? 'error' : (summary.totalMs || 0) > 500 ? 'warn' : 'ok';
2390
4745
  const primaryTrace = summary.primaryTrace?.trace;
4746
+ const currentPerfRequest = primaryTrace ? buildRequestPerformance(primaryTrace) : undefined;
2391
4747
  const minimizedLabel =
2392
4748
  session.kind === 'client-navigation'
2393
4749
  ? session.label
@@ -2455,7 +4811,13 @@ export default function DevProfiler() {
2455
4811
  </div>
2456
4812
  </div>
2457
4813
 
2458
- <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>
2459
4821
  </div>
2460
4822
  ) : null}
2461
4823
 
@@ -2492,6 +4854,20 @@ export default function DevProfiler() {
2492
4854
  onClick={() => profilerRuntime.openPanel('api')}
2493
4855
  tone={summary.apiAsyncCount > 0 || summary.apiSyncCount > 0 ? 'ok' : 'warn'}
2494
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
+ />
2495
4871
  {summary.errorCount > 0 ? (
2496
4872
  <StatusToken
2497
4873
  label={`${summary.errorCount} error${summary.errorCount === 1 ? '' : 's'}`}
@@ -2499,6 +4875,11 @@ export default function DevProfiler() {
2499
4875
  tone="error"
2500
4876
  />
2501
4877
  ) : null}
4878
+ <StatusToken
4879
+ label="Diagnose"
4880
+ onClick={() => profilerRuntime.openPanel('diagnose')}
4881
+ tone={summary.errorCount > 0 ? 'error' : 'warn'}
4882
+ />
2502
4883
  <div className="proteum-profiler__spacer" />
2503
4884
  <button className="proteum-profiler__token" onClick={() => profilerRuntime.setUiState('pinned-handle')} type="button">
2504
4885
  Hide