@oneuptime/common 12.0.20 → 12.0.22

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 (119) hide show
  1. package/Models/AnalyticsModels/ChangeEvent.ts +331 -0
  2. package/Models/AnalyticsModels/Index.ts +2 -0
  3. package/Models/DatabaseModels/Index.ts +2 -0
  4. package/Models/DatabaseModels/UserTwoFactorBackupCode.ts +262 -0
  5. package/Server/API/UserTwoFactorBackupCodeAPI.ts +258 -0
  6. package/Server/EnvironmentConfig.ts +1 -8
  7. package/Server/Infrastructure/Postgres/SchemaMigrations/1789000000000-AddUserSlackAndMicrosoftTeams.ts +42 -0
  8. package/Server/Infrastructure/Postgres/SchemaMigrations/1789100000000-AddUserTwoFactorBackupCode.ts +63 -0
  9. package/Server/Infrastructure/Postgres/SchemaMigrations/Index.ts +2 -2
  10. package/Server/Services/ChangeEventService.ts +11 -0
  11. package/Server/Services/EnterpriseLicenseService.ts +3 -3
  12. package/Server/Services/Index.ts +4 -0
  13. package/Server/Services/UserService.ts +42 -0
  14. package/Server/Services/UserTwoFactorBackupCodeService.ts +355 -0
  15. package/Server/Utils/Attribution.ts +11 -11
  16. package/Server/Utils/Marketing/MarketingEventWebhook.ts +4 -5
  17. package/Server/Utils/Telemetry/ChangeEventRow.ts +228 -0
  18. package/Server/Utils/TwoFactorBackupCode.ts +266 -0
  19. package/Server/Views/Partials/AnalyticsConsent.ejs +3 -95
  20. package/Tests/App/Dashboard/DashboardChartWidgetZoom.test.tsx +64 -0
  21. package/Tests/App/Dashboard/EventOverlayHook.test.tsx +249 -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/MetricViewCompare.test.tsx +233 -0
  26. package/Tests/Server/API/UserAuthenticationAPI.test.ts +16 -3
  27. package/Tests/Server/API/UserTwoFactorAuthAdminAPI.test.ts +18 -2
  28. package/Tests/Server/API/UserTwoFactorBackupCodeAPI.test.ts +1390 -0
  29. package/Tests/Server/Infrastructure/Postgres/AddUserSlackAndMicrosoftTeamsMigration.test.ts +236 -0
  30. package/Tests/Server/Services/UserAuthenticationService.test.ts +23 -1
  31. package/Tests/Server/Services/UserTwoFactorAuthAdmin.test.ts +21 -0
  32. package/Tests/Server/Services/UserTwoFactorBackupCodeAdminSurface.test.ts +919 -0
  33. package/Tests/Server/Services/UserTwoFactorBackupCodeService.test.ts +862 -0
  34. package/Tests/Server/Utils/AI/SRE/Insights/Detectors/ExceptionIdentity.test.ts +224 -0
  35. package/Tests/Server/Utils/Attribution.test.ts +4 -5
  36. package/Tests/Server/Utils/Marketing/MarketingEventUtil.test.ts +2 -2
  37. package/Tests/Server/Utils/Monitor/MonitorCriteriaExpectationBuilder.test.ts +300 -0
  38. package/Tests/Server/Utils/TwoFactorBackupCode.test.ts +475 -0
  39. package/Tests/UI/Components/Charts/ChartGhostSeries.test.tsx +103 -0
  40. package/Tests/UI/Components/Charts/TooltipEntries.test.ts +44 -8
  41. package/Tests/Utils/Telemetry/CrossSignalScope.test.ts +13 -2
  42. package/Types/AnalyticsDatabase/AnalyticsTableName.ts +7 -0
  43. package/Types/Email/EmailTemplateType.ts +2 -0
  44. package/Types/Marketing/Attribution.ts +9 -15
  45. package/Types/Marketing/MarketingEvent.ts +10 -46
  46. package/Types/UserAuthenticationStatus.ts +16 -0
  47. package/UI/Components/Charts/Area/AreaChart.tsx +15 -0
  48. package/UI/Components/Charts/ChartGroup/ChartGroup.tsx +16 -1
  49. package/UI/Components/Charts/ChartLibrary/AreaChart/AreaChart.tsx +189 -93
  50. package/UI/Components/Charts/ChartLibrary/BarChart/BarChart.tsx +6 -1
  51. package/UI/Components/Charts/ChartLibrary/LineChart/LineChart.tsx +213 -114
  52. package/UI/Components/Charts/ChartLibrary/Utils/TooltipEntries.ts +42 -27
  53. package/UI/Components/Charts/Line/LineChart.tsx +15 -0
  54. package/Utils/Metrics/MetricExplorerUrl.ts +119 -5
  55. package/Utils/Telemetry/CrossSignalScope.ts +19 -10
  56. package/build/dist/Models/AnalyticsModels/ChangeEvent.js +298 -0
  57. package/build/dist/Models/AnalyticsModels/ChangeEvent.js.map +1 -0
  58. package/build/dist/Models/AnalyticsModels/Index.js +2 -0
  59. package/build/dist/Models/AnalyticsModels/Index.js.map +1 -1
  60. package/build/dist/Models/DatabaseModels/Index.js +2 -0
  61. package/build/dist/Models/DatabaseModels/Index.js.map +1 -1
  62. package/build/dist/Models/DatabaseModels/UserTwoFactorBackupCode.js +277 -0
  63. package/build/dist/Models/DatabaseModels/UserTwoFactorBackupCode.js.map +1 -0
  64. package/build/dist/Server/API/UserTwoFactorBackupCodeAPI.js +201 -0
  65. package/build/dist/Server/API/UserTwoFactorBackupCodeAPI.js.map +1 -0
  66. package/build/dist/Server/EnvironmentConfig.js +1 -7
  67. package/build/dist/Server/EnvironmentConfig.js.map +1 -1
  68. package/build/dist/Server/Infrastructure/Postgres/SchemaMigrations/1789000000000-AddUserSlackAndMicrosoftTeams.js +39 -0
  69. package/build/dist/Server/Infrastructure/Postgres/SchemaMigrations/1789000000000-AddUserSlackAndMicrosoftTeams.js.map +1 -1
  70. package/build/dist/Server/Infrastructure/Postgres/SchemaMigrations/1789100000000-AddUserTwoFactorBackupCode.js +46 -0
  71. package/build/dist/Server/Infrastructure/Postgres/SchemaMigrations/1789100000000-AddUserTwoFactorBackupCode.js.map +1 -0
  72. package/build/dist/Server/Infrastructure/Postgres/SchemaMigrations/Index.js +2 -2
  73. package/build/dist/Server/Infrastructure/Postgres/SchemaMigrations/Index.js.map +1 -1
  74. package/build/dist/Server/Services/ChangeEventService.js +9 -0
  75. package/build/dist/Server/Services/ChangeEventService.js.map +1 -0
  76. package/build/dist/Server/Services/EnterpriseLicenseService.js +3 -3
  77. package/build/dist/Server/Services/Index.js +4 -0
  78. package/build/dist/Server/Services/Index.js.map +1 -1
  79. package/build/dist/Server/Services/UserService.js +39 -0
  80. package/build/dist/Server/Services/UserService.js.map +1 -1
  81. package/build/dist/Server/Services/UserTwoFactorBackupCodeService.js +327 -0
  82. package/build/dist/Server/Services/UserTwoFactorBackupCodeService.js.map +1 -0
  83. package/build/dist/Server/Utils/Attribution.js +6 -6
  84. package/build/dist/Server/Utils/Marketing/MarketingEventWebhook.js +4 -5
  85. package/build/dist/Server/Utils/Marketing/MarketingEventWebhook.js.map +1 -1
  86. package/build/dist/Server/Utils/Telemetry/ChangeEventRow.js +150 -0
  87. package/build/dist/Server/Utils/Telemetry/ChangeEventRow.js.map +1 -0
  88. package/build/dist/Server/Utils/TwoFactorBackupCode.js +269 -0
  89. package/build/dist/Server/Utils/TwoFactorBackupCode.js.map +1 -0
  90. package/build/dist/Types/AnalyticsDatabase/AnalyticsTableName.js +7 -0
  91. package/build/dist/Types/AnalyticsDatabase/AnalyticsTableName.js.map +1 -1
  92. package/build/dist/Types/Email/EmailTemplateType.js +2 -0
  93. package/build/dist/Types/Email/EmailTemplateType.js.map +1 -1
  94. package/build/dist/Types/Marketing/Attribution.js +9 -15
  95. package/build/dist/Types/Marketing/Attribution.js.map +1 -1
  96. package/build/dist/Types/Marketing/MarketingEvent.js +2 -33
  97. package/build/dist/Types/Marketing/MarketingEvent.js.map +1 -1
  98. package/build/dist/UI/Components/Charts/Area/AreaChart.js +1 -1
  99. package/build/dist/UI/Components/Charts/Area/AreaChart.js.map +1 -1
  100. package/build/dist/UI/Components/Charts/ChartGroup/ChartGroup.js +9 -1
  101. package/build/dist/UI/Components/Charts/ChartGroup/ChartGroup.js.map +1 -1
  102. package/build/dist/UI/Components/Charts/ChartLibrary/AreaChart/AreaChart.js +92 -38
  103. package/build/dist/UI/Components/Charts/ChartLibrary/AreaChart/AreaChart.js.map +1 -1
  104. package/build/dist/UI/Components/Charts/ChartLibrary/BarChart/BarChart.js +8 -8
  105. package/build/dist/UI/Components/Charts/ChartLibrary/BarChart/BarChart.js.map +1 -1
  106. package/build/dist/UI/Components/Charts/ChartLibrary/LineChart/LineChart.js +95 -37
  107. package/build/dist/UI/Components/Charts/ChartLibrary/LineChart/LineChart.js.map +1 -1
  108. package/build/dist/UI/Components/Charts/ChartLibrary/Utils/TooltipEntries.js +39 -24
  109. package/build/dist/UI/Components/Charts/ChartLibrary/Utils/TooltipEntries.js.map +1 -1
  110. package/build/dist/UI/Components/Charts/Line/LineChart.js +1 -1
  111. package/build/dist/UI/Components/Charts/Line/LineChart.js.map +1 -1
  112. package/build/dist/Utils/Metrics/MetricExplorerUrl.js +81 -0
  113. package/build/dist/Utils/Metrics/MetricExplorerUrl.js.map +1 -1
  114. package/build/dist/Utils/Telemetry/CrossSignalScope.js +14 -8
  115. package/build/dist/Utils/Telemetry/CrossSignalScope.js.map +1 -1
  116. package/package.json +1 -1
  117. package/Server/Infrastructure/Postgres/SchemaMigrations/1788900000000-RedactStoredMonitorIngestSecrets.ts +0 -117
  118. package/build/dist/Server/Infrastructure/Postgres/SchemaMigrations/1788900000000-RedactStoredMonitorIngestSecrets.js +0 -106
  119. package/build/dist/Server/Infrastructure/Postgres/SchemaMigrations/1788900000000-RedactStoredMonitorIngestSecrets.js.map +0 -1
