Skip to content
Closed
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
21 changes: 20 additions & 1 deletion client/src/Hooks/useMonitorForm.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,11 +7,30 @@ interface UseMonitorFormOptions {
defaultType?: MonitorType;
}

const normalizeNotifications = (notifications?: unknown[]) => {
if (!Array.isArray(notifications)) {
return [];
}
return notifications.map((notification) => {
if (typeof notification === "string") {
return { channelId: notification, delayMinutes: 0 };
}
const item = notification as {
channelId?: unknown;
delayMinutes?: unknown;
};
return {
channelId: typeof item.channelId === "string" ? item.channelId : "",
delayMinutes: typeof item.delayMinutes === "number" ? item.delayMinutes : 0,
};
});
};

const getBaseDefaults = (data?: Monitor | null) => ({
name: data?.name || "",
description: data?.description || "",
interval: data?.interval || 60000,
notifications: data?.notifications || [],
notifications: normalizeNotifications(data?.notifications),
statusWindowSize: data?.statusWindowSize || 5,
statusWindowThreshold: data?.statusWindowThreshold || 60,
geoCheckEnabled: data?.geoCheckEnabled ?? false,
Expand Down
167 changes: 102 additions & 65 deletions client/src/Pages/CreateMonitor/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -705,71 +705,108 @@ const CreateMonitorPage = () => {
name="notifications"
control={control}
render={({ field }) => {
// Map notifications to have 'name' property for Autocomplete
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 notification"
>
<Trash2 size={16} />
</IconButton>
{index < selectedNotifications.length - 1 && <Divider />}
</Stack>
))}
</Stack>
)}
</Stack>
);
}}
/>
}
/>

