@oneuptime/common 14.0.5 → 14.0.6

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 (66) hide show
  1. package/Server/Services/MetricService.ts +1 -1
  2. package/Server/Types/Database/QueryHelper.ts +54 -1
  3. package/Server/Utils/AI/Toolbox/MetricTools.ts +13 -2
  4. package/Server/Utils/AI/Toolbox/ResourceTools.ts +12 -1
  5. package/Server/Utils/AnalyticsDatabase/StatementGenerator.ts +1 -1
  6. package/Server/Utils/Monitor/Criteria/CompareCriteria.ts +33 -0
  7. package/Server/Utils/Monitor/Criteria/MetricMonitorCriteria.ts +4 -3
  8. package/Server/Utils/Monitor/MonitorCriteriaEvaluator.ts +3 -1
  9. package/Tests/App/Dashboard/NetworkTopologyExplorerSearch.test.tsx +1267 -0
  10. package/Tests/App/Dashboard/ResourceOverviewTileTooltips.test.tsx +4 -1
  11. package/Tests/App/Dashboard/RumOverviewPage.test.tsx +127 -0
  12. package/Tests/App/Dashboard/SiteSearchBox.test.tsx +2110 -0
  13. package/Tests/App/Dashboard/TelemetryMetricsSignals.test.ts +168 -8
  14. package/Tests/Server/Services/MetricServiceAggregate.test.ts +12 -0
  15. package/Tests/Server/Services/SavedViewDefaults.test.ts +325 -0
  16. package/Tests/Server/Services/StatusPageCertificateService.test.ts +169 -0
  17. package/Tests/Server/Services/TeamComplianceSettingService.test.ts +191 -0
  18. package/Tests/Server/Types/Database/QueryHelperSearchAllWords.test.ts +651 -0
  19. package/Tests/Server/Utils/AI/AlertWriteTools.test.ts +256 -0
  20. package/Tests/Server/Utils/AI/IncidentWriteTools.test.ts +482 -0
  21. package/Tests/Server/Utils/Monitor/Criteria/CompareCriteria.test.ts +54 -0
  22. package/Tests/Server/Utils/Monitor/MetricDecimalThreshold.test.ts +417 -0
  23. package/Tests/Server/Utils/Monitor/MonitorCriteriaEvaluatorAffectedResourceEdgeCases.test.ts +96 -0
  24. package/Tests/Types/BaseDatabase/AggregationType.test.ts +5 -1
  25. package/Tests/Types/Dashboard/KubernetesDashboardTemplate.test.ts +184 -0
  26. package/Tests/Types/Dashboard/KubernetesDashboardTemplates.test.ts +107 -0
  27. package/Tests/Types/Monitor/KubernetesAlertTemplates.test.ts +8 -14
  28. package/Tests/Types/Monitor/KubernetesMetricCatalog.test.ts +4 -3
  29. package/Tests/Types/Monitor/Utils/KubernetesAgentEmittedMetrics.ts +27 -6
  30. package/Tests/UI/Rum/ChunkLoader.test.ts +76 -0
  31. package/Tests/UI/Rum/ReplayRailDetail.test.tsx +53 -0
  32. package/Tests/UI/Rum/ReplaySignals.test.ts +54 -0
  33. package/Tests/Utils/Dashboard/KubernetesClusterCpuTileRepair.test.ts +370 -0
  34. package/Tests/Utils/Rules/MonitorTypeRuleCriteria.test.ts +181 -0
  35. package/Types/BaseDatabase/AggregationType.ts +7 -0
  36. package/Types/Dashboard/DashboardTemplates.ts +20 -7
  37. package/Types/Rum/SessionReplayCustomEvents.ts +29 -3
  38. package/Types/Rum/WebVitals.ts +13 -0
  39. package/UI/Components/Input/Input.tsx +11 -0
  40. package/Utils/Dashboard/KubernetesClusterCpuTileRepair.ts +147 -0
  41. package/build/dist/Server/Services/MetricService.js +1 -1
  42. package/build/dist/Server/Types/Database/QueryHelper.js +47 -1
  43. package/build/dist/Server/Types/Database/QueryHelper.js.map +1 -1
  44. package/build/dist/Server/Utils/AI/Toolbox/MetricTools.js +13 -2
  45. package/build/dist/Server/Utils/AI/Toolbox/MetricTools.js.map +1 -1
  46. package/build/dist/Server/Utils/AI/Toolbox/ResourceTools.js +12 -1
  47. package/build/dist/Server/Utils/AI/Toolbox/ResourceTools.js.map +1 -1
  48. package/build/dist/Server/Utils/AnalyticsDatabase/StatementGenerator.js +1 -1
  49. package/build/dist/Server/Utils/Monitor/Criteria/CompareCriteria.js +31 -0
  50. package/build/dist/Server/Utils/Monitor/Criteria/CompareCriteria.js.map +1 -1
  51. package/build/dist/Server/Utils/Monitor/Criteria/MetricMonitorCriteria.js +1 -1
  52. package/build/dist/Server/Utils/Monitor/Criteria/MetricMonitorCriteria.js.map +1 -1
  53. package/build/dist/Server/Utils/Monitor/MonitorCriteriaEvaluator.js +1 -1
  54. package/build/dist/Server/Utils/Monitor/MonitorCriteriaEvaluator.js.map +1 -1
  55. package/build/dist/Types/BaseDatabase/AggregationType.js +7 -0
  56. package/build/dist/Types/BaseDatabase/AggregationType.js.map +1 -1
  57. package/build/dist/Types/Dashboard/DashboardTemplates.js +20 -7
  58. package/build/dist/Types/Dashboard/DashboardTemplates.js.map +1 -1
  59. package/build/dist/Types/Rum/SessionReplayCustomEvents.js.map +1 -1
  60. package/build/dist/Types/Rum/WebVitals.js +12 -0
  61. package/build/dist/Types/Rum/WebVitals.js.map +1 -1
  62. package/build/dist/UI/Components/Input/Input.js +1 -1
  63. package/build/dist/UI/Components/Input/Input.js.map +1 -1
  64. package/build/dist/Utils/Dashboard/KubernetesClusterCpuTileRepair.js +110 -0
  65. package/build/dist/Utils/Dashboard/KubernetesClusterCpuTileRepair.js.map +1 -0
  66. package/package.json +1 -1
