From 726e4de6ab8eec96363e725f14defec7ba15faf5 Mon Sep 17 00:00:00 2001 From: Anton Mrosek Date: Mon, 16 Feb 2026 12:31:44 +0100 Subject: [PATCH 001/245] Compose dist-arm: Removed mongodb port mapping --- docker/dist-arm/docker-compose.yaml | 2 -- 1 file changed, 2 deletions(-) diff --git a/docker/dist-arm/docker-compose.yaml b/docker/dist-arm/docker-compose.yaml index 2255de508..b9aba057c 100644 --- a/docker/dist-arm/docker-compose.yaml +++ b/docker/dist-arm/docker-compose.yaml @@ -18,8 +18,6 @@ services: image: mongo:6.0 restart: always command: ["mongod", "--quiet", "--bind_ip_all"] - ports: - - "27017:27017" volumes: - ./mongo/data:/data/db healthcheck: From 2bbaf7d1eb110cfb6f66175f19f0549212e4cf1f Mon Sep 17 00:00:00 2001 From: mannilakash Date: Tue, 7 Apr 2026 07:02:44 -0400 Subject: [PATCH 002/245] feat: adding mobile/desktop pagespeed strategy --- .../design-elements/MonitorStatus.tsx | 11 ++++- .../design-elements/StrategyBadge.tsx | 38 +++++++++++++++++ .../src/Components/design-elements/index.tsx | 1 + client/src/Hooks/useMonitorForm.ts | 2 + client/src/Pages/CreateMonitor/index.tsx | 41 +++++++++++++++++++ .../Components/PageSpeedMonitorsTable.tsx | 17 +++++++- client/src/Types/Monitor.ts | 5 +++ client/src/Validation/monitor.ts | 2 + client/src/locales/en.json | 9 ++++ server/openapi.json | 11 +++++ server/src/db/migration/timescaledb/index.ts | 2 + server/src/db/models/Monitor.ts | 7 +++- .../monitors/MongoMonitorsRepository.ts | 2 + .../monitors/TimescaleMonitorsRepository.ts | 11 +++-- .../network/PageSpeedProvider.ts | 7 ++-- server/src/types/monitor.ts | 5 +++ server/src/validation/monitorValidation.ts | 5 ++- 17 files changed, 164 insertions(+), 12 deletions(-) create mode 100644 client/src/Components/design-elements/StrategyBadge.tsx diff --git a/client/src/Components/design-elements/MonitorStatus.tsx b/client/src/Components/design-elements/MonitorStatus.tsx index 04c62de47..4520356a4 100644 --- a/client/src/Components/design-elements/MonitorStatus.tsx +++ b/client/src/Components/design-elements/MonitorStatus.tsx @@ -1,12 +1,13 @@ import type { Monitor } from "@/Types/Monitor"; import Stack from "@mui/material/Stack"; import Typography from "@mui/material/Typography"; -import { PulseDot, Dot } from "@/Components/design-elements"; +import { PulseDot, Dot, StrategyBadge } from "@/Components/design-elements"; import { getStatusColor, formatUrl } from "@/Utils/MonitorUtils"; import { useTheme } from "@mui/material/styles"; import prettyMilliseconds from "pretty-ms"; import { typographyLevels } from "@/Utils/Theme/Palette"; import useMediaQuery from "@mui/material/useMediaQuery"; +import { LAYOUT } from "@/Utils/Theme/constants"; export const MonitorStatus = ({ monitor }: { monitor: Monitor }) => { const theme = useTheme(); const isSmall = useMediaQuery(theme.breakpoints.down("md")); @@ -28,7 +29,7 @@ export const MonitorStatus = ({ monitor }: { monitor: Monitor }) => { { > {formatUrl(monitor?.url)} + {monitor.type === "pagespeed" && monitor.strategy && ( + <> + + + + )} {!isSmall && ( <> diff --git a/client/src/Components/design-elements/StrategyBadge.tsx b/client/src/Components/design-elements/StrategyBadge.tsx new file mode 100644 index 000000000..1414f8b6c --- /dev/null +++ b/client/src/Components/design-elements/StrategyBadge.tsx @@ -0,0 +1,38 @@ +import Typography from "@mui/material/Typography"; +import { useTheme } from "@mui/material/styles"; +import { useTranslation } from "react-i18next"; +import type { PageSpeedStrategy } from "@/Types/Monitor"; +import { SPACING } from "@/Utils/Theme/constants"; + +interface StrategyBadgeProps { + strategy: PageSpeedStrategy | undefined; + variant?: "caption" | "body2"; +} + +export const StrategyBadge = ({ + strategy, + variant = "body2", +}: StrategyBadgeProps) => { + const theme = useTheme(); + const { t } = useTranslation(); + + if (!strategy) { + return null; + } + + return ( + + {t(`pages.pageSpeed.strategy.${strategy}`)} + + ); +}; diff --git a/client/src/Components/design-elements/index.tsx b/client/src/Components/design-elements/index.tsx index ed6a885f6..45114116b 100644 --- a/client/src/Components/design-elements/index.tsx +++ b/client/src/Components/design-elements/index.tsx @@ -21,3 +21,4 @@ export * from "./SplitBox"; export * from "./TextLink"; export * from "./OfflineBanner"; export * from "./Avatar"; +export * from "./StrategyBadge"; diff --git a/client/src/Hooks/useMonitorForm.ts b/client/src/Hooks/useMonitorForm.ts index 963409fc8..ac13854b3 100644 --- a/client/src/Hooks/useMonitorForm.ts +++ b/client/src/Hooks/useMonitorForm.ts @@ -1,5 +1,6 @@ import { useMemo } from "react"; import { monitorSchema, type MonitorFormData } from "@/Validation/monitor"; +import { DefaultPageSpeedStrategy } from "@/Types/Monitor"; import type { Monitor, MonitorType } from "@/Types/Monitor"; interface UseMonitorFormOptions { @@ -88,6 +89,7 @@ export const useMonitorForm = ({ ...base, type: "pagespeed", url: data?.url || "", + strategy: data?.strategy ?? DefaultPageSpeedStrategy, }; break; case "hardware": diff --git a/client/src/Pages/CreateMonitor/index.tsx b/client/src/Pages/CreateMonitor/index.tsx index 15b76eab3..911e37189 100644 --- a/client/src/Pages/CreateMonitor/index.tsx +++ b/client/src/Pages/CreateMonitor/index.tsx @@ -37,6 +37,7 @@ import { type MonitorType, type GamesMap, supportsGeoCheck, + DefaultPageSpeedStrategy, } from "@/Types/Monitor"; import type { Notification } from "@/Types/Notification"; import type { MonitorFormData } from "@/Validation/monitor"; @@ -51,6 +52,7 @@ interface GeneralSettingsConfig { showSecret: boolean; showGrpcServiceName: boolean; showIgnoreTls: boolean; + showStrategy: boolean; } const getGeneralSettingsConfig = ( @@ -68,6 +70,7 @@ const getGeneralSettingsConfig = ( showSecret: false, showGrpcServiceName: false, showIgnoreTls: false, + showStrategy: false, }, ping: { urlLabel: t("pages.createMonitor.form.general.option.host.label"), @@ -79,6 +82,7 @@ const getGeneralSettingsConfig = ( showSecret: false, showGrpcServiceName: false, showIgnoreTls: false, + showStrategy: false, }, docker: { urlLabel: t("pages.createMonitor.form.general.option.container.label"), @@ -90,6 +94,7 @@ const getGeneralSettingsConfig = ( showSecret: false, showGrpcServiceName: false, showIgnoreTls: false, + showStrategy: false, }, port: { urlLabel: t("pages.createMonitor.form.general.option.url.label"), @@ -101,6 +106,7 @@ const getGeneralSettingsConfig = ( showSecret: false, showGrpcServiceName: false, showIgnoreTls: false, + showStrategy: false, }, game: { urlLabel: t("pages.createMonitor.form.general.option.url.label"), @@ -112,6 +118,7 @@ const getGeneralSettingsConfig = ( showSecret: false, showGrpcServiceName: false, showIgnoreTls: false, + showStrategy: false, }, grpc: { urlLabel: t("pages.createMonitor.form.general.option.host.label"), @@ -123,6 +130,7 @@ const getGeneralSettingsConfig = ( showSecret: false, showGrpcServiceName: true, showIgnoreTls: true, + showStrategy: false, }, pagespeed: { urlLabel: t("pages.createMonitor.form.general.option.url.label"), @@ -134,6 +142,7 @@ const getGeneralSettingsConfig = ( showSecret: false, showGrpcServiceName: false, showIgnoreTls: false, + showStrategy: true, }, hardware: { urlLabel: t("pages.createMonitor.form.general.option.url.label"), @@ -145,6 +154,7 @@ const getGeneralSettingsConfig = ( showSecret: true, showGrpcServiceName: false, showIgnoreTls: false, + showStrategy: false, }, websocket: { urlLabel: t("pages.createMonitor.form.general.option.wsUrl.label"), @@ -156,6 +166,7 @@ const getGeneralSettingsConfig = ( showSecret: false, showGrpcServiceName: false, showIgnoreTls: true, + showStrategy: false, }, }; return configs[type] || configs.http; @@ -382,6 +393,36 @@ const CreateMonitorPage = () => { /> )} + {/* Strategy field - only for pagespeed type */} + {generalSettingsConfig.showStrategy && ( + ( + + )} + /> + )} + {/* Port field - only for port and game types */} {generalSettingsConfig.showPort && ( ), render: (row) => { - return row.name; + return ( + + {row.name} + + + ); }, }, { diff --git a/client/src/Types/Monitor.ts b/client/src/Types/Monitor.ts index 053b517d1..dc9aabaf4 100644 --- a/client/src/Types/Monitor.ts +++ b/client/src/Types/Monitor.ts @@ -38,6 +38,10 @@ export type MonitorStatus = (typeof MonitorStatuses)[number]; export type MonitorMatchMethod = "equal" | "include" | "regex" | ""; +export const PageSpeedStrategies = ["desktop", "mobile"] as const; +export type PageSpeedStrategy = (typeof PageSpeedStrategies)[number]; +export const DefaultPageSpeedStrategy: PageSpeedStrategy = "desktop"; + export interface Monitor { id: string; userId: string; @@ -72,6 +76,7 @@ export interface Monitor { selectedDisks: string[]; gameId?: string; grpcServiceName?: string; + strategy?: PageSpeedStrategy; group: string | null; geoCheckEnabled?: boolean; geoCheckLocations?: GeoContinent[]; diff --git a/client/src/Validation/monitor.ts b/client/src/Validation/monitor.ts index 9acffe6fe..88df71101 100644 --- a/client/src/Validation/monitor.ts +++ b/client/src/Validation/monitor.ts @@ -1,5 +1,6 @@ import { z } from "zod"; import { GeoContinents } from "@/Types/GeoCheck"; +import { PageSpeedStrategies } from "@/Types/Monitor"; // URL schema with custom error message const urlSchema = z.url({ message: "Please enter a valid URL" }); @@ -89,6 +90,7 @@ const grpcSchema = baseSchema.extend({ const pagespeedSchema = baseSchema.extend({ type: z.literal("pagespeed"), url: urlSchema, + strategy: z.enum(PageSpeedStrategies), }); // Hardware/Infrastructure monitor schema diff --git a/client/src/locales/en.json b/client/src/locales/en.json index 83ca4fa33..013858adc 100644 --- a/client/src/locales/en.json +++ b/client/src/locales/en.json @@ -503,6 +503,11 @@ "wsUrl": { "label": "WebSocket URL", "placeholder": "wss://example.com/socket" + }, + "strategy": { + "label": "Strategy", + "desktop": "Desktop", + "mobile": "Mobile" } }, "title": "General settings", @@ -968,6 +973,10 @@ } }, "pageSpeed": { + "strategy": { + "desktop": "Desktop", + "mobile": "Mobile" + }, "charts": { "common": { "cls": "Cumulative Layout Shift (CLS)", diff --git a/server/openapi.json b/server/openapi.json index 35d109693..6a44c19f2 100644 --- a/server/openapi.json +++ b/server/openapi.json @@ -3631,6 +3631,12 @@ "type": "string", "default": "" }, + "strategy": { + "type": "string", + "enum": ["desktop", "mobile"], + "default": "desktop", + "description": "PageSpeed analysis strategy" + }, "selectedDisks": { "type": "array", "items": { @@ -3737,6 +3743,11 @@ "grpcServiceName": { "type": "string" }, + "strategy": { + "type": "string", + "enum": ["desktop", "mobile"], + "description": "PageSpeed analysis strategy" + }, "selectedDisks": { "type": "array", "items": { diff --git a/server/src/db/migration/timescaledb/index.ts b/server/src/db/migration/timescaledb/index.ts index 14084ebf3..9a76d093a 100644 --- a/server/src/db/migration/timescaledb/index.ts +++ b/server/src/db/migration/timescaledb/index.ts @@ -21,6 +21,7 @@ import { createStatusPages, dropStatusPages } from "./0018_create_status_pages.j import { createAppSettings, dropAppSettings } from "./0019_create_app_settings.js"; import { createContinuousAggregates, dropContinuousAggregates } from "./0020_create_continuous_aggregates.js"; import { createRetentionCompression, dropRetentionCompression } from "./0021_create_retention_compression.js"; +import { addStrategyToMonitors, dropStrategyFromMonitors } from "./0022_add_strategy_to_monitors.js"; const SERVICE_NAME = "TimescaleDB Migrations"; @@ -52,6 +53,7 @@ const migrations: MigrationEntry[] = [ { name: "0019_create_app_settings", up: createAppSettings, down: dropAppSettings }, { name: "0020_create_continuous_aggregates", up: createContinuousAggregates, down: dropContinuousAggregates }, { name: "0021_create_retention_compression", up: createRetentionCompression, down: dropRetentionCompression }, + { name: "0022_add_strategy_to_monitors", up: addStrategyToMonitors, down: dropStrategyFromMonitors }, ]; const ensureMigrationsTable = async (pool: Pool) => { diff --git a/server/src/db/models/Monitor.ts b/server/src/db/models/Monitor.ts index 036aeadad..a20c34c09 100644 --- a/server/src/db/models/Monitor.ts +++ b/server/src/db/models/Monitor.ts @@ -1,6 +1,6 @@ import { Schema, model, Types } from "mongoose"; import type { Monitor, MonitorMatchMethod, CheckSnapshot } from "@/types/monitor.js"; -import { MonitorTypes, MonitorStatuses } from "@/types/monitor.js"; +import { MonitorTypes, MonitorStatuses, PageSpeedStrategies, DefaultPageSpeedStrategy } from "@/types/monitor.js"; import type { CheckAudits, CheckCaptureInfo, @@ -330,6 +330,11 @@ const MonitorSchema = new Schema( type: String, default: "", }, + strategy: { + type: String, + enum: PageSpeedStrategies, + default: DefaultPageSpeedStrategy, + }, group: { type: String, trim: true, diff --git a/server/src/repositories/monitors/MongoMonitorsRepository.ts b/server/src/repositories/monitors/MongoMonitorsRepository.ts index b2d759448..aed881006 100644 --- a/server/src/repositories/monitors/MongoMonitorsRepository.ts +++ b/server/src/repositories/monitors/MongoMonitorsRepository.ts @@ -386,6 +386,7 @@ class MongoMonitorsRepository implements IMonitorsRepository { selectedDisks: doc.selectedDisks ?? [], gameId: doc.gameId ?? undefined, grpcServiceName: doc.grpcServiceName ?? undefined, + strategy: doc.strategy ?? undefined, group: doc.group ?? null, recentChecks: (doc.recentChecks ?? []).map((check: CheckSnapshotDocument) => this.toCheckSnapshot(check)), geoCheckEnabled: doc.geoCheckEnabled ?? false, @@ -445,6 +446,7 @@ class MongoMonitorsRepository implements IMonitorsRepository { selectedDisks: doc.selectedDisks ?? [], gameId: doc.gameId ?? undefined, grpcServiceName: doc.grpcServiceName ?? undefined, + strategy: doc.strategy ?? undefined, group: doc.group ?? null, recentChecks: (doc.recentChecks ?? []).map((check: CheckSnapshotDocument) => this.toCheckSnapshot(check)), geoCheckEnabled: doc.geoCheckEnabled ?? false, diff --git a/server/src/repositories/monitors/TimescaleMonitorsRepository.ts b/server/src/repositories/monitors/TimescaleMonitorsRepository.ts index a94d38504..af663680a 100644 --- a/server/src/repositories/monitors/TimescaleMonitorsRepository.ts +++ b/server/src/repositories/monitors/TimescaleMonitorsRepository.ts @@ -1,5 +1,6 @@ import type { Pool } from "pg"; import type { Monitor, MonitorsSummary, MonitorStatus, MonitorType, MonitorMatchMethod, GeoContinent } from "@/types/monitor.js"; +import { DefaultPageSpeedStrategy } from "@/types/monitor.js"; import type { IMonitorsRepository, TeamQueryConfig, SummaryConfig } from "./IMonitorsRepository.js"; import { AppError } from "@/utils/AppError.js"; @@ -36,6 +37,7 @@ interface MonitorRow { selected_disks: string[] | null; game_id: string | null; grpc_service_name: string | null; + strategy: string | null; monitor_group: string | null; geo_check_enabled: boolean; geo_check_locations: GeoContinent[] | null; @@ -49,7 +51,7 @@ const MONITOR_COLUMNS = `id, user_id, team_id, name, description, type, status, interval_ms, is_active, status_window, status_window_size, status_window_threshold, uptime_percentage, cpu_alert_threshold, cpu_alert_counter, memory_alert_threshold, memory_alert_counter, disk_alert_threshold, disk_alert_counter, temp_alert_threshold, temp_alert_counter, selected_disks, - game_id, grpc_service_name, monitor_group, geo_check_enabled, geo_check_locations, geo_check_interval_ms, + game_id, grpc_service_name, strategy, monitor_group, geo_check_enabled, geo_check_locations, geo_check_interval_ms, created_at, updated_at`; export class TimescaleMonitorsRepository implements IMonitorsRepository { @@ -62,8 +64,8 @@ export class TimescaleMonitorsRepository implements IMonitorsRepository { interval_ms, is_active, status_window, status_window_size, status_window_threshold, cpu_alert_threshold, cpu_alert_counter, memory_alert_threshold, memory_alert_counter, disk_alert_threshold, disk_alert_counter, temp_alert_threshold, temp_alert_counter, selected_disks, - game_id, grpc_service_name, monitor_group, geo_check_enabled, geo_check_locations, geo_check_interval_ms) - VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11,$12,$13,$14,$15,$16,$17,$18,$19,$20,$21,$22,$23,$24,$25,$26,$27,$28,$29,$30,$31,$32,$33,$34) + game_id, grpc_service_name, strategy, monitor_group, geo_check_enabled, geo_check_locations, geo_check_interval_ms) + VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11,$12,$13,$14,$15,$16,$17,$18,$19,$20,$21,$22,$23,$24,$25,$26,$27,$28,$29,$30,$31,$32,$33,$34,$35) RETURNING ${MONITOR_COLUMNS}`, [ userId, @@ -96,6 +98,7 @@ export class TimescaleMonitorsRepository implements IMonitorsRepository { monitor.selectedDisks ?? [], monitor.gameId ?? null, monitor.grpcServiceName ?? null, + monitor.strategy ?? DefaultPageSpeedStrategy, monitor.group ?? null, monitor.geoCheckEnabled ?? false, monitor.geoCheckLocations ?? [], @@ -594,6 +597,7 @@ export class TimescaleMonitorsRepository implements IMonitorsRepository { ["selectedDisks", "selected_disks"], ["gameId", "game_id"], ["grpcServiceName", "grpc_service_name"], + ["strategy", "strategy"], ["group", "monitor_group"], ["geoCheckEnabled", "geo_check_enabled"], ["geoCheckLocations", "geo_check_locations"], @@ -1033,6 +1037,7 @@ export class TimescaleMonitorsRepository implements IMonitorsRepository { selectedDisks: row.selected_disks ?? [], gameId: row.game_id ?? undefined, grpcServiceName: row.grpc_service_name ?? undefined, + strategy: (row.strategy as Monitor["strategy"]) ?? undefined, group: row.monitor_group, geoCheckEnabled: row.geo_check_enabled, geoCheckLocations: row.geo_check_locations ?? [], diff --git a/server/src/service/infrastructure/network/PageSpeedProvider.ts b/server/src/service/infrastructure/network/PageSpeedProvider.ts index 121fe9bf8..0d8560868 100644 --- a/server/src/service/infrastructure/network/PageSpeedProvider.ts +++ b/server/src/service/infrastructure/network/PageSpeedProvider.ts @@ -1,6 +1,6 @@ import { IStatusProvider } from "@/service/infrastructure/network/IStatusProvider.js"; import { MonitorStatusResponse, PageSpeedStatusPayload } from "@/types/network.js"; -import { Monitor, MonitorType } from "@/types/monitor.js"; +import { Monitor, MonitorType, DefaultPageSpeedStrategy } from "@/types/monitor.js"; import { HttpProvider } from "@/service/infrastructure/network/HttpProvider.js"; import { ISettingsService } from "@/service/system/settingsService.js"; import { ILogger } from "@/utils/logger.js"; @@ -19,14 +19,15 @@ export class PageSpeedProvider implements IStatusProvider> { - const { url } = monitor; + const { url, strategy } = monitor; try { if (!url) throw new Error("URL is required for PageSpeed monitor"); const dbSettings = await this.settingsService.getDBSettings(); const apiKey = dbSettings?.pagespeedApiKey; + const resolvedStrategy = strategy ?? DefaultPageSpeedStrategy; let pageSpeedUrl = `https://pagespeedonline.googleapis.com/pagespeedonline/v5/runPagespeed?url=${encodeURIComponent( url - )}&category=seo&category=accessibility&category=best-practices&category=performance`; + )}&strategy=${encodeURIComponent(resolvedStrategy)}&category=seo&category=accessibility&category=best-practices&category=performance`; if (apiKey) { pageSpeedUrl += `&key=${apiKey}`; diff --git a/server/src/types/monitor.ts b/server/src/types/monitor.ts index f29ce75d7..4e4309fe7 100644 --- a/server/src/types/monitor.ts +++ b/server/src/types/monitor.ts @@ -6,6 +6,10 @@ export type { GeoContinent } from "@/types/geoCheck.js"; export const MonitorTypes = ["http", "ping", "pagespeed", "hardware", "docker", "port", "game", "grpc", "websocket", "unknown"] as const; export type MonitorType = (typeof MonitorTypes)[number]; +export const PageSpeedStrategies = ["desktop", "mobile"] as const; +export type PageSpeedStrategy = (typeof PageSpeedStrategies)[number]; +export const DefaultPageSpeedStrategy: PageSpeedStrategy = "desktop"; + export const GeoCheckSupportedTypes: readonly MonitorType[] = ["http", "ping"] as const; export const supportsGeoCheck = (type: MonitorType): boolean => GeoCheckSupportedTypes.includes(type); @@ -49,6 +53,7 @@ export interface Monitor { selectedDisks: string[]; gameId?: string; grpcServiceName?: string; + strategy?: PageSpeedStrategy; group: string | null; geoCheckEnabled?: boolean; geoCheckLocations?: GeoContinent[]; diff --git a/server/src/validation/monitorValidation.ts b/server/src/validation/monitorValidation.ts index df000ecef..2685c03f2 100644 --- a/server/src/validation/monitorValidation.ts +++ b/server/src/validation/monitorValidation.ts @@ -1,7 +1,7 @@ import { z } from "zod"; import { booleanCoercion } from "./shared.js"; import { GeoContinents } from "@/types/geoCheck.js"; -import { MonitorMatchMethods, MonitorTypes } from "@/types/monitor.js"; +import { MonitorMatchMethods, MonitorTypes, PageSpeedStrategies, DefaultPageSpeedStrategy } from "@/types/monitor.js"; export const getMonitorByIdParamValidation = z.object({ monitorId: z.string().min(1, "Monitor ID is required"), @@ -73,6 +73,7 @@ export const createMonitorBodyValidation = z.object({ matchMethod: z.union([z.enum(MonitorMatchMethods), z.literal("")]).optional(), gameId: z.union([z.string(), z.literal("")]).optional(), grpcServiceName: z.union([z.string(), z.literal("")]).default(""), + strategy: z.enum(PageSpeedStrategies).default(DefaultPageSpeedStrategy), selectedDisks: z.array(z.string()).optional(), group: z.union([z.string().max(50).trim(), z.null(), z.literal("")]).optional(), geoCheckEnabled: z.boolean().optional(), @@ -102,6 +103,7 @@ export const editMonitorBodyValidation = z.object({ tempAlertThreshold: z.number().optional(), gameId: z.union([z.string(), z.literal("")]).optional(), grpcServiceName: z.union([z.string(), z.literal("")]).optional(), + strategy: z.enum(PageSpeedStrategies).optional(), selectedDisks: z.array(z.string()).optional(), group: z.union([z.string().max(50).trim(), z.null(), z.literal("")]).optional(), geoCheckEnabled: z.boolean().optional(), @@ -156,6 +158,7 @@ const importedMonitorSchema = z.object({ selectedDisks: z.array(z.string()).default([]), gameId: z.union([z.string(), z.literal("")]).optional(), grpcServiceName: z.union([z.string(), z.literal("")]).default(""), + strategy: z.enum(PageSpeedStrategies).default(DefaultPageSpeedStrategy), group: z.union([z.string().max(50).trim(), z.null()]).default(null), geoCheckEnabled: z.boolean().default(false), geoCheckLocations: z.array(z.enum(GeoContinents)).default([]), From f7514bc9389ce21e3e880450d4bba9560a2c9edc Mon Sep 17 00:00:00 2001 From: mannilakash Date: Tue, 7 Apr 2026 07:14:17 -0400 Subject: [PATCH 003/245] fix: format errors --- client/src/Components/design-elements/StrategyBadge.tsx | 5 +---- client/src/Pages/CreateMonitor/index.tsx | 8 ++------ .../Monitors/Components/PageSpeedMonitorsTable.tsx | 7 ++++++- 3 files changed, 9 insertions(+), 11 deletions(-) diff --git a/client/src/Components/design-elements/StrategyBadge.tsx b/client/src/Components/design-elements/StrategyBadge.tsx index 1414f8b6c..9ef3381c2 100644 --- a/client/src/Components/design-elements/StrategyBadge.tsx +++ b/client/src/Components/design-elements/StrategyBadge.tsx @@ -9,10 +9,7 @@ interface StrategyBadgeProps { variant?: "caption" | "body2"; } -export const StrategyBadge = ({ - strategy, - variant = "body2", -}: StrategyBadgeProps) => { +export const StrategyBadge = ({ strategy, variant = "body2" }: StrategyBadgeProps) => { const theme = useTheme(); const { t } = useTranslation(); diff --git a/client/src/Pages/CreateMonitor/index.tsx b/client/src/Pages/CreateMonitor/index.tsx index 911e37189..9c8efa002 100644 --- a/client/src/Pages/CreateMonitor/index.tsx +++ b/client/src/Pages/CreateMonitor/index.tsx @@ -409,14 +409,10 @@ const CreateMonitorPage = () => { error={!!fieldState.error} > - {t( - "pages.createMonitor.form.general.option.strategy.desktop" - )} + {t("pages.createMonitor.form.general.option.strategy.desktop")} - {t( - "pages.createMonitor.form.general.option.strategy.mobile" - )} + {t("pages.createMonitor.form.general.option.strategy.mobile")} )} diff --git a/client/src/Pages/PageSpeed/Monitors/Components/PageSpeedMonitorsTable.tsx b/client/src/Pages/PageSpeed/Monitors/Components/PageSpeedMonitorsTable.tsx index 0c097293e..9e9c6e89b 100644 --- a/client/src/Pages/PageSpeed/Monitors/Components/PageSpeedMonitorsTable.tsx +++ b/client/src/Pages/PageSpeed/Monitors/Components/PageSpeedMonitorsTable.tsx @@ -1,7 +1,12 @@ import Stack from "@mui/material/Stack"; import Box from "@mui/material/Box"; import Typography from "@mui/material/Typography"; -import { Table, Pagination, StatusLabel, StrategyBadge } from "@/Components/design-elements"; +import { + Table, + Pagination, + StatusLabel, + StrategyBadge, +} from "@/Components/design-elements"; import { LAYOUT } from "@/Utils/Theme/constants"; import { HistogramPageSpeed } from "@/Components/monitors"; import { ArrowDown, ArrowUp } from "lucide-react"; From 5791bdd5e7261fcd4be48774d1bae8b5bd530a12 Mon Sep 17 00:00:00 2001 From: mannilakash Date: Tue, 7 Apr 2026 07:25:35 -0400 Subject: [PATCH 004/245] fix: adding timescaledb changes --- .../timescaledb/0022_add_strategy_to_monitors.ts | 15 +++++++++++++++ 1 file changed, 15 insertions(+) create mode 100644 server/src/db/migration/timescaledb/0022_add_strategy_to_monitors.ts diff --git a/server/src/db/migration/timescaledb/0022_add_strategy_to_monitors.ts b/server/src/db/migration/timescaledb/0022_add_strategy_to_monitors.ts new file mode 100644 index 000000000..1adda12bc --- /dev/null +++ b/server/src/db/migration/timescaledb/0022_add_strategy_to_monitors.ts @@ -0,0 +1,15 @@ +import type { Pool } from "pg"; + +export const addStrategyToMonitors = async (pool: Pool) => { + await pool.query(` + ALTER TABLE monitors + ADD COLUMN IF NOT EXISTS strategy TEXT DEFAULT 'desktop'; + `); +}; + +export const dropStrategyFromMonitors = async (pool: Pool) => { + await pool.query(` + ALTER TABLE monitors + DROP COLUMN IF EXISTS strategy; + `); +}; From 99a5aa17221f705e045450ee1f1b1bc2a7568e6f Mon Sep 17 00:00:00 2001 From: mannilakash Date: Mon, 13 Apr 2026 08:29:12 -0400 Subject: [PATCH 005/245] fix: badge and timescaleDB changes --- .../src/Components/design-elements/StrategyBadge.tsx | 9 +++------ client/src/Pages/CreateMonitor/index.tsx | 1 - .../timescaledb/0022_add_strategy_to_monitors.ts | 2 +- server/src/db/models/Monitor.ts | 3 +-- .../monitors/TimescaleMonitorsRepository.ts | 10 +++++----- 5 files changed, 10 insertions(+), 15 deletions(-) diff --git a/client/src/Components/design-elements/StrategyBadge.tsx b/client/src/Components/design-elements/StrategyBadge.tsx index 9ef3381c2..cb06d17c1 100644 --- a/client/src/Components/design-elements/StrategyBadge.tsx +++ b/client/src/Components/design-elements/StrategyBadge.tsx @@ -22,12 +22,9 @@ export const StrategyBadge = ({ strategy, variant = "body2" }: StrategyBadgeProp variant={variant} px={SPACING.XXL} py={SPACING.SM} - textTransform="capitalize" - sx={{ - borderRadius: theme.shape.borderRadius, - backgroundColor: theme.palette.secondary.light, - color: theme.palette.text.primary, - }} + borderRadius={theme.shape.borderRadius} + bgcolor={theme.palette.secondary.light} + color={theme.palette.text.primary} > {t(`pages.pageSpeed.strategy.${strategy}`)} diff --git a/client/src/Pages/CreateMonitor/index.tsx b/client/src/Pages/CreateMonitor/index.tsx index 9c8efa002..744f63bf5 100644 --- a/client/src/Pages/CreateMonitor/index.tsx +++ b/client/src/Pages/CreateMonitor/index.tsx @@ -405,7 +405,6 @@ const CreateMonitorPage = () => { fieldLabel={t( "pages.createMonitor.form.general.option.strategy.label" )} - disabled={isEditMode} error={!!fieldState.error} > diff --git a/server/src/db/migration/timescaledb/0022_add_strategy_to_monitors.ts b/server/src/db/migration/timescaledb/0022_add_strategy_to_monitors.ts index 1adda12bc..06d48cc75 100644 --- a/server/src/db/migration/timescaledb/0022_add_strategy_to_monitors.ts +++ b/server/src/db/migration/timescaledb/0022_add_strategy_to_monitors.ts @@ -3,7 +3,7 @@ import type { Pool } from "pg"; export const addStrategyToMonitors = async (pool: Pool) => { await pool.query(` ALTER TABLE monitors - ADD COLUMN IF NOT EXISTS strategy TEXT DEFAULT 'desktop'; + ADD COLUMN IF NOT EXISTS strategy TEXT DEFAULT NULL; `); }; diff --git a/server/src/db/models/Monitor.ts b/server/src/db/models/Monitor.ts index a20c34c09..40866d191 100644 --- a/server/src/db/models/Monitor.ts +++ b/server/src/db/models/Monitor.ts @@ -1,6 +1,6 @@ import { Schema, model, Types } from "mongoose"; import type { Monitor, MonitorMatchMethod, CheckSnapshot } from "@/types/monitor.js"; -import { MonitorTypes, MonitorStatuses, PageSpeedStrategies, DefaultPageSpeedStrategy } from "@/types/monitor.js"; +import { MonitorTypes, MonitorStatuses, PageSpeedStrategies } from "@/types/monitor.js"; import type { CheckAudits, CheckCaptureInfo, @@ -333,7 +333,6 @@ const MonitorSchema = new Schema( strategy: { type: String, enum: PageSpeedStrategies, - default: DefaultPageSpeedStrategy, }, group: { type: String, diff --git a/server/src/repositories/monitors/TimescaleMonitorsRepository.ts b/server/src/repositories/monitors/TimescaleMonitorsRepository.ts index af663680a..60c382cf1 100644 --- a/server/src/repositories/monitors/TimescaleMonitorsRepository.ts +++ b/server/src/repositories/monitors/TimescaleMonitorsRepository.ts @@ -1,6 +1,6 @@ import type { Pool } from "pg"; -import type { Monitor, MonitorsSummary, MonitorStatus, MonitorType, MonitorMatchMethod, GeoContinent } from "@/types/monitor.js"; -import { DefaultPageSpeedStrategy } from "@/types/monitor.js"; +import type { Monitor, MonitorsSummary, MonitorStatus, MonitorType, MonitorMatchMethod, GeoContinent, PageSpeedStrategy } from "@/types/monitor.js"; + import type { IMonitorsRepository, TeamQueryConfig, SummaryConfig } from "./IMonitorsRepository.js"; import { AppError } from "@/utils/AppError.js"; @@ -37,7 +37,7 @@ interface MonitorRow { selected_disks: string[] | null; game_id: string | null; grpc_service_name: string | null; - strategy: string | null; + strategy: PageSpeedStrategy | null; monitor_group: string | null; geo_check_enabled: boolean; geo_check_locations: GeoContinent[] | null; @@ -98,7 +98,7 @@ export class TimescaleMonitorsRepository implements IMonitorsRepository { monitor.selectedDisks ?? [], monitor.gameId ?? null, monitor.grpcServiceName ?? null, - monitor.strategy ?? DefaultPageSpeedStrategy, + monitor.strategy ?? null, monitor.group ?? null, monitor.geoCheckEnabled ?? false, monitor.geoCheckLocations ?? [], @@ -1037,7 +1037,7 @@ export class TimescaleMonitorsRepository implements IMonitorsRepository { selectedDisks: row.selected_disks ?? [], gameId: row.game_id ?? undefined, grpcServiceName: row.grpc_service_name ?? undefined, - strategy: (row.strategy as Monitor["strategy"]) ?? undefined, + strategy: row.strategy ?? undefined, group: row.monitor_group, geoCheckEnabled: row.geo_check_enabled, geoCheckLocations: row.geo_check_locations ?? [], From ac51dfeab70ca52e502149ddb7bc027b11adfcd7 Mon Sep 17 00:00:00 2001 From: mannilakash Date: Mon, 13 Apr 2026 13:17:24 -0400 Subject: [PATCH 006/245] fix: removing default value for strategy - monitors --- server/src/validation/monitorValidation.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/server/src/validation/monitorValidation.ts b/server/src/validation/monitorValidation.ts index 2685c03f2..f229927d2 100644 --- a/server/src/validation/monitorValidation.ts +++ b/server/src/validation/monitorValidation.ts @@ -73,7 +73,7 @@ export const createMonitorBodyValidation = z.object({ matchMethod: z.union([z.enum(MonitorMatchMethods), z.literal("")]).optional(), gameId: z.union([z.string(), z.literal("")]).optional(), grpcServiceName: z.union([z.string(), z.literal("")]).default(""), - strategy: z.enum(PageSpeedStrategies).default(DefaultPageSpeedStrategy), + strategy: z.enum(PageSpeedStrategies).optional(), selectedDisks: z.array(z.string()).optional(), group: z.union([z.string().max(50).trim(), z.null(), z.literal("")]).optional(), geoCheckEnabled: z.boolean().optional(), From 1a54a24fd5623c09a4fb61c194d67928f6a5af49 Mon Sep 17 00:00:00 2001 From: ZeinebMegaadi Date: Sat, 18 Apr 2026 12:54:13 +0100 Subject: [PATCH 007/245] fix: skip future date validation when editing maintenance window --- client/src/Hooks/useMaintenanceWindowForm.ts | 3 +- client/src/Validation/maintenanceWindow.ts | 52 ++++++++++---------- 2 files changed, 29 insertions(+), 26 deletions(-) diff --git a/client/src/Hooks/useMaintenanceWindowForm.ts b/client/src/Hooks/useMaintenanceWindowForm.ts index 1f10d55ef..a6974a99e 100644 --- a/client/src/Hooks/useMaintenanceWindowForm.ts +++ b/client/src/Hooks/useMaintenanceWindowForm.ts @@ -2,6 +2,7 @@ import { useMemo } from "react"; import dayjs from "dayjs"; import { maintenanceWindowSchema, + maintenanceWindowEditSchema, repeatOptions, type MaintenanceWindowFormData, } from "@/Validation/maintenanceWindow"; @@ -47,6 +48,6 @@ export const useMaintenanceWindowForm = ({ }; } - return { schema: maintenanceWindowSchema, defaults }; + return { schema: data ? maintenanceWindowEditSchema : maintenanceWindowSchema, defaults }; }, [data]); }; diff --git a/client/src/Validation/maintenanceWindow.ts b/client/src/Validation/maintenanceWindow.ts index 61ccf3c8d..dbe447e87 100644 --- a/client/src/Validation/maintenanceWindow.ts +++ b/client/src/Validation/maintenanceWindow.ts @@ -14,30 +14,32 @@ export const durationUnitOptions = [ { id: "days", name: "days", multiplier: 86400000 }, ] as const; -export const maintenanceWindowSchema = z - .object({ - name: z - .string() - .min(1, "Name is required") - .max(100, "Name must be at most 100 characters"), - repeat: z.string(), - startDate: z.string().min(1, "Start date is required"), - startTime: z.string().min(1, "Start time is required"), - duration: z.number().int().min(1, "Duration must be at least 1"), - durationUnit: z.string(), - monitors: z.array(z.string()).min(1, "At least one monitor is required"), - }) - .refine( - (data) => { - const startDateTime = dayjs(data.startDate) - .set("hour", parseInt(data.startTime.split(":")[0], 10)) - .set("minute", parseInt(data.startTime.split(":")[1], 10)); - return startDateTime.isAfter(dayjs()); - }, - { - message: "Start date and time must be in the future", - path: ["startDate"], - } - ); +const maintenanceWindowBaseSchema = z.object({ + name: z + .string() + .min(1, "Name is required") + .max(100, "Name must be at most 100 characters"), + repeat: z.string(), + startDate: z.string().min(1, "Start date is required"), + startTime: z.string().min(1, "Start time is required"), + duration: z.number().int().min(1, "Duration must be at least 1"), + durationUnit: z.string(), + monitors: z.array(z.string()).min(1, "At least one monitor is required"), +}); + +export const maintenanceWindowSchema = maintenanceWindowBaseSchema.refine( + (data) => { + const startDateTime = dayjs(data.startDate) + .set("hour", parseInt(data.startTime.split(":")[0], 10)) + .set("minute", parseInt(data.startTime.split(":")[1], 10)); + return startDateTime.isAfter(dayjs()); + }, + { + message: "Start date and time must be in the future", + path: ["startDate"], + } +); + +export const maintenanceWindowEditSchema = maintenanceWindowBaseSchema; export type MaintenanceWindowFormData = z.infer; From d41d9a3dab1ed74e4122dcc3b820b67610abc3c0 Mon Sep 17 00:00:00 2001 From: ZeinebMegaadi Date: Sat, 18 Apr 2026 12:55:10 +0100 Subject: [PATCH 008/245] fix: skip future date validation when editing maintenance window --- client/src/Hooks/useMaintenanceWindowForm.ts | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/client/src/Hooks/useMaintenanceWindowForm.ts b/client/src/Hooks/useMaintenanceWindowForm.ts index a6974a99e..4e7d84a9a 100644 --- a/client/src/Hooks/useMaintenanceWindowForm.ts +++ b/client/src/Hooks/useMaintenanceWindowForm.ts @@ -48,6 +48,9 @@ export const useMaintenanceWindowForm = ({ }; } - return { schema: data ? maintenanceWindowEditSchema : maintenanceWindowSchema, defaults }; + return { + schema: data ? maintenanceWindowEditSchema : maintenanceWindowSchema, + defaults, + }; }, [data]); }; From 66418bf591741d9522d8edd2ec4fa49bada1d10c Mon Sep 17 00:00:00 2001 From: Aicha Lassoued Date: Sun, 26 Apr 2026 18:25:53 +0100 Subject: [PATCH 009/245] fix: prevent blank screen crash when clearing timezone search field --- client/src/Features/UI/uiSlice.ts | 4 +++- client/src/Pages/Settings/index.tsx | 4 ++-- client/src/Utils/TimeUtils.ts | 7 +++++-- 3 files changed, 10 insertions(+), 5 deletions(-) diff --git a/client/src/Features/UI/uiSlice.ts b/client/src/Features/UI/uiSlice.ts index 84e8248db..be54eead8 100644 --- a/client/src/Features/UI/uiSlice.ts +++ b/client/src/Features/UI/uiSlice.ts @@ -89,7 +89,9 @@ const uiSlice = createSlice({ }, setTimezone: (state, action: PayloadAction<{ timezone: string }>) => { - state.timezone = action.payload.timezone; + if (action.payload.timezone) { + state.timezone = action.payload.timezone; + } }, setLanguage: (state, action: PayloadAction) => { state.language = action.payload; diff --git a/client/src/Pages/Settings/index.tsx b/client/src/Pages/Settings/index.tsx index 302ce9ab2..f9cf09b7c 100644 --- a/client/src/Pages/Settings/index.tsx +++ b/client/src/Pages/Settings/index.tsx @@ -127,8 +127,8 @@ export const SettingsPage = () => { timezoneOptions.find((tz) => tz.id === selectedTimezoneId) ?? null; const handleTimezoneChange = (newValue: Timezone | null) => { - const newId = newValue?.id ?? ""; - dispatch(setTimezone({ timezone: newId })); + if (!newValue?.id) return; + dispatch(setTimezone({ timezone: newValue.id })); }; const handleModeChange = (e: SelectChangeEvent) => { diff --git a/client/src/Utils/TimeUtils.ts b/client/src/Utils/TimeUtils.ts index abd56ce44..cffdd5440 100644 --- a/client/src/Utils/TimeUtils.ts +++ b/client/src/Utils/TimeUtils.ts @@ -19,8 +19,11 @@ export const formatDateWithTz = (timestamp: string, format: string, timezone: st if (!timestamp) { return "Unknown time"; } - const formattedDate = dayjs(timestamp).tz(timezone).format(format); - return formattedDate; + try { + return dayjs(timestamp).tz(timezone).format(format); + } catch { + return dayjs(timestamp).utc().format(format); + } }; export const tickDateFormatLookup = (range: string) => { From 5a72a11ac2af82e867045edd2784764f51e8863f Mon Sep 17 00:00:00 2001 From: Aicha Lassoued Date: Sun, 26 Apr 2026 18:40:04 +0100 Subject: [PATCH 010/245] (client): format code change --- client/src/Pages/Settings/index.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/client/src/Pages/Settings/index.tsx b/client/src/Pages/Settings/index.tsx index f9cf09b7c..b14ee959d 100644 --- a/client/src/Pages/Settings/index.tsx +++ b/client/src/Pages/Settings/index.tsx @@ -127,7 +127,7 @@ export const SettingsPage = () => { timezoneOptions.find((tz) => tz.id === selectedTimezoneId) ?? null; const handleTimezoneChange = (newValue: Timezone | null) => { - if (!newValue?.id) return; + if (!newValue?.id) return; dispatch(setTimezone({ timezone: newValue.id })); }; From 49fcab2ee3c131b273134812d7697083536d6c7a Mon Sep 17 00:00:00 2001 From: Iriome Date: Mon, 27 Apr 2026 14:40:51 +0100 Subject: [PATCH 011/245] fix: allow containers to run as non-root user (UID 1000) - chown public/, dist/, and scripts/ to node:node after build - add USER node directive before EXPOSE in dist-mono and dist-arm Dockerfiles - update CMD to exec form in dist-mono for consistency Fixes running the container with --user 1000 in environments that don't allow containers to run as root. The sed -i in inject-vars.sh requires write permission on ./public/assets/, which previously failed with 'Permission denied' when running as non-root. The node user (UID 1000) is built into the node:20-slim base image. Existing root deployments are unaffected. --- docker/dist-arm/server.Dockerfile | 6 ++++++ docker/dist-mono/server.Dockerfile | 9 +++++++-- 2 files changed, 13 insertions(+), 2 deletions(-) diff --git a/docker/dist-arm/server.Dockerfile b/docker/dist-arm/server.Dockerfile index 208574fe4..ad6c286fb 100644 --- a/docker/dist-arm/server.Dockerfile +++ b/docker/dist-arm/server.Dockerfile @@ -40,6 +40,12 @@ RUN npm run build COPY --from=frontend-build /app/client/dist ./public +RUN chown -R node:node ./public \ + && chown -R node:node ./dist \ + && chown -R node:node ./scripts + +USER node + EXPOSE 52345 CMD ["sh", "-c", "./scripts/inject-vars.sh && node ./dist/index.js"] diff --git a/docker/dist-mono/server.Dockerfile b/docker/dist-mono/server.Dockerfile index 5e1bf5c13..860fd2032 100644 --- a/docker/dist-mono/server.Dockerfile +++ b/docker/dist-mono/server.Dockerfile @@ -26,8 +26,13 @@ RUN cp -r src/templates dist/templates COPY --from=frontend-build /app/client/dist ./public -RUN chmod +x ./scripts/inject-vars.sh +RUN chmod +x ./scripts/inject-vars.sh \ + && chown -R node:node ./public \ + && chown -R node:node ./dist \ + && chown -R node:node ./scripts + +USER node EXPOSE 52345 -CMD ./scripts/inject-vars.sh && node ./dist/index.js \ No newline at end of file +CMD ["sh", "-c", "./scripts/inject-vars.sh && node ./dist/index.js"] From e71892d0bc946cafc5622c9394ac33a74a5a0b96 Mon Sep 17 00:00:00 2001 From: gorkem-bwl Date: Wed, 29 Apr 2026 15:30:16 -0400 Subject: [PATCH 012/245] feat(smtp): add optional sender display name to SMTP configuration MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Closes #3600. Previously the From header on test emails and notifications showed only the raw email address. Adds an optional "Sender name" field so emails appear from a friendly name like "Checkmate notifications" . ## Behaviour - Field is optional. When empty/null/whitespace, the From header falls back to the raw email address — identical to current behaviour, no regression for existing installs. - When set, the email service hands nodemailer { name, address } as the message from field. Nodemailer handles RFC-5322 encoding (quotes, commas, non-ASCII) so the display name is safe for any input. - Unsetting works: clearing the input transforms the value to null in the Zod parse step, and the settings repository already turns null values into a $unset operation. - Both client and server Zod schemas trim and empty-string-to-null, preventing whitespace-only display names from being stored. ## Surfaces - AppSettings schema, Settings type, EmailTransportConfig pick, server Zod validation, client Zod validation, client form defaults, and the Settings page form all carry the new systemEmailDisplayName field. - The transport preview pre-block on /settings now includes a `from` key showing exactly what the email service hands nodemailer — the string when no display name is set, or { name, address } when it is. - en.json carries the new label and placeholder. Other locales fall back until translated (16 locales pending — separate follow-up). ## Conventions conformance (per docs/frontend-conventions.md) - The pre-block I touched had three rule #1 violations (sx p, borderRadius, backgroundColor); hoisted those to native props using theme.shape.borderRadius. Other rule #1 violations elsewhere on this page are pre-existing and intentionally left alone. - No new sx-with-native-equivalents introduced. - No color="text.X" shorthand introduced. - No theme module import introduced. --- client/src/Hooks/useSettingsForm.ts | 1 + client/src/Pages/Settings/index.tsx | 103 +++++++++++------- client/src/Types/Settings.ts | 1 + client/src/Validation/settings.ts | 5 + client/src/locales/en.json | 4 + server/src/db/models/AppSettings.ts | 1 + .../service/infrastructure/emailService.ts | 6 +- server/src/types/email.ts | 1 + server/src/types/settings.ts | 1 + server/src/validation/settingsValidation.ts | 6 + 10 files changed, 90 insertions(+), 39 deletions(-) diff --git a/client/src/Hooks/useSettingsForm.ts b/client/src/Hooks/useSettingsForm.ts index 4b0768d79..643e9caca 100644 --- a/client/src/Hooks/useSettingsForm.ts +++ b/client/src/Hooks/useSettingsForm.ts @@ -18,6 +18,7 @@ export const useSettingsForm = ({ data = null }: UseSettingsFormOptions = {}) => systemEmailHost: data?.systemEmailHost || "", systemEmailUser: data?.systemEmailUser || "", systemEmailAddress: data?.systemEmailAddress || "", + systemEmailDisplayName: data?.systemEmailDisplayName || "", systemEmailConnectionHost: data?.systemEmailConnectionHost || "localhost", systemEmailTLSServername: data?.systemEmailTLSServername || "", systemEmailPort: data?.systemEmailPort, diff --git a/client/src/Pages/Settings/index.tsx b/client/src/Pages/Settings/index.tsx index fc3fceceb..da4960ebb 100644 --- a/client/src/Pages/Settings/index.tsx +++ b/client/src/Pages/Settings/index.tsx @@ -182,6 +182,9 @@ export const SettingsPage = () => { systemEmailRequireTLS: formValues.systemEmailRequireTLS, systemEmailRejectUnauthorized: formValues.systemEmailRejectUnauthorized, ...(formValues.systemEmailUser && { systemEmailUser: formValues.systemEmailUser }), + ...(formValues.systemEmailDisplayName && { + systemEmailDisplayName: formValues.systemEmailDisplayName, + }), ...(formValues.systemEmailTLSServername && { systemEmailTLSServername: formValues.systemEmailTLSServername, }), @@ -577,44 +580,50 @@ export const SettingsPage = () => { href="https://nodemailer.com/smtp/" target="_blank" /> - - - {JSON.stringify( - { - host: form.watch("systemEmailHost") || "", - port: form.watch("systemEmailPort") || "", - secure: form.watch("systemEmailSecure") ?? false, - auth: { - user: - form.watch("systemEmailUser") || - form.watch("systemEmailAddress") || - "", - pass: "", - }, - name: form.watch("systemEmailConnectionHost") || "localhost", - pool: form.watch("systemEmailPool") ?? false, - tls: { - rejectUnauthorized: - form.watch("systemEmailRejectUnauthorized") ?? true, - ignoreTLS: form.watch("systemEmailIgnoreTLS") ?? false, - requireTLS: form.watch("systemEmailRequireTLS") ?? false, - servername: form.watch("systemEmailTLSServername") || "", - }, - }, - null, - 2 - )} - - + {(() => { + const address = form.watch("systemEmailAddress") || ""; + const displayName = form.watch("systemEmailDisplayName")?.trim(); + const from = + displayName && address ? { name: displayName, address } : address; + return ( + + + {JSON.stringify( + { + host: form.watch("systemEmailHost") || "", + port: form.watch("systemEmailPort") || "", + secure: form.watch("systemEmailSecure") ?? false, + auth: { + user: form.watch("systemEmailUser") || address, + pass: "", + }, + name: form.watch("systemEmailConnectionHost") || "localhost", + pool: form.watch("systemEmailPool") ?? false, + from, + tls: { + rejectUnauthorized: + form.watch("systemEmailRejectUnauthorized") ?? true, + ignoreTLS: form.watch("systemEmailIgnoreTLS") ?? false, + requireTLS: form.watch("systemEmailRequireTLS") ?? false, + servername: form.watch("systemEmailTLSServername") || "", + }, + }, + null, + 2 + )} + + + ); + })()} } rightContent={ @@ -680,6 +689,24 @@ export const SettingsPage = () => { )} /> + {/* Email Display Name (Optional) */} + ( + + )} + /> + {/* Email User (Optional) */} (val === "" ? null : val.toLowerCase().trim())) .optional(), + systemEmailDisplayName: z + .string() + .max(100, "Display name must be 100 characters or fewer") + .transform((val) => (val.trim() === "" ? null : val.trim())) + .optional(), systemEmailUser: z .string() .transform((val) => (val.trim() === "" ? null : val.trim())) diff --git a/client/src/locales/en.json b/client/src/locales/en.json index 3c632ef96..d6e8bb4f3 100644 --- a/client/src/locales/en.json +++ b/client/src/locales/en.json @@ -1198,6 +1198,10 @@ "label": "Email address - Used for authentication", "placeholder": "you@example.com" }, + "displayName": { + "label": "Sender name (optional) - Friendly name shown in the From field", + "placeholder": "Checkmate notifications" + }, "user": { "label": "Email user - Username for authentication, overrides email address if specified", "placeholder": "Leave empty if not required" diff --git a/server/src/db/models/AppSettings.ts b/server/src/db/models/AppSettings.ts index 9eeaa640a..195f43044 100644 --- a/server/src/db/models/AppSettings.ts +++ b/server/src/db/models/AppSettings.ts @@ -26,6 +26,7 @@ const AppSettingsSchema = new Schema( systemEmailHost: { type: String }, systemEmailPort: { type: Number }, systemEmailAddress: { type: String }, + systemEmailDisplayName: { type: String }, systemEmailPassword: { type: String }, systemEmailUser: { type: String }, systemEmailConnectionHost: { type: String, default: "localhost" }, diff --git a/server/src/service/infrastructure/emailService.ts b/server/src/service/infrastructure/emailService.ts index 6cf8caf4f..4428b0477 100755 --- a/server/src/service/infrastructure/emailService.ts +++ b/server/src/service/infrastructure/emailService.ts @@ -118,6 +118,7 @@ export class EmailService implements IEmailService { systemEmailPool, systemEmailUser, systemEmailAddress, + systemEmailDisplayName, systemEmailPassword, systemEmailConnectionHost, systemEmailTLSServername, @@ -158,10 +159,13 @@ export class EmailService implements IEmailService { return false; } + const trimmedDisplayName = systemEmailDisplayName?.trim(); + const from = trimmedDisplayName && systemEmailAddress ? { name: trimmedDisplayName, address: systemEmailAddress } : systemEmailAddress; + try { const info = await this.transporter.sendMail({ to: to, - from: systemEmailAddress, + from: from, subject: subject, html: html, }); diff --git a/server/src/types/email.ts b/server/src/types/email.ts index 2ffae187b..6e884319f 100644 --- a/server/src/types/email.ts +++ b/server/src/types/email.ts @@ -5,6 +5,7 @@ export type EmailTransportConfig = Pick< | "systemEmailHost" | "systemEmailPort" | "systemEmailAddress" + | "systemEmailDisplayName" | "systemEmailPassword" | "systemEmailUser" | "systemEmailConnectionHost" diff --git a/server/src/types/settings.ts b/server/src/types/settings.ts index eb8452866..3d0d0d931 100644 --- a/server/src/types/settings.ts +++ b/server/src/types/settings.ts @@ -21,6 +21,7 @@ export interface Settings { systemEmailHost?: string; systemEmailPort?: number; systemEmailAddress?: string; + systemEmailDisplayName?: string; systemEmailPassword?: string; systemEmailUser?: string; systemEmailConnectionHost?: string; diff --git a/server/src/validation/settingsValidation.ts b/server/src/validation/settingsValidation.ts index f2c4e4c94..db58c50ed 100644 --- a/server/src/validation/settingsValidation.ts +++ b/server/src/validation/settingsValidation.ts @@ -14,6 +14,12 @@ export const updateAppSettingsBodyValidation = z timezone: z.string().optional(), systemEmailHost: z.string().nullable().optional(), systemEmailAddress: z.string().nullable().optional(), + systemEmailDisplayName: z + .string() + .max(100) + .transform((val) => (val.trim() === "" ? null : val.trim())) + .nullable() + .optional(), systemEmailPassword: z.string().nullable().optional(), systemEmailUser: z.string().nullable().optional(), systemEmailConnectionHost: z.string().nullable().optional(), From e11553c2184778e71a8ab5258bbf95d0b7ee0060 Mon Sep 17 00:00:00 2001 From: gorkem-bwl Date: Wed, 29 Apr 2026 15:33:15 -0400 Subject: [PATCH 013/245] feat(settings): restore From-header preview block under SMTP transport JSON MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Brings back the second pre-block I dropped when consolidating the preview. The transport JSON shows what's handed to nodemailer createTransport; the From-header block shows the rendered envelope recipients will see — they answer different questions and both belong. Conditioned on systemEmailAddress so empty configs don't render an empty pre. --- client/src/Pages/Settings/index.tsx | 88 +++++++++++++++++------------ 1 file changed, 53 insertions(+), 35 deletions(-) diff --git a/client/src/Pages/Settings/index.tsx b/client/src/Pages/Settings/index.tsx index da4960ebb..e12850987 100644 --- a/client/src/Pages/Settings/index.tsx +++ b/client/src/Pages/Settings/index.tsx @@ -586,42 +586,60 @@ export const SettingsPage = () => { const from = displayName && address ? { name: displayName, address } : address; return ( - - - {JSON.stringify( - { - host: form.watch("systemEmailHost") || "", - port: form.watch("systemEmailPort") || "", - secure: form.watch("systemEmailSecure") ?? false, - auth: { - user: form.watch("systemEmailUser") || address, - pass: "", + <> + + + {JSON.stringify( + { + host: form.watch("systemEmailHost") || "", + port: form.watch("systemEmailPort") || "", + secure: form.watch("systemEmailSecure") ?? false, + auth: { + user: form.watch("systemEmailUser") || address, + pass: "", + }, + name: form.watch("systemEmailConnectionHost") || "localhost", + pool: form.watch("systemEmailPool") ?? false, + from, + tls: { + rejectUnauthorized: + form.watch("systemEmailRejectUnauthorized") ?? true, + ignoreTLS: form.watch("systemEmailIgnoreTLS") ?? false, + requireTLS: form.watch("systemEmailRequireTLS") ?? false, + servername: form.watch("systemEmailTLSServername") || "", + }, }, - name: form.watch("systemEmailConnectionHost") || "localhost", - pool: form.watch("systemEmailPool") ?? false, - from, - tls: { - rejectUnauthorized: - form.watch("systemEmailRejectUnauthorized") ?? true, - ignoreTLS: form.watch("systemEmailIgnoreTLS") ?? false, - requireTLS: form.watch("systemEmailRequireTLS") ?? false, - servername: form.watch("systemEmailTLSServername") || "", - }, - }, - null, - 2 - )} - - + null, + 2 + )} + + + {address && ( + + + {`From: ${displayName ? `"${displayName}" <${address}>` : address}`} + + + )} + ); })()} From baa4d693aeb44dc2c7ed2eb7f0a5a5db578807a6 Mon Sep 17 00:00:00 2001 From: gorkem-bwl Date: Wed, 29 Apr 2026 15:38:22 -0400 Subject: [PATCH 014/245] refactor(email): set From at transport level instead of per-message MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The /settings transport preview shows `from` inside the createTransport JSON, but the implementation was passing it per-message via sendMail. Switch to passing it as part of the transport config so what the preview shows matches what is actually built — no behavioural change for outgoing emails since nodemailer treats transport-level `from` as the default for every sendMail call. --- server/src/service/infrastructure/emailService.ts | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/server/src/service/infrastructure/emailService.ts b/server/src/service/infrastructure/emailService.ts index 4428b0477..137bd9e2b 100755 --- a/server/src/service/infrastructure/emailService.ts +++ b/server/src/service/infrastructure/emailService.ts @@ -127,6 +127,9 @@ export class EmailService implements IEmailService { systemEmailRejectUnauthorized, } = config; + const trimmedDisplayName = systemEmailDisplayName?.trim(); + const from = trimmedDisplayName && systemEmailAddress ? { name: trimmedDisplayName, address: systemEmailAddress } : systemEmailAddress; + const emailConfig = { host: systemEmailHost, port: Number(systemEmailPort), @@ -138,6 +141,7 @@ export class EmailService implements IEmailService { name: systemEmailConnectionHost || "localhost", connectionTimeout: 5000, pool: systemEmailPool, + from, tls: { rejectUnauthorized: systemEmailRejectUnauthorized, ignoreTLS: systemEmailIgnoreTLS, @@ -159,13 +163,9 @@ export class EmailService implements IEmailService { return false; } - const trimmedDisplayName = systemEmailDisplayName?.trim(); - const from = trimmedDisplayName && systemEmailAddress ? { name: trimmedDisplayName, address: systemEmailAddress } : systemEmailAddress; - try { const info = await this.transporter.sendMail({ to: to, - from: from, subject: subject, html: html, }); From 8fbbb1dd4bc201d030d57f7c1429aca2da812dfb Mon Sep 17 00:00:00 2001 From: gorkem-bwl Date: Wed, 29 Apr 2026 15:42:02 -0400 Subject: [PATCH 015/245] Revert "refactor(email): set From at transport level instead of per-message" This reverts commit baa4d693aeb44dc2c7ed2eb7f0a5a5db578807a6. --- server/src/service/infrastructure/emailService.ts | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/server/src/service/infrastructure/emailService.ts b/server/src/service/infrastructure/emailService.ts index 137bd9e2b..4428b0477 100755 --- a/server/src/service/infrastructure/emailService.ts +++ b/server/src/service/infrastructure/emailService.ts @@ -127,9 +127,6 @@ export class EmailService implements IEmailService { systemEmailRejectUnauthorized, } = config; - const trimmedDisplayName = systemEmailDisplayName?.trim(); - const from = trimmedDisplayName && systemEmailAddress ? { name: trimmedDisplayName, address: systemEmailAddress } : systemEmailAddress; - const emailConfig = { host: systemEmailHost, port: Number(systemEmailPort), @@ -141,7 +138,6 @@ export class EmailService implements IEmailService { name: systemEmailConnectionHost || "localhost", connectionTimeout: 5000, pool: systemEmailPool, - from, tls: { rejectUnauthorized: systemEmailRejectUnauthorized, ignoreTLS: systemEmailIgnoreTLS, @@ -163,9 +159,13 @@ export class EmailService implements IEmailService { return false; } + const trimmedDisplayName = systemEmailDisplayName?.trim(); + const from = trimmedDisplayName && systemEmailAddress ? { name: trimmedDisplayName, address: systemEmailAddress } : systemEmailAddress; + try { const info = await this.transporter.sendMail({ to: to, + from: from, subject: subject, html: html, }); From 6f988cc688af91f19046d5bec102cf92f140c369 Mon Sep 17 00:00:00 2001 From: gorkem-bwl Date: Wed, 29 Apr 2026 15:48:12 -0400 Subject: [PATCH 016/245] fix(settings): drop from field from transport JSON preview The transport JSON preview is strictly for verifying what is handed to nodemailer.createTransport. The From header is built per-message in sendMail, so showing it inside the transport JSON was misleading. The separate From: ... pre block below the JSON is the right place for that preview. --- client/src/Pages/Settings/index.tsx | 3 --- 1 file changed, 3 deletions(-) diff --git a/client/src/Pages/Settings/index.tsx b/client/src/Pages/Settings/index.tsx index e12850987..300d91738 100644 --- a/client/src/Pages/Settings/index.tsx +++ b/client/src/Pages/Settings/index.tsx @@ -583,8 +583,6 @@ export const SettingsPage = () => { {(() => { const address = form.watch("systemEmailAddress") || ""; const displayName = form.watch("systemEmailDisplayName")?.trim(); - const from = - displayName && address ? { name: displayName, address } : address; return ( <> { }, name: form.watch("systemEmailConnectionHost") || "localhost", pool: form.watch("systemEmailPool") ?? false, - from, tls: { rejectUnauthorized: form.watch("systemEmailRejectUnauthorized") ?? true, From d5bf45e2a915b4d766b422d4d3fab4023f0639f0 Mon Sep 17 00:00:00 2001 From: Alex Holliday Date: Wed, 29 Apr 2026 12:57:37 -0700 Subject: [PATCH 017/245] allow from --- server/src/controllers/settingsController.ts | 2 ++ server/src/validation/notificationValidation.ts | 1 + 2 files changed, 3 insertions(+) diff --git a/server/src/controllers/settingsController.ts b/server/src/controllers/settingsController.ts index 03bc91898..485a90d64 100644 --- a/server/src/controllers/settingsController.ts +++ b/server/src/controllers/settingsController.ts @@ -89,6 +89,7 @@ class SettingsController implements ISettingsController { systemEmailHost, systemEmailPort, systemEmailAddress, + systemEmailDisplayName, systemEmailPassword, systemEmailUser, systemEmailConnectionHost, @@ -112,6 +113,7 @@ class SettingsController implements ISettingsController { systemEmailPort, systemEmailUser, systemEmailAddress, + systemEmailDisplayName, systemEmailPassword, systemEmailConnectionHost, systemEmailSecure, diff --git a/server/src/validation/notificationValidation.ts b/server/src/validation/notificationValidation.ts index cf7478610..523cb2a6e 100644 --- a/server/src/validation/notificationValidation.ts +++ b/server/src/validation/notificationValidation.ts @@ -113,6 +113,7 @@ export const sendTestEmailBodyValidation = z.object({ systemEmailSecure: z.boolean().optional(), systemEmailPool: z.boolean().optional(), systemEmailAddress: z.string().optional(), + systemEmailDisplayName: z.string().optional(), systemEmailPassword: z.string().optional(), systemEmailUser: z.string().optional(), systemEmailConnectionHost: z.union([z.string(), z.literal("")]).optional(), From 4de2ba4b25cb3f6d5ff61430b1a9211f2f7ba08b Mon Sep 17 00:00:00 2001 From: Alex Holliday Date: Wed, 29 Apr 2026 12:59:48 -0700 Subject: [PATCH 018/245] add missing tests --- .../test/unit/services/emailService.test.ts | 75 +++++++++++++++++++ 1 file changed, 75 insertions(+) diff --git a/server/test/unit/services/emailService.test.ts b/server/test/unit/services/emailService.test.ts index 13b234186..d4d275ad8 100644 --- a/server/test/unit/services/emailService.test.ts +++ b/server/test/unit/services/emailService.test.ts @@ -396,5 +396,80 @@ describe("EmailService", () => { }) ); }); + + // ── from-header formatting ─────────────────────────────────────── + + describe("from header formatting", () => { + const sendAndGetFrom = async (overrides: Partial) => { + const transporter = createMockTransporter(); + const nodemailer = createMockNodemailer(transporter); + const { service } = createService({ nodemailer }); + + await service.sendEmail("to@example.com", "Subject", "

body

", makeTransportConfig(overrides)); + + const call = (transporter.sendMail as jest.Mock).mock.calls[0]?.[0] as { from: unknown } | undefined; + return call?.from; + }; + + it("uses bare address when systemEmailDisplayName is undefined", async () => { + const from = await sendAndGetFrom({ + systemEmailAddress: "noreply@example.com", + systemEmailDisplayName: undefined, + }); + + expect(from).toBe("noreply@example.com"); + }); + + it("uses bare address when systemEmailDisplayName is empty string", async () => { + const from = await sendAndGetFrom({ + systemEmailAddress: "noreply@example.com", + systemEmailDisplayName: "", + }); + + expect(from).toBe("noreply@example.com"); + }); + + it("uses bare address when systemEmailDisplayName is whitespace-only", async () => { + const from = await sendAndGetFrom({ + systemEmailAddress: "noreply@example.com", + systemEmailDisplayName: " ", + }); + + expect(from).toBe("noreply@example.com"); + }); + + it("uses {name, address} when display name and address are both set", async () => { + const from = await sendAndGetFrom({ + systemEmailAddress: "noreply@example.com", + systemEmailDisplayName: "Checkmate notifications", + }); + + expect(from).toEqual({ + name: "Checkmate notifications", + address: "noreply@example.com", + }); + }); + + it("trims surrounding whitespace from display name", async () => { + const from = await sendAndGetFrom({ + systemEmailAddress: "noreply@example.com", + systemEmailDisplayName: " Checkmate ", + }); + + expect(from).toEqual({ + name: "Checkmate", + address: "noreply@example.com", + }); + }); + + it("falls back to bare address when display name is set but address is empty", async () => { + const from = await sendAndGetFrom({ + systemEmailAddress: "", + systemEmailDisplayName: "Checkmate", + }); + + expect(from).toBe(""); + }); + }); }); }); From fcf2557a2f826355bd4bbe4216f3ecfecea3728d Mon Sep 17 00:00:00 2001 From: Gorkem Cetin <167266851+gorkem-bwl@users.noreply.github.com> Date: Wed, 29 Apr 2026 17:04:01 -0400 Subject: [PATCH 019/245] Update README to remove sponsorship and refine features Removed sponsorship links and consolidated monitoring options. --- README.md | 24 ++++-------------------- 1 file changed, 4 insertions(+), 20 deletions(-) diff --git a/README.md b/README.md index 3f32f1b60..2f501e0f7 100644 --- a/README.md +++ b/README.md @@ -24,8 +24,6 @@ Checkmate also has an agent, called [Capture](https://github.com/bluewave-labs/c Checkmate has been stress-tested with 1000+ active monitors without any particular issues or performance bottlenecks. -**If you would like to sponsor a feature, [see this link](https://checkmate.so/sponsored-features).** - ## 📚 Table of contents - [📦 Demo](#demo) @@ -38,7 +36,6 @@ Checkmate has been stress-tested with 1000+ active monitors without any particul - [🏗️ Tech stack](#tech-stack) - [🔗 A few links](#a-few-links) - [🤝 Contributing](#contributing) -- [💰 Our sponsors](#our-sponsors) ## Demo @@ -89,30 +86,17 @@ Feel free to ask questions or share your ideas - we'd love to hear from you! ## Features - Completely open source, deployable on your servers or home devices (e.g Raspberry Pi 4 or 5) -- Website monitoring +- Several monitoring options: Uptime, Docker, Ping, SSL, Port, Game server - Page speed monitoring - Infrastructure monitoring (memory, disk usage, CPU performance, network etc) - requires [Capture](https://github.com/bluewave-labs/capture) agent - Selective disk monitoring with mountpoint selection -- Docker monitoring -- Ping monitoring -- SSL monitoring -- Port monitoring -- Game server monitoring (3.0) - Incidents at a glance -- Status pages -- E-mail, Webhooks, Discord and Slack notifications +- Status pages with 4 beautiful themes +- E-mail, Webhooks, Discord, Slack, PagerDuty, Matrix, Microsoft Teams, Telegram, Pushover, Twilio (SMS) notifications - Scheduled maintenance - JSON query monitoring -- Multi-language support for English, German, Japanese, Portuguese (Brazil), Russian, Turkish, Ukrainian, Vietnamese, Chinese (Traditional, Taiwan) +- Multi-language support for Arabic, Chinese (Simplified), Chinese (Traditional, Taiwan), Czech, English, Finnish, French, German, Japanese, Portuguese (Brazil), Russian, Spanish, Thai, Turkish, Ukrainian, and Vietnamese -**Short term roadmap:** - -- Plugins that will help Checkmate get any information from a remote service (e.g database, etc) -- Better notifications -- Network monitoring -- ..and a few more features - -If you would like to sponsor an additional feature, [see this page](https://checkmate.so/sponsored-features). ## Monitor Lifecycle From 7f4282ab1fb332480ea0cf5b1544e3ad76a5623b Mon Sep 17 00:00:00 2001 From: Gorkem Cetin <167266851+gorkem-bwl@users.noreply.github.com> Date: Wed, 29 Apr 2026 17:46:22 -0400 Subject: [PATCH 020/245] Update README.md --- README.md | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/README.md b/README.md index 2f501e0f7..254d81ee8 100644 --- a/README.md +++ b/README.md @@ -16,7 +16,10 @@ [![Run on PikaPods](https://www.pikapods.com/static/run-button.svg)](https://www.pikapods.com/pods?run=checkmate) -image +image + +
+ This repository contains both the frontend and the backend of Checkmate, an open-source, self-hosted monitoring tool for tracking server hardware, uptime, response times, and incidents in real-time with beautiful visualizations. Checkmate regularly checks whether a server/website is accessible and performs optimally, providing real-time alerts and reports on the monitored services' availability, downtime, and response time. From 55b9702fc3d11f62e7328b78fa62e18aec81bd5d Mon Sep 17 00:00:00 2001 From: gorkem-bwl Date: Thu, 30 Apr 2026 01:26:14 -0400 Subject: [PATCH 021/245] chore(deps): bump axios to 1.15.2 in client and server ## Changes - Bump axios from ^1.7.x to ^1.15.2 in both client/ and server/ ## Benefits - Patches CVE-2025-62718 (NO_PROXY hostname normalization SSRF) - Patches CVE-2026-40175 (cloud metadata exfiltration via header injection) - Minor bump within existing ^1.x semver range; no API changes --- client/package-lock.json | 19 ++++++++++++------- client/package.json | 2 +- server/package-lock.json | 24 ++++-------------------- server/package.json | 2 +- 4 files changed, 18 insertions(+), 29 deletions(-) diff --git a/client/package-lock.json b/client/package-lock.json index 6210918f4..2017ab1ef 100644 --- a/client/package-lock.json +++ b/client/package-lock.json @@ -18,7 +18,7 @@ "@mui/x-date-pickers": "7.29.4", "@reduxjs/toolkit": "2.7.0", "@types/maplibre-gl": "^1.13.2", - "axios": "^1.7.4", + "axios": "^1.15.2", "dayjs": "1.11.13", "html2canvas": "^1.4.1", "human-interval": "2.0.1", @@ -2925,14 +2925,14 @@ } }, "node_modules/axios": { - "version": "1.13.5", - "resolved": "https://registry.npmjs.org/axios/-/axios-1.13.5.tgz", - "integrity": "sha512-cz4ur7Vb0xS4/KUN0tPWe44eqxrIu31me+fbang3ijiNscE129POzipJJA6zniq2C/Z6sJCjMimjS8Lc/GAs8Q==", + "version": "1.15.2", + "resolved": "https://registry.npmjs.org/axios/-/axios-1.15.2.tgz", + "integrity": "sha512-wLrXxPtcrPTsNlJmKjkPnNPK2Ihe0hn0wGSaTEiHRPxwjvJwT3hKmXF4dpqxmPO9SoNb2FsYXj/xEo0gHN+D5A==", "license": "MIT", "dependencies": { "follow-redirects": "^1.15.11", "form-data": "^4.0.5", - "proxy-from-env": "^1.1.0" + "proxy-from-env": "^2.1.0" } }, "node_modules/babel-plugin-macros": { @@ -6010,8 +6010,13 @@ "license": "MIT" }, "node_modules/proxy-from-env": { - "version": "1.1.0", - "license": "MIT" + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/proxy-from-env/-/proxy-from-env-2.1.0.tgz", + "integrity": "sha512-cJ+oHTW1VAEa8cJslgmUZrc+sjRKgAKl3Zyse6+PV38hZe/V6Z14TbCuXcan9F9ghlz4QrFr2c92TNF82UkYHA==", + "license": "MIT", + "engines": { + "node": ">=10" + } }, "node_modules/punycode": { "version": "2.3.1", diff --git a/client/package.json b/client/package.json index 041b1ef86..180c07372 100644 --- a/client/package.json +++ b/client/package.json @@ -27,7 +27,7 @@ "@mui/x-date-pickers": "7.29.4", "@reduxjs/toolkit": "2.7.0", "@types/maplibre-gl": "^1.13.2", - "axios": "^1.7.4", + "axios": "^1.15.2", "dayjs": "1.11.13", "html2canvas": "^1.4.1", "human-interval": "2.0.1", diff --git a/server/package-lock.json b/server/package-lock.json index 8423aa8d0..e62794723 100644 --- a/server/package-lock.json +++ b/server/package-lock.json @@ -12,7 +12,7 @@ "@grpc/grpc-js": "^1.14.3", "@grpc/proto-loader": "^0.8.0", "@pulsecron/pulse": "1.6.8", - "axios": "^1.7.2", + "axios": "^1.15.2", "bcryptjs": "3.0.2", "cacheable-lookup": "7.0.0", "compression": "1.8.1", @@ -851,7 +851,6 @@ "integrity": "sha512-H3mcG6ZDLTlYfaSNi0iOKkigqMFvkTKlGUYlD8GW7nNOYRrevuA46iTypPyv+06V3fEmvvazfntkBU34L0azAw==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@babel/code-frame": "^7.28.6", "@babel/generator": "^7.28.6", @@ -1471,7 +1470,6 @@ } ], "license": "MIT", - "peer": true, "engines": { "node": ">=18" }, @@ -1516,7 +1514,6 @@ } ], "license": "MIT", - "peer": true, "engines": { "node": ">=18" } @@ -4230,7 +4227,6 @@ "integrity": "sha512-wGA0NX93b19/dZC1J18tKWVIYWyyF2ZjT9vin/NRu0qzzvfVzWjs04iq2rQ3H65vCTQYlRqs3YHfY7zjdV+9Kw==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@types/body-parser": "*", "@types/express-serve-static-core": "^5.0.0", @@ -4391,7 +4387,6 @@ "resolved": "https://registry.npmjs.org/@types/node/-/node-24.9.1.tgz", "integrity": "sha512-QoiaXANRkSXK6p0Duvt56W208du4P9Uye9hWLWgGMDTEoKPhuenzNcC4vGUmrNkiOKTlIrBoyNQYNpSwfEZXSg==", "license": "MIT", - "peer": true, "dependencies": { "undici-types": "~7.16.0" } @@ -4629,7 +4624,6 @@ "integrity": "sha512-klQbnPAAiGYFyI02+znpBRLyjL4/BrBd0nyWkdC0s/6xFLkXYQ8OoRrSkqacS1ddVxf/LDyODIKbQ5TgKAf/Fg==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@typescript-eslint/scope-manager": "8.56.1", "@typescript-eslint/types": "8.56.1", @@ -5243,7 +5237,6 @@ "integrity": "sha512-NZyJarBfL7nWwIq+FDL6Zp/yHEhePMNnnJ0y3qfieCrmNvYct8uvtiV41UvlSe6apAfk0fY1FbWx+NwfmpvtTg==", "dev": true, "license": "MIT", - "peer": true, "bin": { "acorn": "bin/acorn" }, @@ -5441,9 +5434,9 @@ "license": "MIT" }, "node_modules/axios": { - "version": "1.15.1", - "resolved": "https://registry.npmjs.org/axios/-/axios-1.15.1.tgz", - "integrity": "sha512-WOG+Jj8ZOvR0a3rAn+Tuf1UQJRxw5venr6DgdbJzngJE3qG7X0kL83CZGpdHMxEm+ZK3seAbvFsw4FfOfP9vxg==", + "version": "1.15.2", + "resolved": "https://registry.npmjs.org/axios/-/axios-1.15.2.tgz", + "integrity": "sha512-wLrXxPtcrPTsNlJmKjkPnNPK2Ihe0hn0wGSaTEiHRPxwjvJwT3hKmXF4dpqxmPO9SoNb2FsYXj/xEo0gHN+D5A==", "license": "MIT", "dependencies": { "follow-redirects": "^1.15.11", @@ -5822,7 +5815,6 @@ } ], "license": "MIT", - "peer": true, "dependencies": { "baseline-browser-mapping": "^2.8.19", "caniuse-lite": "^1.0.30001751", @@ -6716,7 +6708,6 @@ "resolved": "https://registry.npmjs.org/cssnano/-/cssnano-7.1.1.tgz", "integrity": "sha512-fm4D8ti0dQmFPeF8DXSAA//btEmqCOgAc/9Oa3C1LW94h5usNrJEfrON7b4FkPZgnDEn6OUs5NdxiJZmAtGOpQ==", "license": "MIT", - "peer": true, "dependencies": { "cssnano-preset-default": "^7.0.9", "lilconfig": "^3.1.3" @@ -7481,7 +7472,6 @@ "integrity": "sha512-t5aPOpmtJcZcz5UJyY2GbvpDlsK5E8JqRqoKtfiKE3cNh437KIqfJr3A3AKf5k64NPx6d0G3dno6XDY05PqPtw==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@eslint-community/eslint-utils": "^4.8.0", "@eslint-community/regexpp": "^4.12.1", @@ -7843,7 +7833,6 @@ "resolved": "https://registry.npmjs.org/express/-/express-4.22.1.tgz", "integrity": "sha512-F2X8g9P1X7uCPZMA3MVf9wcTqlyNp7IhH5qPCI0izhaOIYXaW9L535tGA3qmjRzpH+bZczqq7hVKxTR4NWnu+g==", "license": "MIT", - "peer": true, "dependencies": { "accepts": "~1.3.8", "array-flatten": "1.1.1", @@ -9512,7 +9501,6 @@ "integrity": "sha512-F26gjC0yWN8uAA5m5Ss8ZQf5nDHWGlN/xWZIh8S5SRbsEKBovwZhxGd6LJlbZYxBgCYOtreSUyb8hpXyGC5O4A==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@jest/core": "30.2.0", "@jest/types": "30.2.0", @@ -12347,7 +12335,6 @@ } ], "license": "MIT", - "peer": true, "dependencies": { "nanoid": "^3.3.11", "picocolors": "^1.1.1", @@ -14370,7 +14357,6 @@ "integrity": "sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==", "dev": true, "license": "MIT", - "peer": true, "engines": { "node": ">=12" }, @@ -14539,7 +14525,6 @@ "integrity": "sha512-f0FFpIdcHgn8zcPSbf1dRevwt047YMnaiJM3u2w2RewrB+fob/zePZcrOyQoLMMO7aBIddLcQIEK5dYjkLnGrQ==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@cspotcode/source-map-support": "^0.8.0", "@tsconfig/node10": "^1.0.7", @@ -14703,7 +14688,6 @@ "integrity": "sha512-CWBzXQrc/qOkhidw1OzBTQuYRbfyxDXJMVJ1XNwUHGROVmuaeiEm3OslpZ1RV96d7SKKjZKrSJu3+t/xlw3R9A==", "devOptional": true, "license": "Apache-2.0", - "peer": true, "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" diff --git a/server/package.json b/server/package.json index 4251a2f9d..5ac5dc305 100755 --- a/server/package.json +++ b/server/package.json @@ -30,7 +30,7 @@ "@grpc/grpc-js": "^1.14.3", "@grpc/proto-loader": "^0.8.0", "@pulsecron/pulse": "1.6.8", - "axios": "^1.7.2", + "axios": "^1.15.2", "bcryptjs": "3.0.2", "cacheable-lookup": "7.0.0", "compression": "1.8.1", From 3adadf0d070e1cfff3310c3221e6ea78b5391f35 Mon Sep 17 00:00:00 2001 From: gorkem-bwl Date: Thu, 30 Apr 2026 01:27:14 -0400 Subject: [PATCH 022/245] chore(deps): bump multer to 1.4.5-lts.2 in server ## Changes - Pin multer from ^1.4.5-lts.1 to ^1.4.5-lts.2 ## Benefits - Picks up the latest 1.x LTS patch (released 2025-03-20) - Patch-level bump within existing semver range; no API changes - Two routes use multer (authRoute, statusPageRoute) and both rely only on the stable basic API --- server/package-lock.json | 18 +----------------- server/package.json | 2 +- 2 files changed, 2 insertions(+), 18 deletions(-) diff --git a/server/package-lock.json b/server/package-lock.json index 8423aa8d0..7cf9aede1 100644 --- a/server/package-lock.json +++ b/server/package-lock.json @@ -34,7 +34,7 @@ "mailersend": "^2.2.0", "mjml": "^5.0.0-alpha.4", "mongoose": "^8.3.3", - "multer": "^1.4.5-lts.1", + "multer": "^1.4.5-lts.2", "nodemailer": "8.0.5", "ping": "0.4.4", "sharp": "0.33.5", @@ -851,7 +851,6 @@ "integrity": "sha512-H3mcG6ZDLTlYfaSNi0iOKkigqMFvkTKlGUYlD8GW7nNOYRrevuA46iTypPyv+06V3fEmvvazfntkBU34L0azAw==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@babel/code-frame": "^7.28.6", "@babel/generator": "^7.28.6", @@ -1471,7 +1470,6 @@ } ], "license": "MIT", - "peer": true, "engines": { "node": ">=18" }, @@ -1516,7 +1514,6 @@ } ], "license": "MIT", - "peer": true, "engines": { "node": ">=18" } @@ -4230,7 +4227,6 @@ "integrity": "sha512-wGA0NX93b19/dZC1J18tKWVIYWyyF2ZjT9vin/NRu0qzzvfVzWjs04iq2rQ3H65vCTQYlRqs3YHfY7zjdV+9Kw==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@types/body-parser": "*", "@types/express-serve-static-core": "^5.0.0", @@ -4391,7 +4387,6 @@ "resolved": "https://registry.npmjs.org/@types/node/-/node-24.9.1.tgz", "integrity": "sha512-QoiaXANRkSXK6p0Duvt56W208du4P9Uye9hWLWgGMDTEoKPhuenzNcC4vGUmrNkiOKTlIrBoyNQYNpSwfEZXSg==", "license": "MIT", - "peer": true, "dependencies": { "undici-types": "~7.16.0" } @@ -4629,7 +4624,6 @@ "integrity": "sha512-klQbnPAAiGYFyI02+znpBRLyjL4/BrBd0nyWkdC0s/6xFLkXYQ8OoRrSkqacS1ddVxf/LDyODIKbQ5TgKAf/Fg==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@typescript-eslint/scope-manager": "8.56.1", "@typescript-eslint/types": "8.56.1", @@ -5243,7 +5237,6 @@ "integrity": "sha512-NZyJarBfL7nWwIq+FDL6Zp/yHEhePMNnnJ0y3qfieCrmNvYct8uvtiV41UvlSe6apAfk0fY1FbWx+NwfmpvtTg==", "dev": true, "license": "MIT", - "peer": true, "bin": { "acorn": "bin/acorn" }, @@ -5822,7 +5815,6 @@ } ], "license": "MIT", - "peer": true, "dependencies": { "baseline-browser-mapping": "^2.8.19", "caniuse-lite": "^1.0.30001751", @@ -6716,7 +6708,6 @@ "resolved": "https://registry.npmjs.org/cssnano/-/cssnano-7.1.1.tgz", "integrity": "sha512-fm4D8ti0dQmFPeF8DXSAA//btEmqCOgAc/9Oa3C1LW94h5usNrJEfrON7b4FkPZgnDEn6OUs5NdxiJZmAtGOpQ==", "license": "MIT", - "peer": true, "dependencies": { "cssnano-preset-default": "^7.0.9", "lilconfig": "^3.1.3" @@ -7481,7 +7472,6 @@ "integrity": "sha512-t5aPOpmtJcZcz5UJyY2GbvpDlsK5E8JqRqoKtfiKE3cNh437KIqfJr3A3AKf5k64NPx6d0G3dno6XDY05PqPtw==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@eslint-community/eslint-utils": "^4.8.0", "@eslint-community/regexpp": "^4.12.1", @@ -7843,7 +7833,6 @@ "resolved": "https://registry.npmjs.org/express/-/express-4.22.1.tgz", "integrity": "sha512-F2X8g9P1X7uCPZMA3MVf9wcTqlyNp7IhH5qPCI0izhaOIYXaW9L535tGA3qmjRzpH+bZczqq7hVKxTR4NWnu+g==", "license": "MIT", - "peer": true, "dependencies": { "accepts": "~1.3.8", "array-flatten": "1.1.1", @@ -9512,7 +9501,6 @@ "integrity": "sha512-F26gjC0yWN8uAA5m5Ss8ZQf5nDHWGlN/xWZIh8S5SRbsEKBovwZhxGd6LJlbZYxBgCYOtreSUyb8hpXyGC5O4A==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@jest/core": "30.2.0", "@jest/types": "30.2.0", @@ -12347,7 +12335,6 @@ } ], "license": "MIT", - "peer": true, "dependencies": { "nanoid": "^3.3.11", "picocolors": "^1.1.1", @@ -14370,7 +14357,6 @@ "integrity": "sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==", "dev": true, "license": "MIT", - "peer": true, "engines": { "node": ">=12" }, @@ -14539,7 +14525,6 @@ "integrity": "sha512-f0FFpIdcHgn8zcPSbf1dRevwt047YMnaiJM3u2w2RewrB+fob/zePZcrOyQoLMMO7aBIddLcQIEK5dYjkLnGrQ==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@cspotcode/source-map-support": "^0.8.0", "@tsconfig/node10": "^1.0.7", @@ -14703,7 +14688,6 @@ "integrity": "sha512-CWBzXQrc/qOkhidw1OzBTQuYRbfyxDXJMVJ1XNwUHGROVmuaeiEm3OslpZ1RV96d7SKKjZKrSJu3+t/xlw3R9A==", "devOptional": true, "license": "Apache-2.0", - "peer": true, "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" diff --git a/server/package.json b/server/package.json index 4251a2f9d..0ee723f99 100755 --- a/server/package.json +++ b/server/package.json @@ -52,7 +52,7 @@ "mailersend": "^2.2.0", "mjml": "^5.0.0-alpha.4", "mongoose": "^8.3.3", - "multer": "^1.4.5-lts.1", + "multer": "^1.4.5-lts.2", "nodemailer": "8.0.5", "ping": "0.4.4", "sharp": "0.33.5", From 788ca782348bb0d471a3914c5cdfc60abce9dc46 Mon Sep 17 00:00:00 2001 From: gorkem-bwl Date: Thu, 30 Apr 2026 01:44:00 -0400 Subject: [PATCH 023/245] chore(deps): bump lodash to 4.18.1 (transitive) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Patches CVE-2026-4800 (high — code injection via _.template) and CVE-2026-2950 (medium — prototype pollution via array path bypass). Transitive bump within existing semver range; no package.json change. --- client/package-lock.json | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/client/package-lock.json b/client/package-lock.json index 6210918f4..a58196186 100644 --- a/client/package-lock.json +++ b/client/package-lock.json @@ -5313,7 +5313,9 @@ } }, "node_modules/lodash": { - "version": "4.17.23", + "version": "4.18.1", + "resolved": "https://registry.npmjs.org/lodash/-/lodash-4.18.1.tgz", + "integrity": "sha512-dMInicTPVE8d1e5otfwmmjlxkZoUpiVLwyeTdUsi/Caj/gfzzblBcCE5sRHV/AsjuCmxWrte2TNGSYuCeCq+0Q==", "license": "MIT" }, "node_modules/lodash.merge": { From 6434afe2c9dfa2735502b5774e4cbbc514fac652 Mon Sep 17 00:00:00 2001 From: gorkem-bwl Date: Thu, 30 Apr 2026 01:44:39 -0400 Subject: [PATCH 024/245] chore(deps): bump picomatch (transitive) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Patches CVE-2026-33671 (high — ReDoS via extglob quantifiers) and CVE-2026-33672 (medium — method injection via POSIX character classes). Transitive bump within existing semver range; no package.json change. --- client/package-lock.json | 10 ++++++---- 1 file changed, 6 insertions(+), 4 deletions(-) diff --git a/client/package-lock.json b/client/package-lock.json index 6210918f4..fe3ffb1e0 100644 --- a/client/package-lock.json +++ b/client/package-lock.json @@ -5470,9 +5470,9 @@ } }, "node_modules/micromatch/node_modules/picomatch": { - "version": "2.3.1", - "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.1.tgz", - "integrity": "sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA==", + "version": "2.3.2", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.2.tgz", + "integrity": "sha512-V7+vQEJ06Z+c5tSye8S+nHUfI51xoXIXjHQ99cQtKUkQqqO1kO/KCJUfZXuB47h/YBlDhah2H3hdUGXn8ie0oA==", "dev": true, "license": "MIT", "engines": { @@ -5881,7 +5881,9 @@ "license": "ISC" }, "node_modules/picomatch": { - "version": "4.0.3", + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.4.tgz", + "integrity": "sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==", "license": "MIT", "engines": { "node": ">=12" From 35daed605f60b84f0fa4e4ead83f6e0173506c99 Mon Sep 17 00:00:00 2001 From: gorkem-bwl Date: Thu, 30 Apr 2026 01:45:33 -0400 Subject: [PATCH 025/245] chore(deps): bump flatted (transitive) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Patches CVE-2026-33228 (high — prototype pollution via parse() in flatted). Transitive bump within existing semver range; no package.json change. --- client/package-lock.json | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/client/package-lock.json b/client/package-lock.json index 6210918f4..844b52b22 100644 --- a/client/package-lock.json +++ b/client/package-lock.json @@ -4191,7 +4191,9 @@ } }, "node_modules/flatted": { - "version": "3.3.3", + "version": "3.4.2", + "resolved": "https://registry.npmjs.org/flatted/-/flatted-3.4.2.tgz", + "integrity": "sha512-PjDse7RzhcPkIJwy5t7KPWQSZ9cAbzQXcafsetQoD7sOJRQlGikNbx7yZp2OotDnJyrDcbyRq3Ttb18iYOqkxA==", "dev": true, "license": "ISC" }, From c40f4e9f1caad1f415396c43b9bb816b7f89e047 Mon Sep 17 00:00:00 2001 From: gorkem-bwl Date: Thu, 30 Apr 2026 01:46:07 -0400 Subject: [PATCH 026/245] chore(deps): bump follow-redirects (transitive) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Patches GHSA-r4q5-vmmm-2653 (medium — leaks Custom-Authorization headers to cross-domain redirect targets). Transitive bump within existing semver range; no package.json change. --- client/package-lock.json | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/client/package-lock.json b/client/package-lock.json index 6210918f4..b30bbba5e 100644 --- a/client/package-lock.json +++ b/client/package-lock.json @@ -4196,7 +4196,9 @@ "license": "ISC" }, "node_modules/follow-redirects": { - "version": "1.15.11", + "version": "1.16.0", + "resolved": "https://registry.npmjs.org/follow-redirects/-/follow-redirects-1.16.0.tgz", + "integrity": "sha512-y5rN/uOsadFT/JfYwhxRS5R7Qce+g3zG97+JrtFZlC9klX/W5hD7iiLzScI4nZqUS7DNUdhPgw4xI8W2LuXlUw==", "funding": [ { "type": "individual", From aaf7d7e47d588b052bae13c6ee0c4ca5a69cebf9 Mon Sep 17 00:00:00 2001 From: gorkem-bwl Date: Thu, 30 Apr 2026 01:46:28 -0400 Subject: [PATCH 027/245] chore(deps): bump postcss (transitive) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Patches CVE-2026-41305 (medium — XSS via unescaped in CSS stringify output). Transitive bump within existing semver range; no package.json change. --- client/package-lock.json | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/client/package-lock.json b/client/package-lock.json index 6210918f4..074b7dab7 100644 --- a/client/package-lock.json +++ b/client/package-lock.json @@ -5912,7 +5912,9 @@ } }, "node_modules/postcss": { - "version": "8.5.6", + "version": "8.5.12", + "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.12.tgz", + "integrity": "sha512-W62t/Se6rA0Az3DfCL0AqJwXuKwBeYg6nOaIgzP+xZ7N5BFCI7DYi1qs6ygUYT6rvfi6t9k65UMLJC+PHZpDAA==", "funding": [ { "type": "opencollective", From 906d3068279b3c1c241c300ef3fe4c76ab1e6d40 Mon Sep 17 00:00:00 2001 From: gorkem-bwl Date: Thu, 30 Apr 2026 01:46:46 -0400 Subject: [PATCH 028/245] chore(deps): bump postcss (transitive) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Patches CVE-2026-41305 (medium — XSS via unescaped in CSS stringify output). Transitive bump within existing semver range; no package.json change. --- server/package-lock.json | 22 +++------------------- 1 file changed, 3 insertions(+), 19 deletions(-) diff --git a/server/package-lock.json b/server/package-lock.json index 8423aa8d0..bdb4993df 100644 --- a/server/package-lock.json +++ b/server/package-lock.json @@ -851,7 +851,6 @@ "integrity": "sha512-H3mcG6ZDLTlYfaSNi0iOKkigqMFvkTKlGUYlD8GW7nNOYRrevuA46iTypPyv+06V3fEmvvazfntkBU34L0azAw==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@babel/code-frame": "^7.28.6", "@babel/generator": "^7.28.6", @@ -1471,7 +1470,6 @@ } ], "license": "MIT", - "peer": true, "engines": { "node": ">=18" }, @@ -1516,7 +1514,6 @@ } ], "license": "MIT", - "peer": true, "engines": { "node": ">=18" } @@ -4230,7 +4227,6 @@ "integrity": "sha512-wGA0NX93b19/dZC1J18tKWVIYWyyF2ZjT9vin/NRu0qzzvfVzWjs04iq2rQ3H65vCTQYlRqs3YHfY7zjdV+9Kw==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@types/body-parser": "*", "@types/express-serve-static-core": "^5.0.0", @@ -4391,7 +4387,6 @@ "resolved": "https://registry.npmjs.org/@types/node/-/node-24.9.1.tgz", "integrity": "sha512-QoiaXANRkSXK6p0Duvt56W208du4P9Uye9hWLWgGMDTEoKPhuenzNcC4vGUmrNkiOKTlIrBoyNQYNpSwfEZXSg==", "license": "MIT", - "peer": true, "dependencies": { "undici-types": "~7.16.0" } @@ -4629,7 +4624,6 @@ "integrity": "sha512-klQbnPAAiGYFyI02+znpBRLyjL4/BrBd0nyWkdC0s/6xFLkXYQ8OoRrSkqacS1ddVxf/LDyODIKbQ5TgKAf/Fg==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@typescript-eslint/scope-manager": "8.56.1", "@typescript-eslint/types": "8.56.1", @@ -5243,7 +5237,6 @@ "integrity": "sha512-NZyJarBfL7nWwIq+FDL6Zp/yHEhePMNnnJ0y3qfieCrmNvYct8uvtiV41UvlSe6apAfk0fY1FbWx+NwfmpvtTg==", "dev": true, "license": "MIT", - "peer": true, "bin": { "acorn": "bin/acorn" }, @@ -5822,7 +5815,6 @@ } ], "license": "MIT", - "peer": true, "dependencies": { "baseline-browser-mapping": "^2.8.19", "caniuse-lite": "^1.0.30001751", @@ -6716,7 +6708,6 @@ "resolved": "https://registry.npmjs.org/cssnano/-/cssnano-7.1.1.tgz", "integrity": "sha512-fm4D8ti0dQmFPeF8DXSAA//btEmqCOgAc/9Oa3C1LW94h5usNrJEfrON7b4FkPZgnDEn6OUs5NdxiJZmAtGOpQ==", "license": "MIT", - "peer": true, "dependencies": { "cssnano-preset-default": "^7.0.9", "lilconfig": "^3.1.3" @@ -7481,7 +7472,6 @@ "integrity": "sha512-t5aPOpmtJcZcz5UJyY2GbvpDlsK5E8JqRqoKtfiKE3cNh437KIqfJr3A3AKf5k64NPx6d0G3dno6XDY05PqPtw==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@eslint-community/eslint-utils": "^4.8.0", "@eslint-community/regexpp": "^4.12.1", @@ -7843,7 +7833,6 @@ "resolved": "https://registry.npmjs.org/express/-/express-4.22.1.tgz", "integrity": "sha512-F2X8g9P1X7uCPZMA3MVf9wcTqlyNp7IhH5qPCI0izhaOIYXaW9L535tGA3qmjRzpH+bZczqq7hVKxTR4NWnu+g==", "license": "MIT", - "peer": true, "dependencies": { "accepts": "~1.3.8", "array-flatten": "1.1.1", @@ -9512,7 +9501,6 @@ "integrity": "sha512-F26gjC0yWN8uAA5m5Ss8ZQf5nDHWGlN/xWZIh8S5SRbsEKBovwZhxGd6LJlbZYxBgCYOtreSUyb8hpXyGC5O4A==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@jest/core": "30.2.0", "@jest/types": "30.2.0", @@ -12329,9 +12317,9 @@ } }, "node_modules/postcss": { - "version": "8.5.6", - "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.6.tgz", - "integrity": "sha512-3Ybi1tAuwAP9s0r1UQ2J4n5Y0G05bJkpUIO0/bI9MhwmD70S5aTWbXGBwxHrelT+XM1k6dM0pk+SwNkpTRN7Pg==", + "version": "8.5.12", + "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.12.tgz", + "integrity": "sha512-W62t/Se6rA0Az3DfCL0AqJwXuKwBeYg6nOaIgzP+xZ7N5BFCI7DYi1qs6ygUYT6rvfi6t9k65UMLJC+PHZpDAA==", "funding": [ { "type": "opencollective", @@ -12347,7 +12335,6 @@ } ], "license": "MIT", - "peer": true, "dependencies": { "nanoid": "^3.3.11", "picocolors": "^1.1.1", @@ -14370,7 +14357,6 @@ "integrity": "sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==", "dev": true, "license": "MIT", - "peer": true, "engines": { "node": ">=12" }, @@ -14539,7 +14525,6 @@ "integrity": "sha512-f0FFpIdcHgn8zcPSbf1dRevwt047YMnaiJM3u2w2RewrB+fob/zePZcrOyQoLMMO7aBIddLcQIEK5dYjkLnGrQ==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@cspotcode/source-map-support": "^0.8.0", "@tsconfig/node10": "^1.0.7", @@ -14703,7 +14688,6 @@ "integrity": "sha512-CWBzXQrc/qOkhidw1OzBTQuYRbfyxDXJMVJ1XNwUHGROVmuaeiEm3OslpZ1RV96d7SKKjZKrSJu3+t/xlw3R9A==", "devOptional": true, "license": "Apache-2.0", - "peer": true, "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" From 29dbebd228c4659d64fbe2d0da15bbee9b03394f Mon Sep 17 00:00:00 2001 From: gorkem-bwl Date: Thu, 30 Apr 2026 01:47:08 -0400 Subject: [PATCH 029/245] chore(deps): bump yaml (transitive) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Patches CVE-2026-33532 (medium — stack overflow via deeply nested YAML collections). Transitive bump within existing semver range; no package.json change. --- client/package-lock.json | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/client/package-lock.json b/client/package-lock.json index 6210918f4..06f01fb22 100644 --- a/client/package-lock.json +++ b/client/package-lock.json @@ -3239,9 +3239,9 @@ } }, "node_modules/cosmiconfig/node_modules/yaml": { - "version": "1.10.2", - "resolved": "https://registry.npmjs.org/yaml/-/yaml-1.10.2.tgz", - "integrity": "sha512-r3vXyErRCYJ7wg28yvBY5VSoAF8ZvlcW9/BwUzEtUsjvX/DKs24dIkuwjtuprwJJHsbyUbLApepYTR1BN4uHrg==", + "version": "1.10.3", + "resolved": "https://registry.npmjs.org/yaml/-/yaml-1.10.3.tgz", + "integrity": "sha512-vIYeF1u3CjlhAFekPPAk2h/Kv4T3mAkMox5OymRiJQB0spDP10LHvt+K7G9Ny6NuuMAb25/6n1qyUjAcGNf/AA==", "license": "ISC", "engines": { "node": ">= 6" @@ -7648,9 +7648,9 @@ "license": "ISC" }, "node_modules/yaml": { - "version": "2.8.2", - "resolved": "https://registry.npmjs.org/yaml/-/yaml-2.8.2.tgz", - "integrity": "sha512-mplynKqc1C2hTVYxd0PU2xQAc22TI1vShAYGksCCfxbn/dFwnHTNi1bvYsBTkhdUNtGIf5xNOg938rrSSYvS9A==", + "version": "2.8.3", + "resolved": "https://registry.npmjs.org/yaml/-/yaml-2.8.3.tgz", + "integrity": "sha512-AvbaCLOO2Otw/lW5bmh9d/WEdcDFdQp2Z2ZUH3pX9U2ihyUY0nvLv7J6TrWowklRGPYbB/IuIMfYgxaCPg5Bpg==", "devOptional": true, "license": "ISC", "bin": { From d868f6931da3e3e39779d6facdb0feb22a1db913 Mon Sep 17 00:00:00 2001 From: gorkem-bwl Date: Thu, 30 Apr 2026 01:47:34 -0400 Subject: [PATCH 030/245] chore(deps): bump protocol-buffers-schema (transitive) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Patches CVE-2026-5758 (medium — prototype pollution in protocol-buffers-schema). Transitive bump within existing semver range; no package.json change. --- client/package-lock.json | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/client/package-lock.json b/client/package-lock.json index 6210918f4..08fdc9724 100644 --- a/client/package-lock.json +++ b/client/package-lock.json @@ -6004,9 +6004,9 @@ "license": "MIT" }, "node_modules/protocol-buffers-schema": { - "version": "3.6.0", - "resolved": "https://registry.npmjs.org/protocol-buffers-schema/-/protocol-buffers-schema-3.6.0.tgz", - "integrity": "sha512-TdDRD+/QNdrCGCE7v8340QyuXd4kIWIgapsE2+n/SaGiSSbomYl4TjHlvIoCWRpE7wFt02EpB35VVA2ImcBVqw==", + "version": "3.6.1", + "resolved": "https://registry.npmjs.org/protocol-buffers-schema/-/protocol-buffers-schema-3.6.1.tgz", + "integrity": "sha512-VG2K63Igkiv9p76tk1lilczEK1cT+kCjKtkdhw1dQZV3k3IXJbd3o6Ho8b9zJZaHSnT2hKe4I+ObmX9w6m5SmQ==", "license": "MIT" }, "node_modules/proxy-from-env": { From bc9441bcd0dde14e5e059290ac7568481ecf0ca2 Mon Sep 17 00:00:00 2001 From: gorkem-bwl Date: Thu, 30 Apr 2026 01:47:42 -0400 Subject: [PATCH 031/245] chore(deps): bump fast-xml-parser (transitive) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Patches CVE-2026-41650 (medium — XML comment and CDATA injection via unescaped delimiters in XMLBuilder). Transitive bump within existing semver range; no package.json change. --- server/package-lock.json | 16 ---------------- 1 file changed, 16 deletions(-) diff --git a/server/package-lock.json b/server/package-lock.json index 8423aa8d0..1c943319f 100644 --- a/server/package-lock.json +++ b/server/package-lock.json @@ -851,7 +851,6 @@ "integrity": "sha512-H3mcG6ZDLTlYfaSNi0iOKkigqMFvkTKlGUYlD8GW7nNOYRrevuA46iTypPyv+06V3fEmvvazfntkBU34L0azAw==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@babel/code-frame": "^7.28.6", "@babel/generator": "^7.28.6", @@ -1471,7 +1470,6 @@ } ], "license": "MIT", - "peer": true, "engines": { "node": ">=18" }, @@ -1516,7 +1514,6 @@ } ], "license": "MIT", - "peer": true, "engines": { "node": ">=18" } @@ -4230,7 +4227,6 @@ "integrity": "sha512-wGA0NX93b19/dZC1J18tKWVIYWyyF2ZjT9vin/NRu0qzzvfVzWjs04iq2rQ3H65vCTQYlRqs3YHfY7zjdV+9Kw==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@types/body-parser": "*", "@types/express-serve-static-core": "^5.0.0", @@ -4391,7 +4387,6 @@ "resolved": "https://registry.npmjs.org/@types/node/-/node-24.9.1.tgz", "integrity": "sha512-QoiaXANRkSXK6p0Duvt56W208du4P9Uye9hWLWgGMDTEoKPhuenzNcC4vGUmrNkiOKTlIrBoyNQYNpSwfEZXSg==", "license": "MIT", - "peer": true, "dependencies": { "undici-types": "~7.16.0" } @@ -4629,7 +4624,6 @@ "integrity": "sha512-klQbnPAAiGYFyI02+znpBRLyjL4/BrBd0nyWkdC0s/6xFLkXYQ8OoRrSkqacS1ddVxf/LDyODIKbQ5TgKAf/Fg==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@typescript-eslint/scope-manager": "8.56.1", "@typescript-eslint/types": "8.56.1", @@ -5243,7 +5237,6 @@ "integrity": "sha512-NZyJarBfL7nWwIq+FDL6Zp/yHEhePMNnnJ0y3qfieCrmNvYct8uvtiV41UvlSe6apAfk0fY1FbWx+NwfmpvtTg==", "dev": true, "license": "MIT", - "peer": true, "bin": { "acorn": "bin/acorn" }, @@ -5822,7 +5815,6 @@ } ], "license": "MIT", - "peer": true, "dependencies": { "baseline-browser-mapping": "^2.8.19", "caniuse-lite": "^1.0.30001751", @@ -6716,7 +6708,6 @@ "resolved": "https://registry.npmjs.org/cssnano/-/cssnano-7.1.1.tgz", "integrity": "sha512-fm4D8ti0dQmFPeF8DXSAA//btEmqCOgAc/9Oa3C1LW94h5usNrJEfrON7b4FkPZgnDEn6OUs5NdxiJZmAtGOpQ==", "license": "MIT", - "peer": true, "dependencies": { "cssnano-preset-default": "^7.0.9", "lilconfig": "^3.1.3" @@ -7481,7 +7472,6 @@ "integrity": "sha512-t5aPOpmtJcZcz5UJyY2GbvpDlsK5E8JqRqoKtfiKE3cNh437KIqfJr3A3AKf5k64NPx6d0G3dno6XDY05PqPtw==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@eslint-community/eslint-utils": "^4.8.0", "@eslint-community/regexpp": "^4.12.1", @@ -7843,7 +7833,6 @@ "resolved": "https://registry.npmjs.org/express/-/express-4.22.1.tgz", "integrity": "sha512-F2X8g9P1X7uCPZMA3MVf9wcTqlyNp7IhH5qPCI0izhaOIYXaW9L535tGA3qmjRzpH+bZczqq7hVKxTR4NWnu+g==", "license": "MIT", - "peer": true, "dependencies": { "accepts": "~1.3.8", "array-flatten": "1.1.1", @@ -9512,7 +9501,6 @@ "integrity": "sha512-F26gjC0yWN8uAA5m5Ss8ZQf5nDHWGlN/xWZIh8S5SRbsEKBovwZhxGd6LJlbZYxBgCYOtreSUyb8hpXyGC5O4A==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@jest/core": "30.2.0", "@jest/types": "30.2.0", @@ -12347,7 +12335,6 @@ } ], "license": "MIT", - "peer": true, "dependencies": { "nanoid": "^3.3.11", "picocolors": "^1.1.1", @@ -14370,7 +14357,6 @@ "integrity": "sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==", "dev": true, "license": "MIT", - "peer": true, "engines": { "node": ">=12" }, @@ -14539,7 +14525,6 @@ "integrity": "sha512-f0FFpIdcHgn8zcPSbf1dRevwt047YMnaiJM3u2w2RewrB+fob/zePZcrOyQoLMMO7aBIddLcQIEK5dYjkLnGrQ==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@cspotcode/source-map-support": "^0.8.0", "@tsconfig/node10": "^1.0.7", @@ -14703,7 +14688,6 @@ "integrity": "sha512-CWBzXQrc/qOkhidw1OzBTQuYRbfyxDXJMVJ1XNwUHGROVmuaeiEm3OslpZ1RV96d7SKKjZKrSJu3+t/xlw3R9A==", "devOptional": true, "license": "Apache-2.0", - "peer": true, "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" From 79d2eb68fa74a076dce63d50bc2152c78a91235a Mon Sep 17 00:00:00 2001 From: gorkem-bwl Date: Thu, 30 Apr 2026 01:48:16 -0400 Subject: [PATCH 032/245] chore(deps): bump vite to ^6.4.2 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Patches CVE-2026-39365 (medium — path traversal in optimized deps .map handling). Relaxes pinned version 6.4.1 to ^6.4.2 patch range; semver-compatible. --- client/package-lock.json | 8 ++++---- client/package.json | 2 +- 2 files changed, 5 insertions(+), 5 deletions(-) diff --git a/client/package-lock.json b/client/package-lock.json index 6210918f4..359268b0a 100644 --- a/client/package-lock.json +++ b/client/package-lock.json @@ -59,7 +59,7 @@ "lint-staged": "^16.2.7", "prettier": "^3.3.3", "typescript": "5.9.3", - "vite": "6.4.1" + "vite": "^6.4.2" } }, "node_modules/@babel/code-frame": { @@ -7365,9 +7365,9 @@ } }, "node_modules/vite": { - "version": "6.4.1", - "resolved": "https://registry.npmjs.org/vite/-/vite-6.4.1.tgz", - "integrity": "sha512-+Oxm7q9hDoLMyJOYfUYBuHQo+dkAloi33apOPP56pzj+vsdJDzr+j1NISE5pyaAuKL4A3UD34qd0lx5+kfKp2g==", + "version": "6.4.2", + "resolved": "https://registry.npmjs.org/vite/-/vite-6.4.2.tgz", + "integrity": "sha512-2N/55r4JDJ4gdrCvGgINMy+HH3iRpNIz8K6SFwVsA+JbQScLiC+clmAxBgwiSPgcG9U15QmvqCGWzMbqda5zGQ==", "license": "MIT", "dependencies": { "esbuild": "^0.25.0", diff --git a/client/package.json b/client/package.json index 041b1ef86..8d02870ba 100644 --- a/client/package.json +++ b/client/package.json @@ -68,6 +68,6 @@ "lint-staged": "^16.2.7", "prettier": "^3.3.3", "typescript": "5.9.3", - "vite": "6.4.1" + "vite": "^6.4.2" } } From d47cc65fedc53234caaed8e2f50cabd4a0bbec64 Mon Sep 17 00:00:00 2001 From: Alex Holliday Date: Thu, 30 Apr 2026 10:30:54 -0700 Subject: [PATCH 033/245] Increase max recent checks limit from 25 -> 50, extract constants --- .../common/charts/HeatmapResponseTime.tsx | 38 +++++++++---------- .../common/charts/HistogramResponseTime.tsx | 36 ++++++++++-------- client/src/Pages/Settings/DummyChart.tsx | 3 +- .../StatusPage/Status/themes/bold/styles.ts | 5 ++- .../Status/themes/editorial/styles.ts | 5 ++- .../StatusPage/Status/themes/modern/styles.ts | 5 ++- .../Status/themes/refined/styles.ts | 5 ++- .../Status/themes/shared/ThemedHeatmap.tsx | 3 +- .../Status/themes/shared/ThemedHistogram.tsx | 4 +- client/src/Types/Monitor.ts | 2 + .../service/infrastructure/statusService.ts | 3 +- server/src/types/monitor.ts | 1 + 12 files changed, 62 insertions(+), 48 deletions(-) diff --git a/client/src/Components/common/charts/HeatmapResponseTime.tsx b/client/src/Components/common/charts/HeatmapResponseTime.tsx index f2aed0b7a..9beb92f37 100644 --- a/client/src/Components/common/charts/HeatmapResponseTime.tsx +++ b/client/src/Components/common/charts/HeatmapResponseTime.tsx @@ -5,7 +5,7 @@ import { getResponseColor } from "@/Utils/DataUtils"; import { HeatmapResponseTimeTooltip } from "@/Components/common/charts/HeatmapResponseTimeTooltip"; import type { SxProps } from "@mui/material/styles"; import type { ResponsiveStyleValue } from "@mui/system"; - +import { MAX_RECENT_CHECKS } from "@/Types/Monitor"; interface PlaceholderCheck { status: "placeholder"; responseTime: 0; @@ -32,11 +32,11 @@ export const HeatmapResponseTime = ({ if (!checks || checks.length === 0) return null; - const latestChecks = checks.slice(-25); + const latestChecks = checks.slice(-MAX_RECENT_CHECKS); let data: Array; - if (latestChecks.length !== 25) { - const placeholders = Array(25 - latestChecks.length).fill({ + if (latestChecks.length !== MAX_RECENT_CHECKS) { + const placeholders = Array(MAX_RECENT_CHECKS - latestChecks.length).fill({ status: "placeholder" as const, }); data = [...latestChecks, ...placeholders]; @@ -44,13 +44,13 @@ export const HeatmapResponseTime = ({ data = latestChecks; } return ( - + @@ -82,50 +82,50 @@ export const HeatmapResponseTime = ({ check={check} > diff --git a/client/src/Components/common/charts/HistogramResponseTime.tsx b/client/src/Components/common/charts/HistogramResponseTime.tsx index 1ef400daf..ba2bc12ff 100644 --- a/client/src/Components/common/charts/HistogramResponseTime.tsx +++ b/client/src/Components/common/charts/HistogramResponseTime.tsx @@ -7,6 +7,7 @@ import type { CheckSnapshot } from "@/Types/Check"; import { HeatmapResponseTimeTooltip } from "./HeatmapResponseTimeTooltip"; import { useMemo } from "react"; import { useTranslation } from "react-i18next"; +import { MAX_RECENT_CHECKS } from "@/Types/Monitor"; interface HistogramResponseTimeProps { checks: CheckSnapshot[]; @@ -57,8 +58,11 @@ export const HistogramResponseTime = ({ if (!Array.isArray(checks) || checks.length === 0) return null; const data = - checks.length !== 25 - ? [...checks, ...Array(25 - checks.length).fill({ status: "placeholder" })] + checks.length !== MAX_RECENT_CHECKS + ? [ + ...checks, + ...Array(MAX_RECENT_CHECKS - checks.length).fill({ status: "placeholder" }), + ] : checks; const chartHeight = typeof height === "number" ? `${height}px` : height; @@ -86,17 +90,17 @@ export const HistogramResponseTime = ({ {data.map((check, index) => { @@ -108,23 +112,23 @@ export const HistogramResponseTime = ({ : theme.palette.error.light; const bar = ( @@ -133,8 +137,8 @@ export const HistogramResponseTime = ({ return isPlaceholder ? ( {bar} diff --git a/client/src/Pages/Settings/DummyChart.tsx b/client/src/Pages/Settings/DummyChart.tsx index b354ee9bb..47b3e45ea 100644 --- a/client/src/Pages/Settings/DummyChart.tsx +++ b/client/src/Pages/Settings/DummyChart.tsx @@ -3,6 +3,7 @@ import Typography from "@mui/material/Typography"; import { useTheme } from "@mui/material/styles"; import { HeatmapResponseTime, HistogramResponseTime } from "@/Components/common"; import type { CheckSnapshot } from "@/Types/Check"; +import { MAX_RECENT_CHECKS } from "@/Types/Monitor"; interface DummyChartProps { chartType: string; @@ -10,7 +11,7 @@ interface DummyChartProps { const generateDummyChecks = (): CheckSnapshot[] => { const checks: CheckSnapshot[] = []; - for (let i = 0; i < 25; i++) { + for (let i = 0; i < MAX_RECENT_CHECKS; i++) { const isUp = Math.random() > 0.1; const responseTime = Math.floor(Math.random() * 80) + 20; checks.push({ diff --git a/client/src/Pages/StatusPage/Status/themes/bold/styles.ts b/client/src/Pages/StatusPage/Status/themes/bold/styles.ts index ac2eac9c0..9153ab994 100644 --- a/client/src/Pages/StatusPage/Status/themes/bold/styles.ts +++ b/client/src/Pages/StatusPage/Status/themes/bold/styles.ts @@ -2,6 +2,7 @@ import type { SxProps, Theme } from "@mui/material/styles"; import type { StatusPageThemeTokens } from "../tokens"; import { type OverallTone, toneColor, toneSoft } from "../shared/overallStatus"; import { BOLD_SANS_STACK, MONO_STACK } from "../shared/fontStacks"; +import { MAX_RECENT_CHECKS } from "@/Types/Monitor"; export type BoldHeatCell = "fast" | "med" | "slow" | "down" | "empty"; export type BoldBarKind = "up" | "down" | "empty"; @@ -297,7 +298,7 @@ export const boldStyles = ( heatmap: { padding: "0 28px 22px", display: "grid", - gridTemplateColumns: "repeat(25, 1fr)", + gridTemplateColumns: `repeat(${MAX_RECENT_CHECKS}, 1fr)`, gap: "3px", height: 48, }, @@ -313,7 +314,7 @@ export const boldStyles = ( histogram: { padding: "0 28px", display: "grid", - gridTemplateColumns: "repeat(25, 1fr)", + gridTemplateColumns: `repeat(${MAX_RECENT_CHECKS}, 1fr)`, gap: "3px", alignItems: "flex-end", height: 48, diff --git a/client/src/Pages/StatusPage/Status/themes/editorial/styles.ts b/client/src/Pages/StatusPage/Status/themes/editorial/styles.ts index bed3e72aa..9c7e1cee0 100644 --- a/client/src/Pages/StatusPage/Status/themes/editorial/styles.ts +++ b/client/src/Pages/StatusPage/Status/themes/editorial/styles.ts @@ -6,6 +6,7 @@ import { MONO_STACK, SERIF_STACK, } from "../shared/fontStacks"; +import { MAX_RECENT_CHECKS } from "@/Types/Monitor"; export type EditorialHeatCell = "fast" | "med" | "slow" | "down" | "empty"; export type EditorialBarKind = "up" | "down" | "empty"; @@ -245,7 +246,7 @@ export const editorialStyles = (tokens: StatusPageThemeTokens): EditorialStyles heatmap: { mt: "16px", display: "grid", - gridTemplateColumns: "repeat(25, 1fr)", + gridTemplateColumns: `repeat(${MAX_RECENT_CHECKS}, 1fr)`, gap: "2px", height: 40, }, @@ -257,7 +258,7 @@ export const editorialStyles = (tokens: StatusPageThemeTokens): EditorialStyles histogram: { mt: "16px", display: "grid", - gridTemplateColumns: "repeat(25, 1fr)", + gridTemplateColumns: `repeat(${MAX_RECENT_CHECKS}, 1fr)`, gap: "2px", alignItems: "flex-end", height: 40, diff --git a/client/src/Pages/StatusPage/Status/themes/modern/styles.ts b/client/src/Pages/StatusPage/Status/themes/modern/styles.ts index ad7356451..1ee3af741 100644 --- a/client/src/Pages/StatusPage/Status/themes/modern/styles.ts +++ b/client/src/Pages/StatusPage/Status/themes/modern/styles.ts @@ -3,6 +3,7 @@ import { keyframes } from "@mui/system"; import type { StatusPageThemeTokens } from "../tokens"; import { type OverallTone, toneColor, toneSoft } from "../shared/overallStatus"; import { MONO_STACK, SANS_STACK } from "../shared/fontStacks"; +import { MAX_RECENT_CHECKS } from "@/Types/Monitor"; export type ModernHeatCell = "fast" | "med" | "slow" | "down" | "empty"; export type ModernBarKind = "up" | "down" | "empty"; @@ -320,7 +321,7 @@ export const modernStyles = ( heatmap: { padding: "0 24px 20px", display: "grid", - gridTemplateColumns: "repeat(25, 1fr)", + gridTemplateColumns: `repeat(${MAX_RECENT_CHECKS}, 1fr)`, gap: "3px", height: 46, }, @@ -335,7 +336,7 @@ export const modernStyles = ( histogram: { padding: "0 24px", display: "grid", - gridTemplateColumns: "repeat(25, 1fr)", + gridTemplateColumns: `repeat(${MAX_RECENT_CHECKS}, 1fr)`, gap: "3px", alignItems: "flex-end", height: 46, diff --git a/client/src/Pages/StatusPage/Status/themes/refined/styles.ts b/client/src/Pages/StatusPage/Status/themes/refined/styles.ts index 52626b20c..dfcf95d93 100644 --- a/client/src/Pages/StatusPage/Status/themes/refined/styles.ts +++ b/client/src/Pages/StatusPage/Status/themes/refined/styles.ts @@ -6,6 +6,7 @@ import { MONO_STACK, SANS_STACK } from "../shared/fontStacks"; export type RefinedHeatCell = "fast" | "med" | "slow" | "down" | "empty"; export type RefinedBarKind = "up" | "down" | "empty"; export type RefinedGaugeFill = "ok" | "warm" | "hot"; +import { MAX_RECENT_CHECKS } from "@/Types/Monitor"; export interface RefinedStyles { page: SxProps; @@ -259,7 +260,7 @@ export const refinedStyles = (tokens: StatusPageThemeTokens): RefinedStyles => { heatmap: { padding: "0 20px 16px", display: "grid", - gridTemplateColumns: "repeat(25, 1fr)", + gridTemplateColumns: `repeat(${MAX_RECENT_CHECKS}, 1fr)`, gap: "3px", height: 42, }, @@ -274,7 +275,7 @@ export const refinedStyles = (tokens: StatusPageThemeTokens): RefinedStyles => { histogram: { padding: "0 20px", display: "grid", - gridTemplateColumns: "repeat(25, 1fr)", + gridTemplateColumns: `repeat(${MAX_RECENT_CHECKS}, 1fr)`, gap: "3px", alignItems: "flex-end", height: 42, diff --git a/client/src/Pages/StatusPage/Status/themes/shared/ThemedHeatmap.tsx b/client/src/Pages/StatusPage/Status/themes/shared/ThemedHeatmap.tsx index 165722530..58ae86d44 100644 --- a/client/src/Pages/StatusPage/Status/themes/shared/ThemedHeatmap.tsx +++ b/client/src/Pages/StatusPage/Status/themes/shared/ThemedHeatmap.tsx @@ -8,8 +8,9 @@ import { useSelector } from "react-redux"; import type { RootState } from "@/store"; import type { CheckSnapshot } from "@/Types/Check"; import { formatDateWithTz } from "@/Utils/TimeUtils"; +import { MAX_RECENT_CHECKS } from "@/Types/Monitor"; -const CELLS = 25; +const CELLS = MAX_RECENT_CHECKS; export type HeatCellKind = "fast" | "med" | "slow" | "down" | "empty"; diff --git a/client/src/Pages/StatusPage/Status/themes/shared/ThemedHistogram.tsx b/client/src/Pages/StatusPage/Status/themes/shared/ThemedHistogram.tsx index f91ad73e5..8408a22ef 100644 --- a/client/src/Pages/StatusPage/Status/themes/shared/ThemedHistogram.tsx +++ b/client/src/Pages/StatusPage/Status/themes/shared/ThemedHistogram.tsx @@ -9,8 +9,8 @@ import { useSelector } from "react-redux"; import type { RootState } from "@/store"; import type { CheckSnapshot } from "@/Types/Check"; import { formatDateWithTz } from "@/Utils/TimeUtils"; - -const CELLS = 25; +import { MAX_RECENT_CHECKS } from "@/Types/Monitor"; +const CELLS = MAX_RECENT_CHECKS; const MIN_HEIGHT_PCT = 6; export type BarKind = "up" | "down" | "empty"; diff --git a/client/src/Types/Monitor.ts b/client/src/Types/Monitor.ts index 053b517d1..3dced0a6c 100644 --- a/client/src/Types/Monitor.ts +++ b/client/src/Types/Monitor.ts @@ -3,6 +3,8 @@ import type { PageSpeedGroupedCheck } from "@/Types/Check"; import type { GeoContinent } from "@/Types/GeoCheck"; export type { GeoContinent } from "@/Types/GeoCheck"; +export const MAX_RECENT_CHECKS = 50; + export const MonitorTypes = [ "http", "ping", diff --git a/server/src/service/infrastructure/statusService.ts b/server/src/service/infrastructure/statusService.ts index e18fe4aea..617f15944 100755 --- a/server/src/service/infrastructure/statusService.ts +++ b/server/src/service/infrastructure/statusService.ts @@ -20,8 +20,9 @@ import { AppError } from "@/utils/AppError.js"; import { ILogger } from "@/utils/logger.js"; import { IBufferService } from "./bufferService.js"; import type { HardwareStatusMetrics } from "@/types/network.js"; +import { MAX_RECENT_CHECKS } from "@/types/index.js"; + const SERVICE_NAME = "StatusService"; -const MAX_RECENT_CHECKS = 25; const HARDWARE_ALERT_COUNTER_START = 5; const HARDWARE_METRIC_KEYS = ["cpu", "memory", "disk", "temp"] as const; type HardwareMetricKey = (typeof HARDWARE_METRIC_KEYS)[number]; diff --git a/server/src/types/monitor.ts b/server/src/types/monitor.ts index f29ce75d7..2c2f9c08f 100644 --- a/server/src/types/monitor.ts +++ b/server/src/types/monitor.ts @@ -14,6 +14,7 @@ export type MonitorStatus = (typeof MonitorStatuses)[number]; export const MonitorMatchMethods = ["equal", "include", "regex"] as const; export type MonitorMatchMethod = (typeof MonitorMatchMethods)[number] | ""; +export const MAX_RECENT_CHECKS = 50; export interface Monitor { id: string; From bacbf8404ee3f37b21ea7a0c9cdbb881a053d7d7 Mon Sep 17 00:00:00 2001 From: Alex Holliday Date: Thu, 30 Apr 2026 10:36:46 -0700 Subject: [PATCH 034/245] refactor tests --- server/test/unit/services/statusService.test.ts | 11 ++++++----- 1 file changed, 6 insertions(+), 5 deletions(-) diff --git a/server/test/unit/services/statusService.test.ts b/server/test/unit/services/statusService.test.ts index a17acbcd9..75323943b 100644 --- a/server/test/unit/services/statusService.test.ts +++ b/server/test/unit/services/statusService.test.ts @@ -1,6 +1,7 @@ import { describe, expect, it, jest, beforeEach } from "@jest/globals"; import { StatusService } from "../../../src/service/infrastructure/statusService.ts"; import { createMockLogger } from "../../helpers/createMockLogger.ts"; +import { MAX_RECENT_CHECKS } from "../../../src/types/index.ts"; import type { Monitor, MonitorStatus, MonitorStatusResponse, Check, HardwareStatusPayload } from "../../../src/types/index.ts"; import type { IMonitorsRepository, IMonitorStatsRepository, IChecksRepository } from "../../../src/repositories/index.ts"; import type { IBufferService } from "../../../src/service/infrastructure/bufferService.ts"; @@ -280,13 +281,13 @@ describe("StatusService", () => { false, expect.any(Object), 5, - 25, + MAX_RECENT_CHECKS, expect.objectContaining({ status: expect.any(String) }) ); }); - it("pushes check snapshot to recentChecks and trims to 25", async () => { - const existingChecks = Array.from({ length: 25 }, (_, i) => ({ id: `old-${i}` })); + it("pushes check snapshot to recentChecks and trims to MAX_RECENT_CHECKS", async () => { + const existingChecks = Array.from({ length: MAX_RECENT_CHECKS }, (_, i) => ({ id: `old-${i}` })); const monitor = makeMonitor({ recentChecks: existingChecks as any, statusWindow: [], statusWindowSize: 5 }); const { service, monitorsRepository } = createService(); (monitorsRepository.findById as jest.Mock).mockResolvedValue(monitor); @@ -294,8 +295,8 @@ describe("StatusService", () => { await service.updateMonitorStatus(makeStatusResponse(), makeCheck({ id: "new-check" })); - expect(monitor.recentChecks).toHaveLength(25); - expect(monitor.recentChecks[24]).toEqual(expect.objectContaining({ id: "new-check" })); + expect(monitor.recentChecks).toHaveLength(MAX_RECENT_CHECKS); + expect(monitor.recentChecks[MAX_RECENT_CHECKS - 1]).toEqual(expect.objectContaining({ id: "new-check" })); }); it("marks status as down when failure threshold is met", async () => { From e906b237a0c5e999daed9ed0898d19f63666ec93 Mon Sep 17 00:00:00 2001 From: Alex Holliday Date: Thu, 30 Apr 2026 10:57:01 -0700 Subject: [PATCH 035/245] mutate status page on save --- 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 d972a90c7..5ef5095ea 100644 --- a/client/src/Pages/StatusPage/Create/index.tsx +++ b/client/src/Pages/StatusPage/Create/index.tsx @@ -32,6 +32,7 @@ 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 { mutate } from "swr"; import axios from "axios"; import { HeaderConfigStatusControls } from "./Components/HeaderConfigStatusControls"; import { ThemePicker } from "./Components/ThemePicker"; @@ -49,6 +50,9 @@ interface TimezoneOption { name: string; } +const buildStatusPageKey = (slug: string | null | undefined) => + slug ? `/status-page/${slug}?type=uptime&type=infrastructure` : null; + const CreateStatusPage = () => { const theme = useTheme(); const { t } = useTranslation(); @@ -59,9 +63,7 @@ const CreateStatusPage = () => { // Fetch existing status page data when configuring const { data: statusPageData, isLoading: isLoadingStatusPage } = - useGet( - isCreate ? null : `/status-page/${url}?type=uptime&type=infrastructure` - ); + useGet(isCreate ? null : buildStatusPageKey(url)); const { data: monitorsResponse } = useGet(monitorsUrl); const monitors = monitorsResponse ?? []; @@ -181,6 +183,10 @@ const CreateStatusPage = () => { } if (result) { + await mutate(buildStatusPageKey(data.url)); + if (!isCreate && url !== data.url) { + await mutate(buildStatusPageKey(url)); + } navigate(`/status/${data.url}`); } }; From 4c04294f713edca02e961f3c6fc6b3fa4a5f756c Mon Sep 17 00:00:00 2001 From: Alex Date: Thu, 30 Apr 2026 14:42:19 -0700 Subject: [PATCH 036/245] extract duplicate code, add slot components --- client/src/Pages/StatusPage/Status/index.tsx | 70 ++++-- .../Status/themes/bold/BoldHeader.tsx | 20 ++ .../Status/themes/bold/BoldHero.tsx | 32 +++ .../Status/themes/bold/BoldStatusPage.tsx | 207 ---------------- .../themes/editorial/EditorialHeader.tsx | 36 +++ .../Status/themes/editorial/EditorialHero.tsx | 46 ++++ .../themes/editorial/EditorialStatusPage.tsx | 224 ------------------ .../Status/themes/editorial/styles.ts | 5 +- .../Status/themes/modern/ModernHeader.tsx | 24 ++ .../Status/themes/modern/ModernHero.tsx | 34 +++ .../Status/themes/refined/RefinedHeader.tsx | 29 +++ .../Status/themes/refined/RefinedHero.tsx | 34 +++ .../themes/refined/RefinedStatusPage.tsx | 210 ---------------- .../Status/themes/refined/styles.ts | 5 +- .../BaseStatusPage.tsx} | 131 ++++++---- 15 files changed, 397 insertions(+), 710 deletions(-) create mode 100644 client/src/Pages/StatusPage/Status/themes/bold/BoldHeader.tsx create mode 100644 client/src/Pages/StatusPage/Status/themes/bold/BoldHero.tsx delete mode 100644 client/src/Pages/StatusPage/Status/themes/bold/BoldStatusPage.tsx create mode 100644 client/src/Pages/StatusPage/Status/themes/editorial/EditorialHeader.tsx create mode 100644 client/src/Pages/StatusPage/Status/themes/editorial/EditorialHero.tsx delete mode 100644 client/src/Pages/StatusPage/Status/themes/editorial/EditorialStatusPage.tsx create mode 100644 client/src/Pages/StatusPage/Status/themes/modern/ModernHeader.tsx create mode 100644 client/src/Pages/StatusPage/Status/themes/modern/ModernHero.tsx create mode 100644 client/src/Pages/StatusPage/Status/themes/refined/RefinedHeader.tsx create mode 100644 client/src/Pages/StatusPage/Status/themes/refined/RefinedHero.tsx delete mode 100644 client/src/Pages/StatusPage/Status/themes/refined/RefinedStatusPage.tsx rename client/src/Pages/StatusPage/Status/themes/{modern/ModernStatusPage.tsx => shared/BaseStatusPage.tsx} (59%) diff --git a/client/src/Pages/StatusPage/Status/index.tsx b/client/src/Pages/StatusPage/Status/index.tsx index a3a535ace..59ba4f21b 100644 --- a/client/src/Pages/StatusPage/Status/index.tsx +++ b/client/src/Pages/StatusPage/Status/index.tsx @@ -8,35 +8,56 @@ import { useTranslation } from "react-i18next"; import { useIsAdmin } from "@/Hooks/useIsAdmin"; import { useLocation, useParams } from "react-router-dom"; import { useGet } from "@/Hooks/UseApi"; -import type { Monitor } from "@/Types/Monitor"; import { resolveStatusPageTheme } from "@/Types/StatusPage"; import { PUBLIC_STATUS_PAGE_PREFIX, - type StatusPage, type StatusPageResponse, type StatusPageTheme, } from "@/Types/StatusPage"; -import { HeaderStatusPageControls } from "./Components/HeaderStatusPageControls"; -import { StatusPageThemeProvider } from "./themes/StatusPageThemeProvider"; -import { RefinedStatusPage } from "./themes/refined/RefinedStatusPage"; -import { ModernStatusPage } from "./themes/modern/ModernStatusPage"; -import { BoldStatusPage } from "./themes/bold/BoldStatusPage"; -import { EditorialStatusPage } from "./themes/editorial/EditorialStatusPage"; -import { BrowserFrame } from "./themes/BrowserFrame"; +import { HeaderStatusPageControls } from "@/Pages/StatusPage/Status/Components/HeaderStatusPageControls"; +import { StatusPageThemeProvider } from "@/Pages/StatusPage/Status/themes/StatusPageThemeProvider"; +import { + BaseStatusPage, + type ThemeConfig, +} from "@/Pages/StatusPage/Status/themes/shared/BaseStatusPage"; +import { BrowserFrame } from "@/Pages/StatusPage/Status/themes/BrowserFrame"; +import { refinedStyles } from "@/Pages/StatusPage/Status/themes/refined/styles"; +import { RefinedHeader } from "@/Pages/StatusPage/Status/themes/refined/RefinedHeader"; +import { RefinedHero } from "@/Pages/StatusPage/Status/themes/refined/RefinedHero"; +import { modernStyles } from "@/Pages/StatusPage/Status/themes/modern/styles"; +import { ModernHeader } from "@/Pages/StatusPage/Status/themes/modern/ModernHeader"; +import { ModernHero } from "@/Pages/StatusPage/Status/themes/modern/ModernHero"; +import { boldStyles } from "@/Pages/StatusPage/Status/themes/bold/styles"; +import { BoldHeader } from "@/Pages/StatusPage/Status/themes/bold/BoldHeader"; +import { BoldHero } from "@/Pages/StatusPage/Status/themes/bold/BoldHero"; +import { editorialStyles } from "@/Pages/StatusPage/Status/themes/editorial/styles"; +import { EditorialHeader } from "@/Pages/StatusPage/Status/themes/editorial/EditorialHeader"; +import { EditorialHero } from "@/Pages/StatusPage/Status/themes/editorial/EditorialHero"; -type ThemedRendererProps = { - statusPage: StatusPage; - monitors: (Monitor & { checks?: Monitor["recentChecks"] })[]; -}; - -const THEMED_RENDERERS: Record< - StatusPageTheme, - React.ComponentType -> = { - refined: RefinedStatusPage, - modern: ModernStatusPage, - bold: BoldStatusPage, - editorial: EditorialStatusPage, +const THEME_CONFIGS: Record> = { + refined: { + createStyles: refinedStyles, + HeaderSlot: RefinedHeader, + HeroSlot: RefinedHero, + }, + modern: { + createStyles: modernStyles, + HeaderSlot: ModernHeader, + HeroSlot: ModernHero, + overallStatusOptions: { iconSize: 20 }, + }, + bold: { + createStyles: boldStyles, + HeaderSlot: BoldHeader, + HeroSlot: BoldHero, + overallStatusOptions: { iconSize: 18 }, + }, + editorial: { + createStyles: editorialStyles, + HeaderSlot: EditorialHeader, + HeroSlot: EditorialHero, + overallStatusOptions: { allUpKey: "pages.statusPages.editorial.allUp" }, + }, }; const StatusPageView = () => { @@ -90,11 +111,12 @@ const StatusPageView = () => { ); } - const ThemedRenderer = THEMED_RENDERERS[resolveStatusPageTheme(statusPage.theme)]; + const themeConfig = THEME_CONFIGS[resolveStatusPageTheme(statusPage.theme)]; const themedRenderer = ( - ); diff --git a/client/src/Pages/StatusPage/Status/themes/bold/BoldHeader.tsx b/client/src/Pages/StatusPage/Status/themes/bold/BoldHeader.tsx new file mode 100644 index 000000000..7b483b474 --- /dev/null +++ b/client/src/Pages/StatusPage/Status/themes/bold/BoldHeader.tsx @@ -0,0 +1,20 @@ +import Box from "@mui/material/Box"; +import { monoFirstChar } from "@/Pages/StatusPage/Status/themes/shared/overallStatus"; +import type { SlotProps } from "@/Pages/StatusPage/Status/themes/shared/BaseStatusPage"; +import type { BoldStyles } from "@/Pages/StatusPage/Status/themes/bold/styles"; + +export const BoldHeader = ({ statusPage, logoSrc, styles }: SlotProps) => ( + + {logoSrc ? ( + + ) : ( + {monoFirstChar(statusPage.companyName)} + )} + {statusPage.companyName} + +); diff --git a/client/src/Pages/StatusPage/Status/themes/bold/BoldHero.tsx b/client/src/Pages/StatusPage/Status/themes/bold/BoldHero.tsx new file mode 100644 index 000000000..3d378ddaf --- /dev/null +++ b/client/src/Pages/StatusPage/Status/themes/bold/BoldHero.tsx @@ -0,0 +1,32 @@ +import Box from "@mui/material/Box"; +import { useTranslation } from "react-i18next"; +import type { SlotProps } from "@/Pages/StatusPage/Status/themes/shared/BaseStatusPage"; +import type { BoldStyles } from "@/Pages/StatusPage/Status/themes/bold/styles"; + +export const BoldHero = ({ overall, monitorCount, styles }: SlotProps) => { + const { t } = useTranslation(); + return ( + + + + {overall.icon} + + {overall.message} + + + {t("pages.statusPages.statusBar.monitoringSummary", { + count: monitorCount, + })} + + + ); +}; diff --git a/client/src/Pages/StatusPage/Status/themes/bold/BoldStatusPage.tsx b/client/src/Pages/StatusPage/Status/themes/bold/BoldStatusPage.tsx deleted file mode 100644 index 32959a2ed..000000000 --- a/client/src/Pages/StatusPage/Status/themes/bold/BoldStatusPage.tsx +++ /dev/null @@ -1,207 +0,0 @@ -import { useMemo, useState } from "react"; -import Box from "@mui/material/Box"; -import Stack from "@mui/material/Stack"; -import { useTranslation } from "react-i18next"; -import type { Monitor } from "@/Types/Monitor"; -import type { StatusPage } from "@/Types/StatusPage"; -import { getMonitorTypeLabel } from "@/Types/StatusPage"; -import { ThemedHeatmap } from "../shared/ThemedHeatmap"; -import { ThemedHistogram } from "../shared/ThemedHistogram"; -import { ThemedInfrastructure } from "../shared/ThemedInfrastructure"; -import { - monitorBadgeTone, - monoFirstChar, - resolveOverallStatus, - statusBadgeKey, -} from "../shared/overallStatus"; -import { useStatusPageTheme } from "../StatusPageThemeProvider"; -import { boldStyles } from "./styles"; - -type StatusPageMonitor = Monitor & { checks?: Monitor["recentChecks"] }; - -interface Props { - statusPage: StatusPage; - monitors: StatusPageMonitor[]; -} - -export const BoldStatusPage = ({ statusPage, monitors }: Props) => { - const { t } = useTranslation(); - const { tokens, mode } = useStatusPageTheme(); - const styles = useMemo(() => boldStyles(tokens, mode === "dark"), [tokens, mode]); - const [chartMode, setChartMode] = useState<"heatmap" | "histogram">("heatmap"); - - const overall = resolveOverallStatus(monitors, t, { iconSize: 18 }); - const logoSrc = statusPage.logo?.data - ? `data:${statusPage.logo.contentType};base64,${statusPage.logo.data}` - : null; - - return ( - - - - {logoSrc ? ( - - ) : ( - {monoFirstChar(statusPage.companyName)} - )} - {statusPage.companyName} - - - - - - - {overall.icon} - - {overall.message} - - - {t("pages.statusPages.statusBar.monitoringSummary", { - count: monitors.length, - })} - - - - {statusPage.showCharts && ( - - - setChartMode("heatmap")} - sx={styles.chartSwitchButton(chartMode === "heatmap")} - > - {t("pages.statusPages.monitorsList.chartTypeHeatmap")} - - setChartMode("histogram")} - sx={styles.chartSwitchButton(chartMode === "histogram")} - > - {t("pages.statusPages.monitorsList.chartTypeHistogram")} - - - - )} - - - {monitors.map((monitor) => { - const isHardware = monitor.type === "hardware"; - const showInfra = isHardware && statusPage.showInfrastructure !== false; - const showChart = !isHardware && statusPage.showCharts !== false; - const badgeTone = monitorBadgeTone(monitor.status); - - return ( - - - - {monitor.name} - - - {getMonitorTypeLabel(monitor.type, t)} - - {monitor.url && ( - - {monitor.url} - - )} - - - - {t(statusBadgeKey[monitor.status])} - - - - {showInfra && ( - - )} - {showChart && - (chartMode === "heatmap" ? ( - - ) : ( - - ))} - - ); - })} - - - - {t("pages.statusPages.footer.poweredBy")}{" "} - - Checkmate - - - - ); -}; diff --git a/client/src/Pages/StatusPage/Status/themes/editorial/EditorialHeader.tsx b/client/src/Pages/StatusPage/Status/themes/editorial/EditorialHeader.tsx new file mode 100644 index 000000000..56a2a9af2 --- /dev/null +++ b/client/src/Pages/StatusPage/Status/themes/editorial/EditorialHeader.tsx @@ -0,0 +1,36 @@ +import Box from "@mui/material/Box"; +import { useTranslation } from "react-i18next"; +import type { SlotProps } from "@/Pages/StatusPage/Status/themes/shared/BaseStatusPage"; +import type { EditorialStyles } from "@/Pages/StatusPage/Status/themes/editorial/styles"; + +export const EditorialHeader = ({ + statusPage, + logoSrc, + styles, +}: SlotProps) => { + const { t } = useTranslation(); + return ( + + {logoSrc && ( + + )} + + {statusPage.companyName} + + + {t("pages.statusPages.editorial.reportTitle")} + + + ); +}; diff --git a/client/src/Pages/StatusPage/Status/themes/editorial/EditorialHero.tsx b/client/src/Pages/StatusPage/Status/themes/editorial/EditorialHero.tsx new file mode 100644 index 000000000..cd6a06406 --- /dev/null +++ b/client/src/Pages/StatusPage/Status/themes/editorial/EditorialHero.tsx @@ -0,0 +1,46 @@ +import { useMemo } from "react"; +import Box from "@mui/material/Box"; +import { useTranslation } from "react-i18next"; +import type { SlotProps } from "@/Pages/StatusPage/Status/themes/shared/BaseStatusPage"; +import type { EditorialStyles } from "@/Pages/StatusPage/Status/themes/editorial/styles"; + +export const EditorialHero = ({ + overall, + monitorCount, + styles, +}: SlotProps) => { + const { t } = useTranslation(); + const todayLabel = useMemo( + () => + new Date().toLocaleDateString(undefined, { + year: "numeric", + month: "long", + day: "numeric", + }), + [] + ); + + return ( + <> + + + {overall.message} + + + {todayLabel} ·{" "} + {t("pages.statusPages.statusBar.monitoringSummary", { + count: monitorCount, + })} + + + ); +}; diff --git a/client/src/Pages/StatusPage/Status/themes/editorial/EditorialStatusPage.tsx b/client/src/Pages/StatusPage/Status/themes/editorial/EditorialStatusPage.tsx deleted file mode 100644 index ee8fa6f99..000000000 --- a/client/src/Pages/StatusPage/Status/themes/editorial/EditorialStatusPage.tsx +++ /dev/null @@ -1,224 +0,0 @@ -import { useMemo, useState } from "react"; -import Box from "@mui/material/Box"; -import Stack from "@mui/material/Stack"; -import { useTranslation } from "react-i18next"; -import type { Monitor } from "@/Types/Monitor"; -import type { StatusPage } from "@/Types/StatusPage"; -import { getMonitorTypeLabel } from "@/Types/StatusPage"; -import { ThemedHeatmap } from "../shared/ThemedHeatmap"; -import { ThemedHistogram } from "../shared/ThemedHistogram"; -import { ThemedInfrastructure } from "../shared/ThemedInfrastructure"; -import { - monitorBadgeTone, - resolveOverallStatus, - statusBadgeKey, -} from "../shared/overallStatus"; -import { useStatusPageTheme } from "../StatusPageThemeProvider"; -import { editorialStyles } from "./styles"; - -type StatusPageMonitor = Monitor & { checks?: Monitor["recentChecks"] }; - -interface Props { - statusPage: StatusPage; - monitors: StatusPageMonitor[]; -} - -export const EditorialStatusPage = ({ statusPage, monitors }: Props) => { - const { t } = useTranslation(); - const { tokens } = useStatusPageTheme(); - const styles = useMemo(() => editorialStyles(tokens), [tokens]); - const [chartMode, setChartMode] = useState<"heatmap" | "histogram">("heatmap"); - - const todayLabel = useMemo( - () => - new Date().toLocaleDateString(undefined, { - year: "numeric", - month: "long", - day: "numeric", - }), - [] - ); - - const overall = resolveOverallStatus(monitors, t, { - allUpKey: "pages.statusPages.editorial.allUp", - }); - const logoSrc = statusPage.logo?.data - ? `data:${statusPage.logo.contentType};base64,${statusPage.logo.data}` - : null; - - return ( - - - - {logoSrc && ( - - )} - - {statusPage.companyName} - - - {t("pages.statusPages.editorial.reportTitle")} - - - - - - - {overall.message} - - - {todayLabel} ·{" "} - {t("pages.statusPages.statusBar.monitoringSummary", { - count: monitors.length, - })} - - - {statusPage.showCharts && ( - - - setChartMode("heatmap")} - sx={styles.chartSwitchButton(chartMode === "heatmap")} - > - {t("pages.statusPages.monitorsList.chartTypeHeatmap")} - - setChartMode("histogram")} - sx={styles.chartSwitchButton(chartMode === "histogram")} - > - {t("pages.statusPages.monitorsList.chartTypeHistogram")} - - - - )} - - - {monitors.map((monitor) => { - const isHardware = monitor.type === "hardware"; - const showInfra = isHardware && statusPage.showInfrastructure !== false; - const showChart = !isHardware && statusPage.showCharts !== false; - const badgeTone = monitorBadgeTone(monitor.status); - - return ( - - - - {monitor.name} - - - {getMonitorTypeLabel(monitor.type, t)} - - {monitor.url && ( - - {monitor.url} - - )} - - - - {t(statusBadgeKey[monitor.status])} - - - - {showInfra && ( - - )} - {showChart && - (chartMode === "heatmap" ? ( - - ) : ( - - ))} - - ); - })} - - - - {t("pages.statusPages.footer.poweredBy")}{" "} - - Checkmate - - - - ); -}; diff --git a/client/src/Pages/StatusPage/Status/themes/editorial/styles.ts b/client/src/Pages/StatusPage/Status/themes/editorial/styles.ts index 9c7e1cee0..57128180e 100644 --- a/client/src/Pages/StatusPage/Status/themes/editorial/styles.ts +++ b/client/src/Pages/StatusPage/Status/themes/editorial/styles.ts @@ -51,7 +51,10 @@ export interface EditorialStyles { footer: SxProps; } -export const editorialStyles = (tokens: StatusPageThemeTokens): EditorialStyles => { +export const editorialStyles = ( + tokens: StatusPageThemeTokens, + _isDark: boolean +): EditorialStyles => { const heatCellBg: Record = { fast: tokens.up, med: `color-mix(in srgb, ${tokens.up} 60%, ${tokens.bg})`, diff --git a/client/src/Pages/StatusPage/Status/themes/modern/ModernHeader.tsx b/client/src/Pages/StatusPage/Status/themes/modern/ModernHeader.tsx new file mode 100644 index 000000000..c7f01bc4e --- /dev/null +++ b/client/src/Pages/StatusPage/Status/themes/modern/ModernHeader.tsx @@ -0,0 +1,24 @@ +import Box from "@mui/material/Box"; +import { monoFirstChar } from "@/Pages/StatusPage/Status/themes/shared/overallStatus"; +import type { SlotProps } from "@/Pages/StatusPage/Status/themes/shared/BaseStatusPage"; +import type { ModernStyles } from "@/Pages/StatusPage/Status/themes/modern/styles"; + +export const ModernHeader = ({ + statusPage, + logoSrc, + styles, +}: SlotProps) => ( + + {logoSrc ? ( + + ) : ( + {monoFirstChar(statusPage.companyName)} + )} + {statusPage.companyName} + +); diff --git a/client/src/Pages/StatusPage/Status/themes/modern/ModernHero.tsx b/client/src/Pages/StatusPage/Status/themes/modern/ModernHero.tsx new file mode 100644 index 000000000..a7cf93bc7 --- /dev/null +++ b/client/src/Pages/StatusPage/Status/themes/modern/ModernHero.tsx @@ -0,0 +1,34 @@ +import Box from "@mui/material/Box"; +import { useTranslation } from "react-i18next"; +import type { SlotProps } from "@/Pages/StatusPage/Status/themes/shared/BaseStatusPage"; +import type { ModernStyles } from "@/Pages/StatusPage/Status/themes/modern/styles"; + +export const ModernHero = ({ + overall, + monitorCount, + styles, +}: SlotProps) => { + const { t } = useTranslation(); + return ( + + + + + {overall.message} + + + {t("pages.statusPages.statusBar.monitoringSummary", { + count: monitorCount, + })} + + + {overall.icon} + + ); +}; diff --git a/client/src/Pages/StatusPage/Status/themes/refined/RefinedHeader.tsx b/client/src/Pages/StatusPage/Status/themes/refined/RefinedHeader.tsx new file mode 100644 index 000000000..43e164f71 --- /dev/null +++ b/client/src/Pages/StatusPage/Status/themes/refined/RefinedHeader.tsx @@ -0,0 +1,29 @@ +import Box from "@mui/material/Box"; +import { monoFirstChar } from "@/Pages/StatusPage/Status/themes/shared/overallStatus"; +import type { SlotProps } from "@/Pages/StatusPage/Status/themes/shared/BaseStatusPage"; +import type { RefinedStyles } from "@/Pages/StatusPage/Status/themes/refined/styles"; + +export const RefinedHeader = ({ + statusPage, + logoSrc, + styles, +}: SlotProps) => ( + + {logoSrc ? ( + + ) : ( + {monoFirstChar(statusPage.companyName)} + )} + + {statusPage.companyName} + + +); diff --git a/client/src/Pages/StatusPage/Status/themes/refined/RefinedHero.tsx b/client/src/Pages/StatusPage/Status/themes/refined/RefinedHero.tsx new file mode 100644 index 000000000..c9a59ad77 --- /dev/null +++ b/client/src/Pages/StatusPage/Status/themes/refined/RefinedHero.tsx @@ -0,0 +1,34 @@ +import Box from "@mui/material/Box"; +import { useTranslation } from "react-i18next"; +import type { SlotProps } from "@/Pages/StatusPage/Status/themes/shared/BaseStatusPage"; +import type { RefinedStyles } from "@/Pages/StatusPage/Status/themes/refined/styles"; + +export const RefinedHero = ({ + overall, + monitorCount, + styles, +}: SlotProps) => { + const { t } = useTranslation(); + return ( + + + + + {overall.message} + + + {t("pages.statusPages.statusBar.monitoringSummary", { + count: monitorCount, + })} + + + {overall.icon} + + ); +}; diff --git a/client/src/Pages/StatusPage/Status/themes/refined/RefinedStatusPage.tsx b/client/src/Pages/StatusPage/Status/themes/refined/RefinedStatusPage.tsx deleted file mode 100644 index 5c5055b4c..000000000 --- a/client/src/Pages/StatusPage/Status/themes/refined/RefinedStatusPage.tsx +++ /dev/null @@ -1,210 +0,0 @@ -import { useMemo, useState } from "react"; -import Box from "@mui/material/Box"; -import Stack from "@mui/material/Stack"; -import { useTranslation } from "react-i18next"; -import type { Monitor } from "@/Types/Monitor"; -import type { StatusPage } from "@/Types/StatusPage"; -import { getMonitorTypeLabel } from "@/Types/StatusPage"; -import { ThemedHeatmap } from "../shared/ThemedHeatmap"; -import { ThemedHistogram } from "../shared/ThemedHistogram"; -import { ThemedInfrastructure } from "../shared/ThemedInfrastructure"; -import { - monitorBadgeTone, - monoFirstChar, - resolveOverallStatus, - statusBadgeKey, -} from "../shared/overallStatus"; -import { useStatusPageTheme } from "../StatusPageThemeProvider"; -import { refinedStyles } from "./styles"; - -type StatusPageMonitor = Monitor & { checks?: Monitor["recentChecks"] }; - -interface Props { - statusPage: StatusPage; - monitors: StatusPageMonitor[]; -} - -export const RefinedStatusPage = ({ statusPage, monitors }: Props) => { - const { t } = useTranslation(); - const { tokens } = useStatusPageTheme(); - const styles = useMemo(() => refinedStyles(tokens), [tokens]); - const [chartMode, setChartMode] = useState<"heatmap" | "histogram">("heatmap"); - - const overall = resolveOverallStatus(monitors, t); - const logoSrc = statusPage.logo?.data - ? `data:${statusPage.logo.contentType};base64,${statusPage.logo.data}` - : null; - - return ( - - - - {logoSrc ? ( - - ) : ( - {monoFirstChar(statusPage.companyName)} - )} - - {statusPage.companyName} - - - - - - - - - {overall.message} - - - {t("pages.statusPages.statusBar.monitoringSummary", { - count: monitors.length, - })} - - - {overall.icon} - - - {statusPage.showCharts && ( - - - setChartMode("heatmap")} - sx={styles.chartSwitchButton(chartMode === "heatmap")} - > - {t("pages.statusPages.monitorsList.chartTypeHeatmap")} - - setChartMode("histogram")} - sx={styles.chartSwitchButton(chartMode === "histogram")} - > - {t("pages.statusPages.monitorsList.chartTypeHistogram")} - - - - )} - - - {monitors.map((monitor) => { - const isHardware = monitor.type === "hardware"; - const showInfra = isHardware && statusPage.showInfrastructure !== false; - const showChart = !isHardware && statusPage.showCharts !== false; - const badgeTone = monitorBadgeTone(monitor.status); - - return ( - - - - {monitor.name} - - - {getMonitorTypeLabel(monitor.type, t)} - - {monitor.url && ( - - {monitor.url} - - )} - - - - {t(statusBadgeKey[monitor.status])} - - - - {showInfra && ( - - )} - {showChart && - (chartMode === "heatmap" ? ( - - ) : ( - - ))} - - ); - })} - - - - {t("pages.statusPages.footer.poweredBy")}{" "} - - Checkmate - - - - ); -}; diff --git a/client/src/Pages/StatusPage/Status/themes/refined/styles.ts b/client/src/Pages/StatusPage/Status/themes/refined/styles.ts index dfcf95d93..a6a52c021 100644 --- a/client/src/Pages/StatusPage/Status/themes/refined/styles.ts +++ b/client/src/Pages/StatusPage/Status/themes/refined/styles.ts @@ -63,7 +63,10 @@ const pillBase = { fontWeight: 600, }; -export const refinedStyles = (tokens: StatusPageThemeTokens): RefinedStyles => { +export const refinedStyles = ( + tokens: StatusPageThemeTokens, + _isDark: boolean +): RefinedStyles => { const heatCellBg: Record = { fast: tokens.up, med: `color-mix(in srgb, ${tokens.up} 60%, #ffffff 40%)`, diff --git a/client/src/Pages/StatusPage/Status/themes/modern/ModernStatusPage.tsx b/client/src/Pages/StatusPage/Status/themes/shared/BaseStatusPage.tsx similarity index 59% rename from client/src/Pages/StatusPage/Status/themes/modern/ModernStatusPage.tsx rename to client/src/Pages/StatusPage/Status/themes/shared/BaseStatusPage.tsx index 461c5e0f5..71aa85a89 100644 --- a/client/src/Pages/StatusPage/Status/themes/modern/ModernStatusPage.tsx +++ b/client/src/Pages/StatusPage/Status/themes/shared/BaseStatusPage.tsx @@ -2,80 +2,125 @@ import { useMemo, useState } from "react"; import Box from "@mui/material/Box"; import Stack from "@mui/material/Stack"; import { useTranslation } from "react-i18next"; +import type { SxProps, Theme } from "@mui/material/styles"; import type { Monitor } from "@/Types/Monitor"; import type { StatusPage } from "@/Types/StatusPage"; import { getMonitorTypeLabel } from "@/Types/StatusPage"; -import { ThemedHeatmap } from "../shared/ThemedHeatmap"; -import { ThemedHistogram } from "../shared/ThemedHistogram"; -import { ThemedInfrastructure } from "../shared/ThemedInfrastructure"; +import type { StatusPageThemeTokens } from "@/Pages/StatusPage/Status/themes/tokens"; import { + ThemedHeatmap, + type HeatCellKind, +} from "@/Pages/StatusPage/Status/themes/shared/ThemedHeatmap"; +import { + ThemedHistogram, + type BarKind, +} from "@/Pages/StatusPage/Status/themes/shared/ThemedHistogram"; +import { + ThemedInfrastructure, + type GaugeFillLevel, +} from "@/Pages/StatusPage/Status/themes/shared/ThemedInfrastructure"; +import { + type OverallStatus, + type OverallTone, monitorBadgeTone, - monoFirstChar, resolveOverallStatus, statusBadgeKey, -} from "../shared/overallStatus"; -import { useStatusPageTheme } from "../StatusPageThemeProvider"; -import { modernStyles } from "./styles"; +} from "@/Pages/StatusPage/Status/themes/shared/overallStatus"; +import { useStatusPageTheme } from "@/Pages/StatusPage/Status/themes/StatusPageThemeProvider"; type StatusPageMonitor = Monitor & { checks?: Monitor["recentChecks"] }; +export interface BaseStyles { + page: SxProps; + top: SxProps; + chartSwitchWrap: SxProps; + chartSwitch: SxProps; + chartSwitchButton: (active: boolean) => SxProps; + monitorList: SxProps; + card: SxProps; + cardRow: SxProps; + cardLeft: SxProps; + monitorName: SxProps; + monitorMeta: SxProps; + pill: SxProps; + pillHardware: SxProps; + monitorUrl: SxProps; + badge: (tone: OverallTone) => SxProps; + heatmap: SxProps; + heatmapCell: (kind: HeatCellKind) => SxProps; + histogram: SxProps; + bar: (kind: BarKind, heightPct: number) => SxProps; + chartStats: SxProps; + infra: SxProps; + infraEmpty: SxProps; + gauge: SxProps; + gaugeLabel: SxProps; + gaugeValue: SxProps; + gaugeBar: SxProps; + gaugeFill: (level: GaugeFillLevel, widthPct: number) => SxProps; + gaugeSub: SxProps; + footer: SxProps; +} + +export interface SlotProps { + statusPage: StatusPage; + logoSrc: string | null; + overall: OverallStatus; + monitorCount: number; + styles: S; +} + +export interface ThemeConfig { + createStyles: (tokens: StatusPageThemeTokens, isDark: boolean) => S; + HeaderSlot: React.ComponentType>; + HeroSlot: React.ComponentType>; + overallStatusOptions?: { iconSize?: number; allUpKey?: string }; +} + interface Props { statusPage: StatusPage; monitors: StatusPageMonitor[]; + config: ThemeConfig; } -export const ModernStatusPage = ({ statusPage, monitors }: Props) => { +export const BaseStatusPage = ({ statusPage, monitors, config }: Props) => { const { t } = useTranslation(); const { tokens, mode } = useStatusPageTheme(); - const styles = useMemo(() => modernStyles(tokens, mode === "dark"), [tokens, mode]); + const styles = useMemo( + () => config.createStyles(tokens, mode === "dark"), + [config, tokens, mode] + ); const [chartMode, setChartMode] = useState<"heatmap" | "histogram">("heatmap"); - const overall = resolveOverallStatus(monitors, t, { iconSize: 20 }); + const overall = resolveOverallStatus(monitors, t, config.overallStatusOptions); const logoSrc = statusPage.logo?.data ? `data:${statusPage.logo.contentType};base64,${statusPage.logo.data}` : null; + const { HeaderSlot, HeroSlot } = config; + return ( - - {logoSrc ? ( - - ) : ( - {monoFirstChar(statusPage.companyName)} - )} - {statusPage.companyName} - + - - - - - {overall.message} - - - {t("pages.statusPages.statusBar.monitoringSummary", { - count: monitors.length, - })} - - - {overall.icon} - + {statusPage.showCharts && ( From 28baeaf21891d1cecb95bbe93383795752076de2 Mon Sep 17 00:00:00 2001 From: Gorkem Cetin <167266851+gorkem-bwl@users.noreply.github.com> Date: Thu, 30 Apr 2026 20:14:25 -0400 Subject: [PATCH 037/245] Remove OpenSSF Best Practices badge Removed OpenSSF Best Practices badge from README. --- README.md | 1 - 1 file changed, 1 deletion(-) diff --git a/README.md b/README.md index 254d81ee8..135d3fa6e 100644 --- a/README.md +++ b/README.md @@ -7,7 +7,6 @@ ![](https://img.shields.io/github/languages/top/bluewave-labs/checkmate) ![](https://img.shields.io/github/issues/bluewave-labs/checkmate) ![](https://img.shields.io/github/issues-pr/bluewave-labs/checkmate) -[![OpenSSF Best Practices](https://www.bestpractices.dev/projects/9901/badge)](https://www.bestpractices.dev/projects/9901) [![Ask DeepWiki](https://deepwiki.com/badge.svg)](https://deepwiki.com/bluewave-labs/checkmate)

Checkmate

From 094def117ad45c6b8b87df0d10588b5adc4d7f1e Mon Sep 17 00:00:00 2001 From: Retr01-sudo Date: Fri, 1 May 2026 19:42:51 +0530 Subject: [PATCH 038/245] feat(monitors): add bulk pause/resume api endpoints and service logic - Validates bulk monitor IDs using zod - Implements optimized Mongo $in/$ne queries for performance - Orchestrates background job pausing/resuming via BullMQ - Added comprehensive unit tests for monitor service logic Fixes #3602 --- server/src/controllers/monitorController.ts | 24 ++++- .../monitors/IMonitorsRepository.ts | 1 + .../monitors/MongoMonitorsRepository.ts | 25 +++++ server/src/routes/monitorRoute.ts | 1 + server/src/service/business/monitorService.ts | 17 ++++ server/src/validation/monitorValidation.ts | 8 ++ .../test/unit/services/monitorService.test.ts | 91 +++++++++++++++++++ 7 files changed, 166 insertions(+), 1 deletion(-) diff --git a/server/src/controllers/monitorController.ts b/server/src/controllers/monitorController.ts index 7f1136b7c..4529f972d 100644 --- a/server/src/controllers/monitorController.ts +++ b/server/src/controllers/monitorController.ts @@ -14,12 +14,12 @@ import { getUptimeDetailsByIdParamValidation, getUptimeDetailsByIdQueryValidation, importMonitorsBodyValidation, + bulkPauseMonitorBodyValidation, } from "@/validation/monitorValidation.js"; import sslChecker from "ssl-checker"; import { fetchMonitorCertificate, requireTeamId, requireUserId } from "@/controllers/controllerUtils.js"; import { AppError } from "@/utils/AppError.js"; import { IMonitorService, INotificationsService } from "@/service/index.js"; -import { GeoContinent } from "@/types/geoCheck.js"; const SERVICE_NAME = "monitorController"; @@ -36,6 +36,7 @@ export interface IMonitorController { deleteAllMonitors: (req: Request, res: Response, next: NextFunction) => Promise; editMonitor: (req: Request, res: Response, next: NextFunction) => Promise; pauseMonitor: (req: Request, res: Response, next: NextFunction) => Promise; + bulkPauseMonitors: (req: Request, res: Response, next: NextFunction) => Promise; addDemoMonitors: (req: Request, res: Response, next: NextFunction) => Promise; getMonitorsByTeamId: (req: Request, res: Response, next: NextFunction) => Promise; getMonitorsWithChecksByTeamId: (req: Request, res: Response, next: NextFunction) => Promise; @@ -307,6 +308,27 @@ class MonitorController implements IMonitorController { } }; + bulkPauseMonitors = async (req: Request, res: Response, next: NextFunction) => { + try { + const validatedBody = bulkPauseMonitorBodyValidation.parse(req.body); + + const { monitorIds, pause } = validatedBody; + const teamId = requireTeamId(req.user?.teamId); + + const monitors = await this.monitorService.bulkPauseMonitors({ teamId, monitorIds, pause }); + + const action = pause ? "paused" : "resumed"; + + return res.status(200).json({ + success: true, + msg: `${monitors.length} monitor(s) ${action} successfully`, + data: monitors, + }); + } catch (error) { + next(error); + } + }; + addDemoMonitors = async (req: Request, res: Response, next: NextFunction) => { try { const id = requireUserId(req.user?.id); diff --git a/server/src/repositories/monitors/IMonitorsRepository.ts b/server/src/repositories/monitors/IMonitorsRepository.ts index 941556589..ac4c8f9eb 100644 --- a/server/src/repositories/monitors/IMonitorsRepository.ts +++ b/server/src/repositories/monitors/IMonitorsRepository.ts @@ -39,6 +39,7 @@ export interface IMonitorsRepository { statusPatch?: Partial ): Promise; togglePauseById(monitorId: string, teamId: string): Promise; + bulkTogglePause(monitorIds: string[], teamId: string, pause: boolean): Promise; // delete deleteById(monitorId: string, teamId: string): Promise; deleteByTeamId(teamId: string): Promise<{ monitors: Monitor[]; deletedCount: number }>; diff --git a/server/src/repositories/monitors/MongoMonitorsRepository.ts b/server/src/repositories/monitors/MongoMonitorsRepository.ts index bd6d506d5..9f5efecfe 100644 --- a/server/src/repositories/monitors/MongoMonitorsRepository.ts +++ b/server/src/repositories/monitors/MongoMonitorsRepository.ts @@ -234,6 +234,31 @@ class MongoMonitorsRepository implements IMonitorsRepository { return this.toEntity(monitor); }; + bulkTogglePause = async (monitorIds: string[], teamId: string, pause: boolean): Promise => { + const objectIds = monitorIds.map((id) => new mongoose.Types.ObjectId(id)); + const filter = { + _id: { $in: objectIds }, + teamId: new mongoose.Types.ObjectId(teamId), + isActive: pause, // Only pause if active (true), only resume if inactive (false) + }; + + const eligibleIds = (await MonitorModel.find(filter).select("_id").lean()).map((doc) => doc._id); + if (eligibleIds.length === 0) return []; + + await MonitorModel.updateMany( + { _id: { $in: eligibleIds } }, + { + $set: { + isActive: !pause, + status: pause ? "paused" : "initializing", + }, + } + ); + + const updatedMonitors = await MonitorModel.find({ _id: { $in: eligibleIds } }); + return this.mapDocuments(updatedMonitors); + }; + deleteById = async (monitorId: string, teamId: string) => { const deletedMonitor = await MonitorModel.findOneAndDelete({ _id: monitorId, teamId }); diff --git a/server/src/routes/monitorRoute.ts b/server/src/routes/monitorRoute.ts index 277cd250b..0b5e24dd0 100755 --- a/server/src/routes/monitorRoute.ts +++ b/server/src/routes/monitorRoute.ts @@ -31,6 +31,7 @@ class MonitorRoutes { // General monitor routes this.router.post("/pause/:monitorId", isAllowed(["admin", "superadmin"]), this.monitorController.pauseMonitor); + this.router.post("/bulk/pause", isAllowed(["admin", "superadmin"]), this.monitorController.bulkPauseMonitors); // Util routes this.router.get("/certificate/:monitorId", (req, res, next) => { diff --git a/server/src/service/business/monitorService.ts b/server/src/service/business/monitorService.ts index 4ab5c9476..e2430420d 100644 --- a/server/src/service/business/monitorService.ts +++ b/server/src/service/business/monitorService.ts @@ -73,6 +73,7 @@ export interface IMonitorService { // update editMonitor(args: { teamId: string; monitorId: string; body: Partial }): Promise; pauseMonitor(args: { teamId: string; monitorId: string }): Promise; + bulkPauseMonitors(args: { teamId: string; monitorIds: string[]; pause: boolean }): Promise; // delete deleteMonitor(args: { teamId: string; monitorId: string }): Promise; @@ -462,6 +463,22 @@ export class MonitorService implements IMonitorService { return monitor; }; + bulkPauseMonitors = async ({ teamId, monitorIds, pause }: { teamId: string; monitorIds: string[]; pause: boolean }): Promise => { + const monitors = await this.monitorsRepository.bulkTogglePause(monitorIds, teamId, pause); + + await Promise.all( + monitors.map(async (monitor) => { + if (monitor.isActive) { + await this.jobQueue.resumeJob(monitor); + } else { + await this.jobQueue.pauseJob(monitor); + } + }) + ); + + return monitors; + }; + 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: unknown) => { diff --git a/server/src/validation/monitorValidation.ts b/server/src/validation/monitorValidation.ts index df000ecef..48b1da34a 100644 --- a/server/src/validation/monitorValidation.ts +++ b/server/src/validation/monitorValidation.ts @@ -113,6 +113,14 @@ export const pauseMonitorParamValidation = z.object({ monitorId: z.string().min(1, "Monitor ID is required"), }); +export const bulkPauseMonitorBodyValidation = z.object({ + monitorIds: z + .array(z.string().min(1, "Monitor ID must not be empty")) + .min(1, "At least one monitor ID is required") + .max(100, "Cannot bulk update more than 100 monitors at once"), + pause: z.boolean(), +}); + export const getUptimeDetailsByIdParamValidation = z.object({ monitorId: z.string().min(1, "Monitor ID is required"), }); diff --git a/server/test/unit/services/monitorService.test.ts b/server/test/unit/services/monitorService.test.ts index cd424acf9..74a0efa98 100644 --- a/server/test/unit/services/monitorService.test.ts +++ b/server/test/unit/services/monitorService.test.ts @@ -23,6 +23,7 @@ const createMonitorsRepositoryMock = () => updateById: jest.fn(), updateNotifications: jest.fn(), togglePauseById: jest.fn(), + bulkTogglePause: jest.fn(), deleteById: jest.fn(), deleteByTeamId: jest.fn(), }) as unknown as IMonitorsRepository; @@ -839,6 +840,96 @@ describe("MonitorService", () => { }); }); + describe("bulkPauseMonitors", () => { + it("resumes jobs for monitors that are active after bulk toggle", async () => { + const monitorsRepository = createMonitorsRepositoryMock(); + const monitors = [makeMonitor({ id: "m1", isActive: true }), makeMonitor({ id: "m2", isActive: true })]; + (monitorsRepository.bulkTogglePause as jest.Mock).mockResolvedValue(monitors); + const { service, jobQueue } = createService({ monitorsRepository }); + + const result = await service.bulkPauseMonitors({ + teamId: TEAM_ID, + monitorIds: ["m1", "m2"], + pause: false, + }); + + expect(result).toHaveLength(2); + expect(monitorsRepository.bulkTogglePause).toHaveBeenCalledWith(["m1", "m2"], TEAM_ID, false); + expect(jobQueue.resumeJob).toHaveBeenCalledTimes(2); + expect(jobQueue.pauseJob).not.toHaveBeenCalled(); + }); + + it("pauses jobs for monitors that are inactive after bulk toggle", async () => { + const monitorsRepository = createMonitorsRepositoryMock(); + const monitors = [makeMonitor({ id: "m1", isActive: false }), makeMonitor({ id: "m2", isActive: false })]; + (monitorsRepository.bulkTogglePause as jest.Mock).mockResolvedValue(monitors); + const { service, jobQueue } = createService({ monitorsRepository }); + + const result = await service.bulkPauseMonitors({ + teamId: TEAM_ID, + monitorIds: ["m1", "m2"], + pause: true, + }); + + expect(result).toHaveLength(2); + expect(monitorsRepository.bulkTogglePause).toHaveBeenCalledWith(["m1", "m2"], TEAM_ID, true); + expect(jobQueue.pauseJob).toHaveBeenCalledTimes(2); + expect(jobQueue.resumeJob).not.toHaveBeenCalled(); + }); + + it("handles mixed active/inactive monitors correctly", async () => { + const monitorsRepository = createMonitorsRepositoryMock(); + const activeMonitor = makeMonitor({ id: "m1", isActive: true }); + const pausedMonitor = makeMonitor({ id: "m2", isActive: false }); + (monitorsRepository.bulkTogglePause as jest.Mock).mockResolvedValue([activeMonitor, pausedMonitor]); + const { service, jobQueue } = createService({ monitorsRepository }); + + const result = await service.bulkPauseMonitors({ + teamId: TEAM_ID, + monitorIds: ["m1", "m2"], + pause: true, + }); + + expect(result).toHaveLength(2); + expect(jobQueue.resumeJob).toHaveBeenCalledTimes(1); + expect(jobQueue.resumeJob).toHaveBeenCalledWith(activeMonitor); + expect(jobQueue.pauseJob).toHaveBeenCalledTimes(1); + expect(jobQueue.pauseJob).toHaveBeenCalledWith(pausedMonitor); + }); + + it("returns empty array when no monitors were affected", async () => { + const monitorsRepository = createMonitorsRepositoryMock(); + (monitorsRepository.bulkTogglePause as jest.Mock).mockResolvedValue([]); + const { service, jobQueue } = createService({ monitorsRepository }); + + const result = await service.bulkPauseMonitors({ + teamId: TEAM_ID, + monitorIds: ["m1", "m2"], + pause: true, + }); + + expect(result).toHaveLength(0); + expect(jobQueue.pauseJob).not.toHaveBeenCalled(); + expect(jobQueue.resumeJob).not.toHaveBeenCalled(); + }); + + it("works with a single monitor", async () => { + const monitorsRepository = createMonitorsRepositoryMock(); + const monitor = makeMonitor({ id: "m1", isActive: false }); + (monitorsRepository.bulkTogglePause as jest.Mock).mockResolvedValue([monitor]); + const { service, jobQueue } = createService({ monitorsRepository }); + + const result = await service.bulkPauseMonitors({ + teamId: TEAM_ID, + monitorIds: ["m1"], + pause: true, + }); + + expect(result).toHaveLength(1); + expect(jobQueue.pauseJob).toHaveBeenCalledWith(monitor); + }); + }); + describe("deleteMonitor", () => { it("deletes monitor and all associated data", async () => { const monitorsRepository = createMonitorsRepositoryMock(); From 6b9759e455f4f0b0f5eba375f33ffe117c1f6b7e Mon Sep 17 00:00:00 2001 From: Retr01-sudo Date: Fri, 1 May 2026 19:43:27 +0530 Subject: [PATCH 039/245] feat(monitors): create bulk actions ui components and selection hooks - Extracts generic table selection logic into useTableSelection hook - Creates BulkActionsBar component with Collapse animations - Implements useBulkMonitorActions to handle API logic and toasts - Adds i18n translation strings across all 16 locales Fixes #3602 --- .../Components/monitors/BulkActionsBar.tsx | 78 +++++++++++++++++++ client/src/Components/monitors/index.tsx | 1 + client/src/Hooks/useBulkMonitorActions.ts | 62 +++++++++++++++ client/src/Hooks/useTableSelection.ts | 74 ++++++++++++++++++ client/src/locales/ar.json | 5 +- client/src/locales/cs.json | 5 +- client/src/locales/de.json | 5 +- client/src/locales/en.json | 5 +- client/src/locales/es.json | 5 +- client/src/locales/fi.json | 5 +- client/src/locales/fr.json | 5 +- client/src/locales/ja.json | 5 +- client/src/locales/pt-BR.json | 5 +- client/src/locales/ru.json | 5 +- client/src/locales/th.json | 5 +- client/src/locales/tr.json | 5 +- client/src/locales/uk.json | 5 +- client/src/locales/vi.json | 5 +- client/src/locales/zh-CN.json | 5 +- client/src/locales/zh-TW.json | 5 +- 20 files changed, 279 insertions(+), 16 deletions(-) create mode 100644 client/src/Components/monitors/BulkActionsBar.tsx create mode 100644 client/src/Hooks/useBulkMonitorActions.ts create mode 100644 client/src/Hooks/useTableSelection.ts diff --git a/client/src/Components/monitors/BulkActionsBar.tsx b/client/src/Components/monitors/BulkActionsBar.tsx new file mode 100644 index 000000000..aef5e4871 --- /dev/null +++ b/client/src/Components/monitors/BulkActionsBar.tsx @@ -0,0 +1,78 @@ +import Box from "@mui/material/Box"; +import Stack from "@mui/material/Stack"; +import Typography from "@mui/material/Typography"; +import Button from "@mui/material/Button"; +import Collapse from "@mui/material/Collapse"; +import { Play, Pause } from "lucide-react"; + +import { useTranslation } from "react-i18next"; +import { useTheme } from "@mui/material/styles"; + +interface BulkActionsBarProps { + selectedCount: number; + onResume: () => void; + onPause: () => void; + onCancel: () => void; +} + +export const BulkActionsBar = ({ + selectedCount, + onResume, + onPause, + onCancel, +}: BulkActionsBarProps) => { + const { t } = useTranslation(); + const theme = useTheme(); + + return ( + 0}> + + + + {t("pages.common.monitors.actions.bulkSelected", { + count: selectedCount, + })} + + + + + + + + + + ); +}; diff --git a/client/src/Components/monitors/index.tsx b/client/src/Components/monitors/index.tsx index efba79a08..46532706f 100644 --- a/client/src/Components/monitors/index.tsx +++ b/client/src/Components/monitors/index.tsx @@ -14,3 +14,4 @@ export * from "./charts/HistogramPageSpeedDetails"; export * from "./charts/HistogramPageSpeedDetailsTooltip"; export * from "./charts/HistogramInfrastructure"; export * from "./HeaderMonitorsSummary"; +export * from "./BulkActionsBar"; diff --git a/client/src/Hooks/useBulkMonitorActions.ts b/client/src/Hooks/useBulkMonitorActions.ts new file mode 100644 index 000000000..3bb520aa1 --- /dev/null +++ b/client/src/Hooks/useBulkMonitorActions.ts @@ -0,0 +1,62 @@ +import { useState, useEffect } from "react"; +import { usePost } from "@/Hooks/UseApi"; + +interface BulkPauseBody { + monitorIds: string[]; + pause: boolean; +} + +interface UseBulkMonitorActionsReturn { + selectedRows: string[]; + setSelectedRows: (rows: string[]) => void; + handleBulkPause: () => Promise; + handleBulkResume: () => Promise; + handleCancelSelection: () => void; +} + +export const useBulkMonitorActions = ( + refetch: () => void, + page?: number +): UseBulkMonitorActionsReturn => { + const [selectedRows, setSelectedRows] = useState([]); + const { post: bulkPost } = usePost(); + + // Clear selection when page changes + useEffect(() => { + setSelectedRows([]); + }, [page]); + + const handleBulkPause = async () => { + const result = await bulkPost("/monitors/bulk/pause", { + monitorIds: selectedRows, + pause: true, + }); + if (result) { + setSelectedRows([]); + refetch(); + } + }; + + const handleBulkResume = async () => { + const result = await bulkPost("/monitors/bulk/pause", { + monitorIds: selectedRows, + pause: false, + }); + if (result) { + setSelectedRows([]); + refetch(); + } + }; + + const handleCancelSelection = () => { + setSelectedRows([]); + }; + + return { + selectedRows, + setSelectedRows, + handleBulkPause, + handleBulkResume, + handleCancelSelection, + }; +}; diff --git a/client/src/Hooks/useTableSelection.ts b/client/src/Hooks/useTableSelection.ts new file mode 100644 index 000000000..890225ade --- /dev/null +++ b/client/src/Hooks/useTableSelection.ts @@ -0,0 +1,74 @@ +import { useMemo, useCallback } from "react"; + +interface SelectableItem { + id: string; +} + +interface UseTableSelectionReturn { + isAllSelected: boolean; + isSomeSelected: boolean; + handleSelectAll: (checked: boolean) => void; + handleSelectRow: (itemId: string, checked: boolean) => void; + isRowSelected: (itemId: string) => boolean; +} + +export const useTableSelection = ( + items: SelectableItem[], + selectedIds: string[], + onSelectionChange?: (selected: string[]) => void +): UseTableSelectionReturn => { + const selectedSet = useMemo(() => new Set(selectedIds), [selectedIds]); + + const isAllSelected = useMemo( + () => + items.length > 0 && + items.every((item) => selectedSet.has(item.id)) && + selectedIds.length >= items.length, + [items, selectedSet, selectedIds.length] + ); + + const isSomeSelected = useMemo( + () => selectedIds.length > 0 && !isAllSelected, + [selectedIds.length, isAllSelected] + ); + + const handleSelectAll = useCallback( + (checked: boolean) => { + if (onSelectionChange) { + if (checked) { + const allIds = items.map((item) => item.id); + onSelectionChange(allIds); + } else { + onSelectionChange([]); + } + } + }, + [items, onSelectionChange] + ); + + const handleSelectRow = useCallback( + (itemId: string, checked: boolean) => { + if (onSelectionChange) { + if (checked) { + onSelectionChange([...selectedIds, itemId]); + } else { + onSelectionChange(selectedIds.filter((id) => id !== itemId)); + } + } + }, + [selectedIds, onSelectionChange] + ); + + const isRowSelected = useCallback( + (itemId: string) => selectedSet.has(itemId), + [selectedSet] + ); + + return { + isAllSelected, + isSomeSelected, + handleSelectAll, + handleSelectRow, + isRowSelected, + }; +}; diff --git a/client/src/locales/ar.json b/client/src/locales/ar.json index 11fa81309..70509feed 100644 --- a/client/src/locales/ar.json +++ b/client/src/locales/ar.json @@ -421,7 +421,10 @@ "inviteMember": "دعوة عضو", "openSite": "فتح الموقع", "pause": "إيقاف مؤقت", - "resume": "استئناف" + "resume": "استئناف", + "bulkSelected": "تم تحديد {{count}} جهاز(أجهزة) مراقبة", + "selectAll": "تحديد جميع أجهزة المراقبة", + "selectMonitor": "تحديد {{name}}" }, "statBoxes": { "activeFor": "نشط منذ", diff --git a/client/src/locales/cs.json b/client/src/locales/cs.json index 454f0b673..e86677c29 100644 --- a/client/src/locales/cs.json +++ b/client/src/locales/cs.json @@ -421,7 +421,10 @@ "inviteMember": "Pozvat člena", "openSite": "Otevřít stránku", "pause": "Pozastavit", - "resume": "Obnovit" + "resume": "Obnovit", + "bulkSelected": "{{count}} monitor(ů) vybráno", + "selectAll": "Vybrat všechny monitory", + "selectMonitor": "Vybrat {{name}}" }, "statBoxes": { "activeFor": "Aktivní po dobu", diff --git a/client/src/locales/de.json b/client/src/locales/de.json index 8090aa5f3..cd93c4810 100644 --- a/client/src/locales/de.json +++ b/client/src/locales/de.json @@ -421,7 +421,10 @@ "inviteMember": "Mitglied einladen", "openSite": "Seite öffnen", "pause": "Pausieren", - "resume": "Fortsetzen" + "resume": "Fortsetzen", + "bulkSelected": "{{count}} Monitor(e) ausgewählt", + "selectAll": "Alle Monitore auswählen", + "selectMonitor": "{{name}} auswählen" }, "statBoxes": { "activeFor": "Aktiv seit", diff --git a/client/src/locales/en.json b/client/src/locales/en.json index d6e8bb4f3..2e22baa07 100644 --- a/client/src/locales/en.json +++ b/client/src/locales/en.json @@ -421,7 +421,10 @@ "inviteMember": "Invite member", "openSite": "Open site", "pause": "Pause", - "resume": "Resume" + "resume": "Resume", + "bulkSelected": "{{count}} Monitor(s) selected", + "selectAll": "Select all monitors", + "selectMonitor": "Select {{name}}" }, "statBoxes": { "activeFor": "Active for", diff --git a/client/src/locales/es.json b/client/src/locales/es.json index 506c317e3..04f447e40 100644 --- a/client/src/locales/es.json +++ b/client/src/locales/es.json @@ -428,7 +428,10 @@ "inviteMember": "Invitar miembro", "openSite": "Abrir sitio", "pause": "Pausar", - "resume": "Reanudar" + "resume": "Reanudar", + "bulkSelected": "{{count}} monitor(es) seleccionado(s)", + "selectAll": "Seleccionar todos los monitores", + "selectMonitor": "Seleccionar {{name}}" }, "statBoxes": { "activeFor": "Activo desde hace", diff --git a/client/src/locales/fi.json b/client/src/locales/fi.json index 6238837f2..74afedd84 100644 --- a/client/src/locales/fi.json +++ b/client/src/locales/fi.json @@ -421,7 +421,10 @@ "inviteMember": "Kutsu jäsen", "openSite": "Avaa sivusto", "pause": "Keskeytä", - "resume": "Jatka" + "resume": "Jatka", + "bulkSelected": "{{count}} monitori(a) valittu", + "selectAll": "Valitse kaikki monitorit", + "selectMonitor": "Valitse {{name}}" }, "statBoxes": { "activeFor": "Aktiivinen", diff --git a/client/src/locales/fr.json b/client/src/locales/fr.json index 306a2bd61..8e3c78b85 100644 --- a/client/src/locales/fr.json +++ b/client/src/locales/fr.json @@ -421,7 +421,10 @@ "inviteMember": "Inviter un membre", "openSite": "Ouvrir le site", "pause": "Pause", - "resume": "Reprendre" + "resume": "Reprendre", + "bulkSelected": "{{count}} moniteur(s) sélectionné(s)", + "selectAll": "Sélectionner tous les moniteurs", + "selectMonitor": "Sélectionner {{name}}" }, "statBoxes": { "activeFor": "Actif depuis", diff --git a/client/src/locales/ja.json b/client/src/locales/ja.json index 923213a46..09db6971f 100644 --- a/client/src/locales/ja.json +++ b/client/src/locales/ja.json @@ -421,7 +421,10 @@ "inviteMember": "メンバーを招待", "openSite": "サイトを開く", "pause": "一時停止", - "resume": "再開" + "resume": "再開", + "bulkSelected": "{{count}} 件のモニターを選択中", + "selectAll": "すべてのモニターを選択", + "selectMonitor": "{{name}} を選択" }, "statBoxes": { "activeFor": "稼働期間", diff --git a/client/src/locales/pt-BR.json b/client/src/locales/pt-BR.json index f7b7c4cbc..6c97a9869 100644 --- a/client/src/locales/pt-BR.json +++ b/client/src/locales/pt-BR.json @@ -421,7 +421,10 @@ "inviteMember": "Convidar membro", "openSite": "Abrir site", "pause": "Pausar", - "resume": "Retomar" + "resume": "Retomar", + "bulkSelected": "{{count}} monitor(es) selecionado(s)", + "selectAll": "Selecionar todos os monitores", + "selectMonitor": "Selecionar {{name}}" }, "statBoxes": { "activeFor": "Ativo há", diff --git a/client/src/locales/ru.json b/client/src/locales/ru.json index 1a5d12452..778d481f9 100644 --- a/client/src/locales/ru.json +++ b/client/src/locales/ru.json @@ -421,7 +421,10 @@ "inviteMember": "Пригласить участника", "openSite": "Открыть сайт", "pause": "Пауза", - "resume": "Продолжить" + "resume": "Продолжить", + "bulkSelected": "Выбрано мониторов: {{count}}", + "selectAll": "Выбрать все мониторы", + "selectMonitor": "Выбрать {{name}}" }, "statBoxes": { "activeFor": "Активен", diff --git a/client/src/locales/th.json b/client/src/locales/th.json index 433ee10cd..aab97dc93 100644 --- a/client/src/locales/th.json +++ b/client/src/locales/th.json @@ -421,7 +421,10 @@ "inviteMember": "เชิญสมาชิก", "openSite": "เปิดเว็บไซต์", "pause": "หยุดชั่วคราว", - "resume": "ดำเนินการต่อ" + "resume": "ดำเนินการต่อ", + "bulkSelected": "เลือก {{count}} มอนิเตอร์แล้ว", + "selectAll": "เลือกมอนิเตอร์ทั้งหมด", + "selectMonitor": "เลือก {{name}}" }, "statBoxes": { "activeFor": "ทำงานมาแล้ว", diff --git a/client/src/locales/tr.json b/client/src/locales/tr.json index a77dd8eb3..6e6aaf365 100644 --- a/client/src/locales/tr.json +++ b/client/src/locales/tr.json @@ -421,7 +421,10 @@ "inviteMember": "Üye davet et", "openSite": "Siteyi aç", "pause": "Duraklat", - "resume": "Devam et" + "resume": "Devam et", + "bulkSelected": "{{count}} monitör seçildi", + "selectAll": "Tüm monitörleri seç", + "selectMonitor": "{{name}} seç" }, "statBoxes": { "activeFor": "Aktif süresi", diff --git a/client/src/locales/uk.json b/client/src/locales/uk.json index 5b467e4b4..f7ff24647 100644 --- a/client/src/locales/uk.json +++ b/client/src/locales/uk.json @@ -421,7 +421,10 @@ "inviteMember": "Запросити учасника", "openSite": "Відкрити сайт", "pause": "Призупинити", - "resume": "Відновити" + "resume": "Відновити", + "bulkSelected": "Вибрано моніторів: {{count}}", + "selectAll": "Вибрати всі монітори", + "selectMonitor": "Вибрати {{name}}" }, "statBoxes": { "activeFor": "Активний протягом", diff --git a/client/src/locales/vi.json b/client/src/locales/vi.json index dc3e804de..90d8afd04 100644 --- a/client/src/locales/vi.json +++ b/client/src/locales/vi.json @@ -421,7 +421,10 @@ "inviteMember": "Mời thành viên", "openSite": "Mở trang web", "pause": "Tạm dừng", - "resume": "Tiếp tục" + "resume": "Tiếp tục", + "bulkSelected": "Đã chọn {{count}} monitor", + "selectAll": "Chọn tất cả monitor", + "selectMonitor": "Chọn {{name}}" }, "statBoxes": { "activeFor": "Hoạt động từ", diff --git a/client/src/locales/zh-CN.json b/client/src/locales/zh-CN.json index ed5e70c1e..73a4ce166 100644 --- a/client/src/locales/zh-CN.json +++ b/client/src/locales/zh-CN.json @@ -421,7 +421,10 @@ "inviteMember": "邀请成员", "openSite": "打开站点", "pause": "暂停", - "resume": "恢复" + "resume": "恢复", + "bulkSelected": "已选择 {{count}} 个监控", + "selectAll": "选择所有监控", + "selectMonitor": "选择 {{name}}" }, "statBoxes": { "activeFor": "已活跃", diff --git a/client/src/locales/zh-TW.json b/client/src/locales/zh-TW.json index ff6e9f6dd..91fc7766e 100644 --- a/client/src/locales/zh-TW.json +++ b/client/src/locales/zh-TW.json @@ -421,7 +421,10 @@ "inviteMember": "邀請成員", "openSite": "開啟網站", "pause": "暫停", - "resume": "恢復" + "resume": "恢復", + "bulkSelected": "已選擇 {{count}} 個監控", + "selectAll": "選擇所有監控", + "selectMonitor": "選擇 {{name}}" }, "statBoxes": { "activeFor": "已啟用", From 4e284c146893e9a1bfdd4b87eaa7deeee565d028 Mon Sep 17 00:00:00 2001 From: Retr01-sudo Date: Fri, 1 May 2026 19:44:07 +0530 Subject: [PATCH 040/245] feat(monitors): integrate bulk actions into uptime and infrastructure tables - Mounts selection checkboxes in UptimeMonitorsTable and MonitorsTable - Enhances global Table.tsx to cleanly render checkboxes on mobile cards via mobileLabel - Injects BulkActionsBar into main list views Fixes #3602 --- .../src/Components/design-elements/Table.tsx | 23 ++++- .../Monitors/Components/MonitorsTable.tsx | 91 ++++++++++++------ .../Pages/Infrastructure/Monitors/index.tsx | 29 +++++- .../Components/UptimeMonitorsTable.tsx | 95 +++++++++++++------ client/src/Pages/Uptime/Monitors/index.tsx | 29 +++++- 5 files changed, 208 insertions(+), 59 deletions(-) diff --git a/client/src/Components/design-elements/Table.tsx b/client/src/Components/design-elements/Table.tsx index 91bb69615..121d890d1 100644 --- a/client/src/Components/design-elements/Table.tsx +++ b/client/src/Components/design-elements/Table.tsx @@ -35,6 +35,7 @@ import useMediaQuery from "@mui/material/useMediaQuery"; export type Header = { id: number | string; content: React.ReactNode; + mobileLabel?: React.ReactNode; onClick?: (event: React.MouseEvent, row: T) => void; render: (row: T) => React.ReactNode; }; @@ -109,6 +110,24 @@ export function DataTable< key={key} > {headers.map((header) => { + if (header.mobileLabel === null) { + return ( + + + {header.render(row)} + + + ); + } + return ( - {header.content} + {header.mobileLabel !== undefined + ? header.mobileLabel + : header.content} void; + setSelectedMonitor: (monitor: Monitor | null) => void; + sortField: string; + setSortField: (field: string) => void; + sortOrder: "asc" | "desc"; + setSortOrder: (order: "asc" | "desc") => void; + count: number; + page: number; + setPage: (page: number) => void; + rowsPerPage: number; + setRowsPerPage: (rowsPerPage: number) => void; + selectedRows?: string[]; + onSelectionChange?: (selected: string[]) => void; +} export const InfraMonitorsTable = ({ monitors, @@ -26,29 +45,22 @@ export const InfraMonitorsTable = ({ setPage, rowsPerPage, setRowsPerPage, -}: { - monitors: Monitor[]; - refetch: Function; - setSelectedMonitor: Function; - sortField: string; - setSortField: (field: string) => void; - sortOrder: "asc" | "desc"; - setSortOrder: (order: "asc" | "desc") => void; - count: number; - page: number; - setPage: (page: number) => void; - rowsPerPage: number; - setRowsPerPage: (rowsPerPage: number) => void; -}) => { + selectedRows = [], + onSelectionChange, +}: InfraMonitorsTableProps) => { const { t } = useTranslation(); const theme = useTheme(); const isSmall = useMediaQuery(theme.breakpoints.down("md")); const navigate = useNavigate(); + const { post } = usePost, Monitor>(); + const { - post, - // loading: isPatching, - // error: postError, - } = usePost(); + isAllSelected, + isSomeSelected, + handleSelectAll, + handleSelectRow, + isRowSelected, + } = useTableSelection(monitors, selectedRows, onSelectionChange); const handlePageChange = ( _e: React.MouseEvent | null, @@ -65,7 +77,7 @@ export const InfraMonitorsTable = ({ setRowsPerPage(value); }; - const handleSort = (e: any, field: string) => { + const handleSort = (e: React.MouseEvent, field: string) => { e.preventDefault(); e.stopPropagation(); if (sortField === field) { @@ -101,13 +113,6 @@ export const InfraMonitorsTable = ({ navigate(`/infrastructure/configure/${monitor.id}`); }, }, - // { - // id: 5, - // label: "Clone", - // action: () => { - - // }, - // }, { id: 6, label: @@ -115,7 +120,7 @@ export const InfraMonitorsTable = ({ ? t("common.buttons.resume") : t("common.buttons.pause"), action: async () => { - await post(`/monitors/pause/${monitor.id}`, {}); + await post(`/monitors/pause/${monitor.id}`, {} as Record); refetch(); }, closeMenu: true, @@ -152,8 +157,34 @@ export const InfraMonitorsTable = ({
); const headers: Header[] = [ + { + id: "selection", + content: ( + handleSelectAll(e.target.checked)} + /> + ), + mobileLabel: null, + render: (row) => ( + { + handleSelectRow(row.id, e.target.checked); + }} + onClick={(e) => e.stopPropagation()} + /> + ), + onClick: (e) => e?.stopPropagation(), + }, { id: "name", + mobileLabel: t("common.table.headers.name"), content: ( { navigate(`/infrastructure/${row.id}`); }} + getRowSx={(row) => ({ + backgroundColor: isRowSelected(row.id) + ? theme.palette.action.selected + : "inherit", + })} /> { const { t } = useTranslation(); @@ -37,6 +42,7 @@ const InfrastructureMonitors = () => { const [sortField, setSortField] = useState(""); const [sortOrder, setSortOrder] = useState<"asc" | "desc">("asc"); const [selectedMonitor, setSelectedMonitor] = useState(null); + const isDialogOpen = Boolean(selectedMonitor); const debouncedSearch = useDebounce(search, 300); @@ -97,6 +103,15 @@ const InfrastructureMonitors = () => { const { summary, count } = monitorsWithChecksData ?? { summary: null, count: 0 }; const isLoading = monitorsWithChecksLoading; + // Bulk actions + const { + selectedRows, + setSelectedRows, + handleBulkPause, + handleBulkResume, + handleCancelSelection, + } = useBulkMonitorActions(refetch, page); + // Check if any filters are active const hasActiveFilters = Boolean(selectedStatus || selectedState || search); @@ -159,6 +174,16 @@ const InfrastructureMonitors = () => { }} />
+ + {!isLoading && ( + + )} + { ); setPage(0); }} + selectedRows={selectedRows} + onSelectionChange={setSelectedRows} /> void; + setSelectedMonitor: (monitor: Monitor | null) => void; + sortField: string; + setSortField: (field: string) => void; + sortOrder: "asc" | "desc"; + setSortOrder: (order: "asc" | "desc") => void; + count: number; + page: number; + setPage: (page: number) => void; + rowsPerPage: number; + setRowsPerPage: (rowsPerPage: number) => void; + selectedRows?: string[]; + onSelectionChange?: (selected: string[]) => void; +} export const MonitorTable = ({ monitors, @@ -34,31 +53,24 @@ export const MonitorTable = ({ setPage, rowsPerPage, setRowsPerPage, -}: { - monitors: Monitor[]; - refetch: Function; - setSelectedMonitor: (monitor: Monitor | null) => void; - sortField: string; - setSortField: (field: string) => void; - sortOrder: "asc" | "desc"; - setSortOrder: (order: "asc" | "desc") => void; - count: number; - page: number; - setPage: (page: number) => void; - rowsPerPage: number; - setRowsPerPage: (rowsPerPage: number) => void; -}) => { + selectedRows = [], + onSelectionChange, +}: MonitorTableProps) => { const { t } = useTranslation(); const theme = useTheme(); const navigate = useNavigate(); const chartType = useSelector((state: RootState) => state.ui?.chartType ?? "histogram"); - const { - post, - // loading: isPosting, - // error: postError, - } = usePost(); + const { post } = usePost, Monitor>(); - const handleSort = (e: any, field: string) => { + const { + isAllSelected, + isSomeSelected, + handleSelectAll, + handleSelectRow, + isRowSelected, + } = useTableSelection(monitors, selectedRows, onSelectionChange); + + const handleSort = (e: React.MouseEvent, field: string) => { e.preventDefault(); e.stopPropagation(); if (sortField === field) { @@ -110,13 +122,6 @@ export const MonitorTable = ({ navigate(`/uptime/configure/${monitor.id}`); }, }, - // { - // id: 5, - // label: "Clone", - // action: () => { - - // }, - // }, { id: 6, label: @@ -124,7 +129,7 @@ export const MonitorTable = ({ ? t("pages.common.monitors.actions.resume") : t("pages.common.monitors.actions.pause"), action: async () => { - await post(`/monitors/pause/${monitor.id}`, {}); + await post(`/monitors/pause/${monitor.id}`, {} as Record); refetch(); }, closeMenu: true, @@ -160,8 +165,34 @@ export const MonitorTable = ({
); const headers: Header[] = [ + { + id: "selection", + content: ( + handleSelectAll(e.target.checked)} + /> + ), + mobileLabel: null, + render: (row) => ( + { + handleSelectRow(row.id, e.target.checked); + }} + onClick={(e) => e.stopPropagation()} + /> + ), + onClick: (e) => e?.stopPropagation(), + }, { id: "name", + mobileLabel: t("common.table.headers.name"), content: ( handleSort(e, "status")} sx={{ cursor: "pointer" }} @@ -212,6 +243,7 @@ export const MonitorTable = ({ }, { id: "type", + mobileLabel: t("common.table.headers.type"), content: ( { navigate(`/uptime/${row.id}`); }} + getRowSx={(row) => ({ + backgroundColor: isRowSelected(row.id) + ? theme.palette.action.selected + : "inherit", + })} /> { const { t } = useTranslation(); @@ -37,6 +42,7 @@ const UptimeMonitorsPage = () => { const [sortField, setSortField] = useState(""); const [sortOrder, setSortOrder] = useState<"asc" | "desc">("asc"); const [selectedMonitor, setSelectedMonitor] = useState(null); + const isDialogOpen = Boolean(selectedMonitor); const debouncedSearch = useDebounce(search, 300); @@ -102,6 +108,15 @@ const UptimeMonitorsPage = () => { count, } = monitorsWithChecksData ?? { monitors: null, summary: null, count: 0 }; + // Bulk actions + const { + selectedRows, + setSelectedRows, + handleBulkPause, + handleBulkResume, + handleCancelSelection, + } = useBulkMonitorActions(refetch, page); + // Delete hook const { deleteFn, loading: isDeleting } = useDelete(); @@ -177,6 +192,16 @@ const UptimeMonitorsPage = () => { }} /> + + {!isLoading && ( + + )} + { ); setPage(0); }} + selectedRows={selectedRows} + onSelectionChange={setSelectedRows} /> Date: Fri, 1 May 2026 10:55:57 -0700 Subject: [PATCH 041/245] move openapi out of src dir Adapted from #3579 by @gorkem-bwl. The openapi/registry, helpers, and per-resource routes/* files are largely their work; this commit relocates the directory out of src/ so the build-time generator code is not emitted into dist/, and shifts spec generation from runtime to a build step. Co-authored-by: gorkem-bwl --- server/openapi.json | 13971 +++++++++++++------ server/openapi/helpers.ts | 54 + server/openapi/index.ts | 56 + server/openapi/registry.ts | 12 + server/openapi/routes/auth.ts | 203 + server/openapi/routes/check.ts | 61 + server/openapi/routes/diagnostic.ts | 11 + server/openapi/routes/geoCheck.ts | 13 + server/openapi/routes/incident.ts | 104 + server/openapi/routes/invite.ts | 35 + server/openapi/routes/log.ts | 11 + server/openapi/routes/maintenanceWindow.ts | 73 + server/openapi/routes/monitor.ts | 247 + server/openapi/routes/notification.ts | 155 + server/openapi/routes/queue.ts | 40 + server/openapi/routes/settings.ts | 35 + server/openapi/routes/statusPage.ts | 52 + server/package-lock.json | 41 + server/package.json | 5 +- server/scripts/buildOpenApi.ts | 10 + server/tsconfig.build.json | 8 + server/tsconfig.json | 6 +- 22 files changed, 11259 insertions(+), 3944 deletions(-) create mode 100644 server/openapi/helpers.ts create mode 100644 server/openapi/index.ts create mode 100644 server/openapi/registry.ts create mode 100644 server/openapi/routes/auth.ts create mode 100644 server/openapi/routes/check.ts create mode 100644 server/openapi/routes/diagnostic.ts create mode 100644 server/openapi/routes/geoCheck.ts create mode 100644 server/openapi/routes/incident.ts create mode 100644 server/openapi/routes/invite.ts create mode 100644 server/openapi/routes/log.ts create mode 100644 server/openapi/routes/maintenanceWindow.ts create mode 100644 server/openapi/routes/monitor.ts create mode 100644 server/openapi/routes/notification.ts create mode 100644 server/openapi/routes/queue.ts create mode 100644 server/openapi/routes/settings.ts create mode 100644 server/openapi/routes/statusPage.ts create mode 100644 server/scripts/buildOpenApi.ts create mode 100644 server/tsconfig.build.json diff --git a/server/openapi.json b/server/openapi.json index cfb1c5aa4..ec3a7f925 100644 --- a/server/openapi.json +++ b/server/openapi.json @@ -1,125 +1,1125 @@ { - "openapi": "3.1.0", + "openapi": "3.0.0", "info": { "title": "Checkmate API", - "summary": "Checkmate OpenAPI Specifications", - "description": "Checkmate is an open source monitoring tool used to track the operational status and performance of servers and websites. It regularly checks whether a server/website is accessible and performs optimally, providing real-time alerts and reports on the monitored services' availability, downtime, and response time.", - "contact": { - "name": "API Support", - "url": "mailto:support@bluewavelabs.ca", - "email": "support@bluewavelabs.ca" - }, - "license": { - "name": "AGPLv3", - "url": "https://github.com/bluewave-labs/checkmate/tree/HEAD/LICENSE" - }, - "version": "2.3" + "version": "1.0.0", + "description": "Generated from Zod validation schemas. Source of truth: server/src/validation/*.ts" }, "servers": [ { - "url": "http://localhost:{PORT}/{API_PATH}", - "description": "Local Development Server", - "variables": { - "PORT": { - "default": "52345" - }, - "API_PATH": { - "default": "api/v1" - } - } - }, - { - "url": "https://checkmate-demo.bluewavelabs.ca/{API_PATH}", - "description": "Checkmate Demo Server", - "variables": { - "API_PATH": { - "default": "api/v1" - } - } + "url": "/api/v1", + "description": "Current server" } ], "tags": [ { "name": "auth", - "description": "Authentication and user management" - }, - { - "name": "invite", - "description": "Team invitation management" + "description": "Sign up, log in, password reset, and the authenticated user's profile." }, { "name": "monitors", - "description": "Monitor management (uptime, page speed, hardware)" + "description": "Configure and inspect monitors (HTTP, port, ping, hardware, docker, game, gRPC) for the caller's team." }, { "name": "checks", - "description": "Monitor check results and history" + "description": "Individual monitoring check results, including history, summaries, and bulk deletes." }, { - "name": "maintenance-window", - "description": "Scheduled maintenance windows" - }, - { - "name": "queue", - "description": "Job queue management" - }, - { - "name": "status-page", - "description": "Public status page management" - }, - { - "name": "settings", - "description": "Application configuration settings" - }, - { - "name": "logs", - "description": "Application logs and diagnostics" - }, - { - "name": "notifications", - "description": "Notification integrations (email, slack, discord, etc.)" - }, - { - "name": "diagnostic", - "description": "System health and performance diagnostics" + "name": "geo-checks", + "description": "Geographic check results for monitors with multi-region probing enabled." }, { "name": "incidents", - "description": "Incident management and resolution" + "description": "Downtime incidents derived from monitor state changes, including filtering and CSV export." + }, + { + "name": "notifications", + "description": "Notification channels (email, webhook, Slack, Discord, PagerDuty, Matrix, Teams, Telegram, Pushover, Twilio) and test alerts." + }, + { + "name": "maintenance-window", + "description": "Scheduled maintenance windows during which monitors do not generate incidents or alerts." + }, + { + "name": "status-page", + "description": "Public status pages, their configuration, and the monitors they expose." + }, + { + "name": "settings", + "description": "Global application settings (admin/superadmin)." + }, + { + "name": "invite", + "description": "Team invitations and accepting them." + }, + { + "name": "queue", + "description": "Background job queue introspection and admin actions (admin/superadmin)." + }, + { + "name": "diagnostic", + "description": "System diagnostics for the running server (admin/superadmin)." + }, + { + "name": "logs", + "description": "Application logs for the running server (admin/superadmin)." } ], + "components": { + "securitySchemes": { + "bearerAuth": { + "type": "http", + "scheme": "bearer", + "bearerFormat": "JWT" + } + }, + "schemas": { + "AuthPayload": { + "type": "object", + "properties": { + "user": { + "$ref": "#/components/schemas/User" + }, + "token": { + "type": "string", + "example": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9..." + } + }, + "required": ["user"] + }, + "User": { + "type": "object", + "properties": { + "_id": { + "type": "string", + "example": "65f1c2a4d8b9e0123456789a" + }, + "firstName": { + "type": "string", + "example": "Ada" + }, + "lastName": { + "type": "string", + "example": "Lovelace" + }, + "email": { + "type": "string", + "example": "ada@example.com" + }, + "role": { + "type": "array", + "items": { + "type": "string" + }, + "example": ["admin"] + }, + "teamId": { + "type": "string", + "example": "65f1c2a4d8b9e01234567890" + }, + "profileImage": { + "type": "string", + "nullable": true + }, + "createdAt": { + "type": "string", + "example": "2026-04-01T10:00:00.000Z" + }, + "updatedAt": { + "type": "string", + "example": "2026-04-15T14:30:00.000Z" + } + }, + "required": ["_id", "firstName", "lastName", "email", "role", "createdAt", "updatedAt"], + "additionalProperties": { + "nullable": true + } + }, + "Incident": { + "type": "object", + "properties": { + "id": { + "type": "string", + "example": "65f1c2a4d8b9e0123456789c" + }, + "monitorId": { + "type": "string", + "example": "65f1c2a4d8b9e0123456789a" + }, + "teamId": { + "type": "string", + "example": "65f1c2a4d8b9e01234567890" + }, + "startTime": { + "type": "string", + "example": "2026-04-15T03:21:00.000Z" + }, + "endTime": { + "type": "string", + "nullable": true, + "example": "2026-04-15T03:34:00.000Z" + }, + "status": { + "type": "boolean", + "example": true, + "description": "true = resolved, false = ongoing" + }, + "message": { + "type": "string", + "nullable": true, + "example": "HTTP 503 from origin" + }, + "statusCode": { + "type": "number", + "nullable": true, + "example": 503 + }, + "resolutionType": { + "type": "string", + "nullable": true, + "enum": ["automatic", "manual", null], + "example": "automatic" + }, + "resolvedBy": { + "type": "string", + "nullable": true + }, + "resolvedByEmail": { + "type": "string", + "nullable": true + }, + "comment": { + "type": "string", + "nullable": true + }, + "createdAt": { + "type": "string", + "example": "2026-04-15T03:21:00.000Z" + }, + "updatedAt": { + "type": "string", + "example": "2026-04-15T03:34:00.000Z" + } + }, + "required": ["id", "monitorId", "teamId", "startTime", "endTime", "status", "resolutionType", "createdAt", "updatedAt"], + "additionalProperties": { + "nullable": true + } + }, + "IncidentSummary": { + "type": "object", + "properties": { + "total": { + "type": "number", + "example": 12 + }, + "totalActive": { + "type": "number", + "example": 1 + }, + "totalManualResolutions": { + "type": "number", + "example": 2 + }, + "totalAutomaticResolutions": { + "type": "number", + "example": 9 + }, + "avgResolutionTimeHours": { + "type": "number", + "example": 0.5 + }, + "topMonitor": { + "type": "object", + "nullable": true, + "properties": { + "monitorId": { + "type": "string", + "example": "65f1c2a4d8b9e0123456789a" + }, + "monitorName": { + "type": "string", + "nullable": true, + "example": "Marketing site" + }, + "incidentCount": { + "type": "number", + "example": 4 + } + }, + "required": ["monitorId", "monitorName", "incidentCount"] + }, + "latestIncidents": { + "type": "array", + "items": { + "$ref": "#/components/schemas/IncidentSummaryItem" + } + } + }, + "required": [ + "total", + "totalActive", + "totalManualResolutions", + "totalAutomaticResolutions", + "avgResolutionTimeHours", + "topMonitor", + "latestIncidents" + ] + }, + "IncidentSummaryItem": { + "type": "object", + "properties": { + "id": { + "type": "string", + "example": "65f1c2a4d8b9e0123456789c" + }, + "monitorId": { + "type": "string", + "example": "65f1c2a4d8b9e0123456789a" + }, + "monitorName": { + "type": "string", + "nullable": true, + "example": "Marketing site" + }, + "status": { + "type": "boolean", + "example": true + }, + "startTime": { + "type": "string", + "example": "2026-04-15T03:21:00.000Z" + }, + "endTime": { + "type": "string", + "nullable": true, + "example": "2026-04-15T03:34:00.000Z" + }, + "resolutionType": { + "type": "string", + "nullable": true, + "enum": ["automatic", "manual", null], + "example": "automatic" + }, + "message": { + "type": "string", + "nullable": true, + "example": "HTTP 503 from origin" + }, + "statusCode": { + "type": "number", + "nullable": true, + "example": 503 + }, + "createdAt": { + "type": "string", + "example": "2026-04-15T03:21:00.000Z" + } + }, + "required": ["id", "monitorId", "monitorName", "status", "startTime", "endTime", "resolutionType", "message", "statusCode", "createdAt"] + }, + "Monitor": { + "type": "object", + "properties": { + "_id": { + "type": "string", + "example": "65f1c2a4d8b9e0123456789a" + }, + "name": { + "type": "string", + "example": "Marketing site" + }, + "description": { + "type": "string", + "example": "Production marketing site monitored from the EU region" + }, + "type": { + "type": "string", + "enum": ["http", "ping", "pagespeed", "hardware", "docker", "port", "game", "grpc", "websocket", "unknown"], + "example": "http" + }, + "url": { + "type": "string", + "example": "https://www.example.com" + }, + "port": { + "type": "number", + "example": 443 + }, + "isActive": { + "type": "boolean", + "example": true + }, + "interval": { + "type": "number", + "example": 60000 + }, + "status": { + "type": "string", + "enum": ["up", "down", "paused", "initializing", "maintenance", "breached"], + "example": "up" + }, + "statusWindowSize": { + "type": "number", + "example": 5 + }, + "statusWindowThreshold": { + "type": "number", + "example": 3 + }, + "ignoreTlsErrors": { + "type": "boolean", + "example": false + }, + "useAdvancedMatching": { + "type": "boolean", + "example": false + }, + "jsonPath": { + "type": "string" + }, + "expectedValue": { + "type": "string" + }, + "matchMethod": { + "type": "string", + "enum": ["equal", "include", "regex"] + }, + "notifications": { + "type": "array", + "items": { + "type": "string" + }, + "example": ["65f1c2a4d8b9e0123456789b"] + }, + "secret": { + "type": "string" + }, + "cpuAlertThreshold": { + "type": "number", + "example": 90 + }, + "memoryAlertThreshold": { + "type": "number", + "example": 90 + }, + "diskAlertThreshold": { + "type": "number", + "example": 90 + }, + "tempAlertThreshold": { + "type": "number", + "example": 80 + }, + "selectedDisks": { + "type": "array", + "items": { + "type": "string" + }, + "example": [] + }, + "gameId": { + "type": "string" + }, + "grpcServiceName": { + "type": "string" + }, + "group": { + "type": "string", + "nullable": true + }, + "geoCheckEnabled": { + "type": "boolean", + "example": false + }, + "geoCheckLocations": { + "type": "array", + "items": { + "type": "string", + "enum": ["EU", "NA", "AS", "SA", "AF", "OC"] + }, + "example": [] + }, + "geoCheckInterval": { + "type": "number", + "example": 300000 + }, + "teamId": { + "type": "string", + "example": "65f1c2a4d8b9e01234567890" + }, + "userId": { + "type": "string", + "example": "65f1c2a4d8b9e01234567891" + }, + "createdAt": { + "type": "string", + "example": "2026-04-01T10:00:00.000Z" + }, + "updatedAt": { + "type": "string", + "example": "2026-04-15T14:30:00.000Z" + } + }, + "required": [ + "_id", + "name", + "type", + "url", + "isActive", + "interval", + "status", + "statusWindowSize", + "statusWindowThreshold", + "ignoreTlsErrors", + "useAdvancedMatching", + "notifications", + "cpuAlertThreshold", + "memoryAlertThreshold", + "diskAlertThreshold", + "tempAlertThreshold", + "selectedDisks", + "geoCheckEnabled", + "geoCheckLocations", + "geoCheckInterval", + "teamId", + "userId", + "createdAt", + "updatedAt" + ], + "additionalProperties": { + "nullable": true + } + }, + "NotificationChannelBody": { + "oneOf": [ + { + "$ref": "#/components/schemas/EmailNotification" + }, + { + "$ref": "#/components/schemas/WebhookNotification" + }, + { + "$ref": "#/components/schemas/SlackNotification" + }, + { + "$ref": "#/components/schemas/DiscordNotification" + }, + { + "$ref": "#/components/schemas/PagerDutyNotification" + }, + { + "$ref": "#/components/schemas/MatrixNotification" + }, + { + "$ref": "#/components/schemas/TeamsNotification" + }, + { + "$ref": "#/components/schemas/TelegramNotification" + }, + { + "$ref": "#/components/schemas/PushoverNotification" + }, + { + "$ref": "#/components/schemas/TwilioNotification" + } + ], + "discriminator": { + "propertyName": "type", + "mapping": { + "email": "#/components/schemas/EmailNotification", + "webhook": "#/components/schemas/WebhookNotification", + "slack": "#/components/schemas/SlackNotification", + "discord": "#/components/schemas/DiscordNotification", + "pager_duty": "#/components/schemas/PagerDutyNotification", + "matrix": "#/components/schemas/MatrixNotification", + "teams": "#/components/schemas/TeamsNotification", + "telegram": "#/components/schemas/TelegramNotification", + "pushover": "#/components/schemas/PushoverNotification", + "twilio": "#/components/schemas/TwilioNotification" + } + } + }, + "EmailNotification": { + "type": "object", + "properties": { + "notificationName": { + "type": "string", + "minLength": 1 + }, + "type": { + "type": "string", + "enum": ["email"] + }, + "address": { + "type": "string", + "format": "email" + }, + "homeserverUrl": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "string", + "enum": [""] + } + ] + }, + "roomId": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "string", + "enum": [""] + } + ] + }, + "accessToken": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "string", + "enum": [""] + } + ] + } + }, + "required": ["notificationName", "type", "address"], + "example": { + "notificationName": "Ops on-call email", + "type": "email", + "address": "alerts@example.com" + } + }, + "WebhookNotification": { + "type": "object", + "properties": { + "notificationName": { + "type": "string", + "minLength": 1 + }, + "type": { + "type": "string", + "enum": ["webhook"] + }, + "address": { + "type": "string", + "format": "uri" + }, + "homeserverUrl": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "string", + "enum": [""] + } + ] + }, + "roomId": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "string", + "enum": [""] + } + ] + }, + "accessToken": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "string", + "enum": [""] + } + ] + } + }, + "required": ["notificationName", "type", "address"], + "example": { + "notificationName": "Custom webhook", + "type": "webhook", + "address": "https://example.com/hooks/checkmate" + } + }, + "SlackNotification": { + "type": "object", + "properties": { + "notificationName": { + "type": "string", + "minLength": 1 + }, + "type": { + "type": "string", + "enum": ["slack"] + }, + "address": { + "type": "string", + "format": "uri" + }, + "homeserverUrl": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "string", + "enum": [""] + } + ] + }, + "roomId": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "string", + "enum": [""] + } + ] + }, + "accessToken": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "string", + "enum": [""] + } + ] + } + }, + "required": ["notificationName", "type", "address"], + "example": { + "notificationName": "#alerts", + "type": "slack", + "address": "https://hooks.slack.com/services/T000/B000/XXXX" + } + }, + "DiscordNotification": { + "type": "object", + "properties": { + "notificationName": { + "type": "string", + "minLength": 1 + }, + "type": { + "type": "string", + "enum": ["discord"] + }, + "address": { + "type": "string", + "format": "uri" + }, + "homeserverUrl": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "string", + "enum": [""] + } + ] + }, + "roomId": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "string", + "enum": [""] + } + ] + }, + "accessToken": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "string", + "enum": [""] + } + ] + } + }, + "required": ["notificationName", "type", "address"], + "example": { + "notificationName": "#status", + "type": "discord", + "address": "https://discord.com/api/webhooks/123/abc" + } + }, + "PagerDutyNotification": { + "type": "object", + "properties": { + "notificationName": { + "type": "string", + "minLength": 1 + }, + "type": { + "type": "string", + "enum": ["pager_duty"] + }, + "address": { + "type": "string", + "minLength": 1 + }, + "homeserverUrl": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "string", + "enum": [""] + } + ] + }, + "roomId": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "string", + "enum": [""] + } + ] + }, + "accessToken": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "string", + "enum": [""] + } + ] + } + }, + "required": ["notificationName", "type", "address"], + "example": { + "notificationName": "PagerDuty primary", + "type": "pager_duty", + "address": "R01XXXXXXXXXXXXXXXXXXXXXXX" + } + }, + "MatrixNotification": { + "type": "object", + "properties": { + "notificationName": { + "type": "string", + "minLength": 1 + }, + "type": { + "type": "string", + "enum": ["matrix"] + }, + "address": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "string", + "enum": [""] + } + ] + }, + "homeserverUrl": { + "type": "string", + "format": "uri" + }, + "roomId": { + "type": "string", + "minLength": 1 + }, + "accessToken": { + "type": "string", + "minLength": 1 + } + }, + "required": ["notificationName", "type", "homeserverUrl", "roomId", "accessToken"], + "example": { + "notificationName": "Matrix room", + "type": "matrix", + "homeserverUrl": "https://matrix.example.com", + "roomId": "!abc123:example.com", + "accessToken": "syt_xxx" + } + }, + "TeamsNotification": { + "type": "object", + "properties": { + "notificationName": { + "type": "string", + "minLength": 1 + }, + "type": { + "type": "string", + "enum": ["teams"] + }, + "address": { + "type": "string", + "format": "uri" + } + }, + "required": ["notificationName", "type", "address"], + "example": { + "notificationName": "Teams ops channel", + "type": "teams", + "address": "https://outlook.office.com/webhook/..." + } + }, + "TelegramNotification": { + "type": "object", + "properties": { + "notificationName": { + "type": "string", + "minLength": 1 + }, + "type": { + "type": "string", + "enum": ["telegram"] + }, + "address": { + "type": "string", + "minLength": 1 + }, + "accessToken": { + "type": "string", + "minLength": 1 + } + }, + "required": ["notificationName", "type", "address", "accessToken"], + "example": { + "notificationName": "Telegram bot", + "type": "telegram", + "address": "-1001234567890", + "accessToken": "123456:ABC-DEF" + } + }, + "PushoverNotification": { + "type": "object", + "properties": { + "notificationName": { + "type": "string", + "minLength": 1 + }, + "type": { + "type": "string", + "enum": ["pushover"] + }, + "address": { + "type": "string", + "minLength": 1 + }, + "accessToken": { + "type": "string", + "minLength": 1 + } + }, + "required": ["notificationName", "type", "address", "accessToken"], + "example": { + "notificationName": "Pushover personal", + "type": "pushover", + "address": "u1234567890abcdef", + "accessToken": "a1234567890abcdef" + } + }, + "TwilioNotification": { + "type": "object", + "properties": { + "notificationName": { + "type": "string", + "minLength": 1 + }, + "type": { + "type": "string", + "enum": ["twilio"] + }, + "accountSid": { + "type": "string", + "minLength": 1 + }, + "accessToken": { + "type": "string", + "minLength": 1 + }, + "phone": { + "type": "string", + "minLength": 1 + }, + "twilioPhoneNumber": { + "type": "string", + "minLength": 1 + } + }, + "required": ["notificationName", "type", "accountSid", "accessToken", "phone", "twilioPhoneNumber"], + "example": { + "notificationName": "Twilio SMS", + "type": "twilio", + "accountSid": "ACxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx", + "accessToken": "your-auth-token", + "phone": "+15551234567", + "twilioPhoneNumber": "+15557654321" + } + } + }, + "parameters": {} + }, "paths": { "/auth/register": { "post": { "tags": ["auth"], - "summary": "Register new user", - "description": "Register a new user account with profile information", + "summary": "Register a new user (first user becomes superadmin)", "requestBody": { - "required": true, "content": { - "application/json": { + "multipart/form-data": { "schema": { - "$ref": "#/components/schemas/RegisterRequest" + "type": "object", + "properties": { + "firstName": { + "type": "string", + "maxLength": 50, + "pattern": "^(?=.*[\\p{L}\\p{Sc}])[\\p{L}\\p{Sc}\\s'\\-().]+$/u" + }, + "lastName": { + "type": "string", + "maxLength": 50, + "pattern": "^(?=.*[\\p{L}\\p{Sc}])[\\p{L}\\p{Sc}\\s'\\-().]+$/u" + }, + "email": { + "type": "string", + "format": "email" + }, + "password": { + "type": "string", + "minLength": 8, + "pattern": "^(?=.*[a-z])(?=.*[A-Z])(?=.*[0-9])(?=.*[!?@#$%^&*()\\-_=+[\\]{};:'\",.~`|\\\\/])[A-Za-z0-9!?@#$%^&*()\\-_=+[\\]{};:'\",.~`|\\\\/]+$" + }, + "profileImage": { + "type": "string", + "format": "binary" + }, + "inviteToken": { + "type": "string", + "default": "" + } + }, + "required": ["firstName", "lastName", "email", "password", "profileImage"] } } } }, "responses": { "200": { - "description": "User registered successfully", + "description": "OK", "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/AuthResponse" + "type": "object", + "properties": { + "success": { + "type": "boolean", + "enum": [true] + }, + "msg": { + "type": "string" + }, + "data": { + "$ref": "#/components/schemas/AuthPayload" + } + }, + "required": ["success", "msg", "data"] } } } }, - "422": { - "$ref": "#/components/responses/ValidationError" + "401": { + "description": "Unauthorized", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "success": { + "type": "boolean", + "enum": [false] + }, + "msg": { + "type": "string" + }, + "data": { + "nullable": true + } + }, + "required": ["success", "msg"] + } + } + } + }, + "403": { + "description": "Forbidden", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "success": { + "type": "boolean", + "enum": [false] + }, + "msg": { + "type": "string" + }, + "data": { + "nullable": true + } + }, + "required": ["success", "msg"] + } + } + } }, "500": { - "$ref": "#/components/responses/InternalServerError" + "description": "Internal server error", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "success": { + "type": "boolean", + "enum": [false] + }, + "msg": { + "type": "string" + }, + "data": { + "nullable": true + } + }, + "required": ["success", "msg"] + } + } + } } } } @@ -127,195 +1127,114 @@ "/auth/login": { "post": { "tags": ["auth"], - "summary": "User login", - "description": "Authenticate user with email and password", + "summary": "Log in", "requestBody": { - "required": true, "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/LoginRequest" + "type": "object", + "properties": { + "email": { + "type": "string", + "format": "email" + }, + "password": { + "type": "string", + "minLength": 1 + } + }, + "required": ["email", "password"] + }, + "example": { + "email": "ada@example.com", + "password": "S3cure!Passw0rd" } } } }, "responses": { "200": { - "description": "Login successful", + "description": "OK", "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/AuthResponse" + "type": "object", + "properties": { + "success": { + "type": "boolean", + "enum": [true] + }, + "msg": { + "type": "string" + }, + "data": { + "$ref": "#/components/schemas/AuthPayload" + } + }, + "required": ["success", "msg", "data"] + }, + "example": { + "success": true, + "msg": "OK", + "data": { + "user": { + "_id": "65f1c2a4d8b9e0123456789a", + "firstName": "Ada", + "lastName": "Lovelace", + "email": "ada@example.com", + "role": ["admin"], + "teamId": "65f1c2a4d8b9e01234567890", + "createdAt": "2026-04-01T10:00:00.000Z", + "updatedAt": "2026-04-15T14:30:00.000Z" + }, + "token": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9..." + } } } } }, "401": { - "$ref": "#/components/responses/Unauthorized" - }, - "422": { - "$ref": "#/components/responses/ValidationError" - }, - "500": { - "$ref": "#/components/responses/InternalServerError" - } - } - } - }, - "/auth/user/{userId}": { - "put": { - "tags": ["auth"], - "summary": "Update user profile", - "description": "Update user information including profile image", - "parameters": [ - { - "name": "userId", - "in": "path", - "required": true, - "schema": { - "type": "string" - } - } - ], - "requestBody": { - "required": true, - "content": { - "multipart/form-data": { - "schema": { - "$ref": "#/components/schemas/UserUpdateRequest" - } - } - } - }, - "responses": { - "200": { - "$ref": "#/components/responses/Success" - }, - "422": { - "$ref": "#/components/responses/ValidationError" - }, - "500": { - "$ref": "#/components/responses/InternalServerError" - } - }, - "security": [ - { - "bearerAuth": [] - } - ] - }, - "delete": { - "tags": ["auth"], - "summary": "Delete user account", - "description": "Permanently delete user account and associated data", - "parameters": [ - { - "name": "userId", - "in": "path", - "required": true, - "schema": { - "type": "string" - } - } - ], - "responses": { - "200": { - "$ref": "#/components/responses/Success" - }, - "404": { - "$ref": "#/components/responses/NotFound" - }, - "500": { - "$ref": "#/components/responses/InternalServerError" - } - }, - "security": [ - { - "bearerAuth": [] - } - ] - } - }, - "/auth/users": { - "get": { - "tags": ["auth"], - "summary": "Get all users", - "description": "Retrieve all users (admin/superadmin only)", - "responses": { - "200": { - "description": "Users retrieved successfully", + "description": "Invalid credentials", "content": { "application/json": { "schema": { - "allOf": [ - { - "$ref": "#/components/schemas/SuccessResponse" + "type": "object", + "properties": { + "success": { + "type": "boolean", + "enum": [false] }, - { - "type": "object", - "properties": { - "data": { - "type": "array", - "items": { - "$ref": "#/components/schemas/User" - } - } - } + "msg": { + "type": "string" } - ] - } - } - } - }, - "403": { - "$ref": "#/components/responses/Forbidden" - }, - "500": { - "$ref": "#/components/responses/InternalServerError" - } - }, - "security": [ - { - "bearerAuth": [] - } - ] - } - }, - "/auth/users/superadmin": { - "get": { - "tags": ["auth"], - "summary": "Check superadmin exists", - "description": "Check if a superadmin account exists in the system", - "responses": { - "200": { - "description": "Check completed", - "content": { - "application/json": { - "schema": { - "allOf": [ - { - "$ref": "#/components/schemas/SuccessResponse" - }, - { - "type": "object", - "properties": { - "data": { - "type": "object", - "properties": { - "exists": { - "type": "boolean" - } - } - } - } - } - ] + }, + "required": ["success", "msg"] } } } }, "500": { - "$ref": "#/components/responses/InternalServerError" + "description": "Internal server error", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "success": { + "type": "boolean", + "enum": [false] + }, + "msg": { + "type": "string" + }, + "data": { + "nullable": true + } + }, + "required": ["success", "msg"] + } + } + } } } } @@ -323,35 +1242,73 @@ "/auth/recovery/request": { "post": { "tags": ["auth"], - "summary": "Request password reset", - "description": "Send password reset email to user", + "summary": "Request a password recovery email", "requestBody": { - "required": true, "content": { "application/json": { "schema": { "type": "object", - "required": ["email"], "properties": { "email": { "type": "string", - "format": "email", - "description": "User email address" + "format": "email" } - } + }, + "required": ["email"] + }, + "example": { + "email": "ada@example.com" } } } }, "responses": { "200": { - "$ref": "#/components/responses/Success" - }, - "404": { - "description": "User not found" + "description": "OK", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "success": { + "type": "boolean", + "enum": [true] + }, + "msg": { + "type": "string" + } + }, + "required": ["success", "msg"] + }, + "example": { + "success": true, + "msg": "OK" + } + } + } }, "500": { - "$ref": "#/components/responses/InternalServerError" + "description": "Internal server error", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "success": { + "type": "boolean", + "enum": [false] + }, + "msg": { + "type": "string" + }, + "data": { + "nullable": true + } + }, + "required": ["success", "msg"] + } + } + } } } } @@ -359,34 +1316,70 @@ "/auth/recovery/validate": { "post": { "tags": ["auth"], - "summary": "Validate recovery token", - "description": "Validate password reset token", + "summary": "Validate a password recovery token", "requestBody": { - "required": true, "content": { "application/json": { "schema": { "type": "object", - "required": ["recoveryToken"], "properties": { "recoveryToken": { "type": "string", - "description": "Password reset token" + "minLength": 1 } - } + }, + "required": ["recoveryToken"] } } } }, "responses": { "200": { - "$ref": "#/components/responses/Success" - }, - "400": { - "description": "Invalid or expired token" + "description": "OK", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "success": { + "type": "boolean", + "enum": [true] + }, + "msg": { + "type": "string" + } + }, + "required": ["success", "msg"] + }, + "example": { + "success": true, + "msg": "OK" + } + } + } }, "500": { - "$ref": "#/components/responses/InternalServerError" + "description": "Internal server error", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "success": { + "type": "boolean", + "enum": [false] + }, + "msg": { + "type": "string" + }, + "data": { + "nullable": true + } + }, + "required": ["success", "msg"] + } + } + } } } } @@ -394,308 +1387,89 @@ "/auth/recovery/reset": { "post": { "tags": ["auth"], - "summary": "Reset password", - "description": "Reset user password with recovery token", + "summary": "Reset password using a recovery token", "requestBody": { - "required": true, "content": { "application/json": { "schema": { "type": "object", - "required": ["recoveryToken", "password"], "properties": { "recoveryToken": { "type": "string", - "description": "Password reset token" + "minLength": 1 }, "password": { "type": "string", - "format": "password", - "description": "New password" + "minLength": 8, + "pattern": "^(?=.*[a-z])(?=.*[A-Z])(?=.*[0-9])(?=.*[!?@#$%^&*()\\-_=+[\\]{};:'\",.~`|\\\\/])[A-Za-z0-9!?@#$%^&*()\\-_=+[\\]{};:'\",.~`|\\\\/]+$" + }, + "confirm": { + "type": "string" } - } + }, + "required": ["recoveryToken", "password"] } } } }, "responses": { "200": { - "$ref": "#/components/responses/Success" - }, - "400": { - "description": "Invalid token or password requirements not met" + "description": "OK", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "success": { + "type": "boolean", + "enum": [true] + }, + "msg": { + "type": "string" + } + }, + "required": ["success", "msg"] + }, + "example": { + "success": true, + "msg": "OK" + } + } + } }, "500": { - "$ref": "#/components/responses/InternalServerError" + "description": "Internal server error", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "success": { + "type": "boolean", + "enum": [false] + }, + "msg": { + "type": "string" + }, + "data": { + "nullable": true + } + }, + "required": ["success", "msg"] + } + } + } } } } }, - "/monitors": { - "post": { - "tags": ["monitors"], - "summary": "Create monitor", - "description": "Create a new monitoring endpoint", - "requestBody": { - "required": true, - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/CreateMonitorRequest" - } - } - } - }, - "responses": { - "201": { - "description": "Monitor created successfully", - "content": { - "application/json": { - "schema": { - "allOf": [ - { - "$ref": "#/components/schemas/SuccessResponse" - }, - { - "type": "object", - "properties": { - "data": { - "$ref": "#/components/schemas/Monitor" - } - } - } - ] - } - } - } - }, - "422": { - "$ref": "#/components/responses/ValidationError" - }, - "500": { - "$ref": "#/components/responses/InternalServerError" - } - }, - "security": [ - { - "bearerAuth": [] - } - ] - }, - "delete": { - "tags": ["monitors"], - "summary": "Delete all monitors", - "description": "Delete all monitors (superadmin only)", - "responses": { - "200": { - "$ref": "#/components/responses/Success" - }, - "403": { - "$ref": "#/components/responses/Forbidden" - }, - "500": { - "$ref": "#/components/responses/InternalServerError" - } - }, - "security": [ - { - "bearerAuth": [] - } - ] - } - }, - "/monitors/team": { + "/auth/users/superadmin": { "get": { - "tags": ["monitors"], - "summary": "Get monitors by team", - "description": "Get monitors filtered by team with optional parameters", - "parameters": [ - { - "name": "type", - "in": "query", - "description": "Filter by monitor type", - "schema": { - "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"] - } - } - ] - } - }, - { - "name": "filter", - "in": "query", - "description": "Search filter value", - "schema": { - "type": "string" - } - } - ], + "tags": ["auth"], + "summary": "Check whether a superadmin user exists", "responses": { "200": { - "description": "Team monitors retrieved successfully", - "content": { - "application/json": { - "schema": { - "allOf": [ - { - "$ref": "#/components/schemas/SuccessResponse" - }, - { - "type": "object", - "properties": { - "data": { - "type": "object", - "properties": { - "monitors": { - "type": "array", - "items": { - "$ref": "#/components/schemas/Monitor" - } - }, - "totalCount": { - "type": "integer" - } - } - } - } - } - ] - } - } - } - }, - "500": { - "$ref": "#/components/responses/InternalServerError" - } - }, - "security": [ - { - "bearerAuth": [] - } - ] - } - }, - "/monitors/team/with-checks": { - "get": { - "tags": ["monitors"], - "summary": "Get monitors with recent checks", - "description": "Get team monitors with their most recent check results", - "parameters": [ - { - "name": "limit", - "in": "query", - "description": "Number of recent checks to include per monitor", - "schema": { - "type": "integer", - "minimum": 1, - "maximum": 100 - } - }, - { - "name": "page", - "in": "query", - "description": "Zero-based page index for pagination", - "schema": { - "type": "integer", - "minimum": 0 - } - }, - { - "name": "rowsPerPage", - "in": "query", - "description": "Number of monitors per page", - "schema": { - "type": "integer", - "minimum": 1, - "maximum": 100 - } - }, - { - "name": "filter", - "in": "query", - "description": "Search filter value", - "schema": { - "type": "string" - } - }, - { - "name": "field", - "in": "query", - "description": "Field to sort by", - "schema": { - "type": "string" - } - }, - { - "name": "order", - "in": "query", - "description": "Sort order", - "schema": { - "type": "string", - "enum": ["asc", "desc"] - } - }, - { - "name": "type", - "in": "query", - "description": "Filter by monitor type", - "schema": { - "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"] - } - } - ] - } - }, - { - "name": "explain", - "in": "query", - "description": "Include MongoDB query explain output (debug)", - "schema": { - "type": "boolean" - } - } - ], - "responses": { - "200": { - "description": "Monitors with checks retrieved successfully" - }, - "500": { - "$ref": "#/components/responses/InternalServerError" - } - }, - "security": [ - { - "bearerAuth": [] - } - ] - } - }, - "/monitors/team/groups": { - "get": { - "tags": ["monitors"], - "summary": "Get monitor groups by team", - "description": "Get all monitor groups for the authenticated user's team", - "responses": { - "200": { - "description": "Monitor groups retrieved successfully", + "description": "OK", "content": { "application/json": { "schema": { @@ -703,316 +1477,90 @@ "properties": { "success": { "type": "boolean", - "example": true + "enum": [true] }, "msg": { - "type": "string", - "example": "Monitor groups retrieved successfully" + "type": "string" + }, + "data": { + "type": "object", + "properties": { + "superAdminExists": { + "type": "boolean" + } + }, + "required": ["superAdminExists"] + } + }, + "required": ["success", "msg", "data"] + } + } + } + }, + "500": { + "description": "Internal server error", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "success": { + "type": "boolean", + "enum": [false] + }, + "msg": { + "type": "string" + }, + "data": { + "nullable": true + } + }, + "required": ["success", "msg"] + } + } + } + } + } + } + }, + "/auth/users": { + "get": { + "tags": ["auth"], + "summary": "List all users (admin/superadmin)", + "security": [ + { + "bearerAuth": [] + } + ], + "responses": { + "200": { + "description": "OK", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "success": { + "type": "boolean", + "enum": [true] + }, + "msg": { + "type": "string" }, "data": { "type": "array", "items": { - "type": "object", - "properties": { - "_id": { - "type": "string" - }, - "name": { - "type": "string" - }, - "monitors": { - "type": "array", - "items": { - "type": "string" - } - } - } + "$ref": "#/components/schemas/User" } } - } + }, + "required": ["success", "msg", "data"] } } } }, "401": { - "$ref": "#/components/responses/Unauthorized" - }, - "500": { - "$ref": "#/components/responses/InternalServerError" - } - }, - "security": [ - { - "bearerAuth": [] - } - ] - } - }, - "/monitors/{monitorId}": { - "get": { - "tags": ["monitors"], - "summary": "Get monitor by ID", - "description": "Retrieve a specific monitor by its ID", - "parameters": [ - { - "name": "monitorId", - "in": "path", - "required": true, - "description": "Monitor ID", - "schema": { - "type": "string" - } - } - ], - "responses": { - "200": { - "description": "Monitor retrieved successfully", - "content": { - "application/json": { - "schema": { - "allOf": [ - { - "$ref": "#/components/schemas/SuccessResponse" - }, - { - "type": "object", - "properties": { - "data": { - "$ref": "#/components/schemas/Monitor" - } - } - } - ] - } - } - } - }, - "404": { - "$ref": "#/components/responses/NotFound" - }, - "500": { - "$ref": "#/components/responses/InternalServerError" - } - }, - "security": [ - { - "bearerAuth": [] - } - ] - }, - "patch": { - "tags": ["monitors"], - "summary": "Update monitor", - "description": "Update an existing monitor", - "parameters": [ - { - "name": "monitorId", - "in": "path", - "required": true, - "schema": { - "type": "string" - } - } - ], - "requestBody": { - "required": true, - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/UpdateMonitorRequest" - } - } - } - }, - "responses": { - "200": { - "$ref": "#/components/responses/Success" - }, - "404": { - "$ref": "#/components/responses/NotFound" - }, - "422": { - "$ref": "#/components/responses/ValidationError" - }, - "500": { - "$ref": "#/components/responses/InternalServerError" - } - }, - "security": [ - { - "bearerAuth": [] - } - ] - }, - "delete": { - "tags": ["monitors"], - "summary": "Delete monitor", - "description": "Delete a specific monitor", - "parameters": [ - { - "name": "monitorId", - "in": "path", - "required": true, - "schema": { - "type": "string" - } - } - ], - "responses": { - "200": { - "$ref": "#/components/responses/Success" - }, - "404": { - "$ref": "#/components/responses/NotFound" - }, - "500": { - "$ref": "#/components/responses/InternalServerError" - } - }, - "security": [ - { - "bearerAuth": [] - } - ] - } - }, - "/monitors/uptime/details/{monitorId}": { - "get": { - "tags": ["monitors"], - "summary": "Get uptime details", - "description": "Get detailed uptime statistics for a monitor", - "parameters": [ - { - "name": "monitorId", - "in": "path", - "required": true, - "schema": { - "type": "string" - } - } - ], - "responses": { - "200": { - "description": "Uptime details retrieved successfully" - }, - "404": { - "$ref": "#/components/responses/NotFound" - }, - "500": { - "$ref": "#/components/responses/InternalServerError" - } - }, - "security": [ - { - "bearerAuth": [] - } - ] - } - }, - "/monitors/hardware/details/{monitorId}": { - "get": { - "tags": ["monitors"], - "summary": "Get hardware monitoring details", - "description": "Get hardware performance metrics for a monitor", - "parameters": [ - { - "name": "monitorId", - "in": "path", - "required": true, - "schema": { - "type": "string" - } - } - ], - "responses": { - "200": { - "description": "Hardware details retrieved successfully" - }, - "404": { - "$ref": "#/components/responses/NotFound" - }, - "500": { - "$ref": "#/components/responses/InternalServerError" - } - }, - "security": [ - { - "bearerAuth": [] - } - ] - } - }, - "/monitors/pause/{monitorId}": { - "post": { - "tags": ["monitors"], - "summary": "Pause/unpause monitor", - "description": "Toggle monitor active status", - "parameters": [ - { - "name": "monitorId", - "in": "path", - "required": true, - "schema": { - "type": "string" - } - } - ], - "responses": { - "200": { - "$ref": "#/components/responses/Success" - }, - "404": { - "$ref": "#/components/responses/NotFound" - }, - "500": { - "$ref": "#/components/responses/InternalServerError" - } - }, - "security": [ - { - "bearerAuth": [] - } - ] - } - }, - "/monitors/pagespeed/details/{monitorId}": { - "get": { - "tags": ["monitors"], - "summary": "Get PageSpeed monitor details", - "description": "Get detailed PageSpeed monitoring data including performance metrics and Core Web Vitals", - "parameters": [ - { - "name": "monitorId", - "in": "path", - "required": true, - "description": "The PageSpeed monitor ID", - "schema": { - "type": "string" - } - }, - { - "name": "sortOrder", - "in": "query", - "description": "Sort order for results", - "schema": { - "type": "string", - "enum": ["asc", "desc"], - "default": "desc" - } - }, - { - "name": "limit", - "in": "query", - "description": "Number of results to return", - "schema": { - "type": "integer", - "default": 50 - } - } - ], - "responses": { - "200": { - "description": "PageSpeed details retrieved successfully", + "description": "Unauthorized", "content": { "application/json": { "schema": { @@ -1020,839 +1568,2577 @@ "properties": { "success": { "type": "boolean", - "example": true + "enum": [false] }, "msg": { - "type": "string", - "example": "PageSpeed details retrieved successfully" + "type": "string" }, "data": { - "type": "object", - "properties": { - "monitor": { - "$ref": "#/components/schemas/Monitor" - }, - "checks": { - "type": "array", - "items": { - "$ref": "#/components/schemas/Check" - } - } - } + "nullable": true } - } + }, + "required": ["success", "msg"] } } } }, - "401": { - "$ref": "#/components/responses/Unauthorized" - }, - "404": { - "$ref": "#/components/responses/NotFound" - }, - "500": { - "$ref": "#/components/responses/InternalServerError" - } - }, - "security": [ - { - "bearerAuth": [] - } - ] - } - }, - "/monitors/certificate/{monitorId}": { - "get": { - "tags": ["monitors"], - "summary": "Get SSL certificate info", - "description": "Get SSL certificate information for a monitor", - "parameters": [ - { - "name": "monitorId", - "in": "path", - "required": true, - "schema": { - "type": "string" - } - } - ], - "responses": { - "200": { - "description": "Certificate information retrieved successfully" - }, - "404": { - "$ref": "#/components/responses/NotFound" - }, - "500": { - "$ref": "#/components/responses/InternalServerError" - } - }, - "security": [ - { - "bearerAuth": [] - } - ] - } - }, - "/monitors/demo": { - "post": { - "tags": ["monitors"], - "summary": "Add demo monitors", - "description": "Add preconfigured demo monitors for testing", - "responses": { - "200": { - "$ref": "#/components/responses/Success" - }, - "500": { - "$ref": "#/components/responses/InternalServerError" - } - }, - "security": [ - { - "bearerAuth": [] - } - ] - } - }, - "/monitors/export/json": { - "get": { - "tags": ["monitors"], - "summary": "Export monitors to JSON", - "description": "Export all monitors to JSON format (admin/superadmin only)", - "responses": { - "200": { - "description": "JSON file generated successfully", - "content": { - "application/json": { - "schema": { - "type": "object", - "properties": { - "success": { - "type": "boolean", - "example": true - }, - "msg": { - "type": "string", - "example": "Monitors exported successfully" - }, - "data": { - "type": "array", - "items": { - "$ref": "#/components/schemas/Monitor" - } - } - } - } - } - } - }, - "401": { - "$ref": "#/components/responses/Unauthorized" - }, "403": { - "$ref": "#/components/responses/Forbidden" + "description": "Forbidden", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "success": { + "type": "boolean", + "enum": [false] + }, + "msg": { + "type": "string" + }, + "data": { + "nullable": true + } + }, + "required": ["success", "msg"] + } + } + } }, "500": { - "$ref": "#/components/responses/InternalServerError" + "description": "Internal server error", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "success": { + "type": "boolean", + "enum": [false] + }, + "msg": { + "type": "string" + }, + "data": { + "nullable": true + } + }, + "required": ["success", "msg"] + } + } + } } - }, + } + }, + "post": { + "tags": ["auth"], + "summary": "Create a new user (superadmin)", "security": [ { "bearerAuth": [] } - ] - } - }, - "/monitors/bulk": { - "post": { - "tags": ["monitors"], - "summary": "Bulk import monitors", - "description": "Import monitors from CSV file", + ], "requestBody": { - "required": true, "content": { "multipart/form-data": { "schema": { "type": "object", "properties": { - "csvFile": { + "firstName": { "type": "string", - "format": "binary", - "description": "CSV file containing monitor data" - } - } - } - } - } - }, - "responses": { - "200": { - "description": "Monitors imported successfully" - }, - "422": { - "$ref": "#/components/responses/ValidationError" - }, - "500": { - "$ref": "#/components/responses/InternalServerError" - } - }, - "security": [ - { - "bearerAuth": [] - } - ] - } - }, - "/monitors/test-email": { - "post": { - "tags": ["monitors"], - "summary": "Send test email", - "description": "Send a test email notification", - "responses": { - "200": { - "$ref": "#/components/responses/Success" - }, - "500": { - "$ref": "#/components/responses/InternalServerError" - } - }, - "security": [ - { - "bearerAuth": [] - } - ] - } - }, - "/settings": { - "get": { - "tags": ["settings"], - "summary": "Get application settings", - "description": "Retrieve current application configuration", - "responses": { - "200": { - "description": "Settings retrieved successfully", - "content": { - "application/json": { - "schema": { - "allOf": [ - { - "$ref": "#/components/schemas/SuccessResponse" + "maxLength": 50, + "pattern": "^(?=.*[\\p{L}\\p{Sc}])[\\p{L}\\p{Sc}\\s'\\-().]+$/u" + }, + "lastName": { + "type": "string", + "maxLength": 50, + "pattern": "^(?=.*[\\p{L}\\p{Sc}])[\\p{L}\\p{Sc}\\s'\\-().]+$/u" + }, + "email": { + "type": "string", + "format": "email" + }, + "password": { + "type": "string", + "minLength": 8, + "pattern": "^(?=.*[a-z])(?=.*[A-Z])(?=.*[0-9])(?=.*[!?@#$%^&*()\\-_=+[\\]{};:'\",.~`|\\\\/])[A-Za-z0-9!?@#$%^&*()\\-_=+[\\]{};:'\",.~`|\\\\/]+$" + }, + "role": { + "type": "array", + "items": { + "type": "string", + "enum": ["user", "admin", "superadmin", "demo"] }, - { - "type": "object", - "properties": { - "data": { - "$ref": "#/components/schemas/AppSettings" - } - } - } - ] - } - } - } - }, - "500": { - "$ref": "#/components/responses/InternalServerError" - } - }, - "security": [ - { - "bearerAuth": [] - } - ] - }, - "put": { - "tags": ["settings"], - "summary": "Update application settings", - "description": "Update application configuration (admin/superadmin only)", - "requestBody": { - "required": true, - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/AppSettings" - } - } - } - }, - "responses": { - "200": { - "$ref": "#/components/responses/Success" - }, - "403": { - "$ref": "#/components/responses/Forbidden" - }, - "422": { - "$ref": "#/components/responses/ValidationError" - }, - "500": { - "$ref": "#/components/responses/InternalServerError" - } - }, - "security": [ - { - "bearerAuth": [] - } - ] - } - }, - "/settings/test-email": { - "post": { - "tags": ["settings"], - "summary": "Send test email", - "description": "Send test email to verify email configuration", - "responses": { - "200": { - "$ref": "#/components/responses/Success" - }, - "403": { - "$ref": "#/components/responses/Forbidden" - }, - "500": { - "$ref": "#/components/responses/InternalServerError" - } - }, - "security": [ - { - "bearerAuth": [] - } - ] - } - }, - "/notifications": { - "post": { - "tags": ["notifications"], - "summary": "Create notification", - "description": "Create a new notification integration", - "requestBody": { - "required": true, - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/CreateNotificationRequest" + "minItems": 1 + }, + "profileImage": { + "type": "string", + "format": "binary" + } + }, + "required": ["firstName", "lastName", "email", "password", "role", "profileImage"] } } } }, "responses": { "201": { - "description": "Notification created successfully" - }, - "422": { - "$ref": "#/components/responses/ValidationError" - }, - "500": { - "$ref": "#/components/responses/InternalServerError" - } - }, - "security": [ - { - "bearerAuth": [] - } - ] - } - }, - "/notifications/team": { - "get": { - "tags": ["notifications"], - "summary": "Get team notifications", - "description": "Get all notification configurations for the team", - "responses": { - "200": { - "description": "Notifications retrieved successfully", + "description": "User created", "content": { "application/json": { "schema": { - "allOf": [ - { - "$ref": "#/components/schemas/SuccessResponse" + "type": "object", + "properties": { + "success": { + "type": "boolean", + "enum": [true] }, - { - "type": "object", - "properties": { - "data": { - "type": "array", - "items": { - "$ref": "#/components/schemas/Notification" - } - } - } + "msg": { + "type": "string" + }, + "data": { + "$ref": "#/components/schemas/User" } - ] + }, + "required": ["success", "msg", "data"] } } } }, - "500": { - "$ref": "#/components/responses/InternalServerError" - } - }, - "security": [ - { - "bearerAuth": [] - } - ] - } - }, - "/notifications/{id}": { - "get": { - "tags": ["notifications"], - "summary": "Get notification by ID", - "description": "Retrieve a specific notification configuration", - "parameters": [ - { - "name": "id", - "in": "path", - "required": true, - "schema": { - "type": "string" - } - } - ], - "responses": { - "200": { - "description": "Notification retrieved successfully" - }, - "404": { - "$ref": "#/components/responses/NotFound" - }, - "500": { - "$ref": "#/components/responses/InternalServerError" - } - }, - "security": [ - { - "bearerAuth": [] - } - ] - }, - "put": { - "tags": ["notifications"], - "summary": "Update notification", - "description": "Update an existing notification configuration", - "parameters": [ - { - "name": "id", - "in": "path", - "required": true, - "schema": { - "type": "string" - } - } - ], - "requestBody": { - "required": true, - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/CreateNotificationRequest" - } - } - } - }, - "responses": { - "200": { - "$ref": "#/components/responses/Success" - }, - "404": { - "$ref": "#/components/responses/NotFound" - }, - "422": { - "$ref": "#/components/responses/ValidationError" - }, - "500": { - "$ref": "#/components/responses/InternalServerError" - } - }, - "security": [ - { - "bearerAuth": [] - } - ] - }, - "delete": { - "tags": ["notifications"], - "summary": "Delete notification", - "description": "Delete a notification configuration", - "parameters": [ - { - "name": "id", - "in": "path", - "required": true, - "schema": { - "type": "string" - } - } - ], - "responses": { - "200": { - "$ref": "#/components/responses/Success" - }, - "404": { - "$ref": "#/components/responses/NotFound" - }, - "500": { - "$ref": "#/components/responses/InternalServerError" - } - }, - "security": [ - { - "bearerAuth": [] - } - ] - } - }, - "/notifications/test": { - "post": { - "tags": ["notifications"], - "summary": "Test notification", - "description": "Send a test notification to verify configuration", - "responses": { - "200": { - "$ref": "#/components/responses/Success" - }, - "500": { - "$ref": "#/components/responses/InternalServerError" - } - }, - "security": [ - { - "bearerAuth": [] - } - ] - } - }, - "/notifications/test/all": { - "post": { - "tags": ["notifications"], - "summary": "Test all notifications", - "description": "Send test notifications to all configured integrations", - "responses": { - "200": { - "$ref": "#/components/responses/Success" - }, - "500": { - "$ref": "#/components/responses/InternalServerError" - } - }, - "security": [ - { - "bearerAuth": [] - } - ] - } - }, - "/logs": { - "get": { - "tags": ["logs"], - "summary": "Get application logs", - "description": "Retrieve application logs (admin/superadmin only)", - "parameters": [ - { - "name": "level", - "in": "query", - "description": "Log level filter", - "schema": { - "type": "string", - "enum": ["error", "warn", "info", "debug"] - } - }, - { - "name": "limit", - "in": "query", - "description": "Number of log entries to return", - "schema": { - "type": "integer", - "minimum": 1, - "maximum": 1000, - "default": 100 - } - } - ], - "responses": { - "200": { - "description": "Logs retrieved successfully" - }, - "403": { - "$ref": "#/components/responses/Forbidden" - }, - "500": { - "$ref": "#/components/responses/InternalServerError" - } - }, - "security": [ - { - "bearerAuth": [] - } - ] - } - }, - "/diagnostic/system": { - "get": { - "tags": ["diagnostic"], - "summary": "Get system diagnostics", - "description": "Get system health and performance metrics (admin/superadmin only)", - "responses": { - "200": { - "description": "System diagnostics retrieved successfully", + "401": { + "description": "Unauthorized", "content": { "application/json": { "schema": { - "allOf": [ - { - "$ref": "#/components/schemas/SuccessResponse" + "type": "object", + "properties": { + "success": { + "type": "boolean", + "enum": [false] }, - { - "type": "object", - "properties": { - "data": { - "$ref": "#/components/schemas/SystemDiagnostics" - } - } + "msg": { + "type": "string" + }, + "data": { + "nullable": true } - ] + }, + "required": ["success", "msg"] } } } }, "403": { - "$ref": "#/components/responses/Forbidden" + "description": "Forbidden", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "success": { + "type": "boolean", + "enum": [false] + }, + "msg": { + "type": "string" + }, + "data": { + "nullable": true + } + }, + "required": ["success", "msg"] + } + } + } }, "500": { - "$ref": "#/components/responses/InternalServerError" - } - }, - "security": [ - { - "bearerAuth": [] - } - ] - } - }, - "/auth/user": { - "put": { - "tags": ["auth"], - "summary": "Update current user profile", - "description": "Update authenticated user's profile information", - "requestBody": { - "required": true, - "content": { - "multipart/form-data": { - "schema": { - "$ref": "#/components/schemas/UserUpdateRequest" + "description": "Internal server error", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "success": { + "type": "boolean", + "enum": [false] + }, + "msg": { + "type": "string" + }, + "data": { + "nullable": true + } + }, + "required": ["success", "msg"] + } } } } - }, - "responses": { - "200": { - "$ref": "#/components/responses/Success" - }, - "422": { - "$ref": "#/components/responses/ValidationError" - }, - "500": { - "$ref": "#/components/responses/InternalServerError" - } - }, - "security": [ - { - "bearerAuth": [] - } - ] - }, - "delete": { - "tags": ["auth"], - "summary": "Delete current user account", - "description": "Delete authenticated user's account and associated data", - "responses": { - "200": { - "$ref": "#/components/responses/Success" - }, - "500": { - "$ref": "#/components/responses/InternalServerError" - } - }, - "security": [ - { - "bearerAuth": [] - } - ] + } } }, "/auth/users/{userId}": { "get": { "tags": ["auth"], - "summary": "Get user by ID", - "description": "Get a specific user by ID (superadmin only)", + "summary": "Get a user by id", + "security": [ + { + "bearerAuth": [] + } + ], "parameters": [ { - "name": "userId", - "in": "path", - "required": true, "schema": { - "type": "string" - } + "type": "string", + "minLength": 1 + }, + "required": true, + "name": "userId", + "in": "path" } ], "responses": { "200": { - "description": "User retrieved successfully", + "description": "OK", "content": { "application/json": { "schema": { - "allOf": [ - { - "$ref": "#/components/schemas/SuccessResponse" + "type": "object", + "properties": { + "success": { + "type": "boolean", + "enum": [true] }, - { - "type": "object", - "properties": { - "data": { - "$ref": "#/components/schemas/User" - } - } + "msg": { + "type": "string" + }, + "data": { + "$ref": "#/components/schemas/User" } - ] + }, + "required": ["success", "msg", "data"] + } + } + } + }, + "401": { + "description": "Unauthorized", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "success": { + "type": "boolean", + "enum": [false] + }, + "msg": { + "type": "string" + }, + "data": { + "nullable": true + } + }, + "required": ["success", "msg"] + } + } + } + }, + "403": { + "description": "Forbidden", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "success": { + "type": "boolean", + "enum": [false] + }, + "msg": { + "type": "string" + }, + "data": { + "nullable": true + } + }, + "required": ["success", "msg"] } } } }, "404": { - "$ref": "#/components/responses/NotFound" - }, - "403": { - "$ref": "#/components/responses/Forbidden" + "description": "User not found", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "success": { + "type": "boolean", + "enum": [false] + }, + "msg": { + "type": "string" + } + }, + "required": ["success", "msg"] + } + } + } }, "500": { - "$ref": "#/components/responses/InternalServerError" + "description": "Internal server error", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "success": { + "type": "boolean", + "enum": [false] + }, + "msg": { + "type": "string" + }, + "data": { + "nullable": true + } + }, + "required": ["success", "msg"] + } + } + } } - }, + } + }, + "patch": { + "tags": ["auth"], + "summary": "Edit a user (superadmin)", "security": [ { "bearerAuth": [] } - ] - }, - "put": { - "tags": ["auth"], - "summary": "Update user by ID", - "description": "Update a specific user by ID (superadmin only)", + ], "parameters": [ { - "name": "userId", - "in": "path", - "required": true, "schema": { - "type": "string" - } + "type": "string", + "minLength": 1 + }, + "required": true, + "name": "userId", + "in": "path" } ], "requestBody": { - "required": true, "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/UserUpdateRequest" + "type": "object", + "properties": { + "firstName": { + "type": "string", + "maxLength": 50, + "pattern": "^(?=.*[\\p{L}\\p{Sc}])[\\p{L}\\p{Sc}\\s'\\-().]+$/u" + }, + "lastName": { + "type": "string", + "maxLength": 50, + "pattern": "^(?=.*[\\p{L}\\p{Sc}])[\\p{L}\\p{Sc}\\s'\\-().]+$/u" + }, + "role": { + "type": "array", + "items": { + "type": "string", + "enum": ["user", "admin", "superadmin", "demo"] + }, + "minItems": 1 + } + }, + "required": ["firstName", "lastName", "role"] } } } }, "responses": { "200": { - "$ref": "#/components/responses/Success" + "description": "OK", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "success": { + "type": "boolean", + "enum": [true] + }, + "msg": { + "type": "string" + } + }, + "required": ["success", "msg"] + }, + "example": { + "success": true, + "msg": "OK" + } + } + } }, - "404": { - "$ref": "#/components/responses/NotFound" + "401": { + "description": "Unauthorized", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "success": { + "type": "boolean", + "enum": [false] + }, + "msg": { + "type": "string" + }, + "data": { + "nullable": true + } + }, + "required": ["success", "msg"] + } + } + } }, "403": { - "$ref": "#/components/responses/Forbidden" - }, - "422": { - "$ref": "#/components/responses/ValidationError" + "description": "Forbidden", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "success": { + "type": "boolean", + "enum": [false] + }, + "msg": { + "type": "string" + }, + "data": { + "nullable": true + } + }, + "required": ["success", "msg"] + } + } + } }, "500": { - "$ref": "#/components/responses/InternalServerError" + "description": "Internal server error", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "success": { + "type": "boolean", + "enum": [false] + }, + "msg": { + "type": "string" + }, + "data": { + "nullable": true + } + }, + "required": ["success", "msg"] + } + } + } } - }, + } + }, + "delete": { + "tags": ["auth"], + "summary": "Delete a user (admin/superadmin)", "security": [ { "bearerAuth": [] } - ] + ], + "parameters": [ + { + "schema": { + "type": "string", + "minLength": 1 + }, + "required": true, + "name": "userId", + "in": "path" + } + ], + "responses": { + "200": { + "description": "OK", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "success": { + "type": "boolean", + "enum": [true] + }, + "msg": { + "type": "string" + } + }, + "required": ["success", "msg"] + }, + "example": { + "success": true, + "msg": "OK" + } + } + } + }, + "401": { + "description": "Unauthorized", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "success": { + "type": "boolean", + "enum": [false] + }, + "msg": { + "type": "string" + }, + "data": { + "nullable": true + } + }, + "required": ["success", "msg"] + } + } + } + }, + "403": { + "description": "Forbidden", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "success": { + "type": "boolean", + "enum": [false] + }, + "msg": { + "type": "string" + }, + "data": { + "nullable": true + } + }, + "required": ["success", "msg"] + } + } + } + }, + "500": { + "description": "Internal server error", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "success": { + "type": "boolean", + "enum": [false] + }, + "msg": { + "type": "string" + }, + "data": { + "nullable": true + } + }, + "required": ["success", "msg"] + } + } + } + } + } } }, "/auth/users/{userId}/password": { - "put": { + "patch": { "tags": ["auth"], - "summary": "Update user password by ID", - "description": "Update a specific user's password by ID (superadmin only)", + "summary": "Change a user's password (superadmin)", + "security": [ + { + "bearerAuth": [] + } + ], "parameters": [ { - "name": "userId", - "in": "path", - "required": true, "schema": { - "type": "string" - } + "type": "string", + "minLength": 1 + }, + "required": true, + "name": "userId", + "in": "path" } ], "requestBody": { - "required": true, "content": { "application/json": { "schema": { "type": "object", - "required": ["password"], "properties": { "password": { "type": "string", - "format": "password", "minLength": 8, - "description": "New password for the user" + "pattern": "^(?=.*[a-z])(?=.*[A-Z])(?=.*[0-9])(?=.*[!?@#$%^&*()\\-_=+[\\]{};:'\",.~`|\\\\/])[A-Za-z0-9!?@#$%^&*()\\-_=+[\\]{};:'\",.~`|\\\\/]+$" } - } + }, + "required": ["password"] } } } }, "responses": { "200": { - "$ref": "#/components/responses/Success" + "description": "OK", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "success": { + "type": "boolean", + "enum": [true] + }, + "msg": { + "type": "string" + } + }, + "required": ["success", "msg"] + }, + "example": { + "success": true, + "msg": "OK" + } + } + } }, - "404": { - "$ref": "#/components/responses/NotFound" + "401": { + "description": "Unauthorized", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "success": { + "type": "boolean", + "enum": [false] + }, + "msg": { + "type": "string" + }, + "data": { + "nullable": true + } + }, + "required": ["success", "msg"] + } + } + } }, "403": { - "$ref": "#/components/responses/Forbidden" - }, - "422": { - "$ref": "#/components/responses/ValidationError" + "description": "Forbidden", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "success": { + "type": "boolean", + "enum": [false] + }, + "msg": { + "type": "string" + }, + "data": { + "nullable": true + } + }, + "required": ["success", "msg"] + } + } + } }, "500": { - "$ref": "#/components/responses/InternalServerError" + "description": "Internal server error", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "success": { + "type": "boolean", + "enum": [false] + }, + "msg": { + "type": "string" + }, + "data": { + "nullable": true + } + }, + "required": ["success", "msg"] + } + } + } } - }, + } + } + }, + "/auth/user": { + "patch": { + "tags": ["auth"], + "summary": "Edit the currently authenticated user", "security": [ { "bearerAuth": [] } - ] + ], + "requestBody": { + "content": { + "multipart/form-data": { + "schema": { + "type": "object", + "properties": { + "firstName": { + "type": "string", + "maxLength": 50, + "pattern": "^(?=.*[\\p{L}\\p{Sc}])[\\p{L}\\p{Sc}\\s'\\-().]+$/u" + }, + "lastName": { + "type": "string", + "maxLength": 50, + "pattern": "^(?=.*[\\p{L}\\p{Sc}])[\\p{L}\\p{Sc}\\s'\\-().]+$/u" + }, + "email": { + "type": "string", + "format": "email" + }, + "profilePicture": { + "type": "string" + }, + "profileImage": { + "type": "string", + "format": "binary" + } + }, + "required": ["profileImage"] + } + } + } + }, + "responses": { + "200": { + "description": "OK", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "success": { + "type": "boolean", + "enum": [true] + }, + "msg": { + "type": "string" + }, + "data": { + "$ref": "#/components/schemas/User" + } + }, + "required": ["success", "msg", "data"] + } + } + } + }, + "401": { + "description": "Unauthorized", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "success": { + "type": "boolean", + "enum": [false] + }, + "msg": { + "type": "string" + }, + "data": { + "nullable": true + } + }, + "required": ["success", "msg"] + } + } + } + }, + "403": { + "description": "Forbidden", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "success": { + "type": "boolean", + "enum": [false] + }, + "msg": { + "type": "string" + }, + "data": { + "nullable": true + } + }, + "required": ["success", "msg"] + } + } + } + }, + "500": { + "description": "Internal server error", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "success": { + "type": "boolean", + "enum": [false] + }, + "msg": { + "type": "string" + }, + "data": { + "nullable": true + } + }, + "required": ["success", "msg"] + } + } + } + } + } + }, + "delete": { + "tags": ["auth"], + "summary": "Delete the currently authenticated user", + "security": [ + { + "bearerAuth": [] + } + ], + "responses": { + "200": { + "description": "OK", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "success": { + "type": "boolean", + "enum": [true] + }, + "msg": { + "type": "string" + } + }, + "required": ["success", "msg"] + }, + "example": { + "success": true, + "msg": "OK" + } + } + } + }, + "401": { + "description": "Unauthorized", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "success": { + "type": "boolean", + "enum": [false] + }, + "msg": { + "type": "string" + }, + "data": { + "nullable": true + } + }, + "required": ["success", "msg"] + } + } + } + }, + "403": { + "description": "Forbidden", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "success": { + "type": "boolean", + "enum": [false] + }, + "msg": { + "type": "string" + }, + "data": { + "nullable": true + } + }, + "required": ["success", "msg"] + } + } + } + }, + "500": { + "description": "Internal server error", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "success": { + "type": "boolean", + "enum": [false] + }, + "msg": { + "type": "string" + }, + "data": { + "nullable": true + } + }, + "required": ["success", "msg"] + } + } + } + } + } } }, - "/invite": { + "/checks/team/summary": { + "get": { + "tags": ["checks"], + "summary": "Aggregate check summary for the caller's team", + "security": [ + { + "bearerAuth": [] + } + ], + "parameters": [ + { + "schema": { + "type": "string", + "enum": ["recent", "hour", "day", "week", "month", "all"] + }, + "required": false, + "name": "dateRange", + "in": "query" + } + ], + "responses": { + "200": { + "description": "OK", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "success": { + "type": "boolean", + "enum": [true] + }, + "msg": { + "type": "string" + }, + "data": { + "nullable": true + } + }, + "required": ["success", "msg"] + } + } + } + }, + "401": { + "description": "Unauthorized", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "success": { + "type": "boolean", + "enum": [false] + }, + "msg": { + "type": "string" + }, + "data": { + "nullable": true + } + }, + "required": ["success", "msg"] + } + } + } + }, + "403": { + "description": "Forbidden", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "success": { + "type": "boolean", + "enum": [false] + }, + "msg": { + "type": "string" + }, + "data": { + "nullable": true + } + }, + "required": ["success", "msg"] + } + } + } + }, + "500": { + "description": "Internal server error", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "success": { + "type": "boolean", + "enum": [false] + }, + "msg": { + "type": "string" + }, + "data": { + "nullable": true + } + }, + "required": ["success", "msg"] + } + } + } + } + } + } + }, + "/checks/team": { + "get": { + "tags": ["checks"], + "summary": "List checks across the team", + "security": [ + { + "bearerAuth": [] + } + ], + "parameters": [ + { + "schema": { + "type": "string", + "enum": ["asc", "desc"] + }, + "required": true, + "name": "sortOrder", + "in": "query" + }, + { + "schema": { + "type": "number", + "nullable": true + }, + "required": false, + "name": "limit", + "in": "query" + }, + { + "schema": { + "type": "string", + "enum": ["recent", "hour", "day", "week", "month", "all"] + }, + "required": true, + "name": "dateRange", + "in": "query" + }, + { + "schema": { + "type": "string", + "enum": ["all", "up", "down", "resolve"] + }, + "required": false, + "name": "filter", + "in": "query" + }, + { + "schema": { + "type": "boolean" + }, + "required": false, + "name": "ack", + "in": "query" + }, + { + "schema": { + "type": "number", + "nullable": true + }, + "required": false, + "name": "page", + "in": "query" + }, + { + "schema": { + "type": "number", + "nullable": true + }, + "required": false, + "name": "rowsPerPage", + "in": "query" + } + ], + "responses": { + "200": { + "description": "OK", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "success": { + "type": "boolean", + "enum": [true] + }, + "msg": { + "type": "string" + }, + "data": { + "nullable": true + } + }, + "required": ["success", "msg"] + } + } + } + }, + "401": { + "description": "Unauthorized", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "success": { + "type": "boolean", + "enum": [false] + }, + "msg": { + "type": "string" + }, + "data": { + "nullable": true + } + }, + "required": ["success", "msg"] + } + } + } + }, + "403": { + "description": "Forbidden", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "success": { + "type": "boolean", + "enum": [false] + }, + "msg": { + "type": "string" + }, + "data": { + "nullable": true + } + }, + "required": ["success", "msg"] + } + } + } + }, + "500": { + "description": "Internal server error", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "success": { + "type": "boolean", + "enum": [false] + }, + "msg": { + "type": "string" + }, + "data": { + "nullable": true + } + }, + "required": ["success", "msg"] + } + } + } + } + } + }, + "delete": { + "tags": ["checks"], + "summary": "Delete all checks for the team (admin/superadmin)", + "security": [ + { + "bearerAuth": [] + } + ], + "responses": { + "200": { + "description": "OK", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "success": { + "type": "boolean", + "enum": [true] + }, + "msg": { + "type": "string" + }, + "data": { + "type": "object", + "properties": { + "deletedCount": { + "type": "number" + } + }, + "required": ["deletedCount"] + } + }, + "required": ["success", "msg", "data"] + } + } + } + }, + "401": { + "description": "Unauthorized", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "success": { + "type": "boolean", + "enum": [false] + }, + "msg": { + "type": "string" + }, + "data": { + "nullable": true + } + }, + "required": ["success", "msg"] + } + } + } + }, + "403": { + "description": "Forbidden", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "success": { + "type": "boolean", + "enum": [false] + }, + "msg": { + "type": "string" + }, + "data": { + "nullable": true + } + }, + "required": ["success", "msg"] + } + } + } + }, + "500": { + "description": "Internal server error", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "success": { + "type": "boolean", + "enum": [false] + }, + "msg": { + "type": "string" + }, + "data": { + "nullable": true + } + }, + "required": ["success", "msg"] + } + } + } + } + } + } + }, + "/checks/{monitorId}": { + "get": { + "tags": ["checks"], + "summary": "List checks for a monitor", + "security": [ + { + "bearerAuth": [] + } + ], + "parameters": [ + { + "schema": { + "type": "string", + "minLength": 1 + }, + "required": true, + "name": "monitorId", + "in": "path" + }, + { + "schema": { + "type": "string", + "enum": ["http", "ping", "pagespeed", "hardware", "docker", "port", "game", "grpc", "websocket"] + }, + "required": false, + "name": "type", + "in": "query" + }, + { + "schema": { + "type": "string", + "enum": ["asc", "desc"] + }, + "required": true, + "name": "sortOrder", + "in": "query" + }, + { + "schema": { + "type": "number", + "nullable": true + }, + "required": false, + "name": "limit", + "in": "query" + }, + { + "schema": { + "type": "string", + "enum": ["recent", "hour", "day", "week", "month", "all"] + }, + "required": true, + "name": "dateRange", + "in": "query" + }, + { + "schema": { + "type": "string", + "enum": ["all", "up", "down", "resolve"] + }, + "required": false, + "name": "filter", + "in": "query" + }, + { + "schema": { + "type": "boolean" + }, + "required": false, + "name": "ack", + "in": "query" + }, + { + "schema": { + "type": "number", + "nullable": true + }, + "required": false, + "name": "page", + "in": "query" + }, + { + "schema": { + "type": "number", + "nullable": true + }, + "required": false, + "name": "rowsPerPage", + "in": "query" + }, + { + "schema": { + "type": "boolean" + }, + "required": false, + "name": "status", + "in": "query" + }, + { + "schema": { + "anyOf": [ + { + "type": "string", + "enum": ["EU", "NA", "AS", "SA", "AF", "OC"] + }, + { + "type": "array", + "items": { + "type": "string", + "enum": ["EU", "NA", "AS", "SA", "AF", "OC"] + } + } + ] + }, + "required": false, + "name": "continent", + "in": "query" + } + ], + "responses": { + "200": { + "description": "OK", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "success": { + "type": "boolean", + "enum": [true] + }, + "msg": { + "type": "string" + }, + "data": { + "nullable": true + } + }, + "required": ["success", "msg"] + } + } + } + }, + "401": { + "description": "Unauthorized", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "success": { + "type": "boolean", + "enum": [false] + }, + "msg": { + "type": "string" + }, + "data": { + "nullable": true + } + }, + "required": ["success", "msg"] + } + } + } + }, + "403": { + "description": "Forbidden", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "success": { + "type": "boolean", + "enum": [false] + }, + "msg": { + "type": "string" + }, + "data": { + "nullable": true + } + }, + "required": ["success", "msg"] + } + } + } + }, + "500": { + "description": "Internal server error", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "success": { + "type": "boolean", + "enum": [false] + }, + "msg": { + "type": "string" + }, + "data": { + "nullable": true + } + }, + "required": ["success", "msg"] + } + } + } + } + } + }, + "delete": { + "tags": ["checks"], + "summary": "Delete checks for a monitor", + "security": [ + { + "bearerAuth": [] + } + ], + "parameters": [ + { + "schema": { + "type": "string", + "minLength": 1 + }, + "required": true, + "name": "monitorId", + "in": "path" + } + ], + "responses": { + "200": { + "description": "OK", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "success": { + "type": "boolean", + "enum": [true] + }, + "msg": { + "type": "string" + } + }, + "required": ["success", "msg"] + }, + "example": { + "success": true, + "msg": "OK" + } + } + } + }, + "401": { + "description": "Unauthorized", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "success": { + "type": "boolean", + "enum": [false] + }, + "msg": { + "type": "string" + }, + "data": { + "nullable": true + } + }, + "required": ["success", "msg"] + } + } + } + }, + "403": { + "description": "Forbidden", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "success": { + "type": "boolean", + "enum": [false] + }, + "msg": { + "type": "string" + }, + "data": { + "nullable": true + } + }, + "required": ["success", "msg"] + } + } + } + }, + "500": { + "description": "Internal server error", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "success": { + "type": "boolean", + "enum": [false] + }, + "msg": { + "type": "string" + }, + "data": { + "nullable": true + } + }, + "required": ["success", "msg"] + } + } + } + } + } + } + }, + "/diagnostic/system": { + "get": { + "tags": ["diagnostic"], + "summary": "Get system diagnostics (admin/superadmin)", + "security": [ + { + "bearerAuth": [] + } + ], + "responses": { + "200": { + "description": "OK", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "success": { + "type": "boolean", + "enum": [true] + }, + "msg": { + "type": "string" + }, + "data": { + "nullable": true + } + }, + "required": ["success", "msg"] + } + } + } + }, + "401": { + "description": "Unauthorized", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "success": { + "type": "boolean", + "enum": [false] + }, + "msg": { + "type": "string" + }, + "data": { + "nullable": true + } + }, + "required": ["success", "msg"] + } + } + } + }, + "403": { + "description": "Forbidden", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "success": { + "type": "boolean", + "enum": [false] + }, + "msg": { + "type": "string" + }, + "data": { + "nullable": true + } + }, + "required": ["success", "msg"] + } + } + } + }, + "500": { + "description": "Internal server error", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "success": { + "type": "boolean", + "enum": [false] + }, + "msg": { + "type": "string" + }, + "data": { + "nullable": true + } + }, + "required": ["success", "msg"] + } + } + } + } + } + } + }, + "/geo-checks/{monitorId}": { + "get": { + "tags": ["geo-checks"], + "summary": "Get geo check results for a monitor", + "security": [ + { + "bearerAuth": [] + } + ], + "parameters": [ + { + "schema": { + "type": "string", + "minLength": 1 + }, + "required": true, + "name": "monitorId", + "in": "path" + }, + { + "schema": { + "type": "string", + "enum": ["http", "ping", "pagespeed", "hardware", "docker", "port", "game", "grpc", "websocket"] + }, + "required": false, + "name": "type", + "in": "query" + }, + { + "schema": { + "type": "string", + "enum": ["asc", "desc"] + }, + "required": true, + "name": "sortOrder", + "in": "query" + }, + { + "schema": { + "type": "number", + "nullable": true + }, + "required": false, + "name": "limit", + "in": "query" + }, + { + "schema": { + "type": "string", + "enum": ["recent", "hour", "day", "week", "month", "all"] + }, + "required": true, + "name": "dateRange", + "in": "query" + }, + { + "schema": { + "type": "string", + "enum": ["all", "up", "down", "resolve"] + }, + "required": false, + "name": "filter", + "in": "query" + }, + { + "schema": { + "type": "boolean" + }, + "required": false, + "name": "ack", + "in": "query" + }, + { + "schema": { + "type": "number", + "nullable": true + }, + "required": false, + "name": "page", + "in": "query" + }, + { + "schema": { + "type": "number", + "nullable": true + }, + "required": false, + "name": "rowsPerPage", + "in": "query" + }, + { + "schema": { + "type": "boolean" + }, + "required": false, + "name": "status", + "in": "query" + }, + { + "schema": { + "anyOf": [ + { + "type": "string", + "enum": ["EU", "NA", "AS", "SA", "AF", "OC"] + }, + { + "type": "array", + "items": { + "type": "string", + "enum": ["EU", "NA", "AS", "SA", "AF", "OC"] + } + } + ] + }, + "required": false, + "name": "continent", + "in": "query" + } + ], + "responses": { + "200": { + "description": "OK", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "success": { + "type": "boolean", + "enum": [true] + }, + "msg": { + "type": "string" + }, + "data": { + "nullable": true + } + }, + "required": ["success", "msg"] + } + } + } + }, + "401": { + "description": "Unauthorized", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "success": { + "type": "boolean", + "enum": [false] + }, + "msg": { + "type": "string" + }, + "data": { + "nullable": true + } + }, + "required": ["success", "msg"] + } + } + } + }, + "403": { + "description": "Forbidden", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "success": { + "type": "boolean", + "enum": [false] + }, + "msg": { + "type": "string" + }, + "data": { + "nullable": true + } + }, + "required": ["success", "msg"] + } + } + } + }, + "500": { + "description": "Internal server error", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "success": { + "type": "boolean", + "enum": [false] + }, + "msg": { + "type": "string" + }, + "data": { + "nullable": true + } + }, + "required": ["success", "msg"] + } + } + } + } + } + } + }, + "/incidents/team": { + "get": { + "tags": ["incidents"], + "summary": "List incidents for the caller's team", + "security": [ + { + "bearerAuth": [] + } + ], + "parameters": [ + { + "schema": { + "type": "string", + "enum": ["asc", "desc"] + }, + "required": true, + "name": "sortOrder", + "in": "query" + }, + { + "schema": { + "type": "string", + "enum": ["recent", "hour", "day", "week", "month", "all"], + "default": "all" + }, + "required": false, + "name": "dateRange", + "in": "query" + }, + { + "schema": { + "type": "integer", + "nullable": true, + "minimum": 0 + }, + "required": false, + "name": "page", + "in": "query" + }, + { + "schema": { + "type": "integer", + "minimum": 1 + }, + "required": true, + "name": "rowsPerPage", + "in": "query" + }, + { + "schema": { + "type": "boolean" + }, + "required": false, + "name": "status", + "in": "query" + }, + { + "schema": { + "type": "string" + }, + "required": false, + "name": "monitorId", + "in": "query" + }, + { + "schema": { + "type": "string", + "enum": ["manual", "automatic"] + }, + "required": false, + "name": "resolutionType", + "in": "query" + } + ], + "responses": { + "200": { + "description": "OK", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "success": { + "type": "boolean", + "enum": [true] + }, + "msg": { + "type": "string" + }, + "data": { + "type": "array", + "items": { + "$ref": "#/components/schemas/Incident" + } + } + }, + "required": ["success", "msg", "data"] + } + } + } + }, + "401": { + "description": "Unauthorized", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "success": { + "type": "boolean", + "enum": [false] + }, + "msg": { + "type": "string" + }, + "data": { + "nullable": true + } + }, + "required": ["success", "msg"] + } + } + } + }, + "403": { + "description": "Forbidden", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "success": { + "type": "boolean", + "enum": [false] + }, + "msg": { + "type": "string" + }, + "data": { + "nullable": true + } + }, + "required": ["success", "msg"] + } + } + } + }, + "500": { + "description": "Internal server error", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "success": { + "type": "boolean", + "enum": [false] + }, + "msg": { + "type": "string" + }, + "data": { + "nullable": true + } + }, + "required": ["success", "msg"] + } + } + } + } + } + } + }, + "/incidents/team/summary": { + "get": { + "tags": ["incidents"], + "summary": "Incident summary for the caller's team", + "security": [ + { + "bearerAuth": [] + } + ], + "parameters": [ + { + "schema": { + "type": "integer", + "minimum": 1 + }, + "required": false, + "name": "limit", + "in": "query" + } + ], + "responses": { + "200": { + "description": "OK", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "success": { + "type": "boolean", + "enum": [true] + }, + "msg": { + "type": "string" + }, + "data": { + "$ref": "#/components/schemas/IncidentSummary" + } + }, + "required": ["success", "msg", "data"] + } + } + } + }, + "401": { + "description": "Unauthorized", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "success": { + "type": "boolean", + "enum": [false] + }, + "msg": { + "type": "string" + }, + "data": { + "nullable": true + } + }, + "required": ["success", "msg"] + } + } + } + }, + "403": { + "description": "Forbidden", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "success": { + "type": "boolean", + "enum": [false] + }, + "msg": { + "type": "string" + }, + "data": { + "nullable": true + } + }, + "required": ["success", "msg"] + } + } + } + }, + "500": { + "description": "Internal server error", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "success": { + "type": "boolean", + "enum": [false] + }, + "msg": { + "type": "string" + }, + "data": { + "nullable": true + } + }, + "required": ["success", "msg"] + } + } + } + } + } + } + }, + "/incidents/{incidentId}": { + "get": { + "tags": ["incidents"], + "summary": "Get an incident by id", + "security": [ + { + "bearerAuth": [] + } + ], + "parameters": [ + { + "schema": { + "type": "string" + }, + "required": true, + "name": "incidentId", + "in": "path" + } + ], + "responses": { + "200": { + "description": "OK", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "success": { + "type": "boolean", + "enum": [true] + }, + "msg": { + "type": "string" + }, + "data": { + "type": "object", + "properties": { + "incident": { + "$ref": "#/components/schemas/Incident" + }, + "monitor": { + "nullable": true + }, + "user": { + "nullable": true + } + }, + "required": ["incident"] + } + }, + "required": ["success", "msg", "data"] + } + } + } + }, + "401": { + "description": "Unauthorized", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "success": { + "type": "boolean", + "enum": [false] + }, + "msg": { + "type": "string" + }, + "data": { + "nullable": true + } + }, + "required": ["success", "msg"] + } + } + } + }, + "403": { + "description": "Forbidden", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "success": { + "type": "boolean", + "enum": [false] + }, + "msg": { + "type": "string" + }, + "data": { + "nullable": true + } + }, + "required": ["success", "msg"] + } + } + } + }, + "500": { + "description": "Internal server error", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "success": { + "type": "boolean", + "enum": [false] + }, + "msg": { + "type": "string" + }, + "data": { + "nullable": true + } + }, + "required": ["success", "msg"] + } + } + } + } + } + } + }, + "/incidents/{incidentId}/resolve": { + "put": { + "tags": ["incidents"], + "summary": "Manually resolve an incident (admin/superadmin)", + "security": [ + { + "bearerAuth": [] + } + ], + "parameters": [ + { + "schema": { + "type": "string" + }, + "required": true, + "name": "incidentId", + "in": "path" + } + ], + "responses": { + "200": { + "description": "OK", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "success": { + "type": "boolean", + "enum": [true] + }, + "msg": { + "type": "string" + } + }, + "required": ["success", "msg"] + }, + "example": { + "success": true, + "msg": "OK" + } + } + } + }, + "401": { + "description": "Unauthorized", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "success": { + "type": "boolean", + "enum": [false] + }, + "msg": { + "type": "string" + }, + "data": { + "nullable": true + } + }, + "required": ["success", "msg"] + } + } + } + }, + "403": { + "description": "Forbidden", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "success": { + "type": "boolean", + "enum": [false] + }, + "msg": { + "type": "string" + }, + "data": { + "nullable": true + } + }, + "required": ["success", "msg"] + } + } + } + }, + "500": { + "description": "Internal server error", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "success": { + "type": "boolean", + "enum": [false] + }, + "msg": { + "type": "string" + }, + "data": { + "nullable": true + } + }, + "required": ["success", "msg"] + } + } + } + } + } + } + }, + "/invite/send": { "post": { "tags": ["invite"], - "summary": "Create invite token", - "description": "Create a new invitation token", + "summary": "Send an invite email (admin/superadmin)", + "security": [ + { + "bearerAuth": [] + } + ], "requestBody": { - "required": true, "content": { "application/json": { "schema": { "type": "object", - "required": ["email", "role"], "properties": { "email": { "type": "string", @@ -1862,292 +4148,210 @@ "type": "array", "items": { "type": "string", - "enum": ["user", "admin"] - } - } - } - } - } - } - }, - "responses": { - "201": { - "description": "Invite token created successfully" - }, - "403": { - "$ref": "#/components/responses/Forbidden" - }, - "422": { - "$ref": "#/components/responses/ValidationError" - }, - "500": { - "$ref": "#/components/responses/InternalServerError" - } - }, - "security": [ - { - "bearerAuth": [] - } - ] - } - }, - "/invite/send": { - "post": { - "tags": ["invite"], - "summary": "Send invitation email", - "description": "Send invitation email to user", - "requestBody": { - "required": true, - "content": { - "application/json": { - "schema": { - "type": "object", - "required": ["email"], - "properties": { - "email": { + "enum": ["user", "admin", "superadmin", "demo"] + }, + "minItems": 1 + }, + "teamId": { "type": "string", - "format": "email" + "minLength": 1 } - } + }, + "required": ["email", "role", "teamId"] } } } }, "responses": { "200": { - "$ref": "#/components/responses/Success" + "description": "OK", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "success": { + "type": "boolean", + "enum": [true] + }, + "msg": { + "type": "string" + } + }, + "required": ["success", "msg"] + }, + "example": { + "success": true, + "msg": "OK" + } + } + } + }, + "401": { + "description": "Unauthorized", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "success": { + "type": "boolean", + "enum": [false] + }, + "msg": { + "type": "string" + }, + "data": { + "nullable": true + } + }, + "required": ["success", "msg"] + } + } + } }, "403": { - "$ref": "#/components/responses/Forbidden" + "description": "Forbidden", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "success": { + "type": "boolean", + "enum": [false] + }, + "msg": { + "type": "string" + }, + "data": { + "nullable": true + } + }, + "required": ["success", "msg"] + } + } + } }, "500": { - "$ref": "#/components/responses/InternalServerError" + "description": "Internal server error", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "success": { + "type": "boolean", + "enum": [false] + }, + "msg": { + "type": "string" + }, + "data": { + "nullable": true + } + }, + "required": ["success", "msg"] + } + } + } } - }, - "security": [ - { - "bearerAuth": [] - } - ] + } } }, "/invite/verify": { "post": { "tags": ["invite"], - "summary": "Verify invitation token", - "description": "Verify an invitation token", + "summary": "Verify an invite token", "requestBody": { - "required": true, "content": { "application/json": { "schema": { "type": "object", - "required": ["token"], "properties": { "token": { - "type": "string" + "type": "string", + "minLength": 1 } - } + }, + "required": ["token"] } } } }, "responses": { "200": { - "$ref": "#/components/responses/Success" - }, - "400": { - "description": "Invalid or expired token" + "description": "OK", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "success": { + "type": "boolean", + "enum": [true] + }, + "msg": { + "type": "string" + }, + "data": { + "type": "object", + "properties": { + "email": { + "type": "string" + }, + "role": { + "type": "array", + "items": { + "type": "string" + } + } + }, + "required": ["email"], + "additionalProperties": { + "nullable": true + } + } + }, + "required": ["success", "msg", "data"] + } + } + } }, "500": { - "$ref": "#/components/responses/InternalServerError" + "description": "Internal server error", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "success": { + "type": "boolean", + "enum": [false] + }, + "msg": { + "type": "string" + }, + "data": { + "nullable": true + } + }, + "required": ["success", "msg"] + } + } + } } } } }, - "/checks/{monitorId}": { - "get": { - "tags": ["checks"], - "summary": "Get checks by monitor", - "description": "Get all checks for a specific monitor", - "parameters": [ - { - "name": "monitorId", - "in": "path", - "required": true, - "schema": { - "type": "string" - } - } - ], - "responses": { - "200": { - "description": "Checks retrieved successfully" - }, - "404": { - "$ref": "#/components/responses/NotFound" - }, - "500": { - "$ref": "#/components/responses/InternalServerError" - } - }, + "/invite": { + "post": { + "tags": ["invite"], + "summary": "Create an invite token (admin/superadmin)", "security": [ { "bearerAuth": [] } - ] - }, - "delete": { - "tags": ["checks"], - "summary": "Delete checks by monitor", - "description": "Delete all checks for a specific monitor", - "parameters": [ - { - "name": "monitorId", - "in": "path", - "required": true, - "schema": { - "type": "string" - } - } - ], - "responses": { - "200": { - "$ref": "#/components/responses/Success" - }, - "404": { - "$ref": "#/components/responses/NotFound" - }, - "500": { - "$ref": "#/components/responses/InternalServerError" - } - }, - "security": [ - { - "bearerAuth": [] - } - ] - } - }, - "/checks/team": { - "get": { - "tags": ["checks"], - "summary": "Get checks by team", - "description": "Get all checks for team monitors", - "responses": { - "200": { - "description": "Team checks retrieved successfully" - }, - "500": { - "$ref": "#/components/responses/InternalServerError" - } - }, - "security": [ - { - "bearerAuth": [] - } - ] - }, - "delete": { - "tags": ["checks"], - "summary": "Delete team checks", - "description": "Delete all checks for team (admin/superadmin only)", - "responses": { - "200": { - "$ref": "#/components/responses/Success" - }, - "403": { - "$ref": "#/components/responses/Forbidden" - }, - "500": { - "$ref": "#/components/responses/InternalServerError" - } - }, - "security": [ - { - "bearerAuth": [] - } - ] - } - }, - "/checks/team/summary": { - "get": { - "tags": ["checks"], - "summary": "Get team checks summary", - "description": "Get summary statistics for team checks", - "responses": { - "200": { - "description": "Team checks summary retrieved successfully" - }, - "500": { - "$ref": "#/components/responses/InternalServerError" - } - }, - "security": [ - { - "bearerAuth": [] - } - ] - } - }, - "/checks/team/ttl": { - "put": { - "tags": ["checks"], - "summary": "Update checks TTL", - "description": "Update time-to-live for checks (admin/superadmin only)", - "requestBody": { - "required": true, - "content": { - "application/json": { - "schema": { - "type": "object", - "required": ["ttl"], - "properties": { - "ttl": { - "type": "integer", - "minimum": 1, - "description": "Time to live in days" - } - } - } - } - } - }, - "responses": { - "200": { - "$ref": "#/components/responses/Success" - }, - "403": { - "$ref": "#/components/responses/Forbidden" - }, - "422": { - "$ref": "#/components/responses/ValidationError" - }, - "500": { - "$ref": "#/components/responses/InternalServerError" - } - }, - "security": [ - { - "bearerAuth": [] - } - ] - } - }, - "/checks/check/{checkId}": { - "put": { - "tags": ["checks"], - "summary": "Acknowledge check", - "description": "Acknowledge a specific check", - "parameters": [ - { - "name": "checkId", - "in": "path", - "required": true, - "schema": { - "type": "string" - } - } ], "requestBody": { "content": { @@ -2155,70 +4359,31 @@ "schema": { "type": "object", "properties": { - "ack": { - "type": "boolean", - "description": "Acknowledgement status" + "email": { + "type": "string", + "format": "email" + }, + "role": { + "type": "array", + "items": { + "type": "string", + "enum": ["user", "admin", "superadmin", "demo"] + }, + "minItems": 1 + }, + "teamId": { + "type": "string", + "minLength": 1 } - } + }, + "required": ["email", "role", "teamId"] } } } }, "responses": { "200": { - "$ref": "#/components/responses/Success" - }, - "404": { - "$ref": "#/components/responses/NotFound" - }, - "500": { - "$ref": "#/components/responses/InternalServerError" - } - }, - "security": [ - { - "bearerAuth": [] - } - ] - } - }, - "/checks/{action}": { - "put": { - "tags": ["checks"], - "summary": "Acknowledge all checks for all monitors", - "description": "Acknowledge all checks across all monitors for the team", - "parameters": [ - { - "name": "action", - "in": "path", - "required": true, - "description": "Action to perform (e.g., 'ack')", - "schema": { - "type": "string", - "enum": ["ack"] - } - } - ], - "requestBody": { - "required": true, - "content": { - "application/json": { - "schema": { - "type": "object", - "required": ["ack"], - "properties": { - "ack": { - "type": "boolean", - "description": "Acknowledgement status to set" - } - } - } - } - } - }, - "responses": { - "200": { - "description": "Checks acknowledged successfully", + "description": "OK", "content": { "application/json": { "schema": { @@ -2226,81 +4391,31 @@ "properties": { "success": { "type": "boolean", - "example": true + "enum": [true] }, "msg": { - "type": "string", - "example": "All checks acknowledged successfully" + "type": "string" }, "data": { "type": "object", - "description": "Updated checks information" + "properties": { + "token": { + "type": "string" + } + }, + "required": ["token"], + "additionalProperties": { + "nullable": true + } } - } + }, + "required": ["success", "msg", "data"] } } } }, "401": { - "$ref": "#/components/responses/Unauthorized" - }, - "500": { - "$ref": "#/components/responses/InternalServerError" - } - }, - "security": [ - { - "bearerAuth": [] - } - ] - } - }, - "/checks/{action}/{monitorId}": { - "put": { - "tags": ["checks"], - "summary": "Acknowledge all checks for a specific monitor", - "description": "Acknowledge all checks for a specific monitor", - "parameters": [ - { - "name": "action", - "in": "path", - "required": true, - "description": "Action to perform (e.g., 'ack')", - "schema": { - "type": "string", - "enum": ["ack"] - } - }, - { - "name": "monitorId", - "in": "path", - "required": true, - "description": "Monitor ID to acknowledge checks for", - "schema": { - "type": "string" - } - } - ], - "requestBody": { - "required": true, - "content": { - "application/json": { - "schema": { - "type": "object", - "required": ["ack"], - "properties": { - "ack": { - "type": "boolean", - "description": "Acknowledgement status to set" - } - } - } - } - } - }, - "responses": { - "200": { - "description": "Checks acknowledged successfully", + "description": "Unauthorized", "content": { "application/json": { "schema": { @@ -2308,216 +4423,760 @@ "properties": { "success": { "type": "boolean", - "example": true + "enum": [false] }, "msg": { - "type": "string", - "example": "All checks acknowledged successfully" + "type": "string" }, "data": { - "type": "object", - "description": "Updated checks information" + "nullable": true } - } + }, + "required": ["success", "msg"] + } + } + } + }, + "403": { + "description": "Forbidden", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "success": { + "type": "boolean", + "enum": [false] + }, + "msg": { + "type": "string" + }, + "data": { + "nullable": true + } + }, + "required": ["success", "msg"] + } + } + } + }, + "500": { + "description": "Internal server error", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "success": { + "type": "boolean", + "enum": [false] + }, + "msg": { + "type": "string" + }, + "data": { + "nullable": true + } + }, + "required": ["success", "msg"] + } + } + } + } + } + } + }, + "/logs": { + "get": { + "tags": ["logs"], + "summary": "Get application logs (admin/superadmin)", + "security": [ + { + "bearerAuth": [] + } + ], + "responses": { + "200": { + "description": "OK", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "success": { + "type": "boolean", + "enum": [true] + }, + "msg": { + "type": "string" + }, + "data": { + "nullable": true + } + }, + "required": ["success", "msg"] } } } }, "401": { - "$ref": "#/components/responses/Unauthorized" + "description": "Unauthorized", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "success": { + "type": "boolean", + "enum": [false] + }, + "msg": { + "type": "string" + }, + "data": { + "nullable": true + } + }, + "required": ["success", "msg"] + } + } + } }, - "404": { - "$ref": "#/components/responses/NotFound" + "403": { + "description": "Forbidden", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "success": { + "type": "boolean", + "enum": [false] + }, + "msg": { + "type": "string" + }, + "data": { + "nullable": true + } + }, + "required": ["success", "msg"] + } + } + } }, "500": { - "$ref": "#/components/responses/InternalServerError" + "description": "Internal server error", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "success": { + "type": "boolean", + "enum": [false] + }, + "msg": { + "type": "string" + }, + "data": { + "nullable": true + } + }, + "required": ["success", "msg"] + } + } + } } - }, - "security": [ - { - "bearerAuth": [] - } - ] + } } }, "/maintenance-window": { "post": { "tags": ["maintenance-window"], - "summary": "Create maintenance window", - "description": "Create a new maintenance window", + "summary": "Create one or more maintenance windows", + "security": [ + { + "bearerAuth": [] + } + ], "requestBody": { - "required": true, "content": { "application/json": { "schema": { "type": "object", - "required": ["name", "startTime", "endTime"], "properties": { - "name": { - "type": "string", - "maxLength": 100 - }, - "description": { - "type": "string", - "maxLength": 500 - }, - "startTime": { - "type": "string", - "format": "date-time" - }, - "endTime": { - "type": "string", - "format": "date-time" - }, "monitors": { "type": "array", "items": { "type": "string" - } + }, + "minItems": 1 + }, + "name": { + "type": "string", + "minLength": 1 + }, + "active": { + "type": "boolean" + }, + "duration": { + "type": "number", + "minimum": 1 + }, + "durationUnit": { + "type": "string", + "enum": ["seconds", "minutes", "hours", "days"] + }, + "start": { + "type": "string", + "nullable": true, + "format": "date-time" + }, + "end": { + "type": "string", + "nullable": true, + "format": "date-time" + }, + "repeat": { + "type": "number", + "minimum": 0 + }, + "expiry": { + "type": "string", + "nullable": true, + "format": "date-time" } - } + }, + "required": ["monitors", "name", "duration", "durationUnit", "start", "end", "repeat"] } } } }, "responses": { - "201": { - "description": "Maintenance window created successfully" + "200": { + "description": "OK", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "success": { + "type": "boolean", + "enum": [true] + }, + "msg": { + "type": "string" + }, + "data": { + "nullable": true + } + }, + "required": ["success", "msg"] + } + } + } }, - "422": { - "$ref": "#/components/responses/ValidationError" + "401": { + "description": "Unauthorized", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "success": { + "type": "boolean", + "enum": [false] + }, + "msg": { + "type": "string" + }, + "data": { + "nullable": true + } + }, + "required": ["success", "msg"] + } + } + } + }, + "403": { + "description": "Forbidden", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "success": { + "type": "boolean", + "enum": [false] + }, + "msg": { + "type": "string" + }, + "data": { + "nullable": true + } + }, + "required": ["success", "msg"] + } + } + } }, "500": { - "$ref": "#/components/responses/InternalServerError" + "description": "Internal server error", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "success": { + "type": "boolean", + "enum": [false] + }, + "msg": { + "type": "string" + }, + "data": { + "nullable": true + } + }, + "required": ["success", "msg"] + } + } + } } - }, - "security": [ - { - "bearerAuth": [] - } - ] + } } }, "/maintenance-window/team": { "get": { "tags": ["maintenance-window"], - "summary": "Get team maintenance windows", - "description": "Get all maintenance windows for the team", - "responses": { - "200": { - "description": "Maintenance windows retrieved successfully" - }, - "500": { - "$ref": "#/components/responses/InternalServerError" - } - }, + "summary": "List maintenance windows for the caller's team", "security": [ { "bearerAuth": [] } - ] + ], + "parameters": [ + { + "schema": { + "type": "boolean" + }, + "required": false, + "name": "active", + "in": "query" + }, + { + "schema": { + "type": "number", + "nullable": true + }, + "required": false, + "name": "page", + "in": "query" + }, + { + "schema": { + "type": "number", + "nullable": true + }, + "required": false, + "name": "rowsPerPage", + "in": "query" + }, + { + "schema": { + "type": "string" + }, + "required": false, + "name": "field", + "in": "query" + }, + { + "schema": { + "type": "string", + "enum": ["asc", "desc"] + }, + "required": false, + "name": "order", + "in": "query" + } + ], + "responses": { + "200": { + "description": "OK", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "success": { + "type": "boolean", + "enum": [true] + }, + "msg": { + "type": "string" + }, + "data": { + "nullable": true + } + }, + "required": ["success", "msg"] + } + } + } + }, + "401": { + "description": "Unauthorized", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "success": { + "type": "boolean", + "enum": [false] + }, + "msg": { + "type": "string" + }, + "data": { + "nullable": true + } + }, + "required": ["success", "msg"] + } + } + } + }, + "403": { + "description": "Forbidden", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "success": { + "type": "boolean", + "enum": [false] + }, + "msg": { + "type": "string" + }, + "data": { + "nullable": true + } + }, + "required": ["success", "msg"] + } + } + } + }, + "500": { + "description": "Internal server error", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "success": { + "type": "boolean", + "enum": [false] + }, + "msg": { + "type": "string" + }, + "data": { + "nullable": true + } + }, + "required": ["success", "msg"] + } + } + } + } + } } }, "/maintenance-window/monitor/{monitorId}": { "get": { "tags": ["maintenance-window"], - "summary": "Get maintenance windows by monitor", - "description": "Get all maintenance windows for a specific monitor", - "parameters": [ - { - "name": "monitorId", - "in": "path", - "required": true, - "schema": { - "type": "string" - } - } - ], - "responses": { - "200": { - "description": "Monitor maintenance windows retrieved successfully" - }, - "404": { - "$ref": "#/components/responses/NotFound" - }, - "500": { - "$ref": "#/components/responses/InternalServerError" - } - }, + "summary": "List maintenance windows for a monitor", "security": [ { "bearerAuth": [] } - ] + ], + "parameters": [ + { + "schema": { + "type": "string", + "minLength": 1 + }, + "required": true, + "name": "monitorId", + "in": "path" + } + ], + "responses": { + "200": { + "description": "OK", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "success": { + "type": "boolean", + "enum": [true] + }, + "msg": { + "type": "string" + }, + "data": { + "nullable": true + } + }, + "required": ["success", "msg"] + } + } + } + }, + "401": { + "description": "Unauthorized", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "success": { + "type": "boolean", + "enum": [false] + }, + "msg": { + "type": "string" + }, + "data": { + "nullable": true + } + }, + "required": ["success", "msg"] + } + } + } + }, + "403": { + "description": "Forbidden", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "success": { + "type": "boolean", + "enum": [false] + }, + "msg": { + "type": "string" + }, + "data": { + "nullable": true + } + }, + "required": ["success", "msg"] + } + } + } + }, + "500": { + "description": "Internal server error", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "success": { + "type": "boolean", + "enum": [false] + }, + "msg": { + "type": "string" + }, + "data": { + "nullable": true + } + }, + "required": ["success", "msg"] + } + } + } + } + } } }, "/maintenance-window/{id}": { "get": { "tags": ["maintenance-window"], - "summary": "Get maintenance window by ID", - "description": "Get a specific maintenance window", - "parameters": [ - { - "name": "id", - "in": "path", - "required": true, - "schema": { - "type": "string" - } - } - ], - "responses": { - "200": { - "description": "Maintenance window retrieved successfully" - }, - "404": { - "$ref": "#/components/responses/NotFound" - }, - "500": { - "$ref": "#/components/responses/InternalServerError" - } - }, + "summary": "Get a maintenance window by id", "security": [ { "bearerAuth": [] } - ] - }, - "put": { - "tags": ["maintenance-window"], - "summary": "Update maintenance window", - "description": "Update an existing maintenance window", + ], "parameters": [ { - "name": "id", - "in": "path", - "required": true, "schema": { - "type": "string" + "type": "string", + "minLength": 1 + }, + "required": true, + "name": "id", + "in": "path" + } + ], + "responses": { + "200": { + "description": "OK", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "success": { + "type": "boolean", + "enum": [true] + }, + "msg": { + "type": "string" + }, + "data": { + "nullable": true + } + }, + "required": ["success", "msg"] + } + } } + }, + "401": { + "description": "Unauthorized", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "success": { + "type": "boolean", + "enum": [false] + }, + "msg": { + "type": "string" + }, + "data": { + "nullable": true + } + }, + "required": ["success", "msg"] + } + } + } + }, + "403": { + "description": "Forbidden", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "success": { + "type": "boolean", + "enum": [false] + }, + "msg": { + "type": "string" + }, + "data": { + "nullable": true + } + }, + "required": ["success", "msg"] + } + } + } + }, + "500": { + "description": "Internal server error", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "success": { + "type": "boolean", + "enum": [false] + }, + "msg": { + "type": "string" + }, + "data": { + "nullable": true + } + }, + "required": ["success", "msg"] + } + } + } + } + } + }, + "patch": { + "tags": ["maintenance-window"], + "summary": "Edit a maintenance window", + "security": [ + { + "bearerAuth": [] + } + ], + "parameters": [ + { + "schema": { + "type": "string", + "minLength": 1 + }, + "required": true, + "name": "id", + "in": "path" } ], "requestBody": { - "required": true, "content": { "application/json": { "schema": { "type": "object", "properties": { + "active": { + "type": "boolean" + }, "name": { - "type": "string", - "maxLength": 100 + "type": "string" }, - "description": { - "type": "string", - "maxLength": 500 + "repeat": { + "type": "number" }, - "startTime": { + "start": { "type": "string", + "nullable": true, "format": "date-time" }, - "endTime": { + "end": { "type": "string", + "nullable": true, + "format": "date-time" + }, + "expiry": { + "type": "string", + "nullable": true, "format": "date-time" }, "monitors": { @@ -2525,6 +5184,13 @@ "items": { "type": "string" } + }, + "duration": { + "type": "number" + }, + "durationUnit": { + "type": "string", + "enum": ["seconds", "minutes", "hours", "days"] } } } @@ -2533,64 +5199,121 @@ }, "responses": { "200": { - "$ref": "#/components/responses/Success" + "description": "OK", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "success": { + "type": "boolean", + "enum": [true] + }, + "msg": { + "type": "string" + }, + "data": { + "nullable": true + } + }, + "required": ["success", "msg"] + } + } + } }, - "404": { - "$ref": "#/components/responses/NotFound" + "401": { + "description": "Unauthorized", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "success": { + "type": "boolean", + "enum": [false] + }, + "msg": { + "type": "string" + }, + "data": { + "nullable": true + } + }, + "required": ["success", "msg"] + } + } + } }, - "422": { - "$ref": "#/components/responses/ValidationError" + "403": { + "description": "Forbidden", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "success": { + "type": "boolean", + "enum": [false] + }, + "msg": { + "type": "string" + }, + "data": { + "nullable": true + } + }, + "required": ["success", "msg"] + } + } + } }, "500": { - "$ref": "#/components/responses/InternalServerError" + "description": "Internal server error", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "success": { + "type": "boolean", + "enum": [false] + }, + "msg": { + "type": "string" + }, + "data": { + "nullable": true + } + }, + "required": ["success", "msg"] + } + } + } } - }, - "security": [ - { - "bearerAuth": [] - } - ] + } }, "delete": { "tags": ["maintenance-window"], - "summary": "Delete maintenance window", - "description": "Delete a specific maintenance window", + "summary": "Delete a maintenance window", + "security": [ + { + "bearerAuth": [] + } + ], "parameters": [ { - "name": "id", - "in": "path", - "required": true, "schema": { - "type": "string" - } + "type": "string", + "minLength": 1 + }, + "required": true, + "name": "id", + "in": "path" } ], "responses": { "200": { - "$ref": "#/components/responses/Success" - }, - "404": { - "$ref": "#/components/responses/NotFound" - }, - "500": { - "$ref": "#/components/responses/InternalServerError" - } - }, - "security": [ - { - "bearerAuth": [] - } - ] - } - }, - "/queue/jobs": { - "get": { - "tags": ["queue"], - "summary": "Get queue jobs", - "description": "Retrieve all jobs in the queue (admin/superadmin only)", - "responses": { - "200": { - "description": "Queue jobs retrieved successfully", + "description": "OK", "content": { "application/json": { "schema": { @@ -2598,46 +5321,46 @@ "properties": { "success": { "type": "boolean", - "example": true + "enum": [true] }, "msg": { - "type": "string", - "example": "Queue jobs fetched successfully" + "type": "string" + } + }, + "required": ["success", "msg"] + }, + "example": { + "success": true, + "msg": "OK" + } + } + } + }, + "401": { + "description": "Unauthorized", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "success": { + "type": "boolean", + "enum": [false] + }, + "msg": { + "type": "string" }, "data": { - "type": "array", - "items": { - "$ref": "#/components/schemas/QueueJob" - } + "nullable": true } - } + }, + "required": ["success", "msg"] } } } }, - "401": { - "$ref": "#/components/responses/Unauthorized" - }, "403": { - "$ref": "#/components/responses/Forbidden" - }, - "500": { - "$ref": "#/components/responses/InternalServerError" - } - }, - "security": [ - { - "bearerAuth": [] - } - ] - }, - "post": { - "tags": ["queue"], - "summary": "Add job to queue", - "description": "Add a new job to the queue (admin/superadmin only)", - "responses": { - "200": { - "description": "Job added successfully", + "description": "Forbidden", "content": { "application/json": { "schema": { @@ -2645,474 +5368,96 @@ "properties": { "success": { "type": "boolean", - "example": true + "enum": [false] }, "msg": { - "type": "string", - "example": "Job added to queue successfully" - } - } - } - } - } - }, - "401": { - "$ref": "#/components/responses/Unauthorized" - }, - "403": { - "$ref": "#/components/responses/Forbidden" - }, - "500": { - "$ref": "#/components/responses/InternalServerError" - } - }, - "security": [ - { - "bearerAuth": [] - } - ] - } - }, - "/queue/metrics": { - "get": { - "tags": ["queue"], - "summary": "Get queue metrics", - "description": "Retrieve queue metrics (admin/superadmin only)", - "responses": { - "200": { - "description": "Queue metrics retrieved successfully", - "content": { - "application/json": { - "schema": { - "type": "object", - "properties": { - "success": { - "type": "boolean", - "example": true - }, - "msg": { - "type": "string", - "example": "Queue metrics fetched successfully" + "type": "string" }, "data": { - "$ref": "#/components/schemas/QueueMetrics" + "nullable": true } - } + }, + "required": ["success", "msg"] } } } }, - "401": { - "$ref": "#/components/responses/Unauthorized" - }, - "403": { - "$ref": "#/components/responses/Forbidden" - }, "500": { - "$ref": "#/components/responses/InternalServerError" + "description": "Internal server error", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "success": { + "type": "boolean", + "enum": [false] + }, + "msg": { + "type": "string" + }, + "data": { + "nullable": true + } + }, + "required": ["success", "msg"] + } + } + } } - }, - "security": [ - { - "bearerAuth": [] - } - ] + } } }, - "/queue/health": { - "get": { - "tags": ["queue"], - "summary": "Check queue health", - "description": "Check the health status of the job queue (admin/superadmin only)", - "responses": { - "200": { - "description": "Queue health status retrieved successfully" - }, - "403": { - "$ref": "#/components/responses/Forbidden" - }, - "500": { - "$ref": "#/components/responses/InternalServerError" - } - }, - "security": [ - { - "bearerAuth": [] - } - ] - } - }, - "/queue/all-metrics": { - "get": { - "tags": ["queue"], - "summary": "Get all queue metrics", - "description": "Get comprehensive queue metrics (admin/superadmin only)", - "responses": { - "200": { - "description": "All queue metrics retrieved successfully" - }, - "403": { - "$ref": "#/components/responses/Forbidden" - }, - "500": { - "$ref": "#/components/responses/InternalServerError" - } - }, - "security": [ - { - "bearerAuth": [] - } - ] - } - }, - "/queue/flush": { - "post": { - "tags": ["queue"], - "summary": "Flush queue", - "description": "Clear all jobs from the queue (admin/superadmin only)", - "responses": { - "200": { - "$ref": "#/components/responses/Success" - }, - "403": { - "$ref": "#/components/responses/Forbidden" - }, - "500": { - "$ref": "#/components/responses/InternalServerError" - } - }, - "security": [ - { - "bearerAuth": [] - } - ] - } - }, - "/monitors/games": { + "/monitors/team": { "get": { "tags": ["monitors"], - "summary": "Get game server list", - "description": "Get available game servers for monitoring", - "responses": { - "200": { - "description": "Game servers retrieved successfully", - "content": { - "application/json": { - "schema": { - "allOf": [ - { - "$ref": "#/components/schemas/SuccessResponse" - }, - { - "type": "object", - "properties": { - "data": { - "type": "array", - "items": { - "type": "object", - "properties": { - "id": { - "type": "string" - }, - "name": { - "type": "string" - } - } - } - } - } - } - ] - } - } - } - }, - "500": { - "$ref": "#/components/responses/InternalServerError" - } - }, + "summary": "List monitors for the caller's team", "security": [ { "bearerAuth": [] } - ] - } - }, - "/status-page": { - "get": { - "tags": ["status-page"], - "summary": "Get status page", - "description": "Get default status page information", - "responses": { - "200": { - "$ref": "#/components/responses/Success" - }, - "500": { - "$ref": "#/components/responses/InternalServerError" - } - } - }, - "post": { - "tags": ["status-page"], - "summary": "Create status page", - "description": "Create a new status page with optional logo upload", - "requestBody": { - "content": { - "multipart/form-data": { - "schema": { - "type": "object", - "properties": { - "logo": { + ], + "parameters": [ + { + "schema": { + "anyOf": [ + { + "type": "string", + "enum": ["http", "ping", "pagespeed", "docker", "hardware", "port", "game", "grpc", "websocket"] + }, + { + "type": "array", + "items": { "type": "string", - "format": "binary", - "description": "Logo file for the status page" - }, - "title": { - "type": "string", - "description": "Status page title" - }, - "description": { - "type": "string", - "description": "Status page description" - }, - "url": { - "type": "string", - "description": "Custom URL for the status page" - }, - "theme": { - "$ref": "#/components/schemas/StatusPageTheme" - }, - "themeMode": { - "$ref": "#/components/schemas/StatusPageThemeMode" + "enum": ["http", "ping", "pagespeed", "docker", "hardware", "port", "game", "grpc", "websocket"] } } - } - } - } - }, - "responses": { - "201": { - "$ref": "#/components/responses/Success" + ] + }, + "required": false, + "name": "type", + "in": "query" }, - "400": { - "$ref": "#/components/responses/BadRequest" - }, - "401": { - "$ref": "#/components/responses/Unauthorized" - }, - "500": { - "$ref": "#/components/responses/InternalServerError" - } - }, - "security": [ { - "bearerAuth": [] - } - ] - }, - "put": { - "tags": ["status-page"], - "summary": "Update status page", - "description": "Update an existing status page with optional logo upload", - "requestBody": { - "content": { - "multipart/form-data": { - "schema": { - "type": "object", - "properties": { - "logo": { - "type": "string", - "format": "binary", - "description": "Logo file for the status page" - }, - "title": { - "type": "string", - "description": "Status page title" - }, - "description": { - "type": "string", - "description": "Status page description" - }, - "url": { - "type": "string", - "description": "Custom URL for the status page" - }, - "theme": { - "$ref": "#/components/schemas/StatusPageTheme" - }, - "themeMode": { - "$ref": "#/components/schemas/StatusPageThemeMode" - } + "schema": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "string", + "enum": [""] } - } - } - } - }, - "responses": { - "200": { - "$ref": "#/components/responses/Success" - }, - "400": { - "$ref": "#/components/responses/BadRequest" - }, - "401": { - "$ref": "#/components/responses/Unauthorized" - }, - "404": { - "$ref": "#/components/responses/NotFound" - }, - "500": { - "$ref": "#/components/responses/InternalServerError" - } - }, - "security": [ - { - "bearerAuth": [] - } - ] - } - }, - "/status-page/team": { - "get": { - "tags": ["status-page"], - "summary": "Get status pages by team", - "description": "Get all status pages for the authenticated user's team", - "responses": { - "200": { - "$ref": "#/components/responses/Success" - }, - "401": { - "$ref": "#/components/responses/Unauthorized" - }, - "500": { - "$ref": "#/components/responses/InternalServerError" - } - }, - "security": [ - { - "bearerAuth": [] - } - ] - } - }, - "/status-page/{url}": { - "get": { - "tags": ["status-page"], - "summary": "Get status page by URL", - "description": "Get a specific status page by its custom URL", - "parameters": [ - { - "name": "url", - "in": "path", - "required": true, - "schema": { - "type": "string" + ] }, - "description": "Custom URL of the status page" + "required": false, + "name": "filter", + "in": "query" } ], "responses": { "200": { - "$ref": "#/components/responses/Success" - }, - "404": { - "$ref": "#/components/responses/NotFound" - }, - "500": { - "$ref": "#/components/responses/InternalServerError" - } - } - }, - "delete": { - "tags": ["status-page"], - "summary": "Delete status page", - "description": "Delete a status page by its custom URL", - "parameters": [ - { - "name": "url", - "in": "path", - "required": true, - "schema": { - "type": "string" - }, - "description": "Custom URL of the status page (supports wildcards)" - } - ], - "responses": { - "200": { - "$ref": "#/components/responses/Success" - }, - "401": { - "$ref": "#/components/responses/Unauthorized" - }, - "404": { - "$ref": "#/components/responses/NotFound" - }, - "500": { - "$ref": "#/components/responses/InternalServerError" - } - }, - "security": [ - { - "bearerAuth": [] - } - ] - } - }, - "/incidents/team": { - "get": { - "tags": ["incidents"], - "summary": "Get incidents by team", - "description": "Retrieve all incidents for the authenticated user's team with optional filtering", - "parameters": [ - { - "name": "status", - "in": "query", - "description": "Filter by incident status (true = ongoing, false = resolved)", - "schema": { - "type": "boolean" - } - }, - { - "name": "monitorId", - "in": "query", - "description": "Filter by monitor ID", - "schema": { - "type": "string" - } - }, - { - "name": "page", - "in": "query", - "description": "Page number for pagination", - "schema": { - "type": "integer", - "default": 1 - } - }, - { - "name": "limit", - "in": "query", - "description": "Number of items per page", - "schema": { - "type": "integer", - "default": 10 - } - }, - { - "name": "sortOrder", - "in": "query", - "description": "Sort order for results", - "schema": { - "type": "string", - "enum": ["asc", "desc"], - "default": "desc" - } - } - ], - "responses": { - "200": { - "description": "Incidents retrieved successfully", + "description": "OK", "content": { "application/json": { "schema": { @@ -3120,57 +5465,200 @@ "properties": { "success": { "type": "boolean", - "example": true + "enum": [true] }, "msg": { - "type": "string", - "example": "Incidents retrieved successfully" + "type": "string" }, "data": { "type": "array", "items": { - "$ref": "#/components/schemas/Incident" + "$ref": "#/components/schemas/Monitor" } } - } + }, + "required": ["success", "msg", "data"] } } } }, "401": { - "$ref": "#/components/responses/Unauthorized" + "description": "Unauthorized", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "success": { + "type": "boolean", + "enum": [false] + }, + "msg": { + "type": "string" + }, + "data": { + "nullable": true + } + }, + "required": ["success", "msg"] + } + } + } + }, + "403": { + "description": "Forbidden", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "success": { + "type": "boolean", + "enum": [false] + }, + "msg": { + "type": "string" + }, + "data": { + "nullable": true + } + }, + "required": ["success", "msg"] + } + } + } }, "500": { - "$ref": "#/components/responses/InternalServerError" + "description": "Internal server error", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "success": { + "type": "boolean", + "enum": [false] + }, + "msg": { + "type": "string" + }, + "data": { + "nullable": true + } + }, + "required": ["success", "msg"] + } + } + } } - }, + } + } + }, + "/monitors/team/with-checks": { + "get": { + "tags": ["monitors"], + "summary": "List team monitors with their most recent checks (paginated)", "security": [ { "bearerAuth": [] } - ] - } - }, - "/incidents/team/summary": { - "get": { - "tags": ["incidents"], - "summary": "Get incident summary", - "description": "Retrieve a summary of incidents for the authenticated user's team", + ], "parameters": [ { - "name": "dateRange", - "in": "query", - "description": "Date range for summary", + "schema": { + "type": "integer", + "minimum": 1, + "maximum": 100 + }, + "required": false, + "name": "limit", + "in": "query" + }, + { + "schema": { + "type": "integer", + "nullable": true, + "minimum": 0 + }, + "required": false, + "name": "page", + "in": "query" + }, + { + "schema": { + "type": "integer", + "minimum": 1, + "maximum": 100 + }, + "required": false, + "name": "rowsPerPage", + "in": "query" + }, + { + "schema": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "string", + "enum": [""] + } + ] + }, + "required": false, + "name": "filter", + "in": "query" + }, + { + "schema": { + "type": "string" + }, + "required": false, + "name": "field", + "in": "query" + }, + { "schema": { "type": "string", - "enum": ["day", "week", "month"], - "default": "week" - } + "enum": ["asc", "desc"] + }, + "required": false, + "name": "order", + "in": "query" + }, + { + "schema": { + "anyOf": [ + { + "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"] + } + } + ] + }, + "required": false, + "name": "type", + "in": "query" + }, + { + "schema": { + "type": "boolean" + }, + "required": false, + "name": "explain", + "in": "query" } ], "responses": { "200": { - "description": "Incident summary retrieved successfully", + "description": "OK", "content": { "application/json": { "schema": { @@ -3178,53 +5666,104 @@ "properties": { "success": { "type": "boolean", - "example": true + "enum": [true] }, "msg": { - "type": "string", - "example": "Incident summary retrieved successfully" + "type": "string" }, "data": { - "$ref": "#/components/schemas/IncidentSummary" + "nullable": true } - } + }, + "required": ["success", "msg"] } } } }, "401": { - "$ref": "#/components/responses/Unauthorized" + "description": "Unauthorized", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "success": { + "type": "boolean", + "enum": [false] + }, + "msg": { + "type": "string" + }, + "data": { + "nullable": true + } + }, + "required": ["success", "msg"] + } + } + } + }, + "403": { + "description": "Forbidden", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "success": { + "type": "boolean", + "enum": [false] + }, + "msg": { + "type": "string" + }, + "data": { + "nullable": true + } + }, + "required": ["success", "msg"] + } + } + } }, "500": { - "$ref": "#/components/responses/InternalServerError" + "description": "Internal server error", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "success": { + "type": "boolean", + "enum": [false] + }, + "msg": { + "type": "string" + }, + "data": { + "nullable": true + } + }, + "required": ["success", "msg"] + } + } + } } - }, - "security": [ - { - "bearerAuth": [] - } - ] + } } }, - "/incidents/{incidentId}": { + "/monitors/team/groups": { "get": { - "tags": ["incidents"], - "summary": "Get incident by ID", - "description": "Retrieve a specific incident by its ID", - "parameters": [ + "tags": ["monitors"], + "summary": "List monitor groups for the caller's team", + "security": [ { - "name": "incidentId", - "in": "path", - "required": true, - "description": "The incident ID", - "schema": { - "type": "string" - } + "bearerAuth": [] } ], "responses": { "200": { - "description": "Incident retrieved successfully", + "description": "OK", "content": { "application/json": { "schema": { @@ -3232,51 +5771,911 @@ "properties": { "success": { "type": "boolean", - "example": true + "enum": [true] }, "msg": { - "type": "string", - "example": "Incident retrieved successfully" + "type": "string" }, "data": { - "$ref": "#/components/schemas/Incident" + "type": "array", + "items": { + "type": "string" + } } - } + }, + "required": ["success", "msg", "data"] } } } }, "401": { - "$ref": "#/components/responses/Unauthorized" + "description": "Unauthorized", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "success": { + "type": "boolean", + "enum": [false] + }, + "msg": { + "type": "string" + }, + "data": { + "nullable": true + } + }, + "required": ["success", "msg"] + } + } + } }, - "404": { - "$ref": "#/components/responses/NotFound" + "403": { + "description": "Forbidden", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "success": { + "type": "boolean", + "enum": [false] + }, + "msg": { + "type": "string" + }, + "data": { + "nullable": true + } + }, + "required": ["success", "msg"] + } + } + } }, "500": { - "$ref": "#/components/responses/InternalServerError" + "description": "Internal server error", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "success": { + "type": "boolean", + "enum": [false] + }, + "msg": { + "type": "string" + }, + "data": { + "nullable": true + } + }, + "required": ["success", "msg"] + } + } + } } - }, + } + } + }, + "/monitors/uptime/details/{monitorId}": { + "get": { + "tags": ["monitors"], + "summary": "Get uptime details for a monitor", "security": [ { "bearerAuth": [] } - ] - } - }, - "/incidents/{incidentId}/resolve": { - "put": { - "tags": ["incidents"], - "summary": "Resolve incident manually", - "description": "Manually resolve an ongoing incident. Requires admin or superadmin role.", + ], "parameters": [ { - "name": "incidentId", - "in": "path", - "required": true, - "description": "The incident ID to resolve", "schema": { - "type": "string" + "type": "string", + "minLength": 1 + }, + "required": true, + "name": "monitorId", + "in": "path" + }, + { + "schema": { + "type": "string", + "enum": ["recent", "hour", "day", "week", "month", "all"] + }, + "required": true, + "name": "dateRange", + "in": "query" + }, + { + "schema": { + "type": "boolean" + }, + "required": false, + "name": "normalize", + "in": "query" + } + ], + "responses": { + "200": { + "description": "OK", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "success": { + "type": "boolean", + "enum": [true] + }, + "msg": { + "type": "string" + }, + "data": { + "nullable": true + } + }, + "required": ["success", "msg"] + } + } } + }, + "401": { + "description": "Unauthorized", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "success": { + "type": "boolean", + "enum": [false] + }, + "msg": { + "type": "string" + }, + "data": { + "nullable": true + } + }, + "required": ["success", "msg"] + } + } + } + }, + "403": { + "description": "Forbidden", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "success": { + "type": "boolean", + "enum": [false] + }, + "msg": { + "type": "string" + }, + "data": { + "nullable": true + } + }, + "required": ["success", "msg"] + } + } + } + }, + "500": { + "description": "Internal server error", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "success": { + "type": "boolean", + "enum": [false] + }, + "msg": { + "type": "string" + }, + "data": { + "nullable": true + } + }, + "required": ["success", "msg"] + } + } + } + } + } + } + }, + "/monitors/hardware/details/{monitorId}": { + "get": { + "tags": ["monitors"], + "summary": "Get hardware metrics detail for a monitor", + "security": [ + { + "bearerAuth": [] + } + ], + "parameters": [ + { + "schema": { + "type": "string", + "minLength": 1 + }, + "required": true, + "name": "monitorId", + "in": "path" + }, + { + "schema": { + "type": "string", + "enum": ["recent", "hour", "day", "week", "month", "all"] + }, + "required": false, + "name": "dateRange", + "in": "query" + } + ], + "responses": { + "200": { + "description": "OK", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "success": { + "type": "boolean", + "enum": [true] + }, + "msg": { + "type": "string" + }, + "data": { + "nullable": true + } + }, + "required": ["success", "msg"] + } + } + } + }, + "401": { + "description": "Unauthorized", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "success": { + "type": "boolean", + "enum": [false] + }, + "msg": { + "type": "string" + }, + "data": { + "nullable": true + } + }, + "required": ["success", "msg"] + } + } + } + }, + "403": { + "description": "Forbidden", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "success": { + "type": "boolean", + "enum": [false] + }, + "msg": { + "type": "string" + }, + "data": { + "nullable": true + } + }, + "required": ["success", "msg"] + } + } + } + }, + "500": { + "description": "Internal server error", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "success": { + "type": "boolean", + "enum": [false] + }, + "msg": { + "type": "string" + }, + "data": { + "nullable": true + } + }, + "required": ["success", "msg"] + } + } + } + } + } + } + }, + "/monitors/pagespeed/details/{monitorId}": { + "get": { + "tags": ["monitors"], + "summary": "Get PageSpeed detail for a monitor", + "security": [ + { + "bearerAuth": [] + } + ], + "parameters": [ + { + "schema": { + "type": "string", + "minLength": 1 + }, + "required": true, + "name": "monitorId", + "in": "path" + }, + { + "schema": { + "type": "string", + "enum": ["recent", "hour", "day", "week", "month", "all"] + }, + "required": false, + "name": "dateRange", + "in": "query" + } + ], + "responses": { + "200": { + "description": "OK", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "success": { + "type": "boolean", + "enum": [true] + }, + "msg": { + "type": "string" + }, + "data": { + "nullable": true + } + }, + "required": ["success", "msg"] + } + } + } + }, + "401": { + "description": "Unauthorized", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "success": { + "type": "boolean", + "enum": [false] + }, + "msg": { + "type": "string" + }, + "data": { + "nullable": true + } + }, + "required": ["success", "msg"] + } + } + } + }, + "403": { + "description": "Forbidden", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "success": { + "type": "boolean", + "enum": [false] + }, + "msg": { + "type": "string" + }, + "data": { + "nullable": true + } + }, + "required": ["success", "msg"] + } + } + } + }, + "500": { + "description": "Internal server error", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "success": { + "type": "boolean", + "enum": [false] + }, + "msg": { + "type": "string" + }, + "data": { + "nullable": true + } + }, + "required": ["success", "msg"] + } + } + } + } + } + } + }, + "/monitors/{monitorId}/geo-checks": { + "get": { + "tags": ["monitors"], + "summary": "Get geo check results for a monitor", + "security": [ + { + "bearerAuth": [] + } + ], + "parameters": [ + { + "schema": { + "type": "string", + "minLength": 1 + }, + "required": true, + "name": "monitorId", + "in": "path" + }, + { + "schema": { + "type": "boolean" + }, + "required": false, + "name": "status", + "in": "query" + }, + { + "schema": { + "type": "string", + "enum": ["asc", "desc"] + }, + "required": false, + "name": "sortOrder", + "in": "query" + }, + { + "schema": { + "type": "number", + "nullable": true + }, + "required": false, + "name": "limit", + "in": "query" + }, + { + "schema": { + "type": "string", + "enum": ["recent", "hour", "day", "week", "month", "all"] + }, + "required": false, + "name": "dateRange", + "in": "query" + }, + { + "schema": { + "type": "number", + "nullable": true + }, + "required": false, + "name": "numToDisplay", + "in": "query" + }, + { + "schema": { + "type": "boolean" + }, + "required": false, + "name": "normalize", + "in": "query" + }, + { + "schema": { + "anyOf": [ + { + "type": "string", + "enum": ["EU", "NA", "AS", "SA", "AF", "OC"] + }, + { + "type": "array", + "items": { + "type": "string", + "enum": ["EU", "NA", "AS", "SA", "AF", "OC"] + } + } + ] + }, + "required": false, + "name": "continent", + "in": "query" + } + ], + "responses": { + "200": { + "description": "OK", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "success": { + "type": "boolean", + "enum": [true] + }, + "msg": { + "type": "string" + }, + "data": { + "nullable": true + } + }, + "required": ["success", "msg"] + } + } + } + }, + "401": { + "description": "Unauthorized", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "success": { + "type": "boolean", + "enum": [false] + }, + "msg": { + "type": "string" + }, + "data": { + "nullable": true + } + }, + "required": ["success", "msg"] + } + } + } + }, + "403": { + "description": "Forbidden", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "success": { + "type": "boolean", + "enum": [false] + }, + "msg": { + "type": "string" + }, + "data": { + "nullable": true + } + }, + "required": ["success", "msg"] + } + } + } + }, + "500": { + "description": "Internal server error", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "success": { + "type": "boolean", + "enum": [false] + }, + "msg": { + "type": "string" + }, + "data": { + "nullable": true + } + }, + "required": ["success", "msg"] + } + } + } + } + } + } + }, + "/monitors/pause/{monitorId}": { + "post": { + "tags": ["monitors"], + "summary": "Toggle pause state for a monitor", + "security": [ + { + "bearerAuth": [] + } + ], + "parameters": [ + { + "schema": { + "type": "string", + "minLength": 1 + }, + "required": true, + "name": "monitorId", + "in": "path" + } + ], + "responses": { + "200": { + "description": "OK", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "success": { + "type": "boolean", + "enum": [true] + }, + "msg": { + "type": "string" + }, + "data": { + "$ref": "#/components/schemas/Monitor" + } + }, + "required": ["success", "msg", "data"] + } + } + } + }, + "401": { + "description": "Unauthorized", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "success": { + "type": "boolean", + "enum": [false] + }, + "msg": { + "type": "string" + }, + "data": { + "nullable": true + } + }, + "required": ["success", "msg"] + } + } + } + }, + "403": { + "description": "Forbidden", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "success": { + "type": "boolean", + "enum": [false] + }, + "msg": { + "type": "string" + }, + "data": { + "nullable": true + } + }, + "required": ["success", "msg"] + } + } + } + }, + "500": { + "description": "Internal server error", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "success": { + "type": "boolean", + "enum": [false] + }, + "msg": { + "type": "string" + }, + "data": { + "nullable": true + } + }, + "required": ["success", "msg"] + } + } + } + } + } + } + }, + "/monitors/certificate/{monitorId}": { + "get": { + "tags": ["monitors"], + "summary": "Get SSL certificate info for a monitor", + "security": [ + { + "bearerAuth": [] + } + ], + "parameters": [ + { + "schema": { + "type": "string", + "minLength": 1 + }, + "required": true, + "name": "monitorId", + "in": "path" + } + ], + "responses": { + "200": { + "description": "OK", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "success": { + "type": "boolean", + "enum": [true] + }, + "msg": { + "type": "string" + }, + "data": { + "type": "object", + "properties": { + "certificateDate": { + "type": "string" + } + }, + "required": ["certificateDate"] + } + }, + "required": ["success", "msg", "data"] + } + } + } + }, + "401": { + "description": "Unauthorized", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "success": { + "type": "boolean", + "enum": [false] + }, + "msg": { + "type": "string" + }, + "data": { + "nullable": true + } + }, + "required": ["success", "msg"] + } + } + } + }, + "403": { + "description": "Forbidden", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "success": { + "type": "boolean", + "enum": [false] + }, + "msg": { + "type": "string" + }, + "data": { + "nullable": true + } + }, + "required": ["success", "msg"] + } + } + } + }, + "500": { + "description": "Internal server error", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "success": { + "type": "boolean", + "enum": [false] + }, + "msg": { + "type": "string" + }, + "data": { + "nullable": true + } + }, + "required": ["success", "msg"] + } + } + } + } + } + } + }, + "/monitors/notifications": { + "patch": { + "tags": ["monitors"], + "summary": "Bulk update notifications across monitors", + "security": [ + { + "bearerAuth": [] } ], "requestBody": { @@ -3285,10 +6684,1493 @@ "schema": { "type": "object", "properties": { - "comment": { + "monitorIds": { + "type": "array", + "items": { + "type": "string" + }, + "minItems": 1, + "maxItems": 100 + }, + "notificationIds": { + "type": "array", + "items": { + "type": "string" + }, + "maxItems": 100 + }, + "action": { "type": "string", - "description": "Optional comment about the resolution", - "example": "Issue resolved by restarting the service" + "enum": ["add", "remove", "set"] + } + }, + "required": ["monitorIds", "notificationIds", "action"] + } + } + } + }, + "responses": { + "200": { + "description": "OK", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "success": { + "type": "boolean", + "enum": [true] + }, + "msg": { + "type": "string" + }, + "data": { + "type": "object", + "properties": { + "modifiedCount": { + "type": "number" + } + }, + "required": ["modifiedCount"] + } + }, + "required": ["success", "msg", "data"] + } + } + } + }, + "401": { + "description": "Unauthorized", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "success": { + "type": "boolean", + "enum": [false] + }, + "msg": { + "type": "string" + }, + "data": { + "nullable": true + } + }, + "required": ["success", "msg"] + } + } + } + }, + "403": { + "description": "Forbidden", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "success": { + "type": "boolean", + "enum": [false] + }, + "msg": { + "type": "string" + }, + "data": { + "nullable": true + } + }, + "required": ["success", "msg"] + } + } + } + }, + "500": { + "description": "Internal server error", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "success": { + "type": "boolean", + "enum": [false] + }, + "msg": { + "type": "string" + }, + "data": { + "nullable": true + } + }, + "required": ["success", "msg"] + } + } + } + } + } + } + }, + "/monitors": { + "post": { + "tags": ["monitors"], + "summary": "Create a new monitor", + "security": [ + { + "bearerAuth": [] + } + ], + "requestBody": { + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "_id": { + "type": "string" + }, + "name": { + "type": "string", + "minLength": 1 + }, + "description": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "string", + "enum": [""] + } + ] + }, + "type": { + "type": "string", + "enum": ["http", "ping", "pagespeed", "hardware", "docker", "port", "game", "grpc", "websocket", "unknown"] + }, + "statusWindowSize": { + "type": "number", + "minimum": 1, + "maximum": 20, + "default": 5 + }, + "statusWindowThreshold": { + "type": "number", + "minimum": 1, + "maximum": 100, + "default": 60 + }, + "url": { + "type": "string", + "minLength": 1 + }, + "ignoreTlsErrors": { + "type": "boolean", + "default": false + }, + "useAdvancedMatching": { + "type": "boolean", + "default": false + }, + "port": { + "type": "number" + }, + "isActive": { + "type": "boolean" + }, + "interval": { + "type": "number" + }, + "cpuAlertThreshold": { + "type": "number" + }, + "memoryAlertThreshold": { + "type": "number" + }, + "diskAlertThreshold": { + "type": "number" + }, + "tempAlertThreshold": { + "type": "number" + }, + "notifications": { + "type": "array", + "items": { + "type": "string" + } + }, + "secret": { + "type": "string" + }, + "jsonPath": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "string", + "enum": [""] + } + ] + }, + "expectedValue": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "string", + "enum": [""] + } + ] + }, + "matchMethod": { + "anyOf": [ + { + "type": "string", + "enum": ["equal", "include", "regex"] + }, + { + "type": "string", + "enum": [""] + } + ] + }, + "gameId": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "string", + "enum": [""] + } + ] + }, + "grpcServiceName": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "string", + "enum": [""] + } + ], + "default": "" + }, + "selectedDisks": { + "type": "array", + "items": { + "type": "string" + } + }, + "group": { + "anyOf": [ + { + "type": "string", + "maxLength": 50 + }, + { + "nullable": true + }, + { + "type": "string", + "enum": [""] + } + ] + }, + "geoCheckEnabled": { + "type": "boolean" + }, + "geoCheckLocations": { + "type": "array", + "items": { + "type": "string", + "enum": ["EU", "NA", "AS", "SA", "AF", "OC"] + } + }, + "geoCheckInterval": { + "type": "number", + "minimum": 300000 + } + }, + "required": ["name", "type", "url"] + } + } + } + }, + "responses": { + "200": { + "description": "Monitor created", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "success": { + "type": "boolean", + "enum": [true] + }, + "msg": { + "type": "string" + }, + "data": { + "$ref": "#/components/schemas/Monitor" + } + }, + "required": ["success", "msg", "data"] + } + } + } + }, + "401": { + "description": "Unauthorized", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "success": { + "type": "boolean", + "enum": [false] + }, + "msg": { + "type": "string" + }, + "data": { + "nullable": true + } + }, + "required": ["success", "msg"] + } + } + } + }, + "403": { + "description": "Forbidden", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "success": { + "type": "boolean", + "enum": [false] + }, + "msg": { + "type": "string" + }, + "data": { + "nullable": true + } + }, + "required": ["success", "msg"] + } + } + } + }, + "500": { + "description": "Internal server error", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "success": { + "type": "boolean", + "enum": [false] + }, + "msg": { + "type": "string" + }, + "data": { + "nullable": true + } + }, + "required": ["success", "msg"] + } + } + } + } + } + }, + "delete": { + "tags": ["monitors"], + "summary": "Delete every monitor (superadmin only)", + "security": [ + { + "bearerAuth": [] + } + ], + "responses": { + "200": { + "description": "OK", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "success": { + "type": "boolean", + "enum": [true] + }, + "msg": { + "type": "string" + } + }, + "required": ["success", "msg"] + }, + "example": { + "success": true, + "msg": "OK" + } + } + } + }, + "401": { + "description": "Unauthorized", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "success": { + "type": "boolean", + "enum": [false] + }, + "msg": { + "type": "string" + }, + "data": { + "nullable": true + } + }, + "required": ["success", "msg"] + } + } + } + }, + "403": { + "description": "Forbidden", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "success": { + "type": "boolean", + "enum": [false] + }, + "msg": { + "type": "string" + }, + "data": { + "nullable": true + } + }, + "required": ["success", "msg"] + } + } + } + }, + "500": { + "description": "Internal server error", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "success": { + "type": "boolean", + "enum": [false] + }, + "msg": { + "type": "string" + }, + "data": { + "nullable": true + } + }, + "required": ["success", "msg"] + } + } + } + } + } + } + }, + "/monitors/demo": { + "post": { + "tags": ["monitors"], + "summary": "Insert preconfigured demo monitors", + "security": [ + { + "bearerAuth": [] + } + ], + "responses": { + "200": { + "description": "OK", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "success": { + "type": "boolean", + "enum": [true] + }, + "msg": { + "type": "string" + }, + "data": { + "type": "number" + } + }, + "required": ["success", "msg", "data"] + } + } + } + }, + "401": { + "description": "Unauthorized", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "success": { + "type": "boolean", + "enum": [false] + }, + "msg": { + "type": "string" + }, + "data": { + "nullable": true + } + }, + "required": ["success", "msg"] + } + } + } + }, + "403": { + "description": "Forbidden", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "success": { + "type": "boolean", + "enum": [false] + }, + "msg": { + "type": "string" + }, + "data": { + "nullable": true + } + }, + "required": ["success", "msg"] + } + } + } + }, + "500": { + "description": "Internal server error", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "success": { + "type": "boolean", + "enum": [false] + }, + "msg": { + "type": "string" + }, + "data": { + "nullable": true + } + }, + "required": ["success", "msg"] + } + } + } + } + } + } + }, + "/monitors/export/json": { + "get": { + "tags": ["monitors"], + "summary": "Export all team monitors as JSON", + "security": [ + { + "bearerAuth": [] + } + ], + "responses": { + "200": { + "description": "OK", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "success": { + "type": "boolean", + "enum": [true] + }, + "msg": { + "type": "string" + }, + "data": { + "nullable": true + } + }, + "required": ["success", "msg"] + } + } + } + }, + "401": { + "description": "Unauthorized", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "success": { + "type": "boolean", + "enum": [false] + }, + "msg": { + "type": "string" + }, + "data": { + "nullable": true + } + }, + "required": ["success", "msg"] + } + } + } + }, + "403": { + "description": "Forbidden", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "success": { + "type": "boolean", + "enum": [false] + }, + "msg": { + "type": "string" + }, + "data": { + "nullable": true + } + }, + "required": ["success", "msg"] + } + } + } + }, + "500": { + "description": "Internal server error", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "success": { + "type": "boolean", + "enum": [false] + }, + "msg": { + "type": "string" + }, + "data": { + "nullable": true + } + }, + "required": ["success", "msg"] + } + } + } + } + } + } + }, + "/monitors/import/json": { + "post": { + "tags": ["monitors"], + "summary": "Import monitors from JSON", + "security": [ + { + "bearerAuth": [] + } + ], + "requestBody": { + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "monitors": { + "type": "array", + "items": { + "type": "object", + "properties": { + "id": { + "type": "string" + }, + "userId": { + "type": "string" + }, + "teamId": { + "type": "string" + }, + "name": { + "type": "string", + "minLength": 1 + }, + "description": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "string", + "enum": [""] + } + ] + }, + "status": { + "type": "string", + "enum": ["up", "down", "paused", "initializing", "maintenance", "breached"], + "default": "initializing" + }, + "statusWindow": { + "type": "array", + "items": { + "type": "boolean" + }, + "default": [] + }, + "statusWindowSize": { + "type": "number", + "minimum": 1, + "maximum": 20, + "default": 5 + }, + "statusWindowThreshold": { + "type": "number", + "minimum": 1, + "maximum": 100, + "default": 60 + }, + "type": { + "type": "string", + "enum": ["http", "ping", "pagespeed", "hardware", "docker", "port", "game", "grpc", "websocket", "unknown"] + }, + "ignoreTlsErrors": { + "type": "boolean", + "default": false + }, + "useAdvancedMatching": { + "type": "boolean", + "default": false + }, + "jsonPath": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "string", + "enum": [""] + } + ] + }, + "expectedValue": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "string", + "enum": [""] + } + ] + }, + "matchMethod": { + "anyOf": [ + { + "type": "string", + "enum": ["equal", "include", "regex"] + }, + { + "type": "string", + "enum": [""] + } + ] + }, + "url": { + "type": "string", + "minLength": 1 + }, + "port": { + "type": "number" + }, + "isActive": { + "type": "boolean", + "default": true + }, + "interval": { + "type": "number", + "default": 60000 + }, + "uptimePercentage": { + "type": "number" + }, + "notifications": { + "type": "array", + "items": { + "type": "string" + }, + "default": [] + }, + "secret": { + "type": "string" + }, + "cpuAlertThreshold": { + "type": "number", + "default": 100 + }, + "cpuAlertCounter": { + "type": "number", + "default": 5 + }, + "memoryAlertThreshold": { + "type": "number", + "default": 100 + }, + "memoryAlertCounter": { + "type": "number", + "default": 5 + }, + "diskAlertThreshold": { + "type": "number", + "default": 100 + }, + "diskAlertCounter": { + "type": "number", + "default": 5 + }, + "tempAlertThreshold": { + "type": "number", + "default": 100 + }, + "tempAlertCounter": { + "type": "number", + "default": 5 + }, + "selectedDisks": { + "type": "array", + "items": { + "type": "string" + }, + "default": [] + }, + "gameId": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "string", + "enum": [""] + } + ] + }, + "grpcServiceName": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "string", + "enum": [""] + } + ], + "default": "" + }, + "group": { + "anyOf": [ + { + "type": "string", + "maxLength": 50 + }, + { + "nullable": true + } + ], + "default": null + }, + "geoCheckEnabled": { + "type": "boolean", + "default": false + }, + "geoCheckLocations": { + "type": "array", + "items": { + "type": "string", + "enum": ["EU", "NA", "AS", "SA", "AF", "OC"] + }, + "default": [] + }, + "geoCheckInterval": { + "type": "number", + "minimum": 300000, + "default": 300000 + }, + "createdAt": { + "type": "string" + }, + "updatedAt": { + "type": "string" + } + }, + "required": ["name", "type", "url"] + }, + "minItems": 1 + } + }, + "required": ["monitors"] + } + } + } + }, + "responses": { + "200": { + "description": "OK", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "success": { + "type": "boolean", + "enum": [true] + }, + "msg": { + "type": "string" + }, + "data": { + "type": "object", + "properties": { + "imported": { + "type": "number" + } + }, + "required": ["imported"], + "additionalProperties": { + "nullable": true + } + } + }, + "required": ["success", "msg", "data"] + } + } + } + }, + "401": { + "description": "Unauthorized", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "success": { + "type": "boolean", + "enum": [false] + }, + "msg": { + "type": "string" + }, + "data": { + "nullable": true + } + }, + "required": ["success", "msg"] + } + } + } + }, + "403": { + "description": "Forbidden", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "success": { + "type": "boolean", + "enum": [false] + }, + "msg": { + "type": "string" + }, + "data": { + "nullable": true + } + }, + "required": ["success", "msg"] + } + } + } + }, + "500": { + "description": "Internal server error", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "success": { + "type": "boolean", + "enum": [false] + }, + "msg": { + "type": "string" + }, + "data": { + "nullable": true + } + }, + "required": ["success", "msg"] + } + } + } + } + } + } + }, + "/monitors/games": { + "get": { + "tags": ["monitors"], + "summary": "List supported game-server types", + "security": [ + { + "bearerAuth": [] + } + ], + "responses": { + "200": { + "description": "OK", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "success": { + "type": "boolean", + "enum": [true] + }, + "msg": { + "type": "string" + }, + "data": { + "type": "array", + "items": { + "type": "string" + } + } + }, + "required": ["success", "msg", "data"] + } + } + } + }, + "401": { + "description": "Unauthorized", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "success": { + "type": "boolean", + "enum": [false] + }, + "msg": { + "type": "string" + }, + "data": { + "nullable": true + } + }, + "required": ["success", "msg"] + } + } + } + }, + "403": { + "description": "Forbidden", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "success": { + "type": "boolean", + "enum": [false] + }, + "msg": { + "type": "string" + }, + "data": { + "nullable": true + } + }, + "required": ["success", "msg"] + } + } + } + }, + "500": { + "description": "Internal server error", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "success": { + "type": "boolean", + "enum": [false] + }, + "msg": { + "type": "string" + }, + "data": { + "nullable": true + } + }, + "required": ["success", "msg"] + } + } + } + } + } + } + }, + "/monitors/{monitorId}": { + "get": { + "tags": ["monitors"], + "summary": "Get a monitor by id", + "security": [ + { + "bearerAuth": [] + } + ], + "parameters": [ + { + "schema": { + "type": "string", + "minLength": 1 + }, + "required": true, + "name": "monitorId", + "in": "path" + } + ], + "responses": { + "200": { + "description": "OK", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "success": { + "type": "boolean", + "enum": [true] + }, + "msg": { + "type": "string" + }, + "data": { + "$ref": "#/components/schemas/Monitor" + } + }, + "required": ["success", "msg", "data"] + } + } + } + }, + "401": { + "description": "Unauthorized", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "success": { + "type": "boolean", + "enum": [false] + }, + "msg": { + "type": "string" + }, + "data": { + "nullable": true + } + }, + "required": ["success", "msg"] + } + } + } + }, + "403": { + "description": "Forbidden", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "success": { + "type": "boolean", + "enum": [false] + }, + "msg": { + "type": "string" + }, + "data": { + "nullable": true + } + }, + "required": ["success", "msg"] + } + } + } + }, + "500": { + "description": "Internal server error", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "success": { + "type": "boolean", + "enum": [false] + }, + "msg": { + "type": "string" + }, + "data": { + "nullable": true + } + }, + "required": ["success", "msg"] + } + } + } + } + } + }, + "patch": { + "tags": ["monitors"], + "summary": "Edit a monitor", + "security": [ + { + "bearerAuth": [] + } + ], + "parameters": [ + { + "schema": { + "type": "string", + "minLength": 1 + }, + "required": true, + "name": "monitorId", + "in": "path" + } + ], + "requestBody": { + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "name": { + "type": "string" + }, + "type": { + "type": "string", + "enum": ["http", "ping", "pagespeed", "hardware", "docker", "port", "game", "grpc", "websocket", "unknown"] + }, + "url": { + "type": "string" + }, + "statusWindowSize": { + "type": "number", + "minimum": 1, + "maximum": 20, + "default": 5 + }, + "statusWindowThreshold": { + "type": "number", + "minimum": 1, + "maximum": 100, + "default": 60 + }, + "description": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "string", + "enum": [""] + } + ] + }, + "interval": { + "type": "number" + }, + "notifications": { + "type": "array", + "items": { + "type": "string" + } + }, + "secret": { + "type": "string" + }, + "ignoreTlsErrors": { + "type": "boolean" + }, + "useAdvancedMatching": { + "type": "boolean" + }, + "jsonPath": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "string", + "enum": [""] + } + ] + }, + "expectedValue": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "string", + "enum": [""] + } + ] + }, + "matchMethod": { + "anyOf": [ + { + "type": "string", + "enum": ["equal", "include", "regex"] + }, + { + "type": "string", + "enum": [""] + } + ] + }, + "port": { + "type": "number", + "minimum": 1, + "maximum": 65535 + }, + "cpuAlertThreshold": { + "type": "number" + }, + "memoryAlertThreshold": { + "type": "number" + }, + "diskAlertThreshold": { + "type": "number" + }, + "tempAlertThreshold": { + "type": "number" + }, + "gameId": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "string", + "enum": [""] + } + ] + }, + "grpcServiceName": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "string", + "enum": [""] + } + ] + }, + "selectedDisks": { + "type": "array", + "items": { + "type": "string" + } + }, + "group": { + "anyOf": [ + { + "type": "string", + "maxLength": 50 + }, + { + "nullable": true + }, + { + "type": "string", + "enum": [""] + } + ] + }, + "geoCheckEnabled": { + "type": "boolean" + }, + "geoCheckLocations": { + "type": "array", + "items": { + "type": "string", + "enum": ["EU", "NA", "AS", "SA", "AF", "OC"] + } + }, + "geoCheckInterval": { + "type": "number", + "minimum": 300000 } } } @@ -3297,7 +8179,7 @@ }, "responses": { "200": { - "description": "Incident resolved successfully", + "description": "OK", "content": { "application/json": { "schema": { @@ -3305,1433 +8187,2642 @@ "properties": { "success": { "type": "boolean", - "example": true + "enum": [true] }, "msg": { - "type": "string", - "example": "Incident resolved successfully" + "type": "string" }, "data": { - "$ref": "#/components/schemas/Incident" + "$ref": "#/components/schemas/Monitor" } - } + }, + "required": ["success", "msg", "data"] } } } }, "401": { - "$ref": "#/components/responses/Unauthorized" + "description": "Unauthorized", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "success": { + "type": "boolean", + "enum": [false] + }, + "msg": { + "type": "string" + }, + "data": { + "nullable": true + } + }, + "required": ["success", "msg"] + } + } + } }, "403": { - "$ref": "#/components/responses/Forbidden" - }, - "404": { - "$ref": "#/components/responses/NotFound" + "description": "Forbidden", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "success": { + "type": "boolean", + "enum": [false] + }, + "msg": { + "type": "string" + }, + "data": { + "nullable": true + } + }, + "required": ["success", "msg"] + } + } + } }, "500": { - "$ref": "#/components/responses/InternalServerError" + "description": "Internal server error", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "success": { + "type": "boolean", + "enum": [false] + }, + "msg": { + "type": "string" + }, + "data": { + "nullable": true + } + }, + "required": ["success", "msg"] + } + } + } } - }, + } + }, + "delete": { + "tags": ["monitors"], + "summary": "Delete a monitor", "security": [ { "bearerAuth": [] } - ] - } - } - }, - "components": { - "securitySchemes": { - "bearerAuth": { - "type": "http", - "scheme": "bearer", - "bearerFormat": "JWT", - "description": "JWT token obtained from login endpoint" - } - }, - "responses": { - "Success": { - "description": "Operation successful", - "content": { - "application/json": { + ], + "parameters": [ + { "schema": { - "$ref": "#/components/schemas/SuccessResponse" - } - } - } - }, - "NotFound": { - "description": "Resource not found", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/ErrorResponse" - } - } - } - }, - "ValidationError": { - "description": "Validation failed", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/ErrorResponse" - } - } - } - }, - "Unauthorized": { - "description": "Authentication required or token invalid", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/ErrorResponse" - } - } - } - }, - "Forbidden": { - "description": "Insufficient permissions", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/ErrorResponse" - } - } - } - }, - "BadRequest": { - "description": "Invalid request parameters", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/ErrorResponse" - } - } - } - }, - "InternalServerError": { - "description": "Internal server error", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/ErrorResponse" - } - } - } - } - }, - "schemas": { - "StatusPageTheme": { - "type": "string", - "enum": ["refined", "modern", "bold", "editorial"], - "default": "refined", - "description": "Visual theme for the public status page. Defaults to 'refined'." - }, - "StatusPageThemeMode": { - "type": "string", - "enum": ["auto", "light", "dark"], - "default": "auto", - "description": "Forces a light/dark mode on the public page. 'auto' follows the visitor's OS preference. Defaults to 'auto'." - }, - "SuccessResponse": { - "type": "object", - "required": ["success", "msg"], - "properties": { - "success": { - "type": "boolean", - "example": true - }, - "msg": { - "type": "string", - "example": "Operation completed successfully" - }, - "data": { - "type": "object", - "description": "Response payload (varies by endpoint)" - } - } - }, - "ErrorResponse": { - "type": "object", - "required": ["success", "msg"], - "properties": { - "success": { - "type": "boolean", - "example": false - }, - "msg": { - "type": "string", - "example": "An error occurred" - }, - "details": { - "type": "object", - "description": "Additional error details" - } - } - }, - "RegisterRequest": { - "type": "object", - "required": ["firstName", "lastName", "email", "password"], - "properties": { - "firstName": { - "type": "string", - "minLength": 1, - "maxLength": 50, - "example": "John" - }, - "lastName": { - "type": "string", - "minLength": 1, - "maxLength": 50, - "example": "Doe" - }, - "email": { - "type": "string", - "format": "email", - "example": "john@example.com" - }, - "password": { - "type": "string", - "minLength": 8, - "format": "password", - "example": "SecurePass123!" - }, - "profileImage": { - "type": "string", - "format": "binary", - "description": "Optional profile image file" - }, - "role": { - "type": "array", - "items": { "type": "string", - "enum": ["user", "admin", "superadmin", "Demo"] + "minLength": 1 }, - "default": ["user"] + "required": true, + "name": "monitorId", + "in": "path" + } + ], + "responses": { + "200": { + "description": "OK", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "success": { + "type": "boolean", + "enum": [true] + }, + "msg": { + "type": "string" + }, + "data": { + "$ref": "#/components/schemas/Monitor" + } + }, + "required": ["success", "msg", "data"] + } + } + } + }, + "401": { + "description": "Unauthorized", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "success": { + "type": "boolean", + "enum": [false] + }, + "msg": { + "type": "string" + }, + "data": { + "nullable": true + } + }, + "required": ["success", "msg"] + } + } + } + }, + "403": { + "description": "Forbidden", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "success": { + "type": "boolean", + "enum": [false] + }, + "msg": { + "type": "string" + }, + "data": { + "nullable": true + } + }, + "required": ["success", "msg"] + } + } + } + }, + "500": { + "description": "Internal server error", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "success": { + "type": "boolean", + "enum": [false] + }, + "msg": { + "type": "string" + }, + "data": { + "nullable": true + } + }, + "required": ["success", "msg"] + } + } + } } } - }, - "LoginRequest": { - "type": "object", - "required": ["email", "password"], - "properties": { - "email": { - "type": "string", - "format": "email", - "example": "john@example.com" + } + }, + "/notifications": { + "post": { + "tags": ["notifications"], + "summary": "Create a notification channel", + "description": "Create a notification channel of any supported type. The `type` field discriminates the body shape.", + "security": [ + { + "bearerAuth": [] + } + ], + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/NotificationChannelBody" + } + } + } + }, + "responses": { + "200": { + "description": "OK", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "success": { + "type": "boolean", + "enum": [true] + }, + "msg": { + "type": "string" + }, + "data": { + "nullable": true + } + }, + "required": ["success", "msg"] + } + } + } }, - "password": { - "type": "string", - "format": "password", - "example": "SecurePass123!" + "401": { + "description": "Unauthorized", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "success": { + "type": "boolean", + "enum": [false] + }, + "msg": { + "type": "string" + }, + "data": { + "nullable": true + } + }, + "required": ["success", "msg"] + } + } + } + }, + "403": { + "description": "Forbidden", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "success": { + "type": "boolean", + "enum": [false] + }, + "msg": { + "type": "string" + }, + "data": { + "nullable": true + } + }, + "required": ["success", "msg"] + } + } + } + }, + "500": { + "description": "Internal server error", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "success": { + "type": "boolean", + "enum": [false] + }, + "msg": { + "type": "string" + }, + "data": { + "nullable": true + } + }, + "required": ["success", "msg"] + } + } + } } } - }, - "AuthResponse": { - "allOf": [ + } + }, + "/notifications/test/all": { + "post": { + "tags": ["notifications"], + "summary": "Send a test alert through every notification channel for the team", + "security": [ { - "$ref": "#/components/schemas/SuccessResponse" - }, - { - "type": "object", - "properties": { - "data": { + "bearerAuth": [] + } + ], + "requestBody": { + "content": { + "application/json": { + "schema": { "type": "object", "properties": { - "token": { + "monitorId": { "type": "string", - "description": "JWT access token" + "minLength": 1 + } + }, + "required": ["monitorId"] + } + } + } + }, + "responses": { + "200": { + "description": "OK", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "success": { + "type": "boolean", + "enum": [true] + }, + "msg": { + "type": "string" + }, + "data": { + "nullable": true + } }, - "user": { - "$ref": "#/components/schemas/User" + "required": ["success", "msg"] + } + } + } + }, + "401": { + "description": "Unauthorized", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "success": { + "type": "boolean", + "enum": [false] + }, + "msg": { + "type": "string" + }, + "data": { + "nullable": true + } + }, + "required": ["success", "msg"] + } + } + } + }, + "403": { + "description": "Forbidden", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "success": { + "type": "boolean", + "enum": [false] + }, + "msg": { + "type": "string" + }, + "data": { + "nullable": true + } + }, + "required": ["success", "msg"] + } + } + } + }, + "500": { + "description": "Internal server error", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "success": { + "type": "boolean", + "enum": [false] + }, + "msg": { + "type": "string" + }, + "data": { + "nullable": true + } + }, + "required": ["success", "msg"] + } + } + } + } + } + } + }, + "/notifications/test": { + "post": { + "tags": ["notifications"], + "summary": "Send a test alert through a single notification channel", + "security": [ + { + "bearerAuth": [] + } + ], + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/NotificationChannelBody" + } + } + } + }, + "responses": { + "200": { + "description": "OK", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "success": { + "type": "boolean", + "enum": [true] + }, + "msg": { + "type": "string" + }, + "data": { + "type": "object", + "properties": { + "success": { + "type": "boolean" + } + }, + "required": ["success"], + "additionalProperties": { + "nullable": true + } + } + }, + "required": ["success", "msg", "data"] + } + } + } + }, + "401": { + "description": "Unauthorized", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "success": { + "type": "boolean", + "enum": [false] + }, + "msg": { + "type": "string" + }, + "data": { + "nullable": true + } + }, + "required": ["success", "msg"] + } + } + } + }, + "403": { + "description": "Forbidden", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "success": { + "type": "boolean", + "enum": [false] + }, + "msg": { + "type": "string" + }, + "data": { + "nullable": true + } + }, + "required": ["success", "msg"] + } + } + } + }, + "500": { + "description": "Internal server error", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "success": { + "type": "boolean", + "enum": [false] + }, + "msg": { + "type": "string" + }, + "data": { + "nullable": true + } + }, + "required": ["success", "msg"] + } + } + } + } + } + } + }, + "/notifications/team": { + "get": { + "tags": ["notifications"], + "summary": "List notification channels for the caller's team", + "security": [ + { + "bearerAuth": [] + } + ], + "responses": { + "200": { + "description": "OK", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "success": { + "type": "boolean", + "enum": [true] + }, + "msg": { + "type": "string" + }, + "data": { + "nullable": true + } + }, + "required": ["success", "msg"] + } + } + } + }, + "401": { + "description": "Unauthorized", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "success": { + "type": "boolean", + "enum": [false] + }, + "msg": { + "type": "string" + }, + "data": { + "nullable": true + } + }, + "required": ["success", "msg"] + } + } + } + }, + "403": { + "description": "Forbidden", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "success": { + "type": "boolean", + "enum": [false] + }, + "msg": { + "type": "string" + }, + "data": { + "nullable": true + } + }, + "required": ["success", "msg"] + } + } + } + }, + "500": { + "description": "Internal server error", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "success": { + "type": "boolean", + "enum": [false] + }, + "msg": { + "type": "string" + }, + "data": { + "nullable": true + } + }, + "required": ["success", "msg"] + } + } + } + } + } + } + }, + "/notifications/{id}": { + "get": { + "tags": ["notifications"], + "summary": "Get a notification channel by id", + "security": [ + { + "bearerAuth": [] + } + ], + "parameters": [ + { + "schema": { + "type": "string", + "minLength": 1 + }, + "required": true, + "name": "id", + "in": "path" + } + ], + "responses": { + "200": { + "description": "OK", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "success": { + "type": "boolean", + "enum": [true] + }, + "msg": { + "type": "string" + }, + "data": { + "nullable": true + } + }, + "required": ["success", "msg"] + } + } + } + }, + "401": { + "description": "Unauthorized", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "success": { + "type": "boolean", + "enum": [false] + }, + "msg": { + "type": "string" + }, + "data": { + "nullable": true + } + }, + "required": ["success", "msg"] + } + } + } + }, + "403": { + "description": "Forbidden", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "success": { + "type": "boolean", + "enum": [false] + }, + "msg": { + "type": "string" + }, + "data": { + "nullable": true + } + }, + "required": ["success", "msg"] + } + } + } + }, + "500": { + "description": "Internal server error", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "success": { + "type": "boolean", + "enum": [false] + }, + "msg": { + "type": "string" + }, + "data": { + "nullable": true + } + }, + "required": ["success", "msg"] + } + } + } + } + } + }, + "delete": { + "tags": ["notifications"], + "summary": "Delete a notification channel", + "security": [ + { + "bearerAuth": [] + } + ], + "parameters": [ + { + "schema": { + "type": "string", + "minLength": 1 + }, + "required": true, + "name": "id", + "in": "path" + } + ], + "responses": { + "200": { + "description": "OK", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "success": { + "type": "boolean", + "enum": [true] + }, + "msg": { + "type": "string" + } + }, + "required": ["success", "msg"] + }, + "example": { + "success": true, + "msg": "OK" + } + } + } + }, + "401": { + "description": "Unauthorized", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "success": { + "type": "boolean", + "enum": [false] + }, + "msg": { + "type": "string" + }, + "data": { + "nullable": true + } + }, + "required": ["success", "msg"] + } + } + } + }, + "403": { + "description": "Forbidden", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "success": { + "type": "boolean", + "enum": [false] + }, + "msg": { + "type": "string" + }, + "data": { + "nullable": true + } + }, + "required": ["success", "msg"] + } + } + } + }, + "500": { + "description": "Internal server error", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "success": { + "type": "boolean", + "enum": [false] + }, + "msg": { + "type": "string" + }, + "data": { + "nullable": true + } + }, + "required": ["success", "msg"] + } + } + } + } + } + }, + "patch": { + "tags": ["notifications"], + "summary": "Edit a notification channel", + "security": [ + { + "bearerAuth": [] + } + ], + "parameters": [ + { + "schema": { + "type": "string", + "minLength": 1 + }, + "required": true, + "name": "id", + "in": "path" + } + ], + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/NotificationChannelBody" + } + } + } + }, + "responses": { + "200": { + "description": "OK", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "success": { + "type": "boolean", + "enum": [true] + }, + "msg": { + "type": "string" + }, + "data": { + "nullable": true + } + }, + "required": ["success", "msg"] + } + } + } + }, + "401": { + "description": "Unauthorized", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "success": { + "type": "boolean", + "enum": [false] + }, + "msg": { + "type": "string" + }, + "data": { + "nullable": true + } + }, + "required": ["success", "msg"] + } + } + } + }, + "403": { + "description": "Forbidden", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "success": { + "type": "boolean", + "enum": [false] + }, + "msg": { + "type": "string" + }, + "data": { + "nullable": true + } + }, + "required": ["success", "msg"] + } + } + } + }, + "500": { + "description": "Internal server error", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "success": { + "type": "boolean", + "enum": [false] + }, + "msg": { + "type": "string" + }, + "data": { + "nullable": true + } + }, + "required": ["success", "msg"] + } + } + } + } + } + } + }, + "/queue/jobs": { + "get": { + "tags": ["queue"], + "summary": "List queued monitor jobs (admin/superadmin)", + "security": [ + { + "bearerAuth": [] + } + ], + "responses": { + "200": { + "description": "OK", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "success": { + "type": "boolean", + "enum": [true] + }, + "msg": { + "type": "string" + }, + "data": { + "nullable": true + } + }, + "required": ["success", "msg"] + } + } + } + }, + "401": { + "description": "Unauthorized", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "success": { + "type": "boolean", + "enum": [false] + }, + "msg": { + "type": "string" + }, + "data": { + "nullable": true + } + }, + "required": ["success", "msg"] + } + } + } + }, + "403": { + "description": "Forbidden", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "success": { + "type": "boolean", + "enum": [false] + }, + "msg": { + "type": "string" + }, + "data": { + "nullable": true + } + }, + "required": ["success", "msg"] + } + } + } + }, + "500": { + "description": "Internal server error", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "success": { + "type": "boolean", + "enum": [false] + }, + "msg": { + "type": "string" + }, + "data": { + "nullable": true + } + }, + "required": ["success", "msg"] + } + } + } + } + } + } + }, + "/queue/metrics": { + "get": { + "tags": ["queue"], + "summary": "Get queue runtime metrics (admin/superadmin)", + "security": [ + { + "bearerAuth": [] + } + ], + "responses": { + "200": { + "description": "OK", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "success": { + "type": "boolean", + "enum": [true] + }, + "msg": { + "type": "string" + }, + "data": { + "nullable": true + } + }, + "required": ["success", "msg"] + } + } + } + }, + "401": { + "description": "Unauthorized", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "success": { + "type": "boolean", + "enum": [false] + }, + "msg": { + "type": "string" + }, + "data": { + "nullable": true + } + }, + "required": ["success", "msg"] + } + } + } + }, + "403": { + "description": "Forbidden", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "success": { + "type": "boolean", + "enum": [false] + }, + "msg": { + "type": "string" + }, + "data": { + "nullable": true + } + }, + "required": ["success", "msg"] + } + } + } + }, + "500": { + "description": "Internal server error", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "success": { + "type": "boolean", + "enum": [false] + }, + "msg": { + "type": "string" + }, + "data": { + "nullable": true + } + }, + "required": ["success", "msg"] + } + } + } + } + } + } + }, + "/queue/all-metrics": { + "get": { + "tags": ["queue"], + "summary": "Get queue metrics across all monitors (admin/superadmin)", + "security": [ + { + "bearerAuth": [] + } + ], + "responses": { + "200": { + "description": "OK", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "success": { + "type": "boolean", + "enum": [true] + }, + "msg": { + "type": "string" + }, + "data": { + "nullable": true + } + }, + "required": ["success", "msg"] + } + } + } + }, + "401": { + "description": "Unauthorized", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "success": { + "type": "boolean", + "enum": [false] + }, + "msg": { + "type": "string" + }, + "data": { + "nullable": true + } + }, + "required": ["success", "msg"] + } + } + } + }, + "403": { + "description": "Forbidden", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "success": { + "type": "boolean", + "enum": [false] + }, + "msg": { + "type": "string" + }, + "data": { + "nullable": true + } + }, + "required": ["success", "msg"] + } + } + } + }, + "500": { + "description": "Internal server error", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "success": { + "type": "boolean", + "enum": [false] + }, + "msg": { + "type": "string" + }, + "data": { + "nullable": true + } + }, + "required": ["success", "msg"] + } + } + } + } + } + } + }, + "/queue/flush": { + "post": { + "tags": ["queue"], + "summary": "Flush the monitor job queue (admin/superadmin)", + "security": [ + { + "bearerAuth": [] + } + ], + "responses": { + "200": { + "description": "OK", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "success": { + "type": "boolean", + "enum": [true] + }, + "msg": { + "type": "string" + } + }, + "required": ["success", "msg"] + }, + "example": { + "success": true, + "msg": "OK" + } + } + } + }, + "401": { + "description": "Unauthorized", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "success": { + "type": "boolean", + "enum": [false] + }, + "msg": { + "type": "string" + }, + "data": { + "nullable": true + } + }, + "required": ["success", "msg"] + } + } + } + }, + "403": { + "description": "Forbidden", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "success": { + "type": "boolean", + "enum": [false] + }, + "msg": { + "type": "string" + }, + "data": { + "nullable": true + } + }, + "required": ["success", "msg"] + } + } + } + }, + "500": { + "description": "Internal server error", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "success": { + "type": "boolean", + "enum": [false] + }, + "msg": { + "type": "string" + }, + "data": { + "nullable": true + } + }, + "required": ["success", "msg"] + } + } + } + } + } + } + }, + "/settings": { + "get": { + "tags": ["settings"], + "summary": "Get application settings", + "security": [ + { + "bearerAuth": [] + } + ], + "responses": { + "200": { + "description": "OK", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "success": { + "type": "boolean", + "enum": [true] + }, + "msg": { + "type": "string" + }, + "data": { + "nullable": true + } + }, + "required": ["success", "msg"] + } + } + } + }, + "401": { + "description": "Unauthorized", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "success": { + "type": "boolean", + "enum": [false] + }, + "msg": { + "type": "string" + }, + "data": { + "nullable": true + } + }, + "required": ["success", "msg"] + } + } + } + }, + "403": { + "description": "Forbidden", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "success": { + "type": "boolean", + "enum": [false] + }, + "msg": { + "type": "string" + }, + "data": { + "nullable": true + } + }, + "required": ["success", "msg"] + } + } + } + }, + "500": { + "description": "Internal server error", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "success": { + "type": "boolean", + "enum": [false] + }, + "msg": { + "type": "string" + }, + "data": { + "nullable": true + } + }, + "required": ["success", "msg"] + } + } + } + } + } + }, + "patch": { + "tags": ["settings"], + "summary": "Update application settings (admin/superadmin)", + "security": [ + { + "bearerAuth": [] + } + ], + "requestBody": { + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "checkTTL": { + "type": "integer", + "minimum": 1, + "maximum": 366 + }, + "systemEmailPort": { + "type": "number", + "nullable": true + }, + "pagespeedApiKey": { + "type": "string", + "nullable": true + }, + "language": { + "type": "string" + }, + "timezone": { + "type": "string" + }, + "systemEmailHost": { + "type": "string", + "nullable": true + }, + "systemEmailAddress": { + "type": "string", + "nullable": true + }, + "systemEmailDisplayName": { + "type": "string", + "nullable": true, + "maxLength": 100 + }, + "systemEmailPassword": { + "type": "string", + "nullable": true + }, + "systemEmailUser": { + "type": "string", + "nullable": true + }, + "systemEmailConnectionHost": { + "type": "string", + "nullable": true + }, + "systemEmailTLSServername": { + "type": "string", + "nullable": true + }, + "showURL": { + "type": "boolean" + }, + "systemEmailSecure": { + "type": "boolean" + }, + "systemEmailPool": { + "type": "boolean" + }, + "systemEmailIgnoreTLS": { + "type": "boolean" + }, + "systemEmailRequireTLS": { + "type": "boolean" + }, + "systemEmailRejectUnauthorized": { + "type": "boolean" + }, + "globalThresholds": { + "type": "object", + "properties": { + "cpu": { + "type": "number", + "minimum": 1, + "maximum": 100 + }, + "memory": { + "type": "number", + "minimum": 1, + "maximum": 100 + }, + "disk": { + "type": "number", + "minimum": 1, + "maximum": 100 + }, + "temperature": { + "type": "number", + "minimum": 1, + "maximum": 150 + } + } } } } } } - ] - }, - "UserUpdateRequest": { - "type": "object", - "properties": { - "firstName": { - "type": "string", - "minLength": 1, - "maxLength": 50 - }, - "lastName": { - "type": "string", - "minLength": 1, - "maxLength": 50 - }, - "password": { - "type": "string", - "format": "password", - "description": "Current password for verification" - }, - "newPassword": { - "type": "string", - "minLength": 8, - "format": "password", - "description": "New password (if changing)" - }, - "profileImage": { - "type": "string", - "format": "binary", - "description": "New profile image file" - }, - "deleteProfileImage": { - "type": "boolean", - "description": "Flag to delete current profile image" - } - } - }, - "User": { - "type": "object", - "properties": { - "_id": { - "type": "string", - "example": "64f123a456b789012c345def" - }, - "firstName": { - "type": "string", - "example": "John" - }, - "lastName": { - "type": "string", - "example": "Doe" - }, - "email": { - "type": "string", - "format": "email", - "example": "john@example.com" - }, - "role": { - "type": "array", - "items": { - "type": "string", - "enum": ["user", "admin", "superadmin", "Demo"] - } - }, - "profileImage": { - "type": "string", - "description": "URL or path to profile image" - }, - "isActive": { - "type": "boolean" - }, - "teamId": { - "type": "string" - }, - "createdAt": { - "type": "string", - "format": "date-time" - }, - "updatedAt": { - "type": "string", - "format": "date-time" - } - } - }, - "CreateMonitorRequest": { - "type": "object", - "required": ["name", "type", "url"], - "properties": { - "name": { - "type": "string", - "example": "My Website Monitor" - }, - "description": { - "type": "string", - "example": "Monitors the main website homepage" - }, - "type": { - "type": "string", - "enum": ["http", "ping", "pagespeed", "hardware", "docker", "port", "game", "grpc", "websocket"], - "example": "http" - }, - "url": { - "type": "string", - "format": "uri", - "example": "https://example.com" - }, - "interval": { - "type": "integer", - "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": false - }, - "useAdvancedMatching": { - "type": "boolean", - "default": false - }, - "port": { - "type": "integer" - }, - "notifications": { - "type": "array", - "items": { - "type": "string" - }, - "description": "Array of notification IDs to associate with this monitor" - }, - "secret": { - "type": "string" - }, - "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": "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 - } - } - }, - "UpdateMonitorRequest": { - "type": "object", - "properties": { - "name": { - "type": "string" - }, - "type": { - "type": "string", - "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" - }, - "interval": { - "type": "integer" - }, - "notifications": { - "type": "array", - "items": { - "type": "string" - } - }, - "secret": { - "type": "string" - }, - "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": "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 - } - } - }, - "Monitor": { - "type": "object", - "properties": { - "_id": { - "type": "string", - "example": "64f123a456b789012c345def" - }, - "userId": { - "type": "string" - }, - "teamId": { - "type": "string" - }, - "name": { - "type": "string", - "example": "My Website Monitor" - }, - "description": { - "type": "string", - "example": "Monitors the main website homepage" - }, - "type": { - "type": "string", - "enum": ["http", "ping", "pagespeed", "hardware", "docker", "port"] - }, - "url": { - "type": "string", - "format": "uri", - "example": "https://example.com" - }, - "interval": { - "type": "integer", - "example": 300 - }, - "isActive": { - "type": "boolean", - "example": true - }, - "status": { - "type": "boolean", - "description": "Current monitor status (up/down)" - }, - "lastChecked": { - "type": "string", - "format": "date-time", - "description": "Timestamp of last check" - }, - "notifications": { - "type": "array", - "items": { - "type": "string" - } - }, - "httpOptions": { - "type": "object", - "properties": { - "method": { - "type": "string" - }, - "headers": { - "type": "object" - }, - "body": { - "type": "string" - }, - "timeout": { - "type": "integer" + }, + "responses": { + "200": { + "description": "OK", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "success": { + "type": "boolean", + "enum": [true] + }, + "msg": { + "type": "string" + }, + "data": { + "nullable": true + } + }, + "required": ["success", "msg"] + } } } }, - "assertions": { - "type": "array", - "items": { - "type": "object" + "401": { + "description": "Unauthorized", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "success": { + "type": "boolean", + "enum": [false] + }, + "msg": { + "type": "string" + }, + "data": { + "nullable": true + } + }, + "required": ["success", "msg"] + } + } } }, - "createdAt": { - "type": "string", - "format": "date-time" + "403": { + "description": "Forbidden", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "success": { + "type": "boolean", + "enum": [false] + }, + "msg": { + "type": "string" + }, + "data": { + "nullable": true + } + }, + "required": ["success", "msg"] + } + } + } }, - "updatedAt": { - "type": "string", - "format": "date-time" + "500": { + "description": "Internal server error", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "success": { + "type": "boolean", + "enum": [false] + }, + "msg": { + "type": "string" + }, + "data": { + "nullable": true + } + }, + "required": ["success", "msg"] + } + } + } } } - }, - "CreateNotificationRequest": { - "type": "object", - "required": ["type", "name"], - "properties": { - "type": { - "type": "string", - "enum": ["email", "webhook", "slack", "discord", "telegram", "zapier"], - "example": "email" - }, - "name": { - "type": "string", - "minLength": 1, - "maxLength": 100, - "example": "Admin Email Notifications" - }, - "config": { - "type": "object", - "description": "Configuration specific to notification type", - "oneOf": [ - { - "title": "Email Configuration", + } + }, + "/settings/test-email": { + "post": { + "tags": ["settings"], + "summary": "Send a test email using current SMTP settings (admin/superadmin)", + "security": [ + { + "bearerAuth": [] + } + ], + "requestBody": { + "content": { + "application/json": { + "schema": { + "type": "object", "properties": { "to": { + "type": "string", + "minLength": 1 + }, + "systemEmailHost": { + "type": "string" + }, + "systemEmailPort": { + "type": "number" + }, + "systemEmailSecure": { + "type": "boolean" + }, + "systemEmailPool": { + "type": "boolean" + }, + "systemEmailAddress": { + "type": "string" + }, + "systemEmailDisplayName": { + "type": "string" + }, + "systemEmailPassword": { + "type": "string" + }, + "systemEmailUser": { + "type": "string" + }, + "systemEmailConnectionHost": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "string", + "enum": [""] + } + ] + }, + "systemEmailIgnoreTLS": { + "type": "boolean" + }, + "systemEmailRequireTLS": { + "type": "boolean" + }, + "systemEmailRejectUnauthorized": { + "type": "boolean" + }, + "systemEmailTLSServername": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "string", + "enum": [""] + } + ] + } + }, + "required": ["to"] + } + } + } + }, + "responses": { + "200": { + "description": "OK", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "success": { + "type": "boolean", + "enum": [true] + }, + "msg": { + "type": "string" + } + }, + "required": ["success", "msg"] + }, + "example": { + "success": true, + "msg": "OK" + } + } + } + }, + "401": { + "description": "Unauthorized", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "success": { + "type": "boolean", + "enum": [false] + }, + "msg": { + "type": "string" + }, + "data": { + "nullable": true + } + }, + "required": ["success", "msg"] + } + } + } + }, + "403": { + "description": "Forbidden", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "success": { + "type": "boolean", + "enum": [false] + }, + "msg": { + "type": "string" + }, + "data": { + "nullable": true + } + }, + "required": ["success", "msg"] + } + } + } + }, + "500": { + "description": "Internal server error", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "success": { + "type": "boolean", + "enum": [false] + }, + "msg": { + "type": "string" + }, + "data": { + "nullable": true + } + }, + "required": ["success", "msg"] + } + } + } + } + } + } + }, + "/status-page/team": { + "get": { + "tags": ["status-page"], + "summary": "List status pages for the caller's team", + "security": [ + { + "bearerAuth": [] + } + ], + "responses": { + "200": { + "description": "OK", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "success": { + "type": "boolean", + "enum": [true] + }, + "msg": { + "type": "string" + }, + "data": { + "nullable": true + } + }, + "required": ["success", "msg"] + } + } + } + }, + "401": { + "description": "Unauthorized", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "success": { + "type": "boolean", + "enum": [false] + }, + "msg": { + "type": "string" + }, + "data": { + "nullable": true + } + }, + "required": ["success", "msg"] + } + } + } + }, + "403": { + "description": "Forbidden", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "success": { + "type": "boolean", + "enum": [false] + }, + "msg": { + "type": "string" + }, + "data": { + "nullable": true + } + }, + "required": ["success", "msg"] + } + } + } + }, + "500": { + "description": "Internal server error", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "success": { + "type": "boolean", + "enum": [false] + }, + "msg": { + "type": "string" + }, + "data": { + "nullable": true + } + }, + "required": ["success", "msg"] + } + } + } + } + } + } + }, + "/status-page": { + "post": { + "tags": ["status-page"], + "summary": "Create a status page (logo upload optional)", + "security": [ + { + "bearerAuth": [] + } + ], + "requestBody": { + "content": { + "multipart/form-data": { + "schema": { + "type": "object", + "properties": { + "type": { + "anyOf": [ + { + "type": "string", + "enum": ["uptime", "infrastructure"] + }, + { + "type": "array", + "items": { + "type": "string", + "enum": ["uptime", "infrastructure"] + } + } + ] + }, + "companyName": { + "type": "string", + "minLength": 1 + }, + "url": { + "type": "string", + "pattern": "^[a-zA-Z0-9_-]+$" + }, + "timezone": { + "type": "string" + }, + "color": { + "type": "string" + }, + "monitors": { "type": "array", "items": { "type": "string", - "format": "email" + "pattern": "^[0-9a-fA-F]{24}$" + }, + "minItems": 1 + }, + "subMonitors": { + "type": "array", + "items": { + "type": "string", + "pattern": "^[0-9a-fA-F]{24}$" } + }, + "deleteSubmonitors": { + "type": "boolean" + }, + "isPublished": { + "type": "boolean" + }, + "showCharts": { + "type": "boolean" + }, + "showUptimePercentage": { + "type": "boolean" + }, + "showAdminLoginLink": { + "type": "boolean" + }, + "showInfrastructure": { + "type": "boolean" + }, + "removeLogo": { + "anyOf": [ + { + "type": "string", + "enum": ["true"] + }, + { + "type": "string", + "enum": ["false"] + } + ] + }, + "theme": { + "type": "string", + "enum": ["refined", "modern", "bold", "editorial"] + }, + "themeMode": { + "type": "string", + "enum": ["auto", "light", "dark"] + }, + "logo": { + "type": "string", + "format": "binary" } + }, + "required": ["type", "companyName", "url", "monitors", "isPublished", "showUptimePercentage", "logo"] + } + } + } + }, + "responses": { + "200": { + "description": "OK", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "success": { + "type": "boolean", + "enum": [true] + }, + "msg": { + "type": "string" + }, + "data": { + "nullable": true + } + }, + "required": ["success", "msg"] } - }, - { - "title": "Webhook Configuration", + } + } + }, + "401": { + "description": "Unauthorized", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "success": { + "type": "boolean", + "enum": [false] + }, + "msg": { + "type": "string" + }, + "data": { + "nullable": true + } + }, + "required": ["success", "msg"] + } + } + } + }, + "403": { + "description": "Forbidden", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "success": { + "type": "boolean", + "enum": [false] + }, + "msg": { + "type": "string" + }, + "data": { + "nullable": true + } + }, + "required": ["success", "msg"] + } + } + } + }, + "500": { + "description": "Internal server error", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "success": { + "type": "boolean", + "enum": [false] + }, + "msg": { + "type": "string" + }, + "data": { + "nullable": true + } + }, + "required": ["success", "msg"] + } + } + } + } + } + } + }, + "/status-page/{id}": { + "put": { + "tags": ["status-page"], + "summary": "Update a status page (logo upload optional)", + "security": [ + { + "bearerAuth": [] + } + ], + "requestBody": { + "content": { + "multipart/form-data": { + "schema": { + "type": "object", "properties": { + "type": { + "anyOf": [ + { + "type": "string", + "enum": ["uptime", "infrastructure"] + }, + { + "type": "array", + "items": { + "type": "string", + "enum": ["uptime", "infrastructure"] + } + } + ] + }, + "companyName": { + "type": "string", + "minLength": 1 + }, "url": { "type": "string", - "format": "uri" + "pattern": "^[a-zA-Z0-9_-]+$" }, - "headers": { - "type": "object", - "additionalProperties": { + "timezone": { + "type": "string" + }, + "color": { + "type": "string" + }, + "monitors": { + "type": "array", + "items": { + "type": "string", + "pattern": "^[0-9a-fA-F]{24}$" + }, + "minItems": 1 + }, + "subMonitors": { + "type": "array", + "items": { + "type": "string", + "pattern": "^[0-9a-fA-F]{24}$" + } + }, + "deleteSubmonitors": { + "type": "boolean" + }, + "isPublished": { + "type": "boolean" + }, + "showCharts": { + "type": "boolean" + }, + "showUptimePercentage": { + "type": "boolean" + }, + "showAdminLoginLink": { + "type": "boolean" + }, + "showInfrastructure": { + "type": "boolean" + }, + "removeLogo": { + "anyOf": [ + { + "type": "string", + "enum": ["true"] + }, + { + "type": "string", + "enum": ["false"] + } + ] + }, + "theme": { + "type": "string", + "enum": ["refined", "modern", "bold", "editorial"] + }, + "themeMode": { + "type": "string", + "enum": ["auto", "light", "dark"] + }, + "logo": { + "type": "string", + "format": "binary" + } + }, + "required": ["type", "companyName", "url", "monitors", "isPublished", "showUptimePercentage", "logo"] + } + } + } + }, + "responses": { + "200": { + "description": "OK", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "success": { + "type": "boolean", + "enum": [true] + }, + "msg": { + "type": "string" + }, + "data": { + "nullable": true + } + }, + "required": ["success", "msg"] + } + } + } + }, + "401": { + "description": "Unauthorized", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "success": { + "type": "boolean", + "enum": [false] + }, + "msg": { + "type": "string" + }, + "data": { + "nullable": true + } + }, + "required": ["success", "msg"] + } + } + } + }, + "403": { + "description": "Forbidden", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "success": { + "type": "boolean", + "enum": [false] + }, + "msg": { + "type": "string" + }, + "data": { + "nullable": true + } + }, + "required": ["success", "msg"] + } + } + } + }, + "500": { + "description": "Internal server error", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "success": { + "type": "boolean", + "enum": [false] + }, + "msg": { + "type": "string" + }, + "data": { + "nullable": true + } + }, + "required": ["success", "msg"] + } + } + } + } + } + }, + "delete": { + "tags": ["status-page"], + "summary": "Delete a status page", + "security": [ + { + "bearerAuth": [] + } + ], + "responses": { + "200": { + "description": "OK", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "success": { + "type": "boolean", + "enum": [true] + }, + "msg": { "type": "string" } - } - } - }, - { - "title": "Slack Configuration", - "properties": { - "webhookUrl": { - "type": "string", - "format": "uri" }, - "channel": { - "type": "string" - } + "required": ["success", "msg"] + }, + "example": { + "success": true, + "msg": "OK" } - }, - { - "title": "Discord Configuration", - "properties": { - "webhookUrl": { + } + } + }, + "401": { + "description": "Unauthorized", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "success": { + "type": "boolean", + "enum": [false] + }, + "msg": { + "type": "string" + }, + "data": { + "nullable": true + } + }, + "required": ["success", "msg"] + } + } + } + }, + "403": { + "description": "Forbidden", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "success": { + "type": "boolean", + "enum": [false] + }, + "msg": { + "type": "string" + }, + "data": { + "nullable": true + } + }, + "required": ["success", "msg"] + } + } + } + }, + "500": { + "description": "Internal server error", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "success": { + "type": "boolean", + "enum": [false] + }, + "msg": { + "type": "string" + }, + "data": { + "nullable": true + } + }, + "required": ["success", "msg"] + } + } + } + } + } + } + }, + "/status-page/{url}": { + "get": { + "tags": ["status-page"], + "summary": "Get a public status page by its URL slug", + "parameters": [ + { + "schema": { + "type": "string", + "minLength": 1 + }, + "required": true, + "name": "url", + "in": "path" + }, + { + "schema": { + "anyOf": [ + { + "type": "string", + "enum": ["uptime", "infrastructure"] + }, + { + "type": "array", + "items": { "type": "string", - "format": "uri" + "enum": ["uptime", "infrastructure"] } } - } - ] - }, - "isActive": { - "type": "boolean", - "default": true - } - } - }, - "Notification": { - "type": "object", - "properties": { - "_id": { - "type": "string" - }, - "userId": { - "type": "string" - }, - "teamId": { - "type": "string" - }, - "type": { - "type": "string", - "enum": ["email", "webhook", "slack", "discord", "telegram", "zapier"] - }, - "name": { - "type": "string" - }, - "config": { - "type": "object", - "description": "Type-specific configuration" - }, - "isActive": { - "type": "boolean" - }, - "createdAt": { - "type": "string", - "format": "date-time" - }, - "updatedAt": { - "type": "string", - "format": "date-time" - } - } - }, - "AppSettings": { - "type": "object", - "properties": { - "appName": { - "type": "string", - "example": "Checkmate" - }, - "appUrl": { - "type": "string", - "format": "uri", - "example": "https://checkmate.example.com" - }, - "logLevel": { - "type": "string", - "enum": ["error", "warn", "info", "debug"], - "default": "info" - }, - "emailConfig": { - "type": "object", - "properties": { - "host": { - "type": "string" - }, - "port": { - "type": "integer" - }, - "secure": { - "type": "boolean" - }, - "user": { - "type": "string" - }, - "pass": { - "type": "string", - "format": "password" - }, - "from": { - "type": "string", - "format": "email" - } - } - }, - "webhookRetries": { - "type": "integer", - "minimum": 0, - "maximum": 10, - "default": 3 - }, - "checksRetention": { - "type": "integer", - "minimum": 1, - "maximum": 365, - "default": 90, - "description": "Days to retain check results" - } - } - }, - "SystemDiagnostics": { - "type": "object", - "properties": { - "uptime": { - "type": "number", - "description": "System uptime in seconds" - }, - "memory": { - "type": "object", - "properties": { - "total": { - "type": "number", - "description": "Total memory in bytes" - }, - "used": { - "type": "number", - "description": "Used memory in bytes" - }, - "free": { - "type": "number", - "description": "Free memory in bytes" - } - } - }, - "cpu": { - "type": "object", - "properties": { - "usage": { - "type": "number", - "description": "CPU usage percentage" - }, - "cores": { - "type": "integer", - "description": "Number of CPU cores" - } - } - }, - "database": { - "type": "object", - "properties": { - "status": { - "type": "string", - "enum": ["connected", "disconnected", "error"] - }, - "responseTime": { - "type": "number", - "description": "Database response time in milliseconds" - } - } - }, - "queue": { - "type": "object", - "properties": { - "active": { - "type": "integer" - }, - "waiting": { - "type": "integer" - }, - "completed": { - "type": "integer" - }, - "failed": { - "type": "integer" - } - } - } - } - }, - "Incident": { - "type": "object", - "properties": { - "_id": { - "type": "string", - "description": "Unique incident identifier" - }, - "monitorId": { - "type": "string", - "description": "ID of the associated monitor" - }, - "teamId": { - "type": "string", - "description": "ID of the team" - }, - "startTime": { - "type": "string", - "format": "date-time", - "description": "When the incident started" - }, - "endTime": { - "type": ["string", "null"], - "format": "date-time", - "description": "When the incident was resolved (null if ongoing)" - }, - "status": { - "type": "boolean", - "description": "Incident status (true = ongoing, false = resolved)" - }, - "message": { - "type": ["string", "null"], - "description": "Error message or description" - }, - "statusCode": { - "type": ["integer", "null"], - "description": "HTTP status code if applicable" - }, - "resolutionType": { - "type": ["string", "null"], - "enum": ["automatic", "manual", null], - "description": "How the incident was resolved" - }, - "resolvedBy": { - "type": ["string", "null"], - "description": "User ID who resolved the incident (for manual resolution)" - }, - "comment": { - "type": ["string", "null"], - "description": "Resolution comment" - }, - "checks": { - "type": "array", - "items": { - "type": "string" + ] }, - "description": "Array of check IDs associated with this incident" + "required": true, + "name": "type", + "in": "query" }, - "createdAt": { - "type": "string", - "format": "date-time" - }, - "updatedAt": { - "type": "string", - "format": "date-time" + { + "schema": { + "type": "number", + "nullable": true + }, + "required": false, + "name": "timeFrame", + "in": "query" } - } - }, - "IncidentSummary": { - "type": "object", - "properties": { - "totalIncidents": { - "type": "integer", - "description": "Total number of incidents" - }, - "activeIncidents": { - "type": "integer", - "description": "Number of ongoing incidents" - }, - "resolvedIncidents": { - "type": "integer", - "description": "Number of resolved incidents" - }, - "averageResolutionTime": { - "type": "number", - "description": "Average time to resolve incidents in milliseconds" - }, - "incidentsByMonitor": { - "type": "array", - "items": { - "type": "object", - "properties": { - "monitorId": { - "type": "string" - }, - "monitorName": { - "type": "string" - }, - "count": { - "type": "integer" + ], + "responses": { + "200": { + "description": "OK", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "success": { + "type": "boolean", + "enum": [true] + }, + "msg": { + "type": "string" + }, + "data": { + "nullable": true + } + }, + "required": ["success", "msg"] } } } - } - } - }, - "QueueJob": { - "type": "object", - "properties": { - "id": { - "type": "string", - "description": "Job ID" }, - "name": { - "type": "string", - "description": "Job name" + "404": { + "description": "Status page not found" }, - "data": { - "type": "object", - "description": "Job payload data" - }, - "status": { - "type": "string", - "enum": ["waiting", "active", "completed", "failed", "delayed"], - "description": "Current job status" - }, - "progress": { - "type": "number", - "description": "Job progress percentage" - }, - "attemptsMade": { - "type": "integer", - "description": "Number of attempts made" - }, - "timestamp": { - "type": "string", - "format": "date-time", - "description": "When the job was created" - }, - "processedOn": { - "type": ["string", "null"], - "format": "date-time", - "description": "When the job started processing" - }, - "finishedOn": { - "type": ["string", "null"], - "format": "date-time", - "description": "When the job finished" - } - } - }, - "QueueMetrics": { - "type": "object", - "properties": { - "waiting": { - "type": "integer", - "description": "Number of jobs waiting" - }, - "active": { - "type": "integer", - "description": "Number of jobs currently processing" - }, - "completed": { - "type": "integer", - "description": "Number of completed jobs" - }, - "failed": { - "type": "integer", - "description": "Number of failed jobs" - }, - "delayed": { - "type": "integer", - "description": "Number of delayed jobs" - } - } - }, - "Check": { - "type": "object", - "description": "A monitoring check result", - "properties": { - "_id": { - "type": "string", - "description": "Check ID" - }, - "metadata": { - "type": "object", - "properties": { - "monitorId": { - "type": "string", - "description": "Associated monitor ID" - }, - "teamId": { - "type": "string", - "description": "Associated team ID" - }, - "type": { - "type": "string", - "enum": ["http", "ping", "pagespeed", "hardware", "docker", "port"], - "description": "Monitor type" - } - }, - "required": ["monitorId", "teamId", "type"] - }, - "status": { - "type": "boolean", - "description": "Check status (true = up, false = down)" - }, - "responseTime": { - "type": "number", - "description": "Response time in milliseconds" - }, - "statusCode": { - "type": "integer", - "description": "HTTP status code (for HTTP monitors)" - }, - "message": { - "type": "string", - "description": "Status message or error description" - }, - "timings": { - "$ref": "#/components/schemas/CheckTimings" - }, - "cpu": { - "$ref": "#/components/schemas/CheckCpuInfo" - }, - "memory": { - "$ref": "#/components/schemas/CheckMemoryInfo" - }, - "disk": { - "type": "array", - "items": { - "$ref": "#/components/schemas/CheckDiskInfo" - } - }, - "host": { - "$ref": "#/components/schemas/CheckHostInfo" - }, - "errors": { - "type": "array", - "items": { - "$ref": "#/components/schemas/CheckErrorInfo" - } - }, - "capture": { - "$ref": "#/components/schemas/CheckCaptureInfo" - }, - "net": { - "type": "array", - "items": { - "$ref": "#/components/schemas/CheckNetworkInfo" - } - }, - "accessibility": { - "type": "number", - "description": "PageSpeed accessibility score (0-100)" - }, - "bestPractices": { - "type": "number", - "description": "PageSpeed best practices score (0-100)" - }, - "seo": { - "type": "number", - "description": "PageSpeed SEO score (0-100)" - }, - "performance": { - "type": "number", - "description": "PageSpeed performance score (0-100)" - }, - "audits": { - "$ref": "#/components/schemas/CheckAudits" - }, - "ack": { - "type": "boolean", - "description": "Whether the check has been acknowledged" - }, - "ackAt": { - "type": ["string", "null"], - "format": "date-time", - "description": "When the check was acknowledged" - }, - "expiry": { - "type": "string", - "format": "date-time", - "description": "When the check expires" - }, - "createdAt": { - "type": "string", - "format": "date-time" - }, - "updatedAt": { - "type": "string", - "format": "date-time" - } - } - }, - "CheckTimings": { - "type": "object", - "description": "Detailed timing information for HTTP checks", - "properties": { - "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": "object", - "properties": { - "wait": { - "type": "number" - }, - "dns": { - "type": "number" - }, - "tcp": { - "type": "number" - }, - "tls": { - "type": "number" - }, - "request": { - "type": "number" - }, - "firstByte": { - "type": "number" - }, - "download": { - "type": "number" - }, - "total": { - "type": "number" + "500": { + "description": "Internal server error", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "success": { + "type": "boolean", + "enum": [false] + }, + "msg": { + "type": "string" + }, + "data": { + "nullable": true + } + }, + "required": ["success", "msg"] + } } } } } - }, - "CheckCpuInfo": { - "type": "object", - "description": "CPU information for infrastructure checks", - "properties": { - "physical_core": { - "type": "integer" - }, - "logical_core": { - "type": "integer" - }, - "frequency": { - "type": "number" - }, - "temperature": { - "type": "array", - "items": { - "type": "number" - } - }, - "free_percent": { - "type": "number" - }, - "usage_percent": { - "type": "number" - } - } - }, - "CheckMemoryInfo": { - "type": "object", - "description": "Memory information for infrastructure checks", - "properties": { - "total_bytes": { - "type": "number" - }, - "available_bytes": { - "type": "number" - }, - "used_bytes": { - "type": "number" - }, - "usage_percent": { - "type": "number" - } - } - }, - "CheckDiskInfo": { - "type": "object", - "description": "Disk information for infrastructure checks", - "properties": { - "device": { - "type": "string" - }, - "mountpoint": { - "type": "string" - }, - "read_speed_bytes": { - "type": "number" - }, - "write_speed_bytes": { - "type": "number" - }, - "total_bytes": { - "type": "number" - }, - "free_bytes": { - "type": "number" - }, - "usage_percent": { - "type": "number" - } - } - }, - "CheckHostInfo": { - "type": "object", - "description": "Host information for infrastructure checks", - "properties": { - "os": { - "type": "string" - }, - "platform": { - "type": "string" - }, - "kernel_version": { - "type": "string" - } - } - }, - "CheckErrorInfo": { - "type": "object", - "description": "Error information from checks", - "properties": { - "metric": { - "type": "array", - "items": { - "type": "string" - } - }, - "err": { - "type": "string" - } - } - }, - "CheckCaptureInfo": { - "type": "object", - "description": "Capture agent information", - "properties": { - "version": { - "type": "string" - }, - "mode": { - "type": "string" - } - } - }, - "CheckNetworkInfo": { - "type": "object", - "description": "Network interface information", - "properties": { - "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" - } - } - }, - "CheckAudits": { - "type": "object", - "description": "PageSpeed audit results", - "properties": { - "cls": { - "$ref": "#/components/schemas/LighthouseAudit" - }, - "si": { - "$ref": "#/components/schemas/LighthouseAudit" - }, - "fcp": { - "$ref": "#/components/schemas/LighthouseAudit" - }, - "lcp": { - "$ref": "#/components/schemas/LighthouseAudit" - }, - "tbt": { - "$ref": "#/components/schemas/LighthouseAudit" - } - } - }, - "LighthouseAudit": { - "type": "object", - "description": "Individual Lighthouse audit result", - "properties": { - "id": { - "type": "string" - }, - "title": { - "type": "string" - }, - "score": { - "type": "number" - }, - "displayValue": { - "type": "string" - }, - "numericValue": { - "type": "number" - }, - "numericUnit": { - "type": "string" - } - } } } } diff --git a/server/openapi/helpers.ts b/server/openapi/helpers.ts new file mode 100644 index 000000000..fcbd87840 --- /dev/null +++ b/server/openapi/helpers.ts @@ -0,0 +1,54 @@ +import "./registry.js"; +import { z } from "zod"; + +export const successEnvelope = (data: T) => + z.object({ + success: z.literal(true), + msg: z.string(), + data, + }); + +export const successEnvelopeNoData = z.object({ + success: z.literal(true), + msg: z.string(), +}); + +export const errorEnvelope = z.object({ + success: z.literal(false), + msg: z.string(), + data: z.unknown().optional(), +}); + +export const bearer = [{ bearerAuth: [] }]; + +export const json = (schema: T, example?: unknown) => ({ + "application/json": example === undefined ? { schema } : { schema, example }, +}); + +export const standardErrors = { + "401": { description: "Unauthorized", content: json(errorEnvelope) }, + "403": { description: "Forbidden", content: json(errorEnvelope) }, + "500": { description: "Internal server error", content: json(errorEnvelope) }, +}; + +export const okJson = (data: T, description = "OK", example?: unknown) => ({ + description, + content: json(successEnvelope(data), example === undefined ? undefined : { success: true, msg: "OK", data: example }), +}); + +export const okJsonNoData = (description = "OK") => ({ + description, + content: json(successEnvelopeNoData, { success: true, msg: "OK" }), +}); + +export const okUnknown = okJson(z.unknown()); + +export const multipart = (fields: Record, fileField?: string) => { + const shape: Record = { ...fields }; + if (fileField) { + shape[fileField] = z.string().openapi({ type: "string", format: "binary" }); + } + return { + "multipart/form-data": { schema: z.object(shape) }, + }; +}; diff --git a/server/openapi/index.ts b/server/openapi/index.ts new file mode 100644 index 000000000..9758c9d54 --- /dev/null +++ b/server/openapi/index.ts @@ -0,0 +1,56 @@ +import "./registry.js"; +import "./routes/auth.js"; +import "./routes/check.js"; +import "./routes/diagnostic.js"; +import "./routes/geoCheck.js"; +import "./routes/incident.js"; +import "./routes/invite.js"; +import "./routes/log.js"; +import "./routes/maintenanceWindow.js"; +import "./routes/monitor.js"; +import "./routes/notification.js"; +import "./routes/queue.js"; +import "./routes/settings.js"; +import "./routes/statusPage.js"; + +import { OpenApiGeneratorV3 } from "@asteasolutions/zod-to-openapi"; +import type { JsonObject } from "swagger-ui-express"; +import { registry } from "./registry.js"; + +let cached: JsonObject | null = null; + +export function getOpenApiSpec(): JsonObject { + if (cached) return cached; + + const generator = new OpenApiGeneratorV3(registry.definitions); + const document = generator.generateDocument({ + openapi: "3.0.0", + info: { + title: "Checkmate API", + version: "1.0.0", + description: "Generated from Zod validation schemas. Source of truth: server/src/validation/*.ts", + }, + servers: [{ url: "/api/v1", description: "Current server" }], + tags: [ + { name: "auth", description: "Sign up, log in, password reset, and the authenticated user's profile." }, + { name: "monitors", description: "Configure and inspect monitors (HTTP, port, ping, hardware, docker, game, gRPC) for the caller's team." }, + { name: "checks", description: "Individual monitoring check results, including history, summaries, and bulk deletes." }, + { name: "geo-checks", description: "Geographic check results for monitors with multi-region probing enabled." }, + { name: "incidents", description: "Downtime incidents derived from monitor state changes, including filtering and CSV export." }, + { + name: "notifications", + description: "Notification channels (email, webhook, Slack, Discord, PagerDuty, Matrix, Teams, Telegram, Pushover, Twilio) and test alerts.", + }, + { name: "maintenance-window", description: "Scheduled maintenance windows during which monitors do not generate incidents or alerts." }, + { name: "status-page", description: "Public status pages, their configuration, and the monitors they expose." }, + { name: "settings", description: "Global application settings (admin/superadmin)." }, + { name: "invite", description: "Team invitations and accepting them." }, + { name: "queue", description: "Background job queue introspection and admin actions (admin/superadmin)." }, + { name: "diagnostic", description: "System diagnostics for the running server (admin/superadmin)." }, + { name: "logs", description: "Application logs for the running server (admin/superadmin)." }, + ], + }); + + cached = document as unknown as JsonObject; + return cached; +} diff --git a/server/openapi/registry.ts b/server/openapi/registry.ts new file mode 100644 index 000000000..a733e61b2 --- /dev/null +++ b/server/openapi/registry.ts @@ -0,0 +1,12 @@ +import { OpenAPIRegistry, extendZodWithOpenApi } from "@asteasolutions/zod-to-openapi"; +import { z } from "zod"; + +extendZodWithOpenApi(z); + +export const registry = new OpenAPIRegistry(); + +registry.registerComponent("securitySchemes", "bearerAuth", { + type: "http", + scheme: "bearer", + bearerFormat: "JWT", +}); diff --git a/server/openapi/routes/auth.ts b/server/openapi/routes/auth.ts new file mode 100644 index 000000000..91254edf3 --- /dev/null +++ b/server/openapi/routes/auth.ts @@ -0,0 +1,203 @@ +import { z } from "zod"; +import { registry } from "../registry.js"; +import { bearer, json, multipart, okJson, okJsonNoData, standardErrors } from "../helpers.js"; +import { + loginValidation, + registrationBodyValidation, + recoveryValidation, + recoveryTokenBodyValidation, + newPasswordValidation, +} from "@/validation/authValidation.js"; +import { + getUserByIdParamValidation, + editUserByIdParamValidation, + editUserByIdBodyValidation, + editUserPasswordByIdBodyValidation, + createUserBodyValidation, + editUserBodyValidation, +} from "@/validation/userValidation.js"; + +const tags = ["auth"]; + +const userObject = z + .object({ + _id: z.string().openapi({ example: "65f1c2a4d8b9e0123456789a" }), + firstName: z.string().openapi({ example: "Ada" }), + lastName: z.string().openapi({ example: "Lovelace" }), + email: z.string().openapi({ example: "ada@example.com" }), + role: z.array(z.string()).openapi({ example: ["admin"] }), + teamId: z.string().optional().openapi({ example: "65f1c2a4d8b9e01234567890" }), + profileImage: z.string().nullable().optional(), + createdAt: z.string().openapi({ example: "2026-04-01T10:00:00.000Z" }), + updatedAt: z.string().openapi({ example: "2026-04-15T14:30:00.000Z" }), + }) + .passthrough() + .openapi("User"); + +const authPayload = z + .object({ + user: userObject, + token: z.string().optional().openapi({ example: "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9..." }), + }) + .openapi("AuthPayload"); + +registry.registerPath({ + method: "post", + path: "/auth/register", + tags, + summary: "Register a new user (first user becomes superadmin)", + request: { body: { content: multipart(registrationBodyValidation.shape, "profileImage") } }, + responses: { "200": okJson(authPayload), ...standardErrors }, +}); + +registry.registerPath({ + method: "post", + path: "/auth/login", + tags, + summary: "Log in", + request: { + body: { + content: json(loginValidation, { email: "ada@example.com", password: "S3cure!Passw0rd" }), + }, + }, + responses: { + "200": okJson(authPayload, "OK", { + user: { + _id: "65f1c2a4d8b9e0123456789a", + firstName: "Ada", + lastName: "Lovelace", + email: "ada@example.com", + role: ["admin"], + teamId: "65f1c2a4d8b9e01234567890", + createdAt: "2026-04-01T10:00:00.000Z", + updatedAt: "2026-04-15T14:30:00.000Z", + }, + token: "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9...", + }), + "401": { description: "Invalid credentials", content: json(z.object({ success: z.literal(false), msg: z.string() })) }, + "500": standardErrors["500"], + }, +}); + +registry.registerPath({ + method: "post", + path: "/auth/recovery/request", + tags, + summary: "Request a password recovery email", + request: { body: { content: json(recoveryValidation, { email: "ada@example.com" }) } }, + responses: { "200": okJsonNoData(), "500": standardErrors["500"] }, +}); + +registry.registerPath({ + method: "post", + path: "/auth/recovery/validate", + tags, + summary: "Validate a password recovery token", + request: { body: { content: json(recoveryTokenBodyValidation) } }, + responses: { "200": okJsonNoData(), "500": standardErrors["500"] }, +}); + +registry.registerPath({ + method: "post", + path: "/auth/recovery/reset", + tags, + summary: "Reset password using a recovery token", + request: { body: { content: json(newPasswordValidation) } }, + responses: { "200": okJsonNoData(), "500": standardErrors["500"] }, +}); + +registry.registerPath({ + method: "get", + path: "/auth/users/superadmin", + tags, + summary: "Check whether a superadmin user exists", + responses: { "200": okJson(z.object({ superAdminExists: z.boolean() })), "500": standardErrors["500"] }, +}); + +registry.registerPath({ + method: "get", + path: "/auth/users", + tags, + summary: "List all users (admin/superadmin)", + security: bearer, + responses: { "200": okJson(z.array(userObject)), ...standardErrors }, +}); + +registry.registerPath({ + method: "post", + path: "/auth/users", + tags, + summary: "Create a new user (superadmin)", + security: bearer, + request: { body: { content: multipart(createUserBodyValidation.shape, "profileImage") } }, + responses: { "201": okJson(userObject, "User created"), ...standardErrors }, +}); + +registry.registerPath({ + method: "get", + path: "/auth/users/{userId}", + tags, + summary: "Get a user by id", + security: bearer, + request: { params: getUserByIdParamValidation }, + responses: { + "200": okJson(userObject), + "404": { description: "User not found", content: json(z.object({ success: z.literal(false), msg: z.string() })) }, + ...standardErrors, + }, +}); + +registry.registerPath({ + method: "patch", + path: "/auth/users/{userId}", + tags, + summary: "Edit a user (superadmin)", + security: bearer, + request: { + params: editUserByIdParamValidation, + body: { content: json(editUserByIdBodyValidation) }, + }, + responses: { "200": okJsonNoData(), ...standardErrors }, +}); + +registry.registerPath({ + method: "patch", + path: "/auth/users/{userId}/password", + tags, + summary: "Change a user's password (superadmin)", + security: bearer, + request: { + params: editUserByIdParamValidation, + body: { content: json(editUserPasswordByIdBodyValidation) }, + }, + responses: { "200": okJsonNoData(), ...standardErrors }, +}); + +registry.registerPath({ + method: "delete", + path: "/auth/users/{userId}", + tags, + summary: "Delete a user (admin/superadmin)", + security: bearer, + request: { params: getUserByIdParamValidation }, + responses: { "200": okJsonNoData(), ...standardErrors }, +}); + +registry.registerPath({ + method: "patch", + path: "/auth/user", + tags, + summary: "Edit the currently authenticated user", + security: bearer, + request: { body: { content: multipart(editUserBodyValidation.shape, "profileImage") } }, + responses: { "200": okJson(userObject), ...standardErrors }, +}); + +registry.registerPath({ + method: "delete", + path: "/auth/user", + tags, + summary: "Delete the currently authenticated user", + security: bearer, + responses: { "200": okJsonNoData(), ...standardErrors }, +}); diff --git a/server/openapi/routes/check.ts b/server/openapi/routes/check.ts new file mode 100644 index 000000000..a9b6b3804 --- /dev/null +++ b/server/openapi/routes/check.ts @@ -0,0 +1,61 @@ +import { z } from "zod"; +import { registry } from "../registry.js"; +import { bearer, okJson, okJsonNoData, okUnknown, standardErrors } from "../helpers.js"; +import { + getChecksParamValidation, + getChecksQueryValidation, + getTeamChecksQueryValidation, + deleteChecksParamValidation, + getChecksSummaryByTeamIdQueryValidation, +} from "@/validation/checkValidation.js"; + +const tags = ["checks"]; + +registry.registerPath({ + method: "get", + path: "/checks/team/summary", + tags, + summary: "Aggregate check summary for the caller's team", + security: bearer, + request: { query: getChecksSummaryByTeamIdQueryValidation }, + responses: { "200": okUnknown, ...standardErrors }, +}); + +registry.registerPath({ + method: "get", + path: "/checks/team", + tags, + summary: "List checks across the team", + security: bearer, + request: { query: getTeamChecksQueryValidation }, + responses: { "200": okUnknown, ...standardErrors }, +}); + +registry.registerPath({ + method: "delete", + path: "/checks/team", + tags, + summary: "Delete all checks for the team (admin/superadmin)", + security: bearer, + responses: { "200": okJson(z.object({ deletedCount: z.number() })), ...standardErrors }, +}); + +registry.registerPath({ + method: "get", + path: "/checks/{monitorId}", + tags, + summary: "List checks for a monitor", + security: bearer, + request: { params: getChecksParamValidation, query: getChecksQueryValidation }, + responses: { "200": okUnknown, ...standardErrors }, +}); + +registry.registerPath({ + method: "delete", + path: "/checks/{monitorId}", + tags, + summary: "Delete checks for a monitor", + security: bearer, + request: { params: deleteChecksParamValidation }, + responses: { "200": okJsonNoData(), ...standardErrors }, +}); diff --git a/server/openapi/routes/diagnostic.ts b/server/openapi/routes/diagnostic.ts new file mode 100644 index 000000000..49e384876 --- /dev/null +++ b/server/openapi/routes/diagnostic.ts @@ -0,0 +1,11 @@ +import { registry } from "../registry.js"; +import { bearer, okUnknown, standardErrors } from "../helpers.js"; + +registry.registerPath({ + method: "get", + path: "/diagnostic/system", + tags: ["diagnostic"], + summary: "Get system diagnostics (admin/superadmin)", + security: bearer, + responses: { "200": okUnknown, ...standardErrors }, +}); diff --git a/server/openapi/routes/geoCheck.ts b/server/openapi/routes/geoCheck.ts new file mode 100644 index 000000000..7b90d0a0d --- /dev/null +++ b/server/openapi/routes/geoCheck.ts @@ -0,0 +1,13 @@ +import { registry } from "../registry.js"; +import { bearer, okUnknown, standardErrors } from "../helpers.js"; +import { getChecksParamValidation, getChecksQueryValidation } from "@/validation/checkValidation.js"; + +registry.registerPath({ + method: "get", + path: "/geo-checks/{monitorId}", + tags: ["geo-checks"], + summary: "Get geo check results for a monitor", + security: bearer, + request: { params: getChecksParamValidation, query: getChecksQueryValidation }, + responses: { "200": okUnknown, ...standardErrors }, +}); diff --git a/server/openapi/routes/incident.ts b/server/openapi/routes/incident.ts new file mode 100644 index 000000000..6497a5bd0 --- /dev/null +++ b/server/openapi/routes/incident.ts @@ -0,0 +1,104 @@ +import { z } from "zod"; +import { registry } from "../registry.js"; +import { bearer, okJson, okJsonNoData, standardErrors } from "../helpers.js"; +import { getIncidentsByTeamQueryValidation, getIncidentSummaryQueryValidation } from "@/validation/incidentValidation.js"; + +const tags = ["incidents"]; + +const incidentIdParam = z.object({ incidentId: z.string() }); + +const incidentObject = z + .object({ + id: z.string().openapi({ example: "65f1c2a4d8b9e0123456789c" }), + monitorId: z.string().openapi({ example: "65f1c2a4d8b9e0123456789a" }), + teamId: z.string().openapi({ example: "65f1c2a4d8b9e01234567890" }), + startTime: z.string().openapi({ example: "2026-04-15T03:21:00.000Z" }), + endTime: z.string().nullable().openapi({ example: "2026-04-15T03:34:00.000Z" }), + status: z.boolean().openapi({ example: true, description: "true = resolved, false = ongoing" }), + message: z.string().nullable().optional().openapi({ example: "HTTP 503 from origin" }), + statusCode: z.number().nullable().optional().openapi({ example: 503 }), + resolutionType: z.enum(["automatic", "manual"]).nullable().openapi({ example: "automatic" }), + resolvedBy: z.string().nullable().optional(), + resolvedByEmail: z.string().nullable().optional(), + comment: z.string().nullable().optional(), + createdAt: z.string().openapi({ example: "2026-04-15T03:21:00.000Z" }), + updatedAt: z.string().openapi({ example: "2026-04-15T03:34:00.000Z" }), + }) + .passthrough() + .openapi("Incident"); + +const incidentSummaryItem = z + .object({ + id: z.string().openapi({ example: "65f1c2a4d8b9e0123456789c" }), + monitorId: z.string().openapi({ example: "65f1c2a4d8b9e0123456789a" }), + monitorName: z.string().nullable().openapi({ example: "Marketing site" }), + status: z.boolean().openapi({ example: true }), + startTime: z.string().openapi({ example: "2026-04-15T03:21:00.000Z" }), + endTime: z.string().nullable().openapi({ example: "2026-04-15T03:34:00.000Z" }), + resolutionType: z.enum(["automatic", "manual"]).nullable().openapi({ example: "automatic" }), + message: z.string().nullable().openapi({ example: "HTTP 503 from origin" }), + statusCode: z.number().nullable().openapi({ example: 503 }), + createdAt: z.string().openapi({ example: "2026-04-15T03:21:00.000Z" }), + }) + .openapi("IncidentSummaryItem"); + +const incidentSummary = z + .object({ + total: z.number().openapi({ example: 12 }), + totalActive: z.number().openapi({ example: 1 }), + totalManualResolutions: z.number().openapi({ example: 2 }), + totalAutomaticResolutions: z.number().openapi({ example: 9 }), + avgResolutionTimeHours: z.number().openapi({ example: 0.5 }), + topMonitor: z + .object({ + monitorId: z.string().openapi({ example: "65f1c2a4d8b9e0123456789a" }), + monitorName: z.string().nullable().openapi({ example: "Marketing site" }), + incidentCount: z.number().openapi({ example: 4 }), + }) + .nullable(), + latestIncidents: z.array(incidentSummaryItem), + }) + .openapi("IncidentSummary"); + +registry.registerPath({ + method: "get", + path: "/incidents/team", + tags, + summary: "List incidents for the caller's team", + security: bearer, + request: { query: getIncidentsByTeamQueryValidation }, + responses: { "200": okJson(z.array(incidentObject)), ...standardErrors }, +}); + +registry.registerPath({ + method: "get", + path: "/incidents/team/summary", + tags, + summary: "Incident summary for the caller's team", + security: bearer, + request: { query: getIncidentSummaryQueryValidation }, + responses: { "200": okJson(incidentSummary), ...standardErrors }, +}); + +registry.registerPath({ + method: "get", + path: "/incidents/{incidentId}", + tags, + summary: "Get an incident by id", + security: bearer, + request: { params: incidentIdParam }, + responses: { + "200": okJson(z.object({ incident: incidentObject, monitor: z.unknown(), user: z.unknown().nullable() })), + ...standardErrors, + }, +}); + +registry.registerPath({ + method: "put", + path: "/incidents/{incidentId}/resolve", + tags, + summary: "Manually resolve an incident (admin/superadmin)", + security: bearer, + request: { params: incidentIdParam }, + responses: { "200": okJsonNoData(), ...standardErrors }, +}); diff --git a/server/openapi/routes/invite.ts b/server/openapi/routes/invite.ts new file mode 100644 index 000000000..bf9b568b3 --- /dev/null +++ b/server/openapi/routes/invite.ts @@ -0,0 +1,35 @@ +import { registry } from "../registry.js"; +import { bearer, json, okJson, okJsonNoData, standardErrors } from "../helpers.js"; +import { z } from "zod"; +import { inviteBodyValidation, inviteVerificationBodyValidation } from "@/validation/authValidation.js"; + +const tags = ["invite"]; + +registry.registerPath({ + method: "post", + path: "/invite/send", + tags, + summary: "Send an invite email (admin/superadmin)", + security: bearer, + request: { body: { content: json(inviteBodyValidation) } }, + responses: { "200": okJsonNoData(), ...standardErrors }, +}); + +registry.registerPath({ + method: "post", + path: "/invite/verify", + tags, + summary: "Verify an invite token", + request: { body: { content: json(inviteVerificationBodyValidation) } }, + responses: { "200": okJson(z.object({ email: z.string(), role: z.array(z.string()).optional() }).passthrough()), "500": standardErrors["500"] }, +}); + +registry.registerPath({ + method: "post", + path: "/invite", + tags, + summary: "Create an invite token (admin/superadmin)", + security: bearer, + request: { body: { content: json(inviteBodyValidation) } }, + responses: { "200": okJson(z.object({ token: z.string() }).passthrough()), ...standardErrors }, +}); diff --git a/server/openapi/routes/log.ts b/server/openapi/routes/log.ts new file mode 100644 index 000000000..8fc043ff7 --- /dev/null +++ b/server/openapi/routes/log.ts @@ -0,0 +1,11 @@ +import { registry } from "../registry.js"; +import { bearer, okUnknown, standardErrors } from "../helpers.js"; + +registry.registerPath({ + method: "get", + path: "/logs", + tags: ["logs"], + summary: "Get application logs (admin/superadmin)", + security: bearer, + responses: { "200": okUnknown, ...standardErrors }, +}); diff --git a/server/openapi/routes/maintenanceWindow.ts b/server/openapi/routes/maintenanceWindow.ts new file mode 100644 index 000000000..6afc57031 --- /dev/null +++ b/server/openapi/routes/maintenanceWindow.ts @@ -0,0 +1,73 @@ +import { registry } from "../registry.js"; +import { bearer, json, okJsonNoData, okUnknown, standardErrors } from "../helpers.js"; +import { + createMaintenanceWindowBodyValidation, + getMaintenanceWindowByIdParamValidation, + getMaintenanceWindowsByTeamIdQueryValidation, + getMaintenanceWindowsByMonitorIdParamValidation, + deleteMaintenanceWindowByIdParamValidation, + editMaintenanceWindowByIdParamValidation, + editMaintenanceByIdWindowBodyValidation, +} from "@/validation/maintenanceWindowValidation.js"; + +const tags = ["maintenance-window"]; + +registry.registerPath({ + method: "post", + path: "/maintenance-window", + tags, + summary: "Create one or more maintenance windows", + security: bearer, + request: { body: { content: json(createMaintenanceWindowBodyValidation) } }, + responses: { "200": okUnknown, ...standardErrors }, +}); + +registry.registerPath({ + method: "get", + path: "/maintenance-window/team", + tags, + summary: "List maintenance windows for the caller's team", + security: bearer, + request: { query: getMaintenanceWindowsByTeamIdQueryValidation }, + responses: { "200": okUnknown, ...standardErrors }, +}); + +registry.registerPath({ + method: "get", + path: "/maintenance-window/monitor/{monitorId}", + tags, + summary: "List maintenance windows for a monitor", + security: bearer, + request: { params: getMaintenanceWindowsByMonitorIdParamValidation }, + responses: { "200": okUnknown, ...standardErrors }, +}); + +registry.registerPath({ + method: "get", + path: "/maintenance-window/{id}", + tags, + summary: "Get a maintenance window by id", + security: bearer, + request: { params: getMaintenanceWindowByIdParamValidation }, + responses: { "200": okUnknown, ...standardErrors }, +}); + +registry.registerPath({ + method: "patch", + path: "/maintenance-window/{id}", + tags, + summary: "Edit a maintenance window", + security: bearer, + request: { params: editMaintenanceWindowByIdParamValidation, body: { content: json(editMaintenanceByIdWindowBodyValidation) } }, + responses: { "200": okUnknown, ...standardErrors }, +}); + +registry.registerPath({ + method: "delete", + path: "/maintenance-window/{id}", + tags, + summary: "Delete a maintenance window", + security: bearer, + request: { params: deleteMaintenanceWindowByIdParamValidation }, + responses: { "200": okJsonNoData(), ...standardErrors }, +}); diff --git a/server/openapi/routes/monitor.ts b/server/openapi/routes/monitor.ts new file mode 100644 index 000000000..0e6187318 --- /dev/null +++ b/server/openapi/routes/monitor.ts @@ -0,0 +1,247 @@ +import { z } from "zod"; +import { registry } from "../registry.js"; +import { bearer, json, okJson, okJsonNoData, okUnknown, standardErrors } from "../helpers.js"; +import { GeoContinents } from "@/types/geoCheck.js"; +import { MonitorMatchMethods, MonitorTypes } from "@/types/monitor.js"; +import { + getMonitorByIdParamValidation, + getMonitorByIdQueryValidation, + getMonitorsByTeamIdQueryValidation, + getMonitorsWithChecksQueryValidation, + getCertificateParamValidation, + createMonitorBodyValidation, + editMonitorBodyValidation, + pauseMonitorParamValidation, + getUptimeDetailsByIdParamValidation, + getUptimeDetailsByIdQueryValidation, + importMonitorsBodyValidation, + getHardwareDetailsByIdParamValidation, + getHardwareDetailsByIdQueryValidation, +} from "@/validation/monitorValidation.js"; +import { updateNotificationsValidation } from "@/validation/notificationValidation.js"; + +const tags = ["monitors"]; + +const monitorObject = z + .object({ + _id: z.string().openapi({ example: "65f1c2a4d8b9e0123456789a" }), + name: z.string().openapi({ example: "Marketing site" }), + description: z.string().optional().openapi({ example: "Production marketing site monitored from the EU region" }), + type: z.enum(MonitorTypes).openapi({ example: "http" }), + url: z.string().openapi({ example: "https://www.example.com" }), + port: z.number().optional().openapi({ example: 443 }), + isActive: z.boolean().openapi({ example: true }), + interval: z.number().openapi({ example: 60000 }), + status: z.enum(["up", "down", "paused", "initializing", "maintenance", "breached"]).openapi({ example: "up" }), + statusWindowSize: z.number().openapi({ example: 5 }), + statusWindowThreshold: z.number().openapi({ example: 3 }), + ignoreTlsErrors: z.boolean().openapi({ example: false }), + useAdvancedMatching: z.boolean().openapi({ example: false }), + jsonPath: z.string().optional(), + expectedValue: z.string().optional(), + matchMethod: z.enum(MonitorMatchMethods).optional(), + notifications: z.array(z.string()).openapi({ example: ["65f1c2a4d8b9e0123456789b"] }), + secret: z.string().optional(), + cpuAlertThreshold: z.number().openapi({ example: 90 }), + memoryAlertThreshold: z.number().openapi({ example: 90 }), + diskAlertThreshold: z.number().openapi({ example: 90 }), + tempAlertThreshold: z.number().openapi({ example: 80 }), + selectedDisks: z.array(z.string()).openapi({ example: [] }), + gameId: z.string().optional(), + grpcServiceName: z.string().optional(), + group: z.string().nullable().optional(), + geoCheckEnabled: z.boolean().openapi({ example: false }), + geoCheckLocations: z.array(z.enum(GeoContinents)).openapi({ example: [] }), + geoCheckInterval: z.number().openapi({ example: 300000 }), + teamId: z.string().openapi({ example: "65f1c2a4d8b9e01234567890" }), + userId: z.string().openapi({ example: "65f1c2a4d8b9e01234567891" }), + createdAt: z.string().openapi({ example: "2026-04-01T10:00:00.000Z" }), + updatedAt: z.string().openapi({ example: "2026-04-15T14:30:00.000Z" }), + }) + .passthrough() + .openapi("Monitor"); + +registry.registerPath({ + method: "get", + path: "/monitors/team", + tags, + summary: "List monitors for the caller's team", + security: bearer, + request: { query: getMonitorsByTeamIdQueryValidation }, + responses: { "200": okJson(z.array(monitorObject)), ...standardErrors }, +}); + +registry.registerPath({ + method: "get", + path: "/monitors/team/with-checks", + tags, + summary: "List team monitors with their most recent checks (paginated)", + security: bearer, + request: { query: getMonitorsWithChecksQueryValidation }, + responses: { "200": okUnknown, ...standardErrors }, +}); + +registry.registerPath({ + method: "get", + path: "/monitors/team/groups", + tags, + summary: "List monitor groups for the caller's team", + security: bearer, + responses: { "200": okJson(z.array(z.string())), ...standardErrors }, +}); + +registry.registerPath({ + method: "get", + path: "/monitors/uptime/details/{monitorId}", + tags, + summary: "Get uptime details for a monitor", + security: bearer, + request: { params: getUptimeDetailsByIdParamValidation, query: getUptimeDetailsByIdQueryValidation }, + responses: { "200": okUnknown, ...standardErrors }, +}); + +registry.registerPath({ + method: "get", + path: "/monitors/hardware/details/{monitorId}", + tags, + summary: "Get hardware metrics detail for a monitor", + security: bearer, + request: { params: getHardwareDetailsByIdParamValidation, query: getHardwareDetailsByIdQueryValidation }, + responses: { "200": okUnknown, ...standardErrors }, +}); + +registry.registerPath({ + method: "get", + path: "/monitors/pagespeed/details/{monitorId}", + tags, + summary: "Get PageSpeed detail for a monitor", + security: bearer, + request: { params: getHardwareDetailsByIdParamValidation, query: getHardwareDetailsByIdQueryValidation }, + responses: { "200": okUnknown, ...standardErrors }, +}); + +registry.registerPath({ + method: "get", + path: "/monitors/{monitorId}/geo-checks", + tags, + summary: "Get geo check results for a monitor", + security: bearer, + request: { params: getMonitorByIdParamValidation, query: getMonitorByIdQueryValidation }, + responses: { "200": okUnknown, ...standardErrors }, +}); + +registry.registerPath({ + method: "post", + path: "/monitors/pause/{monitorId}", + tags, + summary: "Toggle pause state for a monitor", + security: bearer, + request: { params: pauseMonitorParamValidation }, + responses: { "200": okJson(monitorObject), ...standardErrors }, +}); + +registry.registerPath({ + method: "get", + path: "/monitors/certificate/{monitorId}", + tags, + summary: "Get SSL certificate info for a monitor", + security: bearer, + request: { params: getCertificateParamValidation }, + responses: { "200": okJson(z.object({ certificateDate: z.string() })), ...standardErrors }, +}); + +registry.registerPath({ + method: "patch", + path: "/monitors/notifications", + tags, + summary: "Bulk update notifications across monitors", + security: bearer, + request: { body: { content: json(updateNotificationsValidation) } }, + responses: { "200": okJson(z.object({ modifiedCount: z.number() })), ...standardErrors }, +}); + +registry.registerPath({ + method: "post", + path: "/monitors", + tags, + summary: "Create a new monitor", + security: bearer, + request: { body: { content: json(createMonitorBodyValidation) } }, + responses: { "200": okJson(monitorObject, "Monitor created"), ...standardErrors }, +}); + +registry.registerPath({ + method: "delete", + path: "/monitors", + tags, + summary: "Delete every monitor (superadmin only)", + security: bearer, + responses: { "200": okJsonNoData(), ...standardErrors }, +}); + +registry.registerPath({ + method: "post", + path: "/monitors/demo", + tags, + summary: "Insert preconfigured demo monitors", + security: bearer, + responses: { "200": okJson(z.number()), ...standardErrors }, +}); + +registry.registerPath({ + method: "get", + path: "/monitors/export/json", + tags, + summary: "Export all team monitors as JSON", + security: bearer, + responses: { "200": okUnknown, ...standardErrors }, +}); + +registry.registerPath({ + method: "post", + path: "/monitors/import/json", + tags, + summary: "Import monitors from JSON", + security: bearer, + request: { body: { content: json(importMonitorsBodyValidation) } }, + responses: { "200": okJson(z.object({ imported: z.number() }).passthrough()), ...standardErrors }, +}); + +registry.registerPath({ + method: "get", + path: "/monitors/games", + tags, + summary: "List supported game-server types", + security: bearer, + responses: { "200": okJson(z.array(z.string())), ...standardErrors }, +}); + +registry.registerPath({ + method: "get", + path: "/monitors/{monitorId}", + tags, + summary: "Get a monitor by id", + security: bearer, + request: { params: getMonitorByIdParamValidation }, + responses: { "200": okJson(monitorObject), ...standardErrors }, +}); + +registry.registerPath({ + method: "patch", + path: "/monitors/{monitorId}", + tags, + summary: "Edit a monitor", + security: bearer, + request: { params: getMonitorByIdParamValidation, body: { content: json(editMonitorBodyValidation) } }, + responses: { "200": okJson(monitorObject), ...standardErrors }, +}); + +registry.registerPath({ + method: "delete", + path: "/monitors/{monitorId}", + tags, + summary: "Delete a monitor", + security: bearer, + request: { params: getMonitorByIdParamValidation }, + responses: { "200": okJson(monitorObject), ...standardErrors }, +}); diff --git a/server/openapi/routes/notification.ts b/server/openapi/routes/notification.ts new file mode 100644 index 000000000..b34c542e7 --- /dev/null +++ b/server/openapi/routes/notification.ts @@ -0,0 +1,155 @@ +import { z } from "zod"; +import { registry } from "../registry.js"; +import { bearer, json, okJson, okJsonNoData, okUnknown, standardErrors } from "../helpers.js"; +import { + createNotificationBodyValidation, + deleteNotificationParamValidation, + getNotificationByIdParamValidation, + editNotificationParamValidation, + testAllNotificationsBodyValidation, +} from "@/validation/notificationValidation.js"; + +const tags = ["notifications"]; + +// OpenAPI metadata for each notification variant. Keyed by the discriminator +// value in createNotificationBodyValidation; build fails loudly if a variant +// in the validator has no entry here, which doubles as drift detection. +const notificationVariantMeta: Record }> = { + email: { + component: "EmailNotification", + example: { notificationName: "Ops on-call email", type: "email", address: "alerts@example.com" }, + }, + webhook: { + component: "WebhookNotification", + example: { notificationName: "Custom webhook", type: "webhook", address: "https://example.com/hooks/checkmate" }, + }, + slack: { + component: "SlackNotification", + example: { notificationName: "#alerts", type: "slack", address: "https://hooks.slack.com/services/T000/B000/XXXX" }, + }, + discord: { + component: "DiscordNotification", + example: { notificationName: "#status", type: "discord", address: "https://discord.com/api/webhooks/123/abc" }, + }, + pager_duty: { + component: "PagerDutyNotification", + example: { notificationName: "PagerDuty primary", type: "pager_duty", address: "R01XXXXXXXXXXXXXXXXXXXXXXX" }, + }, + matrix: { + component: "MatrixNotification", + example: { + notificationName: "Matrix room", + type: "matrix", + homeserverUrl: "https://matrix.example.com", + roomId: "!abc123:example.com", + accessToken: "syt_xxx", + }, + }, + teams: { + component: "TeamsNotification", + example: { notificationName: "Teams ops channel", type: "teams", address: "https://outlook.office.com/webhook/..." }, + }, + telegram: { + component: "TelegramNotification", + example: { notificationName: "Telegram bot", type: "telegram", address: "-1001234567890", accessToken: "123456:ABC-DEF" }, + }, + pushover: { + component: "PushoverNotification", + example: { notificationName: "Pushover personal", type: "pushover", address: "u1234567890abcdef", accessToken: "a1234567890abcdef" }, + }, + twilio: { + component: "TwilioNotification", + example: { + notificationName: "Twilio SMS", + type: "twilio", + accountSid: "ACxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx", + accessToken: "your-auth-token", + phone: "+15551234567", + twilioPhoneNumber: "+15557654321", + }, + }, +}; + +const decoratedVariants = createNotificationBodyValidation.options.map((variant) => { + const typeField = variant.shape.type as z.ZodLiteral; + const type = typeField.value; + const meta = notificationVariantMeta[type]; + if (!meta) { + throw new Error(`Missing OpenAPI metadata for notification variant "${type}". Add an entry in notificationVariantMeta.`); + } + return variant.openapi(meta.component, { example: meta.example }); +}); + +const notificationBody = z + .discriminatedUnion("type", decoratedVariants as typeof createNotificationBodyValidation.options) + .openapi("NotificationChannelBody"); + +registry.registerPath({ + method: "post", + path: "/notifications", + tags, + summary: "Create a notification channel", + description: "Create a notification channel of any supported type. The `type` field discriminates the body shape.", + security: bearer, + request: { body: { content: json(notificationBody) } }, + responses: { "200": okUnknown, ...standardErrors }, +}); + +registry.registerPath({ + method: "post", + path: "/notifications/test/all", + tags, + summary: "Send a test alert through every notification channel for the team", + security: bearer, + request: { body: { content: json(testAllNotificationsBodyValidation) } }, + responses: { "200": okUnknown, ...standardErrors }, +}); + +registry.registerPath({ + method: "post", + path: "/notifications/test", + tags, + summary: "Send a test alert through a single notification channel", + security: bearer, + request: { body: { content: json(notificationBody) } }, + responses: { "200": okJson(z.object({ success: z.boolean() }).passthrough()), ...standardErrors }, +}); + +registry.registerPath({ + method: "get", + path: "/notifications/team", + tags, + summary: "List notification channels for the caller's team", + security: bearer, + responses: { "200": okUnknown, ...standardErrors }, +}); + +registry.registerPath({ + method: "get", + path: "/notifications/{id}", + tags, + summary: "Get a notification channel by id", + security: bearer, + request: { params: getNotificationByIdParamValidation }, + responses: { "200": okUnknown, ...standardErrors }, +}); + +registry.registerPath({ + method: "delete", + path: "/notifications/{id}", + tags, + summary: "Delete a notification channel", + security: bearer, + request: { params: deleteNotificationParamValidation }, + responses: { "200": okJsonNoData(), ...standardErrors }, +}); + +registry.registerPath({ + method: "patch", + path: "/notifications/{id}", + tags, + summary: "Edit a notification channel", + security: bearer, + request: { params: editNotificationParamValidation, body: { content: json(notificationBody) } }, + responses: { "200": okUnknown, ...standardErrors }, +}); diff --git a/server/openapi/routes/queue.ts b/server/openapi/routes/queue.ts new file mode 100644 index 000000000..f2970c22c --- /dev/null +++ b/server/openapi/routes/queue.ts @@ -0,0 +1,40 @@ +import { registry } from "../registry.js"; +import { bearer, okJsonNoData, okUnknown, standardErrors } from "../helpers.js"; + +const tags = ["queue"]; + +registry.registerPath({ + method: "get", + path: "/queue/jobs", + tags, + summary: "List queued monitor jobs (admin/superadmin)", + security: bearer, + responses: { "200": okUnknown, ...standardErrors }, +}); + +registry.registerPath({ + method: "get", + path: "/queue/metrics", + tags, + summary: "Get queue runtime metrics (admin/superadmin)", + security: bearer, + responses: { "200": okUnknown, ...standardErrors }, +}); + +registry.registerPath({ + method: "get", + path: "/queue/all-metrics", + tags, + summary: "Get queue metrics across all monitors (admin/superadmin)", + security: bearer, + responses: { "200": okUnknown, ...standardErrors }, +}); + +registry.registerPath({ + method: "post", + path: "/queue/flush", + tags, + summary: "Flush the monitor job queue (admin/superadmin)", + security: bearer, + responses: { "200": okJsonNoData(), ...standardErrors }, +}); diff --git a/server/openapi/routes/settings.ts b/server/openapi/routes/settings.ts new file mode 100644 index 000000000..1297c3e85 --- /dev/null +++ b/server/openapi/routes/settings.ts @@ -0,0 +1,35 @@ +import { registry } from "../registry.js"; +import { bearer, json, okJsonNoData, okUnknown, standardErrors } from "../helpers.js"; +import { updateAppSettingsBodyValidation } from "@/validation/settingsValidation.js"; +import { sendTestEmailBodyValidation } from "@/validation/notificationValidation.js"; + +const tags = ["settings"]; + +registry.registerPath({ + method: "get", + path: "/settings", + tags, + summary: "Get application settings", + security: bearer, + responses: { "200": okUnknown, ...standardErrors }, +}); + +registry.registerPath({ + method: "patch", + path: "/settings", + tags, + summary: "Update application settings (admin/superadmin)", + security: bearer, + request: { body: { content: json(updateAppSettingsBodyValidation) } }, + responses: { "200": okUnknown, ...standardErrors }, +}); + +registry.registerPath({ + method: "post", + path: "/settings/test-email", + tags, + summary: "Send a test email using current SMTP settings (admin/superadmin)", + security: bearer, + request: { body: { content: json(sendTestEmailBodyValidation) } }, + responses: { "200": okJsonNoData(), ...standardErrors }, +}); diff --git a/server/openapi/routes/statusPage.ts b/server/openapi/routes/statusPage.ts new file mode 100644 index 000000000..ac315b31c --- /dev/null +++ b/server/openapi/routes/statusPage.ts @@ -0,0 +1,52 @@ +import { registry } from "../registry.js"; +import { bearer, multipart, okJsonNoData, okUnknown, standardErrors } from "../helpers.js"; +import { createStatusPageBodyValidation, getStatusPageParamValidation, getStatusPageQueryValidation } from "@/validation/statusPageValidation.js"; + +const tags = ["status-page"]; + +registry.registerPath({ + method: "get", + path: "/status-page/team", + tags, + summary: "List status pages for the caller's team", + security: bearer, + responses: { "200": okUnknown, ...standardErrors }, +}); + +registry.registerPath({ + method: "post", + path: "/status-page", + tags, + summary: "Create a status page (logo upload optional)", + security: bearer, + request: { body: { content: multipart(createStatusPageBodyValidation.shape, "logo") } }, + responses: { "200": okUnknown, ...standardErrors }, +}); + +registry.registerPath({ + method: "put", + path: "/status-page/{id}", + tags, + summary: "Update a status page (logo upload optional)", + security: bearer, + request: { body: { content: multipart(createStatusPageBodyValidation.shape, "logo") } }, + responses: { "200": okUnknown, ...standardErrors }, +}); + +registry.registerPath({ + method: "get", + path: "/status-page/{url}", + tags, + summary: "Get a public status page by its URL slug", + request: { params: getStatusPageParamValidation, query: getStatusPageQueryValidation }, + responses: { "200": okUnknown, "404": { description: "Status page not found" }, "500": standardErrors["500"] }, +}); + +registry.registerPath({ + method: "delete", + path: "/status-page/{id}", + tags, + summary: "Delete a status page", + security: bearer, + responses: { "200": okJsonNoData(), ...standardErrors }, +}); diff --git a/server/package-lock.json b/server/package-lock.json index cf06ec454..52f37fde8 100644 --- a/server/package-lock.json +++ b/server/package-lock.json @@ -46,6 +46,7 @@ "zod": "^4.3.6" }, "devDependencies": { + "@asteasolutions/zod-to-openapi": "8.5.0", "@eslint/js": "^9.17.0", "@types/compression": "1.8.1", "@types/cookie-parser": "1.4.10", @@ -126,6 +127,19 @@ "integrity": "sha512-n8GuYSrI9bF7FFZ/SjhwevlHc8xaVlb/7HmHelnc/PZXBD2ZR49NnN9sMMuDdEGPeeRQ5d0hqlSlEpgCX3Wl0Q==", "license": "MIT" }, + "node_modules/@asteasolutions/zod-to-openapi": { + "version": "8.5.0", + "resolved": "https://registry.npmjs.org/@asteasolutions/zod-to-openapi/-/zod-to-openapi-8.5.0.tgz", + "integrity": "sha512-SABbKiObg5dLRiTFnqiW1WWwGcg1BJfmHtT2asIBnBHg6Smy/Ms2KHc650+JI4Hw7lSkdiNebEGXpwoxfben8Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "openapi3-ts": "^4.1.2" + }, + "peerDependencies": { + "zod": "^4.0.0" + } + }, "node_modules/@aws-crypto/sha256-browser": { "version": "5.2.0", "resolved": "https://registry.npmjs.org/@aws-crypto/sha256-browser/-/sha256-browser-5.2.0.tgz", @@ -851,6 +865,7 @@ "integrity": "sha512-H3mcG6ZDLTlYfaSNi0iOKkigqMFvkTKlGUYlD8GW7nNOYRrevuA46iTypPyv+06V3fEmvvazfntkBU34L0azAw==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@babel/code-frame": "^7.28.6", "@babel/generator": "^7.28.6", @@ -1470,6 +1485,7 @@ } ], "license": "MIT", + "peer": true, "engines": { "node": ">=18" }, @@ -1514,6 +1530,7 @@ } ], "license": "MIT", + "peer": true, "engines": { "node": ">=18" } @@ -4227,6 +4244,7 @@ "integrity": "sha512-wGA0NX93b19/dZC1J18tKWVIYWyyF2ZjT9vin/NRu0qzzvfVzWjs04iq2rQ3H65vCTQYlRqs3YHfY7zjdV+9Kw==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@types/body-parser": "*", "@types/express-serve-static-core": "^5.0.0", @@ -4387,6 +4405,7 @@ "resolved": "https://registry.npmjs.org/@types/node/-/node-24.9.1.tgz", "integrity": "sha512-QoiaXANRkSXK6p0Duvt56W208du4P9Uye9hWLWgGMDTEoKPhuenzNcC4vGUmrNkiOKTlIrBoyNQYNpSwfEZXSg==", "license": "MIT", + "peer": true, "dependencies": { "undici-types": "~7.16.0" } @@ -4624,6 +4643,7 @@ "integrity": "sha512-klQbnPAAiGYFyI02+znpBRLyjL4/BrBd0nyWkdC0s/6xFLkXYQ8OoRrSkqacS1ddVxf/LDyODIKbQ5TgKAf/Fg==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@typescript-eslint/scope-manager": "8.56.1", "@typescript-eslint/types": "8.56.1", @@ -5237,6 +5257,7 @@ "integrity": "sha512-NZyJarBfL7nWwIq+FDL6Zp/yHEhePMNnnJ0y3qfieCrmNvYct8uvtiV41UvlSe6apAfk0fY1FbWx+NwfmpvtTg==", "dev": true, "license": "MIT", + "peer": true, "bin": { "acorn": "bin/acorn" }, @@ -5815,6 +5836,7 @@ } ], "license": "MIT", + "peer": true, "dependencies": { "baseline-browser-mapping": "^2.8.19", "caniuse-lite": "^1.0.30001751", @@ -6708,6 +6730,7 @@ "resolved": "https://registry.npmjs.org/cssnano/-/cssnano-7.1.1.tgz", "integrity": "sha512-fm4D8ti0dQmFPeF8DXSAA//btEmqCOgAc/9Oa3C1LW94h5usNrJEfrON7b4FkPZgnDEn6OUs5NdxiJZmAtGOpQ==", "license": "MIT", + "peer": true, "dependencies": { "cssnano-preset-default": "^7.0.9", "lilconfig": "^3.1.3" @@ -7472,6 +7495,7 @@ "integrity": "sha512-t5aPOpmtJcZcz5UJyY2GbvpDlsK5E8JqRqoKtfiKE3cNh437KIqfJr3A3AKf5k64NPx6d0G3dno6XDY05PqPtw==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@eslint-community/eslint-utils": "^4.8.0", "@eslint-community/regexpp": "^4.12.1", @@ -7833,6 +7857,7 @@ "resolved": "https://registry.npmjs.org/express/-/express-4.22.1.tgz", "integrity": "sha512-F2X8g9P1X7uCPZMA3MVf9wcTqlyNp7IhH5qPCI0izhaOIYXaW9L535tGA3qmjRzpH+bZczqq7hVKxTR4NWnu+g==", "license": "MIT", + "peer": true, "dependencies": { "accepts": "~1.3.8", "array-flatten": "1.1.1", @@ -9501,6 +9526,7 @@ "integrity": "sha512-F26gjC0yWN8uAA5m5Ss8ZQf5nDHWGlN/xWZIh8S5SRbsEKBovwZhxGd6LJlbZYxBgCYOtreSUyb8hpXyGC5O4A==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@jest/core": "30.2.0", "@jest/types": "30.2.0", @@ -11944,6 +11970,16 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/openapi3-ts": { + "version": "4.5.0", + "resolved": "https://registry.npmjs.org/openapi3-ts/-/openapi3-ts-4.5.0.tgz", + "integrity": "sha512-jaL+HgTq2Gj5jRcfdutgRGLosCy/hT8sQf6VOy+P+g36cZOjI1iukdPnijC+4CmeRzg/jEllJUboEic2FhxhtQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "yaml": "^2.8.0" + } + }, "node_modules/optionator": { "version": "0.9.4", "resolved": "https://registry.npmjs.org/optionator/-/optionator-0.9.4.tgz", @@ -12335,6 +12371,7 @@ } ], "license": "MIT", + "peer": true, "dependencies": { "nanoid": "^3.3.11", "picocolors": "^1.1.1", @@ -14357,6 +14394,7 @@ "integrity": "sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==", "dev": true, "license": "MIT", + "peer": true, "engines": { "node": ">=12" }, @@ -14525,6 +14563,7 @@ "integrity": "sha512-f0FFpIdcHgn8zcPSbf1dRevwt047YMnaiJM3u2w2RewrB+fob/zePZcrOyQoLMMO7aBIddLcQIEK5dYjkLnGrQ==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@cspotcode/source-map-support": "^0.8.0", "@tsconfig/node10": "^1.0.7", @@ -14688,6 +14727,7 @@ "integrity": "sha512-CWBzXQrc/qOkhidw1OzBTQuYRbfyxDXJMVJ1XNwUHGROVmuaeiEm3OslpZ1RV96d7SKKjZKrSJu3+t/xlw3R9A==", "devOptional": true, "license": "Apache-2.0", + "peer": true, "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" @@ -15522,6 +15562,7 @@ "resolved": "https://registry.npmjs.org/zod/-/zod-4.3.6.tgz", "integrity": "sha512-rftlrkhHZOcjDwkGlnUtZZkvaPHCsDATp4pGpuOOMDaTdDDXF91wuVDJoWoPsKX/3YPQ5fHuF3STjcYyKr+Qhg==", "license": "MIT", + "peer": true, "funding": { "url": "https://github.com/sponsors/colinhacks" } diff --git a/server/package.json b/server/package.json index 245ae50e7..ea581be1a 100755 --- a/server/package.json +++ b/server/package.json @@ -11,7 +11,9 @@ "test:integration": "NODE_OPTIONS=--experimental-vm-modules jest --no-coverage test/integration", "dev": "nodemon --exec tsx src/index.js", "start": "node --watch ./dist/index.js", - "build": "tsc && tsc-alias && cp -r src/templates dist/templates", + "clean": "rm -rf dist", + "build": "npm run clean && npm run build:openapi && tsc -p tsconfig.build.json && tsc-alias -p tsconfig.build.json && cp -r src/templates dist/templates", + "build:openapi": "tsc --noEmit && tsx scripts/buildOpenApi.ts", "lint": "eslint .", "lint-fix": "eslint --fix .", "format": "prettier --write .", @@ -64,6 +66,7 @@ "zod": "^4.3.6" }, "devDependencies": { + "@asteasolutions/zod-to-openapi": "8.5.0", "@eslint/js": "^9.17.0", "@types/compression": "1.8.1", "@types/cookie-parser": "1.4.10", diff --git a/server/scripts/buildOpenApi.ts b/server/scripts/buildOpenApi.ts new file mode 100644 index 000000000..4f02004b7 --- /dev/null +++ b/server/scripts/buildOpenApi.ts @@ -0,0 +1,10 @@ +import { writeFileSync } from "node:fs"; +import path from "node:path"; +import { fileURLToPath } from "node:url"; +import { getOpenApiSpec } from "../openapi/index.js"; + +const __dirname = path.dirname(fileURLToPath(import.meta.url)); +const outPath = path.resolve(__dirname, "..", "openapi.json"); + +writeFileSync(outPath, JSON.stringify(getOpenApiSpec(), null, 2) + "\n"); +console.log(`OpenAPI spec written to ${outPath}`); diff --git a/server/tsconfig.build.json b/server/tsconfig.build.json new file mode 100644 index 000000000..b4d63e55e --- /dev/null +++ b/server/tsconfig.build.json @@ -0,0 +1,8 @@ +{ + "extends": "./tsconfig.json", + "compilerOptions": { + "rootDir": "./src", + "noEmit": false + }, + "include": ["src"] +} diff --git a/server/tsconfig.json b/server/tsconfig.json index fe8233c0c..d4544d9bb 100644 --- a/server/tsconfig.json +++ b/server/tsconfig.json @@ -1,6 +1,5 @@ { "compilerOptions": { - "rootDir": "./src", "allowJs": true, "checkJs": false, "paths": { @@ -15,8 +14,9 @@ "skipLibCheck": true, "noUncheckedIndexedAccess": true, "isolatedModules": true, - "resolveJsonModule": true + "resolveJsonModule": true, + "noEmit": true }, - "include": ["src"], + "include": ["src", "openapi", "scripts"], "exclude": ["tests", "dist", "node_modules"] } From a57da284d716aa04975b23980f59d9f1ffb01f0c Mon Sep 17 00:00:00 2001 From: Alex Holliday Date: Fri, 1 May 2026 11:15:46 -0700 Subject: [PATCH 042/245] remove unused import --- server/openapi/helpers.ts | 1 - 1 file changed, 1 deletion(-) diff --git a/server/openapi/helpers.ts b/server/openapi/helpers.ts index fcbd87840..bd3728836 100644 --- a/server/openapi/helpers.ts +++ b/server/openapi/helpers.ts @@ -1,4 +1,3 @@ -import "./registry.js"; import { z } from "zod"; export const successEnvelope = (data: T) => From 34feb6d3fa2425e73dbcdd42c82cdac73db7dad4 Mon Sep 17 00:00:00 2001 From: Alex Holliday Date: Fri, 1 May 2026 11:24:19 -0700 Subject: [PATCH 043/245] replace inline objects with defined constants --- server/openapi.json | 158 ++++++++++++--------- server/openapi/routes/auth.ts | 36 ++--- server/openapi/routes/monitor.ts | 71 ++++----- server/src/validation/monitorValidation.ts | 42 +++++- server/src/validation/userValidation.ts | 16 +++ 5 files changed, 195 insertions(+), 128 deletions(-) diff --git a/server/openapi.json b/server/openapi.json index ec3a7f925..f62062332 100644 --- a/server/openapi.json +++ b/server/openapi.json @@ -81,58 +81,72 @@ "$ref": "#/components/schemas/User" }, "token": { - "type": "string", - "example": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9..." + "type": "string" } }, - "required": ["user"] + "required": ["user"], + "example": { + "user": { + "_id": "65f1c2a4d8b9e0123456789a", + "firstName": "Ada", + "lastName": "Lovelace", + "email": "ada@example.com", + "role": ["admin"], + "teamId": "65f1c2a4d8b9e01234567890", + "createdAt": "2026-04-01T10:00:00.000Z", + "updatedAt": "2026-04-15T14:30:00.000Z" + }, + "token": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9..." + } }, "User": { "type": "object", "properties": { "_id": { - "type": "string", - "example": "65f1c2a4d8b9e0123456789a" + "type": "string" }, "firstName": { - "type": "string", - "example": "Ada" + "type": "string" }, "lastName": { - "type": "string", - "example": "Lovelace" + "type": "string" }, "email": { - "type": "string", - "example": "ada@example.com" + "type": "string" }, "role": { "type": "array", "items": { "type": "string" - }, - "example": ["admin"] + } }, "teamId": { - "type": "string", - "example": "65f1c2a4d8b9e01234567890" + "type": "string" }, "profileImage": { "type": "string", "nullable": true }, "createdAt": { - "type": "string", - "example": "2026-04-01T10:00:00.000Z" + "type": "string" }, "updatedAt": { - "type": "string", - "example": "2026-04-15T14:30:00.000Z" + "type": "string" } }, "required": ["_id", "firstName", "lastName", "email", "role", "createdAt", "updatedAt"], "additionalProperties": { "nullable": true + }, + "example": { + "_id": "65f1c2a4d8b9e0123456789a", + "firstName": "Ada", + "lastName": "Lovelace", + "email": "ada@example.com", + "role": ["admin"], + "teamId": "65f1c2a4d8b9e01234567890", + "createdAt": "2026-04-01T10:00:00.000Z", + "updatedAt": "2026-04-15T14:30:00.000Z" } }, "Incident": { @@ -322,58 +336,45 @@ "type": "object", "properties": { "_id": { - "type": "string", - "example": "65f1c2a4d8b9e0123456789a" + "type": "string" }, "name": { - "type": "string", - "example": "Marketing site" + "type": "string" }, "description": { - "type": "string", - "example": "Production marketing site monitored from the EU region" + "type": "string" }, "type": { "type": "string", - "enum": ["http", "ping", "pagespeed", "hardware", "docker", "port", "game", "grpc", "websocket", "unknown"], - "example": "http" + "enum": ["http", "ping", "pagespeed", "hardware", "docker", "port", "game", "grpc", "websocket", "unknown"] }, "url": { - "type": "string", - "example": "https://www.example.com" + "type": "string" }, "port": { - "type": "number", - "example": 443 + "type": "number" }, "isActive": { - "type": "boolean", - "example": true + "type": "boolean" }, "interval": { - "type": "number", - "example": 60000 + "type": "number" }, "status": { "type": "string", - "enum": ["up", "down", "paused", "initializing", "maintenance", "breached"], - "example": "up" + "enum": ["up", "down", "paused", "initializing", "maintenance", "breached"] }, "statusWindowSize": { - "type": "number", - "example": 5 + "type": "number" }, "statusWindowThreshold": { - "type": "number", - "example": 3 + "type": "number" }, "ignoreTlsErrors": { - "type": "boolean", - "example": false + "type": "boolean" }, "useAdvancedMatching": { - "type": "boolean", - "example": false + "type": "boolean" }, "jsonPath": { "type": "string" @@ -389,34 +390,28 @@ "type": "array", "items": { "type": "string" - }, - "example": ["65f1c2a4d8b9e0123456789b"] + } }, "secret": { "type": "string" }, "cpuAlertThreshold": { - "type": "number", - "example": 90 + "type": "number" }, "memoryAlertThreshold": { - "type": "number", - "example": 90 + "type": "number" }, "diskAlertThreshold": { - "type": "number", - "example": 90 + "type": "number" }, "tempAlertThreshold": { - "type": "number", - "example": 80 + "type": "number" }, "selectedDisks": { "type": "array", "items": { "type": "string" - }, - "example": [] + } }, "gameId": { "type": "string" @@ -429,36 +424,29 @@ "nullable": true }, "geoCheckEnabled": { - "type": "boolean", - "example": false + "type": "boolean" }, "geoCheckLocations": { "type": "array", "items": { "type": "string", "enum": ["EU", "NA", "AS", "SA", "AF", "OC"] - }, - "example": [] + } }, "geoCheckInterval": { - "type": "number", - "example": 300000 + "type": "number" }, "teamId": { - "type": "string", - "example": "65f1c2a4d8b9e01234567890" + "type": "string" }, "userId": { - "type": "string", - "example": "65f1c2a4d8b9e01234567891" + "type": "string" }, "createdAt": { - "type": "string", - "example": "2026-04-01T10:00:00.000Z" + "type": "string" }, "updatedAt": { - "type": "string", - "example": "2026-04-15T14:30:00.000Z" + "type": "string" } }, "required": [ @@ -489,6 +477,34 @@ ], "additionalProperties": { "nullable": true + }, + "example": { + "_id": "65f1c2a4d8b9e0123456789a", + "name": "Marketing site", + "description": "Production marketing site monitored from the EU region", + "type": "http", + "url": "https://www.example.com", + "port": 443, + "isActive": true, + "interval": 60000, + "status": "up", + "statusWindowSize": 5, + "statusWindowThreshold": 3, + "ignoreTlsErrors": false, + "useAdvancedMatching": false, + "notifications": ["65f1c2a4d8b9e0123456789b"], + "cpuAlertThreshold": 90, + "memoryAlertThreshold": 90, + "diskAlertThreshold": 90, + "tempAlertThreshold": 80, + "selectedDisks": [], + "geoCheckEnabled": false, + "geoCheckLocations": [], + "geoCheckInterval": 300000, + "teamId": "65f1c2a4d8b9e01234567890", + "userId": "65f1c2a4d8b9e01234567891", + "createdAt": "2026-04-01T10:00:00.000Z", + "updatedAt": "2026-04-15T14:30:00.000Z" } }, "NotificationChannelBody": { diff --git a/server/openapi/routes/auth.ts b/server/openapi/routes/auth.ts index 91254edf3..66b74382b 100644 --- a/server/openapi/routes/auth.ts +++ b/server/openapi/routes/auth.ts @@ -15,31 +15,35 @@ import { editUserPasswordByIdBodyValidation, createUserBodyValidation, editUserBodyValidation, + userResponseSchema, } from "@/validation/userValidation.js"; const tags = ["auth"]; -const userObject = z - .object({ - _id: z.string().openapi({ example: "65f1c2a4d8b9e0123456789a" }), - firstName: z.string().openapi({ example: "Ada" }), - lastName: z.string().openapi({ example: "Lovelace" }), - email: z.string().openapi({ example: "ada@example.com" }), - role: z.array(z.string()).openapi({ example: ["admin"] }), - teamId: z.string().optional().openapi({ example: "65f1c2a4d8b9e01234567890" }), - profileImage: z.string().nullable().optional(), - createdAt: z.string().openapi({ example: "2026-04-01T10:00:00.000Z" }), - updatedAt: z.string().openapi({ example: "2026-04-15T14:30:00.000Z" }), - }) - .passthrough() - .openapi("User"); +const userExample = { + _id: "65f1c2a4d8b9e0123456789a", + firstName: "Ada", + lastName: "Lovelace", + email: "ada@example.com", + role: ["admin"], + teamId: "65f1c2a4d8b9e01234567890", + createdAt: "2026-04-01T10:00:00.000Z", + updatedAt: "2026-04-15T14:30:00.000Z", +}; + +const userObject = userResponseSchema.openapi("User", { example: userExample }); const authPayload = z .object({ user: userObject, - token: z.string().optional().openapi({ example: "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9..." }), + token: z.string().optional(), }) - .openapi("AuthPayload"); + .openapi("AuthPayload", { + example: { + user: userExample, + token: "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9...", + }, + }); registry.registerPath({ method: "post", diff --git a/server/openapi/routes/monitor.ts b/server/openapi/routes/monitor.ts index 0e6187318..10f988fa8 100644 --- a/server/openapi/routes/monitor.ts +++ b/server/openapi/routes/monitor.ts @@ -1,8 +1,6 @@ import { z } from "zod"; import { registry } from "../registry.js"; import { bearer, json, okJson, okJsonNoData, okUnknown, standardErrors } from "../helpers.js"; -import { GeoContinents } from "@/types/geoCheck.js"; -import { MonitorMatchMethods, MonitorTypes } from "@/types/monitor.js"; import { getMonitorByIdParamValidation, getMonitorByIdQueryValidation, @@ -17,49 +15,42 @@ import { importMonitorsBodyValidation, getHardwareDetailsByIdParamValidation, getHardwareDetailsByIdQueryValidation, + monitorResponseSchema, } from "@/validation/monitorValidation.js"; import { updateNotificationsValidation } from "@/validation/notificationValidation.js"; const tags = ["monitors"]; -const monitorObject = z - .object({ - _id: z.string().openapi({ example: "65f1c2a4d8b9e0123456789a" }), - name: z.string().openapi({ example: "Marketing site" }), - description: z.string().optional().openapi({ example: "Production marketing site monitored from the EU region" }), - type: z.enum(MonitorTypes).openapi({ example: "http" }), - url: z.string().openapi({ example: "https://www.example.com" }), - port: z.number().optional().openapi({ example: 443 }), - isActive: z.boolean().openapi({ example: true }), - interval: z.number().openapi({ example: 60000 }), - status: z.enum(["up", "down", "paused", "initializing", "maintenance", "breached"]).openapi({ example: "up" }), - statusWindowSize: z.number().openapi({ example: 5 }), - statusWindowThreshold: z.number().openapi({ example: 3 }), - ignoreTlsErrors: z.boolean().openapi({ example: false }), - useAdvancedMatching: z.boolean().openapi({ example: false }), - jsonPath: z.string().optional(), - expectedValue: z.string().optional(), - matchMethod: z.enum(MonitorMatchMethods).optional(), - notifications: z.array(z.string()).openapi({ example: ["65f1c2a4d8b9e0123456789b"] }), - secret: z.string().optional(), - cpuAlertThreshold: z.number().openapi({ example: 90 }), - memoryAlertThreshold: z.number().openapi({ example: 90 }), - diskAlertThreshold: z.number().openapi({ example: 90 }), - tempAlertThreshold: z.number().openapi({ example: 80 }), - selectedDisks: z.array(z.string()).openapi({ example: [] }), - gameId: z.string().optional(), - grpcServiceName: z.string().optional(), - group: z.string().nullable().optional(), - geoCheckEnabled: z.boolean().openapi({ example: false }), - geoCheckLocations: z.array(z.enum(GeoContinents)).openapi({ example: [] }), - geoCheckInterval: z.number().openapi({ example: 300000 }), - teamId: z.string().openapi({ example: "65f1c2a4d8b9e01234567890" }), - userId: z.string().openapi({ example: "65f1c2a4d8b9e01234567891" }), - createdAt: z.string().openapi({ example: "2026-04-01T10:00:00.000Z" }), - updatedAt: z.string().openapi({ example: "2026-04-15T14:30:00.000Z" }), - }) - .passthrough() - .openapi("Monitor"); +const monitorObject = monitorResponseSchema.openapi("Monitor", { + example: { + _id: "65f1c2a4d8b9e0123456789a", + name: "Marketing site", + description: "Production marketing site monitored from the EU region", + type: "http", + url: "https://www.example.com", + port: 443, + isActive: true, + interval: 60000, + status: "up", + statusWindowSize: 5, + statusWindowThreshold: 3, + ignoreTlsErrors: false, + useAdvancedMatching: false, + notifications: ["65f1c2a4d8b9e0123456789b"], + cpuAlertThreshold: 90, + memoryAlertThreshold: 90, + diskAlertThreshold: 90, + tempAlertThreshold: 80, + selectedDisks: [], + geoCheckEnabled: false, + geoCheckLocations: [], + geoCheckInterval: 300000, + teamId: "65f1c2a4d8b9e01234567890", + userId: "65f1c2a4d8b9e01234567891", + createdAt: "2026-04-01T10:00:00.000Z", + updatedAt: "2026-04-15T14:30:00.000Z", + }, +}); registry.registerPath({ method: "get", diff --git a/server/src/validation/monitorValidation.ts b/server/src/validation/monitorValidation.ts index df000ecef..57099e7ff 100644 --- a/server/src/validation/monitorValidation.ts +++ b/server/src/validation/monitorValidation.ts @@ -1,7 +1,7 @@ import { z } from "zod"; import { booleanCoercion } from "./shared.js"; import { GeoContinents } from "@/types/geoCheck.js"; -import { MonitorMatchMethods, MonitorTypes } from "@/types/monitor.js"; +import { MonitorMatchMethods, MonitorStatuses, MonitorTypes } from "@/types/monitor.js"; export const getMonitorByIdParamValidation = z.object({ monitorId: z.string().min(1, "Monitor ID is required"), @@ -177,3 +177,43 @@ export const getHardwareDetailsByIdParamValidation = z.object({ export const getHardwareDetailsByIdQueryValidation = z.object({ dateRange: z.enum(["recent", "hour", "day", "week", "month", "all"]).optional(), }); + +// Canonical monitor shape returned by /monitors endpoints. Keep aligned with +// what the controllers actually serialize. +export const monitorResponseSchema = z + .object({ + _id: z.string(), + name: z.string(), + description: z.string().optional(), + type: z.enum(MonitorTypes), + url: z.string(), + port: z.number().optional(), + isActive: z.boolean(), + interval: z.number(), + status: z.enum(MonitorStatuses), + statusWindowSize: z.number(), + statusWindowThreshold: z.number(), + ignoreTlsErrors: z.boolean(), + useAdvancedMatching: z.boolean(), + jsonPath: z.string().optional(), + expectedValue: z.string().optional(), + matchMethod: z.enum(MonitorMatchMethods).optional(), + notifications: z.array(z.string()), + secret: z.string().optional(), + cpuAlertThreshold: z.number(), + memoryAlertThreshold: z.number(), + diskAlertThreshold: z.number(), + tempAlertThreshold: z.number(), + selectedDisks: z.array(z.string()), + gameId: z.string().optional(), + grpcServiceName: z.string().optional(), + group: z.string().nullable().optional(), + geoCheckEnabled: z.boolean(), + geoCheckLocations: z.array(z.enum(GeoContinents)), + geoCheckInterval: z.number(), + teamId: z.string(), + userId: z.string(), + createdAt: z.string(), + updatedAt: z.string(), + }) + .passthrough(); diff --git a/server/src/validation/userValidation.ts b/server/src/validation/userValidation.ts index 9e11c8c57..e613b86e6 100644 --- a/server/src/validation/userValidation.ts +++ b/server/src/validation/userValidation.ts @@ -50,3 +50,19 @@ export const editUserBodyValidation = z.object({ email: lowercaseEmailValidation.optional(), profilePicture: z.string().optional(), }); + +// Canonical user shape returned by auth/user endpoints. Keep aligned with what +// the controllers actually serialize (password is intentionally omitted). +export const userResponseSchema = z + .object({ + _id: z.string(), + firstName: z.string(), + lastName: z.string(), + email: z.string(), + role: z.array(z.string()), + teamId: z.string().optional(), + profileImage: z.string().nullable().optional(), + createdAt: z.string(), + updatedAt: z.string(), + }) + .passthrough(); From d5cd957bbd542163b0b0bb5bb0bb95751b8336a7 Mon Sep 17 00:00:00 2001 From: gorkem-bwl Date: Fri, 1 May 2026 16:46:46 -0400 Subject: [PATCH 044/245] feat(infrastructure): add helper text to capture endpoint and secret fields MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two real users hit the same confusion: the Capture endpoint URL needs the full path /api/v1/metrics, and the authorization secret should not include a "Bearer" prefix. The form previously gave no guidance on either — same generic placeholder as HTTP monitors and no helper text. - Replace the URL field's generic label/placeholder with hardware- specific copy ("Capture endpoint", realistic 192.168.1.10:5001/... example) and add helper text under the field. - Add helper text under the authorization secret field clarifying that only the raw token belongs there — Bearer is added automatically. - Wire optional urlHelperText / secretHelperText through GeneralSettingsConfig so other monitor types are unaffected. - Add three new i18n keys (captureUrl.label/placeholder/helperText) and one (secret.helperText) to all 16 locales. Non-English locales carry the English copy as a placeholder until translators pick the keys up via the existing PoEditor pipeline. --- client/src/Pages/CreateMonitor/index.tsx | 20 ++++++++++++++++---- client/src/locales/ar.json | 8 +++++++- client/src/locales/cs.json | 8 +++++++- client/src/locales/de.json | 8 +++++++- client/src/locales/en.json | 8 +++++++- client/src/locales/es.json | 8 +++++++- client/src/locales/fi.json | 8 +++++++- client/src/locales/fr.json | 8 +++++++- client/src/locales/ja.json | 8 +++++++- client/src/locales/pt-BR.json | 8 +++++++- client/src/locales/ru.json | 8 +++++++- client/src/locales/th.json | 8 +++++++- client/src/locales/tr.json | 8 +++++++- client/src/locales/uk.json | 8 +++++++- client/src/locales/vi.json | 8 +++++++- client/src/locales/zh-CN.json | 8 +++++++- client/src/locales/zh-TW.json | 8 +++++++- 17 files changed, 128 insertions(+), 20 deletions(-) diff --git a/client/src/Pages/CreateMonitor/index.tsx b/client/src/Pages/CreateMonitor/index.tsx index 54db38c13..7bc828393 100644 --- a/client/src/Pages/CreateMonitor/index.tsx +++ b/client/src/Pages/CreateMonitor/index.tsx @@ -44,11 +44,13 @@ import type { MonitorFormData } from "@/Validation/monitor"; interface GeneralSettingsConfig { urlLabel: string; urlPlaceholder: string; + urlHelperText?: string; namePlaceholder: string; showUrl: boolean; showPort: boolean; showGameSelect: boolean; showSecret: boolean; + secretHelperText?: string; showGrpcServiceName: boolean; showIgnoreTls: boolean; } @@ -136,13 +138,15 @@ const getGeneralSettingsConfig = ( showIgnoreTls: false, }, hardware: { - urlLabel: t("pages.createMonitor.form.general.option.url.label"), - urlPlaceholder: t("pages.createMonitor.form.general.option.url.placeholder"), + urlLabel: t("pages.createMonitor.form.general.option.captureUrl.label"), + urlPlaceholder: t("pages.createMonitor.form.general.option.captureUrl.placeholder"), + urlHelperText: t("pages.createMonitor.form.general.option.captureUrl.helperText"), namePlaceholder: t("pages.createMonitor.form.general.option.name.placeholder"), showUrl: true, showPort: false, showGameSelect: false, showSecret: true, + secretHelperText: t("pages.createMonitor.form.general.option.secret.helperText"), showGrpcServiceName: false, showIgnoreTls: false, }, @@ -389,7 +393,11 @@ const CreateMonitorPage = () => { fullWidth disabled={isEditMode} error={!!fieldState.error} - helperText={fieldState.error?.message ?? ""} + helperText={ + fieldState.error?.message ?? + generalSettingsConfig.urlHelperText ?? + "" + } /> )} /> @@ -490,7 +498,11 @@ const CreateMonitorPage = () => { )} fullWidth error={!!fieldState.error} - helperText={fieldState.error?.message ?? ""} + helperText={ + fieldState.error?.message ?? + generalSettingsConfig.secretHelperText ?? + "" + } /> )} /> diff --git a/client/src/locales/ar.json b/client/src/locales/ar.json index 11fa81309..85e9d6b86 100644 --- a/client/src/locales/ar.json +++ b/client/src/locales/ar.json @@ -513,7 +513,8 @@ }, "secret": { "label": "مفتاح التفويض", - "placeholder": "أدخل مفتاحك السري" + "placeholder": "أدخل مفتاحك السري", + "helperText": "Paste only the token string. Don't include 'Bearer' — it's added automatically." }, "url": { "label": "URL", @@ -534,6 +535,11 @@ "wsUrl": { "label": "رابط WebSocket", "placeholder": "wss://example.com/socket" + }, + "captureUrl": { + "label": "Capture endpoint", + "placeholder": "http://192.168.1.10:5001/api/v1/metrics", + "helperText": "The full Capture metrics endpoint, including the path. Example: http://192.168.1.10:5001/api/v1/metrics" } }, "title": "الإعدادات العامة", diff --git a/client/src/locales/cs.json b/client/src/locales/cs.json index 454f0b673..eb9e0260a 100644 --- a/client/src/locales/cs.json +++ b/client/src/locales/cs.json @@ -513,7 +513,8 @@ }, "secret": { "label": "Autorizační tajný klíč", - "placeholder": "Zadejte svůj tajný klíč" + "placeholder": "Zadejte svůj tajný klíč", + "helperText": "Paste only the token string. Don't include 'Bearer' — it's added automatically." }, "url": { "label": "URL", @@ -534,6 +535,11 @@ "wsUrl": { "label": "WebSocket URL", "placeholder": "wss://example.com/socket" + }, + "captureUrl": { + "label": "Capture endpoint", + "placeholder": "http://192.168.1.10:5001/api/v1/metrics", + "helperText": "The full Capture metrics endpoint, including the path. Example: http://192.168.1.10:5001/api/v1/metrics" } }, "title": "Obecná nastavení", diff --git a/client/src/locales/de.json b/client/src/locales/de.json index 8090aa5f3..ecce58f67 100644 --- a/client/src/locales/de.json +++ b/client/src/locales/de.json @@ -513,7 +513,8 @@ }, "secret": { "label": "Autorisierungsschlüssel", - "placeholder": "Geben Sie Ihren geheimen Schlüssel ein" + "placeholder": "Geben Sie Ihren geheimen Schlüssel ein", + "helperText": "Paste only the token string. Don't include 'Bearer' — it's added automatically." }, "url": { "label": "URL", @@ -534,6 +535,11 @@ "wsUrl": { "label": "WebSocket-URL", "placeholder": "wss://example.com/socket" + }, + "captureUrl": { + "label": "Capture endpoint", + "placeholder": "http://192.168.1.10:5001/api/v1/metrics", + "helperText": "The full Capture metrics endpoint, including the path. Example: http://192.168.1.10:5001/api/v1/metrics" } }, "title": "Allgemeine Einstellungen", diff --git a/client/src/locales/en.json b/client/src/locales/en.json index d6e8bb4f3..b1336a5dd 100644 --- a/client/src/locales/en.json +++ b/client/src/locales/en.json @@ -513,7 +513,8 @@ }, "secret": { "label": "Authorization secret", - "placeholder": "Enter your secret key" + "placeholder": "Enter your secret key", + "helperText": "Paste only the token string. Don't include 'Bearer' — it's added automatically." }, "url": { "label": "URL", @@ -534,6 +535,11 @@ "wsUrl": { "label": "WebSocket URL", "placeholder": "wss://example.com/socket" + }, + "captureUrl": { + "label": "Capture endpoint", + "placeholder": "http://192.168.1.10:5001/api/v1/metrics", + "helperText": "The full Capture metrics endpoint, including the path. Example: http://192.168.1.10:5001/api/v1/metrics" } }, "title": "General settings", diff --git a/client/src/locales/es.json b/client/src/locales/es.json index 506c317e3..bdfd9e3ae 100644 --- a/client/src/locales/es.json +++ b/client/src/locales/es.json @@ -520,7 +520,8 @@ }, "secret": { "label": "Secreto de autorización", - "placeholder": "Ingrese su clave secreta" + "placeholder": "Ingrese su clave secreta", + "helperText": "Paste only the token string. Don't include 'Bearer' — it's added automatically." }, "url": { "label": "URL", @@ -541,6 +542,11 @@ "wsUrl": { "label": "URL de WebSocket", "placeholder": "wss://example.com/socket" + }, + "captureUrl": { + "label": "Capture endpoint", + "placeholder": "http://192.168.1.10:5001/api/v1/metrics", + "helperText": "The full Capture metrics endpoint, including the path. Example: http://192.168.1.10:5001/api/v1/metrics" } }, "title": "Configuración general", diff --git a/client/src/locales/fi.json b/client/src/locales/fi.json index 6238837f2..834f869dd 100644 --- a/client/src/locales/fi.json +++ b/client/src/locales/fi.json @@ -513,7 +513,8 @@ }, "secret": { "label": "Valtuutusavain", - "placeholder": "Syötä salausavaimesi" + "placeholder": "Syötä salausavaimesi", + "helperText": "Paste only the token string. Don't include 'Bearer' — it's added automatically." }, "url": { "label": "URL", @@ -534,6 +535,11 @@ "wsUrl": { "label": "WebSocket URL", "placeholder": "wss://example.com/socket" + }, + "captureUrl": { + "label": "Capture endpoint", + "placeholder": "http://192.168.1.10:5001/api/v1/metrics", + "helperText": "The full Capture metrics endpoint, including the path. Example: http://192.168.1.10:5001/api/v1/metrics" } }, "title": "Yleiset asetukset", diff --git a/client/src/locales/fr.json b/client/src/locales/fr.json index 306a2bd61..b5ab31986 100644 --- a/client/src/locales/fr.json +++ b/client/src/locales/fr.json @@ -513,7 +513,8 @@ }, "secret": { "label": "Secret d'autorisation", - "placeholder": "Saisissez votre clé secrète" + "placeholder": "Saisissez votre clé secrète", + "helperText": "Paste only the token string. Don't include 'Bearer' — it's added automatically." }, "url": { "label": "URL", @@ -534,6 +535,11 @@ "wsUrl": { "label": "URL WebSocket", "placeholder": "wss://example.com/socket" + }, + "captureUrl": { + "label": "Capture endpoint", + "placeholder": "http://192.168.1.10:5001/api/v1/metrics", + "helperText": "The full Capture metrics endpoint, including the path. Example: http://192.168.1.10:5001/api/v1/metrics" } }, "title": "Paramètres généraux", diff --git a/client/src/locales/ja.json b/client/src/locales/ja.json index 923213a46..5ec727046 100644 --- a/client/src/locales/ja.json +++ b/client/src/locales/ja.json @@ -513,7 +513,8 @@ }, "secret": { "label": "認証シークレット", - "placeholder": "シークレットキーを入力" + "placeholder": "シークレットキーを入力", + "helperText": "Paste only the token string. Don't include 'Bearer' — it's added automatically." }, "url": { "label": "URL", @@ -534,6 +535,11 @@ "wsUrl": { "label": "WebSocket URL", "placeholder": "wss://example.com/socket" + }, + "captureUrl": { + "label": "Capture endpoint", + "placeholder": "http://192.168.1.10:5001/api/v1/metrics", + "helperText": "The full Capture metrics endpoint, including the path. Example: http://192.168.1.10:5001/api/v1/metrics" } }, "title": "一般設定", diff --git a/client/src/locales/pt-BR.json b/client/src/locales/pt-BR.json index f7b7c4cbc..e5568897b 100644 --- a/client/src/locales/pt-BR.json +++ b/client/src/locales/pt-BR.json @@ -513,7 +513,8 @@ }, "secret": { "label": "Chave de autorização", - "placeholder": "Digite sua chave secreta" + "placeholder": "Digite sua chave secreta", + "helperText": "Paste only the token string. Don't include 'Bearer' — it's added automatically." }, "url": { "label": "URL", @@ -534,6 +535,11 @@ "wsUrl": { "label": "URL do WebSocket", "placeholder": "wss://exemplo.com/socket" + }, + "captureUrl": { + "label": "Capture endpoint", + "placeholder": "http://192.168.1.10:5001/api/v1/metrics", + "helperText": "The full Capture metrics endpoint, including the path. Example: http://192.168.1.10:5001/api/v1/metrics" } }, "title": "Configurações gerais", diff --git a/client/src/locales/ru.json b/client/src/locales/ru.json index 1a5d12452..8da92929a 100644 --- a/client/src/locales/ru.json +++ b/client/src/locales/ru.json @@ -513,7 +513,8 @@ }, "secret": { "label": "Секрет авторизации", - "placeholder": "Введите Ваш секретный ключ" + "placeholder": "Введите Ваш секретный ключ", + "helperText": "Paste only the token string. Don't include 'Bearer' — it's added automatically." }, "url": { "label": "URL", @@ -534,6 +535,11 @@ "wsUrl": { "label": "WebSocket URL", "placeholder": "wss://example.com/socket" + }, + "captureUrl": { + "label": "Capture endpoint", + "placeholder": "http://192.168.1.10:5001/api/v1/metrics", + "helperText": "The full Capture metrics endpoint, including the path. Example: http://192.168.1.10:5001/api/v1/metrics" } }, "title": "Основные настройки", diff --git a/client/src/locales/th.json b/client/src/locales/th.json index 433ee10cd..cfac73ae2 100644 --- a/client/src/locales/th.json +++ b/client/src/locales/th.json @@ -513,7 +513,8 @@ }, "secret": { "label": "รหัสลับสำหรับยืนยันตัวตน", - "placeholder": "กรอกรหัสลับของคุณ" + "placeholder": "กรอกรหัสลับของคุณ", + "helperText": "Paste only the token string. Don't include 'Bearer' — it's added automatically." }, "url": { "label": "URL", @@ -534,6 +535,11 @@ "wsUrl": { "label": "URL ของ WebSocket", "placeholder": "wss://example.com/socket" + }, + "captureUrl": { + "label": "Capture endpoint", + "placeholder": "http://192.168.1.10:5001/api/v1/metrics", + "helperText": "The full Capture metrics endpoint, including the path. Example: http://192.168.1.10:5001/api/v1/metrics" } }, "title": "ตั้งค่าทั่วไป", diff --git a/client/src/locales/tr.json b/client/src/locales/tr.json index a77dd8eb3..6a2acf120 100644 --- a/client/src/locales/tr.json +++ b/client/src/locales/tr.json @@ -513,7 +513,8 @@ }, "secret": { "label": "Yetkilendirme anahtarı", - "placeholder": "Gizli anahtarınızı girin" + "placeholder": "Gizli anahtarınızı girin", + "helperText": "Paste only the token string. Don't include 'Bearer' — it's added automatically." }, "url": { "label": "URL", @@ -534,6 +535,11 @@ "wsUrl": { "label": "WebSocket URL", "placeholder": "wss://example.com/socket" + }, + "captureUrl": { + "label": "Capture endpoint", + "placeholder": "http://192.168.1.10:5001/api/v1/metrics", + "helperText": "The full Capture metrics endpoint, including the path. Example: http://192.168.1.10:5001/api/v1/metrics" } }, "title": "Genel ayarlar", diff --git a/client/src/locales/uk.json b/client/src/locales/uk.json index 5b467e4b4..0fbdad260 100644 --- a/client/src/locales/uk.json +++ b/client/src/locales/uk.json @@ -513,7 +513,8 @@ }, "secret": { "label": "Секрет авторизації", - "placeholder": "Введіть Ваш секретний ключ" + "placeholder": "Введіть Ваш секретний ключ", + "helperText": "Paste only the token string. Don't include 'Bearer' — it's added automatically." }, "url": { "label": "URL", @@ -534,6 +535,11 @@ "wsUrl": { "label": "WebSocket URL", "placeholder": "wss://example.com/socket" + }, + "captureUrl": { + "label": "Capture endpoint", + "placeholder": "http://192.168.1.10:5001/api/v1/metrics", + "helperText": "The full Capture metrics endpoint, including the path. Example: http://192.168.1.10:5001/api/v1/metrics" } }, "title": "Загальні налаштування", diff --git a/client/src/locales/vi.json b/client/src/locales/vi.json index dc3e804de..017518e61 100644 --- a/client/src/locales/vi.json +++ b/client/src/locales/vi.json @@ -513,7 +513,8 @@ }, "secret": { "label": "Khóa xác thực", - "placeholder": "Nhập khóa bí mật của bạn" + "placeholder": "Nhập khóa bí mật của bạn", + "helperText": "Paste only the token string. Don't include 'Bearer' — it's added automatically." }, "url": { "label": "URL", @@ -534,6 +535,11 @@ "wsUrl": { "label": "URL WebSocket", "placeholder": "wss://example.com/socket" + }, + "captureUrl": { + "label": "Capture endpoint", + "placeholder": "http://192.168.1.10:5001/api/v1/metrics", + "helperText": "The full Capture metrics endpoint, including the path. Example: http://192.168.1.10:5001/api/v1/metrics" } }, "title": "Cài đặt chung", diff --git a/client/src/locales/zh-CN.json b/client/src/locales/zh-CN.json index ed5e70c1e..3e7d3cb2f 100644 --- a/client/src/locales/zh-CN.json +++ b/client/src/locales/zh-CN.json @@ -513,7 +513,8 @@ }, "secret": { "label": "授权密钥", - "placeholder": "输入您的密钥" + "placeholder": "输入您的密钥", + "helperText": "Paste only the token string. Don't include 'Bearer' — it's added automatically." }, "url": { "label": "URL", @@ -534,6 +535,11 @@ "wsUrl": { "label": "WebSocket URL", "placeholder": "wss://example.com/socket" + }, + "captureUrl": { + "label": "Capture endpoint", + "placeholder": "http://192.168.1.10:5001/api/v1/metrics", + "helperText": "The full Capture metrics endpoint, including the path. Example: http://192.168.1.10:5001/api/v1/metrics" } }, "title": "常规设置", diff --git a/client/src/locales/zh-TW.json b/client/src/locales/zh-TW.json index ff6e9f6dd..d3515f72d 100644 --- a/client/src/locales/zh-TW.json +++ b/client/src/locales/zh-TW.json @@ -513,7 +513,8 @@ }, "secret": { "label": "授權密鑰", - "placeholder": "輸入您的密鑰" + "placeholder": "輸入您的密鑰", + "helperText": "Paste only the token string. Don't include 'Bearer' — it's added automatically." }, "url": { "label": "URL", @@ -534,6 +535,11 @@ "wsUrl": { "label": "WebSocket URL", "placeholder": "wss://example.com/socket" + }, + "captureUrl": { + "label": "Capture endpoint", + "placeholder": "http://192.168.1.10:5001/api/v1/metrics", + "helperText": "The full Capture metrics endpoint, including the path. Example: http://192.168.1.10:5001/api/v1/metrics" } }, "title": "一般設定", From 53c58eb945be3e90b64e8179ca177fd4ee856446 Mon Sep 17 00:00:00 2001 From: Iriome Date: Fri, 1 May 2026 21:56:40 +0100 Subject: [PATCH 045/245] fix: make non-root support opt-in, keep root as default Remove USER node directive so existing deployments are unaffected. The chown ensures --user 1000 works without changing the default behaviour. --- docker/dist-arm/server.Dockerfile | 1 - docker/dist-mono/server.Dockerfile | 1 - 2 files changed, 2 deletions(-) diff --git a/docker/dist-arm/server.Dockerfile b/docker/dist-arm/server.Dockerfile index ad6c286fb..a522dabfa 100644 --- a/docker/dist-arm/server.Dockerfile +++ b/docker/dist-arm/server.Dockerfile @@ -44,7 +44,6 @@ RUN chown -R node:node ./public \ && chown -R node:node ./dist \ && chown -R node:node ./scripts -USER node EXPOSE 52345 diff --git a/docker/dist-mono/server.Dockerfile b/docker/dist-mono/server.Dockerfile index 860fd2032..3f14d8fc1 100644 --- a/docker/dist-mono/server.Dockerfile +++ b/docker/dist-mono/server.Dockerfile @@ -31,7 +31,6 @@ RUN chmod +x ./scripts/inject-vars.sh \ && chown -R node:node ./dist \ && chown -R node:node ./scripts -USER node EXPOSE 52345 From 63c1a60b06f971c07dbabdbb51cc0c9960dcf517 Mon Sep 17 00:00:00 2001 From: gorkem-bwl Date: Fri, 1 May 2026 18:01:44 -0400 Subject: [PATCH 046/245] refactor(infrastructure): move capture-form guidance to ConfigBox subtitle MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The previous commit added inline helperText under the URL and Secret fields, but no other form in the codebase uses helperText for static guidance — only for validation errors. The project's pattern for setup guidance is the ConfigBox subtitle text on the left side of the form, populated from pages.createMonitor.form.general.description.. Move the guidance there so this form follows the same convention as every other monitor type. - Remove urlHelperText / secretHelperText from GeneralSettingsConfig - Remove the new helperText props on the URL and Secret TextFields - Drop captureUrl.helperText and secret.helperText keys from all 16 locales - Beef up description.hardware to mention the /api/v1/metrics path requirement and the no-Bearer-prefix rule The realistic captureUrl placeholder (added in the previous commit) stays — it does most of the URL-disambiguation work on its own. --- client/src/Pages/CreateMonitor/index.tsx | 16 ++-------------- client/src/locales/ar.json | 8 +++----- client/src/locales/cs.json | 8 +++----- client/src/locales/de.json | 8 +++----- client/src/locales/en.json | 8 +++----- client/src/locales/es.json | 8 +++----- client/src/locales/fi.json | 8 +++----- client/src/locales/fr.json | 8 +++----- client/src/locales/ja.json | 8 +++----- client/src/locales/pt-BR.json | 8 +++----- client/src/locales/ru.json | 8 +++----- client/src/locales/th.json | 8 +++----- client/src/locales/tr.json | 8 +++----- client/src/locales/uk.json | 8 +++----- client/src/locales/vi.json | 8 +++----- client/src/locales/zh-CN.json | 8 +++----- client/src/locales/zh-TW.json | 8 +++----- 17 files changed, 50 insertions(+), 94 deletions(-) diff --git a/client/src/Pages/CreateMonitor/index.tsx b/client/src/Pages/CreateMonitor/index.tsx index 7bc828393..4ae518caa 100644 --- a/client/src/Pages/CreateMonitor/index.tsx +++ b/client/src/Pages/CreateMonitor/index.tsx @@ -44,13 +44,11 @@ import type { MonitorFormData } from "@/Validation/monitor"; interface GeneralSettingsConfig { urlLabel: string; urlPlaceholder: string; - urlHelperText?: string; namePlaceholder: string; showUrl: boolean; showPort: boolean; showGameSelect: boolean; showSecret: boolean; - secretHelperText?: string; showGrpcServiceName: boolean; showIgnoreTls: boolean; } @@ -140,13 +138,11 @@ const getGeneralSettingsConfig = ( hardware: { urlLabel: t("pages.createMonitor.form.general.option.captureUrl.label"), urlPlaceholder: t("pages.createMonitor.form.general.option.captureUrl.placeholder"), - urlHelperText: t("pages.createMonitor.form.general.option.captureUrl.helperText"), namePlaceholder: t("pages.createMonitor.form.general.option.name.placeholder"), showUrl: true, showPort: false, showGameSelect: false, showSecret: true, - secretHelperText: t("pages.createMonitor.form.general.option.secret.helperText"), showGrpcServiceName: false, showIgnoreTls: false, }, @@ -393,11 +389,7 @@ const CreateMonitorPage = () => { fullWidth disabled={isEditMode} error={!!fieldState.error} - helperText={ - fieldState.error?.message ?? - generalSettingsConfig.urlHelperText ?? - "" - } + helperText={fieldState.error?.message ?? ""} /> )} /> @@ -498,11 +490,7 @@ const CreateMonitorPage = () => { )} fullWidth error={!!fieldState.error} - helperText={ - fieldState.error?.message ?? - generalSettingsConfig.secretHelperText ?? - "" - } + helperText={fieldState.error?.message ?? ""} /> )} /> diff --git a/client/src/locales/ar.json b/client/src/locales/ar.json index 85e9d6b86..590b743d4 100644 --- a/client/src/locales/ar.json +++ b/client/src/locales/ar.json @@ -513,8 +513,7 @@ }, "secret": { "label": "مفتاح التفويض", - "placeholder": "أدخل مفتاحك السري", - "helperText": "Paste only the token string. Don't include 'Bearer' — it's added automatically." + "placeholder": "أدخل مفتاحك السري" }, "url": { "label": "URL", @@ -538,8 +537,7 @@ }, "captureUrl": { "label": "Capture endpoint", - "placeholder": "http://192.168.1.10:5001/api/v1/metrics", - "helperText": "The full Capture metrics endpoint, including the path. Example: http://192.168.1.10:5001/api/v1/metrics" + "placeholder": "http://192.168.1.10:5001/api/v1/metrics" } }, "title": "الإعدادات العامة", @@ -552,7 +550,7 @@ "pagespeed": "تتبع أداء تحميل الصفحة ومؤشرات Core Web Vitals ودرجات التحسين لموقعك.", "grpc": "أدخل اسم المضيف ومنفذ خادم gRPC، وحدد اختيارياً اسم خدمة لفحص الصحة، وأضف اسم عرض.", "websocket": "أدخل رابط WebSocket للمراقبة (مثال: wss://example.com/socket) وأضف اسم عرض.", - "hardware": "مراقبة استخدام CPU والذاكرة والقرص ودرجة الحرارة للبنية التحتية الخاصة بك." + "hardware": "Monitor CPU, memory, disk usage, and temperature for your infrastructure. The Capture endpoint URL must include the full path /api/v1/metrics (e.g. http://192.168.1.10:5001/api/v1/metrics). For the authorization secret, paste only the token string — don't include 'Bearer'." } }, "ignoreTls": { diff --git a/client/src/locales/cs.json b/client/src/locales/cs.json index eb9e0260a..8678b2e0e 100644 --- a/client/src/locales/cs.json +++ b/client/src/locales/cs.json @@ -513,8 +513,7 @@ }, "secret": { "label": "Autorizační tajný klíč", - "placeholder": "Zadejte svůj tajný klíč", - "helperText": "Paste only the token string. Don't include 'Bearer' — it's added automatically." + "placeholder": "Zadejte svůj tajný klíč" }, "url": { "label": "URL", @@ -538,8 +537,7 @@ }, "captureUrl": { "label": "Capture endpoint", - "placeholder": "http://192.168.1.10:5001/api/v1/metrics", - "helperText": "The full Capture metrics endpoint, including the path. Example: http://192.168.1.10:5001/api/v1/metrics" + "placeholder": "http://192.168.1.10:5001/api/v1/metrics" } }, "title": "Obecná nastavení", @@ -552,7 +550,7 @@ "pagespeed": "Sledujte výkon načítání stránky, metriky Core Web Vitals a hodnocení optimalizace vašeho webu.", "grpc": "Zadejte název hostitele a port gRPC serveru, volitelně zadejte název služby pro kontrolu stavu a přidejte zobrazovaný název.", "websocket": "Zadejte WebSocket URL ke sledování (např. wss://example.com/socket) a přidejte zobrazovaný název.", - "hardware": "Sledujte využití CPU, paměti, disku a teplotu vaší infrastruktury." + "hardware": "Monitor CPU, memory, disk usage, and temperature for your infrastructure. The Capture endpoint URL must include the full path /api/v1/metrics (e.g. http://192.168.1.10:5001/api/v1/metrics). For the authorization secret, paste only the token string — don't include 'Bearer'." } }, "ignoreTls": { diff --git a/client/src/locales/de.json b/client/src/locales/de.json index ecce58f67..e0d4a8ba6 100644 --- a/client/src/locales/de.json +++ b/client/src/locales/de.json @@ -513,8 +513,7 @@ }, "secret": { "label": "Autorisierungsschlüssel", - "placeholder": "Geben Sie Ihren geheimen Schlüssel ein", - "helperText": "Paste only the token string. Don't include 'Bearer' — it's added automatically." + "placeholder": "Geben Sie Ihren geheimen Schlüssel ein" }, "url": { "label": "URL", @@ -538,8 +537,7 @@ }, "captureUrl": { "label": "Capture endpoint", - "placeholder": "http://192.168.1.10:5001/api/v1/metrics", - "helperText": "The full Capture metrics endpoint, including the path. Example: http://192.168.1.10:5001/api/v1/metrics" + "placeholder": "http://192.168.1.10:5001/api/v1/metrics" } }, "title": "Allgemeine Einstellungen", @@ -552,7 +550,7 @@ "pagespeed": "Verfolgen Sie die Ladezeit, Core Web Vitals und Optimierungswerte Ihrer Website.", "grpc": "Geben Sie den Hostnamen und Port des gRPC-Servers ein, optional einen Dienstnamen für den Health-Check, und fügen Sie einen Anzeigenamen hinzu.", "websocket": "Geben Sie die zu überwachende WebSocket-URL ein (z. B. wss://example.com/socket) und fügen Sie einen Anzeigenamen hinzu.", - "hardware": "Überwachen Sie CPU-, Arbeitsspeicher-, Festplattenauslastung und Temperatur Ihrer Infrastruktur." + "hardware": "Monitor CPU, memory, disk usage, and temperature for your infrastructure. The Capture endpoint URL must include the full path /api/v1/metrics (e.g. http://192.168.1.10:5001/api/v1/metrics). For the authorization secret, paste only the token string — don't include 'Bearer'." } }, "ignoreTls": { diff --git a/client/src/locales/en.json b/client/src/locales/en.json index b1336a5dd..b5fe0c1cb 100644 --- a/client/src/locales/en.json +++ b/client/src/locales/en.json @@ -513,8 +513,7 @@ }, "secret": { "label": "Authorization secret", - "placeholder": "Enter your secret key", - "helperText": "Paste only the token string. Don't include 'Bearer' — it's added automatically." + "placeholder": "Enter your secret key" }, "url": { "label": "URL", @@ -538,8 +537,7 @@ }, "captureUrl": { "label": "Capture endpoint", - "placeholder": "http://192.168.1.10:5001/api/v1/metrics", - "helperText": "The full Capture metrics endpoint, including the path. Example: http://192.168.1.10:5001/api/v1/metrics" + "placeholder": "http://192.168.1.10:5001/api/v1/metrics" } }, "title": "General settings", @@ -552,7 +550,7 @@ "pagespeed": "Track page load performance, Core Web Vitals, and optimization scores for your website.", "grpc": "Enter the hostname and port of the gRPC server, optionally specify a service name for the health check, and add a display name.", "websocket": "Enter the WebSocket URL to monitor (e.g., wss://example.com/socket) and add a display name.", - "hardware": "Monitor CPU, memory, disk usage, and temperature for your infrastructure." + "hardware": "Monitor CPU, memory, disk usage, and temperature for your infrastructure. The Capture endpoint URL must include the full path /api/v1/metrics (e.g. http://192.168.1.10:5001/api/v1/metrics). For the authorization secret, paste only the token string — don't include 'Bearer'." } }, "ignoreTls": { diff --git a/client/src/locales/es.json b/client/src/locales/es.json index bdfd9e3ae..b4acab28e 100644 --- a/client/src/locales/es.json +++ b/client/src/locales/es.json @@ -520,8 +520,7 @@ }, "secret": { "label": "Secreto de autorización", - "placeholder": "Ingrese su clave secreta", - "helperText": "Paste only the token string. Don't include 'Bearer' — it's added automatically." + "placeholder": "Ingrese su clave secreta" }, "url": { "label": "URL", @@ -545,8 +544,7 @@ }, "captureUrl": { "label": "Capture endpoint", - "placeholder": "http://192.168.1.10:5001/api/v1/metrics", - "helperText": "The full Capture metrics endpoint, including the path. Example: http://192.168.1.10:5001/api/v1/metrics" + "placeholder": "http://192.168.1.10:5001/api/v1/metrics" } }, "title": "Configuración general", @@ -559,7 +557,7 @@ "pagespeed": "Rastree el rendimiento de carga de página, Core Web Vitals y puntuaciones de optimización de su sitio web.", "grpc": "Ingrese el nombre de host y puerto del servidor gRPC, opcionalmente especifique un nombre de servicio para la verificación de salud, y agregue un nombre para mostrar.", "websocket": "Ingrese la URL de WebSocket a monitorear (p. ej., wss://example.com/socket) y agregue un nombre para mostrar.", - "hardware": "Monitoree el uso de CPU, memoria, disco y temperatura de su infraestructura." + "hardware": "Monitor CPU, memory, disk usage, and temperature for your infrastructure. The Capture endpoint URL must include the full path /api/v1/metrics (e.g. http://192.168.1.10:5001/api/v1/metrics). For the authorization secret, paste only the token string — don't include 'Bearer'." } }, "ignoreTls": { diff --git a/client/src/locales/fi.json b/client/src/locales/fi.json index 834f869dd..b5ae80912 100644 --- a/client/src/locales/fi.json +++ b/client/src/locales/fi.json @@ -513,8 +513,7 @@ }, "secret": { "label": "Valtuutusavain", - "placeholder": "Syötä salausavaimesi", - "helperText": "Paste only the token string. Don't include 'Bearer' — it's added automatically." + "placeholder": "Syötä salausavaimesi" }, "url": { "label": "URL", @@ -538,8 +537,7 @@ }, "captureUrl": { "label": "Capture endpoint", - "placeholder": "http://192.168.1.10:5001/api/v1/metrics", - "helperText": "The full Capture metrics endpoint, including the path. Example: http://192.168.1.10:5001/api/v1/metrics" + "placeholder": "http://192.168.1.10:5001/api/v1/metrics" } }, "title": "Yleiset asetukset", @@ -552,7 +550,7 @@ "pagespeed": "Seuraa sivun latausnopeutta, Core Web Vitals -arvoja ja optimointipisteitä verkkosivustollasi.", "grpc": "Syötä gRPC-palvelimen isäntänimi ja portti, määritä halutessasi palvelun nimi terveystarkistusta varten ja lisää näyttönimi.", "websocket": "Syötä monitoroitava WebSocket URL (esim. wss://example.com/socket) ja lisää näyttönimi.", - "hardware": "Monitoroi infrastruktuurisi CPU:n, muistin ja levyn käyttöä sekä lämpötilaa." + "hardware": "Monitor CPU, memory, disk usage, and temperature for your infrastructure. The Capture endpoint URL must include the full path /api/v1/metrics (e.g. http://192.168.1.10:5001/api/v1/metrics). For the authorization secret, paste only the token string — don't include 'Bearer'." } }, "ignoreTls": { diff --git a/client/src/locales/fr.json b/client/src/locales/fr.json index b5ab31986..8a9da0ec3 100644 --- a/client/src/locales/fr.json +++ b/client/src/locales/fr.json @@ -513,8 +513,7 @@ }, "secret": { "label": "Secret d'autorisation", - "placeholder": "Saisissez votre clé secrète", - "helperText": "Paste only the token string. Don't include 'Bearer' — it's added automatically." + "placeholder": "Saisissez votre clé secrète" }, "url": { "label": "URL", @@ -538,8 +537,7 @@ }, "captureUrl": { "label": "Capture endpoint", - "placeholder": "http://192.168.1.10:5001/api/v1/metrics", - "helperText": "The full Capture metrics endpoint, including the path. Example: http://192.168.1.10:5001/api/v1/metrics" + "placeholder": "http://192.168.1.10:5001/api/v1/metrics" } }, "title": "Paramètres généraux", @@ -552,7 +550,7 @@ "pagespeed": "Suivez les performances de chargement, les Core Web Vitals et les scores d'optimisation de votre site web.", "grpc": "Saisissez le nom d'hôte et le port du serveur gRPC, spécifiez éventuellement un nom de service pour le bilan de santé, et ajoutez un nom d'affichage.", "websocket": "Saisissez l'URL WebSocket à surveiller (ex. wss://example.com/socket) et ajoutez un nom d'affichage.", - "hardware": "Surveillez l'utilisation du CPU, de la mémoire, du disque et la température de votre infrastructure." + "hardware": "Monitor CPU, memory, disk usage, and temperature for your infrastructure. The Capture endpoint URL must include the full path /api/v1/metrics (e.g. http://192.168.1.10:5001/api/v1/metrics). For the authorization secret, paste only the token string — don't include 'Bearer'." } }, "ignoreTls": { diff --git a/client/src/locales/ja.json b/client/src/locales/ja.json index 5ec727046..b17bdb4fe 100644 --- a/client/src/locales/ja.json +++ b/client/src/locales/ja.json @@ -513,8 +513,7 @@ }, "secret": { "label": "認証シークレット", - "placeholder": "シークレットキーを入力", - "helperText": "Paste only the token string. Don't include 'Bearer' — it's added automatically." + "placeholder": "シークレットキーを入力" }, "url": { "label": "URL", @@ -538,8 +537,7 @@ }, "captureUrl": { "label": "Capture endpoint", - "placeholder": "http://192.168.1.10:5001/api/v1/metrics", - "helperText": "The full Capture metrics endpoint, including the path. Example: http://192.168.1.10:5001/api/v1/metrics" + "placeholder": "http://192.168.1.10:5001/api/v1/metrics" } }, "title": "一般設定", @@ -552,7 +550,7 @@ "pagespeed": "Webサイトのページ読み込みパフォーマンス、Core Web Vitals、最適化スコアを追跡します。", "grpc": "gRPCサーバーのホスト名とポートを入力し、必要に応じてヘルスチェック用のサービス名を指定し、表示名を追加してください。", "websocket": "監視するWebSocket URLを入力し(例: wss://example.com/socket)、表示名を追加してください。", - "hardware": "インフラストラクチャのCPU、メモリ、ディスク使用量、温度を監視します。" + "hardware": "Monitor CPU, memory, disk usage, and temperature for your infrastructure. The Capture endpoint URL must include the full path /api/v1/metrics (e.g. http://192.168.1.10:5001/api/v1/metrics). For the authorization secret, paste only the token string — don't include 'Bearer'." } }, "ignoreTls": { diff --git a/client/src/locales/pt-BR.json b/client/src/locales/pt-BR.json index e5568897b..d579c5bb5 100644 --- a/client/src/locales/pt-BR.json +++ b/client/src/locales/pt-BR.json @@ -513,8 +513,7 @@ }, "secret": { "label": "Chave de autorização", - "placeholder": "Digite sua chave secreta", - "helperText": "Paste only the token string. Don't include 'Bearer' — it's added automatically." + "placeholder": "Digite sua chave secreta" }, "url": { "label": "URL", @@ -538,8 +537,7 @@ }, "captureUrl": { "label": "Capture endpoint", - "placeholder": "http://192.168.1.10:5001/api/v1/metrics", - "helperText": "The full Capture metrics endpoint, including the path. Example: http://192.168.1.10:5001/api/v1/metrics" + "placeholder": "http://192.168.1.10:5001/api/v1/metrics" } }, "title": "Configurações gerais", @@ -552,7 +550,7 @@ "pagespeed": "Acompanhe o desempenho de carregamento da página, Core Web Vitals e pontuações de otimização do seu site.", "grpc": "Digite o hostname e a porta do servidor gRPC, opcionalmente especifique um nome de serviço para a verificação de saúde e adicione um nome de exibição.", "websocket": "Digite a URL do WebSocket para monitorar (ex.: wss://exemplo.com/socket) e adicione um nome de exibição.", - "hardware": "Monitore o uso de CPU, memória, disco e temperatura da sua infraestrutura." + "hardware": "Monitor CPU, memory, disk usage, and temperature for your infrastructure. The Capture endpoint URL must include the full path /api/v1/metrics (e.g. http://192.168.1.10:5001/api/v1/metrics). For the authorization secret, paste only the token string — don't include 'Bearer'." } }, "ignoreTls": { diff --git a/client/src/locales/ru.json b/client/src/locales/ru.json index 8da92929a..81d2dc8ef 100644 --- a/client/src/locales/ru.json +++ b/client/src/locales/ru.json @@ -513,8 +513,7 @@ }, "secret": { "label": "Секрет авторизации", - "placeholder": "Введите Ваш секретный ключ", - "helperText": "Paste only the token string. Don't include 'Bearer' — it's added automatically." + "placeholder": "Введите Ваш секретный ключ" }, "url": { "label": "URL", @@ -538,8 +537,7 @@ }, "captureUrl": { "label": "Capture endpoint", - "placeholder": "http://192.168.1.10:5001/api/v1/metrics", - "helperText": "The full Capture metrics endpoint, including the path. Example: http://192.168.1.10:5001/api/v1/metrics" + "placeholder": "http://192.168.1.10:5001/api/v1/metrics" } }, "title": "Основные настройки", @@ -552,7 +550,7 @@ "pagespeed": "Отслеживайте производительность загрузки, Core Web Vitals и показатели оптимизации для Вашего сайта.", "grpc": "Введите имя хоста и порт gRPC-сервера, при необходимости укажите имя сервиса для проверки состояния и добавьте отображаемое имя.", "websocket": "Введите WebSocket URL для мониторинга (напр., wss://example.com/socket) и добавьте отображаемое имя.", - "hardware": "Мониторинг CPU, памяти, дисков и температуры Вашей инфраструктуры." + "hardware": "Monitor CPU, memory, disk usage, and temperature for your infrastructure. The Capture endpoint URL must include the full path /api/v1/metrics (e.g. http://192.168.1.10:5001/api/v1/metrics). For the authorization secret, paste only the token string — don't include 'Bearer'." } }, "ignoreTls": { diff --git a/client/src/locales/th.json b/client/src/locales/th.json index cfac73ae2..ee3b1da11 100644 --- a/client/src/locales/th.json +++ b/client/src/locales/th.json @@ -513,8 +513,7 @@ }, "secret": { "label": "รหัสลับสำหรับยืนยันตัวตน", - "placeholder": "กรอกรหัสลับของคุณ", - "helperText": "Paste only the token string. Don't include 'Bearer' — it's added automatically." + "placeholder": "กรอกรหัสลับของคุณ" }, "url": { "label": "URL", @@ -538,8 +537,7 @@ }, "captureUrl": { "label": "Capture endpoint", - "placeholder": "http://192.168.1.10:5001/api/v1/metrics", - "helperText": "The full Capture metrics endpoint, including the path. Example: http://192.168.1.10:5001/api/v1/metrics" + "placeholder": "http://192.168.1.10:5001/api/v1/metrics" } }, "title": "ตั้งค่าทั่วไป", @@ -552,7 +550,7 @@ "pagespeed": "ติดตามประสิทธิภาพการโหลดหน้า Core Web Vitals และคะแนนการเพิ่มประสิทธิภาพของเว็บไซต์", "grpc": "กรอกชื่อโฮสต์และพอร์ตของเซิร์ฟเวอร์ gRPC ระบุชื่อบริการสำหรับ health check (ไม่บังคับ) และตั้งชื่อที่แสดง", "websocket": "กรอก URL ของ WebSocket ที่ต้องการตรวจสอบ (เช่น wss://example.com/socket) และตั้งชื่อที่แสดง", - "hardware": "ตรวจสอบการใช้งาน CPU หน่วยความจำ ดิสก์ และอุณหภูมิของโครงสร้างพื้นฐาน" + "hardware": "Monitor CPU, memory, disk usage, and temperature for your infrastructure. The Capture endpoint URL must include the full path /api/v1/metrics (e.g. http://192.168.1.10:5001/api/v1/metrics). For the authorization secret, paste only the token string — don't include 'Bearer'." } }, "ignoreTls": { diff --git a/client/src/locales/tr.json b/client/src/locales/tr.json index 6a2acf120..b3be88ae5 100644 --- a/client/src/locales/tr.json +++ b/client/src/locales/tr.json @@ -513,8 +513,7 @@ }, "secret": { "label": "Yetkilendirme anahtarı", - "placeholder": "Gizli anahtarınızı girin", - "helperText": "Paste only the token string. Don't include 'Bearer' — it's added automatically." + "placeholder": "Gizli anahtarınızı girin" }, "url": { "label": "URL", @@ -538,8 +537,7 @@ }, "captureUrl": { "label": "Capture endpoint", - "placeholder": "http://192.168.1.10:5001/api/v1/metrics", - "helperText": "The full Capture metrics endpoint, including the path. Example: http://192.168.1.10:5001/api/v1/metrics" + "placeholder": "http://192.168.1.10:5001/api/v1/metrics" } }, "title": "Genel ayarlar", @@ -552,7 +550,7 @@ "pagespeed": "Web sitenizin sayfa yükleme performansını, Core Web Vitals metriklerini ve optimizasyon puanlarını takip edin.", "grpc": "gRPC sunucusunun ana bilgisayar adını ve portunu girin, isteğe bağlı olarak sağlık kontrolü için bir servis adı belirtin ve bir görünen ad ekleyin.", "websocket": "İzlenecek WebSocket URL'sini girin (örn. wss://example.com/socket) ve bir görünen ad ekleyin.", - "hardware": "Altyapınız için CPU, bellek, disk kullanımı ve sıcaklığı izleyin." + "hardware": "Monitor CPU, memory, disk usage, and temperature for your infrastructure. The Capture endpoint URL must include the full path /api/v1/metrics (e.g. http://192.168.1.10:5001/api/v1/metrics). For the authorization secret, paste only the token string — don't include 'Bearer'." } }, "ignoreTls": { diff --git a/client/src/locales/uk.json b/client/src/locales/uk.json index 0fbdad260..7aa795123 100644 --- a/client/src/locales/uk.json +++ b/client/src/locales/uk.json @@ -513,8 +513,7 @@ }, "secret": { "label": "Секрет авторизації", - "placeholder": "Введіть Ваш секретний ключ", - "helperText": "Paste only the token string. Don't include 'Bearer' — it's added automatically." + "placeholder": "Введіть Ваш секретний ключ" }, "url": { "label": "URL", @@ -538,8 +537,7 @@ }, "captureUrl": { "label": "Capture endpoint", - "placeholder": "http://192.168.1.10:5001/api/v1/metrics", - "helperText": "The full Capture metrics endpoint, including the path. Example: http://192.168.1.10:5001/api/v1/metrics" + "placeholder": "http://192.168.1.10:5001/api/v1/metrics" } }, "title": "Загальні налаштування", @@ -552,7 +550,7 @@ "pagespeed": "Відстежуйте швидкість завантаження сторінки, показники Core Web Vitals та оцінки оптимізації Вашого сайту.", "grpc": "Введіть ім'я хоста та порт gRPC-сервера, за бажанням вкажіть назву сервісу для перевірки стану та додайте відображуване ім'я.", "websocket": "Введіть WebSocket URL для моніторингу (напр., wss://example.com/socket) та додайте відображуване ім'я.", - "hardware": "Моніторинг CPU, пам'яті, використання диска та температури Вашої інфраструктури." + "hardware": "Monitor CPU, memory, disk usage, and temperature for your infrastructure. The Capture endpoint URL must include the full path /api/v1/metrics (e.g. http://192.168.1.10:5001/api/v1/metrics). For the authorization secret, paste only the token string — don't include 'Bearer'." } }, "ignoreTls": { diff --git a/client/src/locales/vi.json b/client/src/locales/vi.json index 017518e61..3a277ebda 100644 --- a/client/src/locales/vi.json +++ b/client/src/locales/vi.json @@ -513,8 +513,7 @@ }, "secret": { "label": "Khóa xác thực", - "placeholder": "Nhập khóa bí mật của bạn", - "helperText": "Paste only the token string. Don't include 'Bearer' — it's added automatically." + "placeholder": "Nhập khóa bí mật của bạn" }, "url": { "label": "URL", @@ -538,8 +537,7 @@ }, "captureUrl": { "label": "Capture endpoint", - "placeholder": "http://192.168.1.10:5001/api/v1/metrics", - "helperText": "The full Capture metrics endpoint, including the path. Example: http://192.168.1.10:5001/api/v1/metrics" + "placeholder": "http://192.168.1.10:5001/api/v1/metrics" } }, "title": "Cài đặt chung", @@ -552,7 +550,7 @@ "pagespeed": "Theo dõi hiệu suất tải trang, Core Web Vitals và điểm tối ưu hóa cho trang web của bạn.", "grpc": "Nhập tên máy chủ và cổng của máy chủ gRPC, tùy chọn chỉ định tên dịch vụ cho kiểm tra sức khỏe và thêm tên hiển thị.", "websocket": "Nhập URL WebSocket cần giám sát (vd: wss://example.com/socket) và thêm tên hiển thị.", - "hardware": "Giám sát CPU, bộ nhớ, dung lượng ổ đĩa và nhiệt độ cho hạ tầng của bạn." + "hardware": "Monitor CPU, memory, disk usage, and temperature for your infrastructure. The Capture endpoint URL must include the full path /api/v1/metrics (e.g. http://192.168.1.10:5001/api/v1/metrics). For the authorization secret, paste only the token string — don't include 'Bearer'." } }, "ignoreTls": { diff --git a/client/src/locales/zh-CN.json b/client/src/locales/zh-CN.json index 3e7d3cb2f..32211dc5c 100644 --- a/client/src/locales/zh-CN.json +++ b/client/src/locales/zh-CN.json @@ -513,8 +513,7 @@ }, "secret": { "label": "授权密钥", - "placeholder": "输入您的密钥", - "helperText": "Paste only the token string. Don't include 'Bearer' — it's added automatically." + "placeholder": "输入您的密钥" }, "url": { "label": "URL", @@ -538,8 +537,7 @@ }, "captureUrl": { "label": "Capture endpoint", - "placeholder": "http://192.168.1.10:5001/api/v1/metrics", - "helperText": "The full Capture metrics endpoint, including the path. Example: http://192.168.1.10:5001/api/v1/metrics" + "placeholder": "http://192.168.1.10:5001/api/v1/metrics" } }, "title": "常规设置", @@ -552,7 +550,7 @@ "pagespeed": "跟踪网站的页面加载性能、Core Web Vitals 和优化评分。", "grpc": "输入 gRPC 服务器的主机名和端口,可选指定健康检查的服务名称,并添加显示名称。", "websocket": "输入要监控的 WebSocket URL(例如 wss://example.com/socket),并添加显示名称。", - "hardware": "监控基础设施的 CPU、内存、磁盘使用率和温度。" + "hardware": "Monitor CPU, memory, disk usage, and temperature for your infrastructure. The Capture endpoint URL must include the full path /api/v1/metrics (e.g. http://192.168.1.10:5001/api/v1/metrics). For the authorization secret, paste only the token string — don't include 'Bearer'." } }, "ignoreTls": { diff --git a/client/src/locales/zh-TW.json b/client/src/locales/zh-TW.json index d3515f72d..5113a2cc9 100644 --- a/client/src/locales/zh-TW.json +++ b/client/src/locales/zh-TW.json @@ -513,8 +513,7 @@ }, "secret": { "label": "授權密鑰", - "placeholder": "輸入您的密鑰", - "helperText": "Paste only the token string. Don't include 'Bearer' — it's added automatically." + "placeholder": "輸入您的密鑰" }, "url": { "label": "URL", @@ -538,8 +537,7 @@ }, "captureUrl": { "label": "Capture endpoint", - "placeholder": "http://192.168.1.10:5001/api/v1/metrics", - "helperText": "The full Capture metrics endpoint, including the path. Example: http://192.168.1.10:5001/api/v1/metrics" + "placeholder": "http://192.168.1.10:5001/api/v1/metrics" } }, "title": "一般設定", @@ -552,7 +550,7 @@ "pagespeed": "追蹤您網站的頁面載入效能、Core Web Vitals 和最佳化分數。", "grpc": "輸入 gRPC 伺服器的主機名稱和連接埠,可選擇指定健康檢查的服務名稱,並新增一個顯示名稱。", "websocket": "輸入要監控的 WebSocket URL(例如 wss://example.com/socket),並新增一個顯示名稱。", - "hardware": "監控您基礎架構的 CPU、記憶體、磁碟使用量和溫度。" + "hardware": "Monitor CPU, memory, disk usage, and temperature for your infrastructure. The Capture endpoint URL must include the full path /api/v1/metrics (e.g. http://192.168.1.10:5001/api/v1/metrics). For the authorization secret, paste only the token string — don't include 'Bearer'." } }, "ignoreTls": { From 13b13b681825f8da310e1fe1fb8482ef4bff1c0a Mon Sep 17 00:00:00 2001 From: gorkem-bwl Date: Fri, 1 May 2026 21:51:31 -0400 Subject: [PATCH 047/245] i18n: stop clobbering translated hardware description The previous commit overwrote description.hardware with English in 15 non-English locales. Restore each locale's existing translation; only the en.json copy carries the new path/Bearer guidance. Translators will pick up the English text on the next PoEditor sync. --- client/src/locales/ar.json | 2 +- client/src/locales/cs.json | 2 +- client/src/locales/de.json | 2 +- client/src/locales/es.json | 2 +- client/src/locales/fi.json | 2 +- client/src/locales/fr.json | 2 +- client/src/locales/ja.json | 2 +- client/src/locales/pt-BR.json | 2 +- client/src/locales/ru.json | 2 +- client/src/locales/th.json | 2 +- client/src/locales/tr.json | 2 +- client/src/locales/uk.json | 2 +- client/src/locales/vi.json | 2 +- client/src/locales/zh-CN.json | 2 +- client/src/locales/zh-TW.json | 2 +- 15 files changed, 15 insertions(+), 15 deletions(-) diff --git a/client/src/locales/ar.json b/client/src/locales/ar.json index 590b743d4..33b55598d 100644 --- a/client/src/locales/ar.json +++ b/client/src/locales/ar.json @@ -550,7 +550,7 @@ "pagespeed": "تتبع أداء تحميل الصفحة ومؤشرات Core Web Vitals ودرجات التحسين لموقعك.", "grpc": "أدخل اسم المضيف ومنفذ خادم gRPC، وحدد اختيارياً اسم خدمة لفحص الصحة، وأضف اسم عرض.", "websocket": "أدخل رابط WebSocket للمراقبة (مثال: wss://example.com/socket) وأضف اسم عرض.", - "hardware": "Monitor CPU, memory, disk usage, and temperature for your infrastructure. The Capture endpoint URL must include the full path /api/v1/metrics (e.g. http://192.168.1.10:5001/api/v1/metrics). For the authorization secret, paste only the token string — don't include 'Bearer'." + "hardware": "مراقبة استخدام CPU والذاكرة والقرص ودرجة الحرارة للبنية التحتية الخاصة بك." } }, "ignoreTls": { diff --git a/client/src/locales/cs.json b/client/src/locales/cs.json index 8678b2e0e..19366984d 100644 --- a/client/src/locales/cs.json +++ b/client/src/locales/cs.json @@ -550,7 +550,7 @@ "pagespeed": "Sledujte výkon načítání stránky, metriky Core Web Vitals a hodnocení optimalizace vašeho webu.", "grpc": "Zadejte název hostitele a port gRPC serveru, volitelně zadejte název služby pro kontrolu stavu a přidejte zobrazovaný název.", "websocket": "Zadejte WebSocket URL ke sledování (např. wss://example.com/socket) a přidejte zobrazovaný název.", - "hardware": "Monitor CPU, memory, disk usage, and temperature for your infrastructure. The Capture endpoint URL must include the full path /api/v1/metrics (e.g. http://192.168.1.10:5001/api/v1/metrics). For the authorization secret, paste only the token string — don't include 'Bearer'." + "hardware": "Sledujte využití CPU, paměti, disku a teplotu vaší infrastruktury." } }, "ignoreTls": { diff --git a/client/src/locales/de.json b/client/src/locales/de.json index e0d4a8ba6..c0d9ca0ec 100644 --- a/client/src/locales/de.json +++ b/client/src/locales/de.json @@ -550,7 +550,7 @@ "pagespeed": "Verfolgen Sie die Ladezeit, Core Web Vitals und Optimierungswerte Ihrer Website.", "grpc": "Geben Sie den Hostnamen und Port des gRPC-Servers ein, optional einen Dienstnamen für den Health-Check, und fügen Sie einen Anzeigenamen hinzu.", "websocket": "Geben Sie die zu überwachende WebSocket-URL ein (z. B. wss://example.com/socket) und fügen Sie einen Anzeigenamen hinzu.", - "hardware": "Monitor CPU, memory, disk usage, and temperature for your infrastructure. The Capture endpoint URL must include the full path /api/v1/metrics (e.g. http://192.168.1.10:5001/api/v1/metrics). For the authorization secret, paste only the token string — don't include 'Bearer'." + "hardware": "Überwachen Sie CPU-, Arbeitsspeicher-, Festplattenauslastung und Temperatur Ihrer Infrastruktur." } }, "ignoreTls": { diff --git a/client/src/locales/es.json b/client/src/locales/es.json index b4acab28e..5a2515a6a 100644 --- a/client/src/locales/es.json +++ b/client/src/locales/es.json @@ -557,7 +557,7 @@ "pagespeed": "Rastree el rendimiento de carga de página, Core Web Vitals y puntuaciones de optimización de su sitio web.", "grpc": "Ingrese el nombre de host y puerto del servidor gRPC, opcionalmente especifique un nombre de servicio para la verificación de salud, y agregue un nombre para mostrar.", "websocket": "Ingrese la URL de WebSocket a monitorear (p. ej., wss://example.com/socket) y agregue un nombre para mostrar.", - "hardware": "Monitor CPU, memory, disk usage, and temperature for your infrastructure. The Capture endpoint URL must include the full path /api/v1/metrics (e.g. http://192.168.1.10:5001/api/v1/metrics). For the authorization secret, paste only the token string — don't include 'Bearer'." + "hardware": "Monitoree el uso de CPU, memoria, disco y temperatura de su infraestructura." } }, "ignoreTls": { diff --git a/client/src/locales/fi.json b/client/src/locales/fi.json index b5ae80912..fb4172136 100644 --- a/client/src/locales/fi.json +++ b/client/src/locales/fi.json @@ -550,7 +550,7 @@ "pagespeed": "Seuraa sivun latausnopeutta, Core Web Vitals -arvoja ja optimointipisteitä verkkosivustollasi.", "grpc": "Syötä gRPC-palvelimen isäntänimi ja portti, määritä halutessasi palvelun nimi terveystarkistusta varten ja lisää näyttönimi.", "websocket": "Syötä monitoroitava WebSocket URL (esim. wss://example.com/socket) ja lisää näyttönimi.", - "hardware": "Monitor CPU, memory, disk usage, and temperature for your infrastructure. The Capture endpoint URL must include the full path /api/v1/metrics (e.g. http://192.168.1.10:5001/api/v1/metrics). For the authorization secret, paste only the token string — don't include 'Bearer'." + "hardware": "Monitoroi infrastruktuurisi CPU:n, muistin ja levyn käyttöä sekä lämpötilaa." } }, "ignoreTls": { diff --git a/client/src/locales/fr.json b/client/src/locales/fr.json index 8a9da0ec3..97f0011cf 100644 --- a/client/src/locales/fr.json +++ b/client/src/locales/fr.json @@ -550,7 +550,7 @@ "pagespeed": "Suivez les performances de chargement, les Core Web Vitals et les scores d'optimisation de votre site web.", "grpc": "Saisissez le nom d'hôte et le port du serveur gRPC, spécifiez éventuellement un nom de service pour le bilan de santé, et ajoutez un nom d'affichage.", "websocket": "Saisissez l'URL WebSocket à surveiller (ex. wss://example.com/socket) et ajoutez un nom d'affichage.", - "hardware": "Monitor CPU, memory, disk usage, and temperature for your infrastructure. The Capture endpoint URL must include the full path /api/v1/metrics (e.g. http://192.168.1.10:5001/api/v1/metrics). For the authorization secret, paste only the token string — don't include 'Bearer'." + "hardware": "Surveillez l'utilisation du CPU, de la mémoire, du disque et la température de votre infrastructure." } }, "ignoreTls": { diff --git a/client/src/locales/ja.json b/client/src/locales/ja.json index b17bdb4fe..649b36aed 100644 --- a/client/src/locales/ja.json +++ b/client/src/locales/ja.json @@ -550,7 +550,7 @@ "pagespeed": "Webサイトのページ読み込みパフォーマンス、Core Web Vitals、最適化スコアを追跡します。", "grpc": "gRPCサーバーのホスト名とポートを入力し、必要に応じてヘルスチェック用のサービス名を指定し、表示名を追加してください。", "websocket": "監視するWebSocket URLを入力し(例: wss://example.com/socket)、表示名を追加してください。", - "hardware": "Monitor CPU, memory, disk usage, and temperature for your infrastructure. The Capture endpoint URL must include the full path /api/v1/metrics (e.g. http://192.168.1.10:5001/api/v1/metrics). For the authorization secret, paste only the token string — don't include 'Bearer'." + "hardware": "インフラストラクチャのCPU、メモリ、ディスク使用量、温度を監視します。" } }, "ignoreTls": { diff --git a/client/src/locales/pt-BR.json b/client/src/locales/pt-BR.json index d579c5bb5..42c3e8e2c 100644 --- a/client/src/locales/pt-BR.json +++ b/client/src/locales/pt-BR.json @@ -550,7 +550,7 @@ "pagespeed": "Acompanhe o desempenho de carregamento da página, Core Web Vitals e pontuações de otimização do seu site.", "grpc": "Digite o hostname e a porta do servidor gRPC, opcionalmente especifique um nome de serviço para a verificação de saúde e adicione um nome de exibição.", "websocket": "Digite a URL do WebSocket para monitorar (ex.: wss://exemplo.com/socket) e adicione um nome de exibição.", - "hardware": "Monitor CPU, memory, disk usage, and temperature for your infrastructure. The Capture endpoint URL must include the full path /api/v1/metrics (e.g. http://192.168.1.10:5001/api/v1/metrics). For the authorization secret, paste only the token string — don't include 'Bearer'." + "hardware": "Monitore o uso de CPU, memória, disco e temperatura da sua infraestrutura." } }, "ignoreTls": { diff --git a/client/src/locales/ru.json b/client/src/locales/ru.json index 81d2dc8ef..88eb95471 100644 --- a/client/src/locales/ru.json +++ b/client/src/locales/ru.json @@ -550,7 +550,7 @@ "pagespeed": "Отслеживайте производительность загрузки, Core Web Vitals и показатели оптимизации для Вашего сайта.", "grpc": "Введите имя хоста и порт gRPC-сервера, при необходимости укажите имя сервиса для проверки состояния и добавьте отображаемое имя.", "websocket": "Введите WebSocket URL для мониторинга (напр., wss://example.com/socket) и добавьте отображаемое имя.", - "hardware": "Monitor CPU, memory, disk usage, and temperature for your infrastructure. The Capture endpoint URL must include the full path /api/v1/metrics (e.g. http://192.168.1.10:5001/api/v1/metrics). For the authorization secret, paste only the token string — don't include 'Bearer'." + "hardware": "Мониторинг CPU, памяти, дисков и температуры Вашей инфраструктуры." } }, "ignoreTls": { diff --git a/client/src/locales/th.json b/client/src/locales/th.json index ee3b1da11..6dbe6d1ae 100644 --- a/client/src/locales/th.json +++ b/client/src/locales/th.json @@ -550,7 +550,7 @@ "pagespeed": "ติดตามประสิทธิภาพการโหลดหน้า Core Web Vitals และคะแนนการเพิ่มประสิทธิภาพของเว็บไซต์", "grpc": "กรอกชื่อโฮสต์และพอร์ตของเซิร์ฟเวอร์ gRPC ระบุชื่อบริการสำหรับ health check (ไม่บังคับ) และตั้งชื่อที่แสดง", "websocket": "กรอก URL ของ WebSocket ที่ต้องการตรวจสอบ (เช่น wss://example.com/socket) และตั้งชื่อที่แสดง", - "hardware": "Monitor CPU, memory, disk usage, and temperature for your infrastructure. The Capture endpoint URL must include the full path /api/v1/metrics (e.g. http://192.168.1.10:5001/api/v1/metrics). For the authorization secret, paste only the token string — don't include 'Bearer'." + "hardware": "ตรวจสอบการใช้งาน CPU หน่วยความจำ ดิสก์ และอุณหภูมิของโครงสร้างพื้นฐาน" } }, "ignoreTls": { diff --git a/client/src/locales/tr.json b/client/src/locales/tr.json index b3be88ae5..f003e372d 100644 --- a/client/src/locales/tr.json +++ b/client/src/locales/tr.json @@ -550,7 +550,7 @@ "pagespeed": "Web sitenizin sayfa yükleme performansını, Core Web Vitals metriklerini ve optimizasyon puanlarını takip edin.", "grpc": "gRPC sunucusunun ana bilgisayar adını ve portunu girin, isteğe bağlı olarak sağlık kontrolü için bir servis adı belirtin ve bir görünen ad ekleyin.", "websocket": "İzlenecek WebSocket URL'sini girin (örn. wss://example.com/socket) ve bir görünen ad ekleyin.", - "hardware": "Monitor CPU, memory, disk usage, and temperature for your infrastructure. The Capture endpoint URL must include the full path /api/v1/metrics (e.g. http://192.168.1.10:5001/api/v1/metrics). For the authorization secret, paste only the token string — don't include 'Bearer'." + "hardware": "Altyapınız için CPU, bellek, disk kullanımı ve sıcaklığı izleyin." } }, "ignoreTls": { diff --git a/client/src/locales/uk.json b/client/src/locales/uk.json index 7aa795123..4b363b592 100644 --- a/client/src/locales/uk.json +++ b/client/src/locales/uk.json @@ -550,7 +550,7 @@ "pagespeed": "Відстежуйте швидкість завантаження сторінки, показники Core Web Vitals та оцінки оптимізації Вашого сайту.", "grpc": "Введіть ім'я хоста та порт gRPC-сервера, за бажанням вкажіть назву сервісу для перевірки стану та додайте відображуване ім'я.", "websocket": "Введіть WebSocket URL для моніторингу (напр., wss://example.com/socket) та додайте відображуване ім'я.", - "hardware": "Monitor CPU, memory, disk usage, and temperature for your infrastructure. The Capture endpoint URL must include the full path /api/v1/metrics (e.g. http://192.168.1.10:5001/api/v1/metrics). For the authorization secret, paste only the token string — don't include 'Bearer'." + "hardware": "Моніторинг CPU, пам'яті, використання диска та температури Вашої інфраструктури." } }, "ignoreTls": { diff --git a/client/src/locales/vi.json b/client/src/locales/vi.json index 3a277ebda..7948083af 100644 --- a/client/src/locales/vi.json +++ b/client/src/locales/vi.json @@ -550,7 +550,7 @@ "pagespeed": "Theo dõi hiệu suất tải trang, Core Web Vitals và điểm tối ưu hóa cho trang web của bạn.", "grpc": "Nhập tên máy chủ và cổng của máy chủ gRPC, tùy chọn chỉ định tên dịch vụ cho kiểm tra sức khỏe và thêm tên hiển thị.", "websocket": "Nhập URL WebSocket cần giám sát (vd: wss://example.com/socket) và thêm tên hiển thị.", - "hardware": "Monitor CPU, memory, disk usage, and temperature for your infrastructure. The Capture endpoint URL must include the full path /api/v1/metrics (e.g. http://192.168.1.10:5001/api/v1/metrics). For the authorization secret, paste only the token string — don't include 'Bearer'." + "hardware": "Giám sát CPU, bộ nhớ, dung lượng ổ đĩa và nhiệt độ cho hạ tầng của bạn." } }, "ignoreTls": { diff --git a/client/src/locales/zh-CN.json b/client/src/locales/zh-CN.json index 32211dc5c..9a440f3bd 100644 --- a/client/src/locales/zh-CN.json +++ b/client/src/locales/zh-CN.json @@ -550,7 +550,7 @@ "pagespeed": "跟踪网站的页面加载性能、Core Web Vitals 和优化评分。", "grpc": "输入 gRPC 服务器的主机名和端口,可选指定健康检查的服务名称,并添加显示名称。", "websocket": "输入要监控的 WebSocket URL(例如 wss://example.com/socket),并添加显示名称。", - "hardware": "Monitor CPU, memory, disk usage, and temperature for your infrastructure. The Capture endpoint URL must include the full path /api/v1/metrics (e.g. http://192.168.1.10:5001/api/v1/metrics). For the authorization secret, paste only the token string — don't include 'Bearer'." + "hardware": "监控基础设施的 CPU、内存、磁盘使用率和温度。" } }, "ignoreTls": { diff --git a/client/src/locales/zh-TW.json b/client/src/locales/zh-TW.json index 5113a2cc9..18498e1a9 100644 --- a/client/src/locales/zh-TW.json +++ b/client/src/locales/zh-TW.json @@ -550,7 +550,7 @@ "pagespeed": "追蹤您網站的頁面載入效能、Core Web Vitals 和最佳化分數。", "grpc": "輸入 gRPC 伺服器的主機名稱和連接埠,可選擇指定健康檢查的服務名稱,並新增一個顯示名稱。", "websocket": "輸入要監控的 WebSocket URL(例如 wss://example.com/socket),並新增一個顯示名稱。", - "hardware": "Monitor CPU, memory, disk usage, and temperature for your infrastructure. The Capture endpoint URL must include the full path /api/v1/metrics (e.g. http://192.168.1.10:5001/api/v1/metrics). For the authorization secret, paste only the token string — don't include 'Bearer'." + "hardware": "監控您基礎架構的 CPU、記憶體、磁碟使用量和溫度。" } }, "ignoreTls": { From cb285bbb6601497a0ac5ba2e30dca9432147db2b Mon Sep 17 00:00:00 2001 From: gorkem-bwl Date: Fri, 1 May 2026 22:06:03 -0400 Subject: [PATCH 048/245] i18n: machine-translate new captureUrl key and hardware description Translates captureUrl.label ("Capture endpoint") and the new long description.hardware copy into all 15 non-English locales (ar, cs, de, es, fi, fr, ja, pt-BR, ru, th, tr, uk, vi, zh-CN, zh-TW). The literal URL example and the token name "Bearer" are kept untranslated. --- client/src/locales/ar.json | 4 ++-- client/src/locales/cs.json | 4 ++-- client/src/locales/de.json | 4 ++-- client/src/locales/es.json | 4 ++-- client/src/locales/fi.json | 4 ++-- client/src/locales/fr.json | 4 ++-- client/src/locales/ja.json | 4 ++-- client/src/locales/pt-BR.json | 4 ++-- client/src/locales/ru.json | 4 ++-- client/src/locales/th.json | 4 ++-- client/src/locales/tr.json | 4 ++-- client/src/locales/uk.json | 4 ++-- client/src/locales/vi.json | 4 ++-- client/src/locales/zh-CN.json | 4 ++-- client/src/locales/zh-TW.json | 4 ++-- 15 files changed, 30 insertions(+), 30 deletions(-) diff --git a/client/src/locales/ar.json b/client/src/locales/ar.json index 33b55598d..0c4169c25 100644 --- a/client/src/locales/ar.json +++ b/client/src/locales/ar.json @@ -536,7 +536,7 @@ "placeholder": "wss://example.com/socket" }, "captureUrl": { - "label": "Capture endpoint", + "label": "نقطة نهاية Capture", "placeholder": "http://192.168.1.10:5001/api/v1/metrics" } }, @@ -550,7 +550,7 @@ "pagespeed": "تتبع أداء تحميل الصفحة ومؤشرات Core Web Vitals ودرجات التحسين لموقعك.", "grpc": "أدخل اسم المضيف ومنفذ خادم gRPC، وحدد اختيارياً اسم خدمة لفحص الصحة، وأضف اسم عرض.", "websocket": "أدخل رابط WebSocket للمراقبة (مثال: wss://example.com/socket) وأضف اسم عرض.", - "hardware": "مراقبة استخدام CPU والذاكرة والقرص ودرجة الحرارة للبنية التحتية الخاصة بك." + "hardware": "راقب استخدام وحدة المعالجة المركزية والذاكرة والقرص ودرجة الحرارة لبنيتك التحتية. يجب أن يتضمن عنوان URL لنقطة نهاية Capture المسار الكامل ‎/api/v1/metrics‎ (مثال: ‎http://192.168.1.10:5001/api/v1/metrics‎). بالنسبة لمفتاح التفويض، الصق سلسلة الرمز فقط — لا تُضمّن كلمة 'Bearer'." } }, "ignoreTls": { diff --git a/client/src/locales/cs.json b/client/src/locales/cs.json index 19366984d..fd2421495 100644 --- a/client/src/locales/cs.json +++ b/client/src/locales/cs.json @@ -536,7 +536,7 @@ "placeholder": "wss://example.com/socket" }, "captureUrl": { - "label": "Capture endpoint", + "label": "Endpoint Capture", "placeholder": "http://192.168.1.10:5001/api/v1/metrics" } }, @@ -550,7 +550,7 @@ "pagespeed": "Sledujte výkon načítání stránky, metriky Core Web Vitals a hodnocení optimalizace vašeho webu.", "grpc": "Zadejte název hostitele a port gRPC serveru, volitelně zadejte název služby pro kontrolu stavu a přidejte zobrazovaný název.", "websocket": "Zadejte WebSocket URL ke sledování (např. wss://example.com/socket) a přidejte zobrazovaný název.", - "hardware": "Sledujte využití CPU, paměti, disku a teplotu vaší infrastruktury." + "hardware": "Sledujte využití CPU, paměti, disku a teplotu vaší infrastruktury. URL endpointu Capture musí obsahovat celou cestu /api/v1/metrics (např. http://192.168.1.10:5001/api/v1/metrics). Do autorizačního klíče vložte pouze řetězec tokenu — nezahrnujte 'Bearer'." } }, "ignoreTls": { diff --git a/client/src/locales/de.json b/client/src/locales/de.json index c0d9ca0ec..1602610e0 100644 --- a/client/src/locales/de.json +++ b/client/src/locales/de.json @@ -536,7 +536,7 @@ "placeholder": "wss://example.com/socket" }, "captureUrl": { - "label": "Capture endpoint", + "label": "Capture-Endpunkt", "placeholder": "http://192.168.1.10:5001/api/v1/metrics" } }, @@ -550,7 +550,7 @@ "pagespeed": "Verfolgen Sie die Ladezeit, Core Web Vitals und Optimierungswerte Ihrer Website.", "grpc": "Geben Sie den Hostnamen und Port des gRPC-Servers ein, optional einen Dienstnamen für den Health-Check, und fügen Sie einen Anzeigenamen hinzu.", "websocket": "Geben Sie die zu überwachende WebSocket-URL ein (z. B. wss://example.com/socket) und fügen Sie einen Anzeigenamen hinzu.", - "hardware": "Überwachen Sie CPU-, Arbeitsspeicher-, Festplattenauslastung und Temperatur Ihrer Infrastruktur." + "hardware": "Überwachen Sie CPU-, Speicher-, Festplattenauslastung und Temperatur Ihrer Infrastruktur. Die URL des Capture-Endpunkts muss den vollständigen Pfad /api/v1/metrics enthalten (z. B. http://192.168.1.10:5001/api/v1/metrics). Fügen Sie für das Autorisierungsgeheimnis nur den Token-String ein — geben Sie 'Bearer' nicht mit an." } }, "ignoreTls": { diff --git a/client/src/locales/es.json b/client/src/locales/es.json index 5a2515a6a..b359a3d15 100644 --- a/client/src/locales/es.json +++ b/client/src/locales/es.json @@ -543,7 +543,7 @@ "placeholder": "wss://example.com/socket" }, "captureUrl": { - "label": "Capture endpoint", + "label": "Endpoint de Capture", "placeholder": "http://192.168.1.10:5001/api/v1/metrics" } }, @@ -557,7 +557,7 @@ "pagespeed": "Rastree el rendimiento de carga de página, Core Web Vitals y puntuaciones de optimización de su sitio web.", "grpc": "Ingrese el nombre de host y puerto del servidor gRPC, opcionalmente especifique un nombre de servicio para la verificación de salud, y agregue un nombre para mostrar.", "websocket": "Ingrese la URL de WebSocket a monitorear (p. ej., wss://example.com/socket) y agregue un nombre para mostrar.", - "hardware": "Monitoree el uso de CPU, memoria, disco y temperatura de su infraestructura." + "hardware": "Supervisa el uso de CPU, memoria, disco y temperatura de tu infraestructura. La URL del endpoint de Capture debe incluir la ruta completa /api/v1/metrics (p. ej., http://192.168.1.10:5001/api/v1/metrics). Para el secreto de autorización, pega solo la cadena del token — no incluyas 'Bearer'." } }, "ignoreTls": { diff --git a/client/src/locales/fi.json b/client/src/locales/fi.json index fb4172136..b66aebb18 100644 --- a/client/src/locales/fi.json +++ b/client/src/locales/fi.json @@ -536,7 +536,7 @@ "placeholder": "wss://example.com/socket" }, "captureUrl": { - "label": "Capture endpoint", + "label": "Capture-päätepiste", "placeholder": "http://192.168.1.10:5001/api/v1/metrics" } }, @@ -550,7 +550,7 @@ "pagespeed": "Seuraa sivun latausnopeutta, Core Web Vitals -arvoja ja optimointipisteitä verkkosivustollasi.", "grpc": "Syötä gRPC-palvelimen isäntänimi ja portti, määritä halutessasi palvelun nimi terveystarkistusta varten ja lisää näyttönimi.", "websocket": "Syötä monitoroitava WebSocket URL (esim. wss://example.com/socket) ja lisää näyttönimi.", - "hardware": "Monitoroi infrastruktuurisi CPU:n, muistin ja levyn käyttöä sekä lämpötilaa." + "hardware": "Seuraa infrastruktuurisi suorittimen, muistin, levyn käyttöä ja lämpötilaa. Capture-päätepisteen URL-osoitteen on sisällettävä koko polku /api/v1/metrics (esim. http://192.168.1.10:5001/api/v1/metrics). Liitä valtuutusavaimeen vain tokenin merkkijono — älä sisällytä sanaa 'Bearer'." } }, "ignoreTls": { diff --git a/client/src/locales/fr.json b/client/src/locales/fr.json index 97f0011cf..8f7057e61 100644 --- a/client/src/locales/fr.json +++ b/client/src/locales/fr.json @@ -536,7 +536,7 @@ "placeholder": "wss://example.com/socket" }, "captureUrl": { - "label": "Capture endpoint", + "label": "Point de terminaison Capture", "placeholder": "http://192.168.1.10:5001/api/v1/metrics" } }, @@ -550,7 +550,7 @@ "pagespeed": "Suivez les performances de chargement, les Core Web Vitals et les scores d'optimisation de votre site web.", "grpc": "Saisissez le nom d'hôte et le port du serveur gRPC, spécifiez éventuellement un nom de service pour le bilan de santé, et ajoutez un nom d'affichage.", "websocket": "Saisissez l'URL WebSocket à surveiller (ex. wss://example.com/socket) et ajoutez un nom d'affichage.", - "hardware": "Surveillez l'utilisation du CPU, de la mémoire, du disque et la température de votre infrastructure." + "hardware": "Surveillez l'utilisation du processeur, de la mémoire, du disque et la température de votre infrastructure. L'URL du point de terminaison Capture doit inclure le chemin complet /api/v1/metrics (par ex. http://192.168.1.10:5001/api/v1/metrics). Pour le secret d'autorisation, collez uniquement la chaîne du jeton — n'incluez pas 'Bearer'." } }, "ignoreTls": { diff --git a/client/src/locales/ja.json b/client/src/locales/ja.json index 649b36aed..1df620670 100644 --- a/client/src/locales/ja.json +++ b/client/src/locales/ja.json @@ -536,7 +536,7 @@ "placeholder": "wss://example.com/socket" }, "captureUrl": { - "label": "Capture endpoint", + "label": "Capture エンドポイント", "placeholder": "http://192.168.1.10:5001/api/v1/metrics" } }, @@ -550,7 +550,7 @@ "pagespeed": "Webサイトのページ読み込みパフォーマンス、Core Web Vitals、最適化スコアを追跡します。", "grpc": "gRPCサーバーのホスト名とポートを入力し、必要に応じてヘルスチェック用のサービス名を指定し、表示名を追加してください。", "websocket": "監視するWebSocket URLを入力し(例: wss://example.com/socket)、表示名を追加してください。", - "hardware": "インフラストラクチャのCPU、メモリ、ディスク使用量、温度を監視します。" + "hardware": "インフラの CPU、メモリ、ディスク使用率、温度を監視します。Capture エンドポイント URL には完全なパス /api/v1/metrics を含める必要があります (例: http://192.168.1.10:5001/api/v1/metrics)。認証シークレットにはトークン文字列のみを貼り付けてください — 'Bearer' は含めないでください。" } }, "ignoreTls": { diff --git a/client/src/locales/pt-BR.json b/client/src/locales/pt-BR.json index 42c3e8e2c..72e687bb8 100644 --- a/client/src/locales/pt-BR.json +++ b/client/src/locales/pt-BR.json @@ -536,7 +536,7 @@ "placeholder": "wss://exemplo.com/socket" }, "captureUrl": { - "label": "Capture endpoint", + "label": "Endpoint do Capture", "placeholder": "http://192.168.1.10:5001/api/v1/metrics" } }, @@ -550,7 +550,7 @@ "pagespeed": "Acompanhe o desempenho de carregamento da página, Core Web Vitals e pontuações de otimização do seu site.", "grpc": "Digite o hostname e a porta do servidor gRPC, opcionalmente especifique um nome de serviço para a verificação de saúde e adicione um nome de exibição.", "websocket": "Digite a URL do WebSocket para monitorar (ex.: wss://exemplo.com/socket) e adicione um nome de exibição.", - "hardware": "Monitore o uso de CPU, memória, disco e temperatura da sua infraestrutura." + "hardware": "Monitore o uso de CPU, memória, disco e temperatura da sua infraestrutura. A URL do endpoint do Capture deve incluir o caminho completo /api/v1/metrics (por exemplo, http://192.168.1.10:5001/api/v1/metrics). Para a chave de autorização, cole apenas a string do token — não inclua 'Bearer'." } }, "ignoreTls": { diff --git a/client/src/locales/ru.json b/client/src/locales/ru.json index 88eb95471..775f5ff3b 100644 --- a/client/src/locales/ru.json +++ b/client/src/locales/ru.json @@ -536,7 +536,7 @@ "placeholder": "wss://example.com/socket" }, "captureUrl": { - "label": "Capture endpoint", + "label": "Эндпоинт Capture", "placeholder": "http://192.168.1.10:5001/api/v1/metrics" } }, @@ -550,7 +550,7 @@ "pagespeed": "Отслеживайте производительность загрузки, Core Web Vitals и показатели оптимизации для Вашего сайта.", "grpc": "Введите имя хоста и порт gRPC-сервера, при необходимости укажите имя сервиса для проверки состояния и добавьте отображаемое имя.", "websocket": "Введите WebSocket URL для мониторинга (напр., wss://example.com/socket) и добавьте отображаемое имя.", - "hardware": "Мониторинг CPU, памяти, дисков и температуры Вашей инфраструктуры." + "hardware": "Отслеживайте использование ЦП, памяти, диска и температуру вашей инфраструктуры. URL эндпоинта Capture должен включать полный путь /api/v1/metrics (например, http://192.168.1.10:5001/api/v1/metrics). В секрете авторизации укажите только строку токена — не включайте 'Bearer'." } }, "ignoreTls": { diff --git a/client/src/locales/th.json b/client/src/locales/th.json index 6dbe6d1ae..9aa3dd781 100644 --- a/client/src/locales/th.json +++ b/client/src/locales/th.json @@ -536,7 +536,7 @@ "placeholder": "wss://example.com/socket" }, "captureUrl": { - "label": "Capture endpoint", + "label": "ปลายทาง Capture", "placeholder": "http://192.168.1.10:5001/api/v1/metrics" } }, @@ -550,7 +550,7 @@ "pagespeed": "ติดตามประสิทธิภาพการโหลดหน้า Core Web Vitals และคะแนนการเพิ่มประสิทธิภาพของเว็บไซต์", "grpc": "กรอกชื่อโฮสต์และพอร์ตของเซิร์ฟเวอร์ gRPC ระบุชื่อบริการสำหรับ health check (ไม่บังคับ) และตั้งชื่อที่แสดง", "websocket": "กรอก URL ของ WebSocket ที่ต้องการตรวจสอบ (เช่น wss://example.com/socket) และตั้งชื่อที่แสดง", - "hardware": "ตรวจสอบการใช้งาน CPU หน่วยความจำ ดิสก์ และอุณหภูมิของโครงสร้างพื้นฐาน" + "hardware": "ตรวจสอบการใช้ CPU หน่วยความจำ ดิสก์ และอุณหภูมิของโครงสร้างพื้นฐานของคุณ URL ของปลายทาง Capture ต้องรวมเส้นทางแบบเต็ม /api/v1/metrics (ตัวอย่าง: http://192.168.1.10:5001/api/v1/metrics) สำหรับรหัสลับสำหรับยืนยันตัวตน ให้วางเฉพาะสตริงโทเค็น — อย่ารวมคำว่า 'Bearer'" } }, "ignoreTls": { diff --git a/client/src/locales/tr.json b/client/src/locales/tr.json index f003e372d..26947680b 100644 --- a/client/src/locales/tr.json +++ b/client/src/locales/tr.json @@ -536,7 +536,7 @@ "placeholder": "wss://example.com/socket" }, "captureUrl": { - "label": "Capture endpoint", + "label": "Capture uç noktası", "placeholder": "http://192.168.1.10:5001/api/v1/metrics" } }, @@ -550,7 +550,7 @@ "pagespeed": "Web sitenizin sayfa yükleme performansını, Core Web Vitals metriklerini ve optimizasyon puanlarını takip edin.", "grpc": "gRPC sunucusunun ana bilgisayar adını ve portunu girin, isteğe bağlı olarak sağlık kontrolü için bir servis adı belirtin ve bir görünen ad ekleyin.", "websocket": "İzlenecek WebSocket URL'sini girin (örn. wss://example.com/socket) ve bir görünen ad ekleyin.", - "hardware": "Altyapınız için CPU, bellek, disk kullanımı ve sıcaklığı izleyin." + "hardware": "Altyapınızın CPU, bellek, disk kullanımını ve sıcaklığını izleyin. Capture uç nokta URL'si tam yolu /api/v1/metrics içermelidir (ör. http://192.168.1.10:5001/api/v1/metrics). Yetkilendirme anahtarına yalnızca token dizesini yapıştırın — 'Bearer' eklemeyin." } }, "ignoreTls": { diff --git a/client/src/locales/uk.json b/client/src/locales/uk.json index 4b363b592..c74dd3d31 100644 --- a/client/src/locales/uk.json +++ b/client/src/locales/uk.json @@ -536,7 +536,7 @@ "placeholder": "wss://example.com/socket" }, "captureUrl": { - "label": "Capture endpoint", + "label": "Точка доступу Capture", "placeholder": "http://192.168.1.10:5001/api/v1/metrics" } }, @@ -550,7 +550,7 @@ "pagespeed": "Відстежуйте швидкість завантаження сторінки, показники Core Web Vitals та оцінки оптимізації Вашого сайту.", "grpc": "Введіть ім'я хоста та порт gRPC-сервера, за бажанням вкажіть назву сервісу для перевірки стану та додайте відображуване ім'я.", "websocket": "Введіть WebSocket URL для моніторингу (напр., wss://example.com/socket) та додайте відображуване ім'я.", - "hardware": "Моніторинг CPU, пам'яті, використання диска та температури Вашої інфраструктури." + "hardware": "Відстежуйте використання ЦП, пам'яті, диска та температуру вашої інфраструктури. URL точки доступу Capture має містити повний шлях /api/v1/metrics (наприклад, http://192.168.1.10:5001/api/v1/metrics). У секрет авторизації вставте лише рядок токена — не додавайте 'Bearer'." } }, "ignoreTls": { diff --git a/client/src/locales/vi.json b/client/src/locales/vi.json index 7948083af..be3074ee2 100644 --- a/client/src/locales/vi.json +++ b/client/src/locales/vi.json @@ -536,7 +536,7 @@ "placeholder": "wss://example.com/socket" }, "captureUrl": { - "label": "Capture endpoint", + "label": "Điểm cuối Capture", "placeholder": "http://192.168.1.10:5001/api/v1/metrics" } }, @@ -550,7 +550,7 @@ "pagespeed": "Theo dõi hiệu suất tải trang, Core Web Vitals và điểm tối ưu hóa cho trang web của bạn.", "grpc": "Nhập tên máy chủ và cổng của máy chủ gRPC, tùy chọn chỉ định tên dịch vụ cho kiểm tra sức khỏe và thêm tên hiển thị.", "websocket": "Nhập URL WebSocket cần giám sát (vd: wss://example.com/socket) và thêm tên hiển thị.", - "hardware": "Giám sát CPU, bộ nhớ, dung lượng ổ đĩa và nhiệt độ cho hạ tầng của bạn." + "hardware": "Giám sát mức sử dụng CPU, bộ nhớ, ổ đĩa và nhiệt độ của hạ tầng. URL điểm cuối Capture phải bao gồm đường dẫn đầy đủ /api/v1/metrics (ví dụ: http://192.168.1.10:5001/api/v1/metrics). Đối với khóa xác thực, chỉ dán chuỗi token — không bao gồm 'Bearer'." } }, "ignoreTls": { diff --git a/client/src/locales/zh-CN.json b/client/src/locales/zh-CN.json index 9a440f3bd..2a5037f86 100644 --- a/client/src/locales/zh-CN.json +++ b/client/src/locales/zh-CN.json @@ -536,7 +536,7 @@ "placeholder": "wss://example.com/socket" }, "captureUrl": { - "label": "Capture endpoint", + "label": "Capture 端点", "placeholder": "http://192.168.1.10:5001/api/v1/metrics" } }, @@ -550,7 +550,7 @@ "pagespeed": "跟踪网站的页面加载性能、Core Web Vitals 和优化评分。", "grpc": "输入 gRPC 服务器的主机名和端口,可选指定健康检查的服务名称,并添加显示名称。", "websocket": "输入要监控的 WebSocket URL(例如 wss://example.com/socket),并添加显示名称。", - "hardware": "监控基础设施的 CPU、内存、磁盘使用率和温度。" + "hardware": "监控基础设施的 CPU、内存、磁盘使用情况和温度。Capture 端点 URL 必须包含完整路径 /api/v1/metrics(例如 http://192.168.1.10:5001/api/v1/metrics)。授权密钥只需粘贴令牌字符串——请勿包含 'Bearer'。" } }, "ignoreTls": { diff --git a/client/src/locales/zh-TW.json b/client/src/locales/zh-TW.json index 18498e1a9..8d0975d1b 100644 --- a/client/src/locales/zh-TW.json +++ b/client/src/locales/zh-TW.json @@ -536,7 +536,7 @@ "placeholder": "wss://example.com/socket" }, "captureUrl": { - "label": "Capture endpoint", + "label": "Capture 端點", "placeholder": "http://192.168.1.10:5001/api/v1/metrics" } }, @@ -550,7 +550,7 @@ "pagespeed": "追蹤您網站的頁面載入效能、Core Web Vitals 和最佳化分數。", "grpc": "輸入 gRPC 伺服器的主機名稱和連接埠,可選擇指定健康檢查的服務名稱,並新增一個顯示名稱。", "websocket": "輸入要監控的 WebSocket URL(例如 wss://example.com/socket),並新增一個顯示名稱。", - "hardware": "監控您基礎架構的 CPU、記憶體、磁碟使用量和溫度。" + "hardware": "監控基礎架構的 CPU、記憶體、磁碟使用情況和溫度。Capture 端點 URL 必須包含完整路徑 /api/v1/metrics(例如 http://192.168.1.10:5001/api/v1/metrics)。授權密鑰僅貼上權杖字串——請勿包含 'Bearer'。" } }, "ignoreTls": { From 1458cf4e4abaa4c35503bc9439944a49f64e7fba Mon Sep 17 00:00:00 2001 From: gorkem-bwl Date: Fri, 1 May 2026 22:28:12 -0400 Subject: [PATCH 049/245] fix(status-page): use operator-configured timezone on public pages MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Anonymous visitors to a public status page have no persisted Redux state, so ThemedHeatmap and ThemedHistogram tooltips always rendered timestamps in the hardcoded "America/Toronto" default — regardless of the timezone the operator configured. Even on the same domain, ThemedHeatmap and ThemedHistogram had no code path to consume the operator-configured statusPage.timezone returned by the API. - Add SYSTEM_TIMEZONE module constant and a small resolveTimezone() helper to TimeUtils.ts. formatDateWithTz's timezone parameter is now optional and defaults to the system tz, so callers don't need to rebuild the prop -> browser -> UTC chain inline. - Add a `timezone` field to StatusPageThemeContextValue so any themed component on the public page can read the page-wide tz without prop drilling (parallels how `theme` and `mode` are already exposed). - Wire statusPage.timezone into StatusPageThemeProvider from Pages/StatusPage/Status/index.tsx (both the public route and the admin preview). - ThemedHeatmap and ThemedHistogram now read tz from the context instead of useSelector(state.ui.timezone) — that selector was unreliable for anonymous visitors and removing it cuts a Redux subscription on the public page. - Replace the inline `data?.timezone || Intl.DateTimeFormat()...` expression in useStatusPageForm.ts with resolveTimezone(). Closes #3628. --- client/src/Hooks/useStatusPageForm.ts | 3 ++- client/src/Pages/StatusPage/Status/index.tsx | 2 ++ .../Status/themes/StatusPageThemeProvider.tsx | 15 +++++++++++-- .../Status/themes/shared/ThemedHeatmap.tsx | 11 +++------- .../Status/themes/shared/ThemedHistogram.tsx | 7 +++--- client/src/Utils/TimeUtils.ts | 22 +++++++++++++++++-- 6 files changed, 43 insertions(+), 17 deletions(-) diff --git a/client/src/Hooks/useStatusPageForm.ts b/client/src/Hooks/useStatusPageForm.ts index 8bec86f9b..681a3da6c 100644 --- a/client/src/Hooks/useStatusPageForm.ts +++ b/client/src/Hooks/useStatusPageForm.ts @@ -6,6 +6,7 @@ import { DEFAULT_STATUS_PAGE_THEME_MODE, } from "@/Types/StatusPage"; import type { Monitor } from "@/Types/Monitor"; +import { resolveTimezone } from "@/Utils/TimeUtils"; interface UseStatusPageFormOptions { data?: StatusPage | null; @@ -30,7 +31,7 @@ export const useStatusPageForm = ({ const defaults: StatusPageFormData = { companyName: data?.companyName || "", url: data?.url || generateDefaultUrl(), - timezone: data?.timezone || Intl.DateTimeFormat().resolvedOptions().timeZone, + timezone: resolveTimezone(data?.timezone), type: data?.type || ["uptime"], color: data?.color || "#13715B", monitors: data?.monitors || [], diff --git a/client/src/Pages/StatusPage/Status/index.tsx b/client/src/Pages/StatusPage/Status/index.tsx index 59ba4f21b..9c4001fae 100644 --- a/client/src/Pages/StatusPage/Status/index.tsx +++ b/client/src/Pages/StatusPage/Status/index.tsx @@ -126,6 +126,7 @@ const StatusPageView = () => { {themedRenderer} @@ -149,6 +150,7 @@ const StatusPageView = () => { {themedRenderer} diff --git a/client/src/Pages/StatusPage/Status/themes/StatusPageThemeProvider.tsx b/client/src/Pages/StatusPage/Status/themes/StatusPageThemeProvider.tsx index 2b10b0d9a..f79991c3b 100644 --- a/client/src/Pages/StatusPage/Status/themes/StatusPageThemeProvider.tsx +++ b/client/src/Pages/StatusPage/Status/themes/StatusPageThemeProvider.tsx @@ -13,6 +13,7 @@ import { type StatusPageTheme, type StatusPageThemeMode, } from "@/Types/StatusPage"; +import { resolveTimezone } from "@/Utils/TimeUtils"; import { themeTokens, type StatusPageThemeTokens } from "./tokens"; type ResolvedMode = "light" | "dark"; @@ -21,6 +22,7 @@ interface StatusPageThemeContextValue { theme: StatusPageTheme; mode: ResolvedMode; tokens: StatusPageThemeTokens; + timezone: string; } const StatusPageThemeContext = createContext(null); @@ -33,6 +35,7 @@ const resolveSystemMode = (): ResolvedMode => { interface Props { theme?: StatusPageTheme; themeMode?: StatusPageThemeMode; + timezone?: string; /** * When true, paint the document body with the theme background so it * covers beyond the provider's wrapper (useful on the public route @@ -54,12 +57,14 @@ interface Props { export const StatusPageThemeProvider = ({ theme, themeMode, + timezone, paintBody = false, transparent = false, children, }: Props) => { const resolvedTheme = resolveStatusPageTheme(theme); const resolvedThemeMode = resolveStatusPageThemeMode(themeMode); + const resolvedTimezone = resolveTimezone(timezone); const [systemMode, setSystemMode] = useState(resolveSystemMode); @@ -107,8 +112,13 @@ export const StatusPageThemeProvider = ({ const tokens = themeTokens[resolvedTheme][resolvedMode]; const value = useMemo( - () => ({ theme: resolvedTheme, mode: resolvedMode, tokens }), - [resolvedTheme, resolvedMode, tokens] + () => ({ + theme: resolvedTheme, + mode: resolvedMode, + tokens, + timezone: resolvedTimezone, + }), + [resolvedTheme, resolvedMode, tokens, resolvedTimezone] ); return ( @@ -136,6 +146,7 @@ export const useStatusPageTheme = (): StatusPageThemeContextValue => { theme: DEFAULT_STATUS_PAGE_THEME, mode: resolveSystemMode(), tokens: themeTokens[DEFAULT_STATUS_PAGE_THEME][resolveSystemMode()], + timezone: resolveTimezone(), }; } return ctx; diff --git a/client/src/Pages/StatusPage/Status/themes/shared/ThemedHeatmap.tsx b/client/src/Pages/StatusPage/Status/themes/shared/ThemedHeatmap.tsx index 58ae86d44..fe56155c3 100644 --- a/client/src/Pages/StatusPage/Status/themes/shared/ThemedHeatmap.tsx +++ b/client/src/Pages/StatusPage/Status/themes/shared/ThemedHeatmap.tsx @@ -4,11 +4,10 @@ import Stack from "@mui/material/Stack"; import Typography from "@mui/material/Typography"; import type { SxProps, Theme } from "@mui/material/styles"; import { useTranslation } from "react-i18next"; -import { useSelector } from "react-redux"; -import type { RootState } from "@/store"; import type { CheckSnapshot } from "@/Types/Check"; import { formatDateWithTz } from "@/Utils/TimeUtils"; import { MAX_RECENT_CHECKS } from "@/Types/Monitor"; +import { useStatusPageTheme } from "../StatusPageThemeProvider"; const CELLS = MAX_RECENT_CHECKS; @@ -30,7 +29,7 @@ const classify = (check: CheckSnapshot): Exclude => { export const ThemedHeatmap = ({ checks, containerSx, cellSx }: Props) => { const { t } = useTranslation(); - const uiTimezone = useSelector((state: RootState) => state.ui.timezone); + const { timezone } = useStatusPageTheme(); const source = checks.slice(-CELLS); const padded: (CheckSnapshot | null)[] = [ @@ -68,11 +67,7 @@ export const ThemedHeatmap = ({ checks, containerSx, cellSx }: Props) => { variant="caption" sx={{ opacity: 0.8 }} > - {formatDateWithTz( - check.createdAt, - "ddd, MMMM D, YYYY, HH:mm A", - uiTimezone - )} + {formatDateWithTz(check.createdAt, "ddd, MMMM D, YYYY, HH:mm A", timezone)} ); diff --git a/client/src/Pages/StatusPage/Status/themes/shared/ThemedHistogram.tsx b/client/src/Pages/StatusPage/Status/themes/shared/ThemedHistogram.tsx index 8408a22ef..9d06a99ac 100644 --- a/client/src/Pages/StatusPage/Status/themes/shared/ThemedHistogram.tsx +++ b/client/src/Pages/StatusPage/Status/themes/shared/ThemedHistogram.tsx @@ -5,11 +5,10 @@ import Stack from "@mui/material/Stack"; import Typography from "@mui/material/Typography"; import type { SxProps, Theme } from "@mui/material/styles"; import { useTranslation } from "react-i18next"; -import { useSelector } from "react-redux"; -import type { RootState } from "@/store"; import type { CheckSnapshot } from "@/Types/Check"; import { formatDateWithTz } from "@/Utils/TimeUtils"; import { MAX_RECENT_CHECKS } from "@/Types/Monitor"; +import { useStatusPageTheme } from "../StatusPageThemeProvider"; const CELLS = MAX_RECENT_CHECKS; const MIN_HEIGHT_PCT = 6; @@ -34,7 +33,7 @@ export const ThemedHistogram = ({ statsGap = 1, }: Props) => { const { t } = useTranslation(); - const uiTimezone = useSelector((state: RootState) => state.ui.timezone); + const { timezone } = useStatusPageTheme(); const { padded, max, avg, peak } = useMemo(() => { const source = checks.slice(-CELLS); @@ -83,7 +82,7 @@ export const ThemedHistogram = ({ {formatDateWithTz( check.createdAt, "ddd, MMMM D, YYYY, HH:mm A", - uiTimezone + timezone )} diff --git a/client/src/Utils/TimeUtils.ts b/client/src/Utils/TimeUtils.ts index cffdd5440..5b63de87e 100644 --- a/client/src/Utils/TimeUtils.ts +++ b/client/src/Utils/TimeUtils.ts @@ -15,12 +15,30 @@ export const MS_PER_HOUR = 60 * MS_PER_MINUTE; export const MS_PER_DAY = 24 * MS_PER_HOUR; export const MS_PER_WEEK = MS_PER_DAY * 7; -export const formatDateWithTz = (timestamp: string, format: string, timezone: string) => { +const resolveSystemTimezone = (): string => { + try { + return Intl.DateTimeFormat().resolvedOptions().timeZone || "UTC"; + } catch { + return "UTC"; + } +}; + +export const SYSTEM_TIMEZONE = resolveSystemTimezone(); + +export const resolveTimezone = (preferred?: string | null): string => + preferred?.trim() || SYSTEM_TIMEZONE; + +export const formatDateWithTz = ( + timestamp: string, + format: string, + timezone?: string +) => { if (!timestamp) { return "Unknown time"; } + const tz = resolveTimezone(timezone); try { - return dayjs(timestamp).tz(timezone).format(format); + return dayjs(timestamp).tz(tz).format(format); } catch { return dayjs(timestamp).utc().format(format); } From ff34c872c812c8903992eb22f7ce563bb8b7a4f3 Mon Sep 17 00:00:00 2001 From: harsh-aghara Date: Sat, 2 May 2026 14:28:12 +0530 Subject: [PATCH 050/245] refactor(ui): convert BulkActionsBar to modular bottom pill using children pattern Resolves maintainer feedback regarding UI/UX layout and prevents code collisions with other PRs by extracting action logic out of the bar component. Fixes #3602 --- .../Components/monitors/BulkActionsBar.tsx | 116 +++++++++++------- .../Pages/Infrastructure/Monitors/index.tsx | 22 +++- client/src/Pages/Uptime/Monitors/index.tsx | 22 +++- 3 files changed, 106 insertions(+), 54 deletions(-) diff --git a/client/src/Components/monitors/BulkActionsBar.tsx b/client/src/Components/monitors/BulkActionsBar.tsx index aef5e4871..aa520745f 100644 --- a/client/src/Components/monitors/BulkActionsBar.tsx +++ b/client/src/Components/monitors/BulkActionsBar.tsx @@ -1,78 +1,102 @@ +import React from "react"; import Box from "@mui/material/Box"; import Stack from "@mui/material/Stack"; import Typography from "@mui/material/Typography"; -import Button from "@mui/material/Button"; -import Collapse from "@mui/material/Collapse"; -import { Play, Pause } from "lucide-react"; +import Paper from "@mui/material/Paper"; +import Slide from "@mui/material/Slide"; +import IconButton from "@mui/material/IconButton"; +import { X } from "lucide-react"; import { useTranslation } from "react-i18next"; import { useTheme } from "@mui/material/styles"; +import useMediaQuery from "@mui/material/useMediaQuery"; +import { LAYOUT } from "@/Utils/Theme/constants"; +import { useSidebar } from "@/Hooks/useSidebar"; interface BulkActionsBarProps { selectedCount: number; - onResume: () => void; - onPause: () => void; onCancel: () => void; + children?: React.ReactNode; } export const BulkActionsBar = ({ selectedCount, - onResume, - onPause, onCancel, + children, }: BulkActionsBarProps) => { const { t } = useTranslation(); const theme = useTheme(); + const isOpen = selectedCount > 0; + + const isSmall = useMediaQuery(theme.breakpoints.down("md")); + const { width, collapsedWidth, transition } = useSidebar(); return ( - 0}> - + - - - {t("pages.common.monitors.actions.bulkSelected", { - count: selectedCount, - })} - - - - + + - - - + + {children} + + + +
); }; diff --git a/client/src/Pages/Infrastructure/Monitors/index.tsx b/client/src/Pages/Infrastructure/Monitors/index.tsx index 33e6d7f02..63031f41e 100644 --- a/client/src/Pages/Infrastructure/Monitors/index.tsx +++ b/client/src/Pages/Infrastructure/Monitors/index.tsx @@ -7,7 +7,8 @@ import { HeaderMonitorsSummary, BulkActionsBar, } from "@/Components/monitors"; -import { TextField, Dialog } from "@/Components/inputs"; +import { TextField, Dialog, Button } from "@/Components/inputs"; +import { Play, Pause } from "lucide-react"; import { useGet, useDelete } from "@/Hooks/UseApi"; import { useIsAdmin } from "@/Hooks/useIsAdmin"; @@ -178,10 +179,23 @@ const InfrastructureMonitors = () => { {!isLoading && ( + > + + + )} { {!isLoading && ( + > + + + )} Date: Sat, 2 May 2026 14:28:13 +0530 Subject: [PATCH 051/245] fix(ui): correct table header alignment glitch caused by checkbox injection Adds explicit 'align' support to Table headers to override strict index-based alignment. Restores exact center-alignment spacer boxes to Uptime and Infrastructure tables to match develop branch styling. Fixes #3602 --- client/src/Components/design-elements/Table.tsx | 5 +++-- .../Monitors/Components/MonitorsTable.tsx | 13 ++++++++----- .../Monitors/Components/UptimeMonitorsTable.tsx | 2 ++ 3 files changed, 13 insertions(+), 7 deletions(-) diff --git a/client/src/Components/design-elements/Table.tsx b/client/src/Components/design-elements/Table.tsx index 121d890d1..c6628a611 100644 --- a/client/src/Components/design-elements/Table.tsx +++ b/client/src/Components/design-elements/Table.tsx @@ -36,6 +36,7 @@ export type Header = { id: number | string; content: React.ReactNode; mobileLabel?: React.ReactNode; + align?: "left" | "center" | "right" | "justify" | "inherit"; onClick?: (event: React.MouseEvent, row: T) => void; render: (row: T) => React.ReactNode; }; @@ -214,7 +215,7 @@ export function DataTable< {headers.map((header, idx) => { return ( {header.content} @@ -243,7 +244,7 @@ export function DataTable< {headers.map((header, index) => { return ( header.onClick!(e, row) : undefined diff --git a/client/src/Pages/Infrastructure/Monitors/Components/MonitorsTable.tsx b/client/src/Pages/Infrastructure/Monitors/Components/MonitorsTable.tsx index 1cf5c6ec6..dbae1b1e9 100644 --- a/client/src/Pages/Infrastructure/Monitors/Components/MonitorsTable.tsx +++ b/client/src/Pages/Infrastructure/Monitors/Components/MonitorsTable.tsx @@ -1,4 +1,5 @@ import Box from "@mui/material/Box"; +import Stack from "@mui/material/Stack"; import Typography from "@mui/material/Typography"; import { Table, Pagination, StatusLabel, Gauge } from "@/Components/design-elements"; import type { Header } from "@/Components/design-elements/Table"; @@ -184,6 +185,7 @@ export const InfraMonitorsTable = ({ }, { id: "name", + align: "left", mobileLabel: t("common.table.headers.name"), content: ( handleSort(e, "status")} sx={{ cursor: "pointer" }} > + {t("common.table.headers.status")} {renderSortIcon(sortField === "status")} - + ), render: (row) => { return ; diff --git a/client/src/Pages/Uptime/Monitors/Components/UptimeMonitorsTable.tsx b/client/src/Pages/Uptime/Monitors/Components/UptimeMonitorsTable.tsx index 4aee90fa5..e4d870cc8 100644 --- a/client/src/Pages/Uptime/Monitors/Components/UptimeMonitorsTable.tsx +++ b/client/src/Pages/Uptime/Monitors/Components/UptimeMonitorsTable.tsx @@ -192,6 +192,7 @@ export const MonitorTable = ({ }, { id: "name", + align: "left", mobileLabel: t("common.table.headers.name"), content: ( handleSort(e, "status")} sx={{ cursor: "pointer" }} From 657a35e948aafae880dfbfb9e0ca66e0be6afcfe Mon Sep 17 00:00:00 2001 From: harsh-aghara Date: Sat, 2 May 2026 14:28:15 +0530 Subject: [PATCH 052/245] feat(i18n): implement strict pluralization for bulk action notifications Removes generic '(s)' syntax from backend responses. Adds proper _one and _other pluralization keys to all 16 locales with native grammar translations, resolving 0-count edge cases. Fixes #3602 --- client/src/Hooks/useBulkMonitorActions.ts | 60 ++++++++++++++------- client/src/locales/ar.json | 17 ++++-- client/src/locales/cs.json | 15 +++++- client/src/locales/de.json | 15 +++++- client/src/locales/en.json | 13 ++++- client/src/locales/es.json | 15 +++++- client/src/locales/fi.json | 17 ++++-- client/src/locales/fr.json | 15 +++++- client/src/locales/ja.json | 15 +++++- client/src/locales/pt-BR.json | 15 +++++- client/src/locales/ru.json | 15 +++++- client/src/locales/th.json | 17 ++++-- client/src/locales/tr.json | 15 +++++- client/src/locales/uk.json | 15 +++++- client/src/locales/vi.json | 17 ++++-- client/src/locales/zh-CN.json | 17 ++++-- client/src/locales/zh-TW.json | 17 ++++-- server/src/controllers/monitorController.ts | 3 +- 18 files changed, 256 insertions(+), 57 deletions(-) diff --git a/client/src/Hooks/useBulkMonitorActions.ts b/client/src/Hooks/useBulkMonitorActions.ts index 3bb520aa1..ddf96fedb 100644 --- a/client/src/Hooks/useBulkMonitorActions.ts +++ b/client/src/Hooks/useBulkMonitorActions.ts @@ -1,9 +1,14 @@ import { useState, useEffect } from "react"; -import { usePost } from "@/Hooks/UseApi"; +import { post } from "@/Utils/ApiClient"; +import { useToast } from "@/Hooks/UseToast"; +import { useTranslation } from "react-i18next"; +import { logger } from "@/Utils/logger"; +import type { Monitor } from "@/Types/Monitor"; -interface BulkPauseBody { - monitorIds: string[]; - pause: boolean; +interface ApiResponse { + success: boolean; + msg: string; + data: Monitor[]; } interface UseBulkMonitorActionsReturn { @@ -19,33 +24,50 @@ export const useBulkMonitorActions = ( page?: number ): UseBulkMonitorActionsReturn => { const [selectedRows, setSelectedRows] = useState([]); - const { post: bulkPost } = usePost(); + const { toastSuccess, toastError, toastInfo } = useToast(); + const { t } = useTranslation(); // Clear selection when page changes useEffect(() => { setSelectedRows([]); }, [page]); - const handleBulkPause = async () => { - const result = await bulkPost("/monitors/bulk/pause", { - monitorIds: selectedRows, - pause: true, - }); - if (result) { + const executeBulkAction = async (pause: boolean) => { + try { + const res = await post("/monitors/bulk/pause", { + monitorIds: selectedRows, + pause, + }); + + const affectedCount = res.data?.data?.length ?? 0; + + if (affectedCount === 0) { + const key = pause + ? "pages.common.monitors.bulkPause.alreadyPaused" + : "pages.common.monitors.bulkPause.alreadyRunning"; + toastInfo(t(key, { count: selectedRows.length })); + } else { + const key = pause + ? "pages.common.monitors.bulkPause.paused" + : "pages.common.monitors.bulkPause.resumed"; + toastSuccess(t(key, { count: affectedCount })); + } + setSelectedRows([]); refetch(); + } catch (err: any) { + const errMsg = err?.response?.data?.msg || err.message || "An error occurred"; + logger.error("Bulk pause/resume failed", err, { pause }); + toastError(errMsg); } }; + const handleBulkPause = async () => { + await executeBulkAction(true); + }; + const handleBulkResume = async () => { - const result = await bulkPost("/monitors/bulk/pause", { - monitorIds: selectedRows, - pause: false, - }); - if (result) { - setSelectedRows([]); - refetch(); - } + await executeBulkAction(false); }; const handleCancelSelection = () => { diff --git a/client/src/locales/ar.json b/client/src/locales/ar.json index 70509feed..5b21acd8e 100644 --- a/client/src/locales/ar.json +++ b/client/src/locales/ar.json @@ -422,9 +422,10 @@ "openSite": "فتح الموقع", "pause": "إيقاف مؤقت", "resume": "استئناف", - "bulkSelected": "تم تحديد {{count}} جهاز(أجهزة) مراقبة", - "selectAll": "تحديد جميع أجهزة المراقبة", - "selectMonitor": "تحديد {{name}}" + "selectAll": "تحديد جميع الشاشات", + "selectMonitor": "تحديد {{name}}", + "bulkSelected_one": "{{count}} شاشة محددة", + "bulkSelected_other": "{{count}} شاشات محددة" }, "statBoxes": { "activeFor": "نشط منذ", @@ -452,6 +453,16 @@ "optionHardware": "البنية التحتية", "optionGrpc": "gRPC", "optionWebSocket": "WebSocket" + }, + "bulkPause": { + "paused_one": "تم إيقاف {{count}} شاشة مؤقتاً", + "paused_other": "تم إيقاف {{count}} شاشات مؤقتاً", + "resumed_one": "تم استئناف {{count}} شاشة", + "resumed_other": "تم استئناف {{count}} شاشات", + "alreadyPaused_one": "الشاشة المحددة متوقفة مؤقتاً بالفعل", + "alreadyPaused_other": "جميع الشاشات المحددة متوقفة مؤقتاً بالفعل", + "alreadyRunning_one": "الشاشة المحددة قيد التشغيل بالفعل", + "alreadyRunning_other": "جميع الشاشات المحددة قيد التشغيل بالفعل" } } }, diff --git a/client/src/locales/cs.json b/client/src/locales/cs.json index e86677c29..e440b3b12 100644 --- a/client/src/locales/cs.json +++ b/client/src/locales/cs.json @@ -422,9 +422,10 @@ "openSite": "Otevřít stránku", "pause": "Pozastavit", "resume": "Obnovit", - "bulkSelected": "{{count}} monitor(ů) vybráno", "selectAll": "Vybrat všechny monitory", - "selectMonitor": "Vybrat {{name}}" + "selectMonitor": "Vybrat {{name}}", + "bulkSelected_one": "Vybrán {{count}} monitor", + "bulkSelected_other": "Vybráno {{count}} monitorů" }, "statBoxes": { "activeFor": "Aktivní po dobu", @@ -452,6 +453,16 @@ "optionHardware": "Infrastruktura", "optionGrpc": "gRPC", "optionWebSocket": "WebSocket" + }, + "bulkPause": { + "paused_one": "{{count}} monitor pozastaven", + "paused_other": "{{count}} monitorů pozastaveno", + "resumed_one": "{{count}} monitor obnoven", + "resumed_other": "{{count}} monitorů obnoveno", + "alreadyPaused_one": "Vybraný monitor je již pozastaven", + "alreadyPaused_other": "Všechny vybrané monitory jsou již pozastaveny", + "alreadyRunning_one": "Vybraný monitor již běží", + "alreadyRunning_other": "Všechny vybrané monitory již běží" } } }, diff --git a/client/src/locales/de.json b/client/src/locales/de.json index cd93c4810..c3bc35011 100644 --- a/client/src/locales/de.json +++ b/client/src/locales/de.json @@ -422,9 +422,10 @@ "openSite": "Seite öffnen", "pause": "Pausieren", "resume": "Fortsetzen", - "bulkSelected": "{{count}} Monitor(e) ausgewählt", "selectAll": "Alle Monitore auswählen", - "selectMonitor": "{{name}} auswählen" + "selectMonitor": "{{name}} auswählen", + "bulkSelected_one": "{{count}} Monitor ausgewählt", + "bulkSelected_other": "{{count}} Monitore ausgewählt" }, "statBoxes": { "activeFor": "Aktiv seit", @@ -452,6 +453,16 @@ "optionHardware": "Infrastruktur", "optionGrpc": "gRPC", "optionWebSocket": "WebSocket" + }, + "bulkPause": { + "paused_one": "{{count}} Monitor pausiert", + "paused_other": "{{count}} Monitore pausiert", + "resumed_one": "{{count}} Monitor fortgesetzt", + "resumed_other": "{{count}} Monitore fortgesetzt", + "alreadyPaused_one": "Der ausgewählte Monitor ist bereits pausiert", + "alreadyPaused_other": "Alle ausgewählten Monitore sind bereits pausiert", + "alreadyRunning_one": "Der ausgewählte Monitor wird bereits ausgeführt", + "alreadyRunning_other": "Alle ausgewählten Monitore werden bereits ausgeführt" } } }, diff --git a/client/src/locales/en.json b/client/src/locales/en.json index 2e22baa07..c505d0213 100644 --- a/client/src/locales/en.json +++ b/client/src/locales/en.json @@ -422,10 +422,21 @@ "openSite": "Open site", "pause": "Pause", "resume": "Resume", - "bulkSelected": "{{count}} Monitor(s) selected", + "bulkSelected_one": "{{count}} monitor selected", + "bulkSelected_other": "{{count}} monitors selected", "selectAll": "Select all monitors", "selectMonitor": "Select {{name}}" }, + "bulkPause": { + "alreadyPaused_one": "The selected monitor is already paused", + "alreadyPaused_other": "All selected monitors are already paused", + "alreadyRunning_one": "The selected monitor is already running", + "alreadyRunning_other": "All selected monitors are already running", + "paused_one": "{{count}} monitor paused", + "paused_other": "{{count}} monitors paused", + "resumed_one": "{{count}} monitor resumed", + "resumed_other": "{{count}} monitors resumed" + }, "statBoxes": { "activeFor": "Active for", "certificateExpiry": "Certificate expiry", diff --git a/client/src/locales/es.json b/client/src/locales/es.json index 04f447e40..f7c719f53 100644 --- a/client/src/locales/es.json +++ b/client/src/locales/es.json @@ -429,9 +429,10 @@ "openSite": "Abrir sitio", "pause": "Pausar", "resume": "Reanudar", - "bulkSelected": "{{count}} monitor(es) seleccionado(s)", "selectAll": "Seleccionar todos los monitores", - "selectMonitor": "Seleccionar {{name}}" + "selectMonitor": "Seleccionar {{name}}", + "bulkSelected_one": "{{count}} monitor seleccionado", + "bulkSelected_other": "{{count}} monitores seleccionados" }, "statBoxes": { "activeFor": "Activo desde hace", @@ -459,6 +460,16 @@ "optionHardware": "Infraestructura", "optionGrpc": "gRPC", "optionWebSocket": "WebSocket" + }, + "bulkPause": { + "paused_one": "{{count}} monitor pausado", + "paused_other": "{{count}} monitores pausados", + "resumed_one": "{{count}} monitor reanudado", + "resumed_other": "{{count}} monitores reanudados", + "alreadyPaused_one": "El monitor seleccionado ya está pausado", + "alreadyPaused_other": "Todos los monitores seleccionados ya están pausados", + "alreadyRunning_one": "El monitor seleccionado ya se está ejecutando", + "alreadyRunning_other": "Todos los monitores seleccionados ya se están ejecutando" } } }, diff --git a/client/src/locales/fi.json b/client/src/locales/fi.json index 74afedd84..f74e9f58f 100644 --- a/client/src/locales/fi.json +++ b/client/src/locales/fi.json @@ -422,9 +422,10 @@ "openSite": "Avaa sivusto", "pause": "Keskeytä", "resume": "Jatka", - "bulkSelected": "{{count}} monitori(a) valittu", - "selectAll": "Valitse kaikki monitorit", - "selectMonitor": "Valitse {{name}}" + "selectAll": "Valitse kaikki näytöt", + "selectMonitor": "Valitse {{name}}", + "bulkSelected_one": "{{count}} näyttö valittu", + "bulkSelected_other": "{{count}} näyttöä valittu" }, "statBoxes": { "activeFor": "Aktiivinen", @@ -452,6 +453,16 @@ "optionHardware": "Infrastruktuuri", "optionGrpc": "gRPC", "optionWebSocket": "WebSocket" + }, + "bulkPause": { + "paused_one": "{{count}} näyttö keskeytetty", + "paused_other": "{{count}} näyttöä keskeytetty", + "resumed_one": "{{count}} näyttö jatkettu", + "resumed_other": "{{count}} näyttöä jatkettu", + "alreadyPaused_one": "Valittu näyttö on jo keskeytetty", + "alreadyPaused_other": "Kaikki valitut näytöt on jo keskeytetty", + "alreadyRunning_one": "Valittu näyttö on jo käynnissä", + "alreadyRunning_other": "Kaikki valitut näytöt ovat jo käynnissä" } } }, diff --git a/client/src/locales/fr.json b/client/src/locales/fr.json index 8e3c78b85..003f64118 100644 --- a/client/src/locales/fr.json +++ b/client/src/locales/fr.json @@ -422,9 +422,10 @@ "openSite": "Ouvrir le site", "pause": "Pause", "resume": "Reprendre", - "bulkSelected": "{{count}} moniteur(s) sélectionné(s)", "selectAll": "Sélectionner tous les moniteurs", - "selectMonitor": "Sélectionner {{name}}" + "selectMonitor": "Sélectionner {{name}}", + "bulkSelected_one": "{{count}} moniteur sélectionné", + "bulkSelected_other": "{{count}} moniteurs sélectionnés" }, "statBoxes": { "activeFor": "Actif depuis", @@ -452,6 +453,16 @@ "optionHardware": "Infrastructure", "optionGrpc": "gRPC", "optionWebSocket": "WebSocket" + }, + "bulkPause": { + "paused_one": "{{count}} moniteur en pause", + "paused_other": "{{count}} moniteurs en pause", + "resumed_one": "{{count}} moniteur repris", + "resumed_other": "{{count}} moniteurs repris", + "alreadyPaused_one": "Le moniteur sélectionné est déjà en pause", + "alreadyPaused_other": "Tous les moniteurs sélectionnés sont déjà en pause", + "alreadyRunning_one": "Le moniteur sélectionné est déjà en cours d'exécution", + "alreadyRunning_other": "Tous les moniteurs sélectionnés sont déjà en cours d'exécution" } } }, diff --git a/client/src/locales/ja.json b/client/src/locales/ja.json index 09db6971f..1cf614379 100644 --- a/client/src/locales/ja.json +++ b/client/src/locales/ja.json @@ -422,9 +422,10 @@ "openSite": "サイトを開く", "pause": "一時停止", "resume": "再開", - "bulkSelected": "{{count}} 件のモニターを選択中", "selectAll": "すべてのモニターを選択", - "selectMonitor": "{{name}} を選択" + "selectMonitor": "{{name}} を選択", + "bulkSelected_one": "{{count}} 個のモニターが選択されました", + "bulkSelected_other": "{{count}} 個のモニターが選択されました" }, "statBoxes": { "activeFor": "稼働期間", @@ -452,6 +453,16 @@ "optionHardware": "インフラストラクチャ", "optionGrpc": "gRPC", "optionWebSocket": "WebSocket" + }, + "bulkPause": { + "paused_one": "{{count}} 個のモニターを一時停止しました", + "paused_other": "{{count}} 個のモニターを一時停止しました", + "resumed_one": "{{count}} 個のモニターを再開しました", + "resumed_other": "{{count}} 個のモニターを再開しました", + "alreadyPaused_one": "選択したモニターはすでに一時停止されています", + "alreadyPaused_other": "選択したすべてのモニターはすでに一時停止されています", + "alreadyRunning_one": "選択したモニターはすでに実行中です", + "alreadyRunning_other": "選択したすべてのモニターはすでに実行中です" } } }, diff --git a/client/src/locales/pt-BR.json b/client/src/locales/pt-BR.json index 6c97a9869..6cc241777 100644 --- a/client/src/locales/pt-BR.json +++ b/client/src/locales/pt-BR.json @@ -422,9 +422,10 @@ "openSite": "Abrir site", "pause": "Pausar", "resume": "Retomar", - "bulkSelected": "{{count}} monitor(es) selecionado(s)", "selectAll": "Selecionar todos os monitores", - "selectMonitor": "Selecionar {{name}}" + "selectMonitor": "Selecionar {{name}}", + "bulkSelected_one": "{{count}} monitor selecionado", + "bulkSelected_other": "{{count}} monitores selecionados" }, "statBoxes": { "activeFor": "Ativo há", @@ -452,6 +453,16 @@ "optionHardware": "Infraestrutura", "optionGrpc": "gRPC", "optionWebSocket": "WebSocket" + }, + "bulkPause": { + "paused_one": "{{count}} monitor pausado", + "paused_other": "{{count}} monitores pausados", + "resumed_one": "{{count}} monitor retomado", + "resumed_other": "{{count}} monitores retomados", + "alreadyPaused_one": "O monitor selecionado já está pausado", + "alreadyPaused_other": "Todos os monitores selecionados já estão pausados", + "alreadyRunning_one": "O monitor selecionado já está em execução", + "alreadyRunning_other": "Todos os monitores selecionados já estão em execução" } } }, diff --git a/client/src/locales/ru.json b/client/src/locales/ru.json index 778d481f9..171a596dd 100644 --- a/client/src/locales/ru.json +++ b/client/src/locales/ru.json @@ -422,9 +422,10 @@ "openSite": "Открыть сайт", "pause": "Пауза", "resume": "Продолжить", - "bulkSelected": "Выбрано мониторов: {{count}}", "selectAll": "Выбрать все мониторы", - "selectMonitor": "Выбрать {{name}}" + "selectMonitor": "Выбрать {{name}}", + "bulkSelected_one": "Выбран {{count}} монитор", + "bulkSelected_other": "Выбрано {{count}} мониторов" }, "statBoxes": { "activeFor": "Активен", @@ -452,6 +453,16 @@ "optionHardware": "Инфраструктура", "optionGrpc": "gRPC", "optionWebSocket": "WebSocket" + }, + "bulkPause": { + "paused_one": "{{count}} монитор приостановлен", + "paused_other": "{{count}} мониторов приостановлено", + "resumed_one": "{{count}} монитор возобновлен", + "resumed_other": "{{count}} мониторов возобновлено", + "alreadyPaused_one": "Выбранный монитор уже приостановлен", + "alreadyPaused_other": "Все выбранные мониторы уже приостановлены", + "alreadyRunning_one": "Выбранный монитор уже запущен", + "alreadyRunning_other": "Все выбранные мониторы уже запущены" } } }, diff --git a/client/src/locales/th.json b/client/src/locales/th.json index aab97dc93..d39a20a6f 100644 --- a/client/src/locales/th.json +++ b/client/src/locales/th.json @@ -422,9 +422,10 @@ "openSite": "เปิดเว็บไซต์", "pause": "หยุดชั่วคราว", "resume": "ดำเนินการต่อ", - "bulkSelected": "เลือก {{count}} มอนิเตอร์แล้ว", - "selectAll": "เลือกมอนิเตอร์ทั้งหมด", - "selectMonitor": "เลือก {{name}}" + "selectAll": "เลือกจอภาพทั้งหมด", + "selectMonitor": "เลือก {{name}}", + "bulkSelected_one": "เลือกแล้ว {{count}} จอภาพ", + "bulkSelected_other": "เลือกแล้ว {{count}} จอภาพ" }, "statBoxes": { "activeFor": "ทำงานมาแล้ว", @@ -452,6 +453,16 @@ "optionHardware": "โครงสร้างพื้นฐาน", "optionGrpc": "gRPC", "optionWebSocket": "WebSocket" + }, + "bulkPause": { + "paused_one": "หยุดจอภาพชั่วคราว {{count}} จอ", + "paused_other": "หยุดจอภาพชั่วคราว {{count}} จอ", + "resumed_one": "กลับมาทำงานต่อ {{count}} จอ", + "resumed_other": "กลับมาทำงานต่อ {{count}} จอ", + "alreadyPaused_one": "จอภาพที่เลือกถูกหยุดชั่วคราวแล้ว", + "alreadyPaused_other": "จอภาพที่เลือกทั้งหมดถูกหยุดชั่วคราวแล้ว", + "alreadyRunning_one": "จอภาพที่เลือกกำลังทำงานอยู่แล้ว", + "alreadyRunning_other": "จอภาพที่เลือกทั้งหมดกำลังทำงานอยู่แล้ว" } } }, diff --git a/client/src/locales/tr.json b/client/src/locales/tr.json index 6e6aaf365..dd5fcfa80 100644 --- a/client/src/locales/tr.json +++ b/client/src/locales/tr.json @@ -422,9 +422,10 @@ "openSite": "Siteyi aç", "pause": "Duraklat", "resume": "Devam et", - "bulkSelected": "{{count}} monitör seçildi", "selectAll": "Tüm monitörleri seç", - "selectMonitor": "{{name}} seç" + "selectMonitor": "{{name}}'i seç", + "bulkSelected_one": "{{count}} monitör seçildi", + "bulkSelected_other": "{{count}} monitör seçildi" }, "statBoxes": { "activeFor": "Aktif süresi", @@ -452,6 +453,16 @@ "optionHardware": "Altyapı", "optionGrpc": "gRPC", "optionWebSocket": "WebSocket" + }, + "bulkPause": { + "paused_one": "{{count}} monitör duraklatıldı", + "paused_other": "{{count}} monitör duraklatıldı", + "resumed_one": "{{count}} monitör sürdürüldü", + "resumed_other": "{{count}} monitör sürdürüldü", + "alreadyPaused_one": "Seçili monitör zaten duraklatıldı", + "alreadyPaused_other": "Seçili tüm monitörler zaten duraklatıldı", + "alreadyRunning_one": "Seçili monitör zaten çalışıyor", + "alreadyRunning_other": "Seçili tüm monitörler zaten çalışıyor" } } }, diff --git a/client/src/locales/uk.json b/client/src/locales/uk.json index f7ff24647..9b893d56a 100644 --- a/client/src/locales/uk.json +++ b/client/src/locales/uk.json @@ -422,9 +422,10 @@ "openSite": "Відкрити сайт", "pause": "Призупинити", "resume": "Відновити", - "bulkSelected": "Вибрано моніторів: {{count}}", "selectAll": "Вибрати всі монітори", - "selectMonitor": "Вибрати {{name}}" + "selectMonitor": "Вибрати {{name}}", + "bulkSelected_one": "Вибрано {{count}} монітор", + "bulkSelected_other": "Вибрано {{count}} моніторів" }, "statBoxes": { "activeFor": "Активний протягом", @@ -452,6 +453,16 @@ "optionHardware": "Інфраструктура", "optionGrpc": "gRPC", "optionWebSocket": "WebSocket" + }, + "bulkPause": { + "paused_one": "{{count}} монітор призупинено", + "paused_other": "{{count}} моніторів призупинено", + "resumed_one": "{{count}} монітор відновлено", + "resumed_other": "{{count}} моніторів відновлено", + "alreadyPaused_one": "Вибраний монітор уже призупинено", + "alreadyPaused_other": "Всі вибрані монітори уже призупинено", + "alreadyRunning_one": "Вибраний монітор уже запущено", + "alreadyRunning_other": "Всі вибрані монітори уже запущено" } } }, diff --git a/client/src/locales/vi.json b/client/src/locales/vi.json index 90d8afd04..a7363cea5 100644 --- a/client/src/locales/vi.json +++ b/client/src/locales/vi.json @@ -422,9 +422,10 @@ "openSite": "Mở trang web", "pause": "Tạm dừng", "resume": "Tiếp tục", - "bulkSelected": "Đã chọn {{count}} monitor", - "selectAll": "Chọn tất cả monitor", - "selectMonitor": "Chọn {{name}}" + "selectAll": "Chọn tất cả màn hình", + "selectMonitor": "Chọn {{name}}", + "bulkSelected_one": "Đã chọn {{count}} màn hình", + "bulkSelected_other": "Đã chọn {{count}} màn hình" }, "statBoxes": { "activeFor": "Hoạt động từ", @@ -452,6 +453,16 @@ "optionHardware": "Hạ tầng", "optionGrpc": "gRPC", "optionWebSocket": "WebSocket" + }, + "bulkPause": { + "paused_one": "Đã tạm dừng {{count}} màn hình", + "paused_other": "Đã tạm dừng {{count}} màn hình", + "resumed_one": "Đã tiếp tục {{count}} màn hình", + "resumed_other": "Đã tiếp tục {{count}} màn hình", + "alreadyPaused_one": "Màn hình đã chọn đã được tạm dừng", + "alreadyPaused_other": "Tất cả các màn hình đã chọn đã được tạm dừng", + "alreadyRunning_one": "Màn hình đã chọn đã đang chạy", + "alreadyRunning_other": "Tất cả các màn hình đã chọn đã đang chạy" } } }, diff --git a/client/src/locales/zh-CN.json b/client/src/locales/zh-CN.json index 73a4ce166..861f0dd8b 100644 --- a/client/src/locales/zh-CN.json +++ b/client/src/locales/zh-CN.json @@ -422,9 +422,10 @@ "openSite": "打开站点", "pause": "暂停", "resume": "恢复", - "bulkSelected": "已选择 {{count}} 个监控", - "selectAll": "选择所有监控", - "selectMonitor": "选择 {{name}}" + "selectAll": "选择所有监视器", + "selectMonitor": "选择 {{name}}", + "bulkSelected_one": "已选择 {{count}} 个监视器", + "bulkSelected_other": "已选择 {{count}} 个监视器" }, "statBoxes": { "activeFor": "已活跃", @@ -452,6 +453,16 @@ "optionHardware": "基础设施", "optionGrpc": "gRPC", "optionWebSocket": "WebSocket" + }, + "bulkPause": { + "paused_one": "已暂停 {{count}} 个监视器", + "paused_other": "已暂停 {{count}} 个监视器", + "resumed_one": "已恢复 {{count}} 个监视器", + "resumed_other": "已恢复 {{count}} 个监视器", + "alreadyPaused_one": "所选监视器已暂停", + "alreadyPaused_other": "所有选定的监视器均已暂停", + "alreadyRunning_one": "所选监视器已经在运行", + "alreadyRunning_other": "所有选定的监视器均已在运行" } } }, diff --git a/client/src/locales/zh-TW.json b/client/src/locales/zh-TW.json index 91fc7766e..c5992e2b4 100644 --- a/client/src/locales/zh-TW.json +++ b/client/src/locales/zh-TW.json @@ -422,9 +422,10 @@ "openSite": "開啟網站", "pause": "暫停", "resume": "恢復", - "bulkSelected": "已選擇 {{count}} 個監控", - "selectAll": "選擇所有監控", - "selectMonitor": "選擇 {{name}}" + "selectAll": "選擇所有監視器", + "selectMonitor": "選擇 {{name}}", + "bulkSelected_one": "已選擇 {{count}} 個監視器", + "bulkSelected_other": "已選擇 {{count}} 個監視器" }, "statBoxes": { "activeFor": "已啟用", @@ -452,6 +453,16 @@ "optionHardware": "基礎架構", "optionGrpc": "gRPC", "optionWebSocket": "WebSocket" + }, + "bulkPause": { + "paused_one": "已暫停 {{count}} 個監視器", + "paused_other": "已暫停 {{count}} 個監視器", + "resumed_one": "已恢復 {{count}} 個監視器", + "resumed_other": "已恢復 {{count}} 個監視器", + "alreadyPaused_one": "所選監視器已暫停", + "alreadyPaused_other": "所有選定的監視器均已暫停", + "alreadyRunning_one": "所選監視器已經在運行", + "alreadyRunning_other": "所有選定的監視器均已在運行" } } }, diff --git a/server/src/controllers/monitorController.ts b/server/src/controllers/monitorController.ts index 4529f972d..7422d5abb 100644 --- a/server/src/controllers/monitorController.ts +++ b/server/src/controllers/monitorController.ts @@ -318,10 +318,11 @@ class MonitorController implements IMonitorController { const monitors = await this.monitorService.bulkPauseMonitors({ teamId, monitorIds, pause }); const action = pause ? "paused" : "resumed"; + const monitorStr = monitors.length === 1 ? "monitor" : "monitors"; return res.status(200).json({ success: true, - msg: `${monitors.length} monitor(s) ${action} successfully`, + msg: `${monitors.length} ${monitorStr} ${action} successfully`, data: monitors, }); } catch (error) { From cb218a46a9b21e6382a92c39d2fe2871737d3c51 Mon Sep 17 00:00:00 2001 From: Alex Holliday Date: Mon, 4 May 2026 10:21:28 -0700 Subject: [PATCH 053/245] fix pause/resume logic --- .../infrastructure/SuperSimpleQueue/SuperSimpleQueue.ts | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/server/src/service/infrastructure/SuperSimpleQueue/SuperSimpleQueue.ts b/server/src/service/infrastructure/SuperSimpleQueue/SuperSimpleQueue.ts index 7260c82c7..d64a8932b 100644 --- a/server/src/service/infrastructure/SuperSimpleQueue/SuperSimpleQueue.ts +++ b/server/src/service/infrastructure/SuperSimpleQueue/SuperSimpleQueue.ts @@ -224,7 +224,7 @@ export class SuperSimpleQueue implements ISuperSimpleQueue { throw new Error("Failed to pause monitor"); } const geoJob = await this.scheduler.getJob(`${monitor.id}-geo`); - if (geoJob) await this.scheduler.removeJob(`${monitor.id}-geo`); + if (geoJob) await this.scheduler.pauseJob(`${monitor.id}-geo`); this.logger.debug({ message: `Paused monitor ${monitor.id}`, @@ -239,8 +239,10 @@ export class SuperSimpleQueue implements ISuperSimpleQueue { throw new Error("Failed to resume monitor"); } - await this.scheduler.resumeJob(`${monitor.id}-geo`); - + const geoJob = await this.scheduler.getJob(`${monitor.id}-geo`); + if (geoJob) { + await this.scheduler.resumeJob(`${monitor.id}-geo`); + } this.logger.debug({ message: `Resumed monitor ${monitor.id}`, service: SERVICE_NAME, From 6f0cc307dc85e7ce5250a2001e7fc56c9d2bb3cf Mon Sep 17 00:00:00 2001 From: Alex Holliday Date: Mon, 4 May 2026 10:51:53 -0700 Subject: [PATCH 054/245] log error on geo check pause/resume fail, update tests --- .../SuperSimpleQueue/SuperSimpleQueue.ts | 20 ++++++- .../unit/services/superSimpleQueue.test.ts | 54 ++++++++++++++++--- 2 files changed, 65 insertions(+), 9 deletions(-) diff --git a/server/src/service/infrastructure/SuperSimpleQueue/SuperSimpleQueue.ts b/server/src/service/infrastructure/SuperSimpleQueue/SuperSimpleQueue.ts index d64a8932b..1d50d720c 100644 --- a/server/src/service/infrastructure/SuperSimpleQueue/SuperSimpleQueue.ts +++ b/server/src/service/infrastructure/SuperSimpleQueue/SuperSimpleQueue.ts @@ -224,7 +224,16 @@ export class SuperSimpleQueue implements ISuperSimpleQueue { throw new Error("Failed to pause monitor"); } const geoJob = await this.scheduler.getJob(`${monitor.id}-geo`); - if (geoJob) await this.scheduler.pauseJob(`${monitor.id}-geo`); + if (geoJob) { + const geoRes = await this.scheduler.pauseJob(`${monitor.id}-geo`); + if (geoRes === false) { + this.logger.error({ + message: `Failed to pause geo check job for monitor ${monitor.id}`, + service: SERVICE_NAME, + method: "pauseJob", + }); + } + } this.logger.debug({ message: `Paused monitor ${monitor.id}`, @@ -241,7 +250,14 @@ export class SuperSimpleQueue implements ISuperSimpleQueue { const geoJob = await this.scheduler.getJob(`${monitor.id}-geo`); if (geoJob) { - await this.scheduler.resumeJob(`${monitor.id}-geo`); + const geoRes = await this.scheduler.resumeJob(`${monitor.id}-geo`); + if (geoRes === false) { + this.logger.error({ + message: `Failed to resume geo check job for monitor ${monitor.id}`, + service: SERVICE_NAME, + method: "resumeJob", + }); + } } this.logger.debug({ message: `Resumed monitor ${monitor.id}`, diff --git a/server/test/unit/services/superSimpleQueue.test.ts b/server/test/unit/services/superSimpleQueue.test.ts index 93563dd00..95367cb5a 100644 --- a/server/test/unit/services/superSimpleQueue.test.ts +++ b/server/test/unit/services/superSimpleQueue.test.ts @@ -291,34 +291,46 @@ describe("SuperSimpleQueue", () => { // ── pauseJob ───────────────────────────────────────────────────────────── describe("pauseJob", () => { - it("pauses monitor and removes geo job if it exists", async () => { + it("pauses monitor and pauses geo job when geo exists", async () => { const { queue, scheduler, logger } = createQueue(); (scheduler.getJob as jest.Mock).mockResolvedValue({ id: "mon-1-geo" }); await queue.pauseJob(makeMonitor()); expect(scheduler.pauseJob).toHaveBeenCalledWith("mon-1"); - expect(scheduler.removeJob).toHaveBeenCalledWith("mon-1-geo"); + expect(scheduler.pauseJob).toHaveBeenCalledWith("mon-1-geo"); + expect(scheduler.removeJob).not.toHaveBeenCalledWith("mon-1-geo"); expect(logger.debug).toHaveBeenCalledWith(expect.objectContaining({ message: "Paused monitor mon-1" })); }); - it("does not remove geo job if it does not exist", async () => { + it("does not pause geo job when it does not exist", async () => { const { queue, scheduler } = createQueue(); await queue.pauseJob(makeMonitor()); - expect(scheduler.removeJob).not.toHaveBeenCalledWith("mon-1-geo"); + expect(scheduler.pauseJob).toHaveBeenCalledWith("mon-1"); + expect(scheduler.pauseJob).not.toHaveBeenCalledWith("mon-1-geo"); }); - it("throws when scheduler.pauseJob returns false", async () => { + it("throws when scheduler.pauseJob returns false for the main job", async () => { const { queue, scheduler } = createQueue(); (scheduler.pauseJob as jest.Mock).mockResolvedValue(false); await expect(queue.pauseJob(makeMonitor())).rejects.toThrow("Failed to pause monitor"); }); + + it("logs error but does not throw when geo pauseJob returns false", async () => { + const { queue, scheduler, logger } = createQueue(); + (scheduler.getJob as jest.Mock).mockResolvedValue({ id: "mon-1-geo" }); + (scheduler.pauseJob as jest.Mock).mockImplementation(async (id: string) => id !== "mon-1-geo"); + + await expect(queue.pauseJob(makeMonitor())).resolves.toBeUndefined(); + expect(logger.error).toHaveBeenCalledWith(expect.objectContaining({ message: "Failed to pause geo check job for monitor mon-1" })); + }); }); // ── resumeJob ──────────────────────────────────────────────────────────── describe("resumeJob", () => { - it("resumes monitor and geo job", async () => { + it("resumes monitor and geo job when geo exists", async () => { const { queue, scheduler, logger } = createQueue(); + (scheduler.getJob as jest.Mock).mockResolvedValue({ id: "mon-1-geo" }); await queue.resumeJob(makeMonitor()); expect(scheduler.resumeJob).toHaveBeenCalledWith("mon-1"); @@ -326,11 +338,39 @@ describe("SuperSimpleQueue", () => { expect(logger.debug).toHaveBeenCalledWith(expect.objectContaining({ message: "Resumed monitor mon-1" })); }); - it("throws when scheduler.resumeJob returns false", async () => { + it("does not resume geo job when it does not exist", async () => { + const { queue, scheduler } = createQueue(); + await queue.resumeJob(makeMonitor()); + expect(scheduler.resumeJob).toHaveBeenCalledWith("mon-1"); + expect(scheduler.resumeJob).not.toHaveBeenCalledWith("mon-1-geo"); + }); + + it("throws when scheduler.resumeJob returns false for the main job", async () => { const { queue, scheduler } = createQueue(); (scheduler.resumeJob as jest.Mock).mockResolvedValue(false); await expect(queue.resumeJob(makeMonitor())).rejects.toThrow("Failed to resume monitor"); }); + + it("logs error but does not throw when geo resumeJob returns false", async () => { + const { queue, scheduler, logger } = createQueue(); + (scheduler.getJob as jest.Mock).mockResolvedValue({ id: "mon-1-geo" }); + (scheduler.resumeJob as jest.Mock).mockImplementation(async (id: string) => id !== "mon-1-geo"); + + await expect(queue.resumeJob(makeMonitor())).resolves.toBeUndefined(); + expect(logger.error).toHaveBeenCalledWith(expect.objectContaining({ message: "Failed to resume geo check job for monitor mon-1" })); + }); + + it("preserves geo job through a pause/resume cycle", async () => { + const { queue, scheduler } = createQueue(); + (scheduler.getJob as jest.Mock).mockResolvedValue({ id: "mon-1-geo" }); + + await queue.pauseJob(makeMonitor()); + await queue.resumeJob(makeMonitor()); + + expect(scheduler.removeJob).not.toHaveBeenCalledWith("mon-1-geo"); + expect(scheduler.pauseJob).toHaveBeenCalledWith("mon-1-geo"); + expect(scheduler.resumeJob).toHaveBeenCalledWith("mon-1-geo"); + }); }); // ── updateJob ──────────────────────────────────────────────────────────── From f648ab1b243a11e2bff9e9276c5249c088f75e40 Mon Sep 17 00:00:00 2001 From: Alex Holliday Date: Mon, 4 May 2026 11:52:45 -0700 Subject: [PATCH 055/245] add geoInterval, monitorActive, and repeat to jobs data --- .../SuperSimpleQueue/SuperSimpleQueue.ts | 11 +++++++++++ 1 file changed, 11 insertions(+) diff --git a/server/src/service/infrastructure/SuperSimpleQueue/SuperSimpleQueue.ts b/server/src/service/infrastructure/SuperSimpleQueue/SuperSimpleQueue.ts index 1d50d720c..58e7a9585 100644 --- a/server/src/service/infrastructure/SuperSimpleQueue/SuperSimpleQueue.ts +++ b/server/src/service/infrastructure/SuperSimpleQueue/SuperSimpleQueue.ts @@ -28,6 +28,8 @@ type QueueJobSummary = { monitorUrl: string | null; monitorType: string | null; monitorInterval: number | null; + monitorGeoInterval: number | null; + monitorActive: boolean | null; active: boolean; lockedAt: number | null; runCount: number; @@ -37,6 +39,7 @@ type QueueJobSummary = { lastFinishedAt: number | null; lastRunTook: number | null; lastFailedAt: number | null; + repeat: number | null; }; export interface ISuperSimpleQueue { @@ -223,6 +226,8 @@ export class SuperSimpleQueue implements ISuperSimpleQueue { if (result === false) { throw new Error("Failed to pause monitor"); } + this.scheduler.updateJob(monitor.id, { data: monitor }); + const geoJob = await this.scheduler.getJob(`${monitor.id}-geo`); if (geoJob) { const geoRes = await this.scheduler.pauseJob(`${monitor.id}-geo`); @@ -233,6 +238,7 @@ export class SuperSimpleQueue implements ISuperSimpleQueue { method: "pauseJob", }); } + this.scheduler.updateJob(`${monitor.id}-geo`, { data: monitor }); } this.logger.debug({ @@ -247,6 +253,7 @@ export class SuperSimpleQueue implements ISuperSimpleQueue { if (result === false) { throw new Error("Failed to resume monitor"); } + this.scheduler.updateJob(monitor.id, { data: monitor }); const geoJob = await this.scheduler.getJob(`${monitor.id}-geo`); if (geoJob) { @@ -258,6 +265,7 @@ export class SuperSimpleQueue implements ISuperSimpleQueue { method: "resumeJob", }); } + this.scheduler.updateJob(`${monitor.id}-geo`, { data: monitor }); } this.logger.debug({ message: `Resumed monitor ${monitor.id}`, @@ -359,7 +367,10 @@ export class SuperSimpleQueue implements ISuperSimpleQueue { monitorUrl: job.data?.url || null, monitorType: job.data?.type || null, monitorInterval: job.data?.interval || null, + monitorGeoInterval: job.data?.geoCheckInterval || null, + monitorActive: job.data?.isActive ?? null, active: job.active, + repeat: job.repeat ?? null, lockedAt: job.lockedAt ?? null, runCount: job.runCount ?? 0, failCount: job.failCount ?? 0, From 22230b8a08f4882eb91b88cc2fb3734b54b47df4 Mon Sep 17 00:00:00 2001 From: Alex Holliday Date: Mon, 4 May 2026 11:53:12 -0700 Subject: [PATCH 056/245] UI fixes for job queue table --- client/src/Pages/Logs/TabQueue.tsx | 2 +- .../src/Pages/Logs/components/TableJobs.tsx | 47 ++++++++++++++++--- client/src/Types/Queue.ts | 3 ++ client/src/locales/en.json | 5 +- 4 files changed, 49 insertions(+), 8 deletions(-) diff --git a/client/src/Pages/Logs/TabQueue.tsx b/client/src/Pages/Logs/TabQueue.tsx index 84573e8f0..51c9693c0 100644 --- a/client/src/Pages/Logs/TabQueue.tsx +++ b/client/src/Pages/Logs/TabQueue.tsx @@ -19,7 +19,7 @@ export const TabQueue = () => { isLoading, // error, refetch, - } = useGet("/queue/all-metrics", {}, { refreshInterval: 5000 }); + } = useGet("/queue/all-metrics", {}, { refreshInterval: 1000 }); const { post, loading: isFlushing } = usePost(); diff --git a/client/src/Pages/Logs/components/TableJobs.tsx b/client/src/Pages/Logs/components/TableJobs.tsx index f690bd417..1f1da1003 100644 --- a/client/src/Pages/Logs/components/TableJobs.tsx +++ b/client/src/Pages/Logs/components/TableJobs.tsx @@ -30,6 +30,22 @@ export const TableJobs = ({ jobs }: TableJobsProps) => { }; const headers: Header[] = [ + { + id: "state", + content: t("pages.logs.table.headers.state"), + render: (row) => { + if (row.lockedAt) { + return t("common.labels.running"); + } + if (row.monitorActive === true) { + return t("common.labels.active"); + } + if (row.monitorActive === false) { + return t("common.labels.paused"); + } + return t("common.labels.idle"); + }, + }, { id: "id", content: t("common.table.headers.monitorId"), @@ -47,7 +63,10 @@ export const TableJobs = ({ jobs }: TableJobsProps) => { id: "url", content: t("common.table.headers.url"), render: (row) => ( - + {row.monitorUrl} ), @@ -60,9 +79,15 @@ export const TableJobs = ({ jobs }: TableJobsProps) => { { id: "interval", content: t("common.table.headers.interval"), - render: (row) => ( - {prettyMilliseconds(row.monitorInterval ?? 0)} - ), + render: (row) => { + let interval; + if (row.monitorId.toString().includes("geo")) { + interval = row.monitorGeoInterval ?? 0; + } else { + interval = row.repeat ?? 0; + } + return {prettyMilliseconds(interval)}; + }, }, { id: "lastRun", @@ -70,7 +95,10 @@ export const TableJobs = ({ jobs }: TableJobsProps) => { render: (row) => { const v = formatTimestamp(row.lastRunAt) ?? "-"; return ( - + {v} ); @@ -90,7 +118,10 @@ export const TableJobs = ({ jobs }: TableJobsProps) => { render: (row) => { const v = formatTimestamp(row.lockedAt) ?? "-"; return ( - + {v} ); @@ -100,6 +131,7 @@ export const TableJobs = ({ jobs }: TableJobsProps) => { const isDark = theme.palette.mode === "dark"; const runningBg = isDark ? "rgba(19, 113, 91, 0.18)" : "#ECF7F2"; + const pausedBg = isDark ? "rgba(255, 165, 0, 0.18)" : "#FFF4E5"; return ( { ...(row.lockedAt && { "& td": { backgroundColor: runningBg }, }), + ...(row.monitorActive === false && { + "& td": { backgroundColor: pausedBg }, + }), })} /> ); diff --git a/client/src/Types/Queue.ts b/client/src/Types/Queue.ts index 77a770aeb..bbb3ca9a4 100644 --- a/client/src/Types/Queue.ts +++ b/client/src/Types/Queue.ts @@ -23,6 +23,8 @@ export interface QueueJobSummary { monitorUrl: string | null; monitorType: MonitorType | null; monitorInterval: number | null; + monitorGeoInterval: number | null; + monitorActive: boolean | null; active: boolean; lockedAt: number | null; runCount: number; @@ -32,6 +34,7 @@ export interface QueueJobSummary { lastFinishedAt: number | null; lastRunTook: number | null; lastFailedAt: number | null; + repeat: number | null; } export interface QueueData { diff --git a/client/src/locales/en.json b/client/src/locales/en.json index b5fe0c1cb..a08cdfe8a 100644 --- a/client/src/locales/en.json +++ b/client/src/locales/en.json @@ -70,7 +70,9 @@ }, "labels": { "active": "Active", - "paused": "paused", + "running": "Running", + "paused": "Paused", + "idle": "Idle", "na": "N/A", "resolved": "Resolved", "responseTime": "Response time" @@ -887,6 +889,7 @@ }, "table": { "headers": { + "state": "State", "timestamp": "Timestamp", "level": "Level", "service": "Service", From 1fd56610eaca3e1b55f95e4f013a93551de56b5c Mon Sep 17 00:00:00 2001 From: Alex Holliday Date: Mon, 4 May 2026 11:55:39 -0700 Subject: [PATCH 057/245] update tests --- .../unit/services/superSimpleQueue.test.ts | 80 ++++++++++++++++++- 1 file changed, 79 insertions(+), 1 deletion(-) diff --git a/server/test/unit/services/superSimpleQueue.test.ts b/server/test/unit/services/superSimpleQueue.test.ts index 95367cb5a..685dd71c7 100644 --- a/server/test/unit/services/superSimpleQueue.test.ts +++ b/server/test/unit/services/superSimpleQueue.test.ts @@ -302,6 +302,17 @@ describe("SuperSimpleQueue", () => { expect(logger.debug).toHaveBeenCalledWith(expect.objectContaining({ message: "Paused monitor mon-1" })); }); + it("refreshes scheduler data with current monitor on pause", async () => { + const { queue, scheduler } = createQueue(); + (scheduler.getJob as jest.Mock).mockResolvedValue({ id: "mon-1-geo" }); + const monitor = makeMonitor({ isActive: false, status: "paused" }); + + await queue.pauseJob(monitor); + + expect(scheduler.updateJob).toHaveBeenCalledWith("mon-1", { data: monitor }); + expect(scheduler.updateJob).toHaveBeenCalledWith("mon-1-geo", { data: monitor }); + }); + it("does not pause geo job when it does not exist", async () => { const { queue, scheduler } = createQueue(); await queue.pauseJob(makeMonitor()); @@ -309,12 +320,26 @@ describe("SuperSimpleQueue", () => { expect(scheduler.pauseJob).not.toHaveBeenCalledWith("mon-1-geo"); }); + it("does not refresh geo data when geo job does not exist", async () => { + const { queue, scheduler } = createQueue(); + await queue.pauseJob(makeMonitor()); + expect(scheduler.updateJob).toHaveBeenCalledWith("mon-1", expect.objectContaining({ data: expect.anything() })); + expect(scheduler.updateJob).not.toHaveBeenCalledWith("mon-1-geo", expect.anything()); + }); + it("throws when scheduler.pauseJob returns false for the main job", async () => { const { queue, scheduler } = createQueue(); (scheduler.pauseJob as jest.Mock).mockResolvedValue(false); await expect(queue.pauseJob(makeMonitor())).rejects.toThrow("Failed to pause monitor"); }); + it("does not refresh data when main pauseJob throws", async () => { + const { queue, scheduler } = createQueue(); + (scheduler.pauseJob as jest.Mock).mockResolvedValue(false); + await expect(queue.pauseJob(makeMonitor())).rejects.toThrow(); + expect(scheduler.updateJob).not.toHaveBeenCalled(); + }); + it("logs error but does not throw when geo pauseJob returns false", async () => { const { queue, scheduler, logger } = createQueue(); (scheduler.getJob as jest.Mock).mockResolvedValue({ id: "mon-1-geo" }); @@ -338,6 +363,17 @@ describe("SuperSimpleQueue", () => { expect(logger.debug).toHaveBeenCalledWith(expect.objectContaining({ message: "Resumed monitor mon-1" })); }); + it("refreshes scheduler data with current monitor on resume", async () => { + const { queue, scheduler } = createQueue(); + (scheduler.getJob as jest.Mock).mockResolvedValue({ id: "mon-1-geo" }); + const monitor = makeMonitor({ isActive: true, status: "initializing" }); + + await queue.resumeJob(monitor); + + expect(scheduler.updateJob).toHaveBeenCalledWith("mon-1", { data: monitor }); + expect(scheduler.updateJob).toHaveBeenCalledWith("mon-1-geo", { data: monitor }); + }); + it("does not resume geo job when it does not exist", async () => { const { queue, scheduler } = createQueue(); await queue.resumeJob(makeMonitor()); @@ -345,12 +381,26 @@ describe("SuperSimpleQueue", () => { expect(scheduler.resumeJob).not.toHaveBeenCalledWith("mon-1-geo"); }); + it("does not refresh geo data when geo job does not exist", async () => { + const { queue, scheduler } = createQueue(); + await queue.resumeJob(makeMonitor()); + expect(scheduler.updateJob).toHaveBeenCalledWith("mon-1", expect.objectContaining({ data: expect.anything() })); + expect(scheduler.updateJob).not.toHaveBeenCalledWith("mon-1-geo", expect.anything()); + }); + it("throws when scheduler.resumeJob returns false for the main job", async () => { const { queue, scheduler } = createQueue(); (scheduler.resumeJob as jest.Mock).mockResolvedValue(false); await expect(queue.resumeJob(makeMonitor())).rejects.toThrow("Failed to resume monitor"); }); + it("does not refresh data when main resumeJob throws", async () => { + const { queue, scheduler } = createQueue(); + (scheduler.resumeJob as jest.Mock).mockResolvedValue(false); + await expect(queue.resumeJob(makeMonitor())).rejects.toThrow(); + expect(scheduler.updateJob).not.toHaveBeenCalled(); + }); + it("logs error but does not throw when geo resumeJob returns false", async () => { const { queue, scheduler, logger } = createQueue(); (scheduler.getJob as jest.Mock).mockResolvedValue({ id: "mon-1-geo" }); @@ -507,6 +557,7 @@ describe("SuperSimpleQueue", () => { { id: "m1", active: true, + repeat: 60000, lockedAt: null, runCount: 5, failCount: 1, @@ -514,7 +565,7 @@ describe("SuperSimpleQueue", () => { lastRunAt: 1000, lastFinishedAt: 1100, lastFailedAt: 900, - data: { url: "http://a.com", type: "http", interval: 60000 }, + data: { url: "http://a.com", type: "http", interval: 60000, geoCheckInterval: 300000, isActive: true }, }, ]); const jobs = await queue.getJobs(); @@ -525,7 +576,10 @@ describe("SuperSimpleQueue", () => { monitorUrl: "http://a.com", monitorType: "http", monitorInterval: 60000, + monitorGeoInterval: 300000, + monitorActive: true, active: true, + repeat: 60000, lockedAt: null, runCount: 5, failCount: 1, @@ -553,6 +607,30 @@ describe("SuperSimpleQueue", () => { expect(jobs[0].monitorUrl).toBeNull(); expect(jobs[0].monitorType).toBeNull(); expect(jobs[0].monitorInterval).toBeNull(); + expect(jobs[0].monitorGeoInterval).toBeNull(); + expect(jobs[0].monitorActive).toBeNull(); + expect(jobs[0].repeat).toBeNull(); + }); + + it("exposes monitor.isActive as monitorActive in the summary", async () => { + const { queue, scheduler } = createQueue(); + (scheduler.getJobs as jest.Mock).mockResolvedValue([ + { id: "m1", active: true, data: { url: "a", type: "http", interval: 60000, isActive: true } }, + { id: "m2", active: false, data: { url: "b", type: "http", interval: 60000, isActive: false } }, + ]); + const jobs = await queue.getJobs(); + expect(jobs[0].monitorActive).toBe(true); + expect(jobs[1].monitorActive).toBe(false); + }); + + it("exposes geo check interval and repeat", async () => { + const { queue, scheduler } = createQueue(); + (scheduler.getJobs as jest.Mock).mockResolvedValue([ + { id: "m1", active: true, repeat: 120000, data: { url: "a", type: "http", interval: 60000, geoCheckInterval: 600000 } }, + ]); + const jobs = await queue.getJobs(); + expect(jobs[0].repeat).toBe(120000); + expect(jobs[0].monitorGeoInterval).toBe(600000); }); }); From 2e9b87ba32ee75406f43ea7195a5a8ff58e2d9ca Mon Sep 17 00:00:00 2001 From: Alex Holliday Date: Mon, 4 May 2026 11:59:02 -0700 Subject: [PATCH 058/245] normalize tabqueue --- client/src/Pages/Logs/TabQueue.tsx | 38 +++++++++++------------------ client/src/Utils/Theme/constants.ts | 1 + 2 files changed, 15 insertions(+), 24 deletions(-) diff --git a/client/src/Pages/Logs/TabQueue.tsx b/client/src/Pages/Logs/TabQueue.tsx index 51c9693c0..87d658635 100644 --- a/client/src/Pages/Logs/TabQueue.tsx +++ b/client/src/Pages/Logs/TabQueue.tsx @@ -10,6 +10,7 @@ import type { QueueData } from "@/Types/Queue"; import { Metrics } from "@/Pages/Logs/components/Metrics"; import { Button } from "@/Components/inputs"; import { EmptyState } from "@/Components/design-elements"; +import { SPACING } from "@/Utils/Theme/constants"; export const TabQueue = () => { const theme = useTheme(); @@ -49,32 +50,26 @@ export const TabQueue = () => { {t("pages.logs.jobQueue")} - + ), }} @@ -87,16 +82,11 @@ export const TabQueue = () => { {t("pages.logs.failedJobs")} - + {t("pages.logs.failedJobsExplainer")} diff --git a/client/src/Utils/Theme/constants.ts b/client/src/Utils/Theme/constants.ts index d718d61dc..518657183 100644 --- a/client/src/Utils/Theme/constants.ts +++ b/client/src/Utils/Theme/constants.ts @@ -1,4 +1,5 @@ export const SPACING = { + XXS: 0.25, XS: 0.5, SM: 1, MD: 1.5, From 3365a8f7da3f0d540da1ea521706f94a10360720 Mon Sep 17 00:00:00 2001 From: Alex Holliday Date: Mon, 4 May 2026 12:11:57 -0700 Subject: [PATCH 059/245] extract colors to theme --- client/src/Pages/Logs/TabQueue.tsx | 6 +----- client/src/Pages/Logs/components/TableJobs.tsx | 8 ++------ client/src/Utils/Theme/Palette.ts | 13 +++++++++++++ client/src/Utils/Theme/Theme.ts | 2 ++ 4 files changed, 18 insertions(+), 11 deletions(-) diff --git a/client/src/Pages/Logs/TabQueue.tsx b/client/src/Pages/Logs/TabQueue.tsx index 87d658635..a39250be0 100644 --- a/client/src/Pages/Logs/TabQueue.tsx +++ b/client/src/Pages/Logs/TabQueue.tsx @@ -61,11 +61,7 @@ export const TabQueue = () => { highlight: ( { }, ]; - const isDark = theme.palette.mode === "dark"; - const runningBg = isDark ? "rgba(19, 113, 91, 0.18)" : "#ECF7F2"; - const pausedBg = isDark ? "rgba(255, 165, 0, 0.18)" : "#FFF4E5"; - return (
({ ...(row.lockedAt && { - "& td": { backgroundColor: runningBg }, + "& td": { backgroundColor: theme.palette.rowStatus.running }, }), ...(row.monitorActive === false && { - "& td": { backgroundColor: pausedBg }, + "& td": { backgroundColor: theme.palette.rowStatus.paused }, }), })} /> diff --git a/client/src/Utils/Theme/Palette.ts b/client/src/Utils/Theme/Palette.ts index 92c08cd2f..55dc1e26e 100644 --- a/client/src/Utils/Theme/Palette.ts +++ b/client/src/Utils/Theme/Palette.ts @@ -1,3 +1,5 @@ +import { alpha } from "@mui/material/styles"; + const typographyBase = 13; export const typographyLevels = { @@ -17,6 +19,9 @@ export const colors = { gray850: "#1c1c21", brandGreen: "#13715B", brandGreenLight: "#4DAF94", + green200: "#ECF7F2", + yellow200: "#FFF4E5", + orange500: "#FFA500", }; export const lightPalette = { @@ -29,6 +34,10 @@ export const lightPalette = { sidebar: { accent: colors.brandGreen, }, + rowStatus: { + running: colors.green200, + paused: colors.yellow200, + }, }; export const darkPalette = { @@ -45,4 +54,8 @@ export const darkPalette = { sidebar: { accent: colors.brandGreenLight, }, + rowStatus: { + running: alpha(colors.brandGreen, 0.18), + paused: alpha(colors.orange500, 0.18), + }, }; diff --git a/client/src/Utils/Theme/Theme.ts b/client/src/Utils/Theme/Theme.ts index 7c712d66d..04f4c94eb 100644 --- a/client/src/Utils/Theme/Theme.ts +++ b/client/src/Utils/Theme/Theme.ts @@ -15,9 +15,11 @@ declare module "@mui/material/styles" { } interface Palette { sidebar: { accent: string }; + rowStatus: { running: string; paused: string }; } interface PaletteOptions { sidebar?: { accent: string }; + rowStatus?: { running: string; paused: string }; } } From 387cb35494406414979db3ce29d984fa2df03d61 Mon Sep 17 00:00:00 2001 From: harsh-aghara Date: Tue, 5 May 2026 17:06:48 +0530 Subject: [PATCH 060/245] refactor(ui): simplify table selection and clarify mobile label rendering Addresses PR feedback: - Removes over-abstracted useTableSelection hook, inlines selection logic - Replaces triple-condition with explicit hideMobileLabel bool flag - Removes unsafe Record casting Fixes #3602 --- .../src/Components/design-elements/Table.tsx | 7 +- client/src/Hooks/useTableSelection.ts | 74 ------------------- .../Monitors/Components/MonitorsTable.tsx | 29 +++++--- .../Components/UptimeMonitorsTable.tsx | 29 +++++--- 4 files changed, 41 insertions(+), 98 deletions(-) delete mode 100644 client/src/Hooks/useTableSelection.ts diff --git a/client/src/Components/design-elements/Table.tsx b/client/src/Components/design-elements/Table.tsx index c6628a611..a02f9be8f 100644 --- a/client/src/Components/design-elements/Table.tsx +++ b/client/src/Components/design-elements/Table.tsx @@ -36,6 +36,7 @@ export type Header = { id: number | string; content: React.ReactNode; mobileLabel?: React.ReactNode; + hideMobileLabel?: boolean; align?: "left" | "center" | "right" | "justify" | "inherit"; onClick?: (event: React.MouseEvent, row: T) => void; render: (row: T) => React.ReactNode; @@ -111,7 +112,7 @@ export function DataTable< key={key} > {headers.map((header) => { - if (header.mobileLabel === null) { + if (header.hideMobileLabel) { return ( - {header.mobileLabel !== undefined - ? header.mobileLabel - : header.content} + {header.mobileLabel ?? header.content} void; - handleSelectRow: (itemId: string, checked: boolean) => void; - isRowSelected: (itemId: string) => boolean; -} - -export const useTableSelection = ( - items: SelectableItem[], - selectedIds: string[], - onSelectionChange?: (selected: string[]) => void -): UseTableSelectionReturn => { - const selectedSet = useMemo(() => new Set(selectedIds), [selectedIds]); - - const isAllSelected = useMemo( - () => - items.length > 0 && - items.every((item) => selectedSet.has(item.id)) && - selectedIds.length >= items.length, - [items, selectedSet, selectedIds.length] - ); - - const isSomeSelected = useMemo( - () => selectedIds.length > 0 && !isAllSelected, - [selectedIds.length, isAllSelected] - ); - - const handleSelectAll = useCallback( - (checked: boolean) => { - if (onSelectionChange) { - if (checked) { - const allIds = items.map((item) => item.id); - onSelectionChange(allIds); - } else { - onSelectionChange([]); - } - } - }, - [items, onSelectionChange] - ); - - const handleSelectRow = useCallback( - (itemId: string, checked: boolean) => { - if (onSelectionChange) { - if (checked) { - onSelectionChange([...selectedIds, itemId]); - } else { - onSelectionChange(selectedIds.filter((id) => id !== itemId)); - } - } - }, - [selectedIds, onSelectionChange] - ); - - const isRowSelected = useCallback( - (itemId: string) => selectedSet.has(itemId), - [selectedSet] - ); - - return { - isAllSelected, - isSomeSelected, - handleSelectAll, - handleSelectRow, - isRowSelected, - }; -}; diff --git a/client/src/Pages/Infrastructure/Monitors/Components/MonitorsTable.tsx b/client/src/Pages/Infrastructure/Monitors/Components/MonitorsTable.tsx index dbae1b1e9..e6458ea34 100644 --- a/client/src/Pages/Infrastructure/Monitors/Components/MonitorsTable.tsx +++ b/client/src/Pages/Infrastructure/Monitors/Components/MonitorsTable.tsx @@ -14,7 +14,6 @@ import { usePost } from "@/Hooks/UseApi"; import type { Monitor } from "@/Types/Monitor"; import { Checkbox } from "@/Components/inputs"; -import { useTableSelection } from "@/Hooks/useTableSelection"; interface InfraMonitorsTableProps { monitors: Monitor[]; @@ -55,13 +54,23 @@ export const InfraMonitorsTable = ({ const navigate = useNavigate(); const { post } = usePost, Monitor>(); - const { - isAllSelected, - isSomeSelected, - handleSelectAll, - handleSelectRow, - isRowSelected, - } = useTableSelection(monitors, selectedRows, onSelectionChange); + const selectedSet = new Set(selectedRows); + const isAllSelected = + monitors.length > 0 && monitors.every((m) => selectedSet.has(m.id)); + const isSomeSelected = selectedRows.length > 0 && !isAllSelected; + + const handleSelectAll = (checked: boolean) => { + onSelectionChange?.(checked ? monitors.map((m) => m.id) : []); + }; + + const handleSelectRow = (id: string, checked: boolean) => { + if (!onSelectionChange) return; + onSelectionChange( + checked ? [...selectedRows, id] : selectedRows.filter((rowId) => rowId !== id) + ); + }; + + const isRowSelected = (id: string) => selectedSet.has(id); const handlePageChange = ( _e: React.MouseEvent | null, @@ -121,7 +130,7 @@ export const InfraMonitorsTable = ({ ? t("common.buttons.resume") : t("common.buttons.pause"), action: async () => { - await post(`/monitors/pause/${monitor.id}`, {} as Record); + await post(`/monitors/pause/${monitor.id}`, {}); refetch(); }, closeMenu: true, @@ -168,7 +177,7 @@ export const InfraMonitorsTable = ({ onChange={(e) => handleSelectAll(e.target.checked)} /> ), - mobileLabel: null, + hideMobileLabel: true, render: (row) => ( state.ui?.chartType ?? "histogram"); const { post } = usePost, Monitor>(); - const { - isAllSelected, - isSomeSelected, - handleSelectAll, - handleSelectRow, - isRowSelected, - } = useTableSelection(monitors, selectedRows, onSelectionChange); + const selectedSet = new Set(selectedRows); + const isAllSelected = + monitors.length > 0 && monitors.every((m) => selectedSet.has(m.id)); + const isSomeSelected = selectedRows.length > 0 && !isAllSelected; + + const handleSelectAll = (checked: boolean) => { + onSelectionChange?.(checked ? monitors.map((m) => m.id) : []); + }; + + const handleSelectRow = (id: string, checked: boolean) => { + if (!onSelectionChange) return; + onSelectionChange( + checked ? [...selectedRows, id] : selectedRows.filter((rowId) => rowId !== id) + ); + }; + + const isRowSelected = (id: string) => selectedSet.has(id); const handleSort = (e: React.MouseEvent, field: string) => { e.preventDefault(); @@ -129,7 +138,7 @@ export const MonitorTable = ({ ? t("pages.common.monitors.actions.resume") : t("pages.common.monitors.actions.pause"), action: async () => { - await post(`/monitors/pause/${monitor.id}`, {} as Record); + await post(`/monitors/pause/${monitor.id}`, {}); refetch(); }, closeMenu: true, @@ -175,7 +184,7 @@ export const MonitorTable = ({ onChange={(e) => handleSelectAll(e.target.checked)} /> ), - mobileLabel: null, + hideMobileLabel: true, render: (row) => ( Date: Tue, 5 May 2026 17:07:13 +0530 Subject: [PATCH 061/245] fix(ui): enforce strict type safety in bulk action error handling Addresses PR feedback: - Replaces forbidden any type in catch block with unknown - Adds type discrimination using axios.isAxiosError and instanceof Error - Narrows error type for logger.error compatibility Fixes #3602 --- client/src/Hooks/useBulkMonitorActions.ts | 16 +++++++++++++--- 1 file changed, 13 insertions(+), 3 deletions(-) diff --git a/client/src/Hooks/useBulkMonitorActions.ts b/client/src/Hooks/useBulkMonitorActions.ts index ddf96fedb..268137392 100644 --- a/client/src/Hooks/useBulkMonitorActions.ts +++ b/client/src/Hooks/useBulkMonitorActions.ts @@ -1,4 +1,5 @@ import { useState, useEffect } from "react"; +import axios from "axios"; import { post } from "@/Utils/ApiClient"; import { useToast } from "@/Hooks/UseToast"; import { useTranslation } from "react-i18next"; @@ -55,9 +56,18 @@ export const useBulkMonitorActions = ( setSelectedRows([]); refetch(); - } catch (err: any) { - const errMsg = err?.response?.data?.msg || err.message || "An error occurred"; - logger.error("Bulk pause/resume failed", err, { pause }); + } catch (err: unknown) { + let errMsg = "An error occurred"; + + if (axios.isAxiosError(err)) { + errMsg = err.response?.data?.msg || err.message || errMsg; + } else if (err instanceof Error) { + errMsg = err.message; + } + + logger.error("Bulk pause/resume failed", err instanceof Error ? err : undefined, { + pause, + }); toastError(errMsg); } }; From e725128b10c040d0d9f5864b532c2a9fd953e076 Mon Sep 17 00:00:00 2001 From: harsh-aghara Date: Tue, 5 May 2026 17:08:25 +0530 Subject: [PATCH 062/245] feat(monitors): track and report bulk action queue failures Addresses PR feedback: - Upgrades bulk pausing from Promise.all to Promise.allSettled - Logs individual job queue sync failures with monitor ID context - Surfaces failedCount to the frontend via controller response - Adds unit tests to verify partial failure handling Fixes #3602 --- server/src/controllers/monitorController.ts | 9 ++- server/src/service/business/monitorService.ts | 29 ++++++++-- .../test/unit/services/monitorService.test.ts | 55 +++++++++++++++++-- 3 files changed, 82 insertions(+), 11 deletions(-) diff --git a/server/src/controllers/monitorController.ts b/server/src/controllers/monitorController.ts index 7422d5abb..5cb6f32be 100644 --- a/server/src/controllers/monitorController.ts +++ b/server/src/controllers/monitorController.ts @@ -315,14 +315,19 @@ class MonitorController implements IMonitorController { const { monitorIds, pause } = validatedBody; const teamId = requireTeamId(req.user?.teamId); - const monitors = await this.monitorService.bulkPauseMonitors({ teamId, monitorIds, pause }); + const { monitors, failedCount } = await this.monitorService.bulkPauseMonitors({ teamId, monitorIds, pause }); const action = pause ? "paused" : "resumed"; const monitorStr = monitors.length === 1 ? "monitor" : "monitors"; + let msg = `${monitors.length} ${monitorStr} ${action} successfully`; + if (failedCount > 0) { + msg = `${monitors.length} ${monitorStr} ${action} in database, but ${failedCount} failed to sync with the job queue. Please check logs.`; + } + return res.status(200).json({ success: true, - msg: `${monitors.length} ${monitorStr} ${action} successfully`, + msg, data: monitors, }); } catch (error) { diff --git a/server/src/service/business/monitorService.ts b/server/src/service/business/monitorService.ts index e2430420d..d0dc8ca46 100644 --- a/server/src/service/business/monitorService.ts +++ b/server/src/service/business/monitorService.ts @@ -73,7 +73,7 @@ export interface IMonitorService { // update editMonitor(args: { teamId: string; monitorId: string; body: Partial }): Promise; pauseMonitor(args: { teamId: string; monitorId: string }): Promise; - bulkPauseMonitors(args: { teamId: string; monitorIds: string[]; pause: boolean }): Promise; + bulkPauseMonitors(args: { teamId: string; monitorIds: string[]; pause: boolean }): Promise<{ monitors: Monitor[]; failedCount: number }>; // delete deleteMonitor(args: { teamId: string; monitorId: string }): Promise; @@ -463,10 +463,18 @@ export class MonitorService implements IMonitorService { return monitor; }; - bulkPauseMonitors = async ({ teamId, monitorIds, pause }: { teamId: string; monitorIds: string[]; pause: boolean }): Promise => { + bulkPauseMonitors = async ({ + teamId, + monitorIds, + pause, + }: { + teamId: string; + monitorIds: string[]; + pause: boolean; + }): Promise<{ monitors: Monitor[]; failedCount: number }> => { const monitors = await this.monitorsRepository.bulkTogglePause(monitorIds, teamId, pause); - await Promise.all( + const results = await Promise.allSettled( monitors.map(async (monitor) => { if (monitor.isActive) { await this.jobQueue.resumeJob(monitor); @@ -476,7 +484,20 @@ export class MonitorService implements IMonitorService { }) ); - return monitors; + let failedCount = 0; + results.forEach((result, index) => { + if (result.status === "rejected") { + failedCount++; + this.logger.error({ + message: `Failed to sync job queue for monitor ${monitors[index].id} during bulk ${pause ? "pause" : "resume"}`, + service: SERVICE_NAME, + method: "bulkPauseMonitors", + stack: result.reason instanceof Error ? result.reason.stack : undefined, + }); + } + }); + + return { monitors, failedCount }; }; deleteMonitor = async ({ teamId, monitorId }: { teamId: string; monitorId: string }): Promise => { diff --git a/server/test/unit/services/monitorService.test.ts b/server/test/unit/services/monitorService.test.ts index 74a0efa98..d2ee164bc 100644 --- a/server/test/unit/services/monitorService.test.ts +++ b/server/test/unit/services/monitorService.test.ts @@ -853,7 +853,8 @@ describe("MonitorService", () => { pause: false, }); - expect(result).toHaveLength(2); + expect(result.monitors).toHaveLength(2); + expect(result.failedCount).toBe(0); expect(monitorsRepository.bulkTogglePause).toHaveBeenCalledWith(["m1", "m2"], TEAM_ID, false); expect(jobQueue.resumeJob).toHaveBeenCalledTimes(2); expect(jobQueue.pauseJob).not.toHaveBeenCalled(); @@ -871,7 +872,8 @@ describe("MonitorService", () => { pause: true, }); - expect(result).toHaveLength(2); + expect(result.monitors).toHaveLength(2); + expect(result.failedCount).toBe(0); expect(monitorsRepository.bulkTogglePause).toHaveBeenCalledWith(["m1", "m2"], TEAM_ID, true); expect(jobQueue.pauseJob).toHaveBeenCalledTimes(2); expect(jobQueue.resumeJob).not.toHaveBeenCalled(); @@ -890,7 +892,8 @@ describe("MonitorService", () => { pause: true, }); - expect(result).toHaveLength(2); + expect(result.monitors).toHaveLength(2); + expect(result.failedCount).toBe(0); expect(jobQueue.resumeJob).toHaveBeenCalledTimes(1); expect(jobQueue.resumeJob).toHaveBeenCalledWith(activeMonitor); expect(jobQueue.pauseJob).toHaveBeenCalledTimes(1); @@ -908,7 +911,8 @@ describe("MonitorService", () => { pause: true, }); - expect(result).toHaveLength(0); + expect(result.monitors).toHaveLength(0); + expect(result.failedCount).toBe(0); expect(jobQueue.pauseJob).not.toHaveBeenCalled(); expect(jobQueue.resumeJob).not.toHaveBeenCalled(); }); @@ -925,9 +929,50 @@ describe("MonitorService", () => { pause: true, }); - expect(result).toHaveLength(1); + expect(result.monitors).toHaveLength(1); + expect(result.failedCount).toBe(0); expect(jobQueue.pauseJob).toHaveBeenCalledWith(monitor); }); + + it("returns failedCount and logs errors when multiple job operations reject", async () => { + const monitorsRepository = createMonitorsRepositoryMock(); + const monitors = [ + makeMonitor({ id: "m1", isActive: false }), + makeMonitor({ id: "m2", isActive: false }), + makeMonitor({ id: "m3", isActive: false }), + ]; + (monitorsRepository.bulkTogglePause as jest.Mock).mockResolvedValue(monitors); + const jobQueue = createJobQueueMock(); + (jobQueue.pauseJob as jest.Mock) + .mockRejectedValueOnce(new Error("Queue failure 1")) + .mockResolvedValueOnce(undefined) + .mockRejectedValueOnce(new Error("Queue failure 2")); + + const logger = createMockLogger(); + const { service } = createService({ monitorsRepository, jobQueue, logger }); + + const result = await service.bulkPauseMonitors({ + teamId: TEAM_ID, + monitorIds: ["m1", "m2", "m3"], + pause: true, + }); + + expect(result.monitors).toHaveLength(3); + expect(result.failedCount).toBe(2); + expect(logger.error).toHaveBeenCalledTimes(2); + expect(logger.error).toHaveBeenCalledWith( + expect.objectContaining({ + message: expect.stringContaining("Failed to sync job queue for monitor m1"), + method: "bulkPauseMonitors", + }) + ); + expect(logger.error).toHaveBeenCalledWith( + expect.objectContaining({ + message: expect.stringContaining("Failed to sync job queue for monitor m3"), + method: "bulkPauseMonitors", + }) + ); + }); }); describe("deleteMonitor", () => { From ba2d23fd822a0aaa6c57eca95291e42080a250cb Mon Sep 17 00:00:00 2001 From: harsh-aghara Date: Tue, 5 May 2026 23:43:06 +0530 Subject: [PATCH 063/245] refactor(monitors): optimize bulkTogglePause from 3 to 2 DB queries Use find + updateMany with timestamps:false instead of find IDs + updateMany + fetch updated. Manually set updatedAt and edit in-memory documents to avoid a third DB round trip. Fixes #3602 --- .../monitors/MongoMonitorsRepository.ts | 25 +++++++++++-------- 1 file changed, 14 insertions(+), 11 deletions(-) diff --git a/server/src/repositories/monitors/MongoMonitorsRepository.ts b/server/src/repositories/monitors/MongoMonitorsRepository.ts index 9f5efecfe..3e7de17eb 100644 --- a/server/src/repositories/monitors/MongoMonitorsRepository.ts +++ b/server/src/repositories/monitors/MongoMonitorsRepository.ts @@ -241,22 +241,25 @@ class MongoMonitorsRepository implements IMonitorsRepository { teamId: new mongoose.Types.ObjectId(teamId), isActive: pause, // Only pause if active (true), only resume if inactive (false) }; + const nextStatus = pause ? "paused" : "initializing"; - const eligibleIds = (await MonitorModel.find(filter).select("_id").lean()).map((doc) => doc._id); - if (eligibleIds.length === 0) return []; + const eligible = await MonitorModel.find(filter); + if (eligible.length === 0) return []; + const now = new Date(); await MonitorModel.updateMany( - { _id: { $in: eligibleIds } }, - { - $set: { - isActive: !pause, - status: pause ? "paused" : "initializing", - }, - } + { _id: { $in: eligible.map((doc) => doc._id) } }, + { $set: { isActive: !pause, status: nextStatus, updatedAt: now } }, + { timestamps: false } ); - const updatedMonitors = await MonitorModel.find({ _id: { $in: eligibleIds } }); - return this.mapDocuments(updatedMonitors); + eligible.forEach((doc) => { + doc.isActive = !pause; + doc.status = nextStatus; + doc.updatedAt = now; + }); + + return this.mapDocuments(eligible); }; deleteById = async (monitorId: string, teamId: string) => { From c72287cb2181c29cb346f8b0fa38b6ace6dea2e5 Mon Sep 17 00:00:00 2001 From: harsh-aghara Date: Wed, 6 May 2026 00:09:29 +0530 Subject: [PATCH 064/245] fix(monitor): Resolve build fail. Fixes #3602 --- server/src/service/business/monitorService.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/server/src/service/business/monitorService.ts b/server/src/service/business/monitorService.ts index d0dc8ca46..742e0200a 100644 --- a/server/src/service/business/monitorService.ts +++ b/server/src/service/business/monitorService.ts @@ -489,7 +489,7 @@ export class MonitorService implements IMonitorService { if (result.status === "rejected") { failedCount++; this.logger.error({ - message: `Failed to sync job queue for monitor ${monitors[index].id} during bulk ${pause ? "pause" : "resume"}`, + message: `Failed to sync job queue for monitor ${monitors[index]?.id || "unknown"} during bulk ${pause ? "pause" : "resume"}`, service: SERVICE_NAME, method: "bulkPauseMonitors", stack: result.reason instanceof Error ? result.reason.stack : undefined, From 940fc38056214f969e10da76227f3737e7d275c2 Mon Sep 17 00:00:00 2001 From: gorkem-bwl Date: Wed, 6 May 2026 13:55:58 -0400 Subject: [PATCH 065/245] docs: rename frontend-conventions to coding-conventions, add backend rules MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The conventions doc grew up as an FE-only thing. After the DNS provider PR series highlighted real backend patterns that get repeatedly missed in code review (DI, AppError envelope, schema enum, test setup style, centralized Zod enums, OpenAPI response schemas), it makes sense to have a single doc that covers both halves of the codebase. Changes: - Rename docs/frontend-conventions.md → docs/coding-conventions.md. - Add a new top-level rule 0: look at peer implementations and follow the established pattern. Applies to both FE and BE. Includes a 4-step "how to apply" and a self-check for PR authors. - Keep the 5 frontend rules unchanged (rules 1-5). - Add 6 backend rules (rules 6-11): - Controller → Service → Repository → Mongoose layering. - Provider skeleton (DI, SERVICE_NAME / TIMEOUT_MS, timeRequest, inline setTimeout race, outer AppError catch). - Mongoose enum reusing types/* const tuples. - Provider test conventions (file location, @jest/globals, contract test, inline setup() per test). - Centralize Zod enums in types/* — don't duplicate. - New entity fields must land in *ResponseSchema for OpenAPI. - Pre-PR checklist split into Universal / Frontend / Backend. - CLAUDE.md updated to reference the new file and summarize all 11 rules + rule 0. --- CLAUDE.md | 16 +- docs/coding-conventions.md | 404 +++++++++++++++++++++++++++++++++++ docs/frontend-conventions.md | 211 ------------------ 3 files changed, 418 insertions(+), 213 deletions(-) create mode 100644 docs/coding-conventions.md delete mode 100644 docs/frontend-conventions.md diff --git a/CLAUDE.md b/CLAUDE.md index c5f16d3a9..63a349ae7 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -159,14 +159,26 @@ When working on anything related to check scheduling, incident lifecycle, or not ## Code Conventions -### Frontend conventions (mandatory for `client/src`) -Read `docs/frontend-conventions.md` before touching any `.tsx` file. Five rules, all enforced in code review: +### Coding conventions (mandatory) +Read `docs/coding-conventions.md` before touching any `.tsx` or `.ts` file. The doc covers both frontend and backend rules, all enforced in code review. + +**Universal — rule 0:** look at peer files first and follow the established pattern. Don't sneak in novel shapes. + +**Frontend (`client/src`):** 1. Prefer MUI native props over `sx` (e.g. `color={…}`, `bgcolor={…}`, `mt={…}` — not `sx={{ color, bgcolor, mt }}`). 2. Use the full theme path for colors: `color={theme.palette.text.secondary}`, never `color="text.secondary"` (greppability). 3. No hardcoded literals — use `LAYOUT.*`, `typographyLevels.*`, `theme.shape.borderRadius`, `theme.palette.*`. 4. Use `useTheme()` inside components; don't `import { theme } from "@/Utils/Theme/Theme"`. 5. Pair runtime tuples with derived types: `const X = [...] as const; type X = (typeof X)[number]`. +**Backend (`server/src`):** +6. Layering: Controller → Service → Repository → Mongoose. No DB calls outside repositories. +7. Provider conventions: DI for stdlib clients, `SERVICE_NAME` + `TIMEOUT_MS` constants, `timeRequest()` helper, inline `setTimeout` race for timeout, outer try/catch into `AppError`. +8. Mongoose schema fields with closed value sets must declare `enum` reusing the same `types/*` const tuple. +9. Provider tests at `server/test/unit/providers/network/.test.ts`, `from "@jest/globals"`, `testStatusProviderContract`, inline `setup()` per test (not `beforeEach`). +10. Centralize validation enums in `types/*` const tuples; never inline `z.enum([…])` more than once. +11. New entity fields must land in the validator's `*ResponseSchema` so the auto-generated OpenAPI spec sees them. + ### Internationalization All user-facing strings must use the translation function: ```javascript diff --git a/docs/coding-conventions.md b/docs/coding-conventions.md new file mode 100644 index 000000000..61f4a5b9c --- /dev/null +++ b/docs/coding-conventions.md @@ -0,0 +1,404 @@ +# Coding conventions + +Style and structural rules the codebase follows. Frontend rules are extracted from review feedback on PR series #3591–#3596; backend rules are extracted from the provider/repository/service layering used across `server/src/service/infrastructure/network/*` and the patterns enforced in code review. These are non-negotiable for new code. They exist to keep the codebase greppable, maintainable, and consistent. + +> If you're touching a file in `client/src` the **frontend** rules apply. +> If you're touching a file in `server/src` the **backend** rules apply. +> Rule 0 applies everywhere. + +--- + +## 0. Look at existing implementations and follow the established pattern + +Before writing new code, find one or two files that already do something similar — a peer provider for a new monitor type, a peer page for a new admin screen, a peer repository method for a new query — and follow the same shape. Match imports, helper-function placement, naming, error handling, mock-construction style, and assertion idioms. If you're tempted to do it differently, the burden is on you to justify the divergence. + +**Why:** consistency is more valuable than locally-optimal cleverness. A reader who has internalized the pattern of one peer file should be able to read the new one with no extra mental load. New shapes also make refactors more expensive — when someone wants to change the convention later, every variant has to be visited and reasoned about separately. + +**How to apply:** before writing a new component, page, provider, repository, or test file: + +1. Grep for the closest existing peers (e.g. `WebSocketProvider.ts` if you're writing a new monitor provider). +2. Read at least two of them end-to-end. +3. Mirror their structure: same constants at the top (`SERVICE_NAME`, `TIMEOUT_MS`), same import order, same DI shape, same try/catch envelope, same test-file location and helper imports. +4. If your new code needs a shape no peer has, name it explicitly in the PR description and link to the peer you diverged from. Don't sneak novelty in. + +**Concrete check before opening a PR:** can a reader open a peer file side-by-side with yours and trace each section in the same order? If not, restructure. + +--- + +# Part 1 — Frontend (`client/src`) + +## 1. Prefer MUI native (system) props over `sx` + +MUI components accept layout, spacing, color, border, typography and sizing values as top-level props. Use those. Reach for `sx` only when no native prop exists for the value you need (e.g. `textDecoration`, pseudo-selectors, complex selectors, conditional CSS). + +**Why:** native props are easier to read in JSX, easier to grep, easier to refactor, and don't force the reader to mentally parse a nested object literal for things that are conceptually one attribute. A `sx` block also can't be split-line-edited cleanly when most of its keys are simple primitives. + +**How to apply:** any time you write `sx={{ … }}`, ask whether each key has a native equivalent. If yes, hoist it. + +### Canonical mappings + +| `sx` key | Native prop | +|---|---| +| `color` | `color` | +| `backgroundColor` | `bgcolor` | +| `borderRadius` | `borderRadius` | +| `border`, `borderTop`, … | `border`, `borderTop`, … | +| `width`, `height`, `minHeight`, `maxWidth` | same names as native | +| `p`, `pt`, `pb`, `px`, `py` | same names as native | +| `m`, `mt`, `mb`, `mx`, `my` | same names as native | +| `display` | `display` | +| `textAlign` | `textAlign` | +| `fontWeight`, `fontSize`, `lineHeight` | same names as native | +| `gap` (on Stack) | `gap` | + +### Examples + +```tsx +// BAD — everything inside sx + + +// GOOD — every key that has a native prop is hoisted + +``` + +```tsx +// BAD + + +// GOOD — hoist what you can; drop fontSize:13 because it's the body default + +``` + +### When `sx` is still correct + +Keep `sx` for things that don't have a native prop: + +```tsx + +``` + +Pseudo-selectors (`"&:hover"`), `transition`, `cursor`, `outline`, `overflow` (when not on Box/Stack), media queries, and conditional spread objects all stay in `sx`. + +--- + +## 2. Use the full theme path for colors, never the shorthand string + +Prop forms like `color="text.secondary"`, `color="primary.main"`, `bgcolor="background.paper"` work, but they are **forbidden in this codebase**. + +**Why:** the shorthand is invisible to grep. If a future change needs to find every place that consumes `theme.palette.text.secondary` — to retheme, to swap, to audit — the shorthand strings are missed. That is a maintenance trap. It's worth a few extra characters at every callsite to keep the codebase searchable. + +**How to apply:** always destructure or reference the theme value explicitly. + +```tsx +// BAD +… +… + +// GOOD +const theme = useTheme(); +… +… +``` + +This applies to every theme-aware string prop (`color`, `bgcolor`, `borderColor`, etc.), and inside `sx` if you really need it there. + +--- + +## 3. No hardcoded numeric or string literals — use tokens + +The theme exposes tokens for every kind of magic value. Use them. Reserve raw numbers for values that are genuinely one-off (and even then, push back on yourself first). + +**Why:** tokens are the single source of truth. A literal `2` in a `mt` prop is invisible — it could mean `LAYOUT.XXS`, a rounding correction, or a thinko. The reader can't tell. With a token, intent is in the name. + +**How to apply:** before writing a literal, check whether one of these tokens covers it. If it nearly does but not exactly, extend the token rather than hardcoding (Alex did exactly this — he added `LAYOUT.XXS = 2` rather than letting `mt: "2px"` stand). + +### Token sources + +- **Spacing / layout** — `client/src/Utils/Theme/constants.ts` + - `LAYOUT.XXS = 2` · `LAYOUT.XS = 4` · `LAYOUT.SM = 6` · `LAYOUT.MD = 8` · `LAYOUT.LG = 10` · `LAYOUT.XL = 12` · `LAYOUT.XXL = 16` + - `SPACING` for `theme.spacing(...)` multipliers +- **Typography sizes** — `client/src/Utils/Theme/Palette.ts` → `typographyLevels` + - `xs / s / m / l / xl / xxl` (rem values) +- **Hover / interaction** — `HOVER.DARKEN` etc. +- **Shape** — `theme.shape.borderRadius` (don't write `borderRadius: 1` or `borderRadius: 4`) +- **Colors** — `theme.palette.*` (rule 2 above) + +### Examples + +```tsx +// BAD + + + + + // 13 is the body default — drop the prop entirely + +// GOOD + + + + + // inherits 13px from theme +``` + +### Token naming gotcha + +`typographyLevels.xl` was historically used for both 18px and 23px callsites. Don't reuse a token for two different purposes — split it (`xl` = 18, `xxl` = 23) and update every callsite. If you find an ambiguous token while editing, fix it the same way. + +--- + +## 4. Use `useTheme()` inside components, don't import the theme module + +The `theme` object exported from `@/Utils/Theme/Theme` is the *factory output*, not a live, mode-aware instance. Importing it directly couples a component to the wrong reference and breaks dark-mode reactivity. It also breaks the build at import time in some module-graph configurations (this happened in PR #3596 and required a follow-up commit). + +**Why:** themes can change at runtime (mode toggle), and the hook is the only correct way to read the active theme. Imports also create circular-graph risk because `Theme.ts` itself depends on `Palette.ts`. + +**How to apply:** inside any component or component-helper that needs theme values: + +```tsx +// BAD +import { theme } from "@/Utils/Theme/Theme"; +const SectionHeading = ({ children }) => ( + {children} +); + +// GOOD +import { useTheme } from "@mui/material/styles"; +const SectionHeading = ({ children }) => { + const theme = useTheme(); + return {children}; +}; +``` + +Module-level utility files that don't render React (pure helpers) should accept `theme` as a parameter rather than importing it. + +--- + +## 5. Discriminated unions: pair the runtime list with the type + +When you have a closed set of string literals that's used both as a type and as runtime data (severities, statuses, kinds), declare the runtime tuple first and derive the type from it. Don't declare them as two parallel literals that can drift. + +**Why:** if the list and the type aren't tied to each other, adding a new value to one and forgetting the other compiles fine and breaks at runtime. Deriving the type from the tuple makes them inseparable. + +```tsx +// BAD +type Severity = "info" | "warning" | "error"; + +// GOOD +export const Severities = ["info", "warning", "error"] as const; +export type Severity = (typeof Severities)[number]; +``` + +Export both when callers need to iterate (e.g. for a Storybook stories matrix or a select dropdown). + +--- + +# Part 2 — Backend (`server/src`) + +## 6. Layering: Controller → Service → Repository → Mongoose + +The backend enforces a strict three-layer separation between HTTP handling, business logic, and data access. Every new feature follows this path, no shortcuts. + +``` +Request → Controller → Service → Repository → MongoDB (Mongoose) +``` + +**Why:** this is what makes each layer independently testable. Mongoose calls in services force services to mock the database in tests; raw DB queries in controllers couple HTTP shape to schema shape and turn every refactor into a multi-file rewrite. + +**How to apply:** when adding a new feature, the pattern is — **add a repository method** for any new DB query, **call it from a service**, **expose it via a controller**. If a service constructs a `MonitorModel.findOne({...})`, you've broken the rule; the query belongs in the repository. + +- **Controllers** (`server/src/controllers/`) handle HTTP concerns only: parse params, call the service, return via the `responseHandler` middleware. No business logic. +- **Services** (`server/src/service/business/`) contain all business logic. They throw `AppError` on validation/auth failures; they do not write directly to MongoDB. +- **Repositories** (`server/src/repositories/`) are the sole layer that talks to MongoDB. They expose clean query methods (`findByMonitorId`, `createCheck`, `updateStatusWindowAndChecks`) and return entities — never Mongoose documents — via a `toEntity` private helper. + +--- + +## 7. Provider conventions for `service/infrastructure/network/*` + +Every monitor-type provider implements `IStatusProvider` and follows the same skeleton. + +**Why:** the registry in `services.ts` builds providers identically (`new XxxProvider(deps)`) and `NetworkService` invokes them identically (`provider.handle(monitor)`). If your provider's shape diverges, the registry/dispatch becomes asymmetric and `NetworkService` grows special cases. + +**How to apply:** copy the structure of a recent peer (`WebSocketProvider`, `GrpcProvider`, `PortProvider`). The skeleton: + +```ts +const SERVICE_NAME = "XxxProvider"; +const TIMEOUT_MS = 10000; + +type ClientCtor = typeof SomeNodeStdlibClass; + +export class XxxProvider implements IStatusProvider { + readonly type = "xxx"; + + constructor(private Client: ClientCtor) {} + + supports(type: MonitorType): boolean { + return type === "xxx"; + } + + async handle(monitor: Monitor): Promise> { + try { + // 1. validate inputs → throw AppError(400) on bad input + // 2. set up the client (use this.Client, never imported singleton) + // 3. perform the call inside timeRequest(...), with a setTimeout race + // in the Promise body to enforce TIMEOUT_MS + // 4. return MonitorStatusResponse with payload + } catch (err: unknown) { + if (err instanceof AppError) throw err; + const originalMessage = err instanceof Error ? err.message : String(err); + throw new AppError({ + message: originalMessage || "Error performing xxx check", + status: 500, + service: SERVICE_NAME, + method: "handle", + details: { /* monitor-shaped context */ }, + }); + } + } +} +``` + +### Hard rules + +- **DI for all external dependencies.** `typeof WebSocket`, `typeof net` — pass the constructor in via the provider's constructor. Never `import { Foo } from "some/library"` and call it inside `handle()`. This is the single thing that makes the provider unit-testable without `jest.unstable_mockModule`. +- **`SERVICE_NAME` and `TIMEOUT_MS`** as module-level constants near the top, not magic strings/numbers in the body. +- **Use the shared `timeRequest()` helper** from `service/infrastructure/network/utils.ts` to measure latency. Don't roll your own `process.hrtime()`. +- **Enforce a hard timeout.** Every peer provider inlines a `setTimeout` race in the Promise body around the underlying client call (see `WebSocketProvider.ts`); sockets, DNS resolvers, and external clients can hang indefinitely without it. There is no shared `withTimeout` helper today — match the inline pattern. +- **Outer try/catch** that converts unknown errors to `AppError({ service, method, details })` while letting your own `AppError` pass through. + +--- + +## 8. Mongoose schema fields with closed value sets + +If a field's TypeScript type is a closed union (status, type, record type), the Mongoose schema must declare the same set as `enum`. + +**Why:** the validator and the schema are independent gates. A service-layer Zod check can be bypassed (imports, migrations, direct repo writes). A schema `enum` is the last line of defense and surfaces invalid data as a Mongoose validation error rather than silently accepting `"badtype"` and breaking downstream consumers. + +**How to apply:** when you add a string field whose values are constrained by a const tuple in `types/`, reuse the same const for the schema enum: + +```ts +// types/monitor.ts (existing) +export const MonitorMatchMethods = ["equal", "include", "regex"] as const; + +// db/models/Monitor.ts +{ + matchMethod: { + type: String, + enum: MonitorMatchMethods, + }, +} +``` + +The same applies to `MonitorTypes`, `MonitorStatuses`, and any new closed-set field. + +--- + +## 9. Test conventions for `server/test/unit/providers/network/*` + +Each provider gets a unit test file at `server/test/unit/providers/network/.test.ts`. Follow the structure used by `webSocketProvider.test.ts`, `grpcProvider.test.ts`, `portProvider.test.ts`. + +**Why:** consistency lets reviewers diff a new test file against any peer and only see the actual logic differences, not framework/import noise. It also lets the `testStatusProviderContract` helper guarantee every provider satisfies the `IStatusProvider` contract. + +**How to apply:** + +- **Imports**: `from "@jest/globals"` (not the global `jest`), and `.ts` extensions on every relative import. +- **Contract test**: call `testStatusProviderContract("XxxProvider", { create, supportedType, unsupportedType, makeMonitor })` before the per-test `describe` block. +- **Mock construction**: build the constructor mock as `jest.fn().mockImplementation(() => mock) as unknown as typeof Client` (typed cast, not `as any`). +- **`makeMonitor` factory**: takes `Partial` overrides, spreads onto a baseline, returns `as Monitor`. +- **Inline `setup()` per test** rather than `beforeEach` shared state. Each test should construct its own mock + provider, so a single test can be read in isolation: + + ```ts + const setup = () => { + const mock = createMockClient(); + const Ctor = jest.fn().mockImplementation(() => mock) as unknown as typeof Client; + const provider = new XxxProvider(Ctor); + return { mock, provider }; + }; + + it("does X", async () => { + const { mock, provider } = setup(); + // ... + }); + ``` + +- **Assertion style**: individual `expect(result.field).toBe(...)` lines or `expect(result).toEqual(expect.objectContaining({...}))`. Both are used; pick what reads cleanest for the case. +- **Test naming**: lowercase imperative ("returns success when connection opens", "uses the configured timeout", "throws AppError when host is missing"). + +--- + +## 10. Validation: centralize, don't inline + +Whenever the same string-set or shape constraint appears in two or more validators, lift it into `types/` as a const tuple and `import` it into both validators. Don't inline `z.enum(["A", "AAAA", ...])` in three places. + +**Why:** drift. Three inline copies will eventually disagree — someone adds a new value to one and forgets the other two. The validator silently accepts it on `POST /monitors` and rejects it on `PATCH`, leading to bug reports the author can't reproduce. + +**How to apply:** keep validation source-of-truth in `server/src/types/*.ts` as paired const tuples + derived types (rule 5). The validators in `server/src/validation/*.ts` and the Mongoose schemas in `server/src/db/models/*.ts` import from `types/` and reuse the same tuple. + +--- + +## 11. OpenAPI: response schemas live in the validator file + +The OpenAPI `monitorObject` (and friends) are generated from a `monitorResponseSchema` Zod object exported from the validator file, not hand-built in `server/openapi/routes/`. When you add a field to the data model, add it to the response schema in the same edit. + +**Why:** the spec is auto-generated. If the response schema doesn't list a field, SDK consumers don't see it, even though the server returns it. That's a silent contract gap. + +**How to apply:** when you add an entity field, update three files in the same PR: + +1. `types/.ts` — add the field to the TS interface. +2. `db/models/.ts` — add the Mongoose schema entry (with `enum` if the value set is closed; rule 8). +3. `validation/Validation.ts` — add the field to the response schema (and create/edit body schemas if it's user-settable). + +The repository's `toEntity` helper also needs to surface the new field; that's part of the change, not a separate one. + +--- + +# Pre-PR checklist + +Before opening a PR, grep your diff for the patterns below. Each item describes the **anti-pattern** to find and fix; if your diff has none, that line passes. Frontend section applies to `client/src`; backend to `server/src`. + +## Universal +- [ ] I can name the peer file I used as a template, and my file's structure mirrors it (rule 0). +- [ ] `npm run build` and `npm run format-check` are clean in both `client/` and `server/`. + +## Frontend (`client/src`) +- [ ] No `sx={{ … }}` block containing keys with a native-prop equivalent (rule 1). +- [ ] No `color="text.X"`, `bgcolor="background.X"`, or other short-string palette paths (rule 2). +- [ ] No raw number or px-string in spacing / margin / padding / fontSize / borderRadius props that should be a token (rule 3). +- [ ] No `import { theme } from "@/Utils/Theme/Theme"` inside a component (rule 4). +- [ ] No `type X = "a" | "b" | "c"` for a closed set used at runtime (rule 5). + +## Backend (`server/src`) +- [ ] No `MonitorModel.findOne` (or similar Mongoose call) outside `repositories/` (rule 6). +- [ ] No provider that imports its underlying client at module top instead of receiving it via constructor DI (rule 7). +- [ ] No provider missing `SERVICE_NAME` / `TIMEOUT_MS` / `AppError` outer catch / `timeRequest()` / inline timeout (rule 7). +- [ ] No closed-set string field on a Mongoose schema without an `enum` (rule 8). +- [ ] No provider test that uses `beforeEach` instead of inline `setup()` (rule 9). +- [ ] No duplicated `z.enum([...])` literal that should reference a `types/*` const tuple (rule 10). +- [ ] No new entity field missing from `monitorResponseSchema` (or peer response schema) for OpenAPI (rule 11). + +A clean PR on these axes is a PR that ships without a normalize follow-up. diff --git a/docs/frontend-conventions.md b/docs/frontend-conventions.md deleted file mode 100644 index 4f14c555b..000000000 --- a/docs/frontend-conventions.md +++ /dev/null @@ -1,211 +0,0 @@ -# Frontend conventions - -Style and structural rules the client codebase follows. These are extracted from review feedback on the PR series #3591–#3596 and are non-negotiable for new code. They exist to keep the codebase greppable, maintainable, and consistent with the existing theme. - -> If you're touching a file in `client/src`, every rule below applies. The "why" matters as much as the "what" — read the reasoning so you can judge edge cases. - ---- - -## 1. Prefer MUI native (system) props over `sx` - -MUI components accept layout, spacing, color, border, typography and sizing values as top-level props. Use those. Reach for `sx` only when no native prop exists for the value you need (e.g. `textDecoration`, pseudo-selectors, complex selectors, conditional CSS). - -**Why:** native props are easier to read in JSX, easier to grep, easier to refactor, and don't force the reader to mentally parse a nested object literal for things that are conceptually one attribute. A `sx` block also can't be split-line-edited cleanly when most of its keys are simple primitives. - -**How to apply:** any time you write `sx={{ … }}`, ask whether each key has a native equivalent. If yes, hoist it. - -### Canonical mappings - -| `sx` key | Native prop | -|---|---| -| `color` | `color` | -| `backgroundColor` | `bgcolor` | -| `borderRadius` | `borderRadius` | -| `border`, `borderTop`, … | `border`, `borderTop`, … | -| `width`, `height`, `minHeight`, `maxWidth` | same names as native | -| `p`, `pt`, `pb`, `px`, `py` | same names as native | -| `m`, `mt`, `mb`, `mx`, `my` | same names as native | -| `display` | `display` | -| `textAlign` | `textAlign` | -| `fontWeight`, `fontSize`, `lineHeight` | same names as native | -| `gap` (on Stack) | `gap` | - -### Examples - -```tsx -// BAD — everything inside sx - - -// GOOD — every key that has a native prop is hoisted - -``` - -```tsx -// BAD - - -// GOOD — hoist what you can; drop fontSize:13 because it's the body default - -``` - -### When `sx` is still correct - -Keep `sx` for things that don't have a native prop: - -```tsx - -``` - -Pseudo-selectors (`"&:hover"`), `transition`, `cursor`, `outline`, `overflow` (when not on Box/Stack), media queries, and conditional spread objects all stay in `sx`. - ---- - -## 2. Use the full theme path for colors, never the shorthand string - -Prop forms like `color="text.secondary"`, `color="primary.main"`, `bgcolor="background.paper"` work, but they are **forbidden in this codebase**. - -**Why:** the shorthand is invisible to grep. If a future change needs to find every place that consumes `theme.palette.text.secondary` — to retheme, to swap, to audit — the shorthand strings are missed. That is a maintenance trap. It's worth a few extra characters at every callsite to keep the codebase searchable. - -**How to apply:** always destructure or reference the theme value explicitly. - -```tsx -// BAD -… -… - -// GOOD -const theme = useTheme(); -… -… -``` - -This applies to every theme-aware string prop (`color`, `bgcolor`, `borderColor`, etc.), and inside `sx` if you really need it there. - ---- - -## 3. No hardcoded numeric or string literals — use tokens - -The theme exposes tokens for every kind of magic value. Use them. Reserve raw numbers for values that are genuinely one-off (and even then, push back on yourself first). - -**Why:** tokens are the single source of truth. A literal `2` in a `mt` prop is invisible — it could mean `LAYOUT.XXS`, a rounding correction, or a thinko. The reader can't tell. With a token, intent is in the name. - -**How to apply:** before writing a literal, check whether one of these tokens covers it. If it nearly does but not exactly, extend the token rather than hardcoding (Alex did exactly this — he added `LAYOUT.XXS = 2` rather than letting `mt: "2px"` stand). - -### Token sources - -- **Spacing / layout** — `client/src/Utils/Theme/constants.ts` - - `LAYOUT.XXS = 2` · `LAYOUT.XS = 4` · `LAYOUT.SM = 6` · `LAYOUT.MD = 8` · `LAYOUT.LG = 10` · `LAYOUT.XL = 12` · `LAYOUT.XXL = 16` - - `SPACING` for `theme.spacing(...)` multipliers -- **Typography sizes** — `client/src/Utils/Theme/Palette.ts` → `typographyLevels` - - `xs / s / m / l / xl / xxl` (rem values) -- **Hover / interaction** — `HOVER.DARKEN` etc. -- **Shape** — `theme.shape.borderRadius` (don't write `borderRadius: 1` or `borderRadius: 4`) -- **Colors** — `theme.palette.*` (rule 2 above) - -### Examples - -```tsx -// BAD - - - - - // 13 is the body default — drop the prop entirely - -// GOOD - - - - - // inherits 13px from theme -``` - -### Token naming gotcha - -`typographyLevels.xl` was historically used for both 18px and 23px callsites. Don't reuse a token for two different purposes — split it (`xl` = 18, `xxl` = 23) and update every callsite. If you find an ambiguous token while editing, fix it the same way. - ---- - -## 4. Use `useTheme()` inside components, don't import the theme module - -The `theme` object exported from `@/Utils/Theme/Theme` is the *factory output*, not a live, mode-aware instance. Importing it directly couples a component to the wrong reference and breaks dark-mode reactivity. It also breaks the build at import time in some module-graph configurations (this happened in PR #3596 and required a follow-up commit). - -**Why:** themes can change at runtime (mode toggle), and the hook is the only correct way to read the active theme. Imports also create circular-graph risk because `Theme.ts` itself depends on `Palette.ts`. - -**How to apply:** inside any component or component-helper that needs theme values: - -```tsx -// BAD -import { theme } from "@/Utils/Theme/Theme"; -const SectionHeading = ({ children }) => ( - {children} -); - -// GOOD -import { useTheme } from "@mui/material/styles"; -const SectionHeading = ({ children }) => { - const theme = useTheme(); - return {children}; -}; -``` - -Module-level utility files that don't render React (pure helpers) should accept `theme` as a parameter rather than importing it. - ---- - -## 5. Discriminated unions: pair the runtime list with the type - -When you have a closed set of string literals that's used both as a type and as runtime data (severities, statuses, kinds), declare the runtime tuple first and derive the type from it. Don't declare them as two parallel literals that can drift. - -**Why:** if the list and the type aren't tied to each other, adding a new value to one and forgetting the other compiles fine and breaks at runtime. Deriving the type from the tuple makes them inseparable. - -```tsx -// BAD -type Severity = "info" | "warning" | "error"; - -// GOOD -export const Severities = ["info", "warning", "error"] as const; -export type Severity = (typeof Severities)[number]; -``` - -Export both when callers need to iterate (e.g. for a Storybook stories matrix or a select dropdown). - ---- - -## Pre-PR checklist - -Before you open a PR, scan your diff for these: - -- [ ] Any `sx={{ … }}` block that contains keys with a native-prop equivalent (rule 1) -- [ ] Any `color="text.X"`, `bgcolor="background.X"`, or any short-string palette path (rule 2) -- [ ] Any raw number or px-string in spacing / margin / padding / fontSize / borderRadius props that should be a token (rule 3) -- [ ] Any `import { theme } from "@/Utils/Theme/Theme"` inside a component (rule 4) -- [ ] Any `type X = "a" | "b" | "c"` for a closed set used at runtime (rule 5) -- [ ] Run `npm run build` and `npm run format-check` in both `client/` and `server/` (per global instructions) - -A clean PR on these axes is a PR that ships without a normalize follow-up. From 602769b3e7c04e313ad9c137a44c90ce45e6a9af Mon Sep 17 00:00:00 2001 From: gorkem-bwl Date: Thu, 7 May 2026 01:13:09 -0400 Subject: [PATCH 066/245] feat(checks): render status code 5000 as "Down" with subtype tooltip MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Status code 5000 is Checkmate's internal sentinel for "no HTTP response received" (DNS failure, connection refused, timeout, TLS error, etc.). Showing the raw number to admins is confusing — the value isn't a real HTTP code and means nothing without context. This change replaces "5000" with "Down" across all check/incident surfaces, and adds a hover tooltip that explains the underlying cause based on the stored error message. Real HTTP codes (2xx/3xx/4xx/5xx) are unchanged. ## Changes - Add client/src/Utils/statusCode.ts with formatStatusCode, getStatusCodeTooltip, getStatusCodeValueType, extractErrorCode, and a message-to-subtype parser (timeout / dns / refused / tls / reset). - Wire into 6 render sites: Checks table, Uptime details Checks table, GeoChecks table + map popup, Incidents table, Incident details dialog. - Add common.statusCode.* i18n keys in en.json with subtype-specific descriptions. Tooltip appends the raw error code (ECONNREFUSED, ETIMEDOUT, CERT_HAS_EXPIRED, etc.) when present in the message. ## Tooltip examples - "queryA ECONNREFUSED www.example.com" → "The server refused the connection (port closed or service not listening). (ECONNREFUSED)" - "connect ETIMEDOUT 1.2.3.4:443" → "The server didn't respond in time (request timed out). (ETIMEDOUT)" ## Notes - No DB or server changes; statusCode field stays 5000 in storage. - Other locales fall back to English keys until translated. --- .../src/Components/monitors/GeoChecksMap.tsx | 5 +- .../Pages/Checks/Components/ChecksTable.tsx | 31 ++++++-- .../Incidents/Components/CardDetails.tsx | 21 ++++- .../Incidents/Components/IncidentTable.tsx | 27 ++++--- .../Uptime/Details/Components/ChecksTable.tsx | 16 +++- .../Details/Components/GeoChecksTable.tsx | 16 +++- client/src/Utils/statusCode.ts | 78 +++++++++++++++++++ client/src/locales/en.json | 11 +++ 8 files changed, 181 insertions(+), 24 deletions(-) create mode 100644 client/src/Utils/statusCode.ts diff --git a/client/src/Components/monitors/GeoChecksMap.tsx b/client/src/Components/monitors/GeoChecksMap.tsx index 3f9429853..e013f2235 100644 --- a/client/src/Components/monitors/GeoChecksMap.tsx +++ b/client/src/Components/monitors/GeoChecksMap.tsx @@ -3,6 +3,8 @@ import { Box, Typography, Stack, useTheme, GlobalStyles } from "@mui/material"; import Map, { Marker, Popup } from "react-map-gl/maplibre"; import type { MapRef } from "react-map-gl/maplibre"; import type { FlatGeoCheck } from "@/Types/GeoCheck"; +import { useTranslation } from "react-i18next"; +import { formatStatusCode } from "@/Utils/statusCode"; import "maplibre-gl/dist/maplibre-gl.css"; interface GeoChecksMapProps { @@ -14,6 +16,7 @@ export const GeoChecksMap = ({ geoChecks }: GeoChecksMapProps) => { const [selectedCheck, setSelectedCheck] = useState(null); const theme = useTheme(); const isDarkMode = theme.palette.mode === "dark"; + const { t } = useTranslation(); const mapPopupStyles = ( { > Status Code: {" "} - {selectedCheck.statusCode} + {formatStatusCode(selectedCheck.statusCode, t)} state.ui.timezone); - const getHeaders = (t: Function, uiTimezone: string) => { + const getHeaders = (t: (key: string) => string, uiTimezone: string) => { const headers: Header[] = [ { id: "monitorName", @@ -65,13 +76,21 @@ export const ChecksTable = ({ render: (row) => { const code = row.statusCode; if (!code) return "N/A"; - const value = code < 300 ? "positive" : code < 400 ? "neutral" : "negative"; - return ( + const text = formatStatusCode(code, t); + const tooltip = getStatusCodeTooltip(code, row.message, t); + const label = ( ); + return tooltip ? ( + + {label} + + ) : ( + label + ); }, }, { diff --git a/client/src/Pages/Incidents/Components/CardDetails.tsx b/client/src/Pages/Incidents/Components/CardDetails.tsx index b6c1288dd..2cd8fbde4 100644 --- a/client/src/Pages/Incidents/Components/CardDetails.tsx +++ b/client/src/Pages/Incidents/Components/CardDetails.tsx @@ -2,7 +2,7 @@ import Stack from "@mui/material/Stack"; import Grid from "@mui/material/Grid"; import Divider from "@mui/material/Divider"; import Typography from "@mui/material/Typography"; -import { BaseBox, ValueLabel } from "@/Components/design-elements"; +import { BaseBox, ValueLabel, Tooltip } from "@/Components/design-elements"; import { LAYOUT } from "@/Utils/Theme/constants"; import { useTranslation } from "react-i18next"; @@ -13,6 +13,7 @@ import { useSelector } from "react-redux"; import type { RootState } from "@/Types/state"; import { formatDateWithTz } from "@/Utils/TimeUtils"; import { getIncidentsDuration } from "@/Pages/Incidents/utils"; +import { formatStatusCode, getStatusCodeTooltip } from "@/Utils/statusCode"; interface CardDetailsProps { incident: Incident | null; @@ -46,6 +47,14 @@ export const CardDetails = ({ incident, monitor, sx }: CardDetailsProps) => { if (!incident) { return null; } + + const statusCodeText = formatStatusCode(incident.statusCode, t); + const statusCodeTooltip = getStatusCodeTooltip( + incident.statusCode, + incident.message, + t + ); + return ( { {t("pages.incidents.dialog.details.statusCode")} - {incident.statusCode ?? "N/A"} + + {statusCodeTooltip ? ( + + {statusCodeText} + + ) : ( + statusCodeText + )} + {t("pages.incidents.dialog.details.downtime")} diff --git a/client/src/Pages/Incidents/Components/IncidentTable.tsx b/client/src/Pages/Incidents/Components/IncidentTable.tsx index bb1759dcd..c66cdb0ed 100644 --- a/client/src/Pages/Incidents/Components/IncidentTable.tsx +++ b/client/src/Pages/Incidents/Components/IncidentTable.tsx @@ -1,8 +1,6 @@ -import { Table } from "@/Components/design-elements"; +import { Table, ValueLabel, Tooltip } from "@/Components/design-elements"; import { Pagination } from "@/Components/design-elements/Table"; import type { Header } from "@/Components/design-elements/Table"; -import { ValueLabel } from "@/Components/design-elements"; -import type { ValueType } from "@/Components/design-elements/StatusLabel"; import { ActionsMenu } from "@/Components/actions-menu"; import type { Incident } from "@/Types/Incident"; import type { Monitor } from "@/Types/Monitor"; @@ -16,6 +14,11 @@ import Typography from "@mui/material/Typography"; import { useTheme } from "@mui/material/styles"; import Box from "@mui/material/Box"; import { getMonitorPath } from "@/Utils/MonitorUtils"; +import { + formatStatusCode, + getStatusCodeTooltip, + getStatusCodeValueType, +} from "@/Utils/statusCode"; interface IncidentsTableProps { title?: string; @@ -157,15 +160,21 @@ export const IncidentsTable = ({ render: (row) => { const code = row.statusCode; if (!code) return "N/A"; - let value: ValueType = "neutral"; - if (code < 300) value = "positive"; - else if (code >= 400) value = "negative"; - return ( + const text = formatStatusCode(code, t); + const tooltip = getStatusCodeTooltip(code, row.message, t); + const label = ( ); + return tooltip ? ( + + {label} + + ) : ( + label + ); }, }, { diff --git a/client/src/Pages/Uptime/Details/Components/ChecksTable.tsx b/client/src/Pages/Uptime/Details/Components/ChecksTable.tsx index 0aaede394..0ab9a7a7c 100644 --- a/client/src/Pages/Uptime/Details/Components/ChecksTable.tsx +++ b/client/src/Pages/Uptime/Details/Components/ChecksTable.tsx @@ -1,4 +1,4 @@ -import { Table, Pagination, StatusLabel } from "@/Components/design-elements"; +import { Table, Pagination, StatusLabel, Tooltip } from "@/Components/design-elements"; import Box from "@mui/material/Box"; import type { Header } from "@/Components/design-elements"; import type { Check } from "@/Types/Check"; @@ -6,10 +6,11 @@ import type { Check } from "@/Types/Check"; import { useNavigate } from "react-router"; import { useTranslation } from "react-i18next"; import { formatDateWithTz } from "@/Utils/TimeUtils"; +import { formatStatusCode, getStatusCodeTooltip } from "@/Utils/statusCode"; import type { RootState } from "@/Types/state"; import { useSelector } from "react-redux"; -const getHeaders = (t: Function, uiTimezone: string) => { +const getHeaders = (t: (key: string) => string, uiTimezone: string) => { const headers: Header[] = [ { id: "status", @@ -36,7 +37,16 @@ const getHeaders = (t: Function, uiTimezone: string) => { id: "statusCode", content: t("pages.checks.table.headers.statusCode"), render: (row) => { - return row.statusCode || "N/A"; + if (!row.statusCode) return "N/A"; + const text = formatStatusCode(row.statusCode, t); + const tooltip = getStatusCodeTooltip(row.statusCode, row.message, t); + return tooltip ? ( + + {text} + + ) : ( + text + ); }, }, ]; diff --git a/client/src/Pages/Uptime/Details/Components/GeoChecksTable.tsx b/client/src/Pages/Uptime/Details/Components/GeoChecksTable.tsx index 31ad6a3dd..ae56e037e 100644 --- a/client/src/Pages/Uptime/Details/Components/GeoChecksTable.tsx +++ b/client/src/Pages/Uptime/Details/Components/GeoChecksTable.tsx @@ -1,14 +1,15 @@ -import { Table, Pagination, StatusLabel } from "@/Components/design-elements"; +import { Table, Pagination, StatusLabel, Tooltip } from "@/Components/design-elements"; import Box from "@mui/material/Box"; import type { Header } from "@/Components/design-elements"; import type { FlatGeoCheck } from "@/Types/GeoCheck"; import { useTranslation } from "react-i18next"; import { formatDateWithTz } from "@/Utils/TimeUtils"; +import { formatStatusCode, getStatusCodeTooltip } from "@/Utils/statusCode"; import type { RootState } from "@/Types/state"; import { useSelector } from "react-redux"; import prettyMilliseconds from "pretty-ms"; -const getHeaders = (t: Function, uiTimezone: string) => { +const getHeaders = (t: (key: string) => string, uiTimezone: string) => { const headers: Header[] = [ { id: "status", @@ -29,7 +30,16 @@ const getHeaders = (t: Function, uiTimezone: string) => { id: "statusCode", content: t("pages.checks.table.headers.statusCode"), render: (row) => { - return row.statusCode || "N/A"; + if (!row.statusCode) return "N/A"; + const text = formatStatusCode(row.statusCode, t); + const tooltip = getStatusCodeTooltip(row.statusCode, undefined, t); + return tooltip ? ( + + {text} + + ) : ( + text + ); }, }, { diff --git a/client/src/Utils/statusCode.ts b/client/src/Utils/statusCode.ts new file mode 100644 index 000000000..75e4a9b19 --- /dev/null +++ b/client/src/Utils/statusCode.ts @@ -0,0 +1,78 @@ +import type { ValueType } from "@/Components/design-elements/StatusLabel"; + +export const NETWORK_ERROR_CODE = 5000; + +export const NETWORK_ERROR_SUBTYPES = [ + "timeout", + "dns", + "refused", + "tls", + "reset", + "unknown", +] as const; +export type NetworkErrorSubtype = (typeof NETWORK_ERROR_SUBTYPES)[number]; + +type TranslateFn = (key: string) => string; + +export const isNetworkError = (code?: number | null): boolean => + code === NETWORK_ERROR_CODE; + +const ERROR_CODE_PATTERN = + /\b(E[A-Z_]{2,}|CERT_[A-Z_]+|UNABLE_TO_VERIFY[A-Z_]*|DEPTH_ZERO_SELF_SIGNED[A-Z_]*|SELF_SIGNED[A-Z_]*)\b/; + +export const extractErrorCode = (message?: string | null): string | null => { + if (!message) return null; + const match = message.match(ERROR_CODE_PATTERN); + return match ? match[1] : null; +}; + +export const getNetworkErrorSubtype = (message?: string | null): NetworkErrorSubtype => { + if (!message) return "unknown"; + const m = message.toUpperCase(); + if (m.includes("ETIMEDOUT") || m.includes("TIMEOUT") || m.includes("ESOCKETTIMEDOUT")) + return "timeout"; + if (m.includes("ENOTFOUND") || m.includes("EAI_AGAIN") || m.includes("DNS")) + return "dns"; + if (m.includes("ECONNREFUSED")) return "refused"; + if ( + m.includes("CERT_") || + m.includes("UNABLE_TO_VERIFY") || + m.includes("DEPTH_ZERO_SELF_SIGNED") || + m.includes("SELF_SIGNED") || + m.includes("TLS") || + m.includes("SSL") + ) + return "tls"; + if (m.includes("ECONNRESET")) return "reset"; + return "unknown"; +}; + +export const formatStatusCode = ( + code: number | null | undefined, + t: TranslateFn, + fallback: string = "N/A" +): string => { + if (code == null) return fallback; + if (isNetworkError(code)) return t("common.statusCode.down"); + return String(code); +}; + +export const getStatusCodeTooltip = ( + code: number | null | undefined, + message: string | null | undefined, + t: TranslateFn +): string | null => { + if (!isNetworkError(code)) return null; + const description = t( + `common.statusCode.networkError.${getNetworkErrorSubtype(message)}` + ); + const errorCode = extractErrorCode(message); + return errorCode ? `${description} (${errorCode})` : description; +}; + +export const getStatusCodeValueType = (code: number): ValueType => { + if (isNetworkError(code)) return "negative"; + if (code < 300) return "positive"; + if (code < 400) return "neutral"; + return "negative"; +}; diff --git a/client/src/locales/en.json b/client/src/locales/en.json index 211021276..587e25df1 100644 --- a/client/src/locales/en.json +++ b/client/src/locales/en.json @@ -14,6 +14,17 @@ } }, "appName": "Checkmate", + "statusCode": { + "down": "Down", + "networkError": { + "timeout": "The server didn't respond in time (request timed out).", + "dns": "Couldn't resolve the hostname (DNS lookup failed).", + "refused": "The server refused the connection (port closed or service not listening).", + "tls": "TLS or SSL handshake failed (certificate or encryption issue).", + "reset": "The connection was reset before a response was received.", + "unknown": "No HTTP response was received from the target." + } + }, "breadcrumbs": { "details": "Details", "home": "Home" From 4db95cfa449baa0ad7622630364ee693fc991f95 Mon Sep 17 00:00:00 2001 From: gorkem-bwl Date: Thu, 7 May 2026 12:29:26 -0400 Subject: [PATCH 067/245] fix(maintenance): stop silent deletion of one-time maintenance windows Maintenance windows could disappear seconds after creation when the API received a payload with end dates already in the past. The repository auto-set expiry = end for one-time windows (repeat = 0), and Mongo's TTL index on the expiry field then deleted the row on its next sweep. This was particularly visible with automation tools sending payloads in non-UTC timezones. ## Changes ### Validator hardening - Add .strict() to create + edit body schemas so unknown keys return 400 instead of being silently dropped. - Refine end > start on create. - Refine end > now for one-time create payloads (repeat = 0). - Refine end > start on edit when both are provided. - Remove the accepted expiry field from both schemas. ### Stop the auto-delete behaviour - Repository.create no longer copies end into expiry. - Schema's expiry field + TTL index removed. - Migration 0007 drops the live TTL index and unsets stale expiry values on existing rows so legacy data is no longer at risk. ### Client - Form submission no longer includes expiry. --- client/src/Pages/Maintenance/create/index.tsx | 4 -- .../0007_dropMaintenanceWindowExpiryIndex.ts | 48 +++++++++++++++ server/src/db/migration/index.ts | 2 + server/src/db/models/MaintenanceWindow.ts | 5 -- .../MongoMaintenanceWindowsRepository.ts | 5 -- .../validation/maintenanceWindowValidation.ts | 58 ++++++++++++------- 6 files changed, 86 insertions(+), 36 deletions(-) create mode 100644 server/src/db/migration/0007_dropMaintenanceWindowExpiryIndex.ts diff --git a/client/src/Pages/Maintenance/create/index.tsx b/client/src/Pages/Maintenance/create/index.tsx index d130ee7c7..44a4b9a81 100644 --- a/client/src/Pages/Maintenance/create/index.tsx +++ b/client/src/Pages/Maintenance/create/index.tsx @@ -85,10 +85,6 @@ const CreateMaintenanceWindowPage = () => { repeat, }; - if (repeat === 0) { - payload.expiry = endDateTime.toISOString(); - } - return payload; }; diff --git a/server/src/db/migration/0007_dropMaintenanceWindowExpiryIndex.ts b/server/src/db/migration/0007_dropMaintenanceWindowExpiryIndex.ts new file mode 100644 index 000000000..850ab19ef --- /dev/null +++ b/server/src/db/migration/0007_dropMaintenanceWindowExpiryIndex.ts @@ -0,0 +1,48 @@ +import { MaintenanceWindowModel } from "../models/MaintenanceWindow.js"; +import { logger } from "@/utils/logger.js"; + +/** + * Drops the TTL index on MaintenanceWindow.expiry and unsets the expiry field. + * + * Earlier versions auto-set expiry = end for one-time maintenance windows. Combined with the + * TTL index ({ expires: "0s" }), this caused Mongo to silently delete completed one-time + * windows shortly after their end-date passed — including windows whose end-date was already + * in the past at creation time (e.g. timezone-mismatched payloads from automation tools). + * The fix stops writing expiry on create, and this migration removes the index and stale field + * so legacy rows are no longer at risk of being deleted by the TTL monitor. + */ +export async function dropMaintenanceWindowExpiryIndex(): Promise { + const SERVICE_NAME = "Migration:DropMaintenanceWindowExpiryIndex"; + + try { + const collection = MaintenanceWindowModel.collection; + const indexes = await collection.indexes(); + const expiryIndex = indexes.find((idx) => idx.key && idx.key.expiry === 1); + + if (expiryIndex?.name) { + await collection.dropIndex(expiryIndex.name); + logger.info({ + service: SERVICE_NAME, + message: `Dropped TTL index ${expiryIndex.name} on MaintenanceWindow.expiry`, + }); + } else { + logger.info({ + service: SERVICE_NAME, + message: "No TTL index found on MaintenanceWindow.expiry", + }); + } + + const result = await collection.updateMany({ expiry: { $exists: true } }, { $unset: { expiry: "" } }); + logger.info({ + service: SERVICE_NAME, + message: `Unset expiry on ${result.modifiedCount} maintenance windows`, + }); + } catch (error) { + const errorMessage = error instanceof Error ? error.message : String(error); + logger.error({ + service: SERVICE_NAME, + message: `Error dropping expiry index: ${errorMessage}`, + }); + throw error; + } +} diff --git a/server/src/db/migration/index.ts b/server/src/db/migration/index.ts index 5b74a96fc..fcf0cf1fb 100644 --- a/server/src/db/migration/index.ts +++ b/server/src/db/migration/index.ts @@ -5,6 +5,7 @@ import { fixInfrastructureThresholds } from "./0004_fixInfrastructureThresholds. import MigrationModel from "../models/Migration.js"; import { migrateStatusPageTypeToArray } from "./0005_migrateStatusPageTypeToArray.js"; import { cleanupDuplicateMonitorStatsForUniqueIndex } from "./0006_cleanupDuplicateMonitorStatsForUniqueIndex.js"; +import { dropMaintenanceWindowExpiryIndex } from "./0007_dropMaintenanceWindowExpiryIndex.js"; import type { ILogger } from "@/utils/logger.js"; type MigrationEntry = { @@ -19,6 +20,7 @@ const migrations: MigrationEntry[] = [ { name: "0004_fixInfrastructureThresholds", execute: fixInfrastructureThresholds }, { name: "0005_migrateStatusPageTypeToArray", execute: migrateStatusPageTypeToArray }, { name: "0006_cleanupDuplicateMonitorStatsForUniqueIndex", execute: cleanupDuplicateMonitorStatsForUniqueIndex }, + { name: "0007_dropMaintenanceWindowExpiryIndex", execute: dropMaintenanceWindowExpiryIndex }, ]; const runMigrations = async (logger?: ILogger) => { diff --git a/server/src/db/models/MaintenanceWindow.ts b/server/src/db/models/MaintenanceWindow.ts index 311cf97bf..e3287ce7c 100644 --- a/server/src/db/models/MaintenanceWindow.ts +++ b/server/src/db/models/MaintenanceWindow.ts @@ -6,7 +6,6 @@ type MaintenanceWindowDocumentBase = Omit( end: { type: Date, }, - expiry: { - type: Date, - index: { expires: "0s" }, - }, }, { timestamps: true, diff --git a/server/src/repositories/maintenance-windows/MongoMaintenanceWindowsRepository.ts b/server/src/repositories/maintenance-windows/MongoMaintenanceWindowsRepository.ts index ad96328c5..0cdda805e 100644 --- a/server/src/repositories/maintenance-windows/MongoMaintenanceWindowsRepository.ts +++ b/server/src/repositories/maintenance-windows/MongoMaintenanceWindowsRepository.ts @@ -45,11 +45,6 @@ class MongoMaintenanceWindowsRepository implements IMaintenanceWindowsRepository create = async (data: Partial): Promise => { const maintenanceWindow = new MaintenanceWindowModel(data); - - // If the maintenance window is a one time window, set the expiry to the end date - if (maintenanceWindow.repeat === 0) { - maintenanceWindow.expiry = maintenanceWindow.end; - } const result = await maintenanceWindow.save(); return this.toEntity(result); }; diff --git a/server/src/validation/maintenanceWindowValidation.ts b/server/src/validation/maintenanceWindowValidation.ts index bee06e7bc..5d83b3fed 100644 --- a/server/src/validation/maintenanceWindowValidation.ts +++ b/server/src/validation/maintenanceWindowValidation.ts @@ -7,17 +7,26 @@ const dateToString = z.coerce.date().transform((d) => d.toISOString()); // Maintenance Window Validations //**************************************** -export const createMaintenanceWindowBodyValidation = z.object({ - monitors: z.array(z.string()).min(1, "At least one monitor is required"), - name: z.string().min(1, "Name is required"), - active: z.boolean().optional(), - duration: z.number().min(1, "Duration is required"), - durationUnit: z.enum(["seconds", "minutes", "hours", "days"]), - start: dateToString, - end: dateToString, - repeat: z.number().min(0, "Repeat must be a non-negative number"), - expiry: dateToString.optional(), -}); +export const createMaintenanceWindowBodyValidation = z + .object({ + monitors: z.array(z.string()).min(1, "At least one monitor is required"), + name: z.string().min(1, "Name is required"), + active: z.boolean().optional(), + duration: z.number().min(1, "Duration is required"), + durationUnit: z.enum(["seconds", "minutes", "hours", "days"]), + start: dateToString, + end: dateToString, + repeat: z.number().min(0, "Repeat must be a non-negative number"), + }) + .strict() + .refine((data) => new Date(data.end).getTime() > new Date(data.start).getTime(), { + message: "End must be after start", + path: ["end"], + }) + .refine((data) => data.repeat > 0 || new Date(data.end).getTime() > Date.now(), { + message: "End must be in the future for one-time maintenance windows", + path: ["end"], + }); export const getMaintenanceWindowByIdParamValidation = z.object({ id: z.string().min(1, "ID is required"), @@ -43,14 +52,19 @@ export const editMaintenanceWindowByIdParamValidation = z.object({ id: z.string().min(1, "ID is required"), }); -export const editMaintenanceByIdWindowBodyValidation = z.object({ - active: z.boolean().optional(), - name: z.string().optional(), - repeat: z.number().optional(), - start: dateToString.optional(), - end: dateToString.optional(), - expiry: dateToString.optional(), - monitors: z.array(z.string()).optional(), - duration: z.number().optional(), - durationUnit: z.enum(["seconds", "minutes", "hours", "days"]).optional(), -}); +export const editMaintenanceByIdWindowBodyValidation = z + .object({ + active: z.boolean().optional(), + name: z.string().optional(), + repeat: z.number().optional(), + start: dateToString.optional(), + end: dateToString.optional(), + monitors: z.array(z.string()).optional(), + duration: z.number().optional(), + durationUnit: z.enum(["seconds", "minutes", "hours", "days"]).optional(), + }) + .strict() + .refine((data) => !data.start || !data.end || new Date(data.end).getTime() > new Date(data.start).getTime(), { + message: "End must be after start", + path: ["end"], + }); From bd059da94a3027d7a196a5483067c398107224be Mon Sep 17 00:00:00 2001 From: gorkem-bwl Date: Thu, 7 May 2026 12:33:03 -0400 Subject: [PATCH 068/245] refactor(maintenance): tighten validator + migration per /simplify - Collapse two refines into a single superRefine in createMaintenanceWindowBodyValidation; same on edit. - Match peer migration convention: use mongoose.connection.db directly, not Model.collection. - Tighten TTL index detection: require expireAfterSeconds + single-field key. - Add Starting log at migration top to match peers 0003-0006. --- .../0007_dropMaintenanceWindowExpiryIndex.ts | 30 +++++++++----- .../validation/maintenanceWindowValidation.ts | 39 ++++++++++++++----- 2 files changed, 49 insertions(+), 20 deletions(-) diff --git a/server/src/db/migration/0007_dropMaintenanceWindowExpiryIndex.ts b/server/src/db/migration/0007_dropMaintenanceWindowExpiryIndex.ts index 850ab19ef..38e55c0d7 100644 --- a/server/src/db/migration/0007_dropMaintenanceWindowExpiryIndex.ts +++ b/server/src/db/migration/0007_dropMaintenanceWindowExpiryIndex.ts @@ -1,23 +1,33 @@ -import { MaintenanceWindowModel } from "../models/MaintenanceWindow.js"; +import mongoose from "mongoose"; import { logger } from "@/utils/logger.js"; /** - * Drops the TTL index on MaintenanceWindow.expiry and unsets the expiry field. + * Drops the TTL index on MaintenanceWindow.expiry and unsets the field on existing rows. * - * Earlier versions auto-set expiry = end for one-time maintenance windows. Combined with the - * TTL index ({ expires: "0s" }), this caused Mongo to silently delete completed one-time - * windows shortly after their end-date passed — including windows whose end-date was already - * in the past at creation time (e.g. timezone-mismatched payloads from automation tools). - * The fix stops writing expiry on create, and this migration removes the index and stale field - * so legacy rows are no longer at risk of being deleted by the TTL monitor. + * Why: a previous version copied `end` into `expiry` for one-time windows. Combined with the + * TTL index on `expiry`, this caused Mongo to silently delete rows whose `end` was in the past + * at creation time (a common symptom for automation tools sending non-UTC timestamps). The + * fix stops writing `expiry`; this migration removes the live exposure on existing installs. */ export async function dropMaintenanceWindowExpiryIndex(): Promise { const SERVICE_NAME = "Migration:DropMaintenanceWindowExpiryIndex"; try { - const collection = MaintenanceWindowModel.collection; + logger.info({ + service: SERVICE_NAME, + message: "Starting drop of MaintenanceWindow.expiry TTL index", + }); + + const db = mongoose.connection.db; + if (!db) { + throw new Error("Mongo connection not initialized"); + } + const collection = db.collection("maintenancewindows"); + const indexes = await collection.indexes(); - const expiryIndex = indexes.find((idx) => idx.key && idx.key.expiry === 1); + const expiryIndex = indexes.find( + (idx) => idx.expireAfterSeconds !== undefined && idx.key && Object.keys(idx.key).length === 1 && idx.key.expiry === 1 + ); if (expiryIndex?.name) { await collection.dropIndex(expiryIndex.name); diff --git a/server/src/validation/maintenanceWindowValidation.ts b/server/src/validation/maintenanceWindowValidation.ts index 5d83b3fed..9bf775596 100644 --- a/server/src/validation/maintenanceWindowValidation.ts +++ b/server/src/validation/maintenanceWindowValidation.ts @@ -19,13 +19,23 @@ export const createMaintenanceWindowBodyValidation = z repeat: z.number().min(0, "Repeat must be a non-negative number"), }) .strict() - .refine((data) => new Date(data.end).getTime() > new Date(data.start).getTime(), { - message: "End must be after start", - path: ["end"], - }) - .refine((data) => data.repeat > 0 || new Date(data.end).getTime() > Date.now(), { - message: "End must be in the future for one-time maintenance windows", - path: ["end"], + .superRefine((data, ctx) => { + const start = new Date(data.start).getTime(); + const end = new Date(data.end).getTime(); + if (end <= start) { + ctx.addIssue({ + code: z.ZodIssueCode.custom, + message: "End must be after start", + path: ["end"], + }); + } + if (data.repeat === 0 && end <= Date.now()) { + ctx.addIssue({ + code: z.ZodIssueCode.custom, + message: "End must be in the future for one-time maintenance windows", + path: ["end"], + }); + } }); export const getMaintenanceWindowByIdParamValidation = z.object({ @@ -64,7 +74,16 @@ export const editMaintenanceByIdWindowBodyValidation = z durationUnit: z.enum(["seconds", "minutes", "hours", "days"]).optional(), }) .strict() - .refine((data) => !data.start || !data.end || new Date(data.end).getTime() > new Date(data.start).getTime(), { - message: "End must be after start", - path: ["end"], + .superRefine((data, ctx) => { + if (data.start && data.end) { + const start = new Date(data.start).getTime(); + const end = new Date(data.end).getTime(); + if (end <= start) { + ctx.addIssue({ + code: z.ZodIssueCode.custom, + message: "End must be after start", + path: ["end"], + }); + } + } }); From b462ba756272fbab3364fdf67c32d24db283b2c5 Mon Sep 17 00:00:00 2001 From: gorkem-bwl Date: Thu, 7 May 2026 12:38:54 -0400 Subject: [PATCH 069/245] refactor(maintenance): centralize DurationUnit + sync OpenAPI spec Convention pass per docs/coding-conventions.md (rules 5/8/10/11): - Promote DurationUnit from a raw union type to a const tuple + derived type. - Mongoose schema enum imports DurationUnits instead of inlining the values. - Both validators import DurationUnits instead of inlining z.enum twice. - Drop stale 'expiry' entries from openapi.json (spec is now strict-mode-correct). --- server/openapi.json | 10 ---------- server/src/db/models/MaintenanceWindow.ts | 4 ++-- server/src/types/maintenanceWindow.ts | 3 ++- server/src/validation/maintenanceWindowValidation.ts | 5 +++-- 4 files changed, 7 insertions(+), 15 deletions(-) diff --git a/server/openapi.json b/server/openapi.json index f62062332..058e934b7 100644 --- a/server/openapi.json +++ b/server/openapi.json @@ -4657,11 +4657,6 @@ "repeat": { "type": "number", "minimum": 0 - }, - "expiry": { - "type": "string", - "nullable": true, - "format": "date-time" } }, "required": ["monitors", "name", "duration", "durationUnit", "start", "end", "repeat"] @@ -5190,11 +5185,6 @@ "nullable": true, "format": "date-time" }, - "expiry": { - "type": "string", - "nullable": true, - "format": "date-time" - }, "monitors": { "type": "array", "items": { diff --git a/server/src/db/models/MaintenanceWindow.ts b/server/src/db/models/MaintenanceWindow.ts index e3287ce7c..1fc9b5f4a 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 } from "@/types/maintenanceWindow.js"; +import { DurationUnits, type MaintenanceWindow } from "@/types/maintenanceWindow.js"; type MaintenanceWindowDocumentBase = Omit & { monitorId: Types.ObjectId; @@ -38,7 +38,7 @@ const MaintenanceWindowSchema = new Schema( }, durationUnit: { type: String, - enum: ["seconds", "minutes", "hours", "days"], + enum: DurationUnits, }, repeat: { type: Number, diff --git a/server/src/types/maintenanceWindow.ts b/server/src/types/maintenanceWindow.ts index bcbd075a1..950dc1713 100644 --- a/server/src/types/maintenanceWindow.ts +++ b/server/src/types/maintenanceWindow.ts @@ -1,4 +1,5 @@ -export type DurationUnit = "seconds" | "minutes" | "hours" | "days"; +export const DurationUnits = ["seconds", "minutes", "hours", "days"] as const; +export type DurationUnit = (typeof DurationUnits)[number]; export interface MaintenanceWindow { id: string; diff --git a/server/src/validation/maintenanceWindowValidation.ts b/server/src/validation/maintenanceWindowValidation.ts index 9bf775596..bdd11df5f 100644 --- a/server/src/validation/maintenanceWindowValidation.ts +++ b/server/src/validation/maintenanceWindowValidation.ts @@ -1,5 +1,6 @@ import { z } from "zod"; import { booleanCoercion } from "./shared.js"; +import { DurationUnits } from "@/types/maintenanceWindow.js"; const dateToString = z.coerce.date().transform((d) => d.toISOString()); @@ -13,7 +14,7 @@ export const createMaintenanceWindowBodyValidation = z name: z.string().min(1, "Name is required"), active: z.boolean().optional(), duration: z.number().min(1, "Duration is required"), - durationUnit: z.enum(["seconds", "minutes", "hours", "days"]), + durationUnit: z.enum(DurationUnits), start: dateToString, end: dateToString, repeat: z.number().min(0, "Repeat must be a non-negative number"), @@ -71,7 +72,7 @@ export const editMaintenanceByIdWindowBodyValidation = z end: dateToString.optional(), monitors: z.array(z.string()).optional(), duration: z.number().optional(), - durationUnit: z.enum(["seconds", "minutes", "hours", "days"]).optional(), + durationUnit: z.enum(DurationUnits).optional(), }) .strict() .superRefine((data, ctx) => { From 7906f2f615a8f28cabce0d573ae9b57fb04c7cf8 Mon Sep 17 00:00:00 2001 From: Alex Date: Thu, 7 May 2026 09:54:03 -0700 Subject: [PATCH 070/245] add stauts code label component --- .../design-elements/StatusCodeLabel.tsx | 36 +++++++++++++++++++ 1 file changed, 36 insertions(+) create mode 100644 client/src/Components/design-elements/StatusCodeLabel.tsx diff --git a/client/src/Components/design-elements/StatusCodeLabel.tsx b/client/src/Components/design-elements/StatusCodeLabel.tsx new file mode 100644 index 000000000..b1500d42b --- /dev/null +++ b/client/src/Components/design-elements/StatusCodeLabel.tsx @@ -0,0 +1,36 @@ +import { useTranslation } from "react-i18next"; +import { + formatStatusCode, + getStatusCodeTooltip, + getStatusCodeValueType, +} from "@/Utils/statusCode"; +import { ValueLabel } from "./StatusLabel"; +import { Tooltip } from "./Tooltip"; + +export type StatusCodeLabelProps = { + statusCode: number | null | undefined; + message?: string | null; + fallback?: string; +}; + +export const StatusCodeLabel = ({ + statusCode, + message, + fallback = "N/A", +}: StatusCodeLabelProps) => { + const { t } = useTranslation(); + if (!statusCode) { + return <>{fallback}; + } + + const text = formatStatusCode(statusCode, t); + const tooltip = getStatusCodeTooltip(statusCode, message, t); + const body = ( + + ); + if (!tooltip) return body; + return {body}; +}; From 7c14d81a2428c99df99ac27517d5aa866bb165bf Mon Sep 17 00:00:00 2001 From: Alex Date: Thu, 7 May 2026 09:54:22 -0700 Subject: [PATCH 071/245] refactor utils --- client/src/Utils/statusCode.ts | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/client/src/Utils/statusCode.ts b/client/src/Utils/statusCode.ts index 75e4a9b19..33a5f3de1 100644 --- a/client/src/Utils/statusCode.ts +++ b/client/src/Utils/statusCode.ts @@ -49,10 +49,8 @@ export const getNetworkErrorSubtype = (message?: string | null): NetworkErrorSub export const formatStatusCode = ( code: number | null | undefined, - t: TranslateFn, - fallback: string = "N/A" + t: TranslateFn ): string => { - if (code == null) return fallback; if (isNetworkError(code)) return t("common.statusCode.down"); return String(code); }; From 0827d4f5285abbd80231d155be347f02b91807f5 Mon Sep 17 00:00:00 2001 From: Alex Date: Thu, 7 May 2026 09:54:50 -0700 Subject: [PATCH 072/245] add new component to barrel export --- client/src/Components/design-elements/index.tsx | 1 + 1 file changed, 1 insertion(+) diff --git a/client/src/Components/design-elements/index.tsx b/client/src/Components/design-elements/index.tsx index f6e2771ec..557f3bfc2 100644 --- a/client/src/Components/design-elements/index.tsx +++ b/client/src/Components/design-elements/index.tsx @@ -25,3 +25,4 @@ export * from "./TextLink"; export * from "./NoticeBanner"; export * from "./OfflineBanner"; export * from "./Avatar"; +export * from "./StatusCodeLabel"; From 1d90b8d8d9032ce98b0e9450a598bbee0163304a Mon Sep 17 00:00:00 2001 From: Alex Date: Thu, 7 May 2026 09:54:58 -0700 Subject: [PATCH 073/245] refactor all call sites --- .../Pages/Checks/Components/ChecksTable.tsx | 32 ++-------- .../Incidents/Components/CardDetails.tsx | 21 ++---- .../Incidents/Components/IncidentTable.tsx | 39 ++++------- .../Uptime/Details/Components/ChecksTable.tsx | 64 +++++++++---------- .../Details/Components/GeoChecksTable.tsx | 59 ++++++++--------- 5 files changed, 78 insertions(+), 137 deletions(-) diff --git a/client/src/Pages/Checks/Components/ChecksTable.tsx b/client/src/Pages/Checks/Components/ChecksTable.tsx index bd7170572..31737f4bb 100644 --- a/client/src/Pages/Checks/Components/ChecksTable.tsx +++ b/client/src/Pages/Checks/Components/ChecksTable.tsx @@ -1,21 +1,14 @@ import { Table, Pagination, - ValueLabel, StatusLabel, - Tooltip, + StatusCodeLabel, } from "@/Components/design-elements"; import Box from "@mui/material/Box"; import type { Header } from "@/Components/design-elements/Table"; import type { Monitor } from "@/Types/Monitor"; - import { useTranslation } from "react-i18next"; import { formatDateWithTz } from "@/Utils/TimeUtils"; -import { - formatStatusCode, - getStatusCodeTooltip, - getStatusCodeValueType, -} from "@/Utils/statusCode"; import type { Check } from "@/Types/Check"; import type { RootState } from "@/Types/state"; import { useSelector } from "react-redux"; @@ -40,7 +33,7 @@ export const ChecksTable = ({ const { t } = useTranslation(); const uiTimezone = useSelector((state: RootState) => state.ui.timezone); - const getHeaders = (t: (key: string) => string, uiTimezone: string) => { + const getHeaders = () => { const headers: Header[] = [ { id: "monitorName", @@ -74,23 +67,12 @@ export const ChecksTable = ({ id: "statusCode", content: t("pages.checks.table.headers.statusCode"), render: (row) => { - const code = row.statusCode; - if (!code) return "N/A"; - const text = formatStatusCode(code, t); - const tooltip = getStatusCodeTooltip(code, row.message, t); - const label = ( - ); - return tooltip ? ( - - {label} - - ) : ( - label - ); }, }, { @@ -104,7 +86,7 @@ export const ChecksTable = ({ return headers; }; - const headers = getHeaders(t, uiTimezone); + const headers = getHeaders(); const handlePageChange = ( _e: React.MouseEvent | null, diff --git a/client/src/Pages/Incidents/Components/CardDetails.tsx b/client/src/Pages/Incidents/Components/CardDetails.tsx index 2cd8fbde4..b2d72d08c 100644 --- a/client/src/Pages/Incidents/Components/CardDetails.tsx +++ b/client/src/Pages/Incidents/Components/CardDetails.tsx @@ -2,7 +2,7 @@ import Stack from "@mui/material/Stack"; import Grid from "@mui/material/Grid"; import Divider from "@mui/material/Divider"; import Typography from "@mui/material/Typography"; -import { BaseBox, ValueLabel, Tooltip } from "@/Components/design-elements"; +import { BaseBox, ValueLabel, StatusCodeLabel } from "@/Components/design-elements"; import { LAYOUT } from "@/Utils/Theme/constants"; import { useTranslation } from "react-i18next"; @@ -13,7 +13,6 @@ import { useSelector } from "react-redux"; import type { RootState } from "@/Types/state"; import { formatDateWithTz } from "@/Utils/TimeUtils"; import { getIncidentsDuration } from "@/Pages/Incidents/utils"; -import { formatStatusCode, getStatusCodeTooltip } from "@/Utils/statusCode"; interface CardDetailsProps { incident: Incident | null; @@ -48,13 +47,6 @@ export const CardDetails = ({ incident, monitor, sx }: CardDetailsProps) => { return null; } - const statusCodeText = formatStatusCode(incident.statusCode, t); - const statusCodeTooltip = getStatusCodeTooltip( - incident.statusCode, - incident.message, - t - ); - return ( { - {statusCodeTooltip ? ( - - {statusCodeText} - - ) : ( - statusCodeText - )} + diff --git a/client/src/Pages/Incidents/Components/IncidentTable.tsx b/client/src/Pages/Incidents/Components/IncidentTable.tsx index c66cdb0ed..399f4d8f8 100644 --- a/client/src/Pages/Incidents/Components/IncidentTable.tsx +++ b/client/src/Pages/Incidents/Components/IncidentTable.tsx @@ -1,4 +1,4 @@ -import { Table, ValueLabel, Tooltip } from "@/Components/design-elements"; +import { Table, ValueLabel, StatusCodeLabel } from "@/Components/design-elements"; import { Pagination } from "@/Components/design-elements/Table"; import type { Header } from "@/Components/design-elements/Table"; import { ActionsMenu } from "@/Components/actions-menu"; @@ -14,11 +14,6 @@ import Typography from "@mui/material/Typography"; import { useTheme } from "@mui/material/styles"; import Box from "@mui/material/Box"; import { getMonitorPath } from "@/Utils/MonitorUtils"; -import { - formatStatusCode, - getStatusCodeTooltip, - getStatusCodeValueType, -} from "@/Utils/statusCode"; interface IncidentsTableProps { title?: string; @@ -139,13 +134,12 @@ export const IncidentsTable = ({ return ( {row.resolutionType} @@ -158,23 +152,12 @@ export const IncidentsTable = ({ id: "statusCode", content: t("pages.incidents.table.headers.statusCode"), render: (row) => { - const code = row.statusCode; - if (!code) return "N/A"; - const text = formatStatusCode(code, t); - const tooltip = getStatusCodeTooltip(code, row.message, t); - const label = ( - ); - return tooltip ? ( - - {label} - - ) : ( - label - ); }, }, { diff --git a/client/src/Pages/Uptime/Details/Components/ChecksTable.tsx b/client/src/Pages/Uptime/Details/Components/ChecksTable.tsx index 0ab9a7a7c..b100c28c4 100644 --- a/client/src/Pages/Uptime/Details/Components/ChecksTable.tsx +++ b/client/src/Pages/Uptime/Details/Components/ChecksTable.tsx @@ -1,4 +1,9 @@ -import { Table, Pagination, StatusLabel, Tooltip } from "@/Components/design-elements"; +import { + Table, + Pagination, + StatusLabel, + StatusCodeLabel, +} from "@/Components/design-elements"; import Box from "@mui/material/Box"; import type { Header } from "@/Components/design-elements"; import type { Check } from "@/Types/Check"; @@ -6,11 +11,28 @@ import type { Check } from "@/Types/Check"; import { useNavigate } from "react-router"; import { useTranslation } from "react-i18next"; import { formatDateWithTz } from "@/Utils/TimeUtils"; -import { formatStatusCode, getStatusCodeTooltip } from "@/Utils/statusCode"; import type { RootState } from "@/Types/state"; import { useSelector } from "react-redux"; -const getHeaders = (t: (key: string) => string, uiTimezone: string) => { +export const ChecksTable = ({ + checks, + count, + page, + setPage, + rowsPerPage, + setRowsPerPage, +}: { + checks: Check[]; + count: number; + page: number; + setPage: (page: number) => void; + rowsPerPage: number; + setRowsPerPage: (rowsPerPage: number) => void; +}) => { + const navigate = useNavigate(); + const { t } = useTranslation(); + const uiTimezone = useSelector((state: RootState) => state.ui.timezone); + const headers: Header[] = [ { id: "status", @@ -37,41 +59,15 @@ const getHeaders = (t: (key: string) => string, uiTimezone: string) => { id: "statusCode", content: t("pages.checks.table.headers.statusCode"), render: (row) => { - if (!row.statusCode) return "N/A"; - const text = formatStatusCode(row.statusCode, t); - const tooltip = getStatusCodeTooltip(row.statusCode, row.message, t); - return tooltip ? ( - - {text} - - ) : ( - text + return ( + ); }, }, ]; - return headers; -}; - -export const ChecksTable = ({ - checks, - count, - page, - setPage, - rowsPerPage, - setRowsPerPage, -}: { - checks: Check[]; - count: number; - page: number; - setPage: (page: number) => void; - rowsPerPage: number; - setRowsPerPage: (rowsPerPage: number) => void; -}) => { - const navigate = useNavigate(); - const { t } = useTranslation(); - const uiTimezone = useSelector((state: RootState) => state.ui.timezone); - const headers = getHeaders(t, uiTimezone); const handlePageChange = ( _e: React.MouseEvent | null, diff --git a/client/src/Pages/Uptime/Details/Components/GeoChecksTable.tsx b/client/src/Pages/Uptime/Details/Components/GeoChecksTable.tsx index ae56e037e..373e09da2 100644 --- a/client/src/Pages/Uptime/Details/Components/GeoChecksTable.tsx +++ b/client/src/Pages/Uptime/Details/Components/GeoChecksTable.tsx @@ -1,15 +1,36 @@ -import { Table, Pagination, StatusLabel, Tooltip } from "@/Components/design-elements"; +import { + Table, + Pagination, + StatusLabel, + StatusCodeLabel, +} from "@/Components/design-elements"; import Box from "@mui/material/Box"; import type { Header } from "@/Components/design-elements"; import type { FlatGeoCheck } from "@/Types/GeoCheck"; import { useTranslation } from "react-i18next"; import { formatDateWithTz } from "@/Utils/TimeUtils"; -import { formatStatusCode, getStatusCodeTooltip } from "@/Utils/statusCode"; import type { RootState } from "@/Types/state"; import { useSelector } from "react-redux"; import prettyMilliseconds from "pretty-ms"; -const getHeaders = (t: (key: string) => string, uiTimezone: string) => { +export const GeoChecksTable = ({ + geoChecks, + count, + page, + setPage, + rowsPerPage, + setRowsPerPage, +}: { + geoChecks: FlatGeoCheck[]; + count: number; + page: number; + setPage: (page: number) => void; + rowsPerPage: number; + setRowsPerPage: (rowsPerPage: number) => void; +}) => { + const { t } = useTranslation(); + const uiTimezone = useSelector((state: RootState) => state.ui.timezone); + const headers: Header[] = [ { id: "status", @@ -30,16 +51,7 @@ const getHeaders = (t: (key: string) => string, uiTimezone: string) => { id: "statusCode", content: t("pages.checks.table.headers.statusCode"), render: (row) => { - if (!row.statusCode) return "N/A"; - const text = formatStatusCode(row.statusCode, t); - const tooltip = getStatusCodeTooltip(row.statusCode, undefined, t); - return tooltip ? ( - - {text} - - ) : ( - text - ); + return ; }, }, { @@ -61,27 +73,6 @@ const getHeaders = (t: (key: string) => string, uiTimezone: string) => { }, }, ]; - return headers; -}; - -export const GeoChecksTable = ({ - geoChecks, - count, - page, - setPage, - rowsPerPage, - setRowsPerPage, -}: { - geoChecks: FlatGeoCheck[]; - count: number; - page: number; - setPage: (page: number) => void; - rowsPerPage: number; - setRowsPerPage: (rowsPerPage: number) => void; -}) => { - const { t } = useTranslation(); - const uiTimezone = useSelector((state: RootState) => state.ui.timezone); - const headers = getHeaders(t, uiTimezone); const handlePageChange = ( _e: React.MouseEvent | null, From 713c3ea34fe6c7629af5db46a75200cee628b990 Mon Sep 17 00:00:00 2001 From: Alex Date: Thu, 7 May 2026 09:56:56 -0700 Subject: [PATCH 074/245] add span for ref --- client/src/Components/design-elements/StatusCodeLabel.tsx | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/client/src/Components/design-elements/StatusCodeLabel.tsx b/client/src/Components/design-elements/StatusCodeLabel.tsx index b1500d42b..f088550f0 100644 --- a/client/src/Components/design-elements/StatusCodeLabel.tsx +++ b/client/src/Components/design-elements/StatusCodeLabel.tsx @@ -32,5 +32,9 @@ export const StatusCodeLabel = ({ /> ); if (!tooltip) return body; - return {body}; + return ( + + {body} + + ); }; From c45bcfaa2fdd323741e45ccdb3111078c2ff1675 Mon Sep 17 00:00:00 2001 From: gorkem-bwl Date: Thu, 7 May 2026 13:38:10 -0400 Subject: [PATCH 075/245] revert(maintenance): keep one-time TTL cleanup, narrow PR to validator-only Per review feedback: auto-deleting completed one-time maintenance windows is intentional cleanup, not a bug. The actual bug is past-dated end values being accepted at creation time, which the validator refines now reject up front. Reverts: - Restore expiry field + TTL index on MaintenanceWindow schema - Restore repository's auto-set of expiry = end for repeat=0 - Drop migration 0007 (no longer needed) - Restore expiry properties in openapi.json - Restore expiry in create/edit validators (still optional) Kept from this PR: - .strict() on create/edit body schemas - end > start refine on create/edit - end > now refine on create when repeat=0 (the real fix) - DurationUnits const tuple + reuse (rules 5/8/10) - Client no longer sends redundant expiry (server still derives it) --- server/openapi.json | 10 ++++ .../0007_dropMaintenanceWindowExpiryIndex.ts | 58 ------------------- server/src/db/migration/index.ts | 2 - server/src/db/models/MaintenanceWindow.ts | 5 ++ .../MongoMaintenanceWindowsRepository.ts | 5 ++ .../validation/maintenanceWindowValidation.ts | 2 + 6 files changed, 22 insertions(+), 60 deletions(-) delete mode 100644 server/src/db/migration/0007_dropMaintenanceWindowExpiryIndex.ts diff --git a/server/openapi.json b/server/openapi.json index 058e934b7..f62062332 100644 --- a/server/openapi.json +++ b/server/openapi.json @@ -4657,6 +4657,11 @@ "repeat": { "type": "number", "minimum": 0 + }, + "expiry": { + "type": "string", + "nullable": true, + "format": "date-time" } }, "required": ["monitors", "name", "duration", "durationUnit", "start", "end", "repeat"] @@ -5185,6 +5190,11 @@ "nullable": true, "format": "date-time" }, + "expiry": { + "type": "string", + "nullable": true, + "format": "date-time" + }, "monitors": { "type": "array", "items": { diff --git a/server/src/db/migration/0007_dropMaintenanceWindowExpiryIndex.ts b/server/src/db/migration/0007_dropMaintenanceWindowExpiryIndex.ts deleted file mode 100644 index 38e55c0d7..000000000 --- a/server/src/db/migration/0007_dropMaintenanceWindowExpiryIndex.ts +++ /dev/null @@ -1,58 +0,0 @@ -import mongoose from "mongoose"; -import { logger } from "@/utils/logger.js"; - -/** - * Drops the TTL index on MaintenanceWindow.expiry and unsets the field on existing rows. - * - * Why: a previous version copied `end` into `expiry` for one-time windows. Combined with the - * TTL index on `expiry`, this caused Mongo to silently delete rows whose `end` was in the past - * at creation time (a common symptom for automation tools sending non-UTC timestamps). The - * fix stops writing `expiry`; this migration removes the live exposure on existing installs. - */ -export async function dropMaintenanceWindowExpiryIndex(): Promise { - const SERVICE_NAME = "Migration:DropMaintenanceWindowExpiryIndex"; - - try { - logger.info({ - service: SERVICE_NAME, - message: "Starting drop of MaintenanceWindow.expiry TTL index", - }); - - const db = mongoose.connection.db; - if (!db) { - throw new Error("Mongo connection not initialized"); - } - const collection = db.collection("maintenancewindows"); - - const indexes = await collection.indexes(); - const expiryIndex = indexes.find( - (idx) => idx.expireAfterSeconds !== undefined && idx.key && Object.keys(idx.key).length === 1 && idx.key.expiry === 1 - ); - - if (expiryIndex?.name) { - await collection.dropIndex(expiryIndex.name); - logger.info({ - service: SERVICE_NAME, - message: `Dropped TTL index ${expiryIndex.name} on MaintenanceWindow.expiry`, - }); - } else { - logger.info({ - service: SERVICE_NAME, - message: "No TTL index found on MaintenanceWindow.expiry", - }); - } - - const result = await collection.updateMany({ expiry: { $exists: true } }, { $unset: { expiry: "" } }); - logger.info({ - service: SERVICE_NAME, - message: `Unset expiry on ${result.modifiedCount} maintenance windows`, - }); - } catch (error) { - const errorMessage = error instanceof Error ? error.message : String(error); - logger.error({ - service: SERVICE_NAME, - message: `Error dropping expiry index: ${errorMessage}`, - }); - throw error; - } -} diff --git a/server/src/db/migration/index.ts b/server/src/db/migration/index.ts index fcf0cf1fb..5b74a96fc 100644 --- a/server/src/db/migration/index.ts +++ b/server/src/db/migration/index.ts @@ -5,7 +5,6 @@ import { fixInfrastructureThresholds } from "./0004_fixInfrastructureThresholds. import MigrationModel from "../models/Migration.js"; import { migrateStatusPageTypeToArray } from "./0005_migrateStatusPageTypeToArray.js"; import { cleanupDuplicateMonitorStatsForUniqueIndex } from "./0006_cleanupDuplicateMonitorStatsForUniqueIndex.js"; -import { dropMaintenanceWindowExpiryIndex } from "./0007_dropMaintenanceWindowExpiryIndex.js"; import type { ILogger } from "@/utils/logger.js"; type MigrationEntry = { @@ -20,7 +19,6 @@ const migrations: MigrationEntry[] = [ { name: "0004_fixInfrastructureThresholds", execute: fixInfrastructureThresholds }, { name: "0005_migrateStatusPageTypeToArray", execute: migrateStatusPageTypeToArray }, { name: "0006_cleanupDuplicateMonitorStatsForUniqueIndex", execute: cleanupDuplicateMonitorStatsForUniqueIndex }, - { name: "0007_dropMaintenanceWindowExpiryIndex", execute: dropMaintenanceWindowExpiryIndex }, ]; const runMigrations = async (logger?: ILogger) => { diff --git a/server/src/db/models/MaintenanceWindow.ts b/server/src/db/models/MaintenanceWindow.ts index 1fc9b5f4a..703c144c8 100644 --- a/server/src/db/models/MaintenanceWindow.ts +++ b/server/src/db/models/MaintenanceWindow.ts @@ -6,6 +6,7 @@ type MaintenanceWindowDocumentBase = Omit( end: { type: Date, }, + expiry: { + type: Date, + index: { expires: "0s" }, + }, }, { timestamps: true, diff --git a/server/src/repositories/maintenance-windows/MongoMaintenanceWindowsRepository.ts b/server/src/repositories/maintenance-windows/MongoMaintenanceWindowsRepository.ts index 0cdda805e..ad96328c5 100644 --- a/server/src/repositories/maintenance-windows/MongoMaintenanceWindowsRepository.ts +++ b/server/src/repositories/maintenance-windows/MongoMaintenanceWindowsRepository.ts @@ -45,6 +45,11 @@ class MongoMaintenanceWindowsRepository implements IMaintenanceWindowsRepository create = async (data: Partial): Promise => { const maintenanceWindow = new MaintenanceWindowModel(data); + + // If the maintenance window is a one time window, set the expiry to the end date + if (maintenanceWindow.repeat === 0) { + maintenanceWindow.expiry = maintenanceWindow.end; + } const result = await maintenanceWindow.save(); return this.toEntity(result); }; diff --git a/server/src/validation/maintenanceWindowValidation.ts b/server/src/validation/maintenanceWindowValidation.ts index bdd11df5f..c53e39b20 100644 --- a/server/src/validation/maintenanceWindowValidation.ts +++ b/server/src/validation/maintenanceWindowValidation.ts @@ -18,6 +18,7 @@ export const createMaintenanceWindowBodyValidation = z start: dateToString, end: dateToString, repeat: z.number().min(0, "Repeat must be a non-negative number"), + expiry: dateToString.optional(), }) .strict() .superRefine((data, ctx) => { @@ -70,6 +71,7 @@ export const editMaintenanceByIdWindowBodyValidation = z repeat: z.number().optional(), start: dateToString.optional(), end: dateToString.optional(), + expiry: dateToString.optional(), monitors: z.array(z.string()).optional(), duration: z.number().optional(), durationUnit: z.enum(DurationUnits).optional(), From 60cded78a646505d1f9dc3bf7ba04a89dc341598 Mon Sep 17 00:00:00 2001 From: Alex Date: Thu, 7 May 2026 11:43:00 -0700 Subject: [PATCH 076/245] validation tests --- server/jest.config.ts | 1 + .../maintenanceWindowValidation.test.ts | 171 ++++++++++++++++++ 2 files changed, 172 insertions(+) create mode 100644 server/test/unit/validation/maintenanceWindowValidation.test.ts diff --git a/server/jest.config.ts b/server/jest.config.ts index be7d359b1..1faf98181 100644 --- a/server/jest.config.ts +++ b/server/jest.config.ts @@ -13,6 +13,7 @@ const config: Config = { "^@/(.*)\\.ts$": "/src/$1.ts", "^@/(.*)\\.js$": "/src/$1.ts", "^@/(.*)$": "/src/$1", + "^(\\.{1,2}/.*)\\.js$": "$1", }, testMatch: ["/test/**/*.test.ts"], setupFilesAfterEnv: [], diff --git a/server/test/unit/validation/maintenanceWindowValidation.test.ts b/server/test/unit/validation/maintenanceWindowValidation.test.ts new file mode 100644 index 000000000..dfede3670 --- /dev/null +++ b/server/test/unit/validation/maintenanceWindowValidation.test.ts @@ -0,0 +1,171 @@ +import { describe, expect, it, jest, afterEach } from "@jest/globals"; +import { + createMaintenanceWindowBodyValidation, + editMaintenanceByIdWindowBodyValidation, +} from "../../../src/validation/maintenanceWindowValidation.ts"; + +const FROZEN_NOW = new Date("2026-05-07T12:00:00Z").getTime(); + +const baseCreateBody = (overrides: Record = {}) => ({ + monitors: ["mon-1"], + name: "Scheduled Maintenance", + duration: 60, + durationUnit: "minutes", + start: new Date(FROZEN_NOW + 60 * 60 * 1000).toISOString(), + end: new Date(FROZEN_NOW + 2 * 60 * 60 * 1000).toISOString(), + repeat: 0, + ...overrides, +}); + +afterEach(() => { + jest.useRealTimers(); +}); + +describe("createMaintenanceWindowBodyValidation", () => { + it("accepts a valid one-time window with end in the future", () => { + jest.useFakeTimers().setSystemTime(FROZEN_NOW); + const result = createMaintenanceWindowBodyValidation.safeParse(baseCreateBody()); + expect(result.success).toBe(true); + }); + + it("accepts a recurring window even when end is in the past", () => { + jest.useFakeTimers().setSystemTime(FROZEN_NOW); + const result = createMaintenanceWindowBodyValidation.safeParse( + baseCreateBody({ + repeat: 1, + start: new Date(FROZEN_NOW - 2 * 60 * 60 * 1000).toISOString(), + end: new Date(FROZEN_NOW - 60 * 60 * 1000).toISOString(), + }) + ); + expect(result.success).toBe(true); + }); + + it("rejects when end is before start", () => { + jest.useFakeTimers().setSystemTime(FROZEN_NOW); + const result = createMaintenanceWindowBodyValidation.safeParse( + baseCreateBody({ + start: new Date(FROZEN_NOW + 2 * 60 * 60 * 1000).toISOString(), + end: new Date(FROZEN_NOW + 60 * 60 * 1000).toISOString(), + }) + ); + expect(result.success).toBe(false); + if (!result.success) { + expect(result.error.issues).toEqual(expect.arrayContaining([expect.objectContaining({ message: "End must be after start", path: ["end"] })])); + } + }); + + it("rejects when end equals start", () => { + jest.useFakeTimers().setSystemTime(FROZEN_NOW); + const sameInstant = new Date(FROZEN_NOW + 60 * 60 * 1000).toISOString(); + const result = createMaintenanceWindowBodyValidation.safeParse(baseCreateBody({ start: sameInstant, end: sameInstant })); + expect(result.success).toBe(false); + if (!result.success) { + expect(result.error.issues).toEqual(expect.arrayContaining([expect.objectContaining({ message: "End must be after start", path: ["end"] })])); + } + }); + + it("rejects a one-time window with end in the past", () => { + jest.useFakeTimers().setSystemTime(FROZEN_NOW); + const result = createMaintenanceWindowBodyValidation.safeParse( + baseCreateBody({ + start: new Date(FROZEN_NOW - 2 * 60 * 60 * 1000).toISOString(), + end: new Date(FROZEN_NOW - 60 * 60 * 1000).toISOString(), + }) + ); + expect(result.success).toBe(false); + if (!result.success) { + expect(result.error.issues).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + message: "End must be in the future for one-time maintenance windows", + path: ["end"], + }), + ]) + ); + } + }); + + it("rejects a one-time window with end exactly equal to now", () => { + jest.useFakeTimers().setSystemTime(FROZEN_NOW); + const result = createMaintenanceWindowBodyValidation.safeParse( + baseCreateBody({ + start: new Date(FROZEN_NOW - 60 * 60 * 1000).toISOString(), + end: new Date(FROZEN_NOW).toISOString(), + }) + ); + expect(result.success).toBe(false); + if (!result.success) { + expect(result.error.issues).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + message: "End must be in the future for one-time maintenance windows", + path: ["end"], + }), + ]) + ); + } + }); + + it("rejects unknown fields under .strict()", () => { + jest.useFakeTimers().setSystemTime(FROZEN_NOW); + const result = createMaintenanceWindowBodyValidation.safeParse(baseCreateBody({ teamId: "team-1" })); + expect(result.success).toBe(false); + }); + + it("rejects an invalid durationUnit value", () => { + jest.useFakeTimers().setSystemTime(FROZEN_NOW); + const result = createMaintenanceWindowBodyValidation.safeParse(baseCreateBody({ durationUnit: "weeks" })); + expect(result.success).toBe(false); + }); + + it("rejects a missing required field", () => { + jest.useFakeTimers().setSystemTime(FROZEN_NOW); + const { name: _name, ...withoutName } = baseCreateBody(); + const result = createMaintenanceWindowBodyValidation.safeParse(withoutName); + expect(result.success).toBe(false); + }); +}); + +describe("editMaintenanceByIdWindowBodyValidation", () => { + it("accepts an empty body (all fields optional)", () => { + const result = editMaintenanceByIdWindowBodyValidation.safeParse({}); + expect(result.success).toBe(true); + }); + + it("accepts a partial update with only active toggled", () => { + const result = editMaintenanceByIdWindowBodyValidation.safeParse({ active: false }); + expect(result.success).toBe(true); + }); + + it("rejects unknown fields under .strict()", () => { + const result = editMaintenanceByIdWindowBodyValidation.safeParse({ + active: false, + id: "mw-1", + }); + expect(result.success).toBe(false); + }); + + it("rejects when start and end are both present and end <= start", () => { + const start = new Date("2026-05-08T10:00:00Z").toISOString(); + const end = new Date("2026-05-08T09:00:00Z").toISOString(); + const result = editMaintenanceByIdWindowBodyValidation.safeParse({ start, end }); + expect(result.success).toBe(false); + if (!result.success) { + expect(result.error.issues).toEqual(expect.arrayContaining([expect.objectContaining({ message: "End must be after start", path: ["end"] })])); + } + }); + + it("does not apply the start/end ordering rule when only end is provided", () => { + const result = editMaintenanceByIdWindowBodyValidation.safeParse({ + end: new Date("2020-01-01T00:00:00Z").toISOString(), + }); + expect(result.success).toBe(true); + }); + + it("does not apply the start/end ordering rule when only start is provided", () => { + const result = editMaintenanceByIdWindowBodyValidation.safeParse({ + start: new Date("2099-01-01T00:00:00Z").toISOString(), + }); + expect(result.success).toBe(true); + }); +}); From 0d720710194a2fe3d1cb13ba5bbac49fbf5c0808 Mon Sep 17 00:00:00 2001 From: Alex Holliday Date: Fri, 8 May 2026 08:59:11 -0700 Subject: [PATCH 077/245] add dns types --- server/src/types/monitor.ts | 4 +++- server/src/types/network.ts | 8 ++++++++ 2 files changed, 11 insertions(+), 1 deletion(-) diff --git a/server/src/types/monitor.ts b/server/src/types/monitor.ts index 2c2f9c08f..6e2747e1a 100644 --- a/server/src/types/monitor.ts +++ b/server/src/types/monitor.ts @@ -3,7 +3,7 @@ export type { CheckSnapshot } from "@/types/check.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; +export const MonitorTypes = ["http", "ping", "pagespeed", "hardware", "docker", "port", "game", "grpc", "websocket", "dns", "unknown"] as const; export type MonitorType = (typeof MonitorTypes)[number]; export const GeoCheckSupportedTypes: readonly MonitorType[] = ["http", "ping"] as const; @@ -54,6 +54,8 @@ export interface Monitor { geoCheckEnabled?: boolean; geoCheckLocations?: GeoContinent[]; geoCheckInterval?: number; + dnsServer?: string; + dnsRecordType?: string; recentChecks: CheckSnapshot[]; createdAt: string; updatedAt: string; diff --git a/server/src/types/network.ts b/server/src/types/network.ts index 6c7172724..d87b62395 100644 --- a/server/src/types/network.ts +++ b/server/src/types/network.ts @@ -116,6 +116,14 @@ export interface WebSocketStatusPayload { connected: boolean; } +export interface DNSStatusPayload { + hostname: string; + dnsServer: string; + recordType: string; + resolved: boolean; + results: unknown; +} + export interface MonitorPayloadMap { ping: PingStatusPayload; http: HttpStatusPayload; From c6d86d78b852bc20f01d9577031e74dfcceae1d1 Mon Sep 17 00:00:00 2001 From: Alex Holliday Date: Fri, 8 May 2026 08:59:25 -0700 Subject: [PATCH 078/245] Add DNS provider --- .../infrastructure/network/DNSProvider.ts | 96 +++++++++++++++++++ 1 file changed, 96 insertions(+) create mode 100644 server/src/service/infrastructure/network/DNSProvider.ts diff --git a/server/src/service/infrastructure/network/DNSProvider.ts b/server/src/service/infrastructure/network/DNSProvider.ts new file mode 100644 index 000000000..c2cf21705 --- /dev/null +++ b/server/src/service/infrastructure/network/DNSProvider.ts @@ -0,0 +1,96 @@ +import { DNSStatusPayload, MonitorStatusResponse } from "@/types/network.js"; +import { IStatusProvider } from "@/service/infrastructure/network/IStatusProvider.js"; +import { Resolver } from "dns/promises"; +import { Monitor, MonitorType } from "@/types/monitor.js"; +import { AppError } from "@/utils/AppError.js"; +import { NETWORK_ERROR, timeRequest } from "@/service/infrastructure/network/utils.js"; + +const SERVICE_NAME = "DNSProvider"; + +export class DNSProvider implements IStatusProvider { + readonly type = "dns"; + constructor(private resolver: Resolver) {} + supports(type: MonitorType) { + return type === "dns"; + } + + async handle(monitor: Monitor): Promise> { + try { + const { url: hostname, dnsServer, dnsRecordType = "A" } = monitor; + if (!dnsServer) { + throw new Error("DNS server is required for DNS monitoring"); + } + + const resolver = new Resolver(); + resolver.setServers([dnsServer]); + + const { + response: results, + responseTime, + error, + } = await timeRequest(() => { + switch (dnsRecordType.toUpperCase()) { + case "A": + return resolver.resolve4(hostname); + case "AAAA": + return resolver.resolve6(hostname); + case "CNAME": + return resolver.resolveCname(hostname); + case "MX": + return resolver.resolveMx(hostname); + case "TXT": + return resolver.resolveTxt(hostname); + case "NS": + return resolver.resolveNs(hostname); + default: + return resolver.resolve(hostname, dnsRecordType); + } + }); + + if (error) { + return { + monitorId: monitor.id, + teamId: monitor.teamId, + type: monitor.type, + status: false, + code: NETWORK_ERROR, + message: error instanceof Error ? error.message : String(error), + responseTime, + payload: { + hostname, + dnsServer, + recordType: dnsRecordType, + resolved: false, + results, + }, + }; + } + + return { + monitorId: monitor.id, + teamId: monitor.teamId, + type: monitor.type, + status: true, + code: 200, + message: "Success", + responseTime, + payload: { + hostname, + dnsServer, + recordType: dnsRecordType, + resolved: true, + results, + }, + }; + } catch (error: unknown) { + const originalMessage = error instanceof Error ? error.message : String(error); + throw new AppError({ + message: originalMessage || "Error performing DNS check", + status: 500, + service: SERVICE_NAME, + method: "handle", + details: { url: monitor.url }, + }); + } + } +} From fda9d9edfe2f4a6c58305beb80d0af7b6fb3d533 Mon Sep 17 00:00:00 2001 From: Alex Holliday Date: Fri, 8 May 2026 08:59:31 -0700 Subject: [PATCH 079/245] add tests --- .../providers/network/dnsProvider.test.ts | 204 ++++++++++++++++++ 1 file changed, 204 insertions(+) create mode 100644 server/test/unit/providers/network/dnsProvider.test.ts diff --git a/server/test/unit/providers/network/dnsProvider.test.ts b/server/test/unit/providers/network/dnsProvider.test.ts new file mode 100644 index 000000000..92c874836 --- /dev/null +++ b/server/test/unit/providers/network/dnsProvider.test.ts @@ -0,0 +1,204 @@ +import { beforeEach, describe, expect, it, jest } from "@jest/globals"; +import { testStatusProviderContract } from "../../../helpers/statusProviderContract.ts"; +import { NETWORK_ERROR } from "../../../../src/service/infrastructure/network/utils.ts"; +import type { Monitor } from "../../../../src/types/index.ts"; + +// ── Mocks ──────────────────────────────────────────────────────────────────── + +const mockResolverInstance = { + setServers: jest.fn(), + resolve4: jest.fn<() => Promise>(), + resolve6: jest.fn<() => Promise>(), + resolveCname: jest.fn<() => Promise>(), + resolveMx: jest.fn<() => Promise>(), + resolveTxt: jest.fn<() => Promise>(), + resolveNs: jest.fn<() => Promise>(), + resolve: jest.fn<() => Promise>(), +}; + +const ResolverMock = jest.fn().mockImplementation(() => mockResolverInstance); + +jest.unstable_mockModule("dns/promises", () => ({ + Resolver: ResolverMock, +})); + +const { DNSProvider } = await import("../../../../src/service/infrastructure/network/DNSProvider.ts"); + +// ── Helpers ────────────────────────────────────────────────────────────────── + +const makeMonitor = (overrides?: Partial): Monitor => + ({ + id: "mon-1", + teamId: "team-1", + type: "dns", + url: "example.com", + dnsServer: "8.8.8.8", + dnsRecordType: "A", + ...overrides, + }) as Monitor; + +beforeEach(() => { + ResolverMock.mockClear(); + mockResolverInstance.setServers.mockClear(); + mockResolverInstance.resolve4.mockReset().mockResolvedValue(["1.2.3.4"]); + mockResolverInstance.resolve6.mockReset().mockResolvedValue(["::1"]); + mockResolverInstance.resolveCname.mockReset().mockResolvedValue(["alias.example.com"]); + mockResolverInstance.resolveMx.mockReset().mockResolvedValue([{ exchange: "mx.example.com", priority: 10 }]); + mockResolverInstance.resolveTxt.mockReset().mockResolvedValue([["v=spf1"]]); + mockResolverInstance.resolveNs.mockReset().mockResolvedValue(["ns.example.com"]); + mockResolverInstance.resolve.mockReset().mockResolvedValue(["something"]); +}); + +// ── Contract ───────────────────────────────────────────────────────────────── + +testStatusProviderContract("DNSProvider", { + create: () => new DNSProvider({} as any), + supportedType: "dns", + unsupportedType: "ping", + makeMonitor: () => makeMonitor(), +}); + +// ── Tests ──────────────────────────────────────────────────────────────────── + +describe("DNSProvider", () => { + it("returns success with results and payload on resolve", async () => { + mockResolverInstance.resolve4.mockResolvedValue(["1.2.3.4"]); + const provider = new DNSProvider({} as any); + + const result = await provider.handle(makeMonitor()); + + expect(result).toEqual( + expect.objectContaining({ + monitorId: "mon-1", + teamId: "team-1", + type: "dns", + status: true, + code: 200, + message: "Success", + payload: { + hostname: "example.com", + dnsServer: "8.8.8.8", + recordType: "A", + resolved: true, + results: ["1.2.3.4"], + }, + }) + ); + expect(typeof result.responseTime).toBe("number"); + }); + + it("calls setServers with the configured dnsServer", async () => { + const provider = new DNSProvider({} as any); + await provider.handle(makeMonitor({ dnsServer: "1.1.1.1" })); + expect(mockResolverInstance.setServers).toHaveBeenCalledWith(["1.1.1.1"]); + }); + + it("defaults to A record when dnsRecordType is not provided", async () => { + const provider = new DNSProvider({} as any); + const result = await provider.handle(makeMonitor({ dnsRecordType: undefined })); + expect(mockResolverInstance.resolve4).toHaveBeenCalledWith("example.com"); + expect(result.payload?.recordType).toBe("A"); + }); + + it("dispatches AAAA records to resolve6", async () => { + const provider = new DNSProvider({} as any); + await provider.handle(makeMonitor({ dnsRecordType: "AAAA" })); + expect(mockResolverInstance.resolve6).toHaveBeenCalledWith("example.com"); + }); + + it("dispatches CNAME records to resolveCname", async () => { + const provider = new DNSProvider({} as any); + await provider.handle(makeMonitor({ dnsRecordType: "CNAME" })); + expect(mockResolverInstance.resolveCname).toHaveBeenCalledWith("example.com"); + }); + + it("dispatches MX records to resolveMx", async () => { + const provider = new DNSProvider({} as any); + await provider.handle(makeMonitor({ dnsRecordType: "MX" })); + expect(mockResolverInstance.resolveMx).toHaveBeenCalledWith("example.com"); + }); + + it("dispatches TXT records to resolveTxt", async () => { + const provider = new DNSProvider({} as any); + await provider.handle(makeMonitor({ dnsRecordType: "TXT" })); + expect(mockResolverInstance.resolveTxt).toHaveBeenCalledWith("example.com"); + }); + + it("dispatches NS records to resolveNs", async () => { + const provider = new DNSProvider({} as any); + await provider.handle(makeMonitor({ dnsRecordType: "NS" })); + expect(mockResolverInstance.resolveNs).toHaveBeenCalledWith("example.com"); + }); + + it("falls back to generic resolve for unrecognized record types", async () => { + const provider = new DNSProvider({} as any); + await provider.handle(makeMonitor({ dnsRecordType: "SOA" })); + expect(mockResolverInstance.resolve).toHaveBeenCalledWith("example.com", "SOA"); + }); + + it("treats record type case-insensitively", async () => { + const provider = new DNSProvider({} as any); + await provider.handle(makeMonitor({ dnsRecordType: "mx" })); + expect(mockResolverInstance.resolveMx).toHaveBeenCalledWith("example.com"); + }); + + it("returns failure response with NETWORK_ERROR when resolver rejects", async () => { + mockResolverInstance.resolve4.mockRejectedValue(new Error("ENOTFOUND")); + const provider = new DNSProvider({} as any); + + const result = await provider.handle(makeMonitor()); + + expect(result).toEqual( + expect.objectContaining({ + monitorId: "mon-1", + teamId: "team-1", + type: "dns", + status: false, + code: NETWORK_ERROR, + message: "ENOTFOUND", + payload: expect.objectContaining({ + hostname: "example.com", + dnsServer: "8.8.8.8", + recordType: "A", + resolved: false, + results: null, + }), + }) + ); + expect(typeof result.responseTime).toBe("number"); + }); + + it("stringifies non-Error rejections in failure response", async () => { + mockResolverInstance.resolve4.mockRejectedValue("DNS bombed"); + const provider = new DNSProvider({} as any); + + const result = await provider.handle(makeMonitor()); + + expect(result.status).toBe(false); + expect(result.message).toBe("DNS bombed"); + }); + + it("throws AppError when dnsServer is missing", async () => { + const provider = new DNSProvider({} as any); + + await expect(provider.handle(makeMonitor({ dnsServer: undefined }))).rejects.toThrow("DNS server is required for DNS monitoring"); + }); + + it("falls back to default message when underlying error has empty message", async () => { + mockResolverInstance.setServers.mockImplementationOnce(() => { + throw new Error(""); + }); + const provider = new DNSProvider({} as any); + + await expect(provider.handle(makeMonitor())).rejects.toThrow("Error performing DNS check"); + }); + + it("stringifies non-Error throws from outside timeRequest", async () => { + mockResolverInstance.setServers.mockImplementationOnce(() => { + throw 42; + }); + const provider = new DNSProvider({} as any); + + await expect(provider.handle(makeMonitor())).rejects.toThrow("42"); + }); +}); From 0797008ac9d9dc12f73f5a85aa1c268ca4ab8d42 Mon Sep 17 00:00:00 2001 From: Alex Date: Fri, 8 May 2026 12:43:02 -0700 Subject: [PATCH 080/245] add dns record type --- server/src/types/monitor.ts | 6 +++++- server/src/types/network.ts | 1 + 2 files changed, 6 insertions(+), 1 deletion(-) diff --git a/server/src/types/monitor.ts b/server/src/types/monitor.ts index 6e2747e1a..09797f2ca 100644 --- a/server/src/types/monitor.ts +++ b/server/src/types/monitor.ts @@ -14,6 +14,10 @@ export type MonitorStatus = (typeof MonitorStatuses)[number]; export const MonitorMatchMethods = ["equal", "include", "regex"] as const; export type MonitorMatchMethod = (typeof MonitorMatchMethods)[number] | ""; + +export const DnsRecordTypes = ["A", "AAAA", "CNAME", "MX", "TXT", "NS"] as const; +export type DnsRecordType = (typeof DnsRecordTypes)[number]; + export const MAX_RECENT_CHECKS = 50; export interface Monitor { @@ -55,7 +59,7 @@ export interface Monitor { geoCheckLocations?: GeoContinent[]; geoCheckInterval?: number; dnsServer?: string; - dnsRecordType?: string; + dnsRecordType?: DnsRecordType; recentChecks: CheckSnapshot[]; createdAt: string; updatedAt: string; diff --git a/server/src/types/network.ts b/server/src/types/network.ts index d87b62395..3269806d8 100644 --- a/server/src/types/network.ts +++ b/server/src/types/network.ts @@ -134,6 +134,7 @@ export interface MonitorPayloadMap { game: GameStatusPayload; grpc: GrpcStatusPayload; websocket: WebSocketStatusPayload; + dns: DNSStatusPayload; unknown: unknown; } From 06ef850577779cb2bd5f917fc445b92624bab647 Mon Sep 17 00:00:00 2001 From: Alex Date: Fri, 8 May 2026 12:43:33 -0700 Subject: [PATCH 081/245] update validation to allow for dnsServer and dnsRecord --- server/src/validation/monitorValidation.ts | 10 +++++++++- 1 file changed, 9 insertions(+), 1 deletion(-) diff --git a/server/src/validation/monitorValidation.ts b/server/src/validation/monitorValidation.ts index 57099e7ff..0960e8099 100644 --- a/server/src/validation/monitorValidation.ts +++ b/server/src/validation/monitorValidation.ts @@ -1,7 +1,7 @@ import { z } from "zod"; import { booleanCoercion } from "./shared.js"; import { GeoContinents } from "@/types/geoCheck.js"; -import { MonitorMatchMethods, MonitorStatuses, MonitorTypes } from "@/types/monitor.js"; +import { DnsRecordTypes, MonitorMatchMethods, MonitorStatuses, MonitorTypes } from "@/types/monitor.js"; export const getMonitorByIdParamValidation = z.object({ monitorId: z.string().min(1, "Monitor ID is required"), @@ -78,6 +78,8 @@ export const createMonitorBodyValidation = z.object({ geoCheckEnabled: z.boolean().optional(), geoCheckLocations: z.array(z.enum(GeoContinents)).optional(), geoCheckInterval: z.number().min(300000).optional(), + dnsServer: z.string().optional(), + dnsRecordType: z.enum(DnsRecordTypes).optional(), }); export const editMonitorBodyValidation = z.object({ @@ -107,6 +109,8 @@ export const editMonitorBodyValidation = z.object({ geoCheckEnabled: z.boolean().optional(), geoCheckLocations: z.array(z.enum(GeoContinents)).optional(), geoCheckInterval: z.number().min(300000).optional(), + dnsServer: z.string().optional(), + dnsRecordType: z.enum(DnsRecordTypes).optional(), }); export const pauseMonitorParamValidation = z.object({ @@ -160,6 +164,8 @@ const importedMonitorSchema = z.object({ geoCheckEnabled: z.boolean().default(false), geoCheckLocations: z.array(z.enum(GeoContinents)).default([]), geoCheckInterval: z.number().min(300000).default(300000), + dnsServer: z.string().optional(), + dnsRecordType: z.enum(DnsRecordTypes).optional(), createdAt: z.string().optional(), updatedAt: z.string().optional(), }); @@ -211,6 +217,8 @@ export const monitorResponseSchema = z geoCheckEnabled: z.boolean(), geoCheckLocations: z.array(z.enum(GeoContinents)), geoCheckInterval: z.number(), + dnsServer: z.string().optional(), + dnsRecordType: z.enum(DnsRecordTypes).optional(), teamId: z.string(), userId: z.string(), createdAt: z.string(), From cb9cc101c09a96a9ba9e95994c393f4bdf01b7ce Mon Sep 17 00:00:00 2001 From: Alex Date: Fri, 8 May 2026 12:44:18 -0700 Subject: [PATCH 082/245] add dnsServer and dnsRecordType to monitor model --- server/src/db/models/Monitor.ts | 9 ++++++++- 1 file changed, 8 insertions(+), 1 deletion(-) diff --git a/server/src/db/models/Monitor.ts b/server/src/db/models/Monitor.ts index 036aeadad..e1a2f4f91 100644 --- a/server/src/db/models/Monitor.ts +++ b/server/src/db/models/Monitor.ts @@ -1,6 +1,6 @@ import { Schema, model, Types } from "mongoose"; import type { Monitor, MonitorMatchMethod, CheckSnapshot } from "@/types/monitor.js"; -import { MonitorTypes, MonitorStatuses } from "@/types/monitor.js"; +import { DnsRecordTypes, MonitorTypes, MonitorStatuses } from "@/types/monitor.js"; import type { CheckAudits, CheckCaptureInfo, @@ -351,6 +351,13 @@ const MonitorSchema = new Schema( type: Number, default: 300000, }, + dnsServer: { + type: String, + }, + dnsRecordType: { + type: String, + enum: DnsRecordTypes, + }, recentChecks: { type: [checkSnapshotSchema], default: [], From 2a7bde95749b4ba841d3dec4584ecbe492d26db1 Mon Sep 17 00:00:00 2001 From: Alex Date: Fri, 8 May 2026 12:44:44 -0700 Subject: [PATCH 083/245] update toEntity methods to extract dnsServer and dnsRecord --- server/src/repositories/monitors/MongoMonitorsRepository.ts | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/server/src/repositories/monitors/MongoMonitorsRepository.ts b/server/src/repositories/monitors/MongoMonitorsRepository.ts index bd6d506d5..93846aeec 100644 --- a/server/src/repositories/monitors/MongoMonitorsRepository.ts +++ b/server/src/repositories/monitors/MongoMonitorsRepository.ts @@ -418,6 +418,8 @@ class MongoMonitorsRepository implements IMonitorsRepository { geoCheckEnabled: doc.geoCheckEnabled ?? false, geoCheckLocations: doc.geoCheckLocations ?? [], geoCheckInterval: doc.geoCheckInterval ?? 300000, + dnsServer: doc.dnsServer ?? undefined, + dnsRecordType: doc.dnsRecordType ?? undefined, createdAt: toDateString(doc.createdAt), updatedAt: toDateString(doc.updatedAt), }; @@ -477,6 +479,8 @@ class MongoMonitorsRepository implements IMonitorsRepository { geoCheckEnabled: doc.geoCheckEnabled ?? false, geoCheckLocations: doc.geoCheckLocations ?? [], geoCheckInterval: doc.geoCheckInterval ?? 300000, + dnsServer: doc.dnsServer ?? undefined, + dnsRecordType: doc.dnsRecordType ?? undefined, createdAt: toDateString(doc.createdAt), updatedAt: toDateString(doc.updatedAt), }; From 0c36c8e6251d1ea193ba407b12f6e9bfec205875 Mon Sep 17 00:00:00 2001 From: Alex Date: Fri, 8 May 2026 12:44:58 -0700 Subject: [PATCH 084/245] tests --- server/jest.config.ts | 1 + server/src/config/services.ts | 4 + .../unit/validation/monitorValidation.test.ts | 86 +++++++++++++++++++ 3 files changed, 91 insertions(+) create mode 100644 server/test/unit/validation/monitorValidation.test.ts diff --git a/server/jest.config.ts b/server/jest.config.ts index be7d359b1..1faf98181 100644 --- a/server/jest.config.ts +++ b/server/jest.config.ts @@ -13,6 +13,7 @@ const config: Config = { "^@/(.*)\\.ts$": "/src/$1.ts", "^@/(.*)\\.js$": "/src/$1.ts", "^@/(.*)$": "/src/$1", + "^(\\.{1,2}/.*)\\.js$": "$1", }, testMatch: ["/test/**/*.test.ts"], setupFilesAfterEnv: [], diff --git a/server/src/config/services.ts b/server/src/config/services.ts index b6cbb3a30..c00f91c72 100644 --- a/server/src/config/services.ts +++ b/server/src/config/services.ts @@ -64,8 +64,10 @@ import { PortProvider } from "@/service/infrastructure/network/PortProvider.js"; import { GameProvider } from "@/service/infrastructure/network/GameProvider.js"; import { GrpcProvider } from "@/service/infrastructure/network/GrpcProvider.js"; import { WebSocketProvider } from "@/service/infrastructure/network/WebSocketProvider.js"; +import { DNSProvider } from "@/service/infrastructure/network/DNSProvider.js"; // Third-party +import { Resolver } from "dns/promises"; import axios from "axios"; import got from "got"; import ping from "ping"; @@ -226,6 +228,7 @@ export const initializeServices = async ({ const gameProvider = new GameProvider(logger, GameDig); const grpcProvider = new GrpcProvider(grpc, protoLoader); const webSocketProvider = new WebSocketProvider(WebSocket); + const dnsProvider = new DNSProvider(new Resolver()); const networkService = new NetworkService(axios, logger, [ pingProvider, @@ -237,6 +240,7 @@ export const initializeServices = async ({ gameProvider, grpcProvider, webSocketProvider, + dnsProvider, ]); const emailService = new EmailService(settingsService, fs, path, compile, mjml2html, nodemailer, logger); diff --git a/server/test/unit/validation/monitorValidation.test.ts b/server/test/unit/validation/monitorValidation.test.ts new file mode 100644 index 000000000..5bd178f51 --- /dev/null +++ b/server/test/unit/validation/monitorValidation.test.ts @@ -0,0 +1,86 @@ +import { describe, expect, it } from "@jest/globals"; +import { createMonitorBodyValidation, editMonitorBodyValidation, importMonitorsBodyValidation } from "../../../src/validation/monitorValidation.ts"; + +const baseDnsBody = { + name: "DNS check", + type: "dns" as const, + url: "example.com", +}; + +describe("monitorValidation — DNS fields", () => { + describe("createMonitorBodyValidation", () => { + it("retains dnsServer and dnsRecordType on a DNS monitor", () => { + const parsed = createMonitorBodyValidation.parse({ + ...baseDnsBody, + dnsServer: "8.8.8.8", + dnsRecordType: "A", + }); + + expect(parsed.dnsServer).toBe("8.8.8.8"); + expect(parsed.dnsRecordType).toBe("A"); + }); + + it("accepts every supported DNS record type", () => { + for (const recordType of ["A", "AAAA", "CNAME", "MX", "TXT", "NS"] as const) { + const parsed = createMonitorBodyValidation.parse({ + ...baseDnsBody, + dnsServer: "1.1.1.1", + dnsRecordType: recordType, + }); + expect(parsed.dnsRecordType).toBe(recordType); + } + }); + + it("rejects unknown DNS record types", () => { + expect(() => + createMonitorBodyValidation.parse({ + ...baseDnsBody, + dnsServer: "8.8.8.8", + dnsRecordType: "BOGUS", + }) + ).toThrow(); + }); + + it("treats DNS fields as optional (other monitor types still validate)", () => { + const parsed = createMonitorBodyValidation.parse({ + name: "HTTP check", + type: "http", + url: "https://example.com", + }); + + expect(parsed.dnsServer).toBeUndefined(); + expect(parsed.dnsRecordType).toBeUndefined(); + }); + }); + + describe("editMonitorBodyValidation", () => { + it("retains dnsServer and dnsRecordType on edits", () => { + const parsed = editMonitorBodyValidation.parse({ + dnsServer: "1.1.1.1", + dnsRecordType: "MX", + }); + + expect(parsed.dnsServer).toBe("1.1.1.1"); + expect(parsed.dnsRecordType).toBe("MX"); + }); + }); + + describe("importMonitorsBodyValidation", () => { + it("retains dnsServer and dnsRecordType on imported DNS monitors", () => { + const parsed = importMonitorsBodyValidation.parse({ + monitors: [ + { + name: "Imported DNS", + type: "dns", + url: "example.com", + dnsServer: "8.8.4.4", + dnsRecordType: "TXT", + }, + ], + }); + + expect(parsed.monitors[0].dnsServer).toBe("8.8.4.4"); + expect(parsed.monitors[0].dnsRecordType).toBe("TXT"); + }); + }); +}); From 172d36ba1d9706849383788a9c2f4875088d8614 Mon Sep 17 00:00:00 2001 From: Alex Date: Fri, 8 May 2026 12:56:47 -0700 Subject: [PATCH 085/245] use injected Resolver --- .../infrastructure/network/DNSProvider.ts | 17 ++++++++--------- 1 file changed, 8 insertions(+), 9 deletions(-) diff --git a/server/src/service/infrastructure/network/DNSProvider.ts b/server/src/service/infrastructure/network/DNSProvider.ts index c2cf21705..4ad212e62 100644 --- a/server/src/service/infrastructure/network/DNSProvider.ts +++ b/server/src/service/infrastructure/network/DNSProvider.ts @@ -21,8 +21,7 @@ export class DNSProvider implements IStatusProvider { throw new Error("DNS server is required for DNS monitoring"); } - const resolver = new Resolver(); - resolver.setServers([dnsServer]); + this.resolver.setServers([dnsServer]); const { response: results, @@ -31,19 +30,19 @@ export class DNSProvider implements IStatusProvider { } = await timeRequest(() => { switch (dnsRecordType.toUpperCase()) { case "A": - return resolver.resolve4(hostname); + return this.resolver.resolve4(hostname); case "AAAA": - return resolver.resolve6(hostname); + return this.resolver.resolve6(hostname); case "CNAME": - return resolver.resolveCname(hostname); + return this.resolver.resolveCname(hostname); case "MX": - return resolver.resolveMx(hostname); + return this.resolver.resolveMx(hostname); case "TXT": - return resolver.resolveTxt(hostname); + return this.resolver.resolveTxt(hostname); case "NS": - return resolver.resolveNs(hostname); + return this.resolver.resolveNs(hostname); default: - return resolver.resolve(hostname, dnsRecordType); + return this.resolver.resolve(hostname, dnsRecordType); } }); From 4b4fae91c287efdab5d5054bd5919cf90a77c3b5 Mon Sep 17 00:00:00 2001 From: Alex Date: Fri, 8 May 2026 12:56:56 -0700 Subject: [PATCH 086/245] use enums for validation --- server/src/types/network.ts | 3 ++- server/src/validation/monitorValidation.ts | 14 ++------------ 2 files changed, 4 insertions(+), 13 deletions(-) diff --git a/server/src/types/network.ts b/server/src/types/network.ts index 3269806d8..19f063cc6 100644 --- a/server/src/types/network.ts +++ b/server/src/types/network.ts @@ -8,6 +8,7 @@ import type { CheckNetworkInterfaceInfo, GotTimings, ILighthouseAudit, + DnsRecordType, Monitor, MonitorMatchMethod, MonitorType, @@ -119,7 +120,7 @@ export interface WebSocketStatusPayload { export interface DNSStatusPayload { hostname: string; dnsServer: string; - recordType: string; + recordType: DnsRecordType; resolved: boolean; results: unknown; } diff --git a/server/src/validation/monitorValidation.ts b/server/src/validation/monitorValidation.ts index 0960e8099..882c7353a 100644 --- a/server/src/validation/monitorValidation.ts +++ b/server/src/validation/monitorValidation.ts @@ -20,12 +20,7 @@ export const getMonitorByIdQueryValidation = z.object({ export const getMonitorsByTeamIdParamValidation = z.object({}); export const getMonitorsByTeamIdQueryValidation = z.object({ - type: z - .union([ - z.enum(["http", "ping", "pagespeed", "docker", "hardware", "port", "game", "grpc", "websocket"]), - z.array(z.enum(["http", "ping", "pagespeed", "docker", "hardware", "port", "game", "grpc", "websocket"])), - ]) - .optional(), + type: z.union([z.enum(MonitorTypes), z.array(z.enum(MonitorTypes))]).optional(), filter: z.union([z.string(), z.literal("")]).optional(), }); @@ -36,12 +31,7 @@ export const getMonitorsWithChecksQueryValidation = z.object({ filter: z.union([z.string(), z.literal("")]).optional(), field: z.string().optional(), order: z.enum(["asc", "desc"]).optional(), - type: z - .union([ - z.enum(["http", "ping", "pagespeed", "docker", "hardware", "port", "game", "grpc", "websocket"]), - z.array(z.enum(["http", "ping", "pagespeed", "docker", "hardware", "port", "game", "grpc", "websocket"])), - ]) - .optional(), + type: z.union([z.enum(MonitorTypes), z.array(z.enum(MonitorTypes))]).optional(), explain: booleanCoercion.optional(), }); From 0f662e1a65a213f5b1a82e94ba29cf4752bfdda3 Mon Sep 17 00:00:00 2001 From: Alex Date: Fri, 8 May 2026 13:04:18 -0700 Subject: [PATCH 087/245] update tests --- .../providers/network/dnsProvider.test.ts | 32 +++++++++---------- 1 file changed, 16 insertions(+), 16 deletions(-) diff --git a/server/test/unit/providers/network/dnsProvider.test.ts b/server/test/unit/providers/network/dnsProvider.test.ts index 92c874836..ba38fa5d2 100644 --- a/server/test/unit/providers/network/dnsProvider.test.ts +++ b/server/test/unit/providers/network/dnsProvider.test.ts @@ -52,7 +52,7 @@ beforeEach(() => { // ── Contract ───────────────────────────────────────────────────────────────── testStatusProviderContract("DNSProvider", { - create: () => new DNSProvider({} as any), + create: () => new DNSProvider(mockResolverInstance as any), supportedType: "dns", unsupportedType: "ping", makeMonitor: () => makeMonitor(), @@ -63,7 +63,7 @@ testStatusProviderContract("DNSProvider", { describe("DNSProvider", () => { it("returns success with results and payload on resolve", async () => { mockResolverInstance.resolve4.mockResolvedValue(["1.2.3.4"]); - const provider = new DNSProvider({} as any); + const provider = new DNSProvider(mockResolverInstance as any); const result = await provider.handle(makeMonitor()); @@ -88,63 +88,63 @@ describe("DNSProvider", () => { }); it("calls setServers with the configured dnsServer", async () => { - const provider = new DNSProvider({} as any); + const provider = new DNSProvider(mockResolverInstance as any); await provider.handle(makeMonitor({ dnsServer: "1.1.1.1" })); expect(mockResolverInstance.setServers).toHaveBeenCalledWith(["1.1.1.1"]); }); it("defaults to A record when dnsRecordType is not provided", async () => { - const provider = new DNSProvider({} as any); + const provider = new DNSProvider(mockResolverInstance as any); const result = await provider.handle(makeMonitor({ dnsRecordType: undefined })); expect(mockResolverInstance.resolve4).toHaveBeenCalledWith("example.com"); expect(result.payload?.recordType).toBe("A"); }); it("dispatches AAAA records to resolve6", async () => { - const provider = new DNSProvider({} as any); + const provider = new DNSProvider(mockResolverInstance as any); await provider.handle(makeMonitor({ dnsRecordType: "AAAA" })); expect(mockResolverInstance.resolve6).toHaveBeenCalledWith("example.com"); }); it("dispatches CNAME records to resolveCname", async () => { - const provider = new DNSProvider({} as any); + const provider = new DNSProvider(mockResolverInstance as any); await provider.handle(makeMonitor({ dnsRecordType: "CNAME" })); expect(mockResolverInstance.resolveCname).toHaveBeenCalledWith("example.com"); }); it("dispatches MX records to resolveMx", async () => { - const provider = new DNSProvider({} as any); + const provider = new DNSProvider(mockResolverInstance as any); await provider.handle(makeMonitor({ dnsRecordType: "MX" })); expect(mockResolverInstance.resolveMx).toHaveBeenCalledWith("example.com"); }); it("dispatches TXT records to resolveTxt", async () => { - const provider = new DNSProvider({} as any); + const provider = new DNSProvider(mockResolverInstance as any); await provider.handle(makeMonitor({ dnsRecordType: "TXT" })); expect(mockResolverInstance.resolveTxt).toHaveBeenCalledWith("example.com"); }); it("dispatches NS records to resolveNs", async () => { - const provider = new DNSProvider({} as any); + const provider = new DNSProvider(mockResolverInstance as any); await provider.handle(makeMonitor({ dnsRecordType: "NS" })); expect(mockResolverInstance.resolveNs).toHaveBeenCalledWith("example.com"); }); it("falls back to generic resolve for unrecognized record types", async () => { - const provider = new DNSProvider({} as any); + const provider = new DNSProvider(mockResolverInstance as any); await provider.handle(makeMonitor({ dnsRecordType: "SOA" })); expect(mockResolverInstance.resolve).toHaveBeenCalledWith("example.com", "SOA"); }); it("treats record type case-insensitively", async () => { - const provider = new DNSProvider({} as any); + const provider = new DNSProvider(mockResolverInstance as any); await provider.handle(makeMonitor({ dnsRecordType: "mx" })); expect(mockResolverInstance.resolveMx).toHaveBeenCalledWith("example.com"); }); it("returns failure response with NETWORK_ERROR when resolver rejects", async () => { mockResolverInstance.resolve4.mockRejectedValue(new Error("ENOTFOUND")); - const provider = new DNSProvider({} as any); + const provider = new DNSProvider(mockResolverInstance as any); const result = await provider.handle(makeMonitor()); @@ -170,7 +170,7 @@ describe("DNSProvider", () => { it("stringifies non-Error rejections in failure response", async () => { mockResolverInstance.resolve4.mockRejectedValue("DNS bombed"); - const provider = new DNSProvider({} as any); + const provider = new DNSProvider(mockResolverInstance as any); const result = await provider.handle(makeMonitor()); @@ -179,7 +179,7 @@ describe("DNSProvider", () => { }); it("throws AppError when dnsServer is missing", async () => { - const provider = new DNSProvider({} as any); + const provider = new DNSProvider(mockResolverInstance as any); await expect(provider.handle(makeMonitor({ dnsServer: undefined }))).rejects.toThrow("DNS server is required for DNS monitoring"); }); @@ -188,7 +188,7 @@ describe("DNSProvider", () => { mockResolverInstance.setServers.mockImplementationOnce(() => { throw new Error(""); }); - const provider = new DNSProvider({} as any); + const provider = new DNSProvider(mockResolverInstance as any); await expect(provider.handle(makeMonitor())).rejects.toThrow("Error performing DNS check"); }); @@ -197,7 +197,7 @@ describe("DNSProvider", () => { mockResolverInstance.setServers.mockImplementationOnce(() => { throw 42; }); - const provider = new DNSProvider({} as any); + const provider = new DNSProvider(mockResolverInstance as any); await expect(provider.handle(makeMonitor())).rejects.toThrow("42"); }); From fc711e8e24333dc272e0ceba11d50803f455c5ee Mon Sep 17 00:00:00 2001 From: Alex Date: Fri, 8 May 2026 13:15:45 -0700 Subject: [PATCH 088/245] per call resolver --- server/src/config/services.ts | 2 +- .../infrastructure/network/DNSProvider.ts | 21 +++++----- .../providers/network/dnsProvider.test.ts | 42 ++++++++----------- 3 files changed, 30 insertions(+), 35 deletions(-) diff --git a/server/src/config/services.ts b/server/src/config/services.ts index c00f91c72..309f55242 100644 --- a/server/src/config/services.ts +++ b/server/src/config/services.ts @@ -228,7 +228,7 @@ export const initializeServices = async ({ const gameProvider = new GameProvider(logger, GameDig); const grpcProvider = new GrpcProvider(grpc, protoLoader); const webSocketProvider = new WebSocketProvider(WebSocket); - const dnsProvider = new DNSProvider(new Resolver()); + const dnsProvider = new DNSProvider(() => new Resolver()); const networkService = new NetworkService(axios, logger, [ pingProvider, diff --git a/server/src/service/infrastructure/network/DNSProvider.ts b/server/src/service/infrastructure/network/DNSProvider.ts index 4ad212e62..64e473e88 100644 --- a/server/src/service/infrastructure/network/DNSProvider.ts +++ b/server/src/service/infrastructure/network/DNSProvider.ts @@ -1,6 +1,6 @@ import { DNSStatusPayload, MonitorStatusResponse } from "@/types/network.js"; import { IStatusProvider } from "@/service/infrastructure/network/IStatusProvider.js"; -import { Resolver } from "dns/promises"; +import type { Resolver } from "dns/promises"; import { Monitor, MonitorType } from "@/types/monitor.js"; import { AppError } from "@/utils/AppError.js"; import { NETWORK_ERROR, timeRequest } from "@/service/infrastructure/network/utils.js"; @@ -9,7 +9,7 @@ const SERVICE_NAME = "DNSProvider"; export class DNSProvider implements IStatusProvider { readonly type = "dns"; - constructor(private resolver: Resolver) {} + constructor(private createResolver: () => Resolver) {} supports(type: MonitorType) { return type === "dns"; } @@ -21,7 +21,8 @@ export class DNSProvider implements IStatusProvider { throw new Error("DNS server is required for DNS monitoring"); } - this.resolver.setServers([dnsServer]); + const resolver = this.createResolver(); + resolver.setServers([dnsServer]); const { response: results, @@ -30,19 +31,19 @@ export class DNSProvider implements IStatusProvider { } = await timeRequest(() => { switch (dnsRecordType.toUpperCase()) { case "A": - return this.resolver.resolve4(hostname); + return resolver.resolve4(hostname); case "AAAA": - return this.resolver.resolve6(hostname); + return resolver.resolve6(hostname); case "CNAME": - return this.resolver.resolveCname(hostname); + return resolver.resolveCname(hostname); case "MX": - return this.resolver.resolveMx(hostname); + return resolver.resolveMx(hostname); case "TXT": - return this.resolver.resolveTxt(hostname); + return resolver.resolveTxt(hostname); case "NS": - return this.resolver.resolveNs(hostname); + return resolver.resolveNs(hostname); default: - return this.resolver.resolve(hostname, dnsRecordType); + return resolver.resolve(hostname, dnsRecordType); } }); diff --git a/server/test/unit/providers/network/dnsProvider.test.ts b/server/test/unit/providers/network/dnsProvider.test.ts index ba38fa5d2..3fcea5113 100644 --- a/server/test/unit/providers/network/dnsProvider.test.ts +++ b/server/test/unit/providers/network/dnsProvider.test.ts @@ -2,6 +2,7 @@ import { beforeEach, describe, expect, it, jest } from "@jest/globals"; import { testStatusProviderContract } from "../../../helpers/statusProviderContract.ts"; import { NETWORK_ERROR } from "../../../../src/service/infrastructure/network/utils.ts"; import type { Monitor } from "../../../../src/types/index.ts"; +import { DNSProvider } from "../../../../src/service/infrastructure/network/DNSProvider.ts"; // ── Mocks ──────────────────────────────────────────────────────────────────── @@ -16,13 +17,7 @@ const mockResolverInstance = { resolve: jest.fn<() => Promise>(), }; -const ResolverMock = jest.fn().mockImplementation(() => mockResolverInstance); - -jest.unstable_mockModule("dns/promises", () => ({ - Resolver: ResolverMock, -})); - -const { DNSProvider } = await import("../../../../src/service/infrastructure/network/DNSProvider.ts"); +const createResolver = () => mockResolverInstance as any; // ── Helpers ────────────────────────────────────────────────────────────────── @@ -38,7 +33,6 @@ const makeMonitor = (overrides?: Partial): Monitor => }) as Monitor; beforeEach(() => { - ResolverMock.mockClear(); mockResolverInstance.setServers.mockClear(); mockResolverInstance.resolve4.mockReset().mockResolvedValue(["1.2.3.4"]); mockResolverInstance.resolve6.mockReset().mockResolvedValue(["::1"]); @@ -52,7 +46,7 @@ beforeEach(() => { // ── Contract ───────────────────────────────────────────────────────────────── testStatusProviderContract("DNSProvider", { - create: () => new DNSProvider(mockResolverInstance as any), + create: () => new DNSProvider(createResolver), supportedType: "dns", unsupportedType: "ping", makeMonitor: () => makeMonitor(), @@ -63,7 +57,7 @@ testStatusProviderContract("DNSProvider", { describe("DNSProvider", () => { it("returns success with results and payload on resolve", async () => { mockResolverInstance.resolve4.mockResolvedValue(["1.2.3.4"]); - const provider = new DNSProvider(mockResolverInstance as any); + const provider = new DNSProvider(createResolver); const result = await provider.handle(makeMonitor()); @@ -88,63 +82,63 @@ describe("DNSProvider", () => { }); it("calls setServers with the configured dnsServer", async () => { - const provider = new DNSProvider(mockResolverInstance as any); + const provider = new DNSProvider(createResolver); await provider.handle(makeMonitor({ dnsServer: "1.1.1.1" })); expect(mockResolverInstance.setServers).toHaveBeenCalledWith(["1.1.1.1"]); }); it("defaults to A record when dnsRecordType is not provided", async () => { - const provider = new DNSProvider(mockResolverInstance as any); + const provider = new DNSProvider(createResolver); const result = await provider.handle(makeMonitor({ dnsRecordType: undefined })); expect(mockResolverInstance.resolve4).toHaveBeenCalledWith("example.com"); expect(result.payload?.recordType).toBe("A"); }); it("dispatches AAAA records to resolve6", async () => { - const provider = new DNSProvider(mockResolverInstance as any); + const provider = new DNSProvider(createResolver); await provider.handle(makeMonitor({ dnsRecordType: "AAAA" })); expect(mockResolverInstance.resolve6).toHaveBeenCalledWith("example.com"); }); it("dispatches CNAME records to resolveCname", async () => { - const provider = new DNSProvider(mockResolverInstance as any); + const provider = new DNSProvider(createResolver); await provider.handle(makeMonitor({ dnsRecordType: "CNAME" })); expect(mockResolverInstance.resolveCname).toHaveBeenCalledWith("example.com"); }); it("dispatches MX records to resolveMx", async () => { - const provider = new DNSProvider(mockResolverInstance as any); + const provider = new DNSProvider(createResolver); await provider.handle(makeMonitor({ dnsRecordType: "MX" })); expect(mockResolverInstance.resolveMx).toHaveBeenCalledWith("example.com"); }); it("dispatches TXT records to resolveTxt", async () => { - const provider = new DNSProvider(mockResolverInstance as any); + const provider = new DNSProvider(createResolver); await provider.handle(makeMonitor({ dnsRecordType: "TXT" })); expect(mockResolverInstance.resolveTxt).toHaveBeenCalledWith("example.com"); }); it("dispatches NS records to resolveNs", async () => { - const provider = new DNSProvider(mockResolverInstance as any); + const provider = new DNSProvider(createResolver); await provider.handle(makeMonitor({ dnsRecordType: "NS" })); expect(mockResolverInstance.resolveNs).toHaveBeenCalledWith("example.com"); }); it("falls back to generic resolve for unrecognized record types", async () => { - const provider = new DNSProvider(mockResolverInstance as any); + const provider = new DNSProvider(createResolver); await provider.handle(makeMonitor({ dnsRecordType: "SOA" })); expect(mockResolverInstance.resolve).toHaveBeenCalledWith("example.com", "SOA"); }); it("treats record type case-insensitively", async () => { - const provider = new DNSProvider(mockResolverInstance as any); + const provider = new DNSProvider(createResolver); await provider.handle(makeMonitor({ dnsRecordType: "mx" })); expect(mockResolverInstance.resolveMx).toHaveBeenCalledWith("example.com"); }); it("returns failure response with NETWORK_ERROR when resolver rejects", async () => { mockResolverInstance.resolve4.mockRejectedValue(new Error("ENOTFOUND")); - const provider = new DNSProvider(mockResolverInstance as any); + const provider = new DNSProvider(createResolver); const result = await provider.handle(makeMonitor()); @@ -170,7 +164,7 @@ describe("DNSProvider", () => { it("stringifies non-Error rejections in failure response", async () => { mockResolverInstance.resolve4.mockRejectedValue("DNS bombed"); - const provider = new DNSProvider(mockResolverInstance as any); + const provider = new DNSProvider(createResolver); const result = await provider.handle(makeMonitor()); @@ -179,7 +173,7 @@ describe("DNSProvider", () => { }); it("throws AppError when dnsServer is missing", async () => { - const provider = new DNSProvider(mockResolverInstance as any); + const provider = new DNSProvider(createResolver); await expect(provider.handle(makeMonitor({ dnsServer: undefined }))).rejects.toThrow("DNS server is required for DNS monitoring"); }); @@ -188,7 +182,7 @@ describe("DNSProvider", () => { mockResolverInstance.setServers.mockImplementationOnce(() => { throw new Error(""); }); - const provider = new DNSProvider(mockResolverInstance as any); + const provider = new DNSProvider(createResolver); await expect(provider.handle(makeMonitor())).rejects.toThrow("Error performing DNS check"); }); @@ -197,7 +191,7 @@ describe("DNSProvider", () => { mockResolverInstance.setServers.mockImplementationOnce(() => { throw 42; }); - const provider = new DNSProvider(mockResolverInstance as any); + const provider = new DNSProvider(createResolver); await expect(provider.handle(makeMonitor())).rejects.toThrow("42"); }); From 80fca45ce8f488763a7b59c28192c6d13e661f03 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Agust=C3=AD=20Dosaiguas?= Date: Sat, 9 May 2026 18:13:45 +0200 Subject: [PATCH 089/245] i18n: add Catalan language --- .../Components/inputs/LanguageSelector.tsx | 1 + client/src/locales/ca.json | 1489 +++++++++++++++++ 2 files changed, 1490 insertions(+) create mode 100644 client/src/locales/ca.json diff --git a/client/src/Components/inputs/LanguageSelector.tsx b/client/src/Components/inputs/LanguageSelector.tsx index 430cd28b5..a83f3e0fa 100644 --- a/client/src/Components/inputs/LanguageSelector.tsx +++ b/client/src/Components/inputs/LanguageSelector.tsx @@ -10,6 +10,7 @@ import type { RootState } from "@/Types/state"; export const languageNames: Record = { en: "English", de: "Deutsch", + ca: "Català", es: "Español", fr: "Français", "pt-BR": "Português (BR)", diff --git a/client/src/locales/ca.json b/client/src/locales/ca.json new file mode 100644 index 000000000..fe80cf9c4 --- /dev/null +++ b/client/src/locales/ca.json @@ -0,0 +1,1489 @@ +{ + "common": { + "auth": { + "roles": { + "admin": "Administrador", + "demo": "Demo", + "superadmin": "Superadministrador", + "user": "Usuari" + } + }, + "alerts": { + "pageSpeedApiKey": { + "content": "Avís: Encara no heu afegit una clau API de Google PageSpeed. Aneu a Configuració per afegir-ne una. Sense ella, el monitor PageSpeed no funcionarà." + } + }, + "appName": "Checkmate", + "breadcrumbs": { + "details": "Detalls", + "home": "Inici" + }, + "buttons": { + "addMember": "Afegir membre", + "cancel": "Cancel·lar", + "close": "Tancar", + "configure": "Configurar", + "confirm": "Confirmar", + "create": "Crear", + "delete": "Eliminar", + "generateToken": "Generar token", + "incidents": "Incidents", + "inviteMember": "Convidar membre", + "pause": "Pausar", + "resume": "Reprendre", + "save": "Desar", + "sendInvite": "Enviar convit", + "test": "Provar", + "testNotifications": "Provar notificacions", + "toggleTheme": "Canviar tema clar/fosc", + "flushQueue": "Buidar cua", + "notFound": "Anar al tauler principal", + "resetPassword": "Restablir contrasenya", + "reset": "Restablir", + "clear": "Netejar", + "addDemo": "Afegir monitors de demostració", + "removeMonitors": "Eliminar monitors", + "sendTestEmail": "Enviar correu de prova", + "exportToJSON": "Exportar a JSON", + "importFromJSON": "Importar des de JSON", + "clearFilters": "Netejar filtres", + "removeUser": "Eliminar usuari" + }, + "charts": { + "labels": { + "averageResponseTime": "Temps de resposta mitjà", + "downtime": "Temps d'inactivitat", + "high": "Alt", + "low": "Baix", + "uptime": "Temps en línia" + }, + "histogram": { + "avg": "Mitjana: {{value}} ms", + "max": "Màx: {{value}} ms" + } + }, + "dialogs": { + "delete": { + "description": "Aquesta acció no es pot desfer.", + "title": "Esteu segur que voleu eliminar això?" + } + }, + "labels": { + "active": "Actiu", + "paused": "pausat", + "na": "N/D", + "resolved": "Resolt", + "responseTime": "Temps de resposta" + }, + "form": { + "role": { + "option": { + "role": { + "label": "Rols" + } + } + }, + "name": { + "option": { + "firstName": { + "label": "Nom", + "placeholder": "Introduïu el vostre nom" + }, + "lastName": { + "label": "Cognoms", + "placeholder": "Introduïu els vostres cognoms" + } + } + }, + "email": { + "option": { + "email": { + "label": "Correu electrònic", + "placeholder": "vosaltres@exemple.com" + } + } + } + }, + "table": { + "empty": "Encara no hi ha res per mostrar", + "headers": { + "actions": "Accions", + "dateTime": "Data i hora", + "message": "Missatge", + "monitor": "Monitor", + "monitorId": "ID del monitor", + "name": "Nom", + "status": "Estat", + "type": "Tipus", + "url": "URL", + "interval": "Interval", + "active": "Actiu", + "responseTime": "Temps de resposta" + } + } + }, + "components": { + "pageHeader": { + "links": { + "support": "Suport", + "discussions": "Discussions", + "docs": "Documentació", + "changelog": "Registre de canvis" + } + }, + "imageUpload": { + "clickToUpload": "Feu clic per pujar", + "dragAndDrop": "o arrossegueu i deixeu anar", + "supportedFormats": "Formats admesos", + "maxSize": "Mida màxima", + "orDragAndDrop": "o arrossegueu i deixeu anar", + "errors": { + "invalidFileSize": "La mida del fitxer és massa gran!", + "invalidFileFormat": "Format de fitxer no admès!" + } + }, + "headerStatusPageControls": { + "publicLink": "Enllaç públic" + }, + "headerTimeRange": { + "labels": { + "day": "Dia", + "month": "Mes", + "recent": "Recent", + "week": "Setmana" + }, + "description": { + "recent": "Mostrant estadístiques de les darreres 2 hores.", + "day": "Mostrant estadístiques de les darreres 24 hores.", + "week": "Mostrant estadístiques dels darrers 7 dies.", + "month": "Mostrant estadístiques dels darrers 30 dies." + } + }, + "offlineBanner": { + "serverUnreachable": "No es pot arribar al servidor", + "retry": "Tornar a intentar", + "retrying": "Reintentant...", + "reconnected": "Connexió restablerta" + }, + "sidebar": { + "menu": { + "uptime": "Temps en línia", + "pagespeed": "Velocitat de pàgina", + "infrastructure": "Infraestructura", + "notifications": "Notificacions", + "checks": "Comprovacions", + "incidents": "Incidents", + "statusPages": "Pàgines d'estat", + "maintenance": "Manteniment", + "logs": "Registres", + "settings": "Configuració" + }, + "bottomMenu": { + "support": "Suport", + "discussions": "Discussions", + "docs": "Docs", + "changelog": "Novetats" + }, + "accountMenu": { + "profile": "Perfil", + "password": "Contrasenya", + "team": "Equip" + }, + "starPrompt": { + "title": "Dona una estrella a Checkmate", + "description": "Veu les darreres versions i ajuda a fer créixer la comunitat a GitHub" + }, + "authFooter": { + "navControls": "Controls", + "logOut": "Tancar sessió", + "roles": { + "superAdmin": "Superadministrador", + "admin": "Administrador", + "user": "Usuari", + "demoUser": "Usuari de demostració" + } + } + }, + "starPrompt": { + "title": "Dona una estrella a Checkmate", + "description": "Veu les darreres versions i ajuda a fer créixer la comunitat a GitHub" + } + }, + "pages": { + "notFound": { + "title": "Oh no! Has deixat caure el teu sushi!", + "subtitle": "O bé la URL no existeix, o bé no tens accés." + }, + "account": { + "tabs": { + "profile": "Perfil", + "password": "Contrasenya", + "team": "Equip" + }, + "form": { + "name": { + "title": "Nom", + "description": "Actualitza la teva informació personal" + }, + "photo": { + "title": "Foto de perfil", + "description": "Puja una foto de perfil" + }, + "currentPassword": { + "title": "Contrasenya actual", + "description": "Introdueix la teva contrasenya actual per verificar la teva identitat", + "option": { + "label": "Contrasenya actual", + "placeholder": "Introdueix la contrasenya actual" + } + }, + "newPassword": { + "title": "Nova contrasenya", + "description": "Tria una contrasenya forta amb almenys 8 caràcters", + "option": { + "newPassword": { + "label": "Nova contrasenya", + "placeholder": "Introdueix la nova contrasenya" + }, + "confirm": { + "label": "Confirmar contrasenya", + "placeholder": "Confirma la nova contrasenya" + } + } + }, + "deleteAccount": { + "title": "Eliminar compte", + "description": "Aquesta acció és permanent i no es pot desfer" + } + }, + "team": { + "fallback": { + "title": "Encara no hi ha membres a l'equip", + "description": "Convida companys per correu electrònic o afegeix-los directament. Un cop s'uneixin, apareixeran aquí amb el seu rol." + }, + "filter": { + "placeholder": "Filtrar per rol", + "all": "Tots els rols", + "admin": "Administrador", + "member": "Membre" + }, + "table": { + "headers": { + "email": "Correu electrònic", + "role": "Rol", + "created": "Creat el" + } + }, + "addMember": { + "title": "Registrar nou membre de l'equip", + "description": "Crea un nou compte d'usuari. Comparteix les credencials de forma segura després de la creació." + }, + "invite": { + "title": "Convidar membre a l'equip", + "description": "Envia una invitació per unir-se al teu equip", + "email": { + "label": "Adreça de correu electrònic", + "placeholder": "Introdueix l'adreça de correu" + }, + "role": { + "label": "Rol", + "placeholder": "Selecciona un rol" + }, + "linkLabel": "Enllaç d'invitació" + } + }, + "header": { + "title": "Compte", + "description": "Gestiona el teu perfil, contrasenya, equip i preferències de l'espai de treball." + } + }, + "auth": { + "brandPanel": { + "eyebrow": "Monitoratge d'infraestructura de codi obert", + "tagline": "Vigila cada moviment dels teus servidors.", + "description": "Seguiment en temps real del temps en línia, temps de resposta i incidents — allotjat per tu i sempre teu." + }, + "common": { + "passwordRules": { + "length": { + "beginning": "Ha de tenir almenys", + "highlighted": "8 caràcters" + }, + "lowercase": { + "beginning": "Ha de contenir almenys", + "highlighted": "una lletra minúscula" + }, + "match": { + "beginning": "Les contrasenyes", + "highlighted": "han de coincidir" + }, + "number": { + "beginning": "Ha de contenir almenys", + "highlighted": "un número" + }, + "special": { + "beginning": "Ha de contenir almenys", + "highlighted": "un caràcter especial (!?@#$%^&*()-_=+[]{}|;:'\",./\\~`)" + }, + "uppercase": { + "beginning": "Ha de contenir almenys", + "highlighted": "una lletra majúscula" + } + }, + "form": { + "option": { + "email": { + "label": "Correu electrònic", + "placeholder": "jo@exemple.com" + }, + "password": { + "label": "Contrasenya", + "placeholder": "********" + }, + "confirmPassword": { + "label": "Confirmar contrasenya" + } + } + } + }, + "login": { + "title": "Benvingut de nou a Checkmate!", + "subtitle": "Inicia sessió per continuar", + "submit": "Iniciar sessió", + "links": { + "forgotPassword": { + "text": "Has oblidat la contrasenya?", + "linkText": "Restablir contrasenya" + }, + "register": { + "text": "No tens un compte?", + "linkText": "Registra't aquí" + } + } + }, + "register": { + "title": "Benvingut a Checkmate!", + "subtitle": "Registra't per començar", + "setupNotice": "Aquesta pàgina només és accessible durant la configuració inicial. Un cop registrat el superadministrador, els usuaris addicionals es poden convidar des de la configuració de l'equip.", + "submit": "Registrar" + }, + "forgotPassword": { + "title": "Has oblidat la teva contrasenya?", + "subtitle": "No et preocupis, t'enviarem instruccions per restablir-la.", + "submit": "Sol·licitar recuperació", + "links": { + "login": { + "text": "Torna a", + "linkText": "iniciar sessió" + } + } + }, + "setNewPassword": { + "title": "Restableix la teva contrasenya", + "subtitle": "La teva nova contrasenya ha de ser diferent de les contrasenyes usades anteriorment." + } + }, + "checks": { + "fallback": { + "title": "Encara no hi ha comprovacions", + "description": "Un cop creïs un monitor, cada sonda d'estat i temps de resposta apareixerà aquí com a comprovació." + }, + "selects": { + "monitor": { + "all": "Tots els monitors" + }, + "status": { + "all": "Tots", + "down": "Caigut", + "up": "En línia" + } + }, + "table": { + "empty": "Cap comprovació caiguda en aquest interval de temps", + "headers": { + "statusCode": "Codi d'estat", + "location": "Ubicació" + } + }, + "header": { + "title": "Comprovacions", + "description": "Cada sonda que Checkmate executa contra els teus monitors apareix aquí, amb codi d'estat i temps de resposta." + } + }, + "common": { + "monitors": { + "actions": { + "configure": "Configurar", + "delete": "Eliminar", + "generateToken": "Generar token", + "details": "Detalls", + "incidents": "Incidents", + "inviteMember": "Convidar membre", + "openSite": "Obrir lloc", + "pause": "Pausar", + "resume": "Reprendre" + }, + "statBoxes": { + "activeFor": "Actiu des de", + "certificateExpiry": "Caducitat del certificat", + "lastCheck": "Última comprovació", + "lastResponseTime": "Últim temps de resposta", + "serviceIsDown": "El servei és caigut" + }, + "status": { + "down": "caigut", + "breached": "llindar superat", + "initializing": "inicialitzant", + "maintenance": "manteniment", + "paused": "pausat", + "total": "total", + "up": "en línia" + }, + "monitorTypes": { + "optionDocker": "Docker", + "optionGame": "Joc", + "optionHttp": "HTTP(S)", + "optionPing": "Ping", + "optionPort": "Port", + "optionPagespeed": "PageSpeed", + "optionHardware": "Infraestructura", + "optionGrpc": "gRPC", + "optionWebSocket": "WebSocket" + } + } + }, + "createMonitor": { + "form": { + "advanced": { + "description": "Configuració opcional per a casos d'ús avançats", + "option": { + "advancedMatching": { + "label": "Usar coincidència avançada" + }, + "expectedValue": { + "label": "Valor esperat" + }, + "jsonPath": { + "description": "Aquesta expressió s'avaluarà contra les dades JSON de resposta i el resultat s'usarà per coincidir amb el valor esperat. Vegeu jmespath.org per a la documentació del llenguatge de consulta.", + "label": "Expressió JSONPath" + }, + "matchMethod": { + "label": "Mètode de coincidència", + "equal": "Igual", + "include": "Inclou", + "regex": "Regex" + } + }, + "title": "Configuració avançada" + }, + "frequency": { + "description": "Amb quina freqüència voleu comprovar l'estat d'aquest monitor?", + "option": { + "frequency": { + "label": "Freqüència de comprovació", + "value": { + "fifteenMinutes": "15 minuts", + "fifteenSeconds": "15 segons", + "fiveMinutes": "5 minuts", + "fourMinutes": "4 minuts", + "oneMinute": "1 minut", + "tenMinutes": "10 minuts", + "thirtyMinutes": "30 minuts", + "thirtySeconds": "30 segons", + "threeMinutes": "3 minuts", + "twoMinutes": "2 minuts" + } + } + }, + "title": "Freqüència de comprovació" + }, + "general": { + "option": { + "container": { + "label": "Nom/ID del contenidor", + "placeholder": "my-app o abcd1234" + }, + "host": { + "label": "Host", + "placeholder": "192.168.1.100 o example.com" + }, + "name": { + "label": "Nom de visualització", + "placeholder": "p. ex. El meu lloc web" + }, + "secret": { + "label": "Secret d'autorització", + "placeholder": "Introdueix la teva clau secreta" + }, + "url": { + "label": "URL", + "placeholder": "https://www.google.com" + }, + "game": { + "label": "Escull el joc", + "placeholder": "Selecciona un joc" + }, + "port": { + "label": "Port a monitorar", + "placeholder": 80 + }, + "grpcServiceName": { + "label": "Nom del servei", + "placeholder": "p. ex. my.service.v1 (deixeu buit per a la salut general)" + }, + "wsUrl": { + "label": "URL de WebSocket", + "placeholder": "wss://example.com/socket" + }, + "captureUrl": { + "label": "Endpoint de captura", + "placeholder": "http://192.168.1.10:5001/api/v1/metrics" + } + }, + "title": "Configuració general", + "description": { + "http": "Introdueix la URL o IP a monitorar (p. ex., https://example.com/ o 192.168.1.100) i afegeix un nom de visualització clar que aparegui al tauler.", + "ping": "Introdueix l'adreça IP o el nom d'host per fer ping (p. ex., 192.168.1.100 o example.com) i afegeix un nom de visualització clar que aparegui al tauler.", + "port": "Introdueix la URL o IP del servidor, el número de port i un nom de visualització clar que aparegui al tauler.", + "docker": "Introdueix el nom o ID del contenidor Docker. Pots usar el nom del contenidor (p. ex., my-app) o l'ID del contenidor (ID complet de 64 caràcters o ID curt).", + "game": "Introdueix l'adreça IP o el nom d'host i el número de port per fer ping (p. ex., 192.168.1.100 o example.com) i escull el tipus de joc. Alguns jocs requereixen activar les consultes del servidor al seu fitxer de configuració — consulta la documentació del teu servidor de joc. Consulta la llista de jocs admesos per obtenir detalls del protocol.", + "pagespeed": "Fes un seguiment del rendiment de càrrega de pàgina, Core Web Vitals i puntuacions d'optimització del teu lloc web.", + "grpc": "Introdueix el nom d'host i el port del servidor gRPC, opcionalment especifica un nom de servei per a la comprovació de salut, i afegeix un nom de visualització.", + "websocket": "Introdueix la URL de WebSocket a monitorar (p. ex., wss://example.com/socket) i afegeix un nom de visualització.", + "hardware": "Monitoritza l'ús de CPU, memòria, disc i temperatura per a la teva infraestructura. La URL de l'endpoint de captura ha d'incloure la ruta completa /api/v1/metrics (p. ex., http://192.168.1.10:5001/api/v1/metrics). Per al secret d'autorització, enganxa només la cadena del token — no incloguis 'Bearer'." + } + }, + "ignoreTls": { + "description": "Configura la validació del certificat TLS/SSL per a connexions HTTPS.", + "option": { + "tls": { + "label": "Ignorar errors TLS/SSL" + } + }, + "title": "Configuració TLS/SSL" + }, + "incidents": { + "description": "S'usa una finestra lliscant per determinar quan un monitor cau. L'estat d'un monitor només canviarà quan el percentatge de comprovacions a la finestra lliscant arribi al valor especificat.", + "option": { + "checks": { + "label": "Nombre de comprovacions a la finestra lliscant" + }, + "percentage": { + "label": "Quin percentatge de comprovacions a la finestra lliscant fallen/réïxen abans que canviï l'estat del monitor?" + } + }, + "title": "Incidents" + }, + "notifications": { + "description": "Selecciona els canals de notificació que vols usar", + "title": "Notificacions" + }, + "type": { + "description": "Selecciona el tipus de comprovació a realitzar", + "optionDockerDescription": "Usa Docker per monitorar si un contenidor s'executa.", + "optionGameDescription": "Monitoritza si un servidor de joc específic està en línia.", + "optionGrpcDescription": "Monitoritza serveis gRPC usant el protocol estàndard de comprovació de salut.", + "optionWebSocketDescription": "Monitoritza endpoints de WebSocket per a la salut de la connexió i el temps de resposta.", + "optionHttpDescription": "Usa HTTP(S) per monitorar el teu lloc web o endpoint d'API.", + "optionPingDescription": "Usa ICMP Ping per monitorar si un servidor és en línia.", + "optionPortDescription": "Monitoritza si un port específic d'un servidor és obert.", + "title": "Tipus" + }, + "thresholds": { + "title": "Llindars d'alerta", + "description": "Defineix els llindars als quals s'han de desencadenar alertes per a aquest monitor de maquinari.", + "option": { + "cpuThreshold": { + "label": "Llindar d'alerta de CPU (%)" + }, + "memoryThreshold": { + "label": "Llindar d'alerta de memòria (%)" + }, + "diskThreshold": { + "label": "Llindar d'alerta de disc (%)" + }, + "tempThreshold": { + "label": "Llindar d'alerta de temperatura (°C)" + } + } + }, + "geoChecks": { + "title": "Comprovacions geodistribuïdes", + "description": "Executa comprovacions des de múltiples ubicacions geogràfiques per monitorar la disponibilitat i el rendiment globals.", + "option": { + "enabled": { + "label": "Activar comprovacions geodistribuïdes" + }, + "locations": { + "label": "Ubicacions", + "placeholder": "Selecciona ubicacions", + "options": { + "EU": "Europa", + "NA": "Amèrica del Nord", + "AS": "Àsia", + "SA": "Amèrica del Sud", + "AF": "Àfrica", + "OC": "Oceania" + } + }, + "interval": { + "label": "Interval de comprovació", + "value": { + "fiveMinutes": "5 minuts", + "tenMinutes": "10 minuts", + "fifteenMinutes": "15 minuts", + "thirtyMinutes": "30 minuts" + } + } + } + }, + "url": { + "title": "IP/URL del monitor a la pàgina d'estat", + "description": "Mostra l'adreça IP o URL del monitor a la pàgina d'estat pública. Si està desactivat, només es mostrarà el nom del monitor per protegir informació sensible.", + "option": { + "showURL": { + "label": "Mostrar IP/URL a la pàgina d'estat", + "enabled": "Activat", + "disabled": "Desactivat" + } + } + }, + "stats": { + "title": "Historial del monitor", + "description": "Neteja tot l'historial de monitoratge i les estadístiques del teu equip. Aquesta acció és irreversible.", + "buttonText": "Netejar totes les estadístiques", + "dialog": { + "title": "Netejar tot l'historial de monitoratge?", + "description": "Això eliminarà permanentment tot l'historial de monitoratge, les estadístiques i les dades de comprovació del teu equip. Aquesta acció no es pot desfer.", + "confirm": "Sí, netejar totes les estadístiques" + } + } + } + }, + "editUser": { + "form": { + "roles": { + "title": "Rols", + "description": "Assigna rols a l'usuari. Es poden seleccionar múltiples rols." + } + }, + "dialog": { + "removeUser": { + "title": "Eliminar usuari", + "content": "Esteu segur que voleu eliminar {{name}} del vostre equip? Aquesta acció no es pot desfer." + } + } + }, + "incidents": { + "fallback": { + "title": "Cap incident — tots els sistemes sans", + "description": "Quan un monitor cau o es recupera, l'incident apareixerà aquí amb el moment, els detalls del monitor i l'historial de resolució." + }, + "dialog": { + "resolveIncident": { + "title": "Resoldre incident", + "option": { + "comment": { + "label": "Comentari (opcional)", + "placeholder": "Afegeix un comentari sobre la resolució..." + } + } + }, + "details": { + "analysis": "Anàlisi de l'incident", + "comment": "Comentari:", + "detailsLabel": "Detalls", + "downtime": "Temps d'inactivitat:", + "message": "Missatge:", + "monitor": "Monitor:", + "overview": "Visió general", + "resolutionDetails": "Detalls de la resolució", + "resolutionType": "Tipus:", + "resolutionTypes": { + "automatic": "Automàtica", + "manual": "Manual" + }, + "resolve": "Resoldre incident", + "resolvedAt": "Resolt el:", + "resolvedBy": "Resolt per:", + "startedAt": "Iniciat el:", + "status": "Estat:", + "statusCode": "Codi d'estat:", + "timeline": "Línia de temps", + "title": "Detalls de l'incident", + "url": "URL:" + } + }, + "filters": { + "allMonitors": "Tots els monitors", + "monitor": "Monitor", + "resolutionType": "Tipus de resolució", + "resolutionTypes": { + "manual": "Manual", + "automatic": "Automàtica", + "all": "Tots" + } + }, + "summaryCard": { + "activeIncidents": { + "title": "Incidents actius", + "active_zero": "Cap incident actiu", + "active_one": "{{count}} incident actiu", + "active_other": "{{count}} incidents actius" + }, + "incidentStats": { + "avgResolutionTime": "Temps de resolució mitjà", + "mostAffectedMonitor": "Monitor més afectat", + "title": "Estadístiques d'incidents", + "totalIncidents": "Total d'incidents" + }, + "latestIncidents": { + "title": "Darrers incidents" + } + }, + "table": { + "actions": { + "details": "Detalls", + "goToMonitor": "Anar al monitor", + "resolveManually": "Resoldre manualment" + }, + "activeIncidents": "Incidents actius", + "headers": { + "endTime": "Hora de fi", + "resolutionType": "Tipus de resolució", + "startTime": "Hora d'inici", + "statusCode": "Codi d'estat" + }, + "resolvedIncidents": "Incidents resolts", + "status": { + "active": "Actiu", + "resolved": "Resolt" + } + }, + "header": { + "title": "Incidents", + "description": "Quan els monitors cauen o es recuperen, els incidents apareixen aquí amb el moment, els detalls del monitor i l'historial de resolució." + } + }, + "infrastructure": { + "charts": { + "labels": { + "cpu": "Ús de CPU", + "disk": "Ús de disc", + "memory": "Ús de memòria", + "netBytesRecv": "{{name}} - Bytes rebuts", + "netBytesSent": "{{name}} - Bytes enviats", + "netNoTraffic": "La captura informa de zero tràfic en totes les interfícies. Això és normal quan la captura s'executa dins d'un contenidor que només pot veure interfícies de loopback — executa la captura directament a l'amfitrió per veure l'activitat de xarxa real.", + "temp": "Temperatura" + } + }, + "gauges": { + "cpu": { + "lowerLabel": "Freqüència màxima", + "title": "Ús de CPU", + "upperLabel": "Freqüència actual" + }, + "disk": { + "lowerLabel": "Lliure", + "title": "Ús del disc {{idx}}", + "upperLabel": "Usat" + }, + "memory": { + "lowerLabel": "Lliure", + "title": "Ús de memòria", + "upperLabel": "Usada" + } + }, + "statBoxes": { + "avgCpuTemperature": "Temperatura mitjana de CPU", + "cpuFrequency": "Freqüència de CPU", + "cpuLogical": "CPU (lògica)", + "cpuPhysical": "CPU (física)", + "disk": "Disc", + "memory": "Memòria", + "os": "SO" + }, + "table": { + "headers": { + "cpu": "CPU", + "disk": "Disc", + "memory": "Memòria" + } + }, + "tabs": { + "labels": { + "network": "Xarxa", + "overview": "Visió general" + } + }, + "fallback": { + "actionButton": "Crear un monitor", + "description": "Fes un seguiment del rendiment del servidor, identifica colls d'ampolla i garanteix la fiabilitat amb monitoratge d'infraestructura en temps real.", + "title": "Encara no hi ha monitors d'infraestructura" + }, + "header": { + "title": "Monitors d'infraestructura", + "description": "Fes un seguiment del rendiment del servidor, identifica colls d'ampolla i garanteix la fiabilitat amb monitoratge d'infraestructura en temps real." + } + }, + "logs": { + "fallback": { + "title": "Encara no hi ha registres", + "description": "L'activitat del servidor, els avisos i els errors apareixeran aquí mentre s'executa Checkmate." + }, + "queueFallback": { + "title": "Cap feina a la cua", + "description": "Les comprovacions de monitor que estan programades o en execució apareixeran aquí. Una cua buida significa que tot és al dia." + }, + "tabs": { + "diagnostics": "Diagnòstics", + "logs": "Registres", + "queue": "Cua de feines" + }, + "logLevelSelect": { + "label": "Nivell de registre" + }, + "jobQueue": "Cua de feines", + "jobQueueExplainer": "Les files ressaltades són feines que s'estan processant per un treballador.", + "failedJobs": "Feines fallides", + "failedJobsExplainer": "Fallades recents per monitor — darrera vegada de fallada, recompte de fallades i el motiu.", + "noLogs": "No s'han trobat registres", + "metrics": { + "jobs": "Feines", + "activeJobs": "Feines actives", + "failingJobs": "Feines fallides", + "totalRuns": "Total d'execucions", + "totalFailures": "Total de fallades" + }, + "diagnostics": { + "sections": { + "runtimeStats": { + "title": "Estadístiques d'execució", + "description": "Instantània del procés Node.js i de la màquina amfitriona. S'actualitza cada 5 segons." + }, + "memoryCpu": { + "title": "Memòria i CPU", + "description": "Com el procés usa la seva memòria assignada i la CPU de l'amfitrió." + } + }, + "stats": { + "eventLoopDelay": "Retard del bucle d'esdeveniments", + "uptime": "Temps en funcionament", + "usedHeapSize": "Mida del heap usat", + "totalHeapSize": "Mida total del heap", + "osMemoryLimit": "Límit de memòria del SO" + }, + "gauges": { + "heapAllocation": "Assignació de heap", + "heapUsage": "Ús del heap", + "heapUtilization": "Utilització del heap", + "instantCpuUsage": "Ús instantani de CPU", + "availableMemoryPercentage": "% de memòria disponible", + "allocatedPercentage": "% assignat", + "usedSPercentage": "% d'1s usat per la CPU", + "total": "Total", + "used": "Usat" + } + }, + "table": { + "headers": { + "timestamp": "Marca de temps", + "level": "Nivell", + "service": "Servei", + "method": "Mètode", + "monitorId": "ID del monitor", + "runCount": "Recompte d'execucions", + "failCount": "Recompte", + "lastRunAt": "Darrera execució", + "lockedAt": "Bloquejat el", + "lastFinishedAt": "Darrera finalització", + "lastRunTook": "Durada de la darrera execució", + "lastFailedAt": "Darrera fallada", + "failReason": "Motiu de la fallada" + } + }, + "header": { + "title": "Registres i cua", + "description": "Inspecciona els registres del servidor, la cua de feines del monitor i els diagnòstics d'execució en un sol lloc." + } + }, + "maintenanceWindow": { + "fallback": { + "actionButton": "Crear una finestra de manteniment", + "description": "Programa temps d'inactivitat planificat perquè les alertes s'evitin i el teu equip i usuaris sàpiguen què esperar.", + "title": "Cap finestra de manteniment programada" + }, + "table": { + "headers": { + "nextWindow": "Propera finestra", + "repeat": "Repetició" + } + }, + "form": { + "general": { + "title": "Configuració general", + "description": "Estableix un nom i opció de repetició per a la teva finestra de manteniment.", + "option": { + "name": { + "label": "Nom", + "placeholder": "p. ex. Manteniment setmanal" + }, + "repeat": { + "label": "Repetició" + } + } + }, + "startDate": { + "title": "Data d'inici", + "description": "Selecciona la data d'inici per a la teva finestra de manteniment.", + "option": { + "startDate": { + "label": "Data d'inici" + } + } + }, + "startTime": { + "title": "Hora d'inici", + "description": "Estableix l'hora d'inici i la durada per a la teva finestra de manteniment. Tots els valors són en UTC.", + "option": { + "duration": { + "label": "Durada" + }, + "startTime": { + "label": "Hora d'inici" + } + }, + "monitors": { + "title": "Monitors", + "description": "Monitors als quals s'ha d'aplicar la finestra de manteniment", + "option": { + "addMonitors": { + "label": "Afegir monitors" + } + } + } + } + }, + "header": { + "title": "Finestres de manteniment", + "description": "Programa temps d'inactivitat planificat perquè les alertes s'evitin i el teu equip i usuaris sàpiguen què esperar." + } + }, + "notifications": { + "fallback": { + "actionButton": "Crear un canal", + "description": "Connecta Slack, Discord, correu electrònic, webhooks i més perquè les persones adequades s'assabentin dels incidents en el moment en què passen.", + "title": "Encara no hi ha canals de notificació" + }, + "form": { + "accessToken": { + "optionAccessToken": "Token d'accés", + "placeholder": "syt_YWxleF9ob2xsaWRheQ_VmtScmV0U2VjcmV0S2V5_abc123" + }, + "address": { + "description": "L'adreça on s'enviaran les notificacions.", + "optionAddress": "Adreça", + "placeholderEmail": "exemple@exemple.com", + "placeholderWebhook": "https://el-teu-servidor.com/webhook", + "title": "Adreça" + }, + "homeServer": { + "optionHomeServer": "Servidor principal", + "placeholder": "example.com" + }, + "matrix": { + "description": "Configura la connexió del teu servidor Matrix per a notificacions.", + "title": "Configuració de Matrix" + }, + "notificationName": { + "description": "Un nom descriptiu per al canal de notificació", + "optionName": "Nom del canal", + "placeholder": "p. ex. Alertes de producció", + "title": "Nom del canal" + }, + "pagerDuty": { + "description": "La teva clau d'integració PagerDuty per rebre alertes.", + "optionIntegrationKey": "Clau d'integració", + "placeholder": "a1b2c3d4e5f6g7h8i9j0k1l2m3n4o5p6", + "title": "Clau d'integració" + }, + "roomId": { + "optionRoomId": "ID de sala", + "placeholder": "!abcdefg:matrix.org" + }, + "type": { + "description": "Selecciona el tipus de canal de notificació a crear.", + "optionType": "Tipus", + "title": "Tipus de canal" + }, + "telegram": { + "title": "Configuració de Telegram", + "description": "Configura el teu bot de Telegram per enviar notificacions.", + "optionBotToken": "Token del bot", + "placeholderBotToken": "123456789:ABCdefGhIJKlmNoPQRsTUVwxyZ", + "optionChatId": "ID del xat", + "placeholderChatId": "-1001234567890" + }, + "pushover": { + "title": "Configuració de Pushover", + "description": "Configura Pushover per rebre notificacions push als teus dispositius.", + "optionAppToken": "Token de l'API de l'aplicació", + "placeholderAppToken": "azGDORePK8gMaC0QOYAMyEEuzJnyUi", + "optionUserKey": "Clau d'usuari", + "placeholderUserKey": "uQiRzpo4DXghDmr9QzzfQu27cmVRsG" + }, + "twilio": { + "title": "Configuració de SMS de Twilio", + "description": "Configura Twilio per enviar notificacions SMS a un número de telèfon.", + "optionAccountSid": "SID del compte", + "placeholderAccountSid": "ACxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx", + "optionAuthToken": "Token d'autenticació", + "placeholderAuthToken": "el_teu_token_auth", + "optionFromNumber": "Número d'origen (Twilio)", + "placeholderFromNumber": "+34931234567", + "optionToNumber": "Número de destinació (receptor)", + "placeholderToNumber": "+34939876543" + } + }, + "table": { + "headers": { + "destination": "Destinació" + } + }, + "header": { + "title": "Notificacions", + "description": "Connecta Slack, Discord, correu electrònic, webhooks i més perquè les persones adequades s'assabentin dels incidents en el moment en què passen." + } + }, + "pageSpeed": { + "charts": { + "common": { + "cls": "Canvi acumulat de maquetació (CLS)", + "fcp": "Primera pintura amb contingut (FCP)", + "lcp": "Pintura amb contingut més gran (LCP)", + "si": "Índex de velocitat (SI)", + "tbt": "Temps total de bloqueig (TBT)" + }, + "legend": { + "title": "Informe de PageSpeed", + "weight": "Pes" + }, + "pie": { + "title": "Informe de rendiment" + } + }, + "table": { + "headers": { + "pageSpeedScore": "Puntuació de PageSpeed" + } + }, + "fallback": { + "actionButton": "Crear un monitor", + "description": "Fes un seguiment de les Core Web Vitals i les puntuacions de Lighthouse al llarg del temps per detectar regressions abans que els usuaris ho facin.", + "title": "Encara no hi ha monitors de PageSpeed" + }, + "header": { + "title": "Monitors de PageSpeed", + "description": "Fes un seguiment de les Core Web Vitals i les puntuacions de Lighthouse al llarg del temps per detectar regressions abans que els usuaris ho facin." + } + }, + "settings": { + "form": { + "timezone": { + "title": "Zona horària de visualització", + "description": "Selecciona la zona horària que s'usa per mostrar dates i hores a tota l'aplicació.", + "option": { + "timezone": { + "label": "Zona horària de visualització" + } + } + }, + "ui": { + "title": "Aparença", + "description": "Canvia entre el mode clar i fosc, canvia l'idioma o personalitza el tipus de gràfic.", + "option": { + "theme": { + "label": "Mode del tema", + "light": "Clar", + "dark": "Fosc" + }, + "language": { + "label": "Idioma" + }, + "chartType": { + "label": "Tipus de gràfic", + "histogram": "Histograma", + "heatmap": "Mapa de calor" + } + } + }, + "pagespeed": { + "title": "Clau API de Google PageSpeed", + "description": "Introdueix la teva clau API de Google PageSpeed per activar el monitoratge de Google PageSpeed. Fes clic a Restablir per actualitzar la clau.", + "option": { + "apiKey": { + "label": "Clau API de PageSpeed", + "labelSet": "La clau API està configurada. Fes clic a Restablir per canviar-la.", + "placeholder": "Introdueix la teva clau API de Google PageSpeed" + } + } + }, + "url": { + "title": "IP/URL del monitor a la pàgina d'estat", + "description": "Mostra l'adreça IP o URL del monitor a la pàgina d'estat pública. Si està desactivat, només es mostrarà el nom del monitor per protegir informació sensible.", + "option": { + "showURL": { + "label": "Mostrar IP/URL a la pàgina d'estat", + "enabled": "Activat", + "disabled": "Desactivat" + } + } + }, + "stats": { + "title": "Historial del monitor", + "description": "Neteja tot l'historial de monitoratge i les estadístiques del teu equip. Aquesta acció és irreversible.", + "option": { + "clear": { + "label": "Netejar totes les estadístiques. Això és irreversible." + } + }, + "dialog": { + "title": "Netejar tot l'historial de monitoratge?", + "description": "Això no es pot desfer" + } + }, + "retention": { + "title": "Retenció de comprovacions", + "description": "Estableix quant de temps es retenen les dades de comprovació abans de ser netejades automàticament.", + "option": { + "days": { + "label": "Període de retenció (dies)", + "unlimited": "Il·limitat" + } + } + }, + "thresholds": { + "title": "Llindars globals", + "description": "Estableix llindars globals de CPU, memòria, disc i temperatura per al monitoratge d'infraestructura. S'apliquen a tots els monitors de maquinari tret que se substitueixin.", + "option": { + "cpu": { + "label": "Llindar de CPU (%)", + "placeholder": "1 - 100" + }, + "memory": { + "label": "Llindar de memòria (%)", + "placeholder": "1 - 100" + }, + "disk": { + "label": "Llindar de disc (%)", + "placeholder": "1 - 100" + }, + "temperature": { + "label": "Llindar de temperatura (°C)", + "placeholder": "1 - 150" + } + } + }, + "email": { + "title": "Configuració de correu electrònic", + "description": "Configura els paràmetres de correu electrònic del teu sistema. S'usa per enviar notificacions i alertes.", + "descriptionTransport": "Això crea un transport SMTP per a NodeMailer", + "descriptionTransportLink": "Veu les especificacions aquí", + "option": { + "host": { + "label": "Servidor de correu — Nom d'host o adreça IP per connectar", + "placeholder": "smtp.gmail.com" + }, + "port": { + "label": "Port de correu — Port per connectar", + "placeholder": "587" + }, + "address": { + "label": "Adreça de correu — Usada per a l'autenticació", + "placeholder": "tu@exemple.com" + }, + "displayName": { + "label": "Nom del remitent (opcional) — Nom amigable que apareix al camp De", + "placeholder": "Notificacions de Checkmate" + }, + "user": { + "label": "Usuari de correu — Nom d'usuari per a l'autenticació, substitueix l'adreça de correu si s'especifica", + "placeholder": "Deixa buit si no és necessari" + }, + "password": { + "label": "Contrasenya de correu — Contrasenya per a l'autenticació", + "labelSet": "La contrasenya està configurada. Fes clic a Restablir per canviar-la.", + "placeholder": "Introdueix la teva contrasenya" + }, + "tlsServername": { + "label": "Nom de servidor TLS — Nom d'host opcional per a la validació TLS quan el host és una IP", + "placeholder": "example.com" + }, + "connectionHost": { + "label": "Host de connexió de correu — Nom d'host per usar a la salutació HELO/EHLO", + "placeholder": "localhost" + }, + "secure": { + "label": "Usar SSL (recomanat): Xifrar la connexió usant SSL/TLS" + }, + "pool": { + "label": "Activar agrupació de connexions: Reutilitzar connexions existents per millorar el rendiment" + }, + "ignoreTLS": { + "label": "Deshabilitar STARTTLS: No usar TLS encara que el servidor ho admeti" + }, + "requireTLS": { + "label": "Forçar STARTTLS: Requerir l'actualització TLS, fallar si no s'admet" + }, + "rejectUnauthorized": { + "label": "Rebutjar certificats no vàlids: Rebutjar connexions amb certificats autofirmats o no de confiança" + } + } + }, + "demoMonitors": { + "title": "Monitors de demostració", + "description": "Afegeix monitors de mostra amb finalitats de demostració." + }, + "removeMonitors": { + "title": "Restabliment del sistema", + "description": "Elimina tots els monitors del teu sistema.", + "dialog": { + "title": "Eliminar tots els monitors?", + "description": "Això no es pot desfer.", + "paragraph": "Tot l'historial de comprovacions, incidents, finestres de manteniment programades i qualsevol pàgina d'estat que referenciés aquests monitors també s'eliminarà. Exporta els teus monitors primer per conservar una còpia de seguretat de les seves dades." + } + }, + "exportMonitors": { + "title": "Exportar monitors" + }, + "importExportMonitors": { + "title": "Importar / Exportar monitors", + "description": "Importa o exporta les dades dels teus monitors com a fitxer JSON per a còpia de seguretat o transferència." + }, + "about": { + "title": "Quant a", + "developedBy": "Desenvolupat per Bluewave Labs" + }, + "validation": { + "errorMessage": "Si us plau, corregiu els errors de validació següents:" + } + }, + "header": { + "title": "Configuració", + "description": "Configura SMTP, integracions, retenció i el comportament general de l'aplicació." + } + }, + "statusPages": { + "deleteSuccess": "Pàgina d'estat eliminada correctament", + "fallback": { + "title": "Encara no hi ha pàgines d'estat", + "description": "Publica una pàgina pública que mostri el temps en línia en temps real i l'historial d'incidents als teus clients i parts interessades.", + "actionButton": "Crear una pàgina d'estat" + }, + "monitorsList": { + "chartTypeHeatmap": "Mapa de calor", + "chartTypeHistogram": "Histograma", + "noData": "No hi ha dades disponibles", + "status": { + "up": "Operatiu", + "down": "Caigut", + "breached": "Llindar superat", + "maintenance": "Manteniment", + "paused": "Pausat", + "initializing": "Inicialitzant" + }, + "chart": { + "avg": "mitj {{value}}ms", + "max": "màx {{value}}ms", + "downTooltip": "Caigut", + "heatmapAria": "Mapa de calor del temps de resposta" + }, + "infrastructure": { + "title": "Infraestructura", + "cpu": "CPU", + "memory": "RAM", + "memoryText": "Memòria", + "disk": "Disc", + "usage": "Ús", + "used": "Usat", + "total": "Total" + }, + "uptime": { + "title": "Temps en línia", + "responseTime": "Temps de resposta" + } + }, + "statusBar": { + "monitoringSummary": "Monitorant {{count}} servei · S'actualitza cada 10 segons", + "monitoringSummary_other": "Monitorant {{count}} serveis · S'actualitza cada 10 segons", + "allUp": "Tots els sistemes operatius", + "allBreached": "Tots els sistemes han superat els llindars", + "allMaintenance": "Tots els sistemes estan en manteniment", + "allDown": "Tots els sistemes estan caiguts", + "allPaused": "Tots els sistemes estan pausats", + "allInitializing": "Tots els sistemes s'estan inicialitzant", + "breachedAndDown": "Alguns sistemes han superat els llindars i estan caiguts", + "breached": "Alguns sistemes han superat els llindars", + "degraded": "Alguns sistemes estan experimentant problemes", + "maintenanceAndDown": "Alguns sistemes estan caiguts durant el manteniment", + "maintenance": "Alguns sistemes estan en manteniment", + "partiallyPaused": "Alguns sistemes estan pausats", + "initializing": "Alguns sistemes s'estan inicialitzant", + "noMonitors": "No s'han configurat monitors", + "unknown": "No es pot determinar l'estat del sistema" + }, + "table": { + "headers": { + "name": "Nom de la pàgina d'estat", + "url": "URL pública" + }, + "published": "Publicada", + "unpublished": "No publicada" + }, + "title": "Pàgines d'estat", + "details": { + "empty": { + "title": "Encara no hi ha res aquí", + "addMonitor": "Afegeix un monitor per començar" + } + }, + "footer": { + "poweredBy": "Powered by" + }, + "editorial": { + "reportTitle": "Informe del servei", + "allUp": "Tots els sistemes funcionen amb normalitat." + }, + "form": { + "access": { + "title": "Accés", + "description": "Si la teva pàgina d'estat és llesta, pots marcar-la com a publicada.", + "option": { + "published": { + "name": "Publicada i visible al públic" + } + } + }, + "basicInfo": { + "title": "Informació bàsica", + "description": "Defineix el nom de l'empresa i el subdomini al qual apunta la teva pàgina d'estat.", + "option": { + "name": { + "label": "Nom de l'empresa", + "placeholder": "Acme S.A." + }, + "url": { + "label": "L'adreça de la teva pàgina d'estat", + "placeholder": "la-meva-pagina-destat" + } + } + }, + "monitors": { + "title": "Monitors", + "description": "Selecciona els monitors que es mostraran a la teva pàgina d'estat.", + "noMonitors": "Cap monitor seleccionat", + "option": { + "monitors": { + "label": "Seleccionar monitors", + "placeholder": "Cerca i selecciona monitors..." + } + } + }, + "timezone": { + "title": "Zona horària", + "description": "Selecciona la zona horària en què es mostrarà la teva pàgina d'estat.", + "option": { + "timezone": { + "label": "Zona horària", + "placeholder": "Selecciona la zona horària..." + } + } + }, + "appearance": { + "title": "Aparença", + "description": "Defineix l'aspecte predeterminat de la teva pàgina d'estat pública.", + "option": { + "logo": { + "label": "Logotip" + }, + "color": { + "label": "Color de marca" + } + } + }, + "theme": { + "title": "Tema de la pàgina", + "description": "Tria l'estil visual per a la teva pàgina d'estat pública.", + "label": "Tema", + "options": { + "refined": { + "name": "Refinat", + "description": "Net, professional, proper a l'aspecte clàssic de Checkmate." + }, + "modern": { + "name": "Modern", + "description": "De nivell SaaS amb un punt d'estat pulsant i targetes de desfosa suau." + }, + "bold": { + "name": "Atrevit", + "description": "Tipografia gran i espaiat confident. Ideal per a pàgines orientades a la marca." + }, + "editorial": { + "name": "Editorial", + "description": "Capçaleres amb serifa i separadors de línia fina. Distintiu i tranquil." + } + } + }, + "themeMode": { + "label": "Mode del tema", + "auto": "Automàtic (segueix el dispositiu del visitant)", + "light": "Només clar", + "dark": "Només fosc" + }, + "features": { + "title": "Funcionalitats", + "description": "Configura quina informació es mostra a la teva pàgina d'estat.", + "option": { + "showCharts": { + "label": "Mostrar gràfics de temps de resposta" + }, + "showUptimePercentage": { + "label": "Mostrar percentatge de temps en línia" + }, + "showAdminLoginLink": { + "label": "Mostrar l'enllaç d'inici de sessió de l'administrador" + }, + "showInfrastructure": { + "label": "Mostrar mètriques d'infraestructura" + } + } + } + }, + "header": { + "title": "Pàgines d'estat", + "description": "Publica pàgines públiques que mostrin el temps en línia en temps real i l'historial d'incidents als teus clients i parts interessades." + } + }, + "uptime": { + "filters": { + "search": { + "placeholder": "Cerca monitors..." + } + }, + "table": { + "headers": { + "responseTime": "Temps de resposta" + } + }, + "fallback": { + "actionButton": "Crear un monitor", + "description": "Vigila endpoints HTTP, pings, contenidors i ports — i rep alertes en el moment en que alguna cosa cau.", + "title": "Encara no hi ha monitors de temps en línia" + }, + "header": { + "title": "Monitors de temps en línia", + "description": "Vigila endpoints HTTP, pings, contenidors i ports — i rep alertes en el moment en que alguna cosa cau." + } + } + } +} From 9a004ab1fb5faeb652b74a69f5bf7d1f1caff200 Mon Sep 17 00:00:00 2001 From: Alex Date: Mon, 11 May 2026 09:59:53 -0700 Subject: [PATCH 090/245] udpate monitor types to include DNS, add dnsRecordType --- client/src/Types/Monitor.ts | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/client/src/Types/Monitor.ts b/client/src/Types/Monitor.ts index 3dced0a6c..a4317e1ff 100644 --- a/client/src/Types/Monitor.ts +++ b/client/src/Types/Monitor.ts @@ -15,10 +15,14 @@ export const MonitorTypes = [ "game", "grpc", "websocket", + "dns", "unknown", ] as const; export type MonitorType = (typeof MonitorTypes)[number]; +export const DnsRecordTypes = ["A", "AAAA", "CNAME", "MX", "TXT", "NS"] as const; +export type DnsRecordType = (typeof DnsRecordTypes)[number]; + export const GeoCheckSupportedTypes: readonly MonitorType[] = ["http", "ping"] as const; export const supportsGeoCheck = (type: MonitorType | undefined): boolean => { @@ -78,6 +82,8 @@ export interface Monitor { geoCheckEnabled?: boolean; geoCheckLocations?: GeoContinent[]; geoCheckInterval?: number; + dnsServer?: string; + dnsRecordType?: DnsRecordType; recentChecks: CheckSnapshot[]; createdAt: string; updatedAt: string; From 427f8c632f4e43ebabe8ea88761a497624bdbab2 Mon Sep 17 00:00:00 2001 From: Alex Date: Mon, 11 May 2026 10:01:04 -0700 Subject: [PATCH 091/245] update validation to include dnsServer and dnsRecordType --- client/src/Validation/monitor.ts | 24 ++++++++++++++++++++++++ 1 file changed, 24 insertions(+) diff --git a/client/src/Validation/monitor.ts b/client/src/Validation/monitor.ts index 9acffe6fe..ede0a44b3 100644 --- a/client/src/Validation/monitor.ts +++ b/client/src/Validation/monitor.ts @@ -1,5 +1,6 @@ import { z } from "zod"; import { GeoContinents } from "@/Types/GeoCheck"; +import { DnsRecordTypes } from "@/Types/Monitor"; // URL schema with custom error message const urlSchema = z.url({ message: "Please enter a valid URL" }); @@ -122,6 +123,28 @@ const websocketSchema = baseSchema.extend({ ignoreTlsErrors: z.boolean(), }); +// Hostname (FQDN) — labels of 1-63 alphanumerics/hyphens separated by dots, +// no scheme, port, path, or whitespace. Total length ≤ 253. +const hostnameRegex = + /^(?=.{1,253}$)([a-zA-Z0-9](?:[a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?\.)+[a-zA-Z]{2,63}$/; + +// DNS monitor schema +const dnsSchema = baseSchema.extend({ + type: z.literal("dns"), + url: z + .string() + .min(1, "Domain is required") + .regex(hostnameRegex, "Enter a valid domain (e.g. www.example.com)"), + dnsServer: z + .string() + .min(1, "DNS server is required") + .refine( + (v) => z.ipv4().safeParse(v).success || z.ipv6().safeParse(v).success, + "Enter a valid IPv4 or IPv6 address (e.g. 8.8.8.8)" + ), + dnsRecordType: z.enum(DnsRecordTypes), +}); + // Discriminated union of all monitor types export const monitorSchema = z.discriminatedUnion("type", [ httpSchema, @@ -133,6 +156,7 @@ export const monitorSchema = z.discriminatedUnion("type", [ pagespeedSchema, hardwareSchema, websocketSchema, + dnsSchema, ]); export type MonitorFormData = z.infer; From 98d2bbfbb9d3d08b1b9f051e56b42aa928b2689a Mon Sep 17 00:00:00 2001 From: Alex Date: Mon, 11 May 2026 10:01:23 -0700 Subject: [PATCH 092/245] add DNS monitor to create monitor form --- client/src/Hooks/useMonitorForm.ts | 9 ++ client/src/Pages/CreateMonitor/index.tsx | 110 ++++++++++++++++++++- client/src/Pages/Uptime/Monitors/index.tsx | 2 +- client/src/locales/en.json | 22 ++++- 4 files changed, 139 insertions(+), 4 deletions(-) diff --git a/client/src/Hooks/useMonitorForm.ts b/client/src/Hooks/useMonitorForm.ts index 963409fc8..f202b355e 100644 --- a/client/src/Hooks/useMonitorForm.ts +++ b/client/src/Hooks/useMonitorForm.ts @@ -111,6 +111,15 @@ export const useMonitorForm = ({ ignoreTlsErrors: data?.ignoreTlsErrors || false, }; break; + case "dns": + defaults = { + ...base, + type: "dns", + url: data?.url || "", + dnsServer: data?.dnsServer || "", + dnsRecordType: data?.dnsRecordType || "A", + }; + break; default: defaults = { ...base, diff --git a/client/src/Pages/CreateMonitor/index.tsx b/client/src/Pages/CreateMonitor/index.tsx index 4ae518caa..036ef929e 100644 --- a/client/src/Pages/CreateMonitor/index.tsx +++ b/client/src/Pages/CreateMonitor/index.tsx @@ -51,6 +51,8 @@ interface GeneralSettingsConfig { showSecret: boolean; showGrpcServiceName: boolean; showIgnoreTls: boolean; + showDnsServer: boolean; + showDnsRecordType: boolean; } const getGeneralSettingsConfig = ( @@ -68,6 +70,8 @@ const getGeneralSettingsConfig = ( showSecret: false, showGrpcServiceName: false, showIgnoreTls: false, + showDnsServer: false, + showDnsRecordType: false, }, ping: { urlLabel: t("pages.createMonitor.form.general.option.host.label"), @@ -79,6 +83,8 @@ const getGeneralSettingsConfig = ( showSecret: false, showGrpcServiceName: false, showIgnoreTls: false, + showDnsServer: false, + showDnsRecordType: false, }, docker: { urlLabel: t("pages.createMonitor.form.general.option.container.label"), @@ -90,6 +96,8 @@ const getGeneralSettingsConfig = ( showSecret: false, showGrpcServiceName: false, showIgnoreTls: false, + showDnsServer: false, + showDnsRecordType: false, }, port: { urlLabel: t("pages.createMonitor.form.general.option.url.label"), @@ -101,6 +109,8 @@ const getGeneralSettingsConfig = ( showSecret: false, showGrpcServiceName: false, showIgnoreTls: false, + showDnsServer: false, + showDnsRecordType: false, }, game: { urlLabel: t("pages.createMonitor.form.general.option.url.label"), @@ -112,6 +122,8 @@ const getGeneralSettingsConfig = ( showSecret: false, showGrpcServiceName: false, showIgnoreTls: false, + showDnsServer: false, + showDnsRecordType: false, }, grpc: { urlLabel: t("pages.createMonitor.form.general.option.host.label"), @@ -123,6 +135,8 @@ const getGeneralSettingsConfig = ( showSecret: false, showGrpcServiceName: true, showIgnoreTls: true, + showDnsServer: false, + showDnsRecordType: false, }, pagespeed: { urlLabel: t("pages.createMonitor.form.general.option.url.label"), @@ -134,6 +148,8 @@ const getGeneralSettingsConfig = ( showSecret: false, showGrpcServiceName: false, showIgnoreTls: false, + showDnsServer: false, + showDnsRecordType: false, }, hardware: { urlLabel: t("pages.createMonitor.form.general.option.captureUrl.label"), @@ -145,6 +161,8 @@ const getGeneralSettingsConfig = ( showSecret: true, showGrpcServiceName: false, showIgnoreTls: false, + showDnsServer: false, + showDnsRecordType: false, }, websocket: { urlLabel: t("pages.createMonitor.form.general.option.wsUrl.label"), @@ -156,6 +174,21 @@ const getGeneralSettingsConfig = ( showSecret: false, showGrpcServiceName: false, showIgnoreTls: true, + showDnsServer: false, + showDnsRecordType: false, + }, + dns: { + urlLabel: t("pages.createMonitor.form.general.option.url.label"), + urlPlaceholder: t("pages.createMonitor.form.general.option.url.placeholder"), + namePlaceholder: t("pages.createMonitor.form.general.option.name.placeholder"), + showUrl: true, + showPort: false, + showGameSelect: false, + showSecret: false, + showGrpcServiceName: false, + showIgnoreTls: false, + showDnsServer: true, + showDnsRecordType: true, }, }; return configs[type] || configs.http; @@ -349,6 +382,13 @@ const CreateMonitorPage = () => { "pages.createMonitor.form.type.optionWebSocketDescription" )} /> + )} @@ -495,6 +535,74 @@ const CreateMonitorPage = () => { )} /> )} + {generalSettingsConfig.showDnsServer && ( + ( + + )} + /> + )} + {generalSettingsConfig.showDnsRecordType && ( + ( + + )} + /> + )} { /> { const effectiveTypes = selectedTypes.length > 0 ? selectedTypes - : ["http", "ping", "docker", "port", "game", "grpc", "websocket"]; + : ["http", "ping", "docker", "port", "game", "grpc", "websocket", "dns"]; // Build URL for monitors with checks const monitorsWithChecksUrl = useMemo(() => { diff --git a/client/src/locales/en.json b/client/src/locales/en.json index 587e25df1..53ea095f1 100644 --- a/client/src/locales/en.json +++ b/client/src/locales/en.json @@ -475,7 +475,8 @@ "optionPagespeed": "PageSpeed", "optionHardware": "Infrastructure", "optionGrpc": "gRPC", - "optionWebSocket": "WebSocket" + "optionWebSocket": "WebSocket", + "optionDns": "DNS" } } }, @@ -565,6 +566,21 @@ "captureUrl": { "label": "Capture endpoint", "placeholder": "http://192.168.1.10:5001/api/v1/metrics" + }, + "dnsServer": { + "label": "DNS server", + "placeholder": "8.8.8.8" + }, + "dnsRecordType": { + "label": "DNS Record Type", + "value": { + "A": "A", + "AAAA": "AAAA", + "CNAME": "CNAME", + "MX": "MX", + "NS": "NS", + "TXT": "TXT" + } } }, "title": "General settings", @@ -577,7 +593,8 @@ "pagespeed": "Track page load performance, Core Web Vitals, and optimization scores for your website.", "grpc": "Enter the hostname and port of the gRPC server, optionally specify a service name for the health check, and add a display name.", "websocket": "Enter the WebSocket URL to monitor (e.g., wss://example.com/socket) and add a display name.", - "hardware": "Monitor CPU, memory, disk usage, and temperature for your infrastructure. The Capture endpoint URL must include the full path /api/v1/metrics (e.g. http://192.168.1.10:5001/api/v1/metrics). For the authorization secret, paste only the token string — don't include 'Bearer'." + "hardware": "Monitor CPU, memory, disk usage, and temperature for your infrastructure. The Capture endpoint URL must include the full path /api/v1/metrics (e.g. http://192.168.1.10:5001/api/v1/metrics). For the authorization secret, paste only the token string — don't include 'Bearer'.", + "dns": "Monitor if a specific DNS server is responding." } }, "ignoreTls": { @@ -614,6 +631,7 @@ "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.", + "optionDnsDescription": "Monitor if a specific DNS server is responding.", "title": "Type" }, "thresholds": { From 272e4b50147722c13cfb367a4be226816b52e0af Mon Sep 17 00:00:00 2001 From: Alex Date: Mon, 11 May 2026 10:01:42 -0700 Subject: [PATCH 093/245] update server services to support DNS type queries --- server/src/service/business/monitorService.ts | 3 ++- server/src/validation/checkValidation.ts | 2 +- server/src/validation/monitorValidation.ts | 8 ++++---- server/src/validation/shared.ts | 5 +++++ 4 files changed, 12 insertions(+), 6 deletions(-) diff --git a/server/src/service/business/monitorService.ts b/server/src/service/business/monitorService.ts index 742e0200a..947a971fe 100644 --- a/server/src/service/business/monitorService.ts +++ b/server/src/service/business/monitorService.ts @@ -223,7 +223,8 @@ export class MonitorService implements IMonitorService { checksData.monitorType !== "port" && checksData.monitorType !== "game" && checksData.monitorType !== "grpc" && - checksData.monitorType !== "websocket" + checksData.monitorType !== "websocket" && + checksData.monitorType !== "dns" ) { throw new AppError({ message: `${monitor.type} monitors are not supported for uptime details`, status: 400 }); } diff --git a/server/src/validation/checkValidation.ts b/server/src/validation/checkValidation.ts index e511118e6..8a4a7938e 100644 --- a/server/src/validation/checkValidation.ts +++ b/server/src/validation/checkValidation.ts @@ -11,7 +11,7 @@ export const getChecksParamValidation = z.object({ }); export const getChecksQueryValidation = z.object({ - type: z.enum(["http", "ping", "pagespeed", "hardware", "docker", "port", "game", "grpc", "websocket"]).optional(), + type: z.enum(["http", "ping", "pagespeed", "hardware", "docker", "port", "game", "grpc", "websocket", "dns"]).optional(), sortOrder: z.enum(["asc", "desc"]), limit: z.coerce.number().optional(), dateRange: z.enum(["recent", "hour", "day", "week", "month", "all"]), diff --git a/server/src/validation/monitorValidation.ts b/server/src/validation/monitorValidation.ts index 5065a5aa1..beb990f71 100644 --- a/server/src/validation/monitorValidation.ts +++ b/server/src/validation/monitorValidation.ts @@ -1,5 +1,5 @@ import { z } from "zod"; -import { booleanCoercion } from "./shared.js"; +import { booleanCoercion, dnsServerValidation } from "./shared.js"; import { GeoContinents } from "@/types/geoCheck.js"; import { DnsRecordTypes, MonitorMatchMethods, MonitorStatuses, MonitorTypes } from "@/types/monitor.js"; @@ -68,7 +68,7 @@ export const createMonitorBodyValidation = z.object({ geoCheckEnabled: z.boolean().optional(), geoCheckLocations: z.array(z.enum(GeoContinents)).optional(), geoCheckInterval: z.number().min(300000).optional(), - dnsServer: z.string().optional(), + dnsServer: dnsServerValidation.optional(), dnsRecordType: z.enum(DnsRecordTypes).optional(), }); @@ -99,7 +99,7 @@ export const editMonitorBodyValidation = z.object({ geoCheckEnabled: z.boolean().optional(), geoCheckLocations: z.array(z.enum(GeoContinents)).optional(), geoCheckInterval: z.number().min(300000).optional(), - dnsServer: z.string().optional(), + dnsServer: dnsServerValidation.optional(), dnsRecordType: z.enum(DnsRecordTypes).optional(), }); @@ -162,7 +162,7 @@ const importedMonitorSchema = z.object({ geoCheckEnabled: z.boolean().default(false), geoCheckLocations: z.array(z.enum(GeoContinents)).default([]), geoCheckInterval: z.number().min(300000).default(300000), - dnsServer: z.string().optional(), + dnsServer: dnsServerValidation.optional(), dnsRecordType: z.enum(DnsRecordTypes).optional(), createdAt: z.string().optional(), updatedAt: z.string().optional(), diff --git a/server/src/validation/shared.ts b/server/src/validation/shared.ts index 28fd13c82..6b607cce3 100644 --- a/server/src/validation/shared.ts +++ b/server/src/validation/shared.ts @@ -15,6 +15,11 @@ export const nameValidation = z export const lowercaseEmailValidation = z.email().transform((val) => val.toLowerCase()); +export const dnsServerValidation = z + .string() + .min(1, "DNS server is required") + .refine((v) => z.ipv4().safeParse(v).success || z.ipv6().safeParse(v).success, "Enter a valid IPv4 or IPv6 address (e.g. 8.8.8.8)"); + export const booleanCoercion = z.preprocess((val) => { if (val === "true" || val === true) return true; if (val === "false" || val === false) return false; From 08043a5ea0787d9fa18666b2faeba325ca74abe6 Mon Sep 17 00:00:00 2001 From: Alex Date: Mon, 11 May 2026 10:12:27 -0700 Subject: [PATCH 094/245] tighten validation, add tests --- client/src/locales/en.json | 2 +- .../infrastructure/network/DNSProvider.ts | 10 +- server/src/validation/checkValidation.ts | 3 +- server/src/validation/monitorValidation.ts | 228 ++++++++++-------- server/src/validation/shared.ts | 5 + .../providers/network/dnsProvider.test.ts | 22 ++ .../unit/validation/monitorValidation.test.ts | 33 +++ 7 files changed, 194 insertions(+), 109 deletions(-) diff --git a/client/src/locales/en.json b/client/src/locales/en.json index 53ea095f1..6d0d37eb5 100644 --- a/client/src/locales/en.json +++ b/client/src/locales/en.json @@ -572,7 +572,7 @@ "placeholder": "8.8.8.8" }, "dnsRecordType": { - "label": "DNS Record Type", + "label": "DNS record type", "value": { "A": "A", "AAAA": "AAAA", diff --git a/server/src/service/infrastructure/network/DNSProvider.ts b/server/src/service/infrastructure/network/DNSProvider.ts index 64e473e88..92b4eb333 100644 --- a/server/src/service/infrastructure/network/DNSProvider.ts +++ b/server/src/service/infrastructure/network/DNSProvider.ts @@ -16,11 +16,19 @@ export class DNSProvider implements IStatusProvider { async handle(monitor: Monitor): Promise> { try { - const { url: hostname, dnsServer, dnsRecordType = "A" } = monitor; + const { url, dnsServer, dnsRecordType = "A" } = monitor; if (!dnsServer) { throw new Error("DNS server is required for DNS monitoring"); } + let hostname: string; + try { + const withScheme = /^[a-z]+:\/\//i.test(url) ? url : `http://${url}`; // Ensure URL has a scheme for proper parsing + hostname = new URL(withScheme).hostname; + } catch { + hostname = url; + } + const resolver = this.createResolver(); resolver.setServers([dnsServer]); diff --git a/server/src/validation/checkValidation.ts b/server/src/validation/checkValidation.ts index 8a4a7938e..0c58b9df1 100644 --- a/server/src/validation/checkValidation.ts +++ b/server/src/validation/checkValidation.ts @@ -1,6 +1,7 @@ import { z } from "zod"; import { booleanCoercion } from "./shared.js"; import { GeoContinents } from "@/types/geoCheck.js"; +import { MonitorTypes } from "@/types/monitor.js"; //**************************************** // Check Validations @@ -11,7 +12,7 @@ export const getChecksParamValidation = z.object({ }); export const getChecksQueryValidation = z.object({ - type: z.enum(["http", "ping", "pagespeed", "hardware", "docker", "port", "game", "grpc", "websocket", "dns"]).optional(), + type: z.enum(MonitorTypes).optional(), sortOrder: z.enum(["asc", "desc"]), limit: z.coerce.number().optional(), dateRange: z.enum(["recent", "hour", "day", "week", "month", "all"]), diff --git a/server/src/validation/monitorValidation.ts b/server/src/validation/monitorValidation.ts index beb990f71..13c65cd4c 100644 --- a/server/src/validation/monitorValidation.ts +++ b/server/src/validation/monitorValidation.ts @@ -1,5 +1,5 @@ import { z } from "zod"; -import { booleanCoercion, dnsServerValidation } from "./shared.js"; +import { booleanCoercion, dnsHostnameRegex, dnsServerValidation } from "./shared.js"; import { GeoContinents } from "@/types/geoCheck.js"; import { DnsRecordTypes, MonitorMatchMethods, MonitorStatuses, MonitorTypes } from "@/types/monitor.js"; @@ -39,69 +39,83 @@ export const getCertificateParamValidation = z.object({ monitorId: z.string().min(1, "Monitor ID is required"), }); -export const createMonitorBodyValidation = z.object({ - _id: z.string().optional(), - name: z.string().min(1, "Name is required"), - description: z.union([z.string(), z.literal("")]).optional(), - type: z.enum(MonitorTypes, "Invalid monitor type"), - statusWindowSize: z.number().min(1).max(20).default(5), - statusWindowThreshold: z.number().min(1).max(100).default(60), - url: z.string().min(1, "URL is required"), - ignoreTlsErrors: z.boolean().default(false), - useAdvancedMatching: z.boolean().default(false), - port: z.number().optional(), - isActive: z.boolean().optional(), - interval: z.number().optional(), - cpuAlertThreshold: z.number().optional(), - memoryAlertThreshold: z.number().optional(), - diskAlertThreshold: z.number().optional(), - tempAlertThreshold: z.number().optional(), - notifications: z.array(z.string()).optional(), - secret: z.string().optional(), - 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(), - gameId: z.union([z.string(), z.literal("")]).optional(), - grpcServiceName: z.union([z.string(), z.literal("")]).default(""), - selectedDisks: z.array(z.string()).optional(), - group: z.union([z.string().max(50).trim(), z.null(), z.literal("")]).optional(), - geoCheckEnabled: z.boolean().optional(), - geoCheckLocations: z.array(z.enum(GeoContinents)).optional(), - geoCheckInterval: z.number().min(300000).optional(), - dnsServer: dnsServerValidation.optional(), - dnsRecordType: z.enum(DnsRecordTypes).optional(), -}); +const refineDnsHostname = (body: { type?: string; url?: string }, ctx: z.RefinementCtx) => { + if (body.type === "dns" && body.url && !dnsHostnameRegex.test(body.url)) { + ctx.addIssue({ + code: "custom", + path: ["url"], + message: "Enter a valid domain (e.g. www.example.com)", + }); + } +}; -export const editMonitorBodyValidation = z.object({ - name: z.string().optional(), - type: z.enum(MonitorTypes).optional(), - url: z.string().optional(), - statusWindowSize: z.number().min(1).max(20).default(5), - statusWindowThreshold: z.number().min(1).max(100).default(60), - description: z.union([z.string(), z.literal("")]).optional(), - interval: z.number().optional(), - notifications: z.array(z.string()).optional(), - secret: z.string().optional(), - ignoreTlsErrors: z.boolean().optional(), - useAdvancedMatching: z.boolean().optional(), - 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(), - port: z.number().min(1).max(65535).optional(), - cpuAlertThreshold: z.number().optional(), - memoryAlertThreshold: z.number().optional(), - diskAlertThreshold: z.number().optional(), - tempAlertThreshold: z.number().optional(), - gameId: z.union([z.string(), z.literal("")]).optional(), - grpcServiceName: z.union([z.string(), z.literal("")]).optional(), - selectedDisks: z.array(z.string()).optional(), - group: z.union([z.string().max(50).trim(), z.null(), z.literal("")]).optional(), - geoCheckEnabled: z.boolean().optional(), - geoCheckLocations: z.array(z.enum(GeoContinents)).optional(), - geoCheckInterval: z.number().min(300000).optional(), - dnsServer: dnsServerValidation.optional(), - dnsRecordType: z.enum(DnsRecordTypes).optional(), -}); +export const createMonitorBodyValidation = z + .object({ + _id: z.string().optional(), + name: z.string().min(1, "Name is required"), + description: z.union([z.string(), z.literal("")]).optional(), + type: z.enum(MonitorTypes, "Invalid monitor type"), + statusWindowSize: z.number().min(1).max(20).default(5), + statusWindowThreshold: z.number().min(1).max(100).default(60), + url: z.string().min(1, "URL is required"), + ignoreTlsErrors: z.boolean().default(false), + useAdvancedMatching: z.boolean().default(false), + port: z.number().optional(), + isActive: z.boolean().optional(), + interval: z.number().optional(), + cpuAlertThreshold: z.number().optional(), + memoryAlertThreshold: z.number().optional(), + diskAlertThreshold: z.number().optional(), + tempAlertThreshold: z.number().optional(), + notifications: z.array(z.string()).optional(), + secret: z.string().optional(), + 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(), + gameId: z.union([z.string(), z.literal("")]).optional(), + grpcServiceName: z.union([z.string(), z.literal("")]).default(""), + selectedDisks: z.array(z.string()).optional(), + group: z.union([z.string().max(50).trim(), z.null(), z.literal("")]).optional(), + geoCheckEnabled: z.boolean().optional(), + geoCheckLocations: z.array(z.enum(GeoContinents)).optional(), + geoCheckInterval: z.number().min(300000).optional(), + dnsServer: dnsServerValidation.optional(), + dnsRecordType: z.enum(DnsRecordTypes).optional(), + }) + .superRefine(refineDnsHostname); + +export const editMonitorBodyValidation = z + .object({ + name: z.string().optional(), + type: z.enum(MonitorTypes).optional(), + url: z.string().optional(), + statusWindowSize: z.number().min(1).max(20).default(5), + statusWindowThreshold: z.number().min(1).max(100).default(60), + description: z.union([z.string(), z.literal("")]).optional(), + interval: z.number().optional(), + notifications: z.array(z.string()).optional(), + secret: z.string().optional(), + ignoreTlsErrors: z.boolean().optional(), + useAdvancedMatching: z.boolean().optional(), + 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(), + port: z.number().min(1).max(65535).optional(), + cpuAlertThreshold: z.number().optional(), + memoryAlertThreshold: z.number().optional(), + diskAlertThreshold: z.number().optional(), + tempAlertThreshold: z.number().optional(), + gameId: z.union([z.string(), z.literal("")]).optional(), + grpcServiceName: z.union([z.string(), z.literal("")]).optional(), + selectedDisks: z.array(z.string()).optional(), + group: z.union([z.string().max(50).trim(), z.null(), z.literal("")]).optional(), + geoCheckEnabled: z.boolean().optional(), + geoCheckLocations: z.array(z.enum(GeoContinents)).optional(), + geoCheckInterval: z.number().min(300000).optional(), + dnsServer: dnsServerValidation.optional(), + dnsRecordType: z.enum(DnsRecordTypes).optional(), + }) + .superRefine(refineDnsHostname); export const pauseMonitorParamValidation = z.object({ monitorId: z.string().min(1, "Monitor ID is required"), @@ -124,49 +138,51 @@ export const getUptimeDetailsByIdQueryValidation = z.object({ normalize: booleanCoercion.optional(), }); -const importedMonitorSchema = z.object({ - id: z.string().optional(), - userId: z.string().optional(), - 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"]).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().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().default(true), - interval: z.number().default(60000), - uptimePercentage: z.number().optional(), - notifications: z.array(z.string()).default([]), - secret: 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("")]).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), - dnsServer: dnsServerValidation.optional(), - dnsRecordType: z.enum(DnsRecordTypes).optional(), - createdAt: z.string().optional(), - updatedAt: z.string().optional(), -}); +const importedMonitorSchema = z + .object({ + id: z.string().optional(), + userId: z.string().optional(), + 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"]).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().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().default(true), + interval: z.number().default(60000), + uptimePercentage: z.number().optional(), + notifications: z.array(z.string()).default([]), + secret: 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("")]).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), + dnsServer: dnsServerValidation.optional(), + dnsRecordType: z.enum(DnsRecordTypes).optional(), + createdAt: z.string().optional(), + updatedAt: z.string().optional(), + }) + .superRefine(refineDnsHostname); export const importMonitorsBodyValidation = z.object({ monitors: z.array(importedMonitorSchema).min(1, "At least one monitor is required"), diff --git a/server/src/validation/shared.ts b/server/src/validation/shared.ts index 6b607cce3..05ecd9726 100644 --- a/server/src/validation/shared.ts +++ b/server/src/validation/shared.ts @@ -20,6 +20,11 @@ export const dnsServerValidation = z .min(1, "DNS server is required") .refine((v) => z.ipv4().safeParse(v).success || z.ipv6().safeParse(v).success, "Enter a valid IPv4 or IPv6 address (e.g. 8.8.8.8)"); +// Hostname (FQDN) — labels of 1-63 alphanumerics/hyphens separated by dots, +// no scheme, port, path, or whitespace. Total length ≤ 253. Kept in sync with +// the client-side regex in client/src/Validation/monitor.ts. +export const dnsHostnameRegex = /^(?=.{1,253}$)([a-zA-Z0-9](?:[a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?\.)+[a-zA-Z]{2,63}$/; + export const booleanCoercion = z.preprocess((val) => { if (val === "true" || val === true) return true; if (val === "false" || val === false) return false; diff --git a/server/test/unit/providers/network/dnsProvider.test.ts b/server/test/unit/providers/network/dnsProvider.test.ts index 3fcea5113..a96731be5 100644 --- a/server/test/unit/providers/network/dnsProvider.test.ts +++ b/server/test/unit/providers/network/dnsProvider.test.ts @@ -187,6 +187,28 @@ describe("DNSProvider", () => { await expect(provider.handle(makeMonitor())).rejects.toThrow("Error performing DNS check"); }); + it("strips scheme from url before querying the resolver", async () => { + const provider = new DNSProvider(createResolver); + const result = await provider.handle(makeMonitor({ url: "https://www.google.ca" })); + + expect(mockResolverInstance.resolve4).toHaveBeenCalledWith("www.google.ca"); + expect(result.payload?.hostname).toBe("www.google.ca"); + }); + + it("strips port and path from url before querying the resolver", async () => { + const provider = new DNSProvider(createResolver); + await provider.handle(makeMonitor({ url: "example.com:53/some/path" })); + + expect(mockResolverInstance.resolve4).toHaveBeenCalledWith("example.com"); + }); + + it("passes a bare hostname through unchanged", async () => { + const provider = new DNSProvider(createResolver); + await provider.handle(makeMonitor({ url: "example.com" })); + + expect(mockResolverInstance.resolve4).toHaveBeenCalledWith("example.com"); + }); + it("stringifies non-Error throws from outside timeRequest", async () => { mockResolverInstance.setServers.mockImplementationOnce(() => { throw 42; diff --git a/server/test/unit/validation/monitorValidation.test.ts b/server/test/unit/validation/monitorValidation.test.ts index 5bd178f51..87ac58b6d 100644 --- a/server/test/unit/validation/monitorValidation.test.ts +++ b/server/test/unit/validation/monitorValidation.test.ts @@ -51,6 +51,39 @@ describe("monitorValidation — DNS fields", () => { expect(parsed.dnsServer).toBeUndefined(); expect(parsed.dnsRecordType).toBeUndefined(); }); + + it("rejects a DNS monitor whose url has a scheme or path", () => { + for (const badUrl of ["https://example.com", "example.com/path", "example.com:53", " example.com"]) { + expect(() => + createMonitorBodyValidation.parse({ + ...baseDnsBody, + url: badUrl, + dnsServer: "8.8.8.8", + dnsRecordType: "A", + }) + ).toThrow(); + } + }); + + it("rejects a DNS monitor with an invalid dnsServer", () => { + expect(() => + createMonitorBodyValidation.parse({ + ...baseDnsBody, + dnsServer: "not-an-ip", + dnsRecordType: "A", + }) + ).toThrow(); + }); + + it("does not enforce the hostname rule for non-DNS monitors", () => { + const parsed = createMonitorBodyValidation.parse({ + name: "HTTP check", + type: "http", + url: "https://example.com/some/path", + }); + + expect(parsed.url).toBe("https://example.com/some/path"); + }); }); describe("editMonitorBodyValidation", () => { From 766d8475614fc33b2d503d5405a6cfe9dc69e52e Mon Sep 17 00:00:00 2001 From: Alex Date: Mon, 11 May 2026 10:27:41 -0700 Subject: [PATCH 095/245] tighten validation, replace negative type checks with allowed list --- client/src/Validation/monitor.ts | 5 +++-- server/src/service/business/monitorService.ts | 17 ++++++----------- server/src/types/monitor.ts | 13 +++++++++++++ server/src/validation/shared.ts | 5 +++-- .../unit/validation/monitorValidation.test.ts | 12 ++++++++++++ 5 files changed, 37 insertions(+), 15 deletions(-) diff --git a/client/src/Validation/monitor.ts b/client/src/Validation/monitor.ts index ede0a44b3..108aa729e 100644 --- a/client/src/Validation/monitor.ts +++ b/client/src/Validation/monitor.ts @@ -124,9 +124,10 @@ const websocketSchema = baseSchema.extend({ }); // Hostname (FQDN) — labels of 1-63 alphanumerics/hyphens separated by dots, -// no scheme, port, path, or whitespace. Total length ≤ 253. +// optionally prefixed with `_` for service labels (e.g. _dmarc, _imaps._tcp). +// No scheme, port, path, or whitespace. Total length ≤ 253. const hostnameRegex = - /^(?=.{1,253}$)([a-zA-Z0-9](?:[a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?\.)+[a-zA-Z]{2,63}$/; + /^(?=.{1,253}$)([a-zA-Z0-9_](?:[a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?\.)+[a-zA-Z]{2,63}$/; // DNS monitor schema const dnsSchema = baseSchema.extend({ diff --git a/server/src/service/business/monitorService.ts b/server/src/service/business/monitorService.ts index 947a971fe..72c8f1fe8 100644 --- a/server/src/service/business/monitorService.ts +++ b/server/src/service/business/monitorService.ts @@ -9,7 +9,8 @@ import type { GamesMap, GroupedGeoCheckResult, } from "@/types/monitor.js"; -import { supportsGeoCheck } from "@/types/monitor.js"; +import { supportsGeoCheck, supportsUptimeDetails } from "@/types/monitor.js"; +import type { UptimeChecksResult, HardwareChecksResult, PageSpeedChecksResult } from "@/types/check.js"; import type { GeoContinent } from "@/types/geoCheck.js"; import type { IChecksRepository, @@ -28,6 +29,9 @@ import { ILogger } from "@/utils/logger.js"; const SERVICE_NAME = "MonitorService"; type DateRangeKey = "recent" | "day" | "week" | "month" | "all"; +const isUptimeChecksResult = (result: UptimeChecksResult | HardwareChecksResult | PageSpeedChecksResult): result is UptimeChecksResult => + supportsUptimeDetails(result.monitorType); + export interface IMonitorService { readonly serviceName: string; @@ -216,16 +220,7 @@ export class MonitorService implements IMonitorService { }); const monitorStats = await this.monitorStatsRepository.findByMonitorId(monitor.id); - if ( - checksData.monitorType !== "http" && - checksData.monitorType !== "ping" && - checksData.monitorType !== "docker" && - checksData.monitorType !== "port" && - checksData.monitorType !== "game" && - checksData.monitorType !== "grpc" && - checksData.monitorType !== "websocket" && - checksData.monitorType !== "dns" - ) { + if (!isUptimeChecksResult(checksData)) { throw new AppError({ message: `${monitor.type} monitors are not supported for uptime details`, status: 400 }); } diff --git a/server/src/types/monitor.ts b/server/src/types/monitor.ts index 09797f2ca..62b3a367e 100644 --- a/server/src/types/monitor.ts +++ b/server/src/types/monitor.ts @@ -9,6 +9,19 @@ export type MonitorType = (typeof MonitorTypes)[number]; export const GeoCheckSupportedTypes: readonly MonitorType[] = ["http", "ping"] as const; export const supportsGeoCheck = (type: MonitorType): boolean => GeoCheckSupportedTypes.includes(type); +export const UptimeDetailsSupportedTypes = [ + "http", + "ping", + "docker", + "port", + "game", + "grpc", + "websocket", + "dns", +] as const satisfies readonly MonitorType[]; +export type UptimeDetailsSupportedType = (typeof UptimeDetailsSupportedTypes)[number]; +export const supportsUptimeDetails = (type: MonitorType): type is UptimeDetailsSupportedType => UptimeDetailsSupportedTypes.some((t) => t === type); + export const MonitorStatuses = ["up", "down", "paused", "initializing", "maintenance", "breached"] as const; export type MonitorStatus = (typeof MonitorStatuses)[number]; diff --git a/server/src/validation/shared.ts b/server/src/validation/shared.ts index 05ecd9726..99f92f250 100644 --- a/server/src/validation/shared.ts +++ b/server/src/validation/shared.ts @@ -21,9 +21,10 @@ export const dnsServerValidation = z .refine((v) => z.ipv4().safeParse(v).success || z.ipv6().safeParse(v).success, "Enter a valid IPv4 or IPv6 address (e.g. 8.8.8.8)"); // Hostname (FQDN) — labels of 1-63 alphanumerics/hyphens separated by dots, -// no scheme, port, path, or whitespace. Total length ≤ 253. Kept in sync with +// optionally prefixed with `_` for service labels (e.g. _dmarc, _imaps._tcp). +// No scheme, port, path, or whitespace. Total length ≤ 253. Kept in sync with // the client-side regex in client/src/Validation/monitor.ts. -export const dnsHostnameRegex = /^(?=.{1,253}$)([a-zA-Z0-9](?:[a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?\.)+[a-zA-Z]{2,63}$/; +export const dnsHostnameRegex = /^(?=.{1,253}$)([a-zA-Z0-9_](?:[a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?\.)+[a-zA-Z]{2,63}$/; export const booleanCoercion = z.preprocess((val) => { if (val === "true" || val === true) return true; diff --git a/server/test/unit/validation/monitorValidation.test.ts b/server/test/unit/validation/monitorValidation.test.ts index 87ac58b6d..62fa91625 100644 --- a/server/test/unit/validation/monitorValidation.test.ts +++ b/server/test/unit/validation/monitorValidation.test.ts @@ -75,6 +75,18 @@ describe("monitorValidation — DNS fields", () => { ).toThrow(); }); + it("accepts service labels with leading underscore (DMARC, SRV, ACME)", () => { + for (const url of ["_dmarc.example.com", "_imaps._tcp.example.com", "_acme-challenge.example.com"]) { + const parsed = createMonitorBodyValidation.parse({ + ...baseDnsBody, + url, + dnsServer: "8.8.8.8", + dnsRecordType: "TXT", + }); + expect(parsed.url).toBe(url); + } + }); + it("does not enforce the hostname rule for non-DNS monitors", () => { const parsed = createMonitorBodyValidation.parse({ name: "HTTP check", From 60770a454461bf3c4bd847ce483f91dc2bff564e Mon Sep 17 00:00:00 2001 From: Alex Date: Mon, 11 May 2026 10:29:59 -0700 Subject: [PATCH 096/245] udpate monitor path map --- client/src/Utils/MonitorUtils.ts | 1 + 1 file changed, 1 insertion(+) diff --git a/client/src/Utils/MonitorUtils.ts b/client/src/Utils/MonitorUtils.ts index d0ede67e1..256d523de 100644 --- a/client/src/Utils/MonitorUtils.ts +++ b/client/src/Utils/MonitorUtils.ts @@ -10,6 +10,7 @@ export const getMonitorPath = (type: MonitorType): string => { game: "uptime", grpc: "uptime", websocket: "uptime", + dns: "uptime", unknown: "uptime", docker: "uptime", hardware: "infrastructure", From 6b63091d89162e2ff62f05b42f8526f5906416d1 Mon Sep 17 00:00:00 2001 From: Alex Date: Mon, 11 May 2026 11:28:39 -0700 Subject: [PATCH 097/245] tighten validation, add tests --- .../0022_add_strategy_to_monitors.ts | 15 --- server/src/validation/monitorValidation.ts | 23 ++++- .../unit/validation/monitorValidation.test.ts | 93 +++++++++++++++++++ 3 files changed, 111 insertions(+), 20 deletions(-) delete mode 100644 server/src/db/migration/timescaledb/0022_add_strategy_to_monitors.ts diff --git a/server/src/db/migration/timescaledb/0022_add_strategy_to_monitors.ts b/server/src/db/migration/timescaledb/0022_add_strategy_to_monitors.ts deleted file mode 100644 index 06d48cc75..000000000 --- a/server/src/db/migration/timescaledb/0022_add_strategy_to_monitors.ts +++ /dev/null @@ -1,15 +0,0 @@ -import type { Pool } from "pg"; - -export const addStrategyToMonitors = async (pool: Pool) => { - await pool.query(` - ALTER TABLE monitors - ADD COLUMN IF NOT EXISTS strategy TEXT DEFAULT NULL; - `); -}; - -export const dropStrategyFromMonitors = async (pool: Pool) => { - await pool.query(` - ALTER TABLE monitors - DROP COLUMN IF EXISTS strategy; - `); -}; diff --git a/server/src/validation/monitorValidation.ts b/server/src/validation/monitorValidation.ts index 1889aa415..69d1c2c2a 100644 --- a/server/src/validation/monitorValidation.ts +++ b/server/src/validation/monitorValidation.ts @@ -1,7 +1,7 @@ import { z } from "zod"; import { booleanCoercion, dnsHostnameRegex, dnsServerValidation } from "./shared.js"; import { GeoContinents } from "@/types/geoCheck.js"; -import { DefaultPageSpeedStrategy, DnsRecordTypes, MonitorMatchMethods, MonitorStatuses, MonitorTypes, PageSpeedStrategies } from "@/types/monitor.js"; +import { DnsRecordTypes, MonitorMatchMethods, MonitorStatuses, MonitorTypes, PageSpeedStrategies } from "@/types/monitor.js"; export const getMonitorByIdParamValidation = z.object({ monitorId: z.string().min(1, "Monitor ID is required"), @@ -49,6 +49,16 @@ const refineDnsHostname = (body: { type?: string; url?: string }, ctx: z.Refinem } }; +const refineStrategyType = (body: { type?: string; strategy?: string }, ctx: z.RefinementCtx) => { + if (body.strategy !== undefined && body.type !== undefined && body.type !== "pagespeed") { + ctx.addIssue({ + code: "custom", + path: ["strategy"], + message: "Strategy is only valid for pagespeed monitors", + }); + } +}; + export const createMonitorBodyValidation = z .object({ _id: z.string().optional(), @@ -83,7 +93,8 @@ export const createMonitorBodyValidation = z dnsServer: dnsServerValidation.optional(), dnsRecordType: z.enum(DnsRecordTypes).optional(), }) - .superRefine(refineDnsHostname); + .superRefine(refineDnsHostname) + .superRefine(refineStrategyType); export const editMonitorBodyValidation = z .object({ @@ -117,7 +128,8 @@ export const editMonitorBodyValidation = z dnsServer: dnsServerValidation.optional(), dnsRecordType: z.enum(DnsRecordTypes).optional(), }) - .superRefine(refineDnsHostname); + .superRefine(refineDnsHostname) + .superRefine(refineStrategyType); export const pauseMonitorParamValidation = z.object({ monitorId: z.string().min(1, "Monitor ID is required"), @@ -175,7 +187,7 @@ const importedMonitorSchema = z selectedDisks: z.array(z.string()).default([]), gameId: z.union([z.string(), z.literal("")]).optional(), grpcServiceName: z.union([z.string(), z.literal("")]).default(""), - strategy: z.enum(PageSpeedStrategies).default(DefaultPageSpeedStrategy), + strategy: z.enum(PageSpeedStrategies).optional(), group: z.union([z.string().max(50).trim(), z.null()]).default(null), geoCheckEnabled: z.boolean().default(false), geoCheckLocations: z.array(z.enum(GeoContinents)).default([]), @@ -185,7 +197,8 @@ const importedMonitorSchema = z createdAt: z.string().optional(), updatedAt: z.string().optional(), }) - .superRefine(refineDnsHostname); + .superRefine(refineDnsHostname) + .superRefine(refineStrategyType); export const importMonitorsBodyValidation = z.object({ monitors: z.array(importedMonitorSchema).min(1, "At least one monitor is required"), diff --git a/server/test/unit/validation/monitorValidation.test.ts b/server/test/unit/validation/monitorValidation.test.ts index 62fa91625..057ff6ae1 100644 --- a/server/test/unit/validation/monitorValidation.test.ts +++ b/server/test/unit/validation/monitorValidation.test.ts @@ -129,3 +129,96 @@ describe("monitorValidation — DNS fields", () => { }); }); }); + +describe("monitorValidation — strategy gating", () => { + describe("createMonitorBodyValidation", () => { + it("accepts strategy on pagespeed monitors", () => { + const parsed = createMonitorBodyValidation.parse({ + name: "PS check", + type: "pagespeed", + url: "https://example.com", + strategy: "mobile", + }); + expect(parsed.strategy).toBe("mobile"); + }); + + it("accepts pagespeed monitors without a strategy", () => { + const parsed = createMonitorBodyValidation.parse({ + name: "PS check", + type: "pagespeed", + url: "https://example.com", + }); + expect(parsed.strategy).toBeUndefined(); + }); + + it("rejects strategy on non-pagespeed monitor types", () => { + for (const type of ["http", "ping", "docker", "port", "game", "grpc", "websocket", "dns", "hardware"] as const) { + expect(() => + createMonitorBodyValidation.parse({ + name: "wrong-type check", + type, + url: type === "dns" ? "example.com" : "https://example.com", + strategy: "desktop", + }) + ).toThrow(); + } + }); + + it("allows non-pagespeed monitors without a strategy", () => { + const parsed = createMonitorBodyValidation.parse({ + name: "HTTP check", + type: "http", + url: "https://example.com", + }); + expect(parsed.strategy).toBeUndefined(); + }); + }); + + describe("editMonitorBodyValidation", () => { + it("rejects strategy when type is explicitly non-pagespeed", () => { + expect(() => + editMonitorBodyValidation.parse({ + type: "http", + strategy: "mobile", + }) + ).toThrow(); + }); + + it("allows strategy when type is omitted (partial edit)", () => { + const parsed = editMonitorBodyValidation.parse({ + strategy: "mobile", + }); + expect(parsed.strategy).toBe("mobile"); + }); + }); + + describe("importMonitorsBodyValidation", () => { + it("does not inject a default strategy on imported HTTP monitors", () => { + const parsed = importMonitorsBodyValidation.parse({ + monitors: [ + { + name: "Imported HTTP", + type: "http", + url: "https://example.com", + }, + ], + }); + expect(parsed.monitors[0].strategy).toBeUndefined(); + }); + + it("rejects strategy on imported non-pagespeed monitors", () => { + expect(() => + importMonitorsBodyValidation.parse({ + monitors: [ + { + name: "Imported HTTP", + type: "http", + url: "https://example.com", + strategy: "desktop", + }, + ], + }) + ).toThrow(); + }); + }); +}); From 4b888c1ed1185c0c9052e9675128f2e2f77e78b1 Mon Sep 17 00:00:00 2001 From: Alex Date: Mon, 11 May 2026 11:30:58 -0700 Subject: [PATCH 098/245] bump dependencies --- server/openapi.json | 69 +++++++++++++++++++---- server/package-lock.json | 119 ++++++++++++++++++++++----------------- server/package.json | 2 +- 3 files changed, 125 insertions(+), 65 deletions(-) diff --git a/server/openapi.json b/server/openapi.json index f62062332..26c58fa29 100644 --- a/server/openapi.json +++ b/server/openapi.json @@ -346,7 +346,7 @@ }, "type": { "type": "string", - "enum": ["http", "ping", "pagespeed", "hardware", "docker", "port", "game", "grpc", "websocket", "unknown"] + "enum": ["http", "ping", "pagespeed", "hardware", "docker", "port", "game", "grpc", "websocket", "dns", "unknown"] }, "url": { "type": "string" @@ -436,6 +436,13 @@ "geoCheckInterval": { "type": "number" }, + "dnsServer": { + "type": "string" + }, + "dnsRecordType": { + "type": "string", + "enum": ["A", "AAAA", "CNAME", "MX", "TXT", "NS"] + }, "teamId": { "type": "string" }, @@ -2981,7 +2988,7 @@ { "schema": { "type": "string", - "enum": ["http", "ping", "pagespeed", "hardware", "docker", "port", "game", "grpc", "websocket"] + "enum": ["http", "ping", "pagespeed", "hardware", "docker", "port", "game", "grpc", "websocket", "dns", "unknown"] }, "required": false, "name": "type", @@ -3416,7 +3423,7 @@ { "schema": { "type": "string", - "enum": ["http", "ping", "pagespeed", "hardware", "docker", "port", "game", "grpc", "websocket"] + "enum": ["http", "ping", "pagespeed", "hardware", "docker", "port", "game", "grpc", "websocket", "dns", "unknown"] }, "required": false, "name": "type", @@ -4664,7 +4671,8 @@ "format": "date-time" } }, - "required": ["monitors", "name", "duration", "durationUnit", "start", "end", "repeat"] + "required": ["monitors", "name", "duration", "durationUnit", "start", "end", "repeat"], + "additionalProperties": false } } } @@ -5208,7 +5216,8 @@ "type": "string", "enum": ["seconds", "minutes", "hours", "days"] } - } + }, + "additionalProperties": false } } } @@ -5439,13 +5448,13 @@ "anyOf": [ { "type": "string", - "enum": ["http", "ping", "pagespeed", "docker", "hardware", "port", "game", "grpc", "websocket"] + "enum": ["http", "ping", "pagespeed", "hardware", "docker", "port", "game", "grpc", "websocket", "dns", "unknown"] }, { "type": "array", "items": { "type": "string", - "enum": ["http", "ping", "pagespeed", "docker", "hardware", "port", "game", "grpc", "websocket"] + "enum": ["http", "ping", "pagespeed", "hardware", "docker", "port", "game", "grpc", "websocket", "dns", "unknown"] } } ] @@ -5648,13 +5657,13 @@ "anyOf": [ { "type": "string", - "enum": ["http", "ping", "pagespeed", "docker", "hardware", "port", "game", "grpc", "websocket"] + "enum": ["http", "ping", "pagespeed", "hardware", "docker", "port", "game", "grpc", "websocket", "dns", "unknown"] }, { "type": "array", "items": { "type": "string", - "enum": ["http", "ping", "pagespeed", "docker", "hardware", "port", "game", "grpc", "websocket"] + "enum": ["http", "ping", "pagespeed", "hardware", "docker", "port", "game", "grpc", "websocket", "dns", "unknown"] } } ] @@ -6862,7 +6871,7 @@ }, "type": { "type": "string", - "enum": ["http", "ping", "pagespeed", "hardware", "docker", "port", "game", "grpc", "websocket", "unknown"] + "enum": ["http", "ping", "pagespeed", "hardware", "docker", "port", "game", "grpc", "websocket", "dns", "unknown"] }, "statusWindowSize": { "type": "number", @@ -6975,6 +6984,10 @@ ], "default": "" }, + "strategy": { + "type": "string", + "enum": ["desktop", "mobile"] + }, "selectedDisks": { "type": "array", "items": { @@ -7009,6 +7022,14 @@ "geoCheckInterval": { "type": "number", "minimum": 300000 + }, + "dnsServer": { + "type": "string", + "minLength": 1 + }, + "dnsRecordType": { + "type": "string", + "enum": ["A", "AAAA", "CNAME", "MX", "TXT", "NS"] } }, "required": ["name", "type", "url"] @@ -7496,7 +7517,7 @@ }, "type": { "type": "string", - "enum": ["http", "ping", "pagespeed", "hardware", "docker", "port", "game", "grpc", "websocket", "unknown"] + "enum": ["http", "ping", "pagespeed", "hardware", "docker", "port", "game", "grpc", "websocket", "dns", "unknown"] }, "ignoreTlsErrors": { "type": "boolean", @@ -7630,6 +7651,10 @@ ], "default": "" }, + "strategy": { + "type": "string", + "enum": ["desktop", "mobile"] + }, "group": { "anyOf": [ { @@ -7659,6 +7684,14 @@ "minimum": 300000, "default": 300000 }, + "dnsServer": { + "type": "string", + "minLength": 1 + }, + "dnsRecordType": { + "type": "string", + "enum": ["A", "AAAA", "CNAME", "MX", "TXT", "NS"] + }, "createdAt": { "type": "string" }, @@ -8034,7 +8067,7 @@ }, "type": { "type": "string", - "enum": ["http", "ping", "pagespeed", "hardware", "docker", "port", "game", "grpc", "websocket", "unknown"] + "enum": ["http", "ping", "pagespeed", "hardware", "docker", "port", "game", "grpc", "websocket", "dns", "unknown"] }, "url": { "type": "string" @@ -8153,6 +8186,10 @@ } ] }, + "strategy": { + "type": "string", + "enum": ["desktop", "mobile"] + }, "selectedDisks": { "type": "array", "items": { @@ -8187,6 +8224,14 @@ "geoCheckInterval": { "type": "number", "minimum": 300000 + }, + "dnsServer": { + "type": "string", + "minLength": 1 + }, + "dnsRecordType": { + "type": "string", + "enum": ["A", "AAAA", "CNAME", "MX", "TXT", "NS"] } } } diff --git a/server/package-lock.json b/server/package-lock.json index 52f37fde8..9010cfb9e 100644 --- a/server/package-lock.json +++ b/server/package-lock.json @@ -21,7 +21,7 @@ "dockerode": "4.0.6", "dotenv": "^16.4.5", "express": "^4.19.2", - "express-rate-limit": "8.3.2", + "express-rate-limit": "^8.5.1", "gamedig": "5.2.0", "got": "14.4.7", "handlebars": "^4.7.8", @@ -811,14 +811,15 @@ } }, "node_modules/@aws-sdk/xml-builder": { - "version": "3.972.16", - "resolved": "https://registry.npmjs.org/@aws-sdk/xml-builder/-/xml-builder-3.972.16.tgz", - "integrity": "sha512-iu2pyvaqmeatIJLURLqx9D+4jKAdTH20ntzB6BFwjyN7V960r4jK32mx0Zf7YbtOYAbmbtQfDNuL60ONinyw7A==", + "version": "3.972.22", + "resolved": "https://registry.npmjs.org/@aws-sdk/xml-builder/-/xml-builder-3.972.22.tgz", + "integrity": "sha512-PMYKKtJd70IsSG0yHrdAbxBr+ZWBKLvzFZfD3/urxgf6hXVMzuU5M+3MJ5G67RpOmLBu1fAUN65SbWuKUCOlAA==", "dev": true, "license": "Apache-2.0", "dependencies": { - "@smithy/types": "^4.13.1", - "fast-xml-parser": "5.5.8", + "@nodable/entities": "2.1.0", + "@smithy/types": "^4.14.1", + "fast-xml-parser": "5.7.2", "tslib": "^2.6.2" }, "engines": { @@ -865,7 +866,6 @@ "integrity": "sha512-H3mcG6ZDLTlYfaSNi0iOKkigqMFvkTKlGUYlD8GW7nNOYRrevuA46iTypPyv+06V3fEmvvazfntkBU34L0azAw==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@babel/code-frame": "^7.28.6", "@babel/generator": "^7.28.6", @@ -1485,7 +1485,6 @@ } ], "license": "MIT", - "peer": true, "engines": { "node": ">=18" }, @@ -1530,7 +1529,6 @@ } ], "license": "MIT", - "peer": true, "engines": { "node": ">=18" } @@ -3193,6 +3191,19 @@ "@tybys/wasm-util": "^0.10.0" } }, + "node_modules/@nodable/entities": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/@nodable/entities/-/entities-2.1.0.tgz", + "integrity": "sha512-nyT7T3nbMyBI/lvr6L5TyWbFJAI9FTgVRakNoBqCD+PmID8DzFrrNdLLtHMwMszOtqZa8PAOV24ZqDnQrhQINA==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/nodable" + } + ], + "license": "MIT" + }, "node_modules/@nodelib/fs.scandir": { "version": "2.1.5", "resolved": "https://registry.npmjs.org/@nodelib/fs.scandir/-/fs.scandir-2.1.5.tgz", @@ -3757,9 +3768,9 @@ } }, "node_modules/@smithy/types": { - "version": "4.13.1", - "resolved": "https://registry.npmjs.org/@smithy/types/-/types-4.13.1.tgz", - "integrity": "sha512-787F3yzE2UiJIQ+wYW1CVg2odHjmaWLGksnKQHUrK/lYZSEcy1msuLVvxaR/sI2/aDe9U+TBuLsXnr3vod1g0g==", + "version": "4.14.1", + "resolved": "https://registry.npmjs.org/@smithy/types/-/types-4.14.1.tgz", + "integrity": "sha512-59b5HtSVrVR/eYNei3BUj3DCPKD/G7EtDDe7OEJE7i7FtQFugYo6MxbotS8mVJkLNVf8gYaAlEBwwtJ9HzhWSg==", "dev": true, "license": "Apache-2.0", "dependencies": { @@ -4244,7 +4255,6 @@ "integrity": "sha512-wGA0NX93b19/dZC1J18tKWVIYWyyF2ZjT9vin/NRu0qzzvfVzWjs04iq2rQ3H65vCTQYlRqs3YHfY7zjdV+9Kw==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@types/body-parser": "*", "@types/express-serve-static-core": "^5.0.0", @@ -4405,7 +4415,6 @@ "resolved": "https://registry.npmjs.org/@types/node/-/node-24.9.1.tgz", "integrity": "sha512-QoiaXANRkSXK6p0Duvt56W208du4P9Uye9hWLWgGMDTEoKPhuenzNcC4vGUmrNkiOKTlIrBoyNQYNpSwfEZXSg==", "license": "MIT", - "peer": true, "dependencies": { "undici-types": "~7.16.0" } @@ -4643,7 +4652,6 @@ "integrity": "sha512-klQbnPAAiGYFyI02+znpBRLyjL4/BrBd0nyWkdC0s/6xFLkXYQ8OoRrSkqacS1ddVxf/LDyODIKbQ5TgKAf/Fg==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@typescript-eslint/scope-manager": "8.56.1", "@typescript-eslint/types": "8.56.1", @@ -5257,7 +5265,6 @@ "integrity": "sha512-NZyJarBfL7nWwIq+FDL6Zp/yHEhePMNnnJ0y3qfieCrmNvYct8uvtiV41UvlSe6apAfk0fY1FbWx+NwfmpvtTg==", "dev": true, "license": "MIT", - "peer": true, "bin": { "acorn": "bin/acorn" }, @@ -5836,7 +5843,6 @@ } ], "license": "MIT", - "peer": true, "dependencies": { "baseline-browser-mapping": "^2.8.19", "caniuse-lite": "^1.0.30001751", @@ -6730,7 +6736,6 @@ "resolved": "https://registry.npmjs.org/cssnano/-/cssnano-7.1.1.tgz", "integrity": "sha512-fm4D8ti0dQmFPeF8DXSAA//btEmqCOgAc/9Oa3C1LW94h5usNrJEfrON7b4FkPZgnDEn6OUs5NdxiJZmAtGOpQ==", "license": "MIT", - "peer": true, "dependencies": { "cssnano-preset-default": "^7.0.9", "lilconfig": "^3.1.3" @@ -7495,7 +7500,6 @@ "integrity": "sha512-t5aPOpmtJcZcz5UJyY2GbvpDlsK5E8JqRqoKtfiKE3cNh437KIqfJr3A3AKf5k64NPx6d0G3dno6XDY05PqPtw==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@eslint-community/eslint-utils": "^4.8.0", "@eslint-community/regexpp": "^4.12.1", @@ -7857,7 +7861,6 @@ "resolved": "https://registry.npmjs.org/express/-/express-4.22.1.tgz", "integrity": "sha512-F2X8g9P1X7uCPZMA3MVf9wcTqlyNp7IhH5qPCI0izhaOIYXaW9L535tGA3qmjRzpH+bZczqq7hVKxTR4NWnu+g==", "license": "MIT", - "peer": true, "dependencies": { "accepts": "~1.3.8", "array-flatten": "1.1.1", @@ -7900,12 +7903,12 @@ } }, "node_modules/express-rate-limit": { - "version": "8.3.2", - "resolved": "https://registry.npmjs.org/express-rate-limit/-/express-rate-limit-8.3.2.tgz", - "integrity": "sha512-77VmFeJkO0/rvimEDuUC5H30oqUC4EyOhyGccfqoLebB0oiEYfM7nwPrsDsBL1gsTpwfzX8SFy2MT3TDyRq+bg==", + "version": "8.5.1", + "resolved": "https://registry.npmjs.org/express-rate-limit/-/express-rate-limit-8.5.1.tgz", + "integrity": "sha512-5O6KYmyJEpuPJV5hNTXKbAHWRqrzyu+OI3vUnSd2kXFubIVpG7ezpgxQy76Zo5GQZtrQBg86hF+CM/NX+cioiQ==", "license": "MIT", "dependencies": { - "ip-address": "10.1.0" + "ip-address": "^10.2.0" }, "engines": { "node": ">= 16" @@ -7990,9 +7993,9 @@ "license": "MIT" }, "node_modules/fast-xml-builder": { - "version": "1.1.4", - "resolved": "https://registry.npmjs.org/fast-xml-builder/-/fast-xml-builder-1.1.4.tgz", - "integrity": "sha512-f2jhpN4Eccy0/Uz9csxh3Nu6q4ErKxf0XIsasomfOihuSUa3/xw6w8dnOtCDgEItQFJG8KyXPzQXzcODDrrbOg==", + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/fast-xml-builder/-/fast-xml-builder-1.2.0.tgz", + "integrity": "sha512-00aAWieqff+ZJhsXA4g1g7M8k+7AYoMUUHF+/zFb5U6Uv/P0Vl4QZo84/IcufzYalLuEj9928bXN9PbbFzMF0Q==", "dev": true, "funding": [ { @@ -8002,13 +8005,14 @@ ], "license": "MIT", "dependencies": { - "path-expression-matcher": "^1.1.3" + "path-expression-matcher": "^1.5.0", + "xml-naming": "^0.1.0" } }, "node_modules/fast-xml-parser": { - "version": "5.5.8", - "resolved": "https://registry.npmjs.org/fast-xml-parser/-/fast-xml-parser-5.5.8.tgz", - "integrity": "sha512-Z7Fh2nVQSb2d+poDViM063ix2ZGt9jmY1nWhPfHBOK2Hgnb/OW3P4Et3P/81SEej0J7QbWtJqxO05h8QYfK7LQ==", + "version": "5.7.2", + "resolved": "https://registry.npmjs.org/fast-xml-parser/-/fast-xml-parser-5.7.2.tgz", + "integrity": "sha512-P7oW7tLbYnhOLQk/Gv7cZgzgMPP/XN03K02/Jy6Y/NHzyIAIpxuZIM/YqAkfiXFPxA2CTm7NtCijK9EDu09u2w==", "dev": true, "funding": [ { @@ -8018,9 +8022,10 @@ ], "license": "MIT", "dependencies": { - "fast-xml-builder": "^1.1.4", - "path-expression-matcher": "^1.2.0", - "strnum": "^2.2.0" + "@nodable/entities": "^2.1.0", + "fast-xml-builder": "^1.1.5", + "path-expression-matcher": "^1.5.0", + "strnum": "^2.2.3" }, "bin": { "fxparser": "src/cli/cli.js" @@ -9097,9 +9102,9 @@ } }, "node_modules/ip-address": { - "version": "10.1.0", - "resolved": "https://registry.npmjs.org/ip-address/-/ip-address-10.1.0.tgz", - "integrity": "sha512-XXADHxXmvT9+CRxhXg56LJovE+bmWnEWB78LB83VZTprKTmaC5QfruXocxzTZ2Kl0DNwKuBdlIhjL8LeY8Sf8Q==", + "version": "10.2.0", + "resolved": "https://registry.npmjs.org/ip-address/-/ip-address-10.2.0.tgz", + "integrity": "sha512-/+S6j4E9AHvW9SWMSEY9Xfy66O5PWvVEJ08O0y5JGyEKQpojb0K0GKpz/v5HJ/G0vi3D2sjGK78119oXZeE0qA==", "license": "MIT", "engines": { "node": ">= 12" @@ -9526,7 +9531,6 @@ "integrity": "sha512-F26gjC0yWN8uAA5m5Ss8ZQf5nDHWGlN/xWZIh8S5SRbsEKBovwZhxGd6LJlbZYxBgCYOtreSUyb8hpXyGC5O4A==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@jest/core": "30.2.0", "@jest/types": "30.2.0", @@ -11543,9 +11547,9 @@ } }, "node_modules/mongoose": { - "version": "8.19.2", - "resolved": "https://registry.npmjs.org/mongoose/-/mongoose-8.19.2.tgz", - "integrity": "sha512-ww2T4dBV+suCbOfG5YPwj9pLCfUVyj8FEA1D3Ux1HHqutpLxGyOYEPU06iPRBW4cKr3PJfOSYsIpHWPTkz5zig==", + "version": "8.23.1", + "resolved": "https://registry.npmjs.org/mongoose/-/mongoose-8.23.1.tgz", + "integrity": "sha512-gHSPD8qEwRmiXapK17hEnFWZdcFENMegHTcw5XIIg2+7R8eXQvdwSiMpD/A2oG8tKzFLLHyRXd8/eaDPAVwZgQ==", "license": "MIT", "dependencies": { "bson": "^6.10.4", @@ -12154,9 +12158,9 @@ } }, "node_modules/path-expression-matcher": { - "version": "1.2.1", - "resolved": "https://registry.npmjs.org/path-expression-matcher/-/path-expression-matcher-1.2.1.tgz", - "integrity": "sha512-d7gQQmLvAKXKXE2GeP9apIGbMYKz88zWdsn/BN2HRWVQsDFdUY36WSLTY0Jvd4HWi7Fb30gQ62oAOzdgJA6fZw==", + "version": "1.5.0", + "resolved": "https://registry.npmjs.org/path-expression-matcher/-/path-expression-matcher-1.5.0.tgz", + "integrity": "sha512-cbrerZV+6rvdQrrD+iGMcZFEiiSrbv9Tfdkvnusy6y0x0GKBXREFg/Y65GhIfm0tnLntThhzCnfKwp1WRjeCyQ==", "dev": true, "funding": [ { @@ -12371,7 +12375,6 @@ } ], "license": "MIT", - "peer": true, "dependencies": { "nanoid": "^3.3.11", "picocolors": "^1.1.1", @@ -14210,9 +14213,9 @@ } }, "node_modules/strnum": { - "version": "2.2.2", - "resolved": "https://registry.npmjs.org/strnum/-/strnum-2.2.2.tgz", - "integrity": "sha512-DnR90I+jtXNSTXWdwrEy9FakW7UX+qUZg28gj5fk2vxxl7uS/3bpI4fjFYVmdK9etptYBPNkpahuQnEwhwECqA==", + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/strnum/-/strnum-2.3.0.tgz", + "integrity": "sha512-ums3KNd42PGyx5xaoVTO1mjU1bH3NpY4vsrVlnv9PNGqQj8wd7rJ6nEypLrJ7z5vxK5RP0yMLo6J/Gsm62DI5Q==", "dev": true, "funding": [ { @@ -14394,7 +14397,6 @@ "integrity": "sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==", "dev": true, "license": "MIT", - "peer": true, "engines": { "node": ">=12" }, @@ -14563,7 +14565,6 @@ "integrity": "sha512-f0FFpIdcHgn8zcPSbf1dRevwt047YMnaiJM3u2w2RewrB+fob/zePZcrOyQoLMMO7aBIddLcQIEK5dYjkLnGrQ==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@cspotcode/source-map-support": "^0.8.0", "@tsconfig/node10": "^1.0.7", @@ -14727,7 +14728,6 @@ "integrity": "sha512-CWBzXQrc/qOkhidw1OzBTQuYRbfyxDXJMVJ1XNwUHGROVmuaeiEm3OslpZ1RV96d7SKKjZKrSJu3+t/xlw3R9A==", "devOptional": true, "license": "Apache-2.0", - "peer": true, "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" @@ -15390,6 +15390,22 @@ "node": ">=18" } }, + "node_modules/xml-naming": { + "version": "0.1.0", + "resolved": "https://registry.npmjs.org/xml-naming/-/xml-naming-0.1.0.tgz", + "integrity": "sha512-k8KO9hrMyNk6tUWqUfkTEZbezRRpONVOzUTnc97VnCvyj6Tf9lyUR9EDAIeiVLv56jsMcoXEwjW8Kv5yPY52lw==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/NaturalIntelligence" + } + ], + "license": "MIT", + "engines": { + "node": ">=16.0.0" + } + }, "node_modules/xmlbuilder": { "version": "8.2.2", "resolved": "https://registry.npmjs.org/xmlbuilder/-/xmlbuilder-8.2.2.tgz", @@ -15562,7 +15578,6 @@ "resolved": "https://registry.npmjs.org/zod/-/zod-4.3.6.tgz", "integrity": "sha512-rftlrkhHZOcjDwkGlnUtZZkvaPHCsDATp4pGpuOOMDaTdDDXF91wuVDJoWoPsKX/3YPQ5fHuF3STjcYyKr+Qhg==", "license": "MIT", - "peer": true, "funding": { "url": "https://github.com/sponsors/colinhacks" } diff --git a/server/package.json b/server/package.json index ea581be1a..958cb1eff 100755 --- a/server/package.json +++ b/server/package.json @@ -41,7 +41,7 @@ "dockerode": "4.0.6", "dotenv": "^16.4.5", "express": "^4.19.2", - "express-rate-limit": "8.3.2", + "express-rate-limit": "^8.5.1", "gamedig": "5.2.0", "got": "14.4.7", "handlebars": "^4.7.8", From 2f646ee154a28b24c528e7244478d982e21f0e3c Mon Sep 17 00:00:00 2001 From: Alex Date: Mon, 11 May 2026 11:37:23 -0700 Subject: [PATCH 099/245] fix(husky): guard pre-commit against merge/rebase state MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit lint-staged uses git stash internally to isolate staged files for linting. During a merge/rebase/cherry-pick/revert, that stash machinery interacts catastrophically with MERGE_HEAD and friends — on any lint failure it resets, silently wiping MERGE_HEAD and your conflict resolutions. The user ends up in a loop where every merge attempt leaves a clean tree with no merge commit. This adds an early-exit guard for the four mid-operation states, and also anchors the script to the repo root + scopes each lint-staged run in a subshell so a subdir commit or per-package failure can't poison the cwd for the next step. Co-Authored-By: Claude Opus 4.7 (1M context) --- .husky/pre-commit | 23 +++++++++++++++++++++-- 1 file changed, 21 insertions(+), 2 deletions(-) mode change 100644 => 100755 .husky/pre-commit diff --git a/.husky/pre-commit b/.husky/pre-commit old mode 100644 new mode 100755 index dac57bbf1..efabe6826 --- a/.husky/pre-commit +++ b/.husky/pre-commit @@ -1,4 +1,23 @@ #!/bin/sh + +# Skip during merge/rebase/cherry-pick/revert. lint-staged's internal +# git-stash mechanism is incompatible with these states and will silently +# wipe MERGE_HEAD and your conflict resolutions on commit failure. +git_dir=$(git rev-parse --git-dir) +if [ -e "$git_dir/MERGE_HEAD" ] \ + || [ -e "$git_dir/CHERRY_PICK_HEAD" ] \ + || [ -e "$git_dir/REVERT_HEAD" ] \ + || [ -d "$git_dir/rebase-merge" ] \ + || [ -d "$git_dir/rebase-apply" ]; then + echo "Skipping lint-staged (mid merge/rebase/cherry-pick/revert)" + exit 0 +fi + +# Anchor to repo root so commits made from a subdirectory still work, and +# run each package's lint-staged in a subshell so cwd is scoped (a failure +# inside one package can't poison the cwd for the next). +cd "$(git rev-parse --show-toplevel)" || exit 1 + echo "Running lint-staged..." -cd client && npx lint-staged && cd .. -cd server && npx lint-staged && cd .. \ No newline at end of file +(cd client && npx lint-staged) || exit 1 +(cd server && npx lint-staged) || exit 1 From 6f0540fb33f9abd9e4a214b08406268e51075804 Mon Sep 17 00:00:00 2001 From: Alex Holliday Date: Tue, 12 May 2026 09:41:58 -0700 Subject: [PATCH 100/245] properly type payload, avgResponseTime -> originalAvgResponseTime --- .../monitors/charts/HistogramDetails.tsx | 15 ++++++++------- 1 file changed, 8 insertions(+), 7 deletions(-) diff --git a/client/src/Components/monitors/charts/HistogramDetails.tsx b/client/src/Components/monitors/charts/HistogramDetails.tsx index c2f75aa7b..bd78f3289 100644 --- a/client/src/Components/monitors/charts/HistogramDetails.tsx +++ b/client/src/Components/monitors/charts/HistogramDetails.tsx @@ -9,6 +9,8 @@ import { ResponsiveContainer, Text, } from "recharts"; +import type { TooltipProps } from "recharts"; +import type { NameType, ValueType } from "recharts/types/component/DefaultTooltipContent"; import Typography from "@mui/material/Typography"; import { useSelector } from "react-redux"; @@ -18,13 +20,14 @@ import { tooltipDateFormatLookup, } from "@/Utils/TimeUtils"; import { useTheme } from "@mui/material/styles"; +import type { Theme } from "@mui/material/styles"; import type { GroupedCheck } from "@/Types/Check"; import type { RootState } from "@/Types/state"; type XTickProps = { x: number; y: number; - payload: { value: any }; + payload: { value: string }; range: string; }; @@ -46,12 +49,9 @@ export const XTick = ({ x, y, payload, range }: XTickProps) => { ); }; -type ResponseTimeToolTipProps = { - active?: boolean | undefined; - payload?: any[]; - label?: string | number; +type ResponseTimeToolTipProps = TooltipProps & { range: string; - theme: any; + theme: Theme; uiTimezone: string; }; @@ -68,7 +68,8 @@ const ResponseTimeToolTip = ({ if (!active) return null; const format = tooltipDateFormatLookup(range); - const responseTime = Math.floor(payload?.[0]?.payload?.avgResponseTime || 0); + console.log(payload); + const responseTime = Math.floor(payload[0]?.payload?.originalAvgResponseTime || 0); return ( {formatDateWithTz(String(label), format, uiTimezone)} From bdecc1a5e24ac942afdde5b07c5d513ae4fa7306 Mon Sep 17 00:00:00 2001 From: Alex Holliday Date: Tue, 12 May 2026 10:37:59 -0700 Subject: [PATCH 101/245] update maintenance window model to use array of IDs instead of ID --- server/src/db/models/MaintenanceWindow.ts | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/server/src/db/models/MaintenanceWindow.ts b/server/src/db/models/MaintenanceWindow.ts index 703c144c8..654ae50dd 100644 --- a/server/src/db/models/MaintenanceWindow.ts +++ b/server/src/db/models/MaintenanceWindow.ts @@ -1,8 +1,8 @@ import { Schema, model, type Types } from "mongoose"; import { DurationUnits, type MaintenanceWindow } from "@/types/maintenanceWindow.js"; -type MaintenanceWindowDocumentBase = Omit & { - monitorId: Types.ObjectId; +type MaintenanceWindowDocumentBase = Omit & { + monitorIds: Types.ObjectId[]; teamId: Types.ObjectId; start: Date; end: Date; @@ -17,10 +17,10 @@ interface MaintenanceWindowDocument extends MaintenanceWindowDocumentBase { const MaintenanceWindowSchema = new Schema( { - monitorId: { - type: Schema.Types.ObjectId, + monitorIds: { + type: [Schema.Types.ObjectId], ref: "Monitor", - immutable: true, + index: true, }, teamId: { type: Schema.Types.ObjectId, From c90442b49fac7bd256bf3c7019e109be9ed8cb41 Mon Sep 17 00:00:00 2001 From: Alex Holliday Date: Tue, 12 May 2026 10:38:22 -0700 Subject: [PATCH 102/245] update maintenance window type to match DB model --- server/src/types/maintenanceWindow.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/server/src/types/maintenanceWindow.ts b/server/src/types/maintenanceWindow.ts index 950dc1713..4fc446dda 100644 --- a/server/src/types/maintenanceWindow.ts +++ b/server/src/types/maintenanceWindow.ts @@ -3,7 +3,7 @@ export type DurationUnit = (typeof DurationUnits)[number]; export interface MaintenanceWindow { id: string; - monitorId: string; + monitorIds: string[]; teamId: string; active: boolean; name: string; From 7a2dab6cc47ce4c18b37d60df4f2859d79ccb817 Mon Sep 17 00:00:00 2001 From: Alex Holliday Date: Tue, 12 May 2026 10:38:47 -0700 Subject: [PATCH 103/245] update repository toEntity method --- .../maintenance-windows/MongoMaintenanceWindowsRepository.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/server/src/repositories/maintenance-windows/MongoMaintenanceWindowsRepository.ts b/server/src/repositories/maintenance-windows/MongoMaintenanceWindowsRepository.ts index ad96328c5..635ddc2de 100644 --- a/server/src/repositories/maintenance-windows/MongoMaintenanceWindowsRepository.ts +++ b/server/src/repositories/maintenance-windows/MongoMaintenanceWindowsRepository.ts @@ -29,7 +29,7 @@ class MongoMaintenanceWindowsRepository implements IMaintenanceWindowsRepository private toEntity = (doc: MaintenanceWindowDocument): MaintenanceWindow => { return { id: this.toStringId(doc._id), - monitorId: this.toStringId(doc.monitorId), + monitorIds: doc.monitorIds.map(this.toStringId), teamId: this.toStringId(doc.teamId), active: doc.active, name: doc.name, @@ -67,7 +67,7 @@ class MongoMaintenanceWindowsRepository implements IMaintenanceWindowsRepository findByMonitorId = async (monitorId: string, teamId: string): Promise => { const maintenanceWindows = await MaintenanceWindowModel.find({ - monitorId: monitorId, + monitorIds: monitorId, teamId: teamId, }); return this.mapDocuments(maintenanceWindows); From e78524d1c9b31525b4425cb6cf5ac0ef7bd8eb8a Mon Sep 17 00:00:00 2001 From: Alex Holliday Date: Tue, 12 May 2026 10:39:05 -0700 Subject: [PATCH 104/245] udpate service to use monitorIds instead of monitorId --- .../business/maintenanceWindowService.ts | 58 ++++++++++++++----- 1 file changed, 42 insertions(+), 16 deletions(-) diff --git a/server/src/service/business/maintenanceWindowService.ts b/server/src/service/business/maintenanceWindowService.ts index 50278d215..e56ad3757 100644 --- a/server/src/service/business/maintenanceWindowService.ts +++ b/server/src/service/business/maintenanceWindowService.ts @@ -27,7 +27,11 @@ export interface IMaintenanceWindowService { }): Promise<{ maintenanceWindows: MaintenanceWindow[]; maintenanceWindowCount: number }>; getMaintenanceWindowsByMonitorId(params: { monitorId: string; teamId: string }): Promise; deleteMaintenanceWindow(params: { id: string; teamId: string }): Promise; - editMaintenanceWindow(params: { id: string; teamId: string; body: Partial }): Promise; + editMaintenanceWindow(params: { + id: string; + teamId: string; + body: Partial> & { monitors?: string[] }; + }): Promise; } export class MaintenanceWindowService implements IMaintenanceWindowService { @@ -84,20 +88,17 @@ export class MaintenanceWindowService implements IMaintenanceWindowService { }); } - const dbTransactions = monitorIDs.map((monitorId: string) => { - return this.maintenanceWindowsRepository.create({ - teamId, - monitorId, - name: name, - active: active, - duration: duration, - durationUnit: durationUnit, - repeat: repeat, - start: start, - end: end, - }); + await this.maintenanceWindowsRepository.create({ + teamId, + monitorIds: monitorIDs, + name: name, + active: active, + duration: duration, + durationUnit: durationUnit, + repeat: repeat, + start: start, + end: end, }); - await Promise.all(dbTransactions); }; getMaintenanceWindowById = async ({ id, teamId }: { id: string; teamId: string }) => { @@ -135,7 +136,32 @@ export class MaintenanceWindowService implements IMaintenanceWindowService { return await this.maintenanceWindowsRepository.deleteById(id, teamId); }; - editMaintenanceWindow = async ({ id, teamId, body }: { id: string; teamId: string; body: Partial }) => { - return await this.maintenanceWindowsRepository.updateById(id, teamId, body); + editMaintenanceWindow = async ({ + id, + teamId, + body, + }: { + id: string; + teamId: string; + body: Partial> & { monitors?: string[] }; + }) => { + const { monitors, ...rest } = body; + const update: Partial = rest; + + if (monitors !== undefined) { + const monitorDocs = await this.monitorsRepository.findByIds(monitors); + const unauthorizedMonitors = monitorDocs.filter((monitor) => monitor.teamId !== teamId); + if (unauthorizedMonitors.length > 0) { + throw new AppError({ + message: "Unauthorized to edit maintenance window for one or more monitors", + service: SERVICE_NAME, + method: "editMaintenanceWindow", + status: 403, + }); + } + update.monitorIds = monitors; + } + + return await this.maintenanceWindowsRepository.updateById(id, teamId, update); }; } From f86ceae0c68ad7a21f0e0eff23035dbbc53a7faf Mon Sep 17 00:00:00 2001 From: Alex Holliday Date: Tue, 12 May 2026 10:41:30 -0700 Subject: [PATCH 105/245] update tests --- .../heartbeatJobMaintenance.test.ts | 19 +++++- .../services/maintenanceWindowService.test.ts | 66 +++++++++++++++++-- 2 files changed, 78 insertions(+), 7 deletions(-) diff --git a/server/test/integration/heartbeatJobMaintenance.test.ts b/server/test/integration/heartbeatJobMaintenance.test.ts index 663c2e61e..5df287242 100644 --- a/server/test/integration/heartbeatJobMaintenance.test.ts +++ b/server/test/integration/heartbeatJobMaintenance.test.ts @@ -8,7 +8,7 @@ const makeMaintenanceWindow = (overrides?: Partial): Maintena const end = new Date(now.getTime() + 60 * 60 * 1000); // 1 hour from now return { id: "mw-1", - monitorId: "mon-1", + monitorIds: ["mon-1"], teamId: "team-1", active: true, name: "Test Maintenance", @@ -109,6 +109,23 @@ describe("Heartbeat job: maintenance windows", () => { expect(h.bufferStub.addToBuffer).toHaveBeenCalledTimes(1); }); + it("skips checks for every monitor covered by a group window", async () => { + const monitorA = makeMonitor({ id: "mon-a", name: "Monitor A" }); + const monitorB = makeMonitor({ id: "mon-b", name: "Monitor B" }); + h.monitorsRepo.seed(monitorA); + h.monitorsRepo.seed(monitorB); + + // A single group window covers both monitors + h.maintenanceWindowsRepo.findByMonitorId.mockResolvedValue([makeMaintenanceWindow({ monitorIds: ["mon-a", "mon-b"] })]); + + await h.heartbeatJob(monitorA); + await h.heartbeatJob(monitorB); + + expect((await h.monitorsRepo.findById("mon-a", "team-1")).status).toBe("maintenance"); + expect((await h.monitorsRepo.findById("mon-b", "team-1")).status).toBe("maintenance"); + expect(h.networkService.requestStatus).not.toHaveBeenCalled(); + }); + it("skips checks when window is in the past (expired)", async () => { const monitor = makeMonitor(); h.monitorsRepo.seed(monitor); diff --git a/server/test/unit/services/maintenanceWindowService.test.ts b/server/test/unit/services/maintenanceWindowService.test.ts index 302ff128c..5ce64238a 100644 --- a/server/test/unit/services/maintenanceWindowService.test.ts +++ b/server/test/unit/services/maintenanceWindowService.test.ts @@ -37,7 +37,7 @@ const createService = (overrides?: { const makeWindow = (overrides?: Partial): MaintenanceWindow => ({ id: "mw-1", - monitorId: "mon-1", + monitorIds: ["mon-1"], teamId: "team-1", active: true, name: "Scheduled Maintenance", @@ -80,16 +80,16 @@ describe("MaintenanceWindowService", () => { // ── createMaintenanceWindow ────────────────────────────────────────────── describe("createMaintenanceWindow", () => { - it("creates a maintenance window for each monitor", async () => { + it("creates a single maintenance window covering all monitor IDs", async () => { const { service, maintenanceWindowsRepository } = createService(); await service.createMaintenanceWindow(defaultCreateParams); - expect(maintenanceWindowsRepository.create).toHaveBeenCalledTimes(2); + expect(maintenanceWindowsRepository.create).toHaveBeenCalledTimes(1); expect(maintenanceWindowsRepository.create).toHaveBeenCalledWith( expect.objectContaining({ teamId: "team-1", - monitorId: "mon-1", + monitorIds: ["mon-1", "mon-2"], name: "Scheduled Maintenance", active: true, duration: 60, @@ -99,7 +99,6 @@ describe("MaintenanceWindowService", () => { end: "2026-04-10T03:00:00Z", }) ); - expect(maintenanceWindowsRepository.create).toHaveBeenCalledWith(expect.objectContaining({ monitorId: "mon-2" })); }); it("verifies monitor ownership via findByIds", async () => { @@ -130,12 +129,13 @@ describe("MaintenanceWindowService", () => { await expect(service.createMaintenanceWindow(defaultCreateParams)).resolves.toBeUndefined(); }); - it("creates a single window when monitorIDs has one entry", async () => { + it("passes a single-element monitorIds array when only one monitor is provided", async () => { const { service, maintenanceWindowsRepository } = createService(); await service.createMaintenanceWindow({ ...defaultCreateParams, monitorIDs: ["mon-1"] }); expect(maintenanceWindowsRepository.create).toHaveBeenCalledTimes(1); + expect(maintenanceWindowsRepository.create).toHaveBeenCalledWith(expect.objectContaining({ monitorIds: ["mon-1"] })); }); }); @@ -248,5 +248,59 @@ describe("MaintenanceWindowService", () => { expect(maintenanceWindowsRepository.updateById).toHaveBeenCalledWith("mw-1", "team-1", { active: false, repeat: 3600000 }); }); + + it("maps the monitors body field to monitorIds when calling the repository", async () => { + const { service, maintenanceWindowsRepository } = createService(); + + await service.editMaintenanceWindow({ + id: "mw-1", + teamId: "team-1", + body: { monitors: ["mon-1", "mon-2"] }, + }); + + expect(maintenanceWindowsRepository.updateById).toHaveBeenCalledWith("mw-1", "team-1", { monitorIds: ["mon-1", "mon-2"] }); + }); + + it("verifies monitor ownership when monitors is in the body", async () => { + const { service, monitorsRepository } = createService(); + + await service.editMaintenanceWindow({ + id: "mw-1", + teamId: "team-1", + body: { monitors: ["mon-1", "mon-2"] }, + }); + + expect(monitorsRepository.findByIds).toHaveBeenCalledWith(["mon-1", "mon-2"]); + }); + + it("throws 403 when a new monitor belongs to a different team", async () => { + const monitorsRepository = createMonitorsRepo(); + (monitorsRepository.findByIds as jest.Mock).mockResolvedValue([ + { id: "mon-1", teamId: "team-1" }, + { id: "mon-2", teamId: "team-other" }, + ]); + const { service, maintenanceWindowsRepository } = createService({ monitorsRepository }); + + await expect( + service.editMaintenanceWindow({ + id: "mw-1", + teamId: "team-1", + body: { monitors: ["mon-1", "mon-2"] }, + }) + ).rejects.toThrow("Unauthorized to edit maintenance window for one or more monitors"); + expect(maintenanceWindowsRepository.updateById).not.toHaveBeenCalled(); + }); + + it("does not call findByIds when monitors is not in the body", async () => { + const { service, monitorsRepository } = createService(); + + await service.editMaintenanceWindow({ + id: "mw-1", + teamId: "team-1", + body: { name: "Updated Name" }, + }); + + expect(monitorsRepository.findByIds).not.toHaveBeenCalled(); + }); }); }); From 780189e4a14608b461c6abd790d2a4df983bc1cd Mon Sep 17 00:00:00 2001 From: Alex Holliday Date: Tue, 12 May 2026 10:53:40 -0700 Subject: [PATCH 106/245] add migrations --- ...igrateMaintenanceWindowMonitorIdToArray.ts | 111 ++++++++++++++++++ server/src/db/migration/index.ts | 2 + 2 files changed, 113 insertions(+) create mode 100644 server/src/db/migration/0007_migrateMaintenanceWindowMonitorIdToArray.ts diff --git a/server/src/db/migration/0007_migrateMaintenanceWindowMonitorIdToArray.ts b/server/src/db/migration/0007_migrateMaintenanceWindowMonitorIdToArray.ts new file mode 100644 index 000000000..a60b8786a --- /dev/null +++ b/server/src/db/migration/0007_migrateMaintenanceWindowMonitorIdToArray.ts @@ -0,0 +1,111 @@ +import mongoose from "mongoose"; +import type { AnyBulkWriteOperation, Document } from "mongodb"; +import { logger } from "@/utils/logger.js"; +import MaintenanceWindowModel from "../models/MaintenanceWindow.js"; + +type GroupResult = { + _id: { + teamId: mongoose.Types.ObjectId; + name: string; + start: Date; + end: Date; + repeat: number; + durationUnit: string; + duration: number; + active: boolean; + }; + docIds: mongoose.Types.ObjectId[]; + monitorIds: mongoose.Types.ObjectId[]; +}; + +export async function migrateMaintenanceWindowMonitorIdToArray(): Promise { + const SERVICE_NAME = "Migration:MigrateMaintenanceWindowMonitorIdToArray"; + + try { + logger.info({ + service: SERVICE_NAME, + message: "Starting migration of MaintenanceWindow.monitorId to monitorIds array (eager merge)", + }); + + const db = mongoose.connection.db; + if (!db) { + throw new Error("Database connection is not initialized"); + } + + // Group old-style docs by their scheduling key. + // Skip rows that already have a monitorIds array — those are either already migrated + // or in a partially-migrated state that needs manual review. + const groups: GroupResult[] = await MaintenanceWindowModel.aggregate([ + { + $match: { + monitorId: { $exists: true, $ne: null }, + monitorIds: { $exists: false }, + }, + }, + { + $group: { + _id: { + teamId: "$teamId", + name: "$name", + start: "$start", + end: "$end", + repeat: "$repeat", + durationUnit: "$durationUnit", + duration: "$duration", + active: "$active", + }, + docIds: { $push: "$_id" }, + monitorIds: { $addToSet: "$monitorId" }, + }, + }, + ]); + + if (groups.length === 0) { + logger.info({ service: SERVICE_NAME, message: "No MaintenanceWindow documents needed migration" }); + return; + } + + const bulkOps: AnyBulkWriteOperation[] = []; + let originalDocCount = 0; + + for (const group of groups) { + const { docIds, monitorIds } = group; + originalDocCount += docIds.length; + const [survivorId, ...duplicateIds] = docIds; + + bulkOps.push({ + updateOne: { + filter: { _id: survivorId }, + update: { + $set: { monitorIds }, + $unset: { monitorId: "" }, + }, + }, + }); + + if (duplicateIds.length > 0) { + bulkOps.push({ + deleteMany: { + filter: { _id: { $in: duplicateIds } }, + }, + }); + } + } + + const result = await MaintenanceWindowModel.collection.bulkWrite(bulkOps, { ordered: true }); + const deletedCount = originalDocCount - groups.length; + + logger.info({ + service: SERVICE_NAME, + message: `Migration complete. Consolidated ${originalDocCount} old document(s) into ${groups.length} group window(s); deleted ${deletedCount} duplicate(s).`, + }); + logger.debug({ + service: SERVICE_NAME, + message: `bulkWrite result: modified=${result.modifiedCount}, deleted=${result.deletedCount}`, + }); + } catch (error) { + const errorMessage = error instanceof Error ? error.message : String(error); + logger.error({ service: SERVICE_NAME, message: `Error during MaintenanceWindow migration: ${errorMessage}` }); + throw error; + } +} diff --git a/server/src/db/migration/index.ts b/server/src/db/migration/index.ts index 5b74a96fc..61785118e 100644 --- a/server/src/db/migration/index.ts +++ b/server/src/db/migration/index.ts @@ -5,6 +5,7 @@ import { fixInfrastructureThresholds } from "./0004_fixInfrastructureThresholds. import MigrationModel from "../models/Migration.js"; import { migrateStatusPageTypeToArray } from "./0005_migrateStatusPageTypeToArray.js"; import { cleanupDuplicateMonitorStatsForUniqueIndex } from "./0006_cleanupDuplicateMonitorStatsForUniqueIndex.js"; +import { migrateMaintenanceWindowMonitorIdToArray } from "./0007_migrateMaintenanceWindowMonitorIdToArray.js"; import type { ILogger } from "@/utils/logger.js"; type MigrationEntry = { @@ -19,6 +20,7 @@ const migrations: MigrationEntry[] = [ { name: "0004_fixInfrastructureThresholds", execute: fixInfrastructureThresholds }, { name: "0005_migrateStatusPageTypeToArray", execute: migrateStatusPageTypeToArray }, { name: "0006_cleanupDuplicateMonitorStatsForUniqueIndex", execute: cleanupDuplicateMonitorStatsForUniqueIndex }, + { name: "0007_migrateMaintenanceWindowMonitorIdToArray", execute: migrateMaintenanceWindowMonitorIdToArray }, ]; const runMigrations = async (logger?: ILogger) => { From 47d2b62e92e57e8883bf51c09fd1160c4fa5a324 Mon Sep 17 00:00:00 2001 From: Alex Holliday Date: Tue, 12 May 2026 10:58:03 -0700 Subject: [PATCH 107/245] update type --- client/src/Types/MaintenanceWindow.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/client/src/Types/MaintenanceWindow.ts b/client/src/Types/MaintenanceWindow.ts index bcbd075a1..dfc0bd4d4 100644 --- a/client/src/Types/MaintenanceWindow.ts +++ b/client/src/Types/MaintenanceWindow.ts @@ -2,7 +2,7 @@ export type DurationUnit = "seconds" | "minutes" | "hours" | "days"; export interface MaintenanceWindow { id: string; - monitorId: string; + monitorIds: string[]; teamId: string; active: boolean; name: string; From f7136985724c1b4ac95f30dc3b1103cff61316c1 Mon Sep 17 00:00:00 2001 From: Alex Holliday Date: Tue, 12 May 2026 11:06:26 -0700 Subject: [PATCH 108/245] update maintenacne window hook and form --- client/src/Hooks/useMaintenanceWindowForm.ts | 2 +- client/src/Pages/Maintenance/create/index.tsx | 4 ++++ 2 files changed, 5 insertions(+), 1 deletion(-) diff --git a/client/src/Hooks/useMaintenanceWindowForm.ts b/client/src/Hooks/useMaintenanceWindowForm.ts index 1f10d55ef..4f72dbd64 100644 --- a/client/src/Hooks/useMaintenanceWindowForm.ts +++ b/client/src/Hooks/useMaintenanceWindowForm.ts @@ -32,7 +32,7 @@ export const useMaintenanceWindowForm = ({ startTime: startDate.format("HH:mm"), duration: data.duration ?? 0, durationUnit: data.durationUnit ?? "minutes", - monitors: [data.monitorId], + monitors: data.monitorIds, }; } else { const now = dayjs(); diff --git a/client/src/Pages/Maintenance/create/index.tsx b/client/src/Pages/Maintenance/create/index.tsx index 44a4b9a81..20a0b461e 100644 --- a/client/src/Pages/Maintenance/create/index.tsx +++ b/client/src/Pages/Maintenance/create/index.tsx @@ -22,6 +22,7 @@ import type { MaintenanceWindowFormData } from "@/Validation/maintenanceWindow"; import { repeatOptions, durationUnitOptions } from "@/Validation/maintenanceWindow"; import { useMaintenanceWindowForm } from "@/Hooks/useMaintenanceWindowForm"; import { useGet, usePost, usePatch } from "@/Hooks/UseApi"; +import { mutate } from "swr"; import { useParams, useNavigate } from "react-router-dom"; import type { Monitor } from "@/Types/Monitor"; import { useForm, Controller } from "react-hook-form"; @@ -99,6 +100,9 @@ const CreateMaintenanceWindowPage = () => { } if (result?.success) { + await mutate( + (key) => typeof key === "string" && key.startsWith("/maintenance-window") + ); navigate("/maintenance"); } }; From b925434078d865772052d17e2f9f654140bfc270 Mon Sep 17 00:00:00 2001 From: Alex Holliday Date: Tue, 12 May 2026 11:18:51 -0700 Subject: [PATCH 109/245] tighten validation, add tests --- .../validation/maintenanceWindowValidation.ts | 2 +- .../maintenanceWindowValidation.test.ts | 20 +++++++++++++++++++ 2 files changed, 21 insertions(+), 1 deletion(-) diff --git a/server/src/validation/maintenanceWindowValidation.ts b/server/src/validation/maintenanceWindowValidation.ts index c53e39b20..837db28e8 100644 --- a/server/src/validation/maintenanceWindowValidation.ts +++ b/server/src/validation/maintenanceWindowValidation.ts @@ -72,7 +72,7 @@ export const editMaintenanceByIdWindowBodyValidation = z start: dateToString.optional(), end: dateToString.optional(), expiry: dateToString.optional(), - monitors: z.array(z.string()).optional(), + monitors: z.array(z.string()).min(1, "At least one monitor is required").optional(), duration: z.number().optional(), durationUnit: z.enum(DurationUnits).optional(), }) diff --git a/server/test/unit/validation/maintenanceWindowValidation.test.ts b/server/test/unit/validation/maintenanceWindowValidation.test.ts index dfede3670..a209ba64f 100644 --- a/server/test/unit/validation/maintenanceWindowValidation.test.ts +++ b/server/test/unit/validation/maintenanceWindowValidation.test.ts @@ -168,4 +168,24 @@ describe("editMaintenanceByIdWindowBodyValidation", () => { }); expect(result.success).toBe(true); }); + + it("accepts a body without a monitors field (monitors is optional on edit)", () => { + const result = editMaintenanceByIdWindowBodyValidation.safeParse({ name: "Updated Name" }); + expect(result.success).toBe(true); + }); + + it("accepts a body with one or more monitors", () => { + const result = editMaintenanceByIdWindowBodyValidation.safeParse({ monitors: ["mon-1"] }); + expect(result.success).toBe(true); + }); + + it("rejects an empty monitors array", () => { + const result = editMaintenanceByIdWindowBodyValidation.safeParse({ monitors: [] }); + expect(result.success).toBe(false); + if (!result.success) { + expect(result.error.issues).toEqual( + expect.arrayContaining([expect.objectContaining({ message: "At least one monitor is required", path: ["monitors"] })]) + ); + } + }); }); From b7844f8b101a2948ecf2f17ee351868404b4c653 Mon Sep 17 00:00:00 2001 From: Alex Holliday Date: Wed, 13 May 2026 11:26:25 -0700 Subject: [PATCH 110/245] update query --- server/src/repositories/monitors/MongoMonitorsRepository.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/server/src/repositories/monitors/MongoMonitorsRepository.ts b/server/src/repositories/monitors/MongoMonitorsRepository.ts index d814c3348..3ad27b227 100644 --- a/server/src/repositories/monitors/MongoMonitorsRepository.ts +++ b/server/src/repositories/monitors/MongoMonitorsRepository.ts @@ -108,7 +108,7 @@ class MongoMonitorsRepository implements IMonitorsRepository { $lookup: { from: "maintenancewindows", let: { monitorId: "$_id" }, - pipeline: [{ $match: { $expr: { $eq: ["$monitorId", "$$monitorId"] } } }], + pipeline: [{ $match: { $expr: { $in: ["$$monitorId", { $ifNull: ["$monitorIds", []] }] } } }], as: "maintenanceWindows", }, }, From bc7b3a5ffb7fde1ff9a9ed6367c403426a82697f Mon Sep 17 00:00:00 2001 From: gorkem-bwl Date: Thu, 14 May 2026 07:36:37 -0400 Subject: [PATCH 111/245] fix(i18n): close translation gaps across 16 locales ## Changes - Add 59 missing keys to every non-en locale (DNS monitor strings, status-page themes, monitor status labels, network error messages, email sender display name) - Catalan: add 12 bulk-monitor selection keys that were introduced after ca.json was first added - Fill 188 empty string values across ar, cs, fi, fr, ja, pt-BR, tr, uk and vi - Remove 10 orphan keys from es.json that were not referenced anywhere in the client code ## Benefits - All 16 locales now report 100% structural coverage against en.json - Status page theme picker, DNS monitor type, and bulk monitor actions are fully translated - No more empty strings rendered as blank labels in production UI - es.json no longer carries dead keys diverging from the reference ## Notes - Placeholders ({{count}}, {{value}}, {{name}}) verified intact in every locale - Brand terms (DNS, A, AAAA, CNAME, MX, NS, TXT, 8.8.8.8) intentionally kept untranslated - Build, prettier format-check and JSON validity all pass --- client/src/locales/ar.json | 183 ++++++++++++++++++++++++++-------- client/src/locales/ca.json | 67 +++++++++++-- client/src/locales/cs.json | 159 +++++++++++++++++++++++------ client/src/locales/de.json | 109 ++++++++++++++++++-- client/src/locales/es.json | 123 +++++++++++++++++++---- client/src/locales/fi.json | 183 ++++++++++++++++++++++++++-------- client/src/locales/fr.json | 115 +++++++++++++++++++-- client/src/locales/ja.json | 131 ++++++++++++++++++++---- client/src/locales/pt-BR.json | 123 ++++++++++++++++++++--- client/src/locales/ru.json | 109 ++++++++++++++++++-- client/src/locales/th.json | 109 ++++++++++++++++++-- client/src/locales/tr.json | 145 ++++++++++++++++++++++----- client/src/locales/uk.json | 159 +++++++++++++++++++++++------ client/src/locales/vi.json | 159 +++++++++++++++++++++++------ client/src/locales/zh-CN.json | 109 ++++++++++++++++++-- client/src/locales/zh-TW.json | 109 ++++++++++++++++++-- 16 files changed, 1794 insertions(+), 298 deletions(-) diff --git a/client/src/locales/ar.json b/client/src/locales/ar.json index 880667e86..9df395661 100644 --- a/client/src/locales/ar.json +++ b/client/src/locales/ar.json @@ -73,7 +73,9 @@ "paused": "متوقف مؤقتاً", "na": "غير متوفر", "resolved": "تم الحل", - "responseTime": "وقت الاستجابة" + "responseTime": "وقت الاستجابة", + "idle": "خامل", + "running": "قيد التشغيل" }, "form": { "role": { @@ -120,6 +122,17 @@ "active": "نشط", "responseTime": "وقت الاستجابة" } + }, + "statusCode": { + "down": "متوقف", + "networkError": { + "dns": "تعذر تحليل اسم المضيف (فشل بحث DNS).", + "refused": "رفض الخادم الاتصال (المنفذ مغلق أو الخدمة لا تستمع).", + "reset": "تمت إعادة تعيين الاتصال قبل تلقي استجابة.", + "timeout": "لم يستجب الخادم في الوقت المحدد (انتهت مهلة الطلب).", + "tls": "فشل تأكيد اتصال TLS أو SSL (مشكلة في الشهادة أو التشفير).", + "unknown": "لم يتم تلقي أي استجابة HTTP من الهدف." + } } }, "components": { @@ -306,28 +319,28 @@ "common": { "passwordRules": { "length": { - "beginning": "", - "highlighted": "" + "beginning": "يجب أن يكون على الأقل", + "highlighted": "8 أحرف" }, "lowercase": { - "beginning": "", - "highlighted": "" + "beginning": "يجب أن يحتوي على الأقل على", + "highlighted": "حرف صغير واحد" }, "match": { - "beginning": "", - "highlighted": "" + "beginning": "كلمات المرور", + "highlighted": "يجب أن تتطابق" }, "number": { - "beginning": "", - "highlighted": "" + "beginning": "يجب أن يحتوي على الأقل على", + "highlighted": "رقم واحد" }, "special": { - "beginning": "", - "highlighted": "" + "beginning": "يجب أن يحتوي على الأقل على", + "highlighted": "حرف خاص واحد (!?@#$%^&*()-_=+[]{}|;:'\",./\\~`)" }, "uppercase": { - "beginning": "", - "highlighted": "" + "beginning": "يجب أن يحتوي على الأقل على", + "highlighted": "حرف كبير واحد" } }, "form": { @@ -452,7 +465,8 @@ "optionPagespeed": "PageSpeed", "optionHardware": "البنية التحتية", "optionGrpc": "gRPC", - "optionWebSocket": "WebSocket" + "optionWebSocket": "WebSocket", + "optionDns": "DNS" }, "bulkPause": { "paused_one": "تم إيقاف {{count}} شاشة مؤقتاً", @@ -483,9 +497,9 @@ }, "matchMethod": { "label": "طريقة المطابقة", - "equal": "", - "include": "", - "regex": "" + "equal": "يساوي", + "include": "يحتوي", + "regex": "تعبير عادي" } }, "title": "إعدادات متقدمة" @@ -497,15 +511,15 @@ "label": "تكرار الفحص", "value": { "fifteenMinutes": "15 دقيقة", - "fifteenSeconds": "", + "fifteenSeconds": "15 ثانية", "fiveMinutes": "5 دقائق", - "fourMinutes": "", - "oneMinute": "", + "fourMinutes": "4 دقائق", + "oneMinute": "دقيقة واحدة", "tenMinutes": "10 دقائق", "thirtyMinutes": "30 دقيقة", - "thirtySeconds": "", - "threeMinutes": "", - "twoMinutes": "" + "thirtySeconds": "30 ثانية", + "threeMinutes": "3 دقائق", + "twoMinutes": "دقيقتان" } } }, @@ -534,8 +548,8 @@ "placeholder": "https://www.google.com" }, "game": { - "label": "", - "placeholder": "" + "label": "اختر اللعبة", + "placeholder": "حدد لعبة" }, "port": { "label": "المنفذ المراد مراقبته", @@ -552,19 +566,40 @@ "captureUrl": { "label": "نقطة نهاية Capture", "placeholder": "http://192.168.1.10:5001/api/v1/metrics" + }, + "dnsRecordType": { + "label": "نوع سجل DNS", + "value": { + "A": "A", + "AAAA": "AAAA", + "CNAME": "CNAME", + "MX": "MX", + "NS": "NS", + "TXT": "TXT" + } + }, + "dnsServer": { + "label": "خادم DNS", + "placeholder": "8.8.8.8" + }, + "strategy": { + "desktop": "سطح المكتب", + "label": "الاستراتيجية", + "mobile": "الجوال" } }, "title": "الإعدادات العامة", "description": { "http": "أدخل الرابط أو عنوان IP للمراقبة (مثال: https://example.com/ أو 192.168.1.100) وأضف اسم عرض واضح يظهر في لوحة التحكم.", - "ping": "", - "port": "", - "docker": "", + "ping": "أدخل عنوان IP أو اسم المضيف للاتصال (على سبيل المثال، 192.168.1.100 أو example.com) وأضف اسماً واضحاً يظهر في لوحة المعلومات.", + "port": "أدخل عنوان URL أو IP للخادم ورقم المنفذ واسماً واضحاً يظهر في لوحة المعلومات.", + "docker": "أدخل اسم حاوية Docker أو المعرف. يمكنك استخدام اسم الحاوية (مثل my-app) أو معرف الحاوية (معرف كامل 64 حرف أو معرف قصير).", "game": "أدخل عنوان IP أو اسم المضيف ورقم المنفذ للاختبار (مثل 192.168.1.100 أو example.com) واختر نوع اللعبة. تتطلب بعض الألعاب تفعيل استعلامات الخادم في ملف الإعدادات الخاص بها — يُرجى مراجعة وثائق خادم اللعبة. اطّلع على قائمة الألعاب المدعومة لمعرفة تفاصيل البروتوكول.", "pagespeed": "تتبع أداء تحميل الصفحة ومؤشرات Core Web Vitals ودرجات التحسين لموقعك.", "grpc": "أدخل اسم المضيف ومنفذ خادم gRPC، وحدد اختيارياً اسم خدمة لفحص الصحة، وأضف اسم عرض.", "websocket": "أدخل رابط WebSocket للمراقبة (مثال: wss://example.com/socket) وأضف اسم عرض.", - "hardware": "راقب استخدام وحدة المعالجة المركزية والذاكرة والقرص ودرجة الحرارة لبنيتك التحتية. يجب أن يتضمن عنوان URL لنقطة نهاية Capture المسار الكامل ‎/api/v1/metrics‎ (مثال: ‎http://192.168.1.10:5001/api/v1/metrics‎). بالنسبة لمفتاح التفويض، الصق سلسلة الرمز فقط — لا تُضمّن كلمة 'Bearer'." + "hardware": "راقب استخدام وحدة المعالجة المركزية والذاكرة والقرص ودرجة الحرارة لبنيتك التحتية. يجب أن يتضمن عنوان URL لنقطة نهاية Capture المسار الكامل ‎/api/v1/metrics‎ (مثال: ‎http://192.168.1.10:5001/api/v1/metrics‎). بالنسبة لمفتاح التفويض، الصق سلسلة الرمز فقط — لا تُضمّن كلمة 'Bearer'.", + "dns": "راقب ما إذا كان خادم DNS محدد يستجيب." } }, "ignoreTls": { @@ -601,7 +636,8 @@ "optionHttpDescription": "استخدم HTTP(S) لمراقبة موقعك الإلكتروني أو نقطة نهاية API.", "optionPingDescription": "استخدم ICMP Ping لمراقبة ما إذا كان الخادم متصلاً.", "optionPortDescription": "مراقبة ما إذا كان منفذ معين على الخادم مفتوحاً.", - "title": "النوع" + "title": "النوع", + "optionDnsDescription": "راقب ما إذا كان خادم DNS محدد يستجيب." }, "thresholds": { "title": "حدود التنبيه", @@ -765,7 +801,7 @@ "headers": { "endTime": "وقت الانتهاء", "resolutionType": "نوع الحل", - "startTime": "", + "startTime": "وقت البدء", "statusCode": "رمز الحالة" }, "resolvedIncidents": "الحوادث المحلولة", @@ -809,13 +845,13 @@ } }, "statBoxes": { - "avgCpuTemperature": "", - "cpuFrequency": "", - "cpuLogical": "", - "cpuPhysical": "", + "avgCpuTemperature": "متوسط درجة حرارة المعالج", + "cpuFrequency": "تردد المعالج", + "cpuLogical": "المعالج (المنطقي)", + "cpuPhysical": "المعالج (الفعلي)", "disk": "القرص", "memory": "الذاكرة", - "os": "" + "os": "نظام التشغيل" }, "table": { "headers": { @@ -826,7 +862,7 @@ }, "tabs": { "labels": { - "network": "", + "network": "الشبكة", "overview": "نظرة عامة" } }, @@ -909,7 +945,8 @@ "lastFinishedAt": "آخر انتهاء في", "lastRunTook": "مدة آخر تشغيل", "lastFailedAt": "آخر فشل في", - "failReason": "سبب الفشل" + "failReason": "سبب الفشل", + "state": "الحالة" } }, "queueFallback": { @@ -929,8 +966,8 @@ }, "table": { "headers": { - "nextWindow": "", - "repeat": "" + "nextWindow": "النافذة التالية", + "repeat": "تكرار" } }, "form": { @@ -1032,7 +1069,7 @@ }, "telegram": { "title": "تهيئة Telegram", - "description": "", + "description": "قم بتكوين بوت Telegram الخاص بك لإرسال الإشعارات.", "optionBotToken": "رمز البوت", "placeholderBotToken": "123456789:ABCdefGhIJKlmNoPQRsTUVwxyZ", "optionChatId": "معرّف المحادثة", @@ -1099,6 +1136,10 @@ "header": { "title": "مراقبات PageSpeed", "description": "تابع Core Web Vitals ودرجات Lighthouse عبر الزمن لاكتشاف التراجع قبل أن يلاحظه المستخدمون." + }, + "strategy": { + "desktop": "سطح المكتب", + "mobile": "الجوال" } }, "settings": { @@ -1201,7 +1242,7 @@ "email": { "title": "إعدادات البريد الإلكتروني", "description": "تهيئة إعدادات البريد الإلكتروني لنظامك. يُستخدم لإرسال الإشعارات والتنبيهات.", - "descriptionTransport": "", + "descriptionTransport": "هذا يبني نقل SMTP لـ NodeMailer", "descriptionTransportLink": "اطلع على المواصفات هنا", "option": { "host": { @@ -1247,6 +1288,10 @@ }, "rejectUnauthorized": { "label": "رفض الشهادات غير الصالحة: رفض الاتصالات ذات الشهادات الموقعة ذاتياً أو غير الموثوقة" + }, + "displayName": { + "label": "اسم المرسل (اختياري) - الاسم الودي المعروض في حقل المرسل", + "placeholder": "إشعارات Checkmate" } } }, @@ -1307,6 +1352,20 @@ "uptime": { "title": "وقت التشغيل", "responseTime": "وقت الاستجابة" + }, + "chart": { + "avg": "متوسط {{value}} مللي ثانية", + "downTooltip": "متوقف", + "heatmapAria": "خريطة حرارية لزمن الاستجابة", + "max": "أقصى {{value}} مللي ثانية" + }, + "status": { + "breached": "تم تجاوز الحد", + "down": "متوقف", + "initializing": "قيد التهيئة", + "maintenance": "صيانة", + "paused": "متوقف مؤقتاً", + "up": "قيد التشغيل" } }, "statusBar": { @@ -1324,7 +1383,9 @@ "maintenance": "بعض الأنظمة قيد الصيانة", "partiallyPaused": "بعض الأنظمة متوقفة مؤقتًا", "initializing": "بعض الأنظمة قيد التهيئة", - "noMonitors": "لم يتم تكوين أي شاشة" + "noMonitors": "لم يتم تكوين أي شاشة", + "monitoringSummary": "مراقبة {{count}} خدمة · يتم التحديث كل 10 ثوانٍ", + "monitoringSummary_other": "مراقبة {{count}} خدمات · يتم التحديث كل 10 ثوانٍ" }, "table": { "headers": { @@ -1415,11 +1476,47 @@ "label": "إظهار مقاييس البنية التحتية" } } + }, + "theme": { + "description": "اختر النمط المرئي لصفحة الحالة العامة الخاصة بك.", + "label": "السمة", + "options": { + "bold": { + "description": "خط كبير ومسافات واثقة. رائعة للصفحات التي تركز على العلامة التجارية.", + "name": "غامق" + }, + "editorial": { + "description": "عناوين بخط مسرف وفواصل دقيقة. مميز وهادئ.", + "name": "تحريري" + }, + "modern": { + "description": "بجودة SaaS مع نقطة حالة نابضة وبطاقات تتلاشى بنعومة.", + "name": "حديث" + }, + "refined": { + "description": "نظيف واحترافي وقريب من مظهر Checkmate الكلاسيكي.", + "name": "مكرر" + } + }, + "title": "سمة الصفحة" + }, + "themeMode": { + "auto": "تلقائي (اتبع جهاز الزائر)", + "dark": "داكن فقط", + "label": "وضع السمة", + "light": "فاتح فقط" } }, "header": { "title": "صفحات الحالة", "description": "انشر صفحات عامة تُظهر للعملاء وأصحاب المصلحة وقت التشغيل الفعلي وتاريخ الحوادث." + }, + "editorial": { + "allUp": "جميع الأنظمة تعمل بشكل طبيعي.", + "reportTitle": "تقرير الخدمة" + }, + "footer": { + "poweredBy": "مشغل بواسطة" } }, "uptime": { diff --git a/client/src/locales/ca.json b/client/src/locales/ca.json index fe80cf9c4..259acf0ab 100644 --- a/client/src/locales/ca.json +++ b/client/src/locales/ca.json @@ -73,7 +73,9 @@ "paused": "pausat", "na": "N/D", "resolved": "Resolt", - "responseTime": "Temps de resposta" + "responseTime": "Temps de resposta", + "idle": "Inactiu", + "running": "En execució" }, "form": { "role": { @@ -120,6 +122,17 @@ "active": "Actiu", "responseTime": "Temps de resposta" } + }, + "statusCode": { + "down": "Inactiu", + "networkError": { + "dns": "No s'ha pogut resoldre el nom d'amfitrió (la cerca DNS ha fallat).", + "refused": "El servidor ha rebutjat la connexió (port tancat o servei no actiu).", + "reset": "La connexió s'ha reiniciat abans de rebre una resposta.", + "timeout": "El servidor no ha respost a temps (s'ha esgotat el temps d'espera).", + "tls": "Ha fallat la negociació TLS o SSL (problema de certificat o xifratge).", + "unknown": "No s'ha rebut cap resposta HTTP de l'objectiu." + } } }, "components": { @@ -421,7 +434,11 @@ "inviteMember": "Convidar membre", "openSite": "Obrir lloc", "pause": "Pausar", - "resume": "Reprendre" + "resume": "Reprendre", + "bulkSelected_one": "{{count}} monitor seleccionat", + "bulkSelected_other": "{{count}} monitors seleccionats", + "selectAll": "Selecciona tots els monitors", + "selectMonitor": "Selecciona {{name}}" }, "statBoxes": { "activeFor": "Actiu des de", @@ -448,7 +465,18 @@ "optionPagespeed": "PageSpeed", "optionHardware": "Infraestructura", "optionGrpc": "gRPC", - "optionWebSocket": "WebSocket" + "optionWebSocket": "WebSocket", + "optionDns": "DNS" + }, + "bulkPause": { + "alreadyPaused_one": "El monitor seleccionat ja està pausat", + "alreadyPaused_other": "Tots els monitors seleccionats ja estan pausats", + "alreadyRunning_one": "El monitor seleccionat ja s'està executant", + "alreadyRunning_other": "Tots els monitors seleccionats ja s'estan executant", + "paused_one": "{{count}} monitor pausat", + "paused_other": "{{count}} monitors pausats", + "resumed_one": "{{count}} monitor reprès", + "resumed_other": "{{count}} monitors represos" } } }, @@ -538,6 +566,26 @@ "captureUrl": { "label": "Endpoint de captura", "placeholder": "http://192.168.1.10:5001/api/v1/metrics" + }, + "dnsRecordType": { + "label": "Tipus de registre DNS", + "value": { + "A": "A", + "AAAA": "AAAA", + "CNAME": "CNAME", + "MX": "MX", + "NS": "NS", + "TXT": "TXT" + } + }, + "dnsServer": { + "label": "Servidor DNS", + "placeholder": "8.8.8.8" + }, + "strategy": { + "desktop": "Escriptori", + "label": "Estratègia", + "mobile": "Mòbil" } }, "title": "Configuració general", @@ -550,7 +598,8 @@ "pagespeed": "Fes un seguiment del rendiment de càrrega de pàgina, Core Web Vitals i puntuacions d'optimització del teu lloc web.", "grpc": "Introdueix el nom d'host i el port del servidor gRPC, opcionalment especifica un nom de servei per a la comprovació de salut, i afegeix un nom de visualització.", "websocket": "Introdueix la URL de WebSocket a monitorar (p. ex., wss://example.com/socket) i afegeix un nom de visualització.", - "hardware": "Monitoritza l'ús de CPU, memòria, disc i temperatura per a la teva infraestructura. La URL de l'endpoint de captura ha d'incloure la ruta completa /api/v1/metrics (p. ex., http://192.168.1.10:5001/api/v1/metrics). Per al secret d'autorització, enganxa només la cadena del token — no incloguis 'Bearer'." + "hardware": "Monitoritza l'ús de CPU, memòria, disc i temperatura per a la teva infraestructura. La URL de l'endpoint de captura ha d'incloure la ruta completa /api/v1/metrics (p. ex., http://192.168.1.10:5001/api/v1/metrics). Per al secret d'autorització, enganxa només la cadena del token — no incloguis 'Bearer'.", + "dns": "Monitoritza si un servidor DNS específic respon." } }, "ignoreTls": { @@ -587,7 +636,8 @@ "optionHttpDescription": "Usa HTTP(S) per monitorar el teu lloc web o endpoint d'API.", "optionPingDescription": "Usa ICMP Ping per monitorar si un servidor és en línia.", "optionPortDescription": "Monitoritza si un port específic d'un servidor és obert.", - "title": "Tipus" + "title": "Tipus", + "optionDnsDescription": "Monitoritza si un servidor DNS específic respon." }, "thresholds": { "title": "Llindars d'alerta", @@ -899,7 +949,8 @@ "lastFinishedAt": "Darrera finalització", "lastRunTook": "Durada de la darrera execució", "lastFailedAt": "Darrera fallada", - "failReason": "Motiu de la fallada" + "failReason": "Motiu de la fallada", + "state": "Estat" } }, "header": { @@ -1085,6 +1136,10 @@ "header": { "title": "Monitors de PageSpeed", "description": "Fes un seguiment de les Core Web Vitals i les puntuacions de Lighthouse al llarg del temps per detectar regressions abans que els usuaris ho facin." + }, + "strategy": { + "desktop": "Escriptori", + "mobile": "Mòbil" } }, "settings": { diff --git a/client/src/locales/cs.json b/client/src/locales/cs.json index 19d506399..79969899c 100644 --- a/client/src/locales/cs.json +++ b/client/src/locales/cs.json @@ -73,7 +73,9 @@ "paused": "pozastaveno", "na": "N/A", "resolved": "Vyřešeno", - "responseTime": "Doba odezvy" + "responseTime": "Doba odezvy", + "idle": "Nečinný", + "running": "Spuštěno" }, "form": { "role": { @@ -120,6 +122,17 @@ "active": "Aktivní", "responseTime": "Doba odezvy" } + }, + "statusCode": { + "down": "Mimo provoz", + "networkError": { + "dns": "Nepodařilo se přeložit název hostitele (vyhledávání DNS selhalo).", + "refused": "Server odmítl spojení (port je uzavřený nebo služba neposlouchá).", + "reset": "Spojení bylo resetováno před přijetím odpovědi.", + "timeout": "Server neodpověděl včas (vypršel časový limit požadavku).", + "tls": "Selhal handshake TLS nebo SSL (problém s certifikátem nebo šifrováním).", + "unknown": "Z cíle nebyla obdržena žádná odpověď HTTP." + } } }, "components": { @@ -452,7 +465,8 @@ "optionPagespeed": "PageSpeed", "optionHardware": "Infrastruktura", "optionGrpc": "gRPC", - "optionWebSocket": "WebSocket" + "optionWebSocket": "WebSocket", + "optionDns": "DNS" }, "bulkPause": { "paused_one": "{{count}} monitor pozastaven", @@ -483,9 +497,9 @@ }, "matchMethod": { "label": "Metoda porovnání", - "equal": "", - "include": "", - "regex": "" + "equal": "Rovná se", + "include": "Obsahuje", + "regex": "Regulární výraz" } }, "title": "Pokročilá nastavení" @@ -497,15 +511,15 @@ "label": "Frekvence kontroly", "value": { "fifteenMinutes": "15 minut", - "fifteenSeconds": "", + "fifteenSeconds": "15 sekund", "fiveMinutes": "5 minut", - "fourMinutes": "", - "oneMinute": "", + "fourMinutes": "4 minuty", + "oneMinute": "1 minuta", "tenMinutes": "10 minut", "thirtyMinutes": "30 minut", - "thirtySeconds": "", - "threeMinutes": "", - "twoMinutes": "" + "thirtySeconds": "30 sekund", + "threeMinutes": "3 minuty", + "twoMinutes": "2 minuty" } } }, @@ -534,8 +548,8 @@ "placeholder": "https://www.google.com" }, "game": { - "label": "", - "placeholder": "" + "label": "Vybrat hru", + "placeholder": "Vyberte hru" }, "port": { "label": "Port ke sledování", @@ -552,19 +566,40 @@ "captureUrl": { "label": "Endpoint Capture", "placeholder": "http://192.168.1.10:5001/api/v1/metrics" + }, + "dnsRecordType": { + "label": "Typ záznamu DNS", + "value": { + "A": "A", + "AAAA": "AAAA", + "CNAME": "CNAME", + "MX": "MX", + "NS": "NS", + "TXT": "TXT" + } + }, + "dnsServer": { + "label": "DNS server", + "placeholder": "8.8.8.8" + }, + "strategy": { + "desktop": "Stolní počítač", + "label": "Strategie", + "mobile": "Mobil" } }, "title": "Obecná nastavení", "description": { "http": "Zadejte URL nebo IP adresu ke sledování (např. https://example.com/ nebo 192.168.1.100) a přidejte jasný zobrazovaný název, který se zobrazí na přehledu.", - "ping": "", - "port": "", - "docker": "", + "ping": "Zadejte IP adresu nebo název hostitele pro ping (např. 192.168.1.100 nebo example.com) a přidejte přehledný název, který se zobrazí na dashboardu.", + "port": "Zadejte URL nebo IP serveru, číslo portu a přehledný název, který se zobrazí na dashboardu.", + "docker": "Zadejte název nebo ID Docker kontejneru. Můžete použít buď název kontejneru (např. my-app) nebo ID kontejneru (úplné 64znakové ID nebo krátké ID).", "game": "Zadejte IP adresu nebo název hostitele a číslo portu pro ping (např. 192.168.1.100 nebo example.com) a vyberte typ hry. Některé hry vyžadují povolení dotazů na server v jejich konfiguračním souboru — viz dokumentace vašeho herního serveru. Podrobnosti o protokolu najdete v seznamu podporovaných her.", "pagespeed": "Sledujte výkon načítání stránky, metriky Core Web Vitals a hodnocení optimalizace vašeho webu.", "grpc": "Zadejte název hostitele a port gRPC serveru, volitelně zadejte název služby pro kontrolu stavu a přidejte zobrazovaný název.", "websocket": "Zadejte WebSocket URL ke sledování (např. wss://example.com/socket) a přidejte zobrazovaný název.", - "hardware": "Sledujte využití CPU, paměti, disku a teplotu vaší infrastruktury. URL endpointu Capture musí obsahovat celou cestu /api/v1/metrics (např. http://192.168.1.10:5001/api/v1/metrics). Do autorizačního klíče vložte pouze řetězec tokenu — nezahrnujte 'Bearer'." + "hardware": "Sledujte využití CPU, paměti, disku a teplotu vaší infrastruktury. URL endpointu Capture musí obsahovat celou cestu /api/v1/metrics (např. http://192.168.1.10:5001/api/v1/metrics). Do autorizačního klíče vložte pouze řetězec tokenu — nezahrnujte 'Bearer'.", + "dns": "Sledujte, zda konkrétní DNS server odpovídá." } }, "ignoreTls": { @@ -601,7 +636,8 @@ "optionHttpDescription": "Použijte HTTP(S) ke sledování vašeho webu nebo API koncového bodu.", "optionPingDescription": "Použijte ICMP Ping ke sledování, zda je server online.", "optionPortDescription": "Sledujte, zda je konkrétní port na serveru otevřen.", - "title": "Typ" + "title": "Typ", + "optionDnsDescription": "Sledujte, zda konkrétní DNS server odpovídá." }, "thresholds": { "title": "Prahové hodnoty upozornění", @@ -765,7 +801,7 @@ "headers": { "endTime": "Čas ukončení", "resolutionType": "Typ řešení", - "startTime": "", + "startTime": "Čas zahájení", "statusCode": "Stavový kód" }, "resolvedIncidents": "Vyřešené incidenty", @@ -809,13 +845,13 @@ } }, "statBoxes": { - "avgCpuTemperature": "", - "cpuFrequency": "", - "cpuLogical": "", - "cpuPhysical": "", + "avgCpuTemperature": "Průměrná teplota CPU", + "cpuFrequency": "Frekvence CPU", + "cpuLogical": "CPU (logický)", + "cpuPhysical": "CPU (fyzický)", "disk": "Disk", "memory": "Paměť", - "os": "" + "os": "OS" }, "table": { "headers": { @@ -826,7 +862,7 @@ }, "tabs": { "labels": { - "network": "", + "network": "Síť", "overview": "Přehled" } }, @@ -909,7 +945,8 @@ "lastFinishedAt": "Naposledy dokončeno", "lastRunTook": "Poslední běh trval", "lastFailedAt": "Poslední selhání", - "failReason": "Důvod selhání" + "failReason": "Důvod selhání", + "state": "Stav" } }, "queueFallback": { @@ -929,8 +966,8 @@ }, "table": { "headers": { - "nextWindow": "", - "repeat": "" + "nextWindow": "Další okno", + "repeat": "Opakovat" } }, "form": { @@ -1032,7 +1069,7 @@ }, "telegram": { "title": "Konfigurace Telegramu", - "description": "", + "description": "Nakonfigurujte svého Telegram bota pro odesílání oznámení.", "optionBotToken": "Token bota", "placeholderBotToken": "123456789:ABCdefGhIJKlmNoPQRsTUVwxyZ", "optionChatId": "ID chatu", @@ -1099,6 +1136,10 @@ "header": { "title": "Monitory PageSpeed", "description": "Sledujte Core Web Vitals a skóre Lighthouse v čase a zachyťte regrese dříve než vaši uživatelé." + }, + "strategy": { + "desktop": "Stolní počítač", + "mobile": "Mobil" } }, "settings": { @@ -1201,7 +1242,7 @@ "email": { "title": "Nastavení emailu", "description": "Nastavte emailovou konfiguraci pro váš systém. Používá se k odesílání oznámení a upozornění.", - "descriptionTransport": "", + "descriptionTransport": "Toto vytvoří SMTP transport pro NodeMailer", "descriptionTransportLink": "Zobrazit specifikace zde", "option": { "host": { @@ -1247,6 +1288,10 @@ }, "rejectUnauthorized": { "label": "Odmítnout neplatné certifikáty: Odmítnout připojení se sebou podepsanými nebo nedůvěryhodnými certifikáty" + }, + "displayName": { + "label": "Jméno odesílatele (nepovinné) - Přátelské jméno zobrazené v poli Od", + "placeholder": "Oznámení Checkmate" } } }, @@ -1307,6 +1352,20 @@ "uptime": { "title": "Dostupnost", "responseTime": "Doba odezvy" + }, + "chart": { + "avg": "průměr {{value}}ms", + "downTooltip": "Mimo provoz", + "heatmapAria": "Tepelná mapa doby odezvy", + "max": "max {{value}}ms" + }, + "status": { + "breached": "Překročen limit", + "down": "Mimo provoz", + "initializing": "Inicializace", + "maintenance": "Údržba", + "paused": "Pozastaveno", + "up": "V provozu" } }, "statusBar": { @@ -1324,7 +1383,9 @@ "maintenance": "Některé systémy jsou v údržbě", "partiallyPaused": "Některé systémy jsou pozastavené", "initializing": "Některé systémy se inicializují", - "noMonitors": "Nebyly nakonfigurovány žádné monitory" + "noMonitors": "Nebyly nakonfigurovány žádné monitory", + "monitoringSummary": "Sledování {{count}} služby · Aktualizace každých 10 sekund", + "monitoringSummary_other": "Sledování {{count}} služeb · Aktualizace každých 10 sekund" }, "table": { "headers": { @@ -1415,11 +1476,47 @@ "label": "Zobrazit metriky infrastruktury" } } + }, + "theme": { + "description": "Vyberte vizuální styl pro veřejnou stavovou stránku.", + "label": "Motiv", + "options": { + "bold": { + "description": "Velké písmo a sebevědomé rozložení. Vhodné pro stránky zaměřené na značku.", + "name": "Tučný" + }, + "editorial": { + "description": "Patkové nadpisy a tenké oddělovače. Odlišný a klidný.", + "name": "Redakční" + }, + "modern": { + "description": "Úroveň SaaS s pulzující stavovou tečkou a jemně mizejícími kartami.", + "name": "Moderní" + }, + "refined": { + "description": "Čistý, profesionální, blízký klasickému vzhledu Checkmate.", + "name": "Vytříbený" + } + }, + "title": "Motiv stránky" + }, + "themeMode": { + "auto": "Automaticky (podle zařízení návštěvníka)", + "dark": "Pouze tmavý", + "label": "Režim motivu", + "light": "Pouze světlý" } }, "header": { "title": "Stavové stránky", "description": "Publikujte veřejné stránky, které zákazníkům a zúčastněným stranám zobrazují dostupnost v reálném čase a historii incidentů." + }, + "editorial": { + "allUp": "Všechny systémy fungují normálně.", + "reportTitle": "Zpráva o službě" + }, + "footer": { + "poweredBy": "Provozováno službou" } }, "uptime": { diff --git a/client/src/locales/de.json b/client/src/locales/de.json index 0d0808135..09f644cd2 100644 --- a/client/src/locales/de.json +++ b/client/src/locales/de.json @@ -73,7 +73,9 @@ "paused": "pausiert", "na": "N/A", "resolved": "Gelöst", - "responseTime": "Antwortzeit" + "responseTime": "Antwortzeit", + "idle": "Untätig", + "running": "Läuft" }, "form": { "role": { @@ -120,6 +122,17 @@ "active": "Aktiv", "responseTime": "Antwortzeit" } + }, + "statusCode": { + "down": "Ausgefallen", + "networkError": { + "dns": "Hostname konnte nicht aufgelöst werden (DNS-Auflösung fehlgeschlagen).", + "refused": "Der Server hat die Verbindung abgelehnt (Port geschlossen oder Dienst lauscht nicht).", + "reset": "Die Verbindung wurde zurückgesetzt, bevor eine Antwort empfangen wurde.", + "timeout": "Der Server hat nicht rechtzeitig geantwortet (Zeitüberschreitung der Anfrage).", + "tls": "TLS- oder SSL-Handshake fehlgeschlagen (Zertifikats- oder Verschlüsselungsproblem).", + "unknown": "Es wurde keine HTTP-Antwort vom Ziel empfangen." + } } }, "components": { @@ -452,7 +465,8 @@ "optionPagespeed": "PageSpeed", "optionHardware": "Infrastruktur", "optionGrpc": "gRPC", - "optionWebSocket": "WebSocket" + "optionWebSocket": "WebSocket", + "optionDns": "DNS" }, "bulkPause": { "paused_one": "{{count}} Monitor pausiert", @@ -552,6 +566,26 @@ "captureUrl": { "label": "Capture-Endpunkt", "placeholder": "http://192.168.1.10:5001/api/v1/metrics" + }, + "dnsRecordType": { + "label": "DNS-Eintragstyp", + "value": { + "A": "A", + "AAAA": "AAAA", + "CNAME": "CNAME", + "MX": "MX", + "NS": "NS", + "TXT": "TXT" + } + }, + "dnsServer": { + "label": "DNS-Server", + "placeholder": "8.8.8.8" + }, + "strategy": { + "desktop": "Desktop", + "label": "Strategie", + "mobile": "Mobil" } }, "title": "Allgemeine Einstellungen", @@ -564,7 +598,8 @@ "pagespeed": "Verfolgen Sie die Ladezeit, Core Web Vitals und Optimierungswerte Ihrer Website.", "grpc": "Geben Sie den Hostnamen und Port des gRPC-Servers ein, optional einen Dienstnamen für den Health-Check, und fügen Sie einen Anzeigenamen hinzu.", "websocket": "Geben Sie die zu überwachende WebSocket-URL ein (z. B. wss://example.com/socket) und fügen Sie einen Anzeigenamen hinzu.", - "hardware": "Überwachen Sie CPU-, Speicher-, Festplattenauslastung und Temperatur Ihrer Infrastruktur. Die URL des Capture-Endpunkts muss den vollständigen Pfad /api/v1/metrics enthalten (z. B. http://192.168.1.10:5001/api/v1/metrics). Fügen Sie für das Autorisierungsgeheimnis nur den Token-String ein — geben Sie 'Bearer' nicht mit an." + "hardware": "Überwachen Sie CPU-, Speicher-, Festplattenauslastung und Temperatur Ihrer Infrastruktur. Die URL des Capture-Endpunkts muss den vollständigen Pfad /api/v1/metrics enthalten (z. B. http://192.168.1.10:5001/api/v1/metrics). Fügen Sie für das Autorisierungsgeheimnis nur den Token-String ein — geben Sie 'Bearer' nicht mit an.", + "dns": "Überwachen Sie, ob ein bestimmter DNS-Server antwortet." } }, "ignoreTls": { @@ -601,7 +636,8 @@ "optionHttpDescription": "HTTP(S) verwenden, um Ihre Website oder Ihren API-Endpunkt zu überwachen.", "optionPingDescription": "ICMP-Ping verwenden, um zu überwachen, ob ein Server online ist.", "optionPortDescription": "Überwachen, ob ein bestimmter Port auf einem Server geöffnet ist.", - "title": "Typ" + "title": "Typ", + "optionDnsDescription": "Überwachen Sie, ob ein bestimmter DNS-Server antwortet." }, "thresholds": { "title": "Alarmschwellenwerte", @@ -909,7 +945,8 @@ "lastFinishedAt": "Zuletzt abgeschlossen am", "lastRunTook": "Letzter Lauf dauerte", "lastFailedAt": "Letzter Fehler am", - "failReason": "Fehlergrund" + "failReason": "Fehlergrund", + "state": "Status" } }, "queueFallback": { @@ -1099,6 +1136,10 @@ "header": { "title": "PageSpeed-Monitore", "description": "Verfolgen Sie Core Web Vitals und Lighthouse-Werte im Zeitverlauf, um Regressionen zu erkennen, bevor Nutzer es tun." + }, + "strategy": { + "desktop": "Desktop", + "mobile": "Mobil" } }, "settings": { @@ -1247,6 +1288,10 @@ }, "rejectUnauthorized": { "label": "Ungültige Zertifikate ablehnen: Verbindungen mit selbstsignierten oder nicht vertrauenswürdigen Zertifikaten ablehnen" + }, + "displayName": { + "label": "Absendername (optional) - Anzeigename im Absenderfeld", + "placeholder": "Checkmate-Benachrichtigungen" } } }, @@ -1307,6 +1352,20 @@ "uptime": { "title": "Verfügbarkeit", "responseTime": "Antwortzeit" + }, + "chart": { + "avg": "Ø {{value}}ms", + "downTooltip": "Ausgefallen", + "heatmapAria": "Heatmap der Antwortzeit", + "max": "max. {{value}}ms" + }, + "status": { + "breached": "Schwellenwert überschritten", + "down": "Ausgefallen", + "initializing": "Initialisierung", + "maintenance": "Wartung", + "paused": "Pausiert", + "up": "Betriebsbereit" } }, "statusBar": { @@ -1324,7 +1383,9 @@ "maintenance": "Einige Systeme befinden sich in Wartung", "partiallyPaused": "Einige Systeme sind pausiert", "initializing": "Einige Systeme werden initialisiert", - "noMonitors": "Es wurden keine Monitore konfiguriert" + "noMonitors": "Es wurden keine Monitore konfiguriert", + "monitoringSummary": "Überwachung von {{count}} Dienst · Aktualisierung alle 10 Sekunden", + "monitoringSummary_other": "Überwachung von {{count}} Diensten · Aktualisierung alle 10 Sekunden" }, "table": { "headers": { @@ -1415,11 +1476,47 @@ "label": "Infrastruktur-Metriken anzeigen" } } + }, + "theme": { + "description": "Wählen Sie den visuellen Stil für Ihre öffentliche Statusseite.", + "label": "Theme", + "options": { + "bold": { + "description": "Große Schrift und großzügige Abstände. Ideal für markenorientierte Seiten.", + "name": "Fett" + }, + "editorial": { + "description": "Serifenüberschriften und feine Trennlinien. Markant und ruhig.", + "name": "Redaktionell" + }, + "modern": { + "description": "SaaS-Niveau mit pulsierendem Statuspunkt und sanft verblassenden Karten.", + "name": "Modern" + }, + "refined": { + "description": "Sauber, professionell, nah am klassischen Checkmate-Look.", + "name": "Verfeinert" + } + }, + "title": "Seiten-Theme" + }, + "themeMode": { + "auto": "Automatisch (Gerät des Besuchers folgen)", + "dark": "Nur dunkel", + "label": "Theme-Modus", + "light": "Nur hell" } }, "header": { "title": "Statusseiten", "description": "Veröffentlichen Sie öffentliche Seiten, die Kunden und Stakeholdern Echtzeit-Verfügbarkeit und Vorfallsverlauf zeigen." + }, + "editorial": { + "allUp": "Alle Systeme funktionieren normal.", + "reportTitle": "Servicebericht" + }, + "footer": { + "poweredBy": "Bereitgestellt von" } }, "uptime": { diff --git a/client/src/locales/es.json b/client/src/locales/es.json index be2f6531c..67e2b0dcd 100644 --- a/client/src/locales/es.json +++ b/client/src/locales/es.json @@ -47,14 +47,7 @@ "exportToJSON": "Exportar a JSON", "importFromJSON": "Importar desde JSON", "clearFilters": "Limpiar filtros", - "removeUser": "Eliminar usuario", - "testNotification": "Probar notificación", - "invite": "Invitar", - "copyLink": "Copiar enlace", - "regenerateToken": "Regenerar token", - "copyToken": "Copiar token", - "exportLogs": "Exportar registros", - "refresh": "Actualizar" + "removeUser": "Eliminar usuario" }, "charts": { "labels": { @@ -80,7 +73,9 @@ "paused": "pausado", "na": "N/D", "resolved": "Resuelto", - "responseTime": "Tiempo de respuesta" + "responseTime": "Tiempo de respuesta", + "idle": "Inactivo", + "running": "En ejecución" }, "form": { "role": { @@ -127,6 +122,17 @@ "active": "Activo", "responseTime": "Tiempo de respuesta" } + }, + "statusCode": { + "down": "Caído", + "networkError": { + "dns": "No se pudo resolver el nombre de host (la búsqueda DNS falló).", + "refused": "El servidor rechazó la conexión (puerto cerrado o servicio inactivo).", + "reset": "La conexión se restableció antes de recibir una respuesta.", + "timeout": "El servidor no respondió a tiempo (tiempo de espera agotado).", + "tls": "Falló el protocolo TLS o SSL (problema de certificado o cifrado).", + "unknown": "No se recibió ninguna respuesta HTTP del destino." + } } }, "components": { @@ -459,7 +465,8 @@ "optionPagespeed": "PageSpeed", "optionHardware": "Infraestructura", "optionGrpc": "gRPC", - "optionWebSocket": "WebSocket" + "optionWebSocket": "WebSocket", + "optionDns": "DNS" }, "bulkPause": { "paused_one": "{{count}} monitor pausado", @@ -559,6 +566,26 @@ "captureUrl": { "label": "Endpoint de Capture", "placeholder": "http://192.168.1.10:5001/api/v1/metrics" + }, + "dnsRecordType": { + "label": "Tipo de registro DNS", + "value": { + "A": "A", + "AAAA": "AAAA", + "CNAME": "CNAME", + "MX": "MX", + "NS": "NS", + "TXT": "TXT" + } + }, + "dnsServer": { + "label": "Servidor DNS", + "placeholder": "8.8.8.8" + }, + "strategy": { + "desktop": "Escritorio", + "label": "Estrategia", + "mobile": "Móvil" } }, "title": "Configuración general", @@ -571,7 +598,8 @@ "pagespeed": "Rastree el rendimiento de carga de página, Core Web Vitals y puntuaciones de optimización de su sitio web.", "grpc": "Ingrese el nombre de host y puerto del servidor gRPC, opcionalmente especifique un nombre de servicio para la verificación de salud, y agregue un nombre para mostrar.", "websocket": "Ingrese la URL de WebSocket a monitorear (p. ej., wss://example.com/socket) y agregue un nombre para mostrar.", - "hardware": "Supervisa el uso de CPU, memoria, disco y temperatura de tu infraestructura. La URL del endpoint de Capture debe incluir la ruta completa /api/v1/metrics (p. ej., http://192.168.1.10:5001/api/v1/metrics). Para el secreto de autorización, pega solo la cadena del token — no incluyas 'Bearer'." + "hardware": "Supervisa el uso de CPU, memoria, disco y temperatura de tu infraestructura. La URL del endpoint de Capture debe incluir la ruta completa /api/v1/metrics (p. ej., http://192.168.1.10:5001/api/v1/metrics). Para el secreto de autorización, pega solo la cadena del token — no incluyas 'Bearer'.", + "dns": "Monitorea si un servidor DNS específico responde." } }, "ignoreTls": { @@ -608,7 +636,8 @@ "optionHttpDescription": "Use HTTP(S) para monitorear su sitio web o endpoint de API.", "optionPingDescription": "Use ICMP Ping para monitorear si un servidor está en línea.", "optionPortDescription": "Monitoree si un puerto específico de un servidor está abierto.", - "title": "Tipo" + "title": "Tipo", + "optionDnsDescription": "Monitorea si un servidor DNS específico responde." }, "thresholds": { "title": "Umbrales de alerta", @@ -916,7 +945,8 @@ "lastFinishedAt": "Última finalización", "lastRunTook": "Última ejecución tomó", "lastFailedAt": "Última falla", - "failReason": "Razón de falla" + "failReason": "Razón de falla", + "state": "Estado" } }, "queueFallback": { @@ -1106,6 +1136,10 @@ "header": { "title": "Monitores de PageSpeed", "description": "Sigue las Core Web Vitals y las puntuaciones de Lighthouse a lo largo del tiempo para detectar regresiones antes que tus usuarios." + }, + "strategy": { + "desktop": "Escritorio", + "mobile": "Móvil" } }, "settings": { @@ -1254,6 +1288,10 @@ }, "rejectUnauthorized": { "label": "Rechazar certificados inválidos: Rechazar conexiones con certificados autofirmados o no confiables" + }, + "displayName": { + "label": "Nombre del remitente (opcional) - Nombre amigable mostrado en el campo De", + "placeholder": "Notificaciones de Checkmate" } } }, @@ -1314,6 +1352,20 @@ "uptime": { "title": "Tiempo de actividad", "responseTime": "Tiempo de respuesta" + }, + "chart": { + "avg": "prom {{value}}ms", + "downTooltip": "Caído", + "heatmapAria": "Mapa de calor del tiempo de respuesta", + "max": "máx {{value}}ms" + }, + "status": { + "breached": "Umbral superado", + "down": "Caído", + "initializing": "Inicializando", + "maintenance": "Mantenimiento", + "paused": "Pausado", + "up": "Operativo" } }, "statusBar": { @@ -1331,7 +1383,9 @@ "maintenance": "Algunos sistemas están en mantenimiento", "partiallyPaused": "Algunos sistemas están en pausa", "initializing": "Algunos sistemas se están inicializando", - "noMonitors": "No se ha configurado ningún monitor" + "noMonitors": "No se ha configurado ningún monitor", + "monitoringSummary": "Monitoreando {{count}} servicio · Actualizaciones cada 10 segundos", + "monitoringSummary_other": "Monitoreando {{count}} servicios · Actualizaciones cada 10 segundos" }, "table": { "headers": { @@ -1422,18 +1476,47 @@ "label": "Mostrar métricas de infraestructura" } } + }, + "theme": { + "description": "Elige el estilo visual para tu página de estado pública.", + "label": "Tema", + "options": { + "bold": { + "description": "Tipografía grande y espaciado confiado. Ideal para páginas centradas en la marca.", + "name": "Audaz" + }, + "editorial": { + "description": "Encabezados con serifa y divisores finos. Distintivo y tranquilo.", + "name": "Editorial" + }, + "modern": { + "description": "Calidad SaaS con un punto de estado pulsante y tarjetas que se desvanecen suavemente.", + "name": "Moderno" + }, + "refined": { + "description": "Limpio, profesional, cercano al aspecto clásico de Checkmate.", + "name": "Refinado" + } + }, + "title": "Tema de la página" + }, + "themeMode": { + "auto": "Automático (seguir dispositivo del visitante)", + "dark": "Solo oscuro", + "label": "Modo de tema", + "light": "Solo claro" } }, "header": { "title": "Páginas de estado", "description": "Publica páginas públicas que muestren a tus clientes y partes interesadas la disponibilidad en tiempo real y el historial de incidentes." }, - "publicPage": { - "maintenance": { - "upcoming": "Mantenimiento programado", - "inProgress": "Mantenimiento en curso", - "completed": "Mantenimiento completado" - } + "editorial": { + "allUp": "Todos los sistemas funcionan con normalidad.", + "reportTitle": "Informe de servicio" + }, + "footer": { + "poweredBy": "Desarrollado por" } }, "uptime": { diff --git a/client/src/locales/fi.json b/client/src/locales/fi.json index 0fcf71906..f7e69775b 100644 --- a/client/src/locales/fi.json +++ b/client/src/locales/fi.json @@ -73,7 +73,9 @@ "paused": "keskeytetty", "na": "Ei saatavilla", "resolved": "Ratkaistu", - "responseTime": "Vasteaika" + "responseTime": "Vasteaika", + "idle": "Toimeton", + "running": "Käynnissä" }, "form": { "role": { @@ -120,6 +122,17 @@ "active": "Aktiivinen", "responseTime": "Vasteaika" } + }, + "statusCode": { + "down": "Alhaalla", + "networkError": { + "dns": "Isäntänimeä ei voitu selvittää (DNS-haku epäonnistui).", + "refused": "Palvelin hylkäsi yhteyden (portti suljettu tai palvelu ei kuuntele).", + "reset": "Yhteys nollattiin ennen vastauksen vastaanottamista.", + "timeout": "Palvelin ei vastannut ajoissa (pyynnön aikakatkaisu).", + "tls": "TLS- tai SSL-kättely epäonnistui (sertifikaatti- tai salausongelma).", + "unknown": "Kohteesta ei vastaanotettu HTTP-vastausta." + } } }, "components": { @@ -306,28 +319,28 @@ "common": { "passwordRules": { "length": { - "beginning": "", - "highlighted": "" + "beginning": "Vähintään", + "highlighted": "8 merkkiä pitkä" }, "lowercase": { - "beginning": "", - "highlighted": "" + "beginning": "Sisällettävä vähintään", + "highlighted": "yksi pieni kirjain" }, "match": { - "beginning": "", - "highlighted": "" + "beginning": "Salasanat", + "highlighted": "täytyy täsmätä" }, "number": { - "beginning": "", - "highlighted": "" + "beginning": "Sisällettävä vähintään", + "highlighted": "yksi numero" }, "special": { - "beginning": "", - "highlighted": "" + "beginning": "Sisällettävä vähintään", + "highlighted": "yksi erikoismerkki (!?@#$%^&*()-_=+[]{}|;:'\",./\\~`)" }, "uppercase": { - "beginning": "", - "highlighted": "" + "beginning": "Sisällettävä vähintään", + "highlighted": "yksi iso kirjain" } }, "form": { @@ -452,7 +465,8 @@ "optionPagespeed": "PageSpeed", "optionHardware": "Infrastruktuuri", "optionGrpc": "gRPC", - "optionWebSocket": "WebSocket" + "optionWebSocket": "WebSocket", + "optionDns": "DNS" }, "bulkPause": { "paused_one": "{{count}} näyttö keskeytetty", @@ -483,9 +497,9 @@ }, "matchMethod": { "label": "Vertailutapa", - "equal": "", - "include": "", - "regex": "" + "equal": "Yhtä suuri", + "include": "Sisältää", + "regex": "Säännöllinen lauseke" } }, "title": "Lisäasetukset" @@ -497,15 +511,15 @@ "label": "Tarkistusväli", "value": { "fifteenMinutes": "15 minuuttia", - "fifteenSeconds": "", + "fifteenSeconds": "15 sekuntia", "fiveMinutes": "5 minuuttia", - "fourMinutes": "", - "oneMinute": "", + "fourMinutes": "4 minuuttia", + "oneMinute": "1 minuutti", "tenMinutes": "10 minuuttia", "thirtyMinutes": "30 minuuttia", - "thirtySeconds": "", - "threeMinutes": "", - "twoMinutes": "" + "thirtySeconds": "30 sekuntia", + "threeMinutes": "3 minuuttia", + "twoMinutes": "2 minuuttia" } } }, @@ -534,8 +548,8 @@ "placeholder": "https://www.google.com" }, "game": { - "label": "", - "placeholder": "" + "label": "Valitse peli", + "placeholder": "Valitse peli" }, "port": { "label": "Monitoroitava portti", @@ -552,19 +566,40 @@ "captureUrl": { "label": "Capture-päätepiste", "placeholder": "http://192.168.1.10:5001/api/v1/metrics" + }, + "dnsRecordType": { + "label": "DNS-tietuetyyppi", + "value": { + "A": "A", + "AAAA": "AAAA", + "CNAME": "CNAME", + "MX": "MX", + "NS": "NS", + "TXT": "TXT" + } + }, + "dnsServer": { + "label": "DNS-palvelin", + "placeholder": "8.8.8.8" + }, + "strategy": { + "desktop": "Työpöytä", + "label": "Strategia", + "mobile": "Mobiili" } }, "title": "Yleiset asetukset", "description": { "http": "Syötä monitoroitava URL tai IP (esim. https://example.com/ tai 192.168.1.100) ja lisää selkeä näyttönimi, joka näkyy hallintapaneelissa.", - "ping": "", - "port": "", - "docker": "", + "ping": "Anna pingattava IP-osoite tai isäntänimi (esim. 192.168.1.100 tai example.com) ja lisää selkeä näyttönimi, joka näkyy hallintapaneelissa.", + "port": "Anna palvelimen URL tai IP, portin numero ja selkeä näyttönimi, joka näkyy hallintapaneelissa.", + "docker": "Anna Docker-kontin nimi tai tunnus. Voit käyttää joko kontin nimeä (esim. my-app) tai kontin tunnusta (täysi 64-merkkinen tunnus tai lyhyt tunnus).", "game": "Anna IP-osoite tai isäntänimi ja portin numero pingausta varten (esim. 192.168.1.100 tai example.com) ja valitse pelityyppi. Jotkin pelit vaativat palvelinkyselyiden käyttöönoton asetustiedostossaan — tarkista pelipalvelimesi dokumentaatio. Katso protokollatiedot tuettujen pelien luettelosta.", "pagespeed": "Seuraa sivun latausnopeutta, Core Web Vitals -arvoja ja optimointipisteitä verkkosivustollasi.", "grpc": "Syötä gRPC-palvelimen isäntänimi ja portti, määritä halutessasi palvelun nimi terveystarkistusta varten ja lisää näyttönimi.", "websocket": "Syötä monitoroitava WebSocket URL (esim. wss://example.com/socket) ja lisää näyttönimi.", - "hardware": "Seuraa infrastruktuurisi suorittimen, muistin, levyn käyttöä ja lämpötilaa. Capture-päätepisteen URL-osoitteen on sisällettävä koko polku /api/v1/metrics (esim. http://192.168.1.10:5001/api/v1/metrics). Liitä valtuutusavaimeen vain tokenin merkkijono — älä sisällytä sanaa 'Bearer'." + "hardware": "Seuraa infrastruktuurisi suorittimen, muistin, levyn käyttöä ja lämpötilaa. Capture-päätepisteen URL-osoitteen on sisällettävä koko polku /api/v1/metrics (esim. http://192.168.1.10:5001/api/v1/metrics). Liitä valtuutusavaimeen vain tokenin merkkijono — älä sisällytä sanaa 'Bearer'.", + "dns": "Tarkkaile, vastaako tietty DNS-palvelin." } }, "ignoreTls": { @@ -601,7 +636,8 @@ "optionHttpDescription": "Käytä HTTP(S)-protokollaa verkkosivustosi tai API-päätepisteen monitorointiin.", "optionPingDescription": "Käytä ICMP Ping -komentoa monitoroimaan, onko palvelin verkossa.", "optionPortDescription": "Monitoroi, onko tietty palvelimen portti auki.", - "title": "Tyyppi" + "title": "Tyyppi", + "optionDnsDescription": "Tarkkaile, vastaako tietty DNS-palvelin." }, "thresholds": { "title": "Hälytysrajat", @@ -765,7 +801,7 @@ "headers": { "endTime": "Päättymisaika", "resolutionType": "Ratkaisun tyyppi", - "startTime": "", + "startTime": "Aloitusaika", "statusCode": "Tilakoodi" }, "resolvedIncidents": "Ratkaistut häiriöt", @@ -809,13 +845,13 @@ } }, "statBoxes": { - "avgCpuTemperature": "", - "cpuFrequency": "", - "cpuLogical": "", - "cpuPhysical": "", + "avgCpuTemperature": "Keskimääräinen CPU-lämpötila", + "cpuFrequency": "CPU-taajuus", + "cpuLogical": "CPU (looginen)", + "cpuPhysical": "CPU (fyysinen)", "disk": "Levy", "memory": "Muisti", - "os": "" + "os": "Käyttöjärjestelmä" }, "table": { "headers": { @@ -826,7 +862,7 @@ }, "tabs": { "labels": { - "network": "", + "network": "Verkko", "overview": "Yleiskatsaus" } }, @@ -909,7 +945,8 @@ "lastFinishedAt": "Viimeisin valmistuminen", "lastRunTook": "Viimeisin suoritus kesti", "lastFailedAt": "Viimeisin epäonnistuminen", - "failReason": "Epäonnistumisen syy" + "failReason": "Epäonnistumisen syy", + "state": "Tila" } }, "queueFallback": { @@ -929,8 +966,8 @@ }, "table": { "headers": { - "nextWindow": "", - "repeat": "" + "nextWindow": "Seuraava ikkuna", + "repeat": "Toista" } }, "form": { @@ -1032,7 +1069,7 @@ }, "telegram": { "title": "Telegram-määritykset", - "description": "", + "description": "Määritä Telegram-bottisi lähettämään ilmoituksia.", "optionBotToken": "Bottitunniste", "placeholderBotToken": "123456789:ABCdefGhIJKlmNoPQRsTUVwxyZ", "optionChatId": "Chat-tunnus", @@ -1099,6 +1136,10 @@ "header": { "title": "PageSpeed-monitorit", "description": "Seuraa Core Web Vitals -mittareita ja Lighthouse-pisteitä ajan mittaan ja huomaa heikennykset ennen käyttäjiä." + }, + "strategy": { + "desktop": "Työpöytä", + "mobile": "Mobiili" } }, "settings": { @@ -1201,7 +1242,7 @@ "email": { "title": "Sähköpostiasetukset", "description": "Määritä järjestelmäsi sähköpostiasetukset. Niitä käytetään ilmoitusten ja hälytysten lähettämiseen.", - "descriptionTransport": "", + "descriptionTransport": "Tämä rakentaa SMTP-kuljetuksen NodeMailerille", "descriptionTransportLink": "Katso tekniset tiedot täältä", "option": { "host": { @@ -1247,6 +1288,10 @@ }, "rejectUnauthorized": { "label": "Hylkää virheelliset varmenteet: Hylkää yhteydet itseallekirjoitetuilla tai ei-luotetuilla varmenteilla" + }, + "displayName": { + "label": "Lähettäjän nimi (valinnainen) - Lähettäjäkentässä näkyvä ystävällinen nimi", + "placeholder": "Checkmate-ilmoitukset" } } }, @@ -1307,6 +1352,20 @@ "uptime": { "title": "Käytettävyys", "responseTime": "Vasteaika" + }, + "chart": { + "avg": "ka {{value}}ms", + "downTooltip": "Alhaalla", + "heatmapAria": "Vasteajan lämpökartta", + "max": "max {{value}}ms" + }, + "status": { + "breached": "Kynnys ylittyi", + "down": "Alhaalla", + "initializing": "Alustetaan", + "maintenance": "Huolto", + "paused": "Keskeytetty", + "up": "Toiminnassa" } }, "statusBar": { @@ -1324,7 +1383,9 @@ "maintenance": "Jotkin järjestelmät ovat huollossa", "partiallyPaused": "Jotkin järjestelmät on keskeytetty", "initializing": "Jotkin järjestelmät alustuvat", - "noMonitors": "Yhtään monitoria ei ole määritetty" + "noMonitors": "Yhtään monitoria ei ole määritetty", + "monitoringSummary": "Tarkkaillaan {{count}} palvelua · Päivitykset 10 sekunnin välein", + "monitoringSummary_other": "Tarkkaillaan {{count}} palvelua · Päivitykset 10 sekunnin välein" }, "table": { "headers": { @@ -1415,11 +1476,47 @@ "label": "Näytä infrastruktuurin mittarit" } } + }, + "theme": { + "description": "Valitse julkisen tilasivusi visuaalinen tyyli.", + "label": "Teema", + "options": { + "bold": { + "description": "Suuri typografia ja itsevarma välistys. Sopii brändilähtöisille sivuille.", + "name": "Rohkea" + }, + "editorial": { + "description": "Antiikvaotsikot ja ohuet erottimet. Erottuva ja rauhallinen.", + "name": "Toimituksellinen" + }, + "modern": { + "description": "SaaS-tasoa sykkivällä tilapisteellä ja pehmeästi haalistuvilla korteilla.", + "name": "Moderni" + }, + "refined": { + "description": "Siisti, ammattimainen, lähellä klassista Checkmate-ilmettä.", + "name": "Hienostunut" + } + }, + "title": "Sivun teema" + }, + "themeMode": { + "auto": "Automaattinen (seuraa vierailijan laitetta)", + "dark": "Vain tumma", + "label": "Teematila", + "light": "Vain vaalea" } }, "header": { "title": "Tilasivut", "description": "Julkaise julkisia sivuja, jotka näyttävät asiakkaille ja sidosryhmille reaaliaikaisen käytettävyyden ja tapaushistorian." + }, + "editorial": { + "allUp": "Kaikki järjestelmät toimivat normaalisti.", + "reportTitle": "Palveluraportti" + }, + "footer": { + "poweredBy": "Tarjoaa" } }, "uptime": { diff --git a/client/src/locales/fr.json b/client/src/locales/fr.json index c35956b46..d398864e2 100644 --- a/client/src/locales/fr.json +++ b/client/src/locales/fr.json @@ -73,7 +73,9 @@ "paused": "en pause", "na": "N/A", "resolved": "Résolu", - "responseTime": "Temps de réponse" + "responseTime": "Temps de réponse", + "idle": "Inactif", + "running": "En cours d'exécution" }, "form": { "role": { @@ -120,6 +122,17 @@ "active": "Actif", "responseTime": "Temps de réponse" } + }, + "statusCode": { + "down": "Hors service", + "networkError": { + "dns": "Impossible de résoudre le nom d'hôte (échec de la recherche DNS).", + "refused": "Le serveur a refusé la connexion (port fermé ou service non à l'écoute).", + "reset": "La connexion a été réinitialisée avant la réception d'une réponse.", + "timeout": "Le serveur n'a pas répondu à temps (délai d'attente dépassé).", + "tls": "Échec de la négociation TLS ou SSL (problème de certificat ou de chiffrement).", + "unknown": "Aucune réponse HTTP reçue de la cible." + } } }, "components": { @@ -452,7 +465,8 @@ "optionPagespeed": "PageSpeed", "optionHardware": "Infrastructure", "optionGrpc": "gRPC", - "optionWebSocket": "WebSocket" + "optionWebSocket": "WebSocket", + "optionDns": "DNS" }, "bulkPause": { "paused_one": "{{count}} moniteur en pause", @@ -534,8 +548,8 @@ "placeholder": "https://www.google.com" }, "game": { - "label": "", - "placeholder": "" + "label": "Choisir un jeu", + "placeholder": "Sélectionner un jeu" }, "port": { "label": "Port à surveiller", @@ -552,6 +566,26 @@ "captureUrl": { "label": "Point de terminaison Capture", "placeholder": "http://192.168.1.10:5001/api/v1/metrics" + }, + "dnsRecordType": { + "label": "Type d'enregistrement DNS", + "value": { + "A": "A", + "AAAA": "AAAA", + "CNAME": "CNAME", + "MX": "MX", + "NS": "NS", + "TXT": "TXT" + } + }, + "dnsServer": { + "label": "Serveur DNS", + "placeholder": "8.8.8.8" + }, + "strategy": { + "desktop": "Bureau", + "label": "Stratégie", + "mobile": "Mobile" } }, "title": "Paramètres généraux", @@ -564,7 +598,8 @@ "pagespeed": "Suivez les performances de chargement, les Core Web Vitals et les scores d'optimisation de votre site web.", "grpc": "Saisissez le nom d'hôte et le port du serveur gRPC, spécifiez éventuellement un nom de service pour le bilan de santé, et ajoutez un nom d'affichage.", "websocket": "Saisissez l'URL WebSocket à surveiller (ex. wss://example.com/socket) et ajoutez un nom d'affichage.", - "hardware": "Surveillez l'utilisation du processeur, de la mémoire, du disque et la température de votre infrastructure. L'URL du point de terminaison Capture doit inclure le chemin complet /api/v1/metrics (par ex. http://192.168.1.10:5001/api/v1/metrics). Pour le secret d'autorisation, collez uniquement la chaîne du jeton — n'incluez pas 'Bearer'." + "hardware": "Surveillez l'utilisation du processeur, de la mémoire, du disque et la température de votre infrastructure. L'URL du point de terminaison Capture doit inclure le chemin complet /api/v1/metrics (par ex. http://192.168.1.10:5001/api/v1/metrics). Pour le secret d'autorisation, collez uniquement la chaîne du jeton — n'incluez pas 'Bearer'.", + "dns": "Surveillez si un serveur DNS spécifique répond." } }, "ignoreTls": { @@ -601,7 +636,8 @@ "optionHttpDescription": "Utilisez HTTP(S) pour surveiller votre site web ou point de terminaison API.", "optionPingDescription": "Utilisez le Ping ICMP pour surveiller si un serveur est en ligne.", "optionPortDescription": "Surveillez si un port spécifique sur un serveur est ouvert.", - "title": "Type" + "title": "Type", + "optionDnsDescription": "Surveillez si un serveur DNS spécifique répond." }, "thresholds": { "title": "Seuils d'alerte", @@ -826,7 +862,7 @@ }, "tabs": { "labels": { - "network": "", + "network": "Réseau", "overview": "Aperçu" } }, @@ -909,7 +945,8 @@ "lastFinishedAt": "Dernière fin", "lastRunTook": "Durée dernière exécution", "lastFailedAt": "Dernier échec", - "failReason": "Raison de l'échec" + "failReason": "Raison de l'échec", + "state": "État" } }, "queueFallback": { @@ -1099,6 +1136,10 @@ "header": { "title": "Moniteurs PageSpeed", "description": "Suivez les Core Web Vitals et les scores Lighthouse au fil du temps pour détecter les régressions avant vos utilisateurs." + }, + "strategy": { + "desktop": "Bureau", + "mobile": "Mobile" } }, "settings": { @@ -1247,6 +1288,10 @@ }, "rejectUnauthorized": { "label": "Rejeter les certificats invalides : refuser les connexions avec des certificats auto-signés ou non fiables" + }, + "displayName": { + "label": "Nom de l'expéditeur (facultatif) - Nom convivial affiché dans le champ De", + "placeholder": "Notifications Checkmate" } } }, @@ -1307,6 +1352,20 @@ "uptime": { "title": "Disponibilité", "responseTime": "Temps de réponse" + }, + "chart": { + "avg": "moy {{value}}ms", + "downTooltip": "Hors service", + "heatmapAria": "Carte thermique des temps de réponse", + "max": "max {{value}}ms" + }, + "status": { + "breached": "Seuil dépassé", + "down": "Hors service", + "initializing": "Initialisation", + "maintenance": "Maintenance", + "paused": "En pause", + "up": "Opérationnel" } }, "statusBar": { @@ -1324,7 +1383,9 @@ "maintenance": "Certains systèmes sont en maintenance", "partiallyPaused": "Certains systèmes sont en pause", "initializing": "Certains systèmes sont en cours d'initialisation", - "noMonitors": "Aucun moniteur n'a été configuré" + "noMonitors": "Aucun moniteur n'a été configuré", + "monitoringSummary": "Surveillance de {{count}} service · Mises à jour toutes les 10 secondes", + "monitoringSummary_other": "Surveillance de {{count}} services · Mises à jour toutes les 10 secondes" }, "table": { "headers": { @@ -1415,11 +1476,47 @@ "label": "Afficher les métriques d'infrastructure" } } + }, + "theme": { + "description": "Choisissez le style visuel de votre page de statut publique.", + "label": "Thème", + "options": { + "bold": { + "description": "Grande typographie et espacement assuré. Idéal pour les pages axées sur la marque.", + "name": "Audacieux" + }, + "editorial": { + "description": "Titres avec serif et séparateurs fins. Distinctif et calme.", + "name": "Éditorial" + }, + "modern": { + "description": "Qualité SaaS avec un point de statut pulsant et des cartes qui s'estompent doucement.", + "name": "Moderne" + }, + "refined": { + "description": "Propre, professionnel, proche du look classique de Checkmate.", + "name": "Raffiné" + } + }, + "title": "Thème de la page" + }, + "themeMode": { + "auto": "Automatique (selon l'appareil du visiteur)", + "dark": "Sombre uniquement", + "label": "Mode de thème", + "light": "Clair uniquement" } }, "header": { "title": "Pages de statut", "description": "Publiez des pages publiques montrant à vos clients et parties prenantes la disponibilité en temps réel et l'historique des incidents." + }, + "editorial": { + "allUp": "Tous les systèmes fonctionnent normalement.", + "reportTitle": "Rapport de service" + }, + "footer": { + "poweredBy": "Propulsé par" } }, "uptime": { diff --git a/client/src/locales/ja.json b/client/src/locales/ja.json index ecd1b7e87..af53ce232 100644 --- a/client/src/locales/ja.json +++ b/client/src/locales/ja.json @@ -73,7 +73,9 @@ "paused": "一時停止中", "na": "N/A", "resolved": "解決済み", - "responseTime": "応答時間" + "responseTime": "応答時間", + "idle": "アイドル", + "running": "実行中" }, "form": { "role": { @@ -120,6 +122,17 @@ "active": "アクティブ", "responseTime": "応答時間" } + }, + "statusCode": { + "down": "ダウン", + "networkError": { + "dns": "ホスト名を解決できませんでした(DNSルックアップに失敗)。", + "refused": "サーバーが接続を拒否しました(ポートが閉じているか、サービスがリッスンしていません)。", + "reset": "応答を受信する前に接続がリセットされました。", + "timeout": "サーバーがタイムアウト内に応答しませんでした(リクエストタイムアウト)。", + "tls": "TLSまたはSSLハンドシェイクに失敗しました(証明書または暗号化の問題)。", + "unknown": "ターゲットからのHTTP応答がありませんでした。" + } } }, "components": { @@ -452,7 +465,8 @@ "optionPagespeed": "PageSpeed", "optionHardware": "インフラストラクチャ", "optionGrpc": "gRPC", - "optionWebSocket": "WebSocket" + "optionWebSocket": "WebSocket", + "optionDns": "DNS" }, "bulkPause": { "paused_one": "{{count}} 個のモニターを一時停止しました", @@ -483,9 +497,9 @@ }, "matchMethod": { "label": "マッチ方法", - "equal": "", - "include": "", - "regex": "" + "equal": "等しい", + "include": "含む", + "regex": "正規表現" } }, "title": "詳細設定" @@ -497,15 +511,15 @@ "label": "チェック頻度", "value": { "fifteenMinutes": "15分", - "fifteenSeconds": "", + "fifteenSeconds": "15秒", "fiveMinutes": "5分", - "fourMinutes": "", - "oneMinute": "", + "fourMinutes": "4分", + "oneMinute": "1分", "tenMinutes": "10分", "thirtyMinutes": "30分", - "thirtySeconds": "", + "thirtySeconds": "30秒", "threeMinutes": "3分", - "twoMinutes": "" + "twoMinutes": "2分" } } }, @@ -534,8 +548,8 @@ "placeholder": "https://www.google.com" }, "game": { - "label": "", - "placeholder": "" + "label": "ゲームを選択", + "placeholder": "ゲームを選択" }, "port": { "label": "監視するポート", @@ -552,6 +566,26 @@ "captureUrl": { "label": "Capture エンドポイント", "placeholder": "http://192.168.1.10:5001/api/v1/metrics" + }, + "dnsRecordType": { + "label": "DNSレコードタイプ", + "value": { + "A": "A", + "AAAA": "AAAA", + "CNAME": "CNAME", + "MX": "MX", + "NS": "NS", + "TXT": "TXT" + } + }, + "dnsServer": { + "label": "DNSサーバー", + "placeholder": "8.8.8.8" + }, + "strategy": { + "desktop": "デスクトップ", + "label": "ストラテジー", + "mobile": "モバイル" } }, "title": "一般設定", @@ -564,7 +598,8 @@ "pagespeed": "Webサイトのページ読み込みパフォーマンス、Core Web Vitals、最適化スコアを追跡します。", "grpc": "gRPCサーバーのホスト名とポートを入力し、必要に応じてヘルスチェック用のサービス名を指定し、表示名を追加してください。", "websocket": "監視するWebSocket URLを入力し(例: wss://example.com/socket)、表示名を追加してください。", - "hardware": "インフラの CPU、メモリ、ディスク使用率、温度を監視します。Capture エンドポイント URL には完全なパス /api/v1/metrics を含める必要があります (例: http://192.168.1.10:5001/api/v1/metrics)。認証シークレットにはトークン文字列のみを貼り付けてください — 'Bearer' は含めないでください。" + "hardware": "インフラの CPU、メモリ、ディスク使用率、温度を監視します。Capture エンドポイント URL には完全なパス /api/v1/metrics を含める必要があります (例: http://192.168.1.10:5001/api/v1/metrics)。認証シークレットにはトークン文字列のみを貼り付けてください — 'Bearer' は含めないでください。", + "dns": "特定のDNSサーバーが応答しているかを監視します。" } }, "ignoreTls": { @@ -601,7 +636,8 @@ "optionHttpDescription": "HTTP(S)を使用してWebサイトまたはAPIエンドポイントを監視します。", "optionPingDescription": "ICMP Pingを使用してサーバーがオンラインかどうかを監視します。", "optionPortDescription": "サーバーの特定のポートが開いているかどうかを監視します。", - "title": "タイプ" + "title": "タイプ", + "optionDnsDescription": "特定のDNSサーバーが応答しているかを監視します。" }, "thresholds": { "title": "アラートしきい値", @@ -826,7 +862,7 @@ }, "tabs": { "labels": { - "network": "", + "network": "ネットワーク", "overview": "概要" } }, @@ -909,7 +945,8 @@ "lastFinishedAt": "最終完了日時", "lastRunTook": "最終実行時間", "lastFailedAt": "最終失敗日時", - "failReason": "失敗理由" + "failReason": "失敗理由", + "state": "状態" } }, "queueFallback": { @@ -1099,6 +1136,10 @@ "header": { "title": "PageSpeed監視", "description": "Core Web VitalsとLighthouseスコアを継続的に追跡し、ユーザーが気付く前に劣化を発見します。" + }, + "strategy": { + "desktop": "デスクトップ", + "mobile": "モバイル" } }, "settings": { @@ -1247,6 +1288,10 @@ }, "rejectUnauthorized": { "label": "無効な証明書を拒否: 自己署名または信頼されていない証明書の接続を拒否" + }, + "displayName": { + "label": "送信者名(任意)- 差出人フィールドに表示されるわかりやすい名前", + "placeholder": "Checkmate通知" } } }, @@ -1307,6 +1352,20 @@ "uptime": { "title": "稼働時間", "responseTime": "応答時間" + }, + "chart": { + "avg": "平均 {{value}}ms", + "downTooltip": "ダウン", + "heatmapAria": "応答時間のヒートマップ", + "max": "最大 {{value}}ms" + }, + "status": { + "breached": "しきい値超過", + "down": "ダウン", + "initializing": "初期化中", + "maintenance": "メンテナンス", + "paused": "一時停止中", + "up": "稼働中" } }, "statusBar": { @@ -1324,7 +1383,9 @@ "maintenance": "一部のシステムがメンテナンス中です", "partiallyPaused": "一部のシステムが一時停止中です", "initializing": "一部のシステムが初期化中です", - "noMonitors": "モニターが設定されていません" + "noMonitors": "モニターが設定されていません", + "monitoringSummary": "{{count}}サービスを監視中 · 10秒ごとに更新", + "monitoringSummary_other": "{{count}}サービスを監視中 · 10秒ごとに更新" }, "table": { "headers": { @@ -1415,11 +1476,47 @@ "label": "インフラストラクチャメトリクスを表示" } } + }, + "theme": { + "description": "公開ステータスページの視覚スタイルを選択します。", + "label": "テーマ", + "options": { + "bold": { + "description": "大きな書体と自信のある間隔。ブランド中心のページに最適です。", + "name": "ボールド" + }, + "editorial": { + "description": "セリフ見出しと細い区切り線。独特で落ち着いています。", + "name": "エディトリアル" + }, + "modern": { + "description": "脈動するステータスドットと柔らかくフェードするカードを備えたSaaS品質。", + "name": "モダン" + }, + "refined": { + "description": "クリーンでプロフェッショナル、Checkmateのクラシックな外観に近い。", + "name": "リファインド" + } + }, + "title": "ページテーマ" + }, + "themeMode": { + "auto": "自動(訪問者のデバイスに従う)", + "dark": "ダークのみ", + "label": "テーマモード", + "light": "ライトのみ" } }, "header": { "title": "ステータスページ", "description": "顧客と関係者にリアルタイムの稼働状況とインシデント履歴を表示する公開ページを公開します。" + }, + "editorial": { + "allUp": "すべてのシステムは正常に動作しています。", + "reportTitle": "サービスレポート" + }, + "footer": { + "poweredBy": "提供元" } }, "uptime": { diff --git a/client/src/locales/pt-BR.json b/client/src/locales/pt-BR.json index 072239a49..1ab833c0c 100644 --- a/client/src/locales/pt-BR.json +++ b/client/src/locales/pt-BR.json @@ -73,7 +73,9 @@ "paused": "pausado", "na": "N/D", "resolved": "Resolvido", - "responseTime": "Tempo de resposta" + "responseTime": "Tempo de resposta", + "idle": "Inativo", + "running": "Em execução" }, "form": { "role": { @@ -120,6 +122,17 @@ "active": "Ativo", "responseTime": "Tempo de resposta" } + }, + "statusCode": { + "down": "Fora do ar", + "networkError": { + "dns": "Não foi possível resolver o nome do host (a busca DNS falhou).", + "refused": "O servidor recusou a conexão (porta fechada ou serviço não escutando).", + "reset": "A conexão foi redefinida antes que uma resposta fosse recebida.", + "timeout": "O servidor não respondeu a tempo (tempo limite da solicitação esgotado).", + "tls": "Falha no handshake TLS ou SSL (problema de certificado ou criptografia).", + "unknown": "Nenhuma resposta HTTP recebida do destino." + } } }, "components": { @@ -452,7 +465,8 @@ "optionPagespeed": "PageSpeed", "optionHardware": "Infraestrutura", "optionGrpc": "gRPC", - "optionWebSocket": "WebSocket" + "optionWebSocket": "WebSocket", + "optionDns": "DNS" }, "bulkPause": { "paused_one": "{{count}} monitor pausado", @@ -483,8 +497,8 @@ }, "matchMethod": { "label": "Método de correspondência", - "equal": "", - "include": "", + "equal": "Igual", + "include": "Inclui", "regex": "Regex" } }, @@ -497,13 +511,13 @@ "label": "Frequência de verificação", "value": { "fifteenMinutes": "15 minutos", - "fifteenSeconds": "", + "fifteenSeconds": "15 segundos", "fiveMinutes": "5 minutos", "fourMinutes": "4 minutos", "oneMinute": "1 minuto", "tenMinutes": "10 minutos", "thirtyMinutes": "30 minutos", - "thirtySeconds": "", + "thirtySeconds": "30 segundos", "threeMinutes": "3 minutos", "twoMinutes": "2 minutos" } @@ -534,8 +548,8 @@ "placeholder": "https://www.google.com" }, "game": { - "label": "", - "placeholder": "" + "label": "Escolher jogo", + "placeholder": "Selecione um jogo" }, "port": { "label": "Porta para monitorar", @@ -552,6 +566,26 @@ "captureUrl": { "label": "Endpoint do Capture", "placeholder": "http://192.168.1.10:5001/api/v1/metrics" + }, + "dnsRecordType": { + "label": "Tipo de registro DNS", + "value": { + "A": "A", + "AAAA": "AAAA", + "CNAME": "CNAME", + "MX": "MX", + "NS": "NS", + "TXT": "TXT" + } + }, + "dnsServer": { + "label": "Servidor DNS", + "placeholder": "8.8.8.8" + }, + "strategy": { + "desktop": "Desktop", + "label": "Estratégia", + "mobile": "Mobile" } }, "title": "Configurações gerais", @@ -564,7 +598,8 @@ "pagespeed": "Acompanhe o desempenho de carregamento da página, Core Web Vitals e pontuações de otimização do seu site.", "grpc": "Digite o hostname e a porta do servidor gRPC, opcionalmente especifique um nome de serviço para a verificação de saúde e adicione um nome de exibição.", "websocket": "Digite a URL do WebSocket para monitorar (ex.: wss://exemplo.com/socket) e adicione um nome de exibição.", - "hardware": "Monitore o uso de CPU, memória, disco e temperatura da sua infraestrutura. A URL do endpoint do Capture deve incluir o caminho completo /api/v1/metrics (por exemplo, http://192.168.1.10:5001/api/v1/metrics). Para a chave de autorização, cole apenas a string do token — não inclua 'Bearer'." + "hardware": "Monitore o uso de CPU, memória, disco e temperatura da sua infraestrutura. A URL do endpoint do Capture deve incluir o caminho completo /api/v1/metrics (por exemplo, http://192.168.1.10:5001/api/v1/metrics). Para a chave de autorização, cole apenas a string do token — não inclua 'Bearer'.", + "dns": "Monitore se um servidor DNS específico está respondendo." } }, "ignoreTls": { @@ -601,7 +636,8 @@ "optionHttpDescription": "Use HTTP(S) para monitorar seu site ou endpoint de API.", "optionPingDescription": "Use ICMP Ping para monitorar se um servidor está online.", "optionPortDescription": "Monitore se uma porta específica em um servidor está aberta.", - "title": "Tipo" + "title": "Tipo", + "optionDnsDescription": "Monitore se um servidor DNS específico está respondendo." }, "thresholds": { "title": "Limites de alerta", @@ -826,7 +862,7 @@ }, "tabs": { "labels": { - "network": "", + "network": "Rede", "overview": "Visão geral" } }, @@ -909,7 +945,8 @@ "lastFinishedAt": "Última conclusão em", "lastRunTook": "Última execução durou", "lastFailedAt": "Última falha em", - "failReason": "Motivo da falha" + "failReason": "Motivo da falha", + "state": "Estado" } }, "queueFallback": { @@ -1099,6 +1136,10 @@ "header": { "title": "Monitores PageSpeed", "description": "Acompanhe Core Web Vitals e pontuações do Lighthouse ao longo do tempo para detectar regressões antes dos seus usuários." + }, + "strategy": { + "desktop": "Desktop", + "mobile": "Mobile" } }, "settings": { @@ -1247,6 +1288,10 @@ }, "rejectUnauthorized": { "label": "Rejeitar certificados inválidos: Rejeitar conexões com certificados autoassinados ou não confiáveis" + }, + "displayName": { + "label": "Nome do remetente (opcional) - Nome amigável exibido no campo De", + "placeholder": "Notificações do Checkmate" } } }, @@ -1307,6 +1352,20 @@ "uptime": { "title": "Uptime", "responseTime": "Tempo de resposta" + }, + "chart": { + "avg": "méd {{value}}ms", + "downTooltip": "Fora do ar", + "heatmapAria": "Mapa de calor do tempo de resposta", + "max": "máx {{value}}ms" + }, + "status": { + "breached": "Limite excedido", + "down": "Fora do ar", + "initializing": "Inicializando", + "maintenance": "Manutenção", + "paused": "Pausado", + "up": "Operacional" } }, "statusBar": { @@ -1324,7 +1383,9 @@ "maintenance": "Alguns sistemas estão em manutenção", "partiallyPaused": "Alguns sistemas estão pausados", "initializing": "Alguns sistemas estão inicializando", - "noMonitors": "Nenhum monitor foi configurado" + "noMonitors": "Nenhum monitor foi configurado", + "monitoringSummary": "Monitorando {{count}} serviço · Atualizações a cada 10 segundos", + "monitoringSummary_other": "Monitorando {{count}} serviços · Atualizações a cada 10 segundos" }, "table": { "headers": { @@ -1415,11 +1476,47 @@ "label": "Mostrar métricas de infraestrutura" } } + }, + "theme": { + "description": "Escolha o estilo visual da sua página de status pública.", + "label": "Tema", + "options": { + "bold": { + "description": "Tipografia grande e espaçamento confiante. Ótimo para páginas voltadas à marca.", + "name": "Negrito" + }, + "editorial": { + "description": "Títulos com serifa e divisores finos. Distinto e calmo.", + "name": "Editorial" + }, + "modern": { + "description": "Qualidade SaaS com um ponto de status pulsante e cartões que desaparecem suavemente.", + "name": "Moderno" + }, + "refined": { + "description": "Limpo, profissional, próximo ao visual clássico do Checkmate.", + "name": "Refinado" + } + }, + "title": "Tema da página" + }, + "themeMode": { + "auto": "Automático (seguir dispositivo do visitante)", + "dark": "Apenas escuro", + "label": "Modo de tema", + "light": "Apenas claro" } }, "header": { "title": "Páginas de status", "description": "Publique páginas públicas que mostram aos seus clientes e partes interessadas o tempo de atividade em tempo real e o histórico de incidentes." + }, + "editorial": { + "allUp": "Todos os sistemas estão operando normalmente.", + "reportTitle": "Relatório de serviço" + }, + "footer": { + "poweredBy": "Desenvolvido por" } }, "uptime": { diff --git a/client/src/locales/ru.json b/client/src/locales/ru.json index c6282e75a..c6f247ba8 100644 --- a/client/src/locales/ru.json +++ b/client/src/locales/ru.json @@ -73,7 +73,9 @@ "paused": "приостановлен", "na": "Н/Д", "resolved": "Решён", - "responseTime": "Время ответа" + "responseTime": "Время ответа", + "idle": "Бездействие", + "running": "Выполняется" }, "form": { "role": { @@ -120,6 +122,17 @@ "active": "Активный", "responseTime": "Время ответа" } + }, + "statusCode": { + "down": "Не работает", + "networkError": { + "dns": "Не удалось разрешить имя хоста (поиск DNS не удался).", + "refused": "Сервер отказал в соединении (порт закрыт или служба не прослушивает).", + "reset": "Соединение было сброшено до получения ответа.", + "timeout": "Сервер не ответил вовремя (тайм-аут запроса).", + "tls": "Сбой рукопожатия TLS или SSL (проблема с сертификатом или шифрованием).", + "unknown": "HTTP-ответ от цели не получен." + } } }, "components": { @@ -452,7 +465,8 @@ "optionPagespeed": "PageSpeed", "optionHardware": "Инфраструктура", "optionGrpc": "gRPC", - "optionWebSocket": "WebSocket" + "optionWebSocket": "WebSocket", + "optionDns": "DNS" }, "bulkPause": { "paused_one": "{{count}} монитор приостановлен", @@ -552,6 +566,26 @@ "captureUrl": { "label": "Эндпоинт Capture", "placeholder": "http://192.168.1.10:5001/api/v1/metrics" + }, + "dnsRecordType": { + "label": "Тип записи DNS", + "value": { + "A": "A", + "AAAA": "AAAA", + "CNAME": "CNAME", + "MX": "MX", + "NS": "NS", + "TXT": "TXT" + } + }, + "dnsServer": { + "label": "DNS-сервер", + "placeholder": "8.8.8.8" + }, + "strategy": { + "desktop": "Десктоп", + "label": "Стратегия", + "mobile": "Мобильный" } }, "title": "Основные настройки", @@ -564,7 +598,8 @@ "pagespeed": "Отслеживайте производительность загрузки, Core Web Vitals и показатели оптимизации для Вашего сайта.", "grpc": "Введите имя хоста и порт gRPC-сервера, при необходимости укажите имя сервиса для проверки состояния и добавьте отображаемое имя.", "websocket": "Введите WebSocket URL для мониторинга (напр., wss://example.com/socket) и добавьте отображаемое имя.", - "hardware": "Отслеживайте использование ЦП, памяти, диска и температуру вашей инфраструктуры. URL эндпоинта Capture должен включать полный путь /api/v1/metrics (например, http://192.168.1.10:5001/api/v1/metrics). В секрете авторизации укажите только строку токена — не включайте 'Bearer'." + "hardware": "Отслеживайте использование ЦП, памяти, диска и температуру вашей инфраструктуры. URL эндпоинта Capture должен включать полный путь /api/v1/metrics (например, http://192.168.1.10:5001/api/v1/metrics). В секрете авторизации укажите только строку токена — не включайте 'Bearer'.", + "dns": "Отслеживайте, отвечает ли конкретный DNS-сервер." } }, "ignoreTls": { @@ -601,7 +636,8 @@ "optionHttpDescription": "Используйте HTTP(S) для мониторинга Вашего сайта или API-эндпоинта.", "optionPingDescription": "Используйте ICMP Ping для проверки доступности сервера.", "optionPortDescription": "Мониторинг доступности определённого порта на сервере.", - "title": "Тип" + "title": "Тип", + "optionDnsDescription": "Отслеживайте, отвечает ли конкретный DNS-сервер." }, "thresholds": { "title": "Пороги оповещений", @@ -909,7 +945,8 @@ "lastFinishedAt": "Последнее завершение", "lastRunTook": "Последний запуск занял", "lastFailedAt": "Последняя ошибка", - "failReason": "Причина ошибки" + "failReason": "Причина ошибки", + "state": "Состояние" } }, "queueFallback": { @@ -1099,6 +1136,10 @@ "header": { "title": "Мониторы PageSpeed", "description": "Отслеживайте Core Web Vitals и оценки Lighthouse во времени, чтобы заметить регрессии раньше пользователей." + }, + "strategy": { + "desktop": "Десктоп", + "mobile": "Мобильный" } }, "settings": { @@ -1247,6 +1288,10 @@ }, "rejectUnauthorized": { "label": "Отклонять недействительные сертификаты: отклонять соединения с самоподписанными или ненадёжными сертификатами" + }, + "displayName": { + "label": "Имя отправителя (необязательно) - Понятное имя в поле От", + "placeholder": "Уведомления Checkmate" } } }, @@ -1307,6 +1352,20 @@ "uptime": { "title": "Аптайм", "responseTime": "Время ответа" + }, + "chart": { + "avg": "ср {{value}}мс", + "downTooltip": "Не работает", + "heatmapAria": "Тепловая карта времени отклика", + "max": "макс {{value}}мс" + }, + "status": { + "breached": "Превышен порог", + "down": "Не работает", + "initializing": "Инициализация", + "maintenance": "Обслуживание", + "paused": "Приостановлено", + "up": "Работает" } }, "statusBar": { @@ -1324,7 +1383,9 @@ "maintenance": "Некоторые системы на техобслуживании", "partiallyPaused": "Некоторые системы приостановлены", "initializing": "Некоторые системы инициализируются", - "noMonitors": "Мониторы не настроены" + "noMonitors": "Мониторы не настроены", + "monitoringSummary": "Отслеживается {{count}} сервис · Обновление каждые 10 секунд", + "monitoringSummary_other": "Отслеживается {{count}} сервисов · Обновление каждые 10 секунд" }, "table": { "headers": { @@ -1415,11 +1476,47 @@ "label": "Показывать метрики инфраструктуры" } } + }, + "theme": { + "description": "Выберите визуальный стиль для вашей публичной страницы состояния.", + "label": "Тема", + "options": { + "bold": { + "description": "Крупная типографика и уверенные отступы. Отлично подходит для брендовых страниц.", + "name": "Жирный" + }, + "editorial": { + "description": "Заголовки с засечками и тонкие разделители. Отличительный и спокойный.", + "name": "Редакционный" + }, + "modern": { + "description": "Уровень SaaS с пульсирующей точкой состояния и плавно исчезающими карточками.", + "name": "Современный" + }, + "refined": { + "description": "Чистый, профессиональный, близкий к классическому виду Checkmate.", + "name": "Утонченный" + } + }, + "title": "Тема страницы" + }, + "themeMode": { + "auto": "Авто (по устройству посетителя)", + "dark": "Только темная", + "label": "Режим темы", + "light": "Только светлая" } }, "header": { "title": "Страницы состояния", "description": "Публикуйте публичные страницы со временем работы и историей инцидентов для клиентов и заинтересованных сторон." + }, + "editorial": { + "allUp": "Все системы работают в нормальном режиме.", + "reportTitle": "Отчет о сервисе" + }, + "footer": { + "poweredBy": "Работает на" } }, "uptime": { diff --git a/client/src/locales/th.json b/client/src/locales/th.json index 14e7d5947..34a0755fc 100644 --- a/client/src/locales/th.json +++ b/client/src/locales/th.json @@ -73,7 +73,9 @@ "paused": "หยุดชั่วคราว", "na": "ไม่มีข้อมูล", "resolved": "แก้ไขแล้ว", - "responseTime": "เวลาตอบสนอง" + "responseTime": "เวลาตอบสนอง", + "idle": "ไม่ทำงาน", + "running": "กำลังทำงาน" }, "form": { "role": { @@ -120,6 +122,17 @@ "active": "ใช้งานอยู่", "responseTime": "เวลาตอบสนอง" } + }, + "statusCode": { + "down": "ไม่พร้อมใช้งาน", + "networkError": { + "dns": "ไม่สามารถแก้ไขชื่อโฮสต์ได้ (การค้นหา DNS ล้มเหลว)", + "refused": "เซิร์ฟเวอร์ปฏิเสธการเชื่อมต่อ (พอร์ตปิดอยู่หรือบริการไม่ได้รับฟัง)", + "reset": "การเชื่อมต่อถูกรีเซ็ตก่อนที่จะได้รับการตอบกลับ", + "timeout": "เซิร์ฟเวอร์ไม่ตอบกลับทันเวลา (คำขอหมดเวลา)", + "tls": "การจับมือ TLS หรือ SSL ล้มเหลว (ปัญหาใบรับรองหรือการเข้ารหัส)", + "unknown": "ไม่ได้รับการตอบกลับ HTTP จากเป้าหมาย" + } } }, "components": { @@ -452,7 +465,8 @@ "optionPagespeed": "PageSpeed", "optionHardware": "โครงสร้างพื้นฐาน", "optionGrpc": "gRPC", - "optionWebSocket": "WebSocket" + "optionWebSocket": "WebSocket", + "optionDns": "DNS" }, "bulkPause": { "paused_one": "หยุดจอภาพชั่วคราว {{count}} จอ", @@ -552,6 +566,26 @@ "captureUrl": { "label": "ปลายทาง Capture", "placeholder": "http://192.168.1.10:5001/api/v1/metrics" + }, + "dnsRecordType": { + "label": "ประเภทเรกคอร์ด DNS", + "value": { + "A": "A", + "AAAA": "AAAA", + "CNAME": "CNAME", + "MX": "MX", + "NS": "NS", + "TXT": "TXT" + } + }, + "dnsServer": { + "label": "เซิร์ฟเวอร์ DNS", + "placeholder": "8.8.8.8" + }, + "strategy": { + "desktop": "เดสก์ท็อป", + "label": "กลยุทธ์", + "mobile": "มือถือ" } }, "title": "ตั้งค่าทั่วไป", @@ -564,7 +598,8 @@ "pagespeed": "ติดตามประสิทธิภาพการโหลดหน้า Core Web Vitals และคะแนนการเพิ่มประสิทธิภาพของเว็บไซต์", "grpc": "กรอกชื่อโฮสต์และพอร์ตของเซิร์ฟเวอร์ gRPC ระบุชื่อบริการสำหรับ health check (ไม่บังคับ) และตั้งชื่อที่แสดง", "websocket": "กรอก URL ของ WebSocket ที่ต้องการตรวจสอบ (เช่น wss://example.com/socket) และตั้งชื่อที่แสดง", - "hardware": "ตรวจสอบการใช้ CPU หน่วยความจำ ดิสก์ และอุณหภูมิของโครงสร้างพื้นฐานของคุณ URL ของปลายทาง Capture ต้องรวมเส้นทางแบบเต็ม /api/v1/metrics (ตัวอย่าง: http://192.168.1.10:5001/api/v1/metrics) สำหรับรหัสลับสำหรับยืนยันตัวตน ให้วางเฉพาะสตริงโทเค็น — อย่ารวมคำว่า 'Bearer'" + "hardware": "ตรวจสอบการใช้ CPU หน่วยความจำ ดิสก์ และอุณหภูมิของโครงสร้างพื้นฐานของคุณ URL ของปลายทาง Capture ต้องรวมเส้นทางแบบเต็ม /api/v1/metrics (ตัวอย่าง: http://192.168.1.10:5001/api/v1/metrics) สำหรับรหัสลับสำหรับยืนยันตัวตน ให้วางเฉพาะสตริงโทเค็น — อย่ารวมคำว่า 'Bearer'", + "dns": "ติดตามว่าเซิร์ฟเวอร์ DNS เฉพาะตอบกลับหรือไม่" } }, "ignoreTls": { @@ -601,7 +636,8 @@ "optionHttpDescription": "ใช้ HTTP(S) เพื่อตรวจสอบเว็บไซต์หรือ API endpoint", "optionPingDescription": "ใช้ ICMP Ping เพื่อตรวจสอบว่าเซิร์ฟเวอร์ออนไลน์อยู่หรือไม่", "optionPortDescription": "ตรวจสอบว่าพอร์ตเฉพาะบนเซิร์ฟเวอร์เปิดอยู่หรือไม่", - "title": "ประเภท" + "title": "ประเภท", + "optionDnsDescription": "ติดตามว่าเซิร์ฟเวอร์ DNS เฉพาะตอบกลับหรือไม่" }, "thresholds": { "title": "เกณฑ์การแจ้งเตือน", @@ -909,7 +945,8 @@ "lastFinishedAt": "เสร็จสิ้นล่าสุดเมื่อ", "lastRunTook": "รันล่าสุดใช้เวลา", "lastFailedAt": "ล้มเหลวล่าสุดเมื่อ", - "failReason": "สาเหตุที่ล้มเหลว" + "failReason": "สาเหตุที่ล้มเหลว", + "state": "สถานะ" } }, "queueFallback": { @@ -1099,6 +1136,10 @@ "header": { "title": "ตัวตรวจสอบ PageSpeed", "description": "ติดตาม Core Web Vitals และคะแนน Lighthouse เพื่อจับความถดถอยก่อนที่ผู้ใช้จะสังเกตเห็น" + }, + "strategy": { + "desktop": "เดสก์ท็อป", + "mobile": "มือถือ" } }, "settings": { @@ -1247,6 +1288,10 @@ }, "rejectUnauthorized": { "label": "ปฏิเสธใบรับรองที่ไม่ถูกต้อง: ปฏิเสธการเชื่อมต่อด้วยใบรับรอง self-signed หรือไม่น่าเชื่อถือ" + }, + "displayName": { + "label": "ชื่อผู้ส่ง (ไม่บังคับ) - ชื่อที่เป็นมิตรซึ่งแสดงในช่องจาก", + "placeholder": "การแจ้งเตือน Checkmate" } } }, @@ -1307,6 +1352,20 @@ "uptime": { "title": "อัปไทม์", "responseTime": "เวลาตอบสนอง" + }, + "chart": { + "avg": "เฉลี่ย {{value}}ms", + "downTooltip": "ไม่พร้อมใช้งาน", + "heatmapAria": "แผนที่ความร้อนของเวลาตอบสนอง", + "max": "สูงสุด {{value}}ms" + }, + "status": { + "breached": "เกินขีดจำกัด", + "down": "ไม่พร้อมใช้งาน", + "initializing": "กำลังเริ่มต้น", + "maintenance": "การบำรุงรักษา", + "paused": "หยุดชั่วคราว", + "up": "ใช้งานได้" } }, "statusBar": { @@ -1324,7 +1383,9 @@ "maintenance": "บางระบบอยู่ระหว่างการบำรุงรักษา", "partiallyPaused": "บางระบบถูกหยุดชั่วคราว", "initializing": "บางระบบกำลังเริ่มต้น", - "noMonitors": "ยังไม่มีการกำหนดค่ามอนิเตอร์" + "noMonitors": "ยังไม่มีการกำหนดค่ามอนิเตอร์", + "monitoringSummary": "กำลังตรวจสอบ {{count}} บริการ · อัปเดตทุก 10 วินาที", + "monitoringSummary_other": "กำลังตรวจสอบ {{count}} บริการ · อัปเดตทุก 10 วินาที" }, "table": { "headers": { @@ -1415,11 +1476,47 @@ "label": "แสดงเมตริกโครงสร้างพื้นฐาน" } } + }, + "theme": { + "description": "เลือกรูปแบบภาพสำหรับหน้าสถานะสาธารณะของคุณ", + "label": "ธีม", + "options": { + "bold": { + "description": "ตัวอักษรขนาดใหญ่และการเว้นระยะที่มั่นใจ เหมาะสำหรับหน้าที่เน้นแบรนด์", + "name": "ตัวหนา" + }, + "editorial": { + "description": "หัวเรื่องแบบเซริฟและตัวคั่นบาง โดดเด่นและสงบ", + "name": "บรรณาธิการ" + }, + "modern": { + "description": "ระดับ SaaS พร้อมจุดสถานะที่เต้นและการ์ดที่จางหายอย่างนุ่มนวล", + "name": "ทันสมัย" + }, + "refined": { + "description": "สะอาด เป็นมืออาชีพ ใกล้เคียงกับรูปลักษณ์คลาสสิกของ Checkmate", + "name": "ละเอียดอ่อน" + } + }, + "title": "ธีมของหน้า" + }, + "themeMode": { + "auto": "อัตโนมัติ (ตามอุปกรณ์ของผู้เข้าชม)", + "dark": "มืดเท่านั้น", + "label": "โหมดธีม", + "light": "สว่างเท่านั้น" } }, "header": { "title": "หน้าสถานะ", "description": "เผยแพร่หน้าสาธารณะที่แสดง uptime แบบเรียลไทม์และประวัติเหตุการณ์ให้กับลูกค้าและผู้มีส่วนได้ส่วนเสีย" + }, + "editorial": { + "allUp": "ทุกระบบทำงานเป็นปกติ", + "reportTitle": "รายงานบริการ" + }, + "footer": { + "poweredBy": "ขับเคลื่อนโดย" } }, "uptime": { diff --git a/client/src/locales/tr.json b/client/src/locales/tr.json index c2ef7b2a7..fdb9506e2 100644 --- a/client/src/locales/tr.json +++ b/client/src/locales/tr.json @@ -73,7 +73,9 @@ "paused": "duraklatıldı", "na": "N/A", "resolved": "Çözüldü", - "responseTime": "Yanıt süresi" + "responseTime": "Yanıt süresi", + "idle": "Boşta", + "running": "Çalışıyor" }, "form": { "role": { @@ -120,6 +122,17 @@ "active": "Aktif", "responseTime": "Yanıt süresi" } + }, + "statusCode": { + "down": "Çalışmıyor", + "networkError": { + "dns": "Ana bilgisayar adı çözümlenemedi (DNS araması başarısız oldu).", + "refused": "Sunucu bağlantıyı reddetti (bağlantı noktası kapalı veya hizmet dinlemiyor).", + "reset": "Yanıt alınmadan bağlantı sıfırlandı.", + "timeout": "Sunucu zamanında yanıt vermedi (istek zaman aşımına uğradı).", + "tls": "TLS veya SSL el sıkışması başarısız (sertifika veya şifreleme sorunu).", + "unknown": "Hedeften HTTP yanıtı alınamadı." + } } }, "components": { @@ -306,7 +319,7 @@ "common": { "passwordRules": { "length": { - "beginning": "", + "beginning": "En az", "highlighted": "8 karakter uzunluğunda" }, "lowercase": { @@ -314,7 +327,7 @@ "highlighted": "Bir küçük harf" }, "match": { - "beginning": "", + "beginning": "Parolalar", "highlighted": "eşleşmelidir" }, "number": { @@ -452,7 +465,8 @@ "optionPagespeed": "PageSpeed", "optionHardware": "Altyapı", "optionGrpc": "gRPC", - "optionWebSocket": "WebSocket" + "optionWebSocket": "WebSocket", + "optionDns": "DNS" }, "bulkPause": { "paused_one": "{{count}} monitör duraklatıldı", @@ -483,9 +497,9 @@ }, "matchMethod": { "label": "Eşleştirme yöntemi", - "equal": "", - "include": "", - "regex": "" + "equal": "Eşit", + "include": "İçerir", + "regex": "Regex" } }, "title": "Gelişmiş ayarlar" @@ -497,15 +511,15 @@ "label": "Kontrol sıklığı", "value": { "fifteenMinutes": "15 dakika", - "fifteenSeconds": "", + "fifteenSeconds": "15 saniye", "fiveMinutes": "5 dakika", - "fourMinutes": "", - "oneMinute": "", + "fourMinutes": "4 dakika", + "oneMinute": "1 dakika", "tenMinutes": "10 dakika", "thirtyMinutes": "30 dakika", - "thirtySeconds": "", - "threeMinutes": "", - "twoMinutes": "" + "thirtySeconds": "30 saniye", + "threeMinutes": "3 dakika", + "twoMinutes": "2 dakika" } } }, @@ -534,8 +548,8 @@ "placeholder": "https://www.google.com" }, "game": { - "label": "", - "placeholder": "" + "label": "Oyun seç", + "placeholder": "Bir oyun seçin" }, "port": { "label": "İzlenecek port", @@ -552,19 +566,40 @@ "captureUrl": { "label": "Capture uç noktası", "placeholder": "http://192.168.1.10:5001/api/v1/metrics" + }, + "dnsRecordType": { + "label": "DNS kayıt türü", + "value": { + "A": "A", + "AAAA": "AAAA", + "CNAME": "CNAME", + "MX": "MX", + "NS": "NS", + "TXT": "TXT" + } + }, + "dnsServer": { + "label": "DNS sunucusu", + "placeholder": "8.8.8.8" + }, + "strategy": { + "desktop": "Masaüstü", + "label": "Strateji", + "mobile": "Mobil" } }, "title": "Genel ayarlar", "description": { "http": "İzlenecek URL veya IP adresini girin (örn. https://example.com/ veya 192.168.1.100) ve panoda görünecek açık bir ad ekleyin.", - "ping": "", - "port": "", - "docker": "", + "ping": "Ping atılacak IP adresini veya ana bilgisayar adını girin (ör. 192.168.1.100 veya example.com) ve panoda görünen net bir görünen ad ekleyin.", + "port": "Sunucunun URL veya IP adresini, port numarasını ve panoda görünen net bir görünen ad girin.", + "docker": "Docker kapsayıcı adını veya kimliğini girin. Kapsayıcı adını (ör. my-app) veya kapsayıcı kimliğini (tam 64 karakterli kimlik veya kısa kimlik) kullanabilirsiniz.", "game": "Ping atılacak IP adresini veya ana bilgisayar adını ve port numarasını girin (ör. 192.168.1.100 veya example.com) ve oyun türünü seçin. Bazı oyunlar yapılandırma dosyasında sunucu sorgularının etkinleştirilmesini gerektirir — oyun sunucunuzun belgelerine bakın. Protokol ayrıntıları için desteklenen oyunlar listesine bakın.", "pagespeed": "Web sitenizin sayfa yükleme performansını, Core Web Vitals metriklerini ve optimizasyon puanlarını takip edin.", "grpc": "gRPC sunucusunun ana bilgisayar adını ve portunu girin, isteğe bağlı olarak sağlık kontrolü için bir servis adı belirtin ve bir görünen ad ekleyin.", "websocket": "İzlenecek WebSocket URL'sini girin (örn. wss://example.com/socket) ve bir görünen ad ekleyin.", - "hardware": "Altyapınızın CPU, bellek, disk kullanımını ve sıcaklığını izleyin. Capture uç nokta URL'si tam yolu /api/v1/metrics içermelidir (ör. http://192.168.1.10:5001/api/v1/metrics). Yetkilendirme anahtarına yalnızca token dizesini yapıştırın — 'Bearer' eklemeyin." + "hardware": "Altyapınızın CPU, bellek, disk kullanımını ve sıcaklığını izleyin. Capture uç nokta URL'si tam yolu /api/v1/metrics içermelidir (ör. http://192.168.1.10:5001/api/v1/metrics). Yetkilendirme anahtarına yalnızca token dizesini yapıştırın — 'Bearer' eklemeyin.", + "dns": "Belirli bir DNS sunucusunun yanıt verip vermediğini izleyin." } }, "ignoreTls": { @@ -601,7 +636,8 @@ "optionHttpDescription": "Web sitenizi veya API uç noktanızı izlemek için HTTP(S) kullanın.", "optionPingDescription": "Bir sunucunun çevrimiçi olup olmadığını izlemek için ICMP Ping kullanın.", "optionPortDescription": "Bir sunucuda belirli bir portun açık olup olmadığını izleyin.", - "title": "Tür" + "title": "Tür", + "optionDnsDescription": "Belirli bir DNS sunucusunun yanıt verip vermediğini izleyin." }, "thresholds": { "title": "Uyarı eşikleri", @@ -826,7 +862,7 @@ }, "tabs": { "labels": { - "network": "", + "network": "Ağ", "overview": "Genel bakış" } }, @@ -909,7 +945,8 @@ "lastFinishedAt": "Son tamamlanma zamanı", "lastRunTook": "Son çalışma süresi", "lastFailedAt": "Son başarısızlık zamanı", - "failReason": "Başarısızlık nedeni" + "failReason": "Başarısızlık nedeni", + "state": "Durum" } }, "queueFallback": { @@ -1099,6 +1136,10 @@ "header": { "title": "PageSpeed izleyicileri", "description": "Core Web Vitals ve Lighthouse puanlarını zaman içinde izleyerek kullanıcılarınız fark etmeden gerilemeleri yakalayın." + }, + "strategy": { + "desktop": "Masaüstü", + "mobile": "Mobil" } }, "settings": { @@ -1201,7 +1242,7 @@ "email": { "title": "E-posta ayarları", "description": "Sisteminiz için e-posta ayarlarını yapılandırın. Bu, bildirim ve uyarı göndermek için kullanılır.", - "descriptionTransport": "", + "descriptionTransport": "Bu, NodeMailer için SMTP aktarımı oluşturur", "descriptionTransportLink": "Özellikleri burada görün", "option": { "host": { @@ -1247,6 +1288,10 @@ }, "rejectUnauthorized": { "label": "Geçersiz sertifikaları reddet: Kendinden imzalı veya güvenilmeyen sertifikalı bağlantıları reddet" + }, + "displayName": { + "label": "Gönderen adı (isteğe bağlı) - Gönderen alanında gösterilen kullanıcı dostu ad", + "placeholder": "Checkmate bildirimleri" } } }, @@ -1307,6 +1352,20 @@ "uptime": { "title": "Çalışma süresi", "responseTime": "Yanıt süresi" + }, + "chart": { + "avg": "ort {{value}}ms", + "downTooltip": "Çalışmıyor", + "heatmapAria": "Yanıt süresi ısı haritası", + "max": "maks {{value}}ms" + }, + "status": { + "breached": "Eşik aşıldı", + "down": "Çalışmıyor", + "initializing": "Başlatılıyor", + "maintenance": "Bakım", + "paused": "Duraklatıldı", + "up": "Çalışıyor" } }, "statusBar": { @@ -1324,7 +1383,9 @@ "maintenance": "Bazı sistemler bakımda", "partiallyPaused": "Bazı sistemler duraklatıldı", "initializing": "Bazı sistemler başlatılıyor", - "noMonitors": "Hiç monitör yapılandırılmadı" + "noMonitors": "Hiç monitör yapılandırılmadı", + "monitoringSummary": "{{count}} hizmet izleniyor · 10 saniyede bir güncellenir", + "monitoringSummary_other": "{{count}} hizmet izleniyor · 10 saniyede bir güncellenir" }, "table": { "headers": { @@ -1415,11 +1476,47 @@ "label": "Altyapı metriklerini göster" } } + }, + "theme": { + "description": "Genel durum sayfanız için görsel stili seçin.", + "label": "Tema", + "options": { + "bold": { + "description": "Büyük tipografi ve kendinden emin aralıklar. Marka odaklı sayfalar için harika.", + "name": "Kalın" + }, + "editorial": { + "description": "Serifli başlıklar ve ince ayraçlar. Belirgin ve sakin.", + "name": "Editöryal" + }, + "modern": { + "description": "Atan bir durum noktası ve yumuşakça solan kartlarla SaaS kalitesinde.", + "name": "Modern" + }, + "refined": { + "description": "Temiz, profesyonel, klasik Checkmate görünümüne yakın.", + "name": "Zarif" + } + }, + "title": "Sayfa teması" + }, + "themeMode": { + "auto": "Otomatik (ziyaretçinin cihazına göre)", + "dark": "Yalnızca koyu", + "label": "Tema modu", + "light": "Yalnızca açık" } }, "header": { "title": "Durum sayfaları", "description": "Müşterilerinize ve paydaşlarınıza gerçek zamanlı çalışma süresi ve olay geçmişini gösteren herkese açık sayfalar yayınlayın." + }, + "editorial": { + "allUp": "Tüm sistemler normal şekilde çalışıyor.", + "reportTitle": "Hizmet raporu" + }, + "footer": { + "poweredBy": "Sağlayan" } }, "uptime": { diff --git a/client/src/locales/uk.json b/client/src/locales/uk.json index 2ab0a837f..c036318b7 100644 --- a/client/src/locales/uk.json +++ b/client/src/locales/uk.json @@ -73,7 +73,9 @@ "paused": "призупинено", "na": "Н/Д", "resolved": "Вирішено", - "responseTime": "Час відповіді" + "responseTime": "Час відповіді", + "idle": "Очікування", + "running": "Виконується" }, "form": { "role": { @@ -120,6 +122,17 @@ "active": "Активний", "responseTime": "Час відповіді" } + }, + "statusCode": { + "down": "Не працює", + "networkError": { + "dns": "Не вдалося розв'язати ім'я хоста (пошук DNS не вдався).", + "refused": "Сервер відхилив з'єднання (порт закрито або служба не слухає).", + "reset": "З'єднання було скинуто до отримання відповіді.", + "timeout": "Сервер не відповів вчасно (час очікування запиту вичерпано).", + "tls": "Збій рукостискання TLS або SSL (проблема з сертифікатом або шифруванням).", + "unknown": "HTTP-відповідь від цілі не отримана." + } } }, "components": { @@ -306,28 +319,28 @@ "common": { "passwordRules": { "length": { - "beginning": "", - "highlighted": "" + "beginning": "Має бути щонайменше", + "highlighted": "8 символів" }, "lowercase": { - "beginning": "", - "highlighted": "" + "beginning": "Має містити щонайменше", + "highlighted": "одну малу літеру" }, "match": { - "beginning": "", - "highlighted": "" + "beginning": "Паролі", + "highlighted": "мають збігатися" }, "number": { - "beginning": "", - "highlighted": "" + "beginning": "Має містити щонайменше", + "highlighted": "одну цифру" }, "special": { - "beginning": "", - "highlighted": "" + "beginning": "Має містити щонайменше", + "highlighted": "один спеціальний символ (!?@#$%^&*()-_=+[]{}|;:'\",./\\~`)" }, "uppercase": { - "beginning": "", - "highlighted": "" + "beginning": "Має містити щонайменше", + "highlighted": "одну велику літеру" } }, "form": { @@ -452,7 +465,8 @@ "optionPagespeed": "PageSpeed", "optionHardware": "Інфраструктура", "optionGrpc": "gRPC", - "optionWebSocket": "WebSocket" + "optionWebSocket": "WebSocket", + "optionDns": "DNS" }, "bulkPause": { "paused_one": "{{count}} монітор призупинено", @@ -552,19 +566,40 @@ "captureUrl": { "label": "Точка доступу Capture", "placeholder": "http://192.168.1.10:5001/api/v1/metrics" + }, + "dnsRecordType": { + "label": "Тип запису DNS", + "value": { + "A": "A", + "AAAA": "AAAA", + "CNAME": "CNAME", + "MX": "MX", + "NS": "NS", + "TXT": "TXT" + } + }, + "dnsServer": { + "label": "DNS-сервер", + "placeholder": "8.8.8.8" + }, + "strategy": { + "desktop": "Десктоп", + "label": "Стратегія", + "mobile": "Мобільний" } }, "title": "Загальні налаштування", "description": { "http": "Введіть URL або IP для моніторингу (напр., https://example.com/ або 192.168.1.100) та додайте зрозуміле ім'я для відображення на панелі.", - "ping": "", - "port": "", - "docker": "", + "ping": "Введіть IP-адресу або ім'я хоста для пінгу (наприклад, 192.168.1.100 або example.com) і додайте чітку відображувану назву, що з'явиться на дашборді.", + "port": "Введіть URL або IP сервера, номер порту та чітку відображувану назву, що з'явиться на дашборді.", + "docker": "Введіть назву або ідентифікатор Docker-контейнера. Можна використовувати назву контейнера (наприклад, my-app) або ідентифікатор (повний 64-символьний або скорочений).", "game": "Введіть IP-адресу або ім'я хоста та номер порту для пінгу (наприклад, 192.168.1.100 або example.com) і виберіть тип гри. Деяким іграм потрібно ввімкнути серверні запити у файлі конфігурації — перевірте документацію вашого ігрового сервера. Деталі протоколу див. у списку підтримуваних ігор.", "pagespeed": "Відстежуйте швидкість завантаження сторінки, показники Core Web Vitals та оцінки оптимізації Вашого сайту.", "grpc": "Введіть ім'я хоста та порт gRPC-сервера, за бажанням вкажіть назву сервісу для перевірки стану та додайте відображуване ім'я.", "websocket": "Введіть WebSocket URL для моніторингу (напр., wss://example.com/socket) та додайте відображуване ім'я.", - "hardware": "Відстежуйте використання ЦП, пам'яті, диска та температуру вашої інфраструктури. URL точки доступу Capture має містити повний шлях /api/v1/metrics (наприклад, http://192.168.1.10:5001/api/v1/metrics). У секрет авторизації вставте лише рядок токена — не додавайте 'Bearer'." + "hardware": "Відстежуйте використання ЦП, пам'яті, диска та температуру вашої інфраструктури. URL точки доступу Capture має містити повний шлях /api/v1/metrics (наприклад, http://192.168.1.10:5001/api/v1/metrics). У секрет авторизації вставте лише рядок токена — не додавайте 'Bearer'.", + "dns": "Стежте, чи відповідає певний DNS-сервер." } }, "ignoreTls": { @@ -601,7 +636,8 @@ "optionHttpDescription": "Використовуйте HTTP(S) для моніторингу Вашого сайту або API-ендпоінту.", "optionPingDescription": "Використовуйте ICMP Ping для моніторингу доступності сервера.", "optionPortDescription": "Моніторинг відкритості конкретного порту на сервері.", - "title": "Тип" + "title": "Тип", + "optionDnsDescription": "Стежте, чи відповідає певний DNS-сервер." }, "thresholds": { "title": "Порогові значення сповіщень", @@ -765,7 +801,7 @@ "headers": { "endTime": "Час завершення", "resolutionType": "Тип вирішення", - "startTime": "", + "startTime": "Час початку", "statusCode": "Код статусу" }, "resolvedIncidents": "Вирішені інциденти", @@ -809,13 +845,13 @@ } }, "statBoxes": { - "avgCpuTemperature": "", - "cpuFrequency": "", - "cpuLogical": "", - "cpuPhysical": "", + "avgCpuTemperature": "Середня температура CPU", + "cpuFrequency": "Частота CPU", + "cpuLogical": "CPU (логічний)", + "cpuPhysical": "CPU (фізичний)", "disk": "Диск", "memory": "Пам'ять", - "os": "" + "os": "ОС" }, "table": { "headers": { @@ -909,7 +945,8 @@ "lastFinishedAt": "Останнє завершення", "lastRunTook": "Останній запуск тривав", "lastFailedAt": "Остання помилка", - "failReason": "Причина помилки" + "failReason": "Причина помилки", + "state": "Стан" } }, "queueFallback": { @@ -929,8 +966,8 @@ }, "table": { "headers": { - "nextWindow": "", - "repeat": "" + "nextWindow": "Наступне вікно", + "repeat": "Повторити" } }, "form": { @@ -1032,7 +1069,7 @@ }, "telegram": { "title": "Налаштування Telegram", - "description": "", + "description": "Налаштуйте свого Telegram-бота для надсилання сповіщень.", "optionBotToken": "Токен бота", "placeholderBotToken": "123456789:ABCdefGhIJKlmNoPQRsTUVwxyZ", "optionChatId": "ID чату", @@ -1099,6 +1136,10 @@ "header": { "title": "Монітори PageSpeed", "description": "Відстежуйте Core Web Vitals і оцінки Lighthouse у часі, щоб помітити регресії раніше за користувачів." + }, + "strategy": { + "desktop": "Десктоп", + "mobile": "Мобільний" } }, "settings": { @@ -1201,7 +1242,7 @@ "email": { "title": "Налаштування пошти", "description": "Налаштуйте параметри електронної пошти для Вашої системи. Використовується для надсилання сповіщень та попереджень.", - "descriptionTransport": "", + "descriptionTransport": "Це створює SMTP-транспорт для NodeMailer", "descriptionTransportLink": "Дивіться специфікації тут", "option": { "host": { @@ -1247,6 +1288,10 @@ }, "rejectUnauthorized": { "label": "Відхиляти недійсні сертифікати: відхиляти з'єднання із самопідписаними або ненадійними сертифікатами" + }, + "displayName": { + "label": "Ім'я відправника (необов'язково) - Дружнє ім'я, що відображається в полі Від", + "placeholder": "Сповіщення Checkmate" } } }, @@ -1307,6 +1352,20 @@ "uptime": { "title": "Час роботи", "responseTime": "Час відповіді" + }, + "chart": { + "avg": "сер {{value}}мс", + "downTooltip": "Не працює", + "heatmapAria": "Теплова карта часу відгуку", + "max": "макс {{value}}мс" + }, + "status": { + "breached": "Перевищено поріг", + "down": "Не працює", + "initializing": "Ініціалізація", + "maintenance": "Обслуговування", + "paused": "Призупинено", + "up": "Працює" } }, "statusBar": { @@ -1324,7 +1383,9 @@ "maintenance": "Деякі системи на техобслуговуванні", "partiallyPaused": "Деякі системи призупинено", "initializing": "Деякі системи ініціалізуються", - "noMonitors": "Не налаштовано жодного монітора" + "noMonitors": "Не налаштовано жодного монітора", + "monitoringSummary": "Відстежується {{count}} сервіс · Оновлення кожні 10 секунд", + "monitoringSummary_other": "Відстежується {{count}} сервісів · Оновлення кожні 10 секунд" }, "table": { "headers": { @@ -1415,11 +1476,47 @@ "label": "Показувати метрики інфраструктури" } } + }, + "theme": { + "description": "Виберіть візуальний стиль для вашої публічної сторінки стану.", + "label": "Тема", + "options": { + "bold": { + "description": "Велика типографіка та впевнені відступи. Чудово для брендових сторінок.", + "name": "Жирний" + }, + "editorial": { + "description": "Заголовки з засічками та тонкі роздільники. Відмінний і спокійний.", + "name": "Редакційний" + }, + "modern": { + "description": "Рівень SaaS з пульсуючою крапкою стану та м'яко зникаючими картками.", + "name": "Сучасний" + }, + "refined": { + "description": "Чистий, професійний, близький до класичного вигляду Checkmate.", + "name": "Витончений" + } + }, + "title": "Тема сторінки" + }, + "themeMode": { + "auto": "Авто (за пристроєм відвідувача)", + "dark": "Тільки темна", + "label": "Режим теми", + "light": "Тільки світла" } }, "header": { "title": "Сторінки статусу", "description": "Публікуйте публічні сторінки з часом роботи в реальному часі та історією інцидентів для клієнтів і зацікавлених сторін." + }, + "editorial": { + "allUp": "Усі системи працюють у нормальному режимі.", + "reportTitle": "Звіт про обслуговування" + }, + "footer": { + "poweredBy": "Працює на" } }, "uptime": { diff --git a/client/src/locales/vi.json b/client/src/locales/vi.json index 92372dec6..15665bb91 100644 --- a/client/src/locales/vi.json +++ b/client/src/locales/vi.json @@ -73,7 +73,9 @@ "paused": "tạm dừng", "na": "N/A", "resolved": "Đã giải quyết", - "responseTime": "Thời gian phản hồi" + "responseTime": "Thời gian phản hồi", + "idle": "Không hoạt động", + "running": "Đang chạy" }, "form": { "role": { @@ -120,6 +122,17 @@ "active": "Đang hoạt động", "responseTime": "Thời gian phản hồi" } + }, + "statusCode": { + "down": "Ngừng hoạt động", + "networkError": { + "dns": "Không thể phân giải tên máy chủ (tra cứu DNS thất bại).", + "refused": "Máy chủ từ chối kết nối (cổng đã đóng hoặc dịch vụ không lắng nghe).", + "reset": "Kết nối đã được đặt lại trước khi nhận được phản hồi.", + "timeout": "Máy chủ không phản hồi kịp thời (yêu cầu hết thời gian chờ).", + "tls": "Bắt tay TLS hoặc SSL thất bại (vấn đề chứng chỉ hoặc mã hóa).", + "unknown": "Không nhận được phản hồi HTTP từ mục tiêu." + } } }, "components": { @@ -306,28 +319,28 @@ "common": { "passwordRules": { "length": { - "beginning": "", - "highlighted": "" + "beginning": "Phải có ít nhất", + "highlighted": "8 ký tự" }, "lowercase": { - "beginning": "", - "highlighted": "" + "beginning": "Phải chứa ít nhất", + "highlighted": "một chữ thường" }, "match": { - "beginning": "", - "highlighted": "" + "beginning": "Mật khẩu", + "highlighted": "phải khớp" }, "number": { - "beginning": "", - "highlighted": "" + "beginning": "Phải chứa ít nhất", + "highlighted": "một chữ số" }, "special": { - "beginning": "", - "highlighted": "" + "beginning": "Phải chứa ít nhất", + "highlighted": "một ký tự đặc biệt (!?@#$%^&*()-_=+[]{}|;:'\",./\\~`)" }, "uppercase": { - "beginning": "", - "highlighted": "" + "beginning": "Phải chứa ít nhất", + "highlighted": "một chữ hoa" } }, "form": { @@ -452,7 +465,8 @@ "optionPagespeed": "PageSpeed", "optionHardware": "Hạ tầng", "optionGrpc": "gRPC", - "optionWebSocket": "WebSocket" + "optionWebSocket": "WebSocket", + "optionDns": "DNS" }, "bulkPause": { "paused_one": "Đã tạm dừng {{count}} màn hình", @@ -552,19 +566,40 @@ "captureUrl": { "label": "Điểm cuối Capture", "placeholder": "http://192.168.1.10:5001/api/v1/metrics" + }, + "dnsRecordType": { + "label": "Loại bản ghi DNS", + "value": { + "A": "A", + "AAAA": "AAAA", + "CNAME": "CNAME", + "MX": "MX", + "NS": "NS", + "TXT": "TXT" + } + }, + "dnsServer": { + "label": "Máy chủ DNS", + "placeholder": "8.8.8.8" + }, + "strategy": { + "desktop": "Máy tính để bàn", + "label": "Chiến lược", + "mobile": "Di động" } }, "title": "Cài đặt chung", "description": { "http": "Nhập URL hoặc IP cần giám sát (vd: https://example.com/ hoặc 192.168.1.100) và thêm tên hiển thị rõ ràng trên bảng điều khiển.", - "ping": "", - "port": "", - "docker": "", + "ping": "Nhập địa chỉ IP hoặc tên máy chủ để ping (ví dụ: 192.168.1.100 hoặc example.com) và thêm tên hiển thị rõ ràng xuất hiện trên bảng điều khiển.", + "port": "Nhập URL hoặc IP của máy chủ, số cổng và tên hiển thị rõ ràng xuất hiện trên bảng điều khiển.", + "docker": "Nhập tên hoặc ID của container Docker. Bạn có thể sử dụng tên container (ví dụ: my-app) hoặc ID container (ID đầy đủ 64 ký tự hoặc ID ngắn).", "game": "Nhập địa chỉ IP hoặc tên máy chủ và số cổng để ping (ví dụ: 192.168.1.100 hoặc example.com) và chọn loại trò chơi. Một số trò chơi yêu cầu bật truy vấn máy chủ trong tệp cấu hình của chúng — hãy xem tài liệu của máy chủ trò chơi. Xem danh sách các trò chơi được hỗ trợ để biết chi tiết giao thức.", "pagespeed": "Theo dõi hiệu suất tải trang, Core Web Vitals và điểm tối ưu hóa cho trang web của bạn.", "grpc": "Nhập tên máy chủ và cổng của máy chủ gRPC, tùy chọn chỉ định tên dịch vụ cho kiểm tra sức khỏe và thêm tên hiển thị.", "websocket": "Nhập URL WebSocket cần giám sát (vd: wss://example.com/socket) và thêm tên hiển thị.", - "hardware": "Giám sát mức sử dụng CPU, bộ nhớ, ổ đĩa và nhiệt độ của hạ tầng. URL điểm cuối Capture phải bao gồm đường dẫn đầy đủ /api/v1/metrics (ví dụ: http://192.168.1.10:5001/api/v1/metrics). Đối với khóa xác thực, chỉ dán chuỗi token — không bao gồm 'Bearer'." + "hardware": "Giám sát mức sử dụng CPU, bộ nhớ, ổ đĩa và nhiệt độ của hạ tầng. URL điểm cuối Capture phải bao gồm đường dẫn đầy đủ /api/v1/metrics (ví dụ: http://192.168.1.10:5001/api/v1/metrics). Đối với khóa xác thực, chỉ dán chuỗi token — không bao gồm 'Bearer'.", + "dns": "Giám sát xem một máy chủ DNS cụ thể có phản hồi hay không." } }, "ignoreTls": { @@ -601,7 +636,8 @@ "optionHttpDescription": "Sử dụng HTTP(S) để giám sát trang web hoặc điểm cuối API của bạn.", "optionPingDescription": "Sử dụng ICMP Ping để giám sát xem máy chủ có trực tuyến không.", "optionPortDescription": "Giám sát xem một cổng cụ thể trên máy chủ có mở không.", - "title": "Loại" + "title": "Loại", + "optionDnsDescription": "Giám sát xem một máy chủ DNS cụ thể có phản hồi hay không." }, "thresholds": { "title": "Ngưỡng cảnh báo", @@ -765,7 +801,7 @@ "headers": { "endTime": "Thời gian kết thúc", "resolutionType": "Loại giải quyết", - "startTime": "", + "startTime": "Thời gian bắt đầu", "statusCode": "Mã trạng thái" }, "resolvedIncidents": "Sự cố đã giải quyết", @@ -809,13 +845,13 @@ } }, "statBoxes": { - "avgCpuTemperature": "", - "cpuFrequency": "", - "cpuLogical": "", - "cpuPhysical": "", + "avgCpuTemperature": "Nhiệt độ CPU trung bình", + "cpuFrequency": "Tần số CPU", + "cpuLogical": "CPU (logic)", + "cpuPhysical": "CPU (vật lý)", "disk": "Ổ đĩa", "memory": "Bộ nhớ", - "os": "" + "os": "Hệ điều hành" }, "table": { "headers": { @@ -909,7 +945,8 @@ "lastFinishedAt": "Hoàn thành lần cuối lúc", "lastRunTook": "Lần chạy cuối mất", "lastFailedAt": "Lỗi lần cuối lúc", - "failReason": "Lý do lỗi" + "failReason": "Lý do lỗi", + "state": "Trạng thái" } }, "queueFallback": { @@ -929,8 +966,8 @@ }, "table": { "headers": { - "nextWindow": "", - "repeat": "" + "nextWindow": "Cửa sổ tiếp theo", + "repeat": "Lặp lại" } }, "form": { @@ -1032,7 +1069,7 @@ }, "telegram": { "title": "Cấu hình Telegram", - "description": "", + "description": "Cấu hình bot Telegram của bạn để gửi thông báo.", "optionBotToken": "Token bot", "placeholderBotToken": "123456789:ABCdefGhIJKlmNoPQRsTUVwxyZ", "optionChatId": "ID cuộc trò chuyện", @@ -1099,6 +1136,10 @@ "header": { "title": "Trình giám sát PageSpeed", "description": "Theo dõi Core Web Vitals và điểm Lighthouse theo thời gian để phát hiện sự suy giảm trước khi người dùng nhận ra." + }, + "strategy": { + "desktop": "Máy tính để bàn", + "mobile": "Di động" } }, "settings": { @@ -1201,7 +1242,7 @@ "email": { "title": "Cài đặt email", "description": "Cấu hình cài đặt email cho hệ thống. Được sử dụng để gửi thông báo và cảnh báo.", - "descriptionTransport": "", + "descriptionTransport": "Điều này xây dựng một SMTP transport cho NodeMailer", "descriptionTransportLink": "Xem thông số kỹ thuật tại đây", "option": { "host": { @@ -1247,6 +1288,10 @@ }, "rejectUnauthorized": { "label": "Từ chối chứng chỉ không hợp lệ: Từ chối kết nối với chứng chỉ tự ký hoặc không đáng tin cậy" + }, + "displayName": { + "label": "Tên người gửi (tùy chọn) - Tên thân thiện hiển thị trong trường Từ", + "placeholder": "Thông báo Checkmate" } } }, @@ -1307,6 +1352,20 @@ "uptime": { "title": "Thời gian hoạt động", "responseTime": "Thời gian phản hồi" + }, + "chart": { + "avg": "TB {{value}}ms", + "downTooltip": "Ngừng hoạt động", + "heatmapAria": "Bản đồ nhiệt thời gian phản hồi", + "max": "tối đa {{value}}ms" + }, + "status": { + "breached": "Vượt ngưỡng", + "down": "Ngừng hoạt động", + "initializing": "Đang khởi tạo", + "maintenance": "Bảo trì", + "paused": "Tạm dừng", + "up": "Hoạt động" } }, "statusBar": { @@ -1324,7 +1383,9 @@ "maintenance": "Một số hệ thống đang bảo trì", "partiallyPaused": "Một số hệ thống đã tạm dừng", "initializing": "Một số hệ thống đang khởi tạo", - "noMonitors": "Chưa cấu hình màn hình giám sát nào" + "noMonitors": "Chưa cấu hình màn hình giám sát nào", + "monitoringSummary": "Đang giám sát {{count}} dịch vụ · Cập nhật mỗi 10 giây", + "monitoringSummary_other": "Đang giám sát {{count}} dịch vụ · Cập nhật mỗi 10 giây" }, "table": { "headers": { @@ -1415,11 +1476,47 @@ "label": "Hiển thị số liệu hạ tầng" } } + }, + "theme": { + "description": "Chọn kiểu hiển thị cho trang trạng thái công khai của bạn.", + "label": "Chủ đề", + "options": { + "bold": { + "description": "Kiểu chữ lớn và khoảng cách tự tin. Tuyệt vời cho các trang tập trung vào thương hiệu.", + "name": "Đậm" + }, + "editorial": { + "description": "Tiêu đề serif và dải phân cách mỏng. Đặc biệt và tĩnh lặng.", + "name": "Biên tập" + }, + "modern": { + "description": "Chất lượng SaaS với chấm trạng thái nhấp nháy và thẻ mờ dần nhẹ nhàng.", + "name": "Hiện đại" + }, + "refined": { + "description": "Sạch sẽ, chuyên nghiệp, gần với giao diện Checkmate cổ điển.", + "name": "Tinh tế" + } + }, + "title": "Chủ đề trang" + }, + "themeMode": { + "auto": "Tự động (theo thiết bị của khách)", + "dark": "Chỉ tối", + "label": "Chế độ chủ đề", + "light": "Chỉ sáng" } }, "header": { "title": "Trang trạng thái", "description": "Công bố các trang công khai cho khách hàng và các bên liên quan thấy thời gian hoạt động theo thời gian thực và lịch sử sự cố." + }, + "editorial": { + "allUp": "Tất cả các hệ thống đang hoạt động bình thường.", + "reportTitle": "Báo cáo dịch vụ" + }, + "footer": { + "poweredBy": "Được cung cấp bởi" } }, "uptime": { diff --git a/client/src/locales/zh-CN.json b/client/src/locales/zh-CN.json index 7b369e9b8..08259e06f 100644 --- a/client/src/locales/zh-CN.json +++ b/client/src/locales/zh-CN.json @@ -73,7 +73,9 @@ "paused": "已暂停", "na": "不适用", "resolved": "已解决", - "responseTime": "响应时间" + "responseTime": "响应时间", + "idle": "空闲", + "running": "运行中" }, "form": { "role": { @@ -120,6 +122,17 @@ "active": "活跃", "responseTime": "响应时间" } + }, + "statusCode": { + "down": "宕机", + "networkError": { + "dns": "无法解析主机名(DNS查找失败)。", + "refused": "服务器拒绝连接(端口关闭或服务未监听)。", + "reset": "在收到响应之前连接被重置。", + "timeout": "服务器未及时响应(请求超时)。", + "tls": "TLS或SSL握手失败(证书或加密问题)。", + "unknown": "未从目标接收HTTP响应。" + } } }, "components": { @@ -452,7 +465,8 @@ "optionPagespeed": "PageSpeed", "optionHardware": "基础设施", "optionGrpc": "gRPC", - "optionWebSocket": "WebSocket" + "optionWebSocket": "WebSocket", + "optionDns": "DNS" }, "bulkPause": { "paused_one": "已暂停 {{count}} 个监视器", @@ -552,6 +566,26 @@ "captureUrl": { "label": "Capture 端点", "placeholder": "http://192.168.1.10:5001/api/v1/metrics" + }, + "dnsRecordType": { + "label": "DNS记录类型", + "value": { + "A": "A", + "AAAA": "AAAA", + "CNAME": "CNAME", + "MX": "MX", + "NS": "NS", + "TXT": "TXT" + } + }, + "dnsServer": { + "label": "DNS服务器", + "placeholder": "8.8.8.8" + }, + "strategy": { + "desktop": "桌面", + "label": "策略", + "mobile": "移动端" } }, "title": "常规设置", @@ -564,7 +598,8 @@ "pagespeed": "跟踪网站的页面加载性能、Core Web Vitals 和优化评分。", "grpc": "输入 gRPC 服务器的主机名和端口,可选指定健康检查的服务名称,并添加显示名称。", "websocket": "输入要监控的 WebSocket URL(例如 wss://example.com/socket),并添加显示名称。", - "hardware": "监控基础设施的 CPU、内存、磁盘使用情况和温度。Capture 端点 URL 必须包含完整路径 /api/v1/metrics(例如 http://192.168.1.10:5001/api/v1/metrics)。授权密钥只需粘贴令牌字符串——请勿包含 'Bearer'。" + "hardware": "监控基础设施的 CPU、内存、磁盘使用情况和温度。Capture 端点 URL 必须包含完整路径 /api/v1/metrics(例如 http://192.168.1.10:5001/api/v1/metrics)。授权密钥只需粘贴令牌字符串——请勿包含 'Bearer'。", + "dns": "监控特定DNS服务器是否响应。" } }, "ignoreTls": { @@ -601,7 +636,8 @@ "optionHttpDescription": "使用 HTTP(S) 监控您的网站或 API 端点。", "optionPingDescription": "使用 ICMP Ping 监控服务器是否在线。", "optionPortDescription": "监控服务器上的特定端口是否开放。", - "title": "类型" + "title": "类型", + "optionDnsDescription": "监控特定DNS服务器是否响应。" }, "thresholds": { "title": "告警阈值", @@ -909,7 +945,8 @@ "lastFinishedAt": "上次完成于", "lastRunTook": "上次运行耗时", "lastFailedAt": "上次失败于", - "failReason": "失败原因" + "failReason": "失败原因", + "state": "状态" } }, "queueFallback": { @@ -1099,6 +1136,10 @@ "header": { "title": "PageSpeed 监控器", "description": "持续追踪 Core Web Vitals 和 Lighthouse 评分,在用户察觉之前发现性能回退。" + }, + "strategy": { + "desktop": "桌面", + "mobile": "移动端" } }, "settings": { @@ -1247,6 +1288,10 @@ }, "rejectUnauthorized": { "label": "拒绝无效证书:拒绝自签名或不受信任证书的连接" + }, + "displayName": { + "label": "发件人姓名(可选)- 显示在发件人字段中的友好名称", + "placeholder": "Checkmate通知" } } }, @@ -1307,6 +1352,20 @@ "uptime": { "title": "正常运行时间", "responseTime": "响应时间" + }, + "chart": { + "avg": "平均 {{value}}ms", + "downTooltip": "宕机", + "heatmapAria": "响应时间热图", + "max": "最大 {{value}}ms" + }, + "status": { + "breached": "超出阈值", + "down": "宕机", + "initializing": "初始化中", + "maintenance": "维护中", + "paused": "已暂停", + "up": "运行中" } }, "statusBar": { @@ -1324,7 +1383,9 @@ "maintenance": "部分系统正在维护", "partiallyPaused": "部分系统已暂停", "initializing": "部分系统正在初始化", - "noMonitors": "尚未配置任何监控器" + "noMonitors": "尚未配置任何监控器", + "monitoringSummary": "正在监控{{count}}个服务 · 每10秒更新一次", + "monitoringSummary_other": "正在监控{{count}}个服务 · 每10秒更新一次" }, "table": { "headers": { @@ -1415,11 +1476,47 @@ "label": "显示基础设施指标" } } + }, + "theme": { + "description": "为您的公共状态页面选择视觉样式。", + "label": "主题", + "options": { + "bold": { + "description": "大字体和自信的间距。非常适合品牌导向的页面。", + "name": "粗体" + }, + "editorial": { + "description": "衬线标题和细分隔线。独特而平静。", + "name": "编辑风格" + }, + "modern": { + "description": "SaaS级品质,带有脉动状态点和柔和褪色卡片。", + "name": "现代" + }, + "refined": { + "description": "干净、专业,接近经典Checkmate外观。", + "name": "精致" + } + }, + "title": "页面主题" + }, + "themeMode": { + "auto": "自动(跟随访客设备)", + "dark": "仅深色", + "label": "主题模式", + "light": "仅浅色" } }, "header": { "title": "状态页面", "description": "发布公开页面,向客户和利益相关者展示实时运行情况和事件历史。" + }, + "editorial": { + "allUp": "所有系统运行正常。", + "reportTitle": "服务报告" + }, + "footer": { + "poweredBy": "技术支持" } }, "uptime": { diff --git a/client/src/locales/zh-TW.json b/client/src/locales/zh-TW.json index a15f7ab7d..b05ef5deb 100644 --- a/client/src/locales/zh-TW.json +++ b/client/src/locales/zh-TW.json @@ -73,7 +73,9 @@ "paused": "已暫停", "na": "N/A", "resolved": "已解決", - "responseTime": "回應時間" + "responseTime": "回應時間", + "idle": "閒置", + "running": "執行中" }, "form": { "role": { @@ -120,6 +122,17 @@ "active": "啟用中", "responseTime": "回應時間" } + }, + "statusCode": { + "down": "停機", + "networkError": { + "dns": "無法解析主機名稱(DNS查詢失敗)。", + "refused": "伺服器拒絕連線(連接埠關閉或服務未監聽)。", + "reset": "在收到回應之前連線被重設。", + "timeout": "伺服器未及時回應(請求逾時)。", + "tls": "TLS或SSL交握失敗(憑證或加密問題)。", + "unknown": "未從目標接收HTTP回應。" + } } }, "components": { @@ -452,7 +465,8 @@ "optionPagespeed": "PageSpeed", "optionHardware": "基礎架構", "optionGrpc": "gRPC", - "optionWebSocket": "WebSocket" + "optionWebSocket": "WebSocket", + "optionDns": "DNS" }, "bulkPause": { "paused_one": "已暫停 {{count}} 個監視器", @@ -552,6 +566,26 @@ "captureUrl": { "label": "Capture 端點", "placeholder": "http://192.168.1.10:5001/api/v1/metrics" + }, + "dnsRecordType": { + "label": "DNS記錄類型", + "value": { + "A": "A", + "AAAA": "AAAA", + "CNAME": "CNAME", + "MX": "MX", + "NS": "NS", + "TXT": "TXT" + } + }, + "dnsServer": { + "label": "DNS伺服器", + "placeholder": "8.8.8.8" + }, + "strategy": { + "desktop": "桌面", + "label": "策略", + "mobile": "行動裝置" } }, "title": "一般設定", @@ -564,7 +598,8 @@ "pagespeed": "追蹤您網站的頁面載入效能、Core Web Vitals 和最佳化分數。", "grpc": "輸入 gRPC 伺服器的主機名稱和連接埠,可選擇指定健康檢查的服務名稱,並新增一個顯示名稱。", "websocket": "輸入要監控的 WebSocket URL(例如 wss://example.com/socket),並新增一個顯示名稱。", - "hardware": "監控基礎架構的 CPU、記憶體、磁碟使用情況和溫度。Capture 端點 URL 必須包含完整路徑 /api/v1/metrics(例如 http://192.168.1.10:5001/api/v1/metrics)。授權密鑰僅貼上權杖字串——請勿包含 'Bearer'。" + "hardware": "監控基礎架構的 CPU、記憶體、磁碟使用情況和溫度。Capture 端點 URL 必須包含完整路徑 /api/v1/metrics(例如 http://192.168.1.10:5001/api/v1/metrics)。授權密鑰僅貼上權杖字串——請勿包含 'Bearer'。", + "dns": "監控特定DNS伺服器是否回應。" } }, "ignoreTls": { @@ -601,7 +636,8 @@ "optionHttpDescription": "使用 HTTP(S) 監控您的網站或 API 端點。", "optionPingDescription": "使用 ICMP Ping 監控伺服器是否上線。", "optionPortDescription": "監控伺服器上的特定連接埠是否開啟。", - "title": "類型" + "title": "類型", + "optionDnsDescription": "監控特定DNS伺服器是否回應。" }, "thresholds": { "title": "警報閾值", @@ -909,7 +945,8 @@ "lastFinishedAt": "最後完成於", "lastRunTook": "最後執行耗時", "lastFailedAt": "最後失敗於", - "failReason": "失敗原因" + "failReason": "失敗原因", + "state": "狀態" } }, "queueFallback": { @@ -1099,6 +1136,10 @@ "header": { "title": "PageSpeed 監視器", "description": "持續追蹤 Core Web Vitals 與 Lighthouse 評分,在使用者察覺前發現效能退化。" + }, + "strategy": { + "desktop": "桌面", + "mobile": "行動裝置" } }, "settings": { @@ -1247,6 +1288,10 @@ }, "rejectUnauthorized": { "label": "拒絕無效憑證:拒絕使用自簽或不受信任憑證的連線" + }, + "displayName": { + "label": "寄件者名稱(選填)- 顯示在寄件者欄位的友善名稱", + "placeholder": "Checkmate通知" } } }, @@ -1307,6 +1352,20 @@ "uptime": { "title": "正常運行時間", "responseTime": "回應時間" + }, + "chart": { + "avg": "平均 {{value}}ms", + "downTooltip": "停機", + "heatmapAria": "回應時間熱圖", + "max": "最大 {{value}}ms" + }, + "status": { + "breached": "超過閾值", + "down": "停機", + "initializing": "初始化中", + "maintenance": "維護中", + "paused": "已暫停", + "up": "運行中" } }, "statusBar": { @@ -1324,7 +1383,9 @@ "maintenance": "部分系統正在維護", "partiallyPaused": "部分系統已暫停", "initializing": "部分系統正在初始化", - "noMonitors": "尚未設定任何監視器" + "noMonitors": "尚未設定任何監視器", + "monitoringSummary": "正在監控{{count}}個服務 · 每10秒更新一次", + "monitoringSummary_other": "正在監控{{count}}個服務 · 每10秒更新一次" }, "table": { "headers": { @@ -1415,11 +1476,47 @@ "label": "顯示基礎架構指標" } } + }, + "theme": { + "description": "為您的公開狀態頁面選擇視覺樣式。", + "label": "主題", + "options": { + "bold": { + "description": "大字體和自信的間距。非常適合品牌導向的頁面。", + "name": "粗體" + }, + "editorial": { + "description": "襯線標題和細分隔線。獨特而平靜。", + "name": "編輯風格" + }, + "modern": { + "description": "SaaS級品質,帶有脈動狀態點和柔和淡出的卡片。", + "name": "現代" + }, + "refined": { + "description": "乾淨、專業,接近經典Checkmate外觀。", + "name": "精緻" + } + }, + "title": "頁面主題" + }, + "themeMode": { + "auto": "自動(依照訪客裝置)", + "dark": "僅深色", + "label": "主題模式", + "light": "僅淺色" } }, "header": { "title": "狀態頁面", "description": "發佈公開頁面,向客戶和利害關係人顯示即時的運行狀態與事件歷史。" + }, + "editorial": { + "allUp": "所有系統運作正常。", + "reportTitle": "服務報告" + }, + "footer": { + "poweredBy": "技術支援" } }, "uptime": { From f46c2edd1b8c9e1d4841c0cc812b74741f6015dc Mon Sep 17 00:00:00 2001 From: Alex Holliday Date: Fri, 15 May 2026 10:58:38 -0700 Subject: [PATCH 112/245] add password and newPassword to editUserValidation --- server/src/validation/userValidation.ts | 10 ++++++++++ 1 file changed, 10 insertions(+) diff --git a/server/src/validation/userValidation.ts b/server/src/validation/userValidation.ts index e613b86e6..c26c10116 100644 --- a/server/src/validation/userValidation.ts +++ b/server/src/validation/userValidation.ts @@ -49,6 +49,16 @@ export const editUserBodyValidation = z.object({ lastName: nameValidation.optional(), email: lowercaseEmailValidation.optional(), profilePicture: z.string().optional(), + password: z + .string() + .min(8, "Password must be at least 8 characters") + .regex(passwordPattern, "Password must contain at least one lowercase letter, one uppercase letter, one number, and one special character") + .optional(), + newPassword: z + .string() + .min(8, "Password must be at least 8 characters") + .regex(passwordPattern, "Password must contain at least one lowercase letter, one uppercase letter, one number, and one special character") + .optional(), }); // Canonical user shape returned by auth/user endpoints. Keep aligned with what From 704f25b987814c57d38d1c67cf6d2cc13f8d7610 Mon Sep 17 00:00:00 2001 From: Alex Holliday Date: Fri, 15 May 2026 11:14:39 -0700 Subject: [PATCH 113/245] extract password validation, add regression test --- server/src/validation/shared.ts | 5 ++ server/src/validation/userValidation.ts | 44 ++++++++---------- .../unit/validation/userValidation.test.ts | 46 +++++++++++++++++++ 3 files changed, 70 insertions(+), 25 deletions(-) create mode 100644 server/test/unit/validation/userValidation.test.ts diff --git a/server/src/validation/shared.ts b/server/src/validation/shared.ts index 99f92f250..a3e17866b 100644 --- a/server/src/validation/shared.ts +++ b/server/src/validation/shared.ts @@ -4,6 +4,11 @@ import { type UserRole } from "@/types/user.js"; export const passwordPattern = /^(?=.*[a-z])(?=.*[A-Z])(?=.*[0-9])(?=.*[!?@#$%^&*()\-_=+[\]{};:'",.~`|\\/])[A-Za-z0-9!?@#$%^&*()\-_=+[\]{};:'",.~`|\\/]+$/; +export const passwordValidation = z + .string() + .min(8, "Password must be at least 8 characters") + .regex(passwordPattern, "Password must contain at least one lowercase letter, one uppercase letter, one number, and one special character"); + export const nameValidation = z .string() .trim() diff --git a/server/src/validation/userValidation.ts b/server/src/validation/userValidation.ts index c26c10116..40e7d8b54 100644 --- a/server/src/validation/userValidation.ts +++ b/server/src/validation/userValidation.ts @@ -1,6 +1,6 @@ import { z } from "zod"; import { UserRoles } from "@/types/user.js"; -import { nameValidation, lowercaseEmailValidation, passwordPattern } from "./shared.js"; +import { nameValidation, lowercaseEmailValidation, passwordValidation } from "./shared.js"; //**************************************** // User Validations @@ -27,39 +27,33 @@ export const editSuperadminUserByIdBodyValidation = z.object({ }); export const editUserPasswordByIdBodyValidation = z.object({ - password: z - .string() - .min(8, "Password must be at least 8 characters") - .regex(passwordPattern, "Password must contain at least one lowercase letter, one uppercase letter, one number, and one special character"), + password: passwordValidation, }); export const createUserBodyValidation = z.object({ firstName: nameValidation, lastName: nameValidation, email: lowercaseEmailValidation, - password: z - .string() - .min(8, "Password must be at least 8 characters") - .regex(passwordPattern, "Password must contain at least one lowercase letter, one uppercase letter, one number, and one special character"), + password: passwordValidation, role: z.array(z.enum(UserRoles)).min(1, "At least one role is required"), }); -export const editUserBodyValidation = z.object({ - firstName: nameValidation.optional(), - lastName: nameValidation.optional(), - email: lowercaseEmailValidation.optional(), - profilePicture: z.string().optional(), - password: z - .string() - .min(8, "Password must be at least 8 characters") - .regex(passwordPattern, "Password must contain at least one lowercase letter, one uppercase letter, one number, and one special character") - .optional(), - newPassword: z - .string() - .min(8, "Password must be at least 8 characters") - .regex(passwordPattern, "Password must contain at least one lowercase letter, one uppercase letter, one number, and one special character") - .optional(), -}); +export const editUserBodyValidation = z + .object({ + firstName: nameValidation.optional(), + lastName: nameValidation.optional(), + email: lowercaseEmailValidation.optional(), + profilePicture: z.string().optional(), + password: passwordValidation.optional(), + newPassword: passwordValidation.optional(), + }) + // Changing a password requires both the current password and the new one. + // The service only runs the change-password flow when both are present, so + // reject a half-filled request + .refine((data) => (data.password === undefined) === (data.newPassword === undefined), { + message: "Both current password and new password are required to change your password", + path: ["newPassword"], + }); // Canonical user shape returned by auth/user endpoints. Keep aligned with what // the controllers actually serialize (password is intentionally omitted). diff --git a/server/test/unit/validation/userValidation.test.ts b/server/test/unit/validation/userValidation.test.ts new file mode 100644 index 000000000..2e263d1a9 --- /dev/null +++ b/server/test/unit/validation/userValidation.test.ts @@ -0,0 +1,46 @@ +import { describe, expect, it } from "@jest/globals"; +import { editUserBodyValidation } from "../../../src/validation/userValidation.ts"; + +const validPassword = "OldPass1!"; +const validNewPassword = "NewPass1!"; + +describe("editUserBodyValidation", () => { + // Regression: the Joi->Zod validation refactor (commit c3a41272a3) dropped + // `password`/`newPassword` from this schema. Because z.object() strips + // unknown keys, the change-password flow in userService.editUser silently + // became a no-op. Both fields must survive parsing. + it("retains password and newPassword on the parsed body", () => { + const result = editUserBodyValidation.safeParse({ + password: validPassword, + newPassword: validNewPassword, + }); + expect(result.success).toBe(true); + expect(result.data).toEqual({ + password: validPassword, + newPassword: validNewPassword, + }); + }); + + it("accepts a profile-only edit with neither password field", () => { + const result = editUserBodyValidation.safeParse({ firstName: "Ada", lastName: "Lovelace" }); + expect(result.success).toBe(true); + }); + + it("rejects password without newPassword", () => { + const result = editUserBodyValidation.safeParse({ password: validPassword }); + expect(result.success).toBe(false); + }); + + it("rejects newPassword without password", () => { + const result = editUserBodyValidation.safeParse({ newPassword: validNewPassword }); + expect(result.success).toBe(false); + }); + + it("rejects a newPassword that does not meet the strength policy", () => { + const result = editUserBodyValidation.safeParse({ + password: validPassword, + newPassword: "weak", + }); + expect(result.success).toBe(false); + }); +}); From 3de392c3c94a65a0402b71b7108a2a8041b12bbf Mon Sep 17 00:00:00 2001 From: Alex Holliday Date: Fri, 15 May 2026 11:27:48 -0700 Subject: [PATCH 114/245] fix width for stats size, noWrap for stats --- .../common/charts/HistogramResponseTime.tsx | 14 ++++++++++---- 1 file changed, 10 insertions(+), 4 deletions(-) diff --git a/client/src/Components/common/charts/HistogramResponseTime.tsx b/client/src/Components/common/charts/HistogramResponseTime.tsx index ba2bc12ff..d9485318a 100644 --- a/client/src/Components/common/charts/HistogramResponseTime.tsx +++ b/client/src/Components/common/charts/HistogramResponseTime.tsx @@ -35,7 +35,7 @@ const calculateResponseTimeStats = (checks: CheckSnapshot[]): ResponseTimeStats } const responseTimes = validChecks.map((check) => check.originalResponseTime); - const max = Math.max(...responseTimes); + const max = Math.round(Math.max(...responseTimes)); const avg = Math.round( responseTimes.reduce((sum, time) => sum + time, 0) / responseTimes.length ); @@ -73,14 +73,20 @@ export const HistogramResponseTime = ({ - + {t("common.charts.histogram.avg", { value: stats.avg })} - + {t("common.charts.histogram.max", { value: stats.max })} From 43ff7ca4bd4332cd811b149e955107f3e763da30 Mon Sep 17 00:00:00 2001 From: Alex Holliday Date: Tue, 19 May 2026 11:14:31 -0700 Subject: [PATCH 115/245] create new tag type, update related types --- server/src/types/index.ts | 1 + server/src/types/monitor.ts | 1 + server/src/types/tag.ts | 8 ++++++++ 3 files changed, 10 insertions(+) create mode 100644 server/src/types/tag.ts diff --git a/server/src/types/index.ts b/server/src/types/index.ts index 89de348e2..ca055ba70 100644 --- a/server/src/types/index.ts +++ b/server/src/types/index.ts @@ -15,3 +15,4 @@ export * from "@/types/email.js"; export * from "@/types/team.js"; export * from "@/types/maintenanceWindow.js"; export * from "@/types/dlqItem.js"; +export * from "@/types/tag.js"; diff --git a/server/src/types/monitor.ts b/server/src/types/monitor.ts index b2ad0f76a..0708297f3 100644 --- a/server/src/types/monitor.ts +++ b/server/src/types/monitor.ts @@ -59,6 +59,7 @@ export interface Monitor { interval: number; uptimePercentage?: number; notifications: string[]; + tags: string[]; secret?: string; cpuAlertThreshold: number; cpuAlertCounter: number; diff --git a/server/src/types/tag.ts b/server/src/types/tag.ts new file mode 100644 index 000000000..bab6f740a --- /dev/null +++ b/server/src/types/tag.ts @@ -0,0 +1,8 @@ +export interface Tag { + id: string; + teamId: string; + name: string; + color: string; + createdAt: string; + updatedAt: string; +} From a740a9e00552e329059c894bc5fa2be0207bc02d Mon Sep 17 00:00:00 2001 From: Alex Holliday Date: Tue, 19 May 2026 11:14:55 -0700 Subject: [PATCH 116/245] update DB models, create tag db model --- server/src/db/models/Monitor.ts | 9 ++++++++- server/src/db/models/Tag.ts | 33 +++++++++++++++++++++++++++++++++ server/src/db/models/index.ts | 2 ++ 3 files changed, 43 insertions(+), 1 deletion(-) create mode 100644 server/src/db/models/Tag.ts diff --git a/server/src/db/models/Monitor.ts b/server/src/db/models/Monitor.ts index a92e9331c..e6bffddd8 100644 --- a/server/src/db/models/Monitor.ts +++ b/server/src/db/models/Monitor.ts @@ -18,11 +18,12 @@ type CheckSnapshotDocument = Omit & { createdAt: Dat type MonitorDocumentBase = Omit< Monitor, - "id" | "userId" | "teamId" | "notifications" | "selectedDisks" | "statusWindow" | "recentChecks" | "createdAt" | "updatedAt" + "id" | "userId" | "teamId" | "notifications" | "tags" | "selectedDisks" | "statusWindow" | "recentChecks" | "createdAt" | "updatedAt" > & { statusWindow: boolean[]; recentChecks: CheckSnapshotDocument[]; notifications: Types.ObjectId[]; + tags: Types.ObjectId[]; selectedDisks: string[]; matchMethod?: MonitorMatchMethod; }; @@ -284,6 +285,12 @@ const MonitorSchema = new Schema( ref: "Notification", }, ], + tags: [ + { + type: Schema.Types.ObjectId, + ref: "Tag", + }, + ], secret: { type: String, }, diff --git a/server/src/db/models/Tag.ts b/server/src/db/models/Tag.ts new file mode 100644 index 000000000..df77aa7ab --- /dev/null +++ b/server/src/db/models/Tag.ts @@ -0,0 +1,33 @@ +import { Schema, model, type Types } from "mongoose"; +import type { Tag as TagEntity } from "@/types/index.js"; + +type TagDocumentBase = Omit; + +interface TagDocument extends TagDocumentBase { + _id: Types.ObjectId; + teamId: Types.ObjectId; + createdAt: Date; + updatedAt: Date; +} + +const TagSchema = new Schema( + { + teamId: { + type: Schema.Types.ObjectId, + ref: "Team", + immutable: true, + required: true, + }, + name: { type: String, required: true }, + color: { type: String, required: true, default: "#13715B" }, + }, + { timestamps: true } +); + +TagSchema.index({ teamId: 1, name: 1 }, { unique: true }); + +const TagModel = model("Tag", TagSchema); + +export type { TagDocument }; +export { TagModel }; +export default TagModel; diff --git a/server/src/db/models/index.ts b/server/src/db/models/index.ts index d4c8f1a3b..f0647f9eb 100644 --- a/server/src/db/models/index.ts +++ b/server/src/db/models/index.ts @@ -39,3 +39,5 @@ export { default as GeoCheckModel } from "@/db/models/GeoCheck.js"; export * from "@/db/models/DLQItem.js"; export { default as DLQItemModel } from "@/db/models/DLQItem.js"; +export * from "@/db/models/Tag.js"; +export { default as TagModel } from "@/db/models/Tag.js"; From b1871b31a5399acb0b83b97fe40165698316fbf9 Mon Sep 17 00:00:00 2001 From: Alex Holliday Date: Tue, 19 May 2026 11:15:16 -0700 Subject: [PATCH 117/245] create tags repository, update related repositories --- server/src/repositories/index.ts | 3 + .../monitors/IMonitorsRepository.ts | 1 + .../monitors/MongoMonitorsRepository.ts | 9 +- .../src/repositories/tags/ITagsRepository.ts | 13 +++ .../repositories/tags/MongoTagsRepository.ts | 87 +++++++++++++++++++ 5 files changed, 112 insertions(+), 1 deletion(-) create mode 100644 server/src/repositories/tags/ITagsRepository.ts create mode 100644 server/src/repositories/tags/MongoTagsRepository.ts diff --git a/server/src/repositories/index.ts b/server/src/repositories/index.ts index fc5eefee9..f45768b02 100644 --- a/server/src/repositories/index.ts +++ b/server/src/repositories/index.ts @@ -25,6 +25,9 @@ export { default as MongoSettingsRepository } from "@/repositories/settings/Mong export * from "@/repositories/notifications/INotificationsRepository.js"; export { default as MongoNotificationsRepository } from "@/repositories/notifications/MongoNotificationsRepository.js"; +export * from "@/repositories/tags/ITagsRepository.js"; +export { default as MongoTagsRepository } from "@/repositories/tags/MongoTagsRepository.js"; + export * from "@/repositories/incidents/IIncidentsRepository.js"; export { default as MongoIncidentsRepository } from "@/repositories/incidents/MongoIncidentsRepository.js"; diff --git a/server/src/repositories/monitors/IMonitorsRepository.ts b/server/src/repositories/monitors/IMonitorsRepository.ts index ac4c8f9eb..ea75e11f6 100644 --- a/server/src/repositories/monitors/IMonitorsRepository.ts +++ b/server/src/repositories/monitors/IMonitorsRepository.ts @@ -51,6 +51,7 @@ export interface IMonitorsRepository { findMonitorsSummaryByTeamId(teamId: string, config?: SummaryConfig): Promise; findGroupsByTeamId(teamId: string): Promise; removeNotificationFromMonitors(notificationId: string): Promise; + removeTagFromMonitors(tagId: string): Promise; updateNotifications(teamId: string, monitorIds: string[], notificationIds: string[], action: "add" | "remove" | "set"): Promise; deleteByTeamIdsNotIn(teamIds: string[]): Promise; findAllMonitorIds(): Promise; diff --git a/server/src/repositories/monitors/MongoMonitorsRepository.ts b/server/src/repositories/monitors/MongoMonitorsRepository.ts index 3ad27b227..9e493e740 100644 --- a/server/src/repositories/monitors/MongoMonitorsRepository.ts +++ b/server/src/repositories/monitors/MongoMonitorsRepository.ts @@ -351,6 +351,10 @@ class MongoMonitorsRepository implements IMonitorsRepository { await MonitorModel.updateMany({ notifications: notificationId }, { $pull: { notifications: notificationId } }); }; + removeTagFromMonitors = async (tagId: string): Promise => { + await MonitorModel.updateMany({ tags: tagId }, { $pull: { tags: tagId } }); + }; + updateNotifications = async ( teamId: string, monitorIds: string[], @@ -406,7 +410,7 @@ class MongoMonitorsRepository implements IMonitorsRepository { }; const notificationIds = (doc.notifications ?? []).map((notification) => toStringId(notification)); - + const tagIds = (doc.tags ?? []).map((tag) => toStringId(tag)); return { id: toStringId(doc._id), userId: toStringId(doc.userId), @@ -429,6 +433,7 @@ class MongoMonitorsRepository implements IMonitorsRepository { interval: doc.interval, uptimePercentage: doc.uptimePercentage ?? undefined, notifications: notificationIds, + tags: tagIds, secret: doc.secret ?? undefined, cpuAlertThreshold: doc.cpuAlertThreshold, cpuAlertCounter: doc.cpuAlertCounter, @@ -468,6 +473,7 @@ class MongoMonitorsRepository implements IMonitorsRepository { }; const notificationIds = (doc.notifications ?? []).map((notification: unknown) => toStringId(notification)); + const tagIds = (doc.tags ?? []).map((tag: unknown) => toStringId(tag)); return { id: toStringId(doc._id), @@ -491,6 +497,7 @@ class MongoMonitorsRepository implements IMonitorsRepository { interval: doc.interval, uptimePercentage: doc.uptimePercentage ?? undefined, notifications: notificationIds, + tags: tagIds, secret: doc.secret ?? undefined, cpuAlertThreshold: doc.cpuAlertThreshold, cpuAlertCounter: doc.cpuAlertCounter, diff --git a/server/src/repositories/tags/ITagsRepository.ts b/server/src/repositories/tags/ITagsRepository.ts new file mode 100644 index 000000000..42cec2a5a --- /dev/null +++ b/server/src/repositories/tags/ITagsRepository.ts @@ -0,0 +1,13 @@ +import { Tag } from "@/types/index.js"; + +export interface ITagsRepository { + // create + create(tagData: Partial): Promise; + // read + findById(tagId: string, teamId: string): Promise; + findByTeamId(teamId: string): Promise; + // update + updateById(tagId: string, teamId: string, patch: Partial): Promise; + // delete + deleteById(tagId: string, teamId: string): Promise; +} diff --git a/server/src/repositories/tags/MongoTagsRepository.ts b/server/src/repositories/tags/MongoTagsRepository.ts new file mode 100644 index 000000000..72355c7df --- /dev/null +++ b/server/src/repositories/tags/MongoTagsRepository.ts @@ -0,0 +1,87 @@ +import { ITagsRepository } from "@/repositories/tags/ITagsRepository.js"; +import { Tag } from "@/types/tag.js"; +import { TagDocument, TagModel } from "@/db/models/index.js"; +import mongoose from "mongoose"; +import { AppError } from "@/utils/AppError.js"; + +const SERVICE_NAME = "TagsRepository"; + +class MongoTagsRepository implements ITagsRepository { + static SERVICE_NAME = SERVICE_NAME; + + private toStringId = (value?: mongoose.Types.ObjectId | string | null): string => { + if (!value) { + return ""; + } + return value instanceof mongoose.Types.ObjectId ? value.toString() : String(value); + }; + + private toDateString = (value?: Date | string | null): string => { + if (!value) { + return new Date(0).toISOString(); + } + return value instanceof Date ? value.toISOString() : new Date(value).toISOString(); + }; + + protected toEntity = (doc: TagDocument): Tag => { + return { + id: this.toStringId(doc._id), + teamId: this.toStringId(doc.teamId), + name: doc.name, + color: doc.color, + createdAt: this.toDateString(doc.createdAt), + updatedAt: this.toDateString(doc.updatedAt), + }; + }; + + async create(tagData: Partial): Promise { + try { + const tag = await TagModel.create({ ...tagData }); + return this.toEntity(tag); + } catch (error) { + if (error && typeof error === "object" && (error as { code?: number }).code === 11000) { + throw new AppError({ message: `A tag named "${tagData.name}" already exists`, service: SERVICE_NAME, status: 409 }); + } + throw error; + } + } + + async findById(tagId: string, teamId: string): Promise { + const tag = await TagModel.findOne({ _id: tagId, teamId }); + if (!tag) { + throw new AppError({ message: "Tag not found", service: SERVICE_NAME, status: 404 }); + } + return this.toEntity(tag); + } + + async findByTeamId(teamId: string): Promise { + const tags = await TagModel.find({ teamId }); + return tags.map(this.toEntity); + } + + async updateById(tagId: string, teamId: string, patch: Partial): Promise { + const updatedTag = await TagModel.findOneAndUpdate( + { _id: tagId, teamId }, + { + $set: { + ...patch, + }, + }, + { new: true, runValidators: true } + ); + if (!updatedTag) { + throw new AppError({ message: "Tag not found or could not be updated", service: SERVICE_NAME, status: 404 }); + } + return this.toEntity(updatedTag); + } + + async deleteById(tagId: string, teamId: string): Promise { + const deletedTag = await TagModel.findOneAndDelete({ _id: tagId, teamId }); + if (!deletedTag) { + throw new AppError({ message: "Tag not found or could not be deleted", service: SERVICE_NAME, status: 404 }); + } + return this.toEntity(deletedTag); + } +} + +export default MongoTagsRepository; From 451c752170747d5e155c748c856bbbbad7f6fbbd Mon Sep 17 00:00:00 2001 From: Alex Holliday Date: Tue, 19 May 2026 11:15:42 -0700 Subject: [PATCH 118/245] add tags controller and routes --- server/src/controllers/tagController.ts | 98 +++++++++++++++++++++++++ server/src/routes/tagRoutes.ts | 28 +++++++ 2 files changed, 126 insertions(+) create mode 100644 server/src/controllers/tagController.ts create mode 100644 server/src/routes/tagRoutes.ts diff --git a/server/src/controllers/tagController.ts b/server/src/controllers/tagController.ts new file mode 100644 index 000000000..66b21dc6b --- /dev/null +++ b/server/src/controllers/tagController.ts @@ -0,0 +1,98 @@ +import { Request, Response, NextFunction } from "express"; +import { ITagsService } from "@/service/index.js"; +import { requireTeamId } from "./controllerUtils.js"; +import { + createTagBodyValidation, + editTagBodyValidation, + getTagByIdParamValidation, + editTagParamValidation, + deleteTagParamValidation, +} from "@/validation/index.js"; + +export interface ITagsController { + createTag: (req: Request, res: Response, next: NextFunction) => Promise; + getTagById: (req: Request, res: Response, next: NextFunction) => Promise; + getTagsByTeamId: (req: Request, res: Response, next: NextFunction) => Promise; + editTag: (req: Request, res: Response, next: NextFunction) => Promise; + deleteTag: (req: Request, res: Response, next: NextFunction) => Promise; +} + +class TagsController implements ITagsController { + constructor(private tagsService: ITagsService) {} + + createTag = async (req: Request, res: Response, next: NextFunction) => { + try { + const validatedBody = createTagBodyValidation.parse(req.body); + const teamId = requireTeamId(req.user?.teamId); + + const tag = await this.tagsService.createTag(validatedBody, teamId); + return res.status(200).json({ + success: true, + msg: "Tag created successfully", + data: tag, + }); + } catch (error) { + next(error); + } + }; + getTagById = async (req: Request, res: Response, next: NextFunction) => { + try { + const teamId = requireTeamId(req.user?.teamId); + const { id: tagId } = getTagByIdParamValidation.parse(req.params); + const tag = await this.tagsService.getTag(tagId, teamId); + return res.status(200).json({ + success: true, + msg: "Tag retrieved successfully", + data: tag, + }); + } catch (error) { + next(error); + } + }; + + getTagsByTeamId = async (req: Request, res: Response, next: NextFunction) => { + try { + const teamId = requireTeamId(req.user?.teamId); + const tags = await this.tagsService.getTagsByTeamId(teamId); + return res.status(200).json({ + success: true, + msg: "Tags retrieved successfully", + data: tags, + }); + } catch (error) { + next(error); + } + }; + + editTag = async (req: Request, res: Response, next: NextFunction) => { + try { + const teamId = requireTeamId(req.user?.teamId); + const { id: tagId } = editTagParamValidation.parse(req.params); + const validatedBody = editTagBodyValidation.parse(req.body); + const updatedTag = await this.tagsService.updateTag(tagId, teamId, validatedBody); + return res.status(200).json({ + success: true, + msg: "Tag updated successfully", + data: updatedTag, + }); + } catch (error) { + next(error); + } + }; + + deleteTag = async (req: Request, res: Response, next: NextFunction) => { + try { + const teamId = requireTeamId(req.user?.teamId); + const { id: tagId } = deleteTagParamValidation.parse(req.params); + await this.tagsService.deleteTag(tagId, teamId); + return res.status(200).json({ + success: true, + msg: "Tag deleted successfully", + }); + } catch (error) { + next(error); + } + }; +} + +export default TagsController; diff --git a/server/src/routes/tagRoutes.ts b/server/src/routes/tagRoutes.ts new file mode 100644 index 000000000..9b237c976 --- /dev/null +++ b/server/src/routes/tagRoutes.ts @@ -0,0 +1,28 @@ +import { ITagsController } from "@/controllers/tagController.js"; +import { Router } from "express"; +class TagRoutes { + private router: Router; + private tagController: ITagsController; + + constructor(tagController: ITagsController) { + this.router = Router(); + this.tagController = tagController; + this.initializeRoutes(); + } + + initializeRoutes() { + this.router.post("/", this.tagController.createTag); + + this.router.get("/team", this.tagController.getTagsByTeamId); + + this.router.get("/:id", this.tagController.getTagById); + this.router.delete("/:id", this.tagController.deleteTag); + this.router.patch("/:id", this.tagController.editTag); + } + + getRouter() { + return this.router; + } +} + +export default TagRoutes; From 48735c7dc849322d471a68006704a06ec6ad9937 Mon Sep 17 00:00:00 2001 From: Alex Holliday Date: Tue, 19 May 2026 11:15:54 -0700 Subject: [PATCH 119/245] add tag service --- server/src/service/index.ts | 1 + .../src/service/infrastructure/tagService.ts | 37 +++++++++++++++++++ 2 files changed, 38 insertions(+) create mode 100644 server/src/service/infrastructure/tagService.ts diff --git a/server/src/service/index.ts b/server/src/service/index.ts index 5db8afc4f..9bac381f6 100644 --- a/server/src/service/index.ts +++ b/server/src/service/index.ts @@ -18,6 +18,7 @@ export * from "@/service/infrastructure/emailService.js"; export * from "@/service/infrastructure/globalPingService.js"; export * from "@/service/infrastructure/networkService.js"; export * from "@/service/infrastructure/notificationsService.js"; +export * from "@/service/infrastructure/tagService.js"; export * from "@/service/infrastructure/statusService.js"; // Notification providers diff --git a/server/src/service/infrastructure/tagService.ts b/server/src/service/infrastructure/tagService.ts new file mode 100644 index 000000000..5dc6f7cef --- /dev/null +++ b/server/src/service/infrastructure/tagService.ts @@ -0,0 +1,37 @@ +import { Tag } from "@/types/index.js"; +import { ITagsRepository, IMonitorsRepository } from "@/repositories/index.js"; +export interface ITagsService { + createTag(tag: Partial, teamId: string): Promise; + getTag(tagId: string, teamId: string): Promise; + getTagsByTeamId(teamId: string): Promise; + updateTag(tagId: string, teamId: string, patch: Partial): Promise; + deleteTag(tagId: string, teamId: string): Promise; +} + +export class TagsService implements ITagsService { + constructor( + private tagsRepository: ITagsRepository, + private monitorsRepository: IMonitorsRepository + ) {} + + createTag = async (tagData: Partial, teamId: string): Promise => { + tagData.teamId = teamId; + return await this.tagsRepository.create(tagData); + }; + + getTag = async (tagId: string, teamId: string): Promise => { + return await this.tagsRepository.findById(tagId, teamId); + }; + + getTagsByTeamId = async (teamId: string): Promise => { + return await this.tagsRepository.findByTeamId(teamId); + }; + + updateTag = async (tagId: string, teamId: string, patch: Partial): Promise => { + return await this.tagsRepository.updateById(tagId, teamId, patch); + }; + deleteTag = async (tagId: string, teamId: string): Promise => { + await this.monitorsRepository.removeTagFromMonitors(tagId); + return await this.tagsRepository.deleteById(tagId, teamId); + }; +} From 57acbd3497c34f1aee2903b0bc7f2784d6b5432a Mon Sep 17 00:00:00 2001 From: Alex Holliday Date: Tue, 19 May 2026 11:16:03 -0700 Subject: [PATCH 120/245] add validations --- server/src/validation/index.ts | 1 + server/src/validation/monitorValidation.ts | 3 +++ server/src/validation/tagValidation.ts | 25 ++++++++++++++++++++++ 3 files changed, 29 insertions(+) create mode 100644 server/src/validation/tagValidation.ts diff --git a/server/src/validation/index.ts b/server/src/validation/index.ts index cc1d33372..8627d3892 100644 --- a/server/src/validation/index.ts +++ b/server/src/validation/index.ts @@ -21,3 +21,4 @@ export * from "./statusPageValidation.js"; export * from "./notificationValidation.js"; export * from "./userValidation.js"; export * from "./incidentValidation.js"; +export * from "./tagValidation.js"; diff --git a/server/src/validation/monitorValidation.ts b/server/src/validation/monitorValidation.ts index 69d1c2c2a..b668b4cd2 100644 --- a/server/src/validation/monitorValidation.ts +++ b/server/src/validation/monitorValidation.ts @@ -78,6 +78,7 @@ export const createMonitorBodyValidation = z diskAlertThreshold: z.number().optional(), tempAlertThreshold: z.number().optional(), notifications: z.array(z.string()).optional(), + tags: z.array(z.string()).optional(), secret: z.string().optional(), jsonPath: z.union([z.string(), z.literal("")]).optional(), expectedValue: z.union([z.string(), z.literal("")]).optional(), @@ -106,6 +107,7 @@ export const editMonitorBodyValidation = z description: z.union([z.string(), z.literal("")]).optional(), interval: z.number().optional(), notifications: z.array(z.string()).optional(), + tags: z.array(z.string()).optional(), secret: z.string().optional(), ignoreTlsErrors: z.boolean().optional(), useAdvancedMatching: z.boolean().optional(), @@ -175,6 +177,7 @@ const importedMonitorSchema = z interval: z.number().default(60000), uptimePercentage: z.number().optional(), notifications: z.array(z.string()).default([]), + tags: z.array(z.string()).default([]), secret: z.string().optional(), cpuAlertThreshold: z.number().default(100), cpuAlertCounter: z.number().default(5), diff --git a/server/src/validation/tagValidation.ts b/server/src/validation/tagValidation.ts new file mode 100644 index 000000000..9a5513741 --- /dev/null +++ b/server/src/validation/tagValidation.ts @@ -0,0 +1,25 @@ +import { z } from "zod"; + +//**************************************** +// Tag Validations +//**************************************** + +export const createTagBodyValidation = z.object({ + name: z.string().min(1, "Tag name is required"), + color: z + .string() + .regex(/^#([0-9a-fA-F]{6})$/, "Color must be a 6-digit hex value (e.g. #13715B)") + .optional(), +}); + +export const editTagBodyValidation = createTagBodyValidation; + +export const getTagByIdParamValidation = z.object({ + id: z.string().min(1, "Tag ID is required"), +}); +export const editTagParamValidation = z.object({ + id: z.string().min(1, "Tag ID is required"), +}); +export const deleteTagParamValidation = z.object({ + id: z.string().min(1, "Tag ID is required"), +}); From 6c2e3b4fe1b5493d7b1c348182b1f29f3b10c355 Mon Sep 17 00:00:00 2001 From: Alex Holliday Date: Tue, 19 May 2026 11:16:16 -0700 Subject: [PATCH 121/245] init tags --- server/src/config/controllers.ts | 3 +++ server/src/config/routes.ts | 3 +++ server/src/config/services.ts | 12 ++++++++++++ 3 files changed, 18 insertions(+) diff --git a/server/src/config/controllers.ts b/server/src/config/controllers.ts index 00769f545..e757cfa22 100644 --- a/server/src/config/controllers.ts +++ b/server/src/config/controllers.ts @@ -9,6 +9,7 @@ import QueueController from "../controllers/queueController.js"; import LogController from "../controllers/logController.js"; import StatusPageController from "../controllers/statusPageController.js"; import NotificationController from "../controllers/notificationController.js"; +import TagsController from "../controllers/tagController.js"; import DiagnosticController from "../controllers/diagnosticController.js"; import IncidentController from "../controllers/incidentController.js"; import type { InitializedServices } from "@/config/services.js"; @@ -25,6 +26,7 @@ export interface InitializedControllers { logController: LogController; statusPageController: StatusPageController; notificationController: NotificationController; + tagController: TagsController; diagnosticController: DiagnosticController; incidentController: IncidentController; } @@ -41,6 +43,7 @@ export const initializeControllers = (services: InitializedServices): Initialize logController: new LogController(services.logger), statusPageController: new StatusPageController(services.statusPageService, services.monitorsRepository, services.settingsService), notificationController: new NotificationController(services.notificationsService, services.monitorsRepository), + tagController: new TagsController(services.tagsService), diagnosticController: new DiagnosticController(services.diagnosticService), incidentController: new IncidentController(services.incidentService), }; diff --git a/server/src/config/routes.ts b/server/src/config/routes.ts index f36f56e16..cff5e6b1d 100644 --- a/server/src/config/routes.ts +++ b/server/src/config/routes.ts @@ -17,6 +17,7 @@ import QueueRoutes from "../routes/queueRoute.js"; import LogRoutes from "../routes/logRoutes.js"; import DiagnosticRoutes from "../routes/diagnosticRoute.js"; import NotificationRoutes from "../routes/notificationRoute.js"; +import TagRoutes from "../routes/tagRoutes.js"; import IncidentRoutes from "../routes/incidentRoute.js"; @@ -34,6 +35,7 @@ export const setupRoutes = (app: Application, controllers: InitializedController const verifyStatusPageAccess = createVerifyStatusPageAccess(services.statusPagesRepository, verifyJWT); const statusPageRoutes = new StatusPageRoutes(controllers.statusPageController, verifyJWT, verifyStatusPageAccess); const notificationRoutes = new NotificationRoutes(controllers.notificationController); + const tagRoutes = new TagRoutes(controllers.tagController); const diagnosticRoutes = new DiagnosticRoutes(controllers.diagnosticController, verifyJWT); const incidentRoutes = new IncidentRoutes(controllers.incidentController); @@ -48,6 +50,7 @@ export const setupRoutes = (app: Application, controllers: InitializedController app.use("/api/v1/logs", verifyJWT, logRoutes.getRouter()); app.use("/api/v1/status-page", statusPageRoutes.getRouter()); app.use("/api/v1/notifications", verifyJWT, notificationRoutes.getRouter()); + app.use("/api/v1/tags", verifyJWT, tagRoutes.getRouter()); app.use("/api/v1/diagnostic", verifyJWT, diagnosticRoutes.getRouter()); app.use("/api/v1/incidents", verifyJWT, incidentRoutes.getRouter()); }; diff --git a/server/src/config/services.ts b/server/src/config/services.ts index 309f55242..09c97e1fc 100644 --- a/server/src/config/services.ts +++ b/server/src/config/services.ts @@ -9,6 +9,7 @@ import { SuperSimpleQueue, SuperSimpleQueueHelper, NotificationsService, + TagsService, StatusService, NotificationMessageBuilder, MonitorService, @@ -37,6 +38,7 @@ import { IBufferService, ISuperSimpleQueue, INotificationsService, + ITagsService, IStatusService, IMonitorService, IUserService, @@ -98,6 +100,7 @@ import { MongoInvitesRepository, MongoRecoveryTokensRepository, MongoNotificationsRepository, + MongoTagsRepository, MongoIncidentsRepository, MongoTeamsRepository, MongoMaintenanceWindowsRepository, @@ -112,6 +115,7 @@ import { IRecoveryTokensRepository, ISettingsRepository, INotificationsRepository, + ITagsRepository, IIncidentsRepository, ITeamsRepository, IMaintenanceWindowsRepository, @@ -137,6 +141,7 @@ export type InitializedServices = { incidentService: IIncidentService; logger: ILogger; notificationsService: INotificationsService; + tagsService: ITagsService; statusPageService: IStatusPageService; notificationMessageBuilder: INotificationMessageBuilder; @@ -151,6 +156,7 @@ export type InitializedServices = { recoveryTokensRepository: IRecoveryTokensRepository; settingsRepository: ISettingsRepository; notificationsRepository: INotificationsRepository; + tagsRepository: ITagsRepository; incidentsRepository: IIncidentsRepository; teamsRepository: ITeamsRepository; maintenanceWindowsRepository: IMaintenanceWindowsRepository; @@ -191,6 +197,7 @@ export const initializeServices = async ({ let recoveryTokensRepository: IRecoveryTokensRepository; let settingsRepository: ISettingsRepository; let notificationsRepository: INotificationsRepository; + let tagsRepository: ITagsRepository; let incidentsRepository: IIncidentsRepository; let teamsRepository: ITeamsRepository; let maintenanceWindowsRepository: IMaintenanceWindowsRepository; @@ -208,6 +215,7 @@ export const initializeServices = async ({ recoveryTokensRepository = new MongoRecoveryTokensRepository(); settingsRepository = new MongoSettingsRepository(); notificationsRepository = new MongoNotificationsRepository(); + tagsRepository = new MongoTagsRepository(); incidentsRepository = new MongoIncidentsRepository(); teamsRepository = new MongoTeamsRepository(); maintenanceWindowsRepository = new MongoMaintenanceWindowsRepository(); @@ -293,6 +301,8 @@ export const initializeServices = async ({ notificationMessageBuilder ); + const tagsService = new TagsService(tagsRepository, monitorsRepository); + const superSimpleQueueHelper = new SuperSimpleQueueHelper( logger, networkService, @@ -372,6 +382,7 @@ export const initializeServices = async ({ incidentService, logger, notificationsService, + tagsService, statusPageService, notificationMessageBuilder, @@ -386,6 +397,7 @@ export const initializeServices = async ({ recoveryTokensRepository, settingsRepository, notificationsRepository, + tagsRepository, incidentsRepository, teamsRepository, maintenanceWindowsRepository, From 9b84b98391a072ff4bc837030cb8175c8d0fb2d0 Mon Sep 17 00:00:00 2001 From: Alex Holliday Date: Tue, 19 May 2026 11:16:25 -0700 Subject: [PATCH 122/245] add tests --- server/test/unit/services/tagsService.test.ts | 124 ++++++++++++++++++ 1 file changed, 124 insertions(+) create mode 100644 server/test/unit/services/tagsService.test.ts diff --git a/server/test/unit/services/tagsService.test.ts b/server/test/unit/services/tagsService.test.ts new file mode 100644 index 000000000..d611d641e --- /dev/null +++ b/server/test/unit/services/tagsService.test.ts @@ -0,0 +1,124 @@ +import { describe, expect, it, jest } from "@jest/globals"; +import { TagsService } from "../../../src/service/infrastructure/tagService.ts"; +import type { Tag } from "../../../src/types/index.ts"; + +// ── Helpers ────────────────────────────────────────────────────────────────── + +const createTagsRepo = () => ({ + create: jest.fn(), + findById: jest.fn(), + findByTeamId: jest.fn(), + updateById: jest.fn(), + deleteById: jest.fn(), +}); + +const createMonitorsRepo = () => ({ + removeTagFromMonitors: jest.fn(), +}); + +const createService = (overrides?: Record) => { + const tagsRepository = createTagsRepo(); + const monitorsRepository = createMonitorsRepo(); + + const defaults = { + tagsRepository, + monitorsRepository, + ...overrides, + }; + + const service = new TagsService(defaults.tagsRepository as any, defaults.monitorsRepository as any); + + return { service, ...defaults }; +}; + +const makeTag = (overrides?: Partial): Tag => + ({ + id: "tag-1", + teamId: "team-1", + name: "Production", + color: "#13715B", + createdAt: "2026-01-01T00:00:00Z", + updatedAt: "2026-01-01T00:00:00Z", + ...overrides, + }) as Tag; + +// ── Tests ──────────────────────────────────────────────────────────────────── + +describe("TagsService", () => { + describe("createTag", () => { + it("sets teamId and delegates to repository", async () => { + const created = makeTag(); + const { service, tagsRepository } = createService(); + (tagsRepository.create as jest.Mock).mockResolvedValue(created); + + const result = await service.createTag({ name: "Production", color: "#13715B" }, "team-1"); + + expect(result).toBe(created); + expect(tagsRepository.create).toHaveBeenCalledWith(expect.objectContaining({ teamId: "team-1", name: "Production", color: "#13715B" })); + }); + }); + + describe("getTag", () => { + it("delegates to repository", async () => { + const tag = makeTag(); + const { service, tagsRepository } = createService(); + (tagsRepository.findById as jest.Mock).mockResolvedValue(tag); + + const result = await service.getTag("tag-1", "team-1"); + + expect(result).toBe(tag); + expect(tagsRepository.findById).toHaveBeenCalledWith("tag-1", "team-1"); + }); + }); + + describe("getTagsByTeamId", () => { + it("delegates to repository", async () => { + const tags = [makeTag()]; + const { service, tagsRepository } = createService(); + (tagsRepository.findByTeamId as jest.Mock).mockResolvedValue(tags); + + const result = await service.getTagsByTeamId("team-1"); + + expect(result).toBe(tags); + expect(tagsRepository.findByTeamId).toHaveBeenCalledWith("team-1"); + }); + }); + + describe("updateTag", () => { + it("delegates to repository", async () => { + const updated = makeTag({ name: "Staging" }); + const { service, tagsRepository } = createService(); + (tagsRepository.updateById as jest.Mock).mockResolvedValue(updated); + + const result = await service.updateTag("tag-1", "team-1", { name: "Staging" }); + + expect(result).toBe(updated); + expect(tagsRepository.updateById).toHaveBeenCalledWith("tag-1", "team-1", { name: "Staging" }); + }); + }); + + describe("deleteTag", () => { + it("removes the tag from monitors and deletes it", async () => { + const deleted = makeTag(); + const { service, tagsRepository, monitorsRepository } = createService(); + (tagsRepository.deleteById as jest.Mock).mockResolvedValue(deleted); + + const result = await service.deleteTag("tag-1", "team-1"); + + expect(result).toBe(deleted); + expect(monitorsRepository.removeTagFromMonitors).toHaveBeenCalledWith("tag-1"); + expect(tagsRepository.deleteById).toHaveBeenCalledWith("tag-1", "team-1"); + }); + + it("detaches the tag from monitors before deleting it", async () => { + const { service, tagsRepository, monitorsRepository } = createService(); + (tagsRepository.deleteById as jest.Mock).mockResolvedValue(makeTag()); + + await service.deleteTag("tag-1", "team-1"); + + const removeOrder = (monitorsRepository.removeTagFromMonitors as jest.Mock).mock.invocationCallOrder[0]; + const deleteOrder = (tagsRepository.deleteById as jest.Mock).mock.invocationCallOrder[0]; + expect(removeOrder).toBeLessThan(deleteOrder); + }); + }); +}); From a7abbd60bd91762f8df3c2872db10ec47003a634 Mon Sep 17 00:00:00 2001 From: Alex Holliday Date: Tue, 19 May 2026 14:44:18 -0700 Subject: [PATCH 123/245] add tags to monitors repo --- .../src/repositories/monitors/IMonitorsRepository.ts | 1 + .../repositories/monitors/MongoMonitorsRepository.ts | 12 ++++++++++-- 2 files changed, 11 insertions(+), 2 deletions(-) diff --git a/server/src/repositories/monitors/IMonitorsRepository.ts b/server/src/repositories/monitors/IMonitorsRepository.ts index ea75e11f6..6103132f8 100644 --- a/server/src/repositories/monitors/IMonitorsRepository.ts +++ b/server/src/repositories/monitors/IMonitorsRepository.ts @@ -3,6 +3,7 @@ import { type MonitorType, type Monitor, type MonitorsSummary, CheckSnapshot } f export interface TeamQueryConfig { limit?: number; type?: MonitorType | MonitorType[]; + tags?: string | string[]; page?: number; rowsPerPage?: number; filter?: string; diff --git a/server/src/repositories/monitors/MongoMonitorsRepository.ts b/server/src/repositories/monitors/MongoMonitorsRepository.ts index 9e493e740..24a3cb7d8 100644 --- a/server/src/repositories/monitors/MongoMonitorsRepository.ts +++ b/server/src/repositories/monitors/MongoMonitorsRepository.ts @@ -44,7 +44,7 @@ class MongoMonitorsRepository implements IMonitorsRepository { }; findByTeamId = async (teamId: string, config: TeamQueryConfig): Promise => { - const { page = 0, rowsPerPage = 0, filter, field = "createdAt", order = "desc", type } = config ?? {}; + const { page = 0, rowsPerPage = 0, filter, field = "createdAt", order = "desc", type, tags } = config ?? {}; const query: Record = { teamId: new mongoose.Types.ObjectId(teamId), @@ -54,6 +54,10 @@ class MongoMonitorsRepository implements IMonitorsRepository { query.type = Array.isArray(type) ? { $in: type } : type; } + if (tags !== undefined) { + query.tags = Array.isArray(tags) ? { $in: tags } : tags; + } + if (filter !== undefined) { switch (field) { case "name": @@ -152,7 +156,7 @@ class MongoMonitorsRepository implements IMonitorsRepository { }; findMonitorCountByTeamIdAndType = async (teamId: string, config?: TeamQueryConfig): Promise => { - const { type } = config ?? {}; + const { type, tags } = config ?? {}; const query: FilterQuery = { teamId: new mongoose.Types.ObjectId(teamId), @@ -162,6 +166,10 @@ class MongoMonitorsRepository implements IMonitorsRepository { query.type = Array.isArray(type) ? { $in: type } : type; } + if (tags !== undefined) { + query.tags = Array.isArray(tags) ? { $in: tags } : tags; + } + const count = await MonitorModel.countDocuments(query); return count; }; From 029f732a8618fdc234d4a4062bc5120ed2b21a3a Mon Sep 17 00:00:00 2001 From: Alex Holliday Date: Tue, 19 May 2026 14:44:56 -0700 Subject: [PATCH 124/245] add tags to monitor service --- server/src/service/business/monitorService.ts | 11 +++++++++-- 1 file changed, 9 insertions(+), 2 deletions(-) diff --git a/server/src/service/business/monitorService.ts b/server/src/service/business/monitorService.ts index 72c8f1fe8..fc08200c5 100644 --- a/server/src/service/business/monitorService.ts +++ b/server/src/service/business/monitorService.ts @@ -55,6 +55,7 @@ export interface IMonitorService { teamId: string; limit?: number; type?: MonitorType | MonitorType[]; + tags?: string | string[]; page?: number; rowsPerPage?: number; filter?: string; @@ -65,6 +66,7 @@ export interface IMonitorService { teamId: string; limit?: number; type?: MonitorType | MonitorType[]; + tags?: string | string[]; page?: number; rowsPerPage?: number; filter?: string; @@ -357,19 +359,22 @@ export class MonitorService implements IMonitorService { getMonitorsByTeamId = async ({ teamId, type, + tags, filter, }: { teamId: string; type?: MonitorType | MonitorType[]; + tags?: string | string[]; filter?: string; }): Promise => { - return await this.monitorsRepository.findByTeamId(teamId, { type, filter }); + return await this.monitorsRepository.findByTeamId(teamId, { type, tags, filter }); }; getMonitorsWithChecksByTeamId = async ({ teamId, limit, type, + tags, page, rowsPerPage, filter, @@ -379,6 +384,7 @@ export class MonitorService implements IMonitorService { teamId: string; limit?: number; type?: MonitorType | MonitorType[]; + tags?: string | string[]; page?: number; rowsPerPage?: number; filter?: string; @@ -386,10 +392,11 @@ export class MonitorService implements IMonitorService { order?: "asc" | "desc"; }): Promise => { const summary = await this.monitorsRepository.findMonitorsSummaryByTeamId(teamId, { type }); - const count = await this.monitorsRepository.findMonitorCountByTeamIdAndType(teamId, { type, filter }); + const count = await this.monitorsRepository.findMonitorCountByTeamIdAndType(teamId, { type, tags, filter }); const monitors = await this.monitorsRepository.findByTeamId(teamId, { limit, type, + tags, page, rowsPerPage, filter, From 76ce362eeaed577e160d7a9298b659c82c5cc9b6 Mon Sep 17 00:00:00 2001 From: Alex Holliday Date: Tue, 19 May 2026 14:45:29 -0700 Subject: [PATCH 125/245] add tags to monitor controller --- server/src/controllers/monitorController.ts | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/server/src/controllers/monitorController.ts b/server/src/controllers/monitorController.ts index 5cb6f32be..92289eb22 100644 --- a/server/src/controllers/monitorController.ts +++ b/server/src/controllers/monitorController.ts @@ -357,9 +357,10 @@ class MonitorController implements IMonitorController { const teamId = requireTeamId(req.user?.teamId); const type = validatedQuery.type; + const tags = validatedQuery.tags; const filter = validatedQuery.filter; - const monitors = await this.monitorService.getMonitorsByTeamId({ teamId, type, filter }); + const monitors = await this.monitorService.getMonitorsByTeamId({ teamId, type, tags, filter }); return res.status(200).json({ success: true, @@ -382,12 +383,14 @@ class MonitorController implements IMonitorController { const field = validatedQuery.field; const order = validatedQuery.order; const type = validatedQuery.type; + const tags = validatedQuery.tags; const teamId = requireTeamId(req.user?.teamId); const monitors = await this.monitorService.getMonitorsWithChecksByTeamId({ teamId, limit, type, + tags, page, rowsPerPage, filter, From 2a2f101d4be53a17973a9b901b0a37616da517fa Mon Sep 17 00:00:00 2001 From: Alex Holliday Date: Tue, 19 May 2026 14:45:42 -0700 Subject: [PATCH 126/245] add tests and validation --- server/src/validation/monitorValidation.ts | 3 +++ 1 file changed, 3 insertions(+) diff --git a/server/src/validation/monitorValidation.ts b/server/src/validation/monitorValidation.ts index b668b4cd2..d5164b8ef 100644 --- a/server/src/validation/monitorValidation.ts +++ b/server/src/validation/monitorValidation.ts @@ -22,6 +22,7 @@ export const getMonitorsByTeamIdParamValidation = z.object({}); export const getMonitorsByTeamIdQueryValidation = z.object({ type: z.union([z.enum(MonitorTypes), z.array(z.enum(MonitorTypes))]).optional(), filter: z.union([z.string(), z.literal("")]).optional(), + tags: z.union([z.string(), z.array(z.string())]).optional(), }); export const getMonitorsWithChecksQueryValidation = z.object({ @@ -32,6 +33,7 @@ export const getMonitorsWithChecksQueryValidation = z.object({ field: z.string().optional(), order: z.enum(["asc", "desc"]).optional(), type: z.union([z.enum(MonitorTypes), z.array(z.enum(MonitorTypes))]).optional(), + tags: z.union([z.string(), z.array(z.string())]).optional(), explain: booleanCoercion.optional(), }); @@ -238,6 +240,7 @@ export const monitorResponseSchema = z expectedValue: z.string().optional(), matchMethod: z.enum(MonitorMatchMethods).optional(), notifications: z.array(z.string()), + tags: z.array(z.string()), secret: z.string().optional(), cpuAlertThreshold: z.number(), memoryAlertThreshold: z.number(), From fe74d4b4651bff0172bf2f0eb1781b7d8f1118c1 Mon Sep 17 00:00:00 2001 From: Alex Holliday Date: Wed, 20 May 2026 10:28:07 -0700 Subject: [PATCH 127/245] add tag type --- client/src/Types/Tag.ts | 8 ++++++++ 1 file changed, 8 insertions(+) create mode 100644 client/src/Types/Tag.ts diff --git a/client/src/Types/Tag.ts b/client/src/Types/Tag.ts new file mode 100644 index 000000000..bab6f740a --- /dev/null +++ b/client/src/Types/Tag.ts @@ -0,0 +1,8 @@ +export interface Tag { + id: string; + teamId: string; + name: string; + color: string; + createdAt: string; + updatedAt: string; +} From 1f7344f2e6606cf198225e7c22e8dbb4a98ac112 Mon Sep 17 00:00:00 2001 From: Alex Holliday Date: Wed, 20 May 2026 10:28:26 -0700 Subject: [PATCH 128/245] add Tags pages --- .../src/Pages/Tags/components/TagsTable.tsx | 103 ++++++++++++++++ client/src/Pages/Tags/create/index.tsx | 116 ++++++++++++++++++ client/src/Pages/Tags/index.tsx | 72 +++++++++++ 3 files changed, 291 insertions(+) create mode 100644 client/src/Pages/Tags/components/TagsTable.tsx create mode 100644 client/src/Pages/Tags/create/index.tsx create mode 100644 client/src/Pages/Tags/index.tsx diff --git a/client/src/Pages/Tags/components/TagsTable.tsx b/client/src/Pages/Tags/components/TagsTable.tsx new file mode 100644 index 000000000..c2cbd24d3 --- /dev/null +++ b/client/src/Pages/Tags/components/TagsTable.tsx @@ -0,0 +1,103 @@ +import { ActionsMenu, type ActionMenuItem } from "@/Components/actions-menu"; +import Typography from "@mui/material/Typography"; +import type { Header } from "@/Components/design-elements/Table"; +import { Table, Chip } from "@/Components/design-elements"; +import { Pagination } from "@/Components/design-elements/Table"; +import { useClientPagination } from "@/Hooks/useClientPagination"; + +import type { Tag } from "@/Types/Tag"; +import { useNavigate } from "react-router"; +import { useTranslation } from "react-i18next"; +import { useTheme } from "@mui/material"; + +interface TagsTableProps { + tags: Tag[]; + setSelectedTag: Function; +} + +export const TagsTable = ({ tags, setSelectedTag }: TagsTableProps) => { + const navigate = useNavigate(); + const { t } = useTranslation(); + const theme = useTheme(); + const { pagedRows, paginationProps } = useClientPagination(tags); + + const getActions = (tag: Tag): ActionMenuItem[] => { + return [ + { + id: 1, + label: t("pages.common.monitors.actions.configure"), + action: () => { + navigate(`/tags/configure/${tag.id}`); + }, + closeMenu: true, + }, + + { + id: 2, + label: ( + + {t("pages.common.monitors.actions.delete")} + + ), + action: async () => { + setSelectedTag(tag); + }, + closeMenu: true, + }, + ]; + }; + + const getHeaders = () => { + const headers: Header[] = [ + { + id: "name", + content: t("common.table.headers.name"), + render: (row) => { + return {row?.name}; + }, + }, + + { + id: "color", + content: t("pages.tags.table.headers.color"), + render: (row) => { + return {row?.color}; + }, + }, + { + id: "chip", + content: t("pages.tags.table.headers.chip"), + render: (row) => { + return ( + + ); + }, + }, + + { + id: "actions", + content: t("common.table.headers.actions"), + render: (row) => { + return ; + }, + }, + ]; + return headers; + }; + + return ( + <> +
{ + navigate(`/tags/configure/${row.id}`); + }} + /> + {tags.length > 0 && } + + ); +}; diff --git a/client/src/Pages/Tags/create/index.tsx b/client/src/Pages/Tags/create/index.tsx new file mode 100644 index 000000000..9ce7ca7c4 --- /dev/null +++ b/client/src/Pages/Tags/create/index.tsx @@ -0,0 +1,116 @@ +import { BasePage, ConfigBox } from "@/Components/design-elements"; +import { TextField, Button, ColorInput } from "@/Components/inputs"; +import Stack from "@mui/material/Stack"; +import { useTheme } from "@mui/material/styles"; + +import { useEffect } from "react"; +import { useParams } from "react-router-dom"; +import { useNavigate } from "react-router-dom"; +import { Controller, useForm } from "react-hook-form"; +import { zodResolver } from "@hookform/resolvers/zod"; +import { useGet, usePost, usePatch } from "@/Hooks/UseApi"; +import type { TagFormData } from "@/Validation/tag"; +import type { Tag } from "@/Types/Tag"; +import { useTranslation } from "react-i18next"; +import { useTagsForm } from "@/Hooks/useTagsForm"; + +const TagsCreatePage = () => { + const { t } = useTranslation(); + const theme = useTheme(); + const navigate = useNavigate(); + const { tagId } = useParams<{ tagId: string }>(); + const isEditMode = Boolean(tagId); + + const { data: existingTag } = useGet(isEditMode ? `/tags/${tagId}` : null); + + const { post, loading: isSubmitting } = usePost(); + const { patch, loading: isPatching } = usePatch(); + + const { schema, defaults } = useTagsForm({ data: existingTag }); + + const form = useForm({ + resolver: zodResolver(schema), + defaultValues: defaults, + }); + + const { control, reset, handleSubmit } = form; + + useEffect(() => { + reset(defaults); + }, [defaults, reset]); + + const onSubmit = async (data: TagFormData) => { + const result = isEditMode + ? await patch(`/tags/${tagId}`, data) + : await post("/tags", data); + if (result) { + navigate("/tags"); + } + }; + + return ( + + ( + + )} + /> + } + /> + ( + + )} + /> + } + /> + + + + + + ); +}; + +export default TagsCreatePage; diff --git a/client/src/Pages/Tags/index.tsx b/client/src/Pages/Tags/index.tsx new file mode 100644 index 000000000..9509cfc4b --- /dev/null +++ b/client/src/Pages/Tags/index.tsx @@ -0,0 +1,72 @@ +import { BasePageWithStates } from "@/Components/design-elements"; +import { TagsTable } from "@/Pages/Tags/components/TagsTable"; +import { Dialog } from "@/Components/inputs"; +import { HeaderCreate } from "@/Components/common"; + +import { useState } from "react"; +import { useGet, useDelete } from "@/Hooks/UseApi"; +import { useTranslation } from "react-i18next"; +import type { Tag } from "@/Types/Tag"; +import { useIsAdmin } from "@/Hooks/useIsAdmin"; + +const TagsPage = () => { + const { t } = useTranslation(); + const isAdmin = useIsAdmin(); + + const [selectedTag, setSelectedTag] = useState(null); + const isDialogOpen = Boolean(selectedTag); + + const { + data: tags, + isLoading, + isValidating, + error, + refetch, + } = useGet("/tags/team", {}, { keepPreviousData: true }); + + const { deleteFn, loading: isDeleting } = useDelete(); + + const handleConfirm = async () => { + if (!selectedTag) return; + await deleteFn(`/tags/${selectedTag.id}`); + setSelectedTag(null); + refetch(); + }; + + const handleCancel = () => { + setSelectedTag(null); + }; + + return ( + + + + + + ); +}; + +export default TagsPage; From 8aa55885e530a71a4c27350c5ba55129cc7ea71e Mon Sep 17 00:00:00 2001 From: Alex Holliday Date: Wed, 20 May 2026 10:28:39 -0700 Subject: [PATCH 129/245] add tags validaiton --- client/src/Validation/tag.ts | 10 ++++++++++ 1 file changed, 10 insertions(+) create mode 100644 client/src/Validation/tag.ts diff --git a/client/src/Validation/tag.ts b/client/src/Validation/tag.ts new file mode 100644 index 000000000..96d4c6c94 --- /dev/null +++ b/client/src/Validation/tag.ts @@ -0,0 +1,10 @@ +import { z } from "zod"; + +export const tagSchema = z.object({ + name: z.string().min(1, "Please enter a tag name"), + color: z + .string() + .regex(/^#([0-9a-fA-F]{6})$/, "Color must be a 6-digit hex value (e.g. #13715B)"), +}); + +export type TagFormData = z.infer; From e96de364c0b4d2751d844bc69d8eac1a75157c33 Mon Sep 17 00:00:00 2001 From: Alex Holliday Date: Wed, 20 May 2026 10:28:55 -0700 Subject: [PATCH 130/245] add form hook for tags --- client/src/Hooks/useTagsForm.ts | 18 ++++++++++++++++++ 1 file changed, 18 insertions(+) create mode 100644 client/src/Hooks/useTagsForm.ts diff --git a/client/src/Hooks/useTagsForm.ts b/client/src/Hooks/useTagsForm.ts new file mode 100644 index 000000000..8e64f2ef0 --- /dev/null +++ b/client/src/Hooks/useTagsForm.ts @@ -0,0 +1,18 @@ +import { useMemo } from "react"; +import { tagSchema, type TagFormData } from "@/Validation/tag"; +import type { Tag } from "@/Types/Tag"; + +interface UseTagsFormOptions { + data?: Tag | null; +} + +export const useTagsForm = ({ data }: UseTagsFormOptions) => { + return useMemo(() => { + const defaults: TagFormData = { + name: data?.name ?? "", + color: data?.color ?? "#13715B", + }; + + return { schema: tagSchema, defaults }; + }, [data]); +}; From e6a0f89c684c87f1e8883641e17a352ee41577d2 Mon Sep 17 00:00:00 2001 From: Alex Holliday Date: Wed, 20 May 2026 10:29:06 -0700 Subject: [PATCH 131/245] add strings --- client/src/locales/en.json | 31 +++++++++++++++++++++++++++++++ 1 file changed, 31 insertions(+) diff --git a/client/src/locales/en.json b/client/src/locales/en.json index b964eec84..0428a76d4 100644 --- a/client/src/locales/en.json +++ b/client/src/locales/en.json @@ -876,6 +876,37 @@ "description": "Track server performance, identify bottlenecks, and ensure reliability with real-time infrastructure monitoring." } }, + "tags": { + "header": { + "title": "Tags", + "description": "Organize your monitors with tags" + }, + "fallback": { + "title": "No tags yet", + "description": "Organize your monitors with tags. Once you create a tag and assign it to a monitor, you'll be able to filter by that tag across the app and see tag details here.", + "actionButton": "Create a tag" + }, + "table": { + "headers": { + "color": "Color", + "chip": "Chip" + } + }, + "form": { + "name": { + "title": "Tag name", + "description": "Choose a name for your tag", + "optionName": "Tag name", + "placeholder": "e.g. Group 1" + }, + "color": { + "title": "Tag color", + "description": "Choose a color for your tag", + "optionName": "Tag color", + "placeholder": "e.g. #13715B" + } + } + }, "logs": { "fallback": { "title": "No logs yet", From 5a187cd1c9ac3ce425e2434c26ab60f0688c1d74 Mon Sep 17 00:00:00 2001 From: Alex Holliday Date: Wed, 20 May 2026 10:29:13 -0700 Subject: [PATCH 132/245] add new chip component --- .../src/Components/design-elements/Chip.tsx | 30 +++++++++++++++++++ .../src/Components/design-elements/index.tsx | 1 + client/src/Components/sidebar/Menu.tsx | 5 ++++ 3 files changed, 36 insertions(+) create mode 100644 client/src/Components/design-elements/Chip.tsx diff --git a/client/src/Components/design-elements/Chip.tsx b/client/src/Components/design-elements/Chip.tsx new file mode 100644 index 000000000..c5a902feb --- /dev/null +++ b/client/src/Components/design-elements/Chip.tsx @@ -0,0 +1,30 @@ +import MuiChip from "@mui/material/Chip"; +import Typography from "@mui/material/Typography"; +import { useTheme } from "@mui/material"; +interface ChipProps { + text: string; + color?: string; +} + +export const Chip = ({ text, color }: ChipProps) => { + const theme = useTheme(); + const textColor = color + ? theme.palette.getContrastText(color) + : theme.palette.text.primary; + return ( + + {text} + + } + sx={{ + backgroundColor: color, + }} + /> + ); +}; diff --git a/client/src/Components/design-elements/index.tsx b/client/src/Components/design-elements/index.tsx index 130ccd831..0954041cd 100644 --- a/client/src/Components/design-elements/index.tsx +++ b/client/src/Components/design-elements/index.tsx @@ -27,3 +27,4 @@ export * from "./OfflineBanner"; export * from "./Avatar"; export * from "./StatusCodeLabel"; export * from "./StrategyBadge"; +export * from "./Chip"; diff --git a/client/src/Components/sidebar/Menu.tsx b/client/src/Components/sidebar/Menu.tsx index d3e9d46ee..dae273575 100644 --- a/client/src/Components/sidebar/Menu.tsx +++ b/client/src/Components/sidebar/Menu.tsx @@ -41,6 +41,11 @@ export const getMenu = (t: Function) => { path: "notifications", icon: , }, + { + name: t("components.sidebar.menu.notifications"), + path: "tags", + icon: , + }, { name: t("components.sidebar.menu.checks"), path: "checks", From 02640c31558d512e3c7c12d26b46c2cdeeabef62 Mon Sep 17 00:00:00 2001 From: Alex Holliday Date: Wed, 20 May 2026 10:29:25 -0700 Subject: [PATCH 133/245] add routes --- client/src/Routes/index.tsx | 18 ++++++++++++++++++ 1 file changed, 18 insertions(+) diff --git a/client/src/Routes/index.tsx b/client/src/Routes/index.tsx index 20869c47c..c3091a924 100644 --- a/client/src/Routes/index.tsx +++ b/client/src/Routes/index.tsx @@ -31,9 +31,14 @@ import CreateStatus from "@/Pages/StatusPage/Create/"; import StatusPages from "@/Pages/StatusPage/StatusPages"; import Status from "@/Pages/StatusPage/Status"; +// Notifications import Notifications from "@/Pages/Notifications"; import CreateNotifications from "@/Pages/Notifications/create"; +// Tags +import Tags from "@/Pages/Tags"; +import CreateTags from "@/Pages/Tags/create"; + // Settings import Account from "@/Pages/Account"; import EditUser from "@/Pages/Account/EditUser"; @@ -159,6 +164,19 @@ const Routes = () => { path="notifications/configure/:notificationId" element={} /> + } + /> + } + /> + + } + /> Date: Wed, 20 May 2026 10:45:36 -0700 Subject: [PATCH 134/245] add a colored label --- .../design-elements/StatusLabel.tsx | 20 +++++++++++++++++++ client/src/Components/sidebar/Menu.tsx | 5 +++-- 2 files changed, 23 insertions(+), 2 deletions(-) diff --git a/client/src/Components/design-elements/StatusLabel.tsx b/client/src/Components/design-elements/StatusLabel.tsx index d50058378..ee9a749c2 100644 --- a/client/src/Components/design-elements/StatusLabel.tsx +++ b/client/src/Components/design-elements/StatusLabel.tsx @@ -93,3 +93,23 @@ export const ValueLabel = ({ value, text }: { value: ValueType; text: string }) ); }; + +export const ColoredLabel = ({ text, color }: { text: string; color: string }) => { + const theme = useTheme(); + + return ( + + {text} + + ); +}; diff --git a/client/src/Components/sidebar/Menu.tsx b/client/src/Components/sidebar/Menu.tsx index dae273575..8064a18ac 100644 --- a/client/src/Components/sidebar/Menu.tsx +++ b/client/src/Components/sidebar/Menu.tsx @@ -17,6 +17,7 @@ import { User, Lock, Users, + Tag, } from "lucide-react"; export const getMenu = (t: Function) => { @@ -42,9 +43,9 @@ export const getMenu = (t: Function) => { icon: , }, { - name: t("components.sidebar.menu.notifications"), + name: t("components.sidebar.menu.tags"), path: "tags", - icon: , + icon: , }, { name: t("components.sidebar.menu.checks"), From dc80b3cafce38010b5f2170fb62670b0c2b5d98d Mon Sep 17 00:00:00 2001 From: Alex Holliday Date: Wed, 20 May 2026 10:45:45 -0700 Subject: [PATCH 135/245] add tag to table --- client/src/Pages/Tags/components/TagsTable.tsx | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/client/src/Pages/Tags/components/TagsTable.tsx b/client/src/Pages/Tags/components/TagsTable.tsx index c2cbd24d3..a84922140 100644 --- a/client/src/Pages/Tags/components/TagsTable.tsx +++ b/client/src/Pages/Tags/components/TagsTable.tsx @@ -1,7 +1,7 @@ import { ActionsMenu, type ActionMenuItem } from "@/Components/actions-menu"; import Typography from "@mui/material/Typography"; import type { Header } from "@/Components/design-elements/Table"; -import { Table, Chip } from "@/Components/design-elements"; +import { Table, Chip, ColoredLabel } from "@/Components/design-elements"; import { Pagination } from "@/Components/design-elements/Table"; import { useClientPagination } from "@/Hooks/useClientPagination"; @@ -66,10 +66,10 @@ export const TagsTable = ({ tags, setSelectedTag }: TagsTableProps) => { }, { id: "chip", - content: t("pages.tags.table.headers.chip"), + content: t("pages.tags.table.headers.appearance"), render: (row) => { return ( - From f37ae8fddbf093986f75a097f391c3a043742235 Mon Sep 17 00:00:00 2001 From: Alex Holliday Date: Wed, 20 May 2026 10:45:55 -0700 Subject: [PATCH 136/245] add menu string --- client/src/locales/en.json | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/client/src/locales/en.json b/client/src/locales/en.json index 0428a76d4..becf25a38 100644 --- a/client/src/locales/en.json +++ b/client/src/locales/en.json @@ -184,6 +184,7 @@ "pagespeed": "Pagespeed", "infrastructure": "Infrastructure", "notifications": "Notifications", + "tags": "Tags", "checks": "Checks", "incidents": "Incidents", "statusPages": "Status pages", @@ -889,7 +890,7 @@ "table": { "headers": { "color": "Color", - "chip": "Chip" + "appearance": "Appearance" } }, "form": { From b981427d8d42de6d4588931566827f9d7b93263d Mon Sep 17 00:00:00 2001 From: Alex Holliday Date: Wed, 20 May 2026 10:49:16 -0700 Subject: [PATCH 137/245] mutate SWR on success --- client/src/Pages/Tags/create/index.tsx | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/client/src/Pages/Tags/create/index.tsx b/client/src/Pages/Tags/create/index.tsx index 9ce7ca7c4..5e29096d0 100644 --- a/client/src/Pages/Tags/create/index.tsx +++ b/client/src/Pages/Tags/create/index.tsx @@ -8,6 +8,7 @@ import { useParams } from "react-router-dom"; import { useNavigate } from "react-router-dom"; import { Controller, useForm } from "react-hook-form"; import { zodResolver } from "@hookform/resolvers/zod"; +import { mutate } from "swr"; import { useGet, usePost, usePatch } from "@/Hooks/UseApi"; import type { TagFormData } from "@/Validation/tag"; import type { Tag } from "@/Types/Tag"; @@ -43,7 +44,8 @@ const TagsCreatePage = () => { const result = isEditMode ? await patch(`/tags/${tagId}`, data) : await post("/tags", data); - if (result) { + if (result?.success) { + await mutate((key) => typeof key === "string" && key.startsWith("/tags")); navigate("/tags"); } }; From 67bba1479c56166940fd6371762201711b168d45 Mon Sep 17 00:00:00 2001 From: Alex Holliday Date: Wed, 20 May 2026 11:36:03 -0700 Subject: [PATCH 138/245] remove chip, add coloredlabel, add render options to autocomplete --- .../src/Components/design-elements/Chip.tsx | 30 ------------- .../design-elements/StatusLabel.tsx | 5 +-- client/src/Components/inputs/AutoComplete.tsx | 44 +++++++++++-------- 3 files changed, 26 insertions(+), 53 deletions(-) delete mode 100644 client/src/Components/design-elements/Chip.tsx diff --git a/client/src/Components/design-elements/Chip.tsx b/client/src/Components/design-elements/Chip.tsx deleted file mode 100644 index c5a902feb..000000000 --- a/client/src/Components/design-elements/Chip.tsx +++ /dev/null @@ -1,30 +0,0 @@ -import MuiChip from "@mui/material/Chip"; -import Typography from "@mui/material/Typography"; -import { useTheme } from "@mui/material"; -interface ChipProps { - text: string; - color?: string; -} - -export const Chip = ({ text, color }: ChipProps) => { - const theme = useTheme(); - const textColor = color - ? theme.palette.getContrastText(color) - : theme.palette.text.primary; - return ( - - {text} - - } - sx={{ - backgroundColor: color, - }} - /> - ); -}; diff --git a/client/src/Components/design-elements/StatusLabel.tsx b/client/src/Components/design-elements/StatusLabel.tsx index ee9a749c2..c3fd089d7 100644 --- a/client/src/Components/design-elements/StatusLabel.tsx +++ b/client/src/Components/design-elements/StatusLabel.tsx @@ -100,10 +100,7 @@ export const ColoredLabel = ({ text, color }: { text: string; color: string }) = return ( ["renderInput"]; fieldLabel?: string; required?: boolean; + renderOptionContent?: (option: any) => React.ReactNode; }; export const AutoCompleteInput = ({ fieldLabel, required, renderInput, + renderOptionContent, ...props }: AutoCompleteInputProps) => { const theme = useTheme(); const multiple = props.multiple; - + const { renderOption: customRenderOption, ...autocompleteProps } = props; const defaultRenderInput = (params: any) => ( option.id} renderTags={() => null} - renderOption={(props, option, { selected }) => { - const { key, ...optionProps } = props; - return ( - - { + const { key, ...optionProps } = props; + return ( + - {multiple && } - {option.name} - - - ); - }} + + {multiple && } + {renderOptionContent ? renderOptionContent(option) : option.name} + + + ); + }) + } sx={{ "&.MuiAutocomplete-root .MuiAutocomplete-input": { padding: `0 ${theme.spacing(5)}`, From 76953f9a5c954d302458668c362aceb3d11921b7 Mon Sep 17 00:00:00 2001 From: Alex Holliday Date: Wed, 20 May 2026 11:36:23 -0700 Subject: [PATCH 139/245] add tags to monitor forms --- client/src/Hooks/useMonitorForm.ts | 1 + 1 file changed, 1 insertion(+) diff --git a/client/src/Hooks/useMonitorForm.ts b/client/src/Hooks/useMonitorForm.ts index 990768b96..eeb5c138e 100644 --- a/client/src/Hooks/useMonitorForm.ts +++ b/client/src/Hooks/useMonitorForm.ts @@ -13,6 +13,7 @@ const getBaseDefaults = (data?: Monitor | null) => ({ description: data?.description || "", interval: data?.interval || 60000, notifications: data?.notifications || [], + tags: data?.tags || [], statusWindowSize: data?.statusWindowSize || 5, statusWindowThreshold: data?.statusWindowThreshold || 60, geoCheckEnabled: data?.geoCheckEnabled ?? false, From a09a8857865ee7b3938e760d55dd334797d4a8d1 Mon Sep 17 00:00:00 2001 From: Alex Holliday Date: Wed, 20 May 2026 11:36:40 -0700 Subject: [PATCH 140/245] add tags to monitor type --- client/src/Types/Monitor.ts | 1 + 1 file changed, 1 insertion(+) diff --git a/client/src/Types/Monitor.ts b/client/src/Types/Monitor.ts index 02c434fea..1e3dafb99 100644 --- a/client/src/Types/Monitor.ts +++ b/client/src/Types/Monitor.ts @@ -70,6 +70,7 @@ export interface Monitor { interval: number; uptimePercentage?: number; notifications: string[]; + tags: string[]; secret?: string; cpuAlertThreshold: number; cpuAlertCounter: number; From 7d7926fab9330709fa4cfc8aca7ccdf087c1293d Mon Sep 17 00:00:00 2001 From: Alex Holliday Date: Wed, 20 May 2026 11:36:46 -0700 Subject: [PATCH 141/245] add tags to validation --- client/src/Validation/monitor.ts | 1 + 1 file changed, 1 insertion(+) diff --git a/client/src/Validation/monitor.ts b/client/src/Validation/monitor.ts index adbe64e4a..17be250b1 100644 --- a/client/src/Validation/monitor.ts +++ b/client/src/Validation/monitor.ts @@ -14,6 +14,7 @@ const baseSchema = z.object({ description: z.string().optional(), interval: z.number().min(15000, "Interval must be at least 15 seconds"), notifications: z.array(z.string()), + tags: z.array(z.string()), statusWindowSize: z .number({ message: "Status window size is required" }) .min(1, "Status window size must be at least 1") From 3f290bc963f5a15c95c1c5fd5c2b5066f2807423 Mon Sep 17 00:00:00 2001 From: Alex Holliday Date: Wed, 20 May 2026 11:37:03 -0700 Subject: [PATCH 142/245] add tags to Uptime montitor table --- client/src/Pages/CreateMonitor/index.tsx | 76 ++++++++++++++++++- .../Components/UptimeMonitorsTable.tsx | 34 +++++++++ client/src/Pages/Uptime/Monitors/index.tsx | 4 + 3 files changed, 113 insertions(+), 1 deletion(-) diff --git a/client/src/Pages/CreateMonitor/index.tsx b/client/src/Pages/CreateMonitor/index.tsx index 235ac95bf..3aae2e5e1 100644 --- a/client/src/Pages/CreateMonitor/index.tsx +++ b/client/src/Pages/CreateMonitor/index.tsx @@ -18,7 +18,7 @@ import { Trash2 } from "lucide-react"; import { HeaderDeleteControls } from "@/Components/monitors"; import { GeoContinents } from "@/Types/GeoCheck"; -import { BasePage, ConfigBox } from "@/Components/design-elements"; +import { BasePage, ColoredLabel, ConfigBox } from "@/Components/design-elements"; import { RadioWithDescription, Button, @@ -40,6 +40,7 @@ import { DefaultPageSpeedStrategy, } from "@/Types/Monitor"; import type { Notification } from "@/Types/Notification"; +import type { Tag } from "@/Types/Tag"; import type { MonitorFormData } from "@/Validation/monitor"; interface GeneralSettingsConfig { @@ -237,6 +238,7 @@ const CreateMonitorPage = () => { const { data: notifications } = useGet("/notifications/team"); const { data: games } = useGet("/monitors/games"); + const { data: tags } = useGet("/tags/team"); const { schema, defaults } = useMonitorForm({ data: existingMonitor ?? null, @@ -923,6 +925,78 @@ const CreateMonitorPage = () => { } /> + { + const tagOptions = tags ?? []; + const selectedTags = tagOptions.filter((tag) => + (field.value ?? []).includes(tag.id) + ); + return ( + + option.name} + onChange={(_: unknown, newValue: typeof tagOptions) => { + field.onChange(newValue.map((tag) => tag.id)); + }} + isOptionEqualToValue={(option, value) => option.id === value.id} + renderOptionContent={(option) => ( + + )} + /> + {selectedTags.length > 0 && ( + + {selectedTags.map((tag, index) => ( + + +
+ { + field.onChange( + (field.value ?? []).filter((id: string) => id !== tag.id) + ); + }} + aria-label="Remove tag" + > + + + {index < selectedTags.length - 1 && } + + ))} + + )} + + ); + }} + /> + } + /> + {(watchedType === "http" || watchedType === "grpc" || watchedType === "websocket") && ( diff --git a/client/src/Pages/Uptime/Monitors/Components/UptimeMonitorsTable.tsx b/client/src/Pages/Uptime/Monitors/Components/UptimeMonitorsTable.tsx index 048998776..a9344f4ce 100644 --- a/client/src/Pages/Uptime/Monitors/Components/UptimeMonitorsTable.tsx +++ b/client/src/Pages/Uptime/Monitors/Components/UptimeMonitorsTable.tsx @@ -6,6 +6,7 @@ import { type Header, Pagination, StatusLabel, + ColoredLabel, } from "@/Components/design-elements"; import { HeatmapResponseTime, HistogramResponseTime } from "@/Components/common"; import { ActionsMenu } from "@/Components/actions-menu"; @@ -16,14 +17,18 @@ import { useTheme } from "@mui/material/styles"; import { useNavigate } from "react-router-dom"; import { usePost } from "@/Hooks/UseApi"; import { useSelector } from "react-redux"; +import useMediaQuery from "@mui/material/useMediaQuery"; import type { Monitor } from "@/Types/Monitor"; import type { ActionMenuItem } from "@/Components/actions-menu"; import type { RootState } from "@/Types/state"; import { Checkbox } from "@/Components/inputs"; +import type { Tag } from "@/Types/Tag"; +import { SPACING } from "@/Utils/Theme/constants"; interface MonitorTableProps { monitors: Monitor[]; + tags: Tag[]; refetch: () => void; setSelectedMonitor: (monitor: Monitor | null) => void; sortField: string; @@ -41,6 +46,7 @@ interface MonitorTableProps { export const MonitorTable = ({ monitors, + tags, refetch, setSelectedMonitor, sortField, @@ -60,6 +66,7 @@ export const MonitorTable = ({ const navigate = useNavigate(); const chartType = useSelector((state: RootState) => state.ui?.chartType ?? "histogram"); const { post } = usePost, Monitor>(); + const isSmall = useMediaQuery(theme.breakpoints.down("md")); const selectedSet = new Set(selectedRows); const isAllSelected = @@ -252,6 +259,33 @@ export const MonitorTable = ({ } }, }, + { + id: "tags", + content: t("pages.uptime.table.headers.responseTime"), + render: (row) => { + if (row.tags.length === 0) return "-"; + + return ( + + {row.tags.map((tag) => { + const fullTag = tags.find((t) => t.id === tag); + if (!fullTag) return null; + return ( + + ); + })} + + ); + }, + }, { id: "type", mobileLabel: t("common.table.headers.type"), diff --git a/client/src/Pages/Uptime/Monitors/index.tsx b/client/src/Pages/Uptime/Monitors/index.tsx index 85db2cb11..73dbfa511 100644 --- a/client/src/Pages/Uptime/Monitors/index.tsx +++ b/client/src/Pages/Uptime/Monitors/index.tsx @@ -14,6 +14,7 @@ import { useTranslation } from "react-i18next"; import useMediaQuery from "@mui/material/useMediaQuery"; import { useGet, useDelete } from "@/Hooks/UseApi"; import type { Monitor, MonitorType, MonitorsWithChecksResponse } from "@/Types/Monitor"; +import type { Tag } from "@/Types/Tag"; import { useState, useMemo, useCallback } from "react"; import { useSelector, useDispatch } from "react-redux"; import { setRowsPerPage } from "@/Features/UI/uiSlice.js"; @@ -103,6 +104,8 @@ const UptimeMonitorsPage = () => { { refreshInterval: 5000, keepPreviousData: true } ); + const { data: tags } = useGet("/tags/team"); + const { monitors: monitorsWithChecks, summary, @@ -218,6 +221,7 @@ const UptimeMonitorsPage = () => { Date: Wed, 20 May 2026 11:39:57 -0700 Subject: [PATCH 143/245] remove chip referecnes, update strings --- client/src/Components/design-elements/index.tsx | 1 - client/src/Pages/Tags/components/TagsTable.tsx | 2 +- .../Uptime/Monitors/Components/UptimeMonitorsTable.tsx | 2 +- client/src/locales/en.json | 7 ++++++- 4 files changed, 8 insertions(+), 4 deletions(-) diff --git a/client/src/Components/design-elements/index.tsx b/client/src/Components/design-elements/index.tsx index 0954041cd..130ccd831 100644 --- a/client/src/Components/design-elements/index.tsx +++ b/client/src/Components/design-elements/index.tsx @@ -27,4 +27,3 @@ export * from "./OfflineBanner"; export * from "./Avatar"; export * from "./StatusCodeLabel"; export * from "./StrategyBadge"; -export * from "./Chip"; diff --git a/client/src/Pages/Tags/components/TagsTable.tsx b/client/src/Pages/Tags/components/TagsTable.tsx index a84922140..00d7802ec 100644 --- a/client/src/Pages/Tags/components/TagsTable.tsx +++ b/client/src/Pages/Tags/components/TagsTable.tsx @@ -1,7 +1,7 @@ import { ActionsMenu, type ActionMenuItem } from "@/Components/actions-menu"; import Typography from "@mui/material/Typography"; import type { Header } from "@/Components/design-elements/Table"; -import { Table, Chip, ColoredLabel } from "@/Components/design-elements"; +import { Table, ColoredLabel } from "@/Components/design-elements"; import { Pagination } from "@/Components/design-elements/Table"; import { useClientPagination } from "@/Hooks/useClientPagination"; diff --git a/client/src/Pages/Uptime/Monitors/Components/UptimeMonitorsTable.tsx b/client/src/Pages/Uptime/Monitors/Components/UptimeMonitorsTable.tsx index a9344f4ce..2c026c35d 100644 --- a/client/src/Pages/Uptime/Monitors/Components/UptimeMonitorsTable.tsx +++ b/client/src/Pages/Uptime/Monitors/Components/UptimeMonitorsTable.tsx @@ -261,7 +261,7 @@ export const MonitorTable = ({ }, { id: "tags", - content: t("pages.uptime.table.headers.responseTime"), + content: t("common.table.headers.tags"), render: (row) => { if (row.tags.length === 0) return "-"; diff --git a/client/src/locales/en.json b/client/src/locales/en.json index becf25a38..8e79e23b3 100644 --- a/client/src/locales/en.json +++ b/client/src/locales/en.json @@ -131,7 +131,8 @@ "url": "Url", "interval": "Interval", "active": "Active", - "responseTime": "Response time" + "responseTime": "Response time", + "tags": "Tags" } } }, @@ -628,6 +629,10 @@ "description": "Select the notification channels you want to use", "title": "Notifications" }, + "tags": { + "description": "Select the tags you want to apply to this monitor", + "title": "Tags" + }, "type": { "description": "Select the type of check to perform", "optionDockerDescription": "Use Docker to monitor if a container is running.", From c6d82223f9523c1359e5a7386b5fadd4abcf8f95 Mon Sep 17 00:00:00 2001 From: Alex Holliday Date: Wed, 20 May 2026 12:02:32 -0700 Subject: [PATCH 144/245] add tags to filters --- .../Components/monitors/ControlsFilter.tsx | 48 ++++++++++++++++++- 1 file changed, 46 insertions(+), 2 deletions(-) diff --git a/client/src/Components/monitors/ControlsFilter.tsx b/client/src/Components/monitors/ControlsFilter.tsx index a617d454c..6cb60c935 100644 --- a/client/src/Components/monitors/ControlsFilter.tsx +++ b/client/src/Components/monitors/ControlsFilter.tsx @@ -1,9 +1,11 @@ import Stack from "@mui/material/Stack"; -import { Select, Button } from "@/Components/inputs"; +import { Select, Autocomplete, Button, TextField } from "@/Components/inputs"; +import { ColoredLabel } from "@/Components/design-elements"; import MenuItem from "@mui/material/MenuItem"; import useMediaQuery from "@mui/material/useMediaQuery"; import type { MonitorType } from "@/Types/Monitor"; +import type { Tag } from "@/Types/Tag"; import { Typography, useTheme } from "@mui/material"; import { useTranslation } from "react-i18next"; @@ -28,6 +30,9 @@ export const ControlsFilter = ({ setSelectedStatus, selectedState, setSelectedState, + tagOptions, + selectedTags, + setSelectedTags, onClearFilters, }: { showTypes?: boolean; @@ -37,13 +42,22 @@ export const ControlsFilter = ({ setSelectedStatus: React.Dispatch>; selectedState: string; setSelectedState: React.Dispatch>; + tagOptions?: Tag[]; + selectedTags?: string[]; + setSelectedTags?: React.Dispatch>; onClearFilters: () => void; }) => { const theme = useTheme(); const { t } = useTranslation(); const isSmall = useMediaQuery(theme.breakpoints.down("md")); const isFilterActive = - (selectedTypes?.length ?? 0) > 0 || selectedStatus !== "" || selectedState !== ""; + (selectedTypes?.length ?? 0) > 0 || + selectedStatus !== "" || + selectedState !== "" || + (selectedTags?.length ?? 0) > 0; + const selectedTagObjects = (tagOptions ?? []).filter((tag) => + (selectedTags ?? []).includes(tag.id) + ); return ( ))} + {tagOptions && setSelectedTags && ( + option.name} + onChange={(_: unknown, newValue: Tag[]) => { + setSelectedTags(newValue.map((tag) => tag.id)); + }} + isOptionEqualToValue={(option, value) => option.id === value.id} + renderOptionContent={(option) => ( + + )} + renderInput={(params) => ( + + )} + slotProps={{ + popper: { + sx: { width: "fit-content !important", minWidth: 280 }, + }, + }} + sx={{ minWidth: 180 }} + /> + )} {isFilterActive && ( + {isWizard && ( + + )} + {isWizard && currentStep < TOTAL_STEPS - 1 ? ( + + ) : ( + + )} Date: Mon, 25 May 2026 23:41:55 +0530 Subject: [PATCH 165/245] fix(ui): react button dom --- client/src/Pages/CreateMonitor/index.tsx | 2 ++ 1 file changed, 2 insertions(+) diff --git a/client/src/Pages/CreateMonitor/index.tsx b/client/src/Pages/CreateMonitor/index.tsx index 292c38b83..1240d27a0 100644 --- a/client/src/Pages/CreateMonitor/index.tsx +++ b/client/src/Pages/CreateMonitor/index.tsx @@ -1366,6 +1366,7 @@ const CreateMonitorPage = () => { )} {isWizard && currentStep < TOTAL_STEPS - 1 ? ( ) : ( )} - {isWizard && currentStep < TOTAL_STEPS - 1 ? ( + {showTypeSelector && currentStep < totalSteps - 1 ? ( + {isCreate && ( + + )} + {isCreate && currentStep < totalSteps - 1 ? ( + + ) : ( + + )} , step: number][] => // The fields that belong to a given wizard step. Derived from the schema so it // can't drift from the field definitions. -export const statusPageStepFieldsFor = ( - step: number -): FieldPath[] => +export const statusPageStepFieldsFor = (step: number): FieldPath[] => fieldSteps() .filter(([, fieldStep]) => fieldStep === step) .map(([name]) => name); From 60356a95fe0060f014adf1941315646e70584ae7 Mon Sep 17 00:00:00 2001 From: Alex Holliday Date: Tue, 2 Jun 2026 09:44:23 -0700 Subject: [PATCH 217/245] bump less-simple-scheduler --- server/package-lock.json | 8 ++++---- server/package.json | 2 +- 2 files changed, 5 insertions(+), 5 deletions(-) diff --git a/server/package-lock.json b/server/package-lock.json index 131589613..be543815d 100644 --- a/server/package-lock.json +++ b/server/package-lock.json @@ -31,7 +31,7 @@ "jmespath": "^0.16.0", "jsdom": "^26.1.0", "jsonwebtoken": "9.0.2", - "less-simple-scheduler": "2.2.0", + "less-simple-scheduler": "2.3.0", "mailersend": "^2.2.0", "mjml": "^5.0.0-alpha.4", "mongoose": "^8.3.3", @@ -10417,9 +10417,9 @@ "license": "MIT" }, "node_modules/less-simple-scheduler": { - "version": "2.2.0", - "resolved": "https://registry.npmjs.org/less-simple-scheduler/-/less-simple-scheduler-2.2.0.tgz", - "integrity": "sha512-0bbFohlkcWZY2wfkGfDiYEbDxkshj0RuskkL0RLUDMn1Y3WjoPqy29PAnKNtnHG+DQqn0hMjZ0e3hVFJ4k76oA==", + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/less-simple-scheduler/-/less-simple-scheduler-2.3.0.tgz", + "integrity": "sha512-HL8f8YCcNav03oNTWFQxkWmoqtEzsIXNOgLcVmOEAgFxsALukOk4bDlem6SlZmjVAsip8Y50QUu6ZsblxqQYmA==", "license": "ISC", "dependencies": { "mongodb": "7.2.0" diff --git a/server/package.json b/server/package.json index 559299061..c9b85e17c 100755 --- a/server/package.json +++ b/server/package.json @@ -53,7 +53,7 @@ "jmespath": "^0.16.0", "jsdom": "^26.1.0", "jsonwebtoken": "9.0.2", - "less-simple-scheduler": "2.2.0", + "less-simple-scheduler": "2.3.0", "mailersend": "^2.2.0", "mjml": "^5.0.0-alpha.4", "mongoose": "^8.3.3", From 8b2506cc3d8200d0481dddcf24cf4726060fdc7f Mon Sep 17 00:00:00 2001 From: Alex Holliday Date: Tue, 2 Jun 2026 09:44:49 -0700 Subject: [PATCH 218/245] add queue worker repository --- server/src/repositories/index.ts | 3 ++ .../queue-workers/IQueueWorkersRepository.ts | 7 ++++ .../MongoQueueWorkersRepository.ts | 33 +++++++++++++++++++ 3 files changed, 43 insertions(+) create mode 100644 server/src/repositories/queue-workers/IQueueWorkersRepository.ts create mode 100644 server/src/repositories/queue-workers/MongoQueueWorkersRepository.ts diff --git a/server/src/repositories/index.ts b/server/src/repositories/index.ts index f45768b02..d36d9d189 100644 --- a/server/src/repositories/index.ts +++ b/server/src/repositories/index.ts @@ -42,3 +42,6 @@ export { default as MongoGeoChecksRepository } from "@/repositories/geo-checks/M export * from "@/repositories/dlq/IDLQRepository.js"; export { default as MongoDLQRepository } from "@/repositories/dlq/MongoDLQRepository.js"; + +export * from "@/repositories/queue-workers/IQueueWorkersRepository.js"; +export { default as MongoQueueWorkersRepository } from "@/repositories/queue-workers/MongoQueueWorkersRepository.js"; diff --git a/server/src/repositories/queue-workers/IQueueWorkersRepository.ts b/server/src/repositories/queue-workers/IQueueWorkersRepository.ts new file mode 100644 index 000000000..b1f7e76b1 --- /dev/null +++ b/server/src/repositories/queue-workers/IQueueWorkersRepository.ts @@ -0,0 +1,7 @@ +import type { QueueMode, QueueWorker } from "@/types/settings.js"; + +export interface IQueueWorkersRepository { + upsert(workerId: string, mode: QueueMode): Promise; + findRecent(maxAgeMs: number): Promise; + deleteById(workerId: string): Promise; +} diff --git a/server/src/repositories/queue-workers/MongoQueueWorkersRepository.ts b/server/src/repositories/queue-workers/MongoQueueWorkersRepository.ts new file mode 100644 index 000000000..b8155ea1d --- /dev/null +++ b/server/src/repositories/queue-workers/MongoQueueWorkersRepository.ts @@ -0,0 +1,33 @@ +import type { QueueMode, QueueWorker } from "@/types/settings.js"; +import type { IQueueWorkersRepository } from "./IQueueWorkersRepository.js"; +import type { QueueWorkerDocument } from "@/db/models/QueueWorker.js"; +import { QueueWorkerModel } from "@/db/models/QueueWorker.js"; +const SERVICE_NAME = "MongoQueueWorkersRepository"; + +class MongoQueueWorkersRepository implements IQueueWorkersRepository { + static SERVICE_NAME = SERVICE_NAME; + + protected toEntity = (doc: QueueWorkerDocument): QueueWorker => { + return { + workerId: doc._id, + mode: doc.mode, + lastSeenAt: doc.lastSeenAt.getTime(), + }; + }; + + upsert = async (workerId: string, mode: QueueMode): Promise => { + await QueueWorkerModel.updateOne({ _id: workerId }, { $set: { mode, lastSeenAt: new Date() } }, { upsert: true }); + }; + + findRecent = async (maxAgeMs: number): Promise => { + const cutoff = new Date(Date.now() - maxAgeMs); + const docs = await QueueWorkerModel.find({ lastSeenAt: { $gte: cutoff } }); + return docs.map(this.toEntity); + }; + + deleteById = async (workerId: string): Promise => { + await QueueWorkerModel.deleteOne({ _id: workerId }); + }; +} + +export default MongoQueueWorkersRepository; From cffe3f57facb9fbcfeb6a210fe2ab145fe0014c4 Mon Sep 17 00:00:00 2001 From: Alex Holliday Date: Tue, 2 Jun 2026 09:45:37 -0700 Subject: [PATCH 219/245] add models --- server/src/db/models/QueueWorker.ts | 28 ++++++++++++++++++++++++++++ server/src/db/models/index.ts | 3 +++ 2 files changed, 31 insertions(+) create mode 100644 server/src/db/models/QueueWorker.ts diff --git a/server/src/db/models/QueueWorker.ts b/server/src/db/models/QueueWorker.ts new file mode 100644 index 000000000..76485f56c --- /dev/null +++ b/server/src/db/models/QueueWorker.ts @@ -0,0 +1,28 @@ +import { Schema, model } from "mongoose"; +import { QueueModes, type QueueMode } from "@/types/settings.js"; + +// Heartbeat presence record for a scheduler instance. _id is the scheduler's +// workerId (hostname:pid:uuid). The TTL index on lastSeenAt garbage-collects +// records for instances that have stopped heartbeating. +interface QueueWorkerDocument { + _id: string; + mode: QueueMode; + lastSeenAt: Date; + createdAt: Date; + updatedAt: Date; +} + +const QueueWorkerSchema = new Schema( + { + _id: { type: String, required: true }, + mode: { type: String, enum: QueueModes, required: true }, + lastSeenAt: { type: Date, default: Date.now, expires: 30 }, + }, + { timestamps: true } +); + +const QueueWorkerModel = model("QueueWorker", QueueWorkerSchema); + +export type { QueueWorkerDocument }; +export { QueueWorkerModel }; +export default QueueWorkerModel; diff --git a/server/src/db/models/index.ts b/server/src/db/models/index.ts index f0647f9eb..b825ca503 100644 --- a/server/src/db/models/index.ts +++ b/server/src/db/models/index.ts @@ -41,3 +41,6 @@ export * from "@/db/models/DLQItem.js"; export { default as DLQItemModel } from "@/db/models/DLQItem.js"; export * from "@/db/models/Tag.js"; export { default as TagModel } from "@/db/models/Tag.js"; + +export * from "@/db/models/QueueWorker.js"; +export { default as QueueWorkerModel } from "@/db/models/QueueWorker.js"; From b2526a5be92b5fa02d94507d9d27a3593ef54eec Mon Sep 17 00:00:00 2001 From: Alex Holliday Date: Tue, 2 Jun 2026 09:45:56 -0700 Subject: [PATCH 220/245] init queue worker repo --- server/src/config/services.ts | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/server/src/config/services.ts b/server/src/config/services.ts index ef6233212..514bd04d5 100644 --- a/server/src/config/services.ts +++ b/server/src/config/services.ts @@ -107,6 +107,7 @@ import { MongoTeamsRepository, MongoMaintenanceWindowsRepository, MongoSettingsRepository, + MongoQueueWorkersRepository, IMonitorsRepository, IChecksRepository, IGeoChecksRepository, @@ -248,6 +249,7 @@ export const initializeServices = async ({ const incidentsRepository = new MongoIncidentsRepository(); const teamsRepository = new MongoTeamsRepository(); const maintenanceWindowsRepository = new MongoMaintenanceWindowsRepository(); + const queueWorkersRepository = new MongoQueueWorkersRepository(); // Inject settings repository into settings service (now that DB is connected) (settingsService as SettingsService).setRepository(settingsRepository); @@ -360,7 +362,7 @@ export const initializeServices = async ({ let jobQueue: IJobQueue; switch (envSettings.queueType) { case "lessSimpleQueue": - jobQueue = await LessSimpleQueue.create(logger, queueHelper, monitorsRepository, envSettings, queueMode); + jobQueue = await LessSimpleQueue.create(logger, queueHelper, monitorsRepository, queueWorkersRepository, envSettings, queueMode); break; default: jobQueue = await SuperSimpleQueue.create(logger, queueHelper, monitorsRepository); From c9eacff71f81e5a23b9c870ae567b9852073a870 Mon Sep 17 00:00:00 2001 From: Alex Holliday Date: Tue, 2 Jun 2026 09:46:23 -0700 Subject: [PATCH 221/245] add queue worker type --- server/src/types/settings.ts | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/server/src/types/settings.ts b/server/src/types/settings.ts index d49bd53dd..4a4ff02a2 100644 --- a/server/src/types/settings.ts +++ b/server/src/types/settings.ts @@ -7,6 +7,12 @@ export type QueueType = (typeof QueueTypes)[number]; export const QueueModes = ["primary", "worker"] as const; export type QueueMode = (typeof QueueModes)[number]; +export type QueueWorker = { + workerId: string; // hostname:pid:uuid + mode: QueueMode; + lastSeenAt: number; // epoch ms of the last heartbeat +}; + export interface SettingsThresholds { cpu?: number; memory?: number; From cfed235d1f216e6364096a125d8acb392cc706f1 Mon Sep 17 00:00:00 2001 From: Alex Holliday Date: Tue, 2 Jun 2026 09:47:00 -0700 Subject: [PATCH 222/245] add worker stats --- .../infrastructure/JobQueues/IJobQueue.ts | 4 + .../JobQueues/LessSimpleQueue.ts | 74 +++++++++++++++++-- .../JobQueues/SuperSimpleQueue.ts | 3 + 3 files changed, 76 insertions(+), 5 deletions(-) diff --git a/server/src/service/infrastructure/JobQueues/IJobQueue.ts b/server/src/service/infrastructure/JobQueues/IJobQueue.ts index a97c8eb6a..371a7c74a 100644 --- a/server/src/service/infrastructure/JobQueues/IJobQueue.ts +++ b/server/src/service/infrastructure/JobQueues/IJobQueue.ts @@ -1,4 +1,5 @@ import { Monitor } from "@/types/monitor.js"; +import { QueueWorker } from "@/types/settings.js"; export type QueueJobFailure = { monitorId: string | number; @@ -16,6 +17,7 @@ export type QueueMetrics = { jobsWithFailures: QueueJobFailure[]; totalRuns: number; totalFailures: number; + workers: QueueWorker[]; }; export type QueueJobSummary = { @@ -26,6 +28,8 @@ export type QueueJobSummary = { monitorGeoInterval: number | null; monitorActive: boolean | null; active: boolean; + lockedBy: string | null; + lockedUntil: number | null; lockedAt: number | null; runCount: number; failCount: number; diff --git a/server/src/service/infrastructure/JobQueues/LessSimpleQueue.ts b/server/src/service/infrastructure/JobQueues/LessSimpleQueue.ts index 80655ab4c..cedf29901 100644 --- a/server/src/service/infrastructure/JobQueues/LessSimpleQueue.ts +++ b/server/src/service/infrastructure/JobQueues/LessSimpleQueue.ts @@ -1,4 +1,4 @@ -import { IMonitorsRepository } from "@/repositories/index.js"; +import { IMonitorsRepository, IQueueWorkersRepository } from "@/repositories/index.js"; import { ILogger } from "@/utils/logger.js"; import { MongoStore, Scheduler, IJob } from "less-simple-scheduler"; import { IQueueHelper } from "@/service/infrastructure/JobQueues/QueueHelper.js"; @@ -8,6 +8,12 @@ import { type QueueMode } from "@/types/settings.js"; import { EnvConfig } from "@/service/system/settingsService.js"; const SERVICE_NAME = "JobQueue"; +// How long after its last heartbeat a worker is still considered alive. The +// model's TTL index uses the same window to garbage-collect stale records. +// Heartbeats are driven by the scheduler's `scheduler:heartbeat` event (emitted +// every lockMs/3, so ~5s with the default lockMs), comfortably inside this TTL. +const WORKER_TTL_MS = 30_000; + // For discriminating job types type MonitorJob = Omit & { data: Monitor }; const MONITOR_TEMPLATES = new Set(["monitor-job", "geo-check-job"]); @@ -19,12 +25,21 @@ export class LessSimpleQueue implements IJobQueue { private logger: ILogger; private helper: IQueueHelper; private monitorsRepository: IMonitorsRepository; + private workersRepository: IQueueWorkersRepository; private readonly scheduler: Scheduler; + private heartbeatListener: ((workerId: string) => void) | null = null; - constructor(logger: ILogger, helper: IQueueHelper, monitorsRepository: IMonitorsRepository, scheduler: Scheduler) { + constructor( + logger: ILogger, + helper: IQueueHelper, + monitorsRepository: IMonitorsRepository, + workersRepository: IQueueWorkersRepository, + scheduler: Scheduler + ) { this.logger = logger; this.helper = helper; this.monitorsRepository = monitorsRepository; + this.workersRepository = workersRepository; this.scheduler = scheduler; this.registerListeners(); } @@ -108,19 +123,45 @@ export class LessSimpleQueue implements IJobQueue { }); }; - static async create(logger: ILogger, helper: IQueueHelper, monitorsRepository: IMonitorsRepository, envSettings: EnvConfig, queueMode: QueueMode) { + static async create( + logger: ILogger, + helper: IQueueHelper, + monitorsRepository: IMonitorsRepository, + workersRepository: IQueueWorkersRepository, + envSettings: EnvConfig, + queueMode: QueueMode + ) { const store = new MongoStore({ url: envSettings.dbConnectionString ?? "mongodb://localhost:27017/uptime_db", }); const scheduler = new Scheduler(store, { concurrency: 50, }); - const instance = new LessSimpleQueue(logger, helper, monitorsRepository, scheduler); + const instance = new LessSimpleQueue(logger, helper, monitorsRepository, workersRepository, scheduler); await instance.init(queueMode); return instance; } + private startHeartbeat = (queueMode: QueueMode) => { + const beat = ( + workerId: string // This is the heartbeat function that registers workers + ) => + this.workersRepository.upsert(workerId, queueMode).catch((error: unknown) => { + this.logger.warn({ + message: `Worker heartbeat failed: ${error instanceof Error ? error.message : String(error)}`, + service: SERVICE_NAME, + method: "startHeartbeat", + }); + }); + beat(this.scheduler.workerId); // Beat immediately to register + if (this.heartbeatListener) { + this.scheduler.off("scheduler:heartbeat", this.heartbeatListener); // remove old listeners + } + this.heartbeatListener = beat; /// Keep a reference to the listener + this.scheduler.on("scheduler:heartbeat", beat); // beat on every heartbeat + }; + init = async (queueMode: QueueMode = "primary") => { try { await this.scheduler.start(); @@ -129,6 +170,9 @@ export class LessSimpleQueue implements IJobQueue { this.scheduler.addTemplate("cleanup-orphaned", this.helper.getCleanupOrphanedJob()); this.scheduler.addTemplate("cleanup-retention-job", this.helper.getCleanupRetentionJob()); + // Register in the worker registry + this.startHeartbeat(queueMode); + // If this is a worker, return early - primary will populate queue if (queueMode === "worker") { this.logger.info({ @@ -284,11 +328,26 @@ export class LessSimpleQueue implements IJobQueue { }; shutdown = async () => { + // Stop beating before we remove this worker, so a heartbeat firing between + // the remove and the scheduler stopping can't re-insert it. + if (this.heartbeatListener) { + this.scheduler.off("scheduler:heartbeat", this.heartbeatListener); + this.heartbeatListener = null; + } + await this.workersRepository.deleteById(this.scheduler.workerId).catch((error: unknown) => { + this.logger.warn({ + message: `Failed to deregister worker on shutdown: ${error instanceof Error ? error.message : String(error)}`, + service: SERVICE_NAME, + method: "shutdown", + }); + }); this.scheduler.stop(); }; getMetrics = async () => { const jobs = await this.scheduler.getJobs(); + const now = Date.now(); + const metrics = jobs.reduce( (acc, job) => { const runCount = job.runCount ?? 0; @@ -302,7 +361,8 @@ export class LessSimpleQueue implements IJobQueue { acc.failingJobs++; } - if (job.lockedAt) { + // A job currently held by a live lock is in flight on some worker + if (job.lockedBy && job.lockedUntil && job.lockedUntil > now) { acc.activeJobs++; } @@ -326,8 +386,10 @@ export class LessSimpleQueue implements IJobQueue { jobsWithFailures: [], totalRuns: 0, totalFailures: 0, + workers: [], } ); + metrics.workers = await this.workersRepository.findRecent(WORKER_TTL_MS); return metrics; }; @@ -345,6 +407,8 @@ export class LessSimpleQueue implements IJobQueue { monitorActive: monitor?.isActive ?? null, active: job.active, repeat: job.repeat ?? null, + lockedBy: job.lockedBy ?? null, + lockedUntil: job.lockedUntil ?? null, lockedAt: job.lockedAt ?? null, runCount: job.runCount ?? 0, failCount: job.failCount ?? 0, diff --git a/server/src/service/infrastructure/JobQueues/SuperSimpleQueue.ts b/server/src/service/infrastructure/JobQueues/SuperSimpleQueue.ts index df265cf71..353a7ec7c 100644 --- a/server/src/service/infrastructure/JobQueues/SuperSimpleQueue.ts +++ b/server/src/service/infrastructure/JobQueues/SuperSimpleQueue.ts @@ -354,6 +354,7 @@ export class SuperSimpleQueue implements ISuperSimpleQueue { }>, totalRuns: 0, totalFailures: 0, + workers: [], } ); return metrics; @@ -371,6 +372,8 @@ export class SuperSimpleQueue implements ISuperSimpleQueue { monitorActive: job.data?.isActive ?? null, active: job.active, repeat: job.repeat ?? null, + lockedBy: null, + lockedUntil: null, lockedAt: job.lockedAt ?? null, runCount: job.runCount ?? 0, failCount: job.failCount ?? 0, From 599c5995881dd028422a74d8c2d6e7374af61851 Mon Sep 17 00:00:00 2001 From: Alex Holliday Date: Tue, 2 Jun 2026 09:47:13 -0700 Subject: [PATCH 223/245] add tests --- .../unit/services/lessSimpleQueue.test.ts | 150 +++++++++++++++++- .../unit/services/superSimpleQueue.test.ts | 4 +- 2 files changed, 147 insertions(+), 7 deletions(-) diff --git a/server/test/unit/services/lessSimpleQueue.test.ts b/server/test/unit/services/lessSimpleQueue.test.ts index b5a79445e..1b455ef1b 100644 --- a/server/test/unit/services/lessSimpleQueue.test.ts +++ b/server/test/unit/services/lessSimpleQueue.test.ts @@ -12,6 +12,9 @@ const createScheduler = () => { listeners[event] = listeners[event] || []; listeners[event].push(cb); }), + off: jest.fn((event: string, cb: Function) => { + listeners[event] = (listeners[event] || []).filter((fn) => fn !== cb); + }), emit: (event: string, ...args: unknown[]) => { (listeners[event] || []).forEach((cb) => cb(...args)); }, @@ -51,18 +54,34 @@ const createMonitorsRepo = () => ({ findAll: jest.fn().mockResolvedValue([]), }); +const createWorkersRepo = () => ({ + upsert: jest.fn().mockResolvedValue(undefined), + findRecent: jest.fn().mockResolvedValue([]), + deleteById: jest.fn().mockResolvedValue(undefined), +}); + const createQueue = (overrides?: Record) => { const logger = createMockLogger(); const helper = createQueueHelper(); const monitorsRepository = createMonitorsRepo(); + const workersRepository = createWorkersRepo(); const scheduler = createScheduler(); - const defaults = { logger, helper, monitorsRepository, scheduler, ...overrides }; + const defaults = { logger, helper, monitorsRepository, workersRepository, scheduler, ...overrides }; - const queue = new LessSimpleQueue(defaults.logger as any, defaults.helper as any, defaults.monitorsRepository as any, defaults.scheduler as any); + const queue = new LessSimpleQueue( + defaults.logger as any, + defaults.helper as any, + defaults.monitorsRepository as any, + defaults.workersRepository as any, + defaults.scheduler as any + ); return { queue, ...defaults }; }; +// A lockedUntil far enough ahead that it is always > Date.now() at assertion time. +const FAR_FUTURE = 4_000_000_000_000; // year 2096 + const makeMonitor = (overrides?: Partial): Monitor => ({ id: "mon-1", @@ -112,7 +131,8 @@ describe("LessSimpleQueue", () => { (monitorsRepository.findAll as jest.Mock).mockResolvedValue(monitors); await queue.init(); - jest.runAllTimers(); + // advance past the <100ms seed offsets + jest.advanceTimersByTime(100); expect(scheduler.addJob).toHaveBeenCalledWith(expect.objectContaining({ id: "m1", template: "monitor-job" })); expect(scheduler.addJob).toHaveBeenCalledWith(expect.objectContaining({ id: "m2", template: "monitor-job" })); @@ -454,7 +474,7 @@ describe("LessSimpleQueue", () => { it("returns empty metrics when no jobs", async () => { const { queue } = createQueue(); const metrics = await queue.getMetrics(); - expect(metrics).toEqual({ jobs: 0, activeJobs: 0, failingJobs: 0, jobsWithFailures: [], totalRuns: 0, totalFailures: 0 }); + expect(metrics).toEqual({ jobs: 0, activeJobs: 0, failingJobs: 0, jobsWithFailures: [], totalRuns: 0, totalFailures: 0, workers: [] }); }); it("aggregates metrics from jobs", async () => { @@ -478,6 +498,9 @@ describe("LessSimpleQueue", () => { failCount: 0, lastFailedAt: 0, lastFinishedAt: 50, + // live lock held by a worker + lockedBy: "host-1:1234:abcd", + lockedUntil: FAR_FUTURE, lockedAt: 999, data: { url: "http://b.com", type: "ping" }, }, @@ -526,6 +549,56 @@ describe("LessSimpleQueue", () => { }); }); + // ── getMetrics: activeJobs (in-flight) ─────────────────────────────────────── + + describe("getMetrics - activeJobs", () => { + it("counts jobs held by a live lock as active", async () => { + const { queue, scheduler } = createQueue(); + (scheduler.getJobs as jest.Mock).mockResolvedValue([ + { id: "m1", template: "monitor-job", lockedBy: "worker-a", lockedUntil: FAR_FUTURE, lockedAt: 100, data: { url: "a" } }, + { id: "m2", template: "monitor-job", lockedBy: "worker-b", lockedUntil: FAR_FUTURE, lockedAt: 250, data: { url: "b" } }, + ]); + const metrics = await queue.getMetrics(); + expect(metrics.activeJobs).toBe(2); + }); + + it("does not count unlocked jobs (lockedBy null)", async () => { + const { queue, scheduler } = createQueue(); + (scheduler.getJobs as jest.Mock).mockResolvedValue([ + { id: "m1", template: "monitor-job", lockedBy: null, lockedUntil: null, lockedAt: null, data: { url: "a" } }, + ]); + const metrics = await queue.getMetrics(); + expect(metrics.activeJobs).toBe(0); + }); + + it("does not count jobs whose lock has expired (lockedUntil <= now)", async () => { + const { queue, scheduler } = createQueue(); + (scheduler.getJobs as jest.Mock).mockResolvedValue([ + { id: "m1", template: "monitor-job", lockedBy: "worker-a", lockedUntil: 1, lockedAt: 1, data: { url: "a" } }, + ]); + const metrics = await queue.getMetrics(); + expect(metrics.activeJobs).toBe(0); + }); + }); + + // ── getMetrics: worker registry ────────────────────────────────────────────── + + describe("getMetrics - worker registry", () => { + it("returns the alive workers from the registry", async () => { + const { queue, workersRepository } = createQueue(); + const alive = [ + { workerId: "host:1:aaa", mode: "primary", lastSeenAt: 1000 }, + { workerId: "host:2:bbb", mode: "worker", lastSeenAt: 2000 }, + ]; + (workersRepository.findRecent as jest.Mock).mockResolvedValue(alive); + + const metrics = await queue.getMetrics(); + + expect(workersRepository.findRecent).toHaveBeenCalledWith(30000); + expect(metrics.workers).toEqual(alive); + }); + }); + // ── getJobs ──────────────────────────────────────────────────────────────── describe("getJobs", () => { @@ -559,6 +632,8 @@ describe("LessSimpleQueue", () => { monitorActive: true, active: true, repeat: 60000, + lockedBy: null, + lockedUntil: null, lockedAt: null, runCount: 5, failCount: 1, @@ -628,8 +703,16 @@ describe("LessSimpleQueue", () => { const logger = createMockLogger(); const helper = createQueueHelper(); const monitorsRepository = createMonitorsRepo(); + const workersRepository = createWorkersRepo(); - const instance = await LessSimpleQueue.create(logger as any, helper as any, monitorsRepository as any, envSettings, "primary"); + const instance = await LessSimpleQueue.create( + logger as any, + helper as any, + monitorsRepository as any, + workersRepository as any, + envSettings, + "primary" + ); expect(MockMongoStore).toHaveBeenCalledWith(expect.objectContaining({ url: "mongodb://localhost:27017/test_db" })); expect(MockScheduler).toHaveBeenCalled(); @@ -642,11 +725,66 @@ describe("LessSimpleQueue", () => { const logger = createMockLogger(); const helper = createQueueHelper(); const monitorsRepository = createMonitorsRepo(); + const workersRepository = createWorkersRepo(); - const instance = await LessSimpleQueue.create(logger as any, helper as any, monitorsRepository as any, envSettings, "worker"); + const instance = await LessSimpleQueue.create( + logger as any, + helper as any, + monitorsRepository as any, + workersRepository as any, + envSettings, + "worker" + ); expect(instance).toBeInstanceOf(LessSimpleQueue); expect(monitorsRepository.findAll).not.toHaveBeenCalled(); }); }); + + // ── worker heartbeat / deregistration ──────────────────────────────────────── + + describe("worker heartbeat", () => { + it("writes a heartbeat on init with the workerId and mode (primary)", async () => { + const { queue, workersRepository } = createQueue(); + await queue.init("primary"); + expect(workersRepository.upsert).toHaveBeenCalledWith("host-1:1234:abcd", "primary"); + }); + + it("writes a heartbeat on init with the workerId and mode (worker)", async () => { + const { queue, workersRepository } = createQueue(); + await queue.init("worker"); + expect(workersRepository.upsert).toHaveBeenCalledWith("host-1:1234:abcd", "worker"); + }); + + it("beats again on each scheduler:heartbeat event", async () => { + const { queue, scheduler, workersRepository } = createQueue(); + await queue.init("worker"); + (workersRepository.upsert as jest.Mock).mockClear(); + + scheduler.emit("scheduler:heartbeat", "host-1:1234:abcd"); + scheduler.emit("scheduler:heartbeat", "host-1:1234:abcd"); + + expect(workersRepository.upsert).toHaveBeenCalledTimes(2); + expect(workersRepository.upsert).toHaveBeenCalledWith("host-1:1234:abcd", "worker"); + }); + + it("stops beating after shutdown", async () => { + const { queue, scheduler, workersRepository } = createQueue(); + await queue.init("worker"); + await queue.shutdown(); + (workersRepository.upsert as jest.Mock).mockClear(); + + scheduler.emit("scheduler:heartbeat", "host-1:1234:abcd"); + + expect(workersRepository.upsert).not.toHaveBeenCalled(); + }); + + it("deregisters the worker and stops the scheduler on shutdown", async () => { + const { queue, scheduler, workersRepository } = createQueue(); + await queue.init("worker"); + await queue.shutdown(); + expect(workersRepository.deleteById).toHaveBeenCalledWith("host-1:1234:abcd"); + expect(scheduler.stop).toHaveBeenCalled(); + }); + }); }); diff --git a/server/test/unit/services/superSimpleQueue.test.ts b/server/test/unit/services/superSimpleQueue.test.ts index 660d0b8f7..970706199 100644 --- a/server/test/unit/services/superSimpleQueue.test.ts +++ b/server/test/unit/services/superSimpleQueue.test.ts @@ -489,7 +489,7 @@ describe("SuperSimpleQueue", () => { it("returns empty metrics when no jobs", async () => { const { queue } = createQueue(); const metrics = await queue.getMetrics(); - expect(metrics).toEqual({ jobs: 0, activeJobs: 0, failingJobs: 0, jobsWithFailures: [], totalRuns: 0, totalFailures: 0 }); + expect(metrics).toEqual({ jobs: 0, activeJobs: 0, failingJobs: 0, jobsWithFailures: [], totalRuns: 0, totalFailures: 0, workers: [] }); }); it("aggregates metrics from jobs", async () => { @@ -580,6 +580,8 @@ describe("SuperSimpleQueue", () => { monitorActive: true, active: true, repeat: 60000, + lockedBy: null, + lockedUntil: null, lockedAt: null, runCount: 5, failCount: 1, From caf8f22fec0523a06cf9b5fefed4cb902085a1c4 Mon Sep 17 00:00:00 2001 From: Alex Holliday Date: Tue, 2 Jun 2026 10:59:14 -0700 Subject: [PATCH 224/245] add worker table --- client/src/Pages/Logs/TabQueue.tsx | 34 ++++++- .../src/Pages/Logs/components/TableJobs.tsx | 21 +++++ .../Pages/Logs/components/TableWorkers.tsx | 94 +++++++++++++++++++ client/src/Pages/Logs/index.tsx | 2 +- client/src/Types/Queue.ts | 22 ++++- client/src/locales/en.json | 8 ++ 6 files changed, 174 insertions(+), 7 deletions(-) create mode 100644 client/src/Pages/Logs/components/TableWorkers.tsx diff --git a/client/src/Pages/Logs/TabQueue.tsx b/client/src/Pages/Logs/TabQueue.tsx index a39250be0..bb3aac368 100644 --- a/client/src/Pages/Logs/TabQueue.tsx +++ b/client/src/Pages/Logs/TabQueue.tsx @@ -2,7 +2,7 @@ import Stack from "@mui/material/Stack"; import Typography from "@mui/material/Typography"; import Box from "@mui/material/Box"; import { TableJobs, TableFailedJobs } from "./components/TableJobs"; - +import { TableWorkers } from "./components/TableWorkers"; import { useTheme } from "@mui/material"; import { useTranslation, Trans } from "react-i18next"; import { useGet, usePost } from "@/Hooks/UseApi"; @@ -25,6 +25,10 @@ export const TabQueue = () => { const { post, loading: isFlushing } = usePost(); const jobs = queueData?.jobs ?? []; + const workers = (queueData?.metrics?.workers ?? []).map((worker, idx) => ({ + ...worker, + id: idx, + })); const metrics = queueData?.metrics ?? null; const hasNeverRun = !isLoading && metrics !== null && (metrics.totalRuns ?? 0) === 0; const noQueueData = !isLoading && metrics === null && jobs.length === 0; @@ -46,6 +50,34 @@ export const TabQueue = () => { return ( + + + + {t("pages.logs.workers")} + + + + ), + }} + /> + + + + { ); }, }, + { + id: "lockedBy", + content: t("pages.logs.table.headers.lockedBy"), + render: (row) => { + const lockedBy = row.lockedBy; + if (!lockedBy) { + return -; + } + + const workerId = lockedBy.split(":")[2]; + const shortWorkerId = workerId.slice(workerId.length - 8); + return ( + + {`...${shortWorkerId}`} + + ); + }, + }, ]; return ( diff --git a/client/src/Pages/Logs/components/TableWorkers.tsx b/client/src/Pages/Logs/components/TableWorkers.tsx new file mode 100644 index 000000000..5d1f44860 --- /dev/null +++ b/client/src/Pages/Logs/components/TableWorkers.tsx @@ -0,0 +1,94 @@ +import { Table } from "@/Components/design-elements"; +import { Typography, useTheme } from "@mui/material"; +import { formatTimestamp } from "@/Utils/TimeUtils"; + +import { useTranslation } from "react-i18next"; +import type { QueueWorker } from "@/Types/Queue"; +import type { Header } from "@/Components/design-elements"; + +type QueueWorkerWithId = QueueWorker & { id: number }; + +interface TableWorkersProps { + queueWorkers: QueueWorkerWithId[]; +} + +export const TableWorkers = ({ queueWorkers }: TableWorkersProps) => { + const { t } = useTranslation(); + const theme = useTheme(); + + const cellSx = { + whiteSpace: "nowrap" as const, + overflow: "hidden", + textOverflow: "ellipsis", + maxWidth: 220, + }; + + const headers: Header[] = [ + { + id: "host", + content: t("pages.logs.table.headers.host"), + render: (row) => { + const host = row.workerId.split(":")[0]; + return ( + + {host} + + ); + }, + }, + { + id: "processId", + content: t("pages.logs.table.headers.processId"), + render: (row) => { + const processId = row.workerId.split(":")[1]; + return ( + + {processId} + + ); + }, + }, + { + id: "id", + content: t("pages.logs.table.headers.workerId"), + render: (row) => { + const workerId = row.workerId.split(":")[2]; + const shortWorkerId = workerId.slice(workerId.length - 8); + return ( + + {`...${shortWorkerId}`} + + ); + }, + }, + { + id: "mode", + content: t("pages.logs.table.headers.workerMode"), + render: (row) => {row.mode}, + }, + { + id: "lastSeen", + content: t("pages.logs.table.headers.lastSeenAt"), + render: (row) => ( + {formatTimestamp(row.lastSeenAt)} + ), + }, + ]; + + return ( +
+ ); +}; diff --git a/client/src/Pages/Logs/index.tsx b/client/src/Pages/Logs/index.tsx index b21e331c5..9d71e3253 100644 --- a/client/src/Pages/Logs/index.tsx +++ b/client/src/Pages/Logs/index.tsx @@ -8,7 +8,7 @@ import { useTranslation } from "react-i18next"; const LogsPage = () => { const { t } = useTranslation(); - const [activeTab, setActiveTab] = useState(0); + const [activeTab, setActiveTab] = useState(1); return ( Highlighted rows are jobs currently being processed by a worker.", "failedJobs": "Failed jobs", @@ -1000,6 +1002,11 @@ }, "table": { "headers": { + "host": "Host", + "processId": "Process ID", + "workerId": "Worker ID", + "workerMode": "Worker mode", + "lastSeenAt": "Last seen at", "state": "State", "timestamp": "Timestamp", "level": "Level", @@ -1010,6 +1017,7 @@ "failCount": "Count", "lastRunAt": "Last run at", "lockedAt": "Locked at", + "lockedBy": "Locked by", "lastFinishedAt": "Last finished at", "lastRunTook": "Last run took", "lastFailedAt": "Last failed", From b9fd29b906b70272ff2413c15ee62320af27030a Mon Sep 17 00:00:00 2001 From: Alex Holliday Date: Tue, 2 Jun 2026 11:11:24 -0700 Subject: [PATCH 225/245] add compliant worker ID to super simple queue --- .../infrastructure/JobQueues/SuperSimpleQueue.ts | 15 +++++++++++++-- 1 file changed, 13 insertions(+), 2 deletions(-) diff --git a/server/src/service/infrastructure/JobQueues/SuperSimpleQueue.ts b/server/src/service/infrastructure/JobQueues/SuperSimpleQueue.ts index 353a7ec7c..c96448728 100644 --- a/server/src/service/infrastructure/JobQueues/SuperSimpleQueue.ts +++ b/server/src/service/infrastructure/JobQueues/SuperSimpleQueue.ts @@ -3,6 +3,9 @@ import { ILogger } from "@/utils/logger.js"; import Scheduler from "super-simple-scheduler"; import { IQueueHelper } from "@/service/infrastructure/JobQueues/QueueHelper.js"; import { Monitor, MonitorType, supportsGeoCheck } from "@/types/monitor.js"; +import { hostname } from "node:os"; +import { randomUUID } from "node:crypto"; + const SERVICE_NAME = "JobQueue"; type QueueJobFailure = { @@ -61,6 +64,7 @@ export class SuperSimpleQueue implements ISuperSimpleQueue { private logger: ILogger; private helper: IQueueHelper; + private workerId: string; private monitorsRepository: IMonitorsRepository; private readonly scheduler: Scheduler; @@ -70,6 +74,7 @@ export class SuperSimpleQueue implements ISuperSimpleQueue { this.monitorsRepository = monitorsRepository; this.scheduler = scheduler; this.registerListeners(); + this.workerId = `${hostname()}:${process.pid}:${randomUUID()}`; } get serviceName() { @@ -354,7 +359,13 @@ export class SuperSimpleQueue implements ISuperSimpleQueue { }>, totalRuns: 0, totalFailures: 0, - workers: [], + workers: [ + { + workerId: this.workerId, + mode: "primary", + lastSeenAt: Date.now(), + }, + ], } ); return metrics; @@ -372,7 +383,7 @@ export class SuperSimpleQueue implements ISuperSimpleQueue { monitorActive: job.data?.isActive ?? null, active: job.active, repeat: job.repeat ?? null, - lockedBy: null, + lockedBy: job.lockedAt ? this.workerId : null, lockedUntil: null, lockedAt: job.lockedAt ?? null, runCount: job.runCount ?? 0, From 2b6734bb783446ccf552717a2cd0af4737e682f6 Mon Sep 17 00:00:00 2001 From: Alex Holliday Date: Tue, 2 Jun 2026 11:25:21 -0700 Subject: [PATCH 226/245] update tests --- .../unit/services/superSimpleQueue.test.ts | 19 +++++++++++++++++-- 1 file changed, 17 insertions(+), 2 deletions(-) diff --git a/server/test/unit/services/superSimpleQueue.test.ts b/server/test/unit/services/superSimpleQueue.test.ts index 970706199..3c2506774 100644 --- a/server/test/unit/services/superSimpleQueue.test.ts +++ b/server/test/unit/services/superSimpleQueue.test.ts @@ -486,10 +486,25 @@ describe("SuperSimpleQueue", () => { // ── getMetrics ─────────────────────────────────────────────────────────── describe("getMetrics", () => { - it("returns empty metrics when no jobs", async () => { + it("returns empty job metrics plus this process as the sole worker when no jobs", async () => { const { queue } = createQueue(); const metrics = await queue.getMetrics(); - expect(metrics).toEqual({ jobs: 0, activeJobs: 0, failingJobs: 0, jobsWithFailures: [], totalRuns: 0, totalFailures: 0, workers: [] }); + expect(metrics).toEqual({ + jobs: 0, + activeJobs: 0, + failingJobs: 0, + jobsWithFailures: [], + totalRuns: 0, + totalFailures: 0, + workers: [ + { + // hostname:pid:uuid, matching less-simple-scheduler's workerId format + workerId: expect.stringMatching(/^.+:\d+:[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/), + mode: "primary", + lastSeenAt: expect.any(Number), + }, + ], + }); }); it("aggregates metrics from jobs", async () => { From dfae6c8abdf1669a66f8bc3105c6673dd4d9b2ac Mon Sep 17 00:00:00 2001 From: Alex Holliday Date: Tue, 2 Jun 2026 11:26:10 -0700 Subject: [PATCH 227/245] type metrics for super simple queue --- .../JobQueues/SuperSimpleQueue.ts | 22 ++++++++++++++++++- 1 file changed, 21 insertions(+), 1 deletion(-) diff --git a/server/src/service/infrastructure/JobQueues/SuperSimpleQueue.ts b/server/src/service/infrastructure/JobQueues/SuperSimpleQueue.ts index c96448728..ee542d2be 100644 --- a/server/src/service/infrastructure/JobQueues/SuperSimpleQueue.ts +++ b/server/src/service/infrastructure/JobQueues/SuperSimpleQueue.ts @@ -5,6 +5,7 @@ import { IQueueHelper } from "@/service/infrastructure/JobQueues/QueueHelper.js" import { Monitor, MonitorType, supportsGeoCheck } from "@/types/monitor.js"; import { hostname } from "node:os"; import { randomUUID } from "node:crypto"; +import { QueueMode } from "@/types/settings.js"; const SERVICE_NAME = "JobQueue"; @@ -316,7 +317,26 @@ export class SuperSimpleQueue implements ISuperSimpleQueue { getMetrics = async () => { const jobs = await this.scheduler.getJobs(); - const metrics = jobs.reduce( + const metrics: { + jobs: number; + activeJobs: number; + failingJobs: number; + jobsWithFailures: Array<{ + monitorId: string | number; + monitorUrl: string; + monitorType: MonitorType; + failedAt: number | null; + failCount: number; + failReason: string | null; + }>; + totalRuns: number; + totalFailures: number; + workers: Array<{ + workerId: string; + mode: QueueMode; + lastSeenAt: number; + }>; + } = jobs.reduce( (acc, job) => { const runCount = job.runCount ?? 0; const failCount = job.failCount ?? 0; From f5a4a038983080fd2fc33ab77b3ecb2a4cfb7e38 Mon Sep 17 00:00:00 2001 From: Alex Holliday Date: Tue, 2 Jun 2026 11:31:11 -0700 Subject: [PATCH 228/245] reorder stop --- .../infrastructure/JobQueues/LessSimpleQueue.ts | 17 +++++++++++++---- 1 file changed, 13 insertions(+), 4 deletions(-) diff --git a/server/src/service/infrastructure/JobQueues/LessSimpleQueue.ts b/server/src/service/infrastructure/JobQueues/LessSimpleQueue.ts index cedf29901..ae0b5df72 100644 --- a/server/src/service/infrastructure/JobQueues/LessSimpleQueue.ts +++ b/server/src/service/infrastructure/JobQueues/LessSimpleQueue.ts @@ -27,6 +27,7 @@ export class LessSimpleQueue implements IJobQueue { private monitorsRepository: IMonitorsRepository; private workersRepository: IQueueWorkersRepository; private readonly scheduler: Scheduler; + private queueMode: QueueMode; private heartbeatListener: ((workerId: string) => void) | null = null; constructor( @@ -34,13 +35,15 @@ export class LessSimpleQueue implements IJobQueue { helper: IQueueHelper, monitorsRepository: IMonitorsRepository, workersRepository: IQueueWorkersRepository, - scheduler: Scheduler + scheduler: Scheduler, + queueMode: QueueMode ) { this.logger = logger; this.helper = helper; this.monitorsRepository = monitorsRepository; this.workersRepository = workersRepository; this.scheduler = scheduler; + this.queueMode = queueMode; this.registerListeners(); } @@ -137,7 +140,7 @@ export class LessSimpleQueue implements IJobQueue { const scheduler = new Scheduler(store, { concurrency: 50, }); - const instance = new LessSimpleQueue(logger, helper, monitorsRepository, workersRepository, scheduler); + const instance = new LessSimpleQueue(logger, helper, monitorsRepository, workersRepository, scheduler, queueMode); await instance.init(queueMode); return instance; @@ -422,8 +425,14 @@ export class LessSimpleQueue implements IJobQueue { }; flushQueues = async () => { - const stopRes = await this.scheduler.stop(); - const flushRes = await this.scheduler.flushJobs(); + if (this.queueMode !== "primary") { + // Don't allow workers to flush, though this isn't currently reachable since workers have no API endpoints + this.logger.warn({ message: "Ignoring flush on non-primary worker", service: SERVICE_NAME, method: "flushQueues" }); + return { success: false }; + } + + const flushRes = await this.scheduler.flushJobs(); // Less Simple Queue must flush before stopping as it needs DB + const stopRes = await this.scheduler.stop(); // Destructive, other workers will abort their jobs const initRes = await this.init(); return { success: Boolean(stopRes && flushRes && initRes), From b4970d321d27b23565e1e92019258d9127e12939 Mon Sep 17 00:00:00 2001 From: Alex Holliday Date: Tue, 2 Jun 2026 11:31:22 -0700 Subject: [PATCH 229/245] update tests --- .../unit/services/lessSimpleQueue.test.ts | 28 +++++++++++++++++-- 1 file changed, 26 insertions(+), 2 deletions(-) diff --git a/server/test/unit/services/lessSimpleQueue.test.ts b/server/test/unit/services/lessSimpleQueue.test.ts index 1b455ef1b..19b68dee1 100644 --- a/server/test/unit/services/lessSimpleQueue.test.ts +++ b/server/test/unit/services/lessSimpleQueue.test.ts @@ -67,14 +67,15 @@ const createQueue = (overrides?: Record) => { const workersRepository = createWorkersRepo(); const scheduler = createScheduler(); - const defaults = { logger, helper, monitorsRepository, workersRepository, scheduler, ...overrides }; + const defaults = { logger, helper, monitorsRepository, workersRepository, scheduler, queueMode: "primary", ...overrides }; const queue = new LessSimpleQueue( defaults.logger as any, defaults.helper as any, defaults.monitorsRepository as any, defaults.workersRepository as any, - defaults.scheduler as any + defaults.scheduler as any, + defaults.queueMode as any ); return { queue, ...defaults }; }; @@ -686,6 +687,29 @@ describe("LessSimpleQueue", () => { expect(result.success).toBe(true); }); + it("flushes before stopping, so the store is still connected for removeAll", async () => { + const { queue, scheduler } = createQueue(); + const order: string[] = []; + (scheduler.flushJobs as jest.Mock).mockImplementation(async () => { + order.push("flush"); + return true; + }); + (scheduler.stop as jest.Mock).mockImplementation(async () => { + order.push("stop"); + return true; + }); + await queue.flushQueues(); + expect(order).toEqual(["flush", "stop"]); + }); + + it("refuses to flush on a non-primary worker without touching the scheduler", async () => { + const { queue, scheduler } = createQueue({ queueMode: "worker" }); + const result = await queue.flushQueues(); + expect(result.success).toBe(false); + expect(scheduler.flushJobs).not.toHaveBeenCalled(); + expect(scheduler.stop).not.toHaveBeenCalled(); + }); + it("returns false when flush fails", async () => { const { queue, scheduler } = createQueue(); (scheduler.flushJobs as jest.Mock).mockResolvedValue(false); From 01c3870b22d22a9d208d942cab8e3103ba43218f Mon Sep 17 00:00:00 2001 From: Br0wnHammer Date: Wed, 3 Jun 2026 20:15:01 +0530 Subject: [PATCH 230/245] feat(client): enable step wizard on infrastructure and pagespeed create pages --- client/src/Pages/CreateMonitor/index.tsx | 13 ++++++++----- 1 file changed, 8 insertions(+), 5 deletions(-) diff --git a/client/src/Pages/CreateMonitor/index.tsx b/client/src/Pages/CreateMonitor/index.tsx index f34876359..98510b57c 100644 --- a/client/src/Pages/CreateMonitor/index.tsx +++ b/client/src/Pages/CreateMonitor/index.tsx @@ -234,6 +234,9 @@ const CreateMonitorPage = () => { }, [location.pathname]); const showTypeSelector = pageType === "uptime" && !isEditMode; + // The multi-step wizard applies to every create flow; only the monitor-type + // radio is uptime-specific. Edit mode keeps the flat form. + const isWizard = !isEditMode; const defaultType: MonitorType = pageType === "pagespeed" ? "pagespeed" @@ -267,7 +270,7 @@ const CreateMonitorPage = () => { // Multi-step wizard, uptime create only (showTypeSelector). Other flows // keep the flat form, where showStep() is always true. const [currentStep, setCurrentStep] = useState(0); - const showStep = (step: number) => !showTypeSelector || currentStep === step; + const showStep = (step: number) => !isWizard || currentStep === step; const watchedType = watch("type") as MonitorType; const watchedUseAdvancedMatching = watch("useAdvancedMatching") as boolean; @@ -362,7 +365,7 @@ const CreateMonitorPage = () => { refetch={refetchMonitor} onDelete={handleDeleteClick} /> - {showTypeSelector && ( + {isWizard && ( { - {showTypeSelector && ( + {isWizard && ( )} - {showTypeSelector && currentStep < totalSteps - 1 ? ( + {isWizard && currentStep < totalSteps - 1 ? (
({ - ...(row.lockedAt && { - "& td": { backgroundColor: theme.palette.rowStatus.running }, - }), - ...(row.monitorActive === false && { - "& td": { backgroundColor: theme.palette.rowStatus.paused }, - }), - })} - /> + <> +
({ + ...(row.lockedAt && { + "& td": { backgroundColor: theme.palette.rowStatus.running }, + }), + ...(row.monitorActive === false && { + "& td": { backgroundColor: theme.palette.rowStatus.paused }, + }), + })} + /> + + ); }; diff --git a/client/src/Types/Queue.ts b/client/src/Types/Queue.ts index d7c1d15e2..5a9ae7cdf 100644 --- a/client/src/Types/Queue.ts +++ b/client/src/Types/Queue.ts @@ -51,5 +51,6 @@ export type QueueJobSummary = { export interface QueueData { jobs: QueueJobSummary[]; + count: number; metrics: QueueMetrics; } From 3c85b36b1bd9e8efc7c9777ea8f4d30e50888460 Mon Sep 17 00:00:00 2001 From: Alex Holliday Date: Wed, 3 Jun 2026 13:30:03 -0700 Subject: [PATCH 243/245] add pagnation validation to queue query --- server/src/validation/queueValidation.ts | 6 ++++++ 1 file changed, 6 insertions(+) create mode 100644 server/src/validation/queueValidation.ts diff --git a/server/src/validation/queueValidation.ts b/server/src/validation/queueValidation.ts new file mode 100644 index 000000000..61c552751 --- /dev/null +++ b/server/src/validation/queueValidation.ts @@ -0,0 +1,6 @@ +import { z } from "zod"; + +export const getQueueJobsQueryValidation = z.object({ + page: z.coerce.number().int().min(0).optional(), + rowsPerPage: z.coerce.number().int().min(1).max(100).optional(), +}); From 7872b18108d534b8ad27b700e475feaff3dffdd8 Mon Sep 17 00:00:00 2001 From: Br0wnHammer Date: Thu, 4 Jun 2026 11:09:09 +0530 Subject: [PATCH 244/245] refactor(client): inline isEditMode instead of shadowing it with isWizard --- client/src/Pages/CreateMonitor/index.tsx | 17 +++++++---------- 1 file changed, 7 insertions(+), 10 deletions(-) diff --git a/client/src/Pages/CreateMonitor/index.tsx b/client/src/Pages/CreateMonitor/index.tsx index 98510b57c..94bed9355 100644 --- a/client/src/Pages/CreateMonitor/index.tsx +++ b/client/src/Pages/CreateMonitor/index.tsx @@ -234,9 +234,6 @@ const CreateMonitorPage = () => { }, [location.pathname]); const showTypeSelector = pageType === "uptime" && !isEditMode; - // The multi-step wizard applies to every create flow; only the monitor-type - // radio is uptime-specific. Edit mode keeps the flat form. - const isWizard = !isEditMode; const defaultType: MonitorType = pageType === "pagespeed" ? "pagespeed" @@ -267,10 +264,10 @@ const CreateMonitorPage = () => { reset(defaults); }, [defaults, reset]); - // Multi-step wizard, uptime create only (showTypeSelector). Other flows - // keep the flat form, where showStep() is always true. + // Multi-step wizard on every create flow; edit mode keeps the flat form + // where showStep() is always true. const [currentStep, setCurrentStep] = useState(0); - const showStep = (step: number) => !isWizard || currentStep === step; + const showStep = (step: number) => isEditMode || currentStep === step; const watchedType = watch("type") as MonitorType; const watchedUseAdvancedMatching = watch("useAdvancedMatching") as boolean; @@ -365,7 +362,7 @@ const CreateMonitorPage = () => { refetch={refetchMonitor} onDelete={handleDeleteClick} /> - {isWizard && ( + {!isEditMode && ( { - {isWizard && ( + {!isEditMode && ( )} - {isWizard && currentStep < totalSteps - 1 ? ( + {!isEditMode && currentStep < totalSteps - 1 ? (