feat: adding infrastructure display support to status page

This commit is contained in:
mannilakash committed 2026-03-11 13:57:33 -04:00
1 parent 7a0e0bccf4
commit be1ca4ac28
13 files changed
+341 -41

No files matched your search

+2
View File
@@ -27,12 +27,14 @@ export const useStatusPageForm = ({
companyName: data?.companyName || "",
url: data?.url || generateDefaultUrl(),
timezone: data?.timezone || Intl.DateTimeFormat().resolvedOptions().timeZone,
type: data?.type || ["uptime"],
color: data?.color || "#4169E1",
monitors: data?.monitors || [],
isPublished: data?.isPublished ?? false,
showCharts: data?.showCharts ?? true,
showUptimePercentage: data?.showUptimePercentage ?? true,
showAdminLoginLink: data?.showAdminLoginLink ?? false,
showInfrastructure: data?.showInfrastructure ?? false,
customCSS: data?.customCSS || "",
logo: transformLogo(data?.logo),
};
+48 -4
View File
@@ -28,7 +28,7 @@ import { useStatusPageForm } from "@/Hooks/useStatusPageForm";
import type { StatusPageFormData } from "@/Validation/statusPage";
import { useGet, usePost, usePut, useDelete } from "@/Hooks/UseApi";
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 { useNavigate, useParams } from "react-router-dom";
import axios from "axios";
@@ -49,10 +49,12 @@ const CreateStatusPage = () => {
// Fetch existing status page data when configuring
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[]>(
"/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 ?? [];
@@ -81,6 +83,27 @@ const CreateStatusPage = () => {
reset(defaults);
}, [defaults, reset]);
const watchedMonitorIds: string[] = form.watch("monitors") ?? [];
useEffect(() => {
const selectedMonitors = (watchedMonitorIds ?? [])
.map((id) => monitors.find((m) => m.id === id))
.filter((m): m is Monitor => m !== undefined);
const typesSet: Set<MonitorDisplayType> = new Set();
selectedMonitors.forEach((m) => {
if (m.type === "hardware") {
typesSet.add("infrastructure");
} else {
typesSet.add("uptime");
}
});
const computedTypes: MonitorDisplayType[] = typesSet.size
? Array.from(typesSet)
: ["uptime"];
form.setValue("type", computedTypes);
}, [watchedMonitorIds, monitors, form]);
const onError = (errors: any) => {
logger.debug("Status page validation errors", errors);
};
@@ -103,7 +126,6 @@ const CreateStatusPage = () => {
const onSubmit = async (data: StatusPageFormData) => {
const fd = new FormData();
fd.append("type", "uptime");
fd.append("isPublished", String(data.isPublished));
if (data.companyName) fd.append("companyName", data.companyName);
if (data.url) fd.append("url", data.url);
@@ -112,11 +134,16 @@ const CreateStatusPage = () => {
fd.append("showCharts", String(data.showCharts));
fd.append("showUptimePercentage", String(data.showUptimePercentage));
fd.append("showAdminLoginLink", String(data.showAdminLoginLink));
fd.append("showInfrastructure", String(data.showInfrastructure));
data.monitors.forEach((monitorId) => {
fd.append("monitors[]", monitorId);
});
data.type.forEach((type) => {
fd.append("type[]", type);
});
// Handle logo upload
if (data.logo === null) {
// Signal to remove the logo
@@ -425,6 +452,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
name="showUptimePercentage"
control={control}
@@ -0,0 +1,152 @@
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";
interface StatusPageMonitor extends Monitor {
checks?: Monitor["recentChecks"];
infrastructureCPU?: number;
infrastructureMemory?: number;
infrastructureDisk?: number;
}
interface MetricItemProps {
label: string;
progress: number;
upperLabel?: string;
upperValue?: string;
lowerLabel?: string;
lowerValue?: string;
}
const MetricItem = ({
label,
progress,
upperLabel,
upperValue,
lowerLabel,
lowerValue,
}: MetricItemProps) => {
const theme = useTheme();
return (
<Stack
direction="row"
alignItems="center"
gap={`${theme.spacing(4)} ${theme.spacing(10)}`}
flex={1}
>
<Stack
alignItems="center"
spacing={theme.spacing(2)}
>
<Gauge
progress={progress}
radius={60}
strokeWidth={12}
/>
<Typography variant="body2">{label}</Typography>
</Stack>
{(upperLabel || lowerLabel) && (
<Stack
spacing={theme.spacing(1)}
flex={1}
paddingBottom={theme.spacing(10)}
>
{upperLabel && (
<Stack
direction="row"
justifyContent="space-between"
>
<Typography
variant="body2"
color="text.secondary"
>
{upperLabel}
</Typography>
<Typography variant="body2">{upperValue}</Typography>
</Stack>
)}
{lowerLabel && (
<Stack
direction="row"
justifyContent="space-between"
>
<Typography
variant="body2"
color="text.secondary"
>
{lowerLabel}
</Typography>
<Typography variant="body2">{lowerValue}</Typography>
</Stack>
)}
</Stack>
)}
</Stack>
);
};
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="text.secondary"
>
{t("pages.statusPages.monitorsList.noData")}
</Typography>
);
}
const cpuUsage = (latestCheck?.cpu?.usage_percent || 0) * 100;
const memoryUsage = (latestCheck?.memory?.usage_percent || 0) * 100;
const memoryUsed = latestCheck?.memory?.used_bytes || 0;
const memoryTotal = latestCheck?.memory?.total_bytes || 0;
const disks = latestCheck?.disk ?? [];
const totalDiskUsage = disks.reduce((acc, disk) => acc + (disk?.usage_percent || 0), 0);
const diskCount = disks.length || 1;
const diskUsage = (totalDiskUsage / diskCount) * 100;
const diskUsed = disks.reduce((acc, disk) => acc + (disk?.used_bytes || 0), 0);
const diskTotal = disks.reduce((acc, disk) => acc + (disk?.total_bytes || 0), 0);
return (
<Stack
direction={{ xs: "column", sm: "row" }}
gap={theme.spacing(10)}
columnGap={theme.spacing(15)}
padding={theme.spacing(8)}
>
<MetricItem
label={t("pages.statusPages.monitorsList.infrastructure.cpu")}
progress={cpuUsage}
upperLabel={t("pages.statusPages.monitorsList.infrastructure.usage")}
upperValue={`${cpuUsage.toFixed(1)}%`}
/>
<MetricItem
label={t("pages.statusPages.monitorsList.infrastructure.memory")}
progress={memoryUsage}
upperLabel={t("pages.statusPages.monitorsList.infrastructure.used")}
upperValue={prettyBytes(memoryUsed)}
lowerLabel={t("pages.statusPages.monitorsList.infrastructure.total")}
lowerValue={prettyBytes(memoryTotal)}
/>
<MetricItem
label={t("pages.statusPages.monitorsList.infrastructure.disk")}
progress={diskUsage}
upperLabel={t("pages.statusPages.monitorsList.infrastructure.used")}
upperValue={prettyBytes(diskUsed)}
lowerLabel={t("pages.statusPages.monitorsList.infrastructure.total")}
lowerValue={prettyBytes(diskTotal)}
/>
</Stack>
);
};
@@ -5,6 +5,7 @@ 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 } from "@mui/material/styles";
import { useSelector } from "react-redux";
@@ -32,10 +33,11 @@ export const MonitorsList = ({ statusPage, monitors }: MonitorsListProps) => {
return (
<Stack gap={theme.spacing(8)}>
{statusPage.showCharts !== false && (
{statusPage.showCharts && (
<Stack
direction={"row"}
alignItems={"center"}
gap={theme.spacing(2)}
>
<Typography>{t("pages.statusPages.monitorsList.chartTypeHeatmap")}</Typography>
<SwitchComponent
@@ -53,30 +55,56 @@ export const MonitorsList = ({ statusPage, monitors }: MonitorsListProps) => {
)}
{monitors?.map((monitor) => {
const isInfrastructureMonitor = monitor?.type === "hardware";
return (
<BaseBox
key={monitor.id}
padding={theme.spacing(4)}
padding={theme.spacing(8)}
>
<Stack
direction="row"
alignItems="center"
justifyContent="space-between"
gap={theme.spacing(4)}
mb={statusPage.showCharts !== false ? theme.spacing(4) : 0}
mb={theme.spacing(4)}
>
<Box sx={{ overflow: "hidden", minWidth: 0, flex: 1 }}>
<Typography
variant="h6"
sx={{
overflow: "hidden",
textOverflow: "ellipsis",
whiteSpace: "nowrap",
}}
<Stack
direction="row"
alignItems="center"
gap={theme.spacing(2)}
mb={theme.spacing(1)}
>
{monitor.name}
</Typography>
{showURL && (
<Typography
variant="h6"
sx={{
overflow: "hidden",
textOverflow: "ellipsis",
whiteSpace: "nowrap",
}}
>
{monitor.name}
</Typography>
<Typography
variant="caption"
sx={{
backgroundColor:
monitor?.type === "hardware"
? theme.palette.info.light
: theme.palette.success.light,
color:
monitor?.type === "hardware"
? theme.palette.info.dark
: theme.palette.success.dark,
padding: `${theme.spacing(0.5)} ${theme.spacing(1.5)}`,
borderRadius: theme.shape.borderRadius,
fontWeight: 600,
}}
>
{t(`pages.statusPages.monitorsList.badge.${monitor?.type}`)}
</Typography>
</Stack>
{showURL && monitor?.url && (
<Typography
variant="body2"
color="text.secondary"
@@ -85,22 +113,49 @@ export const MonitorsList = ({ statusPage, monitors }: MonitorsListProps) => {
</Typography>
)}
</Box>
<StatusLabel status={monitor.status} />
<StatusLabel status={monitor?.status} />
</Stack>
{statusPage.showCharts !== false && (
<Box sx={{ overflow: "hidden", minWidth: 0, flex: 1 }}>
{chartType === "histogram" ? (
<HistogramResponseTime
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() ?? []}
/>
{!isInfrastructureMonitor && (
<>
{statusPage.showCharts !== false && (
<Box
sx={{
overflow: "hidden",
minWidth: 0,
flex: 1,
mb: theme.spacing(2),
}}
>
{chartType === "histogram" ? (
<HistogramResponseTime
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>
)}
</Box>
</>
)}
{isInfrastructureMonitor && statusPage.showInfrastructure !== false && (
<BaseBox
key={monitor.id}
padding={theme.spacing(4)}
>
<Stack
direction="row"
alignItems="center"
justifyContent="space-between"
gap={theme.spacing(8)}
></Stack>
<InfrastructureMetrics monitor={monitor} />
</BaseBox>
)}
</BaseBox>
);
+3 -2
View File
@@ -23,16 +23,17 @@ const StatusPageView = () => {
const isSmall = useMediaQuery(theme.breakpoints.down("md"));
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 statusPage = data?.statusPage;
const monitors = data?.monitors ?? [];
const infrastructureMonitors = data?.infrastructureMonitors ?? [];
if (!statusPage) return null;
if (monitors?.length === 0) {
if (monitors?.length === 0 && infrastructureMonitors?.length === 0) {
return (
<BasePage
loading={isLoading}
@@ -97,7 +97,7 @@ export const StatusPagesTable = ({
{
id: "type",
content: t("common.table.headers.type"),
render: (row) => row.type,
render: (row) => row.type.join(", "),
},
{
id: "status",
+4 -1
View File
@@ -1,10 +1,11 @@
import type { Monitor } from "@/Types/Monitor";
export type MonitorDisplayType = "uptime" | "infrastructure";
export interface StatusPage {
id: string;
userId: string;
teamId: string;
type: string;
type: MonitorDisplayType[];
companyName: string;
url: string;
timezone?: string;
@@ -20,6 +21,7 @@ export interface StatusPage {
showCharts: boolean;
showUptimePercentage: boolean;
showAdminLoginLink: boolean;
showInfrastructure: boolean;
customCSS: string;
createdAt: string;
updatedAt: string;
@@ -28,4 +30,5 @@ export interface StatusPage {
export interface StatusPageResponse {
statusPage: StatusPage;
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"
),
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"),
monitors: z.array(z.string()).min(1, "At least one monitor is required"),
isPublished: z.boolean(),
showCharts: z.boolean(),
showUptimePercentage: z.boolean(),
showAdminLoginLink: z.boolean(),
showInfrastructure: z.boolean(),
customCSS: z.string().optional(),
logo: z
.object({
+8 -1
View File
@@ -288,7 +288,13 @@ const logoImageValidation = joi
.optional(); // Make entire object optional
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(),
companyName: joi
.string()
@@ -317,6 +323,7 @@ const statusPageValidation = joi.object({
showUptimePercentage: joi.boolean(),
showCharts: joi.boolean(),
showAdminLoginLink: joi.boolean(),
showInfrastructure: joi.boolean(),
});
const settingsValidation = joi.object({
+27 -1
View File
@@ -1178,7 +1178,30 @@
},
"monitorsList": {
"chartTypeHeatmap": "Heatmap",
"chartTypeHistogram": "Histogram"
"chartTypeHistogram": "Histogram",
"noData": "No data available",
"infrastructure": {
"title": "Infrastructure",
"cpu": "CPU",
"memory": "RAM",
"disk": "Disk",
"usage": "Usage",
"used": "Used",
"total": "Total"
},
"uptime": {
"title": "Uptime",
"responseTime": "Response time"
},
"badge": {
"http": "HTTP",
"ping": "Ping",
"port": "Port",
"docker": "Docker",
"pagespeed": "PageSpeed",
"hardware": "Infrastructure",
"game": "Game"
}
},
"statusBar": {
"allDown": "All systems are down",
@@ -1270,6 +1293,9 @@
},
"showAdminLoginLink": {
"label": "Show admin login link"
},
"showInfrastructure": {
"label": "Show infrastructure metrics"
}
}
}
+6 -2
View File
@@ -43,9 +43,9 @@ const StatusPageSchema = new Schema<StatusPageDocument>(
required: true,
},
type: {
type: String,
type: [String],
required: true,
default: "uptime",
default: [],
enum: StatusPageTypes,
},
companyName: {
@@ -100,6 +100,10 @@ const StatusPageSchema = new Schema<StatusPageDocument>(
type: Boolean,
default: false,
},
showInfrastructure: {
type: Boolean,
default: false,
},
customCSS: {
type: String,
default: "",
@@ -57,6 +57,7 @@ class MongoStatusPagesRepository implements IStatusPagesRepository {
showCharts: doc.showCharts,
showUptimePercentage: doc.showUptimePercentage,
showAdminLoginLink: doc.showAdminLoginLink,
showInfrastructure: doc.showInfrastructure,
customCSS: doc.customCSS,
createdAt: this.toDateString(doc.createdAt),
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 interface StatusPageLogo {
@@ -15,7 +15,7 @@ export interface StatusPage {
id: string;
userId: string;
teamId: string;
type: StatusPageType;
type: StatusPageType[];
companyName: string;
url: string;
timezone?: string;
@@ -28,6 +28,7 @@ export interface StatusPage {
showCharts: boolean;
showUptimePercentage: boolean;
showAdminLoginLink: boolean;
showInfrastructure: boolean;
customCSS: string;
createdAt: string;
updatedAt: string;