Merge branch 'develop' into feat/fe/refactor/input

This commit is contained in:
Alexander Holliday
2024-11-24 18:37:59 -08:00
committed by GitHub
24 changed files with 676 additions and 265 deletions
+39 -39
View File
@@ -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",
+2 -2
View File
@@ -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",
+2 -1
View File
@@ -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,
+27 -17
View File
@@ -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 (
<Stack
gap={theme.spacing(2)}
className={`${className ?? `field field-${type}`}`}
className={`field field-${type} ${className}`}
sx={{
"& fieldset": {
borderColor: theme.palette.border.dark,
@@ -190,7 +199,7 @@ const Field = forwardRef(
<Typography
component="span"
className="input-error"
hidden={className ? true : false}
hidden={hideErrorText}
color={theme.palette.error.main}
mt={theme.spacing(2)}
sx={{
@@ -226,6 +235,7 @@ Field.propTypes = {
disabled: PropTypes.bool,
hidden: PropTypes.bool,
className: PropTypes.string,
hideErrorText: PropTypes.bool,
};
export default Field;
-17
View File
@@ -548,23 +548,6 @@ const Login = () => {
)
)}
</Stack>
<Box
textAlign="center"
p={theme.spacing(12)}
>
<Typography display="inline-block">Don&apos;t have an account? </Typography>
<Typography
component="span"
color={theme.palette.primary.main}
ml={theme.spacing(2)}
onClick={() => {
navigate("/register");
}}
sx={{ userSelect: "none" }}
>
Sign Up
</Typography>
</Box>
</Stack>
);
};
@@ -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) => ({
@@ -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 (
<Stack
direction={"row"}
sx={{
width: "50%",
justifyContent: "space-between",
flexWrap: "wrap"
}}
>
<Box>
<Checkbox
id={checkboxId}
label={checkboxLabel}
isChecked={infrastructureMonitor[checkboxId]}
onChange={onCheckboxChange}
/>
</Box>
<Stack
direction={"row"}
sx={{
justifyContent: "flex-end",
}}
>
<Field
type="number"
className="field-infrastructure-alert"
id={fieldId}
value={infrastructureMonitor[fieldId]}
onBlur={onFieldBlur}
onChange={onFieldChange}
error={errors[fieldId]}
disabled={!infrastructureMonitor[checkboxId]}
></Field>
<Typography
component="p"
m={theme.spacing(3)}
>
{alertUnit}
</Typography>
</Stack>
</Stack>
)}
checkboxId,
checkboxLabel,
onCheckboxChange,
fieldId,
onFieldChange,
onFieldBlur,
alertUnit,
infrastructureMonitor,
errors,
}) => {
const theme = useTheme();
return (
<Stack
direction={"row"}
sx={{
width: "50%",
justifyContent: "space-between",
flexWrap: "wrap",
}}
>
<Box>
<Checkbox
id={checkboxId}
label={checkboxLabel}
isChecked={infrastructureMonitor[checkboxId]}
onChange={onCheckboxChange}
/>
</Box>
<Stack
direction={"row"}
sx={{
justifyContent: "flex-end",
}}
>
<Field
type="number"
className="field-infrastructure-alert"
id={fieldId}
value={infrastructureMonitor[fieldId]}
onBlur={onFieldBlur}
onChange={onFieldChange}
error={errors[fieldId]}
disabled={!infrastructureMonitor[checkboxId]}
hideErrorText={true}
></Field>
<Typography
component="p"
m={theme.spacing(3)}
>
{alertUnit}
</Typography>
</Stack>
</Stack>
);
};
CustomThreshold.propTypes = {
checkboxId: PropTypes.string.isRequired,
@@ -89,4 +89,4 @@ CustomThreshold.propTypes = {
alertUnit: PropTypes.string.isRequired,
infrastructureMonitor: PropTypes.object.isRequired,
errors: PropTypes.object.isRequired,
};
};
@@ -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 = (
<Box>
<Typography mb={theme.spacing(4)}>
Also notify via email to multiple addresses (coming soon)
</Typography>
<Field
id="notify-email-list"
type="text"
placeholder="name@gmail.com"
value=""
onChange={() => logger.warn("disabled")}
onBlur={handleBlur}
/>
<Typography mt={theme.spacing(4)}>
You can separate multiple emails with a comma
</Typography>
</Box>
);
return (
<Box className="create-infrastructure-monitor">
<Breadcrumbs
@@ -253,7 +235,8 @@ const CreateInfrastructureMonitor = () => {
<Box>
<Typography component="h2">General settings</Typography>
<Typography component="p">
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.
</Typography>
</Box>
<Stack gap={theme.spacing(15)}>
@@ -307,15 +290,6 @@ const CreateInfrastructureMonitor = () => {
onChange={(e) => handleChange(e)}
onBlur={handleBlur}
/>
<Checkbox
id="notify-email"
label={NOTIFY_MULTIPLE_EMAIL_LABEL}
isChecked={false}
value=""
onChange={() => logger.warn("disabled")}
onBlur={handleBlur}
isDisabled={true}
/>
</Stack>
</ConfigBox>
@@ -323,8 +297,7 @@ const CreateInfrastructureMonitor = () => {
<Box>
<Typography component="h2">Customize alerts</Typography>
<Typography component="p">
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.
</Typography>
</Box>
<Stack gap={theme.spacing(6)}>
@@ -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 (
<Box sx={{ ...styles, marginTop: theme.spacing(24) }}>
<Stack
direction="column"
gap={theme.spacing(8)}
alignItems="center"
>
{mode === "light" ? <PlaceholderLight /> : <PlaceholderDark />}
<Typography variant="h2">Your infrastructure dashboard will show here</Typography>
<Typography
textAlign="center"
color={theme.palette.text.secondary}
>
Hang tight! When we receive data, we'll show it here. Please check back in a few
minutes.
</Typography>
</Stack>
</Box>
);
};
Empty.propTypes = {
styles: PropTypes.object,
mode: PropTypes.string,
};
export default Empty;
@@ -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 (
<BaseBox>
<Stack
@@ -160,6 +182,7 @@ GaugeBox.propTypes = {
* @returns {React.ReactElement} Infrastructure details page component
*/
const InfrastructureDetails = () => {
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 && (
<Box>
<Breadcrumbs list={navList} />
<Box>
<Breadcrumbs list={navList} />
{monitor?.checks?.length > 0 ? (
<Stack
direction="column"
gap={theme.spacing(10)}
@@ -386,8 +417,18 @@ const InfrastructureDetails = () => {
))}
</Stack>
</Stack>
</Box>
)
) : (
<Empty
styles={{
border: 1,
borderColor: theme.palette.border.light,
borderRadius: theme.shape.borderRadius,
backgroundColor: theme.palette.background.main,
p: theme.spacing(30),
}}
/>
)}
</Box>
);
};
+8
View File
@@ -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() {
+22
View File
@@ -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,
+2 -1
View File
@@ -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],
+4 -4
View File
@@ -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",
+1 -1
View File
@@ -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",
+1 -1
View File
@@ -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"),
};
/**
+2 -7
View File
@@ -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({
+142 -15
View File
@@ -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<boolean>} - 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<boolean>} - 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<boolean>} - 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<boolean>} - 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,
+9 -3
View File
@@ -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 ?? {};
+43
View File
@@ -0,0 +1,43 @@
<mjml>
<mj-head>
<mj-font name="Roboto" href="https://fonts.googleapis.com/css?family=Roboto:300,500"></mj-font>
<mj-attributes>
<mj-all font-family="Roboto, Helvetica, sans-serif"></mj-all>
<mj-text font-weight="300" font-size="16px" color="#616161" line-height="24px"></mj-text>
<mj-section padding="0px"></mj-section>
</mj-attributes>
</mj-head>
<mj-body>
<mj-section padding="20px 0">
<mj-column width="100%">
<mj-text align="left" font-size="10px">
Message from BlueWave Infrastructure Monitoring
</mj-text>
</mj-column>
<mj-column width="45%" padding-top="20px">
<mj-text align="center" font-weight="500" padding="0px" font-size="18px" color="red">
Infrastructure Alerts
</mj-text>
<mj-divider border-width="2px" border-color="#616161"></mj-divider>
</mj-column>
</mj-section>
<mj-section>
<mj-column width="100%">
<mj-text>
<p>Hello {{name}}!</p>
<p>{{monitor}} at {{url}} has the following infrastructure alerts:</p>
{{#each alerts}}
<p>• {{this}}</p>
{{/each}}
</mj-text>
</mj-column>
<mj-column width="100%">
<mj-divider border-width="1px" border-color="#E0E0E0"></mj-divider>
<mj-button background-color="#1570EF"> View Infrastructure Details </mj-button>
<mj-text font-size="12px">
<p>This email was sent by BlueWave Infrastructure Monitoring.</p>
</mj-text>
</mj-column>
</mj-section>
</mj-body>
</mjml>
-40
View File
@@ -1,40 +0,0 @@
<mjml>
<mj-head>
<mj-font name="Roboto" href="https://fonts.googleapis.com/css?family=Roboto:300,500"></mj-font>
<mj-attributes>
<mj-all font-family="Roboto, Helvetica, sans-serif"></mj-all>
<mj-text font-weight="300" font-size="16px" color="#616161" line-height="24px"></mj-text>
<mj-section padding="0px"></mj-section>
</mj-attributes>
</mj-head>
<mj-body>
<mj-section padding="20px 0">
<mj-column width="100%">
<mj-text align="left" font-size="10px">
Message from BlueWave Uptime Service
</mj-text>
</mj-column>
<mj-column width="45%" padding-top="20px">
<mj-text font-weight="500" padding="0px" font-size="18px">
{{message}}
</mj-text>
<mj-text font-weight="500" padding="0px" font-size="18px">
{{#if cpu}}
{{cpu}}
{{/if}}
</mj-text>
<mj-text font-weight="500" padding="0px" font-size="18px">
{{#if disk}}
{{disk}}
{{/if}}
</mj-text>
<mj-text font-weight="500" padding="0px" font-size="18px">
{{#if memory}}
{{memory}}
{{/if}}
</mj-text>
</mj-column>
</mj-section>
</mj-body>
</mjml>
@@ -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;
});
});
});
});
@@ -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;
+1 -1
View File
@@ -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(),
});