Compare commits

...
17 changed files with 563 additions and 65 deletions

No files matched your search

+2
View File
@@ -27,12 +27,14 @@ export const useStatusPageForm = ({
companyName: data?.companyName || "", companyName: data?.companyName || "",
url: data?.url || generateDefaultUrl(), url: data?.url || generateDefaultUrl(),
timezone: data?.timezone || Intl.DateTimeFormat().resolvedOptions().timeZone, timezone: data?.timezone || Intl.DateTimeFormat().resolvedOptions().timeZone,
type: data?.type || ["uptime"],
color: data?.color || "#4169E1", color: data?.color || "#4169E1",
monitors: data?.monitors || [], monitors: data?.monitors || [],
isPublished: data?.isPublished ?? false, isPublished: data?.isPublished ?? false,
showCharts: data?.showCharts ?? true, showCharts: data?.showCharts ?? true,
showUptimePercentage: data?.showUptimePercentage ?? true, showUptimePercentage: data?.showUptimePercentage ?? true,
showAdminLoginLink: data?.showAdminLoginLink ?? false, showAdminLoginLink: data?.showAdminLoginLink ?? false,
showInfrastructure: data?.showInfrastructure ?? false,
customCSS: data?.customCSS || "", customCSS: data?.customCSS || "",
logo: transformLogo(data?.logo), logo: transformLogo(data?.logo),
}; };
+7 -7
View File
@@ -302,49 +302,49 @@ const CreateMonitorPage = () => {
> >
<RadioWithDescription <RadioWithDescription
value="http" value="http"
label={t("pages.createMonitor.form.type.optionHttp")} label={t("pages.common.monitors.monitorTypes.optionHttp")}
description={t( description={t(
"pages.createMonitor.form.type.optionHttpDescription" "pages.createMonitor.form.type.optionHttpDescription"
)} )}
/> />
<RadioWithDescription <RadioWithDescription
value="ping" value="ping"
label={t("pages.createMonitor.form.type.optionPing")} label={t("pages.common.monitors.monitorTypes.optionPing")}
description={t( description={t(
"pages.createMonitor.form.type.optionPingDescription" "pages.createMonitor.form.type.optionPingDescription"
)} )}
/> />
<RadioWithDescription <RadioWithDescription
value="docker" value="docker"
label={t("pages.createMonitor.form.type.optionDocker")} label={t("pages.common.monitors.monitorTypes.optionDocker")}
description={t( description={t(
"pages.createMonitor.form.type.optionDockerDescription" "pages.createMonitor.form.type.optionDockerDescription"
)} )}
/> />
<RadioWithDescription <RadioWithDescription
value="port" value="port"
label={t("pages.createMonitor.form.type.optionPort")} label={t("pages.common.monitors.monitorTypes.optionPort")}
description={t( description={t(
"pages.createMonitor.form.type.optionPortDescription" "pages.createMonitor.form.type.optionPortDescription"
)} )}
/> />
<RadioWithDescription <RadioWithDescription
value="game" value="game"
label={t("pages.createMonitor.form.type.optionGame")} label={t("pages.common.monitors.monitorTypes.optionGame")}
description={t( description={t(
"pages.createMonitor.form.type.optionGameDescription" "pages.createMonitor.form.type.optionGameDescription"
)} )}
/> />
<RadioWithDescription <RadioWithDescription
value="grpc" value="grpc"
label={t("pages.createMonitor.form.type.optionGrpc")} label={t("pages.common.monitors.monitorTypes.optionGrpc")}
description={t( description={t(
"pages.createMonitor.form.type.optionGrpcDescription" "pages.createMonitor.form.type.optionGrpcDescription"
)} )}
/> />
<RadioWithDescription <RadioWithDescription
value="websocket" value="websocket"
label={t("pages.createMonitor.form.type.optionWebSocket")} label={t("pages.common.monitors.monitorTypes.optionWebSocket")}
description={t( description={t(
"pages.createMonitor.form.type.optionWebSocketDescription" "pages.createMonitor.form.type.optionWebSocketDescription"
)} )}
+65 -6
View File
@@ -21,14 +21,14 @@ import {
import { useTheme } from "@mui/material/styles"; import { useTheme } from "@mui/material/styles";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import { useEffect, useState } from "react"; import { useEffect, useMemo, useState } from "react";
import { useForm, Controller } from "react-hook-form"; import { useForm, Controller } from "react-hook-form";
import { zodResolver } from "@hookform/resolvers/zod"; import { zodResolver } from "@hookform/resolvers/zod";
import { useStatusPageForm } from "@/Hooks/useStatusPageForm"; import { useStatusPageForm } from "@/Hooks/useStatusPageForm";
import type { StatusPageFormData } from "@/Validation/statusPage"; import type { StatusPageFormData } from "@/Validation/statusPage";
import { useGet, usePost, usePut, useDelete } from "@/Hooks/UseApi"; import { useGet, usePost, usePut, useDelete } from "@/Hooks/UseApi";
import type { Monitor } from "@/Types/Monitor"; import type { Monitor } from "@/Types/Monitor";
import type { StatusPageResponse } from "@/Types/StatusPage"; import type { MonitorDisplayType, StatusPageResponse } from "@/Types/StatusPage";
import timezones from "@/Utils/timezones.json"; import timezones from "@/Utils/timezones.json";
import { useNavigate, useParams } from "react-router-dom"; import { useNavigate, useParams } from "react-router-dom";
import axios from "axios"; import axios from "axios";
@@ -39,6 +39,22 @@ interface TimezoneOption {
name: string; name: string;
} }
const MONITOR_TYPE_KEYS: Record<string, string> = {
http: "pages.common.monitors.monitorTypes.optionHttp",
ping: "pages.common.monitors.monitorTypes.optionPing",
docker: "pages.common.monitors.monitorTypes.optionDocker",
port: "pages.common.monitors.monitorTypes.optionPort",
game: "pages.common.monitors.monitorTypes.optionGame",
grpc: "pages.common.monitors.monitorTypes.optionGrpc",
websocket: "pages.common.monitors.monitorTypes.optionWebSocket",
hardware: "pages.common.monitors.monitorTypes.optionHardware",
};
const getMonitorTypeLabel = (type: string, t: (key: string) => string): string => {
const i18nMonitorKey = MONITOR_TYPE_KEYS[type];
return i18nMonitorKey ? t(i18nMonitorKey) : type;
};
const CreateStatusPage = () => { const CreateStatusPage = () => {
const theme = useTheme(); const theme = useTheme();
const { t } = useTranslation(); const { t } = useTranslation();
@@ -49,10 +65,12 @@ const CreateStatusPage = () => {
// Fetch existing status page data when configuring // Fetch existing status page data when configuring
const { data: statusPageData, isLoading: isLoadingStatusPage } = const { data: statusPageData, isLoading: isLoadingStatusPage } =
useGet<StatusPageResponse>(isCreate ? null : `/status-page/${url}?type=uptime`); useGet<StatusPageResponse>(
isCreate ? null : `/status-page/${url}?type=uptime&type=infrastructure`
);
const { data: monitorsResponse } = useGet<Monitor[]>( const { data: monitorsResponse } = useGet<Monitor[]>(
"/monitors/team?type=http&type=ping&type=port&type=docker" "/monitors/team?type=http&type=ping&type=port&type=docker&type=hardware"
); );
const monitors = monitorsResponse ?? []; const monitors = monitorsResponse ?? [];
@@ -81,6 +99,24 @@ const CreateStatusPage = () => {
reset(defaults); reset(defaults);
}, [defaults, reset]); }, [defaults, reset]);
const watchedMonitorIds: string[] = form.watch("monitors") ?? [];
const computedTypes: MonitorDisplayType[] = useMemo(() => {
const selectedMonitors = (watchedMonitorIds ?? [])
.map((id) => monitors.find((m) => m.id === id))
.filter((m): m is Monitor => m !== undefined);
const typesSet = new Set<MonitorDisplayType>();
selectedMonitors.forEach((m) => {
typesSet.add(m.type === "hardware" ? "infrastructure" : "uptime");
});
return typesSet.size ? Array.from(typesSet) : ["uptime"];
}, [JSON.stringify(watchedMonitorIds), monitors]);
useEffect(() => {
form.setValue("type", computedTypes);
}, [computedTypes]);
const onError = (errors: any) => { const onError = (errors: any) => {
logger.debug("Status page validation errors", errors); logger.debug("Status page validation errors", errors);
}; };
@@ -103,7 +139,6 @@ const CreateStatusPage = () => {
const onSubmit = async (data: StatusPageFormData) => { const onSubmit = async (data: StatusPageFormData) => {
const fd = new FormData(); const fd = new FormData();
fd.append("type", "uptime");
fd.append("isPublished", String(data.isPublished)); fd.append("isPublished", String(data.isPublished));
if (data.companyName) fd.append("companyName", data.companyName); if (data.companyName) fd.append("companyName", data.companyName);
if (data.url) fd.append("url", data.url); if (data.url) fd.append("url", data.url);
@@ -112,11 +147,16 @@ const CreateStatusPage = () => {
fd.append("showCharts", String(data.showCharts)); fd.append("showCharts", String(data.showCharts));
fd.append("showUptimePercentage", String(data.showUptimePercentage)); fd.append("showUptimePercentage", String(data.showUptimePercentage));
fd.append("showAdminLoginLink", String(data.showAdminLoginLink)); fd.append("showAdminLoginLink", String(data.showAdminLoginLink));
fd.append("showInfrastructure", String(data.showInfrastructure));
data.monitors.forEach((monitorId) => { data.monitors.forEach((monitorId) => {
fd.append("monitors[]", monitorId); fd.append("monitors[]", monitorId);
}); });
data.type.forEach((type) => {
fd.append("type[]", type);
});
// Handle logo upload // Handle logo upload
if (data.logo === null) { if (data.logo === null) {
// Signal to remove the logo // Signal to remove the logo
@@ -303,7 +343,9 @@ const CreateStatusPage = () => {
}} }}
> >
<GripVertical size={20} /> <GripVertical size={20} />
<Typography flexGrow={1}>{monitor.name}</Typography> <Typography
flexGrow={1}
>{`${monitor.name} (${getMonitorTypeLabel(monitor.type, t)})`}</Typography>
<IconButton <IconButton
size="small" size="small"
onClick={() => { onClick={() => {
@@ -425,6 +467,23 @@ const CreateStatusPage = () => {
/> />
)} )}
/> />
<Controller
name="showInfrastructure"
control={control}
render={({ field }) => (
<FormControlLabel
control={
<Checkbox
checked={field.value}
onChange={field.onChange}
/>
}
label={t(
"pages.statusPages.form.features.option.showInfrastructure.label"
)}
/>
)}
/>
{/* <Controller {/* <Controller
name="showUptimePercentage" name="showUptimePercentage"
control={control} control={control}
@@ -0,0 +1,246 @@
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>
);
};
@@ -5,13 +5,15 @@ import Typography from "@mui/material/Typography";
import { HistogramResponseTime, HeatmapResponseTime } from "@/Components/common"; import { HistogramResponseTime, HeatmapResponseTime } from "@/Components/common";
import { StatusLabel, BaseBox } from "@/Components/design-elements"; import { StatusLabel, BaseBox } from "@/Components/design-elements";
import { SwitchComponent } from "@/Components/inputs"; import { SwitchComponent } from "@/Components/inputs";
import { InfrastructureMetrics } from "@/Pages/StatusPage/Status/Components/InfrastructureMetrics";
import { useTheme } from "@mui/material/styles"; import { useTheme, type Theme } from "@mui/material/styles";
import { useSelector } from "react-redux"; import { useSelector } from "react-redux";
import { useState } from "react"; import { useState } from "react";
import type { Monitor } from "@/Types/Monitor"; import type { Monitor } from "@/Types/Monitor";
import type { StatusPage } from "@/Types/StatusPage"; import type { StatusPage } from "@/Types/StatusPage";
import type { RootState } from "@/Types/state"; import type { RootState } from "@/Types/state";
import { LAYOUT, SPACING } from "@/Utils/Theme/constants";
interface StatusPageMonitor extends Monitor { interface StatusPageMonitor extends Monitor {
checks?: Monitor["recentChecks"]; checks?: Monitor["recentChecks"];
@@ -22,6 +24,87 @@ interface MonitorsListProps {
monitors: StatusPageMonitor[]; monitors: StatusPageMonitor[];
} }
const getMonitorBadgeStyles = (monitorType: string, theme: Theme) => {
// for future: specific badge styles can be defined here based on monitor type
const badgeConfig: Record<string, { color: string; bg: string }> = {
hardware: {
color: theme.palette.background.paper,
bg: theme.palette.info.light,
},
http: {
color: theme.palette.background.paper,
bg: theme.palette.success.light,
},
ping: {
color: theme.palette.background.paper,
bg: theme.palette.success.light,
},
port: {
color: theme.palette.background.paper,
bg: theme.palette.success.light,
},
docker: {
color: theme.palette.background.paper,
bg: theme.palette.success.light,
},
pagespeed: {
color: theme.palette.background.paper,
bg: theme.palette.success.light,
},
game: {
color: theme.palette.background.paper,
bg: theme.palette.success.light,
},
};
const config = badgeConfig[monitorType] ?? badgeConfig.http;
return {
backgroundColor: config.bg,
color: config.color,
padding: `${theme.spacing(SPACING.SM)} ${theme.spacing(SPACING.LG)}`,
borderRadius: theme.shape.borderRadius,
};
};
// Extracted component for monitor content below the header row
const MonitorContent = ({
monitor,
statusPage,
chartType,
}: {
monitor: Monitor & { checks?: Monitor["recentChecks"] };
statusPage: StatusPage;
chartType: string;
}) => {
const theme = useTheme();
const isInfrastructureMonitor = monitor?.type === "hardware";
if (isInfrastructureMonitor) {
if (statusPage.showInfrastructure === false) {
return null;
}
return <InfrastructureMetrics monitor={monitor} />;
}
if (statusPage.showCharts === false) {
return null;
}
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={monitor?.checks?.slice().reverse() ?? []}
/>
) : (
<HeatmapResponseTime checks={monitor?.checks?.slice().reverse() ?? []} />
)}
</Box>
);
};
export const MonitorsList = ({ statusPage, monitors }: MonitorsListProps) => { export const MonitorsList = ({ statusPage, monitors }: MonitorsListProps) => {
const theme = useTheme(); const theme = useTheme();
const { t } = useTranslation(); const { t } = useTranslation();
@@ -31,11 +114,12 @@ export const MonitorsList = ({ statusPage, monitors }: MonitorsListProps) => {
const [chartType, setChartType] = useState<"histogram" | "heatmap">("histogram"); const [chartType, setChartType] = useState<"histogram" | "heatmap">("histogram");
return ( return (
<Stack gap={theme.spacing(8)}> <Stack gap={theme.spacing(LAYOUT.MD)}>
{statusPage.showCharts !== false && ( {statusPage.showCharts && (
<Stack <Stack
direction={"row"} direction={"row"}
alignItems={"center"} alignItems={"center"}
gap={theme.spacing(LAYOUT.SM)}
> >
<Typography>{t("pages.statusPages.monitorsList.chartTypeHeatmap")}</Typography> <Typography>{t("pages.statusPages.monitorsList.chartTypeHeatmap")}</Typography>
<SwitchComponent <SwitchComponent
@@ -56,52 +140,62 @@ export const MonitorsList = ({ statusPage, monitors }: MonitorsListProps) => {
return ( return (
<BaseBox <BaseBox
key={monitor.id} key={monitor.id}
padding={theme.spacing(4)} padding={theme.spacing(LAYOUT.MD)}
> >
<Stack <Stack
direction="row" direction="row"
alignItems="center" alignItems="center"
justifyContent="space-between" justifyContent="space-between"
gap={theme.spacing(4)} gap={theme.spacing(LAYOUT.XS)}
mb={statusPage.showCharts !== false ? theme.spacing(4) : 0} mb={theme.spacing(LAYOUT.XS)}
> >
<Box sx={{ overflow: "hidden", minWidth: 0, flex: 1 }}> <Box sx={{ overflow: "hidden", minWidth: 0, flex: 1 }}>
<Typography <Stack
variant="h6" direction="row"
sx={{ alignItems="center"
overflow: "hidden", gap={theme.spacing(SPACING.LG)}
textOverflow: "ellipsis", mb={theme.spacing(SPACING.SM)}
whiteSpace: "nowrap",
}}
> >
{monitor.name} <Typography
</Typography> variant="h6"
{showURL && ( sx={{
overflow: "hidden",
textOverflow: "ellipsis",
whiteSpace: "nowrap",
}}
>
{monitor.name}
</Typography>
<Typography
variant="caption"
sx={getMonitorBadgeStyles(monitor?.type ?? "", theme)}
>
{t(
`pages.common.monitors.monitorTypes.option${monitor?.type.charAt(0).toUpperCase() + monitor?.type.slice(1)}`
)}
</Typography>
</Stack>
{showURL && monitor?.url && (
<Typography <Typography
variant="body2" variant="body2"
color="text.secondary" color={theme.palette.text.secondary}
sx={{
overflow: "hidden",
textOverflow: "ellipsis",
whiteSpace: "nowrap",
}}
> >
{monitor.url} {monitor.url}
</Typography> </Typography>
)} )}
</Box> </Box>
<StatusLabel status={monitor.status} /> <StatusLabel status={monitor?.status} />
</Stack> </Stack>
{statusPage.showCharts !== false && ( <MonitorContent
<Box sx={{ overflow: "hidden", minWidth: 0, flex: 1 }}> monitor={monitor}
{chartType === "histogram" ? ( statusPage={statusPage}
<HistogramResponseTime chartType={chartType}
height={{ xs: 50, md: 100 }} />
gap={{ xs: theme.spacing(0.5), md: theme.spacing(5) }}
checks={monitor?.checks?.slice().reverse() ?? []}
/>
) : (
<HeatmapResponseTime
checks={monitor?.checks?.slice().reverse() ?? []}
/>
)}
</Box>
)}
</BaseBox> </BaseBox>
); );
})} })}
+3 -2
View File
@@ -23,16 +23,17 @@ const StatusPageView = () => {
const isSmall = useMediaQuery(theme.breakpoints.down("md")); const isSmall = useMediaQuery(theme.breakpoints.down("md"));
const isPublic = location.pathname.startsWith("/status/public"); const isPublic = location.pathname.startsWith("/status/public");
const apiUrl = url ? `/status-page/${url}?type=uptime` : null; const apiUrl = url ? `/status-page/${url}?type=uptime&type=infrastructure` : null;
const { data, isLoading, error } = useGet<StatusPageResponse>(apiUrl); const { data, isLoading, error } = useGet<StatusPageResponse>(apiUrl);
const statusPage = data?.statusPage; const statusPage = data?.statusPage;
const monitors = data?.monitors ?? []; const monitors = data?.monitors ?? [];
const infrastructureMonitors = data?.infrastructureMonitors ?? [];
if (!statusPage) return null; if (!statusPage) return null;
if (monitors?.length === 0) { if (monitors?.length === 0 && infrastructureMonitors?.length === 0) {
return ( return (
<BasePage <BasePage
loading={isLoading} loading={isLoading}
@@ -97,7 +97,7 @@ export const StatusPagesTable = ({
{ {
id: "type", id: "type",
content: t("common.table.headers.type"), content: t("common.table.headers.type"),
render: (row) => row.type, render: (row) => row.type.join(", "),
}, },
{ {
id: "status", id: "status",
+4 -1
View File
@@ -1,10 +1,11 @@
import type { Monitor } from "@/Types/Monitor"; import type { Monitor } from "@/Types/Monitor";
export type MonitorDisplayType = "uptime" | "infrastructure";
export interface StatusPage { export interface StatusPage {
id: string; id: string;
userId: string; userId: string;
teamId: string; teamId: string;
type: string; type: MonitorDisplayType[];
companyName: string; companyName: string;
url: string; url: string;
timezone?: string; timezone?: string;
@@ -20,6 +21,7 @@ export interface StatusPage {
showCharts: boolean; showCharts: boolean;
showUptimePercentage: boolean; showUptimePercentage: boolean;
showAdminLoginLink: boolean; showAdminLoginLink: boolean;
showInfrastructure: boolean;
customCSS: string; customCSS: string;
createdAt: string; createdAt: string;
updatedAt: string; updatedAt: string;
@@ -28,4 +30,5 @@ export interface StatusPage {
export interface StatusPageResponse { export interface StatusPageResponse {
statusPage: StatusPage; statusPage: StatusPage;
monitors: Monitor[]; monitors: Monitor[];
infrastructureMonitors?: Monitor[];
} }
+4
View File
@@ -14,12 +14,16 @@ export const statusPageSchema = z.object({
"URL can only contain lowercase letters, numbers, and hyphens" "URL can only contain lowercase letters, numbers, and hyphens"
), ),
timezone: z.string().optional(), timezone: z.string().optional(),
type: z
.array(z.enum(["uptime", "infrastructure"]))
.min(1, "At least one type is required"),
color: z.string().min(1, "Color is required"), color: z.string().min(1, "Color is required"),
monitors: z.array(z.string()).min(1, "At least one monitor is required"), monitors: z.array(z.string()).min(1, "At least one monitor is required"),
isPublished: z.boolean(), isPublished: z.boolean(),
showCharts: z.boolean(), showCharts: z.boolean(),
showUptimePercentage: z.boolean(), showUptimePercentage: z.boolean(),
showAdminLoginLink: z.boolean(), showAdminLoginLink: z.boolean(),
showInfrastructure: z.boolean(),
customCSS: z.string().optional(), customCSS: z.string().optional(),
logo: z logo: z
.object({ .object({
+8 -1
View File
@@ -288,7 +288,13 @@ const logoImageValidation = joi
.optional(); // Make entire object optional .optional(); // Make entire object optional
const statusPageValidation = joi.object({ const statusPageValidation = joi.object({
type: joi.string().valid("uptime").required(), type: joi
.alternatives()
.try(
joi.string().valid("uptime", "infrastructure"),
joi.array().items(joi.string().valid("uptime", "infrastructure"))
)
.required(),
isPublished: joi.bool(), isPublished: joi.bool(),
companyName: joi companyName: joi
.string() .string()
@@ -317,6 +323,7 @@ const statusPageValidation = joi.object({
showUptimePercentage: joi.boolean(), showUptimePercentage: joi.boolean(),
showCharts: joi.boolean(), showCharts: joi.boolean(),
showAdminLoginLink: joi.boolean(), showAdminLoginLink: joi.boolean(),
showInfrastructure: joi.boolean(),
}); });
const settingsValidation = joi.object({ const settingsValidation = joi.object({
+30 -8
View File
@@ -407,6 +407,17 @@
"paused": "paused", "paused": "paused",
"total": "total", "total": "total",
"up": "up" "up": "up"
},
"monitorTypes": {
"optionDocker": "Docker",
"optionGame": "Game",
"optionHttp": "HTTP(S)",
"optionPing": "Ping",
"optionPort": "Port",
"optionPagespeed": "PageSpeed",
"optionHardware": "Infrastructure",
"optionGrpc": "gRPC",
"optionWebSocket": "WebSocket"
} }
} }
}, },
@@ -534,19 +545,12 @@
}, },
"type": { "type": {
"description": "Select the type of check to perform", "description": "Select the type of check to perform",
"optionDocker": "Docker",
"optionDockerDescription": "Use Docker to monitor if a container is running.", "optionDockerDescription": "Use Docker to monitor if a container is running.",
"optionGame": "Game",
"optionGameDescription": "Monitor if a specific game server is online.", "optionGameDescription": "Monitor if a specific game server is online.",
"optionGrpc": "gRPC",
"optionGrpcDescription": "Monitor gRPC services using the standard Health Checking Protocol.", "optionGrpcDescription": "Monitor gRPC services using the standard Health Checking Protocol.",
"optionWebSocket": "WebSocket",
"optionWebSocketDescription": "Monitor WebSocket endpoints for connection health and response time.", "optionWebSocketDescription": "Monitor WebSocket endpoints for connection health and response time.",
"optionHttp": "HTTP(S)",
"optionHttpDescription": "Use HTTP(S) to monitor your website or API endpoint.", "optionHttpDescription": "Use HTTP(S) to monitor your website or API endpoint.",
"optionPing": "Ping",
"optionPingDescription": "Use ICMP Ping to monitor if a server is online.", "optionPingDescription": "Use ICMP Ping to monitor if a server is online.",
"optionPort": "Port",
"optionPortDescription": "Monitor if a specific port on a server is open.", "optionPortDescription": "Monitor if a specific port on a server is open.",
"title": "Type" "title": "Type"
}, },
@@ -1178,7 +1182,22 @@
}, },
"monitorsList": { "monitorsList": {
"chartTypeHeatmap": "Heatmap", "chartTypeHeatmap": "Heatmap",
"chartTypeHistogram": "Histogram" "chartTypeHistogram": "Histogram",
"noData": "No data available",
"infrastructure": {
"title": "Infrastructure",
"cpu": "CPU",
"memory": "RAM",
"memoryText": "Memory",
"disk": "Disk",
"usage": "Usage",
"used": "Used",
"total": "Total"
},
"uptime": {
"title": "Uptime",
"responseTime": "Response time"
}
}, },
"statusBar": { "statusBar": {
"allDown": "All systems are down", "allDown": "All systems are down",
@@ -1270,6 +1289,9 @@
}, },
"showAdminLoginLink": { "showAdminLoginLink": {
"label": "Show admin login link" "label": "Show admin login link"
},
"showInfrastructure": {
"label": "Show infrastructure metrics"
} }
} }
} }
@@ -0,0 +1,52 @@
import mongoose from "mongoose";
import { logger } from "@/utils/logger.js";
import StatusPageModel from "../models/StatusPage.js";
/**
* Migration: Convert StatusPage `type` field from string to array of strings
*
* Old format: { type: "uptime" }
* New format: { type: ["uptime"] }
*/
export async function migrateStatusPageTypeToArray(): Promise<void> {
const SERVICE_NAME = "Migration:MigrateStatusPageTypeToArray";
try {
logger.info({ service: SERVICE_NAME, message: "Starting migration of StatusPage type field from string to array" });
const db = mongoose.connection.db;
if (!db) {
throw new Error("Database connection is not initialized");
}
// Find all status pages where `type` is a string (not yet migrated)
const result = await StatusPageModel.updateMany(
{
$expr: {
$eq: [{ $type: "$type" }, "string"], // only scalar string, not existing array
},
},
[
{
$set: {
type: ["$type"],
},
},
]
);
if (result.modifiedCount === 0) {
logger.info({ service: SERVICE_NAME, message: "No StatusPage documents needed migration" });
return;
}
logger.info({
service: SERVICE_NAME,
message: `Migration complete. Converted ${result.modifiedCount} StatusPage document(s) from string type to array type`,
});
} catch (error) {
const errorMessage = error instanceof Error ? error.message : String(error);
logger.error({ service: SERVICE_NAME, message: `Error during StatusPage type migration: ${errorMessage}` });
throw error;
}
}
+2
View File
@@ -3,6 +3,7 @@ import { convertChecksToTimeSeries } from "./0002_convertChecksToTimeSeries.js";
import { cleanupDuplicateMonitorStats } from "./0003_cleanupDuplicateMonitorStats.js"; import { cleanupDuplicateMonitorStats } from "./0003_cleanupDuplicateMonitorStats.js";
import { fixInfrastructureThresholds } from "./0004_fixInfrastructureThresholds.js"; import { fixInfrastructureThresholds } from "./0004_fixInfrastructureThresholds.js";
import MigrationModel from "../models/Migration.js"; import MigrationModel from "../models/Migration.js";
import { migrateStatusPageTypeToArray } from "./0005_migrateStatusPageTypeToArray.js";
type MigrationEntry = { type MigrationEntry = {
name: string; name: string;
@@ -14,6 +15,7 @@ const migrations: MigrationEntry[] = [
{ name: "0002_convertChecksToTimeSeries", execute: convertChecksToTimeSeries }, { name: "0002_convertChecksToTimeSeries", execute: convertChecksToTimeSeries },
{ name: "0003_cleanupDuplicateMonitorStats", execute: cleanupDuplicateMonitorStats }, { name: "0003_cleanupDuplicateMonitorStats", execute: cleanupDuplicateMonitorStats },
{ name: "0004_fixInfrastructureThresholds", execute: fixInfrastructureThresholds }, { name: "0004_fixInfrastructureThresholds", execute: fixInfrastructureThresholds },
{ name: "0005_migrateStatusPageTypeToArray", execute: migrateStatusPageTypeToArray },
]; ];
const runMigrations = async (logger?: { info: Function; error: Function }) => { const runMigrations = async (logger?: { info: Function; error: Function }) => {
+6 -2
View File
@@ -43,9 +43,9 @@ const StatusPageSchema = new Schema<StatusPageDocument>(
required: true, required: true,
}, },
type: { type: {
type: String, type: [String],
required: true, required: true,
default: "uptime", default: [],
enum: StatusPageTypes, enum: StatusPageTypes,
}, },
companyName: { companyName: {
@@ -100,6 +100,10 @@ const StatusPageSchema = new Schema<StatusPageDocument>(
type: Boolean, type: Boolean,
default: false, default: false,
}, },
showInfrastructure: {
type: Boolean,
default: false,
},
customCSS: { customCSS: {
type: String, type: String,
default: "", default: "",
@@ -57,6 +57,7 @@ class MongoStatusPagesRepository implements IStatusPagesRepository {
showCharts: doc.showCharts, showCharts: doc.showCharts,
showUptimePercentage: doc.showUptimePercentage, showUptimePercentage: doc.showUptimePercentage,
showAdminLoginLink: doc.showAdminLoginLink, showAdminLoginLink: doc.showAdminLoginLink,
showInfrastructure: doc.showInfrastructure,
customCSS: doc.customCSS, customCSS: doc.customCSS,
createdAt: this.toDateString(doc.createdAt), createdAt: this.toDateString(doc.createdAt),
updatedAt: this.toDateString(doc.updatedAt), updatedAt: this.toDateString(doc.updatedAt),
+3 -2
View File
@@ -1,4 +1,4 @@
export const StatusPageTypes = ["uptime"] as const; export const StatusPageTypes = ["uptime", "infrastructure"] as const;
export type StatusPageType = (typeof StatusPageTypes)[number]; export type StatusPageType = (typeof StatusPageTypes)[number];
export interface StatusPageLogo { export interface StatusPageLogo {
@@ -15,7 +15,7 @@ export interface StatusPage {
id: string; id: string;
userId: string; userId: string;
teamId: string; teamId: string;
type: StatusPageType; type: StatusPageType[];
companyName: string; companyName: string;
url: string; url: string;
timezone?: string; timezone?: string;
@@ -28,6 +28,7 @@ export interface StatusPage {
showCharts: boolean; showCharts: boolean;
showUptimePercentage: boolean; showUptimePercentage: boolean;
showAdminLoginLink: boolean; showAdminLoginLink: boolean;
showInfrastructure: boolean;
customCSS: string; customCSS: string;
createdAt: string; createdAt: string;
updatedAt: string; updatedAt: string;
@@ -10,13 +10,13 @@ export const getStatusPageParamValidation = z.object({
}); });
export const getStatusPageQueryValidation = z.object({ export const getStatusPageQueryValidation = z.object({
type: z.literal("uptime"), type: z.array(z.enum(["uptime", "infrastructure"])),
timeFrame: z.coerce.number().optional(), timeFrame: z.coerce.number().optional(),
}); });
export const createStatusPageBodyValidation = z export const createStatusPageBodyValidation = z
.object({ .object({
type: z.literal("uptime"), type: z.array(z.enum(["uptime", "infrastructure"])),
companyName: z.string().min(1, "Company name is required"), companyName: z.string().min(1, "Company name is required"),
url: z.string().regex(/^[a-zA-Z0-9_-]+$/, { url: z.string().regex(/^[a-zA-Z0-9_-]+$/, {
message: "URL can only contain letters, numbers, underscores, and hyphens", message: "URL can only contain letters, numbers, underscores, and hyphens",