diff --git a/client/package-lock.json b/client/package-lock.json index 7d6350f45..7cf0e8631 100644 --- a/client/package-lock.json +++ b/client/package-lock.json @@ -26,6 +26,7 @@ "i18next": "25.4.2", "joi": "17.13.3", "mui-color-input": "^6.0.0", + "pretty-ms": "9.3.0", "react": "18.3.1", "react-dom": "^18.2.0", "react-hook-form": "7.63.0", @@ -5184,6 +5185,18 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/parse-ms": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/parse-ms/-/parse-ms-4.0.0.tgz", + "integrity": "sha512-TXfryirbmq34y8QBwgqCVLi+8oA3oWx2eAnSn62ITyEhEYaWRlVZ2DvMM9eZbMs/RfxPu/PK/aBLyGj4IrqMHw==", + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/path-exists": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/path-exists/-/path-exists-4.0.0.tgz", @@ -5311,6 +5324,21 @@ "url": "https://github.com/prettier/prettier?sponsor=1" } }, + "node_modules/pretty-ms": { + "version": "9.3.0", + "resolved": "https://registry.npmjs.org/pretty-ms/-/pretty-ms-9.3.0.tgz", + "integrity": "sha512-gjVS5hOP+M3wMm5nmNOucbIrqudzs9v/57bWRHQWLYklXqoXKrVfYW2W9+glfGsqtPgpiz5WwyEEB+ksXIx3gQ==", + "license": "MIT", + "dependencies": { + "parse-ms": "^4.0.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/prop-types": { "version": "15.8.1", "resolved": "https://registry.npmjs.org/prop-types/-/prop-types-15.8.1.tgz", diff --git a/client/package.json b/client/package.json index 6d4858027..376671942 100644 --- a/client/package.json +++ b/client/package.json @@ -31,6 +31,7 @@ "i18next": "25.4.2", "joi": "17.13.3", "mui-color-input": "^6.0.0", + "pretty-ms": "9.3.0", "react": "18.3.1", "react-dom": "^18.2.0", "react-hook-form": "7.63.0", @@ -46,14 +47,6 @@ "vite-plugin-svgr": "^4.2.0", "zod": "4.1.11" }, - "unusedDepencies": { - "@solana/wallet-adapter-base": "0.9.25", - "@solana/wallet-adapter-material-ui": "0.16.35", - "@solana/wallet-adapter-react": "0.15.37", - "@solana/wallet-adapter-react-ui": "0.9.37", - "@solana/wallet-adapter-wallets": "0.19.34", - "@solana/web3.js": "1.98.0" - }, "devDependencies": { "@types/node": "24.5.2", "@types/react": "^18.2.66", diff --git a/client/src/App.jsx b/client/src/App.jsx index 04f0d5b65..d21850ebb 100644 --- a/client/src/App.jsx +++ b/client/src/App.jsx @@ -9,7 +9,6 @@ import { CssBaseline, GlobalStyles } from "@mui/material"; import { logger } from "./Utils/Logger"; // Import the logger import { networkService } from "./main"; import { Routes } from "./Routes"; -import WalletProvider from "./Components/WalletProvider"; import AppLayout from "@/Components/v1/Layouts/AppLayout"; function App() { @@ -24,16 +23,12 @@ function App() { }, []); return ( - /* Extract Themeprovider, baseline and global styles to Styles */ - - - - - - - - + + + + + ); } diff --git a/client/src/Components/v2/DesignElements/BaseBox.tsx b/client/src/Components/v2/DesignElements/BaseBox.tsx new file mode 100644 index 000000000..14c4af1c4 --- /dev/null +++ b/client/src/Components/v2/DesignElements/BaseBox.tsx @@ -0,0 +1,23 @@ +import Box from "@mui/material/Box"; +import { useTheme } from "@mui/material/styles"; +import type { SxProps } from "@mui/material/styles"; + +type BaseBoxProps = React.PropsWithChildren<{ sx?: SxProps }>; + +export const BaseBox: React.FC = ({ children, sx }) => { + const theme = useTheme(); + return ( + + {children} + + ); +}; diff --git a/client/src/Components/v2/DesignElements/Dot.tsx b/client/src/Components/v2/DesignElements/Dot.tsx new file mode 100644 index 000000000..ca3884cfb --- /dev/null +++ b/client/src/Components/v2/DesignElements/Dot.tsx @@ -0,0 +1,23 @@ +export const Dot = ({ + color = "gray", + size = "4px", + style, +}: { + color?: string; + size?: string; + style?: React.CSSProperties; +}) => { + return ( + + ); +}; diff --git a/client/src/Components/v2/DesignElements/PulseDot.tsx b/client/src/Components/v2/DesignElements/PulseDot.tsx new file mode 100644 index 000000000..a785b5a0d --- /dev/null +++ b/client/src/Components/v2/DesignElements/PulseDot.tsx @@ -0,0 +1,44 @@ +import Stack from "@mui/material/Stack"; +import Box from "@mui/material/Box"; +import { useTheme } from "@mui/material/styles"; +export const PulseDot = ({ color }: { color: string }) => { + const theme = useTheme(); + return ( + + + + ); +}; diff --git a/client/src/Components/v2/DesignElements/StatBox.tsx b/client/src/Components/v2/DesignElements/StatBox.tsx new file mode 100644 index 000000000..3450444b2 --- /dev/null +++ b/client/src/Components/v2/DesignElements/StatBox.tsx @@ -0,0 +1,57 @@ +import Stack from "@mui/material/Stack"; +import Typography from "@mui/material/Typography"; +import { useTheme } from "@mui/material/styles"; +import { useMediaQuery } from "@mui/material"; +import type { PaletteKey } from "@/Utils/Theme/v2/theme"; +import { BaseBox } from "@/Components/v2/DesignElements"; + +type GradientBox = React.PropsWithChildren<{ palette?: PaletteKey }>; + +export const GradientBox: React.FC = ({ children, palette }) => { + const theme = useTheme(); + const isSmall = useMediaQuery(theme.breakpoints.down("md")); + const bg = palette + ? `linear-gradient(to bottom right, ${theme.palette[palette].main} 30%, ${theme.palette[palette].lowContrast} 70%)` + : `linear-gradient(340deg, ${theme.palette.tertiary.main} 10%, ${theme.palette.primary.main} 45%)`; + + return ( + + {children} + + ); +}; + +type StatBoxProps = React.PropsWithChildren<{ + title: string; + subtitle: string; + palette?: PaletteKey; +}>; + +export const StatBox: React.FC = ({ + title, + subtitle, + palette, + children, +}) => { + const theme = useTheme(); + const textColor = palette ? theme.palette[palette].contrastText : "inherit"; + + return ( + + + {title} + {subtitle} + {children} + + + ); +}; diff --git a/client/src/Components/v2/DesignElements/StatusBox.tsx b/client/src/Components/v2/DesignElements/StatusBox.tsx index 540255dfd..8cd5cfbc1 100644 --- a/client/src/Components/v2/DesignElements/StatusBox.tsx +++ b/client/src/Components/v2/DesignElements/StatusBox.tsx @@ -1,6 +1,7 @@ import Stack from "@mui/material/Stack"; import Typography from "@mui/material/Typography"; import Box from "@mui/material/Box"; +import { BaseBox } from "@/Components/v2/DesignElements"; import Background from "@/assets/Images/background-grid.svg?react"; import { useTranslation } from "react-i18next"; @@ -11,15 +12,13 @@ type StatusBoxProps = React.PropsWithChildren<{}>; export const BGBox: React.FC = ({ children }) => { const theme = useTheme(); return ( - = ({ children }) => { {children} - + ); }; diff --git a/client/src/Components/v2/DesignElements/StatusLabel.tsx b/client/src/Components/v2/DesignElements/StatusLabel.tsx new file mode 100644 index 000000000..69b788e96 --- /dev/null +++ b/client/src/Components/v2/DesignElements/StatusLabel.tsx @@ -0,0 +1,35 @@ +import Box from "@mui/material/Box"; +import { BaseBox } from "@/Components/v2/DesignElements"; +import type { MonitorStatus } from "@/Types/Monitor"; + +import { getStatusPalette } from "@/Utils/MonitorUtils"; +import { useTheme } from "@mui/material/styles"; + +export const StatusLabel = ({ status }: { status: MonitorStatus }) => { + const theme = useTheme(); + const palette = getStatusPalette(status); + const transformedText = status.charAt(0).toUpperCase() + status.slice(1).toLowerCase(); + + return ( + + + {transformedText} + + ); +}; diff --git a/client/src/Components/v2/DesignElements/Table.tsx b/client/src/Components/v2/DesignElements/Table.tsx index 6296d260c..58cc6aed1 100644 --- a/client/src/Components/v2/DesignElements/Table.tsx +++ b/client/src/Components/v2/DesignElements/Table.tsx @@ -5,7 +5,19 @@ import TableCell from "@mui/material/TableCell"; import TableContainer from "@mui/material/TableContainer"; import TableHead from "@mui/material/TableHead"; import TableRow from "@mui/material/TableRow"; + +import IconButton from "@mui/material/IconButton"; +import LastPageIcon from "@mui/icons-material/LastPage"; +import FirstPageIcon from "@mui/icons-material/FirstPage"; +import KeyboardArrowLeft from "@mui/icons-material/KeyboardArrowLeft"; +import KeyboardArrowRight from "@mui/icons-material/KeyboardArrowRight"; + +import Box from "@mui/material/Box"; +import TablePagination from "@mui/material/TablePagination"; +import type { TablePaginationProps } from "@mui/material/TablePagination"; + import { useTheme } from "@mui/material/styles"; +import { useMediaQuery } from "@mui/material"; export type Header = { id: number | string; content: React.ReactNode; @@ -87,3 +99,109 @@ export function DataTable ); } + +interface TablePaginationActionsProps { + count: number; + page: number; + rowsPerPage: number; + onPageChange: (event: React.MouseEvent, newPage: number) => void; +} + +function TablePaginationActions(props: TablePaginationActionsProps) { + const theme = useTheme(); + const { count, page, rowsPerPage, onPageChange } = props; + + const handleFirstPageButtonClick = (event: React.MouseEvent) => { + onPageChange(event, 0); + }; + + const handleBackButtonClick = (event: React.MouseEvent) => { + onPageChange(event, page - 1); + }; + + const handleNextButtonClick = (event: React.MouseEvent) => { + onPageChange(event, page + 1); + }; + + const handleLastPageButtonClick = (event: React.MouseEvent) => { + onPageChange(event, Math.max(0, Math.ceil(count / rowsPerPage) - 1)); + }; + + return ( + + + {theme.direction === "rtl" ? : } + + + {theme.direction === "rtl" ? : } + + = Math.ceil(count / rowsPerPage) - 1} + aria-label="next page" + > + {theme.direction === "rtl" ? : } + + = Math.ceil(count / rowsPerPage) - 1} + aria-label="last page" + > + {theme.direction === "rtl" ? : } + + + ); +} + +export const Pagination: React.FC = ({ ...props }) => { + const isSmall = useMediaQuery((theme: any) => theme.breakpoints.down("sm")); + const theme = useTheme(); + return ( + + ); +}; diff --git a/client/src/Components/v2/DesignElements/index.tsx b/client/src/Components/v2/DesignElements/index.tsx index 41b4b319a..6ba0a19ac 100644 --- a/client/src/Components/v2/DesignElements/index.tsx +++ b/client/src/Components/v2/DesignElements/index.tsx @@ -1,4 +1,7 @@ export { SplitBox as HorizontalSplitBox, ConfigBox } from "./SplitBox"; export { BasePage } from "./BasePage"; export { BGBox, UpStatusBox, DownStatusBox, PausedStatusBox } from "./StatusBox"; -export { DataTable as Table } from "./Table"; +export { DataTable as Table, Pagination } from "./Table"; +export { GradientBox, StatBox } from "./StatBox"; +export { BaseBox } from "./BaseBox"; +export { StatusLabel } from "./StatusLabel"; diff --git a/client/src/Components/v2/Inputs/Button.tsx b/client/src/Components/v2/Inputs/Button.tsx index 0bf1d14e1..2f28e8eda 100644 --- a/client/src/Components/v2/Inputs/Button.tsx +++ b/client/src/Components/v2/Inputs/Button.tsx @@ -1,10 +1,11 @@ import Button from "@mui/material/Button"; import type { ButtonProps } from "@mui/material/Button"; -export const ButtonInput: React.FC = ({ ...props }) => { + +export const ButtonInput: React.FC = ({ sx, ...props }) => { return ( + + + + + + + ); +}; diff --git a/client/src/Components/v2/Monitors/HeaderRange.tsx b/client/src/Components/v2/Monitors/HeaderRange.tsx new file mode 100644 index 000000000..21ce01fe2 --- /dev/null +++ b/client/src/Components/v2/Monitors/HeaderRange.tsx @@ -0,0 +1,62 @@ +import Stack from "@mui/material/Stack"; +import { ButtonGroup, Button } from "@/Components/v2/Inputs"; +import { useTheme } from "@mui/material/styles"; +import Typography from "@mui/material/Typography"; +import { useMediaQuery } from "@mui/material"; +export const HeaderRange = ({ + range, + setRange, + loading, +}: { + range: string; + setRange: Function; + loading: boolean; +}) => { + const theme = useTheme(); + const isSmall = useMediaQuery(theme.breakpoints.down("md")); + return ( + + {`Showing statistics for past ${range}`} + + + + + + + + ); +}; diff --git a/client/src/Components/v2/Monitors/HistogramStatus.tsx b/client/src/Components/v2/Monitors/HistogramStatus.tsx new file mode 100644 index 000000000..aeacf31d2 --- /dev/null +++ b/client/src/Components/v2/Monitors/HistogramStatus.tsx @@ -0,0 +1,216 @@ +import Stack from "@mui/material/Stack"; +import Box from "@mui/material/Box"; +import Typography from "@mui/material/Typography"; +import { BaseBox } from "@/Components/v2/DesignElements"; +import { ResponsiveContainer, BarChart, XAxis, Bar, Cell } from "recharts"; +import UptimeIcon from "@/assets/icons/uptime-icon.svg?react"; +import IncidentsIcon from "@/assets/icons/incidents.svg?react"; + +import type { GroupedCheck } from "@/Types/Check"; +import type { MonitorStatus } from "@/Types/Monitor"; + +import { useState } from "react"; +import { formatDateWithTz } from "@/Utils/TimeUtils"; +import { useSelector } from "react-redux"; +import { useTheme } from "@mui/material/styles"; +import { getResponseTimeColor } from "@/Utils/MonitorUtils"; + +const XLabel = ({ + p1, + p2, + range, +}: { + p1: GroupedCheck; + p2: GroupedCheck; + range: string; +}) => { + const theme = useTheme(); + const uiTimezone = useSelector((state: any) => state.ui.timezone); + const dateFormat = range === "day" ? "MMM D, h:mm A" : "MMM D"; + return ( + <> + + {formatDateWithTz(p1._id, dateFormat, uiTimezone)} + + + {formatDateWithTz(p2._id, dateFormat, uiTimezone)} + + + ); +}; + +type BaseChartProps = React.PropsWithChildren<{ + icon: React.ReactNode; + title: string; +}>; + +export const BaseChart: React.FC = ({ children, icon, title }) => { + const theme = useTheme(); + + return ( + + + + + {icon} + + {title} + + {children} + + + ); +}; + +export const HistogramStatus = ({ + checks, + status, + range, + title, +}: { + checks: GroupedCheck[]; + status: MonitorStatus; + range: string; + title: string; +}) => { + const uiTimezone = useSelector((state: any) => state.ui.timezone); + + const icon = status === "up" ? : ; + const theme = useTheme(); + const [idx, setIdx] = useState(null); + const dateFormat = range === "1d" || range === "2h" ? "MMM D, h A" : "MMM D"; + + if (checks.length === 0) { + return ( + + + + {status === "up" ? "No checks yet" : "Great, no downtime yet!"} + + + + ); + } + + const totalChecks = checks.reduce((count, check) => { + return count + check.count; + }, 0); + + return ( + + + + + Total checks + {idx ? ( + + {checks[idx].count} + + {formatDateWithTz(checks[idx]._id, dateFormat, uiTimezone)} + + + ) : ( + {totalChecks} + )} + + + + + + } + /> + + {checks?.map((groupedCheck, idx) => { + const fillColor = getResponseTimeColor(groupedCheck.avgResponseTime); + return ( + setIdx(idx)} + onMouseLeave={() => setIdx(null)} + key={groupedCheck._id} + fill={theme.palette[fillColor].main} + /> + ); + })} + + + + + + ); +}; diff --git a/client/src/Components/v2/Monitors/MonitorStatus.tsx b/client/src/Components/v2/Monitors/MonitorStatus.tsx new file mode 100644 index 000000000..ff4d83352 --- /dev/null +++ b/client/src/Components/v2/Monitors/MonitorStatus.tsx @@ -0,0 +1,60 @@ +import type { IMonitor } from "@/Types/Monitor"; +import Stack from "@mui/material/Stack"; +import Typography from "@mui/material/Typography"; +import { PulseDot } from "@/Components/v2/DesignElements/PulseDot"; +import { Dot } from "@/Components/v2/DesignElements/Dot"; +import { getStatusColor, formatUrl } from "@/Utils/MonitorUtils"; +import { useTheme } from "@mui/material/styles"; +import prettyMilliseconds from "pretty-ms"; +import { typographyLevels } from "@/Utils/Theme/v2/palette"; +import { useMediaQuery } from "@mui/material"; +export const MonitorStatus = ({ monitor }: { monitor: IMonitor }) => { + const theme = useTheme(); + const isSmall = useMediaQuery(theme.breakpoints.down("md")); + + if (!monitor) { + return null; + } + return ( + + + {monitor.name} + + + + + {formatUrl(monitor?.url)} + + {!isSmall && ( + <> + + + Checking every {prettyMilliseconds(monitor?.interval, { verbose: true })} + + + )} + + + ); +}; diff --git a/client/src/Hooks/v2/UseApi.tsx b/client/src/Hooks/v2/UseApi.tsx index eab661793..9808dbf49 100644 --- a/client/src/Hooks/v2/UseApi.tsx +++ b/client/src/Hooks/v2/UseApi.tsx @@ -2,7 +2,7 @@ import { useState } from "react"; import useSWR from "swr"; import type { SWRConfiguration } from "swr"; import type { AxiosRequestConfig } from "axios"; -import { get, post } from "@/Utils/ApiClient"; // your axios wrapper +import { get, post, patch } from "@/Utils/ApiClient"; // your axios wrapper export type ApiResponse = { message: string; @@ -20,7 +20,7 @@ export const useGet = ( axiosConfig?: AxiosRequestConfig, swrConfig?: SWRConfiguration ) => { - const { data, error, isLoading, mutate } = useSWR( + const { data, error, isLoading, isValidating, mutate } = useSWR( url, (url) => fetcher(url, axiosConfig), swrConfig @@ -29,6 +29,7 @@ export const useGet = ( return { response: data ?? null, loading: isLoading, + isValidating, error: error?.message ?? null, refetch: mutate, }; @@ -56,3 +57,26 @@ export const usePost = (endpoint: string) => { return { post: postFn, loading, error }; }; + +export const usePatch = (endpoint: string) => { + const [loading, setLoading] = useState(false); + const [error, setError] = useState(null); + + const patchFn = async (body: B, config?: AxiosRequestConfig): Promise => { + setLoading(true); + setError(null); + + try { + const res = await patch(endpoint, body, config); + return res.data; + } catch (err: any) { + const errMsg = err?.response?.data?.msg || err.message || "An error occurred"; + setError(errMsg); + return null; + } finally { + setLoading(false); + } + }; + + return { patch: patchFn, loading, error }; +}; diff --git a/client/src/Pages/v2/Uptime/CheckTable.tsx b/client/src/Pages/v2/Uptime/CheckTable.tsx new file mode 100644 index 000000000..42da6da12 --- /dev/null +++ b/client/src/Pages/v2/Uptime/CheckTable.tsx @@ -0,0 +1,93 @@ +import { Table, Pagination } from "@/Components/v2/DesignElements"; +import { StatusLabel } from "@/Components/v2/DesignElements"; +import Box from "@mui/material/Box"; + +import type { Header } from "@/Components/v2/DesignElements/Table"; +import type { Check } from "@/Types/Check"; +import type { ApiResponse } from "@/Hooks/v2/UseApi"; +import type { MonitorStatus } from "@/Types/Monitor"; + +import { useState } from "react"; +import { useTranslation } from "react-i18next"; +import { useGet } from "@/Hooks/v2/UseApi"; +import { formatDateWithTz } from "@/Utils/TimeUtils"; +import { useSelector } from "react-redux"; +const getHeaders = (t: Function, uiTimezone: string) => { + const headers: Header[] = [ + { + id: "status", + content: t("status"), + render: (row) => { + return ; + }, + }, + { + id: "date", + content: t("date&Time"), + render: (row) => { + return formatDateWithTz(row.createdAt, "ddd, MMMM D, YYYY, HH:mm A", uiTimezone); + }, + }, + { + id: "statusCode", + content: t("statusCode"), + render: (row) => { + return row.httpStatusCode || "N/A"; + }, + }, + ]; + return headers; +}; + +export const CheckTable = ({ monitorId }: { monitorId: string }) => { + const [page, setPage] = useState(0); + const [rowsPerPage, setRowsPerPage] = useState(5); + const { t } = useTranslation(); + const uiTimezone = useSelector((state: any) => state.ui.timezone); + const headers = getHeaders(t, uiTimezone); + + const { response, error } = useGet( + `/monitors/${monitorId}/checks?page=${page}&rowsPerPage=${rowsPerPage}`, + {}, + { keepPreviousData: true } + ); + + const checks = response?.data?.checks || []; + const count = response?.data?.count || 0; + + const handlePageChange = ( + _e: React.MouseEvent | null, + newPage: number + ) => { + setPage(newPage); + }; + + const handleRowsPerPageChange = ( + e: React.ChangeEvent + ) => { + const value = Number(e.target.value); + setPage(0); + setRowsPerPage(value); + }; + + if (error) { + console.error(error); + } + + return ( + + + + + ); +}; diff --git a/client/src/Pages/v2/Uptime/Create.tsx b/client/src/Pages/v2/Uptime/Create.tsx index 3aa73b5fb..a135ec84c 100644 --- a/client/src/Pages/v2/Uptime/Create.tsx +++ b/client/src/Pages/v2/Uptime/Create.tsx @@ -5,7 +5,7 @@ import { ConfigBox, BasePage } from "@/Components/v2/DesignElements"; import RadioGroup from "@mui/material/RadioGroup"; import FormControl from "@mui/material/FormControl"; import { RadioWithDescription } from "@/Components/v2/Inputs/RadioInput"; -import Button from "@mui/material/Button"; +import { Button } from "@/Components/v2/Inputs"; import DeleteOutlineRoundedIcon from "@mui/icons-material/DeleteOutlineRounded"; import { Typography } from "@mui/material"; import humanInterval from "human-interval"; @@ -19,7 +19,7 @@ import { zodResolver } from "@hookform/resolvers/zod"; import { useGet, usePost } from "@/Hooks/v2/UseApi"; import type { ApiResponse } from "@/Hooks/v2/UseApi"; -const CreateUptimePage = () => { +const UptimeCreatePage = () => { const { t } = useTranslation(); const theme = useTheme(); @@ -259,4 +259,4 @@ const CreateUptimePage = () => { ); }; -export default CreateUptimePage; +export default UptimeCreatePage; diff --git a/client/src/Pages/v2/Uptime/Details.tsx b/client/src/Pages/v2/Uptime/Details.tsx new file mode 100644 index 000000000..c3ef14e72 --- /dev/null +++ b/client/src/Pages/v2/Uptime/Details.tsx @@ -0,0 +1,161 @@ +import { BasePage } from "@/Components/v2/DesignElements"; +import { HeaderControls } from "@/Components/v2/Monitors/HeaderControls"; +import Stack from "@mui/material/Stack"; +import { StatBox } from "@/Components/v2/DesignElements"; +import { HistogramStatus } from "@/Components/v2/Monitors/HistogramStatus"; +import { ChartAvgResponse } from "@/Components/v2/Monitors/ChartAvgResponse"; +import { ChartResponseTime } from "@/Components/v2/Monitors/ChartResponseTime"; +import { HeaderRange } from "@/Components/v2/Monitors/HeaderRange"; +import { CheckTable } from "@/Pages/v2/Uptime/CheckTable"; + +import type { IMonitor } from "@/Types/Monitor"; +import { useMediaQuery } from "@mui/material"; +import { useTheme } from "@mui/material/styles"; +import { useParams } from "react-router"; +import { useGet, usePatch, type ApiResponse } from "@/Hooks/v2/UseApi"; +import { useState } from "react"; +import { getStatusPalette } from "@/Utils/MonitorUtils"; +import prettyMilliseconds from "pretty-ms"; + +const UptimeDetailsPage = () => { + const { id } = useParams(); + const theme = useTheme(); + const isSmall = useMediaQuery(theme.breakpoints.down("md")); + + // Local state + const [range, setRange] = useState("2h"); + + const { response, isValidating, error, refetch } = useGet( + `/monitors/${id}?embedChecks=true&range=${range}`, + + {}, + { refreshInterval: 30000 } + ); + + const { + response: upResponse, + isValidating: upIsValidating, + error: upError, + } = useGet( + `/monitors/${id}?embedChecks=true&range=${range}&status=up`, + {}, + {} + ); + + const { + response: downResponse, + error: downError, + isValidating: downIsValidating, + } = useGet( + `/monitors/${id}?embedChecks=true&range=${range}&status=down`, + {}, + {} + ); + + const { + patch, + loading: isPatching, + error: postError, + } = usePatch(`/monitors/${id}/active`); + + const monitor: IMonitor = response?.data?.monitor; + + if (!monitor) { + return null; + } + + const stats = response?.data?.stats || null; + const avgResponseTime = stats?.avgResponseTime || 0; + const maxResponseTime = stats?.maxResponseTime || 0; + + const streakDuration = stats?.currentStreakStartedAt + ? Date.now() - stats?.currentStreakStartedAt + : 0; + + const lastChecked = stats?.lastCheckTimestamp + ? Date.now() - stats?.lastCheckTimestamp + : -1; + + const checks = response?.data?.checks || []; + const upChecks = upResponse?.data?.checks ? [...upResponse.data.checks].reverse() : []; + const downChecks = downResponse?.data?.checks + ? [...downResponse.data.checks].reverse() + : []; + + const palette = getStatusPalette(monitor.status); + + if (error || upError || downError || postError) { + console.error("Error fetching monitor data:", { + error, + upError, + downError, + postError, + }); + } + + return ( + + + + + = 0 + ? `${prettyMilliseconds(lastChecked, { secondsDecimalDigits: 0 })} ago` + : "N/A" + } + /> + + + + + + + + + + + + ); +}; + +export default UptimeDetailsPage; diff --git a/client/src/Pages/v2/Uptime/MonitorTable.tsx b/client/src/Pages/v2/Uptime/MonitorTable.tsx index 93ae498f9..038da2e24 100644 --- a/client/src/Pages/v2/Uptime/MonitorTable.tsx +++ b/client/src/Pages/v2/Uptime/MonitorTable.tsx @@ -1,16 +1,23 @@ import Stack from "@mui/material/Stack"; +import Typography from "@mui/material/Typography"; +import { Table } from "@/Components/v2/DesignElements"; import { HistogramResponseTime } from "@/Components/v2/Monitors/HistogramResponseTime"; import type { Header } from "@/Components/v2/DesignElements/Table"; -import type { IMonitor } from "@/Types/Monitor"; -import { Table } from "@/Components/v2/DesignElements"; +import { ActionsMenu } from "@/Components/v2/ActionsMenu"; +import { StatusLabel } from "@/Components/v2/DesignElements"; + import { useTranslation } from "react-i18next"; import { useMediaQuery } from "@mui/material"; import { useTheme } from "@mui/material/styles"; -import { ActionsMenu } from "@/Components/v2/ActionsMenu"; -import type { ActionMenuItem } from "@/Components/v2/ActionsMenu"; -import Typography from "@mui/material/Typography"; +import { useNavigate } from "react-router-dom"; -const getActions = (theme: any): ActionMenuItem[] => { +import type { IMonitor } from "@/Types/Monitor"; +import type { ActionMenuItem } from "@/Components/v2/ActionsMenu"; +const getActions = ( + theme: any, + monitor: IMonitor, + navigate: Function +): ActionMenuItem[] => { return [ { id: 1, @@ -24,7 +31,7 @@ const getActions = (theme: any): ActionMenuItem[] => { id: 2, label: "Details", action: () => { - console.log("Open details"); + navigate(`${monitor._id}`); }, }, { @@ -67,7 +74,7 @@ const getActions = (theme: any): ActionMenuItem[] => { ]; }; -const getHeaders = (theme: any, t: Function) => { +const getHeaders = (theme: any, t: Function, navigate: Function) => { const headers: Header[] = [ { id: "name", @@ -80,7 +87,7 @@ const getHeaders = (theme: any, t: Function) => { id: "status", content: t("status"), render: (row) => { - return row.status; + return ; }, }, { @@ -104,8 +111,8 @@ const getHeaders = (theme: any, t: Function) => { { id: "actions", content: t("actions"), - render: () => { - return ; + render: (row) => { + return ; }, }, ]; @@ -116,8 +123,9 @@ export const MonitorTable = ({ monitors }: { monitors: IMonitor[] }) => { const { t } = useTranslation(); const theme = useTheme(); const isSmall = useMediaQuery(theme.breakpoints.down("md")); + const navigate = useNavigate(); - let headers = getHeaders(theme, t); + let headers = getHeaders(theme, t, navigate); if (isSmall) { headers = headers.filter((h) => h.id !== "histogram"); diff --git a/client/src/Pages/v2/Uptime/UptimeMonitors.tsx b/client/src/Pages/v2/Uptime/UptimeMonitors.tsx index e69da7ebb..8980c8260 100644 --- a/client/src/Pages/v2/Uptime/UptimeMonitors.tsx +++ b/client/src/Pages/v2/Uptime/UptimeMonitors.tsx @@ -18,7 +18,7 @@ const UptimeMonitors = () => { const theme = useTheme(); const isSmall = useMediaQuery(theme.breakpoints.down("md")); - const { response, loading } = useGet("/monitors?embedChecks=true"); + const { response, loading } = useGet("/monitors?embedChecks=true", {}); const monitors = response?.data ?? ([] as IMonitor[]); if (monitors.length === 0 && !loading) { diff --git a/client/src/Routes/v2router.tsx b/client/src/Routes/v2router.tsx index 742302762..0e53deead 100644 --- a/client/src/Routes/v2router.tsx +++ b/client/src/Routes/v2router.tsx @@ -5,7 +5,8 @@ import { lightTheme, darkTheme } from "@/Utils/Theme/v2/theme"; import AuthLoginV2 from "@/Pages/v2/Auth/Login"; import AuthRegisterV2 from "@/Pages/v2/Auth/Register"; import UptimeMonitorsPage from "@/Pages/v2/Uptime/UptimeMonitors"; -import CreateUptimePage from "@/Pages/v2/Uptime/Create"; +import UptimeCreatePage from "@/Pages/v2/Uptime/Create"; +import UptimeDetailsPage from "@/Pages/v2/Uptime/Details"; import RootLayout from "@/Components/v2/Layouts/RootLayout"; const V2Routes = ({ mode = "light" }) => { @@ -34,9 +35,13 @@ const V2Routes = ({ mode = "light" }) => { path="uptime" element={} /> + } + /> } + element={} /> diff --git a/client/src/Types/Check.ts b/client/src/Types/Check.ts index c3c3712f0..542507a4b 100644 --- a/client/src/Types/Check.ts +++ b/client/src/Types/Check.ts @@ -1,8 +1,38 @@ +export interface CheckTimingPhases { + wait: number; + dns: number; + tcp: number; + tls: number; + request: number; + firstByte: number; + download: number; + total: number; +} + +export interface CheckTimings { + start: string; + socket: string; + lookup: string; + connect: string; + secureConnect: string; + response: string; + end: string; + phases: CheckTimingPhases; +} + export interface Check { _id: string; + monitorId: string; + type: string; status: string; + message: string; responseTime: number; + httpStatusCode: number; + ack: boolean; + expiry: string; createdAt: string; + updatedAt: string; + timings: CheckTimings; } export interface GroupedCheck { diff --git a/client/src/Types/Monitor.ts b/client/src/Types/Monitor.ts index 8ceb4d009..05f7d7d6f 100644 --- a/client/src/Types/Monitor.ts +++ b/client/src/Types/Monitor.ts @@ -1,4 +1,5 @@ import type { Check } from "@/Types/Check"; +export type MonitorStatus = "up" | "down" | "initializing"; export interface IMonitor { checks: Check[]; @@ -9,7 +10,7 @@ export interface IMonitor { latestChecks: Check[]; n: number; name: string; - status: string; + status: MonitorStatus; type: string; updatedAt: string; updatedBy: string; diff --git a/client/src/Utils/ApiClient.ts b/client/src/Utils/ApiClient.ts index a44c35905..4f41efe2d 100644 --- a/client/src/Utils/ApiClient.ts +++ b/client/src/Utils/ApiClient.ts @@ -18,4 +18,10 @@ export const post = ( config: AxiosRequestConfig = {} ): Promise> => api.post(url, data, config); +export const patch = ( + url: string, + data: any, + config: AxiosRequestConfig = {} +): Promise> => api.patch(url, data, config); + export default api; diff --git a/client/src/Utils/MonitorUtils.ts b/client/src/Utils/MonitorUtils.ts new file mode 100644 index 000000000..dcd202361 --- /dev/null +++ b/client/src/Utils/MonitorUtils.ts @@ -0,0 +1,38 @@ +import type { MonitorStatus } from "@/Types/Monitor"; +import type { PaletteKey } from "./Theme/v2/theme"; +export const getStatusPalette = (status: MonitorStatus): PaletteKey => { + const paletteMap: Record = { + up: "success", + down: "error", + initializing: "warning", + }; + return paletteMap[status]; +}; + +export const getStatusColor = (status: MonitorStatus, theme: any): string => { + const statusColors: Record = { + up: theme.palette.success.lowContrast, + down: theme.palette.error.lowContrast, + initializing: theme.palette.warning.lowContrast, + }; + return statusColors[status]; +}; + +export const getResponseTimeColor = (responseTime: number): PaletteKey => { + if (responseTime < 200) { + return "success"; + } else if (responseTime < 300) { + return "warning"; + } else { + return "error"; + } +}; + +export const formatUrl = (url: string, maxLength: number = 55) => { + if (!url) return ""; + + const strippedUrl = url.replace(/^https?:\/\//, ""); + return strippedUrl.length > maxLength + ? `${strippedUrl.slice(0, maxLength)}…` + : strippedUrl; +}; diff --git a/client/src/Utils/Theme/v2/theme.ts b/client/src/Utils/Theme/v2/theme.ts index 3d68613b9..d2581e236 100644 --- a/client/src/Utils/Theme/v2/theme.ts +++ b/client/src/Utils/Theme/v2/theme.ts @@ -1,5 +1,12 @@ import { createTheme } from "@mui/material"; import { lightPalette, darkPalette, typographyLevels } from "./palette"; + +import type { Theme } from "@mui/material/styles"; + +export type PaletteKey = { + [K in keyof Theme["palette"]]: Theme["palette"][K] extends { main: any } ? K : never; +}[keyof Theme["palette"]]; + const fontFamilyPrimary = '"Inter" , sans-serif'; const shadow = "0px 4px 24px -4px rgba(16, 24, 40, 0.08), 0px 3px 3px -3px rgba(16, 24, 40, 0.03)"; diff --git a/client/src/Utils/TimeUtils.ts b/client/src/Utils/TimeUtils.ts index 5dc9b23c1..618d9d1bc 100644 --- a/client/src/Utils/TimeUtils.ts +++ b/client/src/Utils/TimeUtils.ts @@ -23,3 +23,31 @@ export const formatDateWithTz = (timestamp: string, format: string, timezone: st const formattedDate = dayjs(timestamp).tz(timezone).format(format); return formattedDate; }; + +export const tickDateFormatLookup = (range: string) => { + const tickFormatLookup: Record = { + "2h": "h:mm A", + "24h": "h:mm A", + "7d": "MM/D, h:mm A", + "30d": "ddd. M/D", + }; + const format = tickFormatLookup[range]; + if (format === undefined) { + return ""; + } + return format; +}; + +export const tooltipDateFormatLookup = (range: string) => { + const dateFormatLookup: Record = { + "2h": "ddd. MMMM D, YYYY, hh:mm A", + "24h": "ddd. MMMM D, YYYY, hh:mm A", + "7d": "ddd. MMMM D, YYYY, hh:mm A", + "30d": "ddd. MMMM D, YYYY", + }; + const format = dateFormatLookup[range]; + if (format === undefined) { + return ""; + } + return format; +}; diff --git a/server/nodemon.json b/server/nodemon.json index 88d6ac686..389209fa7 100755 --- a/server/nodemon.json +++ b/server/nodemon.json @@ -1,5 +1,5 @@ { "ignore": ["src/locales/*", "*.log", "node_modules/*"], - "watch": ["src/**/*.js", "*.json"], - "ext": "js,json" + "watch": ["src/**/*.ts", "src/**/*.js", "*.json"], + "ext": "ts,js,json" } diff --git a/server/src/config/controllers.js b/server/src/config/controllers.js index 78cd7091b..927acd1a2 100644 --- a/server/src/config/controllers.js +++ b/server/src/config/controllers.js @@ -74,7 +74,7 @@ export const initializeControllers = (services) => { controllers.authControllerV2 = new AuthControllerV2(services.authServiceV2, services.inviteServiceV2); controllers.inviteControllerV2 = new InviteControllerV2(services.inviteServiceV2); controllers.maintenanceControllerV2 = new MaintenanceControllerV2(services.maintenanceServiceV2); - controllers.monitorControllerV2 = new MonitorControllerV2(services.monitorServiceV2); + controllers.monitorControllerV2 = new MonitorControllerV2(services.monitorServiceV2, services.checkServiceV2); controllers.notificationChannelControllerV2 = new NotificationChannelControllerV2(services.notificationChannelServiceV2); controllers.queueControllerV2 = new QueueControllerV2(services.jobQueueV2); diff --git a/server/src/controllers/v2/MonitorController.ts b/server/src/controllers/v2/MonitorController.ts index c9a4ce7da..7c0c63d2c 100644 --- a/server/src/controllers/v2/MonitorController.ts +++ b/server/src/controllers/v2/MonitorController.ts @@ -2,10 +2,13 @@ import { Request, Response, NextFunction } from "express"; import ApiError from "../../utils/ApiError.js"; import MonitorService from "../../service/v2/business/MonitorService.js"; import { MonitorType } from "../../db/v2/models/monitors/Monitor.js"; +import CheckService from "../../service/v2/business/CheckService.js"; class MonitorController { private monitorService: MonitorService; - constructor(monitorService: MonitorService) { + private checkService: CheckService; + constructor(monitorService: MonitorService, checkService: CheckService) { this.monitorService = monitorService; + this.checkService = checkService; } create = async (req: Request, res: Response, next: NextFunction) => { @@ -25,6 +28,86 @@ class MonitorController { } }; + getAll = async (req: Request, res: Response, next: NextFunction) => { + try { + const tokenizedUser = req.user; + if (!tokenizedUser) { + return res.status(401).json({ message: "Unauthorized" }); + } + + let monitors; + if (req.query.embedChecks === "true") { + const page = Math.max(1, Number(req.query.page) || 1); + const limit = Math.max(1, Number(req.query.limit) || 10); + const type: MonitorType[] = req.query.type as MonitorType[]; + + monitors = await this.monitorService.getAllEmbedChecks(page, limit, type); + } else { + monitors = await this.monitorService.getAll(); + } + + res.status(200).json({ + message: "Monitors retrieved successfully", + data: monitors, + }); + } catch (error) { + next(error); + } + }; + + getChecks = async (req: Request, res: Response, next: NextFunction) => { + try { + const tokenizedUser = req.user; + if (!tokenizedUser) { + return res.status(401).json({ message: "Unauthorized" }); + } + + const id = req.params.id; + if (!id) { + throw new ApiError("Monitor ID is required", 400); + } + + const page = Number(req.query.page); + const rowsPerPage = Number(req.query.rowsPerPage); + + if (isNaN(page)) throw new ApiError("Page query parameter must be a number", 400); + if (isNaN(rowsPerPage)) throw new ApiError("rowsPerPage query parameter must be a number", 400); + + if (page < 0) throw new ApiError("Page must be greater than 0", 400); + if (rowsPerPage < 0) throw new ApiError("rowsPerPage must be greater than 0", 400); + + const { count, checks } = await this.checkService.getChecks(id, page, rowsPerPage); + res.status(200).json({ + message: "Checks retrieved successfully", + data: { count, checks }, + }); + } catch (error) { + next(error); + } + }; + + toggleActive = async (req: Request, res: Response, next: NextFunction) => { + try { + const tokenizedUser = req.user; + if (!tokenizedUser) { + return res.status(401).json({ message: "Unauthorized" }); + } + + const id = req.params.id; + if (!id) { + throw new ApiError("Monitor ID is required", 400); + } + + const monitor = await this.monitorService.toggleActive(id, tokenizedUser); + res.status(200).json({ + message: "Monitor paused/unpaused successfully", + data: monitor, + }); + } catch (error) { + next(error); + } + }; + get = async (req: Request, res: Response, next: NextFunction) => { try { const tokenizedUser = req.user; @@ -62,55 +145,6 @@ class MonitorController { } }; - getAll = async (req: Request, res: Response, next: NextFunction) => { - try { - const tokenizedUser = req.user; - if (!tokenizedUser) { - return res.status(401).json({ message: "Unauthorized" }); - } - - let monitors; - if (req.query.embedChecks === "true") { - const page = Math.max(1, Number(req.query.page) || 1); - const limit = Math.max(1, Number(req.query.limit) || 10); - const type: MonitorType[] = req.query.type as MonitorType[]; - - monitors = await this.monitorService.getAllEmbedChecks(page, limit, type); - } else { - monitors = await this.monitorService.getAll(); - } - - res.status(200).json({ - message: "Monitors retrieved successfully", - data: monitors, - }); - } catch (error) { - next(error); - } - }; - - toggleActive = async (req: Request, res: Response, next: NextFunction) => { - try { - const tokenizedUser = req.user; - if (!tokenizedUser) { - return res.status(401).json({ message: "Unauthorized" }); - } - - const id = req.params.id; - if (!id) { - throw new ApiError("Monitor ID is required", 400); - } - - const monitor = await this.monitorService.toggleActive(id, tokenizedUser); - res.status(200).json({ - message: "Monitor paused/unpaused successfully", - data: monitor, - }); - } catch (error) { - next(error); - } - }; - update = async (req: Request, res: Response, next: NextFunction) => { try { const tokenizedUser = req.user; diff --git a/server/src/routes/v2/monitors.ts b/server/src/routes/v2/monitors.ts index eb218f4c6..61e5b61d6 100644 --- a/server/src/routes/v2/monitors.ts +++ b/server/src/routes/v2/monitors.ts @@ -17,12 +17,14 @@ class MonitorRoutes { this.router.get("/", verifyToken, verifyPermission(["monitors.view"]), this.controller.getAll); + this.router.get("/:id/checks", verifyToken, verifyPermission(["monitors.view"]), this.controller.getChecks); + this.router.patch("/:id/active", verifyToken, verifyPermission(["monitors.update"]), this.controller.toggleActive); - this.router.patch("/:id", verifyToken, verifyPermission(["monitors.update"]), this.controller.update); - this.router.get("/:id", verifyToken, verifyPermission(["monitors.view"]), this.controller.get); + this.router.patch("/:id", verifyToken, verifyPermission(["monitors.update"]), this.controller.update); + this.router.delete("/:id", verifyToken, verifyPermission(["monitors.delete"]), this.controller.delete); }; diff --git a/server/src/service/v2/business/CheckService.ts b/server/src/service/v2/business/CheckService.ts index 7187fb5a3..54593ed2b 100644 --- a/server/src/service/v2/business/CheckService.ts +++ b/server/src/service/v2/business/CheckService.ts @@ -5,6 +5,7 @@ import { MonitorType } from "../../../db/v2/models/monitors/Monitor.js"; import { StatusResponse } from "../infrastructure/NetworkService.js"; import type { ICapturePayload, ILighthousePayload } from "../infrastructure/NetworkService.js"; import mongoose from "mongoose"; +import { stat } from "fs"; const SERVICE_NAME = "CheckServiceV2"; export interface ICheckService { @@ -60,6 +61,7 @@ class CheckService implements ICheckService { monitorId: monitorId, type: statusResponse?.type, status: statusResponse?.status, + httpStatusCode: statusResponse?.code, message: statusResponse?.message, responseTime: statusResponse?.responseTime, timings: statusResponse?.timings, @@ -130,6 +132,16 @@ class CheckService implements ICheckService { return false; } }; + + getChecks = async (monitorId: string, page: number, rowsPerPage: number) => { + const count = await Check.countDocuments({ monitorId: new mongoose.Types.ObjectId(monitorId) }); + const checks = await Check.find({ monitorId: new mongoose.Types.ObjectId(monitorId) }) + .sort({ createdAt: -1 }) + .skip(page * rowsPerPage) + .limit(rowsPerPage) + .exec(); + return { checks, count }; + }; } export default CheckService; diff --git a/server/src/service/v2/business/MonitorService.ts b/server/src/service/v2/business/MonitorService.ts index 61fc8b114..c0570236b 100644 --- a/server/src/service/v2/business/MonitorService.ts +++ b/server/src/service/v2/business/MonitorService.ts @@ -63,8 +63,8 @@ class MonitorService implements IMonitorService { private getStartDate(range: string): Date { const now = new Date(); switch (range) { - case "30m": - return new Date(now.getTime() - 30 * 60 * 1000); + case "2h": + return new Date(now.getTime() - 2 * 60 * 60 * 1000); case "24h": return new Date(now.getTime() - 24 * 60 * 60 * 1000); case "7d": @@ -78,7 +78,7 @@ class MonitorService implements IMonitorService { private getDateFormat(range: string): string { switch (range) { - case "30m": + case "2h": return "%Y-%m-%dT%H:%M:00Z"; case "24h": case "7d":