@oneuptime/common 11.3.28 → 11.4.0
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/DatabaseModels/AIRun.ts +50 -0
- package/Models/DatabaseModels/Project.ts +60 -0
- package/Server/API/AIInvestigationAPI.ts +148 -0
- package/Server/Infrastructure/Postgres/SchemaMigrations/1783598145111-AddEnableAutomaticIncidentInvestigationToProject.ts +19 -0
- package/Server/Infrastructure/Postgres/SchemaMigrations/1783615884106-AddInvestigationSubjectToAIRun.ts +33 -0
- package/Server/Infrastructure/Postgres/SchemaMigrations/1783632447975-AddEnableAutomaticAlertInvestigationToProject.ts +19 -0
- package/Server/Infrastructure/Postgres/SchemaMigrations/Index.ts +6 -0
- package/Server/Services/AlertService.ts +25 -0
- package/Server/Services/IncidentService.ts +25 -0
- package/Server/Services/IncidentStateTimelineService.ts +19 -0
- package/Server/Utils/AI/Chat/ObservabilityAssistant.ts +119 -11
- package/Server/Utils/AI/Sentinel/AlertInvestigationRunner.ts +141 -0
- package/Server/Utils/AI/Sentinel/IncidentInvestigationRunner.ts +178 -0
- package/Server/Utils/AI/Sentinel/IncidentPostmortemRunner.ts +105 -0
- package/Server/Utils/AI/Sentinel/SentinelInvestigationEngine.ts +405 -0
- package/Server/Utils/AI/Sentinel/SentinelMemory.ts +167 -0
- package/Server/Utils/AI/Toolbox/Index.ts +11 -0
- package/Server/Utils/AI/Toolbox/SentinelActionTools.ts +577 -0
- package/Tests/Server/API/BaseAPI.test.ts +1 -0
- package/Tests/Types/OnCallDutyPolicy/ScheduleShiftUtil.test.ts +260 -0
- package/Types/Date.ts +46 -0
- package/Types/OnCallDutyPolicy/ScheduleShiftUtil.ts +257 -0
- package/UI/Components/Calendar/Calendar.tsx +8 -2
- package/UI/Components/Charts/ChartGroup/ChartGroup.tsx +5 -5
- package/build/dist/Models/DatabaseModels/AIRun.js +52 -0
- package/build/dist/Models/DatabaseModels/AIRun.js.map +1 -1
- package/build/dist/Models/DatabaseModels/Project.js +62 -0
- package/build/dist/Models/DatabaseModels/Project.js.map +1 -1
- package/build/dist/Server/API/AIInvestigationAPI.js +107 -0
- package/build/dist/Server/API/AIInvestigationAPI.js.map +1 -0
- package/build/dist/Server/Infrastructure/Postgres/SchemaMigrations/1783598145111-AddEnableAutomaticIncidentInvestigationToProject.js +12 -0
- package/build/dist/Server/Infrastructure/Postgres/SchemaMigrations/1783598145111-AddEnableAutomaticIncidentInvestigationToProject.js.map +1 -0
- package/build/dist/Server/Infrastructure/Postgres/SchemaMigrations/1783615884106-AddInvestigationSubjectToAIRun.js +18 -0
- package/build/dist/Server/Infrastructure/Postgres/SchemaMigrations/1783615884106-AddInvestigationSubjectToAIRun.js.map +1 -0
- package/build/dist/Server/Infrastructure/Postgres/SchemaMigrations/1783632447975-AddEnableAutomaticAlertInvestigationToProject.js +12 -0
- package/build/dist/Server/Infrastructure/Postgres/SchemaMigrations/1783632447975-AddEnableAutomaticAlertInvestigationToProject.js.map +1 -0
- package/build/dist/Server/Infrastructure/Postgres/SchemaMigrations/Index.js +6 -0
- package/build/dist/Server/Infrastructure/Postgres/SchemaMigrations/Index.js.map +1 -1
- package/build/dist/Server/Services/AlertService.js +24 -0
- package/build/dist/Server/Services/AlertService.js.map +1 -1
- package/build/dist/Server/Services/IncidentService.js +24 -0
- package/build/dist/Server/Services/IncidentService.js.map +1 -1
- package/build/dist/Server/Services/IncidentStateTimelineService.js +19 -0
- package/build/dist/Server/Services/IncidentStateTimelineService.js.map +1 -1
- package/build/dist/Server/Utils/AI/Chat/ObservabilityAssistant.js +59 -11
- package/build/dist/Server/Utils/AI/Chat/ObservabilityAssistant.js.map +1 -1
- package/build/dist/Server/Utils/AI/Sentinel/AlertInvestigationRunner.js +119 -0
- package/build/dist/Server/Utils/AI/Sentinel/AlertInvestigationRunner.js.map +1 -0
- package/build/dist/Server/Utils/AI/Sentinel/IncidentInvestigationRunner.js +147 -0
- package/build/dist/Server/Utils/AI/Sentinel/IncidentInvestigationRunner.js.map +1 -0
- package/build/dist/Server/Utils/AI/Sentinel/IncidentPostmortemRunner.js +93 -0
- package/build/dist/Server/Utils/AI/Sentinel/IncidentPostmortemRunner.js.map +1 -0
- package/build/dist/Server/Utils/AI/Sentinel/SentinelInvestigationEngine.js +311 -0
- package/build/dist/Server/Utils/AI/Sentinel/SentinelInvestigationEngine.js.map +1 -0
- package/build/dist/Server/Utils/AI/Sentinel/SentinelMemory.js +143 -0
- package/build/dist/Server/Utils/AI/Sentinel/SentinelMemory.js.map +1 -0
- package/build/dist/Server/Utils/AI/Toolbox/Index.js +6 -0
- package/build/dist/Server/Utils/AI/Toolbox/Index.js.map +1 -1
- package/build/dist/Server/Utils/AI/Toolbox/SentinelActionTools.js +451 -0
- package/build/dist/Server/Utils/AI/Toolbox/SentinelActionTools.js.map +1 -0
- package/build/dist/Types/Date.js +32 -0
- package/build/dist/Types/Date.js.map +1 -1
- package/build/dist/Types/OnCallDutyPolicy/ScheduleShiftUtil.js +159 -0
- package/build/dist/Types/OnCallDutyPolicy/ScheduleShiftUtil.js.map +1 -0
- package/build/dist/UI/Components/Calendar/Calendar.js +2 -2
- package/build/dist/UI/Components/Calendar/Calendar.js.map +1 -1
- package/build/dist/UI/Components/Charts/ChartGroup/ChartGroup.js +2 -2
- package/build/dist/UI/Components/Charts/ChartGroup/ChartGroup.js.map +1 -1
- package/package.json +1 -1
|
@@ -0,0 +1,260 @@
|
|
|
1
|
+
import CalendarEvent from "../../../Types/Calendar/CalendarEvent";
|
|
2
|
+
import OneUptimeDate from "../../../Types/Date";
|
|
3
|
+
import ScheduleShiftUtil, {
|
|
4
|
+
CoverageGap,
|
|
5
|
+
CurrentAndNextShift,
|
|
6
|
+
OnCallShift,
|
|
7
|
+
} from "../../../Types/OnCallDutyPolicy/ScheduleShiftUtil";
|
|
8
|
+
|
|
9
|
+
/*
|
|
10
|
+
* These tests pin the pure event -> shift transformation the on-call schedule
|
|
11
|
+
* summaries depend on. They deliberately do NOT re-test LayerUtil (covered by
|
|
12
|
+
* its own exhaustive suites) — they only cover the grouping / gap / current-next
|
|
13
|
+
* logic layered on top of its output.
|
|
14
|
+
*/
|
|
15
|
+
|
|
16
|
+
// Build a CalendarEvent the way LayerUtil emits one: user id in `title`.
|
|
17
|
+
const event: (userId: string, start: Date, end: Date) => CalendarEvent = (
|
|
18
|
+
userId: string,
|
|
19
|
+
start: Date,
|
|
20
|
+
end: Date,
|
|
21
|
+
): CalendarEvent => {
|
|
22
|
+
return {
|
|
23
|
+
id: 0,
|
|
24
|
+
title: userId,
|
|
25
|
+
allDay: false,
|
|
26
|
+
start,
|
|
27
|
+
end,
|
|
28
|
+
};
|
|
29
|
+
};
|
|
30
|
+
|
|
31
|
+
const at: (iso: string) => Date = (iso: string): Date => {
|
|
32
|
+
return OneUptimeDate.fromString(iso);
|
|
33
|
+
};
|
|
34
|
+
|
|
35
|
+
/*
|
|
36
|
+
* Build an OnCallShift literal for the resolver/gap tests (coverageSeconds is
|
|
37
|
+
* irrelevant to those, so default it to the wall-clock span).
|
|
38
|
+
*/
|
|
39
|
+
const mkShift: (userId: string, start: Date, end: Date) => OnCallShift = (
|
|
40
|
+
userId: string,
|
|
41
|
+
start: Date,
|
|
42
|
+
end: Date,
|
|
43
|
+
): OnCallShift => {
|
|
44
|
+
return {
|
|
45
|
+
userId,
|
|
46
|
+
start,
|
|
47
|
+
end,
|
|
48
|
+
coverageSeconds: OneUptimeDate.getDifferenceInSeconds(end, start),
|
|
49
|
+
};
|
|
50
|
+
};
|
|
51
|
+
|
|
52
|
+
describe("ScheduleShiftUtil", () => {
|
|
53
|
+
describe("groupEventsIntoShifts", () => {
|
|
54
|
+
test("returns empty array for no events", () => {
|
|
55
|
+
expect(ScheduleShiftUtil.groupEventsIntoShifts([])).toEqual([]);
|
|
56
|
+
});
|
|
57
|
+
|
|
58
|
+
test("keeps distinct users as separate shifts (24/7 rotation)", () => {
|
|
59
|
+
const events: Array<CalendarEvent> = [
|
|
60
|
+
event("A", at("2024-01-01T09:00:00Z"), at("2024-01-02T09:00:00Z")),
|
|
61
|
+
event("B", at("2024-01-02T09:00:01Z"), at("2024-01-03T09:00:00Z")),
|
|
62
|
+
event("A", at("2024-01-03T09:00:01Z"), at("2024-01-04T09:00:00Z")),
|
|
63
|
+
];
|
|
64
|
+
|
|
65
|
+
const shifts: Array<OnCallShift> =
|
|
66
|
+
ScheduleShiftUtil.groupEventsIntoShifts(events);
|
|
67
|
+
|
|
68
|
+
expect(shifts).toHaveLength(3);
|
|
69
|
+
expect(
|
|
70
|
+
shifts.map((s: OnCallShift) => {
|
|
71
|
+
return s.userId;
|
|
72
|
+
}),
|
|
73
|
+
).toEqual(["A", "B", "A"]);
|
|
74
|
+
expect(shifts[0]!.end).toEqual(at("2024-01-02T09:00:00Z"));
|
|
75
|
+
});
|
|
76
|
+
|
|
77
|
+
test("merges touching same-user segments into one contiguous shift", () => {
|
|
78
|
+
// Two segments one second apart -> a single shift.
|
|
79
|
+
const events: Array<CalendarEvent> = [
|
|
80
|
+
event("A", at("2024-01-01T09:00:00Z"), at("2024-01-01T17:00:00Z")),
|
|
81
|
+
event("A", at("2024-01-01T17:00:01Z"), at("2024-01-02T09:00:00Z")),
|
|
82
|
+
];
|
|
83
|
+
|
|
84
|
+
const shifts: Array<OnCallShift> =
|
|
85
|
+
ScheduleShiftUtil.groupEventsIntoShifts(events);
|
|
86
|
+
|
|
87
|
+
expect(shifts).toHaveLength(1);
|
|
88
|
+
expect(shifts[0]!.start).toEqual(at("2024-01-01T09:00:00Z"));
|
|
89
|
+
expect(shifts[0]!.end).toEqual(at("2024-01-02T09:00:00Z"));
|
|
90
|
+
});
|
|
91
|
+
|
|
92
|
+
test("does NOT merge same-user segments across a real gap by default", () => {
|
|
93
|
+
// Daily 9-5 restriction: same user, but a 16h nightly gap between days.
|
|
94
|
+
const events: Array<CalendarEvent> = [
|
|
95
|
+
event("A", at("2024-01-01T09:00:00Z"), at("2024-01-01T17:00:00Z")),
|
|
96
|
+
event("A", at("2024-01-02T09:00:00Z"), at("2024-01-02T17:00:00Z")),
|
|
97
|
+
];
|
|
98
|
+
|
|
99
|
+
const shifts: Array<OnCallShift> =
|
|
100
|
+
ScheduleShiftUtil.groupEventsIntoShifts(events);
|
|
101
|
+
|
|
102
|
+
expect(shifts).toHaveLength(2);
|
|
103
|
+
});
|
|
104
|
+
|
|
105
|
+
test("merges same-user segments across gaps when mergeAcrossGaps=true", () => {
|
|
106
|
+
const events: Array<CalendarEvent> = [
|
|
107
|
+
event("A", at("2024-01-01T09:00:00Z"), at("2024-01-01T17:00:00Z")),
|
|
108
|
+
event("A", at("2024-01-02T09:00:00Z"), at("2024-01-02T17:00:00Z")),
|
|
109
|
+
event("B", at("2024-01-03T09:00:00Z"), at("2024-01-03T17:00:00Z")),
|
|
110
|
+
];
|
|
111
|
+
|
|
112
|
+
const shifts: Array<OnCallShift> =
|
|
113
|
+
ScheduleShiftUtil.groupEventsIntoShifts(events, {
|
|
114
|
+
mergeAcrossGaps: true,
|
|
115
|
+
});
|
|
116
|
+
|
|
117
|
+
expect(shifts).toHaveLength(2);
|
|
118
|
+
expect(shifts[0]!.userId).toBe("A");
|
|
119
|
+
expect(shifts[0]!.start).toEqual(at("2024-01-01T09:00:00Z"));
|
|
120
|
+
// Turn end is the last covered instant of that user's run.
|
|
121
|
+
expect(shifts[0]!.end).toEqual(at("2024-01-02T17:00:00Z"));
|
|
122
|
+
/*
|
|
123
|
+
* coverageSeconds is the REAL active time (two 8h days = 16h), not the
|
|
124
|
+
* ~32h wall-clock span across the overnight gap.
|
|
125
|
+
*/
|
|
126
|
+
expect(shifts[0]!.coverageSeconds).toBe(16 * 3600);
|
|
127
|
+
expect(shifts[1]!.userId).toBe("B");
|
|
128
|
+
});
|
|
129
|
+
|
|
130
|
+
test("sorts unsorted input by start time and never mutates the caller array", () => {
|
|
131
|
+
const events: Array<CalendarEvent> = [
|
|
132
|
+
event("B", at("2024-01-02T09:00:00Z"), at("2024-01-03T09:00:00Z")),
|
|
133
|
+
event("A", at("2024-01-01T09:00:00Z"), at("2024-01-02T09:00:00Z")),
|
|
134
|
+
];
|
|
135
|
+
const snapshot: Array<string> = events.map((e: CalendarEvent) => {
|
|
136
|
+
return e.title;
|
|
137
|
+
});
|
|
138
|
+
|
|
139
|
+
const shifts: Array<OnCallShift> =
|
|
140
|
+
ScheduleShiftUtil.groupEventsIntoShifts(events);
|
|
141
|
+
|
|
142
|
+
expect(
|
|
143
|
+
shifts.map((s: OnCallShift) => {
|
|
144
|
+
return s.userId;
|
|
145
|
+
}),
|
|
146
|
+
).toEqual(["A", "B"]);
|
|
147
|
+
// input order preserved (we copy before sorting)
|
|
148
|
+
expect(
|
|
149
|
+
events.map((e: CalendarEvent) => {
|
|
150
|
+
return e.title;
|
|
151
|
+
}),
|
|
152
|
+
).toEqual(snapshot);
|
|
153
|
+
});
|
|
154
|
+
});
|
|
155
|
+
|
|
156
|
+
describe("getCurrentAndNextShift", () => {
|
|
157
|
+
const shifts: Array<OnCallShift> = [
|
|
158
|
+
mkShift("A", at("2024-01-01T09:00:00Z"), at("2024-01-02T09:00:00Z")),
|
|
159
|
+
mkShift("B", at("2024-01-02T09:00:00Z"), at("2024-01-03T09:00:00Z")),
|
|
160
|
+
];
|
|
161
|
+
|
|
162
|
+
test("resolves the current and next shift when now is inside a shift", () => {
|
|
163
|
+
const result: CurrentAndNextShift =
|
|
164
|
+
ScheduleShiftUtil.getCurrentAndNextShift(
|
|
165
|
+
shifts,
|
|
166
|
+
at("2024-01-01T12:00:00Z"),
|
|
167
|
+
);
|
|
168
|
+
|
|
169
|
+
expect(result.current?.userId).toBe("A");
|
|
170
|
+
expect(result.next?.userId).toBe("B");
|
|
171
|
+
});
|
|
172
|
+
|
|
173
|
+
test("current is null inside a coverage gap but next is still found", () => {
|
|
174
|
+
const gapped: Array<OnCallShift> = [
|
|
175
|
+
mkShift("A", at("2024-01-01T09:00:00Z"), at("2024-01-01T17:00:00Z")),
|
|
176
|
+
mkShift("A", at("2024-01-02T09:00:00Z"), at("2024-01-02T17:00:00Z")),
|
|
177
|
+
];
|
|
178
|
+
|
|
179
|
+
const result: CurrentAndNextShift =
|
|
180
|
+
ScheduleShiftUtil.getCurrentAndNextShift(
|
|
181
|
+
gapped,
|
|
182
|
+
at("2024-01-01T20:00:00Z"),
|
|
183
|
+
);
|
|
184
|
+
|
|
185
|
+
expect(result.current).toBeNull();
|
|
186
|
+
expect(result.next?.start).toEqual(at("2024-01-02T09:00:00Z"));
|
|
187
|
+
});
|
|
188
|
+
|
|
189
|
+
test("next is null when now is past the last shift", () => {
|
|
190
|
+
const result: CurrentAndNextShift =
|
|
191
|
+
ScheduleShiftUtil.getCurrentAndNextShift(
|
|
192
|
+
shifts,
|
|
193
|
+
at("2024-01-05T00:00:00Z"),
|
|
194
|
+
);
|
|
195
|
+
|
|
196
|
+
expect(result.current).toBeNull();
|
|
197
|
+
expect(result.next).toBeNull();
|
|
198
|
+
});
|
|
199
|
+
});
|
|
200
|
+
|
|
201
|
+
describe("getCoverageGaps", () => {
|
|
202
|
+
test("reports a leading gap before the first shift", () => {
|
|
203
|
+
const shifts: Array<OnCallShift> = [
|
|
204
|
+
mkShift("A", at("2024-01-01T12:00:00Z"), at("2024-01-01T18:00:00Z")),
|
|
205
|
+
];
|
|
206
|
+
|
|
207
|
+
const gaps: Array<CoverageGap> = ScheduleShiftUtil.getCoverageGaps(
|
|
208
|
+
shifts,
|
|
209
|
+
at("2024-01-01T09:00:00Z"),
|
|
210
|
+
at("2024-01-01T20:00:00Z"),
|
|
211
|
+
);
|
|
212
|
+
|
|
213
|
+
expect(gaps).toHaveLength(1);
|
|
214
|
+
expect(gaps[0]!.start).toEqual(at("2024-01-01T09:00:00Z"));
|
|
215
|
+
expect(gaps[0]!.end).toEqual(at("2024-01-01T12:00:00Z"));
|
|
216
|
+
});
|
|
217
|
+
|
|
218
|
+
test("reports gaps between shifts but not trailing time after the last", () => {
|
|
219
|
+
const shifts: Array<OnCallShift> = [
|
|
220
|
+
mkShift("A", at("2024-01-01T09:00:00Z"), at("2024-01-01T17:00:00Z")),
|
|
221
|
+
mkShift("A", at("2024-01-02T09:00:00Z"), at("2024-01-02T17:00:00Z")),
|
|
222
|
+
];
|
|
223
|
+
|
|
224
|
+
const gaps: Array<CoverageGap> = ScheduleShiftUtil.getCoverageGaps(
|
|
225
|
+
shifts,
|
|
226
|
+
at("2024-01-01T09:00:00Z"),
|
|
227
|
+
at("2024-01-03T00:00:00Z"),
|
|
228
|
+
);
|
|
229
|
+
|
|
230
|
+
expect(gaps).toHaveLength(1);
|
|
231
|
+
expect(gaps[0]!.start).toEqual(at("2024-01-01T17:00:00Z"));
|
|
232
|
+
expect(gaps[0]!.end).toEqual(at("2024-01-02T09:00:00Z"));
|
|
233
|
+
});
|
|
234
|
+
|
|
235
|
+
test("treats the whole window as a gap when there are no shifts", () => {
|
|
236
|
+
const gaps: Array<CoverageGap> = ScheduleShiftUtil.getCoverageGaps(
|
|
237
|
+
[],
|
|
238
|
+
at("2024-01-01T09:00:00Z"),
|
|
239
|
+
at("2024-01-02T09:00:00Z"),
|
|
240
|
+
);
|
|
241
|
+
|
|
242
|
+
expect(gaps).toHaveLength(1);
|
|
243
|
+
});
|
|
244
|
+
|
|
245
|
+
test("ignores the one-second boundaries between touching segments", () => {
|
|
246
|
+
const shifts: Array<OnCallShift> = [
|
|
247
|
+
mkShift("A", at("2024-01-01T09:00:00Z"), at("2024-01-01T17:00:00Z")),
|
|
248
|
+
mkShift("B", at("2024-01-01T17:00:01Z"), at("2024-01-02T09:00:00Z")),
|
|
249
|
+
];
|
|
250
|
+
|
|
251
|
+
const gaps: Array<CoverageGap> = ScheduleShiftUtil.getCoverageGaps(
|
|
252
|
+
shifts,
|
|
253
|
+
at("2024-01-01T09:00:00Z"),
|
|
254
|
+
at("2024-01-02T09:00:00Z"),
|
|
255
|
+
);
|
|
256
|
+
|
|
257
|
+
expect(gaps).toHaveLength(0);
|
|
258
|
+
});
|
|
259
|
+
});
|
|
260
|
+
});
|
package/Types/Date.ts
CHANGED
|
@@ -286,6 +286,52 @@ export default class OneUptimeDate {
|
|
|
286
286
|
.format(use12HourFormat ? "h:mm A" : "HH:mm");
|
|
287
287
|
}
|
|
288
288
|
|
|
289
|
+
/**
|
|
290
|
+
* Format a single instant as a human-readable "weekday, month day, time"
|
|
291
|
+
* string resolved in a specific IANA timezone (e.g. "America/New_York").
|
|
292
|
+
*
|
|
293
|
+
* Used by the on-call schedule summaries to render each shift's start / end in
|
|
294
|
+
* the SAME zone the rotation engine schedules people in, with a DST-aware zone
|
|
295
|
+
* abbreviation appended so the reader is never in doubt which wall clock the
|
|
296
|
+
* time refers to. When `timezone` is omitted the instant is formatted in the
|
|
297
|
+
* viewer's local zone (matching the rest of the local-time UI).
|
|
298
|
+
*/
|
|
299
|
+
public static getDateAsFormattedStringInTimezone(data: {
|
|
300
|
+
date: Date | string;
|
|
301
|
+
timezone?: string | undefined;
|
|
302
|
+
onlyShowDate?: boolean | undefined;
|
|
303
|
+
showWeekday?: boolean | undefined;
|
|
304
|
+
use12HourFormat?: boolean | undefined;
|
|
305
|
+
}): string {
|
|
306
|
+
const date: Date = this.fromString(data.date);
|
|
307
|
+
const use12HourFormat: boolean =
|
|
308
|
+
data.use12HourFormat ?? this.getUserPrefers12HourFormat();
|
|
309
|
+
|
|
310
|
+
const timePart: string = use12HourFormat ? "h:mm A" : "HH:mm";
|
|
311
|
+
const datePart: string = data.showWeekday
|
|
312
|
+
? "ddd, MMM D, YYYY"
|
|
313
|
+
: "MMM D, YYYY";
|
|
314
|
+
const formatString: string = data.onlyShowDate
|
|
315
|
+
? datePart
|
|
316
|
+
: `${datePart}, ${timePart}`;
|
|
317
|
+
|
|
318
|
+
if (!data.timezone) {
|
|
319
|
+
const local: moment.Moment = moment(date).local();
|
|
320
|
+
return (
|
|
321
|
+
local.format(formatString) +
|
|
322
|
+
(data.onlyShowDate ? "" : " " + this.getCurrentTimezoneString())
|
|
323
|
+
).trim();
|
|
324
|
+
}
|
|
325
|
+
|
|
326
|
+
const zoned: moment.Moment = moment(date).tz(data.timezone);
|
|
327
|
+
return (
|
|
328
|
+
zoned.format(formatString) +
|
|
329
|
+
(data.onlyShowDate
|
|
330
|
+
? ""
|
|
331
|
+
: " " + this.getZoneAbbrByTimezone(data.timezone as Timezone, date))
|
|
332
|
+
).trim();
|
|
333
|
+
}
|
|
334
|
+
|
|
289
335
|
/**
|
|
290
336
|
* Reinterpret the wall-clock components of `date` — read in the process /
|
|
291
337
|
* browser local zone — as the SAME wall-clock in `timezone`, and return that
|
|
@@ -0,0 +1,257 @@
|
|
|
1
|
+
import CalendarEvent from "../Calendar/CalendarEvent";
|
|
2
|
+
import OneUptimeDate from "../Date";
|
|
3
|
+
|
|
4
|
+
/*
|
|
5
|
+
* Turns the low-level calendar events produced by LayerUtil into the
|
|
6
|
+
* human-readable "shifts" the on-call schedule summaries render — e.g.
|
|
7
|
+
* "Alice is on call from Mon 9:00 AM to Tue 9:00 AM".
|
|
8
|
+
*
|
|
9
|
+
* LayerUtil emits one CalendarEvent per contiguous coverage segment. A single
|
|
10
|
+
* rotation turn can therefore be split into several events (a daily 9-5
|
|
11
|
+
* restriction produces one event per day), and the merged multi-layer schedule
|
|
12
|
+
* interleaves events from different users/layers. These helpers collapse those
|
|
13
|
+
* raw segments back into the shifts a human thinks in, and surface the gaps
|
|
14
|
+
* where nobody is on call.
|
|
15
|
+
*
|
|
16
|
+
* Everything here is pure and timezone-agnostic: it operates on the absolute
|
|
17
|
+
* instants already resolved by LayerUtil (which itself resolves restriction
|
|
18
|
+
* wall-clock windows in the schedule's timezone). Rendering code decides which
|
|
19
|
+
* timezone to display those instants in.
|
|
20
|
+
*/
|
|
21
|
+
|
|
22
|
+
/*
|
|
23
|
+
* The identity of the on-call person for an event. LayerUtil stores the user id
|
|
24
|
+
* in CalendarEvent.title before the UI relabels it, so callers pass the events
|
|
25
|
+
* straight through from getEvents/getMultiLayerEvents.
|
|
26
|
+
*/
|
|
27
|
+
export interface OnCallShift {
|
|
28
|
+
userId: string;
|
|
29
|
+
start: Date;
|
|
30
|
+
end: Date;
|
|
31
|
+
/*
|
|
32
|
+
* Total ACTIVE on-call seconds inside this shift, i.e. the sum of the covered
|
|
33
|
+
* segments' durations. For a contiguous shift this equals end - start. For a
|
|
34
|
+
* turn merged across off-hours (mergeAcrossGaps), it is the real time on call,
|
|
35
|
+
* which is smaller than the wall-clock span — so the summary can show honest
|
|
36
|
+
* coverage ("40h across the week") instead of the misleading span ("4d 8h").
|
|
37
|
+
*/
|
|
38
|
+
coverageSeconds: number;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
export interface CoverageGap {
|
|
42
|
+
start: Date;
|
|
43
|
+
end: Date;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
export interface CurrentAndNextShift {
|
|
47
|
+
current: OnCallShift | null;
|
|
48
|
+
next: OnCallShift | null;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
/*
|
|
52
|
+
* Two coverage segments are considered part of the same continuous shift when
|
|
53
|
+
* the gap between them is below this threshold. LayerUtil starts each following
|
|
54
|
+
* segment exactly one second after the previous one ends, so anything within a
|
|
55
|
+
* couple of minutes is "touching"; anything larger is a genuine off-hours gap
|
|
56
|
+
* (a restriction window closing, a rotation with no fallback layer, etc.).
|
|
57
|
+
*/
|
|
58
|
+
const CONTIGUITY_TOLERANCE_SECONDS: number = 90;
|
|
59
|
+
|
|
60
|
+
export default class ScheduleShiftUtil {
|
|
61
|
+
/*
|
|
62
|
+
* Collapse raw coverage events into shifts.
|
|
63
|
+
*
|
|
64
|
+
* - mergeAcrossGaps = false (default): only merges segments that actually
|
|
65
|
+
* touch, so genuine off-hours gaps remain visible. This is what the "final
|
|
66
|
+
* schedule" summary uses so it can honestly show when nobody is on call.
|
|
67
|
+
* - mergeAcrossGaps = true: merges every consecutive run of the same user
|
|
68
|
+
* into a single turn, absorbing the within-turn off-hours gaps. This is what
|
|
69
|
+
* the per-layer rotation summary uses so "Alice: Mon -> Fri" reads as one
|
|
70
|
+
* rotation turn instead of five separate day rows. The turn's coverageSeconds
|
|
71
|
+
* still reflects only the real active on-call time (e.g. 5x8h, not the full
|
|
72
|
+
* Mon->Fri wall-clock span), and the summary shows the layer's active-hours
|
|
73
|
+
* window as context.
|
|
74
|
+
*/
|
|
75
|
+
public static groupEventsIntoShifts(
|
|
76
|
+
events: Array<CalendarEvent>,
|
|
77
|
+
options?: { mergeAcrossGaps?: boolean | undefined } | undefined,
|
|
78
|
+
): Array<OnCallShift> {
|
|
79
|
+
if (!events || events.length === 0) {
|
|
80
|
+
return [];
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
const mergeAcrossGaps: boolean = Boolean(options?.mergeAcrossGaps);
|
|
84
|
+
|
|
85
|
+
// Work on a copy sorted by start time; never mutate the caller's array.
|
|
86
|
+
const sorted: Array<CalendarEvent> = [...events]
|
|
87
|
+
.filter((event: CalendarEvent) => {
|
|
88
|
+
return (
|
|
89
|
+
Boolean(event.title) && Boolean(event.start) && Boolean(event.end)
|
|
90
|
+
);
|
|
91
|
+
})
|
|
92
|
+
.sort((a: CalendarEvent, b: CalendarEvent) => {
|
|
93
|
+
if (OneUptimeDate.isBefore(a.start, b.start)) {
|
|
94
|
+
return -1;
|
|
95
|
+
}
|
|
96
|
+
if (OneUptimeDate.isAfter(a.start, b.start)) {
|
|
97
|
+
return 1;
|
|
98
|
+
}
|
|
99
|
+
return 0;
|
|
100
|
+
});
|
|
101
|
+
|
|
102
|
+
const shifts: Array<OnCallShift> = [];
|
|
103
|
+
|
|
104
|
+
for (const event of sorted) {
|
|
105
|
+
const userId: string = event.title;
|
|
106
|
+
const eventSeconds: number = OneUptimeDate.getDifferenceInSeconds(
|
|
107
|
+
event.end,
|
|
108
|
+
event.start,
|
|
109
|
+
);
|
|
110
|
+
const last: OnCallShift | undefined = shifts[shifts.length - 1];
|
|
111
|
+
|
|
112
|
+
const sameUser: boolean = Boolean(last) && last!.userId === userId;
|
|
113
|
+
|
|
114
|
+
const isContiguous: boolean =
|
|
115
|
+
Boolean(last) &&
|
|
116
|
+
OneUptimeDate.getDifferenceInSeconds(last!.end, event.start) <=
|
|
117
|
+
CONTIGUITY_TOLERANCE_SECONDS &&
|
|
118
|
+
OneUptimeDate.isOnOrBefore(last!.end, event.end);
|
|
119
|
+
|
|
120
|
+
/*
|
|
121
|
+
* Extend the previous shift when it belongs to the same user AND either we
|
|
122
|
+
* are merging whole turns, or the two segments physically touch. Only
|
|
123
|
+
* extend the end forward — never pull it backwards — so overlapping
|
|
124
|
+
* segments (possible after the multi-layer priority merge) can't shrink a
|
|
125
|
+
* shift. Accumulate each merged segment's own duration into coverageSeconds
|
|
126
|
+
* so a turn merged across off-hours reports its real active time on call.
|
|
127
|
+
*/
|
|
128
|
+
if (last && sameUser && (mergeAcrossGaps || isContiguous)) {
|
|
129
|
+
if (OneUptimeDate.isAfter(event.end, last.end)) {
|
|
130
|
+
last.end = event.end;
|
|
131
|
+
}
|
|
132
|
+
last.coverageSeconds += eventSeconds;
|
|
133
|
+
continue;
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
shifts.push({
|
|
137
|
+
userId,
|
|
138
|
+
start: event.start,
|
|
139
|
+
end: event.end,
|
|
140
|
+
coverageSeconds: eventSeconds,
|
|
141
|
+
});
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
return shifts;
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
/*
|
|
148
|
+
* The shift that contains `now` (the person on call right now) and the first
|
|
149
|
+
* shift that starts after `now` (up next). Either can be null: `current` is
|
|
150
|
+
* null when nobody is on call at this instant (a coverage gap), and `next` is
|
|
151
|
+
* null when the computed window does not reach far enough to include another
|
|
152
|
+
* shift.
|
|
153
|
+
*/
|
|
154
|
+
public static getCurrentAndNextShift(
|
|
155
|
+
shifts: Array<OnCallShift>,
|
|
156
|
+
now: Date,
|
|
157
|
+
): CurrentAndNextShift {
|
|
158
|
+
let current: OnCallShift | null = null;
|
|
159
|
+
let next: OnCallShift | null = null;
|
|
160
|
+
|
|
161
|
+
for (const shift of shifts) {
|
|
162
|
+
const hasStarted: boolean = OneUptimeDate.isOnOrBefore(shift.start, now);
|
|
163
|
+
const hasNotEnded: boolean = OneUptimeDate.isAfter(shift.end, now);
|
|
164
|
+
|
|
165
|
+
if (hasStarted && hasNotEnded) {
|
|
166
|
+
current = shift;
|
|
167
|
+
continue;
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
if (OneUptimeDate.isAfter(shift.start, now)) {
|
|
171
|
+
if (!next || OneUptimeDate.isBefore(shift.start, next.start)) {
|
|
172
|
+
next = shift;
|
|
173
|
+
}
|
|
174
|
+
}
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
return { current, next };
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
/*
|
|
181
|
+
* Periods inside [windowStart, windowEnd] during which no shift provides
|
|
182
|
+
* coverage. Includes a leading gap (from windowStart until the first shift
|
|
183
|
+
* begins) and gaps between consecutive shifts. Trailing time after the last
|
|
184
|
+
* shift is intentionally NOT reported as a gap: the summary window is finite,
|
|
185
|
+
* so "nothing scheduled past the window" is an artifact of the window, not a
|
|
186
|
+
* real coverage hole.
|
|
187
|
+
*
|
|
188
|
+
* `shifts` are assumed sorted by start (as returned by groupEventsIntoShifts).
|
|
189
|
+
*/
|
|
190
|
+
public static getCoverageGaps(
|
|
191
|
+
shifts: Array<OnCallShift>,
|
|
192
|
+
windowStart: Date,
|
|
193
|
+
windowEnd: Date,
|
|
194
|
+
): Array<CoverageGap> {
|
|
195
|
+
const gaps: Array<CoverageGap> = [];
|
|
196
|
+
|
|
197
|
+
if (OneUptimeDate.isOnOrAfter(windowStart, windowEnd)) {
|
|
198
|
+
return gaps;
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
/*
|
|
202
|
+
* A gap only "counts" when it is longer than the contiguity tolerance, so
|
|
203
|
+
* the one-second boundaries LayerUtil leaves between segments never show up
|
|
204
|
+
* as spurious coverage holes.
|
|
205
|
+
*/
|
|
206
|
+
const isRealGap: (start: Date, end: Date) => boolean = (
|
|
207
|
+
start: Date,
|
|
208
|
+
end: Date,
|
|
209
|
+
): boolean => {
|
|
210
|
+
return (
|
|
211
|
+
OneUptimeDate.isAfter(end, start) &&
|
|
212
|
+
OneUptimeDate.getDifferenceInSeconds(end, start) >
|
|
213
|
+
CONTIGUITY_TOLERANCE_SECONDS
|
|
214
|
+
);
|
|
215
|
+
};
|
|
216
|
+
|
|
217
|
+
if (shifts.length === 0) {
|
|
218
|
+
if (isRealGap(windowStart, windowEnd)) {
|
|
219
|
+
gaps.push({ start: windowStart, end: windowEnd });
|
|
220
|
+
}
|
|
221
|
+
return gaps;
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
// Leading gap: nobody on call from the window start until the first shift.
|
|
225
|
+
const firstShift: OnCallShift = shifts[0]!;
|
|
226
|
+
if (
|
|
227
|
+
OneUptimeDate.isAfter(firstShift.start, windowStart) &&
|
|
228
|
+
isRealGap(windowStart, firstShift.start)
|
|
229
|
+
) {
|
|
230
|
+
gaps.push({ start: windowStart, end: firstShift.start });
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
/*
|
|
234
|
+
* `coveredUntil` tracks the furthest point covered so far. Shifts can
|
|
235
|
+
* overlap (multi-layer merges), so we advance it monotonically and only
|
|
236
|
+
* record a gap when the next shift starts strictly after it.
|
|
237
|
+
*/
|
|
238
|
+
let coveredUntil: Date = firstShift.end;
|
|
239
|
+
|
|
240
|
+
for (let i: number = 1; i < shifts.length; i++) {
|
|
241
|
+
const shift: OnCallShift = shifts[i]!;
|
|
242
|
+
|
|
243
|
+
if (
|
|
244
|
+
OneUptimeDate.isAfter(shift.start, coveredUntil) &&
|
|
245
|
+
isRealGap(coveredUntil, shift.start)
|
|
246
|
+
) {
|
|
247
|
+
gaps.push({ start: coveredUntil, end: shift.start });
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
if (OneUptimeDate.isAfter(shift.end, coveredUntil)) {
|
|
251
|
+
coveredUntil = shift.end;
|
|
252
|
+
}
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
return gaps;
|
|
256
|
+
}
|
|
257
|
+
}
|
|
@@ -21,6 +21,12 @@ export interface ComponentProps {
|
|
|
21
21
|
id?: string | undefined;
|
|
22
22
|
events: Array<CalendarEvent>;
|
|
23
23
|
defaultCalendarView?: DefaultCalendarView;
|
|
24
|
+
/*
|
|
25
|
+
* Which date the calendar initially opens on. Defaults to "now". Callers that
|
|
26
|
+
* render events shifted into a display timezone pass the same-shifted "now" so
|
|
27
|
+
* the grid opens on that zone's current day rather than the browser's.
|
|
28
|
+
*/
|
|
29
|
+
defaultDate?: Date | undefined;
|
|
24
30
|
onRangeChange: (startAndEndTime: StartAndEndTime) => void;
|
|
25
31
|
}
|
|
26
32
|
|
|
@@ -38,9 +44,9 @@ const CalendarElement: FunctionComponent<ComponentProps> = (
|
|
|
38
44
|
): ReactElement => {
|
|
39
45
|
const { defaultDate } = useMemo(() => {
|
|
40
46
|
return {
|
|
41
|
-
defaultDate: OneUptimeDate.getCurrentDate(),
|
|
47
|
+
defaultDate: props.defaultDate || OneUptimeDate.getCurrentDate(),
|
|
42
48
|
};
|
|
43
|
-
}, []);
|
|
49
|
+
}, [props.defaultDate]);
|
|
44
50
|
|
|
45
51
|
const eventStyleGetter: EventPropGetter<any> = (
|
|
46
52
|
event: CalendarEvent,
|
|
@@ -38,10 +38,10 @@ export interface Chart {
|
|
|
38
38
|
exemplarPoints?: Array<ExemplarPoint> | undefined;
|
|
39
39
|
onExemplarClick?: ((exemplar: ExemplarPoint) => void) | undefined;
|
|
40
40
|
/**
|
|
41
|
-
* Optional control panel rendered
|
|
42
|
-
*
|
|
43
|
-
*
|
|
44
|
-
*
|
|
41
|
+
* Optional control panel rendered directly below the chart body. Used by
|
|
42
|
+
* per-series-grouped metric charts to surface a search box, a sort
|
|
43
|
+
* control, per-series toggles, and a "show all" escape hatch so the chart
|
|
44
|
+
* stays usable at thousands of unique label combinations.
|
|
45
45
|
*/
|
|
46
46
|
seriesControls?: ReactElement | undefined;
|
|
47
47
|
}
|
|
@@ -308,10 +308,10 @@ const ChartGroup: FunctionComponent<ComponentProps> = (
|
|
|
308
308
|
{chart.description}
|
|
309
309
|
</p>
|
|
310
310
|
)}
|
|
311
|
-
{chart.seriesControls ? chart.seriesControls : null}
|
|
312
311
|
<div className="flex-1 flex flex-col min-h-80">
|
|
313
312
|
{getChartContent(chart, index)}
|
|
314
313
|
</div>
|
|
314
|
+
{chart.seriesControls ? chart.seriesControls : null}
|
|
315
315
|
</div>
|
|
316
316
|
);
|
|
317
317
|
})}
|
|
@@ -47,6 +47,8 @@ let AIRun = class AIRun extends BaseModel {
|
|
|
47
47
|
this.userId = undefined;
|
|
48
48
|
this.conversation = undefined;
|
|
49
49
|
this.conversationId = undefined;
|
|
50
|
+
this.triggeredByIncidentId = undefined;
|
|
51
|
+
this.triggeredByAlertId = undefined;
|
|
50
52
|
this.startedAt = undefined;
|
|
51
53
|
this.completedAt = undefined;
|
|
52
54
|
this.lastHeartbeatAt = undefined;
|
|
@@ -286,6 +288,56 @@ __decorate([
|
|
|
286
288
|
}),
|
|
287
289
|
__metadata("design:type", ObjectID)
|
|
288
290
|
], AIRun.prototype, "conversationId", void 0);
|
|
291
|
+
__decorate([
|
|
292
|
+
ColumnAccessControl({
|
|
293
|
+
create: [],
|
|
294
|
+
read: [
|
|
295
|
+
Permission.ProjectOwner,
|
|
296
|
+
Permission.ProjectAdmin,
|
|
297
|
+
Permission.ProjectMember,
|
|
298
|
+
],
|
|
299
|
+
update: [],
|
|
300
|
+
}),
|
|
301
|
+
Index(),
|
|
302
|
+
TableColumn({
|
|
303
|
+
type: TableColumnType.ObjectID,
|
|
304
|
+
required: false,
|
|
305
|
+
canReadOnRelationQuery: true,
|
|
306
|
+
title: "Triggered By Incident ID",
|
|
307
|
+
description: "The incident that triggered this run (for autonomous investigations).",
|
|
308
|
+
}),
|
|
309
|
+
Column({
|
|
310
|
+
type: ColumnType.ObjectID,
|
|
311
|
+
nullable: true,
|
|
312
|
+
transformer: ObjectID.getDatabaseTransformer(),
|
|
313
|
+
}),
|
|
314
|
+
__metadata("design:type", ObjectID)
|
|
315
|
+
], AIRun.prototype, "triggeredByIncidentId", void 0);
|
|
316
|
+
__decorate([
|
|
317
|
+
ColumnAccessControl({
|
|
318
|
+
create: [],
|
|
319
|
+
read: [
|
|
320
|
+
Permission.ProjectOwner,
|
|
321
|
+
Permission.ProjectAdmin,
|
|
322
|
+
Permission.ProjectMember,
|
|
323
|
+
],
|
|
324
|
+
update: [],
|
|
325
|
+
}),
|
|
326
|
+
Index(),
|
|
327
|
+
TableColumn({
|
|
328
|
+
type: TableColumnType.ObjectID,
|
|
329
|
+
required: false,
|
|
330
|
+
canReadOnRelationQuery: true,
|
|
331
|
+
title: "Triggered By Alert ID",
|
|
332
|
+
description: "The alert that triggered this run (for autonomous investigations).",
|
|
333
|
+
}),
|
|
334
|
+
Column({
|
|
335
|
+
type: ColumnType.ObjectID,
|
|
336
|
+
nullable: true,
|
|
337
|
+
transformer: ObjectID.getDatabaseTransformer(),
|
|
338
|
+
}),
|
|
339
|
+
__metadata("design:type", ObjectID)
|
|
340
|
+
], AIRun.prototype, "triggeredByAlertId", void 0);
|
|
289
341
|
__decorate([
|
|
290
342
|
ColumnAccessControl({
|
|
291
343
|
create: [],
|