diff --git a/Client/src/Components/Charts/Utils/chartUtils.jsx b/Client/src/Components/Charts/Utils/chartUtils.jsx index 004f9a227..1d82f10b4 100644 --- a/Client/src/Components/Charts/Utils/chartUtils.jsx +++ b/Client/src/Components/Charts/Utils/chartUtils.jsx @@ -17,7 +17,6 @@ import { Box, Stack, Typography } from "@mui/material"; */ export const TzTick = ({ x, y, payload, index }) => { const theme = useTheme(); - const uiTimezone = useSelector((state) => state.ui.timezone); return ( {yIdx >= 0 ? `${yLabel} ${getFormattedPercentage(payload[0].payload[hardwareType][yIdx][metric])}` - : `${yLabel} ${getFormattedPercentage(payload[0].payload[hardwareType][metric])}`} + : `${yLabel} ${getFormattedPercentage(payload[0].payload[yKey])}`} diff --git a/Client/src/Pages/Infrastructure/Details/index.jsx b/Client/src/Pages/Infrastructure/Details/index.jsx index 66cebdea7..29eddb9fc 100644 --- a/Client/src/Pages/Infrastructure/Details/index.jsx +++ b/Client/src/Pages/Infrastructure/Details/index.jsx @@ -1,7 +1,7 @@ import { useParams } from "react-router-dom"; import { useEffect, useState } from "react"; import Breadcrumbs from "../../../Components/Breadcrumbs"; -import { Stack, Box, Typography } from "@mui/material"; +import { Button, ButtonGroup, Stack, Box, Typography } from "@mui/material"; import { useTheme } from "@emotion/react"; import CustomGauge from "../../../Components/Charts/CustomGauge"; import AreaChart from "../../../Components/Charts/AreaChart"; @@ -185,9 +185,11 @@ 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, + valueOne: PropTypes.oneOfType([PropTypes.string, PropTypes.number, PropTypes.element]) + .isRequired, metricTwo: PropTypes.string.isRequired, - valueTwo: PropTypes.oneOfType([PropTypes.string, PropTypes.number]).isRequired, + valueTwo: PropTypes.oneOfType([PropTypes.string, PropTypes.number, PropTypes.element]) + .isRequired, }; /** @@ -204,7 +206,7 @@ const InfrastructureDetails = () => { ]; const [monitor, setMonitor] = useState(null); const { authToken } = useSelector((state) => state.auth); - const [dateRange, setDateRange] = useState("all"); + const [dateRange, setDateRange] = useState("day"); const { statusColor, statusStyles, determineState } = useUtils(); // These calculations are needed because ResponsiveContainer // doesn't take padding of parent/siblings into account @@ -217,8 +219,9 @@ const InfrastructureDetails = () => { (chartContainerHeight - totalChartContainerPadding - totalTypographyPadding) * 0.95; // end height calculations - const buildStatBoxes = (checks, uptime) => { - let latestCheck = checks[0] ?? null; + const buildStatBoxes = (stats, uptime) => { + if (Object.keys(stats).length === 0) return []; + let latestCheck = stats?.aggregateData?.latestCheck ?? null; if (latestCheck === null) return []; // Extract values from latest check @@ -311,8 +314,10 @@ const InfrastructureDetails = () => { ]; }; - const buildGaugeBoxConfigs = (checks) => { - let latestCheck = checks[0] ?? null; + const buildGaugeBoxConfigs = (stats) => { + if (Object.keys(stats).length === 0) return []; + + let latestCheck = stats?.aggregateData?.latestCheck ?? null; if (latestCheck === null) return []; // Extract values from latest check @@ -359,27 +364,27 @@ const InfrastructureDetails = () => { if (checks === null) return { temps: [], tempKeys: [] }; for (const check of checks) { - if (check?.cpu?.temperature?.length > numCores) { - numCores = check.cpu.temperature.length; + if (check?.avgTemperature?.length > numCores) { + numCores = check.avgTemperature.length; break; } } const temps = checks.map((check) => { // If there's no data, set the temperature to 0 if ( - check?.cpu?.temperature?.length === 0 || - check?.cpu?.temperature === undefined || - check?.cpu?.temperature === null + check?.avgTemperature?.length === 0 || + check?.avgTemperature === undefined || + check?.avgTemperature === null ) { - check.cpu.temperature = Array(numCores).fill(0); + check.avgTemperature = Array(numCores).fill(0); } - const res = check?.cpu?.temperature?.reduce( + const res = check?.avgTemperature?.reduce( (acc, cur, idx) => { acc[`core${idx + 1}`] = cur; return acc; }, { - createdAt: check.createdAt, + _id: check._id, } ); return res; @@ -389,7 +394,7 @@ const InfrastructureDetails = () => { } return { - tempKeys: Object.keys(temps[0] || {}).filter((key) => key !== "createdAt"), + tempKeys: Object.keys(temps[0] || {}).filter((key) => key !== "_id"), temps, }; }; @@ -397,13 +402,12 @@ const InfrastructureDetails = () => { const buildAreaChartConfigs = (checks) => { let latestCheck = checks[0] ?? null; if (latestCheck === null) return []; - const reversedChecks = checks.toReversed(); - const tempData = buildTemps(reversedChecks); + const { temps, tempKeys } = buildTemps(checks); return [ { type: "memory", - data: reversedChecks, - dataKeys: ["memory.usage_percent"], + data: checks, + dataKeys: ["avgMemoryUsage"], heading: "Memory usage", strokeColor: theme.palette.primary.main, gradientStartColor: theme.palette.primary.main, @@ -414,15 +418,15 @@ const InfrastructureDetails = () => { toolTip: ( ), }, { type: "cpu", - data: reversedChecks, - dataKeys: ["cpu.usage_percent"], + data: checks, + dataKeys: ["avgCpuUsage"], heading: "CPU usage", strokeColor: theme.palette.success.main, gradientStartColor: theme.palette.success.main, @@ -433,15 +437,15 @@ const InfrastructureDetails = () => { toolTip: ( ), }, { type: "temperature", - data: tempData.temps, - dataKeys: tempData.tempKeys, + data: temps, + dataKeys: tempKeys, strokeColor: theme.palette.error.main, gradientStartColor: theme.palette.error.main, heading: "CPU Temperature", @@ -450,24 +454,22 @@ const InfrastructureDetails = () => { yDomain: [ 0, Math.max( - Math.max( - ...tempData.temps.flatMap((t) => tempData.tempKeys.map((k) => t[k])) - ) * 1.1, + Math.max(...temps.flatMap((t) => tempKeys.map((k) => t[k]))) * 1.1, 200 ), ], toolTip: ( ), }, - ...(latestCheck?.disk?.map((disk, idx) => ({ + ...(latestCheck?.disks?.map((disk, idx) => ({ type: "disk", - data: reversedChecks, + data: checks, diskIndex: idx, - dataKeys: [`disk[${idx}].usage_percent`], + dataKeys: [`disks[${idx}].usagePercent`], heading: `Disk${idx} usage`, strokeColor: theme.palette.warning.main, gradientStartColor: theme.palette.warning.main, @@ -478,7 +480,7 @@ const InfrastructureDetails = () => { toolTip: ( @@ -491,15 +493,12 @@ const InfrastructureDetails = () => { useEffect(() => { const fetchData = async () => { try { - const response = await networkService.getStatsByMonitorId({ + const response = await networkService.getHardwareDetailsByMonitorId({ authToken: authToken, monitorId: monitorId, - sortOrder: null, - limit: null, dateRange: dateRange, - numToDisplay: 50, - normalize: false, }); + response.data.data; setMonitor(response.data.data); } catch (error) { navigate("/not-found", { replace: true }); @@ -510,16 +509,17 @@ const InfrastructureDetails = () => { }, [authToken, monitorId, dateRange, navigate]); const statBoxConfigs = buildStatBoxes( - monitor?.checks ?? [], + monitor?.stats ?? {}, monitor?.uptimePercentage ?? "Unknown" ); - const gaugeBoxConfigs = buildGaugeBoxConfigs(monitor?.checks ?? []); - const areaChartConfigs = buildAreaChartConfigs(monitor?.checks ?? []); - + const gaugeBoxConfigs = buildGaugeBoxConfigs(monitor?.stats ?? {}); + const areaChartConfigs = buildAreaChartConfigs(monitor?.stats?.checks ?? []); + const lastChecked = + Date.now() - new Date(monitor?.stats?.aggregateData?.latestCheck?.createdAt); return ( - {monitor?.checks?.length > 0 ? ( + {monitor?.stats.checks?.length > 0 ? ( { Checking every {formatDurationRounded(monitor?.interval)} - Last checked {formatDurationSplit(monitor?.lastChecked).time}{" "} - {formatDurationSplit(monitor?.lastChecked).format} ago + Last checked {formatDurationSplit(lastChecked).time}{" "} + {formatDurationSplit(lastChecked).format} ago + { /> ))} + - {gaugeBoxConfigs.map((config) => ( - - ))} + {gaugeBoxConfigs.map((config) => { + return ( + + ); + })} + + + + Showing statistics for past{" "} + {dateRange === "day" + ? "24 hours" + : dateRange === "week" + ? "7 days" + : "30 days"} + . + + + + + + { height={areaChartHeight} data={config.data} dataKeys={config.dataKeys} - xKey="createdAt" + xKey="_id" yDomain={config.yDomain} customTooltip={config.toolTip} xTick={config.xTick} diff --git a/Client/src/Utils/NetworkService.js b/Client/src/Utils/NetworkService.js index 42670a6d0..0cceb8cff 100644 --- a/Client/src/Utils/NetworkService.js +++ b/Client/src/Utils/NetworkService.js @@ -242,6 +242,12 @@ class NetworkService { ); } + async getHardwareDetailsByMonitorId(config) { + const params = new URLSearchParams(); + if (config.dateRange) params.append("dateRange", config.dateRange); + + return this.axiosInstance.get( + `/monitors/hardware/details/${config.monitorId}?${params.toString()}`, async getUptimeDetailsById(config) { const params = new URLSearchParams(); if (config.dateRange) params.append("dateRange", config.dateRange); diff --git a/Server/controllers/monitorController.js b/Server/controllers/monitorController.js index ec317bbb5..5e16b3b18 100644 --- a/Server/controllers/monitorController.js +++ b/Server/controllers/monitorController.js @@ -12,6 +12,8 @@ import { getMonitorStatsByIdParamValidation, getMonitorStatsByIdQueryValidation, getCertificateParamValidation, + getHardwareDetailsByIdParamValidation, + getHardwareDetailsByIdQueryValidation, } from "../validation/joi.js"; import sslChecker from "ssl-checker"; import { successMessages } from "../utils/messages.js"; @@ -117,6 +119,35 @@ class MonitorController { } }; + /** + * Get hardware details for a specific monitor by ID + * @async + * @param {Express.Request} req - Express request object containing monitorId in params + * @param {Express.Response} res - Express response object + * @param {Express.NextFunction} next - Express next middleware function + * @returns {Promise} + * @throws {Error} - Throws error if monitor not found or other database errors + */ + getHardwareDetailsById = async (req, res, next) => { + try { + await getHardwareDetailsByIdParamValidation.validateAsync(req.params); + await getHardwareDetailsByIdQueryValidation.validateAsync(req.query); + } catch (error) { + next(handleValidationError(error, SERVICE_NAME)); + return; + } + try { + const monitor = await this.db.getHardwareDetailsById(req); + return res.status(200).json({ + success: true, + msg: successMessages.MONITOR_GET_BY_ID, + data: monitor, + }); + } catch (error) { + next(handleError(error, SERVICE_NAME, "getHardwareDetailsById")); + } + }; + getMonitorCertificate = async (req, res, next, fetchMonitorCertificate) => { try { await getCertificateParamValidation.validateAsync(req.params); diff --git a/Server/db/mongo/modules/monitorModule.js b/Server/db/mongo/modules/monitorModule.js index 6c00a7e94..1143b9eeb 100644 --- a/Server/db/mongo/modules/monitorModule.js +++ b/Server/db/mongo/modules/monitorModule.js @@ -703,6 +703,286 @@ const getMonitorStatsById = async (req) => { } }; +const getHardwareDetailsById = async (req) => { + try { + const { monitorId } = req.params; + const { dateRange } = req.query; + const monitor = await Monitor.findById(monitorId); + const dates = getDateRange(dateRange); + const formatLookup = { + day: "%Y-%m-%dT%H:00:00Z", + week: "%Y-%m-%dT%H:00:00Z", + month: "%Y-%m-%dT00:00:00Z", + }; + const dateString = formatLookup[dateRange]; + const hardwareStats = await HardwareCheck.aggregate([ + { + $match: { + monitorId: monitor._id, + createdAt: { $gte: dates.start, $lte: dates.end }, + }, + }, + { + $sort: { + createdAt: 1, + }, + }, + { + $facet: { + aggregateData: [ + { + $group: { + _id: null, + latestCheck: { + $last: "$$ROOT", + }, + totalChecks: { + $sum: 1, + }, + }, + }, + ], + upChecks: [ + { + $match: { + status: true, + }, + }, + { + $group: { + _id: null, + totalChecks: { + $sum: 1, + }, + }, + }, + ], + checks: [ + { + $limit: 1, + }, + { + $project: { + diskCount: { + $size: "$disk", + }, + }, + }, + { + $lookup: { + from: "hardwarechecks", + let: { + diskCount: "$diskCount", + }, + pipeline: [ + { + $match: { + $expr: { + $and: [ + { $eq: ["$monitorId", monitor._id] }, + { $gte: ["$createdAt", dates.start] }, + { $lte: ["$createdAt", dates.end] }, + ], + }, + }, + }, + { + $group: { + _id: { + $dateToString: { + format: dateString, + date: "$createdAt", + }, + }, + avgCpuUsage: { + $avg: "$cpu.usage_percent", + }, + avgMemoryUsage: { + $avg: "$memory.usage_percent", + }, + avgTemperatures: { + $push: { + $ifNull: ["$cpu.temperature", [0]], + }, + }, + disks: { + $push: "$disk", + }, + }, + }, + { + $project: { + _id: 1, + avgCpuUsage: 1, + avgMemoryUsage: 1, + avgTemperature: { + $map: { + input: { + $range: [ + 0, + { + $size: { + // Handle null temperatures array + $ifNull: [ + { $arrayElemAt: ["$avgTemperatures", 0] }, + [0], // Default to single-element array if null + ], + }, + }, + ], + }, + as: "index", + in: { + $avg: { + $map: { + input: "$avgTemperatures", + as: "tempArray", + in: { + $ifNull: [ + { $arrayElemAt: ["$$tempArray", "$$index"] }, + 0, // Default to 0 if element is null + ], + }, + }, + }, + }, + }, + }, + disks: { + $map: { + input: { + $range: [0, "$$diskCount"], + }, + as: "diskIndex", + in: { + name: { + $concat: [ + "disk", + { + $toString: "$$diskIndex", + }, + ], + }, + readSpeed: { + $avg: { + $map: { + input: "$disks", + as: "diskArray", + in: { + $arrayElemAt: [ + "$$diskArray.read_speed_bytes", + "$$diskIndex", + ], + }, + }, + }, + }, + writeSpeed: { + $avg: { + $map: { + input: "$disks", + as: "diskArray", + in: { + $arrayElemAt: [ + "$$diskArray.write_speed_bytes", + "$$diskIndex", + ], + }, + }, + }, + }, + totalBytes: { + $avg: { + $map: { + input: "$disks", + as: "diskArray", + in: { + $arrayElemAt: [ + "$$diskArray.total_bytes", + "$$diskIndex", + ], + }, + }, + }, + }, + freeBytes: { + $avg: { + $map: { + input: "$disks", + as: "diskArray", + in: { + $arrayElemAt: [ + "$$diskArray.free_bytes", + "$$diskIndex", + ], + }, + }, + }, + }, + usagePercent: { + $avg: { + $map: { + input: "$disks", + as: "diskArray", + in: { + $arrayElemAt: [ + "$$diskArray.usage_percent", + "$$diskIndex", + ], + }, + }, + }, + }, + }, + }, + }, + }, + }, + ], + as: "hourlyStats", + }, + }, + { + $unwind: "$hourlyStats", + }, + { + $replaceRoot: { + newRoot: "$hourlyStats", + }, + }, + ], + }, + }, + { + $project: { + aggregateData: { + $arrayElemAt: ["$aggregateData", 0], + }, + upChecks: { + $arrayElemAt: ["$upChecks", 0], + }, + checks: { + $sortArray: { + input: "$checks", + sortBy: { _id: 1 }, + }, + }, + }, + }, + ]); + + const monitorStats = { + ...monitor.toObject(), + stats: hardwareStats[0], + }; + return monitorStats; + } catch (error) { + error.service = SERVICE_NAME; + error.method = "getHardwareDetailsById"; + throw error; + } +}; + /** * Get a monitor by ID * @async @@ -991,6 +1271,7 @@ export { deleteMonitorsByUserId, editMonitor, addDemoMonitors, + getHardwareDetailsById, }; // Helper functions diff --git a/Server/routes/monitorRoute.js b/Server/routes/monitorRoute.js index 39ef105c2..d47351486 100644 --- a/Server/routes/monitorRoute.js +++ b/Server/routes/monitorRoute.js @@ -13,13 +13,14 @@ class MonitorRoutes { this.router.get("/", this.monitorController.getAllMonitors); this.router.get("/uptime", this.monitorController.getAllMonitorsWithUptimeStats); this.router.get("/stats/:monitorId", this.monitorController.getMonitorStatsById); - - // New details routes + this.router.get( + "/hardware/details/:monitorId", + this.monitorController.getHardwareDetailsById + ); this.router.get( "/uptime/details/:monitorId", this.monitorController.getUptimeDetailsById ); - this.router.get("/certificate/:monitorId", (req, res, next) => { this.monitorController.getMonitorCertificate( req, diff --git a/Server/validation/joi.js b/Server/validation/joi.js index 141394ac8..dbffef937 100644 --- a/Server/validation/joi.js +++ b/Server/validation/joi.js @@ -232,6 +232,14 @@ const getMonitorURLByQueryValidation = joi.object({ monitorURL: joi.string().uri().required(), }); +const getHardwareDetailsByIdParamValidation = joi.object({ + monitorId: joi.string().required(), +}); + +const getHardwareDetailsByIdQueryValidation = joi.object({ + dateRange: joi.string().valid("day", "week", "month"), +}); + //**************************************** // Alerts //**************************************** @@ -465,6 +473,8 @@ export { getMonitorsByTeamIdQueryValidation, getMonitorStatsByIdParamValidation, getMonitorStatsByIdQueryValidation, + getHardwareDetailsByIdParamValidation, + getHardwareDetailsByIdQueryValidation, getCertificateParamValidation, editMonitorBodyValidation, pauseMonitorParamValidation,