47 files changed
+503
-3507
No files matched your search
@@ -1,7 +1,11 @@
|
||||
import Stack from "@mui/material/Stack";
|
||||
import Box from "@mui/material/Box";
|
||||
import Typography from "@mui/material/Typography";
|
||||
import { ToggleButtonGroup, ToggleButton } from "@/Components/v2/inputs";
|
||||
import { useTheme } from "@mui/material/styles";
|
||||
import CircularProgress from "@mui/material/CircularProgress";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { useMediaQuery } from "@mui/material";
|
||||
|
||||
interface MonitorTimeFrameHeaderProps {
|
||||
isLoading?: boolean;
|
||||
@@ -17,7 +21,8 @@ export const HeaderTimeRange = ({
|
||||
setDateRange,
|
||||
}: MonitorTimeFrameHeaderProps) => {
|
||||
const theme = useTheme();
|
||||
|
||||
const { t } = useTranslation();
|
||||
const isSmallScreen = useMediaQuery(theme.breakpoints.down("md"));
|
||||
const handleChange = (
|
||||
_event: React.MouseEvent<HTMLElement>,
|
||||
newValue: string | null
|
||||
@@ -32,34 +37,24 @@ export const HeaderTimeRange = ({
|
||||
if (hasDateRange) {
|
||||
timeFramePicker = (
|
||||
<ToggleButtonGroup
|
||||
orientation={isSmallScreen ? "vertical" : "horizontal"}
|
||||
value={dateRange}
|
||||
exclusive
|
||||
onChange={handleChange}
|
||||
size="small"
|
||||
fullWidth={isSmallScreen}
|
||||
>
|
||||
<ToggleButton
|
||||
disabled={isLoading}
|
||||
value="recent"
|
||||
>
|
||||
Recent
|
||||
<ToggleButton value="recent">
|
||||
{t("components.headerTimeRange.labels.recent")}
|
||||
</ToggleButton>
|
||||
<ToggleButton
|
||||
disabled={isLoading}
|
||||
value="day"
|
||||
>
|
||||
Day
|
||||
<ToggleButton value="day">
|
||||
{t("components.headerTimeRange.labels.day")}
|
||||
</ToggleButton>
|
||||
<ToggleButton
|
||||
disabled={isLoading}
|
||||
value="week"
|
||||
>
|
||||
Week
|
||||
<ToggleButton value="week">
|
||||
{t("components.headerTimeRange.labels.week")}
|
||||
</ToggleButton>
|
||||
<ToggleButton
|
||||
disabled={isLoading}
|
||||
value="month"
|
||||
>
|
||||
Month
|
||||
<ToggleButton value="month">
|
||||
{t("components.headerTimeRange.labels.month")}
|
||||
</ToggleButton>
|
||||
</ToggleButtonGroup>
|
||||
);
|
||||
@@ -67,21 +62,19 @@ export const HeaderTimeRange = ({
|
||||
|
||||
return (
|
||||
<Stack
|
||||
direction="row"
|
||||
direction={{ xs: "column", md: "row" }}
|
||||
justifyContent="flex-end"
|
||||
alignItems="center"
|
||||
gap={theme.spacing(4)}
|
||||
>
|
||||
<Box
|
||||
width={16}
|
||||
height={16}
|
||||
>
|
||||
{isLoading && <CircularProgress size={16} />}
|
||||
</Box>
|
||||
<Typography variant="body2">
|
||||
Showing statistics for past{" "}
|
||||
{dateRange === "recent"
|
||||
? "2 hours"
|
||||
: dateRange === "day"
|
||||
? "24 hours"
|
||||
: dateRange === "week"
|
||||
? "7 days"
|
||||
: "30 days"}
|
||||
.
|
||||
{t(`components.headerTimeRange.description.${dateRange}`)}
|
||||
</Typography>
|
||||
{timeFramePicker}
|
||||
</Stack>
|
||||
|
||||
@@ -4,12 +4,16 @@ import Box from "@mui/material/Box";
|
||||
import { BaseBox } from "@/Components/v2/design-elements";
|
||||
import Background from "@/assets/Images/background-grid.svg?react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import type { SxProps } from "@mui/material";
|
||||
|
||||
import { useTheme } from "@mui/material/styles";
|
||||
|
||||
type StatusBoxProps = React.PropsWithChildren<{ children: React.ReactNode }>;
|
||||
type StatusBoxProps = React.PropsWithChildren<{
|
||||
children: React.ReactNode;
|
||||
sx?: SxProps;
|
||||
}>;
|
||||
|
||||
export const BGBox = ({ children }: StatusBoxProps) => {
|
||||
export const BGBox = ({ children, sx }: StatusBoxProps) => {
|
||||
const theme = useTheme();
|
||||
return (
|
||||
<BaseBox
|
||||
@@ -18,6 +22,7 @@ export const BGBox = ({ children }: StatusBoxProps) => {
|
||||
position: "relative",
|
||||
flex: 1,
|
||||
padding: theme.spacing(4),
|
||||
...sx,
|
||||
}}
|
||||
>
|
||||
<Box
|
||||
@@ -36,14 +41,16 @@ const StatusBox = ({
|
||||
label,
|
||||
n,
|
||||
color,
|
||||
sx,
|
||||
}: {
|
||||
label: string;
|
||||
n: number;
|
||||
color: string | undefined;
|
||||
sx?: SxProps;
|
||||
}) => {
|
||||
const theme = useTheme();
|
||||
return (
|
||||
<BGBox>
|
||||
<BGBox sx={sx}>
|
||||
<Stack spacing={theme.spacing(4)}>
|
||||
<Typography
|
||||
variant={"h2"}
|
||||
@@ -98,6 +105,39 @@ export const PausedStatusBox = ({ n }: { n: number }) => {
|
||||
/>
|
||||
);
|
||||
};
|
||||
export const TotalChecksBox = ({ n }: { n: number }) => {
|
||||
const theme = useTheme();
|
||||
const { t } = useTranslation();
|
||||
return (
|
||||
<StatusBox
|
||||
label={t("pages.common.monitors.status.total")}
|
||||
n={n}
|
||||
color={theme.palette.primary.light}
|
||||
/>
|
||||
);
|
||||
};
|
||||
export const DownChecksBox = ({ n }: { n: number }) => {
|
||||
const theme = useTheme();
|
||||
const { t } = useTranslation();
|
||||
return (
|
||||
<StatusBox
|
||||
label={t("pages.common.monitors.status.down")}
|
||||
n={n}
|
||||
color={theme.palette.error.light}
|
||||
/>
|
||||
);
|
||||
};
|
||||
export const UpChecksBox = ({ n }: { n: number }) => {
|
||||
const theme = useTheme();
|
||||
const { t } = useTranslation();
|
||||
return (
|
||||
<StatusBox
|
||||
label={t("pages.common.monitors.status.up")}
|
||||
n={n}
|
||||
color={theme.palette.success.light}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
export const InitializingStatusBox = ({ n }: { n: number }) => {
|
||||
const theme = useTheme();
|
||||
|
||||
@@ -21,7 +21,7 @@ export const useGet = <T>(
|
||||
axiosConfig?: AxiosRequestConfig,
|
||||
swrConfig?: SWRConfiguration
|
||||
) => {
|
||||
const { data, error, isLoading, mutate } = useSWR<ApiResponse<T>>(
|
||||
const { data, error, isLoading, isValidating, mutate } = useSWR<ApiResponse<T>>(
|
||||
url,
|
||||
(url: string) => fetcher<T>(url, axiosConfig),
|
||||
swrConfig
|
||||
@@ -30,6 +30,7 @@ export const useGet = <T>(
|
||||
return {
|
||||
data: data?.data ?? null,
|
||||
isLoading,
|
||||
isValidating,
|
||||
error,
|
||||
refetch: mutate,
|
||||
};
|
||||
|
||||
@@ -0,0 +1,132 @@
|
||||
import {
|
||||
Table,
|
||||
Pagination,
|
||||
ValueLabel,
|
||||
StatusLabel,
|
||||
} from "@/Components/v2/design-elements";
|
||||
import Box from "@mui/material/Box";
|
||||
import type { Header } from "@/Components/v2/design-elements/Table";
|
||||
import type { Monitor, MonitorStatus } from "@/Types/Monitor";
|
||||
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { formatDateWithTz } from "@/Utils/TimeUtils";
|
||||
import { useNavigate } from "react-router";
|
||||
import type { Check } from "@/Types/Check";
|
||||
import type { RootState } from "@/Types/state";
|
||||
import { useSelector } from "react-redux";
|
||||
|
||||
export const ChecksTable = ({
|
||||
monitors,
|
||||
checks,
|
||||
checksCount,
|
||||
page,
|
||||
setPage,
|
||||
rowsPerPage,
|
||||
setRowsPerPage,
|
||||
}: {
|
||||
monitors: Monitor[] | null;
|
||||
checks: Check[];
|
||||
checksCount: number;
|
||||
page: number;
|
||||
setPage: (page: number) => void;
|
||||
rowsPerPage: number;
|
||||
setRowsPerPage: (rowsPerPage: number) => void;
|
||||
}) => {
|
||||
const { t } = useTranslation();
|
||||
const uiTimezone = useSelector((state: RootState) => state.ui.timezone);
|
||||
const navigate = useNavigate();
|
||||
|
||||
const getHeaders = (t: Function, uiTimezone: string) => {
|
||||
const headers: Header<Check>[] = [
|
||||
{
|
||||
id: "monitorName",
|
||||
content: t("common.table.headers.monitor"),
|
||||
render: (row) => {
|
||||
return (
|
||||
monitors?.find((monitor) => monitor.id === row.metadata.monitorId)?.name ||
|
||||
"N/A"
|
||||
);
|
||||
},
|
||||
},
|
||||
{
|
||||
id: "status",
|
||||
content: "Status",
|
||||
render: (row) => {
|
||||
return <StatusLabel status={row.status as MonitorStatus} />;
|
||||
},
|
||||
},
|
||||
{
|
||||
id: "date",
|
||||
content: t("common.table.headers.dateTime"),
|
||||
render: (row) => {
|
||||
return formatDateWithTz(
|
||||
row.createdAt,
|
||||
"ddd, MMMM D, YYYY, HH:mm A",
|
||||
uiTimezone
|
||||
);
|
||||
},
|
||||
},
|
||||
{
|
||||
id: "statusCode",
|
||||
content: t("pages.checks.table.headers.statusCode"),
|
||||
render: (row) => {
|
||||
const code = row.statusCode;
|
||||
if (!code) return "N/A";
|
||||
const value = code < 300 ? "positive" : code < 400 ? "neutral" : "negative";
|
||||
return (
|
||||
<ValueLabel
|
||||
value={value}
|
||||
text={String(code)}
|
||||
/>
|
||||
);
|
||||
},
|
||||
},
|
||||
{
|
||||
id: "message",
|
||||
content: t("common.table.headers.message"),
|
||||
render: (row) => {
|
||||
return row.message || "N/A";
|
||||
},
|
||||
},
|
||||
];
|
||||
return headers;
|
||||
};
|
||||
|
||||
const headers = getHeaders(t, uiTimezone);
|
||||
|
||||
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);
|
||||
};
|
||||
|
||||
return (
|
||||
<Box>
|
||||
<Table
|
||||
headers={headers}
|
||||
data={checks}
|
||||
onRowClick={(row) => {
|
||||
navigate(`/checks/${row.id}`);
|
||||
}}
|
||||
emptyViewText={t("pages.checks.table.empty")}
|
||||
/>
|
||||
<Pagination
|
||||
component="div"
|
||||
count={checksCount}
|
||||
page={page}
|
||||
rowsPerPage={rowsPerPage}
|
||||
onPageChange={handlePageChange}
|
||||
onRowsPerPageChange={handleRowsPerPageChange}
|
||||
/>
|
||||
</Box>
|
||||
);
|
||||
};
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -0,0 +1,176 @@
|
||||
import Stack from "@mui/material/Stack";
|
||||
import {
|
||||
BasePage,
|
||||
TotalChecksBox,
|
||||
UpChecksBox,
|
||||
DownChecksBox,
|
||||
} from "@/Components/v2/design-elements";
|
||||
import { HeaderTimeRange } from "@/Components/v2/common";
|
||||
import { Select } from "@/Components/v2/inputs";
|
||||
import { ChecksTable } from "./Components/ChecksTable";
|
||||
|
||||
import { MenuItem } from "@mui/material";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { useState, useMemo } from "react";
|
||||
import { useParams } from "react-router-dom";
|
||||
import { useGet } from "@/Hooks/UseApi";
|
||||
import type { Monitor } from "@/Types/Monitor";
|
||||
import type { ChecksSummary, ChecksResponse } from "@/Types/Check";
|
||||
|
||||
const Checks = () => {
|
||||
const { t } = useTranslation();
|
||||
const { monitorId } = useParams<{ monitorId?: string }>();
|
||||
|
||||
const [selectedMonitor, setSelectedMonitor] = useState<string>(monitorId || "0");
|
||||
const [dateRange, setDateRange] = useState<string>("recent");
|
||||
const [statusFilter, setStatusFilter] = useState<string>("down");
|
||||
const [page, setPage] = useState<number>(0);
|
||||
const [rowsPerPage, setRowsPerPage] = useState<number>(10);
|
||||
|
||||
const monitorsUrl = "/monitors/team";
|
||||
const summaryUrl = `/checks/team/summary?dateRange=${dateRange}`;
|
||||
|
||||
const { data: monitorsResponse, isLoading: isLoadingMonitors } =
|
||||
useGet<Monitor[]>(monitorsUrl);
|
||||
|
||||
const { data: summaryResponse, isLoading: isLoadingSummary } =
|
||||
useGet<ChecksSummary>(summaryUrl);
|
||||
|
||||
const selectedMonitorType = monitorsResponse?.find(
|
||||
(m) => m.id === selectedMonitor
|
||||
)?.type;
|
||||
|
||||
const teamChecksUrl = useMemo(() => {
|
||||
if (selectedMonitor !== "0") return null;
|
||||
const params = new URLSearchParams();
|
||||
params.append("sortOrder", "desc");
|
||||
if (dateRange) params.append("dateRange", dateRange);
|
||||
if (statusFilter) params.append("filter", statusFilter);
|
||||
params.append("page", String(page));
|
||||
params.append("rowsPerPage", String(rowsPerPage));
|
||||
return `/checks/team?${params.toString()}`;
|
||||
}, [selectedMonitor, dateRange, statusFilter, page, rowsPerPage]);
|
||||
|
||||
const monitorChecksUrl = useMemo(() => {
|
||||
if (selectedMonitor === "0" || !selectedMonitorType) return null;
|
||||
const params = new URLSearchParams();
|
||||
params.append("type", selectedMonitorType);
|
||||
params.append("sortOrder", "desc");
|
||||
if (statusFilter) params.append("filter", statusFilter);
|
||||
if (dateRange) params.append("dateRange", dateRange);
|
||||
params.append("page", String(page));
|
||||
params.append("rowsPerPage", String(rowsPerPage));
|
||||
return `/checks/${selectedMonitor}?${params.toString()}`;
|
||||
}, [selectedMonitor, selectedMonitorType, dateRange, statusFilter, page, rowsPerPage]);
|
||||
|
||||
const {
|
||||
data: teamChecksData,
|
||||
isLoading: isLoadingTeamChecks,
|
||||
isValidating: isValidatingTeamChecks,
|
||||
} = useGet<ChecksResponse>(
|
||||
teamChecksUrl,
|
||||
{},
|
||||
{ keepPreviousData: true, refreshInterval: 30000 }
|
||||
);
|
||||
const {
|
||||
data: monitorChecksData,
|
||||
isLoading: isLoadingMonitorChecks,
|
||||
isValidating: isValidatingMonitorChecks,
|
||||
} = useGet<ChecksResponse>(
|
||||
monitorChecksUrl,
|
||||
{},
|
||||
{ keepPreviousData: true, refreshInterval: 30000 }
|
||||
);
|
||||
|
||||
const checks =
|
||||
selectedMonitor === "0"
|
||||
? (teamChecksData?.checks ?? [])
|
||||
: (monitorChecksData?.checks ?? []);
|
||||
const checksCount =
|
||||
selectedMonitor === "0"
|
||||
? (teamChecksData?.checksCount ?? 0)
|
||||
: (monitorChecksData?.checksCount ?? 0);
|
||||
|
||||
const isLoadingChecks =
|
||||
isLoadingTeamChecks ||
|
||||
isLoadingMonitorChecks ||
|
||||
isValidatingTeamChecks ||
|
||||
isValidatingMonitorChecks;
|
||||
|
||||
const isLoading = isLoadingMonitors || isLoadingSummary || isLoadingChecks;
|
||||
const totalChecks = summaryResponse?.totalChecks || 0;
|
||||
const downChecks = summaryResponse?.downChecks || 0;
|
||||
const upChecks = totalChecks - (summaryResponse?.downChecks || 0);
|
||||
|
||||
return (
|
||||
<BasePage>
|
||||
<Stack
|
||||
direction={{ xs: "column", md: "row" }}
|
||||
gap={4}
|
||||
>
|
||||
<TotalChecksBox n={totalChecks} />
|
||||
<UpChecksBox n={upChecks} />
|
||||
<DownChecksBox n={downChecks || 0} />
|
||||
</Stack>
|
||||
|
||||
<Stack
|
||||
direction={{ xs: "column", md: "row" }}
|
||||
justifyContent="space-between"
|
||||
alignItems={{ xs: "stretch", md: "center" }}
|
||||
gap={2}
|
||||
>
|
||||
<Stack
|
||||
gap={2}
|
||||
direction={{ xs: "column", md: "row" }}
|
||||
>
|
||||
<Select
|
||||
fullWidth
|
||||
value={selectedMonitor}
|
||||
onChange={(e: any) => {
|
||||
setSelectedMonitor(e.target.value);
|
||||
setPage(0);
|
||||
}}
|
||||
>
|
||||
<MenuItem value="0">{t("pages.checks.selects.monitor.all")}</MenuItem>
|
||||
{monitorsResponse?.map((monitor) => (
|
||||
<MenuItem
|
||||
key={monitor.id}
|
||||
value={monitor.id}
|
||||
>
|
||||
{monitor.name}
|
||||
</MenuItem>
|
||||
))}
|
||||
</Select>
|
||||
<Select
|
||||
value={statusFilter}
|
||||
onChange={(e: any) => {
|
||||
setStatusFilter(e.target.value);
|
||||
setPage(0);
|
||||
}}
|
||||
>
|
||||
<MenuItem value="all">{t("pages.checks.selects.status.all")}</MenuItem>
|
||||
<MenuItem value="up">{t("pages.checks.selects.status.up")}</MenuItem>
|
||||
<MenuItem value="down">{t("pages.checks.selects.status.down")}</MenuItem>
|
||||
</Select>
|
||||
</Stack>
|
||||
<HeaderTimeRange
|
||||
isLoading={isLoading || isLoadingChecks}
|
||||
dateRange={dateRange}
|
||||
setDateRange={setDateRange}
|
||||
/>
|
||||
</Stack>
|
||||
|
||||
<ChecksTable
|
||||
monitors={monitorsResponse ?? null}
|
||||
checks={checks}
|
||||
checksCount={checksCount}
|
||||
page={page}
|
||||
setPage={setPage}
|
||||
rowsPerPage={rowsPerPage}
|
||||
setRowsPerPage={setRowsPerPage}
|
||||
/>
|
||||
</BasePage>
|
||||
);
|
||||
};
|
||||
|
||||
export default Checks;
|
||||
@@ -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;
|
||||
@@ -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,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,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,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;
|
||||
@@ -33,7 +33,7 @@ import InfrastructureDetails from "../Pages/Infrastructure/Details/index.jsx";
|
||||
import ServerUnreachable from "../Pages/ServerUnreachable.jsx";
|
||||
|
||||
// Checks
|
||||
import Checks from "../Pages/Checks/index.jsx";
|
||||
import Checks from "../Pages/Checks/index";
|
||||
|
||||
// Incidents
|
||||
import Incidents from "../Pages/Incidents/index.jsx";
|
||||
@@ -163,7 +163,13 @@ const Routes = () => {
|
||||
/>
|
||||
<Route
|
||||
path="checks/:monitorId?"
|
||||
element={<Checks />}
|
||||
element={
|
||||
<>
|
||||
<ThemeProvider theme={v2theme}>
|
||||
<Checks />
|
||||
</ThemeProvider>
|
||||
</>
|
||||
}
|
||||
/>
|
||||
<Route
|
||||
path="incidents/:monitorId?"
|
||||
|
||||
@@ -222,9 +222,7 @@ export interface UptimeChecksResult {
|
||||
|
||||
export interface ChecksSummary {
|
||||
totalChecks: number;
|
||||
resolvedChecks: number;
|
||||
downChecks: number;
|
||||
cannotResolveChecks: number;
|
||||
}
|
||||
|
||||
export type CheckSnapshot = Omit<
|
||||
|
||||
@@ -40,7 +40,8 @@
|
||||
"monitor": "Monitor",
|
||||
"name": "Name",
|
||||
"status": "Status",
|
||||
"type": "Type"
|
||||
"type": "Type",
|
||||
"dateTime": "Date & time"
|
||||
},
|
||||
"empty": "Nothing here"
|
||||
},
|
||||
@@ -170,6 +171,22 @@
|
||||
},
|
||||
"add": "Add",
|
||||
"monitors": "monitors",
|
||||
"components": {
|
||||
"headerTimeRange": {
|
||||
"labels": {
|
||||
"recent": "Recent",
|
||||
"day": "Day",
|
||||
"week": "Week",
|
||||
"month": "Month"
|
||||
},
|
||||
"description": {
|
||||
"recent": "Showing statistics for past 2 hours.",
|
||||
"day": "Showing statistics for past 24 hours.",
|
||||
"week": "Showing statistics for past 7 days.",
|
||||
"month": "Showing statistics for past 30 days."
|
||||
}
|
||||
}
|
||||
},
|
||||
"pages": {
|
||||
"common": {
|
||||
"monitors": {
|
||||
@@ -177,6 +194,7 @@
|
||||
"up": "up",
|
||||
"down": "down",
|
||||
"paused": "paused",
|
||||
"total": "total",
|
||||
"initializing": "initializing"
|
||||
},
|
||||
"actions": {
|
||||
@@ -223,6 +241,17 @@
|
||||
"headers": {
|
||||
"dateTime": "Date & time",
|
||||
"statusCode": "Status code"
|
||||
},
|
||||
"empty": "No down checks in this time range"
|
||||
},
|
||||
"selects": {
|
||||
"monitor": {
|
||||
"all": "All monitors"
|
||||
},
|
||||
"status": {
|
||||
"all": "All",
|
||||
"down": "Down",
|
||||
"up": "Up"
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
@@ -15,7 +15,7 @@ services:
|
||||
depends_on:
|
||||
- mongodb
|
||||
mongodb:
|
||||
image: mongo:4.4.30
|
||||
image: mongo:6.0
|
||||
restart: always
|
||||
command: ["mongod", "--quiet", "--bind_ip_all"]
|
||||
ports:
|
||||
@@ -23,7 +23,7 @@ services:
|
||||
volumes:
|
||||
- ./mongo/data:/data/db
|
||||
healthcheck:
|
||||
test: ["CMD", "mongo", "--eval", "db.runCommand({ ping: 1 })", "--quiet"]
|
||||
test: ["CMD", "mongosh", "--eval", "db.runCommand({ ping: 1 })", "--quiet"]
|
||||
interval: 5s
|
||||
timeout: 30s
|
||||
start_period: 0s
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
# ---------------------
|
||||
# Frontend build stage
|
||||
# ---------------------
|
||||
FROM node:24-slim AS frontend-build
|
||||
FROM node:20-slim AS frontend-build
|
||||
|
||||
|
||||
|
||||
@@ -20,7 +20,7 @@ RUN npm run build
|
||||
# ---------------------
|
||||
# Backend stage
|
||||
# ---------------------
|
||||
FROM node:24-slim AS backend
|
||||
FROM node:20-slim AS backend
|
||||
|
||||
# Install ping
|
||||
RUN apt-get update \
|
||||
|
||||
@@ -197,7 +197,7 @@ class AuthController {
|
||||
throw new AppError({ message: "Unauthorized", status: 403 });
|
||||
}
|
||||
|
||||
const userId = req.params.userId;
|
||||
const userId = req.params.userId as string;
|
||||
const user = { ...req.body };
|
||||
|
||||
await editUserByIdParamValidation.validateAsync(req.params);
|
||||
@@ -222,7 +222,7 @@ class AuthController {
|
||||
throw new AppError({ message: "Unauthorized", status: 403 });
|
||||
}
|
||||
|
||||
const userId = req.params.userId;
|
||||
const userId = req.params.userId as string;
|
||||
await editUserByIdParamValidation.validateAsync(req.params);
|
||||
await editUserPasswordByIdBodyValidation.validateAsync(req.body);
|
||||
const updatedPassword = req.body.password;
|
||||
|
||||
@@ -83,7 +83,7 @@ class CheckController {
|
||||
await deleteChecksParamValidation.validateAsync(req.params);
|
||||
|
||||
const deletedCount = await this.checkService.deleteChecks({
|
||||
monitorId: req.params.monitorId,
|
||||
monitorId: req.params.monitorId as string,
|
||||
teamId: req?.user?.teamId,
|
||||
});
|
||||
|
||||
|
||||
@@ -67,7 +67,7 @@ class IncidentController {
|
||||
getIncidentById = async (req: Request, res: Response, next: NextFunction) => {
|
||||
try {
|
||||
const teamId = requireTeamId(req.user?.teamId);
|
||||
const incidentId = req.params.incidentId;
|
||||
const incidentId = req.params.incidentId as string;
|
||||
if (!incidentId) {
|
||||
throw new AppError({ message: "Incident ID is required", service: SERVICE_NAME, status: 400 });
|
||||
}
|
||||
|
||||
@@ -44,7 +44,7 @@ class MaintenanceWindowController {
|
||||
|
||||
const teamId = requireTeamId(req.user?.teamId);
|
||||
|
||||
const maintenanceWindow = await this.maintenanceWindowService.getMaintenanceWindowById({ id: req.params.id, teamId });
|
||||
const maintenanceWindow = await this.maintenanceWindowService.getMaintenanceWindowById({ id: req.params.id as string, teamId });
|
||||
|
||||
return res.status(200).json({
|
||||
success: true,
|
||||
@@ -80,7 +80,10 @@ class MaintenanceWindowController {
|
||||
|
||||
const teamId = requireTeamId(req?.user?.teamId);
|
||||
|
||||
const maintenanceWindows = await this.maintenanceWindowService.getMaintenanceWindowsByMonitorId({ monitorId: req.params.monitorId, teamId });
|
||||
const maintenanceWindows = await this.maintenanceWindowService.getMaintenanceWindowsByMonitorId({
|
||||
monitorId: req.params.monitorId as string,
|
||||
teamId,
|
||||
});
|
||||
|
||||
return res.status(200).json({
|
||||
success: true,
|
||||
@@ -97,7 +100,7 @@ class MaintenanceWindowController {
|
||||
|
||||
const teamId = requireTeamId(req?.user?.teamId);
|
||||
|
||||
await this.maintenanceWindowService.deleteMaintenanceWindow({ id: req.params.id, teamId });
|
||||
await this.maintenanceWindowService.deleteMaintenanceWindow({ id: req.params.id as string, teamId });
|
||||
|
||||
return res.status(200).json({
|
||||
success: true,
|
||||
@@ -115,7 +118,11 @@ class MaintenanceWindowController {
|
||||
|
||||
const teamId = requireTeamId(req.user?.teamId);
|
||||
|
||||
const editedMaintenanceWindow = await this.maintenanceWindowService.editMaintenanceWindow({ id: req.params.id, body: req.body, teamId });
|
||||
const editedMaintenanceWindow = await this.maintenanceWindowService.editMaintenanceWindow({
|
||||
id: req.params.id as string,
|
||||
body: req.body,
|
||||
teamId,
|
||||
});
|
||||
return res.status(200).json({
|
||||
success: true,
|
||||
msg: "Maintenance window edited successfully",
|
||||
|
||||
@@ -97,7 +97,7 @@ class NotificationController {
|
||||
throw new AppError({ message: "Team ID is required", status: 400 });
|
||||
}
|
||||
|
||||
const notificationId = req.params.id;
|
||||
const notificationId = req.params.id as string;
|
||||
if (!notificationId) {
|
||||
throw new AppError({ message: "Notification ID is required", status: 400 });
|
||||
}
|
||||
@@ -115,7 +115,7 @@ class NotificationController {
|
||||
getNotificationById = async (req: Request, res: Response, next: NextFunction) => {
|
||||
try {
|
||||
const teamId = requireTeamId(req.user?.teamId);
|
||||
const notificationId = req.params.id;
|
||||
const notificationId = req.params.id as string;
|
||||
if (!notificationId) {
|
||||
throw new AppError({ message: "Notification ID is required", status: 400 });
|
||||
}
|
||||
@@ -139,7 +139,7 @@ class NotificationController {
|
||||
});
|
||||
|
||||
const teamId = requireTeamId(req.user?.teamId);
|
||||
const notificationId = req.params.id;
|
||||
const notificationId = req.params.id as string;
|
||||
if (!notificationId) {
|
||||
throw new AppError({ message: "Notification ID is required", status: 400 });
|
||||
}
|
||||
|
||||
@@ -19,7 +19,7 @@ import mongoose from "mongoose";
|
||||
const SERVICE_NAME = "StatusService";
|
||||
|
||||
const dateRangeLookup: Record<string, Date | undefined> = {
|
||||
recent: new Date(new Date().setDate(new Date().getDate() - 2)),
|
||||
recent: new Date(new Date().setHours(new Date().getHours() - 2)),
|
||||
hour: new Date(new Date().setHours(new Date().getHours() - 1)),
|
||||
day: new Date(new Date().setDate(new Date().getDate() - 1)),
|
||||
week: new Date(new Date().setDate(new Date().getDate() - 7)),
|
||||
@@ -193,8 +193,37 @@ class MongoChecksRepository implements IChecksRepository {
|
||||
};
|
||||
};
|
||||
|
||||
private mapDocuments = (documents: CheckDocument[]): Check[] => {
|
||||
if (!documents?.length) {
|
||||
return [];
|
||||
}
|
||||
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 (
|
||||
@@ -221,9 +250,14 @@ class MongoChecksRepository implements IChecksRepository {
|
||||
switch (filter) {
|
||||
case "all":
|
||||
break;
|
||||
case "up":
|
||||
matchStage.status = true;
|
||||
break;
|
||||
case "down":
|
||||
matchStage.status = false;
|
||||
break;
|
||||
case "resolve":
|
||||
matchStage.status = false;
|
||||
matchStage.statusCode = 5000;
|
||||
break;
|
||||
default:
|
||||
@@ -245,33 +279,17 @@ class MongoChecksRepository implements IChecksRepository {
|
||||
skip = page * rowsPerPage;
|
||||
}
|
||||
|
||||
const checks = await CheckModel.aggregate([
|
||||
{ $match: matchStage },
|
||||
{ $sort: { createdAt: convertedSortOrder } },
|
||||
{
|
||||
$facet: {
|
||||
summary: [{ $count: "checksCount" }],
|
||||
checks: [{ $skip: skip }, { $limit: rowsPerPage }],
|
||||
},
|
||||
},
|
||||
{
|
||||
$project: {
|
||||
checksCount: {
|
||||
$ifNull: [{ $arrayElemAt: ["$summary.checksCount", 0] }, 0],
|
||||
},
|
||||
checks: {
|
||||
$ifNull: ["$checks", []],
|
||||
},
|
||||
},
|
||||
},
|
||||
const [checksCount, checks] = await Promise.all([
|
||||
CheckModel.countDocuments(matchStage),
|
||||
CheckModel.find(matchStage).sort({ createdAt: convertedSortOrder }).skip(skip).limit(rowsPerPage).lean() as Promise<CheckDocument[]>,
|
||||
]);
|
||||
return checks[0];
|
||||
|
||||
return { checksCount, checks: this.mapDocuments(checks) };
|
||||
};
|
||||
|
||||
findByTeamId = async (sortOrder: string, dateRange: string, filter: string, page: number, rowsPerPage: number, teamId: string) => {
|
||||
const matchStage: Record<string, any> = {
|
||||
"metadata.teamId": new mongoose.Types.ObjectId(teamId),
|
||||
status: false,
|
||||
...(dateRangeLookup[dateRange] && {
|
||||
createdAt: {
|
||||
$gte: dateRangeLookup[dateRange],
|
||||
@@ -283,9 +301,14 @@ class MongoChecksRepository implements IChecksRepository {
|
||||
switch (filter) {
|
||||
case "all":
|
||||
break;
|
||||
case "up":
|
||||
matchStage.status = true;
|
||||
break;
|
||||
case "down":
|
||||
matchStage.status = false;
|
||||
break;
|
||||
case "resolve":
|
||||
matchStage.status = false;
|
||||
matchStage.statusCode = 5000;
|
||||
break;
|
||||
default:
|
||||
@@ -306,26 +329,12 @@ class MongoChecksRepository implements IChecksRepository {
|
||||
skip = page * rowsPerPage;
|
||||
}
|
||||
|
||||
const aggregatePipeline: any = [
|
||||
{ $match: matchStage },
|
||||
const [checksCount, checks] = await Promise.all([
|
||||
CheckModel.countDocuments(matchStage),
|
||||
CheckModel.find(matchStage).sort({ createdAt: parsedSortOrder }).skip(skip).limit(rowsPerPage).lean() as Promise<CheckDocument[]>,
|
||||
]);
|
||||
|
||||
{ $sort: { createdAt: parsedSortOrder } },
|
||||
{
|
||||
$facet: {
|
||||
summary: [{ $count: "checksCount" }],
|
||||
checks: [{ $skip: skip }, { $limit: rowsPerPage }],
|
||||
},
|
||||
},
|
||||
{
|
||||
$project: {
|
||||
checksCount: { $arrayElemAt: ["$summary.checksCount", 0] },
|
||||
checks: "$checks",
|
||||
},
|
||||
},
|
||||
];
|
||||
|
||||
const checks = await CheckModel.aggregate(aggregatePipeline);
|
||||
return checks[0];
|
||||
return { checksCount, checks: this.mapDocuments(checks) };
|
||||
};
|
||||
|
||||
findLatestByMonitorIds = async (monitorIds: string[], options?: { limitPerMonitor?: number }): Promise<LatestChecksMap> => {
|
||||
@@ -366,29 +375,24 @@ class MongoChecksRepository implements IChecksRepository {
|
||||
};
|
||||
|
||||
findSummaryByTeamId = async (teamId: string, dateRange: string) => {
|
||||
const matchStage = {
|
||||
const baseMatch = {
|
||||
"metadata.teamId": new mongoose.Types.ObjectId(teamId),
|
||||
status: false,
|
||||
...(dateRangeLookup[dateRange] && {
|
||||
createdAt: {
|
||||
$gte: dateRangeLookup[dateRange],
|
||||
},
|
||||
}),
|
||||
};
|
||||
const checks = await CheckModel.aggregate([
|
||||
{ $match: matchStage },
|
||||
{
|
||||
$group: {
|
||||
_id: null,
|
||||
totalChecks: { $sum: 1 },
|
||||
resolvedChecks: { $sum: { $cond: [{ $eq: ["$ack", true] }, 1, 0] } },
|
||||
downChecks: { $sum: { $cond: [{ $eq: ["$ack", false] }, 1, 0] } },
|
||||
cannotResolveChecks: { $sum: { $cond: [{ $eq: ["$statusCode", 5000] }, 1, 0] } },
|
||||
},
|
||||
},
|
||||
{ $project: { _id: 0 } },
|
||||
|
||||
const [totalResult, downResult] = await Promise.all([
|
||||
CheckModel.countDocuments(baseMatch),
|
||||
CheckModel.countDocuments({ ...baseMatch, status: false }),
|
||||
]);
|
||||
return checks[0] ?? { totalChecks: 0, resolvedChecks: 0, downChecks: 0, cannotResolveChecks: 0 };
|
||||
|
||||
return {
|
||||
totalChecks: totalResult,
|
||||
downChecks: downResult,
|
||||
};
|
||||
};
|
||||
|
||||
deleteByMonitorId = async (monitorId: string): Promise<number> => {
|
||||
@@ -562,7 +566,7 @@ class MongoChecksRepository implements IChecksRepository {
|
||||
const checks = await CheckModel.find(matchStage).sort({ createdAt: -1 }).limit(25).lean();
|
||||
return {
|
||||
monitorType: "pagespeed" as const,
|
||||
checks: checks.map((doc) => this.toEntity(doc)),
|
||||
checks: this.mapDocuments(checks),
|
||||
};
|
||||
};
|
||||
|
||||
|
||||
@@ -172,9 +172,7 @@ export interface UptimeChecksResult {
|
||||
|
||||
export interface ChecksSummary {
|
||||
totalChecks: number;
|
||||
resolvedChecks: number;
|
||||
downChecks: number;
|
||||
cannotResolveChecks: number;
|
||||
}
|
||||
|
||||
export interface HasResponseTime {
|
||||
|
||||
@@ -111,7 +111,7 @@ const getMonitorByIdQueryValidation = joi.object({
|
||||
status: joi.boolean(),
|
||||
sortOrder: joi.string().valid("asc", "desc"),
|
||||
limit: joi.number(),
|
||||
dateRange: joi.string().valid("hour", "day", "week", "month", "all"),
|
||||
dateRange: joi.string().valid("recent", "hour", "day", "week", "month", "all"),
|
||||
numToDisplay: joi.number(),
|
||||
normalize: joi.boolean(),
|
||||
});
|
||||
@@ -307,7 +307,7 @@ const getChecksQueryValidation = joi.object({
|
||||
sortOrder: joi.string().valid("asc", "desc"),
|
||||
limit: joi.number(),
|
||||
dateRange: joi.string().valid("recent", "hour", "day", "week", "month", "all"),
|
||||
filter: joi.string().valid("all", "down", "resolve"),
|
||||
filter: joi.string().valid("all", "up", "down", "resolve"),
|
||||
ack: joi.boolean(),
|
||||
page: joi.number(),
|
||||
rowsPerPage: joi.number(),
|
||||
@@ -317,8 +317,8 @@ const getChecksQueryValidation = joi.object({
|
||||
const getTeamChecksQueryValidation = joi.object({
|
||||
sortOrder: joi.string().valid("asc", "desc"),
|
||||
limit: joi.number(),
|
||||
dateRange: joi.string().valid("hour", "day", "week", "month", "all"),
|
||||
filter: joi.string().valid("all", "down", "resolve"),
|
||||
dateRange: joi.string().valid("recent", "hour", "day", "week", "month", "all"),
|
||||
filter: joi.string().valid("all", "up", "down", "resolve"),
|
||||
ack: joi.boolean(),
|
||||
page: joi.number(),
|
||||
rowsPerPage: joi.number(),
|
||||
|
||||
Reference in new issue
Block a user