stat boxes
This commit is contained in:
1 parent
f7ae11997d
commit
a2569c50c7
9 files changed
+286
-17
No files matched your search
@@ -11,7 +11,7 @@ import type { Monitor } from "@/Types/Monitor.js";
|
||||
|
||||
interface HeaderMonitorControlsProps {
|
||||
path: string;
|
||||
monitor: Monitor;
|
||||
monitor?: Monitor;
|
||||
isAdmin: boolean;
|
||||
}
|
||||
|
||||
@@ -23,9 +23,13 @@ export const HeaderMonitorControls = ({
|
||||
const { t } = useTranslation();
|
||||
const navigate = useNavigate();
|
||||
const theme = useTheme();
|
||||
|
||||
if (!monitor) {
|
||||
return null;
|
||||
}
|
||||
return (
|
||||
<Stack
|
||||
spacing={{ xs: theme.spacing(4), md: 0 }}
|
||||
spacing={{ xs: theme.spacing(8), md: 0 }}
|
||||
direction={{ xs: "column", md: "row" }}
|
||||
alignItems={"center"}
|
||||
justifyContent={"space-between"}
|
||||
|
||||
@@ -0,0 +1,86 @@
|
||||
import Stack from "@mui/material/Stack";
|
||||
import Typography from "@mui/material/Typography";
|
||||
import Box from "@mui/material/Box";
|
||||
import { useTheme } from "@mui/material/styles";
|
||||
import { lighten } from "@mui/material/styles";
|
||||
import useMediaQuery from "@mui/material/useMediaQuery";
|
||||
import type { PaletteKey } from "@/Utils/Theme/v2Theme";
|
||||
import { BaseBox, TooltipWithInfo } from "@/Components/v2/design-elements";
|
||||
import type { SxProps } from "@mui/material";
|
||||
|
||||
type GradientBox = React.PropsWithChildren<{
|
||||
palette?: PaletteKey;
|
||||
sx?: SxProps;
|
||||
}>;
|
||||
|
||||
export const GradientBox = ({ children, palette, sx }: GradientBox) => {
|
||||
const theme = useTheme();
|
||||
const isSmall = useMediaQuery(theme.breakpoints.down("md"));
|
||||
const isLight = theme.palette.mode === "light";
|
||||
const paper = theme.palette.background.paper;
|
||||
const paperStart = lighten(paper, isLight ? 0.06 : 0.08);
|
||||
const paperEnd = paper;
|
||||
const bg = palette
|
||||
? `linear-gradient(135deg, ${theme.palette[palette].light} 0%, ${theme.palette[palette].main} 100%)`
|
||||
: `linear-gradient(135deg, ${paperStart} 0%, ${paperEnd} 100%)`;
|
||||
|
||||
return (
|
||||
<BaseBox
|
||||
sx={{
|
||||
padding: `${theme.spacing(4)} ${theme.spacing(8)}`,
|
||||
width: isSmall ? `100%` : `calc(25% - (3 * ${theme.spacing(8)} / 4))`,
|
||||
background: bg,
|
||||
...sx,
|
||||
}}
|
||||
>
|
||||
{children}
|
||||
</BaseBox>
|
||||
);
|
||||
};
|
||||
|
||||
type StatBoxProps = React.PropsWithChildren<{
|
||||
title: string;
|
||||
subtitle: string;
|
||||
palette?: PaletteKey;
|
||||
sx?: SxProps;
|
||||
tooltip?: string;
|
||||
onClick?: () => void;
|
||||
}>;
|
||||
|
||||
export const StatBox = ({
|
||||
title,
|
||||
subtitle,
|
||||
palette,
|
||||
children,
|
||||
sx,
|
||||
tooltip,
|
||||
onClick,
|
||||
}: StatBoxProps) => {
|
||||
const theme = useTheme();
|
||||
const textColor = palette ? theme.palette[palette].contrastText : "inherit";
|
||||
|
||||
return (
|
||||
<GradientBox
|
||||
palette={palette}
|
||||
sx={{
|
||||
...(sx as object),
|
||||
...(onClick ? { cursor: "pointer", "&:hover": { opacity: 0.95 } } : {}),
|
||||
}}
|
||||
>
|
||||
<Stack onClick={onClick}>
|
||||
<Box sx={{ display: "flex", alignItems: "center", gap: theme.spacing(2) }}>
|
||||
<Typography color={textColor}>{title}</Typography>
|
||||
{tooltip && (
|
||||
<TooltipWithInfo
|
||||
title={tooltip}
|
||||
iconColor={textColor as string}
|
||||
iconSize={14}
|
||||
/>
|
||||
)}
|
||||
</Box>
|
||||
<Typography color={textColor}>{subtitle}</Typography>
|
||||
{children}
|
||||
</Stack>
|
||||
</GradientBox>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,79 @@
|
||||
import MuiTooltip from "@mui/material/Tooltip";
|
||||
import { useTheme } from "@mui/material/styles";
|
||||
import { Info } from "lucide-react";
|
||||
|
||||
import type { TooltipProps } from "@mui/material/Tooltip";
|
||||
|
||||
type StyledTooltipProps = TooltipProps;
|
||||
|
||||
export const Tooltip = ({ placement = "top", ...props }: StyledTooltipProps) => {
|
||||
const theme = useTheme();
|
||||
|
||||
return (
|
||||
<MuiTooltip
|
||||
title={props.title}
|
||||
placement={placement}
|
||||
slotProps={{
|
||||
tooltip: {
|
||||
sx: {
|
||||
background:
|
||||
theme.palette.mode === "dark"
|
||||
? "linear-gradient(135deg, #1a1a2e 0%, #16213e 100%)"
|
||||
: "linear-gradient(135deg, #2c3e50 0%, #1a252f 100%)",
|
||||
backgroundColor: "transparent",
|
||||
color: "#ffffff",
|
||||
fontSize: "13px",
|
||||
padding: `${theme.spacing(4)} ${theme.spacing(5)}`,
|
||||
borderRadius: `${theme.shape.borderRadius}px`,
|
||||
boxShadow: "0 8px 24px rgba(0, 0, 0, 0.4)",
|
||||
},
|
||||
},
|
||||
}}
|
||||
>
|
||||
{props.children}
|
||||
</MuiTooltip>
|
||||
);
|
||||
};
|
||||
|
||||
export interface TooltipWithInfoProps extends Omit<StyledTooltipProps, "children"> {
|
||||
iconSize?: number;
|
||||
iconColor?: string;
|
||||
}
|
||||
|
||||
export const TooltipWithInfo = ({
|
||||
iconSize = 14,
|
||||
iconColor,
|
||||
...props
|
||||
}: TooltipWithInfoProps) => {
|
||||
const theme = useTheme();
|
||||
|
||||
const defaultColor = theme.palette.text.secondary;
|
||||
|
||||
return (
|
||||
<Tooltip {...props}>
|
||||
<span
|
||||
style={{
|
||||
display: "inline-flex",
|
||||
alignItems: "center",
|
||||
cursor: "help",
|
||||
}}
|
||||
>
|
||||
<Info
|
||||
size={iconSize}
|
||||
strokeWidth={1.5}
|
||||
style={{
|
||||
opacity: 0.7,
|
||||
color: iconColor || defaultColor,
|
||||
transition: "opacity 0.2s ease",
|
||||
}}
|
||||
onMouseEnter={(e) => {
|
||||
e.currentTarget.style.opacity = "1";
|
||||
}}
|
||||
onMouseLeave={(e) => {
|
||||
e.currentTarget.style.opacity = "0.7";
|
||||
}}
|
||||
/>
|
||||
</span>
|
||||
</Tooltip>
|
||||
);
|
||||
};
|
||||
@@ -12,3 +12,5 @@ export * from "./PulseDot";
|
||||
export * from "./Dot";
|
||||
export * from "./MonitorStatus";
|
||||
export { default as Icon } from "./Icon";
|
||||
export * from "./Tooltip";
|
||||
export * from "./StatBox";
|
||||
@@ -0,0 +1,69 @@
|
||||
import Stack from "@mui/material/Stack";
|
||||
import { StatBox } from "@/Components/v2/design-elements";
|
||||
|
||||
import { useTheme } from "@mui/material/styles";
|
||||
import type { MonitorStats, Monitor } from "@/Types/Monitor";
|
||||
import { getHumanReadableDuration } from "@/Utils/timeUtils.js";
|
||||
import { getStatusPalette } from "@/Utils/MonitorUtils";
|
||||
import { useTranslation } from "react-i18next";
|
||||
|
||||
interface MonitorStatBoxesProps {
|
||||
monitor?: Monitor;
|
||||
monitorStats: MonitorStats | null;
|
||||
certificateExpiry?: string;
|
||||
}
|
||||
|
||||
export const MonitorStatBoxes = ({
|
||||
monitor,
|
||||
monitorStats,
|
||||
certificateExpiry,
|
||||
}: MonitorStatBoxesProps) => {
|
||||
const theme = useTheme();
|
||||
const { t } = useTranslation();
|
||||
if (!monitorStats || !monitor) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const timeOfLastFailure = monitorStats?.timeOfLastFailure || 0;
|
||||
const timeSinceLastFailure =
|
||||
timeOfLastFailure > 0
|
||||
? Date.now() - timeOfLastFailure
|
||||
: Date.now() - new Date(monitorStats?.createdAt);
|
||||
|
||||
// Determine time since last check
|
||||
const timeOfLastCheck = monitorStats?.lastCheckTimestamp || 0;
|
||||
const timeSinceLastCheck = Date.now() - timeOfLastCheck;
|
||||
|
||||
const streakTime = getHumanReadableDuration(timeSinceLastFailure);
|
||||
|
||||
const lastCheckTime = getHumanReadableDuration(timeSinceLastCheck);
|
||||
const palette = getStatusPalette(monitor?.status);
|
||||
|
||||
return (
|
||||
<Stack
|
||||
direction={{ xs: "column", md: "row" }}
|
||||
gap={theme.spacing(8)}
|
||||
>
|
||||
<StatBox
|
||||
palette={palette}
|
||||
title={t("pages.common.monitors.statBoxes.activeFor")}
|
||||
subtitle={streakTime}
|
||||
/>
|
||||
<StatBox
|
||||
title={t("pages.common.monitors.statBoxes.lastCheck")}
|
||||
subtitle={lastCheckTime}
|
||||
/>
|
||||
<StatBox
|
||||
title={t("pages.common.monitors.statBoxes.lastResponseTime")}
|
||||
subtitle={monitorStats?.lastResponseTime + " ms"}
|
||||
/>
|
||||
|
||||
{monitor?.type === "http" && (
|
||||
<StatBox
|
||||
title={t("pages.common.monitors.statBoxes.certificateExpiry")}
|
||||
subtitle={certificateExpiry || "N/A"}
|
||||
/>
|
||||
)}
|
||||
</Stack>
|
||||
);
|
||||
};
|
||||
@@ -1 +1,2 @@
|
||||
export * from "./ControlsFilter";
|
||||
export * from "./MonitorStatBoxes";
|
||||
@@ -9,10 +9,15 @@ import { useGet } from "@/Hooks/UseApi";
|
||||
import type { MonitorDetailsResponse } from "@/Types/Monitor";
|
||||
import type { ChecksResponse } from "@/Types/Check";
|
||||
import type { RootState } from "@/Types/state";
|
||||
import useCertificateFetch from "./Hooks/useCertificateFetch";
|
||||
import { MonitorStatBoxes } from "@/Components/v2/monitors";
|
||||
import { formatDateWithTz } from "@/Utils/timeUtils";
|
||||
|
||||
const certificateDateFormat = "MMM D, YYYY h A";
|
||||
|
||||
interface CertificateResponse {
|
||||
certificateDate: string;
|
||||
}
|
||||
|
||||
const UptimeDetailsPage = () => {
|
||||
const isAdmin = useIsAdmin();
|
||||
const { monitorId } = useParams<{ monitorId: string }>();
|
||||
@@ -43,12 +48,22 @@ const UptimeDetailsPage = () => {
|
||||
const monitor = monitorData?.monitor;
|
||||
const monitorStats = monitorDetailsData?.monitorStats ?? null;
|
||||
|
||||
const [certificateExpiry] = useCertificateFetch({
|
||||
monitor,
|
||||
monitorId,
|
||||
certificateDateFormat,
|
||||
uiTimezone,
|
||||
});
|
||||
// Certificate fetch - only for HTTP monitors
|
||||
const certificateUrl = useMemo(() => {
|
||||
if (!monitorId || monitor?.type !== "http") {
|
||||
return null;
|
||||
}
|
||||
return `/monitors/certificate/${monitorId}`;
|
||||
}, [monitorId, monitor?.type]);
|
||||
|
||||
const { data: certificateData } = useGet<CertificateResponse>(certificateUrl);
|
||||
|
||||
const certificateExpiry = useMemo(() => {
|
||||
if (!certificateData?.certificateDate) {
|
||||
return undefined;
|
||||
}
|
||||
return formatDateWithTz(certificateData.certificateDate, certificateDateFormat, uiTimezone) ?? "N/A";
|
||||
}, [certificateData, uiTimezone]);
|
||||
|
||||
const checksUrl = useMemo(() => {
|
||||
if (!monitorId || !monitor?.type) {
|
||||
@@ -79,7 +94,6 @@ const UptimeDetailsPage = () => {
|
||||
monitorError,
|
||||
refetchMonitor,
|
||||
monitorStats,
|
||||
certificateExpiry,
|
||||
checksIsLoading,
|
||||
checksError,
|
||||
checks,
|
||||
@@ -89,10 +103,6 @@ const UptimeDetailsPage = () => {
|
||||
setDateRange,
|
||||
});
|
||||
|
||||
if (!monitor) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<BasePage>
|
||||
<HeaderMonitorControls
|
||||
@@ -100,6 +110,11 @@ const UptimeDetailsPage = () => {
|
||||
monitor={monitor}
|
||||
isAdmin={isAdmin}
|
||||
/>
|
||||
<MonitorStatBoxes
|
||||
monitor={monitor}
|
||||
monitorStats={monitorStats}
|
||||
certificateExpiry={certificateExpiry}
|
||||
/>
|
||||
</BasePage>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -56,11 +56,18 @@ export interface MonitorGroupedCheck {
|
||||
}
|
||||
|
||||
export interface MonitorStats {
|
||||
id: string;
|
||||
monitorId: string;
|
||||
avgResponseTime: number;
|
||||
totalChecks: number;
|
||||
upChecks: number;
|
||||
downChecks: number;
|
||||
totalUpChecks: number;
|
||||
totalDownChecks: number;
|
||||
uptimePercentage: number;
|
||||
averageResponseTime: number;
|
||||
lastCheckTimestamp: number;
|
||||
lastResponseTime: number;
|
||||
timeOfLastFailure?: number;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
}
|
||||
|
||||
export interface MonitorData {
|
||||
|
||||
@@ -169,6 +169,12 @@
|
||||
"pause": "Pause",
|
||||
"resume": "Resume",
|
||||
"delete": "Delete"
|
||||
},
|
||||
"statBoxes": {
|
||||
"activeFor": "Active for",
|
||||
"lastCheck": "Last check",
|
||||
"lastResponseTime": "Last response time",
|
||||
"certificateExpiry": "Certificate expiry"
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
Reference in new issue
Block a user