@oneuptime/common 12.0.19 → 12.0.21

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (124) hide show
  1. package/Models/AnalyticsModels/ChangeEvent.ts +331 -0
  2. package/Models/AnalyticsModels/Index.ts +2 -0
  3. package/Server/API/EnterpriseLicenseAPI.ts +94 -19
  4. package/Server/EnvironmentConfig.ts +15 -5
  5. package/Server/Infrastructure/Postgres/SchemaMigrations/1789000000000-AddUserSlackAndMicrosoftTeams.ts +42 -0
  6. package/Server/Infrastructure/Postgres/SchemaMigrations/Index.ts +0 -2
  7. package/Server/Services/ChangeEventService.ts +11 -0
  8. package/Server/Services/EnterpriseLicenseService.ts +3 -3
  9. package/Server/Services/Index.ts +2 -0
  10. package/Server/Services/ProjectService.ts +104 -0
  11. package/Server/Utils/Attribution.ts +11 -11
  12. package/Server/Utils/Marketing/MarketingEventWebhook.ts +4 -5
  13. package/Server/Utils/Response.ts +2 -2
  14. package/Server/Utils/StartServer.ts +2 -2
  15. package/Server/Utils/Telemetry/ChangeEventRow.ts +228 -0
  16. package/Server/Views/Partials/AnalyticsConsent.ejs +441 -0
  17. package/Tests/App/Dashboard/AlertEpisodeViewFields.test.tsx +465 -0
  18. package/Tests/App/Dashboard/DashboardChartWidgetZoom.test.tsx +366 -0
  19. package/Tests/App/Dashboard/DiscoveryScanWizardValidation.test.tsx +408 -0
  20. package/Tests/App/Dashboard/EventOverlayHook.test.tsx +249 -0
  21. package/Tests/App/Dashboard/IncidentEpisodeViewFields.test.tsx +474 -0
  22. package/Tests/App/Dashboard/InvestigationArtifacts.test.tsx +315 -0
  23. package/Tests/App/Dashboard/InvestigationDrawer.test.tsx +258 -0
  24. package/Tests/App/Dashboard/MetricChartsCompare.test.tsx +171 -0
  25. package/Tests/App/Dashboard/MetricSeriesInvestigateMenu.test.tsx +368 -0
  26. package/Tests/App/Dashboard/MetricViewCompare.test.tsx +233 -0
  27. package/Tests/Server/API/EnterpriseLicenseReportUserCount.test.ts +763 -0
  28. package/Tests/Server/Infrastructure/Postgres/AddUserSlackAndMicrosoftTeamsMigration.test.ts +236 -0
  29. package/Tests/Server/Services/ProjectServiceChangePlan.test.ts +138 -0
  30. package/Tests/Server/Services/ProjectServiceCreateSubscriptionStarted.test.ts +516 -0
  31. package/Tests/Server/Utils/AI/SRE/Insights/Detectors/ExceptionIdentity.test.ts +224 -0
  32. package/Tests/Server/Utils/Attribution.test.ts +4 -5
  33. package/Tests/Server/Utils/Marketing/MarketingEventUtil.test.ts +2 -2
  34. package/Tests/Server/Utils/Monitor/MonitorCriteriaExpectationBuilder.test.ts +300 -0
  35. package/Tests/Server/Utils/ResponseRenderAnalytics.test.ts +53 -0
  36. package/Tests/Server/Views/AnalyticsConsentPartial.test.ts +241 -0
  37. package/Tests/UI/Components/Charts/ChartGhostSeries.test.tsx +103 -0
  38. package/Tests/UI/Components/Charts/ChartTooltipSorted.test.tsx +129 -0
  39. package/Tests/UI/Components/Charts/TooltipEntries.test.ts +191 -0
  40. package/Tests/UI/Components/Detail/DetailFieldErrors.test.tsx +347 -0
  41. package/Tests/UI/Components/Forms/BasicFormStepValidation.test.tsx +392 -0
  42. package/Tests/UI/Components/Forms/ValidationFormSteps.test.ts +549 -0
  43. package/Tests/Utils/EnterpriseLicenseSync.test.ts +562 -0
  44. package/Tests/Utils/Telemetry/CrossSignalScope.test.ts +13 -2
  45. package/Types/Analytics/RevenueEvent.ts +1 -0
  46. package/Types/AnalyticsDatabase/AnalyticsTableName.ts +7 -0
  47. package/Types/Marketing/Attribution.ts +9 -15
  48. package/Types/Marketing/MarketingEvent.ts +19 -12
  49. package/UI/Components/Charts/Area/AreaChart.tsx +23 -1
  50. package/UI/Components/Charts/Bar/BarChart.tsx +8 -1
  51. package/UI/Components/Charts/ChartGroup/ChartGroup.tsx +16 -1
  52. package/UI/Components/Charts/ChartLibrary/AreaChart/AreaChart.tsx +208 -97
  53. package/UI/Components/Charts/ChartLibrary/BarChart/BarChart.tsx +26 -3
  54. package/UI/Components/Charts/ChartLibrary/LineChart/LineChart.tsx +232 -118
  55. package/UI/Components/Charts/ChartLibrary/Utils/TooltipEntries.ts +120 -0
  56. package/UI/Components/Charts/Line/LineChart.tsx +23 -1
  57. package/UI/Components/Detail/Detail.tsx +18 -1
  58. package/Utils/EnterpriseLicense/EnterpriseLicenseSync.ts +244 -0
  59. package/Utils/Metrics/MetricExplorerUrl.ts +119 -5
  60. package/Utils/Telemetry/CrossSignalScope.ts +19 -10
  61. package/build/dist/Models/AnalyticsModels/ChangeEvent.js +298 -0
  62. package/build/dist/Models/AnalyticsModels/ChangeEvent.js.map +1 -0
  63. package/build/dist/Models/AnalyticsModels/Index.js +2 -0
  64. package/build/dist/Models/AnalyticsModels/Index.js.map +1 -1
  65. package/build/dist/Server/API/EnterpriseLicenseAPI.js +66 -12
  66. package/build/dist/Server/API/EnterpriseLicenseAPI.js.map +1 -1
  67. package/build/dist/Server/EnvironmentConfig.js +14 -5
  68. package/build/dist/Server/EnvironmentConfig.js.map +1 -1
  69. package/build/dist/Server/Infrastructure/Postgres/SchemaMigrations/1789000000000-AddUserSlackAndMicrosoftTeams.js +39 -0
  70. package/build/dist/Server/Infrastructure/Postgres/SchemaMigrations/1789000000000-AddUserSlackAndMicrosoftTeams.js.map +1 -1
  71. package/build/dist/Server/Infrastructure/Postgres/SchemaMigrations/Index.js +0 -2
  72. package/build/dist/Server/Infrastructure/Postgres/SchemaMigrations/Index.js.map +1 -1
  73. package/build/dist/Server/Services/ChangeEventService.js +9 -0
  74. package/build/dist/Server/Services/ChangeEventService.js.map +1 -0
  75. package/build/dist/Server/Services/EnterpriseLicenseService.js +3 -3
  76. package/build/dist/Server/Services/Index.js +2 -0
  77. package/build/dist/Server/Services/Index.js.map +1 -1
  78. package/build/dist/Server/Services/ProjectService.js +88 -0
  79. package/build/dist/Server/Services/ProjectService.js.map +1 -1
  80. package/build/dist/Server/Utils/Attribution.js +6 -6
  81. package/build/dist/Server/Utils/Marketing/MarketingEventWebhook.js +4 -5
  82. package/build/dist/Server/Utils/Marketing/MarketingEventWebhook.js.map +1 -1
  83. package/build/dist/Server/Utils/Response.js +2 -2
  84. package/build/dist/Server/Utils/Response.js.map +1 -1
  85. package/build/dist/Server/Utils/StartServer.js +2 -2
  86. package/build/dist/Server/Utils/StartServer.js.map +1 -1
  87. package/build/dist/Server/Utils/Telemetry/ChangeEventRow.js +150 -0
  88. package/build/dist/Server/Utils/Telemetry/ChangeEventRow.js.map +1 -0
  89. package/build/dist/Types/Analytics/RevenueEvent.js +1 -0
  90. package/build/dist/Types/Analytics/RevenueEvent.js.map +1 -1
  91. package/build/dist/Types/AnalyticsDatabase/AnalyticsTableName.js +7 -0
  92. package/build/dist/Types/AnalyticsDatabase/AnalyticsTableName.js.map +1 -1
  93. package/build/dist/Types/Marketing/Attribution.js +9 -15
  94. package/build/dist/Types/Marketing/Attribution.js.map +1 -1
  95. package/build/dist/Types/Marketing/MarketingEvent.js +12 -4
  96. package/build/dist/Types/Marketing/MarketingEvent.js.map +1 -1
  97. package/build/dist/UI/Components/Charts/Area/AreaChart.js +10 -2
  98. package/build/dist/UI/Components/Charts/Area/AreaChart.js.map +1 -1
  99. package/build/dist/UI/Components/Charts/Bar/BarChart.js +9 -1
  100. package/build/dist/UI/Components/Charts/Bar/BarChart.js.map +1 -1
  101. package/build/dist/UI/Components/Charts/ChartGroup/ChartGroup.js +9 -1
  102. package/build/dist/UI/Components/Charts/ChartGroup/ChartGroup.js.map +1 -1
  103. package/build/dist/UI/Components/Charts/ChartLibrary/AreaChart/AreaChart.js +114 -51
  104. package/build/dist/UI/Components/Charts/ChartLibrary/AreaChart/AreaChart.js.map +1 -1
  105. package/build/dist/UI/Components/Charts/ChartLibrary/BarChart/BarChart.js +35 -23
  106. package/build/dist/UI/Components/Charts/ChartLibrary/BarChart/BarChart.js.map +1 -1
  107. package/build/dist/UI/Components/Charts/ChartLibrary/LineChart/LineChart.js +122 -55
  108. package/build/dist/UI/Components/Charts/ChartLibrary/LineChart/LineChart.js.map +1 -1
  109. package/build/dist/UI/Components/Charts/ChartLibrary/Utils/TooltipEntries.js +86 -0
  110. package/build/dist/UI/Components/Charts/ChartLibrary/Utils/TooltipEntries.js.map +1 -0
  111. package/build/dist/UI/Components/Charts/Line/LineChart.js +10 -2
  112. package/build/dist/UI/Components/Charts/Line/LineChart.js.map +1 -1
  113. package/build/dist/UI/Components/Detail/Detail.js +17 -1
  114. package/build/dist/UI/Components/Detail/Detail.js.map +1 -1
  115. package/build/dist/Utils/EnterpriseLicense/EnterpriseLicenseSync.js +174 -0
  116. package/build/dist/Utils/EnterpriseLicense/EnterpriseLicenseSync.js.map +1 -0
  117. package/build/dist/Utils/Metrics/MetricExplorerUrl.js +81 -0
  118. package/build/dist/Utils/Metrics/MetricExplorerUrl.js.map +1 -1
  119. package/build/dist/Utils/Telemetry/CrossSignalScope.js +14 -8
  120. package/build/dist/Utils/Telemetry/CrossSignalScope.js.map +1 -1
  121. package/package.json +1 -1
  122. package/Server/Infrastructure/Postgres/SchemaMigrations/1788900000000-RedactStoredMonitorIngestSecrets.ts +0 -117
  123. package/build/dist/Server/Infrastructure/Postgres/SchemaMigrations/1788900000000-RedactStoredMonitorIngestSecrets.js +0 -106
  124. package/build/dist/Server/Infrastructure/Postgres/SchemaMigrations/1788900000000-RedactStoredMonitorIngestSecrets.js.map +0 -1
