Merge branch 'develop' into feat/be/hardware-details

This commit is contained in:
Alexander Holliday
2025-01-02 09:58:19 -08:00
committed by GitHub
16 changed files with 818 additions and 406 deletions

View File

@@ -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 (
<Box
className="area-tooltip"
@@ -69,7 +71,7 @@ const CustomToolTip = ({ active, payload, label }) => {
Response Time
</Typography>{" "}
<Typography component="span">
{payload[0].payload.originalResponseTime}
{Math.floor(responseTime)}
<Typography
component="span"
sx={{ opacity: 0.8 }}
@@ -87,11 +89,24 @@ const CustomToolTip = ({ active, payload, label }) => {
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 }) => {
</defs>
<XAxis
stroke={theme.palette.border.dark}
dataKey="createdAt"
dataKey="_id"
tick={<CustomTick />}
minTickGap={0}
axisLine={false}
@@ -183,7 +198,7 @@ const MonitorDetailsAreaChart = ({ checks }) => {
/>
<Area
type="monotone"
dataKey="responseTime"
dataKey="avgResponseTime"
stroke={theme.palette.primary.main}
fill="url(#colorUv)"
strokeWidth={isHovered ? 2.5 : 1.5}
@@ -198,15 +213,4 @@ MonitorDetailsAreaChart.propTypes = {
checks: PropTypes.array,
};
CustomToolTip.propTypes = {
active: PropTypes.bool,
payload: PropTypes.arrayOf(
PropTypes.shape({
payload: PropTypes.shape({
originalResponseTime: PropTypes.number.isRequired,
}).isRequired,
})
),
label: PropTypes.oneOfType([PropTypes.string, PropTypes.number]),
};
export default MonitorDetailsAreaChart;

View File

@@ -0,0 +1,42 @@
import PropTypes from "prop-types";
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 (
<>
<text
x={x}
y={height}
dy={-3}
textAnchor="start"
fontSize={11}
>
{formatDateWithTz(firstDataPoint._id, dateFormat, uiTimezone)}
</text>
<text
x={width}
y={height}
dy={-3}
textAnchor="end"
fontSize={11}
>
{formatDateWithTz(lastDataPoint._id, dateFormat, uiTimezone)}
</text>
</>
);
};
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;

View File

@@ -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 (
<ResponsiveContainer
width="100%"
minWidth={250}
height={155}
>
<BarChart
width="100%"
height="100%"
data={stats.downChecks}
onMouseEnter={() => {
setChartHovered(true);
onBarHover({ time: null, totalChecks: 0 });
}}
onMouseLeave={() => {
setChartHovered(false);
setHoveredBarIndex(null);
onBarHover(null);
}}
>
<XAxis
stroke={theme.palette.border.dark}
height={15}
tick={false}
label={
<CustomLabels
x={0}
y={0}
width="100%"
height="100%"
firstDataPoint={stats.downChecks?.[0] ?? {}}
lastDataPoint={stats.downChecks?.[stats.downChecks.length - 1] ?? {}}
type={type}
/>
}
/>
<Bar
dataKey="avgResponseTime"
maxBarSize={7}
background={{ fill: "transparent" }}
>
{stats.downChecks.map((entry, index) => (
<Cell
key={`cell-${entry.time}`}
fill={
hoveredBarIndex === index
? theme.palette.error.main
: chartHovered
? theme.palette.error.light
: theme.palette.error.main
}
onMouseEnter={() => {
setHoveredBarIndex(index);
onBarHover(entry);
}}
onMouseLeave={() => {
setHoveredBarIndex(null);
onBarHover({ time: null, totalChecks: 0 });
}}
/>
))}
</Bar>
</BarChart>
</ResponsiveContainer>
);
});
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;

View File

