diff --git a/client/src/Components/common/charts/HeatmapResponseTimeTooltip.tsx b/client/src/Components/common/charts/HeatmapResponseTimeTooltip.tsx
index c046e05b5..8bc651c8c 100644
--- a/client/src/Components/common/charts/HeatmapResponseTimeTooltip.tsx
+++ b/client/src/Components/common/charts/HeatmapResponseTimeTooltip.tsx
@@ -57,7 +57,8 @@ export const HeatmapResponseTimeTooltip = ({
{formatDateWithTz(check?.createdAt, "ddd, MMMM D, YYYY, HH:mm A", uiTimezone)}
- {t("common.labels.responseTime")}: {check.originalResponseTime.toFixed()} ms
+ {t("common.labels.responseTime")}:{" "}
+ {check?.originalResponseTime?.toFixed() ?? "N/A"} ms
Status:{" "}
diff --git a/client/src/Hooks/useSettingsForm.ts b/client/src/Hooks/useSettingsForm.ts
index 3af8c229e..4b0768d79 100644
--- a/client/src/Hooks/useSettingsForm.ts
+++ b/client/src/Hooks/useSettingsForm.ts
@@ -1,14 +1,14 @@
import { useMemo } from "react";
import { settingsSchema } from "@/Validation/settings";
import type { Settings } from "@/Types/Settings";
-import type { SettingsFormData } from "@/Validation/settings";
+import type { SettingsFormInput } from "@/Validation/settings";
interface UseSettingsFormOptions {
data?: Settings | null;
}
export const useSettingsForm = ({ data = null }: UseSettingsFormOptions = {}) => {
return useMemo(() => {
- const defaults: SettingsFormData = {
+ const defaults: SettingsFormInput = {
systemEmailIgnoreTLS: data?.systemEmailIgnoreTLS ?? false,
systemEmailRequireTLS: data?.systemEmailRequireTLS ?? false,
systemEmailRejectUnauthorized: data?.systemEmailRejectUnauthorized ?? true,
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 = () => {
>
{
// Initialize form with schema and defaults
const { schema, defaults } = useSettingsForm({ data: fetchedSettings?.settings });
- const form = useForm({
+ const form = useForm({
resolver: zodResolver(schema),
defaultValues: defaults,
mode: "onChange",
@@ -256,28 +256,7 @@ export const SettingsPage = () => {
delete (dataToSend as any).systemEmailPassword;
}
- // Convert undefined to empty string for backend unsetting
- const processedData = Object.entries(dataToSend).reduce((acc, [key, value]) => {
- const typedKey = key as keyof SettingsFormData;
- if (value === undefined) {
- (acc as any)[typedKey] = "";
- } else if (typeof value === "object" && value !== null) {
- // Handle nested objects like globalThresholds
- // Convert 0 to "" for unsetting thresholds
- (acc as any)[typedKey] = Object.entries(value).reduce(
- (nested, [nestedKey, nestedValue]) => ({
- ...nested,
- [nestedKey]: nestedValue === undefined ? "" : nestedValue,
- }),
- {}
- );
- } else {
- (acc as any)[typedKey] = value;
- }
- return acc;
- }, {} as Partial);
-
- const result = await patch("/settings", processedData as SettingsFormData);
+ const result = await patch("/settings", dataToSend as SettingsFormData);
if (result?.success) {
// Update API key state from response
diff --git a/client/src/Pages/StatusPage/Create/index.tsx b/client/src/Pages/StatusPage/Create/index.tsx
index 095ac3499..f09d9e47d 100644
--- a/client/src/Pages/StatusPage/Create/index.tsx
+++ b/client/src/Pages/StatusPage/Create/index.tsx
@@ -21,19 +21,28 @@ import {
import { useTheme } from "@mui/material/styles";
import { useTranslation } from "react-i18next";
-import { useEffect, useState } from "react";
+import { useEffect, useMemo, useState } from "react";
import { useForm, Controller } from "react-hook-form";
import { zodResolver } from "@hookform/resolvers/zod";
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 { getMonitorTypeLabel } from "@/Types/StatusPage";
import timezones from "@/Utils/timezones.json";
import { useNavigate, useParams } from "react-router-dom";
import axios from "axios";
import { HeaderConfigStatusControls } from "./Components/HeaderConfigStatusControls";
+const monitorsUrl = (() => {
+ 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..78b7cba41 100644
--- a/client/src/Pages/StatusPage/Status/index.tsx
+++ b/client/src/Pages/StatusPage/Status/index.tsx
@@ -23,16 +23,22 @@ 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);
+ const { data, isLoading, error } = useGet(
+ apiUrl,
+ {},
+ {
+ refreshInterval: 10000,
+ }
+ );
const statusPage = data?.statusPage;
const monitors = data?.monitors ?? [];
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/settings.ts b/client/src/Validation/settings.ts
index 975927d1c..cf122f69d 100644
--- a/client/src/Validation/settings.ts
+++ b/client/src/Validation/settings.ts
@@ -7,7 +7,7 @@ export const settingsSchema = z.object({
systemEmailRejectUnauthorized: z.boolean(),
systemEmailConnectionHost: z
.string()
- .transform((val) => (val.trim() === "" ? undefined : val.trim()))
+ .transform((val) => (val.trim() === "" ? null : val.trim()))
.optional(),
systemEmailSecure: z.boolean().optional(),
systemEmailPool: z.boolean().optional(),
@@ -19,31 +19,30 @@ export const settingsSchema = z.object({
.max(CHECK_TTL_SENTINEL, `Maximum ${CHECK_TTL_SENTINEL}`),
pagespeedApiKey: z
.string()
- .transform((val) => (val.trim() === "" ? undefined : val.trim()))
+ .transform((val) => (val.trim() === "" ? null : val.trim()))
.optional(),
systemEmailHost: z
.string()
.regex(/^[a-zA-Z0-9.-]*$/, "Invalid hostname or IP address")
- .transform((val) => (val.trim() === "" ? undefined : val.trim()))
+ .transform((val) => (val.trim() === "" ? null : val.trim()))
.optional(),
systemEmailPort: z.number().int().min(1).max(65535).optional(),
systemEmailAddress: z
- .string()
.email("Please enter a valid email address")
.or(z.literal(""))
- .transform((val) => (val === "" ? undefined : val.toLowerCase().trim()))
+ .transform((val) => (val === "" ? null : val.toLowerCase().trim()))
.optional(),
systemEmailUser: z
.string()
- .transform((val) => (val.trim() === "" ? undefined : val.trim()))
+ .transform((val) => (val.trim() === "" ? null : val.trim()))
.optional(),
systemEmailPassword: z
.string()
- .transform((val) => (val.trim() === "" ? undefined : val.trim()))
+ .transform((val) => (val.trim() === "" ? null : val.trim()))
.optional(),
systemEmailTLSServername: z
.string()
- .transform((val) => (val.trim() === "" ? undefined : val.trim()))
+ .transform((val) => (val.trim() === "" ? null : val.trim()))
.optional(),
globalThresholds: z.object({
cpu: z.number().int().min(1).max(100),
@@ -53,4 +52,5 @@ export const settingsSchema = z.object({
}),
});
+export type SettingsFormInput = z.input;
export type SettingsFormData = z.infer;
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"
}
}
}
diff --git a/server/eslint.config.js b/server/eslint.config.js
index c854029a0..4e45d6157 100755
--- a/server/eslint.config.js
+++ b/server/eslint.config.js
@@ -37,6 +37,7 @@ export default [
},
},
rules: {
+ "@typescript-eslint/no-unused-vars": ["error", { argsIgnorePattern: "^_" }], // Ignore unused variables that start with '_'
"@typescript-eslint/no-explicit-any": "warn", // Warn on 'any' types
"mocha/max-top-level-suites": "warn", // Warn if there are too many top-level suites instead of failing
},
diff --git a/server/openapi.json b/server/openapi.json
index 1a6bbbfbf..35d109693 100644
--- a/server/openapi.json
+++ b/server/openapi.json
@@ -510,42 +510,24 @@
"summary": "Get monitors by team",
"description": "Get monitors filtered by team with optional parameters",
"parameters": [
- {
- "name": "status",
- "in": "query",
- "description": "Filter by monitor status (up/down)",
- "schema": {
- "type": "boolean"
- }
- },
{
"name": "type",
"in": "query",
"description": "Filter by monitor type",
"schema": {
- "type": "string",
- "enum": ["http", "ping", "pagespeed", "hardware", "docker", "port"]
- }
- },
- {
- "name": "page",
- "in": "query",
- "description": "Page number for pagination",
- "schema": {
- "type": "integer",
- "minimum": 1,
- "default": 1
- }
- },
- {
- "name": "rowsPerPage",
- "in": "query",
- "description": "Number of monitors per page",
- "schema": {
- "type": "integer",
- "minimum": 1,
- "maximum": 100,
- "default": 10
+ "oneOf": [
+ {
+ "type": "string",
+ "enum": ["http", "ping", "pagespeed", "docker", "hardware", "port", "game", "grpc", "websocket"]
+ },
+ {
+ "type": "array",
+ "items": {
+ "type": "string",
+ "enum": ["http", "ping", "pagespeed", "docker", "hardware", "port", "game", "grpc", "websocket"]
+ }
+ }
+ ]
}
},
{
@@ -555,15 +537,6 @@
"schema": {
"type": "string"
}
- },
- {
- "name": "field",
- "in": "query",
- "description": "Field to filter on",
- "schema": {
- "type": "string",
- "enum": ["name", "url", "description"]
- }
}
],
"responses": {
@@ -756,7 +729,7 @@
}
]
},
- "put": {
+ "patch": {
"tags": ["monitors"],
"summary": "Update monitor",
"description": "Update an existing monitor",
@@ -3568,22 +3541,19 @@
},
"CreateMonitorRequest": {
"type": "object",
- "required": ["name", "description", "type", "url", "interval"],
+ "required": ["name", "type", "url"],
"properties": {
"name": {
"type": "string",
- "minLength": 1,
- "maxLength": 100,
"example": "My Website Monitor"
},
"description": {
"type": "string",
- "maxLength": 500,
"example": "Monitors the main website homepage"
},
"type": {
"type": "string",
- "enum": ["http", "ping", "pagespeed", "hardware", "docker", "port"],
+ "enum": ["http", "ping", "pagespeed", "hardware", "docker", "port", "game", "grpc", "websocket"],
"example": "http"
},
"url": {
@@ -3593,14 +3563,34 @@
},
"interval": {
"type": "integer",
- "minimum": 30,
- "maximum": 3600,
"example": 300,
"description": "Check interval in seconds"
},
+ "statusWindowSize": {
+ "type": "integer",
+ "minimum": 1,
+ "maximum": 20,
+ "default": 5
+ },
+ "statusWindowThreshold": {
+ "type": "integer",
+ "minimum": 1,
+ "maximum": 100,
+ "default": 60
+ },
"isActive": {
+ "type": "boolean"
+ },
+ "ignoreTlsErrors": {
"type": "boolean",
- "default": true
+ "default": false
+ },
+ "useAdvancedMatching": {
+ "type": "boolean",
+ "default": false
+ },
+ "port": {
+ "type": "integer"
},
"notifications": {
"type": "array",
@@ -3609,51 +3599,62 @@
},
"description": "Array of notification IDs to associate with this monitor"
},
- "httpOptions": {
- "type": "object",
- "properties": {
- "method": {
- "type": "string",
- "enum": ["GET", "POST", "PUT", "DELETE", "HEAD"],
- "default": "GET"
- },
- "headers": {
- "type": "object",
- "additionalProperties": {
- "type": "string"
- }
- },
- "body": {
- "type": "string",
- "description": "Request body for POST/PUT requests"
- },
- "timeout": {
- "type": "integer",
- "minimum": 1000,
- "maximum": 30000,
- "default": 5000,
- "description": "Request timeout in milliseconds"
- }
- }
+ "secret": {
+ "type": "string"
},
- "assertions": {
+ "jsonPath": {
+ "type": "string"
+ },
+ "expectedValue": {
+ "type": "string"
+ },
+ "matchMethod": {
+ "type": "string",
+ "enum": ["equal", "include", "regex", ""]
+ },
+ "cpuAlertThreshold": {
+ "type": "number"
+ },
+ "memoryAlertThreshold": {
+ "type": "number"
+ },
+ "diskAlertThreshold": {
+ "type": "number"
+ },
+ "tempAlertThreshold": {
+ "type": "number"
+ },
+ "gameId": {
+ "type": "string"
+ },
+ "grpcServiceName": {
+ "type": "string",
+ "default": ""
+ },
+ "selectedDisks": {
"type": "array",
"items": {
- "type": "object",
- "properties": {
- "type": {
- "type": "string",
- "enum": ["status-code", "response-time", "body-contains", "header-contains"]
- },
- "comparison": {
- "type": "string",
- "enum": ["equals", "not-equals", "greater-than", "less-than", "contains", "not-contains"]
- },
- "value": {
- "type": "string"
- }
- }
+ "type": "string"
}
+ },
+ "group": {
+ "type": "string",
+ "nullable": true,
+ "maxLength": 50
+ },
+ "geoCheckEnabled": {
+ "type": "boolean"
+ },
+ "geoCheckLocations": {
+ "type": "array",
+ "items": {
+ "type": "string",
+ "enum": ["northAmerica", "southAmerica", "europe", "asia", "africa", "oceania"]
+ }
+ },
+ "geoCheckInterval": {
+ "type": "integer",
+ "minimum": 300000
}
}
},
@@ -3661,21 +3662,32 @@
"type": "object",
"properties": {
"name": {
+ "type": "string"
+ },
+ "type": {
"type": "string",
- "minLength": 1,
- "maxLength": 100
+ "enum": ["http", "ping", "pagespeed", "hardware", "docker", "port", "game", "grpc", "websocket", "unknown"]
+ },
+ "url": {
+ "type": "string"
+ },
+ "statusWindowSize": {
+ "type": "integer",
+ "minimum": 1,
+ "maximum": 20,
+ "default": 5
+ },
+ "statusWindowThreshold": {
+ "type": "integer",
+ "minimum": 1,
+ "maximum": 100,
+ "default": 60
},
"description": {
- "type": "string",
- "maxLength": 500
+ "type": "string"
},
"interval": {
- "type": "integer",
- "minimum": 30,
- "maximum": 3600
- },
- "isActive": {
- "type": "boolean"
+ "type": "integer"
},
"notifications": {
"type": "array",
@@ -3683,47 +3695,72 @@
"type": "string"
}
},
- "httpOptions": {
- "type": "object",
- "properties": {
- "method": {
- "type": "string",
- "enum": ["GET", "POST", "PUT", "DELETE", "HEAD"]
- },
- "headers": {
- "type": "object",
- "additionalProperties": {
- "type": "string"
- }
- },
- "body": {
- "type": "string"
- },
- "timeout": {
- "type": "integer",
- "minimum": 1000,
- "maximum": 30000
- }
- }
+ "secret": {
+ "type": "string"
},
- "assertions": {
+ "ignoreTlsErrors": {
+ "type": "boolean"
+ },
+ "useAdvancedMatching": {
+ "type": "boolean"
+ },
+ "jsonPath": {
+ "type": "string"
+ },
+ "expectedValue": {
+ "type": "string"
+ },
+ "matchMethod": {
+ "type": "string",
+ "enum": ["equal", "include", "regex", ""]
+ },
+ "port": {
+ "type": "integer",
+ "minimum": 1,
+ "maximum": 65535
+ },
+ "cpuAlertThreshold": {
+ "type": "number"
+ },
+ "memoryAlertThreshold": {
+ "type": "number"
+ },
+ "diskAlertThreshold": {
+ "type": "number"
+ },
+ "tempAlertThreshold": {
+ "type": "number"
+ },
+ "gameId": {
+ "type": "string"
+ },
+ "grpcServiceName": {
+ "type": "string"
+ },
+ "selectedDisks": {
"type": "array",
"items": {
- "type": "object",
- "properties": {
- "type": {
- "type": "string",
- "enum": ["status-code", "response-time", "body-contains", "header-contains"]
- },
- "comparison": {
- "type": "string",
- "enum": ["equals", "not-equals", "greater-than", "less-than", "contains", "not-contains"]
- },
- "value": {
- "type": "string"
- }
- }
+ "type": "string"
}
+ },
+ "group": {
+ "type": "string",
+ "nullable": true,
+ "maxLength": 50
+ },
+ "geoCheckEnabled": {
+ "type": "boolean"
+ },
+ "geoCheckLocations": {
+ "type": "array",
+ "items": {
+ "type": "string",
+ "enum": ["northAmerica", "southAmerica", "europe", "asia", "africa", "oceania"]
+ }
+ },
+ "geoCheckInterval": {
+ "type": "integer",
+ "minimum": 300000
}
}
},
diff --git a/server/src/controllers/monitorController.ts b/server/src/controllers/monitorController.ts
index 4dc28d60d..83f57e7f3 100644
--- a/server/src/controllers/monitorController.ts
+++ b/server/src/controllers/monitorController.ts
@@ -63,7 +63,6 @@ class MonitorController {
const monitorId = validatedParams.monitorId;
const dateRange = validatedQuery.dateRange;
- const normalize = validatedQuery.normalize;
const teamId = requireTeamId(req.user?.teamId);
@@ -71,7 +70,6 @@ class MonitorController {
teamId,
monitorId,
dateRange,
- normalize,
});
return res.status(200).json({
success: true,
@@ -327,7 +325,6 @@ class MonitorController {
try {
const validatedQuery = getMonitorsWithChecksQueryValidation.parse(req.query);
- const explain = validatedQuery.explain;
const limit = validatedQuery.limit;
const page = validatedQuery.page;
const rowsPerPage = validatedQuery.rowsPerPage;
@@ -346,7 +343,6 @@ class MonitorController {
filter,
field,
order,
- explain,
});
return res.status(200).json({
diff --git a/server/src/controllers/settingsController.ts b/server/src/controllers/settingsController.ts
index d3caa0d8f..0fd82a8ac 100644
--- a/server/src/controllers/settingsController.ts
+++ b/server/src/controllers/settingsController.ts
@@ -2,14 +2,15 @@ import { Request, Response, NextFunction } from "express";
import { updateAppSettingsBodyValidation } from "@/validation/settingsValidation.js";
import { sendTestEmailBodyValidation } from "@/validation/notificationValidation.js";
import { AppError } from "@/utils/AppError.js";
+import { IEmailService, ISettingsService } from "@/service/index.js";
const SERVICE_NAME = "SettingsController";
class SettingsController {
static SERVICE_NAME = SERVICE_NAME;
- private settingsService: any;
- private emailService: any;
- constructor(settingsService: any, emailService: any) {
+ private settingsService: ISettingsService;
+ private emailService: IEmailService;
+ constructor(settingsService: ISettingsService, emailService: IEmailService) {
this.settingsService = settingsService;
this.emailService = emailService;
}
@@ -57,9 +58,9 @@ class SettingsController {
updateAppSettings = async (req: Request, res: Response, next: NextFunction) => {
try {
- updateAppSettingsBodyValidation.parse(req.body);
+ const validatedBody = updateAppSettingsBodyValidation.parse(req.body);
- const updatedSettings = await this.settingsService.updateDbSettings(req.body);
+ const updatedSettings = await this.settingsService.updateDbSettings(validatedBody);
const returnSettings = this.buildAppSettings(updatedSettings);
return res.status(200).json({
success: true,
@@ -95,6 +96,9 @@ class SettingsController {
const context = { testName: "Monitoring System" };
const html = await this.emailService.buildEmail("testEmailTemplate", context);
+ if (!html) {
+ throw new AppError({ message: "Failed to build email template.", status: 500 });
+ }
const messageId = await this.emailService.sendEmail(to, subject, html, {
systemEmailHost,
systemEmailPort,
diff --git a/server/src/db/migration/0005_migrateStatusPageTypeToArray.ts b/server/src/db/migration/0005_migrateStatusPageTypeToArray.ts
new file mode 100644
index 000000000..0b13d1cb6
--- /dev/null
+++ b/server/src/db/migration/0005_migrateStatusPageTypeToArray.ts
@@ -0,0 +1,45 @@
+import mongoose from "mongoose";
+import { logger } from "@/utils/logger.js";
+import StatusPageModel from "../models/StatusPage.js";
+
+export async function migrateStatusPageTypeToArray(): Promise {
+ 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");
+ }
+
+ 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;
+ }
+}
diff --git a/server/src/db/migration/index.ts b/server/src/db/migration/index.ts
index 3b7b45ada..61f16ed4d 100644
--- a/server/src/db/migration/index.ts
+++ b/server/src/db/migration/index.ts
@@ -3,6 +3,7 @@ import { convertChecksToTimeSeries } from "./0002_convertChecksToTimeSeries.js";
import { cleanupDuplicateMonitorStats } from "./0003_cleanupDuplicateMonitorStats.js";
import { fixInfrastructureThresholds } from "./0004_fixInfrastructureThresholds.js";
import MigrationModel from "../models/Migration.js";
+import { migrateStatusPageTypeToArray } from "./0005_migrateStatusPageTypeToArray.js";
type MigrationEntry = {
name: string;
@@ -14,6 +15,7 @@ const migrations: MigrationEntry[] = [
{ name: "0002_convertChecksToTimeSeries", execute: convertChecksToTimeSeries },
{ name: "0003_cleanupDuplicateMonitorStats", execute: cleanupDuplicateMonitorStats },
{ name: "0004_fixInfrastructureThresholds", execute: fixInfrastructureThresholds },
+ { name: "0005_migrateStatusPageTypeToArray", execute: migrateStatusPageTypeToArray },
];
const runMigrations = async (logger?: { info: Function; error: Function }) => {
diff --git a/server/src/db/models/MaintenanceWindow.ts b/server/src/db/models/MaintenanceWindow.ts
index ff309a117..311cf97bf 100644
--- a/server/src/db/models/MaintenanceWindow.ts
+++ b/server/src/db/models/MaintenanceWindow.ts
@@ -1,5 +1,5 @@
import { Schema, model, type Types } from "mongoose";
-import type { MaintenanceWindow, DurationUnit } from "@/types/maintenanceWindow.js";
+import type { MaintenanceWindow } from "@/types/maintenanceWindow.js";
type MaintenanceWindowDocumentBase = Omit & {
monitorId: Types.ObjectId;
diff --git a/server/src/db/models/Monitor.ts b/server/src/db/models/Monitor.ts
index 2def7701c..036aeadad 100644
--- a/server/src/db/models/Monitor.ts
+++ b/server/src/db/models/Monitor.ts
@@ -1,6 +1,18 @@
-import { Schema, model, Types, type UpdateQuery } from "mongoose";
+import { Schema, model, Types } from "mongoose";
import type { Monitor, MonitorMatchMethod, CheckSnapshot } from "@/types/monitor.js";
import { MonitorTypes, MonitorStatuses } from "@/types/monitor.js";
+import type {
+ CheckAudits,
+ CheckCaptureInfo,
+ CheckCpuInfo,
+ CheckDiskInfo,
+ CheckErrorInfo,
+ CheckHostInfo,
+ CheckMemoryInfo,
+ CheckNetworkInterfaceInfo,
+ GotTimings,
+ ILighthouseAudit,
+} from "@/types/check.js";
type CheckSnapshotDocument = Omit & { createdAt: Date };
@@ -23,13 +35,167 @@ interface MonitorDocument extends MonitorDocumentBase {
updatedAt: Date;
}
+const snapshotTimingPhasesSchema = new Schema(
+ {
+ wait: { type: Number },
+ dns: { type: Number },
+ tcp: { type: Number },
+ tls: { type: Number },
+ request: { type: Number },
+ firstByte: { type: Number },
+ download: { type: Number },
+ total: { type: Number },
+ },
+ { _id: false }
+);
+
+const snapshotTimingsSchema = new Schema(
+ {
+ start: { type: Number },
+ socket: { type: Number },
+ lookup: { type: Number },
+ connect: { type: Number },
+ secureConnect: { type: Number },
+ upload: { type: Number },
+ response: { type: Number },
+ end: { type: Number },
+ phases: { type: snapshotTimingPhasesSchema },
+ },
+ { _id: false }
+);
+
+const snapshotCpuSchema = new Schema(
+ {
+ physical_core: { type: Number },
+ logical_core: { type: Number },
+ frequency: { type: Number },
+ current_frequency: { type: Number },
+ temperature: { type: [Number] },
+ free_percent: { type: Number },
+ usage_percent: { type: Number },
+ },
+ { _id: false }
+);
+
+const snapshotMemorySchema = new Schema(
+ {
+ total_bytes: { type: Number },
+ available_bytes: { type: Number },
+ used_bytes: { type: Number },
+ usage_percent: { type: Number },
+ },
+ { _id: false }
+);
+
+const snapshotDiskSchema = new Schema(
+ {
+ device: { type: String },
+ mountpoint: { type: String },
+ total_bytes: { type: Number },
+ free_bytes: { type: Number },
+ used_bytes: { type: Number },
+ usage_percent: { type: Number },
+ total_inodes: { type: Number },
+ free_inodes: { type: Number },
+ used_inodes: { type: Number },
+ inodes_usage_percent: { type: Number },
+ read_bytes: { type: Number },
+ write_bytes: { type: Number },
+ read_time: { type: Number },
+ write_time: { type: Number },
+ },
+ { _id: false }
+);
+
+const snapshotHostSchema = new Schema(
+ {
+ os: { type: String },
+ platform: { type: String },
+ kernel_version: { type: String },
+ pretty_name: { type: String },
+ },
+ { _id: false }
+);
+
+const snapshotErrorSchema = new Schema(
+ {
+ metric: { type: [String] },
+ err: { type: String },
+ },
+ { _id: false }
+);
+
+const snapshotCaptureSchema = new Schema(
+ {
+ version: { type: String },
+ mode: { type: String },
+ },
+ { _id: false }
+);
+
+const snapshotNetworkInterfaceSchema = new Schema(
+ {
+ name: { type: String },
+ bytes_sent: { type: Number },
+ bytes_recv: { type: Number },
+ packets_sent: { type: Number },
+ packets_recv: { type: Number },
+ err_in: { type: Number },
+ err_out: { type: Number },
+ drop_in: { type: Number },
+ drop_out: { type: Number },
+ fifo_in: { type: Number },
+ fifo_out: { type: Number },
+ },
+ { _id: false }
+);
+
+const snapshotLighthouseAuditSchema = new Schema(
+ {
+ id: { type: String },
+ title: { type: String },
+ score: { type: Number },
+ displayValue: { type: String },
+ numericValue: { type: Number },
+ numericUnit: { type: String },
+ },
+ { _id: false }
+);
+
+const snapshotAuditsSchema = new Schema(
+ {
+ cls: { type: snapshotLighthouseAuditSchema },
+ si: { type: snapshotLighthouseAuditSchema },
+ fcp: { type: snapshotLighthouseAuditSchema },
+ lcp: { type: snapshotLighthouseAuditSchema },
+ tbt: { type: snapshotLighthouseAuditSchema },
+ },
+ { _id: false }
+);
+
const checkSnapshotSchema = new Schema(
{
id: { type: String, required: true },
status: { type: Boolean, required: true },
+ responseTime: { type: Number },
+ timings: { type: snapshotTimingsSchema },
+ statusCode: { type: Number },
+ message: { type: String },
+ cpu: { type: snapshotCpuSchema },
+ memory: { type: snapshotMemorySchema },
+ disk: { type: [snapshotDiskSchema] },
+ host: { type: snapshotHostSchema },
+ errors: { type: [snapshotErrorSchema] },
+ capture: { type: snapshotCaptureSchema },
+ net: { type: [snapshotNetworkInterfaceSchema] },
+ accessibility: { type: Number },
+ bestPractices: { type: Number },
+ seo: { type: Number },
+ performance: { type: Number },
+ audits: { type: snapshotAuditsSchema },
createdAt: { type: Date, required: true },
},
- { _id: false, strict: false }
+ { _id: false }
);
const MonitorSchema = new Schema(
diff --git a/server/src/db/models/StatusPage.ts b/server/src/db/models/StatusPage.ts
index 56c5ee299..e9b5b59aa 100644
--- a/server/src/db/models/StatusPage.ts
+++ b/server/src/db/models/StatusPage.ts
@@ -43,9 +43,9 @@ const StatusPageSchema = new Schema(
required: true,
},
type: {
- type: String,
+ type: [String],
required: true,
- default: "uptime",
+ default: ["uptime"],
enum: StatusPageTypes,
},
companyName: {
@@ -100,6 +100,10 @@ const StatusPageSchema = new Schema(
type: Boolean,
default: false,
},
+ showInfrastructure: {
+ type: Boolean,
+ default: false,
+ },
customCSS: {
type: String,
default: "",
diff --git a/server/src/middleware/handleErrors.ts b/server/src/middleware/handleErrors.ts
index 1fffc9a8c..be9d9ea1e 100755
--- a/server/src/middleware/handleErrors.ts
+++ b/server/src/middleware/handleErrors.ts
@@ -1,15 +1,18 @@
import type { NextFunction, Request, Response } from "express";
import { logger } from "@/utils/logger.js";
+import { AppError } from "@/utils/AppError.js";
-const handleErrors = (error: any, req: Request, res: Response, next: NextFunction) => {
- const status = error.status || 500;
- const message = error.message || "Server error";
- const service = error.service || "unknownService";
+const handleErrors = (error: unknown, req: Request, res: Response, _next: NextFunction) => {
+ const status = error instanceof AppError ? error.status || 500 : 500;
+ const message = error instanceof AppError ? error.message : "Server error";
+ const service = error instanceof AppError ? error.service : "unknownService";
+ const method = error instanceof AppError ? error.method : "unknownMethod";
logger.error({
message: message,
service: service,
- method: error.method,
- stack: error.stack,
+ method: method,
+ stack: error instanceof AppError ? error.stack : undefined,
+ details: error instanceof AppError ? error.details : undefined,
});
res.status(status).json({
status,
diff --git a/server/src/middleware/sanitization.ts b/server/src/middleware/sanitization.ts
index be971a298..f191ca6ca 100644
--- a/server/src/middleware/sanitization.ts
+++ b/server/src/middleware/sanitization.ts
@@ -1,7 +1,9 @@
import type { Request, Response, NextFunction } from "express";
import DOMPurify from "isomorphic-dompurify";
-export const sanitizeInput = (input: any, options = {}) => {
+export function sanitizeInput(input: string, options?: object): string;
+export function sanitizeInput(input: unknown, options?: object): unknown;
+export function sanitizeInput(input: unknown, options = {}): unknown {
if (typeof input !== "string") {
return input;
}
@@ -15,9 +17,9 @@ export const sanitizeInput = (input: any, options = {}) => {
};
return DOMPurify.sanitize(input, defaultConfig);
-};
+}
-export const sanitizeObject = (obj: Record, options = {}): Record => {
+export const sanitizeObject = (obj: unknown, options = {}): unknown => {
if (typeof obj !== "object" || obj === null) {
return obj;
}
@@ -26,7 +28,7 @@ export const sanitizeObject = (obj: Record, options = {}): Record sanitizeObject(item, options));
}
- const sanitized: Record = {};
+ const sanitized: Record = {};
for (const [key, value] of Object.entries(obj)) {
if (typeof value === "string") {
sanitized[key] = sanitizeInput(value, options);
@@ -52,7 +54,12 @@ export const sanitizeBody = (options = {}): ((req: Request, res: Response, next:
export const sanitizeQuery = (options = {}): ((req: Request, res: Response, next: NextFunction) => void) => {
return (req: Request, res: Response, next: NextFunction) => {
if (req.query && typeof req.query === "object") {
- req.query = sanitizeObject(req.query, options);
+ for (const key of Object.keys(req.query)) {
+ const value = req.query[key];
+ if (typeof value === "string") {
+ req.query[key] = sanitizeInput(value, options);
+ }
+ }
}
next();
};
diff --git a/server/src/middleware/verifyJWT.ts b/server/src/middleware/verifyJWT.ts
index 6c2b0a229..4bec4c077 100644
--- a/server/src/middleware/verifyJWT.ts
+++ b/server/src/middleware/verifyJWT.ts
@@ -3,10 +3,15 @@ import { NextFunction, Request, Response } from "express";
import jwt from "jsonwebtoken";
import { AppError } from "@/utils/AppError.js";
import { Settings } from "@/types/settings.js";
+import type { User } from "@/types/user.js";
const SERVICE_NAME = "verifyJWT";
const TOKEN_PREFIX = "Bearer ";
+const isUser = (payload: unknown): payload is User => {
+ return typeof payload === "object" && payload !== null && "id" in payload && "teamId" in payload && "role" in payload;
+};
+
export const createVerifyJWT = (settingsService: { getSettings: () => Settings }) => {
return (req: Request, res: Response, next: NextFunction) => {
const token = req.headers["authorization"];
@@ -31,21 +36,22 @@ export const createVerifyJWT = (settingsService: { getSettings: () => Settings }
next(error);
return;
}
- jwt.verify(parsedToken, jwtSecret, (err: any, decoded: any) => {
+ jwt.verify(parsedToken, jwtSecret, (err: jwt.VerifyErrors | null, decoded: jwt.JwtPayload | string | undefined) => {
if (err) {
const error = new AppError({
- message: "Failed to authenticate token",
- details: err,
+ message: err instanceof Error ? err.message : "Failed to authenticate token",
status: 401,
service: SERVICE_NAME,
+ details: err instanceof Error ? { error: err } : undefined,
method: "verifyJWT",
});
next(error);
return;
- } else {
- // Token is valid, carry on
+ } else if (isUser(decoded)) {
req.user = decoded;
next();
+ } else {
+ next(new AppError({ message: "Invalid token payload", status: 401, service: SERVICE_NAME, method: "verifyJWT" }));
}
});
};
diff --git a/server/src/repositories/settings/ISettingsRepository.ts b/server/src/repositories/settings/ISettingsRepository.ts
index 578d7f40d..0485a5aea 100644
--- a/server/src/repositories/settings/ISettingsRepository.ts
+++ b/server/src/repositories/settings/ISettingsRepository.ts
@@ -1,11 +1,11 @@
-import type { Settings } from "@/types/index.js";
+import type { Settings, SettingsUpdate } from "@/types/index.js";
export interface ISettingsRepository {
// create
create(settings: Partial): Promise;
// fetch
findSingleton(): Promise;
// update
- update(settings: Partial): Promise;
+ update(settings: SettingsUpdate): Promise;
// delete
deleteLegacy: () => Promise;
}
diff --git a/server/src/repositories/settings/MongoSettingsRepository.ts b/server/src/repositories/settings/MongoSettingsRepository.ts
index f803ae6a9..3117a94ea 100644
--- a/server/src/repositories/settings/MongoSettingsRepository.ts
+++ b/server/src/repositories/settings/MongoSettingsRepository.ts
@@ -1,6 +1,6 @@
-import mongoose from "mongoose";
+import mongoose, { type UpdateQuery } from "mongoose";
import { ISettingsRepository } from "@/repositories/settings/ISettingsRepository.js";
-import type { Settings } from "@/types/index.js";
+import type { Settings, SettingsUpdate } from "@/types/index.js";
import { AppSettingsModel, type AppSettingsDocument } from "@/db/models/index.js";
class MongoSettingsRepository implements ISettingsRepository {
@@ -59,27 +59,23 @@ class MongoSettingsRepository implements ISettingsRepository {
return this.toEntity(settings);
};
- update = async (settings: Partial) => {
- const update: Record = { $set: {}, $unset: {} };
+ update = async (settings: SettingsUpdate) => {
+ const $set: Record = {};
+ const $unset: Record = {};
// Iterate through settings and separate into $set and $unset
Object.entries(settings).forEach(([key, value]) => {
- if (value === undefined || value === "") {
- // Unset undefined or empty string values
- update.$unset[key] = "";
+ if (value === undefined || value === null) {
+ $unset[key] = "";
} else {
- // Set defined values
- update.$set[key] = value;
+ $set[key] = value;
}
});
- // Clean up empty operators
- if (Object.keys(update.$set).length === 0) {
- delete update.$set;
- }
- if (Object.keys(update.$unset).length === 0) {
- delete update.$unset;
- }
+ const update: UpdateQuery = {
+ ...(Object.keys($set).length > 0 && { $set }),
+ ...(Object.keys($unset).length > 0 && { $unset }),
+ };
await AppSettingsModel.findOneAndUpdate({}, update, {
upsert: true,
diff --git a/server/src/repositories/status-pages/MongoStatusPagesRepository.ts b/server/src/repositories/status-pages/MongoStatusPagesRepository.ts
index e19676285..eb2d92729 100644
--- a/server/src/repositories/status-pages/MongoStatusPagesRepository.ts
+++ b/server/src/repositories/status-pages/MongoStatusPagesRepository.ts
@@ -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),
diff --git a/server/src/service/business/monitorService.ts b/server/src/service/business/monitorService.ts
index ff7b87fe8..71c9d9d90 100644
--- a/server/src/service/business/monitorService.ts
+++ b/server/src/service/business/monitorService.ts
@@ -7,6 +7,7 @@ import type {
HardwareDetailsResult,
PageSpeedDetailsResult,
GamesMap,
+ GroupedGeoCheckResult,
} from "@/types/monitor.js";
import { supportsGeoCheck } from "@/types/monitor.js";
import type { GeoContinent } from "@/types/geoCheck.js";
@@ -20,7 +21,10 @@ import type {
} from "@/repositories/index.js";
import demoMonitorsData from "@/utils/demoMonitors.json" with { type: "json" };
import { AppError } from "@/utils/AppError.js";
+import type { ImportedMonitor } from "@/validation/monitorValidation.js";
import { ISuperSimpleQueue } from "../infrastructure/SuperSimpleQueue/SuperSimpleQueue.js";
+import { IEmailService } from "../infrastructure/emailService.js";
+import { ILogger } from "@/utils/logger.js";
const SERVICE_NAME = "MonitorService";
type DateRangeKey = "recent" | "day" | "week" | "month" | "all";
@@ -30,14 +34,19 @@ export interface IMonitorService {
// create
createMonitor(teamId: string, userId: string, body: Partial): Promise;
- createMonitors(monitors: Array, userId: string, teamId: string): Promise;
+ createMonitors(monitors: Array): Promise;
addDemoMonitors(args: { userId: string; teamId: string }): Promise;
// read
- getUptimeDetailsById(args: { teamId: string; monitorId: string; dateRange: string; normalize?: boolean }): Promise;
+ getUptimeDetailsById(args: { teamId: string; monitorId: string; dateRange: string }): Promise;
getHardwareDetailsById(args: { teamId: string; monitorId: string; dateRange: string }): Promise;
getPageSpeedDetailsById(args: { teamId: string; monitorId: string; dateRange: string }): Promise;
- getGeoChecksByMonitorId(args: { teamId: string; monitorId: string; dateRange: string; continents?: GeoContinent[] }): Promise;
+ getGeoChecksByMonitorId(args: {
+ teamId: string;
+ monitorId: string;
+ dateRange: string;
+ continents?: GeoContinent[];
+ }): Promise;
getMonitorById(args: { teamId: string; monitorId: string }): Promise;
getMonitorsByTeamId(args: {
teamId: string;
@@ -58,7 +67,6 @@ export interface IMonitorService {
filter?: string;
field?: string;
order?: "asc" | "desc";
- explain?: boolean;
}): Promise;
getAllGames(): GamesMap;
getGroupsByTeamId(args: { teamId: string }): Promise;
@@ -76,16 +84,16 @@ export interface IMonitorService {
// other
exportMonitorsToJSON(args: { teamId: string }): Promise;
- importMonitorsFromJSON(args: { teamId: string; userId: string; monitors: any[] }): Promise<{ imported: number; errors: string[] }>;
+ importMonitorsFromJSON(args: { teamId: string; userId: string; monitors: ImportedMonitor[] }): Promise<{ imported: number; errors: string[] }>;
}
export class MonitorService implements IMonitorService {
static SERVICE_NAME = SERVICE_NAME;
private jobQueue: ISuperSimpleQueue;
- private emailService: any;
- private logger: any;
- private games: any;
+ private emailService: IEmailService;
+ private logger: ILogger;
+ private games: GamesMap;
private monitorsRepository: IMonitorsRepository;
private checksRepository: IChecksRepository;
private geoChecksRepository: IGeoChecksRepository;
@@ -106,9 +114,9 @@ export class MonitorService implements IMonitorService {
incidentsRepository,
}: {
jobQueue: ISuperSimpleQueue;
- emailService: any;
- logger: any;
- games: any;
+ emailService: IEmailService;
+ logger: ILogger;
+ games: GamesMap;
monitorsRepository: IMonitorsRepository;
checksRepository: IChecksRepository;
geoChecksRepository: IGeoChecksRepository;
@@ -166,7 +174,7 @@ export class MonitorService implements IMonitorService {
this.jobQueue.addJob(monitor.id, monitor);
};
- createMonitors = async (monitors: Array, userId: string, teamId: string): Promise => {
+ createMonitors = async (monitors: Array): Promise => {
const createdMonitors = await this.monitorsRepository.createMonitors(monitors);
if (!monitors || monitors.length === 0) {
throw new AppError({ message: "Failed to create monitors", status: 500, service: SERVICE_NAME, method: "createMonitors" });
@@ -176,7 +184,7 @@ export class MonitorService implements IMonitorService {
return createdMonitors;
};
- addDemoMonitors = async ({ userId, teamId }: { userId: string; teamId: string }): Promise => {
+ addDemoMonitors = async ({ userId, teamId }: { userId: string; teamId: string }): Promise => {
const demoData = demoMonitorsData;
const monitors = demoData.map((monitor) => ({
userId,
@@ -197,12 +205,10 @@ export class MonitorService implements IMonitorService {
teamId,
monitorId,
dateRange,
- normalize,
}: {
teamId: string;
monitorId: string;
dateRange: string;
- normalize?: boolean;
}): Promise => {
const monitor = await this.monitorsRepository.findById(monitorId, teamId);
if (!monitor) {
@@ -330,7 +336,7 @@ export class MonitorService implements IMonitorService {
monitorId: string;
dateRange: string;
continents?: GeoContinent[];
- }): Promise => {
+ }): Promise => {
const monitor = await this.monitorsRepository.findById(monitorId, teamId);
if (!monitor) {
throw new AppError({ message: `Monitor with ID ${monitorId} not found.`, status: 404 });
@@ -383,7 +389,6 @@ export class MonitorService implements IMonitorService {
filter,
field,
order,
- explain,
}: {
teamId: string;
limit?: number;
@@ -393,7 +398,6 @@ export class MonitorService implements IMonitorService {
filter?: string;
field?: string;
order?: "asc" | "desc";
- explain?: boolean;
}): Promise => {
const summary = await this.monitorsRepository.findMonitorsSummaryByTeamId(teamId, { type });
const count = await this.monitorsRepository.findMonitorCountByTeamIdAndType(teamId, { type, filter });
@@ -462,47 +466,51 @@ export class MonitorService implements IMonitorService {
pauseMonitor = async ({ teamId, monitorId }: { teamId: string; monitorId: string }): Promise => {
const monitor = await this.monitorsRepository.togglePauseById(monitorId, teamId);
- monitor.isActive === true ? await this.jobQueue.resumeJob(monitor) : await this.jobQueue.pauseJob(monitor);
+ if (monitor.isActive) {
+ await this.jobQueue.resumeJob(monitor);
+ } else {
+ await this.jobQueue.pauseJob(monitor);
+ }
return monitor;
};
deleteMonitor = async ({ teamId, monitorId }: { teamId: string; monitorId: string }): Promise => {
const monitor = await this.monitorsRepository.deleteById(monitorId, teamId);
- await this.monitorStatsRepository.deleteByMonitorId(monitor.id).catch((err: any) => {
+ await this.monitorStatsRepository.deleteByMonitorId(monitor.id).catch((err: unknown) => {
this.logger.warn({
message: `Error deleting monitor stats for monitor ${monitor.id} with name ${monitor.name}`,
service: SERVICE_NAME,
- stack: err.stack,
+ stack: err instanceof Error ? err.stack : undefined,
});
});
- await this.checksRepository.deleteByMonitorId(monitor.id).catch((err: any) => {
+ await this.checksRepository.deleteByMonitorId(monitor.id).catch((err: unknown) => {
this.logger.warn({
message: `Error deleting checks for monitor ${monitor.id} with name ${monitor.name}`,
service: SERVICE_NAME,
- stack: err.stack,
+ stack: err instanceof Error ? err.stack : undefined,
});
});
- await this.statusPagesRepository.removeMonitorFromStatusPages(monitor.id).catch((err: any) => {
+ await this.statusPagesRepository.removeMonitorFromStatusPages(monitor.id).catch((err: unknown) => {
this.logger.warn({
message: `Error removing monitor ${monitor.id} with name ${monitor.name} from status pages`,
service: SERVICE_NAME,
- stack: err.stack,
+ stack: err instanceof Error ? err.stack : undefined,
});
});
- await this.incidentsRepository.deleteByMonitorId(monitor.id, teamId).catch((err: any) => {
+ await this.incidentsRepository.deleteByMonitorId(monitor.id, teamId).catch((err: unknown) => {
this.logger.warn({
message: `Error deleting incidents for monitor ${monitor.id} with name ${monitor.name}`,
service: SERVICE_NAME,
- stack: err.stack,
+ stack: err instanceof Error ? err.stack : undefined,
});
});
- await this.geoChecksRepository.deleteByMonitorId(monitor.id).catch((err: any) => {
+ await this.geoChecksRepository.deleteByMonitorId(monitor.id).catch((err: unknown) => {
this.logger.warn({
message: `Error deleting geo checks for monitor ${monitor.id} with name ${monitor.name}`,
service: SERVICE_NAME,
- stack: err.stack,
+ stack: err instanceof Error ? err.stack : undefined,
});
});
@@ -520,12 +528,12 @@ export class MonitorService implements IMonitorService {
await this.geoChecksRepository.deleteByMonitorId(monitor.id);
await this.statusPagesRepository.removeMonitorFromStatusPages(monitor.id);
await this.monitorStatsRepository.deleteByMonitorId(monitor.id);
- } catch (error: any) {
+ } catch (error: unknown) {
this.logger.warn({
message: `Error deleting associated records for monitor ${monitor.id} with name ${monitor.name}`,
service: SERVICE_NAME,
method: "deleteAllMonitors",
- stack: error.stack,
+ stack: error instanceof Error ? error.stack : undefined,
});
}
})
@@ -533,7 +541,7 @@ export class MonitorService implements IMonitorService {
return deletedCount;
};
- exportMonitorsToJSON = async ({ teamId }: { teamId: string }): Promise => {
+ exportMonitorsToJSON = async ({ teamId }: { teamId: string }): Promise => {
const monitors = await this.monitorsRepository.findByTeamId(teamId, {});
if (!monitors || monitors.length === 0) {
@@ -550,23 +558,21 @@ export class MonitorService implements IMonitorService {
}: {
teamId: string;
userId: string;
- monitors: any[];
+ monitors: ImportedMonitor[];
}): Promise<{ imported: number; errors: string[] }> => {
const errors: string[] = [];
- const cleanedMonitors = monitors.map((monitor) => {
- const cleanData = { ...monitor };
- delete cleanData.id;
- delete cleanData._id;
- delete cleanData.createdAt;
- delete cleanData.updatedAt;
- delete cleanData.recentChecks;
- // Monitors must belong to current team
- cleanData.teamId = teamId;
- return cleanData;
- });
+ const cleanedMonitors: Monitor[] = monitors.map((monitor) => ({
+ ...monitor,
+ id: "",
+ teamId,
+ userId,
+ recentChecks: [],
+ createdAt: "",
+ updatedAt: "",
+ }));
- const createdMonitors = await this.createMonitors(cleanedMonitors, userId, teamId);
+ const createdMonitors = await this.createMonitors(cleanedMonitors);
if (!createdMonitors || createdMonitors.length === 0) {
throw new AppError({
diff --git a/server/src/service/infrastructure/statusService.ts b/server/src/service/infrastructure/statusService.ts
index badcf78ec..ef725b80a 100755
--- a/server/src/service/infrastructure/statusService.ts
+++ b/server/src/service/infrastructure/statusService.ts
@@ -360,8 +360,7 @@ export class StatusService implements IStatusService {
};
} catch (error: unknown) {
throw new AppError({
- message: `Failed to update monitor status: ${error instanceof Error ? error.message : "Unknown error"}`,
- details: `Error updating status for monitor with id ${check.metadata.monitorId}`,
+ message: `Failed to update monitor with id ${check.metadata.monitorId} with status: ${error instanceof Error ? error.message : "Unknown error"}`,
service: SERVICE_NAME,
method: "updateMonitorStatus",
});
diff --git a/server/src/service/system/settingsService.ts b/server/src/service/system/settingsService.ts
index ac4dfc425..519d2f390 100755
--- a/server/src/service/system/settingsService.ts
+++ b/server/src/service/system/settingsService.ts
@@ -1,5 +1,5 @@
import { ISettingsRepository } from "@/repositories/index.js";
-import { Settings } from "@/types/index.js";
+import { Settings, SettingsUpdate } from "@/types/index.js";
import { AppError } from "@/utils/AppError.js";
import { ValidatedEnv } from "@/validation/envValidation.js";
import type { StringValue } from "ms";
@@ -19,6 +19,7 @@ export interface ISettingsService {
loadSettings(): EnvConfig;
getSettings(): EnvConfig;
getDBSettings(): Promise;
+ updateDbSettings(newSettings: SettingsUpdate): Promise;
}
export class SettingsService implements ISettingsService {
@@ -53,7 +54,7 @@ export class SettingsService implements ISettingsService {
return this.settings;
}
- updateDbSettings = async (newSettings: Partial) => {
+ updateDbSettings = async (newSettings: SettingsUpdate) => {
return await this.settingsRepository.update(newSettings);
};
diff --git a/server/src/types/monitor.ts b/server/src/types/monitor.ts
index bac2313f5..f29ce75d7 100644
--- a/server/src/types/monitor.ts
+++ b/server/src/types/monitor.ts
@@ -1,6 +1,6 @@
import type { CheckSnapshot } from "@/types/check.js";
export type { CheckSnapshot } from "@/types/check.js";
-import type { GeoContinent } from "@/types/geoCheck.js";
+import type { GeoContinent, GroupedGeoCheck } from "@/types/geoCheck.js";
export type { GeoContinent } from "@/types/geoCheck.js";
export const MonitorTypes = ["http", "ping", "pagespeed", "hardware", "docker", "port", "game", "grpc", "websocket", "unknown"] as const;
@@ -74,6 +74,10 @@ export interface MonitorsWithChecksByTeamIdResult {
monitors: Monitor[];
}
+export interface GroupedGeoCheckResult {
+ groupedGeoChecks: GroupedGeoCheck[];
+}
+
export interface UptimeDetailsResult {
monitorData: {
monitor: Monitor;
diff --git a/server/src/types/settings.ts b/server/src/types/settings.ts
index 108cdcb72..405dc3efe 100644
--- a/server/src/types/settings.ts
+++ b/server/src/types/settings.ts
@@ -5,6 +5,10 @@ export interface SettingsThresholds {
temperature?: number;
}
+export type SettingsUpdate = {
+ [K in keyof Settings]?: Settings[K] | null;
+};
+
export interface Settings {
id: string;
checkTTL: number;
diff --git a/server/src/types/statusPage.ts b/server/src/types/statusPage.ts
index b11cf53d6..0329f4e6f 100644
--- a/server/src/types/statusPage.ts
+++ b/server/src/types/statusPage.ts
@@ -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;
diff --git a/server/src/utils/AppError.ts b/server/src/utils/AppError.ts
index c19f726f2..4c8887a9c 100644
--- a/server/src/utils/AppError.ts
+++ b/server/src/utils/AppError.ts
@@ -1,18 +1,18 @@
export interface AppErrorConfig {
- message: string;
+ message?: string;
status?: number;
- service?: string | null;
- method?: string | null;
- details?: unknown;
+ service?: string;
+ method?: string;
+ details?: Record | undefined;
}
export class AppError extends Error {
- private status: number;
- private service: string | null;
- private method: string | null;
- private details: unknown;
+ readonly status: number;
+ readonly service: string;
+ readonly method: string;
+ readonly details: Record | undefined;
- constructor({ message, status = 500, service = null, method = null, details = null }: AppErrorConfig) {
+ constructor({ message, status = 500, service = "unknownService", method = "unknownMethod", details = undefined }: AppErrorConfig) {
super(message);
this.status = status;
this.service = service;
diff --git a/server/src/utils/dataUtils.ts b/server/src/utils/dataUtils.ts
index 3c86c911e..f6d877a35 100755
--- a/server/src/utils/dataUtils.ts
+++ b/server/src/utils/dataUtils.ts
@@ -45,7 +45,7 @@ export const NormalizeData = (checks: T[], rangeMin:
const min = calculatePercentile(checks, 0);
const max = calculatePercentile(checks, 95);
const normalizedChecks = checks.map((check) => {
- const originalResponseTime = check.responseTime;
+ const originalResponseTime = typeof check.responseTime === "number" ? check.responseTime : 0;
let normalizedResponseTime = rangeMin + ((check.responseTime - min) * (rangeMax - rangeMin)) / (max - min);
normalizedResponseTime = Math.max(rangeMin, Math.min(rangeMax, normalizedResponseTime));
@@ -59,7 +59,8 @@ export const NormalizeData = (checks: T[], rangeMin:
return normalizedChecks;
} else {
return checks.map((check) => {
- return { ...check, originalResponseTime: check.responseTime };
+ const originalResponseTime = typeof check.responseTime === "number" ? check.responseTime : 0;
+ return { ...check, originalResponseTime: originalResponseTime };
});
}
};
diff --git a/server/src/validation/monitorValidation.ts b/server/src/validation/monitorValidation.ts
index 783f1a029..df000ecef 100644
--- a/server/src/validation/monitorValidation.ts
+++ b/server/src/validation/monitorValidation.ts
@@ -128,39 +128,38 @@ const importedMonitorSchema = z.object({
teamId: z.string().optional(),
name: z.string().min(1, "Name is required"),
description: z.union([z.string(), z.literal("")]).optional(),
- status: z.enum(["up", "down", "paused", "initializing", "maintenance", "breached"]).optional(),
- statusWindow: z.array(z.boolean()).optional(),
- statusWindowSize: z.number().optional(),
- statusWindowThreshold: z.number().optional(),
+ status: z.enum(["up", "down", "paused", "initializing", "maintenance", "breached"]).default("initializing"),
+ statusWindow: z.array(z.boolean()).default([]),
+ statusWindowSize: z.number().min(1).max(20).default(5),
+ statusWindowThreshold: z.number().min(1).max(100).default(60),
type: z.enum(MonitorTypes, "Invalid monitor type"),
- ignoreTlsErrors: z.boolean().optional(),
- useAdvancedMatching: z.boolean().optional(),
+ ignoreTlsErrors: z.boolean().default(false),
+ useAdvancedMatching: z.boolean().default(false),
jsonPath: z.union([z.string(), z.literal("")]).optional(),
expectedValue: z.union([z.string(), z.literal("")]).optional(),
matchMethod: z.union([z.enum(MonitorMatchMethods), z.literal("")]).optional(),
url: z.string().min(1, "URL is required"),
port: z.number().optional(),
- isActive: z.boolean().optional(),
- interval: z.number().optional(),
+ isActive: z.boolean().default(true),
+ interval: z.number().default(60000),
uptimePercentage: z.number().optional(),
- notifications: z.array(z.string()).optional(),
+ notifications: z.array(z.string()).default([]),
secret: z.string().optional(),
- cpuAlertThreshold: z.number().optional(),
- cpuAlertCounter: z.number().optional(),
- memoryAlertThreshold: z.number().optional(),
- memoryAlertCounter: z.number().optional(),
- diskAlertThreshold: z.number().optional(),
- diskAlertCounter: z.number().optional(),
- tempAlertThreshold: z.number().optional(),
- tempAlertCounter: z.number().optional(),
- selectedDisks: z.array(z.string()).optional(),
+ cpuAlertThreshold: z.number().default(100),
+ cpuAlertCounter: z.number().default(5),
+ memoryAlertThreshold: z.number().default(100),
+ memoryAlertCounter: z.number().default(5),
+ diskAlertThreshold: z.number().default(100),
+ diskAlertCounter: z.number().default(5),
+ tempAlertThreshold: z.number().default(100),
+ tempAlertCounter: z.number().default(5),
+ selectedDisks: z.array(z.string()).default([]),
gameId: z.union([z.string(), z.literal("")]).optional(),
- grpcServiceName: z.union([z.string(), z.literal("")]).optional(),
- group: z.union([z.string(), z.null()]).optional(),
- geoCheckEnabled: z.boolean().optional(),
- geoCheckLocations: z.array(z.enum(GeoContinents)).optional(),
- geoCheckInterval: z.number().optional(),
- recentChecks: z.array(z.unknown()).optional(),
+ grpcServiceName: z.union([z.string(), z.literal("")]).default(""),
+ group: z.union([z.string().max(50).trim(), z.null()]).default(null),
+ geoCheckEnabled: z.boolean().default(false),
+ geoCheckLocations: z.array(z.enum(GeoContinents)).default([]),
+ geoCheckInterval: z.number().min(300000).default(300000),
createdAt: z.string().optional(),
updatedAt: z.string().optional(),
});
@@ -169,6 +168,8 @@ export const importMonitorsBodyValidation = z.object({
monitors: z.array(importedMonitorSchema).min(1, "At least one monitor is required"),
});
+export type ImportedMonitor = z.output;
+
export const getHardwareDetailsByIdParamValidation = z.object({
monitorId: z.string().min(1, "Monitor ID is required"),
});
diff --git a/server/src/validation/settingsValidation.ts b/server/src/validation/settingsValidation.ts
index e7f0dc9b4..f2c4e4c94 100644
--- a/server/src/validation/settingsValidation.ts
+++ b/server/src/validation/settingsValidation.ts
@@ -7,17 +7,17 @@ import { z } from "zod";
export const updateAppSettingsBodyValidation = z
.object({
- checkTTL: z.union([z.number().int().min(1).max(CHECK_TTL_SENTINEL), z.literal("")]).optional(),
- systemEmailPort: z.union([z.number(), z.literal("")]).optional(),
- pagespeedApiKey: z.union([z.string(), z.literal("")]).optional(),
- language: z.union([z.string(), z.literal("")]).optional(),
- timezone: z.union([z.string(), z.literal("")]).optional(),
- systemEmailHost: z.union([z.string(), z.literal("")]).optional(),
- systemEmailAddress: z.union([z.string(), z.literal("")]).optional(),
- systemEmailPassword: z.union([z.string(), z.literal("")]).optional(),
- systemEmailUser: z.union([z.string(), z.literal("")]).optional(),
- systemEmailConnectionHost: z.union([z.string(), z.literal("")]).optional(),
- systemEmailTLSServername: z.union([z.string(), z.literal("")]).optional(),
+ checkTTL: z.number().int().min(1).max(CHECK_TTL_SENTINEL).optional(),
+ systemEmailPort: z.number().nullable().optional(),
+ pagespeedApiKey: z.string().nullable().optional(),
+ language: z.string().optional(),
+ timezone: z.string().optional(),
+ systemEmailHost: z.string().nullable().optional(),
+ systemEmailAddress: z.string().nullable().optional(),
+ systemEmailPassword: z.string().nullable().optional(),
+ systemEmailUser: z.string().nullable().optional(),
+ systemEmailConnectionHost: z.string().nullable().optional(),
+ systemEmailTLSServername: z.string().nullable().optional(),
showURL: z.boolean().optional(),
systemEmailSecure: z.boolean().optional(),
@@ -28,10 +28,10 @@ export const updateAppSettingsBodyValidation = z
globalThresholds: z
.object({
- cpu: z.union([z.number().min(1).max(100), z.literal("")]).optional(),
- memory: z.union([z.number().min(1).max(100), z.literal("")]).optional(),
- disk: z.union([z.number().min(1).max(100), z.literal("")]).optional(),
- temperature: z.union([z.number().min(1).max(150), z.literal("")]).optional(),
+ cpu: z.number().min(1).max(100).optional(),
+ memory: z.number().min(1).max(100).optional(),
+ disk: z.number().min(1).max(100).optional(),
+ temperature: z.number().min(1).max(150).optional(),
})
.optional(),
})
diff --git a/server/src/validation/statusPageValidation.ts b/server/src/validation/statusPageValidation.ts
index b4b5e270c..8b2ab3f20 100644
--- a/server/src/validation/statusPageValidation.ts
+++ b/server/src/validation/statusPageValidation.ts
@@ -1,5 +1,6 @@
import { z } from "zod";
import { booleanCoercion } from "./shared.js";
+import { StatusPageTypes } from "@/types/statusPage.js";
//****************************************
// Status Page Validations
@@ -10,13 +11,13 @@ export const getStatusPageParamValidation = z.object({
});
export const getStatusPageQueryValidation = z.object({
- type: z.literal("uptime"),
+ type: z.union([z.enum(StatusPageTypes), z.array(z.enum(StatusPageTypes))]).transform((val) => (Array.isArray(val) ? val : [val])),
timeFrame: z.coerce.number().optional(),
});
export const createStatusPageBodyValidation = z
.object({
- type: z.literal("uptime"),
+ type: z.union([z.enum(StatusPageTypes), z.array(z.enum(StatusPageTypes))]).transform((val) => (Array.isArray(val) ? val : [val])),
companyName: z.string().min(1, "Company name is required"),
url: z.string().regex(/^[a-zA-Z0-9_-]+$/, {
message: "URL can only contain letters, numbers, underscores, and hyphens",
@@ -30,6 +31,7 @@ export const createStatusPageBodyValidation = z
showCharts: booleanCoercion.optional(),
showUptimePercentage: booleanCoercion,
showAdminLoginLink: booleanCoercion.optional(),
+ showInfrastructure: booleanCoercion.optional(),
removeLogo: z.union([z.literal("true"), z.literal("false")]).optional(),
})
.strip();