diff --git a/client/src/Pages/StatusPage/Status/themes/bold/styles.ts b/client/src/Pages/StatusPage/Status/themes/bold/styles.ts index 86208d41b..197310a7f 100644 --- a/client/src/Pages/StatusPage/Status/themes/bold/styles.ts +++ b/client/src/Pages/StatusPage/Status/themes/bold/styles.ts @@ -1,5 +1,5 @@ import type { SxProps, Theme } from "@mui/material/styles"; -import type { StatusPageThemeTokens } from "../tokens"; +import { mixSeverityColor, 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"; @@ -34,9 +34,9 @@ export interface BoldStyles { monitorUrl: SxProps; badge: (tone: OverallTone) => SxProps; heatmap: SxProps; - heatmapCell: (kind: BoldHeatCell) => SxProps; + heatmapCell: (kind: BoldHeatCell, severity?: number) => SxProps; histogram: SxProps; - bar: (kind: BoldBarKind, heightPct: number) => SxProps; + bar: (kind: BoldBarKind, heightPct: number, severity?: number) => SxProps; chartStats: SxProps; infra: SxProps; infraEmpty: SxProps; @@ -327,9 +327,12 @@ export const boldStyles = ( gap: { xs: "1px", md: "3px" }, height: 48, }, - heatmapCell: (kind) => ({ + heatmapCell: (kind, severity = 1) => ({ borderRadius: "3px", - background: heatCellBg[kind], + background: + kind === "degraded" + ? mixSeverityColor(tokens.up, tokens.degraded, severity) + : heatCellBg[kind], boxShadow: heatCellShadow[kind], opacity: kind === "empty" ? 0.4 : 1, transition: "transform 0.15s", @@ -344,8 +347,11 @@ export const boldStyles = ( alignItems: "flex-end", height: 48, }, - bar: (kind, heightPct) => ({ - background: barBg[kind], + bar: (kind, heightPct, severity = 1) => ({ + background: + kind === "degraded" + ? mixSeverityColor(tokens.up, tokens.degraded, severity) + : barBg[kind], borderRadius: "3px", minHeight: 4, opacity: kind === "empty" ? 0.4 : 1, diff --git a/client/src/Pages/StatusPage/Status/themes/editorial/styles.ts b/client/src/Pages/StatusPage/Status/themes/editorial/styles.ts index 9de2c96fb..759f856a0 100644 --- a/client/src/Pages/StatusPage/Status/themes/editorial/styles.ts +++ b/client/src/Pages/StatusPage/Status/themes/editorial/styles.ts @@ -1,5 +1,5 @@ import type { SxProps, Theme } from "@mui/material/styles"; -import type { StatusPageThemeTokens } from "../tokens"; +import { mixSeverityColor, type StatusPageThemeTokens } from "../tokens"; import { type OverallTone, toneColor, toneSoft } from "../shared/overallStatus"; import { EDITORIAL_SECONDARY_SANS_STACK, @@ -38,9 +38,9 @@ export interface EditorialStyles { monitorUrl: SxProps; badge: (tone: OverallTone) => SxProps; heatmap: SxProps; - heatmapCell: (kind: EditorialHeatCell) => SxProps; + heatmapCell: (kind: EditorialHeatCell, severity?: number) => SxProps; histogram: SxProps; - bar: (kind: EditorialBarKind, heightPct: number) => SxProps; + bar: (kind: EditorialBarKind, heightPct: number, severity?: number) => SxProps; chartStats: SxProps; infra: SxProps; infraEmpty: SxProps; @@ -272,8 +272,11 @@ export const editorialStyles = ( gap: { xs: "1px", md: "2px" }, height: 40, }, - heatmapCell: (kind) => ({ - background: heatCellBg[kind], + heatmapCell: (kind, severity = 1) => ({ + background: + kind === "degraded" + ? mixSeverityColor(tokens.up, tokens.degraded, severity) + : heatCellBg[kind], opacity: kind === "empty" ? 0.5 : 1, }), @@ -285,8 +288,11 @@ export const editorialStyles = ( alignItems: "flex-end", height: 40, }, - bar: (kind, heightPct) => ({ - background: barBg[kind], + bar: (kind, heightPct, severity = 1) => ({ + background: + kind === "degraded" + ? mixSeverityColor(tokens.up, tokens.degraded, severity) + : barBg[kind], minHeight: 3, opacity: kind === "empty" ? 0.5 : 1, height: `${heightPct}%`, diff --git a/client/src/Pages/StatusPage/Status/themes/modern/styles.ts b/client/src/Pages/StatusPage/Status/themes/modern/styles.ts index 7bb108f51..710da1777 100644 --- a/client/src/Pages/StatusPage/Status/themes/modern/styles.ts +++ b/client/src/Pages/StatusPage/Status/themes/modern/styles.ts @@ -1,6 +1,6 @@ import type { SxProps, Theme } from "@mui/material/styles"; import { keyframes } from "@mui/system"; -import type { StatusPageThemeTokens } from "../tokens"; +import { mixSeverityColor, 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"; @@ -37,9 +37,9 @@ export interface ModernStyles { monitorUrl: SxProps; badge: (tone: OverallTone) => SxProps; heatmap: SxProps; - heatmapCell: (kind: ModernHeatCell) => SxProps; + heatmapCell: (kind: ModernHeatCell, severity?: number) => SxProps; histogram: SxProps; - bar: (kind: ModernBarKind, heightPct: number) => SxProps; + bar: (kind: ModernBarKind, heightPct: number, severity?: number) => SxProps; chartStats: SxProps; infra: SxProps; infraEmpty: SxProps; @@ -83,13 +83,14 @@ export const modernStyles = ( ): ModernStyles => { const cardShadow = isDark ? darkShadow : lightShadow; const cardHoverShadow = isDark ? darkShadowHover : lightShadowHover; + const darken = (color: string) => `color-mix(in srgb, ${color} 70%, #000000 30%)`; const heatCellBg: Record = { fast: `linear-gradient(180deg, ${tokens.up}, ${tokens.upStrong})`, med: `linear-gradient(180deg, color-mix(in srgb, ${tokens.up} 70%, #ffffff 30%), ${tokens.up})`, slow: `linear-gradient(180deg, ${tokens.warn}, ${tokens.degraded})`, - degraded: `linear-gradient(180deg, ${tokens.degraded}, color-mix(in srgb, ${tokens.degraded} 70%, #000000 30%))`, - down: `linear-gradient(180deg, ${tokens.down}, color-mix(in srgb, ${tokens.down} 70%, #000000 30%))`, + degraded: `linear-gradient(180deg, ${tokens.degraded}, ${darken(tokens.degraded)})`, + down: `linear-gradient(180deg, ${tokens.down}, ${darken(tokens.down)})`, empty: tokens.border, }; @@ -103,7 +104,7 @@ export const modernStyles = ( const gaugeFillBg: Record = { ok: `linear-gradient(90deg, ${tokens.up}, ${tokens.upStrong})`, warm: `linear-gradient(90deg, ${tokens.warn}, ${tokens.degraded})`, - hot: `linear-gradient(90deg, ${tokens.down}, color-mix(in srgb, ${tokens.down} 70%, #000000 30%))`, + hot: `linear-gradient(90deg, ${tokens.down}, color-mix(in srgb, ${tokens.down}, ${darken(tokens.down)})`, }; return { @@ -354,13 +355,19 @@ export const modernStyles = ( gap: { xs: "1px", md: "3px" }, height: 46, }, - heatmapCell: (kind) => ({ - borderRadius: "3px", - background: heatCellBg[kind], - opacity: kind === "empty" ? 0.4 : 1, - transition: "transform 0.15s", - "&:hover": { transform: "scaleY(1.2)" }, - }), + heatmapCell: (kind, severity = 1) => { + const mixed = mixSeverityColor(tokens.up, tokens.degraded, severity); + return { + borderRadius: "3px", + background: + kind === "degraded" + ? `linear-gradient(180deg, ${mixed}, ${darken(mixed)})` + : heatCellBg[kind], + opacity: kind === "empty" ? 0.4 : 1, + transition: "transform 0.15s", + "&:hover": { transform: "scaleY(1.2)" }, + }; + }, histogram: { padding: { xs: "0 12px", md: "0 24px" }, @@ -370,8 +377,11 @@ export const modernStyles = ( alignItems: "flex-end", height: 46, }, - bar: (kind, heightPct) => ({ - background: barBg[kind], + bar: (kind, heightPct, severity = 1) => ({ + background: + kind === "degraded" + ? mixSeverityColor(tokens.up, tokens.degraded, severity) + : barBg[kind], borderRadius: "3px", minHeight: 4, opacity: kind === "empty" ? 0.4 : 1, diff --git a/client/src/Pages/StatusPage/Status/themes/refined/styles.ts b/client/src/Pages/StatusPage/Status/themes/refined/styles.ts index 38f972c95..2655ab555 100644 --- a/client/src/Pages/StatusPage/Status/themes/refined/styles.ts +++ b/client/src/Pages/StatusPage/Status/themes/refined/styles.ts @@ -1,5 +1,5 @@ import type { SxProps, Theme } from "@mui/material/styles"; -import type { StatusPageThemeTokens } from "../tokens"; +import { mixSeverityColor, type StatusPageThemeTokens } from "../tokens"; import { type OverallTone, toneColor, toneSoft } from "../shared/overallStatus"; import { MONO_STACK, SANS_STACK } from "../shared/fontStacks"; @@ -37,9 +37,9 @@ export interface RefinedStyles { monitorUrl: SxProps; badge: (tone: OverallTone) => SxProps; heatmap: SxProps; - heatmapCell: (kind: RefinedHeatCell) => SxProps; + heatmapCell: (kind: RefinedHeatCell, severity?: number) => SxProps; histogram: SxProps; - bar: (kind: RefinedBarKind, heightPct: number) => SxProps; + bar: (kind: RefinedBarKind, heightPct: number, severity?: number) => SxProps; chartStats: SxProps; infra: SxProps; infraEmpty: SxProps; @@ -296,9 +296,12 @@ export const refinedStyles = ( gap: { xs: "1px", md: "3px" }, height: 42, }, - heatmapCell: (kind) => ({ + heatmapCell: (kind, severity = 1) => ({ borderRadius: "2px", - background: heatCellBg[kind], + background: + kind === "degraded" + ? mixSeverityColor(tokens.up, tokens.degraded, severity) + : heatCellBg[kind], opacity: kind === "empty" ? 0.4 : 1, transition: "transform 0.15s", "&:hover": { transform: "scaleY(1.15)" }, @@ -312,8 +315,11 @@ export const refinedStyles = ( alignItems: "flex-end", height: 42, }, - bar: (kind, heightPct) => ({ - background: barBg[kind], + bar: (kind, heightPct, severity = 1) => ({ + background: + kind === "degraded" + ? mixSeverityColor(tokens.up, tokens.degraded, severity) + : barBg[kind], borderRadius: "2px", minHeight: 3, opacity: kind === "empty" ? 0.4 : 1, diff --git a/client/src/Pages/StatusPage/Status/themes/shared/BaseStatusPage.tsx b/client/src/Pages/StatusPage/Status/themes/shared/BaseStatusPage.tsx index cf08ca62f..807eed742 100644 --- a/client/src/Pages/StatusPage/Status/themes/shared/BaseStatusPage.tsx +++ b/client/src/Pages/StatusPage/Status/themes/shared/BaseStatusPage.tsx @@ -59,9 +59,9 @@ export interface BaseStyles { monitorUrl: SxProps; badge: (tone: OverallTone) => SxProps; heatmap: SxProps; - heatmapCell: (kind: HeatCellKind) => SxProps; + heatmapCell: (kind: HeatCellKind, severity?: number) => SxProps; histogram: SxProps; - bar: (kind: BarKind, heightPct: number) => SxProps; + bar: (kind: BarKind, heightPct: number, severity?: number) => SxProps; chartStats: SxProps; infra: SxProps; infraEmpty: SxProps; diff --git a/client/src/Pages/StatusPage/Status/themes/shared/ChartCells.tsx b/client/src/Pages/StatusPage/Status/themes/shared/ChartCells.tsx index e45d2e6b6..8d8a6a675 100644 --- a/client/src/Pages/StatusPage/Status/themes/shared/ChartCells.tsx +++ b/client/src/Pages/StatusPage/Status/themes/shared/ChartCells.tsx @@ -18,6 +18,7 @@ export type HeatCellKind = "fast" | "med" | "slow" | "degraded" | "down" | "empt export interface ChartCell { key: string; + severity: number; barKind: BarKind; heatKind: HeatCellKind; heightPct: number; // histogram bar height @@ -28,9 +29,22 @@ export interface ChartCell { const DAY_DOWN_THRESHOLD = 0.5; -const classifyDay = (bucket: DailyCheckBucket): BarKind => { +// Color interpolation +// 0 = perfect +// 1 = at or below the down threshold +const SEVERITY_FLOOR = 0.15; // A degraded server should never be green +const SEVERITY_GAMMA = 2; // >1 biases toward green so high-uptime days stay visibly distinct from low ones + +export const severityFromUpRatio = (upRatio: number): number => { + const failed = 1 - upRatio; + if (failed <= 0) return 0; + const scaled = (Math.log10(failed) + 3) / (Math.log10(1 - DAY_DOWN_THRESHOLD) + 3); + const clamped = Math.min(1, Math.max(0, scaled)); + return Math.max(SEVERITY_FLOOR, clamped ** SEVERITY_GAMMA); +}; + +const classifyDay = (bucket: DailyCheckBucket, upRatio: number): BarKind => { if (bucket.totalChecks === 0) return "empty"; - const upRatio = bucket.upChecks / bucket.totalChecks; if (upRatio >= 1) return "up"; if (upRatio < DAY_DOWN_THRESHOLD) return "down"; return "degraded"; @@ -55,6 +69,7 @@ const emptyCell = (key: string): ChartCell => ({ responseTime: 0, tooltip: null, ariaLabel: "", + severity: 0, }); const padCells = (cells: ChartCell[], length: number): ChartCell[] => [ @@ -76,6 +91,7 @@ export const checksToCells = (checks: CheckSnapshot[]): ChartCell[] => { responseTime: check.responseTime ?? 0, tooltip: , ariaLabel: `${formatMs(check.responseTime)}, ${check.status ? "up" : "down"}`, + severity: 0, }) ); return padCells(cells, MAX_RECENT_CHECKS); @@ -93,9 +109,17 @@ export const dailyBucketsToCells = ( return enumerated.map((date) => { const bucket = byDate.get(date); if (!bucket) return emptyCell(`day-${date}`); - const barKind = classifyDay(bucket); + const upRatio = bucket.upChecks / bucket.totalChecks; + + const barKind = classifyDay(bucket, upRatio); return { key: `day-${date}`, + severity: + barKind === "degraded" + ? severityFromUpRatio(upRatio) + : barKind === "down" + ? 1 + : 0, barKind, heatKind: barKind === "up" diff --git a/client/src/Pages/StatusPage/Status/themes/shared/ThemedHeatmap.tsx b/client/src/Pages/StatusPage/Status/themes/shared/ThemedHeatmap.tsx index 390f70667..7ddc7424d 100644 --- a/client/src/Pages/StatusPage/Status/themes/shared/ThemedHeatmap.tsx +++ b/client/src/Pages/StatusPage/Status/themes/shared/ThemedHeatmap.tsx @@ -10,7 +10,7 @@ import type { interface Props { cells: ChartCell[]; containerSx: SxProps; - cellSx: (kind: HeatCellKind) => SxProps; + cellSx: (kind: HeatCellKind, severity?: number) => SxProps; } export const ThemedHeatmap = ({ cells, containerSx, cellSx }: Props) => { @@ -42,7 +42,7 @@ export const ThemedHeatmap = ({ cells, containerSx, cellSx }: Props) => { placement="top" > diff --git a/client/src/Pages/StatusPage/Status/themes/shared/ThemedHistogram.tsx b/client/src/Pages/StatusPage/Status/themes/shared/ThemedHistogram.tsx index 3643d901c..ea5fa649a 100644 --- a/client/src/Pages/StatusPage/Status/themes/shared/ThemedHistogram.tsx +++ b/client/src/Pages/StatusPage/Status/themes/shared/ThemedHistogram.tsx @@ -13,7 +13,7 @@ import type { interface Props { cells: ChartCell[]; containerSx: SxProps; - barSx: (kind: BarKind, heightPct: number) => SxProps; + barSx: (kind: BarKind, heightPct: number, severity?: number) => SxProps; statsSx: SxProps; statsGap?: number; } @@ -61,7 +61,7 @@ export const ThemedHistogram = ({ placement="top" > diff --git a/client/src/Pages/StatusPage/Status/themes/tokens.ts b/client/src/Pages/StatusPage/Status/themes/tokens.ts index 3ec90abde..644bad8d2 100644 --- a/client/src/Pages/StatusPage/Status/themes/tokens.ts +++ b/client/src/Pages/StatusPage/Status/themes/tokens.ts @@ -243,3 +243,10 @@ export const themeTokens: Record = { editorial, minimal, }; + +// Interpolate between a theme's up and degraded colors by severity (0..1). +export const mixSeverityColor = ( + up: string, + degraded: string, + severity: number +): string => `color-mix(in oklch, ${degraded} ${Math.round(severity * 100)}%, ${up})`; diff --git a/client/src/Validation/monitor.ts b/client/src/Validation/monitor.ts index 5b53ff3a5..00ab3aaff 100644 --- a/client/src/Validation/monitor.ts +++ b/client/src/Validation/monitor.ts @@ -69,7 +69,12 @@ const httpSchema = baseSchema.extend({ type: z.literal("http"), url: urlSchema, proxyMode: z.enum(ProxyModes), - proxyId: z.string().optional(), + // The form uses "" for "no proxy selected". drop it from the payload so the + // server never receives an empty ObjectId + proxyId: z + .string() + .transform((value) => (value === "" ? undefined : value)) + .optional(), method: z.enum(HttpMethods).optional().register(monitorStepRegistry, { step: 2 }), ignoreTlsErrors: z.boolean().register(monitorStepRegistry, { step: 2 }), useAdvancedMatching: z.boolean().register(monitorStepRegistry, { step: 2 }), diff --git a/server/src/api/validation/monitorValidation.ts b/server/src/api/validation/monitorValidation.ts index 65a64ed10..e694a216d 100644 --- a/server/src/api/validation/monitorValidation.ts +++ b/server/src/api/validation/monitorValidation.ts @@ -16,6 +16,13 @@ import { DateRanges, SortOrders } from "@/types/query.js"; const httpStatusCode = z.number().refine((code) => HttpStatusCodeSet.has(code), { message: "Must be a valid HTTP status code" }); +// The client form submits proxyId: "" when no proxy is selected, set it to undefined +const proxyIdValidation = z + .string() + .optional() + .transform((value) => (value === "" ? undefined : value)) + .refine((value) => value === undefined || /^[0-9a-f]{24}$/i.test(value), { message: "Invalid proxy ID" }); + export const getMonitorByIdParamValidation = z.object({ monitorId: z.string().min(1, "Monitor ID is required"), }); @@ -122,7 +129,7 @@ export const createMonitorBodyValidation = z url: z.string().min(1, "URL is required"), ignoreTlsErrors: z.boolean().default(false), proxyMode: z.enum(ProxyModes).default("inherit"), - proxyId: z.string().optional(), + proxyId: proxyIdValidation, useAdvancedMatching: z.boolean().default(false), port: z.number().optional(), isActive: z.boolean().optional(), @@ -171,7 +178,7 @@ export const editMonitorBodyValidation = z secret: z.string().optional(), ignoreTlsErrors: z.boolean().optional(), proxyMode: z.enum(ProxyModes).optional(), - proxyId: z.string().optional(), + proxyId: proxyIdValidation, useAdvancedMatching: z.boolean().optional(), jsonPath: z.union([z.string(), z.literal("")]).optional(), expectedValue: z.union([z.string(), z.literal("")]).optional(), @@ -233,7 +240,7 @@ const importedMonitorSchema = z type: z.enum(MonitorTypes, "Invalid monitor type"), ignoreTlsErrors: z.boolean().default(false), proxyMode: z.enum(ProxyModes).default("inherit"), - proxyId: z.string().optional(), + proxyId: proxyIdValidation, useAdvancedMatching: z.boolean().default(false), jsonPath: z.union([z.string(), z.literal("")]).optional(), expectedValue: z.union([z.string(), z.literal("")]).optional(), @@ -306,7 +313,7 @@ export const monitorResponseSchema = z statusWindowThreshold: z.number(), ignoreTlsErrors: z.boolean(), proxyMode: z.enum(ProxyModes), - proxyId: z.string().optional(), + proxyId: proxyIdValidation, useAdvancedMatching: z.boolean(), jsonPath: z.string().optional(), expectedValue: z.string().optional(), diff --git a/server/src/domain/monitors/monitor.repository.interface.ts b/server/src/domain/monitors/monitor.repository.interface.ts index 1ce2b8fa8..1b12fef71 100644 --- a/server/src/domain/monitors/monitor.repository.interface.ts +++ b/server/src/domain/monitors/monitor.repository.interface.ts @@ -34,7 +34,7 @@ export interface IMonitorsRepository { findByIds(monitorIds: string[], options?: { recentChecks?: RecentChecksMode }): Promise; // update - updateById(monitorId: string, teamId: string, updates: Partial): Promise; + updateById(monitorId: string, teamId: string, updates: Partial, options?: { unsetProxyId?: boolean }): Promise; updateByIds(monitorIds: string[], teamId: string, updates: Partial, excludeStatuses?: MonitorStatus[]): Promise; updateStatusWindowAndChecks( monitorId: string, diff --git a/server/src/domain/monitors/monitor.repository.mongo.ts b/server/src/domain/monitors/monitor.repository.mongo.ts index 6cf2e0ecf..8f773f983 100644 --- a/server/src/domain/monitors/monitor.repository.mongo.ts +++ b/server/src/domain/monitors/monitor.repository.mongo.ts @@ -177,16 +177,14 @@ class MongoMonitorsRepository implements IMonitorsRepository { return count; }; - updateById = async (monitorId: string, teamId: string, patch: Partial) => { - const updatedMonitor = await MonitorModel.findOneAndUpdate( - { _id: monitorId, teamId }, - { - $set: { - ...patch, - }, - }, - { new: true, runValidators: true } - ); + updateById = async (monitorId: string, teamId: string, patch: Partial, options?: { unsetProxyId?: boolean }) => { + const fields: Partial = { ...patch }; + const update: Record = { $set: fields }; + if (options?.unsetProxyId) { + delete fields.proxyId; + update.$unset = { proxyId: "" }; + } + const updatedMonitor = await MonitorModel.findOneAndUpdate({ _id: monitorId, teamId }, update, { new: true, runValidators: true }); if (!updatedMonitor) { throw new AppError({ message: `Failed to update monitor with id ${monitorId}`, status: 500 }); } diff --git a/server/src/domain/monitors/monitor.service.ts b/server/src/domain/monitors/monitor.service.ts index 31488c378..6cf9af54b 100644 --- a/server/src/domain/monitors/monitor.service.ts +++ b/server/src/domain/monitors/monitor.service.ts @@ -133,6 +133,10 @@ export class MonitorService implements IMonitorService { } createMonitor = async (teamId: string, userId: string, body: Monitor): Promise => { + // proxyId is only needed in custom mode + if (body.proxyMode !== "custom") { + delete body.proxyId; + } const monitor = await this.monitorsRepository.create(body, teamId, userId); if (!monitor) { throw new AppError({ message: "Failed to create monitor", status: 500, service: SERVICE_NAME, method: "createMonitor" }); @@ -375,7 +379,12 @@ export class MonitorService implements IMonitorService { }; editMonitor = async ({ teamId, monitorId, body }: { teamId: string; monitorId: string; body: Partial }) => { - const editedMonitor = await this.monitorsRepository.updateById(monitorId, teamId, body); + // Moving off custom mode orphans the stored proxyId. Unset it so the stale reference doesn't block deleting that proxy + const unsetProxyId = body.proxyMode !== undefined && body.proxyMode !== "custom"; + if (unsetProxyId) { + delete body.proxyId; + } + const editedMonitor = await this.monitorsRepository.updateById(monitorId, teamId, body, { unsetProxyId }); await this.scheduler.updateJob(editedMonitor); return editedMonitor; }; diff --git a/server/test/unit/services/monitorService.test.ts b/server/test/unit/services/monitorService.test.ts index 0f56e5d2b..fcfdf533d 100644 --- a/server/test/unit/services/monitorService.test.ts +++ b/server/test/unit/services/monitorService.test.ts @@ -124,6 +124,38 @@ describe("MonitorService", () => { expect(jobQueue.addJob).toHaveBeenCalledWith(MONITOR_ID, monitor); }); + it("strips a stray proxyId when proxyMode is not custom", async () => { + const monitorsRepository = createMonitorsRepositoryMock(); + const monitor = makeMonitor(); + (monitorsRepository.create as jest.Mock).mockResolvedValue(monitor); + const { service } = createService({ monitorsRepository }); + + await service.createMonitor(TEAM_ID, USER_ID, { + ...monitor, + proxyMode: "inherit", + proxyId: "64b7a1f2c9e77a001a2b3c4d", + } as any); + + const createdBody = (monitorsRepository.create as jest.Mock).mock.calls[0][0] as Record; + expect(createdBody.proxyId).toBeUndefined(); + }); + + it("keeps proxyId when proxyMode is custom", async () => { + const monitorsRepository = createMonitorsRepositoryMock(); + const monitor = makeMonitor(); + (monitorsRepository.create as jest.Mock).mockResolvedValue(monitor); + const { service } = createService({ monitorsRepository }); + + await service.createMonitor(TEAM_ID, USER_ID, { + ...monitor, + proxyMode: "custom", + proxyId: "64b7a1f2c9e77a001a2b3c4d", + } as any); + + const createdBody = (monitorsRepository.create as jest.Mock).mock.calls[0][0] as Record; + expect(createdBody.proxyId).toBe("64b7a1f2c9e77a001a2b3c4d"); + }); + it("throws when repository returns null", async () => { const monitorsRepository = createMonitorsRepositoryMock(); (monitorsRepository.create as jest.Mock).mockResolvedValue(null); @@ -750,9 +782,42 @@ describe("MonitorService", () => { const result = await service.editMonitor({ teamId: TEAM_ID, monitorId: MONITOR_ID, body: { name: "Updated" } }); expect(result).toMatchObject({ name: "Updated" }); - expect(monitorsRepository.updateById).toHaveBeenCalledWith(MONITOR_ID, TEAM_ID, { name: "Updated" }); + expect(monitorsRepository.updateById).toHaveBeenCalledWith(MONITOR_ID, TEAM_ID, { name: "Updated" }, { unsetProxyId: false }); expect(jobQueue.updateJob).toHaveBeenCalledWith(updatedMonitor); }); + + it("keeps proxyId and does not unset it when proxyMode is custom", async () => { + const monitorsRepository = createMonitorsRepositoryMock(); + (monitorsRepository.updateById as jest.Mock).mockResolvedValue(makeMonitor()); + const { service } = createService({ monitorsRepository }); + + await service.editMonitor({ + teamId: TEAM_ID, + monitorId: MONITOR_ID, + body: { proxyMode: "custom", proxyId: "64b7a1f2c9e77a001a2b3c4d" } as any, + }); + + expect(monitorsRepository.updateById).toHaveBeenCalledWith( + MONITOR_ID, + TEAM_ID, + { proxyMode: "custom", proxyId: "64b7a1f2c9e77a001a2b3c4d" }, + { unsetProxyId: false } + ); + }); + + it("strips proxyId and unsets it when proxyMode moves off custom", async () => { + const monitorsRepository = createMonitorsRepositoryMock(); + (monitorsRepository.updateById as jest.Mock).mockResolvedValue(makeMonitor()); + const { service } = createService({ monitorsRepository }); + + await service.editMonitor({ + teamId: TEAM_ID, + monitorId: MONITOR_ID, + body: { proxyMode: "inherit", proxyId: "64b7a1f2c9e77a001a2b3c4d" } as any, + }); + + expect(monitorsRepository.updateById).toHaveBeenCalledWith(MONITOR_ID, TEAM_ID, { proxyMode: "inherit" }, { unsetProxyId: true }); + }); }); describe("updateNotifications", () => { diff --git a/server/test/unit/validation/monitorValidation.test.ts b/server/test/unit/validation/monitorValidation.test.ts index 4de4538e1..ea2257072 100644 --- a/server/test/unit/validation/monitorValidation.test.ts +++ b/server/test/unit/validation/monitorValidation.test.ts @@ -477,6 +477,7 @@ describe("monitorValidation — proxy fields", () => { type: "http" as const, url: "https://example.com", }; + const PROXY_ID = "64b7a1f2c9e77a001a2b3c4d"; describe("createMonitorBodyValidation", () => { it("defaults proxyMode to inherit", () => { @@ -487,23 +488,38 @@ describe("monitorValidation — proxy fields", () => { }); it("accepts custom mode with a proxyId", () => { - const parsed = createMonitorBodyValidation.parse({ ...baseHttpBody, proxyMode: "custom", proxyId: "proxy-1" }); + const parsed = createMonitorBodyValidation.parse({ ...baseHttpBody, proxyMode: "custom", proxyId: PROXY_ID }); expect(parsed.proxyMode).toBe("custom"); - expect(parsed.proxyId).toBe("proxy-1"); + expect(parsed.proxyId).toBe(PROXY_ID); }); it("rejects custom mode without a proxyId", () => { expect(() => createMonitorBodyValidation.parse({ ...baseHttpBody, proxyMode: "custom" })).toThrow(); }); + it("rejects custom mode with an empty-string proxyId", () => { + expect(() => createMonitorBodyValidation.parse({ ...baseHttpBody, proxyMode: "custom", proxyId: "" })).toThrow(); + }); + + it("normalizes an empty-string proxyId to undefined on non-custom modes", () => { + for (const proxyMode of ["inherit", "none"] as const) { + const parsed = createMonitorBodyValidation.parse({ ...baseHttpBody, proxyMode, proxyId: "" }); + expect(parsed.proxyId).toBeUndefined(); + } + }); + + it("rejects a proxyId that is not a valid ObjectId", () => { + expect(() => createMonitorBodyValidation.parse({ ...baseHttpBody, proxyMode: "custom", proxyId: "proxy-1" })).toThrow(); + }); + it("rejects an unknown proxyMode", () => { expect(() => createMonitorBodyValidation.parse({ ...baseHttpBody, proxyMode: "global" })).toThrow(); }); it("allows a stray proxyId on non-custom modes", () => { for (const proxyMode of ["inherit", "none"] as const) { - const parsed = createMonitorBodyValidation.parse({ ...baseHttpBody, proxyMode, proxyId: "proxy-1" }); + const parsed = createMonitorBodyValidation.parse({ ...baseHttpBody, proxyMode, proxyId: PROXY_ID }); expect(parsed.proxyMode).toBe(proxyMode); } }); @@ -521,9 +537,19 @@ describe("monitorValidation — proxy fields", () => { }); it("accepts custom mode with a proxyId", () => { - const parsed = editMonitorBodyValidation.parse({ proxyMode: "custom", proxyId: "proxy-1" }); + const parsed = editMonitorBodyValidation.parse({ proxyMode: "custom", proxyId: PROXY_ID }); - expect(parsed.proxyId).toBe("proxy-1"); + expect(parsed.proxyId).toBe(PROXY_ID); + }); + + it("normalizes an empty-string proxyId to undefined", () => { + const parsed = editMonitorBodyValidation.parse({ proxyMode: "inherit", proxyId: "" }); + + expect(parsed.proxyId).toBeUndefined(); + }); + + it("rejects a proxyId that is not a valid ObjectId", () => { + expect(() => editMonitorBodyValidation.parse({ proxyMode: "custom", proxyId: "not-an-object-id" })).toThrow(); }); });