This commit is contained in:
Alex Holliday
2025-10-07 14:44:55 -07:00
parent 8a1fc94c31
commit 29fbf0a362
11 changed files with 587 additions and 20 deletions
@@ -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<BaseBoxProps> = ({ children, sx }) => {
const theme = useTheme();
return (
<Box
sx={{
backgroundColor: theme.palette.primary.main,
border: 1,
borderStyle: "solid",
borderColor: theme.palette.primary.lowContrast,
borderRadius: theme.shape.borderRadius,
...sx,
}}
>
{children}
</Box>
);
};
@@ -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<GradientBox> = ({ children, palette }) => {
: `linear-gradient(340deg, ${theme.palette.tertiary.main} 10%, ${theme.palette.primary.main} 45%)`;
return (
<Box
border={1}
<BaseBox
sx={{
padding: `${theme.spacing(4)} ${theme.spacing(8)}`,
width: isSmall
? `calc(50% - (1 * ${theme.spacing(8)} / 2))`
: `calc(25% - (3 * ${theme.spacing(8)} / 4))`,
borderStyle: "solid",
borderRadius: 4,
borderColor: theme.palette.primary.lowContrast,
background: bg,
}}
>
{children}
</Box>
</BaseBox>
);
};
@@ -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<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"
<BaseBox
sx={{
overflow: "hidden",
position: "relative",
flex: 1,
padding: theme.spacing(8),
}}
>
<Box
position="absolute"
@@ -29,7 +28,7 @@ export const BGBox: React.FC<StatusBoxProps> = ({ children }) => {
<Background />
</Box>
{children}
</Box>
</BaseBox>
);
};
@@ -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";
@@ -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<string, string> = {
success: "Excellent",
warning: "Average",
danger: "Poor",
};
return (
<BaseChart icon={<AverageResponseIcon />}>
<Stack
height="100%"
position={"relative"}
justifyContent={"space-between"}
>
<ResponsiveContainer
width="100%"
minWidth={210}
height={155}
>
<RadialBarChart
cy="89%"
data={chartData}
startAngle={180}
endAngle={0}
innerRadius={"120%"}
outerRadius={"200%"}
>
<RadialBar
dataKey="value"
background={{ fill: theme.palette[palette].lowContrast }}
>
<Cell visibility={"hidden"} />
<Cell fill={theme.palette[palette].main} />
</RadialBar>
</RadialBarChart>
</ResponsiveContainer>
<Stack
direction={"row"}
justifyContent={"space-between"}
>
<Typography variant="body2">Low</Typography>
<Typography variant="body2">High</Typography>
</Stack>
<Stack
position="absolute"
top={"50%"}
right={"50%"}
sx={{
transform: "translate(50%, 0%)",
}}
>
<Typography
variant="h6"
textAlign={"center"}
>
{msg[palette]}
</Typography>
<Typography
variant="h6"
textAlign={"center"}
>{`${avg?.toFixed()}ms`}</Typography>
</Stack>
</Stack>
</BaseChart>
);
};
@@ -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<XTickProps> = ({ x, y, payload, range }) => {
const format = tickDateFormatLookup(range);
const theme = useTheme();
const uiTimezone = useSelector((state: any) => state.ui.timezone);
return (
<Text
x={x}
y={y + 10}
textAnchor="middle"
fill={theme.palette.primary.contrastTextTertiary}
fontSize={11}
fontWeight={400}
>
{formatDateWithTz(payload?.value, format, uiTimezone)}
</Text>
);
};
type ResponseTimeToolTipProps = {
active?: boolean | undefined;
payload?: any[];
label?: string;
range: string;
};
const ResponseTimeToolTip: React.FC<ResponseTimeToolTipProps> = ({
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 (
<BaseBox sx={{ py: theme.spacing(2), px: theme.spacing(4) }}>
<Typography>{formatDateWithTz(label, format, uiTimezone)}</Typography>
<Typography>Response time: {responseTime} ms</Typography>
</BaseBox>
);
};
export const ChartResponseTime = ({
checks,
range,
}: {
checks: GroupedCheck[];
range: string;
}) => {
const theme = useTheme();
return (
<BaseChart
icon={<ResponseTimeIcon />}
title="Response times"
>
<ResponsiveContainer
width="100%"
height={300}
>
<AreaChart data={checks?.slice().reverse()}>
<CartesianGrid
stroke={theme.palette.primary.lowContrast}
strokeWidth={1}
strokeOpacity={1}
fill="transparent"
vertical={false}
/>
<defs>
<linearGradient
id="colorUv"
x1="0"
y1="0"
x2="0"
y2="1"
>
<stop
offset="0%"
stopColor={theme.palette.accent.main}
stopOpacity={0.8}
/>
<stop
offset="100%"
stopColor={theme.palette.accent.light}
stopOpacity={0}
/>
</linearGradient>
</defs>
<XAxis
axisLine={false}
tickLine={false}
dataKey="_id"
tick={(props) => (
<XTick
{...props}
range={range}
/>
)}
/>
<Tooltip
content={(props) => (
<ResponseTimeToolTip
{...props}
range={range}
/>
)}
/>
<Area
type="monotone"
dataKey="avgResponseTime"
stroke={theme.palette.accent.main}
fill="url(#colorUv)"
/>
</AreaChart>
</ResponsiveContainer>
</BaseChart>
);
};
@@ -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";
@@ -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 (
<>
<text
x={0}
y="100%"
dy={-3}
textAnchor="start"
fontSize={11}
fill={theme.palette.primary.contrastTextTertiary}
>
{formatDateWithTz(p1._id, dateFormat, uiTimezone)}
</text>
<text
x="100%"
y="100%"
dy={-3}
textAnchor="end"
fontSize={11}
fill={theme.palette.primary.contrastTextTertiary}
>
{formatDateWithTz(p2._id, dateFormat, uiTimezone)}
</text>
</>
);
};
type BaseChartProps = React.PropsWithChildren<{
icon: React.ReactNode;
title: string;
}>;
export const BaseChart: React.FC<BaseChartProps> = ({ children, icon, title }) => {
const theme = useTheme();
return (
<BaseBox
sx={{
padding: theme.spacing(8),
minWidth: 250,
display: "flex",
flex: 1,
}}
>
<Stack
gap={theme.spacing(8)}
flex={1}
>
<Stack
direction="row"
alignItems={"center"}
gap={theme.spacing(4)}
>
<BaseBox
sx={{
display: "flex",
alignItems: "center",
justifyContent: "center",
width: 34,
height: 34,
backgroundColor: theme.palette.tertiary.main,
"& svg": {
width: 20,
height: 20,
"& path": {
stroke: theme.palette.primary.contrastTextTertiary,
},
},
}}
>
{icon}
</BaseBox>
<Typography variant="h2">{title}</Typography>
</Stack>
<Box flex={1}>{children}</Box>
</Stack>
</BaseBox>
);
};
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" ? <UptimeIcon /> : <IncidentsIcon />;
const theme = useTheme();
const [idx, setIdx] = useState<number | null>(null);
const dateFormat = range === "1d" || range === "2h" ? "MMM D, h A" : "MMM D";
if (checks.length === 0) {
return (
<BaseChart
icon={icon}
title={title}
>
<Stack
height={"100%"}
alignItems={"center"}
justifyContent={"center"}
>
<Typography variant="h2">
{status === "up" ? "No checks yet" : "Great, no downtime yet!"}
</Typography>
</Stack>
</BaseChart>
);
}
const totalChecks = checks.reduce((count, check) => {
return count + check.count;
}, 0);
return (
<BaseChart
icon={icon}
title={title}
>
<Stack gap={theme.spacing(8)}>
<Stack
position="relative"
direction="row"
justifyContent="space-between"
>
<Stack>
<Typography>Total checks</Typography>
{idx ? (
<Stack>
<Typography variant="h2">{checks[idx].count}</Typography>
<Typography
position={"absolute"}
top={"100%"}
>
{formatDateWithTz(checks[idx]._id, dateFormat, uiTimezone)}
</Typography>
</Stack>
) : (
<Typography variant="h2">{totalChecks}</Typography>
)}
</Stack>
</Stack>
<ResponsiveContainer
width="100%"
height={155}
>
<BarChart data={checks}>
<XAxis
stroke={theme.palette.primary.lowContrast}
height={15}
tick={false}
label={
<XLabel
p1={checks[0]}
p2={checks[checks.length - 1]}
range={range}
/>
}
/>
<Bar
dataKey="avgResponseTime"
maxBarSize={7}
background={{ fill: "transparent" }}
>
{checks?.map((groupedCheck, idx) => {
const fillColor = getResponseTimeColor(groupedCheck.avgResponseTime);
return (
<Cell
onMouseEnter={() => setIdx(idx)}
onMouseLeave={() => setIdx(null)}
key={groupedCheck._id}
fill={theme.palette[fillColor].main}
/>
);
})}
</Bar>
</BarChart>
</ResponsiveContainer>
</Stack>
</BaseChart>
);
};
+60 -3
View File
@@ -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<ApiResponse>(
`/monitors/${id}?embedChecks=true&range=${range}`,
@@ -23,6 +28,27 @@ const UptimeDetailsPage = () => {
{},
{ refreshInterval: 30000 }
);
const {
response: upResponse,
error: upError,
loading: upLoading,
} = useGet<ApiResponse>(
`/monitors/${id}?embedChecks=true&range=${range}&status=up`,
{},
{}
);
const {
response: downResponse,
error: downError,
loading: downLoading,
} = useGet<ApiResponse>(
`/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"}
/>
</Stack>
<Stack
direction={isSmall ? "column" : "row"}
gap={theme.spacing(8)}
>
<HistogramStatus
title="Uptime"
status={"up"}
checks={upChecks.reverse()}
range={range}
/>
<HistogramStatus
title="Incidents"
checks={downChecks.reverse()}
status={"down"}
range={range}
/>
<ChartAvgResponse
avg={avgResponseTime}
max={maxResponseTime}
/>
</Stack>
<ChartResponseTime
checks={checks}
range={range}
/>
</BasePage>
);
};
+10
View File
@@ -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 "";
+28
View File
@@ -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<string, string> = {
"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<string, string> = {
"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;
};