@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
@@ -10,6 +10,8 @@ enum EmailTemplateType {
10
10
  ConfirmStatusPageSubscription = "ConfirmStatusPageSubscription.hbs",
11
11
  EmailVerified = "EmailVerified.hbs",
12
12
  PasswordChanged = "PasswordChanged.hbs",
13
+ TwoFactorBackupCodeUsed = "TwoFactorBackupCodeUsed.hbs",
14
+ TwoFactorBackupCodesRegenerated = "TwoFactorBackupCodesRegenerated.hbs",
13
15
  ProbeOwnerAdded = "ProbeOwnerAdded.hbs",
14
16
  InviteMember = "InviteMember.hbs",
15
17
  EmailChanged = "EmailChanged.hbs",
@@ -3,19 +3,14 @@
3
3
  * writes it.
4
4
  *
5
5
  * Attribution starts in the browser (Home/Views/head-basic.ejs stores it in
6
- * localStorage) and reaches the server through two unrelated doors:
6
+ * localStorage) and reaches the server through the signup form
7
+ * (App/FeatureSet/Accounts/src/Pages/Register.tsx), which posts it onto the
8
+ * User record.
7
9
  *
8
- * - the signup form (App/FeatureSet/Accounts/src/Pages/Register.tsx), which
9
- * posts it onto the User record;
10
- * - the Cal.com booking webhook (App/API/CalWebhook.ts), which reads it back
11
- * out of the booking metadata the embed carried. Every page that books —
12
- * /enterprise/demo, /support, /enterprise/self-hosted — goes through it.
13
- *
14
- * Both doors are reachable by an unauthenticated caller, so both must whitelist
15
- * and length-bound what they accept. Keeping the key lists here rather than in
16
- * each door means a key added for the browser cannot be silently dropped by one
17
- * of the readers — which is exactly what happened to UTM parameters on the Cal
18
- * path, where the webhook parsed click IDs the embed never sent.
10
+ * That door is reachable by an unauthenticated caller, so it must whitelist and
11
+ * length-bound what it accepts. Keeping the key lists here rather than in the
12
+ * door itself means a key added for the browser cannot be silently dropped on
13
+ * arrival.
19
14
  */
20
15
 
21
16
  // Ad-platform click identifiers, in the spelling each platform uses on the URL.
@@ -31,9 +26,8 @@ export const AdClickIdKeys: Array<string> = [
31
26
  ];
32
27
 
33
28
  /*
34
- * UTM parameters travel as snake_case on a URL and in Cal booking metadata,
35
- * and as camelCase once they are columns on User/Project/MarketingConversion.
36
- * Both spellings are accepted on the way in — Cal booking questions and hand
29
+ * UTM parameters travel as snake_case on a URL and as camelCase once they are
30
+ * columns on User/Project. Both spellings are accepted on the way in — hand
37
31
  * written links produce either — and only the camelCase form is stored.
38
32
  */