@@ -55,6 +55,24 @@ import ChartCurve from "../../Types/ChartCurve";
55
55
  */
56
56
  const MAX_LABELED_TIME_REFERENCE_LINES: number = 6;
57
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
+
58
76
  //#region Legend
59
77
 
60
78
  interface LegendItemProps {
@@ -631,6 +649,26 @@ interface LineChartProps extends React.HTMLAttributes<HTMLDivElement> {
631
649
  formattedExemplarPoints?: Array<FormattedExemplarPoint> | undefined;
632
650
  onExemplarClick?: ((exemplar: ExemplarPoint) => void) | undefined;
633
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;
634
672
  }
635
673
 
636
674
  const LineChart: React.ForwardRefExoticComponent<
@@ -670,6 +708,8 @@ const LineChart: React.ForwardRefExoticComponent<
670
708
  formattedTimeReferenceLines,
671
709
  formattedReferenceRegions,
672
710
  onTimeRangeSelect,
711
+ onBucketClick,
712
+ ghostCategories,
673
713
  ...other
674
714
  } = props;
675
715
  const CustomTooltip: React.ComponentType<TooltipProps> | undefined =
@@ -935,7 +975,12 @@ const LineChart: React.ForwardRefExoticComponent<
935
975
  <ResponsiveContainer>