@@ -0,0 +1,171 @@
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 } from "@testing-library/react";
11
+ import * as React from "react";
12
+ import getJestMockFunction, { MockFunction } from "../../MockType";
13
+
14
+ /*
15
+ * Ghost overlays end-to-end through the real MetricCharts: previous-
16
+ * period results must render as dashed twins of DISPLAYED series only —
17
+ * never as legend chips, never consuming Top-N slots, and always
18
+ * time-shifted onto the current window's bucket grid (unshifted points
19
+ * would be silently dropped by the bucketer).
20
+ */
21
+
22
+ jest.mock("recharts", () => {
23
+ const actual: Record<string, any> = jest.requireActual("recharts") as Record<
24
+ string,
25
+ any
26
+ >;
27
+ return {
28
+ ...actual,
29
+ ResponsiveContainer: ({ children }: { children: React.ReactElement }) => {
30
+ return React.cloneElement(children, {
31
+ width: 600,
32
+ height: 300,
33
+ } as Record<string, unknown>);
34
+ },
35
+ };
36
+ });
37
+
38
+ const fetchExemplarsMock: MockFunction = getJestMockFunction();
39
+
40
+ jest.mock(
41
+ "../../../../App/FeatureSet/Dashboard/src/Components/Metrics/Utils/Metrics",
42
+ () => {
43
+ return {
44
+ __esModule: true,
45
+ default: {
46
+ fetchExemplars: (...args: Array<any>) => {
47
+ fetchExemplarsMock(...args);
48
+ return new Promise(() => {
49
+ // Intentionally never settles.
50
+ });
51
+ },
52
+ setQueryTopNOverride: () => {
53
+ return undefined;
54
+ },
55
+ getQueryConfigTopNKey: (
56
+ _queryConfig: unknown,
57
+ index: number,
58
+ scope?: string,
59
+ ) => {
60
+ return `${scope || ""}:${index}`;
61
+ },
62
+ clearQueryTopNOverridesForScope: () => {
63
+ return undefined;
64
+ },
65
+ serializeAttributeFiltersForKey: (attributes: unknown) => {
66
+ return JSON.stringify(attributes || {});
67
+ },
68
+ },
69
+ DEFAULT_TOP_N_SERIES: 10,
70
+ SHOW_ALL_SERIES_TOP_N: 10_000,
71
+ sanitizeAttributeFilters: (attributes: unknown) => {
72
+ return attributes;
73
+ },
74
+ };
75
+ },
76
+ );
77
+
78
+ import MetricCharts from "../../../../App/FeatureSet/Dashboard/src/Components/Metrics/MetricCharts";
79
+ import AggregatedResult from "../../../Types/BaseDatabase/AggregatedResult";
80
+ import InBetween from "../../../Types/BaseDatabase/InBetween";
81
+ import MetricsAggregationType from "../../../Types/Metrics/MetricsAggregationType";
82
+ import MetricQueryConfigData from "../../../Types/Metrics/MetricQueryConfigData";
83
+ import MetricViewData from "../../../Types/Metrics/MetricViewData";
84
+
85
+ const WINDOW_START: Date = new Date("2026-08-20T10:00:00.000Z");
86
+ const WINDOW_END: Date = new Date("2026-08-20T11:00:00.000Z");
87
+ const WINDOW_MS: number = WINDOW_END.getTime() - WINDOW_START.getTime();
88
+
89
+ function buildViewData(): MetricViewData {
90
+ return {
91
+ queryConfigs: [
92
+ {
93
+ metricAliasData: { metricVariable: "a", legend: "CPU" },
94
+ metricQueryData: {
95
+ filterData: {
96
+ metricName: "cpu.usage",
97
+ attributes: {},
98
+ aggegationType: MetricsAggregationType.Avg,
99
+ },
100
+ },
101
+ } as unknown as MetricQueryConfigData,
102
+ ],
103
+ formulaConfigs: [],
104
+ startAndEndDate: new InBetween<Date>(WINDOW_START, WINDOW_END),
105
+ } as MetricViewData;
106
+ }
107
+
108
+ function buildResult(offsetMs: number): AggregatedResult {
109
+ return {
110
+ data: [
111
+ {
112
+ timestamp: new Date(
113
+ new Date("2026-08-20T10:10:00.000Z").getTime() - offsetMs,
114
+ ),
115
+ value: 40,
116
+ },
117
+ {
118
+ timestamp: new Date(
119
+ new Date("2026-08-20T10:30:00.000Z").getTime() - offsetMs,
120
+ ),
121
+ value: 70,
122
+ },
123
+ ],
124
+ truncated: false,
125
+ } as unknown as AggregatedResult;
126
+ }
127
+
128
+ beforeEach(() => {
129
+ fetchExemplarsMock.mockReset();
130
+ });
131
+
132
+ afterEach(() => {
133
+ cleanup();
134
+ });
135
+
136
+ describe("MetricCharts compare overlays", () => {
137
+ test("renders a dashed ghost twin without adding legend chips", () => {
138
+ const { container } = render(
139
+ <MetricCharts
140
+ metricViewData={buildViewData()}
141
+ metricResults={[buildResult(0)]}
142
+ metricResultsPrevious={[buildResult(WINDOW_MS)]}
143
+ compareOffsetMs={WINDOW_MS}
144
+ metricTypes={[]}
145
+ />,
146
+ );
147
+
148
+ // The ghost line is on the chart, dashed…
149
+ expect(
150
+ container.querySelector('path[stroke-dasharray="6 4"]'),
151
+ ).toBeInTheDocument();
152
+
153
+ // …but the chip legend shows only the live series.
154
+ expect(screen.getAllByText("CPU").length).toBeGreaterThan(0);
155
+ expect(screen.queryByText("CPU (previous)")).toBeNull();
156
+ // And the series count still reads 1.
157
+ expect(screen.getByText("1 series")).toBeInTheDocument();
158
+ });
159
+
160
+ test("no previous results means no ghosts", () => {
161
+ const { container } = render(
162
+ <MetricCharts
163
+ metricViewData={buildViewData()}
164
+ metricResults={[buildResult(0)]}
165
+ metricTypes={[]}
166
+ />,
167
+ );
168
+
169
+ expect(container.querySelector('path[stroke-dasharray="6 4"]')).toBeNull();
170
+ });
171
+ });
@@ -0,0 +1,368 @@
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 per-series investigate menu is the chart's bridge from "this series
22
+ * is spiking" to the signals that explain it: the chip's magnifier must
23
+ * open a menu whose pivots carry the series' group-by labels as REAL
24
+ * filters (logs/traces URLs), whose "Filter to this series" narrows the
25
+ * live explorer view, and whose resource entry lands on the host page the
26
+ * series is about. Rendering the real MetricCharts (not a stub) pins the
27
+ * whole chain: series naming → chip → menu → scoped URL.
28
+ */
29
+
30
+ /*
31
+ * ResponsiveContainer measures its parent, which is always 0x0 in jsdom,
32
+ * so the chart renders nothing without a fixed size.
33
+ */
34
+ jest.mock("recharts", () => {
35
+ const actual: Record<string, any> = jest.requireActual("recharts") as Record<
36
+ string,
37
+ any
38
+ >;
39
+ return {
40
+ ...actual,
41
+ ResponsiveContainer: ({ children }: { children: React.ReactElement }) => {
42
+ return React.cloneElement(children, {
43
+ width: 600,
44
+ height: 300,
45
+ } as Record<string, unknown>);
46
+ },
47
+ };
48
+ });
49
+
50
+ const getListMock: MockFunction = getJestMockFunction();
51
+ const fetchExemplarsMock: MockFunction = getJestMockFunction();
52
+ const showToastMock: MockFunction = getJestMockFunction();
53
+
54
+ /*
55
+ * The arrow wrappers are load bearing: jest.mock is hoisted above the
56
+ * compiled requires, so the mock variables above are still unassigned when
57
+ * the factory runs. Dereferencing them lazily, at call time, is what makes
58
+ * this work.
59
+ */
60
+ jest.mock("../../../UI/Utils/ModelAPI/ModelAPI", () => {
61
+ return {
62
+ __esModule: true,
63
+ default: {
64
+ getList: (...args: Array<any>) => {
65
+ return getListMock(...args);
66
+ },
67
+ },
68
+ };
69
+ });
70
+
71
+ jest.mock("../../../UI/Components/Toast/ToastInit", () => {
72
+ return {
73
+ __esModule: true,
74
+ ShowToastNotification: (...args: Array<any>) => {
75
+ return showToastMock(...args);
76
+ },
77
+ };
78
+ });
79
+
80
+ /*
81
+ * Keep the exemplar fetch (fired by MetricCharts whenever the view has a
82
+ * resolvable window) off the network; everything else on the Metrics util
83
+ * surface that MetricCharts touches is pure enough to stub inline.
84
+ */
85
+ jest.mock(
86
+ "../../../../App/FeatureSet/Dashboard/src/Components/Metrics/Utils/Metrics",
87
+ () => {
88
+ return {
89
+ __esModule: true,
90
+ default: {
91
+ fetchExemplars: (...args: Array<any>) => {
92
+ fetchExemplarsMock(...args);
93
+ /*
94
+ * Never resolves: a resolved exemplar set would setState after
95
+ * each test's assertions and trip React's act() warning; no
96
+ * test here cares about exemplar dots.
97
+ */
98
+ return new Promise(() => {
99
+ // Intentionally never settles.
100
+ });
101
+ },
102
+ setQueryTopNOverride: () => {
103
+ return undefined;
104
+ },
105
+ getQueryConfigTopNKey: (
106
+ _queryConfig: unknown,
107
+ index: number,
108
+ scope?: string,
109
+ ) => {
110
+ return `${scope || ""}:${index}`;
111
+ },
112
+ clearQueryTopNOverridesForScope: () => {
113
+ return undefined;
114
+ },
115
+ serializeAttributeFiltersForKey: (attributes: unknown) => {
116
+ return JSON.stringify(attributes || {});
117
+ },
118
+ },
119
+ DEFAULT_TOP_N_SERIES: 10,
120
+ SHOW_ALL_SERIES_TOP_N: 10_000,
121
+ sanitizeAttributeFilters: (attributes: unknown) => {
122
+ return attributes;
123
+ },
124
+ };
125
+ },
126
+ );
127
+
128
+ import MetricCharts from "../../../../App/FeatureSet/Dashboard/src/Components/Metrics/MetricCharts";
129
+ import AggregatedResult from "../../../Types/BaseDatabase/AggregatedResult";
130
+ import InBetween from "../../../Types/BaseDatabase/InBetween";
131
+ import MetricsAggregationType from "../../../Types/Metrics/MetricsAggregationType";
132
+ import MetricQueryConfigData from "../../../Types/Metrics/MetricQueryConfigData";
133
+ import MetricViewData from "../../../Types/Metrics/MetricViewData";
134
+ import ObjectID from "../../../Types/ObjectID";
135
+ import Navigation from "../../../UI/Utils/Navigation";
136
+ import ProjectUtil from "../../../UI/Utils/Project";
137
+
138
+ const PROJECT_ID: ObjectID = new ObjectID(
139
+ "77777777-1111-4111-8111-777777777777",
140
+ );
141
+ const HOST_MODEL_ID: string = "88888888-2222-4222-8222-888888888888";
142
+
143
+ const WINDOW_START: Date = new Date("2026-08-20T10:00:00.000Z");
144
+ const WINDOW_END: Date = new Date("2026-08-20T11:00:00.000Z");
145
+
146
+ function buildQueryConfig(): MetricQueryConfigData {
147
+ return {
148
+ metricAliasData: { metricVariable: "a" },
149
+ metricQueryData: {
150
+ filterData: {
151
+ metricName: "cpu.usage",
152
+ attributes: {},
153
+ aggegationType: MetricsAggregationType.Avg,
154
+ },
155
+ groupByAttributeKeys: ["host.name"],
156
+ },
157
+ } as unknown as MetricQueryConfigData;
158
+ }
159
+
160
+ function buildViewData(): MetricViewData {
161
+ return {
162
+ queryConfigs: [buildQueryConfig()],
163
+ formulaConfigs: [],
164
+ startAndEndDate: new InBetween<Date>(WINDOW_START, WINDOW_END),
165
+ } as MetricViewData;
166
+ }
167
+
168
+ function buildResults(): Array<AggregatedResult> {
169
+ const timestamps: Array<string> = [
170
+ "2026-08-20T10:10:00.000Z",
171
+ "2026-08-20T10:20:00.000Z",
172
+ ];
173
+ const data: Array<Record<string, unknown>> = [];
174
+ for (const host of ["web-01", "web-02"]) {
175
+ for (const [index, timestamp] of timestamps.entries()) {
176
+ data.push({
177
+ timestamp: new Date(timestamp),
178
+ value: host === "web-01" ? 90 + index : 10 + index,
179
+ attributes: { "host.name": host },
180
+ });
181
+ }
182
+ }
183
+ return [{ data, truncated: false } as unknown as AggregatedResult];
184
+ }
185
+
186
+ let navigateSpy: ReturnType<typeof jest.spyOn>;
187
+
188
+ beforeEach(() => {
189
+ getListMock.mockReset();
190
+ fetchExemplarsMock.mockReset();
191
+ showToastMock.mockReset();
192
+ navigateSpy = jest.spyOn(Navigation, "navigate").mockImplementation(() => {
193
+ return undefined;
194
+ });
195
+ jest.spyOn(ProjectUtil, "getCurrentProjectId").mockReturnValue(PROJECT_ID);
196
+ });
197
+
198
+ afterEach(() => {
199
+ jest.restoreAllMocks();
200
+ cleanup();
201
+ });
202
+
203
+ function getNavigatedUrl(): string {
204
+ const lastCall: Array<unknown> | undefined =
205
+ navigateSpy.mock.calls[navigateSpy.mock.calls.length - 1];
206
+ return decodeURIComponent(String(lastCall?.[0]));
207
+ }
208
+
209
+ describe("per-series investigate menu", () => {
210
+ test("each series chip carries an investigate button that opens the menu", () => {
211
+ render(
212
+ <MetricCharts
213
+ metricViewData={buildViewData()}
214
+ metricResults={buildResults()}
215
+ metricTypes={[]}
216
+ />,
217
+ );
218
+
219
+ fireEvent.click(screen.getByLabelText("Investigate host.name=web-01"));
220
+
221
+ const menu: HTMLElement = screen.getByRole("menu", {
222
+ name: "Series investigation actions",
223
+ });
224
+ expect(menu).toBeInTheDocument();
225
+ expect(screen.getByText("View logs")).toBeInTheDocument();
226
+ expect(screen.getByText("View traces")).toBeInTheDocument();
227
+ expect(screen.getByText("View exceptions")).toBeInTheDocument();
228
+ // The menu header names the series and what it is scoped to.
229
+ expect(menu.textContent).toContain("host.name = web-01");
230
+ });
231
+
232
+ test("read-only hosts get an explorer deep link; read-write hosts filter in place", () => {
233
+ const onQueryConfigsChange: MockFunction = getJestMockFunction();
234
+
235
+ const { unmount } = render(
236
+ <MetricCharts
237
+ metricViewData={buildViewData()}
238
+ metricResults={buildResults()}
239
+ metricTypes={[]}
240
+ />,
241
+ );
242
+
243
+ fireEvent.click(screen.getByLabelText("Investigate host.name=web-01"));
244
+ expect(screen.getByText("Open in Metric Explorer")).toBeInTheDocument();
245
+ expect(screen.queryByText("Filter to this series")).toBeNull();
246
+ unmount();
247
+
248
+ render(
249
+ <MetricCharts
250
+ metricViewData={buildViewData()}
251
+ metricResults={buildResults()}
252
+ metricTypes={[]}
253
+ onQueryConfigsChange={(...args: Array<any>) => {
254
+ onQueryConfigsChange(...args);
255
+ }}
256
+ />,
257
+ );
258
+
259
+ fireEvent.click(screen.getByLabelText("Investigate host.name=web-01"));
260
+ expect(screen.queryByText("Open in Metric Explorer")).toBeNull();
261
+
262
+ fireEvent.click(screen.getByText("Filter to this series"));
263
+
264
+ expect(onQueryConfigsChange).toHaveBeenCalledTimes(1);
265
+ const narrowedConfigs: Array<MetricQueryConfigData> = onQueryConfigsChange
266
+ .mock.calls[0]?.[0] as Array<MetricQueryConfigData>;
267
+ expect(
268
+ (
269
+ narrowedConfigs[0]?.metricQueryData.filterData as Record<
270
+ string,
271
+ unknown
272
+ >
273
+ )["attributes"],
274
+ ).toEqual({ "host.name": "web-01" });
275
+ });
276
+
277
+ test("View logs navigates to the logs explorer scoped to the series and window", async () => {
278
+ render(
279
+ <MetricCharts
280
+ metricViewData={buildViewData()}
281
+ metricResults={buildResults()}
282
+ metricTypes={[]}
283
+ />,
284
+ );
285
+
286
+ fireEvent.click(screen.getByLabelText("Investigate host.name=web-02"));
287
+ fireEvent.click(screen.getByText("View logs"));
288
+
289
+ await waitFor(() => {
290
+ expect(navigateSpy).toHaveBeenCalled();
291
+ });
292
+
293
+ const url: string = getNavigatedUrl();
294
+ expect(url).toContain("/logs");
295
+ expect(url).toContain('["attributes.host.name",["web-02"]]');
296
+ expect(url).toContain("range=Custom");
297
+ expect(url).toContain("start=");
298
+ expect(url).toContain("end=");
299
+ });
300
+
301
+ test("View exceptions reports what the exceptions grammar cannot carry", async () => {
302
+ render(
303
+ <MetricCharts
304
+ metricViewData={buildViewData()}
305
+ metricResults={buildResults()}
306
+ metricTypes={[]}
307
+ />,
308
+ );
309
+
310
+ fireEvent.click(screen.getByLabelText("Investigate host.name=web-01"));
311
+ fireEvent.click(screen.getByText("View exceptions"));
312
+
313
+ await waitFor(() => {
314
+ expect(navigateSpy).toHaveBeenCalled();
315
+ });
316
+
317
+ const url: string = getNavigatedUrl();
318
+ expect(url).toContain("/exceptions");
319
+ expect(url).toContain("status=all");
320
+ // The host.name narrowing has no exceptions facet — the user is told.
321
+ expect(showToastMock).toHaveBeenCalled();
322
+ });
323
+
324
+ test("the host series jumps to the matching Host page", async () => {
325
+ getListMock.mockReturnValue(
326
+ Promise.resolve({ data: [{ _id: HOST_MODEL_ID }] }),
327
+ );
328
+
329
+ render(
330
+ <MetricCharts
331
+ metricViewData={buildViewData()}
332
+ metricResults={buildResults()}
333
+ metricTypes={[]}
334
+ />,
335
+ );
336
+
337
+ fireEvent.click(screen.getByLabelText("Investigate host.name=web-01"));
338
+ fireEvent.click(screen.getByText('Open host "web-01"'));
339
+
340
+ await waitFor(() => {
341
+ expect(navigateSpy).toHaveBeenCalled();
342
+ });
343
+
344
+ const listArgs: Record<string, unknown> = getListMock.mock
345
+ .calls[0]?.[0] as Record<string, unknown>;
346
+ expect(listArgs["query"]).toEqual({
347
+ projectId: PROJECT_ID,
348
+ hostIdentifier: "web-01",
349
+ });
350
+ expect(getNavigatedUrl()).toContain(`/host/${HOST_MODEL_ID}`);
351
+ });
352
+
353
+ test("enableSeriesActions={false} renders plain chips with no investigate affordance", () => {
354
+ render(
355
+ <MetricCharts
356
+ metricViewData={buildViewData()}
357
+ metricResults={buildResults()}
358
+ metricTypes={[]}
359
+ enableSeriesActions={false}
360
+ />,
361
+ );
362
+
363
+ // The chips themselves still render…
364
+ expect(screen.getByText("host.name=web-01")).toBeInTheDocument();
365
+ // …but nothing offers navigation.
366
+ expect(screen.queryByLabelText("Investigate host.name=web-01")).toBeNull();
367
+ });
368
+ });