@oneuptime/common 11.5.5 → 11.5.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 (38) hide show
  1. package/Models/DatabaseModels/User.ts +47 -0
  2. package/Server/Infrastructure/Postgres/SchemaMigrations/1784048917994-AddCreatedByUserToUser.ts +23 -0
  3. package/Server/Infrastructure/Postgres/SchemaMigrations/Index.ts +2 -0
  4. package/Server/Middleware/MasterAdminAuthorization.ts +37 -0
  5. package/Server/Middleware/ProjectAuthorization.ts +40 -15
  6. package/Server/Services/TeamMemberService.ts +2 -0
  7. package/Server/Services/UserService.ts +10 -0
  8. package/Server/Utils/Monitor/MonitorCriteriaExpectationBuilder.ts +16 -6
  9. package/Server/Utils/Monitor/MonitorCriteriaMessageBuilder.ts +14 -0
  10. package/Server/Utils/Monitor/MonitorCriteriaMessageFormatter.ts +14 -3
  11. package/Server/Utils/Monitor/MonitorCriteriaObservationBuilder.ts +130 -12
  12. package/Tests/Server/Utils/Monitor/MonitorCriteriaExpectationBuilderUnits.test.ts +553 -0
  13. package/Tests/Server/Utils/Monitor/MonitorCriteriaMessageBuilderUnits.test.ts +590 -0
  14. package/Tests/Server/Utils/Monitor/MonitorCriteriaMessageFormatterUnits.test.ts +254 -0
  15. package/Tests/Server/Utils/Monitor/MonitorCriteriaObservationBuilderUnits.test.ts +821 -0
  16. package/build/dist/Models/DatabaseModels/User.js +46 -0
  17. package/build/dist/Models/DatabaseModels/User.js.map +1 -1
  18. package/build/dist/Server/Infrastructure/Postgres/SchemaMigrations/1784048917994-AddCreatedByUserToUser.js +18 -0
  19. package/build/dist/Server/Infrastructure/Postgres/SchemaMigrations/1784048917994-AddCreatedByUserToUser.js.map +1 -0
  20. package/build/dist/Server/Infrastructure/Postgres/SchemaMigrations/Index.js +2 -0
  21. package/build/dist/Server/Infrastructure/Postgres/SchemaMigrations/Index.js.map +1 -1
  22. package/build/dist/Server/Middleware/MasterAdminAuthorization.js +26 -0
  23. package/build/dist/Server/Middleware/MasterAdminAuthorization.js.map +1 -1
  24. package/build/dist/Server/Middleware/ProjectAuthorization.js +40 -14
  25. package/build/dist/Server/Middleware/ProjectAuthorization.js.map +1 -1
  26. package/build/dist/Server/Services/TeamMemberService.js +2 -0
  27. package/build/dist/Server/Services/TeamMemberService.js.map +1 -1
  28. package/build/dist/Server/Services/UserService.js +8 -0
  29. package/build/dist/Server/Services/UserService.js.map +1 -1
  30. package/build/dist/Server/Utils/Monitor/MonitorCriteriaExpectationBuilder.js +15 -7
  31. package/build/dist/Server/Utils/Monitor/MonitorCriteriaExpectationBuilder.js.map +1 -1
  32. package/build/dist/Server/Utils/Monitor/MonitorCriteriaMessageBuilder.js +12 -1
  33. package/build/dist/Server/Utils/Monitor/MonitorCriteriaMessageBuilder.js.map +1 -1
  34. package/build/dist/Server/Utils/Monitor/MonitorCriteriaMessageFormatter.js +8 -3
  35. package/build/dist/Server/Utils/Monitor/MonitorCriteriaMessageFormatter.js.map +1 -1
  36. package/build/dist/Server/Utils/Monitor/MonitorCriteriaObservationBuilder.js +96 -12
  37. package/build/dist/Server/Utils/Monitor/MonitorCriteriaObservationBuilder.js.map +1 -1
  38. package/package.json +1 -1
@@ -647,6 +647,53 @@ class User extends UserModel {
647
647
  })