936
976
  <RechartsLineChart
937
977
  data={data}
938
- 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() } : {})}
939
984
  {...(hasOnTimeRangeSelect
940
985
  ? {
941
986
  onMouseDown: handleRangeSelectMouseDown,
@@ -943,19 +988,51 @@ const LineChart: React.ForwardRefExoticComponent<
943
988
  onMouseUp: handleRangeSelectMouseUp,
944
989
  }
945
990
  : {})}
946
- onClick={
947
- hasOnValueChange && (activeLegend || activeDot)
948
- ? () => {
949
- // Ignore the click that follows a drag-to-select.
950
- if (suppressNextClickRef.current) {
951
- return;
952
- }
953
- setActiveDot(undefined);
954
- setActiveLegend(undefined);
955
- onValueChange?.(null);
956
- }
957
- : () => {} // do nothing
958
- }
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
+ }}
959
1036
  margin={{
960
1037
  /*
961
1038
  * Tick labels are 10px font with a translate(0, 6) — they
@@ -1141,85 +1218,95 @@ const LineChart: React.ForwardRefExoticComponent<
1141
1218
  ) as ChartColorValue;
1142
1219
  const isCustomColor: boolean = isHexColorValue(lineColor);
1143
1220
  const lineHex: string = getColorHex(lineColor);
1221
+ const isGhost: boolean =
1222
+ ghostCategories?.includes(category) || false;
1144
1223
  return (
1145
1224
  <Line
1146
1225
  className={cx(getColorClassName(lineColor, "stroke"))}
1147
1226
  strokeOpacity={
1148
- activeDot || (activeLegend && activeLegend !== category)
1227
+ (activeDot || (activeLegend && activeLegend !== category)
1149
1228
  ? 0.3
1150
- : 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
+ }
1151
1309
  }
1152
- activeDot={(props: any) => {
1153
- const {
1154
- cx: cxCoord,
1155
- cy: cyCoord,
1156
- stroke,
1157
- strokeLinecap,
1158
- strokeLinejoin,
1159
- strokeWidth,
1160
- } = props;
1161
- return (
1162
- <Dot
1163
- className={cx(
1164
- "stroke-white",
1165
- onValueChange ? "cursor-pointer" : "",
1166
- getColorClassName(lineColor, "fill"),
1167
- )}
1168
- cx={cxCoord}
1169
- cy={cyCoord}
1170
- r={5}
1171
- fill={isCustomColor ? lineHex : ""}
1172
- stroke={stroke}
1173
- strokeLinecap={strokeLinecap}
1174
- strokeLinejoin={strokeLinejoin}
1175
- strokeWidth={strokeWidth}
1176
- onClick={(_: any, event: any) => {
1177
- return onDotClick(props, event);
1178
- }}
1179
- />
1180
- );
1181
- }}
1182
- dot={(props: any) => {
1183
- const {
1184
- stroke,
1185
- strokeLinecap,
1186
- strokeLinejoin,
1187
- strokeWidth,
1188
- cx: cxCoord,
1189
- cy: cyCoord,
1190
- index,
1191
- } = props;
1192
-
1193
- if (
1194
- (hasOnlyOneValueForKey(data, category) &&
1195
- !(
1196
- activeDot ||
1197
- (activeLegend && activeLegend !== category)
1198
- )) ||
1199
- (activeDot?.index === index &&
1200
- activeDot?.dataKey === category)
1201
- ) {
1202
- return (
1203
- <Dot
1204
- key={index}
1205
- cx={cxCoord}
1206
- cy={cyCoord}
1207
- r={5}
1208
- stroke={stroke}
1209
- fill={isCustomColor ? lineHex : ""}
1210
- strokeLinecap={strokeLinecap}
1211
- strokeLinejoin={strokeLinejoin}
1212
- strokeWidth={strokeWidth}
1213
- className={cx(
1214
- "stroke-white",
1215
- onValueChange ? "cursor-pointer" : "",
1216
- getColorClassName(lineColor, "fill"),
1217
- )}
1218
- />
1219
- );
1220
- }
1221
- return <React.Fragment key={index}></React.Fragment>;
1222
- }}
1223
1310
  key={category}
1224
1311
  name={category}
1225
1312
  type={props.curve || ChartCurve.LINEAR}
@@ -1229,39 +1316,45 @@ const LineChart: React.ForwardRefExoticComponent<
1229
1316
  * so the `stroke-*` Tailwind class on the Line applies.
1230
1317
  */
1231
1318
  stroke={isCustomColor ? lineHex : ""}
1232
- strokeWidth={2}
1319
+ strokeWidth={isGhost ? 1.5 : 2}
1233
1320
  strokeLinejoin="round"
1234
1321
  strokeLinecap="round"
1235
1322
  isAnimationActive={false}
1236
1323
  connectNulls={connectNulls}
1324
+ {...(isGhost ? { strokeDasharray: "6 4" } : {})}
1237
1325
  />
1238
1326
  );
1239
1327
  })}