39
33
  export const UtmWireKeyToPropertyKey: Record<string, string> = {
@@ -13,7 +13,7 @@ import { JSONObject } from "../JSON";
13
13
  *
14
14
  * - DEDUPLICATION. `eventId` is stable for a given real-world occurrence, so
15
15
  * a redelivery carries the id the first attempt did. Retries are expected
16
- * (the queue retries, and Cal retries into us), so a receiver that keys on
16
+ * (the delivery queue retries), so a receiver that keys on
17
17
  * anything else will double-count.
18
18
  * - ORDERING. Events are delivered per-occurrence with no sequence number.
19
19
  * A signup and a plan change seconds apart may arrive either way round;
@@ -26,7 +26,6 @@ import { JSONObject } from "../JSON";
26
26
  */
27
27
  export enum MarketingEventType {
28
28
  SignUp = "sign_up",
29
- MeetingBooked = "meeting_booked",
30
29
  /*
31
30
  * A project's first paid subscription, created together with the project.
32
31
  *
@@ -45,53 +44,19 @@ export enum MarketingEventType {
45
44
  * whose email is typed in by a human rather than captured from a session —
46
45
  * which is exactly what makes it joinable: set EnterpriseLicense.email to
47
46
  * the address the customer booked with and this event shares an identity
48
- * with the meeting_booked that preceded it, months earlier.
47
+ * with whatever else that address has done, months earlier.
49
48
  */
50
49
  EnterpriseLicenseIssued = "enterprise_license_issued",
51
50
  }
52
51
 
53
- /*
54
- * Which conversation a booked meeting actually is.
55
- *
56
- * All three Cal embeds book the same event type (oneuptimehq/demo), so without
57
- * this the webhook cannot tell a free user's support call from a net-new
58
- * enterprise demo — they arrive as one undifferentiated `meeting_booked`, and
59
- * anything bidding on or reporting against that count values them identically.
60
- *
61
- * The browser event has carried this since the embeds were instrumented; the
62
- * webhook did not, which meant the authoritative record was the one that could
63
- * not make the distinction.
64
- *
65
- * `Unknown` is a real state, not a failure: a booking made through a Cal link
66
- * that was not one of the instrumented embeds genuinely has no kind, and is
67
- * still a booking worth emitting.
68
- */
69
- export enum MeetingBookingKind {
70
- EnterpriseDemo = "enterprise_demo",
71
- SupportCall = "support_call",
72
- ArchitectureAssessment = "architecture_assessment",
73
- Unknown = "unknown",
74
- }
75
-
76
- /*
77
- * The Cal booking-metadata key the embeds write and the webhook reads. Shared
78
- * so a rename cannot land on one side only - the failure mode there is silent,
79
- * because a booking with no recognised kind still succeeds.
80
- */
81
- export const BOOKING_KIND_METADATA_KEY: string = "ou_booking_kind";
82
-
83
- export const MeetingBookingKinds: Array<string> =
84
- Object.values(MeetingBookingKind);
85
-
86
52
  export const MARKETING_EVENT_SCHEMA_VERSION: number = 1;
87
53
 
88
54
  /*
89
55
  * The campaign the converting visitor carried.
90
56
  *
91
- * Copied from the User or Project row for a signup or a plan change, and read
92
- * out of Cal booking metadata for a booked meeting. Every field is optional:
93
- * a conversion with no attribution at all is still a conversion, and is still
94
- * emitted.
57
+ * Copied from the User or Project row the conversion happened on. Every field
58
+ * is optional: a conversion with no attribution at all is still a conversion,
59
+ * and is still emitted.
95
60
  */
96
61
  export interface MarketingEventAttribution extends JSONObject {
97
62
  utmSource?: string | undefined;
@@ -110,17 +75,16 @@ export interface MarketingEvent extends JSONObject {
110
75
  * Stable per real-world occurrence, and the receiver's deduplication key:
111
76
  *
112
77
  * sign_up:{userId}
113
- * meeting_booked:{calBookingUid}
114
78
  * subscription_started:{projectId}
115
79
  * subscription_upgraded:{projectId}:{occurredAt}
116
80
  * subscription_downgraded:{projectId}:{occurredAt}
117
81
  * enterprise_license_issued:{enterpriseLicenseId}
118
82
  *
119
- * Four of these are naturally unique — a user signs up once, a booking has
120
- * one uid, a project has one first subscription, a licence row is issued
121
- * once so Cal retrying a delivery or the queue retrying a job cannot
122
- * produce a second conversion. A plan change can legitimately recur for one
123
- * project, so its id carries the instant it happened.
83
+ * Three of these are naturally unique — a user signs up once, a project has
84
+ * one first subscription, a licence row is issued once — so the queue
85
+ * retrying a job cannot produce a second conversion. A plan change can
86
+ * legitimately recur for one project, so its id carries the instant it
87
+ * happened.
124
88
  */
125
89
  eventId: string;
126
90
  eventType: MarketingEventType;
@@ -47,6 +47,22 @@ export default interface UserAuthenticationStatus {
47
47
  */
48
48
  verifiedTwoFactorAuthMethodCount: number;
49
49
 
50
+ /*
51
+ * How many single-use backup codes the user has left.
52
+ *
53
+ * Deliberately NOT folded into `verifiedTwoFactorAuthMethodCount`, and the
54
+ * separation is the operator's whole decision. The count above answers "can
55
+ * they sign in at all today"; this one answers "can they get themselves back
56
+ * in without me". An operator looking at a lost-phone ticket needs both: a
57
+ * user with codes left should be told to use one, and only a user with none
58
+ * needs the reset button -- which signs them out everywhere and marches them
59
+ * through enrolment.
60
+ *
61
+ * Zero is the normal reading for an account that never generated any, not an
62
+ * error state.
63
+ */
64
+ unusedTwoFactorBackupCodeCount: number;
65
+
50
66
  // Is there an unexpired password-reset link outstanding for this user?
51
67
  hasPendingPasswordResetLink: boolean;
52
68
  }
@@ -63,6 +63,19 @@ export interface ComponentProps {
63
63
  * buckets calls back with the [start, end) of the selected time range.
64
64
  */
65
65
  onTimeRangeSelect?: ((startTime: Date, endTime: Date) => void) | undefined;
66
+ // Plain click on a bucket — see ChartLibrary onBucketClick.
67
+ onBucketClick?:
68
+ | ((
69
+ bucketStart: Date,
70
+ bucketEnd: Date,
71
+ valuesAtBucket: Record<string, number | string>,
72
+ ) => void)
73
+ | undefined;
74
+ /*
75
+ * Series rendered as compare-to-previous-period ghosts (dashed, faded,
76
+ * dotless). Names must exist in `data`.
77
+ */
78
+ ghostSeriesNames?: Array<string> | undefined;
66
79
  showLegend?: boolean | undefined;
67
80
  /**
68
81
  * Render a shaded "expected range" band underneath the plotted lines.
@@ -228,6 +241,8 @@ const AreaChartElement: FunctionComponent<AreaInternalProps> = (
228
241
  }
229
242
  onExemplarClick={props.onExemplarClick}
230
243
  onTimeRangeSelect={props.onTimeRangeSelect}
244
+ onBucketClick={props.onBucketClick}
245
+ ghostCategories={props.ghostSeriesNames}
231
246
  anomalyBandLowerKey={bandLower}
232
247
  anomalyBandUpperKey={bandUpper}
233
248
  />
@@ -57,12 +57,27 @@ export interface ComponentProps {
57
57
  charts: Array<Chart>;
58
58
  hideCard?: boolean | undefined;
59
59
  chartCssClass?: string | undefined;
60
+ /**
61
+ * Share one crosshair-sync channel across SEVERAL ChartGroup instances
62
+ * (e.g. every metric widget on one dashboard passes the dashboard id,
63
+ * so hovering an instant on one widget highlights it on all of them).
64
+ * Absent → this group syncs only within itself.
65
+ */
66
+ syncId?: string | undefined;
60
67
  }
61
68
 
62
69
  const ChartGroup: FunctionComponent<ComponentProps> = (
63
70
  props: ComponentProps,
64
71
  ): ReactElement => {
65
- const syncId: string = Text.generateRandomText(10);
72
+ /*
73
+ * Stable per-mount fallback: a plain const regenerated the id on every
74
+ * render, which forced recharts to tear down and re-subscribe its sync
75
+ * listeners on each parent re-render (auto-refresh ticks included).
76
+ */
77
+ const [fallbackSyncId] = useState<string>(() => {
78
+ return Text.generateRandomText(10);
79
+ });
80
+ const syncId: string = props.syncId || fallbackSyncId;
66
81
  const [metricInfoModalChart, setMetricInfoModalChart] =
67
82
  useState<ChartMetricInfo | null>(null);
68
83
 
@@ -54,6 +54,24 @@ import ChartCurve from "../../Types/ChartCurve";
54
54
  */
55
55
  const MAX_LABELED_TIME_REFERENCE_LINES: number = 6;
56
56
 
57
+ /*
58
+ * recharts invokes child event handlers with varying shapes ((event) for
59
+ * plain SVG passthrough, (data, event) for adapted children) — find the
60
+ * DOM event wherever it is so propagation can be stopped reliably.
61
+ */
62
+ function stopChartEventPropagation(...args: Array<unknown>): void {
63
+ for (const candidate of args) {
64
+ if (
65
+ candidate &&
66
+ typeof (candidate as { stopPropagation?: unknown }).stopPropagation ===
67
+ "function"
68
+ ) {
69
+ (candidate as { stopPropagation: () => void }).stopPropagation();
70
+ return;
71
+ }
72
+ }
73
+ }
74
+
57
75
  //#region Legend
58
76
 
59
77
  interface LegendItemProps {
@@ -613,6 +631,26 @@ interface AreaChartProps extends React.HTMLAttributes<HTMLDivElement> {
613
631
  formattedExemplarPoints?: Array<FormattedExemplarPoint> | undefined;
614
632
  onExemplarClick?: ((exemplar: ExemplarPoint) => void) | undefined;
615
633
  onTimeRangeSelect?: ((startTime: Date, endTime: Date) => void) | undefined;
634
+ /**
635
+ * Plain click on a time bucket (the plot background — dot/legend clicks
636
+ * and drag-selections never reach this). [bucketStart, bucketEnd) uses
637
+ * the same adjacent-row width derivation as drag-to-select. Fires only
638
+ * when no toggle-selection is active: a click that clears an active
639
+ * legend/dot selection keeps its existing meaning.
640
+ */
641
+ onBucketClick?:
642
+ | ((
643
+ bucketStart: Date,
644
+ bucketEnd: Date,
645
+ // The clicked row: one numeric entry per series + the axis keys.
646
+ valuesAtBucket: Record<string, number | string>,
647
+ ) => void)
648
+ | undefined;
649
+ /**
650
+ * Categories rendered as compare-to-previous-period ghosts: dashed,
651
+ * faded, no fill, no dots — context, not data.
652
+ */
653
+ ghostCategories?: Array<string> | undefined;
616
654
  /**
617
655
  * Render a shaded "expected range" band underneath the data lines.
618
656
  * Both keys must already be present on every entry of `data` (the
@@ -661,6 +699,8 @@ const AreaChart: React.ForwardRefExoticComponent<
661
699
  formattedTimeReferenceLines,
662
700
  formattedReferenceRegions,
663
701
  onTimeRangeSelect,
702
+ onBucketClick,
703
+ ghostCategories,
664
704
  ...other
665
705
  } = props;
666
706
  const CustomTooltip: React.ComponentType<TooltipProps> | undefined =
@@ -928,7 +968,12 @@ const AreaChart: React.ForwardRefExoticComponent<
928
968
  <ResponsiveContainer>
929
969
  <RechartsAreaChart
930
970
  data={data}
931
- syncId={props.syncid?.toString() || ""}
971
+ /*
972
+ * Omitted, not "": recharts treats "" as a real sync
973
+ * channel, so every unsynced chart page-wide would
974
+ * accidentally sync with every other one.
975
+ */
976
+ {...(props.syncid ? { syncId: props.syncid.toString() } : {})}
932
977
  {...(hasOnTimeRangeSelect
933
978
  ? {
934
979
  onMouseDown: handleRangeSelectMouseDown,
@@ -936,19 +981,51 @@ const AreaChart: React.ForwardRefExoticComponent<
936
981
  onMouseUp: handleRangeSelectMouseUp,
937
982
  }
938
983
  : {})}
939
- onClick={
940
- hasOnValueChange && (activeLegend || activeDot)
941
- ? () => {
942
- // Ignore the click that follows a drag-to-select.
943
- if (suppressNextClickRef.current) {
944
- return;
945
- }
946
- setActiveDot(undefined);
947
- setActiveLegend(undefined);
948
- onValueChange?.(null);
949
- }
950
- : () => {}
951
- }
984
+ onClick={(chartState: RangeSelectionChartState) => {
985
+ // Ignore the click that follows a drag-to-select.
986
+ if (suppressNextClickRef.current) {
987
+ return;
988
+ }
989
+ /*
990
+ * A click while a legend/dot selection is active keeps its
991
+ * long-standing meaning — clear the selection — and never
992
+ * also pins a bucket.
993
+ */
994
+ if (hasOnValueChange && (activeLegend || activeDot)) {
995
+ setActiveDot(undefined);
996
+ setActiveLegend(undefined);
997
+ onValueChange?.(null);
998
+ return;
999
+ }
1000
+ if (!onBucketClick) {
1001
+ return;
1002
+ }
1003
+ const rowIndex: number | null =
1004
+ getRowIndexFromChartState(chartState);
1005
+ if (rowIndex === null) {
1006
+ return;
1007
+ }
1008
+ const bucketStart: Date | null = getBucketDateAtIndex(rowIndex);
1009
+ if (!bucketStart) {
1010
+ return;
1011
+ }
1012
+ /*
1013
+ * Cover the full bucket: width from adjacent row dates, the
1014
+ * same derivation drag-to-select uses.
1015
+ */
1016
+ const adjacentDate: Date | null =
1017
+ rowIndex > 0
1018
+ ? getBucketDateAtIndex(rowIndex - 1)
1019
+ : getBucketDateAtIndex(rowIndex + 1);
1020
+ const bucketWidthInMs: number = adjacentDate
1021
+ ? Math.abs(bucketStart.getTime() - adjacentDate.getTime())
1022
+ : 0;
1023
+ onBucketClick(
1024
+ bucketStart,
1025
+ new Date(bucketStart.getTime() + bucketWidthInMs),
1026
+ (data[rowIndex] || {}) as Record<string, number | string>,
1027
+ );
1028
+ }}
952
1029
  margin={{
953
1030
  bottom: (xAxisLabel
954
1031
  ? 40
@@ -1173,6 +1250,8 @@ const AreaChart: React.ForwardRefExoticComponent<
1173
1250
  ) as ChartColorValue;
1174
1251
  const hex: string = getColorHex(colorKey);
1175
1252
  const isCustomColor: boolean = isHexColorValue(colorKey);
1253
+ const isGhost: boolean =
1254
+ ghostCategories?.includes(category) || false;
1176
1255
 
1177
1256
  return (
1178
1257
  <Area
@@ -1181,92 +1260,103 @@ const AreaChart: React.ForwardRefExoticComponent<
1181
1260
  type={props.curve || ChartCurve.MONOTONE}
1182
1261
  dataKey={category}
1183
1262
  stroke={hex}
1184
- strokeWidth={2}
1263
+ strokeWidth={isGhost ? 1.5 : 2}
1185
1264
  strokeLinejoin="round"
1186
1265
  strokeLinecap="round"
1187
1266
  fill={`url(#${gradientId})`}
1188
- fillOpacity={1}
1267
+ fillOpacity={isGhost ? 0 : 1}
1189
1268
  strokeOpacity={
1190
- activeDot || (activeLegend && activeLegend !== category)
1269
+ (activeDot || (activeLegend && activeLegend !== category)
1191
1270
  ? 0.3
1192
- : 1
1271
+ : 1) * (isGhost ? 0.45 : 1)
1193
1272
  }
1194
1273
  isAnimationActive={false}
1195
1274
  connectNulls={connectNulls}
1196
1275
  // eslint-disable-next-line @typescript-eslint/no-explicit-any
1197
- activeDot={(dotProps: any) => {
1198
- const {
1199
- cx: cxCoord,
1200
- cy: cyCoord,
1201
- stroke,
1202
- strokeLinecap: slc,
1203
- strokeLinejoin: slj,
1204
- strokeWidth,
1205
- } = dotProps;
1206
- return (
1207
- <Dot
1208
- className={cx(
1209
- "stroke-white",
1210
- onValueChange ? "cursor-pointer" : "",
1211
- getColorClassName(colorKey, "fill"),
1212
- )}
1213
- cx={cxCoord}
1214
- cy={cyCoord}
1215
- r={5}
1216
- fill={isCustomColor ? hex : ""}
1217
- stroke={stroke}
1218
- strokeLinecap={slc}
1219
- strokeLinejoin={slj}
1220
- strokeWidth={strokeWidth}
1221
- // eslint-disable-next-line @typescript-eslint/no-explicit-any
1222
- onClick={(_: any, event: any) => {
1223
- return onDotClick(dotProps, event);
1224
- }}
1225
- />
1226
- );
1227
- }}
1276
+ activeDot={
1277
+ isGhost
1278
+ ? false
1279
+ : (dotProps: any) => {
1280
+ const {
1281
+ cx: cxCoord,
1282
+ cy: cyCoord,
1283
+ stroke,
1284
+ strokeLinecap: slc,
1285
+ strokeLinejoin: slj,
1286
+ strokeWidth,
1287
+ } = dotProps;
1288
+ return (
1289
+ <Dot
1290
+ className={cx(
1291
+ "stroke-white",
1292
+ onValueChange ? "cursor-pointer" : "",
1293
+ getColorClassName(colorKey, "fill"),
1294
+ )}
1295
+ cx={cxCoord}
1296
+ cy={cyCoord}
1297
+ r={5}
1298
+ fill={isCustomColor ? hex : ""}
1299
+ stroke={stroke}
1300
+ strokeLinecap={slc}
1301
+ strokeLinejoin={slj}
1302
+ strokeWidth={strokeWidth}
1303
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
1304
+ onClick={(_: any, event: any) => {
1305
+ return onDotClick(dotProps, event);
1306
+ }}
1307
+ />
1308
+ );
1309
+ }
1310
+ }
1228
1311
  // eslint-disable-next-line @typescript-eslint/no-explicit-any
1229
- dot={(dotProps: any) => {
1230
- const {
1231
- stroke,
1232
- strokeLinecap: slc,
1233
- strokeLinejoin: slj,
1234
- strokeWidth,
1235
- cx: cxCoord,
1236
- cy: cyCoord,
1237
- index: dotIndex,
1238
- } = dotProps;
1239
-
1240
- if (
1241
- (hasOnlyOneValueForKey(data, category) &&
1242
- !(
1243
- activeDot ||
1244
- (activeLegend && activeLegend !== category)
1245
- )) ||
1246
- (activeDot?.index === dotIndex &&
1247
- activeDot?.dataKey === category)
1248
- ) {
1249
- return (
1250
- <Dot
1251
- key={dotIndex}
1252
- cx={cxCoord}
1253
- cy={cyCoord}
1254
- r={5}
1255
- stroke={stroke}
1256
- fill={isCustomColor ? hex : ""}
1257
- strokeLinecap={slc}
1258
- strokeLinejoin={slj}
1259
- strokeWidth={strokeWidth}
1260
- className={cx(
1261
- "stroke-white",
1262
- onValueChange ? "cursor-pointer" : "",
1263
- getColorClassName(colorKey, "fill"),
1264
- )}
1265
- />
1266
- );
1267
- }
1268
- return <React.Fragment key={dotIndex}></React.Fragment>;
1269
- }}
1312
+ dot={
1313
+ isGhost
1314
+ ? false
1315
+ : (dotProps: any) => {
1316
+ const {
1317
+ stroke,
1318
+ strokeLinecap: slc,
1319
+ strokeLinejoin: slj,
1320
+ strokeWidth,
1321
+ cx: cxCoord,
1322
+ cy: cyCoord,
1323
+ index: dotIndex,
1324
+ } = dotProps;
1325
+
1326
+ if (
1327
+ (hasOnlyOneValueForKey(data, category) &&
1328
+ !(
1329
+ activeDot ||
1330
+ (activeLegend && activeLegend !== category)
1331
+ )) ||
1332
+ (activeDot?.index === dotIndex &&
1333
+ activeDot?.dataKey === category)
1334
+ ) {
1335
+ return (
1336
+ <Dot
1337
+ key={dotIndex}
1338
+ cx={cxCoord}
1339
+ cy={cyCoord}
1340
+ r={5}
1341
+ stroke={stroke}
1342
+ fill={isCustomColor ? hex : ""}
1343
+ strokeLinecap={slc}
1344
+ strokeLinejoin={slj}
1345
+ strokeWidth={strokeWidth}
1346
+ className={cx(
1347
+ "stroke-white",
1348
+ onValueChange ? "cursor-pointer" : "",
1349
+ getColorClassName(colorKey, "fill"),
1350
+ )}
1351
+ />
1352
+ );
1353
+ }
1354
+ return (
1355
+ <React.Fragment key={dotIndex}></React.Fragment>
1356
+ );
1357
+ }
1358
+ }
1359
+ {...(isGhost ? { strokeDasharray: "6 4" } : {})}
1270
1360
  />
1271
1361
  );
1272
1362
  })}
@@ -1310,7 +1400,9 @@ const AreaChart: React.ForwardRefExoticComponent<
1310
1400
  {...(region.original.onClick
1311
1401
  ? {
1312
1402
  style: { cursor: "pointer" as const },
1313
- onClick: (): void => {
1403
+ onClick: (...args: Array<unknown>): void => {
1404
+ // Never let an annotation click also pin a bucket.
1405
+ stopChartEventPropagation(...args);
1314
1406
  if (suppressNextClickRef.current) {
1315
1407
  return;
1316
1408
  }
@@ -1352,7 +1444,9 @@ const AreaChart: React.ForwardRefExoticComponent<
1352
1444
  {...(timeRefLine.original.onClick
1353
1445
  ? {
1354
1446
  style: { cursor: "pointer" as const },
1355
- onClick: (): void => {
1447
+ onClick: (...args: Array<unknown>): void => {
1448
+ // Never let an annotation click also pin a bucket.
1449
+ stopChartEventPropagation(...args);
1356
1450
  if (suppressNextClickRef.current) {
1357
1451
  return;
1358
1452
  }
@@ -1398,7 +1492,9 @@ const AreaChart: React.ForwardRefExoticComponent<
1398
1492
  stroke="var(--ou-chart-marker-ring, #ffffff)"
1399
1493
  strokeWidth={2}
1400
1494
  style={{ cursor: "pointer" }}
1401
- onClick={() => {
1495
+ onClick={(...args: Array<unknown>) => {
1496
+ // Never let an exemplar click also pin a bucket.
1497
+ stopChartEventPropagation(...args);
1402
1498
  // Ignore the click that follows a drag-to-select.
1403
1499
  if (suppressNextClickRef.current) {
1404
1500
  return;
@@ -843,7 +843,12 @@ const BarChart: React.ForwardRefExoticComponent<
843
843
  <ResponsiveContainer>
844
844
  <RechartsBarChart
845
845
  data={data}
846
- syncId={props.syncid?.toString() || ""}
846
+ /*
847
+ * Omitted, not "": recharts treats "" as a real sync
848
+ * channel, so every unsynced chart page-wide would
849
+ * accidentally sync with every other one.
850
+ */
851
+ {...(props.syncid ? { syncId: props.syncid.toString() } : {})}
847
852
  {...(hasOnValueChange && (activeLegend || activeBar)
848
853
  ? {
849
854
  onClick: handleChartClick,