648
648
  public tempAlertPhoneNumber?: Phone = undefined;
649
649
 
650
+ @ColumnAccessControl({
651
+ create: [],
652
+ read: [],
653
+ update: [],
654
+ })
655
+ @TableColumn({
656
+ manyToOneRelationColumn: "createdByUserId",
657
+ type: TableColumnType.Entity,
658
+ title: "Created by User",
659
+ modelType: User,
660
+ description:
661
+ "Relation to the User who created (invited) this user, if this user was invited to OneUptime by another user.",
662
+ })
663
+ @ManyToOne(
664
+ () => {
665
+ return User;
666
+ },
667
+ {
668
+ cascade: false,
669
+ eager: false,
670
+ nullable: true,
671
+ onDelete: "SET NULL",
672
+ orphanedRowAction: "nullify",
673
+ },
674
+ )
675
+ @JoinColumn({ name: "createdByUserId" })
676
+ public createdByUser?: User = undefined;
677
+
678
+ @ColumnAccessControl({
679
+ create: [],
680
+ read: [],
681
+ update: [],
682
+ })
683
+ @TableColumn({
684
+ type: TableColumnType.ObjectID,
685
+ title: "Created by User ID",
686
+ description:
687
+ "User ID who created (invited) this user, if this user was invited to OneUptime by another user.",
688
+ example: "b2c3d4e5-f6a7-8901-bcde-f12345678901",
689
+ })
690
+ @Column({
691
+ type: ColumnType.ObjectID,
692
+ nullable: true,
693
+ transformer: ObjectID.getDatabaseTransformer(),
694
+ })
695
+ public createdByUserId?: ObjectID = undefined;
696
+
650
697
  @ColumnAccessControl({
651
698
  create: [],
652
699
  read: [],
@@ -0,0 +1,23 @@
1
+ import { MigrationInterface, QueryRunner } from "typeorm";
2
+
3
+ export class AddCreatedByUserToUser1784048917994 implements MigrationInterface {
4
+ public name: string = "AddCreatedByUserToUser1784048917994";
5
+
6
+ public async up(queryRunner: QueryRunner): Promise<void> {
7
+ /*
8
+ * Track who created (invited) a user, when they were invited by another user.
9
+ * Existing users are left NULL; this is only populated going forward.
10
+ */
11
+ await queryRunner.query(`ALTER TABLE "User" ADD "createdByUserId" uuid`);
12
+ await queryRunner.query(
13
+ `ALTER TABLE "User" ADD CONSTRAINT "FK_cd94f8dd722e4d9e890b68ea262" FOREIGN KEY ("createdByUserId") REFERENCES "User"("_id") ON DELETE SET NULL ON UPDATE NO ACTION`,
14
+ );
15
+ }
16
+
17
+ public async down(queryRunner: QueryRunner): Promise<void> {
18
+ await queryRunner.query(
19
+ `ALTER TABLE "User" DROP CONSTRAINT "FK_cd94f8dd722e4d9e890b68ea262"`,
20
+ );
21
+ await queryRunner.query(`ALTER TABLE "User" DROP COLUMN "createdByUserId"`);
22
+ }
23
+ }
@@ -449,6 +449,7 @@ import { AddSentinelInsight1784010274993 } from "./1784010274993-AddSentinelInsi
449
449
  import { AddSentinelInsightFlags1784010274994 } from "./1784010274994-AddSentinelInsightFlags";
450
450
  import { RenameSentinelToAI1784030612266 } from "./1784030612266-RenameSentinelToAI";
451
451
  import { MigrationName1784033837629 } from "./1784033837629-MigrationName";
452
+ import { AddCreatedByUserToUser1784048917994 } from "./1784048917994-AddCreatedByUserToUser";
452
453
 
453
454
  export default [
454
455
  InitialMigration,
@@ -902,4 +903,5 @@ export default [
902
903
  AddSentinelInsightFlags1784010274994,
903
904
  RenameSentinelToAI1784030612266,
904
905
  MigrationName1784033837629,
906
+ AddCreatedByUserToUser1784048917994,
905
907
  ];
@@ -1,4 +1,5 @@
1
1
  import UserMiddleware from "./UserAuthorization";
2
+ import ProjectMiddleware from "./ProjectAuthorization";
2
3
  import JSONWebToken from "../Utils/JsonWebToken";
3
4
  import Response from "../Utils/Response";
4
5
  import {
@@ -8,6 +9,7 @@ import {
8
9
  } from "../Utils/Express";
9
10
  import NotAuthorizedException from "../../Types/Exception/NotAuthorizedException";
10
11
  import JSONWebTokenData from "../../Types/JsonWebTokenData";
12
+ import ObjectID from "../../Types/ObjectID";
11
13
 
12
14
  export default class MasterAdminAuthorization {
13
15
  public static async isAuthorizedMasterAdminMiddleware(
@@ -52,4 +54,39 @@ export default class MasterAdminAuthorization {
52
54
  );
53
55
  }
54
56
  }
57
+
58
+ /*
59
+ * Same as isAuthorizedMasterAdminMiddleware, but ALSO accepts the instance-wide
60
+ * master API key (Admin Dashboard → Settings → API Key) supplied in the
61
+ * `apikey` header. The master key has root/master-admin access, so this lets
62
+ * automated callers reach the master-admin instance-health endpoints with the
63
+ * key instead of a logged-in master-admin session.
64
+ *
65
+ * Deliberately scoped to the read-only health / diagnostics routes only. It is
66
+ * NOT used on higher-risk master-admin actions (e.g. the read/write query
67
+ * console or broadcast email), which stay on the JWT-only middleware above so
68
+ * that a leaked static key cannot trigger them headlessly.
69
+ */
70
+ public static async isAuthorizedMasterAdminOrMasterApiKeyMiddleware(
71
+ req: ExpressRequest,
72
+ res: ExpressResponse,
73
+ next: NextFunction,
74
+ ): Promise<void> {
75
+ try {
76
+ const apiKey: ObjectID | null = ProjectMiddleware.getApiKey(req);
77
+
78
+ if (apiKey && (await ProjectMiddleware.isMasterApiKey(apiKey))) {
79
+ next();
80
+ return;
81
+ }
82
+ } catch {
83
+ // Fall through to the master-admin session (JWT) check below.
84
+ }
85
+
86
+ return MasterAdminAuthorization.isAuthorizedMasterAdminMiddleware(
87
+ req,
88
+ res,
89
+ next,
90
+ );
91
+ }
55
92
  }
@@ -60,6 +60,43 @@ export default class ProjectMiddleware {
60
60
  return Boolean(this.getProjectId(req));
61
61
  }
62
62
 
63
+ /*
64
+ * Whether the given key is the instance-wide master API key
65
+ * (Admin Dashboard → Settings → API Key) and that key is currently enabled.
66
+ * The master key has root/master-admin access, so this is shared with
67
+ * MasterAdminAuthorization to let the key reach master-admin-only endpoints
68
+ * (e.g. instance health) as well.
69
+ */
70
+ @CaptureSpan()
71
+ public static async isMasterApiKey(apiKey: ObjectID): Promise<boolean> {
72
+ /*
73
+ * masterApiKey is a Postgres `uuid` column, so a non-UUID header value would
74
+ * make the lookup raise an "invalid input syntax for type uuid" error on
75
+ * every request. Reject it cleanly up front — a malformed key is never the
76
+ * master key anyway — so callers can safely fall through to other auth.
77
+ */
78
+ if (!ObjectID.isValidUUID(apiKey.toString())) {
79
+ return false;
80
+ }
81
+
82
+ const masterKeyGlobalConfig: GlobalConfig | null =
83
+ await GlobalConfigService.findOneBy({
84
+ query: {
85
+ _id: ObjectID.getZeroObjectID().toString(),
86
+ isMasterApiKeyEnabled: true,
87
+ masterApiKey: apiKey,
88
+ },
89
+ props: {
90
+ isRoot: true,
91
+ },
92
+ select: {
93
+ _id: true,
94
+ },
95
+ });
96
+
97
+ return Boolean(masterKeyGlobalConfig);
98
+ }
99
+
63
100
  @CaptureSpan()
64
101
  public static async isValidProjectIdAndApiKeyMiddleware(
65
102
  req: ExpressRequest,
@@ -124,22 +161,10 @@ export default class ProjectMiddleware {
124
161
 
125
162
  if (!apiKeyRow) {
126
163
  // check master key.
127
- const masterKeyGlobalConfig: GlobalConfig | null =
128
- await GlobalConfigService.findOneBy({
129
- query: {
130
- _id: ObjectID.getZeroObjectID().toString(),
131
- isMasterApiKeyEnabled: true,
132
- masterApiKey: apiKey,
133
- },
134
- props: {
135
- isRoot: true,
136
- },
137
- select: {
138
- _id: true,
139
- },
140
- });
164
+ const isMasterApiKey: boolean =
165
+ await ProjectMiddleware.isMasterApiKey(apiKey);
141
166
 
142
- if (masterKeyGlobalConfig) {
167
+ if (isMasterApiKey) {
143
168
  (req as OneUptimeRequest).userType = UserType.MasterAdmin;
144
169
 
145
170
  // get master admin user
@@ -147,6 +147,8 @@ export class TeamMemberService extends DatabaseService<TeamMember> {
147
147
  user = await UserService.createByEmail({
148
148
  email,
149
149
  name: nameValue ? new Name(nameValue) : undefined,
150
+ // Record who invited this brand-new user, so it can be surfaced later.
151
+ createdByUserId: createBy.props.userId,
150
152
  props: {
151
153
  isRoot: true,
152
154
  },
@@ -446,6 +446,7 @@ export class Service extends DatabaseService<Model> {
446
446
  name: Name | undefined;
447
447
  isEmailVerified?: boolean;
448
448
  generateRandomPassword?: boolean;
449
+ createdByUserId?: ObjectID | undefined;
449
450
  props: DatabaseCommonInteractionProps;
450
451
  }): Promise<Model> {
451
452
  const { email, props } = data;
@@ -457,6 +458,15 @@ export class Service extends DatabaseService<Model> {
457
458
  }
458
459
  user.isEmailVerified = data.isEmailVerified || false;
459
460
 
461
+ /*
462
+ * Record who created this user, when they were created on behalf of
463
+ * another user (for example, when invited to a project by a team member).
464
+ * This lets the admin dashboard show who invited a given user.
465
+ */
466
+ if (data.createdByUserId) {
467
+ user.createdByUserId = data.createdByUserId;
468
+ }
469
+
460
470
  if (data.generateRandomPassword) {
461
471
  user.password = new HashedString(Text.generateRandomText(20));
462
472
  }
@@ -22,6 +22,7 @@ export default class MonitorCriteriaExpectationBuilder {
22
22
 
23
23
  public static describeCriteriaExpectation(
24
24
  criteriaFilter: CriteriaFilter,
25
+ options?: { unit?: string | undefined },
25
26
  ): string | null {
26
27
  if (!criteriaFilter.filterType) {
27
28
  return null;
@@ -31,24 +32,33 @@ export default class MonitorCriteriaExpectationBuilder {
31
32
 
32
33
  const value: string | number | undefined = criteriaFilter.value;
33
34
 
35
+ /*
36
+ * Suffix numeric-threshold comparisons with the metric's display unit
37
+ * (e.g. "greater than 5 sec") so the threshold reads in the same unit
38
+ * as the observed value. Only the value-comparison filter types below
39
+ * carry a numeric threshold — the others (empty, boolean, heartbeat
40
+ * windows, …) supply their own wording and get no suffix.
41
+ */
42
+ const unitSuffix: string = options?.unit ? ` ${options.unit}` : "";
43
+
34
44
  switch (criteriaFilter.filterType) {
35
45
  case FilterType.GreaterThan:
36
- expectation = `to be greater than ${value}`;
46
+ expectation = `to be greater than ${value}${unitSuffix}`;
37
47
  break;
38
48
  case FilterType.GreaterThanOrEqualTo:
39
- expectation = `to be greater than or equal to ${value}`;
49
+ expectation = `to be greater than or equal to ${value}${unitSuffix}`;
40
50
  break;
41
51
  case FilterType.LessThan:
42
- expectation = `to be less than ${value}`;
52
+ expectation = `to be less than ${value}${unitSuffix}`;
43
53
  break;
44
54
  case FilterType.LessThanOrEqualTo:
45
- expectation = `to be less than or equal to ${value}`;
55
+ expectation = `to be less than or equal to ${value}${unitSuffix}`;
46
56
  break;
47
57
  case FilterType.EqualTo:
48
- expectation = `to equal ${value}`;
58
+ expectation = `to equal ${value}${unitSuffix}`;
49
59
  break;
50
60
  case FilterType.NotEqualTo:
51
- expectation = `to not equal ${value}`;
61
+ expectation = `to not equal ${value}${unitSuffix}`;
52
62
  break;
53
63
  case FilterType.Contains:
54
64
  expectation = `to contain ${value}`;
@@ -53,9 +53,23 @@ export default class MonitorCriteriaMessageBuilder {
53
53
  dataToProcess: DataToProcess;
54
54
  monitorStep: MonitorStep;
55
55
  }): string | null {
56
+ /*
57
+ * Resolve the metric's display unit (undefined for non-metric criteria)
58
+ * so the threshold in the expectation clause reads in the same unit as
59
+ * the observed value — "recorded latest 0.06 sec (expected to be greater
60
+ * than 5 sec)" rather than the unitless "0.06 (expected ... 5)".
61
+ */
62
+ const metricDisplayUnit: string | undefined =
63
+ MonitorCriteriaObservationBuilder.getMetricValueDisplayUnit({
64
+ criteriaFilter: input.criteriaFilter,
65
+ dataToProcess: input.dataToProcess,
66
+ monitorStep: input.monitorStep,
67
+ });
68
+
56
69
  const expectation: string | null =
57
70
  MonitorCriteriaExpectationBuilder.describeCriteriaExpectation(
58
71
  input.criteriaFilter,
72
+ { unit: metricDisplayUnit },
59
73
  );
60
74
 
61
75
  const observation: string | null =
@@ -136,7 +136,10 @@ export default class MonitorCriteriaMessageFormatter {
136
136
  return null;
137
137
  }
138
138
 
139
- public static summarizeNumericSeries(values: Array<number>): string | null {
139
+ public static summarizeNumericSeries(
140
+ values: Array<number>,
141
+ unit?: string | undefined,
142
+ ): string | null {
140
143
  if (!values.length) {
141
144
  return null;
142
145
  }
@@ -147,12 +150,18 @@ export default class MonitorCriteriaMessageFormatter {
147
150
  return null;
148
151
  }
149
152
 
153
+ /*
154
+ * Suffix each value with its unit (e.g. "0.06 sec") so the reader knows
155
+ * what the numbers mean. Empty when the metric has no known unit.
156
+ */
157
+ const unitSuffix: string = unit ? ` ${unit}` : "";
158
+
150
159
  const latestFormatted: string | null =
151
160
  MonitorCriteriaMessageFormatter.formatNumber(latest, {
152
161
  maximumFractionDigits: 2,
153
162
  });
154
163
 
155
- let summary: string = `latest ${latestFormatted ?? latest}`;
164
+ let summary: string = `latest ${latestFormatted ?? latest}${unitSuffix}`;
156
165
 
157
166
  if (values.length > 1) {
158
167
  const min: number = Math.min(...values);
@@ -167,7 +176,9 @@ export default class MonitorCriteriaMessageFormatter {
167
176
  maximumFractionDigits: 2,
168
177
  });
169
178
 
170
- summary += ` (min ${minFormatted ?? min}, max ${maxFormatted ?? max})`;
179
+ summary += ` (min ${minFormatted ?? min}${unitSuffix}, max ${
180
+ maxFormatted ?? max
181
+ }${unitSuffix})`;
171
182
  }
172
183
 
173
184
  summary += ` across ${values.length} data point${
@@ -1169,8 +1169,25 @@ export default class MonitorCriteriaObservationBuilder {
1169
1169
  alias: metricValues.alias,
1170
1170
  });
1171
1171
 
1172
+ /*
1173
+ * The display values are expressed in the threshold's unit (or the
1174
+ * metric's native/legend unit when no threshold unit was set). Surface
1175
+ * that unit alongside the numbers so the message reads "latest 0.06 sec"
1176
+ * instead of the unitless "latest 0.06".
1177
+ */
1178
+ const displayUnit: string | undefined =
1179
+ MonitorCriteriaObservationBuilder.resolveMetricUnits({
1180
+ criteriaFilter: input.criteriaFilter,
1181
+ dataToProcess: input.dataToProcess,
1182
+ monitorStep: input.monitorStep,
1183
+ alias: metricValues.alias,
1184
+ }).displayUnit;
1185
+
1172
1186
  const summary: string | null =
1173
- MonitorCriteriaMessageFormatter.summarizeNumericSeries(displayValues);
1187
+ MonitorCriteriaMessageFormatter.summarizeNumericSeries(
1188
+ displayValues,
1189
+ displayUnit,
1190
+ );
1174
1191
 
1175
1192
  if (!summary) {
1176
1193
  return null;
@@ -1195,18 +1212,67 @@ export default class MonitorCriteriaObservationBuilder {
1195
1212
  monitorStep: MonitorStep;
1196
1213
  alias: string | null;
1197
1214
  }): Array<number> {
1198
- const thresholdUnit: string | undefined =
1199
- input.criteriaFilter.metricMonitorOptions?.thresholdUnit;
1215
+ const { sampleUnit, thresholdUnit } =
1216
+ MonitorCriteriaObservationBuilder.resolveMetricUnits({
1217
+ criteriaFilter: input.criteriaFilter,
1218
+ dataToProcess: input.dataToProcess,
1219
+ monitorStep: input.monitorStep,
1220
+ alias: input.alias,
1221
+ });
1222
+
1200
1223
  if (!thresholdUnit) {
1201
1224
  return input.values;
1202
1225
  }
1203
1226
 
1227
+ if (!sampleUnit || sampleUnit === thresholdUnit) {
1228
+ return input.values;
1229
+ }
1230
+
1231
+ return input.values.map((v: number) => {
1232
+ return MetricUnitUtil.convertToMetricUnit({
1233
+ value: v,
1234
+ fromUnit: sampleUnit,
1235
+ metricUnit: thresholdUnit,
1236
+ });
1237
+ });
1238
+ }
1239
+
1240
+ /*
1241
+ * Resolve the units involved in a metric-value observation:
1242
+ * - sampleUnit: the unit the raw samples arrive in (legendUnit, or
1243
+ * the metric's native unit when no legend was set).
1244
+ * - thresholdUnit: the unit the user entered the threshold in, if any.
1245
+ * - displayUnit: the unit the rendered numbers (samples + threshold)
1246
+ * are actually expressed in. This mirrors the
1247
+ * MetricMonitorCriteria evaluator: the threshold unit
1248
+ * wins when present (samples are converted into it),
1249
+ * otherwise the samples stay in their native unit.
1250
+ * Every field is best-effort — undefined when the metric response or
1251
+ * config isn't available. Never throws.
1252
+ */
1253
+ private static resolveMetricUnits(input: {
1254
+ criteriaFilter: CriteriaFilter;
1255
+ dataToProcess: DataToProcess;
1256
+ monitorStep: MonitorStep;
1257
+ alias: string | null;
1258
+ }): {
1259
+ sampleUnit: string | undefined;
1260
+ thresholdUnit: string | undefined;
1261
+ displayUnit: string | undefined;
1262
+ } {
1263
+ const thresholdUnit: string | undefined =
1264
+ input.criteriaFilter.metricMonitorOptions?.thresholdUnit || undefined;
1265
+
1204
1266
  const metricResponse: MetricMonitorResponse | null =
1205
1267
  MonitorCriteriaDataExtractor.getMetricMonitorResponse(
1206
1268
  input.dataToProcess,
1207
1269
  );
1208
1270
  if (!metricResponse) {
1209
- return input.values;
1271
+ return {
1272
+ sampleUnit: undefined,
1273
+ thresholdUnit,
1274
+ displayUnit: thresholdUnit,
1275
+ };
1210
1276
  }
1211
1277
 
1212
1278
  const metricViewConfig: MetricsViewConfig | undefined =
@@ -1244,17 +1310,69 @@ export default class MonitorCriteriaObservationBuilder {
1244
1310
  nativeUnitFromMap ||
1245
1311
  undefined;
1246
1312
 
1247
- if (!sampleUnit || sampleUnit === thresholdUnit) {
1248
- return input.values;
1313
+ return {
1314
+ sampleUnit,
1315
+ thresholdUnit,
1316
+ displayUnit: MonitorCriteriaObservationBuilder.normalizeDisplayUnit(
1317
+ thresholdUnit || sampleUnit,
1318
+ ),
1319
+ };
1320
+ }
1321
+
1322
+ /*
1323
+ * Suppress units that would read as noise next to a raw number. OTel's
1324
+ * dimensionless "1" marks ratio metrics whose samples are fractions in
1325
+ * [0, 1]; rendering "0.06 1" is both ugly and misleading (it is not 1% —
1326
+ * it is 6%). Returning undefined leaves the number unlabelled, matching
1327
+ * the pre-unit behavior for that specific case. Any real unit passes
1328
+ * through unchanged.
1329
+ */
1330
+ private static normalizeDisplayUnit(
1331
+ unit: string | undefined,
1332
+ ): string | undefined {
1333
+ if (!unit || !unit.trim()) {
1334
+ return undefined;
1249
1335
  }
1250
1336
 
1251
- return input.values.map((v: number) => {
1252
- return MetricUnitUtil.convertToMetricUnit({
1253
- value: v,
1254
- fromUnit: sampleUnit,
1255
- metricUnit: thresholdUnit,
1256
- });
1337
+ if (unit.trim() === "1") {
1338
+ return undefined;
1339
+ }
1340
+
1341
+ return unit;
1342
+ }
1343
+
1344
+ /*
1345
+ * Public accessor for the unit that a metric-value criteria's observed
1346
+ * samples and threshold are displayed in. Returns undefined for
1347
+ * non-metric criteria or when no unit can be resolved, so callers can
1348
+ * safely append it as a suffix. Used by the message builder to label the
1349
+ * threshold in the "expected to be greater than 5 sec" clause so it
1350
+ * matches the "recorded latest 0.06 sec" observation.
1351
+ */
1352
+ public static getMetricValueDisplayUnit(input: {
1353
+ criteriaFilter: CriteriaFilter;
1354
+ dataToProcess: DataToProcess;
1355
+ monitorStep: MonitorStep;
1356
+ }): string | undefined {
1357
+ if (input.criteriaFilter.checkOn !== CheckOn.MetricValue) {
1358
+ return undefined;
1359
+ }
1360
+
1361
+ const metricValues: {
1362
+ alias: string | null;
1363
+ values: Array<number>;
1364
+ } | null = MonitorCriteriaDataExtractor.extractMetricValues({
1365
+ criteriaFilter: input.criteriaFilter,
1366
+ dataToProcess: input.dataToProcess,
1367
+ monitorStep: input.monitorStep,
1257
1368
  });
1369
+
1370
+ return MonitorCriteriaObservationBuilder.resolveMetricUnits({
1371
+ criteriaFilter: input.criteriaFilter,
1372
+ dataToProcess: input.dataToProcess,
1373
+ monitorStep: input.monitorStep,
1374
+ alias: metricValues?.alias ?? null,
1375
+ }).displayUnit;
1258
1376
  }
1259
1377
 
1260
1378
  private static getSnmpResponse(input: {