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:
Buco7854 committed 2026-06-12 16:01:03 +00:00
commit 6165ae60e6
68 files changed
+2074 -1125

No files matched your search

+1
View File
@@ -31,6 +31,7 @@ export const useStatusPageForm = ({
const defaults: StatusPageFormData = {
companyName: data?.companyName || "",
url: data?.url || generateDefaultUrl(),
customDomain: data?.customDomain ?? null,
timezone: resolveTimezone(data?.timezone),
type: data?.type || ["uptime"],
color: data?.color || "#13715B",
@@ -151,6 +151,11 @@ const CreateStatusPage = () => {
fd.append("isPublished", String(data.isPublished));
if (data.companyName) fd.append("companyName", data.companyName);
if (data.url) fd.append("url", data.url);
if (data.customDomain !== undefined && data.customDomain !== null) {
fd.append("customDomain", data.customDomain);
} else {
fd.append("customDomain", "");
}
if (data.timezone) fd.append("timezone", data.timezone);
if (data.color) fd.append("color", data.color);
fd.append("showCharts", String(data.showCharts));
@@ -285,6 +290,27 @@ const CreateStatusPage = () => {
/>
)}
/>
<Controller
name="customDomain"
control={control}
render={({ field, fieldState }) => (
<TextField
{...field}
value={field.value ?? ""}
fieldLabel={t(
"pages.statusPages.form.basicInfo.option.customDomain.label"
)}
placeholder={t(
"pages.statusPages.form.basicInfo.option.customDomain.placeholder"
)}
helperText={
fieldState.error?.message ||
t("pages.statusPages.form.basicInfo.option.customDomain.helper")
}
error={!!fieldState.error}
/>
)}
/>
</Stack>
}
/>
@@ -8,7 +8,8 @@ import { Settings, ExternalLink } from "lucide-react";
import { useTheme } from "@mui/material";
import { useTranslation } from "react-i18next";
import { useNavigate } from "react-router-dom";
import { PUBLIC_STATUS_PAGE_PREFIX, type StatusPage } from "@/Types/StatusPage";
import { type StatusPage } from "@/Types/StatusPage";
import { getStatusPagePublicUrl } from "@/Utils/statusPageUrl";
interface HeaderStatusPageControlsProps {
isAdmin: boolean;
@@ -50,7 +51,7 @@ export const HeaderStatusPageControls = ({
<Typography
onClick={() => {
window.open(
`${PUBLIC_STATUS_PAGE_PREFIX}/${statusPage.url}`,
getStatusPagePublicUrl(statusPage),
"_blank",
"noopener,noreferrer"
);
+13 -3
View File
@@ -15,6 +15,11 @@ import {
type StatusPageResponse,
type StatusPageTheme,
} from "@/Types/StatusPage";
import {
buildStatusPageApiPath,
getStatusPagePublicUrl,
isCustomDomainHost,
} from "@/Utils/statusPageUrl";
import { HeaderStatusPageControls } from "@/Pages/StatusPage/Status/Components/HeaderStatusPageControls";
import { StatusPageThemeProvider } from "@/Pages/StatusPage/Status/themes/StatusPageThemeProvider";
import {
@@ -68,9 +73,14 @@ const StatusPageView = () => {
const isAdmin = useIsAdmin();
const location = useLocation();
const isPublic = location.pathname.startsWith(PUBLIC_STATUS_PAGE_PREFIX);
const onCustomDomainHost = isCustomDomainHost();
const isPublic =
onCustomDomainHost || location.pathname.startsWith(PUBLIC_STATUS_PAGE_PREFIX);
const apiUrl = url ? `/status-page/${url}?type=uptime&type=infrastructure` : null;
const apiUrl = buildStatusPageApiPath({
url,
useCustomDomain: onCustomDomainHost,
});
const { data, isLoading, error } = useGet<StatusPageResponse>(
apiUrl,
@@ -131,7 +141,7 @@ const StatusPageView = () => {
);
}
const publicUrl = `${window.location.origin}${PUBLIC_STATUS_PAGE_PREFIX}/${statusPage.url}`;
const publicUrl = getStatusPagePublicUrl(statusPage);
return (
<BasePage
loading={isLoading}
@@ -11,7 +11,9 @@ import { useTranslation } from "react-i18next";
import { useTheme } from "@mui/material/styles";
import { useNavigate } from "react-router-dom";
import { PUBLIC_STATUS_PAGE_PREFIX, type StatusPage } from "@/Types/StatusPage";
import { getStatusPagePublicPath, getStatusPagePublicUrl } from "@/Utils/statusPageUrl";
import { type StatusPage } from "@/Types/StatusPage";
interface StatusPagesTableProps {
data: StatusPage[];
@@ -53,8 +55,7 @@ export const StatusPagesTable = ({
const handleUrlClick = (e: React.MouseEvent, row: StatusPage) => {
if (row.isPublished) {
e.stopPropagation();
const url = `${PUBLIC_STATUS_PAGE_PREFIX}/${row.url}`;
window.open(url, "_blank", "noopener,noreferrer");
window.open(getStatusPagePublicUrl(row), "_blank", "noopener,noreferrer");
}
};
@@ -70,7 +71,9 @@ export const StatusPagesTable = ({
content: t("pages.statusPages.table.headers.url"),
render: (row) => {
const content = row.isPublished
? `/${row.url}`
? row.customDomain
? getStatusPagePublicPath(row)
: `/${row.url}`
: t("pages.statusPages.table.unpublished");
return (
<Stack
+16
View File
@@ -54,8 +54,24 @@ import Logs from "@/Pages/Logs";
import { ProtectedRoute, RoleProtectedRoute } from "@/Components/routing/RouteProtected";
import CreateMonitor from "@/Pages/CreateMonitor";
import { isCustomDomainHost } from "@/Utils/statusPageUrl";
const Routes = () => {
if (isCustomDomainHost()) {
return (
<LibRoutes>
<Route
path="/"
element={<Status />}
/>
<Route
path="*"
element={<Status />}
/>
</LibRoutes>
);
}
return (
<LibRoutes>
<Route
+1
View File
@@ -47,6 +47,7 @@ export interface StatusPage {
type: MonitorDisplayType[];
companyName: string;
url: string;
customDomain?: string | null;
timezone?: string;
color: string;
monitors: string[];
+89
View File
@@ -0,0 +1,89 @@
import { PUBLIC_STATUS_PAGE_PREFIX, type StatusPage } from "@/Types/StatusPage";
const CLIENT_HOST = import.meta.env.VITE_APP_CLIENT_HOST as string | undefined;
const getClientOriginProtocol = (): string => {
const source =
CLIENT_HOST || (typeof window !== "undefined" ? window.location.origin : undefined);
if (!source) {
return "https:";
}
try {
return new URL(source).protocol;
} catch {
return "https:";
}
};
const buildCustomDomainOrigin = (customDomain: string): string =>
`${getClientOriginProtocol()}//${customDomain}`;
export const getAppHostname = (): string | null => {
const source =
CLIENT_HOST || (typeof window !== "undefined" ? window.location.origin : undefined);
if (!source) {
return null;
}
try {
return new URL(source).hostname.toLowerCase();
} catch {
return null;
}
};
export const isCustomDomainHost = (): boolean => {
if (typeof window === "undefined") {
return false;
}
const appHostname = getAppHostname();
if (!appHostname) {
return false;
}
return window.location.hostname.toLowerCase() !== appHostname;
};
export const getStatusPagePublicPath = (
statusPage: Pick<StatusPage, "url" | "customDomain">
): string => {
if (statusPage.customDomain) {
return buildCustomDomainOrigin(statusPage.customDomain);
}
return `${PUBLIC_STATUS_PAGE_PREFIX}/${statusPage.url}`;
};
export const getStatusPagePublicUrl = (
statusPage: Pick<StatusPage, "url" | "customDomain">
): string => {
if (statusPage.customDomain) {
return buildCustomDomainOrigin(statusPage.customDomain);
}
if (typeof window !== "undefined") {
return `${window.location.origin}${PUBLIC_STATUS_PAGE_PREFIX}/${statusPage.url}`;
}
return `${PUBLIC_STATUS_PAGE_PREFIX}/${statusPage.url}`;
};
export const buildStatusPageApiPath = (options: {
url?: string;
useCustomDomain?: boolean;
}): string | null => {
const query = "type=uptime&type=infrastructure";
if (options.useCustomDomain && typeof window !== "undefined") {
const domain = encodeURIComponent(window.location.hostname);
return `/status-page/resolve?domain=${domain}&${query}`;
}
if (options.url) {
return `/status-page/${options.url}?${query}`;
}
return null;
};
+23
View File
@@ -9,6 +9,23 @@ import { STATUS_PAGE_THEMES, STATUS_PAGE_THEME_MODES } from "@/Types/StatusPage"
// field definitions.
export const statusPageStepRegistry = z.registry<{ step: number }>();
const statusPageHostnameRegex =
/^(?=.{1,253}$)([a-zA-Z0-9_](?:[a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?\.)+[a-zA-Z]{2,63}$/;
const normalizeCustomDomainInput = (raw: string | null): string | null => {
if (raw === null) {
return null;
}
const trimmed = raw.trim().toLowerCase();
if (!trimmed) {
return null;
}
const withoutScheme = trimmed.replace(/^https?:\/\//, "");
return withoutScheme.split("/")[0]?.split(":")[0] ?? null;
};
export const statusPageSchema = z.object({
companyName: z
.string()
@@ -22,6 +39,12 @@ export const statusPageSchema = z.object({
/^[a-z0-9-]+$/,
"URL can only contain lowercase letters, numbers, and hyphens"
),
customDomain: z
.union([z.string(), z.null()])
.transform(normalizeCustomDomainInput)
.refine((domain) => domain === null || statusPageHostnameRegex.test(domain), {
message: "Enter a valid domain name (e.g. status.example.com)",
}),
timezone: z.string().optional(),
type: z
.array(z.enum(["uptime", "infrastructure"]))
+5
View File
@@ -1509,6 +1509,11 @@
"url": {
"label": "Your status page address",
"placeholder": "my-status-page"
},
"customDomain": {
"label": "Custom domain",
"placeholder": "status.example.com",
"helper": "Optional. Point a DNS record to this Checkmate instance to serve the status page at your own domain."
}
}
},
+118
View File
@@ -10448,6 +10448,17 @@
"type": "string",
"pattern": "^[a-zA-Z0-9_-]+$"
},
"customDomain": {
"anyOf": [
{
"type": "string",
"pattern": "^(?=.{1,253}$)([a-zA-Z0-9_](?:[a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?\\.)+[a-zA-Z]{2,63}$"
},
{
"nullable": true
}
]
},
"timezone": {
"type": "string"
},
@@ -10655,6 +10666,17 @@
"type": "string",
"pattern": "^[a-zA-Z0-9_-]+$"
},
"customDomain": {
"anyOf": [
{
"type": "string",
"pattern": "^(?=.{1,253}$)([a-zA-Z0-9_](?:[a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?\\.)+[a-zA-Z]{2,63}$"
},
{
"nullable": true
}
]
},
"timezone": {
"type": "string"
},
@@ -10928,6 +10950,102 @@
}
}
},
"/status-page/resolve": {
"get": {
"tags": ["status-page"],
"summary": "Resolve a published status page by custom domain",
"parameters": [
{
"schema": {
"anyOf": [
{
"type": "string",
"enum": ["uptime", "infrastructure"]
},
{
"type": "array",
"items": {
"type": "string",
"enum": ["uptime", "infrastructure"]
}
}
]
},
"required": true,
"name": "type",
"in": "query"
},
{
"schema": {
"type": "number",
"nullable": true
},
"required": false,
"name": "timeFrame",
"in": "query"
},
{
"schema": {
"type": "string"
},
"required": false,
"name": "domain",
"in": "query"
}
],
"responses": {
"200": {
"description": "OK",
"content": {
"application/json": {
"schema": {
"type": "object",
"properties": {
"success": {
"type": "boolean",
"enum": [true]
},
"msg": {
"type": "string"
},
"data": {
"nullable": true
}
},
"required": ["success", "msg"]
}
}
}
},
"404": {
"description": "Status page not found"
},
"500": {
"description": "Internal server error",
"content": {
"application/json": {
"schema": {
"type": "object",
"properties": {
"success": {
"type": "boolean",
"enum": [false]
},
"msg": {
"type": "string"
},
"data": {
"nullable": true
}
},
"required": ["success", "msg"]
}
}
}
}
}
}
},
"/status-page/{url}": {
"get": {
"tags": ["status-page"],
+15 -1
View File
@@ -1,6 +1,11 @@
import { registry } from "../registry.js";
import { bearer, multipart, okJsonNoData, okUnknown, standardErrors } from "../helpers.js";
import { createStatusPageBodyValidation, getStatusPageParamValidation, getStatusPageQueryValidation } from "@/api/validation/statusPageValidation.js";
import {
createStatusPageBodyValidation,
getStatusPageParamValidation,
getStatusPageQueryValidation,
resolveStatusPageQueryValidation,
} from "@/api/validation/statusPageValidation.js";
const tags = ["status-page"];
@@ -33,6 +38,15 @@ registry.registerPath({
responses: { "200": okUnknown, ...standardErrors },
});
registry.registerPath({
method: "get",
path: "/status-page/resolve",
tags,
summary: "Resolve a published status page by custom domain",
request: { query: resolveStatusPageQueryValidation },
responses: { "200": okUnknown, "404": { description: "Status page not found" }, "500": standardErrors["500"] },
});
registry.registerPath({
method: "get",
path: "/status-page/{url}",
@@ -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);
+6 -2
View File
@@ -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;
};
+27
View File
@@ -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 };
+11 -7
View File
@@ -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);
}
+4 -4
View File
@@ -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
View File
@@ -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));
+1 -1
View File
@@ -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),
+40 -19
View File
@@ -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";
+16 -16
View File
@@ -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[];
+6 -6
View File
@@ -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
+1 -1
View File
@@ -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;
}
}
+1 -1
View File
@@ -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);
+5
View File
@@ -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
+29
View File
@@ -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);
};
}
+187
View File
@@ -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;
};
}
+243
View File
@@ -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,
});
}
};
};
}
@@ -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 }>;
}
@@ -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;
};
}
@@ -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 {
+27 -9
View File
@@ -1,5 +1,10 @@
import { jest } from "@jest/globals";
import { QueueHelper } from "../../src/service/job-queues/job-queue.helper.ts";
import { WorkerHelper } from "../../src/worker/worker.helper.ts";
import { MonitorStatusPolicy } from "../../src/worker/worker.monitor-status-policy.ts";
import { CheckPipeline, GeoChecksPipeline } from "../../src/worker/worker.check-pipeline.ts";
import { NotificationReactor } from "../../src/worker/reactors/reactor.notification.ts";
import { IncidentReactor } from "../../src/worker/reactors/reactor.incident.ts";
import { ReactorDispatcher } from "../../src/worker/reactors/reactor.dispatcher.ts";
import { StatusService } from "../../src/service/statusService.ts";
import { IncidentService } from "../../src/domain/incidents/incident.service.ts";
import { InMemoryMonitorsRepository } from "./InMemoryMonitorsRepository.ts";
@@ -142,24 +147,37 @@ export function createHeartbeatTestHarness(): HeartbeatTestHarness {
};
const maintenanceWindowsRepo = { findByMonitorId: jest.fn().mockResolvedValue([]) };
const geoChecksService = { buildGeoCheck: jest.fn() };
const helper = new QueueHelper(
logger,
const checkPipeline = new CheckPipeline(
monitorsRepo as any,
maintenanceWindowsRepo as any,
checkService as any,
networkService as any,
bufferStub as any,
new MonitorStatusPolicy(),
statusService as any,
notificationsService as any,
logger
);
const geoCheckPipeline = new GeoChecksPipeline(maintenanceWindowsRepo as any, geoChecksService as any, bufferStub as any, logger);
const notificationReactor = new NotificationReactor(notificationsService as any);
const incidentReactor = new IncidentReactor(incidentService as any);
const reactorDispatcher = new ReactorDispatcher(logger, [notificationReactor, incidentReactor]);
const helper = new WorkerHelper(
logger,
checkService as any,
{ getDBSettings: jest.fn() } as any,
bufferStub as any,
incidentService as any,
maintenanceWindowsRepo as any,
monitorsRepo as any,
{ findAllTeamIds: jest.fn() } as any,
createStubMonitorStatsRepo() as any,
createStubChecksRepo() as any,
incidentsRepo as any,
{ buildGeoCheck: jest.fn() } as any,
{ deleteByMonitorIdsNotIn: jest.fn() } as any
{ deleteByMonitorIdsNotIn: jest.fn() } as any,
reactorDispatcher,
checkPipeline,
geoCheckPipeline
);
return {
@@ -6,7 +6,7 @@ import type { IMonitorsRepository } from "../../src/domain/monitors/monitor.repo
import type { IUsersRepository } from "../../src/domain/users/user.repository.interface.ts";
import type { INotificationMessageBuilder } from "../../src/domain/notifications/notification.message-builder.ts";
import type { Monitor } from "../../src/domain/monitors/monitor.types.ts";
import type { MonitorActionDecision } from "../../src/service/job-queues/job-queue.helper.ts";
import type { MonitorActionDecision } from "../../src/worker/worker.helper.ts";
// ── Helpers ──────────────────────────────────────────────────────────────────
@@ -0,0 +1,38 @@
import { describe, expect, it, jest } from "@jest/globals";
import type { NextFunction, Request, Response } from "express";
import { MulterError } from "multer";
import { handleErrors } from "../../../src/api/middleware/handleErrors.ts";
import { AppError } from "../../../src/utils/AppError.ts";
const makeRes = (): Response => {
const res = {} as Response;
res.status = jest.fn().mockReturnValue(res) as unknown as Response["status"];
res.json = jest.fn().mockReturnValue(res) as unknown as Response["json"];
return res;
};
describe("handleErrors", () => {
it("maps a multer file-size violation to 413 Payload Too Large", () => {
const res = makeRes();
handleErrors(new MulterError("LIMIT_FILE_SIZE", "profileImage"), {} as Request, res, (() => {}) as NextFunction);
expect(res.status).toHaveBeenCalledWith(413);
});
it("maps other multer errors to 400", () => {
const res = makeRes();
handleErrors(new MulterError("LIMIT_UNEXPECTED_FILE", "profileImage"), {} as Request, res, (() => {}) as NextFunction);
expect(res.status).toHaveBeenCalledWith(400);
});
it("preserves AppError status codes", () => {
const res = makeRes();
handleErrors(new AppError({ status: 415, message: "bad image" }), {} as Request, res, (() => {}) as NextFunction);
expect(res.status).toHaveBeenCalledWith(415);
});
it("falls back to 500 for unknown errors", () => {
const res = makeRes();
handleErrors(new Error("boom"), {} as Request, res, (() => {}) as NextFunction);
expect(res.status).toHaveBeenCalledWith(500);
});
});
@@ -0,0 +1,74 @@
import { describe, expect, it, jest, beforeEach } from "@jest/globals";
import { createStatusPageCorsOrigin } from "../../../src/api/middleware/statusPageCorsOrigin.ts";
import type { IStatusPagesRepository } from "../../../src/domain/status-pages/status-page-repository.interface.ts";
import type { StatusPage } from "../../../src/domain/status-pages/status-page.type.ts";
const makeStatusPage = (overrides?: Partial<StatusPage>): StatusPage =>
({
id: "sp-1",
isPublished: true,
customDomain: "status.example.com",
...overrides,
}) as StatusPage;
const createRepo = () =>
({
findByCustomDomain: jest.fn().mockResolvedValue(makeStatusPage()),
}) as unknown as jest.Mocked<Pick<IStatusPagesRepository, "findByCustomDomain">>;
const invokeOrigin = (originFn: ReturnType<typeof createStatusPageCorsOrigin>, origin: string): Promise<boolean | string> =>
new Promise((resolve, reject) => {
originFn?.(origin, (error, result) => {
if (error) {
reject(error);
return;
}
resolve(result ?? false);
});
});
describe("createStatusPageCorsOrigin", () => {
let repo: jest.Mocked<Pick<IStatusPagesRepository, "findByCustomDomain">>;
beforeEach(() => {
repo = createRepo();
});
it("allows the configured client host without a repository lookup", async () => {
const originFn = createStatusPageCorsOrigin("https://checkmate.example.com", repo as IStatusPagesRepository);
await expect(invokeOrigin(originFn, "https://checkmate.example.com")).resolves.toBe(true);
expect(repo.findByCustomDomain).not.toHaveBeenCalled();
});
it("caches published custom domain lookups", async () => {
const originFn = createStatusPageCorsOrigin("https://checkmate.example.com", repo as IStatusPagesRepository);
await expect(invokeOrigin(originFn, "https://status.example.com")).resolves.toBe("https://status.example.com");
await expect(invokeOrigin(originFn, "https://status.example.com")).resolves.toBe("https://status.example.com");
expect(repo.findByCustomDomain).toHaveBeenCalledTimes(1);
expect(repo.findByCustomDomain).toHaveBeenCalledWith("status.example.com");
});
it("rejects unpublished custom domains", async () => {
repo.findByCustomDomain.mockResolvedValue(makeStatusPage({ isPublished: false }));
const originFn = createStatusPageCorsOrigin("https://checkmate.example.com", repo as IStatusPagesRepository);
await expect(invokeOrigin(originFn, "https://status.example.com")).resolves.toBe(false);
await expect(invokeOrigin(originFn, "https://status.example.com")).resolves.toBe(false);
expect(repo.findByCustomDomain).toHaveBeenCalledTimes(1);
});
it("caches unknown custom domain lookups", async () => {
repo.findByCustomDomain.mockRejectedValue(new Error("Status page not found"));
const originFn = createStatusPageCorsOrigin("https://checkmate.example.com", repo as IStatusPagesRepository);
await expect(invokeOrigin(originFn, "https://unknown.example.com")).resolves.toBe(false);
await expect(invokeOrigin(originFn, "https://unknown.example.com")).resolves.toBe(false);
expect(repo.findByCustomDomain).toHaveBeenCalledTimes(1);
expect(repo.findByCustomDomain).toHaveBeenCalledWith("unknown.example.com");
});
});
@@ -0,0 +1,19 @@
import { describe, expect, it } from "@jest/globals";
import { isPublicStatusPageApiPath } from "../../../src/api/middleware/statusPagePublicApiPath.ts";
describe("isPublicStatusPageApiPath", () => {
it("allows public resolve and slug routes", () => {
expect(isPublicStatusPageApiPath("GET", "/api/v1/status-page/resolve")).toBe(true);
expect(isPublicStatusPageApiPath("OPTIONS", "/api/v1/status-page/resolve")).toBe(true);
expect(isPublicStatusPageApiPath("GET", "/api/v1/status-page/my-status-page")).toBe(true);
expect(isPublicStatusPageApiPath("GET", "/api/v1/status-page/my-status-page?type=uptime")).toBe(true);
});
it("rejects authenticated and mutating status page routes", () => {
expect(isPublicStatusPageApiPath("GET", "/api/v1/status-page/team")).toBe(false);
expect(isPublicStatusPageApiPath("POST", "/api/v1/status-page")).toBe(false);
expect(isPublicStatusPageApiPath("PUT", "/api/v1/status-page/64b1f2a3c4d5e6f7a8b9c0d1")).toBe(false);
expect(isPublicStatusPageApiPath("DELETE", "/api/v1/status-page/64b1f2a3c4d5e6f7a8b9c0d1")).toBe(false);
expect(isPublicStatusPageApiPath("GET", "/api/v1/monitors")).toBe(false);
});
});
@@ -0,0 +1,41 @@
import { describe, expect, it } from "@jest/globals";
import type { Request } from "express";
import { imageUpload } from "../../../src/api/middleware/upload.ts";
import { MAX_IMAGE_SIZE_BYTES } from "../../../src/types/upload.ts";
import { AppError } from "../../../src/utils/AppError.ts";
// Reaches into the multer instance config to exercise the fileFilter/limits in isolation.
// Guards against regressing GHSA-9xvg-x28f-m78m (pre-auth DoS via unbounded uploads).
const config = imageUpload as unknown as { fileFilter: Function; limits: { fileSize: number; files: number } };
const makeFile = (mimetype: string): Express.Multer.File => ({ fieldname: "profileImage", originalname: "x", mimetype }) as Express.Multer.File;
const runFilter = (mimetype: string): Promise<{ error: unknown; accepted: unknown }> =>
new Promise((resolve) => {
config.fileFilter({} as Request, makeFile(mimetype), (error: unknown, accepted: unknown) => resolve({ error, accepted }));
});
describe("imageUpload middleware", () => {
it("enforces a file size limit so large uploads cannot exhaust memory", () => {
expect(config.limits.fileSize).toBe(MAX_IMAGE_SIZE_BYTES);
expect(config.limits.fileSize).toBeLessThanOrEqual(3 * 1024 * 1024);
});
it("limits a request to a single file", () => {
expect(config.limits.files).toBe(1);
});
it("accepts allowed image mime types", async () => {
for (const mimetype of ["image/jpeg", "image/png", "image/jpg"]) {
const { error, accepted } = await runFilter(mimetype);
expect(error).toBeNull();
expect(accepted).toBe(true);
}
});
it("rejects non-image uploads with a 415 AppError", async () => {
const { error } = await runFilter("application/octet-stream");
expect(error).toBeInstanceOf(AppError);
expect((error as AppError).status).toBe(415);
});
});
@@ -0,0 +1,217 @@
import { describe, expect, it, jest } from "@jest/globals";
import { CheckPipeline, GeoChecksPipeline } from "../../../src/worker/worker.check-pipeline.ts";
import { MonitorStatusPolicy } from "../../../src/worker/worker.monitor-status-policy.ts";
import type { Monitor } from "../../../src/domain/monitors/monitor.types.ts";
import { createMockLogger } from "../../helpers/createMockLogger.ts";
const makeMonitor = (overrides?: Partial<Monitor>): Monitor =>
({
id: "m1",
teamId: "team",
type: "http",
interval: 60000,
status: "up",
geoCheckEnabled: false,
geoCheckLocations: [],
...overrides,
}) as Monitor;
const activeWindow = () => {
const now = Date.now();
return { active: true, start: new Date(now - 1000).toISOString(), end: new Date(now + 1000).toISOString(), repeat: 0 };
};
// ── CheckPipeline ────────────────────────────────────────────────────────────
const createCheckPipeline = (overrides?: Record<string, any>) => {
const defaults = {
logger: createMockLogger(),
networkService: { requestStatus: jest.fn().mockResolvedValue({ monitorId: "m1", status: true, code: 200, message: "OK" }) },
statusService: {
updateMonitorStatus: jest.fn().mockResolvedValue({ monitor: { id: "m1", status: "up" }, statusChanged: false, prevStatus: "up", code: 200 }),
},
checkService: { buildCheck: jest.fn().mockReturnValue({ id: "check-1" }) },
buffer: { addToBuffer: jest.fn() },
monitorsRepository: { updateById: jest.fn().mockResolvedValue({}) },
maintenanceWindowsRepository: { findByMonitorId: jest.fn().mockResolvedValue([]) },
monitorStatusPolicy: new MonitorStatusPolicy(),
...overrides,
};
const pipeline = new CheckPipeline(
defaults.monitorsRepository as any,
defaults.maintenanceWindowsRepository as any,
defaults.checkService as any,
defaults.networkService as any,
defaults.buffer as any,
defaults.monitorStatusPolicy as any,
defaults.statusService as any,
defaults.logger as any
);
return { pipeline, defaults };
};
describe("CheckPipeline", () => {
it("throws when monitor id is missing", async () => {
const { pipeline } = createCheckPipeline();
await expect(pipeline.run({} as Monitor)).rejects.toThrow("No monitor id");
});
it("skips and sets maintenance status when in maintenance window", async () => {
const { pipeline, defaults } = createCheckPipeline({
maintenanceWindowsRepository: { findByMonitorId: jest.fn().mockResolvedValue([activeWindow()]) },
});
const result = await pipeline.run(makeMonitor({ status: "up" }));
expect(result).toBeNull();
expect(defaults.networkService.requestStatus).not.toHaveBeenCalled();
expect(defaults.monitorsRepository.updateById).toHaveBeenCalledWith("m1", "team", { status: "maintenance" });
});
it("does not re-write status when already in maintenance", async () => {
const { pipeline, defaults } = createCheckPipeline({
maintenanceWindowsRepository: { findByMonitorId: jest.fn().mockResolvedValue([activeWindow()]) },
});
await pipeline.run(makeMonitor({ status: "maintenance" }));
expect(defaults.monitorsRepository.updateById).not.toHaveBeenCalled();
});
it("throws when network response is null", async () => {
const { pipeline } = createCheckPipeline({
networkService: { requestStatus: jest.fn().mockResolvedValue(null) },
});
await expect(pipeline.run(makeMonitor())).rejects.toThrow("No network response");
});
it("returns null and warns when buildCheck returns null", async () => {
const { pipeline, defaults } = createCheckPipeline({
checkService: { buildCheck: jest.fn().mockReturnValue(null) },
});
const result = await pipeline.run(makeMonitor());
expect(result).toBeNull();
expect(defaults.logger.warn).toHaveBeenCalledWith(expect.objectContaining({ message: expect.stringContaining("No check could be built") }));
expect(defaults.statusService.updateMonitorStatus).not.toHaveBeenCalled();
});
it("buffers the check, updates status, and returns an evaluation", async () => {
const status = { monitorId: "m1", status: false, code: 500, message: "Error" };
const statusChange = { monitor: { id: "m1", status: "down" }, statusChanged: true, prevStatus: "up", code: 500 };
const { pipeline, defaults } = createCheckPipeline({
networkService: { requestStatus: jest.fn().mockResolvedValue(status) },
statusService: { updateMonitorStatus: jest.fn().mockResolvedValue(statusChange) },
});
const result = await pipeline.run(makeMonitor());
expect(defaults.buffer.addToBuffer).toHaveBeenCalledWith({ id: "check-1" });
expect(defaults.statusService.updateMonitorStatus).toHaveBeenCalled();
expect(result).toMatchObject({
monitor: statusChange.monitor,
status,
check: { id: "check-1" },
statusChange,
decision: expect.objectContaining({ shouldCreateIncident: true, shouldSendNotification: true, incidentReason: "status_down" }),
});
});
it("proceeds with the check when no maintenance window is active", async () => {
const { pipeline, defaults } = createCheckPipeline({
maintenanceWindowsRepository: { findByMonitorId: jest.fn().mockResolvedValue([{ ...activeWindow(), active: false }]) },
});
await pipeline.run(makeMonitor());
expect(defaults.networkService.requestStatus).toHaveBeenCalled();
});
});
// ── GeoChecksPipeline ──────────────────────────────────────────────────────────
const createGeoPipeline = (overrides?: Record<string, any>) => {
const defaults = {
logger: createMockLogger(),
geoChecksService: { buildGeoCheck: jest.fn().mockResolvedValue(null) },
buffer: { addGeoCheckToBuffer: jest.fn() },
maintenanceWindowsRepository: { findByMonitorId: jest.fn().mockResolvedValue([]) },
...overrides,
};
const pipeline = new GeoChecksPipeline(
defaults.maintenanceWindowsRepository as any,
defaults.geoChecksService as any,
defaults.buffer as any,
defaults.logger as any
);
return { pipeline, defaults };
};
const geoMonitor = (overrides?: Partial<Monitor>) =>
makeMonitor({ geoCheckEnabled: true, type: "http", geoCheckLocations: ["us-east"], ...overrides });
describe("GeoChecksPipeline", () => {
it("throws when monitor id is missing", async () => {
const { pipeline } = createGeoPipeline();
await expect(pipeline.run({} as Monitor)).rejects.toThrow("No monitor id");
});
it("returns null when geoCheckEnabled is false", async () => {
const { pipeline, defaults } = createGeoPipeline();
const result = await pipeline.run(makeMonitor({ geoCheckEnabled: false }));
expect(result).toBeNull();
expect(defaults.geoChecksService.buildGeoCheck).not.toHaveBeenCalled();
});
it("returns null when monitor type does not support geo checks", async () => {
const { pipeline, defaults } = createGeoPipeline();
await pipeline.run(geoMonitor({ type: "hardware" }));
expect(defaults.logger.debug).toHaveBeenCalledWith(expect.objectContaining({ message: expect.stringContaining("does not support geo checks") }));
expect(defaults.geoChecksService.buildGeoCheck).not.toHaveBeenCalled();
});
it("warns when geoCheckLocations is empty", async () => {
const { pipeline, defaults } = createGeoPipeline();
await pipeline.run(geoMonitor({ geoCheckLocations: [] }));
expect(defaults.logger.warn).toHaveBeenCalledWith(expect.objectContaining({ message: expect.stringContaining("No geo check locations") }));
});
it("warns when geoCheckLocations is undefined", async () => {
const { pipeline, defaults } = createGeoPipeline();
await pipeline.run(geoMonitor({ geoCheckLocations: undefined as any }));
expect(defaults.logger.warn).toHaveBeenCalledWith(expect.objectContaining({ message: expect.stringContaining("No geo check locations") }));
});
it("skips when in maintenance window", async () => {
const { pipeline, defaults } = createGeoPipeline({
maintenanceWindowsRepository: { findByMonitorId: jest.fn().mockResolvedValue([activeWindow()]) },
});
await pipeline.run(geoMonitor());
expect(defaults.logger.debug).toHaveBeenCalledWith(expect.objectContaining({ message: expect.stringContaining("maintenance window") }));
expect(defaults.geoChecksService.buildGeoCheck).not.toHaveBeenCalled();
});
it("warns when buildGeoCheck returns null", async () => {
const { pipeline, defaults } = createGeoPipeline();
await pipeline.run(geoMonitor());
expect(defaults.logger.warn).toHaveBeenCalledWith(expect.objectContaining({ message: expect.stringContaining("No geo check could be built") }));
});
it("adds geo check to buffer on success", async () => {
const geoCheck = { id: "gc-1", monitorId: "m1" };
const { pipeline, defaults } = createGeoPipeline({
geoChecksService: { buildGeoCheck: jest.fn().mockResolvedValue(geoCheck) },
});
await pipeline.run(geoMonitor());
expect(defaults.buffer.addGeoCheckToBuffer).toHaveBeenCalledWith(geoCheck);
expect(defaults.logger.debug).toHaveBeenCalledWith(expect.objectContaining({ message: expect.stringContaining("Geo check job executed") }));
});
it("propagates errors (the job glue handles isolation)", async () => {
const { pipeline } = createGeoPipeline({
geoChecksService: { buildGeoCheck: jest.fn().mockRejectedValue(new Error("api timeout")) },
});
await expect(pipeline.run(geoMonitor())).rejects.toThrow("api timeout");
});
});
@@ -7,7 +7,7 @@ import type { IUsersRepository } from "../../../src/domain/users/user.repository
import type { INotificationMessageBuilder } from "../../../src/domain/notifications/notification.message-builder.ts";
import type { Monitor } from "../../../src/domain/monitors/monitor.types.ts";
import type { Incident } from "../../../src/domain/incidents/incident.type.ts";
import type { MonitorActionDecision } from "../../../src/service/job-queues/job-queue.helper.ts";
import type { MonitorActionDecision } from "../../../src/worker/worker.helper.ts";
// ── Helpers ──────────────────────────────────────────────────────────────────
@@ -44,7 +44,7 @@ jest.unstable_mockModule("less-simple-scheduler", () => ({
MongoStore: MockMongoStore,
}));
const { LessSimpleQueue } = await import("../../../src/service/job-queues/job-queue.less-simple.ts");
const { LessSimpleWorker: LessSimpleQueue } = await import("../../../src/worker/worker.less-simple.ts");
const createQueueHelper = () => ({
getHeartbeatJob: jest.fn().mockReturnValue(() => Promise.resolve()),
@@ -98,7 +98,7 @@ const createService = (
const jobQueue = overrides.jobQueue ?? createJobQueueMock();
const logger = overrides.logger ?? createMockLogger();
const service = new MonitorService({
jobQueue: jobQueue as any,
worker: jobQueue as any,
logger: logger as any,
games: (overrides.games ?? { cs2: { name: "Counter-Strike 2" } }) as any,
monitorsRepository: overrides.monitorsRepository ?? createMonitorsRepositoryMock(),
@@ -1535,7 +1535,7 @@ describe("MonitorService", () => {
const logger = createMockLogger();
const service = new MonitorService({
jobQueue: jobQueue as any,
worker: jobQueue as any,
logger: logger as any,
games: {} as any,
monitorsRepository,
@@ -0,0 +1,74 @@
import { describe, expect, it } from "@jest/globals";
import { MonitorStatusPolicy } from "../../../src/worker/worker.monitor-status-policy.ts";
import type { StatusChangeResult } from "../../../src/types/network.ts";
// ── Helpers ──────────────────────────────────────────────────────────────────
const makeStatusChange = (overrides: Partial<StatusChangeResult> & { status?: string }): StatusChangeResult => {
const { status = "up", ...rest } = overrides;
return {
monitor: { id: "m1", status } as any,
statusChanged: false,
prevStatus: "up",
code: 200,
timestamp: 0,
...rest,
} as StatusChangeResult;
};
// ── Tests ────────────────────────────────────────────────────────────────────
describe("MonitorStatusPolicy", () => {
const policy = new MonitorStatusPolicy();
it("does nothing when statusChanged is false", () => {
const decision = policy.evaluate(makeStatusChange({ status: "down", statusChanged: false, prevStatus: "up" }));
expect(decision).toEqual({
shouldCreateIncident: false,
shouldResolveIncident: false,
shouldSendNotification: false,
incidentReason: null,
notificationReason: null,
});
});
it("creates incident and notifies when monitor goes down", () => {
const decision = policy.evaluate(makeStatusChange({ status: "down", statusChanged: true, prevStatus: "up", code: 500 }));
expect(decision).toMatchObject({
shouldCreateIncident: true,
shouldSendNotification: true,
incidentReason: "status_down",
notificationReason: "status_change",
});
});
it("creates incident and notifies when monitor is breached", () => {
const decision = policy.evaluate(makeStatusChange({ status: "breached", statusChanged: true, prevStatus: "up" }));
expect(decision).toMatchObject({
shouldCreateIncident: true,
shouldSendNotification: true,
incidentReason: "threshold_breach",
notificationReason: "threshold_breach",
});
});
it("resolves incident when monitor recovers from down", () => {
const decision = policy.evaluate(makeStatusChange({ status: "up", statusChanged: true, prevStatus: "down" }));
expect(decision).toMatchObject({
shouldResolveIncident: true,
shouldSendNotification: true,
shouldCreateIncident: false,
notificationReason: "status_change",
});
});
it("resolves incident when monitor recovers from breached", () => {
const decision = policy.evaluate(makeStatusChange({ status: "up", statusChanged: true, prevStatus: "breached" }));
expect(decision).toMatchObject({ shouldResolveIncident: true, shouldSendNotification: true });
});
it("does not create or resolve for unhandled status transitions", () => {
const decision = policy.evaluate(makeStatusChange({ status: "paused", statusChanged: true, prevStatus: "up" }));
expect(decision).toMatchObject({ shouldCreateIncident: false, shouldResolveIncident: false, shouldSendNotification: false });
});
});
@@ -2,7 +2,7 @@ import { describe, expect, it, beforeEach } from "@jest/globals";
import { NotificationMessageBuilder } from "../../../src/domain/notifications/notification.message-builder.ts";
import type { Monitor } from "../../../src/domain/monitors/monitor.types.ts";
import type { MonitorStatusResponse, HardwareStatusPayload } from "../../../src/types/network.ts";
import type { MonitorActionDecision } from "../../../src/service/job-queues/job-queue.helper.ts";
import type { MonitorActionDecision } from "../../../src/worker/worker.helper.ts";
// ── Helpers ──────────────────────────────────────────────────────────────────
@@ -4,7 +4,7 @@ import { createMockLogger } from "../../helpers/createMockLogger.ts";
import type { Monitor } from "../../../src/domain/monitors/monitor.types.ts";
import type { Notification } from "../../../src/domain/notifications/notification.type.ts";
import type { MonitorStatusResponse } from "../../../src/types/network.ts";
import type { MonitorActionDecision } from "../../../src/service/job-queues/job-queue.helper.ts";
import type { MonitorActionDecision } from "../../../src/worker/worker.helper.ts";
// ── Helpers ──────────────────────────────────────────────────────────────────
@@ -0,0 +1,87 @@
import { describe, expect, it, jest } from "@jest/globals";
import { ReactorDispatcher } from "../../../src/worker/reactors/reactor.dispatcher.ts";
import { createMockLogger } from "../../helpers/createMockLogger.ts";
const evaluation = { monitor: { id: "m1" } } as any;
const makeReactor = (name: string, blocking: boolean, impl?: () => Promise<void>) => ({
name,
blocking,
react: jest.fn(impl ?? (() => Promise.resolve())),
});
describe("ReactorDispatcher", () => {
it("runs every reactor with the evaluation", async () => {
const a = makeReactor("a", true);
const b = makeReactor("b", false);
const dispatcher = new ReactorDispatcher(createMockLogger() as any, [a, b] as any);
await dispatcher.dispatch(evaluation);
expect(a.react).toHaveBeenCalledWith(evaluation);
expect(b.react).toHaveBeenCalledWith(evaluation);
});
it("awaits blocking reactors before resolving", async () => {
let done = false;
const blocking = makeReactor(
"incident",
true,
() =>
new Promise((r) =>
setTimeout(() => {
done = true;
r();
}, 10)
)
);
const dispatcher = new ReactorDispatcher(createMockLogger() as any, [blocking] as any);
await dispatcher.dispatch(evaluation);
expect(done).toBe(true);
});
it("does not await non-blocking reactors", async () => {
let done = false;
const nonBlocking = makeReactor(
"notification",
false,
() =>
new Promise((r) =>
setTimeout(() => {
done = true;
r();
}, 20)
)
);
const dispatcher = new ReactorDispatcher(createMockLogger() as any, [nonBlocking] as any);
await dispatcher.dispatch(evaluation);
expect(done).toBe(false); // resolved before the slow fire-and-forget reactor finished
});
it("isolates a throwing blocking reactor and still runs later reactors", async () => {
const logger = createMockLogger();
const bad = makeReactor("incident", true, () => Promise.reject(new Error("db error")));
const good = makeReactor("after", true);
const dispatcher = new ReactorDispatcher(logger as any, [bad, good] as any);
await expect(dispatcher.dispatch(evaluation)).resolves.toBeUndefined();
expect(good.react).toHaveBeenCalled();
expect(logger.error).toHaveBeenCalledWith(expect.objectContaining({ message: expect.stringContaining("db error") }));
});
it("isolates a rejecting non-blocking reactor (fire-and-forget)", async () => {
const logger = createMockLogger();
const bad = makeReactor("notification", false, () => Promise.reject(new Error("smtp down")));
const dispatcher = new ReactorDispatcher(logger as any, [bad] as any);
await dispatcher.dispatch(evaluation);
await new Promise((r) => setTimeout(r, 10)); // let the detached catch run
expect(logger.error).toHaveBeenCalledWith(expect.objectContaining({ message: expect.stringContaining("smtp down") }));
});
});
@@ -26,20 +26,22 @@ const createRepo = () =>
({
create: jest.fn().mockResolvedValue(makeStatusPage()),
findByUrl: jest.fn().mockResolvedValue(makeStatusPage()),
findByCustomDomain: jest.fn().mockResolvedValue(makeStatusPage()),
findByTeamId: jest.fn().mockResolvedValue([makeStatusPage()]),
updateById: jest.fn().mockResolvedValue(makeStatusPage()),
deleteById: jest.fn().mockResolvedValue(makeStatusPage()),
removeMonitorFromStatusPages: jest.fn().mockResolvedValue(1),
}) as unknown as jest.Mocked<IStatusPagesRepository>;
const createSettingsService = (themesEnabled: boolean) =>
const createSettingsService = (themesEnabled: boolean, clientHost = "http://localhost:5173") =>
({
areStatusPageThemesEnabled: jest.fn().mockReturnValue(themesEnabled),
getSettings: jest.fn().mockReturnValue({ clientHost }),
}) as unknown as jest.Mocked<ISettingsService>;
const createService = (themesEnabled = true) => {
const createService = (themesEnabled = true, clientHost = "http://localhost:5173") => {
const repo = createRepo();
const settingsService = createSettingsService(themesEnabled);
const settingsService = createSettingsService(themesEnabled, clientHost);
const service = new StatusPageService(repo, settingsService);
return { service, repo, settingsService };
};
@@ -99,6 +101,50 @@ describe("StatusPageService", () => {
});
});
describe("getStatusPageByCustomDomain", () => {
it("delegates to repository", async () => {
const { service, repo } = createService(true);
const result = await service.getStatusPageByCustomDomain("status.example.com");
expect(repo.findByCustomDomain).toHaveBeenCalledWith("status.example.com");
expect(result).toEqual(makeStatusPage());
});
});
describe("createStatusPage custom domain validation", () => {
it("rejects custom domains that match the Checkmate instance host", async () => {
const { service } = createService(true, "https://checkmate.example.com");
await expect(
service.createStatusPage("user-1", "team-1", undefined, {
customDomain: "checkmate.example.com",
})
).rejects.toMatchObject({
status: 400,
});
});
it("normalizes custom domains before create", async () => {
const { service, repo } = createService(true);
await service.createStatusPage("user-1", "team-1", undefined, {
customDomain: "https://Status.Example.COM",
});
expect(repo.create).toHaveBeenCalledWith("user-1", "team-1", undefined, expect.objectContaining({ customDomain: "status.example.com" }));
});
it("allows multiple status pages without a custom domain", async () => {
const { service, repo } = createService(true);
await service.createStatusPage("user-1", "team-1", undefined, { companyName: "First" });
await service.createStatusPage("user-1", "team-1", undefined, { companyName: "Second" });
expect(repo.create).toHaveBeenCalledTimes(2);
});
});
describe("getStatusPagesByTeamId", () => {
it("delegates to repository", async () => {
const { service, repo } = createService(true);
@@ -35,7 +35,7 @@ jest.unstable_mockModule("super-simple-scheduler", () => ({
default: MockScheduler,
}));
const { SuperSimpleQueue } = await import("../../../src/service/job-queues/job-queue.sumper-simple.ts");
const { SuperSimpleQueue } = await import("../../../src/worker/worker.super-simple.ts");
const createQueueHelper = () => ({
getHeartbeatJob: jest.fn().mockReturnValue(() => Promise.resolve()),
@@ -1,14 +1,17 @@
import { describe, expect, it, jest } from "@jest/globals";
import { QueueHelper } from "../../../src/service/job-queues/job-queue.helper.ts";
import { WorkerHelper } from "../../../src/worker/worker.helper.ts";
import type { Monitor } from "../../../src/domain/monitors/monitor.types.ts";
import { createMockLogger } from "../../helpers/createMockLogger.ts";
// ── Helpers ──────────────────────────────────────────────────────────────────
//
// WorkerHelper is now thin glue: it wires the scheduler's job functions to the
// CheckPipeline / GeoChecksPipeline (acquire→record→decide) and the
// ReactorDispatcher (react), and owns the cleanup jobs. The pipeline and
// dispatcher internals are tested in checkPipeline.test.ts / reactorDispatcher.test.ts;
// here we test only the glue + cleanup jobs.
const createHelper = (overrides?: Record<string, unknown>) => {
const maintenanceWindowsRepository = {
findByMonitorId: jest.fn().mockResolvedValue([]),
};
const monitorsRepository = {
updateById: jest.fn().mockResolvedValue({}),
findAllMonitorIds: jest.fn().mockResolvedValue(["m1"]),
@@ -29,55 +32,48 @@ const createHelper = (overrides?: Record<string, unknown>) => {
const geoChecksRepository = {
deleteByMonitorIdsNotIn: jest.fn().mockResolvedValue(0),
};
const statusServiceMock = {
updateMonitorStatus: jest.fn().mockResolvedValue({ monitor: { id: "m1", status: "up" }, statusChanged: false, prevStatus: "up", code: 200 }),
};
const settingsServiceMock = {
const settingsService = {
getDBSettings: jest.fn().mockResolvedValue({ checkTTL: 30 }),
};
const geoChecksServiceMock = {
buildGeoCheck: jest.fn().mockResolvedValue(null),
const checkService = {
deleteOlderThan: jest.fn().mockResolvedValue(0),
};
const checkPipeline = { run: jest.fn().mockResolvedValue(null) };
const geoCheckPipeline = { run: jest.fn().mockResolvedValue(null) };
const reactorDispatcher = { dispatch: jest.fn().mockResolvedValue(undefined) };
const defaults = {
logger: createMockLogger(),
networkService: { requestStatus: jest.fn() },
statusService: statusServiceMock,
notificationsService: { handleNotifications: jest.fn().mockResolvedValue(undefined) },
checkService: { buildCheck: jest.fn().mockReturnValue({ id: "check-1" }), deleteOlderThan: jest.fn().mockResolvedValue(0) },
settingsService: settingsServiceMock,
buffer: { addToBuffer: jest.fn(), addGeoCheckToBuffer: jest.fn() },
incidentService: { handleIncident: jest.fn().mockResolvedValue(undefined) },
maintenanceWindowsRepository,
checkService,
settingsService,
monitorsRepository,
teamsRepository,
monitorStatsRepository,
checksRepository,
incidentsRepository,
geoChecksService: geoChecksServiceMock,
geoChecksRepository,
reactorDispatcher,
checkPipeline,
geoCheckPipeline,
...overrides,
};
const helper = new QueueHelper(
const helper = new WorkerHelper(
defaults.logger as any,
defaults.networkService as any,
defaults.statusService as any,
defaults.notificationsService as any,
defaults.checkService as any,
defaults.settingsService as any,
defaults.buffer as any,
defaults.incidentService as any,
defaults.maintenanceWindowsRepository as any,
defaults.monitorsRepository as any,
defaults.teamsRepository as any,
defaults.monitorStatsRepository as any,
defaults.checksRepository as any,
defaults.incidentsRepository as any,
defaults.geoChecksService as any,
defaults.geoChecksRepository as any
defaults.geoChecksRepository as any,
defaults.reactorDispatcher as any,
defaults.checkPipeline as any,
defaults.geoCheckPipeline as any
);
return { helper, defaults, maintenanceWindowsRepository };
return { helper, defaults };
};
const makeMonitor = (overrides?: Partial<Monitor>): Monitor =>
@@ -94,7 +90,7 @@ const makeMonitor = (overrides?: Partial<Monitor>): Monitor =>
// ── Tests ────────────────────────────────────────────────────────────────────
describe("QueueHelper", () => {
describe("WorkerHelper", () => {
describe("serviceName", () => {
it("returns JobQueueHelper", () => {
const { helper } = createHelper();
@@ -102,278 +98,63 @@ describe("QueueHelper", () => {
});
});
// ── getHeartbeatJob ──────────────────────────────────────────────────────
// ── getHeartbeatJob (glue) ────────────────────────────────────────────────
describe("getHeartbeatJob", () => {
it("throws when monitor id is missing", async () => {
const { helper } = createHelper();
const job = helper.getHeartbeatJob();
await expect(job({} as Monitor)).rejects.toThrow("No monitor id");
it("runs the pipeline and dispatches the evaluation to reactors", async () => {
const evaluation = { monitor: { id: "m1" }, decision: {} };
const { helper, defaults } = createHelper({ checkPipeline: { run: jest.fn().mockResolvedValue(evaluation) } });
await helper.getHeartbeatJob()(makeMonitor());
expect(defaults.checkPipeline.run).toHaveBeenCalled();
expect(defaults.reactorDispatcher.dispatch).toHaveBeenCalledWith(evaluation);
});
it("skips execution and sets maintenance status when in maintenance window", async () => {
const { helper, defaults } = createHelper();
jest.spyOn(helper, "isInMaintenanceWindow").mockResolvedValue(true);
const job = helper.getHeartbeatJob();
it("does not dispatch when the pipeline returns null (skipped)", async () => {
const { helper, defaults } = createHelper({ checkPipeline: { run: jest.fn().mockResolvedValue(null) } });
await job(makeMonitor({ status: "up" }));
await helper.getHeartbeatJob()(makeMonitor());
expect(defaults.networkService.requestStatus).not.toHaveBeenCalled();
expect(defaults.monitorsRepository.updateById).toHaveBeenCalledWith("m1", "team", { status: "maintenance" });
expect(defaults.reactorDispatcher.dispatch).not.toHaveBeenCalled();
});
it("skips monitor status update when already in maintenance", async () => {
const { helper, defaults } = createHelper();
jest.spyOn(helper, "isInMaintenanceWindow").mockResolvedValue(true);
const job = helper.getHeartbeatJob();
it("logs and rethrows when the pipeline throws", async () => {
const { helper, defaults } = createHelper({ checkPipeline: { run: jest.fn().mockRejectedValue(new Error("boom")) } });
await job(makeMonitor({ status: "maintenance" }));
expect(defaults.monitorsRepository.updateById).not.toHaveBeenCalled();
await expect(helper.getHeartbeatJob()(makeMonitor())).rejects.toThrow("boom");
expect(defaults.logger.warn).toHaveBeenCalledWith(expect.objectContaining({ message: "boom" }));
});
it("throws when network response is null", async () => {
const { helper } = createHelper({
networkService: { requestStatus: jest.fn().mockResolvedValue(null) },
});
jest.spyOn(helper, "isInMaintenanceWindow").mockResolvedValue(false);
const job = helper.getHeartbeatJob();
it("rethrows non-Error failures", async () => {
const { helper } = createHelper({ checkPipeline: { run: jest.fn().mockRejectedValue("unexpected") } });
await expect(job(makeMonitor())).rejects.toThrow("No network response");
});
it("returns early and warns when buildCheck returns null", async () => {
const networkResponse = { monitorId: "m1", status: true, code: 200, message: "OK" };
const { helper, defaults } = createHelper({
networkService: { requestStatus: jest.fn().mockResolvedValue(networkResponse) },
checkService: { buildCheck: jest.fn().mockReturnValue(null), deleteOlderThan: jest.fn() },
});
jest.spyOn(helper, "isInMaintenanceWindow").mockResolvedValue(false);
const job = helper.getHeartbeatJob();
await job(makeMonitor());
expect(defaults.logger.warn).toHaveBeenCalledWith(expect.objectContaining({ message: expect.stringContaining("No check could be built") }));
expect(defaults.statusService.updateMonitorStatus).not.toHaveBeenCalled();
});
it("processes full pipeline: network → check → status → incident", async () => {
const networkResponse = { monitorId: "m1", status: true, code: 200, message: "OK" };
const { helper, defaults } = createHelper({
networkService: { requestStatus: jest.fn().mockResolvedValue(networkResponse) },
});
jest.spyOn(helper, "isInMaintenanceWindow").mockResolvedValue(false);
const job = helper.getHeartbeatJob();
await job(makeMonitor());
expect(defaults.buffer.addToBuffer).toHaveBeenCalled();
expect(defaults.statusService.updateMonitorStatus).toHaveBeenCalled();
expect(defaults.incidentService.handleIncident).toHaveBeenCalled();
});
it("sends notifications when decision says shouldSendNotification", async () => {
const networkResponse = { monitorId: "m1", status: false, code: 500, message: "Error" };
const statusResult = { monitor: { id: "m1", status: "down" }, statusChanged: true, prevStatus: "up", code: 500 };
const { helper, defaults } = createHelper({
networkService: { requestStatus: jest.fn().mockResolvedValue(networkResponse) },
statusService: { updateMonitorStatus: jest.fn().mockResolvedValue(statusResult) },
});
jest.spyOn(helper, "isInMaintenanceWindow").mockResolvedValue(false);
const job = helper.getHeartbeatJob();
await job(makeMonitor());
expect(defaults.notificationsService.handleNotifications).toHaveBeenCalledWith(
statusResult.monitor,
networkResponse,
expect.objectContaining({ shouldSendNotification: true, shouldCreateIncident: true })
);
});
it("logs error when notifications fail (fire-and-forget)", async () => {
const networkResponse = { monitorId: "m1", status: false, code: 500, message: "Error" };
const statusResult = { monitor: { id: "m1", status: "down" }, statusChanged: true, prevStatus: "up", code: 500 };
const notificationsService = { handleNotifications: jest.fn().mockRejectedValue(new Error("smtp down")) };
const { helper, defaults } = createHelper({
networkService: { requestStatus: jest.fn().mockResolvedValue(networkResponse) },
statusService: { updateMonitorStatus: jest.fn().mockResolvedValue(statusResult) },
notificationsService,
});
jest.spyOn(helper, "isInMaintenanceWindow").mockResolvedValue(false);
const job = helper.getHeartbeatJob();
await job(makeMonitor());
// Wait for the fire-and-forget catch to execute
await new Promise((r) => setTimeout(r, 10));
expect(defaults.logger.error).toHaveBeenCalledWith(expect.objectContaining({ message: expect.stringContaining("smtp down") }));
});
it("logs error when notifications fail with non-Error (fire-and-forget)", async () => {
const networkResponse = { monitorId: "m1", status: false, code: 500, message: "Error" };
const statusResult = { monitor: { id: "m1", status: "down" }, statusChanged: true, prevStatus: "up", code: 500 };
const notificationsService = { handleNotifications: jest.fn().mockRejectedValue("string error") };
const { helper, defaults } = createHelper({
networkService: { requestStatus: jest.fn().mockResolvedValue(networkResponse) },
statusService: { updateMonitorStatus: jest.fn().mockResolvedValue(statusResult) },
notificationsService,
});
jest.spyOn(helper, "isInMaintenanceWindow").mockResolvedValue(false);
const job = helper.getHeartbeatJob();
await job(makeMonitor());
await new Promise((r) => setTimeout(r, 10));
expect(defaults.logger.error).toHaveBeenCalledWith(expect.objectContaining({ message: expect.stringContaining("Unknown error") }));
});
it("logs warning when incident handling fails (fire-and-forget)", async () => {
const networkResponse = { monitorId: "m1", status: true, code: 200, message: "OK" };
const incidentService = { handleIncident: jest.fn().mockRejectedValue(new Error("db error")) };
const { helper, defaults } = createHelper({
networkService: { requestStatus: jest.fn().mockResolvedValue(networkResponse) },
incidentService,
});
jest.spyOn(helper, "isInMaintenanceWindow").mockResolvedValue(false);
const job = helper.getHeartbeatJob();
await job(makeMonitor());
await new Promise((r) => setTimeout(r, 10));
expect(defaults.logger.warn).toHaveBeenCalledWith(expect.objectContaining({ message: expect.stringContaining("db error") }));
});
it("logs warning when incident handling fails with non-Error", async () => {
const networkResponse = { monitorId: "m1", status: true, code: 200, message: "OK" };
const incidentService = { handleIncident: jest.fn().mockRejectedValue(42) };
const { helper, defaults } = createHelper({
networkService: { requestStatus: jest.fn().mockResolvedValue(networkResponse) },
incidentService,
});
jest.spyOn(helper, "isInMaintenanceWindow").mockResolvedValue(false);
const job = helper.getHeartbeatJob();
await job(makeMonitor());
await new Promise((r) => setTimeout(r, 10));
expect(defaults.logger.warn).toHaveBeenCalledWith(expect.objectContaining({ message: expect.stringContaining("Unknown error") }));
});
it("logs and rethrows on unexpected error with non-Error", async () => {
const { helper } = createHelper({
networkService: { requestStatus: jest.fn().mockRejectedValue("unexpected") },
});
jest.spyOn(helper, "isInMaintenanceWindow").mockResolvedValue(false);
const job = helper.getHeartbeatJob();
await expect(job(makeMonitor())).rejects.toBe("unexpected");
await expect(helper.getHeartbeatJob()(makeMonitor())).rejects.toBe("unexpected");
});
});
// ── getHeartbeatGeoJob ───────────────────────────────────────────────────
// ── getHeartbeatGeoJob (glue) ─────────────────────────────────────────────
describe("getHeartbeatGeoJob", () => {
it("throws nothing when monitor id is missing (logs error)", async () => {
it("runs the geo pipeline", async () => {
const { helper, defaults } = createHelper();
const job = helper.getHeartbeatGeoJob();
await job({} as Monitor);
await helper.getHeartbeatGeoJob()(makeMonitor());
expect(defaults.logger.error).toHaveBeenCalledWith(expect.objectContaining({ message: "No monitor id" }));
expect(defaults.geoCheckPipeline.run).toHaveBeenCalled();
});
it("returns early when geoCheckEnabled is false", async () => {
const { helper, defaults } = createHelper();
const job = helper.getHeartbeatGeoJob();
await job(makeMonitor({ geoCheckEnabled: false }));
expect(defaults.geoChecksService.buildGeoCheck).not.toHaveBeenCalled();
});
it("returns early when monitor type does not support geo checks", async () => {
const { helper, defaults } = createHelper();
const job = helper.getHeartbeatGeoJob();
await job(makeMonitor({ geoCheckEnabled: true, type: "hardware", geoCheckLocations: ["us-east"] }));
expect(defaults.logger.debug).toHaveBeenCalledWith(
expect.objectContaining({ message: expect.stringContaining("does not support geo checks") })
);
expect(defaults.geoChecksService.buildGeoCheck).not.toHaveBeenCalled();
});
it("returns early when geoCheckLocations is empty", async () => {
const { helper, defaults } = createHelper();
const job = helper.getHeartbeatGeoJob();
await job(makeMonitor({ geoCheckEnabled: true, type: "http", geoCheckLocations: [] }));
expect(defaults.logger.warn).toHaveBeenCalledWith(expect.objectContaining({ message: expect.stringContaining("No geo check locations") }));
});
it("returns early when geoCheckLocations is undefined", async () => {
const { helper, defaults } = createHelper();
const job = helper.getHeartbeatGeoJob();
await job(makeMonitor({ geoCheckEnabled: true, type: "http", geoCheckLocations: undefined as any }));
expect(defaults.logger.warn).toHaveBeenCalledWith(expect.objectContaining({ message: expect.stringContaining("No geo check locations") }));
});
it("skips when in maintenance window", async () => {
const { helper, defaults } = createHelper();
jest.spyOn(helper, "isInMaintenanceWindow").mockResolvedValue(true);
const job = helper.getHeartbeatGeoJob();
await job(makeMonitor({ geoCheckEnabled: true, type: "http", geoCheckLocations: ["us-east"] }));
expect(defaults.logger.debug).toHaveBeenCalledWith(expect.objectContaining({ message: expect.stringContaining("maintenance window") }));
expect(defaults.geoChecksService.buildGeoCheck).not.toHaveBeenCalled();
});
it("warns when buildGeoCheck returns null", async () => {
const { helper, defaults } = createHelper();
jest.spyOn(helper, "isInMaintenanceWindow").mockResolvedValue(false);
const job = helper.getHeartbeatGeoJob();
await job(makeMonitor({ geoCheckEnabled: true, type: "http", geoCheckLocations: ["us-east"] }));
expect(defaults.logger.warn).toHaveBeenCalledWith(expect.objectContaining({ message: expect.stringContaining("No geo check could be built") }));
});
it("adds geo check to buffer on success", async () => {
const geoCheck = { id: "gc-1", monitorId: "m1" };
const { helper, defaults } = createHelper({
geoChecksService: { buildGeoCheck: jest.fn().mockResolvedValue(geoCheck) },
});
jest.spyOn(helper, "isInMaintenanceWindow").mockResolvedValue(false);
const job = helper.getHeartbeatGeoJob();
await job(makeMonitor({ geoCheckEnabled: true, type: "http", geoCheckLocations: ["us-east"] }));
expect(defaults.buffer.addGeoCheckToBuffer).toHaveBeenCalledWith(geoCheck);
expect(defaults.logger.debug).toHaveBeenCalledWith(expect.objectContaining({ message: expect.stringContaining("Geo check job executed") }));
});
it("logs error on unexpected failure without rethrowing", async () => {
const { helper, defaults } = createHelper({
geoChecksService: { buildGeoCheck: jest.fn().mockRejectedValue(new Error("api timeout")) },
});
jest.spyOn(helper, "isInMaintenanceWindow").mockResolvedValue(false);
const job = helper.getHeartbeatGeoJob();
await job(makeMonitor({ geoCheckEnabled: true, type: "http", geoCheckLocations: ["us-east"] }));
it("catches and logs a pipeline error without rethrowing", async () => {
const { helper, defaults } = createHelper({ geoCheckPipeline: { run: jest.fn().mockRejectedValue(new Error("api timeout")) } });
await expect(helper.getHeartbeatGeoJob()(makeMonitor())).resolves.toBeUndefined();
expect(defaults.logger.error).toHaveBeenCalledWith(expect.objectContaining({ message: "api timeout" }));
});
it("logs 'Unknown error' for non-Error exceptions", async () => {
const { helper, defaults } = createHelper({
geoChecksService: { buildGeoCheck: jest.fn().mockRejectedValue("string error") },
});
jest.spyOn(helper, "isInMaintenanceWindow").mockResolvedValue(false);
const job = helper.getHeartbeatGeoJob();
it("logs 'Unknown error' for non-Error failures", async () => {
const { helper, defaults } = createHelper({ geoCheckPipeline: { run: jest.fn().mockRejectedValue("string error") } });
await job(makeMonitor({ geoCheckEnabled: true, type: "http", geoCheckLocations: ["us-east"] }));
await helper.getHeartbeatGeoJob()(makeMonitor());
expect(defaults.logger.error).toHaveBeenCalledWith(expect.objectContaining({ message: "Unknown error", stack: undefined }));
});
@@ -400,7 +181,6 @@ describe("QueueHelper", () => {
expect(defaults.checksRepository.deleteByMonitorIdsNotIn).toHaveBeenCalledWith(["m1"]);
expect(defaults.incidentsRepository.deleteByMonitorIdsNotIn).toHaveBeenCalledWith(["m1"]);
expect(defaults.geoChecksRepository.deleteByMonitorIdsNotIn).toHaveBeenCalledWith(["m1"]);
// Logs info for each deleted count > 0
expect(defaults.logger.info).toHaveBeenCalledWith(expect.objectContaining({ message: expect.stringContaining("2 orphaned monitors") }));
expect(defaults.logger.info).toHaveBeenCalledWith(expect.objectContaining({ message: expect.stringContaining("3 orphaned monitor stats") }));
expect(defaults.logger.info).toHaveBeenCalledWith(expect.objectContaining({ message: expect.stringContaining("4 orphaned checks") }));
@@ -413,7 +193,6 @@ describe("QueueHelper", () => {
const job = helper.getCleanupOrphanedJob();
await job();
// Only start and completion logs, no deletion logs
const infoMessages = (defaults.logger.info as jest.Mock).mock.calls.map((c: any) => c[0].message);
expect(infoMessages).toContain("Starting cleanup of orphaned data");
expect(infoMessages).toContain("Cleanup of orphaned data completed");
@@ -483,236 +262,4 @@ describe("QueueHelper", () => {
expect(defaults.logger.error).toHaveBeenCalledWith(expect.objectContaining({ message: "Unknown error", stack: undefined }));
});
});
// ── isInMaintenanceWindow ────────────────────────────────────────────────
describe("isInMaintenanceWindow", () => {
it("returns true when an active window spans now", async () => {
const now = new Date();
const { helper, maintenanceWindowsRepository } = createHelper();
maintenanceWindowsRepository.findByMonitorId.mockResolvedValue([
{
active: true,
start: new Date(now.getTime() - 1000).toISOString(),
end: new Date(now.getTime() + 1000).toISOString(),
repeat: 0,
},
]);
await expect(helper.isInMaintenanceWindow("m1", "team")).resolves.toBe(true);
});
it("returns true when repeat interval advances window into current time", async () => {
const now = Date.now();
const { helper, maintenanceWindowsRepository } = createHelper();
maintenanceWindowsRepository.findByMonitorId.mockResolvedValue([
{
active: true,
start: new Date(now - 7200000).toISOString(),
end: new Date(now - 6600000).toISOString(),
repeat: 3600000,
},
]);
await expect(helper.isInMaintenanceWindow("m1", "team")).resolves.toBe(true);
});
it("returns false when no active windows exist", async () => {
const { helper } = createHelper();
await expect(helper.isInMaintenanceWindow("m1", "team")).resolves.toBe(false);
});
it("returns false when window is inactive", async () => {
const now = new Date();
const { helper, maintenanceWindowsRepository } = createHelper();
maintenanceWindowsRepository.findByMonitorId.mockResolvedValue([
{
active: false,
start: new Date(now.getTime() - 1000).toISOString(),
end: new Date(now.getTime() + 1000).toISOString(),
repeat: 0,
},
]);
await expect(helper.isInMaintenanceWindow("m1", "team")).resolves.toBe(false);
});
it("returns false when active window is in the past without repeat", async () => {
const now = Date.now();
const { helper, maintenanceWindowsRepository } = createHelper();
maintenanceWindowsRepository.findByMonitorId.mockResolvedValue([
{
active: true,
start: new Date(now - 5000).toISOString(),
end: new Date(now - 1000).toISOString(),
repeat: 0,
},
]);
await expect(helper.isInMaintenanceWindow("m1", "team")).resolves.toBe(false);
});
it("returns false when repeat advances past current time without match", async () => {
const now = Date.now();
const { helper, maintenanceWindowsRepository } = createHelper();
// Window was 2 hours ago, lasts 10 minutes, repeats every hour
// First repeat: -1h to -50min (past), second repeat: now to +10min... actually this would match
// Use a repeat that skips over now
maintenanceWindowsRepository.findByMonitorId.mockResolvedValue([
{
active: true,
start: new Date(now - 3600000 * 3 - 300000).toISOString(), // 3h5min ago
end: new Date(now - 3600000 * 3).toISOString(), // 3h ago, 5min window
repeat: 3600000, // 1 hour repeat
},
]);
// Advances: -2h5m to -2h, -1h5m to -1h, -5m to 0 — that last one might match if now is within 5min
// Let's use a different setup that clearly doesn't match
maintenanceWindowsRepository.findByMonitorId.mockResolvedValue([
{
active: true,
start: new Date(now - 1800000 - 60000).toISOString(), // 31 min ago
end: new Date(now - 1800000).toISOString(), // 30 min ago, 1 min window
repeat: 3600000, // 1 hour repeat — next window would be in 29 min
},
]);
await expect(helper.isInMaintenanceWindow("m1", "team")).resolves.toBe(false);
});
it("preserves previous true result via accumulator", async () => {
const now = new Date();
const { helper, maintenanceWindowsRepository } = createHelper();
maintenanceWindowsRepository.findByMonitorId.mockResolvedValue([
{
active: true,
start: new Date(now.getTime() - 1000).toISOString(),
end: new Date(now.getTime() + 1000).toISOString(),
repeat: 0,
},
{
active: false,
start: new Date(now.getTime() - 1000).toISOString(),
end: new Date(now.getTime() + 1000).toISOString(),
repeat: 0,
},
]);
// Second window is inactive but first already matched — accumulator should preserve true
await expect(helper.isInMaintenanceWindow("m1", "team")).resolves.toBe(true);
});
});
// ── evaluateMonitorAction (tested indirectly via getHeartbeatJob) ─────────
describe("evaluateMonitorAction", () => {
const runJobAndGetDecision = async (statusResult: Record<string, unknown>) => {
const networkResponse = { monitorId: "m1", status: true, code: 200, message: "OK" };
const incidentService = { handleIncident: jest.fn().mockResolvedValue(undefined) };
const notificationsService = { handleNotifications: jest.fn().mockResolvedValue(undefined) };
const { helper } = createHelper({
networkService: { requestStatus: jest.fn().mockResolvedValue(networkResponse) },
statusService: { updateMonitorStatus: jest.fn().mockResolvedValue(statusResult) },
incidentService,
notificationsService,
});
jest.spyOn(helper, "isInMaintenanceWindow").mockResolvedValue(false);
const job = helper.getHeartbeatJob();
await job(makeMonitor());
return { incidentService, notificationsService };
};
it("does nothing when statusChanged is false", async () => {
const { incidentService, notificationsService } = await runJobAndGetDecision({
monitor: { id: "m1", status: "up" },
statusChanged: false,
prevStatus: "up",
code: 200,
});
expect(incidentService.handleIncident).toHaveBeenCalledWith(
expect.anything(),
expect.anything(),
expect.objectContaining({ shouldCreateIncident: false, shouldResolveIncident: false, shouldSendNotification: false }),
expect.anything()
);
});
it("creates incident and notifies when monitor goes down", async () => {
const { incidentService } = await runJobAndGetDecision({
monitor: { id: "m1", status: "down" },
statusChanged: true,
prevStatus: "up",
code: 500,
});
expect(incidentService.handleIncident).toHaveBeenCalledWith(
expect.anything(),
expect.anything(),
expect.objectContaining({
shouldCreateIncident: true,
shouldSendNotification: true,
incidentReason: "status_down",
notificationReason: "status_change",
}),
expect.anything()
);
});
it("creates incident and notifies when monitor is breached", async () => {
const { incidentService } = await runJobAndGetDecision({
monitor: { id: "m1", status: "breached" },
statusChanged: true,
prevStatus: "up",
code: 200,
});
expect(incidentService.handleIncident).toHaveBeenCalledWith(
expect.anything(),
expect.anything(),
expect.objectContaining({
shouldCreateIncident: true,
incidentReason: "threshold_breach",
notificationReason: "threshold_breach",
}),
expect.anything()
);
});
it("resolves incident when monitor recovers from down", async () => {
const { incidentService } = await runJobAndGetDecision({
monitor: { id: "m1", status: "up" },
statusChanged: true,
prevStatus: "down",
code: 200,
});
expect(incidentService.handleIncident).toHaveBeenCalledWith(
expect.anything(),
expect.anything(),
expect.objectContaining({ shouldResolveIncident: true, shouldSendNotification: true }),
expect.anything()
);
});
it("resolves incident when monitor recovers from breached", async () => {
const { incidentService } = await runJobAndGetDecision({
monitor: { id: "m1", status: "up" },
statusChanged: true,
prevStatus: "breached",
code: 200,
});
expect(incidentService.handleIncident).toHaveBeenCalledWith(
expect.anything(),
expect.anything(),
expect.objectContaining({ shouldResolveIncident: true }),
expect.anything()
);
});
it("does not create or resolve for unhandled status transitions", async () => {
const { incidentService } = await runJobAndGetDecision({
monitor: { id: "m1", status: "paused" },
statusChanged: true,
prevStatus: "up",
code: 200,
});
expect(incidentService.handleIncident).toHaveBeenCalledWith(
expect.anything(),
expect.anything(),
expect.objectContaining({ shouldCreateIncident: false, shouldResolveIncident: false, shouldSendNotification: false }),
expect.anything()
);
});
});
});
@@ -65,7 +65,7 @@ const createService = (overrides?: Record<string, unknown>) => {
const settingsService = {
getSettings: jest.fn().mockReturnValue(makeAppSettings()),
};
const jobQueue = {
const worker = {
deleteJob: jest.fn().mockResolvedValue(undefined),
};
const jwtMock = {
@@ -85,7 +85,7 @@ const createService = (overrides?: Record<string, unknown>) => {
monitorsRepository,
emailService,
settingsService,
jobQueue,
worker,
jwt: jwtMock,
crypto: cryptoMock,
...overrides,
@@ -475,23 +475,23 @@ describe("UserService", () => {
describe("deleteUser", () => {
it("deletes user and cleans up monitor jobs for superadmin", async () => {
const { service, usersRepository, jobQueue, monitorsRepository } = createService();
const { service, usersRepository, worker, monitorsRepository } = createService();
await service.deleteUser({ userId: "user-1", teamId: "team-1", roles: ["superadmin"] });
expect(monitorsRepository.findByTeamId).toHaveBeenCalledWith("team-1", {});
expect(jobQueue.deleteJob).toHaveBeenCalledTimes(2);
expect(worker.deleteJob).toHaveBeenCalledTimes(2);
expect(usersRepository.deleteById).toHaveBeenCalledWith("user-1");
});
it("handles null monitors for superadmin gracefully", async () => {
const { service, usersRepository, jobQueue } = createService({
const { service, usersRepository, worker } = createService({
monitorsRepository: { findByTeamId: jest.fn().mockResolvedValue(null) },
});
await service.deleteUser({ userId: "user-1", teamId: "team-1", roles: ["superadmin"] });
expect(jobQueue.deleteJob).not.toHaveBeenCalled();
expect(worker.deleteJob).not.toHaveBeenCalled();
expect(usersRepository.deleteById).toHaveBeenCalledWith("user-1");
});
@@ -0,0 +1,28 @@
import { describe, expect, it } from "@jest/globals";
import { normalizeStatusPageDomain, resolveStatusPageDomainFromRequest } from "../../../src/utils/statusPageDomain.ts";
describe("statusPageDomain utils", () => {
describe("normalizeStatusPageDomain", () => {
it("returns null for empty values", () => {
expect(normalizeStatusPageDomain("")).toBeNull();
expect(normalizeStatusPageDomain(" ")).toBeNull();
expect(normalizeStatusPageDomain(null)).toBeNull();
});
it("normalizes hostnames", () => {
expect(normalizeStatusPageDomain("Status.Example.COM")).toBe("status.example.com");
expect(normalizeStatusPageDomain("https://status.example.com/path")).toBe("status.example.com");
expect(normalizeStatusPageDomain("status.example.com:443")).toBe("status.example.com");
});
});
describe("resolveStatusPageDomainFromRequest", () => {
it("prefers the query domain over the host header", () => {
expect(resolveStatusPageDomainFromRequest("checkmate.example.com", "status.example.com")).toBe("status.example.com");
});
it("falls back to the host header", () => {
expect(resolveStatusPageDomainFromRequest("status.example.com:8443", undefined)).toBe("status.example.com");
});
});
});