Merge pull request #3127 from bluewave-labs/feat/monitor-service-ts

Feat/monitor service ts
This commit is contained in:
Alexander Holliday
2026-01-14 14:49:00 -08:00
committed by GitHub
50 changed files with 5165 additions and 1209 deletions
@@ -85,7 +85,12 @@ const Search = ({
const enhancedOptions = React.useMemo(() => {
return multiple && isAdorned
? [
{ [filteredBy]: t("selectAll"), isSelectAll: true, _id: "select_all" },
{
[filteredBy]: t("selectAll"),
isSelectAll: true,
_id: "select_all",
id: "select_all",
},
...options,
]
: options;
@@ -93,7 +98,7 @@ const Search = ({
const isOptionSelected = (option) => {
if (!multiple && !isAdorned) return false;
if (Array.isArray(value)) {
return value.some((item) => item._id === option._id);
return value.some((item) => (item._id ?? item.id) === (option._id ?? option.id));
}
return false;
};
@@ -145,7 +150,9 @@ const Search = ({
disableClearable
options={enhancedOptions}
getOptionLabel={(option) => option[filteredBy]}
isOptionEqualToValue={(option, value) => option._id === value._id} // Compare by unique identifier
isOptionEqualToValue={(option, value) =>
(option._id ?? option.id) === (value?._id ?? value?.id)
} // Compare by unique identifier
renderInput={(params) => (
<FieldWrapper
label={label}
@@ -206,7 +213,7 @@ const Search = ({
return filtered;
}}
getOptionKey={(option) => {
return option._id;
return option._id ?? option.id;
}}
renderOption={(props, option) => {
const { key, ...optionProps } = props;
@@ -62,6 +62,7 @@ const Select = ({
fieldWrapperSx = {},
}) => {
const theme = useTheme();
const getItemValue = (item) => item?._id ?? item?.id;
const itemStyles = {
fontSize: "var(--env-var-font-size-medium)",
color: theme.palette.primary.contrastTextTertiary,
@@ -123,7 +124,7 @@ const Select = ({
...sx,
}}
renderValue={(selected) => {
const selectedItem = items.find((item) => item._id === selected);
const selectedItem = items.find((item) => getItemValue(item) === selected);
const displayName = selectedItem ? selectedItem.name : placeholder;
return (
<Typography
@@ -152,17 +153,25 @@ const Select = ({
{placeholder}
</MenuItem>
)}
{items.map((item) => (
<MenuItem
value={item._id}
key={`${id}-${item._id}`}
sx={{
...itemStyles,
}}
>
{item.name}
</MenuItem>
))}
{items
.map((item) => {
const itemValue = getItemValue(item);
if (itemValue === undefined || itemValue === null) {
return null;
}
return (
<MenuItem
value={itemValue}
key={`${id}-${itemValue}`}
sx={{
...itemStyles,
}}
>
{item.name}
</MenuItem>
);
})
.filter(Boolean)}
</MuiSelect>
</FieldWrapper>
);
@@ -179,8 +188,8 @@ Select.propTypes = {
.isRequired,
items: PropTypes.arrayOf(
PropTypes.shape({
_id: PropTypes.oneOfType([PropTypes.string, PropTypes.number, PropTypes.bool])
.isRequired,
_id: PropTypes.oneOfType([PropTypes.string, PropTypes.number, PropTypes.bool]),
id: PropTypes.oneOfType([PropTypes.string, PropTypes.number, PropTypes.bool]),
name: PropTypes.string.isRequired,
})
+49 -79
View File
@@ -6,7 +6,7 @@ import { useMonitorUtils } from "./useMonitorUtils.js";
import { useNavigate } from "react-router-dom";
import { useTranslation } from "react-i18next";
const useFetchMonitorsWithSummary = ({ types, monitorUpdateTrigger }) => {
export const useFetchMonitorsWithSummary = ({ types, monitorUpdateTrigger }) => {
const [isLoading, setIsLoading] = useState(false);
const [monitors, setMonitors] = useState(undefined);
const [monitorsSummary, setMonitorsSummary] = useState(undefined);
@@ -37,7 +37,7 @@ const useFetchMonitorsWithSummary = ({ types, monitorUpdateTrigger }) => {
return [monitors, monitorsSummary, isLoading, networkError];
};
const useFetchMonitorsWithChecks = ({
export const useFetchMonitorsWithChecks = ({
types,
limit,
page,
@@ -100,22 +100,9 @@ const useFetchMonitorsWithChecks = ({
return [monitors, count, isLoading, networkError];
};
const useFetchMonitorsByTeamId = ({
types,
limit,
page,
rowsPerPage,
filter,
field,
order,
checkOrder,
normalize,
status,
updateTrigger,
}) => {
export const useFetchMonitorsByTeamId = ({ types, filter, updateTrigger }) => {
const [isLoading, setIsLoading] = useState(false);
const [monitors, setMonitors] = useState(undefined);
const [summary, setSummary] = useState(undefined);
const [networkError, setNetworkError] = useState(false);
useEffect(() => {
@@ -123,20 +110,11 @@ const useFetchMonitorsByTeamId = ({
try {
setIsLoading(true);
const res = await networkService.getMonitorsByTeamId({
limit,
types,
page,
rowsPerPage,
filter,
field,
order,
checkOrder,
status,
normalize,
});
if (res?.data?.data?.filteredMonitors) {
setMonitors(res.data.data.filteredMonitors);
setSummary(res.data.data.summary);
if (res?.data?.data) {
setMonitors(res.data.data);
}
} catch (error) {
setNetworkError(true);
@@ -148,23 +126,11 @@ const useFetchMonitorsByTeamId = ({
}
};
fetchMonitors();
}, [
types,
limit,
page,
rowsPerPage,
filter,
field,
order,
updateTrigger,
checkOrder,
normalize,
status,
]);
return [monitors, summary, isLoading, networkError];
}, [types, filter, updateTrigger]);
return [monitors, isLoading, networkError];
};
const useFetchStatsByMonitorId = ({
export const useFetchStatsByMonitorId = ({
monitorId,
sortOrder,
limit,
@@ -203,7 +169,7 @@ const useFetchStatsByMonitorId = ({
return [monitor, audits, isLoading, networkError];
};
const useFetchMonitorGames = ({ setGames, updateTrigger }) => {
export const useFetchMonitorGames = ({ setGames, updateTrigger }) => {
const [isLoading, setIsLoading] = useState(true);
useEffect(() => {
const fetchGames = async () => {
@@ -222,7 +188,7 @@ const useFetchMonitorGames = ({ setGames, updateTrigger }) => {
return [isLoading];
};
const useFetchMonitorById = ({ monitorId, setMonitor, updateTrigger }) => {
export const useFetchMonitorById = ({ monitorId, setMonitor, updateTrigger }) => {
const [isLoading, setIsLoading] = useState(true);
useEffect(() => {
if (typeof monitorId === "undefined") {
@@ -245,7 +211,7 @@ const useFetchMonitorById = ({ monitorId, setMonitor, updateTrigger }) => {
return [isLoading];
};
const useFetchHardwareMonitorById = ({ monitorId, dateRange, updateTrigger }) => {
export const useFetchHardwareMonitorById = ({ monitorId, dateRange, updateTrigger }) => {
const [isLoading, setIsLoading] = useState(true);
const [networkError, setNetworkError] = useState(false);
const [monitor, setMonitor] = useState(undefined);
@@ -271,8 +237,34 @@ const useFetchHardwareMonitorById = ({ monitorId, dateRange, updateTrigger }) =>
}, [monitorId, dateRange, updateTrigger]);
return [monitor, isLoading, networkError];
};
export const useFetchPageSpeedMonitorById = ({ monitorId, dateRange, updateTrigger }) => {
const [isLoading, setIsLoading] = useState(true);
const [networkError, setNetworkError] = useState(false);
const [monitor, setMonitor] = useState(undefined);
const useFetchUptimeMonitorById = ({ monitorId, dateRange, trigger }) => {
useEffect(() => {
const fetchMonitor = async () => {
try {
if (!monitorId) {
return { monitor: undefined, isLoading: false, networkError: undefined };
}
const response = await networkService.getPageSpeedDetailsByMonitorId({
monitorId: monitorId,
dateRange: dateRange,
});
setMonitor(response.data.data);
} catch (error) {
setNetworkError(true);
} finally {
setIsLoading(false);
}
};
fetchMonitor();
}, [monitorId, dateRange, updateTrigger]);
return [monitor, isLoading, networkError];
};
export const useFetchUptimeMonitorById = ({ monitorId, dateRange, trigger }) => {
const [networkError, setNetworkError] = useState(false);
const [isLoading, setIsLoading] = useState(true);
const [monitor, setMonitor] = useState(undefined);
@@ -300,7 +292,7 @@ const useFetchUptimeMonitorById = ({ monitorId, dateRange, trigger }) => {
return [monitor, monitorStats, isLoading, networkError];
};
const useCreateMonitor = () => {
export const useCreateMonitor = () => {
const [isLoading, setIsLoading] = useState(false);
const navigate = useNavigate();
const createMonitor = async ({ monitor, redirect }) => {
@@ -320,7 +312,7 @@ const useCreateMonitor = () => {
return [createMonitor, isLoading];
};
const useFetchGlobalSettings = () => {
export const useFetchGlobalSettings = () => {
const [isLoading, setIsLoading] = useState(true);
const [globalSettings, setGlobalSettings] = useState(undefined);
useEffect(() => {
@@ -342,7 +334,7 @@ const useFetchGlobalSettings = () => {
return [globalSettings, isLoading];
};
const useDeleteMonitor = () => {
export const useDeleteMonitor = () => {
const [isLoading, setIsLoading] = useState(false);
const navigate = useNavigate();
const deleteMonitor = async ({ monitor, redirect }) => {
@@ -363,7 +355,7 @@ const useDeleteMonitor = () => {
return [deleteMonitor, isLoading];
};
const useUpdateMonitor = () => {
export const useUpdateMonitor = () => {
const [isLoading, setIsLoading] = useState(false);
const navigate = useNavigate();
const updateMonitor = async ({ monitor, redirect }) => {
@@ -410,7 +402,7 @@ const useUpdateMonitor = () => {
return [updateMonitor, isLoading];
};
const usePauseMonitor = () => {
export const usePauseMonitor = () => {
const [isLoading, setIsLoading] = useState(false);
const [error, setError] = useState(undefined);
const pauseMonitor = async ({ monitorId, triggerUpdate }) => {
@@ -433,7 +425,7 @@ const usePauseMonitor = () => {
return [pauseMonitor, isLoading, error];
};
const useAddDemoMonitors = () => {
export const useAddDemoMonitors = () => {
const [isLoading, setIsLoading] = useState(false);
const { t } = useTranslation();
const addDemoMonitors = async () => {
@@ -450,7 +442,7 @@ const useAddDemoMonitors = () => {
return [addDemoMonitors, isLoading];
};
const useDeleteAllMonitors = () => {
export const useDeleteAllMonitors = () => {
const [isLoading, setIsLoading] = useState(false);
const { t } = useTranslation();
const deleteAllMonitors = async () => {
@@ -467,7 +459,7 @@ const useDeleteAllMonitors = () => {
return [deleteAllMonitors, isLoading];
};
const useDeleteMonitorStats = () => {
export const useDeleteMonitorStats = () => {
const { t } = useTranslation();
const [isLoading, setIsLoading] = useState(false);
const deleteMonitorStats = async () => {
@@ -485,7 +477,7 @@ const useDeleteMonitorStats = () => {
return [deleteMonitorStats, isLoading];
};
const useCreateBulkMonitors = () => {
export const useCreateBulkMonitors = () => {
const [isLoading, setIsLoading] = useState(false);
const createBulkMonitors = async (file, user) => {
@@ -508,7 +500,7 @@ const useCreateBulkMonitors = () => {
return [createBulkMonitors, isLoading];
};
const useExportMonitors = () => {
export const useExportMonitors = () => {
const [isLoading, setIsLoading] = useState(false);
const { t } = useTranslation();
@@ -541,7 +533,7 @@ const useExportMonitors = () => {
return [exportMonitors, isLoading];
};
const useFetchJson = () => {
export const useFetchJson = () => {
const [isLoading, setIsLoading] = useState(false);
const fetchJson = async () => {
try {
@@ -557,25 +549,3 @@ const useFetchJson = () => {
};
return [fetchJson, isLoading];
};
export {
useFetchMonitorsWithSummary,
useFetchMonitorsWithChecks,
useFetchMonitorsByTeamId,
useFetchStatsByMonitorId,
useFetchMonitorById,
useFetchUptimeMonitorById,
useFetchHardwareMonitorById,
useCreateMonitor,
useFetchGlobalSettings,
useDeleteMonitor,
useUpdateMonitor,
usePauseMonitor,
useAddDemoMonitors,
useDeleteAllMonitors,
useDeleteMonitorStats,
useCreateBulkMonitors,
useExportMonitors,
useFetchMonitorGames,
useFetchJson,
};
+1 -1
View File
@@ -34,7 +34,7 @@ const Incidents2 = () => {
setUpdateTrigger((prev) => !prev);
};
const [monitors, , isLoadingMonitors, monitorsNetworkError] = useFetchMonitorsByTeamId(
const [monitors, isLoadingMonitors, monitorsNetworkError] = useFetchMonitorsByTeamId(
{}
);
@@ -13,10 +13,7 @@ import { useTheme } from "@emotion/react";
import { useEffect, useState } from "react";
import { useIsAdmin } from "@/Hooks/useIsAdmin.js";
import { useTranslation } from "react-i18next";
import {
useFetchMonitorsByTeamId,
useFetchMonitorsWithChecks,
} from "@/Hooks/monitorHooks.js";
import { useFetchMonitorsWithChecks } from "@/Hooks/monitorHooks.js";
import { useDispatch, useSelector } from "react-redux";
import { setRowsPerPage } from "../../../Features/UI/uiSlice.js";
@@ -24,7 +24,7 @@ const MonitorListItem = ({ monitor, onDelete }) => {
MonitorListItem.propTypes = {
monitor: PropTypes.shape({
_id: PropTypes.string.isRequired,
id: PropTypes.string.isRequired,
name: PropTypes.string.isRequired,
}).isRequired,
onDelete: PropTypes.func.isRequired,
@@ -33,7 +33,7 @@ MonitorListItem.propTypes = {
const MonitorList = ({ selectedMonitors, setSelectedMonitors }) => {
const onDelete = (monitorToDelete) => {
const newMonitors = selectedMonitors.filter(
(monitor) => monitor._id !== monitorToDelete._id
(monitor) => monitor.id !== monitorToDelete.id
);
setSelectedMonitors(newMonitors);
};
@@ -47,7 +47,7 @@ const MonitorList = ({ selectedMonitors, setSelectedMonitors }) => {
>
{selectedMonitors?.map((monitor) => (
<MonitorListItem
key={monitor._id}
key={monitor.id}
monitor={monitor}
onDelete={onDelete}
/>
@@ -59,7 +59,7 @@ const MonitorList = ({ selectedMonitors, setSelectedMonitors }) => {
MonitorList.propTypes = {
selectedMonitors: PropTypes.arrayOf(
PropTypes.shape({
_id: PropTypes.string.isRequired,
id: PropTypes.string.isRequired,
name: PropTypes.string.isRequired,
})
).isRequired,
@@ -22,6 +22,12 @@ const useMaintenanceActions = () => {
weekly: MS_PER_DAY * 7,
};
const handleSubmitForm = async (maintenanceWindowId, form) => {
const toStringId = (monitor) => {
if (!monitor) {
return "";
}
return monitor._id ?? monitor.id ?? "";
};
const start = dayjs(form.startDate)
.set("hour", form.startTime.hour())
.set("minute", form.startTime.minute());
@@ -33,7 +39,7 @@ const useMaintenanceActions = () => {
const repeat = REPEAT_LOOKUP[form.repeat];
const submit = {
monitors: form.monitors.map((monitor) => monitor._id),
monitors: form.monitors.map((monitor) => toStringId(monitor)).filter(Boolean),
name: form.name,
start: start.toISOString(),
end: end.toISOString(),
@@ -41,7 +41,7 @@ const useMaintenanceData = () => {
limit: null,
types: ["http", "ping", "pagespeed", "port"],
});
const fetchedMonitors = response.data.data.monitors;
const fetchedMonitors = response.data.data;
return fetchedMonitors;
};
@@ -55,7 +55,9 @@ const useMaintenanceData = () => {
const endTime = dayjs(end);
const durationInMs = endTime.diff(startTime, "milliseconds").toString();
const { duration, durationUnit } = getDurationAndUnit(durationInMs);
const monitor = monitorList.find((monitor) => monitor._id === monitorId);
const monitor = monitorList.find(
(monitor) => (monitor._id ?? monitor.id) === monitorId
);
const maintenanceWindowInformation = {
name,
repeat: REVERSE_REPEAT_LOOKUP[repeat],
@@ -108,6 +108,7 @@ const CreateMaintenance = () => {
const handleSubmit = async () => {
if (hasValidationErrors(form, maintenanceWindowValidation, setErrors)) return;
const request = handleSubmitForm(maintenanceWindowId, form);
try {
setIsLoading(true);
+3 -8
View File
@@ -11,14 +11,13 @@ import GenericFallback from "@/Components/v1/GenericFallback/index.jsx";
import { useTheme } from "@emotion/react";
import { useIsAdmin } from "@/Hooks/useIsAdmin.js";
import { useParams } from "react-router-dom";
import { useFetchStatsByMonitorId } from "../../../Hooks/monitorHooks.js";
import { useFetchPageSpeedMonitorById } from "../../../Hooks/monitorHooks.js";
import { useState } from "react";
import { useTranslation } from "react-i18next";
// Constants
const BREADCRUMBS = [
{ name: "pagespeed", path: "/pagespeed" },
{ name: "details", path: `` },
// { name: "details", path: `/pagespeed/${monitorId}` }, // Not needed?
];
const PageSpeedDetails = () => {
@@ -36,13 +35,9 @@ const PageSpeedDetails = () => {
});
const [trigger, setTrigger] = useState(false);
// Network
const [monitor, audits, isLoading, networkError] = useFetchStatsByMonitorId({
const [monitor, isLoading, networkError] = useFetchPageSpeedMonitorById({
monitorId,
sortOrder: "desc",
limit: 50,
dateRange: "day",
numToDisplay: null,
normalize: null,
updateTrigger: trigger,
});
@@ -116,7 +111,7 @@ const PageSpeedDetails = () => {
/>
<PerformanceReport
shouldRender={!isLoading}
audits={audits}
audits={monitor?.checks[0]?.audits}
/>
</Stack>
);
+1 -14
View File
@@ -11,10 +11,7 @@ import FallbackPageSpeedWarning from "@/Components/v1/Fallback/FallbackPageSpeed
// Utils
import { useTheme } from "@emotion/react";
import { useIsAdmin } from "@/Hooks/useIsAdmin.js";
import {
useFetchMonitorsByTeamId,
useFetchMonitorsWithChecks,
} from "@/Hooks/monitorHooks.js";
import { useFetchMonitorsWithChecks } from "@/Hooks/monitorHooks.js";
import { useFetchSettings } from "@/Hooks/settingsHooks.js";
// Constants
const BREADCRUMBS = [{ name: `pagespeed`, path: "/pagespeed" }];
@@ -23,16 +20,6 @@ const PageSpeed = () => {
const theme = useTheme();
const isAdmin = useIsAdmin();
// const [monitors, monitorsSummary, isLoading, networkError] = useFetchMonitorsByTeamId({
// limit: 10,
// types: TYPES,
// page: null,
// rowsPerPage: null,
// filter: null,
// field: null,
// order: null,
// });
const [
monitorsWithChecks,
monitorsWithChecksCount,
@@ -43,7 +43,7 @@ const MonitorListItem = ({
const MonitorList = ({ selectedMonitors, setSelectedMonitors }) => {
const onDelete = (monitorToDelete) => {
const newMonitors = selectedMonitors.filter(
(monitor) => monitor._id !== monitorToDelete._id
(monitor) => monitor.id !== monitorToDelete.id
);
setSelectedMonitors(newMonitors);
};
@@ -80,8 +80,8 @@ const MonitorList = ({ selectedMonitors, setSelectedMonitors }) => {
>
{selectedMonitors?.map((monitor, index) => (
<Draggable
key={monitor._id}
draggableId={monitor._id}
key={monitor.id}
draggableId={monitor.id}
index={index}
>
{(provided, snapshot) => (
@@ -24,7 +24,7 @@ const Content = ({
// Handlers
const handleMonitorsChange = (selectedMonitors) => {
handleFormChange({
target: { name: "monitors", value: selectedMonitors.map((monitor) => monitor._id) },
target: { name: "monitors", value: selectedMonitors.map((monitor) => monitor.id) },
});
setSelectedMonitors(selectedMonitors);
};
@@ -16,7 +16,7 @@ const useMonitorsFetch = () => {
limit: null, // donot return any checks for the monitors
types: ["http", "ping", "port", "game"], // include game servers in status page monitor selection
});
setMonitors(response.data.data.monitors);
setMonitors(response.data.data);
} catch (error) {
setNetworkError(true);
createToast({ body: error.message });
+3 -2
View File
@@ -62,7 +62,6 @@ const CreateStatusPage = () => {
useStatusPageFetch(isCreate, url);
const [deleteStatusPage, isDeleting] = useStatusPageDelete(fetchStatusPage, url);
console.log(JSON.stringify(form, null, 2));
// Handlers
const handleFormChange = (e) => {
let { type, name, value, checked } = e.target;
@@ -144,6 +143,8 @@ const CreateStatusPage = () => {
...form,
logo: { type: form.logo?.type ?? null, size: form.logo?.size ?? null },
};
console.log(toSubmit);
const { error } = statusPageValidation.validate(toSubmit, {
abortEarly: false,
});
@@ -205,7 +206,7 @@ const CreateStatusPage = () => {
companyName: statusPage?.companyName,
isPublished: statusPage?.isPublished,
timezone: statusPage?.timezone,
monitors: statusPageMonitors.map((monitor) => monitor._id),
monitors: statusPageMonitors.map((monitor) => monitor.id),
color: statusPage?.color,
logo: newLogo,
showCharts: statusPage?.showCharts ?? true,
@@ -28,14 +28,14 @@ const MonitorsList = ({
const status = determineState(monitor);
return (
<Stack
key={monitor._id}
key={monitor.id}
width="100%"
gap={theme.spacing(10)}
margin="0 auto"
maxWidth="95%"
>
<Host
key={monitor._id}
key={monitor.id}
url={monitor.url}
title={monitor.name}
percentageColor={monitor.percentageColor}
+11 -7
View File
@@ -144,20 +144,16 @@ class NetworkService {
*/
async getMonitorsByTeamId(config) {
const { limit, types, page, rowsPerPage, filter, field, order } = config;
const { types, filter } = config ?? {};
const params = new URLSearchParams();
if (limit) params.append("limit", limit);
if (types) {
types.forEach((type) => {
params.append("type", type);
});
}
if (page) params.append("page", page);
if (rowsPerPage) params.append("rowsPerPage", rowsPerPage);
if (filter) params.append("filter", filter);
if (field) params.append("field", field);
if (order) params.append("order", order);
if (filter !== undefined && filter !== null && filter !== "")
params.append("filter", filter);
return this.axiosInstance.get(`/monitors/team?${params.toString()}`, {
headers: {
@@ -202,6 +198,14 @@ class NetworkService {
`/monitors/hardware/details/${config.monitorId}?${params.toString()}`
);
}
async getPageSpeedDetailsByMonitorId(config) {
const params = new URLSearchParams();
if (config.dateRange) params.append("dateRange", config.dateRange);
return this.axiosInstance.get(
`/monitors/pagespeed/details/${config.monitorId}?${params.toString()}`
);
}
async getUptimeDetailsById(config) {
const params = new URLSearchParams();
if (config.dateRange) params.append("dateRange", config.dateRange);
+20
View File
@@ -0,0 +1,20 @@
import type { Config } from "jest";
const config: Config = {
rootDir: ".",
testEnvironment: "node",
extensionsToTreatAsEsm: [".ts"],
transform: {
"^.+\\.(t|j)sx?$": ["ts-jest", { useESM: true, tsconfig: "./tsconfig.jest.json" }],
},
moduleNameMapper: {
"^@/(.*)$": "<rootDir>/src/$1",
},
testMatch: ["<rootDir>/test/**/*.test.ts"],
setupFilesAfterEnv: [],
collectCoverageFrom: ["src/**/*.ts"],
coveragePathIgnorePatterns: ["/node_modules/", "/test/"],
clearMocks: true,
};
export default config;
+3304 -359
View File
File diff suppressed because it is too large Load Diff
+10 -4
View File
@@ -5,7 +5,7 @@
"main": "index.js",
"type": "module",
"scripts": {
"test": "c8 mocha",
"test": "NODE_OPTIONS=--experimental-vm-modules c8 jest --runInBand",
"dev": "nodemon --exec tsx src/index.js",
"start": "node --watch ./dist/index.js",
"build": "tsc && tsc-alias",
@@ -58,21 +58,27 @@
},
"devDependencies": {
"@eslint/js": "^9.17.0",
"@types/dockerode": "^4.0.0",
"@types/express": "5.0.3",
"@types/gamedig": "^5.0.3",
"@types/jest": "^30.0.0",
"@types/jmespath": "^0.15.2",
"@types/jsonwebtoken": "9.0.10",
"@types/mjml": "^4.7.4",
"@types/multer": "^2.0.0",
"@types/nodemailer": "7.0.1",
"@types/papaparse": "^5.5.2",
"@types/ping": "0.4.4",
"c8": "10.1.3",
"chai": "5.2.0",
"eslint": "^9.17.0",
"eslint-plugin-mocha": "^10.5.0",
"esm": "3.2.25",
"globals": "^15.14.0",
"mocha": "11.1.0",
"jest": "^30.2.0",
"nodemon": "^3.1.11",
"prettier": "^3.3.3",
"sinon": "19.0.2",
"ts-jest": "^29.4.6",
"ts-node": "^10.9.2",
"tsc-alias": "1.8.16",
"tsx": "4.20.5",
"typescript": "5.9.2"
+176
View File
@@ -0,0 +1,176 @@
import mongoose from "mongoose";
import { MonitorModel } from "../dist/db/models/Monitor.js";
import { CheckModel } from "../dist/db/models/Check.js";
const DEFAULT_MONITOR_ID = "000000000000000000000001";
const DEFAULT_TEAM_ID = "0000000000000000000000aa";
const DEFAULT_USER_ID = "0000000000000000000000bb";
const DEFAULT_MONITOR_TYPE = "http";
const DEFAULT_TOTAL = 1_000_000;
const DEFAULT_BATCH_SIZE = 5_000;
const parseObjectId = (value, fallback) => {
try {
return new mongoose.Types.ObjectId(value || fallback);
} catch (error) {
console.warn(`Invalid ObjectId '${value}', falling back to '${fallback}'.`);
return new mongoose.Types.ObjectId(fallback);
}
};
async function ensureMonitor({ monitorId, teamId, userId, type }) {
const existing = await MonitorModel.findById(monitorId);
if (existing) {
return existing;
}
console.log(`Monitor ${monitorId.toString()} not found, creating it.`);
const monitor = new MonitorModel({
_id: monitorId,
userId,
teamId,
name: `Seed Monitor ${monitorId.toString()}`,
description: "Synthetic monitor for performance testing",
statusWindow: [],
statusWindowSize: 5,
statusWindowThreshold: 60,
type,
ignoreTlsErrors: false,
url: "https://example.com",
isActive: true,
interval: 60000,
alertThreshold: 5,
cpuAlertThreshold: 5,
memoryAlertThreshold: 5,
diskAlertThreshold: 5,
tempAlertThreshold: 5,
selectedDisks: [],
});
await monitor.save();
return monitor;
}
async function run() {
const mongoUri = process.env.MONGO_URI ?? "mongodb://localhost:27017/uptime_db";
const monitorId = parseObjectId(process.env.MONITOR_ID ?? DEFAULT_MONITOR_ID, DEFAULT_MONITOR_ID);
const teamId = parseObjectId("69648b0578209af45f9ffe30");
const userId = parseObjectId("69648b0678209af45f9ffe32");
const monitorType = process.env.MONITOR_TYPE ?? DEFAULT_MONITOR_TYPE;
const total = Number(process.env.CHECK_TOTAL ?? DEFAULT_TOTAL);
const batchSize = Number(process.env.CHECK_BATCH_SIZE ?? DEFAULT_BATCH_SIZE);
console.log(`Connecting to MongoDB at ${mongoUri}`);
await mongoose.connect(mongoUri);
await ensureMonitor({ monitorId, teamId, userId, type: monitorType });
console.log(`Seeding ${total} checks for monitor ${monitorId.toString()} (team ${teamId.toString()}) in batches of ${batchSize}.`);
const docs = [];
const startTime = Date.now();
for (let i = 0; i < total; i += 1) {
const baseTime = Date.now() - (total - i) * 1000;
const createdAt = new Date(baseTime);
docs.push({
metadata: {
monitorId,
teamId,
type: monitorType,
},
status: i % 50 !== 0,
statusCode: i % 50 !== 0 ? 200 : 500,
responseTime: Math.floor(Math.random() * 1000),
message: i % 50 !== 0 ? "OK" : "Error",
expiry: createdAt,
createdAt,
updatedAt: createdAt,
timings: {
start: baseTime,
socket: baseTime,
lookup: baseTime,
connect: baseTime,
secureConnect: baseTime,
upload: baseTime,
response: baseTime + 40,
end: baseTime + 45,
phases: {
wait: 0,
dns: 1,
tcp: 2,
tls: 4,
request: 0,
firstByte: 30,
download: 5,
total: 45,
},
},
cpu: {
physical_core: 8,
logical_core: 16,
frequency: 3600,
temperature: [50 + Math.random() * 10],
free_percent: 40,
usage_percent: Math.random() * 100,
},
memory: {
total_bytes: 32 * 1024 ** 3,
available_bytes: 16 * 1024 ** 3,
used_bytes: 16 * 1024 ** 3,
usage_percent: Math.random() * 100,
},
disk: [
{
device: "/dev/sda1",
mountpoint: "/",
read_speed_bytes: Math.random() * 10_000_000,
write_speed_bytes: Math.random() * 10_000_000,
total_bytes: 512 * 1024 ** 3,
free_bytes: 128 * 1024 ** 3,
usage_percent: Math.random() * 100,
},
],
host: {
os: "linux",
platform: "ubuntu",
kernel_version: "5.15.0",
},
net: [
{
name: "eth0",
bytes_sent: Math.random() * 10_000_000,
bytes_recv: Math.random() * 10_000_000,
packets_sent: Math.random() * 1_000_000,
packets_recv: Math.random() * 1_000_000,
err_in: 0,
err_out: 0,
drop_in: 0,
drop_out: 0,
fifo_in: 0,
fifo_out: 0,
},
],
errors: i % 50 === 0 ? [{ metric: ["uptime"], err: "500" }] : [],
});
if (docs.length === batchSize) {
await CheckModel.insertMany(docs, { ordered: false });
console.log(`Inserted ${i + 1} / ${total}`);
docs.length = 0;
}
}
if (docs.length > 0) {
await CheckModel.insertMany(docs, { ordered: false });
}
await mongoose.disconnect();
const duration = ((Date.now() - startTime) / 1000).toFixed(2);
console.log(`Finished inserting ${total} checks in ${duration}s`);
}
run().catch((error) => {
console.error("Failed to seed checks", error);
process.exit(1);
});
@@ -16,7 +16,7 @@ import CheckService from "../service/business/checkService.js";
import DiagnosticService from "../service/business/diagnosticService.js";
import InviteService from "../service/business/inviteService.js";
import MaintenanceWindowService from "../service/business/maintenanceWindowService.js";
import MonitorService from "../service/business/monitorService.js";
import { MonitorService } from "@/service/index.js";
import IncidentService from "../service/business/incidentService.js";
import papaparse from "papaparse";
import axios from "axios";
@@ -34,9 +34,8 @@ const { compile } = pkg;
import mjml2html from "mjml";
import jwt from "jsonwebtoken";
import crypto from "crypto";
import { games } from "gamedig";
import { games, GameDig } from "gamedig";
import jmespath from "jmespath";
import { GameDig } from "gamedig";
import { fileURLToPath } from "url";
import { ObjectId } from "mongodb";
@@ -47,7 +46,6 @@ import { GenerateAvatarImage } from "../utils/imageProcessing.js";
import { ParseBoolean } from "../utils/utils.js";
// Models
import { CheckModel } from "@/db/models/index.js";
import Monitor from "../db/models/Monitor.js";
import User from "../db/models/User.js";
import InviteToken from "../db/models/InviteToken.js";
@@ -72,11 +70,11 @@ import SettingsModule from "../db/modules/settingsModule.js";
import IncidentModule from "../db/modules/incidentModule.js";
// repositories
import { MongoMonitorsRepository, MongoChecksRepository } from "@/repositories/index.js";
import { MongoMonitorsRepository, MongoChecksRepository, MongoMonitorStatsRepository } from "@/repositories/index.js";
export const initializeServices = async ({ logger, envSettings, settingsService }) => {
export const initializeServices = async ({ logger, envSettings, settingsService }: { logger: any; envSettings: any; settingsService: any }) => {
const serviceRegistry = new ServiceRegistry({ logger });
ServiceRegistry.instance = serviceRegistry;
(ServiceRegistry as any).instance = serviceRegistry;
const translationService = new TranslationService(logger);
await translationService.initialize();
@@ -84,7 +82,7 @@ export const initializeServices = async ({ logger, envSettings, settingsService
const stringService = new StringService(translationService);
// Create DB
const checkModule = new CheckModule({ logger, CheckModel, Monitor, User });
const checkModule = new CheckModule({ logger, Monitor, User });
const inviteModule = new InviteModule({ InviteToken, crypto, stringService });
const statusPageModule = new StatusPageModule({ StatusPage, NormalizeData, stringService });
const userModule = new UserModule({ User, Team, GenerateAvatarImage, ParseBoolean, stringService });
@@ -125,6 +123,7 @@ export const initializeServices = async ({ logger, envSettings, settingsService
// Repositories
const monitorsRepository = new MongoMonitorsRepository();
const checksRepository = new MongoChecksRepository();
const monitorStatsRepository = new MongoMonitorStatsRepository();
const networkService = new NetworkService({
axios,
@@ -152,7 +151,7 @@ export const initializeServices = async ({ logger, envSettings, settingsService
const bufferService = new BufferService({ db, logger, envSettings, incidentService });
const statusService = new StatusService({ db, logger, buffer: bufferService, incidentService });
const statusService = new StatusService({ db, logger, buffer: bufferService, incidentService, monitorsRepository });
const notificationUtils = new NotificationUtils({
stringService,
@@ -182,6 +181,7 @@ export const initializeServices = async ({ logger, envSettings, settingsService
db,
logger,
helper: superSimpleQueueHelper,
monitorsRepository,
});
// Business services
@@ -218,7 +218,6 @@ export const initializeServices = async ({ logger, envSettings, settingsService
});
const monitorService = new MonitorService({
db,
settingsService,
jobQueue: superSimpleQueue,
stringService,
emailService,
@@ -228,6 +227,7 @@ export const initializeServices = async ({ logger, envSettings, settingsService
games,
monitorsRepository,
checksRepository,
monitorStatsRepository,
});
const services = {
+100 -1
View File
@@ -1,3 +1,6 @@
import { AppError } from "@/utils/AppError.js";
import { type MonitorType, MonitorTypes } from "@/types/index.js";
const fetchMonitorCertificate = async (sslChecker: any, monitor: any): Promise<any> => {
const monitorUrl = new URL(monitor.url);
const hostname = monitorUrl.hostname;
@@ -8,5 +11,101 @@ const fetchMonitorCertificate = async (sslChecker: any, monitor: any): Promise<a
}
return cert;
};
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 { fetchMonitorCertificate };
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 });
};
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 });
};
const optionalBoolean = (value: unknown, fieldName: string): boolean | undefined => {
if (value === undefined) {
return undefined;
}
if (typeof value === "boolean") {
return value;
}
if (typeof value === "string") {
if (value === "true") {
return true;
}
if (value === "false") {
return false;
}
}
throw new AppError({ message: `${fieldName} must be a boolean`, status: 400 });
};
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);
};
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 });
};
const requireTeamId = (teamId?: string): string => {
if (!teamId) {
throw new AppError({ message: "Team ID is required", status: 400 });
}
return teamId;
};
export {
fetchMonitorCertificate,
requireString,
optionalString,
optionalNumber,
optionalBoolean,
parseMonitorTypeFilter,
parseSortOrder,
requireTeamId,
};
+84 -125
View File
@@ -5,26 +5,35 @@ import {
getMonitorByIdQueryValidation,
getMonitorsByTeamIdParamValidation,
getMonitorsByTeamIdQueryValidation,
getMonitorsWithChecksQueryValidation,
createMonitorBodyValidation,
editMonitorBodyValidation,
pauseMonitorParamValidation,
getMonitorStatsByIdParamValidation,
getMonitorStatsByIdQueryValidation,
getCertificateParamValidation,
getHardwareDetailsByIdParamValidation,
getHardwareDetailsByIdQueryValidation,
} from "@/validation/joi.js";
import sslChecker from "ssl-checker";
import { fetchMonitorCertificate } from "./controllerUtils.js";
import {
fetchMonitorCertificate,
requireString,
optionalString,
optionalNumber,
optionalBoolean,
parseMonitorTypeFilter,
parseSortOrder,
requireTeamId,
} from "./controllerUtils.js";
import { AppError } from "@/utils/AppError.js";
import { IMonitorService } from "@/service/index.js";
const SERVICE_NAME = "monitorController";
class MonitorController {
static SERVICE_NAME = SERVICE_NAME;
private monitorService: any;
private monitorService: IMonitorService;
constructor(monitorService: any) {
constructor(monitorService: IMonitorService) {
this.monitorService = monitorService;
}
@@ -33,33 +42,17 @@ class MonitorController {
}
async verifyTeamAccess(teamId: string, monitorId: string) {
const monitor = await this.monitorService.getMonitorById(monitorId);
if (!monitor.teamId.equals(teamId)) {
const monitor = await this.monitorService.getMonitorById({ teamId, monitorId });
if (monitor.teamId !== teamId) {
throw new AppError({ message: "Access denied", status: 403 });
}
}
getAllMonitors = async (req: Request, res: Response, next: NextFunction) => {
try {
const monitors = await this.monitorService.getAllMonitors();
return res.status(200).json({
success: true,
msg: "Retrieved all monitors successfully",
data: monitors,
});
} catch (error) {
next(error);
}
};
getMonitorCertificate = async (req: Request, res: Response, next: NextFunction) => {
try {
await getCertificateParamValidation.validateAsync(req.params);
const teamId = req?.user?.teamId;
if (!teamId) {
throw new AppError({ message: "Team ID is required", status: 400 });
}
const { monitorId } = req.params;
const teamId = requireTeamId(req?.user?.teamId);
const monitorId = requireString(req.params?.monitorId, "Monitor ID");
const monitor = await this.monitorService.getMonitorById({ teamId, monitorId });
const certificate = await fetchMonitorCertificate(sslChecker, monitor);
@@ -77,15 +70,10 @@ class MonitorController {
getUptimeDetailsById = async (req: Request, res: Response, next: NextFunction) => {
try {
const monitorId = req?.params?.monitorId;
const dateRange = req?.query?.dateRange;
const normalize = req?.query?.normalize;
const teamId = req?.user?.teamId;
if (!teamId) {
throw new AppError({ message: "Team ID is required", status: 400 });
}
const monitorId = requireString(req?.params?.monitorId, "Monitor ID");
const dateRange = requireString(req?.query?.dateRange, "dateRange");
const normalize = optionalBoolean(req?.query?.normalize, "normalize");
const teamId = requireTeamId(req?.user?.teamId);
const data = await this.monitorService.getUptimeDetailsById({
teamId,
@@ -103,50 +91,14 @@ class MonitorController {
}
};
getMonitorStatsById = async (req: Request, res: Response, next: NextFunction) => {
try {
await getMonitorStatsByIdParamValidation.validateAsync(req.params);
await getMonitorStatsByIdQueryValidation.validateAsync(req.query);
let { limit, sortOrder, dateRange, numToDisplay, normalize } = req.query;
const monitorId = req?.params?.monitorId;
const teamId = req?.user?.teamId;
if (!teamId) {
throw new AppError({ message: "Team ID is required", status: 400 });
}
const monitorStats = await this.monitorService.getMonitorStatsById({
teamId,
monitorId,
limit,
sortOrder,
dateRange,
numToDisplay,
normalize,
});
return res.status(200).json({
success: true,
msg: "Monitor stats retrieved successfully",
data: monitorStats,
});
} catch (error) {
next(error);
}
};
getHardwareDetailsById = async (req: Request, res: Response, next: NextFunction) => {
try {
await getHardwareDetailsByIdParamValidation.validateAsync(req.params);
await getHardwareDetailsByIdQueryValidation.validateAsync(req.query);
const monitorId = req?.params?.monitorId;
const dateRange = req?.query?.dateRange;
const teamId = req?.user?.teamId;
if (!teamId) {
throw new AppError({ message: "Team ID is required", status: 400 });
}
const monitorId = requireString(req?.params?.monitorId, "Monitor ID");
const dateRange = requireString(req?.query?.dateRange, "dateRange");
const teamId = requireTeamId(req?.user?.teamId);
const monitor = await this.monitorService.getHardwareDetailsById({
teamId,
@@ -163,18 +115,40 @@ class MonitorController {
next(error);
}
};
getPageSpeedDetailsById = async (req: Request, res: Response, next: NextFunction) => {
try {
await getHardwareDetailsByIdParamValidation.validateAsync(req.params);
await getHardwareDetailsByIdQueryValidation.validateAsync(req.query);
const monitorId = requireString(req?.params?.monitorId, "Monitor ID");
const dateRange = requireString(req?.query?.dateRange, "dateRange");
const teamId = requireTeamId(req?.user?.teamId);
const monitor = await this.monitorService.getPageSpeedDetailsById({
teamId,
monitorId,
dateRange,
});
return res.status(200).json({
success: true,
msg: "Page speed details retrieved successfully",
data: monitor,
});
} catch (error) {
next(error);
}
};
getMonitorById = async (req: Request, res: Response, next: NextFunction) => {
try {
await getMonitorByIdParamValidation.validateAsync(req.params);
await getMonitorByIdQueryValidation.validateAsync(req.query);
const teamId = req?.user?.teamId;
if (!teamId) {
throw new AppError({ message: "Team ID is required", status: 400 });
}
const teamId = requireTeamId(req?.user?.teamId);
const monitorId = requireString(req?.params?.monitorId, "Monitor ID");
const monitor = await this.monitorService.getMonitorById({ teamId, monitorId: req?.params?.monitorId });
const monitor = await this.monitorService.getMonitorById({ teamId, monitorId });
return res.status(200).json({
success: true,
@@ -190,10 +164,10 @@ class MonitorController {
try {
await createMonitorBodyValidation.validateAsync(req.body);
const userId = req?.user?._id;
const teamId = req?.user?.teamId;
const userId = requireString(req?.user?._id, "User ID");
const teamId = requireTeamId(req?.user?.teamId);
const monitor = await this.monitorService.createMonitor({ teamId, userId, body: req.body });
const monitor = await this.monitorService.createMonitor(teamId, userId, req.body);
return res.status(200).json({
success: true,
@@ -219,19 +193,15 @@ class MonitorController {
throw new AppError({ message: "File is empty", status: 400 });
}
const userId = req?.user?._id;
const teamId = req?.user?.teamId;
if (!userId || !teamId) {
throw new AppError({ message: "Missing userId or teamId", status: 400 });
}
const userId = requireString(req?.user?._id, "User ID");
const teamId = requireTeamId(req?.user?.teamId);
const fileData = req?.file?.buffer?.toString("utf-8");
if (!fileData) {
throw new AppError({ message: "Cannot get file from buffer", status: 400 });
}
const monitors = await this.monitorService.createBulkMonitors({ fileData, userId, teamId });
const monitors = await this.monitorService.createBulkMonitors(fileData, userId, teamId);
return res.status(200).json({
success: true,
@@ -246,11 +216,8 @@ class MonitorController {
deleteMonitor = async (req: Request, res: Response, next: NextFunction) => {
try {
await getMonitorByIdParamValidation.validateAsync(req.params);
const monitorId = req.params.monitorId;
const teamId = req?.user?.teamId;
if (!teamId) {
throw new AppError({ message: "Team ID is required", status: 400 });
}
const monitorId = requireString(req?.params?.monitorId, "Monitor ID");
const teamId = requireTeamId(req?.user?.teamId);
const deletedMonitor = await this.monitorService.deleteMonitor({ teamId, monitorId });
@@ -266,10 +233,7 @@ class MonitorController {
deleteAllMonitors = async (req: Request, res: Response, next: NextFunction) => {
try {
const teamId = req?.user?.teamId;
if (!teamId) {
throw new AppError({ message: "Team ID is required", status: 400 });
}
const teamId = requireTeamId(req?.user?.teamId);
const deletedCount = await this.monitorService.deleteAllMonitors({ teamId });
@@ -286,12 +250,8 @@ class MonitorController {
try {
await getMonitorByIdParamValidation.validateAsync(req.params);
await editMonitorBodyValidation.validateAsync(req.body);
const monitorId = req?.params?.monitorId;
const teamId = req?.user?.teamId;
if (!teamId) {
throw new AppError({ message: "Team ID is required", status: 400 });
}
const monitorId = requireString(req?.params?.monitorId, "Monitor ID");
const teamId = requireTeamId(req?.user?.teamId);
const editedMonitor = await this.monitorService.editMonitor({ teamId, monitorId, body: req.body });
@@ -309,11 +269,8 @@ class MonitorController {
try {
await pauseMonitorParamValidation.validateAsync(req.params);
const monitorId = req.params.monitorId;
const teamId = req?.user?.teamId;
if (!teamId) {
throw new AppError({ message: "Team ID is required", status: 400 });
}
const monitorId = requireString(req?.params?.monitorId, "Monitor ID");
const teamId = requireTeamId(req?.user?.teamId);
const monitor = await this.monitorService.pauseMonitor({ teamId, monitorId });
@@ -329,7 +286,8 @@ class MonitorController {
addDemoMonitors = async (req: Request, res: Response, next: NextFunction) => {
try {
const { _id, teamId } = req.user;
const _id = requireString(req?.user?._id, "User ID");
const teamId = requireTeamId(req?.user?.teamId);
const demoMonitors = await this.monitorService.addDemoMonitors({ userId: _id, teamId });
return res.status(200).json({
@@ -365,10 +323,11 @@ class MonitorController {
await getMonitorsByTeamIdParamValidation.validateAsync(req.params);
await getMonitorsByTeamIdQueryValidation.validateAsync(req.query);
let { limit, type, page, rowsPerPage, filter, field, order } = req.query;
const teamId = req?.user?.teamId;
const teamId = requireTeamId(req?.user?.teamId);
const type = parseMonitorTypeFilter(req.query?.type);
const filter = optionalString(req.query?.filter, "filter");
const monitors = await this.monitorService.getMonitorsByTeamId({ teamId, limit, type, page, rowsPerPage, filter, field, order });
const monitors = await this.monitorService.getMonitorsByTeamId({ teamId, type, filter });
return res.status(200).json({
success: true,
@@ -385,12 +344,9 @@ class MonitorController {
await getMonitorsByTeamIdParamValidation.validateAsync(req.params);
await getMonitorsByTeamIdQueryValidation.validateAsync(req.query);
const explain = req?.query?.explain;
const type = req?.query?.type;
const teamId = req?.user?.teamId;
if (!teamId) {
throw new AppError({ message: "Team ID is required", status: 400 });
}
const explain = optionalBoolean(req?.query?.explain, "explain");
const type = parseMonitorTypeFilter(req?.query?.type);
const teamId = requireTeamId(req?.user?.teamId);
const result = await this.monitorService.getMonitorsAndSummaryByTeamId({ teamId, type, explain });
@@ -406,14 +362,17 @@ class MonitorController {
getMonitorsWithChecksByTeamId = async (req: Request, res: Response, next: NextFunction) => {
try {
await getMonitorsByTeamIdParamValidation.validateAsync(req.params);
await getMonitorsByTeamIdQueryValidation.validateAsync(req.query);
await getMonitorsWithChecksQueryValidation.validateAsync(req.query);
const explain = req?.query?.explain;
let { limit, type, page, rowsPerPage, filter, field, order } = req.query;
const teamId = req?.user?.teamId;
if (!teamId) {
throw new AppError({ message: "Team ID is required", status: 400 });
}
const explain = optionalBoolean(req?.query?.explain, "explain");
const limit = optionalNumber(req?.query?.limit, "limit");
const page = optionalNumber(req?.query?.page, "page");
const rowsPerPage = optionalNumber(req?.query?.rowsPerPage, "rowsPerPage");
const filter = optionalString(req?.query?.filter, "filter");
const field = optionalString(req?.query?.field, "field");
const order = parseSortOrder(req?.query?.order);
const type = parseMonitorTypeFilter(req?.query?.type);
const teamId = requireTeamId(req?.user?.teamId);
const monitors = await this.monitorService.getMonitorsWithChecksByTeamId({
teamId,
-6
View File
@@ -11,12 +11,9 @@ class MongoDB {
inviteModule,
statusPageModule,
userModule,
hardwareCheckModule,
maintenanceWindowModule,
monitorModule,
networkCheckModule,
notificationModule,
pageSpeedCheckModule,
recoveryModule,
settingsModule,
incidentModule,
@@ -26,15 +23,12 @@ class MongoDB {
this.userModule = userModule;
this.inviteModule = inviteModule;
this.recoveryModule = recoveryModule;
this.pageSpeedCheckModule = pageSpeedCheckModule;
this.hardwareCheckModule = hardwareCheckModule;
this.checkModule = checkModule;
this.maintenanceWindowModule = maintenanceWindowModule;
this.monitorModule = monitorModule;
this.notificationModule = notificationModule;
this.settingsModule = settingsModule;
this.statusPageModule = statusPageModule;
this.networkCheckModule = networkCheckModule;
this.incidentModule = incidentModule;
}
-49
View File
@@ -1,49 +0,0 @@
import mongoose from "mongoose";
const MonitorStatsSchema = new mongoose.Schema(
{
monitorId: {
type: mongoose.Schema.Types.ObjectId,
ref: "Monitor",
immutable: true,
index: true,
},
avgResponseTime: {
type: Number,
default: 0,
},
totalChecks: {
type: Number,
default: 0,
},
totalUpChecks: {
type: Number,
default: 0,
},
totalDownChecks: {
type: Number,
default: 0,
},
uptimePercentage: {
type: Number,
default: 0,
},
lastCheckTimestamp: {
type: Number,
default: 0,
},
lastResponseTime: {
type: Number,
default: 0,
},
timeOfLastFailure: {
type: Number,
default: 0,
},
},
{ timestamps: true }
);
const MonitorStats = mongoose.model("MonitorStats", MonitorStatsSchema);
export default MonitorStats;
+63
View File
@@ -0,0 +1,63 @@
import { Schema, model, type Types } from "mongoose";
import type { MonitorStats as MonitorStatsEntity } from "@/types/monitorStats.js";
type MonitorStatsDocumentBase = Omit<MonitorStatsEntity, "id" | "monitorId" | "createdAt" | "updatedAt"> & {
monitorId: Types.ObjectId;
};
interface MonitorStatsDocument extends MonitorStatsDocumentBase {
_id: Types.ObjectId;
createdAt: Date;
updatedAt: Date;
}
const MonitorStatsSchema = new Schema<MonitorStatsDocument>(
{
monitorId: {
type: Schema.Types.ObjectId,
ref: "Monitor",
immutable: true,
index: true,
required: true,
},
avgResponseTime: {
type: Number,
default: 0,
},
totalChecks: {
type: Number,
default: 0,
},
totalUpChecks: {
type: Number,
default: 0,
},
totalDownChecks: {
type: Number,
default: 0,
},
uptimePercentage: {
type: Number,
default: 0,
},
lastCheckTimestamp: {
type: Number,
default: 0,
},
lastResponseTime: {
type: Number,
default: 0,
},
timeOfLastFailure: {
type: Number,
default: undefined,
},
},
{ timestamps: true }
);
const MonitorStatsModel = model<MonitorStatsDocument>("MonitorStats", MonitorStatsSchema);
export type { MonitorStatsDocument };
export { MonitorStatsModel };
export default MonitorStatsModel;
+3
View File
@@ -3,3 +3,6 @@ export { default as MonitorModel } from "@/db/models/Monitor.js";
export * from "@/db/models/Check.js";
export { default as CheckModel } from "@/db/models/Check.js";
export * from "@/db/models/MonitorStats.js";
export { default as MonitorStatsModel } from "@/db/models/MonitorStats.js";
+26 -93
View File
@@ -194,17 +194,6 @@ class MonitorModule {
};
};
getAllMonitors = async () => {
try {
const monitors = await this.Monitor.find();
return monitors;
} catch (error) {
error.service = SERVICE_NAME;
error.method = "getAllMonitors";
throw error;
}
};
getMonitorById = async (monitorId) => {
try {
const monitor = await this.Monitor.findById(monitorId);
@@ -264,48 +253,6 @@ class MonitorModule {
}
};
getMonitorStatsById = async ({ monitorId, sortOrder, dateRange, numToDisplay, normalize }) => {
try {
// Get monitor, if we can't find it, abort with error
const monitor = await this.Monitor.findById(monitorId);
if (monitor === null || monitor === undefined) {
throw new Error(this.stringService.getDbFindMonitorById(monitorId));
}
// Get query params
const sort = sortOrder === "asc" ? 1 : -1;
// Get Checks for monitor in date range requested
const dates = this.getDateRange(dateRange);
const { checksAll, checksForDateRange } = await this.getMonitorChecks(monitorId, dates, sort);
// Build monitor stats
const monitorStats = {
...monitor.toObject(),
uptimeDuration: this.calculateUptimeDuration(checksAll),
lastChecked: this.getLastChecked(checksAll),
latestResponseTime: this.getLatestResponseTime(checksAll),
periodIncidents: this.getIncidents(checksForDateRange),
periodTotalChecks: checksForDateRange.length,
checks: this.processChecksForDisplay(this.NormalizeData, checksForDateRange, numToDisplay, normalize),
};
if (monitor.type === "http" || monitor.type === "ping" || monitor.type === "docker" || monitor.type === "port" || monitor.type === "game") {
// HTTP/PING Specific stats
monitorStats.periodAvgResponseTime = this.getAverageResponseTime(checksForDateRange);
monitorStats.periodUptime = this.getUptimePercentage(checksForDateRange);
const groupedChecks = this.groupChecksByTime(checksForDateRange, dateRange);
monitorStats.aggregateData = Object.values(groupedChecks).map(this.calculateGroupStats);
}
return monitorStats;
} catch (error) {
error.service = SERVICE_NAME;
error.method = "getMonitorStatsById";
throw error;
}
};
getHardwareDetailsById = async ({ monitorId, dateRange }) => {
try {
const monitor = await this.Monitor.findById(monitorId);
@@ -342,47 +289,33 @@ class MonitorModule {
}
};
getMonitorsByTeamId = async ({ limit, type, page, rowsPerPage, filter, field, order, teamId }) => {
limit = parseInt(limit);
page = parseInt(page);
rowsPerPage = parseInt(rowsPerPage);
if (field === undefined) {
field = "name";
order = "asc";
}
// Build match stage
const matchStage = { teamId: new this.ObjectId(teamId) };
if (type !== undefined) {
matchStage.type = Array.isArray(type) ? { $in: type } : type;
}
const summaryResult = await this.Monitor.aggregate(buildMonitorSummaryByTeamIdPipeline({ matchStage }));
const summary = summaryResult[0];
const monitors = await this.Monitor.aggregate(buildMonitorsByTeamIdPipeline({ matchStage, field, order }));
const filteredMonitors = await this.Monitor.aggregate(
buildFilteredMonitorsByTeamIdPipeline({
matchStage,
filter,
page,
rowsPerPage,
field,
order,
limit,
type,
})
);
const normalizedFilteredMonitors = filteredMonitors.map((monitor) => {
if (!monitor.checks) {
return monitor;
getMonitorsByTeamId = async ({ teamId, type, filter }) => {
try {
const matchStage = { teamId: new this.ObjectId(teamId) };
if (type !== undefined) {
matchStage.type = Array.isArray(type) ? { $in: type } : type;
}
monitor.checks = this.NormalizeData(monitor.checks, 10, 100);
return monitor;
});
return { summary, monitors, filteredMonitors: normalizedFilteredMonitors };
if (filter !== undefined && filter !== null && filter !== "") {
matchStage.$or = [{ name: { $regex: filter, $options: "i" } }, { url: { $regex: filter, $options: "i" } }];
}
const monitors = await this.Monitor.find(matchStage)
.sort({ name: 1 })
.select({
_id: 1,
name: 1,
type: 1,
url: 1,
status: 1,
isActive: 1,
teamId: 1,
})
.lean();
return monitors;
} catch (error) {
error.service = SERVICE_NAME;
error.method = "getMonitorsByTeamId";
throw error;
}
};
getMonitorsAndSummaryByTeamId = async ({ type, explain, teamId }) => {
@@ -1,10 +1,65 @@
import type { Check, CheckAudits, MonitorType } from "@/types/index.js";
import type { LatestChecksMap } from "@/repositories/checks/MongoChecksRepistory.js";
export interface IChecksRepository {
// create
// single fetch
// collection fetch
findLatestChecksByMonitorIds(monitorIds: string[]): Promise<LatestChecksMap>;
// update
// delete
export interface PageSpeedChecksResult {
monitorType: "pagespeed";
checks: Check[];
}
export interface HardwareChecksResult {
monitorType: "hardware";
aggregateData: {
latestCheck: Check | null;
totalChecks: number;
};
upChecks: {
totalChecks: number;
};
checks: Array<{
_id: string;
avgCpuUsage: number;
avgMemoryUsage: number;
avgTemperature: number[];
disks: Array<{
name: string;
readSpeed: number;
writeSpeed: number;
totalBytes: number;
freeBytes: number;
usagePercent: number;
}>;
net: Array<{
name: string;
bytesSentPerSecond: number;
deltaBytesRecv: number;
deltaPacketsSent: number;
deltaPacketsRecv: number;
deltaErrIn: number;
deltaErrOut: number;
deltaDropIn: number;
deltaDropOut: number;
deltaFifoIn: number;
deltaFifoOut: number;
}>;
}>;
}
export interface UptimeChecksResult {
monitorType: Exclude<MonitorType, "hardware" | "pagespeed">;
groupedChecks: Array<{ _id: string; avgResponseTime: number; totalChecks: number }>;
groupedUpChecks: Array<{ _id: string; totalChecks: number; avgResponseTime: number }>;
groupedDownChecks: Array<{ _id: string; totalChecks: number; avgResponseTime: number }>;
uptimePercentage: number;
avgResponseTime: number;
}
export interface IChecksRepository {
findLatestChecksByMonitorIds(monitorIds: string[], options?: { limitPerMonitor?: number }): Promise<LatestChecksMap>;
findDateRangeChecksByMonitor(
monitorId: string,
startDate: Date,
endDate: Date,
dateString: string,
options?: { type?: MonitorType }
): Promise<UptimeChecksResult | HardwareChecksResult | PageSpeedChecksResult>;
}
@@ -11,9 +11,15 @@ import type {
CheckMetadata,
CheckNetworkInterfaceInfo,
CheckTimings,
MonitorType,
} from "@/types/index.js";
import { CheckModel, type CheckDocument } from "@/db/models/index.js";
import mongoose from "mongoose";
import {
getAggregateData as getHardwareAggregateData,
getHardwareStats,
getUpChecks as getHardwareUpChecks,
} from "@/db/modules/monitorModuleQueries.js";
export type LatestChecksMap = Record<string, Check[]>;
@@ -172,15 +178,16 @@ class MongoChecksRepistory implements IChecksRepository {
};
};
findLatestChecksByMonitorIds = async (monitorIds: string[]): Promise<LatestChecksMap> => {
findLatestChecksByMonitorIds = async (monitorIds: string[], options?: { limitPerMonitor?: number }): Promise<LatestChecksMap> => {
if (monitorIds.length === 0) {
return {};
}
const mongoIds = monitorIds.map((id) => new mongoose.Types.ObjectId(id));
const limitPerMonitor = 25;
const maxIntervalMs = Number(process.env.MONITOR_MAX_INTERVAL_MS ?? 10 * 60 * 1000);
const bufferMs = Number(process.env.MONITOR_INTERVAL_BUFFER_MS ?? maxIntervalMs);
const lookbackMs = 25 * maxIntervalMs + bufferMs;
const limitPerMonitor = options?.limitPerMonitor ?? 25;
const maxIntervalMs = Number(10 * 60 * 1000);
const bufferMs = Number(maxIntervalMs);
const lookbackMs = limitPerMonitor * maxIntervalMs + bufferMs;
const cutoffDate = new Date(Date.now() - lookbackMs);
const checkGroups = await CheckModel.aggregate([
{
@@ -202,12 +209,187 @@ class MongoChecksRepistory implements IChecksRepository {
},
},
]);
return checkGroups.reduce<LatestChecksMap>((acc, group) => {
const monitorId = group._id.toString();
acc[monitorId] = (group.latestChecks ?? []).map((doc: CheckDocument) => this.toEntity(doc));
return acc;
}, {});
};
findDateRangeChecksByMonitor = async (monitorId: string, startDate: Date, endDate: Date, dateString: string, options?: { type?: MonitorType }) => {
const monitorObjectId = new mongoose.Types.ObjectId(monitorId);
if (options?.type === "hardware") {
return this.findHardwareDateRangeChecks(monitorObjectId, startDate, endDate, dateString);
}
if (options?.type === "pagespeed") {
return this.findPageSpeedDateRangeChecks(monitorObjectId, startDate, endDate);
}
return this.findUptimeDateRangeChecks(options?.type ?? "http", monitorObjectId, startDate, endDate, dateString);
};
private findUptimeDateRangeChecks = async (
monitorType: Exclude<MonitorType, "hardware" | "pagespeed">,
monitorObjectId: mongoose.Types.ObjectId,
startDate: Date,
endDate: Date,
dateString: string
) => {
const matchStage = {
"metadata.monitorId": monitorObjectId,
updatedAt: { $gte: startDate, $lte: endDate },
};
const [result] = await CheckModel.aggregate([
{ $match: matchStage },
{ $sort: { updatedAt: 1 } },
{
$facet: {
uptimePercentage: [
{
$group: {
_id: null,
upChecks: { $sum: { $cond: [{ $eq: ["$status", true] }, 1, 0] } },
totalChecks: { $sum: 1 },
},
},
{
$project: {
_id: 0,
percentage: {
$cond: [{ $eq: ["$totalChecks", 0] }, 0, { $divide: ["$upChecks", "$totalChecks"] }],
},
},
},
],
groupedAvgResponseTime: [
{
$group: {
_id: null,
avgResponseTime: { $avg: "$responseTime" },
},
},
],
groupedChecks: [
{
$group: {
_id: {
$dateToString: { format: dateString, date: "$createdAt" },
},
avgResponseTime: { $avg: "$responseTime" },
totalChecks: { $sum: 1 },
},
},
{ $sort: { _id: 1 } },
],
groupedUpChecks: [
{ $match: { status: true } },
{
$group: {
_id: {
$dateToString: { format: dateString, date: "$createdAt" },
},
totalChecks: { $sum: 1 },
avgResponseTime: { $avg: "$responseTime" },
},
},
{ $sort: { _id: 1 } },
],
groupedDownChecks: [
{ $match: { status: false } },
{
$group: {
_id: {
$dateToString: { format: dateString, date: "$createdAt" },
},
totalChecks: { $sum: 1 },
avgResponseTime: { $avg: "$responseTime" },
},
},
{ $sort: { _id: 1 } },
],
},
},
]);
const uptimePercentage = result?.uptimePercentage?.[0]?.percentage ?? 0;
const avgResponseTime = result?.groupedAvgResponseTime?.[0]?.avgResponseTime ?? 0;
return {
monitorType,
groupedChecks: result?.groupedChecks ?? [],
groupedUpChecks: result?.groupedUpChecks ?? [],
groupedDownChecks: result?.groupedDownChecks ?? [],
uptimePercentage,
avgResponseTime,
};
};
private findHardwareDateRangeChecks = async (monitorObjectId: mongoose.Types.ObjectId, startDate: Date, endDate: Date, dateString: string) => {
const monitorId = monitorObjectId.toHexString();
const dates = { start: startDate, end: endDate };
const [aggregateDataDoc, upChecksDoc, hardwareMetrics] = await Promise.all([
getHardwareAggregateData(monitorId, dates),
getHardwareUpChecks(monitorId, dates),
getHardwareStats(monitorId, dates, dateString),
]);
const aggregateData = {
latestCheck: aggregateDataDoc?.latestCheck ? this.toEntity(aggregateDataDoc.latestCheck as CheckDocument) : null,
totalChecks: aggregateDataDoc?.totalChecks ?? 0,
};
const upChecks = {
totalChecks: upChecksDoc?.totalChecks ?? 0,
};
const checks = (hardwareMetrics ?? []).map((metric) => ({
_id: metric._id,
avgCpuUsage: metric.avgCpuUsage ?? 0,
avgMemoryUsage: metric.avgMemoryUsage ?? 0,
avgTemperature: metric.avgTemperature ?? [],
disks: (metric.disks ?? []).map((disk: { [key: string]: number | string | undefined }) => ({
name: disk?.name ?? "",
readSpeed: disk?.readSpeed ?? 0,
writeSpeed: disk?.writeSpeed ?? 0,
totalBytes: disk?.totalBytes ?? 0,
freeBytes: disk?.freeBytes ?? 0,
usagePercent: disk?.usagePercent ?? 0,
})),
net: (metric.net ?? []).map((iface: { [key: string]: number | string | undefined }) => ({
name: iface?.name ?? "",
bytesSentPerSecond: iface?.bytesSentPerSecond ?? 0,
deltaBytesRecv: iface?.deltaBytesRecv ?? 0,
deltaPacketsSent: iface?.deltaPacketsSent ?? 0,
deltaPacketsRecv: iface?.deltaPacketsRecv ?? 0,
deltaErrIn: iface?.deltaErrIn ?? 0,
deltaErrOut: iface?.deltaErrOut ?? 0,
deltaDropIn: iface?.deltaDropIn ?? 0,
deltaDropOut: iface?.deltaDropOut ?? 0,
deltaFifoIn: iface?.deltaFifoIn ?? 0,
deltaFifoOut: iface?.deltaFifoOut ?? 0,
})),
}));
return {
monitorType: "hardware" as const,
aggregateData,
upChecks,
checks,
};
};
private findPageSpeedDateRangeChecks = async (monitorObjectId: mongoose.Types.ObjectId, startDate: Date, endDate: Date) => {
const matchStage = {
"metadata.monitorId": monitorObjectId,
createdAt: { $gte: startDate, $lte: endDate },
};
const checks = await CheckModel.find(matchStage).sort({ createdAt: -1 }).limit(25).lean();
return {
monitorType: "pagespeed" as const,
checks: checks.map((doc) => this.toEntity(doc)),
};
};
}
export default MongoChecksRepistory;
+3
View File
@@ -3,3 +3,6 @@ export { default as MongoMonitorsRepository } from "@/repositories/monitors/Mong
export * from "@/repositories/checks/IChecksRepository.js";
export { default as MongoChecksRepository } from "@/repositories/checks/MongoChecksRepistory.js";
export * from "@/repositories/monitor-stats/IMonitorStatsRepository.js";
export { default as MongoMonitorStatsRepository } from "@/repositories/monitor-stats/MongoMonitorStatsRepository.js";
@@ -0,0 +1,9 @@
import type { MonitorStats } from "@/types/index.js";
export interface IMonitorStatsRepository {
// create
// single fetch
findByMonitorId(monitorId: string): Promise<MonitorStats>;
// update
// delete
// other
}
@@ -0,0 +1,44 @@
import { type MonitorStatsDocument, MonitorStatsModel } from "@/db/models/index.js";
import type { MonitorStats } from "@/types/index.js";
import { IMonitorStatsRepository } from "@/repositories/index.js";
import mongoose from "mongoose";
import { AppError } from "@/utils/AppError.js";
class MongoMonitorStatsRepository implements IMonitorStatsRepository {
private toEntity = (doc: MonitorStatsDocument): MonitorStats => {
const toStringId = (value: unknown): string => {
if (value instanceof mongoose.Types.ObjectId) {
return value.toString();
}
return value?.toString() ?? "";
};
const toDateString = (value: Date | string): string => {
return value instanceof Date ? value.toISOString() : value;
};
return {
id: toStringId(doc._id),
monitorId: toStringId(doc.monitorId),
avgResponseTime: doc.avgResponseTime,
totalChecks: doc.totalChecks,
totalUpChecks: doc.totalUpChecks,
totalDownChecks: doc.totalDownChecks,
uptimePercentage: doc.uptimePercentage,
lastCheckTimestamp: doc.lastCheckTimestamp,
lastResponseTime: doc.lastResponseTime,
timeOfLastFailure: doc.timeOfLastFailure,
createdAt: toDateString(doc.createdAt),
updatedAt: toDateString(doc.updatedAt),
};
};
findByMonitorId = async (monitorId: string): Promise<MonitorStats> => {
const monitorStats = await MonitorStatsModel.findOne({ monitorId: new mongoose.Types.ObjectId(monitorId) });
if (!monitorStats) {
throw new AppError({ message: "Monitor stats not found", status: 404 });
}
return this.toEntity(monitorStats);
};
}
export default MongoMonitorStatsRepository;
@@ -12,12 +12,18 @@ export interface TeamQueryConfig {
export interface IMonitorsRepository {
// create
create(monitor: Monitor, teamId: string, userId: string): Promise<Monitor | null>;
createBulkMonitors(monitors: Monitor[]): Promise<Monitor[]>;
// single fetch
findById(monitorId: string, teamId?: string): Promise<Monitor | null>;
// collection fetch
findAll(): Promise<Monitor[] | null>;
findByTeamId(teamId: string, config: TeamQueryConfig): Promise<Monitor[] | null>;
// update
update(monitorId: string, updates: Partial<Monitor>): Promise<Monitor>;
// delete
deleteByTeamId(teamId: string): Promise<{ monitors: Monitor[]; deletedCount: number }>;
// counts
findMonitorCountByTeamIdAndType(teamId: string, config: TeamQueryConfig): Promise<number>;
@@ -3,11 +3,44 @@ import type { MonitorDocument } from "@/db/models/Monitor.js";
import type { Monitor, MonitorType } from "@/types/monitor.js";
import mongoose, { type FilterQuery } from "mongoose";
import type { IMonitorsRepository, TeamQueryConfig } from "./IMonitorsRepository.js";
import { AppError } from "@/utils/AppError.js";
class MongoMonitorsRepository implements IMonitorsRepository {
findAll = async (): Promise<Monitor[] | null> => {
const documents = await MonitorModel.find().exec();
return this.mapDocuments(documents);
create = async (monitor: Monitor, teamId: string, userId: string) => {
const monitorModel = new MonitorModel({ ...monitor, teamId, userId });
const saved = await monitorModel.save();
return this.toEntity(saved);
};
createBulkMonitors = async (monitors: Monitor[]): Promise<Monitor[]> => {
if (!monitors.length) {
return [];
}
const payload = monitors.map((monitor) => ({ ...monitor, notifications: undefined }));
const inserted = await MonitorModel.insertMany(payload, { ordered: false });
return this.mapDocuments(inserted);
};
findById = async (monitorId: string, teamId?: string): Promise<Monitor> => {
const match: { _id: string; teamId?: string } = { _id: monitorId };
if (teamId) {
match.teamId = teamId;
}
const monitor = await MonitorModel.findOne(match);
if (!monitor) {
if (monitor === null || monitor === undefined) {
throw new AppError({
message: `Monitor with ID ${monitorId} not found.`,
status: 404,
});
}
}
return this.toEntity(monitor);
};
findAll = async (): Promise<Monitor[]> => {
const monitors = await MonitorModel.find();
return this.mapDocuments(monitors);
};
findByTeamId = async (teamId: string, config: TeamQueryConfig): Promise<Monitor[] | null> => {
@@ -64,9 +97,32 @@ class MongoMonitorsRepository implements IMonitorsRepository {
return count;
};
private mapDocuments = (documents: MonitorDocument[]): Monitor[] | null => {
update = async (monitorId: string, patch: Partial<Monitor>) => {
const updatedMonitor = await MonitorModel.findOneAndUpdate(
{ _id: monitorId },
{
$set: {
...patch,
},
},
{ new: true, runValidators: true }
);
if (!updatedMonitor) {
throw new AppError({ message: `Failed to update monitor with id ${monitorId}`, status: 500 });
}
return this.toEntity(updatedMonitor);
};
deleteByTeamId = async (teamId: string) => {
const monitors = await MonitorModel.find({ teamId });
const { deletedCount } = await MonitorModel.deleteMany({ teamId });
return { monitors: this.mapDocuments(monitors), deletedCount };
};
private mapDocuments = (documents: MonitorDocument[]): Monitor[] => {
if (!documents?.length) {
return null;
return [];
}
return documents.map((doc) => this.toEntity(doc));
};
+2 -2
View File
@@ -27,10 +27,11 @@ class MonitorRoutes {
// Hardware routes
this.router.get("/hardware/details/:monitorId", this.monitorController.getHardwareDetailsById);
// PageSpeed routes
this.router.get("/pagespeed/details/:monitorId", this.monitorController.getPageSpeedDetailsById);
// General monitor routes
this.router.post("/pause/:monitorId", isAllowed(["admin", "superadmin"]), this.monitorController.pauseMonitor);
this.router.get("/stats/:monitorId", this.monitorController.getMonitorStatsById);
// Util routes
this.router.get("/certificate/:monitorId", (req, res, next) => {
@@ -38,7 +39,6 @@ class MonitorRoutes {
});
// General monitor CRUD routes
this.router.get("/", this.monitorController.getAllMonitors);
this.router.post("/", isAllowed(["admin", "superadmin"]), this.monitorController.createMonitor);
this.router.delete("/", isAllowed(["superadmin"]), this.monitorController.deleteAllMonitors);
@@ -1,342 +0,0 @@
import { createMonitorsBodyValidation } from "@/validation/joi.js";
import { NormalizeData } from "@/utils/dataUtils.js";
const SERVICE_NAME = "MonitorService";
class MonitorService {
static SERVICE_NAME = SERVICE_NAME;
constructor({
db,
settingsService,
jobQueue,
stringService,
emailService,
papaparse,
logger,
errorService,
games,
monitorsRepository,
checksRepository,
}) {
this.db = db;
this.settingsService = settingsService;
this.jobQueue = jobQueue;
this.stringService = stringService;
this.emailService = emailService;
this.papaparse = papaparse;
this.logger = logger;
this.errorService = errorService;
this.games = games;
this.monitorsRepository = monitorsRepository;
this.checksRepository = checksRepository;
}
get serviceName() {
return MonitorService.SERVICE_NAME;
}
verifyTeamAccess = async ({ teamId, monitorId }) => {
const monitor = await this.db.monitorModule.getMonitorById(monitorId);
if (!monitor?.teamId?.equals(teamId)) {
throw this.errorService.createAuthorizationError();
}
};
getAllMonitors = async () => {
const monitors = await this.db.monitorModule.getAllMonitors();
return monitors;
};
getUptimeDetailsById = async ({ teamId, monitorId, dateRange, normalize }) => {
await this.verifyTeamAccess({ teamId, monitorId });
const data = await this.db.monitorModule.getUptimeDetailsById({
monitorId,
dateRange,
normalize,
});
return data;
};
getMonitorStatsById = async ({ teamId, monitorId, limit, sortOrder, dateRange, numToDisplay, normalize }) => {
await this.verifyTeamAccess({ teamId, monitorId });
const monitorStats = await this.db.monitorModule.getMonitorStatsById({
monitorId,
limit,
sortOrder,
dateRange,
numToDisplay,
normalize,
});
return monitorStats;
};
getHardwareDetailsById = async ({ teamId, monitorId, dateRange }) => {
await this.verifyTeamAccess({ teamId, monitorId });
const monitor = await this.db.monitorModule.getHardwareDetailsById({ monitorId, dateRange });
return monitor;
};
getMonitorById = async ({ teamId, monitorId }) => {
await this.verifyTeamAccess({ teamId, monitorId });
const monitor = await this.db.monitorModule.getMonitorById(monitorId);
return monitor;
};
createMonitor = async ({ teamId, userId, body }) => {
const monitor = await this.db.monitorModule.createMonitor({
body,
teamId,
userId,
});
this.jobQueue.addJob(monitor._id, monitor);
};
createBulkMonitors = async ({ fileData, userId, teamId }) => {
const { parse } = this.papaparse;
return new Promise((resolve, reject) => {
parse(fileData, {
header: true,
skipEmptyLines: true,
transform: (value, header) => {
if (value === "") return undefined; // Empty fields become undefined
// Handle 'port' and 'interval' fields, check if they're valid numbers
if (["port", "interval"].includes(header)) {
const num = parseInt(value, 10);
if (isNaN(num)) {
throw this.errorService.createBadRequestError(`${header} should be a valid number, got: ${value}`);
}
return num;
}
return value;
},
complete: async ({ data, errors }) => {
try {
if (errors.length > 0) {
throw this.errorService.createServerError("Error parsing CSV");
}
if (!data || data.length === 0) {
throw this.errorService.createServerError("CSV file contains no data rows");
}
const enrichedData = data.map((monitor) => ({
userId,
teamId,
...monitor,
description: monitor.description || monitor.name || monitor.url,
name: monitor.name || monitor.url,
type: monitor.type || "http",
}));
await createMonitorsBodyValidation.validateAsync(enrichedData);
const monitors = await this.db.monitorModule.createBulkMonitors(enrichedData);
await Promise.all(
monitors.map(async (monitor) => {
this.jobQueue.addJob(monitor._id, monitor);
})
);
resolve(monitors);
} catch (error) {
reject(error);
}
},
});
});
};
deleteMonitor = async ({ teamId, monitorId }) => {
await this.verifyTeamAccess({ teamId, monitorId });
const monitor = await this.db.monitorModule.deleteMonitor({ teamId, monitorId });
await this.jobQueue.deleteJob(monitor);
await this.db.statusPageModule.deleteStatusPagesByMonitorId(monitor._id);
return monitor;
};
deleteAllMonitors = async ({ teamId }) => {
const { monitors, deletedCount } = await this.db.monitorModule.deleteAllMonitors(teamId);
await Promise.all(
monitors.map(async (monitor) => {
try {
await this.jobQueue.deleteJob(monitor);
await this.db.checkModule.deleteChecks(monitor._id);
await this.db.pageSpeedCheckModule.deletePageSpeedChecksByMonitorId(monitor._id);
await this.db.notificationsModule.deleteNotificationsByMonitorId(monitor._id);
} catch (error) {
this.logger.warn({
message: `Error deleting associated records for monitor ${monitor._id} with name ${monitor.name}`,
service: SERVICE_NAME,
method: "deleteAllMonitors",
stack: error.stack,
});
}
})
);
return deletedCount;
};
editMonitor = async ({ teamId, monitorId, body }) => {
await this.verifyTeamAccess({ teamId, monitorId });
const editedMonitor = await this.db.monitorModule.editMonitor({ monitorId, body });
await this.jobQueue.updateJob(editedMonitor);
};
pauseMonitor = async ({ teamId, monitorId }) => {
await this.verifyTeamAccess({ teamId, monitorId });
const monitor = await this.db.monitorModule.pauseMonitor({ monitorId });
monitor.isActive === true ? await this.jobQueue.resumeJob(monitor._id, monitor) : await this.jobQueue.pauseJob(monitor);
return monitor;
};
addDemoMonitors = async ({ userId, teamId }) => {
const demoMonitors = await this.db.monitorModule.addDemoMonitors(userId, teamId);
await Promise.all(demoMonitors.map((monitor) => this.jobQueue.addJob(monitor._id, monitor)));
return demoMonitors;
};
sendTestEmail = async ({ to }) => {
const subject = this.stringService.testEmailSubject;
const context = { testName: "Monitoring System" };
const html = await this.emailService.buildEmail("testEmailTemplate", context);
const messageId = await this.emailService.sendEmail(to, subject, html);
if (!messageId) {
throw this.errorService.createServerError("Failed to send test email.");
}
return messageId;
};
getMonitorsByTeamId = async ({ teamId, limit, type, page, rowsPerPage, filter, field, order }) => {
const monitors = await this.db.monitorModule.getMonitorsByTeamId({
limit,
type,
page,
rowsPerPage,
filter,
field,
order,
teamId,
});
return monitors;
};
getMonitorsAndSummaryByTeamId = async ({ teamId, type, explain }) => {
const result = await this.db.monitorModule.getMonitorsAndSummaryByTeamId({
type,
explain,
teamId,
});
return result;
};
getMonitorsWithChecksByTeamId = async ({ teamId, limit, type, page, rowsPerPage, filter, field, order, explain }) => {
const count = await this.monitorsRepository.findMonitorCountByTeamIdAndType(teamId, { type, filter });
const monitors = await this.monitorsRepository.findByTeamId(teamId, {
limit,
type,
page,
rowsPerPage,
filter,
field,
order,
});
const monitorIds = monitors?.map((m) => m.id) ?? [];
const checksMap = await this.checksRepository.findLatestChecksByMonitorIds(monitorIds);
const monitorsWithChecks = (monitors ?? []).map((monitor) => {
const checks = NormalizeData(checksMap[monitor.id] ?? [], 10, 100);
return {
...monitor,
checks,
};
});
return { count, monitors: monitorsWithChecks };
};
exportMonitorsToCSV = async ({ teamId }) => {
const monitors = await this.db.monitorModule.getMonitorsByTeamId({ teamId });
if (!monitors || monitors.length === 0) {
throw this.errorService.createNotFoundError("No monitors to export");
}
const csvData = monitors?.filteredMonitors?.map((monitor) => ({
name: monitor.name,
description: monitor.description,
type: monitor.type,
url: monitor.url,
interval: monitor.interval,
port: monitor.port,
ignoreTlsErrors: monitor.ignoreTlsErrors,
isActive: monitor.isActive,
}));
const csv = this.papaparse.unparse(csvData);
return csv;
};
exportMonitorsToJSON = async ({ teamId }) => {
const monitors = await this.db.monitorModule.getMonitorsByTeamId({ teamId });
if (!monitors || monitors.length === 0) {
throw this.errorService.createNotFoundError("No monitors to export");
}
const json = monitors?.filteredMonitors
?.map((monitor) => {
const initialType = monitor.type;
let parsedType;
if (initialType === "hardware") {
parsedType = "infrastructure";
} else if (initialType === "http") {
if (monitor.url.startsWith("https://")) {
parsedType = "https";
} else {
parsedType = "http";
}
} else if (initialType === "pagespeed") {
parsedType = initialType;
} else {
// Skip unsupported types
return;
}
return {
name: monitor.name,
url: monitor.url,
type: parsedType,
interval: monitor.interval,
n: monitor.statusWindowSize,
secret: monitor.secret,
};
})
.filter(Boolean);
return json;
};
getAllGames = () => {
return this.games;
};
getGroupsByTeamId = async ({ teamId }) => {
const groups = await this.db.monitorModule.getGroupsByTeamId({ teamId });
return groups;
};
}
export default MonitorService;
@@ -0,0 +1,566 @@
import { createMonitorsBodyValidation } from "@/validation/joi.js";
import { NormalizeData, NormalizeDataUptimeDetails } from "@/utils/dataUtils.js";
import { type Monitor } from "@/types/index.js";
import type { MonitorType } from "@/types/monitor.js";
import type { IChecksRepository, IMonitorsRepository, IMonitorStatsRepository } from "@/repositories/index.js";
import fs from "fs";
import { fileURLToPath } from "url";
import path from "path";
import { AppError } from "../infrastructure/errorService.js";
const SERVICE_NAME = "MonitorService";
type DateRangeKey = "recent" | "day" | "week" | "month" | "all";
export interface IMonitorService {
readonly serviceName: string;
verifyTeamAccess(args: { teamId: string; monitorId: string }): Promise<void>;
// create
createMonitor(teamId: string, userId: string, body: Monitor): Promise<void>;
createBulkMonitors(fileData: string, userId: string, teamId: string): Promise<any>;
addDemoMonitors(args: { userId: string; teamId: string }): Promise<any[]>;
// read
getUptimeDetailsById(args: { teamId: string; monitorId: string; dateRange: string; normalize?: boolean }): Promise<any>;
getHardwareDetailsById(args: { teamId: string; monitorId: string; dateRange: string }): Promise<any>;
getPageSpeedDetailsById(args: { teamId: string; monitorId: string; dateRange: string }): Promise<any>;
getMonitorById(args: { teamId: string; monitorId: string }): Promise<Monitor>;
getMonitorsByTeamId(args: {
teamId: string;
limit?: number;
type?: MonitorType | MonitorType[];
page?: number;
rowsPerPage?: number;
filter?: string;
field?: string;
order?: "asc" | "desc";
}): Promise<any>;
getMonitorsAndSummaryByTeamId(args: { teamId: string; type?: string | string[]; explain?: boolean }): Promise<any>;
getMonitorsWithChecksByTeamId(args: {
teamId: string;
limit?: number;
type?: MonitorType | MonitorType[];
page?: number;
rowsPerPage?: number;
filter?: string;
field?: string;
order?: "asc" | "desc";
explain?: boolean;
}): Promise<{ count: number; monitors: any[] }>;
getAllGames(): any;
getGroupsByTeamId(args: { teamId: string }): Promise<any[]>;
// update
editMonitor(args: { teamId: string; monitorId: string; body: any }): Promise<void>;
pauseMonitor(args: { teamId: string; monitorId: string }): Promise<any>;
// delete
deleteMonitor(args: { teamId: string; monitorId: string }): Promise<any>;
deleteAllMonitors(args: { teamId: string }): Promise<number>;
// other
sendTestEmail(args: { to: string }): Promise<string>;
exportMonitorsToCSV(args: { teamId: string }): Promise<string>;
exportMonitorsToJSON(args: { teamId: string }): Promise<any[]>;
}
export class MonitorService implements IMonitorService {
static SERVICE_NAME = SERVICE_NAME;
private db: any;
private jobQueue: any;
private stringService: any;
private emailService: any;
private papaparse: any;
private logger: any;
private errorService: any;
private games: any;
private monitorsRepository: IMonitorsRepository;
private checksRepository: IChecksRepository;
private monitorStatsRepository: IMonitorStatsRepository;
constructor({
db,
jobQueue,
stringService,
emailService,
papaparse,
logger,
errorService,
games,
monitorsRepository,
checksRepository,
monitorStatsRepository,
}: {
db: any;
jobQueue: any;
stringService: any;
emailService: any;
papaparse: any;
logger: any;
errorService: any;
games: any;
monitorsRepository: IMonitorsRepository;
checksRepository: IChecksRepository;
monitorStatsRepository: IMonitorStatsRepository;
}) {
this.db = db;
this.jobQueue = jobQueue;
this.stringService = stringService;
this.emailService = emailService;
this.papaparse = papaparse;
this.logger = logger;
this.errorService = errorService;
this.games = games;
this.monitorsRepository = monitorsRepository;
this.checksRepository = checksRepository;
this.monitorStatsRepository = monitorStatsRepository;
}
get serviceName(): string {
return MonitorService.SERVICE_NAME;
}
private getDateRange = (dateRange: DateRangeKey) => {
const startDates = {
recent: new Date(new Date().setHours(new Date().getHours() - 2)),
day: new Date(new Date().setDate(new Date().getDate() - 1)),
week: new Date(new Date().setDate(new Date().getDate() - 7)),
month: new Date(new Date().setMonth(new Date().getMonth() - 1)),
all: new Date(0),
};
return {
start: startDates[dateRange],
end: new Date(),
};
};
private getDateFormat = (dateRange: DateRangeKey): string => {
const formatLookup = {
recent: "%Y-%m-%dT%H:%M:00Z",
day: "%Y-%m-%dT%H:00:00Z",
week: "%Y-%m-%dT00:00:00Z",
month: "%Y-%m-%dT00:00:00Z",
all: "%Y-%m-%dT00:00:00Z",
};
return formatLookup[dateRange];
};
verifyTeamAccess = async ({ teamId, monitorId }: { teamId: string; monitorId: string }): Promise<void> => {
const monitor = await this.db.monitorModule.getMonitorById(monitorId);
if (!monitor?.teamId?.equals(teamId)) {
throw this.errorService.createAuthorizationError();
}
};
createMonitor = async (teamId: string, userId: string, body: Monitor): Promise<void> => {
const monitor = await this.monitorsRepository.create(body, teamId, userId);
if (!monitor) {
throw new AppError("Failed to create monitor", 500);
}
this.jobQueue.addJob(monitor.id, monitor);
};
createBulkMonitors = async (fileData: string, userId: string, teamId: string): Promise<any> => {
const { parse } = this.papaparse;
return new Promise<any>((resolve, reject) => {
parse(fileData, {
header: true,
skipEmptyLines: true,
transform: (value: string, header: string): string | number | undefined => {
if (value === "") return undefined; // Empty fields become undefined
// Handle 'port' and 'interval' fields, check if they're valid numbers
if (["port", "interval"].includes(header)) {
const num = parseInt(value, 10);
if (isNaN(num)) {
throw this.errorService.createBadRequestError(`${header} should be a valid number, got: ${value}`);
}
return num;
}
return value;
},
complete: async ({ data, errors }: { data: any[]; errors: Error[] }): Promise<void> => {
try {
if (errors.length > 0) {
throw this.errorService.createServerError("Error parsing CSV");
}
if (!data || data.length === 0) {
throw this.errorService.createServerError("CSV file contains no data rows");
}
const enrichedData = data.map((monitor: Monitor) => ({
...monitor,
userId,
teamId,
description: monitor.description || monitor.name || monitor.url,
name: monitor.name || monitor.url,
type: monitor.type || "http",
}));
await createMonitorsBodyValidation.validateAsync(enrichedData);
const monitors = await this.monitorsRepository.createBulkMonitors(enrichedData);
await Promise.all(
monitors.map(async (monitor: Monitor) => {
this.jobQueue.addJob(monitor.id, monitor);
})
);
resolve(monitors);
} catch (error) {
reject(error);
}
},
});
});
};
addDemoMonitors = async ({ userId, teamId }: { userId: string; teamId: string }): Promise<any[]> => {
const __filename = fileURLToPath(import.meta.url);
const __dirname = path.dirname(__filename);
const demoMonitorsPath = path.resolve(__dirname, "../../utils/demoMonitors.json");
const demoData = JSON.parse(fs.readFileSync(demoMonitorsPath, "utf8"));
const monitors: Monitor[] = demoData.map((monitor: Monitor) => {
return {
userId,
teamId,
name: monitor.name,
description: monitor.name,
type: "http",
url: monitor.url,
interval: 60000,
};
});
const demoMonitors = await this.monitorsRepository.createBulkMonitors(monitors);
await Promise.all(demoMonitors.map((monitor) => this.jobQueue.addJob(monitor.id, monitor)));
return demoMonitors;
};
getUptimeDetailsById = async ({
teamId,
monitorId,
dateRange,
normalize,
}: {
teamId: string;
monitorId: string;
dateRange: string;
normalize?: boolean;
}): Promise<any> => {
await this.verifyTeamAccess({ teamId, monitorId });
const monitor = await this.monitorsRepository.findById(monitorId, teamId);
if (!monitor) {
throw new AppError({ message: `Monitor with ID ${monitorId} not found.`, status: 404 });
}
const rangeKey = (dateRange as DateRangeKey) ?? "recent";
const { start, end } = this.getDateRange(rangeKey);
const checksData = await this.checksRepository.findDateRangeChecksByMonitor(monitor.id, start, end, this.getDateFormat(rangeKey), {
type: monitor.type,
});
const monitorStats = await this.monitorStatsRepository.findByMonitorId(monitor.id);
if (
checksData.monitorType !== "http" &&
checksData.monitorType !== "ping" &&
checksData.monitorType !== "docker" &&
checksData.monitorType !== "port" &&
checksData.monitorType !== "game"
) {
throw new AppError({ message: `${monitor.type} monitors are not supported for uptime details`, status: 400 });
}
return {
monitorData: {
monitor,
groupedChecks: NormalizeDataUptimeDetails(checksData.groupedChecks, 10, 100),
groupedUpChecks: NormalizeDataUptimeDetails(checksData.groupedUpChecks, 10, 100),
groupedDownChecks: NormalizeDataUptimeDetails(checksData.groupedDownChecks, 10, 100),
groupedAvgResponseTime: checksData.avgResponseTime,
groupedUptimePercentage: checksData.uptimePercentage,
},
monitorStats,
};
};
getHardwareDetailsById = async ({ teamId, monitorId, dateRange }: { teamId: string; monitorId: string; dateRange: string }): Promise<any> => {
await this.verifyTeamAccess({ teamId, monitorId });
const monitor = await this.monitorsRepository.findById(monitorId, teamId);
if (!monitor) {
throw new AppError({ message: `Monitor with ID ${monitorId} not found.`, status: 404 });
}
if (monitor.type !== "hardware") {
throw new AppError({ message: `${monitor.type} monitors are not supported for hardware details`, status: 400 });
}
const rangeKey = (dateRange as DateRangeKey) ?? "recent";
const { start, end } = this.getDateRange(rangeKey);
const checksData = await this.checksRepository.findDateRangeChecksByMonitor(monitor.id, start, end, this.getDateFormat(rangeKey), {
type: monitor.type,
});
if (checksData.monitorType !== "hardware") {
throw new AppError({ message: "Unable to load hardware stats for this monitor", status: 500 });
}
const stats = {
aggregateData: checksData.aggregateData,
upChecks: checksData.upChecks,
checks: checksData.checks,
};
return {
...monitor,
stats,
};
};
getPageSpeedDetailsById = async ({ teamId, monitorId, dateRange }: { teamId: string; monitorId: string; dateRange: string }): Promise<any> => {
await this.verifyTeamAccess({ teamId, monitorId });
const monitor = await this.monitorsRepository.findById(monitorId, teamId);
if (!monitor) {
throw new AppError({ message: `Monitor with ID ${monitorId} not found.`, status: 404 });
}
if (monitor.type !== "pagespeed") {
throw new AppError({ message: `${monitor.type} monitors are not supported for pagespeed details`, status: 400 });
}
const rangeKey = (dateRange as DateRangeKey) ?? "recent";
const { start, end } = this.getDateRange(rangeKey);
const checksData = await this.checksRepository.findDateRangeChecksByMonitor(monitor.id, start, end, this.getDateFormat(rangeKey), {
type: monitor.type,
});
if (checksData.monitorType !== "pagespeed") {
throw new AppError({ message: "Unable to load pagespeed stats for this monitor", status: 500 });
}
return {
...monitor,
checks: checksData.checks,
};
};
getMonitorById = async ({ teamId, monitorId }: { teamId: string; monitorId: string }): Promise<any> => {
await this.verifyTeamAccess({ teamId, monitorId });
const monitor = await this.monitorsRepository.findById(monitorId, teamId);
return monitor;
};
getMonitorsByTeamId = async ({ teamId, type, filter }: { teamId: string; type?: MonitorType | MonitorType[]; filter?: string }): Promise<any> => {
const monitors = await this.monitorsRepository.findByTeamId(teamId, {
type,
filter,
});
return monitors;
};
getMonitorsAndSummaryByTeamId = async ({
teamId,
type,
explain,
}: {
teamId: string;
type?: string | string[];
explain?: boolean;
}): Promise<any> => {
const result = await this.db.monitorModule.getMonitorsAndSummaryByTeamId({
type,
explain,
teamId,
});
return result;
};
getMonitorsWithChecksByTeamId = async ({
teamId,
limit,
type,
page,
rowsPerPage,
filter,
field,
order,
explain,
}: {
teamId: string;
limit?: number;
type?: MonitorType | MonitorType[];
page?: number;
rowsPerPage?: number;
filter?: string;
field?: string;
order?: "asc" | "desc";
explain?: boolean;
}): Promise<{ count: number; monitors: any[] }> => {
const count = await this.monitorsRepository.findMonitorCountByTeamIdAndType(teamId, { type, filter });
const monitors = await this.monitorsRepository.findByTeamId(teamId, {
limit,
type,
page,
rowsPerPage,
filter,
field,
order,
});
const monitorsList = (monitors ?? []) as Monitor[];
const snapshotTypes: MonitorType[] = ["hardware", "pagespeed"];
const requestedTypes = Array.isArray(type) ? type : type ? [type] : [];
const snapshotOnlyRequest =
requestedTypes.length > 0 && requestedTypes.every((requestedType) => snapshotTypes.includes(requestedType as MonitorType));
const limitPerMonitor = snapshotOnlyRequest ? 1 : 25;
const checksMap = await this.checksRepository.findLatestChecksByMonitorIds(
monitorsList.map((monitor) => monitor.id),
{ limitPerMonitor }
);
const monitorsWithChecks = monitorsList.map((monitor: Monitor) => {
const rawChecks = checksMap[monitor.id] ?? [];
const isSnapshotType = snapshotOnlyRequest || snapshotTypes.includes(monitor.type);
const checks = isSnapshotType ? rawChecks.slice(0, 1) : NormalizeData(rawChecks, 10, 100);
return {
...monitor,
checks,
};
});
return { count, monitors: monitorsWithChecks };
};
getAllGames = (): any => {
return this.games;
};
getGroupsByTeamId = async ({ teamId }: { teamId: string }): Promise<any[]> => {
const groups = await this.db.monitorModule.getGroupsByTeamId({ teamId });
return groups;
};
editMonitor = async ({ teamId, monitorId, body }: { teamId: string; monitorId: string; body: any }): Promise<void> => {
await this.verifyTeamAccess({ teamId, monitorId });
const editedMonitor = await this.db.monitorModule.editMonitor({ monitorId, body });
await this.jobQueue.updateJob(editedMonitor);
};
pauseMonitor = async ({ teamId, monitorId }: { teamId: string; monitorId: string }): Promise<any> => {
await this.verifyTeamAccess({ teamId, monitorId });
const monitor = await this.db.monitorModule.pauseMonitor({ monitorId });
monitor.isActive === true ? await this.jobQueue.resumeJob(monitor._id, monitor) : await this.jobQueue.pauseJob(monitor);
return monitor;
};
deleteMonitor = async ({ teamId, monitorId }: { teamId: string; monitorId: string }): Promise<any> => {
await this.verifyTeamAccess({ teamId, monitorId });
const monitor = await this.db.monitorModule.deleteMonitor({ teamId, monitorId });
await this.jobQueue.deleteJob(monitor);
await this.db.statusPageModule.deleteStatusPagesByMonitorId(monitor._id);
return monitor;
};
deleteAllMonitors = async ({ teamId }: { teamId: string }): Promise<number> => {
const { monitors, deletedCount } = await this.monitorsRepository.deleteByTeamId(teamId);
await Promise.all(
monitors.map(async (monitor) => {
try {
await this.jobQueue.deleteJob(monitor);
await this.db.checkModule.deleteChecks(monitor.id);
await this.db.pageSpeedCheckModule.deletePageSpeedChecksByMonitorId(monitor.id);
await this.db.notificationsModule.deleteNotificationsByMonitorId(monitor.id);
} catch (error: any) {
this.logger.warn({
message: `Error deleting associated records for monitor ${monitor.id} with name ${monitor.name}`,
service: SERVICE_NAME,
method: "deleteAllMonitors",
stack: error.stack,
});
}
})
);
return deletedCount;
};
sendTestEmail = async ({ to }: { to: string }): Promise<string> => {
const subject = this.stringService.testEmailSubject;
const context = { testName: "Monitoring System" };
const html = await this.emailService.buildEmail("testEmailTemplate", context);
const messageId = await this.emailService.sendEmail(to, subject, html);
if (!messageId) {
throw this.errorService.createServerError("Failed to send test email.");
}
return messageId;
};
exportMonitorsToCSV = async ({ teamId }: { teamId: string }): Promise<string> => {
const monitors = await this.db.monitorModule.getMonitorsByTeamId({ teamId });
if (!monitors || monitors.length === 0) {
throw this.errorService.createNotFoundError("No monitors to export");
}
const csvData = monitors?.filteredMonitors?.map((monitor: any) => ({
name: monitor.name,
description: monitor.description,
type: monitor.type,
url: monitor.url,
interval: monitor.interval,
port: monitor.port,
ignoreTlsErrors: monitor.ignoreTlsErrors,
isActive: monitor.isActive,
}));
const csv = this.papaparse.unparse(csvData);
return csv;
};
exportMonitorsToJSON = async ({ teamId }: { teamId: string }): Promise<any[]> => {
const monitors = await this.db.monitorModule.getMonitorsByTeamId({ teamId });
if (!monitors || monitors.length === 0) {
throw this.errorService.createNotFoundError("No monitors to export");
}
const json = monitors?.filteredMonitors
?.map((monitor: any) => {
const initialType = monitor.type;
let parsedType;
if (initialType === "hardware") {
parsedType = "infrastructure";
} else if (initialType === "http") {
if (monitor.url.startsWith("https://")) {
parsedType = "https";
} else {
parsedType = "http";
}
} else if (initialType === "pagespeed") {
parsedType = initialType;
} else {
// Skip unsupported types
return;
}
return {
name: monitor.name,
url: monitor.url,
type: parsedType,
interval: monitor.interval,
n: monitor.statusWindowSize,
secret: monitor.secret,
};
})
.filter(Boolean);
return json;
};
}
+1
View File
@@ -0,0 +1 @@
export * from "@/service/business/monitorService.js";
@@ -4,19 +4,20 @@ const SERVICE_NAME = "JobQueue";
class SuperSimpleQueue {
static SERVICE_NAME = SERVICE_NAME;
constructor({ envSettings, db, logger, helper }) {
constructor({ envSettings, db, logger, helper, monitorsRepository }) {
this.envSettings = envSettings;
this.db = db;
this.logger = logger;
this.helper = helper;
this.monitorsRepository = monitorsRepository;
}
get serviceName() {
return SuperSimpleQueue.SERVICE_NAME;
}
static async create({ envSettings, db, logger, helper }) {
const instance = new SuperSimpleQueue({ envSettings, db, logger, helper });
static async create({ envSettings, db, logger, helper, monitorsRepository }) {
const instance = new SuperSimpleQueue({ envSettings, db, logger, helper, monitorsRepository });
await instance.init();
return instance;
}
@@ -33,11 +34,11 @@ class SuperSimpleQueue {
this.scheduler.start();
this.scheduler.addTemplate("monitor-job", this.helper.getMonitorJob());
const monitors = await this.db.monitorModule.getAllMonitors();
const monitors = await this.monitorsRepository.findAll();
for (const monitor of monitors) {
const randomOffset = Math.floor(Math.random() * 100);
setTimeout(() => {
this.addJob(monitor._id, monitor);
this.addJob(monitor.id, monitor);
}, randomOffset);
}
@@ -55,44 +56,44 @@ class SuperSimpleQueue {
addJob = async (monitorId, monitor) => {
this.scheduler.addJob({
id: monitorId.toString(),
id: monitorId,
template: "monitor-job",
repeat: monitor.interval,
active: monitor.isActive,
data: monitor.toObject(),
data: monitor,
});
};
deleteJob = async (monitor) => {
this.scheduler.removeJob(monitor._id.toString());
this.scheduler.removeJob(monitor.id);
};
pauseJob = async (monitor) => {
const result = this.scheduler.pauseJob(monitor._id.toString());
const result = this.scheduler.pauseJob(monitor.id);
if (result === false) {
throw new Error("Failed to resume monitor");
}
this.logger.debug({
message: `Paused monitor ${monitor._id}`,
message: `Paused monitor ${monitor.id}`,
service: SERVICE_NAME,
method: "pauseJob",
});
};
resumeJob = async (monitor) => {
const result = this.scheduler.resumeJob(monitor._id.toString());
const result = this.scheduler.resumeJob(monitor.id);
if (result === false) {
throw new Error("Failed to resume monitor");
}
this.logger.debug({
message: `Resumed monitor ${monitor._id}`,
message: `Resumed monitor ${monitor.id}`,
service: SERVICE_NAME,
method: "resumeJob",
});
};
updateJob = async (monitor) => {
this.scheduler.updateJob(monitor._id.toString(), { repeat: monitor.interval, data: monitor.toObject() });
this.scheduler.updateJob(monitor.id, { repeat: monitor.interval, data: monitor });
};
shutdown = async () => {
@@ -27,7 +27,7 @@ class SuperSimpleQueueHelper {
getMonitorJob = () => {
return async (monitor) => {
try {
const monitorId = monitor._id;
const monitorId = monitor.id;
const teamId = monitor.teamId;
if (!monitorId) {
throw new Error("No monitor id");
@@ -62,7 +62,7 @@ class SuperSimpleQueueHelper {
message: error.message,
service: SERVICE_NAME,
method: "getMonitorJob",
details: `Error sending notifications for job ${monitor._id}: ${error.message}`,
details: `Error sending notifications for job ${monitor.id}: ${error.message}`,
stack: error.stack,
});
});
@@ -95,7 +95,7 @@ class NetworkService {
}
const pingResponse = {
monitorId: monitor._id,
monitorId: monitor.id,
type: "ping",
status: response.alive,
code: 200,
@@ -120,9 +120,9 @@ class NetworkService {
}
async requestHttp(monitor) {
const { url, secret, _id, teamId, type, ignoreTlsErrors, jsonPath, matchMethod, expectedValue } = monitor;
const { url, secret, id, teamId, type, ignoreTlsErrors, jsonPath, matchMethod, expectedValue } = monitor;
const httpResponse = {
monitorId: _id,
monitorId: id,
teamId: teamId,
type,
};
@@ -298,7 +298,7 @@ class NetworkService {
});
const dockerResponse = {
monitorId: monitor._id,
monitorId: monitor.id,
type: monitor.type,
};
@@ -416,7 +416,7 @@ class NetworkService {
code: 200,
status: response.success,
message: this.stringService.portSuccess,
monitorId: monitor._id,
monitorId: monitor.id,
type: monitor.type,
responseTime: responseTime,
};
@@ -444,7 +444,7 @@ class NetworkService {
code: 200,
status: true,
message: "Success",
monitorId: monitor._id,
monitorId: monitor.id,
type: "game",
};
@@ -7,14 +7,19 @@ class StatusService {
/**
* @param {{
* db: any
* logger: any
* buffer: import("./bufferService.js").BufferService
* incidentService: import("../business/incidentService.js").IncidentService
* monitorsRepository: any
* }}
*/ constructor({ db, logger, buffer, incidentService }) {
*/
constructor({ db, logger, buffer, incidentService, monitorsRepository }) {
this.db = db;
this.logger = logger;
this.buffer = buffer;
this.incidentService = incidentService;
this.monitorsRepository = monitorsRepository;
}
get serviceName() {
@@ -23,7 +28,7 @@ class StatusService {
async updateRunningStats({ monitor, networkResponse }) {
try {
const monitorId = monitor._id;
const monitorId = monitor.id;
const { responseTime, status } = networkResponse;
// Get stats
let stats = await MonitorStats.findOne({ monitorId });
@@ -124,7 +129,7 @@ class StatusService {
service: this.SERVICE_NAME,
method: "handleIncidentForCheck",
message: `Failed to save check immediately for ${errorContext}: ${checkError.message}`,
monitorId: monitor._id,
monitorId: monitor.id,
stack: checkError.stack,
});
savedCheck = null;
@@ -139,7 +144,7 @@ class StatusService {
service: this.SERVICE_NAME,
method: "handleIncidentForCheck",
message: `Failed to add incident to buffer for ${errorContext}: ${incidentError.message}`,
monitorId: monitor._id,
monitorId: monitor.id,
action,
stack: incidentError.stack,
});
@@ -150,7 +155,7 @@ class StatusService {
service: this.SERVICE_NAME,
method: "handleIncidentForCheck",
message: `Error in ${errorContext}: ${error.message}`,
monitorId: monitor?._id,
monitorId: monitor?.id,
stack: error.stack,
});
}
@@ -172,7 +177,8 @@ class StatusService {
await this.insertCheck(check);
try {
const { monitorId, status, code } = networkResponse;
const monitor = await this.db.monitorModule.getMonitorById(monitorId);
const monitor = await this.monitorsRepository.findById(monitorId);
// Update running stats
this.updateRunningStats({ monitor, networkResponse });
@@ -198,9 +204,9 @@ class StatusService {
// Return early if not enough data points
if (monitor.statusWindow.length < monitor.statusWindowSize) {
await monitor.save();
const updated = await this.monitorsRepository.update(monitor.id, monitor);
return {
monitor,
monitor: updated,
statusChanged: false,
prevStatus,
code,
@@ -240,14 +246,14 @@ class StatusService {
if (monitor.status === false && !statusChanged) {
try {
const lastManuallyResolvedIncident = await this.db.incidentModule.getLastManuallyResolvedIncident(monitor._id);
const lastManuallyResolvedIncident = await this.db.incidentModule.getLastManuallyResolvedIncident(monitor.id);
let calculatedFailureRate = failureRate;
if (lastManuallyResolvedIncident && lastManuallyResolvedIncident.endTime) {
try {
const checksAfterResolution = await Check.find({
monitorId: monitor._id,
monitorId: monitor.id,
createdAt: { $gt: lastManuallyResolvedIncident.endTime },
})
.sort({ createdAt: 1 })
@@ -267,7 +273,7 @@ class StatusService {
service: this.SERVICE_NAME,
method: "updateStatus",
message: `Failed to query checks after manual resolution: ${checkQueryError.message}`,
monitorId: monitor._id,
monitorId: monitor.id,
stack: checkQueryError.stack,
});
}
@@ -281,17 +287,17 @@ class StatusService {
service: this.SERVICE_NAME,
method: "updateStatus",
message: `Error handling threshold check without status change: ${error.message}`,
monitorId: monitor._id,
monitorId: monitor.id,
stack: error.stack,
});
}
}
monitor.status = newStatus;
await monitor.save();
const updated = await this.monitorsRepository.update(monitor.id, monitor);
return {
monitor,
monitor: updated,
statusChanged,
prevStatus,
code,
+1
View File
@@ -1,2 +1,3 @@
export * from "@/types/check.js";
export * from "@/types/monitor.js";
export * from "@/types/monitorStats.js";
+14
View File
@@ -0,0 +1,14 @@
export interface MonitorStats {
id: string;
monitorId: string;
avgResponseTime: number;
totalChecks: number;
totalUpChecks: number;
totalDownChecks: number;
uptimePercentage: number;
lastCheckTimestamp: number;
lastResponseTime: number;
timeOfLastFailure?: number;
createdAt: string;
updatedAt: string;
}
+17 -18
View File
@@ -120,30 +120,30 @@ const getMonitorByIdQueryValidation = joi.object({
const getMonitorsByTeamIdParamValidation = joi.object({});
const getMonitorsByTeamIdQueryValidation = joi.object({
limit: joi.number(),
type: joi
.alternatives()
.try(
joi.string().valid("http", "ping", "pagespeed", "docker", "hardware", "port", "game"),
joi.array().items(joi.string().valid("http", "ping", "pagespeed", "docker", "hardware", "port", "game"))
),
page: joi.number(),
rowsPerPage: joi.number(),
filter: joi.string(),
field: joi.string(),
order: joi.string().valid("asc", "desc"),
filter: joi.string().allow("", null),
});
const getMonitorStatsByIdParamValidation = joi.object({
monitorId: joi.string().required(),
});
const getMonitorStatsByIdQueryValidation = joi.object({
status: joi.string(),
limit: joi.number(),
sortOrder: joi.string().valid("asc", "desc"),
dateRange: joi.string().valid("hour", "day", "week", "month", "all"),
numToDisplay: joi.number(),
normalize: joi.boolean(),
const getMonitorsWithChecksQueryValidation = joi.object({
limit: joi.number().integer().min(1).max(100).optional(),
page: joi.number().integer().min(0).optional(),
rowsPerPage: joi.number().integer().min(1).max(100).optional(),
filter: joi.string().allow("", null).optional(),
field: joi.string().optional(),
order: joi.string().valid("asc", "desc").optional(),
type: joi
.alternatives()
.try(
joi.string().valid("http", "ping", "pagespeed", "docker", "hardware", "port", "game"),
joi.array().items(joi.string().valid("http", "ping", "pagespeed", "docker", "hardware", "port", "game"))
)
.optional(),
explain: joi.boolean().optional(),
});
const getCertificateParamValidation = joi.object({
@@ -725,8 +725,7 @@ export {
getMonitorByIdQueryValidation,
getMonitorsByTeamIdParamValidation,
getMonitorsByTeamIdQueryValidation,
getMonitorStatsByIdParamValidation,
getMonitorStatsByIdQueryValidation,
getMonitorsWithChecksQueryValidation,
getHardwareDetailsByIdParamValidation,
getHardwareDetailsByIdQueryValidation,
getCertificateParamValidation,
+217
View File
@@ -0,0 +1,217 @@
import { jest } from "@jest/globals";
import { MonitorService } from "../src/service/business/monitorService.ts";
import type { IMonitorsRepository, IChecksRepository } from "../src/repositories/index.ts";
const createMonitorsRepositoryMock = () =>
({
findMonitorCountByTeamIdAndType: jest.fn(),
findByTeamId: jest.fn(),
findById: jest.fn(),
create: jest.fn(),
createBulkMonitors: jest.fn(),
deleteByTeamId: jest.fn(),
}) as unknown as IMonitorsRepository;
const createChecksRepositoryMock = () =>
({
findLatestChecksByMonitorIds: jest.fn(),
findDateRangeChecksByMonitor: jest.fn(),
}) as unknown as IChecksRepository;
const createService = ({
monitorsRepository = createMonitorsRepositoryMock(),
checksRepository = createChecksRepositoryMock(),
monitorStatsRepository = { findByMonitorId: jest.fn() },
monitorModuleOverrides = {},
}: {
monitorsRepository?: IMonitorsRepository;
checksRepository?: IChecksRepository;
monitorStatsRepository?: { findByMonitorId: jest.Mock };
monitorModuleOverrides?: Record<string, unknown>;
} = {}) => {
const monitorModule = {
getMonitorById: jest.fn().mockResolvedValue({ teamId: { equals: () => true } }),
getMonitorStatsById: jest.fn().mockResolvedValue({ latest: {} }),
getMonitorsByTeamId: jest.fn().mockResolvedValue([]),
getMonitorsAndSummaryByTeamId: jest.fn().mockResolvedValue({ monitors: [], summary: {} }),
...monitorModuleOverrides,
};
return new MonitorService({
db: {
monitorModule,
statusPageModule: { deleteStatusPagesByMonitorId: jest.fn() },
checkModule: { deleteChecks: jest.fn() },
pageSpeedCheckModule: { deletePageSpeedChecksByMonitorId: jest.fn() },
notificationsModule: { deleteNotificationsByMonitorId: jest.fn() },
},
jobQueue: {
addJob: jest.fn(),
updateJob: jest.fn(),
resumeJob: jest.fn(),
pauseJob: jest.fn(),
deleteJob: jest.fn(),
},
stringService: {},
emailService: { buildEmail: jest.fn(), sendEmail: jest.fn() },
papaparse: { parse: jest.fn(), unparse: jest.fn() },
logger: { info: jest.fn(), error: jest.fn(), warn: jest.fn() },
errorService: {
createAuthorizationError: jest.fn(() => new Error("unauthorized")),
createServerError: jest.fn(() => new Error("server")),
createBadRequestError: jest.fn(() => new Error("bad request")),
createNotFoundError: jest.fn(() => new Error("not found")),
},
games: [],
monitorsRepository,
checksRepository,
monitorStatsRepository,
});
};
describe("MonitorService", () => {
describe("getMonitorsWithChecksByTeamId", () => {
it("returns monitors enriched with normalized checks", async () => {
const monitorsRepository = createMonitorsRepositoryMock();
(monitorsRepository.findMonitorCountByTeamIdAndType as jest.Mock).mockResolvedValue(2);
(monitorsRepository.findByTeamId as jest.Mock).mockResolvedValue([
{ id: "m1", name: "Monitor 1", interval: 60000 },
{ id: "m2", name: "Monitor 2", interval: 60000 },
]);
const checksRepository = createChecksRepositoryMock();
(checksRepository.findLatestChecksByMonitorIds as jest.Mock).mockResolvedValue({
m1: [
{ responseTime: 10, status: true, message: "OK" },
{ responseTime: 20, status: true, message: "OK" },
],
m2: [{ responseTime: 50, status: true, message: "OK" }],
});
const service = createService({ monitorsRepository, checksRepository });
const result = await service.getMonitorsWithChecksByTeamId({ teamId: "team" });
expect(result).toMatchObject({ count: 2 });
expect(result.monitors).toHaveLength(2);
expect(result.monitors[0]).toHaveProperty("checks");
expect(result.monitors[0].checks.length).toBeGreaterThan(0);
expect(result.monitors[0].checks[0]).toEqual(
expect.objectContaining({
responseTime: expect.any(Number),
status: expect.any(Boolean),
message: expect.any(String),
})
);
});
});
describe("getMonitorsByTeamId", () => {
it("returns monitors array from db module", async () => {
const monitorsPayload = [
{ id: "m1", name: "Monitor 1" },
{ id: "m2", name: "Monitor 2" },
];
const monitorModuleOverrides = {
getMonitorsByTeamId: jest.fn().mockResolvedValue(monitorsPayload),
};
const service = createService({ monitorModuleOverrides });
const result = await service.getMonitorsByTeamId({ teamId: "team" } as any);
expect(result).toHaveLength(2);
expect(result[0]).toHaveProperty("id", "m1");
});
});
describe("getMonitorsAndSummaryByTeamId", () => {
it("returns monitors with summary block", async () => {
const monitorModuleOverrides = {
getMonitorsAndSummaryByTeamId: jest.fn().mockResolvedValue({ monitors: [{ id: "m1" }], summary: { total: 1, uptime: 0.99 } }),
};
const service = createService({ monitorModuleOverrides });
const result = await service.getMonitorsAndSummaryByTeamId({ teamId: "team" });
expect(result).toEqual({ monitors: [{ id: "m1" }], summary: { total: 1, uptime: 0.99 } });
});
});
describe("getUptimeDetailsById", () => {
it("returns monitorData and monitorStats with expected shape", async () => {
const TEAM_ID = "team";
const monitor = {
id: "monitor-1",
teamId: TEAM_ID,
name: "Hardware monitor",
interval: 60000,
statusWindow: [],
statusWindowSize: 5,
statusWindowThreshold: 60,
type: "http",
ignoreTlsErrors: false,
url: "https://example.com",
isActive: true,
alertThreshold: 5,
cpuAlertThreshold: 5,
memoryAlertThreshold: 5,
diskAlertThreshold: 5,
tempAlertThreshold: 5,
selectedDisks: [],
notifications: [],
group: null,
createdAt: new Date().toISOString(),
updatedAt: new Date().toISOString(),
};
const monitorsRepository = createMonitorsRepositoryMock();
(monitorsRepository.findById as jest.Mock).mockResolvedValue(monitor);
const checksRepository = createChecksRepositoryMock();
(checksRepository.findDateRangeChecksByMonitor as jest.Mock).mockResolvedValue({
monitorType: "uptime",
groupedChecks: [{ _id: "2024-01-01", avgResponseTime: 100, totalChecks: 2 }],
groupedUpChecks: [{ _id: "2024-01-01", totalChecks: 2, avgResponseTime: 90 }],
groupedDownChecks: [{ _id: "2024-01-01", totalChecks: 0, avgResponseTime: 0 }],
uptimePercentage: 0.99,
avgResponseTime: 95,
});
const monitorStatsRepository = {
findByMonitorId: jest.fn().mockResolvedValue({
id: "stats-1",
monitorId: monitor.id,
avgResponseTime: 90,
totalChecks: 10,
totalUpChecks: 9,
totalDownChecks: 1,
uptimePercentage: 0.9,
lastCheckTimestamp: 123456789,
lastResponseTime: 80,
timeOfLastFailure: 123456700,
createdAt: new Date().toISOString(),
updatedAt: new Date().toISOString(),
}),
} as any;
const monitorModuleOverrides = {
getMonitorById: jest.fn().mockResolvedValue({ teamId: { equals: (value: string) => value === TEAM_ID } }),
};
const service = createService({ monitorsRepository, checksRepository, monitorModuleOverrides, monitorStatsRepository });
const result = await service.getUptimeDetailsById({ teamId: TEAM_ID, monitorId: "monitor-1", dateRange: "recent" });
expect(result).toHaveProperty("monitorData");
expect(result.monitorData.monitor).toMatchObject({ id: monitor.id, name: monitor.name });
expect(result.monitorData.groupedChecks[0]).toEqual(
expect.objectContaining({
_id: expect.any(String),
avgResponseTime: expect.any(Number),
totalChecks: expect.any(Number),
})
);
expect(result.monitorStats).toEqual(
expect.objectContaining({
monitorId: monitor.id,
avgResponseTime: 90,
totalChecks: 10,
totalUpChecks: 9,
totalDownChecks: 1,
})
);
});
});
});
+9
View File
@@ -0,0 +1,9 @@
{
"extends": "./tsconfig.json",
"compilerOptions": {
"rootDir": ".",
"types": ["jest"],
"noEmit": true
},
"include": ["src", "test"]
}
+2 -1
View File
@@ -12,7 +12,8 @@
"module": "nodenext",
"moduleResolution": "nodenext",
"skipLibCheck": true,
"noUncheckedIndexedAccess": true
"noUncheckedIndexedAccess": true,
"isolatedModules": true
},
"include": ["src"],
"exclude": ["tests", "dist", "node_modules"]