Merge branch 'develop' of https://github.com/bluewave-labs/checkmate into feat/customcss-status-page
# Conflicts: # server/src/api/routes/statusPageRoute.ts
This commit is contained in:
commit
6165ae60e6
68 files changed
+2074
-1125
No files matched your search
@@ -1,4 +1,4 @@
|
||||
import { IJobQueue } from "@/service/job-queues/job-queue.interface.js";
|
||||
import { IWorker } from "@/worker/worker.interface.js";
|
||||
import { getQueueJobsQueryValidation } from "@/api/validation/queueValidation.js";
|
||||
import { Request, Response, NextFunction } from "express";
|
||||
|
||||
@@ -13,9 +13,9 @@ export interface IJobQueueController {
|
||||
|
||||
class JobQueueController implements IJobQueueController {
|
||||
static SERVICE_NAME = SERVICE_NAME;
|
||||
private jobQueue: IJobQueue;
|
||||
constructor(jobQueue: IJobQueue) {
|
||||
this.jobQueue = jobQueue;
|
||||
private worker: IWorker;
|
||||
constructor(worker: IWorker) {
|
||||
this.worker = worker;
|
||||
}
|
||||
|
||||
get serviceName() {
|
||||
@@ -24,7 +24,7 @@ class JobQueueController implements IJobQueueController {
|
||||
|
||||
getMetrics = async (req: Request, res: Response, next: NextFunction) => {
|
||||
try {
|
||||
const metrics = await this.jobQueue.getMetrics();
|
||||
const metrics = await this.worker.getMetrics();
|
||||
res.status(200).json({
|
||||
success: true,
|
||||
msg: "Queue metrics fetched successfully",
|
||||
@@ -38,7 +38,7 @@ class JobQueueController implements IJobQueueController {
|
||||
getJobs = async (req: Request, res: Response, next: NextFunction) => {
|
||||
try {
|
||||
const pagination = getQueueJobsQueryValidation.parse(req.query);
|
||||
const { jobs, count } = await this.jobQueue.getJobs(pagination);
|
||||
const { jobs, count } = await this.worker.getJobs(pagination);
|
||||
return res.status(200).json({
|
||||
success: true,
|
||||
msg: "Queue jobs fetched successfully",
|
||||
@@ -52,8 +52,8 @@ class JobQueueController implements IJobQueueController {
|
||||
getAllMetrics = async (req: Request, res: Response, next: NextFunction) => {
|
||||
try {
|
||||
const pagination = getQueueJobsQueryValidation.parse(req.query);
|
||||
const { jobs, count } = await this.jobQueue.getJobs(pagination);
|
||||
const metrics = await this.jobQueue.getMetrics();
|
||||
const { jobs, count } = await this.worker.getJobs(pagination);
|
||||
const metrics = await this.worker.getMetrics();
|
||||
return res.status(200).json({
|
||||
success: true,
|
||||
msg: "Queue metrics fetched successfully",
|
||||
@@ -66,7 +66,7 @@ class JobQueueController implements IJobQueueController {
|
||||
|
||||
flushQueue = async (req: Request, res: Response, next: NextFunction) => {
|
||||
try {
|
||||
const result = await this.jobQueue.flushQueues();
|
||||
const result = await this.worker.flushQueues();
|
||||
return res.status(200).json({
|
||||
success: true,
|
||||
msg: "Queue flushed successfully",
|
||||
|
||||
@@ -5,6 +5,7 @@ import {
|
||||
getStatusPageParamValidation,
|
||||
getStatusPageQueryValidation,
|
||||
imageValidation,
|
||||
resolveStatusPageQueryValidation,
|
||||
} from "@/api/validation/statusPageValidation.js";
|
||||
import { AppError } from "@/utils/AppError.js";
|
||||
import { requireTeamId, requireUserId } from "@/api/controllers/controllerUtils.js";
|
||||
@@ -12,6 +13,8 @@ import { IStatusPageService } from "@/domain/status-pages/status-page.service.js
|
||||
import { IMonitorsRepository } from "@/domain/monitors/monitor.repository.interface.js";
|
||||
import { ISettingsService } from "@/domain/app-settings/app-settings.service.js";
|
||||
import { NormalizeData } from "@/utils/dataUtils.js";
|
||||
import { resolveStatusPageDomainFromRequest } from "@/utils/statusPageDomain.js";
|
||||
import type { StatusPage } from "@/domain/status-pages/status-page.type.js";
|
||||
|
||||
const SERVICE_NAME = "statusPageController";
|
||||
|
||||
@@ -20,6 +23,7 @@ export interface IStatusPageController {
|
||||
createStatusPage(req: Request, res: Response, next: NextFunction): Promise<Response | void>;
|
||||
updateStatusPage(req: Request, res: Response, next: NextFunction): Promise<Response | void>;
|
||||
getStatusPageByUrl(req: Request, res: Response, next: NextFunction): Promise<Response | void>;
|
||||
resolveStatusPageByDomain(req: Request, res: Response, next: NextFunction): Promise<Response | void>;
|
||||
getStatusPagesByTeamId(req: Request, res: Response, next: NextFunction): Promise<Response | void>;
|
||||
deleteStatusPage(req: Request, res: Response, next: NextFunction): Promise<Response | void>;
|
||||
}
|
||||
@@ -39,6 +43,41 @@ class StatusPageController implements IStatusPageController {
|
||||
return StatusPageController.SERVICE_NAME;
|
||||
}
|
||||
|
||||
private buildStatusPagePayload = async (statusPage: StatusPage, req: Request) => {
|
||||
if (!statusPage.isPublished) {
|
||||
const teamId = requireTeamId(req?.user?.teamId);
|
||||
if (statusPage.teamId !== teamId) {
|
||||
throw new AppError({ message: "Forbidden", status: 403 });
|
||||
}
|
||||
}
|
||||
|
||||
const settings = await this.settingsService.getDBSettings();
|
||||
const showURL = settings.showURL;
|
||||
|
||||
const monitors = await this.monitorsRepository.findByIds(statusPage.monitors);
|
||||
const monitorOrder = new Map(statusPage.monitors.map((id, index) => [id, index]));
|
||||
const sortedMonitors = [...monitors].sort((a, b) => {
|
||||
const orderA = monitorOrder.get(a.id) ?? Number.MAX_SAFE_INTEGER;
|
||||
const orderB = monitorOrder.get(b.id) ?? Number.MAX_SAFE_INTEGER;
|
||||
return orderA - orderB;
|
||||
});
|
||||
|
||||
const normalizedMonitors = sortedMonitors.map((monitor) => {
|
||||
const normalizedChecks = NormalizeData(monitor.recentChecks, 10, 100);
|
||||
if (!showURL) {
|
||||
// eslint-disable-next-line @typescript-eslint/no-unused-vars
|
||||
const { url, port, secret, notifications, ...rest } = monitor;
|
||||
return { ...rest, checks: normalizedChecks };
|
||||
}
|
||||
return { ...monitor, checks: normalizedChecks };
|
||||
});
|
||||
|
||||
return {
|
||||
statusPage,
|
||||
monitors: normalizedMonitors,
|
||||
};
|
||||
};
|
||||
|
||||
createStatusPage = async (req: Request, res: Response, next: NextFunction) => {
|
||||
try {
|
||||
createStatusPageBodyValidation.parse(req.body);
|
||||
@@ -96,47 +135,44 @@ class StatusPageController implements IStatusPageController {
|
||||
}
|
||||
|
||||
const statusPage = await this.statusPageService.getStatusPageByUrl(req.params.url as string);
|
||||
const data = await this.buildStatusPagePayload(statusPage, req);
|
||||
|
||||
if (!statusPage.isPublished) {
|
||||
const teamId = requireTeamId(req?.user?.teamId);
|
||||
if (statusPage.teamId !== teamId) {
|
||||
throw new AppError({ message: "Forbidden", status: 403 });
|
||||
}
|
||||
}
|
||||
|
||||
const settings = await this.settingsService.getDBSettings();
|
||||
const showURL = settings.showURL;
|
||||
|
||||
const monitors = await this.monitorsRepository.findByIds(statusPage.monitors);
|
||||
// Sort monitors according to the order in statusPage.monitors
|
||||
const monitorOrder = new Map(statusPage.monitors.map((id, index) => [id, index]));
|
||||
const sortedMonitors = [...monitors].sort((a, b) => {
|
||||
const orderA = monitorOrder.get(a.id) ?? Number.MAX_SAFE_INTEGER;
|
||||
const orderB = monitorOrder.get(b.id) ?? Number.MAX_SAFE_INTEGER;
|
||||
return orderA - orderB;
|
||||
});
|
||||
|
||||
const normalizedMonitors = sortedMonitors.map((monitor) => {
|
||||
const normalizedChecks = NormalizeData(monitor.recentChecks, 10, 100);
|
||||
if (!showURL) {
|
||||
// eslint-disable-next-line @typescript-eslint/no-unused-vars
|
||||
const { url, port, secret, notifications, ...rest } = monitor;
|
||||
return { ...rest, checks: normalizedChecks };
|
||||
}
|
||||
return { ...monitor, checks: normalizedChecks };
|
||||
});
|
||||
return res.status(200).json({
|
||||
success: true,
|
||||
msg: "Status page retrieved successfully",
|
||||
data: {
|
||||
statusPage,
|
||||
monitors: normalizedMonitors,
|
||||
},
|
||||
data,
|
||||
});
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
};
|
||||
|
||||
resolveStatusPageByDomain = async (req: Request, res: Response, next: NextFunction) => {
|
||||
try {
|
||||
resolveStatusPageQueryValidation.parse(req.query);
|
||||
|
||||
const domain = resolveStatusPageDomainFromRequest(req.hostname, req.query.domain as string | undefined);
|
||||
if (!domain) {
|
||||
throw new AppError({ message: "Domain is required", status: 400 });
|
||||
}
|
||||
|
||||
const statusPage = await this.statusPageService.getStatusPageByCustomDomain(domain);
|
||||
if (!statusPage.isPublished) {
|
||||
throw new AppError({ message: "Status page not found", status: 404 });
|
||||
}
|
||||
|
||||
const data = await this.buildStatusPagePayload(statusPage, req);
|
||||
|
||||
return res.status(200).json({
|
||||
success: true,
|
||||
msg: "Status page retrieved successfully",
|
||||
data,
|
||||
});
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
};
|
||||
|
||||
getStatusPagesByTeamId = async (req: Request, res: Response, next: NextFunction) => {
|
||||
try {
|
||||
const teamId = requireTeamId(req.user?.teamId);
|
||||
|
||||
@@ -1,11 +1,15 @@
|
||||
import type { NextFunction, Request, Response } from "express";
|
||||
import { MulterError } from "multer";
|
||||
import { logger } from "@/utils/logger.js";
|
||||
import { AppError } from "@/utils/AppError.js";
|
||||
|
||||
// Multer throws MulterError, send 413 Payload Too Large / 400
|
||||
const multerErrorStatus = (error: MulterError): number => (error.code === "LIMIT_FILE_SIZE" ? 413 : 400);
|
||||
|
||||
const handleErrors = (error: unknown, req: Request, res: Response, _next: NextFunction) => {
|
||||
const status = error instanceof AppError ? error.status || 500 : 500;
|
||||
const status = error instanceof MulterError ? multerErrorStatus(error) : error instanceof AppError ? error.status || 500 : 500;
|
||||
const message = error instanceof AppError ? error.message : error instanceof Error ? error.message : "Server error";
|
||||
const service = error instanceof AppError ? error.service : "unknownService";
|
||||
const service = error instanceof AppError ? error.service : error instanceof MulterError ? "uploadMiddleware" : "unknownService";
|
||||
const method = error instanceof AppError ? error.method : "unknownMethod";
|
||||
logger.error({
|
||||
message: message,
|
||||
|
||||
@@ -0,0 +1,68 @@
|
||||
import type { CorsOptions } from "cors";
|
||||
import type { IStatusPagesRepository } from "@/domain/status-pages/status-page-repository.interface.js";
|
||||
import { normalizeStatusPageDomain } from "@/utils/statusPageDomain.js";
|
||||
|
||||
const CUSTOM_DOMAIN_CORS_POSITIVE_CACHE_TTL_MS = 5 * 60 * 1000;
|
||||
const CUSTOM_DOMAIN_CORS_NEGATIVE_CACHE_TTL_MS = 60 * 1000;
|
||||
|
||||
type CustomDomainCorsCacheEntry = {
|
||||
allowed: boolean;
|
||||
expiresAt: number;
|
||||
};
|
||||
|
||||
const getCustomDomainCorsCacheTtlMs = (allowed: boolean): number =>
|
||||
allowed ? CUSTOM_DOMAIN_CORS_POSITIVE_CACHE_TTL_MS : CUSTOM_DOMAIN_CORS_NEGATIVE_CACHE_TTL_MS;
|
||||
|
||||
export const createStatusPageCorsOrigin = (clientHost: string, statusPagesRepository: IStatusPagesRepository): CorsOptions["origin"] => {
|
||||
const cache = new Map<string, CustomDomainCorsCacheEntry>();
|
||||
|
||||
const cacheOriginResult = (customDomain: string, allowed: boolean) => {
|
||||
cache.set(customDomain, {
|
||||
allowed,
|
||||
expiresAt: Date.now() + getCustomDomainCorsCacheTtlMs(allowed),
|
||||
});
|
||||
};
|
||||
|
||||
return (origin, callback) => {
|
||||
if (!origin) {
|
||||
callback(null, true);
|
||||
return;
|
||||
}
|
||||
|
||||
if (origin === clientHost) {
|
||||
callback(null, true);
|
||||
return;
|
||||
}
|
||||
|
||||
let customDomain: string | null = null;
|
||||
try {
|
||||
customDomain = normalizeStatusPageDomain(new URL(origin).hostname);
|
||||
} catch {
|
||||
callback(null, false);
|
||||
return;
|
||||
}
|
||||
|
||||
if (!customDomain) {
|
||||
callback(null, false);
|
||||
return;
|
||||
}
|
||||
|
||||
const cached = cache.get(customDomain);
|
||||
if (cached && cached.expiresAt > Date.now()) {
|
||||
callback(null, cached.allowed ? origin : false);
|
||||
return;
|
||||
}
|
||||
|
||||
statusPagesRepository
|
||||
.findByCustomDomain(customDomain)
|
||||
.then((statusPage) => {
|
||||
const allowed = statusPage.isPublished;
|
||||
cacheOriginResult(customDomain, allowed);
|
||||
callback(null, allowed ? origin : false);
|
||||
})
|
||||
.catch(() => {
|
||||
cacheOriginResult(customDomain, false);
|
||||
callback(null, false);
|
||||
});
|
||||
};
|
||||
};
|
||||
@@ -0,0 +1,24 @@
|
||||
const PUBLIC_STATUS_PAGE_API_PREFIX = "/api/v1/status-page";
|
||||
|
||||
export const isPublicStatusPageApiPath = (method: string, path: string): boolean => {
|
||||
if (!path.startsWith(PUBLIC_STATUS_PAGE_API_PREFIX)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (method !== "GET" && method !== "OPTIONS") {
|
||||
return false;
|
||||
}
|
||||
|
||||
const subPath = path.slice(PUBLIC_STATUS_PAGE_API_PREFIX.length).split("?")[0] ?? "";
|
||||
const slug = subPath.replace(/^\//, "");
|
||||
|
||||
if (slug === "resolve") {
|
||||
return true;
|
||||
}
|
||||
|
||||
if (!slug || slug === "team" || slug.includes("/")) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
};
|
||||
@@ -0,0 +1,27 @@
|
||||
import multer from "multer";
|
||||
import { AppError } from "@/utils/AppError.js";
|
||||
import { ImageMimeTypes, MAX_IMAGE_SIZE_BYTES } from "@/types/upload.js";
|
||||
|
||||
// Reusable multer instance for handling image uploads
|
||||
const imageUpload = multer({
|
||||
limits: {
|
||||
fileSize: MAX_IMAGE_SIZE_BYTES,
|
||||
files: 1,
|
||||
},
|
||||
fileFilter: (_req, file, cb) => {
|
||||
if (!(ImageMimeTypes as readonly string[]).includes(file.mimetype)) {
|
||||
cb(
|
||||
new AppError({
|
||||
status: 415,
|
||||
message: "File must be a valid image (jpeg, jpg, or png)",
|
||||
service: "uploadMiddleware",
|
||||
method: "fileFilter",
|
||||
})
|
||||
);
|
||||
return;
|
||||
}
|
||||
cb(null, true);
|
||||
},
|
||||
});
|
||||
|
||||
export { imageUpload };
|
||||
@@ -1,10 +1,8 @@
|
||||
import { Router, RequestHandler } from "express";
|
||||
import { isAllowed } from "../middleware/isAllowed.js";
|
||||
import multer from "multer";
|
||||
import { isAllowed } from "@/api/middleware/isAllowed.js";
|
||||
import { imageUpload } from "@/api/middleware/upload.js";
|
||||
import { IAuthController } from "@/api/controllers/authController.js";
|
||||
|
||||
const upload = multer();
|
||||
|
||||
class AuthRoutes {
|
||||
private router: Router;
|
||||
private authController: IAuthController;
|
||||
@@ -16,7 +14,7 @@ class AuthRoutes {
|
||||
}
|
||||
|
||||
initRoutes(verifyJWT: RequestHandler) {
|
||||
this.router.post("/register", upload.single("profileImage"), this.authController.registerUser);
|
||||
this.router.post("/register", imageUpload.single("profileImage"), this.authController.registerUser);
|
||||
this.router.post("/login", this.authController.loginUser);
|
||||
|
||||
this.router.post("/recovery/request", this.authController.requestRecovery);
|
||||
@@ -26,13 +24,19 @@ class AuthRoutes {
|
||||
this.router.get("/users/superadmin", this.authController.checkSuperadminExists);
|
||||
|
||||
this.router.get("/users", verifyJWT, isAllowed(["admin", "superadmin"]), this.authController.getAllUsers);
|
||||
this.router.post("/users", verifyJWT, isAllowed(["superadmin"]), upload.single("profileImage"), this.authController.createUser);
|
||||
this.router.post("/users", verifyJWT, isAllowed(["superadmin"]), imageUpload.single("profileImage"), this.authController.createUser);
|
||||
this.router.get("/users/:userId", verifyJWT, isAllowed(["admin", "superadmin"]), this.authController.getUserById);
|
||||
this.router.patch("/users/:userId", verifyJWT, isAllowed(["superadmin"]), this.authController.editUserById);
|
||||
this.router.patch("/users/:userId/password", verifyJWT, isAllowed(["superadmin"]), this.authController.editUserPasswordById);
|
||||
this.router.delete("/users/:userId", verifyJWT, isAllowed(["admin", "superadmin"]), this.authController.deleteUserById);
|
||||
|
||||
this.router.patch("/user", verifyJWT, upload.single("profileImage"), isAllowed(["admin", "superadmin", "user"]), this.authController.editUser);
|
||||
this.router.patch(
|
||||
"/user",
|
||||
verifyJWT,
|
||||
imageUpload.single("profileImage"),
|
||||
isAllowed(["admin", "superadmin", "user"]),
|
||||
this.authController.editUser
|
||||
);
|
||||
this.router.delete("/user", verifyJWT, isAllowed(["admin", "superadmin", "user"]), this.authController.deleteUser);
|
||||
}
|
||||
|
||||
|
||||
@@ -1,8 +1,7 @@
|
||||
import { IStatusPageController } from "@/api/controllers/statusPageController.js";
|
||||
import { RequestHandler, Router } from "express";
|
||||
import { isAllowed } from "@/api/middleware/isAllowed.js";
|
||||
import multer from "multer";
|
||||
const upload = multer();
|
||||
import { imageUpload } from "@/api/middleware/upload.js";
|
||||
|
||||
class StatusPageRoutes {
|
||||
private router: Router;
|
||||
@@ -17,9 +16,10 @@ class StatusPageRoutes {
|
||||
initRoutes(verifyJWT: RequestHandler, verifyStatusPageAccess: RequestHandler) {
|
||||
this.router.get("/team", verifyJWT, this.statusPageController.getStatusPagesByTeamId);
|
||||
|
||||
this.router.post("/", upload.single("logo"), verifyJWT, isAllowed(["admin", "superadmin"]), this.statusPageController.createStatusPage);
|
||||
this.router.put("/:id", upload.single("logo"), verifyJWT, isAllowed(["admin", "superadmin"]), this.statusPageController.updateStatusPage);
|
||||
this.router.post("/", imageUpload.single("logo"), verifyJWT, isAllowed(["admin", "superadmin"]), this.statusPageController.createStatusPage);
|
||||
this.router.put("/:id", imageUpload.single("logo"), verifyJWT, isAllowed(["admin", "superadmin"]), this.statusPageController.updateStatusPage);
|
||||
|
||||
this.router.get("/resolve", this.statusPageController.resolveStatusPageByDomain);
|
||||
this.router.get("/:url", verifyStatusPageAccess, this.statusPageController.getStatusPageByUrl);
|
||||
this.router.delete("/:id", verifyJWT, isAllowed(["admin", "superadmin"]), this.statusPageController.deleteStatusPage);
|
||||
}
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { z } from "zod";
|
||||
import { booleanCoercion } from "./shared.js";
|
||||
import { booleanCoercion, dnsHostnameRegex } from "./shared.js";
|
||||
import { StatusPageTypes, StatusPageThemes, StatusPageThemeModes } from "@/domain/status-pages/status-page.type.js";
|
||||
import { normalizeStatusPageDomain } from "@/utils/statusPageDomain.js";
|
||||
|
||||
//****************************************
|
||||
// Status Page Validations
|
||||
@@ -15,6 +16,20 @@ export const getStatusPageQueryValidation = z.object({
|
||||
timeFrame: z.coerce.number().optional(),
|
||||
});
|
||||
|
||||
export const resolveStatusPageQueryValidation = getStatusPageQueryValidation.extend({
|
||||
domain: z.string().optional(),
|
||||
});
|
||||
|
||||
const customDomainValidation = z.preprocess(
|
||||
(val) => {
|
||||
if (val === undefined || val === null) {
|
||||
return undefined;
|
||||
}
|
||||
return normalizeStatusPageDomain(String(val));
|
||||
},
|
||||
z.union([z.string().regex(dnsHostnameRegex, "Enter a valid domain name (e.g. status.example.com)"), z.null()]).optional()
|
||||
);
|
||||
|
||||
export const createStatusPageBodyValidation = z
|
||||
.object({
|
||||
type: z.union([z.enum(StatusPageTypes), z.array(z.enum(StatusPageTypes))]).transform((val) => (Array.isArray(val) ? val : [val])),
|
||||
@@ -22,6 +37,7 @@ export const createStatusPageBodyValidation = z
|
||||
url: z.string().regex(/^[a-zA-Z0-9_-]+$/, {
|
||||
message: "URL can only contain letters, numbers, underscores, and hyphens",
|
||||
}),
|
||||
customDomain: customDomainValidation,
|
||||
timezone: z.string().optional(),
|
||||
color: z.string().optional(),
|
||||
monitors: z.array(z.string().regex(/^[0-9a-fA-F]{24}$/, "Must be a valid monitor ID")).min(1, "At least one monitor is required"),
|
||||
|
||||
+17
-9
@@ -1,6 +1,6 @@
|
||||
import express from "express";
|
||||
import path from "path";
|
||||
import cors from "cors";
|
||||
import cors, { type CorsOptions } from "cors";
|
||||
import helmet from "helmet";
|
||||
import compression from "compression";
|
||||
import cookieParser from "cookie-parser";
|
||||
@@ -12,6 +12,8 @@ import { setupRoutes } from "@/config/routes.js";
|
||||
import { InitializedServices } from "@/config/services.js";
|
||||
import { InitializedControllers } from "@/config/controllers.js";
|
||||
import { EnvConfig } from "@/domain/app-settings/app-settings.service.js";
|
||||
import { createStatusPageCorsOrigin } from "@/api/middleware/statusPageCorsOrigin.js";
|
||||
import { isPublicStatusPageApiPath } from "@/api/middleware/statusPagePublicApiPath.js";
|
||||
|
||||
export const createApp = ({
|
||||
services,
|
||||
@@ -28,17 +30,23 @@ export const createApp = ({
|
||||
}) => {
|
||||
const allowedOrigin = envSettings.clientHost;
|
||||
const app = express();
|
||||
const defaultCorsOptions: CorsOptions = {
|
||||
origin: allowedOrigin,
|
||||
methods: "GET,HEAD,PUT,PATCH,POST,DELETE,OPTIONS",
|
||||
allowedHeaders: ["Content-Type", "Authorization", "Accept-Language"],
|
||||
credentials: true,
|
||||
};
|
||||
const publicStatusPageCorsOrigin = createStatusPageCorsOrigin(allowedOrigin, services.statusPagesRepository);
|
||||
|
||||
app.use(generalApiLimiter);
|
||||
|
||||
app.use(
|
||||
cors({
|
||||
origin: allowedOrigin,
|
||||
methods: "GET,HEAD,PUT,PATCH,POST,DELETE,OPTIONS",
|
||||
allowedHeaders: ["Content-Type", "Authorization", "Accept-Language"],
|
||||
credentials: true,
|
||||
})
|
||||
);
|
||||
app.use((req, res, next) => {
|
||||
const corsOptions = isPublicStatusPageApiPath(req.method, req.path)
|
||||
? { ...defaultCorsOptions, origin: publicStatusPageCorsOrigin }
|
||||
: defaultCorsOptions;
|
||||
|
||||
return cors(corsOptions)(req, res, next);
|
||||
});
|
||||
|
||||
app.use(express.static(frontendPath));
|
||||
|
||||
|
||||
@@ -39,7 +39,7 @@ export const initializeControllers = (services: InitializedServices): Initialize
|
||||
geoCheckController: new GeoCheckController(services.geoChecksService),
|
||||
inviteController: new InviteController(services.inviteService),
|
||||
maintenanceWindowController: new MaintenanceWindowController(services.maintenanceWindowService),
|
||||
queueController: new QueueController(services.jobQueue),
|
||||
queueController: new QueueController(services.worker),
|
||||
logController: new LogController(services.logger),
|
||||
statusPageController: new StatusPageController(services.statusPageService, services.monitorsRepository, services.settingsService),
|
||||
notificationController: new NotificationController(services.notificationsService, services.monitorsRepository),
|
||||
|
||||
@@ -28,12 +28,13 @@ import { IUserService, UserService } from "@/domain/users/user.service.js";
|
||||
import { BufferService, IBufferService } from "@/service/bufferService.js";
|
||||
import { EmailService, IEmailService } from "@/service/emailService.js";
|
||||
import { GlobalPingService } from "@/service/globalPingService.js";
|
||||
import { QueueHelper } from "@/service/job-queues/job-queue.helper.js";
|
||||
import { IJobQueue } from "@/service/job-queues/job-queue.interface.js";
|
||||
import { LessSimpleQueue } from "@/service/job-queues/job-queue.less-simple.js";
|
||||
import { SuperSimpleQueue } from "@/service/job-queues/job-queue.sumper-simple.js";
|
||||
import { WorkerHelper } from "@/worker/worker.helper.js";
|
||||
import { IWorker } from "@/worker/worker.interface.js";
|
||||
import { LessSimpleWorker } from "@/worker/worker.less-simple.js";
|
||||
import { SuperSimpleQueue } from "@/worker/worker.super-simple.js";
|
||||
import { INetworkService, NetworkService } from "@/service/networkService.js";
|
||||
import { IStatusService, StatusService } from "@/service/statusService.js";
|
||||
import { MonitorStatusPolicy } from "@/worker/worker.monitor-status-policy.js";
|
||||
|
||||
// Network providers
|
||||
import { PingProvider } from "@/service/network/PingProvider.js";
|
||||
@@ -102,6 +103,10 @@ import MongoUsersRepository from "@/domain/users/user.repository.mongo.js";
|
||||
import { ILogger } from "@/utils/logger.js";
|
||||
import { AppError } from "@/utils/AppError.js";
|
||||
import { type QueueMode } from "@/domain/app-settings/app-settings.type.js";
|
||||
import { NotificationReactor } from "@/worker/reactors/reactor.notification.js";
|
||||
import { ReactorDispatcher } from "@/worker/reactors/reactor.dispatcher.js";
|
||||
import { IncidentReactor } from "@/worker/reactors/reactor.incident.js";
|
||||
import { CheckPipeline, GeoChecksPipeline } from "@/worker/worker.check-pipeline.js";
|
||||
|
||||
export type InitializedServices = {
|
||||
settingsService: ISettingsService;
|
||||
@@ -110,7 +115,7 @@ export type InitializedServices = {
|
||||
emailService: IEmailService;
|
||||
bufferService: IBufferService;
|
||||
statusService: IStatusService;
|
||||
jobQueue: IJobQueue;
|
||||
worker: IWorker;
|
||||
userService: IUserService;
|
||||
checkService: ICheckService;
|
||||
geoChecksService: IGeoChecksService;
|
||||
@@ -309,23 +314,39 @@ export const initializeServices = async ({
|
||||
|
||||
const tagsService = new TagsService(tagsRepository, monitorsRepository);
|
||||
|
||||
const queueHelper = new QueueHelper(
|
||||
logger,
|
||||
const monitorStatusPolicy = new MonitorStatusPolicy();
|
||||
// reactors and dispatcher
|
||||
const notificationReactor = new NotificationReactor(notificationsService);
|
||||
const incidentReactor = new IncidentReactor(incidentService);
|
||||
const reactorDispatcher = new ReactorDispatcher(logger, [notificationReactor, incidentReactor]);
|
||||
|
||||
// pipelines
|
||||
const checkPipeline = new CheckPipeline(
|
||||
monitorsRepository,
|
||||
maintenanceWindowsRepository,
|
||||
checkService,
|
||||
networkService,
|
||||
bufferService,
|
||||
monitorStatusPolicy,
|
||||
statusService,
|
||||
notificationsService,
|
||||
logger
|
||||
);
|
||||
|
||||
const geoCheckPipeline = new GeoChecksPipeline(maintenanceWindowsRepository, geoChecksService, bufferService, logger);
|
||||
|
||||
const workerHelper = new WorkerHelper(
|
||||
logger,
|
||||
checkService,
|
||||
settingsService,
|
||||
bufferService,
|
||||
incidentService,
|
||||
maintenanceWindowsRepository,
|
||||
monitorsRepository,
|
||||
teamsRepository,
|
||||
monitorStatsRepository,
|
||||
checksRepository,
|
||||
incidentsRepository,
|
||||
geoChecksService,
|
||||
geoChecksRepository
|
||||
geoChecksRepository,
|
||||
reactorDispatcher,
|
||||
checkPipeline,
|
||||
geoCheckPipeline
|
||||
);
|
||||
|
||||
if (queueMode === "worker" && envSettings.queueType !== "lessSimpleQueue") {
|
||||
@@ -337,13 +358,13 @@ export const initializeServices = async ({
|
||||
});
|
||||
}
|
||||
|
||||
let jobQueue: IJobQueue;
|
||||
let worker: IWorker;
|
||||
switch (envSettings.queueType) {
|
||||
case "lessSimpleQueue":
|
||||
jobQueue = await LessSimpleQueue.create(logger, queueHelper, monitorsRepository, queueWorkersRepository, envSettings, queueMode);
|
||||
worker = await LessSimpleWorker.create(logger, workerHelper, monitorsRepository, queueWorkersRepository, envSettings, queueMode);
|
||||
break;
|
||||
default:
|
||||
jobQueue = await SuperSimpleQueue.create(logger, queueHelper, monitorsRepository);
|
||||
worker = await SuperSimpleQueue.create(logger, workerHelper, monitorsRepository);
|
||||
}
|
||||
|
||||
// Business services
|
||||
@@ -353,7 +374,7 @@ export const initializeServices = async ({
|
||||
settingsService,
|
||||
logger,
|
||||
jwt,
|
||||
jobQueue,
|
||||
worker,
|
||||
monitorsRepository,
|
||||
usersRepository,
|
||||
invitesRepository,
|
||||
@@ -373,7 +394,7 @@ export const initializeServices = async ({
|
||||
maintenanceWindowsRepository,
|
||||
});
|
||||
const monitorService = new MonitorService({
|
||||
jobQueue,
|
||||
worker,
|
||||
logger,
|
||||
games,
|
||||
monitorsRepository,
|
||||
@@ -393,7 +414,7 @@ export const initializeServices = async ({
|
||||
emailService,
|
||||
bufferService,
|
||||
statusService,
|
||||
jobQueue,
|
||||
worker,
|
||||
userService,
|
||||
checkService,
|
||||
geoChecksService,
|
||||
|
||||
@@ -8,7 +8,7 @@ import type { IMonitorsRepository } from "@/domain/monitors/monitor.repository.i
|
||||
import type { IUsersRepository } from "@/domain/users/user.repository.interface.js";
|
||||
import type { Incident, IncidentSummary } from "@/domain/incidents/incident.type.js";
|
||||
import type { User } from "@/domain/users/user.type.js";
|
||||
import type { MonitorActionDecision } from "@/service/job-queues/job-queue.helper.js";
|
||||
import type { MonitorActionDecision } from "@/worker/worker.helper.js";
|
||||
import type { INotificationMessageBuilder } from "@/domain/notifications/notification.message-builder.js";
|
||||
import type { ILogger } from "@/utils/logger.js";
|
||||
|
||||
|
||||
@@ -21,7 +21,7 @@ import type { IStatusPagesRepository } from "@/domain/status-pages/status-page-r
|
||||
import demoMonitorsData from "@/utils/demoMonitors.json" with { type: "json" };
|
||||
import { AppError } from "@/utils/AppError.js";
|
||||
import type { ImportedMonitor } from "@/api/validation/monitorValidation.js";
|
||||
import { IJobQueue } from "@/service/job-queues/job-queue.interface.js";
|
||||
import { IWorker } from "@/worker/worker.interface.js";
|
||||
import { ILogger } from "@/utils/logger.js";
|
||||
|
||||
const SERVICE_NAME = "MonitorService";
|
||||
@@ -94,7 +94,7 @@ export interface IMonitorService {
|
||||
export class MonitorService implements IMonitorService {
|
||||
static SERVICE_NAME = SERVICE_NAME;
|
||||
|
||||
private jobQueue: IJobQueue;
|
||||
private worker: IWorker;
|
||||
private logger: ILogger;
|
||||
private games: GamesMap;
|
||||
private monitorsRepository: IMonitorsRepository;
|
||||
@@ -105,7 +105,7 @@ export class MonitorService implements IMonitorService {
|
||||
private incidentsRepository: IIncidentsRepository;
|
||||
|
||||
constructor({
|
||||
jobQueue,
|
||||
worker,
|
||||
logger,
|
||||
games,
|
||||
monitorsRepository,
|
||||
@@ -115,7 +115,7 @@ export class MonitorService implements IMonitorService {
|
||||
statusPagesRepository,
|
||||
incidentsRepository,
|
||||
}: {
|
||||
jobQueue: IJobQueue;
|
||||
worker: IWorker;
|
||||
logger: ILogger;
|
||||
games: GamesMap;
|
||||
monitorsRepository: IMonitorsRepository;
|
||||
@@ -125,7 +125,7 @@ export class MonitorService implements IMonitorService {
|
||||
statusPagesRepository: IStatusPagesRepository;
|
||||
incidentsRepository: IIncidentsRepository;
|
||||
}) {
|
||||
this.jobQueue = jobQueue;
|
||||
this.worker = worker;
|
||||
this.logger = logger;
|
||||
this.games = games;
|
||||
this.monitorsRepository = monitorsRepository;
|
||||
@@ -171,7 +171,7 @@ export class MonitorService implements IMonitorService {
|
||||
throw new AppError({ message: "Failed to create monitor", status: 500, service: SERVICE_NAME, method: "createMonitor" });
|
||||
}
|
||||
|
||||
this.jobQueue.addJob(monitor.id, monitor);
|
||||
this.worker.addJob(monitor.id, monitor);
|
||||
};
|
||||
|
||||
createMonitors = async (monitors: Array<Monitor>): Promise<Monitor[] | null> => {
|
||||
@@ -180,7 +180,7 @@ export class MonitorService implements IMonitorService {
|
||||
throw new AppError({ message: "Failed to create monitors", status: 500, service: SERVICE_NAME, method: "createMonitors" });
|
||||
}
|
||||
|
||||
await Promise.all(createdMonitors.map((monitor) => this.jobQueue.addJob(monitor.id, monitor)));
|
||||
await Promise.all(createdMonitors.map((monitor) => this.worker.addJob(monitor.id, monitor)));
|
||||
return createdMonitors;
|
||||
};
|
||||
|
||||
@@ -196,7 +196,7 @@ export class MonitorService implements IMonitorService {
|
||||
}));
|
||||
const demoMonitors = await this.monitorsRepository.createMonitors(monitors as unknown as Monitor[]);
|
||||
|
||||
await Promise.all(demoMonitors.map((monitor) => this.jobQueue.addJob(monitor.id, monitor)));
|
||||
await Promise.all(demoMonitors.map((monitor) => this.worker.addJob(monitor.id, monitor)));
|
||||
return demoMonitors;
|
||||
};
|
||||
|
||||
@@ -428,7 +428,7 @@ export class MonitorService implements IMonitorService {
|
||||
|
||||
editMonitor = async ({ teamId, monitorId, body }: { teamId: string; monitorId: string; body: Partial<Monitor> }) => {
|
||||
const editedMonitor = await this.monitorsRepository.updateById(monitorId, teamId, body);
|
||||
await this.jobQueue.updateJob(editedMonitor);
|
||||
await this.worker.updateJob(editedMonitor);
|
||||
return editedMonitor;
|
||||
};
|
||||
|
||||
@@ -448,7 +448,7 @@ export class MonitorService implements IMonitorService {
|
||||
// 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.jobQueue.updateJob(monitor)));
|
||||
await Promise.all(monitors.map((monitor) => this.worker.updateJob(monitor)));
|
||||
}
|
||||
|
||||
return modifiedCount;
|
||||
@@ -457,9 +457,9 @@ export class MonitorService implements IMonitorService {
|
||||
pauseMonitor = async ({ teamId, monitorId }: { teamId: string; monitorId: string }): Promise<Monitor> => {
|
||||
const monitor = await this.monitorsRepository.togglePauseById(monitorId, teamId);
|
||||
if (monitor.isActive) {
|
||||
await this.jobQueue.resumeJob(monitor);
|
||||
await this.worker.resumeJob(monitor);
|
||||
} else {
|
||||
await this.jobQueue.pauseJob(monitor);
|
||||
await this.worker.pauseJob(monitor);
|
||||
}
|
||||
return monitor;
|
||||
};
|
||||
@@ -478,9 +478,9 @@ export class MonitorService implements IMonitorService {
|
||||
const results = await Promise.allSettled(
|
||||
monitors.map(async (monitor) => {
|
||||
if (monitor.isActive) {
|
||||
await this.jobQueue.resumeJob(monitor);
|
||||
await this.worker.resumeJob(monitor);
|
||||
} else {
|
||||
await this.jobQueue.pauseJob(monitor);
|
||||
await this.worker.pauseJob(monitor);
|
||||
}
|
||||
})
|
||||
);
|
||||
@@ -541,7 +541,7 @@ export class MonitorService implements IMonitorService {
|
||||
});
|
||||
});
|
||||
|
||||
await this.jobQueue.deleteJob(monitor);
|
||||
await this.worker.deleteJob(monitor);
|
||||
return monitor;
|
||||
};
|
||||
|
||||
@@ -550,7 +550,7 @@ export class MonitorService implements IMonitorService {
|
||||
await Promise.all(
|
||||
monitors.map(async (monitor) => {
|
||||
try {
|
||||
await this.jobQueue.deleteJob(monitor);
|
||||
await this.worker.deleteJob(monitor);
|
||||
await this.checksRepository.deleteByMonitorId(monitor.id);
|
||||
await this.geoChecksRepository.deleteByMonitorId(monitor.id);
|
||||
await this.statusPagesRepository.removeMonitorFromStatusPages(monitor.id);
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import type { Monitor } from "@/domain/monitors/monitor.types.js";
|
||||
import type { HardwareStatusPayload, MonitorStatusResponse } from "@/types/network.js";
|
||||
import type { MonitorActionDecision } from "@/service/job-queues/job-queue.helper.js";
|
||||
import type { MonitorActionDecision } from "@/worker/worker.helper.js";
|
||||
import type {
|
||||
NotificationMessage,
|
||||
NotificationType,
|
||||
|
||||
@@ -5,7 +5,7 @@ import type { NotificationMessage } from "@/domain/notifications/notification.ty
|
||||
import { IMonitorsRepository } from "@/domain/monitors/monitor.repository.interface.js";
|
||||
import { INotificationsRepository } from "@/domain/notifications/notification.repository.interface.js";
|
||||
import { INotificationProvider } from "@/domain/notifications/providers/INotificationProvider.js";
|
||||
import type { MonitorActionDecision } from "@/service/job-queues/job-queue.helper.js";
|
||||
import type { MonitorActionDecision } from "@/worker/worker.helper.js";
|
||||
import type { ISettingsService } from "@/domain/app-settings/app-settings.service.js";
|
||||
import { ILogger } from "@/utils/logger.js";
|
||||
import type { INotificationMessageBuilder } from "@/domain/notifications/notification.message-builder.js";
|
||||
|
||||
@@ -5,6 +5,7 @@ export interface IStatusPagesRepository {
|
||||
create(userId: string, teamId: string, image: Express.Multer.File | undefined, data: Partial<StatusPage>): Promise<StatusPage>;
|
||||
// single fetch
|
||||
findByUrl(url: string): Promise<StatusPage>;
|
||||
findByCustomDomain(customDomain: string): Promise<StatusPage>;
|
||||
findByTeamId(teamId: string): Promise<StatusPage[]>;
|
||||
// collection fetch
|
||||
// update
|
||||
|
||||
@@ -3,6 +3,7 @@ import { type StatusPageDocument, StatusPageModel } from "@/domain/status-pages/
|
||||
import type { StatusPage, StatusPageLogo, StatusPageLogoDocument } from "@/domain/status-pages/status-page.type.js";
|
||||
import mongoose from "mongoose";
|
||||
import { AppError } from "@/utils/AppError.js";
|
||||
import { normalizeStatusPageDomain } from "@/utils/statusPageDomain.js";
|
||||
|
||||
// Type for update data that can include document-level fields (Buffer for logo)
|
||||
type StatusPageUpdateData = Partial<Omit<StatusPage, "id" | "userId" | "teamId" | "logo" | "createdAt" | "updatedAt">> & {
|
||||
@@ -47,6 +48,7 @@ class MongoStatusPagesRepository implements IStatusPagesRepository {
|
||||
type: doc.type,
|
||||
companyName: doc.companyName,
|
||||
url: doc.url,
|
||||
customDomain: doc.customDomain ?? null,
|
||||
timezone: doc.timezone ?? undefined,
|
||||
color: doc.color,
|
||||
monitors: this.mapIdArray(doc.monitors),
|
||||
@@ -74,10 +76,12 @@ class MongoStatusPagesRepository implements IStatusPagesRepository {
|
||||
};
|
||||
|
||||
create = async (userId: string, teamId: string, image: Express.Multer.File | undefined, data: Partial<StatusPage>): Promise<StatusPage> => {
|
||||
const { logo, ...restData } = data;
|
||||
const { logo, customDomain, ...restData } = data;
|
||||
void logo;
|
||||
const normalizedCustomDomain = normalizeStatusPageDomain(customDomain);
|
||||
const statusPage = new StatusPageModel({
|
||||
...restData,
|
||||
...(normalizedCustomDomain ? { customDomain: normalizedCustomDomain } : {}),
|
||||
userId,
|
||||
teamId,
|
||||
});
|
||||
@@ -102,6 +106,21 @@ class MongoStatusPagesRepository implements IStatusPagesRepository {
|
||||
// Get status page
|
||||
};
|
||||
|
||||
findByCustomDomain = async (customDomain: string): Promise<StatusPage> => {
|
||||
const normalizedDomain = normalizeStatusPageDomain(customDomain);
|
||||
if (!normalizedDomain) {
|
||||
throw new AppError({ message: "Status page not found", status: 404 });
|
||||
}
|
||||
|
||||
const statusPage = await StatusPageModel.findOne({
|
||||
customDomain: normalizedDomain,
|
||||
});
|
||||
if (!statusPage) {
|
||||
throw new AppError({ message: "Status page not found", status: 404 });
|
||||
}
|
||||
return this.toEntity(statusPage);
|
||||
};
|
||||
|
||||
findByTeamId = async (teamId: string): Promise<StatusPage[]> => {
|
||||
const statusPages = await StatusPageModel.find({ teamId });
|
||||
return this.mapDocuments(statusPages);
|
||||
@@ -113,19 +132,37 @@ class MongoStatusPagesRepository implements IStatusPagesRepository {
|
||||
image: Express.Multer.File | undefined,
|
||||
patch: Partial<StatusPage> & { removeLogo?: string }
|
||||
): Promise<StatusPage> => {
|
||||
const { logo, removeLogo, ...restPatch } = patch;
|
||||
const { logo, removeLogo, customDomain, ...restPatch } = patch;
|
||||
void logo;
|
||||
const updateData: StatusPageUpdateData = { ...restPatch };
|
||||
const setData: StatusPageUpdateData = { ...restPatch };
|
||||
const unsetData: Record<string, 1> = {};
|
||||
|
||||
if (customDomain !== undefined) {
|
||||
const normalizedCustomDomain = normalizeStatusPageDomain(customDomain);
|
||||
if (normalizedCustomDomain) {
|
||||
setData.customDomain = normalizedCustomDomain;
|
||||
} else {
|
||||
unsetData.customDomain = 1;
|
||||
}
|
||||
}
|
||||
if (image) {
|
||||
updateData.logo = {
|
||||
setData.logo = {
|
||||
data: image.buffer as Buffer,
|
||||
contentType: image.mimetype,
|
||||
};
|
||||
} else if (removeLogo === "true") {
|
||||
updateData.logo = null;
|
||||
setData.logo = null;
|
||||
}
|
||||
|
||||
const statusPage = await StatusPageModel.findOneAndUpdate({ teamId, _id: id }, updateData, {
|
||||
const updateQuery =
|
||||
unsetData.customDomain !== undefined
|
||||
? {
|
||||
$set: setData,
|
||||
$unset: unsetData,
|
||||
}
|
||||
: setData;
|
||||
|
||||
const statusPage = await StatusPageModel.findOneAndUpdate({ teamId, _id: id }, updateQuery, {
|
||||
new: true,
|
||||
});
|
||||
|
||||
|
||||
@@ -65,6 +65,11 @@ const StatusPageSchema = new Schema<StatusPageDocument>(
|
||||
required: true,
|
||||
default: "",
|
||||
},
|
||||
customDomain: {
|
||||
type: String,
|
||||
lowercase: true,
|
||||
trim: true,
|
||||
},
|
||||
timezone: {
|
||||
type: String,
|
||||
},
|
||||
@@ -128,6 +133,14 @@ const StatusPageSchema = new Schema<StatusPageDocument>(
|
||||
{ timestamps: true }
|
||||
);
|
||||
|
||||
StatusPageSchema.index(
|
||||
{ customDomain: 1 },
|
||||
{
|
||||
unique: true,
|
||||
partialFilterExpression: { customDomain: { $type: "string" } },
|
||||
}
|
||||
);
|
||||
|
||||
const StatusPageModel = model<StatusPageDocument>("StatusPage", StatusPageSchema);
|
||||
|
||||
export type { StatusPageDocument };
|
||||
|
||||
@@ -1,10 +1,13 @@
|
||||
import { type IStatusPagesRepository } from "@/domain/status-pages/status-page-repository.interface.js";
|
||||
import { ISettingsService } from "@/domain/app-settings/app-settings.service.js";
|
||||
import { DEFAULT_STATUS_PAGE_THEME, DEFAULT_STATUS_PAGE_THEME_MODE, StatusPage } from "@/domain/status-pages/status-page.type.js";
|
||||
import { AppError } from "@/utils/AppError.js";
|
||||
import { normalizeStatusPageDomain } from "@/utils/statusPageDomain.js";
|
||||
|
||||
export interface IStatusPageService {
|
||||
createStatusPage(userId: string, teamId: string, image: Express.Multer.File | undefined, data: Partial<StatusPage>): Promise<StatusPage>;
|
||||
getStatusPageByUrl(url: string): Promise<StatusPage>;
|
||||
getStatusPageByCustomDomain(customDomain: string): Promise<StatusPage>;
|
||||
getStatusPagesByTeamId(teamId: string): Promise<StatusPage[]>;
|
||||
updateStatusPage(id: string, teamId: string, image: Express.Multer.File | undefined, data: Partial<StatusPage>): Promise<StatusPage>;
|
||||
|
||||
@@ -19,6 +22,30 @@ export class StatusPageService implements IStatusPageService {
|
||||
this.settingsService = settingsService;
|
||||
}
|
||||
|
||||
private assertCustomDomainAllowed = (customDomain: string | null | undefined) => {
|
||||
if (!customDomain) {
|
||||
return;
|
||||
}
|
||||
|
||||
const clientHost = normalizeStatusPageDomain(this.settingsService.getSettings().clientHost);
|
||||
if (clientHost && customDomain === clientHost) {
|
||||
throw new AppError({
|
||||
message: "Custom domain cannot match the Checkmate instance host",
|
||||
status: 400,
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
private normalizeCustomDomainInput = (data: Partial<StatusPage>): Partial<StatusPage> => {
|
||||
if (!("customDomain" in data)) {
|
||||
return data;
|
||||
}
|
||||
|
||||
const customDomain = normalizeStatusPageDomain(data.customDomain);
|
||||
this.assertCustomDomainAllowed(customDomain);
|
||||
return { ...data, customDomain };
|
||||
};
|
||||
|
||||
private withoutThemeFields = (data: Partial<StatusPage>): Partial<StatusPage> => {
|
||||
const { theme: _theme, themeMode: _themeMode, ...rest } = data;
|
||||
return rest;
|
||||
@@ -42,7 +69,8 @@ export class StatusPageService implements IStatusPageService {
|
||||
image: Express.Multer.File | undefined,
|
||||
data: Partial<StatusPage>
|
||||
): Promise<StatusPage> => {
|
||||
const created = await this.statusPagesRepository.create(userId, teamId, image, this.normalizeInput(data));
|
||||
const normalizedData = this.normalizeCustomDomainInput(this.normalizeInput(data));
|
||||
const created = await this.statusPagesRepository.create(userId, teamId, image, normalizedData);
|
||||
return this.normalizeTheme(created);
|
||||
};
|
||||
|
||||
@@ -51,13 +79,19 @@ export class StatusPageService implements IStatusPageService {
|
||||
return this.normalizeTheme(statusPage);
|
||||
};
|
||||
|
||||
getStatusPageByCustomDomain = async (customDomain: string): Promise<StatusPage> => {
|
||||
const statusPage = await this.statusPagesRepository.findByCustomDomain(customDomain);
|
||||
return this.normalizeTheme(statusPage);
|
||||
};
|
||||
|
||||
getStatusPagesByTeamId = async (teamId: string): Promise<StatusPage[]> => {
|
||||
const statusPages = await this.statusPagesRepository.findByTeamId(teamId);
|
||||
return statusPages.map((sp) => this.normalizeTheme(sp));
|
||||
};
|
||||
|
||||
updateStatusPage = async (id: string, teamId: string, image: Express.Multer.File | undefined, data: Partial<StatusPage>): Promise<StatusPage> => {
|
||||
const updated = await this.statusPagesRepository.updateById(id, teamId, image, this.normalizeInput(data));
|
||||
const normalizedData = this.normalizeCustomDomainInput(this.normalizeInput(data));
|
||||
const updated = await this.statusPagesRepository.updateById(id, teamId, image, normalizedData);
|
||||
return this.normalizeTheme(updated);
|
||||
};
|
||||
|
||||
|
||||
@@ -26,6 +26,7 @@ export interface StatusPage {
|
||||
type: StatusPageType[];
|
||||
companyName: string;
|
||||
url: string;
|
||||
customDomain?: string | null;
|
||||
timezone?: string;
|
||||
color: string;
|
||||
monitors: string[];
|
||||
|
||||
@@ -8,12 +8,12 @@ import type { User } from "@/domain/users/user.type.js";
|
||||
import { canManageRole, type UserRole } from "@/domain/users/user.type.js";
|
||||
import bcrypt from "bcryptjs";
|
||||
import { AppError } from "@/utils/AppError.js";
|
||||
import { IJobQueue } from "@/service/job-queues/job-queue.interface.js";
|
||||
import { IEmailService } from "@/service/emailService.js";
|
||||
import { EnvConfig, ISettingsService } from "@/domain/app-settings/app-settings.service.js";
|
||||
import { ILogger } from "@/utils/logger.js";
|
||||
import jwt from "jsonwebtoken";
|
||||
import crypto from "crypto";
|
||||
import { IWorker } from "@/worker/worker.interface.js";
|
||||
type CryptoType = typeof crypto;
|
||||
type JwtType = typeof jwt;
|
||||
const SERVICE_NAME = "userService";
|
||||
@@ -53,7 +53,7 @@ export class UserService implements IUserService {
|
||||
private settingsService: ISettingsService;
|
||||
private logger: ILogger;
|
||||
private jwt: JwtType;
|
||||
private jobQueue: IJobQueue;
|
||||
private worker: IWorker;
|
||||
private crypto: CryptoType;
|
||||
private monitorsRepository: IMonitorsRepository;
|
||||
private usersRepository: IUsersRepository;
|
||||
@@ -68,7 +68,7 @@ export class UserService implements IUserService {
|
||||
settingsService,
|
||||
logger,
|
||||
jwt,
|
||||
jobQueue,
|
||||
worker,
|
||||
monitorsRepository,
|
||||
usersRepository,
|
||||
invitesRepository,
|
||||
@@ -81,7 +81,7 @@ export class UserService implements IUserService {
|
||||
settingsService: ISettingsService;
|
||||
logger: ILogger;
|
||||
jwt: JwtType;
|
||||
jobQueue: IJobQueue;
|
||||
worker: IWorker;
|
||||
monitorsRepository: IMonitorsRepository;
|
||||
usersRepository: IUsersRepository;
|
||||
invitesRepository: IInvitesRepository;
|
||||
@@ -93,7 +93,7 @@ export class UserService implements IUserService {
|
||||
this.settingsService = settingsService;
|
||||
this.logger = logger;
|
||||
this.jwt = jwt;
|
||||
this.jobQueue = jobQueue;
|
||||
this.worker = worker;
|
||||
this.crypto = crypto;
|
||||
this.monitorsRepository = monitorsRepository;
|
||||
this.usersRepository = usersRepository;
|
||||
@@ -330,7 +330,7 @@ export class UserService implements IUserService {
|
||||
if (roles.includes("superadmin")) {
|
||||
const monitors = await this.monitorsRepository.findByTeamId(teamId, {});
|
||||
if (monitors) {
|
||||
await Promise.all(monitors.map((monitor) => this.jobQueue.deleteJob(monitor)));
|
||||
await Promise.all(monitors.map((monitor) => this.worker.deleteJob(monitor)));
|
||||
}
|
||||
}
|
||||
// 6. Delete the user by id
|
||||
|
||||
@@ -63,7 +63,7 @@ export class EmailService implements IEmailService {
|
||||
init = () => {
|
||||
const loadTemplate = (templateName: string) => {
|
||||
try {
|
||||
const templatePath = this.path.join(__dirname, `../../templates/${templateName}.mjml`);
|
||||
const templatePath = this.path.join(__dirname, `../templates/${templateName}.mjml`);
|
||||
const templateContent = this.fs.readFileSync(templatePath, "utf8");
|
||||
return this.compile(templateContent);
|
||||
} catch (error: unknown) {
|
||||
|
||||
@@ -1,434 +0,0 @@
|
||||
const SERVICE_NAME = "JobQueueHelper";
|
||||
import type { Monitor } from "@/domain/monitors/monitor.types.js";
|
||||
import { supportsGeoCheck } from "@/domain/monitors/monitor.types.js";
|
||||
import { AppError } from "@/utils/AppError.js";
|
||||
import { ISettingsService } from "@/domain/app-settings/app-settings.service.js";
|
||||
import { ICheckService } from "@/domain/checks/check.service.js";
|
||||
import { type IGeoChecksService } from "@/domain/geo-checks/geo-check.service.js";
|
||||
import { IncidentService } from "@/domain/incidents/incident.service.js";
|
||||
import { INotificationsService } from "@/domain/notifications/notification.service.js";
|
||||
import { INetworkService } from "@/service/networkService.js";
|
||||
import { IStatusService } from "@/service/statusService.js";
|
||||
import { CHECK_TTL_SENTINEL } from "@/domain/checks/check.type.js";
|
||||
import { type StatusChangeResult } from "@/types/network.js";
|
||||
import { isWindowActive } from "@/utils/maintenanceWindow.js";
|
||||
import { IChecksRepository } from "@/domain/checks/check.repository.interface.js";
|
||||
import { IGeoChecksRepository } from "@/domain/geo-checks/geo-check.repository.interface.js";
|
||||
import { IIncidentsRepository } from "@/domain/incidents/incident.repository.interface.js";
|
||||
import { IMaintenanceWindowsRepository } from "@/domain/maintenance-windows/maintenance-window.repository.interface.js";
|
||||
import { IMonitorStatsRepository } from "@/domain/monitor-stats/monitor-stats.repository.interface.js";
|
||||
import { IMonitorsRepository } from "@/domain/monitors/monitor.repository.interface.js";
|
||||
import { ITeamsRepository } from "@/domain/teams/team.repository.interface.js";
|
||||
import { ILogger } from "@/utils/logger.js";
|
||||
import { IBufferService } from "@/service/bufferService.js";
|
||||
|
||||
export interface IQueueHelper {
|
||||
readonly serviceName: string;
|
||||
getHeartbeatJob(): (monitor: Monitor) => Promise<void>;
|
||||
getHeartbeatGeoJob(): (monitor: Monitor) => Promise<void>;
|
||||
getCleanupOrphanedJob(): () => Promise<void>;
|
||||
getCleanupRetentionJob(): () => Promise<void>;
|
||||
isInMaintenanceWindow(monitorId: string, teamId: string): Promise<boolean>;
|
||||
}
|
||||
|
||||
export interface MonitorActionDecision {
|
||||
shouldCreateIncident: boolean;
|
||||
shouldResolveIncident: boolean;
|
||||
shouldSendNotification: boolean;
|
||||
incidentReason: "status_down" | "threshold_breach" | null;
|
||||
notificationReason: "status_change" | "threshold_breach" | null;
|
||||
thresholdBreaches?: {
|
||||
cpu?: boolean;
|
||||
memory?: boolean;
|
||||
disk?: boolean;
|
||||
temp?: boolean;
|
||||
};
|
||||
}
|
||||
|
||||
export class QueueHelper implements IQueueHelper {
|
||||
static SERVICE_NAME = SERVICE_NAME;
|
||||
|
||||
private logger: ILogger;
|
||||
private networkService: INetworkService;
|
||||
private statusService: IStatusService;
|
||||
private notificationsService: INotificationsService;
|
||||
private checkService: ICheckService;
|
||||
private settingsService: ISettingsService;
|
||||
private buffer: IBufferService;
|
||||
private incidentService: IncidentService;
|
||||
private maintenanceWindowsRepository: IMaintenanceWindowsRepository;
|
||||
private monitorsRepository: IMonitorsRepository;
|
||||
private teamsRepository: ITeamsRepository;
|
||||
private monitorStatsRepository: IMonitorStatsRepository;
|
||||
private checksRepository: IChecksRepository;
|
||||
private incidentsRepository: IIncidentsRepository;
|
||||
private geoChecksService: IGeoChecksService;
|
||||
private geoChecksRepository: IGeoChecksRepository;
|
||||
|
||||
constructor(
|
||||
logger: ILogger,
|
||||
networkService: INetworkService,
|
||||
statusService: IStatusService,
|
||||
notificationsService: INotificationsService,
|
||||
checkService: ICheckService,
|
||||
settingsService: ISettingsService,
|
||||
buffer: IBufferService,
|
||||
incidentService: IncidentService,
|
||||
maintenanceWindowsRepository: IMaintenanceWindowsRepository,
|
||||
monitorsRepository: IMonitorsRepository,
|
||||
teamsRepository: ITeamsRepository,
|
||||
monitorStatsRepository: IMonitorStatsRepository,
|
||||
checksRepository: IChecksRepository,
|
||||
incidentsRepository: IIncidentsRepository,
|
||||
geoChecksService: IGeoChecksService,
|
||||
geoChecksRepository: IGeoChecksRepository
|
||||
) {
|
||||
this.logger = logger;
|
||||
this.networkService = networkService;
|
||||
this.statusService = statusService;
|
||||
this.checkService = checkService;
|
||||
this.settingsService = settingsService;
|
||||
this.buffer = buffer;
|
||||
this.notificationsService = notificationsService;
|
||||
this.incidentService = incidentService;
|
||||
this.maintenanceWindowsRepository = maintenanceWindowsRepository;
|
||||
this.monitorsRepository = monitorsRepository;
|
||||
this.teamsRepository = teamsRepository;
|
||||
this.monitorStatsRepository = monitorStatsRepository;
|
||||
this.checksRepository = checksRepository;
|
||||
this.incidentsRepository = incidentsRepository;
|
||||
this.geoChecksService = geoChecksService;
|
||||
this.geoChecksRepository = geoChecksRepository;
|
||||
}
|
||||
|
||||
get serviceName() {
|
||||
return QueueHelper.SERVICE_NAME;
|
||||
}
|
||||
|
||||
getHeartbeatJob = () => {
|
||||
return async (monitor: Monitor) => {
|
||||
try {
|
||||
const monitorId = monitor.id;
|
||||
const teamId = monitor.teamId;
|
||||
if (!monitorId) {
|
||||
throw new AppError({ message: "No monitor id", service: SERVICE_NAME, method: "getMonitorJob" });
|
||||
}
|
||||
|
||||
// Step 1. Check for maintenance window, if found, skip the check
|
||||
|
||||
const maintenanceWindowActive = await this.isInMaintenanceWindow(monitorId, teamId);
|
||||
if (maintenanceWindowActive) {
|
||||
this.logger.debug({
|
||||
message: `Monitor ${monitorId} is in maintenance window`,
|
||||
service: SERVICE_NAME,
|
||||
method: "getMonitorJob",
|
||||
});
|
||||
if (monitor.status !== "maintenance") {
|
||||
await this.monitorsRepository.updateById(monitorId, teamId, { status: "maintenance" });
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
// Step 2. Request monitor status
|
||||
const status = await this.networkService.requestStatus(monitor);
|
||||
if (!status) {
|
||||
throw new Error("No network response");
|
||||
}
|
||||
|
||||
// Step 3. Build check
|
||||
const check = this.checkService.buildCheck(status);
|
||||
if (!check) {
|
||||
this.logger.warn({
|
||||
message: `No check could be built for monitor ${monitorId}`,
|
||||
service: SERVICE_NAME,
|
||||
method: "getMonitorJob",
|
||||
details: { code: status.code, message: status.message },
|
||||
});
|
||||
return;
|
||||
}
|
||||
// Step 4 Add check to buffer
|
||||
this.buffer.addToBuffer(check);
|
||||
// Step 4. Update monitor status
|
||||
const statusChangeResult = await this.statusService.updateMonitorStatus(status, check);
|
||||
|
||||
// Step 5. Get decisions
|
||||
const decision = this.evaluateMonitorAction(statusChangeResult);
|
||||
|
||||
// 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: unknown) => {
|
||||
this.logger.error({
|
||||
message: `Error sending notifications for job ${statusChangeResult.monitor.id}: ${error instanceof Error ? error.message : "Unknown error"}`,
|
||||
service: SERVICE_NAME,
|
||||
method: "getMonitorJob",
|
||||
stack: error instanceof Error ? error.stack : undefined,
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
// Step 7. Handle incidents
|
||||
try {
|
||||
await this.incidentService.handleIncident(statusChangeResult.monitor, statusChangeResult.code, decision, status);
|
||||
} catch (error: unknown) {
|
||||
this.logger.warn({
|
||||
message: `Error handling incident for job ${monitor.id}: ${error instanceof Error ? error.message : "Unknown error"}`,
|
||||
service: SERVICE_NAME,
|
||||
method: "getMonitorJob",
|
||||
stack: error instanceof Error ? error.stack : undefined,
|
||||
});
|
||||
}
|
||||
} catch (error: unknown) {
|
||||
this.logger.warn({
|
||||
message: error instanceof Error ? error.message : "Unknown error",
|
||||
service: SERVICE_NAME,
|
||||
method: "getMonitorJob",
|
||||
stack: error instanceof Error ? error.stack : undefined,
|
||||
});
|
||||
throw error;
|
||||
}
|
||||
};
|
||||
};
|
||||
|
||||
getCleanupOrphanedJob = () => {
|
||||
return async () => {
|
||||
try {
|
||||
this.logger.info({
|
||||
message: "Starting cleanup of orphaned data",
|
||||
service: SERVICE_NAME,
|
||||
method: "getCleanupOrphanedJob",
|
||||
});
|
||||
|
||||
// Get all valid team IDs
|
||||
const validTeamIds = await this.teamsRepository.findAllTeamIds();
|
||||
this.logger.debug({
|
||||
message: `Found ${validTeamIds.length} valid teams`,
|
||||
service: SERVICE_NAME,
|
||||
method: "getCleanupOrphanedJob",
|
||||
});
|
||||
|
||||
// Remove orphaned monitors (monitors without a valid team)
|
||||
const deletedMonitorCount = await this.monitorsRepository.deleteByTeamIdsNotIn(validTeamIds);
|
||||
if (deletedMonitorCount > 0) {
|
||||
this.logger.info({
|
||||
message: `Deleted ${deletedMonitorCount} orphaned monitors`,
|
||||
service: SERVICE_NAME,
|
||||
method: "getCleanupOrphanedJob",
|
||||
});
|
||||
}
|
||||
|
||||
// Remove orphaned monitorStats (stats without a valid monitor)
|
||||
const allMonitorIds = await this.monitorsRepository.findAllMonitorIds();
|
||||
this.logger.debug({
|
||||
message: `Found ${allMonitorIds.length} valid monitors`,
|
||||
service: SERVICE_NAME,
|
||||
method: "getCleanupOrphanedJob",
|
||||
});
|
||||
|
||||
const deletedStatsCount = await this.monitorStatsRepository.deleteByMonitorIdsNotIn(allMonitorIds);
|
||||
if (deletedStatsCount > 0) {
|
||||
this.logger.info({
|
||||
message: `Deleted ${deletedStatsCount} orphaned monitor stats`,
|
||||
service: SERVICE_NAME,
|
||||
method: "getCleanupOrphanedJob",
|
||||
});
|
||||
}
|
||||
|
||||
// Remove orphaned checks
|
||||
const deletedChecksCount = await this.checksRepository.deleteByMonitorIdsNotIn(allMonitorIds);
|
||||
if (deletedChecksCount > 0) {
|
||||
this.logger.info({
|
||||
message: `Deleted ${deletedChecksCount} orphaned checks`,
|
||||
service: SERVICE_NAME,
|
||||
method: "getCleanupOrphanedJob",
|
||||
});
|
||||
}
|
||||
|
||||
// Remove orphaned incidents
|
||||
const deletedIncidentsCount = await this.incidentsRepository.deleteByMonitorIdsNotIn(allMonitorIds);
|
||||
if (deletedIncidentsCount > 0) {
|
||||
this.logger.info({
|
||||
message: `Deleted ${deletedIncidentsCount} orphaned incidents`,
|
||||
service: SERVICE_NAME,
|
||||
method: "getCleanupOrphanedJob",
|
||||
});
|
||||
}
|
||||
|
||||
// Remove orphaned geo checks
|
||||
const deletedGeoChecksCount = await this.geoChecksRepository.deleteByMonitorIdsNotIn(allMonitorIds);
|
||||
if (deletedGeoChecksCount > 0) {
|
||||
this.logger.info({
|
||||
message: `Deleted ${deletedGeoChecksCount} orphaned geo checks`,
|
||||
service: SERVICE_NAME,
|
||||
method: "getCleanupOrphanedJob",
|
||||
});
|
||||
}
|
||||
|
||||
this.logger.info({
|
||||
message: "Cleanup of orphaned data completed",
|
||||
service: SERVICE_NAME,
|
||||
method: "getCleanupOrphanedJob",
|
||||
});
|
||||
} catch (error: unknown) {
|
||||
this.logger.warn({
|
||||
message: error instanceof Error ? error.message : "Unknown error",
|
||||
service: SERVICE_NAME,
|
||||
method: "getCleanupOrphanedJob",
|
||||
stack: error instanceof Error ? error.stack : undefined,
|
||||
});
|
||||
throw error;
|
||||
}
|
||||
};
|
||||
};
|
||||
|
||||
getHeartbeatGeoJob = () => {
|
||||
return async (monitor: Monitor) => {
|
||||
try {
|
||||
const monitorId = monitor.id;
|
||||
const teamId = monitor.teamId;
|
||||
|
||||
// Step 1: Validate monitor eligibility
|
||||
if (!monitorId) {
|
||||
throw new AppError({ message: "No monitor id", service: SERVICE_NAME, method: "getHeartbeatGeoJob" });
|
||||
}
|
||||
|
||||
if (!monitor.geoCheckEnabled) {
|
||||
return;
|
||||
}
|
||||
if (!supportsGeoCheck(monitor.type)) {
|
||||
this.logger.debug({
|
||||
message: `Monitor ${monitorId} type does not support geo checks, skipping`,
|
||||
service: SERVICE_NAME,
|
||||
method: "getHeartbeatGeoJob",
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
if (!monitor.geoCheckLocations || monitor.geoCheckLocations.length === 0) {
|
||||
this.logger.warn({
|
||||
message: `No geo check locations configured for monitor ${monitorId}`,
|
||||
service: SERVICE_NAME,
|
||||
method: "getHeartbeatGeoJob",
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
// Step 2: Check for maintenance window
|
||||
const maintenanceWindowActive = await this.isInMaintenanceWindow(monitorId, teamId);
|
||||
if (maintenanceWindowActive) {
|
||||
this.logger.debug({
|
||||
message: `Monitor ${monitorId} is in maintenance window, skipping geo check`,
|
||||
service: SERVICE_NAME,
|
||||
method: "getHeartbeatGeoJob",
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
// Step 3: Build geo check (handles API calls and polling)
|
||||
const geoCheck = await this.geoChecksService.buildGeoCheck(monitor);
|
||||
if (!geoCheck) {
|
||||
this.logger.warn({
|
||||
message: `No geo check could be built for monitor ${monitorId}`,
|
||||
service: SERVICE_NAME,
|
||||
method: "getHeartbeatGeoJob",
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
// Step 4: Add geo check to buffer
|
||||
this.buffer.addGeoCheckToBuffer(geoCheck);
|
||||
|
||||
this.logger.debug({
|
||||
message: `Geo check job executed for monitor ${monitorId}`,
|
||||
service: SERVICE_NAME,
|
||||
method: "getHeartbeatGeoJob",
|
||||
});
|
||||
} catch (error: unknown) {
|
||||
this.logger.error({
|
||||
message: error instanceof Error ? error.message : "Unknown error",
|
||||
service: SERVICE_NAME,
|
||||
method: "getHeartbeatGeoJob",
|
||||
stack: error instanceof Error ? error.stack : undefined,
|
||||
});
|
||||
// Don't throw - geo check failures shouldn't crash the job scheduler
|
||||
}
|
||||
};
|
||||
};
|
||||
|
||||
async isInMaintenanceWindow(monitorId: string, teamId: string) {
|
||||
const maintenanceWindows = await this.maintenanceWindowsRepository.findByMonitorId(monitorId, teamId);
|
||||
const now = new Date();
|
||||
return maintenanceWindows.some((window) => isWindowActive(window, now));
|
||||
}
|
||||
|
||||
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;
|
||||
|
||||
// Initialize result
|
||||
const decision: MonitorActionDecision = {
|
||||
shouldCreateIncident: false,
|
||||
shouldResolveIncident: false,
|
||||
shouldSendNotification: false,
|
||||
incidentReason: null,
|
||||
notificationReason: null,
|
||||
};
|
||||
|
||||
if (!statusChanged) {
|
||||
return decision;
|
||||
}
|
||||
|
||||
if (monitor.status === "down") {
|
||||
// Monitor went down (unreachable)
|
||||
decision.shouldCreateIncident = true;
|
||||
decision.shouldSendNotification = true;
|
||||
decision.incidentReason = "status_down";
|
||||
decision.notificationReason = "status_change";
|
||||
} else if (monitor.status === "breached") {
|
||||
// Hardware monitor exceeded thresholds
|
||||
decision.shouldCreateIncident = true;
|
||||
decision.shouldSendNotification = true;
|
||||
decision.incidentReason = "threshold_breach";
|
||||
decision.notificationReason = "threshold_breach";
|
||||
} else if (monitor.status === "up" && (prevStatus === "down" || prevStatus === "breached")) {
|
||||
// Monitor recovered from down or breached state
|
||||
decision.shouldResolveIncident = true;
|
||||
decision.shouldSendNotification = true;
|
||||
decision.notificationReason = "status_change";
|
||||
}
|
||||
|
||||
return decision;
|
||||
}
|
||||
}
|
||||
@@ -16,7 +16,7 @@ export const initShutdownListener = (server: Server | null, services: Initialize
|
||||
|
||||
try {
|
||||
server?.close();
|
||||
await services.jobQueue.shutdown();
|
||||
await services.worker.shutdown();
|
||||
await services.db.disconnect();
|
||||
logger.info({ message: "Graceful shutdown complete" });
|
||||
process.exit(0);
|
||||
|
||||
@@ -0,0 +1,5 @@
|
||||
// Shared constraints for user supplied image uploads
|
||||
export const ImageMimeTypes = ["image/jpeg", "image/png", "image/jpg"] as const;
|
||||
export type ImageMimeType = (typeof ImageMimeTypes)[number];
|
||||
|
||||
export const MAX_IMAGE_SIZE_BYTES = 3 * 1024 * 1024; // 3MB
|
||||
@@ -0,0 +1,29 @@
|
||||
export const normalizeStatusPageDomain = (value?: string | null): string | null => {
|
||||
if (typeof value !== "string") {
|
||||
return null;
|
||||
}
|
||||
|
||||
const trimmed = value.trim().toLowerCase();
|
||||
if (!trimmed) {
|
||||
return null;
|
||||
}
|
||||
|
||||
// Strip scheme, path, and port if a full URL was pasted.
|
||||
const withoutScheme = trimmed.replace(/^https?:\/\//, "");
|
||||
const hostname = withoutScheme.split("/")[0]?.split(":")[0] ?? "";
|
||||
|
||||
return hostname || null;
|
||||
};
|
||||
|
||||
export const resolveStatusPageDomainFromRequest = (hostHeader?: string | null, queryDomain?: string | null): string | null => {
|
||||
const fromQuery = normalizeStatusPageDomain(queryDomain);
|
||||
if (fromQuery) {
|
||||
return fromQuery;
|
||||
}
|
||||
|
||||
if (!hostHeader) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return normalizeStatusPageDomain(hostHeader.split(":")[0]);
|
||||
};
|
||||
@@ -0,0 +1,39 @@
|
||||
import { ILogger } from "@/utils/logger.js";
|
||||
import { MonitorEvaluation } from "@/worker/worker.interface.js";
|
||||
import { IMonitorReactor } from "@/worker/reactors/reactor.interface.js";
|
||||
|
||||
const SERVICE_NAME = "ReactorDispatcher";
|
||||
|
||||
export interface IReactorDispatcher {
|
||||
dispatch(evaluation: MonitorEvaluation): Promise<void>;
|
||||
}
|
||||
|
||||
export class ReactorDispatcher implements IReactorDispatcher {
|
||||
constructor(
|
||||
private logger: ILogger,
|
||||
private reactors: IMonitorReactor[]
|
||||
) {}
|
||||
|
||||
private logFailure(reactor: IMonitorReactor, evaluation: MonitorEvaluation, error: unknown) {
|
||||
this.logger.error({
|
||||
service: SERVICE_NAME,
|
||||
method: "dispatch",
|
||||
message: `Reactor ${reactor.name} failed for monitor ${evaluation.monitor.id}: ${error instanceof Error ? error.message : String(error)}`,
|
||||
stack: error instanceof Error ? error.stack : undefined,
|
||||
});
|
||||
}
|
||||
|
||||
dispatch = async (evaluation: MonitorEvaluation) => {
|
||||
for (const reactor of this.reactors) {
|
||||
if (reactor.blocking) {
|
||||
try {
|
||||
await reactor.react(evaluation);
|
||||
} catch (error: unknown) {
|
||||
this.logFailure(reactor, evaluation, error);
|
||||
}
|
||||
} else {
|
||||
reactor.react(evaluation).catch((error: unknown) => this.logFailure(reactor, evaluation, error));
|
||||
}
|
||||
}
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
import { IMonitorReactor } from "@/worker/reactors/reactor.interface.js";
|
||||
import { MonitorEvaluation } from "@/worker/worker.interface.js";
|
||||
import { IIncidentService } from "@/domain/incidents/incident.service.js";
|
||||
|
||||
export class IncidentReactor implements IMonitorReactor {
|
||||
readonly name = "incident";
|
||||
readonly blocking = true;
|
||||
constructor(private incidentService: IIncidentService) {}
|
||||
|
||||
react = async (evaluation: MonitorEvaluation) => {
|
||||
await this.incidentService.handleIncident(evaluation.monitor, evaluation.statusChange.code, evaluation.decision, evaluation.status);
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
import { MonitorEvaluation } from "@/worker/worker.interface.js";
|
||||
|
||||
export interface IMonitorReactor {
|
||||
readonly name: string;
|
||||
readonly blocking: boolean;
|
||||
react(evaluation: MonitorEvaluation): Promise<void>;
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
import { INotificationsService } from "@/domain/notifications/notification.service.js";
|
||||
import { IMonitorReactor } from "@/worker/reactors/reactor.interface.js";
|
||||
import { MonitorEvaluation } from "@/worker/worker.interface.js";
|
||||
export class NotificationReactor implements IMonitorReactor {
|
||||
readonly name = "notification";
|
||||
readonly blocking = false;
|
||||
constructor(private notificationService: INotificationsService) {}
|
||||
|
||||
react = async (evaluation: MonitorEvaluation) => {
|
||||
if (!evaluation.decision.shouldSendNotification) return;
|
||||
await this.notificationService.handleNotifications(evaluation.monitor, evaluation.status, evaluation.decision);
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,187 @@
|
||||
import { Monitor } from "@/domain/monitors/monitor.types.js";
|
||||
import { MonitorEvaluation } from "@/worker/worker.interface.js";
|
||||
import { IMaintenanceWindowsRepository } from "@/domain/maintenance-windows/maintenance-window.repository.interface.js";
|
||||
import { IMonitorsRepository } from "@/domain/monitors/monitor.repository.interface.js";
|
||||
import { isWindowActive } from "@/utils/maintenanceWindow.js";
|
||||
import { ILogger } from "@/utils/logger.js";
|
||||
import { INetworkService } from "@/service/networkService.js";
|
||||
import { ICheckService } from "@/domain/checks/check.service.js";
|
||||
import { IBufferService } from "@/service/bufferService.js";
|
||||
import { IMonitorStatusPolicy } from "@/worker/worker.monitor-status-policy.js";
|
||||
import { IStatusService } from "@/service/statusService.js";
|
||||
import { AppError } from "@/utils/AppError.js";
|
||||
import { supportsGeoCheck } from "@/domain/monitors/monitor.types.js";
|
||||
import { IGeoChecksService } from "@/domain/geo-checks/geo-check.service.js";
|
||||
|
||||
const SERVICE_NAME = "CheckPipeline";
|
||||
|
||||
export interface ICheckPipeline {
|
||||
run(monitor: Monitor): Promise<MonitorEvaluation | null>; // null = skipped
|
||||
}
|
||||
|
||||
export class CheckPipeline implements ICheckPipeline {
|
||||
constructor(
|
||||
private monitorsRepository: IMonitorsRepository,
|
||||
private maintenanceWindowsRepository: IMaintenanceWindowsRepository,
|
||||
private checkService: ICheckService,
|
||||
private networkService: INetworkService,
|
||||
private bufferService: IBufferService,
|
||||
private monitorStatusPolicy: IMonitorStatusPolicy,
|
||||
private statusService: IStatusService,
|
||||
private logger: ILogger
|
||||
) {}
|
||||
|
||||
private async isInMaintenanceWindow(monitorId: string, teamId: string) {
|
||||
const maintenanceWindows = await this.maintenanceWindowsRepository.findByMonitorId(monitorId, teamId);
|
||||
const now = new Date();
|
||||
return maintenanceWindows.some((window) => isWindowActive(window, now));
|
||||
}
|
||||
|
||||
run = async (monitor: Monitor): Promise<MonitorEvaluation | null> => {
|
||||
const monitorId = monitor.id;
|
||||
if (!monitorId) {
|
||||
throw new AppError({ message: "No monitor id", service: SERVICE_NAME, method: "getMonitorJob" });
|
||||
}
|
||||
// ****************************
|
||||
// Step 1: Acquire
|
||||
// ****************************
|
||||
|
||||
// Step 1a: Maintenance window gate - skip if in maintenance
|
||||
const maintenanceWindowActive = await this.isInMaintenanceWindow(monitor.id, monitor.teamId);
|
||||
if (maintenanceWindowActive) {
|
||||
this.logger.debug({
|
||||
message: `Monitor ${monitor.id} is in maintenance window`,
|
||||
service: SERVICE_NAME,
|
||||
method: "runCheckPipeline",
|
||||
});
|
||||
if (monitor.status !== "maintenance") {
|
||||
await this.monitorsRepository.updateById(monitor.id, monitor.teamId, { status: "maintenance" });
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
// Step 1b: Acquire status
|
||||
const status = await this.networkService.requestStatus(monitor);
|
||||
if (!status) {
|
||||
throw new Error("No network response");
|
||||
}
|
||||
|
||||
// ****************************
|
||||
// Step 2: Record
|
||||
// ****************************
|
||||
|
||||
// Step 2a: Create & record a check, return null if fail
|
||||
const check = this.checkService.buildCheck(status);
|
||||
if (!check) {
|
||||
this.logger.warn({
|
||||
message: `No check could be built for monitor ${monitor.id}`,
|
||||
service: SERVICE_NAME,
|
||||
method: "runCHeckPipeline",
|
||||
details: { code: status.code, message: status.message },
|
||||
});
|
||||
return null;
|
||||
}
|
||||
// Step 2b: Add to buffer
|
||||
this.bufferService.addToBuffer(check);
|
||||
|
||||
// ****************************
|
||||
// Step 3: Evaluate and return result to reactors
|
||||
// ****************************
|
||||
const statusChangeResult = await this.statusService.updateMonitorStatus(status, check);
|
||||
|
||||
// Step 5. Get decisions and create an evaluation obj
|
||||
const decision = this.monitorStatusPolicy.evaluate(statusChangeResult);
|
||||
const evaluation: MonitorEvaluation = {
|
||||
monitor: statusChangeResult.monitor,
|
||||
status,
|
||||
check,
|
||||
statusChange: statusChangeResult,
|
||||
decision,
|
||||
};
|
||||
return evaluation;
|
||||
};
|
||||
}
|
||||
|
||||
export class GeoChecksPipeline implements ICheckPipeline {
|
||||
constructor(
|
||||
private maintenanceWindowsRepository: IMaintenanceWindowsRepository,
|
||||
private geoChecksService: IGeoChecksService,
|
||||
private bufferService: IBufferService,
|
||||
private logger: ILogger
|
||||
) {}
|
||||
|
||||
private async isInMaintenanceWindow(monitorId: string, teamId: string) {
|
||||
const maintenanceWindows = await this.maintenanceWindowsRepository.findByMonitorId(monitorId, teamId);
|
||||
const now = new Date();
|
||||
return maintenanceWindows.some((window) => isWindowActive(window, now));
|
||||
}
|
||||
|
||||
run = async (monitor: Monitor): Promise<MonitorEvaluation | null> => {
|
||||
// ****************************
|
||||
// Step 1: Acquire
|
||||
// ****************************
|
||||
|
||||
// Step 1a: Guards - skip if unsupported or not enabled
|
||||
|
||||
if (!monitor.id) {
|
||||
throw new AppError({ message: "No monitor id", service: SERVICE_NAME, method: "getHeartbeatGeoJob" });
|
||||
}
|
||||
|
||||
if (!monitor.geoCheckEnabled) {
|
||||
return null;
|
||||
}
|
||||
if (!supportsGeoCheck(monitor.type)) {
|
||||
this.logger.debug({
|
||||
message: `Monitor ${monitor.id} type does not support geo checks, skipping`,
|
||||
service: SERVICE_NAME,
|
||||
method: "runGeoChecksPipeline",
|
||||
});
|
||||
return null;
|
||||
}
|
||||
|
||||
if (!monitor.geoCheckLocations || monitor.geoCheckLocations.length === 0) {
|
||||
this.logger.warn({
|
||||
message: `No geo check locations configured for monitor ${monitor.id}`,
|
||||
service: SERVICE_NAME,
|
||||
method: "runGeoChecksPipeline",
|
||||
});
|
||||
return null;
|
||||
}
|
||||
|
||||
// Step 1b: Maintenance window check
|
||||
const maintenanceWindowActive = await this.isInMaintenanceWindow(monitor.id, monitor.teamId);
|
||||
if (maintenanceWindowActive) {
|
||||
this.logger.debug({
|
||||
message: `Monitor ${monitor.id} is in maintenance window, skipping geo check`,
|
||||
service: SERVICE_NAME,
|
||||
method: "runGeoChecksPipeline",
|
||||
});
|
||||
return null;
|
||||
}
|
||||
|
||||
// ****************************
|
||||
// Step 2: Record
|
||||
// ****************************
|
||||
|
||||
// Step 2a: Build geo check, return null if fail
|
||||
const geoCheck = await this.geoChecksService.buildGeoCheck(monitor);
|
||||
if (!geoCheck) {
|
||||
this.logger.warn({
|
||||
message: `No geo check could be built for monitor ${monitor.id}`,
|
||||
service: SERVICE_NAME,
|
||||
method: "runGeoChecksPipeline",
|
||||
});
|
||||
return null;
|
||||
}
|
||||
|
||||
// Step 2b: Add to buffer
|
||||
this.bufferService.addGeoCheckToBuffer(geoCheck);
|
||||
|
||||
this.logger.debug({
|
||||
message: `Geo check job executed for monitor ${monitor.id}`,
|
||||
service: SERVICE_NAME,
|
||||
method: "runGeoChecksPipeline",
|
||||
});
|
||||
return null;
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,243 @@
|
||||
const SERVICE_NAME = "JobQueueHelper";
|
||||
import type { Monitor } from "@/domain/monitors/monitor.types.js";
|
||||
import { ISettingsService } from "@/domain/app-settings/app-settings.service.js";
|
||||
import { ICheckService } from "@/domain/checks/check.service.js";
|
||||
import { CHECK_TTL_SENTINEL } from "@/domain/checks/check.type.js";
|
||||
import { IChecksRepository } from "@/domain/checks/check.repository.interface.js";
|
||||
import { IGeoChecksRepository } from "@/domain/geo-checks/geo-check.repository.interface.js";
|
||||
import { IIncidentsRepository } from "@/domain/incidents/incident.repository.interface.js";
|
||||
import { IMonitorStatsRepository } from "@/domain/monitor-stats/monitor-stats.repository.interface.js";
|
||||
import { IMonitorsRepository } from "@/domain/monitors/monitor.repository.interface.js";
|
||||
import { ITeamsRepository } from "@/domain/teams/team.repository.interface.js";
|
||||
import { ILogger } from "@/utils/logger.js";
|
||||
import { IReactorDispatcher } from "@/worker/reactors/reactor.dispatcher.js";
|
||||
import { ICheckPipeline } from "@/worker/worker.check-pipeline.js";
|
||||
|
||||
export interface IWorkerHelper {
|
||||
readonly serviceName: string;
|
||||
getHeartbeatJob(): (monitor: Monitor) => Promise<void>;
|
||||
getHeartbeatGeoJob(): (monitor: Monitor) => Promise<void>;
|
||||
getCleanupOrphanedJob(): () => Promise<void>;
|
||||
getCleanupRetentionJob(): () => Promise<void>;
|
||||
}
|
||||
|
||||
export interface MonitorActionDecision {
|
||||
shouldCreateIncident: boolean;
|
||||
shouldResolveIncident: boolean;
|
||||
shouldSendNotification: boolean;
|
||||
incidentReason: "status_down" | "threshold_breach" | null;
|
||||
notificationReason: "status_change" | "threshold_breach" | null;
|
||||
thresholdBreaches?: {
|
||||
cpu?: boolean;
|
||||
memory?: boolean;
|
||||
disk?: boolean;
|
||||
temp?: boolean;
|
||||
};
|
||||
}
|
||||
|
||||
export class WorkerHelper implements IWorkerHelper {
|
||||
static SERVICE_NAME = SERVICE_NAME;
|
||||
|
||||
private logger: ILogger;
|
||||
private checkService: ICheckService;
|
||||
private settingsService: ISettingsService;
|
||||
private monitorsRepository: IMonitorsRepository;
|
||||
private teamsRepository: ITeamsRepository;
|
||||
private monitorStatsRepository: IMonitorStatsRepository;
|
||||
private checksRepository: IChecksRepository;
|
||||
private incidentsRepository: IIncidentsRepository;
|
||||
private geoChecksRepository: IGeoChecksRepository;
|
||||
private reactorDispatcher: IReactorDispatcher;
|
||||
private checkPipeline: ICheckPipeline;
|
||||
private geoCheckPipeline: ICheckPipeline;
|
||||
|
||||
constructor(
|
||||
logger: ILogger,
|
||||
checkService: ICheckService,
|
||||
settingsService: ISettingsService,
|
||||
monitorsRepository: IMonitorsRepository,
|
||||
teamsRepository: ITeamsRepository,
|
||||
monitorStatsRepository: IMonitorStatsRepository,
|
||||
checksRepository: IChecksRepository,
|
||||
incidentsRepository: IIncidentsRepository,
|
||||
geoChecksRepository: IGeoChecksRepository,
|
||||
reactorDispatcher: IReactorDispatcher,
|
||||
checkPipeline: ICheckPipeline,
|
||||
geoCheckPipeline: ICheckPipeline
|
||||
) {
|
||||
this.logger = logger;
|
||||
this.checkService = checkService;
|
||||
this.settingsService = settingsService;
|
||||
this.monitorsRepository = monitorsRepository;
|
||||
this.teamsRepository = teamsRepository;
|
||||
this.monitorStatsRepository = monitorStatsRepository;
|
||||
this.checksRepository = checksRepository;
|
||||
this.incidentsRepository = incidentsRepository;
|
||||
this.geoChecksRepository = geoChecksRepository;
|
||||
this.reactorDispatcher = reactorDispatcher;
|
||||
this.checkPipeline = checkPipeline;
|
||||
this.geoCheckPipeline = geoCheckPipeline;
|
||||
}
|
||||
|
||||
get serviceName() {
|
||||
return WorkerHelper.SERVICE_NAME;
|
||||
}
|
||||
|
||||
getHeartbeatJob = () => {
|
||||
return async (monitor: Monitor) => {
|
||||
try {
|
||||
const evaluation = await this.checkPipeline.run(monitor);
|
||||
if (evaluation) await this.reactorDispatcher.dispatch(evaluation);
|
||||
} catch (error: unknown) {
|
||||
this.logger.warn({
|
||||
message: error instanceof Error ? error.message : "Unknown error",
|
||||
service: SERVICE_NAME,
|
||||
method: "getMonitorJob",
|
||||
stack: error instanceof Error ? error.stack : undefined,
|
||||
});
|
||||
throw error;
|
||||
}
|
||||
};
|
||||
};
|
||||
|
||||
getHeartbeatGeoJob = () => {
|
||||
return async (monitor: Monitor) => {
|
||||
try {
|
||||
await this.geoCheckPipeline.run(monitor);
|
||||
} catch (error: unknown) {
|
||||
this.logger.error({
|
||||
message: error instanceof Error ? error.message : "Unknown error",
|
||||
service: SERVICE_NAME,
|
||||
method: "getHeartbeatGeoJob",
|
||||
stack: error instanceof Error ? error.stack : undefined,
|
||||
});
|
||||
// Don't throw - geo check failures shouldn't crash the job scheduler
|
||||
}
|
||||
};
|
||||
};
|
||||
|
||||
getCleanupOrphanedJob = () => {
|
||||
return async () => {
|
||||
try {
|
||||
this.logger.info({
|
||||
message: "Starting cleanup of orphaned data",
|
||||
service: SERVICE_NAME,
|
||||
method: "getCleanupOrphanedJob",
|
||||
});
|
||||
|
||||
// Get all valid team IDs
|
||||
const validTeamIds = await this.teamsRepository.findAllTeamIds();
|
||||
this.logger.debug({
|
||||
message: `Found ${validTeamIds.length} valid teams`,
|
||||
service: SERVICE_NAME,
|
||||
method: "getCleanupOrphanedJob",
|
||||
});
|
||||
|
||||
// Remove orphaned monitors (monitors without a valid team)
|
||||
const deletedMonitorCount = await this.monitorsRepository.deleteByTeamIdsNotIn(validTeamIds);
|
||||
if (deletedMonitorCount > 0) {
|
||||
this.logger.info({
|
||||
message: `Deleted ${deletedMonitorCount} orphaned monitors`,
|
||||
service: SERVICE_NAME,
|
||||
method: "getCleanupOrphanedJob",
|
||||
});
|
||||
}
|
||||
|
||||
// Remove orphaned monitorStats (stats without a valid monitor)
|
||||
const allMonitorIds = await this.monitorsRepository.findAllMonitorIds();
|
||||
this.logger.debug({
|
||||
message: `Found ${allMonitorIds.length} valid monitors`,
|
||||
service: SERVICE_NAME,
|
||||
method: "getCleanupOrphanedJob",
|
||||
});
|
||||
|
||||
const deletedStatsCount = await this.monitorStatsRepository.deleteByMonitorIdsNotIn(allMonitorIds);
|
||||
if (deletedStatsCount > 0) {
|
||||
this.logger.info({
|
||||
message: `Deleted ${deletedStatsCount} orphaned monitor stats`,
|
||||
service: SERVICE_NAME,
|
||||
method: "getCleanupOrphanedJob",
|
||||
});
|
||||
}
|
||||
|
||||
// Remove orphaned checks
|
||||
const deletedChecksCount = await this.checksRepository.deleteByMonitorIdsNotIn(allMonitorIds);
|
||||
if (deletedChecksCount > 0) {
|
||||
this.logger.info({
|
||||
message: `Deleted ${deletedChecksCount} orphaned checks`,
|
||||
service: SERVICE_NAME,
|
||||
method: "getCleanupOrphanedJob",
|
||||
});
|
||||
}
|
||||
|
||||
// Remove orphaned incidents
|
||||
const deletedIncidentsCount = await this.incidentsRepository.deleteByMonitorIdsNotIn(allMonitorIds);
|
||||
if (deletedIncidentsCount > 0) {
|
||||
this.logger.info({
|
||||
message: `Deleted ${deletedIncidentsCount} orphaned incidents`,
|
||||
service: SERVICE_NAME,
|
||||
method: "getCleanupOrphanedJob",
|
||||
});
|
||||
}
|
||||
|
||||
// Remove orphaned geo checks
|
||||
const deletedGeoChecksCount = await this.geoChecksRepository.deleteByMonitorIdsNotIn(allMonitorIds);
|
||||
if (deletedGeoChecksCount > 0) {
|
||||
this.logger.info({
|
||||
message: `Deleted ${deletedGeoChecksCount} orphaned geo checks`,
|
||||
service: SERVICE_NAME,
|
||||
method: "getCleanupOrphanedJob",
|
||||
});
|
||||
}
|
||||
|
||||
this.logger.info({
|
||||
message: "Cleanup of orphaned data completed",
|
||||
service: SERVICE_NAME,
|
||||
method: "getCleanupOrphanedJob",
|
||||
});
|
||||
} catch (error: unknown) {
|
||||
this.logger.warn({
|
||||
message: error instanceof Error ? error.message : "Unknown error",
|
||||
service: SERVICE_NAME,
|
||||
method: "getCleanupOrphanedJob",
|
||||
stack: error instanceof Error ? error.stack : undefined,
|
||||
});
|
||||
throw error;
|
||||
}
|
||||
};
|
||||
};
|
||||
|
||||
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,
|
||||
});
|
||||
}
|
||||
};
|
||||
};
|
||||
}
|
||||
+21
-10
@@ -1,7 +1,18 @@
|
||||
import { Monitor } from "@/domain/monitors/monitor.types.js";
|
||||
import { QueueWorker } from "@/domain/queue-workers/queue-worker.type.js";
|
||||
import { Check } from "@/domain/checks/check.type.js";
|
||||
import { MonitorPayloadMap, MonitorStatusResponse, StatusChangeResult } from "@/types/network.js";
|
||||
import { MonitorActionDecision } from "@/worker/worker.helper.js";
|
||||
|
||||
export type QueueJobFailure = {
|
||||
export type MonitorEvaluation = {
|
||||
monitor: Monitor;
|
||||
status: MonitorStatusResponse<MonitorPayloadMap[keyof MonitorPayloadMap]>; // raw result from networkService.requestStatus
|
||||
check: Check;
|
||||
statusChange: StatusChangeResult; // from statusService.updateMonitorStatus
|
||||
decision: MonitorActionDecision; // from MonitorStatusPolicy
|
||||
};
|
||||
|
||||
export type WorkerJobFailure = {
|
||||
monitorId: string | number;
|
||||
monitorUrl: string | null;
|
||||
monitorType: string | null;
|
||||
@@ -10,17 +21,17 @@ export type QueueJobFailure = {
|
||||
failReason: string | null;
|
||||
};
|
||||
|
||||
export type QueueMetrics = {
|
||||
export type WorkerMetrics = {
|
||||
jobs: number;
|
||||
activeJobs: number;
|
||||
failingJobs: number;
|
||||
jobsWithFailures: QueueJobFailure[];
|
||||
jobsWithFailures: WorkerJobFailure[];
|
||||
totalRuns: number;
|
||||
totalFailures: number;
|
||||
workers: QueueWorker[];
|
||||
};
|
||||
|
||||
export type QueueJobSummary = {
|
||||
export type WorkerJobSummary = {
|
||||
monitorId: string | number;
|
||||
monitorUrl: string | null;
|
||||
monitorType: string | null;
|
||||
@@ -41,17 +52,17 @@ export type QueueJobSummary = {
|
||||
repeat: number | null;
|
||||
};
|
||||
|
||||
export type QueueJobsPagination = {
|
||||
export type WorkerJobsPagination = {
|
||||
page?: number;
|
||||
rowsPerPage?: number;
|
||||
};
|
||||
|
||||
export type QueueJobsPage = {
|
||||
jobs: QueueJobSummary[];
|
||||
export type WorkerJobsPage = {
|
||||
jobs: WorkerJobSummary[];
|
||||
count: number;
|
||||
};
|
||||
|
||||
export interface IJobQueue {
|
||||
export interface IWorker {
|
||||
readonly serviceName: string;
|
||||
init(): Promise<boolean>;
|
||||
addJob(monitorId: string, monitor: Monitor): Promise<void>;
|
||||
@@ -60,7 +71,7 @@ export interface IJobQueue {
|
||||
resumeJob(monitor: Monitor): Promise<void>;
|
||||
updateJob(monitor: Monitor): Promise<void>;
|
||||
shutdown(): Promise<void>;
|
||||
getMetrics(): Promise<QueueMetrics>;
|
||||
getJobs(pagination: QueueJobsPagination): Promise<QueueJobsPage>;
|
||||
getMetrics(): Promise<WorkerMetrics>;
|
||||
getJobs(pagination: WorkerJobsPagination): Promise<WorkerJobsPage>;
|
||||
flushQueues(): Promise<{ success: boolean }>;
|
||||
}
|
||||
+10
-10
@@ -2,9 +2,9 @@ import { IMonitorsRepository } from "@/domain/monitors/monitor.repository.interf
|
||||
import { IQueueWorkersRepository } from "@/domain/queue-workers/queue-worker.repository.interface.js";
|
||||
import { ILogger } from "@/utils/logger.js";
|
||||
import { MongoStore, Scheduler, IJob, AddJobInput } from "less-simple-scheduler";
|
||||
import { IQueueHelper } from "@/service/job-queues/job-queue.helper.js";
|
||||
import { Monitor, supportsGeoCheck } from "@/domain/monitors/monitor.types.js";
|
||||
import { type QueueMetrics, type QueueJobsPagination, type QueueJobsPage, IJobQueue } from "@/service/job-queues/job-queue.interface.js";
|
||||
import { IWorkerHelper } from "@/worker/worker.helper.js";
|
||||
import { type WorkerMetrics, type WorkerJobsPagination, type WorkerJobsPage, IWorker } from "@/worker/worker.interface.js";
|
||||
import { type QueueMode } from "@/domain/app-settings/app-settings.type.js";
|
||||
import { EnvConfig } from "@/domain/app-settings/app-settings.service.js";
|
||||
const SERVICE_NAME = "JobQueue";
|
||||
@@ -17,11 +17,11 @@ type MonitorJob = Omit<IJob, "data"> & { data: Monitor };
|
||||
const MONITOR_TEMPLATES = new Set<string>(["monitor-job", "geo-check-job"]);
|
||||
const isMonitorJob = (job: IJob): job is MonitorJob => MONITOR_TEMPLATES.has(job.template);
|
||||
|
||||
export class LessSimpleQueue implements IJobQueue {
|
||||
export class LessSimpleWorker implements IWorker {
|
||||
static SERVICE_NAME = SERVICE_NAME;
|
||||
|
||||
private logger: ILogger;
|
||||
private helper: IQueueHelper;
|
||||
private helper: IWorkerHelper;
|
||||
private monitorsRepository: IMonitorsRepository;
|
||||
private workersRepository: IQueueWorkersRepository;
|
||||
private readonly scheduler: Scheduler;
|
||||
@@ -30,7 +30,7 @@ export class LessSimpleQueue implements IJobQueue {
|
||||
|
||||
constructor(
|
||||
logger: ILogger,
|
||||
helper: IQueueHelper,
|
||||
helper: IWorkerHelper,
|
||||
monitorsRepository: IMonitorsRepository,
|
||||
workersRepository: IQueueWorkersRepository,
|
||||
scheduler: Scheduler,
|
||||
@@ -46,7 +46,7 @@ export class LessSimpleQueue implements IJobQueue {
|
||||
}
|
||||
|
||||
get serviceName() {
|
||||
return LessSimpleQueue.SERVICE_NAME;
|
||||
return LessSimpleWorker.SERVICE_NAME;
|
||||
}
|
||||
|
||||
private registerListeners = () => {
|
||||
@@ -126,7 +126,7 @@ export class LessSimpleQueue implements IJobQueue {
|
||||
|
||||
static async create(
|
||||
logger: ILogger,
|
||||
helper: IQueueHelper,
|
||||
helper: IWorkerHelper,
|
||||
monitorsRepository: IMonitorsRepository,
|
||||
workersRepository: IQueueWorkersRepository,
|
||||
envSettings: EnvConfig,
|
||||
@@ -139,7 +139,7 @@ export class LessSimpleQueue implements IJobQueue {
|
||||
concurrency: envSettings.queueMode === "primary" ? 0 : 50,
|
||||
processEvery: 100,
|
||||
});
|
||||
const instance = new LessSimpleQueue(logger, helper, monitorsRepository, workersRepository, scheduler, queueMode);
|
||||
const instance = new LessSimpleWorker(logger, helper, monitorsRepository, workersRepository, scheduler, queueMode);
|
||||
await instance.init(queueMode);
|
||||
|
||||
return instance;
|
||||
@@ -350,7 +350,7 @@ export class LessSimpleQueue implements IJobQueue {
|
||||
this.scheduler.stop();
|
||||
};
|
||||
|
||||
getMetrics = async (): Promise<QueueMetrics> => {
|
||||
getMetrics = async (): Promise<WorkerMetrics> => {
|
||||
const stats = await this.scheduler.getStats();
|
||||
|
||||
const jobsWithFailures = stats.jobsWithFailures.map((failure) => {
|
||||
@@ -379,7 +379,7 @@ export class LessSimpleQueue implements IJobQueue {
|
||||
};
|
||||
};
|
||||
|
||||
getJobs = async ({ page = 0, rowsPerPage = 0 }: QueueJobsPagination): Promise<QueueJobsPage> => {
|
||||
getJobs = async ({ page = 0, rowsPerPage = 0 }: WorkerJobsPagination): Promise<WorkerJobsPage> => {
|
||||
const listOptions = rowsPerPage > 0 ? { skip: Math.max(page, 0) * rowsPerPage, limit: rowsPerPage } : undefined;
|
||||
const [jobs, count] = await Promise.all([this.scheduler.getJobs(listOptions), this.scheduler.countJobs()]);
|
||||
const mapped = jobs.map((job) => {
|
||||
@@ -0,0 +1,46 @@
|
||||
import { StatusChangeResult } from "@/types/network.js";
|
||||
import { MonitorActionDecision } from "@/worker/worker.helper.js";
|
||||
|
||||
export interface IMonitorStatusPolicy {
|
||||
evaluate(statusChange: StatusChangeResult): MonitorActionDecision;
|
||||
}
|
||||
|
||||
export class MonitorStatusPolicy implements IMonitorStatusPolicy {
|
||||
evaluate = (statusChangeResult: StatusChangeResult): MonitorActionDecision => {
|
||||
const { monitor, statusChanged, prevStatus } = statusChangeResult;
|
||||
|
||||
// Initialize result
|
||||
const decision: MonitorActionDecision = {
|
||||
shouldCreateIncident: false,
|
||||
shouldResolveIncident: false,
|
||||
shouldSendNotification: false,
|
||||
incidentReason: null,
|
||||
notificationReason: null,
|
||||
};
|
||||
|
||||
if (!statusChanged) {
|
||||
return decision;
|
||||
}
|
||||
|
||||
if (monitor.status === "down") {
|
||||
// Monitor went down (unreachable)
|
||||
decision.shouldCreateIncident = true;
|
||||
decision.shouldSendNotification = true;
|
||||
decision.incidentReason = "status_down";
|
||||
decision.notificationReason = "status_change";
|
||||
} else if (monitor.status === "breached") {
|
||||
// Hardware monitor exceeded thresholds
|
||||
decision.shouldCreateIncident = true;
|
||||
decision.shouldSendNotification = true;
|
||||
decision.incidentReason = "threshold_breach";
|
||||
decision.notificationReason = "threshold_breach";
|
||||
} else if (monitor.status === "up" && (prevStatus === "down" || prevStatus === "breached")) {
|
||||
// Monitor recovered from down or breached state
|
||||
decision.shouldResolveIncident = true;
|
||||
decision.shouldSendNotification = true;
|
||||
decision.notificationReason = "status_change";
|
||||
}
|
||||
|
||||
return decision;
|
||||
};
|
||||
}
|
||||
+7
-7
@@ -1,25 +1,25 @@
|
||||
import { IMonitorsRepository } from "@/domain/monitors/monitor.repository.interface.js";
|
||||
import { ILogger } from "@/utils/logger.js";
|
||||
import Scheduler from "super-simple-scheduler";
|
||||
import { IQueueHelper } from "@/service/job-queues/job-queue.helper.js";
|
||||
import { Monitor, MonitorType, supportsGeoCheck } from "@/domain/monitors/monitor.types.js";
|
||||
import { hostname } from "node:os";
|
||||
import { randomUUID } from "node:crypto";
|
||||
import { QueueMode } from "@/domain/app-settings/app-settings.type.js";
|
||||
import { IJobQueue, type QueueJobSummary } from "@/service/job-queues/job-queue.interface.js";
|
||||
import { IWorker, WorkerJobSummary } from "@/worker/worker.interface.js";
|
||||
import { IWorkerHelper } from "@/worker/worker.helper.js";
|
||||
|
||||
const SERVICE_NAME = "JobQueue";
|
||||
|
||||
export class SuperSimpleQueue implements IJobQueue {
|
||||
export class SuperSimpleQueue implements IWorker {
|
||||
static SERVICE_NAME = SERVICE_NAME;
|
||||
|
||||
private logger: ILogger;
|
||||
private helper: IQueueHelper;
|
||||
private helper: IWorkerHelper;
|
||||
private workerId: string;
|
||||
private monitorsRepository: IMonitorsRepository;
|
||||
private readonly scheduler: Scheduler;
|
||||
|
||||
constructor(logger: ILogger, helper: IQueueHelper, monitorsRepository: IMonitorsRepository, scheduler: Scheduler) {
|
||||
constructor(logger: ILogger, helper: IWorkerHelper, monitorsRepository: IMonitorsRepository, scheduler: Scheduler) {
|
||||
this.logger = logger;
|
||||
this.helper = helper;
|
||||
this.monitorsRepository = monitorsRepository;
|
||||
@@ -100,7 +100,7 @@ export class SuperSimpleQueue implements IJobQueue {
|
||||
});
|
||||
};
|
||||
|
||||
static async create(logger: ILogger, helper: IQueueHelper, monitorsRepository: IMonitorsRepository) {
|
||||
static async create(logger: ILogger, helper: IWorkerHelper, monitorsRepository: IMonitorsRepository) {
|
||||
const scheduler = new Scheduler({
|
||||
concurrency: 50,
|
||||
// storeType: "mongo",
|
||||
@@ -341,7 +341,7 @@ export class SuperSimpleQueue implements IJobQueue {
|
||||
return metrics;
|
||||
};
|
||||
|
||||
getJobs = async ({ page = 0, rowsPerPage = 0 }: { page?: number; rowsPerPage?: number }): Promise<{ jobs: QueueJobSummary[]; count: number }> => {
|
||||
getJobs = async ({ page = 0, rowsPerPage = 0 }: { page?: number; rowsPerPage?: number }): Promise<{ jobs: WorkerJobSummary[]; count: number }> => {
|
||||
const jobs = await this.scheduler.getJobs();
|
||||
const mapped = jobs.map((job) => {
|
||||
return {
|
||||
Reference in new issue
Block a user