This commit is contained in:
Alex Holliday committed 2026-01-23 23:54:58 +00:00
1 parent c2dece862c
commit a0a8ceb0db
27 files changed
+2326 -94

No files matched your search

@@ -0,0 +1,56 @@
import React, { useState } from "react";
import Menu from "@mui/material/Menu";
import MenuItem from "@mui/material/MenuItem";
import IconButton from "@mui/material/IconButton";
import { Settings } from "lucide-react";
export type ActionMenuItem = {
id: number | string;
label: React.ReactNode;
action: Function;
closeMenu?: boolean;
};
export const ActionsMenu = ({ items }: { items: ActionMenuItem[] }) => {
const [anchorEl, setAnchorEl] = useState<null | any>(null);
const open = Boolean(anchorEl);
const handleClick = (e: React.MouseEvent<any>) => {
e.stopPropagation();
setAnchorEl(e.currentTarget);
};
const handleClose = (e: React.MouseEvent<any>) => {
e.stopPropagation();
setAnchorEl(null);
};
return (
<div>
<IconButton onClick={handleClick}>
<Settings
size={20}
strokeWidth={1.5}
/>
</IconButton>
<Menu
anchorEl={anchorEl}
open={open}
onClose={handleClose}
>
{items.map((item) => (
<MenuItem
key={item.id}
onClick={(e: React.MouseEvent<HTMLLIElement>) => {
e.stopPropagation();
if (item.closeMenu) handleClose(e);
item.action();
}}
>
{item.label}
</MenuItem>
))}
</Menu>
</div>
);
};
@@ -0,0 +1,140 @@
import Box from "@mui/material/Box";
import { useTheme } from "@mui/material/styles";
import type { Check } from "@/Types/Check";
import { getResponseColor } from "@/Utils/DataUtils";
import { HeatmapResponseTimeTooltip } from "./HeatmapResponseTimeTooltip";
import type { SxProps } from "@mui/material/styles";
import type { ResponsiveStyleValue } from "@mui/system";
interface PlaceholderCheck {
status: "placeholder";
responseTime: 0;
createdAt: "";
}
interface HeatmapResponseTimeProps {
checks: Check[];
gap?: ResponsiveStyleValue<number | string>;
availabilityCellSx?: SxProps;
responseCellSx?: SxProps;
}
export const HeatmapResponseTime = ({
checks,
gap,
availabilityCellSx,
responseCellSx,
}: HeatmapResponseTimeProps) => {
const theme = useTheme();
if (!gap) {
gap = theme.spacing(0.5);
}
if (!checks || checks.length === 0) return null;
const latestChecks = checks.slice(-25).reverse();
let data: Array<Check | PlaceholderCheck>;
if (latestChecks.length !== 25) {
const placeholders = Array(25 - latestChecks.length).fill({
status: "placeholder" as const,
});
data = [...latestChecks, ...placeholders];
} else {
data = latestChecks;
}
return (
<Box sx={{ width: "100%" }}>
<Box
sx={{
width: "100%",
display: "grid",
gridTemplateColumns: "repeat(25, 1fr)",
gap: gap,
alignItems: "stretch",
}}
>
{data.map((check, index) => {
const statusBg =
check.status === "placeholder"
? theme.palette.action.hover
: check.status === true
? theme.palette.success.light
: theme.palette.error.light;
const respBg =
check.status === "placeholder"
? theme.palette.action.hover
: getResponseColor(check.responseTime || 0, {
start: theme.palette.success.main,
mid: theme.palette.warning.main,
end: theme.palette.error.main,
});
const statusBorder =
check.status === "placeholder"
? theme.palette.divider
: check.status === true
? theme.palette.success.main
: theme.palette.error.main;
return (
<HeatmapResponseTimeTooltip
key={`${check}-${index}`}
check={check}
>
<Box
sx={{
display: "grid",
gridTemplateRows: "auto auto",
}}
>
<Box
sx={{
display: "flex",
flexDirection: "column",
gap: theme.spacing(2),
}}
>
<Box
sx={{
position: "relative",
width: "100%",
aspectRatio: "10",
}}
>
<Box
sx={{
position: "absolute",
inset: 0,
bgcolor: statusBg,
borderRadius: theme.spacing(0.5),
...availabilityCellSx,
}}
/>
<Box
sx={{
position: "absolute",
inset: 0,
pointerEvents: "none",
borderRadius: theme.spacing(0.5),
boxShadow: `inset 0 0 0 2px ${statusBorder}`,
}}
/>
</Box>
<Box
sx={{
width: "100%",
aspectRatio: "1 / 1",
bgcolor: respBg,
borderRadius: theme.spacing(0.5),
...responseCellSx,
}}
/>
</Box>
</Box>
</HeatmapResponseTimeTooltip>
);
})}
</Box>
</Box>
);
};
@@ -0,0 +1,71 @@
import Stack from "@mui/material/Stack";
import Tooltip from "@mui/material/Tooltip";
import Typography from "@mui/material/Typography";
import { formatDateWithTz } from "@/Utils/TimeUtils";
import type { Check } from "@/Types/Check";
import { useTheme } from "@mui/material/styles";
import { useSelector } from "react-redux";
import type { RootState } from "@/Types/state";
type HeatmapCheck = Check | { status: "placeholder"; responseTime: 0; createdAt: "" };
export const HeatmapResponseTimeTooltip = ({
children,
check,
}: {
children: React.ReactElement;
check: HeatmapCheck;
}) => {
const uiTimezone = useSelector((state: RootState) => state.ui.timezone);
const theme = useTheme();
const getColor = (status: boolean) => {
if (status === true) return theme.palette.success.light;
if (status === false) return theme.palette.error.light;
};
if (check.status === "placeholder") {
return children;
}
return (
<Tooltip
slotProps={{
tooltip: {
sx: {
backgroundColor: "transparent",
color: "inherit",
boxShadow: "none",
},
},
}}
title={
<Stack
sx={{
backgroundColor: theme.palette.secondary.main,
border: 1,
borderColor: theme.palette.divider,
borderRadius: theme.shape.borderRadius,
p: theme.spacing(4),
}}
>
<Typography>
{formatDateWithTz(check?.createdAt, "ddd, MMMM D, YYYY, HH:mm A", uiTimezone)}
</Typography>
{check?.originalResponseTime && (
<Typography>
Response Time: {check.originalResponseTime.toFixed()} ms
</Typography>
)}
<Typography textTransform={"capitalize"}>
Status:{" "}
<span style={{ color: getColor(check?.status) }}>{check?.status}</span>
</Typography>
</Stack>
}
>
{children}
</Tooltip>
);
};
@@ -0,0 +1,102 @@
import Box from "@mui/material/Box";
import { useTheme } from "@mui/material/styles";
import type { ResponsiveStyleValue } from "@mui/system";
import type { Check } from "@/Types/Check";
import { HeatmapResponseTimeTooltip } from "./HeatmapResponseTimeTooltip";
interface HistogramResponseTimeProps {
checks: Check[];
height?: ResponsiveStyleValue<number | string>;
gap?: ResponsiveStyleValue<number | string>;
}
const DEFAULT_HEIGHT = 50;
export const HistogramResponseTime = ({
checks,
height = DEFAULT_HEIGHT,
gap,
}: HistogramResponseTimeProps) => {
const theme = useTheme();
if (!Array.isArray(checks) || checks.length === 0) return null;
let data = Array<any>();
if (!checks || checks.length === 0) {
data = [];
}
if (checks.length !== 25) {
const placeholders = Array(25 - checks.length).fill({
status: "placeholder",
});
data = [...checks, ...placeholders];
} else {
data = checks;
}
const chartHeight = typeof height === "number" ? `${height}px` : height;
const gridGap = gap ?? theme.spacing(0.5);
return (
<Box sx={{ width: "100%" }}>
<Box
sx={{
width: "100%",
display: "grid",
gridTemplateColumns: "repeat(25, 1fr)",
gap: gridGap,
alignItems: "end",
height: chartHeight,
}}
>
{data.map((check, index) => {
const isPlaceholder = (check as any).status === "placeholder";
const heightPct = `${Math.max(0, Math.min(100, (check as any).responseTime ?? 0))}%`;
const barColor =
check.status === true
? theme.palette.success.light
: theme.palette.error.light;
const bar = (
<Box
sx={{
position: "relative",
width: "100%",
height: "100%",
borderRadius: theme.spacing(1),
bgcolor: theme.palette.action.hover,
overflow: "hidden",
}}
>
<Box
sx={{
position: "absolute",
bottom: 0,
left: 0,
width: "100%",
height: isPlaceholder ? 0 : heightPct,
bgcolor: barColor,
transition: "height 500ms cubic-bezier(0.4, 0, 0.2, 1)",
}}
/>
</Box>
);
return isPlaceholder ? (
<Box
key={index}
sx={{ height: "100%" }}
>
{bar}
</Box>
) : (
<HeatmapResponseTimeTooltip
key={index}
check={check}
>
{bar}
</HeatmapResponseTimeTooltip>
);
})}
</Box>
</Box>
);
};
+3
View File
@@ -0,0 +1,3 @@
export * from "./HistogramResponseTime";
export * from "./HeatmapResponseTime";
export * from "./HeatmapResponseTimeTooltip";
@@ -0,0 +1,19 @@
import Box, { type BoxProps } from "@mui/material/Box";
import { useTheme } from "@mui/material/styles";
export const BaseBox = ({ sx, ...rest }: BoxProps) => {
const theme = useTheme();
return (
<Box
{...rest}
sx={{
backgroundColor: theme.palette.background.paper,
border: 1,
borderStyle: "solid",
borderColor: theme.palette.divider,
borderRadius: theme.shape.borderRadius,
...sx,
}}
/>
);
};
@@ -0,0 +1,112 @@
import Stack from "@mui/material/Stack";
import Typography from "@mui/material/Typography";
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 { useTheme } from "@mui/material/styles";
type StatusBoxProps = React.PropsWithChildren<{ children: React.ReactNode }>;
export const BGBox = ({ children }: StatusBoxProps) => {
const theme = useTheme();
return (
<BaseBox
sx={{
overflow: "hidden",
position: "relative",
flex: 1,
padding: theme.spacing(4),
}}
>
<Box
position="absolute"
top="-10%"
left="5%"
>
<Background />
</Box>
{children}
</BaseBox>
);
};
const StatusBox = ({
label,
n,
color,
}: {
label: string;
n: number;
color: string | undefined;
}) => {
const theme = useTheme();
return (
<BGBox>
<Stack spacing={theme.spacing(4)}>
<Typography
variant={"h2"}
textTransform="uppercase"
color={theme.palette.text.secondary}
>
{label}
</Typography>
<Typography
variant="h1"
color={color}
>
{n}
</Typography>
</Stack>
</BGBox>
);
};
export const UpStatusBox = ({ 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 DownStatusBox = ({ 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 PausedStatusBox = ({ n }: { n: number }) => {
const theme = useTheme();
const { t } = useTranslation();
return (
<StatusBox
label={t("pages.common.monitors.status.paused")}
n={n}
color={theme.palette.warning.light}
/>
);
};
export const InitializingStatusBox = ({ n }: { n: number }) => {
const theme = useTheme();
const { t } = useTranslation();
return (
<StatusBox
label={t("pages.common.monitors.status.initializing")}
n={n}
color={theme.palette.warning.light}
/>
);
};
@@ -0,0 +1,101 @@
import Box from "@mui/material/Box";
import { BaseBox } from "@/Components/v2/design-elements";
import Typography from "@mui/material/Typography";
import type { MonitorStatus } from "@/Types/Monitor";
import type { SxProps } from "@mui/material/styles";
import { getStatusPalette, getValuePalette } from "@/Utils/MonitorUtils";
import { useTheme } from "@mui/material/styles";
import { useTranslation } from "react-i18next";
export const ValueTypes = ["positive", "negative", "neutral"] as const;
export type ValueType = (typeof ValueTypes)[number];
export const StatusLabel = ({
status,
isActive,
sx,
}: {
status: MonitorStatus;
isActive: boolean;
sx?: SxProps;
}) => {
const { t } = useTranslation();
const theme = useTheme();
const palette = getStatusPalette(status);
const determineStatus = (isActive: boolean, status: MonitorStatus): string => {
if (isActive === false) {
return t("pages.common.monitors.status.paused");
}
if (status === true) {
return t("pages.common.monitors.status.up");
}
if (status === false) {
return t("pages.common.monitors.status.down");
}
return t("pages.common.monitors.status.initializing");
};
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.mode === "dark"
? "rgba(255, 255, 255, 0.08)"
: "rgba(0, 0, 0, 0.08)",
...sx,
}}
>
<Box
width={7}
height={7}
bgcolor={theme.palette[palette].light}
borderRadius="50%"
marginRight="5px"
/>
<Typography textTransform={"capitalize"}>
{determineStatus(isActive, status)}
</Typography>
</BaseBox>
);
};
export const ValueLabel = ({ value, text }: { value: ValueType; text: string }) => {
const theme = useTheme();
const palette = getValuePalette(value);
const transformedText = text.charAt(0).toUpperCase() + text.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.mode === "dark"
? "rgba(255, 255, 255, 0.08)"
: "rgba(0, 0, 0, 0.08)",
}}
>
<Box
width={7}
height={7}
bgcolor={theme.palette[palette].light}
borderRadius="50%"
marginRight="5px"
/>
{transformedText}
</BaseBox>
);
};
@@ -0,0 +1,576 @@
import Stack from "@mui/material/Stack";
import Grid2 from "@mui/material/Grid2";
import Box from "@mui/material/Box";
import Typography from "@mui/material/Typography";
import Paper from "@mui/material/Paper";
import Table from "@mui/material/Table";
import TableBody from "@mui/material/TableBody";
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 Collapse from "@mui/material/Collapse";
import Tooltip from "@mui/material/Tooltip";
import IconButton from "@mui/material/IconButton";
import {
ChevronsLeft,
ChevronsRight,
ChevronLeft,
ChevronRight,
Coffee,
Ellipsis,
PartyPopper,
} from "lucide-react";
import TablePagination from "@mui/material/TablePagination";
import type { TablePaginationProps, TablePaginationOwnProps } from "@mui/material/TablePagination";
import { useTranslation } from "react-i18next";
import { useState, Fragment } from "react";
import { useTheme } from "@mui/material/styles";
import useMediaQuery from "@mui/material/useMediaQuery";
export type Header<T> = {
id: number | string;
content: React.ReactNode;
onClick?: (event: React.MouseEvent<HTMLTableCellElement | null>, row: T) => void;
render: (row: T) => React.ReactNode;
};
type DataTableProps<T extends { id?: string | number; _id?: string | number }> = {
headers: Header<T>[];
data: T[];
onRowClick?: (row: T) => void;
cardsOnSmallScreens?: boolean;
expandableRows?: boolean;
renderExpandedContent?: (row: T) => React.ReactNode;
emptyViewText?: string;
emptyViewPositive?: boolean;
};
export function DataTable<
T extends {
id?: string | number;
_id?: string | number;
onRowClick?: (row: T) => void;
},
>({
headers,
data,
onRowClick,
cardsOnSmallScreens = true,
expandableRows = false,
renderExpandedContent,
emptyViewText,
emptyViewPositive,
}: DataTableProps<T>) {
const theme = useTheme();
const [expanded, setExpanded] = useState<(string | number) | null>(null);
const handleExpand = (row: T) => {
const key = row.id || row._id || null;
setExpanded(expanded === key ? null : key);
};
const isSmall = useMediaQuery(theme.breakpoints.down("md"));
if (data.length === 0 || headers.length === 0) {
return (
<EmptyView
text={emptyViewText}
positive={emptyViewPositive}
/>
);
}
const keys = [];
// Return stack of cards for small screens
if (isSmall && cardsOnSmallScreens) {
return (
<Stack spacing={theme.spacing(4)}>
{data.map((row) => {
const key = row.id || row._id || Math.random();
keys.push(key);
return (
<Stack
onClick={() => (onRowClick ? onRowClick(row) : null)}
spacing={theme.spacing(4)}
sx={{
borderStyle: "solid",
borderWidth: 1,
borderColor: theme.palette.divider,
borderRadius: theme.shape.borderRadius,
padding: theme.spacing(4),
cursor: onRowClick ? "pointer" : "default",
}}
key={key}
>
{headers.map((header) => {
return (
<Grid2
container
key={header.id}
>
<Grid2
size={5}
display={"flex"}
alignItems={"center"}
>
<Typography
component="div"
color={theme.palette.text.primary}
>
{header.content}
</Typography>
</Grid2>
<Grid2
size={7}
display="flex"
alignItems={"center"}
>
{header.render(row)}{" "}
</Grid2>
</Grid2>
);
})}
{expandableRows && renderExpandedContent && renderExpandedContent(row) && (
<Grid2 size={12}>{renderExpandedContent(row)}</Grid2>
)}
</Stack>
);
})}
</Stack>
);
}
return (
<TableContainer
component={Paper}
elevation={0}
sx={{ boxShadow: "none" }}
>
<Table
sx={{
"&.MuiTable-root :is(.MuiTableHead-root, .MuiTableBody-root) :is(th, td)": {
paddingLeft: theme.spacing(8),
},
"& .MuiTableCell-root": {
borderBottom: `1px solid ${theme.palette.divider}`,
},
"& .MuiTableHead-root .MuiTableRow-root": {
height: "28px",
},
"& :is(th)": {
backgroundColor: theme.palette.background.paper,
color: theme.palette.text.secondary,
fontWeight: 500,
textTransform: "uppercase",
padding: `${theme.spacing(2)} ${theme.spacing(8)}`,
fontSize: theme.typography.fontSize,
},
"& :is(td)": {
backgroundColor: theme.palette.background.paper,
color: theme.palette.text.secondary,
padding: `${theme.spacing(7)} ${theme.spacing(8)}`,
fontSize: theme.typography.fontSize,
},
"& .MuiTableBody-root .MuiTableRow-root:last-child .MuiTableCell-root": {
borderBottom: "none",
},
}}
>
<TableHead>
<TableRow>
{headers.map((header, idx) => {
return (
<TableCell
align={idx === 0 ? "left" : "center"}
key={header.id}
>
{header.content}
</TableCell>
);
})}
</TableRow>
</TableHead>
<TableBody>
{data.map((row) => {
const key = row.id || row._id || Math.random();
const isExpanded = expanded === key;
return (
<Fragment key={key}>
<TableRow
sx={{
cursor: onRowClick ? "pointer" : "default",
}}
onClick={() => {
if (expandableRows) handleExpand(row);
else if (onRowClick) onRowClick(row);
}}
>
{headers.map((header, index) => {
return (
<TableCell
align={index === 0 ? "left" : "center"}
key={header.id}
onClick={
header.onClick ? (e) => header.onClick!(e, row) : undefined
}
>
{header.render(row)}
</TableCell>
);
})}
</TableRow>
{expandableRows &&
renderExpandedContent &&
renderExpandedContent(row) && (
<TableRow>
<TableCell
colSpan={headers.length}
style={{
borderBottom: isExpanded ? undefined : "none",
paddingTop: 0,
paddingBottom: 0,
}}
>
<Collapse
in={isExpanded}
timeout="auto"
unmountOnExit
>
<Box sx={{ pt: 4, pb: 4 }}>{renderExpandedContent(row)}</Box>
</Collapse>
</TableCell>
</TableRow>
)}
</Fragment>
);
})}
</TableBody>
</Table>
</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: { xs: 0, md: 2.5 } }}
className="table-pagination-actions"
>
<IconButton
onClick={handleFirstPageButtonClick}
disabled={page === 0}
aria-label="first page"
>
{theme.direction === "rtl" ? (
<ChevronsRight
size={20}
strokeWidth={1.5}
/>
) : (
<ChevronsLeft
size={20}
strokeWidth={1.5}
/>
)}
</IconButton>
<IconButton
onClick={handleBackButtonClick}
disabled={page === 0}
aria-label="previous page"
>
{theme.direction === "rtl" ? (
<ChevronRight
size={20}
strokeWidth={1.5}
/>
) : (
<ChevronLeft
size={20}
strokeWidth={1.5}
/>
)}
</IconButton>
<IconButton
onClick={handleNextButtonClick}
disabled={page >= Math.ceil(count / rowsPerPage) - 1}
aria-label="next page"
>
{theme.direction === "rtl" ? (
<ChevronLeft
size={20}
strokeWidth={1.5}
/>
) : (
<ChevronRight
size={20}
strokeWidth={1.5}
/>
)}
</IconButton>
<IconButton
onClick={handleLastPageButtonClick}
disabled={page >= Math.ceil(count / rowsPerPage) - 1}
aria-label="last page"
>
{theme.direction === "rtl" ? (
<ChevronsLeft
size={20}
strokeWidth={1.5}
/>
) : (
<ChevronsRight
size={20}
strokeWidth={1.5}
/>
)}
</IconButton>
</Box>
);
}
interface HasMoreTablePaginationActionsProps {
hasMore?: boolean;
page: number;
onPageChange: (event: React.MouseEvent<HTMLButtonElement>, newPage: number) => void;
}
function HasMoreTablePaginationActions(props: HasMoreTablePaginationActionsProps) {
const theme = useTheme();
const { hasMore, page, 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);
};
return (
<Box
sx={{ flexShrink: 0, ml: { xs: 0, md: 2.5 } }}
className="table-pagination-actions"
>
<IconButton
onClick={handleFirstPageButtonClick}
disabled={page === 0}
aria-label="first page"
>
{theme.direction === "rtl" ? (
<ChevronsRight
size={20}
strokeWidth={1.5}
/>
) : (
<ChevronsLeft
size={20}
strokeWidth={1.5}
/>
)}
</IconButton>
<IconButton
onClick={handleBackButtonClick}
disabled={page === 0}
aria-label="previous page"
>
{theme.direction === "rtl" ? (
<ChevronRight
size={20}
strokeWidth={1.5}
/>
) : (
<ChevronLeft
size={20}
strokeWidth={1.5}
/>
)}
</IconButton>
<IconButton
onClick={handleNextButtonClick}
disabled={hasMore === false}
aria-label="next page"
>
{theme.direction === "rtl" ? (
<ChevronLeft
size={20}
strokeWidth={1.5}
/>
) : (
<ChevronRight
size={20}
strokeWidth={1.5}
/>
)}
</IconButton>
<Tooltip title={hasMore === true ? "More pages available" : "No more pages"}>
<IconButton
disabled={hasMore === false}
aria-label="next page"
>
<Ellipsis
size={20}
strokeWidth={1.5}
/>
</IconButton>
</Tooltip>
</Box>
);
}
interface PaginationProps extends TablePaginationOwnProps {
component?: React.ElementType;
hasMore?: boolean;
}
export const Pagination = ({ ...props }: PaginationProps) => {
const { hasMore, ...rest } = props;
const isSmall = useMediaQuery((theme: any) => theme.breakpoints.down("sm"));
const theme = useTheme();
const labelDisplayedRows = ({
from,
to,
count,
}: {
from: number;
to: number;
count: number;
}) => {
if (hasMore) {
return to === 0 ? "" : `${from}–${to}`;
}
return `${from}–${to} of ${count}`;
};
return (
<TablePagination
ActionsComponent={(props) =>
typeof hasMore === "boolean" ? (
<HasMoreTablePaginationActions
{...props}
hasMore={hasMore}
/>
) : (
<TablePaginationActions {...props} />
)
}
rowsPerPageOptions={[5, 10, 25]}
labelDisplayedRows={labelDisplayedRows}
{...rest}
sx={{
"& .MuiTablePagination-toolbar": isSmall
? {
display: "grid",
gridTemplateColumns: "1fr 1fr",
gridAutoRows: "auto",
rowGap: theme.spacing(1),
alignItems: "center",
justifyItems: "center",
paddingLeft: 0,
paddingRight: 0,
}
: { display: "flex", alignItems: "center" },
"& .MuiTablePagination-selectLabel": {
gridColumn: "1",
gridRow: "1",
justifySelf: "center",
},
"& .MuiTablePagination-select": {
gridColumn: "2",
gridRow: "1",
justifySelf: "center",
},
"& .MuiTablePagination-displayedRows": isSmall
? { gridColumn: "1 / span 2", gridRow: "2", justifySelf: "center" }
: {},
"& .table-pagination-actions": isSmall
? { gridColumn: "1 / span 2", gridRow: "3", justifySelf: "center" }
: {},
"& .MuiSelect-select": {
border: 1,
borderColor: theme.palette.divider,
borderRadius: theme.shape.borderRadius,
},
}}
/>
);
};
const EmptyView = ({ text, positive }: { text?: string; positive?: boolean }) => {
const theme = useTheme();
const { t } = useTranslation();
const Icon = positive ? PartyPopper : Coffee;
return (
<Stack
alignItems={"center"}
justifyContent={"center"}
sx={{
py: theme.spacing(12),
px: theme.spacing(4),
borderWidth: 1,
borderStyle: "solid",
borderColor: theme.palette.divider,
borderRadius: theme.shape.borderRadius,
textAlign: "center",
}}
>
<Box
sx={{
width: 64,
height: 64,
borderRadius: "50%",
backgroundColor: theme.palette.action.hover,
display: "flex",
alignItems: "center",
justifyContent: "center",
mb: theme.spacing(2),
}}
>
<Icon
size={28}
strokeWidth={1}
color={theme.palette.text.secondary}
/>
</Box>
<Typography
variant="subtitle1"
color={theme.palette.text.primary}
sx={{ fontWeight: 500 }}
>
{text ?? t("common.table.empty")}
</Typography>
</Stack>
);
};
@@ -3,3 +3,8 @@ export * from "./BasePage";
export * from "./Fallback";
export * from "./SkeletonCard";
export * from "./BulletPointCheck";
export * from "./StatusBox";
export * from "./BaseBox";
export * from "./Table";
export { DataTable as Table } from "./Table";
export * from "./StatusLabel";
@@ -0,0 +1,88 @@
import Autocomplete from "@mui/material/Autocomplete";
import type { AutocompleteProps } from "@mui/material/Autocomplete";
import { TextField, Checkbox } from "@/Components/v2/inputs";
import ListItem from "@mui/material/ListItem";
import Stack from "@mui/material/Stack";
import { useTheme } from "@mui/material/styles";
import { FieldLabel } from "./FieldLabel";
import { ChevronDown } from "lucide-react";
type AutoCompleteInputProps = Omit<
AutocompleteProps<any, boolean, boolean, boolean>,
"renderInput"
> & {
renderInput?: AutocompleteProps<any, boolean, boolean, boolean>["renderInput"];
fieldLabel?: string;
required?: boolean;
};
export const AutoCompleteInput = ({
fieldLabel,
required,
renderInput,
...props
}: AutoCompleteInputProps) => {
const theme = useTheme();
const multiple = props.multiple;
const defaultRenderInput = (params: any) => (
<TextField
{...params}
placeholder="Type to search"
/>
);
const autocomplete = (
<Autocomplete
{...props}
disableCloseOnSelect={!!multiple}
popupIcon={
<ChevronDown
size={18}
strokeWidth={1.5}
style={{ marginRight: theme.spacing(3) }}
/>
}
renderInput={renderInput || defaultRenderInput}
getOptionKey={(option) => option.id}
renderTags={() => null}
renderOption={(props, option, { selected }) => {
const { key, ...optionProps } = props;
return (
<ListItem
key={key}
{...optionProps}
>
<Stack
direction={"row"}
alignItems={"center"}
gap={theme.spacing(2)}
>
{multiple && <Checkbox checked={selected} />}
{option.name}
</Stack>
</ListItem>
);
}}
sx={{
"&.MuiAutocomplete-root .MuiAutocomplete-input": {
padding: `0 ${theme.spacing(5)}`,
},
"& .MuiInputBase-root .MuiAutocomplete-endAdornment": {
right: theme.spacing(3),
},
}}
/>
);
if (fieldLabel) {
return (
<Stack spacing={theme.spacing(2)}>
<FieldLabel required={required}>{fieldLabel}</FieldLabel>
{autocomplete}
</Stack>
);
}
return autocomplete;
};
@@ -0,0 +1,56 @@
import { forwardRef } from "react";
import TextField from "@mui/material/TextField";
import type { TextFieldProps } from "@mui/material";
import { typographyLevels } from "@/Utils/Theme/v2Palette";
import { useTheme } from "@mui/material/styles";
import Stack from "@mui/material/Stack";
import { FieldLabel } from "./FieldLabel";
interface TextInputProps extends Omit<TextFieldProps, "label"> {
fieldLabel?: string;
required?: boolean;
}
export const TextInput = forwardRef<HTMLInputElement, TextInputProps>(function TextInput(
{ fieldLabel, required, ...props },
ref
) {
const theme = useTheme();
const input = (
<TextField
{...props}
inputRef={ref}
sx={{
"& .MuiOutlinedInput-root": {
borderRadius: theme.shape.borderRadius,
height: 34,
fontSize: typographyLevels.base,
},
"& .MuiOutlinedInput-notchedOutline": {
borderColor: theme.palette.divider,
},
"&:hover .MuiOutlinedInput-notchedOutline": {
borderColor: theme.palette.divider,
},
"& .MuiFormHelperText-root": {
marginLeft: 0,
marginRight: 0,
marginTop: theme.spacing(1),
},
}}
/>
);
if (fieldLabel) {
return (
<Stack spacing={theme.spacing(2)}>
<FieldLabel required={required}>{fieldLabel}</FieldLabel>
{input}
</Stack>
);
}
return input;
});
TextInput.displayName = "TextInput";
@@ -2,3 +2,5 @@ export { ButtonInput as Button } from "./Button";
export * from "./FieldLabel";
export { SelectInput as Select } from "./Select";
export { CheckboxInput as Checkbox } from "./Checkbox";
export { AutoCompleteInput as Autocomplete } from "./AutoComplete";
export { TextInput as TextField } from "./TextInput";
@@ -16,6 +16,7 @@ export const FilterControls = ({
setSelectedStatus,
selectedState,
setSelectedState,
onClearFilters,
}: {
selectedTypes: MonitorType[];
setSelectedTypes: React.Dispatch<React.SetStateAction<MonitorType[]>>;
@@ -23,6 +24,7 @@ export const FilterControls = ({
setSelectedStatus: React.Dispatch<React.SetStateAction<string>>;
selectedState: string;
setSelectedState: React.Dispatch<React.SetStateAction<string>>;
onClearFilters: () => void;
}) => {
const theme = useTheme();
const isFilterActive =
@@ -78,11 +80,7 @@ export const FilterControls = ({
{isFilterActive && (
<Button
variant="contained"
onClick={() => {
setSelectedTypes([]);
setSelectedStatus("");
setSelectedState("");
}}
onClick={onClearFilters}
>
Clear Filters
</Button>
+5
View File
@@ -29,6 +29,7 @@ const initialState = {
distributedUptimeEnabled: false,
language: "en",
starPromptOpen: true,
chartType: "histogram",
};
const uiSlice = createSlice({
@@ -73,6 +74,9 @@ const uiSlice = createSlice({
setStarPromptOpen: (state, action) => {
state.starPromptOpen = action.payload;
},
setChartType: (state, action) => {
state.chartType = action.payload;
},
},
});
@@ -88,4 +92,5 @@ export const {
setDistributedUptimeEnabled,
setLanguage,
setStarPromptOpen,
setChartType,
} = uiSlice.actions;
+76 -1
View File
@@ -1,7 +1,9 @@
import useSWR from "swr";
import type { SWRConfiguration } from "swr";
import type { AxiosRequestConfig } from "axios";
import { get } from "@/Utils/ApiClient";
import { get, patch, post } from "@/Utils/ApiClient";
import { useState } from "react";
import { useToast } from "@/Hooks/UseToast";
interface ApiResponse<T> {
success: boolean;
@@ -32,3 +34,76 @@ export const useGet = <T>(
refetch: mutate,
};
};
export const usePost = <B = any, R = any>() => {
const [loading, setLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
const { toastError, toastSuccess } = useToast();
const postFn = async (
endpoint: string,
body: B,
config?: AxiosRequestConfig
): Promise<ApiResponse<R> | null> => {
setLoading(true);
setError(null);
try {
const res = await post<ApiResponse<R>>(endpoint, body, {
...config,
headers: {
...config?.headers,
},
});
toastSuccess(res.data?.msg || "Operation successful");
return res.data;
} catch (err: any) {
const errMsg = err?.response?.data?.msg || err.message || "An error occurred";
console.error(err);
toastError(errMsg);
setError(errMsg);
return null;
} finally {
setLoading(false);
}
};
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 { toastError, toastSuccess } = useToast();
const patchFn = async (
endpoint: string,
body?: B,
config?: AxiosRequestConfig
): Promise<ApiResponse<R> | null> => {
setLoading(true);
setError(null);
try {
const res = await patch<ApiResponse<R>>(endpoint, body, {
...config,
headers: {
...config?.headers,
},
});
toastSuccess(res.data?.msg || "Operation successful");
return res.data;
} catch (err: any) {
console.error(err);
const errMsg = err?.response?.data?.msg || err.message || "An error occurred";
toastError(errMsg);
setError(errMsg);
return null;
} finally {
setLoading(false);
}
};
return { patch: patchFn, loading, error };
};
+23
View File
@@ -0,0 +1,23 @@
import { toast, type ToastOptions } from "react-toastify";
export const useToast = () => {
const showToast = (message: string, options?: ToastOptions) => {
toast.dismiss();
const baseStyle: React.CSSProperties = {
whiteSpace: "pre-line",
wordBreak: "break-word",
};
toast(message, {
...options,
style: { ...baseStyle, ...(options?.style || {}) },
});
};
const toastSuccess = (msg: string, opts?: ToastOptions) =>
showToast(msg, { ...opts, type: "success" });
const toastError = (msg: string, opts?: ToastOptions) =>
showToast(msg, { ...opts, type: "error" });
const toastInfo = (msg: string, opts?: ToastOptions) =>
showToast(msg, { ...opts, type: "info" });
return { showToast, toastSuccess, toastError, toastInfo };
};
@@ -0,0 +1,276 @@
import Stack from "@mui/material/Stack";
import Box from "@mui/material/Box";
import Typography from "@mui/material/Typography";
import {
Table,
type Header,
Pagination,
StatusLabel,
} from "@/Components/v2/design-elements";
import { HeatmapResponseTime, HistogramResponseTime } from "@/Components/v2/common";
import { ActionsMenu } from "@/Components/v2/actions-menu";
import { ArrowDown, ArrowUp } from "lucide-react";
import { useTranslation } from "react-i18next";
import { useTheme } from "@mui/material/styles";
import { useNavigate } from "react-router-dom";
import { usePost } from "@/Hooks/UseApi";
import type { Check } from "@/Types/Check";
import type { Monitor } from "@/Types/Monitor";
import type { ActionMenuItem } from "@/Components/v2/actions-menu";
// import type { ChartType } from "@/features/uiSlice";
export const MonitorTable = ({
monitors,
refetch,
// setSelectedMonitor,
// sortField,
// setSortField,
// sortOrder,
// setSortOrder,
count,
page,
setPage,
rowsPerPage,
setRowsPerPage,
}: {
monitors: Monitor[];
refetch: Function;
// setSelectedMonitor: Function;
// sortField: string;
// setSortField: (field: string) => void;
// sortOrder: "asc" | "desc";
// setSortOrder: (order: "asc" | "desc") => void;
count: number;
page: number;
setPage: (page: number) => void;
rowsPerPage: number;
setRowsPerPage: (rowsPerPage: number) => void;
}) => {
const { t } = useTranslation();
const theme = useTheme();
const navigate = useNavigate();
const chartType = "histogram";
const {
post,
// loading: isPosting,
// error: postError,
} = usePost<any, Monitor>();
// const handlePageChange = (
// _e: React.MouseEvent<HTMLButtonElement> | null,
// newPage: number
// ) => {
// setPage(newPage);
// };
// const handleRowsPerPageChange = (
// e: React.ChangeEvent<HTMLTextAreaElement | HTMLInputElement>
// ) => {
// const value = Number(e.target.value);
// setPage(0);
// setRowsPerPage(value);
// };
// const handleSort = (e: any, field: string) => {
// e.preventDefault();
// e.stopPropagation();
// if (sortField === field) {
// const newOrder = sortOrder === "asc" ? "desc" : "asc";
// setSortOrder(newOrder);
// } else {
// setSortField(field);
// setSortOrder("asc");
// }
// refetch();
// };
const getActions = (monitor: Monitor): ActionMenuItem[] => {
return [
{
id: 1,
label: t("pages.common.monitors.actions.openSite"),
action: () => {
window.open(monitor.url, "_blank", "noreferrer");
},
closeMenu: true,
},
{
id: 2,
label: t("pages.common.monitors.actions.details"),
action: () => {
navigate(`${monitor.id}`);
},
},
{
id: 3,
label: t("pages.common.monitors.actions.incidents"),
action: () => {
navigate(`/incidents?monitorId=${monitor.id}`);
},
},
{
id: 4,
label: t("pages.common.monitors.actions.configure"),
action: () => {
navigate(`/uptime/create/${monitor.id}`);
},
},
// {
// id: 5,
// label: "Clone",
// action: () => {
// },
// },
{
id: 6,
label:
monitor.isActive === false
? t("pages.common.monitors.actions.resume")
: t("pages.common.monitors.actions.pause"),
action: async () => {
await post(`/monitors/pause/${monitor.id}`, {});
refetch();
},
closeMenu: true,
},
{
id: 7,
label: (
<Typography color={theme.palette.error.main}>
{t("pages.common.monitors.actions.delete")}
</Typography>
),
action: () => {
// setSelectedMonitor(monitor);
},
closeMenu: true,
},
];
};
const getHeaders = (chartType: string) => {
const renderSortIcon = (isActive: boolean) => (
<Box
width={16}
display="inline-flex"
justifyContent="center"
>
{/* {isActive ? (
sortOrder === "asc" ? (
<ArrowUp size={16} />
) : (
<ArrowDown size={16} />
)
) : null} */}
</Box>
);
const headers: Header<Monitor>[] = [
{
id: "name",
content: (
<Stack
gap={theme.spacing(4)}
direction={"row"}
alignItems={"center"}
// onClick={(e) => handleSort(e, "name")}
sx={{ cursor: "pointer" }}
>
{t("common.table.headers.name")}
{/* {renderSortIcon(sortField === "name")} */}
</Stack>
),
render: (row) => {
return row?.name;
},
},
{
id: "status",
content: (
<Stack
gap={theme.spacing(4)}
direction={"row"}
justifyContent={"center"}
alignItems={"center"}
// onClick={(e) => handleSort(e, "status")}
sx={{ cursor: "pointer" }}
>
{t("common.table.headers.status")}
{/* {renderSortIcon(sortField === "status")} */}
</Stack>
),
render: (row) => {
return (
<StatusLabel
status={row.status}
isActive={row.isActive}
/>
);
},
},
{
id: "histogram",
content: t("pages.uptime.table.headers.responseTime"),
render: (row) => {
if (chartType === "histogram") {
return <HistogramResponseTime checks={row.checks} />;
} else {
return <HeatmapResponseTime checks={row.checks} />;
}
},
},
{
id: "type",
content: (
<Stack
gap={theme.spacing(4)}
direction={"row"}
justifyContent={"center"}
alignItems={"center"}
// onClick={(e) => handleSort(e, "type")}
sx={{ cursor: "pointer" }}
>
{t("common.table.headers.type")}
{/* {renderSortIcon(sortField === "type")} */}
</Stack>
),
render: (row) => {
return row.type;
},
},
{
id: "actions",
content: t("common.table.headers.actions"),
render: (row) => {
return <ActionsMenu items={getActions(row)} />;
},
},
];
return headers;
};
const headers = getHeaders(chartType);
return (
<Box>
<Table
headers={headers}
data={monitors}
onRowClick={(row) => {
navigate(`/uptime/${row.id}`);
}}
/>
<Pagination
component="div"
count={count}
page={page}
rowsPerPage={rowsPerPage}
onPageChange={(_e, newPage) => setPage(newPage)}
onRowsPerPageChange={(e) => setRowsPerPage(Number(e.target.value))}
/>
</Box>
);
};
+125 -27
View File
@@ -1,39 +1,86 @@
import { useGet } from "@/Hooks/UseApi";
import { MonitorBasePageWithStates } from "@/Components/v2/design-elements";
import type { Monitor, MonitorType, MonitorsWithChecksResponse } from "@/Types/Monitor";
import { FilterControls } from "@/Components/v2/monitors";
import { useState, useMemo } from "react";
import { useSelector } from "react-redux";
import {
MonitorBasePageWithStates,
UpStatusBox,
DownStatusBox,
PausedStatusBox,
} from "@/Components/v2/design-elements";
import { TextField } from "@/Components/v2/inputs";
import Stack from "@mui/material/Stack";
import { MonitorTable } from "@/Pages/Uptime/Monitors/Components/UptimeMonitorsTable";
import { useTranslation } from "react-i18next";
import useMediaQuery from "@mui/material/useMediaQuery";
import { useGet } from "@/Hooks/UseApi";
import type { Monitor, MonitorType, MonitorsWithChecksResponse } from "@/Types/Monitor";
import { useState, useMemo, useCallback } from "react";
import { useSelector, useDispatch } from "react-redux";
import { setRowsPerPage } from "@/Features/UI/uiSlice.js";
import type { RootState } from "@/Types/state";
import { useTheme } from "@mui/material";
const UptimeMonitorsPage = () => {
const { t } = useTranslation();
const theme = useTheme();
const isSmall = useMediaQuery(theme.breakpoints.down("md"));
const dispatch = useDispatch();
// Redux state
const rowsPerPage = useSelector((state: RootState) => state.ui.monitors.rowsPerPage);
const rowsPerPage = useSelector(
(state: RootState) => state.ui?.monitors?.rowsPerPage ?? 10
);
// Local state
const [selectedTypes, setSelectedTypes] = useState<MonitorType[]>([]);
const [selectedStatus, setSelectedStatus] = useState<string>("");
const [selectedState, setSelectedState] = useState<string>("");
const [page, setPage] = useState(0);
const [search, setSearch] = useState("");
const [sort, setSort] = useState<
{ field: string; order: "asc" | "desc" } | undefined
>();
const [page, setPage] = useState<number>(0);
const [search, setSearch] = useState<string>("");
const [sort, setSort] = useState<{ field: string; order: "asc" | "desc" } | undefined>(
undefined
);
// Convert filter selections to API filter values
// Status: "up" -> true, "down" -> false
// State: "active" -> true, "paused" -> false
const toFilterStatus = useMemo(() => {
if (selectedStatus === "up") return "true";
if (selectedStatus === "down") return "false";
return undefined;
}, [selectedStatus]);
const toFilterActive = useMemo(() => {
if (selectedState === "active") return "true";
if (selectedState === "paused") return "false";
return undefined;
}, [selectedState]);
// Determine field and filter for the API request
// Priority: status > isActive > search > sort
const filterLookup = new Map<string | undefined, string>([
[toFilterStatus, "status"],
[toFilterActive, "isActive"],
]);
const activeFilter = [...filterLookup].find(([key]) => key !== undefined);
const field = activeFilter?.[1] || (search ? "name" : sort?.field);
const filter = activeFilter?.[0] || search;
// Default to all types when none selected
const effectiveTypes =
selectedTypes.length > 0 ? selectedTypes : ["http", "ping", "docker", "port"];
// Build URL for monitors with checks
const monitorsWithChecksUrl = useMemo(() => {
const params = new URLSearchParams();
const types =
selectedTypes.length > 0 ? selectedTypes : ["http", "ping", "docker", "port"];
types.forEach((type) => params.append("type", type));
effectiveTypes.forEach((type) => params.append("type", type));
params.append("limit", "25");
if (page) params.append("page", String(page));
if (page !== undefined) params.append("page", String(page));
if (rowsPerPage) params.append("rowsPerPage", String(rowsPerPage));
if (search) params.append("filter", search);
if (sort?.field) params.append("field", sort.field);
if (filter) params.append("filter", filter);
if (field) params.append("field", field);
if (sort?.order) params.append("order", sort.order);
return `/monitors/team/with-checks?${params.toString()}`;
}, [selectedTypes, page, rowsPerPage, search, sort]);
}, [effectiveTypes, page, rowsPerPage, filter, field, sort?.order]);
// Data fetching
const {
@@ -46,10 +93,23 @@ const UptimeMonitorsPage = () => {
data: monitorsWithChecksData,
isLoading: monitorsWithChecksLoading,
error: monitorsWithChecksError,
} = useGet<MonitorsWithChecksResponse>(monitorsWithChecksUrl);
refetch,
} = useGet<MonitorsWithChecksResponse>(
monitorsWithChecksUrl,
{},
{ refreshInterval: 5000 }
);
const { monitors: monitorsWithChecks, summary, count } = monitorsWithChecksData ?? {};
// Handlers
const handleClearFilters = useCallback(() => {
setSelectedTypes([]);
setSelectedStatus("");
setSelectedState("");
setSearch("");
}, []);
return (
<MonitorBasePageWithStates
loading={isLoading || monitorsWithChecksLoading}
@@ -58,15 +118,53 @@ const UptimeMonitorsPage = () => {
page="uptime"
actionLink="/uptime/create"
>
<FilterControls
selectedTypes={selectedTypes}
setSelectedTypes={setSelectedTypes}
selectedStatus={selectedStatus}
setSelectedStatus={setSelectedStatus}
selectedState={selectedState}
setSelectedState={setSelectedState}
<Stack
direction={isSmall ? "column" : "row"}
gap={theme.spacing(8)}
>
<UpStatusBox n={summary?.upMonitors || 0} />
<DownStatusBox n={summary?.downMonitors || 0} />
<PausedStatusBox n={summary?.pausedMonitors || 0} />
</Stack>
<Stack
direction="row"
justifyContent={"space-between"}
>
<FilterControls
selectedTypes={selectedTypes}
setSelectedTypes={setSelectedTypes}
selectedStatus={selectedStatus}
setSelectedStatus={setSelectedStatus}
selectedState={selectedState}
setSelectedState={setSelectedState}
onClearFilters={handleClearFilters}
/>
<TextField
placeholder={t("pages.uptime.filters.search.placeholder")}
value={search}
onChange={(event) => {
setSearch(event.target.value);
}}
/>
</Stack>
<MonitorTable
monitors={monitorsWithChecks || []}
refetch={refetch}
count={count || 0}
page={page}
setPage={setPage}
rowsPerPage={rowsPerPage}
setRowsPerPage={(e: any) => {
dispatch(
setRowsPerPage({
value: parseInt(e.target.value, 10),
table: "monitors",
})
);
setPage(0);
}}
/>
<pre>{JSON.stringify(monitors, null, 2)}</pre>
</MonitorBasePageWithStates>
);
};
+120 -28
View File
@@ -1,50 +1,142 @@
export interface CheckTimingPhases {
wait: number;
dns: number;
tcp: number;
tls: number;
request: number;
firstByte: number;
download: number;
total: number;
export interface CheckMetadata {
monitorId: string;
teamId: string;
type: string;
}
export interface CheckCpuInfo {
physical_core?: number;
logical_core?: number;
frequency?: number;
temperature?: number[];
free_percent?: number;
usage_percent?: number;
}
export interface CheckMemoryInfo {
total_bytes?: number;
available_bytes?: number;
used_bytes?: number;
usage_percent?: number;
}
export interface CheckHostInfo {
os?: string;
platform?: string;
kernel_version?: string;
}
export interface CheckCaptureInfo {
version?: string;
mode?: string;
}
export interface CheckDiskInfo {
device?: string;
mountpoint?: string;
read_speed_bytes?: number;
write_speed_bytes?: number;
total_bytes?: number;
free_bytes?: number;
usage_percent?: number;
}
export interface CheckErrorInfo {
metric: string[];
err: string;
}
export interface CheckNetworkInterfaceInfo {
name: string;
bytes_sent: number;
bytes_recv: number;
packets_sent: number;
packets_recv: number;
err_in: number;
err_out: number;
drop_in: number;
drop_out: number;
fifo_in: number;
fifo_out: number;
}
export interface CheckAudits {
cls?: ILighthouseAudit;
si?: ILighthouseAudit;
fcp?: ILighthouseAudit;
lcp?: ILighthouseAudit;
tbt?: ILighthouseAudit;
}
export interface ILighthouseAudit {
id?: string;
title?: string;
score?: number | null;
displayValue?: string;
numericValue?: number;
numericUnit?: string;
}
export interface CheckTimings {
start: string;
socket: string;
lookup: string;
connect: string;
secureConnect: string;
response: string;
end: string;
phases: CheckTimingPhases;
start?: number;
socket?: number;
lookup?: number;
connect?: number;
secureConnect?: number;
response?: number;
end?: number;
abort?: number;
error?: number;
phases?: {
wait?: number;
dns?: number;
tcp?: number;
tls?: number;
request?: number;
firstByte?: number;
download?: number;
total?: number;
};
}
export interface Check {
_id: string;
monitorId: string;
type: string;
status: string;
message: string;
id: string;
metadata: CheckMetadata;
status: boolean;
originalResponseTime?: number;
responseTime: number;
normalResponseTime?: number;
httpStatusCode: number;
timings?: CheckTimings;
statusCode: number;
message: string;
ack: boolean;
ackAt?: string | null;
expiry: string;
cpu?: CheckCpuInfo;
memory?: CheckMemoryInfo;
disk?: CheckDiskInfo[];
host?: CheckHostInfo;
errors?: CheckErrorInfo[];
capture?: CheckCaptureInfo;
net?: CheckNetworkInterfaceInfo[];
accessibility?: number;
bestPractices?: number;
seo?: number;
performance?: number;
audits?: CheckAudits;
__v: number;
createdAt: string;
updatedAt: string;
timings: CheckTimings;
}
export interface GroupedCheck {
_id: string;
avgResponseTime: number;
count: number;
totalChecks: number;
}
export interface LatestCheck {
status: string;
status: boolean;
responseTime: number;
checkedAt: string;
_id: string;
id: string;
}
+5 -6
View File
@@ -1,5 +1,5 @@
import type { Check } from "@/Types/Check";
export type MonitorStatus = "up" | "down" | "initializing";
export type MonitorStatus = boolean | undefined;
export const MonitorTypes = [
"http",
@@ -27,8 +27,7 @@ export interface Monitor {
updatedAt: string;
updatedBy: string;
url: string;
__v: number;
_id: string;
id: string;
}
export interface MonitorWithChecks extends Monitor {
@@ -38,9 +37,9 @@ export interface MonitorWithChecks extends Monitor {
export interface MonitorsSummary {
totalMonitors: number;
totalUp: number;
totalDown: number;
totalPaused: number;
upMonitors: number;
downMonitors: number;
pausedMonitors: number;
}
export interface MonitorsWithChecksResponse {
+7 -15
View File
@@ -1,24 +1,16 @@
import "@mui/material/Button";
declare module "@mui/material/styles" {
interface Palette {
accent: Palette["primary"];
tertiary: Palette["primary"];
}
interface PaletteOptions {
accent?: PaletteOptions["primary"];
tertiary?: PaletteOptions["primary"];
}
interface PaletteColor {
contrastTextSecondary?: string;
contrastTextTertiary?: string;
lowContrast?: string;
}
interface Palette {}
interface PaletteOptions {}
}
declare module "@mui/material/Button" {
interface ButtonPropsColorOverrides {
accent: true;
neutral: true;
}
}
declare module "@mui/material/CircularProgress" {
interface CircularProgressPropsColorOverrides {}
}
+1
View File
@@ -32,6 +32,7 @@ export interface UIState {
distributedUptimeEnabled: boolean;
language: string;
starPromptOpen: boolean;
chartType: string;
}
export interface RootState {
+129
View File
@@ -0,0 +1,129 @@
const MIN_OUT = 10;
const MAX_OUT = 100;
export const normalizeResponseTimes = <T, 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 getVal = (check: T): number => {
const v = check[key] as unknown;
return typeof v === "number" && Number.isFinite(v) ? v : 0;
};
const { min, max } = checks.reduce(
(acc, check) => {
const v = getVal(check);
if (v > acc.max) acc.max = v;
if (v < acc.min) acc.min = v;
return acc;
},
{ max: -Infinity, min: Infinity }
);
const range = max - min || 1;
return checks.map((check) => ({
...check,
normalResponseTime: MIN_OUT + ((getVal(check) - min) * (MAX_OUT - MIN_OUT)) / range,
}));
};
// Interpolate color between three theme colors over 0-100 range.
// 0-50 => start (success.light), 50-75 => mid (warning.light), 75-100 => end (error.light)
export const getResponseColor = (
ms: number,
colors: {
start: string | undefined;
mid: string | undefined;
end: string | undefined;
}
): string => {
// New ranges with open high end:
// 0–300ms: interpolate start (good) -> mid (warning)
// 300–600ms: interpolate mid (warning) -> end (error)
// >600ms: solid end (error)
const safe = { ...colors };
if (!safe.start) safe.start = "#4caf50"; // fallback green
if (!safe.mid) safe.mid = "#ff9800"; // fallback orange
if (!safe.end) safe.end = "#f44336"; // fallback red
const toHex = (c: number) => c.toString(16).padStart(2, "0");
const clamp = (n: number) => Math.min(255, Math.max(0, Math.round(n)));
const rgbToHex = (r: number, g: number, b: number) =>
`#${toHex(clamp(r))}${toHex(clamp(g))}${toHex(clamp(b))}`;
const normalizeToHex = (value: string) => {
const v = value.trim();
if (v.startsWith("#")) {
const h = v.slice(1);
const full =
h.length === 3
? h
.split("")
.map((c) => c + c)
.join("")
: h.substring(0, 6);
return `#${full.toLowerCase()}`;
}
const m = v
.replace(/\s+/g, "")
.match(/^rgba?\((\d{1,3}),(\d{1,3}),(\d{1,3})(?:,(0|0?\.\d+|1))?\)$/i);
if (m) {
const r = parseInt(m[1], 10);
const g = parseInt(m[2], 10);
const b = parseInt(m[3], 10);
return rgbToHex(r, g, b);
}
// Fallback neutral
return "#7f7f7f";
};
const parseHex = (hex: string) => {
const h = hex.replace("#", "");
const full =
h.length === 3
? h
.split("")
.map((c) => c + c)
.join("")
: h;
const n = parseInt(full, 16);
return { r: (n >> 16) & 255, g: (n >> 8) & 255, b: n & 255 };
};
const mix = (a: number, b: number, t: number) => Math.round(a + (b - a) * t);
const v = Math.max(0, ms);
if (v <= 300) {
const t = v / 300; // 0..1 from start->mid
const s = parseHex(normalizeToHex(safe.start));
const m = parseHex(normalizeToHex(safe.mid));
const r = mix(s.r, m.r, t);
const g = mix(s.g, m.g, t);
const b = mix(s.b, m.b, t);
return `#${toHex(r)}${toHex(g)}${toHex(b)}`;
}
if (v <= 600) {
const t = (v - 300) / 300; // 0..1 from mid->end
const m = parseHex(normalizeToHex(safe.mid));
const e = parseHex(normalizeToHex(safe.end));
const r = mix(m.r, e.r, t);
const g = mix(m.g, e.g, t);
const b = mix(m.b, e.b, t);
return `#${toHex(r)}${toHex(g)}${toHex(b)}`;
}
return normalizeToHex(safe.end);
};
+120
View File
@@ -0,0 +1,120 @@
import type { Monitor, MonitorStatus, MonitorType } from "@/Types/Monitor";
import type { PaletteKey } from "@/Utils/Theme/v2Theme";
import type { ValueType } from "@/Components/v2/design-elements/StatusLabel";
export const getMonitorPath = (type: MonitorType): string => {
const pathMap: Record<MonitorType, string> = {
http: "uptime",
port: "uptime",
ping: "uptime",
hardware: "hardware",
pagespeed: "pagespeed",
docker: "docker",
game: "game-servers",
unknown: "uptime",
};
return pathMap[type];
};
export const getStatusPalette = (status: MonitorStatus): PaletteKey => {
if (status === true) {
return "success";
}
if (status === false) {
return "error";
}
return "warning";
};
export const getValuePalette = (value: ValueType): PaletteKey => {
const paletteMap: Record<ValueType, PaletteKey> = {
positive: "success",
negative: "error",
neutral: "warning",
};
return paletteMap[value];
};
export const getStatusColor = (status: MonitorStatus, theme: any): string => {
if (status === true) {
return theme.palette.success.light;
}
if (status === false) {
return theme.palette.error.light;
}
return theme.palette.warning.light;
};
export const getResponseTimeColor = (responseTime: number): PaletteKey => {
if (responseTime < 200) {
return "success";
} else if (responseTime < 300) {
return "warning";
} else {
return "error";
}
};
export const getInfraGaugeColor = (val: number, theme: any) => {
if (val < 50) {
return theme.palette.success.main;
} else if (val < 80) {
return theme.palette.warning.light;
} else {
return theme.palette.error.light;
}
};
export const getPageSpeedPalette = (score: number): PaletteKey => {
if (score >= 90) return "success";
else if (score >= 50) 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;
};
export interface IStatusPageHeaderConfig {
paletteKey: PaletteKey;
message: string;
}
export const getStatusPageHeaderConfig = (
monitors: Monitor[],
t: any
): IStatusPageHeaderConfig => {
if (!monitors || monitors.length === 0) {
return { paletteKey: "error", message: "No monitors available" };
}
const allUp = monitors.every((monitor) => monitor.status === true);
const anyDown = monitors.some((monitor) => monitor.status === false);
const allDown = monitors.every((monitor) => monitor.status === false);
if (allUp)
return {
paletteKey: "success",
message: t("statusPage.details.statusHeader.allUp"),
};
if (allDown)
return {
paletteKey: "error",
message: t("statusPage.details.statusHeader.allDown"),
};
if (anyDown)
return {
paletteKey: "warning",
message: t("statusPage.details.statusHeader.anyDown"),
};
return {
paletteKey: "warning",
message: t("statusPage.details.statusHeader.anyDown"),
};
};
+52
View File
@@ -0,0 +1,52 @@
import dayjs from "dayjs";
import duration from "dayjs/plugin/duration";
import utc from "dayjs/plugin/utc";
import timezone from "dayjs/plugin/timezone";
import customParseFormat from "dayjs/plugin/customParseFormat";
dayjs.extend(utc);
dayjs.extend(timezone);
dayjs.extend(customParseFormat);
dayjs.extend(duration);
export const MS_PER_SECOND = 1000;
export const MS_PER_MINUTE = 60 * MS_PER_SECOND;
export const MS_PER_HOUR = 60 * MS_PER_MINUTE;
export const MS_PER_DAY = 24 * MS_PER_HOUR;
export const MS_PER_WEEK = MS_PER_DAY * 7;
export const formatDateWithTz = (timestamp: string, format: string, timezone: string) => {
if (!timestamp) {
return "Unknown time";
}
const formattedDate = dayjs(timestamp).tz(timezone).format(format);
return formattedDate;
};
export const tickDateFormatLookup = (range: string) => {
const tickFormatLookup: Record<string, string> = {
"1h": "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> = {
"1h": "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;
};
+53 -12
View File
@@ -1,4 +1,27 @@
{
"common": {
"appName": "Checkmate",
"monitoringAgentName": "Capture",
"buttons": {
"toggleTheme": "Toggles light & dark"
},
"toasts": {
"networkError": "Network error",
"checkConnection": "Please check your connection",
"unknownError": "Unknown error"
},
"table": {
"headers": {
"actions": "Actions",
"active": "Active",
"message": "Message",
"monitor": "Monitor",
"name": "Name",
"status": "Status",
"type": "Type"
}
}
},
"submit": "Submit",
"title": "Title",
"distributedStatusHeaderText": "Real-time, real-device coverage",
@@ -126,7 +149,31 @@
"add": "Add",
"monitors": "monitors",
"pages": {
"common": {
"monitors": {
"status": {
"up": "up",
"down": "down",
"paused": "paused",
"initializing": "initializing"
},
"actions": {
"openSite": "Open site",
"details": "Details",
"incidents": "Incidents",
"configure": "Configure",
"pause": "Pause",
"resume": "Resume",
"delete": "Delete"
}
}
},
"uptime": {
"table": {
"headers": {
"responseTime": "Response time"
}
},
"fallback": {
"title": "An uptime monitor is used to:",
"actionButton": "Create a monitor!",
@@ -136,6 +183,11 @@
"Monitor HTTP endpoints, pings, containers & ports",
"Track historical uptime and reliability trends"
]
},
"filters": {
"search": {
"placeholder": "Search monitors..."
}
}
}
},
@@ -523,18 +575,7 @@
"game": "Enter the IP address or hostname and the port number to ping (e.g., 192.168.1.100 or example.com) and choose game type.",
"https": "Enter the URL or IP to monitor (e.g., https://example.com/ or 192.168.1.100) and add a clear display name that appears on the dashboard."
},
"common": {
"appName": "Checkmate",
"monitoringAgentName": "Capture",
"buttons": {
"toggleTheme": "Toggles light & dark"
},
"toasts": {
"networkError": "Network error",
"checkConnection": "Please check your connection",
"unknownError": "Unknown error"
}
},
"auth": {
"common": {
"navigation": {