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
Alex Holliday 5d77b8804f update tests 2026-09-24 14:22:31 -07:00
Alex Holliday 544502c6e5 move formatting 2026-09-24 14:19:13 -07:00
Alexander Holliday 1ef540679b Merge pull request #3992 from bluewave-labs/chore/refactor-cleanup
chore: refactor cleanup
2026-09-24 14:10:19 -07:00
Alex Holliday 18f38818e2 update tests 2026-09-24 14:03:34 -07:00
Alex Holliday 02e6333c3c narrow notificaitonReason type 2026-09-24 14:00:54 -07:00
Alex Holliday cb47439e9f remove double logging 2026-09-24 13:55:17 -07:00
Alex Holliday 4bbfe2e889 remove extra write on short window 2026-09-24 13:49:54 -07:00
Alex Holliday 187a0fc3d4 remove extra guard 2026-09-24 13:43:40 -07:00
Alex Holliday 4e4d3c3971 remove code and timestamp from stautschangeresult, no longer used 2026-09-24 13:41:58 -07:00
Alexander Holliday 6267e08a51 Merge pull request #3991 from bluewave-labs/fix/threshold-source-of-truth
fix: threshold source of truth
2026-09-24 13:33:12 -07:00
Alex Holliday f2c23636fe type only imports, add empty guard for threshold breaches do Docker monitors don't get this 2026-09-24 13:20:03 -07:00
Alex Holliday 11c89d150a update tests 2026-09-24 12:58:28 -07:00
Alex Holliday 6b75f9bf7c remove statusChange from decision, no longer needed 2026-09-24 12:41:33 -07:00
Alex Holliday 91de9e7fb7 pass threshold breaches on rather than reconsructing 2026-09-24 12:25:22 -07:00
Alex Holliday 21fb320f3a add hardware metric key types 2026-09-24 11:46:04 -07:00
Alexander Holliday 3586ac9437 Merge pull request #3984 from bluewave-labs/feat/dashboard-add-new-cards-options
feat(dashboard): add currently down and lowest uptime dashboard cards
2026-09-24 09:38:44 -07:00
Malena Aguiar b0ed75382e feat(dashboard): add code refactors 2026-09-23 17:57:28 -06:00
Alexander Holliday b1646cd83b Merge pull request #3988 from bluewave-labs/chore/evaluator-refactor
chore: refactor out rebuilding of status response
2026-09-23 13:56:56 -07:00
Alex Holliday 7abc74ac98 udpate tests 2026-09-23 13:54:55 -07:00
Alex Holliday a1b10c7469 remove unused param 2026-09-23 13:51:30 -07:00
Alex Holliday 0d75629dcb refactor out rebuilding of status response 2026-09-23 13:38:02 -07:00
Alexander Holliday 849ea26cf9 Merge pull request #3987 from bluewave-labs/fix/email-error-handling
fix: improve email error handling
2026-09-23 12:19:39 -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
Malena Aguiar bdd4634a3b feat(dashboard): add currently down and lowest uptime dashboard cards 2026-09-22 16:18:51 -06:00
97 changed files with 2041 additions and 3152 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",
+28 -1
View File
@@ -1,10 +1,16 @@
import { dashboardCardKeys, type DashboardCardKey } from "@/Types/Dashboard"; import {
dashboardCardKeys,
type DashboardCardKey,
type DashboardSortOrder,
} 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"
@@ -15,6 +21,8 @@ type TableName =
interface TableState { interface TableState {
rowsPerPage: number; rowsPerPage: number;
sortField?: string;
sortOrder?: SortOrder;
} }
interface SidebarState { interface SidebarState {
@@ -22,6 +30,7 @@ interface SidebarState {
} }
interface UIState { interface UIState {
dashboard: TableState;
monitors: TableState; monitors: TableState;
pagespeed: TableState; pagespeed: TableState;
team: TableState; team: TableState;
@@ -38,6 +47,7 @@ interface UIState {
starPromptOpen: boolean; starPromptOpen: boolean;
chartType: ChartType; chartType: ChartType;
dashboardVisibleCards: DashboardCardKey[]; dashboardVisibleCards: DashboardCardKey[];
dashboardUptimeSortOrder: DashboardSortOrder;
} }
const initialMode: ThemeMode = window?.matchMedia?.("(prefers-color-scheme: dark)") const initialMode: ThemeMode = window?.matchMedia?.("(prefers-color-scheme: dark)")
@@ -46,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,
}, },
@@ -78,6 +91,7 @@ const initialState: UIState = {
starPromptOpen: true, starPromptOpen: true,
chartType: "histogram", chartType: "histogram",
dashboardVisibleCards: [...dashboardCardKeys], dashboardVisibleCards: [...dashboardCardKeys],
dashboardUptimeSortOrder: "ascending",
}; };
const uiSlice = createSlice({ const uiSlice = createSlice({
@@ -94,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;
}, },
@@ -124,6 +146,9 @@ const uiSlice = createSlice({
setDashboardVisibleCards: (state, action: PayloadAction<DashboardCardKey[]>) => { setDashboardVisibleCards: (state, action: PayloadAction<DashboardCardKey[]>) => {
state.dashboardVisibleCards = action.payload; state.dashboardVisibleCards = action.payload;
}, },
setDashboardUptimeSortOrder: (state, action: PayloadAction<DashboardSortOrder>) => {
state.dashboardUptimeSortOrder = action.payload;
},
}, },
}); });
@@ -131,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,
@@ -141,4 +167,5 @@ export const {
setStarPromptOpen, setStarPromptOpen,
setChartType, setChartType,
setDashboardVisibleCards, setDashboardVisibleCards,
setDashboardUptimeSortOrder,
} = uiSlice.actions; } = uiSlice.actions;
+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,8 +1,11 @@
import { useState } from "react"; import { useMemo, useState } from "react";
import Stack from "@mui/material/Stack"; import Stack from "@mui/material/Stack";
import FormControlLabel from "@mui/material/FormControlLabel"; import FormControlLabel from "@mui/material/FormControlLabel";
import Typography from "@mui/material/Typography";
import { useTheme } from "@mui/material/styles";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import { Checkbox, Dialog } from "@/Components/inputs"; import { Checkbox, Dialog } from "@/Components/inputs";
import { LAYOUT } from "@/Utils/Theme/constants";
import { type DashboardCardKey, dashboardCardKeys } from "@/Types/Dashboard"; import { type DashboardCardKey, dashboardCardKeys } from "@/Types/Dashboard";
interface EditCardsModalProps { interface EditCardsModalProps {
@@ -18,16 +21,19 @@ export const EditCardsModal = ({
onClose, onClose,
onSave, onSave,
}: EditCardsModalProps) => { }: EditCardsModalProps) => {
const theme = useTheme();
const { t } = useTranslation(); const { t } = useTranslation();
const [draft, setDraft] = useState<Set<DashboardCardKey>>(new Set(visibleCards)); const [cardsSelection, setCardsSelection] = useState<Set<DashboardCardKey>>(
new Set(visibleCards)
);
const handleClose = () => { const handleClose = () => {
setDraft(new Set(visibleCards)); setCardsSelection(new Set(visibleCards));
onClose(); onClose();
}; };
const handleToggle = (key: DashboardCardKey) => { const handleToggle = (key: DashboardCardKey) => {
setDraft((prev) => { setCardsSelection((prev) => {
const next = new Set(prev); const next = new Set(prev);
if (next.has(key)) { if (next.has(key)) {
next.delete(key); next.delete(key);
@@ -38,26 +44,40 @@ export const EditCardsModal = ({
}); });
}; };
const sortedCardKeys = useMemo(
() =>
[...dashboardCardKeys].sort((a, b) =>
t(`pages.dashboard.cards.${a}`).localeCompare(t(`pages.dashboard.cards.${b}`))
),
[t]
);
return ( return (
<Dialog <Dialog
open={open} open={open}
title={t("pages.dashboard.editCardsModal.title")} title={t("pages.dashboard.editCardsModal.title")}
content={t("pages.dashboard.editCardsModal.description")} content={t("pages.dashboard.editCardsModal.description")}
onCancel={handleClose} onCancel={handleClose}
onConfirm={() => onSave(draft)} onConfirm={() => onSave(cardsSelection)}
confirmText={t("pages.dashboard.editCardsModal.done")} confirmText={t("pages.dashboard.editCardsModal.done")}
maxWidth="xs" maxWidth="xs"
fullWidth fullWidth
> >
<Stack> <Stack gap={theme.spacing(LAYOUT.XXS)}>
{dashboardCardKeys.map((key: DashboardCardKey) => ( <Typography
variant="eyebrow"
color={theme.palette.text.disabled}
>
{t("pages.dashboard.editCardsModal.groups.uptime")}
</Typography>
{sortedCardKeys.map((key: DashboardCardKey) => (
<FormControlLabel <FormControlLabel
key={key} key={key}
label={t(`pages.dashboard.cards.${key}`)} label={t(`pages.dashboard.cards.${key}`)}
control={ control={
<Checkbox <Checkbox
checked={draft.has(key)} checked={cardsSelection.has(key)}
disabled={draft.has(key) && draft.size === 1} disabled={cardsSelection.has(key) && cardsSelection.size === 1}
onChange={() => handleToggle(key)} onChange={() => handleToggle(key)}
/> />
} }
@@ -0,0 +1,58 @@
import Stack from "@mui/material/Stack";
import Typography from "@mui/material/Typography";
import { useTheme } from "@mui/material/styles";
import { useTranslation } from "react-i18next";
import { DashboardCard } from "@/Pages/Dashboard/components/DashboardCard";
import { Dot } from "@/Components/design-elements";
import { typographyLevels } from "@/Utils/Theme/Palette";
import { LAYOUT } from "@/Utils/Theme/constants";
import type { Monitor } from "@/Types/Monitor";
interface CurrentlyDownCardProps {
monitors: Monitor[];
}
export const CurrentlyDownCard = ({ monitors }: CurrentlyDownCardProps) => {
const theme = useTheme();
const { t } = useTranslation();
const downMonitors = monitors.filter((monitor) => monitor.status === "down");
return (
<DashboardCard
title={t("pages.dashboard.cards.currentlyDown")}
topRight={downMonitors.length > 0 ? downMonitors.length : undefined}
>
{downMonitors.length === 0 ? (
<Typography
fontSize={typographyLevels.m}
color={theme.palette.text.disabled}
>
{t("pages.dashboard.currentlyDown.empty")}
</Typography>
) : (
<Stack gap={theme.spacing(LAYOUT.SM)}>
{downMonitors.map((monitor) => (
<Stack
key={monitor.id}
direction="row"
alignItems="center"
gap={theme.spacing(LAYOUT.XS)}
>
<Dot color={theme.palette.error.main} />
<Typography
fontSize={typographyLevels.m}
color={theme.palette.text.primary}
overflow="hidden"
textOverflow="ellipsis"
whiteSpace="nowrap"
>
{monitor.name}
</Typography>
</Stack>
))}
</Stack>
)}
</DashboardCard>
);
};
@@ -0,0 +1,90 @@
import IconButton from "@mui/material/IconButton";
import Stack from "@mui/material/Stack";
import Typography from "@mui/material/Typography";
import { useTheme } from "@mui/material/styles";
import { useTranslation } from "react-i18next";
import { useDispatch, useSelector } from "react-redux";
import { ArrowDown, ArrowUp } from "lucide-react";
import { Icon } from "@/Components/design-elements";
import { DashboardCard } from "@/Pages/Dashboard/components/DashboardCard";
import { BarRow } from "@/Pages/Dashboard/components/cards/BarRow";
import { getUptimePercentageColor } from "@/Utils/MonitorUtils";
import { formatPercentage } from "@/Utils/FormatUtils";
import { typographyLevels } from "@/Utils/Theme/Palette";
import { LAYOUT } from "@/Utils/Theme/constants";
import { setDashboardUptimeSortOrder } from "@/Features/UI/uiSlice";
import type { Monitor } from "@/Types/Monitor";
import type { AppDispatch, RootState } from "@/store";
const TOP_N = 5;
interface UptimeCardProps {
monitors: Monitor[];
}
export const UptimeCard = ({ monitors }: UptimeCardProps) => {
const theme = useTheme();
const { t } = useTranslation();
const dispatch = useDispatch<AppDispatch>();
const sortOrder = useSelector((state: RootState) => state.ui.dashboardUptimeSortOrder);
const direction = sortOrder === "ascending" ? 1 : -1;
const ranked = monitors
.filter((monitor) => monitor.status !== "paused")
.filter(
(monitor): monitor is Monitor & { uptimePercentage: number } =>
typeof monitor.uptimePercentage === "number"
)
.sort((a, b) => direction * (a.uptimePercentage - b.uptimePercentage))
.slice(0, TOP_N);
return (
<DashboardCard
title={t("pages.dashboard.cards.uptime")}
topRight={
<IconButton
size="small"
aria-label={t("pages.dashboard.uptimeCard.toggleSortOrder")}
onClick={() =>
dispatch(
setDashboardUptimeSortOrder(
sortOrder === "ascending" ? "descending" : "ascending"
)
)
}
>
<Icon
icon={sortOrder === "ascending" ? ArrowUp : ArrowDown}
size={16}
color={theme.palette.text.secondary}
/>
</IconButton>
}
>
{ranked.length === 0 ? (
<Typography
fontSize={typographyLevels.m}
color={theme.palette.text.disabled}
>
{t("pages.dashboard.noData")}
</Typography>
) : (
<Stack gap={theme.spacing(LAYOUT.SM)}>
{ranked.map((monitor) => {
const paletteKey = getUptimePercentageColor(monitor.uptimePercentage);
const color = theme.palette[paletteKey].main;
return (
<BarRow
key={monitor.id}
label={monitor.name}
value={formatPercentage(monitor.uptimePercentage)}
fillRatio={monitor.uptimePercentage}
valueColor={color}
/>
);
})}
</Stack>
)}
</DashboardCard>
);
};
+5 -1
View File
@@ -14,6 +14,8 @@ import { setDashboardVisibleCards } from "@/Features/UI/uiSlice";
import type { RootState, AppDispatch } from "@/store"; import type { RootState, AppDispatch } from "@/store";
import { MonitorStatusCard } from "@/Pages/Dashboard/components/cards/MonitorStatusCard"; import { MonitorStatusCard } from "@/Pages/Dashboard/components/cards/MonitorStatusCard";
import { CurrentlyDownCard } from "@/Pages/Dashboard/components/cards/CurrentlyDownCard";
import { UptimeCard } from "@/Pages/Dashboard/components/cards/UptimeCard";
import { MonitorsByTypeCard } from "@/Pages/Dashboard/components/cards/MonitorsByTypeCard"; import { MonitorsByTypeCard } from "@/Pages/Dashboard/components/cards/MonitorsByTypeCard";
import { EditCardsModal } from "@/Pages/Dashboard/components/EditCardsModal"; import { EditCardsModal } from "@/Pages/Dashboard/components/EditCardsModal";
import { type DashboardCardKey, dashboardCardKeys } from "@/Types/Dashboard"; import { type DashboardCardKey, dashboardCardKeys } from "@/Types/Dashboard";
@@ -32,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,
}); });
@@ -48,6 +50,8 @@ const Dashboard = () => {
const cardComponents: Record<DashboardCardKey, ReactNode> = { const cardComponents: Record<DashboardCardKey, ReactNode> = {
monitorStatus: <MonitorStatusCard summary={summary} />, monitorStatus: <MonitorStatusCard summary={summary} />,
currentlyDown: <CurrentlyDownCard monitors={monitors ?? []} />,
uptime: <UptimeCard monitors={monitors ?? []} />,
monitorsByType: <MonitorsByTypeCard monitorsByType={monitorsByType} />, monitorsByType: <MonitorsByTypeCard monitorsByType={monitorsByType} />,
}; };
@@ -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}
+9 -1
View File
@@ -1,2 +1,10 @@
export const dashboardCardKeys = ["monitorStatus", "monitorsByType"] as const; export const dashboardCardKeys = [
"monitorStatus",
"currentlyDown",
"uptime",
"monitorsByType",
] as const;
export type DashboardCardKey = (typeof dashboardCardKeys)[number]; export type DashboardCardKey = (typeof dashboardCardKeys)[number];
export const dashboardSortOrders = ["ascending", "descending"] as const;
export type DashboardSortOrder = (typeof dashboardSortOrders)[number];
+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",
+12 -1
View File
@@ -1936,16 +1936,27 @@
}, },
"cards": { "cards": {
"monitorStatus": "Monitor status", "monitorStatus": "Monitor status",
"currentlyDown": "Currently down",
"uptime": "Monitor uptime",
"monitorsByType": "Monitors by type" "monitorsByType": "Monitors by type"
}, },
"uptimeCard": {
"toggleSortOrder": "Toggle sort order"
},
"currentlyDown": {
"empty": "No monitors are down at this moment."
},
"header": { "header": {
"title": "Dashboard", "title": "Dashboard",
"description": "See every monitor type in one place and spot what needs attention." "description": "See every monitor type in one place and spot what needs attention."
}, },
"editCards": "Edit cards", "editCards": "Edit cards",
"editCardsModal": { "editCardsModal": {
"title": "Edit cards", "title": "Dashboard cards",
"description": "Select the cards to display on your dashboard.", "description": "Select the cards to display on your dashboard.",
"groups": {
"uptime": "Uptime"
},
"done": "Done" "done": "Done"
} }
} }
+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 };
}; };
-27
View File
@@ -13,7 +13,6 @@ const SERVICE_NAME = "checkService";
export interface ICheckService { export interface ICheckService {
createChecks(checks: Check[]): Promise<Check[]>; createChecks(checks: Check[]): Promise<Check[]>;
toCheck(statusResponse: MonitorStatusResponse<MonitorPayloadMap[keyof MonitorPayloadMap]>): Check | undefined; toCheck(statusResponse: MonitorStatusResponse<MonitorPayloadMap[keyof MonitorPayloadMap]>): Check | undefined;
toStatusResponse(check: Check): MonitorStatusResponse<MonitorPayloadMap[keyof MonitorPayloadMap]>;
getChecksByMonitor(params: { getChecksByMonitor(params: {
monitorId: string; monitorId: string;
teamId: string; teamId: string;
@@ -135,32 +134,6 @@ export class CheckService implements ICheckService {
return check; return check;
}; };
private toPayload = (check: Check): HardwareStatusPayload | DockerStatusPayload | undefined => {
switch (check.metadata.type) {
case "hardware":
return { data: { cpu: check.cpu, memory: check.memory, disk: check.disk, host: check.host, net: check.net } } as HardwareStatusPayload;
case "docker":
return check.containers ? { containers: check.containers, summary: check.containerSummary } : undefined;
default:
return undefined;
}
};
toStatusResponse = (check: Check) => {
const statusResponse: MonitorStatusResponse<MonitorPayloadMap[keyof MonitorPayloadMap]> = {
monitorId: check.metadata.monitorId,
teamId: check.metadata.teamId,
type: check.metadata.type,
status: check.status,
code: check.statusCode,
message: check.message,
responseTime: check.responseTime,
// re-nest hardware metrics so updateMonitorStatus / extractThresholdBreaches see payload.data
payload: this.toPayload(check),
};
return statusResponse;
};
getChecksByMonitor = async ({ getChecksByMonitor = async ({
monitorId, monitorId,
teamId, teamId,
+8 -6
View File
@@ -45,12 +45,14 @@ const mapMemory = (memory?: CheckMemoryInfo): SnapshotMemoryInfo | undefined =>
}; };
const mapDisk = (disk?: CheckDiskInfo[]): SnapshotDiskInfo[] | undefined => const mapDisk = (disk?: CheckDiskInfo[]): SnapshotDiskInfo[] | undefined =>
disk?.map((entry) => ({ disk
device: entry.device, ?.filter((entry) => entry != null)
total_bytes: entry.total_bytes, .map((entry) => ({
used_bytes: entry.used_bytes, device: entry.device,
usage_percent: entry.usage_percent, total_bytes: entry.total_bytes,
})); used_bytes: entry.used_bytes,
usage_percent: entry.usage_percent,
}));
const mapHost = (host?: CheckHostInfo): SnapshotHostInfo | undefined => const mapHost = (host?: CheckHostInfo): SnapshotHostInfo | undefined =>
host && { host && {
+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);
}), }),
+24 -65
View File
@@ -1,6 +1,5 @@
const SERVICE_NAME = "incidentService";
import type { Monitor } from "@/domain/monitors/monitor.type.js"; import type { Monitor } from "@/domain/monitors/monitor.type.js";
import type { DockerStatusPayload, MonitorStatusResponse } from "@/types/network.js"; import type { Check } from "@/domain/checks/check.type.js";
import { AppError } from "@/utils/AppError.js"; import { AppError } from "@/utils/AppError.js";
import { getDateForRange } from "@/utils/dataUtils.js"; import { getDateForRange } from "@/utils/dataUtils.js";
import type { IIncidentsRepository } from "@/domain/incidents/incident.repository.interface.js"; import type { IIncidentsRepository } from "@/domain/incidents/incident.repository.interface.js";
@@ -12,15 +11,10 @@ import type { MonitorActionDecision } from "@/worker/worker.interface.js";
import type { INotificationMessageBuilder } from "@/domain/notifications/notification.message-builder.js"; import type { INotificationMessageBuilder } from "@/domain/notifications/notification.message-builder.js";
import type { ILogger } from "@/utils/logger.js"; import type { ILogger } from "@/utils/logger.js";
import { DateRange } from "@/types/query.js"; import { DateRange } from "@/types/query.js";
import { describeContainerBreach, findContainerBreaches } from "@/domain/docker/docker-alert.js";
const SERVICE_NAME = "incidentService";
export interface IIncidentService { export interface IIncidentService {
handleIncident( handleIncident(monitor: Monitor, decision: MonitorActionDecision, check: Check): Promise<Incident | null>;
monitor: Monitor,
code: number,
decision: MonitorActionDecision,
monitorStatusResponse?: MonitorStatusResponse
): Promise<Incident | null>;
resolveIncident(incidentId: string, userId: string, teamId: string, comment?: string, userEmail?: string): Promise<Incident>; resolveIncident(incidentId: string, userId: string, teamId: string, comment?: string, userEmail?: string): Promise<Incident>;
getIncidentsByTeam( getIncidentsByTeam(
teamId: string, teamId: string,
@@ -59,73 +53,38 @@ export class IncidentService implements IIncidentService {
this.notificationMessageBuilder = notificationMessageBuilder; this.notificationMessageBuilder = notificationMessageBuilder;
} }
handleIncident = async ( handleIncident = async (monitor: Monitor, decision: MonitorActionDecision, check: Check): Promise<Incident | null> => {
monitor: Monitor, const { transition } = decision;
code: number, if (transition === null) return null;
decision: MonitorActionDecision,
monitorStatusResponse?: MonitorStatusResponse
): Promise<Incident | null> => {
if (!decision.shouldCreateIncident && !decision.shouldResolveIncident) {
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 = code; // 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.buildThresholdBreachMessage(monitor, monitorStatusResponse);
}
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);
}; };
private buildThresholdBreachMessage(monitor: Monitor, monitorStatusResponse?: MonitorStatusResponse): string {
if (monitor.type === "docker") {
const containers = (monitorStatusResponse?.payload as DockerStatusPayload | undefined)?.containers ?? [];
const breaches = findContainerBreaches(monitor, containers);
return breaches.length > 0 ? breaches.map(describeContainerBreach).join(", ") : "Container alert";
}
if (!monitorStatusResponse) {
return "Threshold breach detected";
}
const breaches = this.notificationMessageBuilder.extractThresholdBreaches(monitor, monitorStatusResponse);
if (breaches.length === 0) {
return "Threshold breach detected";
}
return breaches.map((b) => `${b.metric.toUpperCase()}: ${b.formattedValue} (threshold: ${b.threshold}${b.unit})`).join(", ");
}
resolveIncident = async (incidentId: string, userId: string, teamId: string, comment?: string, userEmail?: string) => { resolveIncident = async (incidentId: string, userId: string, teamId: string, comment?: string, userEmail?: string) => {
try { try {
if (!incidentId) { if (!incidentId) {
@@ -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[]> => {
@@ -240,3 +240,8 @@ export type GamesMap = Record<string, Game>;
export type MonitorScheduleFields = Pick<Monitor, "id" | "type" | "isActive" | "interval" | "geoCheckEnabled" | "geoCheckInterval">; export type MonitorScheduleFields = Pick<Monitor, "id" | "type" | "isActive" | "interval" | "geoCheckEnabled" | "geoCheckInterval">;
export type DockerContainerLogsResult = DockerLogPage; export type DockerContainerLogsResult = DockerLogPage;
export const HardwareMetricKeys = ["cpu", "memory", "disk", "temp"] as const;
export type HardwareMetricKey = (typeof HardwareMetricKeys)[number];
export type HardwareBreaches = Record<HardwareMetricKey, boolean>;
export type HardwareCounters = Record<HardwareMetricKey, number>;
@@ -1,5 +1,4 @@
import type { Monitor } from "@/domain/monitors/monitor.type.js"; import { HardwareMetricKeys, type HardwareBreaches, type HardwareMetricKey, type Monitor } from "@/domain/monitors/monitor.type.js";
import type { DockerStatusPayload, HardwareStatusPayload, MonitorStatusResponse } from "@/types/network.js";
import type { MonitorActionDecision } from "@/worker/worker.interface.js"; import type { MonitorActionDecision } from "@/worker/worker.interface.js";
import type { import type {
NotificationMessage, NotificationMessage,
@@ -9,15 +8,11 @@ import type {
NotificationContent, NotificationContent,
} from "@/domain/notifications/notification.type.js"; } from "@/domain/notifications/notification.type.js";
import { describeContainerBreach, findContainerBreaches } from "@/domain/docker/docker-alert.js"; import { describeContainerBreach, findContainerBreaches } from "@/domain/docker/docker-alert.js";
import type { Check } from "@/domain/checks/check.type.js";
export interface INotificationMessageBuilder { export interface INotificationMessageBuilder {
buildMessage( buildMessage(monitor: Monitor, check: Check, decision: MonitorActionDecision, clientHost: string): NotificationMessage;
monitor: Monitor, buildThresholdBreachMessage(monitor: Monitor, check: Check, thresholdBreaches: HardwareBreaches | undefined): string;
monitorStatusResponse: MonitorStatusResponse,
decision: MonitorActionDecision,
clientHost: string
): NotificationMessage;
extractThresholdBreaches(monitor: Monitor, monitorStatusResponse: MonitorStatusResponse): ThresholdBreach[];
} }
const SERVICE_NAME = "NotificationMessageBuilder"; const SERVICE_NAME = "NotificationMessageBuilder";
@@ -25,15 +20,10 @@ const SERVICE_NAME = "NotificationMessageBuilder";
export class NotificationMessageBuilder implements INotificationMessageBuilder { export class NotificationMessageBuilder implements INotificationMessageBuilder {
static SERVICE_NAME = SERVICE_NAME; static SERVICE_NAME = SERVICE_NAME;
buildMessage( buildMessage(monitor: Monitor, check: Check, decision: MonitorActionDecision, clientHost: string): NotificationMessage {
monitor: Monitor,
monitorStatusResponse: MonitorStatusResponse,
decision: MonitorActionDecision,
clientHost: string
): NotificationMessage {
const type = this.determineNotificationType(decision, monitor); const type = this.determineNotificationType(decision, monitor);
const severity = this.determineSeverity(type); const severity = this.determineSeverity(type);
const content = this.buildContent(type, monitor, monitorStatusResponse); const content = this.buildContent(type, monitor, check, decision);
return { return {
type, type,
@@ -49,24 +39,21 @@ export class NotificationMessageBuilder implements INotificationMessageBuilder {
clientHost, clientHost,
metadata: { metadata: {
teamId: monitor.teamId, teamId: monitor.teamId,
notificationReason: decision.notificationReason || "status_change",
}, },
}; };
} }
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";
} }
} }
@@ -92,18 +79,18 @@ export class NotificationMessageBuilder implements INotificationMessageBuilder {
} }
} }
private buildContent(type: NotificationType, monitor: Monitor, monitorStatusResponse: MonitorStatusResponse): NotificationContent { private buildContent(type: NotificationType, monitor: Monitor, check: Check, decision: MonitorActionDecision): NotificationContent {
switch (type) { switch (type) {
case "monitor_down": case "monitor_down":
return this.buildMonitorDownContent(monitor, monitorStatusResponse); return this.buildMonitorDownContent(monitor, check);
case "monitor_up": case "monitor_up":
return this.buildMonitorUpContent(monitor); return this.buildMonitorUpContent(monitor);
case "threshold_breach": case "threshold_breach":
return this.buildThresholdBreachContent(monitor, monitorStatusResponse as MonitorStatusResponse<HardwareStatusPayload>); return this.buildThresholdBreachContent(monitor, check, decision.thresholdBreaches);
case "threshold_resolved": case "threshold_resolved":
return this.buildThresholdResolvedContent(monitor); return this.buildThresholdResolvedContent(monitor);
case "container_breach": case "container_breach":
return this.buildContainerBreachContent(monitor, monitorStatusResponse as MonitorStatusResponse<DockerStatusPayload>); return this.buildContainerBreachContent(monitor, check);
case "container_resolved": case "container_resolved":
return this.buildContainersRecoveredContent(monitor); return this.buildContainersRecoveredContent(monitor);
default: default:
@@ -111,19 +98,19 @@ export class NotificationMessageBuilder implements INotificationMessageBuilder {
} }
} }
private buildMonitorDownContent(monitor: Monitor, monitorStatusResponse: MonitorStatusResponse): NotificationContent { private buildMonitorDownContent(monitor: Monitor, check: Check): NotificationContent {
const title = `Monitor Down: ${monitor.name}`; const title = `Monitor Down: ${monitor.name}`;
const summary = `Monitor "${monitor.name}" is currently down and unreachable.`; const summary = `Monitor "${monitor.name}" is currently down and unreachable.`;
const details = [`URL: ${monitor.url}`, `Status: Down`, `Type: ${monitor.type}`]; const details = [`URL: ${monitor.url}`, `Status: Down`, `Type: ${monitor.type}`];
// Add response code if available // Add response code if available
if (monitorStatusResponse.code) { if (check.statusCode) {
details.push(`Response Code: ${monitorStatusResponse.code}`); details.push(`Response Code: ${check.statusCode}`);
} }
// Add error message if available // Add error message if available
if (monitorStatusResponse.message) { if (check.message) {
details.push(`Error: ${monitorStatusResponse.message}`); details.push(`Error: ${check.message}`);
} }
return { return {
@@ -147,12 +134,12 @@ export class NotificationMessageBuilder implements INotificationMessageBuilder {
}; };
} }
private buildThresholdBreachContent(monitor: Monitor, monitorStatusResponse: MonitorStatusResponse<HardwareStatusPayload>): NotificationContent { private buildThresholdBreachContent(monitor: Monitor, check: Check, thresholdBreaches: HardwareBreaches | undefined): NotificationContent {
const title = `Threshold Exceeded: ${monitor.name}`; const title = `Threshold Exceeded: ${monitor.name}`;
const summary = `Monitor "${monitor.name}" has exceeded one or more thresholds.`; const summary = `Monitor "${monitor.name}" has exceeded one or more thresholds.`;
const details = [`URL: ${monitor.url}`, `Status: Threshold exceeded`, `Type: ${monitor.type}`]; const details = [`URL: ${monitor.url}`, `Status: Threshold exceeded`, `Type: ${monitor.type}`];
const thresholds = this.extractThresholdBreaches(monitor, monitorStatusResponse); const thresholds = this.extractThresholdBreaches(monitor, check, thresholdBreaches);
return { return {
title, title,
@@ -176,13 +163,11 @@ export class NotificationMessageBuilder implements INotificationMessageBuilder {
}; };
} }
private buildContainerBreachContent(monitor: Monitor, monitorStatusResponse: MonitorStatusResponse<DockerStatusPayload>): NotificationContent { private buildContainerBreachContent(monitor: Monitor, check: Check): NotificationContent {
const payload = monitorStatusResponse.payload; const lines = this.describeContainerBreaches(monitor, check);
const containers = payload && typeof payload !== "string" ? payload.containers : [];
const breaches = findContainerBreaches(monitor, containers);
const title = `Container Alert: ${monitor.name}`; const title = `Container Alert: ${monitor.name}`;
const summary = `${breaches.length} container(s) on "${monitor.name}" need attention.`; const summary = `${lines.length} container(s) on "${monitor.name}" need attention.`;
const details = [`URL: ${monitor.url}`, `Type: ${monitor.type}`, ...breaches.map(describeContainerBreach)]; const details = [`URL: ${monitor.url}`, `Type: ${monitor.type}`, ...lines];
return { title, summary, details, timestamp: new Date() }; return { title, summary, details, timestamp: new Date() };
} }
@@ -202,93 +187,57 @@ export class NotificationMessageBuilder implements INotificationMessageBuilder {
}; };
} }
public extractThresholdBreaches(monitor: Monitor, monitorStatusResponse: MonitorStatusResponse<HardwareStatusPayload>): ThresholdBreach[] { private describeBreach(metric: HardwareMetricKey, monitor: Monitor, check: Check): ThresholdBreach {
switch (metric) {
case "cpu": {
const currentValue = (check.cpu?.usage_percent ?? 0) * 100;
return { metric, currentValue, threshold: monitor.cpuAlertThreshold, unit: "%", formattedValue: `${currentValue.toFixed(1)}%` };
}
case "memory": {
const currentValue = (check.memory?.usage_percent ?? 0) * 100;
return { metric, currentValue, threshold: monitor.memoryAlertThreshold, unit: "%", formattedValue: `${currentValue.toFixed(1)}%` };
}
case "disk": {
const currentValue = Math.max(0, ...(check.disk ?? []).map((disk) => disk?.usage_percent ?? 0)) * 100;
return { metric, currentValue, threshold: monitor.diskAlertThreshold, unit: "%", formattedValue: `${currentValue.toFixed(1)}%` };
}
case "temp": {
const currentValue = Math.max(0, ...(check.cpu?.temperature ?? []));
return { metric, currentValue, threshold: monitor.tempAlertThreshold, unit: "°C", formattedValue: `${currentValue.toFixed(1)}°C` };
}
}
}
private extractThresholdBreaches(monitor: Monitor, check: Check, thresholdBreaches: HardwareBreaches | undefined): ThresholdBreach[] {
const breaches: ThresholdBreach[] = []; const breaches: ThresholdBreach[] = [];
// Check if this is a hardware monitor with threshold data // Check if this is a hardware monitor with threshold data
if (monitor.type !== "hardware" || !monitorStatusResponse.payload || typeof monitorStatusResponse.payload === "string") { if (monitor.type !== "hardware" || thresholdBreaches === undefined) {
return breaches; return breaches;
} }
// Cast to HardwareStatusPayload type for (const metric of HardwareMetricKeys) {
const payload = monitorStatusResponse.payload; if (!thresholdBreaches[metric]) continue;
const hardware = payload.data; breaches.push(this.describeBreach(metric, monitor, check));
if (!hardware) {
return breaches;
}
// Note: usage_percent values in hardware payload are decimals (0-1)
if (monitor.cpuAlertThreshold !== undefined && monitor.cpuAlertThreshold !== null && hardware.cpu?.usage_percent !== undefined) {
const cpuUsageDecimal = hardware.cpu.usage_percent;
const cpuPercent = cpuUsageDecimal * 100;
const threshold = monitor.cpuAlertThreshold;
if (cpuPercent > threshold) {
breaches.push({
metric: "cpu",
currentValue: cpuPercent,
threshold,
unit: "%",
formattedValue: `${cpuPercent.toFixed(1)}%`,
});
}
}
// Memory threshold breach
if (monitor.memoryAlertThreshold !== undefined && monitor.memoryAlertThreshold !== null && hardware.memory?.usage_percent !== undefined) {
const memoryUsageDecimal = hardware.memory.usage_percent;
const memoryPercent = memoryUsageDecimal * 100;
const threshold = monitor.memoryAlertThreshold;
if (memoryPercent > threshold) {
breaches.push({
metric: "memory",
currentValue: memoryPercent,
threshold,
unit: "%",
formattedValue: `${memoryPercent.toFixed(1)}%`,
});
}
}
// Disk threshold breach
if (monitor.diskAlertThreshold !== undefined && monitor.diskAlertThreshold !== null && Array.isArray(hardware.disk)) {
// Find the highest disk usage
let maxDiskUsageDecimal = 0;
for (const disk of hardware.disk) {
if (disk.usage_percent !== undefined && disk.usage_percent > maxDiskUsageDecimal) {
maxDiskUsageDecimal = disk.usage_percent;
}
}
const maxDiskPercent = maxDiskUsageDecimal * 100;
const threshold = monitor.diskAlertThreshold;
if (maxDiskPercent > threshold) {
breaches.push({
metric: "disk",
currentValue: maxDiskPercent,
threshold,
unit: "%",
formattedValue: `${maxDiskPercent.toFixed(1)}%`,
});
}
}
// Temperature threshold breach
if (monitor.tempAlertThreshold !== undefined && monitor.tempAlertThreshold !== null && hardware.cpu?.temperature) {
// Temperature is an array in cpu.temperature
const temps = Array.isArray(hardware.cpu.temperature) ? hardware.cpu.temperature : [hardware.cpu.temperature];
const maxTemp = Math.max(...temps.filter((t: number) => !isNaN(t)));
const threshold = monitor.tempAlertThreshold;
if (maxTemp > threshold) {
breaches.push({
metric: "temp",
currentValue: maxTemp,
threshold,
unit: "°C",
formattedValue: `${maxTemp.toFixed(1)}°C`,
});
}
} }
return breaches; return breaches;
} }
private describeContainerBreaches(monitor: Monitor, check: Check): string[] {
return findContainerBreaches(monitor, check.containers ?? []).map(describeContainerBreach);
}
public buildThresholdBreachMessage(monitor: Monitor, check: Check, thresholdBreaches: HardwareBreaches | undefined): string {
if (monitor.type === "docker") {
const lines = this.describeContainerBreaches(monitor, check);
return lines.length > 0 ? lines.join(", ") : "Container alert";
}
const breaches = this.extractThresholdBreaches(monitor, check, thresholdBreaches);
if (breaches.length === 0) {
return "Threshold breach detected";
}
return breaches.map((b) => `${b.metric.toUpperCase()}: ${b.formattedValue} (threshold: ${b.threshold}${b.unit})`).join(", ");
}
} }
@@ -1,6 +1,5 @@
import type { Monitor } from "@/domain/monitors/monitor.type.js"; import type { Monitor } from "@/domain/monitors/monitor.type.js";
import type { Notification } from "@/domain/notifications/notification.type.js"; import type { Notification } from "@/domain/notifications/notification.type.js";
import type { MonitorStatusResponse } from "@/types/network.js";
import type { NotificationMessage } from "@/domain/notifications/notification.type.js"; import type { NotificationMessage } from "@/domain/notifications/notification.type.js";
import { IMonitorsRepository } from "@/domain/monitors/monitor.repository.interface.js"; import { IMonitorsRepository } from "@/domain/monitors/monitor.repository.interface.js";
import { INotificationsRepository } from "@/domain/notifications/notification.repository.interface.js"; import { INotificationsRepository } from "@/domain/notifications/notification.repository.interface.js";
@@ -10,6 +9,7 @@ import type { ISettingsService } from "@/domain/app-settings/app-settings.servic
import { ILogger } from "@/utils/logger.js"; import { ILogger } from "@/utils/logger.js";
import type { INotificationMessageBuilder } from "@/domain/notifications/notification.message-builder.js"; import type { INotificationMessageBuilder } from "@/domain/notifications/notification.message-builder.js";
import type { NotificationChannel } from "@/domain/notifications/notification.type.js"; import type { NotificationChannel } from "@/domain/notifications/notification.type.js";
import type { Check } from "@/domain/checks/check.type.js";
export type NotificationProviderRegistry = Record<NotificationChannel, INotificationProvider>; export type NotificationProviderRegistry = Record<NotificationChannel, INotificationProvider>;
@@ -19,7 +19,7 @@ export interface INotificationsService {
findNotificationsByTeamId: (teamId: string) => Promise<Notification[]>; findNotificationsByTeamId: (teamId: string) => Promise<Notification[]>;
updateById(id: string, teamId: string, updateData: Partial<Notification>): Promise<Notification>; updateById(id: string, teamId: string, updateData: Partial<Notification>): Promise<Notification>;
deleteById: (id: string, teamId: string) => Promise<Notification>; deleteById: (id: string, teamId: string) => Promise<Notification>;
handleNotifications: (monitor: Monitor, monitorStatusResponse: MonitorStatusResponse, decision: MonitorActionDecision) => Promise<boolean>; handleNotifications: (monitor: Monitor, check: Check, decision: MonitorActionDecision) => Promise<boolean>;
sendTestNotification: (notification: Partial<Notification>) => Promise<boolean>; sendTestNotification: (notification: Partial<Notification>) => Promise<boolean>;
testAllNotifications: (notificationIds: string[]) => Promise<boolean>; testAllNotifications: (notificationIds: string[]) => Promise<boolean>;
@@ -63,7 +63,7 @@ export class NotificationsService implements INotificationsService {
private send = async ( private send = async (
notification: Notification, notification: Notification,
monitor: Monitor, monitor: Monitor,
monitorStatusResponse: MonitorStatusResponse, check: Check,
decision: MonitorActionDecision, decision: MonitorActionDecision,
notificationMessage: NotificationMessage | undefined notificationMessage: NotificationMessage | undefined
): Promise<boolean> => { ): Promise<boolean> => {
@@ -89,16 +89,16 @@ export class NotificationsService implements INotificationsService {
return await provider.sendMessage(notification, notificationMessage); return await provider.sendMessage(notification, notificationMessage);
}; };
private sendNotifications = async (monitor: Monitor, monitorStatusResponse: MonitorStatusResponse, decision: MonitorActionDecision) => { private sendNotifications = async (monitor: Monitor, check: Check, decision: MonitorActionDecision) => {
const notificationIds = monitor.notifications ?? []; const notificationIds = monitor.notifications ?? [];
const notifications = await this.notificationsRepository.findNotificationsByIds(notificationIds); const notifications = await this.notificationsRepository.findNotificationsByIds(notificationIds);
// Build notification message once for all notifications // Build notification message once for all notifications
const settings = this.settingsService.getSettings(); const settings = this.settingsService.getSettings();
const clientHost = settings.clientHost || "Host not defined"; const clientHost = settings.clientHost || "Host not defined";
const notificationMessage = this.notificationMessageBuilder.buildMessage(monitor, monitorStatusResponse, decision, clientHost); const notificationMessage = this.notificationMessageBuilder.buildMessage(monitor, check, decision, clientHost);
const tasks = notifications.map((notification) => this.send(notification, monitor, monitorStatusResponse, decision, notificationMessage)); const tasks = notifications.map((notification) => this.send(notification, monitor, check, decision, notificationMessage));
const outcomes = await Promise.all(tasks); const outcomes = await Promise.all(tasks);
const succeeded = outcomes.filter(Boolean).length; const succeeded = outcomes.filter(Boolean).length;
@@ -114,13 +114,11 @@ export class NotificationsService implements INotificationsService {
return succeeded === notifications.length; return succeeded === notifications.length;
}; };
handleNotifications = async (monitor: Monitor, monitorStatusResponse: MonitorStatusResponse, 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, monitorStatusResponse, decision); return await this.sendNotifications(monitor, check, decision);
}; };
sendTestNotification = async (notification: Partial<Notification>) => { sendTestNotification = async (notification: Partial<Notification>) => {
@@ -1,3 +1,5 @@
import type { HardwareMetricKey } from "@/domain/monitors/monitor.type.js";
export const NotificationChannels = [ export const NotificationChannels = [
"email", "email",
"slack", "slack",
@@ -85,7 +87,7 @@ export interface MonitorInfo {
} }
export interface ThresholdBreach { export interface ThresholdBreach {
metric: "cpu" | "memory" | "disk" | "temp"; metric: HardwareMetricKey;
currentValue: number; currentValue: number;
threshold: number; threshold: number;
unit: string; unit: string;
@@ -117,6 +119,5 @@ export interface NotificationMessage {
clientHost: string; clientHost: string;
metadata: { metadata: {
teamId: string; teamId: string;
notificationReason: string;
}; };
} }
+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",
};
}
}
}
+71 -144
View File
@@ -1,19 +1,15 @@
import { IMonitorStatsRepository } from "@/domain/monitor-stats/monitor-stats.repository.interface.js"; import { IMonitorStatsRepository } from "@/domain/monitor-stats/monitor-stats.repository.interface.js";
import { IMonitorsRepository } from "@/domain/monitors/monitor.repository.interface.js"; import { IMonitorsRepository } from "@/domain/monitors/monitor.repository.interface.js";
import type { Check, CheckDiskInfo } from "@/domain/checks/check.type.js"; import type { Check, CheckDiskInfo } from "@/domain/checks/check.type.js";
import { MonitorStatuses, type Monitor, type MonitorStatus } from "@/domain/monitors/monitor.type.js"; import {
import type { HardwareMetricKeys,
DockerStatusPayload, MonitorStatuses,
GameStatusPayload, type HardwareBreaches,
GrpcStatusPayload, type HardwareCounters,
HardwareStatusPayload, type Monitor,
HttpStatusPayload, type MonitorStatus,
MonitorStatusResponse, } from "@/domain/monitors/monitor.type.js";
PageSpeedStatusPayload, import type { StatusChangeResult } from "@/types/network.js";
PingStatusPayload,
PortStatusPayload,
StatusChangeResult,
} from "@/types/network.js";
import { AppError } from "@/utils/AppError.js"; import { AppError } from "@/utils/AppError.js";
import { ILogger } from "@/utils/logger.js"; import { ILogger } from "@/utils/logger.js";
import type { HardwareStatusMetrics } from "@/types/network.js"; import type { HardwareStatusMetrics } from "@/types/network.js";
@@ -23,28 +19,9 @@ import { findContainerBreaches } from "@/domain/docker/docker-alert.js";
const SERVICE_NAME = "StatusService"; const SERVICE_NAME = "StatusService";
const HARDWARE_ALERT_COUNTER_START = 5; const HARDWARE_ALERT_COUNTER_START = 5;
const HARDWARE_METRIC_KEYS = ["cpu", "memory", "disk", "temp"] as const;
type HardwareMetricKey = (typeof HARDWARE_METRIC_KEYS)[number];
type HardwareBreaches = Record<HardwareMetricKey, boolean>;
type HardwareCounters = Record<HardwareMetricKey, number>;
export interface IStatusService { export interface IStatusService {
updateRunningStats(monitor: Monitor, networkResponse: MonitorStatusResponse): Promise<boolean>; updateMonitorStatus(check: Check, monitor: Monitor): Promise<StatusChangeResult>;
updateMonitorStatus(
statusResponse: MonitorStatusResponse<
| PingStatusPayload
| HttpStatusPayload
| PageSpeedStatusPayload
| HardwareStatusPayload
| DockerStatusPayload
| PortStatusPayload
| GameStatusPayload
| GrpcStatusPayload
| undefined
>,
check: Check,
monitor: Monitor
): Promise<StatusChangeResult>;
} }
export class StatusService implements IStatusService { export class StatusService implements IStatusService {
@@ -59,32 +36,19 @@ export class StatusService implements IStatusService {
this.monitorStatsRepository = monitorStatsRepository; this.monitorStatsRepository = monitorStatsRepository;
} }
async updateRunningStats(monitor: Monitor, networkResponse: MonitorStatusResponse) { private tryUpdateRunningStats = async (check: Check, monitor: Monitor) => {
try { try {
await this.monitorStatsRepository.updateByMonitorId(monitor.id, { await this.monitorStatsRepository.updateByMonitorId(monitor.id, {
status: networkResponse.status === true, status: check.status === true,
responseTime: networkResponse.responseTime ?? 0, responseTime: check.responseTime ?? 0,
now: Date.now(), now: Date.now(),
}); });
return true;
} catch (error: unknown) { } catch (error: unknown) {
this.logger.error({
service: SERVICE_NAME,
message: error instanceof Error ? error.message : "Unknown error",
method: "updateRunningStats",
stack: error instanceof Error ? error.stack : undefined,
});
return false;
}
}
private tryUpdateRunningStats = async (monitor: Monitor, statusResponse: MonitorStatusResponse) => {
const statsOk = await this.updateRunningStats(monitor, statusResponse);
if (!statsOk) {
this.logger.warn({ this.logger.warn({
service: SERVICE_NAME, service: SERVICE_NAME,
method: "updateMonitorStatus", message: error instanceof Error ? error.message : "Unknown error",
message: `Stats update failed for monitor ${monitor.id}`, method: "tryUpdateRunningStats",
stack: error instanceof Error ? error.stack : undefined,
}); });
} }
}; };
@@ -136,7 +100,7 @@ export class StatusService implements IStatusService {
// Update counters: decrement (floored at 0) if breached, reset to start otherwise. // Update counters: decrement (floored at 0) if breached, reset to start otherwise.
const nextCounters = { ...counters }; const nextCounters = { ...counters };
for (const key of HARDWARE_METRIC_KEYS) { for (const key of HardwareMetricKeys) {
nextCounters[key] = breaches[key] ? Math.max(0, counters[key] - 1) : HARDWARE_ALERT_COUNTER_START; nextCounters[key] = breaches[key] ? Math.max(0, counters[key] - 1) : HARDWARE_ALERT_COUNTER_START;
} }
@@ -148,8 +112,8 @@ export class StatusService implements IStatusService {
if (!reachabilityDown) { if (!reachabilityDown) {
// A counter can only reach zero via the decrement path, which only runs when that // A counter can only reach zero via the decrement path, which only runs when that
// metric is currently breaching — so anyCounterZero already implies anyBreached. // metric is currently breaching — so anyCounterZero already implies anyBreached.
const anyCounterZero = HARDWARE_METRIC_KEYS.some((k) => nextCounters[k] === 0); const anyCounterZero = HardwareMetricKeys.some((k) => nextCounters[k] === 0);
const allNormal = HARDWARE_METRIC_KEYS.every((k) => !breaches[k]); const allNormal = HardwareMetricKeys.every((k) => !breaches[k]);
if (anyCounterZero && currentStatus !== "breached") { if (anyCounterZero && currentStatus !== "breached") {
nextStatus = "breached"; nextStatus = "breached";
@@ -180,26 +144,12 @@ export class StatusService implements IStatusService {
return { nextStatus, transitioned }; return { nextStatus, transitioned };
}; };
updateMonitorStatus = async ( updateMonitorStatus = async (check: Check, monitor: Monitor): Promise<StatusChangeResult> => {
statusResponse: MonitorStatusResponse<
| PingStatusPayload
| HttpStatusPayload
| PageSpeedStatusPayload
| HardwareStatusPayload
| DockerStatusPayload
| PortStatusPayload
| GameStatusPayload
| GrpcStatusPayload
| undefined
>,
check: Check,
monitor: Monitor
): Promise<StatusChangeResult> => {
try { try {
const { status, code } = statusResponse; const { status } = check;
// Update running stats // Update running stats
await this.tryUpdateRunningStats(monitor, statusResponse); await this.tryUpdateRunningStats(check, monitor);
const prevStatus = monitor.status; const prevStatus = monitor.status;
const checkSnapshot = toCheckSnapshot(check); const checkSnapshot = toCheckSnapshot(check);
@@ -224,83 +174,62 @@ export class StatusService implements IStatusService {
statusChanged = newStatus === "down"; statusChanged = newStatus === "down";
} }
// Not enough data points yet — record the check and return
if (projectedWindow.length < monitor.statusWindowSize) {
const updated = await this.monitorsRepository.updateStatusWindowAndChecks(
monitor.id,
monitor.teamId,
check.status,
checkSnapshot,
monitor.statusWindowSize,
MAX_RECENT_CHECKS,
patch
);
return {
monitor: updated,
statusChanged,
prevStatus,
code,
timestamp: Date.now(),
};
}
// First evaluate reachability status changes, which apply to all monitor types
// and take precedence over hardware breaches.
const reachabilityResult = this.computeReachability(newStatus, projectedWindow, monitor.statusWindowThreshold);
if (reachabilityResult.transitioned) {
newStatus = reachabilityResult.nextStatus;
statusChanged = true;
}
// Evaluate hardware threshold breaches (only for hardware monitors with metrics payload)
let thresholdBreaches: HardwareBreaches | undefined; let thresholdBreaches: HardwareBreaches | undefined;
const hardwarePayload = statusResponse.payload as HardwareStatusPayload | undefined;
if (monitor.type === "hardware" && hardwarePayload?.data) {
const hardware = this.computeHardwareStatus({
currentStatus: newStatus,
reachabilityDown: newStatus === "down",
metrics: hardwarePayload.data,
thresholds: {
cpu: monitor.cpuAlertThreshold,
memory: monitor.memoryAlertThreshold,
disk: monitor.diskAlertThreshold,
temp: monitor.tempAlertThreshold,
},
counters: {
cpu: monitor.cpuAlertCounter,
memory: monitor.memoryAlertCounter,
disk: monitor.diskAlertCounter,
temp: monitor.tempAlertCounter,
},
});
patch.cpuAlertCounter = hardware.nextCounters.cpu; if (projectedWindow.length >= monitor.statusWindowSize) {
patch.memoryAlertCounter = hardware.nextCounters.memory; // First evaluate reachability status changes, which apply to all monitor types
patch.diskAlertCounter = hardware.nextCounters.disk; // and take precedence over hardware breaches.
patch.tempAlertCounter = hardware.nextCounters.temp; const reachabilityResult = this.computeReachability(newStatus, projectedWindow, monitor.statusWindowThreshold);
thresholdBreaches = hardware.breaches; if (reachabilityResult.transitioned) {
if (hardware.transitioned) { newStatus = reachabilityResult.nextStatus;
newStatus = hardware.nextStatus;
statusChanged = true; statusChanged = true;
} }
}
const dockerPayload = statusResponse.payload as DockerStatusPayload | undefined; // Evaluate hardware threshold breaches
if (monitor.type === "docker" && dockerPayload?.containers) { if (monitor.type === "hardware") {
const breaches = findContainerBreaches(monitor, dockerPayload.containers); const hardware = this.computeHardwareStatus({
const docker = this.computeDockerStatus({ currentStatus: newStatus,
currentStatus: newStatus, reachabilityDown: newStatus === "down",
reachabilityDown: newStatus === "down", metrics: check,
breaching: breaches.length > 0, thresholds: {
}); cpu: monitor.cpuAlertThreshold,
if (docker.transitioned) { memory: monitor.memoryAlertThreshold,
newStatus = docker.nextStatus; disk: monitor.diskAlertThreshold,
statusChanged = true; temp: monitor.tempAlertThreshold,
},
counters: {
cpu: monitor.cpuAlertCounter,
memory: monitor.memoryAlertCounter,
disk: monitor.diskAlertCounter,
temp: monitor.tempAlertCounter,
},
});
patch.cpuAlertCounter = hardware.nextCounters.cpu;
patch.memoryAlertCounter = hardware.nextCounters.memory;
patch.diskAlertCounter = hardware.nextCounters.disk;
patch.tempAlertCounter = hardware.nextCounters.temp;
thresholdBreaches = hardware.breaches;
if (hardware.transitioned) {
newStatus = hardware.nextStatus;
statusChanged = true;
}
} }
}
patch.status = newStatus; if (monitor.type === "docker" && check.containers) {
const breaches = findContainerBreaches(monitor, check.containers);
const docker = this.computeDockerStatus({
currentStatus: newStatus,
reachabilityDown: newStatus === "down",
breaching: breaches.length > 0,
});
if (docker.transitioned) {
newStatus = docker.nextStatus;
statusChanged = true;
}
}
patch.status = newStatus;
}
// Single atomic write: push arrays + set status/counters // Single atomic write: push arrays + set status/counters
const updated = await this.monitorsRepository.updateStatusWindowAndChecks( const updated = await this.monitorsRepository.updateStatusWindowAndChecks(
@@ -317,8 +246,6 @@ export class StatusService implements IStatusService {
monitor: updated, monitor: updated,
statusChanged, statusChanged,
prevStatus, prevStatus,
code,
timestamp: new Date().getTime(),
thresholdBreaches, thresholdBreaches,
}; };
} catch (error: unknown) { } catch (error: unknown) {
+2 -9
View File
@@ -10,7 +10,7 @@ import type {
ILighthouseAudit, ILighthouseAudit,
} from "@/domain/checks/check.type.js"; } from "@/domain/checks/check.type.js";
import { DockerContainerInfo, DockerContainerLogs, DockerContainerSummary } from "@/domain/docker/docker.type.js"; import { DockerContainerInfo, DockerContainerLogs, DockerContainerSummary } from "@/domain/docker/docker.type.js";
import type { DnsRecordType, Monitor, MonitorMatchMethod, MonitorStatus, MonitorType } from "@/domain/monitors/monitor.type.js"; import type { DnsRecordType, HardwareBreaches, Monitor, MonitorMatchMethod, MonitorStatus, MonitorType } from "@/domain/monitors/monitor.type.js";
import type { QueryResult } from "gamedig"; import type { QueryResult } from "gamedig";
@@ -182,14 +182,7 @@ export type StatusChangeResult = {
monitor: Monitor; monitor: Monitor;
statusChanged: boolean; statusChanged: boolean;
prevStatus: MonitorStatus; prevStatus: MonitorStatus;
code: number; thresholdBreaches?: HardwareBreaches;
timestamp: number;
thresholdBreaches?: {
cpu: boolean;
memory: boolean;
disk: boolean;
temp: boolean;
};
}; };
export type MonitorStatusResponseOverrides<T> = Partial<Omit<MonitorStatusResponse<T>, "monitorId" | "teamId" | "type">>; export type MonitorStatusResponseOverrides<T> = Partial<Omit<MonitorStatusResponse<T>, "monitorId" | "teamId" | "type">>;
@@ -8,6 +8,6 @@ export class IncidentReactor implements IMonitorReactor {
constructor(private incidentService: IIncidentService) {} constructor(private incidentService: IIncidentService) {}
react = async (evaluation: MonitorEvaluation) => { react = async (evaluation: MonitorEvaluation) => {
await this.incidentService.handleIncident(evaluation.monitor, evaluation.statusChange.code, evaluation.decision, evaluation.status); await this.incidentService.handleIncident(evaluation.monitor, evaluation.decision, evaluation.check);
}; };
} }
@@ -7,7 +7,6 @@ export class NotificationReactor implements IMonitorReactor {
constructor(private notificationService: INotificationsService) {} constructor(private notificationService: INotificationsService) {}
react = async (evaluation: MonitorEvaluation) => { react = async (evaluation: MonitorEvaluation) => {
if (!evaluation.decision.shouldSendNotification) return; await this.notificationService.handleNotifications(evaluation.monitor, evaluation.check, evaluation.decision);
await this.notificationService.handleNotifications(evaluation.monitor, evaluation.status, evaluation.decision);
}; };
} }
+7 -15
View File
@@ -1,31 +1,23 @@
import { Monitor } from "@/domain/monitors/monitor.type.js"; import { HardwareBreaches, Monitor } from "@/domain/monitors/monitor.type.js";
import { Check } from "@/domain/checks/check.type.js"; import { Check } from "@/domain/checks/check.type.js";
import { Job, JobType } from "@/domain/jobs/job.type.js"; import { Job, JobType } from "@/domain/jobs/job.type.js";
import { MonitorPayloadMap, MonitorStatusResponse, StatusChangeResult } from "@/types/network.js";
import { QueueWorker } from "@/domain/queue-workers/queue-worker.type.js"; import { QueueWorker } from "@/domain/queue-workers/queue-worker.type.js";
import type { QueueMode } from "@/domain/app-settings/app-settings.type.js"; 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; thresholdBreaches?: HardwareBreaches;
shouldSendNotification: boolean;
incidentReason: "status_down" | "threshold_breach" | null;
notificationReason: "status_change" | "threshold_breach" | "threshold_resolved" | null;
thresholdBreaches?: {
cpu?: boolean;
memory?: boolean;
disk?: boolean;
temp?: boolean;
};
} }
export type MonitorEvaluation = { export type MonitorEvaluation = {
monitor: Monitor; monitor: Monitor;
status: MonitorStatusResponse<MonitorPayloadMap[keyof MonitorPayloadMap]>; // raw result from networkService.requestStatus
check: Check; check: Check;
statusChange: StatusChangeResult; // from statusService.updateMonitorStatus
decision: MonitorActionDecision; decision: MonitorActionDecision;
}; };
+28 -66
View File
@@ -3,15 +3,14 @@ import { ICheckService } from "@/domain/checks/check.service.js";
import { Check } from "@/domain/checks/check.type.js"; import { Check } from "@/domain/checks/check.type.js";
import { IDockerLogsService } from "@/domain/docker/docker-log.service.js"; import { IDockerLogsService } from "@/domain/docker/docker-log.service.js";
import { IEgressService, isHttpStatusCode } from "@/domain/egress/egress.service.js"; import { IEgressService, isHttpStatusCode } from "@/domain/egress/egress.service.js";
import type { EgressStatus } from "@/domain/egress/egress.type.js";
import { IJobsRepository } from "@/domain/jobs/job.repository.interface.js"; import { IJobsRepository } from "@/domain/jobs/job.repository.interface.js";
import { PendingCheck } from "@/domain/jobs/job.type.js"; import { PendingCheck } from "@/domain/jobs/job.type.js";
import { IMaintenanceWindowsRepository } from "@/domain/maintenance-windows/maintenance-window.repository.interface.js"; import { IMaintenanceWindowsRepository } from "@/domain/maintenance-windows/maintenance-window.repository.interface.js";
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";
@@ -37,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;
@@ -104,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");
} }
@@ -128,7 +127,7 @@ export class WorkerPipeline implements IWorkerPipeline {
// Step 2b: On a transport failure, ask whether the instance itself can reach anything before blaming the // Step 2b: On a transport failure, ask whether the instance itself can reach anything before blaming the
// target. Null means the egress check is disabled (or failed internally) and the check is treated as usual. // target. Null means the egress check is disabled (or failed internally) and the check is treated as usual.
const egressStatus = this.isTransportFailure(monitor, status) ? await this.assessEgress(monitor.id) : null; const egressStatus = this.isTransportFailure(monitor, status) ? await this.deps.egressService.assessAfterFailure() : null;
if (egressStatus !== null) { if (egressStatus !== null) {
check.egressStatus = egressStatus; check.egressStatus = egressStatus;
} }
@@ -171,20 +170,6 @@ export class WorkerPipeline implements IWorkerPipeline {
return status.status === false && isEgressAttributable(monitor) && !this.peerAnswered(status); return status.status === false && isEgressAttributable(monitor) && !this.peerAnswered(status);
} }
// The egress check must never stop a check being recorded, so a rejection is logged and treated as unknown.
private async assessEgress(monitorId: string): Promise<EgressStatus | null> {
try {
return await this.deps.egressService.assessAfterFailure();
} catch (error: unknown) {
this.deps.logger.warn({
message: `Egress assessment failed for monitor ${monitorId}: ${error instanceof Error ? error.message : String(error)}`,
service: SERVICE_NAME,
method: "assessEgress",
});
return null;
}
}
// **************************************************************** // ****************************************************************
// Stage 2: Ingestion // Stage 2: Ingestion
// Called by the buffer, checks are inserted and evaluation jobs created // Called by the buffer, checks are inserted and evaluation jobs created
@@ -240,8 +225,7 @@ export class WorkerPipeline implements IWorkerPipeline {
let current = monitor; let current = monitor;
for (const check of checks) { for (const check of checks) {
const status = this.deps.checkService.toStatusResponse(check); const evaluation = await this.evaluateCheck(check, current);
const evaluation = await this.evaluateCheck(status, check, current);
await this.deps.dispatcher.dispatch(evaluation); // Handle incidents and notifications await this.deps.dispatcher.dispatch(evaluation); // Handle incidents and notifications
const leaseHeld = await this.deps.jobsRepository.pullEvaluated(job.id, [check.id]); const leaseHeld = await this.deps.jobsRepository.pullEvaluated(job.id, [check.id]);
if (!leaseHeld) { if (!leaseHeld) {
@@ -253,7 +237,7 @@ export class WorkerPipeline implements IWorkerPipeline {
}); });
return; return;
} }
current = evaluation.statusChange.monitor; // fresh statusWindow/status/counters for the next check current = evaluation.monitor; // fresh statusWindow/status/counters for the next check
} }
// Ids whose check was not returned (deleted by retention cleanup) would otherwise sit on the row forever // Ids whose check was not returned (deleted by retention cleanup) would otherwise sit on the row forever
@@ -272,69 +256,47 @@ export class WorkerPipeline implements IWorkerPipeline {
// A check taken while the instance had no outbound connectivity says nothing about the target. // A check taken while the instance had no outbound connectivity says nothing about the target.
// It is left out of the status window, running stats and monitor status entirely, so that no incident // It is left out of the status window, running stats and monitor status entirely, so that no incident
// opens for it and no spurious "resolved"/"up" fires once egress returns. // opens for it and no spurious "resolved"/"up" fires once egress returns.
private skipDegradedEgressCheck = (status: MonitorStatusResponse, check: Check, monitor: Monitor): MonitorEvaluation => { private skipDegradedEgressCheck = (check: Check, monitor: Monitor): MonitorEvaluation => {
this.deps.logger.debug({ this.deps.logger.debug({
message: `Skipping evaluation of check ${check.id} for monitor ${monitor.id}: instance egress was degraded`, message: `Skipping evaluation of check ${check.id} for monitor ${monitor.id}: instance egress was degraded`,
service: SERVICE_NAME, service: SERVICE_NAME,
method: "evaluateCheck", method: "evaluateCheck",
}); });
const decision: MonitorActionDecision = {
shouldCreateIncident: false,
shouldResolveIncident: false,
shouldSendNotification: false,
incidentReason: null,
notificationReason: null,
};
return { return {
monitor, monitor,
status,
check, check,
statusChange: { monitor, statusChanged: false, prevStatus: monitor.status, code: status.code, timestamp: Date.now() }, decision: { transition: null },
decision,
}; };
}; };
evaluateCheck = async (status: MonitorStatusResponse, check: Check, monitor: Monitor): Promise<MonitorEvaluation> => { evaluateCheck = async (check: Check, monitor: Monitor): Promise<MonitorEvaluation> => {
if (check.egressStatus === "degraded") { if (check.egressStatus === "degraded") {
return this.skipDegradedEgressCheck(status, check, monitor); return this.skipDegradedEgressCheck(check, monitor);
} }
const statusChange = await this.deps.statusService.updateMonitorStatus(status, check, monitor); const statusChange = await this.deps.statusService.updateMonitorStatus(check, monitor);
const decision = this.decide(statusChange); const decision = this.decide(statusChange);
return { monitor: statusChange.monitor, status, check, statusChange, decision }; return { monitor: statusChange.monitor, check, decision };
}; };
private decide = (statusChange: StatusChangeResult): MonitorActionDecision => { private decide = (statusChange: StatusChangeResult): MonitorActionDecision => {
const { monitor, statusChanged, prevStatus } = statusChange; const { monitor, statusChanged, prevStatus, thresholdBreaches } = statusChange;
const decision: MonitorActionDecision = { if (!statusChanged) return { transition: null };
shouldCreateIncident: false,
shouldResolveIncident: false,
shouldSendNotification: false,
incidentReason: null,
notificationReason: 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 }
decision.shouldCreateIncident = true; : // Docker
decision.shouldSendNotification = true; { transition: "threshold_breach" };
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 };
}; };
} }
+21 -33
View File
@@ -5,17 +5,17 @@ import { IncidentReactor } from "../../src/worker/reactors/reactor.incident.ts";
import { ReactorDispatcher } from "../../src/worker/reactors/reactor.dispatcher.ts"; import { ReactorDispatcher } from "../../src/worker/reactors/reactor.dispatcher.ts";
import { StatusService } from "../../src/service/statusService.ts"; import { StatusService } from "../../src/service/statusService.ts";
import { IncidentService } from "../../src/domain/incidents/incident.service.ts"; import { IncidentService } from "../../src/domain/incidents/incident.service.ts";
import { CheckService } from "../../src/domain/checks/check.service.ts";
import { InMemoryMonitorsRepository } from "./InMemoryMonitorsRepository.ts"; import { InMemoryMonitorsRepository } from "./InMemoryMonitorsRepository.ts";
import { InMemoryIncidentsRepository } from "./InMemoryIncidentsRepository.ts"; import { InMemoryIncidentsRepository } from "./InMemoryIncidentsRepository.ts";
import { createMockLogger } from "./createMockLogger.ts"; import { createMockLogger } from "./createMockLogger.ts";
import type { Monitor } from "../../src/domain/monitors/monitor.type.ts"; import type { Monitor } from "../../src/domain/monitors/monitor.type.ts";
import type { MonitorStatusResponse } from "../../src/types/network.ts";
import type { Check } from "../../src/domain/checks/check.type.ts"; import type { Check } from "../../src/domain/checks/check.type.ts";
import type { MonitorActionDecision } from "../../src/worker/worker.interface.ts";
import type { MonitorStatusResponse } from "../../src/types/network.ts";
import type { MaintenanceWindow } from "../../src/domain/maintenance-windows/maintenance-window.type.ts"; import type { MaintenanceWindow } from "../../src/domain/maintenance-windows/maintenance-window.type.ts";
import type { EgressStatus } from "../../src/domain/egress/egress.type.ts"; import type { EgressStatus } from "../../src/domain/egress/egress.type.ts";
let checkCounter = 0;
export const makeMonitor = (overrides?: Partial<Monitor>): Monitor => export const makeMonitor = (overrides?: Partial<Monitor>): Monitor =>
({ ({
id: "mon-1", id: "mon-1",
@@ -45,20 +45,6 @@ export const makeStatusResponse = (status: boolean, code: number): MonitorStatus
responseTime: status ? 150 : 0, responseTime: status ? 150 : 0,
}); });
export const makeCheck = (status: boolean, code: number): Check => {
const now = new Date().toISOString();
return {
id: `check-${++checkCounter}`,
metadata: { monitorId: "mon-1", teamId: "team-1", type: "http" },
status,
statusCode: code,
responseTime: status ? 150 : 0,
message: status ? "OK" : "Service Unavailable",
createdAt: now,
updatedAt: now,
};
};
const createStubMonitorStatsRepo = () => ({ const createStubMonitorStatsRepo = () => ({
findByMonitorId: jest.fn().mockRejectedValue(new Error("no stats")), findByMonitorId: jest.fn().mockRejectedValue(new Error("no stats")),
create: jest.fn().mockResolvedValue({}), create: jest.fn().mockResolvedValue({}),
@@ -74,10 +60,10 @@ 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: { extractThresholdBreaches: jest.Mock }; messageBuilder: { buildThresholdBreachMessage: jest.Mock };
egressService: { assessAfterFailure: jest.Mock }; egressService: { assessAfterFailure: jest.Mock };
heartbeatJob: (monitor: Monitor) => Promise<void>; heartbeatJob: (monitor: Monitor) => Promise<void>;
setNextResponse: (status: boolean, code: number) => void; setNextResponse: (status: boolean, code: number) => void;
@@ -86,8 +72,6 @@ export interface HeartbeatTestHarness {
} }
export function createHeartbeatTestHarness(): HeartbeatTestHarness { export function createHeartbeatTestHarness(): HeartbeatTestHarness {
checkCounter = 0;
const monitorsRepo = new InMemoryMonitorsRepository(); const monitorsRepo = new InMemoryMonitorsRepository();
const incidentsRepo = new InMemoryIncidentsRepository(); const incidentsRepo = new InMemoryIncidentsRepository();
const logger = createMockLogger() as any; const logger = createMockLogger() as any;
@@ -95,7 +79,7 @@ export function createHeartbeatTestHarness(): HeartbeatTestHarness {
const statusService = new StatusService(logger, monitorsRepo as any, createStubMonitorStatsRepo() as any); const statusService = new StatusService(logger, monitorsRepo as any, createStubMonitorStatsRepo() as any);
const messageBuilder = { extractThresholdBreaches: jest.fn().mockReturnValue([]) }; const messageBuilder = { buildThresholdBreachMessage: jest.fn().mockReturnValue("") };
const incidentService = new IncidentService(logger, incidentsRepo, monitorsRepo as any, { findById: jest.fn() } as any, messageBuilder as any); const incidentService = new IncidentService(logger, incidentsRepo, monitorsRepo as any, { findById: jest.fn() } as any, messageBuilder as any);
const notificationsService = { handleNotifications: jest.fn().mockResolvedValue(true) }; const notificationsService = { handleNotifications: jest.fn().mockResolvedValue(true) };
@@ -103,19 +87,17 @@ 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);
} }
return Promise.resolve(makeStatusResponse(nextStatus, nextCode)); return Promise.resolve(makeStatusResponse(nextStatus, nextCode));
}), }),
}; };
const checkService = { // The real mapper, so the stored check carries the hardware/docker payload the evaluator reads.
toCheck: jest.fn().mockImplementation((response: MonitorStatusResponse) => { // Only toCheck is reached; the repositories are never touched.
return makeCheck(response.status, response.code); const checkService = new CheckService(monitorsRepo as any, logger, {} as any);
}),
};
const setNextResponse = (status: boolean, code: number) => { const setNextResponse = (status: boolean, code: number) => {
nextResponse = null; nextResponse = null;
@@ -137,7 +119,13 @@ export function createHeartbeatTestHarness(): HeartbeatTestHarness {
nextEgressStatus = status; nextEgressStatus = status;
}; };
const notificationReactor = new NotificationReactor(notificationsService as any); // The real NotificationsService ignores decisions with no transition; the stub is reached only for the ones it
// would act on, so tests can assert on handleNotifications calls as "notifications sent".
const guardedNotificationsService = {
handleNotifications: (monitor: Monitor, check: Check, decision: MonitorActionDecision) =>
decision.transition !== null ? notificationsService.handleNotifications(monitor, check, decision) : Promise.resolve(false),
};
const notificationReactor = new NotificationReactor(guardedNotificationsService as any);
const incidentReactor = new IncidentReactor(incidentService as any); const incidentReactor = new IncidentReactor(incidentService as any);
const reactorDispatcher = new ReactorDispatcher(logger, [notificationReactor, incidentReactor]); const reactorDispatcher = new ReactorDispatcher(logger, [notificationReactor, incidentReactor]);
@@ -149,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,
@@ -165,7 +153,7 @@ export function createHeartbeatTestHarness(): HeartbeatTestHarness {
if (!monitor) return; if (!monitor) return;
const result = await pipeline.produce(monitor); const result = await pipeline.produce(monitor);
if (!result) return; // skipped (e.g. maintenance window) if (!result) return; // skipped (e.g. maintenance window)
const evaluation = await pipeline.evaluateCheck(result.status, result.check, monitor); const evaluation = await pipeline.evaluateCheck(result.check, monitor);
await reactorDispatcher.dispatch(evaluation); await reactorDispatcher.dispatch(evaluation);
}; };
@@ -175,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,14 +126,14 @@ 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,
statusService: { statusService: {
updateMonitorStatus: async (_status: MonitorStatusResponse, check: Check, monitor: Monitor) => { updateMonitorStatus: async (check: Check, monitor: Monitor) => {
evaluatedCheckIds.push(check.id); evaluatedCheckIds.push(check.id);
return { monitor, statusChanged: false, prevStatus: monitor.status, code: 200, timestamp: 0 }; return { monitor, statusChanged: false, prevStatus: monitor.status };
}, },
} as any, } as any,
dispatcher: { dispatch: jest.fn<any>().mockResolvedValue(undefined) } as any, dispatcher: { dispatch: jest.fn<any>().mockResolvedValue(undefined) } 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);
}); });
}); });
@@ -49,7 +49,7 @@ describe("Heartbeat job: hardware threshold breach", () => {
h.setNextResponseFull(makeHardwareResponse(0.9)); h.setNextResponseFull(makeHardwareResponse(0.9));
// Configure message builder for when incident is created // Configure message builder for when incident is created
h.messageBuilder.extractThresholdBreaches.mockReturnValue([{ metric: "cpu", formattedValue: "90%", threshold: 80, unit: "%" }]); h.messageBuilder.buildThresholdBreachMessage.mockReturnValue("CPU: 90% (threshold: 80%)");
// 4 heartbeats: counter goes 5→4→3→2→1, status still "up" // 4 heartbeats: counter goes 5→4→3→2→1, status still "up"
for (let i = 0; i < 4; i++) { for (let i = 0; i < 4; i++) {
@@ -191,7 +191,7 @@ describe("Heartbeat job: hardware threshold breach", () => {
h.monitorsRepo.seed(monitor); h.monitorsRepo.seed(monitor);
h.setNextResponseFull(makeHardwareResponse(0.9)); h.setNextResponseFull(makeHardwareResponse(0.9));
h.messageBuilder.extractThresholdBreaches.mockReturnValue([{ metric: "cpu", formattedValue: "90%", threshold: 80, unit: "%" }]); h.messageBuilder.buildThresholdBreachMessage.mockReturnValue("CPU: 90% (threshold: 80%)");
// 5 heartbeats to trigger breach, then 5 more while still breaching // 5 heartbeats to trigger breach, then 5 more while still breaching
for (let i = 0; i < 10; i++) { for (let i = 0; i < 10; i++) {
@@ -7,6 +7,7 @@ import type { IUsersRepository } from "../../src/domain/users/user.repository.in
import type { INotificationMessageBuilder } from "../../src/domain/notifications/notification.message-builder.ts"; import type { INotificationMessageBuilder } from "../../src/domain/notifications/notification.message-builder.ts";
import type { Monitor } from "../../src/domain/monitors/monitor.type.ts"; import type { Monitor } from "../../src/domain/monitors/monitor.type.ts";
import type { MonitorActionDecision } from "../../src/worker/worker.interface.ts"; import type { MonitorActionDecision } from "../../src/worker/worker.interface.ts";
import type { Check } from "../../src/domain/checks/check.type.ts";
// ── Helpers ────────────────────────────────────────────────────────────────── // ── Helpers ──────────────────────────────────────────────────────────────────
@@ -22,14 +23,21 @@ 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,
}); });
const makeCheck = (statusCode: number): Check => ({
id: "check-1",
metadata: { monitorId: "mon-1", teamId: "team-1", type: "http" },
status: statusCode < 400,
statusCode,
responseTime: 100,
message: statusCode < 400 ? "OK" : "Service Unavailable",
createdAt: "2026-01-01T00:00:00Z",
updatedAt: "2026-01-01T00:00:00Z",
});
// ── Test suite ─────────────────────────────────────────────────────────────── // ── Test suite ───────────────────────────────────────────────────────────────
describe("Incident lifecycle (integration)", () => { describe("Incident lifecycle (integration)", () => {
@@ -43,7 +51,7 @@ describe("Incident lifecycle (integration)", () => {
repo = new InMemoryIncidentsRepository(); repo = new InMemoryIncidentsRepository();
monitorsRepo = { findById: jest.fn() } as unknown as jest.Mocked<IMonitorsRepository>; monitorsRepo = { findById: jest.fn() } as unknown as jest.Mocked<IMonitorsRepository>;
usersRepo = { findById: jest.fn() } as unknown as jest.Mocked<IUsersRepository>; usersRepo = { findById: jest.fn() } as unknown as jest.Mocked<IUsersRepository>;
messageBuilder = { extractThresholdBreaches: jest.fn() } as unknown as jest.Mocked<INotificationMessageBuilder>; messageBuilder = { buildThresholdBreachMessage: jest.fn() } as unknown as jest.Mocked<INotificationMessageBuilder>;
service = new IncidentService(createMockLogger() as any, repo, monitorsRepo, usersRepo, messageBuilder); service = new IncidentService(createMockLogger() as any, repo, monitorsRepo, usersRepo, messageBuilder);
}); });
@@ -51,9 +59,9 @@ 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, 503, decision); const incident = await service.handleIncident(monitor, decision, makeCheck(503));
expect(incident).not.toBeNull(); expect(incident).not.toBeNull();
expect(incident!.monitorId).toBe("mon-1"); expect(incident!.monitorId).toBe("mon-1");
@@ -72,10 +80,10 @@ 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, 503, decision); const first = await service.handleIncident(monitor, decision, makeCheck(503));
const second = await service.handleIncident(monitor, 503, decision); const second = await service.handleIncident(monitor, decision, makeCheck(503));
expect(second!.id).toBe(first!.id); expect(second!.id).toBe(first!.id);
expect(repo.getAll()).toHaveLength(1); expect(repo.getAll()).toHaveLength(1);
@@ -85,11 +93,11 @@ 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, 503, createDecision); 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, 200, resolveDecision); const resolved = await service.handleIncident(monitor, resolveDecision, makeCheck(200));
expect(resolved).not.toBeNull(); expect(resolved).not.toBeNull();
expect(resolved!.id).toBe(created!.id); expect(resolved!.id).toBe(created!.id);
@@ -102,9 +110,9 @@ 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, 200, decision); const result = await service.handleIncident(monitor, decision, makeCheck(200));
expect(result).toBeNull(); expect(result).toBeNull();
}); });
@@ -113,8 +121,8 @@ 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, 500, decision); 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");
@@ -128,8 +136,8 @@ 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, 500, decision); 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");
@@ -140,13 +148,10 @@ 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.extractThresholdBreaches as jest.Mock).mockReturnValue([ (messageBuilder.buildThresholdBreachMessage as jest.Mock).mockReturnValue("CPU: 92% (threshold: 80%), MEMORY: 88% (threshold: 85%)");
{ metric: "cpu", formattedValue: "92%", threshold: 80, unit: "%" }, const decision = makeDecision({ transition: "threshold_breach" });
{ metric: "memory", formattedValue: "88%", threshold: 85, unit: "%" },
]);
const decision = makeDecision({ shouldCreateIncident: true, incidentReason: "threshold_breach" });
const incident = await service.handleIncident(monitor, 200, decision, { monitorId: "mon-1" } as any); const incident = await service.handleIncident(monitor, decision, makeCheck(200));
expect(incident!.statusCode).toBe(9999); expect(incident!.statusCode).toBe(9999);
expect(incident!.message).toBe("CPU: 92% (threshold: 80%), MEMORY: 88% (threshold: 85%)"); expect(incident!.message).toBe("CPU: 92% (threshold: 80%), MEMORY: 88% (threshold: 85%)");
@@ -158,7 +163,7 @@ describe("Incident lifecycle (integration)", () => {
const monitor = makeMonitor({ status: "up" }); const monitor = makeMonitor({ status: "up" });
const decision = makeDecision(); const decision = makeDecision();
const result = await service.handleIncident(monitor, 200, decision); const result = await service.handleIncident(monitor, decision, makeCheck(200));
expect(result).toBeNull(); expect(result).toBeNull();
expect(repo.getAll()).toHaveLength(0); expect(repo.getAll()).toHaveLength(0);
@@ -170,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, 503, makeDecision({ shouldCreateIncident: true, incidentReason: "status_down" })); 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, 502, makeDecision({ shouldCreateIncident: true, incidentReason: "status_down" })); 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, 200, makeDecision({ shouldResolveIncident: true })); 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");
@@ -198,16 +203,16 @@ 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, 500, decision); const incidentA = await service.handleIncident(monitorA, decision, makeCheck(500));
const incidentB = await service.handleIncident(monitorB, 502, decision); const incidentB = await service.handleIncident(monitorB, decision, makeCheck(502));
expect(incidentA!.id).not.toBe(incidentB!.id); expect(incidentA!.id).not.toBe(incidentB!.id);
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, 200, makeDecision({ shouldResolveIncident: true })); 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 ────────────────────────────────────────────────
+1 -109
View File
@@ -3,9 +3,7 @@ import { CheckService } from "../../../src/domain/checks/check.service.ts";
import { createMockLogger } from "../../helpers/createMockLogger.ts"; import { createMockLogger } from "../../helpers/createMockLogger.ts";
import type { IChecksRepository } from "../../../src/domain/checks/check.repository.interface.ts"; import type { IChecksRepository } from "../../../src/domain/checks/check.repository.interface.ts";
import type { IMonitorsRepository } from "../../../src/domain/monitors/monitor.repository.interface.ts"; import type { IMonitorsRepository } from "../../../src/domain/monitors/monitor.repository.interface.ts";
import type { MonitorStatusResponse, HardwareStatusPayload, PageSpeedStatusPayload, DockerStatusPayload } from "../../../src/types/network.ts"; import type { MonitorStatusResponse, HardwareStatusPayload, PageSpeedStatusPayload } from "../../../src/types/network.ts";
import { NotificationMessageBuilder } from "../../../src/domain/notifications/notification.message-builder.ts";
import type { Monitor } from "../../../src/domain/monitors/monitor.type.ts";
// ── Helpers ────────────────────────────────────────────────────────────────── // ── Helpers ──────────────────────────────────────────────────────────────────
@@ -281,112 +279,6 @@ describe("CheckService", () => {
}); });
}); });
// ── toStatusResponse round-trip (Phase 2 §5 guard) ─────────────────────────
// The DB-queue split persists a Check, then the evaluator rebuilds the status response from it.
// toStatusResponse is only correct because every field a downstream consumer reads survives
// toCheck → toStatusResponse. These tests pin that: if a future change drops a consumed field
// from the round-trip, they fail instead of silently regressing the evaluator/reactors.
describe("toStatusResponse round-trip", () => {
const makeHardwareStatus = (overrides?: Partial<MonitorStatusResponse>): MonitorStatusResponse =>
makeStatusResponse({
type: "hardware",
status: true,
code: 200,
message: "OK",
responseTime: 42,
payload: {
data: {
cpu: { usage_percent: 0.92 },
memory: { usage_percent: 0.81 },
disk: [{ device: "/dev/sda", usage_percent: 0.75 }],
host: { os: "linux" },
net: [{ name: "eth0" }],
},
} as HardwareStatusPayload,
...overrides,
} as any);
it("uptime: preserves every consumed scalar field", () => {
const { service } = createService();
const status = makeStatusResponse({ type: "http", status: false, code: 503, message: "Service Unavailable", responseTime: 87 });
const rebuilt = service.toStatusResponse(service.toCheck(status)!);
expect(rebuilt.monitorId).toBe(status.monitorId);
expect(rebuilt.teamId).toBe(status.teamId);
expect(rebuilt.type).toBe(status.type);
expect(rebuilt.status).toBe(status.status);
expect(rebuilt.code).toBe(status.code);
expect(rebuilt.message).toBe(status.message);
expect(rebuilt.responseTime).toBe(status.responseTime);
});
it("hardware: preserves payload.data metrics verbatim, same units (usage_percent stays a 0–1 decimal)", () => {
const { service } = createService();
const status = makeHardwareStatus();
const rebuilt = service.toStatusResponse(service.toCheck(status)!);
const original = status.payload as HardwareStatusPayload;
const roundtripped = rebuilt.payload as HardwareStatusPayload;
expect(roundtripped.data).toEqual(original.data);
expect(roundtripped.data.cpu!.usage_percent).toBe(0.92); // not rescaled to 92
});
it("hardware: a real consumer (extractThresholdBreaches) behaves identically on raw vs round-tripped status", () => {
const { service } = createService();
const builder = new NotificationMessageBuilder();
const monitor = {
id: "mon-1",
type: "hardware",
cpuAlertThreshold: 90, // 0.92 → 92% breaches
memoryAlertThreshold: 90, // 0.81 → 81% does not
diskAlertThreshold: 90, // 0.75 → 75% does not
tempAlertThreshold: 100,
} as Monitor;
const status = makeHardwareStatus();
const rebuilt = service.toStatusResponse(service.toCheck(status)!);
const fromRaw = builder.extractThresholdBreaches(monitor, status as MonitorStatusResponse<HardwareStatusPayload>);
const fromRebuilt = builder.extractThresholdBreaches(monitor, rebuilt as MonitorStatusResponse<HardwareStatusPayload>);
// sanity: the fixture actually triggers a breach, so the equality below is not vacuous
expect(fromRaw).toHaveLength(1);
expect(fromRaw[0].metric).toBe("cpu");
expect(fromRebuilt).toEqual(fromRaw);
});
it("docker: preserves payload.containers and payload.summary verbatim", () => {
const { service } = createService();
const payload: DockerStatusPayload = {
containers: [
{ id: "1", name: "db", image: "postgres:16", state: "exited", status: "Exited (1) 2 minutes ago", health: "none", exitCode: 1 },
{ id: "2", name: "api", image: "api:latest", state: "running", status: "Up 3 hours (unhealthy)", health: "unhealthy", restartCount: 2 },
],
summary: { total: 2, running: 1, stopped: 1, unhealthy: 1 },
};
const status = makeStatusResponse({ type: "docker", status: true, payload } as any);
const rebuilt = service.toStatusResponse(service.toCheck(status)!);
const roundtripped = rebuilt.payload as DockerStatusPayload;
expect(roundtripped.containers).toEqual(payload.containers);
expect(roundtripped.summary).toEqual(payload.summary);
expect(roundtripped.containers[0].exitCode).toBe(1);
});
it("docker: leaves payload undefined for a failed check with no containers", () => {
const { service } = createService();
const status = makeStatusResponse({ type: "docker", status: false, code: 5000, message: "connect ECONNREFUSED", payload: null } as any);
const rebuilt = service.toStatusResponse(service.toCheck(status)!);
expect(rebuilt.payload).toBeUndefined();
});
});
// ── createChecks ───────────────────────────────────────────────────────── // ── createChecks ─────────────────────────────────────────────────────────
describe("createChecks", () => { describe("createChecks", () => {
+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);
}); });
+51 -104
View File
@@ -8,6 +8,7 @@ import type { INotificationMessageBuilder } from "../../../src/domain/notificati
import type { Monitor } from "../../../src/domain/monitors/monitor.type.ts"; import type { Monitor } from "../../../src/domain/monitors/monitor.type.ts";
import type { Incident } from "../../../src/domain/incidents/incident.type.ts"; import type { Incident } from "../../../src/domain/incidents/incident.type.ts";
import type { MonitorActionDecision } from "../../../src/worker/worker.interface.ts"; import type { MonitorActionDecision } from "../../../src/worker/worker.interface.ts";
import type { Check } from "../../../src/domain/checks/check.type.ts";
// ── Helpers ────────────────────────────────────────────────────────────────── // ── Helpers ──────────────────────────────────────────────────────────────────
@@ -35,7 +36,7 @@ const createUsersRepo = () =>
const createMessageBuilder = () => const createMessageBuilder = () =>
({ ({
extractThresholdBreaches: jest.fn(), buildThresholdBreachMessage: jest.fn(),
}) as unknown as jest.Mocked<INotificationMessageBuilder>; }) as unknown as jest.Mocked<INotificationMessageBuilder>;
const createService = (overrides?: { const createService = (overrides?: {
@@ -79,12 +80,21 @@ const makeIncident = (overrides?: Partial<Incident>): Incident =>
...overrides, ...overrides,
}) as Incident; }) as Incident;
const makeCheck = (overrides?: Partial<Check>): Check =>
({
id: "check-1",
metadata: { monitorId: "mon-1", teamId: "team-1", type: "http" },
status: true,
statusCode: 200,
responseTime: 100,
message: "OK",
createdAt: "2026-01-01T00:00:00.000Z",
updatedAt: "2026-01-01T00:00:00.000Z",
...overrides,
}) 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,
}); });
@@ -96,28 +106,28 @@ describe("IncidentService", () => {
describe("handleIncident", () => { describe("handleIncident", () => {
it("returns null when neither create nor resolve is requested", async () => { it("returns null when neither create nor resolve is requested", async () => {
const { service } = createService(); const { service } = createService();
const result = await service.handleIncident(makeMonitor(), 200, makeDecision()); const result = await service.handleIncident(makeMonitor(), makeDecision(), makeCheck());
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(makeMonitor(), 500, makeDecision({ shouldCreateIncident: true, incidentReason: "status_down" })); const result = await service.handleIncident(makeMonitor(), makeDecision({ transition: "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 } = 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(makeMonitor(), 500, makeDecision({ shouldCreateIncident: true, incidentReason: "status_down" })); const result = await service.handleIncident(makeMonitor(), makeDecision({ transition: "status_down" }), makeCheck({ statusCode: 500 }));
expect(result).toBe(created); expect(result).toBe(created);
expect(incidentsRepository.create).toHaveBeenCalledWith( expect(incidentsRepository.create).toHaveBeenCalledWith(
@@ -128,6 +138,7 @@ describe("IncidentService", () => {
statusCode: 500, statusCode: 500,
}) })
); );
expect(notificationMessageBuilder.buildThresholdBreachMessage).not.toHaveBeenCalled();
}); });
it("uses status code 9999 and builds message for threshold_breach incidents", async () => { it("uses status code 9999 and builds message for threshold_breach incidents", async () => {
@@ -135,13 +146,15 @@ describe("IncidentService", () => {
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);
(notificationMessageBuilder.extractThresholdBreaches as jest.Mock).mockReturnValue([ (notificationMessageBuilder.buildThresholdBreachMessage as jest.Mock).mockReturnValue("CPU: 95% (threshold: 80%)");
{ metric: "cpu", formattedValue: "95%", threshold: 80, unit: "%" },
]);
const decision = makeDecision({ shouldCreateIncident: true, incidentReason: "threshold_breach" }); const monitor = makeMonitor({ type: "hardware" });
await service.handleIncident(makeMonitor(), 200, decision, { monitorId: "mon-1" } as any); const check = makeCheck({ metadata: { monitorId: "mon-1", teamId: "team-1", type: "hardware" }, cpu: { usage_percent: 0.95 } });
const thresholdBreaches = { cpu: true, memory: false, disk: false, temp: false };
const decision = makeDecision({ transition: "threshold_breach", thresholdBreaches });
await service.handleIncident(monitor, decision, check);
expect(notificationMessageBuilder.buildThresholdBreachMessage).toHaveBeenCalledWith(monitor, check, thresholdBreaches);
expect(incidentsRepository.create).toHaveBeenCalledWith( expect(incidentsRepository.create).toHaveBeenCalledWith(
expect.objectContaining({ expect.objectContaining({
statusCode: 9999, statusCode: 9999,
@@ -150,97 +163,14 @@ describe("IncidentService", () => {
); );
}); });
it("uses fallback message when monitorStatusResponse is undefined for threshold_breach", async () => { it("resolves the active incident on an up transition", async () => {
const created = makeIncident();
const { service, incidentsRepository } = createService();
(incidentsRepository.findActiveByMonitorId as jest.Mock).mockResolvedValue(null);
(incidentsRepository.create as jest.Mock).mockResolvedValue(created);
const decision = makeDecision({ shouldCreateIncident: true, incidentReason: "threshold_breach" });
await service.handleIncident(makeMonitor(), 200, decision, undefined);
expect(incidentsRepository.create).toHaveBeenCalledWith(expect.objectContaining({ message: "Threshold breach detected" }));
});
it("uses fallback message when extractThresholdBreaches returns empty array", async () => {
const created = makeIncident();
const { service, incidentsRepository, notificationMessageBuilder } = createService();
(incidentsRepository.findActiveByMonitorId as jest.Mock).mockResolvedValue(null);
(incidentsRepository.create as jest.Mock).mockResolvedValue(created);
(notificationMessageBuilder.extractThresholdBreaches as jest.Mock).mockReturnValue([]);
const decision = makeDecision({ shouldCreateIncident: true, incidentReason: "threshold_breach" });
await service.handleIncident(makeMonitor(), 200, decision, { monitorId: "mon-1" } as any);
expect(incidentsRepository.create).toHaveBeenCalledWith(expect.objectContaining({ message: "Threshold breach detected" }));
});
it("builds the container list for docker threshold_breach incidents", async () => {
const created = makeIncident();
const { service, incidentsRepository, notificationMessageBuilder } = createService();
(incidentsRepository.findActiveByMonitorId as jest.Mock).mockResolvedValue(null);
(incidentsRepository.create as jest.Mock).mockResolvedValue(created);
const monitor = makeMonitor({ type: "docker", status: "breached", dockerAlertOnStopped: true, dockerAlertOnUnhealthy: true });
const response = {
monitorId: "mon-1",
payload: {
containers: [
{ id: "1", name: "db", image: "postgres", state: "exited", status: "Exited (1)", health: "none", exitCode: 1 },
{ id: "2", name: "api", image: "api", state: "running", status: "Up", health: "unhealthy" },
{ id: "3", name: "web", image: "nginx", state: "running", status: "Up", health: "healthy" },
],
summary: { total: 3, running: 2, stopped: 1, unhealthy: 1 },
},
} as any;
const decision = makeDecision({ shouldCreateIncident: true, incidentReason: "threshold_breach" });
await service.handleIncident(monitor, 200, decision, response);
expect(incidentsRepository.create).toHaveBeenCalledWith(
expect.objectContaining({ statusCode: 9999, message: "db: stopped (exit code 1), api: unhealthy" })
);
expect(notificationMessageBuilder.extractThresholdBreaches).not.toHaveBeenCalled();
});
it("uses the container fallback message for docker incidents with no response", async () => {
const created = makeIncident();
const { service, incidentsRepository } = createService();
(incidentsRepository.findActiveByMonitorId as jest.Mock).mockResolvedValue(null);
(incidentsRepository.create as jest.Mock).mockResolvedValue(created);
const monitor = makeMonitor({ type: "docker", status: "breached", dockerAlertOnStopped: true });
const decision = makeDecision({ shouldCreateIncident: true, incidentReason: "threshold_breach" });
await service.handleIncident(monitor, 200, decision, undefined);
expect(incidentsRepository.create).toHaveBeenCalledWith(expect.objectContaining({ statusCode: 9999, message: "Container alert" }));
});
it("joins multiple threshold breaches with commas", async () => {
const created = makeIncident();
const { service, incidentsRepository, notificationMessageBuilder } = createService();
(incidentsRepository.findActiveByMonitorId as jest.Mock).mockResolvedValue(null);
(incidentsRepository.create as jest.Mock).mockResolvedValue(created);
(notificationMessageBuilder.extractThresholdBreaches as jest.Mock).mockReturnValue([
{ metric: "cpu", formattedValue: "95%", threshold: 80, unit: "%" },
{ metric: "memory", formattedValue: "90%", threshold: 80, unit: "%" },
]);
const decision = makeDecision({ shouldCreateIncident: true, incidentReason: "threshold_breach" });
await service.handleIncident(makeMonitor(), 200, decision, { monitorId: "mon-1" } as any);
expect(incidentsRepository.create).toHaveBeenCalledWith(
expect.objectContaining({ message: "CPU: 95% (threshold: 80%), MEMORY: 90% (threshold: 80%)" })
);
});
it("resolves active incident when shouldResolveIncident", 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(), 200, makeDecision({ shouldResolveIncident: true })); 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(
@@ -250,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(), 200, makeDecision({ shouldResolveIncident: true })); 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" });
});
});
});
File diff suppressed because it is too large. Load diff
@@ -3,7 +3,7 @@ import { NotificationsService } from "../../../src/domain/notifications/notifica
import { createMockLogger } from "../../helpers/createMockLogger.ts"; import { createMockLogger } from "../../helpers/createMockLogger.ts";
import type { Monitor } from "../../../src/domain/monitors/monitor.type.ts"; import type { Monitor } from "../../../src/domain/monitors/monitor.type.ts";
import type { Notification } from "../../../src/domain/notifications/notification.type.ts"; import type { Notification } from "../../../src/domain/notifications/notification.type.ts";
import type { MonitorStatusResponse } from "../../../src/types/network.ts"; import type { Check } from "../../../src/domain/checks/check.type.ts";
import type { MonitorActionDecision } from "../../../src/worker/worker.interface.ts"; import type { MonitorActionDecision } from "../../../src/worker/worker.interface.ts";
// ── Helpers ────────────────────────────────────────────────────────────────── // ── Helpers ──────────────────────────────────────────────────────────────────
@@ -33,7 +33,7 @@ const createSettingsService = (clientHost = "https://app.example.com") => ({
const createMessageBuilder = () => ({ const createMessageBuilder = () => ({
buildMessage: jest.fn().mockReturnValue({ type: "monitor_down", content: { title: "Down" } }), buildMessage: jest.fn().mockReturnValue({ type: "monitor_down", content: { title: "Down" } }),
extractThresholdBreaches: jest.fn(), buildThresholdBreachMessage: jest.fn(),
}); });
const createService = (overrides?: Record<string, unknown>) => { const createService = (overrides?: Record<string, unknown>) => {
@@ -122,15 +122,21 @@ 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,
}); });
const makeStatusResponse = () => ({ monitorId: "mon-1", status: false, code: 500 }) as unknown as MonitorStatusResponse; const makeCheck = (overrides?: Partial<Check>): Check => ({
id: "check-1",
metadata: { monitorId: "mon-1", teamId: "team-1", type: "http" },
status: false,
responseTime: 100,
statusCode: 500,
message: "Internal Server Error",
createdAt: "2026-01-01T00:00:00Z",
updatedAt: "2026-01-01T00:00:00Z",
...overrides,
});
// ── Tests ──────────────────────────────────────────────────────────────────── // ── Tests ────────────────────────────────────────────────────────────────────
@@ -138,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(), makeStatusResponse(), makeDecision({ shouldSendNotification: false })); const result = await service.handleNotifications(makeMonitor(), makeCheck(), makeDecision({ transition: null }));
expect(result).toBe(false); expect(result).toBe(false);
}); });
@@ -148,7 +154,7 @@ describe("NotificationsService", () => {
const { service, notificationsRepository, emailProvider } = createService(); const { service, notificationsRepository, emailProvider } = createService();
(notificationsRepository.findNotificationsByIds as jest.Mock).mockResolvedValue([makeNotification({ type: "email" })]); (notificationsRepository.findNotificationsByIds as jest.Mock).mockResolvedValue([makeNotification({ type: "email" })]);
const result = await service.handleNotifications(makeMonitor(), makeStatusResponse(), makeDecision()); const result = await service.handleNotifications(makeMonitor(), makeCheck(), makeDecision());
expect(result).toBe(true); expect(result).toBe(true);
expect(emailProvider.sendMessage).toHaveBeenCalledTimes(1); expect(emailProvider.sendMessage).toHaveBeenCalledTimes(1);
@@ -160,7 +166,7 @@ describe("NotificationsService", () => {
const deps = createService(); const deps = createService();
(deps.notificationsRepository.findNotificationsByIds as jest.Mock).mockResolvedValue([makeNotification({ type })]); (deps.notificationsRepository.findNotificationsByIds as jest.Mock).mockResolvedValue([makeNotification({ type })]);
await deps.service.handleNotifications(makeMonitor(), makeStatusResponse(), makeDecision()); await deps.service.handleNotifications(makeMonitor(), makeCheck(), makeDecision());
const providerMap: Record<string, ReturnType<typeof createProvider>> = { const providerMap: Record<string, ReturnType<typeof createProvider>> = {
webhook: deps.webhookProvider, webhook: deps.webhookProvider,
@@ -183,7 +189,7 @@ describe("NotificationsService", () => {
const { service, notificationsRepository, logger } = createService(); const { service, notificationsRepository, logger } = createService();
(notificationsRepository.findNotificationsByIds as jest.Mock).mockResolvedValue([makeNotification({ type: "carrier_pigeon" as any })]); (notificationsRepository.findNotificationsByIds as jest.Mock).mockResolvedValue([makeNotification({ type: "carrier_pigeon" as any })]);
const result = await service.handleNotifications(makeMonitor(), makeStatusResponse(), makeDecision()); const result = await service.handleNotifications(makeMonitor(), makeCheck(), makeDecision());
expect(result).toBe(false); expect(result).toBe(false);
expect(logger.warn).toHaveBeenCalledWith( expect(logger.warn).toHaveBeenCalledWith(
@@ -197,7 +203,7 @@ describe("NotificationsService", () => {
const { service, notificationsRepository, logger } = createService({ notificationMessageBuilder }); const { service, notificationsRepository, logger } = createService({ notificationMessageBuilder });
(notificationsRepository.findNotificationsByIds as jest.Mock).mockResolvedValue([makeNotification()]); (notificationsRepository.findNotificationsByIds as jest.Mock).mockResolvedValue([makeNotification()]);
const result = await service.handleNotifications(makeMonitor(), makeStatusResponse(), makeDecision()); const result = await service.handleNotifications(makeMonitor(), makeCheck(), makeDecision());
expect(result).toBe(false); expect(result).toBe(false);
expect(logger.warn).toHaveBeenCalledWith(expect.objectContaining({ message: "Notification message not provided" })); expect(logger.warn).toHaveBeenCalledWith(expect.objectContaining({ message: "Notification message not provided" }));
@@ -207,7 +213,7 @@ describe("NotificationsService", () => {
const { service, notificationsRepository } = createService(); const { service, notificationsRepository } = createService();
(notificationsRepository.findNotificationsByIds as jest.Mock).mockResolvedValue([]); (notificationsRepository.findNotificationsByIds as jest.Mock).mockResolvedValue([]);
const result = await service.handleNotifications(makeMonitor({ notifications: undefined as any }), makeStatusResponse(), makeDecision()); const result = await service.handleNotifications(makeMonitor({ notifications: undefined as any }), makeCheck(), makeDecision());
expect(result).toBe(true); expect(result).toBe(true);
expect(notificationsRepository.findNotificationsByIds).toHaveBeenCalledWith([]); expect(notificationsRepository.findNotificationsByIds).toHaveBeenCalledWith([]);
@@ -222,7 +228,7 @@ describe("NotificationsService", () => {
emailProvider.sendMessage.mockResolvedValue(true); emailProvider.sendMessage.mockResolvedValue(true);
slackProvider.sendMessage.mockResolvedValue(false); slackProvider.sendMessage.mockResolvedValue(false);
const result = await service.handleNotifications(makeMonitor(), makeStatusResponse(), makeDecision()); const result = await service.handleNotifications(makeMonitor(), makeCheck(), makeDecision());
expect(result).toBe(false); expect(result).toBe(false);
expect(logger.warn).toHaveBeenCalledWith(expect.objectContaining({ message: expect.stringContaining("1 success, 1 failure") })); expect(logger.warn).toHaveBeenCalledWith(expect.objectContaining({ message: expect.stringContaining("1 success, 1 failure") }));
@@ -233,7 +239,7 @@ describe("NotificationsService", () => {
const { service, notificationsRepository, notificationMessageBuilder } = createService({ settingsService }); const { service, notificationsRepository, notificationMessageBuilder } = createService({ settingsService });
(notificationsRepository.findNotificationsByIds as jest.Mock).mockResolvedValue([makeNotification()]); (notificationsRepository.findNotificationsByIds as jest.Mock).mockResolvedValue([makeNotification()]);
await service.handleNotifications(makeMonitor(), makeStatusResponse(), makeDecision()); await service.handleNotifications(makeMonitor(), makeCheck(), makeDecision());
expect(notificationMessageBuilder.buildMessage).toHaveBeenCalledWith( expect(notificationMessageBuilder.buildMessage).toHaveBeenCalledWith(
expect.anything(), expect.anything(),
@@ -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";
+161 -238
View File
@@ -4,7 +4,7 @@ import { createMockLogger } from "../../helpers/createMockLogger.ts";
import { MAX_RECENT_CHECKS } from "../../../src/domain/monitors/monitor.type.ts"; import { MAX_RECENT_CHECKS } from "../../../src/domain/monitors/monitor.type.ts";
import type { Monitor, MonitorStatus } from "../../../src/domain/monitors/monitor.type.ts"; import type { Monitor, MonitorStatus } from "../../../src/domain/monitors/monitor.type.ts";
import type { Check } from "../../../src/domain/checks/check.type.ts"; import type { Check } from "../../../src/domain/checks/check.type.ts";
import type { MonitorStatusResponse, HardwareStatusPayload, DockerStatusPayload } from "../../../src/types/network.ts"; import type { DockerContainerInfo } from "../../../src/domain/docker/docker.type.ts";
import type { IMonitorsRepository } from "../../../src/domain/monitors/monitor.repository.interface.ts"; import type { IMonitorsRepository } from "../../../src/domain/monitors/monitor.repository.interface.ts";
import type { IMonitorStatsRepository } from "../../../src/domain/monitor-stats/monitor-stats.repository.interface.ts"; import type { IMonitorStatsRepository } from "../../../src/domain/monitor-stats/monitor-stats.repository.interface.ts";
@@ -98,18 +98,6 @@ const makeMonitor = (overrides?: Partial<Monitor>): Monitor =>
...overrides, ...overrides,
}) as Monitor; }) as Monitor;
const makeStatusResponse = (overrides?: Partial<MonitorStatusResponse>): MonitorStatusResponse =>
({
monitorId: "mon-1",
teamId: "team-1",
type: "http",
status: true,
code: 200,
message: "OK",
responseTime: 100,
...overrides,
}) as MonitorStatusResponse;
const makeCheck = (overrides?: Partial<Check>): Check => const makeCheck = (overrides?: Partial<Check>): Check =>
({ ({
id: "check-1", id: "check-1",
@@ -143,92 +131,86 @@ const makeExistingStats = (overrides?: Record<string, unknown>) => ({
// ── Tests ──────────────────────────────────────────────────────────────────── // ── Tests ────────────────────────────────────────────────────────────────────
describe("StatusService", () => { describe("StatusService", () => {
// ── updateRunningStats ─────────────────────────────────────────────────── // ── running stats (via updateMonitorStatus) ──────────────────────────────
describe("updateRunningStats", () => { describe("running stats", () => {
// The service now delegates the entire running-stats computation to the repository's // The service delegates the running-stats computation to the repository's atomic `updateByMonitorId`,
// atomic `updateByMonitorId`, so the service-level tests only verify forwarding and error // so these cases only verify what is forwarded and that a failure never blocks the status update.
// handling. The math (running average, uptimePercentage, timeOfLastFailure state machine) // The math (running average, uptimePercentage, timeOfLastFailure) is exercised by the repository tests.
// is a repository-level concern and is exercised by the repository tests.
const run = async (check: Check) => {
const monitor = makeMonitor();
const deps = createService();
(deps.monitorsRepository.findById as jest.Mock).mockResolvedValue(monitor);
(deps.monitorStatsRepository.updateByMonitorId as jest.Mock).mockResolvedValue({});
const result = await deps.service.updateMonitorStatus(check, monitor);
return { ...deps, result };
};
it("forwards a successful check to updateByMonitorId", async () => { it("forwards a successful check to updateByMonitorId", async () => {
const { service, monitorStatsRepository } = createService(); const { monitorStatsRepository } = await run(makeCheck({ responseTime: 50 }));
(monitorStatsRepository.updateByMonitorId as jest.Mock).mockResolvedValue({});
const result = await service.updateRunningStats(makeMonitor(), makeStatusResponse({ responseTime: 50 }));
expect(result).toBe(true);
expect(monitorStatsRepository.updateByMonitorId).toHaveBeenCalledWith( expect(monitorStatsRepository.updateByMonitorId).toHaveBeenCalledWith(
"mon-1", "mon-1",
expect.objectContaining({ expect.objectContaining({ status: true, responseTime: 50, now: expect.any(Number) })
status: true,
responseTime: 50,
now: expect.any(Number),
})
); );
}); });
it("forwards a failed check to updateByMonitorId", async () => { it("forwards a failed check to updateByMonitorId", async () => {
const { service, monitorStatsRepository } = createService(); const { monitorStatsRepository } = await run(makeCheck({ status: false, responseTime: 100 }));
(monitorStatsRepository.updateByMonitorId as jest.Mock).mockResolvedValue({});
const result = await service.updateRunningStats(makeMonitor(), makeStatusResponse({ status: false, responseTime: 100 }));
expect(result).toBe(true);
expect(monitorStatsRepository.updateByMonitorId).toHaveBeenCalledWith("mon-1", expect.objectContaining({ status: false, responseTime: 100 })); expect(monitorStatsRepository.updateByMonitorId).toHaveBeenCalledWith("mon-1", expect.objectContaining({ status: false, responseTime: 100 }));
}); });
it("passes 0 when responseTime is undefined", async () => { it("passes 0 when responseTime is undefined", async () => {
const { service, monitorStatsRepository } = createService(); const { monitorStatsRepository } = await run(makeCheck({ responseTime: undefined as any }));
(monitorStatsRepository.updateByMonitorId as jest.Mock).mockResolvedValue({});
await service.updateRunningStats(makeMonitor(), makeStatusResponse({ responseTime: undefined }));
expect(monitorStatsRepository.updateByMonitorId).toHaveBeenCalledWith("mon-1", expect.objectContaining({ responseTime: 0 })); expect(monitorStatsRepository.updateByMonitorId).toHaveBeenCalledWith("mon-1", expect.objectContaining({ responseTime: 0 }));
}); });
it("forwards responseTime of 0 as 0 (falsy but defined)", async () => { it("forwards responseTime of 0 as 0 (falsy but defined)", async () => {
const { service, monitorStatsRepository } = createService(); const { monitorStatsRepository } = await run(makeCheck({ responseTime: 0 }));
(monitorStatsRepository.updateByMonitorId as jest.Mock).mockResolvedValue({});
await service.updateRunningStats(makeMonitor(), makeStatusResponse({ responseTime: 0 }));
expect(monitorStatsRepository.updateByMonitorId).toHaveBeenCalledWith("mon-1", expect.objectContaining({ responseTime: 0 })); expect(monitorStatsRepository.updateByMonitorId).toHaveBeenCalledWith("mon-1", expect.objectContaining({ responseTime: 0 }));
}); });
it("coerces a non-true status to false (e.g. undefined)", async () => { it("coerces a non-true status to false (e.g. undefined)", async () => {
const { service, monitorStatsRepository } = createService(); const { monitorStatsRepository } = await run(makeCheck({ status: undefined as any }));
(monitorStatsRepository.updateByMonitorId as jest.Mock).mockResolvedValue({});
await service.updateRunningStats(makeMonitor(), makeStatusResponse({ status: undefined as unknown as boolean }));
expect(monitorStatsRepository.updateByMonitorId).toHaveBeenCalledWith("mon-1", expect.objectContaining({ status: false })); expect(monitorStatsRepository.updateByMonitorId).toHaveBeenCalledWith("mon-1", expect.objectContaining({ status: false }));
}); });
it("returns false and logs error when updateByMonitorId throws", async () => { it("warns once and still updates the status when updateByMonitorId throws", async () => {
const { service, logger, monitorStatsRepository } = createService(); const monitor = makeMonitor({ statusWindow: [true, true, true, true], statusWindowSize: 5, status: "up" });
(monitorStatsRepository.updateByMonitorId as jest.Mock).mockRejectedValue(new Error("db write failed")); const { service, logger, monitorsRepository, monitorStatsRepository } = createService();
(monitorsRepository.findById as jest.Mock).mockResolvedValue(monitor);
(monitorStatsRepository.updateByMonitorId as jest.Mock).mockRejectedValue(new Error("stats db down"));
const result = await service.updateRunningStats(makeMonitor(), makeStatusResponse()); const result = await service.updateMonitorStatus(makeCheck({ status: true }), monitor);
expect(result).toBe(false); expect(result.monitor.status).toBe("up");
expect(logger.error).toHaveBeenCalledWith( expect(monitorsRepository.updateStatusWindowAndChecks).toHaveBeenCalledTimes(1);
expect(logger.warn).toHaveBeenCalledTimes(1);
expect(logger.warn).toHaveBeenCalledWith(
expect.objectContaining({ expect.objectContaining({
service: "StatusService", service: "StatusService",
method: "updateRunningStats", method: "tryUpdateRunningStats",
message: "db write failed", message: "stats db down",
stack: expect.any(String),
}) })
); );
expect(logger.error).not.toHaveBeenCalled();
}); });
it("logs error with 'Unknown error' for non-Error exceptions", async () => { it("warns with 'Unknown error' for non-Error rejections", async () => {
const { service, logger, monitorStatsRepository } = createService(); const monitor = makeMonitor();
const { service, logger, monitorsRepository, monitorStatsRepository } = createService();
(monitorsRepository.findById as jest.Mock).mockResolvedValue(monitor);
(monitorStatsRepository.updateByMonitorId as jest.Mock).mockRejectedValue("string error"); (monitorStatsRepository.updateByMonitorId as jest.Mock).mockRejectedValue("string error");
const result = await service.updateRunningStats(makeMonitor(), makeStatusResponse()); await service.updateMonitorStatus(makeCheck(), monitor);
expect(result).toBe(false); expect(logger.warn).toHaveBeenCalledWith(expect.objectContaining({ message: "Unknown error", stack: undefined }));
expect(logger.error).toHaveBeenCalledWith(expect.objectContaining({ message: "Unknown error", stack: undefined }));
}); });
}); });
@@ -241,7 +223,7 @@ describe("StatusService", () => {
(monitorsRepository.findById as jest.Mock).mockResolvedValue(monitor); (monitorsRepository.findById as jest.Mock).mockResolvedValue(monitor);
(monitorsRepository.updateById as jest.Mock).mockImplementation((_id: unknown, _tid: unknown, m: unknown) => Promise.resolve(m)); (monitorsRepository.updateById as jest.Mock).mockImplementation((_id: unknown, _tid: unknown, m: unknown) => Promise.resolve(m));
const result = await service.updateMonitorStatus(makeStatusResponse({ status: true }), makeCheck(), monitor); const result = await service.updateMonitorStatus(makeCheck({ status: true }), monitor);
expect(result.statusChanged).toBe(false); expect(result.statusChanged).toBe(false);
expect(result.prevStatus).toBe("up"); expect(result.prevStatus).toBe("up");
@@ -252,7 +234,7 @@ describe("StatusService", () => {
const { service, monitorsRepository } = createService(); const { service, monitorsRepository } = createService();
(monitorsRepository.updateStatusWindowAndChecks as jest.Mock).mockResolvedValue(monitor); (monitorsRepository.updateStatusWindowAndChecks as jest.Mock).mockResolvedValue(monitor);
const result = await service.updateMonitorStatus(makeStatusResponse({ status: true }), makeCheck(), monitor); const result = await service.updateMonitorStatus(makeCheck({ status: true }), monitor);
expect(monitorsRepository.findById).not.toHaveBeenCalled(); expect(monitorsRepository.findById).not.toHaveBeenCalled();
expect(result.prevStatus).toBe("up"); expect(result.prevStatus).toBe("up");
@@ -265,7 +247,7 @@ describe("StatusService", () => {
(monitorsRepository.findById as jest.Mock).mockResolvedValue(monitor); (monitorsRepository.findById as jest.Mock).mockResolvedValue(monitor);
(monitorsRepository.updateById as jest.Mock).mockImplementation((_id: unknown, _tid: unknown, m: unknown) => Promise.resolve(m)); (monitorsRepository.updateById as jest.Mock).mockImplementation((_id: unknown, _tid: unknown, m: unknown) => Promise.resolve(m));
await service.updateMonitorStatus(makeStatusResponse({ status: false }), makeCheck({ status: false }), monitor); await service.updateMonitorStatus(makeCheck({ status: false }), monitor);
// Atomic push should have been called with the correct status and window size // Atomic push should have been called with the correct status and window size
expect(monitorsRepository.updateStatusWindowAndChecks).toHaveBeenCalledWith( expect(monitorsRepository.updateStatusWindowAndChecks).toHaveBeenCalledWith(
@@ -286,7 +268,7 @@ describe("StatusService", () => {
(monitorsRepository.findById as jest.Mock).mockResolvedValue(monitor); (monitorsRepository.findById as jest.Mock).mockResolvedValue(monitor);
(monitorsRepository.updateById as jest.Mock).mockImplementation((_id: unknown, _tid: unknown, m: unknown) => Promise.resolve(m)); (monitorsRepository.updateById as jest.Mock).mockImplementation((_id: unknown, _tid: unknown, m: unknown) => Promise.resolve(m));
await service.updateMonitorStatus(makeStatusResponse(), makeCheck({ id: "new-check" }), monitor); await service.updateMonitorStatus(makeCheck({ id: "new-check" }), monitor);
expect(monitor.recentChecks).toHaveLength(MAX_RECENT_CHECKS); expect(monitor.recentChecks).toHaveLength(MAX_RECENT_CHECKS);
expect(monitor.recentChecks[MAX_RECENT_CHECKS - 1]).toEqual(expect.objectContaining({ id: "new-check" })); expect(monitor.recentChecks[MAX_RECENT_CHECKS - 1]).toEqual(expect.objectContaining({ id: "new-check" }));
@@ -304,7 +286,7 @@ describe("StatusService", () => {
(monitorsRepository.findById as jest.Mock).mockResolvedValue(monitor); (monitorsRepository.findById as jest.Mock).mockResolvedValue(monitor);
(monitorsRepository.updateById as jest.Mock).mockImplementation((_id: unknown, _tid: unknown, m: unknown) => Promise.resolve(m)); (monitorsRepository.updateById as jest.Mock).mockImplementation((_id: unknown, _tid: unknown, m: unknown) => Promise.resolve(m));
const result = await service.updateMonitorStatus(makeStatusResponse({ status: false }), makeCheck({ status: false }), monitor); const result = await service.updateMonitorStatus(makeCheck({ status: false }), monitor);
expect(result.statusChanged).toBe(true); expect(result.statusChanged).toBe(true);
expect(result.monitor.status).toBe("down"); expect(result.monitor.status).toBe("down");
@@ -322,7 +304,7 @@ describe("StatusService", () => {
(monitorsRepository.findById as jest.Mock).mockResolvedValue(monitor); (monitorsRepository.findById as jest.Mock).mockResolvedValue(monitor);
(monitorsRepository.updateById as jest.Mock).mockImplementation((_id: unknown, _tid: unknown, m: unknown) => Promise.resolve(m)); (monitorsRepository.updateById as jest.Mock).mockImplementation((_id: unknown, _tid: unknown, m: unknown) => Promise.resolve(m));
const result = await service.updateMonitorStatus(makeStatusResponse({ status: true }), makeCheck({ status: true }), monitor); const result = await service.updateMonitorStatus(makeCheck({ status: true }), monitor);
expect(result.statusChanged).toBe(true); expect(result.statusChanged).toBe(true);
expect(result.monitor.status).toBe("up"); expect(result.monitor.status).toBe("up");
@@ -339,7 +321,7 @@ describe("StatusService", () => {
(monitorsRepository.findById as jest.Mock).mockResolvedValue(monitor); (monitorsRepository.findById as jest.Mock).mockResolvedValue(monitor);
(monitorsRepository.updateById as jest.Mock).mockImplementation((_id: unknown, _tid: unknown, m: unknown) => Promise.resolve(m)); (monitorsRepository.updateById as jest.Mock).mockImplementation((_id: unknown, _tid: unknown, m: unknown) => Promise.resolve(m));
const result = await service.updateMonitorStatus(makeStatusResponse({ status: true }), makeCheck(), monitor); const result = await service.updateMonitorStatus(makeCheck({ status: true }), monitor);
expect(result.statusChanged).toBe(false); expect(result.statusChanged).toBe(false);
expect(result.monitor.status).toBe("up"); expect(result.monitor.status).toBe("up");
@@ -359,7 +341,7 @@ describe("StatusService", () => {
(monitorsRepository.findById as jest.Mock).mockResolvedValue(monitor); (monitorsRepository.findById as jest.Mock).mockResolvedValue(monitor);
(monitorsRepository.updateById as jest.Mock).mockImplementation((_id: unknown, _tid: unknown, m: unknown) => Promise.resolve(m)); (monitorsRepository.updateById as jest.Mock).mockImplementation((_id: unknown, _tid: unknown, m: unknown) => Promise.resolve(m));
const result = await service.updateMonitorStatus(makeStatusResponse({ status: false }), makeCheck({ status: false }), monitor); const result = await service.updateMonitorStatus(makeCheck({ status: false }), monitor);
expect(result.statusChanged).toBe(false); expect(result.statusChanged).toBe(false);
expect(result.monitor.status).toBe("up"); expect(result.monitor.status).toBe("up");
@@ -376,7 +358,7 @@ describe("StatusService", () => {
(monitorsRepository.findById as jest.Mock).mockResolvedValue(monitor); (monitorsRepository.findById as jest.Mock).mockResolvedValue(monitor);
(monitorsRepository.updateById as jest.Mock).mockImplementation((_id: unknown, _tid: unknown, m: unknown) => Promise.resolve(m)); (monitorsRepository.updateById as jest.Mock).mockImplementation((_id: unknown, _tid: unknown, m: unknown) => Promise.resolve(m));
const result = await service.updateMonitorStatus(makeStatusResponse({ status: false }), makeCheck({ status: false }), monitor); const result = await service.updateMonitorStatus(makeCheck({ status: false }), monitor);
expect(result.statusChanged).toBe(false); expect(result.statusChanged).toBe(false);
expect(result.monitor.status).toBe("down"); expect(result.monitor.status).toBe("down");
@@ -388,7 +370,7 @@ describe("StatusService", () => {
(monitorsRepository.findById as jest.Mock).mockResolvedValue(monitor); (monitorsRepository.findById as jest.Mock).mockResolvedValue(monitor);
(monitorsRepository.updateById as jest.Mock).mockImplementation((_id: unknown, _tid: unknown, m: unknown) => Promise.resolve(m)); (monitorsRepository.updateById as jest.Mock).mockImplementation((_id: unknown, _tid: unknown, m: unknown) => Promise.resolve(m));
await service.updateMonitorStatus(makeStatusResponse(), makeCheck(), monitor); await service.updateMonitorStatus(makeCheck(), monitor);
expect(monitor.statusWindow).toEqual([true]); expect(monitor.statusWindow).toEqual([true]);
}); });
@@ -399,7 +381,7 @@ describe("StatusService", () => {
(monitorsRepository.findById as jest.Mock).mockResolvedValue(monitor); (monitorsRepository.findById as jest.Mock).mockResolvedValue(monitor);
(monitorsRepository.updateById as jest.Mock).mockImplementation((_id: unknown, _tid: unknown, m: unknown) => Promise.resolve(m)); (monitorsRepository.updateById as jest.Mock).mockImplementation((_id: unknown, _tid: unknown, m: unknown) => Promise.resolve(m));
await service.updateMonitorStatus(makeStatusResponse(), makeCheck(), monitor); await service.updateMonitorStatus(makeCheck(), monitor);
expect(monitor.recentChecks).toHaveLength(1); expect(monitor.recentChecks).toHaveLength(1);
}); });
@@ -409,7 +391,7 @@ describe("StatusService", () => {
const { service, monitorsRepository } = createService(); const { service, monitorsRepository } = createService();
(monitorsRepository.updateStatusWindowAndChecks as jest.Mock).mockRejectedValue(new Error("db error")); (monitorsRepository.updateStatusWindowAndChecks as jest.Mock).mockRejectedValue(new Error("db error"));
await expect(service.updateMonitorStatus(makeStatusResponse(), makeCheck(), monitor)).rejects.toThrow("Failed to update monitor"); await expect(service.updateMonitorStatus(makeCheck(), monitor)).rejects.toThrow("Failed to update monitor");
}); });
it("throws AppError with 'Unknown error' for non-Error exceptions", async () => { it("throws AppError with 'Unknown error' for non-Error exceptions", async () => {
@@ -417,7 +399,7 @@ describe("StatusService", () => {
const { service, monitorsRepository } = createService(); const { service, monitorsRepository } = createService();
(monitorsRepository.updateStatusWindowAndChecks as jest.Mock).mockRejectedValue("string error"); (monitorsRepository.updateStatusWindowAndChecks as jest.Mock).mockRejectedValue("string error");
await expect(service.updateMonitorStatus(makeStatusResponse(), makeCheck(), monitor)).rejects.toThrow("Unknown error"); await expect(service.updateMonitorStatus(makeCheck(), monitor)).rejects.toThrow("Unknown error");
}); });
it("keeps status 'down' on a single successful check that leaves failure rate at threshold (regression: #3438 mirror)", async () => { it("keeps status 'down' on a single successful check that leaves failure rate at threshold (regression: #3438 mirror)", async () => {
@@ -435,7 +417,7 @@ describe("StatusService", () => {
(monitorsRepository.findById as jest.Mock).mockResolvedValue(monitor); (monitorsRepository.findById as jest.Mock).mockResolvedValue(monitor);
(monitorsRepository.updateById as jest.Mock).mockImplementation((_id: unknown, _tid: unknown, m: unknown) => Promise.resolve(m)); (monitorsRepository.updateById as jest.Mock).mockImplementation((_id: unknown, _tid: unknown, m: unknown) => Promise.resolve(m));
const result = await service.updateMonitorStatus(makeStatusResponse({ status: true }), makeCheck({ status: true }), monitor); const result = await service.updateMonitorStatus(makeCheck({ status: true }), monitor);
expect(result.statusChanged).toBe(false); expect(result.statusChanged).toBe(false);
expect(result.monitor.status).toBe("down"); expect(result.monitor.status).toBe("down");
@@ -453,7 +435,7 @@ describe("StatusService", () => {
(monitorsRepository.findById as jest.Mock).mockResolvedValue(monitor); (monitorsRepository.findById as jest.Mock).mockResolvedValue(monitor);
(monitorsRepository.updateById as jest.Mock).mockImplementation((_id: unknown, _tid: unknown, m: unknown) => Promise.resolve(m)); (monitorsRepository.updateById as jest.Mock).mockImplementation((_id: unknown, _tid: unknown, m: unknown) => Promise.resolve(m));
const result = await service.updateMonitorStatus(makeStatusResponse({ status: false }), makeCheck({ status: false }), monitor); const result = await service.updateMonitorStatus(makeCheck({ status: false }), monitor);
expect(result.statusChanged).toBe(true); expect(result.statusChanged).toBe(true);
expect(result.monitor.status).toBe("down"); expect(result.monitor.status).toBe("down");
@@ -475,7 +457,7 @@ describe("StatusService", () => {
(monitorsRepository.findById as jest.Mock).mockResolvedValue(monitor); (monitorsRepository.findById as jest.Mock).mockResolvedValue(monitor);
(monitorsRepository.updateById as jest.Mock).mockImplementation((_id: unknown, _tid: unknown, m: unknown) => Promise.resolve(m)); (monitorsRepository.updateById as jest.Mock).mockImplementation((_id: unknown, _tid: unknown, m: unknown) => Promise.resolve(m));
const result = await service.updateMonitorStatus(makeStatusResponse({ status: true }), makeCheck({ status: true }), monitor); const result = await service.updateMonitorStatus(makeCheck({ status: true }), monitor);
expect(result.monitor.status).toBe("up"); expect(result.monitor.status).toBe("up");
}); });
@@ -493,7 +475,7 @@ describe("StatusService", () => {
(monitorsRepository.findById as jest.Mock).mockResolvedValue(monitor); (monitorsRepository.findById as jest.Mock).mockResolvedValue(monitor);
(monitorsRepository.updateById as jest.Mock).mockImplementation((_id: unknown, _tid: unknown, m: unknown) => Promise.resolve(m)); (monitorsRepository.updateById as jest.Mock).mockImplementation((_id: unknown, _tid: unknown, m: unknown) => Promise.resolve(m));
const result = await service.updateMonitorStatus(makeStatusResponse({ status: false }), makeCheck({ status: false }), monitor); const result = await service.updateMonitorStatus(makeCheck({ status: false }), monitor);
expect(result.monitor.status).toBe("down"); expect(result.monitor.status).toBe("down");
}); });
@@ -514,7 +496,7 @@ describe("StatusService", () => {
(monitorsRepository.findById as jest.Mock).mockResolvedValue(monitor); (monitorsRepository.findById as jest.Mock).mockResolvedValue(monitor);
(monitorsRepository.updateById as jest.Mock).mockImplementation((_id: unknown, _tid: unknown, m: unknown) => Promise.resolve(m)); (monitorsRepository.updateById as jest.Mock).mockImplementation((_id: unknown, _tid: unknown, m: unknown) => Promise.resolve(m));
const result = await service.updateMonitorStatus(makeStatusResponse({ status: true }), makeCheck({ status: true }), monitor); const result = await service.updateMonitorStatus(makeCheck({ status: true }), monitor);
expect(result.monitor.status).toBe("up"); expect(result.monitor.status).toBe("up");
expect(result.statusChanged).toBe(false); // was effectively up already — no notification storm expect(result.statusChanged).toBe(false); // was effectively up already — no notification storm
@@ -531,7 +513,7 @@ describe("StatusService", () => {
(monitorsRepository.findById as jest.Mock).mockResolvedValue(monitor); (monitorsRepository.findById as jest.Mock).mockResolvedValue(monitor);
(monitorsRepository.updateById as jest.Mock).mockImplementation((_id: unknown, _tid: unknown, m: unknown) => Promise.resolve(m)); (monitorsRepository.updateById as jest.Mock).mockImplementation((_id: unknown, _tid: unknown, m: unknown) => Promise.resolve(m));
const result = await service.updateMonitorStatus(makeStatusResponse({ status: false }), makeCheck({ status: false }), monitor); const result = await service.updateMonitorStatus(makeCheck({ status: false }), monitor);
expect(result.monitor.status).toBe("down"); expect(result.monitor.status).toBe("down");
expect(result.statusChanged).toBe(true); // down must surface so an incident is opened expect(result.statusChanged).toBe(true); // down must surface so an incident is opened
@@ -548,7 +530,7 @@ describe("StatusService", () => {
(monitorsRepository.findById as jest.Mock).mockResolvedValue(monitor); (monitorsRepository.findById as jest.Mock).mockResolvedValue(monitor);
(monitorsRepository.updateById as jest.Mock).mockImplementation((_id: unknown, _tid: unknown, m: unknown) => Promise.resolve(m)); (monitorsRepository.updateById as jest.Mock).mockImplementation((_id: unknown, _tid: unknown, m: unknown) => Promise.resolve(m));
const result = await service.updateMonitorStatus(makeStatusResponse({ status: false }), makeCheck({ status: false }), monitor); const result = await service.updateMonitorStatus(makeCheck({ status: false }), monitor);
expect(result.statusChanged).toBe(false); expect(result.statusChanged).toBe(false);
expect(result.monitor.status).toBe("up"); expect(result.monitor.status).toBe("up");
@@ -565,7 +547,7 @@ describe("StatusService", () => {
(monitorsRepository.findById as jest.Mock).mockResolvedValue(monitor); (monitorsRepository.findById as jest.Mock).mockResolvedValue(monitor);
(monitorsRepository.updateById as jest.Mock).mockImplementation((_id: unknown, _tid: unknown, m: unknown) => Promise.resolve(m)); (monitorsRepository.updateById as jest.Mock).mockImplementation((_id: unknown, _tid: unknown, m: unknown) => Promise.resolve(m));
const result = await service.updateMonitorStatus(makeStatusResponse({ status: false }), makeCheck({ status: false }), monitor); const result = await service.updateMonitorStatus(makeCheck({ status: false }), monitor);
expect(result.statusChanged).toBe(true); expect(result.statusChanged).toBe(true);
expect(result.monitor.status).toBe("down"); expect(result.monitor.status).toBe("down");
@@ -580,7 +562,7 @@ describe("StatusService", () => {
(monitorsRepository.findById as jest.Mock).mockResolvedValue(monitor); (monitorsRepository.findById as jest.Mock).mockResolvedValue(monitor);
(monitorsRepository.updateById as jest.Mock).mockImplementation((_id: unknown, _tid: unknown, m: unknown) => Promise.resolve(m)); (monitorsRepository.updateById as jest.Mock).mockImplementation((_id: unknown, _tid: unknown, m: unknown) => Promise.resolve(m));
const result = await service.updateMonitorStatus(makeStatusResponse({ status: false }), makeCheck({ status: false }), monitor); const result = await service.updateMonitorStatus(makeCheck({ status: false }), monitor);
expect(result.statusChanged).toBe(true); expect(result.statusChanged).toBe(true);
expect(result.monitor.status).toBe("down"); expect(result.monitor.status).toBe("down");
@@ -596,7 +578,7 @@ describe("StatusService", () => {
const { service, monitorsRepository } = createService(); const { service, monitorsRepository } = createService();
(monitorsRepository.findById as jest.Mock).mockResolvedValue(monitor); (monitorsRepository.findById as jest.Mock).mockResolvedValue(monitor);
const result = await service.updateMonitorStatus(makeStatusResponse({ status: true }), makeCheck({ status: true }), monitor); const result = await service.updateMonitorStatus(makeCheck({ status: true }), monitor);
expect(result.statusChanged).toBe(false); expect(result.statusChanged).toBe(false);
expect(result.monitor.status).toBe("up"); expect(result.monitor.status).toBe("up");
@@ -611,7 +593,7 @@ describe("StatusService", () => {
const { service, monitorsRepository } = createService(); const { service, monitorsRepository } = createService();
(monitorsRepository.findById as jest.Mock).mockResolvedValue(monitor); (monitorsRepository.findById as jest.Mock).mockResolvedValue(monitor);
const result = await service.updateMonitorStatus(makeStatusResponse({ status: false }), makeCheck({ status: false }), monitor); const result = await service.updateMonitorStatus(makeCheck({ status: false }), monitor);
expect(result.statusChanged).toBe(true); expect(result.statusChanged).toBe(true);
expect(result.monitor.status).toBe("down"); expect(result.monitor.status).toBe("down");
@@ -631,7 +613,7 @@ describe("StatusService", () => {
const { service, monitorsRepository } = createService(); const { service, monitorsRepository } = createService();
(monitorsRepository.findById as jest.Mock).mockResolvedValue(monitor); (monitorsRepository.findById as jest.Mock).mockResolvedValue(monitor);
const result = await service.updateMonitorStatus(makeStatusResponse({ status: true }), makeCheck({ status: true }), monitor); const result = await service.updateMonitorStatus(makeCheck({ status: true }), monitor);
expect(result.monitor.status).toBe("up"); expect(result.monitor.status).toBe("up");
}); });
@@ -646,43 +628,12 @@ describe("StatusService", () => {
(monitorsRepository.findById as jest.Mock).mockResolvedValue(monitor); (monitorsRepository.findById as jest.Mock).mockResolvedValue(monitor);
(monitorsRepository.updateById as jest.Mock).mockImplementation((_id: unknown, _tid: unknown, m: unknown) => Promise.resolve(m)); (monitorsRepository.updateById as jest.Mock).mockImplementation((_id: unknown, _tid: unknown, m: unknown) => Promise.resolve(m));
const result = await service.updateMonitorStatus(makeStatusResponse({ status: true }), makeCheck({ status: true }), monitor); const result = await service.updateMonitorStatus(makeCheck({ status: true }), monitor);
expect(result.statusChanged).toBe(false); expect(result.statusChanged).toBe(false);
expect(result.monitor.status).toBe("down"); expect(result.monitor.status).toBe("down");
}); });
it("logs a warning but still succeeds when running-stats update fails mid-flight", async () => {
const monitor = makeMonitor({ statusWindow: [true, true, true, true], statusWindowSize: 5, status: "up" });
const { service, logger, monitorsRepository, monitorStatsRepository } = createService();
(monitorsRepository.findById as jest.Mock).mockResolvedValue(monitor);
(monitorsRepository.updateById as jest.Mock).mockImplementation((_id: unknown, _tid: unknown, m: unknown) => Promise.resolve(m));
(monitorStatsRepository.updateByMonitorId as jest.Mock).mockRejectedValue(new Error("stats db down"));
const result = await service.updateMonitorStatus(makeStatusResponse({ status: true }), makeCheck(), monitor);
expect(result.monitor.status).toBe("up");
expect(logger.warn).toHaveBeenCalledWith(
expect.objectContaining({
service: "StatusService",
method: "updateMonitorStatus",
message: expect.stringContaining("Stats update failed"),
})
);
});
it("returns code and timestamp in result", async () => {
const monitor = makeMonitor({ statusWindow: [true, true, true, true], statusWindowSize: 5 });
const { service, monitorsRepository } = createService();
(monitorsRepository.findById as jest.Mock).mockResolvedValue(monitor);
(monitorsRepository.updateById as jest.Mock).mockImplementation((_id: unknown, _tid: unknown, m: unknown) => Promise.resolve(m));
const result = await service.updateMonitorStatus(makeStatusResponse({ code: 201 }), makeCheck(), monitor);
expect(result.code).toBe(201);
expect(result.timestamp).toBeGreaterThan(0);
});
// ── Hardware threshold breach tests ────────────────────────────────── // ── Hardware threshold breach tests ──────────────────────────────────
describe("hardware threshold breaches", () => { describe("hardware threshold breaches", () => {
@@ -703,13 +654,12 @@ describe("StatusService", () => {
...overrides, ...overrides,
}); });
const makeHardwareResponse = (payload: HardwareStatusPayload, overrides?: Partial<MonitorStatusResponse<HardwareStatusPayload>>) => const makeHardwareCheck = (metrics: Pick<Check, "cpu" | "memory" | "disk" | "host">, overrides?: Partial<Check>) =>
makeStatusResponse({ makeCheck({
type: "hardware",
status: true, status: true,
payload, ...metrics,
...overrides, ...overrides,
} as any) as MonitorStatusResponse<HardwareStatusPayload>; });
it("detects CPU threshold breach and decrements counter", async () => { it("detects CPU threshold breach and decrements counter", async () => {
const monitor = makeHardwareMonitor({ cpuAlertCounter: 1 }); const monitor = makeHardwareMonitor({ cpuAlertCounter: 1 });
@@ -717,8 +667,8 @@ describe("StatusService", () => {
(monitorsRepository.findById as jest.Mock).mockResolvedValue(monitor); (monitorsRepository.findById as jest.Mock).mockResolvedValue(monitor);
(monitorsRepository.updateById as jest.Mock).mockImplementation((_id: unknown, _tid: unknown, m: unknown) => Promise.resolve(m)); (monitorsRepository.updateById as jest.Mock).mockImplementation((_id: unknown, _tid: unknown, m: unknown) => Promise.resolve(m));
const response = makeHardwareResponse({ data: { cpu: { usage_percent: 0.9 }, memory: { usage_percent: 0.5 }, disk: [], host: {} } } as any); const check = makeHardwareCheck({ cpu: { usage_percent: 0.9 }, memory: { usage_percent: 0.5 }, disk: [], host: {} });
const result = await service.updateMonitorStatus(response, makeCheck(), monitor); const result = await service.updateMonitorStatus(check, monitor);
expect(result.thresholdBreaches?.cpu).toBe(true); expect(result.thresholdBreaches?.cpu).toBe(true);
expect(result.thresholdBreaches?.memory).toBe(false); expect(result.thresholdBreaches?.memory).toBe(false);
@@ -734,8 +684,8 @@ describe("StatusService", () => {
(monitorsRepository.findById as jest.Mock).mockResolvedValue(monitor); (monitorsRepository.findById as jest.Mock).mockResolvedValue(monitor);
(monitorsRepository.updateById as jest.Mock).mockImplementation((_id: unknown, _tid: unknown, m: unknown) => Promise.resolve(m)); (monitorsRepository.updateById as jest.Mock).mockImplementation((_id: unknown, _tid: unknown, m: unknown) => Promise.resolve(m));
const response = makeHardwareResponse({ data: { cpu: { usage_percent: 0.5 }, memory: { usage_percent: 0.9 }, disk: [], host: {} } } as any); const check = makeHardwareCheck({ cpu: { usage_percent: 0.5 }, memory: { usage_percent: 0.9 }, disk: [], host: {} });
const result = await service.updateMonitorStatus(response, makeCheck(), monitor); const result = await service.updateMonitorStatus(check, monitor);
expect(result.thresholdBreaches?.memory).toBe(true); expect(result.thresholdBreaches?.memory).toBe(true);
const patch = (monitorsRepository.updateStatusWindowAndChecks as jest.Mock).mock.calls.at(-1)?.[6]; const patch = (monitorsRepository.updateStatusWindowAndChecks as jest.Mock).mock.calls.at(-1)?.[6];
@@ -749,10 +699,8 @@ describe("StatusService", () => {
(monitorsRepository.findById as jest.Mock).mockResolvedValue(monitor); (monitorsRepository.findById as jest.Mock).mockResolvedValue(monitor);
(monitorsRepository.updateById as jest.Mock).mockImplementation((_id: unknown, _tid: unknown, m: unknown) => Promise.resolve(m)); (monitorsRepository.updateById as jest.Mock).mockImplementation((_id: unknown, _tid: unknown, m: unknown) => Promise.resolve(m));
const response = makeHardwareResponse({ const check = makeHardwareCheck({ cpu: { usage_percent: 0.1 }, memory: { usage_percent: 0.1 }, disk: [{ usage_percent: 0.95 }], host: {} });
data: { cpu: { usage_percent: 0.1 }, memory: { usage_percent: 0.1 }, disk: [{ usage_percent: 0.95 }], host: {} }, const result = await service.updateMonitorStatus(check, monitor);
} as any);
const result = await service.updateMonitorStatus(response, makeCheck(), monitor);
expect(result.thresholdBreaches?.disk).toBe(true); expect(result.thresholdBreaches?.disk).toBe(true);
const patch = (monitorsRepository.updateStatusWindowAndChecks as jest.Mock).mock.calls.at(-1)?.[6]; const patch = (monitorsRepository.updateStatusWindowAndChecks as jest.Mock).mock.calls.at(-1)?.[6];
@@ -766,10 +714,8 @@ describe("StatusService", () => {
(monitorsRepository.findById as jest.Mock).mockResolvedValue(monitor); (monitorsRepository.findById as jest.Mock).mockResolvedValue(monitor);
(monitorsRepository.updateById as jest.Mock).mockImplementation((_id: unknown, _tid: unknown, m: unknown) => Promise.resolve(m)); (monitorsRepository.updateById as jest.Mock).mockImplementation((_id: unknown, _tid: unknown, m: unknown) => Promise.resolve(m));
const response = makeHardwareResponse({ const check = makeHardwareCheck({ cpu: { usage_percent: 0.1, temperature: [90] }, memory: { usage_percent: 0.1 }, disk: [], host: {} });
data: { cpu: { usage_percent: 0.1, temperature: [90] }, memory: { usage_percent: 0.1 }, disk: [], host: {} }, const result = await service.updateMonitorStatus(check, monitor);
} as any);
const result = await service.updateMonitorStatus(response, makeCheck(), monitor);
expect(result.thresholdBreaches?.temp).toBe(true); expect(result.thresholdBreaches?.temp).toBe(true);
const patch = (monitorsRepository.updateStatusWindowAndChecks as jest.Mock).mock.calls.at(-1)?.[6]; const patch = (monitorsRepository.updateStatusWindowAndChecks as jest.Mock).mock.calls.at(-1)?.[6];
@@ -788,10 +734,13 @@ describe("StatusService", () => {
(monitorsRepository.findById as jest.Mock).mockResolvedValue(monitor); (monitorsRepository.findById as jest.Mock).mockResolvedValue(monitor);
(monitorsRepository.updateById as jest.Mock).mockImplementation((_id: unknown, _tid: unknown, m: unknown) => Promise.resolve(m)); (monitorsRepository.updateById as jest.Mock).mockImplementation((_id: unknown, _tid: unknown, m: unknown) => Promise.resolve(m));
const response = makeHardwareResponse({ const check = makeHardwareCheck({
data: { cpu: { usage_percent: 0.1, temperature: [30] }, memory: { usage_percent: 0.1 }, disk: [{ usage_percent: 0.1 }], host: {} }, cpu: { usage_percent: 0.1, temperature: [30] },
} as any); memory: { usage_percent: 0.1 },
await service.updateMonitorStatus(response, makeCheck(), monitor); disk: [{ usage_percent: 0.1 }],
host: {},
});
await service.updateMonitorStatus(check, monitor);
const patch = (monitorsRepository.updateStatusWindowAndChecks as jest.Mock).mock.calls.at(-1)?.[6]; const patch = (monitorsRepository.updateStatusWindowAndChecks as jest.Mock).mock.calls.at(-1)?.[6];
expect(patch.cpuAlertCounter).toBe(5); expect(patch.cpuAlertCounter).toBe(5);
@@ -809,8 +758,8 @@ describe("StatusService", () => {
(monitorsRepository.findById as jest.Mock).mockResolvedValue(monitor); (monitorsRepository.findById as jest.Mock).mockResolvedValue(monitor);
(monitorsRepository.updateById as jest.Mock).mockImplementation((_id: unknown, _tid: unknown, m: unknown) => Promise.resolve(m)); (monitorsRepository.updateById as jest.Mock).mockImplementation((_id: unknown, _tid: unknown, m: unknown) => Promise.resolve(m));
const response = makeHardwareResponse({ data: { cpu: { usage_percent: 0.9 }, memory: { usage_percent: 0.1 }, disk: [], host: {} } } as any); const check = makeHardwareCheck({ cpu: { usage_percent: 0.9 }, memory: { usage_percent: 0.1 }, disk: [], host: {} });
const result = await service.updateMonitorStatus(response, makeCheck(), monitor); const result = await service.updateMonitorStatus(check, monitor);
expect(result.monitor.status).toBe("breached"); expect(result.monitor.status).toBe("breached");
expect(result.statusChanged).toBe(false); expect(result.statusChanged).toBe(false);
@@ -822,10 +771,13 @@ describe("StatusService", () => {
(monitorsRepository.findById as jest.Mock).mockResolvedValue(monitor); (monitorsRepository.findById as jest.Mock).mockResolvedValue(monitor);
(monitorsRepository.updateById as jest.Mock).mockImplementation((_id: unknown, _tid: unknown, m: unknown) => Promise.resolve(m)); (monitorsRepository.updateById as jest.Mock).mockImplementation((_id: unknown, _tid: unknown, m: unknown) => Promise.resolve(m));
const response = makeHardwareResponse({ const check = makeHardwareCheck({
data: { cpu: { usage_percent: 0.1, temperature: [30] }, memory: { usage_percent: 0.1 }, disk: [{ usage_percent: 0.1 }], host: {} }, cpu: { usage_percent: 0.1, temperature: [30] },
} as any); memory: { usage_percent: 0.1 },
const result = await service.updateMonitorStatus(response, makeCheck(), monitor); disk: [{ usage_percent: 0.1 }],
host: {},
});
const result = await service.updateMonitorStatus(check, monitor);
expect(result.statusChanged).toBe(true); expect(result.statusChanged).toBe(true);
expect(result.monitor.status).toBe("up"); expect(result.monitor.status).toBe("up");
@@ -844,11 +796,8 @@ describe("StatusService", () => {
(monitorsRepository.findById as jest.Mock).mockResolvedValue(monitor); (monitorsRepository.findById as jest.Mock).mockResolvedValue(monitor);
(monitorsRepository.updateById as jest.Mock).mockImplementation((_id: unknown, _tid: unknown, m: unknown) => Promise.resolve(m)); (monitorsRepository.updateById as jest.Mock).mockImplementation((_id: unknown, _tid: unknown, m: unknown) => Promise.resolve(m));
const response = makeHardwareResponse({ const check = makeHardwareCheck({ cpu: { usage_percent: 0.9 }, memory: { usage_percent: 0.1 }, disk: [], host: {} }, { status: false });
status: false, const result = await service.updateMonitorStatus(check, monitor);
data: { cpu: { usage_percent: 0.9 }, memory: { usage_percent: 0.1 }, disk: [], host: {} },
} as any);
const result = await service.updateMonitorStatus(response, makeCheck({ status: false }), monitor);
expect(result.monitor.status).toBe("down"); expect(result.monitor.status).toBe("down");
}); });
@@ -859,8 +808,8 @@ describe("StatusService", () => {
(monitorsRepository.findById as jest.Mock).mockResolvedValue(monitor); (monitorsRepository.findById as jest.Mock).mockResolvedValue(monitor);
(monitorsRepository.updateById as jest.Mock).mockImplementation((_id: unknown, _tid: unknown, m: unknown) => Promise.resolve(m)); (monitorsRepository.updateById as jest.Mock).mockImplementation((_id: unknown, _tid: unknown, m: unknown) => Promise.resolve(m));
const response = makeHardwareResponse({ data: { cpu: {}, memory: { usage_percent: 0.1 }, disk: [], host: {} } } as any); const check = makeHardwareCheck({ cpu: {}, memory: { usage_percent: 0.1 }, disk: [], host: {} });
const result = await service.updateMonitorStatus(response, makeCheck(), monitor); const result = await service.updateMonitorStatus(check, monitor);
expect(result.thresholdBreaches?.cpu).toBe(false); expect(result.thresholdBreaches?.cpu).toBe(false);
}); });
@@ -871,8 +820,8 @@ describe("StatusService", () => {
(monitorsRepository.findById as jest.Mock).mockResolvedValue(monitor); (monitorsRepository.findById as jest.Mock).mockResolvedValue(monitor);
(monitorsRepository.updateById as jest.Mock).mockImplementation((_id: unknown, _tid: unknown, m: unknown) => Promise.resolve(m)); (monitorsRepository.updateById as jest.Mock).mockImplementation((_id: unknown, _tid: unknown, m: unknown) => Promise.resolve(m));
const response = makeHardwareResponse({ data: { cpu: { usage_percent: 0.1 }, memory: {}, disk: [], host: {} } } as any); const check = makeHardwareCheck({ cpu: { usage_percent: 0.1 }, memory: {}, disk: [], host: {} });
const result = await service.updateMonitorStatus(response, makeCheck(), monitor); const result = await service.updateMonitorStatus(check, monitor);
expect(result.thresholdBreaches?.memory).toBe(false); expect(result.thresholdBreaches?.memory).toBe(false);
}); });
@@ -883,10 +832,8 @@ describe("StatusService", () => {
(monitorsRepository.findById as jest.Mock).mockResolvedValue(monitor); (monitorsRepository.findById as jest.Mock).mockResolvedValue(monitor);
(monitorsRepository.updateById as jest.Mock).mockImplementation((_id: unknown, _tid: unknown, m: unknown) => Promise.resolve(m)); (monitorsRepository.updateById as jest.Mock).mockImplementation((_id: unknown, _tid: unknown, m: unknown) => Promise.resolve(m));
const response = makeHardwareResponse({ const check = makeHardwareCheck({ cpu: { usage_percent: 0.1, temperature: [] }, memory: { usage_percent: 0.1 }, disk: [], host: {} });
data: { cpu: { usage_percent: 0.1, temperature: [] }, memory: { usage_percent: 0.1 }, disk: [], host: {} }, const result = await service.updateMonitorStatus(check, monitor);
} as any);
const result = await service.updateMonitorStatus(response, makeCheck(), monitor);
expect(result.thresholdBreaches?.temp).toBe(false); expect(result.thresholdBreaches?.temp).toBe(false);
}); });
@@ -897,36 +844,25 @@ describe("StatusService", () => {
(monitorsRepository.findById as jest.Mock).mockResolvedValue(monitor); (monitorsRepository.findById as jest.Mock).mockResolvedValue(monitor);
(monitorsRepository.updateById as jest.Mock).mockImplementation((_id: unknown, _tid: unknown, m: unknown) => Promise.resolve(m)); (monitorsRepository.updateById as jest.Mock).mockImplementation((_id: unknown, _tid: unknown, m: unknown) => Promise.resolve(m));
const response = makeHardwareResponse({ const check = makeHardwareCheck({ cpu: { usage_percent: 0.1 }, memory: { usage_percent: 0.1 }, disk: [], host: {} });
data: { cpu: { usage_percent: 0.1 }, memory: { usage_percent: 0.1 }, disk: [], host: {} }, const result = await service.updateMonitorStatus(check, monitor);
} as any);
const result = await service.updateMonitorStatus(response, makeCheck(), monitor);
expect(result.thresholdBreaches?.temp).toBe(false); expect(result.thresholdBreaches?.temp).toBe(false);
}); });
it("skips threshold evaluation when payload is undefined", async () => { it("reports no breaches when the check carries no hardware metrics", async () => {
const monitor = makeHardwareMonitor(); // A stored hardware check with no metrics (cpu/memory/disk/host all absent) must not
// throw and must not report any breach.
const monitor = makeHardwareMonitor({ cpuAlertCounter: 1 });
const { service, monitorsRepository } = createService(); const { service, monitorsRepository } = createService();
(monitorsRepository.findById as jest.Mock).mockResolvedValue(monitor); (monitorsRepository.findById as jest.Mock).mockResolvedValue(monitor);
(monitorsRepository.updateById as jest.Mock).mockImplementation((_id: unknown, _tid: unknown, m: unknown) => Promise.resolve(m)); (monitorsRepository.updateById as jest.Mock).mockImplementation((_id: unknown, _tid: unknown, m: unknown) => Promise.resolve(m));
const response = makeHardwareResponse(undefined as any); const result = await service.updateMonitorStatus(makeCheck(), monitor);
const result = await service.updateMonitorStatus(response, makeCheck(), monitor);
expect(result.thresholdBreaches).toBeUndefined(); expect(result.thresholdBreaches).toEqual({ cpu: false, memory: false, disk: false, temp: false });
}); expect(result.statusChanged).toBe(false);
expect(result.monitor.status).toBe("up");
it("skips threshold evaluation when payload.data is undefined", async () => {
const monitor = makeHardwareMonitor();
const { service, monitorsRepository } = createService();
(monitorsRepository.findById as jest.Mock).mockResolvedValue(monitor);
(monitorsRepository.updateById as jest.Mock).mockImplementation((_id: unknown, _tid: unknown, m: unknown) => Promise.resolve(m));
const response = makeHardwareResponse({} as any);
const result = await service.updateMonitorStatus(response, makeCheck(), monitor);
expect(result.thresholdBreaches).toBeUndefined();
}); });
it("does not set thresholdBreaches for non-hardware monitors", async () => { it("does not set thresholdBreaches for non-hardware monitors", async () => {
@@ -939,7 +875,7 @@ describe("StatusService", () => {
(monitorsRepository.findById as jest.Mock).mockResolvedValue(monitor); (monitorsRepository.findById as jest.Mock).mockResolvedValue(monitor);
(monitorsRepository.updateById as jest.Mock).mockImplementation((_id: unknown, _tid: unknown, m: unknown) => Promise.resolve(m)); (monitorsRepository.updateById as jest.Mock).mockImplementation((_id: unknown, _tid: unknown, m: unknown) => Promise.resolve(m));
const result = await service.updateMonitorStatus(makeStatusResponse(), makeCheck(), monitor); const result = await service.updateMonitorStatus(makeCheck(), monitor);
expect(result.thresholdBreaches).toBeUndefined(); expect(result.thresholdBreaches).toBeUndefined();
}); });
@@ -950,10 +886,8 @@ describe("StatusService", () => {
(monitorsRepository.findById as jest.Mock).mockResolvedValue(monitor); (monitorsRepository.findById as jest.Mock).mockResolvedValue(monitor);
(monitorsRepository.updateById as jest.Mock).mockImplementation((_id: unknown, _tid: unknown, m: unknown) => Promise.resolve(m)); (monitorsRepository.updateById as jest.Mock).mockImplementation((_id: unknown, _tid: unknown, m: unknown) => Promise.resolve(m));
const response = makeHardwareResponse({ const check = makeHardwareCheck({ cpu: { usage_percent: 0.1 }, memory: { usage_percent: 0.1 }, disk: [null as any], host: {} });
data: { cpu: { usage_percent: 0.1 }, memory: { usage_percent: 0.1 }, disk: [null as any], host: {} }, const result = await service.updateMonitorStatus(check, monitor);
} as any);
const result = await service.updateMonitorStatus(response, makeCheck(), monitor);
expect(result.thresholdBreaches?.disk).toBe(false); expect(result.thresholdBreaches?.disk).toBe(false);
}); });
@@ -964,10 +898,8 @@ describe("StatusService", () => {
(monitorsRepository.findById as jest.Mock).mockResolvedValue(monitor); (monitorsRepository.findById as jest.Mock).mockResolvedValue(monitor);
(monitorsRepository.updateById as jest.Mock).mockImplementation((_id: unknown, _tid: unknown, m: unknown) => Promise.resolve(m)); (monitorsRepository.updateById as jest.Mock).mockImplementation((_id: unknown, _tid: unknown, m: unknown) => Promise.resolve(m));
const response = makeHardwareResponse({ const check = makeHardwareCheck({ cpu: { usage_percent: 0.1 }, memory: { usage_percent: 0.1 }, disk: [{ device: "/dev/sda" }], host: {} });
data: { cpu: { usage_percent: 0.1 }, memory: { usage_percent: 0.1 }, disk: [{ device: "/dev/sda" }], host: {} }, const result = await service.updateMonitorStatus(check, monitor);
} as any);
const result = await service.updateMonitorStatus(response, makeCheck(), monitor);
expect(result.thresholdBreaches?.disk).toBe(false); expect(result.thresholdBreaches?.disk).toBe(false);
}); });
@@ -978,10 +910,8 @@ describe("StatusService", () => {
(monitorsRepository.findById as jest.Mock).mockResolvedValue(monitor); (monitorsRepository.findById as jest.Mock).mockResolvedValue(monitor);
(monitorsRepository.updateById as jest.Mock).mockImplementation((_id: unknown, _tid: unknown, m: unknown) => Promise.resolve(m)); (monitorsRepository.updateById as jest.Mock).mockImplementation((_id: unknown, _tid: unknown, m: unknown) => Promise.resolve(m));
const response = makeHardwareResponse({ const check = makeHardwareCheck({ cpu: undefined, memory: { usage_percent: 0.1 }, disk: [], host: {} });
data: { cpu: undefined, memory: { usage_percent: 0.1 }, disk: [], host: {} }, const result = await service.updateMonitorStatus(check, monitor);
} as any);
const result = await service.updateMonitorStatus(response, makeCheck(), monitor);
expect(result.thresholdBreaches?.cpu).toBe(false); expect(result.thresholdBreaches?.cpu).toBe(false);
expect(result.thresholdBreaches?.temp).toBe(false); expect(result.thresholdBreaches?.temp).toBe(false);
@@ -993,10 +923,8 @@ describe("StatusService", () => {
(monitorsRepository.findById as jest.Mock).mockResolvedValue(monitor); (monitorsRepository.findById as jest.Mock).mockResolvedValue(monitor);
(monitorsRepository.updateById as jest.Mock).mockImplementation((_id: unknown, _tid: unknown, m: unknown) => Promise.resolve(m)); (monitorsRepository.updateById as jest.Mock).mockImplementation((_id: unknown, _tid: unknown, m: unknown) => Promise.resolve(m));
const response = makeHardwareResponse({ const check = makeHardwareCheck({ cpu: { usage_percent: 0.1 }, memory: undefined, disk: [], host: {} });
data: { cpu: { usage_percent: 0.1 }, memory: undefined, disk: [], host: {} }, const result = await service.updateMonitorStatus(check, monitor);
} as any);
const result = await service.updateMonitorStatus(response, makeCheck(), monitor);
expect(result.thresholdBreaches?.memory).toBe(false); expect(result.thresholdBreaches?.memory).toBe(false);
}); });
@@ -1007,10 +935,8 @@ describe("StatusService", () => {
(monitorsRepository.findById as jest.Mock).mockResolvedValue(monitor); (monitorsRepository.findById as jest.Mock).mockResolvedValue(monitor);
(monitorsRepository.updateById as jest.Mock).mockImplementation((_id: unknown, _tid: unknown, m: unknown) => Promise.resolve(m)); (monitorsRepository.updateById as jest.Mock).mockImplementation((_id: unknown, _tid: unknown, m: unknown) => Promise.resolve(m));
const response = makeHardwareResponse({ const check = makeHardwareCheck({ cpu: { usage_percent: 0.1 }, memory: { usage_percent: 0.1 }, disk: undefined, host: {} });
data: { cpu: { usage_percent: 0.1 }, memory: { usage_percent: 0.1 }, disk: undefined, host: {} }, const result = await service.updateMonitorStatus(check, monitor);
} as any);
const result = await service.updateMonitorStatus(response, makeCheck(), monitor);
expect(result.thresholdBreaches?.disk).toBe(false); expect(result.thresholdBreaches?.disk).toBe(false);
}); });
@@ -1023,8 +949,8 @@ describe("StatusService", () => {
(monitorsRepository.findById as jest.Mock).mockResolvedValue(monitor); (monitorsRepository.findById as jest.Mock).mockResolvedValue(monitor);
(monitorsRepository.updateById as jest.Mock).mockImplementation((_id: unknown, _tid: unknown, m: unknown) => Promise.resolve(m)); (monitorsRepository.updateById as jest.Mock).mockImplementation((_id: unknown, _tid: unknown, m: unknown) => Promise.resolve(m));
const response = makeHardwareResponse({ data: { cpu: { usage_percent: 0.9 }, memory: { usage_percent: 0.1 }, disk: [], host: {} } } as any); const check = makeHardwareCheck({ cpu: { usage_percent: 0.9 }, memory: { usage_percent: 0.1 }, disk: [], host: {} });
const result = await service.updateMonitorStatus(response, makeCheck(), monitor); const result = await service.updateMonitorStatus(check, monitor);
const patch = (monitorsRepository.updateStatusWindowAndChecks as jest.Mock).mock.calls.at(-1)?.[6]; const patch = (monitorsRepository.updateStatusWindowAndChecks as jest.Mock).mock.calls.at(-1)?.[6];
expect(patch.cpuAlertCounter).toBe(2); expect(patch.cpuAlertCounter).toBe(2);
@@ -1046,11 +972,8 @@ describe("StatusService", () => {
(monitorsRepository.findById as jest.Mock).mockResolvedValue(monitor); (monitorsRepository.findById as jest.Mock).mockResolvedValue(monitor);
(monitorsRepository.updateById as jest.Mock).mockImplementation((_id: unknown, _tid: unknown, m: unknown) => Promise.resolve(m)); (monitorsRepository.updateById as jest.Mock).mockImplementation((_id: unknown, _tid: unknown, m: unknown) => Promise.resolve(m));
const response = makeHardwareResponse({ const check = makeHardwareCheck({ cpu: { usage_percent: 0.1 }, memory: { usage_percent: 0.1 }, disk: [], host: {} }, { status: false });
status: false, const result = await service.updateMonitorStatus(check, monitor);
data: { cpu: { usage_percent: 0.1 }, memory: { usage_percent: 0.1 }, disk: [], host: {} },
} as any);
const result = await service.updateMonitorStatus(response, makeCheck({ status: false }), monitor);
expect(result.statusChanged).toBe(true); expect(result.statusChanged).toBe(true);
expect(result.monitor.status).toBe("down"); expect(result.monitor.status).toBe("down");
@@ -1067,10 +990,13 @@ describe("StatusService", () => {
(monitorsRepository.findById as jest.Mock).mockResolvedValue(monitor); (monitorsRepository.findById as jest.Mock).mockResolvedValue(monitor);
(monitorsRepository.updateById as jest.Mock).mockImplementation((_id: unknown, _tid: unknown, m: unknown) => Promise.resolve(m)); (monitorsRepository.updateById as jest.Mock).mockImplementation((_id: unknown, _tid: unknown, m: unknown) => Promise.resolve(m));
const response = makeHardwareResponse({ const check = makeHardwareCheck({
data: { cpu: { usage_percent: 0.9, temperature: [90] }, memory: { usage_percent: 0.9 }, disk: [{ usage_percent: 0.95 }], host: {} }, cpu: { usage_percent: 0.9, temperature: [90] },
} as any); memory: { usage_percent: 0.9 },
await service.updateMonitorStatus(response, makeCheck(), monitor); disk: [{ usage_percent: 0.95 }],
host: {},
});
await service.updateMonitorStatus(check, monitor);
expect(monitor.cpuAlertCounter).toBe(0); expect(monitor.cpuAlertCounter).toBe(0);
expect(monitor.memoryAlertCounter).toBe(0); expect(monitor.memoryAlertCounter).toBe(0);
@@ -1099,8 +1025,8 @@ describe("StatusService", () => {
...overrides, ...overrides,
}); });
const makeContainers = (containers: Partial<DockerStatusPayload["containers"][number]>[]): DockerStatusPayload => ({ const makeContainers = (containers: Partial<DockerContainerInfo>[]): DockerContainerInfo[] =>
containers: containers.map((container, index) => ({ containers.map((container, index) => ({
id: `container-${index}`, id: `container-${index}`,
name: `container-${index}`, name: `container-${index}`,
image: "nginx:latest", image: "nginx:latest",
@@ -1108,23 +1034,20 @@ describe("StatusService", () => {
status: "Up 3 hours", status: "Up 3 hours",
health: "none", health: "none",
...container, ...container,
})), }));
summary: { total: containers.length, running: 0, stopped: 0, unhealthy: 0 },
});
const makeDockerResponse = (payload: DockerStatusPayload | null, overrides?: Partial<MonitorStatusResponse<DockerStatusPayload>>) => const makeDockerCheck = (containers: DockerContainerInfo[] | null, overrides?: Partial<Check>) =>
makeStatusResponse({ makeCheck({
type: "docker",
status: true, status: true,
payload, ...(containers ? { containers } : {}),
...overrides, ...overrides,
} as any) as MonitorStatusResponse<DockerStatusPayload>; });
const run = async (monitor: Monitor, response: MonitorStatusResponse<DockerStatusPayload>, check = makeCheck()) => { const run = async (monitor: Monitor, check: Check) => {
const { service, monitorsRepository } = createService(); const { service, monitorsRepository } = createService();
(monitorsRepository.findById as jest.Mock).mockResolvedValue(monitor); (monitorsRepository.findById as jest.Mock).mockResolvedValue(monitor);
(monitorsRepository.updateById as jest.Mock).mockImplementation((_id: unknown, _tid: unknown, m: unknown) => Promise.resolve(m)); (monitorsRepository.updateById as jest.Mock).mockImplementation((_id: unknown, _tid: unknown, m: unknown) => Promise.resolve(m));
const result = await service.updateMonitorStatus(response, check, monitor); const result = await service.updateMonitorStatus(check, monitor);
return { result, monitorsRepository }; return { result, monitorsRepository };
}; };
@@ -1132,14 +1055,14 @@ describe("StatusService", () => {
const unhealthy = { name: "api", health: "unhealthy" as const }; const unhealthy = { name: "api", health: "unhealthy" as const };
it("goes to breached on the first check with a stopped container", async () => { it("goes to breached on the first check with a stopped container", async () => {
const { result } = await run(makeDockerMonitor(), makeDockerResponse(makeContainers([stopped]))); const { result } = await run(makeDockerMonitor(), makeDockerCheck(makeContainers([stopped])));
expect(result.statusChanged).toBe(true); expect(result.statusChanged).toBe(true);
expect(result.monitor.status).toBe("breached"); expect(result.monitor.status).toBe("breached");
}); });
it("goes to breached on the first check with an unhealthy container", async () => { it("goes to breached on the first check with an unhealthy container", async () => {
const { result } = await run(makeDockerMonitor(), makeDockerResponse(makeContainers([unhealthy]))); const { result } = await run(makeDockerMonitor(), makeDockerCheck(makeContainers([unhealthy])));
expect(result.statusChanged).toBe(true); expect(result.statusChanged).toBe(true);
expect(result.monitor.status).toBe("breached"); expect(result.monitor.status).toBe("breached");
@@ -1147,7 +1070,7 @@ describe("StatusService", () => {
it("stays up on a stopped container when both switches are off", async () => { it("stays up on a stopped container when both switches are off", async () => {
const monitor = makeDockerMonitor({ dockerAlertOnStopped: false, dockerAlertOnUnhealthy: false }); const monitor = makeDockerMonitor({ dockerAlertOnStopped: false, dockerAlertOnUnhealthy: false });
const { result } = await run(monitor, makeDockerResponse(makeContainers([stopped, unhealthy]))); const { result } = await run(monitor, makeDockerCheck(makeContainers([stopped, unhealthy])));
expect(result.statusChanged).toBe(false); expect(result.statusChanged).toBe(false);
expect(result.monitor.status).toBe("up"); expect(result.monitor.status).toBe("up");
@@ -1155,35 +1078,35 @@ describe("StatusService", () => {
it("stays up on a stopped container when only the unhealthy switch is on", async () => { it("stays up on a stopped container when only the unhealthy switch is on", async () => {
const monitor = makeDockerMonitor({ dockerAlertOnStopped: false }); const monitor = makeDockerMonitor({ dockerAlertOnStopped: false });
const { result } = await run(monitor, makeDockerResponse(makeContainers([stopped]))); const { result } = await run(monitor, makeDockerCheck(makeContainers([stopped])));
expect(result.statusChanged).toBe(false); expect(result.statusChanged).toBe(false);
expect(result.monitor.status).toBe("up"); expect(result.monitor.status).toBe("up");
}); });
it("stays up on an exited container with code 0", async () => { it("stays up on an exited container with code 0", async () => {
const { result } = await run(makeDockerMonitor(), makeDockerResponse(makeContainers([{ name: "job", state: "exited", exitCode: 0 }]))); const { result } = await run(makeDockerMonitor(), makeDockerCheck(makeContainers([{ name: "job", state: "exited", exitCode: 0 }])));
expect(result.statusChanged).toBe(false); expect(result.statusChanged).toBe(false);
expect(result.monitor.status).toBe("up"); expect(result.monitor.status).toBe("up");
}); });
it("stays breached without a transition while a container is still down", async () => { it("stays breached without a transition while a container is still down", async () => {
const { result } = await run(makeDockerMonitor({ status: "breached" }), makeDockerResponse(makeContainers([stopped]))); const { result } = await run(makeDockerMonitor({ status: "breached" }), makeDockerCheck(makeContainers([stopped])));
expect(result.statusChanged).toBe(false); expect(result.statusChanged).toBe(false);
expect(result.monitor.status).toBe("breached"); expect(result.monitor.status).toBe("breached");
}); });
it("stays up without a transition while every container is clean", async () => { it("stays up without a transition while every container is clean", async () => {
const { result } = await run(makeDockerMonitor(), makeDockerResponse(makeContainers([{ name: "web" }]))); const { result } = await run(makeDockerMonitor(), makeDockerCheck(makeContainers([{ name: "web" }])));
expect(result.statusChanged).toBe(false); expect(result.statusChanged).toBe(false);
expect(result.monitor.status).toBe("up"); expect(result.monitor.status).toBe("up");
}); });
it("recovers from breached to up on the first clean check", async () => { it("recovers from breached to up on the first clean check", async () => {
const { result } = await run(makeDockerMonitor({ status: "breached" }), makeDockerResponse(makeContainers([{ name: "web" }]))); const { result } = await run(makeDockerMonitor({ status: "breached" }), makeDockerCheck(makeContainers([{ name: "web" }])));
expect(result.statusChanged).toBe(true); expect(result.statusChanged).toBe(true);
expect(result.prevStatus).toBe("breached"); expect(result.prevStatus).toBe("breached");
@@ -1192,7 +1115,7 @@ describe("StatusService", () => {
it("recovers from breached to up when the breaching switch is turned off", async () => { it("recovers from breached to up when the breaching switch is turned off", async () => {
const monitor = makeDockerMonitor({ status: "breached", dockerAlertOnStopped: false }); const monitor = makeDockerMonitor({ status: "breached", dockerAlertOnStopped: false });
const { result } = await run(monitor, makeDockerResponse(makeContainers([stopped]))); const { result } = await run(monitor, makeDockerCheck(makeContainers([stopped])));
expect(result.statusChanged).toBe(true); expect(result.statusChanged).toBe(true);
expect(result.monitor.status).toBe("up"); expect(result.monitor.status).toBe("up");
@@ -1200,16 +1123,16 @@ describe("StatusService", () => {
it("does not override down with breached when the host is unreachable", async () => { it("does not override down with breached when the host is unreachable", async () => {
const monitor = makeDockerMonitor({ statusWindow: [false, false, false, false], statusWindowThreshold: 80 }); const monitor = makeDockerMonitor({ statusWindow: [false, false, false, false], statusWindowThreshold: 80 });
const response = makeDockerResponse(null, { status: false, code: 5000 }); const check = makeDockerCheck(null, { status: false, statusCode: 5000 });
const { result } = await run(monitor, response, makeCheck({ status: false })); const { result } = await run(monitor, check);
expect(result.monitor.status).toBe("down"); expect(result.monitor.status).toBe("down");
}); });
it("transitions from breached to down when the reachability threshold trips", async () => { it("transitions from breached to down when the reachability threshold trips", async () => {
const monitor = makeDockerMonitor({ status: "breached", statusWindow: [false, false, false, false], statusWindowThreshold: 80 }); const monitor = makeDockerMonitor({ status: "breached", statusWindow: [false, false, false, false], statusWindowThreshold: 80 });
const response = makeDockerResponse(null, { status: false, code: 5000 }); const check = makeDockerCheck(null, { status: false, statusCode: 5000 });
const { result } = await run(monitor, response, makeCheck({ status: false })); const { result } = await run(monitor, check);
expect(result.statusChanged).toBe(true); expect(result.statusChanged).toBe(true);
expect(result.monitor.status).toBe("down"); expect(result.monitor.status).toBe("down");
@@ -1217,7 +1140,7 @@ describe("StatusService", () => {
it("does not evaluate containers while the status window is short", async () => { it("does not evaluate containers while the status window is short", async () => {
const monitor = makeDockerMonitor({ statusWindow: [true], statusWindowSize: 5 }); const monitor = makeDockerMonitor({ statusWindow: [true], statusWindowSize: 5 });
const { result } = await run(monitor, makeDockerResponse(makeContainers([stopped]))); const { result } = await run(monitor, makeDockerCheck(makeContainers([stopped])));
expect(result.statusChanged).toBe(false); expect(result.statusChanged).toBe(false);
expect(result.monitor.status).toBe("up"); expect(result.monitor.status).toBe("up");
@@ -1225,15 +1148,15 @@ describe("StatusService", () => {
it("does not evaluate a failed check with a null payload", async () => { it("does not evaluate a failed check with a null payload", async () => {
const monitor = makeDockerMonitor({ status: "breached" }); const monitor = makeDockerMonitor({ status: "breached" });
const response = makeDockerResponse(null, { status: false, code: 5000 }); const check = makeDockerCheck(null, { status: false, statusCode: 5000 });
const { result } = await run(monitor, response, makeCheck({ status: false })); const { result } = await run(monitor, check);
expect(result.statusChanged).toBe(false); expect(result.statusChanged).toBe(false);
expect(result.monitor.status).toBe("breached"); expect(result.monitor.status).toBe("breached");
}); });
it("does not touch the hardware counters or thresholdBreaches", async () => { it("does not touch the hardware counters or thresholdBreaches", async () => {
const { result, monitorsRepository } = await run(makeDockerMonitor(), makeDockerResponse(makeContainers([stopped]))); const { result, monitorsRepository } = await run(makeDockerMonitor(), makeDockerCheck(makeContainers([stopped])));
expect(result.thresholdBreaches).toBeUndefined(); expect(result.thresholdBreaches).toBeUndefined();
const patch = (monitorsRepository.updateStatusWindowAndChecks as jest.Mock).mock.calls.at(-1)?.[6]; const patch = (monitorsRepository.updateStatusWindowAndChecks as jest.Mock).mock.calls.at(-1)?.[6];
+112 -140
View File
@@ -64,8 +64,6 @@ const makeStatusChange = (overrides: Partial<StatusChangeResult> & { status?: st
monitor: { id: "m1", status } as any, monitor: { id: "m1", status } as any,
statusChanged: false, statusChanged: false,
prevStatus: "up", prevStatus: "up",
code: 200,
timestamp: 0,
...rest, ...rest,
} as StatusChangeResult; } as StatusChangeResult;
}; };
@@ -92,10 +90,9 @@ const createPipeline = (overrides?: Record<string, any>) => {
}, },
checkService: { checkService: {
toCheck: jest.fn<any>().mockReturnValue({ id: "check-1" }), toCheck: jest.fn<any>().mockReturnValue({ id: "check-1" }),
toStatusResponse: jest.fn<any>().mockReturnValue({ monitorId: "m1", status: true, code: 200, message: "OK" }),
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([]) },
@@ -118,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" });
}); });
@@ -128,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 () => {
@@ -138,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();
}); });
}); });
@@ -159,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();
}); });
@@ -188,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 () => {
@@ -206,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") },
}); });
@@ -219,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 () => {
@@ -228,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" } });
}); });
@@ -243,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 () => {
@@ -253,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 () => {
@@ -271,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");
@@ -300,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) },
}); });
@@ -314,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) },
}); });
@@ -357,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") },
}); });
@@ -373,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") },
}); });
@@ -388,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") },
}); });
@@ -409,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") },
}); });
@@ -427,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") },
}); });
@@ -441,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") },
}); });
@@ -456,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") },
}); });
@@ -475,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") },
}); });
@@ -486,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") },
}); });
@@ -497,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") },
}); });
@@ -509,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,28 +516,10 @@ describe("WorkerPipeline", () => {
expect(check.egressStatus).toBe("ok"); expect(check.egressStatus).toBe("ok");
}); });
it("buffers the check when the egress assessment rejects", async () => {
const check: Record<string, unknown> = { id: "check-1" };
const { pipeline, deps } = createPipeline({
networkService: { requestStatus: jest.fn<any>().mockResolvedValue(failingStatus) },
checkService: { toCheck: jest.fn<any>().mockReturnValue(check) },
egressService: { assessAfterFailure: jest.fn<any>().mockRejectedValue(new Error("settings unavailable")) },
});
const result = await pipeline.produce(makeMonitor());
expect(deps.bufferService.addToBuffer).toHaveBeenCalledWith(check);
expect(check).not.toHaveProperty("egressStatus");
expect(result).toEqual({ status: failingStatus, check });
expect(deps.logger.warn).toHaveBeenCalledWith(
expect.objectContaining({ message: expect.stringContaining("settings unavailable"), service: "WorkerPipeline", method: "assessEgress" })
);
});
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) },
}); });
@@ -624,7 +603,7 @@ describe("WorkerPipeline", () => {
it("loads the row's pending checks, dispatches each, and pulls its id after dispatch", async () => { it("loads the row's pending checks, dispatches each, and pulls its id after dispatch", async () => {
const job = makeEvaluateJob("c1"); const job = makeEvaluateJob("c1");
const { pipeline, deps } = createPipeline({ const { pipeline, deps } = createPipeline({
checksRepository: { findUnevaluatedByMonitorId: jest.fn<any>().mockResolvedValue([{ id: "c1" }]) }, checksRepository: { findUnevaluatedByMonitorId: jest.fn<any>().mockResolvedValue([makeCheck({ id: "c1" })]) },
}); });
await pipeline.handleEvaluate(job); await pipeline.handleEvaluate(job);
@@ -661,7 +640,9 @@ describe("WorkerPipeline", () => {
it("stops applying checks once the lease is lost so a second claimer does not double-apply", async () => { it("stops applying checks once the lease is lost so a second claimer does not double-apply", async () => {
const job = makeEvaluateJob("c1", "c2", "c3"); const job = makeEvaluateJob("c1", "c2", "c3");
const { pipeline, deps } = createPipeline({ const { pipeline, deps } = createPipeline({
checksRepository: { findUnevaluatedByMonitorId: jest.fn<any>().mockResolvedValue([{ id: "c1" }, { id: "c2" }, { id: "c3" }]) }, checksRepository: {
findUnevaluatedByMonitorId: jest.fn<any>().mockResolvedValue([makeCheck({ id: "c1" }), makeCheck({ id: "c2" }), makeCheck({ id: "c3" })]),
},
jobsRepository: { jobsRepository: {
upsertEvaluate: jest.fn<any>(), upsertEvaluate: jest.fn<any>(),
pullEvaluated: jest.fn<any>().mockResolvedValueOnce(true).mockResolvedValueOnce(false), // lease lost after c2 pullEvaluated: jest.fn<any>().mockResolvedValueOnce(true).mockResolvedValueOnce(false), // lease lost after c2
@@ -679,7 +660,7 @@ describe("WorkerPipeline", () => {
it("pulls ids whose check no longer exists so they do not sit on the row forever", async () => { it("pulls ids whose check no longer exists so they do not sit on the row forever", async () => {
const job = makeEvaluateJob("c1", "c-gone"); const job = makeEvaluateJob("c1", "c-gone");
const { pipeline, deps } = createPipeline({ const { pipeline, deps } = createPipeline({
checksRepository: { findUnevaluatedByMonitorId: jest.fn<any>().mockResolvedValue([{ id: "c1" }]) }, // c-gone was cleaned up checksRepository: { findUnevaluatedByMonitorId: jest.fn<any>().mockResolvedValue([makeCheck({ id: "c1" })]) }, // c-gone was cleaned up
}); });
await pipeline.handleEvaluate(job); await pipeline.handleEvaluate(job);
@@ -695,7 +676,7 @@ describe("WorkerPipeline", () => {
const postWriteMonitor = makeMonitor({ status: "down" }); // what updateStatusWindowAndChecks would return after check c1 const postWriteMonitor = makeMonitor({ status: "down" }); // what updateStatusWindowAndChecks would return after check c1
const { pipeline, deps } = createPipeline({ const { pipeline, deps } = createPipeline({
monitorsRepository: { findByIdLean: jest.fn<any>().mockResolvedValue(initialMonitor), updateById: jest.fn<any>() }, monitorsRepository: { findByIdLean: jest.fn<any>().mockResolvedValue(initialMonitor), updateById: jest.fn<any>() },
checksRepository: { findUnevaluatedByMonitorId: jest.fn<any>().mockResolvedValue([{ id: "c1" }, { id: "c2" }]) }, checksRepository: { findUnevaluatedByMonitorId: jest.fn<any>().mockResolvedValue([makeCheck({ id: "c1" }), makeCheck({ id: "c2" })]) },
statusService: { updateMonitorStatus: jest.fn<any>().mockResolvedValue(makeStatusChange({ monitor: postWriteMonitor })) }, statusService: { updateMonitorStatus: jest.fn<any>().mockResolvedValue(makeStatusChange({ monitor: postWriteMonitor })) },
}); });
@@ -705,103 +686,80 @@ describe("WorkerPipeline", () => {
expect(deps.monitorsRepository.findByIdLean).toHaveBeenCalledTimes(1); expect(deps.monitorsRepository.findByIdLean).toHaveBeenCalledTimes(1);
expect(deps.statusService.updateMonitorStatus).toHaveBeenCalledTimes(2); expect(deps.statusService.updateMonitorStatus).toHaveBeenCalledTimes(2);
// First check evaluates against the freshly-read monitor; the second against the post-write monitor from the first // First check evaluates against the freshly-read monitor; the second against the post-write monitor from the first
expect(deps.statusService.updateMonitorStatus.mock.calls[0][2]).toBe(initialMonitor); expect(deps.statusService.updateMonitorStatus.mock.calls[0][1]).toBe(initialMonitor);
expect(deps.statusService.updateMonitorStatus.mock.calls[1][2]).toBe(postWriteMonitor); expect(deps.statusService.updateMonitorStatus.mock.calls[1][1]).toBe(postWriteMonitor);
}); });
}); });
describe("evaluateCheck", () => { describe("evaluateCheck", () => {
it("threads status, check, and monitor through to the status service", async () => { it("threads check and monitor through to the status service", async () => {
const { pipeline, deps } = createPipeline(); const { pipeline, deps } = createPipeline();
const status = { monitorId: "m1", status: true, code: 200, message: "OK" } as any; const check = makeCheck({ status: true, statusCode: 200, message: "OK" });
const check = { id: "check-1" } as any;
const monitor = makeMonitor({ status: "up" }); const monitor = makeMonitor({ status: "up" });
await pipeline.evaluateCheck(status, check, monitor); await pipeline.evaluateCheck(check, monitor);
expect(deps.statusService.updateMonitorStatus).toHaveBeenCalledWith(status, check, monitor); expect(deps.statusService.updateMonitorStatus).toHaveBeenCalledWith(check, monitor);
}); });
it("returns an evaluation that opens an incident on a down transition", async () => { it("returns an evaluation that opens an incident on a down transition", async () => {
const status = { monitorId: "m1", status: false, code: 500, message: "Error" } as any; const check = makeCheck({ status: false, statusCode: 500, message: "Error" });
const check = { id: "check-1" } as any; const statusChange = makeStatusChange({ monitor: makeMonitor({ status: "down" }), statusChanged: true, prevStatus: "up" });
const statusChange = makeStatusChange({ monitor: makeMonitor({ status: "down" }), statusChanged: true, prevStatus: "up", code: 500 });
const { pipeline } = createPipeline({ const { pipeline } = createPipeline({
statusService: { updateMonitorStatus: jest.fn<any>().mockResolvedValue(statusChange) }, statusService: { updateMonitorStatus: jest.fn<any>().mockResolvedValue(statusChange) },
}); });
const result = await pipeline.evaluateCheck(status, check, makeMonitor()); const result = await pipeline.evaluateCheck(check, makeMonitor());
expect(result).toMatchObject({ expect(result).toMatchObject({
monitor: statusChange.monitor, monitor: statusChange.monitor,
status,
check, check,
statusChange, decision: { transition: "status_down" },
decision: expect.objectContaining({
shouldCreateIncident: true,
shouldSendNotification: true,
incidentReason: "status_down",
}),
}); });
}); });
it("returns an evaluation that resolves an incident on recovery", async () => { it("returns an evaluation that resolves an incident on recovery", async () => {
const status = { monitorId: "m1", status: true, code: 200, message: "OK" } as any;
const statusChange = makeStatusChange({ monitor: makeMonitor({ status: "up" }), statusChanged: true, prevStatus: "down" }); const statusChange = makeStatusChange({ monitor: makeMonitor({ status: "up" }), statusChanged: true, prevStatus: "down" });
const { pipeline } = createPipeline({ const { pipeline } = createPipeline({
statusService: { updateMonitorStatus: jest.fn<any>().mockResolvedValue(statusChange) }, statusService: { updateMonitorStatus: jest.fn<any>().mockResolvedValue(statusChange) },
}); });
const result = await pipeline.evaluateCheck(status, { id: "check-1" } as any, 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 () => {
const status = { monitorId: "m1", status: true, code: 200, message: "OK" } as any;
const { pipeline } = createPipeline(); const { pipeline } = createPipeline();
const result = await pipeline.evaluateCheck(status, { id: "check-1" } as any, 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 ───────────────────────────────────────────────────
it("short-circuits a check flagged with degraded egress without touching monitor status", async () => { it("short-circuits a check flagged with degraded egress without touching monitor status", async () => {
const status = { monitorId: "m1", status: false, code: 500, message: "Error" } as any; const check = makeCheck({ status: false, statusCode: 500, message: "Error", egressStatus: "degraded" });
const check = { id: "check-1", egressStatus: "degraded" } as any;
const monitor = makeMonitor({ status: "up" }); const monitor = makeMonitor({ status: "up" });
const { pipeline, deps } = createPipeline(); const { pipeline, deps } = createPipeline();
const result = await pipeline.evaluateCheck(status, check, monitor); const result = await pipeline.evaluateCheck(check, monitor);
expect(deps.statusService.updateMonitorStatus).not.toHaveBeenCalled(); expect(deps.statusService.updateMonitorStatus).not.toHaveBeenCalled();
expect(result.monitor).toBe(monitor); expect(result.monitor).toBe(monitor);
expect(result.statusChange).toMatchObject({ monitor, statusChanged: false, prevStatus: "up", code: 500 }); expect(result).not.toHaveProperty("statusChange");
expect(result.statusChange.timestamp).toEqual(expect.any(Number)); expect(result.decision).toEqual({ transition: null });
expect(result.decision).toEqual({
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") }));
}); });
it("evaluates a check flagged with egress ok as normal", async () => { it("evaluates a check flagged with egress ok as normal", async () => {
const status = { monitorId: "m1", status: false, code: 500, message: "Error" } as any; const check = makeCheck({ status: false, statusCode: 500, message: "Error", egressStatus: "ok" });
const check = { id: "check-1", egressStatus: "ok" } as any;
const { pipeline, deps } = createPipeline(); const { pipeline, deps } = createPipeline();
await pipeline.evaluateCheck(status, check, makeMonitor()); await pipeline.evaluateCheck(check, makeMonitor());
expect(deps.statusService.updateMonitorStatus).toHaveBeenCalledWith(status, check, expect.anything()); expect(deps.statusService.updateMonitorStatus).toHaveBeenCalledWith(check, expect.anything());
}); });
}); });
@@ -812,68 +770,82 @@ describe("WorkerPipeline", () => {
const { pipeline } = createPipeline({ const { pipeline } = createPipeline({
statusService: { updateMonitorStatus: jest.fn<any>().mockResolvedValue(statusChange) }, statusService: { updateMonitorStatus: jest.fn<any>().mockResolvedValue(statusChange) },
}); });
const result = await pipeline.evaluateCheck( const result = await pipeline.evaluateCheck(makeCheck({ status: true, statusCode: 200, message: "OK" }), makeMonitor());
{ monitorId: "m1", status: true, code: 200, message: "OK" } as any,
{ id: "check-1" } as any,
makeMonitor()
);
return result.decision; return result.decision;
}; };
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", code: 500 })); 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 () => {
const thresholdBreaches = { cpu: true, memory: false, disk: true, temp: false };
const decision = await decide(makeStatusChange({ status: "breached", statusChanged: true, prevStatus: "up", thresholdBreaches }));
expect(decision).toEqual({ transition: "threshold_breach", thresholdBreaches });
});
it("leaves threshold breaches off a down decision even when the status change carries them", async () => {
const thresholdBreaches = { cpu: true, memory: false, disk: false, temp: false };
const decision = await decide(makeStatusChange({ status: "down", statusChanged: true, prevStatus: "up", thresholdBreaches }));
expect(decision).not.toHaveProperty("thresholdBreaches");
});
it("leaves threshold breaches off a no-op decision", async () => {
const thresholdBreaches = { cpu: true, memory: false, disk: false, temp: false };
const decision = await decide(makeStatusChange({ status: "breached", statusChanged: false, prevStatus: "breached", thresholdBreaches }));
expect(decision).not.toHaveProperty("thresholdBreaches");
});
// StatusService only sets thresholdBreaches for hardware monitors; a docker breach must not leave the key behind holding undefined.
it("leaves threshold breaches off a breached decision when the status change carries none", async () => {
const decision = await decide(makeStatusChange({ status: "breached", statusChanged: true, prevStatus: "up" }));
expect(decision).toEqual({ transition: "threshold_breach" });
});
// A transition must not leak into the next decision the same pipeline makes, whether that is a no-op
// evaluation or a degraded-egress short-circuit.
it("starts every decision clean after a transition on the same pipeline", async () => {
const updateMonitorStatus = jest
.fn<any>()
.mockResolvedValueOnce(makeStatusChange({ status: "down", statusChanged: true, prevStatus: "up" }))
.mockResolvedValueOnce(makeStatusChange({ status: "down", statusChanged: false, prevStatus: "down" }));
const { pipeline } = createPipeline({ statusService: { updateMonitorStatus } });
const failing = makeCheck({ status: false, statusCode: 500, message: "Error" });
const first = await pipeline.evaluateCheck(failing, makeMonitor());
const second = await pipeline.evaluateCheck(failing, makeMonitor({ status: "down" }));
const degraded = await pipeline.evaluateCheck(makeCheck({ ...failing, egressStatus: "degraded" }), makeMonitor({ status: "down" }));
expect(first.decision).toEqual({ transition: "status_down" });
const clean = { transition: null };
expect(second.decision).toEqual(clean);
expect(degraded.decision).toEqual(clean);
}); });
}); });
}); });