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();