diff --git a/Client/src/App.jsx b/Client/src/App.jsx index 151512701..772c1aa45 100644 --- a/Client/src/App.jsx +++ b/Client/src/App.jsx @@ -39,6 +39,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 +49,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(); @@ -128,6 +130,11 @@ function App() { element={} /> + } + /> + } diff --git a/Client/src/Components/Charts/AreaChart/index.jsx b/Client/src/Components/Charts/AreaChart/index.jsx new file mode 100644 index 000000000..d05dbb14a --- /dev/null +++ b/Client/src/Components/Charts/AreaChart/index.jsx @@ -0,0 +1,157 @@ +import { + AreaChart, + Area, + XAxis, + YAxis, + CartesianGrid, + Tooltip, + ResponsiveContainer, +} from "recharts"; +import { createGradient } from "../Utils/gradientUtils"; +import PropTypes from "prop-types"; +import { useTheme } from "@mui/material"; +import { useId } from "react"; +/** + * CustomAreaChart component for rendering an area chart with optional gradient and custom ticks. + * + * @param {Object} props - The properties object. + * @param {Array} props.data - The data array for the chart. + * @param {string} props.xKey - The key for the x-axis data. + * @param {string} props.yKey - The key for the y-axis data. + * @param {Object} [props.xTick] - Custom tick component for the x-axis. + * @param {Object} [props.yTick] - Custom tick component for the y-axis. + * @param {string} [props.strokeColor] - The stroke color for the area. + * @param {string} [props.fillColor] - The fill color for the area. + * @param {boolean} [props.gradient=false] - Whether to apply a gradient fill. + * @param {string} [props.gradientDirection="vertical"] - The direction of the gradient. + * @param {string} [props.gradientStartColor] - The start color of the gradient. + * @param {string} [props.gradientEndColor] - The end color of the gradient. + * @param {Object} [props.customTooltip] - Custom tooltip component. + * @returns {JSX.Element} The rendered area chart component. + * + * @example + * // Example usage of CustomAreaChart + * import React from 'react'; + * import CustomAreaChart from './CustomAreaChart'; + * import { TzTick, PercentTick, InfrastructureTooltip } from './chartUtils'; + * + * const data = [ + * { createdAt: '2023-01-01T00:00:00Z', cpu: { usage_percent: 0.5 } }, + * { createdAt: '2023-01-01T01:00:00Z', cpu: { usage_percent: 0.6 } }, + * // more data points... + * ]; + * + * const MyChartComponent = () => { + * 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/Pages/Infrastructure/Details/index.jsx b/Client/src/Pages/Infrastructure/Details/index.jsx new file mode 100644 index 000000000..4fb547a5a --- /dev/null +++ b/Client/src/Pages/Infrastructure/Details/index.jsx @@ -0,0 +1,394 @@ +import { useParams } from "react-router-dom"; +import { useEffect, useState } from "react"; +import Breadcrumbs from "../../../Components/Breadcrumbs"; +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 PulseDot from "../../../Components/Animated/PulseDot"; +import useUtils from "../../Monitors/utils"; +import { formatDurationRounded, formatDurationSplit } from "../../../Utils/timeUtils"; +import axios from "axios"; +import { + TzTick, + PercentTick, + InfrastructureTooltip, +} from "../../../Components/Charts/Utils/chartUtils"; +import PropTypes from "prop-types"; + +const BASE_BOX_PADDING_VERTICAL = 4; +const BASE_BOX_PADDING_HORIZONTAL = 8; +const TYPOGRAPHY_PADDING = 8; +/** + * Converts bytes to gigabytes + * @param {number} bytes - Number of bytes to convert + * @returns {number} Converted value in gigabytes + */ +const formatBytes = (bytes) => { + if (typeof bytes !== "number") return "0 GB"; + if (bytes === 0) return "0 GB"; + + const GB = bytes / (1024 * 1024 * 1024); + const MB = bytes / (1024 * 1024); + + if (GB >= 1) { + return `${Number(GB.toFixed(0))} GB`; + } else { + return `${Number(MB.toFixed(0))} MB`; + } +}; + +/** + * Renders a base box with consistent styling + * @param {Object} props - Component properties + * @param {React.ReactNode} props.children - Child components to render inside the box + * @param {Object} props.sx - Additional styling for the box + * @returns {React.ReactElement} Styled box component + */ +const BaseBox = ({ children, sx = {} }) => { + const theme = useTheme(); + return ( + + {children} + + ); +}; + +BaseBox.propTypes = { + children: PropTypes.node.isRequired, + sx: PropTypes.object, +}; + +/** + * Renders a statistic box with a heading and subheading + * @param {Object} props - Component properties + * @param {string} props.heading - Primary heading text + * @param {string} props.subHeading - Secondary heading text + * @returns {React.ReactElement} Stat box component + */ +const StatBox = ({ heading, subHeading }) => { + return ( + + {heading} + {subHeading} + + ); +}; + +StatBox.propTypes = { + heading: PropTypes.string.isRequired, + subHeading: PropTypes.string.isRequired, +}; + +/** + * Renders a gauge box with usage visualization + * @param {Object} props - Component properties + * @param {number} props.value - Percentage value for gauge + * @param {string} props.heading - Box heading + * @param {string} props.metricOne - First metric label + * @param {string} props.valueOne - First metric value + * @param {string} props.metricTwo - Second metric label + * @param {string} props.valueTwo - Second metric value + * @returns {React.ReactElement} Gauge box component + */ +const GaugeBox = ({ value, heading, metricOne, valueOne, metricTwo, valueTwo }) => { + const theme = useTheme(); + return ( + + + + {heading} + + + {metricOne} + {valueOne} + + + {metricTwo} + {valueTwo} + + + + + ); +}; + +GaugeBox.propTypes = { + value: PropTypes.oneOfType([PropTypes.number, PropTypes.string]).isRequired, + heading: PropTypes.string.isRequired, + metricOne: PropTypes.string.isRequired, + valueOne: PropTypes.oneOfType([PropTypes.string, PropTypes.number]).isRequired, + metricTwo: PropTypes.string.isRequired, + valueTwo: PropTypes.oneOfType([PropTypes.string, PropTypes.number]).isRequired, +}; + +/** + * Renders the infrastructure details page + * @returns {React.ReactElement} Infrastructure details page component + */ +const InfrastructureDetails = () => { + const theme = useTheme(); + const { monitorId } = useParams(); + const navList = [ + { name: "infrastructure monitors", path: "/infrastructure" }, + { name: "details", path: `/infrastructure/${monitorId}` }, + ]; + const [monitor, setMonitor] = useState(null); + const { statusColor, determineState } = useUtils(); + // These calculations are needed because ResponsiveContainer + // doesn't take padding of parent/siblings into account + // when calculating height. + const chartContainerHeight = 300; + const totalChartContainerPadding = + parseInt(theme.spacing(BASE_BOX_PADDING_VERTICAL), 10) * 2; + const totalTypographyPadding = parseInt(theme.spacing(TYPOGRAPHY_PADDING), 10) * 2; + const areaChartHeight = + (chartContainerHeight - totalChartContainerPadding - totalTypographyPadding) * 0.95; + // end height calculations + + // Fetch data + 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", + }, + }); + setMonitor(response.data.data); + } catch (error) { + console.error(error); + } + }; + fetchData(); + }, []); + + const statBoxConfigs = [ + { + id: 0, + heading: "CPU", + subHeading: `${monitor?.checks[0]?.cpu?.physical_core} cores`, + }, + { + id: 1, + heading: "Memory", + subHeading: formatBytes(monitor?.checks[0]?.memory?.total_bytes), + }, + { + id: 2, + heading: "Disk", + subHeading: formatBytes(monitor?.checks[0]?.disk[0]?.total_bytes), + }, + { id: 3, heading: "Uptime", subHeading: "100%" }, + { + id: 4, + heading: "Status", + subHeading: monitor?.status === true ? "Active" : "Inactive", + }, + ]; + + const gaugeBoxConfigs = [ + { + type: "memory", + value: monitor?.checks[0]?.memory?.usage_percent * 100, + heading: "Memory Usage", + metricOne: "Used", + valueOne: formatBytes(monitor?.checks[0]?.memory?.used_bytes), + metricTwo: "Total", + valueTwo: formatBytes(monitor?.checks[0]?.memory?.total_bytes), + }, + { + type: "cpu", + value: monitor?.checks[0]?.cpu?.usage_percent * 100, + heading: "CPU Usage", + metricOne: "Cores", + valueOne: monitor?.checks[0]?.cpu?.physical_core, + metricTwo: "Frequency", + valueTwo: `${(monitor?.checks[0]?.cpu?.frequency / 1000).toFixed(2)} Ghz`, + }, + ...(monitor?.checks[0]?.disk ?? []).map((disk, idx) => ({ + type: "disk", + diskIndex: idx, + value: disk.usage_percent * 100, + heading: `Disk${idx} usage`, + metricOne: "Used", + valueOne: formatBytes(disk.total_bytes - disk.free_bytes), + metricTwo: "Total", + valueTwo: formatBytes(disk.total_bytes), + })), + ]; + + const areaChartConfigs = [ + { + type: "memory", + dataKey: "memory.usage_percent", + heading: "Memory usage", + strokeColor: theme.palette.primary.main, + yLabel: "Memory Usage", + }, + { + type: "cpu", + dataKey: "cpu.usage_percent", + heading: "CPU usage", + strokeColor: theme.palette.success.main, + yLabel: "CPU Usage", + }, + ...(monitor?.checks?.[0]?.disk?.map((disk, idx) => ({ + type: "disk", + diskIndex: idx, + dataKey: `disk[${idx}].usage_percent`, + heading: `Disk${idx} usage`, + strokeColor: theme.palette.warning.main, + yLabel: "Disk Usage", + })) || []), + ]; + + return ( + monitor && ( + + + + + + + + + {monitor.name} + + {monitor.url || "..."} + + + Checking every {formatDurationRounded(monitor?.interval)} + + + Last checked {formatDurationSplit(monitor?.lastChecked).time}{" "} + {formatDurationSplit(monitor?.lastChecked).format} ago + + + + {statBoxConfigs.map((statBox) => ( + + ))} + + + {gaugeBoxConfigs.map((config) => ( + + ))} + + *": { + flexBasis: `calc(50% - ${theme.spacing(8)})`, + maxWidth: `calc(50% - ${theme.spacing(8)})`, + }, + }} + > + {areaChartConfigs.map((config) => ( + + + {config.heading} + + ( + + )} + xTick={} + yTick={} + strokeColor={config.strokeColor} + gradient={true} + gradientStartColor={config.strokeColor} + gradientEndColor="#ffffff" + /> + + ))} + + + + ) + ); +}; + +export default InfrastructureDetails; diff --git a/Server/index.js b/Server/index.js index 0e9811a43..996c4fd07 100644 --- a/Server/index.js +++ b/Server/index.js @@ -92,6 +92,23 @@ const startApp = async () => { app.use("/api/v1/queue", verifyJWT, queueRouter); app.use("/api/v1/status-page", statusPageRouter); + app.use("/api/v1/dummy-data", async (req, res) => { + try { + const response = await axios.get( + "https://gist.githubusercontent.com/ajhollid/9afa39410c7bbf52cc905f285a2225bf/raw/429a231a3559ebc95f6f488ed2c766bd7d6f46e5/dummyData.json", + { + headers: { + "Content-Type": "application/json", + "Cache-Control": "no-cache", + }, + } + ); + return res.status(200).json(response.data); + } catch (error) { + return res.status(500).json({ message: error.message }); + } + }); + //health check app.use("/api/v1/healthy", (req, res) => { try {