559 lines
18 KiB
TypeScript
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 };
|
|
};
|
|
}
|