resovle merge conflict
This commit is contained in:
commit
85f674ee08
12 files changed
+423
-4
No files matched your search
@@ -45,6 +45,7 @@ export const getMonitorDefaults = (
|
||||
matchMethod: data?.matchMethod || "",
|
||||
expectedValue: data?.expectedValue || "",
|
||||
jsonPath: data?.jsonPath || "",
|
||||
customUpCodes: data?.customUpCodes || [],
|
||||
};
|
||||
break;
|
||||
case "ping":
|
||||
@@ -138,6 +139,7 @@ export const getMonitorDefaults = (
|
||||
matchMethod: "",
|
||||
expectedValue: "",
|
||||
jsonPath: "",
|
||||
customUpCodes: [],
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { useMemo, useState } from "react";
|
||||
import { useEffect } from "react";
|
||||
import { logger } from "@/Utils/logger";
|
||||
import { ALL_HTTP_STATUS_CODES } from "@/Utils/statusCode";
|
||||
import { useParams, useLocation, useNavigate } from "react-router";
|
||||
import { useForm, Controller } from "react-hook-form";
|
||||
import { zodResolver } from "@hookform/resolvers/zod";
|
||||
@@ -1090,6 +1091,77 @@ const CreateMonitorPage = () => {
|
||||
subtitle={t("pages.createMonitor.form.advanced.description")}
|
||||
rightContent={
|
||||
<Stack spacing={theme.spacing(LAYOUT.MD)}>
|
||||
<Controller
|
||||
name="customUpCodes"
|
||||
control={control}
|
||||
render={({ field }) => {
|
||||
const selectedOptions = ALL_HTTP_STATUS_CODES.filter((option) =>
|
||||
field.value?.includes(option.id)
|
||||
);
|
||||
return (
|
||||
<Stack spacing={theme.spacing(LAYOUT.MD)}>
|
||||
<Autocomplete
|
||||
multiple
|
||||
autoHighlight
|
||||
options={ALL_HTTP_STATUS_CODES}
|
||||
value={selectedOptions}
|
||||
getOptionLabel={(option) => option.name}
|
||||
isOptionEqualToValue={(option, value) => option.id === value.id}
|
||||
onChange={(
|
||||
_: unknown,
|
||||
newValue: typeof ALL_HTTP_STATUS_CODES
|
||||
) => {
|
||||
field.onChange(newValue?.map((v) => v.id) || []);
|
||||
}}
|
||||
fieldLabel={t(
|
||||
"pages.createMonitor.form.advanced.option.customUpCodes.label"
|
||||
)}
|
||||
/>
|
||||
<Typography
|
||||
component="span"
|
||||
color={theme.palette.text.secondary}
|
||||
sx={{ opacity: 0.8 }}
|
||||
>
|
||||
{t(
|
||||
"pages.createMonitor.form.advanced.option.customUpCodes.description"
|
||||
)}
|
||||
</Typography>
|
||||
{selectedOptions.length > 0 && (
|
||||
<Stack
|
||||
flex={1}
|
||||
gap={theme.spacing(SPACING.MD)}
|
||||
width="100%"
|
||||
>
|
||||
{selectedOptions.map((option, index) => (
|
||||
<Stack
|
||||
direction="row"
|
||||
alignItems="center"
|
||||
key={option.id}
|
||||
width="100%"
|
||||
>
|
||||
<Typography flexGrow={1}>{option.name}</Typography>
|
||||
<IconButton
|
||||
size="small"
|
||||
onClick={() => {
|
||||
field.onChange(
|
||||
field.value?.filter((id) => id !== option.id)
|
||||
);
|
||||
}}
|
||||
aria-label={t(
|
||||
"pages.createMonitor.form.advanced.option.customUpCodes.removeAriaLabel"
|
||||
)}
|
||||
>
|
||||
<Trash2 size={16} />
|
||||
</IconButton>
|
||||
{index < selectedOptions.length - 1 && <Divider />}
|
||||
</Stack>
|
||||
))}
|
||||
</Stack>
|
||||
)}
|
||||
</Stack>
|
||||
);
|
||||
}}
|
||||
/>
|
||||
<Controller
|
||||
name="useAdvancedMatching"
|
||||
control={control}
|
||||
|
||||
@@ -71,6 +71,7 @@ export interface Monitor {
|
||||
uptimePercentage?: number;
|
||||
notifications: string[];
|
||||
tags: string[];
|
||||
customUpCodes?: number[];
|
||||
secret?: string;
|
||||
cpuAlertThreshold: number;
|
||||
cpuAlertCounter: number;
|
||||
|
||||
@@ -74,3 +74,96 @@ export const getStatusCodeValueType = (code: number): ValueType => {
|
||||
if (code < 400) return "neutral";
|
||||
return "negative";
|
||||
};
|
||||
|
||||
export interface HttpStatusCodeOption {
|
||||
id: number;
|
||||
name: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Must stay in sync with HttpStatusCodes in server/src/domain/monitors/monitor.types.ts
|
||||
*/
|
||||
export const ALL_HTTP_STATUS_CODES: HttpStatusCodeOption[] = [
|
||||
{ id: 100, name: "100 - Continue" },
|
||||
{ id: 101, name: "101 - Switching Protocols" },
|
||||
{ id: 102, name: "102 - Processing" },
|
||||
{ id: 103, name: "103 - Early Hints" },
|
||||
{ id: 200, name: "200 - OK" },
|
||||
{ id: 201, name: "201 - Created" },
|
||||
{ id: 202, name: "202 - Accepted" },
|
||||
{ id: 203, name: "203 - Non-Authoritative Information" },
|
||||
{ id: 204, name: "204 - No Content" },
|
||||
{ id: 205, name: "205 - Reset Content" },
|
||||
{ id: 206, name: "206 - Partial Content" },
|
||||
{ id: 207, name: "207 - Multi-Status" },
|
||||
{ id: 208, name: "208 - Already Reported" },
|
||||
{ id: 226, name: "226 - IM Used" },
|
||||
{ id: 300, name: "300 - Multiple Choices" },
|
||||
{ id: 301, name: "301 - Moved Permanently" },
|
||||
{ id: 302, name: "302 - Found" },
|
||||
{ id: 303, name: "303 - See Other" },
|
||||
{ id: 304, name: "304 - Not Modified" },
|
||||
{ id: 305, name: "305 - Use Proxy" },
|
||||
{ id: 307, name: "307 - Temporary Redirect" },
|
||||
{ id: 308, name: "308 - Permanent Redirect" },
|
||||
{ id: 400, name: "400 - Bad Request" },
|
||||
{ id: 401, name: "401 - Unauthorized" },
|
||||
{ id: 402, name: "402 - Payment Required" },
|
||||
{ id: 403, name: "403 - Forbidden" },
|
||||
{ id: 404, name: "404 - Not Found" },
|
||||
{ id: 405, name: "405 - Method Not Allowed" },
|
||||
{ id: 406, name: "406 - Not Acceptable" },
|
||||
{ id: 407, name: "407 - Proxy Authentication Required" },
|
||||
{ id: 408, name: "408 - Request Timeout" },
|
||||
{ id: 409, name: "409 - Conflict" },
|
||||
{ id: 410, name: "410 - Gone" },
|
||||
{ id: 411, name: "411 - Length Required" },
|
||||
{ id: 412, name: "412 - Precondition Failed" },
|
||||
{ id: 413, name: "413 - Payload Too Large" },
|
||||
{ id: 414, name: "414 - URI Too Long" },
|
||||
{ id: 415, name: "415 - Unsupported Media Type" },
|
||||
{ id: 416, name: "416 - Range Not Satisfiable" },
|
||||
{ id: 417, name: "417 - Expectation Failed" },
|
||||
{ id: 418, name: "418 - I'm a Teapot" },
|
||||
{ id: 419, name: "419 - Page Expired (Laravel)" },
|
||||
{ id: 420, name: "420 - Enhance Your Calm (Twitter)" },
|
||||
{ id: 421, name: "421 - Misdirected Request" },
|
||||
{ id: 422, name: "422 - Unprocessable Entity" },
|
||||
{ id: 423, name: "423 - Locked" },
|
||||
{ id: 424, name: "424 - Failed Dependency" },
|
||||
{ id: 425, name: "425 - Too Early" },
|
||||
{ id: 426, name: "426 - Upgrade Required" },
|
||||
{ id: 428, name: "428 - Precondition Required" },
|
||||
{ id: 429, name: "429 - Too Many Requests" },
|
||||
{ id: 431, name: "431 - Request Header Fields Too Large" },
|
||||
{ id: 440, name: "440 - Login Time-out (IIS)" },
|
||||
{ id: 449, name: "449 - Retry With (IIS)" },
|
||||
{ id: 451, name: "451 - Unavailable For Legal Reasons" },
|
||||
{ id: 460, name: "460 - Client Closed Connection (AWS ELB)" },
|
||||
{ id: 463, name: "463 - X-Forwarded-For Too Large (AWS ELB)" },
|
||||
{ id: 497, name: "497 - HTTP Request Sent to HTTPS Port (NGINX)" },
|
||||
{ id: 499, name: "499 - Client Closed Request (NGINX)" },
|
||||
{ id: 500, name: "500 - Internal Server Error" },
|
||||
{ id: 501, name: "501 - Not Implemented" },
|
||||
{ id: 502, name: "502 - Bad Gateway" },
|
||||
{ id: 503, name: "503 - Service Unavailable" },
|
||||
{ id: 504, name: "504 - Gateway Timeout" },
|
||||
{ id: 505, name: "505 - HTTP Version Not Supported" },
|
||||
{ id: 506, name: "506 - Variant Also Negotiates" },
|
||||
{ id: 507, name: "507 - Insufficient Storage" },
|
||||
{ id: 508, name: "508 - Loop Detected" },
|
||||
{ id: 509, name: "509 - Bandwidth Limit Exceeded (Apache)" },
|
||||
{ id: 510, name: "510 - Not Extended" },
|
||||
{ id: 511, name: "511 - Network Authentication Required" },
|
||||
{ id: 520, name: "520 - Web Server Returned an Unknown Error (Cloudflare)" },
|
||||
{ id: 521, name: "521 - Web Server Is Down (Cloudflare)" },
|
||||
{ id: 522, name: "522 - Connection Timed Out (Cloudflare)" },
|
||||
{ id: 523, name: "523 - Origin Is Unreachable (Cloudflare)" },
|
||||
{ id: 524, name: "524 - A Timeout Occurred (Cloudflare)" },
|
||||
{ id: 525, name: "525 - SSL Handshake Failed (Cloudflare)" },
|
||||
{ id: 526, name: "526 - Invalid SSL Certificate (Cloudflare)" },
|
||||
{ id: 527, name: "527 - Railgun Error (Cloudflare)" },
|
||||
{ id: 529, name: "529 - Site is overloaded" },
|
||||
{ id: 530, name: "530 - Site is frozen (Cloudflare)" },
|
||||
{ id: 561, name: "561 - Unauthorized (AWS ELB)" },
|
||||
];
|
||||
@@ -2,6 +2,7 @@ import { z } from "zod";
|
||||
import type { FieldPath } from "react-hook-form";
|
||||
import { GeoContinents } from "@/Types/GeoCheck";
|
||||
import { DnsRecordTypes, PageSpeedStrategies, type MonitorType } from "@/Types/Monitor";
|
||||
import { ALL_HTTP_STATUS_CODES } from "@/Utils/statusCode";
|
||||
|
||||
// Wizard step a field is validated on. Attached inline to each field below so
|
||||
// the grouping lives next to the field definition; unannotated fields default
|
||||
@@ -53,6 +54,11 @@ const geoCheckFields = {
|
||||
};
|
||||
|
||||
// HTTP monitor schema
|
||||
const httpStatusCodeSet = new Set(ALL_HTTP_STATUS_CODES.map((c) => c.id));
|
||||
const httpStatusCode = z.number().refine((code) => httpStatusCodeSet.has(code), {
|
||||
message: "Must be a valid HTTP status code",
|
||||
});
|
||||
|
||||
const httpSchema = baseSchema.extend({
|
||||
type: z.literal("http"),
|
||||
url: urlSchema,
|
||||
@@ -64,6 +70,10 @@ const httpSchema = baseSchema.extend({
|
||||
.register(monitorStepRegistry, { step: 2 }),
|
||||
expectedValue: z.string().optional().register(monitorStepRegistry, { step: 2 }),
|
||||
jsonPath: z.string().optional().register(monitorStepRegistry, { step: 2 }),
|
||||
customUpCodes: z
|
||||
.array(httpStatusCode)
|
||||
.optional()
|
||||
.register(monitorStepRegistry, { step: 2 }),
|
||||
...geoCheckFields,
|
||||
});
|
||||
|
||||
|
||||
@@ -505,6 +505,11 @@
|
||||
"equal": "Equal",
|
||||
"include": "Include",
|
||||
"regex": "Regex"
|
||||
},
|
||||
"customUpCodes": {
|
||||
"label": "Accepted status codes",
|
||||
"description": "Add any non-2xx codes that should also be considered successful.",
|
||||
"removeAriaLabel": "Remove status code"
|
||||
}
|
||||
},
|
||||
"title": "Advanced settings"
|
||||
|
||||
@@ -4,7 +4,29 @@ import type { GeoContinent, GroupedGeoCheck } from "@/domain/geo-checks/geo-chec
|
||||
export type { GeoContinent } from "@/domain/geo-checks/geo-check.type.js";
|
||||
import http from "node:http";
|
||||
|
||||
export const HttpStatusCodes = Object.keys(http.STATUS_CODES).map(Number);
|
||||
export const HttpStatusCodes = [
|
||||
...Object.keys(http.STATUS_CODES).map(Number),
|
||||
419, // Page Expired (Laravel)
|
||||
420, // Enhance Your Calm (Twitter)
|
||||
440, // Login Time-out (IIS)
|
||||
449, // Retry With (IIS)
|
||||
460, // Client Closed Connection (AWS ELB)
|
||||
463, // X-Forwarded-For Too Large (AWS ELB)
|
||||
497, // HTTP Request Sent to HTTPS Port (NGINX)
|
||||
499, // Client Closed Request (NGINX)
|
||||
509, // Bandwidth Limit Exceeded (Apache)
|
||||
520, // Web Server Returned an Unknown Error (Cloudflare)
|
||||
521, // Web Server Is Down (Cloudflare)
|
||||
522, // Connection Timed Out (Cloudflare)
|
||||
523, // Origin Is Unreachable (Cloudflare)
|
||||
524, // A Timeout Occurred (Cloudflare)
|
||||
525, // SSL Handshake Failed (Cloudflare)
|
||||
526, // Invalid SSL Certificate (Cloudflare)
|
||||
527, // Railgun Error (Cloudflare)
|
||||
529, // Site is overloaded
|
||||
530, // Site is frozen (Cloudflare)
|
||||
561, // Unauthorized (AWS ELB)
|
||||
];
|
||||
export const HttpStatusCodeSet = new Set(HttpStatusCodes);
|
||||
export type HttpStatusCode = number;
|
||||
|
||||
|
||||
@@ -221,10 +221,10 @@ export class StatusService implements IStatusService {
|
||||
// Build the status patch — computed against the projected window
|
||||
const patch: Partial<Monitor> = {};
|
||||
|
||||
// Resolve "initializing" up-front, incidents should be created on initialization && down
|
||||
// Resolve "initializing" and "maintenance" up front, incidents should be created on initialization && down
|
||||
let newStatus: MonitorStatus = monitor.status;
|
||||
let statusChanged = false;
|
||||
if (monitor.status === "initializing") {
|
||||
if (monitor.status === "initializing" || monitor.status === "maintenance") {
|
||||
newStatus = status === true ? "up" : "down";
|
||||
patch.status = newStatus;
|
||||
statusChanged = newStatus === "down";
|
||||
|
||||
@@ -49,7 +49,8 @@ export class CheckProducer implements ICheckProducer {
|
||||
method: "produce",
|
||||
});
|
||||
if (monitor.status !== "maintenance") {
|
||||
await this.monitorsRepository.updateById(monitor.id, monitor.teamId, { status: "maintenance" });
|
||||
// Clear the status window to avoid incidents being created on next check
|
||||
await this.monitorsRepository.updateById(monitor.id, monitor.teamId, { status: "maintenance", statusWindow: [] });
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
@@ -20,6 +20,114 @@ const activeWindow = () => {
|
||||
return { active: true, start: new Date(now - 1000).toISOString(), end: new Date(now + 1000).toISOString(), repeat: 0 };
|
||||
};
|
||||
|
||||
// ── CheckPipeline ────────────────────────────────────────────────────────────
|
||||
|
||||
const createCheckPipeline = (overrides?: Record<string, any>) => {
|
||||
const defaults = {
|
||||
logger: createMockLogger(),
|
||||
networkService: { requestStatus: jest.fn().mockResolvedValue({ monitorId: "m1", status: true, code: 200, message: "OK" }) },
|
||||
statusService: {
|
||||
updateMonitorStatus: jest.fn().mockResolvedValue({ monitor: { id: "m1", status: "up" }, statusChanged: false, prevStatus: "up", code: 200 }),
|
||||
},
|
||||
checkService: { toCheck: jest.fn().mockReturnValue({ id: "check-1" }) },
|
||||
buffer: { addToBuffer: jest.fn() },
|
||||
monitorsRepository: { updateById: jest.fn().mockResolvedValue({}) },
|
||||
maintenanceWindowsRepository: { findByMonitorId: jest.fn().mockResolvedValue([]) },
|
||||
monitorStatusPolicy: new MonitorStatusPolicy(),
|
||||
...overrides,
|
||||
};
|
||||
const checkProducer = new CheckProducer(
|
||||
defaults.monitorsRepository as any,
|
||||
defaults.maintenanceWindowsRepository as any,
|
||||
defaults.checkService as any,
|
||||
defaults.networkService as any,
|
||||
defaults.buffer as any,
|
||||
defaults.logger as any
|
||||
);
|
||||
const checkEvaluator = new CheckEvaluator(defaults.statusService as any, defaults.monitorStatusPolicy as any);
|
||||
const pipeline = new CheckPipeline(checkProducer, checkEvaluator);
|
||||
return { pipeline, defaults };
|
||||
};
|
||||
|
||||
describe("CheckPipeline", () => {
|
||||
it("throws when monitor id is missing", async () => {
|
||||
const { pipeline } = createCheckPipeline();
|
||||
await expect(pipeline.run({} as Monitor)).rejects.toThrow("No monitor id");
|
||||
});
|
||||
|
||||
it("skips and sets maintenance status when in maintenance window", async () => {
|
||||
const { pipeline, defaults } = createCheckPipeline({
|
||||
maintenanceWindowsRepository: { findByMonitorId: jest.fn().mockResolvedValue([activeWindow()]) },
|
||||
});
|
||||
|
||||
const result = await pipeline.run(makeMonitor({ status: "up" }));
|
||||
|
||||
expect(result).toBeNull();
|
||||
expect(defaults.networkService.requestStatus).not.toHaveBeenCalled();
|
||||
expect(defaults.monitorsRepository.updateById).toHaveBeenCalledWith("m1", "team", { status: "maintenance", statusWindow: [] });
|
||||
});
|
||||
|
||||
it("does not re-write status when already in maintenance", async () => {
|
||||
const { pipeline, defaults } = createCheckPipeline({
|
||||
maintenanceWindowsRepository: { findByMonitorId: jest.fn().mockResolvedValue([activeWindow()]) },
|
||||
});
|
||||
|
||||
await pipeline.run(makeMonitor({ status: "maintenance" }));
|
||||
|
||||
expect(defaults.monitorsRepository.updateById).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("throws when network response is null", async () => {
|
||||
const { pipeline } = createCheckPipeline({
|
||||
networkService: { requestStatus: jest.fn().mockResolvedValue(null) },
|
||||
});
|
||||
await expect(pipeline.run(makeMonitor())).rejects.toThrow("No network response");
|
||||
});
|
||||
|
||||
it("returns null and warns when toCheck returns null", async () => {
|
||||
const { pipeline, defaults } = createCheckPipeline({
|
||||
checkService: { toCheck: jest.fn().mockReturnValue(null) },
|
||||
});
|
||||
|
||||
const result = await pipeline.run(makeMonitor());
|
||||
|
||||
expect(result).toBeNull();
|
||||
expect(defaults.logger.warn).toHaveBeenCalledWith(expect.objectContaining({ message: expect.stringContaining("No check could be built") }));
|
||||
expect(defaults.statusService.updateMonitorStatus).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("buffers the check, updates status, and returns an evaluation", async () => {
|
||||
const status = { monitorId: "m1", status: false, code: 500, message: "Error" };
|
||||
const statusChange = { monitor: { id: "m1", status: "down" }, statusChanged: true, prevStatus: "up", code: 500 };
|
||||
const { pipeline, defaults } = createCheckPipeline({
|
||||
networkService: { requestStatus: jest.fn().mockResolvedValue(status) },
|
||||
statusService: { updateMonitorStatus: jest.fn().mockResolvedValue(statusChange) },
|
||||
});
|
||||
|
||||
const result = await pipeline.run(makeMonitor());
|
||||
|
||||
expect(defaults.buffer.addToBuffer).toHaveBeenCalledWith({ id: "check-1" });
|
||||
expect(defaults.statusService.updateMonitorStatus).toHaveBeenCalled();
|
||||
expect(result).toMatchObject({
|
||||
monitor: statusChange.monitor,
|
||||
status,
|
||||
check: { id: "check-1" },
|
||||
statusChange,
|
||||
decision: expect.objectContaining({ shouldCreateIncident: true, shouldSendNotification: true, incidentReason: "status_down" }),
|
||||
});
|
||||
});
|
||||
|
||||
it("proceeds with the check when no maintenance window is active", async () => {
|
||||
const { pipeline, defaults } = createCheckPipeline({
|
||||
maintenanceWindowsRepository: { findByMonitorId: jest.fn().mockResolvedValue([{ ...activeWindow(), active: false }]) },
|
||||
});
|
||||
|
||||
await pipeline.run(makeMonitor());
|
||||
|
||||
expect(defaults.networkService.requestStatus).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
// ── GeoChecksPipeline ──────────────────────────────────────────────────────────
|
||||
|
||||
const createGeoPipeline = (overrides?: Record<string, any>) => {
|
||||
|
||||
@@ -555,6 +555,55 @@ describe("StatusService", () => {
|
||||
expect(result.prevStatus).toBe("initializing");
|
||||
});
|
||||
|
||||
it("leaves 'maintenance' for 'up' on a passing check after the window closes (empty statusWindow)", async () => {
|
||||
// On entry to a maintenance window the producer clears statusWindow, so the first
|
||||
// check after the window closes sees an empty window and lands in the warmup
|
||||
// early-return branch. A passing check must move the monitor out of 'maintenance'
|
||||
// to 'up' without flagging a status change (no incident).
|
||||
const monitor = makeMonitor({ statusWindow: [], statusWindowSize: 5, status: "maintenance" });
|
||||
const { service, monitorsRepository } = createService();
|
||||
(monitorsRepository.findById as jest.Mock).mockResolvedValue(monitor);
|
||||
|
||||
const result = await service.updateMonitorStatus(makeStatusResponse({ status: true }), makeCheck({ status: true }));
|
||||
|
||||
expect(result.statusChanged).toBe(false);
|
||||
expect(result.monitor.status).toBe("up");
|
||||
expect(result.prevStatus).toBe("maintenance");
|
||||
});
|
||||
|
||||
it("leaves 'maintenance' for 'down' on a failing check after the window closes (empty statusWindow)", async () => {
|
||||
// Mirror of the recovery case: a failing first check after the window closes must
|
||||
// flip the monitor straight to 'down' and surface statusChanged so an incident opens,
|
||||
// rather than leave it stuck in 'maintenance'.
|
||||
const monitor = makeMonitor({ statusWindow: [], statusWindowSize: 5, status: "maintenance" });
|
||||
const { service, monitorsRepository } = createService();
|
||||
(monitorsRepository.findById as jest.Mock).mockResolvedValue(monitor);
|
||||
|
||||
const result = await service.updateMonitorStatus(makeStatusResponse({ status: false }), makeCheck({ status: false }));
|
||||
|
||||
expect(result.statusChanged).toBe(true);
|
||||
expect(result.monitor.status).toBe("down");
|
||||
expect(result.prevStatus).toBe("maintenance");
|
||||
});
|
||||
|
||||
it("unsticks from 'maintenance' to 'up' on a passing check even with a full window (regression: stuck in maintenance)", async () => {
|
||||
// Guards the up-front override directly: even if a full window survives (e.g. the
|
||||
// entry-clear is bypassed), computeReachability only transitions out to 'up' from
|
||||
// 'down', so without the override the monitor would stay stuck in 'maintenance'.
|
||||
const monitor = makeMonitor({
|
||||
statusWindow: [true, true, true, true, true],
|
||||
statusWindowSize: 5,
|
||||
statusWindowThreshold: 80,
|
||||
status: "maintenance",
|
||||
});
|
||||
const { service, monitorsRepository } = createService();
|
||||
(monitorsRepository.findById as jest.Mock).mockResolvedValue(monitor);
|
||||
|
||||
const result = await service.updateMonitorStatus(makeStatusResponse({ status: true }), makeCheck({ status: true }));
|
||||
|
||||
expect(result.monitor.status).toBe("up");
|
||||
});
|
||||
|
||||
it("does not flip status on subsequent checks during warmup once 'initializing' has been left (regression: down→up incident resolves once window fills)", async () => {
|
||||
// After a 'initializing'→'down' transition on check #1, monitor.status is now 'down'
|
||||
// and statusWindow has one false. Subsequent up checks during warmup must leave
|
||||
|
||||
@@ -243,6 +243,14 @@ describe("monitorValidation — customUpCodes", () => {
|
||||
expect(parsed.customUpCodes).toEqual([200, 301, 404, 503]);
|
||||
});
|
||||
|
||||
it("accepts non-standard HTTP status codes (Cloudflare, AWS ELB, etc.)", () => {
|
||||
const parsed = createMonitorBodyValidation.parse({
|
||||
...baseHttpBody,
|
||||
customUpCodes: [419, 420, 440, 449, 460, 463, 497, 499, 509, 520, 521, 522, 523, 524, 525, 526, 527, 529, 530, 561],
|
||||
});
|
||||
expect(parsed.customUpCodes).toEqual([419, 420, 440, 449, 460, 463, 497, 499, 509, 520, 521, 522, 523, 524, 525, 526, 527, 529, 530, 561]);
|
||||
});
|
||||
|
||||
it("rejects invalid HTTP status codes", () => {
|
||||
expect(() =>
|
||||
createMonitorBodyValidation.parse({
|
||||
@@ -259,6 +267,33 @@ describe("monitorValidation — customUpCodes", () => {
|
||||
).toThrow();
|
||||
});
|
||||
|
||||
it("rejects fractional or floating-point status codes", () => {
|
||||
expect(() =>
|
||||
createMonitorBodyValidation.parse({
|
||||
...baseHttpBody,
|
||||
customUpCodes: [200.5],
|
||||
})
|
||||
).toThrow();
|
||||
});
|
||||
|
||||
it("rejects status codes below 100", () => {
|
||||
expect(() =>
|
||||
createMonitorBodyValidation.parse({
|
||||
...baseHttpBody,
|
||||
customUpCodes: [99],
|
||||
})
|
||||
).toThrow();
|
||||
});
|
||||
|
||||
it("rejects valid-looking but mathematically unsupported status codes (e.g. 599)", () => {
|
||||
expect(() =>
|
||||
createMonitorBodyValidation.parse({
|
||||
...baseHttpBody,
|
||||
customUpCodes: [599], // Not in Node's default list nor our expanded list
|
||||
})
|
||||
).toThrow();
|
||||
});
|
||||
|
||||
it("defaults to an empty array when not provided", () => {
|
||||
const parsed = createMonitorBodyValidation.parse(baseHttpBody);
|
||||
expect(parsed.customUpCodes).toEqual([]);
|
||||
@@ -273,6 +308,13 @@ describe("monitorValidation — customUpCodes", () => {
|
||||
expect(parsed.customUpCodes).toEqual([200, 201]);
|
||||
});
|
||||
|
||||
it("accepts non-standard HTTP status codes on edits", () => {
|
||||
const parsed = editMonitorBodyValidation.parse({
|
||||
customUpCodes: [419, 420, 440, 449, 460, 463, 497, 499, 509, 520, 521, 522, 523, 524, 525, 526, 527, 529, 530, 561],
|
||||
});
|
||||
expect(parsed.customUpCodes).toEqual([419, 420, 440, 449, 460, 463, 497, 499, 509, 520, 521, 522, 523, 524, 525, 526, 527, 529, 530, 561]);
|
||||
});
|
||||
|
||||
it("rejects invalid HTTP status codes on edits", () => {
|
||||
expect(() =>
|
||||
editMonitorBodyValidation.parse({
|
||||
@@ -295,6 +337,20 @@ describe("monitorValidation — customUpCodes", () => {
|
||||
expect(parsed.monitors[0].customUpCodes).toEqual([404]);
|
||||
});
|
||||
|
||||
it("accepts non-standard HTTP status codes on imported HTTP monitors", () => {
|
||||
const parsed = importMonitorsBodyValidation.parse({
|
||||
monitors: [
|
||||
{
|
||||
...baseHttpBody,
|
||||
customUpCodes: [419, 420, 440, 449, 460, 463, 497, 499, 509, 520, 521, 522, 523, 524, 525, 526, 527, 529, 530, 561],
|
||||
},
|
||||
],
|
||||
});
|
||||
expect(parsed.monitors[0].customUpCodes).toEqual([
|
||||
419, 420, 440, 449, 460, 463, 497, 499, 509, 520, 521, 522, 523, 524, 525, 526, 527, 529, 530, 561,
|
||||
]);
|
||||
});
|
||||
|
||||
it("rejects invalid HTTP status codes on imported HTTP monitors", () => {
|
||||
expect(() =>
|
||||
importMonitorsBodyValidation.parse({
|
||||
|
||||
Reference in new issue
Block a user