Merge remote-tracking branch 'origin/develop' into feat/pagespeed-mobile-strat

This commit is contained in:
Alex committed 2026-05-11 11:11:13 -07:00
commit 78fdc548bb
395 files changed
+60597 -29063

No files matched your search

+21 -2
View File
@@ -33,6 +33,14 @@ function App() {
body: {
backgroundColor: theme.palette.background.default,
},
".Toastify__progress-bar": {
height: 2,
bottom: 0,
top: "auto",
},
".Toastify__progress-bar--bg": {
display: "none",
},
}}
/>
<AppLayout>
@@ -41,12 +49,23 @@ function App() {
<ToastContainer
newestOnTop={true}
theme={mode}
icon={false}
style={
{
"--toastify-color-progress-light": "#7C8BA1",
"--toastify-color-progress-dark": "#7C8BA1",
"--toastify-color-progress-light": theme.palette.primary.main,
"--toastify-color-progress-dark": theme.palette.primary.main,
"--toastify-toast-min-height": "40px",
"--toastify-toast-padding": "8px 12px",
"--toastify-toast-width": "auto",
} as CSSProperties
}
toastStyle={{
minHeight: 40,
padding: "8px 12px",
width: "auto",
maxWidth: "min(560px, calc(100vw - 32px))",
}}
progressStyle={{ height: 2 }}
/>
</ThemeProvider>
</SWRConfig>
@@ -5,7 +5,7 @@ import { getResponseColor } from "@/Utils/DataUtils";
import { HeatmapResponseTimeTooltip } from "@/Components/common/charts/HeatmapResponseTimeTooltip";
import type { SxProps } from "@mui/material/styles";
import type { ResponsiveStyleValue } from "@mui/system";
import { MAX_RECENT_CHECKS } from "@/Types/Monitor";
interface PlaceholderCheck {
status: "placeholder";
responseTime: 0;
@@ -32,11 +32,11 @@ export const HeatmapResponseTime = ({
if (!checks || checks.length === 0) return null;
const latestChecks = checks.slice(-25);
const latestChecks = checks.slice(-MAX_RECENT_CHECKS);
let data: Array<CheckSnapshot | PlaceholderCheck>;
if (latestChecks.length !== 25) {
const placeholders = Array(25 - latestChecks.length).fill({
if (latestChecks.length !== MAX_RECENT_CHECKS) {
const placeholders = Array(MAX_RECENT_CHECKS - latestChecks.length).fill({
status: "placeholder" as const,
});
data = [...latestChecks, ...placeholders];
@@ -44,13 +44,13 @@ export const HeatmapResponseTime = ({
data = latestChecks;
}
return (
<Box sx={{ width: "100%" }}>
<Box width={"100%"}>
<Box
width={"100%"}
display={"grid"}
gap={gap}
sx={{
width: "100%",
display: "grid",
gridTemplateColumns: "repeat(25, 1fr)",
gap: gap,
gridTemplateColumns: `repeat(${MAX_RECENT_CHECKS}, 1fr)`,
alignItems: "stretch",
}}
>
@@ -82,50 +82,50 @@ export const HeatmapResponseTime = ({
check={check}
>
<Box
display={"grid"}
sx={{
display: "grid",
gridTemplateRows: "auto auto",
}}
>
<Box
display={"flex"}
gap={theme.spacing(2)}
sx={{
display: "flex",
flexDirection: "column",
gap: theme.spacing(2),
}}
>
<Box
width={"100%"}
sx={{
position: "relative",
width: "100%",
aspectRatio: "10",
}}
>
<Box
bgcolor={statusBg}
borderRadius={theme.shape.borderRadius}
sx={{
position: "absolute",
inset: 0,
bgcolor: statusBg,
borderRadius: theme.spacing(0.5),
...availabilityCellSx,
}}
/>
<Box
borderRadius={theme.shape.borderRadius}
sx={{
position: "absolute",
inset: 0,
pointerEvents: "none",
borderRadius: theme.spacing(0.5),
boxShadow: `inset 0 0 0 2px ${statusBorder}`,
}}
/>
</Box>
<Box
width={"100%"}
bgcolor={respBg}
borderRadius={theme.shape.borderRadius}
sx={{
width: "100%",
aspectRatio: "1 / 1",
bgcolor: respBg,
borderRadius: theme.spacing(0.5),
...responseCellSx,
}}
/>
@@ -7,6 +7,7 @@ import type { CheckSnapshot } from "@/Types/Check";
import { HeatmapResponseTimeTooltip } from "./HeatmapResponseTimeTooltip";
import { useMemo } from "react";
import { useTranslation } from "react-i18next";
import { MAX_RECENT_CHECKS } from "@/Types/Monitor";
interface HistogramResponseTimeProps {
checks: CheckSnapshot[];
@@ -57,8 +58,11 @@ export const HistogramResponseTime = ({
if (!Array.isArray(checks) || checks.length === 0) return null;
const data =
checks.length !== 25
? [...checks, ...Array(25 - checks.length).fill({ status: "placeholder" })]
checks.length !== MAX_RECENT_CHECKS
? [
...checks,
...Array(MAX_RECENT_CHECKS - checks.length).fill({ status: "placeholder" }),
]
: checks;
const chartHeight = typeof height === "number" ? `${height}px` : height;
@@ -86,17 +90,17 @@ export const HistogramResponseTime = ({
<Stack
direction={statsPosition === "left" ? "row-reverse" : "row"}
alignItems="center"
sx={{ width: "100%" }}
width={"100%"}
>
<Box sx={{ flex: 1 }}>
<Box
width={"100%"}
display={"grid"}
gap={gridGap}
height={chartHeight}
sx={{
width: "100%",
display: "grid",
gridTemplateColumns: "repeat(25, 1fr)",
gap: gridGap,
gridTemplateColumns: `repeat(${MAX_RECENT_CHECKS}, 1fr)`,
alignItems: "end",
height: chartHeight,
}}
>
{data.map((check, index) => {
@@ -108,23 +112,23 @@ export const HistogramResponseTime = ({
: theme.palette.error.light;
const bar = (
<Box
width={"100%"}
height={"100%"}
borderRadius={theme.shape.borderRadius}
bgcolor={theme.palette.action.hover}
sx={{
position: "relative",
width: "100%",
height: "100%",
borderRadius: theme.spacing(1),
bgcolor: theme.palette.action.hover,
overflow: "hidden",
}}
>
<Box
width={"100%"}
height={isPlaceholder ? 0 : heightPct}
bgcolor={barColor}
sx={{
position: "absolute",
bottom: 0,
left: 0,
width: "100%",
height: isPlaceholder ? 0 : heightPct,
bgcolor: barColor,
transition: "height 500ms cubic-bezier(0.4, 0, 0.2, 1)",
}}
/>
@@ -133,8 +137,8 @@ export const HistogramResponseTime = ({
return isPlaceholder ? (
<Box
height={"100%"}
key={index}
sx={{ height: "100%" }}
>
{bar}
</Box>
@@ -8,7 +8,7 @@ import type { ResponsiveStyleValue } from "@mui/system";
import { useTheme } from "@mui/material/styles";
type BaseChartProps = React.PropsWithChildren<{
icon: React.ReactNode;
icon?: React.ReactNode;
title: string;
width?: number | string;
maxWidth?: number | string;
@@ -17,7 +17,6 @@ type BaseChartProps = React.PropsWithChildren<{
export const BaseChart = ({
children,
icon,
title,
width = "100%",
maxWidth = "100%",
@@ -38,34 +37,12 @@ export const BaseChart = ({
gap={theme.spacing(LAYOUT.MD)}
flex={1}
>
<Stack
direction="row"
alignItems={"center"}
gap={theme.spacing(LAYOUT.XS)}
<Typography
variant="eyebrow"
color="text.secondary"
>
{icon && (
<BaseBox
sx={{
display: "flex",
alignItems: "center",
justifyContent: "center",
width: 34,
height: 34,
backgroundColor: theme.palette.action.hover,
"& svg": {
width: 20,
height: 20,
"& path": {
stroke: theme.palette.text.secondary,
},
},
}}
>
{icon}
</BaseBox>
)}
<Typography variant="h2">{title}</Typography>
</Stack>
{title}
</Typography>
<Box flex={1}>{children}</Box>
</Stack>
</BaseBox>
@@ -1,18 +1,19 @@
import Logo from "@/assets/icons/checkmate-icon.svg?react";
import Stack from "@mui/material/Stack";
import Box from "@mui/material/Box";
import Alert from "@mui/material/Alert";
import Link from "@mui/material/Link";
import { LAYOUT, SPACING } from "@/Utils/Theme/constants";
import { LAYOUT } from "@/Utils/Theme/constants";
import {
ErrorFallback,
EmptyFallback,
EmptyMonitorFallback,
BaseFallback,
} from "@/Components/design-elements/Fallback";
import { EmptyState } from "@/Components/design-elements/EmptyState";
import { NoticeBanner } from "@/Components/design-elements/NoticeBanner";
import { Breadcrumb } from "@/Components/design-elements/Breadcrumb";
import { PageHeader } from "@/Components/design-elements/PageHeader";
import CircularProgress from "@mui/material/CircularProgress";
import { HeaderAuthControls } from "@/Pages/Auth/components/HeaderAuthControls";
import { LanguageSelector, SwitchTheme } from "@/Components/inputs";
import type { StackProps } from "@mui/material/Stack";
import { useTheme } from "@mui/material/styles";
@@ -21,16 +22,15 @@ import { Link as RouterLink } from "react-router-dom";
import { Typography } from "@mui/material";
export const PageSpeedKeyPriorityFallback = () => {
const theme = useTheme();
const { t } = useTranslation();
return (
<BaseFallback>
<Alert
severity="warning"
sx={{
width: "100%",
maxWidth: 600,
}}
>
<Typography>
<EmptyState
fullscreen
title={t("pages.pageSpeed.fallback.title")}
description={t("pages.pageSpeed.fallback.description")}
alert={
<NoticeBanner severity="warning">
<Trans
i18nKey="common.alerts.pageSpeedApiKey.content"
components={{
@@ -38,15 +38,18 @@ export const PageSpeedKeyPriorityFallback = () => {
<Link
component={RouterLink}
to="/settings"
color="inherit"
fontWeight="inherit"
color={theme.palette.primary.main}
fontWeight={600}
sx={{
textDecoration: "underline",
}}
/>
),
}}
/>
</Typography>
</Alert>
</BaseFallback>
</NoticeBanner>
}
/>
);
};
@@ -55,6 +58,9 @@ interface BasePageProps extends StackProps {
error?: boolean;
children: React.ReactNode;
breadcrumbOverride?: string[];
headerKey?: string;
backTo?: string;
backLabel?: string;
}
export const BasePage = ({
@@ -62,9 +68,13 @@ export const BasePage = ({
error,
children,
breadcrumbOverride,
headerKey,
backTo,
backLabel,
...props
}: BasePageProps) => {
const theme = useTheme();
const { t } = useTranslation();
if (loading) {
return (
@@ -95,7 +105,16 @@ export const BasePage = ({
spacing={theme.spacing(LAYOUT.LG)}
{...props}
>
<Breadcrumb breadcrumbOverride={breadcrumbOverride} />
{headerKey ? (
<PageHeader
title={t(`pages.${headerKey}.header.title`)}
description={t(`pages.${headerKey}.header.description`)}
backTo={backTo}
backLabel={backLabel}
/>
) : (
<Breadcrumb breadcrumbOverride={breadcrumbOverride} />
)}
{children}
</Stack>
);
@@ -105,11 +124,12 @@ interface BasePageWithStatesProps extends StackProps {
loading: boolean;
error: any;
totalCount: number;
bullets: string[] | unknown;
description?: string;
page: string;
actionButtonText: string;
actionLink: string;
children: React.ReactNode;
headerKey?: string;
}
export const BasePageWithStates = ({
@@ -117,10 +137,11 @@ export const BasePageWithStates = ({
error,
totalCount,
page,
bullets,
description,
actionButtonText,
actionLink,
children,
headerKey,
...props
}: BasePageWithStatesProps) => {
const showLoading = loading && totalCount === 0;
@@ -128,7 +149,7 @@ export const BasePageWithStates = ({
if (!loading && totalCount === 0) {
return (
<EmptyFallback
bullets={bullets}
description={description}
title={page}
actionButtonText={actionButtonText}
actionLink={actionLink}
@@ -140,6 +161,7 @@ export const BasePageWithStates = ({
<BasePage
loading={showLoading}
error={error}
headerKey={headerKey}
{...props}
>
{children}
@@ -155,6 +177,7 @@ interface MonitorBasePageWithStatesProps extends StackProps {
actionLink?: string;
children: React.ReactNode;
priorityFallback?: React.ReactNode;
headerKey?: string;
}
export const MonitorBasePageWithStates = ({
@@ -165,6 +188,7 @@ export const MonitorBasePageWithStates = ({
actionLink,
children,
priorityFallback,
headerKey,
...props
}: MonitorBasePageWithStatesProps) => {
const { t } = useTranslation();
@@ -172,15 +196,7 @@ export const MonitorBasePageWithStates = ({
const showLoading = loading && totalCount === 0;
if (priorityFallback) {
return (
<BasePage
loading={loading}
error={error}
{...props}
>
<Stack height={"100%"}>{priorityFallback}</Stack>
</BasePage>
);
return <>{priorityFallback}</>;
}
if (!loading && totalCount === 0) {
@@ -188,7 +204,7 @@ export const MonitorBasePageWithStates = ({
<EmptyMonitorFallback
page={page}
title={t(`pages.${page}.fallback.title`)}
bullets={t(`pages.${page}.fallback.checks`, { returnObjects: true })}
description={t(`pages.${page}.fallback.description`)}
actionButtonText={t(`pages.${page}.fallback.actionButton`)}
actionLink={actionLink || ""}
/>
@@ -199,6 +215,7 @@ export const MonitorBasePageWithStates = ({
<BasePage
loading={showLoading}
error={error}
headerKey={headerKey}
{...props}
>
{children}
@@ -215,51 +232,163 @@ export const BaseAuthPage = ({
title,
subtitle,
children,
...props
component,
onSubmit,
}: BaseAuthPageProps) => {
const theme = useTheme();
const { t } = useTranslation();
return (
<BasePage
breadcrumbOverride={[]}
gap={theme.spacing(LAYOUT.MD)}
alignItems={"center"}
justifyContent={"center"}
<Stack
direction={{ xs: "column", md: "row" }}
minHeight="100vh"
position={"relative"}
{...props}
width="100%"
position="relative"
sx={{ backgroundColor: theme.palette.background.default }}
>
<HeaderAuthControls
hideLogo
py={theme.spacing(LAYOUT.XS)}
position={"absolute"}
top={0}
left={0}
/>
<Box width={{ xs: 60, sm: 70, md: 80 }}>
<Logo
width={"100%"}
height={"100%"}
/>
</Box>
<Stack alignItems={"center"}>
<Typography
variant="h1"
mb={theme.spacing(SPACING.LG)}
<Stack
flex={1}
alignItems="center"
justifyContent="center"
position="relative"
px={theme.spacing(LAYOUT.MD)}
py={{ xs: theme.spacing(20), md: theme.spacing(12) }}
>
<Stack
direction="row"
spacing={theme.spacing(2)}
alignItems="center"
sx={{
position: "absolute",
top: theme.spacing(LAYOUT.MD),
right: theme.spacing(LAYOUT.MD),
zIndex: 3,
}}
>
{title}
</Typography>
<Typography variant="h2">{subtitle}</Typography>
<LanguageSelector />
<SwitchTheme />
</Stack>
<Box
component={component as React.ElementType}
onSubmit={onSubmit}
sx={{
display: "flex",
flexDirection: "column",
gap: theme.spacing(LAYOUT.MD),
width: "100%",
maxWidth: 360,
}}
>
<Stack
direction="row"
alignItems="center"
gap={theme.spacing(3)}
mb={theme.spacing(2)}
>
<Box width={28}>
<Logo width="100%" />
</Box>
<Typography sx={{ fontWeight: 500, letterSpacing: "-0.01em", fontSize: 16 }}>
{t("common.appName")}
</Typography>
</Stack>
<Stack gap={theme.spacing(2)}>
<Typography
sx={{
fontSize: 26,
fontWeight: 400,
lineHeight: 1.15,
letterSpacing: "-0.02em",
color: theme.palette.text.primary,
}}
>
{title}
</Typography>
<Typography
sx={{
fontSize: 14,
color: theme.palette.text.secondary,
}}
>
{subtitle}
</Typography>
</Stack>
{children}
</Box>
</Stack>
<Stack
gap={theme.spacing(LAYOUT.MD)}
width={{
xs: "80%",
md: "25%",
lg: "15%",
flex={1}
justifyContent="space-between"
display={{ xs: "none", md: "flex" }}
sx={{
background: "linear-gradient(135deg, #0c4d3d 0%, #155a48 60%, #0f5d4a 100%)",
color: "#fff",
p: theme.spacing(28),
position: "relative",
overflow: "hidden",
"&::after": {
content: '""',
position: "absolute",
inset: 0,
backgroundImage: `
linear-gradient(45deg, rgba(255,255,255,0.04) 25%, transparent 25%),
linear-gradient(-45deg, rgba(255,255,255,0.04) 25%, transparent 25%),
linear-gradient(45deg, transparent 75%, rgba(255,255,255,0.04) 75%),
linear-gradient(-45deg, transparent 75%, rgba(255,255,255,0.04) 75%)
`,
backgroundSize: "80px 80px",
backgroundPosition: "0 0, 0 40px, 40px -40px, -40px 0px",
maskImage: "linear-gradient(135deg, transparent 0%, black 70%)",
WebkitMaskImage: "linear-gradient(135deg, transparent 0%, black 70%)",
pointerEvents: "none",
},
}}
>
{children}
<div />
<Stack sx={{ position: "relative", zIndex: 1, gap: theme.spacing(4) }}>
<Typography
sx={{
fontSize: 13,
fontWeight: 600,
textTransform: "uppercase",
letterSpacing: "0.12em",
color: "rgba(255,255,255,0.75)",
}}
>
{t("pages.auth.brandPanel.eyebrow")}
</Typography>
<Typography
sx={{
fontSize: 44,
fontWeight: 400,
lineHeight: 1.1,
letterSpacing: "-0.02em",
maxWidth: 460,
}}
>
{t("pages.auth.brandPanel.tagline")}
</Typography>
<Typography
sx={{
fontSize: 17,
lineHeight: 1.5,
color: "rgba(255,255,255,0.75)",
maxWidth: 460,
}}
>
{t("pages.auth.brandPanel.description")}
</Typography>
</Stack>
<Typography
sx={{
position: "relative",
zIndex: 1,
color: "rgba(255,255,255,0.5)",
fontSize: 12,
}}
>
v{__APP_VERSION__}
</Typography>
</Stack>
</BasePage>
</Stack>
);
};
@@ -0,0 +1,158 @@
import Stack from "@mui/material/Stack";
import Typography from "@mui/material/Typography";
import { useTheme } from "@mui/material/styles";
import { Link as RouterLink } from "react-router-dom";
import { Button } from "@/Components/inputs";
import { LAYOUT } from "@/Utils/Theme/constants";
import { EmptyStateIllustration } from "./EmptyStateIllustration";
import type { ReactNode } from "react";
interface EmptyStateProps {
title: string;
description?: ReactNode;
actionText?: string;
actionTo?: string;
onAction?: () => void;
secondaryText?: string;
secondaryHref?: string;
compact?: boolean;
fullscreen?: boolean;
alert?: ReactNode;
children?: ReactNode;
}
export const EmptyState = ({
title,
description,
actionText,
actionTo,
onAction,
secondaryText,
secondaryHref,
compact = false,
fullscreen = false,
alert,
children,
}: EmptyStateProps) => {
const theme = useTheme();
if (compact) {
return (
<Stack
direction="row"
alignItems="center"
gap={theme.spacing(LAYOUT.MD)}
sx={{ py: theme.spacing(LAYOUT.MD), px: theme.spacing(LAYOUT.SM) }}
>
<EmptyStateIllustration
width={80}
height={60}
/>
<Stack sx={{ flex: 1 }}>
<Typography
sx={{
fontSize: 14,
fontWeight: 400,
color: theme.palette.text.secondary,
}}
>
{title}
</Typography>
{description && (
<Typography
sx={{
fontSize: 13,
color: theme.palette.text.secondary,
lineHeight: 1.55,
}}
>
{description}
</Typography>
)}
</Stack>
</Stack>
);
}
return (
<Stack
alignItems="center"
justifyContent="center"
sx={{
...(fullscreen && { minHeight: "65vh" }),
py: theme.spacing(LAYOUT.XXL),
px: theme.spacing(LAYOUT.MD),
textAlign: "center",
}}
>
<EmptyStateIllustration />
<Typography
sx={{
mt: theme.spacing(LAYOUT.MD),
mb: theme.spacing(LAYOUT.XS),
fontSize: 14,
fontWeight: 400,
color: theme.palette.text.secondary,
}}
>
{title}
</Typography>
{description && (
<Typography
sx={{
maxWidth: 360,
fontSize: 13,
color: theme.palette.text.secondary,
lineHeight: 1.55,
mb: theme.spacing(LAYOUT.LG),
}}
>
{description}
</Typography>
)}
{alert && (
<Stack sx={{ width: "100%", maxWidth: 520, mb: theme.spacing(LAYOUT.MD) }}>
{alert}
</Stack>
)}
{(actionText || secondaryText) && (
<Stack
direction="row"
alignItems="center"
gap={theme.spacing(LAYOUT.MD)}
>
{actionText && (actionTo || onAction) && (
<Button
variant="contained"
color="primary"
{...(actionTo
? { component: RouterLink, to: actionTo }
: { onClick: onAction })}
>
{actionText}
</Button>
)}
{secondaryText && secondaryHref && (
<Typography
component="a"
href={secondaryHref}
target="_blank"
rel="noopener"
sx={{
fontSize: 13,
fontWeight: 500,
color: theme.palette.primary.main,
textDecoration: "none",
"&:hover": { textDecoration: "underline" },
}}
>
{secondaryText} →
</Typography>
)}
</Stack>
)}
{children}
</Stack>
);
};
@@ -0,0 +1,197 @@
import { useTheme } from "@mui/material/styles";
interface EmptyStateIllustrationProps {
width?: number | string;
height?: number | string;
}
const PALETTE = {
light: {
grid: "#CBD5E1",
backFill: "#F5F7FA",
backStroke: "#D5DBE3",
backInner: "#B6C2CF",
frontFill: "#EAF5F0",
frontStroke: "#7DBFAA",
frontInner: "#4DAF94",
},
dark: {
grid: "#3A4452",
backFill: "#475569",
backStroke: "#64748B",
backInner: "#CBD5E1",
frontFill: "#0F5D4A",
frontStroke: "#4DAF94",
frontInner: "#B5E2D2",
},
};
export const EmptyStateIllustration = ({
width = 160,
height = 120,
}: EmptyStateIllustrationProps) => {
const theme = useTheme();
const p = theme.palette.mode === "dark" ? PALETTE.dark : PALETTE.light;
return (
<svg
viewBox="0 0 160 120"
width={width}
height={height}
aria-hidden="true"
>
<g
stroke={p.grid}
strokeWidth="0.75"
>
<line
x1="16"
y1="0"
x2="16"
y2="120"
/>
<line
x1="56"
y1="0"
x2="56"
y2="120"
strokeDasharray="3 3"
/>
<line
x1="104"
y1="0"
x2="104"
y2="120"
strokeDasharray="3 3"
/>
<line
x1="144"
y1="0"
x2="144"
y2="120"
/>
<line
x1="0"
y1="12"
x2="160"
y2="12"
/>
<line
x1="0"
y1="44"
x2="160"
y2="44"
strokeDasharray="3 3"
/>
<line
x1="0"
y1="76"
x2="160"
y2="76"
strokeDasharray="3 3"
/>
<line
x1="0"
y1="108"
x2="160"
y2="108"
/>
</g>
<g>
<rect
x="38"
y="22"
width="92"
height="42"
rx="6"
fill={p.backFill}
stroke={p.backStroke}
strokeWidth="0.5"
/>
<rect
x="48"
y="32"
width="14"
height="22"
rx="2"
fill={p.backInner}
opacity="0.5"
/>
<rect
x="68"
y="34"
width="50"
height="2.5"
rx="1.25"
fill={p.backInner}
opacity="0.7"
/>
<rect
x="68"
y="42"
width="38"
height="2.5"
rx="1.25"
fill={p.backInner}
opacity="0.55"
/>
<rect
x="68"
y="50"
width="44"
height="2.5"
rx="1.25"
fill={p.backInner}
opacity="0.55"
/>
<rect
x="22"
y="50"
width="92"
height="42"
rx="6"
fill={p.frontFill}
stroke={p.frontStroke}
strokeWidth="0.5"
strokeOpacity="0.4"
/>
<rect
x="32"
y="60"
width="14"
height="22"
rx="2"
fill={p.frontInner}
opacity="0.35"
/>
<rect
x="52"
y="62"
width="50"
height="2.5"
rx="1.25"
fill={p.frontInner}
opacity="0.55"
/>
<rect
x="52"
y="70"
width="38"
height="2.5"
rx="1.25"
fill={p.frontInner}
opacity="0.4"
/>
<rect
x="52"
y="78"
width="44"
height="2.5"
rx="1.25"
fill={p.frontInner}
opacity="0.4"
/>
</g>
</svg>
);
};
@@ -1,12 +1,8 @@
import Stack from "@mui/material/Stack";
import Typography from "@mui/material/Typography";
import { BulletPointCheck, SkeletonCard } from "@/Components/design-elements";
import { Button } from "@/Components/inputs";
import { SPACING, LAYOUT } from "@/Utils/Theme/constants";
import { useNavigate } from "react-router";
import useMediaQuery from "@mui/material/useMediaQuery";
import { useTheme } from "@mui/material/styles";
import { LAYOUT } from "@/Utils/Theme/constants";
import { EmptyState } from "./EmptyState";
import type { StackProps } from "@mui/material/Stack";
interface BaseFallbackProps extends StackProps {
@@ -15,30 +11,13 @@ interface BaseFallbackProps extends StackProps {
export const BaseFallback = ({ children, ...props }: BaseFallbackProps) => {
const theme = useTheme();
const isSmall = useMediaQuery(theme.breakpoints.down("md"));
return (
<Stack
alignItems={"center"}
margin={isSmall ? "inherit" : "auto"}
marginTop={isSmall ? "33%" : "10%"}
width={{
sm: "90%",
md: "70%",
lg: "50%",
xl: "40%",
}}
padding={{ xs: theme.spacing(LAYOUT.MD), md: theme.spacing(LAYOUT.XXL) }}
bgcolor={theme.palette.background.paper}
border={1}
borderColor={theme.palette.divider}
borderRadius={theme.shape.borderRadius}
sx={{
borderStyle: "dashed",
}}
alignItems="center"
justifyContent="center"
sx={{ width: "100%", py: theme.spacing(LAYOUT.LG) }}
{...props}
>
<SkeletonCard showHalo={true} />
{children}
</Stack>
);
@@ -51,126 +30,56 @@ export const ErrorFallback = ({
title: string;
subtitle: string;
}) => {
const theme = useTheme();
return (
<BaseFallback>
<Typography
variant="h1"
marginY={theme.spacing(LAYOUT.XS)}
color={theme.palette.text.secondary}
>
{title}
</Typography>
<Typography>{subtitle}</Typography>
</BaseFallback>
<EmptyState
fullscreen
title={title}
description={subtitle}
/>
);
};
export const EmptyFallback = ({
title,
bullets,
description,
actionButtonText,
actionLink,
}: {
title: string;
bullets: any;
description?: string;
actionButtonText: string;
actionLink: string;
}) => {
const theme = useTheme();
const navigate = useNavigate();
return (
<BaseFallback>
<Stack
gap={theme.spacing(LAYOUT.MD)}
zIndex={1}
alignItems="center"
>
<Typography component="h1">{title}</Typography>
<Stack
sx={{
flexWrap: "wrap",
gap: theme.spacing(SPACING.MD),
maxWidth: "1100px",
width: "100%",
}}
>
{Array.isArray(bullets) &&
bullets?.map((bullet: string) => (
<BulletPointCheck
text={bullet}
key={`${bullet}-${Math.random()}`}
/>
))}
</Stack>
<Stack>
<Button
variant="contained"
color="primary"
onClick={() => navigate(actionLink)}
>
{actionButtonText}
</Button>
</Stack>
</Stack>
</BaseFallback>
<EmptyState
fullscreen
title={title}
description={description}
actionText={actionButtonText}
actionTo={actionLink}
/>
);
};
export const EmptyMonitorFallback = ({
page,
title,
bullets,
description,
actionButtonText,
actionLink,
}: {
page: string;
title: string;
bullets: any;
description?: string;
actionButtonText: string;
actionLink: string;
}) => {
const theme = useTheme();
const navigate = useNavigate();
return (
<BaseFallback>
<Stack
gap={theme.spacing(LAYOUT.MD)}
zIndex={1}
alignItems="center"
>
<Typography
component="h1"
color={theme.palette.primary.contrastText}
>
{title}
</Typography>
<Stack
sx={{
flexWrap: "wrap",
gap: theme.spacing(SPACING.MD),
maxWidth: "1100px",
width: "100%",
}}
>
{Array.isArray(bullets) &&
bullets?.map((bullet: string, index: number) => (
<BulletPointCheck
text={bullet}
key={`${(page + "Monitors").trim().split(" ")[0]}-${index}`}
/>
))}
</Stack>
<Stack>
<Button
variant="contained"
color="primary"
onClick={() => navigate(actionLink)}
>
{actionButtonText}
</Button>
</Stack>
</Stack>
</BaseFallback>
<EmptyState
fullscreen
title={title}
description={description}
actionText={actionButtonText}
actionTo={actionLink}
/>
);
};
@@ -114,6 +114,7 @@ export const DetailGauge = ({
upperValue,
lowerLabel,
lowerValue,
maxWidth = 225,
}: {
title: string;
progress: number;
@@ -121,13 +122,14 @@ export const DetailGauge = ({
upperValue?: string | number;
lowerLabel?: string;
lowerValue?: string | number;
maxWidth?: number;
}) => {
const theme = useTheme();
return (
<BaseChart
icon={null}
title={title}
maxWidth={225}
maxWidth={maxWidth}
>
<Stack
alignItems={"center"}
@@ -18,7 +18,7 @@ export const MonitorStatus = ({ monitor }: { monitor: Monitor }) => {
return (
<Stack>
<Typography
fontSize={typographyLevels.xl}
fontSize={typographyLevels.xxl}
fontWeight={500}
overflow={"hidden"}
textOverflow={"ellipsis"}
@@ -35,7 +35,7 @@ export const MonitorStatus = ({ monitor }: { monitor: Monitor }) => {
<Typography
fontSize={typographyLevels.l}
fontWeight={"bolder"}
fontFamily={"monospace"}
fontFamily={theme.typography.fontFamilyMonospace}
overflow={"hidden"}
textOverflow={"ellipsis"}
whiteSpace={"nowrap"}
@@ -0,0 +1,55 @@
import Stack from "@mui/material/Stack";
import Box from "@mui/material/Box";
import Typography from "@mui/material/Typography";
import { useTheme, alpha } from "@mui/material/styles";
import { LAYOUT } from "@/Utils/Theme/constants";
import { typographyLevels } from "@/Utils/Theme/Palette";
export const Severities = ["info", "warning", "error"] as const;
export type Severity = (typeof Severities)[number];
const SEVERITY_GLYPH: Record<Severity, string> = {
info: "ⓘ",
warning: "⚠",
error: "✕",
};
interface NoticeBannerProps {
severity?: Severity;
children: React.ReactNode;
}
export const NoticeBanner = ({ severity = "info", children }: NoticeBannerProps) => {
const theme = useTheme();
const tone = theme.palette[severity].main;
return (
<Stack
direction="row"
alignItems="flex-start"
gap={theme.spacing(LAYOUT.SM)}
width={"100%"}
p={theme.spacing(LAYOUT.MD)}
borderRadius={theme.shape.borderRadius}
border={`1px solid ${alpha(tone, 0.45)}`}
bgcolor={alpha(tone, 0.08)}
textAlign={"left"}
>
<Box
component="span"
color={tone}
fontSize={typographyLevels.xl}
lineHeight={1}
mt={LAYOUT.XXS}
aria-hidden
>
{SEVERITY_GLYPH[severity]}
</Box>
<Typography
color={theme.palette.text.primary}
lineHeight={1.55}
>
{children}
</Typography>
</Stack>
);
};
@@ -0,0 +1,164 @@
import Stack from "@mui/material/Stack";
import Typography from "@mui/material/Typography";
import Box from "@mui/material/Box";
import Tooltip from "@mui/material/Tooltip";
import { Link as RouterLink } from "react-router-dom";
import { useTranslation } from "react-i18next";
import { useTheme } from "@mui/material/styles";
import { ChevronLeft, HelpCircle, FileText, Code } from "lucide-react";
import type { ReactNode } from "react";
const EXTERNAL_LINKS = {
support: "https://discord.com/invite/NAb6H3UTjK",
docs: "https://checkmate.so/docs",
changelog: "https://github.com/bluewave-labs/checkmate/releases",
};
interface PageHeaderProps {
title: string;
description?: ReactNode;
backTo?: string;
backLabel?: string;
}
export const PageHeader = ({
title,
description,
backTo,
backLabel,
}: PageHeaderProps) => {
const theme = useTheme();
const { t } = useTranslation();
const linkItems: { key: keyof typeof EXTERNAL_LINKS; icon: ReactNode }[] = [
{
key: "support",
icon: (
<HelpCircle
size={16}
strokeWidth={1.6}
/>
),
},
{
key: "docs",
icon: (
<FileText
size={16}
strokeWidth={1.6}
/>
),
},
{
key: "changelog",
icon: (
<Code
size={16}
strokeWidth={1.6}
/>
),
},
];
return (
<Stack
direction="row"
alignItems="flex-start"
justifyContent="space-between"
gap={6}
sx={{ mb: theme.spacing(8) }}
>
<Stack gap={theme.spacing(1)}>
{backTo && backLabel && (
<Box
component={RouterLink}
to={backTo}
sx={{
display: "inline-flex",
alignItems: "center",
gap: 0.5,
textDecoration: "none",
color: theme.palette.text.secondary,
fontSize: 13,
mb: theme.spacing(1),
"&:hover": { color: theme.palette.text.primary },
}}
>
<ChevronLeft
size={14}
strokeWidth={1.8}
/>
{backLabel}
</Box>
)}
<Typography
sx={{
fontSize: 26,
fontWeight: 400,
color: theme.palette.text.primary,
lineHeight: 1.15,
letterSpacing: "-0.02em",
}}
>
{title}
</Typography>
{description && (
<Typography
sx={{
fontSize: 14,
color: theme.palette.text.secondary,
lineHeight: 1.55,
maxWidth: 640,
}}
>
{description}
</Typography>
)}
</Stack>
<Box
sx={{
display: "inline-flex",
alignItems: "center",
border: `1px solid ${theme.palette.divider}`,
borderRadius: 1,
overflow: "hidden",
backgroundColor: theme.palette.background.paper,
flexShrink: 0,
}}
>
{linkItems.map((item, idx) => (
<Tooltip
key={item.key}
title={t(`components.pageHeader.links.${item.key}`)}
>
<Box
component="a"
href={EXTERNAL_LINKS[item.key]}
target="_blank"
rel="noopener noreferrer"
sx={{
display: "inline-flex",
alignItems: "center",
justifyContent: "center",
width: 36,
height: 32,
color: theme.palette.text.secondary,
borderRight:
idx < linkItems.length - 1
? `1px solid ${theme.palette.divider}`
: "none",
"&:hover": {
backgroundColor: theme.palette.action.hover,
color: theme.palette.text.primary,
},
}}
>
{item.icon}
</Box>
</Tooltip>
))}
</Box>
</Stack>
);
};
@@ -53,7 +53,7 @@ export const ConfigBox = ({
rightContent,
}: {
title: string;
subtitle: string;
subtitle: React.ReactNode;
leftContent?: React.ReactNode;
rightContent: React.ReactNode;
}) => {
@@ -63,9 +63,9 @@ export const ConfigBox = ({
left={
<Stack spacing={theme.spacing(LAYOUT.XS)}>
<Typography
textTransform={"capitalize"}
component="h2"
variant="h2"
variant="eyebrow"
color="text.secondary"
>
{title}
</Typography>
@@ -69,7 +69,12 @@ export const StatBox = ({
>
<Stack onClick={onClick}>
<Box sx={{ display: "flex", alignItems: "center", gap: theme.spacing(2) }}>
<Typography color={textColor}>{title}</Typography>
<Typography
variant="eyebrow"
color={palette ? textColor : "text.secondary"}
>
{title}
</Typography>
{tooltip && (
<TooltipWithInfo
title={tooltip}
@@ -21,7 +21,7 @@ export const BGBox = ({ children, sx }: StatusBoxProps) => {
overflow: "hidden",
position: "relative",
flex: 1,
padding: theme.spacing(4),
padding: theme.spacing(6),
...sx,
}}
>
@@ -65,9 +65,13 @@ const StatusBox = ({
<BGBox sx={sx}>
<Stack spacing={theme.spacing(4)}>
<Typography
variant={"h2"}
textTransform="uppercase"
color={theme.palette.text.secondary}
sx={{
fontSize: 11,
fontWeight: 500,
letterSpacing: "0.08em",
}}
>
{label}
</Typography>
@@ -0,0 +1,40 @@
import { useTranslation } from "react-i18next";
import {
formatStatusCode,
getStatusCodeTooltip,
getStatusCodeValueType,
} from "@/Utils/statusCode";
import { ValueLabel } from "./StatusLabel";
import { Tooltip } from "./Tooltip";
export type StatusCodeLabelProps = {
statusCode: number | null | undefined;
message?: string | null;
fallback?: string;
};
export const StatusCodeLabel = ({
statusCode,
message,
fallback = "N/A",
}: StatusCodeLabelProps) => {
const { t } = useTranslation();
if (!statusCode) {
return <>{fallback}</>;
}
const text = formatStatusCode(statusCode, t);
const tooltip = getStatusCodeTooltip(statusCode, message, t);
const body = (
<ValueLabel
value={getStatusCodeValueType(statusCode)}
text={text}
/>
);
if (!tooltip) return body;
return (
<Tooltip title={tooltip}>
<span>{body}</span>
</Tooltip>
);
};
+39 -45
View File
@@ -18,10 +18,9 @@ import {
ChevronsRight,
ChevronLeft,
ChevronRight,
Coffee,
Ellipsis,
PartyPopper,
} from "lucide-react";
import { EmptyState } from "./EmptyState";
import TablePagination from "@mui/material/TablePagination";
import type { TablePaginationOwnProps } from "@mui/material/TablePagination";
@@ -36,6 +35,9 @@ import useMediaQuery from "@mui/material/useMediaQuery";
export type Header<T> = {
id: number | string;
content: React.ReactNode;
mobileLabel?: React.ReactNode;
hideMobileLabel?: boolean;
align?: "left" | "center" | "right" | "justify" | "inherit";
onClick?: (event: React.MouseEvent<HTMLTableCellElement | null>, row: T) => void;
render: (row: T) => React.ReactNode;
};
@@ -110,6 +112,24 @@ export function DataTable<
key={key}
>
{headers.map((header) => {
if (header.hideMobileLabel) {
return (
<Grid2
container
key={header.id}
>
<Grid2
size={12}
display="flex"
alignItems="center"
justifyContent="flex-end"
>
{header.render(row)}
</Grid2>
</Grid2>
);
}
return (
<Grid2
container
@@ -124,7 +144,7 @@ export function DataTable<
component="div"
color={theme.palette.text.primary}
>
{header.content}
{header.mobileLabel ?? header.content}
</Typography>
</Grid2>
<Grid2
@@ -163,14 +183,19 @@ export function DataTable<
},
"& .MuiTableHead-root .MuiTableRow-root": {
height: "28px",
borderBottom: `1px solid ${theme.palette.divider}`,
},
"& .MuiTableHead-root .MuiTableCell-root": {
borderBottom: "none",
},
"& :is(th)": {
backgroundColor: theme.palette.background.paper,
color: theme.palette.text.secondary,
fontWeight: 500,
textTransform: "uppercase",
letterSpacing: "0.08em",
padding: `${theme.spacing(SPACING.LG)} ${theme.spacing(LAYOUT.MD)}`,
fontSize: theme.typography.fontSize,
fontSize: 11,
},
"& :is(td)": {
backgroundColor: theme.palette.background.paper,
@@ -189,7 +214,7 @@ export function DataTable<
{headers.map((header, idx) => {
return (
<TableCell
align={idx === 0 ? "left" : "center"}
align={header.align ?? (idx === 0 ? "left" : "center")}
key={header.id}
>
{header.content}
@@ -218,7 +243,7 @@ export function DataTable<
{headers.map((header, index) => {
return (
<TableCell
align={index === 0 ? "left" : "center"}
align={header.align ?? (index === 0 ? "left" : "center")}
key={header.id}
onClick={
header.onClick ? (e) => header.onClick!(e, row) : undefined
@@ -548,49 +573,18 @@ export const Pagination = ({ ...props }: PaginationProps) => {
);
};
const EmptyView = ({ text, positive }: { text?: string; positive?: boolean }) => {
const theme = useTheme();
const EmptyView = ({ text }: { text?: string; positive?: boolean }) => {
const { t } = useTranslation();
const Icon = positive ? PartyPopper : Coffee;
const theme = useTheme();
return (
<Stack
alignItems={"center"}
justifyContent={"center"}
<Box
sx={{
py: theme.spacing(LAYOUT.XL),
px: theme.spacing(LAYOUT.XS),
borderWidth: 1,
borderStyle: "solid",
borderColor: theme.palette.divider,
borderRadius: theme.shape.borderRadius,
textAlign: "center",
border: `1px solid ${theme.palette.divider}`,
borderRadius: 1,
backgroundColor: theme.palette.background.paper,
}}
>
<Box
sx={{
width: 64,
height: 64,
borderRadius: "50%",
backgroundColor: theme.palette.action.hover,
display: "flex",
alignItems: "center",
justifyContent: "center",
mb: theme.spacing(SPACING.LG),
}}
>
<Icon
size={28}
strokeWidth={1}
color={theme.palette.text.secondary}
/>
</Box>
<Typography
variant="subtitle1"
color={theme.palette.text.primary}
sx={{ fontWeight: 500 }}
>
{text ?? t("common.table.empty")}
</Typography>
</Stack>
<EmptyState title={text ?? t("common.table.empty")} />
</Box>
);
};
@@ -16,16 +16,15 @@ export const Tooltip = ({ placement = "top", ...props }: StyledTooltipProps) =>
slotProps={{
tooltip: {
sx: {
background:
backgroundColor:
theme.palette.mode === "dark"
? "linear-gradient(135deg, #1a1a2e 0%, #16213e 100%)"
: "linear-gradient(135deg, #2c3e50 0%, #1a252f 100%)",
backgroundColor: "transparent",
color: "#ffffff",
? theme.palette.grey[900]
: theme.palette.grey[800],
color: theme.palette.common.white,
fontSize: "13px",
padding: `${theme.spacing(4)} ${theme.spacing(5)}`,
borderRadius: `${theme.shape.borderRadius}px`,
boxShadow: "0 8px 24px rgba(0, 0, 0, 0.4)",
boxShadow: theme.shadows[6],
},
},
}}
@@ -1,5 +1,8 @@
export * from "./Breadcrumb";
export * from "./PageHeader";
export * from "./BasePage";
export * from "./EmptyState";
export * from "./EmptyStateIllustration";
export * from "./Fallback";
export * from "./SkeletonCard";
export * from "./BulletPointCheck";
@@ -19,6 +22,8 @@ export * from "./Gauge";
export * from "./Tabs";
export * from "./SplitBox";
export * from "./TextLink";
export * from "./NoticeBanner";
export * from "./OfflineBanner";
export * from "./Avatar";
export * from "./StatusCodeLabel";
export * from "./StrategyBadge";
+31 -9
View File
@@ -1,19 +1,43 @@
import Button from "@mui/material/Button";
import type { ButtonProps } from "@mui/material/Button";
import { useTheme } from "@mui/material/styles";
import { useTheme, darken } from "@mui/material/styles";
import { HOVER } from "@/Utils/Theme/constants";
const PALETTE_COLORS = [
"primary",
"secondary",
"error",
"warning",
"info",
"success",
] as const;
type PaletteColor = (typeof PALETTE_COLORS)[number];
export const ButtonInput = ({ sx, ...props }: ButtonProps) => {
const theme = useTheme();
const outlinedSx =
const hoverOverrides: Record<string, unknown> = { boxShadow: "none" };
if (props.variant === "outlined") {
hoverOverrides.borderColor = theme.palette.text.secondary;
}
if (props.variant === "contained") {
const colorKey = (props.color ?? "primary") as PaletteColor;
const palette = theme.palette[colorKey];
if (palette && typeof palette === "object" && "main" in palette) {
hoverOverrides.backgroundColor = darken(palette.main, HOVER.DARKEN);
}
}
const variantSx =
props.variant === "outlined"
? {
color: theme.palette.text.primary,
borderColor: theme.palette.divider,
"&:hover": {
borderColor: theme.palette.text.secondary,
},
}
: {};
return (
<Button
{...props}
@@ -26,10 +50,8 @@ export const ButtonInput = ({ sx, ...props }: ButtonProps) => {
textOverflow: "ellipsis",
overflow: "hidden",
boxShadow: "none",
"&:hover": {
boxShadow: "none",
},
...outlinedSx,
"&:hover": hoverOverrides,
...variantSx,
...sx,
}}
/>
+48 -12
View File
@@ -2,10 +2,12 @@ import Dialog from "@mui/material/Dialog";
import type { DialogProps } from "@mui/material/Dialog";
import DialogActions from "@mui/material/DialogActions";
import DialogContent from "@mui/material/DialogContent";
import DialogContentText from "@mui/material/DialogContentText";
import DialogTitle from "@mui/material/DialogTitle";
import Typography from "@mui/material/Typography";
import Box from "@mui/material/Box";
import { Button } from "@/Components/inputs";
import { typographyLevels } from "@/Utils/Theme/Palette";
import { LAYOUT } from "@/Utils/Theme/constants";
import { useTheme } from "@mui/material/styles";
import { useTranslation } from "react-i18next";
import type { ReactNode } from "react";
@@ -16,7 +18,6 @@ export const DialogInput = ({
onConfirm,
onCancel,
confirmColor = "primary",
cancelColor = "error",
loading = false,
cancelText,
confirmText,
@@ -31,7 +32,6 @@ export const DialogInput = ({
onConfirm?(item: any): any;
onCancel?(item: any): any;
confirmColor?: "error" | "primary";
cancelColor?: "error" | "primary";
loading?: boolean;
cancelText?: string;
confirmText?: string;
@@ -41,22 +41,58 @@ export const DialogInput = ({
additionalButtons?: ReactNode;
}) => {
const { t } = useTranslation();
const theme = useTheme();
return (
<Dialog
open={open}
maxWidth={maxWidth}
fullWidth={fullWidth}
onClose={(_event, reason) => {
if (reason !== "backdropClick" && onCancel) onCancel(undefined);
}}
>
{title && <DialogTitle sx={{ fontSize: typographyLevels.l }}>{title}</DialogTitle>}
<DialogContent>
{content && <DialogContentText>{content}</DialogContentText>}
{children}
</DialogContent>
<DialogActions>
{title && (
<DialogTitle
pb={theme.spacing(LAYOUT.XS)}
bgcolor={theme.palette.action.hover}
borderBottom={`1px solid ${theme.palette.divider}`}
>
<Typography
component="span"
variant="h2"
fontWeight={600}
display={"block"}
>
{title}
</Typography>
{content && (
<Typography
variant="body1"
color={theme.palette.text.secondary}
mt={theme.spacing(LAYOUT.XS)}
display={"block"}
>
{content}
</Typography>
)}
</DialogTitle>
)}
{children && (
<DialogContent sx={{ pt: theme.spacing(LAYOUT.MD) }}>
<Box pt={theme.spacing(LAYOUT.XS)}>{children}</Box>
</DialogContent>
)}
<DialogActions
sx={{
p: theme.spacing(LAYOUT.MD),
pt: theme.spacing(LAYOUT.SM),
backgroundColor: theme.palette.action.hover,
borderTop: `1px solid ${theme.palette.divider}`,
}}
>
<Button
loading={loading}
variant="contained"
color={cancelColor}
variant="outlined"
onClick={onCancel}
>
{cancelText ?? t("common.buttons.cancel")}
+1 -1
View File
@@ -15,9 +15,9 @@ export const FieldLabel = ({ children, required = false, htmlFor }: FieldLabelPr
return (
<Typography
component="label"
variant="body1"
htmlFor={htmlFor}
sx={{
fontSize: "14px",
fontWeight: 500,
color: theme.palette.text.secondary,
marginBottom: theme.spacing(2),
+6 -1
View File
@@ -126,7 +126,12 @@ export const ImageUpload = ({
onChange={(e) => handleFile(e.target.files?.[0])}
style={{ position: "absolute", inset: 0, opacity: 0, cursor: "pointer" }}
/>
<Upload size={24} />
<Upload
size={20}
strokeWidth={1.5}
color="currentColor"
style={{ opacity: 0.7 }}
/>
<Typography
variant="body2"
color="text.secondary"
@@ -1,26 +1,34 @@
import Stack from "@mui/material/Stack";
import Box from "@mui/material/Box";
import MenuItem from "@mui/material/MenuItem";
import { Select } from "@/Components/inputs";
import "flag-icons/css/flag-icons.min.css";
import { useTranslation } from "react-i18next";
import { useTheme } from "@mui/material";
import { useSelector } from "react-redux";
import { useDispatch } from "react-redux";
import { setLanguage } from "@/Features/UI/uiSlice";
import type { RootState } from "@/Types/state";
const langMap: Record<string, string> = {
cs: "cz",
ja: "jp",
uk: "ua",
vi: "vn",
export const languageNames: Record<string, string> = {
en: "English",
de: "Deutsch",
ca: "Català",
es: "Español",
fr: "Français",
"pt-BR": "Português (BR)",
cs: "Čeština",
fi: "Suomi",
tr: "Türkçe",
ru: "Русский",
uk: "Українська",
ar: "العربية",
th: "ไทย",
vi: "Tiếng Việt",
ja: "日本語",
"zh-CN": "简体中文",
"zh-TW": "繁體中文",
};
export const LanguageSelector = () => {
const { i18n } = useTranslation();
const theme = useTheme();
const { language = "en" } = useSelector((state: RootState) => state.ui);
const dispatch = useDispatch();
const handleChange = (event: any) => {
@@ -35,64 +43,15 @@ export const LanguageSelector = () => {
value={language}
onChange={handleChange}
size="small"
// sx={{
// minWidth: 80,
// "& .MuiSelect-select": {
// display: "flex",
// alignItems: "center",
// justifyContent: "center",
// },
// "& .MuiSelect-icon": {
// alignSelf: "center",
// },
// }}
>
{languages.map((lang) => {
let parsedLang = lang === "en" ? "gb" : lang;
if (parsedLang.includes("-")) {
parsedLang = parsedLang.split("-")[1].toLowerCase();
}
parsedLang = langMap[parsedLang] || parsedLang;
const flag = parsedLang ? `fi fi-${parsedLang}` : null;
return (
<MenuItem
key={lang}
value={lang}
sx={{
display: "flex",
justifyContent: "center",
alignItems: "center",
}}
>
<Stack
direction="row"
spacing={theme.spacing(2)}
alignItems="center"
justifyContent="center"
>
<Box
component="span"
sx={{
display: "flex",
alignItems: "center",
}}
>
{flag && <span className={flag} />}
</Box>
<Box
component="span"
sx={{ textTransform: "uppercase" }}
>
{lang}
</Box>
</Stack>
</MenuItem>
);
})}
{languages.map((lang) => (
<MenuItem
key={lang}
value={lang}
>
{languageNames[lang] ?? lang}
</MenuItem>
))}
</Select>
);
};
+1 -41
View File
@@ -1,52 +1,13 @@
import Radio from "@mui/material/Radio";
import type { RadioProps } from "@mui/material/Radio";
import { useTheme } from "@mui/material/styles";
import { Circle, CircleDot } from "lucide-react";
import FormControlLabel from "@mui/material/FormControlLabel";
import Typography from "@mui/material/Typography";
interface RadioInputProps extends RadioProps {}
export const RadioInput = ({ ...props }: RadioInputProps) => {
const theme = useTheme();
return (
<Radio
{...props}
icon={
<Circle
size={16}
strokeWidth={1.5}
/>
}
checkedIcon={
<CircleDot
size={14}
strokeWidth={1.5}
/>
}
sx={{
padding: 0,
mt: theme.spacing(0.5),
color: theme.palette.text.secondary,
"&.Mui-checked": {
color: theme.palette.primary.main,
"& svg circle": {
fill: theme.palette.primary.main,
},
},
"& .MuiSvgIcon-root": {
fontSize: 16,
},
"& svg": {
stroke: "currentColor",
},
"& svg path, & svg line, & svg polyline, & svg rect, & svg circle": {
stroke: "currentColor",
fill: "none",
},
}}
/>
);
return <Radio {...props} />;
};
export const RadioWithDescription = ({
@@ -79,7 +40,6 @@ export const RadioWithDescription = ({
backgroundColor: theme.palette.background.paper,
},
"& .MuiButtonBase-root": {
p: 0,
mr: theme.spacing(6),
},
}}
+2 -3
View File
@@ -11,15 +11,14 @@ interface SelectInputProps<T> extends Omit<SelectProps<T>, "label"> {
fieldLabel?: string;
required?: boolean;
placeholder?: string;
placeholderColor?: string;
}
const SelectInputInner = <T,>(
{ fieldLabel, required, placeholder, placeholderColor, ...props }: SelectInputProps<T>,
{ fieldLabel, required, placeholder, ...props }: SelectInputProps<T>,
ref: React.ForwardedRef<HTMLDivElement>
) => {
const theme = useTheme();
const emptyPlaceholderColor = placeholderColor || theme.palette.text.disabled;
const emptyPlaceholderColor = theme.palette.text.secondary;
const renderValue = (selected: unknown) => {
const isMultiple = Boolean((props as { multiple?: boolean }).multiple);
+9 -17
View File
@@ -21,24 +21,16 @@ export const SwitchComponent = forwardRef<HTMLInputElement, SwitchComponentProps
},
}}
sx={[
{
"& .MuiSwitch-switchBase": {
"&.Mui-checked": {
color: "#E0E0E0",
"& + .MuiSwitch-track": {
backgroundColor: theme.palette.primary.main,
opacity: 1,
border: 0,
...(dualOption
? [
{
"& .MuiSwitch-track": {
backgroundColor: theme.palette.primary.main,
opacity: 1,
},
},
},
},
...(dualOption && {
"& .MuiSwitch-track": {
backgroundColor: theme.palette.primary.main,
opacity: 1,
},
}),
},
]
: []),
...additionalSx,
]}
/>
+35 -20
View File
@@ -5,6 +5,7 @@ import { typographyLevels } from "@/Utils/Theme/Palette";
import { useTheme } from "@mui/material/styles";
import Stack from "@mui/material/Stack";
import { FieldLabel } from "./FieldLabel";
import { LAYOUT } from "@/Utils/Theme/constants";
interface TextInputProps extends Omit<TextFieldProps, "label"> {
fieldLabel?: string;
@@ -12,39 +13,53 @@ interface TextInputProps extends Omit<TextFieldProps, "label"> {
}
export const TextInput = forwardRef<HTMLInputElement, TextInputProps>(function TextInput(
{ fieldLabel, required, ...props },
{ fieldLabel, required, sx, ...props },
ref
) {
const theme = useTheme();
const innerSx = {
width: "100%",
"& .MuiOutlinedInput-root": {
borderRadius: theme.shape.borderRadius,
height: 34,
fontSize: typographyLevels.base,
overflow: "hidden",
},
"& .MuiOutlinedInput-notchedOutline": {
borderColor: theme.palette.divider,
},
"&:hover .MuiOutlinedInput-notchedOutline": {
borderColor: theme.palette.divider,
},
"& .MuiFormHelperText-root": {
marginLeft: 0,
marginRight: 0,
marginTop: theme.spacing(1),
},
"& input:-webkit-autofill, & input:-webkit-autofill:hover, & input:-webkit-autofill:focus, & input:-webkit-autofill:active":
{
WebkitBoxShadow: `0 0 0 100px ${theme.palette.background.default} inset !important`,
WebkitTextFillColor: `${theme.palette.text.primary} !important`,
caretColor: theme.palette.text.primary,
transition: "background-color 5000s ease-in-out 0s",
},
};
const input = (
<TextField
{...props}
inputRef={ref}
sx={{
"& .MuiOutlinedInput-root": {
borderRadius: theme.shape.borderRadius,
height: 34,
fontSize: typographyLevels.base,
},
"& .MuiOutlinedInput-notchedOutline": {
borderColor: theme.palette.divider,
},
"&:hover .MuiOutlinedInput-notchedOutline": {
borderColor: theme.palette.divider,
},
"& .MuiFormHelperText-root": {
marginLeft: 0,
marginRight: 0,
marginTop: theme.spacing(1),
},
}}
sx={fieldLabel ? innerSx : { ...innerSx, ...sx }}
/>
);
if (fieldLabel) {
return (
<Stack spacing={theme.spacing(2)}>
<Stack
spacing={theme.spacing(LAYOUT.XXS)}
sx={sx}
>
<FieldLabel required={required}>{fieldLabel}</FieldLabel>
{input}
</Stack>
+19 -1
View File
@@ -3,8 +3,17 @@ import ToggleButtonGroup from "@mui/material/ToggleButtonGroup";
import type { ToggleButtonProps } from "@mui/material/ToggleButton";
import type { ToggleButtonGroupProps } from "@mui/material/ToggleButtonGroup";
import { useTheme } from "@mui/material/styles";
import { alpha } from "@mui/material/styles";
export const ToggleButtonInput = ({ sx, ...props }: ToggleButtonProps) => {
const theme = useTheme();
const isDark = theme.palette.mode === "dark";
const selectedBg = isDark
? alpha(theme.palette.primary.main, 0.22)
: alpha(theme.palette.primary.main, 0.06);
const selectedHoverBg = isDark
? alpha(theme.palette.primary.main, 0.3)
: alpha(theme.palette.primary.main, 0.1);
return (
<ToggleButton
{...props}
@@ -17,8 +26,18 @@ export const ToggleButtonInput = ({ sx, ...props }: ToggleButtonProps) => {
textOverflow: "ellipsis",
overflow: "hidden",
boxShadow: "none",
color: theme.palette.text.secondary,
"&:hover": {
boxShadow: "none",
backgroundColor: theme.palette.action.hover,
},
"&.Mui-selected": {
backgroundColor: selectedBg,
color: theme.palette.text.primary,
fontWeight: 500,
"&:hover": {
backgroundColor: selectedHoverBg,
},
},
...sx,
}}
@@ -34,7 +53,6 @@ export const ToggleButtonGroupInput = ({ sx, ...props }: ToggleButtonGroupProps)
sx={{
"& .MuiToggleButtonGroup-grouped": {
borderColor: theme.palette.divider,
borderRadius: 0,
"&:first-of-type": {
borderTopLeftRadius: 2,
@@ -0,0 +1,102 @@
import React from "react";
import Box from "@mui/material/Box";
import Stack from "@mui/material/Stack";
import Typography from "@mui/material/Typography";
import Paper from "@mui/material/Paper";
import Slide from "@mui/material/Slide";
import IconButton from "@mui/material/IconButton";
import { X } from "lucide-react";
import { useTranslation } from "react-i18next";
import { useTheme } from "@mui/material/styles";
import useMediaQuery from "@mui/material/useMediaQuery";
import { LAYOUT } from "@/Utils/Theme/constants";
import { useSidebar } from "@/Hooks/useSidebar";
interface BulkActionsBarProps {
selectedCount: number;
onCancel: () => void;
children?: React.ReactNode;
}
export const BulkActionsBar = ({
selectedCount,
onCancel,
children,
}: BulkActionsBarProps) => {
const { t } = useTranslation();
const theme = useTheme();
const isOpen = selectedCount > 0;
const isSmall = useMediaQuery(theme.breakpoints.down("md"));
const { width, collapsedWidth, transition } = useSidebar();
return (
<Box
sx={{
position: "fixed",
bottom: theme.spacing(LAYOUT.XS),
left: isSmall ? collapsedWidth : width,
right: 0,
display: "flex",
justifyContent: "center",
pointerEvents: "none",
zIndex: theme.zIndex.snackbar,
transition: transition.replace("width", "left"),
}}
>
<Slide
direction="up"
in={isOpen}
mountOnEnter
unmountOnExit
>
<Paper
elevation={6}
sx={{
pointerEvents: "auto",
px: LAYOUT.MD,
py: LAYOUT.XS,
borderRadius: theme.shape.borderRadius,
backgroundColor: theme.palette.background.paper,
border: `1px solid ${theme.palette.divider}`,
display: "flex",
alignItems: "center",
gap: LAYOUT.XS,
boxShadow: theme.shadows[8],
}}
>
<Stack
direction="row"
alignItems="center"
gap={LAYOUT.XXS}
>
<Typography
variant="body1"
fontWeight={600}
color={theme.palette.text.primary}
>
{t("pages.common.monitors.actions.bulkSelected", {
count: selectedCount,
})}
</Typography>
<IconButton
size="small"
onClick={onCancel}
aria-label={t("common.buttons.cancel")}
>
<X size={18} />
</IconButton>
</Stack>
<Stack
direction="row"
alignItems="center"
gap={LAYOUT.XXS}
>
{children}
</Stack>
</Paper>
</Slide>
</Box>
);
};
@@ -3,6 +3,8 @@ import { Box, Typography, Stack, useTheme, GlobalStyles } from "@mui/material";
import Map, { Marker, Popup } from "react-map-gl/maplibre";
import type { MapRef } from "react-map-gl/maplibre";
import type { FlatGeoCheck } from "@/Types/GeoCheck";
import { useTranslation } from "react-i18next";
import { formatStatusCode } from "@/Utils/statusCode";
import "maplibre-gl/dist/maplibre-gl.css";
interface GeoChecksMapProps {
@@ -14,6 +16,7 @@ export const GeoChecksMap = ({ geoChecks }: GeoChecksMapProps) => {
const [selectedCheck, setSelectedCheck] = useState<FlatGeoCheck | null>(null);
const theme = useTheme();
const isDarkMode = theme.palette.mode === "dark";
const { t } = useTranslation();
const mapPopupStyles = (
<GlobalStyles
@@ -174,7 +177,7 @@ export const GeoChecksMap = ({ geoChecks }: GeoChecksMapProps) => {
>
Status Code:
</Typography>{" "}
{selectedCheck.statusCode}
{formatStatusCode(selectedCheck.statusCode, t)}
</Typography>
<Typography variant="body2">
<Typography
@@ -25,10 +25,7 @@ export const MonitorStatBoxes = ({
}
const timeOfLastFailure = monitorStats?.timeOfLastFailure || 0;
const timeSinceLastFailure =
timeOfLastFailure > 0
? Date.now() - timeOfLastFailure
: Date.now() - new Date(monitorStats?.createdAt || 0).getTime();
const timeSinceLastFailure = timeOfLastFailure > 0 ? Date.now() - timeOfLastFailure : 0;
// Determine time since last check
const timeOfLastCheck = monitorStats?.lastCheckTimestamp || 0;
@@ -42,6 +39,12 @@ export const MonitorStatBoxes = ({
const streakTime = prettyMilliseconds(timeSinceLastFailure, options);
const lastCheckTime = prettyMilliseconds(timeSinceLastCheck, options);
const isActive =
monitor?.status === "up" ||
monitor?.status === "paused" ||
monitor?.status === "maintenance" ||
monitor?.status === "initializing" ||
monitor?.status === "breached";
const palette = getStatusPalette(monitor?.status);
return (
@@ -51,8 +54,12 @@ export const MonitorStatBoxes = ({
>
<StatBox
palette={palette}
title={t("pages.common.monitors.statBoxes.activeFor")}
subtitle={streakTime}
title={
isActive
? t("pages.common.monitors.statBoxes.activeFor")
: t("pages.common.monitors.statBoxes.serviceIsDown")
}
subtitle={isActive ? streakTime : ""}
/>
<StatBox
title={t("pages.common.monitors.statBoxes.lastCheck")}
@@ -52,7 +52,7 @@ export const RadialAvgResponse = ({ avg, max }: { avg: number; max: number }) =>
>
<RadialBar
dataKey="value"
background={{ fill: theme.palette[palette].light }}
background={{ fill: theme.palette.action.disabledBackground }}
>
<Cell visibility={"hidden"} />
<Cell fill={theme.palette[palette].main} />
@@ -75,13 +75,14 @@ export const RadialAvgResponse = ({ avg, max }: { avg: number; max: number }) =>
}}
>
<Typography
variant="h6"
variant="eyebrow"
color={theme.palette[palette].main}
textAlign={"center"}
>
{msg[palette]}
</Typography>
<Typography
variant="h6"
variant="h1"
textAlign={"center"}
>{`${avg?.toFixed()}ms`}</Typography>
</Stack>
+1
View File
@@ -14,3 +14,4 @@ export * from "./charts/HistogramPageSpeedDetails";
export * from "./charts/HistogramPageSpeedDetailsTooltip";
export * from "./charts/HistogramInfrastructure";
export * from "./HeaderMonitorsSummary";
export * from "./BulkActionsBar";
+6 -1
View File
@@ -68,7 +68,12 @@ export const AuthFooter = ({ collapsed, accountMenuItems }: AuthFooterProps) =>
gap: theme.spacing(LAYOUT.MD),
borderRadius: theme.shape.borderRadius,
pl: theme.spacing(LAYOUT.XS),
"& svg": { stroke: theme.palette.text.secondary },
"& svg": {
height: 16,
width: 16,
opacity: 0.81,
stroke: theme.palette.text.secondary,
},
};
return (
+15 -12
View File
@@ -6,6 +6,7 @@ import { toggleSidebar } from "@/Features/UI/uiSlice.js";
import { useDispatch } from "react-redux";
import { useTranslation } from "react-i18next";
import useSidebar from "@/Hooks/useSidebar";
import KingIcon from "@/assets/icons/checkmate-icon.svg?react";
export const Logo = (props: StackProps) => {
const { t } = useTranslation();
@@ -23,19 +24,21 @@ export const Logo = (props: StackProps) => {
sx={{ cursor: "pointer" }}
{...props}
>
<Typography
minWidth={theme.spacing(16)}
minHeight={theme.spacing(16)}
display={"flex"}
justifyContent={"center"}
alignItems={"center"}
bgcolor={theme.palette.primary.main}
borderRadius={theme.shape.borderRadius}
color={theme.palette.primary.contrastText}
fontSize={18}
<Box
sx={{
width: theme.spacing(16),
height: theme.spacing(16),
display: "flex",
justifyContent: "center",
alignItems: "center",
"& svg": {
width: "100%",
height: "100%",
},
}}
>
C
</Typography>
<KingIcon />
</Box>
<Box
overflow={"hidden"}
sx={{
+2 -2
View File
@@ -9,7 +9,7 @@ import {
AlertTriangle,
Wifi,
Wrench,
Database,
ScrollText,
Settings,
HelpCircle,
MessageCircle,
@@ -64,7 +64,7 @@ export const getMenu = (t: Function) => {
{
name: t("components.sidebar.menu.logs"),
path: "logs",
icon: <Icon icon={Database} />,
icon: <Icon icon={ScrollText} />,
},
{
name: t("components.sidebar.menu.settings"),
+4 -2
View File
@@ -22,7 +22,9 @@ export const NavItem = ({
}) => {
const { collapsed } = useSidebar();
const theme = useTheme();
const iconStroke = selected ? theme.palette.primary.main : theme.palette.text.secondary;
const iconStroke = selected
? theme.palette.sidebar.accent
: theme.palette.text.secondary;
const buttonBgColor = selected ? theme.palette.action.selected : "transparent";
const buttonBgHoverColor = selected
@@ -79,7 +81,7 @@ export const NavItem = ({
},
".MuiListItemButton-root:hover &": {
"& svg path, & svg line, & svg polyline, & svg rect, & svg circle": {
stroke: theme.palette.primary.main,
stroke: theme.palette.sidebar.accent,
},
},
}}
+2 -20
View File
@@ -6,7 +6,7 @@ import Divider from "@mui/material/Divider";
import { LAYOUT } from "@/Utils/Theme/constants";
import { useSidebar } from "@/Hooks/useSidebar.js";
import { Logo } from "@/Components/sidebar/Logo";
import { getMenu, getBottomMenu, getAccountMenu } from "@/Components/sidebar/Menu";
import { getMenu, getAccountMenu } from "@/Components/sidebar/Menu";
import { NavItem } from "@/Components/sidebar/NavItem";
import { StarPrompt } from "@/Components/sidebar/StarPrompt";
import { AuthFooter } from "@/Components/sidebar/Authfooter";
@@ -20,7 +20,7 @@ import { setCollapsed } from "@/Features/UI/uiSlice";
const URL_MAP: Record<string, string> = {
support: "https://discord.com/invite/NAb6H3UTjK",
discussions: "https://github.com/bluewave-labs/checkmate/discussions",
docs: "https://bluewavelabs.gitbook.io/checkmate",
docs: "https://checkmate.so/docs",
changelog: "https://github.com/bluewave-labs/checkmate/releases",
};
@@ -33,7 +33,6 @@ export const Sidebar = () => {
const theme = useTheme();
const isSmall = useMediaQuery(theme.breakpoints.down("md"));
const menu = getMenu(t);
const bottomMenu = getBottomMenu(t);
const accountMenu = getAccountMenu(t);
useEffect(() => {
@@ -103,23 +102,6 @@ export const Sidebar = () => {
})}
</List>
<StarPrompt />
<List
component="nav"
disablePadding
sx={{ px: theme.spacing(LAYOUT.SM) }}
>
{bottomMenu.map((item) => {
const selected = location.pathname.startsWith(`/${item.path}`);
return (
<NavItem
key={item.path}
item={item}
selected={selected}
onClick={() => handleNavClick(item.path)}
/>
);
})}
</List>
<Divider sx={{ borderColor: theme.palette.divider }} />
<AuthFooter
+3 -1
View File
@@ -89,7 +89,9 @@ const uiSlice = createSlice({
},
setTimezone: (state, action: PayloadAction<{ timezone: string }>) => {
state.timezone = action.payload.timezone;
if (action.payload.timezone) {
state.timezone = action.payload.timezone;
}
},
setLanguage: (state, action: PayloadAction<string>) => {
state.language = action.payload;
+5 -1
View File
@@ -57,7 +57,11 @@ export const usePost = <B = any, R = any>() => {
...config?.headers,
},
});
toastSuccess(res.data?.msg || "Operation successful");
if (res.data?.success === false) {
toastError(res.data?.msg || "Operation failed");
} else {
toastSuccess(res.data?.msg || "Operation successful");
}
return res.data;
} catch (err: any) {
+3 -2
View File
@@ -4,8 +4,9 @@ export const useToast = () => {
const showToast = (message: string, options?: ToastOptions) => {
toast.dismiss();
const baseStyle: React.CSSProperties = {
whiteSpace: "pre-line",
wordBreak: "break-word",
whiteSpace: "nowrap",
overflow: "hidden",
textOverflow: "ellipsis",
};
toast(message, {
...options,
+94
View File
@@ -0,0 +1,94 @@
import { useState, useEffect } from "react";
import axios from "axios";
import { post } from "@/Utils/ApiClient";
import { useToast } from "@/Hooks/UseToast";
import { useTranslation } from "react-i18next";
import { logger } from "@/Utils/logger";
import type { Monitor } from "@/Types/Monitor";
interface ApiResponse {
success: boolean;
msg: string;
data: Monitor[];
}
interface UseBulkMonitorActionsReturn {
selectedRows: string[];
setSelectedRows: (rows: string[]) => void;
handleBulkPause: () => Promise<void>;
handleBulkResume: () => Promise<void>;
handleCancelSelection: () => void;
}
export const useBulkMonitorActions = (
refetch: () => void,
page?: number
): UseBulkMonitorActionsReturn => {
const [selectedRows, setSelectedRows] = useState<string[]>([]);
const { toastSuccess, toastError, toastInfo } = useToast();
const { t } = useTranslation();
// Clear selection when page changes
useEffect(() => {
setSelectedRows([]);
}, [page]);
const executeBulkAction = async (pause: boolean) => {
try {
const res = await post<ApiResponse>("/monitors/bulk/pause", {
monitorIds: selectedRows,
pause,
});
const affectedCount = res.data?.data?.length ?? 0;
if (affectedCount === 0) {
const key = pause
? "pages.common.monitors.bulkPause.alreadyPaused"
: "pages.common.monitors.bulkPause.alreadyRunning";
toastInfo(t(key, { count: selectedRows.length }));
} else {
const key = pause
? "pages.common.monitors.bulkPause.paused"
: "pages.common.monitors.bulkPause.resumed";
toastSuccess(t(key, { count: affectedCount }));
}
setSelectedRows([]);
refetch();
} catch (err: unknown) {
let errMsg = "An error occurred";
if (axios.isAxiosError(err)) {
errMsg = err.response?.data?.msg || err.message || errMsg;
} else if (err instanceof Error) {
errMsg = err.message;
}
logger.error("Bulk pause/resume failed", err instanceof Error ? err : undefined, {
pause,
});
toastError(errMsg);
}
};
const handleBulkPause = async () => {
await executeBulkAction(true);
};
const handleBulkResume = async () => {
await executeBulkAction(false);
};
const handleCancelSelection = () => {
setSelectedRows([]);
};
return {
selectedRows,
setSelectedRows,
handleBulkPause,
handleBulkResume,
handleCancelSelection,
};
};
+37
View File
@@ -0,0 +1,37 @@
import { useState } from "react";
export const useClientPagination = <T>(rows: T[], initialRowsPerPage = 10) => {
const [page, setPage] = useState(0);
const [rowsPerPage, setRowsPerPage] = useState(initialRowsPerPage);
const handlePageChange = (
_e: React.MouseEvent<HTMLButtonElement> | null,
newPage: number
) => {
setPage(newPage);
};
const handleRowsPerPageChange = (
e: React.ChangeEvent<HTMLTextAreaElement | HTMLInputElement>
) => {
setPage(0);
setRowsPerPage(Number(e.target.value));
};
const pagedRows = rows.slice(page * rowsPerPage, page * rowsPerPage + rowsPerPage);
return {
page,
rowsPerPage,
pagedRows,
paginationProps: {
component: "div" as const,
count: rows.length,
page,
rowsPerPage,
onPageChange: handlePageChange,
onRowsPerPageChange: handleRowsPerPageChange,
itemsOnPage: pagedRows.length,
},
};
};
+9
View File
@@ -113,6 +113,15 @@ export const useMonitorForm = ({
ignoreTlsErrors: data?.ignoreTlsErrors || false,
};
break;
case "dns":
defaults = {
...base,
type: "dns",
url: data?.url || "",
dnsServer: data?.dnsServer || "",
dnsRecordType: data?.dnsRecordType || "A",
};
break;
default:
defaults = {
...base,
+18
View File
@@ -60,6 +60,24 @@ function buildDefaults(data: Notification | null): NotificationFormData {
address: data.address || "",
};
}
if (data?.type === "twilio") {
return {
type: "twilio",
notificationName: data.notificationName || "",
accountSid: data.accountSid || "",
accessToken: data.accessToken || "",
phone: data.phone || "",
twilioPhoneNumber: data.twilioPhoneNumber || "",
};
}
if (data?.type === "pushover") {
return {
type: "pushover",
notificationName: data.notificationName || "",
address: data.address || "",
accessToken: data.accessToken || "",
};
}
// Default: email (covers both data === null and data.type === "email")
return {
type: "email",
+1
View File
@@ -18,6 +18,7 @@ export const useSettingsForm = ({ data = null }: UseSettingsFormOptions = {}) =>
systemEmailHost: data?.systemEmailHost || "",
systemEmailUser: data?.systemEmailUser || "",
systemEmailAddress: data?.systemEmailAddress || "",
systemEmailDisplayName: data?.systemEmailDisplayName || "",
systemEmailConnectionHost: data?.systemEmailConnectionHost || "localhost",
systemEmailTLSServername: data?.systemEmailTLSServername || "",
systemEmailPort: data?.systemEmailPort,
+9 -2
View File
@@ -1,7 +1,12 @@
import { useMemo } from "react";
import { statusPageSchema, type StatusPageFormData } from "@/Validation/statusPage";
import type { StatusPage } from "@/Types/StatusPage";
import {
DEFAULT_STATUS_PAGE_THEME,
DEFAULT_STATUS_PAGE_THEME_MODE,
} from "@/Types/StatusPage";
import type { Monitor } from "@/Types/Monitor";
import { resolveTimezone } from "@/Utils/TimeUtils";
interface UseStatusPageFormOptions {
data?: StatusPage | null;
@@ -26,9 +31,9 @@ export const useStatusPageForm = ({
const defaults: StatusPageFormData = {
companyName: data?.companyName || "",
url: data?.url || generateDefaultUrl(),
timezone: data?.timezone || Intl.DateTimeFormat().resolvedOptions().timeZone,
timezone: resolveTimezone(data?.timezone),
type: data?.type || ["uptime"],
color: data?.color || "#4169E1",
color: data?.color || "#13715B",
monitors: data?.monitors || [],
isPublished: data?.isPublished ?? false,
showCharts: data?.showCharts ?? true,
@@ -36,6 +41,8 @@ export const useStatusPageForm = ({
showAdminLoginLink: data?.showAdminLoginLink ?? false,
showInfrastructure: data?.showInfrastructure ?? false,
customCSS: data?.customCSS || "",
theme: data?.theme ?? DEFAULT_STATUS_PAGE_THEME,
themeMode: data?.themeMode ?? DEFAULT_STATUS_PAGE_THEME_MODE,
logo: transformLogo(data?.logo),
};
+15 -1
View File
@@ -1,15 +1,18 @@
import { Stack } from "@mui/material";
import { useTheme } from "@mui/material";
import { useState, useMemo } from "react";
import { useTranslation } from "react-i18next";
import { HeaderTeamControls } from "./components/HeaderTeamControls";
import { TeamTable } from "./components/TeamTable";
import { InviteTeamMemberDialog } from "./components/InviteTeamMemberDialog";
import { AddTeamMemberDialog } from "./components/AddTeamMemberDialog";
import { EmptyState } from "@/Components/design-elements";
import { useGet } from "@/Hooks/UseApi";
import type { User, UserRole } from "@/Types/User";
export const TabTeam = () => {
const theme = useTheme();
const { t } = useTranslation();
const [filter, setFilter] = useState<UserRole | "">("");
const [inviteDialogOpen, setInviteDialogOpen] = useState(false);
const [addMemberDialogOpen, setAddMemberDialogOpen] = useState(false);
@@ -33,6 +36,9 @@ export const TabTeam = () => {
refetch();
};
const totalUsers = users?.length ?? 0;
const noUsers = users !== undefined && totalUsers === 0;
return (
<Stack gap={theme.spacing(8)}>
<HeaderTeamControls
@@ -41,7 +47,15 @@ export const TabTeam = () => {
onInviteClick={handleOpenInviteDialog}
onAddMemberClick={handleOpenAddMemberDialog}
/>
<TeamTable users={filteredUsers} />
{noUsers ? (
<EmptyState
fullscreen
title={t("pages.account.team.fallback.title")}
description={t("pages.account.team.fallback.description")}
/>
) : (
<TeamTable users={filteredUsers} />
)}
<InviteTeamMemberDialog
open={inviteDialogOpen}
onClose={handleCloseInviteDialog}
@@ -9,6 +9,7 @@ import { useAddTeamMemberForm } from "@/Hooks/useAddTeamMemberForm";
import type { AddTeamMemberFormData } from "@/Validation/addTeamMember";
import type { UserRole, User } from "@/Types/User";
import { usePost } from "@/Hooks/UseApi";
import { LAYOUT } from "@/Utils/Theme/constants";
interface AddTeamMemberDialogProps {
open: boolean;
@@ -43,6 +44,7 @@ export const AddTeamMemberDialog = ({
const roleOptions: { value: UserRole; label: string }[] = [
{ value: "admin", label: t("common.auth.roles.admin") },
{ value: "user", label: t("common.auth.roles.user") },
{ value: "demo", label: t("common.auth.roles.demo") },
];
const handleClose = () => {
@@ -81,38 +83,40 @@ export const AddTeamMemberDialog = ({
maxWidth="sm"
fullWidth
>
<Stack
gap={theme.spacing(4)}
mt={theme.spacing(4)}
>
<Controller
name="firstName"
control={control}
render={({ field, fieldState }) => (
<TextField
{...field}
fieldLabel={t("common.form.name.option.firstName.label")}
placeholder={t("common.form.name.option.firstName.placeholder")}
error={!!fieldState.error}
helperText={fieldState.error?.message ?? ""}
fullWidth
/>
)}
/>
<Controller
name="lastName"
control={control}
render={({ field, fieldState }) => (
<TextField
{...field}
fieldLabel={t("common.form.name.option.lastName.label")}
placeholder={t("common.form.name.option.lastName.placeholder")}
error={!!fieldState.error}
helperText={fieldState.error?.message ?? ""}
fullWidth
/>
)}
/>
<Stack gap={theme.spacing(LAYOUT.XS)}>
<Stack
direction="row"
gap={theme.spacing(LAYOUT.XS)}
>
<Controller
name="firstName"
control={control}
render={({ field, fieldState }) => (
<TextField
{...field}
fieldLabel={t("common.form.name.option.firstName.label")}
placeholder={t("common.form.name.option.firstName.placeholder")}
error={!!fieldState.error}
helperText={fieldState.error?.message ?? ""}
sx={{ flex: 1 }}
/>
)}
/>
<Controller
name="lastName"
control={control}
render={({ field, fieldState }) => (
<TextField
{...field}
fieldLabel={t("common.form.name.option.lastName.label")}
placeholder={t("common.form.name.option.lastName.placeholder")}
error={!!fieldState.error}
helperText={fieldState.error?.message ?? ""}
sx={{ flex: 1 }}
/>
)}
/>
</Stack>
<Controller
name="email"
control={control}
@@ -150,36 +154,41 @@ export const AddTeamMemberDialog = ({
</Select>
)}
/>
<Controller
name="password"
control={control}
render={({ field, fieldState }) => (
<TextField
{...field}
fieldLabel={t("pages.auth.common.form.option.password.label")}
placeholder={t("pages.auth.common.form.option.password.placeholder")}
type="password"
error={!!fieldState.error}
helperText={fieldState.error?.message ?? ""}
fullWidth
/>
)}
/>
<Controller
name="confirm"
control={control}
render={({ field, fieldState }) => (
<TextField
{...field}
fieldLabel={t("pages.auth.common.form.option.confirmPassword.label")}
placeholder={t("pages.auth.common.form.option.password.placeholder")}
type="password"
error={!!fieldState.error}
helperText={fieldState.error?.message ?? ""}
fullWidth
/>
)}
/>
<Stack
direction="row"
gap={theme.spacing(LAYOUT.XS)}
>
<Controller
name="password"
control={control}
render={({ field, fieldState }) => (
<TextField
{...field}
fieldLabel={t("pages.auth.common.form.option.password.label")}
placeholder={t("pages.auth.common.form.option.password.placeholder")}
type="password"
error={!!fieldState.error}
helperText={fieldState.error?.message ?? ""}
sx={{ flex: 1 }}
/>
)}
/>
<Controller
name="confirm"
control={control}
render={({ field, fieldState }) => (
<TextField
{...field}
fieldLabel={t("pages.auth.common.form.option.confirmPassword.label")}
placeholder={t("pages.auth.common.form.option.password.placeholder")}
type="password"
error={!!fieldState.error}
helperText={fieldState.error?.message ?? ""}
sx={{ flex: 1 }}
/>
)}
/>
</Stack>
</Stack>
</Dialog>
);
@@ -9,6 +9,7 @@ import type { UserRole } from "@/Types/User";
import { useInviteForm } from "@/Hooks/useInviteForm";
import type { InviteFormData } from "@/Validation/invite";
import { usePost } from "@/Hooks/UseApi";
import { LAYOUT } from "@/Utils/Theme/constants";
const CLIENT_HOST = import.meta.env.VITE_APP_CLIENT_HOST;
@@ -48,6 +49,7 @@ export const InviteTeamMemberDialog = ({
const roleOptions: { value: UserRole; label: string }[] = [
{ value: "admin", label: t("common.auth.roles.admin") },
{ value: "user", label: t("common.auth.roles.user") },
{ value: "demo", label: t("common.auth.roles.demo") },
];
const handleGenerateToken = async (data: InviteFormData) => {
@@ -94,10 +96,7 @@ export const InviteTeamMemberDialog = ({
</Button>
}
>
<Stack
gap={theme.spacing(4)}
mt={theme.spacing(4)}
>
<Stack gap={theme.spacing(LAYOUT.XS)}>
<Controller
name="email"
control={control}
@@ -2,8 +2,10 @@ import Typography from "@mui/material/Typography";
import { useNavigate } from "react-router-dom";
import { useTranslation } from "react-i18next";
import { Table } from "@/Components/design-elements";
import { Pagination } from "@/Components/design-elements/Table";
import type { Header } from "@/Components/design-elements/Table";
import { useIsAdmin } from "@/Hooks/useIsAdmin";
import { useClientPagination } from "@/Hooks/useClientPagination";
import type { User } from "@/Types/User";
interface TeamTableProps {
@@ -14,6 +16,7 @@ export const TeamTable = ({ users }: TeamTableProps) => {
const { t } = useTranslation();
const navigate = useNavigate();
const isAdmin = useIsAdmin();
const { pagedRows, paginationProps } = useClientPagination(users);
const headers: Header<User>[] = [
{
@@ -51,10 +54,13 @@ export const TeamTable = ({ users }: TeamTableProps) => {
};
return (
<Table
headers={headers}
data={users}
onRowClick={isAdmin ? handleRowClick : undefined}
/>
<>
<Table
headers={headers}
data={pagedRows}
onRowClick={isAdmin ? handleRowClick : undefined}
/>
{users.length > 0 && <Pagination {...paginationProps} />}
</>
);
};
+1 -1
View File
@@ -25,7 +25,7 @@ const Account = ({ open = "profile" }: AccountProps) => {
}, [open]);
return (
<BasePage>
<BasePage headerKey="account">
<Tabs
value={activeTab}
onChange={(_, newValue: number) => setActiveTab(newValue)}
+15 -7
View File
@@ -1,6 +1,7 @@
import { BaseAuthPage, TextLink } from "@/Components/design-elements";
import { Button, TextField } from "@/Components/inputs";
import { useEffect, useState } from "react";
import { useTranslation } from "react-i18next";
import { useForm, Controller } from "react-hook-form";
import { zodResolver } from "@hookform/resolvers/zod/dist/zod.js";
@@ -9,7 +10,7 @@ import type { LoginFormData } from "@/Validation/login";
import { useDispatch } from "react-redux";
import { useNavigate } from "react-router-dom";
import { setAuthState } from "@/Features/Auth/authSlice";
import { usePost } from "@/Hooks/UseApi";
import { useLazyGet, usePost } from "@/Hooks/UseApi";
const LoginPage = () => {
const { t } = useTranslation();
@@ -17,6 +18,17 @@ const LoginPage = () => {
const navigate = useNavigate();
const { post, loading } = usePost();
const [isCheckingAdmin, setIsCheckingAdmin] = useState(true);
const { get } = useLazyGet<boolean>();
useEffect(() => {
get("/auth/users/superadmin").then((res) => {
if (res?.data === false) navigate("/register", { replace: true });
else setIsCheckingAdmin(false);
});
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
const { schema, defaults } = useLoginForm();
const { control, handleSubmit } = useForm<LoginFormData>({
@@ -24,6 +36,8 @@ const LoginPage = () => {
defaultValues: defaults,
});
if (isCheckingAdmin) return null;
const onSubmit = async (data: LoginFormData) => {
if (loading) return;
@@ -82,12 +96,6 @@ const LoginPage = () => {
linkText={t("pages.auth.login.links.forgotPassword.linkText")}
href="/forgot-password"
/>
<TextLink
alignSelf={"center"}
text={t("pages.auth.login.links.register.text")}
linkText={t("pages.auth.login.links.register.linkText")}
href="/register"
/>
</BaseAuthPage>
);
};
+31 -11
View File
@@ -1,16 +1,17 @@
import { BaseAuthPage } from "@/Components/design-elements";
import { Button, TextField } from "@/Components/inputs";
import Alert from "@mui/material/Alert";
import { useForm, Controller } from "react-hook-form";
import { zodResolver } from "@hookform/resolvers/zod/dist/zod.js";
import { useRegisterForm } from "@/Hooks/useRegisterForm";
import type { RegisterFormData } from "@/Validation/register";
import { useTranslation } from "react-i18next";
import { usePost, useGet } from "@/Hooks/UseApi";
import { useLazyGet, usePost } from "@/Hooks/UseApi";
import { setAuthState } from "@/Features/Auth/authSlice";
import { useDispatch } from "react-redux";
import { useNavigate, useParams } from "react-router-dom";
import type { AuthResponse } from "@/Types/User";
import { useEffect, useRef } from "react";
import { useEffect, useRef, useState } from "react";
interface RegisterPayload {
user: Omit<RegisterFormData, "confirm">;
@@ -32,21 +33,23 @@ const RegisterPage = () => {
const { post: verifyToken } = usePost<{ token: string }, InviteVerifyResponse>();
const hasVerified = useRef(false);
const { data: superAdminExists, isLoading: isCheckingAdmin } = useGet<boolean>(
token ? null : "/auth/users/superadmin"
);
const [isCheckingAdmin, setIsCheckingAdmin] = useState(!token);
const { get } = useLazyGet<boolean>();
useEffect(() => {
if (token) return;
get("/auth/users/superadmin").then((res) => {
if (res?.data === true) navigate("/login", { replace: true });
else setIsCheckingAdmin(false);
});
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
const { control, handleSubmit, setError, reset } = useForm<RegisterFormData>({
resolver: zodResolver(schema),
defaultValues: defaults,
});
useEffect(() => {
if (superAdminExists === true) {
navigate("/login", { replace: true });
}
}, [superAdminExists, navigate]);
useEffect(() => {
if (!token || hasVerified.current) return;
hasVerified.current = true;
@@ -92,6 +95,23 @@ const RegisterPage = () => {
title={t("pages.auth.register.title")}
subtitle={t("pages.auth.register.subtitle")}
>
{!token && (
<Alert
severity="info"
icon={false}
sx={(theme) => ({
fontSize: 13,
lineHeight: 1.55,
color: theme.palette.text.secondary,
backgroundColor: theme.palette.action.hover,
border: `1px solid ${theme.palette.divider}`,
borderRadius: 1,
"& .MuiAlert-message": { padding: 0 },
})}
>
{t("pages.auth.register.setupNotice")}
</Alert>
)}
<Controller
name="firstName"
control={control}
@@ -1,8 +1,12 @@
import { Table, Pagination, ValueLabel, StatusLabel } from "@/Components/design-elements";
import {
Table,
Pagination,
StatusLabel,
StatusCodeLabel,
} from "@/Components/design-elements";
import Box from "@mui/material/Box";
import type { Header } from "@/Components/design-elements/Table";
import type { Monitor } from "@/Types/Monitor";
import { useTranslation } from "react-i18next";
import { formatDateWithTz } from "@/Utils/TimeUtils";
import type { Check } from "@/Types/Check";
@@ -29,7 +33,7 @@ export const ChecksTable = ({
const { t } = useTranslation();
const uiTimezone = useSelector((state: RootState) => state.ui.timezone);
const getHeaders = (t: Function, uiTimezone: string) => {
const getHeaders = () => {
const headers: Header<Check>[] = [
{
id: "monitorName",
@@ -63,13 +67,10 @@ export const ChecksTable = ({
id: "statusCode",
content: t("pages.checks.table.headers.statusCode"),
render: (row) => {
const code = row.statusCode;
if (!code) return "N/A";
const value = code < 300 ? "positive" : code < 400 ? "neutral" : "negative";
return (
<ValueLabel
value={value}
text={String(code)}
<StatusCodeLabel
statusCode={row.statusCode}
message={row.message}
/>
);
},
@@ -85,7 +86,7 @@ export const ChecksTable = ({
return headers;
};
const headers = getHeaders(t, uiTimezone);
const headers = getHeaders();
const handlePageChange = (
_e: React.MouseEvent<HTMLButtonElement> | null,
+13 -1
View File
@@ -1,6 +1,7 @@
import Stack from "@mui/material/Stack";
import {
BasePage,
EmptyState,
TotalChecksBox,
UpChecksBox,
DownChecksBox,
@@ -102,8 +103,19 @@ const Checks = () => {
const downChecks = summaryResponse?.downChecks || 0;
const upChecks = totalChecks - (summaryResponse?.downChecks || 0);
const noMonitors = !isLoadingMonitors && (monitorsResponse?.length ?? 0) === 0;
if (noMonitors) {
return (
<EmptyState
fullscreen
title={t("pages.checks.fallback.title")}
description={t("pages.checks.fallback.description")}
/>
);
}
return (
<BasePage>
<BasePage headerKey="checks">
<Stack
direction={{ xs: "column", md: "row" }}
gap={4}
+126 -4
View File
@@ -53,6 +53,8 @@ interface GeneralSettingsConfig {
showGrpcServiceName: boolean;
showIgnoreTls: boolean;
showStrategy: boolean;
showDnsServer: boolean;
showDnsRecordType: boolean;
}
const getGeneralSettingsConfig = (
@@ -71,6 +73,8 @@ const getGeneralSettingsConfig = (
showGrpcServiceName: false,
showIgnoreTls: false,
showStrategy: false,
showDnsServer: false,
showDnsRecordType: false,
},
ping: {
urlLabel: t("pages.createMonitor.form.general.option.host.label"),
@@ -83,6 +87,8 @@ const getGeneralSettingsConfig = (
showGrpcServiceName: false,
showIgnoreTls: false,
showStrategy: false,
showDnsServer: false,
showDnsRecordType: false,
},
docker: {
urlLabel: t("pages.createMonitor.form.general.option.container.label"),
@@ -95,6 +101,8 @@ const getGeneralSettingsConfig = (
showGrpcServiceName: false,
showIgnoreTls: false,
showStrategy: false,
showDnsServer: false,
showDnsRecordType: false,
},
port: {
urlLabel: t("pages.createMonitor.form.general.option.url.label"),
@@ -107,6 +115,8 @@ const getGeneralSettingsConfig = (
showGrpcServiceName: false,
showIgnoreTls: false,
showStrategy: false,
showDnsServer: false,
showDnsRecordType: false,
},
game: {
urlLabel: t("pages.createMonitor.form.general.option.url.label"),
@@ -119,6 +129,8 @@ const getGeneralSettingsConfig = (
showGrpcServiceName: false,
showIgnoreTls: false,
showStrategy: false,
showDnsServer: false,
showDnsRecordType: false,
},
grpc: {
urlLabel: t("pages.createMonitor.form.general.option.host.label"),
@@ -131,6 +143,8 @@ const getGeneralSettingsConfig = (
showGrpcServiceName: true,
showIgnoreTls: true,
showStrategy: false,
showDnsServer: false,
showDnsRecordType: false,
},
pagespeed: {
urlLabel: t("pages.createMonitor.form.general.option.url.label"),
@@ -143,10 +157,12 @@ const getGeneralSettingsConfig = (
showGrpcServiceName: false,
showIgnoreTls: false,
showStrategy: true,
showDnsServer: false,
showDnsRecordType: false,
},
hardware: {
urlLabel: t("pages.createMonitor.form.general.option.url.label"),
urlPlaceholder: t("pages.createMonitor.form.general.option.url.placeholder"),
urlLabel: t("pages.createMonitor.form.general.option.captureUrl.label"),
urlPlaceholder: t("pages.createMonitor.form.general.option.captureUrl.placeholder"),
namePlaceholder: t("pages.createMonitor.form.general.option.name.placeholder"),
showUrl: true,
showPort: false,
@@ -155,6 +171,8 @@ const getGeneralSettingsConfig = (
showGrpcServiceName: false,
showIgnoreTls: false,
showStrategy: false,
showDnsServer: false,
showDnsRecordType: false,
},
websocket: {
urlLabel: t("pages.createMonitor.form.general.option.wsUrl.label"),
@@ -167,6 +185,22 @@ const getGeneralSettingsConfig = (
showGrpcServiceName: false,
showIgnoreTls: true,
showStrategy: false,
showDnsServer: false,
showDnsRecordType: false,
},
dns: {
urlLabel: t("pages.createMonitor.form.general.option.url.label"),
urlPlaceholder: t("pages.createMonitor.form.general.option.url.placeholder"),
namePlaceholder: t("pages.createMonitor.form.general.option.name.placeholder"),
showUrl: true,
showPort: false,
showGameSelect: false,
showSecret: false,
showGrpcServiceName: false,
showIgnoreTls: false,
showStrategy: false,
showDnsServer: true,
showDnsRecordType: true,
},
};
return configs[type] || configs.http;
@@ -360,6 +394,13 @@ const CreateMonitorPage = () => {
"pages.createMonitor.form.type.optionWebSocketDescription"
)}
/>
<RadioWithDescription
value="dns"
label={t("pages.common.monitors.monitorTypes.optionDns")}
description={t(
"pages.createMonitor.form.type.optionDnsDescription"
)}
/>
</RadioGroup>
</FormControl>
)}
@@ -370,7 +411,20 @@ const CreateMonitorPage = () => {
<ConfigBox
title={t("pages.createMonitor.form.general.title")}
subtitle={t(`pages.createMonitor.form.general.description.${watchedType}`)}
subtitle={
<Trans
i18nKey={`pages.createMonitor.form.general.description.${watchedType}`}
components={{
gamedigLink: (
<Link
href="https://github.com/gamedig/node-gamedig/blob/master/GAMES_LIST.md"
target="_blank"
rel="noopener noreferrer"
/>
),
}}
/>
}
rightContent={
<Stack spacing={theme.spacing(LAYOUT.MD)}>
{/* URL/Host/Container field - not shown for hardware */}
@@ -518,6 +572,74 @@ const CreateMonitorPage = () => {
)}
/>
)}
{generalSettingsConfig.showDnsServer && (
<Controller
name="dnsServer"
control={control}
render={({ field, fieldState }) => (
<TextField
{...field}
value={field.value ?? ""}
type="text"
fieldLabel={t(
"pages.createMonitor.form.general.option.dnsServer.label"
)}
placeholder={t(
"pages.createMonitor.form.general.option.dnsServer.placeholder"
)}
fullWidth
error={!!fieldState.error}
helperText={fieldState.error?.message ?? ""}
/>
)}
/>
)}
{generalSettingsConfig.showDnsRecordType && (
<Controller
name="dnsRecordType"
control={control}
defaultValue="A"
render={({ field, fieldState }) => (
<Select
{...field}
value={field.value ?? "A"}
fieldLabel={t(
"pages.createMonitor.form.general.option.dnsRecordType.label"
)}
error={!!fieldState.error}
>
<MenuItem value={"A"}>
{t("pages.createMonitor.form.general.option.dnsRecordType.value.A")}
</MenuItem>
<MenuItem value={"AAAA"}>
{t(
"pages.createMonitor.form.general.option.dnsRecordType.value.AAAA"
)}
</MenuItem>
<MenuItem value={"CNAME"}>
{t(
"pages.createMonitor.form.general.option.dnsRecordType.value.CNAME"
)}
</MenuItem>
<MenuItem value={"MX"}>
{t(
"pages.createMonitor.form.general.option.dnsRecordType.value.MX"
)}
</MenuItem>
<MenuItem value={"NS"}>
{t(
"pages.createMonitor.form.general.option.dnsRecordType.value.NS"
)}
</MenuItem>
<MenuItem value={"TXT"}>
{t(
"pages.createMonitor.form.general.option.dnsRecordType.value.TXT"
)}
</MenuItem>
</Select>
)}
/>
)}
<Controller
name="name"
@@ -923,7 +1045,7 @@ const CreateMonitorPage = () => {
/>
<Typography
component="span"
color="text.secondary"
color={theme.palette.text.secondary}
sx={{ opacity: 0.8 }}
>
<Trans
@@ -2,7 +2,7 @@ import Stack from "@mui/material/Stack";
import Grid from "@mui/material/Grid";
import Divider from "@mui/material/Divider";
import Typography from "@mui/material/Typography";
import { BaseBox, ValueLabel } from "@/Components/design-elements";
import { BaseBox, ValueLabel, StatusCodeLabel } from "@/Components/design-elements";
import { LAYOUT } from "@/Utils/Theme/constants";
import { useTranslation } from "react-i18next";
@@ -20,6 +20,24 @@ interface CardDetailsProps {
sx?: object;
}
const SectionHeading = ({ children }: { children: React.ReactNode }) => {
const theme = useTheme();
return (
<Typography
component="h2"
variant="eyebrow"
color={theme.palette.text.secondary}
>
{children}
</Typography>
);
};
const Cell = ({ children }: { children: React.ReactNode }) => (
<Typography variant="body1">{children}</Typography>
);
export const CardDetails = ({ incident, monitor, sx }: CardDetailsProps) => {
const { t } = useTranslation();
const theme = useTheme();
@@ -28,27 +46,24 @@ export const CardDetails = ({ incident, monitor, sx }: CardDetailsProps) => {
if (!incident) {
return null;
}
return (
<Stack
gap={theme.spacing(LAYOUT.MD)}
sx={sx}
>
<Typography textTransform={"uppercase"}>
{t("pages.incidents.dialog.details.title")}
</Typography>
<SectionHeading>{t("pages.incidents.dialog.details.title")}</SectionHeading>
<BaseBox padding={LAYOUT.MD}>
<Stack gap={theme.spacing(LAYOUT.MD)}>
<Typography textTransform={"uppercase"}>
{t("pages.incidents.dialog.details.overview")}
</Typography>
<Divider />
<SectionHeading>{t("pages.incidents.dialog.details.overview")}</SectionHeading>
<Grid
container
spacing={theme.spacing(LAYOUT.MD)}
alignItems="center"
>
<Grid size={2}>{t("pages.incidents.dialog.details.status")}</Grid>
<Grid size={2}>
<Cell>{t("pages.incidents.dialog.details.status")}</Cell>
</Grid>
<Grid size={10}>
<ValueLabel
value={incident.status ? "negative" : "positive"}
@@ -61,15 +76,17 @@ export const CardDetails = ({ incident, monitor, sx }: CardDetailsProps) => {
</Grid>
{monitor && (
<>
<Grid size={2}>{t("pages.incidents.dialog.details.monitor")}</Grid>
<Grid size={2}>
<Cell>{t("pages.incidents.dialog.details.monitor")}</Cell>
</Grid>
<Grid size={10}>
<Typography>{monitor.name ?? "N/A"}</Typography>
<Cell>{monitor.name ?? "N/A"}</Cell>
</Grid>
<Grid size={2}>
<Typography>{t("pages.incidents.dialog.details.url")}</Typography>
<Cell>{t("pages.incidents.dialog.details.url")}</Cell>
</Grid>
<Grid size={10}>
<Typography>{monitor.url ?? "N/A"}</Typography>
<Cell>{monitor.url ?? "N/A"}</Cell>
</Grid>
</>
)}
@@ -78,51 +95,53 @@ export const CardDetails = ({ incident, monitor, sx }: CardDetailsProps) => {
</BaseBox>
<BaseBox padding={LAYOUT.MD}>
<Stack gap={theme.spacing(LAYOUT.MD)}>
<Typography textTransform={"uppercase"}>
{t("pages.incidents.dialog.details.analysis")}
</Typography>
<Divider />
<SectionHeading>{t("pages.incidents.dialog.details.analysis")}</SectionHeading>
<Grid
container
spacing={theme.spacing(LAYOUT.MD)}
>
<Grid size={6}>
<Typography>{t("pages.incidents.dialog.details.timeline")}</Typography>
<Cell>{t("pages.incidents.dialog.details.timeline")}</Cell>
</Grid>
<Grid size={6}>
<Typography>{t("pages.incidents.dialog.details.detailsLabel")}</Typography>
<Cell>{t("pages.incidents.dialog.details.detailsLabel")}</Cell>
</Grid>
<Grid size={6}>
<Divider></Divider>
<Divider />
</Grid>
<Grid size={6}>
<Divider></Divider>
<Divider />
</Grid>
<Grid size={2}>
<Typography>{t("pages.incidents.dialog.details.startedAt")}</Typography>
<Cell>{t("pages.incidents.dialog.details.startedAt")}</Cell>
</Grid>
<Grid size={4}>
<Typography>
<Cell>
{formatDateWithTz(incident.startTime, "D MMM YYYY, h:mm A", uiTimezone)}
</Typography>
</Cell>
</Grid>
<Grid size={2}>
<Typography>{t("pages.incidents.dialog.details.statusCode")}</Typography>
<Cell>{t("pages.incidents.dialog.details.statusCode")}</Cell>
</Grid>
<Grid size={4}>
<Typography>{incident.statusCode ?? "N/A"}</Typography>
<Cell>
<StatusCodeLabel
statusCode={incident.statusCode}
message={incident.message}
/>
</Cell>
</Grid>
<Grid size={2}>
<Typography>{t("pages.incidents.dialog.details.downtime")}</Typography>
<Cell>{t("pages.incidents.dialog.details.downtime")}</Cell>
</Grid>
<Grid size={4}>
<Typography>{getIncidentsDuration(incident)}</Typography>
<Cell>{getIncidentsDuration(incident)}</Cell>
</Grid>
<Grid size={2}>
<Typography>{t("pages.incidents.dialog.details.message")}</Typography>
<Cell>{t("pages.incidents.dialog.details.message")}</Cell>
</Grid>
<Grid size={4}>
<Typography>{incident.message ?? "N/A"}</Typography>
<Cell>{incident.message ?? "N/A"}</Cell>
</Grid>
</Grid>
</Stack>
@@ -130,60 +149,53 @@ export const CardDetails = ({ incident, monitor, sx }: CardDetailsProps) => {
{!incident.status && (
<BaseBox padding={LAYOUT.MD}>
<Stack gap={theme.spacing(LAYOUT.XS)}>
<Typography textTransform={"uppercase"}>
<SectionHeading>
{t("pages.incidents.dialog.details.resolutionDetails")}
</Typography>
<Divider />
</SectionHeading>
<Grid
container
spacing={theme.spacing(LAYOUT.MD)}
alignItems="center"
>
<Grid size={2}>
<Typography>{t("pages.incidents.dialog.details.resolvedAt")}</Typography>
<Cell>{t("pages.incidents.dialog.details.resolvedAt")}</Cell>
</Grid>
<Grid size={10}>
<Typography>
<Cell>
{incident.endTime
? formatDateWithTz(incident.endTime, "D MMM YYYY, h:mm A", uiTimezone)
: "N/A"}
</Typography>
</Cell>
</Grid>
<Grid size={2}>
<Typography>
{t("pages.incidents.dialog.details.resolutionType")}
</Typography>
<Cell>{t("pages.incidents.dialog.details.resolutionType")}</Cell>
</Grid>
<Grid size={10}>
<Typography>
<Cell>
{incident.resolutionType
? t(
`pages.incidents.dialog.details.resolutionTypes.${incident.resolutionType}`
)
: "N/A"}
</Typography>
</Cell>
</Grid>
{incident.resolvedBy && (
<>
<Grid size={2}>
<Typography>
{t("pages.incidents.dialog.details.resolvedBy")}
</Typography>
<Cell>{t("pages.incidents.dialog.details.resolvedBy")}</Cell>
</Grid>
<Grid size={10}>
<Typography>
{incident.resolvedByEmail ?? incident.resolvedBy}
</Typography>
<Cell>{incident.resolvedByEmail ?? incident.resolvedBy}</Cell>
</Grid>
</>
)}
{incident.comment && (
<>
<Grid size={2}>
<Typography>{t("pages.incidents.dialog.details.comment")}</Typography>
<Cell>{t("pages.incidents.dialog.details.comment")}</Cell>
</Grid>
<Grid size={10}>
<Typography>{incident.comment}</Typography>
<Cell>{incident.comment}</Cell>
</Grid>
</>
)}
@@ -1,8 +1,7 @@
import Stack from "@mui/material/Stack";
import Typography from "@mui/material/Typography";
import Divider from "@mui/material/Divider";
import Grid from "@mui/material/Grid";
import { BaseBox, ValueLabel } from "@/Components/design-elements";
import { BaseBox, Icon, ValueLabel } from "@/Components/design-elements";
import { CircleCheck, TriangleAlert, Bell, Wrench, Globe } from "lucide-react";
import Box from "@mui/material/Box";
@@ -25,6 +24,7 @@ const SummaryItem = ({ icon, label, value }: SummaryItemProps) => {
alignItems="center"
justifyContent="space-between"
gap={theme.spacing(2)}
minHeight={32}
>
<Stack
direction="row"
@@ -32,10 +32,10 @@ const SummaryItem = ({ icon, label, value }: SummaryItemProps) => {
gap={theme.spacing(2)}
>
{icon}
<Typography variant="body2">{label}</Typography>
<Typography variant="body1">{label}</Typography>
</Stack>
<Typography
variant="body2"
variant="body1"
fontWeight={600}
>
{value}
@@ -67,15 +67,11 @@ export const SummaryCard = ({
>
<Typography
component="h2"
sx={{
textTransform: "uppercase",
fontWeight: 500,
fontSize: 13,
}}
variant="eyebrow"
color={theme.palette.text.secondary}
>
{title}
</Typography>
<Divider />
{children}
</Stack>
</BaseBox>
@@ -97,10 +93,13 @@ export const SummaryCardActiveIncidents = ({
const activeCount = summary.totalActive;
const hasActive = activeCount > 0;
const color = hasActive ? theme.palette.error.main : theme.palette.success.main;
const icon = hasActive ? (
<TriangleAlert color={color} />
) : (
<CircleCheck color={color} />
const icon = (
<Box sx={{ color, display: "inline-flex" }}>
<Icon
icon={hasActive ? TriangleAlert : CircleCheck}
size={32}
/>
</Box>
);
const msg = t("pages.incidents.summaryCard.activeIncidents.active", {
count: activeCount,
@@ -133,10 +132,7 @@ const SummaryIncidentItem = ({ incident }: { incident: IncidentSummaryItem }) =>
container
alignItems="center"
spacing={2}
sx={{
width: "100%",
py: theme.spacing(0.5),
}}
sx={{ width: "100%", minHeight: 32 }}
>
<Grid
size={{ xs: 12, lg: 5 }}
@@ -147,7 +143,7 @@ const SummaryIncidentItem = ({ incident }: { incident: IncidentSummaryItem }) =>
gap: theme.spacing(2),
}}
>
<Globe />
<Icon icon={Globe} />
<Typography
variant="body1"
fontWeight={500}
@@ -159,9 +155,7 @@ const SummaryIncidentItem = ({ incident }: { incident: IncidentSummaryItem }) =>
<Grid
size={{ xs: 12, md: 6, lg: 3 }}
sx={{
display: "flex",
}}
sx={{ display: "flex" }}
>
<ValueLabel
value={incident.status ? "negative" : "positive"}
@@ -173,7 +167,7 @@ const SummaryIncidentItem = ({ incident }: { incident: IncidentSummaryItem }) =>
size={{ xs: 12, md: 6, lg: 4 }}
sx={{
textAlign: { xs: "left", md: "right" },
fontWeight: 500,
fontWeight: 600,
}}
>
<Typography variant="body1">{duration}</Typography>
@@ -190,22 +184,17 @@ export const SummaryCardLatestIncidents = ({
summary,
}: SummaryCardLatestIncidentsProps) => {
const { t } = useTranslation();
const theme = useTheme();
const latestIncidents = summary?.latestIncidents ?? [];
return (
<SummaryCard title={t("pages.incidents.summaryCard.latestIncidents.title")}>
<Stack gap={theme.spacing(4)}>
{latestIncidents.slice(0, 3).map((incident, index) => (
<Box key={incident.id}>
<SummaryIncidentItem incident={incident} />
{index < latestIncidents.length - 1 && (
<Divider sx={{ mt: theme.spacing(2) }} />
)}
</Box>
))}
</Stack>
{latestIncidents.slice(0, 3).map((incident) => (
<SummaryIncidentItem
key={incident.id}
incident={incident}
/>
))}
</SummaryCard>
);
};
@@ -224,17 +213,17 @@ export const SummaryCardStats = ({ summary }: SummaryCardStatsProps) => {
return (
<SummaryCard title={t("pages.incidents.summaryCard.incidentStats.title")}>
<SummaryItem
icon={<Bell size={18} />}
icon={<Icon icon={Bell} />}
label={t("pages.incidents.summaryCard.incidentStats.totalIncidents")}
value={summary?.total || 0}
/>
<SummaryItem
icon={<TriangleAlert size={18} />}
icon={<Icon icon={TriangleAlert} />}
label={t("pages.incidents.summaryCard.incidentStats.mostAffectedMonitor")}
value={mostAffected}
/>
<SummaryItem
icon={<Wrench size={18} />}
icon={<Icon icon={Wrench} />}
label={t("pages.incidents.summaryCard.incidentStats.avgResolutionTime")}
value={
summary.total > 0
@@ -75,7 +75,7 @@ export const ControlsIncidentFilter = ({
variant="contained"
onClick={onClearFilters}
>
{t("pages.incidents.filters.clearFilters")}
{t("common.buttons.clearFilters")}
</Button>
)}
</Stack>
@@ -1,9 +1,7 @@
import { useState, useEffect } from "react";
import Box from "@mui/material/Box";
import { Dialog, TextField } from "@/Components/inputs";
import { usePut } from "@/Hooks/UseApi";
import { useTranslation } from "react-i18next";
import { useTheme } from "@mui/material";
interface DialogResolutionProps {
open: boolean;
@@ -19,7 +17,6 @@ export const DialogResolution = ({
onResolved,
}: DialogResolutionProps) => {
const { t } = useTranslation();
const theme = useTheme();
const [comment, setComment] = useState("");
const { put: resolveIncident, loading: isResolving } = usePut();
@@ -50,22 +47,19 @@ export const DialogResolution = ({
onCancel={handleCancel}
onConfirm={handleConfirm}
confirmColor="error"
cancelColor="primary"
loading={isResolving}
maxWidth="sm"
fullWidth
>
<Box sx={{ mt: theme.spacing(4) }}>
<TextField
fieldLabel={t("pages.incidents.dialog.resolveIncident.option.comment.label")}
placeholder={t(
"pages.incidents.dialog.resolveIncident.option.comment.placeholder"
)}
value={comment}
onChange={(e) => setComment(e.target.value)}
fullWidth
/>
</Box>
<TextField
fieldLabel={t("pages.incidents.dialog.resolveIncident.option.comment.label")}
placeholder={t(
"pages.incidents.dialog.resolveIncident.option.comment.placeholder"
)}
value={comment}
onChange={(e) => setComment(e.target.value)}
fullWidth
/>
</Dialog>
);
};
@@ -1,8 +1,6 @@
import { Table } from "@/Components/design-elements";
import { Table, ValueLabel, StatusCodeLabel } from "@/Components/design-elements";
import { Pagination } from "@/Components/design-elements/Table";
import type { Header } from "@/Components/design-elements/Table";
import { ValueLabel } from "@/Components/design-elements";
import type { ValueType } from "@/Components/design-elements/StatusLabel";
import { ActionsMenu } from "@/Components/actions-menu";
import type { Incident } from "@/Types/Incident";
import type { Monitor } from "@/Types/Monitor";
@@ -136,13 +134,12 @@ export const IncidentsTable = ({
return (
<Typography
variant="body2"
sx={{
textTransform: "capitalize",
color:
row.resolutionType === "manual"
? theme.palette.warning.main
: theme.palette.success.main,
}}
color={
row.resolutionType === "manual"
? theme.palette.warning.main
: theme.palette.success.main
}
textTransform={"capitalize"}
>
{row.resolutionType}
</Typography>
@@ -155,15 +152,10 @@ export const IncidentsTable = ({
id: "statusCode",
content: t("pages.incidents.table.headers.statusCode"),
render: (row) => {
const code = row.statusCode;
if (!code) return "N/A";
let value: ValueType = "neutral";
if (code < 300) value = "positive";
else if (code >= 400) value = "negative";
return (
<ValueLabel
value={value}
text={String(code)}
<StatusCodeLabel
statusCode={row.statusCode}
message={row.message}
/>
);
},
+22 -14
View File
@@ -1,4 +1,4 @@
import { BasePage } from "@/Components/design-elements";
import { BasePage, EmptyState } from "@/Components/design-elements";
import Stack from "@mui/material/Stack";
import Typography from "@mui/material/Typography";
import Divider from "@mui/material/Divider";
@@ -15,6 +15,7 @@ import { HeaderTimeRange } from "@/Components/common";
import { ControlsIncidentFilter } from "@/Pages/Incidents/Components/ControlsIncidentFilter";
import { useGet } from "@/Hooks/UseApi";
import { LAYOUT } from "@/Utils/Theme/constants";
import { useState, useEffect, useMemo } from "react";
import { useParams } from "react-router-dom";
import type { Incident, IncidentsResponse, IncidentSummary } from "@/Types/Incident";
@@ -153,8 +154,20 @@ const IncidentsPage = () => {
refetchSummary();
};
const trulyEmpty = summaryData !== undefined && (summaryData?.total ?? 0) === 0;
if (trulyEmpty) {
return (
<EmptyState
fullscreen
title={t("pages.incidents.fallback.title")}
description={t("pages.incidents.fallback.description")}
/>
);
}
return (
<BasePage>
<BasePage headerKey="incidents">
<Stack
direction={{ xs: "column", md: "row" }}
gap={theme.spacing(8)}
@@ -171,11 +184,12 @@ const IncidentsPage = () => {
setSelectedResolutionType={setFilter}
onClearFilters={handleClearFilters}
/>
{activeIncidentsCount > 0 ? (
{activeIncidentsCount > 0 && (
<>
<Typography
variant="h6"
sx={{ mb: theme.spacing(4), textTransform: "uppercase" }}
variant="eyebrow"
color={theme.palette.text.secondary}
mb={theme.spacing(LAYOUT.XS)}
>
{t("pages.incidents.table.activeIncidents")}
</Typography>
@@ -193,18 +207,12 @@ const IncidentsPage = () => {
/>
<Divider />
</>
) : (
<Typography
variant="body1"
sx={{ mb: theme.spacing(4), color: theme.palette.success.main }}
>
{t("pages.incidents.summaryCard.activeIncidents.active_zero")}
</Typography>
)}
<Typography
variant="h6"
sx={{ mb: theme.spacing(4), textTransform: "uppercase" }}
variant="eyebrow"
color={theme.palette.text.secondary}
mb={theme.spacing(LAYOUT.XS)}
>
{t("pages.incidents.table.resolvedIncidents")}
</Typography>
@@ -1,4 +1,6 @@
import Grid from "@mui/material/Grid";
import Stack from "@mui/material/Stack";
import { NoticeBanner } from "@/Components/design-elements";
import { HistogramInfrastructure } from "@/Components/monitors";
import { useTranslation } from "react-i18next";
@@ -6,6 +8,7 @@ import type { HardwareCheckStats } from "@/Types/Monitor";
import { useMemo } from "react";
import { useTheme } from "@mui/material/styles";
import useMediaQuery from "@mui/material/useMediaQuery";
import { LAYOUT } from "@/Utils/Theme/constants";
const formatBytesToMB = (value: number) => `${(value / (1024 * 1024)).toFixed(2)} MB`;
@@ -60,6 +63,11 @@ const getChartConfigs = (
return configs;
};
const hasAnyNetworkTraffic = (checks: HardwareCheckStats[]): boolean =>
checks.some((c) =>
c.net?.some((iface) => iface.bytesSentPerSecond > 0 || iface.deltaBytesRecv > 0)
);
export const InfraNetworkCharts = ({
checks,
dateRange,
@@ -74,35 +82,43 @@ export const InfraNetworkCharts = ({
() => getChartConfigs(theme, checks, t),
[theme, checks, t]
);
const showNoTrafficNotice = chartConfigs.length > 0 && !hasAnyNetworkTraffic(checks);
return (
<Grid
container
spacing={theme.spacing(8)}
>
{chartConfigs.map((config) => {
return (
<Grid
size={isSmall ? 12 : 6}
key={`${config.type}-${config.interfaceName ?? config.idx ?? ""}`}
>
<HistogramInfrastructure
dateRange={dateRange}
title={config.title}
type={config.type}
idx={config.idx}
checks={checks}
xKey="bucketDate"
dataKeys={config.dataKeys}
gradient={true}
gradientStartColor={config.gradientStartColor}
gradientEndColor="#ffffff"
strokeColor={config.strokeColor}
yAxisFormatter={formatBytesToMB}
/>
</Grid>
);
})}
</Grid>
<Stack gap={theme.spacing(LAYOUT.XS)}>
{showNoTrafficNotice && (
<NoticeBanner severity="warning">
{t("pages.infrastructure.charts.labels.netNoTraffic")}
</NoticeBanner>
)}
<Grid
container
spacing={theme.spacing(LAYOUT.MD)}
>
{chartConfigs.map((config) => {
return (
<Grid
size={isSmall ? 12 : 6}
key={`${config.type}-${config.interfaceName ?? config.idx ?? ""}`}
>
<HistogramInfrastructure
dateRange={dateRange}
title={config.title}
type={config.type}
idx={config.idx}
checks={checks}
xKey="bucketDate"
dataKeys={config.dataKeys}
gradient={true}
gradientStartColor={config.gradientStartColor}
gradientEndColor="#ffffff"
strokeColor={config.strokeColor}
yAxisFormatter={formatBytesToMB}
/>
</Grid>
);
})}
</Grid>
</Stack>
);
};
@@ -1,4 +1,5 @@
import Box from "@mui/material/Box";
import Stack from "@mui/material/Stack";
import Typography from "@mui/material/Typography";
import { Table, Pagination, StatusLabel, Gauge } from "@/Components/design-elements";
import type { Header } from "@/Components/design-elements/Table";
@@ -12,6 +13,24 @@ import { useNavigate } from "react-router-dom";
import { usePost } from "@/Hooks/UseApi";
import type { Monitor } from "@/Types/Monitor";
import { Checkbox } from "@/Components/inputs";
interface InfraMonitorsTableProps {
monitors: Monitor[];
refetch: () => void;
setSelectedMonitor: (monitor: Monitor | null) => void;
sortField: string;
setSortField: (field: string) => void;
sortOrder: "asc" | "desc";
setSortOrder: (order: "asc" | "desc") => void;
count: number;
page: number;
setPage: (page: number) => void;
rowsPerPage: number;
setRowsPerPage: (rowsPerPage: number) => void;
selectedRows?: string[];
onSelectionChange?: (selected: string[]) => void;
}
export const InfraMonitorsTable = ({
monitors,
@@ -26,29 +45,32 @@ export const InfraMonitorsTable = ({
setPage,
rowsPerPage,
setRowsPerPage,
}: {
monitors: Monitor[];
refetch: Function;
setSelectedMonitor: Function;
sortField: string;
setSortField: (field: string) => void;
sortOrder: "asc" | "desc";
setSortOrder: (order: "asc" | "desc") => void;
count: number;
page: number;
setPage: (page: number) => void;
rowsPerPage: number;
setRowsPerPage: (rowsPerPage: number) => void;
}) => {
selectedRows = [],
onSelectionChange,
}: InfraMonitorsTableProps) => {
const { t } = useTranslation();
const theme = useTheme();
const isSmall = useMediaQuery(theme.breakpoints.down("md"));
const navigate = useNavigate();
const {
post,
// loading: isPatching,
// error: postError,
} = usePost<any, Monitor>();
const { post } = usePost<Record<string, never>, Monitor>();
const selectedSet = new Set(selectedRows);
const isAllSelected =
monitors.length > 0 && monitors.every((m) => selectedSet.has(m.id));
const isSomeSelected = selectedRows.length > 0 && !isAllSelected;
const handleSelectAll = (checked: boolean) => {
onSelectionChange?.(checked ? monitors.map((m) => m.id) : []);
};
const handleSelectRow = (id: string, checked: boolean) => {
if (!onSelectionChange) return;
onSelectionChange(
checked ? [...selectedRows, id] : selectedRows.filter((rowId) => rowId !== id)
);
};
const isRowSelected = (id: string) => selectedSet.has(id);
const handlePageChange = (
_e: React.MouseEvent<HTMLButtonElement> | null,
@@ -65,7 +87,7 @@ export const InfraMonitorsTable = ({
setRowsPerPage(value);
};
const handleSort = (e: any, field: string) => {
const handleSort = (e: React.MouseEvent, field: string) => {
e.preventDefault();
e.stopPropagation();
if (sortField === field) {
@@ -80,14 +102,6 @@ export const InfraMonitorsTable = ({
const getActions = (monitor: Monitor): ActionMenuItem[] => {
return [
{
id: 1,
label: t("pages.common.monitors.actions.openSite"),
action: () => {
window.open(monitor.url, "_blank", "noreferrer");
},
closeMenu: true,
},
{
id: 2,
label: t("pages.common.monitors.actions.details"),
@@ -109,13 +123,6 @@ export const InfraMonitorsTable = ({
navigate(`/infrastructure/configure/${monitor.id}`);
},
},
// {
// id: 5,
// label: "Clone",
// action: () => {
// },
// },
{
id: 6,
label:
@@ -160,8 +167,35 @@ export const InfraMonitorsTable = ({
</Box>
);
const headers: Header<Monitor>[] = [
{
id: "selection",
content: (
<Checkbox
aria-label={t("pages.common.monitors.actions.selectAll")}
indeterminate={isSomeSelected}
checked={isAllSelected}
onChange={(e) => handleSelectAll(e.target.checked)}
/>
),
hideMobileLabel: true,
render: (row) => (
<Checkbox
aria-label={t("pages.common.monitors.actions.selectMonitor", {
name: row.name,
})}
checked={isRowSelected(row.id)}
onChange={(e) => {
handleSelectRow(row.id, e.target.checked);
}}
onClick={(e) => e.stopPropagation()}
/>
),
onClick: (e) => e?.stopPropagation(),
},
{
id: "name",
align: "left",
mobileLabel: t("common.table.headers.name"),
content: (
<Typography
component="div"
@@ -181,18 +215,20 @@ export const InfraMonitorsTable = ({
},
{
id: "status",
mobileLabel: t("common.table.headers.status"),
content: (
<Typography
component="div"
display="inline-flex"
alignItems="center"
<Stack
gap={theme.spacing(4)}
direction={"row"}
justifyContent={"center"}
alignItems={"center"}
onClick={(e) => handleSort(e, "status")}
sx={{ cursor: "pointer" }}
>
<Box width={theme.spacing(8)} />
{t("common.table.headers.status")}
{renderSortIcon(sortField === "status")}
</Typography>
</Stack>
),
render: (row) => {
return <StatusLabel status={row.status} />;
@@ -256,6 +292,11 @@ export const InfraMonitorsTable = ({
onRowClick={(row) => {
navigate(`/infrastructure/${row.id}`);
}}
getRowSx={(row) => ({
backgroundColor: isRowSelected(row.id)
? theme.palette.action.selected
: "inherit",
})}
/>
<Pagination
component="div"
@@ -2,8 +2,13 @@ import Stack from "@mui/material/Stack";
import useMediaQuery from "@mui/material/useMediaQuery";
import { MonitorBasePageWithStates } from "@/Components/design-elements";
import { HeaderCreate } from "@/Components/common";
import { ControlsFilter, HeaderMonitorsSummary } from "@/Components/monitors";
import { TextField, Dialog } from "@/Components/inputs";
import {
ControlsFilter,
HeaderMonitorsSummary,
BulkActionsBar,
} from "@/Components/monitors";
import { TextField, Dialog, Button } from "@/Components/inputs";
import { Play, Pause } from "lucide-react";
import { useGet, useDelete } from "@/Hooks/UseApi";
import { useIsAdmin } from "@/Hooks/useIsAdmin";
@@ -16,6 +21,7 @@ import { setRowsPerPage } from "@/Features/UI/uiSlice.js";
import type { RootState } from "@/Types/state";
import { InfraMonitorsTable } from "./Components/MonitorsTable";
import useDebounce from "@/Hooks/useDebounce";
import { useBulkMonitorActions } from "@/Hooks/useBulkMonitorActions";
const InfrastructureMonitors = () => {
const { t } = useTranslation();
@@ -37,6 +43,7 @@ const InfrastructureMonitors = () => {
const [sortField, setSortField] = useState<string>("");
const [sortOrder, setSortOrder] = useState<"asc" | "desc">("asc");
const [selectedMonitor, setSelectedMonitor] = useState<Monitor | null>(null);
const isDialogOpen = Boolean(selectedMonitor);
const debouncedSearch = useDebounce<string>(search, 300);
@@ -97,6 +104,15 @@ const InfrastructureMonitors = () => {
const { summary, count } = monitorsWithChecksData ?? { summary: null, count: 0 };
const isLoading = monitorsWithChecksLoading;
// Bulk actions
const {
selectedRows,
setSelectedRows,
handleBulkPause,
handleBulkResume,
handleCancelSelection,
} = useBulkMonitorActions(refetch, page);
// Check if any filters are active
const hasActiveFilters = Boolean(selectedStatus || selectedState || search);
@@ -122,6 +138,7 @@ const InfrastructureMonitors = () => {
return (
<MonitorBasePageWithStates
headerKey="infrastructure"
loading={isLoading}
error={monitorsWithChecksError}
totalCount={effectiveTotalCount}
@@ -158,6 +175,29 @@ const InfrastructureMonitors = () => {
}}
/>
</Stack>
{!isLoading && (
<BulkActionsBar
selectedCount={selectedRows.length}
onCancel={handleCancelSelection}
>
<Button
size="small"
startIcon={<Play size={16} />}
onClick={handleBulkResume}
>
{t("common.buttons.resume")}
</Button>
<Button
size="small"
startIcon={<Pause size={16} />}
onClick={handleBulkPause}
>
{t("common.buttons.pause")}
</Button>
</BulkActionsBar>
)}
<InfraMonitorsTable
monitors={monitorsWithChecksData?.monitors || []}
refetch={refetch}
@@ -179,6 +219,8 @@ const InfrastructureMonitors = () => {
);
setPage(0);
}}
selectedRows={selectedRows}
onSelectionChange={setSelectedRows}
/>
<Dialog
open={isDialogOpen}
+32 -3
View File
@@ -1,13 +1,16 @@
import Stack from "@mui/material/Stack";
import Typography from "@mui/material/Typography";
import { Stats } from "@/Pages/Logs/components/Stats";
import { StatGauges } from "@/Pages/Logs/components/StatGauges";
import { useTheme } from "@mui/material";
import { useTranslation } from "react-i18next";
import { useGet } from "@/Hooks/UseApi";
import type { Diagnostics } from "@/Types/Diagnostics";
export const TabDiagnostics = () => {
const theme = useTheme();
const { t } = useTranslation();
const {
data: diagnostics,
isLoading: _isLoading,
@@ -16,9 +19,35 @@ export const TabDiagnostics = () => {
} = useGet<Diagnostics>("/diagnostic/system", {}, { refreshInterval: 5000 });
return (
<Stack gap={theme.spacing(8)}>
<Stats diagnostics={diagnostics} />
<StatGauges diagnostics={diagnostics} />
<Stack gap={theme.spacing(12)}>
<Stack gap={theme.spacing(4)}>
<Stack gap={theme.spacing(1)}>
<Typography
variant="eyebrow"
color="text.secondary"
>
{t("pages.logs.diagnostics.sections.runtimeStats.title")}
</Typography>
<Typography color={theme.palette.text.secondary}>
{t("pages.logs.diagnostics.sections.runtimeStats.description")}
</Typography>
</Stack>
<Stats diagnostics={diagnostics} />
</Stack>
<Stack gap={theme.spacing(4)}>
<Stack gap={theme.spacing(1)}>
<Typography
variant="eyebrow"
color="text.secondary"
>
{t("pages.logs.diagnostics.sections.memoryCpu.title")}
</Typography>
<Typography color={theme.palette.text.secondary}>
{t("pages.logs.diagnostics.sections.memoryCpu.description")}
</Typography>
</Stack>
<StatGauges diagnostics={diagnostics} />
</Stack>
</Stack>
);
};
+12 -1
View File
@@ -4,6 +4,7 @@ import Typography from "@mui/material/Typography";
import { useSelector } from "react-redux";
import { Select } from "@/Components/inputs";
import { TableLogs } from "@/Pages/Logs/components/TableLogs";
import { EmptyState } from "@/Components/design-elements";
import { useTheme } from "@mui/material";
import { useGet } from "@/Hooks/UseApi";
@@ -15,7 +16,7 @@ import type { RootState } from "@/Types/state";
export const TabLogs = () => {
const theme = useTheme();
const { data: logs } = useGet<Log[]>("/logs");
const { data: logs, isLoading } = useGet<Log[]>("/logs");
const [selectedLogLevel, setSelectedLogLevel] = useState<LogLevelOption>("all");
const [page, setPage] = useState(0);
const rowsPerPage = useSelector(
@@ -33,6 +34,16 @@ export const TabLogs = () => {
const paginatedLogs =
filteredLogs?.slice(page * rowsPerPage, page * rowsPerPage + rowsPerPage) ?? [];
if (!isLoading && (logs?.length ?? 0) === 0) {
return (
<EmptyState
fullscreen
title={t("pages.logs.fallback.title")}
description={t("pages.logs.fallback.description")}
/>
);
}
return (
<Stack gap={theme.spacing(8)}>
<Select
+60 -18
View File
@@ -1,28 +1,42 @@
import Stack from "@mui/material/Stack";
import Typography from "@mui/material/Typography";
import Box from "@mui/material/Box";
import { TableJobs, TableFailedJobs } from "./components/TableJobs";
import { useTheme } from "@mui/material";
import { useTranslation } from "react-i18next";
import { useTranslation, Trans } from "react-i18next";
import { useGet, usePost } from "@/Hooks/UseApi";
import type { QueueData } from "@/Types/Queue";
import { Metrics } from "@/Pages/Logs/components/Metrics";
import { Button } from "@/Components/inputs";
import { EmptyState } from "@/Components/design-elements";
import { SPACING } from "@/Utils/Theme/constants";
export const TabQueue = () => {
const theme = useTheme();
const { t } = useTranslation();
const {
data: queueData,
// isLoading,
isLoading,
// error,
refetch,
} = useGet<QueueData>("/queue/all-metrics", {}, { refreshInterval: 5000 });
} = useGet<QueueData>("/queue/all-metrics", {}, { refreshInterval: 1000 });
const { post, loading: isFlushing } = usePost();
const jobs = queueData?.jobs ?? [];
const metrics = queueData?.metrics ?? null;
const hasNeverRun = !isLoading && metrics !== null && (metrics.totalRuns ?? 0) === 0;
const noQueueData = !isLoading && metrics === null && jobs.length === 0;
if (hasNeverRun || noQueueData) {
return (
<EmptyState
fullscreen
title={t("pages.logs.queueFallback.title")}
description={t("pages.logs.queueFallback.description")}
/>
);
}
const handleFlushQueue = async () => {
await post("/queue/flush", {});
@@ -30,22 +44,50 @@ export const TabQueue = () => {
};
return (
<Stack gap={theme.spacing(8)}>
<Stack gap={theme.spacing(16)}>
<Metrics metrics={metrics} />
<Typography
variant="h6"
sx={{ textTransform: "uppercase" }}
>
{t("pages.logs.jobQueue")}
</Typography>
<TableJobs jobs={jobs} />
<Typography
variant="h6"
sx={{ textTransform: "uppercase" }}
>
{t("pages.logs.failedJobs")}
</Typography>
<TableFailedJobs metrics={metrics} />
<Stack gap={theme.spacing(3)}>
<Stack gap={theme.spacing(1)}>
<Typography
variant="eyebrow"
color={theme.palette.text.secondary}
>
{t("pages.logs.jobQueue")}
</Typography>
<Typography color={theme.palette.text.secondary}>
<Trans
i18nKey="pages.logs.jobQueueExplainer"
components={{
highlight: (
<Box
component="span"
bgcolor={theme.palette.rowStatus.running}
color={theme.palette.text.primary}
px={SPACING.SM}
py={SPACING.XXS}
borderRadius={theme.shape.borderRadius}
/>
),
}}
/>
</Typography>
</Stack>
<TableJobs jobs={jobs} />
</Stack>
<Stack gap={theme.spacing(3)}>
<Stack gap={theme.spacing(1)}>
<Typography
variant="eyebrow"
color={theme.palette.text.secondary}
>
{t("pages.logs.failedJobs")}
</Typography>
<Typography color={theme.palette.text.secondary}>
{t("pages.logs.failedJobsExplainer")}
</Typography>
</Stack>
<TableFailedJobs metrics={metrics} />
</Stack>
<Stack alignItems={"flex-end"}>
<Button
variant="contained"
+45 -29
View File
@@ -1,4 +1,4 @@
import Stack from "@mui/material/Stack";
import Box from "@mui/material/Box";
import { DetailGauge } from "@/Components/design-elements";
import { getPercentage, formatPercentageFromWhole } from "@/Utils/FormatUtils";
@@ -11,62 +11,78 @@ interface StatGaugesProps {
diagnostics: Diagnostics | null;
}
const PLACEHOLDER = "—";
export const StatGauges = ({ diagnostics }: StatGaugesProps) => {
const theme = useTheme();
const { t } = useTranslation();
if (!diagnostics) {
return null;
}
const heapTotalSize = getPercentage(
diagnostics?.v8HeapStats?.totalHeapSizeBytes,
diagnostics?.v8HeapStats?.heapSizeLimitBytes
);
const heapTotalSize = diagnostics
? getPercentage(
diagnostics.v8HeapStats?.totalHeapSizeBytes,
diagnostics.v8HeapStats?.heapSizeLimitBytes
)
: 0;
const heapUsedSize = diagnostics
? getPercentage(
diagnostics.v8HeapStats?.usedHeapSizeBytes,
diagnostics.v8HeapStats?.heapSizeLimitBytes
)
: 0;
const actualHeapUsed = diagnostics
? getPercentage(
diagnostics.v8HeapStats?.usedHeapSizeBytes,
diagnostics.v8HeapStats?.totalHeapSizeBytes
)
: 0;
const cpuUsage = diagnostics?.cpuUsage?.usagePercentage ?? 0;
const heapUsedSize = getPercentage(
diagnostics?.v8HeapStats?.usedHeapSizeBytes,
diagnostics?.v8HeapStats?.heapSizeLimitBytes
);
const actualHeapUsed = getPercentage(
diagnostics?.v8HeapStats?.usedHeapSizeBytes,
diagnostics?.v8HeapStats?.totalHeapSizeBytes
);
const fmt = (n: number) => (diagnostics ? formatPercentageFromWhole(n) : PLACEHOLDER);
const bytes = (n: number | undefined) =>
diagnostics ? prettyBytes(n ?? 0) : PLACEHOLDER;
return (
<Stack
direction={{ xs: "column", md: "row" }}
gap={theme.spacing(8)}
<Box
sx={{
display: "grid",
gridTemplateColumns: { xs: "1fr", md: "repeat(4, 1fr)" },
gap: theme.spacing(8),
"& > *": { width: "100% !important" },
}}
>
<DetailGauge
maxWidth={9999}
title={t("pages.logs.diagnostics.gauges.heapAllocation")}
progress={heapTotalSize}
upperValue={formatPercentageFromWhole(heapTotalSize)}
upperValue={fmt(heapTotalSize)}
lowerLabel={t("pages.logs.diagnostics.gauges.total")}
lowerValue={prettyBytes(diagnostics.v8HeapStats?.heapSizeLimitBytes ?? 0)}
lowerValue={bytes(diagnostics?.v8HeapStats?.heapSizeLimitBytes)}
/>
<DetailGauge
maxWidth={9999}
title={t("pages.logs.diagnostics.gauges.heapUsage")}
progress={heapUsedSize}
upperLabel={t("pages.logs.diagnostics.gauges.availableMemoryPercentage")}
upperValue={formatPercentageFromWhole(heapUsedSize)}
upperValue={fmt(heapUsedSize)}
lowerLabel={t("pages.logs.diagnostics.gauges.used")}
lowerValue={prettyBytes(diagnostics.v8HeapStats?.usedHeapSizeBytes ?? 0)}
lowerValue={bytes(diagnostics?.v8HeapStats?.usedHeapSizeBytes)}
/>
<DetailGauge
maxWidth={9999}
title={t("pages.logs.diagnostics.gauges.heapUtilization")}
progress={actualHeapUsed}
upperLabel={t("pages.logs.diagnostics.gauges.allocatedPercentage")}
upperValue={formatPercentageFromWhole(actualHeapUsed)}
upperValue={fmt(actualHeapUsed)}
lowerLabel={t("pages.logs.diagnostics.gauges.total")}
lowerValue={prettyBytes(diagnostics.v8HeapStats?.usedHeapSizeBytes ?? 0)}
lowerValue={bytes(diagnostics?.v8HeapStats?.usedHeapSizeBytes)}
/>
<DetailGauge
maxWidth={9999}
title={t("pages.logs.diagnostics.gauges.instantCpuUsage")}
progress={diagnostics.cpuUsage?.usagePercentage ?? 0}
progress={cpuUsage}
upperLabel={t("pages.logs.diagnostics.gauges.usedSPercentage")}
upperValue={formatPercentageFromWhole(diagnostics.cpuUsage?.usagePercentage ?? 0)}
upperValue={fmt(cpuUsage)}
/>
</Stack>
</Box>
);
};
+33 -15
View File
@@ -1,4 +1,4 @@
import Stack from "@mui/material/Stack";
import Box from "@mui/material/Box";
import { StatBox } from "@/Components/design-elements";
import prettyBytes from "pretty-bytes";
@@ -11,41 +11,59 @@ interface StatsProps {
diagnostics: Diagnostics | null;
}
const PLACEHOLDER = "—";
export const Stats = ({ diagnostics }: StatsProps) => {
const { t } = useTranslation();
const theme = useTheme();
if (!diagnostics) {
return null;
}
const eventLoopDelay = diagnostics
? prettyMilliseconds(diagnostics.eventLoopDelayMs ?? 0, {
millisecondsDecimalDigits: 2,
})
: PLACEHOLDER;
const uptime = diagnostics
? prettyMilliseconds(diagnostics.uptimeMs ?? 0, { hideSeconds: true })
: PLACEHOLDER;
const usedHeap = diagnostics
? prettyBytes(diagnostics.v8HeapStats?.usedHeapSizeBytes ?? 0)
: PLACEHOLDER;
const totalHeap = diagnostics
? prettyBytes(diagnostics.v8HeapStats?.totalHeapSizeBytes ?? 0)
: PLACEHOLDER;
const osMemory = diagnostics
? prettyBytes(diagnostics.osStats?.totalMemoryBytes ?? 0)
: PLACEHOLDER;
return (
<Stack
direction={{ xs: "column", md: "row" }}
gap={theme.spacing(8)}
<Box
sx={{
display: "grid",
gridTemplateColumns: { xs: "1fr", md: "repeat(5, 1fr)" },
gap: theme.spacing(8),
"& > *": { width: "100% !important" },
}}
>
<StatBox
title={t("pages.logs.diagnostics.stats.eventLoopDelay")}
subtitle={prettyMilliseconds(diagnostics.eventLoopDelayMs ?? 0, {
millisecondsDecimalDigits: 2,
})}
subtitle={eventLoopDelay}
/>
<StatBox
title={t("pages.logs.diagnostics.stats.uptime")}
subtitle={prettyMilliseconds(diagnostics.uptimeMs ?? 0, { hideSeconds: true })}
subtitle={uptime}
/>
<StatBox
title={t("pages.logs.diagnostics.stats.usedHeapSize")}
subtitle={prettyBytes(diagnostics.v8HeapStats?.usedHeapSizeBytes ?? 0)}
subtitle={usedHeap}
/>
<StatBox
title={t("pages.logs.diagnostics.stats.totalHeapSize")}
subtitle={prettyBytes(diagnostics.v8HeapStats?.totalHeapSizeBytes ?? 0)}
subtitle={totalHeap}
/>
<StatBox
title={t("pages.logs.diagnostics.stats.osMemoryLimit")}
subtitle={prettyBytes(diagnostics.osStats?.totalMemoryBytes ?? 0)}
subtitle={osMemory}
/>
</Stack>
</Box>
);
};
+87 -36
View File
@@ -22,66 +22,109 @@ export const TableJobs = ({ jobs }: TableJobsProps) => {
id: job.monitorId,
}));
const cellSx = {
whiteSpace: "nowrap" as const,
overflow: "hidden",
textOverflow: "ellipsis",
maxWidth: 220,
};
const headers: Header<QueueJobWithId>[] = [
{
id: "state",
content: t("pages.logs.table.headers.state"),
render: (row) => {
if (row.lockedAt) {
return t("common.labels.running");
}
if (row.monitorActive === true) {
return t("common.labels.active");
}
if (row.monitorActive === false) {
return t("common.labels.paused");
}
return t("common.labels.idle");
},
},
{
id: "id",
content: t("common.table.headers.monitorId"),
render: (row) => <Typography fontFamily={"monospace"}>{row.monitorId}</Typography>,
render: (row) => (
<Typography
fontFamily={theme.typography.fontFamilyMonospace}
title={String(row.monitorId)}
sx={cellSx}
>
{row.monitorId}
</Typography>
),
},
{
id: "url",
content: t("common.table.headers.url"),
render: (row) => <Typography>{row.monitorUrl}</Typography>,
},
{
id: "interval",
content: t("common.table.headers.interval"),
render: (row) => (
<Typography>{prettyMilliseconds(row.monitorInterval ?? 0)}</Typography>
<Typography
title={row.monitorUrl ?? ""}
sx={cellSx}
>
{row.monitorUrl}
</Typography>
),
},
{
id: "type",
content: t("common.table.headers.type"),
render: (row) => <Typography>{row.monitorType}</Typography>,
render: (row) => <Typography sx={cellSx}>{row.monitorType}</Typography>,
},
{
id: "active",
content: t("common.table.headers.active"),
render: (row) => <Typography>{row.active.toString()}</Typography>,
},
{
id: "runCount",
content: t("pages.logs.table.headers.runCount"),
render: (row) => <Typography>{row.runCount}</Typography>,
},
{
id: "failCount",
content: t("pages.logs.table.headers.failCount"),
render: (row) => <Typography>{row.failCount}</Typography>,
id: "interval",
content: t("common.table.headers.interval"),
render: (row) => {
let interval;
if (row.monitorId.toString().includes("geo")) {
interval = row.monitorGeoInterval ?? 0;
} else {
interval = row.repeat ?? 0;
}
return <Typography sx={cellSx}>{prettyMilliseconds(interval)}</Typography>;
},
},
{
id: "lastRun",
content: t("pages.logs.table.headers.lastRunAt"),
render: (row) => <Typography>{formatTimestamp(row.lastRunAt)}</Typography>,
},
{
id: "lockedAt",
content: t("pages.logs.table.headers.lockedAt"),
render: (row) => <Typography>{formatTimestamp(row.lockedAt)}</Typography>,
},
{
id: "lastFinish",
content: t("pages.logs.table.headers.lastFinishedAt"),
render: (row) => <Typography>{formatTimestamp(row.lastFinishedAt)}</Typography>,
render: (row) => {
const v = formatTimestamp(row.lastRunAt) ?? "-";
return (
<Typography
title={v}
sx={cellSx}
>
{v}
</Typography>
);
},
},
{
id: "lastRunTook",
content: t("pages.logs.table.headers.lastRunTook"),
render: (row) => {
const value = row.lastRunTook ? prettyMilliseconds(row.lastRunTook) : "-";
return <Typography>{value}</Typography>;
return <Typography sx={cellSx}>{value}</Typography>;
},
},
{
id: "lockedAt",
content: t("pages.logs.table.headers.lockedAt"),
render: (row) => {
const v = formatTimestamp(row.lockedAt) ?? "-";
return (
<Typography
title={v}
sx={cellSx}
>
{v}
</Typography>
);
},
},
];
@@ -92,7 +135,10 @@ export const TableJobs = ({ jobs }: TableJobsProps) => {
data={jobsWithId}
getRowSx={(row) => ({
...(row.lockedAt && {
"& td": { backgroundColor: theme.palette.success.light },
"& td": { backgroundColor: theme.palette.rowStatus.running },
}),
...(row.monitorActive === false && {
"& td": { backgroundColor: theme.palette.rowStatus.paused },
}),
})}
/>
@@ -106,6 +152,7 @@ interface TableFailedJobsProps {
}
export const TableFailedJobs = ({ metrics }: TableFailedJobsProps) => {
const { t } = useTranslation();
const theme = useTheme();
if (!metrics) {
return null;
}
@@ -122,7 +169,11 @@ export const TableFailedJobs = ({ metrics }: TableFailedJobsProps) => {
id: "monitorId",
content: t("common.table.headers.monitorId"),
render: (row) => {
return <Typography fontFamily={"monospace"}>{row.monitorId}</Typography>;
return (
<Typography fontFamily={theme.typography.fontFamilyMonospace}>
{row.monitorId}
</Typography>
);
},
},
{
@@ -45,6 +45,7 @@ const LevelBadge = ({ level }: { level: LogLevel }) => {
export const TableLogs = ({ logs, logCount, page, setPage }: TableLogsProps) => {
const { t } = useTranslation();
const theme = useTheme();
const dispatch = useDispatch();
const rowsPerPage = useSelector(
(state: RootState) => state?.ui?.logs?.rowsPerPage ?? 15
@@ -55,7 +56,7 @@ export const TableLogs = ({ logs, logCount, page, setPage }: TableLogsProps) =>
id: "timestamp",
content: t("pages.logs.table.headers.timestamp"),
render: (row) => (
<Typography sx={{ fontFamily: "monospace" }}>
<Typography sx={{ fontFamily: theme.typography.fontFamilyMonospace }}>
{formatTimestamp(row.timestamp)}
</Typography>
),
@@ -74,7 +75,9 @@ export const TableLogs = ({ logs, logCount, page, setPage }: TableLogsProps) =>
id: "method",
content: t("pages.logs.table.headers.method"),
render: (row) => (
<Typography fontFamily={"monospace"}>{row.method || "-"}</Typography>
<Typography fontFamily={theme.typography.fontFamilyMonospace}>
{row.method || "-"}
</Typography>
),
},
{
+1 -1
View File
@@ -10,7 +10,7 @@ const LogsPage = () => {
const { t } = useTranslation();
const [activeTab, setActiveTab] = useState<number>(1);
return (
<BasePage>
<BasePage headerKey="logs">
<Tabs
value={activeTab}
onChange={(_, newValue: number) => setActiveTab(newValue)}
@@ -85,10 +85,6 @@ const CreateMaintenanceWindowPage = () => {
repeat,
};
if (repeat === 0) {
payload.expiry = endDateTime.toISOString();
}
return payload;
};
+2 -3
View File
@@ -32,11 +32,10 @@ const MaintenanceWindowPage = () => {
return (
<BasePageWithStates
headerKey="maintenanceWindow"
page={t("pages.maintenanceWindow.fallback.title")}
totalCount={maintenanceWindowCount}
bullets={
t("pages.maintenanceWindow.fallback.checks", { returnObjects: true }) as string[]
}
description={t("pages.maintenanceWindow.fallback.description")}
loading={isLoading}
error={!!error}
actionButtonText={t("pages.maintenanceWindow.fallback.actionButton")}
@@ -1,7 +1,10 @@
import { ActionsMenu, type ActionMenuItem } from "@/Components/actions-menu";
import Box from "@mui/material/Box";
import Typography from "@mui/material/Typography";
import type { Header } from "@/Components/design-elements/Table";
import { Table } from "@/Components/design-elements";
import { Pagination } from "@/Components/design-elements/Table";
import { useClientPagination } from "@/Hooks/useClientPagination";
import type { Notification } from "@/Types/Notification";
import { useNavigate } from "react-router";
@@ -20,6 +23,7 @@ export const NotificationsTable = ({
const navigate = useNavigate();
const { t } = useTranslation();
const theme = useTheme();
const { pagedRows, paginationProps } = useClientPagination(notifications);
const getActions = (channel: Notification): ActionMenuItem[] => {
return [
@@ -68,7 +72,23 @@ export const NotificationsTable = ({
id: "destination",
content: t("pages.notifications.table.headers.destination"),
render: (row) => {
return <Typography>{row?.address}</Typography>;
return (
<Box sx={{ maxWidth: 320, mx: "auto" }}>
<Typography
title={row?.address}
sx={{
direction: "rtl",
textAlign: "left",
unicodeBidi: "plaintext",
whiteSpace: "nowrap",
overflow: "hidden",
textOverflow: "ellipsis",
}}
>
{row?.address}
</Typography>
</Box>
);
},
},
{
@@ -82,15 +102,16 @@ export const NotificationsTable = ({
return headers;
};
const headers = getHeaders();
return (
<Table
headers={headers}
data={notifications}
onRowClick={(row) => {
navigate(`/notifications/configure/${row.id}`);
}}
/>
<>
<Table
headers={getHeaders()}
data={pagedRows}
onRowClick={(row) => {
navigate(`/notifications/configure/${row.id}`);
}}
/>
{notifications.length > 0 && <Pagination {...paginationProps} />}
</>
);
};
+155 -24
View File
@@ -147,30 +147,33 @@ const NotificationsCreatePage = () => {
/>
}
/>
{watchedType !== "matrix" && watchedType !== "telegram" && (
<ConfigBox
title={addressConfig.title}
subtitle={addressConfig.description}
rightContent={
<Controller
name="address"
control={control}
defaultValue={"address" in defaults ? defaults.address : ""}
render={({ field, fieldState }) => (
<TextField
{...field}
type="text"
fieldLabel={addressConfig.fieldLabel}
placeholder={addressConfig.placeholder}
fullWidth
error={!!fieldState.error}
helperText={fieldState.error?.message ?? ""}
/>
)}
/>
}
/>
)}
{watchedType !== "matrix" &&
watchedType !== "telegram" &&
watchedType !== "pushover" &&
watchedType !== "twilio" && (
<ConfigBox
title={addressConfig.title}
subtitle={addressConfig.description}
rightContent={
<Controller
name="address"
control={control}
defaultValue={"address" in defaults ? defaults.address : ""}
render={({ field, fieldState }) => (
<TextField
{...field}
type="text"
fieldLabel={addressConfig.fieldLabel}
placeholder={addressConfig.placeholder}
fullWidth
error={!!fieldState.error}
helperText={fieldState.error?.message ?? ""}
/>
)}
/>
}
/>
)}
{watchedType === "telegram" && (
<ConfigBox
title={t("pages.notifications.form.telegram.title")}
@@ -214,6 +217,134 @@ const NotificationsCreatePage = () => {
}
/>
)}
{watchedType === "pushover" && (
<ConfigBox
title={t("pages.notifications.form.pushover.title")}
subtitle={t("pages.notifications.form.pushover.description")}
rightContent={
<Stack spacing={theme.spacing(8)}>
<Controller
name="accessToken"
control={control}
defaultValue={"accessToken" in defaults ? defaults.accessToken : ""}
render={({ field, fieldState }) => (
<TextField
{...field}
type="text"
fieldLabel={t("pages.notifications.form.pushover.optionAppToken")}
placeholder={t(
"pages.notifications.form.pushover.placeholderAppToken"
)}
fullWidth
error={!!fieldState.error}
helperText={fieldState.error?.message ?? ""}
/>
)}
/>
<Controller
name="address"
control={control}
defaultValue={"address" in defaults ? defaults.address : ""}
render={({ field, fieldState }) => (
<TextField
{...field}
type="text"
fieldLabel={t("pages.notifications.form.pushover.optionUserKey")}
placeholder={t(
"pages.notifications.form.pushover.placeholderUserKey"
)}
fullWidth
error={!!fieldState.error}
helperText={fieldState.error?.message ?? ""}
/>
)}
/>
</Stack>
}
/>
)}
{watchedType === "twilio" && (
<ConfigBox
title={t("pages.notifications.form.twilio.title")}
subtitle={t("pages.notifications.form.twilio.description")}
rightContent={
<Stack spacing={theme.spacing(8)}>
<Controller
name="accountSid"
control={control}
defaultValue={"accountSid" in defaults ? defaults.accountSid : ""}
render={({ field, fieldState }) => (
<TextField
{...field}
type="text"
fieldLabel={t("pages.notifications.form.twilio.optionAccountSid")}
placeholder={t(
"pages.notifications.form.twilio.placeholderAccountSid"
)}
fullWidth
error={!!fieldState.error}
helperText={fieldState.error?.message ?? ""}
/>
)}
/>
<Controller
name="accessToken"
control={control}
defaultValue={"accessToken" in defaults ? defaults.accessToken : ""}
render={({ field, fieldState }) => (
<TextField
{...field}
type="text"
fieldLabel={t("pages.notifications.form.twilio.optionAuthToken")}
placeholder={t(
"pages.notifications.form.twilio.placeholderAuthToken"
)}
fullWidth
error={!!fieldState.error}
helperText={fieldState.error?.message ?? ""}
/>
)}
/>
<Controller
name="twilioPhoneNumber"
control={control}
defaultValue={
"twilioPhoneNumber" in defaults ? defaults.twilioPhoneNumber : ""
}
render={({ field, fieldState }) => (
<TextField
{...field}
type="text"
fieldLabel={t("pages.notifications.form.twilio.optionFromNumber")}
placeholder={t(
"pages.notifications.form.twilio.placeholderFromNumber"
)}
fullWidth
error={!!fieldState.error}
helperText={fieldState.error?.message ?? ""}
/>
)}
/>
<Controller
name="phone"
control={control}
defaultValue={"phone" in defaults ? defaults.phone : ""}
render={({ field, fieldState }) => (
<TextField
{...field}
type="text"
fieldLabel={t("pages.notifications.form.twilio.optionToNumber")}
placeholder={t("pages.notifications.form.twilio.placeholderToNumber")}
fullWidth
error={!!fieldState.error}
helperText={fieldState.error?.message ?? ""}
/>
)}
/>
</Stack>
}
/>
)}
{watchedType === "matrix" && (
<ConfigBox
title={t("pages.notifications.form.matrix.title")}
+2 -3
View File
@@ -39,10 +39,9 @@ const NotificationsPage = () => {
return (
<BasePageWithStates
headerKey="notifications"
page={t("pages.notifications.fallback.title")}
bullets={
t("pages.notifications.fallback.checks", { returnObjects: true }) as string[]
}
description={t("pages.notifications.fallback.description")}
loading={isLoading || isValidating}
error={!!error}
totalCount={notifications?.length ?? 0}
@@ -77,6 +77,7 @@ const PageSpeedMonitorsPage = () => {
return (
<MonitorBasePageWithStates
headerKey="pageSpeed"
loading={isLoading}
error={monitorsError || settingsError}
totalCount={summary?.totalMonitors ?? 0}
+2 -1
View File
@@ -3,6 +3,7 @@ import Typography from "@mui/material/Typography";
import { useTheme } from "@mui/material/styles";
import { HeatmapResponseTime, HistogramResponseTime } from "@/Components/common";
import type { CheckSnapshot } from "@/Types/Check";
import { MAX_RECENT_CHECKS } from "@/Types/Monitor";
interface DummyChartProps {
chartType: string;
@@ -10,7 +11,7 @@ interface DummyChartProps {
const generateDummyChecks = (): CheckSnapshot[] => {
const checks: CheckSnapshot[] = [];
for (let i = 0; i < 25; i++) {
for (let i = 0; i < MAX_RECENT_CHECKS; i++) {
const isUp = Math.random() > 0.1;
const responseTime = Math.floor(Math.random() * 80) + 20;
checks.push({
+92 -48
View File
@@ -24,6 +24,7 @@ import type { SettingsFormData, SettingsFormInput } from "@/Validation/settings"
import { useState } from "react";
import { Controller } from "react-hook-form";
import { TextField, Button, FieldLabel, SliderWithLabel } from "@/Components/inputs";
import { languageNames } from "@/Components/inputs/LanguageSelector";
import { Box, Typography } from "@mui/material";
import { useDelete } from "@/Hooks/UseApi";
@@ -127,8 +128,8 @@ export const SettingsPage = () => {
timezoneOptions.find((tz) => tz.id === selectedTimezoneId) ?? null;
const handleTimezoneChange = (newValue: Timezone | null) => {
const newId = newValue?.id ?? "";
dispatch(setTimezone({ timezone: newId }));
if (!newValue?.id) return;
dispatch(setTimezone({ timezone: newValue.id }));
};
const handleModeChange = (e: SelectChangeEvent<ThemeMode>) => {
@@ -181,6 +182,9 @@ export const SettingsPage = () => {
systemEmailRequireTLS: formValues.systemEmailRequireTLS,
systemEmailRejectUnauthorized: formValues.systemEmailRejectUnauthorized,
...(formValues.systemEmailUser && { systemEmailUser: formValues.systemEmailUser }),
...(formValues.systemEmailDisplayName && {
systemEmailDisplayName: formValues.systemEmailDisplayName,
}),
...(formValues.systemEmailTLSServername && {
systemEmailTLSServername: formValues.systemEmailTLSServername,
}),
@@ -277,6 +281,7 @@ export const SettingsPage = () => {
return (
<BasePage
headerKey="settings"
component="form"
onSubmit={form.handleSubmit(onSubmit, onError)}
>
@@ -326,7 +331,7 @@ export const SettingsPage = () => {
key={lang}
value={lang}
>
{lang.toUpperCase()}
{languageNames[lang] ?? lang}
</MenuItem>
))}
</Select>
@@ -575,44 +580,65 @@ export const SettingsPage = () => {
href="https://nodemailer.com/smtp/"
target="_blank"
/>
<Box
component="pre"
sx={{
fontFamily: "monospace",
p: 2,
borderRadius: 1,
overflow: "auto",
backgroundColor: theme.palette.mode === "dark" ? "#1e1e1e" : "#f5f5f5",
}}
>
<code>
{JSON.stringify(
{
host: form.watch("systemEmailHost") || "",
port: form.watch("systemEmailPort") || "",
secure: form.watch("systemEmailSecure") ?? false,
auth: {
user:
form.watch("systemEmailUser") ||
form.watch("systemEmailAddress") ||
"",
pass: "<your_password>",
},
name: form.watch("systemEmailConnectionHost") || "localhost",
pool: form.watch("systemEmailPool") ?? false,
tls: {
rejectUnauthorized:
form.watch("systemEmailRejectUnauthorized") ?? true,
ignoreTLS: form.watch("systemEmailIgnoreTLS") ?? false,
requireTLS: form.watch("systemEmailRequireTLS") ?? false,
servername: form.watch("systemEmailTLSServername") || "",
},
},
null,
2
)}
</code>
</Box>
{(() => {
const address = form.watch("systemEmailAddress") || "";
const displayName = form.watch("systemEmailDisplayName")?.trim();
return (
<>
<Box
component="pre"
p={2}
borderRadius={theme.shape.borderRadius}
bgcolor={theme.palette.action.hover}
sx={{
fontFamily: theme.typography.fontFamilyMonospace,
overflow: "auto",
}}
>
<code>
{JSON.stringify(
{
host: form.watch("systemEmailHost") || "",
port: form.watch("systemEmailPort") || "",
secure: form.watch("systemEmailSecure") ?? false,
auth: {
user: form.watch("systemEmailUser") || address,
pass: "<your_password>",
},
name: form.watch("systemEmailConnectionHost") || "localhost",
pool: form.watch("systemEmailPool") ?? false,
tls: {
rejectUnauthorized:
form.watch("systemEmailRejectUnauthorized") ?? true,
ignoreTLS: form.watch("systemEmailIgnoreTLS") ?? false,
requireTLS: form.watch("systemEmailRequireTLS") ?? false,
servername: form.watch("systemEmailTLSServername") || "",
},
},
null,
2
)}
</code>
</Box>
{address && (
<Box
component="pre"
p={2}
borderRadius={theme.shape.borderRadius}
bgcolor={theme.palette.action.hover}
sx={{
fontFamily: theme.typography.fontFamilyMonospace,
overflow: "auto",
}}
>
<code>
{`From: ${displayName ? `"${displayName}" <${address}>` : address}`}
</code>
</Box>
)}
</>
);
})()}
</Stack>
}
rightContent={
@@ -678,6 +704,24 @@ export const SettingsPage = () => {
)}
/>
{/* Email Display Name (Optional) */}
<Controller
name="systemEmailDisplayName"
control={form.control}
render={({ field, fieldState }) => (
<TextField
{...field}
value={field.value ?? ""}
fieldLabel={t("pages.settings.form.email.option.displayName.label")}
placeholder={t(
"pages.settings.form.email.option.displayName.placeholder"
)}
error={!!fieldState.error}
helperText={fieldState.error?.message}
/>
)}
/>
{/* Email User (Optional) */}
<Controller
name="systemEmailUser"
@@ -972,21 +1016,21 @@ export const SettingsPage = () => {
setIsDemoMonitorsDialogOpen(false);
}}
loading={isDeletingAllMonitors}
/>
confirmColor="error"
confirmText={t("common.buttons.removeMonitors")}
>
<Typography variant="body1">
{t("pages.settings.form.removeMonitors.dialog.paragraph")}
</Typography>
</Dialog>
{/* Sticky Save Button */}
<Stack
direction="row"
justifyContent="flex-end"
sx={{
position: "sticky",
bottom: 0,
backgroundColor: theme.palette.background.paper,
borderTop: `1px solid ${theme.palette.divider}`,
padding: theme.spacing(LAYOUT.MD),
marginLeft: theme.spacing(-LAYOUT.MD),
marginRight: theme.spacing(-LAYOUT.MD),
marginBottom: theme.spacing(-LAYOUT.MD),
zIndex: 1000,
}}
>
@@ -0,0 +1,66 @@
import { useTranslation } from "react-i18next";
import Stack from "@mui/material/Stack";
import RadioGroup from "@mui/material/RadioGroup";
import MenuItem from "@mui/material/MenuItem";
import { useTheme } from "@mui/material/styles";
import { RadioWithDescription, Select } from "@/Components/inputs";
import {
STATUS_PAGE_THEMES,
STATUS_PAGE_THEME_MODES,
type StatusPageTheme,
type StatusPageThemeMode,
} from "@/Types/StatusPage";
interface Props {
theme: StatusPageTheme;
themeMode: StatusPageThemeMode;
onThemeChange: (value: StatusPageTheme) => void;
onThemeModeChange: (value: StatusPageThemeMode) => void;
}
export const ThemePicker = ({
theme,
themeMode,
onThemeChange,
onThemeModeChange,
}: Props) => {
const { t } = useTranslation();
const muiTheme = useTheme();
return (
<Stack spacing={muiTheme.spacing(6)}>
<RadioGroup
value={theme}
onChange={(_, value) => onThemeChange(value as StatusPageTheme)}
>
<Stack spacing={muiTheme.spacing(4)}>
{STATUS_PAGE_THEMES.map((option) => (
<RadioWithDescription
key={option}
value={option}
label={t(`pages.statusPages.form.theme.options.${option}.name`)}
description={t(
`pages.statusPages.form.theme.options.${option}.description`
)}
/>
))}
</Stack>
</RadioGroup>
<Select
fieldLabel={t("pages.statusPages.form.themeMode.label")}
value={themeMode}
onChange={(e) => onThemeModeChange(e.target.value as StatusPageThemeMode)}
>
{STATUS_PAGE_THEME_MODES.map((mode) => (
<MenuItem
key={mode}
value={mode}
>
{t(`pages.statusPages.form.themeMode.${mode}`)}
</MenuItem>
))}
</Select>
</Stack>
);
};
+36 -3
View File
@@ -32,8 +32,10 @@ import type { MonitorDisplayType, StatusPageResponse } from "@/Types/StatusPage"
import { getMonitorTypeLabel } from "@/Types/StatusPage";
import timezones from "@/Utils/timezones.json";
import { useNavigate, useParams } from "react-router-dom";
import { mutate } from "swr";
import axios from "axios";
import { HeaderConfigStatusControls } from "./Components/HeaderConfigStatusControls";
import { ThemePicker } from "./Components/ThemePicker";
const monitorsUrl = (() => {
const params = new URLSearchParams();
@@ -48,6 +50,9 @@ interface TimezoneOption {
name: string;
}
const buildStatusPageKey = (slug: string | null | undefined) =>
slug ? `/status-page/${slug}?type=uptime&type=infrastructure` : null;
const CreateStatusPage = () => {
const theme = useTheme();
const { t } = useTranslation();
@@ -58,9 +63,7 @@ const CreateStatusPage = () => {
// Fetch existing status page data when configuring
const { data: statusPageData, isLoading: isLoadingStatusPage } =
useGet<StatusPageResponse>(
isCreate ? null : `/status-page/${url}?type=uptime&type=infrastructure`
);
useGet<StatusPageResponse>(isCreate ? null : buildStatusPageKey(url));
const { data: monitorsResponse } = useGet<Monitor[]>(monitorsUrl);
const monitors = monitorsResponse ?? [];
@@ -139,6 +142,8 @@ const CreateStatusPage = () => {
fd.append("showUptimePercentage", String(data.showUptimePercentage));
fd.append("showAdminLoginLink", String(data.showAdminLoginLink));
fd.append("showInfrastructure", String(data.showInfrastructure));
if (data.theme) fd.append("theme", data.theme);
if (data.themeMode) fd.append("themeMode", data.themeMode);
data.monitors.forEach((monitorId) => {
fd.append("monitors[]", monitorId);
@@ -178,6 +183,10 @@ const CreateStatusPage = () => {
}
if (result) {
await mutate(buildStatusPageKey(data.url));
if (!isCreate && url !== data.url) {
await mutate(buildStatusPageKey(url));
}
navigate(`/status/${data.url}`);
}
};
@@ -438,6 +447,30 @@ const CreateStatusPage = () => {
</Stack>
}
/>
<ConfigBox
title={t("pages.statusPages.form.theme.title")}
subtitle={t("pages.statusPages.form.theme.description")}
rightContent={
<Controller
name="theme"
control={control}
render={({ field: themeField }) => (
<Controller
name="themeMode"
control={control}
render={({ field: modeField }) => (
<ThemePicker
theme={themeField.value ?? "refined"}
themeMode={modeField.value ?? "auto"}
onThemeChange={themeField.onChange}
onThemeModeChange={modeField.onChange}
/>
)}
/>
)}
/>
}
/>
<ConfigBox
title={t("pages.statusPages.form.features.title")}
subtitle={t("pages.statusPages.form.features.description")}
@@ -8,7 +8,7 @@ import { Settings, ExternalLink } from "lucide-react";
import { useTheme } from "@mui/material";
import { useTranslation } from "react-i18next";
import { useNavigate } from "react-router-dom";
import type { StatusPage } from "@/Types/StatusPage";
import { PUBLIC_STATUS_PAGE_PREFIX, type StatusPage } from "@/Types/StatusPage";
interface HeaderStatusPageControlsProps {
isAdmin: boolean;
@@ -50,7 +50,7 @@ export const HeaderStatusPageControls = ({
<Typography
onClick={() => {
window.open(
`/status/public/${statusPage.url}`,
`${PUBLIC_STATUS_PAGE_PREFIX}/${statusPage.url}`,
"_blank",
"noopener,noreferrer"
);
@@ -1,246 +0,0 @@
import Stack from "@mui/material/Stack";
import Typography from "@mui/material/Typography";
import { Gauge } from "@/Components/design-elements";
import { useTheme } from "@mui/material/styles";
import { useTranslation } from "react-i18next";
import prettyBytes from "pretty-bytes";
import type { Monitor } from "@/Types/Monitor";
import Grid from "@mui/material/Grid";
import useMediaQuery from "@mui/material/useMediaQuery";
import Box from "@mui/material/Box";
import { LAYOUT, SPACING } from "@/Utils/Theme/constants";
const GAUGE_RADIUS = 60;
const GAUGE_STROKE_WIDTH = 12;
const PERCENTAGE_MULTIPLIER = 100;
interface StatusPageMonitor extends Monitor {
checks?: Monitor["recentChecks"];
}
interface MetricDetail {
label: string;
value: string;
}
interface MetricConfig {
key: string;
label: string;
hasData: boolean;
progress: number;
details: MetricDetail[];
}
const MetricDetailRow = ({ label, value }: MetricDetail) => {
const theme = useTheme();
return (
<Stack
direction="row"
justifyContent="space-between"
>
<Typography
variant="body2"
color={theme.palette.text.secondary}
>
{label}
</Typography>
<Typography variant="body2">{value}</Typography>
</Stack>
);
};
interface MetricItemProps {
label: string;
progress: number;
details?: MetricDetail[];
}
const MetricItem = ({ label, progress, details }: MetricItemProps) => {
const theme = useTheme();
const isSmall = useMediaQuery(theme.breakpoints.down("md"));
return (
<Grid
size={isSmall ? 12 : 4}
sx={{
display: "flex",
flexDirection: "column",
alignItems: "center",
textAlign: "center",
gap: theme.spacing(SPACING.LG),
padding: theme.spacing(LAYOUT.XS),
borderRight: isSmall ? "none" : `1px solid ${theme.palette.divider}`,
borderBottom: isSmall ? `1px solid ${theme.palette.divider}` : "none",
"&:last-child": {
borderRight: "none",
borderBottom: "none",
paddingBottom: theme.spacing(SPACING.LG),
},
}}
>
<Box
display="flex"
flexDirection="column"
alignItems="center"
>
<Gauge
progress={progress}
radius={GAUGE_RADIUS}
strokeWidth={GAUGE_STROKE_WIDTH}
/>
<Typography variant="body2">{label}</Typography>
</Box>
{details && details.length > 0 && (
<Box
width="100%"
paddingX={theme.spacing(LAYOUT.LG)}
>
{details.map((detail) => (
<MetricDetailRow
key={detail.label}
label={detail.label}
value={detail.value}
/>
))}
</Box>
)}
</Grid>
);
};
type LatestCheck = NonNullable<Monitor["recentChecks"]>[number];
const buildCpuMetric = (
check: LatestCheck,
t: (key: string) => string
): MetricConfig | null => {
if (!check.cpu || typeof check.cpu.usage_percent !== "number") {
return null;
}
const usagePercent = (check.cpu.usage_percent ?? 0) * PERCENTAGE_MULTIPLIER;
return {
key: "cpu",
label: t("pages.statusPages.monitorsList.infrastructure.cpu"),
hasData: true,
progress: usagePercent,
details: [
{
label: t("pages.statusPages.monitorsList.infrastructure.usage"),
value: `${usagePercent.toFixed(2)}%`,
},
],
};
};
const buildMemoryMetric = (
check: LatestCheck,
t: (key: string) => string
): MetricConfig | null => {
if (
!check.memory ||
typeof check.memory.usage_percent !== "number" ||
typeof check.memory.used_bytes !== "number" ||
typeof check.memory.total_bytes !== "number"
) {
return null;
}
return {
key: "memory",
label: t("pages.statusPages.monitorsList.infrastructure.memory"),
hasData: true,
progress: (check.memory.usage_percent ?? 0) * PERCENTAGE_MULTIPLIER,
details: [
{
label: t("pages.statusPages.monitorsList.infrastructure.used"),
value: prettyBytes(check.memory.used_bytes ?? 0),
},
{
label: t("pages.statusPages.monitorsList.infrastructure.total"),
value: prettyBytes(check.memory.total_bytes ?? 0),
},
],
};
};
const buildDiskMetric = (
check: LatestCheck,
t: (key: string) => string
): MetricConfig | null => {
if (!check.disk || check.disk.length === 0) {
return null;
}
const disks = check.disk;
const avgUsagePercent =
(disks.reduce((acc, disk) => acc + (disk?.usage_percent ?? 0), 0) / disks.length) *
PERCENTAGE_MULTIPLIER;
const totalUsedBytes = disks.reduce((acc, disk) => acc + (disk?.used_bytes ?? 0), 0);
const totalTotalBytes = disks.reduce((acc, disk) => acc + (disk?.total_bytes ?? 0), 0);
return {
key: "disk",
label: t("pages.statusPages.monitorsList.infrastructure.disk"),
hasData: true,
progress: avgUsagePercent,
details: [
{
label: t("pages.statusPages.monitorsList.infrastructure.used"),
value: prettyBytes(totalUsedBytes),
},
{
label: t("pages.statusPages.monitorsList.infrastructure.total"),
value: prettyBytes(totalTotalBytes),
},
],
};
};
export const InfrastructureMetrics = ({ monitor }: { monitor: StatusPageMonitor }) => {
const theme = useTheme();
const { t } = useTranslation();
const latestCheck = monitor.recentChecks?.[0] ?? monitor.checks?.[0];
if (!latestCheck) {
return (
<Typography
variant="body2"
color={theme.palette.text.secondary}
>
{t("pages.statusPages.monitorsList.noData")}
</Typography>
);
}
const metrics: MetricConfig[] = [
buildCpuMetric(latestCheck, t),
buildMemoryMetric(latestCheck, t),
buildDiskMetric(latestCheck, t),
].filter((m): m is MetricConfig => m !== null);
if (metrics.length === 0) {
return (
<Typography
variant="body2"
color={theme.palette.text.secondary}
>
{t("pages.statusPages.monitorsList.noData")}
</Typography>
);
}
return (
<Grid
container
alignItems="center"
padding={theme.spacing(LAYOUT.XS)}
>
{metrics.map(({ key, label, progress, details }) => (
<MetricItem
key={key}
label={label}
progress={progress}
details={details}
/>
))}
</Grid>
);
};
@@ -1,171 +0,0 @@
import Stack from "@mui/material/Stack";
import { useTranslation } from "react-i18next";
import Box from "@mui/material/Box";
import Typography from "@mui/material/Typography";
import { HistogramResponseTime, HeatmapResponseTime } from "@/Components/common";
import { StatusLabel, BaseBox } from "@/Components/design-elements";
import { SwitchComponent } from "@/Components/inputs";
import { InfrastructureMetrics } from "@/Pages/StatusPage/Status/Components/InfrastructureMetrics";
import { useTheme, type Theme } from "@mui/material/styles";
import { useSelector } from "react-redux";
import { useState } from "react";
import type { Monitor } from "@/Types/Monitor";
import type { StatusPage } from "@/Types/StatusPage";
import { getMonitorTypeLabel } from "@/Types/StatusPage";
import type { RootState } from "@/Types/state";
import { LAYOUT, SPACING } from "@/Utils/Theme/constants";
interface StatusPageMonitor extends Monitor {
checks?: Monitor["recentChecks"];
}
interface MonitorsListProps {
statusPage: StatusPage;
monitors: StatusPageMonitor[];
}
const getMonitorBadgeStyles = (monitorType: string, theme: Theme) => {
const bg =
monitorType === "hardware" ? theme.palette.info.light : theme.palette.success.light;
return {
backgroundColor: bg,
color: theme.palette.background.paper,
padding: `${theme.spacing(SPACING.SM)} ${theme.spacing(SPACING.LG)}`,
borderRadius: theme.shape.borderRadius,
};
};
const MonitorHeader = ({
monitor,
showURL,
}: {
monitor: StatusPageMonitor;
showURL: boolean;
}) => {
const theme = useTheme();
const { t } = useTranslation();
return (
<Stack
direction="row"
alignItems="center"
justifyContent="space-between"
gap={theme.spacing(LAYOUT.XS)}
mb={theme.spacing(LAYOUT.XS)}
>
<Box sx={{ overflow: "hidden", minWidth: 0, flex: 1 }}>
<Stack
direction="row"
alignItems="center"
gap={theme.spacing(SPACING.LG)}
mb={theme.spacing(SPACING.SM)}
>
<Typography
variant="h6"
sx={{ overflow: "hidden", textOverflow: "ellipsis", whiteSpace: "nowrap" }}
>
{monitor.name}
</Typography>
<Typography
variant="caption"
sx={getMonitorBadgeStyles(monitor.type ?? "", theme)}
>
{getMonitorTypeLabel(monitor.type, t)}
</Typography>
</Stack>
{showURL && monitor.url && (
<Typography
variant="body2"
color={theme.palette.text.secondary}
sx={{ overflow: "hidden", textOverflow: "ellipsis", whiteSpace: "nowrap" }}
>
{monitor.url}
</Typography>
)}
</Box>
<StatusLabel status={monitor.status} />
</Stack>
);
};
const MonitorContent = ({
monitor,
statusPage,
chartType,
}: {
monitor: StatusPageMonitor;
statusPage: StatusPage;
chartType: string;
}) => {
const theme = useTheme();
if (monitor.type === "hardware") {
if (statusPage.showInfrastructure === false) return null;
return <InfrastructureMetrics monitor={monitor} />;
}
if (statusPage.showCharts === false) return null;
const checks = monitor.checks?.slice().reverse() ?? [];
return (
<Box sx={{ overflow: "hidden", minWidth: 0, flex: 1, mb: theme.spacing(SPACING.LG) }}>
{chartType === "histogram" ? (
<HistogramResponseTime
height={{ xs: 50, md: 100 }}
gap={{ xs: theme.spacing(SPACING.SM), md: theme.spacing(LAYOUT.SM) }}
checks={checks}
/>
) : (
<HeatmapResponseTime checks={checks} />
)}
</Box>
);
};
export const MonitorsList = ({ statusPage, monitors }: MonitorsListProps) => {
const theme = useTheme();
const { t } = useTranslation();
const showURL = useSelector((state: RootState) => state.ui?.showURL);
const [chartType, setChartType] = useState<"histogram" | "heatmap">("histogram");
return (
<Stack gap={theme.spacing(LAYOUT.MD)}>
{statusPage.showCharts && (
<Stack
direction="row"
alignItems="center"
gap={theme.spacing(LAYOUT.SM)}
>
<Typography>{t("pages.statusPages.monitorsList.chartTypeHeatmap")}</Typography>
<SwitchComponent
dualOption
value={chartType}
checked={chartType === "histogram"}
onChange={(e) => setChartType(e.target.checked ? "histogram" : "heatmap")}
/>
<Typography>
{t("pages.statusPages.monitorsList.chartTypeHistogram")}
</Typography>
</Stack>
)}
{monitors.map((monitor) => (
<BaseBox
key={monitor.id}
padding={theme.spacing(LAYOUT.MD)}
>
<MonitorHeader
monitor={monitor}
showURL={showURL}
/>
<MonitorContent
monitor={monitor}
statusPage={statusPage}
chartType={chartType}
/>
</BaseBox>
))}
</Stack>
);
};
@@ -1,58 +0,0 @@
import { AlertTriangle, CircleCheck } from "lucide-react";
import Stack from "@mui/material/Stack";
import Typography from "@mui/material/Typography";
import { useTranslation } from "react-i18next";
import type { Theme } from "@mui/material";
import { useTheme } from "@mui/material";
import type { Monitor } from "@/Types/Monitor";
const getMonitorStatus = (monitors: Monitor[], theme: Theme, t: Function) => {
const monitorsStatus: Record<string, any> = {
icon: <AlertTriangle size={24} />,
};
if (monitors.every((monitor) => monitor.status === "up")) {
monitorsStatus.msg = t("pages.statusPages.statusBar.allUp");
monitorsStatus.color = theme.palette.success.main;
monitorsStatus.icon = <CircleCheck size={24} />;
return monitorsStatus;
} else if (monitors.every((monitor) => monitor.status === "down")) {
monitorsStatus.msg = t("pages.statusPages.statusBar.allDown");
monitorsStatus.color = theme.palette.error.main;
return monitorsStatus;
} else if (monitors.some((monitor) => monitor.status === "down")) {
monitorsStatus.msg = t("pages.statusPages.statusBar.degraded");
monitorsStatus.color = theme.palette.warning.main;
return monitorsStatus;
} else {
monitorsStatus.msg = t("pages.statusPages.statusBar.unknown");
monitorsStatus.color = theme.palette.warning.main;
return monitorsStatus;
}
};
interface StatusBarProps {
monitors: Monitor[];
}
export const StatusBar = ({ monitors }: StatusBarProps) => {
const theme = useTheme();
const { t } = useTranslation();
const monitorsStatus = getMonitorStatus(monitors, theme, t);
return (
<Stack
direction="row"
alignItems="center"
justifyContent="center"
gap={theme.spacing(2)}
height={theme.spacing(30)}
bgcolor={monitorsStatus.color}
borderRadius={theme.shape.borderRadius}
>
{monitorsStatus.icon}
<Typography>{monitorsStatus.msg}</Typography>
</Stack>
);
};
+86 -38
View File
@@ -1,18 +1,64 @@
import { BasePage, BaseFallback } from "@/Components/design-elements";
import { StatusBar } from "@/Pages/StatusPage/Status/Components/StatusBar";
import { MonitorsList } from "@/Pages/StatusPage/Status/Components/MonitorsList";
import Typography from "@mui/material/Typography";
import { Link } from "react-router-dom";
import Stack from "@mui/material/Stack";
import Box from "@mui/material/Box";
import { useMediaQuery, useTheme } from "@mui/material";
import { useTheme } from "@mui/material";
import { useTranslation } from "react-i18next";
import { useIsAdmin } from "@/Hooks/useIsAdmin";
import { useLocation, useParams } from "react-router-dom";
import { useGet } from "@/Hooks/UseApi";
import type { StatusPageResponse } from "@/Types/StatusPage";
import { HeaderStatusPageControls } from "./Components/HeaderStatusPageControls";
import { resolveStatusPageTheme } from "@/Types/StatusPage";
import {
PUBLIC_STATUS_PAGE_PREFIX,
type StatusPageResponse,
type StatusPageTheme,
} from "@/Types/StatusPage";
import { HeaderStatusPageControls } from "@/Pages/StatusPage/Status/Components/HeaderStatusPageControls";
import { StatusPageThemeProvider } from "@/Pages/StatusPage/Status/themes/StatusPageThemeProvider";
import {
BaseStatusPage,
type ThemeConfig,
} from "@/Pages/StatusPage/Status/themes/shared/BaseStatusPage";
import { BrowserFrame } from "@/Pages/StatusPage/Status/themes/BrowserFrame";
import { refinedStyles } from "@/Pages/StatusPage/Status/themes/refined/styles";
import { RefinedHeader } from "@/Pages/StatusPage/Status/themes/refined/RefinedHeader";
import { RefinedHero } from "@/Pages/StatusPage/Status/themes/refined/RefinedHero";
import { modernStyles } from "@/Pages/StatusPage/Status/themes/modern/styles";
import { ModernHeader } from "@/Pages/StatusPage/Status/themes/modern/ModernHeader";
import { ModernHero } from "@/Pages/StatusPage/Status/themes/modern/ModernHero";
import { boldStyles } from "@/Pages/StatusPage/Status/themes/bold/styles";
import { BoldHeader } from "@/Pages/StatusPage/Status/themes/bold/BoldHeader";
import { BoldHero } from "@/Pages/StatusPage/Status/themes/bold/BoldHero";
import { editorialStyles } from "@/Pages/StatusPage/Status/themes/editorial/styles";
import { EditorialHeader } from "@/Pages/StatusPage/Status/themes/editorial/EditorialHeader";
import { EditorialHero } from "@/Pages/StatusPage/Status/themes/editorial/EditorialHero";
const THEME_CONFIGS: Record<StatusPageTheme, ThemeConfig<any>> = {
refined: {
createStyles: refinedStyles,
HeaderSlot: RefinedHeader,
HeroSlot: RefinedHero,
},
modern: {
createStyles: modernStyles,
HeaderSlot: ModernHeader,
HeroSlot: ModernHero,
overallStatusOptions: { iconSize: 20 },
},
bold: {
createStyles: boldStyles,
HeaderSlot: BoldHeader,
HeroSlot: BoldHero,
overallStatusOptions: { iconSize: 18 },
},
editorial: {
createStyles: editorialStyles,
HeaderSlot: EditorialHeader,
HeroSlot: EditorialHero,
overallStatusOptions: { allUpKey: "pages.statusPages.editorial.allUp" },
},
};
const StatusPageView = () => {
const theme = useTheme();
@@ -20,8 +66,8 @@ const StatusPageView = () => {
const { url } = useParams();
const isAdmin = useIsAdmin();
const location = useLocation();
const isSmall = useMediaQuery(theme.breakpoints.down("md"));
const isPublic = location.pathname.startsWith("/status/public");
const isPublic = location.pathname.startsWith(PUBLIC_STATUS_PAGE_PREFIX);
const apiUrl = url ? `/status-page/${url}?type=uptime&type=infrastructure` : null;
@@ -65,48 +111,50 @@ const StatusPageView = () => {
);
}
let sx: React.CSSProperties = {};
const themeConfig = THEME_CONFIGS[resolveStatusPageTheme(statusPage.theme)];
const themedRenderer = (
<BaseStatusPage
statusPage={statusPage}
monitors={monitors}
config={themeConfig}
/>
);
// Public route: render directly on the viewport, themed background covers everything.
if (isPublic) {
sx.paddingTop = theme.spacing(20);
sx.paddingLeft = isSmall ? "5vw" : "20vw";
sx.paddingRight = isSmall ? "5vw" : "20vw";
return (
<StatusPageThemeProvider
theme={statusPage.theme}
themeMode={statusPage.themeMode}
timezone={statusPage.timezone}
paintBody
>
{themedRenderer}
</StatusPageThemeProvider>
);
}
const logoSrc = statusPage.logo?.data
? `data:${statusPage.logo.contentType};base64,${statusPage.logo.data}`
: null;
const publicUrl = `${window.location.origin}${PUBLIC_STATUS_PAGE_PREFIX}/${statusPage.url}`;
return (
<BasePage
loading={isLoading}
error={error}
sx={sx}
breadcrumbOverride={isPublic ? [] : undefined}
breadcrumbOverride={undefined}
sx={{ flex: 1, minHeight: 0 }}
>
<HeaderStatusPageControls
isAdmin={isAdmin}
statusPage={statusPage}
isPublic={isPublic}
/>
{logoSrc && (
<Box
component="img"
src={logoSrc}
alignSelf={"flex-start"}
alt={statusPage.companyName}
sx={{
maxHeight: 120,
maxWidth: "100%",
objectFit: "contain",
mb: 2,
}}
/>
)}
<StatusBar monitors={monitors} />
<MonitorsList
statusPage={statusPage}
monitors={monitors}
isPublic={false}
/>
<StatusPageThemeProvider
theme={statusPage.theme}
themeMode={statusPage.themeMode}
timezone={statusPage.timezone}
transparent
>
<BrowserFrame url={publicUrl}>{themedRenderer}</BrowserFrame>
</StatusPageThemeProvider>
</BasePage>
);
};
@@ -0,0 +1,70 @@
import type { ReactNode } from "react";
import Box from "@mui/material/Box";
import { useStatusPageTheme } from "./StatusPageThemeProvider";
interface Props {
url: string;
children: ReactNode;
}
export const BrowserFrame = ({ url, children }: Props) => {
const { tokens } = useStatusPageTheme();
return (
<Box
sx={{
width: "100%",
minHeight: "calc(100vh - 180px)",
display: "flex",
flexDirection: "column",
borderRadius: "12px",
overflow: "hidden",
border: `1px solid ${tokens.border}`,
background: tokens.surface,
color: tokens.text,
}}
>
<Box
sx={{
display: "flex",
alignItems: "center",
gap: 1.25,
padding: "10px 14px",
background: tokens.bg,
borderBottom: `1px solid ${tokens.border}`,
}}
>
<Box sx={{ display: "flex", gap: 0.75 }}>
<Box
sx={{ width: 12, height: 12, borderRadius: "50%", background: "#ff5f57" }}
/>
<Box
sx={{ width: 12, height: 12, borderRadius: "50%", background: "#febc2e" }}
/>
<Box
sx={{ width: 12, height: 12, borderRadius: "50%", background: "#28c840" }}
/>
</Box>
<Box
sx={{
flex: 1,
padding: "4px 12px",
marginLeft: 2,
borderRadius: "6px",
background: tokens.surface,
border: `1px solid ${tokens.border}`,
fontFamily: "ui-monospace, Menlo, monospace",
fontSize: 12,
color: tokens.textMuted,
overflow: "hidden",
textOverflow: "ellipsis",
whiteSpace: "nowrap",
}}
>
{url}
</Box>
</Box>
<Box sx={{ flex: 1, minHeight: 0 }}>{children}</Box>
</Box>
);
};
@@ -0,0 +1,153 @@
import {
createContext,
useContext,
useEffect,
useMemo,
useState,
type ReactNode,
} from "react";
import {
DEFAULT_STATUS_PAGE_THEME,
resolveStatusPageTheme,
resolveStatusPageThemeMode,
type StatusPageTheme,
type StatusPageThemeMode,
} from "@/Types/StatusPage";
import { resolveTimezone } from "@/Utils/TimeUtils";
import { themeTokens, type StatusPageThemeTokens } from "./tokens";
type ResolvedMode = "light" | "dark";
interface StatusPageThemeContextValue {
theme: StatusPageTheme;
mode: ResolvedMode;
tokens: StatusPageThemeTokens;
timezone: string;
}
const StatusPageThemeContext = createContext<StatusPageThemeContextValue | null>(null);
const resolveSystemMode = (): ResolvedMode => {
if (typeof window === "undefined" || !window.matchMedia) return "light";
return window.matchMedia("(prefers-color-scheme: dark)").matches ? "dark" : "light";
};
interface Props {
theme?: StatusPageTheme;
themeMode?: StatusPageThemeMode;
timezone?: string;
/**
* When true, paint the document body with the theme background so it
* covers beyond the provider's wrapper (useful on the public route
* where no admin shell sits behind). Defaults to false for admin
* previews that already have an app background.
*/
paintBody?: boolean;
/**
* When true, the provider's wrapper div does NOT paint its own
* background or set a min-height. Use this when the wrapper is
* inside a clipped container (e.g. BrowserFrame) that already sets
* the visible background, so the wrapper's bg doesn't bleed past the
* container's rounded corners.
*/
transparent?: boolean;
children: ReactNode;
}
export const StatusPageThemeProvider = ({
theme,
themeMode,
timezone,
paintBody = false,
transparent = false,
children,
}: Props) => {
const resolvedTheme = resolveStatusPageTheme(theme);
const resolvedThemeMode = resolveStatusPageThemeMode(themeMode);
const resolvedTimezone = resolveTimezone(timezone);
const [systemMode, setSystemMode] = useState<ResolvedMode>(resolveSystemMode);
useEffect(() => {
if (resolvedThemeMode !== "auto") return;
if (typeof window === "undefined" || !window.matchMedia) return;
const mq = window.matchMedia("(prefers-color-scheme: dark)");
// Reconcile on first paint whenever we (re-)enter auto mode — only
// when it disagrees with the stored value, so we don't trigger an
// extra render on the common hot path.
const next: ResolvedMode = mq.matches ? "dark" : "light";
setSystemMode((prev) => (prev === next ? prev : next));
const handler = (e: MediaQueryListEvent) =>
setSystemMode(e.matches ? "dark" : "light");
mq.addEventListener("change", handler);
return () => mq.removeEventListener("change", handler);
}, [resolvedThemeMode]);
useEffect(() => {
if (!paintBody || typeof document === "undefined") return;
const html = document.documentElement;
const body = document.body;
const prev = {
htmlBg: html.style.background,
bodyBg: body.style.background,
bodyMargin: body.style.margin,
htmlColorScheme: html.style.colorScheme,
};
const resolvedMode = resolvedThemeMode === "auto" ? systemMode : resolvedThemeMode;
const bg = themeTokens[resolvedTheme][resolvedMode].bg;
html.style.background = bg;
body.style.background = bg;
body.style.margin = "0";
html.style.colorScheme = resolvedMode;
return () => {
html.style.background = prev.htmlBg;
body.style.background = prev.bodyBg;
body.style.margin = prev.bodyMargin;
html.style.colorScheme = prev.htmlColorScheme;
};
}, [paintBody, resolvedTheme, resolvedThemeMode, systemMode]);
const resolvedMode: ResolvedMode =
resolvedThemeMode === "auto" ? systemMode : resolvedThemeMode;
const tokens = themeTokens[resolvedTheme][resolvedMode];
const value = useMemo(
() => ({
theme: resolvedTheme,
mode: resolvedMode,
tokens,
timezone: resolvedTimezone,
}),
[resolvedTheme, resolvedMode, tokens, resolvedTimezone]
);
return (
<StatusPageThemeContext.Provider value={value}>
<div
style={
transparent
? undefined
: {
background: tokens.bg,
minHeight: "100vh",
}
}
>
{children}
</div>
</StatusPageThemeContext.Provider>
);
};
export const useStatusPageTheme = (): StatusPageThemeContextValue => {
const ctx = useContext(StatusPageThemeContext);
if (!ctx) {
return {
theme: DEFAULT_STATUS_PAGE_THEME,
mode: resolveSystemMode(),
tokens: themeTokens[DEFAULT_STATUS_PAGE_THEME][resolveSystemMode()],
timezone: resolveTimezone(),
};
}
return ctx;
};
@@ -0,0 +1,20 @@
import Box from "@mui/material/Box";
import { monoFirstChar } from "@/Pages/StatusPage/Status/themes/shared/overallStatus";
import type { SlotProps } from "@/Pages/StatusPage/Status/themes/shared/BaseStatusPage";
import type { BoldStyles } from "@/Pages/StatusPage/Status/themes/bold/styles";
export const BoldHeader = ({ statusPage, logoSrc, styles }: SlotProps<BoldStyles>) => (
<Box sx={styles.brand}>
{logoSrc ? (
<Box
component="img"
src={logoSrc}
alt={statusPage.companyName}
sx={styles.logoImg}
/>
) : (
<Box sx={styles.logoConic}>{monoFirstChar(statusPage.companyName)}</Box>
)}
{statusPage.companyName}
</Box>
);
@@ -0,0 +1,32 @@
import Box from "@mui/material/Box";
import { useTranslation } from "react-i18next";
import type { SlotProps } from "@/Pages/StatusPage/Status/themes/shared/BaseStatusPage";
import type { BoldStyles } from "@/Pages/StatusPage/Status/themes/bold/styles";
export const BoldHero = ({ overall, monitorCount, styles }: SlotProps<BoldStyles>) => {
const { t } = useTranslation();
return (
<Box sx={styles.hero}>
<Box
component="h1"
sx={styles.heroTitle}
>
<Box
component="span"
sx={styles.heroCheck(overall.tone)}
>
{overall.icon}
</Box>
{overall.message}
</Box>
<Box
component="p"
sx={styles.heroSub}
>
{t("pages.statusPages.statusBar.monitoringSummary", {
count: monitorCount,
})}
</Box>
</Box>
);
};
@@ -0,0 +1,405 @@
import type { SxProps, Theme } from "@mui/material/styles";
import type { StatusPageThemeTokens } from "../tokens";
import { type OverallTone, toneColor, toneSoft } from "../shared/overallStatus";
import { BOLD_SANS_STACK, MONO_STACK } from "../shared/fontStacks";
import { MAX_RECENT_CHECKS } from "@/Types/Monitor";
export type BoldHeatCell = "fast" | "med" | "slow" | "down" | "empty";
export type BoldBarKind = "up" | "down" | "empty";
export type BoldGaugeFill = "ok" | "warm" | "hot";
export interface BoldStyles {
page: SxProps<Theme>;
top: SxProps<Theme>;
brand: SxProps<Theme>;
logoConic: SxProps<Theme>;
logoImg: SxProps<Theme>;
hero: SxProps<Theme>;
heroTitle: SxProps<Theme>;
heroCheck: (tone: OverallTone) => SxProps<Theme>;
heroSub: SxProps<Theme>;
chartSwitchWrap: SxProps<Theme>;
chartSwitch: SxProps<Theme>;
chartSwitchButton: (active: boolean) => SxProps<Theme>;
monitorList: SxProps<Theme>;
card: SxProps<Theme>;
cardRow: SxProps<Theme>;
cardLeft: SxProps<Theme>;
monitorName: SxProps<Theme>;
monitorMeta: SxProps<Theme>;
pill: SxProps<Theme>;
pillHardware: SxProps<Theme>;
monitorUrl: SxProps<Theme>;
badge: (tone: OverallTone) => SxProps<Theme>;
heatmap: SxProps<Theme>;
heatmapCell: (kind: BoldHeatCell) => SxProps<Theme>;
histogram: SxProps<Theme>;
bar: (kind: BoldBarKind, heightPct: number) => SxProps<Theme>;
chartStats: SxProps<Theme>;
infra: SxProps<Theme>;
infraEmpty: SxProps<Theme>;
gauge: SxProps<Theme>;
gaugeLabel: SxProps<Theme>;
gaugeValue: SxProps<Theme>;
gaugeBar: SxProps<Theme>;
gaugeFill: (level: BoldGaugeFill, widthPct: number) => SxProps<Theme>;
gaugeSub: SxProps<Theme>;
footer: SxProps<Theme>;
}
export const boldStyles = (
tokens: StatusPageThemeTokens,
isDark: boolean
): BoldStyles => {
const heatCellBg: Record<BoldHeatCell, string> = {
fast: tokens.up,
med: `color-mix(in srgb, ${tokens.up} 70%, #ffffff 30%)`,
slow: tokens.warn,
down: tokens.down,
empty: tokens.border,
};
const heatCellShadow: Record<BoldHeatCell, string> = {
fast: `0 0 6px color-mix(in srgb, ${tokens.up} 18%, transparent)`,
med: `0 0 6px color-mix(in srgb, ${tokens.up} 18%, transparent)`,
slow: `0 0 6px color-mix(in srgb, ${tokens.warn} 20%, transparent)`,
down: `0 0 6px color-mix(in srgb, ${tokens.down} 20%, transparent)`,
empty: "none",
};
const barBg: Record<BoldBarKind, string> = {
up: tokens.up,
down: tokens.down,
empty: tokens.border,
};
const gaugeFillBg: Record<BoldGaugeFill, string> = {
ok: `linear-gradient(90deg, ${tokens.up}, color-mix(in srgb, ${tokens.up} 70%, #ffffff 30%))`,
warm: `linear-gradient(90deg, ${tokens.warn}, #fbbf24)`,
hot: `linear-gradient(90deg, ${tokens.down}, #fb7185)`,
};
const heroBgLight = `linear-gradient(135deg, color-mix(in srgb, ${tokens.up} 8%, transparent), transparent 60%), ${tokens.surface}`;
const heroBgDark = `linear-gradient(135deg, color-mix(in srgb, ${tokens.up} 16%, transparent), transparent 60%), ${tokens.surface}`;
const heroGlowLight = `radial-gradient(400px 200px at 20% 30%, color-mix(in srgb, ${tokens.up} 14%, transparent), transparent 60%), radial-gradient(300px 200px at 90% 80%, color-mix(in srgb, ${tokens.upStrong} 10%, transparent), transparent 60%)`;
const heroGlowDark = `radial-gradient(400px 200px at 20% 30%, color-mix(in srgb, ${tokens.up} 32%, transparent), transparent 60%), radial-gradient(300px 200px at 90% 80%, color-mix(in srgb, ${tokens.upStrong} 22%, transparent), transparent 60%)`;
return {
page: {
flex: "1 0 auto",
maxWidth: 1040,
width: "100%",
mx: "auto",
p: "56px 24px 96px",
fontFamily: BOLD_SANS_STACK,
fontSize: 14,
lineHeight: 1.55,
color: tokens.text,
WebkitFontSmoothing: "antialiased",
},
top: {
display: "flex",
alignItems: "center",
justifyContent: "space-between",
mb: "36px",
},
brand: {
display: "flex",
alignItems: "center",
gap: "14px",
fontWeight: 700,
fontSize: 16,
letterSpacing: "-0.01em",
color: tokens.text,
},
logoConic: {
width: 36,
height: 36,
borderRadius: "12px",
background: `conic-gradient(from 140deg, ${tokens.up}, ${tokens.upStrong}, #0f766e, ${tokens.up})`,
display: "grid",
placeItems: "center",
color: "#05070a",
fontWeight: 900,
fontSize: 14,
boxShadow: `0 8px 24px color-mix(in srgb, ${tokens.up} 30%, transparent), inset 0 1px 0 rgba(255, 255, 255, 0.2)`,
},
logoImg: { maxHeight: 36, maxWidth: 140, objectFit: "contain" },
hero: {
position: "relative",
borderRadius: "22px",
padding: "36px 36px 32px",
mb: "28px",
background: isDark ? heroBgDark : heroBgLight,
border: `1px solid ${tokens.border}`,
overflow: "hidden",
"&::before": {
content: '""',
position: "absolute",
inset: 0,
background: isDark ? heroGlowDark : heroGlowLight,
pointerEvents: "none",
opacity: 0.8,
zIndex: 0,
...(isDark ? { mixBlendMode: "screen" } : {}),
},
"& > *": { position: "relative", zIndex: 1 },
},
heroTitle: {
m: 0,
fontSize: 34,
fontWeight: 800,
letterSpacing: "-0.02em",
lineHeight: 1.1,
color: tokens.text,
display: "flex",
alignItems: "center",
gap: "10px",
},
heroCheck: (tone) => ({
display: "inline-grid",
placeItems: "center",
width: 32,
height: 32,
borderRadius: "50%",
background: toneColor(tone, tokens),
color: tone === "down" ? "#fff" : "#05070a",
flexShrink: 0,
boxShadow: `0 0 0 6px ${toneSoft(tone, tokens)}`,
}),
heroSub: { mt: "10px", mb: 0, color: tokens.textMuted, fontSize: 14 },
chartSwitchWrap: { display: "flex", justifyContent: "flex-end", mb: "16px" },
chartSwitch: {
display: "inline-flex",
border: `1px solid ${tokens.border}`,
borderRadius: "999px",
background: tokens.surface,
p: "3px",
gap: "2px",
},
chartSwitchButton: (active) => ({
border: 0,
background: active ? tokens.upSoft : "transparent",
fontFamily: "inherit",
fontSize: 11,
padding: "8px 18px",
cursor: "pointer",
color: active ? tokens.up : tokens.textMuted,
fontWeight: 700,
textTransform: "uppercase",
letterSpacing: "0.12em",
transition: "background 0.2s, color 0.2s",
borderRadius: "999px",
"&:hover": { color: active ? tokens.up : tokens.text },
}),
monitorList: {
listStyle: "none",
m: 0,
p: 0,
display: "flex",
flexDirection: "column",
gap: "14px",
},
card: {
background: tokens.surface,
border: `1px solid ${tokens.border}`,
borderRadius: "18px",
overflow: "hidden",
transition: "transform 0.2s cubic-bezier(0.2, 0.8, 0.2, 1), border-color 0.2s",
"&:hover": {
transform: "translateY(-3px)",
borderColor: `color-mix(in srgb, ${tokens.up} 40%, ${tokens.border})`,
},
},
cardRow: {
display: "grid",
gridTemplateColumns: "1fr auto",
alignItems: "center",
gap: "24px",
p: "22px 28px",
},
cardLeft: { minWidth: 0 },
monitorName: {
fontWeight: 700,
fontSize: 17,
letterSpacing: "-0.01em",
color: tokens.text,
overflow: "hidden",
textOverflow: "ellipsis",
whiteSpace: "nowrap",
},
monitorMeta: {
display: "flex",
gap: "10px",
alignItems: "center",
mt: "6px",
flexWrap: "wrap",
},
pill: {
fontSize: 10,
textTransform: "uppercase",
letterSpacing: "0.1em",
color: tokens.textMuted,
background: `color-mix(in srgb, ${tokens.surface} 80%, ${tokens.bg})`,
border: `1px solid ${tokens.border}`,
padding: "3px 10px",
borderRadius: "999px",
fontWeight: 700,
},
pillHardware: {
fontSize: 10,
textTransform: "uppercase",
letterSpacing: "0.1em",
padding: "3px 10px",
borderRadius: "999px",
fontWeight: 700,
color: tokens.up,
borderColor: `color-mix(in srgb, ${tokens.up} 40%, transparent)`,
border: `1px solid color-mix(in srgb, ${tokens.up} 40%, transparent)`,
background: tokens.upSoft,
},
monitorUrl: {
fontSize: 12,
color: tokens.textMuted,
fontFamily: MONO_STACK,
overflow: "hidden",
textOverflow: "ellipsis",
whiteSpace: "nowrap",
maxWidth: 300,
},
badge: (tone) => ({
fontSize: 11,
fontWeight: 800,
padding: "6px 14px",
borderRadius: "999px",
display: "inline-flex",
alignItems: "center",
gap: "7px",
textTransform: "uppercase",
letterSpacing: "0.08em",
whiteSpace: "nowrap",
background: toneSoft(tone, tokens),
color: toneColor(tone, tokens),
"&::before": {
content: '""',
width: 6,
height: 6,
borderRadius: "50%",
background: "currentColor",
boxShadow: "0 0 8px currentColor",
},
}),
heatmap: {
padding: "0 28px 22px",
display: "grid",
gridTemplateColumns: `repeat(${MAX_RECENT_CHECKS}, 1fr)`,
gap: "3px",
height: 48,
},
heatmapCell: (kind) => ({
borderRadius: "3px",
background: heatCellBg[kind],
boxShadow: heatCellShadow[kind],
opacity: kind === "empty" ? 0.4 : 1,
transition: "transform 0.15s",
"&:hover": { transform: "scaleY(1.2)" },
}),
histogram: {
padding: "0 28px",
display: "grid",
gridTemplateColumns: `repeat(${MAX_RECENT_CHECKS}, 1fr)`,
gap: "3px",
alignItems: "flex-end",
height: 48,
},
bar: (kind, heightPct) => ({
background: barBg[kind],
borderRadius: "3px",
minHeight: 4,
opacity: kind === "empty" ? 0.4 : 1,
height: `${heightPct}%`,
}),
chartStats: {
padding: "0 28px 22px",
fontSize: 11,
color: tokens.textMuted,
fontVariantNumeric: "tabular-nums",
fontWeight: 700,
letterSpacing: "0.05em",
},
infra: {
padding: "18px 28px 26px",
display: "grid",
gridTemplateColumns: { xs: "1fr", md: "repeat(3, 1fr)" },
gap: "16px",
},
infraEmpty: {
padding: "18px 28px 26px",
color: tokens.textMuted,
fontSize: 13,
},
gauge: {
border: `1px solid ${tokens.border}`,
borderRadius: "16px",
p: "16px 18px",
background: `color-mix(in srgb, ${tokens.surface} 80%, ${tokens.bg})`,
},
gaugeLabel: {
fontSize: 10,
color: tokens.textMuted,
textTransform: "uppercase",
letterSpacing: "0.12em",
fontWeight: 700,
},
gaugeValue: {
fontSize: 30,
fontWeight: 800,
letterSpacing: "-0.02em",
mt: "6px",
fontVariantNumeric: "tabular-nums",
color: tokens.text,
},
gaugeBar: {
height: 6,
background: tokens.border,
borderRadius: "3px",
overflow: "hidden",
mt: "12px",
},
gaugeFill: (level, widthPct) => ({
display: "block",
height: "100%",
background: gaugeFillBg[level],
borderRadius: "3px",
transition: "width 0.9s cubic-bezier(0.2, 0.8, 0.2, 1)",
width: `${Math.max(0, Math.min(100, widthPct))}%`,
}),
gaugeSub: {
fontSize: 11,
color: tokens.textMuted,
mt: "10px",
fontVariantNumeric: "tabular-nums",
},
footer: {
textAlign: "center",
color: tokens.textMuted,
fontSize: 12,
mt: "52px",
"& a": {
color: tokens.up,
textDecoration: "underline",
textUnderlineOffset: "3px",
fontWeight: 800,
},
},
};
};
@@ -0,0 +1,36 @@
import Box from "@mui/material/Box";
import { useTranslation } from "react-i18next";
import type { SlotProps } from "@/Pages/StatusPage/Status/themes/shared/BaseStatusPage";
import type { EditorialStyles } from "@/Pages/StatusPage/Status/themes/editorial/styles";
export const EditorialHeader = ({
statusPage,
logoSrc,
styles,
}: SlotProps<EditorialStyles>) => {
const { t } = useTranslation();
return (
<Box sx={styles.brandWrap}>
{logoSrc && (
<Box
component="img"
src={logoSrc}
alt={statusPage.companyName}
sx={styles.logoImg}
/>
)}
<Box
component="h2"
sx={styles.brandEyebrow}
>
{statusPage.companyName}
</Box>
<Box
component="h1"
sx={styles.brandTitle}
>
{t("pages.statusPages.editorial.reportTitle")}
</Box>
</Box>
);
};
@@ -0,0 +1,46 @@
import { useMemo } from "react";
import Box from "@mui/material/Box";
import { useTranslation } from "react-i18next";
import type { SlotProps } from "@/Pages/StatusPage/Status/themes/shared/BaseStatusPage";
import type { EditorialStyles } from "@/Pages/StatusPage/Status/themes/editorial/styles";
export const EditorialHero = ({
overall,
monitorCount,
styles,
}: SlotProps<EditorialStyles>) => {
const { t } = useTranslation();
const todayLabel = useMemo(
() =>
new Date().toLocaleDateString(undefined, {
year: "numeric",
month: "long",
day: "numeric",
}),
[]
);
return (
<>
<Box
component="p"
sx={styles.statusLine}
>
<Box
component="span"
sx={styles.statusDot(overall.tone)}
/>
{overall.message}
</Box>
<Box
component="p"
sx={styles.dateline}
>
{todayLabel} ·{" "}
{t("pages.statusPages.statusBar.monitoringSummary", {
count: monitorCount,
})}
</Box>
</>
);
};
@@ -0,0 +1,341 @@
import type { SxProps, Theme } from "@mui/material/styles";
import type { StatusPageThemeTokens } from "../tokens";
import { type OverallTone, toneColor, toneSoft } from "../shared/overallStatus";
import {
EDITORIAL_SECONDARY_SANS_STACK,
MONO_STACK,
SERIF_STACK,
} from "../shared/fontStacks";
import { MAX_RECENT_CHECKS } from "@/Types/Monitor";
export type EditorialHeatCell = "fast" | "med" | "slow" | "down" | "empty";
export type EditorialBarKind = "up" | "down" | "empty";
export type EditorialGaugeFill = "ok" | "warm" | "hot";
export interface EditorialStyles {
page: SxProps<Theme>;
top: SxProps<Theme>;
brandWrap: SxProps<Theme>;
logoImg: SxProps<Theme>;
brandEyebrow: SxProps<Theme>;
brandTitle: SxProps<Theme>;
statusLine: SxProps<Theme>;
statusDot: (tone: OverallTone) => SxProps<Theme>;
dateline: SxProps<Theme>;
chartSwitchWrap: SxProps<Theme>;
chartSwitch: SxProps<Theme>;
chartSwitchButton: (active: boolean) => SxProps<Theme>;
monitorList: SxProps<Theme>;
card: SxProps<Theme>;
cardRow: SxProps<Theme>;
cardLeft: SxProps<Theme>;
monitorName: SxProps<Theme>;
monitorMeta: SxProps<Theme>;
pill: SxProps<Theme>;
pillHardware: SxProps<Theme>;
monitorUrl: SxProps<Theme>;
badge: (tone: OverallTone) => SxProps<Theme>;
heatmap: SxProps<Theme>;
heatmapCell: (kind: EditorialHeatCell) => SxProps<Theme>;
histogram: SxProps<Theme>;
bar: (kind: EditorialBarKind, heightPct: number) => SxProps<Theme>;
chartStats: SxProps<Theme>;
infra: SxProps<Theme>;
infraEmpty: SxProps<Theme>;
gauge: SxProps<Theme>;
gaugeLabel: SxProps<Theme>;
gaugeValue: SxProps<Theme>;
gaugeBar: SxProps<Theme>;
gaugeFill: (level: EditorialGaugeFill, widthPct: number) => SxProps<Theme>;
gaugeSub: SxProps<Theme>;
footer: SxProps<Theme>;
}
export const editorialStyles = (
tokens: StatusPageThemeTokens,
_isDark: boolean
): EditorialStyles => {
const heatCellBg: Record<EditorialHeatCell, string> = {
fast: tokens.up,
med: `color-mix(in srgb, ${tokens.up} 60%, ${tokens.bg})`,
slow: tokens.warn,
down: tokens.down,
empty: tokens.border,
};
const barBg: Record<EditorialBarKind, string> = {
up: tokens.up,
down: tokens.down,
empty: tokens.border,
};
const gaugeFillBg: Record<EditorialGaugeFill, string> = {
ok: tokens.up,
warm: tokens.warn,
hot: tokens.down,
};
return {
page: {
flex: "1 0 auto",
maxWidth: 760,
width: "100%",
mx: "auto",
p: "64px 24px 96px",
fontFamily: SERIF_STACK,
fontSize: 15,
lineHeight: 1.6,
color: tokens.text,
WebkitFontSmoothing: "antialiased",
},
top: {
display: "flex",
alignItems: "flex-end",
justifyContent: "space-between",
mb: "48px",
borderBottom: `2px solid ${tokens.text}`,
pb: "18px",
},
brandWrap: { display: "flex", flexDirection: "column" },
logoImg: { maxHeight: 48, maxWidth: 160, objectFit: "contain", mb: "8px" },
brandEyebrow: {
m: 0,
fontSize: 11,
textTransform: "uppercase",
letterSpacing: "0.3em",
fontWeight: 700,
color: tokens.textMuted,
fontFamily: EDITORIAL_SECONDARY_SANS_STACK,
},
brandTitle: {
mt: "6px",
mb: 0,
fontSize: 40,
fontWeight: 700,
letterSpacing: "-0.02em",
lineHeight: 1,
color: tokens.text,
},
statusLine: {
fontSize: 22,
fontWeight: 400,
m: 0,
mb: "8px",
letterSpacing: "-0.01em",
lineHeight: 1.35,
color: tokens.text,
fontFamily: SERIF_STACK,
},
statusDot: (tone) => ({
display: "inline-block",
width: 10,
height: 10,
borderRadius: "50%",
background: toneColor(tone, tokens),
mr: "10px",
verticalAlign: "middle",
}),
dateline: {
fontFamily: EDITORIAL_SECONDARY_SANS_STACK,
fontSize: 11,
textTransform: "uppercase",
letterSpacing: "0.12em",
color: tokens.textMuted,
m: 0,
mb: "56px",
},
chartSwitchWrap: {
display: "flex",
justifyContent: "flex-end",
mb: "20px",
fontFamily: EDITORIAL_SECONDARY_SANS_STACK,
},
chartSwitch: {
display: "inline-flex",
border: `1px solid ${tokens.text}`,
"& > button + button": { borderLeft: `1px solid ${tokens.text}` },
},
chartSwitchButton: (active) => ({
fontFamily: "inherit",
fontSize: 10,
textTransform: "uppercase",
letterSpacing: "0.2em",
padding: "6px 14px",
border: 0,
background: active ? tokens.text : "transparent",
color: active ? tokens.bg : tokens.text,
cursor: "pointer",
fontWeight: 700,
}),
monitorList: {
listStyle: "none",
m: 0,
p: 0,
display: "flex",
flexDirection: "column",
},
card: {
background: "transparent",
border: 0,
borderBottom: `1px solid ${tokens.border}`,
mb: 0,
py: "20px",
"&:last-of-type": { borderBottom: 0 },
},
cardRow: {
display: "grid",
gridTemplateColumns: "1fr auto",
alignItems: "baseline",
gap: "20px",
},
cardLeft: { minWidth: 0 },
monitorName: {
fontSize: 20,
fontWeight: 700,
letterSpacing: "-0.015em",
color: tokens.text,
overflow: "hidden",
textOverflow: "ellipsis",
whiteSpace: "nowrap",
},
monitorMeta: {
fontFamily: EDITORIAL_SECONDARY_SANS_STACK,
fontSize: 12,
color: tokens.textMuted,
mt: "6px",
display: "flex",
gap: "12px",
flexWrap: "wrap",
alignItems: "center",
},
pill: {
textTransform: "uppercase",
letterSpacing: "0.12em",
fontWeight: 700,
color: tokens.textMuted,
},
pillHardware: {
textTransform: "uppercase",
letterSpacing: "0.12em",
fontWeight: 700,
color: tokens.up,
},
monitorUrl: {
fontFamily: MONO_STACK,
overflow: "hidden",
textOverflow: "ellipsis",
whiteSpace: "nowrap",
maxWidth: 280,
fontSize: 12,
},
badge: (tone) => ({
fontFamily: EDITORIAL_SECONDARY_SANS_STACK,
fontSize: 10,
fontWeight: 700,
padding: "4px 10px",
borderRadius: 0,
textTransform: "uppercase",
letterSpacing: "0.15em",
whiteSpace: "nowrap",
background: toneSoft(tone, tokens),
color: toneColor(tone, tokens),
}),
heatmap: {
mt: "16px",
display: "grid",
gridTemplateColumns: `repeat(${MAX_RECENT_CHECKS}, 1fr)`,
gap: "2px",
height: 40,
},
heatmapCell: (kind) => ({
background: heatCellBg[kind],
opacity: kind === "empty" ? 0.5 : 1,
}),
histogram: {
mt: "16px",
display: "grid",
gridTemplateColumns: `repeat(${MAX_RECENT_CHECKS}, 1fr)`,
gap: "2px",
alignItems: "flex-end",
height: 40,
},
bar: (kind, heightPct) => ({
background: barBg[kind],
minHeight: 3,
opacity: kind === "empty" ? 0.5 : 1,
height: `${heightPct}%`,
}),
chartStats: {
fontFamily: EDITORIAL_SECONDARY_SANS_STACK,
fontSize: 11,
color: tokens.textMuted,
fontVariantNumeric: "tabular-nums",
textTransform: "uppercase",
letterSpacing: "0.1em",
},
infra: {
display: "grid",
gridTemplateColumns: { xs: "1fr", md: "repeat(3, 1fr)" },
gap: "24px",
mt: "20px",
fontFamily: EDITORIAL_SECONDARY_SANS_STACK,
},
infraEmpty: { mt: "20px", color: tokens.textMuted, fontSize: 13 },
gauge: {},
gaugeLabel: {
fontSize: 10,
color: tokens.textMuted,
textTransform: "uppercase",
letterSpacing: "0.18em",
fontWeight: 700,
},
gaugeValue: {
fontSize: 28,
fontWeight: 700,
letterSpacing: "-0.02em",
mt: "4px",
fontVariantNumeric: "tabular-nums",
fontFamily: SERIF_STACK,
color: tokens.text,
},
gaugeBar: { height: 2, background: tokens.border, mt: "10px" },
gaugeFill: (level, widthPct) => ({
display: "block",
height: "100%",
background: gaugeFillBg[level],
transition: "width 0.8s",
width: `${Math.max(0, Math.min(100, widthPct))}%`,
}),
gaugeSub: {
fontSize: 11,
color: tokens.textMuted,
mt: "8px",
fontVariantNumeric: "tabular-nums",
},
footer: {
textAlign: "center",
color: tokens.textMuted,
fontSize: 11,
mt: "56px",
textTransform: "uppercase",
letterSpacing: "0.2em",
fontFamily: EDITORIAL_SECONDARY_SANS_STACK,
"& a": {
color: tokens.text,
textDecoration: "none",
borderBottom: `1px solid ${tokens.text}`,
pb: "2px",
fontWeight: 700,
},
},
};
};
@@ -0,0 +1,24 @@
import Box from "@mui/material/Box";
import { monoFirstChar } from "@/Pages/StatusPage/Status/themes/shared/overallStatus";
import type { SlotProps } from "@/Pages/StatusPage/Status/themes/shared/BaseStatusPage";
import type { ModernStyles } from "@/Pages/StatusPage/Status/themes/modern/styles";
export const ModernHeader = ({
statusPage,
logoSrc,
styles,
}: SlotProps<ModernStyles>) => (
<Box sx={styles.brand}>
{logoSrc ? (
<Box
component="img"
src={logoSrc}
alt={statusPage.companyName}
sx={styles.logoImg}
/>
) : (
<Box sx={styles.logoGrad}>{monoFirstChar(statusPage.companyName)}</Box>
)}
{statusPage.companyName}
</Box>
);
@@ -0,0 +1,34 @@
import Box from "@mui/material/Box";
import { useTranslation } from "react-i18next";
import type { SlotProps } from "@/Pages/StatusPage/Status/themes/shared/BaseStatusPage";
import type { ModernStyles } from "@/Pages/StatusPage/Status/themes/modern/styles";
export const ModernHero = ({
overall,
monitorCount,
styles,
}: SlotProps<ModernStyles>) => {
const { t } = useTranslation();
return (
<Box sx={styles.hero}>
<Box sx={styles.pulse(overall.tone)} />
<Box sx={styles.statusCopy}>
<Box
component="h1"
sx={styles.heroTitle}
>
{overall.message}
</Box>
<Box
component="p"
sx={styles.heroSub}
>
{t("pages.statusPages.statusBar.monitoringSummary", {
count: monitorCount,
})}
</Box>
</Box>
<Box sx={styles.heroIcon(overall.tone)}>{overall.icon}</Box>
</Box>
);
};
Loaded 100 of 395 files, more files were not shown because too many files have changed in this diff. Show more