diff --git a/Client/package-lock.json b/Client/package-lock.json index 095a013f7..38c9d6c7d 100644 --- a/Client/package-lock.json +++ b/Client/package-lock.json @@ -15,8 +15,8 @@ "@mui/lab": "^5.0.0-alpha.170", "@mui/material": "^5.16.7", "@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": { @@ -1400,9 +1400,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", @@ -1458,9 +1458,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", @@ -1495,9 +1495,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 a13ee09ae..6a7d436a3 100644 --- a/Client/package.json +++ b/Client/package.json @@ -18,8 +18,8 @@ "@mui/lab": "^5.0.0-alpha.170", "@mui/material": "^5.16.7", "@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/App.jsx b/Client/src/App.jsx index 151512701..82c8203a8 100644 --- a/Client/src/App.jsx +++ b/Client/src/App.jsx @@ -10,6 +10,7 @@ import Register from "./Pages/Auth/Register/Register"; import Account from "./Pages/Account"; import Monitors from "./Pages/Monitors/Home"; import CreateMonitor from "./Pages/Monitors/CreateMonitor"; +import CreateInfrastructureMonitor from "./Pages/Infrastructure/CreateMonitor"; import Incidents from "./Pages/Incidents"; import Status from "./Pages/Status"; import Integrations from "./Pages/Integrations"; @@ -39,6 +40,7 @@ import { getAppSettings } from "./Features/Settings/settingsSlice"; import { logger } from "./Utils/Logger"; // Import the logger import { networkService } from "./main"; import { Infrastructure } from "./Pages/Infrastructure"; +import InfrastructureDetails from "./Pages/Infrastructure/Details"; function App() { const AdminCheckedRegister = withAdminCheck(Register); const MonitorsWithAdminProp = withAdminProp(Monitors); @@ -48,6 +50,7 @@ function App() { const MaintenanceWithAdminProp = withAdminProp(Maintenance); const SettingsWithAdminProp = withAdminProp(Settings); const AdvancedSettingsWithAdminProp = withAdminProp(AdvancedSettings); + const InfrastructureDetailsWithAdminProp = withAdminProp(InfrastructureDetails); const mode = useSelector((state) => state.ui.mode); const { authToken } = useSelector((state) => state.auth); const dispatch = useDispatch(); @@ -127,6 +130,14 @@ function App() { path="infrastructure" element={} /> + } + /> + } + /> { + * return ( + * } + * yTick={} + * strokeColor="#8884d8" + * fillColor="#8884d8" + * gradient={true} + * gradientStartColor="#8884d8" + * gradientEndColor="#82ca9d" + * customTooltip={({ active, payload, label }) => ( + * + * )} + * /> + * ); + * }; + * + * export default MyChartComponent; + */ +const CustomAreaChart = ({ + data, + dataKey, + xKey, + yKey, + xTick, + yTick, + strokeColor, + fillColor, + gradient = false, + gradientDirection = "vertical", + gradientStartColor, + gradientEndColor, + customTooltip, + height = "100%", +}) => { + const theme = useTheme(); + const uniqueId = useId(); + const gradientId = `gradient-${uniqueId}`; + return ( + + + + + {gradient === true && + createGradient({ + id: gradientId, + startColor: gradientStartColor, + endColor: gradientEndColor, + direction: gradientDirection, + })} + + + {customTooltip ? ( + + ) : ( + + )} + + + ); +}; + +CustomAreaChart.propTypes = { + data: PropTypes.array.isRequired, + dataKey: PropTypes.string.isRequired, + xTick: PropTypes.object, // Recharts takes an instance of component, so we can't pass the component itself + yTick: PropTypes.object, // Recharts takes an instance of component, so we can't pass the component itself + xKey: PropTypes.string.isRequired, + yKey: PropTypes.string.isRequired, + fillColor: PropTypes.string, + strokeColor: PropTypes.string, + gradient: PropTypes.bool, + gradientDirection: PropTypes.string, + gradientStartColor: PropTypes.string, + gradientEndColor: PropTypes.string, + customTooltip: PropTypes.func, + height: PropTypes.oneOfType([PropTypes.string, PropTypes.number]), +}; + +export default CustomAreaChart; diff --git a/Client/src/Components/Charts/CustomGauge/index.css b/Client/src/Components/Charts/CustomGauge/index.css new file mode 100644 index 000000000..1b64a6b9a --- /dev/null +++ b/Client/src/Components/Charts/CustomGauge/index.css @@ -0,0 +1,14 @@ +.radial-chart { + position: relative; + display: inline-block; +} + +.radial-chart-base { + opacity: 0.3; +} + +.radial-chart-progress { + transform: rotate(-90deg); + transform-origin: center; + transition: stroke-dashoffset 1.5s ease-in-out; +} diff --git a/Client/src/Components/Charts/CustomGauge/index.jsx b/Client/src/Components/Charts/CustomGauge/index.jsx new file mode 100644 index 000000000..f3ad15b0b --- /dev/null +++ b/Client/src/Components/Charts/CustomGauge/index.jsx @@ -0,0 +1,113 @@ +import { useTheme } from "@emotion/react"; +import { useEffect, useState, useMemo } from "react"; +import { Box, Typography } from "@mui/material"; +import PropTypes from "prop-types"; +import "./index.css"; + +/** + * A Performant SVG based circular gauge + * + * @component + * @param {Object} props - Component properties + * @param {number} [props.progress=0] - Progress percentage (0-100) + * @param {number} [props.radius=60] - Radius of the gauge circle + * @param {string} [props.color="#000000"] - Color of the progress stroke + * @param {number} [props.strokeWidth=15] - Width of the gauge stroke + * + * @example + * + * + * @returns {React.ReactElement} Rendered CustomGauge component + */ +const CustomGauge = ({ + progress = 0, + radius = 60, + color = "#000000", + strokeWidth = 15, +}) => { + // Calculate the length of the stroke for the circle + const { circumference, totalSize, strokeLength } = useMemo( + () => ({ + circumference: 2 * Math.PI * radius, + totalSize: radius * 2 + strokeWidth * 2, + strokeLength: (progress / 100) * (2 * Math.PI * radius), + }), + [radius, strokeWidth, progress] + ); + const [offset, setOffset] = useState(circumference); + const theme = useTheme(); + + // Handle initial animation + useEffect(() => { + setOffset(circumference); + const timer = setTimeout(() => { + setOffset(circumference - strokeLength); + }, 100); + + return () => clearTimeout(timer); + }, [progress, circumference, strokeLength]); + + return ( + + + + + + + + {`${progress.toFixed(2)}%`} + + + ); +}; + +export default CustomGauge; + +CustomGauge.propTypes = { + progress: PropTypes.number, + radius: PropTypes.number, + color: PropTypes.string, + strokeWidth: PropTypes.number, +}; diff --git a/Client/src/Components/Charts/Utils/chartUtils.jsx b/Client/src/Components/Charts/Utils/chartUtils.jsx new file mode 100644 index 000000000..043203525 --- /dev/null +++ b/Client/src/Components/Charts/Utils/chartUtils.jsx @@ -0,0 +1,190 @@ +import PropTypes from "prop-types"; +import { useSelector } from "react-redux"; +import { useTheme } from "@mui/material"; +import { Text } from "recharts"; +import { formatDateWithTz } from "../../../Utils/timeUtils"; +import { Box, Stack, Typography } from "@mui/material"; + +/** + * Custom tick component for rendering time with timezone. + * + * @param {Object} props - The properties object. + * @param {number} props.x - The x-coordinate for the tick. + * @param {number} props.y - The y-coordinate for the tick. + * @param {Object} props.payload - The payload object containing tick data. + * @param {number} props.index - The index of the tick. + * @returns {JSX.Element} The rendered tick component. + */ +export const TzTick = ({ x, y, payload, index }) => { + const theme = useTheme(); + + const uiTimezone = useSelector((state) => state.ui.timezone); + return ( + + {formatDateWithTz(payload?.value, "h:mm a", uiTimezone)} + + ); +}; + +TzTick.propTypes = { + x: PropTypes.number, + y: PropTypes.number, + payload: PropTypes.object, + index: PropTypes.number, +}; + +/** + * Custom tick component for rendering percentage values. + * + * @param {Object} props - The properties object. + * @param {number} props.x - The x-coordinate for the tick. + * @param {number} props.y - The y-coordinate for the tick. + * @param {Object} props.payload - The payload object containing tick data. + * @param {number} props.index - The index of the tick. + * @returns {JSX.Element|null} The rendered tick component or null for the first tick. + */ +export const PercentTick = ({ x, y, payload, index }) => { + const theme = useTheme(); + if (index === 0) return null; + return ( + + {`${payload?.value * 100}%`} + + ); +}; + +PercentTick.propTypes = { + x: PropTypes.number, + y: PropTypes.number, + payload: PropTypes.object, + index: PropTypes.number, +}; + +/** + * Converts a decimal value to a formatted percentage string. + * + * @param {number} value - The decimal value to convert (e.g., 0.75) + * @returns {string} Formatted percentage string (e.g., "75.00%") or original input if not a number + * + * @example + * getFormattedPercentage(0.7543) // Returns "75.43%" + * getFormattedPercentage(1) // Returns "100.00%" + * getFormattedPercentage("test") // Returns "test" + */ +const getFormattedPercentage = (value) => { + if (typeof value !== "number") return value; + return `${(value * 100).toFixed(2)}.%`; +}; + +/** + * Custom tooltip component for displaying infrastructure data. + * + * @param {Object} props - The properties object. + * @param {boolean} props.active - Indicates if the tooltip is active. + * @param {Array} props.payload - The payload array containing tooltip data. + * @param {string} props.label - The label for the tooltip. + * @param {string} props.yKey - The key for the y-axis data. + * @param {string} props.yLabel - The label for the y-axis data. + * @param {string} props.dotColor - The color of the dot in the tooltip. + * @returns {JSX.Element|null} The rendered tooltip component or null if inactive. + */ +export const InfrastructureTooltip = ({ + active, + payload, + label, + yKey, + yIdx = -1, + yLabel, + dotColor, +}) => { + const uiTimezone = useSelector((state) => state.ui.timezone); + const theme = useTheme(); + if (active && payload && payload.length) { + const [hardwareType, metric] = yKey.split("."); + return ( + + + {formatDateWithTz(label, "ddd, MMMM D, YYYY, h:mm A", uiTimezone)} + + + + + + {yIdx >= 0 + ? `${yLabel} ${getFormattedPercentage(payload[0].payload[hardwareType][yIdx][metric])}` + : `${yLabel} ${getFormattedPercentage(payload[0].payload[hardwareType][metric])}`} + + + + + {/* Display original value */} + + ); + } + return null; +}; + +InfrastructureTooltip.propTypes = { + active: PropTypes.bool, + payload: PropTypes.array, + label: PropTypes.oneOfType([ + PropTypes.instanceOf(Date), + PropTypes.string, + PropTypes.number, + ]), + yKey: PropTypes.string, + yIdx: PropTypes.number, + yLabel: PropTypes.string, + dotColor: PropTypes.string, +}; diff --git a/Client/src/Components/Charts/Utils/gradientUtils.jsx b/Client/src/Components/Charts/Utils/gradientUtils.jsx new file mode 100644 index 000000000..b5920374d --- /dev/null +++ b/Client/src/Components/Charts/Utils/gradientUtils.jsx @@ -0,0 +1,47 @@ +/** + * Creates an SVG gradient definition for use in charts + * @param {Object} params - The gradient parameters + * @param {string} [params.id="colorUv"] - Unique identifier for the gradient + * @param {string} params.startColor - Starting color of the gradient (hex, rgb, or color name) + * @param {string} params.endColor - Ending color of the gradient (hex, rgb, or color name) + * @param {number} [params.startOpacity=0.8] - Starting opacity (0-1) + * @param {number} [params.endOpacity=0] - Ending opacity (0-1) + * @param {('vertical'|'horizontal')} [params.direction="vertical"] - Direction of the gradient + * @returns {JSX.Element} SVG gradient definition element + * @example + * createCustomGradient({ + * startColor: "#1976D2", + * endColor: "#42A5F5", + * direction: "horizontal" + * }) + */ + +export const createGradient = ({ + id, + startColor, + endColor, + startOpacity = 0.8, + endOpacity = 0, + direction = "vertical", // or "horizontal" +}) => ( + + + + + + +); diff --git a/Client/src/Components/Inputs/Checkbox/index.jsx b/Client/src/Components/Inputs/Checkbox/index.jsx index 7d9a31bd3..2f89d86df 100644 --- a/Client/src/Components/Inputs/Checkbox/index.jsx +++ b/Client/src/Components/Inputs/Checkbox/index.jsx @@ -37,9 +37,9 @@ const Checkbox = ({ onChange, isDisabled, }) => { + /* TODO move sizes to theme */ const sizes = { small: "14px", medium: "16px", large: "18px" }; const theme = useTheme(); - return ( } @@ -65,7 +66,6 @@ const Checkbox = ({ sx={{ borderRadius: theme.shape.borderRadius, p: theme.spacing(2.5), - m: theme.spacing(-2.5), "& .MuiButtonBase-root": { width: theme.spacing(10), p: 0, @@ -78,6 +78,10 @@ const Checkbox = ({ fontSize: 13, color: theme.palette.text.tertiary, }, + ".MuiFormControlLabel-label.Mui-disabled": { + color: theme.palette.text.tertiary, + opacity: 0.25, + }, }} /> ); @@ -85,7 +89,7 @@ const Checkbox = ({ Checkbox.propTypes = { id: PropTypes.string.isRequired, - label: PropTypes.string.isRequired, + label: PropTypes.oneOfType([PropTypes.string, PropTypes.node]).isRequired, size: PropTypes.oneOf(["small", "medium", "large"]), isChecked: PropTypes.bool.isRequired, value: PropTypes.string, diff --git a/Client/src/Components/Inputs/Field/index.css b/Client/src/Components/Inputs/Field/index.css index 2d251e4bf..940969c9e 100644 --- a/Client/src/Components/Inputs/Field/index.css +++ b/Client/src/Components/Inputs/Field/index.css @@ -1,6 +1,12 @@ .field { - min-width: 250px; + min-width: var(--env-var-width-3); } + +.field-infrastructure-alert{ + max-width: var(--env-var-width-4); + min-width: unset; +} + .field h3.MuiTypography-root, .field h5.MuiTypography-root, .field input, @@ -14,7 +20,7 @@ padding-right: var(--env-var-spacing-1-minus); } .field .MuiInputBase-root:has(input) { - height: 34px; + height: var(--env-var-height-2); } .field .MuiInputBase-root:has(.MuiInputAdornment-root) { padding-right: var(--env-var-spacing-1-minus); diff --git a/Client/src/Components/Inputs/Field/index.jsx b/Client/src/Components/Inputs/Field/index.jsx index ccb0c1e37..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( @@ -46,6 +54,8 @@ const Field = forwardRef( error, disabled, hidden, + className, + hideErrorText = false, }, ref ) => { @@ -56,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 new file mode 100644 index 000000000..3b664eb5d --- /dev/null +++ b/Client/src/Pages/Infrastructure/CreateMonitor/CustomThreshold/index.jsx @@ -0,0 +1,92 @@ +import { Box, Stack, Typography } from "@mui/material"; +import Field from "../../../../Components/Inputs/Field"; +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 + * 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 + * @param {string} param0.checkboxId - The text is the id of the checkbox. + * @param {string} param0.checkboxLabel - The text to be displayed as the label next to the check icon. + * @param {func} param0.onCheckboxChange - The function to invoke when checkbox is checked or unchecked. + * @param {string} param0.fieldId - The text is the id of the input field. + * @param {func} param0.onFieldChange - The function to invoke when input field is changed. + * @param {func} param0.onFieldBlur - The function to invoke when input field is losing focus. + * @param {string} param0.alertUnit the threshold unit such as usage percentage '%' etc + * @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} + + + + ); +}; + +CustomThreshold.propTypes = { + checkboxId: PropTypes.string.isRequired, + checkboxLabel: PropTypes.string.isRequired, + onCheckboxChange: PropTypes.func.isRequired, + fieldId: PropTypes.string.isRequired, + onFieldChange: PropTypes.func.isRequired, + onFieldBlur: PropTypes.func.isRequired, + alertUnit: PropTypes.string.isRequired, + infrastructureMonitor: PropTypes.object.isRequired, + errors: PropTypes.object.isRequired, +}; diff --git a/Client/src/Pages/Infrastructure/CreateMonitor/index.jsx b/Client/src/Pages/Infrastructure/CreateMonitor/index.jsx new file mode 100644 index 000000000..bbcb7b05d --- /dev/null +++ b/Client/src/Pages/Infrastructure/CreateMonitor/index.jsx @@ -0,0 +1,386 @@ +import { useState } from "react"; +import { Box, Stack, Typography } from "@mui/material"; +import LoadingButton from "@mui/lab/LoadingButton"; +import { useSelector, useDispatch } from "react-redux"; +import { infrastructureMonitorValidation } from "../../../Validation/validation"; +import { + createInfrastructureMonitor, + checkInfrastructureEndpointResolution, +} from "../../../Features/InfrastructureMonitors/infrastructureMonitorsSlice"; +import { useNavigate } from "react-router-dom"; +import { useTheme } from "@emotion/react"; +import { createToast } from "../../../Utils/toastUtils"; +import { logger } from "../../../Utils/Logger"; +import { ConfigBox } from "../../Monitors/styled"; +import Field from "../../../Components/Inputs/Field"; +import Select from "../../../Components/Inputs/Select"; +import Checkbox from "../../../Components/Inputs/Checkbox"; +import Breadcrumbs from "../../../Components/Breadcrumbs"; +import { buildErrors, hasValidationErrors } from "../../../Validation/error"; +import { capitalizeFirstLetter } from "../../../Utils/stringUtils"; +import { CustomThreshold } from "../CreateMonitor/CustomThreshold"; + +const CreateInfrastructureMonitor = () => { + const [infrastructureMonitor, setInfrastructureMonitor] = useState({ + url: "", + name: "", + notifications: [], + interval: 0.25, + cpu: false, + usage_cpu: "", + memory: false, + usage_memory: "", + disk: false, + usage_disk: "", + secret: "", + }); + + const MS_PER_MINUTE = 60000; + const THRESHOLD_FIELD_PREFIX = "usage_"; + const HARDWARE_MONITOR_TYPES = ["cpu", "memory", "disk"]; + const { user, authToken } = useSelector((state) => state.auth); + const monitorState = useSelector((state) => state.infrastructureMonitor); + const dispatch = useDispatch(); + const navigate = useNavigate(); + const theme = useTheme(); + + const idMap = { + "notify-email-default": "notification-email", + }; + + const [errors, setErrors] = useState({}); + + const alertErrKeyLen = Object.keys(errors).filter((k) => + k.startsWith(THRESHOLD_FIELD_PREFIX) + ).length; + + const handleCustomAlertCheckChange = (event) => { + const { value, id } = event.target; + setInfrastructureMonitor((prev) => { + const newState = { + [id]: prev[id] == undefined && value == "on" ? true : !prev[id], + }; + return { + ...prev, + ...newState, + [THRESHOLD_FIELD_PREFIX + id]: newState[id] + ? prev[THRESHOLD_FIELD_PREFIX + id] + : "", + }; + }); + // Remove the error if unchecked + setErrors((prev) => { + return buildErrors(prev, [THRESHOLD_FIELD_PREFIX + id]); + }); + }; + + const handleBlur = (event, appenedID) => { + event.preventDefault(); + const { value, id } = event.target; + if (id?.startsWith("notify-email-")) return; + const { error } = infrastructureMonitorValidation.validate( + { [id ?? appenedID]: value }, + { + abortEarly: false, + } + ); + setErrors((prev) => { + return buildErrors(prev, id ?? appenedID, error); + }); + }; + + const handleChange = (event, appendedId) => { + event.preventDefault(); + const { value, id } = event.target; + let name = appendedId ?? idMap[id] ?? id; + + if (name.includes("notification-")) { + name = name.replace("notification-", ""); + let hasNotif = infrastructureMonitor.notifications.some( + (notification) => notification.type === name + ); + setInfrastructureMonitor((prev) => { + const notifs = [...prev.notifications]; + if (hasNotif) { + return { + ...prev, + notifications: notifs.filter((notif) => notif.type !== name), + }; + } else { + return { + ...prev, + notifications: [ + ...notifs, + name === "email" + ? { type: name, address: value } + : // TODO - phone number + { type: name, phone: value }, + ], + }; + } + }); + } else { + setInfrastructureMonitor((prev) => ({ + ...prev, + [name]: value, + })); + } + }; + + const generatePayload = (form) => { + let thresholds = {}; + Object.keys(form) + .filter((k) => k.startsWith(THRESHOLD_FIELD_PREFIX)) + .map((k) => { + if (form[k]) thresholds[k] = form[k] / 100; + delete form[k]; + delete form[k.substring(THRESHOLD_FIELD_PREFIX.length)]; + }); + + form = { + ...form, + description: form.name, + teamId: user.teamId, + userId: user._id, + type: "hardware", + notifications: infrastructureMonitor.notifications, + thresholds, + }; + return form; + }; + const handleCreateInfrastructureMonitor = async (event) => { + event.preventDefault(); + let form = { + ...infrastructureMonitor, + name: + infrastructureMonitor.name === "" + ? infrastructureMonitor.url + : infrastructureMonitor.name, + interval: infrastructureMonitor.interval * MS_PER_MINUTE, + }; + + delete form.notifications; + if (hasValidationErrors(form, infrastructureMonitorValidation, setErrors)) { + return; + } else { + const checkEndpointAction = await dispatch( + checkInfrastructureEndpointResolution({ authToken, monitorURL: form.url }) + ); + if (checkEndpointAction.meta.requestStatus === "rejected") { + createToast({ + body: "The endpoint you entered doesn't resolve. Check the URL again.", + }); + setErrors({ url: "The entered URL is not reachable." }); + return; + } + const action = await dispatch( + createInfrastructureMonitor({ authToken, monitor: generatePayload(form) }) + ); + if (action.meta.requestStatus === "fulfilled") { + createToast({ body: "Infrastructure monitor created successfully!" }); + navigate("/infrastructure"); + } else { + createToast({ body: "Failed to create monitor." }); + } + } + }; + + //select values + const frequencies = [ + { _id: 0.25, name: "15 seconds" }, + { _id: 0.5, name: "30 seconds" }, + { _id: 1, name: "1 minute" }, + { _id: 2, name: "2 minutes" }, + { _id: 5, name: "5 minutes" }, + { _id: 10, name: "10 minutes" }, + ]; + + return ( + + + + + + Create your{" "} + + + infrastructure monitor + + + + + General settings + + Here you can select the URL of the host, together with the friendly name and + authorization secret to connect to the server agent. + + + + + + + + + + + Incident notifications + + When there is an incident, notify users. + + + + When there is a new incident, + notification.type === "email" + )} + value={user?.email} + onChange={(e) => handleChange(e)} + onBlur={handleBlur} + /> + + + + + + Customize alerts + + Send a notification to user(s) when thresholds exceed a specified percentage. + + + + {HARDWARE_MONITOR_TYPES.map((type, idx) => ( + + ))} + {alertErrKeyLen > 0 && ( + + { + errors[ + THRESHOLD_FIELD_PREFIX + + HARDWARE_MONITOR_TYPES.filter( + (type) => errors[THRESHOLD_FIELD_PREFIX + type] + )[0] + ] + } + + )} + + + + + Advanced settings + + +