@oneuptime/common 12.0.20 → 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 (86) hide show
  1. package/Models/AnalyticsModels/ChangeEvent.ts +331 -0
  2. package/Models/AnalyticsModels/Index.ts +2 -0
  3. package/Server/EnvironmentConfig.ts +1 -8
  4. package/Server/Infrastructure/Postgres/SchemaMigrations/1789000000000-AddUserSlackAndMicrosoftTeams.ts +42 -0
  5. package/Server/Infrastructure/Postgres/SchemaMigrations/Index.ts +0 -2
  6. package/Server/Services/ChangeEventService.ts +11 -0
  7. package/Server/Services/EnterpriseLicenseService.ts +3 -3
  8. package/Server/Services/Index.ts +2 -0
  9. package/Server/Utils/Attribution.ts +11 -11
  10. package/Server/Utils/Marketing/MarketingEventWebhook.ts +4 -5
  11. package/Server/Utils/Telemetry/ChangeEventRow.ts +228 -0
  12. package/Server/Views/Partials/AnalyticsConsent.ejs +3 -95
  13. package/Tests/App/Dashboard/DashboardChartWidgetZoom.test.tsx +64 -0
  14. package/Tests/App/Dashboard/EventOverlayHook.test.tsx +249 -0
  15. package/Tests/App/Dashboard/InvestigationArtifacts.test.tsx +315 -0
  16. package/Tests/App/Dashboard/InvestigationDrawer.test.tsx +258 -0
  17. package/Tests/App/Dashboard/MetricChartsCompare.test.tsx +171 -0
  18. package/Tests/App/Dashboard/MetricViewCompare.test.tsx +233 -0
  19. package/Tests/Server/Infrastructure/Postgres/AddUserSlackAndMicrosoftTeamsMigration.test.ts +236 -0
  20. package/Tests/Server/Utils/AI/SRE/Insights/Detectors/ExceptionIdentity.test.ts +224 -0
  21. package/Tests/Server/Utils/Attribution.test.ts +4 -5
  22. package/Tests/Server/Utils/Marketing/MarketingEventUtil.test.ts +2 -2
  23. package/Tests/Server/Utils/Monitor/MonitorCriteriaExpectationBuilder.test.ts +300 -0
  24. package/Tests/UI/Components/Charts/ChartGhostSeries.test.tsx +103 -0
  25. package/Tests/UI/Components/Charts/TooltipEntries.test.ts +44 -8
  26. package/Tests/Utils/Telemetry/CrossSignalScope.test.ts +13 -2
  27. package/Types/AnalyticsDatabase/AnalyticsTableName.ts +7 -0
  28. package/Types/Marketing/Attribution.ts +9 -15
  29. package/Types/Marketing/MarketingEvent.ts +10 -46
  30. package/UI/Components/Charts/Area/AreaChart.tsx +15 -0
  31. package/UI/Components/Charts/ChartGroup/ChartGroup.tsx +16 -1
  32. package/UI/Components/Charts/ChartLibrary/AreaChart/AreaChart.tsx +189 -93
  33. package/UI/Components/Charts/ChartLibrary/BarChart/BarChart.tsx +6 -1
  34. package/UI/Components/Charts/ChartLibrary/LineChart/LineChart.tsx +213 -114
  35. package/UI/Components/Charts/ChartLibrary/Utils/TooltipEntries.ts +42 -27
  36. package/UI/Components/Charts/Line/LineChart.tsx +15 -0
  37. package/Utils/Metrics/MetricExplorerUrl.ts +119 -5
  38. package/Utils/Telemetry/CrossSignalScope.ts +19 -10
  39. package/build/dist/Models/AnalyticsModels/ChangeEvent.js +298 -0
  40. package/build/dist/Models/AnalyticsModels/ChangeEvent.js.map +1 -0
  41. package/build/dist/Models/AnalyticsModels/Index.js +2 -0
  42. package/build/dist/Models/AnalyticsModels/Index.js.map +1 -1
  43. package/build/dist/Server/EnvironmentConfig.js +1 -7
  44. package/build/dist/Server/EnvironmentConfig.js.map +1 -1
  45. package/build/dist/Server/Infrastructure/Postgres/SchemaMigrations/1789000000000-AddUserSlackAndMicrosoftTeams.js +39 -0
  46. package/build/dist/Server/Infrastructure/Postgres/SchemaMigrations/1789000000000-AddUserSlackAndMicrosoftTeams.js.map +1 -1
  47. package/build/dist/Server/Infrastructure/Postgres/SchemaMigrations/Index.js +0 -2
  48. package/build/dist/Server/Infrastructure/Postgres/SchemaMigrations/Index.js.map +1 -1
  49. package/build/dist/Server/Services/ChangeEventService.js +9 -0
  50. package/build/dist/Server/Services/ChangeEventService.js.map +1 -0
  51. package/build/dist/Server/Services/EnterpriseLicenseService.js +3 -3
  52. package/build/dist/Server/Services/Index.js +2 -0
  53. package/build/dist/Server/Services/Index.js.map +1 -1
  54. package/build/dist/Server/Utils/Attribution.js +6 -6
  55. package/build/dist/Server/Utils/Marketing/MarketingEventWebhook.js +4 -5
  56. package/build/dist/Server/Utils/Marketing/MarketingEventWebhook.js.map +1 -1
  57. package/build/dist/Server/Utils/Telemetry/ChangeEventRow.js +150 -0
  58. package/build/dist/Server/Utils/Telemetry/ChangeEventRow.js.map +1 -0
  59. package/build/dist/Types/AnalyticsDatabase/AnalyticsTableName.js +7 -0
  60. package/build/dist/Types/AnalyticsDatabase/AnalyticsTableName.js.map +1 -1
  61. package/build/dist/Types/Marketing/Attribution.js +9 -15
  62. package/build/dist/Types/Marketing/Attribution.js.map +1 -1
  63. package/build/dist/Types/Marketing/MarketingEvent.js +2 -33
  64. package/build/dist/Types/Marketing/MarketingEvent.js.map +1 -1
  65. package/build/dist/UI/Components/Charts/Area/AreaChart.js +1 -1
  66. package/build/dist/UI/Components/Charts/Area/AreaChart.js.map +1 -1
  67. package/build/dist/UI/Components/Charts/ChartGroup/ChartGroup.js +9 -1
  68. package/build/dist/UI/Components/Charts/ChartGroup/ChartGroup.js.map +1 -1
  69. package/build/dist/UI/Components/Charts/ChartLibrary/AreaChart/AreaChart.js +92 -38
  70. package/build/dist/UI/Components/Charts/ChartLibrary/AreaChart/AreaChart.js.map +1 -1
  71. package/build/dist/UI/Components/Charts/ChartLibrary/BarChart/BarChart.js +8 -8
  72. package/build/dist/UI/Components/Charts/ChartLibrary/BarChart/BarChart.js.map +1 -1
  73. package/build/dist/UI/Components/Charts/ChartLibrary/LineChart/LineChart.js +95 -37
  74. package/build/dist/UI/Components/Charts/ChartLibrary/LineChart/LineChart.js.map +1 -1
  75. package/build/dist/UI/Components/Charts/ChartLibrary/Utils/TooltipEntries.js +39 -24
  76. package/build/dist/UI/Components/Charts/ChartLibrary/Utils/TooltipEntries.js.map +1 -1
  77. package/build/dist/UI/Components/Charts/Line/LineChart.js +1 -1
  78. package/build/dist/UI/Components/Charts/Line/LineChart.js.map +1 -1
  79. package/build/dist/Utils/Metrics/MetricExplorerUrl.js +81 -0
  80. package/build/dist/Utils/Metrics/MetricExplorerUrl.js.map +1 -1
  81. package/build/dist/Utils/Telemetry/CrossSignalScope.js +14 -8
  82. package/build/dist/Utils/Telemetry/CrossSignalScope.js.map +1 -1
  83. package/package.json +1 -1
  84. package/Server/Infrastructure/Postgres/SchemaMigrations/1788900000000-RedactStoredMonitorIngestSecrets.ts +0 -117
  85. package/build/dist/Server/Infrastructure/Postgres/SchemaMigrations/1788900000000-RedactStoredMonitorIngestSecrets.js +0 -106
  86. package/build/dist/Server/Infrastructure/Postgres/SchemaMigrations/1788900000000-RedactStoredMonitorIngestSecrets.js.map +0 -1
