Compare commits

...
Author SHA1 Message Date
Alexander Holliday a5766a55a9 Merge pull request #4000 from bluewave-labs/feat/express-5-bump
feat: migrate to Express 5
2026-09-25 13:19:47 -07:00
Alex Holliday c5902b397a bump node and npm 2026-09-25 13:15:58 -07:00
Alex Holliday e52ec2d6ee fix dependencies 2026-09-25 13:06:02 -07:00
Alex Holliday a2c978527f bump express to 5, migrate, bump multer to 2 2026-09-25 12:57:35 -07:00
Alexander Holliday 224dc60309 Merge pull request #3999 from bluewave-labs/chore/buffer-cleanup
chore: buffer cleanup
2026-09-25 11:54:35 -07:00
Alex Holliday d8e4c775d5 update tests 2026-09-25 11:51:10 -07:00
Alex Holliday a07d0059b7 remove unreachable catch blocks 2026-09-25 11:50:13 -07:00
Alexander Holliday 1a48d303f7 Merge pull request #3998 from bluewave-labs/chore/cleanup-controller-utils
chore: remove dead code
2026-09-25 11:47:37 -07:00
Alex Holliday 5bb9ca970c remove dead code 2026-09-25 11:44:05 -07:00
Alexander Holliday 2947b558a2 Merge pull request #3997 from bluewave-labs/chore/refactor-network-service
chore: refactor network service to remove dead code
2026-09-25 11:41:07 -07:00
Alex Holliday 98a5fa9941 fix names 2026-09-25 11:39:16 -07:00
Alex Holliday 712350d669 refactor network service to remove dead code 2026-09-25 11:34:41 -07:00
Alexander Holliday 1a287be323 Merge pull request #3995 from bluewave-labs/fix/decision-refactor
fix: convert decision objecty into a state object
2026-09-25 11:13:45 -07:00
Alexander Holliday 014750f91e Merge pull request #3996 from bluewave-labs/fix/dashboard-table-state
fix: add a dashboard table state
2026-09-25 11:13:29 -07:00
Alex Holliday 574078bdc8 fix default 2026-09-25 11:11:45 -07:00
Alex Holliday 7ab80ac3f4 add a dashboard table state 2026-09-25 11:06:48 -07:00
Alexander Holliday a258910ccf Merge pull request #3985 from fabiangigler/feat/persist-monitor-sorting
Feat/persist monitor sorting
2026-09-25 10:57:45 -07:00
fabiangigler e15a85bd61 Merge latest develop into monitor sorting
# Conflicts:
#	client/src/Features/UI/uiSlice.ts
2026-09-25 11:31:38 +02:00
fabiangigler ced90211a0 apply monitor sorting feedback 2026-09-25 11:24:32 +02:00
Alex Holliday 4ae8426766 convert decision obj into a state object 2026-09-24 15:13:10 -07:00
Alexander Holliday 32a5ca8799 Merge pull request #3993 from bluewave-labs/fix/extract-formatting-from-incident-service
fix: extract formatting from incident service
2026-09-24 15:08:20 -07:00
fabiangigler 380968472f Allow monitor sort headers to reset to default 2026-09-23 13:35:01 +02:00
fabiangigler 0cb48596a0 Clarify monitor sort fallback 2026-09-23 12:38:57 +02:00
fabiangigler 22d0123966 Reset monitor pages when sorting 2026-09-23 12:37:45 +02:00
fabiangigler 2a45b4d46f Persist monitor list sorting 2026-09-23 12:21:26 +02:00
82 changed files with 1137 additions and 1795 deletions

No files matched your search