@@ -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 (
<ResponsiveContainer
width="100%"
minWidth={210}
height={155}
>
<RadialBarChart
width="100%"
height="100%"
cy="89%"
data={data}
startAngle={180}
endAngle={0}
innerRadius={100}
outerRadius={150}
>
<text
x={0}
y="100%"
dx="5%"
dy={-2}
textAnchor="start"
fontSize={11}
>
low
</text>
<text
x="100%"
y="100%"
dx="-3%"
dy={-2}
textAnchor="end"
fontSize={11}
>
high
</text>
<text
x="50%"
y="45%"
textAnchor="middle"
dominantBaseline="middle"
fontSize={18}
fontWeight={400}
>
{responseProps.category}
</text>
<text
x="50%"
y="55%"
textAnchor="middle"
dominantBaseline="hanging"
fontSize={25}
>
<tspan fontWeight={600}>{responseTime}</tspan> <tspan opacity={0.8}>ms</tspan>
</text>
<RadialBar
background={{ fill: responseProps.bg }}
clockWise
dataKey="response"
stroke="none"
>
<Cell
fill="transparent"
background={false}
barSize={0}
/>
<Cell fill={responseProps.main} />
</RadialBar>
</RadialBarChart>
</ResponsiveContainer>
);
};
ResponseGaugeChart.propTypes = {
avgResponseTime: PropTypes.number.isRequired,
};
export default ResponseGaugeChart;

View File

@@ -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 (
<ResponsiveContainer
width="100%"
minWidth={210}
height={155}
>
<BarChart
width="100%"
height="100%"
data={stats.upChecks}
onMouseEnter={() => {
setChartHovered(true);
onBarHover({ time: null, totalChecks: 0, avgResponseTime: 0 });
}}
onMouseLeave={() => {
setChartHovered(false);
setHoveredBarIndex(null);
onBarHover(null);
}}
>
<XAxis
stroke={theme.palette.border.dark}
height={15}
tick={false}
label={
<CustomLabels
x={0}
y={0}
width="100%"
height="100%"
firstDataPoint={stats.upChecks[0]}
lastDataPoint={stats.upChecks[stats.upChecks.length - 1]}
type={type}
/>
}
/>
<Bar
dataKey="avgResponseTime"
maxBarSize={7}
background={{ fill: "transparent" }}
>
{stats.upChecks.map((entry, index) => {
let { main, light } = getColorRange(entry.avgResponseTime);
return (
<Cell
key={`cell-${entry.time}`}
fill={hoveredBarIndex === index ? main : chartHovered ? light : main}
onMouseEnter={() => {
setHoveredBarIndex(index);
onBarHover(entry);
}}
onMouseLeave={() => {
setHoveredBarIndex(null);
onBarHover({
time: null,
totalChecks: 0,
groupUptimePercentage: 0,
});
}}
/>
);
})}
</Bar>
</BarChart>
</ResponsiveContainer>
);
});
// 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;

View File