@@ -0,0 +1,228 @@
1
+ import Dictionary from "../../../Types/Dictionary";
2
+ import OneUptimeDate from "../../../Types/Date";
3
+ import ObjectID from "../../../Types/ObjectID";
4
+ import { JSONArray, JSONObject } from "../../../Types/JSON";
5
+ /*
6
+ * Type-only: pulling the ingest service's runtime graph into this pure
7
+ * module would drag ClickHouse/DB deps into every test that touches it.
8
+ */
9
+ import type { TelemetryServiceMetadata } from "../../Services/OpenTelemetryIngestService";
10
+
11
+ /*
12
+ * Change-event ingest parsing + ClickHouse row building. Pure on purpose
13
+ * (no network, no request objects) so App/Tests can pin every validation
14
+ * rule without a server.
15
+ */
16
+
17
+ /*
18
+ * Events whose source timestamp is outside this window are stamped with
19
+ * the ingestion time instead (original preserved in attributes). The
20
+ * table partitions by toYYYYMMDD(time), so a forged or garbage timestamp
21
+ * must not be allowed to create arbitrary partitions.
22
+ */
23
+ export const MAX_CHANGE_EVENT_AGE_IN_DAYS: number = 366;
24
+ export const MAX_CHANGE_EVENT_FUTURE_SKEW_IN_MINUTES: number = 60;
25
+
26
+ // Caps keep a CI script's mistake from becoming a storage problem.
27
+ export const MAX_CHANGE_EVENTS_PER_REQUEST: number = 100;
28
+ export const MAX_CHANGE_EVENT_TITLE_LENGTH: number = 500;
29
+ export const MAX_CHANGE_EVENT_DESCRIPTION_LENGTH: number = 5000;
30
+ export const MAX_CHANGE_EVENT_TYPE_LENGTH: number = 50;
31
+ export const MAX_CHANGE_EVENT_ATTRIBUTES: number = 50;
32
+
33
+ export const DEFAULT_CHANGE_EVENT_TYPE: string = "deployment";
34
+
35
+ /*
36
+ * Serviceless change events (project-wide markers) have no service
37
+ * retention ladder to consult; deploy markers are tiny and valuable for
38
+ * long-baseline comparisons, so keep them a year.
39
+ */
40
+ export const DEFAULT_CHANGE_EVENT_RETENTION_IN_DAYS: number = 365;
41
+
42
+ export interface ParsedChangeEventEntry {
43
+ /** null → stamp with the ingestion time. */
44
+ time: Date | null;
45
+ eventType: string;
46
+ title: string;
47
+ description: string;
48
+ attributes: Dictionary<string>;
49
+ }
50
+
51
+ function parseEntryTime(value: unknown): Date | null {
52
+ if (typeof value === "number" && Number.isFinite(value)) {
53
+ /*
54
+ * Epoch seconds vs milliseconds: anything below 1e12 read as ms would
55
+ * be before 2001 — CI clocks don't say that; treat it as seconds.
56
+ */
57
+ const ms: number = value < 1e12 ? value * 1000 : value;
58
+ const date: Date = new Date(ms);
59
+ return Number.isNaN(date.getTime()) ? null : date;
60
+ }
61
+ if (typeof value === "string" && value.trim() !== "") {
62
+ const date: Date = new Date(value);
63
+ return Number.isNaN(date.getTime()) ? null : date;
64
+ }
65
+ return null;
66
+ }
67
+
68
+ /**
69
+ * Validate one raw ingest entry. Returns null when the entry cannot be a
70
+ * change event at all (no usable title); every other malformation is
71
+ * repaired (defaults, trims, caps) rather than rejected — CI pipelines
72
+ * should not fail a deploy over a long description.
73
+ */
74
+ export function parseChangeEventIngestEntry(
75
+ entry: JSONObject,
76
+ ): ParsedChangeEventEntry | null {
77
+ if (!entry || typeof entry !== "object" || Array.isArray(entry)) {
78
+ return null;
79
+ }
80
+
81
+ const rawTitle: unknown = entry["title"] ?? entry["name"];
82
+ const title: string =
83
+ typeof rawTitle === "string"
84
+ ? rawTitle.trim().slice(0, MAX_CHANGE_EVENT_TITLE_LENGTH)
85
+ : "";
86
+
87
+ if (title === "") {
88
+ return null;
89
+ }
90
+
91
+ const rawEventType: unknown = entry["eventType"] ?? entry["type"];
92
+ const eventType: string =
93
+ typeof rawEventType === "string" && rawEventType.trim() !== ""
94
+ ? rawEventType.trim().toLowerCase().slice(0, MAX_CHANGE_EVENT_TYPE_LENGTH)
95
+ : DEFAULT_CHANGE_EVENT_TYPE;
96
+
97
+ const rawDescription: unknown = entry["description"];
98
+ const description: string =
99
+ typeof rawDescription === "string"
100
+ ? rawDescription.trim().slice(0, MAX_CHANGE_EVENT_DESCRIPTION_LENGTH)
101
+ : "";
102
+
103
+ const attributes: Dictionary<string> = {};
104
+ const rawAttributes: unknown = entry["attributes"];
105
+ if (
106
+ rawAttributes &&
107
+ typeof rawAttributes === "object" &&
108
+ !Array.isArray(rawAttributes)
109
+ ) {
110
+ for (const key of Object.keys(rawAttributes as JSONObject)) {
111
+ if (key.trim() === "") {
112
+ continue;
113
+ }
114
+ if (Object.keys(attributes).length >= MAX_CHANGE_EVENT_ATTRIBUTES) {
115
+ break;
116
+ }
117
+ const value: unknown = (rawAttributes as JSONObject)[key];
118
+ if (
119
+ typeof value === "string" ||
120
+ typeof value === "number" ||
121
+ typeof value === "boolean"
122
+ ) {
123
+ attributes[key] = String(value);
124
+ }
125
+ }
126
+ }
127
+
128
+ return {
129
+ time: parseEntryTime(entry["time"] ?? entry["timestamp"]),
130
+ eventType,
131
+ title,
132
+ description,
133
+ attributes,
134
+ };
135
+ }
136
+
137
+ /**
138
+ * Accept the shapes CI scripts actually send: a bare object (one event),
139
+ * a bare array, or { events: [...] }.
140
+ */
141
+ export function extractChangeEventEntries(body: unknown): Array<JSONObject> {
142
+ if (!body) {
143
+ return [];
144
+ }
145
+
146
+ if (Array.isArray(body)) {
147
+ return (body as JSONArray).filter((item: unknown): boolean => {
148
+ return typeof item === "object" && item !== null && !Array.isArray(item);
149
+ }) as Array<JSONObject>;
150
+ }
151
+
152
+ if (typeof body !== "object") {
153
+ return [];
154
+ }
155
+
156
+ const asObject: JSONObject = body as JSONObject;
157
+ const nested: unknown = asObject["events"];
158
+ if (Array.isArray(nested)) {
159
+ return extractChangeEventEntries(nested);
160
+ }
161
+
162
+ // A single bare event object — but not the {events: <non-array>} shape.
163
+ if (nested === undefined && Object.keys(asObject).length > 0) {
164
+ return [asObject];
165
+ }
166
+
167
+ return [];
168
+ }
169
+
170
+ /**
171
+ * ParsedChangeEventEntry -> ChangeEvent ClickHouse row.
172
+ */
173
+ export function buildChangeEventDbRow(data: {
174
+ parsed: ParsedChangeEventEntry;
175
+ projectId: ObjectID;
176
+ serviceMetadata: TelemetryServiceMetadata | null;
177
+ retentionDays: number;
178
+ }): JSONObject {
179
+ const { parsed, projectId, serviceMetadata, retentionDays } = data;
180
+
181
+ const ingestionDate: Date = OneUptimeDate.getCurrentDate();
182
+
183
+ let eventTime: Date = parsed.time || ingestionDate;
184
+ let attributes: Dictionary<string> = { ...parsed.attributes };
185
+
186
+ const ageInDays: number = OneUptimeDate.getNumberOfDaysBetweenDates(
187
+ eventTime,
188
+ ingestionDate,
189
+ );
190
+ const futureSkewInMinutes: number =
191
+ (eventTime.getTime() - ingestionDate.getTime()) / (60 * 1000);
192
+
193
+ if (
194
+ Number.isNaN(eventTime.getTime()) ||
195
+ ageInDays > MAX_CHANGE_EVENT_AGE_IN_DAYS ||
196
+ futureSkewInMinutes > MAX_CHANGE_EVENT_FUTURE_SKEW_IN_MINUTES
197
+ ) {
198
+ attributes = {
199
+ ...attributes,
200
+ "oneuptime.original_time": String(parsed.time),
201
+ };
202
+ eventTime = ingestionDate;
203
+ }
204
+
205
+ const retentionDate: Date = OneUptimeDate.addRemoveDays(
206
+ ingestionDate,
207
+ retentionDays,
208
+ );
209
+
210
+ return {
211
+ _id: ObjectID.generateTimeOrdered().toString(),
212
+ createdAt: OneUptimeDate.toClickhouseDateTime(ingestionDate),
213
+ projectId: projectId.toString(),
214
+ ...(serviceMetadata
215
+ ? {
216
+ primaryEntityId: serviceMetadata.primaryEntityId.toString(),
217
+ primaryEntityType: serviceMetadata.primaryEntityType,
218
+ }
219
+ : {}),
220
+ time: OneUptimeDate.toClickhouseDateTime64(eventTime),
221
+ eventType: parsed.eventType,
222
+ title: parsed.title,
223
+ description: parsed.description,
224
+ attributes,
225
+ attributeKeys: Object.keys(attributes).sort(),
226
+ retentionDate: OneUptimeDate.toClickhouseDateTime(retentionDate),
227
+ } satisfies JSONObject;
228
+ }
@@ -28,9 +28,7 @@
28
28
  the home/-prefixed page view and CTA listeners. Those are Home's alone and
