diff --git a/Client/src/Components/Charts/MonitorDetailsAreaChart/index.jsx b/Client/src/Components/Charts/MonitorDetailsAreaChart/index.jsx index c45fa1b72..2fefd3daa 100644 --- a/Client/src/Components/Charts/MonitorDetailsAreaChart/index.jsx +++ b/Client/src/Components/Charts/MonitorDetailsAreaChart/index.jsx @@ -17,9 +17,11 @@ import "./index.css"; const CustomToolTip = ({ active, payload, label }) => { const uiTimezone = useSelector((state) => state.ui.timezone); - const theme = useTheme(); if (active && payload && payload.length) { + const responseTime = payload[0]?.payload?.originalAvgResponseTime + ? payload[0]?.payload?.originalAvgResponseTime + : (payload[0]?.payload?.avgResponseTime ?? 0); return ( { Response Time {" "} - {payload[0].payload.originalResponseTime} + {Math.floor(responseTime)} { return null; }; +CustomToolTip.propTypes = { + active: PropTypes.bool, + payload: PropTypes.arrayOf( + PropTypes.shape({ + value: PropTypes.number, + payload: PropTypes.shape({ + _id: PropTypes.string, + avgResponseTime: PropTypes.number, + originalAvgResponseTime: PropTypes.number, + }), + }) + ), + label: PropTypes.oneOfType([PropTypes.string, PropTypes.number]), +}; const CustomTick = ({ x, y, payload, index }) => { const theme = useTheme(); const uiTimezone = useSelector((state) => state.ui.timezone); - // Render nothing for the first tick if (index === 0) return null; return ( @@ -168,7 +183,7 @@ const MonitorDetailsAreaChart = ({ checks }) => { } minTickGap={0} axisLine={false} @@ -183,7 +198,7 @@ const MonitorDetailsAreaChart = ({ checks }) => { /> { + const uiTimezone = useSelector((state) => state.ui.timezone); + const dateFormat = type === "day" ? "MMM D, h:mm A" : "MMM D"; + + return ( + <> + + {formatDateWithTz(firstDataPoint._id, dateFormat, uiTimezone)} + + + {formatDateWithTz(lastDataPoint._id, dateFormat, uiTimezone)} + + + ); +}; + +CustomLabels.propTypes = { + x: PropTypes.number.isRequired, + width: PropTypes.oneOfType([PropTypes.number, PropTypes.string]), + height: PropTypes.oneOfType([PropTypes.number, PropTypes.string]), + firstDataPoint: PropTypes.object.isRequired, + lastDataPoint: PropTypes.object.isRequired, + type: PropTypes.string.isRequired, +}; + +export default CustomLabels; diff --git a/Client/src/Pages/Uptime/Details/Charts/DownBarChart.jsx b/Client/src/Pages/Uptime/Details/Charts/DownBarChart.jsx new file mode 100644 index 000000000..a851a93ea --- /dev/null +++ b/Client/src/Pages/Uptime/Details/Charts/DownBarChart.jsx @@ -0,0 +1,89 @@ +import { memo, useState } from "react"; +import { useTheme } from "@mui/material"; +import { ResponsiveContainer, BarChart, XAxis, Bar, Cell } from "recharts"; +import PropTypes from "prop-types"; +import CustomLabels from "./CustomLabels"; + +const DownBarChart = memo(({ stats, type, onBarHover }) => { + const theme = useTheme(); + + const [chartHovered, setChartHovered] = useState(false); + const [hoveredBarIndex, setHoveredBarIndex] = useState(null); + + return ( + + { + setChartHovered(true); + onBarHover({ time: null, totalChecks: 0 }); + }} + onMouseLeave={() => { + setChartHovered(false); + setHoveredBarIndex(null); + onBarHover(null); + }} + > + + } + /> + + {stats.downChecks.map((entry, index) => ( + { + setHoveredBarIndex(index); + onBarHover(entry); + }} + onMouseLeave={() => { + setHoveredBarIndex(null); + onBarHover({ time: null, totalChecks: 0 }); + }} + /> + ))} + + + + ); +}); + +DownBarChart.displayName = "DownBarChart"; +DownBarChart.propTypes = { + stats: PropTypes.shape({ + downChecks: PropTypes.arrayOf(PropTypes.object), + downChecksAggregate: PropTypes.object, + }), + type: PropTypes.string, + onBarHover: PropTypes.func, +}; +export default DownBarChart; diff --git a/Client/src/Pages/Uptime/Details/Charts/ResponseGaugeChart.jsx b/Client/src/Pages/Uptime/Details/Charts/ResponseGaugeChart.jsx new file mode 100644 index 000000000..29e0cc8bb --- /dev/null +++ b/Client/src/Pages/Uptime/Details/Charts/ResponseGaugeChart.jsx @@ -0,0 +1,117 @@ +import PropTypes from "prop-types"; +import { useTheme } from "@mui/material"; +import { ResponsiveContainer, RadialBarChart, RadialBar, Cell } from "recharts"; + +const ResponseGaugeChart = ({ avgResponseTime }) => { + const theme = useTheme(); + + let max = 1000; // max ms + + const data = [ + { response: max, fill: "transparent", background: false }, + { response: avgResponseTime, background: true }, + ]; + let responseTime = Math.floor(avgResponseTime); + let responseProps = + responseTime <= 200 + ? { + category: "Excellent", + main: theme.palette.success.main, + bg: theme.palette.success.contrastText, + } + : responseTime <= 500 + ? { + category: "Fair", + main: theme.palette.success.main, + bg: theme.palette.success.contrastText, + } + : responseTime <= 600 + ? { + category: "Acceptable", + main: theme.palette.warning.main, + bg: theme.palette.warning.dark, + } + : { + category: "Poor", + main: theme.palette.error.main, + bg: theme.palette.error.light, + }; + + return ( + + + + low + + + high + + + {responseProps.category} + + + {responseTime} ms + + + + + + + + ); +}; + +ResponseGaugeChart.propTypes = { + avgResponseTime: PropTypes.number.isRequired, +}; + +export default ResponseGaugeChart; diff --git a/Client/src/Pages/Uptime/Details/Charts/UpBarChart.jsx b/Client/src/Pages/Uptime/Details/Charts/UpBarChart.jsx new file mode 100644 index 000000000..08c8d778e --- /dev/null +++ b/Client/src/Pages/Uptime/Details/Charts/UpBarChart.jsx @@ -0,0 +1,100 @@ +import { memo, useState } from "react"; +import { useTheme } from "@mui/material"; +import { ResponsiveContainer, BarChart, XAxis, Bar, Cell } from "recharts"; +import PropTypes from "prop-types"; +import CustomLabels from "./CustomLabels"; + +const UpBarChart = memo(({ stats, type, onBarHover }) => { + const theme = useTheme(); + const [chartHovered, setChartHovered] = useState(false); + const [hoveredBarIndex, setHoveredBarIndex] = useState(null); + + const getColorRange = (responseTime) => { + return responseTime < 200 + ? { main: theme.palette.success.main, light: theme.palette.success.light } + : responseTime < 300 + ? { main: theme.palette.warning.main, light: theme.palette.warning.light } + : { main: theme.palette.error.main, light: theme.palette.error.light }; + }; + + return ( + + { + setChartHovered(true); + onBarHover({ time: null, totalChecks: 0, avgResponseTime: 0 }); + }} + onMouseLeave={() => { + setChartHovered(false); + setHoveredBarIndex(null); + onBarHover(null); + }} + > + + } + /> + + {stats.upChecks.map((entry, index) => { + let { main, light } = getColorRange(entry.avgResponseTime); + return ( + { + setHoveredBarIndex(index); + onBarHover(entry); + }} + onMouseLeave={() => { + setHoveredBarIndex(null); + onBarHover({ + time: null, + totalChecks: 0, + groupUptimePercentage: 0, + }); + }} + /> + ); + })} + + + + ); +}); + +// Add display name for the component +UpBarChart.displayName = "UpBarChart"; + +// Validate props using PropTypes +UpBarChart.propTypes = { + stats: PropTypes.shape({ + upChecks: PropTypes.array, + upChecksAggregate: PropTypes.object, + }), + type: PropTypes.string, + onBarHover: PropTypes.func, +}; +export default UpBarChart; diff --git a/Client/src/Pages/Uptime/Details/Charts/index.jsx b/Client/src/Pages/Uptime/Details/Charts/index.jsx deleted file mode 100644 index 7c5adc9e2..000000000 --- a/Client/src/Pages/Uptime/Details/Charts/index.jsx +++ /dev/null @@ -1,346 +0,0 @@ -import { useTheme } from "@emotion/react"; -import PropTypes from "prop-types"; -import { - BarChart, - Bar, - XAxis, - ResponsiveContainer, - Cell, - RadialBarChart, - RadialBar, -} from "recharts"; -import { memo, useMemo, useState } from "react"; -import { useSelector } from "react-redux"; -import { formatDateWithTz } from "../../../../Utils/timeUtils"; - -const CustomLabels = ({ x, width, height, firstDataPoint, lastDataPoint, type }) => { - const uiTimezone = useSelector((state) => state.ui.timezone); - const dateFormat = type === "day" ? "MMM D, h:mm A" : "MMM D"; - - return ( - <> - - {formatDateWithTz(new Date(firstDataPoint.time), dateFormat, uiTimezone)} - - - {formatDateWithTz(new Date(lastDataPoint.time), dateFormat, uiTimezone)} - - - ); -}; - -CustomLabels.propTypes = { - x: PropTypes.number.isRequired, - width: PropTypes.oneOfType([PropTypes.number, PropTypes.string]), - height: PropTypes.oneOfType([PropTypes.number, PropTypes.string]), - firstDataPoint: PropTypes.object.isRequired, - lastDataPoint: PropTypes.object.isRequired, - type: PropTypes.string.isRequired, -}; - -const UpBarChart = memo(({ data, type, onBarHover }) => { - const theme = useTheme(); - - const [chartHovered, setChartHovered] = useState(false); - const [hoveredBarIndex, setHoveredBarIndex] = useState(null); - - const getColorRange = (uptime) => { - return uptime > 80 - ? { main: theme.palette.success.main, light: theme.palette.success.light } - : uptime > 50 - ? { main: theme.palette.warning.main, light: theme.palette.warning.light } - : { main: theme.palette.error.contrastText, light: theme.palette.error.light }; - }; - - // TODO - REMOVE THIS LATER - const reversedData = useMemo(() => [...data].reverse(), [data]); - - return ( - - { - setChartHovered(true); - onBarHover({ time: null, totalChecks: 0, uptimePercentage: 0 }); - }} - onMouseLeave={() => { - setChartHovered(false); - setHoveredBarIndex(null); - onBarHover(null); - }} - > - - } - /> - - {reversedData.map((entry, index) => { - let { main, light } = getColorRange(entry.uptimePercentage); - return ( - { - setHoveredBarIndex(index); - onBarHover(entry); - }} - onMouseLeave={() => { - setHoveredBarIndex(null); - onBarHover({ - time: null, - totalChecks: 0, - uptimePercentage: 0, - }); - }} - /> - ); - })} - - - - ); -}); - -// Add display name for the component -UpBarChart.displayName = "UpBarChart"; - -// Validate props using PropTypes -UpBarChart.propTypes = { - data: PropTypes.arrayOf(PropTypes.object), - type: PropTypes.string, - onBarHover: PropTypes.func, -}; -export { UpBarChart }; - -const DownBarChart = memo(({ data, type, onBarHover }) => { - const theme = useTheme(); - - const [chartHovered, setChartHovered] = useState(false); - const [hoveredBarIndex, setHoveredBarIndex] = useState(null); - - // TODO - REMOVE THIS LATER - const reversedData = useMemo(() => [...data].reverse(), [data]); - - return ( - - { - setChartHovered(true); - onBarHover({ time: null, totalIncidents: 0 }); - }} - onMouseLeave={() => { - setChartHovered(false); - setHoveredBarIndex(null); - onBarHover(null); - }} - > - - } - /> - - {reversedData.map((entry, index) => ( - { - setHoveredBarIndex(index); - onBarHover(entry); - }} - onMouseLeave={() => { - setHoveredBarIndex(null); - onBarHover({ time: null, totalIncidents: 0 }); - }} - /> - ))} - - - - ); -}); - -DownBarChart.displayName = "DownBarChart"; -DownBarChart.propTypes = { - data: PropTypes.arrayOf(PropTypes.object), - type: PropTypes.string, - onBarHover: PropTypes.func, -}; -export { DownBarChart }; - -const ResponseGaugeChart = ({ data }) => { - const theme = useTheme(); - - let max = 1000; // max ms - - const memoizedData = useMemo( - () => [{ response: max, fill: "transparent", background: false }, ...data], - [data[0].response] - ); - - let responseTime = Math.floor(memoizedData[1].response); - let responseProps = - responseTime <= 200 - ? { - category: "Excellent", - main: theme.palette.success.main, - bg: theme.palette.success.contrastText, - } - : responseTime <= 500 - ? { - category: "Fair", - main: theme.palette.success.main, - bg: theme.palette.success.contrastText, - } - : responseTime <= 600 - ? { - category: "Acceptable", - main: theme.palette.warning.main, - bg: theme.palette.warning.dark, - } - : { - category: "Poor", - main: theme.palette.error.light, - bg: theme.palette.error.dark, - }; - - return ( - - - - low - - - high - - - {responseProps.category} - - - {responseTime} ms - - - - - - - - ); -}; - -ResponseGaugeChart.propTypes = { - data: PropTypes.arrayOf(PropTypes.object).isRequired, -}; - -export { ResponseGaugeChart }; diff --git a/Client/src/Pages/Uptime/Details/index.jsx b/Client/src/Pages/Uptime/Details/index.jsx index 631667d08..7a2aa6756 100644 --- a/Client/src/Pages/Uptime/Details/index.jsx +++ b/Client/src/Pages/Uptime/Details/index.jsx @@ -5,7 +5,6 @@ import { useSelector } from "react-redux"; import { useNavigate, useParams } from "react-router-dom"; import { networkService } from "../../../main"; import { logger } from "../../../Utils/Logger"; -import { formatDurationRounded, formatDurationSplit } from "../../../Utils/timeUtils"; import MonitorDetailsAreaChart from "../../../Components/Charts/MonitorDetailsAreaChart"; import ButtonGroup from "@mui/material/ButtonGroup"; import SettingsIcon from "../../../assets/icons/settings-bold.svg?react"; @@ -18,14 +17,16 @@ import PaginationTable from "./PaginationTable"; import Breadcrumbs from "../../../Components/Breadcrumbs"; import PulseDot from "../../../Components/Animated/PulseDot"; import { ChartBox } from "./styled"; -import { DownBarChart, ResponseGaugeChart, UpBarChart } from "./Charts"; import SkeletonLayout from "./skeleton"; import "./index.css"; import useUtils from "../utils"; -import { formatDateWithTz } from "../../../Utils/timeUtils"; +import { formatDateWithTz, formatDurationSplit } from "../../../Utils/timeUtils"; import { useIsAdmin } from "../../../Hooks/useIsAdmin"; import IconBox from "../../../Components/IconBox"; import StatBox from "../../../Components/StatBox"; +import UpBarChart from "./Charts/UpBarChart"; +import DownBarChart from "./Charts/DownBarChart"; +import ResponseGaugeChart from "./Charts/ResponseGaugeChart"; /** * Details page component displaying monitor details and related information. * @component @@ -47,16 +48,12 @@ const DetailsPage = () => { const fetchMonitor = useCallback(async () => { try { - const res = await networkService.getStatsByMonitorId({ + const res = await networkService.getUptimeDetailsById({ authToken: authToken, monitorId: monitorId, - sortOrder: null, - limit: null, dateRange: dateRange, - numToDisplay: 50, normalize: true, }); - console.log(res?.data?.data); setMonitor(res?.data?.data ?? {}); } catch (error) { logger.error(error); @@ -185,7 +182,7 @@ const DetailsPage = () => { }, }} > - Checking every {formatDurationRounded(monitor?.interval)}. + {/* Checking every {formatDurationRounded(monitor?.interval)}. */} @@ -224,17 +221,17 @@ const DetailsPage = () => { - {monitor?.latestResponseTime} + {monitor?.stats?.latestResponseTime} {"ms"} } @@ -311,7 +308,7 @@ const DetailsPage = () => { {hoveredUptimeData !== null ? hoveredUptimeData.totalChecks - : monitor?.periodTotalChecks} + : (monitor.stats?.upChecksAggregate?.totalChecks ?? 0)} {hoveredUptimeData !== null && hoveredUptimeData.time !== null && ( { color={theme.palette.text.tertiary} > {formatDateWithTz( - hoveredUptimeData.time, + hoveredUptimeData._id, dateFormat, uiTimezone )} @@ -330,17 +327,27 @@ const DetailsPage = () => { )} - Uptime Percentage + + {hoveredUptimeData !== null + ? "Avg Response Time" + : "Uptime Percentage"} + {hoveredUptimeData !== null - ? Math.floor(hoveredUptimeData.uptimePercentage * 10) / 10 - : Math.floor(monitor?.periodUptime * 10) / 10} - % + ? Math.floor(hoveredUptimeData?.avgResponseTime ?? 0) + : Math.floor( + ((monitor?.stats?.upChecksAggregate?.totalChecks ?? 0) / + (monitor?.stats?.totalChecks ?? 1)) * + 100 + )} + + {hoveredUptimeData !== null ? " ms" : " %"} + @@ -356,8 +363,8 @@ const DetailsPage = () => { Total Incidents {hoveredIncidentsData !== null - ? hoveredIncidentsData.totalIncidents - : monitor?.periodIncidents} + ? hoveredIncidentsData.totalChecks + : (monitor.stats?.downChecksAggregate?.totalChecks ?? 0)} {hoveredIncidentsData !== null && hoveredIncidentsData.time !== null && ( @@ -369,7 +376,7 @@ const DetailsPage = () => { color={theme.palette.text.tertiary} > {formatDateWithTz( - hoveredIncidentsData.time, + hoveredIncidentsData._id, dateFormat, uiTimezone )} @@ -377,7 +384,7 @@ const DetailsPage = () => { )} @@ -390,7 +397,7 @@ const DetailsPage = () => { Average Response Time @@ -403,7 +410,7 @@ const DetailsPage = () => { Response Times - + { }; export const formatDateWithTz = (timestamp, format, timezone) => { - const formattedDate = dayjs(timestamp, timezone).tz(timezone).format(format); + const formattedDate = dayjs(timestamp).tz(timezone).format(format); return formattedDate; }; diff --git a/README.md b/README.md index 3b39ad91b..35e3f0fd5 100644 --- a/README.md +++ b/README.md @@ -1,10 +1,9 @@ -**Need support or have a suggestion? Check our [Discord channel](https://discord.gg/NAb6H3UTjK) or [Discussions](https://github.com/bluewave-labs/checkmate/discussions) forum.** -**Checkmate is on [GitHub trending](https://github.com/trending) and #1 trending for JavaScript apps on Fri Dec 13th & 15th** +# **We're opening our $5000 grant funding announcement soon, powered by Checkmate and [UpRock](https://uprock.com) - check [our web page](https://checkmate.so) for preliminary details.** -**If you would like to support us, please consider giving it a ⭐, and think about contributing or providing feedback.** +**If you would like to support us, please consider giving it a ⭐, and think about contributing or providing feedback. Need support or have a suggestion? Check our [Discord channel](https://discord.gg/NAb6H3UTjK) or [Discussions](https://github.com/bluewave-labs/checkmate/discussions) forum.** -![Frame 34](https://github.com/user-attachments/assets/4bf57845-3f47-4759-835b-285a5486191d) +Frame 34 ![](https://img.shields.io/github/license/bluewave-labs/checkmate) @@ -53,20 +52,21 @@ If you have any questions, suggestions or comments, please use our [Discord chan - Infrastructure monitoring (memory, disk usage, CPU performance etc) - requires [Capture](https://github.com/bluewave-labs/capture) - Docker monitoring - Ping monitoring +- SSL monitoring - Incidents at a glance - E-mail notifications - Scheduled maintenance -- Can monitor 1000+ servers at the same time on a moderate server **Short term roadmap:** -- Global (distributed) uptime checking on Solana network -- Status pages -- Better notification options (think Discord, Telegram, Slack) +- Port monitoring (**complete**, waiting to be deployed to stable version) +- Global (distributed) uptime checking on Solana network (**in progress**) +- Status pages (**in progress**) +- Better notification options (Webhooks, Discord, Telegram, Slack) - More configuration options +- Translations +- Tagging/grouping monitors - DNS monitoring -- SSL monitoring -- Port monitoring ## 🏗️ Screenshots diff --git a/Server/controllers/monitorController.js b/Server/controllers/monitorController.js index e1e37aae0..5e16b3b18 100644 --- a/Server/controllers/monitorController.js +++ b/Server/controllers/monitorController.js @@ -76,6 +76,19 @@ class MonitorController { } }; + getUptimeDetailsById = async (req, res, next) => { + try { + const monitor = await this.db.getUptimeDetailsById(req); + return res.status(200).json({ + success: true, + msg: successMessages.MONITOR_GET_BY_ID, + data: monitor, + }); + } catch (error) { + next(handleError(error, SERVICE_NAME, "getMonitorDetailsById")); + } + }; + /** * Returns monitor stats for monitor with matching ID * @async diff --git a/Server/db/mongo/modules/monitorModule.js b/Server/db/mongo/modules/monitorModule.js index 94ebf9352..1143b9eeb 100644 --- a/Server/db/mongo/modules/monitorModule.js +++ b/Server/db/mongo/modules/monitorModule.js @@ -4,7 +4,7 @@ import PageSpeedCheck from "../../models/PageSpeedCheck.js"; import HardwareCheck from "../../models/HardwareCheck.js"; import { errorMessages } from "../../../utils/messages.js"; import Notification from "../../models/Notification.js"; -import { NormalizeData } from "../../../utils/dataUtils.js"; +import { NormalizeData, NormalizeDataUptimeDetails } from "../../../utils/dataUtils.js"; import fs from "fs"; import path from "path"; import { fileURLToPath } from "url"; @@ -311,6 +311,329 @@ const calculateGroupStats = (group) => { }; }; +/** + * Get uptime details by monitor ID + * @async + * @param {Express.Request} req + * @param {Express.Response} res + * @returns {Promise} + * @throws {Error} + */ +const getUptimeDetailsById = async (req) => { + try { + const { monitorId } = req.params; + const monitor = await Monitor.findById(monitorId); + if (monitor === null || monitor === undefined) { + throw new Error(errorMessages.DB_FIND_MONITOR_BY_ID(monitorId)); + } + + const { dateRange, normalize } = req.query; + 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 monitorData = await Check.aggregate([ + { + $match: { + monitorId: monitor._id, + }, + }, + { + $sort: { + createdAt: 1, + }, + }, + { + $facet: { + aggregateData: [ + { + $group: { + _id: null, + avgResponseTime: { + $avg: "$responseTime", + }, + firstCheck: { + $first: "$$ROOT", + }, + lastCheck: { + $last: "$$ROOT", + }, + totalChecks: { + $sum: 1, + }, + }, + }, + ], + uptimeDuration: [ + { + $match: { + status: false, + }, + }, + { + $sort: { + createdAt: 1, + }, + }, + { + $group: { + _id: null, + lastFalseCheck: { + $last: "$$ROOT", + }, + }, + }, + ], + groupChecks: [ + { + $match: { + createdAt: { $gte: dates.start, $lte: dates.end }, + }, + }, + { + $group: { + _id: { + $dateToString: { + format: dateString, + date: "$createdAt", + }, + }, + avgResponseTime: { + $avg: "$responseTime", + }, + totalChecks: { + $sum: 1, + }, + }, + }, + { + $sort: { + _id: 1, + }, + }, + ], + groupAggregate: [ + { + $match: { + createdAt: { $gte: dates.start, $lte: dates.end }, + }, + }, + { + $group: { + _id: null, + avgResponseTime: { + $avg: "$responseTime", + }, + }, + }, + ], + upChecksAggregate: [ + { + $match: { + status: true, + }, + }, + { + $group: { + _id: null, + avgResponseTime: { + $avg: "$responseTime", + }, + totalChecks: { + $sum: 1, + }, + }, + }, + ], + upChecks: [ + { + $match: { + status: true, + createdAt: { $gte: dates.start, $lte: dates.end }, + }, + }, + { + $group: { + _id: { + $dateToString: { + format: dateString, + date: "$createdAt", + }, + }, + totalChecks: { + $sum: 1, + }, + avgResponseTime: { + $avg: "$responseTime", + }, + }, + }, + { + $sort: { _id: 1 }, + }, + ], + downChecksAggregate: [ + { + $match: { + status: false, + }, + }, + { + $group: { + _id: null, + avgResponseTime: { + $avg: "$responseTime", + }, + totalChecks: { + $sum: 1, + }, + }, + }, + ], + downChecks: [ + { + $match: { + status: false, + createdAt: { $gte: dates.start, $lte: dates.end }, + }, + }, + { + $group: { + _id: { + $dateToString: { + format: dateString, + date: "$createdAt", + }, + }, + totalChecks: { + $sum: 1, + }, + avgResponseTime: { + $avg: "$responseTime", + }, + }, + }, + { + $sort: { _id: 1 }, + }, + ], + }, + }, + { + $project: { + avgResponseTime: { + $arrayElemAt: ["$aggregateData.avgResponseTime", 0], + }, + totalChecks: { + $arrayElemAt: ["$aggregateData.totalChecks", 0], + }, + latestResponseTime: { + $arrayElemAt: ["$aggregateData.lastCheck.responseTime", 0], + }, + timeSinceLastCheck: { + $let: { + vars: { + lastCheck: { + $arrayElemAt: ["$aggregateData.lastCheck", 0], + }, + }, + in: { + $cond: [ + { + $ifNull: ["$$lastCheck", false], + }, + { + $subtract: [new Date(), "$$lastCheck.createdAt"], + }, + 0, + ], + }, + }, + }, + timeSinceLastFalseCheck: { + $let: { + vars: { + lastFalseCheck: { + $arrayElemAt: ["$uptimeDuration.lastFalseCheck", 0], + }, + firstCheck: { + $arrayElemAt: ["$aggregateData.firstCheck", 0], + }, + }, + in: { + $cond: [ + { + $ifNull: ["$$lastFalseCheck", false], + }, + { + $subtract: [new Date(), "$$lastFalseCheck.createdAt"], + }, + { + $cond: [ + { + $ifNull: ["$$firstCheck", false], + }, + { + $subtract: [new Date(), "$$firstCheck.createdAt"], + }, + 0, + ], + }, + ], + }, + }, + }, + groupChecks: "$groupChecks", + groupAggregate: { + $arrayElemAt: ["$groupAggregate", 0], + }, + upChecksAggregate: { + $arrayElemAt: ["$upChecksAggregate", 0], + }, + upChecks: "$upChecks", + downChecksAggregate: { + $arrayElemAt: ["$downChecksAggregate", 0], + }, + downChecks: "$downChecks", + }, + }, + ]); + + const normalizedGroupChecks = NormalizeDataUptimeDetails( + monitorData[0].groupChecks, + 10, + 100 + ); + + const monitorStats = { + ...monitor.toObject(), + stats: { + avgResponseTime: monitorData[0].avgResponseTime, + totalChecks: monitorData[0].totalChecks, + timeSinceLastCheck: monitorData[0].timeSinceLastCheck, + timeSinceLastFalseCheck: monitorData[0].timeSinceLastFalseCheck, + latestResponseTime: monitorData[0].latestResponseTime, + groupChecks: normalizedGroupChecks, + groupAggregate: monitorData[0].groupAggregate, + upChecksAggregate: monitorData[0].upChecksAggregate, + upChecks: monitorData[0].upChecks, + downChecksAggregate: monitorData[0].downChecksAggregate, + downChecks: monitorData[0].downChecks, + }, + }; + + return monitorStats; + } catch (error) { + error.service = SERVICE_NAME; + error.method = "getUptimeDetailsById"; + throw error; + } +}; + /** * Get stats by monitor ID * @async @@ -939,6 +1262,7 @@ export { getAllMonitorsWithUptimeStats, getMonitorStatsById, getMonitorById, + getUptimeDetailsById, getMonitorsAndSummaryByTeamId, getMonitorsByTeamId, createMonitor, diff --git a/Server/package-lock.json b/Server/package-lock.json index e9dc276c4..3b7c616d2 100644 --- a/Server/package-lock.json +++ b/Server/package-lock.json @@ -11,7 +11,7 @@ "dependencies": { "axios": "^1.7.2", "bcrypt": "5.1.1", - "bullmq": "5.34.5", + "bullmq": "5.34.6", "cors": "^2.8.5", "dockerode": "4.0.2", "dotenv": "^16.4.5", @@ -1541,9 +1541,9 @@ } }, "node_modules/bullmq": { - "version": "5.34.5", - "resolved": "https://registry.npmjs.org/bullmq/-/bullmq-5.34.5.tgz", - "integrity": "sha512-MHho9EOhLCTY3ZF+dd0wHv0VlY2FtpBcopMRsvj0kPra4TAwBFh2pik/s4WbX56cIfCE+VzfHIHy4xvqp3g1+Q==", + "version": "5.34.6", + "resolved": "https://registry.npmjs.org/bullmq/-/bullmq-5.34.6.tgz", + "integrity": "sha512-pRCYyO9RlkQWxdmKlrNnUthyFwurYXRYLVXD1YIx+nCCdhAOiHatD8FDHbsT/w2I31c0NWoMcfZiIGuipiF7Lg==", "license": "MIT", "dependencies": { "cron-parser": "^4.9.0", diff --git a/Server/package.json b/Server/package.json index c8460aa03..894aca859 100644 --- a/Server/package.json +++ b/Server/package.json @@ -18,7 +18,7 @@ "dependencies": { "axios": "^1.7.2", "bcrypt": "5.1.1", - "bullmq": "5.34.5", + "bullmq": "5.34.6", "cors": "^2.8.5", "dockerode": "4.0.2", "dotenv": "^16.4.5", diff --git a/Server/routes/monitorRoute.js b/Server/routes/monitorRoute.js index 9decd4339..d47351486 100644 --- a/Server/routes/monitorRoute.js +++ b/Server/routes/monitorRoute.js @@ -17,6 +17,10 @@ class MonitorRoutes { "/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/utils/dataUtils.js b/Server/utils/dataUtils.js index b0f8a2fd1..fd10a4257 100644 --- a/Server/utils/dataUtils.js +++ b/Server/utils/dataUtils.js @@ -8,12 +8,23 @@ const calculatePercentile = (arr, percentile) => { return sorted[lower].responseTime * (1 - weight) + sorted[upper].responseTime * weight; }; +const calculatePercentileUptimeDetails = (arr, percentile) => { + const sorted = arr.slice().sort((a, b) => a.avgResponseTime - b.avgResponseTime); + const index = (percentile / 100) * (sorted.length - 1); + const lower = Math.floor(index); + const upper = lower + 1; + const weight = index % 1; + if (upper >= sorted.length) return sorted[lower].avgResponseTime; + return ( + sorted[lower].avgResponseTime * (1 - weight) + sorted[upper].avgResponseTime * weight + ); +}; + const NormalizeData = (checks, rangeMin, rangeMax) => { if (checks.length > 1) { // Get the 5th and 95th percentile const min = calculatePercentile(checks, 0); const max = calculatePercentile(checks, 95); - const normalizedChecks = checks.map((check) => { const originalResponseTime = check.responseTime; // Normalize the response time between 1 and 100 @@ -40,5 +51,42 @@ const NormalizeData = (checks, rangeMin, rangeMax) => { }); } }; +const NormalizeDataUptimeDetails = (checks, rangeMin, rangeMax) => { + if (checks.length > 1) { + // Get the 5th and 95th percentile + const min = calculatePercentileUptimeDetails(checks, 0); + const max = calculatePercentileUptimeDetails(checks, 95); -export { calculatePercentile, NormalizeData }; + const normalizedChecks = checks.map((check) => { + const originalResponseTime = check.avgResponseTime; + // Normalize the response time between 1 and 100 + let normalizedResponseTime = + rangeMin + ((check.avgResponseTime - min) * (rangeMax - rangeMin)) / (max - min); + + // Put a floor on the response times so we don't have extreme outliers + // Better visuals + normalizedResponseTime = Math.max( + rangeMin, + Math.min(rangeMax, normalizedResponseTime) + ); + return { + ...check, + avgResponseTime: normalizedResponseTime, + originalAvgResponseTime: originalResponseTime, + }; + }); + + return normalizedChecks; + } else { + return checks.map((check) => { + return { ...check, originalResponseTime: check.responseTime }; + }); + } +}; + +export { + calculatePercentile, + NormalizeData, + calculatePercentileUptimeDetails, + NormalizeDataUptimeDetails, +};