@@ -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 (
<>
<text
x={x}
y={height}
dy={-3}
textAnchor="start"
fontSize={11}
>
{formatDateWithTz(new Date(firstDataPoint.time), dateFormat, uiTimezone)}
</text>
<text
x={width}
y={height}
dy={-3}
textAnchor="end"
fontSize={11}
>
{formatDateWithTz(new Date(lastDataPoint.time), dateFormat, uiTimezone)}
</text>
</>
);
};
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 (
<ResponsiveContainer
width="100%"
minWidth={210}
height={155}
>
<BarChart
width="100%"
height="100%"
data={reversedData}
onMouseEnter={() => {
setChartHovered(true);
onBarHover({ time: null, totalChecks: 0, uptimePercentage: 0 });
}}
onMouseLeave={() => {
setChartHovered(false);
setHoveredBarIndex(null);
onBarHover(null);
}}
>
<XAxis
stroke={theme.palette.border.dark}
height={15}
tick={false}
label={
<CustomLabels
x={0}
y={0}
width="100%"
height="100%"
firstDataPoint={reversedData[0]}
lastDataPoint={reversedData[reversedData.length - 1]}
type={type}
/>
}
/>
<Bar
dataKey="totalChecks"
maxBarSize={7}
background={{ fill: "transparent" }}
>
{reversedData.map((entry, index) => {
let { main, light } = getColorRange(entry.uptimePercentage);
return (
<Cell
key={`cell-${entry.time}`}
fill={hoveredBarIndex === index ? main : chartHovered ? light : main}
onMouseEnter={() => {
setHoveredBarIndex(index);
onBarHover(entry);
}}
onMouseLeave={() => {
setHoveredBarIndex(null);
onBarHover({
time: null,
totalChecks: 0,
uptimePercentage: 0,
});
}}
/>
);
})}
</Bar>
</BarChart>
</ResponsiveContainer>
);
});
// 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 (
<ResponsiveContainer
width="100%"
minWidth={250}
height={155}
>
<BarChart
width="100%"
height="100%"
data={reversedData}
onMouseEnter={() => {
setChartHovered(true);
onBarHover({ time: null, totalIncidents: 0 });
}}
onMouseLeave={() => {
setChartHovered(false);
setHoveredBarIndex(null);
onBarHover(null);
}}
>
<XAxis
stroke={theme.palette.border.dark}
height={15}
tick={false}
label={
<CustomLabels
x={0}
y={0}
width="100%"
height="100%"
firstDataPoint={reversedData[0]}
lastDataPoint={reversedData[reversedData.length - 1]}
type={type}
/>
}
/>
<Bar
dataKey="totalIncidents"
maxBarSize={7}
background={{ fill: "transparent" }}
>
{reversedData.map((entry, index) => (
<Cell
key={`cell-${entry.time}`}
fill={
hoveredBarIndex === index
? theme.palette.error.contrastText
: chartHovered
? theme.palette.error.light
: theme.palette.error.contrastText
}
onMouseEnter={() => {
setHoveredBarIndex(index);
onBarHover(entry);
}}
onMouseLeave={() => {
setHoveredBarIndex(null);
onBarHover({ time: null, totalIncidents: 0 });
}}
/>
))}
</Bar>
</BarChart>
</ResponsiveContainer>
);
});
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 (
<ResponsiveContainer
width="100%"
minWidth={210}
height={155}
>
<RadialBarChart
width="100%"
height="100%"
cy="89%"
data={memoizedData}
startAngle={180}
endAngle={0}
innerRadius={100}
outerRadius={150}
>
<text
x={0}
y="100%"
dx="5%"
dy={-2}
textAnchor="start"
fontSize={11}
>
low
</text>
<text
x="100%"
y="100%"
dx="-3%"
dy={-2}
textAnchor="end"
fontSize={11}
>
high
</text>
<text
x="50%"
y="45%"
textAnchor="middle"
dominantBaseline="middle"
fontSize={18}
fontWeight={400}
>
{responseProps.category}
</text>
<text
x="50%"
y="55%"
textAnchor="middle"
dominantBaseline="hanging"
fontSize={25}
>
<tspan fontWeight={600}>{responseTime}</tspan> <tspan opacity={0.8}>ms</tspan>
</text>
<RadialBar
background={{ fill: responseProps.bg }}
clockWise
dataKey="response"
stroke="none"
>
<Cell
fill="transparent"
background={false}
barSize={0}
/>
<Cell fill={responseProps.main} />
</RadialBar>
</RadialBarChart>
</ResponsiveContainer>
);
};
ResponseGaugeChart.propTypes = {
data: PropTypes.arrayOf(PropTypes.object).isRequired,
};
export { ResponseGaugeChart };

View File

