@oneuptime/common 12.0.19 → 12.0.21

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 (124) hide show
  1. package/Models/AnalyticsModels/ChangeEvent.ts +331 -0
  2. package/Models/AnalyticsModels/Index.ts +2 -0
  3. package/Server/API/EnterpriseLicenseAPI.ts +94 -19
  4. package/Server/EnvironmentConfig.ts +15 -5
  5. package/Server/Infrastructure/Postgres/SchemaMigrations/1789000000000-AddUserSlackAndMicrosoftTeams.ts +42 -0
  6. package/Server/Infrastructure/Postgres/SchemaMigrations/Index.ts +0 -2
  7. package/Server/Services/ChangeEventService.ts +11 -0
  8. package/Server/Services/EnterpriseLicenseService.ts +3 -3
  9. package/Server/Services/Index.ts +2 -0
  10. package/Server/Services/ProjectService.ts +104 -0
  11. package/Server/Utils/Attribution.ts +11 -11
  12. package/Server/Utils/Marketing/MarketingEventWebhook.ts +4 -5
  13. package/Server/Utils/Response.ts +2 -2
  14. package/Server/Utils/StartServer.ts +2 -2
  15. package/Server/Utils/Telemetry/ChangeEventRow.ts +228 -0
  16. package/Server/Views/Partials/AnalyticsConsent.ejs +441 -0
  17. package/Tests/App/Dashboard/AlertEpisodeViewFields.test.tsx +465 -0
  18. package/Tests/App/Dashboard/DashboardChartWidgetZoom.test.tsx +366 -0
  19. package/Tests/App/Dashboard/DiscoveryScanWizardValidation.test.tsx +408 -0
  20. package/Tests/App/Dashboard/EventOverlayHook.test.tsx +249 -0
  21. package/Tests/App/Dashboard/IncidentEpisodeViewFields.test.tsx +474 -0
  22. package/Tests/App/Dashboard/InvestigationArtifacts.test.tsx +315 -0
  23. package/Tests/App/Dashboard/InvestigationDrawer.test.tsx +258 -0
  24. package/Tests/App/Dashboard/MetricChartsCompare.test.tsx +171 -0
  25. package/Tests/App/Dashboard/MetricSeriesInvestigateMenu.test.tsx +368 -0
  26. package/Tests/App/Dashboard/MetricViewCompare.test.tsx +233 -0
  27. package/Tests/Server/API/EnterpriseLicenseReportUserCount.test.ts +763 -0
  28. package/Tests/Server/Infrastructure/Postgres/AddUserSlackAndMicrosoftTeamsMigration.test.ts +236 -0
  29. package/Tests/Server/Services/ProjectServiceChangePlan.test.ts +138 -0
  30. package/Tests/Server/Services/ProjectServiceCreateSubscriptionStarted.test.ts +516 -0
  31. package/Tests/Server/Utils/AI/SRE/Insights/Detectors/ExceptionIdentity.test.ts +224 -0
  32. package/Tests/Server/Utils/Attribution.test.ts +4 -5
  33. package/Tests/Server/Utils/Marketing/MarketingEventUtil.test.ts +2 -2
  34. package/Tests/Server/Utils/Monitor/MonitorCriteriaExpectationBuilder.test.ts +300 -0
  35. package/Tests/Server/Utils/ResponseRenderAnalytics.test.ts +53 -0
  36. package/Tests/Server/Views/AnalyticsConsentPartial.test.ts +241 -0
  37. package/Tests/UI/Components/Charts/ChartGhostSeries.test.tsx +103 -0
  38. package/Tests/UI/Components/Charts/ChartTooltipSorted.test.tsx +129 -0
  39. package/Tests/UI/Components/Charts/TooltipEntries.test.ts +191 -0
  40. package/Tests/UI/Components/Detail/DetailFieldErrors.test.tsx +347 -0
  41. package/Tests/UI/Components/Forms/BasicFormStepValidation.test.tsx +392 -0
  42. package/Tests/UI/Components/Forms/ValidationFormSteps.test.ts +549 -0
  43. package/Tests/Utils/EnterpriseLicenseSync.test.ts +562 -0
  44. package/Tests/Utils/Telemetry/CrossSignalScope.test.ts +13 -2
  45. package/Types/Analytics/RevenueEvent.ts +1 -0
  46. package/Types/AnalyticsDatabase/AnalyticsTableName.ts +7 -0
  47. package/Types/Marketing/Attribution.ts +9 -15
  48. package/Types/Marketing/MarketingEvent.ts +19 -12
  49. package/UI/Components/Charts/Area/AreaChart.tsx +23 -1
  50. package/UI/Components/Charts/Bar/BarChart.tsx +8 -1
  51. package/UI/Components/Charts/ChartGroup/ChartGroup.tsx +16 -1
  52. package/UI/Components/Charts/ChartLibrary/AreaChart/AreaChart.tsx +208 -97
  53. package/UI/Components/Charts/ChartLibrary/BarChart/BarChart.tsx +26 -3
  54. package/UI/Components/Charts/ChartLibrary/LineChart/LineChart.tsx +232 -118
  55. package/UI/Components/Charts/ChartLibrary/Utils/TooltipEntries.ts +120 -0
  56. package/UI/Components/Charts/Line/LineChart.tsx +23 -1
  57. package/UI/Components/Detail/Detail.tsx +18 -1
  58. package/Utils/EnterpriseLicense/EnterpriseLicenseSync.ts +244 -0
  59. package/Utils/Metrics/MetricExplorerUrl.ts +119 -5
  60. package/Utils/Telemetry/CrossSignalScope.ts +19 -10
  61. package/build/dist/Models/AnalyticsModels/ChangeEvent.js +298 -0
  62. package/build/dist/Models/AnalyticsModels/ChangeEvent.js.map +1 -0
  63. package/build/dist/Models/AnalyticsModels/Index.js +2 -0
  64. package/build/dist/Models/AnalyticsModels/Index.js.map +1 -1
  65. package/build/dist/Server/API/EnterpriseLicenseAPI.js +66 -12
  66. package/build/dist/Server/API/EnterpriseLicenseAPI.js.map +1 -1
  67. package/build/dist/Server/EnvironmentConfig.js +14 -5
  68. package/build/dist/Server/EnvironmentConfig.js.map +1 -1
  69. package/build/dist/Server/Infrastructure/Postgres/SchemaMigrations/1789000000000-AddUserSlackAndMicrosoftTeams.js +39 -0
  70. package/build/dist/Server/Infrastructure/Postgres/SchemaMigrations/1789000000000-AddUserSlackAndMicrosoftTeams.js.map +1 -1
  71. package/build/dist/Server/Infrastructure/Postgres/SchemaMigrations/Index.js +0 -2
  72. package/build/dist/Server/Infrastructure/Postgres/SchemaMigrations/Index.js.map +1 -1
  73. package/build/dist/Server/Services/ChangeEventService.js +9 -0
  74. package/build/dist/Server/Services/ChangeEventService.js.map +1 -0
  75. package/build/dist/Server/Services/EnterpriseLicenseService.js +3 -3
  76. package/build/dist/Server/Services/Index.js +2 -0
  77. package/build/dist/Server/Services/Index.js.map +1 -1
  78. package/build/dist/Server/Services/ProjectService.js +88 -0
  79. package/build/dist/Server/Services/ProjectService.js.map +1 -1
  80. package/build/dist/Server/Utils/Attribution.js +6 -6
  81. package/build/dist/Server/Utils/Marketing/MarketingEventWebhook.js +4 -5
  82. package/build/dist/Server/Utils/Marketing/MarketingEventWebhook.js.map +1 -1
  83. package/build/dist/Server/Utils/Response.js +2 -2
  84. package/build/dist/Server/Utils/Response.js.map +1 -1
  85. package/build/dist/Server/Utils/StartServer.js +2 -2
  86. package/build/dist/Server/Utils/StartServer.js.map +1 -1
  87. package/build/dist/Server/Utils/Telemetry/ChangeEventRow.js +150 -0
  88. package/build/dist/Server/Utils/Telemetry/ChangeEventRow.js.map +1 -0
  89. package/build/dist/Types/Analytics/RevenueEvent.js +1 -0
  90. package/build/dist/Types/Analytics/RevenueEvent.js.map +1 -1
  91. package/build/dist/Types/AnalyticsDatabase/AnalyticsTableName.js +7 -0
  92. package/build/dist/Types/AnalyticsDatabase/AnalyticsTableName.js.map +1 -1
  93. package/build/dist/Types/Marketing/Attribution.js +9 -15
  94. package/build/dist/Types/Marketing/Attribution.js.map +1 -1
  95. package/build/dist/Types/Marketing/MarketingEvent.js +12 -4
  96. package/build/dist/Types/Marketing/MarketingEvent.js.map +1 -1
  97. package/build/dist/UI/Components/Charts/Area/AreaChart.js +10 -2
  98. package/build/dist/UI/Components/Charts/Area/AreaChart.js.map +1 -1
  99. package/build/dist/UI/Components/Charts/Bar/BarChart.js +9 -1
  100. package/build/dist/UI/Components/Charts/Bar/BarChart.js.map +1 -1
  101. package/build/dist/UI/Components/Charts/ChartGroup/ChartGroup.js +9 -1
  102. package/build/dist/UI/Components/Charts/ChartGroup/ChartGroup.js.map +1 -1
  103. package/build/dist/UI/Components/Charts/ChartLibrary/AreaChart/AreaChart.js +114 -51
  104. package/build/dist/UI/Components/Charts/ChartLibrary/AreaChart/AreaChart.js.map +1 -1
  105. package/build/dist/UI/Components/Charts/ChartLibrary/BarChart/BarChart.js +35 -23
  106. package/build/dist/UI/Components/Charts/ChartLibrary/BarChart/BarChart.js.map +1 -1
  107. package/build/dist/UI/Components/Charts/ChartLibrary/LineChart/LineChart.js +122 -55
  108. package/build/dist/UI/Components/Charts/ChartLibrary/LineChart/LineChart.js.map +1 -1
  109. package/build/dist/UI/Components/Charts/ChartLibrary/Utils/TooltipEntries.js +86 -0
  110. package/build/dist/UI/Components/Charts/ChartLibrary/Utils/TooltipEntries.js.map +1 -0
  111. package/build/dist/UI/Components/Charts/Line/LineChart.js +10 -2
  112. package/build/dist/UI/Components/Charts/Line/LineChart.js.map +1 -1
  113. package/build/dist/UI/Components/Detail/Detail.js +17 -1
  114. package/build/dist/UI/Components/Detail/Detail.js.map +1 -1
  115. package/build/dist/Utils/EnterpriseLicense/EnterpriseLicenseSync.js +174 -0
  116. package/build/dist/Utils/EnterpriseLicense/EnterpriseLicenseSync.js.map +1 -0
  117. package/build/dist/Utils/Metrics/MetricExplorerUrl.js +81 -0
  118. package/build/dist/Utils/Metrics/MetricExplorerUrl.js.map +1 -1
  119. package/build/dist/Utils/Telemetry/CrossSignalScope.js +14 -8
  120. package/build/dist/Utils/Telemetry/CrossSignalScope.js.map +1 -1
  121. package/package.json +1 -1
  122. package/Server/Infrastructure/Postgres/SchemaMigrations/1788900000000-RedactStoredMonitorIngestSecrets.ts +0 -117
  123. package/build/dist/Server/Infrastructure/Postgres/SchemaMigrations/1788900000000-RedactStoredMonitorIngestSecrets.js +0 -106
  124. package/build/dist/Server/Infrastructure/Postgres/SchemaMigrations/1788900000000-RedactStoredMonitorIngestSecrets.js.map +0 -1
