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 - name: Set up Node
uses: actions/setup-node@v4 uses: actions/setup-node@v4
with: with:
node-version: 22 node-version: 24
- name: Install client dependencies - name: Install client dependencies
working-directory: client working-directory: client
@@ -29,7 +29,7 @@ jobs:
- name: Set up Node - name: Set up Node
uses: actions/setup-node@v4 uses: actions/setup-node@v4
with: with:
node-version: 22 node-version: 24
- name: Install server dependencies - name: Install server dependencies
working-directory: server working-directory: server
+2 -2
View File
@@ -12,7 +12,7 @@ jobs:
- name: Set up Node - name: Set up Node
uses: actions/setup-node@v4 uses: actions/setup-node@v4
with: with:
node-version: 22 node-version: 24
- name: Install client dependencies - name: Install client dependencies
working-directory: client working-directory: client
@@ -30,7 +30,7 @@ jobs:
- name: Set up Node - name: Set up Node
uses: actions/setup-node@v4 uses: actions/setup-node@v4
with: with:
node-version: 22 node-version: 24
- name: Install server dependencies - name: Install server dependencies
working-directory: server working-directory: server
+1 -1
View File
@@ -12,7 +12,7 @@ jobs:
- name: Set up Node - name: Set up Node
uses: actions/setup-node@v4 uses: actions/setup-node@v4
with: with:
node-version: 22 node-version: 24
- name: Install server dependencies - name: Install server dependencies
working-directory: server working-directory: server
+1 -1
View File
@@ -62,7 +62,7 @@ VITE_APP_LOG_LEVEL="debug"
### Monorepo Structure ### Monorepo Structure
- `/client` - React 18 + TypeScript + Vite + MUI frontend - `/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) - `/docker` - Multi-environment Docker configs (dev, staging, prod, arm, mono)
### Backend Layers ### Backend Layers
+4
View File
@@ -2,6 +2,10 @@
"name": "client", "name": "client",
"private": true, "private": true,
"version": "3.12.0", "version": "3.12.0",
"engines": {
"node": ">=24",
"npm": ">=11"
},
"type": "module", "type": "module",
"scripts": { "scripts": {
"lockfile": "docker run --rm -v \"$PWD\":/w -w /w $(grep -m1 -o 'node:[^ ]*' ../docker/Dockerfile) npm install --package-lock-only", "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 DashboardCardKey,
type DashboardSortOrder, type DashboardSortOrder,
} from "@/Types/Dashboard"; } from "@/Types/Dashboard";
import type { SortOrder } from "@/Types/Query";
import { createSlice, type PayloadAction } from "@reduxjs/toolkit"; import { createSlice, type PayloadAction } from "@reduxjs/toolkit";
type ThemeMode = "light" | "dark"; type ThemeMode = "light" | "dark";
type ChartType = "histogram" | "line"; type ChartType = "histogram" | "line";
type TableName = type TableName =
| "dashboard"
| "monitors" | "monitors"
| "team" | "team"
| "maintenance" | "maintenance"
@@ -19,6 +21,8 @@ type TableName =
interface TableState { interface TableState {
rowsPerPage: number; rowsPerPage: number;
sortField?: string;
sortOrder?: SortOrder;
} }
interface SidebarState { interface SidebarState {
@@ -26,6 +30,7 @@ interface SidebarState {
} }
interface UIState { interface UIState {
dashboard: TableState;
monitors: TableState; monitors: TableState;
pagespeed: TableState; pagespeed: TableState;
team: TableState; team: TableState;
@@ -51,6 +56,9 @@ const initialMode: ThemeMode = window?.matchMedia?.("(prefers-color-scheme: dark
: "light"; : "light";
const initialState: UIState = { const initialState: UIState = {
dashboard: {
rowsPerPage: 100,
},
monitors: { monitors: {
rowsPerPage: 10, rowsPerPage: 10,
}, },
@@ -100,6 +108,14 @@ const uiSlice = createSlice({
const { table, value } = action.payload; const { table, value } = action.payload;
state[table].rowsPerPage = value; 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) => { toggleSidebar: (state) => {
state.sidebar.collapsed = !state.sidebar.collapsed; state.sidebar.collapsed = !state.sidebar.collapsed;
}, },
@@ -140,6 +156,7 @@ export type { UIState, ThemeMode, ChartType, TableName };
export default uiSlice.reducer; export default uiSlice.reducer;
export const { export const {
setRowsPerPage, setRowsPerPage,
setTableSort,
toggleSidebar, toggleSidebar,
setCollapsed, setCollapsed,
setMode, 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 { useDelete, useGet } from "@/Hooks/UseApi";
import { useBulkMonitorActions } from "@/Hooks/useBulkMonitorActions"; import { useBulkMonitorActions } from "@/Hooks/useBulkMonitorActions";
import useDebounce from "@/Hooks/useDebounce"; import useDebounce from "@/Hooks/useDebounce";
@@ -10,6 +10,7 @@ import {
type MonitorType, type MonitorType,
} from "@/Types/Monitor"; } from "@/Types/Monitor";
import type { Tag } from "@/Types/Tag"; import type { Tag } from "@/Types/Tag";
import type { SortOrder } from "@/Types/Query";
import { useCallback, useMemo, useState } from "react"; import { useCallback, useMemo, useState } from "react";
import { useDispatch, useSelector } from "react-redux"; import { useDispatch, useSelector } from "react-redux";
@@ -24,10 +25,12 @@ interface MonitorListConfig {
export const useMonitorListController = (config: MonitorListConfig) => { export const useMonitorListController = (config: MonitorListConfig) => {
const dispatch = useDispatch(); const dispatch = useDispatch();
const rowsPerPage = useSelector( const tableState = useSelector(
(state: RootState) => (state: RootState) => state.ui?.[config.rowsPerPageTable]
state.ui?.[config.rowsPerPageTable]?.rowsPerPage ?? config.rowsPerPageDefault
); );
const rowsPerPage = tableState?.rowsPerPage ?? config.rowsPerPageDefault;
const sortField = tableState?.sortField ?? config.initialSortField ?? "";
const sortOrder = tableState?.sortOrder ?? "asc";
// Filter states // Filter states
const [selectedTypes, setSelectedTypes] = useState<MonitorType[]>([]); const [selectedTypes, setSelectedTypes] = useState<MonitorType[]>([]);
@@ -36,8 +39,6 @@ export const useMonitorListController = (config: MonitorListConfig) => {
const [selectedTags, setSelectedTags] = useState<string[]>([]); const [selectedTags, setSelectedTags] = useState<string[]>([]);
const [page, setPage] = useState<number>(0); const [page, setPage] = useState<number>(0);
const [search, setSearch] = useState<string>(""); 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 [selectedMonitor, setSelectedMonitor] = useState<Monitor | null>(null);
const debouncedSearch = useDebounce<string>(search, 300); const debouncedSearch = useDebounce<string>(search, 300);
@@ -135,6 +136,11 @@ export const useMonitorListController = (config: MonitorListConfig) => {
setPage(0); setPage(0);
}; };
const setSort = (field: string, order: SortOrder) => {
dispatch(setTableSort({ field, order, table: config.rowsPerPageTable }));
setPage(0);
};
// Check for active filters // Check for active filters
const hasActiveFilters = Boolean( const hasActiveFilters = Boolean(
selectedTypes.length > 0 || selectedTypes.length > 0 ||
@@ -189,9 +195,8 @@ export const useMonitorListController = (config: MonitorListConfig) => {
rowsPerPage, rowsPerPage,
handleSetRowsPerPage, handleSetRowsPerPage,
sortField, sortField,
setSortField,
sortOrder, sortOrder,
setSortOrder, setSort,
// Delete // Delete
selectedMonitor, selectedMonitor,
+1 -1
View File
@@ -34,7 +34,7 @@ const Dashboard = () => {
types: [...MonitorTypes], types: [...MonitorTypes],
checksLimit: 1, checksLimit: 1,
refreshInterval: 30000, refreshInterval: 30000,
rowsPerPageTable: "monitors", rowsPerPageTable: "dashboard",
rowsPerPageDefault: 100, rowsPerPageDefault: 100,
}); });
@@ -8,6 +8,7 @@ import {
ColoredLabel, ColoredLabel,
} from "@/Components/design-elements"; } from "@/Components/design-elements";
import { SPACING } from "@/Utils/Theme/constants"; import { SPACING } from "@/Utils/Theme/constants";
import { getNextMonitorSort } from "@/Utils/MonitorUtils";
import type { Header } from "@/Components/design-elements/Table"; import type { Header } from "@/Components/design-elements/Table";
import { ActionsMenu, type ActionMenuItem } from "@/Components/actions-menu"; import { ActionsMenu, type ActionMenuItem } from "@/Components/actions-menu";
import { ArrowUp, ArrowDown } from "lucide-react"; import { ArrowUp, ArrowDown } from "lucide-react";
@@ -20,6 +21,7 @@ import { usePost } from "@/Hooks/UseApi";
import type { Monitor } from "@/Types/Monitor"; import type { Monitor } from "@/Types/Monitor";
import type { Tag } from "@/Types/Tag"; import type { Tag } from "@/Types/Tag";
import type { SortOrder } from "@/Types/Query";
import type { DockerContainerSummary } from "@/Types/Check"; import type { DockerContainerSummary } from "@/Types/Check";
import { Checkbox } from "@/Components/inputs"; import { Checkbox } from "@/Components/inputs";
@@ -34,9 +36,8 @@ interface DockerMonitorsTableProps {
refetch: () => void; refetch: () => void;
setSelectedMonitor: (monitor: Monitor | null) => void; setSelectedMonitor: (monitor: Monitor | null) => void;
sortField: string; sortField: string;
setSortField: (field: string) => void; sortOrder: SortOrder;
sortOrder: "asc" | "desc"; setSort: (field: string, order: SortOrder) => void;
setSortOrder: (order: "asc" | "desc") => void;
count: number; count: number;
page: number; page: number;
setPage: (page: number) => void; setPage: (page: number) => void;
@@ -52,9 +53,8 @@ export const DockerMonitorsTable = ({
refetch, refetch,
setSelectedMonitor, setSelectedMonitor,
sortField, sortField,
setSortField,
sortOrder, sortOrder,
setSortOrder, setSort,
count, count,
page, page,
setPage, setPage,
@@ -105,14 +105,8 @@ export const DockerMonitorsTable = ({
const handleSort = (e: React.MouseEvent, field: string) => { const handleSort = (e: React.MouseEvent, field: string) => {
e.preventDefault(); e.preventDefault();
e.stopPropagation(); e.stopPropagation();
if (sortField === field) { const nextSort = getNextMonitorSort(field, sortField, sortOrder);
const newOrder = sortOrder === "asc" ? "desc" : "asc"; setSort(nextSort.field, nextSort.order);
setSortOrder(newOrder);
} else {
setSortField(field);
setSortOrder("asc");
}
refetch();
}; };
const getActions = (monitor: Monitor): ActionMenuItem[] => { const getActions = (monitor: Monitor): ActionMenuItem[] => {
+1 -2
View File
@@ -31,9 +31,8 @@ const DockerMonitorsPage = () => {
setPage={c.setPage} setPage={c.setPage}
rowsPerPage={c.rowsPerPage} rowsPerPage={c.rowsPerPage}
sortField={c.sortField} sortField={c.sortField}
setSortField={c.setSortField}
sortOrder={c.sortOrder} sortOrder={c.sortOrder}
setSortOrder={c.setSortOrder} setSort={c.setSort}
setRowsPerPage={c.handleSetRowsPerPage} setRowsPerPage={c.handleSetRowsPerPage}
selectedRows={c.selectedRows} selectedRows={c.selectedRows}
onSelectionChange={c.setSelectedRows} onSelectionChange={c.setSelectedRows}
@@ -9,6 +9,7 @@ import {
ColoredLabel, ColoredLabel,
} from "@/Components/design-elements"; } from "@/Components/design-elements";
import { SPACING } from "@/Utils/Theme/constants"; import { SPACING } from "@/Utils/Theme/constants";
import { getNextMonitorSort } from "@/Utils/MonitorUtils";
import type { Header } from "@/Components/design-elements/Table"; import type { Header } from "@/Components/design-elements/Table";
import { ActionsMenu, type ActionMenuItem } from "@/Components/actions-menu"; import { ActionsMenu, type ActionMenuItem } from "@/Components/actions-menu";
import { ArrowUp, ArrowDown } from "lucide-react"; import { ArrowUp, ArrowDown } from "lucide-react";
@@ -21,6 +22,7 @@ import { usePost } from "@/Hooks/UseApi";
import type { Monitor } from "@/Types/Monitor"; import type { Monitor } from "@/Types/Monitor";
import type { Tag } from "@/Types/Tag"; import type { Tag } from "@/Types/Tag";
import type { SortOrder } from "@/Types/Query";
import { Checkbox } from "@/Components/inputs"; import { Checkbox } from "@/Components/inputs";
interface InfraMonitorsTableProps { interface InfraMonitorsTableProps {
@@ -29,9 +31,8 @@ interface InfraMonitorsTableProps {
refetch: () => void; refetch: () => void;
setSelectedMonitor: (monitor: Monitor | null) => void; setSelectedMonitor: (monitor: Monitor | null) => void;
sortField: string; sortField: string;
setSortField: (field: string) => void; sortOrder: SortOrder;
sortOrder: "asc" | "desc"; setSort: (field: string, order: SortOrder) => void;
setSortOrder: (order: "asc" | "desc") => void;
count: number; count: number;
page: number; page: number;
setPage: (page: number) => void; setPage: (page: number) => void;
@@ -47,9 +48,8 @@ export const InfraMonitorsTable = ({
refetch, refetch,
setSelectedMonitor, setSelectedMonitor,
sortField, sortField,
setSortField,
sortOrder, sortOrder,
setSortOrder, setSort,
count, count,
page, page,
setPage, setPage,
@@ -100,14 +100,8 @@ export const InfraMonitorsTable = ({
const handleSort = (e: React.MouseEvent, field: string) => { const handleSort = (e: React.MouseEvent, field: string) => {
e.preventDefault(); e.preventDefault();
e.stopPropagation(); e.stopPropagation();
if (sortField === field) { const nextSort = getNextMonitorSort(field, sortField, sortOrder);
const newOrder = sortOrder === "asc" ? "desc" : "asc"; setSort(nextSort.field, nextSort.order);
setSortOrder(newOrder);
} else {
setSortField(field);
setSortOrder("asc");
}
refetch();
}; };
const getActions = (monitor: Monitor): ActionMenuItem[] => { const getActions = (monitor: Monitor): ActionMenuItem[] => {
@@ -31,9 +31,8 @@ const InfrastructureMonitors = () => {
setPage={c.setPage} setPage={c.setPage}
rowsPerPage={c.rowsPerPage} rowsPerPage={c.rowsPerPage}
sortField={c.sortField} sortField={c.sortField}
setSortField={c.setSortField}
sortOrder={c.sortOrder} sortOrder={c.sortOrder}
setSortOrder={c.setSortOrder} setSort={c.setSort}
setRowsPerPage={c.handleSetRowsPerPage} setRowsPerPage={c.handleSetRowsPerPage}
selectedRows={c.selectedRows} selectedRows={c.selectedRows}
onSelectionChange={c.setSelectedRows} onSelectionChange={c.setSelectedRows}
@@ -9,6 +9,7 @@ import {
ColoredLabel, ColoredLabel,
} from "@/Components/design-elements"; } from "@/Components/design-elements";
import { LAYOUT, SPACING } from "@/Utils/Theme/constants"; import { LAYOUT, SPACING } from "@/Utils/Theme/constants";
import { getNextMonitorSort } from "@/Utils/MonitorUtils";
import { HistogramPageSpeed } from "@/Components/monitors"; import { HistogramPageSpeed } from "@/Components/monitors";
import { ArrowDown, ArrowUp } from "lucide-react"; import { ArrowDown, ArrowUp } from "lucide-react";
import type { Header } from "@/Components/design-elements/Table"; 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 { Monitor, MonitorWithChecks } from "@/Types/Monitor";
import type { Tag } from "@/Types/Tag"; import type { Tag } from "@/Types/Tag";
import type { SortOrder } from "@/Types/Query";
import type { ActionMenuItem } from "@/Components/actions-menu"; import type { ActionMenuItem } from "@/Components/actions-menu";
export const PageSpeedMonitorsTable = ({ export const PageSpeedMonitorsTable = ({
@@ -30,9 +32,8 @@ export const PageSpeedMonitorsTable = ({
refetch, refetch,
setSelectedMonitor, setSelectedMonitor,
sortField, sortField,
setSortField,
sortOrder, sortOrder,
setSortOrder, setSort,
count, count,
page, page,
setPage, setPage,
@@ -44,9 +45,8 @@ export const PageSpeedMonitorsTable = ({
refetch: Function; refetch: Function;
setSelectedMonitor: Function; setSelectedMonitor: Function;
sortField: string; sortField: string;
setSortField: (field: string) => void; sortOrder: SortOrder;
sortOrder: "asc" | "desc"; setSort: (field: string, order: SortOrder) => void;
setSortOrder: (order: "asc" | "desc") => void;
count: number; count: number;
page: number; page: number;
setPage: (page: number) => void; setPage: (page: number) => void;
@@ -81,14 +81,8 @@ export const PageSpeedMonitorsTable = ({
const handleSort = (e: any, field: string) => { const handleSort = (e: any, field: string) => {
e.preventDefault(); e.preventDefault();
e.stopPropagation(); e.stopPropagation();
if (sortField === field) { const nextSort = getNextMonitorSort(field, sortField, sortOrder);
const newOrder = sortOrder === "asc" ? "desc" : "asc"; setSort(nextSort.field, nextSort.order);
setSortOrder(newOrder);
} else {
setSortField(field);
setSortOrder("asc");
}
refetch();
}; };
const getActions = (monitor: Monitor): ActionMenuItem[] => { const getActions = (monitor: Monitor): ActionMenuItem[] => {
@@ -46,9 +46,8 @@ const PageSpeedMonitorsPage = () => {
setPage={c.setPage} setPage={c.setPage}
rowsPerPage={c.rowsPerPage} rowsPerPage={c.rowsPerPage}
sortField={c.sortField} sortField={c.sortField}
setSortField={c.setSortField}
sortOrder={c.sortOrder} sortOrder={c.sortOrder}
setSortOrder={c.setSortOrder} setSort={c.setSort}
setRowsPerPage={c.handleSetRowsPerPage} setRowsPerPage={c.handleSetRowsPerPage}
/> />
</MonitorListPage> </MonitorListPage>
@@ -24,8 +24,9 @@ import type { ActionMenuItem } from "@/Components/actions-menu";
import type { RootState } from "@/Types/state"; import type { RootState } from "@/Types/state";
import { Checkbox } from "@/Components/inputs"; import { Checkbox } from "@/Components/inputs";
import type { Tag } from "@/Types/Tag"; import type { Tag } from "@/Types/Tag";
import type { SortOrder } from "@/Types/Query";
import { SPACING } from "@/Utils/Theme/constants"; import { SPACING } from "@/Utils/Theme/constants";
import { getUptimePercentageColor } from "@/Utils/MonitorUtils"; import { getNextMonitorSort, getUptimePercentageColor } from "@/Utils/MonitorUtils";
import { formatPercentage } from "@/Utils/FormatUtils"; import { formatPercentage } from "@/Utils/FormatUtils";
interface MonitorTableProps { interface MonitorTableProps {
@@ -34,9 +35,8 @@ interface MonitorTableProps {
refetch: () => void; refetch: () => void;
setSelectedMonitor: (monitor: Monitor | null) => void; setSelectedMonitor: (monitor: Monitor | null) => void;
sortField: string; sortField: string;
setSortField: (field: string) => void; sortOrder: SortOrder;
sortOrder: "asc" | "desc"; setSort: (field: string, order: SortOrder) => void;
setSortOrder: (order: "asc" | "desc") => void;
count: number; count: number;
page: number; page: number;
setPage: (page: number) => void; setPage: (page: number) => void;
@@ -52,9 +52,8 @@ export const MonitorTable = ({
refetch, refetch,
setSelectedMonitor, setSelectedMonitor,
sortField, sortField,
setSortField,
sortOrder, sortOrder,
setSortOrder, setSort,
count, count,
page, page,
setPage, setPage,
@@ -91,14 +90,8 @@ export const MonitorTable = ({
const handleSort = (e: React.MouseEvent, field: string) => { const handleSort = (e: React.MouseEvent, field: string) => {
e.preventDefault(); e.preventDefault();
e.stopPropagation(); e.stopPropagation();
if (sortField === field) { const nextSort = getNextMonitorSort(field, sortField, sortOrder);
const newOrder = sortOrder === "asc" ? "desc" : "asc"; setSort(nextSort.field, nextSort.order);
setSortOrder(newOrder);
} else {
setSortField(field);
setSortOrder("asc");
}
refetch();
}; };
const isBrowserOpenable = (monitor: Monitor): boolean => { const isBrowserOpenable = (monitor: Monitor): boolean => {
+1 -2
View File
@@ -30,9 +30,8 @@ const UptimeMonitorsPage = () => {
setPage={monitorListController.setPage} setPage={monitorListController.setPage}
rowsPerPage={monitorListController.rowsPerPage} rowsPerPage={monitorListController.rowsPerPage}
sortField={monitorListController.sortField} sortField={monitorListController.sortField}
setSortField={monitorListController.setSortField}
sortOrder={monitorListController.sortOrder} sortOrder={monitorListController.sortOrder}
setSortOrder={monitorListController.setSortOrder} setSort={monitorListController.setSort}
setRowsPerPage={monitorListController.handleSetRowsPerPage} setRowsPerPage={monitorListController.handleSetRowsPerPage}
selectedRows={monitorListController.selectedRows} selectedRows={monitorListController.selectedRows}
onSelectionChange={monitorListController.setSelectedRows} onSelectionChange={monitorListController.setSelectedRows}
+3
View File
@@ -1,2 +1,5 @@
export const DateRanges = ["recent", "hour", "day", "week", "month", "all"] as const; export const DateRanges = ["recent", "hour", "day", "week", "month", "all"] as const;
export type DateRange = (typeof DateRanges)[number]; 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 { MonitorStatus, MonitorType } from "@/Types/Monitor";
import type { SortOrder } from "@/Types/Query";
import type { PaletteKey } from "@/Utils/Theme/Theme"; import type { PaletteKey } from "@/Utils/Theme/Theme";
import type { ValueType } from "@/Components/design-elements/StatusLabel"; import type { ValueType } from "@/Components/design-elements/StatusLabel";
import { import {
@@ -10,6 +11,16 @@ import {
type DockerLogLine, type DockerLogLine,
} from "@/Types/Check"; } 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 => { export const getMonitorPath = (type: MonitorType): string => {
const pathMap: Record<MonitorType, string> = { const pathMap: Record<MonitorType, string> = {
http: "uptime", http: "uptime",
+2 -2
View File
@@ -4,7 +4,7 @@
# Build on the host arch (BUILDPLATFORM) rather than under QEMU — the output is # 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 # pure JS so it's arch-independent, and this avoids the esbuild host/native
# binary version mismatch under emulation. # 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 WORKDIR /app/client
@@ -19,7 +19,7 @@ RUN npm run build
# --------------------- # ---------------------
# Backend stage # Backend stage
# --------------------- # ---------------------
FROM node:22-slim AS app FROM node:24-slim AS app
# Install ping # Install ping
RUN apt-get update \ 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": "", "author": "",
"license": "ISC", "license": "ISC",
"engines": { "engines": {
"node": ">=20" "node": ">=24",
"npm": ">=11"
}, },
"dependencies": { "dependencies": {
"@csstools/css-tokenizer": "^3.0.4", "@csstools/css-tokenizer": "^3.0.4",
@@ -45,7 +46,7 @@
"dayjs": "^1.11.23", "dayjs": "^1.11.23",
"dockerode": "5.0.0", "dockerode": "5.0.0",
"dotenv": "^16.4.5", "dotenv": "^16.4.5",
"express": "^4.19.2", "express": "5.2.1",
"express-rate-limit": "^8.5.1", "express-rate-limit": "^8.5.1",
"gamedig": "5.2.0", "gamedig": "5.2.0",
"got": "14.4.7", "got": "14.4.7",
@@ -58,11 +59,11 @@
"jsonwebtoken": "9.0.2", "jsonwebtoken": "9.0.2",
"mjml": "^5.0.0-alpha.4", "mjml": "^5.0.0-alpha.4",
"mongoose": "^8.3.3", "mongoose": "^8.3.3",
"multer": "^1.4.5-lts.2", "multer": "2.4.0",
"nodemailer": "9.0.5", "nodemailer": "9.1.1",
"ping": "0.4.4", "ping": "0.4.4",
"re2": "^1.24.1", "re2": "^1.24.1",
"sharp": "0.35.3", "sharp": "0.35.4",
"ssl-checker": "2.0.10", "ssl-checker": "2.0.10",
"swagger-ui-express": "5.0.1", "swagger-ui-express": "5.0.1",
"tldts": "^7.4.10", "tldts": "^7.4.10",
+1 -64
View File
@@ -1,5 +1,5 @@
import { AppError } from "@/utils/AppError.js"; 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 { UserRole } from "@/domain/users/user.type.js";
import sslChecker, { SSLDetails } from "ssl-checker"; import sslChecker, { SSLDetails } from "ssl-checker";
import * as whoiser from "whoiser"; import * as whoiser from "whoiser";
@@ -124,23 +124,6 @@ export const fetchMonitorDomain = async (
return domainExpiry; 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 => { export const extractString = (value: unknown): string | undefined => {
const candidate = Array.isArray(value) ? value[0] : value; const candidate = Array.isArray(value) ? value[0] : value;
if (typeof candidate !== "string" || candidate.length === 0) { if (typeof candidate !== "string" || candidate.length === 0) {
@@ -149,52 +132,6 @@ export const extractString = (value: unknown): string | undefined => {
return candidate; 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 => { export const requireTeamId = (teamId?: string): string => {
if (!teamId) { if (!teamId) {
throw new AppError({ message: "Team ID is required", status: 400 }); 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) => { export const sanitizeQuery = (options = {}): ((req: Request, res: Response, next: NextFunction) => void) => {
return (req: Request, res: Response, next: NextFunction) => { return (req: Request, res: Response, next: NextFunction) => {
if (req.query && typeof req.query === "object") { const sanitized = Object.fromEntries(
for (const key of Object.keys(req.query)) { Object.entries(req.query).map(([key, value]) => [key, typeof value === "string" ? sanitizeInput(value, options) : value])
const value = req.query[key]; );
if (typeof value === "string") { Object.defineProperty(req, "query", { value: sanitized, writable: true, configurable: true, enumerable: true });
req.query[key] = sanitizeInput(value, options);
}
}
}
next(); next();
}; };
}; };
+1 -1
View File
@@ -122,7 +122,7 @@ export const createApp = ({
setupRoutes(app, controllers, services); setupRoutes(app, controllers, services);
// FE routes // FE routes
app.get("*", (req, res) => { app.get("/{*splat}", (req, res) => {
res.sendFile(path.join(frontendPath, "index.html")); res.sendFile(path.join(frontendPath, "index.html"));
}); });
app.use(handleErrors(services.logger)); app.use(handleErrors(services.logger));
+18 -19
View File
@@ -1,5 +1,4 @@
import { Resolver } from "dns/promises"; import { Resolver } from "dns/promises";
import axios from "axios";
import got from "got"; import got from "got";
import ping from "ping"; import ping from "ping";
import Docker from "dockerode"; import Docker from "dockerode";
@@ -13,7 +12,7 @@ import mongoose from "mongoose";
import { EnvConfig } from "@/domain/app-settings/app-settings.service.js"; import { EnvConfig } from "@/domain/app-settings/app-settings.service.js";
import { SharedServices } from "@/config/services.shared.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 { IBufferService, BufferService } from "@/service/bufferService.js";
import { IStatusService, StatusService } from "@/service/statusService.js"; import { IStatusService, StatusService } from "@/service/statusService.js";
import { SecretsRotationService } from "@/service/encryption/secretsRotationService.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 { IncidentReactor } from "@/worker/reactors/reactor.incident.js";
import { ReactorDispatcher } from "@/worker/reactors/reactor.dispatcher.js"; import { ReactorDispatcher } from "@/worker/reactors/reactor.dispatcher.js";
import { DBQueueWorker } from "@/worker/worker.db-queue.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"; import { IEgressService, EgressService } from "@/domain/egress/egress.service.js";
// Network providers // Network providers
import { PingProvider } from "@/service/network/PingProvider.js"; import { PingProvider } from "@/service/networkProviders/PingProvider.js";
import { HttpProvider } from "@/service/network/HttpProvider.js"; import { HttpProvider } from "@/service/networkProviders/HttpProvider.js";
import { AdvancedMatcher } from "@/service/network/AdvancedMatcher.js"; import { AdvancedMatcher } from "@/service/networkProviders/AdvancedMatcher.js";
import { PageSpeedProvider } from "@/service/network/PageSpeedProvider.js"; import { PageSpeedProvider } from "@/service/networkProviders/PageSpeedProvider.js";
import { HardwareProvider } from "@/service/network/HardwareProvider.js"; import { HardwareProvider } from "@/service/networkProviders/HardwareProvider.js";
import { DockerProvider } from "@/service/network/DockerProvider.js"; import { DockerProvider } from "@/service/networkProviders/DockerProvider.js";
import { PortProvider } from "@/service/network/PortProvider.js"; import { PortProvider } from "@/service/networkProviders/PortProvider.js";
import { GameProvider } from "@/service/network/GameProvider.js"; import { GameProvider } from "@/service/networkProviders/GameProvider.js";
import { GrpcProvider } from "@/service/network/GrpcProvider.js"; import { GrpcProvider } from "@/service/networkProviders/GrpcProvider.js";
import { WebSocketProvider } from "@/service/network/WebSocketProvider.js"; import { WebSocketProvider } from "@/service/networkProviders/WebSocketProvider.js";
import { DNSProvider } from "@/service/network/DNSProvider.js"; import { DNSProvider } from "@/service/networkProviders/DNSProvider.js";
import { AppError } from "@/utils/AppError.js"; import { AppError } from "@/utils/AppError.js";
import { WorkerPipeline } from "@/worker/worker.pipeline.js"; import { WorkerPipeline } from "@/worker/worker.pipeline.js";
export interface WorkerServices { export interface WorkerServices {
worker: IQueueWorker; worker: IQueueWorker;
networkService: INetworkService; networkService: IProviderRegistry;
bufferService: IBufferService; bufferService: IBufferService;
statusService: IStatusService; statusService: IStatusService;
egressService: IEgressService; egressService: IEgressService;
@@ -87,7 +86,7 @@ export const buildWorker = async (shared: SharedServices, envSettings: EnvConfig
const webSocketProvider = new WebSocketProvider(WebSocket); const webSocketProvider = new WebSocketProvider(WebSocket);
const dnsProvider = new DNSProvider(() => new Resolver()); const dnsProvider = new DNSProvider(() => new Resolver());
const networkService = new NetworkService(axios, logger, [ const providerRegistry = new ProviderRegistry([
pingProvider, pingProvider,
httpProvider, httpProvider,
pageSpeedProvider, pageSpeedProvider,
@@ -105,7 +104,7 @@ export const buildWorker = async (shared: SharedServices, envSettings: EnvConfig
pipeline.ingestChecks(checks) pipeline.ingestChecks(checks)
); );
const statusService = new StatusService(logger, monitorsRepository, monitorStatsRepository); 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 notificationReactor = new NotificationReactor(notificationsService);
const incidentReactor = new IncidentReactor(incidentService); const incidentReactor = new IncidentReactor(incidentService);
@@ -118,7 +117,7 @@ export const buildWorker = async (shared: SharedServices, envSettings: EnvConfig
checksRepository, checksRepository,
jobsRepository, jobsRepository,
checkService, checkService,
networkService, providerRegistry: providerRegistry,
proxyResolver, proxyResolver,
bufferService, bufferService,
dockerLogsService, 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 { IEgressStateRepository } from "@/domain/egress/egress-state.repository.interface.js";
import type { IJobsRepository } from "@/domain/jobs/job.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 { jobId, type Job, type JobSeed } from "@/domain/jobs/job.type.js";
import type { INetworkService } from "@/service/networkService.js"; import type { IProviderRegistry } from "@/service/networkProviders/providerRegistry.js";
import type { IProxyResolver } from "@/service/network/ProxyResolver.js"; import type { IProxyResolver } from "@/service/networkProviders/ProxyResolver.js";
import type { ILogger } from "@/utils/logger.js"; import type { ILogger } from "@/utils/logger.js";
import { import {
EGRESS_RECOVERY_POLL_SECONDS, EGRESS_RECOVERY_POLL_SECONDS,
@@ -13,7 +13,7 @@ import {
type EgressProbeResult, type EgressProbeResult,
type EgressStatus, type EgressStatus,
} from "@/domain/egress/egress.type.js"; } 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"; import { parseEgressTarget, type EgressTarget } from "@/utils/egressTarget.js";
const SERVICE_NAME = "EgressService"; const SERVICE_NAME = "EgressService";
@@ -54,7 +54,7 @@ export class EgressService implements IEgressService {
private settingsService: ISettingsService, private settingsService: ISettingsService,
private egressStateRepository: IEgressStateRepository, private egressStateRepository: IEgressStateRepository,
private jobsRepository: IJobsRepository, private jobsRepository: IJobsRepository,
private networkService: INetworkService, private providerRegistry: IProviderRegistry,
private proxyResolver: IProxyResolver, private proxyResolver: IProxyResolver,
private logger: ILogger private logger: ILogger
) {} ) {}
@@ -100,7 +100,7 @@ export class EgressService implements IEgressService {
const { response, responseTime, error } = await timeRequest(async () => { const { response, responseTime, error } = await timeRequest(async () => {
const proxyUrl = await this.proxyResolver.resolve(monitor); const proxyUrl = await this.proxyResolver.resolve(monitor);
return Promise.race([ return Promise.race([
this.networkService.requestStatus(monitor, { proxyUrl }), this.providerRegistry.probe(monitor, { proxyUrl }),
new Promise<never>((_, reject) => { new Promise<never>((_, reject) => {
timer = setTimeout(() => reject(new Error(`Egress probe timed out after ${PROBE_TIMEOUT_MS}ms`)), PROBE_TIMEOUT_MS); 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> => { handleIncident = async (monitor: Monitor, decision: MonitorActionDecision, check: Check): Promise<Incident | null> => {
if (!decision.shouldCreateIncident && !decision.shouldResolveIncident) { const { transition } = decision;
return null; if (transition === null) return null;
}
const activeIncident = await this.incidentsRepository.findActiveByMonitorId(monitor.id, monitor.teamId); const activeIncident = await this.incidentsRepository.findActiveByMonitorId(monitor.id, monitor.teamId);
if (decision.shouldCreateIncident) { switch (transition) {
if (activeIncident) { case "status_down":
return activeIncident; case "threshold_breach": {
} else { if (activeIncident) return activeIncident;
let statusCode = check.statusCode; // A threshold breach has no HTTP status
let message: string | undefined; const isBreach = transition === "threshold_breach";
return await this.incidentsRepository.create({
// 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 = {
monitorId: monitor.id, monitorId: monitor.id,
teamId: monitor.teamId, teamId: monitor.teamId,
startTime: Date.now().toString(), startTime: Date.now().toString(),
status: true, status: true,
statusCode, statusCode: isBreach ? 9999 : check.statusCode,
message, message: isBreach ? this.notificationMessageBuilder.buildThresholdBreachMessage(monitor, check, decision.thresholdBreaches) : undefined,
}; });
return await this.incidentsRepository.create(incident); }
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) => { resolveIncident = async (incidentId: string, userId: string, teamId: string, comment?: string, userEmail?: string) => {
@@ -101,7 +101,12 @@ class MongoMonitorsRepository implements IMonitorsRepository {
private pageOptions = (config: TeamQueryConfig) => { private pageOptions = (config: TeamQueryConfig) => {
const { page = 0, rowsPerPage = 0, field = "createdAt", order = "desc" } = config ?? {}; 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[]> => { findByTeamId = async (teamId: string, config: TeamQueryConfig, options?: { includeRecentChecks?: boolean }): Promise<Monitor[]> => {
@@ -39,24 +39,21 @@ export class NotificationMessageBuilder implements INotificationMessageBuilder {
clientHost, clientHost,
metadata: { metadata: {
teamId: monitor.teamId, teamId: monitor.teamId,
notificationReason: decision.notificationReason,
}, },
}; };
} }
private determineNotificationType(decision: MonitorActionDecision, monitor: Monitor): NotificationType { 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"; const isDocker = monitor.type === "docker";
switch (decision.notificationReason) { switch (decision.transition) {
case "status_down":
return "monitor_down";
case "threshold_breach": case "threshold_breach":
return isDocker ? "container_breach" : "threshold_breach"; return isDocker ? "container_breach" : "threshold_breach";
case "threshold_resolved": case "threshold_resolved":
return isDocker ? "container_resolved" : "threshold_resolved"; return isDocker ? "container_resolved" : "threshold_resolved";
default: case "status_up":
case null:
return "monitor_up"; return "monitor_up";
} }
} }
@@ -115,9 +115,7 @@ export class NotificationsService implements INotificationsService {
}; };
handleNotifications = async (monitor: Monitor, check: Check, decision: MonitorActionDecision) => { handleNotifications = async (monitor: Monitor, check: Check, decision: MonitorActionDecision) => {
if (!decision.shouldSendNotification) { if (decision.transition === null) return false;
return false;
}
// Send notifications based on decision // Send notifications based on decision
return await this.sendNotifications(monitor, check, decision); return await this.sendNotifications(monitor, check, decision);
@@ -1,5 +1,4 @@
import type { HardwareMetricKey } from "@/domain/monitors/monitor.type.js"; import type { HardwareMetricKey } from "@/domain/monitors/monitor.type.js";
import { MonitorActionDecision } from "@/worker/worker.interface.js";
export const NotificationChannels = [ export const NotificationChannels = [
"email", "email",
@@ -120,6 +119,5 @@ export interface NotificationMessage {
clientHost: string; clientHost: string;
metadata: { metadata: {
teamId: string; teamId: string;
notificationReason: MonitorActionDecision["notificationReason"];
}; };
} }
+3 -30
View File
@@ -55,42 +55,15 @@ export class BufferService implements IBufferService {
} }
addToBuffer(check: Check) { addToBuffer(check: Check) {
try { this.buffer.push(check);
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,
});
}
} }
addGeoCheckToBuffer(geoCheck: GeoCheck) { addGeoCheckToBuffer(geoCheck: GeoCheck) {
try { this.geoBuffer.push(geoCheck);
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,
});
}
} }
addDockerLogToBuffer(dockerLog: DockerLog): void { addDockerLogToBuffer(dockerLog: DockerLog): void {
try { this.dockerLogBuffer.push(dockerLog);
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,
});
}
} }
scheduleNextFlush() { 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 { MonitorType } from "@/domain/monitors/monitor.type.js";
import type { ILogger } from "@/utils/logger.js"; import type { ILogger } from "@/utils/logger.js";
import got from "got"; import got from "got";
import { isStatusUp } from "@/service/network/utils.js"; import { isStatusUp } from "@/service/networkProviders/utils.js";
const SERVICE_NAME = "GlobalPingService"; const SERVICE_NAME = "GlobalPingService";
const GLOBAL_PING_API_BASE = "https://api.globalping.io/v1"; const GLOBAL_PING_API_BASE = "https://api.globalping.io/v1";
@@ -1,10 +1,10 @@
import { DNSStatusPayload, MonitorStatusResponse } from "@/types/network.js"; 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 dns from "dns";
import type { Resolver } from "dns/promises"; import type { Resolver } from "dns/promises";
import { Monitor, MonitorType } from "@/domain/monitors/monitor.type.js"; import { Monitor, MonitorType } from "@/domain/monitors/monitor.type.js";
import { AppError } from "@/utils/AppError.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"; import { NETWORK_ERROR } from "@/types/network.js";
const SERVICE_NAME = "DNSProvider"; const SERVICE_NAME = "DNSProvider";
@@ -1,7 +1,7 @@
import https from "node:https"; import https from "node:https";
import { IStatusProvider } from "@/service/network/IStatusProvider.js"; import { IStatusProvider } from "@/service/networkProviders/IStatusProvider.js";
import { HttpProvider } from "@/service/network/HttpProvider.js"; import { HttpProvider } from "@/service/networkProviders/HttpProvider.js";
import { CaptureDockerContainer, CaptureDockerStatusPayload, CheckContext, DockerStatusPayload, MonitorStatusResponse } from "@/types/network.js"; import { CaptureDockerContainer, CaptureDockerStatusPayload, CheckContext, DockerStatusPayload, MonitorStatusResponse } from "@/types/network.js";
import { import {
@@ -24,7 +24,7 @@ import { Monitor, MonitorType } from "@/domain/monitors/monitor.type.js";
import { ILogger } from "@/utils/logger.js"; import { ILogger } from "@/utils/logger.js";
import { AppError } from "@/utils/AppError.js"; import { AppError } from "@/utils/AppError.js";
import Dockerode from "dockerode"; 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 { NETWORK_ERROR } from "@/types/network.js";
import { IEncryptionService } from "@/service/encryption/encryptionService.js"; import { IEncryptionService } from "@/service/encryption/encryptionService.js";
import { DOCKER_TLS_URL, isCaptureDockerUrl, isDockerTlsUrl } from "@/utils/dockerHost.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 { GameStatusPayload, MonitorStatusResponse } from "@/types/network.js";
import { Monitor, MonitorType } from "@/domain/monitors/monitor.type.js"; import { Monitor, MonitorType } from "@/domain/monitors/monitor.type.js";
import { GameDig } from "gamedig"; 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 { GrpcStatusPayload, MonitorStatusResponse } from "@/types/network.js";
import { Monitor, MonitorType } from "@/domain/monitors/monitor.type.js"; import { Monitor, MonitorType } from "@/domain/monitors/monitor.type.js";
import { AppError } from "@/utils/AppError.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 grpc from "@grpc/grpc-js";
import * as protoLoader from "@grpc/proto-loader"; 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 { HardwareStatusPayload, MonitorStatusResponse } from "@/types/network.js";
import { Monitor, MonitorType } from "@/domain/monitors/monitor.type.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"; import { AppError } from "@/utils/AppError.js";
export class HardwareProvider implements IStatusProvider<HardwareStatusPayload> { export class HardwareProvider implements IStatusProvider<HardwareStatusPayload> {
@@ -1,12 +1,12 @@
import { type Got, HTTPError, RequestError } from "got"; import { type Got, HTTPError, RequestError } from "got";
import { IAdvancedMatcher } from "@/service/network/AdvancedMatcher.js"; import { IAdvancedMatcher } from "@/service/networkProviders/AdvancedMatcher.js";
import { IStatusProvider } from "@/service/network/IStatusProvider.js"; import { IStatusProvider } from "@/service/networkProviders/IStatusProvider.js";
import { HttpStatusPayload } from "@/types/network.js"; import { HttpStatusPayload } from "@/types/network.js";
import { MonitorStatusResponse } from "@/types/network.js"; import { MonitorStatusResponse } from "@/types/network.js";
import { Agent as HttpsAgent } from "https"; import { Agent as HttpsAgent } from "https";
import { Agent as HttpAgent } from "http"; import { Agent as HttpAgent } from "http";
import { Monitor, MonitorType } from "@/domain/monitors/monitor.type.js"; 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 { NETWORK_ERROR } from "@/types/network.js";
import CacheableLookup from "cacheable-lookup"; import CacheableLookup from "cacheable-lookup";
import { HttpProxyAgent, HttpsProxyAgent } from "hpagent"; 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 { MonitorStatusResponse, PageSpeedStatusPayload } from "@/types/network.js";
import { Monitor, MonitorType, DefaultPageSpeedStrategy } from "@/domain/monitors/monitor.type.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 { ISettingsService } from "@/domain/app-settings/app-settings.service.js";
import { ILogger } from "@/utils/logger.js"; import { ILogger } from "@/utils/logger.js";
import { AppError } from "@/utils/AppError.js"; import { AppError } from "@/utils/AppError.js";
@@ -5,7 +5,7 @@ import { MonitorStatusResponse } from "@/types/network.js";
import { AppError } from "@/utils/AppError.js"; import { AppError } from "@/utils/AppError.js";
import ping from "ping"; import ping from "ping";
import * as net from "net"; import * as net from "net";
import { timeRequest } from "@/service/network/utils.js"; import { timeRequest } from "@/service/networkProviders/utils.js";
const SERVICE_NAME = "PingProvider"; const SERVICE_NAME = "PingProvider";
type Ping = typeof ping; 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 { PortStatusPayload, MonitorStatusResponse } from "@/types/network.js";
import { Monitor, MonitorType } from "@/domain/monitors/monitor.type.js"; import { Monitor, MonitorType } from "@/domain/monitors/monitor.type.js";
import { AppError } from "@/utils/AppError.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 { NETWORK_ERROR } from "@/types/network.js";
import * as net from "net"; import * as net from "net";
type NetType = typeof 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 { WebSocketStatusPayload, MonitorStatusResponse } from "@/types/network.js";
import { Monitor, MonitorType } from "@/domain/monitors/monitor.type.js"; import { Monitor, MonitorType } from "@/domain/monitors/monitor.type.js";
import { AppError } from "@/utils/AppError.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 { NETWORK_ERROR } from "@/types/network.js";
import type WebSocket from "ws"; 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 JobHandler = (job: Job) => Promise<void>;
export type JobHandlers = Record<JobType, JobHandler>; 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 { export interface MonitorActionDecision {
shouldCreateIncident: boolean; transition: MonitorTransition | null;
shouldResolveIncident: boolean;
shouldSendNotification: boolean;
incidentReason: "status_down" | "threshold_breach" | null;
notificationReason: "status_change" | "threshold_breach" | "threshold_resolved" | null;
thresholdBreaches?: HardwareBreaches; 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 { IMonitorsRepository } from "@/domain/monitors/monitor.repository.interface.js";
import { isEgressAttributable, Monitor } from "@/domain/monitors/monitor.type.js"; import { isEgressAttributable, Monitor } from "@/domain/monitors/monitor.type.js";
import { IBufferService } from "@/service/bufferService.js"; import { IBufferService } from "@/service/bufferService.js";
import { IProxyResolver } from "@/service/network/ProxyResolver.js"; import { IProxyResolver } from "@/service/networkProviders/ProxyResolver.js";
import { INetworkService } from "@/service/networkService.js"; import { IProviderRegistry } from "@/service/networkProviders/providerRegistry.js";
import { IStatusService } from "@/service/statusService.js"; import { IStatusService } from "@/service/statusService.js";
import { DockerStatusPayload, MonitorStatusResponse, StatusChangeResult } from "@/types/network.js"; import { DockerStatusPayload, MonitorStatusResponse, StatusChangeResult } from "@/types/network.js";
import { AppError } from "@/utils/AppError.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"; import { JobHandler, MonitorActionDecision, MonitorEvaluation } from "@/worker/worker.interface.js";
const SERVICE_NAME = "WorkerPipeline"; 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. // 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. // produce and evaluateCheck stay public on the class so tests can drive one stage without the queue or the buffer.
export interface IWorkerPipeline { export interface IWorkerPipeline {
@@ -42,7 +36,7 @@ export interface WorkerPipelineDependencies {
checksRepository: IChecksRepository; checksRepository: IChecksRepository;
jobsRepository: IJobsRepository; jobsRepository: IJobsRepository;
checkService: ICheckService; checkService: ICheckService;
networkService: INetworkService; providerRegistry: IProviderRegistry;
proxyResolver: IProxyResolver; proxyResolver: IProxyResolver;
bufferService: IBufferService; bufferService: IBufferService;
dockerLogsService: IDockerLogsService; dockerLogsService: IDockerLogsService;
@@ -109,7 +103,7 @@ export class WorkerPipeline implements IWorkerPipeline {
const proxyUrl = await this.deps.proxyResolver.resolve(monitor); const proxyUrl = await this.deps.proxyResolver.resolve(monitor);
const dockerTlsKey = await this.resolveDockerTlsKey(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) { if (!status) {
throw new Error("No network response"); throw new Error("No network response");
} }
@@ -272,7 +266,7 @@ export class WorkerPipeline implements IWorkerPipeline {
return { return {
monitor, monitor,
check, check,
decision: { ...NO_ACTION }, decision: { transition: null },
}; };
}; };
@@ -288,30 +282,21 @@ export class WorkerPipeline implements IWorkerPipeline {
}; };
private decide = (statusChange: StatusChangeResult): MonitorActionDecision => { private decide = (statusChange: StatusChangeResult): MonitorActionDecision => {
const { monitor, statusChanged, prevStatus } = statusChange; const { monitor, statusChanged, prevStatus, thresholdBreaches } = statusChange;
const decision: MonitorActionDecision = { ...NO_ACTION }; if (!statusChanged) return { transition: null };
if (!statusChanged) return decision;
if (monitor.status === "down") { if (monitor.status === "down") return { transition: "status_down" };
decision.shouldCreateIncident = true;
decision.shouldSendNotification = true; if (monitor.status === "breached") {
decision.incidentReason = "status_down"; return thresholdBreaches
decision.notificationReason = "status_change"; ? // Hardware
} else if (monitor.status === "breached") { { transition: "threshold_breach", thresholdBreaches }
if (statusChange.thresholdBreaches) decision.thresholdBreaches = statusChange.thresholdBreaches; : // Docker
decision.shouldCreateIncident = true; { transition: "threshold_breach" };
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";
} }
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; statusService: StatusService;
incidentService: IncidentService; incidentService: IncidentService;
notificationsService: { handleNotifications: jest.Mock }; notificationsService: { handleNotifications: jest.Mock };
networkService: { requestStatus: jest.Mock }; providerRegistry: { probe: jest.Mock };
bufferStub: { addToBuffer: jest.Mock; addGeoCheckToBuffer: jest.Mock; scheduleNextFlush: jest.Mock }; bufferStub: { addToBuffer: jest.Mock; addGeoCheckToBuffer: jest.Mock; scheduleNextFlush: jest.Mock };
maintenanceWindowsRepo: { findByMonitorId: jest.Mock }; maintenanceWindowsRepo: { findByMonitorId: jest.Mock };
messageBuilder: { buildThresholdBreachMessage: jest.Mock }; messageBuilder: { buildThresholdBreachMessage: jest.Mock };
@@ -87,8 +87,8 @@ export function createHeartbeatTestHarness(): HeartbeatTestHarness {
let nextResponse: MonitorStatusResponse | null = null; let nextResponse: MonitorStatusResponse | null = null;
let nextStatus = true; let nextStatus = true;
let nextCode = 200; let nextCode = 200;
const networkService = { const providerRegistry = {
requestStatus: jest.fn().mockImplementation(() => { probe: jest.fn().mockImplementation(() => {
if (nextResponse) { if (nextResponse) {
return Promise.resolve(nextResponse); return Promise.resolve(nextResponse);
} }
@@ -119,11 +119,11 @@ export function createHeartbeatTestHarness(): HeartbeatTestHarness {
nextEgressStatus = status; nextEgressStatus = status;
}; };
// The real NotificationsService ignores decisions with shouldSendNotification false; the stub is reached only for // The real NotificationsService ignores decisions with no transition; the stub is reached only for the ones it
// the ones it would act on, so tests can assert on handleNotifications calls as "notifications sent". // would act on, so tests can assert on handleNotifications calls as "notifications sent".
const guardedNotificationsService = { const guardedNotificationsService = {
handleNotifications: (monitor: Monitor, check: Check, decision: MonitorActionDecision) => 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 notificationReactor = new NotificationReactor(guardedNotificationsService as any);
const incidentReactor = new IncidentReactor(incidentService as any); const incidentReactor = new IncidentReactor(incidentService as any);
@@ -137,7 +137,7 @@ export function createHeartbeatTestHarness(): HeartbeatTestHarness {
checksRepository: { findUnevaluatedByMonitorId: jest.fn() } as any, checksRepository: { findUnevaluatedByMonitorId: jest.fn() } as any,
jobsRepository: { upsertEvaluate: jest.fn(), pullEvaluated: jest.fn() } as any, jobsRepository: { upsertEvaluate: jest.fn(), pullEvaluated: jest.fn() } as any,
checkService: checkService as any, checkService: checkService as any,
networkService: networkService as any, providerRegistry: providerRegistry as any,
proxyResolver: proxyResolver as any, proxyResolver: proxyResolver as any,
bufferService: bufferStub as any, bufferService: bufferStub as any,
dockerLogsService: dockerLogsService as any, dockerLogsService: dockerLogsService as any,
@@ -163,7 +163,7 @@ export function createHeartbeatTestHarness(): HeartbeatTestHarness {
statusService, statusService,
incidentService, incidentService,
notificationsService, notificationsService,
networkService, providerRegistry,
bufferStub, bufferStub,
maintenanceWindowsRepo, maintenanceWindowsRepo,
messageBuilder, messageBuilder,
+1 -1
View File
@@ -21,7 +21,7 @@ export const makeMessage = (overrides?: Partial<NotificationMessage>): Notificat
timestamp: new Date("2025-01-01T00:00:00Z"), timestamp: new Date("2025-01-01T00:00:00Z"),
}, },
clientHost: "https://app.example.com", clientHost: "https://app.example.com",
metadata: { teamId: "team-1", notificationReason: "status_change" }, metadata: { teamId: "team-1" },
...overrides, ...overrides,
}); });
@@ -14,7 +14,7 @@ const makeMessage = (): NotificationMessage => ({
timestamp: new Date("2025-01-01T00:00:00Z"), timestamp: new Date("2025-01-01T00:00:00Z"),
}, },
clientHost: "https://app.example.com", clientHost: "https://app.example.com",
metadata: { teamId: "team-1", notificationReason: "status_change" }, metadata: { teamId: "team-1" },
}); });
export const testNotificationProviderContract = ( export const testNotificationProviderContract = (
@@ -1,5 +1,5 @@
import { describe, expect, it } from "@jest/globals"; 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"; import type { Monitor } from "../../src/domain/monitors/monitor.type.ts";
/** /**
@@ -126,7 +126,7 @@ describe("Evaluate cursor with more than one processing worker", () => {
checksRepository, checksRepository,
jobsRepository, jobsRepository,
checkService, checkService,
networkService: { requestStatus: jest.fn<any>() } as any, providerRegistry: { probe: jest.fn<any>() } as any,
proxyResolver: { resolve: jest.fn<any>() } as any, proxyResolver: { resolve: jest.fn<any>() } as any,
bufferService, bufferService,
dockerLogsService: { buildDockerLogs: jest.fn<any>() } as any, dockerLogsService: { buildDockerLogs: jest.fn<any>() } as any,
@@ -44,7 +44,7 @@ describe("Heartbeat job: maintenance windows", () => {
expect(storedMonitor.status).toBe("maintenance"); expect(storedMonitor.status).toBe("maintenance");
// No network request should have been made // 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 // No check should have been buffered
expect(h.bufferStub.addToBuffer).not.toHaveBeenCalled(); expect(h.bufferStub.addToBuffer).not.toHaveBeenCalled();
@@ -68,7 +68,7 @@ describe("Heartbeat job: maintenance windows", () => {
// since status was already "maintenance" // since status was already "maintenance"
const storedMonitor = await h.monitorsRepo.findById("mon-1", "team-1"); const storedMonitor = await h.monitorsRepo.findById("mon-1", "team-1");
expect(storedMonitor.status).toBe("maintenance"); 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 () => { it("resumes normal checks when maintenance window expires", async () => {
@@ -88,7 +88,7 @@ describe("Heartbeat job: maintenance windows", () => {
await h.heartbeatJob(monitor); await h.heartbeatJob(monitor);
// Network request should have been made this time // 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 // Check should have been buffered
expect(h.bufferStub.addToBuffer).toHaveBeenCalledTimes(1); expect(h.bufferStub.addToBuffer).toHaveBeenCalledTimes(1);
@@ -105,7 +105,7 @@ describe("Heartbeat job: maintenance windows", () => {
await h.heartbeatJob(monitor); await h.heartbeatJob(monitor);
// Normal check should proceed since window is inactive // 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); 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-a", "team-1")).status).toBe("maintenance");
expect((await h.monitorsRepo.findById("mon-b", "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 () => { it("skips checks when window is in the past (expired)", async () => {
@@ -141,6 +141,6 @@ describe("Heartbeat job: maintenance windows", () => {
await h.heartbeatJob(monitor); await h.heartbeatJob(monitor);
// Window is in the past — normal check should proceed // 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; }) as Monitor;
const makeDecision = (overrides?: Partial<MonitorActionDecision>): MonitorActionDecision => ({ const makeDecision = (overrides?: Partial<MonitorActionDecision>): MonitorActionDecision => ({
shouldCreateIncident: false, transition: null,
shouldResolveIncident: false,
shouldSendNotification: false,
incidentReason: null,
notificationReason: null,
...overrides, ...overrides,
}); });
@@ -63,7 +59,7 @@ describe("Incident lifecycle (integration)", () => {
it("creates an incident when monitor goes down", async () => { it("creates an incident when monitor goes down", async () => {
const monitor = makeMonitor({ status: "down" }); 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)); 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 () => { it("does not create a duplicate incident for the same monitor", async () => {
const monitor = makeMonitor({ status: "down" }); 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 first = await service.handleIncident(monitor, decision, makeCheck(503));
const second = 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 () => { it("auto-resolves an incident when monitor recovers", async () => {
const monitor = makeMonitor({ status: "down" }); 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 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)); const resolved = await service.handleIncident(monitor, resolveDecision, makeCheck(200));
expect(resolved).not.toBeNull(); expect(resolved).not.toBeNull();
@@ -114,7 +110,7 @@ describe("Incident lifecycle (integration)", () => {
it("returns null when resolving with no active incident", async () => { it("returns null when resolving with no active incident", async () => {
const monitor = makeMonitor({ status: "up" }); 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)); 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 () => { it("manually resolves an active incident with comment", async () => {
const monitor = makeMonitor({ status: "down" }); 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 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"); 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 () => { it("throws when manually resolving an already-resolved incident", async () => {
const monitor = makeMonitor({ status: "down" }); 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 created = await service.handleIncident(monitor, decision, makeCheck(500));
await service.resolveIncident(created!.id, "user-1", "team-1"); 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 () => { it("creates a threshold breach incident with statusCode 9999 and descriptive message", async () => {
const monitor = makeMonitor({ status: "breached", type: "hardware" }); const monitor = makeMonitor({ status: "breached", type: "hardware" });
(messageBuilder.buildThresholdBreachMessage as jest.Mock).mockReturnValue("CPU: 92% (threshold: 80%), MEMORY: 88% (threshold: 85%)"); (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)); const incident = await service.handleIncident(monitor, decision, makeCheck(200));
@@ -179,19 +175,19 @@ describe("Incident lifecycle (integration)", () => {
const monitor = makeMonitor({ status: "down" }); const monitor = makeMonitor({ status: "down" });
// First outage // 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); expect(first!.status).toBe(true);
// Manually resolved // Manually resolved
await service.resolveIncident(first!.id, "user-1", "team-1", "Restarted server"); await service.resolveIncident(first!.id, "user-1", "team-1", "Restarted server");
// Second outage — new incident since previous was resolved // 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!.id).not.toBe(first!.id);
expect(second!.statusCode).toBe(502); expect(second!.statusCode).toBe(502);
// Auto-resolve // 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!.id).toBe(second!.id);
expect(resolved!.status).toBe(false); expect(resolved!.status).toBe(false);
expect(resolved!.resolutionType).toBe("automatic"); expect(resolved!.resolutionType).toBe("automatic");
@@ -207,7 +203,7 @@ describe("Incident lifecycle (integration)", () => {
it("incidents for different monitors do not interfere", async () => { it("incidents for different monitors do not interfere", async () => {
const monitorA = makeMonitor({ id: "mon-a", status: "down" }); const monitorA = makeMonitor({ id: "mon-a", status: "down" });
const monitorB = makeMonitor({ id: "mon-b", 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 incidentA = await service.handleIncident(monitorA, decision, makeCheck(500));
const incidentB = await service.handleIncident(monitorB, decision, makeCheck(502)); const incidentB = await service.handleIncident(monitorB, decision, makeCheck(502));
@@ -216,7 +212,7 @@ describe("Incident lifecycle (integration)", () => {
expect(repo.getAll()).toHaveLength(2); expect(repo.getAll()).toHaveLength(2);
// Resolving monitor A does not affect monitor B // 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); expect(resolved!.id).toBe(incidentA!.id);
const bStillActive = await repo.findActiveByMonitorId("mon-b", "team-1"); 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", () => { describe("findByIds recentChecks modes", () => {
const seedFixtures = async () => { const seedFixtures = async () => {
const teamId = new mongoose.Types.ObjectId(); 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 { 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"; import type { Monitor } from "../../../../src/domain/monitors/monitor.type.ts";
// ── Helpers ────────────────────────────────────────────────────────────────── // ── Helpers ──────────────────────────────────────────────────────────────────
@@ -2,7 +2,7 @@ import { beforeEach, describe, expect, it, jest } from "@jest/globals";
import { testStatusProviderContract } from "../../../helpers/statusProviderContract.ts"; import { testStatusProviderContract } from "../../../helpers/statusProviderContract.ts";
import { NETWORK_ERROR } from "../../../../src/types/network.ts"; import { NETWORK_ERROR } from "../../../../src/types/network.ts";
import type { Monitor } from "../../../../src/domain/monitors/monitor.type.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 ──────────────────────────────────────────────────────────────────── // ── Mocks ────────────────────────────────────────────────────────────────────
@@ -1,6 +1,6 @@
import { describe, expect, it, jest } from "@jest/globals"; import { describe, expect, it, jest } from "@jest/globals";
import https from "node:https"; 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 type { IEncryptionService } from "../../../../src/service/encryption/encryptionService.ts";
import { testStatusProviderContract } from "../../../helpers/statusProviderContract.ts"; import { testStatusProviderContract } from "../../../helpers/statusProviderContract.ts";
import { createMockLogger } from "../../../helpers/createMockLogger.ts"; import { createMockLogger } from "../../../helpers/createMockLogger.ts";
@@ -1,5 +1,5 @@
import { describe, expect, it, jest } from "@jest/globals"; 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 { testStatusProviderContract } from "../../../helpers/statusProviderContract.ts";
import { createMockLogger } from "../../../helpers/createMockLogger.ts"; import { createMockLogger } from "../../../helpers/createMockLogger.ts";
import { NETWORK_ERROR } from "../../../../src/types/network.ts"; import { NETWORK_ERROR } from "../../../../src/types/network.ts";
@@ -1,5 +1,5 @@
import { describe, expect, it, jest } from "@jest/globals"; 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 { testStatusProviderContract } from "../../../helpers/statusProviderContract.ts";
import type { Monitor } from "../../../../src/domain/monitors/monitor.type.ts"; import type { Monitor } from "../../../../src/domain/monitors/monitor.type.ts";
@@ -1,7 +1,7 @@
import { describe, expect, it, jest } from "@jest/globals"; 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 { 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"; import type { Monitor } from "../../../../src/domain/monitors/monitor.type.ts";
// ── Helpers ────────────────────────────────────────────────────────────────── // ── 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 gotModule = await import("got");
const { HTTPError, RequestError } = gotModule; const { HTTPError, RequestError } = gotModule;
@@ -1,8 +1,8 @@
import { describe, expect, it, jest } from "@jest/globals"; 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 { testStatusProviderContract } from "../../../helpers/statusProviderContract.ts";
import { createMockLogger } from "../../../helpers/createMockLogger.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 { ISettingsService } from "../../../../src/domain/app-settings/app-settings.service.ts";
import type { Monitor } from "../../../../src/domain/monitors/monitor.type.ts"; import type { Monitor } from "../../../../src/domain/monitors/monitor.type.ts";
@@ -1,5 +1,5 @@
import { describe, expect, it, jest } from "@jest/globals"; 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 { testStatusProviderContract } from "../../../helpers/statusProviderContract.ts";
import type { Monitor } from "../../../../src/domain/monitors/monitor.type.ts"; import type { Monitor } from "../../../../src/domain/monitors/monitor.type.ts";
import * as net from "net"; import * as net from "net";
@@ -1,5 +1,5 @@
import { describe, expect, it, jest } from "@jest/globals"; 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 { testStatusProviderContract } from "../../../helpers/statusProviderContract.ts";
import { NETWORK_ERROR } from "../../../../src/types/network.ts"; import { NETWORK_ERROR } from "../../../../src/types/network.ts";
import type { Monitor } from "../../../../src/domain/monitors/monitor.type.ts"; import type { Monitor } from "../../../../src/domain/monitors/monitor.type.ts";
@@ -1,5 +1,5 @@
import { describe, expect, it } from "@jest/globals"; 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"; import { NETWORK_ERROR } from "../../../../src/types/network.ts";
describe("network utils", () => { describe("network utils", () => {
@@ -1,5 +1,5 @@
import { describe, expect, it, jest, beforeEach, afterEach } from "@jest/globals"; 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 { testStatusProviderContract } from "../../../helpers/statusProviderContract.ts";
import { NETWORK_ERROR } from "../../../../src/types/network.ts"; import { NETWORK_ERROR } from "../../../../src/types/network.ts";
import type { Monitor } from "../../../../src/domain/monitors/monitor.type.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", clientHost: "https://app.example.com",
metadata: { metadata: {
teamId: "team-1", teamId: "team-1",
notificationReason: "monitor_down",
}, },
...overrides, ...overrides,
}); });
@@ -134,41 +134,6 @@ describe("BufferService", () => {
expect(ingest).toHaveBeenCalledWith([check1, check2]); 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 ────────────────────────────────────────────── // ── addGeoCheckToBuffer ──────────────────────────────────────────────
@@ -183,40 +148,6 @@ describe("BufferService", () => {
expect(geoChecksService.createGeoChecks).toHaveBeenCalledWith([geoCheck]); 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", () => { describe("addDockerLogToBuffer", () => {
@@ -229,15 +160,6 @@ describe("BufferService", () => {
expect(dockerLogsService.createDockerLogs).toHaveBeenCalledWith([dockerLog]); 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 ──────────────────────────────────────────────── // ── scheduleNextFlush ────────────────────────────────────────────────
+29 -29
View File
@@ -42,7 +42,7 @@ const makeJob = (overrides?: Partial<Job>): Job => ({
...overrides, ...overrides,
}); });
// requestStatus mock that answers per target url // probe mock that answers per target url
const statusFor = (reachableTargets: string[]) => const statusFor = (reachableTargets: string[]) =>
jest.fn().mockImplementation(async (monitor: any) => ({ jest.fn().mockImplementation(async (monitor: any) => ({
monitorId: monitor.id, monitorId: monitor.id,
@@ -78,7 +78,7 @@ const createService = (overrides?: Record<string, any>) => {
upsertCleanupJob: jest.fn().mockResolvedValue(true), upsertCleanupJob: jest.fn().mockResolvedValue(true),
deleteGlobalJobIfUnchanged: 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) }, proxyResolver: { resolve: jest.fn().mockResolvedValue(undefined) },
logger: createMockLogger(), logger: createMockLogger(),
...overrides, ...overrides,
@@ -87,15 +87,15 @@ const createService = (overrides?: Record<string, any>) => {
defaults.settingsService as any, defaults.settingsService as any,
defaults.egressStateRepository as any, defaults.egressStateRepository as any,
defaults.jobsRepository as any, defaults.jobsRepository as any,
defaults.networkService as any, defaults.providerRegistry as any,
defaults.proxyResolver as any, defaults.proxyResolver as any,
defaults.logger as any defaults.logger as any
); );
return { service, defaults }; return { service, defaults };
}; };
const probedUrls = (defaults: { networkService: { requestStatus: unknown } }) => const probedUrls = (defaults: { providerRegistry: { probe: unknown } }) =>
(defaults.networkService.requestStatus as jest.Mock).mock.calls.map((call) => (call[0] as any).url); (defaults.providerRegistry.probe as jest.Mock).mock.calls.map((call) => (call[0] as any).url);
// ── Tests ──────────────────────────────────────────────────────────────────── // ── 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"]); 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(monitors).toEqual([
expect.objectContaining({ type: "ping", url: "1.1.1.1" }), expect.objectContaining({ type: "ping", url: "1.1.1.1" }),
expect.objectContaining({ type: "port", url: "8.8.8.8", port: 53 }), expect.objectContaining({ type: "port", url: "8.8.8.8", port: 53 }),
@@ -129,13 +129,13 @@ describe("EgressService", () => {
await service.probeTargets(["https://example.com/health"]); await service.probeTargets(["https://example.com/health"]);
expect(resolve).toHaveBeenCalledWith(expect.objectContaining({ type: "http", proxyMode: "inherit" })); 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", proxyUrl: "http://proxy.internal:3128",
}); });
}); });
it("reports reachable only when the provider says status is true", async () => { 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"]); 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 () => { 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, monitorId: monitor.id,
teamId: monitor.teamId, teamId: monitor.teamId,
type: monitor.type, type: monitor.type,
@@ -155,7 +155,7 @@ describe("EgressService", () => {
message: monitor.url.includes("maintenance") ? "Service Unavailable" : "ECONNREFUSED", message: monitor.url.includes("maintenance") ? "Service Unavailable" : "ECONNREFUSED",
responseTime: 3, 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"]); 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"]); 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(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 () => { 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"]); 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 () => { it("counts a hung provider as unreachable once the probe timeout elapses", async () => {
jest.useFakeTimers(); 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"]); const pending = service.probeTargets(["1.1.1.1"]);
await jest.advanceTimersByTimeAsync(5000); await jest.advanceTimersByTimeAsync(5000);
@@ -206,13 +206,13 @@ describe("EgressService", () => {
const result = await service.assessAfterFailure(); const result = await service.assessAfterFailure();
expect(result).toBeNull(); expect(result).toBeNull();
expect(defaults.networkService.requestStatus).not.toHaveBeenCalled(); expect(defaults.providerRegistry.probe).not.toHaveBeenCalled();
expect(defaults.egressStateRepository.findSingleton).not.toHaveBeenCalled(); expect(defaults.egressStateRepository.findSingleton).not.toHaveBeenCalled();
expect(defaults.egressStateRepository.recordProbe).not.toHaveBeenCalled(); expect(defaults.egressStateRepository.recordProbe).not.toHaveBeenCalled();
}); });
it("returns ok and records the probe when any target is reachable", async () => { 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(); 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 () => { 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(); 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 () => { it("still returns degraded and leaves the job scheduled when another worker performed the transition first", async () => {
const { service, defaults } = createService({ const { service, defaults } = createService({
networkService: { requestStatus: statusFor([]) }, providerRegistry: { probe: statusFor([]) },
egressStateRepository: degradedRepository({ egressStateRepository: degradedRepository({
findSingleton: jest.fn().mockResolvedValue(makeState()), findSingleton: jest.fn().mockResolvedValue(makeState()),
markDegraded: jest.fn().mockResolvedValue(null), markDegraded: jest.fn().mockResolvedValue(null),
@@ -271,7 +271,7 @@ describe("EgressService", () => {
it("does not mark degraded when the recovery job cannot be scheduled", async () => { it("does not mark degraded when the recovery job cannot be scheduled", async () => {
const { service, defaults } = createService({ const { service, defaults } = createService({
networkService: { requestStatus: statusFor([]) }, providerRegistry: { probe: statusFor([]) },
jobsRepository: { jobsRepository: {
upsertJob: jest.fn(), upsertJob: jest.fn(),
upsertCleanupJob: jest.fn().mockRejectedValue(new Error("db down")), upsertCleanupJob: jest.fn().mockRejectedValue(new Error("db down")),
@@ -289,7 +289,7 @@ describe("EgressService", () => {
const result = await service.assessAfterFailure(); const result = await service.assessAfterFailure();
expect(result).toBe("degraded"); 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.markDegraded).not.toHaveBeenCalled();
expect(defaults.egressStateRepository.recordProbe).not.toHaveBeenCalled(); expect(defaults.egressStateRepository.recordProbe).not.toHaveBeenCalled();
expect(defaults.jobsRepository.upsertJob).toHaveBeenCalledWith(expect.objectContaining({ id: "egress", intervalMs: 5_000 })); expect(defaults.jobsRepository.upsertJob).toHaveBeenCalledWith(expect.objectContaining({ id: "egress", intervalMs: 5_000 }));
@@ -322,19 +322,19 @@ describe("EgressService", () => {
describe("shared assessment", () => { describe("shared assessment", () => {
it("shares one probe between concurrent failures", async () => { 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()]); const results = await Promise.all([service.assessAfterFailure(), service.assessAfterFailure(), service.assessAfterFailure()]);
expect(results).toEqual(["ok", "ok", "ok"]); expect(results).toEqual(["ok", "ok", "ok"]);
expect(defaults.settingsService.getCachedDBSettings).toHaveBeenCalledTimes(1); 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); expect(defaults.egressStateRepository.recordProbe).toHaveBeenCalledTimes(1);
}); });
it("keeps sharing an in-flight assessment that outlives the reuse window", async () => { it("keeps sharing an in-flight assessment that outlives the reuse window", async () => {
jest.useFakeTimers(); 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(); const first = service.assessAfterFailure();
await jest.advanceTimersByTimeAsync(4_900); // probes still hanging, past the 5 s reuse window from the first call 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 await jest.advanceTimersByTimeAsync(200); // probe timeout fires, both settle
expect(await Promise.all([first, second])).toEqual(["degraded", "degraded"]); 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 () => { it("reuses a settled assessment within the reuse window and probes again after it", async () => {
jest.useFakeTimers(); 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 service.assessAfterFailure();
await jest.advanceTimersByTimeAsync(1_000); 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 () => { it("marks recovered, releases the job and logs when a target becomes reachable", async () => {
const { service, defaults } = createService({ const { service, defaults } = createService({
egressStateRepository: degradedRepository(), egressStateRepository: degradedRepository(),
networkService: { requestStatus: statusFor(["1.1.1.1"]) }, providerRegistry: { probe: statusFor(["1.1.1.1"]) },
}); });
const job = makeJob({ nextScheduledAt: 123_456 }); 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 () => { it("releases the job but does not log a recovery when another process already recorded it", async () => {
const { service, defaults } = createService({ const { service, defaults } = createService({
egressStateRepository: degradedRepository({ markRecovered: jest.fn().mockResolvedValue(null) }), 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()); await service.checkRecovery(makeJob());
@@ -417,7 +417,7 @@ describe("EgressService", () => {
it("records the probe and keeps the job while every target stays unreachable", async () => { it("records the probe and keeps the job while every target stays unreachable", async () => {
const { service, defaults } = createService({ const { service, defaults } = createService({
egressStateRepository: degradedRepository(), egressStateRepository: degradedRepository(),
networkService: { requestStatus: statusFor([]) }, providerRegistry: { probe: statusFor([]) },
}); });
await service.checkRecovery(makeJob()); await service.checkRecovery(makeJob());
@@ -432,7 +432,7 @@ describe("EgressService", () => {
await service.checkRecovery(makeJob()); await service.checkRecovery(makeJob());
expect(defaults.networkService.requestStatus).not.toHaveBeenCalled(); expect(defaults.providerRegistry.probe).not.toHaveBeenCalled();
expect(defaults.egressStateRepository.markRecovered).not.toHaveBeenCalled(); expect(defaults.egressStateRepository.markRecovered).not.toHaveBeenCalled();
expect(defaults.jobsRepository.deleteGlobalJobIfUnchanged).toHaveBeenCalledWith("egress", 1_000); expect(defaults.jobsRepository.deleteGlobalJobIfUnchanged).toHaveBeenCalledWith("egress", 1_000);
}); });
@@ -445,7 +445,7 @@ describe("EgressService", () => {
await service.checkRecovery(makeJob()); await service.checkRecovery(makeJob());
expect(defaults.networkService.requestStatus).not.toHaveBeenCalled(); expect(defaults.providerRegistry.probe).not.toHaveBeenCalled();
expect(defaults.jobsRepository.deleteGlobalJobIfUnchanged).toHaveBeenCalledWith("egress", 1_000); expect(defaults.jobsRepository.deleteGlobalJobIfUnchanged).toHaveBeenCalledWith("egress", 1_000);
}); });
@@ -94,11 +94,7 @@ const makeCheck = (overrides?: Partial<Check>): Check =>
}) as Check; }) as Check;
const makeDecision = (overrides?: Partial<MonitorActionDecision>): MonitorActionDecision => ({ const makeDecision = (overrides?: Partial<MonitorActionDecision>): MonitorActionDecision => ({
shouldCreateIncident: false, transition: null,
shouldResolveIncident: false,
shouldSendNotification: false,
incidentReason: null,
notificationReason: null,
...overrides, ...overrides,
}); });
@@ -114,32 +110,24 @@ describe("IncidentService", () => {
expect(result).toBeNull(); 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 existing = makeIncident();
const { service, incidentsRepository } = createService(); const { service, incidentsRepository } = createService();
(incidentsRepository.findActiveByMonitorId as jest.Mock).mockResolvedValue(existing); (incidentsRepository.findActiveByMonitorId as jest.Mock).mockResolvedValue(existing);
const result = await service.handleIncident( const result = await service.handleIncident(makeMonitor(), makeDecision({ transition: "status_down" }), makeCheck({ statusCode: 500 }));
makeMonitor(),
makeDecision({ shouldCreateIncident: true, incidentReason: "status_down" }),
makeCheck({ statusCode: 500 })
);
expect(result).toBe(existing); expect(result).toBe(existing);
expect(incidentsRepository.create).not.toHaveBeenCalled(); 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 created = makeIncident();
const { service, incidentsRepository, notificationMessageBuilder } = createService(); const { service, incidentsRepository, notificationMessageBuilder } = createService();
(incidentsRepository.findActiveByMonitorId as jest.Mock).mockResolvedValue(null); (incidentsRepository.findActiveByMonitorId as jest.Mock).mockResolvedValue(null);
(incidentsRepository.create as jest.Mock).mockResolvedValue(created); (incidentsRepository.create as jest.Mock).mockResolvedValue(created);
const result = await service.handleIncident( const result = await service.handleIncident(makeMonitor(), makeDecision({ transition: "status_down" }), makeCheck({ statusCode: 500 }));
makeMonitor(),
makeDecision({ shouldCreateIncident: true, incidentReason: "status_down" }),
makeCheck({ statusCode: 500 })
);
expect(result).toBe(created); expect(result).toBe(created);
expect(incidentsRepository.create).toHaveBeenCalledWith( expect(incidentsRepository.create).toHaveBeenCalledWith(
@@ -163,7 +151,7 @@ describe("IncidentService", () => {
const monitor = makeMonitor({ type: "hardware" }); const monitor = makeMonitor({ type: "hardware" });
const check = makeCheck({ metadata: { monitorId: "mon-1", teamId: "team-1", type: "hardware" }, cpu: { usage_percent: 0.95 } }); 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 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); await service.handleIncident(monitor, decision, check);
expect(notificationMessageBuilder.buildThresholdBreachMessage).toHaveBeenCalledWith(monitor, check, thresholdBreaches); 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 active = makeIncident();
const resolved = makeIncident({ status: false, endTime: "123", resolutionType: "automatic" }); const resolved = makeIncident({ status: false, endTime: "123", resolutionType: "automatic" });
const { service, incidentsRepository } = createService(); const { service, incidentsRepository } = createService();
(incidentsRepository.findActiveByMonitorId as jest.Mock).mockResolvedValue(active); (incidentsRepository.findActiveByMonitorId as jest.Mock).mockResolvedValue(active);
(incidentsRepository.updateById as jest.Mock).mockResolvedValue(resolved); (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(result).toBe(resolved);
expect(incidentsRepository.updateById).toHaveBeenCalledWith( 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(); const { service, incidentsRepository } = createService();
(incidentsRepository.findActiveByMonitorId as jest.Mock).mockResolvedValue(null); (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(); 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; }) as Monitor;
const makeDecision = (overrides?: Partial<MonitorActionDecision>): MonitorActionDecision => ({ const makeDecision = (overrides?: Partial<MonitorActionDecision>): MonitorActionDecision => ({
shouldCreateIncident: false, transition: "status_down",
shouldResolveIncident: false,
shouldSendNotification: true,
incidentReason: null,
notificationReason: "status_change",
...overrides, ...overrides,
}); });
@@ -100,15 +96,12 @@ describe("NotificationMessageBuilder", () => {
}); });
expect(msg.content.title).toBe("Monitor Down: Test Monitor"); expect(msg.content.title).toBe("Monitor Down: Test Monitor");
expect(msg.clientHost).toBe("https://app.example.com"); expect(msg.clientHost).toBe("https://app.example.com");
expect(msg.metadata).toEqual({ expect(msg.metadata).toEqual({ teamId: "team-1" });
teamId: "team-1",
notificationReason: "status_change",
});
}); });
it("builds a monitor_up message", () => { it("builds a monitor_up message", () => {
const monitor = makeMonitor({ status: "up", type: "http" }); 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"); const msg = builder.buildMessage(monitor, makeCheck(), decision, "https://app.example.com");
@@ -120,7 +113,7 @@ describe("NotificationMessageBuilder", () => {
it("builds a threshold_breach message", () => { it("builds a threshold_breach message", () => {
const monitor = makeMonitor({ status: "up", type: "hardware", cpuAlertThreshold: 80 }); const monitor = makeMonitor({ status: "up", type: "hardware", cpuAlertThreshold: 80 });
const decision = makeDecision({ const decision = makeDecision({
notificationReason: "threshold_breach", transition: "threshold_breach",
thresholdBreaches: { cpu: true, memory: false, disk: false, temp: false }, thresholdBreaches: { cpu: true, memory: false, disk: false, temp: false },
}); });
const check = makeCheck({ cpu: { usage_percent: 0.9, temperature: [50] } }); 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", () => { it("builds a threshold_resolved message for hardware monitor recovering", () => {
const monitor = makeMonitor({ status: "up", type: "hardware" }); 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"); const msg = builder.buildMessage(monitor, makeCheck(), decision, "https://app.example.com");
@@ -149,18 +142,9 @@ describe("NotificationMessageBuilder", () => {
// ── determineNotificationType (via buildMessage) ───────────────────── // ── determineNotificationType (via buildMessage) ─────────────────────
describe("determineNotificationType", () => { describe("determineNotificationType", () => {
it("returns monitor_down when status is down, even if notificationReason is threshold_breach", () => { it("returns threshold_breach for a hardware monitor on a threshold_breach transition", () => {
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", () => {
const monitor = makeMonitor({ status: "up", type: "hardware" }); 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, ""); const msg = builder.buildMessage(monitor, makeCheck(), decision, "");
@@ -169,16 +153,16 @@ describe("NotificationMessageBuilder", () => {
it("returns threshold_resolved for hardware monitor with threshold_resolved reason", () => { it("returns threshold_resolved for hardware monitor with threshold_resolved reason", () => {
const monitor = makeMonitor({ status: "up", type: "hardware" }); 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, ""); const msg = builder.buildMessage(monitor, makeCheck(), decision, "");
expect(msg.type).toBe("threshold_resolved"); 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 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, ""); const msg = builder.buildMessage(monitor, makeCheck(), decision, "");
@@ -186,7 +170,7 @@ describe("NotificationMessageBuilder", () => {
}); });
it("returns container_breach for docker monitor with threshold_breach reason", () => { 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, ""); const msg = builder.buildMessage(makeDockerMonitor(), makeCheck(), decision, "");
@@ -194,15 +178,15 @@ describe("NotificationMessageBuilder", () => {
}); });
it("returns container_resolved for docker monitor with threshold_resolved reason", () => { 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, ""); const msg = builder.buildMessage(makeDockerMonitor({ status: "up" }), makeCheck(), decision, "");
expect(msg.type).toBe("container_resolved"); expect(msg.type).toBe("container_resolved");
}); });
it("returns monitor_down for docker monitor that is down regardless of reason", () => { it("returns monitor_down for a docker monitor on a status_down transition", () => {
const decision = makeDecision({ notificationReason: "threshold_breach" }); const decision = makeDecision({ transition: "status_down" });
const msg = builder.buildMessage(makeDockerMonitor({ status: "down" }), makeCheck(), decision, ""); 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", () => { it("returns monitor_up for non-hardware monitor with status up", () => {
const monitor = makeMonitor({ status: "up", type: "http" }); 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, ""); const msg = builder.buildMessage(monitor, makeCheck(), decision, "");
expect(msg.type).toBe("monitor_up"); 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 monitor = makeMonitor({ status: "unknown" as any });
const decision = makeDecision({ notificationReason: null }); const decision = makeDecision({ transition: null });
const msg = builder.buildMessage(monitor, makeCheck(), decision, ""); const msg = builder.buildMessage(monitor, makeCheck(), decision, "");
@@ -240,19 +224,14 @@ describe("NotificationMessageBuilder", () => {
const msg = builder.buildMessage( const msg = builder.buildMessage(
makeMonitor({ status: "up", type: "hardware" }), makeMonitor({ status: "up", type: "hardware" }),
makeCheck(), makeCheck(),
makeDecision({ notificationReason: "threshold_breach" }), makeDecision({ transition: "threshold_breach" }),
"" ""
); );
expect(msg.severity).toBe("warning"); expect(msg.severity).toBe("warning");
}); });
it("returns success for monitor_up", () => { it("returns success for monitor_up", () => {
const msg = builder.buildMessage( const msg = builder.buildMessage(makeMonitor({ status: "up", type: "http" }), makeCheck(), makeDecision({ transition: "status_up" }), "");
makeMonitor({ status: "up", type: "http" }),
makeCheck(),
makeDecision({ notificationReason: "status_change" }),
""
);
expect(msg.severity).toBe("success"); expect(msg.severity).toBe("success");
}); });
@@ -260,24 +239,19 @@ describe("NotificationMessageBuilder", () => {
const msg = builder.buildMessage( const msg = builder.buildMessage(
makeMonitor({ status: "up", type: "hardware" }), makeMonitor({ status: "up", type: "hardware" }),
makeCheck(), makeCheck(),
makeDecision({ notificationReason: "threshold_resolved" }), makeDecision({ transition: "threshold_resolved" }),
"" ""
); );
expect(msg.severity).toBe("success"); expect(msg.severity).toBe("success");
}); });
it("returns warning for container_breach", () => { 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"); expect(msg.severity).toBe("warning");
}); });
it("returns success for container_resolved", () => { it("returns success for container_resolved", () => {
const msg = builder.buildMessage( const msg = builder.buildMessage(makeDockerMonitor({ status: "up" }), makeCheck(), makeDecision({ transition: "threshold_resolved" }), "");
makeDockerMonitor({ status: "up" }),
makeCheck(),
makeDecision({ notificationReason: "threshold_resolved" }),
""
);
expect(msg.severity).toBe("success"); expect(msg.severity).toBe("success");
}); });
}); });
@@ -331,7 +305,7 @@ describe("NotificationMessageBuilder", () => {
describe("monitor_up", () => { describe("monitor_up", () => {
it("includes recovery details", () => { it("includes recovery details", () => {
const monitor = makeMonitor({ status: "up", type: "http" }); 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.title).toBe("Monitor Recovered: Test Monitor");
expect(msg.content.summary).toBe('Monitor "Test Monitor" is back up and operational.'); 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 monitor = makeMonitor({ status: "up", type: "hardware", cpuAlertThreshold: 80 });
const check = makeCheck({ cpu: { usage_percent: 0.9, temperature: [50] } }); const check = makeCheck({ cpu: { usage_percent: 0.9, temperature: [50] } });
const decision = makeDecision({ const decision = makeDecision({
notificationReason: "threshold_breach", transition: "threshold_breach",
thresholdBreaches: { cpu: true, memory: false, disk: false, temp: false }, 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 monitor = makeMonitor({ status: "up", type: "hardware", cpuAlertThreshold: 80 });
const check = makeCheck({ cpu: { usage_percent: 0.9, temperature: [50] } }); 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([]); expect(msg.content.thresholds).toEqual([]);
}); });
@@ -366,7 +340,7 @@ describe("NotificationMessageBuilder", () => {
describe("threshold_resolved", () => { describe("threshold_resolved", () => {
it("includes resolved details", () => { it("includes resolved details", () => {
const monitor = makeMonitor({ status: "up", type: "hardware" }); 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.title).toBe("Thresholds Resolved: Test Monitor");
expect(msg.content.summary).toBe('Monitor "Test Monitor" thresholds have returned to normal.'); 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" }]), 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.title).toBe("Container Alert: Test Monitor");
expect(msg.content.summary).toBe('2 container(s) on "Test Monitor" need attention.'); 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 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"]); 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", () => { it("lists no containers when the check carries no container data", () => {
const check = makeCheck({ status: false, message: "connect ECONNREFUSED" }); 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.summary).toBe('0 container(s) on "Test Monitor" need attention.');
expect(msg.content.details).toEqual(["URL: unix:///var/run/docker.sock", "Type: docker"]); expect(msg.content.details).toEqual(["URL: unix:///var/run/docker.sock", "Type: docker"]);
@@ -416,12 +390,7 @@ describe("NotificationMessageBuilder", () => {
describe("container_resolved", () => { describe("container_resolved", () => {
it("includes recovered details", () => { it("includes recovered details", () => {
const msg = builder.buildMessage( const msg = builder.buildMessage(makeDockerMonitor({ status: "up" }), makeCheck(), makeDecision({ transition: "threshold_resolved" }), "");
makeDockerMonitor({ status: "up" }),
makeCheck(),
makeDecision({ notificationReason: "threshold_resolved" }),
""
);
expect(msg.content.title).toBe("Containers Recovered: Test Monitor"); expect(msg.content.title).toBe("Containers Recovered: Test Monitor");
expect(msg.content.summary).toBe('All containers on "Test Monitor" are back to normal.'); expect(msg.content.summary).toBe('All containers on "Test Monitor" are back to normal.');
@@ -430,18 +399,10 @@ describe("NotificationMessageBuilder", () => {
}); });
describe("default content", () => { describe("default content", () => {
it("builds default content for unhandled notification type", () => { it("builds monitor_up content on a status_up transition", () => {
// 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
const monitor = makeMonitor({ status: "up", type: "http" }); 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"); expect(msg.content.title).toBe("Monitor Recovered: Test Monitor");
}); });
}); });
@@ -122,11 +122,7 @@ const makeMonitor = (overrides?: Partial<Monitor>): Monitor =>
}) as Monitor; }) as Monitor;
const makeDecision = (overrides?: Partial<MonitorActionDecision>): MonitorActionDecision => ({ const makeDecision = (overrides?: Partial<MonitorActionDecision>): MonitorActionDecision => ({
shouldCreateIncident: false, transition: "status_down",
shouldResolveIncident: false,
shouldSendNotification: true,
incidentReason: null,
notificationReason: "status_change",
...overrides, ...overrides,
}); });
@@ -148,9 +144,9 @@ describe("NotificationsService", () => {
// ── handleNotifications ─────────────────────────────────────────────────── // ── handleNotifications ───────────────────────────────────────────────────
describe("handleNotifications", () => { describe("handleNotifications", () => {
it("returns false when shouldSendNotification is false", async () => { it("returns false when there is no transition", async () => {
const { service } = createService(); 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); 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 { 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 { Monitor } from "../../../src/domain/monitors/monitor.type.ts";
import type { Proxy } from "../../../src/domain/proxies/proxy.type.ts"; import type { Proxy } from "../../../src/domain/proxies/proxy.type.ts";
import { createMockLogger } from "../../helpers/createMockLogger.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" }), toCheck: jest.fn<any>().mockReturnValue({ id: "check-1" }),
createChecks: jest.fn<any>().mockResolvedValue([]), 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) }, proxyResolver: { resolve: jest.fn<any>().mockResolvedValue(undefined) },
bufferService: { addToBuffer: jest.fn<any>(), addDockerLogToBuffer: jest.fn<any>() }, bufferService: { addToBuffer: jest.fn<any>(), addDockerLogToBuffer: jest.fn<any>() },
dockerLogsService: { buildDockerLogs: jest.fn<any>().mockResolvedValue([]) }, dockerLogsService: { buildDockerLogs: jest.fn<any>().mockResolvedValue([]) },
@@ -115,7 +115,7 @@ describe("WorkerPipeline", () => {
await pipeline.handleCheck(makeJob()); await pipeline.handleCheck(makeJob());
expect(deps.monitorsRepository.findByIdLean).toHaveBeenCalledWith("m1"); expect(deps.monitorsRepository.findByIdLean).toHaveBeenCalledWith("m1");
expect(deps.networkService.requestStatus).toHaveBeenCalled(); expect(deps.providerRegistry.probe).toHaveBeenCalled();
expect(deps.bufferService.addToBuffer).toHaveBeenCalledWith({ id: "check-1" }); expect(deps.bufferService.addToBuffer).toHaveBeenCalledWith({ id: "check-1" });
}); });
@@ -125,7 +125,7 @@ describe("WorkerPipeline", () => {
await pipeline.handleCheck(makeJob({ refId: null })); await pipeline.handleCheck(makeJob({ refId: null }));
expect(deps.monitorsRepository.findByIdLean).not.toHaveBeenCalled(); 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 () => { it("does nothing when the monitor no longer exists", async () => {
@@ -135,7 +135,7 @@ describe("WorkerPipeline", () => {
await pipeline.handleCheck(makeJob()); 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(result).toBeNull();
expect(deps.monitorsRepository.updateById).toHaveBeenCalledWith("m1", "team", { status: "maintenance", statusWindow: [] }); 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(); 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 // 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. // 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.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 () => { 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()); await pipeline.produce(makeMonitor());
expect(deps.networkService.requestStatus).toHaveBeenCalled(); expect(deps.providerRegistry.probe).toHaveBeenCalled();
}); });
// ── proxy resolution ────────────────────────────────────────────────── // ── proxy resolution ──────────────────────────────────────────────────
it("passes the resolved proxy url into requestStatus", async () => { it("passes the resolved proxy url into probe", async () => {
const { pipeline, deps } = createPipeline({ const { pipeline, deps } = createPipeline({
proxyResolver: { resolve: jest.fn<any>().mockResolvedValue("http://proxy.example.com:8080") }, proxyResolver: { resolve: jest.fn<any>().mockResolvedValue("http://proxy.example.com:8080") },
}); });
@@ -216,7 +216,7 @@ describe("WorkerPipeline", () => {
await pipeline.produce(monitor); 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 () => { it("still produces a check when the resolver returns undefined", async () => {
@@ -225,7 +225,7 @@ describe("WorkerPipeline", () => {
const result = await pipeline.produce(monitor); 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" } }); expect(result).toEqual({ status: expect.objectContaining({ monitorId: "m1" }), check: { id: "check-1" } });
}); });
@@ -240,7 +240,7 @@ describe("WorkerPipeline", () => {
await pipeline.produce(monitor); await pipeline.produce(monitor);
expect(deps.monitorsRepository.findDockerTlsKeyById).toHaveBeenCalledWith("m1"); 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 () => { it("does not fetch a key for a docker monitor without one set", async () => {
@@ -250,7 +250,7 @@ describe("WorkerPipeline", () => {
await pipeline.produce(monitor); await pipeline.produce(monitor);
expect(deps.monitorsRepository.findDockerTlsKeyById).not.toHaveBeenCalled(); 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 () => { 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); await pipeline.produce(monitor);
expect(deps.monitorsRepository.findDockerTlsKeyById).toHaveBeenCalledWith("m1"); 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 ────────────────────────────────────────────────── // ── acquire / record ──────────────────────────────────────────────────
it("throws when the network response is null", async () => { it("throws when the network response is null", async () => {
const { pipeline } = createPipeline({ 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"); 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 status = { monitorId: "m1", status: false, code: 500, message: "Error" };
const check = { id: "check-1" }; const check = { id: "check-1" };
const { pipeline, deps } = createPipeline({ 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) }, 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 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 dockerLogs = [{ id: "docker-log-1" }, { id: "docker-log-2" }];
const { pipeline, deps } = createPipeline({ 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) }, dockerLogsService: { buildDockerLogs: jest.fn<any>().mockResolvedValue(dockerLogs) },
}); });
@@ -354,7 +354,7 @@ describe("WorkerPipeline", () => {
]) { ]) {
const check: Record<string, unknown> = { id: "check-1" }; const check: Record<string, unknown> = { id: "check-1" };
const { pipeline, deps } = createPipeline({ 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) }, checkService: { toCheck: jest.fn<any>().mockReturnValue(check) },
egressService: { assessAfterFailure: jest.fn<any>().mockResolvedValue("degraded") }, egressService: { assessAfterFailure: jest.fn<any>().mockResolvedValue("degraded") },
}); });
@@ -370,7 +370,7 @@ describe("WorkerPipeline", () => {
it("consults egress for a timeout or network error", async () => { it("consults egress for a timeout or network error", async () => {
const check: Record<string, unknown> = { id: "check-1" }; const check: Record<string, unknown> = { id: "check-1" };
const { pipeline, deps } = createPipeline({ 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) }, checkService: { toCheck: jest.fn<any>().mockReturnValue(check) },
egressService: { assessAfterFailure: jest.fn<any>().mockResolvedValue("degraded") }, 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"]) { for (const url of ["unix:///var/run/docker.sock", "/var/run/docker.sock"]) {
const check: Record<string, unknown> = { id: "check-1" }; const check: Record<string, unknown> = { id: "check-1" };
const { pipeline, deps } = createPipeline({ 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) }, checkService: { toCheck: jest.fn<any>().mockReturnValue(check) },
egressService: { assessAfterFailure: jest.fn<any>().mockResolvedValue("degraded") }, egressService: { assessAfterFailure: jest.fn<any>().mockResolvedValue("degraded") },
}); });
@@ -406,7 +406,7 @@ describe("WorkerPipeline", () => {
]) { ]) {
const check: Record<string, unknown> = { id: "check-1" }; const check: Record<string, unknown> = { id: "check-1" };
const { pipeline, deps } = createPipeline({ 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) }, checkService: { toCheck: jest.fn<any>().mockReturnValue(check) },
egressService: { assessAfterFailure: jest.fn<any>().mockResolvedValue("degraded") }, egressService: { assessAfterFailure: jest.fn<any>().mockResolvedValue("degraded") },
}); });
@@ -424,7 +424,7 @@ describe("WorkerPipeline", () => {
// them from a connection failure. // them from a connection failure.
const check: Record<string, unknown> = { id: "check-1" }; const check: Record<string, unknown> = { id: "check-1" };
const { pipeline, deps } = createPipeline({ 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) }, checkService: { toCheck: jest.fn<any>().mockReturnValue(check) },
egressService: { assessAfterFailure: jest.fn<any>().mockResolvedValue("degraded") }, 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 () => { 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 check: Record<string, unknown> = { id: "check-1" };
const { pipeline, deps } = createPipeline({ 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) }, checkService: { toCheck: jest.fn<any>().mockReturnValue(check) },
egressService: { assessAfterFailure: jest.fn<any>().mockResolvedValue("degraded") }, 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 () => { it("flags a failing check as degraded when the egress service reports degraded egress", async () => {
const check: Record<string, unknown> = { id: "check-1" }; const check: Record<string, unknown> = { id: "check-1" };
const { pipeline, deps } = createPipeline({ 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) }, checkService: { toCheck: jest.fn<any>().mockReturnValue(check) },
egressService: { assessAfterFailure: jest.fn<any>().mockResolvedValue("degraded") }, 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 () => { it("clears the status window on the first degraded check of a spell", async () => {
const { pipeline, deps } = createPipeline({ 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") }, 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 () => { it("does not re-write the status window for later degraded checks in the same spell", async () => {
const { pipeline, deps } = createPipeline({ 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") }, 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 () => { it("leaves the status window alone when the probe found egress fine", async () => {
const { pipeline, deps } = createPipeline({ 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") }, 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 () => { it("flags a failing check as ok when the probe found egress fine", async () => {
const check: Record<string, unknown> = { id: "check-1" }; const check: Record<string, unknown> = { id: "check-1" };
const { pipeline } = createPipeline({ const { pipeline } = createPipeline({
networkService: { requestStatus: jest.fn<any>().mockResolvedValue(failingStatus) }, providerRegistry: { probe: jest.fn<any>().mockResolvedValue(failingStatus) },
checkService: { toCheck: jest.fn<any>().mockReturnValue(check) }, checkService: { toCheck: jest.fn<any>().mockReturnValue(check) },
egressService: { assessAfterFailure: jest.fn<any>().mockResolvedValue("ok") }, 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 () => { 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 check: Record<string, unknown> = { id: "check-1" };
const { pipeline, deps } = createPipeline({ 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) }, checkService: { toCheck: jest.fn<any>().mockReturnValue(check) },
}); });
@@ -714,11 +714,7 @@ describe("WorkerPipeline", () => {
expect(result).toMatchObject({ expect(result).toMatchObject({
monitor: statusChange.monitor, monitor: statusChange.monitor,
check, check,
decision: expect.objectContaining({ decision: { transition: "status_down" },
shouldCreateIncident: true,
shouldSendNotification: true,
incidentReason: "status_down",
}),
}); });
}); });
@@ -730,7 +726,7 @@ describe("WorkerPipeline", () => {
const result = await pipeline.evaluateCheck(makeCheck({ status: true, statusCode: 200, message: "OK" }), makeMonitor({ status: "down" })); 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 () => { 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()); const result = await pipeline.evaluateCheck(makeCheck({ status: true, statusCode: 200, message: "OK" }), makeMonitor());
expect(result.decision).toMatchObject({ expect(result.decision).toEqual({ transition: null });
shouldCreateIncident: false,
shouldResolveIncident: false,
shouldSendNotification: false,
});
}); });
// ── degraded egress ─────────────────────────────────────────────────── // ── degraded egress ───────────────────────────────────────────────────
@@ -757,13 +749,7 @@ describe("WorkerPipeline", () => {
expect(deps.statusService.updateMonitorStatus).not.toHaveBeenCalled(); expect(deps.statusService.updateMonitorStatus).not.toHaveBeenCalled();
expect(result.monitor).toBe(monitor); expect(result.monitor).toBe(monitor);
expect(result).not.toHaveProperty("statusChange"); expect(result).not.toHaveProperty("statusChange");
expect(result.decision).toEqual({ expect(result.decision).toEqual({ transition: null });
shouldCreateIncident: false,
shouldResolveIncident: false,
shouldSendNotification: false,
incidentReason: null,
notificationReason: null,
});
expect(deps.logger.debug).toHaveBeenCalledWith(expect.objectContaining({ message: expect.stringContaining("egress was degraded") })); 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 () => { it("does nothing when statusChanged is false", async () => {
const decision = await decide(makeStatusChange({ status: "down", statusChanged: false, prevStatus: "up" })); const decision = await decide(makeStatusChange({ status: "down", statusChanged: false, prevStatus: "up" }));
expect(decision).toEqual({ expect(decision).toEqual({ transition: null });
shouldCreateIncident: false,
shouldResolveIncident: false,
shouldSendNotification: false,
incidentReason: null,
notificationReason: 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" })); const decision = await decide(makeStatusChange({ status: "down", statusChanged: true, prevStatus: "up" }));
expect(decision).toMatchObject({ expect(decision).toEqual({ transition: "status_down" });
shouldCreateIncident: true,
shouldSendNotification: true,
incidentReason: "status_down",
notificationReason: "status_change",
});
}); });
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" })); const decision = await decide(makeStatusChange({ status: "breached", statusChanged: true, prevStatus: "up" }));
expect(decision).toMatchObject({ expect(decision).toEqual({ transition: "threshold_breach" });
shouldCreateIncident: true,
shouldSendNotification: true,
incidentReason: "threshold_breach",
notificationReason: "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" })); const decision = await decide(makeStatusChange({ status: "up", statusChanged: true, prevStatus: "down" }));
expect(decision).toMatchObject({ expect(decision).toEqual({ transition: "status_up" });
shouldResolveIncident: true,
shouldSendNotification: true,
shouldCreateIncident: false,
notificationReason: "status_change",
});
}); });
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" })); const decision = await decide(makeStatusChange({ status: "up", statusChanged: true, prevStatus: "breached" }));
expect(decision).toMatchObject({ expect(decision).toEqual({ transition: "threshold_resolved" });
shouldResolveIncident: true,
shouldSendNotification: true,
shouldCreateIncident: false,
notificationReason: "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" })); 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 () => { it("carries the threshold breaches onto a breached decision", async () => {
const thresholdBreaches = { cpu: true, memory: false, disk: true, temp: false }; const thresholdBreaches = { cpu: true, memory: false, disk: true, temp: false };
const decision = await decide(makeStatusChange({ status: "breached", statusChanged: true, prevStatus: "up", thresholdBreaches })); 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 () => { 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. // 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 () => { 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" })); const decision = await decide(makeStatusChange({ status: "breached", statusChanged: true, prevStatus: "up" }));
expect(decision).toMatchObject({ shouldCreateIncident: true, incidentReason: "threshold_breach" }); expect(decision).toEqual({ transition: "threshold_breach" });
expect(decision).not.toHaveProperty("thresholdBreaches");
}); });
// Each decision starts from a shared template. A transition must not leak its flags into the next decision // A transition must not leak into the next decision the same pipeline makes, whether that is a no-op
// the same pipeline makes, whether that is a no-op evaluation or a degraded-egress short-circuit. // evaluation or a degraded-egress short-circuit.
it("starts every decision clean after a transition on the same pipeline", async () => { it("starts every decision clean after a transition on the same pipeline", async () => {
const updateMonitorStatus = jest const updateMonitorStatus = jest
.fn<any>() .fn<any>()
@@ -883,14 +842,8 @@ describe("WorkerPipeline", () => {
const second = await pipeline.evaluateCheck(failing, makeMonitor({ status: "down" })); const second = await pipeline.evaluateCheck(failing, makeMonitor({ status: "down" }));
const degraded = await pipeline.evaluateCheck(makeCheck({ ...failing, egressStatus: "degraded" }), makeMonitor({ status: "down" })); const degraded = await pipeline.evaluateCheck(makeCheck({ ...failing, egressStatus: "degraded" }), makeMonitor({ status: "down" }));
expect(first.decision.shouldCreateIncident).toBe(true); expect(first.decision).toEqual({ transition: "status_down" });
const clean = { const clean = { transition: null };
shouldCreateIncident: false,
shouldResolveIncident: false,
shouldSendNotification: false,
incidentReason: null,
notificationReason: null,
};
expect(second.decision).toEqual(clean); expect(second.decision).toEqual(clean);
expect(degraded.decision).toEqual(clean); expect(degraded.decision).toEqual(clean);
}); });