@@ -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)}. */}
</Typography>
</Stack>
</Box>
@@ -224,17 +221,17 @@ const DetailsPage = () => {
<StatBox
sx={statusStyles[determineState(monitor)]}
heading={"active for"}
subHeading={splitDuration(monitor?.uptimeDuration)}
subHeading={splitDuration(monitor?.stats?.timeSinceLastFalseCheck)}
/>
<StatBox
heading="last check"
subHeading={splitDuration(monitor?.lastChecked)}
subHeading={splitDuration(monitor?.stats?.timeSinceLastCheck)}
/>
<StatBox
heading="last response time"
subHeading={
<>
{monitor?.latestResponseTime}
{monitor?.stats?.latestResponseTime}
<Typography component="span">{"ms"}</Typography>
</>
}
@@ -311,7 +308,7 @@ const DetailsPage = () => {
<Typography component="span">
{hoveredUptimeData !== null
? hoveredUptimeData.totalChecks
: monitor?.periodTotalChecks}
: (monitor.stats?.upChecksAggregate?.totalChecks ?? 0)}
</Typography>
{hoveredUptimeData !== null && hoveredUptimeData.time !== null && (
<Typography
@@ -322,7 +319,7 @@ const DetailsPage = () => {
color={theme.palette.text.tertiary}
>
{formatDateWithTz(
hoveredUptimeData.time,
hoveredUptimeData._id,
dateFormat,
uiTimezone
)}
@@ -330,17 +327,27 @@ const DetailsPage = () => {
)}
</Box>
<Box>
<Typography>Uptime Percentage</Typography>
<Typography>
{hoveredUptimeData !== null
? "Avg Response Time"
: "Uptime Percentage"}
</Typography>
<Typography component="span">
{hoveredUptimeData !== null
? Math.floor(hoveredUptimeData.uptimePercentage * 10) / 10
: Math.floor(monitor?.periodUptime * 10) / 10}
<Typography component="span">%</Typography>
? Math.floor(hoveredUptimeData?.avgResponseTime ?? 0)
: Math.floor(
((monitor?.stats?.upChecksAggregate?.totalChecks ?? 0) /
(monitor?.stats?.totalChecks ?? 1)) *
100
)}
<Typography component="span">
{hoveredUptimeData !== null ? " ms" : " %"}
</Typography>
</Typography>
</Box>
</Stack>
<UpBarChart
data={monitor?.aggregateData}
stats={monitor?.stats}
type={dateRange}
onBarHover={setHoveredUptimeData}
/>
@@ -356,8 +363,8 @@ const DetailsPage = () => {
<Typography>Total Incidents</Typography>
<Typography component="span">
{hoveredIncidentsData !== null
? hoveredIncidentsData.totalIncidents
: monitor?.periodIncidents}
? hoveredIncidentsData.totalChecks
: (monitor.stats?.downChecksAggregate?.totalChecks ?? 0)}
</Typography>
{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 = () => {
)}
</Box>
<DownBarChart
data={monitor?.aggregateData}
stats={monitor?.stats}
type={dateRange}
onBarHover={setHoveredIncidentsData}
/>
@@ -390,7 +397,7 @@ const DetailsPage = () => {
<Typography component="h2">Average Response Time</Typography>
</Stack>
<ResponseGaugeChart
data={[{ response: monitor?.periodAvgResponseTime }]}
avgResponseTime={monitor?.stats?.groupAggregate?.avgResponseTime ?? 0}
/>
</ChartBox>
<ChartBox sx={{ padding: 0 }}>
@@ -403,7 +410,7 @@ const DetailsPage = () => {
</IconBox>
<Typography component="h2">Response Times</Typography>
</Stack>
<MonitorDetailsAreaChart checks={[...monitor.checks].reverse()} />
<MonitorDetailsAreaChart checks={monitor?.stats?.groupChecks ?? []} />
</ChartBox>
<ChartBox
gap={theme.spacing(8)}

View File

@@ -248,6 +248,13 @@ class NetworkService {
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);
if (config.normalize) params.append("normalize", config.normalize);
return this.axiosInstance.get(
`/monitors/uptime/details/${config.monitorId}?${params.toString()}`,
{
headers: {
Authorization: `Bearer ${config.authToken}`,

View File

@@ -1,8 +1,11 @@
import dayjs from "dayjs";
import utc from "dayjs/plugin/utc";
import timezone from "dayjs/plugin/timezone";
import customParseFormat from "dayjs/plugin/customParseFormat";
dayjs.extend(utc);
dayjs.extend(timezone);
dayjs.extend(customParseFormat);
export const MS_PER_SECOND = 1000;
export const MS_PER_MINUTE = 60 * MS_PER_SECOND;
@@ -90,6 +93,6 @@ export const formatDate = (date, customOptions) => {
};
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;
};

View File

@@ -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)
<img width="1259" alt="Frame 34" src="https://github.com/user-attachments/assets/d491a734-fd7a-4841-9f84-fb5bef5ad586" />
![](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

View File

@@ -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

View File

@@ -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<Monitor>}
* @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,

View File

@@ -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",

View File

@@ -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",

View File

@@ -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,

View File

@@ -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,
};