iniital uptime

This commit is contained in:
Alex Holliday
2025-10-03 15:40:53 -07:00
parent a46ef5c9dd
commit f6b97c691e
18 changed files with 490 additions and 3 deletions

View File

@@ -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}
/>
);
};

View File

@@ -0,0 +1,69 @@
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";
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>) {
if (data.length === 0 || headers.length === 0) return <div>No data</div>;
return (
<TableContainer component={Paper}>
<Table stickyHeader>
<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>
);
}

View File

@@ -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";

View File

@@ -0,0 +1,12 @@
import Button from "@mui/material/Button";
import type { ButtonProps } from "@mui/material/Button";
import { useTheme } from "@mui/material/styles";
export const ButtonInput: React.FC<ButtonProps> = ({ ...props }) => {
const theme = useTheme();
return (
<Button
{...props}
sx={{ textTransform: "none", height: 34, fontWeight: 400, borderRadius: 2 }}
/>
);
};

View File

@@ -0,0 +1 @@
export { ButtonInput as Button } from "./Button";

View File

@@ -6,6 +6,7 @@ const RootLayout = () => {
const theme = useTheme();
return (
<Stack
overflow={"hidden"}
direction="row"
minHeight="100vh"
>

View File

@@ -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>
);
};

View File

@@ -0,0 +1,91 @@
import Stack from "@mui/material/Stack";
import Box from "@mui/material/Box";
import { useTheme } from "@mui/material/styles";
import type { Check } from "@/Types/Check";
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 barHeight =
logMax === logMin ? 100 : ((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 (
<Box
key={`${check}-${index}`}
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>
);
}
})}
</Stack>
);
};

View File

@@ -0,0 +1 @@
export { HeaderCreate } from "./HeaderCreate";

View File

@@ -0,0 +1,63 @@
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";
const getHeaders = (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;
},
},
];
return headers;
};
export const MonitorTable = ({ monitors }: { monitors: IMonitor[] }) => {
const { t } = useTranslation();
const theme = useTheme();
const isSmall = useMediaQuery(theme.breakpoints.down("md"));
let headers = getHeaders(t);
if (isSmall) {
headers = headers.filter((h) => h.id !== "histogram");
}
return (
<Table
headers={headers}
data={monitors}
/>
);
};

View File

@@ -0,0 +1,49 @@
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, error, loading, refetch } = 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;

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"

12
client/src/Types/Check.ts Normal file
View File

@@ -0,0 +1,12 @@
export interface Check {
_id: string;
status: string;
responseTime: number;
createdAt: string;
}
export interface GroupedCheck {
_id: string;
avgResponseTime: number;
count: number;
}

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;
}

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)",

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,
},
};