1240
1328
  {/* hidden lines to increase clickable target area */}
1241
1329
  {onValueChange
1242
- ? categories.map((category: string) => {
1243
- return (
1244
- <Line
1245
- className={cx("cursor-pointer")}
1246
- strokeOpacity={0}
1247
- key={category}
1248
- name={category}
1249
- type={props.curve || ChartCurve.LINEAR}
1250
- dataKey={category}
1251
- stroke="transparent"
1252
- fill="transparent"
1253
- legendType="none"
1254
- tooltipType="none"
1255
- strokeWidth={12}
1256
- connectNulls={connectNulls}
1257
- onClick={(props: any, event: any) => {
1258
- event.stopPropagation();
1259
- const { name } = props;
1260
- onCategoryClick(name);
1261
- }}
1262
- />
1263
- );
1264
- })
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
+ })
1265
1358
  : null}
1266
1359
  {props.referenceLines?.map(
1267
1360
  (refLine: ChartReferenceLineProps, refIndex: number) => {
@@ -1303,7 +1396,9 @@ const LineChart: React.ForwardRefExoticComponent<
1303
1396
  {...(region.original.onClick
1304
1397
  ? {
1305
1398
  style: { cursor: "pointer" as const },
1306
- onClick: (): void => {
1399
+ onClick: (...args: Array<unknown>): void => {
1400
+ // Never let an annotation click also pin a bucket.
1401
+ stopChartEventPropagation(...args);
1307
1402
  if (suppressNextClickRef.current) {
1308
1403
  return;
1309
1404
  }
@@ -1345,7 +1440,9 @@ const LineChart: React.ForwardRefExoticComponent<
1345
1440
  {...(timeRefLine.original.onClick
1346
1441
  ? {
1347
1442
  style: { cursor: "pointer" as const },
1348
- onClick: (): void => {
1443
+ onClick: (...args: Array<unknown>): void => {
1444
+ // Never let an annotation click also pin a bucket.
1445
+ stopChartEventPropagation(...args);
1349
1446
  if (suppressNextClickRef.current) {
1350
1447
  return;
1351
1448
  }
@@ -1391,7 +1488,9 @@ const LineChart: React.ForwardRefExoticComponent<
1391
1488
  stroke="var(--ou-chart-marker-ring, #ffffff)"
1392
1489
  strokeWidth={2}
1393
1490
  style={{ cursor: "pointer" }}
1394
- onClick={() => {
1491
+ onClick={(...args: Array<unknown>) => {
1492
+ // Never let an exemplar click also pin a bucket.
1493
+ stopChartEventPropagation(...args);
1395
1494
  // Ignore the click that follows a drag-to-select.
1396
1495
  if (suppressNextClickRef.current) {
1397
1496
  return;
@@ -21,6 +21,14 @@ export interface SortableTooltipItem {
21
21
 
22
22
  export const DEFAULT_TOOLTIP_MAX_ENTRIES: number = 10;
23
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
+
24
32
  export interface PreparedTooltipEntries<T extends SortableTooltipItem> {
25
33
  /** Entries to render, highest value first, capped at maxEntries. */
26
34
  entries: Array<T>;
@@ -46,42 +54,49 @@ export function prepareTooltipEntries<T extends SortableTooltipItem>(
46
54
  maxEntries: number = DEFAULT_TOOLTIP_MAX_ENTRIES,
47
55
  ): PreparedTooltipEntries<T> {
48
56
  /*
49
- * `type === "none"` entries are series excluded from legends/tooltips
50
- * (recharts tooltipType) — every chart filtered them before this
51
- * module existed, so the filter lives here now to stay uniform.
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.
52
65
  */
53
66
  const visibleEntries: Array<T> = (payload || []).filter((item: T) => {
54
- return item.type !== "none";
67
+ return item.type !== "none" && !Array.isArray(item.value);
55
68
  });
56
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
+
57
87
  const sortedEntries: Array<T> = visibleEntries
58
88
  .slice()
59
89
  .sort((a: T, b: T): number => {
60
- /*
61
- * Non-finite values (missing points, the anomaly band's [low, high]
62
- * array) sort last — they carry no "how high is this series right
63
- * now" signal, so they must never displace a real reading.
64
- */
65
- const aValue: number | null =
66
- typeof a.value === "number" && Number.isFinite(a.value)
67
- ? a.value
68
- : null;
69
- const bValue: number | null =
70
- typeof b.value === "number" && Number.isFinite(b.value)
71
- ? b.value
72
- : null;
73
-
74
- if (aValue === null && bValue === null) {
75
- return compareCategoryNames(a.category, b.category);
90
+ const aTier: number = getTier(a);
91
+ const bTier: number = getTier(b);
92
+ if (aTier !== bTier) {
93
+ return aTier - bTier;
76
94
  }
77
- if (aValue === null) {
78
- return 1;
79
- }
80
- if (bValue === null) {
81
- return -1;
95
+ if (aTier === 2) {
96
+ return compareCategoryNames(a.category, b.category);
82
97
  }
83
- if (bValue !== aValue) {
84
- return bValue - aValue;
98
+ if (b.value !== a.value) {
99
+ return b.value - a.value;
85
100
  }
86
101
  return compareCategoryNames(a.category, b.category);
87
102
  });
@@ -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).
@@ -208,6 +221,8 @@ const LineChartElement: FunctionComponent<LineInternalProps> = (
208
221
  }
209
222
  onExemplarClick={props.onExemplarClick}
210
223
  onTimeRangeSelect={props.onTimeRangeSelect}
224
+ onBucketClick={props.onBucketClick}
225
+ ghostCategories={props.ghostSeriesNames}
211
226
  />
212
227
  {hasNoData && <NoDataMessage />}
213
228
  </div>