From aa61e0f321059c1eaba2799819fd544b88e19d4c Mon Sep 17 00:00:00 2001 From: Alex Holliday Date: Thu, 12 Mar 2026 09:13:35 -0700 Subject: [PATCH 01/32] refacotr settingsController --- server/src/controllers/settingsController.ts | 14 +++++---- server/src/service/system/settingsService.ts | 1 + server/src/validation/settingsValidation.ts | 30 ++++++++++---------- 3 files changed, 25 insertions(+), 20 deletions(-) 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/service/system/settingsService.ts b/server/src/service/system/settingsService.ts index ac4dfc425..10ac61725 100755 --- a/server/src/service/system/settingsService.ts +++ b/server/src/service/system/settingsService.ts @@ -19,6 +19,7 @@ export interface ISettingsService { loadSettings(): EnvConfig; getSettings(): EnvConfig; getDBSettings(): Promise; + updateDbSettings(newSettings: Partial): Promise; } export class SettingsService implements ISettingsService { diff --git a/server/src/validation/settingsValidation.ts b/server/src/validation/settingsValidation.ts index e7f0dc9b4..c89928155 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.union([z.number().int().min(1).max(CHECK_TTL_SENTINEL)]).optional(), + systemEmailPort: z.number().optional(), + pagespeedApiKey: z.string().optional(), + language: z.string().optional(), + timezone: z.string().optional(), + systemEmailHost: z.string().optional(), + systemEmailAddress: z.string().optional(), + systemEmailPassword: z.string().optional(), + systemEmailUser: z.string().optional(), + systemEmailConnectionHost: z.string().optional(), + systemEmailTLSServername: z.string().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.union([z.number().min(1).max(100)]).optional(), + memory: z.union([z.number().min(1).max(100)]).optional(), + disk: z.union([z.number().min(1).max(100)]).optional(), + temperature: z.union([z.number().min(1).max(150)]).optional(), }) .optional(), }) From d070a33d7ffbf312d5217430dece8312d405a6f5 Mon Sep 17 00:00:00 2001 From: Alex Holliday Date: Thu, 12 Mar 2026 09:32:34 -0700 Subject: [PATCH 02/32] remove dead union, remove unused undefined -> conversion --- server/src/validation/settingsValidation.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/server/src/validation/settingsValidation.ts b/server/src/validation/settingsValidation.ts index c89928155..91953eef4 100644 --- a/server/src/validation/settingsValidation.ts +++ b/server/src/validation/settingsValidation.ts @@ -7,7 +7,7 @@ import { z } from "zod"; export const updateAppSettingsBodyValidation = z .object({ - checkTTL: z.union([z.number().int().min(1).max(CHECK_TTL_SENTINEL)]).optional(), + checkTTL: z.number().int().min(1).max(CHECK_TTL_SENTINEL).optional(), systemEmailPort: z.number().optional(), pagespeedApiKey: z.string().optional(), language: z.string().optional(), From 8ec285ed41bdce169431d2f1c8fa7de9c9564fe2 Mon Sep 17 00:00:00 2001 From: Alex Holliday Date: Thu, 12 Mar 2026 09:35:33 -0700 Subject: [PATCH 03/32] remove dead unions --- client/src/Pages/Settings/index.tsx | 10 +--------- server/src/validation/settingsValidation.ts | 8 ++++---- 2 files changed, 5 insertions(+), 13 deletions(-) diff --git a/client/src/Pages/Settings/index.tsx b/client/src/Pages/Settings/index.tsx index 679d66126..e51255157 100644 --- a/client/src/Pages/Settings/index.tsx +++ b/client/src/Pages/Settings/index.tsx @@ -262,15 +262,7 @@ export const SettingsPage = () => { 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, - }), - {} - ); + (acc as any)[typedKey] = value; } else { (acc as any)[typedKey] = value; } diff --git a/server/src/validation/settingsValidation.ts b/server/src/validation/settingsValidation.ts index 91953eef4..b9551dd4e 100644 --- a/server/src/validation/settingsValidation.ts +++ b/server/src/validation/settingsValidation.ts @@ -28,10 +28,10 @@ export const updateAppSettingsBodyValidation = z globalThresholds: z .object({ - cpu: z.union([z.number().min(1).max(100)]).optional(), - memory: z.union([z.number().min(1).max(100)]).optional(), - disk: z.union([z.number().min(1).max(100)]).optional(), - temperature: z.union([z.number().min(1).max(150)]).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(), }) From 84ca5db0fa490b47f8a29c0b5d923ff7ba961ba3 Mon Sep 17 00:00:00 2001 From: Alex Holliday Date: Thu, 12 Mar 2026 09:13:35 -0700 Subject: [PATCH 04/32] refactor settingsController --- server/src/controllers/settingsController.ts | 14 +++++---- server/src/service/system/settingsService.ts | 1 + server/src/validation/settingsValidation.ts | 30 ++++++++++---------- 3 files changed, 25 insertions(+), 20 deletions(-) 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/service/system/settingsService.ts b/server/src/service/system/settingsService.ts index ac4dfc425..10ac61725 100755 --- a/server/src/service/system/settingsService.ts +++ b/server/src/service/system/settingsService.ts @@ -19,6 +19,7 @@ export interface ISettingsService { loadSettings(): EnvConfig; getSettings(): EnvConfig; getDBSettings(): Promise; + updateDbSettings(newSettings: Partial): Promise; } export class SettingsService implements ISettingsService { diff --git a/server/src/validation/settingsValidation.ts b/server/src/validation/settingsValidation.ts index e7f0dc9b4..c89928155 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.union([z.number().int().min(1).max(CHECK_TTL_SENTINEL)]).optional(), + systemEmailPort: z.number().optional(), + pagespeedApiKey: z.string().optional(), + language: z.string().optional(), + timezone: z.string().optional(), + systemEmailHost: z.string().optional(), + systemEmailAddress: z.string().optional(), + systemEmailPassword: z.string().optional(), + systemEmailUser: z.string().optional(), + systemEmailConnectionHost: z.string().optional(), + systemEmailTLSServername: z.string().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.union([z.number().min(1).max(100)]).optional(), + memory: z.union([z.number().min(1).max(100)]).optional(), + disk: z.union([z.number().min(1).max(100)]).optional(), + temperature: z.union([z.number().min(1).max(150)]).optional(), }) .optional(), }) From fa8764fde2822498d351a0ea0fb1f20353341a8b Mon Sep 17 00:00:00 2001 From: Alex Holliday Date: Thu, 12 Mar 2026 09:32:34 -0700 Subject: [PATCH 05/32] remove dead union, remove unused undefined -> conversion --- server/src/validation/settingsValidation.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/server/src/validation/settingsValidation.ts b/server/src/validation/settingsValidation.ts index c89928155..91953eef4 100644 --- a/server/src/validation/settingsValidation.ts +++ b/server/src/validation/settingsValidation.ts @@ -7,7 +7,7 @@ import { z } from "zod"; export const updateAppSettingsBodyValidation = z .object({ - checkTTL: z.union([z.number().int().min(1).max(CHECK_TTL_SENTINEL)]).optional(), + checkTTL: z.number().int().min(1).max(CHECK_TTL_SENTINEL).optional(), systemEmailPort: z.number().optional(), pagespeedApiKey: z.string().optional(), language: z.string().optional(), From e9cb302d6e2d5ecb6196e99da1c8814d64c9bfbf Mon Sep 17 00:00:00 2001 From: Alex Holliday Date: Thu, 12 Mar 2026 09:35:33 -0700 Subject: [PATCH 06/32] remove dead unions --- client/src/Pages/Settings/index.tsx | 10 +--------- server/src/validation/settingsValidation.ts | 8 ++++---- 2 files changed, 5 insertions(+), 13 deletions(-) diff --git a/client/src/Pages/Settings/index.tsx b/client/src/Pages/Settings/index.tsx index 679d66126..e51255157 100644 --- a/client/src/Pages/Settings/index.tsx +++ b/client/src/Pages/Settings/index.tsx @@ -262,15 +262,7 @@ export const SettingsPage = () => { 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, - }), - {} - ); + (acc as any)[typedKey] = value; } else { (acc as any)[typedKey] = value; } diff --git a/server/src/validation/settingsValidation.ts b/server/src/validation/settingsValidation.ts index 91953eef4..b9551dd4e 100644 --- a/server/src/validation/settingsValidation.ts +++ b/server/src/validation/settingsValidation.ts @@ -28,10 +28,10 @@ export const updateAppSettingsBodyValidation = z globalThresholds: z .object({ - cpu: z.union([z.number().min(1).max(100)]).optional(), - memory: z.union([z.number().min(1).max(100)]).optional(), - disk: z.union([z.number().min(1).max(100)]).optional(), - temperature: z.union([z.number().min(1).max(150)]).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(), }) From 411270d2566d2425bf25370b17ae958c40591c86 Mon Sep 17 00:00:00 2001 From: mannilakash Date: Thu, 12 Mar 2026 12:44:46 -0400 Subject: [PATCH 07/32] fix: modify heatmap response time conditional text --- .../Components/common/charts/HeatmapResponseTimeTooltip.tsx | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/client/src/Components/common/charts/HeatmapResponseTimeTooltip.tsx b/client/src/Components/common/charts/HeatmapResponseTimeTooltip.tsx index c046e05b5..dc36ee5d9 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() ?? "-"} ms Status:{" "} From 48e71d4345b698d6c78707b4ee2559a1d64c91b7 Mon Sep 17 00:00:00 2001 From: Alex Holliday Date: Thu, 12 Mar 2026 09:53:31 -0700 Subject: [PATCH 08/32] use null for unsetting instead of empty string --- .../repositories/settings/ISettingsRepository.ts | 4 ++-- .../settings/MongoSettingsRepository.ts | 6 +++--- server/src/service/system/settingsService.ts | 6 +++--- server/src/types/settings.ts | 4 ++++ server/src/validation/settingsValidation.ts | 16 ++++++++-------- 5 files changed, 20 insertions(+), 16 deletions(-) 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..e41abcd96 100644 --- a/server/src/repositories/settings/MongoSettingsRepository.ts +++ b/server/src/repositories/settings/MongoSettingsRepository.ts @@ -1,6 +1,6 @@ import mongoose 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,12 +59,12 @@ class MongoSettingsRepository implements ISettingsRepository { return this.toEntity(settings); }; - update = async (settings: Partial) => { + update = async (settings: SettingsUpdate) => { const update: Record = { $set: {}, $unset: {} }; // Iterate through settings and separate into $set and $unset Object.entries(settings).forEach(([key, value]) => { - if (value === undefined || value === "") { + if (value === undefined || value === null) { // Unset undefined or empty string values update.$unset[key] = ""; } else { diff --git a/server/src/service/system/settingsService.ts b/server/src/service/system/settingsService.ts index 10ac61725..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,7 +19,7 @@ export interface ISettingsService { loadSettings(): EnvConfig; getSettings(): EnvConfig; getDBSettings(): Promise; - updateDbSettings(newSettings: Partial): Promise; + updateDbSettings(newSettings: SettingsUpdate): Promise; } export class SettingsService implements ISettingsService { @@ -54,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/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/validation/settingsValidation.ts b/server/src/validation/settingsValidation.ts index b9551dd4e..f2c4e4c94 100644 --- a/server/src/validation/settingsValidation.ts +++ b/server/src/validation/settingsValidation.ts @@ -8,16 +8,16 @@ import { z } from "zod"; export const updateAppSettingsBodyValidation = z .object({ checkTTL: z.number().int().min(1).max(CHECK_TTL_SENTINEL).optional(), - systemEmailPort: z.number().optional(), - pagespeedApiKey: z.string().optional(), + systemEmailPort: z.number().nullable().optional(), + pagespeedApiKey: z.string().nullable().optional(), language: z.string().optional(), timezone: z.string().optional(), - systemEmailHost: z.string().optional(), - systemEmailAddress: z.string().optional(), - systemEmailPassword: z.string().optional(), - systemEmailUser: z.string().optional(), - systemEmailConnectionHost: z.string().optional(), - systemEmailTLSServername: 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(), From d3605b0149fa787206ec337ae5e34b0c609a8dc0 Mon Sep 17 00:00:00 2001 From: Alex Holliday Date: Thu, 12 Mar 2026 09:54:08 -0700 Subject: [PATCH 09/32] update client validation/pages --- client/src/Hooks/useSettingsForm.ts | 4 ++-- client/src/Pages/Settings/index.tsx | 19 +++---------------- client/src/Validation/settings.ts | 16 ++++++++-------- 3 files changed, 13 insertions(+), 26 deletions(-) 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/Pages/Settings/index.tsx b/client/src/Pages/Settings/index.tsx index e51255157..302ce9ab2 100644 --- a/client/src/Pages/Settings/index.tsx +++ b/client/src/Pages/Settings/index.tsx @@ -20,7 +20,7 @@ import { useGet, usePatch, usePost, useLazyGet } from "@/Hooks/UseApi"; import { useToast } from "@/Hooks/UseToast"; import { useSettingsForm } from "@/Hooks/useSettingsForm"; import { useIsAdmin } from "@/Hooks/useIsAdmin.js"; -import type { SettingsFormData } from "@/Validation/settings"; +import type { SettingsFormData, SettingsFormInput } from "@/Validation/settings"; import { useState } from "react"; import { Controller } from "react-hook-form"; import { TextField, Button, FieldLabel, SliderWithLabel } from "@/Components/inputs"; @@ -89,7 +89,7 @@ export const SettingsPage = () => { // 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,20 +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) { - (acc as any)[typedKey] = value; - } 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/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; From 81f623b632e0a7c6774e7659f588f139539cc0b4 Mon Sep 17 00:00:00 2001 From: Alex Holliday Date: Thu, 12 Mar 2026 10:05:36 -0700 Subject: [PATCH 10/32] properly type updates --- .../settings/MongoSettingsRepository.ts | 22 ++++++++----------- 1 file changed, 9 insertions(+), 13 deletions(-) diff --git a/server/src/repositories/settings/MongoSettingsRepository.ts b/server/src/repositories/settings/MongoSettingsRepository.ts index e41abcd96..3117a94ea 100644 --- a/server/src/repositories/settings/MongoSettingsRepository.ts +++ b/server/src/repositories/settings/MongoSettingsRepository.ts @@ -1,4 +1,4 @@ -import mongoose from "mongoose"; +import mongoose, { type UpdateQuery } from "mongoose"; import { ISettingsRepository } from "@/repositories/settings/ISettingsRepository.js"; import type { Settings, SettingsUpdate } from "@/types/index.js"; import { AppSettingsModel, type AppSettingsDocument } from "@/db/models/index.js"; @@ -60,26 +60,22 @@ class MongoSettingsRepository implements ISettingsRepository { }; update = async (settings: SettingsUpdate) => { - const update: Record = { $set: {}, $unset: {} }; + 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 === null) { - // Unset undefined or empty string values - update.$unset[key] = ""; + $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, From 2ef184c58a5d885e5fd2c1e2dbdb4586fd3ba057 Mon Sep 17 00:00:00 2001 From: Alex Holliday Date: Thu, 12 Mar 2026 10:43:18 -0700 Subject: [PATCH 11/32] properly type snapshot --- server/src/db/models/Monitor.ts | 170 +++++++++++++++++++++++++++++++- 1 file changed, 168 insertions(+), 2 deletions(-) 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( From d6ceedc2dd94e1e9d74003bf7aa6bb617a4f565d Mon Sep 17 00:00:00 2001 From: mannilakash Date: Thu, 12 Mar 2026 14:05:31 -0400 Subject: [PATCH 12/32] fix: upstream changes for resolving invalid responseTime --- .../Components/common/charts/HeatmapResponseTimeTooltip.tsx | 2 +- server/src/utils/dataUtils.ts | 5 +++-- 2 files changed, 4 insertions(+), 3 deletions(-) diff --git a/client/src/Components/common/charts/HeatmapResponseTimeTooltip.tsx b/client/src/Components/common/charts/HeatmapResponseTimeTooltip.tsx index dc36ee5d9..8bc651c8c 100644 --- a/client/src/Components/common/charts/HeatmapResponseTimeTooltip.tsx +++ b/client/src/Components/common/charts/HeatmapResponseTimeTooltip.tsx @@ -58,7 +58,7 @@ export const HeatmapResponseTimeTooltip = ({ {t("common.labels.responseTime")}:{" "} - {check?.originalResponseTime?.toFixed() ?? "-"} ms + {check?.originalResponseTime?.toFixed() ?? "N/A"} ms Status:{" "} 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 }; }); } }; From 9646e7dbdcab7a10778d76762fffdcffd73706be Mon Sep 17 00:00:00 2001 From: Alex Holliday Date: Thu, 12 Mar 2026 11:12:46 -0700 Subject: [PATCH 13/32] type handleErrors --- server/eslint.config.js | 1 + server/src/db/models/MaintenanceWindow.ts | 2 +- server/src/middleware/handleErrors.ts | 15 +++++++++------ .../service/infrastructure/statusService.ts | 3 +-- server/src/utils/AppError.ts | 18 +++++++++--------- 5 files changed, 21 insertions(+), 18 deletions(-) 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/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/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/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/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; From 52a6b4c9faf3428043301f92d3af4bcf0d6193f6 Mon Sep 17 00:00:00 2001 From: Alex Holliday Date: Thu, 12 Mar 2026 11:17:39 -0700 Subject: [PATCH 14/32] overload sanitizeInput --- server/src/middleware/sanitization.ts | 17 ++++++++++++----- 1 file changed, 12 insertions(+), 5 deletions(-) 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(); }; From db9d5cf5e40fdba25fcdc502763c014f6d43c1c6 Mon Sep 17 00:00:00 2001 From: Alex Holliday Date: Thu, 12 Mar 2026 11:25:32 -0700 Subject: [PATCH 15/32] verifyJWT types --- server/src/middleware/verifyJWT.ts | 16 +++++++++++----- 1 file changed, 11 insertions(+), 5 deletions(-) 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" })); } }); }; From 3a9f9f0ce593d57f3f0f2a0dbbcfee23490f1b4f Mon Sep 17 00:00:00 2001 From: mannilakash Date: Mon, 16 Mar 2026 17:14:28 -0400 Subject: [PATCH 16/32] feat: status-page infrastructure section BE --- .../0005_migrateStatusPageTypeToArray.ts | 45 +++++++++++++++++++ server/src/db/migration/index.ts | 2 + server/src/db/models/StatusPage.ts | 8 +++- .../MongoStatusPagesRepository.ts | 1 + server/src/types/statusPage.ts | 5 ++- server/src/validation/statusPageValidation.ts | 4 +- 6 files changed, 59 insertions(+), 6 deletions(-) create mode 100644 server/src/db/migration/0005_migrateStatusPageTypeToArray.ts 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/StatusPage.ts b/server/src/db/models/StatusPage.ts index 56c5ee299..6c1ada40e 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: [], 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/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/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/validation/statusPageValidation.ts b/server/src/validation/statusPageValidation.ts index b4b5e270c..c4d5005f3 100644 --- a/server/src/validation/statusPageValidation.ts +++ b/server/src/validation/statusPageValidation.ts @@ -10,13 +10,13 @@ export const getStatusPageParamValidation = z.object({ }); export const getStatusPageQueryValidation = z.object({ - type: z.literal("uptime"), + type: z.array(z.enum(["uptime", "infrastructure"])), timeFrame: z.coerce.number().optional(), }); export const createStatusPageBodyValidation = z .object({ - type: z.literal("uptime"), + type: z.array(z.enum(["uptime", "infrastructure"])), 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", From 894b6a7a7d86636e725ac5c89c0bc6f3ed5999a7 Mon Sep 17 00:00:00 2001 From: mannilakash Date: Mon, 16 Mar 2026 17:46:13 -0400 Subject: [PATCH 17/32] feat: status-page infra section FE --- client/src/Hooks/useStatusPageForm.ts | 2 + client/src/Pages/CreateMonitor/index.tsx | 14 +- client/src/Pages/StatusPage/Create/index.tsx | 56 ++++- .../Components/InfrastructureMetrics.tsx | 200 ++++++++++++++++++ .../Status/Components/MonitorsList.tsx | 160 +++++++++++--- client/src/Pages/StatusPage/Status/index.tsx | 5 +- .../Components/StatusPagesTable.tsx | 2 +- client/src/Types/StatusPage.ts | 5 +- client/src/Validation/statusPage.ts | 4 + client/src/Validation/validation.js | 9 +- client/src/locales/en.json | 41 +++- 11 files changed, 438 insertions(+), 60 deletions(-) create mode 100644 client/src/Pages/StatusPage/Status/Components/InfrastructureMetrics.tsx 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 = () => { > { // 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" + "/monitors/team?type=http&type=ping&type=port&type=docker&type=hardware" ); const monitors = monitorsResponse ?? []; @@ -81,6 +83,27 @@ const CreateStatusPage = () => { reset(defaults); }, [defaults, reset]); + const watchedMonitorIds: string[] = form.watch("monitors") ?? []; + useEffect(() => { + const selectedMonitors = (watchedMonitorIds ?? []) + .map((id) => monitors.find((m) => m.id === id)) + .filter((m): m is Monitor => m !== undefined); + + const typesSet: Set = new Set(); + selectedMonitors.forEach((m) => { + if (m.type === "hardware") { + typesSet.add("infrastructure"); + } else { + typesSet.add("uptime"); + } + }); + + const computedTypes: MonitorDisplayType[] = typesSet.size + ? Array.from(typesSet) + : ["uptime"]; + form.setValue("type", computedTypes); + }, [watchedMonitorIds, monitors, form]); + const onError = (errors: any) => { logger.debug("Status page validation errors", errors); }; @@ -103,7 +126,6 @@ const CreateStatusPage = () => { const onSubmit = async (data: StatusPageFormData) => { const fd = new FormData(); - fd.append("type", "uptime"); fd.append("isPublished", String(data.isPublished)); if (data.companyName) fd.append("companyName", data.companyName); if (data.url) fd.append("url", data.url); @@ -112,11 +134,16 @@ const CreateStatusPage = () => { fd.append("showCharts", String(data.showCharts)); fd.append("showUptimePercentage", String(data.showUptimePercentage)); fd.append("showAdminLoginLink", String(data.showAdminLoginLink)); + fd.append("showInfrastructure", String(data.showInfrastructure)); data.monitors.forEach((monitorId) => { fd.append("monitors[]", monitorId); }); + data.type.forEach((type) => { + fd.append("type[]", type); + }); + // Handle logo upload if (data.logo === null) { // Signal to remove the logo @@ -303,7 +330,9 @@ const CreateStatusPage = () => { }} > - {monitor.name} + {`${monitor.name} (${t(`pages.common.monitors.monitorTypes.option${monitor?.type.charAt(0).toUpperCase() + monitor?.type.slice(1)}`)})`} { @@ -425,6 +454,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?: MetricDetailRowProps[]; +} + +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) => ( + + ))} + + )} + + ); +}; + +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 = [ + { + key: t("pages.statusPages.monitorsList.infrastructure.cpu"), + labelKey: t("pages.statusPages.monitorsList.infrastructure.cpu"), + hasData: latestCheck.cpu && typeof latestCheck.cpu.usage_percent === "number", + progress: latestCheck.cpu?.usage_percent ? latestCheck.cpu.usage_percent * 100 : 0, + details: [ + { + label: t("pages.statusPages.monitorsList.infrastructure.usage"), + value: `${((latestCheck.cpu?.usage_percent || 0) * 100).toFixed(2)}%`, + }, + ], + }, + { + key: t("pages.statusPages.monitorsList.infrastructure.memoryText"), + labelKey: t("pages.statusPages.monitorsList.infrastructure.memory"), + hasData: + latestCheck.memory && + typeof latestCheck.memory.usage_percent === "number" && + typeof latestCheck.memory.used_bytes === "number" && + typeof latestCheck.memory.total_bytes === "number", + progress: latestCheck.memory?.usage_percent + ? latestCheck.memory.usage_percent * 100 + : 0, + details: [ + { + label: t("pages.statusPages.monitorsList.infrastructure.used"), + value: prettyBytes(latestCheck.memory?.used_bytes || 0), + }, + { + label: t("pages.statusPages.monitorsList.infrastructure.total"), + value: prettyBytes(latestCheck.memory?.total_bytes || 0), + }, + ], + }, + { + key: t("pages.statusPages.monitorsList.infrastructure.disk"), + labelKey: t("pages.statusPages.monitorsList.infrastructure.disk"), + hasData: latestCheck.disk && latestCheck.disk.length > 0, + progress: latestCheck.disk + ? (latestCheck.disk.reduce((acc, disk) => acc + (disk?.usage_percent || 0), 0) / + latestCheck.disk.length) * + 100 + : 0, + details: [ + { + label: t("pages.statusPages.monitorsList.infrastructure.used"), + value: prettyBytes( + latestCheck?.disk?.reduce((acc, disk) => acc + (disk?.used_bytes || 0), 0) ?? + 0 + ), + }, + { + label: t("pages.statusPages.monitorsList.infrastructure.total"), + value: prettyBytes( + latestCheck?.disk?.reduce((acc, disk) => acc + (disk?.total_bytes || 0), 0) ?? + 0 + ), + }, + ], + }, + ]; + + return ( + + {metrics.map( + ({ key, labelKey, hasData, progress, details }) => + hasData && ( + + ) + )} + + ); +}; diff --git a/client/src/Pages/StatusPage/Status/Components/MonitorsList.tsx b/client/src/Pages/StatusPage/Status/Components/MonitorsList.tsx index b94b20a80..c9212f0c4 100644 --- a/client/src/Pages/StatusPage/Status/Components/MonitorsList.tsx +++ b/client/src/Pages/StatusPage/Status/Components/MonitorsList.tsx @@ -5,13 +5,15 @@ 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 type { RootState } from "@/Types/state"; +import { LAYOUT, SPACING } from "@/Utils/Theme/constants"; interface StatusPageMonitor extends Monitor { checks?: Monitor["recentChecks"]; @@ -22,6 +24,87 @@ interface MonitorsListProps { monitors: StatusPageMonitor[]; } +const getMonitorBadgeStyles = (monitorType: string, theme: Theme) => { + // for future: specific badge styles can be defined here based on monitor type + const badgeConfig: Record = { + hardware: { + color: theme.palette.background.paper, + bg: theme.palette.info.light, + }, + http: { + color: theme.palette.background.paper, + bg: theme.palette.success.light, + }, + ping: { + color: theme.palette.background.paper, + bg: theme.palette.success.light, + }, + port: { + color: theme.palette.background.paper, + bg: theme.palette.success.light, + }, + docker: { + color: theme.palette.background.paper, + bg: theme.palette.success.light, + }, + pagespeed: { + color: theme.palette.background.paper, + bg: theme.palette.success.light, + }, + game: { + color: theme.palette.background.paper, + bg: theme.palette.success.light, + }, + }; + + const config = badgeConfig[monitorType] ?? badgeConfig.http; + return { + backgroundColor: config.bg, + color: config.color, + padding: `${theme.spacing(SPACING.SM)} ${theme.spacing(SPACING.LG)}`, + borderRadius: theme.shape.borderRadius, + }; +}; + +// Extracted component for monitor content below the header row +const MonitorContent = ({ + monitor, + statusPage, + chartType, +}: { + monitor: Monitor & { checks?: Monitor["recentChecks"] }; + statusPage: StatusPage; + chartType: string; +}) => { + const theme = useTheme(); + const isInfrastructureMonitor = monitor?.type === "hardware"; + + if (isInfrastructureMonitor) { + if (statusPage.showInfrastructure === false) { + return null; + } + return ; + } + + if (statusPage.showCharts === false) { + return null; + } + + return ( + + {chartType === "histogram" ? ( + + ) : ( + + )} + + ); +}; + export const MonitorsList = ({ statusPage, monitors }: MonitorsListProps) => { const theme = useTheme(); const { t } = useTranslation(); @@ -31,11 +114,12 @@ export const MonitorsList = ({ statusPage, monitors }: MonitorsListProps) => { const [chartType, setChartType] = useState<"histogram" | "heatmap">("histogram"); return ( - - {statusPage.showCharts !== false && ( + + {statusPage.showCharts && ( {t("pages.statusPages.monitorsList.chartTypeHeatmap")} { return ( - - {monitor.name} - - {showURL && ( + + {monitor.name} + + + {t( + `pages.common.monitors.monitorTypes.option${monitor?.type.charAt(0).toUpperCase() + monitor?.type.slice(1)}` + )} + + + {showURL && monitor?.url && ( {monitor.url} )} - + - {statusPage.showCharts !== false && ( - - {chartType === "histogram" ? ( - - ) : ( - - )} - - )} + ); })} diff --git a/client/src/Pages/StatusPage/Status/index.tsx b/client/src/Pages/StatusPage/Status/index.tsx index 0ca629581..77aa0892a 100644 --- a/client/src/Pages/StatusPage/Status/index.tsx +++ b/client/src/Pages/StatusPage/Status/index.tsx @@ -23,16 +23,17 @@ const StatusPageView = () => { const isSmall = useMediaQuery(theme.breakpoints.down("md")); const isPublic = location.pathname.startsWith("/status/public"); - const apiUrl = url ? `/status-page/${url}?type=uptime` : null; + const apiUrl = url ? `/status-page/${url}?type=uptime&type=infrastructure` : null; const { data, isLoading, error } = useGet(apiUrl); const statusPage = data?.statusPage; const monitors = data?.monitors ?? []; + const infrastructureMonitors = data?.infrastructureMonitors ?? []; if (!statusPage) return null; - if (monitors?.length === 0) { + if (monitors?.length === 0 && infrastructureMonitors?.length === 0) { return ( 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..370cc23b5 100644 --- a/client/src/Types/StatusPage.ts +++ b/client/src/Types/StatusPage.ts @@ -1,10 +1,11 @@ import type { Monitor } from "@/Types/Monitor"; +export type MonitorDisplayType = "uptime" | "infrastructure"; export interface StatusPage { id: string; userId: string; teamId: string; - type: string; + type: MonitorDisplayType[]; companyName: string; url: string; timezone?: string; @@ -20,6 +21,7 @@ export interface StatusPage { showCharts: boolean; showUptimePercentage: boolean; showAdminLoginLink: boolean; + showInfrastructure: boolean; customCSS: string; createdAt: string; updatedAt: string; @@ -28,4 +30,5 @@ export interface StatusPage { export interface StatusPageResponse { statusPage: StatusPage; monitors: Monitor[]; + infrastructureMonitors: Monitor[]; } 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..dbe037447 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,21 +545,13 @@ }, "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" + "optionPortDescription": "Monitor if a specific port on a server is open." }, "thresholds": { "title": "Alert thresholds", @@ -1178,7 +1181,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 +1288,9 @@ }, "showAdminLoginLink": { "label": "Show admin login link" + }, + "showInfrastructure": { + "label": "Show infrastructure metrics" } } } From d3d00353a144a94c0a1853ad770b5821cfb4a380 Mon Sep 17 00:00:00 2001 From: mannilakash Date: Tue, 17 Mar 2026 06:04:42 -0400 Subject: [PATCH 18/32] fix: minor refactoring infrastructureMetrics --- client/src/Pages/StatusPage/Create/index.tsx | 39 ++-- .../Components/InfrastructureMetrics.tsx | 208 +++++++++++------- 2 files changed, 153 insertions(+), 94 deletions(-) diff --git a/client/src/Pages/StatusPage/Create/index.tsx b/client/src/Pages/StatusPage/Create/index.tsx index 3b64738e5..1574c21c1 100644 --- a/client/src/Pages/StatusPage/Create/index.tsx +++ b/client/src/Pages/StatusPage/Create/index.tsx @@ -21,7 +21,7 @@ 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"; @@ -39,6 +39,22 @@ interface TimezoneOption { name: string; } +const MONITOR_TYPE_KEYS: Record = { + http: "pages.common.monitors.monitorTypes.optionHttp", + ping: "pages.common.monitors.monitorTypes.optionPing", + docker: "pages.common.monitors.monitorTypes.optionDocker", + port: "pages.common.monitors.monitorTypes.optionPort", + game: "pages.common.monitors.monitorTypes.optionGame", + grpc: "pages.common.monitors.monitorTypes.optionGrpc", + websocket: "pages.common.monitors.monitorTypes.optionWebSocket", + hardware: "pages.common.monitors.monitorTypes.optionHardware", +}; + +const getMonitorTypeLabel = (type: string, t: (key: string) => string): string => { + const i18nMonitorKey = MONITOR_TYPE_KEYS[type]; + return i18nMonitorKey ? t(i18nMonitorKey) : type; +}; + const CreateStatusPage = () => { const theme = useTheme(); const { t } = useTranslation(); @@ -84,25 +100,22 @@ const CreateStatusPage = () => { }, [defaults, reset]); const watchedMonitorIds: string[] = form.watch("monitors") ?? []; - useEffect(() => { + const computedTypes: MonitorDisplayType[] = useMemo(() => { const selectedMonitors = (watchedMonitorIds ?? []) .map((id) => monitors.find((m) => m.id === id)) .filter((m): m is Monitor => m !== undefined); - const typesSet: Set = new Set(); + const typesSet = new Set(); selectedMonitors.forEach((m) => { - if (m.type === "hardware") { - typesSet.add("infrastructure"); - } else { - typesSet.add("uptime"); - } + typesSet.add(m.type === "hardware" ? "infrastructure" : "uptime"); }); - const computedTypes: MonitorDisplayType[] = typesSet.size - ? Array.from(typesSet) - : ["uptime"]; + return typesSet.size ? Array.from(typesSet) : ["uptime"]; + }, [JSON.stringify(watchedMonitorIds), monitors]); + + useEffect(() => { form.setValue("type", computedTypes); - }, [watchedMonitorIds, monitors, form]); + }, [computedTypes]); const onError = (errors: any) => { logger.debug("Status page validation errors", errors); @@ -332,7 +345,7 @@ const CreateStatusPage = () => { {`${monitor.name} (${t(`pages.common.monitors.monitorTypes.option${monitor?.type.charAt(0).toUpperCase() + monitor?.type.slice(1)}`)})`} + >{`${monitor.name} (${getMonitorTypeLabel(monitor.type, t)})`} { diff --git a/client/src/Pages/StatusPage/Status/Components/InfrastructureMetrics.tsx b/client/src/Pages/StatusPage/Status/Components/InfrastructureMetrics.tsx index 9be17105c..32a15d64b 100644 --- a/client/src/Pages/StatusPage/Status/Components/InfrastructureMetrics.tsx +++ b/client/src/Pages/StatusPage/Status/Components/InfrastructureMetrics.tsx @@ -10,19 +10,28 @@ import useMediaQuery from "@mui/material/useMediaQuery"; import Box from "@mui/material/Box"; import { LAYOUT, SPACING } from "@/Utils/Theme/constants"; +const GAUGE_RADIUS = 60; +const GAUGE_STROKE_WIDTH = 12; +const PERCENTAGE_MULTIPLIER = 100; + interface StatusPageMonitor extends Monitor { checks?: Monitor["recentChecks"]; - infrastructureCPU?: number; - infrastructureMemory?: number; - infrastructureDisk?: number; } -interface MetricDetailRowProps { +interface MetricDetail { label: string; value: string; } -const MetricDetailRow = ({ label, value }: MetricDetailRowProps) => { +interface MetricConfig { + key: string; + label: string; + hasData: boolean; + progress: number; + details: MetricDetail[]; +} + +const MetricDetailRow = ({ label, value }: MetricDetail) => { const theme = useTheme(); return ( { interface MetricItemProps { label: string; progress: number; - details?: MetricDetailRowProps[]; + details?: MetricDetail[]; } const MetricItem = ({ label, progress, details }: MetricItemProps) => { @@ -75,8 +84,8 @@ const MetricItem = ({ label, progress, details }: MetricItemProps) => { > {label} @@ -98,6 +107,92 @@ const MetricItem = ({ label, progress, details }: MetricItemProps) => { ); }; +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(); @@ -115,68 +210,22 @@ export const InfrastructureMetrics = ({ monitor }: { monitor: StatusPageMonitor ); } - const metrics = [ - { - key: t("pages.statusPages.monitorsList.infrastructure.cpu"), - labelKey: t("pages.statusPages.monitorsList.infrastructure.cpu"), - hasData: latestCheck.cpu && typeof latestCheck.cpu.usage_percent === "number", - progress: latestCheck.cpu?.usage_percent ? latestCheck.cpu.usage_percent * 100 : 0, - details: [ - { - label: t("pages.statusPages.monitorsList.infrastructure.usage"), - value: `${((latestCheck.cpu?.usage_percent || 0) * 100).toFixed(2)}%`, - }, - ], - }, - { - key: t("pages.statusPages.monitorsList.infrastructure.memoryText"), - labelKey: t("pages.statusPages.monitorsList.infrastructure.memory"), - hasData: - latestCheck.memory && - typeof latestCheck.memory.usage_percent === "number" && - typeof latestCheck.memory.used_bytes === "number" && - typeof latestCheck.memory.total_bytes === "number", - progress: latestCheck.memory?.usage_percent - ? latestCheck.memory.usage_percent * 100 - : 0, - details: [ - { - label: t("pages.statusPages.monitorsList.infrastructure.used"), - value: prettyBytes(latestCheck.memory?.used_bytes || 0), - }, - { - label: t("pages.statusPages.monitorsList.infrastructure.total"), - value: prettyBytes(latestCheck.memory?.total_bytes || 0), - }, - ], - }, - { - key: t("pages.statusPages.monitorsList.infrastructure.disk"), - labelKey: t("pages.statusPages.monitorsList.infrastructure.disk"), - hasData: latestCheck.disk && latestCheck.disk.length > 0, - progress: latestCheck.disk - ? (latestCheck.disk.reduce((acc, disk) => acc + (disk?.usage_percent || 0), 0) / - latestCheck.disk.length) * - 100 - : 0, - details: [ - { - label: t("pages.statusPages.monitorsList.infrastructure.used"), - value: prettyBytes( - latestCheck?.disk?.reduce((acc, disk) => acc + (disk?.used_bytes || 0), 0) ?? - 0 - ), - }, - { - label: t("pages.statusPages.monitorsList.infrastructure.total"), - value: prettyBytes( - latestCheck?.disk?.reduce((acc, disk) => acc + (disk?.total_bytes || 0), 0) ?? - 0 - ), - }, - ], - }, - ]; + 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, labelKey, hasData, progress, details }) => - hasData && ( - - ) - )} + {metrics.map(({ key, label, progress, details }) => ( + + ))} ); }; From 294a149fd10ee49706bd14b234bc580f9731e61b Mon Sep 17 00:00:00 2001 From: mannilakash Date: Tue, 17 Mar 2026 13:43:36 -0400 Subject: [PATCH 19/32] fix: minor changes to statusPageValidation and other fixes --- server/src/db/models/StatusPage.ts | 2 +- server/src/validation/statusPageValidation.ts | 9 +++++++-- 2 files changed, 8 insertions(+), 3 deletions(-) diff --git a/server/src/db/models/StatusPage.ts b/server/src/db/models/StatusPage.ts index 6c1ada40e..e9b5b59aa 100644 --- a/server/src/db/models/StatusPage.ts +++ b/server/src/db/models/StatusPage.ts @@ -45,7 +45,7 @@ const StatusPageSchema = new Schema( type: { type: [String], required: true, - default: [], + default: ["uptime"], enum: StatusPageTypes, }, companyName: { diff --git a/server/src/validation/statusPageValidation.ts b/server/src/validation/statusPageValidation.ts index c4d5005f3..eaa4075a9 100644 --- a/server/src/validation/statusPageValidation.ts +++ b/server/src/validation/statusPageValidation.ts @@ -10,13 +10,17 @@ export const getStatusPageParamValidation = z.object({ }); export const getStatusPageQueryValidation = z.object({ - type: z.array(z.enum(["uptime", "infrastructure"])), + type: z + .union([z.enum(["uptime", "infrastructure"]), z.array(z.enum(["uptime", "infrastructure"]))]) + .transform((val) => (Array.isArray(val) ? val : [val])), timeFrame: z.coerce.number().optional(), }); export const createStatusPageBodyValidation = z .object({ - type: z.array(z.enum(["uptime", "infrastructure"])), + type: z + .union([z.enum(["uptime", "infrastructure"]), z.array(z.enum(["uptime", "infrastructure"]))]) + .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 +34,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(); From 622446c308d4d41b449b12ae0b0924cd1d8e463c Mon Sep 17 00:00:00 2001 From: mannilakash Date: Tue, 17 Mar 2026 15:01:30 -0400 Subject: [PATCH 20/32] fix: refactoring monitorsList and minor fixes based on feedback --- client/src/Pages/StatusPage/Create/index.tsx | 19 +- .../Status/Components/MonitorsList.tsx | 201 ++++++++---------- client/src/Types/StatusPage.ts | 22 +- client/src/locales/en.json | 3 +- 4 files changed, 109 insertions(+), 136 deletions(-) diff --git a/client/src/Pages/StatusPage/Create/index.tsx b/client/src/Pages/StatusPage/Create/index.tsx index 1574c21c1..ea4452dae 100644 --- a/client/src/Pages/StatusPage/Create/index.tsx +++ b/client/src/Pages/StatusPage/Create/index.tsx @@ -29,6 +29,7 @@ import type { StatusPageFormData } from "@/Validation/statusPage"; import { useGet, usePost, usePut, useDelete } from "@/Hooks/UseApi"; import type { Monitor } from "@/Types/Monitor"; 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"; @@ -39,22 +40,6 @@ interface TimezoneOption { name: string; } -const MONITOR_TYPE_KEYS: Record = { - http: "pages.common.monitors.monitorTypes.optionHttp", - ping: "pages.common.monitors.monitorTypes.optionPing", - docker: "pages.common.monitors.monitorTypes.optionDocker", - port: "pages.common.monitors.monitorTypes.optionPort", - game: "pages.common.monitors.monitorTypes.optionGame", - grpc: "pages.common.monitors.monitorTypes.optionGrpc", - websocket: "pages.common.monitors.monitorTypes.optionWebSocket", - hardware: "pages.common.monitors.monitorTypes.optionHardware", -}; - -const getMonitorTypeLabel = (type: string, t: (key: string) => string): string => { - const i18nMonitorKey = MONITOR_TYPE_KEYS[type]; - return i18nMonitorKey ? t(i18nMonitorKey) : type; -}; - const CreateStatusPage = () => { const theme = useTheme(); const { t } = useTranslation(); @@ -111,7 +96,7 @@ const CreateStatusPage = () => { }); return typesSet.size ? Array.from(typesSet) : ["uptime"]; - }, [JSON.stringify(watchedMonitorIds), monitors]); + }, [watchedMonitorIds, monitors]); useEffect(() => { form.setValue("type", computedTypes); diff --git a/client/src/Pages/StatusPage/Status/Components/MonitorsList.tsx b/client/src/Pages/StatusPage/Status/Components/MonitorsList.tsx index c9212f0c4..248a69c9b 100644 --- a/client/src/Pages/StatusPage/Status/Components/MonitorsList.tsx +++ b/client/src/Pages/StatusPage/Status/Components/MonitorsList.tsx @@ -12,6 +12,7 @@ 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"; @@ -25,81 +26,98 @@ interface MonitorsListProps { } const getMonitorBadgeStyles = (monitorType: string, theme: Theme) => { - // for future: specific badge styles can be defined here based on monitor type - const badgeConfig: Record = { - hardware: { - color: theme.palette.background.paper, - bg: theme.palette.info.light, - }, - http: { - color: theme.palette.background.paper, - bg: theme.palette.success.light, - }, - ping: { - color: theme.palette.background.paper, - bg: theme.palette.success.light, - }, - port: { - color: theme.palette.background.paper, - bg: theme.palette.success.light, - }, - docker: { - color: theme.palette.background.paper, - bg: theme.palette.success.light, - }, - pagespeed: { - color: theme.palette.background.paper, - bg: theme.palette.success.light, - }, - game: { - color: theme.palette.background.paper, - bg: theme.palette.success.light, - }, - }; - - const config = badgeConfig[monitorType] ?? badgeConfig.http; + const bg = + monitorType === "hardware" ? theme.palette.info.light : theme.palette.success.light; return { - backgroundColor: config.bg, - color: config.color, + backgroundColor: bg, + color: theme.palette.background.paper, padding: `${theme.spacing(SPACING.SM)} ${theme.spacing(SPACING.LG)}`, borderRadius: theme.shape.borderRadius, }; }; -// Extracted component for monitor content below the header row +const MonitorHeader = ({ + monitor, + showURL, +}: { + monitor: StatusPageMonitor; + showURL: boolean; +}) => { + const theme = useTheme(); + const { t } = useTranslation(); + + return ( + + + + + {monitor.name} + + + {getMonitorTypeLabel(monitor.type, t)} + + + {showURL && monitor.url && ( + + {monitor.url} + + )} + + + + ); +}; + const MonitorContent = ({ monitor, statusPage, chartType, }: { - monitor: Monitor & { checks?: Monitor["recentChecks"] }; + monitor: StatusPageMonitor; statusPage: StatusPage; chartType: string; }) => { const theme = useTheme(); - const isInfrastructureMonitor = monitor?.type === "hardware"; - if (isInfrastructureMonitor) { - if (statusPage.showInfrastructure === false) { - return null; - } + if (monitor.type === "hardware") { + if (statusPage.showInfrastructure === false) return null; return ; } - if (statusPage.showCharts === false) { - return null; - } + if (statusPage.showCharts === false) return null; + const checks = monitor.checks?.slice().reverse() ?? []; return ( {chartType === "histogram" ? ( ) : ( - + )} ); @@ -108,17 +126,15 @@ const MonitorContent = ({ 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 && ( {t("pages.statusPages.monitorsList.chartTypeHeatmap")} @@ -126,9 +142,7 @@ export const MonitorsList = ({ statusPage, monitors }: MonitorsListProps) => { dualOption value={chartType} checked={chartType === "histogram"} - onChange={(e) => { - setChartType(e.target.checked ? "histogram" : "heatmap"); - }} + onChange={(e) => setChartType(e.target.checked ? "histogram" : "heatmap")} /> {t("pages.statusPages.monitorsList.chartTypeHistogram")} @@ -136,69 +150,22 @@ export const MonitorsList = ({ statusPage, monitors }: MonitorsListProps) => { )} - {monitors?.map((monitor) => { - return ( - - - - - - {monitor.name} - - - {t( - `pages.common.monitors.monitorTypes.option${monitor?.type.charAt(0).toUpperCase() + monitor?.type.slice(1)}` - )} - - - {showURL && monitor?.url && ( - - {monitor.url} - - )} - - - - - - ); - })} + {monitors.map((monitor) => ( + + + + + ))} ); }; diff --git a/client/src/Types/StatusPage.ts b/client/src/Types/StatusPage.ts index 370cc23b5..9b4ee313f 100644 --- a/client/src/Types/StatusPage.ts +++ b/client/src/Types/StatusPage.ts @@ -1,6 +1,26 @@ -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; diff --git a/client/src/locales/en.json b/client/src/locales/en.json index dbe037447..92a21939f 100644 --- a/client/src/locales/en.json +++ b/client/src/locales/en.json @@ -551,7 +551,8 @@ "optionWebSocketDescription": "Monitor WebSocket endpoints for connection health and response time.", "optionHttpDescription": "Use HTTP(S) to monitor your website or API endpoint.", "optionPingDescription": "Use ICMP Ping to monitor if a server is online.", - "optionPortDescription": "Monitor if a specific port on a server is open." + "optionPortDescription": "Monitor if a specific port on a server is open.", + "title": "Type" }, "thresholds": { "title": "Alert thresholds", From 115fcc67f6f4d2ef19875b5e33976b798faea25f Mon Sep 17 00:00:00 2001 From: mannilakash Date: Tue, 17 Mar 2026 15:14:44 -0400 Subject: [PATCH 21/32] fix: fetching game/grpc/websocket as well for statusPage --- client/src/Pages/StatusPage/Create/index.tsx | 12 +++++++++--- 1 file changed, 9 insertions(+), 3 deletions(-) diff --git a/client/src/Pages/StatusPage/Create/index.tsx b/client/src/Pages/StatusPage/Create/index.tsx index ea4452dae..f1ecc674c 100644 --- a/client/src/Pages/StatusPage/Create/index.tsx +++ b/client/src/Pages/StatusPage/Create/index.tsx @@ -54,9 +54,15 @@ const CreateStatusPage = () => { isCreate ? null : `/status-page/${url}?type=uptime&type=infrastructure` ); - const { data: monitorsResponse } = useGet( - "/monitors/team?type=http&type=ping&type=port&type=docker&type=hardware" - ); + const monitorsUrl = useMemo(() => { + const params = new URLSearchParams(); + ["http", "ping", "port", "docker", "game", "grpc", "websocket", "hardware"].forEach( + (type) => params.append("type", type) + ); + return `/monitors/team?${params.toString()}`; + }, []); + + const { data: monitorsResponse } = useGet(monitorsUrl); const monitors = monitorsResponse ?? []; const { post, loading: isSubmittingPost } = usePost(); From 5c23ebe31582e016e4cae5f5a82d53d1f878adeb Mon Sep 17 00:00:00 2001 From: mannilakash Date: Wed, 18 Mar 2026 15:36:11 -0400 Subject: [PATCH 22/32] fix: updating swagger - /PUT to /PATCH monitor/{monitorId} --- server/openapi.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/server/openapi.json b/server/openapi.json index 1a6bbbfbf..f4404871c 100644 --- a/server/openapi.json +++ b/server/openapi.json @@ -756,7 +756,7 @@ } ] }, - "put": { + "patch": { "tags": ["monitors"], "summary": "Update monitor", "description": "Update an existing monitor", From f749ed46a6ff8bd788d7808aedbeeffacf509853 Mon Sep 17 00:00:00 2001 From: mannilakash Date: Wed, 18 Mar 2026 16:11:36 -0400 Subject: [PATCH 23/32] fix: updating swagger - schema changes monitor/{monitorId} --- server/openapi.json | 130 ++++++++++++++++++++++++++++---------------- 1 file changed, 83 insertions(+), 47 deletions(-) diff --git a/server/openapi.json b/server/openapi.json index f4404871c..75bad0dd9 100644 --- a/server/openapi.json +++ b/server/openapi.json @@ -3661,21 +3661,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 +3694,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 } } }, From 22f79144d8ea0d433413cf4901d872d59f7b953d Mon Sep 17 00:00:00 2001 From: mannilakash Date: Wed, 18 Mar 2026 16:25:07 -0400 Subject: [PATCH 24/32] fix: hardcoded statusPage value fix --- server/src/validation/statusPageValidation.ts | 9 +++------ 1 file changed, 3 insertions(+), 6 deletions(-) diff --git a/server/src/validation/statusPageValidation.ts b/server/src/validation/statusPageValidation.ts index eaa4075a9..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,17 +11,13 @@ export const getStatusPageParamValidation = z.object({ }); export const getStatusPageQueryValidation = z.object({ - type: z - .union([z.enum(["uptime", "infrastructure"]), z.array(z.enum(["uptime", "infrastructure"]))]) - .transform((val) => (Array.isArray(val) ? val : [val])), + 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 - .union([z.enum(["uptime", "infrastructure"]), z.array(z.enum(["uptime", "infrastructure"]))]) - .transform((val) => (Array.isArray(val) ? val : [val])), + 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", From 5a12105c3dd82fb4683e136c7bc701815dffc386 Mon Sep 17 00:00:00 2001 From: mannilakash Date: Wed, 18 Mar 2026 17:02:17 -0400 Subject: [PATCH 25/32] fix: statusPageMonitor removal of infrastructureMonitors[] --- client/src/Pages/StatusPage/Create/index.tsx | 16 ++++++++-------- client/src/Pages/StatusPage/Status/index.tsx | 3 +-- client/src/Types/StatusPage.ts | 1 - 3 files changed, 9 insertions(+), 11 deletions(-) diff --git a/client/src/Pages/StatusPage/Create/index.tsx b/client/src/Pages/StatusPage/Create/index.tsx index f1ecc674c..f09d9e47d 100644 --- a/client/src/Pages/StatusPage/Create/index.tsx +++ b/client/src/Pages/StatusPage/Create/index.tsx @@ -35,6 +35,14 @@ 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; @@ -54,14 +62,6 @@ const CreateStatusPage = () => { isCreate ? null : `/status-page/${url}?type=uptime&type=infrastructure` ); - const monitorsUrl = useMemo(() => { - const params = new URLSearchParams(); - ["http", "ping", "port", "docker", "game", "grpc", "websocket", "hardware"].forEach( - (type) => params.append("type", type) - ); - return `/monitors/team?${params.toString()}`; - }, []); - const { data: monitorsResponse } = useGet(monitorsUrl); const monitors = monitorsResponse ?? []; diff --git a/client/src/Pages/StatusPage/Status/index.tsx b/client/src/Pages/StatusPage/Status/index.tsx index 77aa0892a..3bb14261f 100644 --- a/client/src/Pages/StatusPage/Status/index.tsx +++ b/client/src/Pages/StatusPage/Status/index.tsx @@ -29,11 +29,10 @@ const StatusPageView = () => { const statusPage = data?.statusPage; const monitors = data?.monitors ?? []; - const infrastructureMonitors = data?.infrastructureMonitors ?? []; if (!statusPage) return null; - if (monitors?.length === 0 && infrastructureMonitors?.length === 0) { + if (monitors.length === 0) { return ( Date: Wed, 18 Mar 2026 17:30:19 -0400 Subject: [PATCH 26/32] fix: updating swagger for createMonitor and /monitor/teams --- server/openapi.json | 179 ++++++++++++++++++++++---------------------- 1 file changed, 90 insertions(+), 89 deletions(-) diff --git a/server/openapi.json b/server/openapi.json index 1a6bbbfbf..6867518ba 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": { @@ -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 } } }, From 78566c0226f063de62dd8ce55fd36aa348414230 Mon Sep 17 00:00:00 2001 From: mannilakash Date: Thu, 19 Mar 2026 11:34:26 -0400 Subject: [PATCH 27/32] fix: modifying refreshInterval=10s for statusPage api --- client/src/Pages/StatusPage/Status/index.tsx | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/client/src/Pages/StatusPage/Status/index.tsx b/client/src/Pages/StatusPage/Status/index.tsx index 3bb14261f..78b7cba41 100644 --- a/client/src/Pages/StatusPage/Status/index.tsx +++ b/client/src/Pages/StatusPage/Status/index.tsx @@ -25,7 +25,13 @@ const StatusPageView = () => { 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 ?? []; From bfbb4c390ad6f27a590effad1309fc6d1b3f4b83 Mon Sep 17 00:00:00 2001 From: Alex Holliday Date: Fri, 20 Mar 2026 09:47:02 -0700 Subject: [PATCH 28/32] Add type for getGeoChecksByMonitorId --- server/src/service/business/monitorService.ts | 10 ++++++++-- server/src/types/monitor.ts | 6 +++++- 2 files changed, 13 insertions(+), 3 deletions(-) diff --git a/server/src/service/business/monitorService.ts b/server/src/service/business/monitorService.ts index 936044908..678e5de37 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"; @@ -37,7 +38,12 @@ export interface IMonitorService { getUptimeDetailsById(args: { teamId: string; monitorId: string; dateRange: string; normalize?: boolean }): 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; @@ -327,7 +333,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 }); 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; From bfd3eee94d5e7bd089ce7452ac932ff3293c95a7 Mon Sep 17 00:00:00 2001 From: Alex Holliday Date: Fri, 20 Mar 2026 09:58:49 -0700 Subject: [PATCH 29/32] monitorService types --- server/src/service/business/monitorService.ts | 84 +++++++++---------- 1 file changed, 39 insertions(+), 45 deletions(-) diff --git a/server/src/service/business/monitorService.ts b/server/src/service/business/monitorService.ts index 678e5de37..b6db59fca 100644 --- a/server/src/service/business/monitorService.ts +++ b/server/src/service/business/monitorService.ts @@ -22,6 +22,8 @@ import type { import demoMonitorsData from "@/utils/demoMonitors.json" with { type: "json" }; import { AppError } from "@/utils/AppError.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"; @@ -31,7 +33,7 @@ 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 @@ -79,16 +81,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: Monitor[] }): 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; @@ -109,9 +111,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; @@ -169,7 +171,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" }); @@ -179,7 +181,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, @@ -200,12 +202,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) { @@ -386,7 +386,6 @@ export class MonitorService implements IMonitorService { filter, field, order, - explain, }: { teamId: string; limit?: number; @@ -396,7 +395,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 }); @@ -443,47 +441,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, }); }); @@ -501,12 +503,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, }); } }) @@ -514,7 +516,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) { @@ -524,30 +526,22 @@ export class MonitorService implements IMonitorService { return monitors; }; - importMonitorsFromJSON = async ({ - teamId, - userId, - monitors, - }: { - teamId: string; - userId: string; - monitors: any[]; - }): Promise<{ imported: number; errors: string[] }> => { + importMonitorsFromJSON = async ({ teamId, monitors }: { teamId: string; monitors: Monitor[] }): 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 + const cleanData = { + ...monitor, + _id: monitor.id, + _createdAt: monitor.createdAt, + _updatedAt: monitor.updatedAt, + recentChecks: monitor.recentChecks, + }; cleanData.teamId = teamId; return cleanData; }); - const createdMonitors = await this.createMonitors(cleanedMonitors, userId, teamId); + const createdMonitors = await this.createMonitors(cleanedMonitors); if (!createdMonitors || createdMonitors.length === 0) { throw new AppError({ From d8ec8d777532f9711d79b10c1bf100ea79930550 Mon Sep 17 00:00:00 2001 From: Alex Holliday Date: Fri, 20 Mar 2026 10:09:29 -0700 Subject: [PATCH 30/32] fix validation --- server/src/service/business/monitorService.ts | 24 ++++++---- server/src/validation/monitorValidation.ts | 47 +++++++++---------- 2 files changed, 37 insertions(+), 34 deletions(-) diff --git a/server/src/service/business/monitorService.ts b/server/src/service/business/monitorService.ts index b6db59fca..8a08a7003 100644 --- a/server/src/service/business/monitorService.ts +++ b/server/src/service/business/monitorService.ts @@ -81,7 +81,7 @@ export interface IMonitorService { // other exportMonitorsToJSON(args: { teamId: string }): Promise; - importMonitorsFromJSON(args: { teamId: string; userId: string; monitors: Monitor[] }): Promise<{ imported: number; errors: string[] }>; + importMonitorsFromJSON(args: { teamId: string; userId: string; monitors: Partial[] }): Promise<{ imported: number; errors: string[] }>; } export class MonitorService implements IMonitorService { @@ -526,22 +526,26 @@ export class MonitorService implements IMonitorService { return monitors; }; - importMonitorsFromJSON = async ({ teamId, monitors }: { teamId: string; monitors: Monitor[] }): Promise<{ imported: number; errors: string[] }> => { + importMonitorsFromJSON = async ({ + teamId, + monitors, + }: { + teamId: string; + monitors: Partial[]; + }): Promise<{ imported: number; errors: string[] }> => { const errors: string[] = []; const cleanedMonitors = monitors.map((monitor) => { - const cleanData = { - ...monitor, - _id: monitor.id, - _createdAt: monitor.createdAt, - _updatedAt: monitor.updatedAt, - recentChecks: monitor.recentChecks, - }; + const cleanData = { ...monitor }; + delete cleanData.id; + delete cleanData.createdAt; + delete cleanData.updatedAt; + delete cleanData.recentChecks; cleanData.teamId = teamId; return cleanData; }); - const createdMonitors = await this.createMonitors(cleanedMonitors); + const createdMonitors = await this.createMonitors(cleanedMonitors as Monitor[]); if (!createdMonitors || createdMonitors.length === 0) { throw new AppError({ diff --git a/server/src/validation/monitorValidation.ts b/server/src/validation/monitorValidation.ts index 783f1a029..bfd5b9126 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(), }); From 210881a3758925e8948b5cf3a535bd73d274299b Mon Sep 17 00:00:00 2001 From: Alex Holliday Date: Fri, 20 Mar 2026 10:17:24 -0700 Subject: [PATCH 31/32] remove unused import --- server/src/controllers/monitorController.ts | 1 - 1 file changed, 1 deletion(-) diff --git a/server/src/controllers/monitorController.ts b/server/src/controllers/monitorController.ts index e66af1306..03cd09e20 100644 --- a/server/src/controllers/monitorController.ts +++ b/server/src/controllers/monitorController.ts @@ -17,7 +17,6 @@ import { } from "@/validation/monitorValidation.js"; import sslChecker from "ssl-checker"; import { fetchMonitorCertificate, requireTeamId, requireUserId } from "./controllerUtils.js"; -import { AppError } from "@/utils/AppError.js"; import { IMonitorService } from "@/service/index.js"; const SERVICE_NAME = "monitorController"; From f69752ccb483477c5d8933379856d10dd1674cf7 Mon Sep 17 00:00:00 2001 From: Alex Holliday Date: Fri, 20 Mar 2026 10:37:29 -0700 Subject: [PATCH 32/32] remove casts --- server/src/controllers/monitorController.ts | 4 --- server/src/service/business/monitorService.ts | 30 ++++++++++--------- server/src/validation/monitorValidation.ts | 2 ++ 3 files changed, 18 insertions(+), 18 deletions(-) diff --git a/server/src/controllers/monitorController.ts b/server/src/controllers/monitorController.ts index 03cd09e20..9a2b7b774 100644 --- a/server/src/controllers/monitorController.ts +++ b/server/src/controllers/monitorController.ts @@ -59,7 +59,6 @@ class MonitorController { const monitorId = validatedParams.monitorId; const dateRange = validatedQuery.dateRange; - const normalize = validatedQuery.normalize; const teamId = requireTeamId(req.user?.teamId); @@ -67,7 +66,6 @@ class MonitorController { teamId, monitorId, dateRange, - normalize, }); return res.status(200).json({ success: true, @@ -323,7 +321,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; @@ -342,7 +339,6 @@ class MonitorController { filter, field, order, - explain, }); return res.status(200).json({ diff --git a/server/src/service/business/monitorService.ts b/server/src/service/business/monitorService.ts index 8a08a7003..8d56fae20 100644 --- a/server/src/service/business/monitorService.ts +++ b/server/src/service/business/monitorService.ts @@ -21,6 +21,7 @@ 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"; @@ -37,7 +38,7 @@ export interface IMonitorService { 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: { @@ -66,7 +67,6 @@ export interface IMonitorService { filter?: string; field?: string; order?: "asc" | "desc"; - explain?: boolean; }): Promise; getAllGames(): GamesMap; getGroupsByTeamId(args: { teamId: string }): Promise; @@ -81,7 +81,7 @@ export interface IMonitorService { // other exportMonitorsToJSON(args: { teamId: string }): Promise; - importMonitorsFromJSON(args: { teamId: string; userId: string; monitors: Partial[] }): Promise<{ imported: number; errors: string[] }>; + importMonitorsFromJSON(args: { teamId: string; userId: string; monitors: ImportedMonitor[] }): Promise<{ imported: number; errors: string[] }>; } export class MonitorService implements IMonitorService { @@ -528,24 +528,26 @@ export class MonitorService implements IMonitorService { importMonitorsFromJSON = async ({ teamId, + userId, monitors, }: { teamId: string; - monitors: Partial[]; + userId: string; + monitors: ImportedMonitor[]; }): Promise<{ imported: number; errors: string[] }> => { const errors: string[] = []; - const cleanedMonitors = monitors.map((monitor) => { - const cleanData = { ...monitor }; - delete cleanData.id; - delete cleanData.createdAt; - delete cleanData.updatedAt; - delete cleanData.recentChecks; - cleanData.teamId = teamId; - return cleanData; - }); + const cleanedMonitors: Monitor[] = monitors.map((monitor) => ({ + ...monitor, + id: "", + teamId, + userId, + recentChecks: [], + createdAt: "", + updatedAt: "", + })); - const createdMonitors = await this.createMonitors(cleanedMonitors as Monitor[]); + const createdMonitors = await this.createMonitors(cleanedMonitors); if (!createdMonitors || createdMonitors.length === 0) { throw new AppError({ diff --git a/server/src/validation/monitorValidation.ts b/server/src/validation/monitorValidation.ts index bfd5b9126..df000ecef 100644 --- a/server/src/validation/monitorValidation.ts +++ b/server/src/validation/monitorValidation.ts @@ -168,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"), });