16 files changed
+237
-68
No files matched your search
@@ -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<Theme>;
|
||||
badge: (tone: OverallTone) => SxProps<Theme>;
|
||||
heatmap: SxProps<Theme>;
|
||||
heatmapCell: (kind: BoldHeatCell) => SxProps<Theme>;
|
||||
heatmapCell: (kind: BoldHeatCell, severity?: number) => SxProps<Theme>;
|
||||
histogram: SxProps<Theme>;
|
||||
bar: (kind: BoldBarKind, heightPct: number) => SxProps<Theme>;
|
||||
bar: (kind: BoldBarKind, heightPct: number, severity?: number) => SxProps<Theme>;
|
||||
chartStats: SxProps<Theme>;
|
||||
infra: SxProps<Theme>;
|
||||
infraEmpty: SxProps<Theme>;
|
||||
@@ -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,
|
||||
|
||||
@@ -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<Theme>;
|
||||
badge: (tone: OverallTone) => SxProps<Theme>;
|
||||
heatmap: SxProps<Theme>;
|
||||
heatmapCell: (kind: EditorialHeatCell) => SxProps<Theme>;
|
||||
heatmapCell: (kind: EditorialHeatCell, severity?: number) => SxProps<Theme>;
|
||||
histogram: SxProps<Theme>;
|
||||
bar: (kind: EditorialBarKind, heightPct: number) => SxProps<Theme>;
|
||||
bar: (kind: EditorialBarKind, heightPct: number, severity?: number) => SxProps<Theme>;
|
||||
chartStats: SxProps<Theme>;
|
||||
infra: SxProps<Theme>;
|
||||
infraEmpty: SxProps<Theme>;
|
||||
@@ -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}%`,
|
||||
|
||||
@@ -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<Theme>;
|
||||
badge: (tone: OverallTone) => SxProps<Theme>;
|
||||
heatmap: SxProps<Theme>;
|
||||
heatmapCell: (kind: ModernHeatCell) => SxProps<Theme>;
|
||||
heatmapCell: (kind: ModernHeatCell, severity?: number) => SxProps<Theme>;
|
||||
histogram: SxProps<Theme>;
|
||||
bar: (kind: ModernBarKind, heightPct: number) => SxProps<Theme>;
|
||||
bar: (kind: ModernBarKind, heightPct: number, severity?: number) => SxProps<Theme>;
|
||||
chartStats: SxProps<Theme>;
|
||||
infra: SxProps<Theme>;
|
||||
infraEmpty: SxProps<Theme>;
|
||||
@@ -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<ModernHeatCell, string> = {
|
||||
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<ModernGaugeFill, string> = {
|
||||
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,
|
||||
|
||||
@@ -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<Theme>;
|
||||
badge: (tone: OverallTone) => SxProps<Theme>;
|
||||
heatmap: SxProps<Theme>;
|
||||
heatmapCell: (kind: RefinedHeatCell) => SxProps<Theme>;
|
||||
heatmapCell: (kind: RefinedHeatCell, severity?: number) => SxProps<Theme>;
|
||||
histogram: SxProps<Theme>;
|
||||
bar: (kind: RefinedBarKind, heightPct: number) => SxProps<Theme>;
|
||||
bar: (kind: RefinedBarKind, heightPct: number, severity?: number) => SxProps<Theme>;
|
||||
chartStats: SxProps<Theme>;
|
||||
infra: SxProps<Theme>;
|
||||
infraEmpty: SxProps<Theme>;
|
||||
@@ -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,
|
||||
|
||||
@@ -59,9 +59,9 @@ export interface BaseStyles {
|
||||
monitorUrl: SxProps<Theme>;
|
||||
badge: (tone: OverallTone) => SxProps<Theme>;
|
||||
heatmap: SxProps<Theme>;
|
||||
heatmapCell: (kind: HeatCellKind) => SxProps<Theme>;
|
||||
heatmapCell: (kind: HeatCellKind, severity?: number) => SxProps<Theme>;
|
||||
histogram: SxProps<Theme>;
|
||||
bar: (kind: BarKind, heightPct: number) => SxProps<Theme>;
|
||||
bar: (kind: BarKind, heightPct: number, severity?: number) => SxProps<Theme>;
|
||||
chartStats: SxProps<Theme>;
|
||||
infra: SxProps<Theme>;
|
||||
infraEmpty: SxProps<Theme>;
|
||||
|
||||
@@ -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: <ThemedChartTooltip check={check} />,
|
||||
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"
|
||||
|
||||
@@ -10,7 +10,7 @@ import type {
|
||||
interface Props {
|
||||
cells: ChartCell[];
|
||||
containerSx: SxProps<Theme>;
|
||||
cellSx: (kind: HeatCellKind) => SxProps<Theme>;
|
||||
cellSx: (kind: HeatCellKind, severity?: number) => SxProps<Theme>;
|
||||
}
|
||||
|
||||
export const ThemedHeatmap = ({ cells, containerSx, cellSx }: Props) => {
|
||||
@@ -42,7 +42,7 @@ export const ThemedHeatmap = ({ cells, containerSx, cellSx }: Props) => {
|
||||
placement="top"
|
||||
>
|
||||
<Box
|
||||
sx={cellSx(cell.heatKind)}
|
||||
sx={cellSx(cell.heatKind, cell.severity)}
|
||||
aria-label={cell.ariaLabel}
|
||||
/>
|
||||
</Tooltip>
|
||||
|
||||
@@ -13,7 +13,7 @@ import type {
|
||||
interface Props {
|
||||
cells: ChartCell[];
|
||||
containerSx: SxProps<Theme>;
|
||||
barSx: (kind: BarKind, heightPct: number) => SxProps<Theme>;
|
||||
barSx: (kind: BarKind, heightPct: number, severity?: number) => SxProps<Theme>;
|
||||
statsSx: SxProps<Theme>;
|
||||
statsGap?: number;
|
||||
}
|
||||
@@ -61,7 +61,7 @@ export const ThemedHistogram = ({
|
||||
placement="top"
|
||||
>
|
||||
<Box
|
||||
sx={barSx(cell.barKind, cell.heightPct)}
|
||||
sx={barSx(cell.barKind, cell.heightPct, cell.severity)}
|
||||
aria-label={cell.ariaLabel}
|
||||
/>
|
||||
</Tooltip>
|
||||
|
||||
@@ -243,3 +243,10 @@ export const themeTokens: Record<StatusPageTheme, ThemeVariants> = {
|
||||
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})`;
|
||||
@@ -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 }),
|
||||
|
||||
@@ -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(),
|
||||
|
||||
@@ -34,7 +34,7 @@ export interface IMonitorsRepository {
|
||||
findByIds(monitorIds: string[], options?: { recentChecks?: RecentChecksMode }): Promise<Monitor[]>;
|
||||
|
||||
// update
|
||||
updateById(monitorId: string, teamId: string, updates: Partial<Monitor>): Promise<Monitor>;
|
||||
updateById(monitorId: string, teamId: string, updates: Partial<Monitor>, options?: { unsetProxyId?: boolean }): Promise<Monitor>;
|
||||
updateByIds(monitorIds: string[], teamId: string, updates: Partial<Monitor>, excludeStatuses?: MonitorStatus[]): Promise<number>;
|
||||
updateStatusWindowAndChecks(
|
||||
monitorId: string,
|
||||
|
||||
@@ -177,16 +177,14 @@ class MongoMonitorsRepository implements IMonitorsRepository {
|
||||
return count;
|
||||
};
|
||||
|
||||
updateById = async (monitorId: string, teamId: string, patch: Partial<Monitor>) => {
|
||||
const updatedMonitor = await MonitorModel.findOneAndUpdate(
|
||||
{ _id: monitorId, teamId },
|
||||
{
|
||||
$set: {
|
||||
...patch,
|
||||
},
|
||||
},
|
||||
{ new: true, runValidators: true }
|
||||
);
|
||||
updateById = async (monitorId: string, teamId: string, patch: Partial<Monitor>, options?: { unsetProxyId?: boolean }) => {
|
||||
const fields: Partial<Monitor> = { ...patch };
|
||||
const update: Record<string, unknown> = { $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 });
|
||||
}
|
||||
|
||||
@@ -133,6 +133,10 @@ export class MonitorService implements IMonitorService {
|
||||
}
|
||||
|
||||
createMonitor = async (teamId: string, userId: string, body: Monitor): Promise<void> => {
|
||||
// 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<Monitor> }) => {
|
||||
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;
|
||||
};
|
||||
|
||||
@@ -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<string, unknown>;
|
||||
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<string, unknown>;
|
||||
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", () => {
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
Reference in new issue
Block a user