@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
|
@@ -0,0 +1,334 @@
|
|
|
1
|
+
import SliType from "../../../Types/ServiceLevelObjective/SliType";
|
|
2
|
+
import SloMultiMonitorMode from "../../../Types/ServiceLevelObjective/SloMultiMonitorMode";
|
|
3
|
+
import SloStatus from "../../../Types/ServiceLevelObjective/SloStatus";
|
|
4
|
+
import SloWindowType from "../../../Types/ServiceLevelObjective/SloWindowType";
|
|
5
|
+
import {
|
|
6
|
+
DEFAULT_AT_RISK_THRESHOLD_PERCENTAGE,
|
|
7
|
+
getSloBudgetTier,
|
|
8
|
+
getSloNotice,
|
|
9
|
+
isRollingWindowNotYetFull,
|
|
10
|
+
SloBudgetTier,
|
|
11
|
+
SloNotice,
|
|
12
|
+
SloNoticeType,
|
|
13
|
+
} from "../../../Utils/Slo/SloHealth";
|
|
14
|
+
|
|
15
|
+
const SECONDS_PER_DAY: number = 24 * 60 * 60;
|
|
16
|
+
|
|
17
|
+
/** The full 30-day error budget of a 99.9% SLO: 43m 12s. */
|
|
18
|
+
const THIRTY_DAY_999_BUDGET_SECONDS: number = 0.001 * 30 * SECONDS_PER_DAY;
|
|
19
|
+
|
|
20
|
+
describe("SloHealth", () => {
|
|
21
|
+
describe("getSloBudgetTier", () => {
|
|
22
|
+
it("returns Unknown when the SLO has not been evaluated", () => {
|
|
23
|
+
expect(getSloBudgetTier({ errorBudgetRemainingPercentage: null })).toBe(
|
|
24
|
+
SloBudgetTier.Unknown,
|
|
25
|
+
);
|
|
26
|
+
expect(
|
|
27
|
+
getSloBudgetTier({ errorBudgetRemainingPercentage: undefined }),
|
|
28
|
+
).toBe(SloBudgetTier.Unknown);
|
|
29
|
+
});
|
|
30
|
+
|
|
31
|
+
it("returns Unknown for non-finite values", () => {
|
|
32
|
+
expect(
|
|
33
|
+
getSloBudgetTier({ errorBudgetRemainingPercentage: Number.NaN }),
|
|
34
|
+
).toBe(SloBudgetTier.Unknown);
|
|
35
|
+
});
|
|
36
|
+
|
|
37
|
+
it("returns Healthy well above the threshold", () => {
|
|
38
|
+
expect(getSloBudgetTier({ errorBudgetRemainingPercentage: 68 })).toBe(
|
|
39
|
+
SloBudgetTier.Healthy,
|
|
40
|
+
);
|
|
41
|
+
});
|
|
42
|
+
|
|
43
|
+
it("returns Exhausted at exactly zero", () => {
|
|
44
|
+
expect(getSloBudgetTier({ errorBudgetRemainingPercentage: 0 })).toBe(
|
|
45
|
+
SloBudgetTier.Exhausted,
|
|
46
|
+
);
|
|
47
|
+
});
|
|
48
|
+
|
|
49
|
+
it("returns Exhausted when overspent", () => {
|
|
50
|
+
expect(getSloBudgetTier({ errorBudgetRemainingPercentage: -37 })).toBe(
|
|
51
|
+
SloBudgetTier.Exhausted,
|
|
52
|
+
);
|
|
53
|
+
});
|
|
54
|
+
|
|
55
|
+
it("returns AtRisk at exactly the threshold", () => {
|
|
56
|
+
expect(
|
|
57
|
+
getSloBudgetTier({
|
|
58
|
+
errorBudgetRemainingPercentage: DEFAULT_AT_RISK_THRESHOLD_PERCENTAGE,
|
|
59
|
+
}),
|
|
60
|
+
).toBe(SloBudgetTier.AtRisk);
|
|
61
|
+
});
|
|
62
|
+
|
|
63
|
+
it("falls back to the 20% default when no threshold is given", () => {
|
|
64
|
+
expect(getSloBudgetTier({ errorBudgetRemainingPercentage: 19.9 })).toBe(
|
|
65
|
+
SloBudgetTier.AtRisk,
|
|
66
|
+
);
|
|
67
|
+
expect(getSloBudgetTier({ errorBudgetRemainingPercentage: 20.1 })).toBe(
|
|
68
|
+
SloBudgetTier.Healthy,
|
|
69
|
+
);
|
|
70
|
+
});
|
|
71
|
+
|
|
72
|
+
it("honours a custom at-risk threshold instead of the hardcoded 20", () => {
|
|
73
|
+
/*
|
|
74
|
+
* The regression this function exists for: at a 50% threshold the
|
|
75
|
+
* old `remaining <= 20` check rendered 35% green while the worker
|
|
76
|
+
* had already moved the SLO to At Risk.
|
|
77
|
+
*/
|
|
78
|
+
expect(
|
|
79
|
+
getSloBudgetTier({
|
|
80
|
+
errorBudgetRemainingPercentage: 35,
|
|
81
|
+
atRiskThresholdPercentage: 50,
|
|
82
|
+
}),
|
|
83
|
+
).toBe(SloBudgetTier.AtRisk);
|
|
84
|
+
|
|
85
|
+
expect(
|
|
86
|
+
getSloBudgetTier({
|
|
87
|
+
errorBudgetRemainingPercentage: 15,
|
|
88
|
+
atRiskThresholdPercentage: 5,
|
|
89
|
+
}),
|
|
90
|
+
).toBe(SloBudgetTier.Healthy);
|
|
91
|
+
});
|
|
92
|
+
|
|
93
|
+
it("treats a zero threshold as 'at risk only once exhausted'", () => {
|
|
94
|
+
expect(
|
|
95
|
+
getSloBudgetTier({
|
|
96
|
+
errorBudgetRemainingPercentage: 0.5,
|
|
97
|
+
atRiskThresholdPercentage: 0,
|
|
98
|
+
}),
|
|
99
|
+
).toBe(SloBudgetTier.Healthy);
|
|
100
|
+
});
|
|
101
|
+
});
|
|
102
|
+
|
|
103
|
+
describe("getSloNotice", () => {
|
|
104
|
+
it("returns null for a normally measuring SLO", () => {
|
|
105
|
+
expect(
|
|
106
|
+
getSloNotice({
|
|
107
|
+
isEnabled: true,
|
|
108
|
+
sloStatus: SloStatus.Healthy,
|
|
109
|
+
sliType: SliType.MonitorUptime,
|
|
110
|
+
monitorCount: 2,
|
|
111
|
+
targetPercentage: 99.9,
|
|
112
|
+
lastEvaluatedAt: new Date(),
|
|
113
|
+
}),
|
|
114
|
+
).toBeNull();
|
|
115
|
+
});
|
|
116
|
+
|
|
117
|
+
it("reports a disabled SLO ahead of any other reason", () => {
|
|
118
|
+
/*
|
|
119
|
+
* A disabled SLO is never evaluated, so its stale Misconfigured
|
|
120
|
+
* status must not send the user off to attach monitors.
|
|
121
|
+
*/
|
|
122
|
+
const notice: SloNotice | null = getSloNotice({
|
|
123
|
+
isEnabled: false,
|
|
124
|
+
sloStatus: SloStatus.Misconfigured,
|
|
125
|
+
monitorCount: 0,
|
|
126
|
+
});
|
|
127
|
+
|
|
128
|
+
expect(notice?.type).toBe(SloNoticeType.Info);
|
|
129
|
+
expect(notice?.title).toBe("This SLO is disabled");
|
|
130
|
+
});
|
|
131
|
+
|
|
132
|
+
it("names 'no monitors' as the reason for Misconfigured", () => {
|
|
133
|
+
const notice: SloNotice | null = getSloNotice({
|
|
134
|
+
isEnabled: true,
|
|
135
|
+
sloStatus: SloStatus.Misconfigured,
|
|
136
|
+
sliType: SliType.MonitorUptime,
|
|
137
|
+
monitorCount: 0,
|
|
138
|
+
targetPercentage: 99.9,
|
|
139
|
+
lastEvaluatedAt: new Date(),
|
|
140
|
+
});
|
|
141
|
+
|
|
142
|
+
expect(notice?.type).toBe(SloNoticeType.Warning);
|
|
143
|
+
expect(notice?.title).toBe("No monitors attached");
|
|
144
|
+
});
|
|
145
|
+
|
|
146
|
+
it("names an out-of-range target as the reason for Misconfigured", () => {
|
|
147
|
+
const notice: SloNotice | null = getSloNotice({
|
|
148
|
+
isEnabled: true,
|
|
149
|
+
sloStatus: SloStatus.Misconfigured,
|
|
150
|
+
sliType: SliType.MonitorUptime,
|
|
151
|
+
monitorCount: 1,
|
|
152
|
+
targetPercentage: 100,
|
|
153
|
+
lastEvaluatedAt: new Date(),
|
|
154
|
+
});
|
|
155
|
+
|
|
156
|
+
expect(notice?.title).toBe("The target is out of range");
|
|
157
|
+
});
|
|
158
|
+
|
|
159
|
+
it("names an unsupported SLI type ahead of the monitor count", () => {
|
|
160
|
+
const notice: SloNotice | null = getSloNotice({
|
|
161
|
+
isEnabled: true,
|
|
162
|
+
sloStatus: SloStatus.Misconfigured,
|
|
163
|
+
sliType: SliType.Metric,
|
|
164
|
+
monitorCount: 0,
|
|
165
|
+
targetPercentage: 99.9,
|
|
166
|
+
lastEvaluatedAt: new Date(),
|
|
167
|
+
});
|
|
168
|
+
|
|
169
|
+
expect(notice?.body).toContain(SliType.MonitorUptime);
|
|
170
|
+
});
|
|
171
|
+
|
|
172
|
+
it("falls back to a generic reason for Misconfigured", () => {
|
|
173
|
+
const notice: SloNotice | null = getSloNotice({
|
|
174
|
+
isEnabled: true,
|
|
175
|
+
sloStatus: SloStatus.Misconfigured,
|
|
176
|
+
sliType: SliType.MonitorUptime,
|
|
177
|
+
monitorCount: 1,
|
|
178
|
+
targetPercentage: 99.9,
|
|
179
|
+
lastEvaluatedAt: new Date(),
|
|
180
|
+
});
|
|
181
|
+
|
|
182
|
+
expect(notice?.title).toBe("This SLO cannot be evaluated");
|
|
183
|
+
});
|
|
184
|
+
|
|
185
|
+
it("explains Paused", () => {
|
|
186
|
+
const notice: SloNotice | null = getSloNotice({
|
|
187
|
+
isEnabled: true,
|
|
188
|
+
sloStatus: SloStatus.Paused,
|
|
189
|
+
sliType: SliType.MonitorUptime,
|
|
190
|
+
monitorCount: 2,
|
|
191
|
+
targetPercentage: 99.9,
|
|
192
|
+
lastEvaluatedAt: new Date(),
|
|
193
|
+
});
|
|
194
|
+
|
|
195
|
+
expect(notice?.type).toBe(SloNoticeType.Info);
|
|
196
|
+
expect(notice?.title).toBe("Measurement is paused");
|
|
197
|
+
});
|
|
198
|
+
|
|
199
|
+
it("explains a never-evaluated SLO", () => {
|
|
200
|
+
const notice: SloNotice | null = getSloNotice({
|
|
201
|
+
isEnabled: true,
|
|
202
|
+
sloStatus: SloStatus.Healthy,
|
|
203
|
+
sliType: SliType.MonitorUptime,
|
|
204
|
+
monitorCount: 1,
|
|
205
|
+
targetPercentage: 99.9,
|
|
206
|
+
lastEvaluatedAt: null,
|
|
207
|
+
});
|
|
208
|
+
|
|
209
|
+
expect(notice?.title).toBe("Not evaluated yet");
|
|
210
|
+
});
|
|
211
|
+
|
|
212
|
+
it("says nothing about a healthy SLO whose isEnabled was not selected", () => {
|
|
213
|
+
// `undefined` means "not loaded", which is not the same as `false`.
|
|
214
|
+
expect(
|
|
215
|
+
getSloNotice({
|
|
216
|
+
sloStatus: SloStatus.Healthy,
|
|
217
|
+
sliType: SliType.MonitorUptime,
|
|
218
|
+
monitorCount: 1,
|
|
219
|
+
targetPercentage: 99.9,
|
|
220
|
+
lastEvaluatedAt: new Date(),
|
|
221
|
+
}),
|
|
222
|
+
).toBeNull();
|
|
223
|
+
});
|
|
224
|
+
});
|
|
225
|
+
|
|
226
|
+
describe("isRollingWindowNotYetFull", () => {
|
|
227
|
+
it("is false for a mature rolling window", () => {
|
|
228
|
+
expect(
|
|
229
|
+
isRollingWindowNotYetFull({
|
|
230
|
+
windowType: SloWindowType.Rolling,
|
|
231
|
+
windowDays: 30,
|
|
232
|
+
targetPercentage: 99.9,
|
|
233
|
+
errorBudgetTotalSeconds: THIRTY_DAY_999_BUDGET_SECONDS,
|
|
234
|
+
}),
|
|
235
|
+
).toBe(false);
|
|
236
|
+
});
|
|
237
|
+
|
|
238
|
+
it("is true for a 30-day SLO carrying only a week of data", () => {
|
|
239
|
+
expect(
|
|
240
|
+
isRollingWindowNotYetFull({
|
|
241
|
+
windowType: SloWindowType.Rolling,
|
|
242
|
+
windowDays: 30,
|
|
243
|
+
targetPercentage: 99.9,
|
|
244
|
+
errorBudgetTotalSeconds: 0.001 * 7 * SECONDS_PER_DAY,
|
|
245
|
+
}),
|
|
246
|
+
).toBe(true);
|
|
247
|
+
});
|
|
248
|
+
|
|
249
|
+
it("stays silent for Monitor Seconds Average — its budget scales with the monitor count", () => {
|
|
250
|
+
/*
|
|
251
|
+
* A five-monitor SLO with a week of data carries 35 monitor-days of
|
|
252
|
+
* budget, which would read as mature against a 30-day yardstick. The
|
|
253
|
+
* helper declines to guess rather than answer wrongly in either
|
|
254
|
+
* direction.
|
|
255
|
+
*/
|
|
256
|
+
expect(
|
|
257
|
+
isRollingWindowNotYetFull({
|
|
258
|
+
windowType: SloWindowType.Rolling,
|
|
259
|
+
windowDays: 30,
|
|
260
|
+
targetPercentage: 99.9,
|
|
261
|
+
errorBudgetTotalSeconds: 0.001 * 7 * SECONDS_PER_DAY,
|
|
262
|
+
multiMonitorMode: SloMultiMonitorMode.MonitorSecondsAverage,
|
|
263
|
+
}),
|
|
264
|
+
).toBe(false);
|
|
265
|
+
});
|
|
266
|
+
|
|
267
|
+
it("still answers for the default Any Monitor Down mode", () => {
|
|
268
|
+
expect(
|
|
269
|
+
isRollingWindowNotYetFull({
|
|
270
|
+
windowType: SloWindowType.Rolling,
|
|
271
|
+
windowDays: 30,
|
|
272
|
+
targetPercentage: 99.9,
|
|
273
|
+
errorBudgetTotalSeconds: 0.001 * 7 * SECONDS_PER_DAY,
|
|
274
|
+
multiMonitorMode: SloMultiMonitorMode.AnyDown,
|
|
275
|
+
}),
|
|
276
|
+
).toBe(true);
|
|
277
|
+
});
|
|
278
|
+
|
|
279
|
+
it("tolerates the small shortfall between now and the last evaluation", () => {
|
|
280
|
+
// 0.5% short — a mature SLO must not flicker the banner.
|
|
281
|
+
expect(
|
|
282
|
+
isRollingWindowNotYetFull({
|
|
283
|
+
windowType: SloWindowType.Rolling,
|
|
284
|
+
windowDays: 30,
|
|
285
|
+
targetPercentage: 99.9,
|
|
286
|
+
errorBudgetTotalSeconds: THIRTY_DAY_999_BUDGET_SECONDS * 0.995,
|
|
287
|
+
}),
|
|
288
|
+
).toBe(false);
|
|
289
|
+
});
|
|
290
|
+
|
|
291
|
+
it("is false for calendar-month windows — their budget is never prorated", () => {
|
|
292
|
+
expect(
|
|
293
|
+
isRollingWindowNotYetFull({
|
|
294
|
+
windowType: SloWindowType.CalendarMonth,
|
|
295
|
+
windowDays: 30,
|
|
296
|
+
targetPercentage: 99.9,
|
|
297
|
+
errorBudgetTotalSeconds: 1,
|
|
298
|
+
}),
|
|
299
|
+
).toBe(false);
|
|
300
|
+
});
|
|
301
|
+
|
|
302
|
+
it("is false when the SLO has not been evaluated", () => {
|
|
303
|
+
expect(
|
|
304
|
+
isRollingWindowNotYetFull({
|
|
305
|
+
windowType: SloWindowType.Rolling,
|
|
306
|
+
windowDays: 30,
|
|
307
|
+
targetPercentage: 99.9,
|
|
308
|
+
errorBudgetTotalSeconds: null,
|
|
309
|
+
}),
|
|
310
|
+
).toBe(false);
|
|
311
|
+
});
|
|
312
|
+
|
|
313
|
+
it("is false when the target would make the full-window budget meaningless", () => {
|
|
314
|
+
expect(
|
|
315
|
+
isRollingWindowNotYetFull({
|
|
316
|
+
windowType: SloWindowType.Rolling,
|
|
317
|
+
windowDays: 30,
|
|
318
|
+
targetPercentage: 100,
|
|
319
|
+
errorBudgetTotalSeconds: 60,
|
|
320
|
+
}),
|
|
321
|
+
).toBe(false);
|
|
322
|
+
});
|
|
323
|
+
|
|
324
|
+
it("defaults windowDays to 30 when it is not set", () => {
|
|
325
|
+
expect(
|
|
326
|
+
isRollingWindowNotYetFull({
|
|
327
|
+
windowType: SloWindowType.Rolling,
|
|
328
|
+
targetPercentage: 99.9,
|
|
329
|
+
errorBudgetTotalSeconds: 0.001 * 7 * SECONDS_PER_DAY,
|
|
330
|
+
}),
|
|
331
|
+
).toBe(true);
|
|
332
|
+
});
|
|
333
|
+
});
|
|
334
|
+
});
|
package/Types/Date.ts
CHANGED
|
@@ -12,6 +12,13 @@ export const Moment: typeof moment = moment;
|
|
|
12
12
|
export default class OneUptimeDate {
|
|
13
13
|
// get date time from unix timestamp
|
|
14
14
|
|
|
15
|
+
/*
|
|
16
|
+
* The timezone the signed-in user configured in User Settings, pushed in by
|
|
17
|
+
* the UI (see UserUtil.initializeUserTimezone). Null on the server and on
|
|
18
|
+
* signed-out pages, where the process / browser zone is used instead.
|
|
19
|
+
*/
|
|
20
|
+
private static userTimezone: Timezone | null = null;
|
|
21
|
+
|
|
15
22
|
private static padDatePart(value: number): string {
|
|
16
23
|
return value.toString().padStart(2, "0");
|
|
17
24
|
}
|
|
@@ -44,6 +51,7 @@ export default class OneUptimeDate {
|
|
|
44
51
|
private static getLocalShortMonthName(date: Date): string {
|
|
45
52
|
return date.toLocaleString("default", {
|
|
46
53
|
month: "short",
|
|
54
|
+
timeZone: this.getCurrentTimezone().toString(),
|
|
47
55
|
});
|
|
48
56
|
}
|
|
49
57
|
|
|
@@ -156,6 +164,19 @@ export default class OneUptimeDate {
|
|
|
156
164
|
return this.getLocalTimeString(date);
|
|
157
165
|
}
|
|
158
166
|
|
|
167
|
+
/**
|
|
168
|
+
* The hour / minute `date` reads at in the current timezone. Time pickers use
|
|
169
|
+
* these instead of Date.getHours()/getMinutes() so the digits they show are
|
|
170
|
+
* the ones the user expects in the zone they configured.
|
|
171
|
+
*/
|
|
172
|
+
public static getLocalHours(date: Date | string): number {
|
|
173
|
+
return this.inCurrentTimezone(date).hours();
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
public static getLocalMinutes(date: Date | string): number {
|
|
177
|
+
return this.inCurrentTimezone(date).minutes();
|
|
178
|
+
}
|
|
179
|
+
|
|
159
180
|
public static getLocalShortMonthNameFromDate(date: Date | string): string {
|
|
160
181
|
date = this.fromString(date);
|
|
161
182
|
return this.getLocalShortMonthName(date);
|
|
@@ -172,26 +193,27 @@ export default class OneUptimeDate {
|
|
|
172
193
|
|
|
173
194
|
const includeMinutes: boolean = options?.includeMinutes ?? true;
|
|
174
195
|
const includeSeconds: boolean = options?.includeSeconds ?? false;
|
|
175
|
-
const
|
|
196
|
+
const localDate: moment.Moment = this.inCurrentTimezone(date);
|
|
197
|
+
const hours: string = this.padDatePart(localDate.hours());
|
|
176
198
|
|
|
177
199
|
if (!includeMinutes) {
|
|
178
200
|
return hours;
|
|
179
201
|
}
|
|
180
202
|
|
|
181
|
-
const minutes: string = this.padDatePart(
|
|
203
|
+
const minutes: string = this.padDatePart(localDate.minutes());
|
|
182
204
|
|
|
183
205
|
if (!includeSeconds) {
|
|
184
206
|
return `${hours}:${minutes}`;
|
|
185
207
|
}
|
|
186
208
|
|
|
187
|
-
const seconds: string = this.padDatePart(
|
|
209
|
+
const seconds: string = this.padDatePart(localDate.seconds());
|
|
188
210
|
return `${hours}:${minutes}:${seconds}`;
|
|
189
211
|
}
|
|
190
212
|
|
|
191
213
|
public static getDateAsLocalDayMonthString(date: Date | string): string {
|
|
192
214
|
date = this.fromString(date);
|
|
193
215
|
|
|
194
|
-
const day: string = this.padDatePart(date.
|
|
216
|
+
const day: string = this.padDatePart(this.inCurrentTimezone(date).date());
|
|
195
217
|
const month: string = this.getLocalShortMonthName(date);
|
|
196
218
|
|
|
197
219
|
return `${day} ${month}`;
|
|
@@ -207,7 +229,7 @@ export default class OneUptimeDate {
|
|
|
207
229
|
date = this.fromString(date);
|
|
208
230
|
|
|
209
231
|
const month: string = this.getLocalShortMonthName(date);
|
|
210
|
-
const year: string = date.
|
|
232
|
+
const year: string = this.inCurrentTimezone(date).year().toString();
|
|
211
233
|
|
|
212
234
|
return `${month} ${year}`;
|
|
213
235
|
}
|
|
@@ -316,7 +338,7 @@ export default class OneUptimeDate {
|
|
|
316
338
|
: `${datePart}, ${timePart}`;
|
|
317
339
|
|
|
318
340
|
if (!data.timezone) {
|
|
319
|
-
const local: moment.Moment =
|
|
341
|
+
const local: moment.Moment = this.inCurrentTimezone(date);
|
|
320
342
|
return (
|
|
321
343
|
local.format(formatString) +
|
|
322
344
|
(data.onlyShowDate ? "" : " " + this.getCurrentTimezoneString())
|
|
@@ -333,18 +355,18 @@ export default class OneUptimeDate {
|
|
|
333
355
|
}
|
|
334
356
|
|
|
335
357
|
/**
|
|
336
|
-
* Reinterpret the wall-clock components of `date` — read in the
|
|
337
|
-
*
|
|
338
|
-
* instant. Used to store a time the user typed
|
|
339
|
-
* even though the time picker captured it in the
|
|
340
|
-
* F1). Inverse of
|
|
358
|
+
* Reinterpret the wall-clock components of `date` — read in the CURRENT
|
|
359
|
+
* timezone, i.e. the one the pickers render in — as the SAME wall-clock in
|
|
360
|
+
* `timezone`, and return that instant. Used to store a time the user typed
|
|
361
|
+
* for the schedule's timezone even though the time picker captured it in the
|
|
362
|
+
* viewer's own zone (audit F1). Inverse of
|
|
363
|
+
* getLocalDateFromWallClockInTimezone.
|
|
341
364
|
*/
|
|
342
365
|
public static getInstantFromLocalWallClockInTimezone(
|
|
343
366
|
date: Date | string,
|
|
344
367
|
timezone: string,
|
|
345
368
|
): Date {
|
|
346
|
-
|
|
347
|
-
const local: moment.Moment = moment(date);
|
|
369
|
+
const local: moment.Moment = this.inCurrentTimezone(date);
|
|
348
370
|
return moment
|
|
349
371
|
.tz(
|
|
350
372
|
{
|
|
@@ -361,10 +383,10 @@ export default class OneUptimeDate {
|
|
|
361
383
|
}
|
|
362
384
|
|
|
363
385
|
/**
|
|
364
|
-
* Inverse of getInstantFromLocalWallClockInTimezone: return a
|
|
365
|
-
*
|
|
366
|
-
*
|
|
367
|
-
*
|
|
386
|
+
* Inverse of getInstantFromLocalWallClockInTimezone: return a Date whose
|
|
387
|
+
* wall-clock IN THE CURRENT TIMEZONE equals `date`'s wall-clock as seen in
|
|
388
|
+
* `timezone`. Used to display a stored schedule-timezone time inside a picker
|
|
389
|
+
* that renders in the viewer's own zone (audit F1).
|
|
368
390
|
*/
|
|
369
391
|
public static getLocalDateFromWallClockInTimezone(
|
|
370
392
|
date: Date | string,
|
|
@@ -372,14 +394,19 @@ export default class OneUptimeDate {
|
|
|
372
394
|
): Date {
|
|
373
395
|
date = this.fromString(date);
|
|
374
396
|
const zoned: moment.Moment = moment.tz(date, timezone);
|
|
375
|
-
return moment
|
|
376
|
-
|
|
377
|
-
|
|
378
|
-
|
|
379
|
-
|
|
380
|
-
|
|
381
|
-
|
|
382
|
-
|
|
397
|
+
return moment
|
|
398
|
+
.tz(
|
|
399
|
+
{
|
|
400
|
+
year: zoned.year(),
|
|
401
|
+
month: zoned.month(),
|
|
402
|
+
day: zoned.date(),
|
|
403
|
+
hour: zoned.hour(),
|
|
404
|
+
minute: zoned.minute(),
|
|
405
|
+
second: zoned.second(),
|
|
406
|
+
},
|
|
407
|
+
this.getCurrentTimezone().toString(),
|
|
408
|
+
)
|
|
409
|
+
.toDate();
|
|
383
410
|
}
|
|
384
411
|
|
|
385
412
|
public static isOverlapping(
|
|
@@ -709,22 +736,26 @@ export default class OneUptimeDate {
|
|
|
709
736
|
return this.addRemoveMinutes(date, date.getTimezoneOffset());
|
|
710
737
|
}
|
|
711
738
|
|
|
739
|
+
/**
|
|
740
|
+
* Render an instant as the value of an `<input type="datetime-local">` — a
|
|
741
|
+
* bare wall-clock with no offset — read in the current timezone.
|
|
742
|
+
*/
|
|
712
743
|
public static toDateTimeLocalString(date: Date): string {
|
|
713
|
-
|
|
714
|
-
|
|
715
|
-
type TenFunction = (i: number) => string;
|
|
716
|
-
|
|
717
|
-
const ten: TenFunction = (i: number): string => {
|
|
718
|
-
return (i < 10 ? "0" : "") + i;
|
|
719
|
-
},
|
|
720
|
-
YYYY: number = date.getFullYear(),
|
|
721
|
-
MM: string = ten(date.getMonth() + 1),
|
|
722
|
-
DD: string = ten(date.getDate()),
|
|
723
|
-
HH: string = ten(date.getHours()),
|
|
724
|
-
II: string = ten(date.getMinutes()),
|
|
725
|
-
SS: string = ten(date.getSeconds());
|
|
744
|
+
return this.inCurrentTimezone(date).format("YYYY-MM-DDTHH:mm:ss");
|
|
745
|
+
}
|
|
726
746
|
|
|
727
|
-
|
|
747
|
+
/**
|
|
748
|
+
* Inverse of toDateTimeLocalString: turn the offset-less wall-clock a user
|
|
749
|
+
* typed into a `<input type="date">` / `<input type="datetime-local">` into
|
|
750
|
+
* the instant it names in the current timezone. Without this the browser
|
|
751
|
+
* zone is used to resolve the wall-clock, so a user whose machine reports a
|
|
752
|
+
* different zone than the one they configured would have their times stored
|
|
753
|
+
* hours off.
|
|
754
|
+
*/
|
|
755
|
+
public static fromDateTimeLocalString(value: string): Date {
|
|
756
|
+
return moment
|
|
757
|
+
.tz(value.trim(), this.getCurrentTimezone().toString())
|
|
758
|
+
.toDate();
|
|
728
759
|
}
|
|
729
760
|
|
|
730
761
|
public static fromJSON(json: JSONObject): Date {
|
|
@@ -1713,7 +1744,7 @@ export default class OneUptimeDate {
|
|
|
1713
1744
|
formatstring = "MMM DD, YYYY";
|
|
1714
1745
|
}
|
|
1715
1746
|
|
|
1716
|
-
const momentDate: moment.Moment =
|
|
1747
|
+
const momentDate: moment.Moment = this.inCurrentTimezone(date);
|
|
1717
1748
|
|
|
1718
1749
|
return (
|
|
1719
1750
|
momentDate.format(formatstring) +
|
|
@@ -1752,8 +1783,41 @@ export default class OneUptimeDate {
|
|
|
1752
1783
|
return zoneAbbr;
|
|
1753
1784
|
}
|
|
1754
1785
|
|
|
1786
|
+
/**
|
|
1787
|
+
* Set the timezone the signed-in user picked in User Settings. Once set,
|
|
1788
|
+
* every "local" helper below resolves wall-clock times in THIS zone instead
|
|
1789
|
+
* of the zone the browser reports, so what the user reads and types matches
|
|
1790
|
+
* the timezone they configured — even when the machine, OS or VPN they are
|
|
1791
|
+
* on reports a different one. Pass null to fall back to the browser zone
|
|
1792
|
+
* (signed-out surfaces, or a user who has not picked a timezone).
|
|
1793
|
+
*/
|
|
1794
|
+
public static setUserTimezone(timezone: Timezone | null | undefined): void {
|
|
1795
|
+
/*
|
|
1796
|
+
* Ignore anything moment does not recognise as an IANA zone. A stale or
|
|
1797
|
+
* corrupt saved value must not take every date in the UI down with it.
|
|
1798
|
+
*/
|
|
1799
|
+
if (!timezone || !moment.tz.zone(timezone.toString())) {
|
|
1800
|
+
this.userTimezone = null;
|
|
1801
|
+
return;
|
|
1802
|
+
}
|
|
1803
|
+
|
|
1804
|
+
this.userTimezone = timezone;
|
|
1805
|
+
}
|
|
1806
|
+
|
|
1807
|
+
public static getUserTimezone(): Timezone | null {
|
|
1808
|
+
return this.userTimezone;
|
|
1809
|
+
}
|
|
1810
|
+
|
|
1755
1811
|
public static getCurrentTimezone(): Timezone {
|
|
1756
|
-
return moment.tz.guess() as Timezone;
|
|
1812
|
+
return this.userTimezone || (moment.tz.guess() as Timezone);
|
|
1813
|
+
}
|
|
1814
|
+
|
|
1815
|
+
/**
|
|
1816
|
+
* The instant `date` viewed through the current timezone — the single place
|
|
1817
|
+
* that decides which wall clock the "local" helpers below read from.
|
|
1818
|
+
*/
|
|
1819
|
+
private static inCurrentTimezone(date: Date | string): moment.Moment {
|
|
1820
|
+
return moment(this.fromString(date)).tz(this.getCurrentTimezone());
|
|
1757
1821
|
}
|
|
1758
1822
|
|
|
1759
1823
|
public static getDateString(date: Date): string {
|
|
@@ -1810,9 +1874,8 @@ export default class OneUptimeDate {
|
|
|
1810
1874
|
}
|
|
1811
1875
|
|
|
1812
1876
|
public static asDateForDatabaseQuery(date: string | Date): string {
|
|
1813
|
-
date = this.fromString(date);
|
|
1814
1877
|
const formatstring: string = "YYYY-MM-DD";
|
|
1815
|
-
return
|
|
1878
|
+
return this.inCurrentTimezone(date).format(formatstring);
|
|
1816
1879
|
}
|
|
1817
1880
|
|
|
1818
1881
|
public static asFilterDateForDatabaseQuery(
|
|
@@ -1850,13 +1913,19 @@ export default class OneUptimeDate {
|
|
|
1850
1913
|
throw new BadDataException("Invalid seconds");
|
|
1851
1914
|
}
|
|
1852
1915
|
|
|
1853
|
-
|
|
1854
|
-
|
|
1855
|
-
|
|
1856
|
-
|
|
1857
|
-
|
|
1858
|
-
|
|
1859
|
-
|
|
1916
|
+
/*
|
|
1917
|
+
* The hour/minute the user picked is a wall-clock reading in THEIR
|
|
1918
|
+
* timezone, so anchor it to today's date in that zone rather than setting
|
|
1919
|
+
* it on a browser-local Date.
|
|
1920
|
+
*/
|
|
1921
|
+
return this.inCurrentTimezone(OneUptimeDate.getCurrentDate())
|
|
1922
|
+
.set({
|
|
1923
|
+
hour: hour,
|
|
1924
|
+
minute: minutes,
|
|
1925
|
+
second: seconds,
|
|
1926
|
+
millisecond: 0,
|
|
1927
|
+
})
|
|
1928
|
+
.toDate();
|
|
1860
1929
|
}
|
|
1861
1930
|
|
|
1862
1931
|
public static toDatabaseDate(date: Date): string {
|
|
@@ -291,9 +291,19 @@ const FormField: <T extends GenericObject>(
|
|
|
291
291
|
fieldDescription = "";
|
|
292
292
|
}
|
|
293
293
|
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
|
|
294
|
+
/*
|
|
295
|
+
* Name the zone in full. The abbreviation on its own ("EDT") is not
|
|
296
|
+
* enough to tell whether the field is following the timezone picked in
|
|
297
|
+
* User Settings or the one the browser reports.
|
|
298
|
+
*/
|
|
299
|
+
fieldDescription = (
|
|
300
|
+
fieldDescription +
|
|
301
|
+
" This is in your timezone - " +
|
|
302
|
+
OneUptimeDate.getCurrentTimezoneString() +
|
|
303
|
+
" (" +
|
|
304
|
+
OneUptimeDate.getCurrentTimezone().toString() +
|
|
305
|
+
")."
|
|
306
|
+
).trim();
|
|
297
307
|
}
|
|
298
308
|
|
|
299
309
|
type GetFieldDescriptionFunction = () => ReactElement | string;
|
|
@@ -174,7 +174,12 @@ const Input: FunctionComponent<ComponentProps> = (
|
|
|
174
174
|
props.type === InputType.DATETIME_LOCAL) &&
|
|
175
175
|
value
|
|
176
176
|
) {
|
|
177
|
-
|
|
177
|
+
/*
|
|
178
|
+
* The input hands back a bare wall-clock with no offset. Resolve
|
|
179
|
+
* it in the user's configured timezone rather than letting the
|
|
180
|
+
* browser assume its own zone.
|
|
181
|
+
*/
|
|
182
|
+
const date: Date = OneUptimeDate.fromDateTimeLocalString(value);
|
|
178
183
|
const dateString: string = OneUptimeDate.toString(date);
|
|
179
184
|
setValue(dateString);
|
|
180
185
|
if (props.onChange) {
|
|
@@ -81,8 +81,12 @@ const TimePicker: FunctionComponent<ComponentProps> = (
|
|
|
81
81
|
return toDate(props.value) || OneUptimeDate.getCurrentDate();
|
|
82
82
|
}, [props.value]);
|
|
83
83
|
|
|
84
|
-
const [hours24, setHours24] = useState<number>(
|
|
85
|
-
|
|
84
|
+
const [hours24, setHours24] = useState<number>(
|
|
85
|
+
OneUptimeDate.getLocalHours(initialDate),
|
|
86
|
+
);
|
|
87
|
+
const [minutes, setMinutes] = useState<number>(
|
|
88
|
+
OneUptimeDate.getLocalMinutes(initialDate),
|
|
89
|
+
);
|
|
86
90
|
|
|
87
91
|
const hoursInputRef: React.MutableRefObject<HTMLInputElement | null> =
|
|
88
92
|
useRef<HTMLInputElement | null>(null);
|
|
@@ -99,8 +103,8 @@ const TimePicker: FunctionComponent<ComponentProps> = (
|
|
|
99
103
|
if (!d) {
|
|
100
104
|
return;
|
|
101
105
|
}
|
|
102
|
-
setHours24(
|
|
103
|
-
setMinutes(
|
|
106
|
+
setHours24(OneUptimeDate.getLocalHours(d));
|
|
107
|
+
setMinutes(OneUptimeDate.getLocalMinutes(d));
|
|
104
108
|
}, [props.value]);
|
|
105
109
|
|
|
106
110
|
const emitChange: (h24: number, m: number) => void = (
|