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/StatBox.tsx b/client/src/Components/v2/DesignElements/StatBox.tsx index e65454188..e5d4dcef5 100644 --- a/client/src/Components/v2/DesignElements/StatBox.tsx +++ b/client/src/Components/v2/DesignElements/StatBox.tsx @@ -4,6 +4,7 @@ import Box from "@mui/material/Box"; 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 }>; @@ -15,22 +16,18 @@ export const GradientBox: React.FC = ({ children, palette }) => { : `linear-gradient(340deg, ${theme.palette.tertiary.main} 10%, ${theme.palette.primary.main} 45%)`; return ( - {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/index.tsx b/client/src/Components/v2/DesignElements/index.tsx index 5e2f69c69..359982c42 100644 --- a/client/src/Components/v2/DesignElements/index.tsx +++ b/client/src/Components/v2/DesignElements/index.tsx @@ -3,3 +3,4 @@ export { BasePage } from "./BasePage"; export { BGBox, UpStatusBox, DownStatusBox, PausedStatusBox } from "./StatusBox"; export { DataTable as Table } from "./Table"; export { GradientBox, StatBox } from "./StatBox"; +export { BaseBox } from "./BaseBox"; diff --git a/client/src/Components/v2/Monitors/ChartAvgResponse.tsx b/client/src/Components/v2/Monitors/ChartAvgResponse.tsx new file mode 100644 index 000000000..9b40a25c6 --- /dev/null +++ b/client/src/Components/v2/Monitors/ChartAvgResponse.tsx @@ -0,0 +1,83 @@ +import { BaseChart } from "./HistogramStatus"; +import Stack from "@mui/material/Stack"; +import Typography from "@mui/material/Typography"; +import Box from "@mui/material/Box"; +import AverageResponseIcon from "@/assets/icons/average-response-icon.svg?react"; +import { Cell, RadialBarChart, RadialBar, ResponsiveContainer } from "recharts"; + +import { getResponseTimeColor } from "@/Utils/MonitorUtils"; +import { useTheme } from "@mui/material/styles"; + +export const ChartAvgResponse = ({ avg, max }: { avg: number; max: number }) => { + const theme = useTheme(); + const chartData = [ + { name: "max", value: max - avg, color: "transparent" }, + { name: "avg", value: avg, color: "red" }, + ]; + + const palette = getResponseTimeColor(avg); + const msg: Record = { + success: "Excellent", + warning: "Average", + danger: "Poor", + }; + + return ( + }> + + + + + + + + + + + Low + High + + + + {msg[palette]} + + {`${avg?.toFixed()}ms`} + + + + ); +}; diff --git a/client/src/Components/v2/Monitors/ChartResponseTime.tsx b/client/src/Components/v2/Monitors/ChartResponseTime.tsx new file mode 100644 index 000000000..459a4f30b --- /dev/null +++ b/client/src/Components/v2/Monitors/ChartResponseTime.tsx @@ -0,0 +1,153 @@ +import { BaseChart } from "./HistogramStatus"; +import { BaseBox } from "../DesignElements"; +import ResponseTimeIcon from "@/assets/icons/response-time-icon.svg?react"; +import { + AreaChart, + Area, + YAxis, + XAxis, + Tooltip, + CartesianGrid, + ResponsiveContainer, + Text, +} from "recharts"; +import Typography from "@mui/material/Typography"; + +import { + formatDateWithTz, + tickDateFormatLookup, + tooltipDateFormatLookup, +} from "@/Utils/TimeUtils"; +import { useTheme } from "@mui/material/styles"; +import type { GroupedCheck } from "@/Types/Check"; +import { useSelector } from "react-redux"; + +type XTickProps = { + x: number; + y: number; + payload: { value: any }; + range: string; +}; + +const XTick: React.FC = ({ x, y, payload, range }) => { + const format = tickDateFormatLookup(range); + const theme = useTheme(); + const uiTimezone = useSelector((state: any) => state.ui.timezone); + return ( + + {formatDateWithTz(payload?.value, format, uiTimezone)} + + ); +}; + +type ResponseTimeToolTipProps = { + active?: boolean | undefined; + payload?: any[]; + label?: string; + range: string; +}; + +const ResponseTimeToolTip: React.FC = ({ + active, + payload, + label, + range, +}) => { + if (!label) return null; + if (!payload) return null; + + const theme = useTheme(); + const format = tooltipDateFormatLookup(range); + const uiTimezone = useSelector((state: any) => state.ui.timezone); + const responseTime = Math.floor(payload?.[0]?.value || 0); + return ( + + {formatDateWithTz(label, format, uiTimezone)} + Response time: {responseTime} ms + + ); +}; + +export const ChartResponseTime = ({ + checks, + range, +}: { + checks: GroupedCheck[]; + range: string; +}) => { + const theme = useTheme(); + return ( + } + title="Response times" + > + + + + + + + + + + ( + + )} + /> + + ( + + )} + /> + + + + + ); +}; diff --git a/client/src/Components/v2/Monitors/HeaderControls.tsx b/client/src/Components/v2/Monitors/HeaderControls.tsx index f7dfcf7c8..056d88f0a 100644 --- a/client/src/Components/v2/Monitors/HeaderControls.tsx +++ b/client/src/Components/v2/Monitors/HeaderControls.tsx @@ -1,7 +1,6 @@ import Stack from "@mui/material/Stack"; import { MonitorStatus } from "@/Components/v2/Monitors/MonitorStatus"; import { ButtonGroup, Button } from "@/Components/v2/Inputs"; -import Tooltip from "@mui/material/Tooltip"; import SettingsOutlinedIcon from "@mui/icons-material/SettingsOutlined"; import PauseOutlinedIcon from "@mui/icons-material/PauseOutlined"; import PlayArrowOutlinedIcon from "@mui/icons-material/PlayArrowOutlined"; diff --git a/client/src/Components/v2/Monitors/HistogramStatus.tsx b/client/src/Components/v2/Monitors/HistogramStatus.tsx new file mode 100644 index 000000000..f71f92b47 --- /dev/null +++ b/client/src/Components/v2/Monitors/HistogramStatus.tsx @@ -0,0 +1,217 @@ +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/Pages/v2/Uptime/Details.tsx b/client/src/Pages/v2/Uptime/Details.tsx index eb8f986ad..d2cc20edf 100644 --- a/client/src/Pages/v2/Uptime/Details.tsx +++ b/client/src/Pages/v2/Uptime/Details.tsx @@ -2,20 +2,25 @@ 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 { 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"; +import { ChartResponseTime } from "@/Components/v2/Monitors/ChartResponseTime"; const UptimeDetailsPage = () => { const { id } = useParams(); const theme = useTheme(); + const isSmall = useMediaQuery(theme.breakpoints.down("md")); // Local state - const [range, setRange] = useState("30m"); + const [range, setRange] = useState("2h"); const { response, loading, error, refetch } = useGet( `/monitors/${id}?embedChecks=true&range=${range}`, @@ -23,6 +28,27 @@ const UptimeDetailsPage = () => { {}, { refreshInterval: 30000 } ); + + const { + response: upResponse, + error: upError, + loading: upLoading, + } = useGet( + `/monitors/${id}?embedChecks=true&range=${range}&status=up`, + {}, + {} + ); + + const { + response: downResponse, + error: downError, + loading: downLoading, + } = useGet( + `/monitors/${id}?embedChecks=true&range=${range}&status=down`, + {}, + {} + ); + const { patch, loading: isPatching, @@ -35,6 +61,8 @@ const UptimeDetailsPage = () => { } const stats = response?.data?.stats || null; + const avgResponseTime = stats?.avgResponseTime || 0; + const maxResponseTime = stats?.maxResponseTime || 0; const streakDuration = stats?.currentStreakStartedAt ? Date.now() - stats?.currentStreakStartedAt @@ -44,9 +72,13 @@ const UptimeDetailsPage = () => { ? Date.now() - stats?.lastCheckTimestamp : -1; - const checks = response?.data?.checks || null; + const checks = response?.data?.checks || []; + const upChecks = upResponse?.data?.checks || []; + const downChecks = downResponse?.data?.checks || []; - console.log(response); + // TODO something with these + + console.log(loading, error, postError, checks, setRange); const palette = getStatusPalette(monitor.status); @@ -80,6 +112,31 @@ const UptimeDetailsPage = () => { subtitle={stats?.lastResponseTime ? `${stats?.lastResponseTime} ms` : "N/A"} /> + + + + + + ); }; diff --git a/client/src/Utils/MonitorUtils.ts b/client/src/Utils/MonitorUtils.ts index 5f598ba33..dcd202361 100644 --- a/client/src/Utils/MonitorUtils.ts +++ b/client/src/Utils/MonitorUtils.ts @@ -18,6 +18,16 @@ export const getStatusColor = (status: MonitorStatus, theme: any): string => { 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 ""; 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; +};