@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,315 @@
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 {
11
+ cleanup,
12
+ fireEvent,
13
+ render,
14
+ screen,
15
+ waitFor,
16
+ } from "@testing-library/react";
17
+ import * as React from "react";
18
+ import getJestMockFunction, { MockFunction } from "../../MockType";
19
+
20
+ /*
21
+ * PR C surfaces on the investigation drawer: the deterministic findings
22
+ * card (built from the drawer's OWN evidence — log signal, patterns,
23
+ * event markers), the "Explain with AI" hand-off (prefill, never
24
+ * auto-send), and "Save to incident" (the evidence pinned to an incident
25
+ * timeline as a markdown internal note).
26
+ */
27
+
28
+ const histogramMock: MockFunction = getJestMockFunction();
29
+ const patternsMock: MockFunction = getJestMockFunction();
30
+ const eventLinesMock: MockFunction = getJestMockFunction();
31
+
32
+ jest.mock(
33
+ "../../../../App/FeatureSet/Dashboard/src/Components/Logs/LogsInsightsApi",
34
+ () => {
35
+ return {
36
+ __esModule: true,
37
+ fetchLogsHistogramRaw: (...args: Array<any>) => {
38
+ return histogramMock(...args);
39
+ },
40
+ fetchTopErrorPatterns: (...args: Array<any>) => {
41
+ return patternsMock(...args);
42
+ },
43
+ };
44
+ },
45
+ );
46
+
47
+ // The marker hook is separately tested — here it hands back a fixed set.
48
+ jest.mock(
49
+ "../../../../App/FeatureSet/Dashboard/src/Components/Metrics/Utils/UseEventTimeReferenceLines",
50
+ () => {
51
+ return {
52
+ __esModule: true,
53
+ default: (...args: Array<any>) => {
54
+ return eventLinesMock(...args);
55
+ },
56
+ };
57
+ },
58
+ );
59
+
60
+ jest.mock(
61
+ "../../../../App/FeatureSet/Dashboard/src/Components/Telemetry/TelemetryCompanionSignalTabs",
62
+ () => {
63
+ return {
64
+ __esModule: true,
65
+ default: (): React.ReactElement => {
66
+ return React.createElement("div", { "data-testid": "companion-tabs" });
67
+ },
68
+ };
69
+ },
70
+ );
71
+
72
+ jest.mock(
73
+ "../../../../App/FeatureSet/Dashboard/src/Components/Metrics/EmbeddedMetricCard",
74
+ () => {
75
+ return {
76
+ __esModule: true,
77
+ default: (): React.ReactElement => {
78
+ return React.createElement("div", {
79
+ "data-testid": "embedded-metric-card",
80
+ });
81
+ },
82
+ };
83
+ },
84
+ );
85
+
86
+ import InvestigationDrawer from "../../../../App/FeatureSet/Dashboard/src/Components/Telemetry/InvestigationDrawer";
87
+ import EventName from "../../../../App/FeatureSet/Dashboard/src/Utils/EventName";
88
+ import Incident from "../../../Models/DatabaseModels/Incident";
89
+ import IncidentInternalNote from "../../../Models/DatabaseModels/IncidentInternalNote";
90
+ import InBetween from "../../../Types/BaseDatabase/InBetween";
91
+ import MetricsAggregationType from "../../../Types/Metrics/MetricsAggregationType";
92
+ import MetricViewData from "../../../Types/Metrics/MetricViewData";
93
+ import ObjectID from "../../../Types/ObjectID";
94
+ import GlobalEvents from "../../../UI/Utils/GlobalEvents";
95
+ import ModelAPI from "../../../UI/Utils/ModelAPI/ModelAPI";
96
+ import ProjectUtil from "../../../UI/Utils/Project";
97
+
98
+ const WINDOW: InBetween<Date> = new InBetween<Date>(
99
+ new Date("2026-08-20T10:00:00.000Z"),
100
+ new Date("2026-08-20T10:15:00.000Z"),
101
+ );
102
+
103
+ const PROJECT_ID: ObjectID = new ObjectID(
104
+ "11111111-1111-1111-1111-111111111111",
105
+ );
106
+ const INCIDENT_ID: ObjectID = new ObjectID(
107
+ "22222222-2222-2222-2222-222222222222",
108
+ );
109
+
110
+ function buildViewData(): MetricViewData {
111
+ return {
112
+ queryConfigs: [
113
+ {
114
+ metricAliasData: { metricVariable: "a" },
115
+ metricQueryData: {
116
+ filterData: {
117
+ metricName: "cpu.usage",
118
+ attributes: { "host.name": "web-01" },
119
+ aggegationType: MetricsAggregationType.Avg,
120
+ },
121
+ },
122
+ },
123
+ ],
124
+ formulaConfigs: [],
125
+ startAndEndDate: null,
126
+ } as unknown as MetricViewData;
127
+ }
128
+
129
+ function buildIncident(): Incident {
130
+ return {
131
+ id: INCIDENT_ID,
132
+ _id: INCIDENT_ID.toString(),
133
+ title: "API latency spike",
134
+ incidentNumberWithPrefix: "INC-42",
135
+ incidentNumber: 42,
136
+ } as unknown as Incident;
137
+ }
138
+
139
+ let dispatchSpy: ReturnType<typeof jest.spyOn>;
140
+ let getListSpy: ReturnType<typeof jest.spyOn>;
141
+ let createSpy: ReturnType<typeof jest.spyOn>;
142
+
143
+ beforeEach(() => {
144
+ histogramMock.mockReset();
145
+ patternsMock.mockReset();
146
+ eventLinesMock.mockReset();
147
+ histogramMock.mockReturnValue(
148
+ Promise.resolve([
149
+ { time: "2026-08-20T10:00:00.000Z", severity: "Information", count: 90 },
150
+ { time: "2026-08-20T10:12:00.000Z", severity: "Error", count: 10 },
151
+ ]),
152
+ );
153
+ patternsMock.mockReturnValue(
154
+ Promise.resolve([
155
+ {
156
+ pattern: "connection refused to <IP>",
157
+ sampleBody: "connection refused to 10.0.0.5",
158
+ count: 8,
159
+ resourceIds: [],
160
+ severities: ["Error"],
161
+ sampleTraceIds: [],
162
+ },
163
+ ]),
164
+ );
165
+ eventLinesMock.mockReturnValue({
166
+ lines: [
167
+ {
168
+ date: new Date("2026-08-20T10:05:00.000Z"),
169
+ label: "Deploy: v2.31.0",
170
+ color: "#6366f1",
171
+ },
172
+ ],
173
+ markerCount: 1,
174
+ });
175
+
176
+ dispatchSpy = jest
177
+ .spyOn(GlobalEvents, "dispatchEvent")
178
+ .mockImplementation(() => {
179
+ return undefined;
180
+ });
181
+ jest.spyOn(ProjectUtil, "getCurrentProjectId").mockReturnValue(PROJECT_ID);
182
+ getListSpy = jest.spyOn(ModelAPI, "getList").mockReturnValue(
183
+ Promise.resolve({
184
+ data: [buildIncident()],
185
+ count: 1,
186
+ skip: 0,
187
+ limit: 10,
188
+ }) as never,
189
+ );
190
+ createSpy = jest.spyOn(ModelAPI, "create").mockImplementation(((input: {
191
+ model: IncidentInternalNote;
192
+ }) => {
193
+ return Promise.resolve(input.model);
194
+ }) as never);
195
+ });
196
+
197
+ afterEach(() => {
198
+ jest.restoreAllMocks();
199
+ cleanup();
200
+ });
201
+
202
+ function renderDrawer(): void {
203
+ render(
204
+ <InvestigationDrawer
205
+ window={WINDOW}
206
+ metricViewData={buildViewData()}
207
+ onClose={() => {
208
+ // not exercised here
209
+ }}
210
+ />,
211
+ );
212
+ }
213
+
214
+ describe("findings card", () => {
215
+ test("correlates markers and the log signal into ranked findings", async () => {
216
+ renderDrawer();
217
+
218
+ /*
219
+ * The change event leads (its marker came from the hook, scoped to
220
+ * the pinned window)…
221
+ */
222
+ expect(
223
+ screen.getByText(/Deploy: v2\.31\.0 landed .*before the end/),
224
+ ).toBeInTheDocument();
225
+ const hookArgs: Record<string, unknown> = eventLinesMock.mock
226
+ .calls[0]?.[0] as Record<string, unknown>;
227
+ expect((hookArgs["window"] as InBetween<Date>).startValue.getTime()).toBe(
228
+ WINDOW.startValue.getTime(),
229
+ );
230
+
231
+ // …and once the log signal lands, the error-rate finding joins it.
232
+ await waitFor(() => {
233
+ expect(
234
+ screen.getByText(/10\.0% of the window's log lines/),
235
+ ).toBeInTheDocument();
236
+ });
237
+ });
238
+ });
239
+
240
+ describe("explain with AI", () => {
241
+ test("dispatches the chat-open event with the evidence as the prompt", async () => {
242
+ renderDrawer();
243
+
244
+ await waitFor(() => {
245
+ expect(
246
+ screen.getByText("connection refused to 10.0.0.5"),
247
+ ).toBeInTheDocument();
248
+ });
249
+
250
+ fireEvent.click(screen.getByText("Explain with AI"));
251
+
252
+ const aiCalls: Array<Array<unknown>> = dispatchSpy.mock.calls.filter(
253
+ (call: Array<unknown>): boolean => {
254
+ return call[0] === EventName.AI_CHAT_TOGGLE;
255
+ },
256
+ );
257
+ expect(aiCalls).toHaveLength(1);
258
+ const prompt: string = (aiCalls[0]?.[1] as Record<string, string>)[
259
+ "prompt"
260
+ ] as string;
261
+ expect(prompt).toContain("host.name = web-01");
262
+ expect(prompt).toContain("connection refused to 10.0.0.5");
263
+ expect(prompt).toContain("Deploy: v2.31.0");
264
+ expect(prompt).toContain("What is the most likely root cause");
265
+ });
266
+ });
267
+
268
+ describe("save to incident", () => {
269
+ test("lists recent incidents, then pins the markdown snapshot as an internal note", async () => {
270
+ renderDrawer();
271
+
272
+ fireEvent.click(screen.getByText("Save to incident"));
273
+
274
+ // The picker fetched the project's recent incidents.
275
+ await waitFor(() => {
276
+ expect(screen.getByText(/INC-42/)).toBeInTheDocument();
277
+ });
278
+ const listArgs: Record<string, unknown> = getListSpy.mock
279
+ .calls[0]?.[0] as Record<string, unknown>;
280
+ expect(listArgs["modelType"]).toBe(Incident);
281
+ expect(listArgs["limit"]).toBe(10);
282
+
283
+ fireEvent.click(screen.getByText("Save note"));
284
+
285
+ await waitFor(() => {
286
+ expect(createSpy).toHaveBeenCalledTimes(1);
287
+ });
288
+ const created: IncidentInternalNote = (
289
+ createSpy.mock.calls[0]?.[0] as { model: IncidentInternalNote }
290
+ ).model;
291
+ expect(created.incidentId?.toString()).toBe(INCIDENT_ID.toString());
292
+ expect(created.projectId?.toString()).toBe(PROJECT_ID.toString());
293
+ expect(created.note).toContain("### Investigation snapshot");
294
+ expect(created.note).toContain("**Scope:** host.name = web-01");
295
+ expect(created.note).toContain("Open these charts in the Metric Explorer");
296
+
297
+ // Success closes the picker.
298
+ await waitFor(() => {
299
+ expect(screen.queryByText("Save note")).toBeNull();
300
+ });
301
+ });
302
+
303
+ test("cancel closes the picker without any write", async () => {
304
+ renderDrawer();
305
+
306
+ fireEvent.click(screen.getByText("Save to incident"));
307
+ await waitFor(() => {
308
+ expect(screen.getByText(/INC-42/)).toBeInTheDocument();
309
+ });
310
+
311
+ fireEvent.click(screen.getByText("Cancel"));
312
+ expect(screen.queryByText(/INC-42/)).toBeNull();
313
+ expect(createSpy).not.toHaveBeenCalled();
314
+ });
315
+ });
@@ -0,0 +1,258 @@
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 {
11
+ cleanup,
12
+ fireEvent,
13
+ render,
14
+ screen,
15
+ waitFor,
16
+ } from "@testing-library/react";
17
+ import * as React from "react";
18
+ import getJestMockFunction, { MockFunction } from "../../MockType";
19
+
20
+ /*
21
+ * The investigation drawer is the "what happened in this window?" panel:
22
+ * it must summarize the log signal for the window+scope, hand every
23
+ * companion signal the SAME pinned window, and route escape hatches
24
+ * (patterns, explorer) through onClose so the page underneath doesn't
25
+ * change behind an open drawer.
26
+ */
27
+
28
+ const histogramMock: MockFunction = getJestMockFunction();
29
+ const patternsMock: MockFunction = getJestMockFunction();
30
+ const companionTabsMock: MockFunction = getJestMockFunction();
31
+ const embeddedCardMock: MockFunction = getJestMockFunction();
32
+
33
+ jest.mock(
34
+ "../../../../App/FeatureSet/Dashboard/src/Components/Logs/LogsInsightsApi",
35
+ () => {
36
+ return {
37
+ __esModule: true,
38
+ fetchLogsHistogramRaw: (...args: Array<any>) => {
39
+ return histogramMock(...args);
40
+ },
41
+ fetchTopErrorPatterns: (...args: Array<any>) => {
42
+ return patternsMock(...args);
43
+ },
44
+ };
45
+ },
46
+ );
47
+
48
+ /*
49
+ * The companion tabs and the embedded metric card are heavy, separately
50
+ * tested surfaces — here they only need to prove WHAT they were handed.
51
+ */
52
+ jest.mock(
53
+ "../../../../App/FeatureSet/Dashboard/src/Components/Telemetry/TelemetryCompanionSignalTabs",
54
+ () => {
55
+ return {
56
+ __esModule: true,
57
+ default: (props: Record<string, unknown>): React.ReactElement => {
58
+ companionTabsMock(props);
59
+ return React.createElement(
60
+ "div",
61
+ { "data-testid": "companion-tabs" },
62
+ props["primarySignalElement"] as React.ReactElement,
63
+ );
64
+ },
65
+ };
66
+ },
67
+ );
68
+
69
+ jest.mock(
70
+ "../../../../App/FeatureSet/Dashboard/src/Components/Metrics/EmbeddedMetricCard",
71
+ () => {
72
+ return {
73
+ __esModule: true,
74
+ default: (props: Record<string, unknown>): React.ReactElement => {
75
+ embeddedCardMock(props);
76
+ return React.createElement("div", {
77
+ "data-testid": "embedded-metric-card",
78
+ });
79
+ },
80
+ };
81
+ },
82
+ );
83
+
84
+ import InvestigationDrawer from "../../../../App/FeatureSet/Dashboard/src/Components/Telemetry/InvestigationDrawer";
85
+ import InBetween from "../../../Types/BaseDatabase/InBetween";
86
+ import MetricsAggregationType from "../../../Types/Metrics/MetricsAggregationType";
87
+ import MetricViewData from "../../../Types/Metrics/MetricViewData";
88
+ import TelemetryType from "../../../Types/Telemetry/TelemetryType";
89
+ import Navigation from "../../../UI/Utils/Navigation";
90
+
91
+ const WINDOW: InBetween<Date> = new InBetween<Date>(
92
+ new Date("2026-08-20T10:00:00.000Z"),
93
+ new Date("2026-08-20T10:15:00.000Z"),
94
+ );
95
+
96
+ function buildViewData(): MetricViewData {
97
+ return {
98
+ queryConfigs: [
99
+ {
100
+ metricAliasData: { metricVariable: "a" },
101
+ metricQueryData: {
102
+ filterData: {
103
+ metricName: "cpu.usage",
104
+ attributes: { "host.name": "web-01" },
105
+ aggegationType: MetricsAggregationType.Avg,
106
+ },
107
+ },
108
+ },
109
+ ],
110
+ formulaConfigs: [],
111
+ startAndEndDate: null,
112
+ } as unknown as MetricViewData;
113
+ }
114
+
115
+ let navigateSpy: ReturnType<typeof jest.spyOn>;
116
+
117
+ beforeEach(() => {
118
+ histogramMock.mockReset();
119
+ patternsMock.mockReset();
120
+ companionTabsMock.mockReset();
121
+ embeddedCardMock.mockReset();
122
+ histogramMock.mockReturnValue(
123
+ Promise.resolve([
124
+ { time: "2026-08-20T10:00:00.000Z", severity: "Information", count: 90 },
125
+ { time: "2026-08-20T10:05:00.000Z", severity: "Error", count: 10 },
126
+ ]),
127
+ );
128
+ patternsMock.mockReturnValue(
129
+ Promise.resolve([
130
+ {
131
+ pattern: "connection refused to <IP>",
132
+ sampleBody: "connection refused to 10.0.0.5",
133
+ count: 42,
134
+ resourceIds: [],
135
+ severities: ["Error"],
136
+ sampleTraceIds: [],
137
+ },
138
+ ]),
139
+ );
140
+ navigateSpy = jest.spyOn(Navigation, "navigate").mockImplementation(() => {
141
+ return undefined;
142
+ });
143
+ });
144
+
145
+ afterEach(() => {
146
+ jest.restoreAllMocks();
147
+ cleanup();
148
+ });
149
+
150
+ describe("InvestigationDrawer", () => {
151
+ test("summarizes the window's log signal and lists top error patterns", async () => {
152
+ render(
153
+ <InvestigationDrawer
154
+ title="host.name=web-01"
155
+ window={WINDOW}
156
+ metricViewData={buildViewData()}
157
+ onClose={() => {
158
+ // not exercised here
159
+ }}
160
+ />,
161
+ );
162
+
163
+ // Scope chips reflect the metric view's filters.
164
+ expect(screen.getByText("host.name = web-01")).toBeInTheDocument();
165
+
166
+ await waitFor(() => {
167
+ expect(screen.getByText("100")).toBeInTheDocument();
168
+ });
169
+ expect(screen.getByText("10")).toBeInTheDocument();
170
+ expect(screen.getByText("10.0%")).toBeInTheDocument();
171
+ expect(
172
+ screen.getByText("connection refused to 10.0.0.5"),
173
+ ).toBeInTheDocument();
174
+
175
+ // The histogram request carried the attribute scope.
176
+ const histogramBody: Record<string, unknown> = histogramMock.mock
177
+ .calls[0]?.[0] as Record<string, unknown>;
178
+ expect(
179
+ (histogramBody["attributes"] as Record<string, unknown>)["host.name"],
180
+ ).toBe("web-01");
181
+ });
182
+
183
+ test("hands the companion tabs the SAME pinned window and metric queries", async () => {
184
+ render(
185
+ <InvestigationDrawer
186
+ window={WINDOW}
187
+ metricViewData={buildViewData()}
188
+ onClose={() => {
189
+ // not exercised here
190
+ }}
191
+ />,
192
+ );
193
+
194
+ await waitFor(() => {
195
+ expect(companionTabsMock).toHaveBeenCalled();
196
+ });
197
+
198
+ const tabsProps: Record<string, unknown> = companionTabsMock.mock
199
+ .calls[0]?.[0] as Record<string, unknown>;
200
+ const telemetryQuery: Record<string, unknown> = tabsProps[
201
+ "telemetryQuery"
202
+ ] as Record<string, unknown>;
203
+
204
+ expect(telemetryQuery["telemetryType"]).toBe(TelemetryType.Metric);
205
+ const viewData: MetricViewData = telemetryQuery[
206
+ "metricViewData"
207
+ ] as MetricViewData;
208
+ expect(viewData.startAndEndDate?.startValue.getTime()).toBe(
209
+ WINDOW.startValue.getTime(),
210
+ );
211
+ // Pinned — never a rolling token.
212
+ expect(viewData.rangeToken).toBeUndefined();
213
+ expect(tabsProps["eventNoun"]).toBe("view");
214
+
215
+ const snapshotWindow: InBetween<Date> = tabsProps[
216
+ "snapshotWindow"
217
+ ] as InBetween<Date>;
218
+ expect(snapshotWindow.endValue.getTime()).toBe(WINDOW.endValue.getTime());
219
+
220
+ // The primary element is the interactive metric card, same queries.
221
+ expect(screen.getByTestId("embedded-metric-card")).toBeInTheDocument();
222
+ const cardProps: Record<string, unknown> = embeddedCardMock.mock
223
+ .calls[0]?.[0] as Record<string, unknown>;
224
+ const cardConfigs: Array<Record<string, any>> = cardProps[
225
+ "queryConfigs"
226
+ ] as Array<Record<string, any>>;
227
+ expect(
228
+ cardConfigs[0]?.["metricQueryData"]["filterData"]["metricName"],
229
+ ).toBe("cpu.usage");
230
+ });
231
+
232
+ test("escape hatches close the drawer before navigating", async () => {
233
+ const onClose: MockFunction = getJestMockFunction();
234
+
235
+ render(
236
+ <InvestigationDrawer
237
+ window={WINDOW}
238
+ metricViewData={buildViewData()}
239
+ onClose={(...args: Array<any>) => {
240
+ onClose(...args);
241
+ }}
242
+ />,
243
+ );
244
+
245
+ fireEvent.click(screen.getByText("Open in Metric Explorer"));
246
+ expect(onClose).toHaveBeenCalledTimes(1);
247
+ expect(navigateSpy).toHaveBeenCalledTimes(1);
248
+
249
+ await waitFor(() => {
250
+ expect(
251
+ screen.getByText("connection refused to 10.0.0.5"),
252
+ ).toBeInTheDocument();
253
+ });
254
+ fireEvent.click(screen.getByText("connection refused to 10.0.0.5"));
255
+ expect(onClose).toHaveBeenCalledTimes(2);
256
+ expect(navigateSpy).toHaveBeenCalledTimes(2);
257
+ });
258
+ });