Merge branch 'develop' into v2-select-component

This commit is contained in:
karenvicent
2025-10-09 18:25:38 -04:00
59 changed files with 2615 additions and 469 deletions
+1
View File
@@ -1,3 +1,4 @@
VITE_APP_API_BASE_URL=UPTIME_APP_API_BASE_URL
VITE_APP_API_V2_BASE_URL=UPTIME_APP_API_V2_BASE_URL
VITE_APP_CLIENT_HOST=UPTIME_APP_CLIENT_HOST
VITE_APP_LOG_LEVEL=UPTIME_APP_LOG_LEVEL
+28
View File
@@ -26,6 +26,7 @@
"i18next": "25.4.2",
"joi": "17.13.3",
"mui-color-input": "^6.0.0",
"pretty-ms": "9.3.0",
"react": "18.3.1",
"react-dom": "^18.2.0",
"react-hook-form": "7.63.0",
@@ -5184,6 +5185,18 @@
"url": "https://github.com/sponsors/sindresorhus"
}
},
"node_modules/parse-ms": {
"version": "4.0.0",
"resolved": "https://registry.npmjs.org/parse-ms/-/parse-ms-4.0.0.tgz",
"integrity": "sha512-TXfryirbmq34y8QBwgqCVLi+8oA3oWx2eAnSn62ITyEhEYaWRlVZ2DvMM9eZbMs/RfxPu/PK/aBLyGj4IrqMHw==",
"license": "MIT",
"engines": {
"node": ">=18"
},
"funding": {
"url": "https://github.com/sponsors/sindresorhus"
}
},
"node_modules/path-exists": {
"version": "4.0.0",
"resolved": "https://registry.npmjs.org/path-exists/-/path-exists-4.0.0.tgz",
@@ -5311,6 +5324,21 @@
"url": "https://github.com/prettier/prettier?sponsor=1"
}
},
"node_modules/pretty-ms": {
"version": "9.3.0",
"resolved": "https://registry.npmjs.org/pretty-ms/-/pretty-ms-9.3.0.tgz",
"integrity": "sha512-gjVS5hOP+M3wMm5nmNOucbIrqudzs9v/57bWRHQWLYklXqoXKrVfYW2W9+glfGsqtPgpiz5WwyEEB+ksXIx3gQ==",
"license": "MIT",
"dependencies": {
"parse-ms": "^4.0.0"
},
"engines": {
"node": ">=18"
},
"funding": {
"url": "https://github.com/sponsors/sindresorhus"
}
},
"node_modules/prop-types": {
"version": "15.8.1",
"resolved": "https://registry.npmjs.org/prop-types/-/prop-types-15.8.1.tgz",
+1 -8
View File
@@ -31,6 +31,7 @@
"i18next": "25.4.2",
"joi": "17.13.3",
"mui-color-input": "^6.0.0",
"pretty-ms": "9.3.0",
"react": "18.3.1",
"react-dom": "^18.2.0",
"react-hook-form": "7.63.0",
@@ -46,14 +47,6 @@
"vite-plugin-svgr": "^4.2.0",
"zod": "4.1.11"
},
"unusedDepencies": {
"@solana/wallet-adapter-base": "0.9.25",
"@solana/wallet-adapter-material-ui": "0.16.35",
"@solana/wallet-adapter-react": "0.15.37",
"@solana/wallet-adapter-react-ui": "0.9.37",
"@solana/wallet-adapter-wallets": "0.19.34",
"@solana/web3.js": "1.98.0"
},
"devDependencies": {
"@types/node": "24.5.2",
"@types/react": "^18.2.66",
+5 -10
View File
@@ -9,7 +9,6 @@ import { CssBaseline, GlobalStyles } from "@mui/material";
import { logger } from "./Utils/Logger"; // Import the logger
import { networkService } from "./main";
import { Routes } from "./Routes";
import WalletProvider from "./Components/WalletProvider";
import AppLayout from "@/Components/v1/Layouts/AppLayout";
function App() {
@@ -24,16 +23,12 @@ function App() {
}, []);
return (
/* Extract Themeprovider, baseline and global styles to Styles */
<ThemeProvider theme={mode === "light" ? lightTheme : darkTheme}>
<WalletProvider>
<CssBaseline />
<AppLayout>
<Routes />
</AppLayout>
<ToastContainer />
</WalletProvider>
<CssBaseline />
<AppLayout>
<Routes />
</AppLayout>
<ToastContainer />
</ThemeProvider>
);
}
@@ -15,11 +15,13 @@ export const ActionsMenu = ({ items }: { items: ActionMenuItem[] }) => {
const [anchorEl, setAnchorEl] = useState<null | any>(null);
const open = Boolean(anchorEl);
const handleClick = (event: React.MouseEvent<any>) => {
setAnchorEl(event.currentTarget);
const handleClick = (e: React.MouseEvent<any>) => {
e.stopPropagation();
setAnchorEl(e.currentTarget);
};
const handleClose = () => {
const handleClose = (e: React.MouseEvent<any>) => {
e.stopPropagation();
setAnchorEl(null);
};
@@ -36,8 +38,9 @@ export const ActionsMenu = ({ items }: { items: ActionMenuItem[] }) => {
{items.map((item) => (
<MenuItem
key={item.id}
onClick={() => {
if (item.closeMenu) handleClose();
onClick={(e: React.MouseEvent<HTMLLIElement>) => {
e.stopPropagation();
if (item.closeMenu) handleClose(e);
item.action();
}}
>
@@ -0,0 +1,48 @@
import Stack from "@mui/material/Stack";
import Box from "@mui/material/Box";
import { HeaderAuth } from "@/Components/v2/Auth";
import Logo from "@/assets/icons/checkmate-icon.svg?react";
import type { StackProps } from "@mui/material/Stack";
import { useTheme } from "@mui/material/styles";
import { Typography } from "@mui/material";
interface AuthBasePageProps extends StackProps {
title?: string;
subtitle?: string;
children: React.ReactNode;
}
export const AuthBasePage: React.FC<AuthBasePageProps> = ({
children,
title,
subtitle,
...props
}) => {
const theme = useTheme();
return (
<Stack
gap={theme.spacing(10)}
minHeight="100vh"
{...props}
>
<HeaderAuth />
<Stack
alignItems="center"
margin="auto"
width="100%"
gap={theme.spacing(4)}
>
<Box
width={{ xs: 60, sm: 70, md: 80 }}
mb={theme.spacing(10)}
>
<Logo style={{ width: "100%", height: "100%" }} />
</Box>
<Typography variant="h1">{title}</Typography>
<Typography variant="h1">{subtitle}</Typography>
{children}
</Stack>
</Stack>
);
};
@@ -0,0 +1,26 @@
import Stack from "@mui/material/Stack";
import Typography from "@mui/material/Typography";
import Logo from "@/assets/icons/checkmate-icon.svg?react";
import { useTheme } from "@mui/material/styles";
import { useTranslation } from "react-i18next";
import { LanguageSelector, ThemeSwitch } from "@/Components/v2/Inputs";
export const HeaderAuth = () => {
const theme = useTheme();
const { t } = useTranslation();
return (
<Stack
width={"100%"}
direction="row"
alignItems="center"
justifyContent="flex-end"
py={theme.spacing(4)}
px={theme.spacing(12)}
gap={theme.spacing(4)}
>
<LanguageSelector />
<ThemeSwitch color="red" />
</Stack>
);
};
+2
View File
@@ -0,0 +1,2 @@
export { HeaderAuth } from "./HeaderAuth";
export { AuthBasePage } from "./AuthBasePage";
@@ -0,0 +1,23 @@
import Box from "@mui/material/Box";
import { useTheme } from "@mui/material/styles";
import type { SxProps } from "@mui/material/styles";
type BaseBoxProps = React.PropsWithChildren<{ sx?: SxProps }>;
export const BaseBox: React.FC<BaseBoxProps> = ({ children, sx }) => {
const theme = useTheme();
return (
<Box
sx={{
backgroundColor: theme.palette.primary.main,
border: 1,
borderStyle: "solid",
borderColor: theme.palette.primary.lowContrast,
borderRadius: theme.shape.borderRadius,
...sx,
}}
>
{children}
</Box>
);
};
@@ -1,7 +1,9 @@
import Stack from "@mui/material/Stack";
import { ErrorFallback, EmptyFallback } from "./Fallback";
import type { StackProps } from "@mui/material/Stack";
import { useTheme } from "@mui/material/styles";
import { useTranslation } from "react-i18next";
interface BasePageProps extends StackProps {
children: React.ReactNode;
}
@@ -22,3 +24,57 @@ export const BasePage: React.FC<BasePageProps> = ({
</Stack>
);
};
interface BasePageWithStatesProps extends StackProps {
loading: boolean;
error: any;
items: any[];
page: string;
actionLink?: string;
children: React.ReactNode;
}
const isEmpty = (items: any[]) => {
if (!items) return true;
if (Array.isArray(items) && items.length === 0) return true;
return false;
};
export const BasePageWithStates: React.FC<BasePageWithStatesProps> = ({
loading,
error,
items,
page,
actionLink,
children,
...props
}: BasePageWithStatesProps) => {
const { t } = useTranslation();
if (loading) {
return null;
}
if (error) {
return (
<ErrorFallback
title="Something went wrong..."
subtitle="Please try again later"
/>
);
}
if (isEmpty(items)) {
return (
<EmptyFallback
page={page}
title={t(`${page}Monitor.fallback.title`)}
bullets={t(`${page}Monitor.fallback.checks`, { returnObjects: true })}
actionButtonText={t(`${page}Monitor.fallback.actionButton`)}
actionLink={actionLink || ""}
/>
);
}
return <BasePage {...props}>{children}</BasePage>;
};
@@ -0,0 +1,45 @@
import Stack from "@mui/material/Stack";
import Typography from "@mui/material/Typography";
import CheckOutlined from "@/assets/icons/check-outlined.svg?react";
import { useTheme } from "@mui/material/styles";
export const BulletPointCheck = ({
text,
variant = "info",
}: {
text: string;
noHighlightText?: string;
variant?: "success" | "error" | "info";
}) => {
const theme = useTheme();
const colors: Record<string, string | undefined> = {
success: theme.palette.success.main,
error: theme.palette.error.main,
info: theme.palette.primary.contrastTextSecondary,
};
return (
<Stack
direction="row"
className="check"
gap={theme.spacing(6)}
alignItems="center"
>
<CheckOutlined />
<Typography
component="span"
color={
variant === "info"
? theme.palette.primary.contrastTextSecondary
: colors[variant]
}
fontWeight={450}
sx={{
opacity: 0.9,
}}
>
{text}
</Typography>
</Stack>
);
};
@@ -0,0 +1,23 @@
export const Dot = ({
color = "gray",
size = "4px",
style,
}: {
color?: string;
size?: string;
style?: React.CSSProperties;
}) => {
return (
<span
style={{
content: '""',
width: size,
height: size,
borderRadius: "50%",
backgroundColor: color,
opacity: 0.8,
...style,
}}
/>
);
};
@@ -0,0 +1,158 @@
import Box from "@mui/material/Box";
import Stack from "@mui/material/Stack";
import OutputAnimation from "@/assets/Animations/output.gif";
import DarkmodeOutput from "@/assets/Animations/darkmodeOutput.gif";
import Typography from "@mui/material/Typography";
import { BulletPointCheck } from "@/Components/v2/DesignElements";
import { Button } from "@/Components/v2/Inputs";
import { useNavigate } from "react-router";
import { useMediaQuery } from "@mui/material";
import { useTheme } from "@mui/material/styles";
import { useSelector } from "react-redux";
import type { BoxProps } from "@mui/material";
interface BaseFallbackProps extends BoxProps {
children: React.ReactNode;
}
export const BaseFallback: React.FC<BaseFallbackProps> = ({ children, ...props }) => {
const theme = useTheme();
const mode = useSelector((state: any) => state.ui.mode);
const isSmall = useMediaQuery(theme.breakpoints.down("md"));
return (
<Box
margin={isSmall ? "inherit" : "auto"}
marginTop={isSmall ? "33%" : "auto"}
width={{
sm: "90%",
md: "70%",
lg: "50%",
xl: "40%",
}}
padding={theme.spacing(16)}
bgcolor={theme.palette.primary.main}
position="relative"
border={1}
borderColor={theme.palette.primary.lowContrast}
borderRadius={theme.shape.borderRadius}
overflow="hidden"
sx={{
borderStyle: "dashed",
}}
{...props}
>
<Stack
alignItems="center"
gap={theme.spacing(20)}
sx={{
width: "fit-content",
margin: "auto",
marginTop: "100px",
}}
>
<Box
component="img"
src={mode === "light" ? OutputAnimation : DarkmodeOutput}
bgcolor="transparent"
alt="Loading animation"
width="100%"
sx={{
zIndex: 1,
border: "none",
borderRadius: theme.spacing(8),
}}
/>
<Stack
gap={theme.spacing(4)}
alignItems="center"
maxWidth={"300px"}
zIndex={1}
>
{children}
</Stack>
</Stack>
</Box>
);
};
export const ErrorFallback = ({
title,
subtitle,
}: {
title: string;
subtitle: string;
}) => {
const theme = useTheme();
return (
<BaseFallback>
<Typography
variant="h1"
marginY={theme.spacing(4)}
color={theme.palette.primary.contrastTextTertiary}
>
{title}
</Typography>
<Typography>{subtitle}</Typography>
</BaseFallback>
);
};
export const EmptyFallback = ({
page,
title,
bullets,
actionButtonText,
actionLink,
}: {
page: string;
title: string;
bullets: any;
actionButtonText: string;
actionLink: string;
}) => {
const theme = useTheme();
const navigate = useNavigate();
return (
<BaseFallback>
<Stack
gap={theme.spacing(10)}
zIndex={1}
alignItems="center"
>
<Typography
component="h1"
color={theme.palette.primary.contrastText}
>
{title}
</Typography>
<Stack
sx={{
flexWrap: "wrap",
gap: theme.spacing(2),
maxWidth: { xs: "90%", md: "80%", lg: "75%" },
}}
>
{bullets?.map((bullet: string, index: number) => (
<BulletPointCheck
text={bullet}
key={`${(page + "Monitors").trim().split(" ")[0]}-${index}`}
/>
))}
</Stack>
<Stack>
<Button
variant="contained"
color="accent"
onClick={() => navigate(actionLink)}
>
{actionButtonText}
</Button>
</Stack>
</Stack>
</BaseFallback>
);
};
@@ -0,0 +1,44 @@
import Stack from "@mui/material/Stack";
import Box from "@mui/material/Box";
import { useTheme } from "@mui/material/styles";
export const PulseDot = ({ color }: { color: string }) => {
const theme = useTheme();
return (
<Stack
width="26px"
height="24px"
alignItems="center"
justifyContent="center"
>
<Box
minWidth="18px"
minHeight="18px"
sx={{
position: "relative",
backgroundColor: color,
borderRadius: "50%",
"&::before": {
content: `""`,
position: "absolute",
width: "100%",
height: "100%",
backgroundColor: "inherit",
borderRadius: "50%",
animation: "ripple 1.8s ease-out infinite",
},
"&::after": {
content: `""`,
position: "absolute",
width: "7px",
height: "7px",
borderRadius: "50%",
backgroundColor: theme.palette.accent.contrastText,
top: "50%",
left: "50%",
transform: "translate(-50%, -50%)",
},
}}
/>
</Stack>
);
};
@@ -0,0 +1,57 @@
import Stack from "@mui/material/Stack";
import Typography from "@mui/material/Typography";
import { useTheme } from "@mui/material/styles";
import { useMediaQuery } from "@mui/material";
import type { PaletteKey } from "@/Utils/Theme/v2/theme";
import { BaseBox } from "@/Components/v2/DesignElements";
type GradientBox = React.PropsWithChildren<{ palette?: PaletteKey }>;
export const GradientBox: React.FC<GradientBox> = ({ children, palette }) => {
const theme = useTheme();
const isSmall = useMediaQuery(theme.breakpoints.down("md"));
const bg = palette
? `linear-gradient(to bottom right, ${theme.palette[palette].main} 30%, ${theme.palette[palette].lowContrast} 70%)`
: `linear-gradient(340deg, ${theme.palette.tertiary.main} 10%, ${theme.palette.primary.main} 45%)`;
return (
<BaseBox
sx={{
padding: `${theme.spacing(4)} ${theme.spacing(8)}`,
width: isSmall
? `calc(50% - (1 * ${theme.spacing(8)} / 2))`
: `calc(25% - (3 * ${theme.spacing(8)} / 4))`,
background: bg,
}}
>
{children}
</BaseBox>
);
};
type StatBoxProps = React.PropsWithChildren<{
title: string;
subtitle: string;
palette?: PaletteKey;
}>;
export const StatBox: React.FC<StatBoxProps> = ({
title,
subtitle,
palette,
children,
}) => {
const theme = useTheme();
const textColor = palette ? theme.palette[palette].contrastText : "inherit";
return (
<GradientBox palette={palette}>
<Stack>
<Typography color={textColor}>{title}</Typography>
<Typography color={textColor}>{subtitle}</Typography>
{children}
</Stack>
</GradientBox>
);
};
@@ -1,6 +1,7 @@
import Stack from "@mui/material/Stack";
import Typography from "@mui/material/Typography";
import Box from "@mui/material/Box";
import { BaseBox } from "@/Components/v2/DesignElements";
import Background from "@/assets/Images/background-grid.svg?react";
import { useTranslation } from "react-i18next";
@@ -11,15 +12,13 @@ type StatusBoxProps = React.PropsWithChildren<{}>;
export const BGBox: React.FC<StatusBoxProps> = ({ children }) => {
const theme = useTheme();
return (
<Box
position="relative"
flex={1}
border={1}
bgcolor={theme.palette.primary.main}
borderColor={theme.palette.primary.lowContrast}
borderRadius={theme.shape.borderRadius}
p={theme.spacing(8)}
overflow="hidden"
<BaseBox
sx={{
overflow: "hidden",
position: "relative",
flex: 1,
padding: theme.spacing(8),
}}
>
<Box
position="absolute"
@@ -29,7 +28,7 @@ export const BGBox: React.FC<StatusBoxProps> = ({ children }) => {
<Background />
</Box>
{children}
</Box>
</BaseBox>
);
};
@@ -0,0 +1,41 @@
import Box from "@mui/material/Box";
import { BaseBox } from "@/Components/v2/DesignElements";
import type { MonitorStatus } from "@/Types/Monitor";
import { getStatusPalette } from "@/Utils/MonitorUtils";
import { useTheme } from "@mui/material/styles";
export const StatusLabel = ({
status,
isActive,
}: {
status: MonitorStatus;
isActive?: boolean;
}) => {
const theme = useTheme();
const palette = getStatusPalette(status);
const transformedText = status.charAt(0).toUpperCase() + status.slice(1).toLowerCase();
return (
<BaseBox
sx={{
display: "inline-flex",
flexDirection: "row",
alignItems: "center",
justifyContent: "center",
padding: theme.spacing(3, 5),
color: theme.palette[palette].main,
borderColor: theme.palette[palette].lowContrast,
}}
>
<Box
width={7}
height={7}
bgcolor={theme.palette[palette].lowContrast}
borderRadius="50%"
marginRight="5px"
/>
{isActive === false ? "Paused" : transformedText}
</BaseBox>
);
};
@@ -5,7 +5,19 @@ import TableCell from "@mui/material/TableCell";
import TableContainer from "@mui/material/TableContainer";
import TableHead from "@mui/material/TableHead";
import TableRow from "@mui/material/TableRow";
import IconButton from "@mui/material/IconButton";
import LastPageIcon from "@mui/icons-material/LastPage";
import FirstPageIcon from "@mui/icons-material/FirstPage";
import KeyboardArrowLeft from "@mui/icons-material/KeyboardArrowLeft";
import KeyboardArrowRight from "@mui/icons-material/KeyboardArrowRight";
import Box from "@mui/material/Box";
import TablePagination from "@mui/material/TablePagination";
import type { TablePaginationProps } from "@mui/material/TablePagination";
import { useTheme } from "@mui/material/styles";
import { useMediaQuery } from "@mui/material";
export type Header<T> = {
id: number | string;
content: React.ReactNode;
@@ -16,12 +28,16 @@ export type Header<T> = {
type DataTableProps<T extends { id?: string | number; _id?: string | number }> = {
headers: Header<T>[];
data: T[];
onRowClick?: (row: T) => void;
};
export function DataTable<T extends { id?: string | number; _id?: string | number }>({
headers,
data,
}: DataTableProps<T>) {
export function DataTable<
T extends {
id?: string | number;
_id?: string | number;
onRowClick?: (row: T) => void;
},
>({ headers, data, onRowClick }: DataTableProps<T>) {
const theme = useTheme();
if (data.length === 0 || headers.length === 0) return <div>No data</div>;
return (
@@ -65,7 +81,11 @@ export function DataTable<T extends { id?: string | number; _id?: string | numbe
const key = row.id || row._id || Math.random();
return (
<TableRow key={key}>
<TableRow
key={key}
sx={{ cursor: onRowClick ? "pointer" : "default" }}
onClick={() => (onRowClick ? onRowClick(row) : null)}
>
{headers.map((header, index) => {
return (
<TableCell
@@ -87,3 +107,109 @@ export function DataTable<T extends { id?: string | number; _id?: string | numbe
</TableContainer>
);
}
interface TablePaginationActionsProps {
count: number;
page: number;
rowsPerPage: number;
onPageChange: (event: React.MouseEvent<HTMLButtonElement>, newPage: number) => void;
}
function TablePaginationActions(props: TablePaginationActionsProps) {
const theme = useTheme();
const { count, page, rowsPerPage, onPageChange } = props;
const handleFirstPageButtonClick = (event: React.MouseEvent<HTMLButtonElement>) => {
onPageChange(event, 0);
};
const handleBackButtonClick = (event: React.MouseEvent<HTMLButtonElement>) => {
onPageChange(event, page - 1);
};
const handleNextButtonClick = (event: React.MouseEvent<HTMLButtonElement>) => {
onPageChange(event, page + 1);
};
const handleLastPageButtonClick = (event: React.MouseEvent<HTMLButtonElement>) => {
onPageChange(event, Math.max(0, Math.ceil(count / rowsPerPage) - 1));
};
return (
<Box
sx={{ flexShrink: 0, ml: 2.5 }}
className="table-pagination-actions"
>
<IconButton
onClick={handleFirstPageButtonClick}
disabled={page === 0}
aria-label="first page"
>
{theme.direction === "rtl" ? <LastPageIcon /> : <FirstPageIcon />}
</IconButton>
<IconButton
onClick={handleBackButtonClick}
disabled={page === 0}
aria-label="previous page"
>
{theme.direction === "rtl" ? <KeyboardArrowRight /> : <KeyboardArrowLeft />}
</IconButton>
<IconButton
onClick={handleNextButtonClick}
disabled={page >= Math.ceil(count / rowsPerPage) - 1}
aria-label="next page"
>
{theme.direction === "rtl" ? <KeyboardArrowLeft /> : <KeyboardArrowRight />}
</IconButton>
<IconButton
onClick={handleLastPageButtonClick}
disabled={page >= Math.ceil(count / rowsPerPage) - 1}
aria-label="last page"
>
{theme.direction === "rtl" ? <FirstPageIcon /> : <LastPageIcon />}
</IconButton>
</Box>
);
}
export const Pagination: React.FC<TablePaginationProps> = ({ ...props }) => {
const isSmall = useMediaQuery((theme: any) => theme.breakpoints.down("sm"));
const theme = useTheme();
return (
<TablePagination
ActionsComponent={TablePaginationActions}
rowsPerPageOptions={[5, 10, 25]}
{...props}
sx={{
"& .MuiTablePagination-toolbar": {
display: isSmall ? "grid" : "flex",
},
"& .MuiTablePagination-selectLabel": {
gridColumn: "1",
gridRow: "1",
justifySelf: "center",
},
"& .MuiTablePagination-select": {
gridColumn: "2",
gridRow: "1",
justifySelf: "center",
},
"& .MuiTablePagination-displayedRows": {
gridColumn: "2",
gridRow: "2",
justifySelf: "center ",
},
"& .table-pagination-actions": {
gridColumn: "1",
gridRow: "2",
justifySelf: "center",
},
"& .MuiSelect-select": {
border: 1,
borderColor: theme.palette.primary.lowContrast,
borderRadius: theme.shape.borderRadius,
},
}}
/>
);
};
@@ -1,4 +1,9 @@
export { SplitBox as HorizontalSplitBox, ConfigBox } from "./SplitBox";
export { BasePage } from "./BasePage";
export { BasePage, BasePageWithStates } from "./BasePage";
export { BGBox, UpStatusBox, DownStatusBox, PausedStatusBox } from "./StatusBox";
export { DataTable as Table } from "./Table";
export { DataTable as Table, Pagination } from "./Table";
export { GradientBox, StatBox } from "./StatBox";
export { BaseBox } from "./BaseBox";
export { StatusLabel } from "./StatusLabel";
export { BaseFallback, ErrorFallback, EmptyFallback } from "./Fallback";
export { BulletPointCheck } from "./BulletPointCheck";
+3 -2
View File
@@ -1,10 +1,11 @@
import Button from "@mui/material/Button";
import type { ButtonProps } from "@mui/material/Button";
export const ButtonInput: React.FC<ButtonProps> = ({ ...props }) => {
export const ButtonInput: React.FC<ButtonProps> = ({ sx, ...props }) => {
return (
<Button
{...props}
sx={{ textTransform: "none", height: 34, fontWeight: 400, borderRadius: 2 }}
sx={{ textTransform: "none", height: 34, fontWeight: 400, borderRadius: 2, ...sx }}
/>
);
};
@@ -0,0 +1,13 @@
import ButtonGroup from "@mui/material/ButtonGroup";
import type { ButtonGroupProps } from "@mui/material/ButtonGroup";
export const ButtonGroupInput: React.FC<ButtonGroupProps> = ({
orientation,
...props
}) => {
return (
<ButtonGroup
orientation={orientation}
{...props}
/>
);
};
@@ -0,0 +1,64 @@
import "flag-icons/css/flag-icons.min.css";
import { Select } from "@/Components/v2/Inputs";
import MenuItem from "@mui/material/MenuItem";
import Stack from "@mui/material/Stack";
import Typography from "@mui/material/Typography";
import { useTranslation } from "react-i18next";
import { useTheme } from "@mui/material/styles";
import { useDispatch } from "react-redux";
import { useSelector } from "react-redux";
import { setLanguage } from "@/Features/UI/uiSlice";
import type { SelectChangeEvent } from "@mui/material/Select";
export const LanguageSelector = () => {
const { i18n } = useTranslation();
const theme = useTheme();
const dispatch = useDispatch();
const language = useSelector((state: any) => state.ui.language);
const languages = Object.keys(i18n.options.resources || {});
const languageMap: Record<string, string> = {
cs: "cz",
ja: "jp",
uk: "ua",
vi: "vn",
};
const handleChange = (event: SelectChangeEvent<unknown>) => {
const newLang = event.target.value;
dispatch(setLanguage(newLang));
};
const languagesForDisplay = languages.map((l) => {
let formattedLanguage = l === "en" ? "gb" : l;
formattedLanguage = formattedLanguage.includes("-")
? formattedLanguage.split("-")[1].toLowerCase()
: formattedLanguage;
formattedLanguage = languageMap[formattedLanguage] || formattedLanguage;
const flag = formattedLanguage ? `fi fi-${formattedLanguage}` : null;
return (
<MenuItem
key={l}
value={l}
>
<Stack
direction="row"
gap={theme.spacing(4)}
>
{flag && <span className={flag} />}
<Typography textTransform={"uppercase"}>{l}</Typography>
</Stack>
</MenuItem>
);
});
return (
<Select
value={language}
onChange={handleChange}
>
{languagesForDisplay}
</Select>
);
};
@@ -2,6 +2,7 @@ import { Typography, Select } from "@mui/material";
import MenuItem from "@mui/material/MenuItem";
import { forwardRef } from "react";
import type { SelectProps } from "@mui/material/Select";
import { useTheme } from "@mui/material/styles";
import KeyboardArrowDownIcon from "@mui/icons-material/KeyboardArrowDown";
type ItemId = string | number;
interface SelectItem {
@@ -20,12 +21,21 @@ export const SelectInput = forwardRef<HTMLDivElement, CustomSelectProps>(
{ items, placeholder, isHidden = false, hasError = false, ...props },
ref
) {
const theme = useTheme();
return (
<Select
sx={{
height: "34px",
"& .MuiSelect-select": {
padding: "0",
},
"& .MuiOutlinedInput-notchedOutline": {
borderRadius: theme.shape.borderRadius,
borderColor: theme.palette.primary.lowContrast,
},
"&:hover .MuiOutlinedInput-notchedOutline": {
borderColor: theme.palette.primary.lowContrast,
},
}}
error={hasError}
ref={ref}
@@ -0,0 +1,37 @@
import Typography from "@mui/material/Typography";
import Stack from "@mui/material/Stack";
import Link from "@mui/material/Link";
import { Link as RouterLink } from "react-router-dom";
import { useTheme } from "@mui/material/styles";
export const TextLink = ({
text,
linkText,
href,
target = "_self",
}: {
text: string;
linkText: string;
href: string;
target?: string;
}) => {
const theme = useTheme();
return (
<Stack
direction="row"
gap={theme.spacing(4)}
>
<Typography>{text}</Typography>
<Link
color="accent"
to={href}
component={RouterLink}
target={target}
>
{linkText}
</Link>
</Stack>
);
};
@@ -0,0 +1,135 @@
import { useTheme } from "@mui/material/styles";
import { useDispatch, useSelector } from "react-redux";
import { setMode } from "@/Features/UI/uiSlice.js";
import { useTranslation } from "react-i18next";
import IconButton from "@mui/material/IconButton";
const SunAndMoonIcon = () => {
const theme = useTheme();
return (
<svg
className="sun-and-moon"
aria-hidden="true"
width="24"
height="24"
viewBox="0 0 24 24"
>
<mask
className="moon"
id="moon-mask"
>
<rect
x="0"
y="0"
width="100%"
height="100%"
fill="#fff"
/>
<circle
cx="24"
cy="10"
r="6"
fill="#000"
/>
</mask>
<circle
className="sun"
cx="12"
cy="12"
r="6"
fill={theme.palette.primary.contrastTextSecondary}
mask="url(#moon-mask)"
/>
<g
className="sun-beams"
stroke={theme.palette.primary.contrastTextSecondary}
>
<line
x1="12"
y1="1"
x2="12"
y2="3"
/>
<line
x1="12"
y1="21"
x2="12"
y2="23"
/>
<line
x1="4.22"
y1="4.22"
x2="5.64"
y2="5.64"
/>
<line
x1="18.36"
y1="18.36"
x2="19.78"
y2="19.78"
/>
<line
x1="1"
y1="12"
x2="3"
y2="12"
/>
<line
x1="21"
y1="12"
x2="23"
y2="12"
/>
<line
x1="4.22"
y1="19.78"
x2="5.64"
y2="18.36"
/>
<line
x1="18.36"
y1="5.64"
x2="19.78"
y2="4.22"
/>
</g>
</svg>
);
};
export const ThemeSwitch = ({
width = 48,
height = 48,
}: {
width?: number;
height?: number;
}) => {
const mode = useSelector((state: any) => state.ui.mode);
const dispatch = useDispatch();
const { t } = useTranslation();
const handleChange = () => {
dispatch(setMode(mode === "light" ? "dark" : "light"));
};
return (
<IconButton
id="theme-toggle"
title={t("common.buttons.toggleTheme")}
className={`theme-${mode}`}
aria-label="auto"
aria-live="polite"
onClick={handleChange}
sx={{
width,
height,
display: "flex",
alignItems: "center",
justifyContent: "center",
}}
>
<SunAndMoonIcon />
</IconButton>
);
};
@@ -1 +1,7 @@
export { ButtonInput as Button } from "./Button";
export { ButtonGroupInput as ButtonGroup } from "./ButtonGroup";
export { TextInput } from "./TextInput";
export { SelectInput as Select } from "./Select";
export { LanguageSelector } from "./LanguageSelector";
export { ThemeSwitch } from "./ThemeSwitch";
export { TextLink } from "./TextLink";
@@ -6,7 +6,6 @@ const RootLayout = () => {
const theme = useTheme();
return (
<Stack
overflow={"hidden"}
direction="row"
minHeight="100vh"
>
@@ -1,12 +1,12 @@
import Notifications from "@/assets/icons/notifications.svg?react";
// import Notifications from "@/assets/icons/notifications.svg?react";
import Monitors from "@/assets/icons/monitors.svg?react";
import PageSpeed from "@/assets/icons/page-speed.svg?react";
import Integrations from "@/assets/icons/integrations.svg?react";
import Incidents from "@/assets/icons/incidents.svg?react";
import StatusPages from "@/assets/icons/status-pages.svg?react";
import Maintenance from "@/assets/icons/maintenance.svg?react";
import Logs from "@/assets/icons/logs.svg?react";
import Settings from "@/assets/icons/settings.svg?react";
// import PageSpeed from "@/assets/icons/page-speed.svg?react";
// import Integrations from "@/assets/icons/integrations.svg?react";
// import Incidents from "@/assets/icons/incidents.svg?react";
// import StatusPages from "@/assets/icons/status-pages.svg?react";
// import Maintenance from "@/assets/icons/maintenance.svg?react";
// import Logs from "@/assets/icons/logs.svg?react";
// import Settings from "@/assets/icons/settings.svg?react";
import Support from "@/assets/icons/support.svg?react";
import Discussions from "@/assets/icons/discussions.svg?react";
import Docs from "@/assets/icons/docs.svg?react";
@@ -14,25 +14,25 @@ import ChangeLog from "@/assets/icons/changeLog.svg?react";
export const getMenu = (t: Function) => [
{ name: t("menu.uptime"), path: "v2/uptime", icon: <Monitors /> },
{ name: t("menu.pagespeed"), path: "pagespeed", icon: <PageSpeed /> },
// { name: t("menu.pagespeed"), path: "pagespeed", icon: <PageSpeed /> },
{ name: t("menu.infrastructure"), path: "infrastructure", icon: <Integrations /> },
{
name: t("menu.notifications"),
path: "notifications",
icon: <Notifications />,
},
{ name: t("menu.incidents"), path: "incidents", icon: <Incidents /> },
// { name: t("menu.infrastructure"), path: "infrastructure", icon: <Integrations /> },
// {
// name: t("menu.notifications"),
// path: "notifications",
// icon: <Notifications />,
// },
// { name: t("menu.incidents"), path: "incidents", icon: <Incidents /> },
{ name: t("menu.statusPages"), path: "status", icon: <StatusPages /> },
{ name: t("menu.maintenance"), path: "maintenance", icon: <Maintenance /> },
{ name: t("menu.logs"), path: "logs", icon: <Logs /> },
// { name: t("menu.statusPages"), path: "status", icon: <StatusPages /> },
// { name: t("menu.maintenance"), path: "maintenance", icon: <Maintenance /> },
// { name: t("menu.logs"), path: "logs", icon: <Logs /> },
{
name: t("menu.settings"),
icon: <Settings />,
path: "settings",
},
// {
// name: t("menu.settings"),
// icon: <Settings />,
// path: "settings",
// },
];
export const getBottomMenu = (t: Function) => [
@@ -37,6 +37,9 @@ export const SideBar = () => {
<Stack
component="aside"
position="sticky"
top={0}
minHeight={"100vh"}
maxHeight={"100vh"}
paddingTop={theme.spacing(6)}
paddingBottom={theme.spacing(6)}
gap={theme.spacing(6)}
@@ -0,0 +1,84 @@
import { BaseChart } from "./HistogramStatus";
import Stack from "@mui/material/Stack";
import Typography from "@mui/material/Typography";
import AverageResponseIcon from "@/assets/icons/average-response-icon.svg?react";
import { Cell, RadialBarChart, RadialBar, ResponsiveContainer } from "recharts";
import { getResponseTimeColor } from "@/Utils/MonitorUtils";
import { useTheme } from "@mui/material/styles";
export const ChartAvgResponse = ({ avg, max }: { avg: number; max: number }) => {
const theme = useTheme();
const chartData = [
{ name: "max", value: max - avg, color: "transparent" },
{ name: "avg", value: avg, color: "red" },
];
const palette = getResponseTimeColor(avg);
const msg: Record<string, string> = {
success: "Excellent",
warning: "Average",
danger: "Poor",
};
return (
<BaseChart
icon={<AverageResponseIcon />}
title={"Average response time"}
>
<Stack
height="100%"
position={"relative"}
justifyContent={"space-between"}
>
<ResponsiveContainer
width="100%"
height={155}
>
<RadialBarChart
cy="89%"
data={chartData}
startAngle={180}
endAngle={0}
innerRadius={"120%"}
outerRadius={"200%"}
>
<RadialBar
dataKey="value"
background={{ fill: theme.palette[palette].lowContrast }}
>
<Cell visibility={"hidden"} />
<Cell fill={theme.palette[palette].main} />
</RadialBar>
</RadialBarChart>
</ResponsiveContainer>
<Stack
direction={"row"}
justifyContent={"space-between"}
>
<Typography variant="body2">Low</Typography>
<Typography variant="body2">High</Typography>
</Stack>
<Stack
position="absolute"
top={"50%"}
right={"50%"}
sx={{
transform: "translate(50%, 0%)",
}}
>
<Typography
variant="h6"
textAlign={"center"}
>
{msg[palette]}
</Typography>
<Typography
variant="h6"
textAlign={"center"}
>{`${avg?.toFixed()}ms`}</Typography>
</Stack>
</Stack>
</BaseChart>
);
};
@@ -0,0 +1,163 @@
import { BaseChart } from "./HistogramStatus";
import { BaseBox } from "../DesignElements";
import ResponseTimeIcon from "@/assets/icons/response-time-icon.svg?react";
import { normalizeResponseTimes } from "@/Utils/DataUtils";
import {
AreaChart,
Area,
XAxis,
Tooltip,
CartesianGrid,
ResponsiveContainer,
Text,
} from "recharts";
import Typography from "@mui/material/Typography";
import {
formatDateWithTz,
tickDateFormatLookup,
tooltipDateFormatLookup,
} from "@/Utils/TimeUtils";
import { useTheme } from "@mui/material/styles";
import type { GroupedCheck } from "@/Types/Check";
import { useSelector } from "react-redux";
type XTickProps = {
x: number;
y: number;
payload: { value: any };
range: string;
};
const XTick: React.FC<XTickProps> = ({ x, y, payload, range }) => {
const format = tickDateFormatLookup(range);
const theme = useTheme();
const uiTimezone = useSelector((state: any) => state.ui.timezone);
return (
<Text
x={x}
y={y + 10}
textAnchor="middle"
fill={theme.palette.primary.contrastTextTertiary}
fontSize={11}
fontWeight={400}
>
{formatDateWithTz(payload?.value, format, uiTimezone)}
</Text>
);
};
type ResponseTimeToolTipProps = {
active?: boolean | undefined;
payload?: any[];
label?: string;
range: string;
theme: any;
uiTimezone: string;
};
const ResponseTimeToolTip: React.FC<ResponseTimeToolTipProps> = ({
active,
payload,
label,
range,
theme,
uiTimezone,
}) => {
if (!label) return null;
if (!payload) return null;
if (!active) return null;
const format = tooltipDateFormatLookup(range);
const responseTime = Math.floor(payload?.[0]?.payload?.avgResponseTime || 0);
return (
<BaseBox sx={{ py: theme.spacing(2), px: theme.spacing(4) }}>
<Typography>{formatDateWithTz(label, format, uiTimezone)}</Typography>
<Typography>Response time: {responseTime} ms</Typography>
</BaseBox>
);
};
export const ChartResponseTime = ({
checks,
range,
}: {
checks: GroupedCheck[];
range: string;
}) => {
const theme = useTheme();
const uiTimezone = useSelector((state: any) => state.ui.timezone);
const normalized = normalizeResponseTimes<GroupedCheck, "avgResponseTime">(
checks,
"avgResponseTime"
);
return (
<BaseChart
icon={<ResponseTimeIcon />}
title="Response times"
>
<ResponsiveContainer
width="100%"
height={300}
>
<AreaChart data={normalized?.slice().reverse()}>
<CartesianGrid
stroke={theme.palette.primary.lowContrast}
strokeWidth={1}
strokeOpacity={1}
fill="transparent"
vertical={false}
/>
<defs>
<linearGradient
id="colorUv"
x1="0"
y1="0"
x2="0"
y2="1"
>
<stop
offset="0%"
stopColor={theme.palette.accent.main}
stopOpacity={0.8}
/>
<stop
offset="100%"
stopColor={theme.palette.accent.light}
stopOpacity={0}
/>
</linearGradient>
</defs>
<XAxis
axisLine={false}
tickLine={false}
dataKey="_id"
tick={(props) => (
<XTick
{...props}
range={range}
/>
)}
/>
<Tooltip
content={(props) => (
<ResponseTimeToolTip
{...props}
range={range}
theme={theme}
uiTimezone={uiTimezone}
/>
)}
/>
<Area
type="monotone"
dataKey="normalResponseTime"
stroke={theme.palette.accent.main}
fill="url(#colorUv)"
/>
</AreaChart>
</ResponsiveContainer>
</BaseChart>
);
};
@@ -0,0 +1,67 @@
import Stack from "@mui/material/Stack";
import { MonitorStatus } from "@/Components/v2/Monitors/MonitorStatus";
import { ButtonGroup, Button } from "@/Components/v2/Inputs";
import SettingsOutlinedIcon from "@mui/icons-material/SettingsOutlined";
import PauseOutlinedIcon from "@mui/icons-material/PauseOutlined";
import PlayArrowOutlinedIcon from "@mui/icons-material/PlayArrowOutlined";
import EmailIcon from "@mui/icons-material/Email";
import BugReportOutlinedIcon from "@mui/icons-material/BugReportOutlined";
import { useMediaQuery } from "@mui/material";
import { useTranslation } from "react-i18next";
import { useTheme } from "@mui/material/styles";
import type { IMonitor } from "@/Types/Monitor";
export const HeaderControls = ({
monitor,
patch,
isPatching,
refetch,
}: {
monitor: IMonitor;
patch: Function;
isPatching: boolean;
refetch: Function;
}) => {
const { t } = useTranslation();
const theme = useTheme();
const isSmall = useMediaQuery(theme.breakpoints.down("md"));
return (
<Stack
direction={isSmall ? "column" : "row"}
spacing={isSmall ? theme.spacing(4) : 0}
justifyContent={"space-between"}
>
<MonitorStatus monitor={monitor} />
<Stack
direction={"row"}
spacing={theme.spacing(2)}
>
<ButtonGroup
orientation={isSmall ? "vertical" : "horizontal"}
fullWidth={isSmall}
variant="contained"
color="secondary"
>
<Button startIcon={<EmailIcon />}>{t("sendTestNotifications")}</Button>
<Button startIcon={<BugReportOutlinedIcon />}>{t("menu.incidents")}</Button>
<Button
loading={isPatching}
onClick={async () => {
await patch(`/monitors/${monitor._id}/active`);
refetch();
}}
startIcon={
monitor?.isActive ? <PauseOutlinedIcon /> : <PlayArrowOutlinedIcon />
}
>
{monitor?.isActive ? t("pause") : t("resume")}
</Button>
<Button startIcon={<SettingsOutlinedIcon />}>{t("configure")}</Button>
</ButtonGroup>
</Stack>
</Stack>
);
};
@@ -0,0 +1,62 @@
import Stack from "@mui/material/Stack";
import { ButtonGroup, Button } from "@/Components/v2/Inputs";
import { useTheme } from "@mui/material/styles";
import Typography from "@mui/material/Typography";
import { useMediaQuery } from "@mui/material";
export const HeaderRange = ({
range,
setRange,
loading,
}: {
range: string;
setRange: Function;
loading: boolean;
}) => {
const theme = useTheme();
const isSmall = useMediaQuery(theme.breakpoints.down("md"));
return (
<Stack
gap={theme.spacing(9)}
direction={isSmall ? "column" : "row"}
alignItems={"center"}
justifyContent="flex-end"
>
<Typography variant="body2">{`Showing statistics for past ${range}`}</Typography>
<ButtonGroup
orientation={isSmall ? "vertical" : "horizontal"}
fullWidth={isSmall}
variant="contained"
color={"primary"}
>
<Button
color={range === "2h" ? "secondary" : "inherit"}
onClick={() => setRange("2h")}
loading={loading}
>
Recent
</Button>
<Button
color={range === "24h" ? "secondary" : "inherit"}
onClick={() => setRange("24h")}
loading={loading}
>
Day
</Button>
<Button
color={range === "7d" ? "secondary" : "inherit"}
onClick={() => setRange("7d")}
loading={loading}
>
7 days
</Button>
<Button
color={range === "30d" ? "secondary" : "inherit"}
onClick={() => setRange("30d")}
loading={loading}
>
30 days
</Button>
</ButtonGroup>
</Stack>
);
};
@@ -3,24 +3,20 @@ import Box from "@mui/material/Box";
import { useTheme } from "@mui/material/styles";
import type { Check } from "@/Types/Check";
import { HistogramResponseTimeTooltip } from "@/Components/v2/Monitors/HistogramResponseTimeTooltip";
import { normalizeResponseTimes } from "@/Utils/DataUtils";
export const HistogramResponseTime = ({ checks }: { checks: Check[] }) => {
const normalChecks = normalizeResponseTimes(checks, "responseTime");
let data = Array<any>();
data = checks.map((c) => Math.max(c.responseTime, 1));
const logResponses = data.map((r) => Math.log10(r));
const logMin = Math.min(...logResponses);
const logMax = Math.max(...logResponses);
if (!checks) {
if (!normalChecks || normalChecks.length === 0) {
return null;
}
if (checks.length !== 25) {
const placeholders = Array(25 - checks.length).fill("placeholder");
data = [...checks, ...placeholders];
if (normalChecks.length !== 25) {
const placeholders = Array(25 - normalChecks.length).fill("placeholder");
data = [...normalChecks, ...placeholders];
} else {
data = checks;
data = normalChecks;
}
const theme = useTheme();
@@ -38,14 +34,6 @@ export const HistogramResponseTime = ({ checks }: { checks: Check[] }) => {
}}
>
{data.map((check, index) => {
const safeResponse = Math.max(check.responseTime, 1);
const logValue = Math.log10(safeResponse);
const minHeight = 10;
const barHeight =
logMax === logMin
? 100
: Math.max(minHeight, ((logValue - logMin) / (logMax - logMin)) * 100);
if (check === "placeholder") {
return (
<Box
@@ -78,7 +66,7 @@ export const HistogramResponseTime = ({ checks }: { checks: Check[] }) => {
position="absolute"
bottom={0}
width="100%"
height={`${barHeight}%`}
height={`${check.normalResponseTime}%`}
bgcolor={
check.status
? theme.palette.success.lowContrast
@@ -0,0 +1,218 @@
import Stack from "@mui/material/Stack";
import Box from "@mui/material/Box";
import Typography from "@mui/material/Typography";
import { BaseBox } from "@/Components/v2/DesignElements";
import { ResponsiveContainer, BarChart, XAxis, Bar, Cell } from "recharts";
import UptimeIcon from "@/assets/icons/uptime-icon.svg?react";
import IncidentsIcon from "@/assets/icons/incidents.svg?react";
import type { GroupedCheck } from "@/Types/Check";
import type { MonitorStatus } from "@/Types/Monitor";
import { normalizeResponseTimes } from "@/Utils/DataUtils";
import { useState } from "react";
import { formatDateWithTz } from "@/Utils/TimeUtils";
import { useSelector } from "react-redux";
import { useTheme } from "@mui/material/styles";
import { getResponseTimeColor } from "@/Utils/MonitorUtils";
const XLabel = ({
p1,
p2,
range,
}: {
p1: GroupedCheck;
p2: GroupedCheck;
range: string;
}) => {
const theme = useTheme();
const uiTimezone = useSelector((state: any) => state.ui.timezone);
const dateFormat = range === "day" ? "MMM D, h:mm A" : "MMM D";
return (
<>
<text
x={0}
y="100%"
dy={-3}
textAnchor="start"
fontSize={11}
fill={theme.palette.primary.contrastTextTertiary}
>
{formatDateWithTz(p1._id, dateFormat, uiTimezone)}
</text>
<text
x="100%"
y="100%"
dy={-3}
textAnchor="end"
fontSize={11}
fill={theme.palette.primary.contrastTextTertiary}
>
{formatDateWithTz(p2._id, dateFormat, uiTimezone)}
</text>
</>
);
};
type BaseChartProps = React.PropsWithChildren<{
icon: React.ReactNode;
title: string;
}>;
export const BaseChart: React.FC<BaseChartProps> = ({ children, icon, title }) => {
const theme = useTheme();
return (
<BaseBox
sx={{
padding: theme.spacing(8),
display: "flex",
flex: 1,
}}
>
<Stack
gap={theme.spacing(8)}
flex={1}
>
<Stack
direction="row"
alignItems={"center"}
gap={theme.spacing(4)}
>
<BaseBox
sx={{
display: "flex",
alignItems: "center",
justifyContent: "center",
width: 34,
height: 34,
backgroundColor: theme.palette.tertiary.main,
"& svg": {
width: 20,
height: 20,
"& path": {
stroke: theme.palette.primary.contrastTextTertiary,
},
},
}}
>
{icon}
</BaseBox>
<Typography variant="h2">{title}</Typography>
</Stack>
<Box flex={1}>{children}</Box>
</Stack>
</BaseBox>
);
};
export const HistogramStatus = ({
checks,
status,
range,
title,
}: {
checks: GroupedCheck[];
status: MonitorStatus;
range: string;
title: string;
}) => {
const uiTimezone = useSelector((state: any) => state.ui.timezone);
const icon = status === "up" ? <UptimeIcon /> : <IncidentsIcon />;
const theme = useTheme();
const [idx, setIdx] = useState<number | null>(null);
const dateFormat = range === "1d" || range === "2h" ? "MMM D, h A" : "MMM D";
const normalChecks = normalizeResponseTimes(checks, "avgResponseTime");
if (normalChecks.length === 0) {
return (
<BaseChart
icon={icon}
title={title}
>
<Stack
height={"100%"}
alignItems={"center"}
justifyContent={"center"}
>
<Typography variant="h2">
{status === "up" ? "No checks yet" : "Great, no downtime yet!"}
</Typography>
</Stack>
</BaseChart>
);
}
const totalChecks = normalChecks.reduce((count, check) => {
return count + check.count;
}, 0);
return (
<BaseChart
icon={icon}
title={title}
>
<Stack gap={theme.spacing(8)}>
<Stack
position="relative"
direction="row"
justifyContent="space-between"
>
<Stack>
<Typography>Total checks</Typography>
{idx ? (
<Stack>
<Typography variant="h2">{normalChecks[idx].count}</Typography>
<Typography
position={"absolute"}
top={"100%"}
>
{formatDateWithTz(normalChecks[idx]._id, dateFormat, uiTimezone)}
</Typography>
</Stack>
) : (
<Typography variant="h2">{totalChecks}</Typography>
)}
</Stack>
</Stack>
<ResponsiveContainer
width="100%"
height={155}
>
<BarChart data={normalChecks}>
<XAxis
stroke={theme.palette.primary.lowContrast}
height={15}
tick={false}
label={
<XLabel
p1={normalChecks[0]}
p2={normalChecks[normalChecks.length - 1]}
range={range}
/>
}
/>
<Bar
dataKey="normalResponseTime"
maxBarSize={7}
background={{ fill: "transparent" }}
>
{normalChecks?.map((groupedCheck, idx) => {
const fillColor = getResponseTimeColor(groupedCheck.normalResponseTime);
return (
<Cell
onMouseEnter={() => setIdx(idx)}
onMouseLeave={() => setIdx(null)}
key={groupedCheck._id}
fill={theme.palette[fillColor].main}
/>
);
})}
</Bar>
</BarChart>
</ResponsiveContainer>
</Stack>
</BaseChart>
);
};
@@ -0,0 +1,60 @@
import type { IMonitor } from "@/Types/Monitor";
import Stack from "@mui/material/Stack";
import Typography from "@mui/material/Typography";
import { PulseDot } from "@/Components/v2/DesignElements/PulseDot";
import { Dot } from "@/Components/v2/DesignElements/Dot";
import { getStatusColor, formatUrl } from "@/Utils/MonitorUtils";
import { useTheme } from "@mui/material/styles";
import prettyMilliseconds from "pretty-ms";
import { typographyLevels } from "@/Utils/Theme/v2/palette";
import { useMediaQuery } from "@mui/material";
export const MonitorStatus = ({ monitor }: { monitor: IMonitor }) => {
const theme = useTheme();
const isSmall = useMediaQuery(theme.breakpoints.down("md"));
if (!monitor) {
return null;
}
return (
<Stack>
<Typography
fontSize={typographyLevels.xl}
fontWeight={500}
color={theme.palette.primary.contrastText}
overflow={"hidden"}
textOverflow={"ellipsis"}
whiteSpace={"nowrap"}
maxWidth={isSmall ? "100%" : "calc((100vw - var(--env-var-width-2)) / 2)"}
>
{monitor.name}
</Typography>
<Stack
direction="row"
alignItems={"center"}
gap={theme.spacing(4)}
>
<PulseDot color={getStatusColor(monitor.status, theme)} />
<Typography
color={theme.palette.primary.contrastTextSecondary}
fontSize={typographyLevels.l}
fontWeight={"bolder"}
fontFamily={"monospace"}
overflow={"hidden"}
textOverflow={"ellipsis"}
whiteSpace={"nowrap"}
maxWidth={isSmall ? "100%" : "calc((100vw - var(--env-var-width-2)) / 2)"}
>
{formatUrl(monitor?.url)}
</Typography>
{!isSmall && (
<>
<Dot />
<Typography>
Checking every {prettyMilliseconds(monitor?.interval, { verbose: true })}
</Typography>
</>
)}
</Stack>
</Stack>
);
};
+36 -4
View File
@@ -2,7 +2,7 @@ import { useState } from "react";
import useSWR from "swr";
import type { SWRConfiguration } from "swr";
import type { AxiosRequestConfig } from "axios";
import { get, post } from "@/Utils/ApiClient"; // your axios wrapper
import { get, post, patch } from "@/Utils/ApiClient"; // your axios wrapper
export type ApiResponse = {
message: string;
@@ -20,7 +20,7 @@ export const useGet = <T,>(
axiosConfig?: AxiosRequestConfig,
swrConfig?: SWRConfiguration<T, Error>
) => {
const { data, error, isLoading, mutate } = useSWR<T>(
const { data, error, isLoading, isValidating, mutate } = useSWR<T>(
url,
(url) => fetcher<T>(url, axiosConfig),
swrConfig
@@ -29,16 +29,21 @@ export const useGet = <T,>(
return {
response: data ?? null,
loading: isLoading,
isValidating,
error: error?.message ?? null,
refetch: mutate,
};
};
export const usePost = <B = any, R = any>(endpoint: string) => {
export const usePost = <B = any, R = any>() => {
const [loading, setLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
const postFn = async (body: B, config?: AxiosRequestConfig): Promise<R | null> => {
const postFn = async (
endpoint: string,
body: B,
config?: AxiosRequestConfig
): Promise<R | null> => {
setLoading(true);
setError(null);
@@ -56,3 +61,30 @@ export const usePost = <B = any, R = any>(endpoint: string) => {
return { post: postFn, loading, error };
};
export const usePatch = <B = any, R = any>() => {
const [loading, setLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
const patchFn = async (
endpoint: string,
body?: B,
config?: AxiosRequestConfig
): Promise<R | null> => {
setLoading(true);
setError(null);
try {
const res = await patch<R>(endpoint, body, config);
return res.data;
} catch (err: any) {
const errMsg = err?.response?.data?.msg || err.message || "An error occurred";
setError(errMsg);
return null;
} finally {
setLoading(false);
}
};
return { patch: patchFn, loading, error };
};
+87 -68
View File
@@ -1,16 +1,19 @@
import { AuthBasePage } from "@/Components/v2/Auth";
import { Button } from "@/Components/v2/Inputs";
import Stack from "@mui/material/Stack";
import { TextInput, TextLink } from "@/Components/v2/Inputs";
import type { ApiResponse } from "@/Hooks/v2/UseApi";
import { zodResolver } from "@hookform/resolvers/zod";
import { z } from "zod";
import { useTranslation } from "react-i18next";
import { useForm, Controller } from "react-hook-form";
import { usePost } from "@/Hooks/v2/UseApi";
import { useNavigate } from "react-router";
import { useTheme } from "@mui/material/styles";
import { useDispatch } from "react-redux";
import { setIsAuthenticated } from "@/Features/Auth/v2AuthSlice";
import { useNavigate } from "react-router";
import { TextInput } from "@/Components/v2/Inputs/TextInput";
import Button from "@mui/material/Button";
import Stack from "@mui/material/Stack";
import { usePost } from "@/Hooks/v2/UseApi";
import type { ApiResponse } from "@/Hooks/v2/UseApi";
import { useTranslation } from "react-i18next";
import { z } from "zod";
import { useForm, Controller } from "react-hook-form";
const schema = z.object({
email: z.email("Invalid email address"),
@@ -23,7 +26,7 @@ const Login = () => {
const { t } = useTranslation();
const dispatch = useDispatch();
const theme = useTheme();
const { post, loading } = usePost<FormData, ApiResponse>("/auth/login");
const { post, loading } = usePost<FormData, ApiResponse>();
const navigate = useNavigate();
const {
@@ -39,7 +42,7 @@ const Login = () => {
});
const onSubmit = async (data: FormData) => {
const result = await post(data);
const result = await post("/auth/login", data);
if (result) {
dispatch(setIsAuthenticated({ authenticated: true }));
navigate("/v2/uptime");
@@ -49,68 +52,84 @@ const Login = () => {
};
return (
<Stack
alignItems={"center"}
justifyContent={"center"}
minHeight="100vh"
<AuthBasePage
title={t("auth.login.welcome")}
subtitle={t("auth.login.heading")}
>
<Stack
component="form"
padding={theme.spacing(8)}
gap={theme.spacing(12)}
onSubmit={handleSubmit(onSubmit)}
maxWidth={400}
sx={{
width: {
sm: "80%",
md: "70%",
lg: "65%",
xl: "65%",
},
}}
width={"100%"}
alignItems={"center"}
justifyContent={"center"}
gap={theme.spacing(8)}
>
<Controller
name="email"
control={control}
defaultValue=""
render={({ field }) => (
<TextInput
{...field}
label={t("auth.common.inputs.email.label")}
fullWidth
placeholder={t("auth.common.inputs.email.placeholder")}
error={!!errors.email}
helperText={errors.email ? errors.email.message : ""}
/>
)}
/>
<Controller
name="password"
control={control}
defaultValue=""
render={({ field }) => (
<TextInput
{...field}
type="password"
label={t("auth.common.inputs.password.label")}
fullWidth
placeholder="••••••••••"
error={!!errors.password}
helperText={errors.password ? errors.password.message : ""}
/>
)}
/>
<Button
variant="contained"
loading={loading}
color="accent"
type="submit"
sx={{ width: "100%", alignSelf: "center", fontWeight: 700 }}
<Stack
component="form"
padding={theme.spacing(8)}
gap={theme.spacing(12)}
onSubmit={handleSubmit(onSubmit)}
maxWidth={400}
sx={{
width: {
sm: "80%",
md: "70%",
lg: "65%",
xl: "65%",
},
}}
>
Login
</Button>
<Controller
name="email"
control={control}
defaultValue=""
render={({ field }) => (
<TextInput
{...field}
label={t("auth.common.inputs.email.label")}
fullWidth
placeholder={t("auth.common.inputs.email.placeholder")}
error={!!errors.email}
helperText={errors.email ? errors.email.message : ""}
/>
)}
/>
<Controller
name="password"
control={control}
defaultValue=""
render={({ field }) => (
<TextInput
{...field}
type="password"
label={t("auth.common.inputs.password.label")}
fullWidth
placeholder="••••••••••"
error={!!errors.password}
helperText={errors.password ? errors.password.message : ""}
/>
)}
/>
<Button
variant="contained"
loading={loading}
color="accent"
type="submit"
sx={{ width: "100%", alignSelf: "center", fontWeight: 700 }}
>
Login
</Button>
</Stack>
<TextLink
text={t("auth.login.links.forgotPassword")}
linkText={t("auth.login.links.forgotPasswordLink")}
href="/forgot-password"
/>
<TextLink
text={t("auth.login.links.register")}
linkText={t("auth.login.links.registerLink")}
href="/register"
/>
</Stack>
</Stack>
</AuthBasePage>
);
};
+121 -113
View File
@@ -1,14 +1,17 @@
import { AuthBasePage } from "@/Components/v2/Auth";
import { TextInput } from "@/Components/v2/Inputs";
import { Button } from "@/Components/v2/Inputs";
import Typography from "@mui/material/Typography";
import Stack from "@mui/material/Stack";
import type { ApiResponse } from "@/Hooks/v2/UseApi";
import { zodResolver } from "@hookform/resolvers/zod";
import { z } from "zod";
import { useTranslation } from "react-i18next";
import { useForm, Controller } from "react-hook-form";
import { useTheme } from "@mui/material/styles";
import Typography from "@mui/material/Typography";
import { TextInput } from "@/Components/v2/Inputs/TextInput";
import Button from "@mui/material/Button";
import Stack from "@mui/material/Stack";
import { usePost } from "@/Hooks/v2/UseApi";
import { useNavigate } from "react-router";
const schema = z
.object({
@@ -30,14 +33,15 @@ type FormData = z.infer<typeof schema>;
const Register = () => {
const { t } = useTranslation();
const theme = useTheme();
const { post, loading, error } = usePost<FormData>("/auth/register");
const navigate = useNavigate();
const { post, loading, error } = usePost<FormData, ApiResponse>();
const {
handleSubmit,
control,
formState: { errors },
} = useForm<FormData>({
resolver: zodResolver(schema), // ⬅️ connect Zod
resolver: zodResolver(schema),
defaultValues: {
email: "",
password: "",
@@ -45,124 +49,128 @@ const Register = () => {
});
const onSubmit = async (data: FormData) => {
const result = await post(data);
const result = await post("/auth/register", data);
if (result) {
console.log(result);
navigate("/v2/uptime");
} else {
console.error("Login failed:", error);
}
};
return (
<Stack
alignItems={"center"}
justifyContent={"center"}
minHeight="100vh"
<AuthBasePage
title={t("auth.registration.welcome")}
subtitle={t("auth.registration.heading.user")}
>
<Stack
component="form"
padding={theme.spacing(8)}
gap={theme.spacing(12)}
onSubmit={handleSubmit(onSubmit)}
maxWidth={400}
sx={{
width: {
sm: "80%",
md: "70%",
lg: "65%",
xl: "65%",
},
}}
alignItems={"center"}
width={"100%"}
>
<Controller
name="email"
control={control}
defaultValue=""
render={({ field }) => (
<TextInput
{...field}
label={t("auth.common.inputs.email.label")}
fullWidth
placeholder={t("auth.common.inputs.email.placeholder")}
error={!!errors.email}
helperText={errors.email ? errors.email.message : ""}
/>
)}
/>
<Controller
name="firstName"
control={control}
defaultValue=""
render={({ field }) => (
<TextInput
{...field}
label={t("auth.common.inputs.firstName.label")}
fullWidth
placeholder={t("auth.common.inputs.firstName.placeholder")}
error={!!errors.firstName}
helperText={errors.firstName ? errors.firstName.message : ""}
/>
)}
/>
<Controller
name="lastName"
control={control}
defaultValue=""
render={({ field }) => (
<TextInput
{...field}
label={t("auth.common.inputs.lastName.label")}
fullWidth
placeholder={t("auth.common.inputs.lastName.placeholder")}
error={!!errors.lastName}
helperText={errors.lastName ? errors.lastName.message : ""}
/>
)}
/>
<Controller
name="password"
control={control}
defaultValue=""
render={({ field }) => (
<TextInput
{...field}
type="password"
label={t("auth.common.inputs.password.label")}
fullWidth
placeholder="••••••••••"
error={!!errors.password}
helperText={errors.password ? errors.password.message : ""}
/>
)}
/>
<Controller
name="confirmPassword"
control={control}
defaultValue=""
render={({ field }) => (
<TextInput
{...field}
type="password"
label={t("auth.common.inputs.passwordConfirm.label")}
fullWidth
placeholder={t("auth.common.inputs.passwordConfirm.placeholder")}
error={!!errors.confirmPassword}
helperText={errors.confirmPassword ? errors.confirmPassword.message : ""}
/>
)}
/>
<Button
variant="contained"
loading={loading}
color="accent"
type="submit"
sx={{ width: "100%", alignSelf: "center", fontWeight: 700 }}
<Stack
component="form"
padding={theme.spacing(8)}
gap={theme.spacing(12)}
onSubmit={handleSubmit(onSubmit)}
maxWidth={400}
sx={{
width: {
sm: "80%",
md: "70%",
lg: "65%",
xl: "65%",
},
}}
>
Login
</Button>
{error && <Typography color="error">{error}</Typography>}
<Controller
name="email"
control={control}
defaultValue=""
render={({ field }) => (
<TextInput
{...field}
label={t("auth.common.inputs.email.label")}
fullWidth
placeholder={t("auth.common.inputs.email.placeholder")}
error={!!errors.email}
helperText={errors.email ? errors.email.message : ""}
/>
)}
/>
<Controller
name="firstName"
control={control}
defaultValue=""
render={({ field }) => (
<TextInput
{...field}
label={t("auth.common.inputs.firstName.label")}
fullWidth
placeholder={t("auth.common.inputs.firstName.placeholder")}
error={!!errors.firstName}
helperText={errors.firstName ? errors.firstName.message : ""}
/>
)}
/>
<Controller
name="lastName"
control={control}
defaultValue=""
render={({ field }) => (
<TextInput
{...field}
label={t("auth.common.inputs.lastName.label")}
fullWidth
placeholder={t("auth.common.inputs.lastName.placeholder")}
error={!!errors.lastName}
helperText={errors.lastName ? errors.lastName.message : ""}
/>
)}
/>
<Controller
name="password"
control={control}
defaultValue=""
render={({ field }) => (
<TextInput
{...field}
type="password"
label={t("auth.common.inputs.password.label")}
fullWidth
placeholder="••••••••••"
error={!!errors.password}
helperText={errors.password ? errors.password.message : ""}
/>
)}
/>
<Controller
name="confirmPassword"
control={control}
defaultValue=""
render={({ field }) => (
<TextInput
{...field}
type="password"
label={t("auth.common.inputs.passwordConfirm.label")}
fullWidth
placeholder={t("auth.common.inputs.passwordConfirm.placeholder")}
error={!!errors.confirmPassword}
helperText={errors.confirmPassword ? errors.confirmPassword.message : ""}
/>
)}
/>
<Button
variant="contained"
loading={loading}
color="accent"
type="submit"
sx={{ width: "100%", alignSelf: "center", fontWeight: 700 }}
>
Register
</Button>
{error && <Typography color="error">{error}</Typography>}
</Stack>
</Stack>
</Stack>
</AuthBasePage>
);
};
+93
View File
@@ -0,0 +1,93 @@
import { Table, Pagination } from "@/Components/v2/DesignElements";
import { StatusLabel } from "@/Components/v2/DesignElements";
import Box from "@mui/material/Box";
import type { Header } from "@/Components/v2/DesignElements/Table";
import type { Check } from "@/Types/Check";
import type { ApiResponse } from "@/Hooks/v2/UseApi";
import type { MonitorStatus } from "@/Types/Monitor";
import { useState } from "react";
import { useTranslation } from "react-i18next";
import { useGet } from "@/Hooks/v2/UseApi";
import { formatDateWithTz } from "@/Utils/TimeUtils";
import { useSelector } from "react-redux";
const getHeaders = (t: Function, uiTimezone: string) => {
const headers: Header<Check>[] = [
{
id: "status",
content: t("status"),
render: (row) => {
return <StatusLabel status={row.status as MonitorStatus} />;
},
},
{
id: "date",
content: t("date&Time"),
render: (row) => {
return formatDateWithTz(row.createdAt, "ddd, MMMM D, YYYY, HH:mm A", uiTimezone);
},
},
{
id: "statusCode",
content: t("statusCode"),
render: (row) => {
return row.httpStatusCode || "N/A";
},
},
];
return headers;
};
export const CheckTable = ({ monitorId }: { monitorId: string }) => {
const [page, setPage] = useState(0);
const [rowsPerPage, setRowsPerPage] = useState(5);
const { t } = useTranslation();
const uiTimezone = useSelector((state: any) => state.ui.timezone);
const headers = getHeaders(t, uiTimezone);
const { response, error } = useGet<ApiResponse>(
`/monitors/${monitorId}/checks?page=${page}&rowsPerPage=${rowsPerPage}`,
{},
{ keepPreviousData: true }
);
const checks = response?.data?.checks || [];
const count = response?.data?.count || 0;
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);
};
if (error) {
console.error(error);
}
return (
<Box>
<Table
headers={headers}
data={checks}
/>
<Pagination
component="div"
count={count}
page={page}
rowsPerPage={rowsPerPage}
onPageChange={handlePageChange}
onRowsPerPageChange={handleRowsPerPageChange}
/>
</Box>
);
};
+5 -5
View File
@@ -5,7 +5,7 @@ import { ConfigBox, BasePage } from "@/Components/v2/DesignElements";
import RadioGroup from "@mui/material/RadioGroup";
import FormControl from "@mui/material/FormControl";
import { RadioWithDescription } from "@/Components/v2/Inputs/RadioInput";
import Button from "@mui/material/Button";
import { Button } from "@/Components/v2/Inputs";
import DeleteOutlineRoundedIcon from "@mui/icons-material/DeleteOutlineRounded";
import { Typography } from "@mui/material";
import humanInterval from "human-interval";
@@ -19,7 +19,7 @@ import { zodResolver } from "@hookform/resolvers/zod";
import { useGet, usePost } from "@/Hooks/v2/UseApi";
import type { ApiResponse } from "@/Hooks/v2/UseApi";
const CreateUptimePage = () => {
const UptimeCreatePage = () => {
const { t } = useTranslation();
const theme = useTheme();
@@ -44,7 +44,7 @@ const CreateUptimePage = () => {
mode: "onChange",
});
const { response } = useGet<ApiResponse>("/notification-channels");
const { post, loading, error } = usePost<SubmitValues, ApiResponse>("/monitors");
const { post, loading, error } = usePost<SubmitValues>();
const selectedType = useWatch({
control,
name: "type",
@@ -58,7 +58,7 @@ const CreateUptimePage = () => {
let interval = humanInterval(data.interval);
if (!interval) interval = 60000;
const submitData = { ...data, interval };
const result = await post(submitData);
const result = await post("/monitors", submitData);
if (result) {
console.log(result);
} else {
@@ -259,4 +259,4 @@ const CreateUptimePage = () => {
);
};
export default CreateUptimePage;
export default UptimeCreatePage;
+157
View File
@@ -0,0 +1,157 @@
import { BasePage } from "@/Components/v2/DesignElements";
import { HeaderControls } from "@/Components/v2/Monitors/HeaderControls";
import Stack from "@mui/material/Stack";
import { StatBox } from "@/Components/v2/DesignElements";
import { HistogramStatus } from "@/Components/v2/Monitors/HistogramStatus";
import { ChartAvgResponse } from "@/Components/v2/Monitors/ChartAvgResponse";
import { ChartResponseTime } from "@/Components/v2/Monitors/ChartResponseTime";
import { HeaderRange } from "@/Components/v2/Monitors/HeaderRange";
import { CheckTable } from "@/Pages/v2/Uptime/CheckTable";
import type { IMonitor } from "@/Types/Monitor";
import { useMediaQuery } from "@mui/material";
import { useTheme } from "@mui/material/styles";
import { useParams } from "react-router";
import { useGet, usePatch, type ApiResponse } from "@/Hooks/v2/UseApi";
import { useState } from "react";
import { getStatusPalette } from "@/Utils/MonitorUtils";
import prettyMilliseconds from "pretty-ms";
const UptimeDetailsPage = () => {
const { id } = useParams();
const theme = useTheme();
const isSmall = useMediaQuery(theme.breakpoints.down("md"));
// Local state
const [range, setRange] = useState("2h");
const { response, isValidating, error, refetch } = useGet<ApiResponse>(
`/monitors/${id}?embedChecks=true&range=${range}`,
{},
{ refreshInterval: 30000, keepPreviousData: true }
);
const {
response: upResponse,
isValidating: upIsValidating,
error: upError,
} = useGet<ApiResponse>(
`/monitors/${id}?embedChecks=true&range=${range}&status=up`,
{},
{ keepPreviousData: true }
);
const {
response: downResponse,
error: downError,
isValidating: downIsValidating,
} = useGet<ApiResponse>(
`/monitors/${id}?embedChecks=true&range=${range}&status=down`,
{},
{ keepPreviousData: true }
);
const { patch, loading: isPatching, error: postError } = usePatch<ApiResponse>();
const monitor: IMonitor = response?.data?.monitor;
if (!monitor) {
return null;
}
const stats = response?.data?.stats || null;
const avgResponseTime = stats?.avgResponseTime || 0;
const maxResponseTime = stats?.maxResponseTime || 0;
const streakDuration = stats?.currentStreakStartedAt
? Date.now() - stats?.currentStreakStartedAt
: 0;
const lastChecked = stats?.lastCheckTimestamp
? Date.now() - stats?.lastCheckTimestamp
: -1;
const checks = response?.data?.checks || [];
const upChecks = upResponse?.data?.checks ? [...upResponse.data.checks].reverse() : [];
const downChecks = downResponse?.data?.checks
? [...downResponse.data.checks].reverse()
: [];
const palette = getStatusPalette(monitor?.status);
if (error || upError || downError || postError) {
console.error("Error fetching monitor data:", {
error,
upError,
downError,
postError,
});
}
return (
<BasePage>
<HeaderControls
monitor={monitor}
patch={patch}
isPatching={isPatching}
refetch={refetch}
/>
<Stack
direction="row"
gap={theme.spacing(8)}
>
<StatBox
palette={palette}
title="Active for"
subtitle={prettyMilliseconds(streakDuration, { secondsDecimalDigits: 0 })}
/>
<StatBox
title="Last check"
subtitle={
lastChecked >= 0
? `${prettyMilliseconds(lastChecked, { secondsDecimalDigits: 0 })} ago`
: "N/A"
}
/>
<StatBox
title="Last response time"
subtitle={stats?.lastResponseTime ? `${stats?.lastResponseTime} ms` : "N/A"}
/>
</Stack>
<HeaderRange
loading={isValidating || upIsValidating || downIsValidating}
range={range}
setRange={setRange}
/>
<Stack
direction={isSmall ? "column" : "row"}
gap={theme.spacing(8)}
>
<HistogramStatus
title="Uptime"
status={"up"}
checks={upChecks}
range={range}
/>
<HistogramStatus
title="Incidents"
checks={downChecks}
status={"down"}
range={range}
/>
<ChartAvgResponse
avg={avgResponseTime}
max={maxResponseTime}
/>
</Stack>
<ChartResponseTime
checks={checks}
range={range}
/>
<CheckTable monitorId={monitor?._id} />
</BasePage>
);
};
export default UptimeDetailsPage;
+135 -108
View File
@@ -1,123 +1,147 @@
import Stack from "@mui/material/Stack";
import Typography from "@mui/material/Typography";
import { Table } from "@/Components/v2/DesignElements";
import { HistogramResponseTime } from "@/Components/v2/Monitors/HistogramResponseTime";
import type { Header } from "@/Components/v2/DesignElements/Table";
import type { IMonitor } from "@/Types/Monitor";
import { Table } from "@/Components/v2/DesignElements";
import { ActionsMenu } from "@/Components/v2/ActionsMenu";
import { StatusLabel } from "@/Components/v2/DesignElements";
import { useTranslation } from "react-i18next";
import { useMediaQuery } from "@mui/material";
import { useTheme } from "@mui/material/styles";
import { ActionsMenu } from "@/Components/v2/ActionsMenu";
import { useNavigate } from "react-router-dom";
import { usePatch } from "@/Hooks/v2/UseApi";
import type { ApiResponse } from "@/Hooks/v2/UseApi";
import type { IMonitor } from "@/Types/Monitor";
import type { ActionMenuItem } from "@/Components/v2/ActionsMenu";
import Typography from "@mui/material/Typography";
const getActions = (theme: any): ActionMenuItem[] => {
return [
{
id: 1,
label: "Open site",
action: () => {
console.log("Open site");
},
closeMenu: true,
},
{
id: 2,
label: "Details",
action: () => {
console.log("Open details");
},
},
{
id: 3,
label: "Incidents",
action: () => {
console.log("Open incidents");
},
},
{
id: 4,
label: "Configure",
action: () => {
console.log("Open configure");
},
},
{
id: 5,
label: "Clone",
action: () => {
console.log("Open clone");
},
},
{
id: 6,
label: "Pause",
action: () => {
console.log("Open pause");
},
closeMenu: true,
},
{
id: 7,
label: <Typography color={theme.palette.error.main}>Remove</Typography>,
action: () => {
console.log("Open delete");
},
closeMenu: true,
},
];
};
const getHeaders = (theme: any, t: Function) => {
const headers: Header<IMonitor>[] = [
{
id: "name",
content: t("host"),
render: (row) => {
return row.name;
},
},
{
id: "status",
content: t("status"),
render: (row) => {
return row.status;
},
},
{
id: "histogram",
content: t("responseTime"),
render: (row) => {
return (
<Stack alignItems={"center"}>
<HistogramResponseTime checks={row.latestChecks} />
</Stack>
);
},
},
{
id: "type",
content: t("type"),
render: (row) => {
return row.type;
},
},
{
id: "actions",
content: t("actions"),
render: () => {
return <ActionsMenu items={getActions(theme)} />;
},
},
];
return headers;
};
export const MonitorTable = ({ monitors }: { monitors: IMonitor[] }) => {
export const MonitorTable = ({
monitors,
refetch,
}: {
monitors: IMonitor[];
refetch: Function;
}) => {
const { t } = useTranslation();
const theme = useTheme();
const isSmall = useMediaQuery(theme.breakpoints.down("md"));
const navigate = useNavigate();
const {
patch,
// loading: isPatching,
// error: postError,
} = usePatch<ApiResponse>();
let headers = getHeaders(theme, t);
const getActions = (monitor: IMonitor): ActionMenuItem[] => {
return [
{
id: 1,
label: "Open site",
action: () => {
window.open(monitor.url, "_blank", "noreferrer");
},
closeMenu: true,
},
{
id: 2,
label: "Details",
action: () => {
navigate(`${monitor._id}`);
},
},
{
id: 3,
label: "Incidents",
action: () => {
navigate(`/v2/incidents/${monitor._id}`);
},
},
{
id: 4,
label: "Configure",
action: () => {
console.log("Open configure");
},
},
{
id: 5,
label: "Clone",
action: () => {
console.log("Open clone");
},
},
{
id: 6,
label: monitor.isActive ? "Pause" : "Resume",
action: async () => {
await patch(`/monitors/${monitor._id}/active`);
refetch();
},
closeMenu: true,
},
{
id: 7,
label: <Typography color={theme.palette.error.main}>Remove</Typography>,
action: () => {
console.log("Open delete");
},
closeMenu: true,
},
];
};
const getHeaders = () => {
const headers: Header<IMonitor>[] = [
{
id: "name",
content: t("host"),
render: (row) => {
return row.name;
},
},
{
id: "status",
content: t("status"),
render: (row) => {
return (
<StatusLabel
status={row.status}
isActive={row.isActive}
/>
);
},
},
{
id: "histogram",
content: t("responseTime"),
render: (row) => {
return (
<Stack alignItems={"center"}>
<HistogramResponseTime checks={row.latestChecks} />
</Stack>
);
},
},
{
id: "type",
content: t("type"),
render: (row) => {
return row.type;
},
},
{
id: "actions",
content: t("actions"),
render: (row) => {
return <ActionsMenu items={getActions(row)} />;
},
},
];
return headers;
};
let headers = getHeaders();
if (isSmall) {
headers = headers.filter((h) => h.id !== "histogram");
@@ -126,6 +150,9 @@ export const MonitorTable = ({ monitors }: { monitors: IMonitor[] }) => {
<Table
headers={headers}
data={monitors}
onRowClick={(row) => {
navigate(row._id);
}}
/>
);
};
+40 -13
View File
@@ -1,5 +1,5 @@
import {
BasePage,
BasePageWithStates,
UpStatusBox,
DownStatusBox,
PausedStatusBox,
@@ -18,29 +18,56 @@ const UptimeMonitors = () => {
const theme = useTheme();
const isSmall = useMediaQuery(theme.breakpoints.down("md"));
const { response, loading } = useGet<ApiResponse>("/monitors?embedChecks=true");
const monitors = response?.data ?? ([] as IMonitor[]);
const { response, isValidating, error, refetch } = useGet<ApiResponse>(
"/monitors?embedChecks=true",
{},
{ refreshInterval: 30000, keepPreviousData: true }
);
const monitors: IMonitor[] = response?.data ?? ([] as IMonitor[]);
if (monitors.length === 0 && !loading) {
return "No monitors found";
}
const monitorStatuses = monitors.reduce(
(acc, monitor) => {
if (monitor.status === "up") {
acc.up += 1;
} else if (monitor.status === "down") {
acc.down += 1;
} else if (monitor.isActive === false) {
acc.paused += 1;
}
return acc;
},
{
up: 0,
down: 0,
paused: 0,
}
);
return (
<BasePage>
<BasePageWithStates
loading={isValidating}
error={error}
items={monitors}
page="uptime"
actionLink="create"
>
<HeaderCreate
isLoading={loading}
isLoading={isValidating}
path="/v2/uptime/create"
/>
<Stack
direction={isSmall ? "column" : "row"}
gap={theme.spacing(8)}
>
<UpStatusBox n={1} />
<DownStatusBox n={1} />
<PausedStatusBox n={1} />
<UpStatusBox n={monitorStatuses.up} />
<DownStatusBox n={monitorStatuses.down} />
<PausedStatusBox n={monitorStatuses.paused} />
</Stack>
<MonitorTable monitors={monitors} />
</BasePage>
<MonitorTable
monitors={monitors}
refetch={refetch}
/>
</BasePageWithStates>
);
};
+7 -2
View File
@@ -5,7 +5,8 @@ import { lightTheme, darkTheme } from "@/Utils/Theme/v2/theme";
import AuthLoginV2 from "@/Pages/v2/Auth/Login";
import AuthRegisterV2 from "@/Pages/v2/Auth/Register";
import UptimeMonitorsPage from "@/Pages/v2/Uptime/UptimeMonitors";
import CreateUptimePage from "@/Pages/v2/Uptime/Create";
import UptimeCreatePage from "@/Pages/v2/Uptime/Create";
import UptimeDetailsPage from "@/Pages/v2/Uptime/Details";
import RootLayout from "@/Components/v2/Layouts/RootLayout";
const V2Routes = ({ mode = "light" }) => {
@@ -34,9 +35,13 @@ const V2Routes = ({ mode = "light" }) => {
path="uptime"
element={<UptimeMonitorsPage />}
/>
<Route
path="uptime/:id"
element={<UptimeDetailsPage />}
/>
<Route
path="uptime/create"
element={<CreateUptimePage />}
element={<UptimeCreatePage />}
/>
</Route>
</Routes>
+31
View File
@@ -1,8 +1,39 @@
export interface CheckTimingPhases {
wait: number;
dns: number;
tcp: number;
tls: number;
request: number;
firstByte: number;
download: number;
total: number;
}
export interface CheckTimings {
start: string;
socket: string;
lookup: string;
connect: string;
secureConnect: string;
response: string;
end: string;
phases: CheckTimingPhases;
}
export interface Check {
_id: string;
monitorId: string;
type: string;
status: string;
message: string;
responseTime: number;
normalResponseTime?: number;
httpStatusCode: number;
ack: boolean;
expiry: string;
createdAt: string;
updatedAt: string;
timings: CheckTimings;
}
export interface GroupedCheck {
+2 -1
View File
@@ -1,4 +1,5 @@
import type { Check } from "@/Types/Check";
export type MonitorStatus = "up" | "down" | "initializing";
export interface IMonitor {
checks: Check[];
@@ -9,7 +10,7 @@ export interface IMonitor {
latestChecks: Check[];
n: number;
name: string;
status: string;
status: MonitorStatus;
type: string;
updatedAt: string;
updatedBy: string;
+6 -1
View File
@@ -1,7 +1,6 @@
import axios from "axios";
import type { AxiosRequestConfig, AxiosResponse } from "axios";
const BASE_URL = import.meta.env.VITE_APP_API_V2_BASE_URL;
const api = axios.create({
baseURL: BASE_URL || "http://localhost:55555/api/v2",
withCredentials: true,
@@ -18,4 +17,10 @@ export const post = <T>(
config: AxiosRequestConfig = {}
): Promise<AxiosResponse<T>> => api.post<T>(url, data, config);
export const patch = <T>(
url: string,
data: any,
config: AxiosRequestConfig = {}
): Promise<AxiosResponse<T>> => api.patch<T>(url, data, config);
export default api;
+37
View File
@@ -0,0 +1,37 @@
const MIN_OUT = 10;
const MAX_OUT = 100;
export const normalizeResponseTimes = <T extends Record<K, number>, K extends keyof T>(
checks: T[],
key: K
): (T & { normalResponseTime: number })[] => {
if (!Array.isArray(checks) || checks.length === 0)
return checks as (T & {
normalResponseTime: number;
})[];
if (checks.length === 1) {
return [
{
...checks[0],
normalResponseTime: 50,
},
];
}
const { min, max } = checks.reduce(
(acc, check) => {
if (check[key] > acc.max) acc.max = check[key];
if (check[key] < acc.min) acc.min = check[key];
return acc;
},
{ max: -Infinity, min: Infinity }
);
const range = max - min || 1;
return checks.map((check) => ({
...check,
normalResponseTime: MIN_OUT + ((check[key] - min) * (MAX_OUT - MIN_OUT)) / range,
}));
};
+38
View File
@@ -0,0 +1,38 @@
import type { MonitorStatus } from "@/Types/Monitor";
import type { PaletteKey } from "./Theme/v2/theme";
export const getStatusPalette = (status: MonitorStatus): PaletteKey => {
const paletteMap: Record<MonitorStatus, PaletteKey> = {
up: "success",
down: "error",
initializing: "warning",
};
return paletteMap[status];
};
export const getStatusColor = (status: MonitorStatus, theme: any): string => {
const statusColors: Record<MonitorStatus, string> = {
up: theme.palette.success.lowContrast,
down: theme.palette.error.lowContrast,
initializing: theme.palette.warning.lowContrast,
};
return statusColors[status];
};
export const getResponseTimeColor = (responseTime: number): PaletteKey => {
if (responseTime < 200) {
return "success";
} else if (responseTime < 300) {
return "warning";
} else {
return "error";
}
};
export const formatUrl = (url: string, maxLength: number = 55) => {
if (!url) return "";
const strippedUrl = url.replace(/^https?:\/\//, "");
return strippedUrl.length > maxLength
? `${strippedUrl.slice(0, maxLength)}`
: strippedUrl;
};
+7
View File
@@ -1,5 +1,12 @@
import { createTheme } from "@mui/material";
import { lightPalette, darkPalette, typographyLevels } from "./palette";
import type { Theme } from "@mui/material/styles";
export type PaletteKey = {
[K in keyof Theme["palette"]]: Theme["palette"][K] extends { main: any } ? K : never;
}[keyof Theme["palette"]];
const fontFamilyPrimary = '"Inter" , sans-serif';
const shadow =
"0px 4px 24px -4px rgba(16, 24, 40, 0.08), 0px 3px 3px -3px rgba(16, 24, 40, 0.03)";
+28
View File
@@ -23,3 +23,31 @@ export const formatDateWithTz = (timestamp: string, format: string, timezone: st
const formattedDate = dayjs(timestamp).tz(timezone).format(format);
return formattedDate;
};
export const tickDateFormatLookup = (range: string) => {
const tickFormatLookup: Record<string, string> = {
"2h": "h:mm A",
"24h": "h:mm A",
"7d": "MM/D, h:mm A",
"30d": "ddd. M/D",
};
const format = tickFormatLookup[range];
if (format === undefined) {
return "";
}
return format;
};
export const tooltipDateFormatLookup = (range: string) => {
const dateFormatLookup: Record<string, string> = {
"2h": "ddd. MMMM D, YYYY, hh:mm A",
"24h": "ddd. MMMM D, YYYY, hh:mm A",
"7d": "ddd. MMMM D, YYYY, hh:mm A",
"30d": "ddd. MMMM D, YYYY",
};
const format = dateFormatLookup[range];
if (format === undefined) {
return "";
}
return format;
};
+2 -2
View File
@@ -1,5 +1,5 @@
{
"ignore": ["src/locales/*", "*.log", "node_modules/*"],
"watch": ["src/**/*.js", "*.json"],
"ext": "js,json"
"watch": ["src/**/*.ts", "src/**/*.js", "*.json"],
"ext": "ts,js,json"
}
+1 -1
View File
@@ -74,7 +74,7 @@ export const initializeControllers = (services) => {
controllers.authControllerV2 = new AuthControllerV2(services.authServiceV2, services.inviteServiceV2);
controllers.inviteControllerV2 = new InviteControllerV2(services.inviteServiceV2);
controllers.maintenanceControllerV2 = new MaintenanceControllerV2(services.maintenanceServiceV2);
controllers.monitorControllerV2 = new MonitorControllerV2(services.monitorServiceV2);
controllers.monitorControllerV2 = new MonitorControllerV2(services.monitorServiceV2, services.checkServiceV2);
controllers.notificationChannelControllerV2 = new NotificationChannelControllerV2(services.notificationChannelServiceV2);
controllers.queueControllerV2 = new QueueControllerV2(services.jobQueueV2);
+84 -50
View File
@@ -2,10 +2,13 @@ import { Request, Response, NextFunction } from "express";
import ApiError from "../../utils/ApiError.js";
import MonitorService from "../../service/v2/business/MonitorService.js";
import { MonitorType } from "../../db/v2/models/monitors/Monitor.js";
import CheckService from "../../service/v2/business/CheckService.js";
class MonitorController {
private monitorService: MonitorService;
constructor(monitorService: MonitorService) {
private checkService: CheckService;
constructor(monitorService: MonitorService, checkService: CheckService) {
this.monitorService = monitorService;
this.checkService = checkService;
}
create = async (req: Request, res: Response, next: NextFunction) => {
@@ -25,6 +28,86 @@ class MonitorController {
}
};
getAll = async (req: Request, res: Response, next: NextFunction) => {
try {
const tokenizedUser = req.user;
if (!tokenizedUser) {
return res.status(401).json({ message: "Unauthorized" });
}
let monitors;
if (req.query.embedChecks === "true") {
const page = Math.max(1, Number(req.query.page) || 1);
const limit = Math.max(1, Number(req.query.limit) || 10);
const type: MonitorType[] = req.query.type as MonitorType[];
monitors = await this.monitorService.getAllEmbedChecks(page, limit, type);
} else {
monitors = await this.monitorService.getAll();
}
res.status(200).json({
message: "Monitors retrieved successfully",
data: monitors,
});
} catch (error) {
next(error);
}
};
getChecks = async (req: Request, res: Response, next: NextFunction) => {
try {
const tokenizedUser = req.user;
if (!tokenizedUser) {
return res.status(401).json({ message: "Unauthorized" });
}
const id = req.params.id;
if (!id) {
throw new ApiError("Monitor ID is required", 400);
}
const page = Number(req.query.page);
const rowsPerPage = Number(req.query.rowsPerPage);
if (isNaN(page)) throw new ApiError("Page query parameter must be a number", 400);
if (isNaN(rowsPerPage)) throw new ApiError("rowsPerPage query parameter must be a number", 400);
if (page < 0) throw new ApiError("Page must be greater than 0", 400);
if (rowsPerPage < 0) throw new ApiError("rowsPerPage must be greater than 0", 400);
const { count, checks } = await this.checkService.getChecks(id, page, rowsPerPage);
res.status(200).json({
message: "Checks retrieved successfully",
data: { count, checks },
});
} catch (error) {
next(error);
}
};
toggleActive = async (req: Request, res: Response, next: NextFunction) => {
try {
const tokenizedUser = req.user;
if (!tokenizedUser) {
return res.status(401).json({ message: "Unauthorized" });
}
const id = req.params.id;
if (!id) {
throw new ApiError("Monitor ID is required", 400);
}
const monitor = await this.monitorService.toggleActive(id, tokenizedUser);
res.status(200).json({
message: "Monitor paused/unpaused successfully",
data: monitor,
});
} catch (error) {
next(error);
}
};
get = async (req: Request, res: Response, next: NextFunction) => {
try {
const tokenizedUser = req.user;
@@ -62,55 +145,6 @@ class MonitorController {
}
};
getAll = async (req: Request, res: Response, next: NextFunction) => {
try {
const tokenizedUser = req.user;
if (!tokenizedUser) {
return res.status(401).json({ message: "Unauthorized" });
}
let monitors;
if (req.query.embedChecks === "true") {
const page = Math.max(1, Number(req.query.page) || 1);
const limit = Math.max(1, Number(req.query.limit) || 10);
const type: MonitorType[] = req.query.type as MonitorType[];
monitors = await this.monitorService.getAllEmbedChecks(page, limit, type);
} else {
monitors = await this.monitorService.getAll();
}
res.status(200).json({
message: "Monitors retrieved successfully",
data: monitors,
});
} catch (error) {
next(error);
}
};
toggleActive = async (req: Request, res: Response, next: NextFunction) => {
try {
const tokenizedUser = req.user;
if (!tokenizedUser) {
return res.status(401).json({ message: "Unauthorized" });
}
const id = req.params.id;
if (!id) {
throw new ApiError("Monitor ID is required", 400);
}
const monitor = await this.monitorService.toggleActive(id, tokenizedUser);
res.status(200).json({
message: "Monitor paused/unpaused successfully",
data: monitor,
});
} catch (error) {
next(error);
}
};
update = async (req: Request, res: Response, next: NextFunction) => {
try {
const tokenizedUser = req.user;
+4 -2
View File
@@ -17,12 +17,14 @@ class MonitorRoutes {
this.router.get("/", verifyToken, verifyPermission(["monitors.view"]), this.controller.getAll);
this.router.get("/:id/checks", verifyToken, verifyPermission(["monitors.view"]), this.controller.getChecks);
this.router.patch("/:id/active", verifyToken, verifyPermission(["monitors.update"]), this.controller.toggleActive);
this.router.patch("/:id", verifyToken, verifyPermission(["monitors.update"]), this.controller.update);
this.router.get("/:id", verifyToken, verifyPermission(["monitors.view"]), this.controller.get);
this.router.patch("/:id", verifyToken, verifyPermission(["monitors.update"]), this.controller.update);
this.router.delete("/:id", verifyToken, verifyPermission(["monitors.delete"]), this.controller.delete);
};
@@ -33,11 +33,11 @@ class NetworkService {
const start = process.hrtime.bigint();
try {
const response = await operation();
// const elapsedMs = Math.round(Number(process.hrtime.bigint() - start) / 1_000_000);
return { response };
const elapsedMs = Math.round(Number(process.hrtime.bigint() - start) / 1_000_000);
return { response, responseTime: elapsedMs };
} catch (error) {
// const elapsedMs = Math.round(Number(process.hrtime.bigint() - start) / 1_000_000);
return { response: null, error };
const elapsedMs = Math.round(Number(process.hrtime.bigint() - start) / 1_000_000);
return { response: null, responseTime: elapsedMs, error };
}
}
@@ -350,9 +350,9 @@ class NetworkService {
}
const container = docker.getContainer(targetContainer.Id);
const { response, error } = await this.timeRequest(() => container.inspect());
const { response, responseTime, error } = await this.timeRequest(() => container.inspect());
dockerResponse.responseTime = response.time;
dockerResponse.responseTime = responseTime;
dockerResponse.status = response?.State?.Status === "running" ? true : false;
dockerResponse.code = 200;
dockerResponse.message = "Docker container status fetched successfully";
@@ -375,7 +375,7 @@ class NetworkService {
async requestPort(monitor) {
try {
const { url, port } = monitor;
const { response, error } = await this.timeRequest(async () => {
const { response, responseTime, error } = await this.timeRequest(async () => {
return new Promise((resolve, reject) => {
const socket = this.net.createConnection(
{
@@ -408,7 +408,7 @@ class NetworkService {
message: this.stringService.portSuccess,
monitorId: monitor._id,
type: monitor.type,
responseTime: response.time,
responseTime: responseTime,
};
if (error) {
@@ -5,6 +5,7 @@ import { MonitorType } from "../../../db/v2/models/monitors/Monitor.js";
import { StatusResponse } from "../infrastructure/NetworkService.js";
import type { ICapturePayload, ILighthousePayload } from "../infrastructure/NetworkService.js";
import mongoose from "mongoose";
import { stat } from "fs";
const SERVICE_NAME = "CheckServiceV2";
export interface ICheckService {
@@ -60,6 +61,7 @@ class CheckService implements ICheckService {
monitorId: monitorId,
type: statusResponse?.type,
status: statusResponse?.status,
httpStatusCode: statusResponse?.code,
message: statusResponse?.message,
responseTime: statusResponse?.responseTime,
timings: statusResponse?.timings,
@@ -130,6 +132,16 @@ class CheckService implements ICheckService {
return false;
}
};
getChecks = async (monitorId: string, page: number, rowsPerPage: number) => {
const count = await Check.countDocuments({ monitorId: new mongoose.Types.ObjectId(monitorId) });
const checks = await Check.find({ monitorId: new mongoose.Types.ObjectId(monitorId) })
.sort({ createdAt: -1 })
.skip(page * rowsPerPage)
.limit(rowsPerPage)
.exec();
return { checks, count };
};
}
export default CheckService;
@@ -63,8 +63,8 @@ class MonitorService implements IMonitorService {
private getStartDate(range: string): Date {
const now = new Date();
switch (range) {
case "30m":
return new Date(now.getTime() - 30 * 60 * 1000);
case "2h":
return new Date(now.getTime() - 2 * 60 * 60 * 1000);
case "24h":
return new Date(now.getTime() - 24 * 60 * 60 * 1000);
case "7d":
@@ -78,7 +78,7 @@ class MonitorService implements IMonitorService {
private getDateFormat(range: string): string {
switch (range) {
case "30m":
case "2h":
return "%Y-%m-%dT%H:%M:00Z";
case "24h":
case "7d":