diff --git a/Client/package-lock.json b/Client/package-lock.json index e9ff425ca..b35b53bee 100644 --- a/Client/package-lock.json +++ b/Client/package-lock.json @@ -15,8 +15,8 @@ "@mui/lab": "^5.0.0-alpha.170", "@mui/material": "6.1.8", "@mui/x-charts": "^7.5.1", - "@mui/x-data-grid": "7.22.2", - "@mui/x-date-pickers": "7.22.2", + "@mui/x-data-grid": "7.22.3", + "@mui/x-date-pickers": "7.22.3", "@reduxjs/toolkit": "2.3.0", "axios": "^1.7.4", "chart.js": "^4.4.3", @@ -362,16 +362,16 @@ } }, "node_modules/@emotion/babel-plugin": { - "version": "11.12.0", - "resolved": "https://registry.npmjs.org/@emotion/babel-plugin/-/babel-plugin-11.12.0.tgz", - "integrity": "sha512-y2WQb+oP8Jqvvclh8Q55gLUyb7UFvgv7eJfsj7td5TToBrIUtPay2kMrZi4xjq9qw2vD0ZR5fSho0yqoFgX7Rw==", + "version": "11.13.5", + "resolved": "https://registry.npmjs.org/@emotion/babel-plugin/-/babel-plugin-11.13.5.tgz", + "integrity": "sha512-pxHCpT2ex+0q+HH91/zsdHkw/lXd468DIN2zvfvLtPKLLMo6gQj7oLObq8PhkrxOZb/gGCq03S3Z7PDhS8pduQ==", "license": "MIT", "dependencies": { "@babel/helper-module-imports": "^7.16.7", "@babel/runtime": "^7.18.3", "@emotion/hash": "^0.9.2", "@emotion/memoize": "^0.9.0", - "@emotion/serialize": "^1.2.0", + "@emotion/serialize": "^1.3.3", "babel-plugin-macros": "^3.1.0", "convert-source-map": "^1.5.0", "escape-string-regexp": "^4.0.0", @@ -381,14 +381,14 @@ } }, "node_modules/@emotion/cache": { - "version": "11.13.1", - "resolved": "https://registry.npmjs.org/@emotion/cache/-/cache-11.13.1.tgz", - "integrity": "sha512-iqouYkuEblRcXmylXIwwOodiEK5Ifl7JcX7o6V4jI3iW4mLXX3dmt5xwBtIkJiQEXFAI+pC8X0i67yiPkH9Ucw==", + "version": "11.13.5", + "resolved": "https://registry.npmjs.org/@emotion/cache/-/cache-11.13.5.tgz", + "integrity": "sha512-Z3xbtJ+UcK76eWkagZ1onvn/wAVb1GOMuR15s30Fm2wrMgC7jzpnO2JZXr4eujTTqoQFUrZIw/rT0c6Zzjca1g==", "license": "MIT", "dependencies": { "@emotion/memoize": "^0.9.0", "@emotion/sheet": "^1.4.0", - "@emotion/utils": "^1.4.0", + "@emotion/utils": "^1.4.2", "@emotion/weak-memoize": "^0.4.0", "stylis": "4.2.0" } @@ -415,17 +415,17 @@ "license": "MIT" }, "node_modules/@emotion/react": { - "version": "11.13.3", - "resolved": "https://registry.npmjs.org/@emotion/react/-/react-11.13.3.tgz", - "integrity": "sha512-lIsdU6JNrmYfJ5EbUCf4xW1ovy5wKQ2CkPRM4xogziOxH1nXxBSjpC9YqbFAP7circxMfYp+6x676BqWcEiixg==", + "version": "11.13.5", + "resolved": "https://registry.npmjs.org/@emotion/react/-/react-11.13.5.tgz", + "integrity": "sha512-6zeCUxUH+EPF1s+YF/2hPVODeV/7V07YU5x+2tfuRL8MdW6rv5vb2+CBEGTGwBdux0OIERcOS+RzxeK80k2DsQ==", "license": "MIT", "dependencies": { "@babel/runtime": "^7.18.3", - "@emotion/babel-plugin": "^11.12.0", - "@emotion/cache": "^11.13.0", - "@emotion/serialize": "^1.3.1", + "@emotion/babel-plugin": "^11.13.5", + "@emotion/cache": "^11.13.5", + "@emotion/serialize": "^1.3.3", "@emotion/use-insertion-effect-with-fallbacks": "^1.1.0", - "@emotion/utils": "^1.4.0", + "@emotion/utils": "^1.4.2", "@emotion/weak-memoize": "^0.4.0", "hoist-non-react-statics": "^3.3.1" }, @@ -439,15 +439,15 @@ } }, "node_modules/@emotion/serialize": { - "version": "1.3.2", - "resolved": "https://registry.npmjs.org/@emotion/serialize/-/serialize-1.3.2.tgz", - "integrity": "sha512-grVnMvVPK9yUVE6rkKfAJlYZgo0cu3l9iMC77V7DW6E1DUIrU68pSEXRmFZFOFB1QFo57TncmOcvcbMDWsL4yA==", + "version": "1.3.3", + "resolved": "https://registry.npmjs.org/@emotion/serialize/-/serialize-1.3.3.tgz", + "integrity": "sha512-EISGqt7sSNWHGI76hC7x1CksiXPahbxEOrC5RjmFRJTqLyEK9/9hZvBbiYn70dw4wuwMKiEMCUlR6ZXTSWQqxA==", "license": "MIT", "dependencies": { "@emotion/hash": "^0.9.2", "@emotion/memoize": "^0.9.0", "@emotion/unitless": "^0.10.0", - "@emotion/utils": "^1.4.1", + "@emotion/utils": "^1.4.2", "csstype": "^3.0.2" } }, @@ -458,17 +458,17 @@ "license": "MIT" }, "node_modules/@emotion/styled": { - "version": "11.13.0", - "resolved": "https://registry.npmjs.org/@emotion/styled/-/styled-11.13.0.tgz", - "integrity": "sha512-tkzkY7nQhW/zC4hztlwucpT8QEZ6eUzpXDRhww/Eej4tFfO0FxQYWRyg/c5CCXa4d/f174kqeXYjuQRnhzf6dA==", + "version": "11.13.5", + "resolved": "https://registry.npmjs.org/@emotion/styled/-/styled-11.13.5.tgz", + "integrity": "sha512-gnOQ+nGLPvDXgIx119JqGalys64lhMdnNQA9TMxhDA4K0Hq5+++OE20Zs5GxiCV9r814xQ2K5WmtofSpHVW6BQ==", "license": "MIT", "dependencies": { "@babel/runtime": "^7.18.3", - "@emotion/babel-plugin": "^11.12.0", + "@emotion/babel-plugin": "^11.13.5", "@emotion/is-prop-valid": "^1.3.0", - "@emotion/serialize": "^1.3.0", + "@emotion/serialize": "^1.3.3", "@emotion/use-insertion-effect-with-fallbacks": "^1.1.0", - "@emotion/utils": "^1.4.0" + "@emotion/utils": "^1.4.2" }, "peerDependencies": { "@emotion/react": "^11.0.0-rc.0", @@ -496,9 +496,9 @@ } }, "node_modules/@emotion/utils": { - "version": "1.4.1", - "resolved": "https://registry.npmjs.org/@emotion/utils/-/utils-1.4.1.tgz", - "integrity": "sha512-BymCXzCG3r72VKJxaYVwOXATqXIZ85cuvg0YOUDxMGNrKc1DJRZk8MgV5wyXRyEayIMd4FuXJIUgTBXvDNW5cA==", + "version": "1.4.2", + "resolved": "https://registry.npmjs.org/@emotion/utils/-/utils-1.4.2.tgz", + "integrity": "sha512-3vLclRofFziIa3J2wDh9jjbkUz9qk5Vi3IZ/FSTKViB0k+ef0fPV7dYrUIugbgupYDx7v9ud/SjrtEP8Y4xLoA==", "license": "MIT" }, "node_modules/@emotion/weak-memoize": { @@ -1535,9 +1535,9 @@ } }, "node_modules/@mui/x-charts": { - "version": "7.22.2", - "resolved": "https://registry.npmjs.org/@mui/x-charts/-/x-charts-7.22.2.tgz", - "integrity": "sha512-0Y2du4Ed7gOT53l8vVJ4vKT+Jz4Dh/iHnLy8TtL3+XhbPH9Ndu9Q30WwyyzOn84yt37hSUru/njQ1BWaSvVPHw==", + "version": "7.22.3", + "resolved": "https://registry.npmjs.org/@mui/x-charts/-/x-charts-7.22.3.tgz", + "integrity": "sha512-w23+AwIK86bpNWkuHewyQwOKi1wYbLDzrvUEqvZ9KVYzZvnqpJmbTKideX1pLVgSNt0On8NDXytzCntV48Nobw==", "license": "MIT", "dependencies": { "@babel/runtime": "^7.25.7", @@ -1593,9 +1593,9 @@ } }, "node_modules/@mui/x-data-grid": { - "version": "7.22.2", - "resolved": "https://registry.npmjs.org/@mui/x-data-grid/-/x-data-grid-7.22.2.tgz", - "integrity": "sha512-yfy2s5A6tbajQZiEdsba49T4FYb9F0WPrzbbG30dl1+sIiX4ZRX7ma44UIDGPZrsZv8xkkE+p8qeJxZ7OaMteA==", + "version": "7.22.3", + "resolved": "https://registry.npmjs.org/@mui/x-data-grid/-/x-data-grid-7.22.3.tgz", + "integrity": "sha512-O6kBf6yt/GkOcWjHca5xWN10qBQ/MkITvJmBuIOtX+LH7YtOAriMgD2zkhNbXxHChi7QdEud3bNC3jw5RLRVCA==", "license": "MIT", "dependencies": { "@babel/runtime": "^7.25.7", @@ -1630,9 +1630,9 @@ } }, "node_modules/@mui/x-date-pickers": { - "version": "7.22.2", - "resolved": "https://registry.npmjs.org/@mui/x-date-pickers/-/x-date-pickers-7.22.2.tgz", - "integrity": "sha512-1KHSlIlnSoY3oHm820By8X344pIdGYqPvCCvfVHrEeeIQ/pHdxDD8tjZFWkFl4Jgm9oVFK90fMcqNZAzc+WaCw==", + "version": "7.22.3", + "resolved": "https://registry.npmjs.org/@mui/x-date-pickers/-/x-date-pickers-7.22.3.tgz", + "integrity": "sha512-shNp92IrST5BiVy2f4jbrmRaD32QhyUthjh1Oexvpcn0v6INyuWgxfodoTi5ZCnE5Ue5UVFSs4R9Xre0UbJ5DQ==", "license": "MIT", "dependencies": { "@babel/runtime": "^7.25.7", diff --git a/Client/package.json b/Client/package.json index 43f135f86..14362f890 100644 --- a/Client/package.json +++ b/Client/package.json @@ -18,8 +18,8 @@ "@mui/lab": "^5.0.0-alpha.170", "@mui/material": "6.1.8", "@mui/x-charts": "^7.5.1", - "@mui/x-data-grid": "7.22.2", - "@mui/x-date-pickers": "7.22.2", + "@mui/x-data-grid": "7.22.3", + "@mui/x-date-pickers": "7.22.3", "@reduxjs/toolkit": "2.3.0", "axios": "^1.7.4", "chart.js": "^4.4.3", diff --git a/Client/src/Components/Inputs/Field/index.css b/Client/src/Components/Inputs/Field/index.css index d98803f2b..4b54a565d 100644 --- a/Client/src/Components/Inputs/Field/index.css +++ b/Client/src/Components/Inputs/Field/index.css @@ -2,12 +2,13 @@ min-width: var(--env-var-width-3); } -.field-infrastructure-alert { +.field-infrastructure-alert{ max-width: var(--env-var-width-4); } .field-infrastructure-alert .MuiInputBase-root:has(input) { /* height: var(--env-var-height-2); */ + min-width: unset; } .field h3.MuiTypography-root, diff --git a/Client/src/Components/Inputs/Field/index.jsx b/Client/src/Components/Inputs/Field/index.jsx index a501d78ba..bbc28eac7 100644 --- a/Client/src/Components/Inputs/Field/index.jsx +++ b/Client/src/Components/Inputs/Field/index.jsx @@ -7,23 +7,31 @@ import Visibility from "@mui/icons-material/Visibility"; import "./index.css"; /** + * Field component for rendering various types of input fields with customizable properties + * * @param {Object} props - * @param {string} [props.type] - Type of input field (e.g., 'text', 'password'). - * @param {string} props.id - ID of the input field. - * @param {string} props.name - Name of the input field. - * @param {string} [props.label] - Label for the input field. - * @param {boolean} [props.https] - Indicates if it should display http or https. - * @param {boolean} [props.isRequired] - Indicates if the field is required, will display a red asterisk. - * @param {boolean} [props.isOptional] - Indicates if the field is optional, will display optional text. - * @param {string} [props.optionalLabel] - Optional label for the input field. - * @param {string} [props.autoComplete] - Autocomplete value for the input field. + * @param {string} [props.type='text'] - Type of input field (text, password, url, email, description, number). + * @param {string} props.id - Unique identifier for the input field. + * @param {string} props.name - Name attribute for the input field. + * @param {string} [props.label] - Label text displayed above the input field. + * @param {boolean} [props.https=true] - For URL type, determines whether to show https:// or http://. + * @param {boolean} [props.isRequired=false] - Displays a red asterisk if the field is required. + * @param {boolean} [props.isOptional=false] - Displays an optional label next to the field. + * @param {string} [props.optionalLabel='(optional)'] - Custom text for optional label. + * @param {string} [props.autoComplete] - Autocomplete attribute for the input. * @param {string} [props.placeholder] - Placeholder text for the input field. - * @param {string} props.value - Value of the input field. - * @param {function} props.onChange - Function called on input change. - * @param {string} [props.error] - Error message to display for the input field. - * @param {boolean} [props.disabled] - Indicates if the input field is disabled. - * @param {boolean} [props.hidden] - Indicates if the input field is hidden. - * @param {React.Ref} [ref] - Ref forwarded to the underlying `TextField` component. Allows for direct interactions such as focusing. + * @param {string} props.value - Current value of the input field. + * @param {function} props.onChange - Callback function triggered on input value change. + * @param {function} [props.onBlur] - Callback function triggered when input loses focus. + * @param {function} [props.onInput] - Callback function triggered on input event. + * @param {string} [props.error] - Error message to display below the input field. + * @param {boolean} [props.disabled=false] - Disables the input field if true. + * @param {boolean} [props.hidden=false] - Hides the entire input field if true. + * @param {string} [props.className] - Additional CSS class names for the input container. + * @param {boolean} [props.hideErrorText=false] - Hides the error message if true. + * @param {React.Ref} [ref] - Ref forwarded to the underlying TextField component. + * + * @returns {React.ReactElement} Rendered input field component */ const Field = forwardRef( @@ -47,6 +55,7 @@ const Field = forwardRef( disabled, hidden, className, + hideErrorText = false, }, ref ) => { @@ -57,7 +66,7 @@ const Field = forwardRef( return ( { ) )} - - Don't have an account? — - { - navigate("/register"); - }} - sx={{ userSelect: "none" }} - > - Sign Up - - ); }; diff --git a/Client/src/Pages/Incidents/IncidentTable/index.jsx b/Client/src/Pages/Incidents/IncidentTable/index.jsx index 258bc47ff..69eed2ba1 100644 --- a/Client/src/Pages/Incidents/IncidentTable/index.jsx +++ b/Client/src/Pages/Incidents/IncidentTable/index.jsx @@ -39,7 +39,7 @@ const IncidentTable = ({ monitors, selectedMonitor, filter }) => { page: 0, rowsPerPage: 14, }); - const [isLoading, setIsLoading] = useState(true); + const [isLoading, setIsLoading] = useState(false); useEffect(() => { setPaginationController((prevPaginationController) => ({ diff --git a/Client/src/Pages/Infrastructure/CreateMonitor/CustomThreshold/index.jsx b/Client/src/Pages/Infrastructure/CreateMonitor/CustomThreshold/index.jsx index 3cdde1bdb..3b664eb5d 100644 --- a/Client/src/Pages/Infrastructure/CreateMonitor/CustomThreshold/index.jsx +++ b/Client/src/Pages/Infrastructure/CreateMonitor/CustomThreshold/index.jsx @@ -4,9 +4,8 @@ import Checkbox from "../../../../Components/Inputs/Checkbox"; import { useTheme } from "@emotion/react"; import PropTypes from "prop-types"; - /** - * `CustomThreshold` is a functional React component that displays a + * `CustomThreshold` is a functional React component that displays a * group of CheckBox with a label and its correspondant threshold input field. * * @param {{ checkboxId: any; checkboxLabel: any; onCheckboxChange: any; fieldId: any; onFieldChange: any; onFieldBlur: any; alertUnit: any; infrastructureMonitor: any; errors: any; }} param0 @@ -20,64 +19,65 @@ import PropTypes from "prop-types"; * @param {object} param0.infrastructureMonitor the form object of the create infrastrcuture monitor page * @param {object} param0.errors the object that holds all the errors of the form page * @returns A compound React component that renders the custom threshold alert section - * + * */ export const CustomThreshold = ({ - checkboxId, - checkboxLabel, - onCheckboxChange, - fieldId, - onFieldChange, - onFieldBlur, - alertUnit, - infrastructureMonitor, - errors -}) => - { - const theme = useTheme(); - return ( - - - - - - - - {alertUnit} - - - -)} + checkboxId, + checkboxLabel, + onCheckboxChange, + fieldId, + onFieldChange, + onFieldBlur, + alertUnit, + infrastructureMonitor, + errors, +}) => { + const theme = useTheme(); + return ( + + + + + + + + {alertUnit} + + + + ); +}; CustomThreshold.propTypes = { checkboxId: PropTypes.string.isRequired, @@ -89,4 +89,4 @@ CustomThreshold.propTypes = { alertUnit: PropTypes.string.isRequired, infrastructureMonitor: PropTypes.object.isRequired, errors: PropTypes.object.isRequired, -}; \ No newline at end of file +}; diff --git a/Client/src/Pages/Infrastructure/CreateMonitor/index.jsx b/Client/src/Pages/Infrastructure/CreateMonitor/index.jsx index 6a7081d9c..bbcb7b05d 100644 --- a/Client/src/Pages/Infrastructure/CreateMonitor/index.jsx +++ b/Client/src/Pages/Infrastructure/CreateMonitor/index.jsx @@ -132,7 +132,7 @@ const CreateInfrastructureMonitor = () => { Object.keys(form) .filter((k) => k.startsWith(THRESHOLD_FIELD_PREFIX)) .map((k) => { - if (form[k]) thresholds[k] = form[k]; + if (form[k]) thresholds[k] = form[k] / 100; delete form[k]; delete form[k.substring(THRESHOLD_FIELD_PREFIX.length)]; }); @@ -158,6 +158,7 @@ const CreateInfrastructureMonitor = () => { : infrastructureMonitor.name, interval: infrastructureMonitor.interval * MS_PER_MINUTE, }; + delete form.notifications; if (hasValidationErrors(form, infrastructureMonitorValidation, setErrors)) { return; @@ -194,25 +195,6 @@ const CreateInfrastructureMonitor = () => { { _id: 10, name: "10 minutes" }, ]; - const NOTIFY_MULTIPLE_EMAIL_LABEL = ( - - - Also notify via email to multiple addresses (coming soon) - - logger.warn("disabled")} - onBlur={handleBlur} - /> - - You can separate multiple emails with a comma - - - ); - return ( { General settings - Here you can select the URL of the host, together with the type of monitor. + Here you can select the URL of the host, together with the friendly name and + authorization secret to connect to the server agent. @@ -307,15 +290,6 @@ const CreateInfrastructureMonitor = () => { onChange={(e) => handleChange(e)} onBlur={handleBlur} /> - logger.warn("disabled")} - onBlur={handleBlur} - isDisabled={true} - /> @@ -323,8 +297,7 @@ const CreateInfrastructureMonitor = () => { Customize alerts - Send a notification to user(s) When the thresholds exceed a certain number - or percentage. + Send a notification to user(s) when thresholds exceed a specified percentage. diff --git a/Client/src/Pages/Infrastructure/Details/empty.jsx b/Client/src/Pages/Infrastructure/Details/empty.jsx new file mode 100644 index 000000000..4a311233f --- /dev/null +++ b/Client/src/Pages/Infrastructure/Details/empty.jsx @@ -0,0 +1,37 @@ +import { useTheme } from "@emotion/react"; +import PlaceholderLight from "../../../assets/Images/data_placeholder.svg?react"; +import PlaceholderDark from "../../../assets/Images/data_placeholder_dark.svg?react"; +import { Box, Typography, Stack } from "@mui/material"; +import PropTypes from "prop-types"; +import { useSelector } from "react-redux"; +const Empty = ({ styles }) => { + const theme = useTheme(); + const mode = useSelector((state) => state.ui.mode); + return ( + + + {mode === "light" ? : } + + Your infrastructure dashboard will show here + + Hang tight! When we receive data, we'll show it here. Please check back in a few + minutes. + + + + ); +}; + +Empty.propTypes = { + styles: PropTypes.object, + mode: PropTypes.string, +}; + +export default Empty; diff --git a/Client/src/Pages/Infrastructure/Details/index.jsx b/Client/src/Pages/Infrastructure/Details/index.jsx index 4fb547a5a..9fbaae81a 100644 --- a/Client/src/Pages/Infrastructure/Details/index.jsx +++ b/Client/src/Pages/Infrastructure/Details/index.jsx @@ -5,10 +5,14 @@ import { Stack, Box, Typography } from "@mui/material"; import { useTheme } from "@emotion/react"; import CustomGauge from "../../../Components/Charts/CustomGauge"; import AreaChart from "../../../Components/Charts/AreaChart"; +import { useSelector } from "react-redux"; +import { networkService } from "../../../main"; import PulseDot from "../../../Components/Animated/PulseDot"; import useUtils from "../../Monitors/utils"; +import { useNavigate } from "react-router-dom"; +import Empty from "./empty"; +import { logger } from "../../../Utils/Logger"; import { formatDurationRounded, formatDurationSplit } from "../../../Utils/timeUtils"; -import axios from "axios"; import { TzTick, PercentTick, @@ -25,6 +29,7 @@ const TYPOGRAPHY_PADDING = 8; * @returns {number} Converted value in gigabytes */ const formatBytes = (bytes) => { + if (bytes === undefined || bytes === null) return "0 GB"; if (typeof bytes !== "number") return "0 GB"; if (bytes === 0) return "0 GB"; @@ -38,6 +43,22 @@ const formatBytes = (bytes) => { } }; +/** + * Converts a decimal value to a percentage + * + * @function decimalToPercentage + * @param {number} value - Decimal value to convert + * @returns {number} Percentage representation + * + * @example + * decimalToPercentage(0.75) // Returns 75 + * decimalToPercentage(null) // Returns 0 + */ +const decimalToPercentage = (value) => { + if (value === null || value === undefined) return 0; + return value * 100; +}; + /** * Renders a base box with consistent styling * @param {Object} props - Component properties @@ -105,6 +126,7 @@ StatBox.propTypes = { */ const GaugeBox = ({ value, heading, metricOne, valueOne, metricTwo, valueTwo }) => { const theme = useTheme(); + return ( { + const navigate = useNavigate(); const theme = useTheme(); const { monitorId } = useParams(); const navList = [ @@ -167,6 +190,8 @@ const InfrastructureDetails = () => { { name: "details", path: `/infrastructure/${monitorId}` }, ]; const [monitor, setMonitor] = useState(null); + const { authToken } = useSelector((state) => state.auth); + const [dateRange, setDateRange] = useState("all"); const { statusColor, determineState } = useUtils(); // These calculations are needed because ResponsiveContainer // doesn't take padding of parent/siblings into account @@ -183,25 +208,31 @@ const InfrastructureDetails = () => { useEffect(() => { const fetchData = async () => { try { - const response = await axios.get("http://localhost:5000/api/v1/dummy-data", { - headers: { - "Content-Type": "application/json", - "Cache-Control": "no-cache", - }, + const response = await networkService.getStatsByMonitorId({ + authToken: authToken, + monitorId: monitorId, + sortOrder: "asc", + limit: null, + dateRange: dateRange, + numToDisplay: 50, + normalize: false, }); + setMonitor(response.data.data); } catch (error) { - console.error(error); + navigate("/not-found", { replace: true }); + logger.error(error); } }; fetchData(); - }, []); + }, [authToken, monitorId, dateRange]); + const statBoxConfigs = [ { id: 0, heading: "CPU", - subHeading: `${monitor?.checks[0]?.cpu?.physical_core} cores`, + subHeading: `${monitor?.checks[0]?.cpu?.physical_core ?? 0} cores`, }, { id: 1, @@ -224,7 +255,7 @@ const InfrastructureDetails = () => { const gaugeBoxConfigs = [ { type: "memory", - value: monitor?.checks[0]?.memory?.usage_percent * 100, + value: decimalToPercentage(monitor?.checks[0]?.memory?.usage_percent), heading: "Memory Usage", metricOne: "Used", valueOne: formatBytes(monitor?.checks[0]?.memory?.used_bytes), @@ -233,17 +264,17 @@ const InfrastructureDetails = () => { }, { type: "cpu", - value: monitor?.checks[0]?.cpu?.usage_percent * 100, + value: decimalToPercentage(monitor?.checks[0]?.cpu?.usage_percent), heading: "CPU Usage", metricOne: "Cores", - valueOne: monitor?.checks[0]?.cpu?.physical_core, + valueOne: monitor?.checks[0]?.cpu?.physical_core ?? 0, metricTwo: "Frequency", - valueTwo: `${(monitor?.checks[0]?.cpu?.frequency / 1000).toFixed(2)} Ghz`, + valueTwo: `${(monitor?.checks[0]?.cpu?.frequency ?? 0 / 1000).toFixed(2)} Ghz`, }, - ...(monitor?.checks[0]?.disk ?? []).map((disk, idx) => ({ + ...(monitor?.checks?.[0]?.disk ?? []).map((disk, idx) => ({ type: "disk", diskIndex: idx, - value: disk.usage_percent * 100, + value: decimalToPercentage(disk.usage_percent), heading: `Disk${idx} usage`, metricOne: "Used", valueOne: formatBytes(disk.total_bytes - disk.free_bytes), @@ -278,9 +309,9 @@ const InfrastructureDetails = () => { ]; return ( - monitor && ( - - + + + {monitor?.checks?.length > 0 ? ( { ))} - - ) + ) : ( + + )} + ); }; diff --git a/Client/src/Utils/Logger.js b/Client/src/Utils/Logger.js index 177391b99..b22cc0a22 100644 --- a/Client/src/Utils/Logger.js +++ b/Client/src/Utils/Logger.js @@ -44,6 +44,14 @@ class Logger { this.log = NO_OP; return; } + + if (logLevel === "debug") { + this.error = console.error.bind(console); + this.warn = console.warn.bind(console); + this.info = console.info.bind(console); + this.log = console.log.bind(console); + return; + } } cleanup() { diff --git a/Server/db/models/Notification.js b/Server/db/models/Notification.js index 9edb0187a..868324cc2 100644 --- a/Server/db/models/Notification.js +++ b/Server/db/models/Notification.js @@ -16,6 +16,28 @@ const NotificationSchema = mongoose.Schema( phone: { type: String, }, + alertThreshold: { + type: Number, + default: 5, + }, + cpuAlertThreshold: { + type: Number, + default: function () { + return this.alertThreshold; + }, + }, + memoryAlertThreshold: { + type: Number, + default: function () { + return this.alertThreshold; + }, + }, + diskAlertThreshold: { + type: Number, + default: function () { + return this.alertThreshold; + }, + }, }, { timestamps: true, diff --git a/Server/db/mongo/modules/monitorModule.js b/Server/db/mongo/modules/monitorModule.js index 4d355141d..fa72aad21 100644 --- a/Server/db/mongo/modules/monitorModule.js +++ b/Server/db/mongo/modules/monitorModule.js @@ -198,7 +198,7 @@ const getIncidents = (checks) => { /** * Get date range parameters - * @param {string} dateRange - 'day' | 'week' | 'month' + * @param {string} dateRange - 'day' | 'week' | 'month' | 'all' * @returns {Object} Start and end dates */ const getDateRange = (dateRange) => { @@ -206,6 +206,7 @@ const getDateRange = (dateRange) => { 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], diff --git a/Server/package-lock.json b/Server/package-lock.json index 68510c236..fe26d5531 100644 --- a/Server/package-lock.json +++ b/Server/package-lock.json @@ -11,7 +11,7 @@ "dependencies": { "axios": "^1.7.2", "bcrypt": "^5.1.1", - "bullmq": "5.28.0", + "bullmq": "5.29.1", "cors": "^2.8.5", "dockerode": "4.0.2", "dotenv": "^16.4.5", @@ -1217,9 +1217,9 @@ } }, "node_modules/bullmq": { - "version": "5.28.0", - "resolved": "https://registry.npmjs.org/bullmq/-/bullmq-5.28.0.tgz", - "integrity": "sha512-HyYr6bfbBuzfyUvRkP6Ux1Zrv2eZcM4zXMl90Q7oneORp3jKnXYyqjCewJGAoo4PqKyE4TWCcdUnFkdxAzysng==", + "version": "5.29.1", + "resolved": "https://registry.npmjs.org/bullmq/-/bullmq-5.29.1.tgz", + "integrity": "sha512-TZWiwRlPnpaN+Qwh4D8IQf2cYLpkiDX1LbaaWEabc6y37ojIttWOSynxDewpVHyW233LssSIC4+aLMSvAjtpmg==", "license": "MIT", "dependencies": { "cron-parser": "^4.6.0", diff --git a/Server/package.json b/Server/package.json index 44a8947d7..5d55ed058 100644 --- a/Server/package.json +++ b/Server/package.json @@ -14,7 +14,7 @@ "dependencies": { "axios": "^1.7.2", "bcrypt": "^5.1.1", - "bullmq": "5.28.0", + "bullmq": "5.29.1", "cors": "^2.8.5", "dockerode": "4.0.2", "dotenv": "^16.4.5", diff --git a/Server/service/emailService.js b/Server/service/emailService.js index 5977048ef..846b7c6c8 100644 --- a/Server/service/emailService.js +++ b/Server/service/emailService.js @@ -65,7 +65,7 @@ class EmailService { serverIsDownTemplate: this.loadTemplate("serverIsDown"), serverIsUpTemplate: this.loadTemplate("serverIsUp"), passwordResetTemplate: this.loadTemplate("passwordReset"), - thresholdViolatedTemplate: this.loadTemplate("thresholdViolated"), + hardwareIncidentTemplate: this.loadTemplate("hardwareIncident"), }; /** diff --git a/Server/service/jobQueue.js b/Server/service/jobQueue.js index eedeb1a5c..182f0235a 100644 --- a/Server/service/jobQueue.js +++ b/Server/service/jobQueue.js @@ -165,17 +165,12 @@ class JobQueue { // Handle status change const { monitor, statusChanged, prevStatus } = await this.statusService.updateStatus(networkResponse); - - //If status hasn't changed, we're done - if (statusChanged === false) return; - - // if prevStatus is undefined, monitor is resuming, we're done - if (prevStatus === undefined) return; - + // Handle notifications this.notificationService.handleNotifications({ ...networkResponse, monitor, prevStatus, + statusChanged, }); } catch (error) { this.logger.error({ diff --git a/Server/service/notificationService.js b/Server/service/notificationService.js index f464c08a7..25bf5bfa6 100644 --- a/Server/service/notificationService.js +++ b/Server/service/notificationService.js @@ -14,15 +14,34 @@ class NotificationService { } /** - * Sends an email notification based on the network response. + * Sends an email notification for hardware infrastructure alerts * - * @param {Object} networkResponse - The response from the network monitor. - * @param {Object} networkResponse.monitor - The monitor object containing details about the monitored service. - * @param {string} networkResponse.monitor.name - The name of the monitor. - * @param {string} networkResponse.monitor.url - The URL of the monitor. - * @param {boolean} networkResponse.status - The current status of the monitor (true for up, false for down). - * @param {boolean} networkResponse.prevStatus - The previous status of the monitor (true for up, false for down). - * @param {string} address - The email address to send the notification to. + * @async + * @function sendHardwareEmail + * @param {Object} networkResponse - Response object containing monitor information + * @param {string} address - Email address to send the notification to + * @param {Array} [alerts=[]] - List of hardware alerts to include in the email + * @returns {Promise} - Indicates whether email was sent successfully + * @throws {Error} + */ + async sendHardwareEmail(networkResponse, address, alerts = []) { + if (alerts.length === 0) return false; + const { monitor, status, prevStatus } = networkResponse; + const template = "hardwareIncidentTemplate"; + const context = { monitor: monitor.name, url: monitor.url, alerts }; + const subject = `Monitor ${monitor.name} infrastructure alerts`; + this.emailService.buildAndSendEmail(template, context, address, subject); + return true; + } + + /** + * Sends an email notification about monitor status change + * + * @async + * @function sendEmail + * @param {Object} networkResponse - Response object containing monitor status information + * @param {string} address - Email address to send the notification to + * @returns {Promise} - Indicates email was sent successfully */ async sendEmail(networkResponse, address) { const { monitor, status, prevStatus } = networkResponse; @@ -30,25 +49,133 @@ class NotificationService { const context = { monitor: monitor.name, url: monitor.url }; const subject = `Monitor ${monitor.name} is ${status === true ? "up" : "down"}`; this.emailService.buildAndSendEmail(template, context, address, subject); + return true; } - /** - * Handles notifications based on the network response. - * - * @param {Object} networkResponse - The response from the network monitor. - * @param {string} networkResponse.monitorId - The ID of the monitor. - */ - async handleNotifications(networkResponse) { + async handleStatusNotifications(networkResponse) { try { + //If status hasn't changed, we're done + if (networkResponse.statusChanged === false) return false; + + // if prevStatus is undefined, monitor is resuming, we're done + if (networkResponse.prevStatus === undefined) return false; const notifications = await this.db.getNotificationsByMonitorId( networkResponse.monitorId ); + for (const notification of notifications) { if (notification.type === "email") { this.sendEmail(networkResponse, notification.address); } // Handle other types of notifications here } + return true; + } catch (error) { + this.logger.warn({ + message: error.message, + service: this.SERVICE_NAME, + method: "handleNotifications", + stack: error.stack, + }); + } + } + /** + * Handles status change notifications for a monitor + * + * @async + * @function handleStatusNotifications + * @param {Object} networkResponse - Response object containing monitor status information + * @returns {Promise} - Indicates whether notifications were processed + * @throws {Error} + */ + async handleHardwareNotifications(networkResponse) { + const thresholds = networkResponse?.monitor?.thresholds; + if (thresholds === undefined) return false; // No thresholds set, we're done + + // Get thresholds from monitor + const { + usage_cpu: cpuThreshold = -1, + usage_memory: memoryThreshold = -1, + usage_disk: diskThreshold = -1, + } = thresholds; + + // Get metrics from response + const metrics = networkResponse?.payload?.data ?? null; + if (metrics === null) return false; + + const { + cpu: { usage_percent: cpuUsage = -1 } = {}, + memory: { usage_percent: memoryUsage = -1 } = {}, + disk = [], + } = metrics; + + const alerts = { + cpu: cpuThreshold !== -1 && cpuUsage > cpuThreshold ? true : false, + memory: memoryThreshold !== -1 && memoryUsage > memoryThreshold ? true : false, + disk: disk.some((d) => diskThreshold !== -1 && d.usage_percent > diskThreshold) + ? true + : false, + }; + + const notifications = await this.db.getNotificationsByMonitorId( + networkResponse.monitorId + ); + for (const notification of notifications) { + const alertsToSend = []; + const alertTypes = ["cpu", "memory", "disk"]; + + for (const type of alertTypes) { + // Iterate over each alert type to see if any need to be decremented + if (alerts[type] === true) { + notification[`${type}AlertThreshold`]--; // Decrement threshold if an alert is triggered + + if (notification[`${type}AlertThreshold`] <= 0) { + // If threshold drops below 0, reset and send notification + notification[`${type}AlertThreshold`] = notification.alertThreshold; + + const formatAlert = { + cpu: () => + `Your current CPU usage (${(cpuUsage * 100).toFixed(0)}%) is above your threshold (${(cpuThreshold * 100).toFixed(0)}%)`, + memory: () => + `Your current memory usage (${(memoryUsage * 100).toFixed(0)}%) is above your threshold (${(memoryThreshold * 100).toFixed(0)}%)`, + disk: () => + `Your current disk usage: ${disk + .map((d, idx) => `(Disk${idx}: ${(d.usage_percent * 100).toFixed(0)}%)`) + .join( + ", " + )} is above your threshold (${(diskThreshold * 100).toFixed(0)}%)`, + }; + alertsToSend.push(formatAlert[type]()); + } + } + } + + await notification.save(); + + if (alertsToSend.length === 0) continue; // No alerts to send, we're done + + if (notification.type === "email") { + this.sendHardwareEmail(networkResponse, notification.address, alertsToSend); + } + } + return true; + } + + /** + * Handles notifications for different monitor types + * + * @async + * @function handleNotifications + * @param {Object} networkResponse - Response object containing monitor information + * @returns {Promise} - Indicates whether notifications were processed successfully + */ + async handleNotifications(networkResponse) { + try { + if (networkResponse.monitor.type === "hardware") { + this.handleHardwareNotifications(networkResponse); + } + this.handleStatusNotifications(networkResponse); + return true; } catch (error) { this.logger.warn({ message: error.message, diff --git a/Server/service/statusService.js b/Server/service/statusService.js index 4f7c1a4ce..251f4e391 100644 --- a/Server/service/statusService.js +++ b/Server/service/statusService.js @@ -11,6 +11,11 @@ class StatusService { this.SERVICE_NAME = "StatusService"; } + getStatusString = (status) => { + if (status === true) return "up"; + if (status === false) return "down"; + return "unknown"; + }; /** * Updates the status of a monitor based on the network response. * @@ -29,12 +34,13 @@ class StatusService { const { monitorId, status } = networkResponse; const monitor = await this.db.getMonitorById(monitorId); // No change in monitor status, return early - if (monitor.status === status) return { statusChanged: false }; + if (monitor.status === status) + return { monitor, statusChanged: false, prevStatus: monitor.status }; // Monitor status changed, save prev status and update monitor this.logger.info({ service: this.SERVICE_NAME, - message: `${monitor.name} went from ${monitor.status === true ? "up" : "down"} to ${status === true ? "up" : "down"}`, + message: `${monitor.name} went from ${this.getStatusString(monitor.status)} to ${this.getStatusString(status)}`, prevStatus: monitor.status, newStatus: status, }); @@ -103,7 +109,7 @@ class StatusService { if (type === "hardware") { const { cpu, memory, disk, host } = payload?.data ?? {}; - const { errors } = payload; + const { errors } = payload?.errors ?? []; check.cpu = cpu ?? {}; check.memory = memory ?? {}; check.disk = disk ?? {}; diff --git a/Server/templates/hardwareIncident.mjml b/Server/templates/hardwareIncident.mjml new file mode 100644 index 000000000..e27933fb9 --- /dev/null +++ b/Server/templates/hardwareIncident.mjml @@ -0,0 +1,43 @@ + + + + + + + + + + + + + + Message from BlueWave Infrastructure Monitoring + + + + + Infrastructure Alerts + + + + + + + + Hello {{name}}! + {{monitor}} at {{url}} has the following infrastructure alerts: + {{#each alerts}} + • {{this}} + {{/each}} + + + + + View Infrastructure Details + + This email was sent by BlueWave Infrastructure Monitoring. + + + + + \ No newline at end of file diff --git a/Server/templates/thresholdViolated.mjml b/Server/templates/thresholdViolated.mjml deleted file mode 100644 index a3d4344e4..000000000 --- a/Server/templates/thresholdViolated.mjml +++ /dev/null @@ -1,40 +0,0 @@ - - - - - - - - - - - - - - Message from BlueWave Uptime Service - - - - - {{message}} - - - {{#if cpu}} - {{cpu}} - {{/if}} - - - {{#if disk}} - {{disk}} - {{/if}} - - - {{#if memory}} - {{memory}} - {{/if}} - - - - - - \ No newline at end of file diff --git a/Server/tests/services/notificationService.test.js b/Server/tests/services/notificationService.test.js index b33bd21ed..ccfdd22ae 100644 --- a/Server/tests/services/notificationService.test.js +++ b/Server/tests/services/notificationService.test.js @@ -78,11 +78,23 @@ describe("NotificationService", () => { describe("handleNotifications", async () => { it("should handle notifications based on the network response", async () => { notificationService.sendEmail = sinon.stub(); - notificationService.db.getNotificationsByMonitorId.resolves([ - { type: "email", address: "www.google.com" }, - ]); - await notificationService.handleNotifications({ monitorId: "123" }); - expect(notificationService.sendEmail.calledOnce).to.be.true; + const res = await notificationService.handleNotifications({ + monitor: { + type: "email", + address: "www.google.com", + }, + }); + expect(res).to.be.true; + }); + it("should handle hardware notifications", async () => { + notificationService.sendEmail = sinon.stub(); + const res = await notificationService.handleNotifications({ + monitor: { + type: "hardware", + address: "www.google.com", + }, + }); + expect(res).to.be.true; }); it("should handle an error when getting notifications", async () => { @@ -92,4 +104,184 @@ describe("NotificationService", () => { expect(notificationService.logger.warn.calledOnce).to.be.true; }); }); + + describe("sendHardwareEmail", async () => { + let networkResponse, address, alerts; + beforeEach(() => { + networkResponse = { + monitor: { + name: "Test Monitor", + url: "http://test.com", + }, + status: true, + prevStatus: false, + }; + address = "test@test.com"; + alerts = ["test"]; + }); + + afterEach(() => { + sinon.restore(); + }); + it("should send an email notification with Hardware Template", async () => { + emailService.buildAndSendEmail.resolves(true); + const res = await notificationService.sendHardwareEmail( + networkResponse, + address, + alerts + ); + expect(res).to.be.true; + }); + it("should return false if no alerts are provided", async () => { + alerts = []; + emailService.buildAndSendEmail.resolves(true); + const res = await notificationService.sendHardwareEmail( + networkResponse, + address, + alerts + ); + expect(res).to.be.false; + }); + }); + describe("handleStatusNotifications", async () => { + let networkResponse; + beforeEach(() => { + networkResponse = { + monitor: { + name: "Test Monitor", + url: "http://test.com", + }, + statusChanged: true, + status: true, + prevStatus: false, + }; + }); + + afterEach(() => { + sinon.restore(); + }); + + it("should handle status notifications", async () => { + db.getNotificationsByMonitorId.resolves([ + { type: "email", address: "test@test.com" }, + ]); + const res = await notificationService.handleStatusNotifications(networkResponse); + expect(res).to.be.true; + }); + it("should return false if status hasn't changed", async () => { + networkResponse.statusChanged = false; + const res = await notificationService.handleStatusNotifications(networkResponse); + expect(res).to.be.false; + }); + it("should return false if prevStatus is undefined", async () => { + networkResponse.prevStatus = undefined; + const res = await notificationService.handleStatusNotifications(networkResponse); + expect(res).to.be.false; + }); + it("should handle an error", async () => { + const testError = new Error("Test Error"); + db.getNotificationsByMonitorId.rejects(testError); + try { + await notificationService.handleStatusNotifications(networkResponse); + } catch (error) { + expect(error).to.be.an.instanceOf(Error); + expect(error.message).to.equal("Test Error"); + } + }); + }); + + describe("handleHardwareNotifications", async () => { + let networkResponse; + beforeEach(() => { + networkResponse = { + monitor: { + name: "Test Monitor", + url: "http://test.com", + thresholds: { + usage_cpu: 1, + usage_memory: 1, + usage_disk: 1, + }, + }, + payload: { + data: { + cpu: { + usage_percent: 0.655, + }, + memory: { + usage_percent: 0.783, + }, + disk: [ + { + name: "/dev/sda1", + usage_percent: 0.452, + }, + { + name: "/dev/sdb1", + usage_percent: 0.627, + }, + ], + }, + }, + }; + }); + afterEach(() => { + sinon.restore(); + }); + + describe("it should return false if no thresholds are set", () => { + it("should return false if no thresholds are set", async () => { + networkResponse.monitor.thresholds = undefined; + const res = + await notificationService.handleHardwareNotifications(networkResponse); + expect(res).to.be.false; + }); + + it("should return false if metrics are null", async () => { + networkResponse.payload.data = null; + const res = + await notificationService.handleHardwareNotifications(networkResponse); + expect(res).to.be.false; + }); + + it("should return true if request is well formed and thresholds > 0", async () => { + db.getNotificationsByMonitorId.resolves([ + { + type: "email", + address: "test@test.com", + alertThreshold: 1, + cpuAlertThreshold: 1, + memoryAlertThreshold: 1, + diskAlertThreshold: 1, + save: sinon.stub().resolves(), + }, + ]); + const res = + await notificationService.handleHardwareNotifications(networkResponse); + expect(res).to.be.true; + }); + + it("should return true if thresholds are exceeded", async () => { + db.getNotificationsByMonitorId.resolves([ + { + type: "email", + address: "test@test.com", + alertThreshold: 1, + cpuAlertThreshold: 1, + memoryAlertThreshold: 1, + diskAlertThreshold: 1, + save: sinon.stub().resolves(), + }, + ]); + networkResponse.monitor.thresholds = { + usage_cpu: 0.01, + usage_memory: 0.01, + usage_disk: 0.01, + }; + const res = + await notificationService.handleHardwareNotifications(networkResponse); + expect(res).to.be.true; + }); + }); + }); }); diff --git a/Server/tests/services/statusService.test.js b/Server/tests/services/statusService.test.js index 7330ab57f..1ac660e51 100644 --- a/Server/tests/services/statusService.test.js +++ b/Server/tests/services/statusService.test.js @@ -27,6 +27,18 @@ describe("StatusService", () => { }); }); + describe("getStatusString", () => { + it("should return 'up' if status is true", () => { + expect(statusService.getStatusString(true)).to.equal("up"); + }); + it("should return 'down' if status is false", () => { + expect(statusService.getStatusString(false)).to.equal("down"); + }); + it("should return 'unknown' if status is undefined or null", () => { + expect(statusService.getStatusString(undefined)).to.equal("unknown"); + }); + }); + describe("updateStatus", async () => { beforeEach(() => { // statusService.insertCheck = sinon.stub().resolves; diff --git a/Server/validation/joi.js b/Server/validation/joi.js index 82a732686..bc3967657 100644 --- a/Server/validation/joi.js +++ b/Server/validation/joi.js @@ -178,7 +178,7 @@ const getMonitorStatsByIdQueryValidation = joi.object({ status: joi.string(), limit: joi.number(), sortOrder: joi.string().valid("asc", "desc"), - dateRange: joi.string().valid("day", "week", "month"), + dateRange: joi.string().valid("day", "week", "month", "all"), numToDisplay: joi.number(), normalize: joi.boolean(), });
Hello {{name}}!
{{monitor}} at {{url}} has the following infrastructure alerts:
• {{this}}
This email was sent by BlueWave Infrastructure Monitoring.