29
29
  stay in head-basic.ejs. Reporting on a captured touch is handed back to
30
30
  the page through window.oneUptimeOnAttributionCaptured for the same
31
- reason. The Cal booking helper does live here: it is a pure reader over
32
- the shared attribution store, and splitting it from the store it reads
33
- would be worse than sharing an unused function definition.
31
+ reason.
34
32
  -%>
35
33
  <!--
36
34
  Consent.
@@ -168,9 +166,8 @@
168
166
 
169
167
  <!--
170
168
  Attribution capture (UTM parameters + ad platform click IDs), and the
171
- readers that hand it on to the two places it has to reach: the signup form
172
- (through localStorage, same origin) and a Cal.com booking (through the
173
- embed's metadata).
169
+ reader that hands it on to the signup form (through localStorage, same
170
+ origin).
174
171
 
175
172
  STORAGE MODEL
176
173
 
@@ -211,9 +208,6 @@
211
208
  // Mirrors MaxAttributionValueLength on the server.
212
209
  var MAX_VALUE_LENGTH = 500;
213
210
 
214
- // Mirrors MAX_FIRST_TOUCH_LENGTH in App/API/CalWebhook.ts.
215
- var MAX_FIRST_TOUCH_LENGTH = 4000;
216
-
217
211
  // Attribution seen on this page but not yet allowed to be stored.
218
212
  var pending = null;
219
213
 
@@ -384,92 +378,6 @@
384
378
  };
