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