Merge branch 'develop' of github.com:bluewave-labs/Checkmate into fix/latest-incident-overflow

This commit is contained in:
mannilakash
2026-01-29 13:22:20 -05:00
83 changed files with 1925 additions and 5784 deletions
+2 -3
View File
@@ -79,9 +79,8 @@ You can see the memory footprint of MongoDB and Redis on the same server (398Mb
If you have any questions, suggestions or comments, you have several options:
- [Discord channel](https://discord.gg/NAb6H3UTjK)
- [GitHub Discussions](https://github.com/bluewave-labs/bluewave-uptime/discussions)
- [Reddit group](https://www.reddit.com/r/CheckmateMonitoring/)
- [Discord channel](https://discord.gg/NAb6H3UTjK) (preferred)
- [GitHub Discussions](https://github.com/bluewave-labs/bluewave-uptime/discussions) (we check here from time to time)
Feel free to ask questions or share your ideas - we'd love to hear from you!
@@ -0,0 +1,106 @@
import Box from "@mui/material/Box";
import Typography from "@mui/material/Typography";
import { useTheme } from "@mui/material/styles";
import { useMemo, useState, useEffect } from "react";
import { getInfraGaugeColor } from "@/Utils/MonitorUtils";
const MINIMUM_VALUE = 0;
const MAXIMUM_VALUE = 100;
export const Gauge = ({
isLoading = false,
progress = 0,
radius = 70,
strokeWidth = 15,
precision = 1,
unit = "%",
}: {
isLoading?: boolean;
progress?: number;
radius?: number;
strokeWidth?: number;
precision?: number;
unit?: string;
}) => {
const theme = useTheme();
const progressWithinRange = Math.max(MINIMUM_VALUE, Math.min(progress, MAXIMUM_VALUE));
// 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);
useEffect(() => {
setOffset(circumference);
const timer = setTimeout(() => {
setOffset(circumference - strokeLength);
}, 100);
return () => clearTimeout(timer);
}, [progress, circumference, strokeLength]);
const fillColor = getInfraGaugeColor(progressWithinRange, theme);
if (isLoading) {
return;
}
return (
<Box
display={"inline-block"}
position={"relative"}
width={radius}
height={radius}
bgcolor={theme.palette.background.paper}
borderRadius={"50%"}
>
<svg
viewBox={`0 0 ${totalSize} ${totalSize}`}
width={radius}
height={radius}
>
<circle
stroke={theme.palette.secondary.main}
strokeWidth={strokeWidth}
fill="none"
cx={totalSize / 2}
cy={totalSize / 2}
r={radius}
/>
<circle
stroke={fillColor}
strokeWidth={strokeWidth}
strokeDasharray={`${circumference} ${circumference}`}
strokeDashoffset={offset}
fill="none"
cx={totalSize / 2}
cy={totalSize / 2}
r={radius}
style={{
transform: "rotate(-90deg)",
transformOrigin: "center",
transition: "stroke-dashoffset 1.5s ease-in-out",
}}
/>
</svg>
<Typography
style={{
position: "absolute",
top: "50%",
left: "50%",
transform: "translate(-50%, -50%)",
}}
>
{`${progressWithinRange.toFixed(precision)}${unit}`}
</Typography>
</Box>
);
};
@@ -0,0 +1,64 @@
import MuiTabs from "@mui/material/Tabs";
import type { TabsProps } from "@mui/material/Tabs";
import { useTheme } from "@mui/material/styles";
interface CustomTabsProps extends TabsProps {}
export const Tabs = (props: CustomTabsProps) => {
const theme = useTheme();
return (
<MuiTabs
sx={{
minHeight: 34,
borderBottom: `1px solid ${theme.palette.divider}`,
"& .MuiTabs-indicator": {
backgroundColor: theme.palette.primary.main,
height: 2,
bottom: 0,
},
"& .MuiTabs-flexContainer": {
gap: theme.spacing(16),
},
}}
{...props}
>
{props.children}
</MuiTabs>
);
};
import MuiTab from "@mui/material/Tab";
import type { TabProps } from "@mui/material/Tab";
interface CustomTabProps extends TabProps {}
export const Tab = (props: CustomTabProps) => {
const theme = useTheme();
return (
<MuiTab
disableRipple
iconPosition="start"
sx={{
textTransform: "none",
fontSize: 14,
fontWeight: 500,
minHeight: 34,
padding: theme.spacing(1, 0),
paddingBottom: 0,
minWidth: "auto",
alignItems: "flex-start",
color: theme.palette.text.secondary,
"&.Mui-selected": {
color: theme.palette.primary.main,
fontWeight: 600,
},
"&:hover": {
color: theme.palette.text.secondary,
},
"& .MuiTab-iconWrapper": {
marginRight: theme.spacing(2),
marginBottom: 0,
},
}}
{...props}
/>
);
};
@@ -15,3 +15,5 @@ export { default as Icon } from "./Icon";
export * from "./Tooltip";
export * from "./StatBox";
export * from "./BaseChart";
export * from "./Gauge";
export * from "./Tabs";
@@ -11,6 +11,7 @@ const statuses = ["up", "down"];
const states = ["active", "paused"];
export const ControlsFilter = ({
showTypes = true,
selectedTypes,
setSelectedTypes,
selectedStatus,
@@ -19,8 +20,9 @@ export const ControlsFilter = ({
setSelectedState,
onClearFilters,
}: {
selectedTypes: MonitorType[];
setSelectedTypes: React.Dispatch<React.SetStateAction<MonitorType[]>>;
showTypes?: boolean;
selectedTypes?: MonitorType[];
setSelectedTypes?: React.Dispatch<React.SetStateAction<MonitorType[]>>;
selectedStatus: string;
setSelectedStatus: React.Dispatch<React.SetStateAction<string>>;
selectedState: string;
@@ -30,27 +32,29 @@ export const ControlsFilter = ({
const theme = useTheme();
const isSmall = useMediaQuery(theme.breakpoints.down("md"));
const isFilterActive =
selectedTypes.length > 0 || selectedStatus !== "" || selectedState !== "";
(selectedTypes?.length ?? 0) > 0 || selectedStatus !== "" || selectedState !== "";
return (
<Stack
direction={isSmall ? "column" : "row"}
gap={theme.spacing(2)}
>
<Select
multiple
placeholder="Type"
value={selectedTypes}
onChange={(e) => setSelectedTypes(e.target.value as MonitorType[])}
>
{types.map((type) => (
<MenuItem
key={type}
value={type}
>
<Typography textTransform={"capitalize"}>{type}</Typography>
</MenuItem>
))}
</Select>
{showTypes && setSelectedTypes && (
<Select
multiple
placeholder="Type"
value={selectedTypes ?? []}
onChange={(e) => setSelectedTypes(e.target.value as MonitorType[])}
>
{types.map((type) => (
<MenuItem
key={type}
value={type}
>
<Typography textTransform={"capitalize"}>{type}</Typography>
</MenuItem>
))}
</Select>
)}
<Select
placeholder="Status"
value={selectedStatus}
@@ -0,0 +1,206 @@
import { BaseChart } from "@/Components/v2/design-elements";
import {
AreaChart,
Area,
XAxis,
YAxis,
CartesianGrid,
ResponsiveContainer,
} from "recharts";
import { Fragment, useId } from "react";
import { XTick } from "@/Components/v2/monitors";
import { useTheme } from "@mui/material/styles";
import type { HardwareCheckStats } from "@/Types/Monitor";
const AREA_COLORS = [
// Blues
"#3182bd", // Deep blue
"#6baed6", // Medium blue
"#9ecae1", // Light blue
// Greens
"#74c476", // Soft green
"#a1d99b", // Light green
"#c7e9c0", // Pale green
// Oranges
"#fdae6b", // Warm orange
"#fdd0a2", // Light orange
"#feedde", // Pale orange
// Purples
"#9467bd", // Lavender
"#a55194", // Deep magenta
"#c994c7", // Soft magenta
// Reds
"#ff9896", // Soft red
"#de2d26", // Deep red
"#fc9272", // Medium red
// Cyans/Teals
"#17becf", // Cyan
"#7fcdbb", // Teal
"#a1dab4", // Light teal
// Yellows
"#fec44f", // Mustard
"#fee391", // Light yellow
"#ffffd4", // Pale yellow
// Additional colors
"#e377c2", // Soft pink
"#bcbd22", // Olive
"#2ca02c", // Vibrant green
];
const createGradient = ({
id,
startColor,
endColor,
startOpacity = 0.8,
endOpacity = 0,
direction = "vertical",
}: {
id: string;
startColor: string;
endColor: string;
startOpacity?: number;
endOpacity?: number;
direction?: "vertical" | "horizontal";
}) => (
<defs>
<linearGradient
id={id}
x1={direction === "vertical" ? "0" : "0"}
y1={direction === "vertical" ? "0" : "0"}
x2={direction === "vertical" ? "0" : "1"}
y2={direction === "vertical" ? "1" : "0"}
>
<stop
offset="0%"
stopColor={startColor}
stopOpacity={startOpacity}
/>
<stop
offset="100%"
stopColor={endColor}
stopOpacity={endOpacity}
/>
</linearGradient>
</defs>
);
export const HistogramInfrastructure = ({
dateRange,
title,
type,
idx: _idx,
checks,
xKey,
yDomain,
dataKeys,
gradient = false,
gradientDirection = "vertical",
gradientStartColor,
gradientEndColor,
strokeColor,
fillColor,
yAxisFormatter,
}: {
dateRange: string;
title: string;
type: string;
idx: number | null;
checks: HardwareCheckStats[];
xKey: string;
yDomain?: number[];
dataKeys: string[];
gradient?: boolean;
gradientDirection?: "vertical" | "horizontal";
gradientStartColor?: string;
gradientEndColor?: string;
strokeColor: string;
fillColor?: string;
yAxisFormatter?: (value: number) => string;
}) => {
const theme = useTheme();
const uniqueId = useId();
const data = checks;
let avgTemps: { bucketDate: string; avg_temp: number | null }[] = [];
let tempYDomain: number[] = [];
if (type === "temp") {
avgTemps = data.map((check) => {
const temps = check.avgTemperature || [];
if (temps.length === 0) return { bucketDate: check.bucketDate, avg_temp: null };
const totalTemp = temps.reduce((sum, temp) => sum + (temp || 0), 0);
const avgTemp = totalTemp / temps.length;
return { bucketDate: check.bucketDate, avg_temp: avgTemp };
});
const maxTemp: number = avgTemps.reduce((max, item) => {
return item.avg_temp && item.avg_temp > max ? item.avg_temp : max;
}, 0);
tempYDomain = [0, Math.ceil((maxTemp * 1.3) / 10) * 10];
}
return (
<BaseChart
icon={null}
title={title}
>
<ResponsiveContainer
width="100%"
height={200}
>
<AreaChart data={type === "temp" ? avgTemps : data}>
<XAxis
dataKey={xKey}
tick={(props) => (
<XTick
{...props}
range={dateRange}
/>
)}
/>
<YAxis
domain={type === "temp" ? tempYDomain : yDomain}
tickFormatter={yAxisFormatter}
/>
<CartesianGrid
stroke={theme.palette.divider}
strokeWidth={1}
strokeOpacity={1}
fill="transparent"
vertical={false}
/>
{dataKeys?.map((dataKey, index) => {
const gradientId = `gradient-${uniqueId}-${index}`;
return (
<Fragment key={`${dataKey}-${index}`}>
{gradient === true &&
createGradient({
id: gradientId,
startColor: gradientStartColor || AREA_COLORS[index],
endColor: gradientEndColor || "transparent",
direction: gradientDirection,
})}
<Area
key={dataKey}
type="monotone"
dataKey={dataKey}
stroke={strokeColor || AREA_COLORS[index]}
fill={gradient === true ? `url(#${gradientId})` : fillColor}
/>
</Fragment>
);
})}
</AreaChart>
</ResponsiveContainer>
</BaseChart>
);
};
@@ -9,3 +9,4 @@ export * from "./charts/PiePageSpeed";
export * from "./charts/PiePageSpeedLegend";
export * from "./charts/HistogramPageSpeedDetails";
export * from "./charts/HistogramPageSpeedDetailsTooltip";
export * from "./charts/HistogramInfrastructure";
@@ -1,233 +0,0 @@
//Components
import Stack from "@mui/material/Stack";
import DataTable from "@/Components/v1/Table/index.jsx";
import Table from "@mui/material/Table";
import TableBody from "@mui/material/TableBody";
import TableRow from "@mui/material/TableRow";
import TableCell from "@mui/material/TableCell";
import TableSkeleton from "@/Components/v1/Table/skeleton.jsx";
import Pagination from "@/Components/v1/Table/TablePagination/index.jsx";
import { StatusLabel } from "@/Components/v1/Label/index.jsx";
import { HttpStatusLabel } from "@/Components/v1/HttpStatusLabel/index.jsx";
import GenericFallback from "@/Components/v1/GenericFallback/index.jsx";
import NetworkError from "@/Components/v1/GenericFallback/NetworkError.jsx";
//Utils
import { formatDateWithTz } from "@/Utils/timeUtilsLegacy.js";
import { useSelector } from "react-redux";
import { useState } from "react";
import PropTypes from "prop-types";
import { useTranslation } from "react-i18next";
import { useFetchChecksTeam, useFetchChecksByMonitor } from "@/Hooks/checkHooks.js";
import { Typography, useTheme } from "@mui/material";
import { lighten } from "@mui/material/styles";
const GetTooltip = (row) => {
const theme = useTheme();
const phases = row?.timings?.phases;
const phaseKeyFormattingMap = {
firstByte: "first byte",
};
return (
<Stack
backgroundColor={lighten(theme.palette.primary.main, 0.1)}
border={`1px solid ${theme.palette.primary.lowContrast}`}
borderRadius={theme.shape.borderRadius}
py={theme.spacing(2)}
px={theme.spacing(4)}
>
<Typography
variant="body2"
color={theme.palette.primary.contrastText}
>{`Status code: ${row?.statusCode}`}</Typography>
<Typography
variant="body2"
color={theme.palette.primary.contrastText}
>{`Response time: ${row?.responseTime} ms`}</Typography>
{phases && (
<>
<Typography
variant="body2"
color={theme.palette.primary.contrastText}
>{`Request timing: `}</Typography>
<Table
size="small"
sx={{ ml: theme.spacing(2), mt: theme.spacing(2) }}
>
<TableBody>
{Object.keys(phases)?.map((phaseKey) => (
<TableRow key={phaseKey}>
<TableCell sx={{ border: "none", p: 0 }}>
<Typography
variant="body2"
color="success"
>
{`${phaseKeyFormattingMap[phaseKey] || phaseKey}:`}
</Typography>
</TableCell>
<TableCell sx={{ border: "none", p: 0 }}>
<Typography
color={theme.palette.primary.contrastText}
variant="body2"
>{`${phases[phaseKey]} ms`}</Typography>
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</>
)}
</Stack>
);
};
const IncidentTable = ({
isLoading,
monitors,
selectedMonitor,
filter,
dateRange,
updateTrigger,
}) => {
//Redux state
const uiTimezone = useSelector((state) => state.ui.timezone);
//Local state
const [page, setPage] = useState(0);
const [rowsPerPage, setRowsPerPage] = useState(10);
const selectedMonitorDetails = monitors?.[selectedMonitor];
const selectedMonitorType = selectedMonitorDetails?.type;
//Hooks
const [checksMonitor, checksCountMonitor, isLoadingMonitor, networkErrorMonitor] =
useFetchChecksByMonitor({
monitorId: selectedMonitor === "0" ? undefined : selectedMonitor,
type: selectedMonitorType,
status: false,
sortOrder: "desc",
limit: null,
dateRange,
filter: filter === "resolved" ? "all" : filter,
ack: filter === "resolved" ? true : false,
page: page,
rowsPerPage: rowsPerPage,
enabled: selectedMonitor !== "0",
updateTrigger,
});
const [checksTeam, checksCountTeam, isLoadingTeam, networkErrorTeam] =
useFetchChecksTeam({
status: false,
sortOrder: "desc",
limit: null,
dateRange,
filter: filter === "resolved" ? "all" : filter,
ack: filter === "resolved" ? true : false,
page: page,
rowsPerPage: rowsPerPage,
enabled: selectedMonitor === "0",
updateTrigger,
});
const checks = selectedMonitor === "0" ? checksTeam : checksMonitor;
const checksCount = selectedMonitor === "0" ? checksCountTeam : checksCountMonitor;
isLoading = isLoadingTeam || isLoadingMonitor;
const networkError = selectedMonitor === "0" ? networkErrorTeam : networkErrorMonitor;
const { t } = useTranslation();
//Handlers
const handleChangePage = (_, newPage) => {
setPage(newPage);
};
const handleChangeRowsPerPage = (event) => {
setRowsPerPage(event.target.value);
};
const headers = [
{
id: "monitorName",
content: t("incidentsTableMonitorName"),
render: (row) => {
return monitors?.[row.metadata?.monitorId]?.name || "N/A";
},
},
{
id: "status",
content: t("incidentsTableStatus"),
render: (row) => {
const status = row.status === true ? "up" : "down";
return (
<StatusLabel
status={status}
text={status}
customStyles={{ textTransform: "capitalize" }}
/>
);
},
},
{
id: "dateTime",
content: t("incidentsTableDateTime"),
render: (row) => {
const formattedDate = formatDateWithTz(
row.createdAt,
"YYYY-MM-DD HH:mm:ss A",
uiTimezone
);
return formattedDate;
},
},
{
id: "statusCode",
content: t("incidentsTableStatusCode"),
render: (row) => <HttpStatusLabel status={row.statusCode} />,
},
{ id: "message", content: t("incidentsTableMessage"), render: (row) => row.message },
];
if (isLoading) return <TableSkeleton />;
if (networkError) {
return (
<GenericFallback>
<NetworkError />
</GenericFallback>
);
}
if (!isLoading && typeof checksCount === "undefined") {
return <GenericFallback>{t("incidentsTableNoIncidents")}</GenericFallback>;
}
return (
<>
<DataTable
headers={headers}
data={checks}
config={{ tooltipContent: GetTooltip }}
/>
<Pagination
paginationLabel={t("incidentsTablePaginationLabel")}
itemCount={checksCount}
page={page}
rowsPerPage={rowsPerPage}
handleChangePage={handleChangePage}
handleChangeRowsPerPage={handleChangeRowsPerPage}
/>
</>
);
};
IncidentTable.propTypes = {
isLoading: PropTypes.bool,
monitors: PropTypes.object,
selectedMonitor: PropTypes.string,
filter: PropTypes.string,
dateRange: PropTypes.string,
updateTrigger: PropTypes.bool,
setUpdateTrigger: PropTypes.func,
};
export default IncidentTable;
@@ -1,139 +0,0 @@
// Components
import { Stack, Typography, Button, ButtonGroup } from "@mui/material";
import Select from "@/Components/v1/Inputs/Select/index.jsx";
import PropTypes from "prop-types";
//Utils
import { useTheme } from "@emotion/react";
import SkeletonLayout from "./skeleton.jsx";
import { useTranslation } from "react-i18next";
const OptionsHeader = ({
shouldRender,
selectedMonitor = 0,
setSelectedMonitor,
monitors,
filter = "all",
setFilter,
dateRange = "hour",
setDateRange,
}) => {
const theme = useTheme();
const { t } = useTranslation();
const monitorNames = typeof monitors !== "undefined" ? Object.values(monitors) : [];
const filterOptions = [
{ id: "all", name: t("incidentsOptionsHeaderFilterAll") },
{ id: "down", name: t("incidentsOptionsHeaderFilterDown") },
{ id: "resolve", name: t("incidentsOptionsHeaderFilterCannotResolve") },
{ id: "resolved", name: t("incidentsOptionsHeaderFilterResolved") },
];
// The stacks below which are three in number have the same style so
const stackStyles = {
direction: "row",
alignItems: "center",
gap: theme.spacing(6),
};
if (!shouldRender) return <SkeletonLayout />;
return (
<Stack
direction="row"
justifyContent="space-between"
>
<Stack {...stackStyles}>
<Typography
display="inline-block"
component="h1"
color={theme.palette.primary.contrastTextSecondary}
>
{t("incidentsOptionsHeader")}
</Typography>
<Select
id="incidents-select-monitor"
placeholder={t("incidentsOptionsPlaceholderAllServers")}
value={selectedMonitor}
onChange={(e) => setSelectedMonitor(e.target.value)}
items={monitorNames}
sx={{
backgroundColor: theme.palette.primary.main,
color: theme.palette.primary.contrastTextSecondary,
}}
maxWidth={250}
/>
</Stack>
<Stack {...stackStyles}>
<Typography
display="inline-block"
component="h1"
color={theme.palette.primary.contrastTextSecondary}
>
{t("incidentsOptionsHeaderFilterBy")}
</Typography>
<Select
id="incidents-select-filter"
value={filter}
onChange={(e) => setFilter(e.target.value)}
items={filterOptions}
sx={{
backgroundColor: theme.palette.primary.main,
color: theme.palette.primary.contrastTextSecondary,
}}
/>
</Stack>
<Stack {...stackStyles}>
<Typography
display="inline-block"
component="h1"
color={theme.palette.primary.contrastTextSecondary}
>
{t("incidentsOptionsHeaderShow")}
</Typography>
<ButtonGroup>
<Button
variant="group"
filled={(dateRange === "hour").toString()}
onClick={() => setDateRange("hour")}
>
{t("incidentsOptionsHeaderLastHour")}
</Button>
<Button
variant="group"
filled={(dateRange === "day").toString()}
onClick={() => setDateRange("day")}
>
{t("incidentsOptionsHeaderLastDay")}
</Button>
<Button
variant="group"
filled={(dateRange === "week").toString()}
onClick={() => setDateRange("week")}
>
{t("incidentsOptionsHeaderLastWeek")}
</Button>
<Button
variant="group"
filled={(dateRange === "all").toString()}
onClick={() => setDateRange("all")}
>
{t("incidentsOptionsHeaderFilterAll")}
</Button>
</ButtonGroup>
</Stack>
</Stack>
);
};
OptionsHeader.propTypes = {
shouldRender: PropTypes.bool,
selectedMonitor: PropTypes.string,
setSelectedMonitor: PropTypes.func,
monitors: PropTypes.object,
filter: PropTypes.string,
setFilter: PropTypes.func,
dateRange: PropTypes.string,
setDateRange: PropTypes.func,
};
export default OptionsHeader;
@@ -1,11 +0,0 @@
import { Stack, Skeleton } from "@mui/material";
const SkeletonLayout = () => {
return (
<Stack>
<Skeleton height={40} />
</Stack>
);
};
export default SkeletonLayout;
@@ -1,129 +0,0 @@
import PropTypes from "prop-types";
import { useTheme } from "@emotion/react";
import { Box, Stack, Typography } from "@mui/material";
import Background from "@/assets/Images/background-grid.svg?react";
import Icon from "@/Components/v1/Icon";
const StatusBox = ({ title, value, status }) => {
const theme = useTheme();
let sharedStyles = {
position: "absolute",
right: 8,
"& svg": {
width: 20,
height: 20,
opacity: 0.9,
"& path": { stroke: theme.palette.primary.contrastTextTertiary, strokeWidth: 1.7 },
},
};
let color;
let icon;
if (status === "up") {
color = theme.palette.success.lowContrast;
icon = (
<Box sx={{ ...sharedStyles, top: theme.spacing(6), right: theme.spacing(6) }}>
<Icon
name="CheckCircle"
size={20}
/>
</Box>
);
} else if (status === "down") {
color = theme.palette.error.lowContrast;
icon = (
<Box sx={{ ...sharedStyles, top: theme.spacing(6), right: theme.spacing(6) }}>
<Icon
name="X"
size={20}
/>
</Box>
);
} else if (status === "paused") {
color = theme.palette.warning.lowContrast;
icon = (
<Box sx={{ ...sharedStyles, top: theme.spacing(6), right: theme.spacing(6) }}>
<Icon
name="AlertTriangle"
size={20}
/>
</Box>
);
} else {
color = theme.palette.accent.main;
icon = (
<Box sx={{ ...sharedStyles, top: theme.spacing(6), right: theme.spacing(6) }}>
<Icon
name="Bell"
size={20}
/>
</Box>
);
}
return (
<Box
position="relative"
flex={1}
border={1}
backgroundColor={theme.palette.primary.main}
borderColor={theme.palette.primary.lowContrast}
borderRadius={theme.shape.borderRadius}
p={theme.spacing(8)}
overflow="hidden"
>
<Box
position="absolute"
top="-10%"
left="5%"
>
<Background />
</Box>
<Stack direction="column">
<Stack
direction="row"
alignItems="center"
mb={theme.spacing(8)}
>
<Typography
variant={"h2"}
textTransform="uppercase"
color={theme.palette.primary.contrastTextTertiary}
>
{title}
</Typography>
{icon}
</Stack>
<Stack
direction="row"
alignItems="flex-start"
fontSize={theme.typography.h1.fontSize}
fontWeight={600}
color={color}
gap={theme.spacing(1)}
>
{value}
<Typography
fontSize={theme.typography.label.fontSize}
fontWeight={300}
color={theme.palette.primary.contrastTextSecondary}
sx={{
opacity: 0.3,
}}
>
#
</Typography>
</Stack>
</Stack>
</Box>
);
};
StatusBox.propTypes = {
title: PropTypes.string.isRequired,
value: PropTypes.oneOfType([PropTypes.number, PropTypes.string]).isRequired,
status: PropTypes.string,
};
export default StatusBox;
@@ -1,46 +0,0 @@
import PropTypes from "prop-types";
import { Stack } from "@mui/material";
import StatusBox from "./StatusBox.jsx";
import { useTheme } from "@emotion/react";
import { useTranslation } from "react-i18next";
import SkeletonLayout from "./skeleton.jsx";
const StatusBoxes = ({ isLoading, summary }) => {
const theme = useTheme();
const { t } = useTranslation();
if (isLoading) return <SkeletonLayout shouldRender={isLoading} />;
return (
<Stack
gap={theme.spacing(12)}
direction="row"
justifyContent="space-between"
>
<StatusBox
title={t("incidentsOptionsHeaderTotalIncidents")}
value={summary?.totalChecks + summary?.cannotResolveChecks || 0}
/>
<StatusBox
title={t("incidentsOptionsHeaderFilterResolved")}
status="up"
value={summary?.resolvedChecks || 0}
/>
<StatusBox
title={t("incidentsOptionsHeaderFilterCannotResolve")}
status="paused"
value={summary?.cannotResolveChecks || 0}
/>
<StatusBox
title={t("incidentsOptionsHeaderFilterDown")}
status="down"
value={summary?.downChecks || 0}
/>
</Stack>
);
};
StatusBoxes.propTypes = {
isLoading: PropTypes.bool,
summary: PropTypes.object,
};
export default StatusBoxes;
@@ -1,36 +0,0 @@
import { Skeleton, Stack } from "@mui/material";
import { useTheme } from "@emotion/react";
const SkeletonLayout = () => {
const theme = useTheme();
return (
<Stack
gap={theme.spacing(12)}
direction="row"
justifyContent="space-between"
>
<Skeleton
variant="rounded"
width="100%"
height={100}
/>
<Skeleton
variant="rounded"
width="100%"
height={100}
/>
<Skeleton
variant="rounded"
width="100%"
height={100}
/>
<Skeleton
variant="rounded"
width="100%"
height={100}
/>
</Stack>
);
};
export default SkeletonLayout;
-100
View File
@@ -1,100 +0,0 @@
// Components
import { Stack } from "@mui/material";
import Breadcrumbs from "@/Components/v1/Breadcrumbs/index.jsx";
import GenericFallback from "@/Components/v1/GenericFallback/index.jsx";
import IncidentTable from "./Components/IncidentTable/index.jsx";
import OptionsHeader from "./Components/OptionsHeader/index.jsx";
import StatusBoxes from "./Components/StatusBoxes/index.jsx";
//Utils
import { useTheme } from "@emotion/react";
import { useFetchMonitorsByTeamId } from "@/Hooks/monitorHooks.js";
import { useFetchChecksSummaryByTeamId } from "@/Hooks/checkHooks.js";
import { useState, useEffect } from "react";
import NetworkError from "@/Components/v1/GenericFallback/NetworkError.jsx";
import { useTranslation } from "react-i18next";
import { useParams } from "react-router-dom";
//Constants
const Checks = () => {
// Redux state
const { t } = useTranslation();
const BREADCRUMBS = [{ name: t("checksPageTitle"), path: "/checks" }];
// Local state
const [selectedMonitor, setSelectedMonitor] = useState("0");
const [filter, setFilter] = useState(undefined);
const [dateRange, setDateRange] = useState("hour");
const [monitorLookup, setMonitorLookup] = useState(undefined);
const [updateTrigger, setUpdateTrigger] = useState(false);
//Hooks
//Utils
const theme = useTheme();
const [monitors, , isLoading, networkError] = useFetchMonitorsByTeamId({});
const [summary, isLoadingSummary, networkErrorSummary] = useFetchChecksSummaryByTeamId({
dateRange,
updateTrigger,
});
const { monitorId } = useParams();
useEffect(() => {
if (monitorId) {
setSelectedMonitor(monitorId);
}
}, [monitorId]);
useEffect(() => {
const monitorLookup = monitors?.reduce((acc, monitor) => {
acc[monitor.id] = {
id: monitor.id,
name: monitor.name,
type: monitor.type,
};
return acc;
}, {});
setMonitorLookup(monitorLookup);
}, [monitors]);
if (networkError || networkErrorSummary) {
return (
<GenericFallback>
<NetworkError />
</GenericFallback>
);
}
return (
<Stack gap={theme.spacing(10)}>
<Breadcrumbs list={BREADCRUMBS} />
<StatusBoxes
isLoading={isLoadingSummary}
summary={summary}
/>
<OptionsHeader
shouldRender={!isLoading}
monitors={monitorLookup}
selectedMonitor={selectedMonitor}
setSelectedMonitor={setSelectedMonitor}
filter={filter}
setFilter={setFilter}
dateRange={dateRange}
setDateRange={setDateRange}
/>
<IncidentTable
isLoading={isLoading}
monitors={monitorLookup ? monitorLookup : {}}
selectedMonitor={selectedMonitor}
filter={filter}
dateRange={dateRange}
updateTrigger={updateTrigger}
setUpdateTrigger={setUpdateTrigger}
/>
</Stack>
);
};
export default Checks;
@@ -1,37 +0,0 @@
// Components
import { Typography } from "@mui/material";
import BaseContainer from "../BaseContainer/index.jsx";
import AreaChart from "@/Components/v1/Charts/AreaChart/index.jsx";
// Utils
import { useTheme } from "@emotion/react";
import { useHardwareUtils } from "../../Hooks/useHardwareUtils.jsx";
const InfraAreaChart = ({ config }) => {
const theme = useTheme();
const { getDimensions } = useHardwareUtils();
return (
<BaseContainer>
<Typography
component="h2"
padding={theme.spacing(8)}
>
{config.heading}
</Typography>
<AreaChart
height={getDimensions().areaChartHeight}
data={config.data}
dataKeys={config.dataKeys}
xKey="_id"
yDomain={config.yDomain}
customTooltip={config.toolTip}
xTick={config.xTick}
yTick={config.yTick}
strokeColor={config.strokeColor}
gradient={true}
gradientStartColor={config.gradientStartColor}
gradientEndColor="#ffffff"
/>
</BaseContainer>
);
};
export default InfraAreaChart;
@@ -1,140 +0,0 @@
// Components
import { Stack } from "@mui/material";
import InfraAreaChart from "./InfraAreaChart.jsx";
import SkeletonLayout from "./skeleton.jsx";
// Utils
import {
PercentTick,
TzTick,
InfrastructureTooltip,
TemperatureTooltip,
} from "@/Components/v1/Charts/Utils/chartUtils.jsx";
import { useTheme } from "@emotion/react";
import { useHardwareUtils } from "../../Hooks/useHardwareUtils.jsx";
import { useTranslation } from "react-i18next";
const AreaChartBoxes = ({ shouldRender, monitor, dateRange }) => {
const theme = useTheme();
const { t } = useTranslation();
const { buildTemps } = useHardwareUtils();
if (!shouldRender) {
return <SkeletonLayout />;
}
const { stats } = monitor ?? {};
const { checks } = stats;
let latestCheck = checks[0];
const { temps, tempKeys } = buildTemps(checks);
const configs = [
{
type: "memory",
data: checks,
dataKeys: ["avgMemoryUsage"],
heading: t("memoryUsage"),
strokeColor: theme.palette.accent.main, // CAIO_REVIEW
gradientStartColor: theme.palette.accent.main, // CAIO_REVIEW
yLabel: t("memoryUsage"),
yDomain: [0, 1],
yTick: <PercentTick />,
xTick: <TzTick dateRange={dateRange} />,
toolTip: (
<InfrastructureTooltip
dotColor={theme.palette.primary.main}
yKey={"avgMemoryUsage"}
yLabel={"Memory usage"}
dateRange={dateRange}
/>
),
},
{
type: "cpu",
data: checks,
dataKeys: ["avgCpuUsage"],
heading: t("cpuUsage"),
strokeColor: theme.palette.success.main,
gradientStartColor: theme.palette.success.main,
yLabel: t("cpuUsage"),
yDomain: [0, 1],
yTick: <PercentTick />,
xTick: <TzTick dateRange={dateRange} />,
toolTip: (
<InfrastructureTooltip
dotColor={theme.palette.success.main}
yKey={"avgCpuUsage"}
yLabel={"CPU usage"}
dateRange={dateRange}
/>
),
},
{
type: "temperature",
data: temps,
dataKeys: tempKeys,
strokeColor: theme.palette.error.main,
gradientStartColor: theme.palette.error.main,
heading: t("cpuTemperature"),
yLabel: "Temperature",
xTick: <TzTick dateRange={dateRange} />,
yDomain: [
0,
Math.max(Math.max(...temps.flatMap((t) => tempKeys.map((k) => t[k]))) * 1.1, 200),
],
toolTip: (
<TemperatureTooltip
keys={tempKeys}
dotColor={theme.palette.error.main}
dateRange={dateRange}
/>
),
},
...(latestCheck?.disks?.map((disk, idx) => ({
type: "disk",
data: checks,
diskIndex: idx,
dataKeys: [`disks[${idx}].usagePercent`],
heading: `Disk${idx} usage`,
strokeColor: theme.palette.warning.main,
gradientStartColor: theme.palette.warning.main,
yLabel: t("diskUsage"),
yDomain: [0, 1],
yTick: <PercentTick />,
xTick: <TzTick dateRange={dateRange} />,
toolTip: (
<InfrastructureTooltip
dotColor={theme.palette.warning.main}
yKey={`disks.usagePercent`}
yLabel={"Disc usage"}
yIdx={idx}
dateRange={dateRange}
/>
),
})) || []),
];
return (
<Stack
direction={"row"}
// height={chartContainerHeight} // FE team HELP! Possibly no longer needed?
gap={theme.spacing(8)} // FE team HELP!
flexWrap="wrap" // //FE team HELP! Better way to do this?
sx={{
"& > *": {
flexBasis: `calc(50% - ${theme.spacing(8)})`,
maxWidth: `calc(50% - ${theme.spacing(8)})`,
},
}}
>
{configs.map((config) => (
<InfraAreaChart
key={`${config.type}-${config.diskIndex ?? ""}`}
config={config}
/>
))}
</Stack>
);
};
export default AreaChartBoxes;
@@ -1,25 +0,0 @@
import { Stack, Skeleton } from "@mui/material";
import { useTheme } from "@emotion/react";
const SkeletonLayout = () => {
const theme = useTheme();
return (
<Stack
direction="row"
flexWrap="wrap"
gap={theme.spacing(8)}
>
<Skeleton
height={"33vh"}
sx={{
flex: 1,
}}
/>
<Skeleton
height={"33vh"}
sx={{ flex: 1 }}
/>
</Stack>
);
};
export default SkeletonLayout;
@@ -1,44 +0,0 @@
/**
* 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
*/
// Components
import { Box } from "@mui/material";
// Utils
import { useTheme } from "@emotion/react";
import { useHardwareUtils } from "../../Hooks/useHardwareUtils.jsx";
import PropTypes from "prop-types";
const BaseContainer = ({ children, sx = {}, shouldExpand = false }) => {
const theme = useTheme();
const { getDimensions } = useHardwareUtils();
return (
<Box
sx={{
padding: `${theme.spacing(getDimensions().baseBoxPaddingVertical)} ${theme.spacing(getDimensions().baseBoxPaddingHorizontal)}`,
minWidth: 200,
width: shouldExpand ? "100%" : 225,
backgroundColor: theme.palette.primary.main,
border: 1,
borderStyle: "solid",
borderColor: theme.palette.primary.lowContrast,
...sx,
}}
>
{children}
</Box>
);
};
BaseContainer.propTypes = {
children: PropTypes.node.isRequired,
sx: PropTypes.object,
shouldExpand: PropTypes.bool,
};
export default BaseContainer;
@@ -0,0 +1,104 @@
import Grid from "@mui/material/Grid2";
import { HistogramInfrastructure } from "@/Components/v2/monitors";
import { useTranslation } from "react-i18next";
import type { HardwareCheckStats } from "@/Types/Monitor";
import { useMemo } from "react";
import { useTheme } from "@mui/material/styles";
import useMediaQuery from "@mui/material/useMediaQuery";
const formatPercent = (value: number) => `${(value * 100).toFixed(0)}%`;
const formatTemp = (value: number) => `${value.toFixed(0)}°C`;
const getChartConfigs = (theme: any, checks: HardwareCheckStats[], t: any) => {
return [
{
title: t("pages.infrastructure.charts.labels.memory"),
type: "memory",
dataKeys: ["avgMemoryUsage"],
strokeColor: theme.palette.primary.main,
gradientStartColor: theme.palette.primary.main,
yDomain: [0, 1],
yAxisFormatter: formatPercent,
idx: null,
},
{
title: t("pages.infrastructure.charts.labels.cpu"),
type: "cpu",
dataKeys: ["avgCpuUsage"],
strokeColor: theme.palette.success.main,
gradientStartColor: theme.palette.success.main,
yDomain: [0, 1],
yAxisFormatter: formatPercent,
idx: null,
},
{
title: t("pages.infrastructure.charts.labels.temp"),
type: "temp",
dataKeys: ["avg_temp"],
strokeColor: theme.palette.error.main,
gradientStartColor: theme.palette.error.main,
yDomain: [0, 150],
yAxisFormatter: formatTemp,
idx: null,
},
...(checks[0]?.disks?.map((_, idx) => ({
title: t("pages.infrastructure.charts.labels.disk", { idx }),
type: "disk",
dataKeys: [`disks[${idx}].usagePercent`],
strokeColor: theme.palette.warning.main,
gradientStartColor: theme.palette.warning.main,
yDomain: [0, 1],
yAxisFormatter: formatPercent,
idx,
})) || []),
];
};
export const InfraDetailsCharts = ({
checks,
dateRange,
}: {
checks: HardwareCheckStats[];
dateRange: string;
}) => {
const theme = useTheme();
const isSmall = useMediaQuery(theme.breakpoints.down("md"));
const { t } = useTranslation();
const chartConfigs = useMemo(
() => getChartConfigs(theme, checks, t),
[theme, checks, t]
);
return (
<Grid
container
spacing={theme.spacing(8)}
>
{chartConfigs.map((config) => {
return (
<Grid
size={isSmall ? 12 : 6}
key={`${config.type}-${config.idx ?? ""}`}
>
<HistogramInfrastructure
dateRange={dateRange}
title={config.title}
type={config.type}
idx={config.idx}
key={`${config.type}-${config.idx ?? ""}`}
checks={checks}
xKey="bucketDate"
yDomain={config.yDomain}
dataKeys={config.dataKeys}
gradient={true}
gradientStartColor={config.gradientStartColor}
gradientEndColor="#ffffff"
strokeColor={config.strokeColor}
yAxisFormatter={config.yAxisFormatter}
/>
</Grid>
);
})}
</Grid>
);
};
@@ -0,0 +1,108 @@
import Grid from "@mui/material/Grid2";
import { HistogramInfrastructure } from "@/Components/v2/monitors";
import { useTranslation } from "react-i18next";
import type { HardwareCheckStats } from "@/Types/Monitor";
import { useMemo } from "react";
import { useTheme } from "@mui/material/styles";
import useMediaQuery from "@mui/material/useMediaQuery";
const formatBytesToMB = (value: number) => `${(value / (1024 * 1024)).toFixed(2)} MB`;
interface ChartConfig {
title: string;
type: string;
dataKeys: string[];
strokeColor: string;
gradientStartColor: string;
idx: number | null;
interfaceName?: string;
}
const getChartConfigs = (
theme: any,
checks: HardwareCheckStats[],
t: any
): ChartConfig[] => {
const configs: ChartConfig[] = [];
// Find the first check that has network data to get interface names
const checkWithNet = checks.find((c) => c.net && c.net.length > 0);
const netInterfaces = checkWithNet?.net || [];
netInterfaces.forEach((iface, idx) => {
configs.push(
{
title: t("pages.infrastructure.charts.labels.netBytesSent", {
name: iface.name,
}),
type: "netBytesSent",
dataKeys: [`net[${idx}].bytesSentPerSecond`],
strokeColor: theme.palette.primary.main,
gradientStartColor: theme.palette.primary.main,
idx,
interfaceName: iface.name,
},
{
title: t("pages.infrastructure.charts.labels.netBytesRecv", {
name: iface.name,
}),
type: "netBytesRecv",
dataKeys: [`net[${idx}].deltaBytesRecv`],
strokeColor: theme.palette.success.main,
gradientStartColor: theme.palette.success.main,
idx,
interfaceName: iface.name,
}
);
});
return configs;
};
export const InfraNetworkCharts = ({
checks,
dateRange,
}: {
checks: HardwareCheckStats[];
dateRange: string;
}) => {
const theme = useTheme();
const isSmall = useMediaQuery(theme.breakpoints.down("md"));
const { t } = useTranslation();
const chartConfigs = useMemo(
() => getChartConfigs(theme, checks, t),
[theme, checks, t]
);
return (
<Grid
container
spacing={theme.spacing(8)}
>
{chartConfigs.map((config) => {
return (
<Grid
size={isSmall ? 12 : 6}
key={`${config.type}-${config.interfaceName ?? config.idx ?? ""}`}
>
<HistogramInfrastructure
dateRange={dateRange}
title={config.title}
type={config.type}
idx={config.idx}
checks={checks}
xKey="bucketDate"
dataKeys={config.dataKeys}
gradient={true}
gradientStartColor={config.gradientStartColor}
gradientEndColor="#ffffff"
strokeColor={config.strokeColor}
yAxisFormatter={formatBytesToMB}
/>
</Grid>
);
})}
</Grid>
);
};
@@ -1,127 +0,0 @@
// Components
import CustomGauge from "@/Components/v1/Charts/CustomGauge/index.jsx";
import BaseContainer from "../BaseContainer/index.jsx";
import { Stack, Typography, Box } from "@mui/material";
// Utils
import { useTheme } from "@emotion/react";
import PropTypes from "prop-types";
const Gauge = ({
value,
heading,
metricOne,
valueOne,
metricTwo,
valueTwo,
metricThree,
valueThree,
metricFour,
valueFour,
shouldExpand = false,
}) => {
const theme = useTheme();
const valueStyle = {
borderRadius: theme.spacing(2),
backgroundColor: theme.palette.tertiary.main,
minWidth: "40%",
maxWidth: "60%",
mb: theme.spacing(2),
mt: theme.spacing(2),
pr: theme.spacing(2),
textAlign: "right",
overflow: "hidden",
textOverflow: "ellipsis",
whiteSpace: "nowrap",
};
return (
<BaseContainer shouldExpand={shouldExpand}>
<Stack
direction="column"
gap={theme.spacing(2)}
alignItems="center"
>
<Box
sx={{
display: "flex",
flexDirection: "column",
alignItems: "center",
width: "100%",
backgroundColor: theme.palette.gradient.color1,
}}
>
<CustomGauge
progress={value}
radius={100}
/>
<Typography
component="h2"
sx={{ fontWeight: 600 }}
>
{heading}
</Typography>
</Box>
<Box
sx={{
width: "100%",
borderTop: `1px solid ${theme.palette.primary.lowContrast}`,
}}
>
<Stack
justifyContent={"space-between"}
direction="row"
alignItems="center"
gap={theme.spacing(2)}
>
<Typography>{metricOne}</Typography>
<Typography sx={valueStyle}>{valueOne}</Typography>
</Stack>
<Stack
justifyContent={"space-between"}
direction="row"
alignItems="center"
gap={theme.spacing(2)}
>
<Typography>{metricTwo}</Typography>
<Typography sx={valueStyle}>{valueTwo}</Typography>
</Stack>
<Stack
justifyContent={"space-between"}
direction="row"
alignItems="center"
gap={theme.spacing(2)}
>
<Typography>{metricThree}</Typography>
<Typography sx={valueStyle}>{valueThree}</Typography>
</Stack>
<Stack
justifyContent={"space-between"}
direction="row"
alignItems="center"
gap={theme.spacing(2)}
>
<Typography>{metricFour}</Typography>
<Typography sx={valueStyle}>{valueFour}</Typography>
</Stack>
</Box>
</Stack>
</BaseContainer>
);
};
Gauge.propTypes = {
value: PropTypes.number,
heading: PropTypes.string,
metricOne: PropTypes.string,
valueOne: PropTypes.oneOfType([PropTypes.string, PropTypes.node]),
metricTwo: PropTypes.string,
valueTwo: PropTypes.oneOfType([PropTypes.string, PropTypes.node]),
metricThree: PropTypes.string,
valueThree: PropTypes.oneOfType([PropTypes.string, PropTypes.node]),
metricFour: PropTypes.string,
valueFour: PropTypes.oneOfType([PropTypes.string, PropTypes.node]),
shouldExpand: PropTypes.bool,
};
export default Gauge;
@@ -1,112 +0,0 @@
// Components
import { Box } from "@mui/material";
import Gauge from "./Gauge.jsx";
import SkeletonLayout from "./skeleton.jsx";
import PropTypes from "prop-types";
// Utils
import { useHardwareUtils } from "../../Hooks/useHardwareUtils.jsx";
import { useTheme } from "@emotion/react";
import { useTranslation } from "react-i18next";
const Gauges = ({ isLoading = false, monitor }) => {
const { decimalToPercentage, formatBytes, formatDeviceName, formatMountpoint } =
useHardwareUtils();
const theme = useTheme();
const { t } = useTranslation();
if (isLoading) {
return <SkeletonLayout />;
}
const latestCheck = monitor?.recentChecks?.[0];
const memoryUsagePercent = latestCheck?.memory?.usage_percent ?? 0;
const memoryUsedBytes = latestCheck?.memory?.used_bytes ?? 0;
const memoryTotalBytes = latestCheck?.memory?.total_bytes ?? 0;
const cpuUsagePercent = latestCheck?.cpu?.usage_percent ?? 0;
const cpuPhysicalCores = latestCheck?.cpu?.physical_core ?? 0;
const cpuFrequency = latestCheck?.cpu?.frequency ?? 0;
const gauges = [
{
type: "memory",
value: decimalToPercentage(memoryUsagePercent),
heading: t("memoryUsage"),
metricOne: t("used"),
valueOne: formatBytes(memoryUsedBytes, true),
metricTwo: t("total"),
valueTwo: formatBytes(memoryTotalBytes, true),
},
{
type: "cpu",
value: decimalToPercentage(cpuUsagePercent),
heading: t("cpuUsage"),
metricOne: t("cores"),
valueOne: cpuPhysicalCores ?? 0,
metricTwo: t("frequency"),
valueTwo: `${(cpuFrequency / 1000).toFixed(2)} Ghz`,
},
...(latestCheck?.disk ?? [])
.filter((disk) => {
if (!monitor?.selectedDisks || monitor.selectedDisks.length === 0) {
return true;
}
return monitor.selectedDisks.includes(disk.mountpoint || disk.device);
})
.map((disk, idx) => ({
type: "disk",
diskIndex: idx,
value: decimalToPercentage(disk.usage_percent),
heading: `Disk${idx} usage`,
metricOne: t("used"),
valueOne: formatBytes(disk.total_bytes - disk.free_bytes, true),
metricTwo: t("total"),
valueTwo: formatBytes(disk.total_bytes, true),
metricThree: t("device"),
valueThree: formatDeviceName(disk.device),
metricFour: t("mountedOn"),
valueFour: formatMountpoint(disk.mountpoint),
})),
];
// Only expand gauges to fill row when there are 4 or more
const shouldExpand = gauges.length >= 4;
return (
<Box
sx={{
display: "grid",
gridTemplateColumns: shouldExpand
? "repeat(auto-fill, minmax(200px, 1fr))"
: "repeat(auto-fill, 225px)",
gap: theme.spacing(4),
}}
>
{gauges.map((gauge) => {
return (
<Gauge
key={`${gauge.type}-${gauge.diskIndex ?? ""}`}
value={gauge.value}
heading={gauge.heading}
metricOne={gauge.metricOne}
valueOne={gauge.valueOne}
metricTwo={gauge.metricTwo}
valueTwo={gauge.valueTwo}
metricThree={gauge.metricThree}
valueThree={gauge.valueThree}
metricFour={gauge.metricFour}
valueFour={gauge.valueFour}
shouldExpand={shouldExpand}
/>
);
})}
</Box>
);
};
Gauges.propTypes = {
isLoading: PropTypes.bool,
monitor: PropTypes.object,
};
export default Gauges;
@@ -1,26 +0,0 @@
import { Stack, Skeleton } from "@mui/material";
import { useTheme } from "@emotion/react";
const SkeletonLayout = () => {
const theme = useTheme();
return (
<Stack
direction="row"
gap={theme.spacing(8)}
>
{Array.from({ length: 3 }).map((_, idx) => {
return (
<Skeleton
key={`gauge-${idx}`}
variant="rectangular"
width={200}
height={200}
/>
);
})}
</Stack>
);
};
export default SkeletonLayout;
@@ -0,0 +1,109 @@
import Stack from "@mui/material/Stack";
import Typography from "@mui/material/Typography";
import { BaseChart, Gauge } from "@/Components/v2/design-elements";
import { useTranslation } from "react-i18next";
import { getGbs, getFrequency } from "@/Utils/InfraUtils";
import { useTheme } from "@mui/material";
import useMediaQuery from "@mui/material/useMediaQuery";
import type { CheckSnapshot } from "@/Types/Check";
const InfraDetailGauge = ({
title,
progress,
upperLabel,
upperValue,
lowerLabel,
lowerValue,
}: {
title: string;
progress: number;
upperLabel?: string;
upperValue?: string | number;
lowerLabel?: string;
lowerValue?: string | number;
}) => {
const theme = useTheme();
return (
<BaseChart
icon={null}
title={title}
maxWidth={225}
>
<Stack
alignItems={"center"}
mb={theme.spacing(4)}
gap={theme.spacing(4)}
>
<Gauge progress={progress} />
</Stack>
<Stack
direction={"row"}
justifyContent={"space-between"}
>
<Typography>{upperLabel}</Typography>
<Typography>{upperValue}</Typography>
</Stack>
<Stack
direction={"row"}
justifyContent={"space-between"}
>
<Typography>{lowerLabel}</Typography>
<Typography>{lowerValue}</Typography>
</Stack>
</BaseChart>
);
};
export const InfraDetailsGauges = ({
snapshot,
}: {
snapshot: CheckSnapshot | undefined;
}) => {
const { t } = useTranslation();
const theme = useTheme();
const isSmall = useMediaQuery(theme.breakpoints.down("md"));
if (!snapshot) {
return null;
}
return (
<Stack
direction={isSmall ? "column" : "row"}
spacing={theme.spacing(8)}
alignItems={"center"}
>
<InfraDetailGauge
title={t("pages.infrastructure.gauges.memory.title")}
progress={(snapshot?.memory?.usage_percent || 0) * 100}
upperLabel={t("pages.infrastructure.gauges.memory.upperLabel")}
upperValue={getGbs(snapshot?.memory?.used_bytes || 0)}
lowerLabel={t("pages.infrastructure.gauges.memory.lowerLabel")}
lowerValue={getGbs(snapshot?.memory?.total_bytes || 0)}
/>
<InfraDetailGauge
title={t("pages.infrastructure.gauges.cpu.title")}
progress={(snapshot?.cpu?.usage_percent || 0) * 100}
upperLabel={t("pages.infrastructure.gauges.cpu.upperLabel")}
upperValue={getFrequency(snapshot?.cpu?.current_frequency || 0)}
lowerLabel={t("pages.infrastructure.gauges.cpu.lowerLabel")}
lowerValue={getFrequency(snapshot?.cpu?.frequency || 0)}
/>
{snapshot?.disk?.map((disk, idx) => {
return (
<InfraDetailGauge
key={disk?.device || 0 + idx}
// title={`Disk ${idx} usage`}
title={t("pages.infrastructure.gauges.disk.title", { idx })}
progress={(disk.usage_percent || 0) * 100}
upperLabel={t("pages.infrastructure.gauges.disk.upperLabel")}
upperValue={getGbs(disk?.used_bytes || 0)}
lowerLabel={t("pages.infrastructure.gauges.disk.lowerLabel")}
lowerValue={getGbs(disk?.total_bytes || 0)}
/>
);
})}
</Stack>
);
};
@@ -1,132 +0,0 @@
import PropTypes from "prop-types";
import { Stack, Typography } from "@mui/material";
import InfraAreaChart from "../AreaChartBoxes/InfraAreaChart.jsx";
import {
TzTick,
InfrastructureTooltip,
NetworkTick,
} from "@/Components/v1/Charts/Utils/chartUtils.jsx";
import { useTheme } from "@emotion/react";
import { useTranslation } from "react-i18next";
import { useHardwareUtils } from "../../Hooks/useHardwareUtils.jsx";
const NetworkCharts = ({ ethernetData, dateRange }) => {
const theme = useTheme();
const { t } = useTranslation();
const { formatBytesPerSecondString, formatPacketsPerSecondString } = useHardwareUtils();
if (!ethernetData?.length) {
return <Typography>{t("noNetworkStatsAvailable")}</Typography>;
}
const configs = [
{
type: "network-bytes",
data: ethernetData,
dataKeys: ["bytesPerSec"],
heading: t("dataReceived"),
strokeColor: theme.palette.info.main,
gradientStartColor: theme.palette.info.main,
yLabel: t("rate"),
xTick: <TzTick dateRange={dateRange} />,
yTick: <NetworkTick formatter={formatBytesPerSecondString} />,
toolTip: (
<InfrastructureTooltip
dotColor={theme.palette.info.main}
yKey={"bytesPerSec"}
yLabel={t("dataRate") + ": "}
dateRange={dateRange}
formatter={formatBytesPerSecondString}
/>
),
},
{
type: "network-packets",
data: ethernetData,
dataKeys: ["packetsPerSec"],
heading: t("packetsReceivedRate"),
strokeColor: theme.palette.success.main,
gradientStartColor: theme.palette.success.main,
yLabel: t("rate"),
xTick: <TzTick dateRange={dateRange} />,
yTick: <NetworkTick formatter={formatPacketsPerSecondString} />,
toolTip: (
<InfrastructureTooltip
dotColor={theme.palette.success.main}
yKey={"packetsPerSec"}
yLabel={t("packetsPerSecond") + ": "}
dateRange={dateRange}
formatter={formatPacketsPerSecondString}
/>
),
},
{
type: "network-errors",
data: ethernetData,
dataKeys: ["errors"],
heading: t("networkErrors"),
strokeColor: theme.palette.error.main,
gradientStartColor: theme.palette.error.main,
yLabel: t("errors"),
xTick: <TzTick dateRange={dateRange} />,
toolTip: (
<InfrastructureTooltip
dotColor={theme.palette.error.main}
yKey={"errors"}
yLabel={t("errors") + ": "}
dateRange={dateRange}
formatter={(value) => Math.round(value).toLocaleString()}
/>
),
},
{
type: "network-drops",
data: ethernetData,
dataKeys: ["drops"],
heading: t("networkDrops"),
strokeColor: theme.palette.warning.main,
gradientStartColor: theme.palette.warning.main,
yLabel: t("drops"),
xTick: <TzTick dateRange={dateRange} />,
toolTip: (
<InfrastructureTooltip
dotColor={theme.palette.warning.main}
yKey={"drops"}
yLabel={t("drops") + ": "}
dateRange={dateRange}
formatter={(value) => Math.round(value).toLocaleString()}
/>
),
},
];
return (
<Stack
direction={"row"}
gap={theme.spacing(8)}
flexWrap="wrap"
sx={{
"& > *": {
flexBasis: `calc(50% - ${theme.spacing(8)})`,
maxWidth: `calc(50% - ${theme.spacing(8)})`,
},
}}
>
{configs.map((config) => (
<InfraAreaChart
key={config.type}
config={config}
/>
))}
</Stack>
);
};
NetworkCharts.propTypes = {
ethernetData: PropTypes.array.isRequired,
dateRange: PropTypes.string.isRequired,
};
export default NetworkCharts;
@@ -1,81 +0,0 @@
import PropTypes from "prop-types";
import StatusBoxes from "@/Components/v1/StatusBoxes/index.jsx";
import StatBox from "@/Components/v1/StatBox/index.jsx";
import { Typography } from "@mui/material";
import { useTranslation } from "react-i18next";
import { useHardwareUtils } from "../../Hooks/useHardwareUtils.jsx";
function formatNumber(num) {
return num != null ? num.toLocaleString() : "0";
}
const NetworkStatBoxes = ({ shouldRender, net, ifaceName }) => {
const { t } = useTranslation();
const { formatBytes } = useHardwareUtils();
const filtered = net?.filter((iface) => iface.name === ifaceName) || [];
if (!net?.length) {
return <Typography>{t("noNetworkStatsAvailable")}</Typography>;
}
return (
<StatusBoxes
shouldRender={shouldRender}
flexWrap="wrap"
>
{filtered
.map((iface) => [
<StatBox
key={`${iface.name}-bytes-sent`}
heading={t("bytesSent")}
subHeading={formatBytes(iface.bytes_sent)}
/>,
<StatBox
key={`${iface.name}-bytes-recv`}
heading={t("bytesReceived")}
subHeading={formatBytes(iface.bytes_recv)}
/>,
<StatBox
key={`${iface.name}-packets-sent`}
heading={t("packetsSent")}
subHeading={formatNumber(iface.packets_sent)}
/>,
<StatBox
key={`${iface.name}-packets-recv`}
heading={t("packetsReceived")}
subHeading={formatNumber(iface.packets_recv)}
/>,
<StatBox
key={`${iface.name}-err-in`}
heading={t("errorsIn")}
subHeading={formatNumber(iface.err_in)}
/>,
<StatBox
key={`${iface.name}-err-out`}
heading={t("errorsOut")}
subHeading={formatNumber(iface.err_out)}
/>,
])
.flat()}
</StatusBoxes>
);
};
NetworkStatBoxes.propTypes = {
shouldRender: PropTypes.bool.isRequired,
net: PropTypes.arrayOf(
PropTypes.shape({
name: PropTypes.string.isRequired,
bytes_sent: PropTypes.number,
bytes_recv: PropTypes.number,
packets_sent: PropTypes.number,
packets_recv: PropTypes.number,
err_in: PropTypes.number,
err_out: PropTypes.number,
})
),
ifaceName: PropTypes.string.isRequired,
};
export default NetworkStatBoxes;
@@ -1,101 +0,0 @@
import PropTypes from "prop-types";
import { useState, useEffect } from "react";
import { Box } from "@mui/material";
import { useTranslation } from "react-i18next";
import { useTheme } from "@emotion/react";
import Select from "@/Components/v1/Inputs/Select/index.jsx";
import NetworkStatBoxes from "./NetworkStatBoxes.jsx";
import NetworkCharts from "./NetworkCharts.jsx";
import MonitorTimeFrameHeader from "@/Components/v1/MonitorTimeFrameHeader/index.jsx";
const getAvailableInterfaces = (net) => {
return (net || []).map((iface) => iface.name).filter(Boolean);
};
const getNetworkInterfaceData = (checks, ifaceName) => {
if (!ifaceName) return [];
// Transform backend data structure for the selected interface
// Backend already calculates deltas, we just reshape the data
return (checks || [])
.map((check) => {
const networkInterface = (check.net || []).find(
(iface) => iface.name === ifaceName
);
if (!networkInterface) return null;
return {
_id: check._id,
bytesPerSec: networkInterface.deltaBytesRecv,
packetsPerSec: networkInterface.deltaPacketsRecv,
errors: networkInterface.deltaErrOut ?? 0,
drops: networkInterface.deltaDropOut ?? 0,
};
})
.filter(Boolean);
};
const Network = ({ net, checks, isLoading, dateRange, setDateRange }) => {
const { t } = useTranslation();
const theme = useTheme();
const availableInterfaces = getAvailableInterfaces(net);
const [selectedInterface, setSelectedInterface] = useState("");
// Set default interface when data loads
useEffect(() => {
if (availableInterfaces.length > 0 && !selectedInterface) {
setSelectedInterface(availableInterfaces[0]);
}
}, [availableInterfaces, selectedInterface]);
const ethernetData = getNetworkInterfaceData(checks, selectedInterface);
return (
<>
<NetworkStatBoxes
shouldRender={!isLoading}
net={net}
ifaceName={selectedInterface}
/>
<Box
display="flex"
justifyContent="space-between"
alignItems="flex-end"
gap={theme.spacing(4)}
>
{availableInterfaces.length > 0 && (
<Select
name="networkInterface"
label={t("networkInterface")}
value={selectedInterface}
onChange={(e) => setSelectedInterface(e.target.value)}
items={availableInterfaces.map((interfaceName) => ({
_id: interfaceName,
name: interfaceName,
}))}
sx={{ minWidth: 200 }}
/>
)}
<MonitorTimeFrameHeader
isLoading={isLoading}
dateRange={dateRange}
setDateRange={setDateRange}
/>
</Box>
<NetworkCharts
ethernetData={ethernetData}
dateRange={dateRange}
/>
</>
);
};
Network.propTypes = {
net: PropTypes.array,
checks: PropTypes.array,
isLoading: PropTypes.bool.isRequired,
dateRange: PropTypes.string.isRequired,
setDateRange: PropTypes.func.isRequired,
};
export default Network;
@@ -1,56 +0,0 @@
import {
Card,
CardContent,
Skeleton,
Table,
TableHead,
TableRow,
TableCell,
TableBody,
} from "@mui/material";
const SkeletonLayout = () => {
return (
<Card>
<CardContent>
<Skeleton
variant="text"
width={180}
height={32}
/>
<Table size="small">
<TableHead>
<TableRow>
<TableCell>Name</TableCell>
<TableCell>Bytes Sent</TableCell>
<TableCell>Bytes Received</TableCell>
<TableCell>Packets Sent</TableCell>
<TableCell>Packets Received</TableCell>
<TableCell>Errors In</TableCell>
<TableCell>Errors Out</TableCell>
<TableCell>Drops In</TableCell>
<TableCell>Drops Out</TableCell>
</TableRow>
</TableHead>
<TableBody>
{Array.from({ length: 5 }).map((_, idx) => (
<TableRow key={idx}>
{Array.from({ length: 9 }).map((__, colIdx) => (
<TableCell key={colIdx}>
<Skeleton
variant="text"
width={80}
height={24}
/>
</TableCell>
))}
</TableRow>
))}
</TableBody>
</Table>
</CardContent>
</Card>
);
};
export default SkeletonLayout;
@@ -0,0 +1,73 @@
import Stack from "@mui/material/Stack";
import { StatBox } from "@/Components/v2/design-elements";
import { useTheme } from "@mui/material";
import { useTranslation } from "react-i18next";
import type { Monitor } from "@/Types/Monitor";
import {
getAvgTemp,
getCores,
getFrequency,
getGbs,
getDiskTotalGbs,
getOsAndPlatform,
} from "@/Utils/InfraUtils";
export const StatusBoxes = ({ monitor }: { monitor: Monitor }) => {
const { t } = useTranslation();
const theme = useTheme();
const latestCheck = monitor?.recentChecks?.[0];
// Get data from latest check
const physicalCores = getCores(latestCheck?.cpu?.physical_core);
const logicalCores = getCores(latestCheck?.cpu?.logical_core);
const cpuFrequency = getFrequency(latestCheck?.cpu?.frequency);
const cpuTemps = latestCheck?.cpu?.temperature ?? [];
const cpuTemperature = getAvgTemp(cpuTemps);
const memoryTotalBytes = getGbs(latestCheck?.memory?.total_bytes);
const diskTotalBytes = getDiskTotalGbs(latestCheck?.disk);
const os = getOsAndPlatform(latestCheck?.host);
const platform = latestCheck?.host?.platform ?? undefined;
const osPlatform =
typeof os === "undefined" && typeof platform === "undefined"
? undefined
: `${os} ${platform}`;
return (
<Stack
direction="row"
gap={theme.spacing(8)}
flexWrap={"wrap"}
>
<StatBox
title={t("pages.infrastructure.statBoxes.cpuPhysical")}
subtitle={physicalCores.toString()}
/>
<StatBox
title={t("pages.infrastructure.statBoxes.cpuLogical")}
subtitle={logicalCores.toString()}
/>
<StatBox
title={t("pages.infrastructure.statBoxes.cpuFrequency")}
subtitle={cpuFrequency}
/>
<StatBox
title={t("pages.infrastructure.statBoxes.avgCpuTemperature")}
subtitle={cpuTemperature}
/>
<StatBox
title={t("pages.infrastructure.statBoxes.memory")}
subtitle={memoryTotalBytes.toString()}
/>
<StatBox
title={t("pages.infrastructure.statBoxes.disk")}
subtitle={diskTotalBytes.toString()}
/>
<StatBox
title={t("pages.infrastructure.statBoxes.os")}
subtitle={osPlatform || "N/A"}
/>
</Stack>
);
};
@@ -1,115 +0,0 @@
// Components
import { Stack, Typography } from "@mui/material";
import StatusBoxes from "@/Components/v1/StatusBoxes/index.jsx";
import StatBox from "@/Components/v1/StatBox/index.jsx";
//Utils
import { useMonitorUtils } from "../../../../../Hooks/useMonitorUtils.js";
import { useHardwareUtils } from "../../Hooks/useHardwareUtils.jsx";
import { useTranslation } from "react-i18next";
const InfraStatBoxes = ({ shouldRender, monitor }) => {
// Utils
const { formatBytes } = useHardwareUtils();
const { determineState } = useMonitorUtils();
const { t } = useTranslation();
const latestCheck = monitor?.recentChecks?.[0];
// Get data from latest check
const physicalCores = latestCheck?.cpu?.physical_core ?? 0;
const logicalCores = latestCheck?.cpu?.logical_core ?? 0;
const cpuFrequency = latestCheck?.cpu?.frequency ?? 0;
const cpuTemperature =
latestCheck?.cpu?.temperature?.length > 0
? latestCheck.cpu.temperature.reduce((acc, curr) => acc + curr, 0) /
latestCheck.cpu.temperature.length
: 0;
const memoryTotalBytes = latestCheck?.memory?.total_bytes ?? 0;
const diskTotalBytes = latestCheck?.disk[0]?.total_bytes ?? 0;
const os = latestCheck?.host?.os ?? undefined;
const platform = latestCheck?.host?.platform ?? undefined;
const osPlatform =
typeof os === "undefined" && typeof platform === "undefined"
? undefined
: `${os} ${platform}`;
return (
<StatusBoxes
shouldRender={shouldRender}
flexWrap="wrap"
>
<StatBox
gradient={true}
status={determineState(monitor)}
heading={t("status")}
subHeading={determineState(monitor)}
/>
<StatBox
heading={t("cpuPhysical")}
subHeading={
<>
{physicalCores}
<Typography component="span">
{physicalCores === 1 ? "core" : "cores"}
</Typography>
</>
}
/>
<StatBox
key={2}
heading={t("cpuLogical")}
subHeading={
<>
{logicalCores}
<Typography component="span">
{logicalCores === 1 ? "core" : "cores"}
</Typography>
</>
}
/>
<StatBox
heading={t("cpuFrequency")}
subHeading={
<>
{(cpuFrequency / 1000).toFixed(2)}
<Typography component="span">Ghz</Typography>
</>
}
/>
<StatBox
heading={t("avgCpuTemperature")}
subHeading={
<>
{cpuTemperature.toFixed(2)}
<Typography component="span">°C</Typography>
</>
}
/>
<StatBox
heading={t("memory")}
subHeading={formatBytes(memoryTotalBytes)}
/>
<StatBox
heading={t("disk")}
subHeading={formatBytes(diskTotalBytes)}
/>
{/* <StatBox
heading={t("uptime")}
subHeading={
<>
{(uptimePercentage * 100).toFixed(2)}
<Typography component="span">%</Typography>
</>
}
/> */}
<StatBox
key={8}
heading={t("os")}
subHeading={osPlatform}
/>
</StatusBoxes>
);
};
export default InfraStatBoxes;
@@ -0,0 +1,29 @@
import Stack from "@mui/material/Stack";
import { InfraNetworkCharts } from "./ChartsNetwork";
import { useTheme } from "@mui/material";
import type { HardwareStats } from "@/Types/Monitor";
export const TabNetwork = ({
stats,
dateRange,
}: {
stats: HardwareStats | undefined;
dateRange: string;
}) => {
const theme = useTheme();
if (!stats) {
return null;
}
const checks = stats?.checks || [];
return (
<Stack gap={theme.spacing(8)}>
<InfraNetworkCharts
checks={checks}
dateRange={dateRange}
/>
</Stack>
);
};
@@ -0,0 +1,34 @@
import Stack from "@mui/material/Stack";
import { InfraDetailsGauges } from "@/Pages/Infrastructure/Details/Components/Gauges";
import { StatusBoxes } from "@/Pages/Infrastructure/Details/Components/StatusBoxes";
import { InfraDetailsCharts } from "@/Pages/Infrastructure/Details/Components/Charts";
import { useTheme } from "@mui/material";
import type { HardwareStats, Monitor } from "@/Types/Monitor";
export const TabOverview = ({
monitor,
stats,
dateRange,
}: {
monitor: Monitor | undefined;
stats: HardwareStats | undefined;
dateRange: string;
}) => {
const theme = useTheme();
if (!monitor) {
return null;
}
const checks = stats?.checks || [];
return (
<Stack gap={theme.spacing(8)}>
<StatusBoxes monitor={monitor} />
<InfraDetailsGauges snapshot={monitor.recentChecks?.[0]} />
<InfraDetailsCharts
checks={checks}
dateRange={dateRange}
/>
</Stack>
);
};
@@ -1,270 +0,0 @@
import { Typography, Tooltip } from "@mui/material";
import { useTheme } from "@emotion/react";
import { useTranslation } from "react-i18next";
// Constants
const BASE_BOX_PADDING_VERTICAL = 4;
const BASE_BOX_PADDING_HORIZONTAL = 8;
const TYPOGRAPHY_PADDING = 8;
const CHART_CONTAINER_HEIGHT = 300;
const useHardwareUtils = () => {
const theme = useTheme();
const { t } = useTranslation();
const getDimensions = () => {
const totalTypographyPadding = parseInt(theme.spacing(TYPOGRAPHY_PADDING), 10) * 2;
const totalChartContainerPadding =
parseInt(theme.spacing(BASE_BOX_PADDING_VERTICAL), 10) * 2;
return {
baseBoxPaddingVertical: BASE_BOX_PADDING_VERTICAL,
baseBoxPaddingHorizontal: BASE_BOX_PADDING_HORIZONTAL,
totalContainerPadding: parseInt(theme.spacing(BASE_BOX_PADDING_VERTICAL), 10) * 2,
areaChartHeight:
CHART_CONTAINER_HEIGHT - totalChartContainerPadding - totalTypographyPadding,
};
};
const formatBytes = (bytes, space = false) => {
if (bytes === undefined || bytes === null)
return (
<>
{0}
{space ? " " : ""}
<Typography component="span">{t("gb")}</Typography>
</>
);
if (typeof bytes !== "number")
return (
<>
{0}
{space ? " " : ""}
<Typography component="span">{t("gb")}</Typography>
</>
);
if (bytes === 0)
return (
<>
{0}
{space ? " " : ""}
<Typography component="span">{t("gb")}</Typography>
</>
);
const GB = bytes / (1024 * 1024 * 1024);
const MB = bytes / (1024 * 1024);
if (GB >= 1) {
return (
<>
{Number(GB.toFixed(2))}
{space ? " " : ""}
<Typography component="span">{t("gb")}</Typography>
</>
);
} else {
return (
<>
{Number(MB.toFixed(2))}
{space ? " " : ""}
<Typography component="span">{t("mb")}</Typography>
</>
);
}
};
const formatBytesPerSecondString = (bytesPerSec, space = false) => {
if (
bytesPerSec === undefined ||
bytesPerSec === null ||
typeof bytesPerSec !== "number" ||
bytesPerSec === 0
) {
return `0${space ? " " : ""}B/s`;
}
const GB = bytesPerSec / (1024 * 1024 * 1024);
const MB = bytesPerSec / (1024 * 1024);
const KB = bytesPerSec / 1024;
if (GB >= 1) {
return `${Number(GB.toFixed(1))}${space ? " " : ""}GB/s`;
} else if (MB >= 1) {
return `${Number(MB.toFixed(1))}${space ? " " : ""}MB/s`;
} else if (KB >= 1) {
return `${Number(KB.toFixed(1))}${space ? " " : ""}KB/s`;
} else {
return `${Number(bytesPerSec.toFixed(1))}${space ? " " : ""}B/s`;
}
};
const formatPacketsPerSecondString = (packetsPerSec, space = false) => {
if (
packetsPerSec === undefined ||
packetsPerSec === null ||
typeof packetsPerSec !== "number" ||
packetsPerSec === 0
) {
return `0${space ? " " : ""}pps`;
}
const M = packetsPerSec / (1000 * 1000);
const K = packetsPerSec / 1000;
if (M >= 1) {
return `${Number(M.toFixed(1))}${space ? " " : ""}Mpps`;
} else if (K >= 1) {
return `${Number(K.toFixed(1))}${space ? " " : ""}Kpps`;
} else {
return `${Math.round(packetsPerSec)}${space ? " " : ""}pps`;
}
};
const formatDeviceName = (device) => {
const deviceStr = String(device || "");
// Show full device path
return (
<Tooltip
title={deviceStr}
arrow
placement="top"
>
<Typography
component="span"
sx={{
cursor: "default",
display: "inline-block",
userSelect: "none",
whiteSpace: "nowrap",
overflow: "hidden",
textOverflow: "ellipsis",
maxWidth: "100%",
}}
>
{deviceStr}
</Typography>
</Tooltip>
);
};
const formatMountpoint = (mountpoint) => {
const mountpointStr = String(mountpoint || "");
if (!mountpointStr) {
return (
<Tooltip
title="No mountpoint available"
arrow
placement="top"
>
<Typography
component="span"
sx={{
cursor: "default",
display: "inline-block",
userSelect: "none",
color: "text.secondary",
fontStyle: "italic",
}}
>
N/A
</Typography>
</Tooltip>
);
}
// Show full mountpoint path
return (
<Tooltip
title={mountpointStr}
arrow
placement="top"
>
<Typography
component="span"
sx={{
cursor: "default",
display: "inline-block",
userSelect: "none",
whiteSpace: "nowrap",
overflow: "hidden",
textOverflow: "ellipsis",
maxWidth: "100%",
}}
>
{mountpointStr}
</Typography>
</Tooltip>
);
};
/**
* Converts a decimal value to a percentage
*
* @function decimalToPercentage
* @param {number} value - Decimal value to convert
* @returns {number} Percentage representation
*
* @example
* decimalToPercentage(0.75) // Returns 75
* decimalToPercentage(null) // Returns 0
*/
const decimalToPercentage = (value) => {
if (value === null || value === undefined) return 0;
return value * 100;
};
const buildTemps = (checks) => {
let numCores = 1;
if (checks === null) return { temps: [], tempKeys: [] };
for (const check of checks) {
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?.avgTemperature?.length === 0 ||
check?.avgTemperature === undefined ||
check?.avgTemperature === null
) {
check.avgTemperature = Array(numCores).fill(0);
}
const res = check?.avgTemperature?.reduce(
(acc, cur, idx) => {
acc[`core${idx + 1}`] = cur;
return acc;
},
{
_id: check._id,
}
);
return res;
});
if (temps.length === 0 || !temps[0]) {
return { temps: [], tempKeys: [] };
}
return {
tempKeys: Object.keys(temps[0] || {}).filter((key) => key !== "_id"),
temps,
};
};
return {
formatBytes,
formatDeviceName,
formatMountpoint,
decimalToPercentage,
buildTemps,
getDimensions,
formatBytesPerSecondString,
formatPacketsPerSecondString,
};
};
export { useHardwareUtils };
@@ -1,142 +0,0 @@
// Components
import { Stack, Typography, Tab } from "@mui/material";
import Breadcrumbs from "@/Components/v1/Breadcrumbs/index.jsx";
import MonitorDetailsControlHeader from "@/Components/v1/MonitorDetailsControlHeader/index.jsx";
import MonitorTimeFrameHeader from "@/Components/v1/MonitorTimeFrameHeader/index.jsx";
import StatusBoxes from "./Components/StatusBoxes/index.jsx";
import GaugeBoxes from "./Components/GaugeBoxes/index.jsx";
import AreaChartBoxes from "./Components/AreaChartBoxes/index.jsx";
import GenericFallback from "@/Components/v1/GenericFallback/index.jsx";
import NetworkStats from "./Components/NetworkStats/index.jsx";
import CustomTabList from "@/Components/v1/Tab/index.jsx";
import TabContext from "@mui/lab/TabContext";
// Utils
import { useTheme } from "@emotion/react";
import { useIsAdmin } from "@/Hooks/useIsAdmin.js";
import { useFetchHardwareMonitorById } from "../../../Hooks/monitorHooks.js";
import { useState } from "react";
import { useTranslation } from "react-i18next";
import { useParams } from "react-router-dom";
// Constants
const BREADCRUMBS = [
{ name: "infrastructure monitors", path: "/infrastructure" },
{ name: "details", path: "" },
];
const InfrastructureDetails = () => {
// Local state
const [dateRange, setDateRange] = useState("recent");
const [trigger, setTrigger] = useState(false);
const [tab, setTab] = useState("details");
// Utils
const theme = useTheme();
const { monitorId } = useParams();
const { t } = useTranslation();
const isAdmin = useIsAdmin();
const [monitor, isLoading, networkError] = useFetchHardwareMonitorById({
monitorId,
dateRange,
updateTrigger: trigger,
});
const triggerUpdate = () => {
setTrigger(!trigger);
};
if (networkError === true) {
return (
<GenericFallback>
<Typography
variant="h1"
marginY={theme.spacing(4)}
color={theme.palette.primary.contrastTextTertiary}
>
{t("common.toasts.networkError")}
</Typography>
<Typography>{t("common.toasts.checkConnection")}</Typography>
</GenericFallback>
);
}
if (!isLoading && monitor?.stats?.checks?.length === 0) {
return (
<Stack gap={theme.spacing(10)}>
<Breadcrumbs list={BREADCRUMBS} />
<MonitorDetailsControlHeader
path={"infrastructure"}
isLoading={isLoading}
isAdmin={isAdmin}
monitor={monitor}
triggerUpdate={triggerUpdate}
/>
<GenericFallback>
<Typography>{t("distributedUptimeDetailsNoMonitorHistory")}</Typography>
</GenericFallback>
</Stack>
);
}
return (
<Stack gap={theme.spacing(10)}>
<Breadcrumbs list={BREADCRUMBS} />
<MonitorDetailsControlHeader
path={"infrastructure"}
isLoading={isLoading}
isAdmin={isAdmin}
monitor={monitor}
triggerUpdate={triggerUpdate}
/>
<TabContext value={tab}>
<CustomTabList
value={tab}
onChange={(e, v) => setTab(v)}
>
<Tab
label={t("details")}
value="details"
/>
<Tab
label={t("network")}
value="network"
/>
</CustomTabList>
{tab === "details" && (
<>
<StatusBoxes
shouldRender={!isLoading}
monitor={monitor}
/>
<GaugeBoxes
isLoading={isLoading}
monitor={monitor}
/>
<MonitorTimeFrameHeader
isLoading={isLoading}
dateRange={dateRange}
setDateRange={setDateRange}
/>
<AreaChartBoxes
shouldRender={!isLoading}
monitor={monitor}
dateRange={dateRange}
/>
</>
)}
{tab === "network" && (
<NetworkStats
net={monitor?.recentChecks?.[0]?.net || []}
isLoading={isLoading}
checks={monitor?.stats?.checks}
dateRange={dateRange}
setDateRange={setDateRange}
/>
)}
</TabContext>
</Stack>
);
};
export default InfrastructureDetails;
@@ -0,0 +1,90 @@
import { BasePage, Tab, Tabs } from "@/Components/v2/design-elements";
import { HeaderMonitorControls, HeaderTimeRange } from "@/Components/v2/common";
import { MonitorStatBoxes } from "@/Components/v2/monitors";
import { TabNetwork } from "@/Pages/Infrastructure/Details/Components/TabNetwork";
import { TabOverview } from "@/Pages/Infrastructure/Details/Components/TabOverview";
import { useMemo, useState } from "react";
import { useParams } from "react-router-dom";
import { useGet } from "@/Hooks/UseApi";
import type { HardwareDetailsResponse } from "@/Types/Monitor";
import { useIsAdmin } from "@/Hooks/useIsAdmin";
import { useTranslation } from "react-i18next";
const InfrastructureDetails = () => {
const { t } = useTranslation();
const isAdmin = useIsAdmin();
const { monitorId } = useParams<{ monitorId: string }>();
const [dateRange, setDateRange] = useState<string>("recent");
const [selectedTab, setSelectedTab] = useState<number>(0);
const monitorDetailsUrl = useMemo(() => {
if (!monitorId) {
return null;
}
const params = new URLSearchParams();
params.append("dateRange", dateRange);
return `/monitors/hardware/details/${monitorId}?${params.toString()}`;
}, [monitorId, dateRange]);
const {
data: monitorDetailsData,
isLoading: monitorIsLoading,
refetch: refetchMonitor,
} = useGet<HardwareDetailsResponse>(
monitorDetailsUrl,
{},
{ refreshInterval: 10000, keepPreviousData: true }
);
const monitor = monitorDetailsData?.monitor;
const monitorStats = monitorDetailsData?.monitorStats ?? null;
const stats = monitorDetailsData?.stats;
return (
<BasePage>
<HeaderMonitorControls
path="hardware"
monitor={monitor}
isAdmin={isAdmin}
refetch={refetchMonitor}
/>
<MonitorStatBoxes
monitor={monitor}
monitorStats={monitorStats}
/>
<HeaderTimeRange
isLoading={monitorIsLoading}
hasDateRange={true}
dateRange={dateRange}
setDateRange={setDateRange}
/>
<Tabs
value={selectedTab}
onChange={(_e, value) => {
setSelectedTab(value);
}}
>
<Tab label={t("pages.infrastructure.tabs.labels.overview")} />
<Tab label={t("pages.infrastructure.tabs.labels.network")} />
</Tabs>
{selectedTab === 0 && (
<TabOverview
monitor={monitor}
stats={stats}
dateRange={dateRange}
/>
)}
{selectedTab === 1 && (
<TabNetwork
stats={stats}
dateRange={dateRange}
/>
)}
</BasePage>
);
};
export default InfrastructureDetails;
@@ -1,94 +0,0 @@
import { useTheme } from "@emotion/react";
import PropTypes from "prop-types";
import FilterHeader from "@/Components/v1/FilterHeader/index.jsx";
import { useMemo } from "react";
import { Box, Button } from "@mui/material";
import Icon from "@/Components/v1/Icon";
import { useTranslation } from "react-i18next";
/**
* Filter Component
*
* A high-level component that provides filtering options for status in Infrastructure Page.
* It allows users to select multiple options for each filter and reset the filters.
*
* @component
* @param {Object} props - The component props.
* @param {string[]} props.selectedStatus - An array of selected status values.
* @param {function} props.setSelectedStatus - A function to set the selected status values.
* @param {function} props.setToFilterStatus - A function to set the filter status based on selected status values.
* @param {function} props.handleReset - A function to reset all filters.
*
* @returns {JSX.Element} The rendered Filter component.
*/
const statusOptions = [
{ value: "Up", label: "Up" },
{ value: "Down", label: "Down" },
];
const Filter = ({
selectedStatus,
setSelectedStatus,
setToFilterStatus,
handleReset,
}) => {
const theme = useTheme();
const { t } = useTranslation();
const handleStatusChange = (event) => {
const selectedValues = event.target.value;
setSelectedStatus(selectedValues.length > 0 ? selectedValues : undefined);
if (selectedValues.length === 0 || selectedValues.length === 2) {
setToFilterStatus(undefined);
} else {
setToFilterStatus(selectedValues[0] === "Up" ? "true" : "false");
}
};
const isFilterActive = useMemo(() => {
return (selectedStatus?.length ?? 0) > 0;
}, [selectedStatus]);
return (
<Box
sx={{
m: theme.spacing(2),
ml: theme.spacing(4),
}}
>
<FilterHeader
header={t("status")}
options={statusOptions}
value={selectedStatus}
onChange={handleStatusChange}
/>
<Button
color={theme.palette.primary.contrastText}
onClick={handleReset}
variant="contained"
endIcon={
<Icon
name="X"
size={18}
/>
}
sx={{
visibility: isFilterActive ? "visible" : "hidden",
}}
>
{t("reset")}
</Button>
</Box>
);
};
Filter.propTypes = {
selectedStatus: PropTypes.arrayOf(PropTypes.string),
setSelectedStatus: PropTypes.func.isRequired,
setToFilterStatus: PropTypes.func.isRequired,
handleReset: PropTypes.func.isRequired,
};
export default Filter;
@@ -0,0 +1,275 @@
import Box from "@mui/material/Box";
import Typography from "@mui/material/Typography";
import { Table, Pagination, StatusLabel, Gauge } from "@/Components/v2/design-elements";
import type { Header } from "@/Components/v2/design-elements/Table";
import { ActionsMenu, type ActionMenuItem } from "@/Components/v2/actions-menu";
import { ArrowUp, ArrowDown } from "lucide-react";
import { useTranslation } from "react-i18next";
import useMediaQuery from "@mui/material/useMediaQuery";
import { useTheme } from "@mui/material/styles";
import { useNavigate } from "react-router-dom";
import { usePost } from "@/Hooks/UseApi";
import type { Monitor } from "@/Types/Monitor";
export const InfraMonitorsTable = ({
monitors,
refetch,
setSelectedMonitor,
sortField,
setSortField,
sortOrder,
setSortOrder,
count,
page,
setPage,
rowsPerPage,
setRowsPerPage,
}: {
monitors: Monitor[];
refetch: Function;
setSelectedMonitor: Function;
sortField: string;
setSortField: (field: string) => void;
sortOrder: "asc" | "desc";
setSortOrder: (order: "asc" | "desc") => void;
count: number;
page: number;
setPage: (page: number) => void;
rowsPerPage: number;
setRowsPerPage: (rowsPerPage: number) => void;
}) => {
const { t } = useTranslation();
const theme = useTheme();
const isSmall = useMediaQuery(theme.breakpoints.down("md"));
const navigate = useNavigate();
const {
post,
// loading: isPatching,
// error: postError,
} = usePost<any, Monitor>();
const handlePageChange = (
_e: React.MouseEvent<HTMLButtonElement> | null,
newPage: number
) => {
setPage(newPage);
};
const handleRowsPerPageChange = (
e: React.ChangeEvent<HTMLTextAreaElement | HTMLInputElement>
) => {
const value = Number(e.target.value);
setPage(0);
setRowsPerPage(value);
};
const handleSort = (e: any, field: string) => {
e.preventDefault();
e.stopPropagation();
if (sortField === field) {
const newOrder = sortOrder === "asc" ? "desc" : "asc";
setSortOrder(newOrder);
} else {
setSortField(field);
setSortOrder("asc");
}
refetch();
};
const getActions = (monitor: Monitor): ActionMenuItem[] => {
return [
{
id: 1,
label: t("pages.common.monitors.actions.openSite"),
action: () => {
window.open(monitor.url, "_blank", "noreferrer");
},
closeMenu: true,
},
{
id: 2,
label: t("pages.common.monitors.actions.details"),
action: () => {
navigate(`${monitor.id}`);
},
},
{
id: 3,
label: t("pages.common.monitors.actions.incidents"),
action: () => {
navigate(`/incidents?monitorId=${monitor.id}`);
},
},
{
id: 4,
label: t("pages.common.monitors.actions.configure"),
action: () => {
navigate(`/infrastructure/configure/${monitor.id}`);
},
},
// {
// id: 5,
// label: "Clone",
// action: () => {
// },
// },
{
id: 6,
label:
monitor.isActive === false
? t("common.buttons.resume")
: t("common.buttons.pause"),
action: async () => {
await post(`/monitors/pause/${monitor.id}`, {});
refetch();
},
closeMenu: true,
},
{
id: 7,
label: (
<Typography color={theme.palette.error.main}>
{t("common.buttons.delete")}
</Typography>
),
action: () => {
setSelectedMonitor(monitor);
},
closeMenu: true,
},
];
};
const getHeaders = () => {
const renderSortIcon = (isActive: boolean) => (
<Box
width={16}
display="inline-flex"
justifyContent="center"
>
{isActive ? (
sortOrder === "asc" ? (
<ArrowUp size={16} />
) : (
<ArrowDown size={16} />
)
) : null}
</Box>
);
const headers: Header<Monitor>[] = [
{
id: "name",
content: (
<Typography
component="div"
display="inline-flex"
alignItems="center"
gap={theme.spacing(4)}
onClick={(e) => handleSort(e, "name")}
sx={{ cursor: "pointer" }}
>
{t("common.table.headers.name")}
{renderSortIcon(sortField === "name")}
</Typography>
),
render: (row) => {
return row.name;
},
},
{
id: "status",
content: (
<Typography
component="div"
display="inline-flex"
alignItems="center"
gap={theme.spacing(4)}
onClick={(e) => handleSort(e, "status")}
sx={{ cursor: "pointer" }}
>
{t("common.table.headers.status")}
{renderSortIcon(sortField === "status")}
</Typography>
),
render: (row) => {
return (
<StatusLabel
status={row.status}
isActive={row.isActive}
/>
);
},
},
{
id: "cpu",
content: t("pages.infrastructure.table.headers.cpu"),
render: (row) => {
const check = row.recentChecks?.[0];
const cpuUsage = (check?.cpu?.usage_percent || 0) * 100;
return <Gauge progress={cpuUsage} />;
},
},
{
id: "memory",
content: t("pages.infrastructure.table.headers.memory"),
render: (row) => {
const check = row.recentChecks?.[0];
const memoryUsage = (check?.memory?.usage_percent || 0) * 100;
return <Gauge progress={memoryUsage} />;
},
},
{
id: "disk",
content: t("pages.infrastructure.table.headers.disk"),
render: (row) => {
const check = row.recentChecks?.[0];
const totalDiskUsage = check?.disk?.reduce(
(acc, disk) => acc + (disk?.usage_percent || 0),
0
);
const diskCount = check?.disk?.length || 1;
const diskUsage = ((totalDiskUsage || 0) / diskCount) * 100;
return <Gauge progress={diskUsage} />;
},
},
{
id: "actions",
content: t("common.table.headers.actions"),
render: (row) => {
return <ActionsMenu items={getActions(row)} />;
},
},
];
return headers;
};
let headers = getHeaders();
if (isSmall) {
headers = headers.filter((h) => h.id !== "histogram");
}
return (
<Box>
<Table
headers={headers}
data={monitors}
onRowClick={(row) => {
navigate(`/infrastructure/${row.id}`);
}}
/>
<Pagination
component="div"
count={count}
page={page}
rowsPerPage={rowsPerPage}
onPageChange={handlePageChange}
onRowsPerPageChange={handleRowsPerPageChange}
/>
</Box>
);
};
@@ -1,162 +0,0 @@
// Components
import { Box } from "@mui/material";
import DataTable from "@/Components/v1/Table/index.jsx";
import Host from "@/Components/v1/Host/index.jsx";
import { StatusLabel } from "@/Components/v1/Label/index.jsx";
import { Stack } from "@mui/material";
import { InfrastructureMenu } from "../MonitorsTableMenu/index.jsx";
import LoadingSpinner from "../../../../Uptime/Monitors/Components/LoadingSpinner/index.jsx";
// Assets
import Icon from "@/Components/v1/Icon";
import CustomGauge from "@/Components/v1/Charts/CustomGauge/index.jsx";
// Utils
import { useTheme } from "@emotion/react";
import { useMonitorUtils } from "../../../../../Hooks/useMonitorUtils.js";
import { useNavigate } from "react-router-dom";
import PropTypes from "prop-types";
import { useTranslation } from "react-i18next";
const MonitorsTable = ({
isLoading,
monitors,
isAdmin,
handleActionMenuDelete,
isSearching,
}) => {
// Utils
const theme = useTheme();
const { t } = useTranslation();
const { determineState } = useMonitorUtils();
const navigate = useNavigate();
// Handlers
const openDetails = (id) => {
navigate(`/infrastructure/${id}`);
};
const headers = [
{
id: "host",
content: t("host"),
render: (row) => (
<Host
title={row.name}
url={row.url}
percentage={row.uptimePercentage}
percentageColor={row.percentageColor}
/>
),
},
{
id: "status",
content: t("incidentsTableStatus"),
render: (row) => (
<StatusLabel
status={row.status}
text={row.status}
/>
),
},
{
id: "frequency",
content: t("frequency"),
render: (row) => (
<Stack
direction={"row"}
justifyContent={"center"}
alignItems={"center"}
gap=".25rem"
>
<Icon
name="Cpu"
size={20}
/>
{row.processor}
</Stack>
),
},
{ id: "cpu", content: t("cpu"), render: (row) => <CustomGauge progress={row.cpu} /> },
{
id: "memory",
content: t("memory"),
render: (row) => <CustomGauge progress={row.mem} />,
},
{
id: "disk",
content: t("disk"),
render: (row) => <CustomGauge progress={row.disk} />,
},
{
id: "actions",
content: t("actions"),
render: (row) => (
<InfrastructureMenu
monitor={row}
isAdmin={isAdmin}
updateCallback={handleActionMenuDelete}
isLoading={isLoading}
/>
),
},
];
const data = monitors?.map((monitor) => {
const processor =
((monitor.recentChecks?.[0]?.cpu?.frequency ?? 0) / 1000).toFixed(2) + " GHz";
const cpu = (monitor?.recentChecks?.[0]?.cpu?.usage_percent ?? 0) * 100;
const mem = (monitor?.recentChecks?.[0]?.memory?.usage_percent ?? 0) * 100;
const disk = (monitor?.recentChecks?.[0]?.disk?.[0]?.usage_percent ?? 0) * 100;
const status = determineState(monitor);
const percentageColor =
monitor.uptimePercentage < 0.25
? theme.palette.error.main
: monitor.uptimePercentage < 0.5
? theme.palette.warning.main
: theme.palette.success.main;
return {
id: monitor.id,
name: monitor.name,
url: monitor.url,
processor,
cpu,
mem,
disk,
status,
percentageColor,
};
});
return (
<Box position="relative">
<LoadingSpinner shouldRender={isSearching} />
<DataTable
shouldRender={!isLoading}
headers={headers}
data={data}
config={{
/* TODO this behavior seems to be repeated. Put it on the root table? */
rowSX: {
cursor: "pointer",
"&:hover td": {
backgroundColor: theme.palette.tertiary.main,
transition: "background-color .3s ease",
},
},
onRowClick: (row) => openDetails(row.id),
emptyView: "No monitors found",
}}
/>
</Box>
);
};
MonitorsTable.propTypes = {
isLoading: PropTypes.bool,
monitors: PropTypes.array,
isAdmin: PropTypes.bool,
handleActionMenuDelete: PropTypes.func,
isSearching: PropTypes.bool,
};
export default MonitorsTable;
@@ -1,196 +0,0 @@
/* TODO I basically copied and pasted this component from the actionsMenu. Check how we can make it reusable */
import { useRef, useState } from "react";
import { useTheme } from "@emotion/react";
import { useNavigate } from "react-router-dom";
import { createToast } from "@/Utils/toastUtils.jsx";
import { IconButton, Menu, MenuItem } from "@mui/material";
import Icon from "@/Components/v1/Icon";
import PropTypes from "prop-types";
import Dialog from "@/Components/v1/Dialog/index.jsx";
import { networkService } from "@/Utils/NetworkService.js";
import { usePauseMonitor } from "@/Hooks/monitorHooks.js";
import { useTranslation } from "react-i18next";
/**
* InfrastructureMenu Component
* Provides a dropdown menu for managing infrastructure monitors.
*
* @param {Object} props - The component props.
* @param {Object} props.monitor - The monitor object containing details about the infrastructure monitor.
* @param {string} props.monitor.id - Unique ID of the monitor.
* @param {string} [props.monitor.url] - URL associated with the monitor.
* @param {string} props.monitor.type - Type of monitor (e.g., uptime, infrastructure).
* @param {boolean} props.monitor.isActive - Indicates if the monitor is currently active (true) or paused (false).
* @param {boolean} props.isAdmin - Whether the user has admin privileges.
* @param {Function} props.updateCallback - Callback to trigger when the monitor data is updated.
* @returns {JSX.Element} The rendered component.
*/
const InfrastructureMenu = ({ monitor, isAdmin, updateCallback }) => {
const anchor = useRef(null);
const [isOpen, setIsOpen] = useState(false);
const [isDialogOpen, setIsDialogOpen] = useState(false);
const theme = useTheme();
const [pauseMonitor] = usePauseMonitor();
const { t } = useTranslation();
const openMenu = (e) => {
e.stopPropagation();
setIsOpen(true);
};
const closeMenu = (e) => {
e.stopPropagation();
setIsOpen(false);
};
const openRemove = (e) => {
closeMenu(e);
setIsDialogOpen(true);
};
const cancelRemove = () => {
setIsDialogOpen(false);
};
const navigate = useNavigate();
function openDetails(id) {
navigate(`/infrastructure/${id}`);
}
const openConfigure = (id) => {
navigate(`/infrastructure/configure/${id}`);
};
const handlePause = async () => {
// Pass updateCallback as triggerUpdate to the hook
await pauseMonitor({ monitorId: monitor.id, triggerUpdate: updateCallback });
// Toast is already displayed in the hook, no need to display it again
};
const handleRemove = async () => {
try {
await networkService.deleteMonitorById({
monitorId: monitor.id,
});
createToast({
body: t("monitorActions.deleteSuccess"),
});
} catch (error) {
createToast({ body: t("monitorActions.deleteFailed") });
} finally {
setIsDialogOpen(false);
updateCallback();
}
};
return (
<>
<IconButton
aria-label="monitor actions"
onClick={openMenu}
disabled={!isAdmin}
sx={{
"&:focus": {
outline: "none",
},
"& svg path": {
stroke: theme.palette.primary.contrastTextTertiary,
},
}}
ref={anchor}
>
<Icon
name="Settings"
size={20}
/>
</IconButton>
<Menu
className="actions-menu"
anchorEl={anchor.current}
open={isOpen}
onClose={closeMenu}
disableScrollLock
slotProps={{
paper: {
sx: {
"& ul": { p: theme.spacing(2.5) },
"& li": { m: 0 },
"& li:last-of-type": {
color: theme.palette.error.main,
},
},
},
}}
>
<MenuItem
onClick={(e) => {
e.stopPropagation();
openDetails(monitor.id);
closeMenu(e);
}}
>
{t("monitorActions.details")}
</MenuItem>
{isAdmin && (
<MenuItem
onClick={(e) => {
e.stopPropagation();
openConfigure(monitor.id);
closeMenu(e);
}}
>
{t("configure")}
</MenuItem>
)}
{isAdmin && (
<MenuItem
onClick={async (e) => {
e.stopPropagation();
await handlePause();
closeMenu(e);
}}
>
{!monitor.isActive ? t("resume") : t("pause")}
</MenuItem>
)}
{isAdmin && (
<MenuItem
onClick={openRemove}
sx={{ color: theme.palette.error.main }}
>
{t("remove")}
</MenuItem>
)}
</Menu>
<Dialog
open={isDialogOpen}
theme={theme}
title={t("deleteDialogTitle")}
description={t("deleteDialogDescription")}
onCancel={cancelRemove}
confirmationButtonLabel={t("delete")}
onConfirm={handleRemove}
modelTitle="modal-delete-monitor"
modelDescription="delete-monitor-confirmation"
/>
</>
);
};
InfrastructureMenu.propTypes = {
monitor: PropTypes.shape({
id: PropTypes.string.isRequired,
url: PropTypes.string,
// Note: type must remain optional. Making it required (type: PropTypes.string.isRequired)
// causes runtime errors as some monitors don't have a defined type property
type: PropTypes.string,
isActive: PropTypes.bool, // Determines whether the monitor is paused (false) or active (true)
status: PropTypes.string, // Represents the monitor's operational status (e.g., 'up', 'down', etc.)
}).isRequired,
isAdmin: PropTypes.bool.isRequired,
updateCallback: PropTypes.func.isRequired,
};
export { InfrastructureMenu };
@@ -1,139 +0,0 @@
// Components
import { Stack } from "@mui/material";
import Breadcrumbs from "@/Components/v1/Breadcrumbs/index.jsx";
import MonitorCountHeader from "@/Components/v1/MonitorCountHeader/index.jsx";
import MonitorCreateHeader from "@/Components/v1/MonitorCreateHeader/index.jsx";
import MonitorsTable from "./Components/MonitorsTable/index.jsx";
import Pagination from "@/Components/v1/Table/TablePagination/index.jsx";
import PageStateWrapper from "@/Components/v1/PageStateWrapper/index.jsx";
import Filter from "./Components/Filters/index.jsx";
import SearchComponent from "../../Uptime/Monitors/Components/SearchComponent/index.jsx";
// Utils
import { useTheme } from "@emotion/react";
import { useEffect, useState } from "react";
import { useIsAdmin } from "@/Hooks/useIsAdmin.js";
import { useTranslation } from "react-i18next";
import { useFetchMonitorsWithChecks } from "@/Hooks/monitorHooks.js";
import { useDispatch, useSelector } from "react-redux";
import { setRowsPerPage } from "../../../Features/UI/uiSlice.js";
// Constants
const TYPES = ["hardware"];
const BREADCRUMBS = [{ name: `infrastructure`, path: "/infrastructure" }];
const InfrastructureMonitors = () => {
// Redux state
const rowsPerPage = useSelector((state) => state.ui?.infrastructure?.rowsPerPage ?? 5);
const dispatch = useDispatch();
// Local state
const [page, setPage] = useState(0);
const [updateTrigger, setUpdateTrigger] = useState(false);
const [selectedStatus, setSelectedStatus] = useState(undefined);
const [toFilterStatus, setToFilterStatus] = useState(undefined);
const [search, setSearch] = useState(undefined);
const [isSearching, setIsSearching] = useState(false);
// Utils
const theme = useTheme();
const isAdmin = useIsAdmin();
const { t } = useTranslation();
// Handlers
const handleActionMenuDelete = () => {
setUpdateTrigger(!updateTrigger);
};
const handleChangePage = (event, newPage) => {
setPage(newPage);
};
const handleChangeRowsPerPage = (event) => {
dispatch(
setRowsPerPage({
value: parseInt(event.target.value, 10),
table: "infrastructure",
})
);
setPage(0);
};
useEffect(() => {
if (isSearching) {
setPage(0);
}
}, [isSearching]);
const handleReset = () => {
setSelectedStatus(undefined);
setToFilterStatus(undefined);
};
const field = toFilterStatus !== undefined ? "status" : undefined;
const [summary, monitors, count, isLoading, networkError] = useFetchMonitorsWithChecks({
types: TYPES,
limit: 1,
page: page,
field: field,
filter: toFilterStatus ?? search,
rowsPerPage: rowsPerPage,
monitorUpdateTrigger: updateTrigger,
});
return (
<>
<PageStateWrapper
networkError={networkError}
isLoading={isLoading}
items={monitors}
type="infrastructureMonitor"
fallbackLink="/infrastructure/create"
>
<Stack gap={theme.spacing(10)}>
<Breadcrumbs list={BREADCRUMBS} />
<MonitorCreateHeader
isAdmin={isAdmin}
isLoading={isLoading}
path="/infrastructure/create"
/>
<Stack direction={"row"}>
<MonitorCountHeader
isLoading={isLoading}
monitorCount={count || 0}
/>
<Filter
selectedStatus={selectedStatus}
setSelectedStatus={setSelectedStatus}
setToFilterStatus={setToFilterStatus}
handleReset={handleReset}
/>
<SearchComponent
monitors={monitors}
onSearchChange={setSearch}
setIsSearching={setIsSearching}
/>
</Stack>
<MonitorsTable
isLoading={isLoading}
monitors={monitors}
isAdmin={isAdmin}
handleActionMenuDelete={handleActionMenuDelete}
isSearching={isSearching}
/>
<Pagination
itemCount={count || 0}
paginationLabel={t("monitors")}
page={page}
rowsPerPage={rowsPerPage}
handleChangePage={handleChangePage}
handleChangeRowsPerPage={handleChangeRowsPerPage}
/>
</Stack>
</PageStateWrapper>
</>
);
};
export default InfrastructureMonitors;
@@ -0,0 +1,200 @@
import Stack from "@mui/material/Stack";
import useMediaQuery from "@mui/material/useMediaQuery";
import {
MonitorBasePageWithStates,
UpStatusBox,
DownStatusBox,
PausedStatusBox,
} from "@/Components/v2/design-elements";
import { HeaderCreate } from "@/Components/v2/common";
import { ControlsFilter } from "@/Components/v2/monitors";
import { TextField, Dialog } from "@/Components/v2/inputs";
import { useGet, useDelete } from "@/Hooks/UseApi";
import { useIsAdmin } from "@/Hooks/useIsAdmin";
import type { Monitor, MonitorsWithChecksResponse } from "@/Types/Monitor";
import { useTheme } from "@mui/material";
import { useState, useCallback, useMemo } from "react";
import { useTranslation } from "react-i18next";
import { useSelector, useDispatch } from "react-redux";
import { setRowsPerPage } from "@/Features/UI/uiSlice.js";
import type { RootState } from "@/Types/state";
import { InfraMonitorsTable } from "./Components/MonitorsTable";
const InfrastructureMonitors = () => {
const { t } = useTranslation();
const theme = useTheme();
const isSmall = useMediaQuery(theme.breakpoints.down("md"));
const isAdmin = useIsAdmin();
const dispatch = useDispatch();
// Redux state
const rowsPerPage = useSelector(
(state: RootState) => state.ui?.infrastructure?.rowsPerPage ?? 5
);
// Local state
const [selectedStatus, setSelectedStatus] = useState<string>("");
const [selectedState, setSelectedState] = useState<string>("");
const [search, setSearch] = useState<string>("");
const [page, setPage] = useState<number>(0);
const [sortField, setSortField] = useState<string>("");
const [sortOrder, setSortOrder] = useState<"asc" | "desc">("asc");
const [selectedMonitor, setSelectedMonitor] = useState<Monitor | null>(null);
const isDialogOpen = Boolean(selectedMonitor);
const handleClearFilters = useCallback(() => {
setSelectedStatus("");
setSelectedState("");
setSearch("");
}, []);
// Convert filter selections to API filter values
const toFilterStatus = useMemo(() => {
if (selectedStatus === "up") return "true";
if (selectedStatus === "down") return "false";
return undefined;
}, [selectedStatus]);
const toFilterActive = useMemo(() => {
if (selectedState === "active") return "true";
if (selectedState === "paused") return "false";
return undefined;
}, [selectedState]);
// Determine field and filter for the API request
// Priority: status > isActive > search
const filterLookup = new Map<string | undefined, string>([
[toFilterStatus, "status"],
[toFilterActive, "isActive"],
]);
const activeFilter = [...filterLookup].find(([key]) => key !== undefined);
const field = activeFilter?.[1] || (search ? "name" : sortField || undefined);
const filter = activeFilter?.[0] || search || undefined;
// Build URL for monitors with checks
const monitorsWithChecksUrl = useMemo(() => {
const params = new URLSearchParams();
params.append("type", "hardware");
params.append("limit", "1");
if (page !== undefined) params.append("page", String(page));
if (rowsPerPage) params.append("rowsPerPage", String(rowsPerPage));
if (filter) params.append("filter", filter);
if (field) params.append("field", field);
if (sortOrder) params.append("order", sortOrder);
return `/monitors/team/with-checks?${params.toString()}`;
}, [page, rowsPerPage, filter, field, sortOrder]);
const {
data: monitors,
isLoading: monitorsLoading,
error,
refetch: refetchMonitors,
} = useGet<Monitor[]>("/monitors/team?type=hardware", {}, { keepPreviousData: true });
const {
data: monitorsWithChecksData,
isLoading: monitorsWithChecksLoading,
error: monitorsWithChecksError,
refetch,
} = useGet<MonitorsWithChecksResponse>(
monitorsWithChecksUrl,
{},
{ refreshInterval: 5000, keepPreviousData: true }
);
const { summary, count } = monitorsWithChecksData ?? {};
const isLoading = monitorsLoading || monitorsWithChecksLoading;
// Delete hook
const { deleteFn, loading: isDeleting } = useDelete();
const handleConfirm = async () => {
if (!selectedMonitor) return;
await deleteFn(`/monitors/${selectedMonitor.id}`);
setSelectedMonitor(null);
refetch();
refetchMonitors();
};
const handleCancel = () => {
setSelectedMonitor(null);
};
return (
<MonitorBasePageWithStates
loading={isLoading}
error={error ?? monitorsWithChecksError}
items={monitors || []}
page="infrastructure"
actionLink="/infrastructure/create"
>
<HeaderCreate
path="/infrastructure/create"
isLoading={isLoading}
isAdmin={isAdmin}
/>
<Stack
direction={isSmall ? "column" : "row"}
gap={theme.spacing(8)}
>
<UpStatusBox n={summary?.upMonitors || 0} />
<DownStatusBox n={summary?.downMonitors || 0} />
<PausedStatusBox n={summary?.pausedMonitors || 0} />
</Stack>
<Stack
direction={isSmall ? "column" : "row"}
justifyContent={isSmall ? "flex-start" : "space-between"}
gap={theme.spacing(4)}
>
<ControlsFilter
showTypes={false}
selectedStatus={selectedStatus}
setSelectedStatus={setSelectedStatus}
selectedState={selectedState}
setSelectedState={setSelectedState}
onClearFilters={handleClearFilters}
/>
<TextField
placeholder={t("pages.uptime.filters.search.placeholder")}
value={search}
onChange={(event) => {
setSearch(event.target.value);
}}
/>
</Stack>
<InfraMonitorsTable
monitors={monitorsWithChecksData?.monitors || []}
refetch={refetch}
setSelectedMonitor={setSelectedMonitor}
sortField={sortField}
setSortField={setSortField}
sortOrder={sortOrder}
setSortOrder={setSortOrder}
count={count || 0}
page={page}
setPage={setPage}
rowsPerPage={rowsPerPage}
setRowsPerPage={(value: number) => {
dispatch(
setRowsPerPage({
value,
table: "infrastructure",
})
);
setPage(0);
}}
/>
<Dialog
open={isDialogOpen}
title={t("common.dialogs.delete.title")}
content={t("common.dialogs.delete.description")}
onConfirm={handleConfirm}
onCancel={handleCancel}
loading={isDeleting}
/>
</MonitorBasePageWithStates>
);
};
export default InfrastructureMonitors;
@@ -1,83 +0,0 @@
// Components
import Menu from "@mui/material/Menu";
import IconButton from "@mui/material/IconButton";
import Icon from "@/Components/v1/Icon";
import MenuItem from "@mui/material/MenuItem";
// Utils
import { useState } from "react";
import { useTheme } from "@emotion/react";
import { useNavigate } from "react-router-dom";
import PropTypes from "prop-types";
import { useTranslation } from "react-i18next";
const ActionMenu = ({ notification, onDelete }) => {
const theme = useTheme();
const navigate = useNavigate();
const [anchorEl, setAnchorEl] = useState(null);
const open = Boolean(anchorEl);
const { t } = useTranslation();
// Handlers
const handleClick = (event) => {
event.stopPropagation();
setAnchorEl(event.currentTarget);
};
const handleClose = (event) => {
if (event) {
event.stopPropagation();
}
setAnchorEl(null);
};
const handleRemove = (e) => {
e.stopPropagation();
onDelete(notification.id);
handleClose();
};
const handleConfigure = (e) => {
e.stopPropagation();
navigate(`/notifications/${notification.id}`);
handleClose();
};
return (
<>
<IconButton
aria-label="notification actions"
onClick={handleClick}
onMouseDown={(e) => e.stopPropagation()}
>
<Icon
name="Settings"
size={20}
/>
</IconButton>
<Menu
anchorEl={anchorEl}
open={open}
onClose={handleClose}
anchorOrigin={{ vertical: "bottom", horizontal: "right" }}
onClick={(e) => e.stopPropagation()}
onMouseDown={(e) => e.stopPropagation()}
>
<MenuItem onClick={handleConfigure}>{t("configure")}</MenuItem>
<MenuItem
onClick={handleRemove}
sx={{ "&.MuiButtonBase-root": { color: theme.palette.error.main } }}
>
{t("delete")}
</MenuItem>
</Menu>
</>
);
};
ActionMenu.propTypes = {
notification: PropTypes.object,
onDelete: PropTypes.func,
};
export default ActionMenu;
@@ -0,0 +1,96 @@
import { ActionsMenu, type ActionMenuItem } from "@/Components/v2/actions-menu";
import Typography from "@mui/material/Typography";
import type { Header } from "@/Components/v2/design-elements/Table";
import { Table } from "@/Components/v2/design-elements";
import type { Notification } from "@/Types/Notification";
import { useNavigate } from "react-router";
import { useTranslation } from "react-i18next";
import { useTheme } from "@mui/material";
interface NotificationsTableProps {
notifications: Notification[];
setSelectedChannel: Function;
}
export const NotificationsTable = ({
notifications,
setSelectedChannel,
}: NotificationsTableProps) => {
const navigate = useNavigate();
const { t } = useTranslation();
const theme = useTheme();
const getActions = (channel: Notification): ActionMenuItem[] => {
return [
{
id: 1,
label: t("pages.common.monitors.actions.configure"),
action: () => {
navigate(`/notifications/${channel.id}`);
},
closeMenu: true,
},
{
id: 7,
label: (
<Typography color={theme.palette.error.main}>
{t("pages.common.monitors.actions.delete")}
</Typography>
),
action: async () => {
setSelectedChannel(channel);
},
closeMenu: true,
},
];
};
const getHeaders = () => {
const headers: Header<Notification>[] = [
{
id: "name",
content: t("common.table.headers.name"),
render: (row) => {
return <Typography>{row?.notificationName}</Typography>;
},
},
{
id: "type",
content: t("common.table.headers.type"),
render: (row) => {
return <Typography textTransform={"capitalize"}>{row?.type}</Typography>;
},
},
{
id: "destination",
content: t("pages.notifications.table.headers.destination"),
render: (row) => {
return <Typography>{row?.address}</Typography>;
},
},
{
id: "actions",
content: t("common.table.headers.actions"),
render: (row) => {
return <ActionsMenu items={getActions(row)} />;
},
},
];
return headers;
};
const headers = getHeaders();
return (
<Table
headers={headers}
data={notifications}
onRowClick={(row) => {
navigate(`/notifications/${row.id}`);
}}
/>
);
};
-121
View File
@@ -1,121 +0,0 @@
// Components
import Stack from "@mui/material/Stack";
import Typography from "@mui/material/Typography";
import Breadcrumbs from "@/Components/v1/Breadcrumbs/index.jsx";
import Button from "@mui/material/Button";
import DataTable from "@/Components/v1/Table/index.jsx";
import ActionMenu from "./components/ActionMenu.jsx";
import PageStateWrapper from "@/Components/v1/PageStateWrapper/index.jsx";
// Utils
import { useState } from "react";
import { useTheme } from "@emotion/react";
import { useNavigate } from "react-router-dom";
import {
useGetNotificationsByTeamId,
useDeleteNotification,
} from "../../Hooks/useNotifications.js";
import { useTranslation } from "react-i18next";
const Notifications = () => {
const navigate = useNavigate();
const theme = useTheme();
const BREADCRUMBS = [{ name: "notifications", path: "/notifications" }];
const [updateTrigger, setUpdateTrigger] = useState(false);
const [notifications, isLoading, error] = useGetNotificationsByTeamId(updateTrigger);
const [deleteNotification, isDeleting, deleteError] = useDeleteNotification();
const { t } = useTranslation();
// Handlers
const triggerUpdate = () => {
setUpdateTrigger(!updateTrigger);
};
const onDelete = (id) => {
deleteNotification(id, triggerUpdate);
};
const headers = [
{
id: "name",
content: "Name",
render: (row) => {
return row.notificationName;
},
},
{
id: "target",
content: "Target",
render: (row) => {
return row.address;
},
},
{
id: "platform",
content: "Platform",
render: (row) => {
return row?.config?.platform || row.type;
},
},
{
id: "actions",
content: "Actions",
onClick: (e) => {
e.stopPropagation();
},
render: (row) => {
return (
<ActionMenu
notification={row}
onDelete={onDelete}
/>
);
},
},
];
return (
<>
<PageStateWrapper
networkError={error}
isLoading={isLoading}
items={notifications}
type="notifications"
fallbackLink="/notifications/create"
>
<Stack gap={theme.spacing(10)}>
<Breadcrumbs list={BREADCRUMBS} />
<Stack
direction="row"
justifyContent="flex-end"
>
<Button
variant="contained"
color="accent"
onClick={() => navigate("/notifications/create")}
>
{t("notifications.createButton")}
</Button>
</Stack>
<Typography variant="h1">{t("notifications.createTitle")}</Typography>
<DataTable
config={{
onRowClick: (row) => navigate(`/notifications/${row.id}`),
rowSX: {
cursor: "pointer",
"&:hover td": {
backgroundColor: theme.palette.tertiary.main,
transition: "background-color .3s ease",
},
},
}}
headers={headers}
data={notifications}
/>
</Stack>
</PageStateWrapper>
</>
);
};
export default Notifications;
+65
View File
@@ -0,0 +1,65 @@
import { BasePageWithStates } from "@/Components/v2/design-elements";
import { NotificationsTable } from "@/Pages/Notifications/components/NotificationsTable";
import { Dialog } from "@/Components/v2/inputs";
import { useState } from "react";
import { useGet, useDelete } from "@/Hooks/UseApi";
import { useTranslation } from "react-i18next";
import type { Notification } from "@/Types/Notification";
const NotificationsPage = () => {
const { t } = useTranslation();
const [selectedChannel, setSelectedChannel] = useState<Notification | null>(null);
const isDialogOpen = Boolean(selectedChannel);
const {
data: notifications,
isLoading,
isValidating,
error,
refetch,
} = useGet<Notification[]>("/notifications/team", {}, { keepPreviousData: true });
const { deleteFn, loading: isDeleting } = useDelete();
const handleConfirm = async () => {
if (!selectedChannel) return;
await deleteFn(`/notifications/${selectedChannel.id}`);
setSelectedChannel(null);
refetch();
};
const handleCancel = () => {
setSelectedChannel(null);
};
return (
<BasePageWithStates
page={t("pages.notifications.fallback.title")}
bullets={
t("pages.notifications.fallback.checks", { returnObjects: true }) as string[]
}
loading={isLoading || isValidating}
error={!!error}
items={notifications ?? []}
actionButtonText={t("pages.notifications.fallback.actionButton")}
actionLink="/notifications/create"
>
<NotificationsTable
notifications={notifications ?? []}
setSelectedChannel={setSelectedChannel}
/>
<Dialog
open={isDialogOpen}
title={t("common.dialogs.delete.title")}
content={t("common.dialogs.delete.description")}
onConfirm={handleConfirm}
onCancel={handleCancel}
loading={isDeleting}
/>
</BasePageWithStates>
);
};
export default NotificationsPage;
@@ -1,330 +0,0 @@
import PropTypes from "prop-types";
import {
AreaChart,
Area,
XAxis,
Tooltip,
CartesianGrid,
ResponsiveContainer,
Text,
} from "recharts";
import { useTheme } from "@emotion/react";
import { useMemo, useState } from "react";
import { Box, Stack, Typography } from "@mui/material";
import { formatDateWithTz } from "../../../../../Utils/timeUtilsLegacy.js";
import { useSelector } from "react-redux";
const config = {
seo: {
id: "seo",
text: "SEO",
color: "secondary",
},
performance: {
id: "performance",
text: "performance",
color: "success",
},
bestPractices: {
id: "bestPractices",
text: "best practices",
color: "warning",
},
accessibility: {
id: "accessibility",
text: "accessibility",
color: "accent",
},
};
/**
* Custom tooltip for the area chart.
* @param {Object} props
* @param {boolean} props.active - Whether the tooltip is active.
* @param {Array} props.payload - The payload data for the tooltip.
* @param {string} props.label - The label for the tooltip.
* @returns {JSX.Element|null} The tooltip element or null if not active.
*/
const CustomToolTip = ({ active, payload, label, config }) => {
const theme = useTheme();
const uiTimezone = useSelector((state) => state.ui.timezone);
if (active && payload && payload.length) {
return (
<Box
sx={{
backgroundColor: theme.palette.primary.main,
border: 1,
borderColor: theme.palette.primary.lowContrast,
borderRadius: theme.shape.borderRadius,
py: theme.spacing(2),
px: theme.spacing(4),
}}
>
<Typography
sx={{
color: theme.palette.primary.contrastTextTertiary,
fontSize: 12,
fontWeight: 500,
}}
>
{formatDateWithTz(label, "ddd, MMMM D, YYYY, h:mm A", uiTimezone)}
</Typography>
{Object.keys(config)
.reverse()
.map((key) => {
const { color } = config[key];
const dotColor = theme.palette[color].main;
return (
<Stack
key={`${key}-tooltip`}
direction="row"
alignItems="center"
gap={theme.spacing(3)}
mt={theme.spacing(1)}
sx={{
"& span": {
color: theme.palette.primary.contrastTextTertiary,
fontSize: 11,
fontWeight: 500,
},
}}
>
<Box
width={theme.spacing(4)}
height={theme.spacing(4)}
backgroundColor={dotColor}
sx={{ borderRadius: "50%" }}
/>
<Typography
component="span"
textTransform="capitalize"
sx={{ opacity: 0.8 }}
>
{config[key].text}
</Typography>{" "}
<Typography component="span">{payload[0].payload[key]}</Typography>
</Stack>
);
})}
</Box>
);
}
return null;
};
CustomToolTip.propTypes = {
active: PropTypes.bool,
payload: PropTypes.array,
label: PropTypes.string,
config: PropTypes.object,
};
/**
* Processes data to insert gaps with null values based on the interval.
* @param {Array} data
* @param {number} interval - The interval in milliseconds for gaps.
* @returns {Array} The formatted data with gaps.
*/
const processDataWithGaps = (data, interval) => {
if (data.length === 0) return [];
let formattedData = [];
let last = new Date(data[0].createdAt).getTime();
// Helper function to add a null entry
const addNullEntry = (timestamp) => {
formattedData.push({
accessibility: "N/A",
bestPractices: "N/A",
performance: "N/A",
seo: "N/A",
createdAt: timestamp,
});
};
data.forEach((entry) => {
const current = new Date(entry.createdAt).getTime();
if (current - last > interval * 2) {
// Insert null entries for each interval
let temp = last + interval;
while (temp < current) {
addNullEntry(new Date(temp).toISOString());
temp += interval;
}
}
formattedData.push(entry);
last = current;
});
return formattedData;
};
/**
* Custom tick component to render ticks on the XAxis.
*
* @param {Object} props
* @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 data object containing the tick value.
* @param {number} props.index - The index of the tick in the array of ticks.
*
* @returns {JSX.Element|null} The tick element or null if the tick should be hidden.
*/
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 (
<Text
x={x}
y={y + 8}
textAnchor="middle"
fill={theme.palette.primary.contrastTextTertiary}
fontSize={11}
fontWeight={400}
>
{formatDateWithTz(payload?.value, "h:mm a", uiTimezone)}
</Text>
);
};
CustomTick.propTypes = {
x: PropTypes.number,
y: PropTypes.number,
payload: PropTypes.shape({
value: PropTypes.string.isRequired,
}),
index: PropTypes.number,
};
/**
* A chart displaying pagespeed details over time.
* @param {Object} props
* @param {Array} props.data - The data to display in the chart.
* @param {number} props.interval - The interval in milliseconds for processing gaps.
* @returns {JSX.Element} The area chart component.
*/
const PageSpeedAreaChart = ({ data, monitor, metrics }) => {
const theme = useTheme();
const [isHovered, setIsHovered] = useState(false);
const memoizedData = useMemo(
() => processDataWithGaps(data, monitor.interval),
[data[0]]
);
const filteredConfig = Object.keys(config).reduce((result, key) => {
if (metrics[key]) {
result[key] = config[key];
}
return result;
}, {});
return (
<ResponsiveContainer
width="100%"
minWidth={25}
height={215}
>
<AreaChart
width="100%"
height="100%"
data={memoizedData}
margin={{ top: 10 }}
onMouseMove={() => setIsHovered(true)}
onMouseLeave={() => setIsHovered(false)}
>
<CartesianGrid
stroke={theme.palette.primary.lowContrast}
strokeWidth={1}
strokeOpacity={1}
fill="transparent"
vertical={false}
/>
<XAxis
stroke={theme.palette.primary.lowContrast}
dataKey="createdAt"
tick={<CustomTick />}
axisLine={false}
tickLine={false}
height={18}
minTickGap={0}
interval="equidistantPreserveStart"
/>
<Tooltip
cursor={{ stroke: theme.palette.primary.lowContrast }}
content={<CustomToolTip config={filteredConfig} />}
/>
<defs>
{Object.values(filteredConfig).map(({ id, color }) => {
/* TODO not working? */
const startColor = theme.palette[color].main;
const endColor = theme.palette[color].lowContrast;
return (
<linearGradient
id={id}
x1="0"
y1="0"
x2="0"
y2="1"
key={id}
>
<stop
offset="0%"
stopColor={startColor}
stopOpacity={0.8}
/>
<stop
offset="100%"
stopColor={endColor}
stopOpacity={0}
/>
</linearGradient>
);
})}
</defs>
{Object.keys(filteredConfig).map((key) => {
const { color } = filteredConfig[key];
const strokeColor = theme.palette[color].main;
const bgColor = theme.palette.primary.main;
return (
<Area
connectNulls
key={key}
dataKey={key}
stackId={1}
stroke={strokeColor}
strokeWidth={isHovered ? 2.5 : 1.5}
fill={`url(#${filteredConfig[key].id})`}
activeDot={{ stroke: bgColor, fill: strokeColor, r: 4.5 }}
/>
);
})}
</AreaChart>
</ResponsiveContainer>
);
};
PageSpeedAreaChart.propTypes = {
data: PropTypes.arrayOf(
PropTypes.shape({
createdAt: PropTypes.string.isRequired,
accessibility: PropTypes.oneOfType([PropTypes.string, PropTypes.number]).isRequired,
bestPractices: PropTypes.oneOfType([PropTypes.string, PropTypes.number]).isRequired,
performance: PropTypes.oneOfType([PropTypes.string, PropTypes.number]).isRequired,
seo: PropTypes.oneOfType([PropTypes.string, PropTypes.number]).isRequired,
})
).isRequired,
monitor: PropTypes.object.isRequired,
metrics: PropTypes.object,
};
export default PageSpeedAreaChart;
@@ -1,62 +0,0 @@
import { Box, Typography, Divider } from "@mui/material";
import Checkbox from "@/Components/v1/Inputs/Checkbox/index.jsx";
import Icon from "@/Components/v1/Icon";
import LegendBox from "@/Components/v1/Charts/LegendBox/index.jsx";
import { useTranslation } from "react-i18next";
import { useTheme } from "@emotion/react";
const AreaChartLegend = ({ metrics, handleMetrics }) => {
const theme = useTheme();
const { t } = useTranslation();
return (
<LegendBox
icon={
<Icon
name="Ruler"
size={20}
/>
}
header="Metrics"
>
<Box>
<Typography
fontSize={11}
fontWeight={500}
>
{t("shown")}
</Typography>
<Divider sx={{ mt: theme.spacing(2) }} />
</Box>
<Checkbox
id="accessibility-toggle"
label="Accessibility"
isChecked={metrics.accessibility}
onChange={() => handleMetrics("accessibility")}
/>
<Divider />
<Checkbox
id="best-practices-toggle"
label="Best Practices"
isChecked={metrics.bestPractices}
onChange={() => handleMetrics("bestPractices")}
/>
<Divider />
<Checkbox
id="performance-toggle"
label="Performance"
isChecked={metrics.performance}
onChange={() => handleMetrics("performance")}
/>
<Divider />
<Checkbox
id="seo-toggle"
label="Search Engine Optimization"
isChecked={metrics.seo}
onChange={() => handleMetrics("seo")}
/>
<Divider />
</LegendBox>
);
};
export default AreaChartLegend;
@@ -1,327 +0,0 @@
import PropTypes from "prop-types";
import { PieChart as MuiPieChart } from "@mui/x-charts/PieChart";
import { useDrawingArea } from "@mui/x-charts";
import { useState } from "react";
import { useTheme } from "@emotion/react";
import { Box } from "@mui/material";
/**
* Renders a centered label within a pie chart.
*
* @param {Object} props
* @param {string | number} props.value - The value to display in the label.
* @param {string} props.color - The color of the text.
* @returns {JSX.Element}
*/
const PieCenterLabel = ({ value, color, setExpand }) => {
const { width, height } = useDrawingArea();
return (
<g
transform={`translate(${width / 2}, ${height / 2})`}
onMouseEnter={() => setExpand(true)}
>
<circle
cx={0}
cy={0}
r={width / 4}
fill="transparent"
/>
<text
className="pie-label"
style={{
fill: color,
fontSize: 48,
textAnchor: "middle",
dominantBaseline: "central",
userSelect: "none",
}}
>
{value}
</text>
</g>
);
};
PieCenterLabel.propTypes = {
value: PropTypes.oneOfType([PropTypes.string, PropTypes.number]),
color: PropTypes.string,
setExpand: PropTypes.func,
};
/**
* A component that renders a label on a pie chart slice.
* The label is positioned relative to the center of the pie chart and is optionally highlighted.
*
* @param {Object} props
* @param {number} props.value - The value to display inside the pie slice.
* @param {number} props.startAngle - The starting angle of the pie slice in degrees.
* @param {number} props.endAngle - The ending angle of the pie slice in degrees.
* @param {string} props.color - The color of the label text when highlighted.
* @param {boolean} props.highlighted - Determines if the label should be highlighted or not.
* @returns {JSX.Element}
*/
const PieValueLabel = ({ value, startAngle, endAngle, color, highlighted }) => {
const { width, height } = useDrawingArea();
// Compute the midpoint angle in radians
const angle = (((startAngle + endAngle) / 2) * Math.PI) / 180;
const radius = height / 4; // length from center of the circle to where the text is positioned
// Calculate x and y positions
const x = Math.sin(angle) * radius;
const y = -Math.cos(angle) * radius;
return (
<g transform={`translate(${width / 2}, ${height / 2})`}>
<text
className="pie-value-label"
x={x}
y={y}
style={{
fill: highlighted ? color : "rgba(0,0,0,0)",
fontSize: 12,
textAnchor: "middle",
dominantBaseline: "central",
userSelect: "none",
pointerEvents: "none",
}}
>
+{value}
</text>
</g>
);
};
// Validate props using PropTypes
PieValueLabel.propTypes = {
value: PropTypes.number.isRequired,
startAngle: PropTypes.number.isRequired,
endAngle: PropTypes.number.isRequired,
color: PropTypes.string.isRequired,
highlighted: PropTypes.bool.isRequired,
};
/**
* Weight constants for different performance metrics.
* @type {Object}
*/
const weights = {
fcp: 10,
si: 10,
lcp: 25,
tbt: 30,
cls: 25,
};
const PieChart = ({ audits }) => {
const theme = useTheme();
const [highlightedItem, setHighLightedItem] = useState(null);
const [expand, setExpand] = useState(false);
/**
* Retrieves color properties based on the performance value.
*
* @param {number} value - The performance score used to determine the color properties.
* @returns {{stroke: string, strokeBg: string, text: string, bg: string}} The color properties for the given performance value.
*/
const getColors = (value) => {
if (value >= 90 && value <= 100)
return {
stroke: theme.palette.success.main,
strokeBg: theme.palette.success.lowContrast,
text: theme.palette.success.contrastText,
bg: theme.palette.success.lowContrast,
};
else if (value >= 50 && value < 90)
return {
stroke: theme.palette.warning.main,
strokeBg: theme.palette.warning.lowContrast,
text: theme.palette.warning.contrastText,
bg: theme.palette.warning.lowContrast,
};
else if (value >= 0 && value < 50)
return {
stroke: theme.palette.error.contrastText,
strokeBg: theme.palette.error.lowContrast,
text: theme.palette.error.contrastText,
bg: theme.palette.error.lowContrast,
};
return {
stroke: theme.palette.tertiary.contrastText,
strokeBg: theme.palette.tertiary.contrastText,
text: theme.palette.tertiary.contrastText,
bg: theme.palette.tertiary.main,
};
};
/**
* Calculates and formats the data needed for rendering a pie chart based on audit scores and weights.
* This function generates properties for each pie slice, including angles, radii, and colors.
* It also calculates performance based on the weighted values.
*
* @returns {Array<Object>} An array of objects, each representing the properties for a slice of the pie chart.
* @returns {number} performance - A variable updated with the rounded sum of weighted values.
*/
let performance = 0;
const getPieData = (audits) => {
if (typeof audits === "undefined") return undefined;
let data = [];
let startAngle = 0;
const padding = 3; // padding between arcs
const max = 360 - padding * (Object.keys(audits).length - 1); // _id is a child of audits
Object.keys(audits).forEach((key) => {
if (audits[key].score) {
let value = audits[key].score * weights[key];
let endAngle = startAngle + (weights[key] * max) / 100;
let theme = getColors(audits[key].score * 100);
data.push({
id: key,
data: [
{
value: value,
color: theme.stroke,
label: key.toUpperCase(),
},
{
value: weights[key] - value,
color: theme.strokeBg,
label: "",
},
],
arcLabel: (item) => `${item.label}`,
arcLabelRadius: 95,
startAngle: startAngle,
endAngle: endAngle,
innerRadius: 73,
outerRadius: 80,
cornerRadius: 2,
highlightScope: { faded: "global", highlighted: "series" },
faded: {
innerRadius: 73,
outerRadius: 80,
additionalRadius: -20,
arcLabelRadius: 5,
},
cx: pieSize.width / 2,
});
performance += Math.floor(value);
startAngle = endAngle + padding;
}
});
return data;
};
const pieSize = { width: 230, height: 230 };
const pieData = getPieData(audits);
const colorMap = getColors(performance);
return (
<Box
onMouseLeave={() => setExpand(false)}
sx={{
display: "flex",
justifyContent: "center",
alignItems: "center",
width: "100%",
}}
>
{expand ? (
<MuiPieChart
series={[
{
data: [
{
value: 100,
color: colorMap.bg,
},
],
outerRadius: 77,
cx: pieSize.width / 2,
},
...pieData,
]}
width={pieSize.width}
height={pieSize.height}
margin={{ left: 0, top: 0, right: 0, bottom: 0 }}
onHighlightChange={setHighLightedItem}
slotProps={{
legend: { hidden: true },
}}
tooltip={{ trigger: "none" }}
sx={{
"&:has(.MuiPieArcLabel-faded) .pie-label": {
fill: "rgba(0,0,0,0) !important",
},
}}
>
<PieCenterLabel
value={performance}
color={colorMap.text}
setExpand={setExpand}
/>
{pieData?.map((pie) => (
<PieValueLabel
key={pie.id}
value={Math.round(pie.data[0].value * 10) / 10}
startAngle={pie.startAngle}
endAngle={pie.endAngle}
color={pie.data[0].color}
highlighted={highlightedItem?.seriesId === pie.id}
/>
))}
</MuiPieChart>
) : (
<MuiPieChart
series={[
{
data: [
{
value: 100,
color: colorMap.bg,
},
],
outerRadius: 77,
cx: pieSize.width / 2,
},
{
data: [
{
value: performance,
color: colorMap.stroke,
},
],
innerRadius: 73,
outerRadius: 80,
paddingAngle: 5,
cornerRadius: 2,
startAngle: 0,
endAngle: (performance / 100) * 360,
cx: pieSize.width / 2,
},
]}
width={pieSize.width}
height={pieSize.height}
margin={{ left: 0, top: 0, right: 0, bottom: 0 }}
tooltip={{ trigger: "none" }}
>
<PieCenterLabel
value={performance}
color={colorMap.text}
setExpand={setExpand}
/>
</MuiPieChart>
)}
</Box>
);
};
PieChart.propTypes = {
audits: PropTypes.object,
};
export default PieChart;
@@ -1,101 +0,0 @@
import { Stack, Box, Typography } from "@mui/material";
import { useTheme } from "@emotion/react";
import LegendBox from "@/Components/v1/Charts/LegendBox/index.jsx";
import Icon from "@/Components/v1/Icon";
import PropTypes from "prop-types";
const PieChartLegend = ({ audits }) => {
const theme = useTheme();
return (
<LegendBox
icon={
<Icon
name="Gauge"
size={20}
/>
}
header="Performance metrics"
sx={{ flex: 1 }}
>
{typeof audits !== "undefined" &&
Object.keys(audits).map((key) => {
if (key === "_id") return;
let audit = audits[key];
let score = audit.score * 100;
let bg =
score >= 90
? theme.palette.success.main
: score >= 50
? theme.palette.warning.main
: score >= 0
? theme.palette.error.main
: theme.palette.tertiary.main;
// Find the position where the number ends and the unit begins
const match = audit?.displayValue?.match(/(\d+\.?\d*)\s*([a-zA-Z]+)/);
let value;
let unit;
if (match) {
value = match[1];
match[2] === "s" ? (unit = "seconds") : (unit = match[2]);
} else {
value = audit.displayValue;
}
return (
<Stack
flex={1}
key={`${key}-box`}
justifyContent="space-between"
direction="row"
gap={theme.spacing(4)}
p={theme.spacing(3)}
border={1}
borderColor={theme.palette.primary.lowContrast}
borderRadius={4}
>
<Box>
<Typography
fontSize={12}
fontWeight={500}
lineHeight={1}
mb={1}
textTransform="uppercase"
>
{audit.title}
</Typography>
<Typography
component="span"
fontSize={14}
fontWeight={500}
color={theme.palette.primary.contrastText}
>
{value}
<Typography
component="span"
variant="body2"
ml={2}
>
{unit}
</Typography>
</Typography>
</Box>
<Box
width={4}
backgroundColor={bg}
borderRadius={4}
/>
</Stack>
);
})}
</LegendBox>
);
};
PieChartLegend.propTypes = {
audits: PropTypes.object,
};
export default PieChartLegend;
@@ -1,60 +0,0 @@
import ChartBox from "@/Components/v1/Charts/ChartBox/index.jsx";
import AreaChart from "../Charts/AreaChart.jsx";
import AreaChartLegend from "../Charts/AreaChartLegend.jsx";
import SkeletonLayout from "./skeleton.jsx";
import Icon from "@/Components/v1/Icon";
import { Stack } from "@mui/material";
import PropTypes from "prop-types";
import { useTheme } from "@emotion/react";
const PageSpeedAreaChart = ({ shouldRender, monitor, metrics, handleMetrics }) => {
const theme = useTheme();
if (!shouldRender) {
return <SkeletonLayout />;
}
const data = monitor?.checks ? [...monitor.checks].reverse() : [];
return (
<Stack
direction="row"
gap={theme.spacing(10)}
>
<ChartBox
justifyContent="flex-start"
icon={
<Icon
name="TrendingUp"
size={20}
/>
}
header="Score history"
height="100%"
borderRadiusRight={16}
Legend={
<AreaChartLegend
metrics={metrics}
handleMetrics={handleMetrics}
/>
}
>
<AreaChart
data={data}
monitor={monitor}
metrics={metrics}
/>
</ChartBox>
</Stack>
);
};
PageSpeedAreaChart.propTypes = {
shouldRender: PropTypes.bool,
monitor: PropTypes.object,
metrics: PropTypes.object,
handleMetrics: PropTypes.func,
};
export default PageSpeedAreaChart;
@@ -1,17 +0,0 @@
import { Box, Skeleton } from "@mui/material";
const SkeletonLayout = () => {
return (
<Box
height={"100%"}
width={"100%"}
>
<Skeleton
height={"100%"}
width={"100%"}
/>
</Box>
);
};
export default SkeletonLayout;
@@ -1,49 +0,0 @@
import StatusBoxes from "@/Components/v1/StatusBoxes/index.jsx";
import StatBox from "@/Components/v1/StatBox/index.jsx";
import { Typography } from "@mui/material";
import { getHumanReadableDuration } from "../../../../../Utils/timeUtilsLegacy.js";
import { useTranslation } from "react-i18next";
const PageSpeedStatusBoxes = ({ shouldRender, monitorStats }) => {
const lastChecked = monitorStats?.lastCheckTimestamp
? Date.now() - monitorStats.lastCheckTimestamp
: 0;
// Determine time since last failure
const timeOfLastFailure = monitorStats?.timeOfLastFailure;
const timeSinceLastFailure =
timeOfLastFailure > 0
? Date.now() - timeOfLastFailure
: Date.now() - new Date(monitorStats?.createdAt);
const streakTime = getHumanReadableDuration(timeSinceLastFailure);
const time = getHumanReadableDuration(lastChecked);
const { t } = useTranslation();
return (
<StatusBoxes shouldRender={shouldRender}>
<StatBox
heading="checks since"
subHeading={
<>
{streakTime}
<Typography component="span">{t("ago")}</Typography>
</>
}
/>
<StatBox
heading="last check"
subHeading={
<>
{time}
<Typography component="span">{t("ago")}</Typography>
</>
}
/>
</StatusBoxes>
);
};
export default PageSpeedStatusBoxes;
@@ -1,61 +0,0 @@
import ChartBox from "@/Components/v1/Charts/ChartBox/index.jsx";
import Icon from "@/Components/v1/Icon";
import PieChart from "../Charts/PieChart.jsx";
import { Typography } from "@mui/material";
import { useTheme } from "@emotion/react";
import PieChartLegend from "../Charts/PieChartLegend.jsx";
import SkeletonLayout from "./skeleton.jsx";
import { useTranslation } from "react-i18next";
const PerformanceReport = ({ shouldRender, audits }) => {
const theme = useTheme();
const { t } = useTranslation();
if (!shouldRender) {
return <SkeletonLayout />;
}
return (
<ChartBox
icon={
<Icon
name="Layers"
size={20}
/>
}
header="Performance report"
Legend={<PieChartLegend audits={audits} />}
borderRadiusRight={16}
>
<PieChart audits={audits} />
<Typography
variant="body1"
mt="auto"
sx={{
textAlign: "center",
}}
>
{t("pageSpeedDetailsPerformanceReport")}{" "}
<Typography
component="span"
fontSize="inherit"
sx={{
color: theme.palette.primary.contrastTextTertiary,
fontWeight: 500,
textDecoration: "underline",
textUnderlineOffset: 2,
transition: "all 200ms",
cursor: "pointer",
"&:hover": {
textUnderlineOffset: 4,
},
}}
>
{t("pageSpeedDetailsPerformanceReportCalculator")}
</Typography>
</Typography>
</ChartBox>
);
};
export default PerformanceReport;
@@ -1,17 +0,0 @@
import { Box, Skeleton } from "@mui/material";
const SkeletonLayout = () => {
return (
<Box
height={"100%"}
width={"100%"}
>
<Skeleton
height={"100%"}
width={"100%"}
/>
</Box>
);
};
export default SkeletonLayout;
-120
View File
@@ -1,120 +0,0 @@
// Components
import { Stack, Typography } from "@mui/material";
import Breadcrumbs from "@/Components/v1/Breadcrumbs/index.jsx";
import MonitorTimeFrameHeader from "@/Components/v1/MonitorTimeFrameHeader/index.jsx";
import PageSpeedStatusBoxes from "./Components/PageSpeedStatusBoxes/index.jsx";
import MonitorDetailsControlHeader from "@/Components/v1/MonitorDetailsControlHeader/index.jsx";
import PageSpeedAreaChart from "./Components/PageSpeedAreaChart/index.jsx";
import PerformanceReport from "./Components/PerformanceReport/index.jsx";
import GenericFallback from "@/Components/v1/GenericFallback/index.jsx";
// Utils
import { useTheme } from "@emotion/react";
import { useIsAdmin } from "@/Hooks/useIsAdmin.js";
import { useParams } from "react-router-dom";
import { useFetchPageSpeedMonitorById } from "../../../Hooks/monitorHooks.js";
import { useState } from "react";
import { useTranslation } from "react-i18next";
// Constants
const BREADCRUMBS = [
{ name: "pagespeed", path: "/pagespeed" },
{ name: "details", path: `` },
];
const PageSpeedDetails = () => {
const theme = useTheme();
const { t } = useTranslation();
const isAdmin = useIsAdmin();
const { monitorId } = useParams();
// Local state
const [metrics, setMetrics] = useState({
accessibility: true,
bestPractices: true,
performance: true,
seo: true,
});
const [trigger, setTrigger] = useState(false);
// Network
const [monitor, monitorStats, isLoading, networkError] = useFetchPageSpeedMonitorById({
monitorId,
dateRange: "day",
updateTrigger: trigger,
});
// Handlers
const handleMetrics = (id) => {
setMetrics((prev) => ({ ...prev, [id]: !prev[id] }));
};
const triggerUpdate = () => {
setTrigger(!trigger);
};
if (networkError === true) {
return (
<GenericFallback>
<Typography
variant="h1"
marginY={theme.spacing(4)}
color={theme.palette.primary.contrastTextTertiary}
>
{t("common.toasts.networkError")}
</Typography>
<Typography>{t("common.toasts.checkConnection")}</Typography>
</GenericFallback>
);
}
// Empty view, displayed when loading is complete and there are no checks
if (!isLoading && monitor?.checks?.length === 0) {
return (
<Stack gap={theme.spacing(10)}>
<Breadcrumbs list={BREADCRUMBS} />
<MonitorDetailsControlHeader
path={"pagespeed"}
isLoading={isLoading}
isAdmin={isAdmin}
monitor={monitor}
triggerUpdate={triggerUpdate}
/>
<GenericFallback>
<Typography>{t("distributedUptimeDetailsNoMonitorHistory")}</Typography>
</GenericFallback>
</Stack>
);
}
return (
<Stack gap={theme.spacing(10)}>
<Breadcrumbs list={BREADCRUMBS} />
<MonitorDetailsControlHeader
path={"pagespeed"}
isLoading={isLoading}
isAdmin={isAdmin}
monitor={monitor}
triggerUpdate={triggerUpdate}
/>
<PageSpeedStatusBoxes
shouldRender={!isLoading}
monitorStats={monitorStats}
/>
<MonitorTimeFrameHeader
shouldRender={!isLoading}
dateRange={"day"}
hasDateRange={false}
/>
<PageSpeedAreaChart
shouldRender={!isLoading}
monitor={monitor}
metrics={metrics}
handleMetrics={handleMetrics}
/>
<PerformanceReport
shouldRender={!isLoading}
audits={monitor?.checks[0]?.audits}
/>
</Stack>
);
};
export default PageSpeedDetails;
@@ -1,331 +0,0 @@
import PropTypes from "prop-types";
import Icon from "@/Components/v1/Icon";
import { StatusLabel } from "@/Components/v1/Label/index.jsx";
import { Box, Grid, Stack, Typography } from "@mui/material";
import { useNavigate } from "react-router-dom";
import { useTheme } from "@emotion/react";
import { Area, AreaChart, CartesianGrid, ResponsiveContainer, Tooltip } from "recharts";
import { useSelector } from "react-redux";
import { formatDateWithTz, formatDurationSplit } from "@/Utils/timeUtilsLegacy.js";
import { useMonitorUtils } from "@/Hooks/useMonitorUtils.js";
import { useState } from "react";
import { useTranslation } from "react-i18next";
import IconBox from "@/Components/v1/IconBox/index.jsx";
/**
* CustomToolTip displays a tooltip with formatted date and score information.
* @param {Object} props
* @param {Array} props.payload - Data to display in the tooltip
* @returns {JSX.Element} The rendered tooltip component
*/
const CustomToolTip = ({ payload }) => {
const theme = useTheme();
const uiTimezone = useSelector((state) => state.ui.timezone);
return (
<Box
sx={{
backgroundColor: theme.palette.primary.main,
border: 1,
borderColor: theme.palette.primary.lowContrast,
borderRadius: theme.shape.borderRadius,
py: theme.spacing(2),
px: theme.spacing(4),
}}
>
<Typography
sx={{
color: theme.palette.primary.contrastTextTertiary,
fontSize: 12,
fontWeight: 500,
}}
>
{formatDateWithTz(
payload[0]?.payload.createdAt,
"ddd, MMMM D, YYYY, h:mm A",
uiTimezone
)}
</Typography>
<Stack
direction="row"
alignItems="center"
gap={theme.spacing(3)}
mt={theme.spacing(1)}
sx={{
"& span": {
color: theme.palette.primary.contrastTextTertiary,
fontSize: 11,
fontWeight: 500,
},
}}
>
<Box
width={theme.spacing(4)}
height={theme.spacing(4)}
backgroundColor={payload[0]?.color}
sx={{ borderRadius: "50%" }}
/>
<Typography
component="span"
textTransform="capitalize"
sx={{ opacity: 0.8 }}
>
{payload[0]?.name}
</Typography>{" "}
<Typography component="span">{payload[0]?.payload.score}</Typography>
</Stack>
</Box>
);
};
CustomToolTip.propTypes = {
payload: PropTypes.array,
};
/* TODO separate utils in folder*/
/**
* Processes the raw data to include a score for each entry.
* @param {Array<Object>} data - The raw data array.
* @returns {Array<Object>} - The formatted data array with scores.
*/
const processData = (data) => {
if (data.length === 0) return [];
let formattedData = [];
const calculateScore = (entry) => {
return (
(entry.accessibility + entry.bestPractices + entry.performance + entry.seo) / 4
);
};
data.forEach((entry) => {
entry = { ...entry, score: calculateScore(entry) };
formattedData.push(entry);
});
return formattedData;
};
/* TODO separate component*/
/**
* Renders an area chart displaying page speed scores.
* @param {Object} props
* @param {Array<Object>} props.data - The raw data to be displayed in the chart.
* @param {string} props.status - The status of the page speed which determines the chart's color scheme.
* @returns {JSX.Element} - The rendered area chart.
*/
const PagespeedAreaChart = ({ data, status }) => {
const theme = useTheme();
const [isHovered, setIsHovered] = useState(false);
const { statusToTheme } = useMonitorUtils();
const themeColor = statusToTheme[status];
const formattedData = processData(data);
return (
<ResponsiveContainer
width="100%"
minWidth={25}
height={85}
>
<AreaChart
width="100%"
height="100%"
data={formattedData}
margin={{ top: 10, bottom: -5 }}
style={{ cursor: "pointer" }}
onMouseMove={() => setIsHovered(true)}
onMouseLeave={() => setIsHovered(false)}
>
<CartesianGrid
stroke={theme.palette.primary.lowContrast}
strokeWidth={1}
strokeOpacity={1}
fill="transparent"
vertical={false}
/>
<Tooltip
cursor={{ stroke: theme.palette.primary.lowContrast }}
content={<CustomToolTip />}
/>
<defs>
<linearGradient
id={`pagespeed-chart-${status}`}
x1="0"
y1="0"
x2="0"
y2="1"
>
<stop
offset="0%"
stopColor={theme.palette[themeColor].lowContrast}
stopOpacity={0.8}
/>
<stop
offset="100%"
stopColor={theme.palette[themeColor].main}
stopOpacity={0}
/>
</linearGradient>
</defs>
<Area
dataKey="score"
stroke={theme.palette[themeColor].lowContrast}
strokeWidth={isHovered ? 2.5 : 1.5}
fill={`url(#pagespeed-chart-${status})`}
activeDot={{
stroke: theme.palette[themeColor].main,
fill: theme.palette[themeColor].lowContrast,
r: 4.5,
}}
/>
</AreaChart>
</ResponsiveContainer>
);
};
PagespeedAreaChart.propTypes = {
data: PropTypes.arrayOf(
PropTypes.shape({
accessibility: PropTypes.number.isRequired,
bestPractices: PropTypes.number.isRequired,
performance: PropTypes.number.isRequired,
seo: PropTypes.number.isRequired,
})
).isRequired,
status: PropTypes.string.isRequired,
};
/* TODO separate component */
/**
* Renders a card displaying monitor details and an area chart.
* @param {Object} props
* @param {Object} props.monitor - The monitor data to be displayed in the card.
* @returns {JSX.Element} - The rendered card.
*/
const Card = ({ monitor }) => {
const { determineState, pagespeedStatusMsg } = useMonitorUtils();
const theme = useTheme();
const { t } = useTranslation();
const navigate = useNavigate();
const monitorState = determineState(monitor);
return (
<Grid
item
lg={6}
flexGrow={1}
>
<Box
position="relative"
p={theme.spacing(8)}
onClick={() => navigate(`/pagespeed/${monitor.id}`)}
border={1}
borderColor={theme.palette.primary.lowContrast}
borderRadius={theme.shape.borderRadius}
backgroundColor={theme.palette.primary.main}
sx={{
display: "grid",
gridTemplateColumns: "34px 2fr 1fr",
columnGap: theme.spacing(5),
gridTemplateRows: "34px 1fr 3fr",
cursor: "pointer",
"&:hover": {
backgroundColor: theme.palette.tertiary.main,
},
"& path": {
transition: "stroke-width 400ms ease",
},
}}
>
<IconBox>
<Icon
name="Gauge"
size={20}
/>
</IconBox>
<Typography
component="h2"
variant="monitorUrl"
fontWeight={500}
alignSelf="center"
>
{monitor.name}
</Typography>
<StatusLabel
status={monitorState}
text={pagespeedStatusMsg[monitorState] || "Pending..."}
customStyles={{
width: "max-content",
textTransform: "capitalize",
alignSelf: "flex-start",
justifySelf: "flex-end",
}}
/>
<Typography
variant="body2"
mt={theme.spacing(-2)}
sx={{ gridColumnStart: 2 }}
>
{monitor.url}
</Typography>
<Box
mx={theme.spacing(-8)}
mt={theme.spacing(4)}
mb={theme.spacing(-8)}
sx={{ gridColumnStart: 1, gridColumnEnd: 4 }}
>
<PagespeedAreaChart
data={monitor?.checks?.slice().reverse()}
status={monitorState}
/>
</Box>
<Box
position="absolute"
bottom={0}
py={theme.spacing(1)}
px={theme.spacing(4)}
borderTop={1}
borderRight={1}
borderColor={theme.palette.primary.lowContrast}
backgroundColor={theme.palette.tertiary.main}
sx={{
pointerEvents: "none",
userSelect: "none",
borderTopRightRadius: 8,
borderBottomLeftRadius: 4,
}}
>
<Typography
fontSize={11}
color={theme.palette.primary.contrastTextSecondary}
>
{t("checkingEvery")}{" "}
{(() => {
const { time, format } = formatDurationSplit(monitor?.interval);
return (
<>
<Typography
component="span"
fontSize={12}
color={theme.palette.primary.contrastText}
>
{time}{" "}
</Typography>
{format}
</>
);
})()}
</Typography>
</Box>
</Box>
</Grid>
);
};
Card.propTypes = {
monitor: PropTypes.object.isRequired,
};
export default Card;
@@ -1,22 +0,0 @@
import { Grid } from "@mui/material";
import Card from "../Card/index.jsx";
const MonitorGrid = ({ monitors, shouldRender = true }) => {
if (!shouldRender) return null;
return (
<Grid
container
spacing={12}
>
{monitors?.map((monitor) => (
<Card
monitor={monitor}
key={monitor._id || monitor.id}
/>
))}
</Grid>
);
};
export default MonitorGrid;
@@ -1,86 +0,0 @@
// Components
import { useState } from "react";
import Breadcrumbs from "@/Components/v1/Breadcrumbs/index.jsx";
import { Stack } from "@mui/material";
import CreateMonitorHeader from "@/Components/v1/MonitorCreateHeader/index.jsx";
import MonitorCountHeader from "@/Components/v1/MonitorCountHeader/index.jsx";
import MonitorGrid from "./Components/MonitorGrid/index.jsx";
import PageStateWrapper from "@/Components/v1/PageStateWrapper/index.jsx";
import FallbackPageSpeedWarning from "@/Components/v1/Fallback/FallbackPageSpeedWarning.jsx";
// Utils
import { useTheme } from "@emotion/react";
import { useIsAdmin } from "@/Hooks/useIsAdmin.js";
import { useFetchMonitorsWithChecks } from "@/Hooks/monitorHooks.js";
import { useFetchSettings } from "@/Hooks/settingsHooks.js";
// Constants
const BREADCRUMBS = [{ name: `pagespeed`, path: "/pagespeed" }];
const TYPES = ["pagespeed"];
const PageSpeed = () => {
const theme = useTheme();
const isAdmin = useIsAdmin();
const [
summary,
monitorsWithChecks,
monitorsWithChecksCount,
monitorsWithChecksIsLoading,
monitorsWithChecksNetworkError,
] = useFetchMonitorsWithChecks({
types: TYPES,
limit: 10,
page: null,
rowsPerPage: null,
filter: null,
field: null,
order: null,
monitorUpdateTrigger: null,
});
const [settingsData, setSettingsData] = useState(undefined);
const [isSettingsLoading, settingsError] = useFetchSettings({
setSettingsData,
setIsApiKeySet: () => {},
setIsEmailPasswordSet: () => {},
});
return (
<>
<PageStateWrapper
networkError={monitorsWithChecksNetworkError}
isLoading={monitorsWithChecksIsLoading}
items={monitorsWithChecks}
type="pageSpeed"
fallbackLink="/pagespeed/create"
fallbackChildren={
isAdmin &&
settingsData &&
!settingsData.pagespeedApiKey && (
<FallbackPageSpeedWarning settingsData={settingsData} />
)
}
>
<Stack gap={theme.spacing(10)}>
<Breadcrumbs list={BREADCRUMBS} />
<CreateMonitorHeader
isAdmin={isAdmin}
isLoading={monitorsWithChecksIsLoading}
path="/pagespeed/create"
/>
<MonitorCountHeader
isLoading={monitorsWithChecksIsLoading}
monitorCount={monitorsWithChecksCount}
sx={{ mb: theme.spacing(8) }}
/>
<MonitorGrid
size={6}
shouldRender={!monitorsWithChecksIsLoading}
monitors={monitorsWithChecks}
/>
</Stack>
</PageStateWrapper>
</>
);
};
export default PageSpeed;
@@ -1,44 +0,0 @@
import { logger } from "../../../../Utils/Logger.js";
import { useEffect, useState } from "react";
import { networkService } from "../../../../main.jsx";
import { formatDateWithTz } from "../../../../Utils/timeUtilsLegacy.js";
const useCertificateFetch = ({
monitor,
monitorId,
certificateDateFormat,
uiTimezone,
}) => {
const [certificateExpiry, setCertificateExpiry] = useState(undefined);
const [isLoading, setIsLoading] = useState(false);
useEffect(() => {
const fetchCertificate = async () => {
if (monitor?.type !== "http") {
return;
}
try {
setIsLoading(true);
const res = await networkService.getCertificateExpiry({
monitorId: monitorId,
});
if (res?.data?.data?.certificateDate) {
const date = res.data.data.certificateDate;
setCertificateExpiry(
formatDateWithTz(date, certificateDateFormat, uiTimezone) ?? "N/A"
);
}
} catch (error) {
setCertificateExpiry("N/A");
logger.error(error);
} finally {
setIsLoading(false);
}
};
fetchCertificate();
}, [monitorId, certificateDateFormat, uiTimezone, monitor]);
return [certificateExpiry, isLoading];
};
export default useCertificateFetch;
+1 -1
View File
@@ -8,6 +8,7 @@ import {
} from "@/Components/v2/monitors";
import { TrendingUp, AlertTriangle } from "lucide-react";
import { ChecksTable } from "@/Pages/Uptime/Details/Components/ChecksTable";
import { MonitorStatBoxes } from "@/Components/v2/monitors";
import { useTheme } from "@mui/material/styles";
import { useIsAdmin } from "@/Hooks/useIsAdmin";
@@ -18,7 +19,6 @@ import { useGet } from "@/Hooks/UseApi";
import type { MonitorDetailsResponse } from "@/Types/Monitor";
import type { ChecksResponse } from "@/Types/Check";
import type { RootState } from "@/Types/state";
import { MonitorStatBoxes } from "@/Components/v2/monitors";
import { formatDateWithTz } from "@/Utils/timeUtilsLegacy";
import { t } from "i18next";
-190
View File
@@ -1,190 +0,0 @@
// Components
import Breadcrumbs from "@/Components/v1/Breadcrumbs/index.jsx";
import MonitorDetailsControlHeader from "@/Components/v1/MonitorDetailsControlHeader/index.jsx";
import MonitorTimeFrameHeader from "@/Components/v1/MonitorTimeFrameHeader/index.jsx";
import ChartBoxes from "./Components/ChartBoxes/index.jsx";
import ResponseTimeChart from "./Components/Charts/ResponseTimeChart.jsx";
import ResponseTable from "./Components/ResponseTable/index.jsx";
import UptimeStatusBoxes from "./Components/UptimeStatusBoxes/index.jsx";
import GenericFallback from "@/Components/v1/GenericFallback/index.jsx";
import Stack from "@mui/material/Stack";
import Typography from "@mui/material/Typography";
// Utils
import { useState } from "react";
import { useParams } from "react-router-dom";
import { useSelector } from "react-redux";
import { useTheme } from "@emotion/react";
import { useIsAdmin } from "@/Hooks/useIsAdmin.js";
import { useFetchUptimeMonitorById } from "../../../Hooks/monitorHooks.js";
import useCertificateFetch from "./Hooks/useCertificateFetch.jsx";
import { useFetchChecksByMonitor } from "../../../Hooks/checkHooks.js";
import { useTranslation } from "react-i18next";
// Constants
const BREADCRUMBS = [
{ name: "uptime", path: "/uptime" },
{ name: "details", path: "" },
// { name: "details", path: `/uptime/${monitorId}` }, Is this needed? We can't click on this anywy
];
const certificateDateFormat = "MMM D, YYYY h A";
const UptimeDetails = () => {
// Redux state
const uiTimezone = useSelector((state) => state.ui.timezone);
// Local state
const [dateRange, setDateRange] = useState("recent");
const [page, setPage] = useState(0);
const [rowsPerPage, setRowsPerPage] = useState(5);
const [trigger, setTrigger] = useState(false);
// Utils
const dateFormat =
dateRange === "day" || dateRange === "recent" ? "MMM D, h A" : "MMM D";
const { monitorId } = useParams();
const theme = useTheme();
const isAdmin = useIsAdmin();
const { t } = useTranslation();
const [monitorData, monitorStats, monitorIsLoading, monitorNetworkError] =
useFetchUptimeMonitorById({
monitorId,
dateRange,
trigger,
});
const monitor = monitorData?.monitor;
const [certificateExpiry, certificateIsLoading] = useCertificateFetch({
monitor,
monitorId,
certificateDateFormat,
uiTimezone,
});
const monitorType = monitor?.type;
const [checks, checksCount, checksAreLoading, checksNetworkError] =
useFetchChecksByMonitor({
monitorId,
type: monitorType,
sortOrder: "desc",
limit: null,
dateRange,
filter: null,
page,
rowsPerPage,
});
// Handlers
const triggerUpdate = () => {
setTrigger(!trigger);
};
const handlePageChange = (_, newPage) => {
setPage(newPage);
};
const handleChangeRowsPerPage = (event) => {
setRowsPerPage(event.target.value);
};
if (monitorNetworkError || checksNetworkError) {
return (
<GenericFallback>
<Typography
variant="h1"
marginY={theme.spacing(4)}
color={theme.palette.primary.contrastTextTertiary}
>
{t("common.toasts.networkError")}
</Typography>
<Typography>{t("common.toasts.checkConnection")}</Typography>
</GenericFallback>
);
}
// Empty view, displayed when loading is complete and there are no checks
if (!monitorIsLoading && !checksAreLoading && checksCount === 0) {
return (
<Stack gap={theme.spacing(10)}>
<Breadcrumbs list={BREADCRUMBS} />
<MonitorDetailsControlHeader
path={"uptime"}
isAdmin={isAdmin}
isLoading={monitorIsLoading}
monitor={monitor}
triggerUpdate={triggerUpdate}
/>
<UptimeStatusBoxes
isLoading={monitorIsLoading}
monitor={monitor}
monitorStats={monitorStats}
certificateExpiry={certificateExpiry}
/>
<MonitorTimeFrameHeader
isLoading={monitorIsLoading}
hasDateRange={true}
dateRange={dateRange}
setDateRange={setDateRange}
/>
<GenericFallback>
<Typography>{t("distributedUptimeDetailsNoMonitorHistory")}</Typography>
</GenericFallback>
</Stack>
);
}
return (
<Stack gap={theme.spacing(10)}>
<Breadcrumbs list={BREADCRUMBS} />
<MonitorDetailsControlHeader
path={"uptime"}
isAdmin={isAdmin}
isLoading={monitorIsLoading}
monitor={monitor}
triggerUpdate={triggerUpdate}
/>
<UptimeStatusBoxes
isLoading={monitorIsLoading}
monitor={monitor}
monitorStats={monitorStats}
certificateExpiry={certificateExpiry}
/>
<MonitorTimeFrameHeader
isLoading={monitorIsLoading}
hasDateRange={true}
dateRange={dateRange}
setDateRange={setDateRange}
/>
<ChartBoxes
isLoading={monitorIsLoading}
monitorData={monitorData}
uiTimezone={uiTimezone}
dateRange={dateRange}
dateFormat={dateFormat}
/>
<ResponseTimeChart
isLoading={monitorIsLoading}
groupedChecks={monitorData?.groupedChecks}
dateRange={dateRange}
/>
<ResponseTable
isLoading={checksAreLoading}
checks={checks}
uiTimezone={uiTimezone}
page={page}
setPage={handlePageChange}
rowsPerPage={rowsPerPage}
setRowsPerPage={handleChangeRowsPerPage}
checksCount={checksCount}
/>
</Stack>
);
};
export default UptimeDetails;
@@ -1,162 +0,0 @@
import { useTheme } from "@emotion/react";
import PropTypes from "prop-types";
import FilterHeader from "@/Components/v1/FilterHeader/index.jsx";
import { useMemo } from "react";
import { Box, Button } from "@mui/material";
import Icon from "@/Components/v1/Icon";
import { useTranslation } from "react-i18next";
/**
* Filter Component
*
* A high-level component that provides filtering options for type, status, and state.
* It allows users to select multiple options for each filter and reset the filters.
*
* @component
* @param {Object} props - The component props.
* @param {string[]} props.selectedTypes - An array of selected type values.
* @param {function} props.setSelectedTypes - A function to set the selected type values.
* @param {string[]} props.selectedStatus - An array of selected status values.
* @param {function} props.setSelectedStatus - A function to set the selected status values.
* @param {string[]} props.selectedState - An array of selected state values.
* @param {function} props.setSelectedState - A function to set the selected state values.
* @param {function} props.setToFilterStatus - A function to set the filter status based on selected status values.
* @param {function} props.setToFilterActive - A function to set the filter active state based on selected state values.
* @param {function} props.handleReset - A function to reset all filters.
*
* @returns {JSX.Element} The rendered Filter component.
*/
const getTypeOptions = () => [
{ value: "http", label: "HTTP(S)" },
{ value: "ping", label: "Ping" },
{ value: "docker", label: "Docker" },
{ value: "port", label: "Port" },
{ value: "game", label: "Game" },
];
// These functions were moved inline to ensure translations are applied correctly
const Filter = ({
selectedTypes,
setSelectedTypes,
selectedStatus,
setSelectedStatus,
selectedState,
setSelectedState,
setToFilterStatus,
setToFilterActive,
handleReset,
}) => {
const theme = useTheme();
const { t } = useTranslation();
const typeOptions = getTypeOptions();
// Create status options with translations
const statusOptions = [
{ value: "Up", label: t("monitorStatus.up") },
{ value: "Down", label: t("monitorStatus.down") },
];
// Create state options with translations
const stateOptions = [
{ value: "Active", label: t("monitorState.active") },
{ value: "Paused", label: t("monitorState.paused") },
];
const handleTypeChange = (event) => {
const selectedValues = event.target.value;
setSelectedTypes(selectedValues.length > 0 ? selectedValues : undefined);
};
const handleStatusChange = (event) => {
const selectedValues = event.target.value;
setSelectedStatus(selectedValues.length > 0 ? selectedValues : undefined);
if (selectedValues.length === 0 || selectedValues.length === 2) {
setToFilterStatus(undefined);
} else {
setToFilterStatus(selectedValues[0] === "Up" ? "true" : "false");
}
};
const handleStateChange = (event) => {
const selectedValues = event.target.value;
setSelectedState(selectedValues);
if (selectedValues.length === 0 || selectedValues.length === 2) {
setToFilterActive(undefined);
} else {
setToFilterActive(selectedValues[0] === "Active" ? "true" : "false");
}
};
const isFilterActive = useMemo(() => {
return (
(selectedTypes?.length ?? 0) > 0 ||
(selectedState?.length ?? 0) > 0 ||
(selectedStatus?.length ?? 0) > 0
);
}, [selectedState, selectedTypes, selectedStatus]);
return (
<Box
sx={{
display: "flex",
flexDirection: "row",
alignItems: "center",
m: theme.spacing(2),
ml: theme.spacing(4),
gap: theme.spacing(2),
}}
>
<FilterHeader
header={t("type")}
options={typeOptions}
value={selectedTypes}
onChange={handleTypeChange}
/>
<FilterHeader
header={t("status")}
options={statusOptions}
value={selectedStatus}
onChange={handleStatusChange}
/>
<FilterHeader
header={t("state")}
options={stateOptions}
value={selectedState}
onChange={handleStateChange}
/>
<Button
color={theme.palette.primary.contrastText}
onClick={handleReset}
variant="contained"
endIcon={
<Icon
name="X"
size={18}
/>
}
sx={{
visibility: isFilterActive ? "visible" : "hidden",
}}
>
{t("reset")}
</Button>
</Box>
);
};
Filter.propTypes = {
selectedTypes: PropTypes.arrayOf(PropTypes.string),
setSelectedTypes: PropTypes.func.isRequired,
selectedStatus: PropTypes.arrayOf(PropTypes.string),
setSelectedStatus: PropTypes.func.isRequired,
selectedState: PropTypes.arrayOf(PropTypes.string),
setSelectedState: PropTypes.func.isRequired,
setToFilterStatus: PropTypes.func.isRequired,
setToFilterActive: PropTypes.func.isRequired,
handleReset: PropTypes.func.isRequired,
};
export default Filter;
@@ -1,46 +0,0 @@
import { CircularProgress, Box } from "@mui/material";
import { useTheme } from "@emotion/react";
import PropTypes from "prop-types";
const LoadingSpinner = ({ shouldRender }) => {
const theme = useTheme();
if (shouldRender === false) {
return;
}
return (
<>
<Box
width="100%"
height="100%"
position="absolute"
sx={{
backgroundColor: theme.palette.primary.main,
opacity: 0.8,
zIndex: 100,
}}
/>
<Box
height="100%"
position="absolute"
top="50%"
left="50%"
sx={{
transform: "translateX(-50%)",
zIndex: 101,
}}
>
<CircularProgress
sx={{
color: theme.palette.accent.main,
}}
/>
</Box>
</>
);
};
LoadingSpinner.propTypes = {
shouldRender: PropTypes.bool,
};
export default LoadingSpinner;
@@ -1,49 +0,0 @@
import { useState } from "react";
import Search from "@/Components/v1/Inputs/Search/index.jsx";
import { Box } from "@mui/material";
import useDebounce from "../../Hooks/useDebounce.jsx";
import { useEffect, useRef } from "react";
import PropTypes from "prop-types";
const SearchComponent = ({ monitors = [], onSearchChange, setIsSearching }) => {
const isFirstRender = useRef(true);
const [localSearch, setLocalSearch] = useState("");
const debouncedSearch = useDebounce(localSearch, 500);
useEffect(() => {
if (isFirstRender.current === true) {
isFirstRender.current = false;
return;
}
onSearchChange(debouncedSearch);
setIsSearching(false);
}, [debouncedSearch, onSearchChange, setIsSearching]);
const handleSearch = (value) => {
setLocalSearch(value);
setIsSearching(true);
};
return (
<Box
width="25%"
minWidth={150}
ml="auto"
mt={2}
>
<Search
options={monitors}
filteredBy="name"
inputValue={localSearch}
handleInputChange={handleSearch}
/>
</Box>
);
};
SearchComponent.propTypes = {
monitors: PropTypes.array,
onSearchChange: PropTypes.func,
setIsSearching: PropTypes.func,
};
export default SearchComponent;
@@ -1,61 +0,0 @@
import { Skeleton, Stack } from "@mui/material";
import { useTheme } from "@emotion/react";
/**
* Renders a skeleton layout.
*
* @returns {JSX.Element}
*/
const SkeletonLayout = () => {
const theme = useTheme();
return (
<>
<Stack
direction="row"
justifyContent="space-between"
alignItems="center"
>
<Skeleton
variant="rounded"
width="50%"
height={36}
/>
<Skeleton
variant="rounded"
width="15%"
height={36}
/>
</Stack>
<Stack
gap={theme.spacing(12)}
direction="row"
justifyContent="space-between"
>
<Skeleton
variant="rounded"
width="100%"
height={100}
/>
<Skeleton
variant="rounded"
width="100%"
height={100}
/>
<Skeleton
variant="rounded"
width="100%"
height={100}
/>
</Stack>
<Skeleton
variant="rounded"
width="100%"
height="100%"
flex={1}
/>
</>
);
};
export default SkeletonLayout;
@@ -1,42 +0,0 @@
import PropTypes from "prop-types";
import { Stack } from "@mui/material";
import StatusBox from "./statusBox.jsx";
import { useTheme } from "@emotion/react";
import { useTranslation } from "react-i18next";
import SkeletonLayout from "./skeleton.jsx";
const StatusBoxes = ({ shouldRender, monitorsSummary }) => {
const theme = useTheme();
const { t } = useTranslation();
if (!shouldRender) return <SkeletonLayout shouldRender={shouldRender} />;
return (
<Stack
gap={theme.spacing(8)}
direction="row"
justifyContent="space-between"
>
<StatusBox
title={t("monitorStatus.up")}
status="up"
value={monitorsSummary?.upMonitors ?? 0}
/>
<StatusBox
title={t("monitorStatus.down")}
status="down"
value={monitorsSummary?.downMonitors ?? 0}
/>
<StatusBox
title={t("monitorStatus.paused")}
status="paused"
value={monitorsSummary?.pausedMonitors ?? 0}
/>
</Stack>
);
};
StatusBoxes.propTypes = {
monitorsSummary: PropTypes.object,
shouldRender: PropTypes.bool,
};
export default StatusBoxes;
@@ -1,31 +0,0 @@
import { Skeleton, Stack } from "@mui/material";
import { useTheme } from "@emotion/react";
const SkeletonLayout = () => {
const theme = useTheme();
return (
<Stack
gap={theme.spacing(12)}
direction="row"
justifyContent="space-between"
>
<Skeleton
variant="rounded"
width="100%"
height={100}
/>
<Skeleton
variant="rounded"
width="100%"
height={100}
/>
<Skeleton
variant="rounded"
width="100%"
height={100}
/>
</Stack>
);
};
export default SkeletonLayout;
@@ -1,115 +0,0 @@
import PropTypes from "prop-types";
import { useTheme } from "@emotion/react";
import { Box, Stack, Typography } from "@mui/material";
import Icon from "@/Components/v1/Icon";
import Background from "@/assets/Images/background-grid.svg?react";
const StatusBox = ({ title, value, status }) => {
const theme = useTheme();
let sharedStyles = {
position: "absolute",
right: 8,
opacity: 0.5,
"& svg path": { stroke: theme.palette.primary.contrastTextTertiary },
};
let color;
let icon;
if (status === "up") {
color = theme.palette.success.lowContrast;
icon = (
<Box sx={{ ...sharedStyles, top: theme.spacing(4) }}>
<Icon
name="ArrowUpRight"
size={20}
/>
</Box>
);
} else if (status === "down") {
color = theme.palette.error.lowContrast;
icon = (
<Box sx={{ ...sharedStyles, transform: "rotate(180deg)", top: theme.spacing(2) }}>
<Icon
name="ArrowUpRight"
size={20}
/>
</Box>
);
} else if (status === "paused") {
color = theme.palette.warning.lowContrast;
icon = (
<Box sx={{ ...sharedStyles, top: theme.spacing(6), right: theme.spacing(6) }}>
<Icon
name="Clock"
size={20}
/>
</Box>
);
}
return (
<Box
position="relative"
flex={1}
border={1}
backgroundColor={theme.palette.primary.main}
borderColor={theme.palette.primary.lowContrast}
borderRadius={theme.shape.borderRadius}
p={theme.spacing(8)}
overflow="hidden"
>
<Box
position="absolute"
top="-10%"
left="5%"
>
<Background />
</Box>
<Stack direction="column">
<Stack
direction="row"
alignItems="center"
mb={theme.spacing(8)}
>
<Typography
variant={"h2"}
textTransform="uppercase"
color={theme.palette.primary.contrastTextTertiary}
>
{title}
</Typography>
{icon}
</Stack>
<Stack
direction="row"
alignItems="flex-start"
fontSize={theme.typography.h1.fontSize}
fontWeight={600}
color={color}
gap={theme.spacing(1)}
>
{value}
<Typography
fontSize={theme.typography.label.fontSize}
fontWeight={300}
color={theme.palette.primary.contrastTextSecondary}
sx={{
opacity: 0.3,
}}
>
#
</Typography>
</Stack>
</Stack>
</Box>
);
};
StatusBox.propTypes = {
title: PropTypes.string,
value: PropTypes.oneOfType([PropTypes.number, PropTypes.string]).isRequired,
status: PropTypes.string,
};
export default StatusBox;
@@ -1,241 +0,0 @@
// Components
import { Box, Stack } from "@mui/material";
import DataTable from "@/Components/v1/Table/index.jsx";
import Icon from "@/Components/v1/Icon";
import Host from "@/Components/v1/Host/index.jsx";
import { StatusLabel } from "@/Components/v1/Label/index.jsx";
import BarChart from "@/Components/v1/Charts/BarChart/index.jsx";
import ActionsMenu from "@/Components/v1/ActionsMenu/index.jsx";
import LoadingSpinner from "../LoadingSpinner/index.jsx";
import TableSkeleton from "@/Components/v1/Table/skeleton.jsx";
// Utils
import { useTheme } from "@emotion/react";
import { useMonitorUtils } from "../../../../../Hooks/useMonitorUtils.js";
import { useNavigate } from "react-router-dom";
import PropTypes from "prop-types";
import { useTranslation } from "react-i18next";
/**
* UptimeDataTable displays a table of uptime monitors with sorting, searching, and action capabilities
* @param {Object} props - Component props
* @param {boolean} props.isAdmin - Whether the current user has admin privileges
* @param {boolean} props.isLoading - Loading state of the table
* @param {Array<{
* id: string,
* url: string,
* title: string,
* percentage: number,
* percentageColor: string,
* monitor: {
* id: string,
* type: string,
* checks: Array
* }
* }>} props.monitors - Array of monitor objects to display
* @param {number} props.monitorCount - Total count of monitors
* @param {Object} props.sort - Current sort configuration
* @param {string} props.sort.field - Field to sort by
* @param {'asc'|'desc'} props.sort.order - Sort direction
* @param {Function} props.setSort - Callback to update sort configuration
* @param {string} props.search - Current search query
* @param {Function} props.setSearch - Callback to update search query
* @param {boolean} props.isSearching - Whether a search is in progress
* @param {Function} props.setIsLoading - Callback to update loading state
* @param {Function} props.triggerUpdate - Callback to trigger a data refresh
* @returns {JSX.Element} Rendered component
*/
const UptimeDataTable = ({
isAdmin,
isSearching,
filteredMonitors,
sort,
setSort,
triggerUpdate,
monitorsAreLoading,
}) => {
// Utils
const navigate = useNavigate();
const { determineState } = useMonitorUtils();
const theme = useTheme();
const { t } = useTranslation();
// Local state
// Handlers
const handleSort = (field) => {
let order = "";
if (sort?.field !== field) {
order = "desc";
} else {
order = sort?.order === "asc" ? "desc" : "asc";
}
setSort({ field, order });
};
const headers = [
{
id: "name",
content: (
<Stack
gap={theme.spacing(4)}
alignItems="center"
direction="row"
onClick={() => handleSort("name")}
>
{t("host")}
<Stack
justifyContent="center"
style={{
visibility: sort?.field === "name" ? "visible" : "hidden",
}}
>
{sort?.order === "asc" ? (
<Icon
name="ArrowUp"
size={18}
/>
) : (
<Icon
name="ArrowDown"
size={18}
/>
)}
</Stack>
</Stack>
),
render: (row) => (
<Host
key={row.id}
url={row.url}
title={row.name}
percentageColor={row.percentageColor}
percentage={row.percentage}
/>
),
},
{
id: "status",
content: (
<Stack
direction="row"
gap={theme.spacing(4)}
alignItems="center"
display={"inline-flex"}
onClick={() => handleSort("status")}
>
{" "}
{t("status")}
<Stack
justifyContent="center"
style={{
visibility: sort?.field === "status" ? "visible" : "hidden",
}}
>
{sort?.order === "asc" ? (
<Icon
name="ArrowUp"
size={18}
/>
) : (
<Icon
name="ArrowDown"
size={18}
/>
)}
</Stack>
</Stack>
),
render: (row) => {
const status = determineState(row.monitor);
return (
<StatusLabel
status={status}
text={status}
customStyles={{ textTransform: "capitalize" }}
/>
);
},
},
{
id: "responseTime",
content: t("responseTime"),
render: (row) => (
<Box
display="flex"
justifyContent="center"
>
<BarChart checks={row.monitor.checks.slice().reverse()} />
</Box>
),
},
{
id: "type",
content: t("type"),
render: (row) => (
<span style={{ textTransform: "uppercase" }}>
{row.monitor.type === "http" ? "HTTP(s)" : row.monitor.type}
</span>
),
},
{
id: "actions",
content: t("actions"),
render: (row) => (
<ActionsMenu
monitor={row.monitor}
isAdmin={isAdmin}
updateRowCallback={triggerUpdate}
pauseCallback={triggerUpdate}
/>
),
},
];
if (monitorsAreLoading) {
return <TableSkeleton />;
}
return (
<Box position="relative">
<LoadingSpinner shouldRender={isSearching} />
<DataTable
headers={headers}
data={filteredMonitors}
config={{
rowSX: {
cursor: "pointer",
"&:hover td": {
backgroundColor: theme.palette.tertiary.main,
transition: "background-color .3s ease",
},
},
onRowClick: (row) => {
navigate(`/uptime/${row.id}`);
},
emptyView: "No monitors found",
}}
/>
</Box>
);
};
UptimeDataTable.propTypes = {
isSearching: PropTypes.bool,
setSort: PropTypes.func,
setSearch: PropTypes.func,
triggerUpdate: PropTypes.func,
debouncedSearch: PropTypes.string,
onSearchChange: PropTypes.func,
isAdmin: PropTypes.bool,
monitors: PropTypes.array,
filteredMonitors: PropTypes.array,
monitorCount: PropTypes.number,
monitorsAreLoading: PropTypes.bool,
sort: PropTypes.shape({
field: PropTypes.string,
order: PropTypes.oneOf(["asc", "desc"]),
}),
};
export default UptimeDataTable;
@@ -1,18 +0,0 @@
import { useState, useEffect } from "react";
const useDebounce = (value, delay) => {
const [debouncedValue, setDebouncedValue] = useState(value);
useEffect(() => {
const handler = setTimeout(() => {
setDebouncedValue(value);
}, delay);
return () => {
clearTimeout(handler);
};
}, [value, delay]);
return debouncedValue;
};
export default useDebounce;
-225
View File
@@ -1,225 +0,0 @@
// Required Data
// 1. Monitor summary
// 2. List of monitors filtered by search term with 25 checks each
// 2a.List of monitors must have the total number of monitors that match.
// Components
import Breadcrumbs from "@/Components/v1/Breadcrumbs/index.jsx";
import Greeting from "../../../Utils/greeting.jsx";
import StatusBoxes from "./Components/StatusBoxes/index.jsx";
import UptimeDataTable from "./Components/UptimeDataTable/index.jsx";
import Pagination from "@/Components/v1/Table/TablePagination/index.jsx";
import CreateMonitorHeader from "@/Components/v1/MonitorCreateHeader/index.jsx";
import SearchComponent from "./Components/SearchComponent/index.jsx";
import Filter from "./Components/Filter/index.jsx";
import PageStateWrapper from "@/Components/v1/PageStateWrapper/index.jsx";
import MonitorCountHeader from "@/Components/v1/MonitorCountHeader/index.jsx";
// MUI Components
import { Stack, Box, Button } from "@mui/material";
// Utils
import { useState, useCallback, useEffect } from "react";
import { useIsAdmin } from "@/Hooks/useIsAdmin.js";
import { useTheme } from "@emotion/react";
import { useNavigate } from "react-router-dom";
import { useSelector, useDispatch } from "react-redux";
import { setRowsPerPage } from "../../../Features/UI/uiSlice.js";
import PropTypes from "prop-types";
import {
useFetchMonitorsWithChecks,
useFetchMonitorsByTeamId,
} from "@/Hooks/monitorHooks.js";
import { useTranslation } from "react-i18next";
const TYPES = ["http", "ping", "docker", "port", "game"];
const CreateMonitorButton = ({ shouldRender }) => {
// Utils
const navigate = useNavigate();
const { t } = useTranslation();
if (shouldRender === false) {
return;
}
return (
<Box alignSelf="flex-end">
<Button
variant="contained"
color="accent"
onClick={() => {
navigate("/uptime/create");
}}
>
{t("createNew")}
</Button>
</Box>
);
};
CreateMonitorButton.propTypes = {
shouldRender: PropTypes.bool,
};
const UptimeMonitors = () => {
// Redux state
const rowsPerPage = useSelector((state) => state.ui?.monitors?.rowsPerPage ?? 10);
// Local state
const [search, setSearch] = useState(undefined);
const [page, setPage] = useState(undefined);
const [sort, setSort] = useState(undefined);
const [isSearching, setIsSearching] = useState(false);
const [monitorUpdateTrigger, setMonitorUpdateTrigger] = useState(false);
const [selectedTypes, setSelectedTypes] = useState(undefined);
const [selectedState, setSelectedState] = useState(undefined);
const [selectedStatus, setSelectedStatus] = useState(undefined);
const [toFilterStatus, setToFilterStatus] = useState(undefined);
const [toFilterActive, setToFilterActive] = useState(undefined);
// Utils
const theme = useTheme();
const isAdmin = useIsAdmin();
const dispatch = useDispatch();
const { t } = useTranslation();
const BREADCRUMBS = [{ name: t("menu.uptime"), path: "/uptime" }];
// Handlers
const handleChangePage = (event, newPage) => {
setPage(newPage);
};
const handleChangeRowsPerPage = (event) => {
dispatch(
setRowsPerPage({
value: parseInt(event.target.value, 10),
table: "monitors",
})
);
setPage(0);
};
const triggerUpdate = useCallback(() => {
setMonitorUpdateTrigger((prev) => !prev);
}, []);
const handleReset = () => {
setSelectedState(undefined);
setSelectedTypes(undefined);
setSelectedStatus(undefined);
setToFilterStatus(undefined);
setToFilterActive(undefined);
};
const filterLookup = new Map([
[toFilterStatus, "status"],
[toFilterActive, "isActive"],
]);
const activeFilter = [...filterLookup].find(([key]) => key !== undefined);
const field = activeFilter?.[1] || (search ? "name" : sort?.field);
const filter = activeFilter?.[0] || search;
const effectiveTypes = selectedTypes?.length ? selectedTypes : TYPES;
const [
summary,
monitorsWithChecks,
monitorsWithChecksCount,
monitorsWithChecksIsLoading,
networkError,
] = useFetchMonitorsWithChecks({
types: effectiveTypes,
limit: 25,
page: page,
rowsPerPage: rowsPerPage,
filter: filter,
field: field,
order: sort?.order,
monitorUpdateTrigger,
});
const [monitors, listIsLoading, listNetworkError] = useFetchMonitorsByTeamId({
type: ["http", "ping", "docker", "port", "game"],
});
useEffect(() => {
if (isSearching) {
setPage(undefined);
}
}, [isSearching]);
const isLoading = monitorsWithChecksIsLoading;
return (
<>
<PageStateWrapper
networkError={networkError}
isLoading={isLoading}
items={monitors}
type="uptimeMonitor"
fallbackLink="/uptime/create"
>
<Stack
className="monitors"
gap={theme.spacing(10)}
>
<Breadcrumbs list={BREADCRUMBS} />
<CreateMonitorHeader
isAdmin={isAdmin}
isLoading={isLoading}
path="/uptime/create"
bulkPath="/uptime/bulk-import"
/>
<Greeting type="uptime" />
<StatusBoxes
monitorsSummary={summary}
shouldRender={!monitorsWithChecksIsLoading}
/>
<Stack direction={"row"}>
<MonitorCountHeader
isLoading={monitorsWithChecksIsLoading}
monitorCount={summary?.totalMonitors}
/>
<Filter
selectedTypes={selectedTypes}
setSelectedTypes={setSelectedTypes}
selectedStatus={selectedStatus}
setSelectedStatus={setSelectedStatus}
selectedState={selectedState}
setSelectedState={setSelectedState}
setToFilterStatus={setToFilterStatus}
setToFilterActive={setToFilterActive}
handleReset={handleReset}
/>
<SearchComponent
monitors={monitors}
onSearchChange={setSearch}
setIsSearching={setIsSearching}
/>
</Stack>
<UptimeDataTable
isAdmin={isAdmin}
isSearching={isSearching}
filteredMonitors={monitorsWithChecks}
sort={sort}
setSort={setSort}
monitorsAreLoading={monitorsWithChecksIsLoading}
triggerUpdate={triggerUpdate}
/>
<Pagination
itemCount={monitorsWithChecksCount}
paginationLabel="monitors"
page={page}
rowsPerPage={rowsPerPage}
handleChangePage={handleChangePage}
handleChangeRowsPerPage={handleChangeRowsPerPage}
/>
</Stack>
</PageStateWrapper>
</>
);
};
export default UptimeMonitors;
+24 -6
View File
@@ -25,9 +25,9 @@ import PageSpeedDetails from "../Pages/PageSpeed/Details/";
import PageSpeedCreate from "../Pages/PageSpeed/Create/index.jsx";
// Infrastructure
import Infrastructure from "../Pages/Infrastructure/Monitors/index.jsx";
import Infrastructure from "../Pages/Infrastructure/Monitors";
import InfrastructureCreate from "../Pages/Infrastructure/Create/index.jsx";
import InfrastructureDetails from "../Pages/Infrastructure/Details/index.jsx";
import InfrastructureDetails from "../Pages/Infrastructure/Details/index";
// Server Status
import ServerUnreachable from "../Pages/ServerUnreachable.jsx";
@@ -43,7 +43,7 @@ import CreateStatus from "../Pages/StatusPage/Create/index.jsx";
import StatusPages from "../Pages/StatusPage/StatusPages/index.jsx";
import Status from "../Pages/StatusPage/Status/index.jsx";
import Notifications from "../Pages/Notifications/index.jsx";
import Notifications from "../Pages/Notifications";
import CreateNotifications from "../Pages/Notifications/create/index.jsx";
// Settings
@@ -147,7 +147,13 @@ const Routes = () => {
/>
<Route
path="infrastructure"
element={<Infrastructure />}
element={
<>
<ThemeProvider theme={v2theme}>
<Infrastructure />
</ThemeProvider>
</>
}
/>
<Route
path="infrastructure/create"
@@ -159,7 +165,13 @@ const Routes = () => {
/>
<Route
path="infrastructure/:monitorId"
element={<InfrastructureDetails />}
element={
<>
<ThemeProvider theme={v2theme}>
<InfrastructureDetails />
</ThemeProvider>
</>
}
/>
<Route
path="checks/:monitorId?"
@@ -198,7 +210,13 @@ const Routes = () => {
<Route
path="notifications"
element={<Notifications />}
element={
<>
<ThemeProvider theme={v2theme}>
<Notifications />
</ThemeProvider>
</>
}
/>
<Route
path="notifications/create"
+12 -3
View File
@@ -16,6 +16,7 @@ export interface CheckCpuInfo {
physical_core?: number;
logical_core?: number;
frequency?: number;
current_frequency?: number;
temperature?: number[];
free_percent?: number;
usage_percent?: number;
@@ -32,6 +33,7 @@ export interface CheckHostInfo {
os?: string;
platform?: string;
kernel_version?: string;
pretty_name?: string;
}
export interface CheckCaptureInfo {
@@ -42,11 +44,18 @@ export interface CheckCaptureInfo {
export interface CheckDiskInfo {
device?: string;
mountpoint?: string;
read_speed_bytes?: number;
write_speed_bytes?: number;
total_bytes?: number;
free_bytes?: number;
used_bytes?: number;
usage_percent?: number;
total_inodes?: number;
free_inodes?: number;
used_inodes?: number;
inodes_usage_percent?: number;
read_bytes?: number;
write_bytes?: number;
read_time?: number;
write_time?: number;
}
export interface CheckErrorInfo {
@@ -183,7 +192,7 @@ export interface HardwareChecksResult {
totalChecks: number;
};
checks: Array<{
_id: string;
bucketDate: string;
avgCpuUsage: number;
avgMemoryUsage: number;
avgTemperature: number[];
+48
View File
@@ -107,3 +107,51 @@ export interface PageSpeedDetailsResponse {
monitor: MonitorWithChecks;
monitorStats: MonitorStats | null;
}
export interface HardwareDiskStats {
name: string;
readSpeed: number;
writeSpeed: number;
totalBytes: number;
freeBytes: number;
usagePercent: number;
}
export interface HardwareNetStats {
name: string;
bytesSentPerSecond: number;
deltaBytesRecv: number;
deltaPacketsSent: number;
deltaPacketsRecv: number;
deltaErrIn: number;
deltaErrOut: number;
deltaDropIn: number;
deltaDropOut: number;
deltaFifoIn: number;
deltaFifoOut: number;
}
export interface HardwareCheckStats {
bucketDate: string;
avgCpuUsage: number;
avgMemoryUsage: number;
avgTemperature: number[];
disks: HardwareDiskStats[];
net: HardwareNetStats[];
}
export interface HardwareStats {
aggregateData: {
totalChecks: number;
};
upChecks: {
totalChecks: number;
};
checks: HardwareCheckStats[];
}
export interface HardwareDetailsResponse {
monitor: Monitor;
stats: HardwareStats;
monitorStats: MonitorStats | null;
}
+24
View File
@@ -0,0 +1,24 @@
export const NotificationChannels = [
"email",
"slack",
"discord",
"webhook",
"pager_duty",
"matrix",
] as const;
export type NotificationChannel = (typeof NotificationChannels)[number];
export interface Notification {
id: string;
userId: string;
teamId: string;
type: NotificationChannel;
notificationName: string;
address?: string;
phone?: string;
homeserverUrl?: string;
roomId?: string;
accessToken?: string;
createdAt: string;
updatedAt: string;
}
+54
View File
@@ -0,0 +1,54 @@
import type { CheckDiskInfo, CheckHostInfo } from "@/Types/Check";
export const getFrequency = (frequency: number | undefined): string => {
if (!frequency) return "N/A";
const ghz = (frequency / 1000).toFixed(2);
return `${ghz} GHz`;
};
export const getCores = (cores: number | undefined) => {
if (!cores) return "N/A";
if (cores === 1) return `${cores} core`;
return `${cores} cores`;
};
export const getAvgTemp = (temps: number[]): string => {
if (!temps || temps.length === 0) return "N/A";
const avgTemp = temps.reduce((a, b) => a + b, 0) / temps.length;
return `${avgTemp?.toFixed(2)} °C`;
};
export const getGbs = (bytes: number | undefined): string => {
if (!bytes) {
return "N/A";
}
if (bytes === 0) {
return "0 GB";
}
const GB = bytes / (1024 * 1024 * 1024);
const MB = bytes / (1024 * 1024);
if (GB >= 1) {
return GB.toFixed(2) + " GB";
} else {
return MB.toFixed(2) + " MB";
}
};
export const getDiskTotalGbs = (disk?: Partial<CheckDiskInfo>[]): string => {
if (!disk) {
return getGbs(0);
}
const totalBytes = disk?.reduce((acc, disk) => acc + (disk.total_bytes || 0), 0) || 0;
return getGbs(totalBytes);
};
export const getOsAndPlatform = (hostInfo: CheckHostInfo | undefined): string => {
if (!hostInfo) {
return "N/A";
}
const os = hostInfo?.pretty_name || hostInfo?.os || "N/A";
const platform = hostInfo?.platform || "N/A";
return `${os} (${platform})`;
};
+67
View File
@@ -276,6 +276,73 @@
"weight": "Weight"
}
}
},
"infrastructure": {
"table": {
"headers": {
"cpu": "CPU",
"disk": "Disk",
"memory": "Memory"
}
},
"tabs": {
"labels": {
"overview": "Overview",
"network": "Network"
}
},
"statBoxes": {
"cpuPhysical": "CPU (Physical)",
"cpuLogical": "CPU (Logical)",
"cpuFrequency": "CPU Frequency",
"avgCpuTemperature": "Average CPU Temperature",
"memory": "Memory",
"disk": "Disk",
"os": "OS"
},
"gauges": {
"cpu": {
"lowerLabel": "Max frequency",
"title": "CPU usage",
"upperLabel": "Current frequency"
},
"disk": {
"lowerLabel": "Free",
"title": "Disk {{idx}} usage",
"upperLabel": "Used"
},
"memory": {
"lowerLabel": "Free",
"title": "Memory usage",
"upperLabel": "Used"
}
},
"charts": {
"labels": {
"memory": "Memory usage",
"cpu": "CPU usage",
"temp": "Temp",
"disk": "Disk usage",
"netBytesSent": "{{name}} - Bytes Sent",
"netBytesRecv": "{{name}} - Bytes Received"
}
}
},
"notifications": {
"fallback": {
"actionButton": "Create a channel",
"checks": [
"Alert teams about downtime or performance issues",
"Let engineers know when incidents happen",
"Keep administrators informed of system changes"
],
"title": "Notification channles are used to:"
},
"table": {
"headers": {
"destination": "Destination"
}
}
}
},
"incidentsTableNoIncidents": "No incidents recorded",
+2 -2
View File
@@ -93,7 +93,7 @@ class MonitorController {
const dateRange = optionalString(req?.query?.dateRange, "dateRange") || "recent";
const teamId = requireTeamId(req?.user?.teamId);
const monitor = await this.monitorService.getHardwareDetailsById({
const data = await this.monitorService.getHardwareDetailsById({
teamId,
monitorId,
dateRange,
@@ -102,7 +102,7 @@ class MonitorController {
return res.status(200).json({
success: true,
msg: "Hardware details retrieved successfully",
data: monitor,
data: data,
});
} catch (error) {
next(error);
+11 -2
View File
@@ -71,6 +71,7 @@ const cpuSchema = new Schema<CheckCpuInfo>(
physical_core: { type: Number, default: 0 },
logical_core: { type: Number, default: 0 },
frequency: { type: Number, default: 0 },
current_frequency: { type: Number, default: 0 },
temperature: { type: [Number], default: [] },
free_percent: { type: Number, default: 0 },
usage_percent: { type: Number, default: 0 },
@@ -92,11 +93,18 @@ const diskSchema = new Schema<CheckDiskInfo>(
{
device: { type: String, default: "" },
mountpoint: { type: String, default: "" },
read_speed_bytes: { type: Number, default: 0 },
write_speed_bytes: { type: Number, default: 0 },
total_bytes: { type: Number, default: 0 },
free_bytes: { type: Number, default: 0 },
used_bytes: { type: Number, default: 0 },
usage_percent: { type: Number, default: 0 },
total_inodes: { type: Number, default: 0 },
free_inodes: { type: Number, default: 0 },
used_inodes: { type: Number, default: 0 },
inodes_usage_percent: { type: Number, default: 0 },
read_bytes: { type: Number, default: 0 },
write_bytes: { type: Number, default: 0 },
read_time: { type: Number, default: 0 },
write_time: { type: Number, default: 0 },
},
{ _id: false }
);
@@ -106,6 +114,7 @@ const hostSchema = new Schema<CheckHostInfo>(
os: { type: String, default: "" },
platform: { type: String, default: "" },
kernel_version: { type: String, default: "" },
pretty_name: { type: String, default: "" },
},
{ _id: false }
);
@@ -117,11 +117,18 @@ class MongoChecksRepository implements IChecksRepository {
(disks ?? []).map((disk) => ({
device: disk?.device ?? "",
mountpoint: disk?.mountpoint ?? "",
read_speed_bytes: disk?.read_speed_bytes ?? 0,
write_speed_bytes: disk?.write_speed_bytes ?? 0,
total_bytes: disk?.total_bytes ?? 0,
free_bytes: disk?.free_bytes ?? 0,
used_bytes: disk?.used_bytes ?? 0,
usage_percent: disk?.usage_percent ?? 0,
total_inodes: disk?.total_inodes ?? 0,
free_inodes: disk?.free_inodes ?? 0,
used_inodes: disk?.used_inodes ?? 0,
inodes_usage_percent: disk?.inodes_usage_percent ?? 0,
read_bytes: disk?.read_bytes ?? 0,
write_bytes: disk?.write_bytes ?? 0,
read_time: disk?.read_time ?? 0,
write_time: disk?.write_time ?? 0,
}));
const mapErrors = (errors?: CheckErrorInfo[]): CheckErrorInfo[] =>
@@ -200,8 +207,30 @@ class MongoChecksRepository implements IChecksRepository {
return documents.map((doc) => this.toEntity(doc));
};
private toDocument = (check: Partial<Check>): CheckDocument => {
// Map id to _id for MongoDB storage
const { id, metadata, ...rest } = check;
return {
_id: id ? new mongoose.Types.ObjectId(id) : new mongoose.Types.ObjectId(),
metadata: metadata
? {
monitorId: new mongoose.Types.ObjectId(metadata.monitorId),
teamId: new mongoose.Types.ObjectId(metadata.teamId),
type: metadata.type,
}
: {
monitorId: new mongoose.Types.ObjectId(),
teamId: new mongoose.Types.ObjectId(),
type: "http",
},
...rest,
} as unknown as CheckDocument;
};
createChecks = async (checks: Check[]) => {
return await CheckModel.insertMany(checks);
const docs = checks.map((check) => this.toDocument(check));
const inserted = await CheckModel.insertMany(docs);
return this.mapDocuments(inserted as unknown as CheckDocument[]);
};
findByMonitorId = async (
@@ -500,7 +529,7 @@ class MongoChecksRepository implements IChecksRepository {
};
const checks = (hardwareMetrics ?? []).map((metric) => ({
_id: metric._id,
bucketDate: metric._id,
avgCpuUsage: metric.avgCpuUsage ?? 0,
avgMemoryUsage: metric.avgMemoryUsage ?? 0,
avgTemperature: metric.avgTemperature ?? [],
@@ -606,8 +635,8 @@ class MongoChecksRepository implements IChecksRepository {
as: "dIdx",
in: {
name: { $concat: ["disk", { $toString: "$$dIdx" }] },
readSpeed: { $avg: { $map: { input: "$disks", as: "dA", in: { $arrayElemAt: ["$$dA.read_speed_bytes", "$$dIdx"] } } } },
writeSpeed: { $avg: { $map: { input: "$disks", as: "dA", in: { $arrayElemAt: ["$$dA.write_speed_bytes", "$$dIdx"] } } } },
readSpeed: { $avg: { $map: { input: "$disks", as: "dA", in: { $arrayElemAt: ["$$dA.read_bytes", "$$dIdx"] } } } },
writeSpeed: { $avg: { $map: { input: "$disks", as: "dA", in: { $arrayElemAt: ["$$dA.write_bytes", "$$dIdx"] } } } },
totalBytes: { $avg: { $map: { input: "$disks", as: "dA", in: { $arrayElemAt: ["$$dA.total_bytes", "$$dIdx"] } } } },
freeBytes: { $avg: { $map: { input: "$disks", as: "dA", in: { $arrayElemAt: ["$$dA.free_bytes", "$$dIdx"] } } } },
usagePercent: { $avg: { $map: { input: "$disks", as: "dA", in: { $arrayElemAt: ["$$dA.usage_percent", "$$dIdx"] } } } },
@@ -257,9 +257,12 @@ export class MonitorService implements IMonitorService {
checks: checksData.checks,
};
const monitorStats = await this.monitorStatsRepository.findByMonitorId(monitor.id);
return {
...monitor,
monitor,
stats,
monitorStats,
};
};
+12 -3
View File
@@ -12,6 +12,7 @@ export interface CheckCpuInfo {
physical_core?: number;
logical_core?: number;
frequency?: number;
current_frequency?: number;
temperature?: number[];
free_percent?: number;
usage_percent?: number;
@@ -28,6 +29,7 @@ export interface CheckHostInfo {
os?: string;
platform?: string;
kernel_version?: string;
pretty_name?: string;
}
export interface CheckCaptureInfo {
@@ -38,11 +40,18 @@ export interface CheckCaptureInfo {
export interface CheckDiskInfo {
device?: string;
mountpoint?: string;
read_speed_bytes?: number;
write_speed_bytes?: number;
total_bytes?: number;
free_bytes?: number;
used_bytes?: number;
usage_percent?: number;
total_inodes?: number;
free_inodes?: number;
used_inodes?: number;
inodes_usage_percent?: number;
read_bytes?: number;
write_bytes?: number;
read_time?: number;
write_time?: number;
}
export interface CheckErrorInfo {
@@ -127,7 +136,7 @@ export interface HardwareChecksResult {
totalChecks: number;
};
checks: Array<{
_id: string;
bucketDate: string;
avgCpuUsage: number;
avgMemoryUsage: number;
avgTemperature: number[];
+45 -35
View File
@@ -75,42 +75,52 @@ export interface UptimeDetailsResult {
monitorStats: import("./monitorStats.js").MonitorStats | null;
}
export interface HardwareDetailsResult extends Monitor {
stats: {
aggregateData: {
totalChecks: number;
};
upChecks: {
totalChecks: number;
};
checks: Array<{
_id: string;
avgCpuUsage: number;
avgMemoryUsage: number;
avgTemperature: number[];
disks: Array<{
name: string;
readSpeed: number;
writeSpeed: number;
totalBytes: number;
freeBytes: number;
usagePercent: number;
}>;
net: Array<{
name: string;
bytesSentPerSecond: number;
deltaBytesRecv: number;
deltaPacketsSent: number;
deltaPacketsRecv: number;
deltaErrIn: number;
deltaErrOut: number;
deltaDropIn: number;
deltaDropOut: number;
deltaFifoIn: number;
deltaFifoOut: number;
}>;
}>;
export interface HardwareDiskStats {
name: string;
readSpeed: number;
writeSpeed: number;
totalBytes: number;
freeBytes: number;
usagePercent: number;
}
export interface HardwareNetStats {
name: string;
bytesSentPerSecond: number;
deltaBytesRecv: number;
deltaPacketsSent: number;
deltaPacketsRecv: number;
deltaErrIn: number;
deltaErrOut: number;
deltaDropIn: number;
deltaDropOut: number;
deltaFifoIn: number;
deltaFifoOut: number;
}
export interface HardwareCheckStats {
bucketDate: string;
avgCpuUsage: number;
avgMemoryUsage: number;
avgTemperature: number[];
disks: HardwareDiskStats[];
net: HardwareNetStats[];
}
export interface HardwareStats {
aggregateData: {
totalChecks: number;
};
upChecks: {
totalChecks: number;
};
checks: HardwareCheckStats[];
}
export interface HardwareDetailsResult {
monitor: Monitor;
stats: HardwareStats;
monitorStats: import("./monitorStats.js").MonitorStats | null;
}
export interface PageSpeedDetailsResult {