diff --git a/client/src/Components/v2/ActionsMenu/index.tsx b/client/src/Components/v2/ActionsMenu/index.tsx new file mode 100644 index 000000000..5b606e5b0 --- /dev/null +++ b/client/src/Components/v2/ActionsMenu/index.tsx @@ -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); + const open = Boolean(anchorEl); + + const handleClick = (event: React.MouseEvent) => { + setAnchorEl(event.currentTarget); + }; + + const handleClose = () => { + setAnchorEl(null); + }; + + return ( +
+ + + + + {items.map((item) => ( + { + if (item.closeMenu) handleClose(); + item.action(); + }} + > + {item.label} + + ))} + +
+ ); +}; diff --git a/client/src/Components/v2/DesignElements/StatusBox.tsx b/client/src/Components/v2/DesignElements/StatusBox.tsx new file mode 100644 index 000000000..540255dfd --- /dev/null +++ b/client/src/Components/v2/DesignElements/StatusBox.tsx @@ -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 = ({ children }) => { + const theme = useTheme(); + return ( + + + + + {children} + + ); +}; + +const StatusBox = ({ + label, + n, + color, +}: { + label: string; + n: number; + color: string | undefined; +}) => { + const theme = useTheme(); + return ( + + + + {label} + + + {n} + + + + ); +}; + +export const UpStatusBox = ({ n }: { n: number }) => { + const theme = useTheme(); + const { t } = useTranslation(); + return ( + + ); +}; + +export const DownStatusBox = ({ n }: { n: number }) => { + const theme = useTheme(); + const { t } = useTranslation(); + return ( + + ); +}; + +export const PausedStatusBox = ({ n }: { n: number }) => { + const theme = useTheme(); + const { t } = useTranslation(); + return ( + + ); +}; diff --git a/client/src/Components/v2/DesignElements/Table.tsx b/client/src/Components/v2/DesignElements/Table.tsx new file mode 100644 index 000000000..6296d260c --- /dev/null +++ b/client/src/Components/v2/DesignElements/Table.tsx @@ -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 = { + id: number | string; + content: React.ReactNode; + onClick?: (event: React.MouseEvent, row: T) => void; + render: (row: T) => React.ReactNode; +}; + +type DataTableProps = { + headers: Header[]; + data: T[]; +}; + +export function DataTable({ + headers, + data, +}: DataTableProps) { + const theme = useTheme(); + if (data.length === 0 || headers.length === 0) return
No data
; + return ( + + + + + {headers.map((header, idx) => { + return ( + + {header.content} + + ); + })} + + + + {data.map((row) => { + const key = row.id || row._id || Math.random(); + + return ( + + {headers.map((header, index) => { + return ( + header.onClick!(e, row) : undefined + } + > + {header.render(row)} + + ); + })} + + ); + })} + +
+
+ ); +} diff --git a/client/src/Components/v2/DesignElements/index.tsx b/client/src/Components/v2/DesignElements/index.tsx index bc21144c0..41b4b319a 100644 --- a/client/src/Components/v2/DesignElements/index.tsx +++ b/client/src/Components/v2/DesignElements/index.tsx @@ -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"; diff --git a/client/src/Components/v2/Inputs/Button.tsx b/client/src/Components/v2/Inputs/Button.tsx new file mode 100644 index 000000000..0bf1d14e1 --- /dev/null +++ b/client/src/Components/v2/Inputs/Button.tsx @@ -0,0 +1,10 @@ +import Button from "@mui/material/Button"; +import type { ButtonProps } from "@mui/material/Button"; +export const ButtonInput: React.FC = ({ ...props }) => { + return ( + + + ); +}; diff --git a/client/src/Components/v2/Monitors/HistogramResponseTime.tsx b/client/src/Components/v2/Monitors/HistogramResponseTime.tsx new file mode 100644 index 000000000..d19ddbf53 --- /dev/null +++ b/client/src/Components/v2/Monitors/HistogramResponseTime.tsx @@ -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(); + + 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 ( + 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 ( + + ); + } else { + return ( + + + + + + ); + } + })} + + ); +}; diff --git a/client/src/Components/v2/Monitors/HistogramResponseTimeTooltip.tsx b/client/src/Components/v2/Monitors/HistogramResponseTimeTooltip.tsx new file mode 100644 index 000000000..d704cf5d4 --- /dev/null +++ b/client/src/Components/v2/Monitors/HistogramResponseTimeTooltip.tsx @@ -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 ( + + + {formatDateWithTz(check?.checkedAt, "ddd, MMMM D, YYYY, HH:mm A", uiTimezone)} + + {check?.responseTime && ( + Response Time: {check.responseTime} ms + )} + + } + > + {children} + + ); +}; diff --git a/client/src/Components/v2/Monitors/index.tsx b/client/src/Components/v2/Monitors/index.tsx new file mode 100644 index 000000000..3b0acbd20 --- /dev/null +++ b/client/src/Components/v2/Monitors/index.tsx @@ -0,0 +1 @@ +export { HeaderCreate } from "./HeaderCreate"; diff --git a/client/src/Pages/v2/Uptime/MonitorTable.tsx b/client/src/Pages/v2/Uptime/MonitorTable.tsx new file mode 100644 index 000000000..93ae498f9 --- /dev/null +++ b/client/src/Pages/v2/Uptime/MonitorTable.tsx @@ -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: Remove, + action: () => { + console.log("Open delete"); + }, + closeMenu: true, + }, + ]; +}; + +const getHeaders = (theme: any, t: Function) => { + const headers: Header[] = [ + { + 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 ( + + + + ); + }, + }, + { + id: "type", + content: t("type"), + render: (row) => { + return row.type; + }, + }, + { + id: "actions", + content: t("actions"), + render: () => { + return ; + }, + }, + ]; + 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 ( + + ); +}; diff --git a/client/src/Pages/v2/Uptime/UptimeMonitors.tsx b/client/src/Pages/v2/Uptime/UptimeMonitors.tsx new file mode 100644 index 000000000..e69da7ebb --- /dev/null +++ b/client/src/Pages/v2/Uptime/UptimeMonitors.tsx @@ -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("/monitors?embedChecks=true"); + const monitors = response?.data ?? ([] as IMonitor[]); + + if (monitors.length === 0 && !loading) { + return "No monitors found"; + } + + return ( + + + + + + + + + + ); +}; + +export default UptimeMonitors; diff --git a/client/src/Routes/v2router.tsx b/client/src/Routes/v2router.tsx index 4be761a4b..742302762 100644 --- a/client/src/Routes/v2router.tsx +++ b/client/src/Routes/v2router.tsx @@ -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" }) => { > Uptime} + element={} /> Test Page} + element={} /> ({ 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)", diff --git a/client/src/Utils/Theme/v2/palette.ts b/client/src/Utils/Theme/v2/palette.ts index 855f7b6b8..43370fe8b 100644 --- a/client/src/Utils/Theme/v2/palette.ts +++ b/client/src/Utils/Theme/v2/palette.ts @@ -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, + }, }; diff --git a/client/src/Utils/Theme/v2/theme.ts b/client/src/Utils/Theme/v2/theme.ts index c278f9004..3d68613b9 100644 --- a/client/src/Utils/Theme/v2/theme.ts +++ b/client/src/Utils/Theme/v2/theme.ts @@ -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, diff --git a/client/src/Utils/TimeUtils.ts b/client/src/Utils/TimeUtils.ts new file mode 100644 index 000000000..5dc9b23c1 --- /dev/null +++ b/client/src/Utils/TimeUtils.ts @@ -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; +};