@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,224 @@
1
+ import {
2
+ EXCEPTION_LABEL_MESSAGE_MAX_LENGTH,
3
+ buildExceptionFailureModeKey,
4
+ buildExceptionLabel,
5
+ } from "../../../../../../../Server/Utils/AI/SRE/Insights/Detectors/ExceptionIdentity";
6
+ import Crypto from "../../../../../../../Utils/Crypto";
7
+ import { normalizeExceptionText } from "../../../../../../../Server/Utils/Telemetry/ExceptionSanitizer";
8
+ import ObjectID from "../../../../../../../Types/ObjectID";
9
+ import { describe, expect, test } from "@jest/globals";
10
+
11
+ describe("ExceptionIdentity", () => {
12
+ describe("buildExceptionLabel", () => {
13
+ test("falls back to a fixed label when both type and message are empty", () => {
14
+ expect(buildExceptionLabel("", "")).toBe("Unknown exception");
15
+ expect(buildExceptionLabel(undefined, undefined)).toBe(
16
+ "Unknown exception",
17
+ );
18
+ // Whitespace-only inputs trim down to empty and hit the same fallback.
19
+ expect(buildExceptionLabel(" ", " \t ")).toBe("Unknown exception");
20
+ });
21
+
22
+ test("uses the type alone when there is no message", () => {
23
+ expect(buildExceptionLabel("TypeError", "")).toBe("TypeError");
24
+ expect(buildExceptionLabel(" TypeError ", undefined)).toBe("TypeError");
25
+ });
26
+
27
+ test("uses the message alone when there is no type", () => {
28
+ expect(buildExceptionLabel("", "connection refused")).toBe(
29
+ "connection refused",
30
+ );
31
+ expect(buildExceptionLabel(undefined, " connection refused ")).toBe(
32
+ "connection refused",
33
+ );
34
+ });
35
+
36
+ test("combines type and message with a colon separator", () => {
37
+ expect(
38
+ buildExceptionLabel(
39
+ "TypeError",
40
+ "Cannot read properties of undefined (reading 'user')",
41
+ ),
42
+ ).toBe("TypeError: Cannot read properties of undefined (reading 'user')");
43
+ });
44
+
45
+ test("does not repeat the type when the message already starts with it", () => {
46
+ // "Error: <message>" — the runtime-prefixed shape.
47
+ expect(buildExceptionLabel("Error", "Error: connect ECONNREFUSED")).toBe(
48
+ "Error: connect ECONNREFUSED",
49
+ );
50
+ // "<Type> <message>" — space-separated prefix.
51
+ expect(
52
+ buildExceptionLabel("ValueError", "ValueError invalid literal"),
53
+ ).toBe("ValueError invalid literal");
54
+ // Message reported verbatim as the type.
55
+ expect(buildExceptionLabel("401", "401")).toBe("401");
56
+ });
57
+
58
+ test("treats the type-prefix check case-insensitively", () => {
59
+ expect(buildExceptionLabel("error", "ERROR: boom")).toBe("ERROR: boom");
60
+ expect(buildExceptionLabel("TypeError", "typeerror: boom")).toBe(
61
+ "typeerror: boom",
62
+ );
63
+ });
64
+
65
+ test("does not collapse when the type only appears mid-message", () => {
66
+ // "Error" is a substring but not a prefix, so both halves are kept.
67
+ expect(buildExceptionLabel("Error", "Fatal Error happened")).toBe(
68
+ "Error: Fatal Error happened",
69
+ );
70
+ });
71
+
72
+ test("truncates a long message and appends an ellipsis", () => {
73
+ const longMessage: string = "x".repeat(
74
+ EXCEPTION_LABEL_MESSAGE_MAX_LENGTH + 50,
75
+ );
76
+ const label: string = buildExceptionLabel("TypeError", longMessage);
77
+
78
+ const expectedShort: string =
79
+ "x".repeat(EXCEPTION_LABEL_MESSAGE_MAX_LENGTH) + "…";
80
+ expect(label).toBe(`TypeError: ${expectedShort}`);
81
+ // The ellipsis is a single char, so the message portion is cap + 1.
82
+ expect(label.endsWith("…")).toBe(true);
83
+ });
84
+
85
+ test("does not truncate a message exactly at the cap", () => {
86
+ const exact: string = "y".repeat(EXCEPTION_LABEL_MESSAGE_MAX_LENGTH);
87
+ expect(buildExceptionLabel("", exact)).toBe(exact);
88
+ expect(buildExceptionLabel("", exact).endsWith("…")).toBe(false);
89
+ });
90
+
91
+ test("honors a custom message max length", () => {
92
+ expect(buildExceptionLabel("E", "abcdefghij", 4)).toBe("E: abcd…");
93
+ expect(buildExceptionLabel("", "abcdefghij", 4)).toBe("abcd…");
94
+ });
95
+ });
96
+
97
+ describe("buildExceptionFailureModeKey", () => {
98
+ const entityId: ObjectID = ObjectID.generate();
99
+
100
+ test("is deterministic for the same inputs", () => {
101
+ const args: {
102
+ primaryEntityId: ObjectID;
103
+ exceptionType: string;
104
+ message: string;
105
+ } = {
106
+ primaryEntityId: entityId,
107
+ exceptionType: "TypeError",
108
+ message: "Cannot read properties of undefined",
109
+ };
110
+
111
+ expect(buildExceptionFailureModeKey(args)).toBe(
112
+ buildExceptionFailureModeKey(args),
113
+ );
114
+ });
115
+
116
+ test("keeps the entity id in the clear and hashes the content half", () => {
117
+ const key: string = buildExceptionFailureModeKey({
118
+ primaryEntityId: entityId,
119
+ exceptionType: "TypeError",
120
+ message: "boom",
121
+ });
122
+
123
+ const expectedHash: string = Crypto.getSha256Hash(
124
+ `TypeError|${normalizeExceptionText("boom")}`,
125
+ );
126
+ expect(key).toBe(`${entityId.toString()}:${expectedHash}`);
127
+ // sha256 hex is exactly 64 lowercase hex chars.
128
+ expect(key.split(":")[1]).toMatch(/^[0-9a-f]{64}$/);
129
+ });
130
+
131
+ test("collapses messages that differ only by a normalized dynamic value", () => {
132
+ // Two UUIDs normalize to <UUID>, so both throws are one failure mode.
133
+ const first: string = buildExceptionFailureModeKey({
134
+ primaryEntityId: entityId,
135
+ exceptionType: "NotFoundException",
136
+ message: "user 550e8400-e29b-41d4-a716-446655440000 not found",
137
+ });
138
+ const second: string = buildExceptionFailureModeKey({
139
+ primaryEntityId: entityId,
140
+ exceptionType: "NotFoundException",
141
+ message: "user 11111111-2222-3333-4444-555555555555 not found",
142
+ });
143
+
144
+ expect(first).toBe(second);
145
+ });
146
+
147
+ test("separates different exception types under the same entity", () => {
148
+ const typeError: string = buildExceptionFailureModeKey({
149
+ primaryEntityId: entityId,
150
+ exceptionType: "TypeError",
151
+ message: "same message",
152
+ });
153
+ const rangeError: string = buildExceptionFailureModeKey({
154
+ primaryEntityId: entityId,
155
+ exceptionType: "RangeError",
156
+ message: "same message",
157
+ });
158
+
159
+ expect(typeError).not.toBe(rangeError);
160
+ });
161
+
162
+ test("separates the same failure across different entities", () => {
163
+ const other: ObjectID = ObjectID.generate();
164
+ const a: string = buildExceptionFailureModeKey({
165
+ primaryEntityId: entityId,
166
+ exceptionType: "TypeError",
167
+ message: "boom",
168
+ });
169
+ const b: string = buildExceptionFailureModeKey({
170
+ primaryEntityId: other,
171
+ exceptionType: "TypeError",
172
+ message: "boom",
173
+ });
174
+
175
+ expect(a).not.toBe(b);
176
+ expect(a.startsWith(`${entityId.toString()}:`)).toBe(true);
177
+ expect(b.startsWith(`${other.toString()}:`)).toBe(true);
178
+ });
179
+
180
+ test("uses a stable placeholder when the entity is unattributed", () => {
181
+ const key: string = buildExceptionFailureModeKey({
182
+ primaryEntityId: undefined,
183
+ exceptionType: "TypeError",
184
+ message: "boom",
185
+ });
186
+
187
+ expect(key.startsWith("unattributed-entity:")).toBe(true);
188
+ // Still deterministic without an entity.
189
+ expect(key).toBe(
190
+ buildExceptionFailureModeKey({
191
+ primaryEntityId: undefined,
192
+ exceptionType: "TypeError",
193
+ message: "boom",
194
+ }),
195
+ );
196
+ });
197
+
198
+ test("trims the type before hashing so padding does not fork the key", () => {
199
+ const padded: string = buildExceptionFailureModeKey({
200
+ primaryEntityId: entityId,
201
+ exceptionType: " TypeError ",
202
+ message: " boom ",
203
+ });
204
+ const clean: string = buildExceptionFailureModeKey({
205
+ primaryEntityId: entityId,
206
+ exceptionType: "TypeError",
207
+ message: "boom",
208
+ });
209
+
210
+ expect(padded).toBe(clean);
211
+ });
212
+
213
+ test("handles a missing type and message without throwing", () => {
214
+ const key: string = buildExceptionFailureModeKey({
215
+ primaryEntityId: entityId,
216
+ exceptionType: undefined,
217
+ message: undefined,
218
+ });
219
+
220
+ const expectedHash: string = Crypto.getSha256Hash(`|`);
221
+ expect(key).toBe(`${entityId.toString()}:${expectedHash}`);
222
+ });
223
+ });
224
+ });
@@ -206,14 +206,13 @@ describe("Attribution", () => {
206
206
  * -------------------------------------------------------------------------
207
207
  * sanitizeUtm
208
208
  *
209
- * UTM values reach the server in two spellings from three unauthenticated
210
- * doors: snake_case off a URL and out of Cal booking metadata, camelCase out
211
- * of the signup form's JSON body. All of them write the same columns, so all
212
- * of them read through here.
209
+ * UTM values reach the server in two spellings: snake_case off a URL, and
210
+ * camelCase out of the signup form's JSON body. Both write the same columns,
211
+ * so both read through here.
213
212
  * -------------------------------------------------------------------------
214
213
  */
215
214
  describe("sanitizeUtm", () => {
216
- test("reads the snake_case spelling a URL and Cal metadata carry", () => {
215
+ test("reads the snake_case spelling a URL carries", () => {
217
216
  expect(
218
217
  Attribution.sanitizeUtm({
219
218
  utm_source: "google",
@@ -74,8 +74,8 @@ describe("MarketingEventUtil", () => {
74
74
  */
75
75
  test("carries the address and a matching SHA-256", () => {
76
76
  const event: MarketingEvent = MarketingEventUtil.buildEvent({
77
- eventType: MarketingEventType.MeetingBooked,
78
- eventId: "meeting_booked:b1",
77
+ eventType: MarketingEventType.SignUp,
78
+ eventId: "sign_up:u2",
79
79
  occurredAt: new Date(),
80
80
  email: "Buyer@Example.com",
81
81
  });
@@ -0,0 +1,300 @@
1
+ import MonitorCriteriaExpectationBuilder from "../../../../Server/Utils/Monitor/MonitorCriteriaExpectationBuilder";
2
+ import {
3
+ CheckOn,
4
+ CriteriaFilter,
5
+ EvaluateOverTimeType,
6
+ FilterType,
7
+ } from "../../../../Types/Monitor/CriteriaFilter";
8
+ import { describe, expect, test } from "@jest/globals";
9
+
10
+ /*
11
+ * A minimal CriteriaFilter builder so each test states only the fields it
12
+ * cares about; everything else stays undefined the way the persisted shape
13
+ * allows.
14
+ */
15
+ const makeFilter: (overrides: Partial<CriteriaFilter>) => CriteriaFilter = (
16
+ overrides: Partial<CriteriaFilter>,
17
+ ): CriteriaFilter => {
18
+ return {
19
+ checkOn: CheckOn.ResponseTime,
20
+ filterType: undefined,
21
+ value: undefined,
22
+ ...overrides,
23
+ };
24
+ };
25
+
26
+ describe("MonitorCriteriaExpectationBuilder", () => {
27
+ describe("getCriteriaFilterDescription", () => {
28
+ test("joins checkOn, filterType and value with single spaces", () => {
29
+ const description: string =
30
+ MonitorCriteriaExpectationBuilder.getCriteriaFilterDescription(
31
+ makeFilter({
32
+ checkOn: CheckOn.ResponseTime,
33
+ filterType: FilterType.GreaterThan,
34
+ value: 500,
35
+ }),
36
+ );
37
+
38
+ expect(description).toBe("Response Time (in ms) Greater Than 500");
39
+ });
40
+
41
+ test("omits filterType and value when they are absent", () => {
42
+ const description: string =
43
+ MonitorCriteriaExpectationBuilder.getCriteriaFilterDescription(
44
+ makeFilter({ checkOn: CheckOn.IsOnline }),
45
+ );
46
+
47
+ expect(description).toBe("Is Online");
48
+ });
49
+
50
+ test("keeps a zero value (does not treat 0 as missing)", () => {
51
+ const description: string =
52
+ MonitorCriteriaExpectationBuilder.getCriteriaFilterDescription(
53
+ makeFilter({
54
+ checkOn: CheckOn.ResponseTime,
55
+ filterType: FilterType.EqualTo,
56
+ value: 0,
57
+ }),
58
+ );
59
+
60
+ expect(description).toBe("Response Time (in ms) Equal To 0");
61
+ });
62
+
63
+ test("drops a null value but keeps the filter type", () => {
64
+ const description: string =
65
+ MonitorCriteriaExpectationBuilder.getCriteriaFilterDescription(
66
+ makeFilter({
67
+ checkOn: CheckOn.ResponseTime,
68
+ filterType: FilterType.IsEmpty,
69
+ value: null as unknown as undefined,
70
+ }),
71
+ );
72
+
73
+ expect(description).toBe("Response Time (in ms) Is Empty");
74
+ });
75
+
76
+ test("stringifies a string value as-is", () => {
77
+ const description: string =
78
+ MonitorCriteriaExpectationBuilder.getCriteriaFilterDescription(
79
+ makeFilter({
80
+ checkOn: CheckOn.ResponseBody,
81
+ filterType: FilterType.Contains,
82
+ value: "healthy",
83
+ }),
84
+ );
85
+
86
+ expect(description).toBe("Response Body Contains healthy");
87
+ });
88
+ });
89
+
90
+ describe("describeCriteriaExpectation", () => {
91
+ test("returns null when no filter type is set", () => {
92
+ expect(
93
+ MonitorCriteriaExpectationBuilder.describeCriteriaExpectation(
94
+ makeFilter({ filterType: undefined, value: 5 }),
95
+ ),
96
+ ).toBeNull();
97
+ });
98
+
99
+ test("renders each numeric comparison in plain English", () => {
100
+ const cases: Array<[FilterType, string]> = [
101
+ [FilterType.GreaterThan, "to be greater than 5"],
102
+ [FilterType.GreaterThanOrEqualTo, "to be greater than or equal to 5"],
103
+ [FilterType.LessThan, "to be less than 5"],
104
+ [FilterType.LessThanOrEqualTo, "to be less than or equal to 5"],
105
+ [FilterType.EqualTo, "to equal 5"],
106
+ [FilterType.NotEqualTo, "to not equal 5"],
107
+ ];
108
+
109
+ for (const [filterType, expected] of cases) {
110
+ expect(
111
+ MonitorCriteriaExpectationBuilder.describeCriteriaExpectation(
112
+ makeFilter({ filterType, value: 5 }),
113
+ ),
114
+ ).toBe(expected);
115
+ }
116
+ });
117
+
118
+ test("appends the unit suffix to numeric-threshold comparisons only", () => {
119
+ expect(
120
+ MonitorCriteriaExpectationBuilder.describeCriteriaExpectation(
121
+ makeFilter({ filterType: FilterType.GreaterThan, value: 5 }),
122
+ { unit: "sec" },
123
+ ),
124
+ ).toBe("to be greater than 5 sec");
125
+
126
+ // String comparisons carry their own wording and get no unit suffix.
127
+ expect(
128
+ MonitorCriteriaExpectationBuilder.describeCriteriaExpectation(
129
+ makeFilter({ filterType: FilterType.Contains, value: "ok" }),
130
+ { unit: "sec" },
131
+ ),
132
+ ).toBe("to contain ok");
133
+ });
134
+
135
+ test("renders string comparisons", () => {
136
+ const cases: Array<[FilterType, string]> = [
137
+ [FilterType.Contains, "to contain ok"],
138
+ [FilterType.NotContains, "to not contain ok"],
139
+ [FilterType.StartsWith, "to start with ok"],
140
+ [FilterType.EndsWith, "to end with ok"],
141
+ ];
142
+
143
+ for (const [filterType, expected] of cases) {
144
+ expect(
145
+ MonitorCriteriaExpectationBuilder.describeCriteriaExpectation(
146
+ makeFilter({ filterType, value: "ok" }),
147
+ ),
148
+ ).toBe(expected);
149
+ }
150
+ });
151
+
152
+ test("renders valueless filter types with fixed wording", () => {
153
+ const cases: Array<[FilterType, string]> = [
154
+ [FilterType.IsEmpty, "to be empty"],
155
+ [FilterType.IsNotEmpty, "to not be empty"],
156
+ [FilterType.True, "to be true"],
157
+ [FilterType.False, "to be false"],
158
+ [FilterType.IsExecuting, "to be executing"],
159
+ [FilterType.IsNotExecuting, "to not be executing"],
160
+ [FilterType.EvaluatesToTrue, "to evaluate to true"],
161
+ ];
162
+
163
+ for (const [filterType, expected] of cases) {
164
+ expect(
165
+ MonitorCriteriaExpectationBuilder.describeCriteriaExpectation(
166
+ makeFilter({ filterType }),
167
+ ),
168
+ ).toBe(expected);
169
+ }
170
+ });
171
+
172
+ test("describes heartbeat windows with and without a value", () => {
173
+ expect(
174
+ MonitorCriteriaExpectationBuilder.describeCriteriaExpectation(
175
+ makeFilter({ filterType: FilterType.RecievedInMinutes, value: 10 }),
176
+ ),
177
+ ).toBe("to receive a heartbeat within 10 minutes");
178
+
179
+ expect(
180
+ MonitorCriteriaExpectationBuilder.describeCriteriaExpectation(
181
+ makeFilter({ filterType: FilterType.RecievedInMinutes }),
182
+ ),
183
+ ).toBe("to receive a heartbeat within the configured window");
184
+
185
+ expect(
186
+ MonitorCriteriaExpectationBuilder.describeCriteriaExpectation(
187
+ makeFilter({
188
+ filterType: FilterType.NotRecievedInMinutes,
189
+ value: 3,
190
+ }),
191
+ ),
192
+ ).toBe("to miss a heartbeat for at least 3 minutes");
193
+
194
+ expect(
195
+ MonitorCriteriaExpectationBuilder.describeCriteriaExpectation(
196
+ makeFilter({ filterType: FilterType.NotRecievedInMinutes }),
197
+ ),
198
+ ).toBe("to miss a heartbeat within the configured window");
199
+ });
200
+
201
+ test("falls back to the raw filter type for unhandled types", () => {
202
+ expect(
203
+ MonitorCriteriaExpectationBuilder.describeCriteriaExpectation(
204
+ makeFilter({ filterType: FilterType.Anomalous, value: 42 }),
205
+ ),
206
+ ).toBe("Anomalous 42");
207
+
208
+ expect(
209
+ MonitorCriteriaExpectationBuilder.describeCriteriaExpectation(
210
+ makeFilter({ filterType: FilterType.Anomalous }),
211
+ ),
212
+ ).toBe("Anomalous");
213
+ });
214
+
215
+ test("appends an evaluation window when configured", () => {
216
+ expect(
217
+ MonitorCriteriaExpectationBuilder.describeCriteriaExpectation(
218
+ makeFilter({
219
+ filterType: FilterType.GreaterThan,
220
+ value: 5,
221
+ evaluateOverTime: true,
222
+ evaluateOverTimeOptions: {
223
+ timeValueInMinutes: 15,
224
+ evaluateOverTimeType: EvaluateOverTimeType.Average,
225
+ },
226
+ }),
227
+ { unit: "ms" },
228
+ ),
229
+ ).toBe("to be greater than 5 ms over the last 15 minutes using average");
230
+ });
231
+ });
232
+
233
+ describe("getEvaluationWindowDescription", () => {
234
+ test("returns null when nothing about a window is configured", () => {
235
+ expect(
236
+ MonitorCriteriaExpectationBuilder.getEvaluationWindowDescription(
237
+ makeFilter({ filterType: FilterType.GreaterThan, value: 5 }),
238
+ ),
239
+ ).toBeNull();
240
+ });
241
+
242
+ test("describes only the time span when no aggregation is set", () => {
243
+ expect(
244
+ MonitorCriteriaExpectationBuilder.getEvaluationWindowDescription(
245
+ makeFilter({
246
+ evaluateOverTime: true,
247
+ evaluateOverTimeOptions: {
248
+ timeValueInMinutes: 30,
249
+ evaluateOverTimeType: undefined,
250
+ },
251
+ }),
252
+ ),
253
+ ).toBe("over the last 30 minutes");
254
+ });
255
+
256
+ test("ignores the time span when evaluateOverTime is off", () => {
257
+ // The span is only surfaced when evaluateOverTime is true.
258
+ expect(
259
+ MonitorCriteriaExpectationBuilder.getEvaluationWindowDescription(
260
+ makeFilter({
261
+ evaluateOverTime: false,
262
+ evaluateOverTimeOptions: {
263
+ timeValueInMinutes: 30,
264
+ evaluateOverTimeType: EvaluateOverTimeType.Sum,
265
+ },
266
+ }),
267
+ ),
268
+ ).toBe("using sum");
269
+ });
270
+
271
+ test("falls back to the metric aggregation type when no over-time type is set", () => {
272
+ expect(
273
+ MonitorCriteriaExpectationBuilder.getEvaluationWindowDescription(
274
+ makeFilter({
275
+ metricMonitorOptions: {
276
+ metricAggregationType: EvaluateOverTimeType.MaximumValue,
277
+ },
278
+ }),
279
+ ),
280
+ ).toBe("using maximum value");
281
+ });
282
+
283
+ test("prefers the over-time type over the metric aggregation type", () => {
284
+ expect(
285
+ MonitorCriteriaExpectationBuilder.getEvaluationWindowDescription(
286
+ makeFilter({
287
+ evaluateOverTime: true,
288
+ evaluateOverTimeOptions: {
289
+ timeValueInMinutes: 5,
290
+ evaluateOverTimeType: EvaluateOverTimeType.Average,
291
+ },
292
+ metricMonitorOptions: {
293
+ metricAggregationType: EvaluateOverTimeType.Sum,
294
+ },
295
+ }),
296
+ ),
297
+ ).toBe("over the last 5 minutes using average");
298
+ });
299
+ });
300
+ });
@@ -0,0 +1,103 @@
1
+ import { AreaChart } from "../../../../UI/Components/Charts/ChartLibrary/AreaChart/AreaChart";
2
+ import { LineChart } from "../../../../UI/Components/Charts/ChartLibrary/LineChart/LineChart";
3
+ import "@testing-library/jest-dom";
4
+ import { render } from "@testing-library/react";
5
+ import React from "react";
6
+ import { describe, expect, jest, test } from "@jest/globals";
7
+
8
+ /*
9
+ * ResponsiveContainer measures its parent, which is always 0x0 in jsdom,
10
+ * so the chart renders nothing without a fixed size.
11
+ */
12
+ jest.mock("recharts", () => {
13
+ const actual: Record<string, any> = jest.requireActual("recharts") as Record<
14
+ string,
15
+ any
16
+ >;
17
+ return {
18
+ ...actual,
19
+ ResponsiveContainer: ({ children }: { children: React.ReactElement }) => {
20
+ return React.cloneElement(children, {
21
+ width: 600,
22
+ height: 300,
23
+ } as Record<string, unknown>);
24
+ },
25
+ };
26
+ });
27
+
28
+ /*
29
+ * Compare-to-previous ghost series must be visually and interactively
30
+ * second-class: dashed, and never part of the fat invisible click-target
31
+ * lines that make live series selectable.
32
+ */
33
+
34
+ type ChartRow = Record<string, any>;
35
+
36
+ const data: Array<ChartRow> = [
37
+ { time: "10:00", live: 10, "live (previous)": 8 },
38
+ { time: "10:05", live: 20, "live (previous)": 12 },
39
+ { time: "10:10", live: 15, "live (previous)": 9 },
40
+ ];
41
+
42
+ describe.each([
43
+ ["LineChart", LineChart],
44
+ ["AreaChart", AreaChart],
45
+ ] as Array<[string, typeof LineChart | typeof AreaChart]>)(
46
+ "%s ghost series",
47
+ (_name: string, Chart: typeof LineChart | typeof AreaChart) => {
48
+ test("ghost categories render dashed; live ones do not", () => {
49
+ const { container } = render(
50
+ <Chart
51
+ data={data}
52
+ index="time"
53
+ categories={["live", "live (previous)"]}
54
+ ghostCategories={["live (previous)"]}
55
+ />,
56
+ );
57
+
58
+ const dashed: Array<Element> = Array.from(
59
+ container.querySelectorAll('path[stroke-dasharray="6 4"]'),
60
+ );
61
+ expect(dashed.length).toBeGreaterThan(0);
62
+
63
+ /*
64
+ * The live series' curve must NOT be dashed — recharts names each
65
+ * series path group; assert at least one series curve without the
66
+ * ghost dash exists.
67
+ */
68
+ const allCurves: Array<Element> = Array.from(
69
+ container.querySelectorAll(".recharts-layer path"),
70
+ ).filter((el: Element) => {
71
+ return el.getAttribute("stroke-dasharray") !== "6 4";
72
+ });
73
+ expect(allCurves.length).toBeGreaterThan(0);
74
+ });
75
+
76
+ test("ghosts are excluded from the fat click-target lines", () => {
77
+ const { container } = render(
78
+ <Chart
79
+ data={data}
80
+ index="time"
81
+ categories={["live", "live (previous)"]}
82
+ ghostCategories={["live (previous)"]}
83
+ onValueChange={() => {
84
+ // Enables the click-target machinery, like the wrappers do.
85
+ }}
86
+ />,
87
+ );
88
+
89
+ /*
90
+ * LineChart renders invisible strokeWidth-12 click-targets per
91
+ * SELECTABLE series; ghosts must not get one. (AreaChart has no
92
+ * fat-line mechanism — zero is correct there.)
93
+ */
94
+ const fatLines: Array<Element> = Array.from(
95
+ container.querySelectorAll('path[stroke-width="12"]'),
96
+ );
97
+ expect(fatLines.length).toBeLessThanOrEqual(1);
98
+ for (const fatLine of fatLines) {
99
+ expect(fatLine.getAttribute("name")).not.toContain("(previous)");
100
+ }
101
+ });
102
+ },
103
+ );