Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions client/src/Hooks/useMonitorForm.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,11 @@ const getBaseDefaults = (data?: Monitor | null) => ({
notifications: data?.notifications || [],
statusWindowSize: data?.statusWindowSize || 5,
statusWindowThreshold: data?.statusWindowThreshold || 60,

escalationEnabled: data?.escalationEnabled ?? false,
escalationDelay: data?.escalationDelay ?? 60000,
escalationNotifications: data?.escalationNotifications || [],

geoCheckEnabled: data?.geoCheckEnabled ?? false,
geoCheckLocations: data?.geoCheckLocations || [],
geoCheckInterval: data?.geoCheckInterval || 300000,
Expand Down
164 changes: 164 additions & 0 deletions client/src/Pages/CreateMonitor/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -211,12 +211,25 @@ const CreateMonitorPage = () => {
const watchedType = watch("type") as MonitorType;

const watchedUseAdvancedMatching = watch("useAdvancedMatching") as boolean;
const watchedEscalationEnabled = watch("escalationEnabled") as boolean;
const watchedEscalationDelay = watch("escalationDelay") as number | undefined;
const watchGeoCheckEnabled = watch("geoCheckEnabled") as boolean;
const [escalationDelayInput, setEscalationDelayInput] = useState("1");
const [isEscalationDelayFocused, setIsEscalationDelayFocused] = useState(false);

useEffect(() => {
clearErrors();
}, [watchedType, clearErrors]);

useEffect(() => {
if (isEscalationDelayFocused) {
return;
}

const minutes = Math.max(1, Math.round((watchedEscalationDelay ?? 60000) / 60000));
setEscalationDelayInput(String(minutes));
}, [watchedEscalationDelay, isEscalationDelayFocused]);

const generalSettingsConfig = useMemo(
() => getGeneralSettingsConfig(watchedType, t),
[watchedType, t]
Expand Down Expand Up @@ -765,6 +778,157 @@ const CreateMonitorPage = () => {
}
/>

<ConfigBox
title={t("pages.createMonitor.form.escalation.title")}
subtitle={t("pages.createMonitor.form.escalation.description")}
rightContent={
<Stack spacing={theme.spacing(LAYOUT.MD)}>
<Controller
name="escalationEnabled"
control={control}
render={({ field }) => (
<Stack
direction="row"
alignItems="center"
spacing={theme.spacing(SPACING.LG)}
>
<Switch
checked={field.value ?? false}
onChange={(e) => field.onChange(e.target.checked)}
/>
<Typography>
{t("pages.createMonitor.form.escalation.option.enabled.label")}
</Typography>
</Stack>
)}
/>

{watchedEscalationEnabled && (
<>
<Controller
name="escalationDelay"
control={control}
render={({ field, fieldState }) => (
<TextField
type="number"
fieldLabel={t("pages.createMonitor.form.escalation.option.delay.label")}
value={escalationDelayInput}
onFocus={() => setIsEscalationDelayFocused(true)}
onBlur={() => {
setIsEscalationDelayFocused(false);
const parsedMinutes = Number(escalationDelayInput);

if (
escalationDelayInput.trim() === "" ||
Number.isNaN(parsedMinutes) ||
parsedMinutes <= 0
) {
setEscalationDelayInput("1");
field.onChange(60000);
return;
}

const minutes = Math.max(1, Math.round(parsedMinutes));
setEscalationDelayInput(String(minutes));
field.onChange(minutes * 60000);
}}
onChange={(e) => {
const rawValue = e.target.value;
setEscalationDelayInput(rawValue);

if (rawValue.trim() === "") {
return;
}

const parsedMinutes = Number(rawValue);

if (Number.isNaN(parsedMinutes)) {
return;
}

if (parsedMinutes <= 0) {
field.onChange(60000);
return;
}

const minutes = Math.max(1, Math.round(parsedMinutes));
field.onChange(minutes * 60000);
}}
inputProps={{ min: 0, step: 1 }}
fullWidth
error={!!fieldState.error}
helperText={fieldState.error?.message ?? ""}
/>
)}
/>

<Controller
name="escalationNotifications"
control={control}
render={({ field }) => {
const notificationOptions = (notifications ?? []).map((n) => ({
...n,
name: n.notificationName,
}));
const selectedNotifications = notificationOptions.filter((n) =>
(field.value ?? []).includes(n.id)
);

return (
<Stack spacing={theme.spacing(LAYOUT.MD)}>
<Autocomplete
multiple
options={notificationOptions}
value={selectedNotifications}
getOptionLabel={(option) => option.name}
onChange={(_: unknown, newValue: typeof notificationOptions) => {
field.onChange(newValue.map((n) => n.id));
}}
isOptionEqualToValue={(option, value) => option.id === value.id}
/>
{selectedNotifications.length > 0 && (
<Stack
flex={1}
width="100%"
>
{selectedNotifications.map((notification, index) => (
<Stack
direction="row"
alignItems="center"
key={notification.id}
width="100%"
>
<Typography flexGrow={1}>
{notification.notificationName}
</Typography>
<IconButton
size="small"
onClick={() => {
field.onChange(
(field.value ?? []).filter(
(id: string) => id !== notification.id
)
);
}}
aria-label="Remove escalation notification"
>
<Trash2 size={16} />
</IconButton>
{index < selectedNotifications.length - 1 && <Divider />}
</Stack>
))}
</Stack>
)}
</Stack>
);
}}
/>
</>
)}
</Stack>
}
/>

{(watchedType === "http" ||
watchedType === "grpc" ||
watchedType === "websocket") && (
Expand Down
4 changes: 4 additions & 0 deletions client/src/Types/Monitor.ts
Original file line number Diff line number Diff line change
Expand Up @@ -79,6 +79,10 @@ export interface Monitor {
recentChecks: CheckSnapshot[];
createdAt: string;
updatedAt: string;

escalationEnabled: boolean;
escalationDelay: number;
escalationNotifications: string[];
}

export type MonitorWithChecks = Monitor;
Expand Down
8 changes: 8 additions & 0 deletions client/src/Validation/monitor.ts
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,14 @@ const baseSchema = z.object({
.number({ message: "Threshold percentage is required" })
.min(1, "Incident percentage must be at least 1")
.max(100, "Incident percentage must be at most 100"),

escalationEnabled: z.boolean().optional(),
escalationDelay: z
.number()
.min(60000, "Escalation delay must be at least 1 minutes")
.optional(),
escalationNotifications: z.array(z.string()).optional(),

geoCheckEnabled: z.boolean().optional(),
geoCheckLocations: z.array(z.enum(GeoContinents)).optional(),
geoCheckInterval: z
Expand Down
20 changes: 20 additions & 0 deletions client/src/locales/en.json
Original file line number Diff line number Diff line change
Expand Up @@ -543,6 +543,26 @@
"description": "Select the notification channels you want to use",
"title": "Notifications"
},
"escalation": {
"title": "Escalation Rules",
"description": "Configure an escalation notification if a monitor stays down for a defined amount of time.",
"option": {
"enabled": {
"label": "Enable escalation"
},
"delay": {
"label": "Escalate after",
"value": {
"oneMinute": "1 minute",
"fiveMinutes": "5 minutes",
"tenMinutes": "10 minutes",
"fifteenMinutes": "15 minutes",
"thirtyMinutes": "30 minutes",
"sixtyMinutes": "60 minutes"
}
}
}
},
"type": {
"description": "Select the type of check to perform",
"optionDockerDescription": "Use Docker to monitor if a container is running.",
Expand Down
8 changes: 8 additions & 0 deletions server/src/db/models/Incident.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,8 @@ type IncidentDocumentBase = Omit<Incident, "id" | "monitorId" | "teamId" | "reso
endTime: Date | null;
createdAt: Date;
updatedAt: Date;

escalatedAt?: Date | null;
};

export interface IncidentDocument extends IncidentDocumentBase {
Expand Down Expand Up @@ -72,6 +74,12 @@ const IncidentSchema = new Schema<IncidentDocument>(
type: String,
default: null,
},

escalatedAt: {
type: Date,
default: null,
},

},
{ timestamps: true }
);
Expand Down
22 changes: 21 additions & 1 deletion server/src/db/models/Monitor.ts
Original file line number Diff line number Diff line change
Expand Up @@ -18,11 +18,14 @@ type CheckSnapshotDocument = Omit<CheckSnapshot, "createdAt"> & { createdAt: Dat

type MonitorDocumentBase = Omit<
Monitor,
"id" | "userId" | "teamId" | "notifications" | "selectedDisks" | "statusWindow" | "recentChecks" | "createdAt" | "updatedAt"
"id" | "userId" | "teamId" | "notifications" | "escalationNotifications" | "selectedDisks" | "statusWindow" | "recentChecks" | "createdAt" | "updatedAt"
> & {
statusWindow: boolean[];
recentChecks: CheckSnapshotDocument[];
notifications: Types.ObjectId[];

escalationNotifications: Types.ObjectId[];

selectedDisks: string[];
matchMethod?: MonitorMatchMethod;
};
Expand Down Expand Up @@ -284,6 +287,23 @@ const MonitorSchema = new Schema<MonitorDocument>(
ref: "Notification",
},
],

escalationEnabled: {
type: Boolean,
default: false,
},
escalationDelay: {
type: Number,
default: 60000,
},
escalationNotifications: [
{
type: Schema.Types.ObjectId,
ref: "Notification",
},
],


secret: {
type: String,
},
Expand Down
8 changes: 8 additions & 0 deletions server/src/repositories/monitors/MongoMonitorsRepository.ts
Original file line number Diff line number Diff line change
Expand Up @@ -351,6 +351,7 @@ class MongoMonitorsRepository implements IMonitorsRepository {
};

const notificationIds = (doc.notifications ?? []).map((notification) => toStringId(notification));
const escalationNotificationIds = (doc.escalationNotifications ?? []).map((notification) => toStringId(notification));

return {
id: toStringId(doc._id),
Expand All @@ -374,6 +375,9 @@ class MongoMonitorsRepository implements IMonitorsRepository {
interval: doc.interval,
uptimePercentage: doc.uptimePercentage ?? undefined,
notifications: notificationIds,
escalationEnabled: doc.escalationEnabled ?? false,
escalationDelay: doc.escalationDelay ?? 60000,
escalationNotifications: escalationNotificationIds,
secret: doc.secret ?? undefined,
cpuAlertThreshold: doc.cpuAlertThreshold,
cpuAlertCounter: doc.cpuAlertCounter,
Expand Down Expand Up @@ -410,6 +414,7 @@ class MongoMonitorsRepository implements IMonitorsRepository {
};

const notificationIds = (doc.notifications ?? []).map((notification: unknown) => toStringId(notification));
const escalationNotificationIds = (doc.escalationNotifications ?? []).map((notification: unknown) => toStringId(notification));

return {
id: toStringId(doc._id),
Expand All @@ -433,6 +438,9 @@ class MongoMonitorsRepository implements IMonitorsRepository {
interval: doc.interval,
uptimePercentage: doc.uptimePercentage ?? undefined,
notifications: notificationIds,
escalationEnabled: doc.escalationEnabled ?? false,
escalationDelay: doc.escalationDelay ?? 60000,
escalationNotifications: escalationNotificationIds,
secret: doc.secret ?? undefined,
cpuAlertThreshold: doc.cpuAlertThreshold,
cpuAlertCounter: doc.cpuAlertCounter,
Expand Down
3 changes: 3 additions & 0 deletions server/src/service/business/monitorService.ts
Original file line number Diff line number Diff line change
Expand Up @@ -567,6 +567,9 @@ export class MonitorService implements IMonitorService {
id: "",
teamId,
userId,
escalationEnabled: (monitor as Partial<Monitor>).escalationEnabled ?? false,
escalationDelay: (monitor as Partial<Monitor>).escalationDelay ?? 60000,
escalationNotifications: (monitor as Partial<Monitor>).escalationNotifications ?? [],
recentChecks: [],
createdAt: "",
updatedAt: "",
Expand Down
Loading