@oneuptime/common 11.6.2 → 11.7.1
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.
- package/Models/AnalyticsModels/SloHistory.ts +6 -3
- package/Models/DatabaseModels/ServiceLevelObjective.ts +9 -0
- package/Server/Infrastructure/Postgres/SchemaMigrations/1785066759532-MigrationName.ts +30 -0
- package/Server/Infrastructure/Postgres/SchemaMigrations/Index.ts +2 -0
- package/Server/Services/ServiceLevelObjectiveService.ts +12 -4
- package/Tests/Server/Services/ServiceLevelObjectiveService.test.ts +2 -2
- package/Tests/Types/DateUserTimezone.test.ts +205 -0
- package/Tests/Types/DateUserTimezoneOverridesBrowser.test.ts +73 -0
- package/Tests/Types/Monitor/MonitorCriteria.test.ts +155 -0
- package/Tests/Types/Monitor/MonitorCriteriaInstance.test.ts +465 -0
- package/Tests/Types/Monitor/MonitorStep.test.ts +183 -0
- package/Tests/Types/Monitor/MonitorSteps.test.ts +183 -0
- package/Tests/UI/Components/TimePicker/TimePicker.test.tsx +11 -0
- package/Tests/Utils/Memory.test.ts +44 -0
- package/Tests/Utils/Slo/SloBurnRateRuleState.test.ts +124 -0
- package/Tests/Utils/Slo/SloDuration.test.ts +148 -0
- package/Tests/Utils/Slo/SloHealth.test.ts +334 -0
- package/Types/Date.ts +119 -50
- package/UI/Components/Forms/Fields/FormField.tsx +13 -3
- package/UI/Components/Input/Input.tsx +6 -1
- package/UI/Components/TimePicker/TimePicker.tsx +8 -4
- package/UI/Utils/User.ts +19 -0
- package/Utils/Slo/SloBurnRateRuleState.ts +91 -0
- package/Utils/Slo/SloDuration.ts +165 -0
- package/Utils/Slo/SloEvaluation.ts +22 -0
- package/Utils/Slo/SloHealth.ts +302 -0
- package/build/dist/Models/AnalyticsModels/SloHistory.js +6 -3
- package/build/dist/Models/AnalyticsModels/SloHistory.js.map +1 -1
- package/build/dist/Models/DatabaseModels/ServiceLevelObjective.js +11 -1
- package/build/dist/Models/DatabaseModels/ServiceLevelObjective.js.map +1 -1
- package/build/dist/Server/Infrastructure/Postgres/SchemaMigrations/1785066759532-MigrationName.js +21 -0
- package/build/dist/Server/Infrastructure/Postgres/SchemaMigrations/1785066759532-MigrationName.js.map +1 -0
- package/build/dist/Server/Infrastructure/Postgres/SchemaMigrations/Index.js +2 -0
- package/build/dist/Server/Infrastructure/Postgres/SchemaMigrations/Index.js.map +1 -1
- package/build/dist/Server/Services/ServiceLevelObjectiveService.js +10 -4
- package/build/dist/Server/Services/ServiceLevelObjectiveService.js.map +1 -1
- package/build/dist/Types/Date.js +106 -35
- package/build/dist/Types/Date.js.map +1 -1
- package/build/dist/UI/Components/Forms/Fields/FormField.js +11 -3
- package/build/dist/UI/Components/Forms/Fields/FormField.js.map +1 -1
- package/build/dist/UI/Components/Input/Input.js +6 -1
- package/build/dist/UI/Components/Input/Input.js.map +1 -1
- package/build/dist/UI/Components/TimePicker/TimePicker.js +4 -4
- package/build/dist/UI/Components/TimePicker/TimePicker.js.map +1 -1
- package/build/dist/UI/Utils/User.js +19 -0
- package/build/dist/UI/Utils/User.js.map +1 -1
- package/build/dist/Utils/Slo/SloBurnRateRuleState.js +35 -0
- package/build/dist/Utils/Slo/SloBurnRateRuleState.js.map +1 -0
- package/build/dist/Utils/Slo/SloDuration.js +87 -0
- package/build/dist/Utils/Slo/SloDuration.js.map +1 -0
- package/build/dist/Utils/Slo/SloEvaluation.js +21 -0
- package/build/dist/Utils/Slo/SloEvaluation.js.map +1 -0
- package/build/dist/Utils/Slo/SloHealth.js +179 -0
- package/build/dist/Utils/Slo/SloHealth.js.map +1 -0
- package/package.json +1 -1
- package/Server/Infrastructure/Postgres/SchemaMigrations/1773414578773-MigrationName.ts +0 -79
- package/Server/Infrastructure/Postgres/SchemaMigrations/1773500000000-MigrationName.ts +0 -41
- package/Server/Infrastructure/Postgres/SchemaMigrations/1776886248361-MigrationName.ts +0 -17
- package/build/dist/Server/Infrastructure/Postgres/SchemaMigrations/1773414578773-MigrationName.js +0 -34
- package/build/dist/Server/Infrastructure/Postgres/SchemaMigrations/1773414578773-MigrationName.js.map +0 -1
- package/build/dist/Server/Infrastructure/Postgres/SchemaMigrations/1773500000000-MigrationName.js +0 -22
- package/build/dist/Server/Infrastructure/Postgres/SchemaMigrations/1773500000000-MigrationName.js.map +0 -1
- package/build/dist/Server/Infrastructure/Postgres/SchemaMigrations/1776886248361-MigrationName.js +0 -12
- package/build/dist/Server/Infrastructure/Postgres/SchemaMigrations/1776886248361-MigrationName.js.map +0 -1
|
@@ -42,8 +42,11 @@ import Permission from "../../Types/Permission";
|
|
|
42
42
|
* dashboard charts do) or dedupe explicitly via
|
|
43
43
|
* `argMax(value, version) ... GROUP BY <identity>`.
|
|
44
44
|
*
|
|
45
|
-
* `metricName` values: "sli.percent",
|
|
46
|
-
* "
|
|
45
|
+
* `metricName` values written today: "sli.percent",
|
|
46
|
+
* "error.budget.remaining.percent" and "burn.rate" — the three gauges the
|
|
47
|
+
* evaluation worker persists per run. "good.count" / "total.count" are
|
|
48
|
+
* reserved for event-based (Metric) SLIs, which the worker does not
|
|
49
|
+
* evaluate yet; nothing writes or reads them.
|
|
47
50
|
*/
|
|
48
51
|
export default class SloHistory extends AnalyticsBaseModel {
|
|
49
52
|
public constructor() {
|
|
@@ -87,7 +90,7 @@ export default class SloHistory extends AnalyticsBaseModel {
|
|
|
87
90
|
isLowCardinality: true,
|
|
88
91
|
title: "Metric Name",
|
|
89
92
|
description:
|
|
90
|
-
"Which SLO series this row belongs to: sli.percent, error.budget.remaining.percent, burn.rate
|
|
93
|
+
"Which SLO series this row belongs to: sli.percent, error.budget.remaining.percent, or burn.rate.",
|
|
91
94
|
required: true,
|
|
92
95
|
type: TableColumnType.Text,
|
|
93
96
|
accessControl: {
|
|
@@ -12,6 +12,7 @@ import AccessControlColumn from "../../Types/Database/AccessControlColumn";
|
|
|
12
12
|
import ColumnLength from "../../Types/Database/ColumnLength";
|
|
13
13
|
import ColumnType from "../../Types/Database/ColumnType";
|
|
14
14
|
import CrudApiEndpoint from "../../Types/Database/CrudApiEndpoint";
|
|
15
|
+
import EnableAuditLog from "../../Types/Database/EnableAuditLog";
|
|
15
16
|
import EnableDocumentation from "../../Types/Database/EnableDocumentation";
|
|
16
17
|
import EnableWorkflow from "../../Types/Database/EnableWorkflow";
|
|
17
18
|
import SlugifyColumn from "../../Types/Database/SlugifyColumn";
|
|
@@ -72,6 +73,14 @@ const decimalTransformer: ValueTransformer = {
|
|
|
72
73
|
* an owner of the SLO they just created).
|
|
73
74
|
*/
|
|
74
75
|
@OperationalResource()
|
|
76
|
+
/*
|
|
77
|
+
* An SLO's target, window and monitor set are the definition of what
|
|
78
|
+
* "reliable enough" means for a service, so a change to any of them
|
|
79
|
+
* silently rewrites history: budgets recompute against the new
|
|
80
|
+
* definition on the worker's next tick. Recording those edits is how a
|
|
81
|
+
* team answers "did the SLO get better, or did someone move the target?".
|
|
82
|
+
*/
|
|
83
|
+
@EnableAuditLog()
|
|
75
84
|
@EnableDocumentation()
|
|
76
85
|
@AccessControlColumn("labels")
|
|
77
86
|
@TenantColumn("projectId")
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
import { MigrationInterface, QueryRunner } from "typeorm";
|
|
2
|
+
|
|
3
|
+
export class MigrationName1785066759532 implements MigrationInterface {
|
|
4
|
+
public name = "MigrationName1785066759532";
|
|
5
|
+
|
|
6
|
+
public async up(queryRunner: QueryRunner): Promise<void> {
|
|
7
|
+
/*
|
|
8
|
+
* 1784200000000 added these two as `text`, but the entity declares them
|
|
9
|
+
* ColumnType.LongText, which maps to `varchar`. The generator wants to
|
|
10
|
+
* DROP and re-ADD the columns; an in-place TYPE change reaches the same
|
|
11
|
+
* schema without discarding the SNMP v3 credentials already stored on
|
|
12
|
+
* existing discovery scans.
|
|
13
|
+
*/
|
|
14
|
+
await queryRunner.query(
|
|
15
|
+
`ALTER TABLE "NetworkDeviceDiscoveryScan" ALTER COLUMN "snmpV3AuthKey" TYPE character varying`,
|
|
16
|
+
);
|
|
17
|
+
await queryRunner.query(
|
|
18
|
+
`ALTER TABLE "NetworkDeviceDiscoveryScan" ALTER COLUMN "snmpV3PrivKey" TYPE character varying`,
|
|
19
|
+
);
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
public async down(queryRunner: QueryRunner): Promise<void> {
|
|
23
|
+
await queryRunner.query(
|
|
24
|
+
`ALTER TABLE "NetworkDeviceDiscoveryScan" ALTER COLUMN "snmpV3PrivKey" TYPE text`,
|
|
25
|
+
);
|
|
26
|
+
await queryRunner.query(
|
|
27
|
+
`ALTER TABLE "NetworkDeviceDiscoveryScan" ALTER COLUMN "snmpV3AuthKey" TYPE text`,
|
|
28
|
+
);
|
|
29
|
+
}
|
|
30
|
+
}
|
|
@@ -469,6 +469,7 @@ import { AddNetworkSiteAlerting1784897317860 } from "./1784897317860-AddNetworkS
|
|
|
469
469
|
import { AddNetworkDevicePollingColumns1784970388777 } from "./1784970388777-AddNetworkDevicePollingColumns";
|
|
470
470
|
import { AddNetworkSiteTypeTable1784986826214 } from "./1784986826214-AddNetworkSiteTypeTable";
|
|
471
471
|
import { AddServiceLevelObjective1784987015619 } from "./1784987015619-AddServiceLevelObjective";
|
|
472
|
+
import { MigrationName1785066759532 } from "./1785066759532-MigrationName";
|
|
472
473
|
|
|
473
474
|
export default [
|
|
474
475
|
InitialMigration,
|
|
@@ -942,4 +943,5 @@ export default [
|
|
|
942
943
|
AddNetworkDevicePollingColumns1784970388777,
|
|
943
944
|
AddNetworkSiteTypeTable1784986826214,
|
|
944
945
|
AddServiceLevelObjective1784987015619,
|
|
946
|
+
MigrationName1785066759532,
|
|
945
947
|
];
|
|
@@ -650,8 +650,15 @@ export class Service extends DatabaseService<Model> {
|
|
|
650
650
|
private validateWindowDays(value: unknown): number {
|
|
651
651
|
const windowDays: number = this.normalizeNumericInput(value);
|
|
652
652
|
|
|
653
|
-
|
|
654
|
-
|
|
653
|
+
/*
|
|
654
|
+
* The column is a Postgres integer, so a decimal that cleared only the
|
|
655
|
+
* range check would fail the INSERT with a raw driver error instead of
|
|
656
|
+
* this message.
|
|
657
|
+
*/
|
|
658
|
+
if (!Number.isInteger(windowDays) || windowDays < 1 || windowDays > 366) {
|
|
659
|
+
throw new BadDataException(
|
|
660
|
+
"SLO window must be a whole number of days between 1 and 366.",
|
|
661
|
+
);
|
|
655
662
|
}
|
|
656
663
|
|
|
657
664
|
return windowDays;
|
|
@@ -660,13 +667,14 @@ export class Service extends DatabaseService<Model> {
|
|
|
660
667
|
private validateAtRiskThresholdPercentage(value: unknown): number {
|
|
661
668
|
const atRiskThresholdPercentage: number = this.normalizeNumericInput(value);
|
|
662
669
|
|
|
670
|
+
// Integer column — see validateWindowDays.
|
|
663
671
|
if (
|
|
664
|
-
!Number.
|
|
672
|
+
!Number.isInteger(atRiskThresholdPercentage) ||
|
|
665
673
|
atRiskThresholdPercentage < 0 ||
|
|
666
674
|
atRiskThresholdPercentage > 100
|
|
667
675
|
) {
|
|
668
676
|
throw new BadDataException(
|
|
669
|
-
"SLO at-risk threshold must be a percentage between 0 and 100.",
|
|
677
|
+
"SLO at-risk threshold must be a whole percentage between 0 and 100.",
|
|
670
678
|
);
|
|
671
679
|
}
|
|
672
680
|
|
|
@@ -62,9 +62,9 @@ const RESOLVED_STATE_ID: ObjectID = new ObjectID(
|
|
|
62
62
|
const TARGET_PERCENTAGE_ERROR_MESSAGE: string =
|
|
63
63
|
"SLO target must be greater than 0 and at most 99.999. A 100% target leaves no error budget.";
|
|
64
64
|
const WINDOW_DAYS_ERROR_MESSAGE: string =
|
|
65
|
-
"SLO window must be between 1 and 366
|
|
65
|
+
"SLO window must be a whole number of days between 1 and 366.";
|
|
66
66
|
const AT_RISK_THRESHOLD_ERROR_MESSAGE: string =
|
|
67
|
-
"SLO at-risk threshold must be a percentage between 0 and 100.";
|
|
67
|
+
"SLO at-risk threshold must be a whole percentage between 0 and 100.";
|
|
68
68
|
|
|
69
69
|
/*
|
|
70
70
|
* Every column getDueSlos must hand to the evaluation worker. Enumerated here
|
|
@@ -0,0 +1,205 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The user's profile timezone (User Settings > Timezone) — not the zone the
|
|
3
|
+
* browser reports — decides which wall clock OneUptimeDate reads and writes.
|
|
4
|
+
*
|
|
5
|
+
* The bug these lock in: a user in America/New_York whose browser reported
|
|
6
|
+
* America/Adak was told "your local timezone - HDT" on every date field, and
|
|
7
|
+
* had to enter Hawaii-Aleutian times for their events to land correctly. The
|
|
8
|
+
* profile timezone was stored but never consulted by the UI.
|
|
9
|
+
*
|
|
10
|
+
* Every assertion is expressed in explicit-zone wall-clock (or absolute
|
|
11
|
+
* milliseconds) so it holds under whatever TZ the suite runs in.
|
|
12
|
+
*/
|
|
13
|
+
import OneUptimeDate from "../../Types/Date";
|
|
14
|
+
import Timezone from "../../Types/Timezone";
|
|
15
|
+
import moment from "moment-timezone";
|
|
16
|
+
|
|
17
|
+
const NY: Timezone = Timezone.AmericaNew_York;
|
|
18
|
+
const ADAK: Timezone = Timezone.AmericaAdak;
|
|
19
|
+
const KOLKATA: Timezone = Timezone.AsiaKolkata;
|
|
20
|
+
|
|
21
|
+
describe("OneUptimeDate user timezone", () => {
|
|
22
|
+
afterEach(() => {
|
|
23
|
+
// Never leak an override into the rest of the suite.
|
|
24
|
+
OneUptimeDate.setUserTimezone(null);
|
|
25
|
+
});
|
|
26
|
+
|
|
27
|
+
describe("setUserTimezone / getCurrentTimezone", () => {
|
|
28
|
+
it("falls back to the browser / process zone when no user timezone is set", () => {
|
|
29
|
+
expect(OneUptimeDate.getUserTimezone()).toBeNull();
|
|
30
|
+
expect(OneUptimeDate.getCurrentTimezone().toString()).toBe(
|
|
31
|
+
moment.tz.guess(),
|
|
32
|
+
);
|
|
33
|
+
});
|
|
34
|
+
|
|
35
|
+
it("prefers the user timezone over the browser / process zone", () => {
|
|
36
|
+
OneUptimeDate.setUserTimezone(NY);
|
|
37
|
+
|
|
38
|
+
expect(OneUptimeDate.getCurrentTimezone()).toBe(NY);
|
|
39
|
+
expect(OneUptimeDate.getUserTimezone()).toBe(NY);
|
|
40
|
+
});
|
|
41
|
+
|
|
42
|
+
it("reports the abbreviation of the user timezone, DST aware", () => {
|
|
43
|
+
OneUptimeDate.setUserTimezone(NY);
|
|
44
|
+
|
|
45
|
+
// The bug reported "HDT" (America/Adak) for a New York user.
|
|
46
|
+
expect(OneUptimeDate.getCurrentTimezoneString()).not.toBe("HDT");
|
|
47
|
+
expect(["EST", "EDT"]).toContain(
|
|
48
|
+
OneUptimeDate.getCurrentTimezoneString(),
|
|
49
|
+
);
|
|
50
|
+
});
|
|
51
|
+
|
|
52
|
+
it("clears back to the browser / process zone when set to null", () => {
|
|
53
|
+
OneUptimeDate.setUserTimezone(NY);
|
|
54
|
+
OneUptimeDate.setUserTimezone(null);
|
|
55
|
+
|
|
56
|
+
expect(OneUptimeDate.getUserTimezone()).toBeNull();
|
|
57
|
+
expect(OneUptimeDate.getCurrentTimezone().toString()).toBe(
|
|
58
|
+
moment.tz.guess(),
|
|
59
|
+
);
|
|
60
|
+
});
|
|
61
|
+
|
|
62
|
+
it("ignores a value moment does not recognise as a zone rather than breaking every date", () => {
|
|
63
|
+
OneUptimeDate.setUserTimezone("Not/AZone" as Timezone);
|
|
64
|
+
|
|
65
|
+
expect(OneUptimeDate.getUserTimezone()).toBeNull();
|
|
66
|
+
expect(OneUptimeDate.getCurrentTimezone().toString()).toBe(
|
|
67
|
+
moment.tz.guess(),
|
|
68
|
+
);
|
|
69
|
+
// Formatting still works.
|
|
70
|
+
expect(
|
|
71
|
+
OneUptimeDate.toDateTimeLocalString(new Date("2026-07-09T13:00:00Z")),
|
|
72
|
+
).toMatch(/^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}$/);
|
|
73
|
+
});
|
|
74
|
+
});
|
|
75
|
+
|
|
76
|
+
describe("datetime-local round trip", () => {
|
|
77
|
+
it("renders a stored instant as its wall-clock in the user timezone", () => {
|
|
78
|
+
OneUptimeDate.setUserTimezone(NY);
|
|
79
|
+
|
|
80
|
+
// 17:00 UTC on 2026-07-09 is 13:00 in New York (EDT).
|
|
81
|
+
expect(
|
|
82
|
+
OneUptimeDate.toDateTimeLocalString(new Date("2026-07-09T17:00:00Z")),
|
|
83
|
+
).toBe("2026-07-09T13:00:00");
|
|
84
|
+
});
|
|
85
|
+
|
|
86
|
+
it("resolves a typed wall-clock in the user timezone, not the browser zone", () => {
|
|
87
|
+
OneUptimeDate.setUserTimezone(NY);
|
|
88
|
+
|
|
89
|
+
const typed: Date =
|
|
90
|
+
OneUptimeDate.fromDateTimeLocalString("2026-07-09T13:00");
|
|
91
|
+
|
|
92
|
+
expect(typed.toISOString()).toBe("2026-07-09T17:00:00.000Z");
|
|
93
|
+
});
|
|
94
|
+
|
|
95
|
+
it("round trips what the user typed back into the picker unchanged", () => {
|
|
96
|
+
OneUptimeDate.setUserTimezone(NY);
|
|
97
|
+
|
|
98
|
+
const typed: Date =
|
|
99
|
+
OneUptimeDate.fromDateTimeLocalString("2026-01-15T09:30");
|
|
100
|
+
|
|
101
|
+
expect(OneUptimeDate.toDateTimeLocalString(typed)).toBe(
|
|
102
|
+
"2026-01-15T09:30:00",
|
|
103
|
+
);
|
|
104
|
+
});
|
|
105
|
+
|
|
106
|
+
it("stores different instants for the same wall-clock in different user timezones", () => {
|
|
107
|
+
OneUptimeDate.setUserTimezone(NY);
|
|
108
|
+
const inNY: Date =
|
|
109
|
+
OneUptimeDate.fromDateTimeLocalString("2026-07-09T13:00");
|
|
110
|
+
|
|
111
|
+
OneUptimeDate.setUserTimezone(ADAK);
|
|
112
|
+
const inAdak: Date =
|
|
113
|
+
OneUptimeDate.fromDateTimeLocalString("2026-07-09T13:00");
|
|
114
|
+
|
|
115
|
+
// Adak is 5 hours behind New York in July.
|
|
116
|
+
expect(inAdak.getTime() - inNY.getTime()).toBe(5 * 60 * 60 * 1000);
|
|
117
|
+
});
|
|
118
|
+
|
|
119
|
+
it("honours the user timezone's DST offset for a winter instant", () => {
|
|
120
|
+
OneUptimeDate.setUserTimezone(NY);
|
|
121
|
+
|
|
122
|
+
// 09:00 EST is 14:00 UTC in January.
|
|
123
|
+
expect(
|
|
124
|
+
OneUptimeDate.fromDateTimeLocalString("2026-01-15T09:00").toISOString(),
|
|
125
|
+
).toBe("2026-01-15T14:00:00.000Z");
|
|
126
|
+
});
|
|
127
|
+
});
|
|
128
|
+
|
|
129
|
+
describe("display helpers", () => {
|
|
130
|
+
it("formats an instant in the user timezone with its abbreviation", () => {
|
|
131
|
+
OneUptimeDate.setUserTimezone(NY);
|
|
132
|
+
|
|
133
|
+
const formatted: string = OneUptimeDate.getDateAsLocalFormattedString(
|
|
134
|
+
new Date("2026-07-09T17:00:00Z"),
|
|
135
|
+
);
|
|
136
|
+
|
|
137
|
+
expect(formatted).toContain("Jul 09 2026");
|
|
138
|
+
expect(formatted).toContain("13:00");
|
|
139
|
+
expect(formatted).toContain("EDT");
|
|
140
|
+
});
|
|
141
|
+
|
|
142
|
+
it("reads the hour and minute of an instant in the user timezone", () => {
|
|
143
|
+
OneUptimeDate.setUserTimezone(KOLKATA);
|
|
144
|
+
|
|
145
|
+
const instant: Date = new Date("2026-07-09T17:00:00Z");
|
|
146
|
+
|
|
147
|
+
// 17:00 UTC is 22:30 in Kolkata (UTC+5:30).
|
|
148
|
+
expect(OneUptimeDate.getLocalHours(instant)).toBe(22);
|
|
149
|
+
expect(OneUptimeDate.getLocalMinutes(instant)).toBe(30);
|
|
150
|
+
expect(OneUptimeDate.getLocalTimeString(instant)).toBe("22:30");
|
|
151
|
+
});
|
|
152
|
+
|
|
153
|
+
it("rolls the calendar day when the user timezone puts the instant on another date", () => {
|
|
154
|
+
OneUptimeDate.setUserTimezone(KOLKATA);
|
|
155
|
+
|
|
156
|
+
// 23:00 UTC on Jul 9 is already 04:30 on Jul 10 in Kolkata.
|
|
157
|
+
const instant: Date = new Date("2026-07-09T23:00:00Z");
|
|
158
|
+
|
|
159
|
+
expect(OneUptimeDate.asDateForDatabaseQuery(instant)).toBe("2026-07-10");
|
|
160
|
+
expect(OneUptimeDate.getDateAsLocalDayMonthString(instant)).toBe(
|
|
161
|
+
"10 Jul",
|
|
162
|
+
);
|
|
163
|
+
expect(OneUptimeDate.getDateAsLocalMonthYearString(instant)).toBe(
|
|
164
|
+
"Jul 2026",
|
|
165
|
+
);
|
|
166
|
+
});
|
|
167
|
+
});
|
|
168
|
+
|
|
169
|
+
describe("schedule-zone wall-clock bridge", () => {
|
|
170
|
+
it("reads the entered wall-clock in the user timezone before anchoring it to the schedule zone", () => {
|
|
171
|
+
OneUptimeDate.setUserTimezone(NY);
|
|
172
|
+
|
|
173
|
+
// The admin typed 09:00 while working in New York.
|
|
174
|
+
const typed: Date =
|
|
175
|
+
OneUptimeDate.fromDateTimeLocalString("2026-07-09T09:00");
|
|
176
|
+
|
|
177
|
+
const stored: Date = OneUptimeDate.getInstantFromLocalWallClockInTimezone(
|
|
178
|
+
typed,
|
|
179
|
+
KOLKATA.toString(),
|
|
180
|
+
);
|
|
181
|
+
|
|
182
|
+
// ...and the schedule enforces 09:00 in Kolkata.
|
|
183
|
+
expect(moment.tz(stored, KOLKATA.toString()).format("HH:mm")).toBe(
|
|
184
|
+
"09:00",
|
|
185
|
+
);
|
|
186
|
+
});
|
|
187
|
+
|
|
188
|
+
it("redisplays a stored schedule-zone time as the same wall-clock in the picker", () => {
|
|
189
|
+
OneUptimeDate.setUserTimezone(NY);
|
|
190
|
+
|
|
191
|
+
const stored: Date = moment
|
|
192
|
+
.tz("2026-07-09 09:00", KOLKATA.toString())
|
|
193
|
+
.toDate();
|
|
194
|
+
|
|
195
|
+
const forPicker: Date = OneUptimeDate.getLocalDateFromWallClockInTimezone(
|
|
196
|
+
stored,
|
|
197
|
+
KOLKATA.toString(),
|
|
198
|
+
);
|
|
199
|
+
|
|
200
|
+
expect(OneUptimeDate.toDateTimeLocalString(forPicker)).toBe(
|
|
201
|
+
"2026-07-09T09:00:00",
|
|
202
|
+
);
|
|
203
|
+
});
|
|
204
|
+
});
|
|
205
|
+
});
|
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
/** @timezone America/Adak */
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Regression test for the reported bug, reproduced exactly: the process (a
|
|
5
|
+
* stand-in for the customer's browser) reports America/Adak — abbreviated
|
|
6
|
+
* "HDT" — while the user has picked America/New_York in User Settings.
|
|
7
|
+
*
|
|
8
|
+
* Before the fix, every date field labelled itself "your local timezone - HDT"
|
|
9
|
+
* and resolved what the user typed in Adak's zone, so a New York user had to
|
|
10
|
+
* enter Hawaii-Aleutian times to get the right instant stored. The profile
|
|
11
|
+
* timezone must win over whatever the browser reports.
|
|
12
|
+
*/
|
|
13
|
+
import OneUptimeDate from "../../Types/Date";
|
|
14
|
+
import Timezone from "../../Types/Timezone";
|
|
15
|
+
import moment from "moment-timezone";
|
|
16
|
+
|
|
17
|
+
const NY: Timezone = Timezone.AmericaNew_York;
|
|
18
|
+
|
|
19
|
+
describe("user timezone overrides a browser reporting a different zone", () => {
|
|
20
|
+
afterEach(() => {
|
|
21
|
+
OneUptimeDate.setUserTimezone(null);
|
|
22
|
+
});
|
|
23
|
+
|
|
24
|
+
it("confirms the process really is on the Adak (HDT) clock", () => {
|
|
25
|
+
// Guards the premise of every assertion below.
|
|
26
|
+
expect(moment.tz.guess()).toBe("America/Adak");
|
|
27
|
+
expect(OneUptimeDate.getCurrentTimezoneString()).toBe("HDT");
|
|
28
|
+
});
|
|
29
|
+
|
|
30
|
+
it("labels date fields with the profile timezone, not HDT", () => {
|
|
31
|
+
OneUptimeDate.setUserTimezone(NY);
|
|
32
|
+
|
|
33
|
+
expect(OneUptimeDate.getCurrentTimezoneString()).toBe("EDT");
|
|
34
|
+
expect(OneUptimeDate.getCurrentTimezone()).toBe(NY);
|
|
35
|
+
});
|
|
36
|
+
|
|
37
|
+
it("stores the wall-clock the user typed as New York time", () => {
|
|
38
|
+
OneUptimeDate.setUserTimezone(NY);
|
|
39
|
+
|
|
40
|
+
/*
|
|
41
|
+
* The user schedules maintenance for 14:00 on Jul 9. That is 18:00 UTC in
|
|
42
|
+
* New York; reading it on the Adak clock would have stored 23:00 UTC —
|
|
43
|
+
* five hours off, which is exactly what the customer worked around.
|
|
44
|
+
*/
|
|
45
|
+
const startsAt: Date =
|
|
46
|
+
OneUptimeDate.fromDateTimeLocalString("2026-07-09T14:00");
|
|
47
|
+
|
|
48
|
+
expect(startsAt.toISOString()).toBe("2026-07-09T18:00:00.000Z");
|
|
49
|
+
});
|
|
50
|
+
|
|
51
|
+
it("reads a stored event back at the wall-clock the user typed", () => {
|
|
52
|
+
OneUptimeDate.setUserTimezone(NY);
|
|
53
|
+
|
|
54
|
+
const startsAt: Date = new Date("2026-07-09T18:00:00.000Z");
|
|
55
|
+
|
|
56
|
+
expect(OneUptimeDate.toDateTimeLocalString(startsAt)).toBe(
|
|
57
|
+
"2026-07-09T14:00:00",
|
|
58
|
+
);
|
|
59
|
+
expect(OneUptimeDate.getDateAsLocalFormattedString(startsAt)).toContain(
|
|
60
|
+
"EDT",
|
|
61
|
+
);
|
|
62
|
+
expect(OneUptimeDate.getLocalTimeString(startsAt)).toBe("14:00");
|
|
63
|
+
});
|
|
64
|
+
|
|
65
|
+
it("still follows the browser zone for a user who has not picked one", () => {
|
|
66
|
+
OneUptimeDate.setUserTimezone(null);
|
|
67
|
+
|
|
68
|
+
// 14:00 HDT (UTC-9) is 23:00 UTC the same day.
|
|
69
|
+
expect(
|
|
70
|
+
OneUptimeDate.fromDateTimeLocalString("2026-07-09T14:00").toISOString(),
|
|
71
|
+
).toBe("2026-07-09T23:00:00.000Z");
|
|
72
|
+
});
|
|
73
|
+
});
|
|
@@ -0,0 +1,155 @@
|
|
|
1
|
+
import MonitorCriteria from "../../../Types/Monitor/MonitorCriteria";
|
|
2
|
+
import MonitorCriteriaInstance from "../../../Types/Monitor/MonitorCriteriaInstance";
|
|
3
|
+
import MonitorType from "../../../Types/Monitor/MonitorType";
|
|
4
|
+
import ObjectID from "../../../Types/ObjectID";
|
|
5
|
+
import { JSONObject, ObjectType } from "../../../Types/JSON";
|
|
6
|
+
import BadDataException from "../../../Types/Exception/BadDataException";
|
|
7
|
+
|
|
8
|
+
/*
|
|
9
|
+
* MonitorCriteria wraps the ordered list of MonitorCriteriaInstance that a
|
|
10
|
+
* monitor step evaluates. These tests lock in the default builder (which
|
|
11
|
+
* composes the online + offline instances), the validation contract (which
|
|
12
|
+
* delegates to each instance), and the toJSON/fromJSON round-trip.
|
|
13
|
+
*/
|
|
14
|
+
|
|
15
|
+
const DEFAULT_ARG: {
|
|
16
|
+
monitorType: MonitorType;
|
|
17
|
+
monitorName: string;
|
|
18
|
+
onlineMonitorStatusId: ObjectID;
|
|
19
|
+
offlineMonitorStatusId: ObjectID;
|
|
20
|
+
defaultIncidentSeverityId: ObjectID;
|
|
21
|
+
defaultAlertSeverityId: ObjectID;
|
|
22
|
+
} = {
|
|
23
|
+
monitorType: MonitorType.Ping,
|
|
24
|
+
monitorName: "Gateway",
|
|
25
|
+
onlineMonitorStatusId: new ObjectID("100000000000000000000011"),
|
|
26
|
+
offlineMonitorStatusId: new ObjectID("100000000000000000000012"),
|
|
27
|
+
defaultIncidentSeverityId: new ObjectID("100000000000000000000013"),
|
|
28
|
+
defaultAlertSeverityId: new ObjectID("100000000000000000000014"),
|
|
29
|
+
};
|
|
30
|
+
|
|
31
|
+
describe("MonitorCriteria", () => {
|
|
32
|
+
describe("getDefaultMonitorCriteria", () => {
|
|
33
|
+
test("composes both an offline and an online instance for Ping", () => {
|
|
34
|
+
const criteria: MonitorCriteria =
|
|
35
|
+
MonitorCriteria.getDefaultMonitorCriteria(DEFAULT_ARG);
|
|
36
|
+
|
|
37
|
+
// Ping has both an offline (Disallow) and an online criteria instance.
|
|
38
|
+
expect(criteria.data?.monitorCriteriaInstanceArray).toHaveLength(2);
|
|
39
|
+
for (const instance of criteria.data!.monitorCriteriaInstanceArray) {
|
|
40
|
+
expect(instance).toBeInstanceOf(MonitorCriteriaInstance);
|
|
41
|
+
}
|
|
42
|
+
});
|
|
43
|
+
|
|
44
|
+
test("the default Ping criteria passes validation", () => {
|
|
45
|
+
const criteria: MonitorCriteria =
|
|
46
|
+
MonitorCriteria.getDefaultMonitorCriteria(DEFAULT_ARG);
|
|
47
|
+
expect(
|
|
48
|
+
MonitorCriteria.getValidationError(criteria, MonitorType.Ping),
|
|
49
|
+
).toBeNull();
|
|
50
|
+
});
|
|
51
|
+
});
|
|
52
|
+
|
|
53
|
+
describe("getValidationError", () => {
|
|
54
|
+
test("errors when data is missing", () => {
|
|
55
|
+
const criteria: MonitorCriteria = new MonitorCriteria();
|
|
56
|
+
criteria.data = undefined;
|
|
57
|
+
expect(
|
|
58
|
+
MonitorCriteria.getValidationError(criteria, MonitorType.Ping),
|
|
59
|
+
).toBe("Monitor Criteria is required");
|
|
60
|
+
});
|
|
61
|
+
|
|
62
|
+
test("errors when the instance array is empty", () => {
|
|
63
|
+
const criteria: MonitorCriteria = new MonitorCriteria();
|
|
64
|
+
criteria.data = { monitorCriteriaInstanceArray: [] };
|
|
65
|
+
expect(
|
|
66
|
+
MonitorCriteria.getValidationError(criteria, MonitorType.Ping),
|
|
67
|
+
).toBe("Monitor Criteria is required");
|
|
68
|
+
});
|
|
69
|
+
|
|
70
|
+
test("propagates a validation error from a contained instance", () => {
|
|
71
|
+
const criteria: MonitorCriteria =
|
|
72
|
+
MonitorCriteria.getDefaultMonitorCriteria(DEFAULT_ARG);
|
|
73
|
+
// Corrupt a contained instance: strip its required name.
|
|
74
|
+
criteria.data!.monitorCriteriaInstanceArray[0]!.data!.name = "";
|
|
75
|
+
const error: string | null = MonitorCriteria.getValidationError(
|
|
76
|
+
criteria,
|
|
77
|
+
MonitorType.Ping,
|
|
78
|
+
);
|
|
79
|
+
expect(error).toContain("Name is required");
|
|
80
|
+
});
|
|
81
|
+
});
|
|
82
|
+
|
|
83
|
+
describe("getNewMonitorCriteriaAsJSON", () => {
|
|
84
|
+
test("wraps a single default instance", () => {
|
|
85
|
+
const json: JSONObject = MonitorCriteria.getNewMonitorCriteriaAsJSON();
|
|
86
|
+
expect(json["_type"]).toBe("MonitorCriteria");
|
|
87
|
+
const value: JSONObject = json["value"] as JSONObject;
|
|
88
|
+
expect(Array.isArray(value["monitorCriteriaInstanceArray"])).toBe(true);
|
|
89
|
+
expect(
|
|
90
|
+
(value["monitorCriteriaInstanceArray"] as Array<unknown>).length,
|
|
91
|
+
).toBe(1);
|
|
92
|
+
});
|
|
93
|
+
});
|
|
94
|
+
|
|
95
|
+
describe("toJSON / fromJSON round-trip", () => {
|
|
96
|
+
test("serializes with the MonitorCriteria type wrapper", () => {
|
|
97
|
+
const criteria: MonitorCriteria =
|
|
98
|
+
MonitorCriteria.getDefaultMonitorCriteria(DEFAULT_ARG);
|
|
99
|
+
const json: JSONObject = criteria.toJSON();
|
|
100
|
+
expect(json["_type"]).toBe(ObjectType.MonitorCriteria);
|
|
101
|
+
});
|
|
102
|
+
|
|
103
|
+
test("round-trips the instance count", () => {
|
|
104
|
+
const criteria: MonitorCriteria =
|
|
105
|
+
MonitorCriteria.getDefaultMonitorCriteria(DEFAULT_ARG);
|
|
106
|
+
const restored: MonitorCriteria = MonitorCriteria.fromJSON(
|
|
107
|
+
criteria.toJSON(),
|
|
108
|
+
);
|
|
109
|
+
expect(restored.data?.monitorCriteriaInstanceArray).toHaveLength(2);
|
|
110
|
+
expect(restored.data?.monitorCriteriaInstanceArray[0]).toBeInstanceOf(
|
|
111
|
+
MonitorCriteriaInstance,
|
|
112
|
+
);
|
|
113
|
+
});
|
|
114
|
+
|
|
115
|
+
test("toString returns a JSON string of the serialized value", () => {
|
|
116
|
+
const criteria: MonitorCriteria =
|
|
117
|
+
MonitorCriteria.getDefaultMonitorCriteria(DEFAULT_ARG);
|
|
118
|
+
const parsed: JSONObject = JSON.parse(criteria.toString()) as JSONObject;
|
|
119
|
+
expect(parsed["_type"]).toBe(ObjectType.MonitorCriteria);
|
|
120
|
+
});
|
|
121
|
+
});
|
|
122
|
+
|
|
123
|
+
describe("fromJSON validation", () => {
|
|
124
|
+
test("returns the same instance when given a MonitorCriteria", () => {
|
|
125
|
+
const criteria: MonitorCriteria =
|
|
126
|
+
MonitorCriteria.getDefaultMonitorCriteria(DEFAULT_ARG);
|
|
127
|
+
expect(MonitorCriteria.fromJSON(criteria as unknown as JSONObject)).toBe(
|
|
128
|
+
criteria,
|
|
129
|
+
);
|
|
130
|
+
});
|
|
131
|
+
|
|
132
|
+
test("throws on a wrong _type", () => {
|
|
133
|
+
expect(() => {
|
|
134
|
+
return MonitorCriteria.fromJSON({ _type: "Nope", value: {} });
|
|
135
|
+
}).toThrow(BadDataException);
|
|
136
|
+
});
|
|
137
|
+
|
|
138
|
+
test("throws when value is missing", () => {
|
|
139
|
+
expect(() => {
|
|
140
|
+
return MonitorCriteria.fromJSON({
|
|
141
|
+
_type: ObjectType.MonitorCriteria,
|
|
142
|
+
});
|
|
143
|
+
}).toThrow(BadDataException);
|
|
144
|
+
});
|
|
145
|
+
|
|
146
|
+
test("throws when monitorCriteriaInstanceArray is missing", () => {
|
|
147
|
+
expect(() => {
|
|
148
|
+
return MonitorCriteria.fromJSON({
|
|
149
|
+
_type: ObjectType.MonitorCriteria,
|
|
150
|
+
value: {},
|
|
151
|
+
});
|
|
152
|
+
}).toThrow(BadDataException);
|
|
153
|
+
});
|
|
154
|
+
});
|
|
155
|
+
});
|