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
101 changes: 98 additions & 3 deletions client/src/Pages/CreateMonitor/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -217,6 +217,35 @@ const CreateMonitorPage = () => {
clearErrors();
}, [watchedType, clearErrors]);

useEffect(() => {
if (!existingMonitor) return;

useEffect(() => {
if (!existingMonitor) return;

const steps = (existingMonitor as any).escalations;

if (Array.isArray(steps) && steps.length > 0) {
setEscalationSteps(
steps.map((s: any, i: number) => ({
id: `init_${i}`,
delayMinutes: s.delay ?? 0,
email: s.email ?? "",
}))
);
} else {
setEscalationSteps([
{ id: `step_${Date.now()}`, delayMinutes: 5, email: "" }
]);
}
}, [existingMonitor]);

if (escalation) {
setEscalationDelay(escalation.delay ?? 0);
setEscalationNotifications(escalation.notificationIds ?? []);
}
}, [existingMonitor]);

const generalSettingsConfig = useMemo(
() => getGeneralSettingsConfig(watchedType, t),
[watchedType, t]
Expand All @@ -229,6 +258,10 @@ const CreateMonitorPage = () => {
const [isDeleteDialogOpen, setIsDeleteDialogOpen] = useState(false);
const { deleteFn, loading: isDeleting } = useDelete();

const [escalationDelay, setEscalationDelay] = useState<number>(0);

const [escalationNotifications, setEscalationNotifications] = useState<string[]>([]);

const handleDeleteClick = () => {
setIsDeleteDialogOpen(true);
};
Expand All @@ -252,11 +285,19 @@ const CreateMonitorPage = () => {
};

const onSubmit = async (data: MonitorFormData) => {
const payload = {
...data,
escalation: {
delay: escalationDelay,
notificationIds: escalationNotifications
}
};

let result;
if (isEditMode && monitorId) {
result = await patch(`/monitors/${monitorId}`, data);
result = await patch(`/monitors/${monitorId}`, payload);
} else {
result = await post("/monitors", data);
result = await post("/monitors", payload);
}

if (result?.success) {
Expand Down Expand Up @@ -731,6 +772,7 @@ const CreateMonitorPage = () => {
width="100%"
>
{selectedNotifications.map((notification, index) => (

<Stack
direction="row"
alignItems="center"
Expand Down Expand Up @@ -764,6 +806,58 @@ const CreateMonitorPage = () => {
/>
}
/>
{/* Escalation Settings */}
<ConfigBox
title={"Escalation Rules"}
subtitle={"If the monitor stays down for the specified time, notify additional channels."}
rightContent={
<Stack spacing={theme.spacing(LAYOUT.MD)}>

<Stack direction={{ xs: "column", md: "row" }} spacing={theme.spacing(LAYOUT.MD)}>

<TextField
type="number"
fieldLabel={"Escalate after (minutes)"}
value={escalationDelay}
onChange={(e) => {
const val = Number(e.target.value);
setEscalationDelay(isNaN(val) ? 0 : val);
}}
sx={{ flex: 1 }}
/>

<Autocomplete
multiple
options={(notifications ?? []).map((n) => ({
...n,
name: n.notificationName,
}))}
value={(notifications ?? [])
.filter((n) => escalationNotifications.includes(n.id))
.map((n) => ({
...n,
name: n.notificationName,
}))}
getOptionLabel={(option) => option.name}
onChange={(_, newValue) => {
setEscalationNotifications(newValue.map((n) => n.id));
}}
isOptionEqualToValue={(option, value) => option.id === value.id}
sx={{ flex: 2 }}
renderInput={(params) => (
<TextField
{...params}
fieldLabel={"Escalation notification channels"}
placeholder="Type to search"
/>
)}
/>

</Stack>

</Stack>
}
/>

{(watchedType === "http" ||
watchedType === "grpc" ||
Expand All @@ -776,6 +870,7 @@ const CreateMonitorPage = () => {
name="ignoreTlsErrors"
control={control}
render={({ field }) => (

<Stack
direction="row"
alignItems="center"
Expand Down Expand Up @@ -1043,7 +1138,7 @@ const CreateMonitorPage = () => {
}
/>
)}

<Stack
direction="row"
justifyContent="flex-end"
Expand Down
12 changes: 12 additions & 0 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

3 changes: 3 additions & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -24,5 +24,8 @@
"homepage": "https://github.com/bluewave-labs/Checkmate#readme",
"devDependencies": {
"husky": "^9.1.7"
},
"dependencies": {
"nodemailer": "^8.0.5"
}
}
6 changes: 6 additions & 0 deletions server/src/controllers/monitorController.ts
Original file line number Diff line number Diff line change
Expand Up @@ -201,6 +201,12 @@ class MonitorController implements IMonitorController {

createMonitor = async (req: Request, res: Response, next: NextFunction) => {
try {
if (req.body.escalations) {
req.body.escalations = req.body.escalations.map((e: any) => ({
delay: Number(e.delay),
email: String(e.email),
}));
}
const validatedBody = createMonitorBodyValidation.parse(req.body);

const userId = requireUserId(req.user?.id);
Expand Down
24 changes: 24 additions & 0 deletions server/src/service/emailService.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
import nodemailer from "nodemailer";

export class EmailService implements IEmailService {
private transporter = nodemailer.createTransport({
host: "smtp.gmail.com",
port: 587,
secure: false,
auth: {
user: process.env.SMTP_USER,
pass: process.env.SMTP_PASS,
},
});

async sendEmail(email: string, subject: string, body: string) {
await this.transporter.sendMail({
from: process.env.SMTP_USER,
to: email,
subject,
html: body,
});

console.log("✅ Email sent to:", email);
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -109,6 +109,7 @@ export class SuperSimpleQueueHelper implements ISuperSimpleQueueHelper {

getHeartbeatJob = () => {
return async (monitor: Monitor) => {

try {
const monitorId = monitor.id;
const teamId = monitor.teamId;
Expand Down
47 changes: 42 additions & 5 deletions server/src/service/infrastructure/statusService.ts
Original file line number Diff line number Diff line change
Expand Up @@ -20,7 +20,10 @@ import type {
import { AppError } from "@/utils/AppError.js";
import { ILogger } from "@/utils/logger.js";
import { IBufferService } from "./bufferService.js";
import { scheduleEscalationNotifications } from "@/utils/escalationScheduler";
import { IEmailService } from "@/service/emailService";
const SERVICE_NAME = "StatusService";
const activeEscalations = new Map<string, ReturnType<typeof scheduleEscalationNotifications>>();

export interface IStatusService {
updateRunningStats(monitor: Monitor, networkResponse: MonitorStatusResponse): Promise<boolean>;
Expand All @@ -47,19 +50,22 @@ export class StatusService implements IStatusService {
private monitorsRepository: IMonitorsRepository;
private monitorStatsRepository: IMonitorStatsRepository;
private checksRepository: IChecksRepository;
private emailService: IEmailService;

constructor(
logger: ILogger,
buffer: IBufferService,
monitorsRepository: IMonitorsRepository,
monitorStatsRepository: IMonitorStatsRepository,
checksRepository: IChecksRepository
checksRepository: IChecksRepository,
emailService: IEmailService
) {
this.logger = logger;
this.buffer = buffer;
this.monitorsRepository = monitorsRepository;
this.monitorStatsRepository = monitorStatsRepository;
this.checksRepository = checksRepository;
this.emailService = emailService;
}

get serviceName() {
Expand Down Expand Up @@ -236,13 +242,14 @@ export class StatusService implements IStatusService {
let newStatus: MonitorStatus = status === true ? "up" : "down";
let statusChanged = false;

// Return early if not enough data points
if (monitor.statusWindow.length < monitor.statusWindowSize) {
monitor.status = newStatus;

const updated = await this.monitorsRepository.updateById(monitor.id, monitor.teamId, monitor);

return {
monitor: updated,
statusChanged: false,
statusChanged: prevStatus !== newStatus,
prevStatus,
code,
timestamp: Date.now(),
Expand Down Expand Up @@ -345,8 +352,38 @@ export class StatusService implements IStatusService {
}
}

// Apply the final status
monitor.status = newStatus;
if (prevStatus !== "down" && newStatus === "down") {
activeEscalations.get(monitor.id)?.cancel();

const handle = scheduleEscalationNotifications(
monitor.id,
monitor.escalations || [],
async (id) => {
const latest = await this.monitorsRepository.findById(id, monitor.teamId);
return latest?.status === "down";
},
async (email, message) => {
await this.emailService.sendEmail(
email,
`${monitor.name} still down`,
`
<h2>${monitor.name} is STILL DOWN</h2>
<p>The issue has not been resolved.</p>
<p>This is an escalation notification.</p>
<hr/>
${message}
`
);
}
);

activeEscalations.set(monitor.id, handle);
}
if(prevStatus === "down" && newStatus !== "down") {
activeEscalations.get(monitor.id)?.cancel();
activeEscalations.delete(monitor.id);
}


const updated = await this.monitorsRepository.updateById(monitor.id, monitor.teamId, monitor);

Expand Down
40 changes: 40 additions & 0 deletions server/src/utils/escalationScheduler.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,40 @@
export type EscalationStep = {
delayMinutes: number;
email: string;
};

export function scheduleEscalationNotifications(
monitorId: string,
monitorName: string,
steps: EscalationStep[],
isMonitorDown: (monitorId: string) => Promise<boolean> | boolean,
sendEmail: (email: string, subject: string, body: string) => Promise<void> | void
) {
const timers: NodeJS.Timeout[] = [];

for (const step of steps) {
const delayMs = step.delayMinutes * 60_000;

const timer = setTimeout(async () => {
const stillDown = await Promise.resolve(isMonitorDown(monitorId));
if (!stillDown) return;

const subject = `[Checkmate] Monitor "${monitorName}" still down`;
const body = `
Monitor: ${monitorName}
Status: Still down
Time elapsed: ${step.delayMinutes} minute(s)

Please investigate the issue.
`;

await Promise.resolve(sendEmail(step.email, subject, body));
}, delayMs);

timers.push(timer);
}

return {
cancel: () => timers.forEach(clearTimeout),
};
}