385
379
  };
386
380
 
387
- /*
388
- * The same attribution, flattened into the scalar string map Cal.com
389
- * accepts as booking metadata.
390
- *
391
- * Keys are the snake_case wire spelling the server parses in
392
- * App/API/CalWebhook.ts. The first touch is a nested object, and Cal
393
- * metadata values are scalars, so it travels as one JSON string under
394
- * ou_first_touch — which is exactly what the webhook expects to parse.
395
- *
396
- * Returns {} when there is nothing to attribute, so a caller can spread
397
- * it unconditionally without sending an empty metadata bag.
398
- */
399
- /*
400
- * `bookingKind` says which conversation this embed books. All three
401
- * embeds use the same Cal event type, so without it the webhook cannot
402
- * tell a support call from an enterprise demo. It travels in the same
403
- * bracketed metadata bag as the attribution and is read back by
404
- * App/API/CalWebhook.ts, which allowlists the value.
405
- */
406
- window.oneUptimeCalAttributionMetadata = function (bookingKind) {
407
- var attribution = window.oneUptimeGetAttribution();
408
- var config = {};
409
-
410
- /*
411
- * Cal takes booking metadata as FLAT, BRACKETED keys on the embed
412
- * config — `metadata[utm_source]` — and hands them back on the
413
- * webhook as payload.metadata.utm_source.
414
- *
415
- * Not as a nested `metadata: { utm_source: ... }` object. Cal
416
- * serialises each config value into a query parameter, so a nested
417
- * object becomes the string "[object Object]" and every key inside
418
- * it is lost. Nothing errors; bookings keep being recorded, they
419
- * just arrive attributable to nothing — which is precisely the bug
420
- * this whole path exists to fix, so getting the shape wrong here
421
- * would have been indistinguishable from not having done the work.
422
- *
423
- * https://cal.com/help/embedding/prefill-booking-form-embed
424
- */
425
- function put(key, value, maxLength) {
426
- if (value) {
427
- config['metadata[' + key + ']'] =
428
- String(value).slice(0, maxLength || MAX_VALUE_LENGTH);
429
- }
430
- }
431
-
432
- Object.keys(UTM_KEY_MAP).forEach(function (param) {
433
- put(param, attribution.utm[UTM_KEY_MAP[param]]);
434
- });
435
-
436
- put('utm_url', attribution.utm.utmUrl);
437
-
438
- Object.keys(attribution.clickIds).forEach(function (param) {
439
- put(param, attribution.clickIds[param]);
440
- });
441
-
442
- if (attribution.firstTouch) {
443
- var serialized = JSON.stringify(attribution.firstTouch);
444
- /*
445
- * The first touch is JSON, so it gets the JSON bound, NOT the
446
- * 500-char per-value one. A realistic first touch — five UTM
447
- * values, a click id, and a landing URL that repeats them all
448
- * as query parameters — runs past 500 characters easily, and
449
- * truncating JSON produces a string the webhook's JSON.parse
450
- * rejects. It would drop the whole first touch, silently.
451
- *
452
- * Mirrors MAX_FIRST_TOUCH_LENGTH in App/API/CalWebhook.ts.
453
- */
454
- if (serialized.length <= MAX_FIRST_TOUCH_LENGTH) {
455
- put('ou_first_touch', serialized, MAX_FIRST_TOUCH_LENGTH);
456
- }
457
- }
458
-
459
- /*
460
- * Unconditional, unlike everything above it. The attribution keys
461
- * describe the visitor and are absent when there is nothing to say
462
- * about them; the booking kind describes the embed, which always
463
- * knows what it is. A booking from a visitor who refused
464
- * attribution storage still arrives knowing it was a demo.
465
- */
466
- if (bookingKind) {
467
- config['metadata[ou_booking_kind]'] = String(bookingKind).slice(0, MAX_VALUE_LENGTH);
468
- }
469
-
470
- return config;
471
- };
472
-
473
381
  var touch = readTouchFromUrl();
