resovle merge conflict

This commit is contained in:
Alex Holliday committed 2026-06-19 09:55:52 -07:00
commit 85f674ee08
12 files changed
+423 -4

No files matched your search

+2
View File
@@ -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: [],
};
}
+72
View File
@@ -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}
+1
View File
@@ -71,6 +71,7 @@ export interface Monitor {
uptimePercentage?: number;
notifications: string[];
tags: string[];
customUpCodes?: number[];
secret?: string;
cpuAlertThreshold: number;
cpuAlertCounter: number;
+93
View File
@@ -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)" },
];
+10
View File
@@ -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,
});
+5
View File
@@ -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"
+23 -1
View File
@@ -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;
+2 -2
View File
@@ -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";
+2 -1
View File
@@ -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({