Files
Checkmate/server/src/domain/monitors/monitor.service.ts
T

559 lines
18 KiB
TypeScript

import { NormalizeData } from "@/utils/dataUtils.js";
import { type Monitor } from "@/domain/monitors/monitor.type.js";
import type {
MonitorType,
MonitorsWithChecksByTeamIdResult,
UptimeDetailsResult,
HardwareDetailsResult,
PageSpeedDetailsResult,
GamesMap,
GroupedGeoCheckResult,
} from "@/domain/monitors/monitor.type.js";
import { supportsGeoCheck, supportsUptimeDetails } from "@/domain/monitors/monitor.type.js";
import type { UptimeChecksResult, HardwareChecksResult, PageSpeedChecksResult } from "@/domain/checks/check.type.js";
import type { GeoContinent } from "@/domain/geo-checks/geo-check.type.js";
import type { IChecksRepository } from "@/domain/checks/check.repository.interface.js";
import type { IGeoChecksRepository } from "@/domain/geo-checks/geo-check.repository.interface.js";
import type { IIncidentsRepository } from "@/domain/incidents/incident.repository.interface.js";
import type { IMonitorStatsRepository } from "@/domain/monitor-stats/monitor-stats.repository.interface.js";
import type { IMonitorsRepository } from "@/domain/monitors/monitor.repository.interface.js";
import type { IStatusPagesRepository } from "@/domain/status-pages/status-page-repository.interface.js";
import demoMonitorsData from "@/utils/demoMonitors.json" with { type: "json" };
import { AppError } from "@/utils/AppError.js";
import type { ImportedMonitor } from "@/api/validation/monitorValidation.js";
import { ILogger } from "@/utils/logger.js";
import { IJobScheduler } from "@/worker/worker.interface.js";
import { DateRange } from "@/types/query.js";
const SERVICE_NAME = "MonitorService";
const isUptimeChecksResult = (result: UptimeChecksResult | HardwareChecksResult | PageSpeedChecksResult): result is UptimeChecksResult =>
supportsUptimeDetails(result.monitorType);
export interface IMonitorService {
// create
createMonitor(teamId: string, userId: string, body: Partial<Monitor>): Promise<void>;
createMonitors(monitors: Array<Monitor>): Promise<Monitor[] | null>;
addDemoMonitors(args: { userId: string; teamId: string }): Promise<Monitor[]>;
// read
getUptimeDetailsById(args: { teamId: string; monitorId: string; dateRange: DateRange }): Promise<UptimeDetailsResult>;
getHardwareDetailsById(args: { teamId: string; monitorId: string; dateRange: DateRange }): Promise<HardwareDetailsResult>;
getPageSpeedDetailsById(args: { teamId: string; monitorId: string; dateRange: DateRange }): Promise<PageSpeedDetailsResult>;
getGeoChecksByMonitorId(args: {
teamId: string;
monitorId: string;
dateRange: DateRange;
continents?: GeoContinent[];
}): Promise<GroupedGeoCheckResult>;
getMonitorById(args: { teamId: string; monitorId: string }): Promise<Monitor>;
getMonitorsByTeamId(args: {
teamId: string;
limit?: number;
type?: MonitorType | MonitorType[];
tags?: string | string[];
page?: number;
rowsPerPage?: number;
filter?: string;
field?: string;
order?: "asc" | "desc";
}): Promise<Monitor[] | null>;
getMonitorsWithChecksByTeamId(args: {
teamId: string;
limit?: number;
type?: MonitorType | MonitorType[];
tags?: string | string[];
page?: number;
rowsPerPage?: number;
filter?: string;
field?: string;
order?: "asc" | "desc";
}): Promise<MonitorsWithChecksByTeamIdResult>;
getAllGames(): GamesMap;
// update
editMonitor(args: { teamId: string; monitorId: string; body: Partial<Monitor> }): Promise<Monitor>;
pauseMonitor(args: { teamId: string; monitorId: string }): Promise<Monitor>;
bulkPauseMonitors(args: { teamId: string; monitorIds: string[]; pause: boolean }): Promise<{ monitors: Monitor[]; failedCount: number }>;
// delete
deleteMonitor(args: { teamId: string; monitorId: string }): Promise<Monitor>;
deleteAllMonitors(args: { teamId: string }): Promise<number>;
// notifications
updateNotifications(args: { teamId: string; monitorIds: string[]; notificationIds: string[]; action: "add" | "remove" | "set" }): Promise<number>;
// other
exportMonitorsToJSON(args: { teamId: string }): Promise<Monitor[]>;
importMonitorsFromJSON(args: { teamId: string; userId: string; monitors: ImportedMonitor[] }): Promise<{ imported: number; errors: string[] }>;
}
export class MonitorService implements IMonitorService {
static SERVICE_NAME = SERVICE_NAME;
private scheduler: IJobScheduler;
private logger: ILogger;
private games: GamesMap;
private monitorsRepository: IMonitorsRepository;
private checksRepository: IChecksRepository;
private geoChecksRepository: IGeoChecksRepository;
private monitorStatsRepository: IMonitorStatsRepository;
private statusPagesRepository: IStatusPagesRepository;
private incidentsRepository: IIncidentsRepository;
constructor({
scheduler,
logger,
games,
monitorsRepository,
checksRepository,
geoChecksRepository,
monitorStatsRepository,
statusPagesRepository,
incidentsRepository,
}: {
scheduler: IJobScheduler;
logger: ILogger;
games: GamesMap;
monitorsRepository: IMonitorsRepository;
checksRepository: IChecksRepository;
geoChecksRepository: IGeoChecksRepository;
monitorStatsRepository: IMonitorStatsRepository;
statusPagesRepository: IStatusPagesRepository;
incidentsRepository: IIncidentsRepository;
}) {
this.scheduler = scheduler;
this.logger = logger;
this.games = games;
this.monitorsRepository = monitorsRepository;
this.checksRepository = checksRepository;
this.geoChecksRepository = geoChecksRepository;
this.monitorStatsRepository = monitorStatsRepository;
this.statusPagesRepository = statusPagesRepository;
this.incidentsRepository = incidentsRepository;
}
createMonitor = async (teamId: string, userId: string, body: Monitor): Promise<void> => {
const monitor = await this.monitorsRepository.create(body, teamId, userId);
if (!monitor) {
throw new AppError({ message: "Failed to create monitor", status: 500, service: SERVICE_NAME, method: "createMonitor" });
}
this.scheduler.addJob(monitor.id, monitor);
};
createMonitors = async (monitors: Array<Monitor>): Promise<Monitor[] | null> => {
const createdMonitors = await this.monitorsRepository.createMonitors(monitors);
if (!createdMonitors || createdMonitors.length === 0) {
throw new AppError({ message: "Failed to create monitors", status: 500, service: SERVICE_NAME, method: "createMonitors" });
}
await Promise.all(createdMonitors.map((monitor) => this.scheduler.addJob(monitor.id, monitor)));
return createdMonitors;
};
addDemoMonitors = async ({ userId, teamId }: { userId: string; teamId: string }): Promise<Monitor[]> => {
const monitors = demoMonitorsData.map((monitor) => ({
userId,
teamId,
name: monitor.name,
description: monitor.name,
type: "http" as const,
url: monitor.url,
interval: 60000,
}));
const demoMonitors = await this.monitorsRepository.createMonitors(monitors as unknown as Monitor[]);
await Promise.all(demoMonitors.map((monitor) => this.scheduler.addJob(monitor.id, monitor)));
return demoMonitors;
};
getUptimeDetailsById = async ({
teamId,
monitorId,
dateRange,
}: {
teamId: string;
monitorId: string;
dateRange: DateRange;
}): Promise<UptimeDetailsResult> => {
const monitor = await this.monitorsRepository.findById(monitorId, teamId);
if (!monitor) {
throw new AppError({ message: `Monitor with ID ${monitorId} not found.`, status: 404 });
}
const checksData = await this.checksRepository.findByDateRangeAndMonitorId(monitor.id, dateRange, {
type: monitor.type,
});
const monitorStats = await this.monitorStatsRepository.findByMonitorId(monitor.id);
if (!isUptimeChecksResult(checksData)) {
throw new AppError({ message: `${monitor.type} monitors are not supported for uptime details`, status: 400 });
}
return {
monitorData: {
monitor,
groupedChecks: checksData.groupedChecks,
groupedUpChecks: checksData.groupedUpChecks,
groupedDownChecks: checksData.groupedDownChecks,
groupedAvgResponseTime: checksData.avgResponseTime,
groupedUptimePercentage: checksData.uptimePercentage,
},
monitorStats,
};
};
getHardwareDetailsById = async ({
teamId,
monitorId,
dateRange,
}: {
teamId: string;
monitorId: string;
dateRange: DateRange;
}): Promise<HardwareDetailsResult> => {
const monitor = await this.monitorsRepository.findById(monitorId, teamId);
if (!monitor) {
throw new AppError({ message: `Monitor with ID ${monitorId} not found.`, status: 404 });
}
if (monitor.type !== "hardware") {
throw new AppError({ message: `${monitor.type} monitors are not supported for hardware details`, status: 400 });
}
const checksData = await this.checksRepository.findByDateRangeAndMonitorId(monitor.id, dateRange, {
type: monitor.type,
});
if (checksData.monitorType !== "hardware") {
throw new AppError({ message: "Unable to load hardware stats for this monitor", status: 500 });
}
const stats = {
aggregateData: checksData.aggregateData,
upChecks: checksData.upChecks,
checks: checksData.checks,
};
const monitorStats = await this.monitorStatsRepository.findByMonitorId(monitor.id);
return {
monitor,
stats,
monitorStats,
};
};
getPageSpeedDetailsById = async ({
teamId,
monitorId,
dateRange,
}: {
teamId: string;
monitorId: string;
dateRange: DateRange;
}): Promise<PageSpeedDetailsResult> => {
const monitor = await this.monitorsRepository.findById(monitorId, teamId);
if (!monitor) {
throw new AppError({ message: `Monitor with ID ${monitorId} not found.`, status: 404 });
}
if (monitor.type !== "pagespeed") {
throw new AppError({ message: `${monitor.type} monitors are not supported for pagespeed details`, status: 400 });
}
const checksData = await this.checksRepository.findByDateRangeAndMonitorId(monitor.id, dateRange, {
type: monitor.type,
});
if (checksData.monitorType !== "pagespeed") {
throw new AppError({ message: "Unable to load pagespeed stats for this monitor", status: 500 });
}
const monitorStats = await this.monitorStatsRepository.findByMonitorId(monitor.id);
return {
monitorData: {
monitor,
groupedChecks: checksData.groupedChecks,
},
monitorStats,
};
};
getGeoChecksByMonitorId = async ({
teamId,
monitorId,
dateRange,
continents,
}: {
teamId: string;
monitorId: string;
dateRange: DateRange;
continents?: GeoContinent[];
}): Promise<GroupedGeoCheckResult> => {
const monitor = await this.monitorsRepository.findById(monitorId, teamId);
if (!monitor) {
throw new AppError({ message: `Monitor with ID ${monitorId} not found.`, status: 404 });
}
if (!supportsGeoCheck(monitor.type) || !monitor.geoCheckEnabled) {
return { groupedGeoChecks: [] };
}
const groupedGeoChecks = await this.geoChecksRepository.findGroupedByMonitorIdAndDateRange(monitor.id, dateRange, continents);
return { groupedGeoChecks };
};
getMonitorById = async ({ teamId, monitorId }: { teamId: string; monitorId: string }): Promise<Monitor> => {
return await this.monitorsRepository.findById(monitorId, teamId);
};
getMonitorsByTeamId = async ({
teamId,
type,
tags,
filter,
}: {
teamId: string;
type?: MonitorType | MonitorType[];
tags?: string | string[];
filter?: string;
}): Promise<Monitor[]> => {
return await this.monitorsRepository.findByTeamId(teamId, { type, tags, filter });
};
getMonitorsWithChecksByTeamId = async ({
teamId,
limit,
type,
tags,
page,
rowsPerPage,
filter,
field,
order,
}: {
teamId: string;
limit?: number;
type?: MonitorType | MonitorType[];
tags?: string | string[];
page?: number;
rowsPerPage?: number;
filter?: string;
field?: string;
order?: "asc" | "desc";
}): Promise<MonitorsWithChecksByTeamIdResult> => {
const summary = await this.monitorsRepository.findMonitorsSummaryByTeamId(teamId, { type, tags });
const count = await this.monitorsRepository.findMonitorCountByTeamIdAndType(teamId, { type, tags, filter });
const monitors = await this.monitorsRepository.findByTeamIdWithStats(teamId, {
limit,
type,
tags,
page,
rowsPerPage,
filter,
field,
order,
});
const snapshotTypes: MonitorType[] = ["hardware"];
const requestedTypes = Array.isArray(type) ? type : type ? [type] : [];
const snapshotOnlyRequest =
requestedTypes.length > 0 && requestedTypes.every((requestedType) => snapshotTypes.includes(requestedType as MonitorType));
const monitorsWithChecks = monitors.map((monitor: Monitor) => {
const rawChecks = monitor.recentChecks ?? [];
const isSnapshotType = snapshotOnlyRequest || snapshotTypes.includes(monitor.type);
const checks = isSnapshotType ? rawChecks.slice(0, 1) : NormalizeData(rawChecks, 10, 100);
monitor.recentChecks = checks;
return monitor;
});
return { summary: summary ?? null, count, monitors: monitorsWithChecks };
};
getAllGames = (): GamesMap => {
return this.games;
};
editMonitor = async ({ teamId, monitorId, body }: { teamId: string; monitorId: string; body: Partial<Monitor> }) => {
const editedMonitor = await this.monitorsRepository.updateById(monitorId, teamId, body);
await this.scheduler.updateJob(editedMonitor);
return editedMonitor;
};
updateNotifications = async ({
teamId,
monitorIds,
notificationIds,
action,
}: {
teamId: string;
monitorIds: string[];
notificationIds: string[];
action: "add" | "remove" | "set";
}): Promise<number> => {
const modifiedCount = await this.monitorsRepository.updateNotifications(teamId, monitorIds, notificationIds, action);
// If notifications were updated, we should update the jobs in the queue
if (modifiedCount > 0) {
const monitors = await this.monitorsRepository.findByIds(monitorIds);
await Promise.all(monitors.map((monitor) => this.scheduler.updateJob(monitor)));
}
return modifiedCount;
};
pauseMonitor = async ({ teamId, monitorId }: { teamId: string; monitorId: string }): Promise<Monitor> => {
const monitor = await this.monitorsRepository.togglePauseById(monitorId, teamId);
if (monitor.isActive) {
await this.scheduler.resumeJob(monitor);
} else {
await this.scheduler.pauseJob(monitor);
}
return monitor;
};
bulkPauseMonitors = async ({
teamId,
monitorIds,
pause,
}: {
teamId: string;
monitorIds: string[];
pause: boolean;
}): Promise<{ monitors: Monitor[]; failedCount: number }> => {
const monitors = await this.monitorsRepository.bulkTogglePause(monitorIds, teamId, pause);
const results = await Promise.allSettled(
monitors.map(async (monitor) => {
if (monitor.isActive) {
await this.scheduler.resumeJob(monitor);
} else {
await this.scheduler.pauseJob(monitor);
}
})
);
let failedCount = 0;
results.forEach((result, index) => {
if (result.status === "rejected") {
failedCount++;
this.logger.error({
message: `Failed to sync job queue for monitor ${monitors[index]?.id || "unknown"} during bulk ${pause ? "pause" : "resume"}`,
service: SERVICE_NAME,
method: "bulkPauseMonitors",
stack: result.reason instanceof Error ? result.reason.stack : undefined,
});
}
});
return { monitors, failedCount };
};
private deleteMonitorChildren = async (monitor: Monitor, teamId: string) => {
await this.checksRepository.deleteByMonitorId(monitor.id).catch((err: unknown) => {
this.logger.warn({
message: `Error deleting checks for monitor ${monitor.id} with name ${monitor.name}`,
service: SERVICE_NAME,
stack: err instanceof Error ? err.stack : undefined,
});
});
await this.statusPagesRepository.removeMonitorFromStatusPages(monitor.id).catch((err: unknown) => {
this.logger.warn({
message: `Error removing monitor ${monitor.id} with name ${monitor.name} from status pages`,
service: SERVICE_NAME,
stack: err instanceof Error ? err.stack : undefined,
});
});
await this.incidentsRepository.deleteByMonitorId(monitor.id, teamId).catch((err: unknown) => {
this.logger.warn({
message: `Error deleting incidents for monitor ${monitor.id} with name ${monitor.name}`,
service: SERVICE_NAME,
stack: err instanceof Error ? err.stack : undefined,
});
});
await this.geoChecksRepository.deleteByMonitorId(monitor.id).catch((err: unknown) => {
this.logger.warn({
message: `Error deleting geo checks for monitor ${monitor.id} with name ${monitor.name}`,
service: SERVICE_NAME,
stack: err instanceof Error ? err.stack : undefined,
});
});
await this.monitorStatsRepository.deleteByMonitorId(monitor.id).catch((err: unknown) => {
this.logger.warn({
message: `Error deleting monitor stats for monitor ${monitor.id} with name ${monitor.name}`,
service: SERVICE_NAME,
stack: err instanceof Error ? err.stack : undefined,
});
});
};
deleteMonitor = async ({ teamId, monitorId }: { teamId: string; monitorId: string }): Promise<Monitor> => {
const monitor = await this.monitorsRepository.deleteById(monitorId, teamId);
await this.deleteMonitorChildren(monitor, teamId);
await this.scheduler.deleteJob(monitor);
return monitor;
};
deleteAllMonitors = async ({ teamId }: { teamId: string }): Promise<number> => {
const { monitors, deletedCount } = await this.monitorsRepository.deleteByTeamId(teamId);
await Promise.all(
monitors.map(async (monitor) => {
try {
await this.deleteMonitorChildren(monitor, teamId);
await this.scheduler.deleteJob(monitor);
} catch (error: unknown) {
this.logger.warn({
message: `Error deleting associated records for monitor ${monitor.id} with name ${monitor.name}`,
service: SERVICE_NAME,
method: "deleteAllMonitors",
stack: error instanceof Error ? error.stack : undefined,
});
}
})
);
return deletedCount;
};
exportMonitorsToJSON = async ({ teamId }: { teamId: string }): Promise<Monitor[]> => {
const monitors = await this.monitorsRepository.findByTeamId(teamId, {});
if (monitors.length === 0) {
throw new AppError({ message: "No monitors found to export.", service: SERVICE_NAME, method: "exportMonitorsToJSON", status: 400 });
}
return monitors;
};
importMonitorsFromJSON = async ({
teamId,
userId,
monitors,
}: {
teamId: string;
userId: string;
monitors: ImportedMonitor[];
}): Promise<{ imported: number; errors: string[] }> => {
const errors: string[] = [];
const cleanedMonitors: Monitor[] = monitors.map((monitor) => ({
...monitor,
id: "",
teamId,
userId,
recentChecks: [],
createdAt: "",
updatedAt: "",
lastEvaluatedAt: 0,
}));
const createdMonitors = await this.createMonitors(cleanedMonitors);
return { imported: createdMonitors!.length, errors };
};
}