Skip to content
Merged
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
1 change: 1 addition & 0 deletions client/src/Hooks/useMonitorForm.ts
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@ const getBaseDefaults = (data?: Monitor | null) => ({
geoCheckEnabled: data?.geoCheckEnabled ?? false,
geoCheckLocations: data?.geoCheckLocations || [],
geoCheckInterval: data?.geoCheckInterval || 300000,
escalations: data?.escalations || [],
});

export const useMonitorForm = ({
Expand Down
149 changes: 148 additions & 1 deletion client/src/Pages/CreateMonitor/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@ import { useMemo, useState } from "react";
import { useEffect } from "react";
import { logger } from "@/Utils/logger";
import { useParams, useLocation, useNavigate } from "react-router";
import { useForm, Controller } from "react-hook-form";
import { useForm, Controller, useFieldArray } from "react-hook-form";
import { zodResolver } from "@hookform/resolvers/zod";
import { useTheme } from "@mui/material";
import Stack from "@mui/material/Stack";
Expand Down Expand Up @@ -161,6 +161,140 @@ const getGeneralSettingsConfig = (
return configs[type] || configs.http;
};

interface EscalationsSectionProps {
control: ReturnType<typeof useForm<MonitorFormData>>["control"];
notifications: Notification[];
t: (key: string) => string;
theme: ReturnType<typeof useTheme>;
}

const EscalationsSection = ({ control, notifications, t, theme }: EscalationsSectionProps) => {
const { fields, append, remove } = useFieldArray({
control,
name: "escalations",
});

const notificationOptions = notifications.map((n) => ({
...n,
name: n.notificationName,
}));

return (
<Stack spacing={theme.spacing(LAYOUT.MD)}>
{fields.map((field, index) => (
<Stack
key={field.id}
spacing={theme.spacing(LAYOUT.MD)}
sx={{
p: theme.spacing(LAYOUT.MD),
border: `1px solid ${theme.palette.divider}`,
borderRadius: 1,
}}
>
<Stack
direction="row"
alignItems="center"
justifyContent="space-between"
>
<Typography fontWeight={600}>
{t("pages.createMonitor.form.escalations.title")} {index + 1}
</Typography>
<IconButton
size="small"
onClick={() => remove(index)}
aria-label={t("pages.createMonitor.form.escalations.removeButton")}
>
<Trash2 size={16} />
</IconButton>
</Stack>
<Controller
name={`escalations.${index}.delay`}
control={control}
render={({ field: f, fieldState }) => (
<TextField
{...f}
type="number"
fieldLabel={t("pages.createMonitor.form.escalations.delay.label")}
placeholder={t("pages.createMonitor.form.escalations.delay.placeholder")}
inputProps={{ min: 1 }}
onChange={(e) => f.onChange(Number(e.target.value))}
error={!!fieldState.error}
helperText={fieldState.error?.message ?? ""}
fullWidth
/>
)}
/>
<Controller
name={`escalations.${index}.notifications`}
control={control}
render={({ field: f, fieldState }) => {
const selected = notificationOptions.filter((n) =>
(f.value ?? []).includes(n.id)
);
return (
<Stack spacing={theme.spacing(LAYOUT.MD)}>
<Autocomplete
multiple
options={notificationOptions}
value={selected}
getOptionLabel={(option) => option.name}
onChange={(_: unknown, newValue: typeof notificationOptions) => {
f.onChange(newValue.map((n) => n.id));
}}
isOptionEqualToValue={(option, value) => option.id === value.id}
fieldLabel={t(
"pages.createMonitor.form.escalations.notifications.label"
)}
/>
{selected.length > 0 && (
<Stack flex={1} width="100%">
{selected.map((notification, ni) => (
<Stack
direction="row"
alignItems="center"
key={notification.id}
width="100%"
>
<Typography flexGrow={1}>
{notification.notificationName}
</Typography>
<IconButton
size="small"
onClick={() => {
f.onChange(
(f.value ?? []).filter(
(id: string) => id !== notification.id
)
);
}}
aria-label={t(
"pages.createMonitor.form.escalations.removeButton"
)}
>
<Trash2 size={16} />
</IconButton>
{ni < selected.length - 1 && <Divider />}
</Stack>
))}
</Stack>
)}
</Stack>
);
}}
/>
</Stack>
))}
<Button
variant="outlined"
color="primary"
onClick={() => append({ delay: 60, notifications: [] })}
>
{t("pages.createMonitor.form.escalations.addButton")}
</Button>
</Stack>
);
};

const CreateMonitorPage = () => {
const theme = useTheme();
const { t } = useTranslation();
Expand Down Expand Up @@ -765,6 +899,19 @@ const CreateMonitorPage = () => {
}
/>

<ConfigBox
title={t("pages.createMonitor.form.escalations.title")}
subtitle={t("pages.createMonitor.form.escalations.description")}
rightContent={
<EscalationsSection
control={control}
notifications={notifications ?? []}
t={t}
theme={theme}
/>
}
/>

{(watchedType === "http" ||
watchedType === "grpc" ||
watchedType === "websocket") && (
Expand Down
6 changes: 6 additions & 0 deletions client/src/Types/Monitor.ts
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,11 @@ export type MonitorStatus = (typeof MonitorStatuses)[number];

export type MonitorMatchMethod = "equal" | "include" | "regex" | "";

export interface EscalationLevel {
delay: number;
notifications: string[];
}

export interface Monitor {
id: string;
userId: string;
Expand Down Expand Up @@ -76,6 +81,7 @@ export interface Monitor {
geoCheckEnabled?: boolean;
geoCheckLocations?: GeoContinent[];
geoCheckInterval?: number;
escalations?: EscalationLevel[];
recentChecks: CheckSnapshot[];
createdAt: string;
updatedAt: string;
Expand Down
10 changes: 10 additions & 0 deletions client/src/Validation/monitor.ts
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,16 @@ const baseSchema = z.object({
.number()
.min(300000, "Interval must be at least 5 minutes")
.optional(),
escalations: z
.array(
z.object({
delay: z.number().int().min(1, "Delay must be at least 1 minute"),
notifications: z
.array(z.string())
.min(1, "At least one notification channel is required"),
})
)
.optional(),
});

// HTTP monitor schema
Expand Down
15 changes: 15 additions & 0 deletions client/src/locales/en.json
Original file line number Diff line number Diff line change
Expand Up @@ -543,6 +543,21 @@
"description": "Select the notification channels you want to use",
"title": "Notifications"
},
"escalations": {
"title": "Escalated Notifications",
"description": "Define time-delayed alert levels that fire if an incident persists. Each level can target different notification channels.",
"addButton": "Add Escalation Level",
"removeButton": "Remove",
"delay": {
"label": "Alert after (minutes)",
"placeholder": "e.g. 30",
"unit": "min"
},
"notifications": {
"label": "Notification channels",
"placeholder": "Select channels for this level"
}
},
"type": {
"description": "Select the type of check to perform",
"optionDockerDescription": "Use Docker to monitor if a container is running.",
Expand Down
4 changes: 4 additions & 0 deletions server/src/db/models/Incident.ts
Original file line number Diff line number Diff line change
Expand Up @@ -72,6 +72,10 @@ const IncidentSchema = new Schema<IncidentDocument>(
type: String,
default: null,
},
firedEscalations: {
type: [Number],
default: [],
},
},
{ timestamps: true }
);
Expand Down
20 changes: 19 additions & 1 deletion server/src/db/models/Monitor.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,15 +16,21 @@ import type {

type CheckSnapshotDocument = Omit<CheckSnapshot, "createdAt"> & { createdAt: Date };

type EscalationLevelDocument = {
delay: number;
notifications: Types.ObjectId[];
};

type MonitorDocumentBase = Omit<
Monitor,
"id" | "userId" | "teamId" | "notifications" | "selectedDisks" | "statusWindow" | "recentChecks" | "createdAt" | "updatedAt"
"id" | "userId" | "teamId" | "notifications" | "selectedDisks" | "statusWindow" | "recentChecks" | "createdAt" | "updatedAt" | "escalations"
> & {
statusWindow: boolean[];
recentChecks: CheckSnapshotDocument[];
notifications: Types.ObjectId[];
selectedDisks: string[];
matchMethod?: MonitorMatchMethod;
escalations: EscalationLevelDocument[];
};

interface MonitorDocument extends MonitorDocumentBase {
Expand Down Expand Up @@ -198,6 +204,14 @@ const checkSnapshotSchema = new Schema<CheckSnapshotDocument>(
{ _id: false }
);

const escalationLevelSchema = new Schema<EscalationLevelDocument>(
{
delay: { type: Number, required: true, min: 1 },
notifications: [{ type: Schema.Types.ObjectId, ref: "Notification" }],
},
{ _id: false }
);

const MonitorSchema = new Schema<MonitorDocument>(
{
userId: {
Expand Down Expand Up @@ -351,6 +365,10 @@ const MonitorSchema = new Schema<MonitorDocument>(
type: Number,
default: 300000,
},
escalations: {
type: [escalationLevelSchema],
default: [],
},
recentChecks: {
type: [checkSnapshotSchema],
default: [],
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -60,6 +60,7 @@ class MongoIncidentRepository implements IIncidentsRepository {
resolvedBy: doc.resolvedBy ? this.toStringId(doc.resolvedBy) : null,
resolvedByEmail: doc.resolvedByEmail ?? null,
comment: doc.comment ?? null,
firedEscalations: doc.firedEscalations ?? [],
createdAt: this.toDateString(doc.createdAt),
updatedAt: this.toDateString(doc.updatedAt),
};
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 @@ -391,6 +391,10 @@ class MongoMonitorsRepository implements IMonitorsRepository {
geoCheckEnabled: doc.geoCheckEnabled ?? false,
geoCheckLocations: doc.geoCheckLocations ?? [],
geoCheckInterval: doc.geoCheckInterval ?? 300000,
escalations: (doc.escalations ?? []).map((esc) => ({
delay: esc.delay,
notifications: (esc.notifications ?? []).map((n) => toStringId(n)),
})),
createdAt: toDateString(doc.createdAt),
updatedAt: toDateString(doc.updatedAt),
};
Expand Down Expand Up @@ -450,6 +454,10 @@ class MongoMonitorsRepository implements IMonitorsRepository {
geoCheckEnabled: doc.geoCheckEnabled ?? false,
geoCheckLocations: doc.geoCheckLocations ?? [],
geoCheckInterval: doc.geoCheckInterval ?? 300000,
escalations: (doc.escalations ?? []).map((esc) => ({
delay: esc.delay,
notifications: (esc.notifications ?? []).map((n: unknown) => toStringId(n)),
})),
createdAt: toDateString(doc.createdAt),
updatedAt: toDateString(doc.updatedAt),
};
Expand Down
1 change: 1 addition & 0 deletions server/src/service/business/monitorService.ts
Original file line number Diff line number Diff line change
Expand Up @@ -568,6 +568,7 @@ export class MonitorService implements IMonitorService {
teamId,
userId,
recentChecks: [],
escalations: [],
createdAt: "",
updatedAt: "",
}));
Expand Down
Loading