@@ -40,6 +40,10 @@ import {
40
40
  import { cx } from "../Utils/Cx";
41
41
  import { getYAxisDomain } from "../Utils/GetYAxisDomain";
42
42
  import { hasOnlyOneValueForKey } from "../Utils/HasOnlyOneValueForKey";
43
+ import {
44
+ PreparedTooltipEntries,
45
+ prepareTooltipEntries,
46
+ } from "../Utils/TooltipEntries";
43
47
  import ChartCurve from "../../Types/ChartCurve";
44
48
 
45
49
  /*
@@ -51,6 +55,24 @@ import ChartCurve from "../../Types/ChartCurve";
51
55
  */
52
56
  const MAX_LABELED_TIME_REFERENCE_LINES: number = 6;
53
57
 
58
+ /*
59
+ * recharts invokes child event handlers with varying shapes ((event) for
60
+ * plain SVG passthrough, (data, event) for adapted children) — find the
61
+ * DOM event wherever it is so propagation can be stopped reliably.
62
+ */
63
+ function stopChartEventPropagation(...args: Array<unknown>): void {
64
+ for (const candidate of args) {
65
+ if (
66
+ candidate &&
67
+ typeof (candidate as { stopPropagation?: unknown }).stopPropagation ===
68
+ "function"
69
+ ) {
70
+ (candidate as { stopPropagation: () => void }).stopPropagation();
71
+ return;
72
+ }
73
+ }
74
+ }
75
+
54
76
  //#region Legend
55
77
 
56
78
  interface LegendItemProps {
@@ -478,9 +500,15 @@ const ChartTooltip: ({
478
500
  valueFormatter,
479
501
  }: ChartTooltipProps): React.JSX.Element | null => {
480
502
  if (active && payload && payload.length) {
481
- const legendPayload: PayloadItem[] = payload.filter((item: PayloadItem) => {
482
- return item.type !== "none";
483
- });
503
+ /*
504
+ * Highest value at the hovered timestamp first, capped — on a grouped
505
+ * chart (one series per host/pod), the spiking series must be the
506
+ * first line of the tooltip, not buried at its alphabetical position.
507
+ */
508
+ const {
509
+ entries: legendPayload,
510
+ overflowCount,
511
+ }: PreparedTooltipEntries<PayloadItem> = prepareTooltipEntries(payload);
484
512
  return (
485
513
  <div
486
514
  className={cx(
@@ -550,6 +578,11 @@ const ChartTooltip: ({
550
578
  );
551
579
  },
552
580
  )}
581
+ {overflowCount > 0 ? (
582
+ <p className={cx("pt-1 text-xs", "text-gray-400")}>
583
+ +{overflowCount} more series — highest values shown
584
+ </p>
585
+ ) : null}
553
586
  </div>
554
587
  </div>
555
588
  );
@@ -616,6 +649,26 @@ interface LineChartProps extends React.HTMLAttributes<HTMLDivElement> {
616
649
  formattedExemplarPoints?: Array<FormattedExemplarPoint> | undefined;
617
650
  onExemplarClick?: ((exemplar: ExemplarPoint) => void) | undefined;
618
651
  onTimeRangeSelect?: ((startTime: Date, endTime: Date) => void) | undefined;
652
+ /**
653
+ * Plain click on a time bucket (the plot background — dot/legend clicks
654
+ * and drag-selections never reach this). [bucketStart, bucketEnd) uses
655
+ * the same adjacent-row width derivation as drag-to-select. Fires only
656
+ * when no toggle-selection is active: a click that clears an active
657
+ * legend/dot selection keeps its existing meaning.
658
+ */
659
+ onBucketClick?:
660
+ | ((
661
+ bucketStart: Date,
662
+ bucketEnd: Date,
663
+ // The clicked row: one numeric entry per series + the axis keys.
664
+ valuesAtBucket: Record<string, number | string>,
665
+ ) => void)
666
+ | undefined;
667
+ /**
668
+ * Categories rendered as compare-to-previous-period ghosts: dashed,
669
+ * faded, no dots, not click-selectable — context, not data.
670
+ */
671
+ ghostCategories?: Array<string> | undefined;
619
672
  }
620
673
 
621
674
  const LineChart: React.ForwardRefExoticComponent<
@@ -655,6 +708,8 @@ const LineChart: React.ForwardRefExoticComponent<
655
708
  formattedTimeReferenceLines,
656
709
  formattedReferenceRegions,
657
710
  onTimeRangeSelect,
711
+ onBucketClick,
712
+ ghostCategories,
658
713
  ...other
659
714
  } = props;
660
715
  const CustomTooltip: React.ComponentType<TooltipProps> | undefined =
@@ -920,7 +975,12 @@ const LineChart: React.ForwardRefExoticComponent<
920
975
  <ResponsiveContainer>
921
976
  <RechartsLineChart
922
977
  data={data}
923
- syncId={props.syncid?.toString() || ""}
978
+ /*
979
+ * Omitted, not "": recharts treats "" as a real sync
980
+ * channel, so every unsynced chart page-wide would
981
+ * accidentally sync with every other one.
982
+ */
983
+ {...(props.syncid ? { syncId: props.syncid.toString() } : {})}
924
984
  {...(hasOnTimeRangeSelect
925
985
  ? {
926
986
  onMouseDown: handleRangeSelectMouseDown,
@@ -928,19 +988,51 @@ const LineChart: React.ForwardRefExoticComponent<
928
988
  onMouseUp: handleRangeSelectMouseUp,
929
989
  }
930
990
  : {})}
931
- onClick={
932
- hasOnValueChange && (activeLegend || activeDot)
933
- ? () => {
934
- // Ignore the click that follows a drag-to-select.
935
- if (suppressNextClickRef.current) {
936
- return;
937
- }
938
- setActiveDot(undefined);
939
- setActiveLegend(undefined);
940
- onValueChange?.(null);
941
- }
942
- : () => {} // do nothing
943
- }
991
+ onClick={(chartState: RangeSelectionChartState) => {
992
+ // Ignore the click that follows a drag-to-select.
993
+ if (suppressNextClickRef.current) {
994
+ return;
995
+ }
996
+ /*
997
+ * A click while a legend/dot selection is active keeps its
998
+ * long-standing meaning — clear the selection — and never
999
+ * also pins a bucket.
1000
+ */
1001
+ if (hasOnValueChange && (activeLegend || activeDot)) {
1002
+ setActiveDot(undefined);
1003
+ setActiveLegend(undefined);
1004
+ onValueChange?.(null);
1005
+ return;
1006
+ }
1007
+ if (!onBucketClick) {
1008
+ return;
1009
+ }
1010
+ const rowIndex: number | null =
1011
+ getRowIndexFromChartState(chartState);
1012
+ if (rowIndex === null) {
1013
+ return;
1014
+ }
1015
+ const bucketStart: Date | null = getBucketDateAtIndex(rowIndex);
1016
+ if (!bucketStart) {
1017
+ return;
1018
+ }
1019
+ /*
1020
+ * Cover the full bucket: width from adjacent row dates, the
1021
+ * same derivation drag-to-select uses.
1022
+ */
1023
+ const adjacentDate: Date | null =
1024
+ rowIndex > 0
1025
+ ? getBucketDateAtIndex(rowIndex - 1)
1026
+ : getBucketDateAtIndex(rowIndex + 1);
1027
+ const bucketWidthInMs: number = adjacentDate
1028
+ ? Math.abs(bucketStart.getTime() - adjacentDate.getTime())
1029
+ : 0;
1030
+ onBucketClick(
1031
+ bucketStart,
1032
+ new Date(bucketStart.getTime() + bucketWidthInMs),
1033
+ (data[rowIndex] || {}) as Record<string, number | string>,
1034
+ );
1035
+ }}
944
1036
  margin={{
945
1037
  /*
946
1038
  * Tick labels are 10px font with a translate(0, 6) — they
@@ -1126,85 +1218,95 @@ const LineChart: React.ForwardRefExoticComponent<
1126
1218
  ) as ChartColorValue;
1127
1219
  const isCustomColor: boolean = isHexColorValue(lineColor);
1128
1220
  const lineHex: string = getColorHex(lineColor);
1221
+ const isGhost: boolean =
1222
+ ghostCategories?.includes(category) || false;
1129
1223
  return (
1130
1224
  <Line
1131
1225
  className={cx(getColorClassName(lineColor, "stroke"))}
1132
1226
  strokeOpacity={
1133
- activeDot || (activeLegend && activeLegend !== category)
1227
+ (activeDot || (activeLegend && activeLegend !== category)
1134
1228
  ? 0.3
1135
- : 1
1229
+ : 1) * (isGhost ? 0.45 : 1)
1230
+ }
1231
+ activeDot={
1232
+ isGhost
1233
+ ? false
1234
+ : (props: any) => {
1235
+ const {
1236
+ cx: cxCoord,
1237
+ cy: cyCoord,
1238
+ stroke,
1239
+ strokeLinecap,
1240
+ strokeLinejoin,
1241
+ strokeWidth,
1242
+ } = props;
1243
+ return (
1244
+ <Dot
1245
+ className={cx(
1246
+ "stroke-white",
1247
+ onValueChange ? "cursor-pointer" : "",
1248
+ getColorClassName(lineColor, "fill"),
1249
+ )}
1250
+ cx={cxCoord}
1251
+ cy={cyCoord}
1252
+ r={5}
1253
+ fill={isCustomColor ? lineHex : ""}
1254
+ stroke={stroke}
1255
+ strokeLinecap={strokeLinecap}
1256
+ strokeLinejoin={strokeLinejoin}
1257
+ strokeWidth={strokeWidth}
1258
+ onClick={(_: any, event: any) => {
1259
+ return onDotClick(props, event);
1260
+ }}
1261
+ />
1262
+ );
1263
+ }
1264
+ }
1265
+ dot={
1266
+ isGhost
1267
+ ? false
1268
+ : (props: any) => {
1269
+ const {
1270
+ stroke,
1271
+ strokeLinecap,
1272
+ strokeLinejoin,
1273
+ strokeWidth,
1274
+ cx: cxCoord,
1275
+ cy: cyCoord,
1276
+ index,
1277
+ } = props;
1278
+
1279
+ if (
1280
+ (hasOnlyOneValueForKey(data, category) &&
1281
+ !(
1282
+ activeDot ||
1283
+ (activeLegend && activeLegend !== category)
1284
+ )) ||
1285
+ (activeDot?.index === index &&
1286
+ activeDot?.dataKey === category)
1287
+ ) {
1288
+ return (
1289
+ <Dot
1290
+ key={index}
1291
+ cx={cxCoord}
1292
+ cy={cyCoord}
1293
+ r={5}
1294
+ stroke={stroke}
1295
+ fill={isCustomColor ? lineHex : ""}
1296
+ strokeLinecap={strokeLinecap}
1297
+ strokeLinejoin={strokeLinejoin}
1298
+ strokeWidth={strokeWidth}
1299
+ className={cx(
1300
+ "stroke-white",
1301
+ onValueChange ? "cursor-pointer" : "",
1302
+ getColorClassName(lineColor, "fill"),
1303
+ )}
1304
+ />
1305
+ );
1306
+ }
1307
+ return <React.Fragment key={index}></React.Fragment>;
1308
+ }
1136
1309
  }
1137
- activeDot={(props: any) => {
1138
- const {
1139
- cx: cxCoord,
1140
- cy: cyCoord,
1141
- stroke,
1142
- strokeLinecap,
1143
- strokeLinejoin,
1144
- strokeWidth,
1145
- } = props;
1146
- return (
1147
- <Dot
1148
- className={cx(
1149
- "stroke-white",
1150
- onValueChange ? "cursor-pointer" : "",
1151
- getColorClassName(lineColor, "fill"),
1152
- )}
1153
- cx={cxCoord}
1154
- cy={cyCoord}
1155
- r={5}
1156
- fill={isCustomColor ? lineHex : ""}
1157
- stroke={stroke}
1158
- strokeLinecap={strokeLinecap}
1159
- strokeLinejoin={strokeLinejoin}
1160
- strokeWidth={strokeWidth}
1161
- onClick={(_: any, event: any) => {
1162
- return onDotClick(props, event);
1163
- }}
1164
- />
1165
- );
1166
- }}
1167
- dot={(props: any) => {
1168
- const {
1169
- stroke,
1170
- strokeLinecap,
1171
- strokeLinejoin,
1172
- strokeWidth,
1173
- cx: cxCoord,
1174
- cy: cyCoord,
1175
- index,
1176
- } = props;
1177
-
1178
- if (
1179
- (hasOnlyOneValueForKey(data, category) &&
1180
- !(
1181
- activeDot ||
1182
- (activeLegend && activeLegend !== category)
1183
- )) ||
1184
- (activeDot?.index === index &&
1185
- activeDot?.dataKey === category)
1186
- ) {
1187
- return (
1188
- <Dot
1189
- key={index}
1190
- cx={cxCoord}
1191
- cy={cyCoord}
1192
- r={5}
1193
- stroke={stroke}
1194
- fill={isCustomColor ? lineHex : ""}
1195
- strokeLinecap={strokeLinecap}
1196
- strokeLinejoin={strokeLinejoin}
1197
- strokeWidth={strokeWidth}
1198
- className={cx(
1199
- "stroke-white",
1200
- onValueChange ? "cursor-pointer" : "",
1201
- getColorClassName(lineColor, "fill"),
1202
- )}
1203
- />
1204
- );
1205
- }
1206
- return <React.Fragment key={index}></React.Fragment>;
1207
- }}
1208
1310
  key={category}
1209
1311
  name={category}
1210
1312
  type={props.curve || ChartCurve.LINEAR}
@@ -1214,39 +1316,45 @@ const LineChart: React.ForwardRefExoticComponent<
1214
1316
  * so the `stroke-*` Tailwind class on the Line applies.
1215
1317
  */
1216
1318
  stroke={isCustomColor ? lineHex : ""}
1217
- strokeWidth={2}
1319
+ strokeWidth={isGhost ? 1.5 : 2}
1218
1320
  strokeLinejoin="round"
1219
1321
  strokeLinecap="round"
1220
1322
  isAnimationActive={false}
1221
1323
  connectNulls={connectNulls}
1324
+ {...(isGhost ? { strokeDasharray: "6 4" } : {})}
1222
1325
  />
1223
1326
  );
1224
1327
  })}
1225
1328
  {/* hidden lines to increase clickable target area */}
1226
1329
  {onValueChange
1227
- ? categories.map((category: string) => {
1228
- return (
1229
- <Line
1230
- className={cx("cursor-pointer")}
1231
- strokeOpacity={0}
1232
- key={category}
1233
- name={category}
1234
- type={props.curve || ChartCurve.LINEAR}
1235
- dataKey={category}
1236
- stroke="transparent"
1237
- fill="transparent"
1238
- legendType="none"
1239
- tooltipType="none"
1240
- strokeWidth={12}
1241
- connectNulls={connectNulls}
1242
- onClick={(props: any, event: any) => {
1243
- event.stopPropagation();
1244
- const { name } = props;
1245
- onCategoryClick(name);
1246
- }}
1247
- />
1248
- );
1249
- })
1330
+ ? categories
1331
+ .filter((category: string) => {
1332
+ // Ghosts are context, not click-selectable series.
1333
+ return !(ghostCategories?.includes(category) || false);
1334
+ })
1335
+ .map((category: string) => {
1336
+ return (
1337
+ <Line
1338
+ className={cx("cursor-pointer")}
1339
+ strokeOpacity={0}
1340
+ key={category}
1341
+ name={category}
1342
+ type={props.curve || ChartCurve.LINEAR}
1343
+ dataKey={category}
1344
+ stroke="transparent"
1345
+ fill="transparent"
1346
+ legendType="none"
1347
+ tooltipType="none"
1348
+ strokeWidth={12}
1349
+ connectNulls={connectNulls}
1350
+ onClick={(props: any, event: any) => {
1351
+ event.stopPropagation();
1352
+ const { name } = props;
1353
+ onCategoryClick(name);
1354
+ }}
1355
+ />
1356
+ );
1357
+ })
1250
1358
  : null}
1251
1359
  {props.referenceLines?.map(
1252
1360
  (refLine: ChartReferenceLineProps, refIndex: number) => {
@@ -1288,7 +1396,9 @@ const LineChart: React.ForwardRefExoticComponent<
1288
1396
  {...(region.original.onClick
1289
1397
  ? {
1290
1398
  style: { cursor: "pointer" as const },
1291
- onClick: (): void => {
1399
+ onClick: (...args: Array<unknown>): void => {
1400
+ // Never let an annotation click also pin a bucket.
1401
+ stopChartEventPropagation(...args);
1292
1402
  if (suppressNextClickRef.current) {
1293
1403
  return;
1294
1404
  }
@@ -1330,7 +1440,9 @@ const LineChart: React.ForwardRefExoticComponent<
1330
1440
  {...(timeRefLine.original.onClick
1331
1441
  ? {
1332
1442
  style: { cursor: "pointer" as const },
1333
- onClick: (): void => {
1443
+ onClick: (...args: Array<unknown>): void => {
1444
+ // Never let an annotation click also pin a bucket.
1445
+ stopChartEventPropagation(...args);
1334
1446
  if (suppressNextClickRef.current) {
1335
1447
  return;
1336
1448
  }
@@ -1376,7 +1488,9 @@ const LineChart: React.ForwardRefExoticComponent<
1376
1488
  stroke="var(--ou-chart-marker-ring, #ffffff)"
1377
1489
  strokeWidth={2}
1378
1490
  style={{ cursor: "pointer" }}
1379
- onClick={() => {
1491
+ onClick={(...args: Array<unknown>) => {
1492
+ // Never let an exemplar click also pin a bucket.
1493
+ stopChartEventPropagation(...args);
1380
1494
  // Ignore the click that follows a drag-to-select.
1381
1495
  if (suppressNextClickRef.current) {
1382
1496
  return;
@@ -1419,4 +1533,4 @@ const LineChart: React.ForwardRefExoticComponent<
1419
1533
 
1420
1534
  LineChart.displayName = "LineChart";
1421
1535
 
1422
- export { LineChart, type LineChartEventProps, type TooltipProps };
1536
+ export { ChartTooltip, LineChart, type LineChartEventProps, type TooltipProps };
@@ -0,0 +1,120 @@
1
+ /*
2
+ * Shared ordering/capping for the hover tooltip of the Area / Line / Bar
3
+ * charts. With a grouped metric (say 20 hosts on one chart) the recharts
4
+ * payload arrives in series-render order — effectively alphabetical — so
5
+ * the series that is actually spiking at the hovered timestamp is buried
6
+ * mid-list. Sorting by the value AT THE HOVERED POINT puts the top series
7
+ * first, and capping the list keeps a high-cardinality chart's tooltip
8
+ * readable (the "+N more" line reports what was elided).
9
+ */
10
+
11
+ /*
12
+ * The slice of a recharts tooltip payload item the ordering needs. The
13
+ * three chart implementations each declare their own structurally-equal
14
+ * PayloadItem, so this stays generic instead of importing any of them.
15
+ */
16
+ export interface SortableTooltipItem {
17
+ category: string;
18
+ value: number;
19
+ type?: string | undefined;
20
+ }
21
+
22
+ export const DEFAULT_TOOLTIP_MAX_ENTRIES: number = 10;
23
+
24
+ /*
25
+ * Series carrying this suffix are compare-to-previous-period ghosts.
26
+ * They still read out in the tooltip (that is the point of comparing)
27
+ * but sort after every live series so they can never displace a real
28
+ * reading under the entry cap.
29
+ */
30
+ export const PREVIOUS_PERIOD_SERIES_SUFFIX: string = " (previous)";
31
+
32
+ export interface PreparedTooltipEntries<T extends SortableTooltipItem> {
33
+ /** Entries to render, highest value first, capped at maxEntries. */
34
+ entries: Array<T>;
35
+ /** How many entries the cap hid (0 when everything fits). */
36
+ overflowCount: number;
37
+ /** Entry count after filtering, before capping. */
38
+ totalCount: number;
39
+ }
40
+
41
+ /*
42
+ * Natural compare so host names order like cpu0 < cpu2 < cpu10 instead of
43
+ * lexicographically — mirrors the series chips' name ordering.
44
+ */
45
+ function compareCategoryNames(a: string, b: string): number {
46
+ return String(a).localeCompare(String(b), undefined, {
47
+ numeric: true,
48
+ sensitivity: "base",
49
+ });
50
+ }
51
+
52
+ export function prepareTooltipEntries<T extends SortableTooltipItem>(
53
+ payload: Array<T> | undefined | null,
54
+ maxEntries: number = DEFAULT_TOOLTIP_MAX_ENTRIES,
55
+ ): PreparedTooltipEntries<T> {
56
+ /*
57
+ * Two kinds of non-series entries are excluded:
58
+ * - `type === "none"` (recharts tooltipType) — series explicitly
59
+ * excluded from tooltips; every chart filtered these before this
60
+ * module existed, so the filter lives here now to stay uniform.
61
+ * - Array values — the anomaly band's range Area yields a
62
+ * [low, high] tuple per point; it is a shaded region, not a series
63
+ * reading, so it must neither render as a row nor count toward the
64
+ * "+N more series" overflow.
65
+ */
66
+ const visibleEntries: Array<T> = (payload || []).filter((item: T) => {
67
+ return item.type !== "none" && !Array.isArray(item.value);
68
+ });
69
+
70
+ /*
71
+ * Sort tiers: live finite readings first (value desc), then
72
+ * previous-period ghosts (value desc), then non-finite entries
73
+ * (missing points) — lower tiers must never displace a real reading
74
+ * under the entry cap.
75
+ */
76
+ const getTier: (item: T) => number = (item: T): number => {
77
+ const hasFiniteValue: boolean =
78
+ typeof item.value === "number" && Number.isFinite(item.value);
79
+ if (!hasFiniteValue) {
80
+ return 2;
81
+ }
82
+ return String(item.category).endsWith(PREVIOUS_PERIOD_SERIES_SUFFIX)
83
+ ? 1
84
+ : 0;
85
+ };
86
+
87
+ const sortedEntries: Array<T> = visibleEntries
88
+ .slice()
89
+ .sort((a: T, b: T): number => {
90
+ const aTier: number = getTier(a);
91
+ const bTier: number = getTier(b);
92
+ if (aTier !== bTier) {
93
+ return aTier - bTier;
94
+ }
95
+ if (aTier === 2) {
96
+ return compareCategoryNames(a.category, b.category);
97
+ }
98
+ if (b.value !== a.value) {
99
+ return b.value - a.value;
100
+ }
101
+ return compareCategoryNames(a.category, b.category);
102
+ });
103
+
104
+ const totalCount: number = sortedEntries.length;
105
+
106
+ const cap: number =
107
+ Number.isInteger(maxEntries) && maxEntries > 0
108
+ ? maxEntries
109
+ : DEFAULT_TOOLTIP_MAX_ENTRIES;
110
+
111
+ if (totalCount <= cap) {
112
+ return { entries: sortedEntries, overflowCount: 0, totalCount };
113
+ }
114
+
115
+ return {
116
+ entries: sortedEntries.slice(0, cap),
117
+ overflowCount: totalCount - cap,
118
+ totalCount,
119
+ };
120
+ }
@@ -62,6 +62,19 @@ export interface ComponentProps {
62
62
  * buckets calls back with the [start, end) of the selected time range.
63
63
  */
64
64
  onTimeRangeSelect?: ((startTime: Date, endTime: Date) => void) | undefined;
65
+ // Plain click on a bucket — see ChartLibrary onBucketClick.
66
+ onBucketClick?:
67
+ | ((
68
+ bucketStart: Date,
69
+ bucketEnd: Date,
70
+ valuesAtBucket: Record<string, number | string>,
71
+ ) => void)
72
+ | undefined;
73
+ /*
74
+ * Series rendered as compare-to-previous-period ghosts (dashed, faded,
75
+ * dotless). Names must exist in `data`.
76
+ */
77
+ ghostSeriesNames?: Array<string> | undefined;
65
78
  showLegend?: boolean | undefined;
66
79
  /*
67
80
  * Optional per-series color override (named palette keys or hex strings).
@@ -161,7 +174,14 @@ const LineChartElement: FunctionComponent<LineInternalProps> = (
161
174
 
162
175
  return (
163
176
  <div
164
- className="relative flex flex-1"
177
+ /*
178
+ * `isolate` opens a stacking context so the recharts tooltip's
179
+ * z-index competes only inside this chart — without it the tooltip
180
+ * (zIndex 10, pinned to the chart's top edge) ties with the app
181
+ * header's sticky z-10 in the root context and paints over the
182
+ * navbar on half-scrolled charts.
183
+ */
184
+ className="relative isolate flex flex-1"
165
185
  style={props.heightInPx ? { height: `${props.heightInPx}px` } : undefined}
166
186
  >
167
187
  <LineChart
@@ -201,6 +221,8 @@ const LineChartElement: FunctionComponent<LineInternalProps> = (
201
221
  }
202
222
  onExemplarClick={props.onExemplarClick}
203
223
  onTimeRangeSelect={props.onTimeRangeSelect}
224
+ onBucketClick={props.onBucketClick}
225
+ ghostCategories={props.ghostSeriesNames}
204
226
  />
205
227
  {hasNoData && <NoDataMessage />}
206
228
  </div>
@@ -715,7 +715,24 @@ const Detail: DetailFunction = <T extends GenericObject>(
715
715
  }
716
716
 
717
717
  if (field.getElement) {
718
- data = field.getElement(props.item);
718
+ /*
719
+ * getField runs inside Detail's own render, so a throw out of a field
720
+ * renderer unwinds the whole route to the app level ErrorBoundary and
721
+ * replaces the page with "Something went wrong". One misbehaving field
722
+ * must never cost the entire page: log the misconfiguration and keep
723
+ * whatever this field type already rendered - the entity badge, or ""
724
+ * so the placeholder below can stand in.
725
+ */
726
+ try {
727
+ data = field.getElement(props.item);
728
+ } catch (err) {
729
+ Logger.error(
730
+ "Detail: getElement for '" +
731
+ fieldKeyStr +
732
+ "' threw and was ignored. " +
733
+ (err instanceof Error ? err.message : String(err)),
734
+ );
735
+ }
719
736
  }
720
737
 
721
738
  let className: string = "sm:col-span-1";