diff --git a/client/src/Components/inputs/Slider.tsx b/client/src/Components/inputs/Slider.tsx index 83037ab54..a17fc8a10 100644 --- a/client/src/Components/inputs/Slider.tsx +++ b/client/src/Components/inputs/Slider.tsx @@ -11,20 +11,26 @@ import type { ResponsiveStyleValue } from "@mui/system"; interface SliderInputProps extends SliderProps { sx?: SliderProps["sx"]; showValue?: boolean; + formatDisplayValue?: (value: number) => string; } export const SliderInput = forwardRef( - ({ sx, showValue = false, ...props }, ref) => { + ({ sx, showValue = false, formatDisplayValue, ...props }, ref) => { const theme = useTheme(); const additionalSx = Array.isArray(sx) ? sx : sx ? [sx] : []; + const displayValue = + showValue && formatDisplayValue && typeof props.value === "number" + ? formatDisplayValue(props.value) + : props.value; + return ( - {showValue && {props.value}} + {showValue && {displayValue}} string; sliderMaxWidth?: ResponsiveStyleValue; } export const SliderWithLabel = forwardRef( ( - { fieldLabel, required, showValue = true, value, sliderMaxWidth = "100%", ...props }, + { + fieldLabel, + required, + showValue = true, + formatDisplayValue, + value, + sliderMaxWidth = "100%", + ...props + }, ref ) => { const theme = useTheme(); @@ -82,6 +97,7 @@ export const SliderWithLabel = forwardRef diff --git a/client/src/Pages/Settings/index.tsx b/client/src/Pages/Settings/index.tsx index 44fb4a42f..679d66126 100644 --- a/client/src/Pages/Settings/index.tsx +++ b/client/src/Pages/Settings/index.tsx @@ -37,6 +37,7 @@ import { } from "@/Features/UI/uiSlice.js"; import timezones from "@/Utils/timezones.json"; import type { RootState } from "@/Types/state"; +import { CHECK_TTL_SENTINEL } from "@/Types/Check"; interface Timezone { id: string; @@ -462,6 +463,43 @@ export const SettingsPage = () => { /> )} + {/* Check Retention */} + {isAdmin && ( + ( + field.onChange(value)} + valueLabelDisplay="auto" + valueLabelFormat={(value: number) => + value >= CHECK_TTL_SENTINEL + ? t("pages.settings.form.retention.option.days.unlimited") + : `${value}` + } + formatDisplayValue={(value: number) => + value >= CHECK_TTL_SENTINEL + ? t("pages.settings.form.retention.option.days.unlimited") + : `${value}` + } + /> + )} + /> + } + /> + )} + {/* Global Thresholds */} {isAdmin && ( & { +export type CheckSnapshot = Omit & { originalResponseTime: number; }; diff --git a/client/src/Validation/settings.ts b/client/src/Validation/settings.ts index 7eddaafe2..975927d1c 100644 --- a/client/src/Validation/settings.ts +++ b/client/src/Validation/settings.ts @@ -1,3 +1,4 @@ +import { CHECK_TTL_SENTINEL } from "@/Types/Check"; import { z } from "zod"; export const settingsSchema = z.object({ @@ -11,7 +12,11 @@ export const settingsSchema = z.object({ systemEmailSecure: z.boolean().optional(), systemEmailPool: z.boolean().optional(), showURL: z.boolean().optional(), - checkTTL: z.number().int().min(1, "Please enter a value").max(365, "Maximum 365 days"), + checkTTL: z + .number() + .int() + .min(1, "Please enter a value") + .max(CHECK_TTL_SENTINEL, `Maximum ${CHECK_TTL_SENTINEL}`), pagespeedApiKey: z .string() .transform((val) => (val.trim() === "" ? undefined : val.trim())) diff --git a/client/src/locales/en.json b/client/src/locales/en.json index 5f582cac5..4c4965aec 100644 --- a/client/src/locales/en.json +++ b/client/src/locales/en.json @@ -1041,7 +1041,7 @@ }, "stats": { "title": "Monitor history", - "description": "Define how long you want to retain historical data. You can also clear all existing data.", + "description": "Clear all monitoring history and statistics for your team. This action is irreversible.", "option": { "clear": { "label": "Clear all stats. This is irreversible." @@ -1052,6 +1052,16 @@ "description": "This cannot be undone" } }, + "retention": { + "title": "Check retention", + "description": "Set how long check data is retained before being automatically cleaned up.", + "option": { + "days": { + "label": "Retention period (days)", + "unlimited": "Unlimited" + } + } + }, "thresholds": { "title": "Global Thresholds", "description": "Set global CPU, Memory, Disk, and Temperature thresholds for infrastructure monitoring. These apply to all hardware monitors unless overridden.", diff --git a/server/src/config/services.ts b/server/src/config/services.ts index 9434b3ae5..b31c8a5e9 100644 --- a/server/src/config/services.ts +++ b/server/src/config/services.ts @@ -252,6 +252,7 @@ export const initializeServices = async ({ statusService, notificationsService, checkService, + settingsService, bufferService, incidentService, maintenanceWindowsRepository, diff --git a/server/src/controllers/checkController.ts b/server/src/controllers/checkController.ts index ec6722bd3..879ceaffe 100755 --- a/server/src/controllers/checkController.ts +++ b/server/src/controllers/checkController.ts @@ -4,18 +4,19 @@ import { getChecksParamValidation, getChecksQueryValidation, getTeamChecksQueryValidation, + getChecksSummaryByTeamIdQueryValidation, deleteChecksParamValidation, - deleteChecksByTeamIdParamValidation, - updateChecksTTLBodyValidation, } from "@/validation/checkValidation.js"; +import { ICheckService } from "@/service/index.js"; +import { requireTeamId } from "@/controllers/controllerUtils.js"; const SERVICE_NAME = "checkController"; class CheckController { static SERVICE_NAME = SERVICE_NAME; - private checkService: any; - constructor(checkService: any) { + private checkService: ICheckService; + constructor(checkService: ICheckService) { this.checkService = checkService; } @@ -25,13 +26,20 @@ class CheckController { getChecksByMonitor = async (req: Request, res: Response, next: NextFunction) => { try { - getChecksParamValidation.parse(req.params); - getChecksQueryValidation.parse(req.query); + const validatedParams = getChecksParamValidation.parse(req.params); + const validatedQuery = getChecksQueryValidation.parse(req.query); + + const teamId = requireTeamId(req.user?.teamId); const result = await this.checkService.getChecksByMonitor({ - monitorId: req?.params?.monitorId, - query: req?.query, - teamId: req?.user?.teamId, + monitorId: validatedParams.monitorId, + teamId, + sortOrder: validatedQuery.sortOrder, + dateRange: validatedQuery.dateRange, + filter: validatedQuery.filter, + page: validatedQuery.page, + rowsPerPage: validatedQuery.rowsPerPage, + status: validatedQuery.status, }); return res.status(200).json({ @@ -46,10 +54,16 @@ class CheckController { getChecksByTeam = async (req: Request, res: Response, next: NextFunction) => { try { - getTeamChecksQueryValidation.parse(req.query); + const validatedQuery = getTeamChecksQueryValidation.parse(req.query); + const teamId = requireTeamId(req.user?.teamId); + const checkData = await this.checkService.getChecksByTeam({ - teamId: req?.user?.teamId, - query: req?.query, + teamId, + sortOrder: validatedQuery.sortOrder, + dateRange: validatedQuery.dateRange, + page: validatedQuery.page, + rowsPerPage: validatedQuery.rowsPerPage, + filter: validatedQuery.filter, }); return res.status(200).json({ success: true, @@ -63,8 +77,11 @@ class CheckController { getChecksSummaryByTeamId = async (req: Request, res: Response, next: NextFunction) => { try { - const dateRange = req.query?.dateRange || "hour"; - const summary = await this.checkService.getChecksSummaryByTeamId({ teamId: req?.user?.teamId, dateRange }); + const validatedQuery = getChecksSummaryByTeamIdQueryValidation.parse(req.query); + const teamId = requireTeamId(req.user?.teamId); + const dateRange = validatedQuery.dateRange ?? "hour"; + + const summary = await this.checkService.getChecksSummaryByTeamId({ teamId, dateRange }); return res.status(200).json({ success: true, msg: "Checks summary retrieved successfully", @@ -77,11 +94,12 @@ class CheckController { deleteChecks = async (req: Request, res: Response, next: NextFunction) => { try { - deleteChecksParamValidation.parse(req.params); + const validatedParams = deleteChecksParamValidation.parse(req.params); + const teamId = requireTeamId(req.user?.teamId); const deletedCount = await this.checkService.deleteChecks({ - monitorId: req.params.monitorId as string, - teamId: req?.user?.teamId, + monitorId: validatedParams.monitorId, + teamId, }); return res.status(200).json({ @@ -96,9 +114,9 @@ class CheckController { deleteChecksByTeamId = async (req: Request, res: Response, next: NextFunction) => { try { - deleteChecksByTeamIdParamValidation.parse(req.params); + const teamId = requireTeamId(req.user?.teamId); - const deletedCount = await this.checkService.deleteChecksByTeamId({ teamId: req?.user?.teamId }); + const deletedCount = await this.checkService.deleteChecksByTeamId({ teamId }); return res.status(200).json({ success: true, @@ -109,24 +127,6 @@ class CheckController { next(error); } }; - - updateChecksTTL = async (req: Request, res: Response, next: NextFunction) => { - try { - updateChecksTTLBodyValidation.parse(req.body); - - await this.checkService.updateChecksTTL({ - teamId: req?.user?.teamId, - ttl: req?.body?.ttl, - }); - - return res.status(200).json({ - success: true, - msg: "Checks TTL updated successfully", - }); - } catch (error) { - next(error); - } - }; } export default CheckController; diff --git a/server/src/db/models/Check.ts b/server/src/db/models/Check.ts index 7bd1cec6f..541abb4d9 100644 --- a/server/src/db/models/Check.ts +++ b/server/src/db/models/Check.ts @@ -21,13 +21,10 @@ type CheckMetadataDocument = Omit & { type: MonitorType; }; -type CheckDocumentBase = Omit & { +type CheckDocumentBase = Omit & { metadata: CheckMetadataDocument; - expiry: Date; - ackAt?: Date | null; createdAt: Date; updatedAt: Date; - __v: number; }; interface CheckDocument extends CheckDocumentBase { @@ -225,18 +222,7 @@ const CheckSchema = new Schema( message: { type: String, }, - expiry: { - type: Date, - default: Date.now, - }, - ack: { - type: Boolean, - default: false, - }, - ackAt: { - type: Date, - default: undefined, - }, + cpu: { type: cpuSchema, default: () => ({}), diff --git a/server/src/repositories/checks/IChecksRepository.ts b/server/src/repositories/checks/IChecksRepository.ts index c89e5d5fc..b96d14933 100644 --- a/server/src/repositories/checks/IChecksRepository.ts +++ b/server/src/repositories/checks/IChecksRepository.ts @@ -47,4 +47,5 @@ export interface IChecksRepository { deleteByMonitorId(monitorId: string): Promise; deleteByTeamId(teamId: string): Promise; deleteByMonitorIdsNotIn(monitorIds: string[]): Promise; + deleteOlderThan(date: Date): Promise; } diff --git a/server/src/repositories/checks/MongoChecksRepistory.ts b/server/src/repositories/checks/MongoChecksRepistory.ts index b019a0430..4055ddfc3 100644 --- a/server/src/repositories/checks/MongoChecksRepistory.ts +++ b/server/src/repositories/checks/MongoChecksRepistory.ts @@ -13,9 +13,10 @@ import type { GotTimings, MonitorType, } from "@/types/index.js"; -import { CheckModel, MonitorModel, type CheckDocument } from "@/db/models/index.js"; +import { CheckModel, type CheckDocument } from "@/db/models/index.js"; import mongoose from "mongoose"; import { getDateForRange } from "@/utils/dataUtils.js"; +import { ILogger } from "@/utils/logger.js"; const SERVICE_NAME = "StatusService"; @@ -26,8 +27,8 @@ type HardwareUpChecks = { totalChecks: number }; class MongoChecksRepository implements IChecksRepository { static SERVICE_NAME = SERVICE_NAME; - private logger: any; - constructor(logger: any) { + private logger: ILogger; + constructor(logger: ILogger) { this.logger = logger; } @@ -46,13 +47,6 @@ class MongoChecksRepository implements IChecksRepository { return value instanceof Date ? value.toISOString() : new Date(value).toISOString(); }; - const toOptionalDateString = (value?: Date | string | null): string | undefined => { - if (!value) { - return undefined; - } - return toDateString(value); - }; - const mapTimings = (timings?: GotTimings): GotTimings => { const phases = timings?.phases ?? { wait: 0, @@ -171,9 +165,6 @@ class MongoChecksRepository implements IChecksRepository { timings: mapTimings(doc.timings), statusCode: doc.statusCode ?? 0, message: doc.message ?? "", - ack: doc.ack ?? false, - ackAt: toOptionalDateString(doc.ackAt), - expiry: toDateString(doc.expiry), cpu: mapCpu(doc.cpu), memory: mapMemory(doc.memory), disk: mapDisks(doc.disk), @@ -186,7 +177,6 @@ class MongoChecksRepository implements IChecksRepository { seo: doc.seo, performance: doc.performance, audits: mapAudits(doc.audits), - __v: doc.__v ?? 0, createdAt: toDateString(doc.createdAt), updatedAt: toDateString(doc.updatedAt), }; @@ -237,7 +227,7 @@ class MongoChecksRepository implements IChecksRepository { status: boolean | undefined ) => { // Match - const matchStage: Record = { + const matchStage: Record = { "metadata.monitorId": new mongoose.Types.ObjectId(monitorId), ...(typeof status !== "undefined" && { status }), ...(getDateForRange(dateRange) && { @@ -289,7 +279,7 @@ class MongoChecksRepository implements IChecksRepository { }; findByTeamId = async (sortOrder: string, dateRange: string, filter: string, page: number, rowsPerPage: number, teamId: string) => { - const matchStage: Record = { + const matchStage: Record = { "metadata.teamId": new mongoose.Types.ObjectId(teamId), ...(getDateForRange(dateRange) && { createdAt: { @@ -358,7 +348,7 @@ class MongoChecksRepository implements IChecksRepository { ); const mapped = results.reduce((acc, { monitorId, docs }) => { - acc[monitorId] = docs.map((doc: any) => this.toEntity(doc as CheckDocument)); + acc[monitorId] = docs.map((doc: CheckDocument) => this.toEntity(doc)); return acc; }, {}); return mapped; @@ -412,6 +402,31 @@ class MongoChecksRepository implements IChecksRepository { return result.deletedCount ?? 0; }; + deleteOlderThan = async (cutoffDate: Date, batchDays: number = 30): Promise => { + // Find the oldest check that is older than the cutoff + const oldest = await CheckModel.findOne({ createdAt: { $lt: cutoffDate } }) + .sort({ createdAt: 1 }) + .select({ createdAt: 1 }); + if (!oldest?.createdAt) return 0; + + let totalDeleted = 0; + let batchStart = new Date(oldest.createdAt); + const batchMs = batchDays * 24 * 60 * 60 * 1000; + + // Delete in 30 day chunks until we reach the cutoff + while (batchStart < cutoffDate) { + // Advance startTime by batchMs to get to the end of last batch + const nextStart = batchStart.getTime() + batchMs; + const batchEnd = new Date(Math.min(nextStart, cutoffDate.getTime())); + const result = await CheckModel.deleteMany({ + createdAt: { $gte: batchStart, $lt: batchEnd }, + }); + totalDeleted += result.deletedCount ?? 0; + batchStart = batchEnd; + } + + return totalDeleted; + }; private findUptimeDateRangeChecks = async ( monitorType: Exclude, monitorObjectId: mongoose.Types.ObjectId, diff --git a/server/src/routes/checkRoute.ts b/server/src/routes/checkRoute.ts index 4df286846..71a0cd5f3 100755 --- a/server/src/routes/checkRoute.ts +++ b/server/src/routes/checkRoute.ts @@ -15,7 +15,6 @@ class CheckRoutes { initRoutes() { this.router.get("/team/summary", this.checkController.getChecksSummaryByTeamId); this.router.get("/team", this.checkController.getChecksByTeam); - this.router.put("/team/ttl", isAllowed(["admin", "superadmin"]), this.checkController.updateChecksTTL); this.router.delete("/team", isAllowed(["admin", "superadmin"]), this.checkController.deleteChecksByTeamId); this.router.get("/:monitorId", this.checkController.getChecksByMonitor); this.router.delete("/:monitorId", this.checkController.deleteChecks); diff --git a/server/src/service/business/checkService.ts b/server/src/service/business/checkService.ts index c83a3d411..374090709 100644 --- a/server/src/service/business/checkService.ts +++ b/server/src/service/business/checkService.ts @@ -1,22 +1,45 @@ import { Types } from "mongoose"; import { IChecksRepository, IMonitorsRepository } from "@/repositories/index.js"; -import type { MonitorStatusResponse, CheckErrorInfo, Check, ILighthouseAudit, ChecksQueryResult, ChecksSummary } from "@/types/index.js"; +import type { + MonitorStatusResponse, + CheckErrorInfo, + Check, + ILighthouseAudit, + ChecksQueryResult, + ChecksSummary, + MonitorPayloadMap, +} from "@/types/index.js"; import type { HardwareStatusPayload, PageSpeedStatusPayload } from "@/types/network.js"; import { AppError } from "@/utils/AppError.js"; -import { ParseBoolean } from "@/utils/utils.js"; import { ILogger } from "@/utils/logger.js"; const SERVICE_NAME = "checkService"; export interface ICheckService { createChecks(checks: Check[]): Promise; - buildCheck(statusResponse: MonitorStatusResponse): Partial | undefined; - getChecksByMonitor(params: { monitorId: string; query: Record; teamId: string }): Promise; - getChecksByTeam(params: { teamId: string; query: Record }): Promise; + buildCheck(statusResponse: MonitorStatusResponse): Check | undefined; + getChecksByMonitor(params: { + monitorId: string; + teamId: string; + sortOrder: string; + dateRange: string; + page: number; + rowsPerPage: number; + filter?: string; + status?: boolean; + }): Promise; + getChecksByTeam(params: { + teamId: string; + sortOrder: string; + dateRange: string; + page: number; + rowsPerPage: number; + filter?: string; + }): Promise; getChecksSummaryByTeamId(params: { teamId: string; dateRange: string }): Promise; deleteChecks(params: { monitorId: string; teamId: string }): Promise; deleteChecksByTeamId(params: { teamId: string }): Promise; - updateChecksTTL(params: { teamId: string; ttl: string }): Promise; + deleteOlderThan(date: Date): Promise; } export class CheckService implements ICheckService { @@ -39,12 +62,14 @@ export class CheckService implements ICheckService { return this.checksRepository.createChecks(checks); }; - buildCheck = (statusResponse: MonitorStatusResponse) => { + buildCheck = (statusResponse: MonitorStatusResponse): Check | undefined => { const { monitorId, teamId, type, status, responseTime, code, message, payload, timings } = statusResponse; - const check: Partial = { + const now = new Date().toISOString(); + const check: Check = { id: new Types.ObjectId().toString(), - createdAt: new Date().toISOString(), + createdAt: now, + updatedAt: now, metadata: { monitorId, teamId, @@ -112,7 +137,25 @@ export class CheckService implements ICheckService { return check; }; - getChecksByMonitor = async ({ monitorId, query, teamId }: { monitorId: string; query: Record; teamId: string }) => { + getChecksByMonitor = async ({ + monitorId, + teamId, + sortOrder, + dateRange, + filter, + page, + rowsPerPage, + status, + }: { + monitorId: string; + teamId: string; + sortOrder: string; + dateRange: string; + page: number; + rowsPerPage: number; + status?: boolean; + filter?: string; + }) => { if (!monitorId) { throw new AppError({ message: "No monitor ID in request", service: SERVICE_NAME, method: "getChecksByMonitor", status: 400 }); } @@ -123,74 +166,54 @@ export class CheckService implements ICheckService { // For verification, throws an error if monitor doesn't belong to team await this.monitorsRepository.findById(monitorId, teamId); - const { sortOrder, dateRange, filter, page, rowsPerPage, status } = query; - if (!sortOrder || !dateRange || !page || !rowsPerPage) { - throw new AppError({ - message: `Missing required query parameters sortOrder: ${sortOrder}, dateRange: ${dateRange}, filter: ${filter}, page: ${page}, rowsPerPage: ${rowsPerPage}`, - service: SERVICE_NAME, - method: "getChecksByMonitor", - status: 400, - }); - } - const parsedStatus = typeof status === "undefined" ? status : ParseBoolean(status); - const parsedPage = page ? parseInt(page) : 0; - const parsedRowsPerPage = rowsPerPage ? parseInt(rowsPerPage) : 5; + const parsedPage = page ?? 0; + const parsedRowsPerPage = rowsPerPage ?? 5; - const result = await this.checksRepository.findByMonitorId(monitorId, sortOrder, dateRange, filter, parsedPage, parsedRowsPerPage, parsedStatus); + const result = await this.checksRepository.findByMonitorId(monitorId, sortOrder, dateRange, filter, parsedPage, parsedRowsPerPage, status); return result; }; - getChecksByTeam = async ({ teamId, query }: { teamId: string; query: Record }) => { - const { sortOrder, dateRange, filter, page, rowsPerPage } = query; - if (!sortOrder || !dateRange || !page || !rowsPerPage) { - throw new AppError({ message: "Missing required query parameters", service: SERVICE_NAME, method: "getChecksByTeam", status: 400 }); - } - - if (!teamId) { - throw new AppError({ message: "No team ID in request", service: SERVICE_NAME, method: "getChecksByTeam", status: 400 }); - } - - const parsedPage = page ? parseInt(page) : 0; - const parsedRowsPerPage = rowsPerPage ? parseInt(rowsPerPage) : 5; + getChecksByTeam = async ({ + teamId, + sortOrder, + dateRange, + filter, + page, + rowsPerPage, + }: { + teamId: string; + sortOrder: string; + dateRange: string; + page: number; + rowsPerPage: number; + filter?: string; + }) => { + const parsedPage = page ?? 0; + const parsedRowsPerPage = rowsPerPage ?? 5; const checkData = await this.checksRepository.findByTeamId(sortOrder, dateRange, filter, parsedPage, parsedRowsPerPage, teamId); return checkData; }; getChecksSummaryByTeamId = async ({ teamId, dateRange }: { teamId: string; dateRange: string }) => { - if (!teamId) { - throw new AppError({ message: "No team ID in request", service: SERVICE_NAME, method: "getChecksSummaryByTeamId", status: 400 }); - } const summary = await this.checksRepository.findSummaryByTeamId(teamId, dateRange); return summary; }; deleteChecks = async ({ monitorId, teamId }: { monitorId: string; teamId: string }) => { - if (!monitorId) { - throw new AppError({ message: "No monitor ID in request", service: SERVICE_NAME, method: "deleteChecks", status: 400 }); - } - - if (!teamId) { - throw new AppError({ message: "No team ID in request", service: SERVICE_NAME, method: "deleteChecks", status: 400 }); - } - - // For verification, throws an error if monitor doesn't belong to team await this.monitorsRepository.findById(monitorId, teamId); const deletedCount = await this.checksRepository.deleteByMonitorId(monitorId); return deletedCount; }; deleteChecksByTeamId = async ({ teamId }: { teamId: string }) => { - if (!teamId) { - throw new AppError({ message: "No team ID in request", service: SERVICE_NAME, method: "deleteChecksByTeamId", status: 400 }); - } - const deletedCount = await this.checksRepository.deleteByTeamId(teamId); return deletedCount; }; - updateChecksTTL = async ({ teamId, ttl }: { teamId: string; ttl: string }) => { - throw new AppError({ message: "Not implemented", service: SERVICE_NAME, method: "updateChecksTTL", status: 500, details: { teamId, ttl } }); + deleteOlderThan = async (date: Date) => { + const deletedCount = await this.checksRepository.deleteOlderThan(date); + return deletedCount; }; } diff --git a/server/src/service/business/userService.ts b/server/src/service/business/userService.ts index f224e9ba4..3337e491d 100644 --- a/server/src/service/business/userService.ts +++ b/server/src/service/business/userService.ts @@ -349,7 +349,7 @@ export class UserService implements IUserService { if (res && res.length > 0) { await Promise.all( res.map(async (monitor) => { - await this.jobQueue.deleteJob(monitor.id); + await this.jobQueue.deleteJob(monitor); }) ); } diff --git a/server/src/service/infrastructure/SuperSimpleQueue/SuperSimpleQueue.ts b/server/src/service/infrastructure/SuperSimpleQueue/SuperSimpleQueue.ts index a96e7855b..f4fd5b1e6 100644 --- a/server/src/service/infrastructure/SuperSimpleQueue/SuperSimpleQueue.ts +++ b/server/src/service/infrastructure/SuperSimpleQueue/SuperSimpleQueue.ts @@ -2,7 +2,7 @@ import { IMonitorsRepository } from "@/repositories/index.js"; import { ILogger } from "@/utils/logger.js"; import Scheduler from "super-simple-scheduler"; import { ISuperSimpleQueueHelper } from "@/service/infrastructure/SuperSimpleQueue/SuperSimpleQueueHelper.js"; -import { supportsGeoCheck } from "@/types/monitor.js"; +import { Monitor, MonitorType, supportsGeoCheck } from "@/types/monitor.js"; const SERVICE_NAME = "JobQueue"; type QueueJobFailure = { @@ -42,11 +42,11 @@ type QueueJobSummary = { export interface ISuperSimpleQueue { readonly serviceName: string; init(): Promise; - addJob(monitorId: string, monitor: any): Promise; - deleteJob(monitor: any): Promise; - pauseJob(monitor: any): Promise; - resumeJob(monitor: any): Promise; - updateJob(monitor: any): Promise; + addJob(monitorId: string, monitor: Monitor): Promise; + deleteJob(monitor: Monitor): Promise; + pauseJob(monitor: Monitor): Promise; + resumeJob(monitor: Monitor): Promise; + updateJob(monitor: Monitor): Promise; shutdown(): Promise; getMetrics(): Promise; getJobs(): Promise; @@ -89,9 +89,10 @@ export class SuperSimpleQueue implements ISuperSimpleQueue { try { this.scheduler.start(); - this.scheduler.addTemplate("monitor-job", this.helper.getMonitorJob()); - this.scheduler.addTemplate("geo-check-job", this.helper.getGeoCheckJob()); + this.scheduler.addTemplate("monitor-job", this.helper.getHeartbeatJob()); + this.scheduler.addTemplate("geo-check-job", this.helper.getHeartbeatGeoJob()); this.scheduler.addTemplate("cleanup-orphaned", this.helper.getCleanupOrphanedJob()); + this.scheduler.addTemplate("cleanup-retention-job", this.helper.getCleanupRetentionJob()); const monitors = await this.monitorsRepository.findAll(); if (!monitors) { return true; @@ -104,20 +105,20 @@ export class SuperSimpleQueue implements ISuperSimpleQueue { } this.scheduler.addJob({ id: "cleanup-orphaned", template: "cleanup-orphaned", active: true }); + this.scheduler.addJob({ id: "cleanup-retention", template: "cleanup-retention-job", active: true, repeat: 24 * 60 * 60 * 1000 }); return true; - } catch (error: any) { + } catch (error: unknown) { this.logger.error({ - message: "Failed to initialize SuperSimpleQueue", + message: `Failed to initialize SuperSimpleQueue: ${error instanceof Error ? error.message : String(error)}`, service: SERVICE_NAME, method: "init", - details: error, }); return false; } }; - addJob = async (monitorId: string, monitor: any) => { + addJob = async (monitorId: string, monitor: Monitor) => { this.scheduler.addJob({ id: monitorId, template: "monitor-job", @@ -143,12 +144,12 @@ export class SuperSimpleQueue implements ISuperSimpleQueue { } }; - deleteJob = async (monitor: any) => { + deleteJob = async (monitor: Monitor) => { this.scheduler.removeJob(monitor.id); this.scheduler.removeJob(`${monitor.id}-geo`); }; - pauseJob = async (monitor: any) => { + pauseJob = async (monitor: Monitor) => { const result = await this.scheduler.pauseJob(monitor.id); if (result === false) { throw new Error("Failed to pause monitor"); @@ -161,7 +162,7 @@ export class SuperSimpleQueue implements ISuperSimpleQueue { }); }; - resumeJob = async (monitor: any) => { + resumeJob = async (monitor: Monitor) => { const result = await this.scheduler.resumeJob(monitor.id); if (result === false) { throw new Error("Failed to resume monitor"); @@ -176,7 +177,7 @@ export class SuperSimpleQueue implements ISuperSimpleQueue { }); }; - updateJob = async (monitor: any) => { + updateJob = async (monitor: Monitor) => { this.scheduler.updateJob(monitor.id, { repeat: monitor.interval, data: monitor }); // Handle geo check job lifecycle @@ -244,8 +245,8 @@ export class SuperSimpleQueue implements ISuperSimpleQueue { failingJobs: 0, jobsWithFailures: [] as Array<{ monitorId: string | number; - monitorUrl: any; - monitorType: any; + monitorUrl: string; + monitorType: MonitorType; failedAt: number | null; failCount: number; failReason: string | null; diff --git a/server/src/service/infrastructure/SuperSimpleQueue/SuperSimpleQueueHelper.ts b/server/src/service/infrastructure/SuperSimpleQueue/SuperSimpleQueueHelper.ts index eef0a75be..b6908127b 100644 --- a/server/src/service/infrastructure/SuperSimpleQueue/SuperSimpleQueueHelper.ts +++ b/server/src/service/infrastructure/SuperSimpleQueue/SuperSimpleQueueHelper.ts @@ -2,8 +2,16 @@ const SERVICE_NAME = "JobQueueHelper"; import type { Monitor } from "@/types/monitor.js"; import { supportsGeoCheck } from "@/types/monitor.js"; import { AppError } from "@/utils/AppError.js"; -import { INetworkService, INotificationsService, IStatusService, IncidentService, type IGeoChecksService } from "@/service/index.js"; -import type { StatusChangeResult } from "@/types/index.js"; +import { + ICheckService, + INetworkService, + INotificationsService, + ISettingsService, + IStatusService, + IncidentService, + type IGeoChecksService, +} from "@/service/index.js"; +import { CHECK_TTL_SENTINEL, type MaintenanceWindow, type StatusChangeResult } from "@/types/index.js"; import { IMaintenanceWindowsRepository, IMonitorsRepository, @@ -18,9 +26,10 @@ import { IBufferService } from "@/service/index.js"; export interface ISuperSimpleQueueHelper { readonly serviceName: string; - getMonitorJob(): (monitor: Monitor) => Promise; + getHeartbeatJob(): (monitor: Monitor) => Promise; + getHeartbeatGeoJob(): (monitor: Monitor) => Promise; getCleanupOrphanedJob(): () => Promise; - getGeoCheckJob(): (monitor: Monitor) => Promise; + getCleanupRetentionJob(): () => Promise; isInMaintenanceWindow(monitorId: string, teamId: string): Promise; } @@ -41,11 +50,12 @@ export interface MonitorActionDecision { export class SuperSimpleQueueHelper implements ISuperSimpleQueueHelper { static SERVICE_NAME = SERVICE_NAME; - private logger: any; + private logger: ILogger; private networkService: INetworkService; private statusService: IStatusService; private notificationsService: INotificationsService; - private checkService: any; + private checkService: ICheckService; + private settingsService: ISettingsService; private buffer: IBufferService; private incidentService: IncidentService; private maintenanceWindowsRepository: IMaintenanceWindowsRepository; @@ -62,7 +72,8 @@ export class SuperSimpleQueueHelper implements ISuperSimpleQueueHelper { networkService: INetworkService, statusService: IStatusService, notificationsService: INotificationsService, - checkService: any, + checkService: ICheckService, + settingsService: ISettingsService, buffer: IBufferService, incidentService: IncidentService, maintenanceWindowsRepository: IMaintenanceWindowsRepository, @@ -78,6 +89,7 @@ export class SuperSimpleQueueHelper implements ISuperSimpleQueueHelper { this.networkService = networkService; this.statusService = statusService; this.checkService = checkService; + this.settingsService = settingsService; this.buffer = buffer; this.notificationsService = notificationsService; this.incidentService = incidentService; @@ -95,7 +107,7 @@ export class SuperSimpleQueueHelper implements ISuperSimpleQueueHelper { return SuperSimpleQueueHelper.SERVICE_NAME; } - getMonitorJob = () => { + getHeartbeatJob = () => { return async (monitor: Monitor) => { try { const monitorId = monitor.id; @@ -126,7 +138,7 @@ export class SuperSimpleQueueHelper implements ISuperSimpleQueueHelper { } // Step 3. Build check - const check = await this.checkService.buildCheck(status); + const check = this.checkService.buildCheck(status); if (!check) { this.logger.warn({ message: `No check could be built for monitor ${monitorId}`, @@ -146,33 +158,31 @@ export class SuperSimpleQueueHelper implements ISuperSimpleQueueHelper { // Step 6. Handle notifications (best effort, continue even in event of failure, don't wait) if (decision.shouldSendNotification) { - this.notificationsService.handleNotifications(statusChangeResult.monitor, status, decision).catch((error: any) => { + this.notificationsService.handleNotifications(statusChangeResult.monitor, status, decision).catch((error: unknown) => { this.logger.error({ - message: error.message, + message: `Error sending notifications for job ${statusChangeResult.monitor.id}: ${error instanceof Error ? error.message : "Unknown error"}`, service: SERVICE_NAME, method: "getMonitorJob", - details: `Error sending notifications for job ${statusChangeResult.monitor.id}: ${error.message}`, - stack: error.stack, + stack: error instanceof Error ? error.stack : undefined, }); }); } // Step 7. Handle incidents (best effort, don't wait) - this.incidentService.handleIncident(statusChangeResult.monitor, statusChangeResult.code, decision, status).catch((error: any) => { + this.incidentService.handleIncident(statusChangeResult.monitor, statusChangeResult.code, decision, status).catch((error: unknown) => { this.logger.warn({ - message: error.message, + message: `Error handling incident for job ${monitor.id}: ${error instanceof Error ? error.message : "Unknown error"}`, service: SERVICE_NAME, method: "getMonitorJob", - details: `Error handling incident for job ${monitor.id}: ${error.message}`, - stack: error.stack, + stack: error instanceof Error ? error.stack : undefined, }); }); - } catch (error: any) { + } catch (error: unknown) { this.logger.warn({ - message: error.message, - service: error.service || SERVICE_NAME, - method: error.method || "getMonitorJob", - stack: error.stack, + message: error instanceof Error ? error.message : "Unknown error", + service: SERVICE_NAME, + method: "getMonitorJob", + stack: error instanceof Error ? error.stack : undefined, }); throw error; } @@ -258,19 +268,19 @@ export class SuperSimpleQueueHelper implements ISuperSimpleQueueHelper { service: SERVICE_NAME, method: "getCleanupOrphanedJob", }); - } catch (error: any) { + } catch (error: unknown) { this.logger.warn({ - message: error.message, + message: error instanceof Error ? error.message : "Unknown error", service: SERVICE_NAME, method: "getCleanupOrphanedJob", - stack: error.stack, + stack: error instanceof Error ? error.stack : undefined, }); throw error; } }; }; - getGeoCheckJob = () => { + getHeartbeatGeoJob = () => { return async (monitor: Monitor) => { try { const monitorId = monitor.id; @@ -278,7 +288,7 @@ export class SuperSimpleQueueHelper implements ISuperSimpleQueueHelper { // Step 1: Validate monitor eligibility if (!monitorId) { - throw new AppError({ message: "No monitor id", service: SERVICE_NAME, method: "getGeoCheckJob" }); + throw new AppError({ message: "No monitor id", service: SERVICE_NAME, method: "getHeartbeatGeoJob" }); } if (!monitor.geoCheckEnabled) { @@ -288,7 +298,7 @@ export class SuperSimpleQueueHelper implements ISuperSimpleQueueHelper { this.logger.debug({ message: `Monitor ${monitorId} type does not support geo checks, skipping`, service: SERVICE_NAME, - method: "getGeoCheckJob", + method: "getHeartbeatGeoJob", }); return; } @@ -297,7 +307,7 @@ export class SuperSimpleQueueHelper implements ISuperSimpleQueueHelper { this.logger.warn({ message: `No geo check locations configured for monitor ${monitorId}`, service: SERVICE_NAME, - method: "getGeoCheckJob", + method: "getHeartbeatGeoJob", }); return; } @@ -308,7 +318,7 @@ export class SuperSimpleQueueHelper implements ISuperSimpleQueueHelper { this.logger.debug({ message: `Monitor ${monitorId} is in maintenance window, skipping geo check`, service: SERVICE_NAME, - method: "getGeoCheckJob", + method: "getHeartbeatGeoJob", }); return; } @@ -319,7 +329,7 @@ export class SuperSimpleQueueHelper implements ISuperSimpleQueueHelper { this.logger.warn({ message: `No geo check could be built for monitor ${monitorId}`, service: SERVICE_NAME, - method: "getGeoCheckJob", + method: "getHeartbeatGeoJob", }); return; } @@ -330,14 +340,14 @@ export class SuperSimpleQueueHelper implements ISuperSimpleQueueHelper { this.logger.debug({ message: `Geo check job executed for monitor ${monitorId}`, service: SERVICE_NAME, - method: "getGeoCheckJob", + method: "getHeartbeatGeoJob", }); - } catch (error: any) { + } catch (error: unknown) { this.logger.error({ - message: error.message, + message: error instanceof Error ? error.message : "Unknown error", service: SERVICE_NAME, - method: "getGeoCheckJob", - stack: error.stack, + method: "getHeartbeatGeoJob", + stack: error instanceof Error ? error.stack : undefined, }); // Don't throw - geo check failures shouldn't crash the job scheduler } @@ -347,7 +357,7 @@ export class SuperSimpleQueueHelper implements ISuperSimpleQueueHelper { async isInMaintenanceWindow(monitorId: string, teamId: string) { const maintenanceWindows = await this.maintenanceWindowsRepository.findByMonitorId(monitorId, teamId); // Check for active maintenance window: - const maintenanceWindowIsActive = maintenanceWindows.reduce((acc: any, window: any) => { + const maintenanceWindowIsActive = maintenanceWindows.reduce((acc: boolean, window: MaintenanceWindow) => { if (window.active) { const start = new Date(window.start); const end = new Date(window.end); @@ -374,6 +384,40 @@ export class SuperSimpleQueueHelper implements ISuperSimpleQueueHelper { return maintenanceWindowIsActive; } + getCleanupRetentionJob = () => { + return async () => { + try { + const settings = await this.settingsService.getDBSettings(); + + const checkTTL = settings.checkTTL; // Check TTL is in DAYS, not MS + + if (checkTTL === CHECK_TTL_SENTINEL) { + this.logger.info({ + message: `Check TTL is set to unlimited, skipping cleanup`, + service: SERVICE_NAME, + method: "getCleanupRetentionJob", + }); + return; + } + const checkTTLInMs = checkTTL * 24 * 60 * 60 * 1000; + const cutoffDate = new Date(Date.now() - checkTTLInMs); + const deleteCount = await this.checkService.deleteOlderThan(cutoffDate); + this.logger.info({ + message: `Deleted ${deleteCount} checks older than ${cutoffDate.toISOString()}`, + service: SERVICE_NAME, + method: "getCleanupRetentionJob", + }); + } catch (error: unknown) { + this.logger.error({ + message: error instanceof Error ? error.message : "Unknown error", + service: SERVICE_NAME, + method: "getCleanupRetentionJob", + stack: error instanceof Error ? error.stack : undefined, + }); + } + }; + }; + private evaluateMonitorAction(statusChangeResult: StatusChangeResult): MonitorActionDecision { const { monitor, statusChanged, prevStatus } = statusChangeResult; diff --git a/server/src/service/infrastructure/networkService.ts b/server/src/service/infrastructure/networkService.ts index e475bc24b..9111ef7b2 100644 --- a/server/src/service/infrastructure/networkService.ts +++ b/server/src/service/infrastructure/networkService.ts @@ -1,4 +1,4 @@ -import type { Monitor, MonitorStatusResponse } from "@/types/index.js"; +import type { Monitor, MonitorStatusResponse, MonitorPayloadMap, MonitorType } from "@/types/index.js"; import type { AxiosStatic } from "axios"; import { AppError } from "@/utils/AppError.js"; import { NETWORK_ERROR } from "@/service/infrastructure/network/utils.js"; @@ -8,7 +8,7 @@ const SERVICE_NAME = "NetworkService"; export interface INetworkService { readonly serviceName: string; - requestStatus(monitor: Monitor): Promise>; + requestStatus(monitor: Monitor & { type: T }): Promise>; requestWebhook( type: string, url: string, @@ -43,12 +43,12 @@ export class NetworkService implements INetworkService { } // Main entry point - async requestStatus(monitor: Monitor) { + async requestStatus(monitor: Monitor & { type: T }): Promise> { const provider = this.providers.find((p) => p.supports(monitor.type)); if (!provider) { - return this.handleUnsupportedType(monitor.type); + return this.handleUnsupportedType(monitor.type) as Promise>; } - return provider.handle(monitor); + return provider.handle(monitor) as Promise>; } private async handleUnsupportedType(type: string): Promise { diff --git a/server/src/types/check.ts b/server/src/types/check.ts index 6c4c26445..f4c64f926 100644 --- a/server/src/types/check.ts +++ b/server/src/types/check.ts @@ -1,5 +1,8 @@ import type { MonitorType } from "@/types/index.js"; import type { Response } from "got"; + +export const CHECK_TTL_SENTINEL = 366; + export type GotTimings = Response["timings"]; export interface CheckMetadata { @@ -98,9 +101,6 @@ export interface Check { timings?: GotTimings; statusCode: number; message: string; - ack: boolean; - ackAt?: string | null; - expiry: string; cpu?: CheckCpuInfo; memory?: CheckMemoryInfo; disk?: CheckDiskInfo[]; @@ -113,7 +113,6 @@ export interface Check { seo?: number; performance?: number; audits?: CheckAudits; - __v: number; createdAt: string; updatedAt: string; } @@ -205,4 +204,4 @@ export type NormalizedUptimeCheck = T & { originalAvgResponseTime: number; }; -export type CheckSnapshot = Omit; +export type CheckSnapshot = Omit; diff --git a/server/src/types/network.ts b/server/src/types/network.ts index 07e7c3736..6c7172724 100644 --- a/server/src/types/network.ts +++ b/server/src/types/network.ts @@ -126,7 +126,7 @@ export interface MonitorPayloadMap { game: GameStatusPayload; grpc: GrpcStatusPayload; websocket: WebSocketStatusPayload; - default: unknown; + unknown: unknown; } export type StatusChangeResult = { diff --git a/server/src/validation/checkValidation.ts b/server/src/validation/checkValidation.ts index d4f75f3ac..e511118e6 100644 --- a/server/src/validation/checkValidation.ts +++ b/server/src/validation/checkValidation.ts @@ -12,33 +12,31 @@ export const getChecksParamValidation = z.object({ export const getChecksQueryValidation = z.object({ type: z.enum(["http", "ping", "pagespeed", "hardware", "docker", "port", "game", "grpc", "websocket"]).optional(), - sortOrder: z.enum(["asc", "desc"]).optional(), + sortOrder: z.enum(["asc", "desc"]), limit: z.coerce.number().optional(), - dateRange: z.enum(["recent", "hour", "day", "week", "month", "all"]).optional(), + dateRange: z.enum(["recent", "hour", "day", "week", "month", "all"]), filter: z.enum(["all", "up", "down", "resolve"]).optional(), ack: booleanCoercion.optional(), - page: z.coerce.number().optional(), - rowsPerPage: z.coerce.number().optional(), + page: z.coerce.number(), + rowsPerPage: z.coerce.number(), status: booleanCoercion.optional(), continent: z.union([z.enum(GeoContinents), z.array(z.enum(GeoContinents))]).optional(), }); export const getTeamChecksQueryValidation = z.object({ - sortOrder: z.enum(["asc", "desc"]).optional(), + sortOrder: z.enum(["asc", "desc"]), limit: z.coerce.number().optional(), - dateRange: z.enum(["recent", "hour", "day", "week", "month", "all"]).optional(), + dateRange: z.enum(["recent", "hour", "day", "week", "month", "all"]), filter: z.enum(["all", "up", "down", "resolve"]).optional(), ack: booleanCoercion.optional(), - page: z.coerce.number().optional(), - rowsPerPage: z.coerce.number().optional(), + page: z.coerce.number(), + rowsPerPage: z.coerce.number(), }); export const deleteChecksParamValidation = z.object({ monitorId: z.string().min(1, "Monitor ID is required"), }); -export const deleteChecksByTeamIdParamValidation = z.object({}); - -export const updateChecksTTLBodyValidation = z.object({ - ttl: z.number().min(1, "TTL is required"), +export const getChecksSummaryByTeamIdQueryValidation = z.object({ + dateRange: z.enum(["recent", "hour", "day", "week", "month", "all"]).optional(), }); diff --git a/server/src/validation/settingsValidation.ts b/server/src/validation/settingsValidation.ts index ee2a2ee87..e7f0dc9b4 100644 --- a/server/src/validation/settingsValidation.ts +++ b/server/src/validation/settingsValidation.ts @@ -1,3 +1,4 @@ +import { CHECK_TTL_SENTINEL } from "@/types/check.js"; import { z } from "zod"; //**************************************** @@ -6,9 +7,8 @@ import { z } from "zod"; export const updateAppSettingsBodyValidation = z .object({ - checkTTL: z.union([z.number(), z.literal("")]).optional(), + checkTTL: z.union([z.number().int().min(1).max(CHECK_TTL_SENTINEL), z.literal("")]).optional(), systemEmailPort: z.union([z.number(), z.literal("")]).optional(), - pagespeedApiKey: z.union([z.string(), z.literal("")]).optional(), language: z.union([z.string(), z.literal("")]).optional(), timezone: z.union([z.string(), z.literal("")]).optional(),