Compare commits
60
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 | ||
|
|
4a66cf6e11 | ||
|
|
41c7cfbdaa | ||
|
|
7fb57d8090 | ||
|
|
618258d282 | ||
|
|
e50568dba3 | ||
|
|
5fa894cb79 | ||
|
|
6d4e944a58 | ||
|
|
cfe7d2a359 | ||
|
|
5fb26367ed | ||
|
|
cd65488cbd | ||
|
|
4ea6539a7d | ||
|
|
a75bb5fae6 | ||
|
|
380968472f | ||
|
|
0cb48596a0 | ||
|
|
22d0123966 | ||
|
|
2a45b4d46f | ||
|
|
bdd4634a3b |
No files matched your search
@@ -12,7 +12,7 @@ jobs:
|
|||||||
- name: Set up Node
|
- name: Set up Node
|
||||||
uses: actions/setup-node@v4
|
uses: actions/setup-node@v4
|
||||||
with:
|
with:
|
||||||
node-version: 22
|
node-version: 24
|
||||||
|
|
||||||
- name: Install client dependencies
|
- name: Install client dependencies
|
||||||
working-directory: client
|
working-directory: client
|
||||||
@@ -29,7 +29,7 @@ jobs:
|
|||||||
- name: Set up Node
|
- name: Set up Node
|
||||||
uses: actions/setup-node@v4
|
uses: actions/setup-node@v4
|
||||||
with:
|
with:
|
||||||
node-version: 22
|
node-version: 24
|
||||||
|
|
||||||
- name: Install server dependencies
|
- name: Install server dependencies
|
||||||
working-directory: server
|
working-directory: server
|
||||||
|
|||||||
@@ -12,7 +12,7 @@ jobs:
|
|||||||
- name: Set up Node
|
- name: Set up Node
|
||||||
uses: actions/setup-node@v4
|
uses: actions/setup-node@v4
|
||||||
with:
|
with:
|
||||||
node-version: 22
|
node-version: 24
|
||||||
|
|
||||||
- name: Install client dependencies
|
- name: Install client dependencies
|
||||||
working-directory: client
|
working-directory: client
|
||||||
@@ -30,7 +30,7 @@ jobs:
|
|||||||
- name: Set up Node
|
- name: Set up Node
|
||||||
uses: actions/setup-node@v4
|
uses: actions/setup-node@v4
|
||||||
with:
|
with:
|
||||||
node-version: 22
|
node-version: 24
|
||||||
|
|
||||||
- name: Install server dependencies
|
- name: Install server dependencies
|
||||||
working-directory: server
|
working-directory: server
|
||||||
|
|||||||
@@ -12,7 +12,7 @@ jobs:
|
|||||||
- name: Set up Node
|
- name: Set up Node
|
||||||
uses: actions/setup-node@v4
|
uses: actions/setup-node@v4
|
||||||
with:
|
with:
|
||||||
node-version: 22
|
node-version: 24
|
||||||
|
|
||||||
- name: Install server dependencies
|
- name: Install server dependencies
|
||||||
working-directory: server
|
working-directory: server
|
||||||
|
|||||||
@@ -62,7 +62,7 @@ VITE_APP_LOG_LEVEL="debug"
|
|||||||
|
|
||||||
### Monorepo Structure
|
### Monorepo Structure
|
||||||
- `/client` - React 18 + TypeScript + Vite + MUI frontend
|
- `/client` - React 18 + TypeScript + Vite + MUI frontend
|
||||||
- `/server` - Node.js 20+ + Express + TypeScript backend
|
- `/server` - Node.js 24+ + Express + TypeScript backend
|
||||||
- `/docker` - Multi-environment Docker configs (dev, staging, prod, arm, mono)
|
- `/docker` - Multi-environment Docker configs (dev, staging, prod, arm, mono)
|
||||||
|
|
||||||
### Backend Layers
|
### Backend Layers
|
||||||
|
|||||||
@@ -2,6 +2,10 @@
|
|||||||
"name": "client",
|
"name": "client",
|
||||||
"private": true,
|
"private": true,
|
||||||
"version": "3.12.0",
|
"version": "3.12.0",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=24",
|
||||||
|
"npm": ">=11"
|
||||||
|
},
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"lockfile": "docker run --rm -v \"$PWD\":/w -w /w $(grep -m1 -o 'node:[^ ]*' ../docker/Dockerfile) npm install --package-lock-only",
|
"lockfile": "docker run --rm -v \"$PWD\":/w -w /w $(grep -m1 -o 'node:[^ ]*' ../docker/Dockerfile) npm install --package-lock-only",
|
||||||
|
|||||||
@@ -1,10 +1,16 @@
|
|||||||
import { dashboardCardKeys, type DashboardCardKey } from "@/Types/Dashboard";
|
import {
|
||||||
|
dashboardCardKeys,
|
||||||
|
type DashboardCardKey,
|
||||||
|
type DashboardSortOrder,
|
||||||
|
} from "@/Types/Dashboard";
|
||||||
|
import type { SortOrder } from "@/Types/Query";
|
||||||
import { createSlice, type PayloadAction } from "@reduxjs/toolkit";
|
import { createSlice, type PayloadAction } from "@reduxjs/toolkit";
|
||||||
|
|
||||||
type ThemeMode = "light" | "dark";
|
type ThemeMode = "light" | "dark";
|
||||||
type ChartType = "histogram" | "line";
|
type ChartType = "histogram" | "line";
|
||||||
|
|
||||||
type TableName =
|
type TableName =
|
||||||
|
| "dashboard"
|
||||||
| "monitors"
|
| "monitors"
|
||||||
| "team"
|
| "team"
|
||||||
| "maintenance"
|
| "maintenance"
|
||||||
@@ -15,6 +21,8 @@ type TableName =
|
|||||||
|
|
||||||
interface TableState {
|
interface TableState {
|
||||||
rowsPerPage: number;
|
rowsPerPage: number;
|
||||||
|
sortField?: string;
|
||||||
|
sortOrder?: SortOrder;
|
||||||
}
|
}
|
||||||
|
|
||||||
interface SidebarState {
|
interface SidebarState {
|
||||||
@@ -22,6 +30,7 @@ interface SidebarState {
|
|||||||
}
|
}
|
||||||
|
|
||||||
interface UIState {
|
interface UIState {
|
||||||
|
dashboard: TableState;
|
||||||
monitors: TableState;
|
monitors: TableState;
|
||||||
pagespeed: TableState;
|
pagespeed: TableState;
|
||||||
team: TableState;
|
team: TableState;
|
||||||
@@ -38,6 +47,7 @@ interface UIState {
|
|||||||
starPromptOpen: boolean;
|
starPromptOpen: boolean;
|
||||||
chartType: ChartType;
|
chartType: ChartType;
|
||||||
dashboardVisibleCards: DashboardCardKey[];
|
dashboardVisibleCards: DashboardCardKey[];
|
||||||
|
dashboardUptimeSortOrder: DashboardSortOrder;
|
||||||
}
|
}
|
||||||
|
|
||||||
const initialMode: ThemeMode = window?.matchMedia?.("(prefers-color-scheme: dark)")
|
const initialMode: ThemeMode = window?.matchMedia?.("(prefers-color-scheme: dark)")
|
||||||
@@ -46,6 +56,9 @@ const initialMode: ThemeMode = window?.matchMedia?.("(prefers-color-scheme: dark
|
|||||||
: "light";
|
: "light";
|
||||||
|
|
||||||
const initialState: UIState = {
|
const initialState: UIState = {
|
||||||
|
dashboard: {
|
||||||
|
rowsPerPage: 100,
|
||||||
|
},
|
||||||
monitors: {
|
monitors: {
|
||||||
rowsPerPage: 10,
|
rowsPerPage: 10,
|
||||||
},
|
},
|
||||||
@@ -78,6 +91,7 @@ const initialState: UIState = {
|
|||||||
starPromptOpen: true,
|
starPromptOpen: true,
|
||||||
chartType: "histogram",
|
chartType: "histogram",
|
||||||
dashboardVisibleCards: [...dashboardCardKeys],
|
dashboardVisibleCards: [...dashboardCardKeys],
|
||||||
|
dashboardUptimeSortOrder: "ascending",
|
||||||
};
|
};
|
||||||
|
|
||||||
const uiSlice = createSlice({
|
const uiSlice = createSlice({
|
||||||
@@ -94,6 +108,14 @@ const uiSlice = createSlice({
|
|||||||
const { table, value } = action.payload;
|
const { table, value } = action.payload;
|
||||||
state[table].rowsPerPage = value;
|
state[table].rowsPerPage = value;
|
||||||
},
|
},
|
||||||
|
setTableSort: (
|
||||||
|
state,
|
||||||
|
action: PayloadAction<{ table: TableName; field: string; order: SortOrder }>
|
||||||
|
) => {
|
||||||
|
const { table, field, order } = action.payload;
|
||||||
|
state[table].sortField = field;
|
||||||
|
state[table].sortOrder = order;
|
||||||
|
},
|
||||||
toggleSidebar: (state) => {
|
toggleSidebar: (state) => {
|
||||||
state.sidebar.collapsed = !state.sidebar.collapsed;
|
state.sidebar.collapsed = !state.sidebar.collapsed;
|
||||||
},
|
},
|
||||||
@@ -124,6 +146,9 @@ const uiSlice = createSlice({
|
|||||||
setDashboardVisibleCards: (state, action: PayloadAction<DashboardCardKey[]>) => {
|
setDashboardVisibleCards: (state, action: PayloadAction<DashboardCardKey[]>) => {
|
||||||
state.dashboardVisibleCards = action.payload;
|
state.dashboardVisibleCards = action.payload;
|
||||||
},
|
},
|
||||||
|
setDashboardUptimeSortOrder: (state, action: PayloadAction<DashboardSortOrder>) => {
|
||||||
|
state.dashboardUptimeSortOrder = action.payload;
|
||||||
|
},
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -131,6 +156,7 @@ export type { UIState, ThemeMode, ChartType, TableName };
|
|||||||
export default uiSlice.reducer;
|
export default uiSlice.reducer;
|
||||||
export const {
|
export const {
|
||||||
setRowsPerPage,
|
setRowsPerPage,
|
||||||
|
setTableSort,
|
||||||
toggleSidebar,
|
toggleSidebar,
|
||||||
setCollapsed,
|
setCollapsed,
|
||||||
setMode,
|
setMode,
|
||||||
@@ -141,4 +167,5 @@ export const {
|
|||||||
setStarPromptOpen,
|
setStarPromptOpen,
|
||||||
setChartType,
|
setChartType,
|
||||||
setDashboardVisibleCards,
|
setDashboardVisibleCards,
|
||||||
|
setDashboardUptimeSortOrder,
|
||||||
} = uiSlice.actions;
|
} = uiSlice.actions;
|
||||||
@@ -85,6 +85,8 @@ export const getMonitorDefaults = (
|
|||||||
dockerTlsKey: "",
|
dockerTlsKey: "",
|
||||||
dockerTlsKeySet: data?.dockerTlsKeySet ?? false,
|
dockerTlsKeySet: data?.dockerTlsKeySet ?? false,
|
||||||
dockerLogsEnabled: data?.dockerLogsEnabled ?? false,
|
dockerLogsEnabled: data?.dockerLogsEnabled ?? false,
|
||||||
|
dockerAlertOnStopped: data?.dockerAlertOnStopped ?? false,
|
||||||
|
dockerAlertOnUnhealthy: data?.dockerAlertOnUnhealthy ?? false,
|
||||||
};
|
};
|
||||||
break;
|
break;
|
||||||
case "game":
|
case "game":
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { setRowsPerPage, type TableName } from "@/Features/UI/uiSlice";
|
import { setRowsPerPage, setTableSort, type TableName } from "@/Features/UI/uiSlice";
|
||||||
import { useDelete, useGet } from "@/Hooks/UseApi";
|
import { useDelete, useGet } from "@/Hooks/UseApi";
|
||||||
import { useBulkMonitorActions } from "@/Hooks/useBulkMonitorActions";
|
import { useBulkMonitorActions } from "@/Hooks/useBulkMonitorActions";
|
||||||
import useDebounce from "@/Hooks/useDebounce";
|
import useDebounce from "@/Hooks/useDebounce";
|
||||||
@@ -10,6 +10,7 @@ import {
|
|||||||
type MonitorType,
|
type MonitorType,
|
||||||
} from "@/Types/Monitor";
|
} from "@/Types/Monitor";
|
||||||
import type { Tag } from "@/Types/Tag";
|
import type { Tag } from "@/Types/Tag";
|
||||||
|
import type { SortOrder } from "@/Types/Query";
|
||||||
import { useCallback, useMemo, useState } from "react";
|
import { useCallback, useMemo, useState } from "react";
|
||||||
import { useDispatch, useSelector } from "react-redux";
|
import { useDispatch, useSelector } from "react-redux";
|
||||||
|
|
||||||
@@ -24,10 +25,12 @@ interface MonitorListConfig {
|
|||||||
|
|
||||||
export const useMonitorListController = (config: MonitorListConfig) => {
|
export const useMonitorListController = (config: MonitorListConfig) => {
|
||||||
const dispatch = useDispatch();
|
const dispatch = useDispatch();
|
||||||
const rowsPerPage = useSelector(
|
const tableState = useSelector(
|
||||||
(state: RootState) =>
|
(state: RootState) => state.ui?.[config.rowsPerPageTable]
|
||||||
state.ui?.[config.rowsPerPageTable]?.rowsPerPage ?? config.rowsPerPageDefault
|
|
||||||
);
|
);
|
||||||
|
const rowsPerPage = tableState?.rowsPerPage ?? config.rowsPerPageDefault;
|
||||||
|
const sortField = tableState?.sortField ?? config.initialSortField ?? "";
|
||||||
|
const sortOrder = tableState?.sortOrder ?? "asc";
|
||||||
|
|
||||||
// Filter states
|
// Filter states
|
||||||
const [selectedTypes, setSelectedTypes] = useState<MonitorType[]>([]);
|
const [selectedTypes, setSelectedTypes] = useState<MonitorType[]>([]);
|
||||||
@@ -36,8 +39,6 @@ export const useMonitorListController = (config: MonitorListConfig) => {
|
|||||||
const [selectedTags, setSelectedTags] = useState<string[]>([]);
|
const [selectedTags, setSelectedTags] = useState<string[]>([]);
|
||||||
const [page, setPage] = useState<number>(0);
|
const [page, setPage] = useState<number>(0);
|
||||||
const [search, setSearch] = useState<string>("");
|
const [search, setSearch] = useState<string>("");
|
||||||
const [sortField, setSortField] = useState<string>(config.initialSortField ?? "");
|
|
||||||
const [sortOrder, setSortOrder] = useState<"asc" | "desc">("asc");
|
|
||||||
const [selectedMonitor, setSelectedMonitor] = useState<Monitor | null>(null);
|
const [selectedMonitor, setSelectedMonitor] = useState<Monitor | null>(null);
|
||||||
const debouncedSearch = useDebounce<string>(search, 300);
|
const debouncedSearch = useDebounce<string>(search, 300);
|
||||||
|
|
||||||
@@ -135,6 +136,11 @@ export const useMonitorListController = (config: MonitorListConfig) => {
|
|||||||
setPage(0);
|
setPage(0);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const setSort = (field: string, order: SortOrder) => {
|
||||||
|
dispatch(setTableSort({ field, order, table: config.rowsPerPageTable }));
|
||||||
|
setPage(0);
|
||||||
|
};
|
||||||
|
|
||||||
// Check for active filters
|
// Check for active filters
|
||||||
const hasActiveFilters = Boolean(
|
const hasActiveFilters = Boolean(
|
||||||
selectedTypes.length > 0 ||
|
selectedTypes.length > 0 ||
|
||||||
@@ -189,9 +195,8 @@ export const useMonitorListController = (config: MonitorListConfig) => {
|
|||||||
rowsPerPage,
|
rowsPerPage,
|
||||||
handleSetRowsPerPage,
|
handleSetRowsPerPage,
|
||||||
sortField,
|
sortField,
|
||||||
setSortField,
|
|
||||||
sortOrder,
|
sortOrder,
|
||||||
setSortOrder,
|
setSort,
|
||||||
|
|
||||||
// Delete
|
// Delete
|
||||||
selectedMonitor,
|
selectedMonitor,
|
||||||
|
|||||||
@@ -617,7 +617,6 @@ const CreateMonitorPage = () => {
|
|||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{showStep(0) && (
|
{showStep(0) && (
|
||||||
<ConfigBox
|
<ConfigBox
|
||||||
title={t("pages.createMonitor.form.general.title")}
|
title={t("pages.createMonitor.form.general.title")}
|
||||||
@@ -758,7 +757,6 @@ const CreateMonitorPage = () => {
|
|||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{showStep(0) && watchedType === "docker" && isDockerTlsUrl(watchedUrl) && (
|
{showStep(0) && watchedType === "docker" && isDockerTlsUrl(watchedUrl) && (
|
||||||
<ConfigBox
|
<ConfigBox
|
||||||
title={t("pages.createMonitor.form.dockerTls.title")}
|
title={t("pages.createMonitor.form.dockerTls.title")}
|
||||||
@@ -816,7 +814,6 @@ const CreateMonitorPage = () => {
|
|||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{showStep(1) && (
|
{showStep(1) && (
|
||||||
<ConfigBox
|
<ConfigBox
|
||||||
title={t("pages.createMonitor.form.frequency.title")}
|
title={t("pages.createMonitor.form.frequency.title")}
|
||||||
@@ -837,7 +834,6 @@ const CreateMonitorPage = () => {
|
|||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Alert Thresholds - only for hardware type */}
|
{/* Alert Thresholds - only for hardware type */}
|
||||||
{watchedType === "hardware" && showStep(1) && (
|
{watchedType === "hardware" && showStep(1) && (
|
||||||
<ConfigBox
|
<ConfigBox
|
||||||
@@ -893,7 +889,6 @@ const CreateMonitorPage = () => {
|
|||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{showStep(1) && (
|
{showStep(1) && (
|
||||||
<ConfigBox
|
<ConfigBox
|
||||||
title={t("pages.createMonitor.form.incidents.title")}
|
title={t("pages.createMonitor.form.incidents.title")}
|
||||||
@@ -920,7 +915,6 @@ const CreateMonitorPage = () => {
|
|||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{showStep(1) && (
|
{showStep(1) && (
|
||||||
<ConfigBox
|
<ConfigBox
|
||||||
title={t("pages.createMonitor.form.notifications.title")}
|
title={t("pages.createMonitor.form.notifications.title")}
|
||||||
@@ -933,7 +927,6 @@ const CreateMonitorPage = () => {
|
|||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{showStep(1) && (
|
{showStep(1) && (
|
||||||
<ConfigBox
|
<ConfigBox
|
||||||
title={t("pages.createMonitor.form.tags.title")}
|
title={t("pages.createMonitor.form.tags.title")}
|
||||||
@@ -960,7 +953,6 @@ const CreateMonitorPage = () => {
|
|||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{showStep(1) && watchedType === "docker" && !isCaptureDocker && (
|
{showStep(1) && watchedType === "docker" && !isCaptureDocker && (
|
||||||
<ConfigBox
|
<ConfigBox
|
||||||
title={t("pages.createMonitor.form.dockerLogs.title")}
|
title={t("pages.createMonitor.form.dockerLogs.title")}
|
||||||
@@ -973,7 +965,28 @@ const CreateMonitorPage = () => {
|
|||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
{showStep(1) && watchedType === "docker" && (
|
||||||
|
<ConfigBox
|
||||||
|
title={t("pages.createMonitor.form.dockerAlerts.title")}
|
||||||
|
subtitle={t("pages.createMonitor.form.dockerAlerts.description")}
|
||||||
|
rightContent={
|
||||||
|
<Stack gap={theme.spacing(LAYOUT.MD)}>
|
||||||
|
<FormSwitchField
|
||||||
|
name="dockerAlertOnStopped"
|
||||||
|
label={t(
|
||||||
|
"pages.createMonitor.form.dockerAlerts.option.onStopped.label"
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
<FormSwitchField
|
||||||
|
name="dockerAlertOnUnhealthy"
|
||||||
|
label={t(
|
||||||
|
"pages.createMonitor.form.dockerAlerts.option.onUnhealthy.label"
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
</Stack>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
{showStep(2) &&
|
{showStep(2) &&
|
||||||
(watchedType === "http" ||
|
(watchedType === "http" ||
|
||||||
watchedType === "grpc" ||
|
watchedType === "grpc" ||
|
||||||
@@ -989,7 +1002,6 @@ const CreateMonitorPage = () => {
|
|||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{showStep(2) && watchedType === "http" && (
|
{showStep(2) && watchedType === "http" && (
|
||||||
<ConfigBox
|
<ConfigBox
|
||||||
title={t("pages.createMonitor.form.advanced.title")}
|
title={t("pages.createMonitor.form.advanced.title")}
|
||||||
@@ -1089,7 +1101,6 @@ const CreateMonitorPage = () => {
|
|||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{showStep(2) && supportsGeoCheck(watchedType) && (
|
{showStep(2) && supportsGeoCheck(watchedType) && (
|
||||||
<ConfigBox
|
<ConfigBox
|
||||||
title={t("pages.createMonitor.form.geoChecks.title")}
|
title={t("pages.createMonitor.form.geoChecks.title")}
|
||||||
@@ -1122,7 +1133,6 @@ const CreateMonitorPage = () => {
|
|||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<Stack
|
<Stack
|
||||||
direction="row"
|
direction="row"
|
||||||
justifyContent={!isEditMode ? "space-between" : "flex-end"}
|
justifyContent={!isEditMode ? "space-between" : "flex-end"}
|
||||||
|
|||||||
@@ -1,8 +1,11 @@
|
|||||||
import { useState } from "react";
|
import { useMemo, useState } from "react";
|
||||||
import Stack from "@mui/material/Stack";
|
import Stack from "@mui/material/Stack";
|
||||||
import FormControlLabel from "@mui/material/FormControlLabel";
|
import FormControlLabel from "@mui/material/FormControlLabel";
|
||||||
|
import Typography from "@mui/material/Typography";
|
||||||
|
import { useTheme } from "@mui/material/styles";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
import { Checkbox, Dialog } from "@/Components/inputs";
|
import { Checkbox, Dialog } from "@/Components/inputs";
|
||||||
|
import { LAYOUT } from "@/Utils/Theme/constants";
|
||||||
import { type DashboardCardKey, dashboardCardKeys } from "@/Types/Dashboard";
|
import { type DashboardCardKey, dashboardCardKeys } from "@/Types/Dashboard";
|
||||||
|
|
||||||
interface EditCardsModalProps {
|
interface EditCardsModalProps {
|
||||||
@@ -18,16 +21,19 @@ export const EditCardsModal = ({
|
|||||||
onClose,
|
onClose,
|
||||||
onSave,
|
onSave,
|
||||||
}: EditCardsModalProps) => {
|
}: EditCardsModalProps) => {
|
||||||
|
const theme = useTheme();
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const [draft, setDraft] = useState<Set<DashboardCardKey>>(new Set(visibleCards));
|
const [cardsSelection, setCardsSelection] = useState<Set<DashboardCardKey>>(
|
||||||
|
new Set(visibleCards)
|
||||||
|
);
|
||||||
|
|
||||||
const handleClose = () => {
|
const handleClose = () => {
|
||||||
setDraft(new Set(visibleCards));
|
setCardsSelection(new Set(visibleCards));
|
||||||
onClose();
|
onClose();
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleToggle = (key: DashboardCardKey) => {
|
const handleToggle = (key: DashboardCardKey) => {
|
||||||
setDraft((prev) => {
|
setCardsSelection((prev) => {
|
||||||
const next = new Set(prev);
|
const next = new Set(prev);
|
||||||
if (next.has(key)) {
|
if (next.has(key)) {
|
||||||
next.delete(key);
|
next.delete(key);
|
||||||
@@ -38,26 +44,40 @@ export const EditCardsModal = ({
|
|||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const sortedCardKeys = useMemo(
|
||||||
|
() =>
|
||||||
|
[...dashboardCardKeys].sort((a, b) =>
|
||||||
|
t(`pages.dashboard.cards.${a}`).localeCompare(t(`pages.dashboard.cards.${b}`))
|
||||||
|
),
|
||||||
|
[t]
|
||||||
|
);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Dialog
|
<Dialog
|
||||||
open={open}
|
open={open}
|
||||||
title={t("pages.dashboard.editCardsModal.title")}
|
title={t("pages.dashboard.editCardsModal.title")}
|
||||||
content={t("pages.dashboard.editCardsModal.description")}
|
content={t("pages.dashboard.editCardsModal.description")}
|
||||||
onCancel={handleClose}
|
onCancel={handleClose}
|
||||||
onConfirm={() => onSave(draft)}
|
onConfirm={() => onSave(cardsSelection)}
|
||||||
confirmText={t("pages.dashboard.editCardsModal.done")}
|
confirmText={t("pages.dashboard.editCardsModal.done")}
|
||||||
maxWidth="xs"
|
maxWidth="xs"
|
||||||
fullWidth
|
fullWidth
|
||||||
>
|
>
|
||||||
<Stack>
|
<Stack gap={theme.spacing(LAYOUT.XXS)}>
|
||||||
{dashboardCardKeys.map((key: DashboardCardKey) => (
|
<Typography
|
||||||
|
variant="eyebrow"
|
||||||
|
color={theme.palette.text.disabled}
|
||||||
|
>
|
||||||
|
{t("pages.dashboard.editCardsModal.groups.uptime")}
|
||||||
|
</Typography>
|
||||||
|
{sortedCardKeys.map((key: DashboardCardKey) => (
|
||||||
<FormControlLabel
|
<FormControlLabel
|
||||||
key={key}
|
key={key}
|
||||||
label={t(`pages.dashboard.cards.${key}`)}
|
label={t(`pages.dashboard.cards.${key}`)}
|
||||||
control={
|
control={
|
||||||
<Checkbox
|
<Checkbox
|
||||||
checked={draft.has(key)}
|
checked={cardsSelection.has(key)}
|
||||||
disabled={draft.has(key) && draft.size === 1}
|
disabled={cardsSelection.has(key) && cardsSelection.size === 1}
|
||||||
onChange={() => handleToggle(key)}
|
onChange={() => handleToggle(key)}
|
||||||
/>
|
/>
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,58 @@
|
|||||||
|
import Stack from "@mui/material/Stack";
|
||||||
|
import Typography from "@mui/material/Typography";
|
||||||
|
import { useTheme } from "@mui/material/styles";
|
||||||
|
import { useTranslation } from "react-i18next";
|
||||||
|
import { DashboardCard } from "@/Pages/Dashboard/components/DashboardCard";
|
||||||
|
import { Dot } from "@/Components/design-elements";
|
||||||
|
import { typographyLevels } from "@/Utils/Theme/Palette";
|
||||||
|
import { LAYOUT } from "@/Utils/Theme/constants";
|
||||||
|
import type { Monitor } from "@/Types/Monitor";
|
||||||
|
|
||||||
|
interface CurrentlyDownCardProps {
|
||||||
|
monitors: Monitor[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export const CurrentlyDownCard = ({ monitors }: CurrentlyDownCardProps) => {
|
||||||
|
const theme = useTheme();
|
||||||
|
const { t } = useTranslation();
|
||||||
|
|
||||||
|
const downMonitors = monitors.filter((monitor) => monitor.status === "down");
|
||||||
|
|
||||||
|
return (
|
||||||
|
<DashboardCard
|
||||||
|
title={t("pages.dashboard.cards.currentlyDown")}
|
||||||
|
topRight={downMonitors.length > 0 ? downMonitors.length : undefined}
|
||||||
|
>
|
||||||
|
{downMonitors.length === 0 ? (
|
||||||
|
<Typography
|
||||||
|
fontSize={typographyLevels.m}
|
||||||
|
color={theme.palette.text.disabled}
|
||||||
|
>
|
||||||
|
{t("pages.dashboard.currentlyDown.empty")}
|
||||||
|
</Typography>
|
||||||
|
) : (
|
||||||
|
<Stack gap={theme.spacing(LAYOUT.SM)}>
|
||||||
|
{downMonitors.map((monitor) => (
|
||||||
|
<Stack
|
||||||
|
key={monitor.id}
|
||||||
|
direction="row"
|
||||||
|
alignItems="center"
|
||||||
|
gap={theme.spacing(LAYOUT.XS)}
|
||||||
|
>
|
||||||
|
<Dot color={theme.palette.error.main} />
|
||||||
|
<Typography
|
||||||
|
fontSize={typographyLevels.m}
|
||||||
|
color={theme.palette.text.primary}
|
||||||
|
overflow="hidden"
|
||||||
|
textOverflow="ellipsis"
|
||||||
|
whiteSpace="nowrap"
|
||||||
|
>
|
||||||
|
{monitor.name}
|
||||||
|
</Typography>
|
||||||
|
</Stack>
|
||||||
|
))}
|
||||||
|
</Stack>
|
||||||
|
)}
|
||||||
|
</DashboardCard>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -0,0 +1,90 @@
|
|||||||
|
import IconButton from "@mui/material/IconButton";
|
||||||
|
import Stack from "@mui/material/Stack";
|
||||||
|
import Typography from "@mui/material/Typography";
|
||||||
|
import { useTheme } from "@mui/material/styles";
|
||||||
|
import { useTranslation } from "react-i18next";
|
||||||
|
import { useDispatch, useSelector } from "react-redux";
|
||||||
|
import { ArrowDown, ArrowUp } from "lucide-react";
|
||||||
|
import { Icon } from "@/Components/design-elements";
|
||||||
|
import { DashboardCard } from "@/Pages/Dashboard/components/DashboardCard";
|
||||||
|
import { BarRow } from "@/Pages/Dashboard/components/cards/BarRow";
|
||||||
|
import { getUptimePercentageColor } from "@/Utils/MonitorUtils";
|
||||||
|
import { formatPercentage } from "@/Utils/FormatUtils";
|
||||||
|
import { typographyLevels } from "@/Utils/Theme/Palette";
|
||||||
|
import { LAYOUT } from "@/Utils/Theme/constants";
|
||||||
|
import { setDashboardUptimeSortOrder } from "@/Features/UI/uiSlice";
|
||||||
|
import type { Monitor } from "@/Types/Monitor";
|
||||||
|
import type { AppDispatch, RootState } from "@/store";
|
||||||
|
|
||||||
|
const TOP_N = 5;
|
||||||
|
|
||||||
|
interface UptimeCardProps {
|
||||||
|
monitors: Monitor[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export const UptimeCard = ({ monitors }: UptimeCardProps) => {
|
||||||
|
const theme = useTheme();
|
||||||
|
const { t } = useTranslation();
|
||||||
|
const dispatch = useDispatch<AppDispatch>();
|
||||||
|
const sortOrder = useSelector((state: RootState) => state.ui.dashboardUptimeSortOrder);
|
||||||
|
|
||||||
|
const direction = sortOrder === "ascending" ? 1 : -1;
|
||||||
|
const ranked = monitors
|
||||||
|
.filter((monitor) => monitor.status !== "paused")
|
||||||
|
.filter(
|
||||||
|
(monitor): monitor is Monitor & { uptimePercentage: number } =>
|
||||||
|
typeof monitor.uptimePercentage === "number"
|
||||||
|
)
|
||||||
|
.sort((a, b) => direction * (a.uptimePercentage - b.uptimePercentage))
|
||||||
|
.slice(0, TOP_N);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<DashboardCard
|
||||||
|
title={t("pages.dashboard.cards.uptime")}
|
||||||
|
topRight={
|
||||||
|
<IconButton
|
||||||
|
size="small"
|
||||||
|
aria-label={t("pages.dashboard.uptimeCard.toggleSortOrder")}
|
||||||
|
onClick={() =>
|
||||||
|
dispatch(
|
||||||
|
setDashboardUptimeSortOrder(
|
||||||
|
sortOrder === "ascending" ? "descending" : "ascending"
|
||||||
|
)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<Icon
|
||||||
|
icon={sortOrder === "ascending" ? ArrowUp : ArrowDown}
|
||||||
|
size={16}
|
||||||
|
color={theme.palette.text.secondary}
|
||||||
|
/>
|
||||||
|
</IconButton>
|
||||||
|
}
|
||||||
|
>
|
||||||
|
{ranked.length === 0 ? (
|
||||||
|
<Typography
|
||||||
|
fontSize={typographyLevels.m}
|
||||||
|
color={theme.palette.text.disabled}
|
||||||
|
>
|
||||||
|
{t("pages.dashboard.noData")}
|
||||||
|
</Typography>
|
||||||
|
) : (
|
||||||
|
<Stack gap={theme.spacing(LAYOUT.SM)}>
|
||||||
|
{ranked.map((monitor) => {
|
||||||
|
const paletteKey = getUptimePercentageColor(monitor.uptimePercentage);
|
||||||
|
const color = theme.palette[paletteKey].main;
|
||||||
|
return (
|
||||||
|
<BarRow
|
||||||
|
key={monitor.id}
|
||||||
|
label={monitor.name}
|
||||||
|
value={formatPercentage(monitor.uptimePercentage)}
|
||||||
|
fillRatio={monitor.uptimePercentage}
|
||||||
|
valueColor={color}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</Stack>
|
||||||
|
)}
|
||||||
|
</DashboardCard>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -14,6 +14,8 @@ import { setDashboardVisibleCards } from "@/Features/UI/uiSlice";
|
|||||||
import type { RootState, AppDispatch } from "@/store";
|
import type { RootState, AppDispatch } from "@/store";
|
||||||
|
|
||||||
import { MonitorStatusCard } from "@/Pages/Dashboard/components/cards/MonitorStatusCard";
|
import { MonitorStatusCard } from "@/Pages/Dashboard/components/cards/MonitorStatusCard";
|
||||||
|
import { CurrentlyDownCard } from "@/Pages/Dashboard/components/cards/CurrentlyDownCard";
|
||||||
|
import { UptimeCard } from "@/Pages/Dashboard/components/cards/UptimeCard";
|
||||||
import { MonitorsByTypeCard } from "@/Pages/Dashboard/components/cards/MonitorsByTypeCard";
|
import { MonitorsByTypeCard } from "@/Pages/Dashboard/components/cards/MonitorsByTypeCard";
|
||||||
import { EditCardsModal } from "@/Pages/Dashboard/components/EditCardsModal";
|
import { EditCardsModal } from "@/Pages/Dashboard/components/EditCardsModal";
|
||||||
import { type DashboardCardKey, dashboardCardKeys } from "@/Types/Dashboard";
|
import { type DashboardCardKey, dashboardCardKeys } from "@/Types/Dashboard";
|
||||||
@@ -32,7 +34,7 @@ const Dashboard = () => {
|
|||||||
types: [...MonitorTypes],
|
types: [...MonitorTypes],
|
||||||
checksLimit: 1,
|
checksLimit: 1,
|
||||||
refreshInterval: 30000,
|
refreshInterval: 30000,
|
||||||
rowsPerPageTable: "monitors",
|
rowsPerPageTable: "dashboard",
|
||||||
rowsPerPageDefault: 100,
|
rowsPerPageDefault: 100,
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -48,6 +50,8 @@ const Dashboard = () => {
|
|||||||
|
|
||||||
const cardComponents: Record<DashboardCardKey, ReactNode> = {
|
const cardComponents: Record<DashboardCardKey, ReactNode> = {
|
||||||
monitorStatus: <MonitorStatusCard summary={summary} />,
|
monitorStatus: <MonitorStatusCard summary={summary} />,
|
||||||
|
currentlyDown: <CurrentlyDownCard monitors={monitors ?? []} />,
|
||||||
|
uptime: <UptimeCard monitors={monitors ?? []} />,
|
||||||
monitorsByType: <MonitorsByTypeCard monitorsByType={monitorsByType} />,
|
monitorsByType: <MonitorsByTypeCard monitorsByType={monitorsByType} />,
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ import {
|
|||||||
ColoredLabel,
|
ColoredLabel,
|
||||||
} from "@/Components/design-elements";
|
} from "@/Components/design-elements";
|
||||||
import { SPACING } from "@/Utils/Theme/constants";
|
import { SPACING } from "@/Utils/Theme/constants";
|
||||||
|
import { getNextMonitorSort } from "@/Utils/MonitorUtils";
|
||||||
import type { Header } from "@/Components/design-elements/Table";
|
import type { Header } from "@/Components/design-elements/Table";
|
||||||
import { ActionsMenu, type ActionMenuItem } from "@/Components/actions-menu";
|
import { ActionsMenu, type ActionMenuItem } from "@/Components/actions-menu";
|
||||||
import { ArrowUp, ArrowDown } from "lucide-react";
|
import { ArrowUp, ArrowDown } from "lucide-react";
|
||||||
@@ -20,6 +21,7 @@ import { usePost } from "@/Hooks/UseApi";
|
|||||||
|
|
||||||
import type { Monitor } from "@/Types/Monitor";
|
import type { Monitor } from "@/Types/Monitor";
|
||||||
import type { Tag } from "@/Types/Tag";
|
import type { Tag } from "@/Types/Tag";
|
||||||
|
import type { SortOrder } from "@/Types/Query";
|
||||||
import type { DockerContainerSummary } from "@/Types/Check";
|
import type { DockerContainerSummary } from "@/Types/Check";
|
||||||
import { Checkbox } from "@/Components/inputs";
|
import { Checkbox } from "@/Components/inputs";
|
||||||
|
|
||||||
@@ -34,9 +36,8 @@ interface DockerMonitorsTableProps {
|
|||||||
refetch: () => void;
|
refetch: () => void;
|
||||||
setSelectedMonitor: (monitor: Monitor | null) => void;
|
setSelectedMonitor: (monitor: Monitor | null) => void;
|
||||||
sortField: string;
|
sortField: string;
|
||||||
setSortField: (field: string) => void;
|
sortOrder: SortOrder;
|
||||||
sortOrder: "asc" | "desc";
|
setSort: (field: string, order: SortOrder) => void;
|
||||||
setSortOrder: (order: "asc" | "desc") => void;
|
|
||||||
count: number;
|
count: number;
|
||||||
page: number;
|
page: number;
|
||||||
setPage: (page: number) => void;
|
setPage: (page: number) => void;
|
||||||
@@ -52,9 +53,8 @@ export const DockerMonitorsTable = ({
|
|||||||
refetch,
|
refetch,
|
||||||
setSelectedMonitor,
|
setSelectedMonitor,
|
||||||
sortField,
|
sortField,
|
||||||
setSortField,
|
|
||||||
sortOrder,
|
sortOrder,
|
||||||
setSortOrder,
|
setSort,
|
||||||
count,
|
count,
|
||||||
page,
|
page,
|
||||||
setPage,
|
setPage,
|
||||||
@@ -105,14 +105,8 @@ export const DockerMonitorsTable = ({
|
|||||||
const handleSort = (e: React.MouseEvent, field: string) => {
|
const handleSort = (e: React.MouseEvent, field: string) => {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
e.stopPropagation();
|
e.stopPropagation();
|
||||||
if (sortField === field) {
|
const nextSort = getNextMonitorSort(field, sortField, sortOrder);
|
||||||
const newOrder = sortOrder === "asc" ? "desc" : "asc";
|
setSort(nextSort.field, nextSort.order);
|
||||||
setSortOrder(newOrder);
|
|
||||||
} else {
|
|
||||||
setSortField(field);
|
|
||||||
setSortOrder("asc");
|
|
||||||
}
|
|
||||||
refetch();
|
|
||||||
};
|
};
|
||||||
|
|
||||||
const getActions = (monitor: Monitor): ActionMenuItem[] => {
|
const getActions = (monitor: Monitor): ActionMenuItem[] => {
|
||||||
|
|||||||
@@ -19,6 +19,7 @@ const DockerMonitorsPage = () => {
|
|||||||
controller={c}
|
controller={c}
|
||||||
bulkActions
|
bulkActions
|
||||||
showTypeFilter={false}
|
showTypeFilter={false}
|
||||||
|
summaryProps={{ showBreached: true }}
|
||||||
>
|
>
|
||||||
<DockerMonitorsTable
|
<DockerMonitorsTable
|
||||||
monitors={c.monitors || []}
|
monitors={c.monitors || []}
|
||||||
@@ -30,9 +31,8 @@ const DockerMonitorsPage = () => {
|
|||||||
setPage={c.setPage}
|
setPage={c.setPage}
|
||||||
rowsPerPage={c.rowsPerPage}
|
rowsPerPage={c.rowsPerPage}
|
||||||
sortField={c.sortField}
|
sortField={c.sortField}
|
||||||
setSortField={c.setSortField}
|
|
||||||
sortOrder={c.sortOrder}
|
sortOrder={c.sortOrder}
|
||||||
setSortOrder={c.setSortOrder}
|
setSort={c.setSort}
|
||||||
setRowsPerPage={c.handleSetRowsPerPage}
|
setRowsPerPage={c.handleSetRowsPerPage}
|
||||||
selectedRows={c.selectedRows}
|
selectedRows={c.selectedRows}
|
||||||
onSelectionChange={c.setSelectedRows}
|
onSelectionChange={c.setSelectedRows}
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ import {
|
|||||||
ColoredLabel,
|
ColoredLabel,
|
||||||
} from "@/Components/design-elements";
|
} from "@/Components/design-elements";
|
||||||
import { SPACING } from "@/Utils/Theme/constants";
|
import { SPACING } from "@/Utils/Theme/constants";
|
||||||
|
import { getNextMonitorSort } from "@/Utils/MonitorUtils";
|
||||||
import type { Header } from "@/Components/design-elements/Table";
|
import type { Header } from "@/Components/design-elements/Table";
|
||||||
import { ActionsMenu, type ActionMenuItem } from "@/Components/actions-menu";
|
import { ActionsMenu, type ActionMenuItem } from "@/Components/actions-menu";
|
||||||
import { ArrowUp, ArrowDown } from "lucide-react";
|
import { ArrowUp, ArrowDown } from "lucide-react";
|
||||||
@@ -21,6 +22,7 @@ import { usePost } from "@/Hooks/UseApi";
|
|||||||
|
|
||||||
import type { Monitor } from "@/Types/Monitor";
|
import type { Monitor } from "@/Types/Monitor";
|
||||||
import type { Tag } from "@/Types/Tag";
|
import type { Tag } from "@/Types/Tag";
|
||||||
|
import type { SortOrder } from "@/Types/Query";
|
||||||
import { Checkbox } from "@/Components/inputs";
|
import { Checkbox } from "@/Components/inputs";
|
||||||
|
|
||||||
interface InfraMonitorsTableProps {
|
interface InfraMonitorsTableProps {
|
||||||
@@ -29,9 +31,8 @@ interface InfraMonitorsTableProps {
|
|||||||
refetch: () => void;
|
refetch: () => void;
|
||||||
setSelectedMonitor: (monitor: Monitor | null) => void;
|
setSelectedMonitor: (monitor: Monitor | null) => void;
|
||||||
sortField: string;
|
sortField: string;
|
||||||
setSortField: (field: string) => void;
|
sortOrder: SortOrder;
|
||||||
sortOrder: "asc" | "desc";
|
setSort: (field: string, order: SortOrder) => void;
|
||||||
setSortOrder: (order: "asc" | "desc") => void;
|
|
||||||
count: number;
|
count: number;
|
||||||
page: number;
|
page: number;
|
||||||
setPage: (page: number) => void;
|
setPage: (page: number) => void;
|
||||||
@@ -47,9 +48,8 @@ export const InfraMonitorsTable = ({
|
|||||||
refetch,
|
refetch,
|
||||||
setSelectedMonitor,
|
setSelectedMonitor,
|
||||||
sortField,
|
sortField,
|
||||||
setSortField,
|
|
||||||
sortOrder,
|
sortOrder,
|
||||||
setSortOrder,
|
setSort,
|
||||||
count,
|
count,
|
||||||
page,
|
page,
|
||||||
setPage,
|
setPage,
|
||||||
@@ -100,14 +100,8 @@ export const InfraMonitorsTable = ({
|
|||||||
const handleSort = (e: React.MouseEvent, field: string) => {
|
const handleSort = (e: React.MouseEvent, field: string) => {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
e.stopPropagation();
|
e.stopPropagation();
|
||||||
if (sortField === field) {
|
const nextSort = getNextMonitorSort(field, sortField, sortOrder);
|
||||||
const newOrder = sortOrder === "asc" ? "desc" : "asc";
|
setSort(nextSort.field, nextSort.order);
|
||||||
setSortOrder(newOrder);
|
|
||||||
} else {
|
|
||||||
setSortField(field);
|
|
||||||
setSortOrder("asc");
|
|
||||||
}
|
|
||||||
refetch();
|
|
||||||
};
|
};
|
||||||
|
|
||||||
const getActions = (monitor: Monitor): ActionMenuItem[] => {
|
const getActions = (monitor: Monitor): ActionMenuItem[] => {
|
||||||
|
|||||||
@@ -31,9 +31,8 @@ const InfrastructureMonitors = () => {
|
|||||||
setPage={c.setPage}
|
setPage={c.setPage}
|
||||||
rowsPerPage={c.rowsPerPage}
|
rowsPerPage={c.rowsPerPage}
|
||||||
sortField={c.sortField}
|
sortField={c.sortField}
|
||||||
setSortField={c.setSortField}
|
|
||||||
sortOrder={c.sortOrder}
|
sortOrder={c.sortOrder}
|
||||||
setSortOrder={c.setSortOrder}
|
setSort={c.setSort}
|
||||||
setRowsPerPage={c.handleSetRowsPerPage}
|
setRowsPerPage={c.handleSetRowsPerPage}
|
||||||
selectedRows={c.selectedRows}
|
selectedRows={c.selectedRows}
|
||||||
onSelectionChange={c.setSelectedRows}
|
onSelectionChange={c.setSelectedRows}
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ import {
|
|||||||
ColoredLabel,
|
ColoredLabel,
|
||||||
} from "@/Components/design-elements";
|
} from "@/Components/design-elements";
|
||||||
import { LAYOUT, SPACING } from "@/Utils/Theme/constants";
|
import { LAYOUT, SPACING } from "@/Utils/Theme/constants";
|
||||||
|
import { getNextMonitorSort } from "@/Utils/MonitorUtils";
|
||||||
import { HistogramPageSpeed } from "@/Components/monitors";
|
import { HistogramPageSpeed } from "@/Components/monitors";
|
||||||
import { ArrowDown, ArrowUp } from "lucide-react";
|
import { ArrowDown, ArrowUp } from "lucide-react";
|
||||||
import type { Header } from "@/Components/design-elements/Table";
|
import type { Header } from "@/Components/design-elements/Table";
|
||||||
@@ -22,6 +23,7 @@ import { usePatch } from "@/Hooks/UseApi";
|
|||||||
|
|
||||||
import type { Monitor, MonitorWithChecks } from "@/Types/Monitor";
|
import type { Monitor, MonitorWithChecks } from "@/Types/Monitor";
|
||||||
import type { Tag } from "@/Types/Tag";
|
import type { Tag } from "@/Types/Tag";
|
||||||
|
import type { SortOrder } from "@/Types/Query";
|
||||||
import type { ActionMenuItem } from "@/Components/actions-menu";
|
import type { ActionMenuItem } from "@/Components/actions-menu";
|
||||||
|
|
||||||
export const PageSpeedMonitorsTable = ({
|
export const PageSpeedMonitorsTable = ({
|
||||||
@@ -30,9 +32,8 @@ export const PageSpeedMonitorsTable = ({
|
|||||||
refetch,
|
refetch,
|
||||||
setSelectedMonitor,
|
setSelectedMonitor,
|
||||||
sortField,
|
sortField,
|
||||||
setSortField,
|
|
||||||
sortOrder,
|
sortOrder,
|
||||||
setSortOrder,
|
setSort,
|
||||||
count,
|
count,
|
||||||
page,
|
page,
|
||||||
setPage,
|
setPage,
|
||||||
@@ -44,9 +45,8 @@ export const PageSpeedMonitorsTable = ({
|
|||||||
refetch: Function;
|
refetch: Function;
|
||||||
setSelectedMonitor: Function;
|
setSelectedMonitor: Function;
|
||||||
sortField: string;
|
sortField: string;
|
||||||
setSortField: (field: string) => void;
|
sortOrder: SortOrder;
|
||||||
sortOrder: "asc" | "desc";
|
setSort: (field: string, order: SortOrder) => void;
|
||||||
setSortOrder: (order: "asc" | "desc") => void;
|
|
||||||
count: number;
|
count: number;
|
||||||
page: number;
|
page: number;
|
||||||
setPage: (page: number) => void;
|
setPage: (page: number) => void;
|
||||||
@@ -81,14 +81,8 @@ export const PageSpeedMonitorsTable = ({
|
|||||||
const handleSort = (e: any, field: string) => {
|
const handleSort = (e: any, field: string) => {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
e.stopPropagation();
|
e.stopPropagation();
|
||||||
if (sortField === field) {
|
const nextSort = getNextMonitorSort(field, sortField, sortOrder);
|
||||||
const newOrder = sortOrder === "asc" ? "desc" : "asc";
|
setSort(nextSort.field, nextSort.order);
|
||||||
setSortOrder(newOrder);
|
|
||||||
} else {
|
|
||||||
setSortField(field);
|
|
||||||
setSortOrder("asc");
|
|
||||||
}
|
|
||||||
refetch();
|
|
||||||
};
|
};
|
||||||
|
|
||||||
const getActions = (monitor: Monitor): ActionMenuItem[] => {
|
const getActions = (monitor: Monitor): ActionMenuItem[] => {
|
||||||
|
|||||||
@@ -46,9 +46,8 @@ const PageSpeedMonitorsPage = () => {
|
|||||||
setPage={c.setPage}
|
setPage={c.setPage}
|
||||||
rowsPerPage={c.rowsPerPage}
|
rowsPerPage={c.rowsPerPage}
|
||||||
sortField={c.sortField}
|
sortField={c.sortField}
|
||||||
setSortField={c.setSortField}
|
|
||||||
sortOrder={c.sortOrder}
|
sortOrder={c.sortOrder}
|
||||||
setSortOrder={c.setSortOrder}
|
setSort={c.setSort}
|
||||||
setRowsPerPage={c.handleSetRowsPerPage}
|
setRowsPerPage={c.handleSetRowsPerPage}
|
||||||
/>
|
/>
|
||||||
</MonitorListPage>
|
</MonitorListPage>
|
||||||
|
|||||||
@@ -24,8 +24,9 @@ import type { ActionMenuItem } from "@/Components/actions-menu";
|
|||||||
import type { RootState } from "@/Types/state";
|
import type { RootState } from "@/Types/state";
|
||||||
import { Checkbox } from "@/Components/inputs";
|
import { Checkbox } from "@/Components/inputs";
|
||||||
import type { Tag } from "@/Types/Tag";
|
import type { Tag } from "@/Types/Tag";
|
||||||
|
import type { SortOrder } from "@/Types/Query";
|
||||||
import { SPACING } from "@/Utils/Theme/constants";
|
import { SPACING } from "@/Utils/Theme/constants";
|
||||||
import { getUptimePercentageColor } from "@/Utils/MonitorUtils";
|
import { getNextMonitorSort, getUptimePercentageColor } from "@/Utils/MonitorUtils";
|
||||||
import { formatPercentage } from "@/Utils/FormatUtils";
|
import { formatPercentage } from "@/Utils/FormatUtils";
|
||||||
|
|
||||||
interface MonitorTableProps {
|
interface MonitorTableProps {
|
||||||
@@ -34,9 +35,8 @@ interface MonitorTableProps {
|
|||||||
refetch: () => void;
|
refetch: () => void;
|
||||||
setSelectedMonitor: (monitor: Monitor | null) => void;
|
setSelectedMonitor: (monitor: Monitor | null) => void;
|
||||||
sortField: string;
|
sortField: string;
|
||||||
setSortField: (field: string) => void;
|
sortOrder: SortOrder;
|
||||||
sortOrder: "asc" | "desc";
|
setSort: (field: string, order: SortOrder) => void;
|
||||||
setSortOrder: (order: "asc" | "desc") => void;
|
|
||||||
count: number;
|
count: number;
|
||||||
page: number;
|
page: number;
|
||||||
setPage: (page: number) => void;
|
setPage: (page: number) => void;
|
||||||
@@ -52,9 +52,8 @@ export const MonitorTable = ({
|
|||||||
refetch,
|
refetch,
|
||||||
setSelectedMonitor,
|
setSelectedMonitor,
|
||||||
sortField,
|
sortField,
|
||||||
setSortField,
|
|
||||||
sortOrder,
|
sortOrder,
|
||||||
setSortOrder,
|
setSort,
|
||||||
count,
|
count,
|
||||||
page,
|
page,
|
||||||
setPage,
|
setPage,
|
||||||
@@ -91,14 +90,8 @@ export const MonitorTable = ({
|
|||||||
const handleSort = (e: React.MouseEvent, field: string) => {
|
const handleSort = (e: React.MouseEvent, field: string) => {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
e.stopPropagation();
|
e.stopPropagation();
|
||||||
if (sortField === field) {
|
const nextSort = getNextMonitorSort(field, sortField, sortOrder);
|
||||||
const newOrder = sortOrder === "asc" ? "desc" : "asc";
|
setSort(nextSort.field, nextSort.order);
|
||||||
setSortOrder(newOrder);
|
|
||||||
} else {
|
|
||||||
setSortField(field);
|
|
||||||
setSortOrder("asc");
|
|
||||||
}
|
|
||||||
refetch();
|
|
||||||
};
|
};
|
||||||
|
|
||||||
const isBrowserOpenable = (monitor: Monitor): boolean => {
|
const isBrowserOpenable = (monitor: Monitor): boolean => {
|
||||||
|
|||||||
@@ -30,9 +30,8 @@ const UptimeMonitorsPage = () => {
|
|||||||
setPage={monitorListController.setPage}
|
setPage={monitorListController.setPage}
|
||||||
rowsPerPage={monitorListController.rowsPerPage}
|
rowsPerPage={monitorListController.rowsPerPage}
|
||||||
sortField={monitorListController.sortField}
|
sortField={monitorListController.sortField}
|
||||||
setSortField={monitorListController.setSortField}
|
|
||||||
sortOrder={monitorListController.sortOrder}
|
sortOrder={monitorListController.sortOrder}
|
||||||
setSortOrder={monitorListController.setSortOrder}
|
setSort={monitorListController.setSort}
|
||||||
setRowsPerPage={monitorListController.handleSetRowsPerPage}
|
setRowsPerPage={monitorListController.handleSetRowsPerPage}
|
||||||
selectedRows={monitorListController.selectedRows}
|
selectedRows={monitorListController.selectedRows}
|
||||||
onSelectionChange={monitorListController.setSelectedRows}
|
onSelectionChange={monitorListController.setSelectedRows}
|
||||||
|
|||||||
@@ -1,2 +1,10 @@
|
|||||||
export const dashboardCardKeys = ["monitorStatus", "monitorsByType"] as const;
|
export const dashboardCardKeys = [
|
||||||
|
"monitorStatus",
|
||||||
|
"currentlyDown",
|
||||||
|
"uptime",
|
||||||
|
"monitorsByType",
|
||||||
|
] as const;
|
||||||
export type DashboardCardKey = (typeof dashboardCardKeys)[number];
|
export type DashboardCardKey = (typeof dashboardCardKeys)[number];
|
||||||
|
|
||||||
|
export const dashboardSortOrders = ["ascending", "descending"] as const;
|
||||||
|
export type DashboardSortOrder = (typeof dashboardSortOrders)[number];
|
||||||
@@ -169,6 +169,8 @@ export interface Monitor {
|
|||||||
geoCheckLocations?: GeoContinent[];
|
geoCheckLocations?: GeoContinent[];
|
||||||
geoCheckInterval?: number;
|
geoCheckInterval?: number;
|
||||||
dockerLogsEnabled?: boolean;
|
dockerLogsEnabled?: boolean;
|
||||||
|
dockerAlertOnStopped?: boolean;
|
||||||
|
dockerAlertOnUnhealthy?: boolean;
|
||||||
dockerTlsCa?: string;
|
dockerTlsCa?: string;
|
||||||
dockerTlsCert?: string;
|
dockerTlsCert?: string;
|
||||||
dockerTlsKeySet?: boolean;
|
dockerTlsKeySet?: boolean;
|
||||||
|
|||||||
@@ -1,2 +1,5 @@
|
|||||||
export const DateRanges = ["recent", "hour", "day", "week", "month", "all"] as const;
|
export const DateRanges = ["recent", "hour", "day", "week", "month", "all"] as const;
|
||||||
export type DateRange = (typeof DateRanges)[number];
|
export type DateRange = (typeof DateRanges)[number];
|
||||||
|
|
||||||
|
export const SortOrders = ["asc", "desc"] as const;
|
||||||
|
export type SortOrder = (typeof SortOrders)[number];
|
||||||
@@ -1,4 +1,5 @@
|
|||||||
import type { MonitorStatus, MonitorType } from "@/Types/Monitor";
|
import type { MonitorStatus, MonitorType } from "@/Types/Monitor";
|
||||||
|
import type { SortOrder } from "@/Types/Query";
|
||||||
import type { PaletteKey } from "@/Utils/Theme/Theme";
|
import type { PaletteKey } from "@/Utils/Theme/Theme";
|
||||||
import type { ValueType } from "@/Components/design-elements/StatusLabel";
|
import type { ValueType } from "@/Components/design-elements/StatusLabel";
|
||||||
import {
|
import {
|
||||||
@@ -10,6 +11,16 @@ import {
|
|||||||
type DockerLogLine,
|
type DockerLogLine,
|
||||||
} from "@/Types/Check";
|
} from "@/Types/Check";
|
||||||
|
|
||||||
|
export const getNextMonitorSort = (
|
||||||
|
field: string,
|
||||||
|
sortField: string,
|
||||||
|
sortOrder: SortOrder
|
||||||
|
): { field: string; order: SortOrder } => {
|
||||||
|
if (field !== sortField) return { field, order: "asc" };
|
||||||
|
if (sortOrder === "asc") return { field, order: "desc" };
|
||||||
|
return { field: "", order: "asc" };
|
||||||
|
};
|
||||||
|
|
||||||
export const getMonitorPath = (type: MonitorType): string => {
|
export const getMonitorPath = (type: MonitorType): string => {
|
||||||
const pathMap: Record<MonitorType, string> = {
|
const pathMap: Record<MonitorType, string> = {
|
||||||
http: "uptime",
|
http: "uptime",
|
||||||
@@ -20,7 +31,7 @@ export const getMonitorPath = (type: MonitorType): string => {
|
|||||||
websocket: "uptime",
|
websocket: "uptime",
|
||||||
dns: "uptime",
|
dns: "uptime",
|
||||||
unknown: "uptime",
|
unknown: "uptime",
|
||||||
docker: "docker",
|
docker: "docker/host",
|
||||||
hardware: "infrastructure",
|
hardware: "infrastructure",
|
||||||
pagespeed: "pagespeed",
|
pagespeed: "pagespeed",
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -152,6 +152,8 @@ const dockerSchema = baseSchema.extend({
|
|||||||
// Read only mirror of the server flag, stripped on submit
|
// Read only mirror of the server flag, stripped on submit
|
||||||
dockerTlsKeySet: z.boolean(),
|
dockerTlsKeySet: z.boolean(),
|
||||||
dockerLogsEnabled: z.boolean().register(monitorStepRegistry, { step: 1 }),
|
dockerLogsEnabled: z.boolean().register(monitorStepRegistry, { step: 1 }),
|
||||||
|
dockerAlertOnStopped: z.boolean().register(monitorStepRegistry, { step: 1 }),
|
||||||
|
dockerAlertOnUnhealthy: z.boolean().register(monitorStepRegistry, { step: 1 }),
|
||||||
});
|
});
|
||||||
|
|
||||||
// Game server monitor schema
|
// Game server monitor schema
|
||||||
|
|||||||
@@ -557,7 +557,7 @@
|
|||||||
},
|
},
|
||||||
"status": {
|
"status": {
|
||||||
"down": "down",
|
"down": "down",
|
||||||
"breached": "threshold exceeded",
|
"breached": "alert",
|
||||||
"initializing": "initializing",
|
"initializing": "initializing",
|
||||||
"maintenance": "maintenance",
|
"maintenance": "maintenance",
|
||||||
"paused": "paused",
|
"paused": "paused",
|
||||||
@@ -827,6 +827,18 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"dockerAlerts": {
|
||||||
|
"title": "Container alerts",
|
||||||
|
"description": "Mark this host as breached while any container on it is stopped or failing its health check, and notify on the change. Containers that exit with code 0 do not count as stopped. Capture does not report exit codes, so on Capture hosts every stopped container counts.",
|
||||||
|
"option": {
|
||||||
|
"onStopped": {
|
||||||
|
"label": "Alert when a container is stopped"
|
||||||
|
},
|
||||||
|
"onUnhealthy": {
|
||||||
|
"label": "Alert when a container is unhealthy"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
"stats": {
|
"stats": {
|
||||||
"title": "Monitor history",
|
"title": "Monitor history",
|
||||||
"description": "Clear all monitoring history and statistics for your team. This action is irreversible.",
|
"description": "Clear all monitoring history and statistics for your team. This action is irreversible.",
|
||||||
@@ -1685,7 +1697,7 @@
|
|||||||
"status": {
|
"status": {
|
||||||
"up": "Operational",
|
"up": "Operational",
|
||||||
"down": "Down",
|
"down": "Down",
|
||||||
"breached": "Threshold exceeded",
|
"breached": "Alert",
|
||||||
"maintenance": "Maintenance",
|
"maintenance": "Maintenance",
|
||||||
"paused": "Paused",
|
"paused": "Paused",
|
||||||
"initializing": "Initializing"
|
"initializing": "Initializing"
|
||||||
@@ -1725,13 +1737,13 @@
|
|||||||
"monitoringSummary": "Monitoring {{count}} service · Updates every 10 seconds",
|
"monitoringSummary": "Monitoring {{count}} service · Updates every 10 seconds",
|
||||||
"monitoringSummary_other": "Monitoring {{count}} services · Updates every 10 seconds",
|
"monitoringSummary_other": "Monitoring {{count}} services · Updates every 10 seconds",
|
||||||
"allUp": "All systems operational",
|
"allUp": "All systems operational",
|
||||||
"allBreached": "All systems have exceeded thresholds",
|
"allBreached": "All systems have active alerts",
|
||||||
"allMaintenance": "All systems are under maintenance",
|
"allMaintenance": "All systems are under maintenance",
|
||||||
"allDown": "All systems are down",
|
"allDown": "All systems are down",
|
||||||
"allPaused": "All systems are paused",
|
"allPaused": "All systems are paused",
|
||||||
"allInitializing": "All systems are initializing",
|
"allInitializing": "All systems are initializing",
|
||||||
"breachedAndDown": "Some systems have exceeded thresholds and are down",
|
"breachedAndDown": "Some systems have active alerts and some are down",
|
||||||
"breached": "Some systems have exceeded thresholds",
|
"breached": "Some systems have active alerts",
|
||||||
"degraded": "Some systems are experiencing issues",
|
"degraded": "Some systems are experiencing issues",
|
||||||
"maintenanceAndDown": "Some systems are down during maintenance",
|
"maintenanceAndDown": "Some systems are down during maintenance",
|
||||||
"maintenance": "Some systems are under maintenance",
|
"maintenance": "Some systems are under maintenance",
|
||||||
@@ -1924,16 +1936,27 @@
|
|||||||
},
|
},
|
||||||
"cards": {
|
"cards": {
|
||||||
"monitorStatus": "Monitor status",
|
"monitorStatus": "Monitor status",
|
||||||
|
"currentlyDown": "Currently down",
|
||||||
|
"uptime": "Monitor uptime",
|
||||||
"monitorsByType": "Monitors by type"
|
"monitorsByType": "Monitors by type"
|
||||||
},
|
},
|
||||||
|
"uptimeCard": {
|
||||||
|
"toggleSortOrder": "Toggle sort order"
|
||||||
|
},
|
||||||
|
"currentlyDown": {
|
||||||
|
"empty": "No monitors are down at this moment."
|
||||||
|
},
|
||||||
"header": {
|
"header": {
|
||||||
"title": "Dashboard",
|
"title": "Dashboard",
|
||||||
"description": "See every monitor type in one place and spot what needs attention."
|
"description": "See every monitor type in one place and spot what needs attention."
|
||||||
},
|
},
|
||||||
"editCards": "Edit cards",
|
"editCards": "Edit cards",
|
||||||
"editCardsModal": {
|
"editCardsModal": {
|
||||||
"title": "Edit cards",
|
"title": "Dashboard cards",
|
||||||
"description": "Select the cards to display on your dashboard.",
|
"description": "Select the cards to display on your dashboard.",
|
||||||
|
"groups": {
|
||||||
|
"uptime": "Uptime"
|
||||||
|
},
|
||||||
"done": "Done"
|
"done": "Done"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+2
-2
@@ -4,7 +4,7 @@
|
|||||||
# Build on the host arch (BUILDPLATFORM) rather than under QEMU — the output is
|
# Build on the host arch (BUILDPLATFORM) rather than under QEMU — the output is
|
||||||
# pure JS so it's arch-independent, and this avoids the esbuild host/native
|
# pure JS so it's arch-independent, and this avoids the esbuild host/native
|
||||||
# binary version mismatch under emulation.
|
# binary version mismatch under emulation.
|
||||||
FROM --platform=$BUILDPLATFORM node:22-slim AS frontend-build
|
FROM --platform=$BUILDPLATFORM node:24-slim AS frontend-build
|
||||||
|
|
||||||
WORKDIR /app/client
|
WORKDIR /app/client
|
||||||
|
|
||||||
@@ -19,7 +19,7 @@ RUN npm run build
|
|||||||
# ---------------------
|
# ---------------------
|
||||||
# Backend stage
|
# Backend stage
|
||||||
# ---------------------
|
# ---------------------
|
||||||
FROM node:22-slim AS app
|
FROM node:24-slim AS app
|
||||||
|
|
||||||
# Install ping
|
# Install ping
|
||||||
RUN apt-get update \
|
RUN apt-get update \
|
||||||
|
|||||||
@@ -466,6 +466,12 @@
|
|||||||
"dockerLogsEnabled": {
|
"dockerLogsEnabled": {
|
||||||
"type": "boolean"
|
"type": "boolean"
|
||||||
},
|
},
|
||||||
|
"dockerAlertOnStopped": {
|
||||||
|
"type": "boolean"
|
||||||
|
},
|
||||||
|
"dockerAlertOnUnhealthy": {
|
||||||
|
"type": "boolean"
|
||||||
|
},
|
||||||
"dockerTlsCa": {
|
"dockerTlsCa": {
|
||||||
"type": "string"
|
"type": "string"
|
||||||
},
|
},
|
||||||
@@ -523,6 +529,8 @@
|
|||||||
"geoCheckLocations",
|
"geoCheckLocations",
|
||||||
"geoCheckInterval",
|
"geoCheckInterval",
|
||||||
"dockerLogsEnabled",
|
"dockerLogsEnabled",
|
||||||
|
"dockerAlertOnStopped",
|
||||||
|
"dockerAlertOnUnhealthy",
|
||||||
"teamId",
|
"teamId",
|
||||||
"userId",
|
"userId",
|
||||||
"createdAt",
|
"createdAt",
|
||||||
@@ -694,6 +702,12 @@
|
|||||||
"dockerLogsEnabled": {
|
"dockerLogsEnabled": {
|
||||||
"type": "boolean"
|
"type": "boolean"
|
||||||
},
|
},
|
||||||
|
"dockerAlertOnStopped": {
|
||||||
|
"type": "boolean"
|
||||||
|
},
|
||||||
|
"dockerAlertOnUnhealthy": {
|
||||||
|
"type": "boolean"
|
||||||
|
},
|
||||||
"dockerTlsCa": {
|
"dockerTlsCa": {
|
||||||
"type": "string"
|
"type": "string"
|
||||||
},
|
},
|
||||||
@@ -753,6 +767,8 @@
|
|||||||
"geoCheckLocations",
|
"geoCheckLocations",
|
||||||
"geoCheckInterval",
|
"geoCheckInterval",
|
||||||
"dockerLogsEnabled",
|
"dockerLogsEnabled",
|
||||||
|
"dockerAlertOnStopped",
|
||||||
|
"dockerAlertOnUnhealthy",
|
||||||
"teamId",
|
"teamId",
|
||||||
"userId",
|
"userId",
|
||||||
"createdAt",
|
"createdAt",
|
||||||
@@ -7186,6 +7202,12 @@
|
|||||||
"dockerLogsEnabled": {
|
"dockerLogsEnabled": {
|
||||||
"type": "boolean"
|
"type": "boolean"
|
||||||
},
|
},
|
||||||
|
"dockerAlertOnStopped": {
|
||||||
|
"type": "boolean"
|
||||||
|
},
|
||||||
|
"dockerAlertOnUnhealthy": {
|
||||||
|
"type": "boolean"
|
||||||
|
},
|
||||||
"dockerTlsCa": {
|
"dockerTlsCa": {
|
||||||
"type": "string"
|
"type": "string"
|
||||||
},
|
},
|
||||||
@@ -7245,6 +7267,8 @@
|
|||||||
"geoCheckLocations",
|
"geoCheckLocations",
|
||||||
"geoCheckInterval",
|
"geoCheckInterval",
|
||||||
"dockerLogsEnabled",
|
"dockerLogsEnabled",
|
||||||
|
"dockerAlertOnStopped",
|
||||||
|
"dockerAlertOnUnhealthy",
|
||||||
"teamId",
|
"teamId",
|
||||||
"userId",
|
"userId",
|
||||||
"createdAt",
|
"createdAt",
|
||||||
@@ -7761,6 +7785,12 @@
|
|||||||
"dockerLogsEnabled": {
|
"dockerLogsEnabled": {
|
||||||
"type": "boolean"
|
"type": "boolean"
|
||||||
},
|
},
|
||||||
|
"dockerAlertOnStopped": {
|
||||||
|
"type": "boolean"
|
||||||
|
},
|
||||||
|
"dockerAlertOnUnhealthy": {
|
||||||
|
"type": "boolean"
|
||||||
|
},
|
||||||
"dockerTlsCa": {
|
"dockerTlsCa": {
|
||||||
"type": "string"
|
"type": "string"
|
||||||
},
|
},
|
||||||
@@ -7820,6 +7850,8 @@
|
|||||||
"geoCheckLocations",
|
"geoCheckLocations",
|
||||||
"geoCheckInterval",
|
"geoCheckInterval",
|
||||||
"dockerLogsEnabled",
|
"dockerLogsEnabled",
|
||||||
|
"dockerAlertOnStopped",
|
||||||
|
"dockerAlertOnUnhealthy",
|
||||||
"teamId",
|
"teamId",
|
||||||
"userId",
|
"userId",
|
||||||
"createdAt",
|
"createdAt",
|
||||||
@@ -9308,6 +9340,12 @@
|
|||||||
"dockerLogsEnabled": {
|
"dockerLogsEnabled": {
|
||||||
"type": "boolean"
|
"type": "boolean"
|
||||||
},
|
},
|
||||||
|
"dockerAlertOnStopped": {
|
||||||
|
"type": "boolean"
|
||||||
|
},
|
||||||
|
"dockerAlertOnUnhealthy": {
|
||||||
|
"type": "boolean"
|
||||||
|
},
|
||||||
"dockerTlsCa": {
|
"dockerTlsCa": {
|
||||||
"anyOf": [
|
"anyOf": [
|
||||||
{
|
{
|
||||||
@@ -10033,6 +10071,14 @@
|
|||||||
"type": "boolean",
|
"type": "boolean",
|
||||||
"default": false
|
"default": false
|
||||||
},
|
},
|
||||||
|
"dockerAlertOnStopped": {
|
||||||
|
"type": "boolean",
|
||||||
|
"default": false
|
||||||
|
},
|
||||||
|
"dockerAlertOnUnhealthy": {
|
||||||
|
"type": "boolean",
|
||||||
|
"default": false
|
||||||
|
},
|
||||||
"dnsServer": {
|
"dnsServer": {
|
||||||
"type": "string",
|
"type": "string",
|
||||||
"minLength": 1
|
"minLength": 1
|
||||||
@@ -10600,6 +10646,12 @@
|
|||||||
"dockerLogsEnabled": {
|
"dockerLogsEnabled": {
|
||||||
"type": "boolean"
|
"type": "boolean"
|
||||||
},
|
},
|
||||||
|
"dockerAlertOnStopped": {
|
||||||
|
"type": "boolean"
|
||||||
|
},
|
||||||
|
"dockerAlertOnUnhealthy": {
|
||||||
|
"type": "boolean"
|
||||||
|
},
|
||||||
"dockerTlsCa": {
|
"dockerTlsCa": {
|
||||||
"anyOf": [
|
"anyOf": [
|
||||||
{
|
{
|
||||||
|
|||||||
Generated
+486
-445
File diff suppressed because it is too large.
Load diff
+6
-5
@@ -30,7 +30,8 @@
|
|||||||
"author": "",
|
"author": "",
|
||||||
"license": "ISC",
|
"license": "ISC",
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">=20"
|
"node": ">=24",
|
||||||
|
"npm": ">=11"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@csstools/css-tokenizer": "^3.0.4",
|
"@csstools/css-tokenizer": "^3.0.4",
|
||||||
@@ -45,7 +46,7 @@
|
|||||||
"dayjs": "^1.11.23",
|
"dayjs": "^1.11.23",
|
||||||
"dockerode": "5.0.0",
|
"dockerode": "5.0.0",
|
||||||
"dotenv": "^16.4.5",
|
"dotenv": "^16.4.5",
|
||||||
"express": "^4.19.2",
|
"express": "5.2.1",
|
||||||
"express-rate-limit": "^8.5.1",
|
"express-rate-limit": "^8.5.1",
|
||||||
"gamedig": "5.2.0",
|
"gamedig": "5.2.0",
|
||||||
"got": "14.4.7",
|
"got": "14.4.7",
|
||||||
@@ -58,11 +59,11 @@
|
|||||||
"jsonwebtoken": "9.0.2",
|
"jsonwebtoken": "9.0.2",
|
||||||
"mjml": "^5.0.0-alpha.4",
|
"mjml": "^5.0.0-alpha.4",
|
||||||
"mongoose": "^8.3.3",
|
"mongoose": "^8.3.3",
|
||||||
"multer": "^1.4.5-lts.2",
|
"multer": "2.4.0",
|
||||||
"nodemailer": "9.0.5",
|
"nodemailer": "9.1.1",
|
||||||
"ping": "0.4.4",
|
"ping": "0.4.4",
|
||||||
"re2": "^1.24.1",
|
"re2": "^1.24.1",
|
||||||
"sharp": "0.35.3",
|
"sharp": "0.35.4",
|
||||||
"ssl-checker": "2.0.10",
|
"ssl-checker": "2.0.10",
|
||||||
"swagger-ui-express": "5.0.1",
|
"swagger-ui-express": "5.0.1",
|
||||||
"tldts": "^7.4.10",
|
"tldts": "^7.4.10",
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { AppError } from "@/utils/AppError.js";
|
import { AppError } from "@/utils/AppError.js";
|
||||||
import { Monitor, MonitorTypes, type MonitorType } from "@/domain/monitors/monitor.type.js";
|
import { Monitor } from "@/domain/monitors/monitor.type.js";
|
||||||
import { UserRole } from "@/domain/users/user.type.js";
|
import { UserRole } from "@/domain/users/user.type.js";
|
||||||
import sslChecker, { SSLDetails } from "ssl-checker";
|
import sslChecker, { SSLDetails } from "ssl-checker";
|
||||||
import * as whoiser from "whoiser";
|
import * as whoiser from "whoiser";
|
||||||
@@ -124,23 +124,6 @@ export const fetchMonitorDomain = async (
|
|||||||
return domainExpiry;
|
return domainExpiry;
|
||||||
};
|
};
|
||||||
|
|
||||||
export const requireString = (value: unknown, fieldName: string): string => {
|
|
||||||
if (typeof value === "string" && value.trim().length > 0) {
|
|
||||||
return value;
|
|
||||||
}
|
|
||||||
throw new AppError({ message: `${fieldName} is required`, status: 400 });
|
|
||||||
};
|
|
||||||
|
|
||||||
export const optionalString = (value: unknown, fieldName: string): string | undefined => {
|
|
||||||
if (value === undefined) {
|
|
||||||
return undefined;
|
|
||||||
}
|
|
||||||
if (typeof value === "string") {
|
|
||||||
return value;
|
|
||||||
}
|
|
||||||
throw new AppError({ message: `${fieldName} must be a string`, status: 400 });
|
|
||||||
};
|
|
||||||
|
|
||||||
export const extractString = (value: unknown): string | undefined => {
|
export const extractString = (value: unknown): string | undefined => {
|
||||||
const candidate = Array.isArray(value) ? value[0] : value;
|
const candidate = Array.isArray(value) ? value[0] : value;
|
||||||
if (typeof candidate !== "string" || candidate.length === 0) {
|
if (typeof candidate !== "string" || candidate.length === 0) {
|
||||||
@@ -149,52 +132,6 @@ export const extractString = (value: unknown): string | undefined => {
|
|||||||
return candidate;
|
return candidate;
|
||||||
};
|
};
|
||||||
|
|
||||||
export const optionalNumber = (value: unknown, fieldName: string): number | undefined => {
|
|
||||||
if (value === undefined) {
|
|
||||||
return undefined;
|
|
||||||
}
|
|
||||||
if (typeof value === "number" && Number.isFinite(value)) {
|
|
||||||
return value;
|
|
||||||
}
|
|
||||||
if (typeof value === "string" && value.trim() !== "") {
|
|
||||||
const parsed = Number(value);
|
|
||||||
if (!Number.isNaN(parsed)) {
|
|
||||||
return parsed;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
throw new AppError({ message: `${fieldName} must be a number`, status: 400 });
|
|
||||||
};
|
|
||||||
|
|
||||||
export const parseMonitorTypeFilter = (value: unknown): MonitorType | MonitorType[] | undefined => {
|
|
||||||
const parseSingle = (input: unknown): MonitorType => {
|
|
||||||
if (typeof input !== "string") {
|
|
||||||
throw new AppError({ message: "Monitor type must be a string", status: 400 });
|
|
||||||
}
|
|
||||||
if (!MonitorTypes.includes(input as MonitorType)) {
|
|
||||||
throw new AppError({ message: `Invalid monitor type: ${input}`, status: 400 });
|
|
||||||
}
|
|
||||||
return input as MonitorType;
|
|
||||||
};
|
|
||||||
|
|
||||||
if (value === undefined) {
|
|
||||||
return undefined;
|
|
||||||
}
|
|
||||||
if (Array.isArray(value)) {
|
|
||||||
return value.map((entry) => parseSingle(entry));
|
|
||||||
}
|
|
||||||
return parseSingle(value);
|
|
||||||
};
|
|
||||||
|
|
||||||
export const parseSortOrder = (value: unknown): "asc" | "desc" | undefined => {
|
|
||||||
if (value === undefined) {
|
|
||||||
return undefined;
|
|
||||||
}
|
|
||||||
if (value === "asc" || value === "desc") {
|
|
||||||
return value;
|
|
||||||
}
|
|
||||||
throw new AppError({ message: "order must be either 'asc' or 'desc'", status: 400 });
|
|
||||||
};
|
|
||||||
|
|
||||||
export const requireTeamId = (teamId?: string): string => {
|
export const requireTeamId = (teamId?: string): string => {
|
||||||
if (!teamId) {
|
if (!teamId) {
|
||||||
throw new AppError({ message: "Team ID is required", status: 400 });
|
throw new AppError({ message: "Team ID is required", status: 400 });
|
||||||
|
|||||||
@@ -35,11 +35,18 @@ class NotificationController implements INotificationController {
|
|||||||
|
|
||||||
testNotification = catchAsync(async (req: Request, res: Response) => {
|
testNotification = catchAsync(async (req: Request, res: Response) => {
|
||||||
const notification = testNotificationBodyValidation.parse(req.body);
|
const notification = testNotificationBodyValidation.parse(req.body);
|
||||||
const success = await this.notificationsService.sendTestNotification(notification);
|
let success = false;
|
||||||
|
let reason: string | undefined;
|
||||||
|
try {
|
||||||
|
success = await this.notificationsService.sendTestNotification(notification);
|
||||||
|
} catch (error: unknown) {
|
||||||
|
if (!(error instanceof AppError)) throw error;
|
||||||
|
reason = error.message;
|
||||||
|
}
|
||||||
|
|
||||||
return res.status(200).json({
|
return res.status(200).json({
|
||||||
success,
|
success,
|
||||||
msg: success ? "Notification sent successfully" : "Notification could not be sent — check the destination details.",
|
msg: success ? "Notification sent successfully" : (reason ?? "Notification could not be sent — check the destination details."),
|
||||||
details: { service: SERVICE_NAME },
|
details: { service: SERVICE_NAME },
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -9,6 +9,8 @@ import { IProxiesService } from "@/domain/proxies/proxy.service.js";
|
|||||||
import { IEgressStateService } from "@/domain/egress/egress-state.service.js";
|
import { IEgressStateService } from "@/domain/egress/egress-state.service.js";
|
||||||
import { Settings } from "@/domain/app-settings/app-settings.type.js";
|
import { Settings } from "@/domain/app-settings/app-settings.type.js";
|
||||||
|
|
||||||
|
const SERVICE_NAME = "SettingsController";
|
||||||
|
|
||||||
export interface ISettingsController {
|
export interface ISettingsController {
|
||||||
getAppSettings: RequestHandler;
|
getAppSettings: RequestHandler;
|
||||||
updateAppSettings: RequestHandler;
|
updateAppSettings: RequestHandler;
|
||||||
@@ -119,7 +121,7 @@ class SettingsController implements ISettingsController {
|
|||||||
|
|
||||||
const html = await this.emailService.buildEmail("testEmailTemplate", context);
|
const html = await this.emailService.buildEmail("testEmailTemplate", context);
|
||||||
if (!html) {
|
if (!html) {
|
||||||
throw new AppError({ message: "Failed to build email template.", status: 500 });
|
throw new AppError({ message: "Failed to build email template.", status: 500, service: SERVICE_NAME, method: "sendTestEmail" });
|
||||||
}
|
}
|
||||||
let messageId: string;
|
let messageId: string;
|
||||||
try {
|
try {
|
||||||
@@ -142,8 +144,11 @@ class SettingsController implements ISettingsController {
|
|||||||
// Surface the underlying SMTP failure: diagnosing the settings is the whole
|
// Surface the underlying SMTP failure: diagnosing the settings is the whole
|
||||||
// point of the test email endpoint.
|
// point of the test email endpoint.
|
||||||
throw new AppError({
|
throw new AppError({
|
||||||
message: error instanceof Error ? `Failed to send test email. ${error.message}` : "Failed to send test email.",
|
message: error instanceof AppError ? error.message : "Failed to send test email.",
|
||||||
status: 500,
|
status: error instanceof AppError ? error.status : 500,
|
||||||
|
service: SERVICE_NAME,
|
||||||
|
method: "sendTestEmail",
|
||||||
|
details: error instanceof AppError ? error.details : undefined,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -53,14 +53,10 @@ export const sanitizeBody = (options = {}): ((req: Request, res: Response, next:
|
|||||||
|
|
||||||
export const sanitizeQuery = (options = {}): ((req: Request, res: Response, next: NextFunction) => void) => {
|
export const sanitizeQuery = (options = {}): ((req: Request, res: Response, next: NextFunction) => void) => {
|
||||||
return (req: Request, res: Response, next: NextFunction) => {
|
return (req: Request, res: Response, next: NextFunction) => {
|
||||||
if (req.query && typeof req.query === "object") {
|
const sanitized = Object.fromEntries(
|
||||||
for (const key of Object.keys(req.query)) {
|
Object.entries(req.query).map(([key, value]) => [key, typeof value === "string" ? sanitizeInput(value, options) : value])
|
||||||
const value = req.query[key];
|
);
|
||||||
if (typeof value === "string") {
|
Object.defineProperty(req, "query", { value: sanitized, writable: true, configurable: true, enumerable: true });
|
||||||
req.query[key] = sanitizeInput(value, options);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
next();
|
next();
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
@@ -220,6 +220,8 @@ export const createMonitorBodyValidation = z
|
|||||||
geoCheckLocations: z.array(z.enum(GeoContinents)).optional(),
|
geoCheckLocations: z.array(z.enum(GeoContinents)).optional(),
|
||||||
geoCheckInterval: z.number().min(300000).optional(),
|
geoCheckInterval: z.number().min(300000).optional(),
|
||||||
dockerLogsEnabled: z.boolean().optional(),
|
dockerLogsEnabled: z.boolean().optional(),
|
||||||
|
dockerAlertOnStopped: z.boolean().optional(),
|
||||||
|
dockerAlertOnUnhealthy: z.boolean().optional(),
|
||||||
dockerTlsCa: z.union([z.string(), z.literal("")]).optional(),
|
dockerTlsCa: z.union([z.string(), z.literal("")]).optional(),
|
||||||
dockerTlsCert: z.union([z.string(), z.literal("")]).optional(),
|
dockerTlsCert: z.union([z.string(), z.literal("")]).optional(),
|
||||||
dockerTlsKey: z.union([z.string(), z.literal("")]).optional(),
|
dockerTlsKey: z.union([z.string(), z.literal("")]).optional(),
|
||||||
@@ -270,6 +272,8 @@ export const editMonitorBodyValidation = z
|
|||||||
geoCheckLocations: z.array(z.enum(GeoContinents)).optional(),
|
geoCheckLocations: z.array(z.enum(GeoContinents)).optional(),
|
||||||
geoCheckInterval: z.number().min(300000).optional(),
|
geoCheckInterval: z.number().min(300000).optional(),
|
||||||
dockerLogsEnabled: z.boolean().optional(),
|
dockerLogsEnabled: z.boolean().optional(),
|
||||||
|
dockerAlertOnStopped: z.boolean().optional(),
|
||||||
|
dockerAlertOnUnhealthy: z.boolean().optional(),
|
||||||
dockerTlsCa: z.union([z.string(), z.literal("")]).optional(),
|
dockerTlsCa: z.union([z.string(), z.literal("")]).optional(),
|
||||||
dockerTlsCert: z.union([z.string(), z.literal("")]).optional(),
|
dockerTlsCert: z.union([z.string(), z.literal("")]).optional(),
|
||||||
dockerTlsKey: z.union([z.string(), z.literal("")]).optional(),
|
dockerTlsKey: z.union([z.string(), z.literal("")]).optional(),
|
||||||
@@ -350,6 +354,8 @@ const importedMonitorSchema = z
|
|||||||
geoCheckLocations: z.array(z.enum(GeoContinents)).default([]),
|
geoCheckLocations: z.array(z.enum(GeoContinents)).default([]),
|
||||||
geoCheckInterval: z.number().min(300000).default(300000),
|
geoCheckInterval: z.number().min(300000).default(300000),
|
||||||
dockerLogsEnabled: z.boolean().default(false),
|
dockerLogsEnabled: z.boolean().default(false),
|
||||||
|
dockerAlertOnStopped: z.boolean().default(false),
|
||||||
|
dockerAlertOnUnhealthy: z.boolean().default(false),
|
||||||
dnsServer: dnsServerValidation.optional(),
|
dnsServer: dnsServerValidation.optional(),
|
||||||
dnsRecordType: z.enum(DnsRecordTypes).optional(),
|
dnsRecordType: z.enum(DnsRecordTypes).optional(),
|
||||||
createdAt: z.string().optional(),
|
createdAt: z.string().optional(),
|
||||||
@@ -440,6 +446,8 @@ export const monitorResponseSchema = z
|
|||||||
geoCheckLocations: z.array(z.enum(GeoContinents)),
|
geoCheckLocations: z.array(z.enum(GeoContinents)),
|
||||||
geoCheckInterval: z.number(),
|
geoCheckInterval: z.number(),
|
||||||
dockerLogsEnabled: z.boolean(),
|
dockerLogsEnabled: z.boolean(),
|
||||||
|
dockerAlertOnStopped: z.boolean(),
|
||||||
|
dockerAlertOnUnhealthy: z.boolean(),
|
||||||
dockerTlsCa: z.string().optional(),
|
dockerTlsCa: z.string().optional(),
|
||||||
dockerTlsCert: z.string().optional(),
|
dockerTlsCert: z.string().optional(),
|
||||||
dockerTlsKeySet: z.boolean().optional(),
|
dockerTlsKeySet: z.boolean().optional(),
|
||||||
|
|||||||
+1
-1
@@ -122,7 +122,7 @@ export const createApp = ({
|
|||||||
setupRoutes(app, controllers, services);
|
setupRoutes(app, controllers, services);
|
||||||
|
|
||||||
// FE routes
|
// FE routes
|
||||||
app.get("*", (req, res) => {
|
app.get("/{*splat}", (req, res) => {
|
||||||
res.sendFile(path.join(frontendPath, "index.html"));
|
res.sendFile(path.join(frontendPath, "index.html"));
|
||||||
});
|
});
|
||||||
app.use(handleErrors(services.logger));
|
app.use(handleErrors(services.logger));
|
||||||
|
|||||||
@@ -1,5 +1,4 @@
|
|||||||
import { Resolver } from "dns/promises";
|
import { Resolver } from "dns/promises";
|
||||||
import axios from "axios";
|
|
||||||
import got from "got";
|
import got from "got";
|
||||||
import ping from "ping";
|
import ping from "ping";
|
||||||
import Docker from "dockerode";
|
import Docker from "dockerode";
|
||||||
@@ -13,7 +12,7 @@ import mongoose from "mongoose";
|
|||||||
|
|
||||||
import { EnvConfig } from "@/domain/app-settings/app-settings.service.js";
|
import { EnvConfig } from "@/domain/app-settings/app-settings.service.js";
|
||||||
import { SharedServices } from "@/config/services.shared.js";
|
import { SharedServices } from "@/config/services.shared.js";
|
||||||
import { INetworkService, NetworkService } from "@/service/networkService.js";
|
import { IProviderRegistry, ProviderRegistry } from "@/service/networkProviders/providerRegistry.js";
|
||||||
import { IBufferService, BufferService } from "@/service/bufferService.js";
|
import { IBufferService, BufferService } from "@/service/bufferService.js";
|
||||||
import { IStatusService, StatusService } from "@/service/statusService.js";
|
import { IStatusService, StatusService } from "@/service/statusService.js";
|
||||||
import { SecretsRotationService } from "@/service/encryption/secretsRotationService.js";
|
import { SecretsRotationService } from "@/service/encryption/secretsRotationService.js";
|
||||||
@@ -24,25 +23,25 @@ import { NotificationReactor } from "@/worker/reactors/reactor.notification.js";
|
|||||||
import { IncidentReactor } from "@/worker/reactors/reactor.incident.js";
|
import { IncidentReactor } from "@/worker/reactors/reactor.incident.js";
|
||||||
import { ReactorDispatcher } from "@/worker/reactors/reactor.dispatcher.js";
|
import { ReactorDispatcher } from "@/worker/reactors/reactor.dispatcher.js";
|
||||||
import { DBQueueWorker } from "@/worker/worker.db-queue.js";
|
import { DBQueueWorker } from "@/worker/worker.db-queue.js";
|
||||||
import { ProxyResolver } from "@/service/network/ProxyResolver.js";
|
import { ProxyResolver } from "@/service/networkProviders/ProxyResolver.js";
|
||||||
import { IEgressService, EgressService } from "@/domain/egress/egress.service.js";
|
import { IEgressService, EgressService } from "@/domain/egress/egress.service.js";
|
||||||
// Network providers
|
// Network providers
|
||||||
import { PingProvider } from "@/service/network/PingProvider.js";
|
import { PingProvider } from "@/service/networkProviders/PingProvider.js";
|
||||||
import { HttpProvider } from "@/service/network/HttpProvider.js";
|
import { HttpProvider } from "@/service/networkProviders/HttpProvider.js";
|
||||||
import { AdvancedMatcher } from "@/service/network/AdvancedMatcher.js";
|
import { AdvancedMatcher } from "@/service/networkProviders/AdvancedMatcher.js";
|
||||||
import { PageSpeedProvider } from "@/service/network/PageSpeedProvider.js";
|
import { PageSpeedProvider } from "@/service/networkProviders/PageSpeedProvider.js";
|
||||||
import { HardwareProvider } from "@/service/network/HardwareProvider.js";
|
import { HardwareProvider } from "@/service/networkProviders/HardwareProvider.js";
|
||||||
import { DockerProvider } from "@/service/network/DockerProvider.js";
|
import { DockerProvider } from "@/service/networkProviders/DockerProvider.js";
|
||||||
import { PortProvider } from "@/service/network/PortProvider.js";
|
import { PortProvider } from "@/service/networkProviders/PortProvider.js";
|
||||||
import { GameProvider } from "@/service/network/GameProvider.js";
|
import { GameProvider } from "@/service/networkProviders/GameProvider.js";
|
||||||
import { GrpcProvider } from "@/service/network/GrpcProvider.js";
|
import { GrpcProvider } from "@/service/networkProviders/GrpcProvider.js";
|
||||||
import { WebSocketProvider } from "@/service/network/WebSocketProvider.js";
|
import { WebSocketProvider } from "@/service/networkProviders/WebSocketProvider.js";
|
||||||
import { DNSProvider } from "@/service/network/DNSProvider.js";
|
import { DNSProvider } from "@/service/networkProviders/DNSProvider.js";
|
||||||
import { AppError } from "@/utils/AppError.js";
|
import { AppError } from "@/utils/AppError.js";
|
||||||
import { WorkerPipeline } from "@/worker/worker.pipeline.js";
|
import { WorkerPipeline } from "@/worker/worker.pipeline.js";
|
||||||
export interface WorkerServices {
|
export interface WorkerServices {
|
||||||
worker: IQueueWorker;
|
worker: IQueueWorker;
|
||||||
networkService: INetworkService;
|
networkService: IProviderRegistry;
|
||||||
bufferService: IBufferService;
|
bufferService: IBufferService;
|
||||||
statusService: IStatusService;
|
statusService: IStatusService;
|
||||||
egressService: IEgressService;
|
egressService: IEgressService;
|
||||||
@@ -87,7 +86,7 @@ export const buildWorker = async (shared: SharedServices, envSettings: EnvConfig
|
|||||||
const webSocketProvider = new WebSocketProvider(WebSocket);
|
const webSocketProvider = new WebSocketProvider(WebSocket);
|
||||||
const dnsProvider = new DNSProvider(() => new Resolver());
|
const dnsProvider = new DNSProvider(() => new Resolver());
|
||||||
|
|
||||||
const networkService = new NetworkService(axios, logger, [
|
const providerRegistry = new ProviderRegistry([
|
||||||
pingProvider,
|
pingProvider,
|
||||||
httpProvider,
|
httpProvider,
|
||||||
pageSpeedProvider,
|
pageSpeedProvider,
|
||||||
@@ -105,7 +104,7 @@ export const buildWorker = async (shared: SharedServices, envSettings: EnvConfig
|
|||||||
pipeline.ingestChecks(checks)
|
pipeline.ingestChecks(checks)
|
||||||
);
|
);
|
||||||
const statusService = new StatusService(logger, monitorsRepository, monitorStatsRepository);
|
const statusService = new StatusService(logger, monitorsRepository, monitorStatsRepository);
|
||||||
const egressService = new EgressService(settingsService, egressStateRepository, jobsRepository, networkService, proxyResolver, logger);
|
const egressService = new EgressService(settingsService, egressStateRepository, jobsRepository, providerRegistry, proxyResolver, logger);
|
||||||
|
|
||||||
const notificationReactor = new NotificationReactor(notificationsService);
|
const notificationReactor = new NotificationReactor(notificationsService);
|
||||||
const incidentReactor = new IncidentReactor(incidentService);
|
const incidentReactor = new IncidentReactor(incidentService);
|
||||||
@@ -118,7 +117,7 @@ export const buildWorker = async (shared: SharedServices, envSettings: EnvConfig
|
|||||||
checksRepository,
|
checksRepository,
|
||||||
jobsRepository,
|
jobsRepository,
|
||||||
checkService,
|
checkService,
|
||||||
networkService,
|
providerRegistry: providerRegistry,
|
||||||
proxyResolver,
|
proxyResolver,
|
||||||
bufferService,
|
bufferService,
|
||||||
dockerLogsService,
|
dockerLogsService,
|
||||||
@@ -188,5 +187,5 @@ export const buildWorker = async (shared: SharedServices, envSettings: EnvConfig
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
return { worker, networkService, bufferService, statusService, egressService };
|
return { worker, networkService: providerRegistry, bufferService, statusService, egressService };
|
||||||
};
|
};
|
||||||
@@ -220,6 +220,7 @@ const dockerContainerSchema = new Schema<DockerContainerInfo>(
|
|||||||
startedAt: { type: String },
|
startedAt: { type: String },
|
||||||
ports: { type: [dockerContainerPortSchema], default: undefined },
|
ports: { type: [dockerContainerPortSchema], default: undefined },
|
||||||
mounts: { type: [dockerContainerMountSchema], default: undefined },
|
mounts: { type: [dockerContainerMountSchema], default: undefined },
|
||||||
|
exitCode: { type: Number, default: undefined },
|
||||||
},
|
},
|
||||||
{ _id: false }
|
{ _id: false }
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -13,7 +13,6 @@ const SERVICE_NAME = "checkService";
|
|||||||
export interface ICheckService {
|
export interface ICheckService {
|
||||||
createChecks(checks: Check[]): Promise<Check[]>;
|
createChecks(checks: Check[]): Promise<Check[]>;
|
||||||
toCheck(statusResponse: MonitorStatusResponse<MonitorPayloadMap[keyof MonitorPayloadMap]>): Check | undefined;
|
toCheck(statusResponse: MonitorStatusResponse<MonitorPayloadMap[keyof MonitorPayloadMap]>): Check | undefined;
|
||||||
toStatusResponse(check: Check): MonitorStatusResponse<MonitorPayloadMap[keyof MonitorPayloadMap]>;
|
|
||||||
getChecksByMonitor(params: {
|
getChecksByMonitor(params: {
|
||||||
monitorId: string;
|
monitorId: string;
|
||||||
teamId: string;
|
teamId: string;
|
||||||
@@ -135,24 +134,6 @@ export class CheckService implements ICheckService {
|
|||||||
return check;
|
return check;
|
||||||
};
|
};
|
||||||
|
|
||||||
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:
|
|
||||||
check.metadata.type === "hardware"
|
|
||||||
? ({ data: { cpu: check.cpu, memory: check.memory, disk: check.disk, host: check.host, net: check.net } } as HardwareStatusPayload)
|
|
||||||
: undefined,
|
|
||||||
};
|
|
||||||
return statusResponse;
|
|
||||||
};
|
|
||||||
|
|
||||||
getChecksByMonitor = async ({
|
getChecksByMonitor = async ({
|
||||||
monitorId,
|
monitorId,
|
||||||
teamId,
|
teamId,
|
||||||
|
|||||||
@@ -45,12 +45,14 @@ const mapMemory = (memory?: CheckMemoryInfo): SnapshotMemoryInfo | undefined =>
|
|||||||
};
|
};
|
||||||
|
|
||||||
const mapDisk = (disk?: CheckDiskInfo[]): SnapshotDiskInfo[] | undefined =>
|
const mapDisk = (disk?: CheckDiskInfo[]): SnapshotDiskInfo[] | undefined =>
|
||||||
disk?.map((entry) => ({
|
disk
|
||||||
device: entry.device,
|
?.filter((entry) => entry != null)
|
||||||
total_bytes: entry.total_bytes,
|
.map((entry) => ({
|
||||||
used_bytes: entry.used_bytes,
|
device: entry.device,
|
||||||
usage_percent: entry.usage_percent,
|
total_bytes: entry.total_bytes,
|
||||||
}));
|
used_bytes: entry.used_bytes,
|
||||||
|
usage_percent: entry.usage_percent,
|
||||||
|
}));
|
||||||
|
|
||||||
const mapHost = (host?: CheckHostInfo): SnapshotHostInfo | undefined =>
|
const mapHost = (host?: CheckHostInfo): SnapshotHostInfo | undefined =>
|
||||||
host && {
|
host && {
|
||||||
|
|||||||
@@ -0,0 +1,47 @@
|
|||||||
|
import type { DockerContainerInfo } from "@/domain/docker/docker.type.js";
|
||||||
|
import type { Monitor } from "@/domain/monitors/monitor.type.js";
|
||||||
|
|
||||||
|
export const DockerAlertReasons = ["stopped", "unhealthy"] as const;
|
||||||
|
export type DockerAlertReason = (typeof DockerAlertReasons)[number];
|
||||||
|
|
||||||
|
export interface DockerContainerBreach {
|
||||||
|
name: string;
|
||||||
|
reason: DockerAlertReason;
|
||||||
|
exitCode?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const isContainerStopped = (container: DockerContainerInfo): boolean => {
|
||||||
|
// Container with exit code 0 is probably a one shot container don't report stopped
|
||||||
|
return container.state === "dead" || (container.state === "exited" && container.exitCode !== 0);
|
||||||
|
};
|
||||||
|
|
||||||
|
export const isContainerUnhealthy = (container: DockerContainerInfo): boolean => {
|
||||||
|
return container.health === "unhealthy";
|
||||||
|
};
|
||||||
|
|
||||||
|
export const findContainerBreaches = (monitor: Monitor, containers: DockerContainerInfo[]): DockerContainerBreach[] => {
|
||||||
|
const breaches: DockerContainerBreach[] = [];
|
||||||
|
for (const container of containers) {
|
||||||
|
if (monitor.dockerAlertOnStopped && isContainerStopped(container)) {
|
||||||
|
breaches.push({
|
||||||
|
name: container.name,
|
||||||
|
reason: "stopped",
|
||||||
|
exitCode: container.exitCode,
|
||||||
|
});
|
||||||
|
} else if (monitor.dockerAlertOnUnhealthy && isContainerUnhealthy(container)) {
|
||||||
|
breaches.push({
|
||||||
|
name: container.name,
|
||||||
|
reason: "unhealthy",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return breaches;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const describeContainerBreach = (breach: DockerContainerBreach): string => {
|
||||||
|
if (breach.reason === "unhealthy") return `${breach.name}: unhealthy`;
|
||||||
|
if (breach.exitCode === undefined) {
|
||||||
|
return `${breach.name}: stopped`;
|
||||||
|
}
|
||||||
|
return `${breach.name}: stopped (exit code ${breach.exitCode})`;
|
||||||
|
};
|
||||||
@@ -55,6 +55,7 @@ export interface DockerContainerInfo {
|
|||||||
startedAt?: string; // ISO date
|
startedAt?: string; // ISO date
|
||||||
ports?: DockerContainerPort[];
|
ports?: DockerContainerPort[];
|
||||||
mounts?: DockerContainerMount[];
|
mounts?: DockerContainerMount[];
|
||||||
|
exitCode?: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface DockerContainerSummary {
|
export interface DockerContainerSummary {
|
||||||
|
|||||||
@@ -3,8 +3,8 @@ import type { ISettingsService } from "@/domain/app-settings/app-settings.servic
|
|||||||
import type { IEgressStateRepository } from "@/domain/egress/egress-state.repository.interface.js";
|
import type { IEgressStateRepository } from "@/domain/egress/egress-state.repository.interface.js";
|
||||||
import type { IJobsRepository } from "@/domain/jobs/job.repository.interface.js";
|
import type { IJobsRepository } from "@/domain/jobs/job.repository.interface.js";
|
||||||
import { jobId, type Job, type JobSeed } from "@/domain/jobs/job.type.js";
|
import { jobId, type Job, type JobSeed } from "@/domain/jobs/job.type.js";
|
||||||
import type { INetworkService } from "@/service/networkService.js";
|
import type { IProviderRegistry } from "@/service/networkProviders/providerRegistry.js";
|
||||||
import type { IProxyResolver } from "@/service/network/ProxyResolver.js";
|
import type { IProxyResolver } from "@/service/networkProviders/ProxyResolver.js";
|
||||||
import type { ILogger } from "@/utils/logger.js";
|
import type { ILogger } from "@/utils/logger.js";
|
||||||
import {
|
import {
|
||||||
EGRESS_RECOVERY_POLL_SECONDS,
|
EGRESS_RECOVERY_POLL_SECONDS,
|
||||||
@@ -13,7 +13,7 @@ import {
|
|||||||
type EgressProbeResult,
|
type EgressProbeResult,
|
||||||
type EgressStatus,
|
type EgressStatus,
|
||||||
} from "@/domain/egress/egress.type.js";
|
} from "@/domain/egress/egress.type.js";
|
||||||
import { timeRequest } from "@/service/network/utils.js";
|
import { timeRequest } from "@/service/networkProviders/utils.js";
|
||||||
import { parseEgressTarget, type EgressTarget } from "@/utils/egressTarget.js";
|
import { parseEgressTarget, type EgressTarget } from "@/utils/egressTarget.js";
|
||||||
|
|
||||||
const SERVICE_NAME = "EgressService";
|
const SERVICE_NAME = "EgressService";
|
||||||
@@ -54,7 +54,7 @@ export class EgressService implements IEgressService {
|
|||||||
private settingsService: ISettingsService,
|
private settingsService: ISettingsService,
|
||||||
private egressStateRepository: IEgressStateRepository,
|
private egressStateRepository: IEgressStateRepository,
|
||||||
private jobsRepository: IJobsRepository,
|
private jobsRepository: IJobsRepository,
|
||||||
private networkService: INetworkService,
|
private providerRegistry: IProviderRegistry,
|
||||||
private proxyResolver: IProxyResolver,
|
private proxyResolver: IProxyResolver,
|
||||||
private logger: ILogger
|
private logger: ILogger
|
||||||
) {}
|
) {}
|
||||||
@@ -100,7 +100,7 @@ export class EgressService implements IEgressService {
|
|||||||
const { response, responseTime, error } = await timeRequest(async () => {
|
const { response, responseTime, error } = await timeRequest(async () => {
|
||||||
const proxyUrl = await this.proxyResolver.resolve(monitor);
|
const proxyUrl = await this.proxyResolver.resolve(monitor);
|
||||||
return Promise.race([
|
return Promise.race([
|
||||||
this.networkService.requestStatus(monitor, { proxyUrl }),
|
this.providerRegistry.probe(monitor, { proxyUrl }),
|
||||||
new Promise<never>((_, reject) => {
|
new Promise<never>((_, reject) => {
|
||||||
timer = setTimeout(() => reject(new Error(`Egress probe timed out after ${PROBE_TIMEOUT_MS}ms`)), PROBE_TIMEOUT_MS);
|
timer = setTimeout(() => reject(new Error(`Egress probe timed out after ${PROBE_TIMEOUT_MS}ms`)), PROBE_TIMEOUT_MS);
|
||||||
}),
|
}),
|
||||||
|
|||||||
@@ -1,6 +1,5 @@
|
|||||||
const SERVICE_NAME = "incidentService";
|
|
||||||
import type { Monitor } from "@/domain/monitors/monitor.type.js";
|
import type { Monitor } from "@/domain/monitors/monitor.type.js";
|
||||||
import type { MonitorStatusResponse } from "@/types/network.js";
|
import type { Check } from "@/domain/checks/check.type.js";
|
||||||
import { AppError } from "@/utils/AppError.js";
|
import { AppError } from "@/utils/AppError.js";
|
||||||
import { getDateForRange } from "@/utils/dataUtils.js";
|
import { getDateForRange } from "@/utils/dataUtils.js";
|
||||||
import type { IIncidentsRepository } from "@/domain/incidents/incident.repository.interface.js";
|
import type { IIncidentsRepository } from "@/domain/incidents/incident.repository.interface.js";
|
||||||
@@ -13,13 +12,9 @@ import type { INotificationMessageBuilder } from "@/domain/notifications/notific
|
|||||||
import type { ILogger } from "@/utils/logger.js";
|
import type { ILogger } from "@/utils/logger.js";
|
||||||
import { DateRange } from "@/types/query.js";
|
import { DateRange } from "@/types/query.js";
|
||||||
|
|
||||||
|
const SERVICE_NAME = "incidentService";
|
||||||
export interface IIncidentService {
|
export interface IIncidentService {
|
||||||
handleIncident(
|
handleIncident(monitor: Monitor, decision: MonitorActionDecision, check: Check): Promise<Incident | null>;
|
||||||
monitor: Monitor,
|
|
||||||
code: number,
|
|
||||||
decision: MonitorActionDecision,
|
|
||||||
monitorStatusResponse?: MonitorStatusResponse
|
|
||||||
): Promise<Incident | null>;
|
|
||||||
resolveIncident(incidentId: string, userId: string, teamId: string, comment?: string, userEmail?: string): Promise<Incident>;
|
resolveIncident(incidentId: string, userId: string, teamId: string, comment?: string, userEmail?: string): Promise<Incident>;
|
||||||
getIncidentsByTeam(
|
getIncidentsByTeam(
|
||||||
teamId: string,
|
teamId: string,
|
||||||
@@ -58,67 +53,38 @@ export class IncidentService implements IIncidentService {
|
|||||||
this.notificationMessageBuilder = notificationMessageBuilder;
|
this.notificationMessageBuilder = notificationMessageBuilder;
|
||||||
}
|
}
|
||||||
|
|
||||||
handleIncident = async (
|
handleIncident = async (monitor: Monitor, decision: MonitorActionDecision, check: Check): Promise<Incident | null> => {
|
||||||
monitor: Monitor,
|
const { transition } = decision;
|
||||||
code: number,
|
if (transition === null) return null;
|
||||||
decision: MonitorActionDecision,
|
|
||||||
monitorStatusResponse?: MonitorStatusResponse
|
|
||||||
): Promise<Incident | null> => {
|
|
||||||
if (!decision.shouldCreateIncident && !decision.shouldResolveIncident) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
const activeIncident = await this.incidentsRepository.findActiveByMonitorId(monitor.id, monitor.teamId);
|
const activeIncident = await this.incidentsRepository.findActiveByMonitorId(monitor.id, monitor.teamId);
|
||||||
|
|
||||||
if (decision.shouldCreateIncident) {
|
switch (transition) {
|
||||||
if (activeIncident) {
|
case "status_down":
|
||||||
return activeIncident;
|
case "threshold_breach": {
|
||||||
} else {
|
if (activeIncident) return activeIncident;
|
||||||
let statusCode = code;
|
// A threshold breach has no HTTP status
|
||||||
let message: string | undefined;
|
const isBreach = transition === "threshold_breach";
|
||||||
|
return await this.incidentsRepository.create({
|
||||||
// For threshold breaches, use 9999 status code and build descriptive message
|
|
||||||
if (decision.incidentReason === "threshold_breach") {
|
|
||||||
statusCode = 9999;
|
|
||||||
message = this.buildThresholdBreachMessage(monitor, monitorStatusResponse);
|
|
||||||
}
|
|
||||||
|
|
||||||
const incident = {
|
|
||||||
monitorId: monitor.id,
|
monitorId: monitor.id,
|
||||||
teamId: monitor.teamId,
|
teamId: monitor.teamId,
|
||||||
startTime: Date.now().toString(),
|
startTime: Date.now().toString(),
|
||||||
status: true,
|
status: true,
|
||||||
statusCode,
|
statusCode: isBreach ? 9999 : check.statusCode,
|
||||||
message,
|
message: isBreach ? this.notificationMessageBuilder.buildThresholdBreachMessage(monitor, check, decision.thresholdBreaches) : undefined,
|
||||||
};
|
});
|
||||||
return await this.incidentsRepository.create(incident);
|
}
|
||||||
|
case "status_up":
|
||||||
|
case "threshold_resolved": {
|
||||||
|
if (!activeIncident) return null;
|
||||||
|
activeIncident.status = false;
|
||||||
|
activeIncident.endTime = Date.now().toString();
|
||||||
|
activeIncident.resolutionType = "automatic";
|
||||||
|
return await this.incidentsRepository.updateById(activeIncident.id, activeIncident.teamId, activeIncident);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!decision.shouldResolveIncident || !activeIncident) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
activeIncident.status = false;
|
|
||||||
activeIncident.endTime = Date.now().toString();
|
|
||||||
activeIncident.resolutionType = "automatic";
|
|
||||||
return await this.incidentsRepository.updateById(activeIncident.id, activeIncident.teamId, activeIncident);
|
|
||||||
};
|
};
|
||||||
|
|
||||||
private buildThresholdBreachMessage(monitor: Monitor, monitorStatusResponse?: MonitorStatusResponse): string {
|
|
||||||
if (!monitorStatusResponse) {
|
|
||||||
return "Threshold breach detected";
|
|
||||||
}
|
|
||||||
|
|
||||||
const breaches = this.notificationMessageBuilder.extractThresholdBreaches(monitor, monitorStatusResponse);
|
|
||||||
|
|
||||||
if (breaches.length === 0) {
|
|
||||||
return "Threshold breach detected";
|
|
||||||
}
|
|
||||||
|
|
||||||
return breaches.map((b) => `${b.metric.toUpperCase()}: ${b.formattedValue} (threshold: ${b.threshold}${b.unit})`).join(", ");
|
|
||||||
}
|
|
||||||
|
|
||||||
resolveIncident = async (incidentId: string, userId: string, teamId: string, comment?: string, userEmail?: string) => {
|
resolveIncident = async (incidentId: string, userId: string, teamId: string, comment?: string, userEmail?: string) => {
|
||||||
try {
|
try {
|
||||||
if (!incidentId) {
|
if (!incidentId) {
|
||||||
|
|||||||
@@ -307,6 +307,14 @@ const MonitorSchema = new Schema<MonitorDocument>(
|
|||||||
type: Boolean,
|
type: Boolean,
|
||||||
default: false,
|
default: false,
|
||||||
},
|
},
|
||||||
|
dockerAlertOnStopped: {
|
||||||
|
type: Boolean,
|
||||||
|
default: false,
|
||||||
|
},
|
||||||
|
dockerAlertOnUnhealthy: {
|
||||||
|
type: Boolean,
|
||||||
|
default: false,
|
||||||
|
},
|
||||||
dockerTlsCa: {
|
dockerTlsCa: {
|
||||||
type: String,
|
type: String,
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -101,7 +101,12 @@ class MongoMonitorsRepository implements IMonitorsRepository {
|
|||||||
|
|
||||||
private pageOptions = (config: TeamQueryConfig) => {
|
private pageOptions = (config: TeamQueryConfig) => {
|
||||||
const { page = 0, rowsPerPage = 0, field = "createdAt", order = "desc" } = config ?? {};
|
const { page = 0, rowsPerPage = 0, field = "createdAt", order = "desc" } = config ?? {};
|
||||||
return { sort: { [field]: order === "asc" ? 1 : -1 } as const, skip: Math.max(page, 0) * rowsPerPage, limit: rowsPerPage };
|
const direction: 1 | -1 = order === "asc" ? 1 : -1;
|
||||||
|
const sort: Record<string, 1 | -1> = { [field]: direction };
|
||||||
|
if (field !== "createdAt") {
|
||||||
|
sort.createdAt = 1;
|
||||||
|
}
|
||||||
|
return { sort, skip: Math.max(page, 0) * rowsPerPage, limit: rowsPerPage };
|
||||||
};
|
};
|
||||||
|
|
||||||
findByTeamId = async (teamId: string, config: TeamQueryConfig, options?: { includeRecentChecks?: boolean }): Promise<Monitor[]> => {
|
findByTeamId = async (teamId: string, config: TeamQueryConfig, options?: { includeRecentChecks?: boolean }): Promise<Monitor[]> => {
|
||||||
@@ -468,6 +473,8 @@ class MongoMonitorsRepository implements IMonitorsRepository {
|
|||||||
geoCheckLocations: doc.geoCheckLocations ?? [],
|
geoCheckLocations: doc.geoCheckLocations ?? [],
|
||||||
geoCheckInterval: doc.geoCheckInterval ?? 300000,
|
geoCheckInterval: doc.geoCheckInterval ?? 300000,
|
||||||
dockerLogsEnabled: doc.dockerLogsEnabled ?? false,
|
dockerLogsEnabled: doc.dockerLogsEnabled ?? false,
|
||||||
|
dockerAlertOnStopped: doc.dockerAlertOnStopped ?? false,
|
||||||
|
dockerAlertOnUnhealthy: doc.dockerAlertOnUnhealthy ?? false,
|
||||||
dockerTlsCa: doc.dockerTlsCa ?? undefined,
|
dockerTlsCa: doc.dockerTlsCa ?? undefined,
|
||||||
dockerTlsCert: doc.dockerTlsCert ?? undefined,
|
dockerTlsCert: doc.dockerTlsCert ?? undefined,
|
||||||
dockerTlsKeySet: doc.dockerTlsKeySet ?? false,
|
dockerTlsKeySet: doc.dockerTlsKeySet ?? false,
|
||||||
|
|||||||
@@ -51,6 +51,22 @@ export const UptimeDetailsSupportedTypes = ["http", "ping", "port", "game", "grp
|
|||||||
export type UptimeDetailsSupportedType = (typeof UptimeDetailsSupportedTypes)[number];
|
export type UptimeDetailsSupportedType = (typeof UptimeDetailsSupportedTypes)[number];
|
||||||
export const supportsUptimeDetails = (type: MonitorType): type is UptimeDetailsSupportedType => UptimeDetailsSupportedTypes.some((t) => t === type);
|
export const supportsUptimeDetails = (type: MonitorType): type is UptimeDetailsSupportedType => UptimeDetailsSupportedTypes.some((t) => t === type);
|
||||||
|
|
||||||
|
const MonitorPaths: Record<MonitorType, string> = {
|
||||||
|
http: "uptime",
|
||||||
|
port: "uptime",
|
||||||
|
ping: "uptime",
|
||||||
|
game: "uptime",
|
||||||
|
grpc: "uptime",
|
||||||
|
websocket: "uptime",
|
||||||
|
dns: "uptime",
|
||||||
|
unknown: "uptime",
|
||||||
|
docker: "docker/host",
|
||||||
|
hardware: "infrastructure",
|
||||||
|
pagespeed: "pagespeed",
|
||||||
|
};
|
||||||
|
|
||||||
|
export const getMonitorPath = (type: MonitorType): string => MonitorPaths[type];
|
||||||
|
|
||||||
// Types whose check leaves the instance, and whose failure to reach the target can therefore be the instance's
|
// Types whose check leaves the instance, and whose failure to reach the target can therefore be the instance's
|
||||||
// own loss of egress. A hardware check is an ordinary outbound HTTP request to the Capture agent, so it counts
|
// own loss of egress. A hardware check is an ordinary outbound HTTP request to the Capture agent, so it counts
|
||||||
// however far away the agent is. `unknown` is excluded because no request is made for it.
|
// however far away the agent is. `unknown` is excluded because no request is made for it.
|
||||||
@@ -134,6 +150,8 @@ export interface Monitor {
|
|||||||
geoCheckLocations?: GeoContinent[];
|
geoCheckLocations?: GeoContinent[];
|
||||||
geoCheckInterval?: number;
|
geoCheckInterval?: number;
|
||||||
dockerLogsEnabled?: boolean;
|
dockerLogsEnabled?: boolean;
|
||||||
|
dockerAlertOnStopped?: boolean;
|
||||||
|
dockerAlertOnUnhealthy?: boolean;
|
||||||
dockerTlsCa?: string;
|
dockerTlsCa?: string;
|
||||||
dockerTlsCert?: string;
|
dockerTlsCert?: string;
|
||||||
// EncryptionService ciphertext. Normally excluded
|
// EncryptionService ciphertext. Normally excluded
|
||||||
@@ -222,3 +240,8 @@ export type GamesMap = Record<string, Game>;
|
|||||||
export type MonitorScheduleFields = Pick<Monitor, "id" | "type" | "isActive" | "interval" | "geoCheckEnabled" | "geoCheckInterval">;
|
export type MonitorScheduleFields = Pick<Monitor, "id" | "type" | "isActive" | "interval" | "geoCheckEnabled" | "geoCheckInterval">;
|
||||||
|
|
||||||
export type DockerContainerLogsResult = DockerLogPage;
|
export type DockerContainerLogsResult = DockerLogPage;
|
||||||
|
|
||||||
|
export const HardwareMetricKeys = ["cpu", "memory", "disk", "temp"] as const;
|
||||||
|
export type HardwareMetricKey = (typeof HardwareMetricKeys)[number];
|
||||||
|
export type HardwareBreaches = Record<HardwareMetricKey, boolean>;
|
||||||
|
export type HardwareCounters = Record<HardwareMetricKey, number>;
|
||||||
@@ -1,5 +1,4 @@
|
|||||||
import type { Monitor } from "@/domain/monitors/monitor.type.js";
|
import { HardwareMetricKeys, type HardwareBreaches, type HardwareMetricKey, type Monitor } from "@/domain/monitors/monitor.type.js";
|
||||||
import type { HardwareStatusPayload, MonitorStatusResponse } from "@/types/network.js";
|
|
||||||
import type { MonitorActionDecision } from "@/worker/worker.interface.js";
|
import type { MonitorActionDecision } from "@/worker/worker.interface.js";
|
||||||
import type {
|
import type {
|
||||||
NotificationMessage,
|
NotificationMessage,
|
||||||
@@ -8,15 +7,12 @@ import type {
|
|||||||
ThresholdBreach,
|
ThresholdBreach,
|
||||||
NotificationContent,
|
NotificationContent,
|
||||||
} from "@/domain/notifications/notification.type.js";
|
} 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 {
|
export interface INotificationMessageBuilder {
|
||||||
buildMessage(
|
buildMessage(monitor: Monitor, check: Check, decision: MonitorActionDecision, clientHost: string): NotificationMessage;
|
||||||
monitor: Monitor,
|
buildThresholdBreachMessage(monitor: Monitor, check: Check, thresholdBreaches: HardwareBreaches | undefined): string;
|
||||||
monitorStatusResponse: MonitorStatusResponse,
|
|
||||||
decision: MonitorActionDecision,
|
|
||||||
clientHost: string
|
|
||||||
): NotificationMessage;
|
|
||||||
extractThresholdBreaches(monitor: Monitor, monitorStatusResponse: MonitorStatusResponse): ThresholdBreach[];
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const SERVICE_NAME = "NotificationMessageBuilder";
|
const SERVICE_NAME = "NotificationMessageBuilder";
|
||||||
@@ -24,15 +20,10 @@ const SERVICE_NAME = "NotificationMessageBuilder";
|
|||||||
export class NotificationMessageBuilder implements INotificationMessageBuilder {
|
export class NotificationMessageBuilder implements INotificationMessageBuilder {
|
||||||
static SERVICE_NAME = SERVICE_NAME;
|
static SERVICE_NAME = SERVICE_NAME;
|
||||||
|
|
||||||
buildMessage(
|
buildMessage(monitor: Monitor, check: Check, decision: MonitorActionDecision, clientHost: string): NotificationMessage {
|
||||||
monitor: Monitor,
|
|
||||||
monitorStatusResponse: MonitorStatusResponse,
|
|
||||||
decision: MonitorActionDecision,
|
|
||||||
clientHost: string
|
|
||||||
): NotificationMessage {
|
|
||||||
const type = this.determineNotificationType(decision, monitor);
|
const type = this.determineNotificationType(decision, monitor);
|
||||||
const severity = this.determineSeverity(type);
|
const severity = this.determineSeverity(type);
|
||||||
const content = this.buildContent(type, monitor, monitorStatusResponse);
|
const content = this.buildContent(type, monitor, check, decision);
|
||||||
|
|
||||||
return {
|
return {
|
||||||
type,
|
type,
|
||||||
@@ -48,43 +39,37 @@ export class NotificationMessageBuilder implements INotificationMessageBuilder {
|
|||||||
clientHost,
|
clientHost,
|
||||||
metadata: {
|
metadata: {
|
||||||
teamId: monitor.teamId,
|
teamId: monitor.teamId,
|
||||||
notificationReason: decision.notificationReason || "status_change",
|
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
private determineNotificationType(decision: MonitorActionDecision, monitor: Monitor): NotificationType {
|
private determineNotificationType(decision: MonitorActionDecision, monitor: Monitor): NotificationType {
|
||||||
// Down status has highest priority (critical)
|
const isDocker = monitor.type === "docker";
|
||||||
if (monitor.status === "down") {
|
switch (decision.transition) {
|
||||||
return "monitor_down";
|
case "status_down":
|
||||||
|
return "monitor_down";
|
||||||
|
case "threshold_breach":
|
||||||
|
return isDocker ? "container_breach" : "threshold_breach";
|
||||||
|
case "threshold_resolved":
|
||||||
|
return isDocker ? "container_resolved" : "threshold_resolved";
|
||||||
|
case "status_up":
|
||||||
|
case null:
|
||||||
|
return "monitor_up";
|
||||||
}
|
}
|
||||||
|
|
||||||
// Threshold breach (only if not down)
|
|
||||||
if (decision.notificationReason === "threshold_breach") {
|
|
||||||
return "threshold_breach";
|
|
||||||
}
|
|
||||||
|
|
||||||
// Recovery from threshold breach (only for hardware monitors)
|
|
||||||
if (decision.notificationReason === "status_change" && monitor.status === "up" && monitor.type === "hardware") {
|
|
||||||
return "threshold_resolved";
|
|
||||||
}
|
|
||||||
|
|
||||||
// Standard recovery (up)
|
|
||||||
if (monitor.status === "up") {
|
|
||||||
return "monitor_up";
|
|
||||||
}
|
|
||||||
|
|
||||||
// Default to monitor_up for any other case
|
|
||||||
return "monitor_up";
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private determineSeverity(type: NotificationType): NotificationSeverity {
|
private determineSeverity(type: NotificationType): NotificationSeverity {
|
||||||
switch (type) {
|
switch (type) {
|
||||||
|
case "monitor_up":
|
||||||
|
return "success";
|
||||||
case "monitor_down":
|
case "monitor_down":
|
||||||
return "critical";
|
return "critical";
|
||||||
|
case "container_breach":
|
||||||
|
return "warning";
|
||||||
case "threshold_breach":
|
case "threshold_breach":
|
||||||
return "warning";
|
return "warning";
|
||||||
case "monitor_up":
|
case "container_resolved":
|
||||||
|
return "success";
|
||||||
case "threshold_resolved":
|
case "threshold_resolved":
|
||||||
return "success";
|
return "success";
|
||||||
case "test":
|
case "test":
|
||||||
@@ -94,34 +79,38 @@ export class NotificationMessageBuilder implements INotificationMessageBuilder {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private buildContent(type: NotificationType, monitor: Monitor, monitorStatusResponse: MonitorStatusResponse): NotificationContent {
|
private buildContent(type: NotificationType, monitor: Monitor, check: Check, decision: MonitorActionDecision): NotificationContent {
|
||||||
switch (type) {
|
switch (type) {
|
||||||
case "monitor_down":
|
case "monitor_down":
|
||||||
return this.buildMonitorDownContent(monitor, monitorStatusResponse);
|
return this.buildMonitorDownContent(monitor, check);
|
||||||
case "monitor_up":
|
case "monitor_up":
|
||||||
return this.buildMonitorUpContent(monitor);
|
return this.buildMonitorUpContent(monitor);
|
||||||
case "threshold_breach":
|
case "threshold_breach":
|
||||||
return this.buildThresholdBreachContent(monitor, monitorStatusResponse as MonitorStatusResponse<HardwareStatusPayload>);
|
return this.buildThresholdBreachContent(monitor, check, decision.thresholdBreaches);
|
||||||
case "threshold_resolved":
|
case "threshold_resolved":
|
||||||
return this.buildThresholdResolvedContent(monitor);
|
return this.buildThresholdResolvedContent(monitor);
|
||||||
|
case "container_breach":
|
||||||
|
return this.buildContainerBreachContent(monitor, check);
|
||||||
|
case "container_resolved":
|
||||||
|
return this.buildContainersRecoveredContent(monitor);
|
||||||
default:
|
default:
|
||||||
return this.buildDefaultContent(monitor);
|
return this.buildDefaultContent(monitor);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private buildMonitorDownContent(monitor: Monitor, monitorStatusResponse: MonitorStatusResponse): NotificationContent {
|
private buildMonitorDownContent(monitor: Monitor, check: Check): NotificationContent {
|
||||||
const title = `Monitor Down: ${monitor.name}`;
|
const title = `Monitor Down: ${monitor.name}`;
|
||||||
const summary = `Monitor "${monitor.name}" is currently down and unreachable.`;
|
const summary = `Monitor "${monitor.name}" is currently down and unreachable.`;
|
||||||
const details = [`URL: ${monitor.url}`, `Status: Down`, `Type: ${monitor.type}`];
|
const details = [`URL: ${monitor.url}`, `Status: Down`, `Type: ${monitor.type}`];
|
||||||
|
|
||||||
// Add response code if available
|
// Add response code if available
|
||||||
if (monitorStatusResponse.code) {
|
if (check.statusCode) {
|
||||||
details.push(`Response Code: ${monitorStatusResponse.code}`);
|
details.push(`Response Code: ${check.statusCode}`);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Add error message if available
|
// Add error message if available
|
||||||
if (monitorStatusResponse.message) {
|
if (check.message) {
|
||||||
details.push(`Error: ${monitorStatusResponse.message}`);
|
details.push(`Error: ${check.message}`);
|
||||||
}
|
}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
@@ -145,12 +134,12 @@ export class NotificationMessageBuilder implements INotificationMessageBuilder {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
private buildThresholdBreachContent(monitor: Monitor, monitorStatusResponse: MonitorStatusResponse<HardwareStatusPayload>): NotificationContent {
|
private buildThresholdBreachContent(monitor: Monitor, check: Check, thresholdBreaches: HardwareBreaches | undefined): NotificationContent {
|
||||||
const title = `Threshold Exceeded: ${monitor.name}`;
|
const title = `Threshold Exceeded: ${monitor.name}`;
|
||||||
const summary = `Monitor "${monitor.name}" has exceeded one or more thresholds.`;
|
const summary = `Monitor "${monitor.name}" has exceeded one or more thresholds.`;
|
||||||
const details = [`URL: ${monitor.url}`, `Status: Threshold exceeded`, `Type: ${monitor.type}`];
|
const details = [`URL: ${monitor.url}`, `Status: Threshold exceeded`, `Type: ${monitor.type}`];
|
||||||
|
|
||||||
const thresholds = this.extractThresholdBreaches(monitor, monitorStatusResponse);
|
const thresholds = this.extractThresholdBreaches(monitor, check, thresholdBreaches);
|
||||||
|
|
||||||
return {
|
return {
|
||||||
title,
|
title,
|
||||||
@@ -174,6 +163,21 @@ export class NotificationMessageBuilder implements INotificationMessageBuilder {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private buildContainerBreachContent(monitor: Monitor, check: Check): NotificationContent {
|
||||||
|
const lines = this.describeContainerBreaches(monitor, check);
|
||||||
|
const title = `Container Alert: ${monitor.name}`;
|
||||||
|
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() };
|
||||||
|
}
|
||||||
|
|
||||||
|
private buildContainersRecoveredContent(monitor: Monitor): NotificationContent {
|
||||||
|
const title = `Containers Recovered: ${monitor.name}`;
|
||||||
|
const summary = `All containers on "${monitor.name}" are back to normal.`;
|
||||||
|
const details = [`URL: ${monitor.url}`, `Status: Up`, `Type: ${monitor.type}`];
|
||||||
|
return { title, summary, details, timestamp: new Date() };
|
||||||
|
}
|
||||||
|
|
||||||
private buildDefaultContent(monitor: Monitor): NotificationContent {
|
private buildDefaultContent(monitor: Monitor): NotificationContent {
|
||||||
return {
|
return {
|
||||||
title: `Monitor: ${monitor.name}`,
|
title: `Monitor: ${monitor.name}`,
|
||||||
@@ -183,93 +187,57 @@ export class NotificationMessageBuilder implements INotificationMessageBuilder {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
public extractThresholdBreaches(monitor: Monitor, monitorStatusResponse: MonitorStatusResponse<HardwareStatusPayload>): ThresholdBreach[] {
|
private describeBreach(metric: HardwareMetricKey, monitor: Monitor, check: Check): ThresholdBreach {
|
||||||
|
switch (metric) {
|
||||||
|
case "cpu": {
|
||||||
|
const currentValue = (check.cpu?.usage_percent ?? 0) * 100;
|
||||||
|
return { metric, currentValue, threshold: monitor.cpuAlertThreshold, unit: "%", formattedValue: `${currentValue.toFixed(1)}%` };
|
||||||
|
}
|
||||||
|
case "memory": {
|
||||||
|
const currentValue = (check.memory?.usage_percent ?? 0) * 100;
|
||||||
|
return { metric, currentValue, threshold: monitor.memoryAlertThreshold, unit: "%", formattedValue: `${currentValue.toFixed(1)}%` };
|
||||||
|
}
|
||||||
|
case "disk": {
|
||||||
|
const currentValue = Math.max(0, ...(check.disk ?? []).map((disk) => disk?.usage_percent ?? 0)) * 100;
|
||||||
|
return { metric, currentValue, threshold: monitor.diskAlertThreshold, unit: "%", formattedValue: `${currentValue.toFixed(1)}%` };
|
||||||
|
}
|
||||||
|
case "temp": {
|
||||||
|
const currentValue = Math.max(0, ...(check.cpu?.temperature ?? []));
|
||||||
|
return { metric, currentValue, threshold: monitor.tempAlertThreshold, unit: "°C", formattedValue: `${currentValue.toFixed(1)}°C` };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private extractThresholdBreaches(monitor: Monitor, check: Check, thresholdBreaches: HardwareBreaches | undefined): ThresholdBreach[] {
|
||||||
const breaches: ThresholdBreach[] = [];
|
const breaches: ThresholdBreach[] = [];
|
||||||
|
|
||||||
// Check if this is a hardware monitor with threshold data
|
// Check if this is a hardware monitor with threshold data
|
||||||
if (monitor.type !== "hardware" || !monitorStatusResponse.payload || typeof monitorStatusResponse.payload === "string") {
|
if (monitor.type !== "hardware" || thresholdBreaches === undefined) {
|
||||||
return breaches;
|
return breaches;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Cast to HardwareStatusPayload type
|
for (const metric of HardwareMetricKeys) {
|
||||||
const payload = monitorStatusResponse.payload;
|
if (!thresholdBreaches[metric]) continue;
|
||||||
const hardware = payload.data;
|
breaches.push(this.describeBreach(metric, monitor, check));
|
||||||
|
|
||||||
if (!hardware) {
|
|
||||||
return breaches;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Note: usage_percent values in hardware payload are decimals (0-1)
|
|
||||||
if (monitor.cpuAlertThreshold !== undefined && monitor.cpuAlertThreshold !== null && hardware.cpu?.usage_percent !== undefined) {
|
|
||||||
const cpuUsageDecimal = hardware.cpu.usage_percent;
|
|
||||||
const cpuPercent = cpuUsageDecimal * 100;
|
|
||||||
const threshold = monitor.cpuAlertThreshold;
|
|
||||||
if (cpuPercent > threshold) {
|
|
||||||
breaches.push({
|
|
||||||
metric: "cpu",
|
|
||||||
currentValue: cpuPercent,
|
|
||||||
threshold,
|
|
||||||
unit: "%",
|
|
||||||
formattedValue: `${cpuPercent.toFixed(1)}%`,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Memory threshold breach
|
|
||||||
if (monitor.memoryAlertThreshold !== undefined && monitor.memoryAlertThreshold !== null && hardware.memory?.usage_percent !== undefined) {
|
|
||||||
const memoryUsageDecimal = hardware.memory.usage_percent;
|
|
||||||
const memoryPercent = memoryUsageDecimal * 100;
|
|
||||||
const threshold = monitor.memoryAlertThreshold;
|
|
||||||
if (memoryPercent > threshold) {
|
|
||||||
breaches.push({
|
|
||||||
metric: "memory",
|
|
||||||
currentValue: memoryPercent,
|
|
||||||
threshold,
|
|
||||||
unit: "%",
|
|
||||||
formattedValue: `${memoryPercent.toFixed(1)}%`,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Disk threshold breach
|
|
||||||
if (monitor.diskAlertThreshold !== undefined && monitor.diskAlertThreshold !== null && Array.isArray(hardware.disk)) {
|
|
||||||
// Find the highest disk usage
|
|
||||||
let maxDiskUsageDecimal = 0;
|
|
||||||
for (const disk of hardware.disk) {
|
|
||||||
if (disk.usage_percent !== undefined && disk.usage_percent > maxDiskUsageDecimal) {
|
|
||||||
maxDiskUsageDecimal = disk.usage_percent;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
const maxDiskPercent = maxDiskUsageDecimal * 100;
|
|
||||||
const threshold = monitor.diskAlertThreshold;
|
|
||||||
if (maxDiskPercent > threshold) {
|
|
||||||
breaches.push({
|
|
||||||
metric: "disk",
|
|
||||||
currentValue: maxDiskPercent,
|
|
||||||
threshold,
|
|
||||||
unit: "%",
|
|
||||||
formattedValue: `${maxDiskPercent.toFixed(1)}%`,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Temperature threshold breach
|
|
||||||
if (monitor.tempAlertThreshold !== undefined && monitor.tempAlertThreshold !== null && hardware.cpu?.temperature) {
|
|
||||||
// Temperature is an array in cpu.temperature
|
|
||||||
const temps = Array.isArray(hardware.cpu.temperature) ? hardware.cpu.temperature : [hardware.cpu.temperature];
|
|
||||||
const maxTemp = Math.max(...temps.filter((t: number) => !isNaN(t)));
|
|
||||||
const threshold = monitor.tempAlertThreshold;
|
|
||||||
if (maxTemp > threshold) {
|
|
||||||
breaches.push({
|
|
||||||
metric: "temp",
|
|
||||||
currentValue: maxTemp,
|
|
||||||
threshold,
|
|
||||||
unit: "°C",
|
|
||||||
formattedValue: `${maxTemp.toFixed(1)}°C`,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return breaches;
|
return breaches;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private describeContainerBreaches(monitor: Monitor, check: Check): string[] {
|
||||||
|
return findContainerBreaches(monitor, check.containers ?? []).map(describeContainerBreach);
|
||||||
|
}
|
||||||
|
|
||||||
|
public buildThresholdBreachMessage(monitor: Monitor, check: Check, thresholdBreaches: HardwareBreaches | undefined): string {
|
||||||
|
if (monitor.type === "docker") {
|
||||||
|
const lines = this.describeContainerBreaches(monitor, check);
|
||||||
|
return lines.length > 0 ? lines.join(", ") : "Container alert";
|
||||||
|
}
|
||||||
|
|
||||||
|
const breaches = this.extractThresholdBreaches(monitor, check, thresholdBreaches);
|
||||||
|
if (breaches.length === 0) {
|
||||||
|
return "Threshold breach detected";
|
||||||
|
}
|
||||||
|
return breaches.map((b) => `${b.metric.toUpperCase()}: ${b.formattedValue} (threshold: ${b.threshold}${b.unit})`).join(", ");
|
||||||
|
}
|
||||||
}
|
}
|
||||||
@@ -1,6 +1,5 @@
|
|||||||
import type { Monitor } from "@/domain/monitors/monitor.type.js";
|
import type { Monitor } from "@/domain/monitors/monitor.type.js";
|
||||||
import type { Notification } from "@/domain/notifications/notification.type.js";
|
import type { Notification } from "@/domain/notifications/notification.type.js";
|
||||||
import type { MonitorStatusResponse } from "@/types/network.js";
|
|
||||||
import type { NotificationMessage } from "@/domain/notifications/notification.type.js";
|
import type { NotificationMessage } from "@/domain/notifications/notification.type.js";
|
||||||
import { IMonitorsRepository } from "@/domain/monitors/monitor.repository.interface.js";
|
import { IMonitorsRepository } from "@/domain/monitors/monitor.repository.interface.js";
|
||||||
import { INotificationsRepository } from "@/domain/notifications/notification.repository.interface.js";
|
import { INotificationsRepository } from "@/domain/notifications/notification.repository.interface.js";
|
||||||
@@ -10,6 +9,7 @@ import type { ISettingsService } from "@/domain/app-settings/app-settings.servic
|
|||||||
import { ILogger } from "@/utils/logger.js";
|
import { ILogger } from "@/utils/logger.js";
|
||||||
import type { INotificationMessageBuilder } from "@/domain/notifications/notification.message-builder.js";
|
import type { INotificationMessageBuilder } from "@/domain/notifications/notification.message-builder.js";
|
||||||
import type { NotificationChannel } from "@/domain/notifications/notification.type.js";
|
import type { NotificationChannel } from "@/domain/notifications/notification.type.js";
|
||||||
|
import type { Check } from "@/domain/checks/check.type.js";
|
||||||
|
|
||||||
export type NotificationProviderRegistry = Record<NotificationChannel, INotificationProvider>;
|
export type NotificationProviderRegistry = Record<NotificationChannel, INotificationProvider>;
|
||||||
|
|
||||||
@@ -19,7 +19,7 @@ export interface INotificationsService {
|
|||||||
findNotificationsByTeamId: (teamId: string) => Promise<Notification[]>;
|
findNotificationsByTeamId: (teamId: string) => Promise<Notification[]>;
|
||||||
updateById(id: string, teamId: string, updateData: Partial<Notification>): Promise<Notification>;
|
updateById(id: string, teamId: string, updateData: Partial<Notification>): Promise<Notification>;
|
||||||
deleteById: (id: string, teamId: string) => Promise<Notification>;
|
deleteById: (id: string, teamId: string) => Promise<Notification>;
|
||||||
handleNotifications: (monitor: Monitor, monitorStatusResponse: MonitorStatusResponse, decision: MonitorActionDecision) => Promise<boolean>;
|
handleNotifications: (monitor: Monitor, check: Check, decision: MonitorActionDecision) => Promise<boolean>;
|
||||||
|
|
||||||
sendTestNotification: (notification: Partial<Notification>) => Promise<boolean>;
|
sendTestNotification: (notification: Partial<Notification>) => Promise<boolean>;
|
||||||
testAllNotifications: (notificationIds: string[]) => Promise<boolean>;
|
testAllNotifications: (notificationIds: string[]) => Promise<boolean>;
|
||||||
@@ -63,7 +63,7 @@ export class NotificationsService implements INotificationsService {
|
|||||||
private send = async (
|
private send = async (
|
||||||
notification: Notification,
|
notification: Notification,
|
||||||
monitor: Monitor,
|
monitor: Monitor,
|
||||||
monitorStatusResponse: MonitorStatusResponse,
|
check: Check,
|
||||||
decision: MonitorActionDecision,
|
decision: MonitorActionDecision,
|
||||||
notificationMessage: NotificationMessage | undefined
|
notificationMessage: NotificationMessage | undefined
|
||||||
): Promise<boolean> => {
|
): Promise<boolean> => {
|
||||||
@@ -89,16 +89,16 @@ export class NotificationsService implements INotificationsService {
|
|||||||
return await provider.sendMessage(notification, notificationMessage);
|
return await provider.sendMessage(notification, notificationMessage);
|
||||||
};
|
};
|
||||||
|
|
||||||
private sendNotifications = async (monitor: Monitor, monitorStatusResponse: MonitorStatusResponse, decision: MonitorActionDecision) => {
|
private sendNotifications = async (monitor: Monitor, check: Check, decision: MonitorActionDecision) => {
|
||||||
const notificationIds = monitor.notifications ?? [];
|
const notificationIds = monitor.notifications ?? [];
|
||||||
const notifications = await this.notificationsRepository.findNotificationsByIds(notificationIds);
|
const notifications = await this.notificationsRepository.findNotificationsByIds(notificationIds);
|
||||||
|
|
||||||
// Build notification message once for all notifications
|
// Build notification message once for all notifications
|
||||||
const settings = this.settingsService.getSettings();
|
const settings = this.settingsService.getSettings();
|
||||||
const clientHost = settings.clientHost || "Host not defined";
|
const clientHost = settings.clientHost || "Host not defined";
|
||||||
const notificationMessage = this.notificationMessageBuilder.buildMessage(monitor, monitorStatusResponse, decision, clientHost);
|
const notificationMessage = this.notificationMessageBuilder.buildMessage(monitor, check, decision, clientHost);
|
||||||
|
|
||||||
const tasks = notifications.map((notification) => this.send(notification, monitor, monitorStatusResponse, decision, notificationMessage));
|
const tasks = notifications.map((notification) => this.send(notification, monitor, check, decision, notificationMessage));
|
||||||
|
|
||||||
const outcomes = await Promise.all(tasks);
|
const outcomes = await Promise.all(tasks);
|
||||||
const succeeded = outcomes.filter(Boolean).length;
|
const succeeded = outcomes.filter(Boolean).length;
|
||||||
@@ -114,13 +114,11 @@ export class NotificationsService implements INotificationsService {
|
|||||||
return succeeded === notifications.length;
|
return succeeded === notifications.length;
|
||||||
};
|
};
|
||||||
|
|
||||||
handleNotifications = async (monitor: Monitor, monitorStatusResponse: MonitorStatusResponse, decision: MonitorActionDecision) => {
|
handleNotifications = async (monitor: Monitor, check: Check, decision: MonitorActionDecision) => {
|
||||||
if (!decision.shouldSendNotification) {
|
if (decision.transition === null) return false;
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Send notifications based on decision
|
// Send notifications based on decision
|
||||||
return await this.sendNotifications(monitor, monitorStatusResponse, decision);
|
return await this.sendNotifications(monitor, check, decision);
|
||||||
};
|
};
|
||||||
|
|
||||||
sendTestNotification = async (notification: Partial<Notification>) => {
|
sendTestNotification = async (notification: Partial<Notification>) => {
|
||||||
@@ -148,7 +146,17 @@ export class NotificationsService implements INotificationsService {
|
|||||||
|
|
||||||
testAllNotifications = async (notificationIds: string[]) => {
|
testAllNotifications = async (notificationIds: string[]) => {
|
||||||
const notifications = await this.notificationsRepository.findNotificationsByIds(notificationIds);
|
const notifications = await this.notificationsRepository.findNotificationsByIds(notificationIds);
|
||||||
const tasks = notifications.map((notification) => this.sendTestNotification(notification));
|
const tasks = notifications.map((notification) =>
|
||||||
|
this.sendTestNotification(notification).catch((error: unknown) => {
|
||||||
|
this.logger.warn({
|
||||||
|
message: `Test notification failed: ${error instanceof Error ? error.message : "Unknown error"}`,
|
||||||
|
service: SERVICE_NAME,
|
||||||
|
method: "testAllNotifications",
|
||||||
|
details: { notificationId: notification.id, type: notification.type },
|
||||||
|
});
|
||||||
|
return false;
|
||||||
|
})
|
||||||
|
);
|
||||||
const outcomes = await Promise.all(tasks);
|
const outcomes = await Promise.all(tasks);
|
||||||
const succeeded = outcomes.filter(Boolean).length;
|
const succeeded = outcomes.filter(Boolean).length;
|
||||||
const failed = outcomes.length - succeeded;
|
const failed = outcomes.length - succeeded;
|
||||||
|
|||||||
@@ -1,3 +1,5 @@
|
|||||||
|
import type { HardwareMetricKey } from "@/domain/monitors/monitor.type.js";
|
||||||
|
|
||||||
export const NotificationChannels = [
|
export const NotificationChannels = [
|
||||||
"email",
|
"email",
|
||||||
"slack",
|
"slack",
|
||||||
@@ -71,7 +73,8 @@ export interface AlertDiscordPayload {
|
|||||||
* Part of notification system unification effort
|
* Part of notification system unification effort
|
||||||
*/
|
*/
|
||||||
|
|
||||||
export type NotificationType = "monitor_down" | "monitor_up" | "threshold_breach" | "threshold_resolved" | "test";
|
export type NotificationType =
|
||||||
|
"monitor_down" | "monitor_up" | "threshold_breach" | "threshold_resolved" | "container_breach" | "container_resolved" | "test";
|
||||||
|
|
||||||
export type NotificationSeverity = "critical" | "warning" | "info" | "success";
|
export type NotificationSeverity = "critical" | "warning" | "info" | "success";
|
||||||
|
|
||||||
@@ -84,7 +87,7 @@ export interface MonitorInfo {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export interface ThresholdBreach {
|
export interface ThresholdBreach {
|
||||||
metric: "cpu" | "memory" | "disk" | "temp";
|
metric: HardwareMetricKey;
|
||||||
currentValue: number;
|
currentValue: number;
|
||||||
threshold: number;
|
threshold: number;
|
||||||
unit: string;
|
unit: string;
|
||||||
@@ -116,6 +119,5 @@ export interface NotificationMessage {
|
|||||||
clientHost: string;
|
clientHost: string;
|
||||||
metadata: {
|
metadata: {
|
||||||
teamId: string;
|
teamId: string;
|
||||||
notificationReason: string;
|
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
@@ -1,6 +1,7 @@
|
|||||||
const SERVICE_NAME = "EmailProvider";
|
const SERVICE_NAME = "EmailProvider";
|
||||||
import type { Notification } from "@/domain/notifications/notification.type.js";
|
import type { Notification } from "@/domain/notifications/notification.type.js";
|
||||||
import { NotificationProvider } from "@/domain/notifications/providers/INotificationProvider.js";
|
import { NotificationProvider } from "@/domain/notifications/providers/INotificationProvider.js";
|
||||||
|
import { AppError } from "@/utils/AppError.js";
|
||||||
import { buildTestEmail } from "@/domain/notifications/providers/utils.js";
|
import { buildTestEmail } from "@/domain/notifications/providers/utils.js";
|
||||||
import type { NotificationMessage } from "@/domain/notifications/notification.type.js";
|
import type { NotificationMessage } from "@/domain/notifications/notification.type.js";
|
||||||
import type { ILogger } from "@/utils/logger.js";
|
import type { ILogger } from "@/utils/logger.js";
|
||||||
@@ -40,11 +41,13 @@ export class EmailProvider extends NotificationProvider {
|
|||||||
return true;
|
return true;
|
||||||
} catch (error: unknown) {
|
} catch (error: unknown) {
|
||||||
this.logger.warn({
|
this.logger.warn({
|
||||||
message: "Email test alert failed",
|
message: `Email test alert failed: ${error instanceof Error ? error.message : "Unknown error"}`,
|
||||||
service: SERVICE_NAME,
|
service: SERVICE_NAME,
|
||||||
method: "sendTestAlert",
|
method: "sendTestAlert",
|
||||||
|
details: error instanceof AppError ? error.details : undefined,
|
||||||
stack: error instanceof Error ? error.stack : undefined,
|
stack: error instanceof Error ? error.stack : undefined,
|
||||||
});
|
});
|
||||||
|
if (error instanceof AppError) throw error;
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -73,9 +76,10 @@ export class EmailProvider extends NotificationProvider {
|
|||||||
return true;
|
return true;
|
||||||
} catch (error: unknown) {
|
} catch (error: unknown) {
|
||||||
this.logger.warn({
|
this.logger.warn({
|
||||||
message: "Email notification failed",
|
message: `Email notification failed: ${error instanceof Error ? error.message : "Unknown error"}`,
|
||||||
service: SERVICE_NAME,
|
service: SERVICE_NAME,
|
||||||
method: "sendMessage",
|
method: "sendMessage",
|
||||||
|
details: error instanceof AppError ? error.details : undefined,
|
||||||
stack: error instanceof Error ? error.stack : undefined,
|
stack: error instanceof Error ? error.stack : undefined,
|
||||||
});
|
});
|
||||||
return false;
|
return false;
|
||||||
@@ -92,6 +96,10 @@ export class EmailProvider extends NotificationProvider {
|
|||||||
return `Monitor ${message.monitor.name} threshold exceeded`;
|
return `Monitor ${message.monitor.name} threshold exceeded`;
|
||||||
case "threshold_resolved":
|
case "threshold_resolved":
|
||||||
return `Monitor ${message.monitor.name} thresholds resolved`;
|
return `Monitor ${message.monitor.name} thresholds resolved`;
|
||||||
|
case "container_breach":
|
||||||
|
return `Monitor ${message.monitor.name} container alert`;
|
||||||
|
case "container_resolved":
|
||||||
|
return `Monitor ${message.monitor.name} containers recovered`;
|
||||||
default:
|
default:
|
||||||
return `Alert: ${message.monitor.name}`;
|
return `Alert: ${message.monitor.name}`;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,7 +3,7 @@ import got from "got";
|
|||||||
import { NotificationProvider } from "@/domain/notifications/providers/INotificationProvider.js";
|
import { NotificationProvider } from "@/domain/notifications/providers/INotificationProvider.js";
|
||||||
import type { AlertMatrixPayload, Notification } from "@/domain/notifications/notification.type.js";
|
import type { AlertMatrixPayload, Notification } from "@/domain/notifications/notification.type.js";
|
||||||
import type { NotificationMessage } from "@/domain/notifications/notification.type.js";
|
import type { NotificationMessage } from "@/domain/notifications/notification.type.js";
|
||||||
import { getTestMessage } from "@/domain/notifications/providers/utils.js";
|
import { getTestMessage, incidentUrl } from "@/domain/notifications/providers/utils.js";
|
||||||
import { randomUUID } from "crypto";
|
import { randomUUID } from "crypto";
|
||||||
|
|
||||||
export class MatrixProvider extends NotificationProvider {
|
export class MatrixProvider extends NotificationProvider {
|
||||||
@@ -172,10 +172,10 @@ export class MatrixProvider extends NotificationProvider {
|
|||||||
|
|
||||||
// Incident link (if incident exists)
|
// Incident link (if incident exists)
|
||||||
if (message.content.incident) {
|
if (message.content.incident) {
|
||||||
const incidentUrl = `${message.clientHost}/infrastructure/${message.monitor.id}`;
|
const url = incidentUrl(message);
|
||||||
plainLines.push("");
|
plainLines.push("");
|
||||||
plainLines.push(`View Incident: ${incidentUrl}`);
|
plainLines.push(`View Incident: ${url}`);
|
||||||
htmlLines.push(`<p><a href="${this.escapeHtml(incidentUrl)}">View Incident</a></p>`);
|
htmlLines.push(`<p><a href="${this.escapeHtml(url)}">View Incident</a></p>`);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Footer with timestamp
|
// Footer with timestamp
|
||||||
|
|||||||
@@ -2,7 +2,7 @@ const SERVICE_NAME = "NtfyProvider";
|
|||||||
import type { Notification } from "@/domain/notifications/notification.type.js";
|
import type { Notification } from "@/domain/notifications/notification.type.js";
|
||||||
import { NotificationProvider } from "@/domain/notifications/providers/INotificationProvider.js";
|
import { NotificationProvider } from "@/domain/notifications/providers/INotificationProvider.js";
|
||||||
import type { NotificationMessage } from "@/domain/notifications/notification.type.js";
|
import type { NotificationMessage } from "@/domain/notifications/notification.type.js";
|
||||||
import { getTestMessage } from "@/domain/notifications/providers/utils.js";
|
import { getTestMessage, incidentUrl } from "@/domain/notifications/providers/utils.js";
|
||||||
import got from "got";
|
import got from "got";
|
||||||
|
|
||||||
export class NtfyProvider extends NotificationProvider {
|
export class NtfyProvider extends NotificationProvider {
|
||||||
@@ -142,7 +142,7 @@ export class NtfyProvider extends NotificationProvider {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (message.content.incident) {
|
if (message.content.incident) {
|
||||||
lines.push("", `${message.clientHost}/infrastructure/${message.monitor.id}`);
|
lines.push("", incidentUrl(message));
|
||||||
}
|
}
|
||||||
|
|
||||||
return lines.join("\n");
|
return lines.join("\n");
|
||||||
|
|||||||
@@ -75,7 +75,8 @@ export class PagerDutyProvider extends NotificationProvider {
|
|||||||
|
|
||||||
private buildPagerDutyPayload(notification: Notification, message: NotificationMessage): AlertPagerDutyPayload {
|
private buildPagerDutyPayload(notification: Notification, message: NotificationMessage): AlertPagerDutyPayload {
|
||||||
// Map our notification type to PagerDuty event_action
|
// Map our notification type to PagerDuty event_action
|
||||||
const eventAction = message.type === "monitor_up" || message.type === "threshold_resolved" ? "resolve" : "trigger";
|
const eventAction =
|
||||||
|
message.type === "monitor_up" || message.type === "threshold_resolved" || message.type === "container_resolved" ? "resolve" : "trigger";
|
||||||
|
|
||||||
// Map severity to PagerDuty severity levels
|
// Map severity to PagerDuty severity levels
|
||||||
const severityMap: Record<string, string> = {
|
const severityMap: Record<string, string> = {
|
||||||
|
|||||||
@@ -2,7 +2,7 @@ const SERVICE_NAME = "PushoverProvider";
|
|||||||
import type { Notification } from "@/domain/notifications/notification.type.js";
|
import type { Notification } from "@/domain/notifications/notification.type.js";
|
||||||
import { NotificationProvider } from "@/domain/notifications/providers/INotificationProvider.js";
|
import { NotificationProvider } from "@/domain/notifications/providers/INotificationProvider.js";
|
||||||
import type { NotificationMessage } from "@/domain/notifications/notification.type.js";
|
import type { NotificationMessage } from "@/domain/notifications/notification.type.js";
|
||||||
import { getTestMessage } from "@/domain/notifications/providers/utils.js";
|
import { getTestMessage, incidentUrl } from "@/domain/notifications/providers/utils.js";
|
||||||
import got from "got";
|
import got from "got";
|
||||||
|
|
||||||
export class PushoverProvider extends NotificationProvider {
|
export class PushoverProvider extends NotificationProvider {
|
||||||
@@ -101,7 +101,7 @@ export class PushoverProvider extends NotificationProvider {
|
|||||||
|
|
||||||
if (message.content.incident) {
|
if (message.content.incident) {
|
||||||
lines.push("");
|
lines.push("");
|
||||||
lines.push(`View Incident: ${message.clientHost}/incidents/${message.monitor.id}`);
|
lines.push(`View Incident: ${incidentUrl(message)}`);
|
||||||
}
|
}
|
||||||
|
|
||||||
return lines.join("\n");
|
return lines.join("\n");
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
const SERVICE_NAME = "RocketChatProvider";
|
const SERVICE_NAME = "RocketChatProvider";
|
||||||
import { supportsUptimeDetails, type MonitorType } from "@/domain/monitors/monitor.type.js";
|
import { getMonitorPath, type MonitorType } from "@/domain/monitors/monitor.type.js";
|
||||||
import type { Notification, NotificationMessage } from "@/domain/notifications/notification.type.js";
|
import type { Notification, NotificationMessage } from "@/domain/notifications/notification.type.js";
|
||||||
import { NotificationProvider } from "@/domain/notifications/providers/INotificationProvider.js";
|
import { NotificationProvider } from "@/domain/notifications/providers/INotificationProvider.js";
|
||||||
import { getTestMessage } from "@/domain/notifications/providers/utils.js";
|
import { getTestMessage } from "@/domain/notifications/providers/utils.js";
|
||||||
@@ -139,19 +139,7 @@ export class RocketChatProvider extends NotificationProvider {
|
|||||||
return undefined;
|
return undefined;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (message.monitor.type === "hardware") {
|
return `${clientHost}/${getMonitorPath(message.monitor.type as MonitorType)}/${message.monitor.id}`;
|
||||||
return `${clientHost}/infrastructure/${message.monitor.id}`;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (message.monitor.type === "pagespeed") {
|
|
||||||
return `${clientHost}/pagespeed/${message.monitor.id}`;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (supportsUptimeDetails(message.monitor.type as MonitorType)) {
|
|
||||||
return `${clientHost}/uptime/${message.monitor.id}`;
|
|
||||||
}
|
|
||||||
|
|
||||||
return undefined;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private severityColor(severity: NotificationMessage["severity"]): string {
|
private severityColor(severity: NotificationMessage["severity"]): string {
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
const SERVICE_NAME = "SignalgridProvider";
|
const SERVICE_NAME = "SignalgridProvider";
|
||||||
import type { Notification, NotificationMessage, NotificationSeverity } from "@/domain/notifications/notification.type.js";
|
import type { Notification, NotificationMessage, NotificationSeverity } from "@/domain/notifications/notification.type.js";
|
||||||
import { NotificationProvider } from "@/domain/notifications/providers/INotificationProvider.js";
|
import { NotificationProvider } from "@/domain/notifications/providers/INotificationProvider.js";
|
||||||
import { getTestMessage } from "@/domain/notifications/providers/utils.js";
|
import { getTestMessage, incidentUrl } from "@/domain/notifications/providers/utils.js";
|
||||||
import got from "got";
|
import got from "got";
|
||||||
|
|
||||||
export class SignalgridProvider extends NotificationProvider {
|
export class SignalgridProvider extends NotificationProvider {
|
||||||
@@ -115,7 +115,7 @@ export class SignalgridProvider extends NotificationProvider {
|
|||||||
|
|
||||||
if (message.content.incident) {
|
if (message.content.incident) {
|
||||||
lines.push("");
|
lines.push("");
|
||||||
lines.push(`View Incident: ${message.clientHost}/incidents/${message.monitor.id}`);
|
lines.push(`View Incident: ${incidentUrl(message)}`);
|
||||||
}
|
}
|
||||||
|
|
||||||
return lines.join("\n");
|
return lines.join("\n");
|
||||||
|
|||||||
@@ -2,7 +2,7 @@ const SERVICE_NAME = "SlackProvider";
|
|||||||
import type { Notification } from "@/domain/notifications/notification.type.js";
|
import type { Notification } from "@/domain/notifications/notification.type.js";
|
||||||
import { NotificationProvider } from "@/domain/notifications/providers/INotificationProvider.js";
|
import { NotificationProvider } from "@/domain/notifications/providers/INotificationProvider.js";
|
||||||
import type { NotificationMessage } from "@/domain/notifications/notification.type.js";
|
import type { NotificationMessage } from "@/domain/notifications/notification.type.js";
|
||||||
import { getTestMessage } from "@/domain/notifications/providers/utils.js";
|
import { getTestMessage, incidentUrl } from "@/domain/notifications/providers/utils.js";
|
||||||
import got, { HTTPError } from "got";
|
import got, { HTTPError } from "got";
|
||||||
|
|
||||||
export class SlackProvider extends NotificationProvider {
|
export class SlackProvider extends NotificationProvider {
|
||||||
@@ -161,7 +161,7 @@ export class SlackProvider extends NotificationProvider {
|
|||||||
text: "View Incident",
|
text: "View Incident",
|
||||||
emoji: true,
|
emoji: true,
|
||||||
},
|
},
|
||||||
url: `${message.clientHost}/infrastructure/${message.monitor.id}`,
|
url: incidentUrl(message),
|
||||||
style: "primary",
|
style: "primary",
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
|
|||||||
@@ -2,7 +2,7 @@ const SERVICE_NAME = "TeamsProvider";
|
|||||||
import type { Notification } from "@/domain/notifications/notification.type.js";
|
import type { Notification } from "@/domain/notifications/notification.type.js";
|
||||||
import { NotificationProvider } from "@/domain/notifications/providers/INotificationProvider.js";
|
import { NotificationProvider } from "@/domain/notifications/providers/INotificationProvider.js";
|
||||||
import type { NotificationMessage } from "@/domain/notifications/notification.type.js";
|
import type { NotificationMessage } from "@/domain/notifications/notification.type.js";
|
||||||
import { getTestMessage } from "@/domain/notifications/providers/utils.js";
|
import { getTestMessage, incidentUrl } from "@/domain/notifications/providers/utils.js";
|
||||||
import got, { HTTPError } from "got";
|
import got, { HTTPError } from "got";
|
||||||
|
|
||||||
// Types for Adaptive Card elements
|
// Types for Adaptive Card elements
|
||||||
@@ -254,7 +254,7 @@ export class TeamsProvider extends NotificationProvider {
|
|||||||
actions.push({
|
actions.push({
|
||||||
type: "Action.OpenUrl",
|
type: "Action.OpenUrl",
|
||||||
title: "View Incident",
|
title: "View Incident",
|
||||||
url: `${message.clientHost}/incidents/${message.content.incident.id}`,
|
url: incidentUrl(message),
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -2,7 +2,7 @@ const SERVICE_NAME = "TelegramProvider";
|
|||||||
import type { Notification } from "@/domain/notifications/notification.type.js";
|
import type { Notification } from "@/domain/notifications/notification.type.js";
|
||||||
import { NotificationProvider } from "@/domain/notifications/providers/INotificationProvider.js";
|
import { NotificationProvider } from "@/domain/notifications/providers/INotificationProvider.js";
|
||||||
import type { NotificationMessage } from "@/domain/notifications/notification.type.js";
|
import type { NotificationMessage } from "@/domain/notifications/notification.type.js";
|
||||||
import { getTestMessage } from "@/domain/notifications/providers/utils.js";
|
import { getTestMessage, incidentUrl } from "@/domain/notifications/providers/utils.js";
|
||||||
import got from "got";
|
import got from "got";
|
||||||
|
|
||||||
export class TelegramProvider extends NotificationProvider {
|
export class TelegramProvider extends NotificationProvider {
|
||||||
@@ -101,7 +101,7 @@ export class TelegramProvider extends NotificationProvider {
|
|||||||
|
|
||||||
if (message.content.incident) {
|
if (message.content.incident) {
|
||||||
lines.push("");
|
lines.push("");
|
||||||
lines.push(`<a href="${message.clientHost}/incidents/${message.monitor.id}">View Incident</a>`);
|
lines.push(`<a href="${incidentUrl(message)}">View Incident</a>`);
|
||||||
}
|
}
|
||||||
|
|
||||||
return lines.join("\n");
|
return lines.join("\n");
|
||||||
|
|||||||
@@ -1,4 +1,8 @@
|
|||||||
import { IEmailService } from "@/service/emailService.js";
|
import { IEmailService } from "@/service/emailService.js";
|
||||||
|
import type { NotificationMessage } from "@/domain/notifications/notification.type.js";
|
||||||
|
|
||||||
|
export const incidentUrl = (message: NotificationMessage): string => `${message.clientHost}/incidents/${message.monitor.id}`;
|
||||||
|
|
||||||
export const buildTestEmail = async (emailService: IEmailService) => {
|
export const buildTestEmail = async (emailService: IEmailService) => {
|
||||||
const context = { testName: "Monitoring System" };
|
const context = { testName: "Monitoring System" };
|
||||||
const html = await emailService.buildEmail("testEmailTemplate", context);
|
const html = await emailService.buildEmail("testEmailTemplate", context);
|
||||||
|
|||||||
@@ -2,7 +2,7 @@ const SERVICE_NAME = "WebhookProvider";
|
|||||||
import type { Notification } from "@/domain/notifications/notification.type.js";
|
import type { Notification } from "@/domain/notifications/notification.type.js";
|
||||||
import { NotificationProvider } from "@/domain/notifications/providers/INotificationProvider.js";
|
import { NotificationProvider } from "@/domain/notifications/providers/INotificationProvider.js";
|
||||||
import type { NotificationMessage } from "@/domain/notifications/notification.type.js";
|
import type { NotificationMessage } from "@/domain/notifications/notification.type.js";
|
||||||
import { getTestMessage } from "@/domain/notifications/providers/utils.js";
|
import { getTestMessage, incidentUrl } from "@/domain/notifications/providers/utils.js";
|
||||||
import got from "got";
|
import got from "got";
|
||||||
|
|
||||||
export class WebhookProvider extends NotificationProvider {
|
export class WebhookProvider extends NotificationProvider {
|
||||||
@@ -74,7 +74,7 @@ export class WebhookProvider extends NotificationProvider {
|
|||||||
|
|
||||||
// Incident link
|
// Incident link
|
||||||
if (message.content.incident) {
|
if (message.content.incident) {
|
||||||
lines.push(`[View Incident](${message.clientHost}/infrastructure/${message.monitor.id})`);
|
lines.push(`[View Incident](${incidentUrl(message)})`);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Return webhook payload with both text and structured data
|
// Return webhook payload with both text and structured data
|
||||||
|
|||||||
@@ -55,42 +55,15 @@ export class BufferService implements IBufferService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
addToBuffer(check: Check) {
|
addToBuffer(check: Check) {
|
||||||
try {
|
this.buffer.push(check);
|
||||||
this.buffer.push(check);
|
|
||||||
} catch (error: unknown) {
|
|
||||||
this.logger.error({
|
|
||||||
message: error instanceof Error ? error.message : "Unknown error",
|
|
||||||
service: this.SERVICE_NAME,
|
|
||||||
method: "addToBuffer",
|
|
||||||
stack: error instanceof Error ? error.stack : undefined,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
addGeoCheckToBuffer(geoCheck: GeoCheck) {
|
addGeoCheckToBuffer(geoCheck: GeoCheck) {
|
||||||
try {
|
this.geoBuffer.push(geoCheck);
|
||||||
this.geoBuffer.push(geoCheck);
|
|
||||||
} catch (error: unknown) {
|
|
||||||
this.logger.error({
|
|
||||||
message: error instanceof Error ? error.message : "Unknown error",
|
|
||||||
service: this.SERVICE_NAME,
|
|
||||||
method: "addGeoCheckToBuffer",
|
|
||||||
stack: error instanceof Error ? error.stack : undefined,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
addDockerLogToBuffer(dockerLog: DockerLog): void {
|
addDockerLogToBuffer(dockerLog: DockerLog): void {
|
||||||
try {
|
this.dockerLogBuffer.push(dockerLog);
|
||||||
this.dockerLogBuffer.push(dockerLog);
|
|
||||||
} catch (error: unknown) {
|
|
||||||
this.logger.error({
|
|
||||||
message: error instanceof Error ? error.message : "Unknown error",
|
|
||||||
service: this.SERVICE_NAME,
|
|
||||||
method: "addDockerLogToBuffer",
|
|
||||||
stack: error instanceof Error ? error.stack : undefined,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
scheduleNextFlush() {
|
scheduleNextFlush() {
|
||||||
|
|||||||
@@ -12,6 +12,23 @@ const __filename = fileURLToPath(import.meta.url);
|
|||||||
const __dirname = path.dirname(__filename);
|
const __dirname = path.dirname(__filename);
|
||||||
|
|
||||||
const SERVICE_NAME = "EmailService";
|
const SERVICE_NAME = "EmailService";
|
||||||
|
|
||||||
|
const SMTP_FAILURE_REASONS: Record<string, string> = {
|
||||||
|
EAUTH: "rejected the login. Check the user name and password",
|
||||||
|
ENOAUTH: "requires a login. Set the user name and password",
|
||||||
|
EDNS: "could not be resolved. Check the host name",
|
||||||
|
ECONNECTION: "refused the connection. Check the host and port",
|
||||||
|
ESOCKET: "refused the connection. Check the host and port",
|
||||||
|
ETIMEDOUT: "did not respond. Check the port and the secure setting",
|
||||||
|
ETLS: "failed the TLS handshake. Check the secure and TLS settings",
|
||||||
|
EPROTOCOL: "sent an invalid response. Check the port and the secure setting",
|
||||||
|
};
|
||||||
|
|
||||||
|
const describeSmtpFailure = (error: unknown, host: string, port: number): string => {
|
||||||
|
const code = typeof error === "object" && error !== null && "code" in error ? String((error as { code: unknown }).code) : undefined;
|
||||||
|
const reason = (code !== undefined && SMTP_FAILURE_REASONS[code]) || "failed verification";
|
||||||
|
return `SMTP host ${host}:${port} ${reason}.`;
|
||||||
|
};
|
||||||
type MjmlFn = typeof mjml2html;
|
type MjmlFn = typeof mjml2html;
|
||||||
type FileSystem = typeof fs;
|
type FileSystem = typeof fs;
|
||||||
type PathModule = typeof path;
|
type PathModule = typeof path;
|
||||||
@@ -123,6 +140,15 @@ export class EmailService implements IEmailService {
|
|||||||
systemEmailRejectUnauthorized,
|
systemEmailRejectUnauthorized,
|
||||||
} = config;
|
} = config;
|
||||||
|
|
||||||
|
if (!systemEmailHost) {
|
||||||
|
throw new AppError({
|
||||||
|
message: "Email is not configured. Set the system email host in settings.",
|
||||||
|
status: 400,
|
||||||
|
service: SERVICE_NAME,
|
||||||
|
method: "sendEmail",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
const emailConfig = {
|
const emailConfig = {
|
||||||
host: systemEmailHost,
|
host: systemEmailHost,
|
||||||
port: Number(systemEmailPort),
|
port: Number(systemEmailPort),
|
||||||
@@ -146,17 +172,20 @@ export class EmailService implements IEmailService {
|
|||||||
try {
|
try {
|
||||||
await this.transporter.verify();
|
await this.transporter.verify();
|
||||||
} catch (error: unknown) {
|
} catch (error: unknown) {
|
||||||
|
const cause = error instanceof Error ? error.message : "Unknown error";
|
||||||
this.logger.warn({
|
this.logger.warn({
|
||||||
message: "Email transporter verification failed",
|
message: `Email transporter verification failed: ${cause}`,
|
||||||
service: SERVICE_NAME,
|
service: SERVICE_NAME,
|
||||||
method: "verifyTransporter",
|
method: "verifyTransporter",
|
||||||
|
details: { host: systemEmailHost, port: emailConfig.port },
|
||||||
stack: error instanceof Error ? error.stack : undefined,
|
stack: error instanceof Error ? error.stack : undefined,
|
||||||
});
|
});
|
||||||
throw new AppError({
|
throw new AppError({
|
||||||
message: "Email transporter verification failed",
|
message: describeSmtpFailure(error, systemEmailHost, emailConfig.port),
|
||||||
|
status: 502,
|
||||||
service: SERVICE_NAME,
|
service: SERVICE_NAME,
|
||||||
method: "sendEmail",
|
method: "sendEmail",
|
||||||
details: { cause: error instanceof Error ? error.message : "Unknown error" },
|
details: { cause },
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -180,6 +209,7 @@ export class EmailService implements IEmailService {
|
|||||||
});
|
});
|
||||||
throw new AppError({
|
throw new AppError({
|
||||||
message: "Failed to send email",
|
message: "Failed to send email",
|
||||||
|
status: 502,
|
||||||
service: SERVICE_NAME,
|
service: SERVICE_NAME,
|
||||||
method: "sendEmail",
|
method: "sendEmail",
|
||||||
details: { cause: error instanceof Error ? error.message : "Unknown error" },
|
details: { cause: error instanceof Error ? error.message : "Unknown error" },
|
||||||
|
|||||||
@@ -3,7 +3,7 @@ import { supportsGeoCheck, type HttpStatusCode } from "@/domain/monitors/monitor
|
|||||||
import { MonitorType } from "@/domain/monitors/monitor.type.js";
|
import { MonitorType } from "@/domain/monitors/monitor.type.js";
|
||||||
import type { ILogger } from "@/utils/logger.js";
|
import type { ILogger } from "@/utils/logger.js";
|
||||||
import got from "got";
|
import got from "got";
|
||||||
import { isStatusUp } from "@/service/network/utils.js";
|
import { isStatusUp } from "@/service/networkProviders/utils.js";
|
||||||
|
|
||||||
const SERVICE_NAME = "GlobalPingService";
|
const SERVICE_NAME = "GlobalPingService";
|
||||||
const GLOBAL_PING_API_BASE = "https://api.globalping.io/v1";
|
const GLOBAL_PING_API_BASE = "https://api.globalping.io/v1";
|
||||||
|
|||||||
File renamed without changes.
+2
-2
@@ -1,10 +1,10 @@
|
|||||||
import { DNSStatusPayload, MonitorStatusResponse } from "@/types/network.js";
|
import { DNSStatusPayload, MonitorStatusResponse } from "@/types/network.js";
|
||||||
import { IStatusProvider } from "@/service/network/IStatusProvider.js";
|
import { IStatusProvider } from "@/service/networkProviders/IStatusProvider.js";
|
||||||
import dns from "dns";
|
import dns from "dns";
|
||||||
import type { Resolver } from "dns/promises";
|
import type { Resolver } from "dns/promises";
|
||||||
import { Monitor, MonitorType } from "@/domain/monitors/monitor.type.js";
|
import { Monitor, MonitorType } from "@/domain/monitors/monitor.type.js";
|
||||||
import { AppError } from "@/utils/AppError.js";
|
import { AppError } from "@/utils/AppError.js";
|
||||||
import { timeRequest } from "@/service/network/utils.js";
|
import { timeRequest } from "@/service/networkProviders/utils.js";
|
||||||
import { NETWORK_ERROR } from "@/types/network.js";
|
import { NETWORK_ERROR } from "@/types/network.js";
|
||||||
|
|
||||||
const SERVICE_NAME = "DNSProvider";
|
const SERVICE_NAME = "DNSProvider";
|
||||||
+19
-3
@@ -1,7 +1,7 @@
|
|||||||
import https from "node:https";
|
import https from "node:https";
|
||||||
|
|
||||||
import { IStatusProvider } from "@/service/network/IStatusProvider.js";
|
import { IStatusProvider } from "@/service/networkProviders/IStatusProvider.js";
|
||||||
import { HttpProvider } from "@/service/network/HttpProvider.js";
|
import { HttpProvider } from "@/service/networkProviders/HttpProvider.js";
|
||||||
import { CaptureDockerContainer, CaptureDockerStatusPayload, CheckContext, DockerStatusPayload, MonitorStatusResponse } from "@/types/network.js";
|
import { CaptureDockerContainer, CaptureDockerStatusPayload, CheckContext, DockerStatusPayload, MonitorStatusResponse } from "@/types/network.js";
|
||||||
|
|
||||||
import {
|
import {
|
||||||
@@ -24,7 +24,7 @@ import { Monitor, MonitorType } from "@/domain/monitors/monitor.type.js";
|
|||||||
import { ILogger } from "@/utils/logger.js";
|
import { ILogger } from "@/utils/logger.js";
|
||||||
import { AppError } from "@/utils/AppError.js";
|
import { AppError } from "@/utils/AppError.js";
|
||||||
import Dockerode from "dockerode";
|
import Dockerode from "dockerode";
|
||||||
import { timeRequest, peerAnsweredFromError } from "@/service/network/utils.js";
|
import { timeRequest, peerAnsweredFromError } from "@/service/networkProviders/utils.js";
|
||||||
import { NETWORK_ERROR } from "@/types/network.js";
|
import { NETWORK_ERROR } from "@/types/network.js";
|
||||||
import { IEncryptionService } from "@/service/encryption/encryptionService.js";
|
import { IEncryptionService } from "@/service/encryption/encryptionService.js";
|
||||||
import { DOCKER_TLS_URL, isCaptureDockerUrl, isDockerTlsUrl } from "@/utils/dockerHost.js";
|
import { DOCKER_TLS_URL, isCaptureDockerUrl, isDockerTlsUrl } from "@/utils/dockerHost.js";
|
||||||
@@ -42,6 +42,8 @@ const DOCKER_LOG_TRUNCATION_MARKER = " …[truncated]";
|
|||||||
const DOCKER_LOG_TS_REGEX = /^(\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2})(?:\.(\d{1,9}))?Z /;
|
const DOCKER_LOG_TS_REGEX = /^(\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2})(?:\.(\d{1,9}))?Z /;
|
||||||
const DOCKER_TLS_DEFAULT_PORT = 2376;
|
const DOCKER_TLS_DEFAULT_PORT = 2376;
|
||||||
const CAPTURE_FATAL_ERROR_METRICS = new Set(["docker.client", "docker.container.list"]);
|
const CAPTURE_FATAL_ERROR_METRICS = new Set(["docker.client", "docker.container.list"]);
|
||||||
|
const EXITED_STATUS = /^Exited \((\d+)\)/;
|
||||||
|
const HEALTH_STATUS = /\((healthy|unhealthy|health: starting)\)$/;
|
||||||
|
|
||||||
export interface DockerError extends Error {
|
export interface DockerError extends Error {
|
||||||
statusCode?: number;
|
statusCode?: number;
|
||||||
@@ -140,6 +142,17 @@ export class DockerProvider implements IStatusProvider<DockerStatusPayload> {
|
|||||||
return status && (DockerHealthStatuses as readonly string[]).includes(status) ? (status as DockerHealthStatus) : "none";
|
return status && (DockerHealthStatuses as readonly string[]).includes(status) ? (status as DockerHealthStatus) : "none";
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private exitCodeFromStatus = (status: string): number | undefined => {
|
||||||
|
const match = EXITED_STATUS.exec(status);
|
||||||
|
return match ? Number(match[1]) : undefined;
|
||||||
|
};
|
||||||
|
|
||||||
|
private healthFromStatus = (status: string): DockerHealthStatus => {
|
||||||
|
const match = HEALTH_STATUS.exec(status);
|
||||||
|
if (!match) return "none";
|
||||||
|
return match[1] === "health: starting" ? "starting" : (match[1] as DockerHealthStatus);
|
||||||
|
};
|
||||||
|
|
||||||
private toContainerSummary(containers: DockerContainerInfo[]): DockerContainerSummary {
|
private toContainerSummary(containers: DockerContainerInfo[]): DockerContainerSummary {
|
||||||
return {
|
return {
|
||||||
total: containers.length,
|
total: containers.length,
|
||||||
@@ -273,7 +286,10 @@ export class DockerProvider implements IStatusProvider<DockerStatusPayload> {
|
|||||||
info.health = this.toHealthStatus(inspectResult.value.State?.Health?.Status);
|
info.health = this.toHealthStatus(inspectResult.value.State?.Health?.Status);
|
||||||
info.ports = this.toPorts(inspectResult.value.NetworkSettings?.Ports);
|
info.ports = this.toPorts(inspectResult.value.NetworkSettings?.Ports);
|
||||||
info.mounts = this.toMounts(inspectResult.value.Mounts);
|
info.mounts = this.toMounts(inspectResult.value.Mounts);
|
||||||
|
info.exitCode = inspectResult.value.State?.ExitCode;
|
||||||
} else {
|
} else {
|
||||||
|
info.exitCode = this.exitCodeFromStatus(summary.Status);
|
||||||
|
info.health = this.healthFromStatus(summary.Status);
|
||||||
this.logger.warn({
|
this.logger.warn({
|
||||||
message: `Failed to inspect container ${info.name}`,
|
message: `Failed to inspect container ${info.name}`,
|
||||||
service: SERVICE_NAME,
|
service: SERVICE_NAME,
|
||||||
+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 { GameStatusPayload, MonitorStatusResponse } from "@/types/network.js";
|
||||||
import { Monitor, MonitorType } from "@/domain/monitors/monitor.type.js";
|
import { Monitor, MonitorType } from "@/domain/monitors/monitor.type.js";
|
||||||
import { GameDig } from "gamedig";
|
import { GameDig } from "gamedig";
|
||||||
+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 { GrpcStatusPayload, MonitorStatusResponse } from "@/types/network.js";
|
||||||
import { Monitor, MonitorType } from "@/domain/monitors/monitor.type.js";
|
import { Monitor, MonitorType } from "@/domain/monitors/monitor.type.js";
|
||||||
import { AppError } from "@/utils/AppError.js";
|
import { AppError } from "@/utils/AppError.js";
|
||||||
import { timeRequest } from "@/service/network/utils.js";
|
import { timeRequest } from "@/service/networkProviders/utils.js";
|
||||||
import * as grpc from "@grpc/grpc-js";
|
import * as grpc from "@grpc/grpc-js";
|
||||||
import * as protoLoader from "@grpc/proto-loader";
|
import * as protoLoader from "@grpc/proto-loader";
|
||||||
|
|
||||||
+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 { HardwareStatusPayload, MonitorStatusResponse } from "@/types/network.js";
|
||||||
import { Monitor, MonitorType } from "@/domain/monitors/monitor.type.js";
|
import { Monitor, MonitorType } from "@/domain/monitors/monitor.type.js";
|
||||||
import { HttpProvider } from "@/service/network/HttpProvider.js";
|
import { HttpProvider } from "@/service/networkProviders/HttpProvider.js";
|
||||||
import { AppError } from "@/utils/AppError.js";
|
import { AppError } from "@/utils/AppError.js";
|
||||||
|
|
||||||
export class HardwareProvider implements IStatusProvider<HardwareStatusPayload> {
|
export class HardwareProvider implements IStatusProvider<HardwareStatusPayload> {
|
||||||
+3
-3
@@ -1,12 +1,12 @@
|
|||||||
import { type Got, HTTPError, RequestError } from "got";
|
import { type Got, HTTPError, RequestError } from "got";
|
||||||
import { IAdvancedMatcher } from "@/service/network/AdvancedMatcher.js";
|
import { IAdvancedMatcher } from "@/service/networkProviders/AdvancedMatcher.js";
|
||||||
import { IStatusProvider } from "@/service/network/IStatusProvider.js";
|
import { IStatusProvider } from "@/service/networkProviders/IStatusProvider.js";
|
||||||
import { HttpStatusPayload } from "@/types/network.js";
|
import { HttpStatusPayload } from "@/types/network.js";
|
||||||
import { MonitorStatusResponse } from "@/types/network.js";
|
import { MonitorStatusResponse } from "@/types/network.js";
|
||||||
import { Agent as HttpsAgent } from "https";
|
import { Agent as HttpsAgent } from "https";
|
||||||
import { Agent as HttpAgent } from "http";
|
import { Agent as HttpAgent } from "http";
|
||||||
import { Monitor, MonitorType } from "@/domain/monitors/monitor.type.js";
|
import { Monitor, MonitorType } from "@/domain/monitors/monitor.type.js";
|
||||||
import { isStatusUp, peerAnsweredFromError } from "@/service/network/utils.js";
|
import { isStatusUp, peerAnsweredFromError } from "@/service/networkProviders/utils.js";
|
||||||
import { NETWORK_ERROR } from "@/types/network.js";
|
import { NETWORK_ERROR } from "@/types/network.js";
|
||||||
import CacheableLookup from "cacheable-lookup";
|
import CacheableLookup from "cacheable-lookup";
|
||||||
import { HttpProxyAgent, HttpsProxyAgent } from "hpagent";
|
import { HttpProxyAgent, HttpsProxyAgent } from "hpagent";
|
||||||
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 { MonitorStatusResponse, PageSpeedStatusPayload } from "@/types/network.js";
|
||||||
import { Monitor, MonitorType, DefaultPageSpeedStrategy } from "@/domain/monitors/monitor.type.js";
|
import { Monitor, MonitorType, DefaultPageSpeedStrategy } from "@/domain/monitors/monitor.type.js";
|
||||||
import { HttpProvider } from "@/service/network/HttpProvider.js";
|
import { HttpProvider } from "@/service/networkProviders/HttpProvider.js";
|
||||||
import { ISettingsService } from "@/domain/app-settings/app-settings.service.js";
|
import { ISettingsService } from "@/domain/app-settings/app-settings.service.js";
|
||||||
import { ILogger } from "@/utils/logger.js";
|
import { ILogger } from "@/utils/logger.js";
|
||||||
import { AppError } from "@/utils/AppError.js";
|
import { AppError } from "@/utils/AppError.js";
|
||||||
+1
-1
@@ -5,7 +5,7 @@ import { MonitorStatusResponse } from "@/types/network.js";
|
|||||||
import { AppError } from "@/utils/AppError.js";
|
import { AppError } from "@/utils/AppError.js";
|
||||||
import ping from "ping";
|
import ping from "ping";
|
||||||
import * as net from "net";
|
import * as net from "net";
|
||||||
import { timeRequest } from "@/service/network/utils.js";
|
import { timeRequest } from "@/service/networkProviders/utils.js";
|
||||||
const SERVICE_NAME = "PingProvider";
|
const SERVICE_NAME = "PingProvider";
|
||||||
|
|
||||||
type Ping = typeof ping;
|
type Ping = typeof ping;
|
||||||
+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 { PortStatusPayload, MonitorStatusResponse } from "@/types/network.js";
|
||||||
import { Monitor, MonitorType } from "@/domain/monitors/monitor.type.js";
|
import { Monitor, MonitorType } from "@/domain/monitors/monitor.type.js";
|
||||||
import { AppError } from "@/utils/AppError.js";
|
import { AppError } from "@/utils/AppError.js";
|
||||||
import { timeRequest, peerAnsweredFromError } from "@/service/network/utils.js";
|
import { timeRequest, peerAnsweredFromError } from "@/service/networkProviders/utils.js";
|
||||||
import { NETWORK_ERROR } from "@/types/network.js";
|
import { NETWORK_ERROR } from "@/types/network.js";
|
||||||
import * as net from "net";
|
import * as net from "net";
|
||||||
type NetType = typeof net;
|
type NetType = typeof net;
|
||||||
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 { WebSocketStatusPayload, MonitorStatusResponse } from "@/types/network.js";
|
||||||
import { Monitor, MonitorType } from "@/domain/monitors/monitor.type.js";
|
import { Monitor, MonitorType } from "@/domain/monitors/monitor.type.js";
|
||||||
import { AppError } from "@/utils/AppError.js";
|
import { AppError } from "@/utils/AppError.js";
|
||||||
import { timeRequest, peerAnsweredFromError } from "@/service/network/utils.js";
|
import { timeRequest, peerAnsweredFromError } from "@/service/networkProviders/utils.js";
|
||||||
import { NETWORK_ERROR } from "@/types/network.js";
|
import { NETWORK_ERROR } from "@/types/network.js";
|
||||||
import type WebSocket from "ws";
|
import type WebSocket from "ws";
|
||||||
|
|
||||||
@@ -0,0 +1,34 @@
|
|||||||
|
import type { Monitor, MonitorType } from "@/domain/monitors/monitor.type.js";
|
||||||
|
import type { CheckContext, MonitorPayloadMap, MonitorStatusResponse } from "@/types/network.js";
|
||||||
|
import { NETWORK_ERROR } from "@/types/network.js";
|
||||||
|
import { IStatusProvider } from "./IStatusProvider.js";
|
||||||
|
const SERVICE_NAME = "ProviderRegistry";
|
||||||
|
|
||||||
|
export interface IProviderRegistry {
|
||||||
|
probe<T extends MonitorType>(monitor: Monitor & { type: T }, ctx?: CheckContext): Promise<MonitorStatusResponse<MonitorPayloadMap[T]>>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export class ProviderRegistry implements IProviderRegistry {
|
||||||
|
static SERVICE_NAME = SERVICE_NAME;
|
||||||
|
|
||||||
|
constructor(private providers: IStatusProvider<unknown>[]) {}
|
||||||
|
|
||||||
|
async probe<T extends MonitorType>(monitor: Monitor & { type: T }, ctx?: CheckContext): Promise<MonitorStatusResponse<MonitorPayloadMap[T]>> {
|
||||||
|
const provider = this.providers.find((p) => p.supports(monitor.type));
|
||||||
|
if (!provider) {
|
||||||
|
return this.handleUnsupportedType(monitor.type) as Promise<MonitorStatusResponse<MonitorPayloadMap[T]>>;
|
||||||
|
}
|
||||||
|
return provider.handle(monitor, ctx) as Promise<MonitorStatusResponse<MonitorPayloadMap[T]>>;
|
||||||
|
}
|
||||||
|
|
||||||
|
private async handleUnsupportedType(type: string): Promise<MonitorStatusResponse> {
|
||||||
|
return {
|
||||||
|
monitorId: "unknown",
|
||||||
|
teamId: "unknown",
|
||||||
|
type: "unknown",
|
||||||
|
status: false,
|
||||||
|
code: NETWORK_ERROR,
|
||||||
|
message: `Unsupported type: ${type}`,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
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,49 +1,27 @@
|
|||||||
import { IMonitorStatsRepository } from "@/domain/monitor-stats/monitor-stats.repository.interface.js";
|
import { IMonitorStatsRepository } from "@/domain/monitor-stats/monitor-stats.repository.interface.js";
|
||||||
import { IMonitorsRepository } from "@/domain/monitors/monitor.repository.interface.js";
|
import { IMonitorsRepository } from "@/domain/monitors/monitor.repository.interface.js";
|
||||||
import type { Check, CheckDiskInfo } from "@/domain/checks/check.type.js";
|
import type { Check, CheckDiskInfo } from "@/domain/checks/check.type.js";
|
||||||
import { MonitorStatuses, type Monitor, type MonitorStatus } from "@/domain/monitors/monitor.type.js";
|
import {
|
||||||
import type {
|
HardwareMetricKeys,
|
||||||
DockerStatusPayload,
|
MonitorStatuses,
|
||||||
GameStatusPayload,
|
type HardwareBreaches,
|
||||||
GrpcStatusPayload,
|
type HardwareCounters,
|
||||||
HardwareStatusPayload,
|
type Monitor,
|
||||||
HttpStatusPayload,
|
type MonitorStatus,
|
||||||
MonitorStatusResponse,
|
} from "@/domain/monitors/monitor.type.js";
|
||||||
PageSpeedStatusPayload,
|
import type { StatusChangeResult } from "@/types/network.js";
|
||||||
PingStatusPayload,
|
|
||||||
PortStatusPayload,
|
|
||||||
StatusChangeResult,
|
|
||||||
} from "@/types/network.js";
|
|
||||||
import { AppError } from "@/utils/AppError.js";
|
import { AppError } from "@/utils/AppError.js";
|
||||||
import { ILogger } from "@/utils/logger.js";
|
import { ILogger } from "@/utils/logger.js";
|
||||||
import type { HardwareStatusMetrics } from "@/types/network.js";
|
import type { HardwareStatusMetrics } from "@/types/network.js";
|
||||||
import { MAX_RECENT_CHECKS } from "@/domain/monitors/monitor.type.js";
|
import { MAX_RECENT_CHECKS } from "@/domain/monitors/monitor.type.js";
|
||||||
import { toCheckSnapshot } from "@/domain/checks/check.snapshot.js";
|
import { toCheckSnapshot } from "@/domain/checks/check.snapshot.js";
|
||||||
|
import { findContainerBreaches } from "@/domain/docker/docker-alert.js";
|
||||||
|
|
||||||
const SERVICE_NAME = "StatusService";
|
const SERVICE_NAME = "StatusService";
|
||||||
const HARDWARE_ALERT_COUNTER_START = 5;
|
const HARDWARE_ALERT_COUNTER_START = 5;
|
||||||
const HARDWARE_METRIC_KEYS = ["cpu", "memory", "disk", "temp"] as const;
|
|
||||||
type HardwareMetricKey = (typeof HARDWARE_METRIC_KEYS)[number];
|
|
||||||
type HardwareBreaches = Record<HardwareMetricKey, boolean>;
|
|
||||||
type HardwareCounters = Record<HardwareMetricKey, number>;
|
|
||||||
|
|
||||||
export interface IStatusService {
|
export interface IStatusService {
|
||||||
updateRunningStats(monitor: Monitor, networkResponse: MonitorStatusResponse): Promise<boolean>;
|
updateMonitorStatus(check: Check, monitor: Monitor): Promise<StatusChangeResult>;
|
||||||
updateMonitorStatus(
|
|
||||||
statusResponse: MonitorStatusResponse<
|
|
||||||
| PingStatusPayload
|
|
||||||
| HttpStatusPayload
|
|
||||||
| PageSpeedStatusPayload
|
|
||||||
| HardwareStatusPayload
|
|
||||||
| DockerStatusPayload
|
|
||||||
| PortStatusPayload
|
|
||||||
| GameStatusPayload
|
|
||||||
| GrpcStatusPayload
|
|
||||||
| undefined
|
|
||||||
>,
|
|
||||||
check: Check,
|
|
||||||
monitor: Monitor
|
|
||||||
): Promise<StatusChangeResult>;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export class StatusService implements IStatusService {
|
export class StatusService implements IStatusService {
|
||||||
@@ -58,32 +36,19 @@ export class StatusService implements IStatusService {
|
|||||||
this.monitorStatsRepository = monitorStatsRepository;
|
this.monitorStatsRepository = monitorStatsRepository;
|
||||||
}
|
}
|
||||||
|
|
||||||
async updateRunningStats(monitor: Monitor, networkResponse: MonitorStatusResponse) {
|
private tryUpdateRunningStats = async (check: Check, monitor: Monitor) => {
|
||||||
try {
|
try {
|
||||||
await this.monitorStatsRepository.updateByMonitorId(monitor.id, {
|
await this.monitorStatsRepository.updateByMonitorId(monitor.id, {
|
||||||
status: networkResponse.status === true,
|
status: check.status === true,
|
||||||
responseTime: networkResponse.responseTime ?? 0,
|
responseTime: check.responseTime ?? 0,
|
||||||
now: Date.now(),
|
now: Date.now(),
|
||||||
});
|
});
|
||||||
return true;
|
|
||||||
} catch (error: unknown) {
|
} catch (error: unknown) {
|
||||||
this.logger.error({
|
|
||||||
service: SERVICE_NAME,
|
|
||||||
message: error instanceof Error ? error.message : "Unknown error",
|
|
||||||
method: "updateRunningStats",
|
|
||||||
stack: error instanceof Error ? error.stack : undefined,
|
|
||||||
});
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private tryUpdateRunningStats = async (monitor: Monitor, statusResponse: MonitorStatusResponse) => {
|
|
||||||
const statsOk = await this.updateRunningStats(monitor, statusResponse);
|
|
||||||
if (!statsOk) {
|
|
||||||
this.logger.warn({
|
this.logger.warn({
|
||||||
service: SERVICE_NAME,
|
service: SERVICE_NAME,
|
||||||
method: "updateMonitorStatus",
|
message: error instanceof Error ? error.message : "Unknown error",
|
||||||
message: `Stats update failed for monitor ${monitor.id}`,
|
method: "tryUpdateRunningStats",
|
||||||
|
stack: error instanceof Error ? error.stack : undefined,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
@@ -135,7 +100,7 @@ export class StatusService implements IStatusService {
|
|||||||
|
|
||||||
// Update counters: decrement (floored at 0) if breached, reset to start otherwise.
|
// Update counters: decrement (floored at 0) if breached, reset to start otherwise.
|
||||||
const nextCounters = { ...counters };
|
const nextCounters = { ...counters };
|
||||||
for (const key of HARDWARE_METRIC_KEYS) {
|
for (const key of HardwareMetricKeys) {
|
||||||
nextCounters[key] = breaches[key] ? Math.max(0, counters[key] - 1) : HARDWARE_ALERT_COUNTER_START;
|
nextCounters[key] = breaches[key] ? Math.max(0, counters[key] - 1) : HARDWARE_ALERT_COUNTER_START;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -147,8 +112,8 @@ export class StatusService implements IStatusService {
|
|||||||
if (!reachabilityDown) {
|
if (!reachabilityDown) {
|
||||||
// A counter can only reach zero via the decrement path, which only runs when that
|
// A counter can only reach zero via the decrement path, which only runs when that
|
||||||
// metric is currently breaching — so anyCounterZero already implies anyBreached.
|
// metric is currently breaching — so anyCounterZero already implies anyBreached.
|
||||||
const anyCounterZero = HARDWARE_METRIC_KEYS.some((k) => nextCounters[k] === 0);
|
const anyCounterZero = HardwareMetricKeys.some((k) => nextCounters[k] === 0);
|
||||||
const allNormal = HARDWARE_METRIC_KEYS.every((k) => !breaches[k]);
|
const allNormal = HardwareMetricKeys.every((k) => !breaches[k]);
|
||||||
|
|
||||||
if (anyCounterZero && currentStatus !== "breached") {
|
if (anyCounterZero && currentStatus !== "breached") {
|
||||||
nextStatus = "breached";
|
nextStatus = "breached";
|
||||||
@@ -162,26 +127,29 @@ export class StatusService implements IStatusService {
|
|||||||
return { nextStatus, transitioned, breaches, nextCounters };
|
return { nextStatus, transitioned, breaches, nextCounters };
|
||||||
};
|
};
|
||||||
|
|
||||||
updateMonitorStatus = async (
|
private computeDockerStatus = (params: { currentStatus: MonitorStatus; reachabilityDown: boolean; breaching: boolean }) => {
|
||||||
statusResponse: MonitorStatusResponse<
|
const { currentStatus, reachabilityDown, breaching } = params;
|
||||||
| PingStatusPayload
|
let nextStatus: MonitorStatus = currentStatus;
|
||||||
| HttpStatusPayload
|
let transitioned = false;
|
||||||
| PageSpeedStatusPayload
|
|
||||||
| HardwareStatusPayload
|
if (!reachabilityDown) {
|
||||||
| DockerStatusPayload
|
if (breaching && currentStatus !== "breached") {
|
||||||
| PortStatusPayload
|
nextStatus = "breached";
|
||||||
| GameStatusPayload
|
transitioned = true;
|
||||||
| GrpcStatusPayload
|
} else if (!breaching && currentStatus === "breached") {
|
||||||
| undefined
|
nextStatus = "up";
|
||||||
>,
|
transitioned = true;
|
||||||
check: Check,
|
}
|
||||||
monitor: Monitor
|
}
|
||||||
): Promise<StatusChangeResult> => {
|
return { nextStatus, transitioned };
|
||||||
|
};
|
||||||
|
|
||||||
|
updateMonitorStatus = async (check: Check, monitor: Monitor): Promise<StatusChangeResult> => {
|
||||||
try {
|
try {
|
||||||
const { status, code } = statusResponse;
|
const { status } = check;
|
||||||
|
|
||||||
// Update running stats
|
// Update running stats
|
||||||
await this.tryUpdateRunningStats(monitor, statusResponse);
|
await this.tryUpdateRunningStats(check, monitor);
|
||||||
|
|
||||||
const prevStatus = monitor.status;
|
const prevStatus = monitor.status;
|
||||||
const checkSnapshot = toCheckSnapshot(check);
|
const checkSnapshot = toCheckSnapshot(check);
|
||||||
@@ -206,69 +174,62 @@ export class StatusService implements IStatusService {
|
|||||||
statusChanged = newStatus === "down";
|
statusChanged = newStatus === "down";
|
||||||
}
|
}
|
||||||
|
|
||||||
// Not enough data points yet — record the check and return
|
|
||||||
if (projectedWindow.length < monitor.statusWindowSize) {
|
|
||||||
const updated = await this.monitorsRepository.updateStatusWindowAndChecks(
|
|
||||||
monitor.id,
|
|
||||||
monitor.teamId,
|
|
||||||
check.status,
|
|
||||||
checkSnapshot,
|
|
||||||
monitor.statusWindowSize,
|
|
||||||
MAX_RECENT_CHECKS,
|
|
||||||
patch
|
|
||||||
);
|
|
||||||
|
|
||||||
return {
|
|
||||||
monitor: updated,
|
|
||||||
statusChanged,
|
|
||||||
prevStatus,
|
|
||||||
code,
|
|
||||||
timestamp: Date.now(),
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
// First evaluate reachability status changes, which apply to all monitor types
|
|
||||||
// and take precedence over hardware breaches.
|
|
||||||
const reachabilityResult = this.computeReachability(newStatus, projectedWindow, monitor.statusWindowThreshold);
|
|
||||||
if (reachabilityResult.transitioned) {
|
|
||||||
newStatus = reachabilityResult.nextStatus;
|
|
||||||
statusChanged = true;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Evaluate hardware threshold breaches (only for hardware monitors with metrics payload)
|
|
||||||
let thresholdBreaches: HardwareBreaches | undefined;
|
let thresholdBreaches: HardwareBreaches | undefined;
|
||||||
const hardwarePayload = statusResponse.payload as HardwareStatusPayload | undefined;
|
|
||||||
if (monitor.type === "hardware" && hardwarePayload?.data) {
|
|
||||||
const hardware = this.computeHardwareStatus({
|
|
||||||
currentStatus: newStatus,
|
|
||||||
reachabilityDown: newStatus === "down",
|
|
||||||
metrics: hardwarePayload.data,
|
|
||||||
thresholds: {
|
|
||||||
cpu: monitor.cpuAlertThreshold,
|
|
||||||
memory: monitor.memoryAlertThreshold,
|
|
||||||
disk: monitor.diskAlertThreshold,
|
|
||||||
temp: monitor.tempAlertThreshold,
|
|
||||||
},
|
|
||||||
counters: {
|
|
||||||
cpu: monitor.cpuAlertCounter,
|
|
||||||
memory: monitor.memoryAlertCounter,
|
|
||||||
disk: monitor.diskAlertCounter,
|
|
||||||
temp: monitor.tempAlertCounter,
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
patch.cpuAlertCounter = hardware.nextCounters.cpu;
|
if (projectedWindow.length >= monitor.statusWindowSize) {
|
||||||
patch.memoryAlertCounter = hardware.nextCounters.memory;
|
// First evaluate reachability status changes, which apply to all monitor types
|
||||||
patch.diskAlertCounter = hardware.nextCounters.disk;
|
// and take precedence over hardware breaches.
|
||||||
patch.tempAlertCounter = hardware.nextCounters.temp;
|
const reachabilityResult = this.computeReachability(newStatus, projectedWindow, monitor.statusWindowThreshold);
|
||||||
thresholdBreaches = hardware.breaches;
|
if (reachabilityResult.transitioned) {
|
||||||
if (hardware.transitioned) {
|
newStatus = reachabilityResult.nextStatus;
|
||||||
newStatus = hardware.nextStatus;
|
|
||||||
statusChanged = true;
|
statusChanged = true;
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
patch.status = newStatus;
|
// 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (monitor.type === "docker" && check.containers) {
|
||||||
|
const breaches = findContainerBreaches(monitor, check.containers);
|
||||||
|
const docker = this.computeDockerStatus({
|
||||||
|
currentStatus: newStatus,
|
||||||
|
reachabilityDown: newStatus === "down",
|
||||||
|
breaching: breaches.length > 0,
|
||||||
|
});
|
||||||
|
if (docker.transitioned) {
|
||||||
|
newStatus = docker.nextStatus;
|
||||||
|
statusChanged = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
patch.status = newStatus;
|
||||||
|
}
|
||||||
|
|
||||||
// Single atomic write: push arrays + set status/counters
|
// Single atomic write: push arrays + set status/counters
|
||||||
const updated = await this.monitorsRepository.updateStatusWindowAndChecks(
|
const updated = await this.monitorsRepository.updateStatusWindowAndChecks(
|
||||||
@@ -285,8 +246,6 @@ export class StatusService implements IStatusService {
|
|||||||
monitor: updated,
|
monitor: updated,
|
||||||
statusChanged,
|
statusChanged,
|
||||||
prevStatus,
|
prevStatus,
|
||||||
code,
|
|
||||||
timestamp: new Date().getTime(),
|
|
||||||
thresholdBreaches,
|
thresholdBreaches,
|
||||||
};
|
};
|
||||||
} catch (error: unknown) {
|
} catch (error: unknown) {
|
||||||
|
|||||||
@@ -10,7 +10,7 @@ import type {
|
|||||||
ILighthouseAudit,
|
ILighthouseAudit,
|
||||||
} from "@/domain/checks/check.type.js";
|
} from "@/domain/checks/check.type.js";
|
||||||
import { DockerContainerInfo, DockerContainerLogs, DockerContainerSummary } from "@/domain/docker/docker.type.js";
|
import { DockerContainerInfo, DockerContainerLogs, DockerContainerSummary } from "@/domain/docker/docker.type.js";
|
||||||
import type { DnsRecordType, Monitor, MonitorMatchMethod, MonitorStatus, MonitorType } from "@/domain/monitors/monitor.type.js";
|
import type { DnsRecordType, HardwareBreaches, Monitor, MonitorMatchMethod, MonitorStatus, MonitorType } from "@/domain/monitors/monitor.type.js";
|
||||||
|
|
||||||
import type { QueryResult } from "gamedig";
|
import type { QueryResult } from "gamedig";
|
||||||
|
|
||||||
@@ -182,14 +182,7 @@ export type StatusChangeResult = {
|
|||||||
monitor: Monitor;
|
monitor: Monitor;
|
||||||
statusChanged: boolean;
|
statusChanged: boolean;
|
||||||
prevStatus: MonitorStatus;
|
prevStatus: MonitorStatus;
|
||||||
code: number;
|
thresholdBreaches?: HardwareBreaches;
|
||||||
timestamp: number;
|
|
||||||
thresholdBreaches?: {
|
|
||||||
cpu: boolean;
|
|
||||||
memory: boolean;
|
|
||||||
disk: boolean;
|
|
||||||
temp: boolean;
|
|
||||||
};
|
|
||||||
};
|
};
|
||||||
|
|
||||||
export type MonitorStatusResponseOverrides<T> = Partial<Omit<MonitorStatusResponse<T>, "monitorId" | "teamId" | "type">>;
|
export type MonitorStatusResponseOverrides<T> = Partial<Omit<MonitorStatusResponse<T>, "monitorId" | "teamId" | "type">>;
|
||||||
|
|||||||
+12
-15
@@ -60,21 +60,18 @@ class Logger implements ILogger {
|
|||||||
if (formattedDetails) msg += ` (details: ${formattedDetails})`;
|
if (formattedDetails) msg += ` (details: ${formattedDetails})`;
|
||||||
|
|
||||||
if (typeof stack === "string") {
|
if (typeof stack === "string") {
|
||||||
const stackTrace = stack
|
const stackTrace = stack.split("\n").map((line: string) => {
|
||||||
.split("\n")
|
const match = line.match(/at\s+(.+?)\s+\((.+?):(\d+):(\d+)\)/);
|
||||||
.slice(1) // Remove first line (error message)
|
if (match) {
|
||||||
.map((line: string) => {
|
return {
|
||||||
const match = line.match(/at\s+(.+?)\s+\((.+?):(\d+):(\d+)\)/);
|
function: match[1],
|
||||||
if (match) {
|
file: match[2],
|
||||||
return {
|
line: parseInt(match[3] ?? "0", 10),
|
||||||
function: match[1],
|
column: parseInt(match[4] ?? "0", 10),
|
||||||
file: match[2],
|
};
|
||||||
line: parseInt(match[3] ?? "0", 10),
|
}
|
||||||
column: parseInt(match[4] ?? "0", 10),
|
return line.trim();
|
||||||
};
|
});
|
||||||
}
|
|
||||||
return line.trim();
|
|
||||||
});
|
|
||||||
msg += ` (stack: ${JSON.stringify(stackTrace, null, 2)})`;
|
msg += ` (stack: ${JSON.stringify(stackTrace, null, 2)})`;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -8,6 +8,6 @@ export class IncidentReactor implements IMonitorReactor {
|
|||||||
constructor(private incidentService: IIncidentService) {}
|
constructor(private incidentService: IIncidentService) {}
|
||||||
|
|
||||||
react = async (evaluation: MonitorEvaluation) => {
|
react = async (evaluation: MonitorEvaluation) => {
|
||||||
await this.incidentService.handleIncident(evaluation.monitor, evaluation.statusChange.code, evaluation.decision, evaluation.status);
|
await this.incidentService.handleIncident(evaluation.monitor, evaluation.decision, evaluation.check);
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
@@ -7,7 +7,6 @@ export class NotificationReactor implements IMonitorReactor {
|
|||||||
constructor(private notificationService: INotificationsService) {}
|
constructor(private notificationService: INotificationsService) {}
|
||||||
|
|
||||||
react = async (evaluation: MonitorEvaluation) => {
|
react = async (evaluation: MonitorEvaluation) => {
|
||||||
if (!evaluation.decision.shouldSendNotification) return;
|
await this.notificationService.handleNotifications(evaluation.monitor, evaluation.check, evaluation.decision);
|
||||||
await this.notificationService.handleNotifications(evaluation.monitor, evaluation.status, evaluation.decision);
|
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
@@ -1,31 +1,23 @@
|
|||||||
import { Monitor } from "@/domain/monitors/monitor.type.js";
|
import { HardwareBreaches, Monitor } from "@/domain/monitors/monitor.type.js";
|
||||||
import { Check } from "@/domain/checks/check.type.js";
|
import { Check } from "@/domain/checks/check.type.js";
|
||||||
import { Job, JobType } from "@/domain/jobs/job.type.js";
|
import { Job, JobType } from "@/domain/jobs/job.type.js";
|
||||||
import { MonitorPayloadMap, MonitorStatusResponse, StatusChangeResult } from "@/types/network.js";
|
|
||||||
import { QueueWorker } from "@/domain/queue-workers/queue-worker.type.js";
|
import { QueueWorker } from "@/domain/queue-workers/queue-worker.type.js";
|
||||||
import type { QueueMode } from "@/domain/app-settings/app-settings.type.js";
|
import type { QueueMode } from "@/domain/app-settings/app-settings.type.js";
|
||||||
|
|
||||||
export type JobHandler = (job: Job) => Promise<void>;
|
export type JobHandler = (job: Job) => Promise<void>;
|
||||||
export type JobHandlers = Record<JobType, JobHandler>;
|
export type JobHandlers = Record<JobType, JobHandler>;
|
||||||
|
|
||||||
|
export const MonitorTransitions = ["status_down", "status_up", "threshold_breach", "threshold_resolved"] as const;
|
||||||
|
export type MonitorTransition = (typeof MonitorTransitions)[number];
|
||||||
|
|
||||||
export interface MonitorActionDecision {
|
export interface MonitorActionDecision {
|
||||||
shouldCreateIncident: boolean;
|
transition: MonitorTransition | null;
|
||||||
shouldResolveIncident: boolean;
|
thresholdBreaches?: HardwareBreaches;
|
||||||
shouldSendNotification: boolean;
|
|
||||||
incidentReason: "status_down" | "threshold_breach" | null;
|
|
||||||
notificationReason: "status_change" | "threshold_breach" | null;
|
|
||||||
thresholdBreaches?: {
|
|
||||||
cpu?: boolean;
|
|
||||||
memory?: boolean;
|
|
||||||
disk?: boolean;
|
|
||||||
temp?: boolean;
|
|
||||||
};
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export type MonitorEvaluation = {
|
export type MonitorEvaluation = {
|
||||||
monitor: Monitor;
|
monitor: Monitor;
|
||||||
status: MonitorStatusResponse<MonitorPayloadMap[keyof MonitorPayloadMap]>; // raw result from networkService.requestStatus
|
|
||||||
check: Check;
|
check: Check;
|
||||||
statusChange: StatusChangeResult; // from statusService.updateMonitorStatus
|
|
||||||
decision: MonitorActionDecision;
|
decision: MonitorActionDecision;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -3,15 +3,14 @@ import { ICheckService } from "@/domain/checks/check.service.js";
|
|||||||
import { Check } from "@/domain/checks/check.type.js";
|
import { Check } from "@/domain/checks/check.type.js";
|
||||||
import { IDockerLogsService } from "@/domain/docker/docker-log.service.js";
|
import { IDockerLogsService } from "@/domain/docker/docker-log.service.js";
|
||||||
import { IEgressService, isHttpStatusCode } from "@/domain/egress/egress.service.js";
|
import { IEgressService, isHttpStatusCode } from "@/domain/egress/egress.service.js";
|
||||||
import type { EgressStatus } from "@/domain/egress/egress.type.js";
|
|
||||||
import { IJobsRepository } from "@/domain/jobs/job.repository.interface.js";
|
import { IJobsRepository } from "@/domain/jobs/job.repository.interface.js";
|
||||||
import { PendingCheck } from "@/domain/jobs/job.type.js";
|
import { PendingCheck } from "@/domain/jobs/job.type.js";
|
||||||
import { IMaintenanceWindowsRepository } from "@/domain/maintenance-windows/maintenance-window.repository.interface.js";
|
import { IMaintenanceWindowsRepository } from "@/domain/maintenance-windows/maintenance-window.repository.interface.js";
|
||||||
import { IMonitorsRepository } from "@/domain/monitors/monitor.repository.interface.js";
|
import { IMonitorsRepository } from "@/domain/monitors/monitor.repository.interface.js";
|
||||||
import { isEgressAttributable, Monitor } from "@/domain/monitors/monitor.type.js";
|
import { isEgressAttributable, Monitor } from "@/domain/monitors/monitor.type.js";
|
||||||
import { IBufferService } from "@/service/bufferService.js";
|
import { IBufferService } from "@/service/bufferService.js";
|
||||||
import { IProxyResolver } from "@/service/network/ProxyResolver.js";
|
import { IProxyResolver } from "@/service/networkProviders/ProxyResolver.js";
|
||||||
import { INetworkService } from "@/service/networkService.js";
|
import { IProviderRegistry } from "@/service/networkProviders/providerRegistry.js";
|
||||||
import { IStatusService } from "@/service/statusService.js";
|
import { IStatusService } from "@/service/statusService.js";
|
||||||
import { DockerStatusPayload, MonitorStatusResponse, StatusChangeResult } from "@/types/network.js";
|
import { DockerStatusPayload, MonitorStatusResponse, StatusChangeResult } from "@/types/network.js";
|
||||||
import { AppError } from "@/utils/AppError.js";
|
import { AppError } from "@/utils/AppError.js";
|
||||||
@@ -37,7 +36,7 @@ export interface WorkerPipelineDependencies {
|
|||||||
checksRepository: IChecksRepository;
|
checksRepository: IChecksRepository;
|
||||||
jobsRepository: IJobsRepository;
|
jobsRepository: IJobsRepository;
|
||||||
checkService: ICheckService;
|
checkService: ICheckService;
|
||||||
networkService: INetworkService;
|
providerRegistry: IProviderRegistry;
|
||||||
proxyResolver: IProxyResolver;
|
proxyResolver: IProxyResolver;
|
||||||
bufferService: IBufferService;
|
bufferService: IBufferService;
|
||||||
dockerLogsService: IDockerLogsService;
|
dockerLogsService: IDockerLogsService;
|
||||||
@@ -104,7 +103,7 @@ export class WorkerPipeline implements IWorkerPipeline {
|
|||||||
const proxyUrl = await this.deps.proxyResolver.resolve(monitor);
|
const proxyUrl = await this.deps.proxyResolver.resolve(monitor);
|
||||||
const dockerTlsKey = await this.resolveDockerTlsKey(monitor);
|
const dockerTlsKey = await this.resolveDockerTlsKey(monitor);
|
||||||
|
|
||||||
const status = await this.deps.networkService.requestStatus(monitor, { proxyUrl, dockerTlsKey });
|
const status = await this.deps.providerRegistry.probe(monitor, { proxyUrl, dockerTlsKey });
|
||||||
if (!status) {
|
if (!status) {
|
||||||
throw new Error("No network response");
|
throw new Error("No network response");
|
||||||
}
|
}
|
||||||
@@ -128,7 +127,7 @@ export class WorkerPipeline implements IWorkerPipeline {
|
|||||||
|
|
||||||
// Step 2b: On a transport failure, ask whether the instance itself can reach anything before blaming the
|
// Step 2b: On a transport failure, ask whether the instance itself can reach anything before blaming the
|
||||||
// target. Null means the egress check is disabled (or failed internally) and the check is treated as usual.
|
// target. Null means the egress check is disabled (or failed internally) and the check is treated as usual.
|
||||||
const egressStatus = this.isTransportFailure(monitor, status) ? await this.assessEgress(monitor.id) : null;
|
const egressStatus = this.isTransportFailure(monitor, status) ? await this.deps.egressService.assessAfterFailure() : null;
|
||||||
if (egressStatus !== null) {
|
if (egressStatus !== null) {
|
||||||
check.egressStatus = egressStatus;
|
check.egressStatus = egressStatus;
|
||||||
}
|
}
|
||||||
@@ -171,20 +170,6 @@ export class WorkerPipeline implements IWorkerPipeline {
|
|||||||
return status.status === false && isEgressAttributable(monitor) && !this.peerAnswered(status);
|
return status.status === false && isEgressAttributable(monitor) && !this.peerAnswered(status);
|
||||||
}
|
}
|
||||||
|
|
||||||
// The egress check must never stop a check being recorded, so a rejection is logged and treated as unknown.
|
|
||||||
private async assessEgress(monitorId: string): Promise<EgressStatus | null> {
|
|
||||||
try {
|
|
||||||
return await this.deps.egressService.assessAfterFailure();
|
|
||||||
} catch (error: unknown) {
|
|
||||||
this.deps.logger.warn({
|
|
||||||
message: `Egress assessment failed for monitor ${monitorId}: ${error instanceof Error ? error.message : String(error)}`,
|
|
||||||
service: SERVICE_NAME,
|
|
||||||
method: "assessEgress",
|
|
||||||
});
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// ****************************************************************
|
// ****************************************************************
|
||||||
// Stage 2: Ingestion
|
// Stage 2: Ingestion
|
||||||
// Called by the buffer, checks are inserted and evaluation jobs created
|
// Called by the buffer, checks are inserted and evaluation jobs created
|
||||||
@@ -240,8 +225,7 @@ export class WorkerPipeline implements IWorkerPipeline {
|
|||||||
|
|
||||||
let current = monitor;
|
let current = monitor;
|
||||||
for (const check of checks) {
|
for (const check of checks) {
|
||||||
const status = this.deps.checkService.toStatusResponse(check);
|
const evaluation = await this.evaluateCheck(check, current);
|
||||||
const evaluation = await this.evaluateCheck(status, check, current);
|
|
||||||
await this.deps.dispatcher.dispatch(evaluation); // Handle incidents and notifications
|
await this.deps.dispatcher.dispatch(evaluation); // Handle incidents and notifications
|
||||||
const leaseHeld = await this.deps.jobsRepository.pullEvaluated(job.id, [check.id]);
|
const leaseHeld = await this.deps.jobsRepository.pullEvaluated(job.id, [check.id]);
|
||||||
if (!leaseHeld) {
|
if (!leaseHeld) {
|
||||||
@@ -253,7 +237,7 @@ export class WorkerPipeline implements IWorkerPipeline {
|
|||||||
});
|
});
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
current = evaluation.statusChange.monitor; // fresh statusWindow/status/counters for the next check
|
current = evaluation.monitor; // fresh statusWindow/status/counters for the next check
|
||||||
}
|
}
|
||||||
|
|
||||||
// Ids whose check was not returned (deleted by retention cleanup) would otherwise sit on the row forever
|
// Ids whose check was not returned (deleted by retention cleanup) would otherwise sit on the row forever
|
||||||
@@ -272,65 +256,47 @@ export class WorkerPipeline implements IWorkerPipeline {
|
|||||||
// A check taken while the instance had no outbound connectivity says nothing about the target.
|
// A check taken while the instance had no outbound connectivity says nothing about the target.
|
||||||
// It is left out of the status window, running stats and monitor status entirely, so that no incident
|
// It is left out of the status window, running stats and monitor status entirely, so that no incident
|
||||||
// opens for it and no spurious "resolved"/"up" fires once egress returns.
|
// opens for it and no spurious "resolved"/"up" fires once egress returns.
|
||||||
private skipDegradedEgressCheck = (status: MonitorStatusResponse, check: Check, monitor: Monitor): MonitorEvaluation => {
|
private skipDegradedEgressCheck = (check: Check, monitor: Monitor): MonitorEvaluation => {
|
||||||
this.deps.logger.debug({
|
this.deps.logger.debug({
|
||||||
message: `Skipping evaluation of check ${check.id} for monitor ${monitor.id}: instance egress was degraded`,
|
message: `Skipping evaluation of check ${check.id} for monitor ${monitor.id}: instance egress was degraded`,
|
||||||
service: SERVICE_NAME,
|
service: SERVICE_NAME,
|
||||||
method: "evaluateCheck",
|
method: "evaluateCheck",
|
||||||
});
|
});
|
||||||
const decision: MonitorActionDecision = {
|
|
||||||
shouldCreateIncident: false,
|
|
||||||
shouldResolveIncident: false,
|
|
||||||
shouldSendNotification: false,
|
|
||||||
incidentReason: null,
|
|
||||||
notificationReason: null,
|
|
||||||
};
|
|
||||||
return {
|
return {
|
||||||
monitor,
|
monitor,
|
||||||
status,
|
|
||||||
check,
|
check,
|
||||||
statusChange: { monitor, statusChanged: false, prevStatus: monitor.status, code: status.code, timestamp: Date.now() },
|
decision: { transition: null },
|
||||||
decision,
|
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|
||||||
evaluateCheck = async (status: MonitorStatusResponse, check: Check, monitor: Monitor): Promise<MonitorEvaluation> => {
|
evaluateCheck = async (check: Check, monitor: Monitor): Promise<MonitorEvaluation> => {
|
||||||
if (check.egressStatus === "degraded") {
|
if (check.egressStatus === "degraded") {
|
||||||
return this.skipDegradedEgressCheck(status, check, monitor);
|
return this.skipDegradedEgressCheck(check, monitor);
|
||||||
}
|
}
|
||||||
|
|
||||||
const statusChange = await this.deps.statusService.updateMonitorStatus(status, check, monitor);
|
const statusChange = await this.deps.statusService.updateMonitorStatus(check, monitor);
|
||||||
|
|
||||||
const decision = this.decide(statusChange);
|
const decision = this.decide(statusChange);
|
||||||
return { monitor: statusChange.monitor, status, check, statusChange, decision };
|
return { monitor: statusChange.monitor, check, decision };
|
||||||
};
|
};
|
||||||
|
|
||||||
private decide = (statusChange: StatusChangeResult): MonitorActionDecision => {
|
private decide = (statusChange: StatusChangeResult): MonitorActionDecision => {
|
||||||
const { monitor, statusChanged, prevStatus } = statusChange;
|
const { monitor, statusChanged, prevStatus, thresholdBreaches } = statusChange;
|
||||||
const decision: MonitorActionDecision = {
|
if (!statusChanged) return { transition: null };
|
||||||
shouldCreateIncident: false,
|
|
||||||
shouldResolveIncident: false,
|
|
||||||
shouldSendNotification: false,
|
|
||||||
incidentReason: null,
|
|
||||||
notificationReason: null,
|
|
||||||
};
|
|
||||||
if (!statusChanged) return decision;
|
|
||||||
|
|
||||||
if (monitor.status === "down") {
|
if (monitor.status === "down") return { transition: "status_down" };
|
||||||
decision.shouldCreateIncident = true;
|
|
||||||
decision.shouldSendNotification = true;
|
if (monitor.status === "breached") {
|
||||||
decision.incidentReason = "status_down";
|
return thresholdBreaches
|
||||||
decision.notificationReason = "status_change";
|
? // Hardware
|
||||||
} else if (monitor.status === "breached") {
|
{ transition: "threshold_breach", thresholdBreaches }
|
||||||
decision.shouldCreateIncident = true;
|
: // Docker
|
||||||
decision.shouldSendNotification = true;
|
{ transition: "threshold_breach" };
|
||||||
decision.incidentReason = "threshold_breach";
|
|
||||||
decision.notificationReason = "threshold_breach";
|
|
||||||
} else if (monitor.status === "up" && (prevStatus === "down" || prevStatus === "breached")) {
|
|
||||||
decision.shouldResolveIncident = true;
|
|
||||||
decision.shouldSendNotification = true;
|
|
||||||
decision.notificationReason = "status_change";
|
|
||||||
}
|
}
|
||||||
return decision;
|
|
||||||
|
if (monitor.status === "up" && prevStatus === "breached") return { transition: "threshold_resolved" };
|
||||||
|
if (monitor.status === "up" && prevStatus === "down") return { transition: "status_up" };
|
||||||
|
return { transition: null };
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
@@ -5,17 +5,17 @@ import { IncidentReactor } from "../../src/worker/reactors/reactor.incident.ts";
|
|||||||
import { ReactorDispatcher } from "../../src/worker/reactors/reactor.dispatcher.ts";
|
import { ReactorDispatcher } from "../../src/worker/reactors/reactor.dispatcher.ts";
|
||||||
import { StatusService } from "../../src/service/statusService.ts";
|
import { StatusService } from "../../src/service/statusService.ts";
|
||||||
import { IncidentService } from "../../src/domain/incidents/incident.service.ts";
|
import { IncidentService } from "../../src/domain/incidents/incident.service.ts";
|
||||||
|
import { CheckService } from "../../src/domain/checks/check.service.ts";
|
||||||
import { InMemoryMonitorsRepository } from "./InMemoryMonitorsRepository.ts";
|
import { InMemoryMonitorsRepository } from "./InMemoryMonitorsRepository.ts";
|
||||||
import { InMemoryIncidentsRepository } from "./InMemoryIncidentsRepository.ts";
|
import { InMemoryIncidentsRepository } from "./InMemoryIncidentsRepository.ts";
|
||||||
import { createMockLogger } from "./createMockLogger.ts";
|
import { createMockLogger } from "./createMockLogger.ts";
|
||||||
import type { Monitor } from "../../src/domain/monitors/monitor.type.ts";
|
import type { Monitor } from "../../src/domain/monitors/monitor.type.ts";
|
||||||
import type { MonitorStatusResponse } from "../../src/types/network.ts";
|
|
||||||
import type { Check } from "../../src/domain/checks/check.type.ts";
|
import type { Check } from "../../src/domain/checks/check.type.ts";
|
||||||
|
import type { MonitorActionDecision } from "../../src/worker/worker.interface.ts";
|
||||||
|
import type { MonitorStatusResponse } from "../../src/types/network.ts";
|
||||||
import type { MaintenanceWindow } from "../../src/domain/maintenance-windows/maintenance-window.type.ts";
|
import type { MaintenanceWindow } from "../../src/domain/maintenance-windows/maintenance-window.type.ts";
|
||||||
import type { EgressStatus } from "../../src/domain/egress/egress.type.ts";
|
import type { EgressStatus } from "../../src/domain/egress/egress.type.ts";
|
||||||
|
|
||||||
let checkCounter = 0;
|
|
||||||
|
|
||||||
export const makeMonitor = (overrides?: Partial<Monitor>): Monitor =>
|
export const makeMonitor = (overrides?: Partial<Monitor>): Monitor =>
|
||||||
({
|
({
|
||||||
id: "mon-1",
|
id: "mon-1",
|
||||||
@@ -45,20 +45,6 @@ export const makeStatusResponse = (status: boolean, code: number): MonitorStatus
|
|||||||
responseTime: status ? 150 : 0,
|
responseTime: status ? 150 : 0,
|
||||||
});
|
});
|
||||||
|
|
||||||
export const makeCheck = (status: boolean, code: number): Check => {
|
|
||||||
const now = new Date().toISOString();
|
|
||||||
return {
|
|
||||||
id: `check-${++checkCounter}`,
|
|
||||||
metadata: { monitorId: "mon-1", teamId: "team-1", type: "http" },
|
|
||||||
status,
|
|
||||||
statusCode: code,
|
|
||||||
responseTime: status ? 150 : 0,
|
|
||||||
message: status ? "OK" : "Service Unavailable",
|
|
||||||
createdAt: now,
|
|
||||||
updatedAt: now,
|
|
||||||
};
|
|
||||||
};
|
|
||||||
|
|
||||||
const createStubMonitorStatsRepo = () => ({
|
const createStubMonitorStatsRepo = () => ({
|
||||||
findByMonitorId: jest.fn().mockRejectedValue(new Error("no stats")),
|
findByMonitorId: jest.fn().mockRejectedValue(new Error("no stats")),
|
||||||
create: jest.fn().mockResolvedValue({}),
|
create: jest.fn().mockResolvedValue({}),
|
||||||
@@ -74,10 +60,10 @@ export interface HeartbeatTestHarness {
|
|||||||
statusService: StatusService;
|
statusService: StatusService;
|
||||||
incidentService: IncidentService;
|
incidentService: IncidentService;
|
||||||
notificationsService: { handleNotifications: jest.Mock };
|
notificationsService: { handleNotifications: jest.Mock };
|
||||||
networkService: { requestStatus: jest.Mock };
|
providerRegistry: { probe: jest.Mock };
|
||||||
bufferStub: { addToBuffer: jest.Mock; addGeoCheckToBuffer: jest.Mock; scheduleNextFlush: jest.Mock };
|
bufferStub: { addToBuffer: jest.Mock; addGeoCheckToBuffer: jest.Mock; scheduleNextFlush: jest.Mock };
|
||||||
maintenanceWindowsRepo: { findByMonitorId: jest.Mock };
|
maintenanceWindowsRepo: { findByMonitorId: jest.Mock };
|
||||||
messageBuilder: { extractThresholdBreaches: jest.Mock };
|
messageBuilder: { buildThresholdBreachMessage: jest.Mock };
|
||||||
egressService: { assessAfterFailure: jest.Mock };
|
egressService: { assessAfterFailure: jest.Mock };
|
||||||
heartbeatJob: (monitor: Monitor) => Promise<void>;
|
heartbeatJob: (monitor: Monitor) => Promise<void>;
|
||||||
setNextResponse: (status: boolean, code: number) => void;
|
setNextResponse: (status: boolean, code: number) => void;
|
||||||
@@ -86,8 +72,6 @@ export interface HeartbeatTestHarness {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function createHeartbeatTestHarness(): HeartbeatTestHarness {
|
export function createHeartbeatTestHarness(): HeartbeatTestHarness {
|
||||||
checkCounter = 0;
|
|
||||||
|
|
||||||
const monitorsRepo = new InMemoryMonitorsRepository();
|
const monitorsRepo = new InMemoryMonitorsRepository();
|
||||||
const incidentsRepo = new InMemoryIncidentsRepository();
|
const incidentsRepo = new InMemoryIncidentsRepository();
|
||||||
const logger = createMockLogger() as any;
|
const logger = createMockLogger() as any;
|
||||||
@@ -95,7 +79,7 @@ export function createHeartbeatTestHarness(): HeartbeatTestHarness {
|
|||||||
|
|
||||||
const statusService = new StatusService(logger, monitorsRepo as any, createStubMonitorStatsRepo() as any);
|
const statusService = new StatusService(logger, monitorsRepo as any, createStubMonitorStatsRepo() as any);
|
||||||
|
|
||||||
const messageBuilder = { extractThresholdBreaches: jest.fn().mockReturnValue([]) };
|
const messageBuilder = { buildThresholdBreachMessage: jest.fn().mockReturnValue("") };
|
||||||
const incidentService = new IncidentService(logger, incidentsRepo, monitorsRepo as any, { findById: jest.fn() } as any, messageBuilder as any);
|
const incidentService = new IncidentService(logger, incidentsRepo, monitorsRepo as any, { findById: jest.fn() } as any, messageBuilder as any);
|
||||||
|
|
||||||
const notificationsService = { handleNotifications: jest.fn().mockResolvedValue(true) };
|
const notificationsService = { handleNotifications: jest.fn().mockResolvedValue(true) };
|
||||||
@@ -103,19 +87,17 @@ export function createHeartbeatTestHarness(): HeartbeatTestHarness {
|
|||||||
let nextResponse: MonitorStatusResponse | null = null;
|
let nextResponse: MonitorStatusResponse | null = null;
|
||||||
let nextStatus = true;
|
let nextStatus = true;
|
||||||
let nextCode = 200;
|
let nextCode = 200;
|
||||||
const networkService = {
|
const providerRegistry = {
|
||||||
requestStatus: jest.fn().mockImplementation(() => {
|
probe: jest.fn().mockImplementation(() => {
|
||||||
if (nextResponse) {
|
if (nextResponse) {
|
||||||
return Promise.resolve(nextResponse);
|
return Promise.resolve(nextResponse);
|
||||||
}
|
}
|
||||||
return Promise.resolve(makeStatusResponse(nextStatus, nextCode));
|
return Promise.resolve(makeStatusResponse(nextStatus, nextCode));
|
||||||
}),
|
}),
|
||||||
};
|
};
|
||||||
const checkService = {
|
// The real mapper, so the stored check carries the hardware/docker payload the evaluator reads.
|
||||||
toCheck: jest.fn().mockImplementation((response: MonitorStatusResponse) => {
|
// Only toCheck is reached; the repositories are never touched.
|
||||||
return makeCheck(response.status, response.code);
|
const checkService = new CheckService(monitorsRepo as any, logger, {} as any);
|
||||||
}),
|
|
||||||
};
|
|
||||||
|
|
||||||
const setNextResponse = (status: boolean, code: number) => {
|
const setNextResponse = (status: boolean, code: number) => {
|
||||||
nextResponse = null;
|
nextResponse = null;
|
||||||
@@ -137,7 +119,13 @@ export function createHeartbeatTestHarness(): HeartbeatTestHarness {
|
|||||||
nextEgressStatus = status;
|
nextEgressStatus = status;
|
||||||
};
|
};
|
||||||
|
|
||||||
const notificationReactor = new NotificationReactor(notificationsService as any);
|
// The real NotificationsService ignores decisions with no transition; the stub is reached only for the ones it
|
||||||
|
// would act on, so tests can assert on handleNotifications calls as "notifications sent".
|
||||||
|
const guardedNotificationsService = {
|
||||||
|
handleNotifications: (monitor: Monitor, check: Check, decision: MonitorActionDecision) =>
|
||||||
|
decision.transition !== null ? notificationsService.handleNotifications(monitor, check, decision) : Promise.resolve(false),
|
||||||
|
};
|
||||||
|
const notificationReactor = new NotificationReactor(guardedNotificationsService as any);
|
||||||
const incidentReactor = new IncidentReactor(incidentService as any);
|
const incidentReactor = new IncidentReactor(incidentService as any);
|
||||||
const reactorDispatcher = new ReactorDispatcher(logger, [notificationReactor, incidentReactor]);
|
const reactorDispatcher = new ReactorDispatcher(logger, [notificationReactor, incidentReactor]);
|
||||||
|
|
||||||
@@ -149,7 +137,7 @@ export function createHeartbeatTestHarness(): HeartbeatTestHarness {
|
|||||||
checksRepository: { findUnevaluatedByMonitorId: jest.fn() } as any,
|
checksRepository: { findUnevaluatedByMonitorId: jest.fn() } as any,
|
||||||
jobsRepository: { upsertEvaluate: jest.fn(), pullEvaluated: jest.fn() } as any,
|
jobsRepository: { upsertEvaluate: jest.fn(), pullEvaluated: jest.fn() } as any,
|
||||||
checkService: checkService as any,
|
checkService: checkService as any,
|
||||||
networkService: networkService as any,
|
providerRegistry: providerRegistry as any,
|
||||||
proxyResolver: proxyResolver as any,
|
proxyResolver: proxyResolver as any,
|
||||||
bufferService: bufferStub as any,
|
bufferService: bufferStub as any,
|
||||||
dockerLogsService: dockerLogsService as any,
|
dockerLogsService: dockerLogsService as any,
|
||||||
@@ -165,7 +153,7 @@ export function createHeartbeatTestHarness(): HeartbeatTestHarness {
|
|||||||
if (!monitor) return;
|
if (!monitor) return;
|
||||||
const result = await pipeline.produce(monitor);
|
const result = await pipeline.produce(monitor);
|
||||||
if (!result) return; // skipped (e.g. maintenance window)
|
if (!result) return; // skipped (e.g. maintenance window)
|
||||||
const evaluation = await pipeline.evaluateCheck(result.status, result.check, monitor);
|
const evaluation = await pipeline.evaluateCheck(result.check, monitor);
|
||||||
await reactorDispatcher.dispatch(evaluation);
|
await reactorDispatcher.dispatch(evaluation);
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -175,7 +163,7 @@ export function createHeartbeatTestHarness(): HeartbeatTestHarness {
|
|||||||
statusService,
|
statusService,
|
||||||
incidentService,
|
incidentService,
|
||||||
notificationsService,
|
notificationsService,
|
||||||
networkService,
|
providerRegistry,
|
||||||
bufferStub,
|
bufferStub,
|
||||||
maintenanceWindowsRepo,
|
maintenanceWindowsRepo,
|
||||||
messageBuilder,
|
messageBuilder,
|
||||||
|
|||||||
@@ -21,7 +21,7 @@ export const makeMessage = (overrides?: Partial<NotificationMessage>): Notificat
|
|||||||
timestamp: new Date("2025-01-01T00:00:00Z"),
|
timestamp: new Date("2025-01-01T00:00:00Z"),
|
||||||
},
|
},
|
||||||
clientHost: "https://app.example.com",
|
clientHost: "https://app.example.com",
|
||||||
metadata: { teamId: "team-1", notificationReason: "status_change" },
|
metadata: { teamId: "team-1" },
|
||||||
...overrides,
|
...overrides,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -14,7 +14,7 @@ const makeMessage = (): NotificationMessage => ({
|
|||||||
timestamp: new Date("2025-01-01T00:00:00Z"),
|
timestamp: new Date("2025-01-01T00:00:00Z"),
|
||||||
},
|
},
|
||||||
clientHost: "https://app.example.com",
|
clientHost: "https://app.example.com",
|
||||||
metadata: { teamId: "team-1", notificationReason: "status_change" },
|
metadata: { teamId: "team-1" },
|
||||||
});
|
});
|
||||||
|
|
||||||
export const testNotificationProviderContract = (
|
export const testNotificationProviderContract = (
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { describe, expect, it } from "@jest/globals";
|
import { describe, expect, it } from "@jest/globals";
|
||||||
import type { IStatusProvider } from "../../src/service/network/IStatusProvider.ts";
|
import type { IStatusProvider } from "../../src/service/networkProviders/IStatusProvider.ts";
|
||||||
import type { Monitor } from "../../src/domain/monitors/monitor.type.ts";
|
import type { Monitor } from "../../src/domain/monitors/monitor.type.ts";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -126,14 +126,14 @@ describe("Evaluate cursor with more than one processing worker", () => {
|
|||||||
checksRepository,
|
checksRepository,
|
||||||
jobsRepository,
|
jobsRepository,
|
||||||
checkService,
|
checkService,
|
||||||
networkService: { requestStatus: jest.fn<any>() } as any,
|
providerRegistry: { probe: jest.fn<any>() } as any,
|
||||||
proxyResolver: { resolve: jest.fn<any>() } as any,
|
proxyResolver: { resolve: jest.fn<any>() } as any,
|
||||||
bufferService,
|
bufferService,
|
||||||
dockerLogsService: { buildDockerLogs: jest.fn<any>() } as any,
|
dockerLogsService: { buildDockerLogs: jest.fn<any>() } as any,
|
||||||
statusService: {
|
statusService: {
|
||||||
updateMonitorStatus: async (_status: MonitorStatusResponse, check: Check, monitor: Monitor) => {
|
updateMonitorStatus: async (check: Check, monitor: Monitor) => {
|
||||||
evaluatedCheckIds.push(check.id);
|
evaluatedCheckIds.push(check.id);
|
||||||
return { monitor, statusChanged: false, prevStatus: monitor.status, code: 200, timestamp: 0 };
|
return { monitor, statusChanged: false, prevStatus: monitor.status };
|
||||||
},
|
},
|
||||||
} as any,
|
} as any,
|
||||||
dispatcher: { dispatch: jest.fn<any>().mockResolvedValue(undefined) } as any,
|
dispatcher: { dispatch: jest.fn<any>().mockResolvedValue(undefined) } as any,
|
||||||
|
|||||||
@@ -44,7 +44,7 @@ describe("Heartbeat job: maintenance windows", () => {
|
|||||||
expect(storedMonitor.status).toBe("maintenance");
|
expect(storedMonitor.status).toBe("maintenance");
|
||||||
|
|
||||||
// No network request should have been made
|
// No network request should have been made
|
||||||
expect(h.networkService.requestStatus).not.toHaveBeenCalled();
|
expect(h.providerRegistry.probe).not.toHaveBeenCalled();
|
||||||
|
|
||||||
// No check should have been buffered
|
// No check should have been buffered
|
||||||
expect(h.bufferStub.addToBuffer).not.toHaveBeenCalled();
|
expect(h.bufferStub.addToBuffer).not.toHaveBeenCalled();
|
||||||
@@ -68,7 +68,7 @@ describe("Heartbeat job: maintenance windows", () => {
|
|||||||
// since status was already "maintenance"
|
// since status was already "maintenance"
|
||||||
const storedMonitor = await h.monitorsRepo.findById("mon-1", "team-1");
|
const storedMonitor = await h.monitorsRepo.findById("mon-1", "team-1");
|
||||||
expect(storedMonitor.status).toBe("maintenance");
|
expect(storedMonitor.status).toBe("maintenance");
|
||||||
expect(h.networkService.requestStatus).not.toHaveBeenCalled();
|
expect(h.providerRegistry.probe).not.toHaveBeenCalled();
|
||||||
});
|
});
|
||||||
|
|
||||||
it("resumes normal checks when maintenance window expires", async () => {
|
it("resumes normal checks when maintenance window expires", async () => {
|
||||||
@@ -88,7 +88,7 @@ describe("Heartbeat job: maintenance windows", () => {
|
|||||||
await h.heartbeatJob(monitor);
|
await h.heartbeatJob(monitor);
|
||||||
|
|
||||||
// Network request should have been made this time
|
// Network request should have been made this time
|
||||||
expect(h.networkService.requestStatus).toHaveBeenCalledTimes(1);
|
expect(h.providerRegistry.probe).toHaveBeenCalledTimes(1);
|
||||||
|
|
||||||
// Check should have been buffered
|
// Check should have been buffered
|
||||||
expect(h.bufferStub.addToBuffer).toHaveBeenCalledTimes(1);
|
expect(h.bufferStub.addToBuffer).toHaveBeenCalledTimes(1);
|
||||||
@@ -105,7 +105,7 @@ describe("Heartbeat job: maintenance windows", () => {
|
|||||||
await h.heartbeatJob(monitor);
|
await h.heartbeatJob(monitor);
|
||||||
|
|
||||||
// Normal check should proceed since window is inactive
|
// Normal check should proceed since window is inactive
|
||||||
expect(h.networkService.requestStatus).toHaveBeenCalledTimes(1);
|
expect(h.providerRegistry.probe).toHaveBeenCalledTimes(1);
|
||||||
expect(h.bufferStub.addToBuffer).toHaveBeenCalledTimes(1);
|
expect(h.bufferStub.addToBuffer).toHaveBeenCalledTimes(1);
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -123,7 +123,7 @@ describe("Heartbeat job: maintenance windows", () => {
|
|||||||
|
|
||||||
expect((await h.monitorsRepo.findById("mon-a", "team-1")).status).toBe("maintenance");
|
expect((await h.monitorsRepo.findById("mon-a", "team-1")).status).toBe("maintenance");
|
||||||
expect((await h.monitorsRepo.findById("mon-b", "team-1")).status).toBe("maintenance");
|
expect((await h.monitorsRepo.findById("mon-b", "team-1")).status).toBe("maintenance");
|
||||||
expect(h.networkService.requestStatus).not.toHaveBeenCalled();
|
expect(h.providerRegistry.probe).not.toHaveBeenCalled();
|
||||||
});
|
});
|
||||||
|
|
||||||
it("skips checks when window is in the past (expired)", async () => {
|
it("skips checks when window is in the past (expired)", async () => {
|
||||||
@@ -141,6 +141,6 @@ describe("Heartbeat job: maintenance windows", () => {
|
|||||||
await h.heartbeatJob(monitor);
|
await h.heartbeatJob(monitor);
|
||||||
|
|
||||||
// Window is in the past — normal check should proceed
|
// Window is in the past — normal check should proceed
|
||||||
expect(h.networkService.requestStatus).toHaveBeenCalledTimes(1);
|
expect(h.providerRegistry.probe).toHaveBeenCalledTimes(1);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
@@ -49,7 +49,7 @@ describe("Heartbeat job: hardware threshold breach", () => {
|
|||||||
h.setNextResponseFull(makeHardwareResponse(0.9));
|
h.setNextResponseFull(makeHardwareResponse(0.9));
|
||||||
|
|
||||||
// Configure message builder for when incident is created
|
// Configure message builder for when incident is created
|
||||||
h.messageBuilder.extractThresholdBreaches.mockReturnValue([{ metric: "cpu", formattedValue: "90%", threshold: 80, unit: "%" }]);
|
h.messageBuilder.buildThresholdBreachMessage.mockReturnValue("CPU: 90% (threshold: 80%)");
|
||||||
|
|
||||||
// 4 heartbeats: counter goes 5→4→3→2→1, status still "up"
|
// 4 heartbeats: counter goes 5→4→3→2→1, status still "up"
|
||||||
for (let i = 0; i < 4; i++) {
|
for (let i = 0; i < 4; i++) {
|
||||||
@@ -191,7 +191,7 @@ describe("Heartbeat job: hardware threshold breach", () => {
|
|||||||
h.monitorsRepo.seed(monitor);
|
h.monitorsRepo.seed(monitor);
|
||||||
|
|
||||||
h.setNextResponseFull(makeHardwareResponse(0.9));
|
h.setNextResponseFull(makeHardwareResponse(0.9));
|
||||||
h.messageBuilder.extractThresholdBreaches.mockReturnValue([{ metric: "cpu", formattedValue: "90%", threshold: 80, unit: "%" }]);
|
h.messageBuilder.buildThresholdBreachMessage.mockReturnValue("CPU: 90% (threshold: 80%)");
|
||||||
|
|
||||||
// 5 heartbeats to trigger breach, then 5 more while still breaching
|
// 5 heartbeats to trigger breach, then 5 more while still breaching
|
||||||
for (let i = 0; i < 10; i++) {
|
for (let i = 0; i < 10; i++) {
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ import type { IUsersRepository } from "../../src/domain/users/user.repository.in
|
|||||||
import type { INotificationMessageBuilder } from "../../src/domain/notifications/notification.message-builder.ts";
|
import type { INotificationMessageBuilder } from "../../src/domain/notifications/notification.message-builder.ts";
|
||||||
import type { Monitor } from "../../src/domain/monitors/monitor.type.ts";
|
import type { Monitor } from "../../src/domain/monitors/monitor.type.ts";
|
||||||
import type { MonitorActionDecision } from "../../src/worker/worker.interface.ts";
|
import type { MonitorActionDecision } from "../../src/worker/worker.interface.ts";
|
||||||
|
import type { Check } from "../../src/domain/checks/check.type.ts";
|
||||||
|
|
||||||
// ── Helpers ──────────────────────────────────────────────────────────────────
|
// ── Helpers ──────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
@@ -22,14 +23,21 @@ const makeMonitor = (overrides?: Partial<Monitor>): Monitor =>
|
|||||||
}) as Monitor;
|
}) as Monitor;
|
||||||
|
|
||||||
const makeDecision = (overrides?: Partial<MonitorActionDecision>): MonitorActionDecision => ({
|
const makeDecision = (overrides?: Partial<MonitorActionDecision>): MonitorActionDecision => ({
|
||||||
shouldCreateIncident: false,
|
transition: null,
|
||||||
shouldResolveIncident: false,
|
|
||||||
shouldSendNotification: false,
|
|
||||||
incidentReason: null,
|
|
||||||
notificationReason: null,
|
|
||||||
...overrides,
|
...overrides,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const makeCheck = (statusCode: number): Check => ({
|
||||||
|
id: "check-1",
|
||||||
|
metadata: { monitorId: "mon-1", teamId: "team-1", type: "http" },
|
||||||
|
status: statusCode < 400,
|
||||||
|
statusCode,
|
||||||
|
responseTime: 100,
|
||||||
|
message: statusCode < 400 ? "OK" : "Service Unavailable",
|
||||||
|
createdAt: "2026-01-01T00:00:00Z",
|
||||||
|
updatedAt: "2026-01-01T00:00:00Z",
|
||||||
|
});
|
||||||
|
|
||||||
// ── Test suite ───────────────────────────────────────────────────────────────
|
// ── Test suite ───────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
describe("Incident lifecycle (integration)", () => {
|
describe("Incident lifecycle (integration)", () => {
|
||||||
@@ -43,7 +51,7 @@ describe("Incident lifecycle (integration)", () => {
|
|||||||
repo = new InMemoryIncidentsRepository();
|
repo = new InMemoryIncidentsRepository();
|
||||||
monitorsRepo = { findById: jest.fn() } as unknown as jest.Mocked<IMonitorsRepository>;
|
monitorsRepo = { findById: jest.fn() } as unknown as jest.Mocked<IMonitorsRepository>;
|
||||||
usersRepo = { findById: jest.fn() } as unknown as jest.Mocked<IUsersRepository>;
|
usersRepo = { findById: jest.fn() } as unknown as jest.Mocked<IUsersRepository>;
|
||||||
messageBuilder = { extractThresholdBreaches: jest.fn() } as unknown as jest.Mocked<INotificationMessageBuilder>;
|
messageBuilder = { buildThresholdBreachMessage: jest.fn() } as unknown as jest.Mocked<INotificationMessageBuilder>;
|
||||||
service = new IncidentService(createMockLogger() as any, repo, monitorsRepo, usersRepo, messageBuilder);
|
service = new IncidentService(createMockLogger() as any, repo, monitorsRepo, usersRepo, messageBuilder);
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -51,9 +59,9 @@ describe("Incident lifecycle (integration)", () => {
|
|||||||
|
|
||||||
it("creates an incident when monitor goes down", async () => {
|
it("creates an incident when monitor goes down", async () => {
|
||||||
const monitor = makeMonitor({ status: "down" });
|
const monitor = makeMonitor({ status: "down" });
|
||||||
const decision = makeDecision({ shouldCreateIncident: true, incidentReason: "status_down" });
|
const decision = makeDecision({ transition: "status_down" });
|
||||||
|
|
||||||
const incident = await service.handleIncident(monitor, 503, decision);
|
const incident = await service.handleIncident(monitor, decision, makeCheck(503));
|
||||||
|
|
||||||
expect(incident).not.toBeNull();
|
expect(incident).not.toBeNull();
|
||||||
expect(incident!.monitorId).toBe("mon-1");
|
expect(incident!.monitorId).toBe("mon-1");
|
||||||
@@ -72,10 +80,10 @@ describe("Incident lifecycle (integration)", () => {
|
|||||||
|
|
||||||
it("does not create a duplicate incident for the same monitor", async () => {
|
it("does not create a duplicate incident for the same monitor", async () => {
|
||||||
const monitor = makeMonitor({ status: "down" });
|
const monitor = makeMonitor({ status: "down" });
|
||||||
const decision = makeDecision({ shouldCreateIncident: true, incidentReason: "status_down" });
|
const decision = makeDecision({ transition: "status_down" });
|
||||||
|
|
||||||
const first = await service.handleIncident(monitor, 503, decision);
|
const first = await service.handleIncident(monitor, decision, makeCheck(503));
|
||||||
const second = await service.handleIncident(monitor, 503, decision);
|
const second = await service.handleIncident(monitor, decision, makeCheck(503));
|
||||||
|
|
||||||
expect(second!.id).toBe(first!.id);
|
expect(second!.id).toBe(first!.id);
|
||||||
expect(repo.getAll()).toHaveLength(1);
|
expect(repo.getAll()).toHaveLength(1);
|
||||||
@@ -85,11 +93,11 @@ describe("Incident lifecycle (integration)", () => {
|
|||||||
|
|
||||||
it("auto-resolves an incident when monitor recovers", async () => {
|
it("auto-resolves an incident when monitor recovers", async () => {
|
||||||
const monitor = makeMonitor({ status: "down" });
|
const monitor = makeMonitor({ status: "down" });
|
||||||
const createDecision = makeDecision({ shouldCreateIncident: true, incidentReason: "status_down" });
|
const createDecision = makeDecision({ transition: "status_down" });
|
||||||
const created = await service.handleIncident(monitor, 503, createDecision);
|
const created = await service.handleIncident(monitor, createDecision, makeCheck(503));
|
||||||
|
|
||||||
const resolveDecision = makeDecision({ shouldResolveIncident: true });
|
const resolveDecision = makeDecision({ transition: "status_up" });
|
||||||
const resolved = await service.handleIncident(monitor, 200, resolveDecision);
|
const resolved = await service.handleIncident(monitor, resolveDecision, makeCheck(200));
|
||||||
|
|
||||||
expect(resolved).not.toBeNull();
|
expect(resolved).not.toBeNull();
|
||||||
expect(resolved!.id).toBe(created!.id);
|
expect(resolved!.id).toBe(created!.id);
|
||||||
@@ -102,9 +110,9 @@ describe("Incident lifecycle (integration)", () => {
|
|||||||
|
|
||||||
it("returns null when resolving with no active incident", async () => {
|
it("returns null when resolving with no active incident", async () => {
|
||||||
const monitor = makeMonitor({ status: "up" });
|
const monitor = makeMonitor({ status: "up" });
|
||||||
const decision = makeDecision({ shouldResolveIncident: true });
|
const decision = makeDecision({ transition: "status_up" });
|
||||||
|
|
||||||
const result = await service.handleIncident(monitor, 200, decision);
|
const result = await service.handleIncident(monitor, decision, makeCheck(200));
|
||||||
|
|
||||||
expect(result).toBeNull();
|
expect(result).toBeNull();
|
||||||
});
|
});
|
||||||
@@ -113,8 +121,8 @@ describe("Incident lifecycle (integration)", () => {
|
|||||||
|
|
||||||
it("manually resolves an active incident with comment", async () => {
|
it("manually resolves an active incident with comment", async () => {
|
||||||
const monitor = makeMonitor({ status: "down" });
|
const monitor = makeMonitor({ status: "down" });
|
||||||
const decision = makeDecision({ shouldCreateIncident: true, incidentReason: "status_down" });
|
const decision = makeDecision({ transition: "status_down" });
|
||||||
const created = await service.handleIncident(monitor, 500, decision);
|
const created = await service.handleIncident(monitor, decision, makeCheck(500));
|
||||||
|
|
||||||
const resolved = await service.resolveIncident(created!.id, "user-1", "team-1", "Root cause identified", "user@test.com");
|
const resolved = await service.resolveIncident(created!.id, "user-1", "team-1", "Root cause identified", "user@test.com");
|
||||||
|
|
||||||
@@ -128,8 +136,8 @@ describe("Incident lifecycle (integration)", () => {
|
|||||||
|
|
||||||
it("throws when manually resolving an already-resolved incident", async () => {
|
it("throws when manually resolving an already-resolved incident", async () => {
|
||||||
const monitor = makeMonitor({ status: "down" });
|
const monitor = makeMonitor({ status: "down" });
|
||||||
const decision = makeDecision({ shouldCreateIncident: true, incidentReason: "status_down" });
|
const decision = makeDecision({ transition: "status_down" });
|
||||||
const created = await service.handleIncident(monitor, 500, decision);
|
const created = await service.handleIncident(monitor, decision, makeCheck(500));
|
||||||
|
|
||||||
await service.resolveIncident(created!.id, "user-1", "team-1");
|
await service.resolveIncident(created!.id, "user-1", "team-1");
|
||||||
|
|
||||||
@@ -140,13 +148,10 @@ describe("Incident lifecycle (integration)", () => {
|
|||||||
|
|
||||||
it("creates a threshold breach incident with statusCode 9999 and descriptive message", async () => {
|
it("creates a threshold breach incident with statusCode 9999 and descriptive message", async () => {
|
||||||
const monitor = makeMonitor({ status: "breached", type: "hardware" });
|
const monitor = makeMonitor({ status: "breached", type: "hardware" });
|
||||||
(messageBuilder.extractThresholdBreaches as jest.Mock).mockReturnValue([
|
(messageBuilder.buildThresholdBreachMessage as jest.Mock).mockReturnValue("CPU: 92% (threshold: 80%), MEMORY: 88% (threshold: 85%)");
|
||||||
{ metric: "cpu", formattedValue: "92%", threshold: 80, unit: "%" },
|
const decision = makeDecision({ transition: "threshold_breach" });
|
||||||
{ metric: "memory", formattedValue: "88%", threshold: 85, unit: "%" },
|
|
||||||
]);
|
|
||||||
const decision = makeDecision({ shouldCreateIncident: true, incidentReason: "threshold_breach" });
|
|
||||||
|
|
||||||
const incident = await service.handleIncident(monitor, 200, decision, { monitorId: "mon-1" } as any);
|
const incident = await service.handleIncident(monitor, decision, makeCheck(200));
|
||||||
|
|
||||||
expect(incident!.statusCode).toBe(9999);
|
expect(incident!.statusCode).toBe(9999);
|
||||||
expect(incident!.message).toBe("CPU: 92% (threshold: 80%), MEMORY: 88% (threshold: 85%)");
|
expect(incident!.message).toBe("CPU: 92% (threshold: 80%), MEMORY: 88% (threshold: 85%)");
|
||||||
@@ -158,7 +163,7 @@ describe("Incident lifecycle (integration)", () => {
|
|||||||
const monitor = makeMonitor({ status: "up" });
|
const monitor = makeMonitor({ status: "up" });
|
||||||
const decision = makeDecision();
|
const decision = makeDecision();
|
||||||
|
|
||||||
const result = await service.handleIncident(monitor, 200, decision);
|
const result = await service.handleIncident(monitor, decision, makeCheck(200));
|
||||||
|
|
||||||
expect(result).toBeNull();
|
expect(result).toBeNull();
|
||||||
expect(repo.getAll()).toHaveLength(0);
|
expect(repo.getAll()).toHaveLength(0);
|
||||||
@@ -170,19 +175,19 @@ describe("Incident lifecycle (integration)", () => {
|
|||||||
const monitor = makeMonitor({ status: "down" });
|
const monitor = makeMonitor({ status: "down" });
|
||||||
|
|
||||||
// First outage
|
// First outage
|
||||||
const first = await service.handleIncident(monitor, 503, makeDecision({ shouldCreateIncident: true, incidentReason: "status_down" }));
|
const first = await service.handleIncident(monitor, makeDecision({ transition: "status_down" }), makeCheck(503));
|
||||||
expect(first!.status).toBe(true);
|
expect(first!.status).toBe(true);
|
||||||
|
|
||||||
// Manually resolved
|
// Manually resolved
|
||||||
await service.resolveIncident(first!.id, "user-1", "team-1", "Restarted server");
|
await service.resolveIncident(first!.id, "user-1", "team-1", "Restarted server");
|
||||||
|
|
||||||
// Second outage — new incident since previous was resolved
|
// Second outage — new incident since previous was resolved
|
||||||
const second = await service.handleIncident(monitor, 502, makeDecision({ shouldCreateIncident: true, incidentReason: "status_down" }));
|
const second = await service.handleIncident(monitor, makeDecision({ transition: "status_down" }), makeCheck(502));
|
||||||
expect(second!.id).not.toBe(first!.id);
|
expect(second!.id).not.toBe(first!.id);
|
||||||
expect(second!.statusCode).toBe(502);
|
expect(second!.statusCode).toBe(502);
|
||||||
|
|
||||||
// Auto-resolve
|
// Auto-resolve
|
||||||
const resolved = await service.handleIncident(monitor, 200, makeDecision({ shouldResolveIncident: true }));
|
const resolved = await service.handleIncident(monitor, makeDecision({ transition: "status_up" }), makeCheck(200));
|
||||||
expect(resolved!.id).toBe(second!.id);
|
expect(resolved!.id).toBe(second!.id);
|
||||||
expect(resolved!.status).toBe(false);
|
expect(resolved!.status).toBe(false);
|
||||||
expect(resolved!.resolutionType).toBe("automatic");
|
expect(resolved!.resolutionType).toBe("automatic");
|
||||||
@@ -198,16 +203,16 @@ describe("Incident lifecycle (integration)", () => {
|
|||||||
it("incidents for different monitors do not interfere", async () => {
|
it("incidents for different monitors do not interfere", async () => {
|
||||||
const monitorA = makeMonitor({ id: "mon-a", status: "down" });
|
const monitorA = makeMonitor({ id: "mon-a", status: "down" });
|
||||||
const monitorB = makeMonitor({ id: "mon-b", status: "down" });
|
const monitorB = makeMonitor({ id: "mon-b", status: "down" });
|
||||||
const decision = makeDecision({ shouldCreateIncident: true, incidentReason: "status_down" });
|
const decision = makeDecision({ transition: "status_down" });
|
||||||
|
|
||||||
const incidentA = await service.handleIncident(monitorA, 500, decision);
|
const incidentA = await service.handleIncident(monitorA, decision, makeCheck(500));
|
||||||
const incidentB = await service.handleIncident(monitorB, 502, decision);
|
const incidentB = await service.handleIncident(monitorB, decision, makeCheck(502));
|
||||||
|
|
||||||
expect(incidentA!.id).not.toBe(incidentB!.id);
|
expect(incidentA!.id).not.toBe(incidentB!.id);
|
||||||
expect(repo.getAll()).toHaveLength(2);
|
expect(repo.getAll()).toHaveLength(2);
|
||||||
|
|
||||||
// Resolving monitor A does not affect monitor B
|
// Resolving monitor A does not affect monitor B
|
||||||
const resolved = await service.handleIncident(monitorA, 200, makeDecision({ shouldResolveIncident: true }));
|
const resolved = await service.handleIncident(monitorA, makeDecision({ transition: "status_up" }), makeCheck(200));
|
||||||
expect(resolved!.id).toBe(incidentA!.id);
|
expect(resolved!.id).toBe(incidentA!.id);
|
||||||
|
|
||||||
const bStillActive = await repo.findActiveByMonitorId("mon-b", "team-1");
|
const bStillActive = await repo.findActiveByMonitorId("mon-b", "team-1");
|
||||||
|
|||||||
@@ -69,6 +69,34 @@ describe("MongoMonitorsRepository", () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe("sorting", () => {
|
||||||
|
it("uses creation time as the fallback for equal primary values", async () => {
|
||||||
|
const repo = new MongoMonitorsRepository();
|
||||||
|
const teamId = new mongoose.Types.ObjectId();
|
||||||
|
const createMonitor = (name: string, type: string, status: string, createdAt: Date) =>
|
||||||
|
MonitorModel.create({
|
||||||
|
userId: new mongoose.Types.ObjectId(),
|
||||||
|
teamId,
|
||||||
|
name,
|
||||||
|
type,
|
||||||
|
status,
|
||||||
|
url: "https://example.com",
|
||||||
|
createdAt,
|
||||||
|
});
|
||||||
|
|
||||||
|
await createMonitor("newer-up", "hardware", "up", new Date("2026-03-01T00:00:00.000Z"));
|
||||||
|
await createMonitor("down", "http", "down", new Date("2026-04-01T00:00:00.000Z"));
|
||||||
|
await createMonitor("older-up", "pagespeed", "up", new Date("2026-01-01T00:00:00.000Z"));
|
||||||
|
await createMonitor("middle-up", "docker", "up", new Date("2026-02-01T00:00:00.000Z"));
|
||||||
|
|
||||||
|
const ascending = await repo.findByTeamIdWithStats(teamId.toString(), { field: "status", order: "asc" });
|
||||||
|
const descending = await repo.findByTeamIdWithStats(teamId.toString(), { field: "status", order: "desc" });
|
||||||
|
|
||||||
|
expect(ascending.map((monitor) => monitor.name)).toEqual(["down", "older-up", "middle-up", "newer-up"]);
|
||||||
|
expect(descending.map((monitor) => monitor.name)).toEqual(["older-up", "middle-up", "newer-up", "down"]);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
describe("findByIds recentChecks modes", () => {
|
describe("findByIds recentChecks modes", () => {
|
||||||
const seedFixtures = async () => {
|
const seedFixtures = async () => {
|
||||||
const teamId = new mongoose.Types.ObjectId();
|
const teamId = new mongoose.Types.ObjectId();
|
||||||
|
|||||||
@@ -0,0 +1,107 @@
|
|||||||
|
import { describe, expect, it, jest } from "@jest/globals";
|
||||||
|
import type { NextFunction, Request, Response } from "express";
|
||||||
|
import { sanitizeBody, sanitizeInput, sanitizeObject, sanitizeQuery } from "../../../src/api/middleware/sanitization.ts";
|
||||||
|
|
||||||
|
// Express 5 exposes req.query as a getter on the request prototype that re-parses the URL on
|
||||||
|
// every read, so writes into the returned object are lost. The middleware must shadow the
|
||||||
|
// getter with an own property, which is what this fixture checks.
|
||||||
|
const makeExpress5Request = (query: Record<string, unknown>) => {
|
||||||
|
const proto = {};
|
||||||
|
Object.defineProperty(proto, "query", {
|
||||||
|
get: () => ({ ...query }),
|
||||||
|
configurable: true,
|
||||||
|
enumerable: true,
|
||||||
|
});
|
||||||
|
return Object.create(proto) as Request;
|
||||||
|
};
|
||||||
|
|
||||||
|
describe("sanitizeInput", () => {
|
||||||
|
it("strips html tags and keeps the text content", () => {
|
||||||
|
expect(sanitizeInput('<script>alert("x")</script>hello <b>bold</b>')).toBe("hello bold");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns non-string values unchanged", () => {
|
||||||
|
expect(sanitizeInput(42)).toBe(42);
|
||||||
|
expect(sanitizeInput(null)).toBeNull();
|
||||||
|
expect(sanitizeInput(undefined)).toBeUndefined();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("sanitizeObject", () => {
|
||||||
|
it("sanitizes nested strings and arrays, leaving other values untouched", () => {
|
||||||
|
const result = sanitizeObject({
|
||||||
|
name: "<img src=x onerror=alert(1)>Alice",
|
||||||
|
count: 3,
|
||||||
|
active: true,
|
||||||
|
items: [{ note: "<i>one</i>" }, { note: "two" }],
|
||||||
|
nested: { note: "<p>hi</p>" },
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(result).toEqual({
|
||||||
|
name: "Alice",
|
||||||
|
count: 3,
|
||||||
|
active: true,
|
||||||
|
items: [{ note: "one" }, { note: "two" }],
|
||||||
|
nested: { note: "hi" },
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns primitives and null unchanged", () => {
|
||||||
|
expect(sanitizeObject("<b>x</b>")).toBe("<b>x</b>");
|
||||||
|
expect(sanitizeObject(null)).toBeNull();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("sanitizeBody middleware", () => {
|
||||||
|
it("replaces req.body with a sanitized copy and calls next", () => {
|
||||||
|
const req = { body: { name: "<b>Alice</b>", nested: { note: "<i>x</i>" } } } as unknown as Request;
|
||||||
|
const next = jest.fn() as unknown as NextFunction;
|
||||||
|
|
||||||
|
sanitizeBody()(req, {} as Response, next);
|
||||||
|
|
||||||
|
expect(req.body).toEqual({ name: "Alice", nested: { note: "x" } });
|
||||||
|
expect(next).toHaveBeenCalledWith();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("leaves req.body alone when there is no body", () => {
|
||||||
|
const req = {} as unknown as Request;
|
||||||
|
const next = jest.fn() as unknown as NextFunction;
|
||||||
|
|
||||||
|
sanitizeBody()(req, {} as Response, next);
|
||||||
|
|
||||||
|
expect(req.body).toBeUndefined();
|
||||||
|
expect(next).toHaveBeenCalledWith();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("sanitizeQuery middleware", () => {
|
||||||
|
it("sanitizes string values so that later reads of req.query see them", () => {
|
||||||
|
const req = makeExpress5Request({ filter: "<script>x</script>term", page: "2" });
|
||||||
|
const next = jest.fn() as unknown as NextFunction;
|
||||||
|
|
||||||
|
sanitizeQuery()(req, {} as Response, next);
|
||||||
|
|
||||||
|
expect(req.query).toEqual({ filter: "term", page: "2" });
|
||||||
|
expect(req.query).toEqual({ filter: "term", page: "2" }); // stable across reads
|
||||||
|
expect(next).toHaveBeenCalledWith();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("does not sanitize inside array values", () => {
|
||||||
|
const req = makeExpress5Request({ type: ["<b>http</b>", "ping"] });
|
||||||
|
const next = jest.fn() as unknown as NextFunction;
|
||||||
|
|
||||||
|
sanitizeQuery()(req, {} as Response, next);
|
||||||
|
|
||||||
|
expect(req.query).toEqual({ type: ["<b>http</b>", "ping"] });
|
||||||
|
});
|
||||||
|
|
||||||
|
it("handles an empty query", () => {
|
||||||
|
const req = makeExpress5Request({});
|
||||||
|
const next = jest.fn() as unknown as NextFunction;
|
||||||
|
|
||||||
|
sanitizeQuery()(req, {} as Response, next);
|
||||||
|
|
||||||
|
expect(req.query).toEqual({});
|
||||||
|
expect(next).toHaveBeenCalledWith();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -1,5 +1,5 @@
|
|||||||
import { describe, expect, it, jest } from "@jest/globals";
|
import { describe, expect, it, jest } from "@jest/globals";
|
||||||
import { AdvancedMatcher } from "../../../../src/service/network/AdvancedMatcher.ts";
|
import { AdvancedMatcher } from "../../../../src/service/networkProviders/AdvancedMatcher.ts";
|
||||||
import type { Monitor } from "../../../../src/domain/monitors/monitor.type.ts";
|
import type { Monitor } from "../../../../src/domain/monitors/monitor.type.ts";
|
||||||
|
|
||||||
// ── Helpers ──────────────────────────────────────────────────────────────────
|
// ── Helpers ──────────────────────────────────────────────────────────────────
|
||||||
|
|||||||
Loaded 100 of 136 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user