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
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,
escalationRules: data?.escalationRules || [],
});

export const useMonitorForm = ({
Expand Down
234 changes: 159 additions & 75 deletions client/src/Pages/CreateMonitor/index.tsx
Original file line number Diff line number Diff line change
@@ -1,8 +1,7 @@
import { useMemo, useState } from "react";
import { useEffect } from "react";
import { useMemo, useState, useEffect } from "react";
import { useForm, Controller, useFieldArray } from "react-hook-form";
import { logger } from "@/Utils/logger";
import { useParams, useLocation, useNavigate } from "react-router";
import { useForm, Controller } 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 @@ -30,7 +29,7 @@ import {
Dialog,
} from "@/Components/inputs";
import { SPACING, LAYOUT } from "@/Utils/Theme/constants";
import { useGet, usePost, usePatch, useDelete } from "@/Hooks/UseApi";
import { useGet, useDelete, usePost, usePatch } from "@/Hooks/UseApi";
import { useMonitorForm } from "@/Hooks/useMonitorForm";
import {
type Monitor,
Expand Down Expand Up @@ -209,7 +208,6 @@ const CreateMonitorPage = () => {
}, [defaults, form]);

const watchedType = watch("type") as MonitorType;

const watchedUseAdvancedMatching = watch("useAdvancedMatching") as boolean;
const watchGeoCheckEnabled = watch("geoCheckEnabled") as boolean;

Expand All @@ -222,9 +220,88 @@ const CreateMonitorPage = () => {
[watchedType, t]
);

const { post, loading: isCreating } = usePost<MonitorFormData, Monitor>();
const { patch, loading: isUpdating } = usePatch<MonitorFormData, Monitor>();
const isSubmitting = isCreating || isUpdating;
// Escalation Rules Field Array and notification options (must be after control and notifications)
const { fields: escalationFields, append: appendEscalation, remove: removeEscalation } = useFieldArray({
control,
name: "escalationRules"
});
const notificationOptions = (notifications ?? []).map((n: Notification) => ({
...n,
name: n.notificationName,
}));

// Escalation Rules UI
const renderEscalationRules = () => (
<ConfigBox
title={t("pages.createMonitor.form.escalation.title", "Escalation Rules")}
subtitle={t("pages.createMonitor.form.escalation.description", "Configure escalation steps to notify additional channels after a delay.")}
rightContent={
<Stack spacing={theme.spacing(LAYOUT.MD)}>
{escalationFields.map((field, idx) => (
<Stack key={field.id} direction="row" spacing={theme.spacing(SPACING.MD)} alignItems="center">
<Controller
name={`escalationRules.${idx}.delayMinutes`}
control={control}
render={({ field }) => (
<TextField
{...field}
type="number"
fieldLabel={t("Delay (minutes)")}
sx={{ width: 120 }}
onChange={e => field.onChange(Number(e.target.value))}
/>
)}
/>
<Controller
name={`escalationRules.${idx}.notificationChannelIds`}
control={control}
render={({ field }) => {
// Ensure all selected IDs are represented in the value, even if not in options
const selectedOptions = (field.value ?? []).map((id: string) =>
notificationOptions.find((n) => n.id === id) || { id, name: id }
);
return (
<Autocomplete
multiple
options={notificationOptions}
value={selectedOptions}
getOptionLabel={(option) => option.name}
onChange={(_, newValue: Notification[]) => field.onChange(newValue.map((n: Notification) => n.id))}
isOptionEqualToValue={(option, value) => option.id === value.id}
fieldLabel={t("Notification Channels")}
sx={{ minWidth: 220 }}
/>
);
}}
/>
<Controller
name={`escalationRules.${idx}.messageTemplate`}
control={control}
render={({ field }) => (
<TextField
{...field}
type="text"
fieldLabel={t("Custom Message (optional)")}
sx={{ minWidth: 200 }}
/>
)}
/>
<IconButton onClick={() => removeEscalation(idx)} aria-label="Remove escalation step">
<Trash2 size={16} />
</IconButton>
</Stack>
))}
<Button
variant="outlined"
onClick={() => appendEscalation({ delayMinutes: 10, notificationChannelIds: [], messageTemplate: "" })}
>
{t("Add Escalation Step")}
</Button>
</Stack>
}
/>
);

// Delete functionality
const [isDeleteDialogOpen, setIsDeleteDialogOpen] = useState(false);
const { deleteFn, loading: isDeleting } = useDelete();
Expand All @@ -251,7 +328,18 @@ const CreateMonitorPage = () => {
setIsDeleteDialogOpen(false);
};

const { post, loading: isCreating } = usePost<MonitorFormData, Monitor>();
const { patch, loading: isUpdating } = usePatch<MonitorFormData, Monitor>();
const isSubmitting = isCreating || isUpdating;

const onSubmit = async (data: MonitorFormData) => {
// Debug: log escalationRules and notificationChannelIds
console.log('Submitting monitor form data:', data);
if (data.escalationRules) {
data.escalationRules.forEach((rule, idx) => {
console.log(`Escalation Rule #${idx}:`, rule);
});
}
let result;
if (isEditMode && monitorId) {
result = await patch(`/monitors/${monitorId}`, data);
Expand Down Expand Up @@ -697,73 +785,69 @@ const CreateMonitorPage = () => {
}
/>

<ConfigBox
title={t("pages.createMonitor.form.notifications.title")}
subtitle={t("pages.createMonitor.form.notifications.description")}
rightContent={
<Controller
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"
<ConfigBox
title={t("pages.createMonitor.form.notifications.title")}
subtitle={t("pages.createMonitor.form.notifications.description")}
rightContent={
<Controller
name="notifications"
control={control}
render={({ field }) => {
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: Notification[]) => 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%"
>
<Trash2 size={16} />
</IconButton>
{index < selectedNotifications.length - 1 && <Divider />}
</Stack>
))}
</Stack>
)}
</Stack>
);
}}
/>
}
/>
<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>
);
}}
/>
}
/>