+2 -2
View File
@@ -12,7 +12,7 @@ jobs:
- name: Set up Node
uses: actions/setup-node@v4
with:
node-version: 22
node-version: 24
- name: Install client dependencies
working-directory: client
@@ -29,7 +29,7 @@ jobs:
- name: Set up Node
uses: actions/setup-node@v4
with:
node-version: 22
node-version: 24
- name: Install server dependencies
working-directory: server
+2 -2
View File
@@ -12,7 +12,7 @@ jobs:
- name: Set up Node
uses: actions/setup-node@v4
with:
node-version: 22
node-version: 24
- name: Install client dependencies
working-directory: client
@@ -30,7 +30,7 @@ jobs:
- name: Set up Node
uses: actions/setup-node@v4
with:
node-version: 22
node-version: 24
- name: Install server dependencies
working-directory: server
+1 -1
View File
@@ -12,7 +12,7 @@ jobs:
- name: Set up Node
uses: actions/setup-node@v4
with:
node-version: 22
node-version: 24
- name: Install server dependencies
working-directory: server
+1 -1
View File
@@ -62,7 +62,7 @@ VITE_APP_LOG_LEVEL="debug"
### Monorepo Structure
- `/client` - React 18 + TypeScript + Vite + MUI frontend
- `/server` - Node.js 20+ + Express + TypeScript backend
- `/server` - Node.js 24+ + Express + TypeScript backend
- `/docker` - Multi-environment Docker configs (dev, staging, prod, arm, mono)
### Backend Layers
+4
View File
@@ -2,6 +2,10 @@
"name": "client",
"private": true,
"version": "3.12.0",
"engines": {
"node": ">=24",
"npm": ">=11"
},
"type": "module",
"scripts": {
"lockfile": "docker run --rm -v \"$PWD\":/w -w /w $(grep -m1 -o 'node:[^ ]*' ../docker/Dockerfile) npm install --package-lock-only",
+17
View File
@@ -3,12 +3,14 @@ import {
type DashboardCardKey,
type DashboardSortOrder,
} from "@/Types/Dashboard";
import type { SortOrder } from "@/Types/Query";
import { createSlice, type PayloadAction } from "@reduxjs/toolkit";
type ThemeMode = "light" | "dark";
type ChartType = "histogram" | "line";
type TableName =
| "dashboard"
| "monitors"
| "team"
| "maintenance"
@@ -19,6 +21,8 @@ type TableName =
interface TableState {
rowsPerPage: number;
sortField?: string;
sortOrder?: SortOrder;
}
interface SidebarState {
@@ -26,6 +30,7 @@ interface SidebarState {
}
interface UIState {
dashboard: TableState;
monitors: TableState;
pagespeed: TableState;
team: TableState;
@@ -51,6 +56,9 @@ const initialMode: ThemeMode = window?.matchMedia?.("(prefers-color-scheme: dark
: "light";
const initialState: UIState = {
dashboard: {
rowsPerPage: 100,
},
monitors: {
rowsPerPage: 10,
},
@@ -100,6 +108,14 @@ const uiSlice = createSlice({
const { table, value } = action.payload;
state[table].rowsPerPage = value;
},
setTableSort: (
state,
action: PayloadAction<{ table: TableName; field: string; order: SortOrder }>
) => {
const { table, field, order } = action.payload;
state[table].sortField = field;
state[table].sortOrder = order;
},
toggleSidebar: (state) => {
state.sidebar.collapsed = !state.sidebar.collapsed;
},
@@ -140,6 +156,7 @@ export type { UIState, ThemeMode, ChartType, TableName };
export default uiSlice.reducer;
export const {
setRowsPerPage,
setTableSort,
toggleSidebar,
setCollapsed,
setMode,
+13 -8
View File
@@ -1,4 +1,4 @@
import { setRowsPerPage, type TableName } from "@/Features/UI/uiSlice";
import { setRowsPerPage, setTableSort, type TableName } from "@/Features/UI/uiSlice";
import { useDelete, useGet } from "@/Hooks/UseApi";
import { useBulkMonitorActions } from "@/Hooks/useBulkMonitorActions";
import useDebounce from "@/Hooks/useDebounce";
@@ -10,6 +10,7 @@ import {
type MonitorType,
} from "@/Types/Monitor";
import type { Tag } from "@/Types/Tag";
import type { SortOrder } from "@/Types/Query";
import { useCallback, useMemo, useState } from "react";
import { useDispatch, useSelector } from "react-redux";
@@ -24,10 +25,12 @@ interface MonitorListConfig {
export const useMonitorListController = (config: MonitorListConfig) => {
const dispatch = useDispatch();
const rowsPerPage = useSelector(
(state: RootState) =>
state.ui?.[config.rowsPerPageTable]?.rowsPerPage ?? config.rowsPerPageDefault
const tableState = useSelector(
(state: RootState) => state.ui?.[config.rowsPerPageTable]
);
const rowsPerPage = tableState?.rowsPerPage ?? config.rowsPerPageDefault;
const sortField = tableState?.sortField ?? config.initialSortField ?? "";
const sortOrder = tableState?.sortOrder ?? "asc";
// Filter states
const [selectedTypes, setSelectedTypes] = useState<MonitorType[]>([]);
@@ -36,8 +39,6 @@ export const useMonitorListController = (config: MonitorListConfig) => {
const [selectedTags, setSelectedTags] = useState<string[]>([]);
const [page, setPage] = useState<number>(0);
const [search, setSearch] = useState<string>("");
const [sortField, setSortField] = useState<string>(config.initialSortField ?? "");
const [sortOrder, setSortOrder] = useState<"asc" | "desc">("asc");
const [selectedMonitor, setSelectedMonitor] = useState<Monitor | null>(null);
const debouncedSearch = useDebounce<string>(search, 300);
@@ -135,6 +136,11 @@ export const useMonitorListController = (config: MonitorListConfig) => {
setPage(0);
};
const setSort = (field: string, order: SortOrder) => {
dispatch(setTableSort({ field, order, table: config.rowsPerPageTable }));
setPage(0);
};
// Check for active filters
const hasActiveFilters = Boolean(
selectedTypes.length > 0 ||
@@ -189,9 +195,8 @@ export const useMonitorListController = (config: MonitorListConfig) => {
rowsPerPage,
handleSetRowsPerPage,
sortField,
setSortField,
sortOrder,
setSortOrder,
setSort,
// Delete
selectedMonitor,
+1 -1
View File
@@ -34,7 +34,7 @@ const Dashboard = () => {
types: [...MonitorTypes],
checksLimit: 1,
refreshInterval: 30000,
rowsPerPageTable: "monitors",
rowsPerPageTable: "dashboard",
rowsPerPageDefault: 100,
});
@@ -8,6 +8,7 @@ import {
ColoredLabel,
} from "@/Components/design-elements";
import { SPACING } from "@/Utils/Theme/constants";
import { getNextMonitorSort } from "@/Utils/MonitorUtils";
import type { Header } from "@/Components/design-elements/Table";
import { ActionsMenu, type ActionMenuItem } from "@/Components/actions-menu";
import { ArrowUp, ArrowDown } from "lucide-react";
@@ -20,6 +21,7 @@ import { usePost } from "@/Hooks/UseApi";
import type { Monitor } from "@/Types/Monitor";
import type { Tag } from "@/Types/Tag";
import type { SortOrder } from "@/Types/Query";
import type { DockerContainerSummary } from "@/Types/Check";
import { Checkbox } from "@/Components/inputs";
@@ -34,9 +36,8 @@ interface DockerMonitorsTableProps {
refetch: () => void;
setSelectedMonitor: (monitor: Monitor | null) => void;
sortField: string;
setSortField: (field: string) => void;
sortOrder: "asc" | "desc";
setSortOrder: (order: "asc" | "desc") => void;
sortOrder: SortOrder;
setSort: (field: string, order: SortOrder) => void;
count: number;
page: number;
setPage: (page: number) => void;
@@ -52,9 +53,8 @@ export const DockerMonitorsTable = ({
refetch,
setSelectedMonitor,
sortField,
setSortField,
sortOrder,
setSortOrder,
setSort,
count,
page,
setPage,
@@ -105,14 +105,8 @@ export const DockerMonitorsTable = ({
const handleSort = (e: React.MouseEvent, field: string) => {
e.preventDefault();
e.stopPropagation();
if (sortField === field) {
const newOrder = sortOrder === "asc" ? "desc" : "asc";
setSortOrder(newOrder);
} else {
setSortField(field);
setSortOrder("asc");
}
refetch();
const nextSort = getNextMonitorSort(field, sortField, sortOrder);
setSort(nextSort.field, nextSort.order);
};
const getActions = (monitor: Monitor): ActionMenuItem[] => {
+1 -2
View File
@@ -31,9 +31,8 @@ const DockerMonitorsPage = () => {
setPage={c.setPage}
rowsPerPage={c.rowsPerPage}
sortField={c.sortField}
setSortField={c.setSortField}
sortOrder={c.sortOrder}
setSortOrder={c.setSortOrder}
setSort={c.setSort}
setRowsPerPage={c.handleSetRowsPerPage}
selectedRows={c.selectedRows}
onSelectionChange={c.setSelectedRows}
@@ -9,6 +9,7 @@ import {
ColoredLabel,
} from "@/Components/design-elements";
import { SPACING } from "@/Utils/Theme/constants";
import { getNextMonitorSort } from "@/Utils/MonitorUtils";
import type { Header } from "@/Components/design-elements/Table";
import { ActionsMenu, type ActionMenuItem } from "@/Components/actions-menu";
import { ArrowUp, ArrowDown } from "lucide-react";
@@ -21,6 +22,7 @@ import { usePost } from "@/Hooks/UseApi";
import type { Monitor } from "@/Types/Monitor";
import type { Tag } from "@/Types/Tag";
import type { SortOrder } from "@/Types/Query";
import { Checkbox } from "@/Components/inputs";
interface InfraMonitorsTableProps {
@@ -29,9 +31,8 @@ interface InfraMonitorsTableProps {
refetch: () => void;
setSelectedMonitor: (monitor: Monitor | null) => void;
sortField: string;
setSortField: (field: string) => void;
sortOrder: "asc" | "desc";
setSortOrder: (order: "asc" | "desc") => void;
sortOrder: SortOrder;
setSort: (field: string, order: SortOrder) => void;
count: number;
page: number;
setPage: (page: number) => void;
@@ -47,9 +48,8 @@ export const InfraMonitorsTable = ({
refetch,
setSelectedMonitor,
sortField,
setSortField,
sortOrder,
setSortOrder,
setSort,
count,
page,
setPage,
@@ -100,14 +100,8 @@ export const InfraMonitorsTable = ({
const handleSort = (e: React.MouseEvent, field: string) => {
e.preventDefault();
e.stopPropagation();
if (sortField === field) {
const newOrder = sortOrder === "asc" ? "desc" : "asc";
setSortOrder(newOrder);
} else {
setSortField(field);
setSortOrder("asc");
}
refetch();
const nextSort = getNextMonitorSort(field, sortField, sortOrder);
setSort(nextSort.field, nextSort.order);
};
const getActions = (monitor: Monitor): ActionMenuItem[] => {
@@ -31,9 +31,8 @@ const InfrastructureMonitors = () => {
setPage={c.setPage}
rowsPerPage={c.rowsPerPage}
sortField={c.sortField}
setSortField={c.setSortField}
sortOrder={c.sortOrder}
setSortOrder={c.setSortOrder}
setSort={c.setSort}
setRowsPerPage={c.handleSetRowsPerPage}
selectedRows={c.selectedRows}
onSelectionChange={c.setSelectedRows}
@@ -9,6 +9,7 @@ import {
ColoredLabel,
} from "@/Components/design-elements";
import { LAYOUT, SPACING } from "@/Utils/Theme/constants";
import { getNextMonitorSort } from "@/Utils/MonitorUtils";
import { HistogramPageSpeed } from "@/Components/monitors";
import { ArrowDown, ArrowUp } from "lucide-react";
import type { Header } from "@/Components/design-elements/Table";
@@ -22,6 +23,7 @@ import { usePatch } from "@/Hooks/UseApi";
import type { Monitor, MonitorWithChecks } from "@/Types/Monitor";
import type { Tag } from "@/Types/Tag";
import type { SortOrder } from "@/Types/Query";
import type { ActionMenuItem } from "@/Components/actions-menu";
export const PageSpeedMonitorsTable = ({
@@ -30,9 +32,8 @@ export const PageSpeedMonitorsTable = ({
refetch,
setSelectedMonitor,
sortField,
setSortField,
sortOrder,
setSortOrder,
setSort,
count,
page,
setPage,
@@ -44,9 +45,8 @@ export const PageSpeedMonitorsTable = ({
refetch: Function;
setSelectedMonitor: Function;
sortField: string;
setSortField: (field: string) => void;
sortOrder: "asc" | "desc";
setSortOrder: (order: "asc" | "desc") => void;
sortOrder: SortOrder;
setSort: (field: string, order: SortOrder) => void;
count: number;
page: number;
setPage: (page: number) => void;
@@ -81,14 +81,8 @@ export const PageSpeedMonitorsTable = ({
const handleSort = (e: any, field: string) => {
e.preventDefault();
e.stopPropagation();
if (sortField === field) {
const newOrder = sortOrder === "asc" ? "desc" : "asc";
setSortOrder(newOrder);
} else {
setSortField(field);
setSortOrder("asc");
}
refetch();
const nextSort = getNextMonitorSort(field, sortField, sortOrder);
setSort(nextSort.field, nextSort.order);
};
const getActions = (monitor: Monitor): ActionMenuItem[] => {
@@ -46,9 +46,8 @@ const PageSpeedMonitorsPage = () => {
setPage={c.setPage}
rowsPerPage={c.rowsPerPage}
sortField={c.sortField}
setSortField={c.setSortField}
sortOrder={c.sortOrder}
setSortOrder={c.setSortOrder}
setSort={c.setSort}
setRowsPerPage={c.handleSetRowsPerPage}
/>
</MonitorListPage>
@@ -24,8 +24,9 @@ import type { ActionMenuItem } from "@/Components/actions-menu";
import type { RootState } from "@/Types/state";
import { Checkbox } from "@/Components/inputs";
import type { Tag } from "@/Types/Tag";
import type { SortOrder } from "@/Types/Query";
import { SPACING } from "@/Utils/Theme/constants";
import { getUptimePercentageColor } from "@/Utils/MonitorUtils";
import { getNextMonitorSort, getUptimePercentageColor } from "@/Utils/MonitorUtils";
import { formatPercentage } from "@/Utils/FormatUtils";
interface MonitorTableProps {
@@ -34,9 +35,8 @@ interface MonitorTableProps {
refetch: () => void;
setSelectedMonitor: (monitor: Monitor | null) => void;
sortField: string;
setSortField: (field: string) => void;
sortOrder: "asc" | "desc";
setSortOrder: (order: "asc" | "desc") => void;
sortOrder: SortOrder;
setSort: (field: string, order: SortOrder) => void;
count: number;
page: number;
setPage: (page: number) => void;
@@ -52,9 +52,8 @@ export const MonitorTable = ({
refetch,
setSelectedMonitor,
sortField,
setSortField,
sortOrder,
setSortOrder,
setSort,
count,
page,
setPage,
@@ -91,14 +90,8 @@ export const MonitorTable = ({
const handleSort = (e: React.MouseEvent, field: string) => {
e.preventDefault();
e.stopPropagation();
if (sortField === field) {
const newOrder = sortOrder === "asc" ? "desc" : "asc";
setSortOrder(newOrder);
} else {
setSortField(field);
setSortOrder("asc");
}
refetch();
const nextSort = getNextMonitorSort(field, sortField, sortOrder);
setSort(nextSort.field, nextSort.order);
};
const isBrowserOpenable = (monitor: Monitor): boolean => {
+1 -2
View File
@@ -30,9 +30,8 @@ const UptimeMonitorsPage = () => {
setPage={monitorListController.setPage}
rowsPerPage={monitorListController.rowsPerPage}
sortField={monitorListController.sortField}
setSortField={monitorListController.setSortField}
sortOrder={monitorListController.sortOrder}
setSortOrder={monitorListController.setSortOrder}
setSort={monitorListController.setSort}
setRowsPerPage={monitorListController.handleSetRowsPerPage}
selectedRows={monitorListController.selectedRows}
onSelectionChange={monitorListController.setSelectedRows}
+3
View File
@@ -1,2 +1,5 @@
export const DateRanges = ["recent", "hour", "day", "week", "month", "all"] as const;
export type DateRange = (typeof DateRanges)[number];
export const SortOrders = ["asc", "desc"] as const;
export type SortOrder = (typeof SortOrders)[number];
+11
View File
@@ -1,4 +1,5 @@
import type { MonitorStatus, MonitorType } from "@/Types/Monitor";
import type { SortOrder } from "@/Types/Query";
import type { PaletteKey } from "@/Utils/Theme/Theme";
import type { ValueType } from "@/Components/design-elements/StatusLabel";
import {
@@ -10,6 +11,16 @@ import {
type DockerLogLine,
} from "@/Types/Check";
export const getNextMonitorSort = (
field: string,
sortField: string,
sortOrder: SortOrder
): { field: string; order: SortOrder } => {
if (field !== sortField) return { field, order: "asc" };
if (sortOrder === "asc") return { field, order: "desc" };
return { field: "", order: "asc" };
};
export const getMonitorPath = (type: MonitorType): string => {
const pathMap: Record<MonitorType, string> = {
http: "uptime",
+2 -2
View File
@@ -4,7 +4,7 @@
# Build on the host arch (BUILDPLATFORM) rather than under QEMU — the output is
# pure JS so it's arch-independent, and this avoids the esbuild host/native
# binary version mismatch under emulation.
FROM --platform=$BUILDPLATFORM node:22-slim AS frontend-build
FROM --platform=$BUILDPLATFORM node:24-slim AS frontend-build
WORKDIR /app/client
@@ -19,7 +19,7 @@ RUN npm run build
# ---------------------
# Backend stage
# ---------------------
FROM node:22-slim AS app
FROM node:24-slim AS app
# Install ping
RUN apt-get update \
+486 -445
View File
File diff suppressed because it is too large. Load diff
+6 -5
View File
@@ -30,7 +30,8 @@
"author": "",
"license": "ISC",
"engines": {
"node": ">=20"
"node": ">=24",
"npm": ">=11"
},
"dependencies": {
"@csstools/css-tokenizer": "^3.0.4",
@@ -45,7 +46,7 @@
"dayjs": "^1.11.23",
"dockerode": "5.0.0",
"dotenv": "^16.4.5",
"express": "^4.19.2",
"express": "5.2.1",
"express-rate-limit": "^8.5.1",
"gamedig": "5.2.0",
"got": "14.4.7",
@@ -58,11 +59,11 @@
"jsonwebtoken": "9.0.2",
"mjml": "^5.0.0-alpha.4",
"mongoose": "^8.3.3",
"multer": "^1.4.5-lts.2",
"nodemailer": "9.0.5",
"multer": "2.4.0",
"nodemailer": "9.1.1",
"ping": "0.4.4",
"re2": "^1.24.1",
"sharp": "0.35.3",
"sharp": "0.35.4",
"ssl-checker": "2.0.10",
"swagger-ui-express": "5.0.1",
"tldts": "^7.4.10",
+1 -64
View File
@@ -1,5 +1,5 @@
import { AppError } from "@/utils/AppError.js";
import { Monitor, MonitorTypes, type MonitorType } from "@/domain/monitors/monitor.type.js";
import { Monitor } from "@/domain/monitors/monitor.type.js";
import { UserRole } from "@/domain/users/user.type.js";
import sslChecker, { SSLDetails } from "ssl-checker";
import * as whoiser from "whoiser";
@@ -124,23 +124,6 @@ export const fetchMonitorDomain = async (
return domainExpiry;
};
export const requireString = (value: unknown, fieldName: string): string => {
if (typeof value === "string" && value.trim().length > 0) {
return value;
}
throw new AppError({ message: `${fieldName} is required`, status: 400 });
};
export const optionalString = (value: unknown, fieldName: string): string | undefined => {
if (value === undefined) {
return undefined;
}
if (typeof value === "string") {
return value;
}
throw new AppError({ message: `${fieldName} must be a string`, status: 400 });
};
export const extractString = (value: unknown): string | undefined => {
const candidate = Array.isArray(value) ? value[0] : value;
if (typeof candidate !== "string" || candidate.length === 0) {
@@ -149,52 +132,6 @@ export const extractString = (value: unknown): string | undefined => {
return candidate;
};
export const optionalNumber = (value: unknown, fieldName: string): number | undefined => {
if (value === undefined) {
return undefined;
}
if (typeof value === "number" && Number.isFinite(value)) {
return value;
}
if (typeof value === "string" && value.trim() !== "") {
const parsed = Number(value);
if (!Number.isNaN(parsed)) {
return parsed;
}
}
throw new AppError({ message: `${fieldName} must be a number`, status: 400 });
};
export const parseMonitorTypeFilter = (value: unknown): MonitorType | MonitorType[] | undefined => {
const parseSingle = (input: unknown): MonitorType => {
if (typeof input !== "string") {
throw new AppError({ message: "Monitor type must be a string", status: 400 });
}
if (!MonitorTypes.includes(input as MonitorType)) {
throw new AppError({ message: `Invalid monitor type: ${input}`, status: 400 });
}
return input as MonitorType;
};
if (value === undefined) {
return undefined;
}
if (Array.isArray(value)) {
return value.map((entry) => parseSingle(entry));
}
return parseSingle(value);
};
export const parseSortOrder = (value: unknown): "asc" | "desc" | undefined => {
if (value === undefined) {
return undefined;
}
if (value === "asc" || value === "desc") {
return value;
}
throw new AppError({ message: "order must be either 'asc' or 'desc'", status: 400 });
};
export const requireTeamId = (teamId?: string): string => {
if (!teamId) {
throw new AppError({ message: "Team ID is required", status: 400 });
+4 -8
View File
@@ -53,14 +53,10 @@ export const sanitizeBody = (options = {}): ((req: Request, res: Response, next:
export const sanitizeQuery = (options = {}): ((req: Request, res: Response, next: NextFunction) => void) => {
return (req: Request, res: Response, next: NextFunction) => {
if (req.query && typeof req.query === "object") {
for (const key of Object.keys(req.query)) {
const value = req.query[key];
if (typeof value === "string") {
req.query[key] = sanitizeInput(value, options);
}
}
}
const sanitized = Object.fromEntries(
Object.entries(req.query).map(([key, value]) => [key, typeof value === "string" ? sanitizeInput(value, options) : value])
);
Object.defineProperty(req, "query", { value: sanitized, writable: true, configurable: true, enumerable: true });
next();
};
};
+1 -1
View File
@@ -122,7 +122,7 @@ export const createApp = ({
setupRoutes(app, controllers, services);
// FE routes
app.get("*", (req, res) => {
app.get("/{*splat}", (req, res) => {
res.sendFile(path.join(frontendPath, "index.html"));
});
app.use(handleErrors(services.logger));
+18 -19
View File
@@ -1,5 +1,4 @@
import { Resolver } from "dns/promises";
import axios from "axios";
import got from "got";
import ping from "ping";
import Docker from "dockerode";
@@ -13,7 +12,7 @@ import mongoose from "mongoose";
import { EnvConfig } from "@/domain/app-settings/app-settings.service.js";
import { SharedServices } from "@/config/services.shared.js";
import { INetworkService, NetworkService } from "@/service/networkService.js";
import { IProviderRegistry, ProviderRegistry } from "@/service/networkProviders/providerRegistry.js";
import { IBufferService, BufferService } from "@/service/bufferService.js";
import { IStatusService, StatusService } from "@/service/statusService.js";
import { SecretsRotationService } from "@/service/encryption/secretsRotationService.js";
@@ -24,25 +23,25 @@ import { NotificationReactor } from "@/worker/reactors/reactor.notification.js";
import { IncidentReactor } from "@/worker/reactors/reactor.incident.js";
import { ReactorDispatcher } from "@/worker/reactors/reactor.dispatcher.js";
import { DBQueueWorker } from "@/worker/worker.db-queue.js";
import { ProxyResolver } from "@/service/network/ProxyResolver.js";
import { ProxyResolver } from "@/service/networkProviders/ProxyResolver.js";
import { IEgressService, EgressService } from "@/domain/egress/egress.service.js";
// Network providers
import { PingProvider } from "@/service/network/PingProvider.js";
import { HttpProvider } from "@/service/network/HttpProvider.js";
import { AdvancedMatcher } from "@/service/network/AdvancedMatcher.js";
import { PageSpeedProvider } from "@/service/network/PageSpeedProvider.js";
import { HardwareProvider } from "@/service/network/HardwareProvider.js";
import { DockerProvider } from "@/service/network/DockerProvider.js";
import { PortProvider } from "@/service/network/PortProvider.js";
import { GameProvider } from "@/service/network/GameProvider.js";
import { GrpcProvider } from "@/service/network/GrpcProvider.js";
import { WebSocketProvider } from "@/service/network/WebSocketProvider.js";
import { DNSProvider } from "@/service/network/DNSProvider.js";
import { PingProvider } from "@/service/networkProviders/PingProvider.js";
import { HttpProvider } from "@/service/networkProviders/HttpProvider.js";
import { AdvancedMatcher } from "@/service/networkProviders/AdvancedMatcher.js";
import { PageSpeedProvider } from "@/service/networkProviders/PageSpeedProvider.js";
import { HardwareProvider } from "@/service/networkProviders/HardwareProvider.js";
import { DockerProvider } from "@/service/networkProviders/DockerProvider.js";
import { PortProvider } from "@/service/networkProviders/PortProvider.js";
import { GameProvider } from "@/service/networkProviders/GameProvider.js";
import { GrpcProvider } from "@/service/networkProviders/GrpcProvider.js";
import { WebSocketProvider } from "@/service/networkProviders/WebSocketProvider.js";
import { DNSProvider } from "@/service/networkProviders/DNSProvider.js";
import { AppError } from "@/utils/AppError.js";
import { WorkerPipeline } from "@/worker/worker.pipeline.js";
export interface WorkerServices {
worker: IQueueWorker;
networkService: INetworkService;
networkService: IProviderRegistry;
bufferService: IBufferService;
statusService: IStatusService;
egressService: IEgressService;
@@ -87,7 +86,7 @@ export const buildWorker = async (shared: SharedServices, envSettings: EnvConfig
const webSocketProvider = new WebSocketProvider(WebSocket);
const dnsProvider = new DNSProvider(() => new Resolver());
const networkService = new NetworkService(axios, logger, [
const providerRegistry = new ProviderRegistry([
pingProvider,
httpProvider,
pageSpeedProvider,
@@ -105,7 +104,7 @@ export const buildWorker = async (shared: SharedServices, envSettings: EnvConfig
pipeline.ingestChecks(checks)
);
const statusService = new StatusService(logger, monitorsRepository, monitorStatsRepository);
const egressService = new EgressService(settingsService, egressStateRepository, jobsRepository, networkService, proxyResolver, logger);
const egressService = new EgressService(settingsService, egressStateRepository, jobsRepository, providerRegistry, proxyResolver, logger);
const notificationReactor = new NotificationReactor(notificationsService);
const incidentReactor = new IncidentReactor(incidentService);
@@ -118,7 +117,7 @@ export const buildWorker = async (shared: SharedServices, envSettings: EnvConfig
checksRepository,
jobsRepository,
checkService,
networkService,
providerRegistry: providerRegistry,
proxyResolver,
bufferService,
dockerLogsService,
@@ -188,5 +187,5 @@ export const buildWorker = async (shared: SharedServices, envSettings: EnvConfig
});
}
return { worker, networkService, bufferService, statusService, egressService };
return { worker, networkService: providerRegistry, bufferService, statusService, egressService };
};
+5 -5
View File
@@ -3,8 +3,8 @@ import type { ISettingsService } from "@/domain/app-settings/app-settings.servic
import type { IEgressStateRepository } from "@/domain/egress/egress-state.repository.interface.js";
import type { IJobsRepository } from "@/domain/jobs/job.repository.interface.js";
import { jobId, type Job, type JobSeed } from "@/domain/jobs/job.type.js";
import type { INetworkService } from "@/service/networkService.js";
import type { IProxyResolver } from "@/service/network/ProxyResolver.js";
import type { IProviderRegistry } from "@/service/networkProviders/providerRegistry.js";
import type { IProxyResolver } from "@/service/networkProviders/ProxyResolver.js";
import type { ILogger } from "@/utils/logger.js";
import {
EGRESS_RECOVERY_POLL_SECONDS,
@@ -13,7 +13,7 @@ import {
type EgressProbeResult,
type EgressStatus,
} from "@/domain/egress/egress.type.js";
import { timeRequest } from "@/service/network/utils.js";
import { timeRequest } from "@/service/networkProviders/utils.js";
import { parseEgressTarget, type EgressTarget } from "@/utils/egressTarget.js";
const SERVICE_NAME = "EgressService";
@@ -54,7 +54,7 @@ export class EgressService implements IEgressService {
private settingsService: ISettingsService,
private egressStateRepository: IEgressStateRepository,
private jobsRepository: IJobsRepository,
private networkService: INetworkService,
private providerRegistry: IProviderRegistry,
private proxyResolver: IProxyResolver,
private logger: ILogger
) {}
@@ -100,7 +100,7 @@ export class EgressService implements IEgressService {
const { response, responseTime, error } = await timeRequest(async () => {
const proxyUrl = await this.proxyResolver.resolve(monitor);
return Promise.race([
this.networkService.requestStatus(monitor, { proxyUrl }),
this.providerRegistry.probe(monitor, { proxyUrl }),
new Promise<never>((_, reject) => {
timer = setTimeout(() => reject(new Error(`Egress probe timed out after ${PROBE_TIMEOUT_MS}ms`)), PROBE_TIMEOUT_MS);
}),
+20 -30
View File
@@ -54,45 +54,35 @@ export class IncidentService implements IIncidentService {
}
handleIncident = async (monitor: Monitor, decision: MonitorActionDecision, check: Check): Promise<Incident | null> => {
if (!decision.shouldCreateIncident && !decision.shouldResolveIncident) {
return null;
}
const { transition } = decision;
if (transition === null) return null;
const activeIncident = await this.incidentsRepository.findActiveByMonitorId(monitor.id, monitor.teamId);
if (decision.shouldCreateIncident) {
if (activeIncident) {
return activeIncident;
} else {
let statusCode = check.statusCode;
let message: string | undefined;
// For threshold breaches, use 9999 status code and build descriptive message
if (decision.incidentReason === "threshold_breach") {
statusCode = 9999;
message = this.notificationMessageBuilder.buildThresholdBreachMessage(monitor, check, decision.thresholdBreaches);
}
const incident = {
switch (transition) {
case "status_down":
case "threshold_breach": {
if (activeIncident) return activeIncident;
// A threshold breach has no HTTP status
const isBreach = transition === "threshold_breach";
return await this.incidentsRepository.create({
monitorId: monitor.id,
teamId: monitor.teamId,
startTime: Date.now().toString(),
status: true,
statusCode,
message,
};
return await this.incidentsRepository.create(incident);
statusCode: isBreach ? 9999 : check.statusCode,
message: isBreach ? this.notificationMessageBuilder.buildThresholdBreachMessage(monitor, check, decision.thresholdBreaches) : undefined,
});
}
case "status_up":
case "threshold_resolved": {
if (!activeIncident) return null;
activeIncident.status = false;
activeIncident.endTime = Date.now().toString();
activeIncident.resolutionType = "automatic";
return await this.incidentsRepository.updateById(activeIncident.id, activeIncident.teamId, activeIncident);
}
}
if (!decision.shouldResolveIncident || !activeIncident) {
return null;
}
activeIncident.status = false;
activeIncident.endTime = Date.now().toString();
activeIncident.resolutionType = "automatic";
return await this.incidentsRepository.updateById(activeIncident.id, activeIncident.teamId, activeIncident);
};
resolveIncident = async (incidentId: string, userId: string, teamId: string, comment?: string, userEmail?: string) => {
@@ -101,7 +101,12 @@ class MongoMonitorsRepository implements IMonitorsRepository {
private pageOptions = (config: TeamQueryConfig) => {
const { page = 0, rowsPerPage = 0, field = "createdAt", order = "desc" } = config ?? {};
return { sort: { [field]: order === "asc" ? 1 : -1 } as const, skip: Math.max(page, 0) * rowsPerPage, limit: rowsPerPage };
const direction: 1 | -1 = order === "asc" ? 1 : -1;
const sort: Record<string, 1 | -1> = { [field]: direction };
if (field !== "createdAt") {
sort.createdAt = 1;
}
return { sort, skip: Math.max(page, 0) * rowsPerPage, limit: rowsPerPage };
};
findByTeamId = async (teamId: string, config: TeamQueryConfig, options?: { includeRecentChecks?: boolean }): Promise<Monitor[]> => {
@@ -39,24 +39,21 @@ export class NotificationMessageBuilder implements INotificationMessageBuilder {
clientHost,
metadata: {
teamId: monitor.teamId,
notificationReason: decision.notificationReason,
},
};
}
private determineNotificationType(decision: MonitorActionDecision, monitor: Monitor): NotificationType {
// Down status has highest priority (critical)
if (monitor.status === "down") {
return "monitor_down";
}
const isDocker = monitor.type === "docker";
switch (decision.notificationReason) {
switch (decision.transition) {
case "status_down":
return "monitor_down";
case "threshold_breach":
return isDocker ? "container_breach" : "threshold_breach";
case "threshold_resolved":
return isDocker ? "container_resolved" : "threshold_resolved";
default:
case "status_up":
case null:
return "monitor_up";
}
}
@@ -115,9 +115,7 @@ export class NotificationsService implements INotificationsService {
};
handleNotifications = async (monitor: Monitor, check: Check, decision: MonitorActionDecision) => {
if (!decision.shouldSendNotification) {
return false;
}
if (decision.transition === null) return false;
// Send notifications based on decision
return await this.sendNotifications(monitor, check, decision);
@@ -1,5 +1,4 @@
import type { HardwareMetricKey } from "@/domain/monitors/monitor.type.js";
import { MonitorActionDecision } from "@/worker/worker.interface.js";
export const NotificationChannels = [
"email",
@@ -120,6 +119,5 @@ export interface NotificationMessage {
clientHost: string;
metadata: {
teamId: string;
notificationReason: MonitorActionDecision["notificationReason"];
};
}
+3 -30
View File
@@ -55,42 +55,15 @@ export class BufferService implements IBufferService {
}
addToBuffer(check: Check) {
try {
this.buffer.push(check);
} catch (error: unknown) {
this.logger.error({
message: error instanceof Error ? error.message : "Unknown error",
service: this.SERVICE_NAME,
method: "addToBuffer",
stack: error instanceof Error ? error.stack : undefined,
});
}
this.buffer.push(check);
}
addGeoCheckToBuffer(geoCheck: GeoCheck) {
try {
this.geoBuffer.push(geoCheck);
} catch (error: unknown) {
this.logger.error({
message: error instanceof Error ? error.message : "Unknown error",
service: this.SERVICE_NAME,
method: "addGeoCheckToBuffer",
stack: error instanceof Error ? error.stack : undefined,
});
}
this.geoBuffer.push(geoCheck);
}
addDockerLogToBuffer(dockerLog: DockerLog): void {
try {
this.dockerLogBuffer.push(dockerLog);
} catch (error: unknown) {
this.logger.error({
message: error instanceof Error ? error.message : "Unknown error",
service: this.SERVICE_NAME,
method: "addDockerLogToBuffer",
stack: error instanceof Error ? error.stack : undefined,
});
}
this.dockerLogBuffer.push(dockerLog);
}
scheduleNextFlush() {
+1 -1
View File
@@ -3,7 +3,7 @@ import { supportsGeoCheck, type HttpStatusCode } from "@/domain/monitors/monitor
import { MonitorType } from "@/domain/monitors/monitor.type.js";
import type { ILogger } from "@/utils/logger.js";
import got from "got";
import { isStatusUp } from "@/service/network/utils.js";
import { isStatusUp } from "@/service/networkProviders/utils.js";
const SERVICE_NAME = "GlobalPingService";
const GLOBAL_PING_API_BASE = "https://api.globalping.io/v1";
@@ -1,10 +1,10 @@
import { DNSStatusPayload, MonitorStatusResponse } from "@/types/network.js";
import { IStatusProvider } from "@/service/network/IStatusProvider.js";
import { IStatusProvider } from "@/service/networkProviders/IStatusProvider.js";
import dns from "dns";
import type { Resolver } from "dns/promises";
import { Monitor, MonitorType } from "@/domain/monitors/monitor.type.js";
import { AppError } from "@/utils/AppError.js";
import { timeRequest } from "@/service/network/utils.js";
import { timeRequest } from "@/service/networkProviders/utils.js";
import { NETWORK_ERROR } from "@/types/network.js";
const SERVICE_NAME = "DNSProvider";
@@ -1,7 +1,7 @@
import https from "node:https";
import { IStatusProvider } from "@/service/network/IStatusProvider.js";
import { HttpProvider } from "@/service/network/HttpProvider.js";
import { IStatusProvider } from "@/service/networkProviders/IStatusProvider.js";
import { HttpProvider } from "@/service/networkProviders/HttpProvider.js";
import { CaptureDockerContainer, CaptureDockerStatusPayload, CheckContext, DockerStatusPayload, MonitorStatusResponse } from "@/types/network.js";
import {
@@ -24,7 +24,7 @@ import { Monitor, MonitorType } from "@/domain/monitors/monitor.type.js";
import { ILogger } from "@/utils/logger.js";
import { AppError } from "@/utils/AppError.js";
import Dockerode from "dockerode";
import { timeRequest, peerAnsweredFromError } from "@/service/network/utils.js";
import { timeRequest, peerAnsweredFromError } from "@/service/networkProviders/utils.js";
import { NETWORK_ERROR } from "@/types/network.js";
import { IEncryptionService } from "@/service/encryption/encryptionService.js";
import { DOCKER_TLS_URL, isCaptureDockerUrl, isDockerTlsUrl } from "@/utils/dockerHost.js";
@@ -1,4 +1,4 @@
import { IStatusProvider } from "@/service/network/IStatusProvider.js";
import { IStatusProvider } from "@/service/networkProviders/IStatusProvider.js";
import { GameStatusPayload, MonitorStatusResponse } from "@/types/network.js";
import { Monitor, MonitorType } from "@/domain/monitors/monitor.type.js";
import { GameDig } from "gamedig";
@@ -1,8 +1,8 @@
import { IStatusProvider } from "@/service/network/IStatusProvider.js";
import { IStatusProvider } from "@/service/networkProviders/IStatusProvider.js";
import { GrpcStatusPayload, MonitorStatusResponse } from "@/types/network.js";
import { Monitor, MonitorType } from "@/domain/monitors/monitor.type.js";
import { AppError } from "@/utils/AppError.js";
import { timeRequest } from "@/service/network/utils.js";
import { timeRequest } from "@/service/networkProviders/utils.js";
import * as grpc from "@grpc/grpc-js";
import * as protoLoader from "@grpc/proto-loader";
@@ -1,7 +1,7 @@
import { IStatusProvider } from "@/service/network/IStatusProvider.js";
import { IStatusProvider } from "@/service/networkProviders/IStatusProvider.js";
import { HardwareStatusPayload, MonitorStatusResponse } from "@/types/network.js";
import { Monitor, MonitorType } from "@/domain/monitors/monitor.type.js";
import { HttpProvider } from "@/service/network/HttpProvider.js";
import { HttpProvider } from "@/service/networkProviders/HttpProvider.js";
import { AppError } from "@/utils/AppError.js";
export class HardwareProvider implements IStatusProvider<HardwareStatusPayload> {
@@ -1,12 +1,12 @@
import { type Got, HTTPError, RequestError } from "got";
import { IAdvancedMatcher } from "@/service/network/AdvancedMatcher.js";
import { IStatusProvider } from "@/service/network/IStatusProvider.js";
import { IAdvancedMatcher } from "@/service/networkProviders/AdvancedMatcher.js";
import { IStatusProvider } from "@/service/networkProviders/IStatusProvider.js";
import { HttpStatusPayload } from "@/types/network.js";
import { MonitorStatusResponse } from "@/types/network.js";
import { Agent as HttpsAgent } from "https";
import { Agent as HttpAgent } from "http";
import { Monitor, MonitorType } from "@/domain/monitors/monitor.type.js";
import { isStatusUp, peerAnsweredFromError } from "@/service/network/utils.js";
import { isStatusUp, peerAnsweredFromError } from "@/service/networkProviders/utils.js";
import { NETWORK_ERROR } from "@/types/network.js";
import CacheableLookup from "cacheable-lookup";
import { HttpProxyAgent, HttpsProxyAgent } from "hpagent";
@@ -1,7 +1,7 @@
import { IStatusProvider } from "@/service/network/IStatusProvider.js";
import { IStatusProvider } from "@/service/networkProviders/IStatusProvider.js";
import { MonitorStatusResponse, PageSpeedStatusPayload } from "@/types/network.js";
import { Monitor, MonitorType, DefaultPageSpeedStrategy } from "@/domain/monitors/monitor.type.js";
import { HttpProvider } from "@/service/network/HttpProvider.js";
import { HttpProvider } from "@/service/networkProviders/HttpProvider.js";
import { ISettingsService } from "@/domain/app-settings/app-settings.service.js";
import { ILogger } from "@/utils/logger.js";
import { AppError } from "@/utils/AppError.js";
@@ -5,7 +5,7 @@ import { MonitorStatusResponse } from "@/types/network.js";
import { AppError } from "@/utils/AppError.js";
import ping from "ping";
import * as net from "net";
import { timeRequest } from "@/service/network/utils.js";
import { timeRequest } from "@/service/networkProviders/utils.js";
const SERVICE_NAME = "PingProvider";
type Ping = typeof ping;
@@ -1,8 +1,8 @@
import { IStatusProvider } from "@/service/network/IStatusProvider.js";
import { IStatusProvider } from "@/service/networkProviders/IStatusProvider.js";
import { PortStatusPayload, MonitorStatusResponse } from "@/types/network.js";
import { Monitor, MonitorType } from "@/domain/monitors/monitor.type.js";
import { AppError } from "@/utils/AppError.js";
import { timeRequest, peerAnsweredFromError } from "@/service/network/utils.js";
import { timeRequest, peerAnsweredFromError } from "@/service/networkProviders/utils.js";
import { NETWORK_ERROR } from "@/types/network.js";
import * as net from "net";
type NetType = typeof net;
@@ -1,8 +1,8 @@
import { IStatusProvider } from "@/service/network/IStatusProvider.js";
import { IStatusProvider } from "@/service/networkProviders/IStatusProvider.js";
import { WebSocketStatusPayload, MonitorStatusResponse } from "@/types/network.js";
import { Monitor, MonitorType } from "@/domain/monitors/monitor.type.js";
import { AppError } from "@/utils/AppError.js";
import { timeRequest, peerAnsweredFromError } from "@/service/network/utils.js";
import { timeRequest, peerAnsweredFromError } from "@/service/networkProviders/utils.js";
import { NETWORK_ERROR } from "@/types/network.js";
import type WebSocket from "ws";
@@ -0,0 +1,34 @@
import type { Monitor, MonitorType } from "@/domain/monitors/monitor.type.js";
import type { CheckContext, MonitorPayloadMap, MonitorStatusResponse } from "@/types/network.js";
import { NETWORK_ERROR } from "@/types/network.js";
import { IStatusProvider } from "./IStatusProvider.js";
const SERVICE_NAME = "ProviderRegistry";
export interface IProviderRegistry {
probe<T extends MonitorType>(monitor: Monitor & { type: T }, ctx?: CheckContext): Promise<MonitorStatusResponse<MonitorPayloadMap[T]>>;
}
export class ProviderRegistry implements IProviderRegistry {
static SERVICE_NAME = SERVICE_NAME;
constructor(private providers: IStatusProvider<unknown>[]) {}
async probe<T extends MonitorType>(monitor: Monitor & { type: T }, ctx?: CheckContext): Promise<MonitorStatusResponse<MonitorPayloadMap[T]>> {
const provider = this.providers.find((p) => p.supports(monitor.type));
if (!provider) {
return this.handleUnsupportedType(monitor.type) as Promise<MonitorStatusResponse<MonitorPayloadMap[T]>>;
}
return provider.handle(monitor, ctx) as Promise<MonitorStatusResponse<MonitorPayloadMap[T]>>;
}
private async handleUnsupportedType(type: string): Promise<MonitorStatusResponse> {
return {
monitorId: "unknown",
teamId: "unknown",
type: "unknown",
status: false,
code: NETWORK_ERROR,
message: `Unsupported type: ${type}`,
};
}
}
-198
View File
@@ -1,198 +0,0 @@
import type { Monitor, MonitorType } from "@/domain/monitors/monitor.type.js";
import type { CheckContext, MonitorPayloadMap, MonitorStatusResponse } from "@/types/network.js";
import type { AxiosStatic } from "axios";
import { AppError } from "@/utils/AppError.js";
import { NETWORK_ERROR } from "@/types/network.js";
import { ILogger } from "@/utils/logger.js";
import { IStatusProvider } from "./network/IStatusProvider.js";
const SERVICE_NAME = "NetworkService";
export interface INetworkService {
requestStatus<T extends MonitorType>(monitor: Monitor & { type: T }, ctx?: CheckContext): Promise<MonitorStatusResponse<MonitorPayloadMap[T]>>;
requestWebhook(
type: string,
url: string,
body: unknown
): Promise<{ type: string; status: boolean; code: number; message: string; payload?: unknown }>;
requestPagerDuty(args: { message: string; routingKey: string; monitorUrl: string }): Promise<boolean>;
requestMatrix(args: { homeserverUrl: string; accessToken: string; roomId: string; message: string }): Promise<{
status: boolean;
code: number;
message: string;
payload?: unknown;
}>;
}
export class NetworkService implements INetworkService {
static SERVICE_NAME = SERVICE_NAME;
private axios: AxiosStatic;
private logger: ILogger;
constructor(
axios: AxiosStatic,
logger: ILogger,
private providers: IStatusProvider<unknown>[]
) {
this.axios = axios;
this.logger = logger;
}
// Main entry point
async requestStatus<T extends MonitorType>(
monitor: Monitor & { type: T },
ctx?: CheckContext
): Promise<MonitorStatusResponse<MonitorPayloadMap[T]>> {
const provider = this.providers.find((p) => p.supports(monitor.type));
if (!provider) {
return this.handleUnsupportedType(monitor.type) as Promise<MonitorStatusResponse<MonitorPayloadMap[T]>>;
}
return provider.handle(monitor, ctx) as Promise<MonitorStatusResponse<MonitorPayloadMap[T]>>;
}
private async handleUnsupportedType(type: string): Promise<MonitorStatusResponse> {
return {
monitorId: "unknown",
teamId: "unknown",
type: "unknown",
status: false,
code: NETWORK_ERROR,
message: `Unsupported type: ${type}`,
};
}
// Other network requests unrelated to monitoring:
async requestWebhook(type: string, url: string, body: unknown) {
try {
const response = await this.axios.post(url, body, {
headers: {
"Content-Type": "application/json",
},
});
return {
type: "webhook",
status: true,
code: response.status,
message: `Successfully sent ${type} notification`,
payload: response.data,
};
} catch (err: unknown) {
this.logger.warn({
message: err instanceof Error ? err.message : String(err),
service: SERVICE_NAME,
method: "requestWebhook",
});
if (err && typeof err === "object" && "response" in err) {
const axiosError = err as { response?: { status?: number; data?: unknown } };
return {
type: "webhook",
status: false,
code: axiosError.response?.status ?? NETWORK_ERROR,
message: `Failed to send ${type} notification`,
payload: axiosError.response?.data,
};
}
return {
type: "webhook",
status: false,
code: NETWORK_ERROR,
message: `Failed to send ${type} notification`,
};
}
}
async requestPagerDuty({ message, routingKey, monitorUrl }: { message: string; routingKey: string; monitorUrl: string }) {
try {
const response = await this.axios.post(`https://events.pagerduty.com/v2/enqueue`, {
routing_key: routingKey,
event_action: "trigger",
payload: {
summary: message,
severity: "critical",
source: monitorUrl,
timestamp: new Date().toISOString(),
},
});
if (response.data?.status !== "success") return false;
return true;
} catch (err: unknown) {
const originalMessage = err instanceof Error ? err.message : String(err);
throw new AppError({
message: originalMessage || "Error sending PagerDuty notification",
service: SERVICE_NAME,
method: "requestPagerDuty",
details: {
responseData: err && typeof err === "object" && "response" in err ? (err as { response?: { data?: unknown } }).response?.data : undefined,
},
});
}
}
async requestMatrix({
homeserverUrl,
accessToken,
roomId,
message,
}: {
homeserverUrl: string;
accessToken: string;
roomId: string;
message: string;
}) {
try {
const url = `${homeserverUrl}/_matrix/client/v3/rooms/${roomId}/send/m.room.message?access_token=${accessToken}`;
const body = {
msgtype: "m.text",
body: message,
format: "org.matrix.custom.html",
formatted_body: message,
};
const response = await this.axios.post(url, body, {
headers: {
"Content-Type": "application/json",
},
});
return {
status: true,
code: response.status,
message: "Successfully sent Matrix notification",
};
} catch (err: unknown) {
if (err instanceof Error) {
this.logger.warn({
message: err.message,
service: SERVICE_NAME,
method: "requestMatrix",
});
if (err && typeof err === "object" && "response" in err) {
const axiosError = err as { response?: { status?: number; data?: unknown } };
return {
status: false,
code: axiosError.response?.status || NETWORK_ERROR,
message: "Failed to send Matrix notification",
payload: axiosError.response?.data,
};
}
}
this.logger.warn({
message: String(err),
service: SERVICE_NAME,
method: "requestMatrix",
});
return {
status: false,
code: NETWORK_ERROR,
message: "Failed to send Matrix notification",
};
}
}
}
+5 -5
View File
@@ -6,12 +6,12 @@ import type { QueueMode } from "@/domain/app-settings/app-settings.type.js";
export type JobHandler = (job: Job) => Promise<void>;
export type JobHandlers = Record<JobType, JobHandler>;
export const MonitorTransitions = ["status_down", "status_up", "threshold_breach", "threshold_resolved"] as const;
export type MonitorTransition = (typeof MonitorTransitions)[number];
export interface MonitorActionDecision {
shouldCreateIncident: boolean;
shouldResolveIncident: boolean;
shouldSendNotification: boolean;
incidentReason: "status_down" | "threshold_breach" | null;
notificationReason: "status_change" | "threshold_breach" | "threshold_resolved" | null;
transition: MonitorTransition | null;
thresholdBreaches?: HardwareBreaches;
}
+20 -35
View File
@@ -9,8 +9,8 @@ import { IMaintenanceWindowsRepository } from "@/domain/maintenance-windows/main
import { IMonitorsRepository } from "@/domain/monitors/monitor.repository.interface.js";
import { isEgressAttributable, Monitor } from "@/domain/monitors/monitor.type.js";
import { IBufferService } from "@/service/bufferService.js";
import { IProxyResolver } from "@/service/network/ProxyResolver.js";
import { INetworkService } from "@/service/networkService.js";
import { IProxyResolver } from "@/service/networkProviders/ProxyResolver.js";
import { IProviderRegistry } from "@/service/networkProviders/providerRegistry.js";
import { IStatusService } from "@/service/statusService.js";
import { DockerStatusPayload, MonitorStatusResponse, StatusChangeResult } from "@/types/network.js";
import { AppError } from "@/utils/AppError.js";
@@ -20,13 +20,7 @@ import { IReactorDispatcher } from "@/worker/reactors/reactor.dispatcher.js";
import { JobHandler, MonitorActionDecision, MonitorEvaluation } from "@/worker/worker.interface.js";
const SERVICE_NAME = "WorkerPipeline";
const NO_ACTION: MonitorActionDecision = {
shouldCreateIncident: false,
shouldResolveIncident: false,
shouldSendNotification: false,
incidentReason: null,
notificationReason: null,
};
// The three entry points: the queue calls the two handlers, the buffer calls ingestChecks.
// produce and evaluateCheck stay public on the class so tests can drive one stage without the queue or the buffer.
export interface IWorkerPipeline {
@@ -42,7 +36,7 @@ export interface WorkerPipelineDependencies {
checksRepository: IChecksRepository;
jobsRepository: IJobsRepository;
checkService: ICheckService;
networkService: INetworkService;
providerRegistry: IProviderRegistry;
proxyResolver: IProxyResolver;
bufferService: IBufferService;
dockerLogsService: IDockerLogsService;
@@ -109,7 +103,7 @@ export class WorkerPipeline implements IWorkerPipeline {
const proxyUrl = await this.deps.proxyResolver.resolve(monitor);
const dockerTlsKey = await this.resolveDockerTlsKey(monitor);
const status = await this.deps.networkService.requestStatus(monitor, { proxyUrl, dockerTlsKey });
const status = await this.deps.providerRegistry.probe(monitor, { proxyUrl, dockerTlsKey });
if (!status) {
throw new Error("No network response");
}
@@ -272,7 +266,7 @@ export class WorkerPipeline implements IWorkerPipeline {
return {
monitor,
check,
decision: { ...NO_ACTION },
decision: { transition: null },
};
};
@@ -288,30 +282,21 @@ export class WorkerPipeline implements IWorkerPipeline {
};
private decide = (statusChange: StatusChangeResult): MonitorActionDecision => {
const { monitor, statusChanged, prevStatus } = statusChange;
const decision: MonitorActionDecision = { ...NO_ACTION };
if (!statusChanged) return decision;
const { monitor, statusChanged, prevStatus, thresholdBreaches } = statusChange;
if (!statusChanged) return { transition: null };
if (monitor.status === "down") {
decision.shouldCreateIncident = true;
decision.shouldSendNotification = true;
decision.incidentReason = "status_down";
decision.notificationReason = "status_change";
} else if (monitor.status === "breached") {
if (statusChange.thresholdBreaches) decision.thresholdBreaches = statusChange.thresholdBreaches;
decision.shouldCreateIncident = true;
decision.shouldSendNotification = true;
decision.incidentReason = "threshold_breach";
decision.notificationReason = "threshold_breach";
} else if (monitor.status === "up" && prevStatus === "breached") {
decision.shouldResolveIncident = true;
decision.shouldSendNotification = true;
decision.notificationReason = "threshold_resolved";
} else if (monitor.status === "up" && prevStatus === "down") {
decision.shouldResolveIncident = true;
decision.shouldSendNotification = true;
decision.notificationReason = "status_change";
if (monitor.status === "down") return { transition: "status_down" };
if (monitor.status === "breached") {
return thresholdBreaches
? // Hardware
{ transition: "threshold_breach", thresholdBreaches }
: // Docker
{ transition: "threshold_breach" };
}
return decision;
if (monitor.status === "up" && prevStatus === "breached") return { transition: "threshold_resolved" };
if (monitor.status === "up" && prevStatus === "down") return { transition: "status_up" };
return { transition: null };
};
}
+8 -8
View File
@@ -60,7 +60,7 @@ export interface HeartbeatTestHarness {
statusService: StatusService;
incidentService: IncidentService;
notificationsService: { handleNotifications: jest.Mock };
networkService: { requestStatus: jest.Mock };
providerRegistry: { probe: jest.Mock };
bufferStub: { addToBuffer: jest.Mock; addGeoCheckToBuffer: jest.Mock; scheduleNextFlush: jest.Mock };
maintenanceWindowsRepo: { findByMonitorId: jest.Mock };
messageBuilder: { buildThresholdBreachMessage: jest.Mock };
@@ -87,8 +87,8 @@ export function createHeartbeatTestHarness(): HeartbeatTestHarness {
let nextResponse: MonitorStatusResponse | null = null;
let nextStatus = true;
let nextCode = 200;
const networkService = {
requestStatus: jest.fn().mockImplementation(() => {
const providerRegistry = {
probe: jest.fn().mockImplementation(() => {
if (nextResponse) {
return Promise.resolve(nextResponse);
}
@@ -119,11 +119,11 @@ export function createHeartbeatTestHarness(): HeartbeatTestHarness {
nextEgressStatus = status;
};
// The real NotificationsService ignores decisions with shouldSendNotification false; the stub is reached only for
// the ones it would act on, so tests can assert on handleNotifications calls as "notifications sent".
// The real NotificationsService ignores decisions with no transition; the stub is reached only for the ones it
// would act on, so tests can assert on handleNotifications calls as "notifications sent".
const guardedNotificationsService = {
handleNotifications: (monitor: Monitor, check: Check, decision: MonitorActionDecision) =>
decision.shouldSendNotification ? notificationsService.handleNotifications(monitor, check, decision) : Promise.resolve(false),
decision.transition !== null ? notificationsService.handleNotifications(monitor, check, decision) : Promise.resolve(false),
};
const notificationReactor = new NotificationReactor(guardedNotificationsService as any);
const incidentReactor = new IncidentReactor(incidentService as any);
@@ -137,7 +137,7 @@ export function createHeartbeatTestHarness(): HeartbeatTestHarness {
checksRepository: { findUnevaluatedByMonitorId: jest.fn() } as any,
jobsRepository: { upsertEvaluate: jest.fn(), pullEvaluated: jest.fn() } as any,
checkService: checkService as any,
networkService: networkService as any,
providerRegistry: providerRegistry as any,
proxyResolver: proxyResolver as any,
bufferService: bufferStub as any,
dockerLogsService: dockerLogsService as any,
@@ -163,7 +163,7 @@ export function createHeartbeatTestHarness(): HeartbeatTestHarness {
statusService,
incidentService,
notificationsService,
networkService,
providerRegistry,
bufferStub,
maintenanceWindowsRepo,
messageBuilder,
+1 -1
View File
@@ -21,7 +21,7 @@ export const makeMessage = (overrides?: Partial<NotificationMessage>): Notificat
timestamp: new Date("2025-01-01T00:00:00Z"),
},
clientHost: "https://app.example.com",
metadata: { teamId: "team-1", notificationReason: "status_change" },
metadata: { teamId: "team-1" },
...overrides,
});
@@ -14,7 +14,7 @@ const makeMessage = (): NotificationMessage => ({
timestamp: new Date("2025-01-01T00:00:00Z"),
},
clientHost: "https://app.example.com",
metadata: { teamId: "team-1", notificationReason: "status_change" },
metadata: { teamId: "team-1" },
});
export const testNotificationProviderContract = (
@@ -1,5 +1,5 @@
import { describe, expect, it } from "@jest/globals";
import type { IStatusProvider } from "../../src/service/network/IStatusProvider.ts";
import type { IStatusProvider } from "../../src/service/networkProviders/IStatusProvider.ts";
import type { Monitor } from "../../src/domain/monitors/monitor.type.ts";
/**
@@ -126,7 +126,7 @@ describe("Evaluate cursor with more than one processing worker", () => {
checksRepository,
jobsRepository,
checkService,
networkService: { requestStatus: jest.fn<any>() } as any,
providerRegistry: { probe: jest.fn<any>() } as any,
proxyResolver: { resolve: jest.fn<any>() } as any,
bufferService,
dockerLogsService: { buildDockerLogs: jest.fn<any>() } as any,
@@ -44,7 +44,7 @@ describe("Heartbeat job: maintenance windows", () => {
expect(storedMonitor.status).toBe("maintenance");
// No network request should have been made
expect(h.networkService.requestStatus).not.toHaveBeenCalled();
expect(h.providerRegistry.probe).not.toHaveBeenCalled();
// No check should have been buffered
expect(h.bufferStub.addToBuffer).not.toHaveBeenCalled();
@@ -68,7 +68,7 @@ describe("Heartbeat job: maintenance windows", () => {
// since status was already "maintenance"
const storedMonitor = await h.monitorsRepo.findById("mon-1", "team-1");
expect(storedMonitor.status).toBe("maintenance");
expect(h.networkService.requestStatus).not.toHaveBeenCalled();
expect(h.providerRegistry.probe).not.toHaveBeenCalled();
});
it("resumes normal checks when maintenance window expires", async () => {
@@ -88,7 +88,7 @@ describe("Heartbeat job: maintenance windows", () => {
await h.heartbeatJob(monitor);
// Network request should have been made this time
expect(h.networkService.requestStatus).toHaveBeenCalledTimes(1);
expect(h.providerRegistry.probe).toHaveBeenCalledTimes(1);
// Check should have been buffered
expect(h.bufferStub.addToBuffer).toHaveBeenCalledTimes(1);
@@ -105,7 +105,7 @@ describe("Heartbeat job: maintenance windows", () => {
await h.heartbeatJob(monitor);
// Normal check should proceed since window is inactive
expect(h.networkService.requestStatus).toHaveBeenCalledTimes(1);
expect(h.providerRegistry.probe).toHaveBeenCalledTimes(1);
expect(h.bufferStub.addToBuffer).toHaveBeenCalledTimes(1);
});
@@ -123,7 +123,7 @@ describe("Heartbeat job: maintenance windows", () => {
expect((await h.monitorsRepo.findById("mon-a", "team-1")).status).toBe("maintenance");
expect((await h.monitorsRepo.findById("mon-b", "team-1")).status).toBe("maintenance");
expect(h.networkService.requestStatus).not.toHaveBeenCalled();
expect(h.providerRegistry.probe).not.toHaveBeenCalled();
});
it("skips checks when window is in the past (expired)", async () => {
@@ -141,6 +141,6 @@ describe("Heartbeat job: maintenance windows", () => {
await h.heartbeatJob(monitor);
// Window is in the past — normal check should proceed
expect(h.networkService.requestStatus).toHaveBeenCalledTimes(1);
expect(h.providerRegistry.probe).toHaveBeenCalledTimes(1);
});
});
@@ -23,11 +23,7 @@ const makeMonitor = (overrides?: Partial<Monitor>): Monitor =>
}) as Monitor;
const makeDecision = (overrides?: Partial<MonitorActionDecision>): MonitorActionDecision => ({
shouldCreateIncident: false,
shouldResolveIncident: false,
shouldSendNotification: false,
incidentReason: null,
notificationReason: null,
transition: null,
...overrides,
});
@@ -63,7 +59,7 @@ describe("Incident lifecycle (integration)", () => {
it("creates an incident when monitor goes down", async () => {
const monitor = makeMonitor({ status: "down" });
const decision = makeDecision({ shouldCreateIncident: true, incidentReason: "status_down" });
const decision = makeDecision({ transition: "status_down" });
const incident = await service.handleIncident(monitor, decision, makeCheck(503));
@@ -84,7 +80,7 @@ describe("Incident lifecycle (integration)", () => {
it("does not create a duplicate incident for the same monitor", async () => {
const monitor = makeMonitor({ status: "down" });
const decision = makeDecision({ shouldCreateIncident: true, incidentReason: "status_down" });
const decision = makeDecision({ transition: "status_down" });
const first = await service.handleIncident(monitor, decision, makeCheck(503));
const second = await service.handleIncident(monitor, decision, makeCheck(503));
@@ -97,10 +93,10 @@ describe("Incident lifecycle (integration)", () => {
it("auto-resolves an incident when monitor recovers", async () => {
const monitor = makeMonitor({ status: "down" });
const createDecision = makeDecision({ shouldCreateIncident: true, incidentReason: "status_down" });
const createDecision = makeDecision({ transition: "status_down" });
const created = await service.handleIncident(monitor, createDecision, makeCheck(503));
const resolveDecision = makeDecision({ shouldResolveIncident: true });
const resolveDecision = makeDecision({ transition: "status_up" });
const resolved = await service.handleIncident(monitor, resolveDecision, makeCheck(200));
expect(resolved).not.toBeNull();
@@ -114,7 +110,7 @@ describe("Incident lifecycle (integration)", () => {
it("returns null when resolving with no active incident", async () => {
const monitor = makeMonitor({ status: "up" });
const decision = makeDecision({ shouldResolveIncident: true });
const decision = makeDecision({ transition: "status_up" });
const result = await service.handleIncident(monitor, decision, makeCheck(200));
@@ -125,7 +121,7 @@ describe("Incident lifecycle (integration)", () => {
it("manually resolves an active incident with comment", async () => {
const monitor = makeMonitor({ status: "down" });
const decision = makeDecision({ shouldCreateIncident: true, incidentReason: "status_down" });
const decision = makeDecision({ transition: "status_down" });
const created = await service.handleIncident(monitor, decision, makeCheck(500));
const resolved = await service.resolveIncident(created!.id, "user-1", "team-1", "Root cause identified", "user@test.com");
@@ -140,7 +136,7 @@ describe("Incident lifecycle (integration)", () => {
it("throws when manually resolving an already-resolved incident", async () => {
const monitor = makeMonitor({ status: "down" });
const decision = makeDecision({ shouldCreateIncident: true, incidentReason: "status_down" });
const decision = makeDecision({ transition: "status_down" });
const created = await service.handleIncident(monitor, decision, makeCheck(500));
await service.resolveIncident(created!.id, "user-1", "team-1");
@@ -153,7 +149,7 @@ describe("Incident lifecycle (integration)", () => {
it("creates a threshold breach incident with statusCode 9999 and descriptive message", async () => {
const monitor = makeMonitor({ status: "breached", type: "hardware" });
(messageBuilder.buildThresholdBreachMessage as jest.Mock).mockReturnValue("CPU: 92% (threshold: 80%), MEMORY: 88% (threshold: 85%)");
const decision = makeDecision({ shouldCreateIncident: true, incidentReason: "threshold_breach" });
const decision = makeDecision({ transition: "threshold_breach" });
const incident = await service.handleIncident(monitor, decision, makeCheck(200));
@@ -179,19 +175,19 @@ describe("Incident lifecycle (integration)", () => {
const monitor = makeMonitor({ status: "down" });
// First outage
const first = await service.handleIncident(monitor, makeDecision({ shouldCreateIncident: true, incidentReason: "status_down" }), makeCheck(503));
const first = await service.handleIncident(monitor, makeDecision({ transition: "status_down" }), makeCheck(503));
expect(first!.status).toBe(true);
// Manually resolved
await service.resolveIncident(first!.id, "user-1", "team-1", "Restarted server");
// Second outage — new incident since previous was resolved
const second = await service.handleIncident(monitor, makeDecision({ shouldCreateIncident: true, incidentReason: "status_down" }), makeCheck(502));
const second = await service.handleIncident(monitor, makeDecision({ transition: "status_down" }), makeCheck(502));
expect(second!.id).not.toBe(first!.id);
expect(second!.statusCode).toBe(502);
// Auto-resolve
const resolved = await service.handleIncident(monitor, makeDecision({ shouldResolveIncident: true }), makeCheck(200));
const resolved = await service.handleIncident(monitor, makeDecision({ transition: "status_up" }), makeCheck(200));
expect(resolved!.id).toBe(second!.id);
expect(resolved!.status).toBe(false);
expect(resolved!.resolutionType).toBe("automatic");
@@ -207,7 +203,7 @@ describe("Incident lifecycle (integration)", () => {
it("incidents for different monitors do not interfere", async () => {
const monitorA = makeMonitor({ id: "mon-a", status: "down" });
const monitorB = makeMonitor({ id: "mon-b", status: "down" });
const decision = makeDecision({ shouldCreateIncident: true, incidentReason: "status_down" });
const decision = makeDecision({ transition: "status_down" });
const incidentA = await service.handleIncident(monitorA, decision, makeCheck(500));
const incidentB = await service.handleIncident(monitorB, decision, makeCheck(502));
@@ -216,7 +212,7 @@ describe("Incident lifecycle (integration)", () => {
expect(repo.getAll()).toHaveLength(2);
// Resolving monitor A does not affect monitor B
const resolved = await service.handleIncident(monitorA, makeDecision({ shouldResolveIncident: true }), makeCheck(200));
const resolved = await service.handleIncident(monitorA, makeDecision({ transition: "status_up" }), makeCheck(200));
expect(resolved!.id).toBe(incidentA!.id);
const bStillActive = await repo.findActiveByMonitorId("mon-b", "team-1");
@@ -69,6 +69,34 @@ describe("MongoMonitorsRepository", () => {
});
});
describe("sorting", () => {
it("uses creation time as the fallback for equal primary values", async () => {
const repo = new MongoMonitorsRepository();
const teamId = new mongoose.Types.ObjectId();
const createMonitor = (name: string, type: string, status: string, createdAt: Date) =>
MonitorModel.create({
userId: new mongoose.Types.ObjectId(),
teamId,
name,
type,
status,
url: "https://example.com",
createdAt,
});
await createMonitor("newer-up", "hardware", "up", new Date("2026-03-01T00:00:00.000Z"));
await createMonitor("down", "http", "down", new Date("2026-04-01T00:00:00.000Z"));
await createMonitor("older-up", "pagespeed", "up", new Date("2026-01-01T00:00:00.000Z"));
await createMonitor("middle-up", "docker", "up", new Date("2026-02-01T00:00:00.000Z"));
const ascending = await repo.findByTeamIdWithStats(teamId.toString(), { field: "status", order: "asc" });
const descending = await repo.findByTeamIdWithStats(teamId.toString(), { field: "status", order: "desc" });
expect(ascending.map((monitor) => monitor.name)).toEqual(["down", "older-up", "middle-up", "newer-up"]);
expect(descending.map((monitor) => monitor.name)).toEqual(["older-up", "middle-up", "newer-up", "down"]);
});
});
describe("findByIds recentChecks modes", () => {
const seedFixtures = async () => {
const teamId = new mongoose.Types.ObjectId();
@@ -0,0 +1,107 @@
import { describe, expect, it, jest } from "@jest/globals";
import type { NextFunction, Request, Response } from "express";
import { sanitizeBody, sanitizeInput, sanitizeObject, sanitizeQuery } from "../../../src/api/middleware/sanitization.ts";
// Express 5 exposes req.query as a getter on the request prototype that re-parses the URL on
// every read, so writes into the returned object are lost. The middleware must shadow the
// getter with an own property, which is what this fixture checks.
const makeExpress5Request = (query: Record<string, unknown>) => {
const proto = {};
Object.defineProperty(proto, "query", {
get: () => ({ ...query }),
configurable: true,
enumerable: true,
});
return Object.create(proto) as Request;
};
describe("sanitizeInput", () => {
it("strips html tags and keeps the text content", () => {
expect(sanitizeInput('<script>alert("x")</script>hello <b>bold</b>')).toBe("hello bold");
});
it("returns non-string values unchanged", () => {
expect(sanitizeInput(42)).toBe(42);
expect(sanitizeInput(null)).toBeNull();
expect(sanitizeInput(undefined)).toBeUndefined();
});
});
describe("sanitizeObject", () => {
it("sanitizes nested strings and arrays, leaving other values untouched", () => {
const result = sanitizeObject({
name: "<img src=x onerror=alert(1)>Alice",
count: 3,
active: true,
items: [{ note: "<i>one</i>" }, { note: "two" }],
nested: { note: "<p>hi</p>" },
});
expect(result).toEqual({
name: "Alice",
count: 3,
active: true,
items: [{ note: "one" }, { note: "two" }],
nested: { note: "hi" },
});
});
it("returns primitives and null unchanged", () => {
expect(sanitizeObject("<b>x</b>")).toBe("<b>x</b>");
expect(sanitizeObject(null)).toBeNull();
});
});
describe("sanitizeBody middleware", () => {
it("replaces req.body with a sanitized copy and calls next", () => {
const req = { body: { name: "<b>Alice</b>", nested: { note: "<i>x</i>" } } } as unknown as Request;
const next = jest.fn() as unknown as NextFunction;
sanitizeBody()(req, {} as Response, next);
expect(req.body).toEqual({ name: "Alice", nested: { note: "x" } });
expect(next).toHaveBeenCalledWith();
});
it("leaves req.body alone when there is no body", () => {
const req = {} as unknown as Request;
const next = jest.fn() as unknown as NextFunction;
sanitizeBody()(req, {} as Response, next);
expect(req.body).toBeUndefined();
expect(next).toHaveBeenCalledWith();
});
});
describe("sanitizeQuery middleware", () => {
it("sanitizes string values so that later reads of req.query see them", () => {
const req = makeExpress5Request({ filter: "<script>x</script>term", page: "2" });
const next = jest.fn() as unknown as NextFunction;
sanitizeQuery()(req, {} as Response, next);
expect(req.query).toEqual({ filter: "term", page: "2" });
expect(req.query).toEqual({ filter: "term", page: "2" }); // stable across reads
expect(next).toHaveBeenCalledWith();
});
it("does not sanitize inside array values", () => {
const req = makeExpress5Request({ type: ["<b>http</b>", "ping"] });
const next = jest.fn() as unknown as NextFunction;
sanitizeQuery()(req, {} as Response, next);
expect(req.query).toEqual({ type: ["<b>http</b>", "ping"] });
});
it("handles an empty query", () => {
const req = makeExpress5Request({});
const next = jest.fn() as unknown as NextFunction;
sanitizeQuery()(req, {} as Response, next);
expect(req.query).toEqual({});
expect(next).toHaveBeenCalledWith();
});
});
@@ -1,5 +1,5 @@
import { describe, expect, it, jest } from "@jest/globals";
import { AdvancedMatcher } from "../../../../src/service/network/AdvancedMatcher.ts";
import { AdvancedMatcher } from "../../../../src/service/networkProviders/AdvancedMatcher.ts";
import type { Monitor } from "../../../../src/domain/monitors/monitor.type.ts";
// ── Helpers ──────────────────────────────────────────────────────────────────
@@ -2,7 +2,7 @@ import { beforeEach, describe, expect, it, jest } from "@jest/globals";
import { testStatusProviderContract } from "../../../helpers/statusProviderContract.ts";
import { NETWORK_ERROR } from "../../../../src/types/network.ts";
import type { Monitor } from "../../../../src/domain/monitors/monitor.type.ts";
import { DNSProvider } from "../../../../src/service/network/DNSProvider.ts";
import { DNSProvider } from "../../../../src/service/networkProviders/DNSProvider.ts";
// ── Mocks ────────────────────────────────────────────────────────────────────
@@ -1,6 +1,6 @@
import { describe, expect, it, jest } from "@jest/globals";
import https from "node:https";
import { DockerProvider } from "../../../../src/service/network/DockerProvider.ts";
import { DockerProvider } from "../../../../src/service/networkProviders/DockerProvider.ts";
import type { IEncryptionService } from "../../../../src/service/encryption/encryptionService.ts";
import { testStatusProviderContract } from "../../../helpers/statusProviderContract.ts";
import { createMockLogger } from "../../../helpers/createMockLogger.ts";
@@ -1,5 +1,5 @@
import { describe, expect, it, jest } from "@jest/globals";
import { GameProvider } from "../../../../src/service/network/GameProvider.ts";
import { GameProvider } from "../../../../src/service/networkProviders/GameProvider.ts";
import { testStatusProviderContract } from "../../../helpers/statusProviderContract.ts";
import { createMockLogger } from "../../../helpers/createMockLogger.ts";
import { NETWORK_ERROR } from "../../../../src/types/network.ts";
@@ -1,5 +1,5 @@
import { describe, expect, it, jest } from "@jest/globals";
import { GrpcProvider } from "../../../../src/service/network/GrpcProvider.ts";
import { GrpcProvider } from "../../../../src/service/networkProviders/GrpcProvider.ts";
import { testStatusProviderContract } from "../../../helpers/statusProviderContract.ts";
import type { Monitor } from "../../../../src/domain/monitors/monitor.type.ts";
@@ -1,7 +1,7 @@
import { describe, expect, it, jest } from "@jest/globals";
import { HardwareProvider } from "../../../../src/service/network/HardwareProvider.ts";
import { HardwareProvider } from "../../../../src/service/networkProviders/HardwareProvider.ts";
import { testStatusProviderContract } from "../../../helpers/statusProviderContract.ts";
import type { HttpProvider } from "../../../../src/service/network/HttpProvider.ts";
import type { HttpProvider } from "../../../../src/service/networkProviders/HttpProvider.ts";
import type { Monitor } from "../../../../src/domain/monitors/monitor.type.ts";
// ── Helpers ──────────────────────────────────────────────────────────────────
@@ -39,7 +39,7 @@ jest.unstable_mockModule("got", () => ({
},
}));
const { HttpProvider } = await import("../../../../src/service/network/HttpProvider.ts");
const { HttpProvider } = await import("../../../../src/service/networkProviders/HttpProvider.ts");
const gotModule = await import("got");
const { HTTPError, RequestError } = gotModule;
@@ -1,8 +1,8 @@
import { describe, expect, it, jest } from "@jest/globals";
import { PageSpeedProvider } from "../../../../src/service/network/PageSpeedProvider.ts";
import { PageSpeedProvider } from "../../../../src/service/networkProviders/PageSpeedProvider.ts";
import { testStatusProviderContract } from "../../../helpers/statusProviderContract.ts";
import { createMockLogger } from "../../../helpers/createMockLogger.ts";
import type { HttpProvider } from "../../../../src/service/network/HttpProvider.ts";
import type { HttpProvider } from "../../../../src/service/networkProviders/HttpProvider.ts";
import type { ISettingsService } from "../../../../src/domain/app-settings/app-settings.service.ts";
import type { Monitor } from "../../../../src/domain/monitors/monitor.type.ts";
@@ -1,5 +1,5 @@
import { describe, expect, it, jest } from "@jest/globals";
import { PingProvider } from "../../../../src/service/network/PingProvider.ts";
import { PingProvider } from "../../../../src/service/networkProviders/PingProvider.ts";
import { testStatusProviderContract } from "../../../helpers/statusProviderContract.ts";
import type { Monitor } from "../../../../src/domain/monitors/monitor.type.ts";
import * as net from "net";
@@ -1,5 +1,5 @@
import { describe, expect, it, jest } from "@jest/globals";
import { PortProvider } from "../../../../src/service/network/PortProvider.ts";
import { PortProvider } from "../../../../src/service/networkProviders/PortProvider.ts";
import { testStatusProviderContract } from "../../../helpers/statusProviderContract.ts";
import { NETWORK_ERROR } from "../../../../src/types/network.ts";
import type { Monitor } from "../../../../src/domain/monitors/monitor.type.ts";
@@ -1,5 +1,5 @@
import { describe, expect, it } from "@jest/globals";
import { timeRequest, isStatusUp, peerAnsweredFromError } from "../../../../src/service/network/utils.ts";
import { timeRequest, isStatusUp, peerAnsweredFromError } from "../../../../src/service/networkProviders/utils.ts";
import { NETWORK_ERROR } from "../../../../src/types/network.ts";
describe("network utils", () => {
@@ -1,5 +1,5 @@
import { describe, expect, it, jest, beforeEach, afterEach } from "@jest/globals";
import { WebSocketProvider } from "../../../../src/service/network/WebSocketProvider.ts";
import { WebSocketProvider } from "../../../../src/service/networkProviders/WebSocketProvider.ts";
import { testStatusProviderContract } from "../../../helpers/statusProviderContract.ts";
import { NETWORK_ERROR } from "../../../../src/types/network.ts";
import type { Monitor } from "../../../../src/domain/monitors/monitor.type.ts";
@@ -48,7 +48,6 @@ const createMessage = (overrides?: Partial<NotificationMessage>): NotificationMe
clientHost: "https://app.example.com",
metadata: {
teamId: "team-1",
notificationReason: "monitor_down",
},
...overrides,
});
@@ -134,41 +134,6 @@ describe("BufferService", () => {
expect(ingest).toHaveBeenCalledWith([check1, check2]);
});
it("logs error if push throws", () => {
const { service, logger } = createService();
// Force buffer.push to throw by making buffer non-extensible
Object.defineProperty(service, "buffer", { value: Object.freeze([]) });
service.addToBuffer(makeCheck());
expect(logger.error).toHaveBeenCalledWith(
expect.objectContaining({
service: "BufferService",
method: "addToBuffer",
})
);
});
it("logs 'Unknown error' for non-Error thrown values", () => {
const { service, logger } = createService();
Object.defineProperty(service, "buffer", {
value: {
push: () => {
throw "string error";
},
},
});
service.addToBuffer(makeCheck());
expect(logger.error).toHaveBeenCalledWith(
expect.objectContaining({
message: "Unknown error",
stack: undefined,
})
);
});
});
// ── addGeoCheckToBuffer ──────────────────────────────────────────────
@@ -183,40 +148,6 @@ describe("BufferService", () => {
expect(geoChecksService.createGeoChecks).toHaveBeenCalledWith([geoCheck]);
});
it("logs error if push throws", () => {
const { service, logger } = createService();
Object.defineProperty(service, "geoBuffer", { value: Object.freeze([]) });
service.addGeoCheckToBuffer(makeGeoCheck());
expect(logger.error).toHaveBeenCalledWith(
expect.objectContaining({
service: "BufferService",
method: "addGeoCheckToBuffer",
})
);
});
it("logs 'Unknown error' for non-Error thrown values", () => {
const { service, logger } = createService();
Object.defineProperty(service, "geoBuffer", {
value: {
push: () => {
throw 42;
},
},
});
service.addGeoCheckToBuffer(makeGeoCheck());
expect(logger.error).toHaveBeenCalledWith(
expect.objectContaining({
message: "Unknown error",
stack: undefined,
})
);
});
});
describe("addDockerLogToBuffer", () => {
@@ -229,15 +160,6 @@ describe("BufferService", () => {
expect(dockerLogsService.createDockerLogs).toHaveBeenCalledWith([dockerLog]);
});
it("logs an error if push throws", () => {
const { service, logger } = createService();
Object.defineProperty(service, "dockerLogBuffer", { value: Object.freeze([]) });
service.addDockerLogToBuffer(makeDockerLog());
expect(logger.error).toHaveBeenCalledWith(expect.objectContaining({ method: "addDockerLogToBuffer" }));
});
});
// ── scheduleNextFlush ────────────────────────────────────────────────
+29 -29
View File
@@ -42,7 +42,7 @@ const makeJob = (overrides?: Partial<Job>): Job => ({
...overrides,
});
// requestStatus mock that answers per target url
// probe mock that answers per target url
const statusFor = (reachableTargets: string[]) =>
jest.fn().mockImplementation(async (monitor: any) => ({
monitorId: monitor.id,
@@ -78,7 +78,7 @@ const createService = (overrides?: Record<string, any>) => {
upsertCleanupJob: jest.fn().mockResolvedValue(true),
deleteGlobalJobIfUnchanged: jest.fn().mockResolvedValue(true),
},
networkService: { requestStatus: statusFor(["1.1.1.1", "8.8.8.8"]) },
providerRegistry: { probe: statusFor(["1.1.1.1", "8.8.8.8"]) },
proxyResolver: { resolve: jest.fn().mockResolvedValue(undefined) },
logger: createMockLogger(),
...overrides,
@@ -87,15 +87,15 @@ const createService = (overrides?: Record<string, any>) => {
defaults.settingsService as any,
defaults.egressStateRepository as any,
defaults.jobsRepository as any,
defaults.networkService as any,
defaults.providerRegistry as any,
defaults.proxyResolver as any,
defaults.logger as any
);
return { service, defaults };
};
const probedUrls = (defaults: { networkService: { requestStatus: unknown } }) =>
(defaults.networkService.requestStatus as jest.Mock).mock.calls.map((call) => (call[0] as any).url);
const probedUrls = (defaults: { providerRegistry: { probe: unknown } }) =>
(defaults.providerRegistry.probe as jest.Mock).mock.calls.map((call) => (call[0] as any).url);
// ── Tests ────────────────────────────────────────────────────────────────────
@@ -112,7 +112,7 @@ describe("EgressService", () => {
await service.probeTargets(["1.1.1.1", "8.8.8.8:53", "https://example.com/health", "[2606:4700::1111]:443", "2606:4700::1111"]);
const monitors = (defaults.networkService.requestStatus as jest.Mock).mock.calls.map((call) => call[0] as any);
const monitors = (defaults.providerRegistry.probe as jest.Mock).mock.calls.map((call) => call[0] as any);
expect(monitors).toEqual([
expect.objectContaining({ type: "ping", url: "1.1.1.1" }),
expect.objectContaining({ type: "port", url: "8.8.8.8", port: 53 }),
@@ -129,13 +129,13 @@ describe("EgressService", () => {
await service.probeTargets(["https://example.com/health"]);
expect(resolve).toHaveBeenCalledWith(expect.objectContaining({ type: "http", proxyMode: "inherit" }));
expect(defaults.networkService.requestStatus).toHaveBeenCalledWith(expect.objectContaining({ type: "http" }), {
expect(defaults.providerRegistry.probe).toHaveBeenCalledWith(expect.objectContaining({ type: "http" }), {
proxyUrl: "http://proxy.internal:3128",
});
});
it("reports reachable only when the provider says status is true", async () => {
const { service } = createService({ networkService: { requestStatus: statusFor(["1.1.1.1"]) } });
const { service } = createService({ providerRegistry: { probe: statusFor(["1.1.1.1"]) } });
const results = await service.probeTargets(["1.1.1.1", "8.8.8.8"]);
@@ -146,7 +146,7 @@ describe("EgressService", () => {
});
it("treats any HTTP response, even 4xx/5xx, as reachable but a transport failure as unreachable", async () => {
const requestStatus = jest.fn().mockImplementation(async (monitor: any) => ({
const probe = jest.fn().mockImplementation(async (monitor: any) => ({
monitorId: monitor.id,
teamId: monitor.teamId,
type: monitor.type,
@@ -155,7 +155,7 @@ describe("EgressService", () => {
message: monitor.url.includes("maintenance") ? "Service Unavailable" : "ECONNREFUSED",
responseTime: 3,
}));
const { service } = createService({ networkService: { requestStatus } });
const { service } = createService({ providerRegistry: { probe } });
const results = await service.probeTargets(["https://example.com/maintenance", "https://example.com/refused", "8.8.8.8:53"]);
@@ -172,11 +172,11 @@ describe("EgressService", () => {
const results = await service.probeTargets(["[1.1.1.1"]);
expect(results).toEqual([expect.objectContaining({ target: "[1.1.1.1", reachable: false, message: "Invalid egress target" })]);
expect(defaults.networkService.requestStatus).not.toHaveBeenCalled();
expect(defaults.providerRegistry.probe).not.toHaveBeenCalled();
});
it("counts a thrown provider error as unreachable", async () => {
const { service } = createService({ networkService: { requestStatus: jest.fn().mockRejectedValue(new Error("ENETUNREACH")) } });
const { service } = createService({ providerRegistry: { probe: jest.fn().mockRejectedValue(new Error("ENETUNREACH")) } });
const results = await service.probeTargets(["1.1.1.1"]);
@@ -185,7 +185,7 @@ describe("EgressService", () => {
it("counts a hung provider as unreachable once the probe timeout elapses", async () => {
jest.useFakeTimers();
const { service } = createService({ networkService: { requestStatus: jest.fn().mockReturnValue(new Promise(() => {})) } });
const { service } = createService({ providerRegistry: { probe: jest.fn().mockReturnValue(new Promise(() => {})) } });
const pending = service.probeTargets(["1.1.1.1"]);
await jest.advanceTimersByTimeAsync(5000);
@@ -206,13 +206,13 @@ describe("EgressService", () => {
const result = await service.assessAfterFailure();
expect(result).toBeNull();
expect(defaults.networkService.requestStatus).not.toHaveBeenCalled();
expect(defaults.providerRegistry.probe).not.toHaveBeenCalled();
expect(defaults.egressStateRepository.findSingleton).not.toHaveBeenCalled();
expect(defaults.egressStateRepository.recordProbe).not.toHaveBeenCalled();
});
it("returns ok and records the probe when any target is reachable", async () => {
const { service, defaults } = createService({ networkService: { requestStatus: statusFor(["8.8.8.8"]) } });
const { service, defaults } = createService({ providerRegistry: { probe: statusFor(["8.8.8.8"]) } });
const result = await service.assessAfterFailure();
@@ -227,7 +227,7 @@ describe("EgressService", () => {
});
it("marks degraded, warns, schedules the recovery job and returns degraded when every target is unreachable", async () => {
const { service, defaults } = createService({ networkService: { requestStatus: statusFor([]) } });
const { service, defaults } = createService({ providerRegistry: { probe: statusFor([]) } });
const result = await service.assessAfterFailure();
@@ -255,7 +255,7 @@ describe("EgressService", () => {
it("still returns degraded and leaves the job scheduled when another worker performed the transition first", async () => {
const { service, defaults } = createService({
networkService: { requestStatus: statusFor([]) },
providerRegistry: { probe: statusFor([]) },
egressStateRepository: degradedRepository({
findSingleton: jest.fn().mockResolvedValue(makeState()),
markDegraded: jest.fn().mockResolvedValue(null),
@@ -271,7 +271,7 @@ describe("EgressService", () => {
it("does not mark degraded when the recovery job cannot be scheduled", async () => {
const { service, defaults } = createService({
networkService: { requestStatus: statusFor([]) },
providerRegistry: { probe: statusFor([]) },
jobsRepository: {
upsertJob: jest.fn(),
upsertCleanupJob: jest.fn().mockRejectedValue(new Error("db down")),
@@ -289,7 +289,7 @@ describe("EgressService", () => {
const result = await service.assessAfterFailure();
expect(result).toBe("degraded");
expect(defaults.networkService.requestStatus).not.toHaveBeenCalled();
expect(defaults.providerRegistry.probe).not.toHaveBeenCalled();
expect(defaults.egressStateRepository.markDegraded).not.toHaveBeenCalled();
expect(defaults.egressStateRepository.recordProbe).not.toHaveBeenCalled();
expect(defaults.jobsRepository.upsertJob).toHaveBeenCalledWith(expect.objectContaining({ id: "egress", intervalMs: 5_000 }));
@@ -322,19 +322,19 @@ describe("EgressService", () => {
describe("shared assessment", () => {
it("shares one probe between concurrent failures", async () => {
const { service, defaults } = createService({ networkService: { requestStatus: statusFor(["8.8.8.8"]) } });
const { service, defaults } = createService({ providerRegistry: { probe: statusFor(["8.8.8.8"]) } });
const results = await Promise.all([service.assessAfterFailure(), service.assessAfterFailure(), service.assessAfterFailure()]);
expect(results).toEqual(["ok", "ok", "ok"]);
expect(defaults.settingsService.getCachedDBSettings).toHaveBeenCalledTimes(1);
expect(defaults.networkService.requestStatus).toHaveBeenCalledTimes(2); // one call per target, once
expect(defaults.providerRegistry.probe).toHaveBeenCalledTimes(2); // one call per target, once
expect(defaults.egressStateRepository.recordProbe).toHaveBeenCalledTimes(1);
});
it("keeps sharing an in-flight assessment that outlives the reuse window", async () => {
jest.useFakeTimers();
const { service, defaults } = createService({ networkService: { requestStatus: jest.fn().mockReturnValue(new Promise(() => {})) } });
const { service, defaults } = createService({ providerRegistry: { probe: jest.fn().mockReturnValue(new Promise(() => {})) } });
const first = service.assessAfterFailure();
await jest.advanceTimersByTimeAsync(4_900); // probes still hanging, past the 5 s reuse window from the first call
@@ -342,12 +342,12 @@ describe("EgressService", () => {
await jest.advanceTimersByTimeAsync(200); // probe timeout fires, both settle
expect(await Promise.all([first, second])).toEqual(["degraded", "degraded"]);
expect(defaults.networkService.requestStatus).toHaveBeenCalledTimes(2); // one round of two targets, not two rounds
expect(defaults.providerRegistry.probe).toHaveBeenCalledTimes(2); // one round of two targets, not two rounds
});
it("reuses a settled assessment within the reuse window and probes again after it", async () => {
jest.useFakeTimers();
const { service, defaults } = createService({ networkService: { requestStatus: statusFor(["8.8.8.8"]) } });
const { service, defaults } = createService({ providerRegistry: { probe: statusFor(["8.8.8.8"]) } });
await service.assessAfterFailure();
await jest.advanceTimersByTimeAsync(1_000);
@@ -385,7 +385,7 @@ describe("EgressService", () => {
it("marks recovered, releases the job and logs when a target becomes reachable", async () => {
const { service, defaults } = createService({
egressStateRepository: degradedRepository(),
networkService: { requestStatus: statusFor(["1.1.1.1"]) },
providerRegistry: { probe: statusFor(["1.1.1.1"]) },
});
const job = makeJob({ nextScheduledAt: 123_456 });
@@ -403,7 +403,7 @@ describe("EgressService", () => {
it("releases the job but does not log a recovery when another process already recorded it", async () => {
const { service, defaults } = createService({
egressStateRepository: degradedRepository({ markRecovered: jest.fn().mockResolvedValue(null) }),
networkService: { requestStatus: statusFor(["1.1.1.1"]) },
providerRegistry: { probe: statusFor(["1.1.1.1"]) },
});
await service.checkRecovery(makeJob());
@@ -417,7 +417,7 @@ describe("EgressService", () => {
it("records the probe and keeps the job while every target stays unreachable", async () => {
const { service, defaults } = createService({
egressStateRepository: degradedRepository(),
networkService: { requestStatus: statusFor([]) },
providerRegistry: { probe: statusFor([]) },
});
await service.checkRecovery(makeJob());
@@ -432,7 +432,7 @@ describe("EgressService", () => {
await service.checkRecovery(makeJob());
expect(defaults.networkService.requestStatus).not.toHaveBeenCalled();
expect(defaults.providerRegistry.probe).not.toHaveBeenCalled();
expect(defaults.egressStateRepository.markRecovered).not.toHaveBeenCalled();
expect(defaults.jobsRepository.deleteGlobalJobIfUnchanged).toHaveBeenCalledWith("egress", 1_000);
});
@@ -445,7 +445,7 @@ describe("EgressService", () => {
await service.checkRecovery(makeJob());
expect(defaults.networkService.requestStatus).not.toHaveBeenCalled();
expect(defaults.providerRegistry.probe).not.toHaveBeenCalled();
expect(defaults.jobsRepository.deleteGlobalJobIfUnchanged).toHaveBeenCalledWith("egress", 1_000);
});
@@ -94,11 +94,7 @@ const makeCheck = (overrides?: Partial<Check>): Check =>
}) as Check;
const makeDecision = (overrides?: Partial<MonitorActionDecision>): MonitorActionDecision => ({
shouldCreateIncident: false,
shouldResolveIncident: false,
shouldSendNotification: false,
incidentReason: null,
notificationReason: null,
transition: null,
...overrides,
});
@@ -114,32 +110,24 @@ describe("IncidentService", () => {
expect(result).toBeNull();
});
it("returns existing active incident when shouldCreateIncident and one already exists", async () => {
it("returns the existing active incident on a down transition when one is already open", async () => {
const existing = makeIncident();
const { service, incidentsRepository } = createService();
(incidentsRepository.findActiveByMonitorId as jest.Mock).mockResolvedValue(existing);
const result = await service.handleIncident(
makeMonitor(),
makeDecision({ shouldCreateIncident: true, incidentReason: "status_down" }),
makeCheck({ statusCode: 500 })
);
const result = await service.handleIncident(makeMonitor(), makeDecision({ transition: "status_down" }), makeCheck({ statusCode: 500 }));
expect(result).toBe(existing);
expect(incidentsRepository.create).not.toHaveBeenCalled();
});
it("creates a new incident when shouldCreateIncident and no active incident exists", async () => {
it("creates a new incident on a down transition when none is open", async () => {
const created = makeIncident();
const { service, incidentsRepository, notificationMessageBuilder } = createService();
(incidentsRepository.findActiveByMonitorId as jest.Mock).mockResolvedValue(null);
(incidentsRepository.create as jest.Mock).mockResolvedValue(created);
const result = await service.handleIncident(
makeMonitor(),
makeDecision({ shouldCreateIncident: true, incidentReason: "status_down" }),
makeCheck({ statusCode: 500 })
);
const result = await service.handleIncident(makeMonitor(), makeDecision({ transition: "status_down" }), makeCheck({ statusCode: 500 }));
expect(result).toBe(created);
expect(incidentsRepository.create).toHaveBeenCalledWith(
@@ -163,7 +151,7 @@ describe("IncidentService", () => {
const monitor = makeMonitor({ type: "hardware" });
const check = makeCheck({ metadata: { monitorId: "mon-1", teamId: "team-1", type: "hardware" }, cpu: { usage_percent: 0.95 } });
const thresholdBreaches = { cpu: true, memory: false, disk: false, temp: false };
const decision = makeDecision({ shouldCreateIncident: true, incidentReason: "threshold_breach", thresholdBreaches });
const decision = makeDecision({ transition: "threshold_breach", thresholdBreaches });
await service.handleIncident(monitor, decision, check);
expect(notificationMessageBuilder.buildThresholdBreachMessage).toHaveBeenCalledWith(monitor, check, thresholdBreaches);
@@ -175,14 +163,14 @@ describe("IncidentService", () => {
);
});
it("resolves active incident when shouldResolveIncident", async () => {
it("resolves the active incident on an up transition", async () => {
const active = makeIncident();
const resolved = makeIncident({ status: false, endTime: "123", resolutionType: "automatic" });
const { service, incidentsRepository } = createService();
(incidentsRepository.findActiveByMonitorId as jest.Mock).mockResolvedValue(active);
(incidentsRepository.updateById as jest.Mock).mockResolvedValue(resolved);
const result = await service.handleIncident(makeMonitor(), makeDecision({ shouldResolveIncident: true }), makeCheck());
const result = await service.handleIncident(makeMonitor(), makeDecision({ transition: "status_up" }), makeCheck());
expect(result).toBe(resolved);
expect(incidentsRepository.updateById).toHaveBeenCalledWith(
@@ -192,11 +180,28 @@ describe("IncidentService", () => {
);
});
it("returns null when shouldResolveIncident but no active incident exists", async () => {
it("resolves the active incident on a threshold_resolved transition", async () => {
const active = makeIncident();
const resolved = makeIncident({ status: false, endTime: "123", resolutionType: "automatic" });
const { service, incidentsRepository } = createService();
(incidentsRepository.findActiveByMonitorId as jest.Mock).mockResolvedValue(active);
(incidentsRepository.updateById as jest.Mock).mockResolvedValue(resolved);
const result = await service.handleIncident(makeMonitor(), makeDecision({ transition: "threshold_resolved" }), makeCheck());
expect(result).toBe(resolved);
expect(incidentsRepository.updateById).toHaveBeenCalledWith(
"inc-1",
"team-1",
expect.objectContaining({ status: false, resolutionType: "automatic" })
);
});
it("returns null on an up transition when no incident is open", async () => {
const { service, incidentsRepository } = createService();
(incidentsRepository.findActiveByMonitorId as jest.Mock).mockResolvedValue(null);
const result = await service.handleIncident(makeMonitor(), makeDecision({ shouldResolveIncident: true }), makeCheck());
const result = await service.handleIncident(makeMonitor(), makeDecision({ transition: "status_up" }), makeCheck());
expect(result).toBeNull();
});
@@ -1,482 +0,0 @@
import { describe, expect, it, jest } from "@jest/globals";
import { NetworkService } from "../../../src/service/networkService.ts";
import { createMockLogger } from "../../helpers/createMockLogger.ts";
import { NETWORK_ERROR } from "../../../src/types/network.ts";
import type { IStatusProvider } from "../../../src/service/network/IStatusProvider.ts";
import type { Monitor, MonitorType } from "../../../src/domain/monitors/monitor.type.ts";
import type { AxiosStatic } from "axios";
// ── Helpers ──────────────────────────────────────────────────────────────────
const createMockAxios = () =>
({
post: jest.fn(),
}) as unknown as jest.Mocked<AxiosStatic>;
const createMockProvider = (type: string, result?: unknown) => {
const provider: jest.Mocked<IStatusProvider<unknown>> = {
type,
supports: jest.fn((t: MonitorType) => t === type) as any,
handle: jest.fn().mockResolvedValue(
result ?? {
monitorId: "mon-1",
teamId: "team-1",
type,
status: true,
code: 200,
message: "OK",
}
),
};
return provider;
};
const createService = (providers: IStatusProvider<unknown>[] = []) => {
const logger = createMockLogger();
const axios = createMockAxios();
const service = new NetworkService(axios, logger as any, providers);
return { service, logger, axios };
};
// ── Tests ────────────────────────────────────────────────────────────────────
describe("NetworkService", () => {
// ── Static / instance properties ─────────────────────────────────────────
// ── requestStatus ────────────────────────────────────────────────────────
describe("requestStatus", () => {
it("delegates to the matching provider", async () => {
const provider = createMockProvider("http");
const { service } = createService([provider]);
const monitor = { type: "http", _id: "mon-1" } as unknown as Monitor & { type: "http" };
const result = await service.requestStatus(monitor);
expect(provider.supports).toHaveBeenCalledWith("http");
expect(provider.handle).toHaveBeenCalledWith(monitor, undefined);
expect(result).toEqual(
expect.objectContaining({
monitorId: "mon-1",
status: true,
code: 200,
})
);
});
it("selects the correct provider when multiple are registered", async () => {
const httpProvider = createMockProvider("http");
const pingProvider = createMockProvider("ping");
const { service } = createService([httpProvider, pingProvider]);
const monitor = { type: "ping", _id: "mon-2" } as unknown as Monitor & { type: "ping" };
await service.requestStatus(monitor);
expect(httpProvider.handle).not.toHaveBeenCalled();
expect(pingProvider.handle).toHaveBeenCalledWith(monitor, undefined);
});
it("forwards the check context to the provider", async () => {
const provider = createMockProvider("http");
const { service } = createService([provider]);
const monitor = { type: "http", _id: "mon-1" } as unknown as Monitor & { type: "http" };
const ctx = { proxyUrl: "http://proxy.example.com:8080" };
await service.requestStatus(monitor, ctx);
expect(provider.handle).toHaveBeenCalledWith(monitor, ctx);
});
it("returns unsupported-type response when no provider matches", async () => {
const { service } = createService([]);
const monitor = { type: "unknown_type" as any, _id: "mon-1" } as unknown as Monitor & { type: any };
const result = await service.requestStatus(monitor);
expect(result).toEqual({
monitorId: "unknown",
teamId: "unknown",
type: "unknown",
status: false,
code: NETWORK_ERROR,
message: "Unsupported type: unknown_type",
});
});
});
// ── requestWebhook ───────────────────────────────────────────────────────
describe("requestWebhook", () => {
it("sends a POST and returns success response", async () => {
const { service, axios } = createService();
(axios.post as jest.Mock).mockResolvedValue({ status: 200, data: { ok: true } });
const result = await service.requestWebhook("slack", "https://hooks.example.com", { text: "hello" });
expect(axios.post).toHaveBeenCalledWith("https://hooks.example.com", { text: "hello" }, { headers: { "Content-Type": "application/json" } });
expect(result).toEqual({
type: "webhook",
status: true,
code: 200,
message: "Successfully sent slack notification",
payload: { ok: true },
});
});
it("returns failure with response data on axios error with response", async () => {
const { service, axios, logger } = createService();
const axiosError = Object.assign(new Error("Request failed"), {
response: { status: 403, data: { error: "forbidden" } },
});
(axios.post as jest.Mock).mockRejectedValue(axiosError);
const result = await service.requestWebhook("discord", "https://hooks.example.com", {});
expect(result).toEqual({
type: "webhook",
status: false,
code: 403,
message: "Failed to send discord notification",
payload: { error: "forbidden" },
});
expect(logger.warn).toHaveBeenCalledWith(
expect.objectContaining({
message: "Request failed",
service: "NetworkService",
method: "requestWebhook",
})
);
});
it("returns failure with NETWORK_ERROR when axios error has no response status", async () => {
const { service, axios } = createService();
const axiosError = Object.assign(new Error("timeout"), {
response: { status: undefined, data: undefined },
});
(axios.post as jest.Mock).mockRejectedValue(axiosError);
const result = await service.requestWebhook("slack", "https://hooks.example.com", {});
expect(result.code).toBe(NETWORK_ERROR);
});
it("returns failure with NETWORK_ERROR on non-axios error (no response property)", async () => {
const { service, axios } = createService();
(axios.post as jest.Mock).mockRejectedValue(new Error("DNS failure"));
const result = await service.requestWebhook("slack", "https://hooks.example.com", {});
expect(result).toEqual({
type: "webhook",
status: false,
code: NETWORK_ERROR,
message: "Failed to send slack notification",
});
});
it("handles non-Error thrown values", async () => {
const { service, axios, logger } = createService();
(axios.post as jest.Mock).mockRejectedValue("string error");
const result = await service.requestWebhook("slack", "https://hooks.example.com", {});
expect(logger.warn).toHaveBeenCalledWith(expect.objectContaining({ message: "string error" }));
expect(result).toEqual({
type: "webhook",
status: false,
code: NETWORK_ERROR,
message: "Failed to send slack notification",
});
});
it("returns NETWORK_ERROR when error has response key but response is undefined", async () => {
const { service, axios } = createService();
const axiosError = Object.assign(new Error("fail"), { response: undefined });
(axios.post as jest.Mock).mockRejectedValue(axiosError);
const result = await service.requestWebhook("slack", "https://hooks.example.com", {});
expect(result.code).toBe(NETWORK_ERROR);
expect(result.payload).toBeUndefined();
});
});
// ── requestPagerDuty ─────────────────────────────────────────────────────
describe("requestPagerDuty", () => {
const args = {
message: "Server down",
routingKey: "routing-key-123",
monitorUrl: "https://monitor.example.com",
};
it("sends correct payload and returns true on success", async () => {
const { service, axios } = createService();
(axios.post as jest.Mock).mockResolvedValue({ data: { status: "success" } });
const result = await service.requestPagerDuty(args);
expect(result).toBe(true);
expect(axios.post).toHaveBeenCalledWith(
"https://events.pagerduty.com/v2/enqueue",
expect.objectContaining({
routing_key: "routing-key-123",
event_action: "trigger",
payload: expect.objectContaining({
summary: "Server down",
severity: "critical",
source: "https://monitor.example.com",
}),
})
);
});
it("returns false when response status is not success", async () => {
const { service, axios } = createService();
(axios.post as jest.Mock).mockResolvedValue({ data: { status: "invalid event" } });
const result = await service.requestPagerDuty(args);
expect(result).toBe(false);
});
it("returns false when response data is undefined", async () => {
const { service, axios } = createService();
(axios.post as jest.Mock).mockResolvedValue({ data: undefined });
const result = await service.requestPagerDuty(args);
expect(result).toBe(false);
});
it("throws AppError on axios failure with response data", async () => {
const { service, axios } = createService();
const axiosError = Object.assign(new Error("Request failed"), {
response: { data: { message: "invalid routing key" } },
});
(axios.post as jest.Mock).mockRejectedValue(axiosError);
await expect(service.requestPagerDuty(args)).rejects.toThrow("Request failed");
});
it("throws AppError on network error without response", async () => {
const { service, axios } = createService();
(axios.post as jest.Mock).mockRejectedValue(new Error("ECONNREFUSED"));
await expect(service.requestPagerDuty(args)).rejects.toThrow("ECONNREFUSED");
});
it("throws AppError with default message for non-Error thrown value", async () => {
const { service, axios } = createService();
(axios.post as jest.Mock).mockRejectedValue(null);
await expect(service.requestPagerDuty(args)).rejects.toThrow("null");
});
it("uses fallback message when Error has empty string message", async () => {
const { service, axios } = createService();
(axios.post as jest.Mock).mockRejectedValue(new Error(""));
try {
await service.requestPagerDuty(args);
expect.unreachable("should have thrown");
} catch (err: any) {
expect(err.message).toBe("Error sending PagerDuty notification");
}
});
it("includes responseData when non-Error object has response property", async () => {
const { service, axios } = createService();
const errorObj = { response: { data: { detail: "bad key" } } };
(axios.post as jest.Mock).mockRejectedValue(errorObj);
try {
await service.requestPagerDuty(args);
expect.unreachable("should have thrown");
} catch (err: any) {
expect(err.details).toEqual({ responseData: { detail: "bad key" } });
}
});
it("sets responseData to undefined when error is a primitive (non-object)", async () => {
const { service, axios } = createService();
(axios.post as jest.Mock).mockRejectedValue(42);
try {
await service.requestPagerDuty(args);
expect.unreachable("should have thrown");
} catch (err: any) {
expect(err.details).toEqual({ responseData: undefined });
}
});
it("sets responseData to undefined when object has response key set to undefined", async () => {
const { service, axios } = createService();
(axios.post as jest.Mock).mockRejectedValue({ response: undefined });
try {
await service.requestPagerDuty(args);
expect.unreachable("should have thrown");
} catch (err: any) {
expect(err.details).toEqual({ responseData: undefined });
}
});
it("includes responseData in AppError details when available", async () => {
const { service, axios } = createService();
const axiosError = Object.assign(new Error("Bad request"), {
response: { data: { errors: ["invalid key"] } },
});
(axios.post as jest.Mock).mockRejectedValue(axiosError);
try {
await service.requestPagerDuty(args);
expect.unreachable("should have thrown");
} catch (err: any) {
expect(err.details).toEqual({
responseData: { errors: ["invalid key"] },
});
}
});
it("sets responseData to undefined in AppError when error has no response", async () => {
const { service, axios } = createService();
(axios.post as jest.Mock).mockRejectedValue(new Error("timeout"));
try {
await service.requestPagerDuty(args);
expect.unreachable("should have thrown");
} catch (err: any) {
expect(err.details).toEqual({ responseData: undefined });
}
});
});
// ── requestMatrix ────────────────────────────────────────────────────────
describe("requestMatrix", () => {
const args = {
homeserverUrl: "https://matrix.example.com",
accessToken: "token-abc",
roomId: "!room:example.com",
message: "<b>Alert</b>",
};
it("sends correct payload and returns success response", async () => {
const { service, axios } = createService();
(axios.post as jest.Mock).mockResolvedValue({ status: 200, data: { event_id: "$abc" } });
const result = await service.requestMatrix(args);
expect(result).toEqual({
status: true,
code: 200,
message: "Successfully sent Matrix notification",
});
expect(axios.post).toHaveBeenCalledWith(
"https://matrix.example.com/_matrix/client/v3/rooms/!room:example.com/send/m.room.message?access_token=token-abc",
{
msgtype: "m.text",
body: "<b>Alert</b>",
format: "org.matrix.custom.html",
formatted_body: "<b>Alert</b>",
},
{ headers: { "Content-Type": "application/json" } }
);
});
it("returns failure with response data on axios error with response (Error instance)", async () => {
const { service, axios, logger } = createService();
const axiosError = Object.assign(new Error("Forbidden"), {
response: { status: 403, data: { errcode: "M_FORBIDDEN" } },
});
(axios.post as jest.Mock).mockRejectedValue(axiosError);
const result = await service.requestMatrix(args);
expect(result).toEqual({
status: false,
code: 403,
message: "Failed to send Matrix notification",
payload: { errcode: "M_FORBIDDEN" },
});
expect(logger.warn).toHaveBeenCalledWith(
expect.objectContaining({
message: "Forbidden",
service: "NetworkService",
method: "requestMatrix",
})
);
});
it("returns NETWORK_ERROR when axios error response has no status", async () => {
const { service, axios } = createService();
const axiosError = Object.assign(new Error("timeout"), {
response: { status: 0, data: undefined },
});
(axios.post as jest.Mock).mockRejectedValue(axiosError);
const result = await service.requestMatrix(args);
expect(result.code).toBe(NETWORK_ERROR);
});
it("returns failure with NETWORK_ERROR on Error without response property", async () => {
const { service, axios, logger } = createService();
(axios.post as jest.Mock).mockRejectedValue(new Error("DNS failure"));
const result = await service.requestMatrix(args);
expect(result).toEqual({
status: false,
code: NETWORK_ERROR,
message: "Failed to send Matrix notification",
});
// First warn call is from the Error instanceof branch
expect(logger.warn).toHaveBeenCalledTimes(2);
});
it("returns failure with NETWORK_ERROR on non-Error thrown value", async () => {
const { service, axios, logger } = createService();
(axios.post as jest.Mock).mockRejectedValue("string error");
const result = await service.requestMatrix(args);
expect(result).toEqual({
status: false,
code: NETWORK_ERROR,
message: "Failed to send Matrix notification",
});
expect(logger.warn).toHaveBeenCalledWith(
expect.objectContaining({
message: "string error",
service: "NetworkService",
method: "requestMatrix",
})
);
});
it("returns NETWORK_ERROR when Error has response key but response is undefined", async () => {
const { service, axios } = createService();
const axiosError = Object.assign(new Error("fail"), { response: undefined });
(axios.post as jest.Mock).mockRejectedValue(axiosError);
const result = await service.requestMatrix(args);
expect(result.code).toBe(NETWORK_ERROR);
});
it("returns NETWORK_ERROR when Error response has undefined status", async () => {
const { service, axios } = createService();
const axiosError = Object.assign(new Error("fail"), {
response: { status: undefined, data: { error: "unknown" } },
});
(axios.post as jest.Mock).mockRejectedValue(axiosError);
const result = await service.requestMatrix(args);
expect(result.code).toBe(NETWORK_ERROR);
expect(result.payload).toEqual({ error: "unknown" });
});
});
});
@@ -23,11 +23,7 @@ const makeMonitor = (overrides?: Partial<Monitor>): Monitor =>
}) as Monitor;
const makeDecision = (overrides?: Partial<MonitorActionDecision>): MonitorActionDecision => ({
shouldCreateIncident: false,
shouldResolveIncident: false,
shouldSendNotification: true,
incidentReason: null,
notificationReason: "status_change",
transition: "status_down",
...overrides,
});
@@ -100,15 +96,12 @@ describe("NotificationMessageBuilder", () => {
});
expect(msg.content.title).toBe("Monitor Down: Test Monitor");
expect(msg.clientHost).toBe("https://app.example.com");
expect(msg.metadata).toEqual({
teamId: "team-1",
notificationReason: "status_change",
});
expect(msg.metadata).toEqual({ teamId: "team-1" });
});
it("builds a monitor_up message", () => {
const monitor = makeMonitor({ status: "up", type: "http" });
const decision = makeDecision({ notificationReason: "status_change" });
const decision = makeDecision({ transition: "status_up" });
const msg = builder.buildMessage(monitor, makeCheck(), decision, "https://app.example.com");
@@ -120,7 +113,7 @@ describe("NotificationMessageBuilder", () => {
it("builds a threshold_breach message", () => {
const monitor = makeMonitor({ status: "up", type: "hardware", cpuAlertThreshold: 80 });
const decision = makeDecision({
notificationReason: "threshold_breach",
transition: "threshold_breach",
thresholdBreaches: { cpu: true, memory: false, disk: false, temp: false },
});
const check = makeCheck({ cpu: { usage_percent: 0.9, temperature: [50] } });
@@ -136,7 +129,7 @@ describe("NotificationMessageBuilder", () => {
it("builds a threshold_resolved message for hardware monitor recovering", () => {
const monitor = makeMonitor({ status: "up", type: "hardware" });
const decision = makeDecision({ notificationReason: "threshold_resolved" });
const decision = makeDecision({ transition: "threshold_resolved" });
const msg = builder.buildMessage(monitor, makeCheck(), decision, "https://app.example.com");
@@ -149,18 +142,9 @@ describe("NotificationMessageBuilder", () => {
// ── determineNotificationType (via buildMessage) ─────────────────────
describe("determineNotificationType", () => {
it("returns monitor_down when status is down, even if notificationReason is threshold_breach", () => {
const monitor = makeMonitor({ status: "down" });
const decision = makeDecision({ notificationReason: "threshold_breach" });
const msg = builder.buildMessage(monitor, makeCheck(), decision, "");
expect(msg.type).toBe("monitor_down");
});
it("returns threshold_breach when reason is threshold_breach and status is not down", () => {
it("returns threshold_breach for a hardware monitor on a threshold_breach transition", () => {
const monitor = makeMonitor({ status: "up", type: "hardware" });
const decision = makeDecision({ notificationReason: "threshold_breach" });
const decision = makeDecision({ transition: "threshold_breach" });
const msg = builder.buildMessage(monitor, makeCheck(), decision, "");
@@ -169,16 +153,16 @@ describe("NotificationMessageBuilder", () => {
it("returns threshold_resolved for hardware monitor with threshold_resolved reason", () => {
const monitor = makeMonitor({ status: "up", type: "hardware" });
const decision = makeDecision({ notificationReason: "threshold_resolved" });
const decision = makeDecision({ transition: "threshold_resolved" });
const msg = builder.buildMessage(monitor, makeCheck(), decision, "");
expect(msg.type).toBe("threshold_resolved");
});
it("returns monitor_up for hardware monitor recovering from down with status_change", () => {
it("returns monitor_up for a hardware monitor on a status_up transition", () => {
const monitor = makeMonitor({ status: "up", type: "hardware" });
const decision = makeDecision({ notificationReason: "status_change" });
const decision = makeDecision({ transition: "status_up" });
const msg = builder.buildMessage(monitor, makeCheck(), decision, "");
@@ -186,7 +170,7 @@ describe("NotificationMessageBuilder", () => {
});
it("returns container_breach for docker monitor with threshold_breach reason", () => {
const decision = makeDecision({ notificationReason: "threshold_breach" });
const decision = makeDecision({ transition: "threshold_breach" });
const msg = builder.buildMessage(makeDockerMonitor(), makeCheck(), decision, "");
@@ -194,15 +178,15 @@ describe("NotificationMessageBuilder", () => {
});
it("returns container_resolved for docker monitor with threshold_resolved reason", () => {
const decision = makeDecision({ notificationReason: "threshold_resolved" });
const decision = makeDecision({ transition: "threshold_resolved" });
const msg = builder.buildMessage(makeDockerMonitor({ status: "up" }), makeCheck(), decision, "");
expect(msg.type).toBe("container_resolved");
});
it("returns monitor_down for docker monitor that is down regardless of reason", () => {
const decision = makeDecision({ notificationReason: "threshold_breach" });
it("returns monitor_down for a docker monitor on a status_down transition", () => {
const decision = makeDecision({ transition: "status_down" });
const msg = builder.buildMessage(makeDockerMonitor({ status: "down" }), makeCheck(), decision, "");
@@ -211,16 +195,16 @@ describe("NotificationMessageBuilder", () => {
it("returns monitor_up for non-hardware monitor with status up", () => {
const monitor = makeMonitor({ status: "up", type: "http" });
const decision = makeDecision({ notificationReason: "status_change" });
const decision = makeDecision({ transition: "status_up" });
const msg = builder.buildMessage(monitor, makeCheck(), decision, "");
expect(msg.type).toBe("monitor_up");
});
it("returns monitor_up as default for unrecognized status", () => {
it("returns monitor_up when there is no transition", () => {
const monitor = makeMonitor({ status: "unknown" as any });
const decision = makeDecision({ notificationReason: null });
const decision = makeDecision({ transition: null });
const msg = builder.buildMessage(monitor, makeCheck(), decision, "");
@@ -240,19 +224,14 @@ describe("NotificationMessageBuilder", () => {
const msg = builder.buildMessage(
makeMonitor({ status: "up", type: "hardware" }),
makeCheck(),
makeDecision({ notificationReason: "threshold_breach" }),
makeDecision({ transition: "threshold_breach" }),
""
);
expect(msg.severity).toBe("warning");
});
it("returns success for monitor_up", () => {
const msg = builder.buildMessage(
makeMonitor({ status: "up", type: "http" }),
makeCheck(),
makeDecision({ notificationReason: "status_change" }),
""
);
const msg = builder.buildMessage(makeMonitor({ status: "up", type: "http" }), makeCheck(), makeDecision({ transition: "status_up" }), "");
expect(msg.severity).toBe("success");
});
@@ -260,24 +239,19 @@ describe("NotificationMessageBuilder", () => {
const msg = builder.buildMessage(
makeMonitor({ status: "up", type: "hardware" }),
makeCheck(),
makeDecision({ notificationReason: "threshold_resolved" }),
makeDecision({ transition: "threshold_resolved" }),
""
);
expect(msg.severity).toBe("success");
});
it("returns warning for container_breach", () => {
const msg = builder.buildMessage(makeDockerMonitor(), makeCheck(), makeDecision({ notificationReason: "threshold_breach" }), "");
const msg = builder.buildMessage(makeDockerMonitor(), makeCheck(), makeDecision({ transition: "threshold_breach" }), "");
expect(msg.severity).toBe("warning");
});
it("returns success for container_resolved", () => {
const msg = builder.buildMessage(
makeDockerMonitor({ status: "up" }),
makeCheck(),
makeDecision({ notificationReason: "threshold_resolved" }),
""
);
const msg = builder.buildMessage(makeDockerMonitor({ status: "up" }), makeCheck(), makeDecision({ transition: "threshold_resolved" }), "");
expect(msg.severity).toBe("success");
});
});
@@ -331,7 +305,7 @@ describe("NotificationMessageBuilder", () => {
describe("monitor_up", () => {
it("includes recovery details", () => {
const monitor = makeMonitor({ status: "up", type: "http" });
const msg = builder.buildMessage(monitor, makeCheck(), makeDecision(), "");
const msg = builder.buildMessage(monitor, makeCheck(), makeDecision({ transition: "status_up" }), "");
expect(msg.content.title).toBe("Monitor Recovered: Test Monitor");
expect(msg.content.summary).toBe('Monitor "Test Monitor" is back up and operational.');
@@ -344,7 +318,7 @@ describe("NotificationMessageBuilder", () => {
const monitor = makeMonitor({ status: "up", type: "hardware", cpuAlertThreshold: 80 });
const check = makeCheck({ cpu: { usage_percent: 0.9, temperature: [50] } });
const decision = makeDecision({
notificationReason: "threshold_breach",
transition: "threshold_breach",
thresholdBreaches: { cpu: true, memory: false, disk: false, temp: false },
});
@@ -357,7 +331,7 @@ describe("NotificationMessageBuilder", () => {
const monitor = makeMonitor({ status: "up", type: "hardware", cpuAlertThreshold: 80 });
const check = makeCheck({ cpu: { usage_percent: 0.9, temperature: [50] } });
const msg = builder.buildMessage(monitor, check, makeDecision({ notificationReason: "threshold_breach" }), "");
const msg = builder.buildMessage(monitor, check, makeDecision({ transition: "threshold_breach" }), "");
expect(msg.content.thresholds).toEqual([]);
});
@@ -366,7 +340,7 @@ describe("NotificationMessageBuilder", () => {
describe("threshold_resolved", () => {
it("includes resolved details", () => {
const monitor = makeMonitor({ status: "up", type: "hardware" });
const msg = builder.buildMessage(monitor, makeCheck(), makeDecision({ notificationReason: "threshold_resolved" }), "");
const msg = builder.buildMessage(monitor, makeCheck(), makeDecision({ transition: "threshold_resolved" }), "");
expect(msg.content.title).toBe("Thresholds Resolved: Test Monitor");
expect(msg.content.summary).toBe('Monitor "Test Monitor" thresholds have returned to normal.');
@@ -381,7 +355,7 @@ describe("NotificationMessageBuilder", () => {
containers: makeContainers([{ name: "db", state: "exited", exitCode: 1 }, { name: "api", health: "unhealthy" }, { name: "web" }]),
});
const msg = builder.buildMessage(makeDockerMonitor(), check, makeDecision({ notificationReason: "threshold_breach" }), "");
const msg = builder.buildMessage(makeDockerMonitor(), check, makeDecision({ transition: "threshold_breach" }), "");
expect(msg.content.title).toBe("Container Alert: Test Monitor");
expect(msg.content.summary).toBe('2 container(s) on "Test Monitor" need attention.');
@@ -399,7 +373,7 @@ describe("NotificationMessageBuilder", () => {
});
const monitor = makeDockerMonitor({ dockerAlertOnStopped: false });
const msg = builder.buildMessage(monitor, check, makeDecision({ notificationReason: "threshold_breach" }), "");
const msg = builder.buildMessage(monitor, check, makeDecision({ transition: "threshold_breach" }), "");
expect(msg.content.details).toEqual(["URL: unix:///var/run/docker.sock", "Type: docker", "api: unhealthy"]);
});
@@ -407,7 +381,7 @@ describe("NotificationMessageBuilder", () => {
it("lists no containers when the check carries no container data", () => {
const check = makeCheck({ status: false, message: "connect ECONNREFUSED" });
const msg = builder.buildMessage(makeDockerMonitor(), check, makeDecision({ notificationReason: "threshold_breach" }), "");
const msg = builder.buildMessage(makeDockerMonitor(), check, makeDecision({ transition: "threshold_breach" }), "");
expect(msg.content.summary).toBe('0 container(s) on "Test Monitor" need attention.');
expect(msg.content.details).toEqual(["URL: unix:///var/run/docker.sock", "Type: docker"]);
@@ -416,12 +390,7 @@ describe("NotificationMessageBuilder", () => {
describe("container_resolved", () => {
it("includes recovered details", () => {
const msg = builder.buildMessage(
makeDockerMonitor({ status: "up" }),
makeCheck(),
makeDecision({ notificationReason: "threshold_resolved" }),
""
);
const msg = builder.buildMessage(makeDockerMonitor({ status: "up" }), makeCheck(), makeDecision({ transition: "threshold_resolved" }), "");
expect(msg.content.title).toBe("Containers Recovered: Test Monitor");
expect(msg.content.summary).toBe('All containers on "Test Monitor" are back to normal.');
@@ -430,18 +399,10 @@ describe("NotificationMessageBuilder", () => {
});
describe("default content", () => {
it("builds default content for unhandled notification type", () => {
// Force default by creating a scenario where type falls through
// We can test buildContent default by subclassing or testing indirectly
// The default case in buildContent handles "test" type among others
// Since determineNotificationType never returns "test", we test via
// a monitor with unrecognized status that maps to monitor_up
// Instead, let's verify the default case exists by checking that
// non-standard types would get default content
it("builds monitor_up content on a status_up transition", () => {
const monitor = makeMonitor({ status: "up", type: "http" });
const msg = builder.buildMessage(monitor, makeCheck(), makeDecision(), "");
const msg = builder.buildMessage(monitor, makeCheck(), makeDecision({ transition: "status_up" }), "");
// monitor_up is handled, so content should be monitor_up specific
expect(msg.content.title).toBe("Monitor Recovered: Test Monitor");
});
});
@@ -122,11 +122,7 @@ const makeMonitor = (overrides?: Partial<Monitor>): Monitor =>
}) as Monitor;
const makeDecision = (overrides?: Partial<MonitorActionDecision>): MonitorActionDecision => ({
shouldCreateIncident: false,
shouldResolveIncident: false,
shouldSendNotification: true,
incidentReason: null,
notificationReason: "status_change",
transition: "status_down",
...overrides,
});
@@ -148,9 +144,9 @@ describe("NotificationsService", () => {
// ── handleNotifications ───────────────────────────────────────────────────
describe("handleNotifications", () => {
it("returns false when shouldSendNotification is false", async () => {
it("returns false when there is no transition", async () => {
const { service } = createService();
const result = await service.handleNotifications(makeMonitor(), makeCheck(), makeDecision({ shouldSendNotification: false }));
const result = await service.handleNotifications(makeMonitor(), makeCheck(), makeDecision({ transition: null }));
expect(result).toBe(false);
});
@@ -0,0 +1,90 @@
import { describe, expect, it, jest } from "@jest/globals";
import { ProviderRegistry } from "../../../src/service/networkProviders/providerRegistry.ts";
import { NETWORK_ERROR } from "../../../src/types/network.ts";
import type { IStatusProvider } from "../../../src/service/networkProviders/IStatusProvider.ts";
import type { Monitor, MonitorType } from "../../../src/domain/monitors/monitor.type.ts";
// ── Helpers ──────────────────────────────────────────────────────────────────
const createMockProvider = (type: string, result?: unknown) => {
const provider: jest.Mocked<IStatusProvider<unknown>> = {
type,
supports: jest.fn((t: MonitorType) => t === type) as any,
handle: jest.fn().mockResolvedValue(
result ?? {
monitorId: "mon-1",
teamId: "team-1",
type,
status: true,
code: 200,
message: "OK",
}
),
};
return provider;
};
const createRegistry = (providers: IStatusProvider<unknown>[] = []) => new ProviderRegistry(providers);
// ── Tests ────────────────────────────────────────────────────────────────────
describe("ProviderRegistry", () => {
describe("probe", () => {
it("delegates to the matching provider", async () => {
const provider = createMockProvider("http");
const registry = createRegistry([provider]);
const monitor = { type: "http", _id: "mon-1" } as unknown as Monitor & { type: "http" };
const result = await registry.probe(monitor);
expect(provider.supports).toHaveBeenCalledWith("http");
expect(provider.handle).toHaveBeenCalledWith(monitor, undefined);
expect(result).toEqual(
expect.objectContaining({
monitorId: "mon-1",
status: true,
code: 200,
})
);
});
it("selects the correct provider when multiple are registered", async () => {
const httpProvider = createMockProvider("http");
const pingProvider = createMockProvider("ping");
const registry = createRegistry([httpProvider, pingProvider]);
const monitor = { type: "ping", _id: "mon-2" } as unknown as Monitor & { type: "ping" };
await registry.probe(monitor);
expect(httpProvider.handle).not.toHaveBeenCalled();
expect(pingProvider.handle).toHaveBeenCalledWith(monitor, undefined);
});
it("forwards the check context to the provider", async () => {
const provider = createMockProvider("http");
const registry = createRegistry([provider]);
const monitor = { type: "http", _id: "mon-1" } as unknown as Monitor & { type: "http" };
const ctx = { proxyUrl: "http://proxy.example.com:8080" };
await registry.probe(monitor, ctx);
expect(provider.handle).toHaveBeenCalledWith(monitor, ctx);
});
it("returns unsupported-type response when no provider matches", async () => {
const registry = createRegistry([]);
const monitor = { type: "unknown_type" as any, _id: "mon-1" } as unknown as Monitor & { type: any };
const result = await registry.probe(monitor);
expect(result).toEqual({
monitorId: "unknown",
teamId: "unknown",
type: "unknown",
status: false,
code: NETWORK_ERROR,
message: "Unsupported type: unknown_type",
});
});
});
});
@@ -1,5 +1,5 @@
import { describe, expect, it, jest } from "@jest/globals";
import { ProxyResolver, buildProxyUrl } from "../../../src/service/network/ProxyResolver.ts";
import { ProxyResolver, buildProxyUrl } from "../../../src/service/networkProviders/ProxyResolver.ts";
import type { Monitor } from "../../../src/domain/monitors/monitor.type.ts";
import type { Proxy } from "../../../src/domain/proxies/proxy.type.ts";
import { createMockLogger } from "../../helpers/createMockLogger.ts";
@@ -92,7 +92,7 @@ const createPipeline = (overrides?: Record<string, any>) => {
toCheck: jest.fn<any>().mockReturnValue({ id: "check-1" }),
createChecks: jest.fn<any>().mockResolvedValue([]),
},
networkService: { requestStatus: jest.fn<any>().mockResolvedValue({ monitorId: "m1", status: true, code: 200, message: "OK" }) },
providerRegistry: { probe: jest.fn<any>().mockResolvedValue({ monitorId: "m1", status: true, code: 200, message: "OK" }) },
proxyResolver: { resolve: jest.fn<any>().mockResolvedValue(undefined) },
bufferService: { addToBuffer: jest.fn<any>(), addDockerLogToBuffer: jest.fn<any>() },
dockerLogsService: { buildDockerLogs: jest.fn<any>().mockResolvedValue([]) },
@@ -115,7 +115,7 @@ describe("WorkerPipeline", () => {
await pipeline.handleCheck(makeJob());
expect(deps.monitorsRepository.findByIdLean).toHaveBeenCalledWith("m1");
expect(deps.networkService.requestStatus).toHaveBeenCalled();
expect(deps.providerRegistry.probe).toHaveBeenCalled();
expect(deps.bufferService.addToBuffer).toHaveBeenCalledWith({ id: "check-1" });
});
@@ -125,7 +125,7 @@ describe("WorkerPipeline", () => {
await pipeline.handleCheck(makeJob({ refId: null }));
expect(deps.monitorsRepository.findByIdLean).not.toHaveBeenCalled();
expect(deps.networkService.requestStatus).not.toHaveBeenCalled();
expect(deps.providerRegistry.probe).not.toHaveBeenCalled();
});
it("does nothing when the monitor no longer exists", async () => {
@@ -135,7 +135,7 @@ describe("WorkerPipeline", () => {
await pipeline.handleCheck(makeJob());
expect(deps.networkService.requestStatus).not.toHaveBeenCalled();
expect(deps.providerRegistry.probe).not.toHaveBeenCalled();
});
});
@@ -156,7 +156,7 @@ describe("WorkerPipeline", () => {
expect(result).toBeNull();
expect(deps.monitorsRepository.updateById).toHaveBeenCalledWith("m1", "team", { status: "maintenance", statusWindow: [] });
expect(deps.networkService.requestStatus).not.toHaveBeenCalled();
expect(deps.providerRegistry.probe).not.toHaveBeenCalled();
expect(deps.bufferService.addToBuffer).not.toHaveBeenCalled();
});
@@ -185,7 +185,7 @@ describe("WorkerPipeline", () => {
// The exit used to be a side effect of evaluating the resulting check, which the degraded-egress
// short-circuit skips. Left implicit, a monitor could sit in "maintenance" for a whole egress outage.
expect(deps.monitorsRepository.updateById).toHaveBeenCalledWith("m1", "team", { status: "initializing" });
expect(deps.networkService.requestStatus).toHaveBeenCalled();
expect(deps.providerRegistry.probe).toHaveBeenCalled();
});
it("does not touch the status of a monitor that was not in maintenance", async () => {
@@ -203,12 +203,12 @@ describe("WorkerPipeline", () => {
await pipeline.produce(makeMonitor());
expect(deps.networkService.requestStatus).toHaveBeenCalled();
expect(deps.providerRegistry.probe).toHaveBeenCalled();
});
// ── proxy resolution ──────────────────────────────────────────────────
it("passes the resolved proxy url into requestStatus", async () => {
it("passes the resolved proxy url into probe", async () => {
const { pipeline, deps } = createPipeline({
proxyResolver: { resolve: jest.fn<any>().mockResolvedValue("http://proxy.example.com:8080") },
});
@@ -216,7 +216,7 @@ describe("WorkerPipeline", () => {
await pipeline.produce(monitor);
expect(deps.networkService.requestStatus).toHaveBeenCalledWith(monitor, { proxyUrl: "http://proxy.example.com:8080" });
expect(deps.providerRegistry.probe).toHaveBeenCalledWith(monitor, { proxyUrl: "http://proxy.example.com:8080" });
});
it("still produces a check when the resolver returns undefined", async () => {
@@ -225,7 +225,7 @@ describe("WorkerPipeline", () => {
const result = await pipeline.produce(monitor);
expect(deps.networkService.requestStatus).toHaveBeenCalledWith(monitor, { proxyUrl: undefined });
expect(deps.providerRegistry.probe).toHaveBeenCalledWith(monitor, { proxyUrl: undefined });
expect(result).toEqual({ status: expect.objectContaining({ monitorId: "m1" }), check: { id: "check-1" } });
});
@@ -240,7 +240,7 @@ describe("WorkerPipeline", () => {
await pipeline.produce(monitor);
expect(deps.monitorsRepository.findDockerTlsKeyById).toHaveBeenCalledWith("m1");
expect(deps.networkService.requestStatus).toHaveBeenCalledWith(monitor, { proxyUrl: undefined, dockerTlsKey: "v1.abc123.iv.tag.data" });
expect(deps.providerRegistry.probe).toHaveBeenCalledWith(monitor, { proxyUrl: undefined, dockerTlsKey: "v1.abc123.iv.tag.data" });
});
it("does not fetch a key for a docker monitor without one set", async () => {
@@ -250,7 +250,7 @@ describe("WorkerPipeline", () => {
await pipeline.produce(monitor);
expect(deps.monitorsRepository.findDockerTlsKeyById).not.toHaveBeenCalled();
expect(deps.networkService.requestStatus).toHaveBeenCalledWith(monitor, { proxyUrl: undefined, dockerTlsKey: undefined });
expect(deps.providerRegistry.probe).toHaveBeenCalledWith(monitor, { proxyUrl: undefined, dockerTlsKey: undefined });
});
it("does not fetch a key for non-docker monitors even if the flag is set", async () => {
@@ -268,14 +268,14 @@ describe("WorkerPipeline", () => {
await pipeline.produce(monitor);
expect(deps.monitorsRepository.findDockerTlsKeyById).toHaveBeenCalledWith("m1");
expect(deps.networkService.requestStatus).toHaveBeenCalledWith(monitor, { proxyUrl: undefined, dockerTlsKey: undefined });
expect(deps.providerRegistry.probe).toHaveBeenCalledWith(monitor, { proxyUrl: undefined, dockerTlsKey: undefined });
});
// ── acquire / record ──────────────────────────────────────────────────
it("throws when the network response is null", async () => {
const { pipeline } = createPipeline({
networkService: { requestStatus: jest.fn<any>().mockResolvedValue(null) },
providerRegistry: { probe: jest.fn<any>().mockResolvedValue(null) },
});
await expect(pipeline.produce(makeMonitor())).rejects.toThrow("No network response");
@@ -297,7 +297,7 @@ describe("WorkerPipeline", () => {
const status = { monitorId: "m1", status: false, code: 500, message: "Error" };
const check = { id: "check-1" };
const { pipeline, deps } = createPipeline({
networkService: { requestStatus: jest.fn<any>().mockResolvedValue(status) },
providerRegistry: { probe: jest.fn<any>().mockResolvedValue(status) },
checkService: { toCheck: jest.fn<any>().mockReturnValue(check) },
});
@@ -311,7 +311,7 @@ describe("WorkerPipeline", () => {
const status = { type: "docker", monitorId: "m1", teamId: "team", status: true, code: 200, message: "OK", payload: {} };
const dockerLogs = [{ id: "docker-log-1" }, { id: "docker-log-2" }];
const { pipeline, deps } = createPipeline({
networkService: { requestStatus: jest.fn<any>().mockResolvedValue(status) },
providerRegistry: { probe: jest.fn<any>().mockResolvedValue(status) },
dockerLogsService: { buildDockerLogs: jest.fn<any>().mockResolvedValue(dockerLogs) },
});
@@ -354,7 +354,7 @@ describe("WorkerPipeline", () => {
]) {
const check: Record<string, unknown> = { id: "check-1" };
const { pipeline, deps } = createPipeline({
networkService: { requestStatus: jest.fn<any>().mockResolvedValue(status) },
providerRegistry: { probe: jest.fn<any>().mockResolvedValue(status) },
checkService: { toCheck: jest.fn<any>().mockReturnValue(check) },
egressService: { assessAfterFailure: jest.fn<any>().mockResolvedValue("degraded") },
});
@@ -370,7 +370,7 @@ describe("WorkerPipeline", () => {
it("consults egress for a timeout or network error", async () => {
const check: Record<string, unknown> = { id: "check-1" };
const { pipeline, deps } = createPipeline({
networkService: { requestStatus: jest.fn<any>().mockResolvedValue(failingStatus) },
providerRegistry: { probe: jest.fn<any>().mockResolvedValue(failingStatus) },
checkService: { toCheck: jest.fn<any>().mockReturnValue(check) },
egressService: { assessAfterFailure: jest.fn<any>().mockResolvedValue("degraded") },
});
@@ -385,7 +385,7 @@ describe("WorkerPipeline", () => {
for (const url of ["unix:///var/run/docker.sock", "/var/run/docker.sock"]) {
const check: Record<string, unknown> = { id: "check-1" };
const { pipeline, deps } = createPipeline({
networkService: { requestStatus: jest.fn<any>().mockResolvedValue({ ...failingStatus, type: "docker" }) },
providerRegistry: { probe: jest.fn<any>().mockResolvedValue({ ...failingStatus, type: "docker" }) },
checkService: { toCheck: jest.fn<any>().mockReturnValue(check) },
egressService: { assessAfterFailure: jest.fn<any>().mockResolvedValue("degraded") },
});
@@ -406,7 +406,7 @@ describe("WorkerPipeline", () => {
]) {
const check: Record<string, unknown> = { id: "check-1" };
const { pipeline, deps } = createPipeline({
networkService: { requestStatus: jest.fn<any>().mockResolvedValue({ ...failingStatus, type: monitor.type }) },
providerRegistry: { probe: jest.fn<any>().mockResolvedValue({ ...failingStatus, type: monitor.type }) },
checkService: { toCheck: jest.fn<any>().mockReturnValue(check) },
egressService: { assessAfterFailure: jest.fn<any>().mockResolvedValue("degraded") },
});
@@ -424,7 +424,7 @@ describe("WorkerPipeline", () => {
// them from a connection failure.
const check: Record<string, unknown> = { id: "check-1" };
const { pipeline, deps } = createPipeline({
networkService: { requestStatus: jest.fn<any>().mockResolvedValue({ ...failingStatus, peerResponded: true }) },
providerRegistry: { probe: jest.fn<any>().mockResolvedValue({ ...failingStatus, peerResponded: true }) },
checkService: { toCheck: jest.fn<any>().mockReturnValue(check) },
egressService: { assessAfterFailure: jest.fn<any>().mockResolvedValue("degraded") },
});
@@ -438,7 +438,7 @@ describe("WorkerPipeline", () => {
it("consults egress when the provider reports that nothing answered, despite an HTTP-shaped code", async () => {
const check: Record<string, unknown> = { id: "check-1" };
const { pipeline, deps } = createPipeline({
networkService: { requestStatus: jest.fn<any>().mockResolvedValue({ ...failingStatus, code: 503, peerResponded: false }) },
providerRegistry: { probe: jest.fn<any>().mockResolvedValue({ ...failingStatus, code: 503, peerResponded: false }) },
checkService: { toCheck: jest.fn<any>().mockReturnValue(check) },
egressService: { assessAfterFailure: jest.fn<any>().mockResolvedValue("degraded") },
});
@@ -453,7 +453,7 @@ describe("WorkerPipeline", () => {
it("flags a failing check as degraded when the egress service reports degraded egress", async () => {
const check: Record<string, unknown> = { id: "check-1" };
const { pipeline, deps } = createPipeline({
networkService: { requestStatus: jest.fn<any>().mockResolvedValue(failingStatus) },
providerRegistry: { probe: jest.fn<any>().mockResolvedValue(failingStatus) },
checkService: { toCheck: jest.fn<any>().mockReturnValue(check) },
egressService: { assessAfterFailure: jest.fn<any>().mockResolvedValue("degraded") },
});
@@ -472,7 +472,7 @@ describe("WorkerPipeline", () => {
it("clears the status window on the first degraded check of a spell", async () => {
const { pipeline, deps } = createPipeline({
networkService: { requestStatus: jest.fn<any>().mockResolvedValue(failingStatus) },
providerRegistry: { probe: jest.fn<any>().mockResolvedValue(failingStatus) },
egressService: { assessAfterFailure: jest.fn<any>().mockResolvedValue("degraded") },
});
@@ -483,7 +483,7 @@ describe("WorkerPipeline", () => {
it("does not re-write the status window for later degraded checks in the same spell", async () => {
const { pipeline, deps } = createPipeline({
networkService: { requestStatus: jest.fn<any>().mockResolvedValue(failingStatus) },
providerRegistry: { probe: jest.fn<any>().mockResolvedValue(failingStatus) },
egressService: { assessAfterFailure: jest.fn<any>().mockResolvedValue("degraded") },
});
@@ -494,7 +494,7 @@ describe("WorkerPipeline", () => {
it("leaves the status window alone when the probe found egress fine", async () => {
const { pipeline, deps } = createPipeline({
networkService: { requestStatus: jest.fn<any>().mockResolvedValue(failingStatus) },
providerRegistry: { probe: jest.fn<any>().mockResolvedValue(failingStatus) },
egressService: { assessAfterFailure: jest.fn<any>().mockResolvedValue("ok") },
});
@@ -506,7 +506,7 @@ describe("WorkerPipeline", () => {
it("flags a failing check as ok when the probe found egress fine", async () => {
const check: Record<string, unknown> = { id: "check-1" };
const { pipeline } = createPipeline({
networkService: { requestStatus: jest.fn<any>().mockResolvedValue(failingStatus) },
providerRegistry: { probe: jest.fn<any>().mockResolvedValue(failingStatus) },
checkService: { toCheck: jest.fn<any>().mockReturnValue(check) },
egressService: { assessAfterFailure: jest.fn<any>().mockResolvedValue("ok") },
});
@@ -519,7 +519,7 @@ describe("WorkerPipeline", () => {
it("leaves the field unset on a failing check when the egress check is disabled", async () => {
const check: Record<string, unknown> = { id: "check-1" };
const { pipeline, deps } = createPipeline({
networkService: { requestStatus: jest.fn<any>().mockResolvedValue(failingStatus) },
providerRegistry: { probe: jest.fn<any>().mockResolvedValue(failingStatus) },
checkService: { toCheck: jest.fn<any>().mockReturnValue(check) },
});
@@ -714,11 +714,7 @@ describe("WorkerPipeline", () => {
expect(result).toMatchObject({
monitor: statusChange.monitor,
check,
decision: expect.objectContaining({
shouldCreateIncident: true,
shouldSendNotification: true,
incidentReason: "status_down",
}),
decision: { transition: "status_down" },
});
});
@@ -730,7 +726,7 @@ describe("WorkerPipeline", () => {
const result = await pipeline.evaluateCheck(makeCheck({ status: true, statusCode: 200, message: "OK" }), makeMonitor({ status: "down" }));
expect(result.decision).toMatchObject({ shouldResolveIncident: true, shouldSendNotification: true });
expect(result.decision).toEqual({ transition: "status_up" });
});
it("produces a no-op decision when status did not change", async () => {
@@ -738,11 +734,7 @@ describe("WorkerPipeline", () => {
const result = await pipeline.evaluateCheck(makeCheck({ status: true, statusCode: 200, message: "OK" }), makeMonitor());
expect(result.decision).toMatchObject({
shouldCreateIncident: false,
shouldResolveIncident: false,
shouldSendNotification: false,
});
expect(result.decision).toEqual({ transition: null });
});
// ── degraded egress ───────────────────────────────────────────────────
@@ -757,13 +749,7 @@ describe("WorkerPipeline", () => {
expect(deps.statusService.updateMonitorStatus).not.toHaveBeenCalled();
expect(result.monitor).toBe(monitor);
expect(result).not.toHaveProperty("statusChange");
expect(result.decision).toEqual({
shouldCreateIncident: false,
shouldResolveIncident: false,
shouldSendNotification: false,
incidentReason: null,
notificationReason: null,
});
expect(result.decision).toEqual({ transition: null });
expect(deps.logger.debug).toHaveBeenCalledWith(expect.objectContaining({ message: expect.stringContaining("egress was degraded") }));
});
@@ -790,64 +776,38 @@ describe("WorkerPipeline", () => {
it("does nothing when statusChanged is false", async () => {
const decision = await decide(makeStatusChange({ status: "down", statusChanged: false, prevStatus: "up" }));
expect(decision).toEqual({
shouldCreateIncident: false,
shouldResolveIncident: false,
shouldSendNotification: false,
incidentReason: null,
notificationReason: null,
});
expect(decision).toEqual({ transition: null });
});
it("creates incident and notifies when monitor goes down", async () => {
it("reports status_down when the monitor goes down", async () => {
const decision = await decide(makeStatusChange({ status: "down", statusChanged: true, prevStatus: "up" }));
expect(decision).toMatchObject({
shouldCreateIncident: true,
shouldSendNotification: true,
incidentReason: "status_down",
notificationReason: "status_change",
});
expect(decision).toEqual({ transition: "status_down" });
});
it("creates incident and notifies when monitor is breached", async () => {
it("reports threshold_breach when the monitor is breached", async () => {
const decision = await decide(makeStatusChange({ status: "breached", statusChanged: true, prevStatus: "up" }));
expect(decision).toMatchObject({
shouldCreateIncident: true,
shouldSendNotification: true,
incidentReason: "threshold_breach",
notificationReason: "threshold_breach",
});
expect(decision).toEqual({ transition: "threshold_breach" });
});
it("resolves incident when monitor recovers from down", async () => {
it("reports status_up when the monitor recovers from down", async () => {
const decision = await decide(makeStatusChange({ status: "up", statusChanged: true, prevStatus: "down" }));
expect(decision).toMatchObject({
shouldResolveIncident: true,
shouldSendNotification: true,
shouldCreateIncident: false,
notificationReason: "status_change",
});
expect(decision).toEqual({ transition: "status_up" });
});
it("resolves incident with threshold_resolved when monitor recovers from breached", async () => {
it("reports threshold_resolved when the monitor recovers from breached", async () => {
const decision = await decide(makeStatusChange({ status: "up", statusChanged: true, prevStatus: "breached" }));
expect(decision).toMatchObject({
shouldResolveIncident: true,
shouldSendNotification: true,
shouldCreateIncident: false,
notificationReason: "threshold_resolved",
});
expect(decision).toEqual({ transition: "threshold_resolved" });
});
it("does not create or resolve for unhandled status transitions", async () => {
it("reports no transition for unhandled status changes", async () => {
const decision = await decide(makeStatusChange({ status: "paused", statusChanged: true, prevStatus: "up" }));
expect(decision).toMatchObject({ shouldCreateIncident: false, shouldResolveIncident: false, shouldSendNotification: false });
expect(decision).toEqual({ transition: null });
});
it("carries the threshold breaches onto a breached decision", async () => {
const thresholdBreaches = { cpu: true, memory: false, disk: true, temp: false };
const decision = await decide(makeStatusChange({ status: "breached", statusChanged: true, prevStatus: "up", thresholdBreaches }));
expect(decision.thresholdBreaches).toEqual(thresholdBreaches);
expect(decision).toEqual({ transition: "threshold_breach", thresholdBreaches });
});
it("leaves threshold breaches off a down decision even when the status change carries them", async () => {
@@ -865,12 +825,11 @@ describe("WorkerPipeline", () => {
// StatusService only sets thresholdBreaches for hardware monitors; a docker breach must not leave the key behind holding undefined.
it("leaves threshold breaches off a breached decision when the status change carries none", async () => {
const decision = await decide(makeStatusChange({ status: "breached", statusChanged: true, prevStatus: "up" }));
expect(decision).toMatchObject({ shouldCreateIncident: true, incidentReason: "threshold_breach" });
expect(decision).not.toHaveProperty("thresholdBreaches");
expect(decision).toEqual({ transition: "threshold_breach" });
});
// Each decision starts from a shared template. A transition must not leak its flags into the next decision
// the same pipeline makes, whether that is a no-op evaluation or a degraded-egress short-circuit.
// A transition must not leak into the next decision the same pipeline makes, whether that is a no-op
// evaluation or a degraded-egress short-circuit.
it("starts every decision clean after a transition on the same pipeline", async () => {
const updateMonitorStatus = jest
.fn<any>()
@@ -883,14 +842,8 @@ describe("WorkerPipeline", () => {
const second = await pipeline.evaluateCheck(failing, makeMonitor({ status: "down" }));
const degraded = await pipeline.evaluateCheck(makeCheck({ ...failing, egressStatus: "degraded" }), makeMonitor({ status: "down" }));
expect(first.decision.shouldCreateIncident).toBe(true);
const clean = {
shouldCreateIncident: false,
shouldResolveIncident: false,
shouldSendNotification: false,
incidentReason: null,
notificationReason: null,
};
expect(first.decision).toEqual({ transition: "status_down" });
const clean = { transition: null };
expect(second.decision).toEqual(clean);
expect(degraded.decision).toEqual(clean);
});