Merge pull request #3398 from bluewave-labs/feat/retention

feat: retention
This commit is contained in:
Alexander Holliday authored and GitHub committed 2026-03-10 10:45:24 -07:00
commit 3e26387b9d
20 files changed
+349 -218

No files matched your search

+19 -3
View File
@@ -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<HTMLSpanElement, SliderInputProps>(
({ 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 (
<Stack
gap={theme.spacing(8)}
direction={"row"}
alignItems={"center"}
>
{showValue && <Typography>{props.value}</Typography>}
{showValue && <Typography>{displayValue}</Typography>}
<Slider
{...props}
ref={ref}
@@ -63,12 +69,21 @@ interface SliderWithLabelProps extends SliderProps {
fieldLabel?: string;
required?: boolean;
showValue?: boolean;
formatDisplayValue?: (value: number) => string;
sliderMaxWidth?: ResponsiveStyleValue<number | string>;
}
export const SliderWithLabel = forwardRef<HTMLSpanElement, SliderWithLabelProps>(
(
{ 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<HTMLSpanElement, SliderWithLabelProps>
<SliderInput
{...props}
showValue={showValue}
formatDisplayValue={formatDisplayValue}
value={value}
ref={ref}
/>
+38
View File
@@ -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 && (
<ConfigBox
title={t("pages.settings.form.retention.title")}
subtitle={t("pages.settings.form.retention.description")}
rightContent={
<Controller
name="checkTTL"
control={form.control}
defaultValue={defaults.checkTTL}
render={({ field }) => (
<SliderWithLabel
{...field}
fieldLabel={t("pages.settings.form.retention.option.days.label")}
min={1}
max={CHECK_TTL_SENTINEL}
sliderMaxWidth={{ xs: "100%", md: "50%" }}
value={field.value || 30}
onChange={(_, value) => 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 && (
<ConfigBox
+3 -8
View File
@@ -1,3 +1,5 @@
export const CHECK_TTL_SENTINEL = 366;
export interface CheckMetadata {
monitorId: string;
teamId: string;
@@ -125,9 +127,6 @@ export interface Check {
timings?: CheckTimings;
statusCode: number;
message: string;
ack: boolean;
ackAt?: string | null;
expiry: string;
cpu?: CheckCpuInfo;
memory?: CheckMemoryInfo;
disk?: CheckDiskInfo[];
@@ -140,7 +139,6 @@ export interface Check {
seo?: number;
performance?: number;
audits?: CheckAudits;
__v: number;
createdAt: string;
updatedAt: string;
}
@@ -243,10 +241,7 @@ export interface ChecksSummary {
downChecks: number;
}
export type CheckSnapshot = Omit<
Check,
"metadata" | "ack" | "ackAt" | "expiry" | "__v" | "updatedAt"
> & {
export type CheckSnapshot = Omit<Check, "metadata" | "__v" | "updatedAt"> & {
originalResponseTime: number;
};
+6 -1
View File
@@ -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()))
+11 -1
View File
@@ -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.",
+1
View File
@@ -252,6 +252,7 @@ export const initializeServices = async ({
statusService,
notificationsService,
checkService,
settingsService,
bufferService,
incidentService,
maintenanceWindowsRepository,
+37 -37
View File
@@ -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;
+2 -16
View File
@@ -21,13 +21,10 @@ type CheckMetadataDocument = Omit<CheckMetadata, "monitorId" | "teamId"> & {
type: MonitorType;
};
type CheckDocumentBase = Omit<Check, "id" | "metadata" | "expiry" | "ackAt" | "createdAt" | "updatedAt"> & {
type CheckDocumentBase = Omit<Check, "id" | "metadata" | "createdAt" | "updatedAt"> & {
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<CheckDocument>(
message: {
type: String,
},
expiry: {
type: Date,
default: Date.now,
},
ack: {
type: Boolean,
default: false,
},
ackAt: {
type: Date,
default: undefined,
},
cpu: {
type: cpuSchema,
default: () => ({}),
@@ -47,4 +47,5 @@ export interface IChecksRepository {
deleteByMonitorId(monitorId: string): Promise<number>;
deleteByTeamId(teamId: string): Promise<number>;
deleteByMonitorIdsNotIn(monitorIds: string[]): Promise<number>;
deleteOlderThan(date: Date): Promise<number>;
}
@@ -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<string, any> = {
const matchStage: Record<string, unknown> = {
"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<string, any> = {
const matchStage: Record<string, unknown> = {
"metadata.teamId": new mongoose.Types.ObjectId(teamId),
...(getDateForRange(dateRange) && {
createdAt: {
@@ -358,7 +348,7 @@ class MongoChecksRepository implements IChecksRepository {
);
const mapped = results.reduce<LatestChecksMap>((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<number> => {
// 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<MonitorType, "hardware" | "pagespeed">,
monitorObjectId: mongoose.Types.ObjectId,
-1
View File
@@ -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);
+76 -53
View File
@@ -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<Check[]>;
buildCheck(statusResponse: MonitorStatusResponse<PageSpeedStatusPayload | HardwareStatusPayload | undefined>): Partial<Check> | undefined;
getChecksByMonitor(params: { monitorId: string; query: Record<string, string>; teamId: string }): Promise<ChecksQueryResult>;
getChecksByTeam(params: { teamId: string; query: Record<string, string> }): Promise<ChecksQueryResult>;
buildCheck(statusResponse: MonitorStatusResponse<MonitorPayloadMap[keyof MonitorPayloadMap]>): Check | undefined;
getChecksByMonitor(params: {
monitorId: string;
teamId: string;
sortOrder: string;
dateRange: string;
page: number;
rowsPerPage: number;
filter?: string;
status?: boolean;
}): Promise<ChecksQueryResult>;
getChecksByTeam(params: {
teamId: string;
sortOrder: string;
dateRange: string;
page: number;
rowsPerPage: number;
filter?: string;
}): Promise<ChecksQueryResult>;
getChecksSummaryByTeamId(params: { teamId: string; dateRange: string }): Promise<ChecksSummary>;
deleteChecks(params: { monitorId: string; teamId: string }): Promise<number>;
deleteChecksByTeamId(params: { teamId: string }): Promise<number>;
updateChecksTTL(params: { teamId: string; ttl: string }): Promise<void>;
deleteOlderThan(date: Date): Promise<number>;
}
export class CheckService implements ICheckService {
@@ -39,12 +62,14 @@ export class CheckService implements ICheckService {
return this.checksRepository.createChecks(checks);
};
buildCheck = (statusResponse: MonitorStatusResponse<PageSpeedStatusPayload | HardwareStatusPayload | undefined>) => {
buildCheck = (statusResponse: MonitorStatusResponse<MonitorPayloadMap[keyof MonitorPayloadMap]>): Check | undefined => {
const { monitorId, teamId, type, status, responseTime, code, message, payload, timings } = statusResponse;
const check: Partial<Check> = {
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<string, string>; 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<string, string> }) => {
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;
};
}
+1 -1
View File
@@ -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);
})
);
}
@@ -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<boolean>;
addJob(monitorId: string, monitor: any): Promise<void>;
deleteJob(monitor: any): Promise<void>;
pauseJob(monitor: any): Promise<void>;
resumeJob(monitor: any): Promise<void>;
updateJob(monitor: any): Promise<void>;
addJob(monitorId: string, monitor: Monitor): Promise<void>;
deleteJob(monitor: Monitor): Promise<void>;
pauseJob(monitor: Monitor): Promise<void>;
resumeJob(monitor: Monitor): Promise<void>;
updateJob(monitor: Monitor): Promise<void>;
shutdown(): Promise<void>;
getMetrics(): Promise<QueueMetrics>;
getJobs(): Promise<QueueJobSummary[]>;
@@ -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;
@@ -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<void>;
getHeartbeatJob(): (monitor: Monitor) => Promise<void>;
getHeartbeatGeoJob(): (monitor: Monitor) => Promise<void>;
getCleanupOrphanedJob(): () => Promise<void>;
getGeoCheckJob(): (monitor: Monitor) => Promise<void>;
getCleanupRetentionJob(): () => Promise<void>;
isInMaintenanceWindow(monitorId: string, teamId: string): Promise<boolean>;
}
@@ -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;
@@ -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<MonitorStatusResponse<unknown>>;
requestStatus<T extends MonitorType>(monitor: Monitor & { type: T }): Promise<MonitorStatusResponse<MonitorPayloadMap[T]>>;
requestWebhook(
type: string,
url: string,
@@ -43,12 +43,12 @@ export class NetworkService implements INetworkService {
}
// Main entry point
async requestStatus(monitor: Monitor) {
async requestStatus<T extends MonitorType>(monitor: Monitor & { type: T }): Promise<MonitorStatusResponse<MonitorPayloadMap[T]>> {
const provider = this.providers.find((p) => p.supports(monitor.type));
if (!provider) {
return this.handleUnsupportedType(monitor.type);
return this.handleUnsupportedType(monitor.type) as Promise<MonitorStatusResponse<MonitorPayloadMap[T]>>;
}
return provider.handle(monitor);
return provider.handle(monitor) as Promise<MonitorStatusResponse<MonitorPayloadMap[T]>>;
}
private async handleUnsupportedType(type: string): Promise<MonitorStatusResponse> {
+4 -5
View File
@@ -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 extends GroupedCheck = GroupedCheck> = T & {
originalAvgResponseTime: number;
};
export type CheckSnapshot = Omit<Check, "metadata" | "ack" | "ackAt" | "expiry" | "__v" | "updatedAt">;
export type CheckSnapshot = Omit<Check, "metadata" | "updatedAt">;
+1 -1
View File
@@ -126,7 +126,7 @@ export interface MonitorPayloadMap {
game: GameStatusPayload;
grpc: GrpcStatusPayload;
websocket: WebSocketStatusPayload;
default: unknown;
unknown: unknown;
}
export type StatusChangeResult = {
+10 -12
View File
@@ -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(),
});
+2 -2
View File
@@ -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(),