474
382
 
475
383
  if (touch) {
@@ -184,6 +184,7 @@ function renderWidget(
184
184
 
185
185
  interface CapturedChartProps {
186
186
  metricViewData: MetricViewData;
187
+ metricResults?: Array<Record<string, unknown>> | undefined;
187
188
  onTimeRangeSelect?: ((startTime: Date, endTime: Date) => void) | undefined;
188
189
  enableSeriesActions?: boolean | undefined;
189
190
  }
@@ -288,6 +289,69 @@ describe("dashboard chart widget drag-to-zoom", () => {
288
289
  expect(lastChartProps().metricViewData.rangeToken).toBe(TimeRange.CUSTOM);
289
290
  });
290
291
 
292
+ test("a stale fetch response can never overwrite a newer window's data", async () => {
293
+ /*
294
+ * Zoom then Reset puts two aggregate calls in flight; if the SLOWER
295
+ * (zoom) response lands last, its data must be discarded — otherwise
296
+ * the chart paints the zoomed window's series under the dashboard
297
+ * window's axis.
298
+ */
299
+ const pendingFetches: Array<(value: unknown) => void> = [];
300
+ fetchResultsMock.mockImplementation(() => {
301
+ return new Promise((resolve: (value: unknown) => void) => {
302
+ pendingFetches.push(resolve);
303
+ });
304
+ });
305
+
306
+ renderWidget();
307
+
308
+ await waitFor(() => {
309
+ expect(pendingFetches.length).toBe(1);
310
+ });
311
+ act(() => {
312
+ pendingFetches[0]?.([{ data: [], truncated: false }]);
313
+ });
314
+ await waitFor(() => {
315
+ expect(screen.getByTestId("metric-charts")).toBeInTheDocument();
316
+ });
317
+
318
+ act(() => {
319
+ lastChartProps().onTimeRangeSelect?.(ZOOM_START, ZOOM_END);
320
+ });
321
+ await waitFor(() => {
322
+ expect(pendingFetches.length).toBe(2);
323
+ });
324
+
325
+ fireEvent.click(screen.getByText("Reset"));
326
+ await waitFor(() => {
327
+ expect(pendingFetches.length).toBe(3);
328
+ });
329
+
330
+ const staleZoomResults: Array<Record<string, unknown>> = [
331
+ { data: [{ marker: "stale-zoom" }], truncated: false },
332
+ ];
333
+ const freshResetResults: Array<Record<string, unknown>> = [
334
+ { data: [], truncated: false },
335
+ ];
336
+
337
+ // The newer (reset) fetch answers first…
338
+ act(() => {
339
+ pendingFetches[2]?.(freshResetResults);
340
+ });
341
+ await waitFor(() => {
342
+ expect(lastChartProps().metricResults).toBe(freshResetResults);
343
+ });
344
+
345
+ // …then the stale zoom fetch answers last and must be ignored.
346
+ act(() => {
347
+ pendingFetches[1]?.(staleZoomResults);
348
+ });
349
+ await act(async () => {
350
+ await Promise.resolve();
351
+ });
352
+ expect(lastChartProps().metricResults).toBe(freshResetResults);
353
+ });
354
+
291
355
  test("edit mode disables zoom and series navigation", async () => {
292
356
  renderWidget({ isEditMode: true });
293
357
 
@@ -0,0 +1,249 @@
1
+ import "@testing-library/jest-dom";
2
+ import {
3
+ afterEach,
4
+ beforeEach,
5
+ describe,
6
+ expect,
7
+ jest,
8
+ test,
9
+ } from "@jest/globals";
10
+ import { cleanup, render, screen, waitFor } from "@testing-library/react";
11
+ import * as React from "react";
12
+ import getJestMockFunction, { MockFunction } from "../../MockType";
13
+
14
+ /*
15
+ * The shared event-overlay hook is what puts "what happened here" on every
16
+ * metric chart: incidents and alerts as solid severity-colored markers
17
+ * with click-through, change events (deploys) as dashed indigo markers.
18
+ * It must fetch nothing on public dashboards and survive any single
19
+ * source failing.
20
+ */
21
+
22
+ const getListMock: MockFunction = getJestMockFunction();
23
+ const analyticsGetListMock: MockFunction = getJestMockFunction();
24
+ const isPublicDashboardMock: MockFunction = getJestMockFunction();
25
+
26
+ /*
27
+ * The arrow wrappers are load bearing: jest.mock is hoisted above the
28
+ * compiled requires, so the mock variables above are still unassigned when
29
+ * the factory runs.
30
+ */
31
+ jest.mock("../../../UI/Utils/ModelAPI/ModelAPI", () => {
32
+ return {
33
+ __esModule: true,
34
+ default: {
35
+ getList: (...args: Array<any>) => {
36
+ return getListMock(...args);
37
+ },
38
+ },
39
+ };
40
+ });
41
+
42
+ jest.mock("../../../UI/Utils/AnalyticsModelAPI/AnalyticsModelAPI", () => {
43
+ return {
44
+ __esModule: true,
45
+ default: {
46
+ getList: (...args: Array<any>) => {
47
+ return analyticsGetListMock(...args);
48
+ },
49
+ },
50
+ };
51
+ });
52
+
53
+ jest.mock(
54
+ "../../../../App/FeatureSet/Dashboard/src/Components/Dashboard/Utils/PublicDashboardContext",
55
+ () => {
56
+ return {
57
+ __esModule: true,
58
+ isPublicDashboard: (...args: Array<any>) => {
59
+ return isPublicDashboardMock(...args);
60
+ },
61
+ };
62
+ },
63
+ );
64
+
65
+ import useEventTimeReferenceLines, {
66
+ EventTimeReferenceLines,
67
+ } from "../../../../App/FeatureSet/Dashboard/src/Components/Metrics/Utils/UseEventTimeReferenceLines";
68
+ import ChartTimeReferenceLineProps from "../../../UI/Components/Charts/Types/TimeReferenceLineProps";
69
+ import InBetween from "../../../Types/BaseDatabase/InBetween";
70
+ import ObjectID from "../../../Types/ObjectID";
71
+ import ProjectUtil from "../../../UI/Utils/Project";
72
+
73
+ const WINDOW: InBetween<Date> = new InBetween<Date>(
74
+ new Date("2026-08-20T10:00:00.000Z"),
75
+ new Date("2026-08-20T11:00:00.000Z"),
76
+ );
77
+
78
+ const PROJECT_ID: ObjectID = new ObjectID(
79
+ "9e1b6b0e-0000-4000-8000-000000000011",
80
+ );
81
+
82
+ function Probe(props: {
83
+ enabled: boolean;
84
+ window: InBetween<Date> | null;
85
+ }): React.ReactElement {
86
+ const { lines, markerCount }: EventTimeReferenceLines =
87
+ useEventTimeReferenceLines({
88
+ enabled: props.enabled,
89
+ window: props.window,
90
+ });
91
+
92
+ return (
93
+ <div>
94
+ <span data-testid="marker-count">{markerCount}</span>
95
+ {lines.map((line: ChartTimeReferenceLineProps, index: number) => {
96
+ return (
97
+ <div
98
+ key={index}
99
+ data-testid="marker"
100
+ data-color={line.color}
101
+ data-dash={line.strokeDasharray || "solid"}
102
+ data-clickable={line.onClick ? "yes" : "no"}
103
+ >
104
+ {line.label}
105
+ </div>
106
+ );
107
+ })}
108
+ </div>
109
+ );
110
+ }
111
+
112
+ beforeEach(() => {
113
+ getListMock.mockReset();
114
+ analyticsGetListMock.mockReset();
115
+ isPublicDashboardMock.mockReset();
116
+ isPublicDashboardMock.mockReturnValue(false);
117
+ jest.spyOn(ProjectUtil, "getCurrentProjectId").mockReturnValue(PROJECT_ID);
118
+ });
119
+
120
+ afterEach(() => {
121
+ jest.restoreAllMocks();
122
+ cleanup();
123
+ });
124
+
125
+ describe("useEventTimeReferenceLines", () => {
126
+ test("maps incidents, alerts, and change events onto distinct marker styles", async () => {
127
+ getListMock.mockImplementation((args: { modelType: { name: string } }) => {
128
+ if (args.modelType.name === "Incident") {
129
+ return Promise.resolve({
130
+ data: [
131
+ {
132
+ id: new ObjectID("11111111-0000-4000-8000-000000000001"),
133
+ createdAt: "2026-08-20T10:10:00.000Z",
134
+ title: "API is down",
135
+ incidentSeverity: { color: "#123456" },
136
+ },
137
+ ],
138
+ });
139
+ }
140
+ return Promise.resolve({
141
+ data: [
142
+ {
143
+ id: new ObjectID("22222222-0000-4000-8000-000000000002"),
144
+ createdAt: "2026-08-20T10:20:00.000Z",
145
+ title: "High latency",
146
+ alertSeverity: undefined,
147
+ },
148
+ ],
149
+ });
150
+ });
151
+ analyticsGetListMock.mockResolvedValue({
152
+ data: [
153
+ {
154
+ time: "2026-08-20T10:03:00.000Z",
155
+ title: "v2.31.0",
156
+ eventType: "deployment",
157
+ },
158
+ ],
159
+ });
160
+
161
+ render(<Probe enabled={true} window={WINDOW} />);
162
+
163
+ await waitFor(() => {
164
+ expect(screen.getByTestId("marker-count").textContent).toBe("3");
165
+ });
166
+
167
+ const markers: Array<HTMLElement> = screen.getAllByTestId("marker");
168
+ const byLabel: (needle: string) => HTMLElement = (
169
+ needle: string,
170
+ ): HTMLElement => {
171
+ const found: HTMLElement | undefined = markers.find(
172
+ (marker: HTMLElement) => {
173
+ return (marker.textContent || "").includes(needle);
174
+ },
175
+ );
176
+ if (!found) {
177
+ throw new Error(`no marker containing ${needle}`);
178
+ }
179
+ return found;
180
+ };
181
+
182
+ const incident: HTMLElement = byLabel("Incident: API is down");
183
+ expect(incident.dataset["color"]).toBe("#123456");
184
+ expect(incident.dataset["dash"]).toBe("solid");
185
+ expect(incident.dataset["clickable"]).toBe("yes");
186
+
187
+ // Alert without a severity color falls back to amber.
188
+ expect(byLabel("Alert: High latency").dataset["color"]).toBe("#fbbf24");
189
+
190
+ const deploy: HTMLElement = byLabel("Deploy: v2.31.0");
191
+ expect(deploy.dataset["color"]).toBe("#6366f1");
192
+ expect(deploy.dataset["dash"]).toBe("4 4");
193
+ // Change events have no detail page — no dead-end click handler.
194
+ expect(deploy.dataset["clickable"]).toBe("no");
195
+ });
196
+
197
+ test("one source failing never takes the others down", async () => {
198
+ getListMock.mockImplementation((args: { modelType: { name: string } }) => {
199
+ if (args.modelType.name === "Incident") {
200
+ return Promise.resolve({
201
+ data: [
202
+ {
203
+ id: new ObjectID("11111111-0000-4000-8000-000000000001"),
204
+ createdAt: "2026-08-20T10:10:00.000Z",
205
+ title: "Still here",
206
+ incidentSeverity: undefined,
207
+ },
208
+ ],
209
+ });
210
+ }
211
+ return Promise.resolve({ data: [] });
212
+ });
213
+ analyticsGetListMock.mockRejectedValue(new Error("clickhouse hiccup"));
214
+
215
+ render(<Probe enabled={true} window={WINDOW} />);
216
+
217
+ await waitFor(() => {
218
+ expect(screen.getByTestId("marker-count").textContent).toBe("1");
219
+ });
220
+ expect(screen.getByText(/Incident: Still here/)).toBeInTheDocument();
221
+ });
222
+
223
+ test("public dashboards fetch nothing at all", async () => {
224
+ isPublicDashboardMock.mockReturnValue(true);
225
+
226
+ render(<Probe enabled={true} window={WINDOW} />);
227
+
228
+ // Give any (wrong) fetch a chance to fire.
229
+ await new Promise((resolve: (value: unknown) => void) => {
230
+ setTimeout(resolve, 20);
231
+ });
232
+
233
+ expect(getListMock).not.toHaveBeenCalled();
234
+ expect(analyticsGetListMock).not.toHaveBeenCalled();
235
+ expect(screen.getByTestId("marker-count").textContent).toBe("0");
236
+ });
237
+
238
+ test("disabled and windowless states fetch nothing and yield no lines", async () => {
239
+ render(<Probe enabled={false} window={WINDOW} />);
240
+ render(<Probe enabled={true} window={null} />);
241
+
242
+ await new Promise((resolve: (value: unknown) => void) => {
243
+ setTimeout(resolve, 20);
244
+ });
245
+
246
+ expect(getListMock).not.toHaveBeenCalled();
247
+ expect(analyticsGetListMock).not.toHaveBeenCalled();
248
+ });
249
+ });