@@ -0,0 +1,370 @@
1
+ import AggregationType from "../../../Types/BaseDatabase/AggregationType";
2
+ import { JSONObject } from "../../../Types/JSON";
3
+ import KubernetesClusterCpuTileRepair, {
4
+ BUSIEST_NODE_CPU_TILE_TITLE,
5
+ KUBERNETES_NODE_CPU_USAGE_METRIC,
6
+ KubernetesClusterCpuTileRepairResult,
7
+ LEGACY_CLUSTER_CPU_TILE_TITLE,
8
+ } from "../../../Utils/Dashboard/KubernetesClusterCpuTileRepair";
9
+ import { describe, expect, test } from "@jest/globals";
10
+
11
+ /*
12
+ * The repair behind the RepairKubernetesDashboardClusterCpuTile migration.
13
+ * Existing Kubernetes dashboards hold a COPY of the template, so their
14
+ * "Cluster CPU (cores in use)" tile still sums k8s.node.cpu.usage over every
15
+ * node, scrape and bucket. What is pinned here: exactly which tiles are
16
+ * rewritten, to what, that nothing else on the dashboard moves, and that
17
+ * the Postgres filter the migration selects rows with agrees with the
18
+ * in-memory matcher.
19
+ *
20
+ * Fixtures are shaped like a stored dashboardViewConfig: plain jsonb, with
21
+ * componentId serialised as { _type: "ObjectID", value } and the
22
+ * misspelled `aggegationType` the fetch layer reads.
23
+ */
24
+
25
+ function makeValueTile(data: {
26
+ componentId?: string;
27
+ title?: string;
28
+ metricName?: string;
29
+ aggregation?: string;
30
+ extraArguments?: JSONObject;
31
+ extraFilterData?: JSONObject;
32
+ }): JSONObject {
33
+ return {
34
+ _type: "DashboardComponent",
35
+ componentType: "Value",
36
+ componentId: {
37
+ _type: "ObjectID",
38
+ value: data.componentId || "0b6a2c3e-0000-4000-8000-000000000001",
39
+ },
40
+ topInDashboardUnits: 11,
41
+ leftInDashboardUnits: 0,
42
+ widthInDashboardUnits: 4,
43
+ heightInDashboardUnits: 1,
44
+ minHeightInDashboardUnits: 1,
45
+ minWidthInDashboardUnits: 1,
46
+ arguments: {
47
+ title: data.title ?? LEGACY_CLUSTER_CPU_TILE_TITLE,
48
+ metricQueryConfig: {
49
+ metricQueryData: {
50
+ filterData: {
51
+ metricName: data.metricName ?? KUBERNETES_NODE_CPU_USAGE_METRIC,
52
+ aggegationType: data.aggregation ?? AggregationType.Sum,
53
+ ...(data.extraFilterData || {}),
54
+ },
55
+ },
56
+ },
57
+ trendDirection: "HigherIsWorse",
58
+ ...(data.extraArguments || {}),
59
+ },
60
+ };
61
+ }
62
+
63
+ function makeConfig(components: Array<unknown>): JSONObject {
64
+ return {
65
+ _type: "DashboardViewConfig",
66
+ heightInDashboardUnits: 21,
67
+ variables: [
68
+ {
69
+ id: "cluster",
70
+ name: "Cluster",
71
+ type: "Telemetry Attribute",
72
+ attributeKey: "resource.k8s.cluster.name",
73
+ },
74
+ ],
75
+ components: components as Array<JSONObject>,
76
+ };
77
+ }
78
+
79
+ function componentsOf(config: JSONObject): Array<JSONObject> {
80
+ return config["components"] as Array<JSONObject>;
81
+ }
82
+
83
+ function filterDataOf(component: JSONObject): JSONObject {
84
+ const args: JSONObject = component["arguments"] as JSONObject;
85
+ const metricQueryConfig: JSONObject = args["metricQueryConfig"] as JSONObject;
86
+ const metricQueryData: JSONObject = metricQueryConfig[
87
+ "metricQueryData"
88
+ ] as JSONObject;
89
+
90
+ return metricQueryData["filterData"] as JSONObject;
91
+ }
92
+
93
+ /*
94
+ * Postgres `@>` for jsonb, as documented (8.14.3): an object contains
95
+ * another when every key of the right side is present and contains its
96
+ * value; an array contains another when every element of the right side is
97
+ * contained by SOME element of the left; scalars must be equal. Used only to
98
+ * show that the migration's SQL filter and isLegacyTile pick the same rows.
99
+ */
100
+ function jsonbContains(left: unknown, right: unknown): boolean {
101
+ if (Array.isArray(right)) {
102
+ return (
103
+ Array.isArray(left) &&
104
+ right.every((rightItem: unknown) => {
105
+ return left.some((leftItem: unknown) => {
106
+ return jsonbContains(leftItem, rightItem);
107
+ });
108
+ })
109
+ );
110
+ }
111
+
112
+ if (right && typeof right === "object") {
113
+ if (!left || typeof left !== "object" || Array.isArray(left)) {
114
+ return false;
115
+ }
116
+
117
+ return Object.entries(right as JSONObject).every(
118
+ ([key, value]: [string, unknown]) => {
119
+ return (
120
+ Object.prototype.hasOwnProperty.call(left, key) &&
121
+ jsonbContains((left as JSONObject)[key], value)
122
+ );
123
+ },
124
+ );
125
+ }
126
+
127
+ return left === right;
128
+ }
129
+
130
+ const TEXT_HEADER: JSONObject = {
131
+ _type: "DashboardComponent",
132
+ componentType: "Text",
133
+ componentId: { _type: "ObjectID", value: "text-header" },
134
+ arguments: { text: "Resource Health", isBold: true },
135
+ };
136
+
137
+ const NETWORK_CHART: JSONObject = {
138
+ _type: "DashboardComponent",
139
+ componentType: "Chart",
140
+ componentId: { _type: "ObjectID", value: "network-chart" },
141
+ arguments: {
142
+ chartTitle: "Network I/O",
143
+ metricQueryConfig: {
144
+ metricQueryData: {
145
+ filterData: {
146
+ metricName: "k8s.pod.network.io",
147
+ aggegationType: AggregationType.Sum,
148
+ },
149
+ },
150
+ },
151
+ },
152
+ };
153
+
154
+ describe("KubernetesClusterCpuTileRepair", () => {
155
+ describe("repair", () => {
156
+ test("rewrites the legacy tile to the busiest node's peak CPU", () => {
157
+ const result: KubernetesClusterCpuTileRepairResult | null =
158
+ KubernetesClusterCpuTileRepair.repair(
159
+ makeConfig([TEXT_HEADER, makeValueTile({}), NETWORK_CHART]),
160
+ );
161
+
162
+ expect(result).not.toBeNull();
163
+ expect(result!.repairedTileCount).toBe(1);
164
+
165
+ const tile: JSONObject = componentsOf(result!.config)[1]!;
166
+ expect((tile["arguments"] as JSONObject)["title"]).toBe(
167
+ BUSIEST_NODE_CPU_TILE_TITLE,
168
+ );
169
+ expect(filterDataOf(tile)["aggegationType"]).toBe(AggregationType.Max);
170
+ expect(filterDataOf(tile)["metricName"]).toBe(
171
+ KUBERNETES_NODE_CPU_USAGE_METRIC,
172
+ );
173
+ });
174
+
175
+ test("changes only the title and the aggregation", () => {
176
+ const original: JSONObject = makeConfig([
177
+ TEXT_HEADER,
178
+ makeValueTile({
179
+ extraArguments: { warningThreshold: 3, criticalThreshold: 6 },
180
+ extraFilterData: { attributes: { "resource.k8s.node.name": "a" } },
181
+ }),
182
+ NETWORK_CHART,
183
+ ]);
184
+
185
+ const expected: JSONObject = JSON.parse(JSON.stringify(original));
186
+ const expectedTile: JSONObject = componentsOf(expected)[1]!;
187
+ (expectedTile["arguments"] as JSONObject)["title"] =
188
+ BUSIEST_NODE_CPU_TILE_TITLE;
189
+ filterDataOf(expectedTile)["aggegationType"] = AggregationType.Max;
190
+
191
+ expect(KubernetesClusterCpuTileRepair.repair(original)!.config).toEqual(
192
+ expected,
193
+ );
194
+ });
195
+
196
+ test("never mutates the config it was given", () => {
197
+ const original: JSONObject = makeConfig([makeValueTile({})]);
198
+ const snapshot: string = JSON.stringify(original);
199
+
200
+ KubernetesClusterCpuTileRepair.repair(original);
201
+
202
+ expect(JSON.stringify(original)).toBe(snapshot);
203
+ });
204
+
205
+ test("repairs every copy of the tile on one dashboard", () => {
206
+ const result: KubernetesClusterCpuTileRepairResult | null =
207
+ KubernetesClusterCpuTileRepair.repair(
208
+ makeConfig([
209
+ makeValueTile({ componentId: "first" }),
210
+ makeValueTile({ componentId: "second" }),
211
+ ]),
212
+ );
213
+
214
+ expect(result!.repairedTileCount).toBe(2);
215
+ for (const tile of componentsOf(result!.config)) {
216
+ expect(filterDataOf(tile)["aggegationType"]).toBe(AggregationType.Max);
217
+ }
218
+ });
219
+
220
+ test("is a no-op on its own output, so a second run writes nothing", () => {
221
+ const once: KubernetesClusterCpuTileRepairResult | null =
222
+ KubernetesClusterCpuTileRepair.repair(makeConfig([makeValueTile({})]));
223
+
224
+ expect(once).not.toBeNull();
225
+ expect(KubernetesClusterCpuTileRepair.repair(once!.config)).toBeNull();
226
+ });
227
+
228
+ /*
229
+ * The migration only corrects what the template put there. A user who
230
+ * renamed the tile, re-aggregated it, pointed it at another metric or
231
+ * drew the same query as a chart made that choice themselves.
232
+ */
233
+ test.each([
234
+ ["a renamed tile", makeValueTile({ title: "All Nodes CPU" })],
235
+ [
236
+ "a title that differs only in whitespace",
237
+ makeValueTile({ title: ` ${LEGACY_CLUSTER_CPU_TILE_TITLE}` }),
238
+ ],
239
+ [
240
+ "a tile already re-aggregated to Avg",
241
+ makeValueTile({ aggregation: AggregationType.Avg }),
242
+ ],
243
+ [
244
+ "a tile already re-aggregated to Max",
245
+ makeValueTile({ aggregation: AggregationType.Max }),
246
+ ],
247
+ [
248
+ "a Sum tile over another metric",
249
+ makeValueTile({ metricName: "k8s.pod.cpu.usage" }),
250
+ ],
251
+ [
252
+ "a chart with the same title, metric and Sum",
253
+ { ...makeValueTile({}), componentType: "Chart" },
254
+ ],
255
+ ["a text widget", TEXT_HEADER],
256
+ ])("leaves %s alone", (_label: string, component: JSONObject) => {
257
+ expect(
258
+ KubernetesClusterCpuTileRepair.repair(makeConfig([component])),
259
+ ).toBeNull();
260
+ });
261
+
262
+ test.each([
263
+ ["undefined", undefined],
264
+ ["null", null],
265
+ ["a string", "not a config"],
266
+ ["an array", [makeValueTile({})]],
267
+ ["a config without components", { _type: "DashboardViewConfig" }],
268
+ [
269
+ "a config whose components are not an array",
270
+ { components: { 0: makeValueTile({}) } },
271
+ ],
272
+ ["a config with no components", makeConfig([])],
273
+ ])("returns null for %s", (_label: string, config: unknown) => {
274
+ expect(KubernetesClusterCpuTileRepair.repair(config)).toBeNull();
275
+ });
276
+
277
+ test("skips malformed components without throwing", () => {
278
+ const result: KubernetesClusterCpuTileRepairResult | null =
279
+ KubernetesClusterCpuTileRepair.repair(
280
+ makeConfig([
281
+ null,
282
+ "text",
283
+ 42,
284
+ { componentType: "Value" },
285
+ { componentType: "Value", arguments: null },
286
+ {
287
+ componentType: "Value",
288
+ arguments: { title: LEGACY_CLUSTER_CPU_TILE_TITLE },
289
+ },
290
+ makeValueTile({}),
291
+ ]),
292
+ );
293
+
294
+ expect(result!.repairedTileCount).toBe(1);
295
+ expect(componentsOf(result!.config).slice(0, 6)).toEqual([
296
+ null,
297
+ "text",
298
+ 42,
299
+ { componentType: "Value" },
300
+ { componentType: "Value", arguments: null },
301
+ {
302
+ componentType: "Value",
303
+ arguments: { title: LEGACY_CLUSTER_CPU_TILE_TITLE },
304
+ },
305
+ ]);
306
+ });
307
+ });
308
+
309
+ describe("getLegacyTileContainment", () => {
310
+ /*
311
+ * The migration reads only the dashboards Postgres matches with this
312
+ * document, then repairs them with isLegacyTile. If the two disagree,
313
+ * a dashboard is either never read or read and left unrepaired.
314
+ */
315
+ test.each([
316
+ ["the legacy tile", makeConfig([TEXT_HEADER, makeValueTile({})]), true],
317
+ [
318
+ "the legacy tile with user thresholds and filters",
319
+ makeConfig([
320
+ makeValueTile({
321
+ extraArguments: { warningThreshold: 3 },
322
+ extraFilterData: { attributes: { "resource.k8s.node.name": "a" } },
323
+ }),
324
+ ]),
325
+ true,
326
+ ],
327
+ [
328
+ "a repaired tile",
329
+ makeConfig([
330
+ makeValueTile({
331
+ title: BUSIEST_NODE_CPU_TILE_TITLE,
332
+ aggregation: AggregationType.Max,
333
+ }),
334
+ ]),
335
+ false,
336
+ ],
337
+ [
338
+ "the title on one tile and the Sum on another",
339
+ makeConfig([
340
+ makeValueTile({ aggregation: AggregationType.Avg }),
341
+ makeValueTile({ title: "All Nodes CPU" }),
342
+ ]),
343
+ false,
344
+ ],
345
+ [
346
+ "a chart with the legacy signature",
347
+ makeConfig([{ ...makeValueTile({}), componentType: "Chart" }]),
348
+ false,
349
+ ],
350
+ [
351
+ "an unrelated dashboard",
352
+ makeConfig([TEXT_HEADER, NETWORK_CHART]),
353
+ false,
354
+ ],
355
+ ])(
356
+ "agrees with repair() on %s",
357
+ (_label: string, config: JSONObject, shouldMatch: boolean) => {
358
+ expect(
359
+ jsonbContains(
360
+ config,
361
+ KubernetesClusterCpuTileRepair.getLegacyTileContainment(),
362
+ ),
363
+ ).toBe(shouldMatch);
364
+ expect(KubernetesClusterCpuTileRepair.repair(config) !== null).toBe(
365
+ shouldMatch,
366
+ );
367
+ },
368
+ );
369
+ });
370
+ });
@@ -0,0 +1,181 @@
1
+ import {
2
+ getMonitorTypeCriteriaValidationError,
3
+ isMonitorTypeCriteriaValue,
4
+ } from "../../../Utils/Rules/MonitorTypeRuleCriteria";
5
+ import MonitorType from "../../../Types/Monitor/MonitorType";
6
+ import {
7
+ RuleCriteriaFilter,
8
+ RuleCriteriaOperator,
9
+ RuleCriteriaValue,
10
+ } from "../../../Types/Rules/RuleCriteria";
11
+ import { describe, expect, test } from "@jest/globals";
12
+
13
+ /*
14
+ * The gate that decides whether a rule's `monitorType` criterion may be saved.
15
+ * DatabaseService runs it on every write and the SLO monitor-rule engine runs
16
+ * it again when it matches monitors, so a criterion this lets through has to
17
+ * be one the engine can actually evaluate: a monitor type is an enum choice,
18
+ * which means an exact match either way and nothing else. A text operator
19
+ * slipping past here is a rule that silently matches nothing.
20
+ */
21
+
22
+ function buildFilter(
23
+ operator: RuleCriteriaOperator,
24
+ value: RuleCriteriaValue,
25
+ ): RuleCriteriaFilter {
26
+ return { field: "monitorType", operator: operator, value: value };
27
+ }
28
+
29
+ const OPERATOR_ERROR: string =
30
+ "Monitor type criteria only support Equals and Does not equal operators.";
31
+ const VALUE_ERROR: string =
32
+ "Monitor type criteria require a valid monitor type.";
33
+
34
+ describe("isMonitorTypeCriteriaValue", () => {
35
+ test("accepts every monitor type the product defines", () => {
36
+ const allTypes: Array<string> = Object.values(MonitorType);
37
+ expect(allTypes.length).toBeGreaterThan(0);
38
+ for (const monitorType of allTypes) {
39
+ expect(isMonitorTypeCriteriaValue(monitorType)).toBe(true);
40
+ }
41
+ });
42
+
43
+ test("rejects a string that is not a monitor type", () => {
44
+ expect(isMonitorTypeCriteriaValue("Website")).toBe(
45
+ Object.values(MonitorType).includes("Website" as MonitorType),
46
+ );
47
+ expect(isMonitorTypeCriteriaValue("NotAMonitorType")).toBe(false);
48
+ expect(isMonitorTypeCriteriaValue("")).toBe(false);
49
+ });
50
+
51
+ test("is case- and whitespace-sensitive, as an enum comparison must be", () => {
52
+ const [first] = Object.values(MonitorType) as Array<string>;
53
+ expect(isMonitorTypeCriteriaValue(first!)).toBe(true);
54
+ expect(isMonitorTypeCriteriaValue(first!.toLowerCase())).toBe(
55
+ first === first!.toLowerCase(),
56
+ );
57
+ expect(isMonitorTypeCriteriaValue(` ${first!}`)).toBe(false);
58
+ expect(isMonitorTypeCriteriaValue(`${first!} `)).toBe(false);
59
+ });
60
+
61
+ test("rejects every non-string value a criterion can carry", () => {
62
+ const nonStrings: Array<unknown> = [
63
+ undefined,
64
+ null,
65
+ 0,
66
+ 1,
67
+ true,
68
+ false,
69
+ [],
70
+ [Object.values(MonitorType)[0]],
71
+ {},
72
+ { value: Object.values(MonitorType)[0] },
73
+ ];
74
+ for (const value of nonStrings) {
75
+ expect(isMonitorTypeCriteriaValue(value)).toBe(false);
76
+ }
77
+ });
78
+
79
+ test("does not accept an inherited Object property as a monitor type", () => {
80
+ // The lookup is a Set, not a plain object, so "toString" is not a member.
81
+ expect(isMonitorTypeCriteriaValue("toString")).toBe(false);
82
+ expect(isMonitorTypeCriteriaValue("constructor")).toBe(false);
83
+ expect(isMonitorTypeCriteriaValue("__proto__")).toBe(false);
84
+ });
85
+ });
86
+
87
+ describe("getMonitorTypeCriteriaValidationError", () => {
88
+ const validType: MonitorType = Object.values(MonitorType)[0] as MonitorType;
89
+
90
+ test("passes Equals and NotEquals against a real monitor type", () => {
91
+ for (const operator of [
92
+ RuleCriteriaOperator.Equals,
93
+ RuleCriteriaOperator.NotEquals,
94
+ ]) {
95
+ expect(
96
+ getMonitorTypeCriteriaValidationError(buildFilter(operator, validType)),
97
+ ).toBeNull();
98
+ }
99
+ });
100
+
101
+ test("passes every monitor type under Equals", () => {
102
+ for (const monitorType of Object.values(MonitorType)) {
103
+ expect(
104
+ getMonitorTypeCriteriaValidationError(
105
+ buildFilter(RuleCriteriaOperator.Equals, monitorType),
106
+ ),
107
+ ).toBeNull();
108
+ }
109
+ });
110
+
111
+ test("rejects every operator other than Equals and NotEquals", () => {
112
+ const rejected: Array<RuleCriteriaOperator> = Object.values(
113
+ RuleCriteriaOperator,
114
+ ).filter((operator: RuleCriteriaOperator) => {
115
+ return (
116
+ operator !== RuleCriteriaOperator.Equals &&
117
+ operator !== RuleCriteriaOperator.NotEquals
118
+ );
119
+ });
120
+ expect(rejected.length).toBeGreaterThan(0);
121
+
122
+ for (const operator of rejected) {
123
+ expect(
124
+ getMonitorTypeCriteriaValidationError(buildFilter(operator, validType)),
125
+ ).toBe(OPERATOR_ERROR);
126
+ }
127
+ });
128
+
129
+ test("reports the operator first when both the operator and the value are wrong", () => {
130
+ expect(
131
+ getMonitorTypeCriteriaValidationError(
132
+ buildFilter(RuleCriteriaOperator.Contains, "NotAMonitorType"),
133
+ ),
134
+ ).toBe(OPERATOR_ERROR);
135
+ });
136
+
137
+ test("rejects a value that is not a monitor type under a supported operator", () => {
138
+ for (const operator of [
139
+ RuleCriteriaOperator.Equals,
140
+ RuleCriteriaOperator.NotEquals,
141
+ ]) {
142
+ expect(
143
+ getMonitorTypeCriteriaValidationError(
144
+ buildFilter(operator, "NotAMonitorType"),
145
+ ),
146
+ ).toBe(VALUE_ERROR);
147
+ expect(
148
+ getMonitorTypeCriteriaValidationError(buildFilter(operator, "")),
149
+ ).toBe(VALUE_ERROR);
150
+ /*
151
+ * An array is what the HasAnyOf-style operators carry; it is not a
152
+ * monitor type even when every member of it is one.
153
+ */
154
+ expect(
155
+ getMonitorTypeCriteriaValidationError(
156
+ buildFilter(operator, [validType]),
157
+ ),
158
+ ).toBe(VALUE_ERROR);
159
+ expect(
160
+ getMonitorTypeCriteriaValidationError(buildFilter(operator, true)),
161
+ ).toBe(VALUE_ERROR);
162
+ expect(
163
+ getMonitorTypeCriteriaValidationError(buildFilter(operator, 0)),
164
+ ).toBe(VALUE_ERROR);
165
+ }
166
+ });
167
+
168
+ test("ignores the filter's field: the caller decides which field this applies to", () => {
169
+ expect(
170
+ getMonitorTypeCriteriaValidationError({
171
+ field: "somethingElse",
172
+ operator: RuleCriteriaOperator.Equals,
173
+ value: validType,
174
+ }),
175
+ ).toBeNull();
176
+ });
177
+
178
+ test("the two errors are distinct, so a form can say which one it is", () => {
179
+ expect(OPERATOR_ERROR).not.toBe(VALUE_ERROR);
180
+ });
181
+ });
@@ -15,6 +15,12 @@ enum AggregationType {
15
15
  * over the raw column, which is the right thing for scalar columns.
16
16
  */
17
17
  P50 = "P50",
18
+ /*
19
+ * The level Core Web Vitals are judged at: Google's good / poor
20
+ * thresholds for LCP, INP and CLS apply to the 75th percentile of page
21
+ * views, so a web-vital chart or monitor wants exactly this one.
22
+ */
23
+ P75 = "P75",
18
24
  P90 = "P90",
19
25
  P95 = "P95",
20
26
  P99 = "P99",
@@ -24,6 +30,7 @@ export default AggregationType;
24
30
 
25
31
  export const PercentileAggregationLevels: Record<string, number> = {
26
32
  [AggregationType.P50]: 0.5,
33
+ [AggregationType.P75]: 0.75,
27
34
  [AggregationType.P90]: 0.9,
28
35
  [AggregationType.P95]: 0.95,
29
36
  [AggregationType.P99]: 0.99,
@@ -1742,6 +1742,18 @@ function createKubernetesDashboardConfig(): DashboardViewConfig {
1742
1742
  * metrics and label them in cores ("2.3 cores"). The Kubernetes
1743
1743
  * cluster overview page — which fetches `k8s.node.allocatable_cpu` —
1744
1744
  * is where CPU is shown as a real "% of capacity".
1745
+ *
1746
+ * - "Cluster CPU (cores in use)" used to be a Value widget over
1747
+ * k8s.node.cpu.usage with `Sum` — the same trap as Pod Count. The
1748
+ * server sums every node's reading in each bucket and the Value widget
1749
+ * then sums the buckets, so a one-hour window of minute buckets and
1750
+ * 30s kubelet scrapes read 540 for three nodes on 1.5 cores each,
1751
+ * instead of 4.5. A Value widget can't express "sum across nodes at one
1752
+ * instant", so the tile is now the busiest node's peak (`Max`), which
1753
+ * is honest at any window and complements the per-node average in
1754
+ * row 1. Cluster-wide CPU against capacity lives on the cluster
1755
+ * overview page. Dashboards already created from this template are
1756
+ * repaired by the RepairKubernetesDashboardClusterCpuTile migration.
1745
1757
  */
1746
1758
  const components: Array<DashboardBaseComponent> = [
1747
1759
  // Row 0: Title
@@ -1875,22 +1887,23 @@ function createKubernetesDashboardConfig(): DashboardViewConfig {
1875
1887
  }),
1876
1888
 
1877
1889
  /*
1878
- * Row 11-13: cluster CPU cores tile and the network throughput
1879
- * chart. This was a 0-100 CPU gauge, but the cores-valued
1890
+ * Row 11-13: busiest-node CPU tile and the network throughput chart.
1891
+ * This was a 0-100 CPU gauge, but the cores-valued
1880
1892
  * `k8s.node.cpu.utilization` pinned it to nonsense (e.g. 711%) and
1881
1893
  * the templated renderer can't divide by allocatable CPU to make a
1882
- * real percentage — so we show total cores in use instead. The old
1883
- * "Memory Utilization" gauge over raw bytes is gone — see
1884
- * top-of-function comment.
1894
+ * real percentage. It then became a `Sum` of cores, which multiplied
1895
+ * by the scrape count — see top-of-function comment. `Max` is the
1896
+ * highest reading any single node reported in the window. The old
1897
+ * "Memory Utilization" gauge over raw bytes is gone too.
1885
1898
  */
1886
1899
  createValueComponent({
1887
- title: "Cluster CPU (cores in use)",
1900
+ title: "Busiest Node CPU (cores, peak)",
1888
1901
  top: 11,
1889
1902
  left: 0,
1890
1903
  width: 4,
1891
1904
  metricConfig: {
1892
1905
  metricName: "k8s.node.cpu.usage",
1893
- aggregationType: MetricsAggregationType.Sum,
1906
+ aggregationType: MetricsAggregationType.Max,
1894
1907
  },
1895
1908
  trendDirection: DashboardValueTrendDirection.HigherIsWorse,
1896
1909
  }),
@@ -215,15 +215,27 @@ export type SessionReplayWebVitalMetric =
215
215
  export type SessionReplayWebVitalRating = "good" | "needs-improvement" | "poor";
216
216
 
217
217
  /*
218
- * A web vital observed once per metric per page. Informational only:
219
- * vitals never trigger an upload, and CLS's value is unitless while the
220
- * others are milliseconds.
218
+ * How the view an INP describes began: the document load ("hard") or a
219
+ * single-page-app route change ("soft").
220
+ */
221
+ export type SessionReplayNavigationType = "hard" | "soft";
222
+
223
+ /* The input that produced an INP, as the browser classes it. */
224
+ export type SessionReplayInteractionType = "pointer" | "keyboard";
225
+
226
+ /*
227
+ * A web vital. LCP, FCP, CLS and TTFB are observed once per page load;
228
+ * INP once per VIEW (newer recorders), so a single-page app reports one
229
+ * INP for each route the user interacted on instead of one for the whole
230
+ * visit. Informational only: vitals never trigger an upload, and CLS's
231
+ * value is unitless while the others are milliseconds.
221
232
  */
222
233
  export interface SessionReplayWebVitalPayload {
223
234
  kind: "web-vital";
224
235
  metric: SessionReplayWebVitalMetric;
225
236
  value: number;
226
237
  rating: SessionReplayWebVitalRating;
238
+ /* INP: the scrubbed URL of the view the interaction happened on. */
227
239
  url?: string;
228
240
  /*
229
241
  * As on SessionReplayPerformanceBudgetPayload: when the measured entry
@@ -232,6 +244,20 @@ export interface SessionReplayWebVitalPayload {
232
244
  * after the moment the number describes. Optional for older recorders.
233
245
  */
234
246
  occurredAtUnixMs?: number;
247
+
248
+ /*
249
+ * INP attribution, all optional (older recorders and engines without
250
+ * the timing fields omit them). The three phases add up to roughly the
251
+ * value: waiting for the main thread, running the page's handlers, and
252
+ * rendering the next frame. The target is a structural selector built
253
+ * exactly as a click's is - never an attribute value or text.
254
+ */
255
+ navigationType?: SessionReplayNavigationType;
256
+ interactionType?: SessionReplayInteractionType;
257
+ interactionTarget?: string;
258
+ inputDelayMs?: number;
259
+ processingDurationMs?: number;
260
+ presentationDelayMs?: number;
235
261
  }
236
262
 
237
263
  export type SessionReplayPerformancePayload =
@@ -86,6 +86,19 @@ export const WebVitalDefinitions: Array<WebVitalDefinition> = [
86
86
  },
87
87
  ];
88
88
 
89
+ /*
90
+ * The metric attribute that names the route a web vital was measured on,
91
+ * tried in order for the RUM overview's per-route breakdown. `app.route`
92
+ * is what the Core Web Vitals docs tell instrumentation to set;
93
+ * `url.template` is OpenTelemetry's semantic-convention name for the same
94
+ * thing. Either must hold the route PATTERN ("/products/:id"), never the
95
+ * concrete URL - one series per product page is a cardinality problem.
96
+ */
97
+ export const WebVitalRouteAttributeKeys: Array<string> = [
98
+ "app.route",
99
+ "url.template",
100
+ ];
101
+
89
102
  // A threshold the way a person would say it: "2.5 s", "800 ms", "0.1".
90
103
  export function formatWebVitalThreshold(
91
104
  value: number,