diff --git a/client/src/Hooks/useStatusPageForm.ts b/client/src/Hooks/useStatusPageForm.ts index a079095cc..0011ea761 100644 --- a/client/src/Hooks/useStatusPageForm.ts +++ b/client/src/Hooks/useStatusPageForm.ts @@ -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), }; diff --git a/client/src/Pages/CreateMonitor/index.tsx b/client/src/Pages/CreateMonitor/index.tsx index b80cddaaa..15b76eab3 100644 --- a/client/src/Pages/CreateMonitor/index.tsx +++ b/client/src/Pages/CreateMonitor/index.tsx @@ -302,49 +302,49 @@ const CreateMonitorPage = () => { > { + const params = new URLSearchParams(); + ["http", "ping", "port", "docker", "game", "grpc", "websocket", "hardware"].forEach( + (type) => params.append("type", type) + ); + return `/monitors/team?${params.toString()}`; +})(); + interface TimezoneOption { _id: string; name: string; @@ -49,11 +58,11 @@ const CreateStatusPage = () => { // Fetch existing status page data when configuring const { data: statusPageData, isLoading: isLoadingStatusPage } = - useGet(isCreate ? null : `/status-page/${url}?type=uptime`); + useGet( + isCreate ? null : `/status-page/${url}?type=uptime&type=infrastructure` + ); - const { data: monitorsResponse } = useGet( - "/monitors/team?type=http&type=ping&type=port&type=docker" - ); + const { data: monitorsResponse } = useGet(monitorsUrl); const monitors = monitorsResponse ?? []; const { post, loading: isSubmittingPost } = usePost(); @@ -81,6 +90,24 @@ const CreateStatusPage = () => { reset(defaults); }, [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(); + selectedMonitors.forEach((m) => { + typesSet.add(m.type === "hardware" ? "infrastructure" : "uptime"); + }); + + return typesSet.size ? Array.from(typesSet) : ["uptime"]; + }, [watchedMonitorIds, monitors]); + + useEffect(() => { + form.setValue("type", computedTypes); + }, [computedTypes]); + const onError = (errors: any) => { logger.debug("Status page validation errors", errors); }; @@ -103,7 +130,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 +138,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 @@ -303,7 +334,9 @@ const CreateStatusPage = () => { }} > - {monitor.name} + {`${monitor.name} (${getMonitorTypeLabel(monitor.type, t)})`} { @@ -425,6 +458,23 @@ const CreateStatusPage = () => { /> )} /> + ( + + } + label={t( + "pages.statusPages.form.features.option.showInfrastructure.label" + )} + /> + )} + /> {/* { + const theme = useTheme(); + return ( + + + {label} + + {value} + + ); +}; + +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 ( + + + + {label} + + {details && details.length > 0 && ( + + {details.map((detail) => ( + + ))} + + )} + + ); +}; + +type LatestCheck = NonNullable[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 ( + + {t("pages.statusPages.monitorsList.noData")} + + ); + } + + const metrics: MetricConfig[] = [ + buildCpuMetric(latestCheck, t), + buildMemoryMetric(latestCheck, t), + buildDiskMetric(latestCheck, t), + ].filter((m): m is MetricConfig => m !== null); + + if (metrics.length === 0) { + return ( + + {t("pages.statusPages.monitorsList.noData")} + + ); + } + + return ( + + {metrics.map(({ key, label, progress, details }) => ( + + ))} + + ); +}; diff --git a/client/src/Pages/StatusPage/Status/Components/MonitorsList.tsx b/client/src/Pages/StatusPage/Status/Components/MonitorsList.tsx index b94b20a80..248a69c9b 100644 --- a/client/src/Pages/StatusPage/Status/Components/MonitorsList.tsx +++ b/client/src/Pages/StatusPage/Status/Components/MonitorsList.tsx @@ -5,13 +5,16 @@ 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 { 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"]; @@ -22,29 +25,124 @@ interface MonitorsListProps { monitors: StatusPageMonitor[]; } -export const MonitorsList = ({ statusPage, monitors }: MonitorsListProps) => { +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(); - const showURL = useSelector((state: RootState) => state.ui?.showURL); + return ( + + + + + {monitor.name} + + + {getMonitorTypeLabel(monitor.type, t)} + + + {showURL && monitor.url && ( + + {monitor.url} + + )} + + + + ); +}; +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 ; + } + + if (statusPage.showCharts === false) return null; + + const checks = monitor.checks?.slice().reverse() ?? []; + return ( + + {chartType === "histogram" ? ( + + ) : ( + + )} + + ); +}; + +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 ( - - {statusPage.showCharts !== false && ( + + {statusPage.showCharts && ( {t("pages.statusPages.monitorsList.chartTypeHeatmap")} { - setChartType(e.target.checked ? "histogram" : "heatmap"); - }} + onChange={(e) => setChartType(e.target.checked ? "histogram" : "heatmap")} /> {t("pages.statusPages.monitorsList.chartTypeHistogram")} @@ -52,59 +150,22 @@ export const MonitorsList = ({ statusPage, monitors }: MonitorsListProps) => { )} - {monitors?.map((monitor) => { - return ( - - - - - {monitor.name} - - {showURL && ( - - {monitor.url} - - )} - - - - {statusPage.showCharts !== false && ( - - {chartType === "histogram" ? ( - - ) : ( - - )} - - )} - - ); - })} + {monitors.map((monitor) => ( + + + + + ))} ); }; diff --git a/client/src/Pages/StatusPage/Status/index.tsx b/client/src/Pages/StatusPage/Status/index.tsx index 0ca629581..3bb14261f 100644 --- a/client/src/Pages/StatusPage/Status/index.tsx +++ b/client/src/Pages/StatusPage/Status/index.tsx @@ -23,7 +23,7 @@ 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(apiUrl); @@ -32,7 +32,7 @@ const StatusPageView = () => { if (!statusPage) return null; - if (monitors?.length === 0) { + if (monitors.length === 0) { return ( row.type, + render: (row) => row.type.join(", "), }, { id: "status", diff --git a/client/src/Types/StatusPage.ts b/client/src/Types/StatusPage.ts index 900373f3d..6fe641210 100644 --- a/client/src/Types/StatusPage.ts +++ b/client/src/Types/StatusPage.ts @@ -1,10 +1,31 @@ -import type { Monitor } from "@/Types/Monitor"; +import type { Monitor, MonitorType } from "@/Types/Monitor"; +export type MonitorDisplayType = "uptime" | "infrastructure"; + +export const MONITOR_TYPE_KEYS: Partial> = { + 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", + pagespeed: "pages.common.monitors.monitorTypes.optionPagespeed", +}; + +export const getMonitorTypeLabel = ( + type: MonitorType, + t: (key: string) => string +): string => { + const key = MONITOR_TYPE_KEYS[type]; + return key ? t(key) : type; +}; export interface StatusPage { id: string; userId: string; teamId: string; - type: string; + type: MonitorDisplayType[]; companyName: string; url: string; timezone?: string; @@ -20,6 +41,7 @@ export interface StatusPage { showCharts: boolean; showUptimePercentage: boolean; showAdminLoginLink: boolean; + showInfrastructure: boolean; customCSS: string; createdAt: string; updatedAt: string; diff --git a/client/src/Validation/statusPage.ts b/client/src/Validation/statusPage.ts index fbe6a0690..43d7198f0 100644 --- a/client/src/Validation/statusPage.ts +++ b/client/src/Validation/statusPage.ts @@ -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({ diff --git a/client/src/Validation/validation.js b/client/src/Validation/validation.js index fa92f2e99..c11543cad 100644 --- a/client/src/Validation/validation.js +++ b/client/src/Validation/validation.js @@ -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({ diff --git a/client/src/locales/en.json b/client/src/locales/en.json index 04e18e3dc..92a21939f 100644 --- a/client/src/locales/en.json +++ b/client/src/locales/en.json @@ -407,6 +407,17 @@ "paused": "paused", "total": "total", "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": { "description": "Select the type of check to perform", - "optionDocker": "Docker", "optionDockerDescription": "Use Docker to monitor if a container is running.", - "optionGame": "Game", "optionGameDescription": "Monitor if a specific game server is online.", - "optionGrpc": "gRPC", "optionGrpcDescription": "Monitor gRPC services using the standard Health Checking Protocol.", - "optionWebSocket": "WebSocket", "optionWebSocketDescription": "Monitor WebSocket endpoints for connection health and response time.", - "optionHttp": "HTTP(S)", "optionHttpDescription": "Use HTTP(S) to monitor your website or API endpoint.", - "optionPing": "Ping", "optionPingDescription": "Use ICMP Ping to monitor if a server is online.", - "optionPort": "Port", "optionPortDescription": "Monitor if a specific port on a server is open.", "title": "Type" }, @@ -1178,7 +1182,22 @@ }, "monitorsList": { "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": { "allDown": "All systems are down", @@ -1270,6 +1289,9 @@ }, "showAdminLoginLink": { "label": "Show admin login link" + }, + "showInfrastructure": { + "label": "Show infrastructure metrics" } } }