Merge pull request #3005 from bluewave-labs/feat/v2/uptime

Feat/v2/uptime
This commit is contained in:
Alexander Holliday
2025-10-06 11:48:39 -07:00
committed by GitHub
22 changed files with 713 additions and 3 deletions
@@ -0,0 +1,50 @@
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 "@/assets/icons/settings-bold.svg?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 = (event: React.MouseEvent<any>) => {
setAnchorEl(event.currentTarget);
};
const handleClose = () => {
setAnchorEl(null);
};
return (
<div>
<IconButton onClick={handleClick}>
<Settings />
</IconButton>
<Menu
anchorEl={anchorEl}
open={open}
onClose={handleClose}
>
{items.map((item) => (
<MenuItem
key={item.id}
onClick={() => {
if (item.closeMenu) handleClose();
item.action();
}}
>
{item.label}
</MenuItem>
))}
</Menu>
</div>
);
};
@@ -0,0 +1,101 @@
import Stack from "@mui/material/Stack";
import Typography from "@mui/material/Typography";
import Box from "@mui/material/Box";
import Background from "@/assets/Images/background-grid.svg?react";
import { useTranslation } from "react-i18next";
import { useTheme } from "@mui/material/styles";
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"
>
<Box
position="absolute"
top="-10%"
left="5%"
>
<Background />
</Box>
{children}
</Box>
);
};
const StatusBox = ({
label,
n,
color,
}: {
label: string;
n: number;
color: string | undefined;
}) => {
const theme = useTheme();
return (
<BGBox>
<Stack spacing={theme.spacing(8)}>
<Typography
variant={"h2"}
textTransform="uppercase"
color={theme.palette.primary.contrastTextTertiary}
>
{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("monitorStatus.up")}
n={n}
color={theme.palette.success.lowContrast}
/>
);
};
export const DownStatusBox = ({ n }: { n: number }) => {
const theme = useTheme();
const { t } = useTranslation();
return (
<StatusBox
label={t("monitorStatus.down")}
n={n}
color={theme.palette.error.lowContrast}
/>
);
};
export const PausedStatusBox = ({ n }: { n: number }) => {
const theme = useTheme();
const { t } = useTranslation();
return (
<StatusBox
label={t("monitorStatus.paused")}
n={n}
color={theme.palette.warning.lowContrast}
/>
);
};
@@ -0,0 +1,89 @@
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 { useTheme } from "@mui/material/styles";
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[];
};
export function DataTable<T extends { id?: string | number; _id?: string | number }>({
headers,
data,
}: DataTableProps<T>) {
const theme = useTheme();
if (data.length === 0 || headers.length === 0) return <div>No data</div>;
return (
<TableContainer component={Paper}>
<Table
stickyHeader
sx={{
"&.MuiTable-root :is(.MuiTableHead-root, .MuiTableBody-root) :is(th, td)": {
paddingLeft: theme.spacing(8),
},
"& :is(th)": {
backgroundColor: theme.palette.secondary.main,
color: theme.palette.secondary.contrastText,
fontWeight: 600,
},
"& :is(td)": {
backgroundColor: theme.palette.primary.main,
color: theme.palette.primary.contrastTextSecondary,
},
"& .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();
return (
<TableRow key={key}>
{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>
);
})}
</TableBody>
</Table>
</TableContainer>
);
}
@@ -1,2 +1,4 @@
export { SplitBox as HorizontalSplitBox, ConfigBox } from "./SplitBox";
export { BasePage } from "./BasePage";
export { BGBox, UpStatusBox, DownStatusBox, PausedStatusBox } from "./StatusBox";
export { DataTable as Table } from "./Table";
@@ -0,0 +1,10 @@
import Button from "@mui/material/Button";
import type { ButtonProps } from "@mui/material/Button";
export const ButtonInput: React.FC<ButtonProps> = ({ ...props }) => {
return (
<Button
{...props}
sx={{ textTransform: "none", height: 34, fontWeight: 400, borderRadius: 2 }}
/>
);
};
@@ -0,0 +1 @@
export { ButtonInput as Button } from "./Button";
@@ -6,6 +6,7 @@ const RootLayout = () => {
const theme = useTheme();
return (
<Stack
overflow={"hidden"}
direction="row"
minHeight="100vh"
>
@@ -0,0 +1,35 @@
import Stack from "@mui/material/Stack";
import { Button } from "@/Components/v2/Inputs";
import { useTheme } from "@mui/material/styles";
import { useTranslation } from "react-i18next";
import { useNavigate } from "react-router";
export const HeaderCreate = ({
label,
isLoading,
path,
}: {
label?: string;
isLoading: boolean;
path: string;
}) => {
const theme = useTheme();
const { t } = useTranslation();
const navigate = useNavigate();
return (
<Stack
direction="row"
justifyContent="end"
alignItems="center"
gap={theme.spacing(6)}
>
<Button
loading={isLoading}
variant="contained"
color="accent"
onClick={() => navigate(path)}
>
{label || t("createNew")}
</Button>
</Stack>
);
};
@@ -0,0 +1,99 @@
import Stack from "@mui/material/Stack";
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";
export const HistogramResponseTime = ({ checks }: { checks: Check[] }) => {
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) {
return null;
}
if (checks.length !== 25) {
const placeholders = Array(25 - checks.length).fill("placeholder");
data = [...checks, ...placeholders];
} else {
data = checks;
}
const theme = useTheme();
return (
<Stack
direction="row"
flexWrap="nowrap"
gap={theme.spacing(1.5)}
height="50px"
width="fit-content"
onClick={(event) => event.stopPropagation()}
sx={{
cursor: "default",
}}
>
{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
key={`${check}-${index}`}
position="relative"
width={theme.spacing(4.5)}
height="100%"
bgcolor={theme.palette.primary.lowContrast}
sx={{
borderRadius: theme.spacing(1.5),
}}
/>
);
} else {
return (
<HistogramResponseTimeTooltip
key={`${check}-${index}`}
check={check}
>
<Box
position="relative"
width="9px"
height="100%"
bgcolor={theme.palette.primary.lowContrast}
sx={{
borderRadius: theme.spacing(1.5),
}}
>
<Box
position="absolute"
bottom={0}
width="100%"
height={`${barHeight}%`}
bgcolor={
check.status
? theme.palette.success.lowContrast
: theme.palette.error.lowContrast
}
sx={{
borderRadius: theme.spacing(1.5),
transition: "height 600ms cubic-bezier(0.4, 0, 0.2, 1)",
}}
/>
</Box>
</HistogramResponseTimeTooltip>
);
}
})}
</Stack>
);
};
@@ -0,0 +1,29 @@
import Stack from "@mui/material/Stack";
import Tooltip from "@mui/material/Tooltip";
import Typography from "@mui/material/Typography";
import { formatDateWithTz } from "@/Utils/TimeUtils";
import { useSelector } from "react-redux";
import type { LatestCheck } from "@/Types/Check";
export const HistogramResponseTimeTooltip: React.FC<{
children: React.ReactElement;
check: LatestCheck;
}> = ({ children, check }) => {
const uiTimezone = useSelector((state: any) => state.ui.timezone);
return (
<Tooltip
title={
<Stack>
<Typography>
{formatDateWithTz(check?.checkedAt, "ddd, MMMM D, YYYY, HH:mm A", uiTimezone)}
</Typography>
{check?.responseTime && (
<Typography>Response Time: {check.responseTime} ms</Typography>
)}
</Stack>
}
>
{children}
</Tooltip>
);
};
@@ -0,0 +1 @@
export { HeaderCreate } from "./HeaderCreate";
+131
View File
@@ -0,0 +1,131 @@
import Stack from "@mui/material/Stack";
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 { useTranslation } from "react-i18next";
import { useMediaQuery } from "@mui/material";
import { useTheme } from "@mui/material/styles";
import { ActionsMenu } from "@/Components/v2/ActionsMenu";
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[] }) => {
const { t } = useTranslation();
const theme = useTheme();
const isSmall = useMediaQuery(theme.breakpoints.down("md"));
let headers = getHeaders(theme, t);
if (isSmall) {
headers = headers.filter((h) => h.id !== "histogram");
}
return (
<Table
headers={headers}
data={monitors}
/>
);
};
@@ -0,0 +1,47 @@
import {
BasePage,
UpStatusBox,
DownStatusBox,
PausedStatusBox,
} from "@/Components/v2/DesignElements";
import { HeaderCreate } from "@/Components/v2/Monitors";
import Stack from "@mui/material/Stack";
import { MonitorTable } from "@/Pages/v2/Uptime/MonitorTable";
import { useTheme } from "@mui/material/styles";
import { useGet } from "@/Hooks/v2/UseApi";
import type { ApiResponse } from "@/Hooks/v2/UseApi";
import type { IMonitor } from "@/Types/Monitor";
import { useMediaQuery } from "@mui/material";
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[]);
if (monitors.length === 0 && !loading) {
return "No monitors found";
}
return (
<BasePage>
<HeaderCreate
isLoading={loading}
path="/v2/uptime/create"
/>
<Stack
direction={isSmall ? "column" : "row"}
gap={theme.spacing(8)}
>
<UpStatusBox n={1} />
<DownStatusBox n={1} />
<PausedStatusBox n={1} />
</Stack>
<MonitorTable monitors={monitors} />
</BasePage>
);
};
export default UptimeMonitors;
+3 -2
View File
@@ -4,6 +4,7 @@ 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 RootLayout from "@/Components/v2/Layouts/RootLayout";
@@ -27,11 +28,11 @@ const V2Routes = ({ mode = "light" }) => {
>
<Route
index
element={<h1>Uptime</h1>}
element={<UptimeMonitorsPage />}
/>
<Route
path="uptime"
element={<h1>Test Page</h1>}
element={<UptimeMonitorsPage />}
/>
<Route
path="uptime/create"
+19
View File
@@ -0,0 +1,19 @@
export interface Check {
_id: string;
status: string;
responseTime: number;
createdAt: string;
}
export interface GroupedCheck {
_id: string;
avgResponseTime: number;
count: number;
}
export interface LatestCheck {
status: string;
responseTime: number;
checkedAt: string;
_id: string;
}
+19
View File
@@ -0,0 +1,19 @@
import type { Check } from "@/Types/Check";
export interface IMonitor {
checks: Check[];
createdAt: string;
createdBy: string;
interval: number;
isActive: boolean;
latestChecks: Check[];
n: number;
name: string;
status: string;
type: string;
updatedAt: string;
updatedBy: string;
url: string;
__v: number;
_id: string;
}
+1 -1
View File
@@ -58,8 +58,8 @@ const baseTheme = (palette) => ({
variants: [
{
props: (props) => props.variant === "contained" && props.color === "accent",
backgroundColor: theme.palette.accent.main,
style: {
backgroundColor: theme.palette.accent.main,
color: theme.palette.primary.contrastTextSecondaryDarkBg,
letterSpacing: "0.5px",
textShadow: "0 0 1px rgba(0, 0, 0, 0.15)",
+30
View File
@@ -66,6 +66,21 @@ export const lightPalette = {
main: colors.gray100,
contrastText: colors.blueGray800,
},
success: {
main: colors.green700,
contrastText: colors.offWhite,
lowContrast: colors.green400,
},
warning: {
main: colors.orange700,
contrastText: colors.offWhite,
lowContrast: colors.orange100,
},
error: {
main: colors.red700,
contrastText: colors.offWhite,
lowContrast: colors.red400,
},
};
export const darkPalette = {
@@ -91,4 +106,19 @@ export const darkPalette = {
main: colors.blueGray800,
contrastText: colors.gray100,
},
success: {
main: colors.green100,
contrastText: colors.offBlack,
lowContrast: colors.green200,
},
warning: {
main: colors.orange200,
contrastText: colors.offBlack,
lowContrast: colors.orange600,
},
error: {
main: colors.red100,
contrastText: colors.offBlack,
lowContrast: colors.red600,
},
};
+20
View File
@@ -1,6 +1,8 @@
import { createTheme } from "@mui/material";
import { lightPalette, darkPalette, typographyLevels } from "./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)";
export const theme = (mode: string, palette: any) =>
createTheme({
@@ -66,6 +68,24 @@ export const theme = (mode: string, palette: any) =>
},
},
},
MuiPaper: {
styleOverrides: {
root: ({ theme }) => {
return {
marginTop: 4,
padding: 0,
border: 1,
borderStyle: "solid",
borderColor: theme.palette.primary.lowContrast,
borderRadius: 4,
boxShadow: shadow,
backgroundColor: theme.palette.primary.main,
backgroundImage: "none",
};
},
},
},
},
shape: {
borderRadius: 2,
+25
View File
@@ -0,0 +1,25 @@
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;
};