{/* Escalation Rules Section */}
{renderEscalationRules()}

{(watchedType === "http" ||
watchedType === "grpc" ||
Expand Down Expand Up @@ -871,7 +955,7 @@ const CreateMonitorPage = () => {
/>
<Controller
name="jsonPath"
control={control}
control={control}
render={({ field, fieldState }) => (
<TextField
{...field}
Expand Down
9 changes: 9 additions & 0 deletions client/src/Types/Monitor.ts
Original file line number Diff line number Diff line change
Expand Up @@ -79,6 +79,15 @@ export interface Monitor {
recentChecks: CheckSnapshot[];
createdAt: string;
updatedAt: string;
/**
* Escalation rules for notifications. Each rule defines a delay (in minutes),
* a set of notification channel IDs, and an optional custom message template.
*/
escalationRules?: Array<{
delayMinutes: number;
notificationChannelIds: string[];
messageTemplate?: string;
}>;
}

export type MonitorWithChecks = Monitor;
Expand Down
9 changes: 9 additions & 0 deletions client/src/Validation/monitor.ts
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,15 @@ const baseSchema = z.object({
.number()
.min(300000, "Interval must be at least 5 minutes")
.optional(),
escalationRules: z
.array(
z.object({
delayMinutes: z.number().min(1, "Delay must be at least 1 minute"),
notificationChannelIds: z.array(z.string().min(1, "Channel is required")),
messageTemplate: z.string().optional(),
})
)
.optional(),
});

// HTTP monitor schema
Expand Down
9 changes: 9 additions & 0 deletions server/jest.config.cjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
module.exports = {
preset: 'ts-jest',
testEnvironment: 'node',
testMatch: ["**/test/**/*.test.ts"],
transform: {
'^.+\\.ts$': 'ts-jest',
},
moduleFileExtensions: ['ts', 'js', 'json', 'node'],
};
42 changes: 22 additions & 20 deletions server/jest.config.ts
Original file line number Diff line number Diff line change
@@ -1,25 +1,27 @@
import type { Config } from "jest";
import type { Config } from 'jest';

const config: Config = {
rootDir: ".",
testEnvironment: "node",
extensionsToTreatAsEsm: [".ts"],
transform: {
"^.+\\.(t|j)sx?$": ["ts-jest", { useESM: true, tsconfig: "./tsconfig.jest.json" }],
},
moduleNameMapper: {
"^@/validation/(.*)\\.js$": "<rootDir>/src/validation/$1.js",
"^@/utils/(AppError)\\.js$": "<rootDir>/src/utils/$1.ts",
"^@/utils/(.*)\\.js$": "<rootDir>/src/utils/$1.js",
"^@/(.*)\\.ts$": "<rootDir>/src/$1.ts",
"^@/(.*)\\.js$": "<rootDir>/src/$1.ts",
"^@/(.*)$": "<rootDir>/src/$1",
},
testMatch: ["<rootDir>/test/**/*.test.ts"],
setupFilesAfterEnv: [],
collectCoverageFrom: ["src/**/*.ts"],
coveragePathIgnorePatterns: ["/node_modules/", "/test/"],
clearMocks: true,
preset: 'ts-jest/presets/default-esm',
rootDir: '.',
testEnvironment: 'node',
extensionsToTreatAsEsm: ['.ts', '.mts'],
transform: {
'^.+\\.ts$': ['ts-jest', { useESM: true, tsconfig: './tsconfig.jest.json' }],
},
moduleNameMapper: {
'^@/validation/(.*)\\.js$': '<rootDir>/src/validation/$1.ts',
'^@/utils/(AppError)\\.js$': '<rootDir>/src/utils/$1.ts',
'^@/utils/(.*)\\.js$': '<rootDir>/src/utils/$1.ts',
'^@/(.*)\\.ts$': '<rootDir>/src/$1.ts',
'^@/(.*)\\.js$': '<rootDir>/src/$1.ts',
'^@/(.*)$': '<rootDir>/src/$1.ts',
},
moduleFileExtensions: ['mts', 'ts', 'js', 'json', 'node'],
testMatch: ['<rootDir>/test/**/*.test.ts', '<rootDir>/test/**/*.test.mts'],
setupFilesAfterEnv: [],
collectCoverageFrom: ['src/**/*.ts'],
coveragePathIgnorePatterns: ['/node_modules/', '/test/'],
clearMocks: true,
};

export default config;
Loading