{(watchedType === "http" ||
watchedType === "grpc" ||
watchedType === "websocket") && (
<ConfigBox
title={t("pages.createMonitor.form.ignoreTls.title")}
// Map notifications to have 'name' property for Autocomplete
const notificationOptions = (notifications ?? []).map((n) => ({
...n,
name: n.notificationName,
}));
const selectedNotifications = notificationOptions.filter((notification) =>
(field.value ?? []).some(
(item: { channelId: string; delayMinutes: number }) =>
item.channelId === notification.id
)
);
return (
<Stack spacing={theme.spacing(LAYOUT.MD)}>
<Autocomplete
multiple
options={notificationOptions}
value={selectedNotifications}
getOptionLabel={(option) => option.name}
onChange={(_: unknown, newValue: typeof notificationOptions) => {
const nextValues = newValue.map((notification) => {
const existing = (field.value ?? []).find(
(item: { channelId: string; delayMinutes: number }) =>
item.channelId === notification.id
);
return {
channelId: notification.id,
delayMinutes: existing?.delayMinutes ?? 0,
};
});
field.onChange(nextValues);
}}
isOptionEqualToValue={(option, value) => option.id === value.id}
/>
{selectedNotifications.length > 0 && (
<Stack flex={1} width="100%">
{selectedNotifications.map((notification, index) => {
const selectedRule = (field.value ?? []).find(
(item: { channelId: string; delayMinutes: number }) =>
item.channelId === notification.id
);
const delayMinutes = selectedRule?.delayMinutes ?? 0;
return (
<Stack
direction="row"
alignItems="center"
key={notification.id}
width="100%"
spacing={theme.spacing(LAYOUT.MD)}
>
<Typography flexGrow={1}>
{notification.notificationName}
</Typography>
<TextField
type="number"
fieldLabel={t(
"pages.createMonitor.form.notifications.option.delayMinutes.label"
)}
value={delayMinutes}
InputProps={{ inputProps: { min: 0 } }}
onChange={(event) => {
const value = Number(event.target.value) || 0;
field.onChange(
(field.value ?? []).map(
(item: { channelId: string; delayMinutes: number }) =>
item.channelId === notification.id
? { ...item, delayMinutes: value }
: item
)
);
}}
/>
<IconButton
size="small"
onClick={() => {
field.onChange(
(field.value ?? []).filter(
(item: { channelId: string }) =>
item.channelId !== notification.id
)
);
}}
aria-label="Remove notification"
>
<Trash2 size={16} />
</IconButton>
{index < selectedNotifications.length - 1 && <Divider />}
</Stack>
);
})}
</Stack>
)}
</Stack>
);
}}
/>
}
/>
{(watchedType === "http" ||
watchedType === "grpc" ||
watchedType === "websocket") && (
<ConfigBox
title={t("pages.createMonitor.form.ignoreTls.title")}
subtitle={t("pages.createMonitor.form.ignoreTls.description")}
rightContent={
<Controller
Expand Down
7 changes: 6 additions & 1 deletion 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 MonitorNotificationRule {
channelId: string;
delayMinutes: number;
}

export interface Monitor {
id: string;
userId: string;
Expand All @@ -59,7 +64,7 @@ export interface Monitor {
isActive: boolean;
interval: number;
uptimePercentage?: number;
notifications: string[];
notifications: MonitorNotificationRule[];
secret?: string;
cpuAlertThreshold: number;
cpuAlertCounter: number;
Expand Down
7 changes: 6 additions & 1 deletion client/src/Validation/monitor.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,14 +5,19 @@ import { GeoContinents } from "@/Types/GeoCheck";
const urlSchema = z.url({ message: "Please enter a valid URL" });

// Common base schema for all monitor types
const notificationRuleSchema = z.object({
channelId: z.string().min(1, "Notification channel is required"),
delayMinutes: z.number().min(0, "Delay must be 0 or more"),
});

const baseSchema = z.object({
name: z
.string()
.min(1, "Monitor name is required")
.max(50, "Monitor name must be at most 50 characters"),
description: z.string().optional(),
interval: z.number().min(15000, "Interval must be at least 15 seconds"),
notifications: z.array(z.string()),
notifications: z.array(notificationRuleSchema),
statusWindowSize: z
.number({ message: "Status window size is required" })
.min(1, "Status window size must be at least 1")
Expand Down
8 changes: 7 additions & 1 deletion client/src/locales/en.json
Original file line number Diff line number Diff line change
Expand Up @@ -540,7 +540,13 @@
"title": "Incidents"
},
"notifications": {
"description": "Select the notification channels you want to use",
"description": "Select the notification channels you want to use and configure escalation delays for each.",
"option": {
"delayMinutes": {
"label": "Escalation delay (minutes)",
"description": "How many minutes to wait before sending this notification after an incident starts."
}
},
"title": "Notifications"
},
"type": {
Expand Down
1 change: 1 addition & 0 deletions server/src/config/services.ts
Original file line number Diff line number Diff line change
Expand Up @@ -234,6 +234,7 @@ export const initializeServices = async ({
const notificationsService = new NotificationsService(
notificationsRepository,
monitorsRepository,
incidentsRepository,
webhookProvider,
emailProvider,
slackProvider,
Expand Down
2 changes: 1 addition & 1 deletion server/src/controllers/notificationController.ts
Original file line number Diff line number Diff line change
Expand Up @@ -142,7 +142,7 @@ class NotificationController implements INotificationController {
const teamId = requireTeamId(req.user?.teamId);

const monitor = await this.monitorsRepository.findById(validatedBody.monitorId, teamId);
const notifications = monitor.notifications || [];
const notifications = (monitor.notifications || []).map((notification) => notification.channelId);

if (notifications.length === 0) {
throw new AppError({ message: "No notifications", status: 400 });
Expand Down
29 changes: 29 additions & 0 deletions server/src/db/models/Incident.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,11 @@ type IncidentDocumentBase = Omit<Incident, "id" | "monitorId" | "teamId" | "reso
endTime: Date | null;
createdAt: Date;
updatedAt: Date;
acknowledged: boolean;
acknowledgedBy?: Types.ObjectId | null;
acknowledgedAt: Date | null;
acknowledgedByEmail?: string | null;
escalatedNotificationIds: string[];
};

export interface IncidentDocument extends IncidentDocumentBase {
Expand Down Expand Up @@ -72,6 +77,28 @@ const IncidentSchema = new Schema<IncidentDocument>(
type: String,
default: null,
},
escalatedNotificationIds: {
type: [String],
default: [],
},
acknowledged: {
type: Boolean,
default: false,
index: true,
},
acknowledgedBy: {
type: Schema.Types.ObjectId,
ref: "User",
default: null,
},
acknowledgedAt: {
type: Date,
default: null,
},
acknowledgedByEmail: {
type: String,
default: null,
},
},
{ timestamps: true }
);
Expand All @@ -83,6 +110,8 @@ IncidentSchema.index({ status: 1, startTime: -1 });
IncidentSchema.index({ resolutionType: 1, status: 1 });
IncidentSchema.index({ resolvedBy: 1, status: 1 });
IncidentSchema.index({ createdAt: -1 });
IncidentSchema.index({ acknowledged: 1, status: 1 });
IncidentSchema.index({ acknowledgedBy: 1, status: 1 });

const IncidentModel = model<IncidentDocument>("Incident", IncidentSchema);

Expand Down
16 changes: 12 additions & 4 deletions server/src/db/models/Monitor.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import { Schema, model, Types } from "mongoose";
import type { Monitor, MonitorMatchMethod, CheckSnapshot } from "@/types/monitor.js";
import type { Monitor, MonitorMatchMethod, CheckSnapshot, EscalationNotification } from "@/types/monitor.js";
import { MonitorTypes, MonitorStatuses } from "@/types/monitor.js";
import type {
CheckAudits,
Expand All @@ -22,7 +22,7 @@ type MonitorDocumentBase = Omit<
> & {
statusWindow: boolean[];
recentChecks: CheckSnapshotDocument[];
notifications: Types.ObjectId[];
notifications: EscalationNotification[];
selectedDisks: string[];
matchMethod?: MonitorMatchMethod;
};
Expand Down Expand Up @@ -280,8 +280,16 @@ const MonitorSchema = new Schema<MonitorDocument>(
},
notifications: [
{
type: Schema.Types.ObjectId,
ref: "Notification",
delayMinutes: {
type: Number,
required: true,
min: 0,
},
channelId: {
type: Schema.Types.ObjectId,
ref: "Notification",
required: true,
},
},
],
secret: {
Expand Down
5 changes: 5 additions & 0 deletions server/src/repositories/incidents/MongoIncidentRepository.ts
Original file line number Diff line number Diff line change
Expand Up @@ -60,6 +60,11 @@ class MongoIncidentRepository implements IIncidentsRepository {
resolvedBy: doc.resolvedBy ? this.toStringId(doc.resolvedBy) : null,
resolvedByEmail: doc.resolvedByEmail ?? null,
comment: doc.comment ?? null,
acknowledged: doc.acknowledged,
acknowledgedBy: doc.acknowledgedBy ? this.toStringId(doc.acknowledgedBy) : null,
acknowledgedAt: doc.acknowledgedAt ? this.toDateString(doc.acknowledgedAt) : null,
acknowledgedByEmail: doc.acknowledgedByEmail ?? null,
escalatedNotificationIds: doc.escalatedNotificationIds ?? [],
createdAt: this.toDateString(doc.createdAt),
updatedAt: this.toDateString(doc.updatedAt),
};
Expand Down
Loading