Compare commits
48
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
a5766a55a9 | ||
|
|
c5902b397a | ||
|
|
e52ec2d6ee | ||
|
|
a2c978527f | ||
|
|
224dc60309 | ||
|
|
d8e4c775d5 | ||
|
|
a07d0059b7 | ||
|
|
1a48d303f7 | ||
|
|
5bb9ca970c | ||
|
|
2947b558a2 | ||
|
|
98a5fa9941 | ||
|
|
712350d669 | ||
|
|
1a287be323 | ||
|
|
014750f91e | ||
|
|
574078bdc8 | ||
|
|
7ab80ac3f4 | ||
|
|
a258910ccf | ||
|
|
e15a85bd61 | ||
|
|
ced90211a0 | ||
|
|
4ae8426766 | ||
|
|
32a5ca8799 | ||
|
|
5d77b8804f | ||
|
|
544502c6e5 | ||
|
|
1ef540679b | ||
|
|
18f38818e2 | ||
|
|
02e6333c3c | ||
|
|
cb47439e9f | ||
|
|
4bbfe2e889 | ||
|
|
187a0fc3d4 | ||
|
|
4e4d3c3971 | ||
|
|
6267e08a51 | ||
|
|
f2c23636fe | ||
|
|
11c89d150a | ||
|
|
6b75f9bf7c | ||
|
|
91de9e7fb7 | ||
|
|
21fb320f3a | ||
|
|
3586ac9437 | ||
|
|
b0ed75382e | ||
|
|
b1646cd83b | ||
|
|
7abc74ac98 | ||
|
|
a1b10c7469 | ||
|
|
0d75629dcb | ||
|
|
849ea26cf9 | ||
|
|
380968472f | ||
|
|
0cb48596a0 | ||
|
|
22d0123966 | ||
|
|
2a45b4d46f | ||
|
|
bdd4634a3b |
No files matched your search
@@ -12,7 +12,7 @@ jobs:
|
||||
- name: Set up Node
|
||||
uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: 22
|
||||
node-version: 24
|
||||
|
||||
- name: Install client dependencies
|
||||
working-directory: client
|
||||
@@ -29,7 +29,7 @@ jobs:
|
||||
- name: Set up Node
|
||||
uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: 22
|
||||
node-version: 24
|
||||
|
||||
- name: Install server dependencies
|
||||
working-directory: server
|
||||
|
||||
@@ -12,7 +12,7 @@ jobs:
|
||||
- name: Set up Node
|
||||
uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: 22
|
||||
node-version: 24
|
||||
|
||||
- name: Install client dependencies
|
||||
working-directory: client
|
||||
@@ -30,7 +30,7 @@ jobs:
|
||||
- name: Set up Node
|
||||
uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: 22
|
||||
node-version: 24
|
||||
|
||||
- name: Install server dependencies
|
||||
working-directory: server
|
||||
|
||||
@@ -12,7 +12,7 @@ jobs:
|
||||
- name: Set up Node
|
||||
uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: 22
|
||||
node-version: 24
|
||||
|
||||
- name: Install server dependencies
|
||||
working-directory: server
|
||||
|
||||
@@ -62,7 +62,7 @@ VITE_APP_LOG_LEVEL="debug"
|
||||
|
||||
### Monorepo Structure
|
||||
- `/client` - React 18 + TypeScript + Vite + MUI frontend
|
||||
- `/server` - Node.js 20+ + Express + TypeScript backend
|
||||
- `/server` - Node.js 24+ + Express + TypeScript backend
|
||||
- `/docker` - Multi-environment Docker configs (dev, staging, prod, arm, mono)
|
||||
|
||||
### Backend Layers
|
||||
|
||||
@@ -2,6 +2,10 @@
|
||||
"name": "client",
|
||||
"private": true,
|
||||
"version": "3.12.0",
|
||||
"engines": {
|
||||
"node": ">=24",
|
||||
"npm": ">=11"
|
||||
},
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"lockfile": "docker run --rm -v \"$PWD\":/w -w /w $(grep -m1 -o 'node:[^ ]*' ../docker/Dockerfile) npm install --package-lock-only",
|
||||
|
||||
@@ -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";
|
||||
|
||||
type ThemeMode = "light" | "dark";
|
||||
type ChartType = "histogram" | "line";
|
||||
|
||||
type TableName =
|
||||
| "dashboard"
|
||||
| "monitors"
|
||||
| "team"
|
||||
| "maintenance"
|
||||
@@ -15,6 +21,8 @@ type TableName =
|
||||
|
||||
interface TableState {
|
||||
rowsPerPage: number;
|
||||
sortField?: string;
|
||||
sortOrder?: SortOrder;
|
||||
}
|
||||
|
||||
interface SidebarState {
|
||||
@@ -22,6 +30,7 @@ interface SidebarState {
|
||||
}
|
||||
|
||||
interface UIState {
|
||||
dashboard: TableState;
|
||||
monitors: TableState;
|
||||
pagespeed: TableState;
|
||||
team: TableState;
|
||||
@@ -38,6 +47,7 @@ interface UIState {
|
||||
starPromptOpen: boolean;
|
||||
chartType: ChartType;
|
||||
dashboardVisibleCards: DashboardCardKey[];
|
||||
dashboardUptimeSortOrder: DashboardSortOrder;
|
||||
}
|
||||
|
||||
const initialMode: ThemeMode = window?.matchMedia?.("(prefers-color-scheme: dark)")
|
||||
@@ -46,6 +56,9 @@ const initialMode: ThemeMode = window?.matchMedia?.("(prefers-color-scheme: dark
|
||||
: "light";
|
||||
|
||||
const initialState: UIState = {
|
||||
dashboard: {
|
||||
rowsPerPage: 100,
|
||||
},
|
||||
monitors: {
|
||||
rowsPerPage: 10,
|
||||
},
|
||||
@@ -78,6 +91,7 @@ const initialState: UIState = {
|
||||
starPromptOpen: true,
|
||||
chartType: "histogram",
|
||||
dashboardVisibleCards: [...dashboardCardKeys],
|
||||
dashboardUptimeSortOrder: "ascending",
|
||||
};
|
||||
|
||||
const uiSlice = createSlice({
|
||||
@@ -94,6 +108,14 @@ const uiSlice = createSlice({
|
||||
const { table, value } = action.payload;
|
||||
state[table].rowsPerPage = value;
|
||||
},
|
||||
setTableSort: (
|
||||
state,
|
||||
action: PayloadAction<{ table: TableName; field: string; order: SortOrder }>
|
||||
) => {
|
||||
const { table, field, order } = action.payload;
|
||||
state[table].sortField = field;
|
||||
state[table].sortOrder = order;
|
||||
},
|
||||
toggleSidebar: (state) => {
|
||||
state.sidebar.collapsed = !state.sidebar.collapsed;
|
||||
},
|
||||
@@ -124,6 +146,9 @@ const uiSlice = createSlice({
|
||||
setDashboardVisibleCards: (state, action: PayloadAction<DashboardCardKey[]>) => {
|
||||
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 const {
|
||||
setRowsPerPage,
|
||||
setTableSort,
|
||||
toggleSidebar,
|
||||
setCollapsed,
|
||||
setMode,
|
||||
@@ -141,4 +167,5 @@ export const {
|
||||
setStarPromptOpen,
|
||||
setChartType,
|
||||
setDashboardVisibleCards,
|
||||
setDashboardUptimeSortOrder,
|
||||
} = uiSlice.actions;
|
||||
@@ -1,4 +1,4 @@
|
||||
import { setRowsPerPage, type TableName } from "@/Features/UI/uiSlice";
|
||||
import { setRowsPerPage, setTableSort, type TableName } from "@/Features/UI/uiSlice";
|
||||
import { useDelete, useGet } from "@/Hooks/UseApi";
|
||||
import { useBulkMonitorActions } from "@/Hooks/useBulkMonitorActions";
|
||||
import useDebounce from "@/Hooks/useDebounce";
|
||||
@@ -10,6 +10,7 @@ import {
|
||||
type MonitorType,
|
||||
} from "@/Types/Monitor";
|
||||
import type { Tag } from "@/Types/Tag";
|
||||
import type { SortOrder } from "@/Types/Query";
|
||||
import { useCallback, useMemo, useState } from "react";
|
||||
import { useDispatch, useSelector } from "react-redux";
|
||||
|
||||
@@ -24,10 +25,12 @@ interface MonitorListConfig {
|
||||
|
||||
export const useMonitorListController = (config: MonitorListConfig) => {
|
||||
const dispatch = useDispatch();
|
||||
const rowsPerPage = useSelector(
|
||||
(state: RootState) =>
|
||||
state.ui?.[config.rowsPerPageTable]?.rowsPerPage ?? config.rowsPerPageDefault
|
||||
const tableState = useSelector(
|
||||
(state: RootState) => state.ui?.[config.rowsPerPageTable]
|
||||
);
|
||||
const rowsPerPage = tableState?.rowsPerPage ?? config.rowsPerPageDefault;
|
||||
const sortField = tableState?.sortField ?? config.initialSortField ?? "";
|
||||
const sortOrder = tableState?.sortOrder ?? "asc";
|
||||
|
||||
// Filter states
|
||||
const [selectedTypes, setSelectedTypes] = useState<MonitorType[]>([]);
|
||||
@@ -36,8 +39,6 @@ export const useMonitorListController = (config: MonitorListConfig) => {
|
||||
const [selectedTags, setSelectedTags] = useState<string[]>([]);
|
||||
const [page, setPage] = useState<number>(0);
|
||||
const [search, setSearch] = useState<string>("");
|
||||
const [sortField, setSortField] = useState<string>(config.initialSortField ?? "");
|
||||
const [sortOrder, setSortOrder] = useState<"asc" | "desc">("asc");
|
||||
const [selectedMonitor, setSelectedMonitor] = useState<Monitor | null>(null);
|
||||
const debouncedSearch = useDebounce<string>(search, 300);
|
||||
|
||||
@@ -135,6 +136,11 @@ export const useMonitorListController = (config: MonitorListConfig) => {
|
||||
setPage(0);
|
||||
};
|
||||
|
||||
const setSort = (field: string, order: SortOrder) => {
|
||||
dispatch(setTableSort({ field, order, table: config.rowsPerPageTable }));
|
||||
setPage(0);
|
||||
};
|
||||
|
||||
// Check for active filters
|
||||
const hasActiveFilters = Boolean(
|
||||
selectedTypes.length > 0 ||
|
||||
@@ -189,9 +195,8 @@ export const useMonitorListController = (config: MonitorListConfig) => {
|
||||
rowsPerPage,
|
||||
handleSetRowsPerPage,
|
||||
sortField,
|
||||
setSortField,
|
||||
sortOrder,
|
||||
setSortOrder,
|
||||
setSort,
|
||||
|
||||
// Delete
|
||||
selectedMonitor,
|
||||
|
||||
@@ -1,8 +1,11 @@
|
||||
import { useState } from "react";
|
||||
import { useMemo, useState } from "react";
|
||||
import Stack from "@mui/material/Stack";
|
||||
import FormControlLabel from "@mui/material/FormControlLabel";
|
||||
import Typography from "@mui/material/Typography";
|
||||
import { useTheme } from "@mui/material/styles";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { Checkbox, Dialog } from "@/Components/inputs";
|
||||
import { LAYOUT } from "@/Utils/Theme/constants";
|
||||
import { type DashboardCardKey, dashboardCardKeys } from "@/Types/Dashboard";
|
||||
|
||||
interface EditCardsModalProps {
|
||||
@@ -18,16 +21,19 @@ export const EditCardsModal = ({
|
||||
onClose,
|
||||
onSave,
|
||||
}: EditCardsModalProps) => {
|
||||
const theme = useTheme();
|
||||
const { t } = useTranslation();
|
||||
const [draft, setDraft] = useState<Set<DashboardCardKey>>(new Set(visibleCards));
|
||||
const [cardsSelection, setCardsSelection] = useState<Set<DashboardCardKey>>(
|
||||
new Set(visibleCards)
|
||||
);
|
||||
|
||||
const handleClose = () => {
|
||||
setDraft(new Set(visibleCards));
|
||||
setCardsSelection(new Set(visibleCards));
|
||||
onClose();
|
||||
};
|
||||
|
||||
const handleToggle = (key: DashboardCardKey) => {
|
||||
setDraft((prev) => {
|
||||
setCardsSelection((prev) => {
|
||||
const next = new Set(prev);
|
||||
if (next.has(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 (
|
||||
<Dialog
|
||||
open={open}
|
||||
title={t("pages.dashboard.editCardsModal.title")}
|
||||
content={t("pages.dashboard.editCardsModal.description")}
|
||||
onCancel={handleClose}
|
||||
onConfirm={() => onSave(draft)}
|
||||
onConfirm={() => onSave(cardsSelection)}
|
||||
confirmText={t("pages.dashboard.editCardsModal.done")}
|
||||
maxWidth="xs"
|
||||
fullWidth
|
||||
>
|
||||
<Stack>
|
||||
{dashboardCardKeys.map((key: DashboardCardKey) => (
|
||||
<Stack gap={theme.spacing(LAYOUT.XXS)}>
|
||||
<Typography
|
||||
variant="eyebrow"
|
||||
color={theme.palette.text.disabled}
|
||||
>
|
||||
{t("pages.dashboard.editCardsModal.groups.uptime")}
|
||||
</Typography>
|
||||
{sortedCardKeys.map((key: DashboardCardKey) => (
|
||||
<FormControlLabel
|
||||
key={key}
|
||||
label={t(`pages.dashboard.cards.${key}`)}
|
||||
control={
|
||||
<Checkbox
|
||||
checked={draft.has(key)}
|
||||
disabled={draft.has(key) && draft.size === 1}
|
||||
checked={cardsSelection.has(key)}
|
||||
disabled={cardsSelection.has(key) && cardsSelection.size === 1}
|
||||
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>
|
||||
);
|
||||
};
|
||||
@@ -14,6 +14,8 @@ import { setDashboardVisibleCards } from "@/Features/UI/uiSlice";
|
||||
import type { RootState, AppDispatch } from "@/store";
|
||||
|
||||
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 { EditCardsModal } from "@/Pages/Dashboard/components/EditCardsModal";
|
||||
import { type DashboardCardKey, dashboardCardKeys } from "@/Types/Dashboard";
|
||||
@@ -32,7 +34,7 @@ const Dashboard = () => {
|
||||
types: [...MonitorTypes],
|
||||
checksLimit: 1,
|
||||
refreshInterval: 30000,
|
||||
rowsPerPageTable: "monitors",
|
||||
rowsPerPageTable: "dashboard",
|
||||
rowsPerPageDefault: 100,
|
||||
});
|
||||
|
||||
@@ -48,6 +50,8 @@ const Dashboard = () => {
|
||||
|
||||
const cardComponents: Record<DashboardCardKey, ReactNode> = {
|
||||
monitorStatus: <MonitorStatusCard summary={summary} />,
|
||||
currentlyDown: <CurrentlyDownCard monitors={monitors ?? []} />,
|
||||
uptime: <UptimeCard monitors={monitors ?? []} />,
|
||||
monitorsByType: <MonitorsByTypeCard monitorsByType={monitorsByType} />,
|
||||
};
|
||||
|
||||
|
||||
@@ -8,6 +8,7 @@ import {
|
||||
ColoredLabel,
|
||||
} from "@/Components/design-elements";
|
||||
import { SPACING } from "@/Utils/Theme/constants";
|
||||
import { getNextMonitorSort } from "@/Utils/MonitorUtils";
|
||||
import type { Header } from "@/Components/design-elements/Table";
|
||||
import { ActionsMenu, type ActionMenuItem } from "@/Components/actions-menu";
|
||||
import { ArrowUp, ArrowDown } from "lucide-react";
|
||||
@@ -20,6 +21,7 @@ import { usePost } from "@/Hooks/UseApi";
|
||||
|
||||
import type { Monitor } from "@/Types/Monitor";
|
||||
import type { Tag } from "@/Types/Tag";
|
||||
import type { SortOrder } from "@/Types/Query";
|
||||
import type { DockerContainerSummary } from "@/Types/Check";
|
||||
import { Checkbox } from "@/Components/inputs";
|
||||
|
||||
@@ -34,9 +36,8 @@ interface DockerMonitorsTableProps {
|
||||
refetch: () => void;
|
||||
setSelectedMonitor: (monitor: Monitor | null) => void;
|
||||
sortField: string;
|
||||
setSortField: (field: string) => void;
|
||||
sortOrder: "asc" | "desc";
|
||||
setSortOrder: (order: "asc" | "desc") => void;
|
||||
sortOrder: SortOrder;
|
||||
setSort: (field: string, order: SortOrder) => void;
|
||||
count: number;
|
||||
page: number;
|
||||
setPage: (page: number) => void;
|
||||
@@ -52,9 +53,8 @@ export const DockerMonitorsTable = ({
|
||||
refetch,
|
||||
setSelectedMonitor,
|
||||
sortField,
|
||||
setSortField,
|
||||
sortOrder,
|
||||
setSortOrder,
|
||||
setSort,
|
||||
count,
|
||||
page,
|
||||
setPage,
|
||||
@@ -105,14 +105,8 @@ export const DockerMonitorsTable = ({
|
||||
const handleSort = (e: React.MouseEvent, field: string) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
if (sortField === field) {
|
||||
const newOrder = sortOrder === "asc" ? "desc" : "asc";
|
||||
setSortOrder(newOrder);
|
||||
} else {
|
||||
setSortField(field);
|
||||
setSortOrder("asc");
|
||||
}
|
||||
refetch();
|
||||
const nextSort = getNextMonitorSort(field, sortField, sortOrder);
|
||||
setSort(nextSort.field, nextSort.order);
|
||||
};
|
||||
|
||||
const getActions = (monitor: Monitor): ActionMenuItem[] => {
|
||||
|
||||
@@ -31,9 +31,8 @@ const DockerMonitorsPage = () => {
|
||||
setPage={c.setPage}
|
||||
rowsPerPage={c.rowsPerPage}
|
||||
sortField={c.sortField}
|
||||
setSortField={c.setSortField}
|
||||
sortOrder={c.sortOrder}
|
||||
setSortOrder={c.setSortOrder}
|
||||
setSort={c.setSort}
|
||||
setRowsPerPage={c.handleSetRowsPerPage}
|
||||
selectedRows={c.selectedRows}
|
||||
onSelectionChange={c.setSelectedRows}
|
||||
|
||||
@@ -9,6 +9,7 @@ import {
|
||||
ColoredLabel,
|
||||
} from "@/Components/design-elements";
|
||||
import { SPACING } from "@/Utils/Theme/constants";
|
||||
import { getNextMonitorSort } from "@/Utils/MonitorUtils";
|
||||
import type { Header } from "@/Components/design-elements/Table";
|
||||
import { ActionsMenu, type ActionMenuItem } from "@/Components/actions-menu";
|
||||
import { ArrowUp, ArrowDown } from "lucide-react";
|
||||
@@ -21,6 +22,7 @@ import { usePost } from "@/Hooks/UseApi";
|
||||
|
||||
import type { Monitor } from "@/Types/Monitor";
|
||||
import type { Tag } from "@/Types/Tag";
|
||||
import type { SortOrder } from "@/Types/Query";
|
||||
import { Checkbox } from "@/Components/inputs";
|
||||
|
||||
interface InfraMonitorsTableProps {
|
||||
@@ -29,9 +31,8 @@ interface InfraMonitorsTableProps {
|
||||
refetch: () => void;
|
||||
setSelectedMonitor: (monitor: Monitor | null) => void;
|
||||
sortField: string;
|
||||
setSortField: (field: string) => void;
|
||||
sortOrder: "asc" | "desc";
|
||||
setSortOrder: (order: "asc" | "desc") => void;
|
||||
sortOrder: SortOrder;
|
||||
setSort: (field: string, order: SortOrder) => void;
|
||||
count: number;
|
||||
page: number;
|
||||
setPage: (page: number) => void;
|
||||
@@ -47,9 +48,8 @@ export const InfraMonitorsTable = ({
|
||||
refetch,
|
||||
setSelectedMonitor,
|
||||
sortField,
|
||||
setSortField,
|
||||
sortOrder,
|
||||
setSortOrder,
|
||||
setSort,
|
||||
count,
|
||||
page,
|
||||
setPage,
|
||||
@@ -100,14 +100,8 @@ export const InfraMonitorsTable = ({
|
||||
const handleSort = (e: React.MouseEvent, field: string) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
if (sortField === field) {
|
||||
const newOrder = sortOrder === "asc" ? "desc" : "asc";
|
||||
setSortOrder(newOrder);
|
||||
} else {
|
||||
setSortField(field);
|
||||
setSortOrder("asc");
|
||||
}
|
||||
refetch();
|
||||
const nextSort = getNextMonitorSort(field, sortField, sortOrder);
|
||||
setSort(nextSort.field, nextSort.order);
|
||||
};
|
||||
|
||||
const getActions = (monitor: Monitor): ActionMenuItem[] => {
|
||||
|
||||
@@ -31,9 +31,8 @@ const InfrastructureMonitors = () => {
|
||||
setPage={c.setPage}
|
||||
rowsPerPage={c.rowsPerPage}
|
||||
sortField={c.sortField}
|
||||
setSortField={c.setSortField}
|
||||
sortOrder={c.sortOrder}
|
||||
setSortOrder={c.setSortOrder}
|
||||
setSort={c.setSort}
|
||||
setRowsPerPage={c.handleSetRowsPerPage}
|
||||
selectedRows={c.selectedRows}
|
||||
onSelectionChange={c.setSelectedRows}
|
||||
|
||||
@@ -9,6 +9,7 @@ import {
|
||||
ColoredLabel,
|
||||
} from "@/Components/design-elements";
|
||||
import { LAYOUT, SPACING } from "@/Utils/Theme/constants";
|
||||
import { getNextMonitorSort } from "@/Utils/MonitorUtils";
|
||||
import { HistogramPageSpeed } from "@/Components/monitors";
|
||||
import { ArrowDown, ArrowUp } from "lucide-react";
|
||||
import type { Header } from "@/Components/design-elements/Table";
|
||||
@@ -22,6 +23,7 @@ import { usePatch } from "@/Hooks/UseApi";
|
||||
|
||||
import type { Monitor, MonitorWithChecks } from "@/Types/Monitor";
|
||||
import type { Tag } from "@/Types/Tag";
|
||||
import type { SortOrder } from "@/Types/Query";
|
||||
import type { ActionMenuItem } from "@/Components/actions-menu";
|
||||
|
||||
export const PageSpeedMonitorsTable = ({
|
||||
@@ -30,9 +32,8 @@ export const PageSpeedMonitorsTable = ({
|
||||
refetch,
|
||||
setSelectedMonitor,
|
||||
sortField,
|
||||
setSortField,
|
||||
sortOrder,
|
||||
setSortOrder,
|
||||
setSort,
|
||||
count,
|
||||
page,
|
||||
setPage,
|
||||
@@ -44,9 +45,8 @@ export const PageSpeedMonitorsTable = ({
|
||||
refetch: Function;
|
||||
setSelectedMonitor: Function;
|
||||
sortField: string;
|
||||
setSortField: (field: string) => void;
|
||||
sortOrder: "asc" | "desc";
|
||||
setSortOrder: (order: "asc" | "desc") => void;
|
||||
sortOrder: SortOrder;
|
||||
setSort: (field: string, order: SortOrder) => void;
|
||||
count: number;
|
||||
page: number;
|
||||
setPage: (page: number) => void;
|
||||
@@ -81,14 +81,8 @@ export const PageSpeedMonitorsTable = ({
|
||||
const handleSort = (e: any, field: string) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
if (sortField === field) {
|
||||
const newOrder = sortOrder === "asc" ? "desc" : "asc";
|
||||
setSortOrder(newOrder);
|
||||
} else {
|
||||
setSortField(field);
|
||||
setSortOrder("asc");
|
||||
}
|
||||
refetch();
|
||||
const nextSort = getNextMonitorSort(field, sortField, sortOrder);
|
||||
setSort(nextSort.field, nextSort.order);
|
||||
};
|
||||
|
||||
const getActions = (monitor: Monitor): ActionMenuItem[] => {
|
||||
|
||||
@@ -46,9 +46,8 @@ const PageSpeedMonitorsPage = () => {
|
||||
setPage={c.setPage}
|
||||
rowsPerPage={c.rowsPerPage}
|
||||
sortField={c.sortField}
|
||||
setSortField={c.setSortField}
|
||||
sortOrder={c.sortOrder}
|
||||
setSortOrder={c.setSortOrder}
|
||||
setSort={c.setSort}
|
||||
setRowsPerPage={c.handleSetRowsPerPage}
|
||||
/>
|
||||
</MonitorListPage>
|
||||
|
||||
@@ -24,8 +24,9 @@ import type { ActionMenuItem } from "@/Components/actions-menu";
|
||||
import type { RootState } from "@/Types/state";
|
||||
import { Checkbox } from "@/Components/inputs";
|
||||
import type { Tag } from "@/Types/Tag";
|
||||
import type { SortOrder } from "@/Types/Query";
|
||||
import { SPACING } from "@/Utils/Theme/constants";
|
||||
import { getUptimePercentageColor } from "@/Utils/MonitorUtils";
|
||||
import { getNextMonitorSort, getUptimePercentageColor } from "@/Utils/MonitorUtils";
|
||||
import { formatPercentage } from "@/Utils/FormatUtils";
|
||||
|
||||
interface MonitorTableProps {
|
||||
@@ -34,9 +35,8 @@ interface MonitorTableProps {
|
||||
refetch: () => void;
|
||||
setSelectedMonitor: (monitor: Monitor | null) => void;
|
||||
sortField: string;
|
||||
setSortField: (field: string) => void;
|
||||
sortOrder: "asc" | "desc";
|
||||
setSortOrder: (order: "asc" | "desc") => void;
|
||||
sortOrder: SortOrder;
|
||||
setSort: (field: string, order: SortOrder) => void;
|
||||
count: number;
|
||||
page: number;
|
||||
setPage: (page: number) => void;
|
||||
@@ -52,9 +52,8 @@ export const MonitorTable = ({
|
||||
refetch,
|
||||
setSelectedMonitor,
|
||||
sortField,
|
||||
setSortField,
|
||||
sortOrder,
|
||||
setSortOrder,
|
||||
setSort,
|
||||
count,
|
||||
page,
|
||||
setPage,
|
||||
@@ -91,14 +90,8 @@ export const MonitorTable = ({
|
||||
const handleSort = (e: React.MouseEvent, field: string) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
if (sortField === field) {
|
||||
const newOrder = sortOrder === "asc" ? "desc" : "asc";
|
||||
setSortOrder(newOrder);
|
||||
} else {
|
||||
setSortField(field);
|
||||
setSortOrder("asc");
|
||||
}
|
||||
refetch();
|
||||
const nextSort = getNextMonitorSort(field, sortField, sortOrder);
|
||||
setSort(nextSort.field, nextSort.order);
|
||||
};
|
||||
|
||||
const isBrowserOpenable = (monitor: Monitor): boolean => {
|
||||
|
||||
@@ -30,9 +30,8 @@ const UptimeMonitorsPage = () => {
|
||||
setPage={monitorListController.setPage}
|
||||
rowsPerPage={monitorListController.rowsPerPage}
|
||||
sortField={monitorListController.sortField}
|
||||
setSortField={monitorListController.setSortField}
|
||||
sortOrder={monitorListController.sortOrder}
|
||||
setSortOrder={monitorListController.setSortOrder}
|
||||
setSort={monitorListController.setSort}
|
||||
setRowsPerPage={monitorListController.handleSetRowsPerPage}
|
||||
selectedRows={monitorListController.selectedRows}
|
||||
onSelectionChange={monitorListController.setSelectedRows}
|
||||
|
||||
@@ -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 const dashboardSortOrders = ["ascending", "descending"] as const;
|
||||
export type DashboardSortOrder = (typeof dashboardSortOrders)[number];
|
||||
@@ -1,2 +1,5 @@
|
||||
export const DateRanges = ["recent", "hour", "day", "week", "month", "all"] as const;
|
||||
export type DateRange = (typeof DateRanges)[number];
|
||||
|
||||
export const SortOrders = ["asc", "desc"] as const;
|
||||
export type SortOrder = (typeof SortOrders)[number];
|
||||
@@ -1,4 +1,5 @@
|
||||
import type { MonitorStatus, MonitorType } from "@/Types/Monitor";
|
||||
import type { SortOrder } from "@/Types/Query";
|
||||
import type { PaletteKey } from "@/Utils/Theme/Theme";
|
||||
import type { ValueType } from "@/Components/design-elements/StatusLabel";
|
||||
import {
|
||||
@@ -10,6 +11,16 @@ import {
|
||||
type DockerLogLine,
|
||||
} from "@/Types/Check";
|
||||
|
||||
export const getNextMonitorSort = (
|
||||
field: string,
|
||||
sortField: string,
|
||||
sortOrder: SortOrder
|
||||
): { field: string; order: SortOrder } => {
|
||||
if (field !== sortField) return { field, order: "asc" };
|
||||
if (sortOrder === "asc") return { field, order: "desc" };
|
||||
return { field: "", order: "asc" };
|
||||
};
|
||||
|
||||
export const getMonitorPath = (type: MonitorType): string => {
|
||||
const pathMap: Record<MonitorType, string> = {
|
||||
http: "uptime",
|
||||
|
||||
@@ -1936,16 +1936,27 @@
|
||||
},
|
||||
"cards": {
|
||||
"monitorStatus": "Monitor status",
|
||||
"currentlyDown": "Currently down",
|
||||
"uptime": "Monitor uptime",
|
||||
"monitorsByType": "Monitors by type"
|
||||
},
|
||||
"uptimeCard": {
|
||||
"toggleSortOrder": "Toggle sort order"
|
||||
},
|
||||
"currentlyDown": {
|
||||
"empty": "No monitors are down at this moment."
|
||||
},
|
||||
"header": {
|
||||
"title": "Dashboard",
|
||||
"description": "See every monitor type in one place and spot what needs attention."
|
||||
},
|
||||
"editCards": "Edit cards",
|
||||
"editCardsModal": {
|
||||
"title": "Edit cards",
|
||||
"title": "Dashboard cards",
|
||||
"description": "Select the cards to display on your dashboard.",
|
||||
"groups": {
|
||||
"uptime": "Uptime"
|
||||
},
|
||||
"done": "Done"
|
||||
}
|
||||
}
|
||||
|
||||
+2
-2
@@ -4,7 +4,7 @@
|
||||
# Build on the host arch (BUILDPLATFORM) rather than under QEMU — the output is
|
||||
# pure JS so it's arch-independent, and this avoids the esbuild host/native
|
||||
# binary version mismatch under emulation.
|
||||
FROM --platform=$BUILDPLATFORM node:22-slim AS frontend-build
|
||||
FROM --platform=$BUILDPLATFORM node:24-slim AS frontend-build
|
||||
|
||||
WORKDIR /app/client
|
||||
|
||||
@@ -19,7 +19,7 @@ RUN npm run build
|
||||
# ---------------------
|
||||
# Backend stage
|
||||
# ---------------------
|
||||
FROM node:22-slim AS app
|
||||
FROM node:24-slim AS app
|
||||
|
||||
# Install ping
|
||||
RUN apt-get update \
|
||||
|
||||
Generated
+486
-445
File diff suppressed because it is too large.
Load diff
+6
-5
@@ -30,7 +30,8 @@
|
||||
"author": "",
|
||||
"license": "ISC",
|
||||
"engines": {
|
||||
"node": ">=20"
|
||||
"node": ">=24",
|
||||
"npm": ">=11"
|
||||
},
|
||||
"dependencies": {
|
||||
"@csstools/css-tokenizer": "^3.0.4",
|
||||
@@ -45,7 +46,7 @@
|
||||
"dayjs": "^1.11.23",
|
||||
"dockerode": "5.0.0",
|
||||
"dotenv": "^16.4.5",
|
||||
"express": "^4.19.2",
|
||||
"express": "5.2.1",
|
||||
"express-rate-limit": "^8.5.1",
|
||||
"gamedig": "5.2.0",
|
||||
"got": "14.4.7",
|
||||
@@ -58,11 +59,11 @@
|
||||
"jsonwebtoken": "9.0.2",
|
||||
"mjml": "^5.0.0-alpha.4",
|
||||
"mongoose": "^8.3.3",
|
||||
"multer": "^1.4.5-lts.2",
|
||||
"nodemailer": "9.0.5",
|
||||
"multer": "2.4.0",
|
||||
"nodemailer": "9.1.1",
|
||||
"ping": "0.4.4",
|
||||
"re2": "^1.24.1",
|
||||
"sharp": "0.35.3",
|
||||
"sharp": "0.35.4",
|
||||
"ssl-checker": "2.0.10",
|
||||
"swagger-ui-express": "5.0.1",
|
||||
"tldts": "^7.4.10",
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { AppError } from "@/utils/AppError.js";
|
||||
import { Monitor, MonitorTypes, type MonitorType } from "@/domain/monitors/monitor.type.js";
|
||||
import { Monitor } from "@/domain/monitors/monitor.type.js";
|
||||
import { UserRole } from "@/domain/users/user.type.js";
|
||||
import sslChecker, { SSLDetails } from "ssl-checker";
|
||||
import * as whoiser from "whoiser";
|
||||
@@ -124,23 +124,6 @@ export const fetchMonitorDomain = async (
|
||||
return domainExpiry;
|
||||
};
|
||||
|
||||
export const requireString = (value: unknown, fieldName: string): string => {
|
||||
if (typeof value === "string" && value.trim().length > 0) {
|
||||
return value;
|
||||
}
|
||||
throw new AppError({ message: `${fieldName} is required`, status: 400 });
|
||||
};
|
||||
|
||||
export const optionalString = (value: unknown, fieldName: string): string | undefined => {
|
||||
if (value === undefined) {
|
||||
return undefined;
|
||||
}
|
||||
if (typeof value === "string") {
|
||||
return value;
|
||||
}
|
||||
throw new AppError({ message: `${fieldName} must be a string`, status: 400 });
|
||||
};
|
||||
|
||||
export const extractString = (value: unknown): string | undefined => {
|
||||
const candidate = Array.isArray(value) ? value[0] : value;
|
||||
if (typeof candidate !== "string" || candidate.length === 0) {
|
||||
@@ -149,52 +132,6 @@ export const extractString = (value: unknown): string | undefined => {
|
||||
return candidate;
|
||||
};
|
||||
|
||||
export const optionalNumber = (value: unknown, fieldName: string): number | undefined => {
|
||||
if (value === undefined) {
|
||||
return undefined;
|
||||
}
|
||||
if (typeof value === "number" && Number.isFinite(value)) {
|
||||
return value;
|
||||
}
|
||||
if (typeof value === "string" && value.trim() !== "") {
|
||||
const parsed = Number(value);
|
||||
if (!Number.isNaN(parsed)) {
|
||||
return parsed;
|
||||
}
|
||||
}
|
||||
throw new AppError({ message: `${fieldName} must be a number`, status: 400 });
|
||||
};
|
||||
|
||||
export const parseMonitorTypeFilter = (value: unknown): MonitorType | MonitorType[] | undefined => {
|
||||
const parseSingle = (input: unknown): MonitorType => {
|
||||
if (typeof input !== "string") {
|
||||
throw new AppError({ message: "Monitor type must be a string", status: 400 });
|
||||
}
|
||||
if (!MonitorTypes.includes(input as MonitorType)) {
|
||||
throw new AppError({ message: `Invalid monitor type: ${input}`, status: 400 });
|
||||
}
|
||||
return input as MonitorType;
|
||||
};
|
||||
|
||||
if (value === undefined) {
|
||||
return undefined;
|
||||
}
|
||||
if (Array.isArray(value)) {
|
||||
return value.map((entry) => parseSingle(entry));
|
||||
}
|
||||
return parseSingle(value);
|
||||
};
|
||||
|
||||
export const parseSortOrder = (value: unknown): "asc" | "desc" | undefined => {
|
||||
if (value === undefined) {
|
||||
return undefined;
|
||||
}
|
||||
if (value === "asc" || value === "desc") {
|
||||
return value;
|
||||
}
|
||||
throw new AppError({ message: "order must be either 'asc' or 'desc'", status: 400 });
|
||||
};
|
||||
|
||||
export const requireTeamId = (teamId?: string): string => {
|
||||
if (!teamId) {
|
||||
throw new AppError({ message: "Team ID is required", status: 400 });
|
||||
|
||||
@@ -53,14 +53,10 @@ export const sanitizeBody = (options = {}): ((req: Request, res: Response, next:
|
||||
|
||||
export const sanitizeQuery = (options = {}): ((req: Request, res: Response, next: NextFunction) => void) => {
|
||||
return (req: Request, res: Response, next: NextFunction) => {
|
||||
if (req.query && typeof req.query === "object") {
|
||||
for (const key of Object.keys(req.query)) {
|
||||
const value = req.query[key];
|
||||
if (typeof value === "string") {
|
||||
req.query[key] = sanitizeInput(value, options);
|
||||
}
|
||||
}
|
||||
}
|
||||
const sanitized = Object.fromEntries(
|
||||
Object.entries(req.query).map(([key, value]) => [key, typeof value === "string" ? sanitizeInput(value, options) : value])
|
||||
);
|
||||
Object.defineProperty(req, "query", { value: sanitized, writable: true, configurable: true, enumerable: true });
|
||||
next();
|
||||
};
|
||||
};
|
||||
+1
-1
@@ -122,7 +122,7 @@ export const createApp = ({
|
||||
setupRoutes(app, controllers, services);
|
||||
|
||||
// FE routes
|
||||
app.get("*", (req, res) => {
|
||||
app.get("/{*splat}", (req, res) => {
|
||||
res.sendFile(path.join(frontendPath, "index.html"));
|
||||
});
|
||||
app.use(handleErrors(services.logger));
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
import { Resolver } from "dns/promises";
|
||||
import axios from "axios";
|
||||
import got from "got";
|
||||
import ping from "ping";
|
||||
import Docker from "dockerode";
|
||||
@@ -13,7 +12,7 @@ import mongoose from "mongoose";
|
||||
|
||||
import { EnvConfig } from "@/domain/app-settings/app-settings.service.js";
|
||||
import { SharedServices } from "@/config/services.shared.js";
|
||||
import { INetworkService, NetworkService } from "@/service/networkService.js";
|
||||
import { IProviderRegistry, ProviderRegistry } from "@/service/networkProviders/providerRegistry.js";
|
||||
import { IBufferService, BufferService } from "@/service/bufferService.js";
|
||||
import { IStatusService, StatusService } from "@/service/statusService.js";
|
||||
import { SecretsRotationService } from "@/service/encryption/secretsRotationService.js";
|
||||
@@ -24,25 +23,25 @@ import { NotificationReactor } from "@/worker/reactors/reactor.notification.js";
|
||||
import { IncidentReactor } from "@/worker/reactors/reactor.incident.js";
|
||||
import { ReactorDispatcher } from "@/worker/reactors/reactor.dispatcher.js";
|
||||
import { DBQueueWorker } from "@/worker/worker.db-queue.js";
|
||||
import { ProxyResolver } from "@/service/network/ProxyResolver.js";
|
||||
import { ProxyResolver } from "@/service/networkProviders/ProxyResolver.js";
|
||||
import { IEgressService, EgressService } from "@/domain/egress/egress.service.js";
|
||||
// Network providers
|
||||
import { PingProvider } from "@/service/network/PingProvider.js";
|
||||
import { HttpProvider } from "@/service/network/HttpProvider.js";
|
||||
import { AdvancedMatcher } from "@/service/network/AdvancedMatcher.js";
|
||||
import { PageSpeedProvider } from "@/service/network/PageSpeedProvider.js";
|
||||
import { HardwareProvider } from "@/service/network/HardwareProvider.js";
|
||||
import { DockerProvider } from "@/service/network/DockerProvider.js";
|
||||
import { PortProvider } from "@/service/network/PortProvider.js";
|
||||
import { GameProvider } from "@/service/network/GameProvider.js";
|
||||
import { GrpcProvider } from "@/service/network/GrpcProvider.js";
|
||||
import { WebSocketProvider } from "@/service/network/WebSocketProvider.js";
|
||||
import { DNSProvider } from "@/service/network/DNSProvider.js";
|
||||
import { PingProvider } from "@/service/networkProviders/PingProvider.js";
|
||||
import { HttpProvider } from "@/service/networkProviders/HttpProvider.js";
|
||||
import { AdvancedMatcher } from "@/service/networkProviders/AdvancedMatcher.js";
|
||||
import { PageSpeedProvider } from "@/service/networkProviders/PageSpeedProvider.js";
|
||||
import { HardwareProvider } from "@/service/networkProviders/HardwareProvider.js";
|
||||
import { DockerProvider } from "@/service/networkProviders/DockerProvider.js";
|
||||
import { PortProvider } from "@/service/networkProviders/PortProvider.js";
|
||||
import { GameProvider } from "@/service/networkProviders/GameProvider.js";
|
||||
import { GrpcProvider } from "@/service/networkProviders/GrpcProvider.js";
|
||||
import { WebSocketProvider } from "@/service/networkProviders/WebSocketProvider.js";
|
||||
import { DNSProvider } from "@/service/networkProviders/DNSProvider.js";
|
||||
import { AppError } from "@/utils/AppError.js";
|
||||
import { WorkerPipeline } from "@/worker/worker.pipeline.js";
|
||||
export interface WorkerServices {
|
||||
worker: IQueueWorker;
|
||||
networkService: INetworkService;
|
||||
networkService: IProviderRegistry;
|
||||
bufferService: IBufferService;
|
||||
statusService: IStatusService;
|
||||
egressService: IEgressService;
|
||||
@@ -87,7 +86,7 @@ export const buildWorker = async (shared: SharedServices, envSettings: EnvConfig
|
||||
const webSocketProvider = new WebSocketProvider(WebSocket);
|
||||
const dnsProvider = new DNSProvider(() => new Resolver());
|
||||
|
||||
const networkService = new NetworkService(axios, logger, [
|
||||
const providerRegistry = new ProviderRegistry([
|
||||
pingProvider,
|
||||
httpProvider,
|
||||
pageSpeedProvider,
|
||||
@@ -105,7 +104,7 @@ export const buildWorker = async (shared: SharedServices, envSettings: EnvConfig
|
||||
pipeline.ingestChecks(checks)
|
||||
);
|
||||
const statusService = new StatusService(logger, monitorsRepository, monitorStatsRepository);
|
||||
const egressService = new EgressService(settingsService, egressStateRepository, jobsRepository, networkService, proxyResolver, logger);
|
||||
const egressService = new EgressService(settingsService, egressStateRepository, jobsRepository, providerRegistry, proxyResolver, logger);
|
||||
|
||||
const notificationReactor = new NotificationReactor(notificationsService);
|
||||
const incidentReactor = new IncidentReactor(incidentService);
|
||||
@@ -118,7 +117,7 @@ export const buildWorker = async (shared: SharedServices, envSettings: EnvConfig
|
||||
checksRepository,
|
||||
jobsRepository,
|
||||
checkService,
|
||||
networkService,
|
||||
providerRegistry: providerRegistry,
|
||||
proxyResolver,
|
||||
bufferService,
|
||||
dockerLogsService,
|
||||
@@ -188,5 +187,5 @@ export const buildWorker = async (shared: SharedServices, envSettings: EnvConfig
|
||||
});
|
||||
}
|
||||
|
||||
return { worker, networkService, bufferService, statusService, egressService };
|
||||
return { worker, networkService: providerRegistry, bufferService, statusService, egressService };
|
||||
};
|
||||
@@ -13,7 +13,6 @@ const SERVICE_NAME = "checkService";
|
||||
export interface ICheckService {
|
||||
createChecks(checks: Check[]): Promise<Check[]>;
|
||||
toCheck(statusResponse: MonitorStatusResponse<MonitorPayloadMap[keyof MonitorPayloadMap]>): Check | undefined;
|
||||
toStatusResponse(check: Check): MonitorStatusResponse<MonitorPayloadMap[keyof MonitorPayloadMap]>;
|
||||
getChecksByMonitor(params: {
|
||||
monitorId: string;
|
||||
teamId: string;
|
||||
@@ -135,32 +134,6 @@ export class CheckService implements ICheckService {
|
||||
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 ({
|
||||
monitorId,
|
||||
teamId,
|
||||
|
||||
@@ -45,12 +45,14 @@ const mapMemory = (memory?: CheckMemoryInfo): SnapshotMemoryInfo | undefined =>
|
||||
};
|
||||
|
||||
const mapDisk = (disk?: CheckDiskInfo[]): SnapshotDiskInfo[] | undefined =>
|
||||
disk?.map((entry) => ({
|
||||
device: entry.device,
|
||||
total_bytes: entry.total_bytes,
|
||||
used_bytes: entry.used_bytes,
|
||||
usage_percent: entry.usage_percent,
|
||||
}));
|
||||
disk
|
||||
?.filter((entry) => entry != null)
|
||||
.map((entry) => ({
|
||||
device: entry.device,
|
||||
total_bytes: entry.total_bytes,
|
||||
used_bytes: entry.used_bytes,
|
||||
usage_percent: entry.usage_percent,
|
||||
}));
|
||||
|
||||
const mapHost = (host?: CheckHostInfo): SnapshotHostInfo | undefined =>
|
||||
host && {
|
||||
|
||||
@@ -3,8 +3,8 @@ import type { ISettingsService } from "@/domain/app-settings/app-settings.servic
|
||||
import type { IEgressStateRepository } from "@/domain/egress/egress-state.repository.interface.js";
|
||||
import type { IJobsRepository } from "@/domain/jobs/job.repository.interface.js";
|
||||
import { jobId, type Job, type JobSeed } from "@/domain/jobs/job.type.js";
|
||||
import type { INetworkService } from "@/service/networkService.js";
|
||||
import type { IProxyResolver } from "@/service/network/ProxyResolver.js";
|
||||
import type { IProviderRegistry } from "@/service/networkProviders/providerRegistry.js";
|
||||
import type { IProxyResolver } from "@/service/networkProviders/ProxyResolver.js";
|
||||
import type { ILogger } from "@/utils/logger.js";
|
||||
import {
|
||||
EGRESS_RECOVERY_POLL_SECONDS,
|
||||
@@ -13,7 +13,7 @@ import {
|
||||
type EgressProbeResult,
|
||||
type EgressStatus,
|
||||
} from "@/domain/egress/egress.type.js";
|
||||
import { timeRequest } from "@/service/network/utils.js";
|
||||
import { timeRequest } from "@/service/networkProviders/utils.js";
|
||||
import { parseEgressTarget, type EgressTarget } from "@/utils/egressTarget.js";
|
||||
|
||||
const SERVICE_NAME = "EgressService";
|
||||
@@ -54,7 +54,7 @@ export class EgressService implements IEgressService {
|
||||
private settingsService: ISettingsService,
|
||||
private egressStateRepository: IEgressStateRepository,
|
||||
private jobsRepository: IJobsRepository,
|
||||
private networkService: INetworkService,
|
||||
private providerRegistry: IProviderRegistry,
|
||||
private proxyResolver: IProxyResolver,
|
||||
private logger: ILogger
|
||||
) {}
|
||||
@@ -100,7 +100,7 @@ export class EgressService implements IEgressService {
|
||||
const { response, responseTime, error } = await timeRequest(async () => {
|
||||
const proxyUrl = await this.proxyResolver.resolve(monitor);
|
||||
return Promise.race([
|
||||
this.networkService.requestStatus(monitor, { proxyUrl }),
|
||||
this.providerRegistry.probe(monitor, { proxyUrl }),
|
||||
new Promise<never>((_, reject) => {
|
||||
timer = setTimeout(() => reject(new Error(`Egress probe timed out after ${PROBE_TIMEOUT_MS}ms`)), PROBE_TIMEOUT_MS);
|
||||
}),
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
const SERVICE_NAME = "incidentService";
|
||||
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 { getDateForRange } from "@/utils/dataUtils.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 { ILogger } from "@/utils/logger.js";
|
||||
import { DateRange } from "@/types/query.js";
|
||||
import { describeContainerBreach, findContainerBreaches } from "@/domain/docker/docker-alert.js";
|
||||
|
||||
const SERVICE_NAME = "incidentService";
|
||||
export interface IIncidentService {
|
||||
handleIncident(
|
||||
monitor: Monitor,
|
||||
code: number,
|
||||
decision: MonitorActionDecision,
|
||||
monitorStatusResponse?: MonitorStatusResponse
|
||||
): Promise<Incident | null>;
|
||||
handleIncident(monitor: Monitor, decision: MonitorActionDecision, check: Check): Promise<Incident | null>;
|
||||
resolveIncident(incidentId: string, userId: string, teamId: string, comment?: string, userEmail?: string): Promise<Incident>;
|
||||
getIncidentsByTeam(
|
||||
teamId: string,
|
||||
@@ -59,73 +53,38 @@ export class IncidentService implements IIncidentService {
|
||||
this.notificationMessageBuilder = notificationMessageBuilder;
|
||||
}
|
||||
|
||||
handleIncident = async (
|
||||
monitor: Monitor,
|
||||
code: number,
|
||||
decision: MonitorActionDecision,
|
||||
monitorStatusResponse?: MonitorStatusResponse
|
||||
): Promise<Incident | null> => {
|
||||
if (!decision.shouldCreateIncident && !decision.shouldResolveIncident) {
|
||||
return null;
|
||||
}
|
||||
handleIncident = async (monitor: Monitor, decision: MonitorActionDecision, check: Check): Promise<Incident | null> => {
|
||||
const { transition } = decision;
|
||||
if (transition === null) return null;
|
||||
|
||||
const activeIncident = await this.incidentsRepository.findActiveByMonitorId(monitor.id, monitor.teamId);
|
||||
|
||||
if (decision.shouldCreateIncident) {
|
||||
if (activeIncident) {
|
||||
return activeIncident;
|
||||
} else {
|
||||
let statusCode = code;
|
||||
let message: string | undefined;
|
||||
|
||||
// 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 = {
|
||||
switch (transition) {
|
||||
case "status_down":
|
||||
case "threshold_breach": {
|
||||
if (activeIncident) return activeIncident;
|
||||
// A threshold breach has no HTTP status
|
||||
const isBreach = transition === "threshold_breach";
|
||||
return await this.incidentsRepository.create({
|
||||
monitorId: monitor.id,
|
||||
teamId: monitor.teamId,
|
||||
startTime: Date.now().toString(),
|
||||
status: true,
|
||||
statusCode,
|
||||
message,
|
||||
};
|
||||
return await this.incidentsRepository.create(incident);
|
||||
statusCode: isBreach ? 9999 : check.statusCode,
|
||||
message: isBreach ? this.notificationMessageBuilder.buildThresholdBreachMessage(monitor, check, decision.thresholdBreaches) : undefined,
|
||||
});
|
||||
}
|
||||
case "status_up":
|
||||
case "threshold_resolved": {
|
||||
if (!activeIncident) return null;
|
||||
activeIncident.status = false;
|
||||
activeIncident.endTime = Date.now().toString();
|
||||
activeIncident.resolutionType = "automatic";
|
||||
return await this.incidentsRepository.updateById(activeIncident.id, activeIncident.teamId, activeIncident);
|
||||
}
|
||||
}
|
||||
|
||||
if (!decision.shouldResolveIncident || !activeIncident) {
|
||||
return null;
|
||||
}
|
||||
|
||||
activeIncident.status = false;
|
||||
activeIncident.endTime = Date.now().toString();
|
||||
activeIncident.resolutionType = "automatic";
|
||||
return await this.incidentsRepository.updateById(activeIncident.id, activeIncident.teamId, activeIncident);
|
||||
};
|
||||
|
||||
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) => {
|
||||
try {
|
||||
if (!incidentId) {
|
||||
|
||||
@@ -101,7 +101,12 @@ class MongoMonitorsRepository implements IMonitorsRepository {
|
||||
|
||||
private pageOptions = (config: TeamQueryConfig) => {
|
||||
const { page = 0, rowsPerPage = 0, field = "createdAt", order = "desc" } = config ?? {};
|
||||
return { sort: { [field]: order === "asc" ? 1 : -1 } as const, skip: Math.max(page, 0) * rowsPerPage, limit: rowsPerPage };
|
||||
const direction: 1 | -1 = order === "asc" ? 1 : -1;
|
||||
const sort: Record<string, 1 | -1> = { [field]: direction };
|
||||
if (field !== "createdAt") {
|
||||
sort.createdAt = 1;
|
||||
}
|
||||
return { sort, skip: Math.max(page, 0) * rowsPerPage, limit: rowsPerPage };
|
||||
};
|
||||
|
||||
findByTeamId = async (teamId: string, config: TeamQueryConfig, options?: { includeRecentChecks?: boolean }): Promise<Monitor[]> => {
|
||||
|
||||
@@ -240,3 +240,8 @@ export type GamesMap = Record<string, Game>;
|
||||
export type MonitorScheduleFields = Pick<Monitor, "id" | "type" | "isActive" | "interval" | "geoCheckEnabled" | "geoCheckInterval">;
|
||||
|
||||
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 type { DockerStatusPayload, HardwareStatusPayload, MonitorStatusResponse } from "@/types/network.js";
|
||||
import { HardwareMetricKeys, type HardwareBreaches, type HardwareMetricKey, type Monitor } from "@/domain/monitors/monitor.type.js";
|
||||
import type { MonitorActionDecision } from "@/worker/worker.interface.js";
|
||||
import type {
|
||||
NotificationMessage,
|
||||
@@ -9,15 +8,11 @@ import type {
|
||||
NotificationContent,
|
||||
} from "@/domain/notifications/notification.type.js";
|
||||
import { describeContainerBreach, findContainerBreaches } from "@/domain/docker/docker-alert.js";
|
||||
import type { Check } from "@/domain/checks/check.type.js";
|
||||
|
||||
export interface INotificationMessageBuilder {
|
||||
buildMessage(
|
||||
monitor: Monitor,
|
||||
monitorStatusResponse: MonitorStatusResponse,
|
||||
decision: MonitorActionDecision,
|
||||
clientHost: string
|
||||
): NotificationMessage;
|
||||
extractThresholdBreaches(monitor: Monitor, monitorStatusResponse: MonitorStatusResponse): ThresholdBreach[];
|
||||
buildMessage(monitor: Monitor, check: Check, decision: MonitorActionDecision, clientHost: string): NotificationMessage;
|
||||
buildThresholdBreachMessage(monitor: Monitor, check: Check, thresholdBreaches: HardwareBreaches | undefined): string;
|
||||
}
|
||||
|
||||
const SERVICE_NAME = "NotificationMessageBuilder";
|
||||
@@ -25,15 +20,10 @@ const SERVICE_NAME = "NotificationMessageBuilder";
|
||||
export class NotificationMessageBuilder implements INotificationMessageBuilder {
|
||||
static SERVICE_NAME = SERVICE_NAME;
|
||||
|
||||
buildMessage(
|
||||
monitor: Monitor,
|
||||
monitorStatusResponse: MonitorStatusResponse,
|
||||
decision: MonitorActionDecision,
|
||||
clientHost: string
|
||||
): NotificationMessage {
|
||||
buildMessage(monitor: Monitor, check: Check, decision: MonitorActionDecision, clientHost: string): NotificationMessage {
|
||||
const type = this.determineNotificationType(decision, monitor);
|
||||
const severity = this.determineSeverity(type);
|
||||
const content = this.buildContent(type, monitor, monitorStatusResponse);
|
||||
const content = this.buildContent(type, monitor, check, decision);
|
||||
|
||||
return {
|
||||
type,
|
||||
@@ -49,24 +39,21 @@ export class NotificationMessageBuilder implements INotificationMessageBuilder {
|
||||
clientHost,
|
||||
metadata: {
|
||||
teamId: monitor.teamId,
|
||||
notificationReason: decision.notificationReason || "status_change",
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
private determineNotificationType(decision: MonitorActionDecision, monitor: Monitor): NotificationType {
|
||||
// Down status has highest priority (critical)
|
||||
if (monitor.status === "down") {
|
||||
return "monitor_down";
|
||||
}
|
||||
|
||||
const isDocker = monitor.type === "docker";
|
||||
switch (decision.notificationReason) {
|
||||
switch (decision.transition) {
|
||||
case "status_down":
|
||||
return "monitor_down";
|
||||
case "threshold_breach":
|
||||
return isDocker ? "container_breach" : "threshold_breach";
|
||||
case "threshold_resolved":
|
||||
return isDocker ? "container_resolved" : "threshold_resolved";
|
||||
default:
|
||||
case "status_up":
|
||||
case null:
|
||||
return "monitor_up";
|
||||
}
|
||||
}
|
||||
@@ -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) {
|
||||
case "monitor_down":
|
||||
return this.buildMonitorDownContent(monitor, monitorStatusResponse);
|
||||
return this.buildMonitorDownContent(monitor, check);
|
||||
case "monitor_up":
|
||||
return this.buildMonitorUpContent(monitor);
|
||||
case "threshold_breach":
|
||||
return this.buildThresholdBreachContent(monitor, monitorStatusResponse as MonitorStatusResponse<HardwareStatusPayload>);
|
||||
return this.buildThresholdBreachContent(monitor, check, decision.thresholdBreaches);
|
||||
case "threshold_resolved":
|
||||
return this.buildThresholdResolvedContent(monitor);
|
||||
case "container_breach":
|
||||
return this.buildContainerBreachContent(monitor, monitorStatusResponse as MonitorStatusResponse<DockerStatusPayload>);
|
||||
return this.buildContainerBreachContent(monitor, check);
|
||||
case "container_resolved":
|
||||
return this.buildContainersRecoveredContent(monitor);
|
||||
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 summary = `Monitor "${monitor.name}" is currently down and unreachable.`;
|
||||
const details = [`URL: ${monitor.url}`, `Status: Down`, `Type: ${monitor.type}`];
|
||||
|
||||
// Add response code if available
|
||||
if (monitorStatusResponse.code) {
|
||||
details.push(`Response Code: ${monitorStatusResponse.code}`);
|
||||
if (check.statusCode) {
|
||||
details.push(`Response Code: ${check.statusCode}`);
|
||||
}
|
||||
|
||||
// Add error message if available
|
||||
if (monitorStatusResponse.message) {
|
||||
details.push(`Error: ${monitorStatusResponse.message}`);
|
||||
if (check.message) {
|
||||
details.push(`Error: ${check.message}`);
|
||||
}
|
||||
|
||||
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 summary = `Monitor "${monitor.name}" has exceeded one or more thresholds.`;
|
||||
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 {
|
||||
title,
|
||||
@@ -176,13 +163,11 @@ export class NotificationMessageBuilder implements INotificationMessageBuilder {
|
||||
};
|
||||
}
|
||||
|
||||
private buildContainerBreachContent(monitor: Monitor, monitorStatusResponse: MonitorStatusResponse<DockerStatusPayload>): NotificationContent {
|
||||
const payload = monitorStatusResponse.payload;
|
||||
const containers = payload && typeof payload !== "string" ? payload.containers : [];
|
||||
const breaches = findContainerBreaches(monitor, containers);
|
||||
private buildContainerBreachContent(monitor: Monitor, check: Check): NotificationContent {
|
||||
const lines = this.describeContainerBreaches(monitor, check);
|
||||
const title = `Container Alert: ${monitor.name}`;
|
||||
const summary = `${breaches.length} container(s) on "${monitor.name}" need attention.`;
|
||||
const details = [`URL: ${monitor.url}`, `Type: ${monitor.type}`, ...breaches.map(describeContainerBreach)];
|
||||
const summary = `${lines.length} container(s) on "${monitor.name}" need attention.`;
|
||||
const details = [`URL: ${monitor.url}`, `Type: ${monitor.type}`, ...lines];
|
||||
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[] = [];
|
||||
|
||||
// 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;
|
||||
}
|
||||
|
||||
// Cast to HardwareStatusPayload type
|
||||
const payload = monitorStatusResponse.payload;
|
||||
const hardware = payload.data;
|
||||
|
||||
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`,
|
||||
});
|
||||
}
|
||||
for (const metric of HardwareMetricKeys) {
|
||||
if (!thresholdBreaches[metric]) continue;
|
||||
breaches.push(this.describeBreach(metric, monitor, check));
|
||||
}
|
||||
|
||||
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 { Notification } from "@/domain/notifications/notification.type.js";
|
||||
import type { MonitorStatusResponse } from "@/types/network.js";
|
||||
import type { NotificationMessage } from "@/domain/notifications/notification.type.js";
|
||||
import { IMonitorsRepository } from "@/domain/monitors/monitor.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 type { INotificationMessageBuilder } from "@/domain/notifications/notification.message-builder.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>;
|
||||
|
||||
@@ -19,7 +19,7 @@ export interface INotificationsService {
|
||||
findNotificationsByTeamId: (teamId: string) => Promise<Notification[]>;
|
||||
updateById(id: string, teamId: string, updateData: Partial<Notification>): 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>;
|
||||
testAllNotifications: (notificationIds: string[]) => Promise<boolean>;
|
||||
@@ -63,7 +63,7 @@ export class NotificationsService implements INotificationsService {
|
||||
private send = async (
|
||||
notification: Notification,
|
||||
monitor: Monitor,
|
||||
monitorStatusResponse: MonitorStatusResponse,
|
||||
check: Check,
|
||||
decision: MonitorActionDecision,
|
||||
notificationMessage: NotificationMessage | undefined
|
||||
): Promise<boolean> => {
|
||||
@@ -89,16 +89,16 @@ export class NotificationsService implements INotificationsService {
|
||||
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 notifications = await this.notificationsRepository.findNotificationsByIds(notificationIds);
|
||||
|
||||
// Build notification message once for all notifications
|
||||
const settings = this.settingsService.getSettings();
|
||||
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 succeeded = outcomes.filter(Boolean).length;
|
||||
@@ -114,13 +114,11 @@ export class NotificationsService implements INotificationsService {
|
||||
return succeeded === notifications.length;
|
||||
};
|
||||
|
||||
handleNotifications = async (monitor: Monitor, monitorStatusResponse: MonitorStatusResponse, decision: MonitorActionDecision) => {
|
||||
if (!decision.shouldSendNotification) {
|
||||
return false;
|
||||
}
|
||||
handleNotifications = async (monitor: Monitor, check: Check, decision: MonitorActionDecision) => {
|
||||
if (decision.transition === null) return false;
|
||||
|
||||
// Send notifications based on decision
|
||||
return await this.sendNotifications(monitor, monitorStatusResponse, decision);
|
||||
return await this.sendNotifications(monitor, check, decision);
|
||||
};
|
||||
|
||||
sendTestNotification = async (notification: Partial<Notification>) => {
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
import type { HardwareMetricKey } from "@/domain/monitors/monitor.type.js";
|
||||
|
||||
export const NotificationChannels = [
|
||||
"email",
|
||||
"slack",
|
||||
@@ -85,7 +87,7 @@ export interface MonitorInfo {
|
||||
}
|
||||
|
||||
export interface ThresholdBreach {
|
||||
metric: "cpu" | "memory" | "disk" | "temp";
|
||||
metric: HardwareMetricKey;
|
||||
currentValue: number;
|
||||
threshold: number;
|
||||
unit: string;
|
||||
@@ -117,6 +119,5 @@ export interface NotificationMessage {
|
||||
clientHost: string;
|
||||
metadata: {
|
||||
teamId: string;
|
||||
notificationReason: string;
|
||||
};
|
||||
}
|
||||
@@ -55,42 +55,15 @@ export class BufferService implements IBufferService {
|
||||
}
|
||||
|
||||
addToBuffer(check: Check) {
|
||||
try {
|
||||
this.buffer.push(check);
|
||||
} catch (error: unknown) {
|
||||
this.logger.error({
|
||||
message: error instanceof Error ? error.message : "Unknown error",
|
||||
service: this.SERVICE_NAME,
|
||||
method: "addToBuffer",
|
||||
stack: error instanceof Error ? error.stack : undefined,
|
||||
});
|
||||
}
|
||||
this.buffer.push(check);
|
||||
}
|
||||
|
||||
addGeoCheckToBuffer(geoCheck: GeoCheck) {
|
||||
try {
|
||||
this.geoBuffer.push(geoCheck);
|
||||
} catch (error: unknown) {
|
||||
this.logger.error({
|
||||
message: error instanceof Error ? error.message : "Unknown error",
|
||||
service: this.SERVICE_NAME,
|
||||
method: "addGeoCheckToBuffer",
|
||||
stack: error instanceof Error ? error.stack : undefined,
|
||||
});
|
||||
}
|
||||
this.geoBuffer.push(geoCheck);
|
||||
}
|
||||
|
||||
addDockerLogToBuffer(dockerLog: DockerLog): void {
|
||||
try {
|
||||
this.dockerLogBuffer.push(dockerLog);
|
||||
} catch (error: unknown) {
|
||||
this.logger.error({
|
||||
message: error instanceof Error ? error.message : "Unknown error",
|
||||
service: this.SERVICE_NAME,
|
||||
method: "addDockerLogToBuffer",
|
||||
stack: error instanceof Error ? error.stack : undefined,
|
||||
});
|
||||
}
|
||||
this.dockerLogBuffer.push(dockerLog);
|
||||
}
|
||||
|
||||
scheduleNextFlush() {
|
||||
|
||||
@@ -3,7 +3,7 @@ import { supportsGeoCheck, type HttpStatusCode } from "@/domain/monitors/monitor
|
||||
import { MonitorType } from "@/domain/monitors/monitor.type.js";
|
||||
import type { ILogger } from "@/utils/logger.js";
|
||||
import got from "got";
|
||||
import { isStatusUp } from "@/service/network/utils.js";
|
||||
import { isStatusUp } from "@/service/networkProviders/utils.js";
|
||||
|
||||
const SERVICE_NAME = "GlobalPingService";
|
||||
const GLOBAL_PING_API_BASE = "https://api.globalping.io/v1";
|
||||
|
||||
File renamed without changes.
+2
-2
@@ -1,10 +1,10 @@
|
||||
import { DNSStatusPayload, MonitorStatusResponse } from "@/types/network.js";
|
||||
import { IStatusProvider } from "@/service/network/IStatusProvider.js";
|
||||
import { IStatusProvider } from "@/service/networkProviders/IStatusProvider.js";
|
||||
import dns from "dns";
|
||||
import type { Resolver } from "dns/promises";
|
||||
import { Monitor, MonitorType } from "@/domain/monitors/monitor.type.js";
|
||||
import { AppError } from "@/utils/AppError.js";
|
||||
import { timeRequest } from "@/service/network/utils.js";
|
||||
import { timeRequest } from "@/service/networkProviders/utils.js";
|
||||
import { NETWORK_ERROR } from "@/types/network.js";
|
||||
|
||||
const SERVICE_NAME = "DNSProvider";
|
||||
+3
-3
@@ -1,7 +1,7 @@
|
||||
import https from "node:https";
|
||||
|
||||
import { IStatusProvider } from "@/service/network/IStatusProvider.js";
|
||||
import { HttpProvider } from "@/service/network/HttpProvider.js";
|
||||
import { IStatusProvider } from "@/service/networkProviders/IStatusProvider.js";
|
||||
import { HttpProvider } from "@/service/networkProviders/HttpProvider.js";
|
||||
import { CaptureDockerContainer, CaptureDockerStatusPayload, CheckContext, DockerStatusPayload, MonitorStatusResponse } from "@/types/network.js";
|
||||
|
||||
import {
|
||||
@@ -24,7 +24,7 @@ import { Monitor, MonitorType } from "@/domain/monitors/monitor.type.js";
|
||||
import { ILogger } from "@/utils/logger.js";
|
||||
import { AppError } from "@/utils/AppError.js";
|
||||
import Dockerode from "dockerode";
|
||||
import { timeRequest, peerAnsweredFromError } from "@/service/network/utils.js";
|
||||
import { timeRequest, peerAnsweredFromError } from "@/service/networkProviders/utils.js";
|
||||
import { NETWORK_ERROR } from "@/types/network.js";
|
||||
import { IEncryptionService } from "@/service/encryption/encryptionService.js";
|
||||
import { DOCKER_TLS_URL, isCaptureDockerUrl, isDockerTlsUrl } from "@/utils/dockerHost.js";
|
||||
+1
-1
@@ -1,4 +1,4 @@
|
||||
import { IStatusProvider } from "@/service/network/IStatusProvider.js";
|
||||
import { IStatusProvider } from "@/service/networkProviders/IStatusProvider.js";
|
||||
import { GameStatusPayload, MonitorStatusResponse } from "@/types/network.js";
|
||||
import { Monitor, MonitorType } from "@/domain/monitors/monitor.type.js";
|
||||
import { GameDig } from "gamedig";
|
||||
+2
-2
@@ -1,8 +1,8 @@
|
||||
import { IStatusProvider } from "@/service/network/IStatusProvider.js";
|
||||
import { IStatusProvider } from "@/service/networkProviders/IStatusProvider.js";
|
||||
import { GrpcStatusPayload, MonitorStatusResponse } from "@/types/network.js";
|
||||
import { Monitor, MonitorType } from "@/domain/monitors/monitor.type.js";
|
||||
import { AppError } from "@/utils/AppError.js";
|
||||
import { timeRequest } from "@/service/network/utils.js";
|
||||
import { timeRequest } from "@/service/networkProviders/utils.js";
|
||||
import * as grpc from "@grpc/grpc-js";
|
||||
import * as protoLoader from "@grpc/proto-loader";
|
||||
|
||||
+2
-2
@@ -1,7 +1,7 @@
|
||||
import { IStatusProvider } from "@/service/network/IStatusProvider.js";
|
||||
import { IStatusProvider } from "@/service/networkProviders/IStatusProvider.js";
|
||||
import { HardwareStatusPayload, MonitorStatusResponse } from "@/types/network.js";
|
||||
import { Monitor, MonitorType } from "@/domain/monitors/monitor.type.js";
|
||||
import { HttpProvider } from "@/service/network/HttpProvider.js";
|
||||
import { HttpProvider } from "@/service/networkProviders/HttpProvider.js";
|
||||
import { AppError } from "@/utils/AppError.js";
|
||||
|
||||
export class HardwareProvider implements IStatusProvider<HardwareStatusPayload> {
|
||||
+3
-3
@@ -1,12 +1,12 @@
|
||||
import { type Got, HTTPError, RequestError } from "got";
|
||||
import { IAdvancedMatcher } from "@/service/network/AdvancedMatcher.js";
|
||||
import { IStatusProvider } from "@/service/network/IStatusProvider.js";
|
||||
import { IAdvancedMatcher } from "@/service/networkProviders/AdvancedMatcher.js";
|
||||
import { IStatusProvider } from "@/service/networkProviders/IStatusProvider.js";
|
||||
import { HttpStatusPayload } from "@/types/network.js";
|
||||
import { MonitorStatusResponse } from "@/types/network.js";
|
||||
import { Agent as HttpsAgent } from "https";
|
||||
import { Agent as HttpAgent } from "http";
|
||||
import { Monitor, MonitorType } from "@/domain/monitors/monitor.type.js";
|
||||
import { isStatusUp, peerAnsweredFromError } from "@/service/network/utils.js";
|
||||
import { isStatusUp, peerAnsweredFromError } from "@/service/networkProviders/utils.js";
|
||||
import { NETWORK_ERROR } from "@/types/network.js";
|
||||
import CacheableLookup from "cacheable-lookup";
|
||||
import { HttpProxyAgent, HttpsProxyAgent } from "hpagent";
|
||||
File renamed without changes.
+2
-2
@@ -1,7 +1,7 @@
|
||||
import { IStatusProvider } from "@/service/network/IStatusProvider.js";
|
||||
import { IStatusProvider } from "@/service/networkProviders/IStatusProvider.js";
|
||||
import { MonitorStatusResponse, PageSpeedStatusPayload } from "@/types/network.js";
|
||||
import { Monitor, MonitorType, DefaultPageSpeedStrategy } from "@/domain/monitors/monitor.type.js";
|
||||
import { HttpProvider } from "@/service/network/HttpProvider.js";
|
||||
import { HttpProvider } from "@/service/networkProviders/HttpProvider.js";
|
||||
import { ISettingsService } from "@/domain/app-settings/app-settings.service.js";
|
||||
import { ILogger } from "@/utils/logger.js";
|
||||
import { AppError } from "@/utils/AppError.js";
|
||||
+1
-1
@@ -5,7 +5,7 @@ import { MonitorStatusResponse } from "@/types/network.js";
|
||||
import { AppError } from "@/utils/AppError.js";
|
||||
import ping from "ping";
|
||||
import * as net from "net";
|
||||
import { timeRequest } from "@/service/network/utils.js";
|
||||
import { timeRequest } from "@/service/networkProviders/utils.js";
|
||||
const SERVICE_NAME = "PingProvider";
|
||||
|
||||
type Ping = typeof ping;
|
||||
+2
-2
@@ -1,8 +1,8 @@
|
||||
import { IStatusProvider } from "@/service/network/IStatusProvider.js";
|
||||
import { IStatusProvider } from "@/service/networkProviders/IStatusProvider.js";
|
||||
import { PortStatusPayload, MonitorStatusResponse } from "@/types/network.js";
|
||||
import { Monitor, MonitorType } from "@/domain/monitors/monitor.type.js";
|
||||
import { AppError } from "@/utils/AppError.js";
|
||||
import { timeRequest, peerAnsweredFromError } from "@/service/network/utils.js";
|
||||
import { timeRequest, peerAnsweredFromError } from "@/service/networkProviders/utils.js";
|
||||
import { NETWORK_ERROR } from "@/types/network.js";
|
||||
import * as net from "net";
|
||||
type NetType = typeof net;
|
||||
File renamed without changes.
+2
-2
@@ -1,8 +1,8 @@
|
||||
import { IStatusProvider } from "@/service/network/IStatusProvider.js";
|
||||
import { IStatusProvider } from "@/service/networkProviders/IStatusProvider.js";
|
||||
import { WebSocketStatusPayload, MonitorStatusResponse } from "@/types/network.js";
|
||||
import { Monitor, MonitorType } from "@/domain/monitors/monitor.type.js";
|
||||
import { AppError } from "@/utils/AppError.js";
|
||||
import { timeRequest, peerAnsweredFromError } from "@/service/network/utils.js";
|
||||
import { timeRequest, peerAnsweredFromError } from "@/service/networkProviders/utils.js";
|
||||
import { NETWORK_ERROR } from "@/types/network.js";
|
||||
import type WebSocket from "ws";
|
||||
|
||||
@@ -0,0 +1,34 @@
|
||||
import type { Monitor, MonitorType } from "@/domain/monitors/monitor.type.js";
|
||||
import type { CheckContext, MonitorPayloadMap, MonitorStatusResponse } from "@/types/network.js";
|
||||
import { NETWORK_ERROR } from "@/types/network.js";
|
||||
import { IStatusProvider } from "./IStatusProvider.js";
|
||||
const SERVICE_NAME = "ProviderRegistry";
|
||||
|
||||
export interface IProviderRegistry {
|
||||
probe<T extends MonitorType>(monitor: Monitor & { type: T }, ctx?: CheckContext): Promise<MonitorStatusResponse<MonitorPayloadMap[T]>>;
|
||||
}
|
||||
|
||||
export class ProviderRegistry implements IProviderRegistry {
|
||||
static SERVICE_NAME = SERVICE_NAME;
|
||||
|
||||
constructor(private providers: IStatusProvider<unknown>[]) {}
|
||||
|
||||
async probe<T extends MonitorType>(monitor: Monitor & { type: T }, ctx?: CheckContext): Promise<MonitorStatusResponse<MonitorPayloadMap[T]>> {
|
||||
const provider = this.providers.find((p) => p.supports(monitor.type));
|
||||
if (!provider) {
|
||||
return this.handleUnsupportedType(monitor.type) as Promise<MonitorStatusResponse<MonitorPayloadMap[T]>>;
|
||||
}
|
||||
return provider.handle(monitor, ctx) as Promise<MonitorStatusResponse<MonitorPayloadMap[T]>>;
|
||||
}
|
||||
|
||||
private async handleUnsupportedType(type: string): Promise<MonitorStatusResponse> {
|
||||
return {
|
||||
monitorId: "unknown",
|
||||
teamId: "unknown",
|
||||
type: "unknown",
|
||||
status: false,
|
||||
code: NETWORK_ERROR,
|
||||
message: `Unsupported type: ${type}`,
|
||||
};
|
||||
}
|
||||
}
|
||||
File renamed without changes.
@@ -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",
|
||||
};
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,19 +1,15 @@
|
||||
import { IMonitorStatsRepository } from "@/domain/monitor-stats/monitor-stats.repository.interface.js";
|
||||
import { IMonitorsRepository } from "@/domain/monitors/monitor.repository.interface.js";
|
||||
import type { Check, CheckDiskInfo } from "@/domain/checks/check.type.js";
|
||||
import { MonitorStatuses, type Monitor, type MonitorStatus } from "@/domain/monitors/monitor.type.js";
|
||||
import type {
|
||||
DockerStatusPayload,
|
||||
GameStatusPayload,
|
||||
GrpcStatusPayload,
|
||||
HardwareStatusPayload,
|
||||
HttpStatusPayload,
|
||||
MonitorStatusResponse,
|
||||
PageSpeedStatusPayload,
|
||||
PingStatusPayload,
|
||||
PortStatusPayload,
|
||||
StatusChangeResult,
|
||||
} from "@/types/network.js";
|
||||
import {
|
||||
HardwareMetricKeys,
|
||||
MonitorStatuses,
|
||||
type HardwareBreaches,
|
||||
type HardwareCounters,
|
||||
type Monitor,
|
||||
type MonitorStatus,
|
||||
} from "@/domain/monitors/monitor.type.js";
|
||||
import type { StatusChangeResult } from "@/types/network.js";
|
||||
import { AppError } from "@/utils/AppError.js";
|
||||
import { ILogger } from "@/utils/logger.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 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 {
|
||||
updateRunningStats(monitor: Monitor, networkResponse: MonitorStatusResponse): Promise<boolean>;
|
||||
updateMonitorStatus(
|
||||
statusResponse: MonitorStatusResponse<
|
||||
| PingStatusPayload
|
||||
| HttpStatusPayload
|
||||
| PageSpeedStatusPayload
|
||||
| HardwareStatusPayload
|
||||
| DockerStatusPayload
|
||||
| PortStatusPayload
|
||||
| GameStatusPayload
|
||||
| GrpcStatusPayload
|
||||
| undefined
|
||||
>,
|
||||
check: Check,
|
||||
monitor: Monitor
|
||||
): Promise<StatusChangeResult>;
|
||||
updateMonitorStatus(check: Check, monitor: Monitor): Promise<StatusChangeResult>;
|
||||
}
|
||||
|
||||
export class StatusService implements IStatusService {
|
||||
@@ -59,32 +36,19 @@ export class StatusService implements IStatusService {
|
||||
this.monitorStatsRepository = monitorStatsRepository;
|
||||
}
|
||||
|
||||
async updateRunningStats(monitor: Monitor, networkResponse: MonitorStatusResponse) {
|
||||
private tryUpdateRunningStats = async (check: Check, monitor: Monitor) => {
|
||||
try {
|
||||
await this.monitorStatsRepository.updateByMonitorId(monitor.id, {
|
||||
status: networkResponse.status === true,
|
||||
responseTime: networkResponse.responseTime ?? 0,
|
||||
status: check.status === true,
|
||||
responseTime: check.responseTime ?? 0,
|
||||
now: Date.now(),
|
||||
});
|
||||
return true;
|
||||
} 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({
|
||||
service: SERVICE_NAME,
|
||||
method: "updateMonitorStatus",
|
||||
message: `Stats update failed for monitor ${monitor.id}`,
|
||||
message: error instanceof Error ? error.message : "Unknown error",
|
||||
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.
|
||||
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;
|
||||
}
|
||||
|
||||
@@ -148,8 +112,8 @@ export class StatusService implements IStatusService {
|
||||
if (!reachabilityDown) {
|
||||
// A counter can only reach zero via the decrement path, which only runs when that
|
||||
// metric is currently breaching — so anyCounterZero already implies anyBreached.
|
||||
const anyCounterZero = HARDWARE_METRIC_KEYS.some((k) => nextCounters[k] === 0);
|
||||
const allNormal = HARDWARE_METRIC_KEYS.every((k) => !breaches[k]);
|
||||
const anyCounterZero = HardwareMetricKeys.some((k) => nextCounters[k] === 0);
|
||||
const allNormal = HardwareMetricKeys.every((k) => !breaches[k]);
|
||||
|
||||
if (anyCounterZero && currentStatus !== "breached") {
|
||||
nextStatus = "breached";
|
||||
@@ -180,26 +144,12 @@ export class StatusService implements IStatusService {
|
||||
return { nextStatus, transitioned };
|
||||
};
|
||||
|
||||
updateMonitorStatus = async (
|
||||
statusResponse: MonitorStatusResponse<
|
||||
| PingStatusPayload
|
||||
| HttpStatusPayload
|
||||
| PageSpeedStatusPayload
|
||||
| HardwareStatusPayload
|
||||
| DockerStatusPayload
|
||||
| PortStatusPayload
|
||||
| GameStatusPayload
|
||||
| GrpcStatusPayload
|
||||
| undefined
|
||||
>,
|
||||
check: Check,
|
||||
monitor: Monitor
|
||||
): Promise<StatusChangeResult> => {
|
||||
updateMonitorStatus = async (check: Check, monitor: Monitor): Promise<StatusChangeResult> => {
|
||||
try {
|
||||
const { status, code } = statusResponse;
|
||||
const { status } = check;
|
||||
|
||||
// Update running stats
|
||||
await this.tryUpdateRunningStats(monitor, statusResponse);
|
||||
await this.tryUpdateRunningStats(check, monitor);
|
||||
|
||||
const prevStatus = monitor.status;
|
||||
const checkSnapshot = toCheckSnapshot(check);
|
||||
@@ -224,83 +174,62 @@ export class StatusService implements IStatusService {
|
||||
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;
|
||||
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;
|
||||
patch.memoryAlertCounter = hardware.nextCounters.memory;
|
||||
patch.diskAlertCounter = hardware.nextCounters.disk;
|
||||
patch.tempAlertCounter = hardware.nextCounters.temp;
|
||||
thresholdBreaches = hardware.breaches;
|
||||
if (hardware.transitioned) {
|
||||
newStatus = hardware.nextStatus;
|
||||
if (projectedWindow.length >= monitor.statusWindowSize) {
|
||||
// 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;
|
||||
}
|
||||
}
|
||||
|
||||
const dockerPayload = statusResponse.payload as DockerStatusPayload | undefined;
|
||||
if (monitor.type === "docker" && dockerPayload?.containers) {
|
||||
const breaches = findContainerBreaches(monitor, dockerPayload.containers);
|
||||
const docker = this.computeDockerStatus({
|
||||
currentStatus: newStatus,
|
||||
reachabilityDown: newStatus === "down",
|
||||
breaching: breaches.length > 0,
|
||||
});
|
||||
if (docker.transitioned) {
|
||||
newStatus = docker.nextStatus;
|
||||
statusChanged = true;
|
||||
// Evaluate hardware threshold breaches
|
||||
if (monitor.type === "hardware") {
|
||||
const hardware = this.computeHardwareStatus({
|
||||
currentStatus: newStatus,
|
||||
reachabilityDown: newStatus === "down",
|
||||
metrics: check,
|
||||
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;
|
||||
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
|
||||
const updated = await this.monitorsRepository.updateStatusWindowAndChecks(
|
||||
@@ -317,8 +246,6 @@ export class StatusService implements IStatusService {
|
||||
monitor: updated,
|
||||
statusChanged,
|
||||
prevStatus,
|
||||
code,
|
||||
timestamp: new Date().getTime(),
|
||||
thresholdBreaches,
|
||||
};
|
||||
} catch (error: unknown) {
|
||||
|
||||
@@ -10,7 +10,7 @@ import type {
|
||||
ILighthouseAudit,
|
||||
} from "@/domain/checks/check.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";
|
||||
|
||||
@@ -182,14 +182,7 @@ export type StatusChangeResult = {
|
||||
monitor: Monitor;
|
||||
statusChanged: boolean;
|
||||
prevStatus: MonitorStatus;
|
||||
code: number;
|
||||
timestamp: number;
|
||||
thresholdBreaches?: {
|
||||
cpu: boolean;
|
||||
memory: boolean;
|
||||
disk: boolean;
|
||||
temp: boolean;
|
||||
};
|
||||
thresholdBreaches?: HardwareBreaches;
|
||||
};
|
||||
|
||||
export type MonitorStatusResponseOverrides<T> = Partial<Omit<MonitorStatusResponse<T>, "monitorId" | "teamId" | "type">>;
|
||||
|
||||
@@ -8,6 +8,6 @@ export class IncidentReactor implements IMonitorReactor {
|
||||
constructor(private incidentService: IIncidentService) {}
|
||||
|
||||
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) {}
|
||||
|
||||
react = async (evaluation: MonitorEvaluation) => {
|
||||
if (!evaluation.decision.shouldSendNotification) return;
|
||||
await this.notificationService.handleNotifications(evaluation.monitor, evaluation.status, evaluation.decision);
|
||||
await this.notificationService.handleNotifications(evaluation.monitor, evaluation.check, evaluation.decision);
|
||||
};
|
||||
}
|
||||
@@ -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 { 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 type { QueueMode } from "@/domain/app-settings/app-settings.type.js";
|
||||
|
||||
export type JobHandler = (job: Job) => Promise<void>;
|
||||
export type JobHandlers = Record<JobType, JobHandler>;
|
||||
|
||||
export const MonitorTransitions = ["status_down", "status_up", "threshold_breach", "threshold_resolved"] as const;
|
||||
export type MonitorTransition = (typeof MonitorTransitions)[number];
|
||||
|
||||
export interface MonitorActionDecision {
|
||||
shouldCreateIncident: boolean;
|
||||
shouldResolveIncident: boolean;
|
||||
shouldSendNotification: boolean;
|
||||
incidentReason: "status_down" | "threshold_breach" | null;
|
||||
notificationReason: "status_change" | "threshold_breach" | "threshold_resolved" | null;
|
||||
thresholdBreaches?: {
|
||||
cpu?: boolean;
|
||||
memory?: boolean;
|
||||
disk?: boolean;
|
||||
temp?: boolean;
|
||||
};
|
||||
transition: MonitorTransition | null;
|
||||
thresholdBreaches?: HardwareBreaches;
|
||||
}
|
||||
|
||||
export type MonitorEvaluation = {
|
||||
monitor: Monitor;
|
||||
status: MonitorStatusResponse<MonitorPayloadMap[keyof MonitorPayloadMap]>; // raw result from networkService.requestStatus
|
||||
check: Check;
|
||||
statusChange: StatusChangeResult; // from statusService.updateMonitorStatus
|
||||
decision: MonitorActionDecision;
|
||||
};
|
||||
|
||||
|
||||
@@ -3,15 +3,14 @@ import { ICheckService } from "@/domain/checks/check.service.js";
|
||||
import { Check } from "@/domain/checks/check.type.js";
|
||||
import { IDockerLogsService } from "@/domain/docker/docker-log.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 { PendingCheck } from "@/domain/jobs/job.type.js";
|
||||
import { IMaintenanceWindowsRepository } from "@/domain/maintenance-windows/maintenance-window.repository.interface.js";
|
||||
import { IMonitorsRepository } from "@/domain/monitors/monitor.repository.interface.js";
|
||||
import { isEgressAttributable, Monitor } from "@/domain/monitors/monitor.type.js";
|
||||
import { IBufferService } from "@/service/bufferService.js";
|
||||
import { IProxyResolver } from "@/service/network/ProxyResolver.js";
|
||||
import { INetworkService } from "@/service/networkService.js";
|
||||
import { IProxyResolver } from "@/service/networkProviders/ProxyResolver.js";
|
||||
import { IProviderRegistry } from "@/service/networkProviders/providerRegistry.js";
|
||||
import { IStatusService } from "@/service/statusService.js";
|
||||
import { DockerStatusPayload, MonitorStatusResponse, StatusChangeResult } from "@/types/network.js";
|
||||
import { AppError } from "@/utils/AppError.js";
|
||||
@@ -37,7 +36,7 @@ export interface WorkerPipelineDependencies {
|
||||
checksRepository: IChecksRepository;
|
||||
jobsRepository: IJobsRepository;
|
||||
checkService: ICheckService;
|
||||
networkService: INetworkService;
|
||||
providerRegistry: IProviderRegistry;
|
||||
proxyResolver: IProxyResolver;
|
||||
bufferService: IBufferService;
|
||||
dockerLogsService: IDockerLogsService;
|
||||
@@ -104,7 +103,7 @@ export class WorkerPipeline implements IWorkerPipeline {
|
||||
const proxyUrl = await this.deps.proxyResolver.resolve(monitor);
|
||||
const dockerTlsKey = await this.resolveDockerTlsKey(monitor);
|
||||
|
||||
const status = await this.deps.networkService.requestStatus(monitor, { proxyUrl, dockerTlsKey });
|
||||
const status = await this.deps.providerRegistry.probe(monitor, { proxyUrl, dockerTlsKey });
|
||||
if (!status) {
|
||||
throw new Error("No network response");
|
||||
}
|
||||
@@ -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
|
||||
// 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) {
|
||||
check.egressStatus = egressStatus;
|
||||
}
|
||||
@@ -171,20 +170,6 @@ export class WorkerPipeline implements IWorkerPipeline {
|
||||
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
|
||||
// Called by the buffer, checks are inserted and evaluation jobs created
|
||||
@@ -240,8 +225,7 @@ export class WorkerPipeline implements IWorkerPipeline {
|
||||
|
||||
let current = monitor;
|
||||
for (const check of checks) {
|
||||
const status = this.deps.checkService.toStatusResponse(check);
|
||||
const evaluation = await this.evaluateCheck(status, check, current);
|
||||
const evaluation = await this.evaluateCheck(check, current);
|
||||
await this.deps.dispatcher.dispatch(evaluation); // Handle incidents and notifications
|
||||
const leaseHeld = await this.deps.jobsRepository.pullEvaluated(job.id, [check.id]);
|
||||
if (!leaseHeld) {
|
||||
@@ -253,7 +237,7 @@ export class WorkerPipeline implements IWorkerPipeline {
|
||||
});
|
||||
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
|
||||
@@ -272,69 +256,47 @@ export class WorkerPipeline implements IWorkerPipeline {
|
||||
// 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
|
||||
// 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({
|
||||
message: `Skipping evaluation of check ${check.id} for monitor ${monitor.id}: instance egress was degraded`,
|
||||
service: SERVICE_NAME,
|
||||
method: "evaluateCheck",
|
||||
});
|
||||
const decision: MonitorActionDecision = {
|
||||
shouldCreateIncident: false,
|
||||
shouldResolveIncident: false,
|
||||
shouldSendNotification: false,
|
||||
incidentReason: null,
|
||||
notificationReason: null,
|
||||
};
|
||||
|
||||
return {
|
||||
monitor,
|
||||
status,
|
||||
check,
|
||||
statusChange: { monitor, statusChanged: false, prevStatus: monitor.status, code: status.code, timestamp: Date.now() },
|
||||
decision,
|
||||
decision: { transition: null },
|
||||
};
|
||||
};
|
||||
|
||||
evaluateCheck = async (status: MonitorStatusResponse, check: Check, monitor: Monitor): Promise<MonitorEvaluation> => {
|
||||
evaluateCheck = async (check: Check, monitor: Monitor): Promise<MonitorEvaluation> => {
|
||||
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);
|
||||
return { monitor: statusChange.monitor, status, check, statusChange, decision };
|
||||
return { monitor: statusChange.monitor, check, decision };
|
||||
};
|
||||
|
||||
private decide = (statusChange: StatusChangeResult): MonitorActionDecision => {
|
||||
const { monitor, statusChanged, prevStatus } = statusChange;
|
||||
const decision: MonitorActionDecision = {
|
||||
shouldCreateIncident: false,
|
||||
shouldResolveIncident: false,
|
||||
shouldSendNotification: false,
|
||||
incidentReason: null,
|
||||
notificationReason: null,
|
||||
};
|
||||
if (!statusChanged) return decision;
|
||||
const { monitor, statusChanged, prevStatus, thresholdBreaches } = statusChange;
|
||||
if (!statusChanged) return { transition: null };
|
||||
|
||||
if (monitor.status === "down") {
|
||||
decision.shouldCreateIncident = true;
|
||||
decision.shouldSendNotification = true;
|
||||
decision.incidentReason = "status_down";
|
||||
decision.notificationReason = "status_change";
|
||||
} else if (monitor.status === "breached") {
|
||||
decision.shouldCreateIncident = true;
|
||||
decision.shouldSendNotification = true;
|
||||
decision.incidentReason = "threshold_breach";
|
||||
decision.notificationReason = "threshold_breach";
|
||||
} else if (monitor.status === "up" && prevStatus === "breached") {
|
||||
decision.shouldResolveIncident = true;
|
||||
decision.shouldSendNotification = true;
|
||||
decision.notificationReason = "threshold_resolved";
|
||||
} else if (monitor.status === "up" && prevStatus === "down") {
|
||||
decision.shouldResolveIncident = true;
|
||||
decision.shouldSendNotification = true;
|
||||
decision.notificationReason = "status_change";
|
||||
if (monitor.status === "down") return { transition: "status_down" };
|
||||
|
||||
if (monitor.status === "breached") {
|
||||
return thresholdBreaches
|
||||
? // Hardware
|
||||
{ transition: "threshold_breach", thresholdBreaches }
|
||||
: // Docker
|
||||
{ transition: "threshold_breach" };
|
||||
}
|
||||
return decision;
|
||||
|
||||
if (monitor.status === "up" && prevStatus === "breached") return { transition: "threshold_resolved" };
|
||||
if (monitor.status === "up" && prevStatus === "down") return { transition: "status_up" };
|
||||
return { transition: null };
|
||||
};
|
||||
}
|
||||
@@ -5,17 +5,17 @@ import { IncidentReactor } from "../../src/worker/reactors/reactor.incident.ts";
|
||||
import { ReactorDispatcher } from "../../src/worker/reactors/reactor.dispatcher.ts";
|
||||
import { StatusService } from "../../src/service/statusService.ts";
|
||||
import { IncidentService } from "../../src/domain/incidents/incident.service.ts";
|
||||
import { CheckService } from "../../src/domain/checks/check.service.ts";
|
||||
import { InMemoryMonitorsRepository } from "./InMemoryMonitorsRepository.ts";
|
||||
import { InMemoryIncidentsRepository } from "./InMemoryIncidentsRepository.ts";
|
||||
import { createMockLogger } from "./createMockLogger.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 { 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 { EgressStatus } from "../../src/domain/egress/egress.type.ts";
|
||||
|
||||
let checkCounter = 0;
|
||||
|
||||
export const makeMonitor = (overrides?: Partial<Monitor>): Monitor =>
|
||||
({
|
||||
id: "mon-1",
|
||||
@@ -45,20 +45,6 @@ export const makeStatusResponse = (status: boolean, code: number): MonitorStatus
|
||||
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 = () => ({
|
||||
findByMonitorId: jest.fn().mockRejectedValue(new Error("no stats")),
|
||||
create: jest.fn().mockResolvedValue({}),
|
||||
@@ -74,10 +60,10 @@ export interface HeartbeatTestHarness {
|
||||
statusService: StatusService;
|
||||
incidentService: IncidentService;
|
||||
notificationsService: { handleNotifications: jest.Mock };
|
||||
networkService: { requestStatus: jest.Mock };
|
||||
providerRegistry: { probe: jest.Mock };
|
||||
bufferStub: { addToBuffer: jest.Mock; addGeoCheckToBuffer: jest.Mock; scheduleNextFlush: jest.Mock };
|
||||
maintenanceWindowsRepo: { findByMonitorId: jest.Mock };
|
||||
messageBuilder: { extractThresholdBreaches: jest.Mock };
|
||||
messageBuilder: { buildThresholdBreachMessage: jest.Mock };
|
||||
egressService: { assessAfterFailure: jest.Mock };
|
||||
heartbeatJob: (monitor: Monitor) => Promise<void>;
|
||||
setNextResponse: (status: boolean, code: number) => void;
|
||||
@@ -86,8 +72,6 @@ export interface HeartbeatTestHarness {
|
||||
}
|
||||
|
||||
export function createHeartbeatTestHarness(): HeartbeatTestHarness {
|
||||
checkCounter = 0;
|
||||
|
||||
const monitorsRepo = new InMemoryMonitorsRepository();
|
||||
const incidentsRepo = new InMemoryIncidentsRepository();
|
||||
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 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 notificationsService = { handleNotifications: jest.fn().mockResolvedValue(true) };
|
||||
@@ -103,19 +87,17 @@ export function createHeartbeatTestHarness(): HeartbeatTestHarness {
|
||||
let nextResponse: MonitorStatusResponse | null = null;
|
||||
let nextStatus = true;
|
||||
let nextCode = 200;
|
||||
const networkService = {
|
||||
requestStatus: jest.fn().mockImplementation(() => {
|
||||
const providerRegistry = {
|
||||
probe: jest.fn().mockImplementation(() => {
|
||||
if (nextResponse) {
|
||||
return Promise.resolve(nextResponse);
|
||||
}
|
||||
return Promise.resolve(makeStatusResponse(nextStatus, nextCode));
|
||||
}),
|
||||
};
|
||||
const checkService = {
|
||||
toCheck: jest.fn().mockImplementation((response: MonitorStatusResponse) => {
|
||||
return makeCheck(response.status, response.code);
|
||||
}),
|
||||
};
|
||||
// The real mapper, so the stored check carries the hardware/docker payload the evaluator reads.
|
||||
// Only toCheck is reached; the repositories are never touched.
|
||||
const checkService = new CheckService(monitorsRepo as any, logger, {} as any);
|
||||
|
||||
const setNextResponse = (status: boolean, code: number) => {
|
||||
nextResponse = null;
|
||||
@@ -137,7 +119,13 @@ export function createHeartbeatTestHarness(): HeartbeatTestHarness {
|
||||
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 reactorDispatcher = new ReactorDispatcher(logger, [notificationReactor, incidentReactor]);
|
||||
|
||||
@@ -149,7 +137,7 @@ export function createHeartbeatTestHarness(): HeartbeatTestHarness {
|
||||
checksRepository: { findUnevaluatedByMonitorId: jest.fn() } as any,
|
||||
jobsRepository: { upsertEvaluate: jest.fn(), pullEvaluated: jest.fn() } as any,
|
||||
checkService: checkService as any,
|
||||
networkService: networkService as any,
|
||||
providerRegistry: providerRegistry as any,
|
||||
proxyResolver: proxyResolver as any,
|
||||
bufferService: bufferStub as any,
|
||||
dockerLogsService: dockerLogsService as any,
|
||||
@@ -165,7 +153,7 @@ export function createHeartbeatTestHarness(): HeartbeatTestHarness {
|
||||
if (!monitor) return;
|
||||
const result = await pipeline.produce(monitor);
|
||||
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);
|
||||
};
|
||||
|
||||
@@ -175,7 +163,7 @@ export function createHeartbeatTestHarness(): HeartbeatTestHarness {
|
||||
statusService,
|
||||
incidentService,
|
||||
notificationsService,
|
||||
networkService,
|
||||
providerRegistry,
|
||||
bufferStub,
|
||||
maintenanceWindowsRepo,
|
||||
messageBuilder,
|
||||
|
||||
@@ -21,7 +21,7 @@ export const makeMessage = (overrides?: Partial<NotificationMessage>): Notificat
|
||||
timestamp: new Date("2025-01-01T00:00:00Z"),
|
||||
},
|
||||
clientHost: "https://app.example.com",
|
||||
metadata: { teamId: "team-1", notificationReason: "status_change" },
|
||||
metadata: { teamId: "team-1" },
|
||||
...overrides,
|
||||
});
|
||||
|
||||
|
||||
@@ -14,7 +14,7 @@ const makeMessage = (): NotificationMessage => ({
|
||||
timestamp: new Date("2025-01-01T00:00:00Z"),
|
||||
},
|
||||
clientHost: "https://app.example.com",
|
||||
metadata: { teamId: "team-1", notificationReason: "status_change" },
|
||||
metadata: { teamId: "team-1" },
|
||||
});
|
||||
|
||||
export const testNotificationProviderContract = (
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { describe, expect, it } from "@jest/globals";
|
||||
import type { IStatusProvider } from "../../src/service/network/IStatusProvider.ts";
|
||||
import type { IStatusProvider } from "../../src/service/networkProviders/IStatusProvider.ts";
|
||||
import type { Monitor } from "../../src/domain/monitors/monitor.type.ts";
|
||||
|
||||
/**
|
||||
|
||||
@@ -126,14 +126,14 @@ describe("Evaluate cursor with more than one processing worker", () => {
|
||||
checksRepository,
|
||||
jobsRepository,
|
||||
checkService,
|
||||
networkService: { requestStatus: jest.fn<any>() } as any,
|
||||
providerRegistry: { probe: jest.fn<any>() } as any,
|
||||
proxyResolver: { resolve: jest.fn<any>() } as any,
|
||||
bufferService,
|
||||
dockerLogsService: { buildDockerLogs: jest.fn<any>() } as any,
|
||||
statusService: {
|
||||
updateMonitorStatus: async (_status: MonitorStatusResponse, check: Check, monitor: Monitor) => {
|
||||
updateMonitorStatus: async (check: Check, monitor: Monitor) => {
|
||||
evaluatedCheckIds.push(check.id);
|
||||
return { monitor, statusChanged: false, prevStatus: monitor.status, code: 200, timestamp: 0 };
|
||||
return { monitor, statusChanged: false, prevStatus: monitor.status };
|
||||
},
|
||||
} 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");
|
||||
|
||||
// No network request should have been made
|
||||
expect(h.networkService.requestStatus).not.toHaveBeenCalled();
|
||||
expect(h.providerRegistry.probe).not.toHaveBeenCalled();
|
||||
|
||||
// No check should have been buffered
|
||||
expect(h.bufferStub.addToBuffer).not.toHaveBeenCalled();
|
||||
@@ -68,7 +68,7 @@ describe("Heartbeat job: maintenance windows", () => {
|
||||
// since status was already "maintenance"
|
||||
const storedMonitor = await h.monitorsRepo.findById("mon-1", "team-1");
|
||||
expect(storedMonitor.status).toBe("maintenance");
|
||||
expect(h.networkService.requestStatus).not.toHaveBeenCalled();
|
||||
expect(h.providerRegistry.probe).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("resumes normal checks when maintenance window expires", async () => {
|
||||
@@ -88,7 +88,7 @@ describe("Heartbeat job: maintenance windows", () => {
|
||||
await h.heartbeatJob(monitor);
|
||||
|
||||
// Network request should have been made this time
|
||||
expect(h.networkService.requestStatus).toHaveBeenCalledTimes(1);
|
||||
expect(h.providerRegistry.probe).toHaveBeenCalledTimes(1);
|
||||
|
||||
// Check should have been buffered
|
||||
expect(h.bufferStub.addToBuffer).toHaveBeenCalledTimes(1);
|
||||
@@ -105,7 +105,7 @@ describe("Heartbeat job: maintenance windows", () => {
|
||||
await h.heartbeatJob(monitor);
|
||||
|
||||
// Normal check should proceed since window is inactive
|
||||
expect(h.networkService.requestStatus).toHaveBeenCalledTimes(1);
|
||||
expect(h.providerRegistry.probe).toHaveBeenCalledTimes(1);
|
||||
expect(h.bufferStub.addToBuffer).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
@@ -123,7 +123,7 @@ describe("Heartbeat job: maintenance windows", () => {
|
||||
|
||||
expect((await h.monitorsRepo.findById("mon-a", "team-1")).status).toBe("maintenance");
|
||||
expect((await h.monitorsRepo.findById("mon-b", "team-1")).status).toBe("maintenance");
|
||||
expect(h.networkService.requestStatus).not.toHaveBeenCalled();
|
||||
expect(h.providerRegistry.probe).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("skips checks when window is in the past (expired)", async () => {
|
||||
@@ -141,6 +141,6 @@ describe("Heartbeat job: maintenance windows", () => {
|
||||
await h.heartbeatJob(monitor);
|
||||
|
||||
// Window is in the past — normal check should proceed
|
||||
expect(h.networkService.requestStatus).toHaveBeenCalledTimes(1);
|
||||
expect(h.providerRegistry.probe).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
});
|
||||
@@ -49,7 +49,7 @@ describe("Heartbeat job: hardware threshold breach", () => {
|
||||
h.setNextResponseFull(makeHardwareResponse(0.9));
|
||||
|
||||
// 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"
|
||||
for (let i = 0; i < 4; i++) {
|
||||
@@ -191,7 +191,7 @@ describe("Heartbeat job: hardware threshold breach", () => {
|
||||
h.monitorsRepo.seed(monitor);
|
||||
|
||||
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
|
||||
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 { Monitor } from "../../src/domain/monitors/monitor.type.ts";
|
||||
import type { MonitorActionDecision } from "../../src/worker/worker.interface.ts";
|
||||
import type { Check } from "../../src/domain/checks/check.type.ts";
|
||||
|
||||
// ── Helpers ──────────────────────────────────────────────────────────────────
|
||||
|
||||
@@ -22,14 +23,21 @@ const makeMonitor = (overrides?: Partial<Monitor>): Monitor =>
|
||||
}) as Monitor;
|
||||
|
||||
const makeDecision = (overrides?: Partial<MonitorActionDecision>): MonitorActionDecision => ({
|
||||
shouldCreateIncident: false,
|
||||
shouldResolveIncident: false,
|
||||
shouldSendNotification: false,
|
||||
incidentReason: null,
|
||||
notificationReason: null,
|
||||
transition: null,
|
||||
...overrides,
|
||||
});
|
||||
|
||||
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 ───────────────────────────────────────────────────────────────
|
||||
|
||||
describe("Incident lifecycle (integration)", () => {
|
||||
@@ -43,7 +51,7 @@ describe("Incident lifecycle (integration)", () => {
|
||||
repo = new InMemoryIncidentsRepository();
|
||||
monitorsRepo = { findById: jest.fn() } as unknown as jest.Mocked<IMonitorsRepository>;
|
||||
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);
|
||||
});
|
||||
|
||||
@@ -51,9 +59,9 @@ describe("Incident lifecycle (integration)", () => {
|
||||
|
||||
it("creates an incident when monitor goes down", async () => {
|
||||
const monitor = makeMonitor({ status: "down" });
|
||||
const decision = makeDecision({ shouldCreateIncident: true, incidentReason: "status_down" });
|
||||
const decision = makeDecision({ transition: "status_down" });
|
||||
|
||||
const incident = await service.handleIncident(monitor, 503, decision);
|
||||
const incident = await service.handleIncident(monitor, decision, makeCheck(503));
|
||||
|
||||
expect(incident).not.toBeNull();
|
||||
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 () => {
|
||||
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 second = await service.handleIncident(monitor, 503, decision);
|
||||
const first = await service.handleIncident(monitor, decision, makeCheck(503));
|
||||
const second = await service.handleIncident(monitor, decision, makeCheck(503));
|
||||
|
||||
expect(second!.id).toBe(first!.id);
|
||||
expect(repo.getAll()).toHaveLength(1);
|
||||
@@ -85,11 +93,11 @@ describe("Incident lifecycle (integration)", () => {
|
||||
|
||||
it("auto-resolves an incident when monitor recovers", async () => {
|
||||
const monitor = makeMonitor({ status: "down" });
|
||||
const createDecision = makeDecision({ shouldCreateIncident: true, incidentReason: "status_down" });
|
||||
const created = await service.handleIncident(monitor, 503, createDecision);
|
||||
const createDecision = makeDecision({ transition: "status_down" });
|
||||
const created = await service.handleIncident(monitor, createDecision, makeCheck(503));
|
||||
|
||||
const resolveDecision = makeDecision({ shouldResolveIncident: true });
|
||||
const resolved = await service.handleIncident(monitor, 200, resolveDecision);
|
||||
const resolveDecision = makeDecision({ transition: "status_up" });
|
||||
const resolved = await service.handleIncident(monitor, resolveDecision, makeCheck(200));
|
||||
|
||||
expect(resolved).not.toBeNull();
|
||||
expect(resolved!.id).toBe(created!.id);
|
||||
@@ -102,9 +110,9 @@ describe("Incident lifecycle (integration)", () => {
|
||||
|
||||
it("returns null when resolving with no active incident", async () => {
|
||||
const monitor = makeMonitor({ status: "up" });
|
||||
const decision = makeDecision({ shouldResolveIncident: true });
|
||||
const decision = makeDecision({ transition: "status_up" });
|
||||
|
||||
const result = await service.handleIncident(monitor, 200, decision);
|
||||
const result = await service.handleIncident(monitor, decision, makeCheck(200));
|
||||
|
||||
expect(result).toBeNull();
|
||||
});
|
||||
@@ -113,8 +121,8 @@ describe("Incident lifecycle (integration)", () => {
|
||||
|
||||
it("manually resolves an active incident with comment", async () => {
|
||||
const monitor = makeMonitor({ status: "down" });
|
||||
const decision = makeDecision({ shouldCreateIncident: true, incidentReason: "status_down" });
|
||||
const created = await service.handleIncident(monitor, 500, decision);
|
||||
const decision = makeDecision({ transition: "status_down" });
|
||||
const created = await service.handleIncident(monitor, decision, makeCheck(500));
|
||||
|
||||
const resolved = await service.resolveIncident(created!.id, "user-1", "team-1", "Root cause identified", "user@test.com");
|
||||
|
||||
@@ -128,8 +136,8 @@ describe("Incident lifecycle (integration)", () => {
|
||||
|
||||
it("throws when manually resolving an already-resolved incident", async () => {
|
||||
const monitor = makeMonitor({ status: "down" });
|
||||
const decision = makeDecision({ shouldCreateIncident: true, incidentReason: "status_down" });
|
||||
const created = await service.handleIncident(monitor, 500, decision);
|
||||
const decision = makeDecision({ transition: "status_down" });
|
||||
const created = await service.handleIncident(monitor, decision, makeCheck(500));
|
||||
|
||||
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 () => {
|
||||
const monitor = makeMonitor({ status: "breached", type: "hardware" });
|
||||
(messageBuilder.extractThresholdBreaches as jest.Mock).mockReturnValue([
|
||||
{ metric: "cpu", formattedValue: "92%", threshold: 80, unit: "%" },
|
||||
{ metric: "memory", formattedValue: "88%", threshold: 85, unit: "%" },
|
||||
]);
|
||||
const decision = makeDecision({ shouldCreateIncident: true, incidentReason: "threshold_breach" });
|
||||
(messageBuilder.buildThresholdBreachMessage as jest.Mock).mockReturnValue("CPU: 92% (threshold: 80%), MEMORY: 88% (threshold: 85%)");
|
||||
const decision = makeDecision({ transition: "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!.message).toBe("CPU: 92% (threshold: 80%), MEMORY: 88% (threshold: 85%)");
|
||||
@@ -158,7 +163,7 @@ describe("Incident lifecycle (integration)", () => {
|
||||
const monitor = makeMonitor({ status: "up" });
|
||||
const decision = makeDecision();
|
||||
|
||||
const result = await service.handleIncident(monitor, 200, decision);
|
||||
const result = await service.handleIncident(monitor, decision, makeCheck(200));
|
||||
|
||||
expect(result).toBeNull();
|
||||
expect(repo.getAll()).toHaveLength(0);
|
||||
@@ -170,19 +175,19 @@ describe("Incident lifecycle (integration)", () => {
|
||||
const monitor = makeMonitor({ status: "down" });
|
||||
|
||||
// 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);
|
||||
|
||||
// Manually resolved
|
||||
await service.resolveIncident(first!.id, "user-1", "team-1", "Restarted server");
|
||||
|
||||
// Second outage — new incident since previous was resolved
|
||||
const second = await service.handleIncident(monitor, 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!.statusCode).toBe(502);
|
||||
|
||||
// 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!.status).toBe(false);
|
||||
expect(resolved!.resolutionType).toBe("automatic");
|
||||
@@ -198,16 +203,16 @@ describe("Incident lifecycle (integration)", () => {
|
||||
it("incidents for different monitors do not interfere", async () => {
|
||||
const monitorA = makeMonitor({ id: "mon-a", status: "down" });
|
||||
const monitorB = makeMonitor({ id: "mon-b", status: "down" });
|
||||
const decision = makeDecision({ shouldCreateIncident: true, incidentReason: "status_down" });
|
||||
const decision = makeDecision({ transition: "status_down" });
|
||||
|
||||
const incidentA = await service.handleIncident(monitorA, 500, decision);
|
||||
const incidentB = await service.handleIncident(monitorB, 502, decision);
|
||||
const incidentA = await service.handleIncident(monitorA, decision, makeCheck(500));
|
||||
const incidentB = await service.handleIncident(monitorB, decision, makeCheck(502));
|
||||
|
||||
expect(incidentA!.id).not.toBe(incidentB!.id);
|
||||
expect(repo.getAll()).toHaveLength(2);
|
||||
|
||||
// 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);
|
||||
|
||||
const bStillActive = await repo.findActiveByMonitorId("mon-b", "team-1");
|
||||
|
||||
@@ -69,6 +69,34 @@ describe("MongoMonitorsRepository", () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe("sorting", () => {
|
||||
it("uses creation time as the fallback for equal primary values", async () => {
|
||||
const repo = new MongoMonitorsRepository();
|
||||
const teamId = new mongoose.Types.ObjectId();
|
||||
const createMonitor = (name: string, type: string, status: string, createdAt: Date) =>
|
||||
MonitorModel.create({
|
||||
userId: new mongoose.Types.ObjectId(),
|
||||
teamId,
|
||||
name,
|
||||
type,
|
||||
status,
|
||||
url: "https://example.com",
|
||||
createdAt,
|
||||
});
|
||||
|
||||
await createMonitor("newer-up", "hardware", "up", new Date("2026-03-01T00:00:00.000Z"));
|
||||
await createMonitor("down", "http", "down", new Date("2026-04-01T00:00:00.000Z"));
|
||||
await createMonitor("older-up", "pagespeed", "up", new Date("2026-01-01T00:00:00.000Z"));
|
||||
await createMonitor("middle-up", "docker", "up", new Date("2026-02-01T00:00:00.000Z"));
|
||||
|
||||
const ascending = await repo.findByTeamIdWithStats(teamId.toString(), { field: "status", order: "asc" });
|
||||
const descending = await repo.findByTeamIdWithStats(teamId.toString(), { field: "status", order: "desc" });
|
||||
|
||||
expect(ascending.map((monitor) => monitor.name)).toEqual(["down", "older-up", "middle-up", "newer-up"]);
|
||||
expect(descending.map((monitor) => monitor.name)).toEqual(["older-up", "middle-up", "newer-up", "down"]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("findByIds recentChecks modes", () => {
|
||||
const seedFixtures = async () => {
|
||||
const teamId = new mongoose.Types.ObjectId();
|
||||
|
||||
@@ -0,0 +1,107 @@
|
||||
import { describe, expect, it, jest } from "@jest/globals";
|
||||
import type { NextFunction, Request, Response } from "express";
|
||||
import { sanitizeBody, sanitizeInput, sanitizeObject, sanitizeQuery } from "../../../src/api/middleware/sanitization.ts";
|
||||
|
||||
// Express 5 exposes req.query as a getter on the request prototype that re-parses the URL on
|
||||
// every read, so writes into the returned object are lost. The middleware must shadow the
|
||||
// getter with an own property, which is what this fixture checks.
|
||||
const makeExpress5Request = (query: Record<string, unknown>) => {
|
||||
const proto = {};
|
||||
Object.defineProperty(proto, "query", {
|
||||
get: () => ({ ...query }),
|
||||
configurable: true,
|
||||
enumerable: true,
|
||||
});
|
||||
return Object.create(proto) as Request;
|
||||
};
|
||||
|
||||
describe("sanitizeInput", () => {
|
||||
it("strips html tags and keeps the text content", () => {
|
||||
expect(sanitizeInput('<script>alert("x")</script>hello <b>bold</b>')).toBe("hello bold");
|
||||
});
|
||||
|
||||
it("returns non-string values unchanged", () => {
|
||||
expect(sanitizeInput(42)).toBe(42);
|
||||
expect(sanitizeInput(null)).toBeNull();
|
||||
expect(sanitizeInput(undefined)).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe("sanitizeObject", () => {
|
||||
it("sanitizes nested strings and arrays, leaving other values untouched", () => {
|
||||
const result = sanitizeObject({
|
||||
name: "<img src=x onerror=alert(1)>Alice",
|
||||
count: 3,
|
||||
active: true,
|
||||
items: [{ note: "<i>one</i>" }, { note: "two" }],
|
||||
nested: { note: "<p>hi</p>" },
|
||||
});
|
||||
|
||||
expect(result).toEqual({
|
||||
name: "Alice",
|
||||
count: 3,
|
||||
active: true,
|
||||
items: [{ note: "one" }, { note: "two" }],
|
||||
nested: { note: "hi" },
|
||||
});
|
||||
});
|
||||
|
||||
it("returns primitives and null unchanged", () => {
|
||||
expect(sanitizeObject("<b>x</b>")).toBe("<b>x</b>");
|
||||
expect(sanitizeObject(null)).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe("sanitizeBody middleware", () => {
|
||||
it("replaces req.body with a sanitized copy and calls next", () => {
|
||||
const req = { body: { name: "<b>Alice</b>", nested: { note: "<i>x</i>" } } } as unknown as Request;
|
||||
const next = jest.fn() as unknown as NextFunction;
|
||||
|
||||
sanitizeBody()(req, {} as Response, next);
|
||||
|
||||
expect(req.body).toEqual({ name: "Alice", nested: { note: "x" } });
|
||||
expect(next).toHaveBeenCalledWith();
|
||||
});
|
||||
|
||||
it("leaves req.body alone when there is no body", () => {
|
||||
const req = {} as unknown as Request;
|
||||
const next = jest.fn() as unknown as NextFunction;
|
||||
|
||||
sanitizeBody()(req, {} as Response, next);
|
||||
|
||||
expect(req.body).toBeUndefined();
|
||||
expect(next).toHaveBeenCalledWith();
|
||||
});
|
||||
});
|
||||
|
||||
describe("sanitizeQuery middleware", () => {
|
||||
it("sanitizes string values so that later reads of req.query see them", () => {
|
||||
const req = makeExpress5Request({ filter: "<script>x</script>term", page: "2" });
|
||||
const next = jest.fn() as unknown as NextFunction;
|
||||
|
||||
sanitizeQuery()(req, {} as Response, next);
|
||||
|
||||
expect(req.query).toEqual({ filter: "term", page: "2" });
|
||||
expect(req.query).toEqual({ filter: "term", page: "2" }); // stable across reads
|
||||
expect(next).toHaveBeenCalledWith();
|
||||
});
|
||||
|
||||
it("does not sanitize inside array values", () => {
|
||||
const req = makeExpress5Request({ type: ["<b>http</b>", "ping"] });
|
||||
const next = jest.fn() as unknown as NextFunction;
|
||||
|
||||
sanitizeQuery()(req, {} as Response, next);
|
||||
|
||||
expect(req.query).toEqual({ type: ["<b>http</b>", "ping"] });
|
||||
});
|
||||
|
||||
it("handles an empty query", () => {
|
||||
const req = makeExpress5Request({});
|
||||
const next = jest.fn() as unknown as NextFunction;
|
||||
|
||||
sanitizeQuery()(req, {} as Response, next);
|
||||
|
||||
expect(req.query).toEqual({});
|
||||
expect(next).toHaveBeenCalledWith();
|
||||
});
|
||||
});
|
||||
@@ -1,5 +1,5 @@
|
||||
import { describe, expect, it, jest } from "@jest/globals";
|
||||
import { AdvancedMatcher } from "../../../../src/service/network/AdvancedMatcher.ts";
|
||||
import { AdvancedMatcher } from "../../../../src/service/networkProviders/AdvancedMatcher.ts";
|
||||
import type { Monitor } from "../../../../src/domain/monitors/monitor.type.ts";
|
||||
|
||||
// ── Helpers ──────────────────────────────────────────────────────────────────
|
||||
|
||||
@@ -2,7 +2,7 @@ import { beforeEach, describe, expect, it, jest } from "@jest/globals";
|
||||
import { testStatusProviderContract } from "../../../helpers/statusProviderContract.ts";
|
||||
import { NETWORK_ERROR } from "../../../../src/types/network.ts";
|
||||
import type { Monitor } from "../../../../src/domain/monitors/monitor.type.ts";
|
||||
import { DNSProvider } from "../../../../src/service/network/DNSProvider.ts";
|
||||
import { DNSProvider } from "../../../../src/service/networkProviders/DNSProvider.ts";
|
||||
|
||||
// ── Mocks ────────────────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { describe, expect, it, jest } from "@jest/globals";
|
||||
import https from "node:https";
|
||||
import { DockerProvider } from "../../../../src/service/network/DockerProvider.ts";
|
||||
import { DockerProvider } from "../../../../src/service/networkProviders/DockerProvider.ts";
|
||||
import type { IEncryptionService } from "../../../../src/service/encryption/encryptionService.ts";
|
||||
import { testStatusProviderContract } from "../../../helpers/statusProviderContract.ts";
|
||||
import { createMockLogger } from "../../../helpers/createMockLogger.ts";
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { describe, expect, it, jest } from "@jest/globals";
|
||||
import { GameProvider } from "../../../../src/service/network/GameProvider.ts";
|
||||
import { GameProvider } from "../../../../src/service/networkProviders/GameProvider.ts";
|
||||
import { testStatusProviderContract } from "../../../helpers/statusProviderContract.ts";
|
||||
import { createMockLogger } from "../../../helpers/createMockLogger.ts";
|
||||
import { NETWORK_ERROR } from "../../../../src/types/network.ts";
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { describe, expect, it, jest } from "@jest/globals";
|
||||
import { GrpcProvider } from "../../../../src/service/network/GrpcProvider.ts";
|
||||
import { GrpcProvider } from "../../../../src/service/networkProviders/GrpcProvider.ts";
|
||||
import { testStatusProviderContract } from "../../../helpers/statusProviderContract.ts";
|
||||
import type { Monitor } from "../../../../src/domain/monitors/monitor.type.ts";
|
||||
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { describe, expect, it, jest } from "@jest/globals";
|
||||
import { HardwareProvider } from "../../../../src/service/network/HardwareProvider.ts";
|
||||
import { HardwareProvider } from "../../../../src/service/networkProviders/HardwareProvider.ts";
|
||||
import { testStatusProviderContract } from "../../../helpers/statusProviderContract.ts";
|
||||
import type { HttpProvider } from "../../../../src/service/network/HttpProvider.ts";
|
||||
import type { HttpProvider } from "../../../../src/service/networkProviders/HttpProvider.ts";
|
||||
import type { Monitor } from "../../../../src/domain/monitors/monitor.type.ts";
|
||||
|
||||
// ── Helpers ──────────────────────────────────────────────────────────────────
|
||||
|
||||
@@ -39,7 +39,7 @@ jest.unstable_mockModule("got", () => ({
|
||||
},
|
||||
}));
|
||||
|
||||
const { HttpProvider } = await import("../../../../src/service/network/HttpProvider.ts");
|
||||
const { HttpProvider } = await import("../../../../src/service/networkProviders/HttpProvider.ts");
|
||||
const gotModule = await import("got");
|
||||
const { HTTPError, RequestError } = gotModule;
|
||||
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
import { describe, expect, it, jest } from "@jest/globals";
|
||||
import { PageSpeedProvider } from "../../../../src/service/network/PageSpeedProvider.ts";
|
||||
import { PageSpeedProvider } from "../../../../src/service/networkProviders/PageSpeedProvider.ts";
|
||||
import { testStatusProviderContract } from "../../../helpers/statusProviderContract.ts";
|
||||
import { createMockLogger } from "../../../helpers/createMockLogger.ts";
|
||||
import type { HttpProvider } from "../../../../src/service/network/HttpProvider.ts";
|
||||
import type { HttpProvider } from "../../../../src/service/networkProviders/HttpProvider.ts";
|
||||
import type { ISettingsService } from "../../../../src/domain/app-settings/app-settings.service.ts";
|
||||
import type { Monitor } from "../../../../src/domain/monitors/monitor.type.ts";
|
||||
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { describe, expect, it, jest } from "@jest/globals";
|
||||
import { PingProvider } from "../../../../src/service/network/PingProvider.ts";
|
||||
import { PingProvider } from "../../../../src/service/networkProviders/PingProvider.ts";
|
||||
import { testStatusProviderContract } from "../../../helpers/statusProviderContract.ts";
|
||||
import type { Monitor } from "../../../../src/domain/monitors/monitor.type.ts";
|
||||
import * as net from "net";
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { describe, expect, it, jest } from "@jest/globals";
|
||||
import { PortProvider } from "../../../../src/service/network/PortProvider.ts";
|
||||
import { PortProvider } from "../../../../src/service/networkProviders/PortProvider.ts";
|
||||
import { testStatusProviderContract } from "../../../helpers/statusProviderContract.ts";
|
||||
import { NETWORK_ERROR } from "../../../../src/types/network.ts";
|
||||
import type { Monitor } from "../../../../src/domain/monitors/monitor.type.ts";
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { describe, expect, it } from "@jest/globals";
|
||||
import { timeRequest, isStatusUp, peerAnsweredFromError } from "../../../../src/service/network/utils.ts";
|
||||
import { timeRequest, isStatusUp, peerAnsweredFromError } from "../../../../src/service/networkProviders/utils.ts";
|
||||
import { NETWORK_ERROR } from "../../../../src/types/network.ts";
|
||||
|
||||
describe("network utils", () => {
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { describe, expect, it, jest, beforeEach, afterEach } from "@jest/globals";
|
||||
import { WebSocketProvider } from "../../../../src/service/network/WebSocketProvider.ts";
|
||||
import { WebSocketProvider } from "../../../../src/service/networkProviders/WebSocketProvider.ts";
|
||||
import { testStatusProviderContract } from "../../../helpers/statusProviderContract.ts";
|
||||
import { NETWORK_ERROR } from "../../../../src/types/network.ts";
|
||||
import type { Monitor } from "../../../../src/domain/monitors/monitor.type.ts";
|
||||
|
||||
@@ -48,7 +48,6 @@ const createMessage = (overrides?: Partial<NotificationMessage>): NotificationMe
|
||||
clientHost: "https://app.example.com",
|
||||
metadata: {
|
||||
teamId: "team-1",
|
||||
notificationReason: "monitor_down",
|
||||
},
|
||||
...overrides,
|
||||
});
|
||||
|
||||
@@ -134,41 +134,6 @@ describe("BufferService", () => {
|
||||
|
||||
expect(ingest).toHaveBeenCalledWith([check1, check2]);
|
||||
});
|
||||
|
||||
it("logs error if push throws", () => {
|
||||
const { service, logger } = createService();
|
||||
// Force buffer.push to throw by making buffer non-extensible
|
||||
Object.defineProperty(service, "buffer", { value: Object.freeze([]) });
|
||||
|
||||
service.addToBuffer(makeCheck());
|
||||
|
||||
expect(logger.error).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
service: "BufferService",
|
||||
method: "addToBuffer",
|
||||
})
|
||||
);
|
||||
});
|
||||
|
||||
it("logs 'Unknown error' for non-Error thrown values", () => {
|
||||
const { service, logger } = createService();
|
||||
Object.defineProperty(service, "buffer", {
|
||||
value: {
|
||||
push: () => {
|
||||
throw "string error";
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
service.addToBuffer(makeCheck());
|
||||
|
||||
expect(logger.error).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
message: "Unknown error",
|
||||
stack: undefined,
|
||||
})
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
// ── addGeoCheckToBuffer ──────────────────────────────────────────────
|
||||
@@ -183,40 +148,6 @@ describe("BufferService", () => {
|
||||
|
||||
expect(geoChecksService.createGeoChecks).toHaveBeenCalledWith([geoCheck]);
|
||||
});
|
||||
|
||||
it("logs error if push throws", () => {
|
||||
const { service, logger } = createService();
|
||||
Object.defineProperty(service, "geoBuffer", { value: Object.freeze([]) });
|
||||
|
||||
service.addGeoCheckToBuffer(makeGeoCheck());
|
||||
|
||||
expect(logger.error).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
service: "BufferService",
|
||||
method: "addGeoCheckToBuffer",
|
||||
})
|
||||
);
|
||||
});
|
||||
|
||||
it("logs 'Unknown error' for non-Error thrown values", () => {
|
||||
const { service, logger } = createService();
|
||||
Object.defineProperty(service, "geoBuffer", {
|
||||
value: {
|
||||
push: () => {
|
||||
throw 42;
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
service.addGeoCheckToBuffer(makeGeoCheck());
|
||||
|
||||
expect(logger.error).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
message: "Unknown error",
|
||||
stack: undefined,
|
||||
})
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("addDockerLogToBuffer", () => {
|
||||
@@ -229,15 +160,6 @@ describe("BufferService", () => {
|
||||
|
||||
expect(dockerLogsService.createDockerLogs).toHaveBeenCalledWith([dockerLog]);
|
||||
});
|
||||
|
||||
it("logs an error if push throws", () => {
|
||||
const { service, logger } = createService();
|
||||
Object.defineProperty(service, "dockerLogBuffer", { value: Object.freeze([]) });
|
||||
|
||||
service.addDockerLogToBuffer(makeDockerLog());
|
||||
|
||||
expect(logger.error).toHaveBeenCalledWith(expect.objectContaining({ method: "addDockerLogToBuffer" }));
|
||||
});
|
||||
});
|
||||
|
||||
// ── scheduleNextFlush ────────────────────────────────────────────────
|
||||
|
||||
@@ -3,9 +3,7 @@ import { CheckService } from "../../../src/domain/checks/check.service.ts";
|
||||
import { createMockLogger } from "../../helpers/createMockLogger.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 { MonitorStatusResponse, HardwareStatusPayload, PageSpeedStatusPayload, DockerStatusPayload } 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";
|
||||
import type { MonitorStatusResponse, HardwareStatusPayload, PageSpeedStatusPayload } from "../../../src/types/network.ts";
|
||||
|
||||
// ── 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 ─────────────────────────────────────────────────────────
|
||||
|
||||
describe("createChecks", () => {
|
||||
|
||||
@@ -42,7 +42,7 @@ const makeJob = (overrides?: Partial<Job>): Job => ({
|
||||
...overrides,
|
||||
});
|
||||
|
||||
// requestStatus mock that answers per target url
|
||||
// probe mock that answers per target url
|
||||
const statusFor = (reachableTargets: string[]) =>
|
||||
jest.fn().mockImplementation(async (monitor: any) => ({
|
||||
monitorId: monitor.id,
|
||||
@@ -78,7 +78,7 @@ const createService = (overrides?: Record<string, any>) => {
|
||||
upsertCleanupJob: jest.fn().mockResolvedValue(true),
|
||||
deleteGlobalJobIfUnchanged: jest.fn().mockResolvedValue(true),
|
||||
},
|
||||
networkService: { requestStatus: statusFor(["1.1.1.1", "8.8.8.8"]) },
|
||||
providerRegistry: { probe: statusFor(["1.1.1.1", "8.8.8.8"]) },
|
||||
proxyResolver: { resolve: jest.fn().mockResolvedValue(undefined) },
|
||||
logger: createMockLogger(),
|
||||
...overrides,
|
||||
@@ -87,15 +87,15 @@ const createService = (overrides?: Record<string, any>) => {
|
||||
defaults.settingsService as any,
|
||||
defaults.egressStateRepository as any,
|
||||
defaults.jobsRepository as any,
|
||||
defaults.networkService as any,
|
||||
defaults.providerRegistry as any,
|
||||
defaults.proxyResolver as any,
|
||||
defaults.logger as any
|
||||
);
|
||||
return { service, defaults };
|
||||
};
|
||||
|
||||
const probedUrls = (defaults: { networkService: { requestStatus: unknown } }) =>
|
||||
(defaults.networkService.requestStatus as jest.Mock).mock.calls.map((call) => (call[0] as any).url);
|
||||
const probedUrls = (defaults: { providerRegistry: { probe: unknown } }) =>
|
||||
(defaults.providerRegistry.probe as jest.Mock).mock.calls.map((call) => (call[0] as any).url);
|
||||
|
||||
// ── Tests ────────────────────────────────────────────────────────────────────
|
||||
|
||||
@@ -112,7 +112,7 @@ describe("EgressService", () => {
|
||||
|
||||
await service.probeTargets(["1.1.1.1", "8.8.8.8:53", "https://example.com/health", "[2606:4700::1111]:443", "2606:4700::1111"]);
|
||||
|
||||
const monitors = (defaults.networkService.requestStatus as jest.Mock).mock.calls.map((call) => call[0] as any);
|
||||
const monitors = (defaults.providerRegistry.probe as jest.Mock).mock.calls.map((call) => call[0] as any);
|
||||
expect(monitors).toEqual([
|
||||
expect.objectContaining({ type: "ping", url: "1.1.1.1" }),
|
||||
expect.objectContaining({ type: "port", url: "8.8.8.8", port: 53 }),
|
||||
@@ -129,13 +129,13 @@ describe("EgressService", () => {
|
||||
await service.probeTargets(["https://example.com/health"]);
|
||||
|
||||
expect(resolve).toHaveBeenCalledWith(expect.objectContaining({ type: "http", proxyMode: "inherit" }));
|
||||
expect(defaults.networkService.requestStatus).toHaveBeenCalledWith(expect.objectContaining({ type: "http" }), {
|
||||
expect(defaults.providerRegistry.probe).toHaveBeenCalledWith(expect.objectContaining({ type: "http" }), {
|
||||
proxyUrl: "http://proxy.internal:3128",
|
||||
});
|
||||
});
|
||||
|
||||
it("reports reachable only when the provider says status is true", async () => {
|
||||
const { service } = createService({ networkService: { requestStatus: statusFor(["1.1.1.1"]) } });
|
||||
const { service } = createService({ providerRegistry: { probe: statusFor(["1.1.1.1"]) } });
|
||||
|
||||
const results = await service.probeTargets(["1.1.1.1", "8.8.8.8"]);
|
||||
|
||||
@@ -146,7 +146,7 @@ describe("EgressService", () => {
|
||||
});
|
||||
|
||||
it("treats any HTTP response, even 4xx/5xx, as reachable but a transport failure as unreachable", async () => {
|
||||
const requestStatus = jest.fn().mockImplementation(async (monitor: any) => ({
|
||||
const probe = jest.fn().mockImplementation(async (monitor: any) => ({
|
||||
monitorId: monitor.id,
|
||||
teamId: monitor.teamId,
|
||||
type: monitor.type,
|
||||
@@ -155,7 +155,7 @@ describe("EgressService", () => {
|
||||
message: monitor.url.includes("maintenance") ? "Service Unavailable" : "ECONNREFUSED",
|
||||
responseTime: 3,
|
||||
}));
|
||||
const { service } = createService({ networkService: { requestStatus } });
|
||||
const { service } = createService({ providerRegistry: { probe } });
|
||||
|
||||
const results = await service.probeTargets(["https://example.com/maintenance", "https://example.com/refused", "8.8.8.8:53"]);
|
||||
|
||||
@@ -172,11 +172,11 @@ describe("EgressService", () => {
|
||||
const results = await service.probeTargets(["[1.1.1.1"]);
|
||||
|
||||
expect(results).toEqual([expect.objectContaining({ target: "[1.1.1.1", reachable: false, message: "Invalid egress target" })]);
|
||||
expect(defaults.networkService.requestStatus).not.toHaveBeenCalled();
|
||||
expect(defaults.providerRegistry.probe).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("counts a thrown provider error as unreachable", async () => {
|
||||
const { service } = createService({ networkService: { requestStatus: jest.fn().mockRejectedValue(new Error("ENETUNREACH")) } });
|
||||
const { service } = createService({ providerRegistry: { probe: jest.fn().mockRejectedValue(new Error("ENETUNREACH")) } });
|
||||
|
||||
const results = await service.probeTargets(["1.1.1.1"]);
|
||||
|
||||
@@ -185,7 +185,7 @@ describe("EgressService", () => {
|
||||
|
||||
it("counts a hung provider as unreachable once the probe timeout elapses", async () => {
|
||||
jest.useFakeTimers();
|
||||
const { service } = createService({ networkService: { requestStatus: jest.fn().mockReturnValue(new Promise(() => {})) } });
|
||||
const { service } = createService({ providerRegistry: { probe: jest.fn().mockReturnValue(new Promise(() => {})) } });
|
||||
|
||||
const pending = service.probeTargets(["1.1.1.1"]);
|
||||
await jest.advanceTimersByTimeAsync(5000);
|
||||
@@ -206,13 +206,13 @@ describe("EgressService", () => {
|
||||
const result = await service.assessAfterFailure();
|
||||
|
||||
expect(result).toBeNull();
|
||||
expect(defaults.networkService.requestStatus).not.toHaveBeenCalled();
|
||||
expect(defaults.providerRegistry.probe).not.toHaveBeenCalled();
|
||||
expect(defaults.egressStateRepository.findSingleton).not.toHaveBeenCalled();
|
||||
expect(defaults.egressStateRepository.recordProbe).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("returns ok and records the probe when any target is reachable", async () => {
|
||||
const { service, defaults } = createService({ networkService: { requestStatus: statusFor(["8.8.8.8"]) } });
|
||||
const { service, defaults } = createService({ providerRegistry: { probe: statusFor(["8.8.8.8"]) } });
|
||||
|
||||
const result = await service.assessAfterFailure();
|
||||
|
||||
@@ -227,7 +227,7 @@ describe("EgressService", () => {
|
||||
});
|
||||
|
||||
it("marks degraded, warns, schedules the recovery job and returns degraded when every target is unreachable", async () => {
|
||||
const { service, defaults } = createService({ networkService: { requestStatus: statusFor([]) } });
|
||||
const { service, defaults } = createService({ providerRegistry: { probe: statusFor([]) } });
|
||||
|
||||
const result = await service.assessAfterFailure();
|
||||
|
||||
@@ -255,7 +255,7 @@ describe("EgressService", () => {
|
||||
|
||||
it("still returns degraded and leaves the job scheduled when another worker performed the transition first", async () => {
|
||||
const { service, defaults } = createService({
|
||||
networkService: { requestStatus: statusFor([]) },
|
||||
providerRegistry: { probe: statusFor([]) },
|
||||
egressStateRepository: degradedRepository({
|
||||
findSingleton: jest.fn().mockResolvedValue(makeState()),
|
||||
markDegraded: jest.fn().mockResolvedValue(null),
|
||||
@@ -271,7 +271,7 @@ describe("EgressService", () => {
|
||||
|
||||
it("does not mark degraded when the recovery job cannot be scheduled", async () => {
|
||||
const { service, defaults } = createService({
|
||||
networkService: { requestStatus: statusFor([]) },
|
||||
providerRegistry: { probe: statusFor([]) },
|
||||
jobsRepository: {
|
||||
upsertJob: jest.fn(),
|
||||
upsertCleanupJob: jest.fn().mockRejectedValue(new Error("db down")),
|
||||
@@ -289,7 +289,7 @@ describe("EgressService", () => {
|
||||
const result = await service.assessAfterFailure();
|
||||
|
||||
expect(result).toBe("degraded");
|
||||
expect(defaults.networkService.requestStatus).not.toHaveBeenCalled();
|
||||
expect(defaults.providerRegistry.probe).not.toHaveBeenCalled();
|
||||
expect(defaults.egressStateRepository.markDegraded).not.toHaveBeenCalled();
|
||||
expect(defaults.egressStateRepository.recordProbe).not.toHaveBeenCalled();
|
||||
expect(defaults.jobsRepository.upsertJob).toHaveBeenCalledWith(expect.objectContaining({ id: "egress", intervalMs: 5_000 }));
|
||||
@@ -322,19 +322,19 @@ describe("EgressService", () => {
|
||||
|
||||
describe("shared assessment", () => {
|
||||
it("shares one probe between concurrent failures", async () => {
|
||||
const { service, defaults } = createService({ networkService: { requestStatus: statusFor(["8.8.8.8"]) } });
|
||||
const { service, defaults } = createService({ providerRegistry: { probe: statusFor(["8.8.8.8"]) } });
|
||||
|
||||
const results = await Promise.all([service.assessAfterFailure(), service.assessAfterFailure(), service.assessAfterFailure()]);
|
||||
|
||||
expect(results).toEqual(["ok", "ok", "ok"]);
|
||||
expect(defaults.settingsService.getCachedDBSettings).toHaveBeenCalledTimes(1);
|
||||
expect(defaults.networkService.requestStatus).toHaveBeenCalledTimes(2); // one call per target, once
|
||||
expect(defaults.providerRegistry.probe).toHaveBeenCalledTimes(2); // one call per target, once
|
||||
expect(defaults.egressStateRepository.recordProbe).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("keeps sharing an in-flight assessment that outlives the reuse window", async () => {
|
||||
jest.useFakeTimers();
|
||||
const { service, defaults } = createService({ networkService: { requestStatus: jest.fn().mockReturnValue(new Promise(() => {})) } });
|
||||
const { service, defaults } = createService({ providerRegistry: { probe: jest.fn().mockReturnValue(new Promise(() => {})) } });
|
||||
|
||||
const first = service.assessAfterFailure();
|
||||
await jest.advanceTimersByTimeAsync(4_900); // probes still hanging, past the 5 s reuse window from the first call
|
||||
@@ -342,12 +342,12 @@ describe("EgressService", () => {
|
||||
await jest.advanceTimersByTimeAsync(200); // probe timeout fires, both settle
|
||||
|
||||
expect(await Promise.all([first, second])).toEqual(["degraded", "degraded"]);
|
||||
expect(defaults.networkService.requestStatus).toHaveBeenCalledTimes(2); // one round of two targets, not two rounds
|
||||
expect(defaults.providerRegistry.probe).toHaveBeenCalledTimes(2); // one round of two targets, not two rounds
|
||||
});
|
||||
|
||||
it("reuses a settled assessment within the reuse window and probes again after it", async () => {
|
||||
jest.useFakeTimers();
|
||||
const { service, defaults } = createService({ networkService: { requestStatus: statusFor(["8.8.8.8"]) } });
|
||||
const { service, defaults } = createService({ providerRegistry: { probe: statusFor(["8.8.8.8"]) } });
|
||||
|
||||
await service.assessAfterFailure();
|
||||
await jest.advanceTimersByTimeAsync(1_000);
|
||||
@@ -385,7 +385,7 @@ describe("EgressService", () => {
|
||||
it("marks recovered, releases the job and logs when a target becomes reachable", async () => {
|
||||
const { service, defaults } = createService({
|
||||
egressStateRepository: degradedRepository(),
|
||||
networkService: { requestStatus: statusFor(["1.1.1.1"]) },
|
||||
providerRegistry: { probe: statusFor(["1.1.1.1"]) },
|
||||
});
|
||||
const job = makeJob({ nextScheduledAt: 123_456 });
|
||||
|
||||
@@ -403,7 +403,7 @@ describe("EgressService", () => {
|
||||
it("releases the job but does not log a recovery when another process already recorded it", async () => {
|
||||
const { service, defaults } = createService({
|
||||
egressStateRepository: degradedRepository({ markRecovered: jest.fn().mockResolvedValue(null) }),
|
||||
networkService: { requestStatus: statusFor(["1.1.1.1"]) },
|
||||
providerRegistry: { probe: statusFor(["1.1.1.1"]) },
|
||||
});
|
||||
|
||||
await service.checkRecovery(makeJob());
|
||||
@@ -417,7 +417,7 @@ describe("EgressService", () => {
|
||||
it("records the probe and keeps the job while every target stays unreachable", async () => {
|
||||
const { service, defaults } = createService({
|
||||
egressStateRepository: degradedRepository(),
|
||||
networkService: { requestStatus: statusFor([]) },
|
||||
providerRegistry: { probe: statusFor([]) },
|
||||
});
|
||||
|
||||
await service.checkRecovery(makeJob());
|
||||
@@ -432,7 +432,7 @@ describe("EgressService", () => {
|
||||
|
||||
await service.checkRecovery(makeJob());
|
||||
|
||||
expect(defaults.networkService.requestStatus).not.toHaveBeenCalled();
|
||||
expect(defaults.providerRegistry.probe).not.toHaveBeenCalled();
|
||||
expect(defaults.egressStateRepository.markRecovered).not.toHaveBeenCalled();
|
||||
expect(defaults.jobsRepository.deleteGlobalJobIfUnchanged).toHaveBeenCalledWith("egress", 1_000);
|
||||
});
|
||||
@@ -445,7 +445,7 @@ describe("EgressService", () => {
|
||||
|
||||
await service.checkRecovery(makeJob());
|
||||
|
||||
expect(defaults.networkService.requestStatus).not.toHaveBeenCalled();
|
||||
expect(defaults.providerRegistry.probe).not.toHaveBeenCalled();
|
||||
expect(defaults.jobsRepository.deleteGlobalJobIfUnchanged).toHaveBeenCalledWith("egress", 1_000);
|
||||
});
|
||||
|
||||
|
||||
@@ -8,6 +8,7 @@ import type { INotificationMessageBuilder } from "../../../src/domain/notificati
|
||||
import type { Monitor } from "../../../src/domain/monitors/monitor.type.ts";
|
||||
import type { Incident } from "../../../src/domain/incidents/incident.type.ts";
|
||||
import type { MonitorActionDecision } from "../../../src/worker/worker.interface.ts";
|
||||
import type { Check } from "../../../src/domain/checks/check.type.ts";
|
||||
|
||||
// ── Helpers ──────────────────────────────────────────────────────────────────
|
||||
|
||||
@@ -35,7 +36,7 @@ const createUsersRepo = () =>
|
||||
|
||||
const createMessageBuilder = () =>
|
||||
({
|
||||
extractThresholdBreaches: jest.fn(),
|
||||
buildThresholdBreachMessage: jest.fn(),
|
||||
}) as unknown as jest.Mocked<INotificationMessageBuilder>;
|
||||
|
||||
const createService = (overrides?: {
|
||||
@@ -79,12 +80,21 @@ const makeIncident = (overrides?: Partial<Incident>): Incident =>
|
||||
...overrides,
|
||||
}) 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 => ({
|
||||
shouldCreateIncident: false,
|
||||
shouldResolveIncident: false,
|
||||
shouldSendNotification: false,
|
||||
incidentReason: null,
|
||||
notificationReason: null,
|
||||
transition: null,
|
||||
...overrides,
|
||||
});
|
||||
|
||||
@@ -96,28 +106,28 @@ describe("IncidentService", () => {
|
||||
describe("handleIncident", () => {
|
||||
it("returns null when neither create nor resolve is requested", async () => {
|
||||
const { service } = createService();
|
||||
const result = await service.handleIncident(makeMonitor(), 200, makeDecision());
|
||||
const result = await service.handleIncident(makeMonitor(), makeDecision(), makeCheck());
|
||||
expect(result).toBeNull();
|
||||
});
|
||||
|
||||
it("returns existing active incident when shouldCreateIncident and one already exists", async () => {
|
||||
it("returns the existing active incident on a down transition when one is already open", async () => {
|
||||
const existing = makeIncident();
|
||||
const { service, incidentsRepository } = createService();
|
||||
(incidentsRepository.findActiveByMonitorId as jest.Mock).mockResolvedValue(existing);
|
||||
|
||||
const result = await service.handleIncident(makeMonitor(), 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(incidentsRepository.create).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("creates a new incident when shouldCreateIncident and no active incident exists", async () => {
|
||||
it("creates a new incident on a down transition when none is open", async () => {
|
||||
const created = makeIncident();
|
||||
const { service, incidentsRepository } = createService();
|
||||
const { service, incidentsRepository, notificationMessageBuilder } = createService();
|
||||
(incidentsRepository.findActiveByMonitorId as jest.Mock).mockResolvedValue(null);
|
||||
(incidentsRepository.create as jest.Mock).mockResolvedValue(created);
|
||||
|
||||
const result = await service.handleIncident(makeMonitor(), 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(incidentsRepository.create).toHaveBeenCalledWith(
|
||||
@@ -128,6 +138,7 @@ describe("IncidentService", () => {
|
||||
statusCode: 500,
|
||||
})
|
||||
);
|
||||
expect(notificationMessageBuilder.buildThresholdBreachMessage).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("uses status code 9999 and builds message for threshold_breach incidents", async () => {
|
||||
@@ -135,13 +146,15 @@ describe("IncidentService", () => {
|
||||
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: "%" },
|
||||
]);
|
||||
(notificationMessageBuilder.buildThresholdBreachMessage as jest.Mock).mockReturnValue("CPU: 95% (threshold: 80%)");
|
||||
|
||||
const decision = makeDecision({ shouldCreateIncident: true, incidentReason: "threshold_breach" });
|
||||
await service.handleIncident(makeMonitor(), 200, decision, { monitorId: "mon-1" } as any);
|
||||
const monitor = makeMonitor({ type: "hardware" });
|
||||
const check = makeCheck({ metadata: { monitorId: "mon-1", teamId: "team-1", type: "hardware" }, cpu: { usage_percent: 0.95 } });
|
||||
const thresholdBreaches = { cpu: true, memory: false, disk: false, temp: false };
|
||||
const decision = makeDecision({ transition: "threshold_breach", thresholdBreaches });
|
||||
await service.handleIncident(monitor, decision, check);
|
||||
|
||||
expect(notificationMessageBuilder.buildThresholdBreachMessage).toHaveBeenCalledWith(monitor, check, thresholdBreaches);
|
||||
expect(incidentsRepository.create).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
statusCode: 9999,
|
||||
@@ -150,97 +163,14 @@ describe("IncidentService", () => {
|
||||
);
|
||||
});
|
||||
|
||||
it("uses fallback message when monitorStatusResponse is undefined for threshold_breach", 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 () => {
|
||||
it("resolves the active incident on an up transition", async () => {
|
||||
const active = makeIncident();
|
||||
const resolved = makeIncident({ status: false, endTime: "123", resolutionType: "automatic" });
|
||||
const { service, incidentsRepository } = createService();
|
||||
(incidentsRepository.findActiveByMonitorId as jest.Mock).mockResolvedValue(active);
|
||||
(incidentsRepository.updateById as jest.Mock).mockResolvedValue(resolved);
|
||||
|
||||
const result = await service.handleIncident(makeMonitor(), 200, makeDecision({ shouldResolveIncident: true }));
|
||||
const result = await service.handleIncident(makeMonitor(), makeDecision({ transition: "status_up" }), makeCheck());
|
||||
|
||||
expect(result).toBe(resolved);
|
||||
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();
|
||||
(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();
|
||||
});
|
||||
|
||||
@@ -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 type { Monitor } from "../../../src/domain/monitors/monitor.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";
|
||||
|
||||
// ── Helpers ──────────────────────────────────────────────────────────────────
|
||||
@@ -33,7 +33,7 @@ const createSettingsService = (clientHost = "https://app.example.com") => ({
|
||||
|
||||
const createMessageBuilder = () => ({
|
||||
buildMessage: jest.fn().mockReturnValue({ type: "monitor_down", content: { title: "Down" } }),
|
||||
extractThresholdBreaches: jest.fn(),
|
||||
buildThresholdBreachMessage: jest.fn(),
|
||||
});
|
||||
|
||||
const createService = (overrides?: Record<string, unknown>) => {
|
||||
@@ -122,15 +122,21 @@ const makeMonitor = (overrides?: Partial<Monitor>): Monitor =>
|
||||
}) as Monitor;
|
||||
|
||||
const makeDecision = (overrides?: Partial<MonitorActionDecision>): MonitorActionDecision => ({
|
||||
shouldCreateIncident: false,
|
||||
shouldResolveIncident: false,
|
||||
shouldSendNotification: true,
|
||||
incidentReason: null,
|
||||
notificationReason: "status_change",
|
||||
transition: "status_down",
|
||||
...overrides,
|
||||
});
|
||||
|
||||
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 ────────────────────────────────────────────────────────────────────
|
||||
|
||||
@@ -138,9 +144,9 @@ describe("NotificationsService", () => {
|
||||
// ── handleNotifications ───────────────────────────────────────────────────
|
||||
|
||||
describe("handleNotifications", () => {
|
||||
it("returns false when shouldSendNotification is false", async () => {
|
||||
it("returns false when there is no transition", async () => {
|
||||
const { service } = createService();
|
||||
const result = await service.handleNotifications(makeMonitor(), makeStatusResponse(), makeDecision({ shouldSendNotification: false }));
|
||||
const result = await service.handleNotifications(makeMonitor(), makeCheck(), makeDecision({ transition: null }));
|
||||
expect(result).toBe(false);
|
||||
});
|
||||
|
||||
@@ -148,7 +154,7 @@ describe("NotificationsService", () => {
|
||||
const { service, notificationsRepository, emailProvider } = createService();
|
||||
(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(emailProvider.sendMessage).toHaveBeenCalledTimes(1);
|
||||
@@ -160,7 +166,7 @@ describe("NotificationsService", () => {
|
||||
const deps = createService();
|
||||
(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>> = {
|
||||
webhook: deps.webhookProvider,
|
||||
@@ -183,7 +189,7 @@ describe("NotificationsService", () => {
|
||||
const { service, notificationsRepository, logger } = createService();
|
||||
(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(logger.warn).toHaveBeenCalledWith(
|
||||
@@ -197,7 +203,7 @@ describe("NotificationsService", () => {
|
||||
const { service, notificationsRepository, logger } = createService({ notificationMessageBuilder });
|
||||
(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(logger.warn).toHaveBeenCalledWith(expect.objectContaining({ message: "Notification message not provided" }));
|
||||
@@ -207,7 +213,7 @@ describe("NotificationsService", () => {
|
||||
const { service, notificationsRepository } = createService();
|
||||
(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(notificationsRepository.findNotificationsByIds).toHaveBeenCalledWith([]);
|
||||
@@ -222,7 +228,7 @@ describe("NotificationsService", () => {
|
||||
emailProvider.sendMessage.mockResolvedValue(true);
|
||||
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(logger.warn).toHaveBeenCalledWith(expect.objectContaining({ message: expect.stringContaining("1 success, 1 failure") }));
|
||||
@@ -233,7 +239,7 @@ describe("NotificationsService", () => {
|
||||
const { service, notificationsRepository, notificationMessageBuilder } = createService({ settingsService });
|
||||
(notificationsRepository.findNotificationsByIds as jest.Mock).mockResolvedValue([makeNotification()]);
|
||||
|
||||
await service.handleNotifications(makeMonitor(), makeStatusResponse(), makeDecision());
|
||||
await service.handleNotifications(makeMonitor(), makeCheck(), makeDecision());
|
||||
|
||||
expect(notificationMessageBuilder.buildMessage).toHaveBeenCalledWith(
|
||||
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 { ProxyResolver, buildProxyUrl } from "../../../src/service/network/ProxyResolver.ts";
|
||||
import { ProxyResolver, buildProxyUrl } from "../../../src/service/networkProviders/ProxyResolver.ts";
|
||||
import type { Monitor } from "../../../src/domain/monitors/monitor.type.ts";
|
||||
import type { Proxy } from "../../../src/domain/proxies/proxy.type.ts";
|
||||
import { createMockLogger } from "../../helpers/createMockLogger.ts";
|
||||
|
||||
@@ -4,7 +4,7 @@ import { createMockLogger } from "../../helpers/createMockLogger.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 { 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 { IMonitorStatsRepository } from "../../../src/domain/monitor-stats/monitor-stats.repository.interface.ts";
|
||||
|
||||
@@ -98,18 +98,6 @@ const makeMonitor = (overrides?: Partial<Monitor>): Monitor =>
|
||||
...overrides,
|
||||
}) 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 =>
|
||||
({
|
||||
id: "check-1",
|
||||
@@ -143,92 +131,86 @@ const makeExistingStats = (overrides?: Record<string, unknown>) => ({
|
||||
// ── Tests ────────────────────────────────────────────────────────────────────
|
||||
|
||||
describe("StatusService", () => {
|
||||
// ── updateRunningStats ───────────────────────────────────────────────────
|
||||
// ── running stats (via updateMonitorStatus) ──────────────────────────────
|
||||
|
||||
describe("updateRunningStats", () => {
|
||||
// The service now delegates the entire running-stats computation to the repository's
|
||||
// atomic `updateByMonitorId`, so the service-level tests only verify forwarding and error
|
||||
// handling. The math (running average, uptimePercentage, timeOfLastFailure state machine)
|
||||
// is a repository-level concern and is exercised by the repository tests.
|
||||
describe("running stats", () => {
|
||||
// The service delegates the running-stats computation to the repository's atomic `updateByMonitorId`,
|
||||
// so these cases only verify what is forwarded and that a failure never blocks the status update.
|
||||
// The math (running average, uptimePercentage, timeOfLastFailure) 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 () => {
|
||||
const { service, monitorStatsRepository } = createService();
|
||||
(monitorStatsRepository.updateByMonitorId as jest.Mock).mockResolvedValue({});
|
||||
const { monitorStatsRepository } = await run(makeCheck({ responseTime: 50 }));
|
||||
|
||||
const result = await service.updateRunningStats(makeMonitor(), makeStatusResponse({ responseTime: 50 }));
|
||||
|
||||
expect(result).toBe(true);
|
||||
expect(monitorStatsRepository.updateByMonitorId).toHaveBeenCalledWith(
|
||||
"mon-1",
|
||||
expect.objectContaining({
|
||||
status: true,
|
||||
responseTime: 50,
|
||||
now: expect.any(Number),
|
||||
})
|
||||
expect.objectContaining({ status: true, responseTime: 50, now: expect.any(Number) })
|
||||
);
|
||||
});
|
||||
|
||||
it("forwards a failed check to updateByMonitorId", async () => {
|
||||
const { service, monitorStatsRepository } = createService();
|
||||
(monitorStatsRepository.updateByMonitorId as jest.Mock).mockResolvedValue({});
|
||||
const { monitorStatsRepository } = await run(makeCheck({ status: false, responseTime: 100 }));
|
||||
|
||||
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 }));
|
||||
});
|
||||
|
||||
it("passes 0 when responseTime is undefined", async () => {
|
||||
const { service, monitorStatsRepository } = createService();
|
||||
(monitorStatsRepository.updateByMonitorId as jest.Mock).mockResolvedValue({});
|
||||
|
||||
await service.updateRunningStats(makeMonitor(), makeStatusResponse({ responseTime: undefined }));
|
||||
const { monitorStatsRepository } = await run(makeCheck({ responseTime: undefined as any }));
|
||||
|
||||
expect(monitorStatsRepository.updateByMonitorId).toHaveBeenCalledWith("mon-1", expect.objectContaining({ responseTime: 0 }));
|
||||
});
|
||||
|
||||
it("forwards responseTime of 0 as 0 (falsy but defined)", async () => {
|
||||
const { service, monitorStatsRepository } = createService();
|
||||
(monitorStatsRepository.updateByMonitorId as jest.Mock).mockResolvedValue({});
|
||||
|
||||
await service.updateRunningStats(makeMonitor(), makeStatusResponse({ responseTime: 0 }));
|
||||
const { monitorStatsRepository } = await run(makeCheck({ responseTime: 0 }));
|
||||
|
||||
expect(monitorStatsRepository.updateByMonitorId).toHaveBeenCalledWith("mon-1", expect.objectContaining({ responseTime: 0 }));
|
||||
});
|
||||
|
||||
it("coerces a non-true status to false (e.g. undefined)", async () => {
|
||||
const { service, monitorStatsRepository } = createService();
|
||||
(monitorStatsRepository.updateByMonitorId as jest.Mock).mockResolvedValue({});
|
||||
|
||||
await service.updateRunningStats(makeMonitor(), makeStatusResponse({ status: undefined as unknown as boolean }));
|
||||
const { monitorStatsRepository } = await run(makeCheck({ status: undefined as any }));
|
||||
|
||||
expect(monitorStatsRepository.updateByMonitorId).toHaveBeenCalledWith("mon-1", expect.objectContaining({ status: false }));
|
||||
});
|
||||
|
||||
it("returns false and logs error when updateByMonitorId throws", async () => {
|
||||
const { service, logger, monitorStatsRepository } = createService();
|
||||
(monitorStatsRepository.updateByMonitorId as jest.Mock).mockRejectedValue(new Error("db write failed"));
|
||||
it("warns once and still updates the status when updateByMonitorId throws", 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);
|
||||
(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(logger.error).toHaveBeenCalledWith(
|
||||
expect(result.monitor.status).toBe("up");
|
||||
expect(monitorsRepository.updateStatusWindowAndChecks).toHaveBeenCalledTimes(1);
|
||||
expect(logger.warn).toHaveBeenCalledTimes(1);
|
||||
expect(logger.warn).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
service: "StatusService",
|
||||
method: "updateRunningStats",
|
||||
message: "db write failed",
|
||||
method: "tryUpdateRunningStats",
|
||||
message: "stats db down",
|
||||
stack: expect.any(String),
|
||||
})
|
||||
);
|
||||
expect(logger.error).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("logs error with 'Unknown error' for non-Error exceptions", async () => {
|
||||
const { service, logger, monitorStatsRepository } = createService();
|
||||
it("warns with 'Unknown error' for non-Error rejections", async () => {
|
||||
const monitor = makeMonitor();
|
||||
const { service, logger, monitorsRepository, monitorStatsRepository } = createService();
|
||||
(monitorsRepository.findById as jest.Mock).mockResolvedValue(monitor);
|
||||
(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.error).toHaveBeenCalledWith(expect.objectContaining({ message: "Unknown error", stack: undefined }));
|
||||
expect(logger.warn).toHaveBeenCalledWith(expect.objectContaining({ message: "Unknown error", stack: undefined }));
|
||||
});
|
||||
});
|
||||
|
||||
@@ -241,7 +223,7 @@ describe("StatusService", () => {
|
||||
(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({ status: true }), makeCheck(), monitor);
|
||||
const result = await service.updateMonitorStatus(makeCheck({ status: true }), monitor);
|
||||
|
||||
expect(result.statusChanged).toBe(false);
|
||||
expect(result.prevStatus).toBe("up");
|
||||
@@ -252,7 +234,7 @@ describe("StatusService", () => {
|
||||
const { service, monitorsRepository } = createService();
|
||||
(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(result.prevStatus).toBe("up");
|
||||
@@ -265,7 +247,7 @@ describe("StatusService", () => {
|
||||
(monitorsRepository.findById as jest.Mock).mockResolvedValue(monitor);
|
||||
(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
|
||||
expect(monitorsRepository.updateStatusWindowAndChecks).toHaveBeenCalledWith(
|
||||
@@ -286,7 +268,7 @@ describe("StatusService", () => {
|
||||
(monitorsRepository.findById as jest.Mock).mockResolvedValue(monitor);
|
||||
(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[MAX_RECENT_CHECKS - 1]).toEqual(expect.objectContaining({ id: "new-check" }));
|
||||
@@ -304,7 +286,7 @@ describe("StatusService", () => {
|
||||
(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({ status: false }), makeCheck({ status: false }), monitor);
|
||||
const result = await service.updateMonitorStatus(makeCheck({ status: false }), monitor);
|
||||
|
||||
expect(result.statusChanged).toBe(true);
|
||||
expect(result.monitor.status).toBe("down");
|
||||
@@ -322,7 +304,7 @@ describe("StatusService", () => {
|
||||
(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({ status: true }), makeCheck({ status: true }), monitor);
|
||||
const result = await service.updateMonitorStatus(makeCheck({ status: true }), monitor);
|
||||
|
||||
expect(result.statusChanged).toBe(true);
|
||||
expect(result.monitor.status).toBe("up");
|
||||
@@ -339,7 +321,7 @@ describe("StatusService", () => {
|
||||
(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({ status: true }), makeCheck(), monitor);
|
||||
const result = await service.updateMonitorStatus(makeCheck({ status: true }), monitor);
|
||||
|
||||
expect(result.statusChanged).toBe(false);
|
||||
expect(result.monitor.status).toBe("up");
|
||||
@@ -359,7 +341,7 @@ describe("StatusService", () => {
|
||||
(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({ status: false }), makeCheck({ status: false }), monitor);
|
||||
const result = await service.updateMonitorStatus(makeCheck({ status: false }), monitor);
|
||||
|
||||
expect(result.statusChanged).toBe(false);
|
||||
expect(result.monitor.status).toBe("up");
|
||||
@@ -376,7 +358,7 @@ describe("StatusService", () => {
|
||||
(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({ status: false }), makeCheck({ status: false }), monitor);
|
||||
const result = await service.updateMonitorStatus(makeCheck({ status: false }), monitor);
|
||||
|
||||
expect(result.statusChanged).toBe(false);
|
||||
expect(result.monitor.status).toBe("down");
|
||||
@@ -388,7 +370,7 @@ describe("StatusService", () => {
|
||||
(monitorsRepository.findById as jest.Mock).mockResolvedValue(monitor);
|
||||
(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]);
|
||||
});
|
||||
@@ -399,7 +381,7 @@ describe("StatusService", () => {
|
||||
(monitorsRepository.findById as jest.Mock).mockResolvedValue(monitor);
|
||||
(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);
|
||||
});
|
||||
@@ -409,7 +391,7 @@ describe("StatusService", () => {
|
||||
const { service, monitorsRepository } = createService();
|
||||
(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 () => {
|
||||
@@ -417,7 +399,7 @@ describe("StatusService", () => {
|
||||
const { service, monitorsRepository } = createService();
|
||||
(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 () => {
|
||||
@@ -435,7 +417,7 @@ describe("StatusService", () => {
|
||||
(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({ status: true }), makeCheck({ status: true }), monitor);
|
||||
const result = await service.updateMonitorStatus(makeCheck({ status: true }), monitor);
|
||||
|
||||
expect(result.statusChanged).toBe(false);
|
||||
expect(result.monitor.status).toBe("down");
|
||||
@@ -453,7 +435,7 @@ describe("StatusService", () => {
|
||||
(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({ status: false }), makeCheck({ status: false }), monitor);
|
||||
const result = await service.updateMonitorStatus(makeCheck({ status: false }), monitor);
|
||||
|
||||
expect(result.statusChanged).toBe(true);
|
||||
expect(result.monitor.status).toBe("down");
|
||||
@@ -475,7 +457,7 @@ describe("StatusService", () => {
|
||||
(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({ status: true }), makeCheck({ status: true }), monitor);
|
||||
const result = await service.updateMonitorStatus(makeCheck({ status: true }), monitor);
|
||||
|
||||
expect(result.monitor.status).toBe("up");
|
||||
});
|
||||
@@ -493,7 +475,7 @@ describe("StatusService", () => {
|
||||
(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({ status: false }), makeCheck({ status: false }), monitor);
|
||||
const result = await service.updateMonitorStatus(makeCheck({ status: false }), monitor);
|
||||
|
||||
expect(result.monitor.status).toBe("down");
|
||||
});
|
||||
@@ -514,7 +496,7 @@ describe("StatusService", () => {
|
||||
(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({ status: true }), makeCheck({ status: true }), monitor);
|
||||
const result = await service.updateMonitorStatus(makeCheck({ status: true }), monitor);
|
||||
|
||||
expect(result.monitor.status).toBe("up");
|
||||
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.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.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.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.monitor.status).toBe("up");
|
||||
@@ -565,7 +547,7 @@ describe("StatusService", () => {
|
||||
(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({ status: false }), makeCheck({ status: false }), monitor);
|
||||
const result = await service.updateMonitorStatus(makeCheck({ status: false }), monitor);
|
||||
|
||||
expect(result.statusChanged).toBe(true);
|
||||
expect(result.monitor.status).toBe("down");
|
||||
@@ -580,7 +562,7 @@ describe("StatusService", () => {
|
||||
(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({ status: false }), makeCheck({ status: false }), monitor);
|
||||
const result = await service.updateMonitorStatus(makeCheck({ status: false }), monitor);
|
||||
|
||||
expect(result.statusChanged).toBe(true);
|
||||
expect(result.monitor.status).toBe("down");
|
||||
@@ -596,7 +578,7 @@ describe("StatusService", () => {
|
||||
const { service, monitorsRepository } = createService();
|
||||
(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.monitor.status).toBe("up");
|
||||
@@ -611,7 +593,7 @@ describe("StatusService", () => {
|
||||
const { service, monitorsRepository } = createService();
|
||||
(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.monitor.status).toBe("down");
|
||||
@@ -631,7 +613,7 @@ describe("StatusService", () => {
|
||||
const { service, monitorsRepository } = createService();
|
||||
(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");
|
||||
});
|
||||
@@ -646,43 +628,12 @@ describe("StatusService", () => {
|
||||
(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({ status: true }), makeCheck({ status: true }), monitor);
|
||||
const result = await service.updateMonitorStatus(makeCheck({ status: true }), monitor);
|
||||
|
||||
expect(result.statusChanged).toBe(false);
|
||||
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 ──────────────────────────────────
|
||||
|
||||
describe("hardware threshold breaches", () => {
|
||||
@@ -703,13 +654,12 @@ describe("StatusService", () => {
|
||||
...overrides,
|
||||
});
|
||||
|
||||
const makeHardwareResponse = (payload: HardwareStatusPayload, overrides?: Partial<MonitorStatusResponse<HardwareStatusPayload>>) =>
|
||||
makeStatusResponse({
|
||||
type: "hardware",
|
||||
const makeHardwareCheck = (metrics: Pick<Check, "cpu" | "memory" | "disk" | "host">, overrides?: Partial<Check>) =>
|
||||
makeCheck({
|
||||
status: true,
|
||||
payload,
|
||||
...metrics,
|
||||
...overrides,
|
||||
} as any) as MonitorStatusResponse<HardwareStatusPayload>;
|
||||
});
|
||||
|
||||
it("detects CPU threshold breach and decrements counter", async () => {
|
||||
const monitor = makeHardwareMonitor({ cpuAlertCounter: 1 });
|
||||
@@ -717,8 +667,8 @@ describe("StatusService", () => {
|
||||
(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({ data: { cpu: { usage_percent: 0.9 }, memory: { usage_percent: 0.5 }, disk: [], host: {} } } as any);
|
||||
const result = await service.updateMonitorStatus(response, makeCheck(), monitor);
|
||||
const check = makeHardwareCheck({ cpu: { usage_percent: 0.9 }, memory: { usage_percent: 0.5 }, disk: [], host: {} });
|
||||
const result = await service.updateMonitorStatus(check, monitor);
|
||||
|
||||
expect(result.thresholdBreaches?.cpu).toBe(true);
|
||||
expect(result.thresholdBreaches?.memory).toBe(false);
|
||||
@@ -734,8 +684,8 @@ describe("StatusService", () => {
|
||||
(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({ data: { cpu: { usage_percent: 0.5 }, memory: { usage_percent: 0.9 }, disk: [], host: {} } } as any);
|
||||
const result = await service.updateMonitorStatus(response, makeCheck(), monitor);
|
||||
const check = makeHardwareCheck({ cpu: { usage_percent: 0.5 }, memory: { usage_percent: 0.9 }, disk: [], host: {} });
|
||||
const result = await service.updateMonitorStatus(check, monitor);
|
||||
|
||||
expect(result.thresholdBreaches?.memory).toBe(true);
|
||||
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.updateById as jest.Mock).mockImplementation((_id: unknown, _tid: unknown, m: unknown) => Promise.resolve(m));
|
||||
|
||||
const response = makeHardwareResponse({
|
||||
data: { cpu: { usage_percent: 0.1 }, memory: { usage_percent: 0.1 }, disk: [{ usage_percent: 0.95 }], host: {} },
|
||||
} as any);
|
||||
const result = await service.updateMonitorStatus(response, makeCheck(), monitor);
|
||||
const check = makeHardwareCheck({ cpu: { usage_percent: 0.1 }, memory: { usage_percent: 0.1 }, disk: [{ usage_percent: 0.95 }], host: {} });
|
||||
const result = await service.updateMonitorStatus(check, monitor);
|
||||
|
||||
expect(result.thresholdBreaches?.disk).toBe(true);
|
||||
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.updateById as jest.Mock).mockImplementation((_id: unknown, _tid: unknown, m: unknown) => Promise.resolve(m));
|
||||
|
||||
const response = makeHardwareResponse({
|
||||
data: { cpu: { usage_percent: 0.1, temperature: [90] }, memory: { usage_percent: 0.1 }, disk: [], host: {} },
|
||||
} as any);
|
||||
const result = await service.updateMonitorStatus(response, makeCheck(), monitor);
|
||||
const check = makeHardwareCheck({ cpu: { usage_percent: 0.1, temperature: [90] }, memory: { usage_percent: 0.1 }, disk: [], host: {} });
|
||||
const result = await service.updateMonitorStatus(check, monitor);
|
||||
|
||||
expect(result.thresholdBreaches?.temp).toBe(true);
|
||||
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.updateById as jest.Mock).mockImplementation((_id: unknown, _tid: unknown, m: unknown) => Promise.resolve(m));
|
||||
|
||||
const response = makeHardwareResponse({
|
||||
data: { cpu: { usage_percent: 0.1, temperature: [30] }, memory: { usage_percent: 0.1 }, disk: [{ usage_percent: 0.1 }], host: {} },
|
||||
} as any);
|
||||
await service.updateMonitorStatus(response, makeCheck(), monitor);
|
||||
const check = makeHardwareCheck({
|
||||
cpu: { usage_percent: 0.1, temperature: [30] },
|
||||
memory: { usage_percent: 0.1 },
|
||||
disk: [{ usage_percent: 0.1 }],
|
||||
host: {},
|
||||
});
|
||||
await service.updateMonitorStatus(check, monitor);
|
||||
|
||||
const patch = (monitorsRepository.updateStatusWindowAndChecks as jest.Mock).mock.calls.at(-1)?.[6];
|
||||
expect(patch.cpuAlertCounter).toBe(5);
|
||||
@@ -809,8 +758,8 @@ describe("StatusService", () => {
|
||||
(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({ data: { cpu: { usage_percent: 0.9 }, memory: { usage_percent: 0.1 }, disk: [], host: {} } } as any);
|
||||
const result = await service.updateMonitorStatus(response, makeCheck(), monitor);
|
||||
const check = makeHardwareCheck({ cpu: { usage_percent: 0.9 }, memory: { usage_percent: 0.1 }, disk: [], host: {} });
|
||||
const result = await service.updateMonitorStatus(check, monitor);
|
||||
|
||||
expect(result.monitor.status).toBe("breached");
|
||||
expect(result.statusChanged).toBe(false);
|
||||
@@ -822,10 +771,13 @@ describe("StatusService", () => {
|
||||
(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({
|
||||
data: { cpu: { usage_percent: 0.1, temperature: [30] }, memory: { usage_percent: 0.1 }, disk: [{ usage_percent: 0.1 }], host: {} },
|
||||
} as any);
|
||||
const result = await service.updateMonitorStatus(response, makeCheck(), monitor);
|
||||
const check = makeHardwareCheck({
|
||||
cpu: { usage_percent: 0.1, temperature: [30] },
|
||||
memory: { usage_percent: 0.1 },
|
||||
disk: [{ usage_percent: 0.1 }],
|
||||
host: {},
|
||||
});
|
||||
const result = await service.updateMonitorStatus(check, monitor);
|
||||
|
||||
expect(result.statusChanged).toBe(true);
|
||||
expect(result.monitor.status).toBe("up");
|
||||
@@ -844,11 +796,8 @@ describe("StatusService", () => {
|
||||
(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({
|
||||
status: false,
|
||||
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);
|
||||
const check = makeHardwareCheck({ cpu: { usage_percent: 0.9 }, memory: { usage_percent: 0.1 }, disk: [], host: {} }, { status: false });
|
||||
const result = await service.updateMonitorStatus(check, monitor);
|
||||
|
||||
expect(result.monitor.status).toBe("down");
|
||||
});
|
||||
@@ -859,8 +808,8 @@ describe("StatusService", () => {
|
||||
(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({ data: { cpu: {}, memory: { usage_percent: 0.1 }, disk: [], host: {} } } as any);
|
||||
const result = await service.updateMonitorStatus(response, makeCheck(), monitor);
|
||||
const check = makeHardwareCheck({ cpu: {}, memory: { usage_percent: 0.1 }, disk: [], host: {} });
|
||||
const result = await service.updateMonitorStatus(check, monitor);
|
||||
|
||||
expect(result.thresholdBreaches?.cpu).toBe(false);
|
||||
});
|
||||
@@ -871,8 +820,8 @@ describe("StatusService", () => {
|
||||
(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({ data: { cpu: { usage_percent: 0.1 }, memory: {}, disk: [], host: {} } } as any);
|
||||
const result = await service.updateMonitorStatus(response, makeCheck(), monitor);
|
||||
const check = makeHardwareCheck({ cpu: { usage_percent: 0.1 }, memory: {}, disk: [], host: {} });
|
||||
const result = await service.updateMonitorStatus(check, monitor);
|
||||
|
||||
expect(result.thresholdBreaches?.memory).toBe(false);
|
||||
});
|
||||
@@ -883,10 +832,8 @@ describe("StatusService", () => {
|
||||
(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({
|
||||
data: { cpu: { usage_percent: 0.1, temperature: [] }, memory: { usage_percent: 0.1 }, disk: [], host: {} },
|
||||
} as any);
|
||||
const result = await service.updateMonitorStatus(response, makeCheck(), monitor);
|
||||
const check = makeHardwareCheck({ cpu: { usage_percent: 0.1, temperature: [] }, memory: { usage_percent: 0.1 }, disk: [], host: {} });
|
||||
const result = await service.updateMonitorStatus(check, monitor);
|
||||
|
||||
expect(result.thresholdBreaches?.temp).toBe(false);
|
||||
});
|
||||
@@ -897,36 +844,25 @@ describe("StatusService", () => {
|
||||
(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({
|
||||
data: { cpu: { usage_percent: 0.1 }, memory: { usage_percent: 0.1 }, disk: [], host: {} },
|
||||
} as any);
|
||||
const result = await service.updateMonitorStatus(response, makeCheck(), monitor);
|
||||
const check = makeHardwareCheck({ cpu: { usage_percent: 0.1 }, memory: { usage_percent: 0.1 }, disk: [], host: {} });
|
||||
const result = await service.updateMonitorStatus(check, monitor);
|
||||
|
||||
expect(result.thresholdBreaches?.temp).toBe(false);
|
||||
});
|
||||
|
||||
it("skips threshold evaluation when payload is undefined", async () => {
|
||||
const monitor = makeHardwareMonitor();
|
||||
it("reports no breaches when the check carries no hardware metrics", async () => {
|
||||
// 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();
|
||||
(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(undefined as any);
|
||||
const result = await service.updateMonitorStatus(response, makeCheck(), monitor);
|
||||
const result = await service.updateMonitorStatus(makeCheck(), monitor);
|
||||
|
||||
expect(result.thresholdBreaches).toBeUndefined();
|
||||
});
|
||||
|
||||
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();
|
||||
expect(result.thresholdBreaches).toEqual({ cpu: false, memory: false, disk: false, temp: false });
|
||||
expect(result.statusChanged).toBe(false);
|
||||
expect(result.monitor.status).toBe("up");
|
||||
});
|
||||
|
||||
it("does not set thresholdBreaches for non-hardware monitors", async () => {
|
||||
@@ -939,7 +875,7 @@ describe("StatusService", () => {
|
||||
(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(), makeCheck(), monitor);
|
||||
const result = await service.updateMonitorStatus(makeCheck(), monitor);
|
||||
|
||||
expect(result.thresholdBreaches).toBeUndefined();
|
||||
});
|
||||
@@ -950,10 +886,8 @@ describe("StatusService", () => {
|
||||
(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({
|
||||
data: { cpu: { usage_percent: 0.1 }, memory: { usage_percent: 0.1 }, disk: [null as any], host: {} },
|
||||
} as any);
|
||||
const result = await service.updateMonitorStatus(response, makeCheck(), monitor);
|
||||
const check = makeHardwareCheck({ cpu: { usage_percent: 0.1 }, memory: { usage_percent: 0.1 }, disk: [null as any], host: {} });
|
||||
const result = await service.updateMonitorStatus(check, monitor);
|
||||
|
||||
expect(result.thresholdBreaches?.disk).toBe(false);
|
||||
});
|
||||
@@ -964,10 +898,8 @@ describe("StatusService", () => {
|
||||
(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({
|
||||
data: { cpu: { usage_percent: 0.1 }, memory: { usage_percent: 0.1 }, disk: [{ device: "/dev/sda" }], host: {} },
|
||||
} as any);
|
||||
const result = await service.updateMonitorStatus(response, makeCheck(), monitor);
|
||||
const check = makeHardwareCheck({ cpu: { usage_percent: 0.1 }, memory: { usage_percent: 0.1 }, disk: [{ device: "/dev/sda" }], host: {} });
|
||||
const result = await service.updateMonitorStatus(check, monitor);
|
||||
|
||||
expect(result.thresholdBreaches?.disk).toBe(false);
|
||||
});
|
||||
@@ -978,10 +910,8 @@ describe("StatusService", () => {
|
||||
(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({
|
||||
data: { cpu: undefined, memory: { usage_percent: 0.1 }, disk: [], host: {} },
|
||||
} as any);
|
||||
const result = await service.updateMonitorStatus(response, makeCheck(), monitor);
|
||||
const check = makeHardwareCheck({ cpu: undefined, memory: { usage_percent: 0.1 }, disk: [], host: {} });
|
||||
const result = await service.updateMonitorStatus(check, monitor);
|
||||
|
||||
expect(result.thresholdBreaches?.cpu).toBe(false);
|
||||
expect(result.thresholdBreaches?.temp).toBe(false);
|
||||
@@ -993,10 +923,8 @@ describe("StatusService", () => {
|
||||
(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({
|
||||
data: { cpu: { usage_percent: 0.1 }, memory: undefined, disk: [], host: {} },
|
||||
} as any);
|
||||
const result = await service.updateMonitorStatus(response, makeCheck(), monitor);
|
||||
const check = makeHardwareCheck({ cpu: { usage_percent: 0.1 }, memory: undefined, disk: [], host: {} });
|
||||
const result = await service.updateMonitorStatus(check, monitor);
|
||||
|
||||
expect(result.thresholdBreaches?.memory).toBe(false);
|
||||
});
|
||||
@@ -1007,10 +935,8 @@ describe("StatusService", () => {
|
||||
(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({
|
||||
data: { cpu: { usage_percent: 0.1 }, memory: { usage_percent: 0.1 }, disk: undefined, host: {} },
|
||||
} as any);
|
||||
const result = await service.updateMonitorStatus(response, makeCheck(), monitor);
|
||||
const check = makeHardwareCheck({ cpu: { usage_percent: 0.1 }, memory: { usage_percent: 0.1 }, disk: undefined, host: {} });
|
||||
const result = await service.updateMonitorStatus(check, monitor);
|
||||
|
||||
expect(result.thresholdBreaches?.disk).toBe(false);
|
||||
});
|
||||
@@ -1023,8 +949,8 @@ describe("StatusService", () => {
|
||||
(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({ data: { cpu: { usage_percent: 0.9 }, memory: { usage_percent: 0.1 }, disk: [], host: {} } } as any);
|
||||
const result = await service.updateMonitorStatus(response, makeCheck(), monitor);
|
||||
const check = makeHardwareCheck({ cpu: { usage_percent: 0.9 }, memory: { usage_percent: 0.1 }, disk: [], host: {} });
|
||||
const result = await service.updateMonitorStatus(check, monitor);
|
||||
|
||||
const patch = (monitorsRepository.updateStatusWindowAndChecks as jest.Mock).mock.calls.at(-1)?.[6];
|
||||
expect(patch.cpuAlertCounter).toBe(2);
|
||||
@@ -1046,11 +972,8 @@ describe("StatusService", () => {
|
||||
(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({
|
||||
status: false,
|
||||
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);
|
||||
const check = makeHardwareCheck({ cpu: { usage_percent: 0.1 }, memory: { usage_percent: 0.1 }, disk: [], host: {} }, { status: false });
|
||||
const result = await service.updateMonitorStatus(check, monitor);
|
||||
|
||||
expect(result.statusChanged).toBe(true);
|
||||
expect(result.monitor.status).toBe("down");
|
||||
@@ -1067,10 +990,13 @@ describe("StatusService", () => {
|
||||
(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({
|
||||
data: { cpu: { usage_percent: 0.9, temperature: [90] }, memory: { usage_percent: 0.9 }, disk: [{ usage_percent: 0.95 }], host: {} },
|
||||
} as any);
|
||||
await service.updateMonitorStatus(response, makeCheck(), monitor);
|
||||
const check = makeHardwareCheck({
|
||||
cpu: { usage_percent: 0.9, temperature: [90] },
|
||||
memory: { usage_percent: 0.9 },
|
||||
disk: [{ usage_percent: 0.95 }],
|
||||
host: {},
|
||||
});
|
||||
await service.updateMonitorStatus(check, monitor);
|
||||
|
||||
expect(monitor.cpuAlertCounter).toBe(0);
|
||||
expect(monitor.memoryAlertCounter).toBe(0);
|
||||
@@ -1099,8 +1025,8 @@ describe("StatusService", () => {
|
||||
...overrides,
|
||||
});
|
||||
|
||||
const makeContainers = (containers: Partial<DockerStatusPayload["containers"][number]>[]): DockerStatusPayload => ({
|
||||
containers: containers.map((container, index) => ({
|
||||
const makeContainers = (containers: Partial<DockerContainerInfo>[]): DockerContainerInfo[] =>
|
||||
containers.map((container, index) => ({
|
||||
id: `container-${index}`,
|
||||
name: `container-${index}`,
|
||||
image: "nginx:latest",
|
||||
@@ -1108,23 +1034,20 @@ describe("StatusService", () => {
|
||||
status: "Up 3 hours",
|
||||
health: "none",
|
||||
...container,
|
||||
})),
|
||||
summary: { total: containers.length, running: 0, stopped: 0, unhealthy: 0 },
|
||||
});
|
||||
}));
|
||||
|
||||
const makeDockerResponse = (payload: DockerStatusPayload | null, overrides?: Partial<MonitorStatusResponse<DockerStatusPayload>>) =>
|
||||
makeStatusResponse({
|
||||
type: "docker",
|
||||
const makeDockerCheck = (containers: DockerContainerInfo[] | null, overrides?: Partial<Check>) =>
|
||||
makeCheck({
|
||||
status: true,
|
||||
payload,
|
||||
...(containers ? { containers } : {}),
|
||||
...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();
|
||||
(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(response, check, monitor);
|
||||
const result = await service.updateMonitorStatus(check, monitor);
|
||||
return { result, monitorsRepository };
|
||||
};
|
||||
|
||||
@@ -1132,14 +1055,14 @@ describe("StatusService", () => {
|
||||
const unhealthy = { name: "api", health: "unhealthy" as const };
|
||||
|
||||
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.monitor.status).toBe("breached");
|
||||
});
|
||||
|
||||
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.monitor.status).toBe("breached");
|
||||
@@ -1147,7 +1070,7 @@ describe("StatusService", () => {
|
||||
|
||||
it("stays up on a stopped container when both switches are off", async () => {
|
||||
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.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 () => {
|
||||
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.monitor.status).toBe("up");
|
||||
});
|
||||
|
||||
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.monitor.status).toBe("up");
|
||||
});
|
||||
|
||||
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.monitor.status).toBe("breached");
|
||||
});
|
||||
|
||||
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.monitor.status).toBe("up");
|
||||
});
|
||||
|
||||
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.prevStatus).toBe("breached");
|
||||
@@ -1192,7 +1115,7 @@ describe("StatusService", () => {
|
||||
|
||||
it("recovers from breached to up when the breaching switch is turned off", async () => {
|
||||
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.monitor.status).toBe("up");
|
||||
@@ -1200,16 +1123,16 @@ describe("StatusService", () => {
|
||||
|
||||
it("does not override down with breached when the host is unreachable", async () => {
|
||||
const monitor = makeDockerMonitor({ statusWindow: [false, false, false, false], statusWindowThreshold: 80 });
|
||||
const response = makeDockerResponse(null, { status: false, code: 5000 });
|
||||
const { result } = await run(monitor, response, makeCheck({ status: false }));
|
||||
const check = makeDockerCheck(null, { status: false, statusCode: 5000 });
|
||||
const { result } = await run(monitor, check);
|
||||
|
||||
expect(result.monitor.status).toBe("down");
|
||||
});
|
||||
|
||||
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 response = makeDockerResponse(null, { status: false, code: 5000 });
|
||||
const { result } = await run(monitor, response, makeCheck({ status: false }));
|
||||
const check = makeDockerCheck(null, { status: false, statusCode: 5000 });
|
||||
const { result } = await run(monitor, check);
|
||||
|
||||
expect(result.statusChanged).toBe(true);
|
||||
expect(result.monitor.status).toBe("down");
|
||||
@@ -1217,7 +1140,7 @@ describe("StatusService", () => {
|
||||
|
||||
it("does not evaluate containers while the status window is short", async () => {
|
||||
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.monitor.status).toBe("up");
|
||||
@@ -1225,15 +1148,15 @@ describe("StatusService", () => {
|
||||
|
||||
it("does not evaluate a failed check with a null payload", async () => {
|
||||
const monitor = makeDockerMonitor({ status: "breached" });
|
||||
const response = makeDockerResponse(null, { status: false, code: 5000 });
|
||||
const { result } = await run(monitor, response, makeCheck({ status: false }));
|
||||
const check = makeDockerCheck(null, { status: false, statusCode: 5000 });
|
||||
const { result } = await run(monitor, check);
|
||||
|
||||
expect(result.statusChanged).toBe(false);
|
||||
expect(result.monitor.status).toBe("breached");
|
||||
});
|
||||
|
||||
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();
|
||||
const patch = (monitorsRepository.updateStatusWindowAndChecks as jest.Mock).mock.calls.at(-1)?.[6];
|
||||
|
||||
@@ -64,8 +64,6 @@ const makeStatusChange = (overrides: Partial<StatusChangeResult> & { status?: st
|
||||
monitor: { id: "m1", status } as any,
|
||||
statusChanged: false,
|
||||
prevStatus: "up",
|
||||
code: 200,
|
||||
timestamp: 0,
|
||||
...rest,
|
||||
} as StatusChangeResult;
|
||||
};
|
||||
@@ -92,10 +90,9 @@ const createPipeline = (overrides?: Record<string, any>) => {
|
||||
},
|
||||
checkService: {
|
||||
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([]),
|
||||
},
|
||||
networkService: { requestStatus: jest.fn<any>().mockResolvedValue({ monitorId: "m1", status: true, code: 200, message: "OK" }) },
|
||||
providerRegistry: { probe: jest.fn<any>().mockResolvedValue({ monitorId: "m1", status: true, code: 200, message: "OK" }) },
|
||||
proxyResolver: { resolve: jest.fn<any>().mockResolvedValue(undefined) },
|
||||
bufferService: { addToBuffer: jest.fn<any>(), addDockerLogToBuffer: jest.fn<any>() },
|
||||
dockerLogsService: { buildDockerLogs: jest.fn<any>().mockResolvedValue([]) },
|
||||
@@ -118,7 +115,7 @@ describe("WorkerPipeline", () => {
|
||||
await pipeline.handleCheck(makeJob());
|
||||
|
||||
expect(deps.monitorsRepository.findByIdLean).toHaveBeenCalledWith("m1");
|
||||
expect(deps.networkService.requestStatus).toHaveBeenCalled();
|
||||
expect(deps.providerRegistry.probe).toHaveBeenCalled();
|
||||
expect(deps.bufferService.addToBuffer).toHaveBeenCalledWith({ id: "check-1" });
|
||||
});
|
||||
|
||||
@@ -128,7 +125,7 @@ describe("WorkerPipeline", () => {
|
||||
await pipeline.handleCheck(makeJob({ refId: null }));
|
||||
|
||||
expect(deps.monitorsRepository.findByIdLean).not.toHaveBeenCalled();
|
||||
expect(deps.networkService.requestStatus).not.toHaveBeenCalled();
|
||||
expect(deps.providerRegistry.probe).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("does nothing when the monitor no longer exists", async () => {
|
||||
@@ -138,7 +135,7 @@ describe("WorkerPipeline", () => {
|
||||
|
||||
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(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();
|
||||
});
|
||||
|
||||
@@ -188,7 +185,7 @@ describe("WorkerPipeline", () => {
|
||||
// The exit used to be a side effect of evaluating the resulting check, which the degraded-egress
|
||||
// short-circuit skips. Left implicit, a monitor could sit in "maintenance" for a whole egress outage.
|
||||
expect(deps.monitorsRepository.updateById).toHaveBeenCalledWith("m1", "team", { status: "initializing" });
|
||||
expect(deps.networkService.requestStatus).toHaveBeenCalled();
|
||||
expect(deps.providerRegistry.probe).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("does not touch the status of a monitor that was not in maintenance", async () => {
|
||||
@@ -206,12 +203,12 @@ describe("WorkerPipeline", () => {
|
||||
|
||||
await pipeline.produce(makeMonitor());
|
||||
|
||||
expect(deps.networkService.requestStatus).toHaveBeenCalled();
|
||||
expect(deps.providerRegistry.probe).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
// ── proxy resolution ──────────────────────────────────────────────────
|
||||
|
||||
it("passes the resolved proxy url into requestStatus", async () => {
|
||||
it("passes the resolved proxy url into probe", async () => {
|
||||
const { pipeline, deps } = createPipeline({
|
||||
proxyResolver: { resolve: jest.fn<any>().mockResolvedValue("http://proxy.example.com:8080") },
|
||||
});
|
||||
@@ -219,7 +216,7 @@ describe("WorkerPipeline", () => {
|
||||
|
||||
await pipeline.produce(monitor);
|
||||
|
||||
expect(deps.networkService.requestStatus).toHaveBeenCalledWith(monitor, { proxyUrl: "http://proxy.example.com:8080" });
|
||||
expect(deps.providerRegistry.probe).toHaveBeenCalledWith(monitor, { proxyUrl: "http://proxy.example.com:8080" });
|
||||
});
|
||||
|
||||
it("still produces a check when the resolver returns undefined", async () => {
|
||||
@@ -228,7 +225,7 @@ describe("WorkerPipeline", () => {
|
||||
|
||||
const result = await pipeline.produce(monitor);
|
||||
|
||||
expect(deps.networkService.requestStatus).toHaveBeenCalledWith(monitor, { proxyUrl: undefined });
|
||||
expect(deps.providerRegistry.probe).toHaveBeenCalledWith(monitor, { proxyUrl: undefined });
|
||||
expect(result).toEqual({ status: expect.objectContaining({ monitorId: "m1" }), check: { id: "check-1" } });
|
||||
});
|
||||
|
||||
@@ -243,7 +240,7 @@ describe("WorkerPipeline", () => {
|
||||
await pipeline.produce(monitor);
|
||||
|
||||
expect(deps.monitorsRepository.findDockerTlsKeyById).toHaveBeenCalledWith("m1");
|
||||
expect(deps.networkService.requestStatus).toHaveBeenCalledWith(monitor, { proxyUrl: undefined, dockerTlsKey: "v1.abc123.iv.tag.data" });
|
||||
expect(deps.providerRegistry.probe).toHaveBeenCalledWith(monitor, { proxyUrl: undefined, dockerTlsKey: "v1.abc123.iv.tag.data" });
|
||||
});
|
||||
|
||||
it("does not fetch a key for a docker monitor without one set", async () => {
|
||||
@@ -253,7 +250,7 @@ describe("WorkerPipeline", () => {
|
||||
await pipeline.produce(monitor);
|
||||
|
||||
expect(deps.monitorsRepository.findDockerTlsKeyById).not.toHaveBeenCalled();
|
||||
expect(deps.networkService.requestStatus).toHaveBeenCalledWith(monitor, { proxyUrl: undefined, dockerTlsKey: undefined });
|
||||
expect(deps.providerRegistry.probe).toHaveBeenCalledWith(monitor, { proxyUrl: undefined, dockerTlsKey: undefined });
|
||||
});
|
||||
|
||||
it("does not fetch a key for non-docker monitors even if the flag is set", async () => {
|
||||
@@ -271,14 +268,14 @@ describe("WorkerPipeline", () => {
|
||||
await pipeline.produce(monitor);
|
||||
|
||||
expect(deps.monitorsRepository.findDockerTlsKeyById).toHaveBeenCalledWith("m1");
|
||||
expect(deps.networkService.requestStatus).toHaveBeenCalledWith(monitor, { proxyUrl: undefined, dockerTlsKey: undefined });
|
||||
expect(deps.providerRegistry.probe).toHaveBeenCalledWith(monitor, { proxyUrl: undefined, dockerTlsKey: undefined });
|
||||
});
|
||||
|
||||
// ── acquire / record ──────────────────────────────────────────────────
|
||||
|
||||
it("throws when the network response is null", async () => {
|
||||
const { pipeline } = createPipeline({
|
||||
networkService: { requestStatus: jest.fn<any>().mockResolvedValue(null) },
|
||||
providerRegistry: { probe: jest.fn<any>().mockResolvedValue(null) },
|
||||
});
|
||||
|
||||
await expect(pipeline.produce(makeMonitor())).rejects.toThrow("No network response");
|
||||
@@ -300,7 +297,7 @@ describe("WorkerPipeline", () => {
|
||||
const status = { monitorId: "m1", status: false, code: 500, message: "Error" };
|
||||
const check = { id: "check-1" };
|
||||
const { pipeline, deps } = createPipeline({
|
||||
networkService: { requestStatus: jest.fn<any>().mockResolvedValue(status) },
|
||||
providerRegistry: { probe: jest.fn<any>().mockResolvedValue(status) },
|
||||
checkService: { toCheck: jest.fn<any>().mockReturnValue(check) },
|
||||
});
|
||||
|
||||
@@ -314,7 +311,7 @@ describe("WorkerPipeline", () => {
|
||||
const status = { type: "docker", monitorId: "m1", teamId: "team", status: true, code: 200, message: "OK", payload: {} };
|
||||
const dockerLogs = [{ id: "docker-log-1" }, { id: "docker-log-2" }];
|
||||
const { pipeline, deps } = createPipeline({
|
||||
networkService: { requestStatus: jest.fn<any>().mockResolvedValue(status) },
|
||||
providerRegistry: { probe: jest.fn<any>().mockResolvedValue(status) },
|
||||
dockerLogsService: { buildDockerLogs: jest.fn<any>().mockResolvedValue(dockerLogs) },
|
||||
});
|
||||
|
||||
@@ -357,7 +354,7 @@ describe("WorkerPipeline", () => {
|
||||
]) {
|
||||
const check: Record<string, unknown> = { id: "check-1" };
|
||||
const { pipeline, deps } = createPipeline({
|
||||
networkService: { requestStatus: jest.fn<any>().mockResolvedValue(status) },
|
||||
providerRegistry: { probe: jest.fn<any>().mockResolvedValue(status) },
|
||||
checkService: { toCheck: jest.fn<any>().mockReturnValue(check) },
|
||||
egressService: { assessAfterFailure: jest.fn<any>().mockResolvedValue("degraded") },
|
||||
});
|
||||
@@ -373,7 +370,7 @@ describe("WorkerPipeline", () => {
|
||||
it("consults egress for a timeout or network error", async () => {
|
||||
const check: Record<string, unknown> = { id: "check-1" };
|
||||
const { pipeline, deps } = createPipeline({
|
||||
networkService: { requestStatus: jest.fn<any>().mockResolvedValue(failingStatus) },
|
||||
providerRegistry: { probe: jest.fn<any>().mockResolvedValue(failingStatus) },
|
||||
checkService: { toCheck: jest.fn<any>().mockReturnValue(check) },
|
||||
egressService: { assessAfterFailure: jest.fn<any>().mockResolvedValue("degraded") },
|
||||
});
|
||||
@@ -388,7 +385,7 @@ describe("WorkerPipeline", () => {
|
||||
for (const url of ["unix:///var/run/docker.sock", "/var/run/docker.sock"]) {
|
||||
const check: Record<string, unknown> = { id: "check-1" };
|
||||
const { pipeline, deps } = createPipeline({
|
||||
networkService: { requestStatus: jest.fn<any>().mockResolvedValue({ ...failingStatus, type: "docker" }) },
|
||||
providerRegistry: { probe: jest.fn<any>().mockResolvedValue({ ...failingStatus, type: "docker" }) },
|
||||
checkService: { toCheck: jest.fn<any>().mockReturnValue(check) },
|
||||
egressService: { assessAfterFailure: jest.fn<any>().mockResolvedValue("degraded") },
|
||||
});
|
||||
@@ -409,7 +406,7 @@ describe("WorkerPipeline", () => {
|
||||
]) {
|
||||
const check: Record<string, unknown> = { id: "check-1" };
|
||||
const { pipeline, deps } = createPipeline({
|
||||
networkService: { requestStatus: jest.fn<any>().mockResolvedValue({ ...failingStatus, type: monitor.type }) },
|
||||
providerRegistry: { probe: jest.fn<any>().mockResolvedValue({ ...failingStatus, type: monitor.type }) },
|
||||
checkService: { toCheck: jest.fn<any>().mockReturnValue(check) },
|
||||
egressService: { assessAfterFailure: jest.fn<any>().mockResolvedValue("degraded") },
|
||||
});
|
||||
@@ -427,7 +424,7 @@ describe("WorkerPipeline", () => {
|
||||
// them from a connection failure.
|
||||
const check: Record<string, unknown> = { id: "check-1" };
|
||||
const { pipeline, deps } = createPipeline({
|
||||
networkService: { requestStatus: jest.fn<any>().mockResolvedValue({ ...failingStatus, peerResponded: true }) },
|
||||
providerRegistry: { probe: jest.fn<any>().mockResolvedValue({ ...failingStatus, peerResponded: true }) },
|
||||
checkService: { toCheck: jest.fn<any>().mockReturnValue(check) },
|
||||
egressService: { assessAfterFailure: jest.fn<any>().mockResolvedValue("degraded") },
|
||||
});
|
||||
@@ -441,7 +438,7 @@ describe("WorkerPipeline", () => {
|
||||
it("consults egress when the provider reports that nothing answered, despite an HTTP-shaped code", async () => {
|
||||
const check: Record<string, unknown> = { id: "check-1" };
|
||||
const { pipeline, deps } = createPipeline({
|
||||
networkService: { requestStatus: jest.fn<any>().mockResolvedValue({ ...failingStatus, code: 503, peerResponded: false }) },
|
||||
providerRegistry: { probe: jest.fn<any>().mockResolvedValue({ ...failingStatus, code: 503, peerResponded: false }) },
|
||||
checkService: { toCheck: jest.fn<any>().mockReturnValue(check) },
|
||||
egressService: { assessAfterFailure: jest.fn<any>().mockResolvedValue("degraded") },
|
||||
});
|
||||
@@ -456,7 +453,7 @@ describe("WorkerPipeline", () => {
|
||||
it("flags a failing check as degraded when the egress service reports degraded egress", async () => {
|
||||
const check: Record<string, unknown> = { id: "check-1" };
|
||||
const { pipeline, deps } = createPipeline({
|
||||
networkService: { requestStatus: jest.fn<any>().mockResolvedValue(failingStatus) },
|
||||
providerRegistry: { probe: jest.fn<any>().mockResolvedValue(failingStatus) },
|
||||
checkService: { toCheck: jest.fn<any>().mockReturnValue(check) },
|
||||
egressService: { assessAfterFailure: jest.fn<any>().mockResolvedValue("degraded") },
|
||||
});
|
||||
@@ -475,7 +472,7 @@ describe("WorkerPipeline", () => {
|
||||
|
||||
it("clears the status window on the first degraded check of a spell", async () => {
|
||||
const { pipeline, deps } = createPipeline({
|
||||
networkService: { requestStatus: jest.fn<any>().mockResolvedValue(failingStatus) },
|
||||
providerRegistry: { probe: jest.fn<any>().mockResolvedValue(failingStatus) },
|
||||
egressService: { assessAfterFailure: jest.fn<any>().mockResolvedValue("degraded") },
|
||||
});
|
||||
|
||||
@@ -486,7 +483,7 @@ describe("WorkerPipeline", () => {
|
||||
|
||||
it("does not re-write the status window for later degraded checks in the same spell", async () => {
|
||||
const { pipeline, deps } = createPipeline({
|
||||
networkService: { requestStatus: jest.fn<any>().mockResolvedValue(failingStatus) },
|
||||
providerRegistry: { probe: jest.fn<any>().mockResolvedValue(failingStatus) },
|
||||
egressService: { assessAfterFailure: jest.fn<any>().mockResolvedValue("degraded") },
|
||||
});
|
||||
|
||||
@@ -497,7 +494,7 @@ describe("WorkerPipeline", () => {
|
||||
|
||||
it("leaves the status window alone when the probe found egress fine", async () => {
|
||||
const { pipeline, deps } = createPipeline({
|
||||
networkService: { requestStatus: jest.fn<any>().mockResolvedValue(failingStatus) },
|
||||
providerRegistry: { probe: jest.fn<any>().mockResolvedValue(failingStatus) },
|
||||
egressService: { assessAfterFailure: jest.fn<any>().mockResolvedValue("ok") },
|
||||
});
|
||||
|
||||
@@ -509,7 +506,7 @@ describe("WorkerPipeline", () => {
|
||||
it("flags a failing check as ok when the probe found egress fine", async () => {
|
||||
const check: Record<string, unknown> = { id: "check-1" };
|
||||
const { pipeline } = createPipeline({
|
||||
networkService: { requestStatus: jest.fn<any>().mockResolvedValue(failingStatus) },
|
||||
providerRegistry: { probe: jest.fn<any>().mockResolvedValue(failingStatus) },
|
||||
checkService: { toCheck: jest.fn<any>().mockReturnValue(check) },
|
||||
egressService: { assessAfterFailure: jest.fn<any>().mockResolvedValue("ok") },
|
||||
});
|
||||
@@ -519,28 +516,10 @@ describe("WorkerPipeline", () => {
|
||||
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 () => {
|
||||
const check: Record<string, unknown> = { id: "check-1" };
|
||||
const { pipeline, deps } = createPipeline({
|
||||
networkService: { requestStatus: jest.fn<any>().mockResolvedValue(failingStatus) },
|
||||
providerRegistry: { probe: jest.fn<any>().mockResolvedValue(failingStatus) },
|
||||
checkService: { toCheck: jest.fn<any>().mockReturnValue(check) },
|
||||
});
|
||||
|
||||
@@ -624,7 +603,7 @@ describe("WorkerPipeline", () => {
|
||||
it("loads the row's pending checks, dispatches each, and pulls its id after dispatch", async () => {
|
||||
const job = makeEvaluateJob("c1");
|
||||
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);
|
||||
@@ -661,7 +640,9 @@ describe("WorkerPipeline", () => {
|
||||
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 { 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: {
|
||||
upsertEvaluate: jest.fn<any>(),
|
||||
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 () => {
|
||||
const job = makeEvaluateJob("c1", "c-gone");
|
||||
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);
|
||||
@@ -695,7 +676,7 @@ describe("WorkerPipeline", () => {
|
||||
const postWriteMonitor = makeMonitor({ status: "down" }); // what updateStatusWindowAndChecks would return after check c1
|
||||
const { pipeline, deps } = createPipeline({
|
||||
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 })) },
|
||||
});
|
||||
|
||||
@@ -705,103 +686,80 @@ describe("WorkerPipeline", () => {
|
||||
expect(deps.monitorsRepository.findByIdLean).toHaveBeenCalledTimes(1);
|
||||
expect(deps.statusService.updateMonitorStatus).toHaveBeenCalledTimes(2);
|
||||
// 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[1][2]).toBe(postWriteMonitor);
|
||||
expect(deps.statusService.updateMonitorStatus.mock.calls[0][1]).toBe(initialMonitor);
|
||||
expect(deps.statusService.updateMonitorStatus.mock.calls[1][1]).toBe(postWriteMonitor);
|
||||
});
|
||||
});
|
||||
|
||||
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 status = { monitorId: "m1", status: true, code: 200, message: "OK" } as any;
|
||||
const check = { id: "check-1" } as any;
|
||||
const check = makeCheck({ status: true, statusCode: 200, message: "OK" });
|
||||
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 () => {
|
||||
const status = { monitorId: "m1", status: false, code: 500, message: "Error" } as any;
|
||||
const check = { id: "check-1" } as any;
|
||||
const statusChange = makeStatusChange({ monitor: makeMonitor({ status: "down" }), statusChanged: true, prevStatus: "up", code: 500 });
|
||||
const check = makeCheck({ status: false, statusCode: 500, message: "Error" });
|
||||
const statusChange = makeStatusChange({ monitor: makeMonitor({ status: "down" }), statusChanged: true, prevStatus: "up" });
|
||||
const { pipeline } = createPipeline({
|
||||
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({
|
||||
monitor: statusChange.monitor,
|
||||
status,
|
||||
check,
|
||||
statusChange,
|
||||
decision: expect.objectContaining({
|
||||
shouldCreateIncident: true,
|
||||
shouldSendNotification: true,
|
||||
incidentReason: "status_down",
|
||||
}),
|
||||
decision: { transition: "status_down" },
|
||||
});
|
||||
});
|
||||
|
||||
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 { pipeline } = createPipeline({
|
||||
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 () => {
|
||||
const status = { monitorId: "m1", status: true, code: 200, message: "OK" } as any;
|
||||
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({
|
||||
shouldCreateIncident: false,
|
||||
shouldResolveIncident: false,
|
||||
shouldSendNotification: false,
|
||||
});
|
||||
expect(result.decision).toEqual({ transition: null });
|
||||
});
|
||||
|
||||
// ── degraded egress ───────────────────────────────────────────────────
|
||||
|
||||
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 = { id: "check-1", egressStatus: "degraded" } as any;
|
||||
const check = makeCheck({ status: false, statusCode: 500, message: "Error", egressStatus: "degraded" });
|
||||
const monitor = makeMonitor({ status: "up" });
|
||||
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(result.monitor).toBe(monitor);
|
||||
expect(result.statusChange).toMatchObject({ monitor, statusChanged: false, prevStatus: "up", code: 500 });
|
||||
expect(result.statusChange.timestamp).toEqual(expect.any(Number));
|
||||
expect(result.decision).toEqual({
|
||||
shouldCreateIncident: false,
|
||||
shouldResolveIncident: false,
|
||||
shouldSendNotification: false,
|
||||
incidentReason: null,
|
||||
notificationReason: null,
|
||||
});
|
||||
expect(result).not.toHaveProperty("statusChange");
|
||||
expect(result.decision).toEqual({ transition: null });
|
||||
expect(deps.logger.debug).toHaveBeenCalledWith(expect.objectContaining({ message: expect.stringContaining("egress was degraded") }));
|
||||
});
|
||||
|
||||
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 = { id: "check-1", egressStatus: "ok" } as any;
|
||||
const check = makeCheck({ status: false, statusCode: 500, message: "Error", egressStatus: "ok" });
|
||||
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({
|
||||
statusService: { updateMonitorStatus: jest.fn<any>().mockResolvedValue(statusChange) },
|
||||
});
|
||||
const result = await pipeline.evaluateCheck(
|
||||
{ monitorId: "m1", status: true, code: 200, message: "OK" } as any,
|
||||
{ id: "check-1" } as any,
|
||||
makeMonitor()
|
||||
);
|
||||
const result = await pipeline.evaluateCheck(makeCheck({ status: true, statusCode: 200, message: "OK" }), makeMonitor());
|
||||
return result.decision;
|
||||
};
|
||||
|
||||
it("does nothing when statusChanged is false", async () => {
|
||||
const decision = await decide(makeStatusChange({ status: "down", statusChanged: false, prevStatus: "up" }));
|
||||
expect(decision).toEqual({
|
||||
shouldCreateIncident: false,
|
||||
shouldResolveIncident: false,
|
||||
shouldSendNotification: false,
|
||||
incidentReason: null,
|
||||
notificationReason: null,
|
||||
});
|
||||
expect(decision).toEqual({ transition: null });
|
||||
});
|
||||
|
||||
it("creates incident and notifies when monitor goes down", async () => {
|
||||
const decision = await decide(makeStatusChange({ status: "down", statusChanged: true, prevStatus: "up", code: 500 }));
|
||||
expect(decision).toMatchObject({
|
||||
shouldCreateIncident: true,
|
||||
shouldSendNotification: true,
|
||||
incidentReason: "status_down",
|
||||
notificationReason: "status_change",
|
||||
});
|
||||
it("reports status_down when the monitor goes down", async () => {
|
||||
const decision = await decide(makeStatusChange({ status: "down", statusChanged: true, prevStatus: "up" }));
|
||||
expect(decision).toEqual({ transition: "status_down" });
|
||||
});
|
||||
|
||||
it("creates incident and notifies when monitor is breached", async () => {
|
||||
it("reports threshold_breach when the monitor is breached", async () => {
|
||||
const decision = await decide(makeStatusChange({ status: "breached", statusChanged: true, prevStatus: "up" }));
|
||||
expect(decision).toMatchObject({
|
||||
shouldCreateIncident: true,
|
||||
shouldSendNotification: true,
|
||||
incidentReason: "threshold_breach",
|
||||
notificationReason: "threshold_breach",
|
||||
});
|
||||
expect(decision).toEqual({ transition: "threshold_breach" });
|
||||
});
|
||||
|
||||
it("resolves incident when monitor recovers from down", async () => {
|
||||
it("reports status_up when the monitor recovers from down", async () => {
|
||||
const decision = await decide(makeStatusChange({ status: "up", statusChanged: true, prevStatus: "down" }));
|
||||
expect(decision).toMatchObject({
|
||||
shouldResolveIncident: true,
|
||||
shouldSendNotification: true,
|
||||
shouldCreateIncident: false,
|
||||
notificationReason: "status_change",
|
||||
});
|
||||
expect(decision).toEqual({ transition: "status_up" });
|
||||
});
|
||||
|
||||
it("resolves incident with threshold_resolved when monitor recovers from breached", async () => {
|
||||
it("reports threshold_resolved when the monitor recovers from breached", async () => {
|
||||
const decision = await decide(makeStatusChange({ status: "up", statusChanged: true, prevStatus: "breached" }));
|
||||
expect(decision).toMatchObject({
|
||||
shouldResolveIncident: true,
|
||||
shouldSendNotification: true,
|
||||
shouldCreateIncident: false,
|
||||
notificationReason: "threshold_resolved",
|
||||
});
|
||||
expect(decision).toEqual({ transition: "threshold_resolved" });
|
||||
});
|
||||
|
||||
it("does not create or resolve for unhandled status transitions", async () => {
|
||||
it("reports no transition for unhandled status changes", async () => {
|
||||
const decision = await decide(makeStatusChange({ status: "paused", statusChanged: true, prevStatus: "up" }));
|
||||
expect(decision).toMatchObject({ shouldCreateIncident: false, shouldResolveIncident: false, shouldSendNotification: false });
|
||||
expect(decision).toEqual({ transition: null });
|
||||
});
|
||||
|
||||
it("carries the threshold breaches onto a breached decision", async () => {
|
||||
const thresholdBreaches = { cpu: true, memory: false, disk: true, temp: false };
|
||||
const decision = await decide(makeStatusChange({ status: "breached", statusChanged: true, prevStatus: "up", thresholdBreaches }));
|
||||
expect(decision).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);
|
||||
});
|
||||
});
|
||||
});
|
||||
Reference in new issue
Block a user