Merge pull request #3231 from bluewave-labs/feat/v2-status-page

feat: v2 status page
This commit is contained in:
Alexander Holliday
2026-02-02 15:01:41 -08:00
committed by GitHub
25 changed files with 669 additions and 732 deletions
@@ -91,7 +91,7 @@ export const BasePage = ({
spacing={theme.spacing(10)}
{...props}
>
<Breadcrumb />
<Breadcrumb breadcrumbOverride={breadcrumbOverride} />
{children}
</Stack>
);
@@ -4,6 +4,7 @@ import { Link, useLocation } from "react-router-dom";
import { useTheme } from "@mui/material/styles";
import { ChevronRight } from "lucide-react";
import { useTranslation } from "react-i18next";
import type { ReactNode } from "react";
const isId = (segment: string): boolean => {
return segment.length === 24 || /^[a-f0-9-]{36}$/.test(segment);
@@ -11,28 +12,8 @@ const isId = (segment: string): boolean => {
const actionSegments = ["create", "configure"];
export const Breadcrumb = () => {
const { t } = useTranslation();
const BreadcrumbWrapper = ({ children }: { children: ReactNode }) => {
const theme = useTheme();
const location = useLocation();
const segments = location.pathname.split("/").filter((x) => x);
// Build simplified breadcrumb: "uptime" or "uptime / details" or "uptime / create"
const basePage = segments[0] || t("common.breadcrumbs.home");
const secondSegment = segments[1];
const isActionPage = secondSegment && actionSegments.includes(secondSegment); // create/config
const isDetailsPage = secondSegment && isId(secondSegment); // details
const hasSubPage = isActionPage || isDetailsPage;
const getSubPageLabel = (): string => {
if (isActionPage) {
return secondSegment.charAt(0).toUpperCase() + secondSegment.slice(1);
}
return t("common.breadcrumbs.details");
};
return (
<MuiBreadcrumbs
separator={
@@ -49,6 +30,72 @@ export const Breadcrumb = () => {
},
}}
>
{children}
</MuiBreadcrumbs>
);
};
export const Breadcrumb = ({
breadcrumbOverride,
}: {
breadcrumbOverride?: string[] | undefined;
}) => {
const { t } = useTranslation();
const theme = useTheme();
const location = useLocation();
// If override is an empty array, hide entirely
if (breadcrumbOverride !== undefined && breadcrumbOverride.length === 0) {
return null;
}
// If override has items, render them directly
if (breadcrumbOverride !== undefined && breadcrumbOverride.length > 0) {
return (
<BreadcrumbWrapper>
{breadcrumbOverride.map((item, index) => {
const isLast = index === breadcrumbOverride.length - 1;
return (
<Typography
key={index}
sx={{
fontSize: "14px",
fontWeight: isLast ? 600 : 400,
color: isLast ? theme.palette.primary.main : theme.palette.text.secondary,
}}
>
{item}
</Typography>
);
})}
</BreadcrumbWrapper>
);
}
// Default behavior: use location pathname
const segments = location.pathname.split("/").filter((x) => x);
if (segments.length === 0) {
return null;
}
// Build simplified breadcrumb: "uptime" or "uptime / details" or "uptime / create"
const basePage = segments[0] || t("common.breadcrumbs.home");
const secondSegment = segments[1];
const isActionPage = secondSegment && actionSegments.includes(secondSegment);
const isDetailsPage = secondSegment && isId(secondSegment);
const hasSubPage = isActionPage || isDetailsPage;
const getSubPageLabel = (): string => {
if (isActionPage) {
return secondSegment.charAt(0).toUpperCase() + secondSegment.slice(1);
}
return t("common.breadcrumbs.details");
};
return (
<BreadcrumbWrapper>
{hasSubPage ? (
<Link
to={`/${basePage}`}
@@ -88,6 +135,6 @@ export const Breadcrumb = () => {
{getSubPageLabel()}
</Typography>
)}
</MuiBreadcrumbs>
</BreadcrumbWrapper>
);
};
+12 -2
View File
@@ -3,8 +3,12 @@ import Switch from "@mui/material/Switch";
import type { SwitchProps } from "@mui/material/Switch";
import { useTheme } from "@mui/material/styles";
export const SwitchComponent = forwardRef<HTMLInputElement, SwitchProps>(
function SwitchComponent({ sx, ...props }, ref) {
interface SwitchComponentProps extends SwitchProps {
dualOption?: boolean;
}
export const SwitchComponent = forwardRef<HTMLInputElement, SwitchComponentProps>(
function SwitchComponent({ sx, dualOption = false, ...props }, ref) {
const theme = useTheme();
const additionalSx = Array.isArray(sx) ? sx : sx ? [sx] : [];
@@ -28,6 +32,12 @@ export const SwitchComponent = forwardRef<HTMLInputElement, SwitchProps>(
},
},
},
...(dualOption && {
"& .MuiSwitch-track": {
backgroundColor: theme.palette.primary.main,
opacity: 1,
},
}),
},
...additionalSx,
]}
@@ -95,7 +95,7 @@ export const HeaderMonitorControls = ({
await refetch();
}}
>
{monitor?.isActive ? t("pause") : t("resume")}
{monitor?.isActive ? t("common.buttons.pause") : t("common.buttons.resume")}
</Button>
)}
{isAdmin && (
@@ -105,7 +105,7 @@ export const HeaderMonitorControls = ({
startIcon={<Icon icon={Settings} />}
onClick={() => navigate(`/${path}/configure/${monitor.id}`)}
>
Configure
{t("common.buttons.configure")}
</Button>
)}
</Stack>
@@ -155,7 +155,7 @@ export const HeaderDeleteControls = ({
await refetch();
}}
>
{monitor?.isActive ? t("pause") : t("resume")}
{monitor?.isActive ? t("common.buttons.pause") : t("common.buttons.resume")}
</Button>
)}
{isAdmin && (
+5 -6
View File
@@ -17,7 +17,6 @@ import { useNavigate } from "react-router-dom";
import { useStatusPageFetch } from "../Status/Hooks/useStatusPageFetch.jsx";
import { useParams } from "react-router-dom";
import { useTranslation } from "react-i18next";
import { useStatusPageDelete } from "../Status/Hooks/useStatusPageDelete.jsx";
//Constants
const ERROR_TAB_MAPPING = [
["companyName", "url", "timezone", "color", "isPublished", "logo"],
@@ -60,7 +59,7 @@ const CreateStatusPage = () => {
const [createStatusPage] = useCreateStatusPage(isCreate);
const [statusPage, statusPageMonitors, statusPageIsLoading, , fetchStatusPage] =
useStatusPageFetch(isCreate, url);
const [deleteStatusPage, isDeleting] = useStatusPageDelete(fetchStatusPage, url);
// const [deleteStatusPage, isDeleting] = useStatusPageDelete(fetchStatusPage, url);
// Handlers
const handleFormChange = (e) => {
@@ -133,7 +132,7 @@ const CreateStatusPage = () => {
const handleDelete = async () => {
setIsDeleteOpen(false);
// Start deletion process but don't wait for it
deleteStatusPage();
// deleteStatusPage();
// Immediately navigate away to prevent additional fetches for the deleted page
navigate("/status");
};
@@ -252,7 +251,7 @@ const CreateStatusPage = () => {
justifyContent="flex-end"
>
<Button
loading={isDeleting}
// loading={isDeleting}
variant="contained"
color="error"
onClick={() => setIsDeleteOpen(true)}
@@ -266,7 +265,7 @@ const CreateStatusPage = () => {
open={isDeleteOpen}
confirmationButtonLabel={t("deleteStatusPageConfirm")}
description={t("deleteStatusPageDescription")}
isLoading={isDeleting || statusPageIsLoading}
// isLoading={isDeleting || statusPageIsLoading}
/>
</Stack>
)}
@@ -290,7 +289,7 @@ const CreateStatusPage = () => {
handleDelete={handleDelete}
isDeleteOpen={isDeleteOpen}
setIsDeleteOpen={setIsDeleteOpen}
isDeleting={isDeleting}
// isDeleting={isDeleting}
isLoading={statusPageIsLoading}
/>
<Stack
@@ -1,36 +0,0 @@
// Components
import { Box, Typography } from "@mui/material";
// Utils
import { useTheme } from "@mui/material/styles";
import { useNavigate } from "react-router-dom";
import { useTranslation } from "react-i18next";
const AdminLink = () => {
const theme = useTheme();
const { t } = useTranslation();
const navigate = useNavigate();
return (
<Box>
<Typography
className="forgot-p"
display="inline-block"
color={theme.palette.primary.contrastText}
>
{t("administrator")}
</Typography>
<Typography
component="span"
color={theme.palette.accent.main}
ml={theme.spacing(2)}
sx={{ cursor: "pointer" }}
onClick={() => navigate("/login")}
>
{t("loginHere")}
</Typography>
</Box>
);
};
export default AdminLink;
@@ -1,141 +0,0 @@
// Components
import { Box, Stack, Typography, Button } from "@mui/material";
import Image from "@/Components/v1/Image/index.jsx";
import Icon from "@/Components/v1/Icon";
import ThemeSwitch from "@/Components/v1/ThemeSwitch/index.jsx";
//Utils
import { useTheme } from "@mui/material/styles";
import { useNavigate } from "react-router-dom";
import { useLocation } from "react-router-dom";
import PropTypes from "prop-types";
import { useTranslation } from "react-i18next";
const Controls = ({ url, type }) => {
const theme = useTheme();
const { t } = useTranslation();
const location = useLocation();
const currentPath = location.pathname;
const navigate = useNavigate();
if (currentPath.startsWith("/status/uptime/public")) {
return null;
}
return (
<Stack
direction="row"
gap={theme.spacing(2)}
>
<Box>
<Button
variant="contained"
color="secondary"
onClick={() => {
if (type === "uptime") {
navigate(`/status/uptime/configure/${url}`);
}
}}
sx={{
px: theme.spacing(5),
"& svg": {
mr: theme.spacing(3),
"& path": {
stroke: theme.palette.secondary.contrastText,
},
},
}}
>
<Icon
name="Settings"
size={18}
style={{ marginRight: "8px" }}
/>{" "}
{t("configure")}
</Button>
</Box>
</Stack>
);
};
Controls.propTypes = {
type: PropTypes.string,
url: PropTypes.string,
};
const ControlsHeader = ({ statusPage, isPublic, url, type = "uptime" }) => {
const theme = useTheme();
const { t } = useTranslation();
const publicUrl = `/status/uptime/public/${url}`;
return (
<Stack
alignSelf="flex-start"
direction="row"
width="100%"
gap={theme.spacing(2)}
justifyContent="space-between"
alignItems="flex-end"
>
<Stack
direction="row"
gap={theme.spacing(8)}
alignItems="flex-end"
>
<Image
shouldRender={statusPage?.logo?.data ? true : false}
alt={"Company logo"}
maxWidth={"300px"}
maxHeight={"50px"}
base64={statusPage?.logo?.data}
/>
<Typography
variant="h1"
overflow="hidden"
textOverflow="ellipsis"
sx={{
maxWidth: { xs: "200px", sm: "100%" },
}}
>
{statusPage?.companyName}
</Typography>
{statusPage?.isPublished && !isPublic && (
<Stack
direction="row"
alignItems="center"
justifyContent="center"
onClick={() => {
window.open(publicUrl, "_blank", "noopener,noreferrer");
}}
sx={{
display: "inline-flex",
":hover": {
cursor: "pointer",
borderBottom: 1,
},
}}
>
<Typography>{t("publicLink")}</Typography>
<Icon
name="ExternalLink"
size={18}
/>
</Stack>
)}
</Stack>
<Controls
url={url}
type={type}
/>
{isPublic && <ThemeSwitch />}
</Stack>
);
};
ControlsHeader.propTypes = {
url: PropTypes.string,
statusPage: PropTypes.object,
isPublic: PropTypes.bool,
type: PropTypes.string,
};
export default ControlsHeader;
@@ -0,0 +1,87 @@
import Stack from "@mui/material/Stack";
import Box from "@mui/material/Box";
import Typography from "@mui/material/Typography";
import { Icon } from "@/Components/v2/design-elements";
import { Button } from "@/Components/v2/inputs";
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";
interface HeaderStatusPageControlsProps {
isAdmin: boolean;
statusPage: StatusPage;
isPublic?: boolean;
}
export const HeaderStatusPageControls = ({
isAdmin,
statusPage,
isPublic = false,
}: HeaderStatusPageControlsProps) => {
const theme = useTheme();
const navigate = useNavigate();
const { t } = useTranslation();
return (
<Stack
direction={"row"}
alignItems={"center"}
justifyContent={"space-between"}
mb={4}
>
<Stack
direction="row"
gap={theme.spacing(4)}
alignItems="baseline"
>
<Typography
variant="h1"
overflow="hidden"
textOverflow="ellipsis"
sx={{
maxWidth: { xs: "200px", sm: "100%" },
}}
>
{statusPage?.companyName}
</Typography>
{statusPage?.isPublished && !isPublic && (
<>
<Typography
onClick={() => {
window.open(
`/status/uptime/public/${statusPage.url}`,
"_blank",
"noopener,noreferrer"
);
}}
sx={{
borderBottom: 1,
borderColor: "transparent",
":hover": {
cursor: "pointer",
borderBottom: 1,
},
}}
>
{t("publicLink")}
</Typography>
<Box>
<ExternalLink size={14} />
</Box>
</>
)}
</Stack>
{isAdmin && !isPublic && (
<Button
variant="contained"
color="secondary"
startIcon={<Icon icon={Settings} />}
onClick={() => navigate(`/status/uptime/configure/${statusPage.url}`)}
>
{t("common.buttons.configure")}
</Button>
)}
</Stack>
);
};
@@ -0,0 +1,113 @@
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/v2/common";
import { StatusLabel, BaseBox } from "@/Components/v2/design-elements";
import { SwitchComponent } from "@/Components/v2/inputs";
import { useTheme } from "@mui/material/styles";
import { determineState } from "@/Utils/MonitorUtils";
import { useSelector } from "react-redux";
import { useState } from "react";
import type { Monitor } from "@/Types/Monitor";
import type { StatusPage } from "@/Types/StatusPage";
import type { RootState } from "@/Types/state";
interface StatusPageMonitor extends Monitor {
checks?: Monitor["recentChecks"];
}
interface MonitorsListProps {
statusPage: StatusPage;
monitors: StatusPageMonitor[];
}
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(8)}>
{statusPage.showCharts !== false && (
<Stack
direction={"row"}
alignItems={"center"}
>
<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) => {
const status = determineState(monitor);
return (
<BaseBox
key={monitor.id}
padding={theme.spacing(4)}
>
<Stack
direction="row"
alignItems="center"
justifyContent="space-between"
gap={theme.spacing(4)}
mb={statusPage.showCharts !== false ? theme.spacing(4) : 0}
>
<Box sx={{ overflow: "hidden", minWidth: 0, flex: 1 }}>
<Typography
variant="h6"
sx={{
overflow: "hidden",
textOverflow: "ellipsis",
whiteSpace: "nowrap",
}}
>
{monitor.name}
</Typography>
{showURL && (
<Typography
variant="body2"
color="text.secondary"
>
{monitor.url}
</Typography>
)}
</Box>
<StatusLabel
status={status === "up"}
isActive={monitor.isActive}
/>
</Stack>
{statusPage.showCharts !== false && (
<Box sx={{ overflow: "hidden", minWidth: 0, flex: 1 }}>
{chartType === "histogram" ? (
<HistogramResponseTime
checks={monitor?.checks?.slice().reverse() ?? []}
/>
) : (
<HeatmapResponseTime
checks={monitor?.checks?.slice().reverse() ?? []}
/>
)}
</Box>
)}
</BaseBox>
);
})}
</Stack>
);
};
@@ -1,69 +0,0 @@
// Components
import { Stack, Box } from "@mui/material";
import Host from "@/Components/v1/Host/index.jsx";
import StatusPageBarChart from "@/Components/v1/Charts/StatusPageBarChart/index.jsx";
import { StatusLabel } from "@/Components/v1/Label/index.jsx";
//Utils
import { useTheme } from "@mui/material/styles";
import { useMonitorUtils } from "../../../../../Hooks/useMonitorUtils.js";
import PropTypes from "prop-types";
import { useSelector } from "react-redux";
const MonitorsList = ({
isLoading = false,
shouldRender = true,
monitors = [],
statusPage = {},
}) => {
const theme = useTheme();
const { determineState } = useMonitorUtils();
const { showURL } = useSelector((state) => state.ui);
return (
<>
{monitors?.map((monitor) => {
const status = determineState(monitor);
return (
<Stack
key={monitor.id}
width="100%"
gap={theme.spacing(10)}
margin="0 auto"
maxWidth="95%"
>
<Host
key={monitor.id}
url={monitor.url}
title={monitor.name}
percentageColor={monitor.percentageColor}
percentage={monitor.percentage}
showURL={showURL}
status={status}
/>
<Stack
direction="row"
alignItems="center"
gap={theme.spacing(1)}
>
{statusPage.showCharts !== false && (
<Box flex={9}>
<StatusPageBarChart checks={monitor?.checks?.slice().reverse()} />
</Box>
)}
</Stack>
</Stack>
);
})}
</>
);
};
MonitorsList.propTypes = {
monitors: PropTypes.array.isRequired,
statusPage: PropTypes.object,
};
export default MonitorsList;
@@ -1,14 +0,0 @@
import { Stack, Skeleton } from "@mui/material";
const SkeletonLayout = () => {
return (
<Stack>
<Skeleton
variant="rectangular"
height={"90vh"}
/>
</Stack>
);
};
export default SkeletonLayout;
@@ -0,0 +1,62 @@
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 === true)) {
monitorsStatus.msg = t("pages.statusPages.statusBar.allUp");
monitorsStatus.color = theme.palette.success.main;
monitorsStatus.icon = <CircleCheck size={24} />;
}
if (monitors.every((monitor) => monitor.status === false)) {
monitorsStatus.msg = t("pages.statusPages.statusBar.allDown");
monitorsStatus.color = theme.palette.error.main;
}
if (monitors.some((monitor) => monitor.status === false)) {
monitorsStatus.msg = t("pages.statusPages.statusBar.degraded");
monitorsStatus.color = theme.palette.warning.main;
}
// Paused or unknown
if (monitors.some((monitor) => typeof monitor.status === "undefined")) {
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>
);
};
@@ -1,77 +0,0 @@
// Components
import { Stack, Typography } from "@mui/material";
import Icon from "@/Components/v1/Icon";
// Utils
import { useTheme } from "@mui/material/styles";
import PropTypes from "prop-types";
const getMonitorStatus = (monitors, theme) => {
const monitorsStatus = {
icon: (
<Icon
name="AlertTriangle"
size={24}
color="primary.contrastTextSecondaryDarkBg"
/>
),
};
if (monitors.every((monitor) => monitor.status === true)) {
monitorsStatus.msg = "All systems operational";
monitorsStatus.color = theme.palette.success.lowContrast;
monitorsStatus.icon = (
<Icon
name="CheckCircle2"
size={24}
color="primary.contrastTextSecondaryDarkBg"
/>
);
}
if (monitors.every((monitor) => monitor.status === false)) {
monitorsStatus.msg = "All systems down";
monitorsStatus.color = theme.palette.error.lowContrast;
}
if (monitors.some((monitor) => monitor.status === false)) {
monitorsStatus.msg = "Degraded performance";
monitorsStatus.color = theme.palette.warning.lowContrast;
}
// Paused or unknown
if (monitors.some((monitor) => typeof monitor.status === "undefined")) {
monitorsStatus.msg = "Unknown status";
monitorsStatus.color = theme.palette.warning.lowContrast;
}
return monitorsStatus;
};
const StatusBar = ({ monitors }) => {
const theme = useTheme();
if (typeof monitors === "undefined") return;
const monitorsStatus = getMonitorStatus(monitors, theme);
return (
<Stack
direction="row"
alignItems="center"
justifyContent="center"
gap={theme.spacing(2)}
height={theme.spacing(30)}
width={"100%"}
backgroundColor={monitorsStatus.color}
borderRadius={theme.spacing(2)}
>
{monitorsStatus.icon}
{/* CAIO_REVIEW */}
<Typography variant="h2DarkBg">{monitorsStatus.msg}</Typography>
</Stack>
);
};
export default StatusBar;
StatusBar.propTypes = {
status: PropTypes.object,
};
@@ -1,166 +0,0 @@
// Components
import { Typography, Stack } from "@mui/material";
import GenericFallback from "@/Components/v1/GenericFallback/index.jsx";
import AdminLink from "./Components/AdminLink/index.jsx";
import ControlsHeader from "./Components/ControlsHeader/index.jsx";
import SkeletonLayout from "./Components/Skeleton/index.jsx";
import StatusBar from "./Components/StatusBar/index.jsx";
import MonitorsList from "./Components/MonitorsList/index.jsx";
import Breadcrumbs from "@/Components/v1/Breadcrumbs/index.jsx";
import TextLink from "@/Components/v1/TextLink/index.jsx";
// Utils
import { useStatusPageFetch } from "./Hooks/useStatusPageFetch.jsx";
import { useTheme } from "@emotion/react";
import { useIsAdmin } from "@/Hooks/useIsAdmin.js";
import { useLocation } from "react-router-dom";
import { useParams } from "react-router-dom";
import { useTranslation } from "react-i18next";
const PublicStatus = () => {
const { url } = useParams();
// Utils
const theme = useTheme();
const { t } = useTranslation();
const location = useLocation();
const isAdmin = useIsAdmin();
const [statusPage, monitors, isLoading, networkError] = useStatusPageFetch(false, url);
// Breadcrumbs
const crumbs = [
{ name: t("statusBreadCrumbsStatusPages"), path: "/status" },
{ name: t("statusBreadCrumbsDetails"), path: `/status/uptime/${statusPage?.url}` },
];
// Setup
let sx = { paddingLeft: theme.spacing(20), paddingRight: theme.spacing(20) };
let link = undefined;
const isPublic = location.pathname.startsWith("/status/uptime/public");
// Public status page
if (isPublic && statusPage && statusPage.showAdminLoginLink === true) {
sx = {
paddingTop: theme.spacing(20),
paddingLeft: "20vw",
paddingRight: "20vw",
};
link = <AdminLink />;
}
// Loading
if (isLoading) {
return <SkeletonLayout />;
}
if (monitors?.length === 0) {
return (
<GenericFallback>
<Typography
variant="h1"
marginY={theme.spacing(4)}
color={theme.palette.primary.contrastTextTertiary}
>
{"Theres nothing here yet"}
</Typography>
{isAdmin && (
<TextLink
linkText={"Add a monitor to get started"}
href={`/status/uptime/configure/${url}`}
/>
)}
</GenericFallback>
);
}
// Error fetching data
if (networkError === true) {
return (
<GenericFallback>
<Typography
variant="h1"
marginY={theme.spacing(4)}
color={theme.palette.primary.contrastTextTertiary}
>
{t("common.toasts.networkError")}
</Typography>
<Typography>{t("common.toasts.checkConnection")}</Typography>
</GenericFallback>
);
}
// Public status page fallback
if (!isLoading && typeof statusPage === "undefined" && isPublic) {
return (
<Stack sx={sx}>
<GenericFallback>
<Typography
variant="h1"
marginY={theme.spacing(4)}
color={theme.palette.primary.contrastTextTertiary}
>
{t("statusPageStatus")}
</Typography>
<Typography>{t("statusPageStatusContactAdmin")}</Typography>
</GenericFallback>
</Stack>
);
}
// Finished loading, but status page is not public
if (!isLoading && isPublic && statusPage.isPublished === false) {
return (
<Stack sx={sx}>
<GenericFallback>
<Typography
variant="h1"
marginY={theme.spacing(4)}
color={theme.palette.primary.contrastTextTertiary}
>
{t("statusPageStatusNotPublic")}
</Typography>
<Typography>{t("statusPageStatusContactAdmin")}</Typography>
</GenericFallback>
</Stack>
);
}
// Status page doesn't exist
if (!isLoading && typeof statusPage === "undefined") {
return (
<GenericFallback>
<Typography
variant="h1"
marginY={theme.spacing(4)}
color={theme.palette.primary.contrastTextTertiary}
>
{t("statusPageStatusNoPage")}
</Typography>
<Typography>{t("statusPageStatusContactAdmin")}</Typography>
</GenericFallback>
);
}
return (
<Stack
gap={theme.spacing(10)}
sx={{ ...sx, position: "relative" }}
>
{!isPublic && <Breadcrumbs list={crumbs} />}
<ControlsHeader
statusPage={statusPage}
url={url}
isPublic={isPublic}
/>
<Typography variant="h2">{t("statusPageStatusServiceStatus")}</Typography>
<StatusBar monitors={monitors} />
<MonitorsList
monitors={monitors}
statusPage={statusPage}
/>
{link}
</Stack>
);
};
export default PublicStatus;
@@ -0,0 +1,60 @@
import { BasePage } from "@/Components/v2/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 { 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";
const StatusPageView = () => {
const theme = useTheme();
const { t } = useTranslation();
const { url } = useParams();
const isAdmin = useIsAdmin();
const location = useLocation();
const isPublic = location.pathname.startsWith("/status/uptime/public");
const apiUrl = url ? `/status-page/${url}?type=uptime` : null;
const { data, isLoading, error } = useGet<StatusPageResponse>(apiUrl);
const statusPage = data?.statusPage;
const monitors = data?.monitors ?? [];
if (!statusPage) return null;
let sx: React.CSSProperties = {};
if (isPublic) {
sx.paddingTop = theme.spacing(20);
sx.paddingLeft = "20vw";
sx.paddingRight = "20vw";
}
return (
<BasePage
loading={isLoading}
error={error}
sx={sx}
breadcrumbOverride={isPublic ? [] : undefined}
>
<HeaderStatusPageControls
isAdmin={isAdmin}
statusPage={statusPage}
isPublic={isPublic}
/>
<Typography variant="h2">{t("statusPageStatusServiceStatus")}</Typography>
<StatusBar monitors={monitors} />
<MonitorsList
statusPage={statusPage}
monitors={monitors}
/>
</BasePage>
);
};
export default StatusPageView;
@@ -0,0 +1,109 @@
import Box from "@mui/material/Box";
import Stack from "@mui/material/Stack";
import Typography from "@mui/material/Typography";
import { Table, type Header, ValueLabel } from "@/Components/v2/design-elements";
import { ExternalLink } from "lucide-react";
import { useTranslation } from "react-i18next";
import { useTheme } from "@mui/material/styles";
import { useNavigate } from "react-router-dom";
import type { StatusPage } from "@/Types/StatusPage";
interface StatusPagesTableProps {
data: StatusPage[];
}
export const StatusPagesTable = ({ data }: StatusPagesTableProps) => {
const { t } = useTranslation();
const theme = useTheme();
const navigate = useNavigate();
const handleUrlClick = (e: React.MouseEvent, row: StatusPage) => {
if (row.isPublished) {
e.stopPropagation();
const url = `/status/uptime/public/${row.url}`;
window.open(url, "_blank", "noopener,noreferrer");
}
};
const getHeaders = (): Header<StatusPage>[] => {
return [
{
id: "name",
content: t("pages.statusPages.table.headers.name"),
render: (row) => row.companyName,
},
{
id: "url",
content: t("pages.statusPages.table.headers.url"),
render: (row) => {
const content = row.isPublished
? `/${row.url}`
: t("pages.statusPages.table.unpublished");
return (
<Stack
direction="row"
alignItems="center"
justifyContent="center"
gap={theme.spacing(2)}
paddingLeft={theme.spacing(2)}
paddingRight={theme.spacing(2)}
onClick={(e) => handleUrlClick(e, row)}
sx={{
...(row.isPublished && {
display: "inline-flex",
":hover": {
cursor: "pointer",
borderBottom: 1,
},
}),
}}
>
<Typography>{content}</Typography>
{row.isPublished && <ExternalLink size={18} />}
</Stack>
);
},
},
{
id: "type",
content: t("common.table.headers.type"),
render: (row) => row.type,
},
{
id: "status",
content: t("common.table.headers.status"),
render: (row) => {
return (
<ValueLabel
value={row.isPublished ? "positive" : "neutral"}
text={
row.isPublished
? t("pages.statusPages.table.published")
: t("pages.statusPages.table.unpublished")
}
/>
);
},
},
];
};
const handleRowClick = (statusPage: StatusPage) => {
navigate(`/status/uptime/${statusPage.url}`);
};
return (
<Box>
<Table
headers={getHeaders()}
data={data}
onRowClick={handleRowClick}
emptyViewText={t("common.table.empty")}
/>
</Box>
);
};
export default StatusPagesTable;
@@ -1,107 +0,0 @@
import DataTable from "@/Components/v1/Table/index.jsx";
import { useTheme } from "@emotion/react";
import { useNavigate } from "react-router-dom";
import { StatusLabel } from "@/Components/v1/Label/index.jsx";
import Icon from "@/Components/v1/Icon";
import { Stack, Typography } from "@mui/material";
import { useTranslation } from "react-i18next";
const StatusPagesTable = ({ data }) => {
const theme = useTheme();
const { t } = useTranslation();
const navigate = useNavigate();
const headers = [
{
id: "name",
content: t("statusPageName"),
render: (row) => {
return row.companyName;
},
},
{
id: "url",
content: t("publicURL"),
onClick: (e, row) => {
if (row.isPublished) {
e.stopPropagation();
const url = `/status/uptime/public/${row.url}`;
window.open(url, "_blank", "noopener,noreferrer");
}
},
render: (row) => {
const content = row.isPublished ? `/${row.url}` : "Unpublished";
return (
<Stack
direction="row"
alignItems="center"
justifyContent="center"
gap={theme.spacing(2)}
paddingLeft={theme.spacing(2)}
paddingRight={theme.spacing(2)}
sx={{
...(row.isPublished && {
display: "inline-flex",
":hover": {
cursor: "pointer",
borderBottom: 1,
},
}),
}}
>
<Typography>{content}</Typography>
{row.isPublished && (
<Icon
name="ExternalLink"
size={18}
/>
)}
</Stack>
);
},
},
{
id: "type",
content: t("type"),
render: (row) => {
return row.type;
},
},
{
id: "status",
content: t("status"),
render: (row) => {
const status = row.isPublished ? "published" : "unpublished";
return (
<StatusLabel
status={status}
text={row.isPublished ? "Published" : "Unpublished"}
/>
);
},
},
];
const handleRowClick = (statusPage) => {
navigate(`/status/uptime/${statusPage.url}`);
};
return (
<DataTable
config={{
rowSX: {
cursor: "pointer",
"&:hover td": {
backgroundColor: theme.palette.tertiary.main,
transition: "background-color .3s ease",
},
},
onRowClick: (row) => {
handleRowClick(row);
},
}}
headers={headers}
data={data}
/>
);
};
export default StatusPagesTable;
@@ -1,32 +0,0 @@
import { useState, useEffect } from "react";
import { networkService } from "../../../../main.jsx";
import { useSelector } from "react-redux";
import { createToast } from "../../../../Utils/toastUtils.jsx";
const useStatusPagesFetch = () => {
const { user } = useSelector((state) => state.auth);
const [isLoading, setIsLoading] = useState(true);
const [networkError, setNetworkError] = useState(false);
const [statusPages, setStatusPages] = useState(undefined);
useEffect(() => {
const fetchStatusPages = async () => {
try {
const res = await networkService.getStatusPagesByTeamId();
setStatusPages(res?.data?.data || []);
} catch (error) {
setNetworkError(true);
createToast({
body: error.message,
});
} finally {
setIsLoading(false);
}
};
fetchStatusPages();
}, [user]);
return [isLoading, networkError, statusPages];
};
export { useStatusPagesFetch };
@@ -1,43 +0,0 @@
// Components
import { Stack } from "@mui/material";
import Breadcrumbs from "@/Components/v1/Breadcrumbs/index.jsx";
import MonitorCreateHeader from "@/Components/v1/MonitorCreateHeader/index.jsx";
import StatusPagesTable from "./Components/StatusPagesTable/index.jsx";
import PageStateWrapper from "@/Components/v1/PageStateWrapper/index.jsx";
// Utils
import { useTheme } from "@emotion/react";
import { useStatusPagesFetch } from "./Hooks/useStatusPagesFetch.jsx";
import { useIsAdmin } from "@/Hooks/useIsAdmin.js";
const BREADCRUMBS = [{ name: `Status Pages`, path: "" }];
const StatusPages = () => {
// Utils
const theme = useTheme();
const isAdmin = useIsAdmin();
const [isLoading, networkError, statusPages] = useStatusPagesFetch();
return (
<>
<PageStateWrapper
networkError={networkError}
isLoading={isLoading}
items={statusPages}
type="statusPage"
fallbackLink="/status/uptime/create"
>
<Stack gap={theme.spacing(10)}>
<Breadcrumbs list={BREADCRUMBS} />
<MonitorCreateHeader
label="Create status page"
isAdmin={isAdmin}
path="/status/uptime/create"
isLoading={isLoading}
/>
<StatusPagesTable data={statusPages} />
</Stack>
</PageStateWrapper>
</>
);
};
export default StatusPages;
@@ -0,0 +1,41 @@
import { BasePageWithStates } from "@/Components/v2/design-elements";
import { StatusPagesTable } from "./Components/StatusPagesTable";
import { useGet } from "@/Hooks/UseApi";
import type { StatusPage } from "@/Types/StatusPage";
import { useTranslation } from "react-i18next";
import { HeaderCreate } from "@/Components/v2/common";
import { useIsAdmin } from "@/Hooks/useIsAdmin";
const StatusPages = () => {
const { t } = useTranslation();
const {
data: statusPages,
isLoading,
error,
} = useGet<StatusPage[]>("/status-page/team");
const isAdmin = useIsAdmin();
return (
<BasePageWithStates
page={t("pages.statusPages.title")}
loading={isLoading}
bullets={
t("pages.statusPages.fallback.checks", { returnObjects: true }) as string[]
}
error={!!error}
items={statusPages ?? []}
actionButtonText={t("pages.statusPages.fallback.actionButton")}
actionLink="/status/uptime/create"
>
<HeaderCreate
path="/status/uptime/create"
isAdmin={isAdmin}
/>
<StatusPagesTable data={statusPages ?? []} />
</BasePageWithStates>
);
};
export default StatusPages;
+23 -5
View File
@@ -37,8 +37,8 @@ import Incidents from "../Pages/Incidents/";
// Status pages
import CreateStatus from "../Pages/StatusPage/Create/index.jsx";
import StatusPages from "../Pages/StatusPage/StatusPages/index.jsx";
import Status from "../Pages/StatusPage/Status/index.jsx";
import StatusPages from "../Pages/StatusPage/StatusPages";
import Status from "../Pages/StatusPage/Status";
import Notifications from "../Pages/Notifications";
import CreateNotifications from "../Pages/Notifications/create";
@@ -227,12 +227,24 @@ const Routes = () => {
<Route
path="status"
element={<StatusPages />}
element={
<>
<ThemeProvider theme={v2theme}>
<StatusPages />
</ThemeProvider>
</>
}
/>
<Route
path="status/uptime/:url"
element={<Status />}
element={
<>
<ThemeProvider theme={v2theme}>
<Status />
</ThemeProvider>
</>
}
/>
<Route
@@ -354,7 +366,13 @@ const Routes = () => {
/>
<Route
path="/status/uptime/public/:url"
element={<Status />}
element={
<>
<ThemeProvider theme={v2theme}>
<Status />
</ThemeProvider>
</>
}
/>
<Route
+31
View File
@@ -0,0 +1,31 @@
import type { Monitor } from "@/Types/Monitor";
export interface StatusPage {
id: string;
userId: string;
teamId: string;
type: string;
companyName: string;
url: string;
timezone?: string;
color: string;
monitors: string[];
subMonitors: string[];
originalMonitors?: string[];
logo?: {
data: string;
contentType: string;
} | null;
isPublished: boolean;
showCharts: boolean;
showUptimePercentage: boolean;
showAdminLoginLink: boolean;
customCSS: string;
createdAt: string;
updatedAt: string;
}
export interface StatusPageResponse {
statusPage: StatusPage;
monitors: Monitor[];
}
+7
View File
@@ -2,6 +2,13 @@ import type { Monitor, MonitorStatus, MonitorType } from "@/Types/Monitor";
import type { PaletteKey } from "@/Utils/Theme/v2Theme";
import type { ValueType } from "@/Components/v2/design-elements/StatusLabel";
export const determineState = (monitor: Monitor) => {
if (typeof monitor === "undefined") return "pending";
if (monitor?.isActive === false) return "paused";
if (monitor?.status === undefined) return "pending";
return monitor?.status == true ? "up" : "down";
};
export const getMonitorPath = (type: MonitorType): string => {
const pathMap: Record<MonitorType, string> = {
http: "uptime",
+45 -7
View File
@@ -22,7 +22,8 @@
"cancel": "Cancel",
"confirm": "Confirm",
"save": "Save",
"test": "Test"
"test": "Test",
"configure": "Configure"
},
"alerts": {
@@ -452,6 +453,37 @@
}
}
}
},
"statusPages": {
"title": "Status Pages",
"table": {
"headers": {
"name": "Status page name",
"url": "Public URL"
},
"published": "Published",
"unpublished": "Unpublished"
},
"fallback": {
"actionButton": "Create a status page!",
"checks": [
"Monitor and display the health of your services in real time",
"Track multiple services and share their status",
"Keep users informed about outages and performance"
],
"title": "A status page is used to:"
},
"serviceStauts": "Service stauts",
"statusBar": {
"allUp": "All systems operational",
"allDown": "All systems down",
"degraded": "Degraded performance",
"unknown": "Unknown status"
},
"monitorsList": {
"chartTypeHeatmap": "Heatmap",
"chartTypeHistogram": "Histogram"
}
}
},
"used": "Used",
@@ -490,8 +522,6 @@
"cancel": "Cancel",
"close": "Close",
"type": "Type",
"statusPageName": "Status page name",
"publicURL": "Public URL",
"repeat": "Repeat",
"edit": "Edit",
"createA": "Create a",
@@ -516,10 +546,6 @@
"showCharts": "Show charts",
"showUptimePercentage": "Show uptime percentage",
"removeLogo": "Remove Logo",
"statusPageStatus": "A public status page is not set up.",
"statusPageStatusContactAdmin": "Please contact to your administrator",
"statusPageStatusNotPublic": "This status page is not public.",
"statusPageStatusNoPage": "There's no status page here.",
"statusPageStatusServiceStatus": "Service status",
"deleteStatusPage": "Do you want to delete this status page?",
"deleteStatusPageConfirm": "Yes, delete status page",
@@ -589,6 +615,10 @@
"getToken": "Get token",
"emailToken": "E-mail token",
"table": {
"headers": {
"name": "Status page name",
"url": "Public URL"
},
"name": "Name",
"email": "Email",
"role": "Role",
@@ -793,6 +823,10 @@
}
},
"table": {
"headers": {
"name": "Status page name",
"url": "Public URL"
},
"timestamp": "Timestamp",
"level": "Level",
"service": "Service",
@@ -973,6 +1007,10 @@
"save": "Save"
},
"table": {
"headers": {
"name": "Status page name",
"url": "Public URL"
},
"actionHeader": "Action",
"roleHeader": "Role"
},
@@ -81,7 +81,7 @@ class StatusPageController {
const settings = await this.settingsService.getDBSettings();
const showURL = settings.showURL;
const monitors = await this.monitorsRepository.findByIdsWithChecks(statusPage.monitors);
const monitors = await this.monitorsRepository.findByIds(statusPage.monitors);
const normalizedMonitors = monitors.map((monitor) => {
const normalizedChecks = NormalizeData(monitor.recentChecks, 10, 100);
if (!showURL) {