implement port monitoring

This commit is contained in:
Alex Holliday committed 2025-11-21 20:03:03 +00:00
1 parent caa863afe8
commit 5d825537ae
10 files changed
+195 -47

No files matched your search

+1 -1
View File
@@ -5,7 +5,7 @@
"type": "module",
"scripts": {
"i18n:display": "i18n-unused display-unused > test.log",
"dev": "vite --mode development",
"dev": "vite --host 127.0.0.1 --mode development",
"build": "tsc -b && vite build --mode production",
"lint": "eslint .",
"preview": "vite preview"
@@ -20,6 +20,7 @@ export const useInitForm = ({
const defaults: z.infer<typeof monitorSchema> = {
type: initialData?.type || "https",
url: initialData?.url || "",
port: initialData?.port || 80,
n: initialData?.n || 3,
notificationChannels: initialData?.notificationChannels || [],
name: initialData?.name || "",
+21
View File
@@ -132,6 +132,11 @@ export const UptimeForm = ({
"Use HTTP to monitor your website or API endpoint."
}
/>
<RadioWithDescription
value="port"
label={"Port"}
description={"Check whether your port is open or not."}
/>
<RadioWithDescription
value="ping"
label={t("pingMonitoring")}
@@ -174,6 +179,22 @@ export const UptimeForm = ({
/>
)}
/>
{selectedType === "port" && (
<Controller
name="port"
control={control}
render={({ field }) => (
<TextInput
{...field}
type="number"
fieldLabel={"Port"}
fullWidth
error={!!errors.port}
helperText={errors.port ? errors.port.message : ""}
/>
)}
/>
)}
<Controller
name="name"
control={control}
+2 -1
View File
@@ -4,13 +4,14 @@ import type { IMonitorStats } from "./monitorStats";
export const MonitorTypes = [
"http",
"https",
"port",
"ping",
"infrastructure",
"pagespeed",
] as const;
export type MonitorType = (typeof MonitorTypes)[number];
export const UptimeMonitorTypes = ["http", "https", "ping"] as const;
export const UptimeMonitorTypes = ["http", "https", "port", "ping"] as const;
export type UptimeMonitorType = (typeof UptimeMonitorTypes)[number];
export const MonitorStatuses = [
+59 -41
View File
@@ -88,17 +88,50 @@ const optionalString = <T extends z.ZodTypeAny>(schema: T) =>
schema.optional()
);
export const monitorSchema = z.object({
type: z.string().min(1, "You must select an option"),
url: z.string().min(1, "URL is required").regex(urlRegex, "Invalid URL"),
n: z.coerce
.number({ message: "Number required" })
.min(1, "Minimum value is 1")
.max(25, "Maximum value is 25"),
notificationChannels: z.array(z.string()).optional().default([]),
name: z.string().min(1, "Display name is required"),
interval: durationSchema,
});
export const monitorSchema = z
.object({
type: z.string().min(1, "You must select an option"),
url: z.string().min(1, "URL is required").regex(urlRegex, "Invalid URL"),
n: z.coerce
.number({ message: "Number required" })
.min(1, "Minimum value is 1")
.max(25, "Maximum value is 25"),
port: z.coerce.number().optional(),
notificationChannels: z.array(z.string()).optional().default([]),
name: z.string().min(1, "Display name is required"),
interval: durationSchema,
})
.superRefine((data, ctx) => {
if (data.type === "port") {
if (!data.port) {
ctx.addIssue({
code: "custom",
message: "Port is required for port monitoring",
path: ["port"],
});
}
if (isNaN(Number(data.port))) {
ctx.addIssue({
code: "custom",
message: "Port must be a number",
path: ["port"],
});
}
if (Number(data.port) < 1 || Number(data.port) > 65535) {
ctx.addIssue({
code: "custom",
message: "Port must be between 1 and 65535",
path: ["port"],
});
}
}
})
.transform((data) => {
if (data.type !== "port") {
return { ...data, port: undefined };
}
return data;
});
export const monitorSchemaPageSpeed = z.object({
type: z.string().min(1, "You must select an option"),
@@ -212,13 +245,9 @@ export const maintenanceSchema = z
export const systemSettingsSchema = z.object({
systemEmailHost: optionalString(
z
.string()
.trim()
.min(1, { message: "SMTP host is required" })
.max(255, {
message: "SMTP host must be 255 characters or fewer",
})
z.string().trim().min(1, { message: "SMTP host is required" }).max(255, {
message: "SMTP host must be 255 characters or fewer",
})
),
systemEmailPort: optionalString(
z
@@ -237,35 +266,24 @@ export const systemSettingsSchema = z.object({
z.string().email({ message: "SMTP email must be valid" })
),
systemEmailPassword: optionalString(
z
.string()
.max(255, {
message: "SMTP password must be 255 characters or fewer",
})
z.string().max(255, {
message: "SMTP password must be 255 characters or fewer",
})
),
systemEmailUser: optionalString(
z
.string()
.trim()
.max(255, {
message: "SMTP username must be 255 characters or fewer",
})
z.string().trim().max(255, {
message: "SMTP username must be 255 characters or fewer",
})
),
systemEmailConnectionHost: optionalString(
z
.string()
.trim()
.max(255, {
message: "Connection host must be 255 characters or fewer",
})
z.string().trim().max(255, {
message: "Connection host must be 255 characters or fewer",
})
),
systemEmailTLSServername: optionalString(
z
.string()
.trim()
.max(255, {
message: "TLS server name must be 255 characters or fewer",
})
z.string().trim().max(255, {
message: "TLS server name must be 255 characters or fewer",
})
),
systemEmailSecure: z.boolean().optional(),
systemEmailPool: z.boolean().optional(),
+4
View File
@@ -4,6 +4,7 @@ import { Check, MonitorStats } from "@/db/models/index.js";
export const MonitorTypes = [
"http",
"https",
"port",
"ping",
"infrastructure",
"pagespeed",
@@ -17,12 +18,14 @@ export const MonitorStatuses = [
"initializing",
] as const;
export type MonitorStatus = (typeof MonitorStatuses)[number];
export interface IMonitor extends Document {
_id: Types.ObjectId;
orgId: Types.ObjectId;
teamId: Types.ObjectId;
name: string;
url: string;
port?: number;
secret?: string;
type: MonitorType;
interval: number; // in ms
@@ -47,6 +50,7 @@ const MonitorSchema = new Schema<IMonitor>(
teamId: { type: Schema.Types.ObjectId, ref: "Team", required: true },
name: { type: String, required: true, trim: true, maxlength: 100 },
url: { type: String, required: true, trim: true },
port: { type: Number, required: false },
secret: { type: String, required: false },
type: {
type: String,
@@ -188,6 +188,8 @@ class CheckService implements ICheckService {
case "ping":
return this.buildBaseCheck(statusResponse);
case "port":
return this.buildBaseCheck(statusResponse);
default:
throw new Error(`Unsupported monitor type: ${type}`);
}
@@ -657,7 +657,7 @@ class MonitorService implements IMonitorService {
export = async (teamId: string) => {
return Monitor.find({ teamId })
.select("name url type interval n secret -_id")
.select("name url port type interval n secret -_id")
.lean();
};
@@ -1,5 +1,6 @@
import { Got, HTTPError } from "got";
import ping from "ping";
import net from "net";
import { IMonitor } from "@/db/models/index.js";
import { GotTimings } from "@/db/models/checks/Check.js";
import type { Response } from "got";
@@ -12,16 +13,16 @@ import { MonitorType, MonitorStatus } from "@/db/models/monitors/Monitor.js";
import ApiError from "@/utils/ApiError.js";
import { config } from "@/config/index.js";
import CacheableLookup from "cacheable-lookup";
import http from "http";
import https from "https";
import { getChildLogger } from "@/logger/Logger.js";
const SERVICE_NAME = "NetworkService";
const logger = getChildLogger(SERVICE_NAME);
export interface INetworkService {
requestHttp: (monitor: IMonitor) => Promise<StatusResponse>;
requestInfrastructure: (monitor: IMonitor) => Promise<StatusResponse>;
requestStatus: (monitor: IMonitor) => Promise<StatusResponse>;
requestPagespeed: (monitor: IMonitor) => Promise<StatusResponse>;
requestPing: (monitor: IMonitor) => Promise<StatusResponse>;
requestPort: (monitor: IMonitor) => Promise<StatusResponse>;
}
export interface ICapturePayload {
@@ -216,6 +217,78 @@ class NetworkService implements INetworkService {
};
};
requestPort = async (monitor: IMonitor) => {
const response = {
monitorId: monitor._id.toString(),
teamId: monitor.teamId.toString(),
type: monitor.type,
status: "down" as MonitorStatus,
message: "Port check failed",
responseTime: 0,
timings: { phases: {} } as GotTimings,
};
try {
const host = monitor.url;
const port = monitor.port;
if (!port) {
throw new Error("No port provided for port monitor");
}
const { status, responseTime, message } = await new Promise<{
status: MonitorStatus;
responseTime: number;
message: string;
}>((resolve) => {
const start = performance.now();
const socket = net.createConnection({
host,
port,
});
socket.setTimeout(5000);
socket.on("connect", () => {
socket.destroy();
const duration = performance.now() - start;
resolve({
status: "up",
responseTime: duration,
message: "Port is open",
});
});
socket.on("error", (err) => {
socket.destroy();
const duration = performance.now() - start;
resolve({
status: "down",
responseTime: duration,
message: err.message,
});
});
socket.on("timeout", () => {
socket.destroy();
const duration = performance.now() - start;
resolve({
status: "down",
responseTime: duration,
message: "Port check timed out",
});
});
});
response.status = status;
response.responseTime = responseTime;
response.message = message;
} catch (error) {
console.error(error);
} finally {
return response;
}
};
requestStatus = async (monitor: IMonitor) => {
switch (monitor?.type) {
case "http":
@@ -228,6 +301,8 @@ class NetworkService implements INetworkService {
return await this.requestPagespeed(monitor); // uses GOT
case "ping":
return await this.requestPing(monitor); // uses PING
case "port":
return await this.requestPort(monitor);
default:
throw new Error("Not implemented");
}
+26
View File
@@ -52,6 +52,7 @@ export const monitorSchema = z
type: z.string().min(1, "You must select an option"),
url: z.string().min(1, "URL is required").regex(urlRegex, "Invalid URL"),
name: z.string().min(1, "Display name is required"),
port: z.coerce.number().optional(),
n: z
.number({ message: "Number required" })
.min(1, "Minimum value is 1")
@@ -81,6 +82,30 @@ export const monitorSchema = z
message: `Secret is required for infrastructure monitors`,
});
}
if (data.type === "port") {
if (!data.port) {
ctx.addIssue({
code: "custom",
message: "Port is required for port monitoring",
path: ["port"],
});
}
if (isNaN(Number(data.port))) {
ctx.addIssue({
code: "custom",
message: "Port must be a number",
path: ["port"],
});
}
if (Number(data.port) < 1 || Number(data.port) > 65535) {
ctx.addIssue({
code: "custom",
message: "Port must be between 1 and 65535",
path: ["port"],
});
}
}
});
export const monitorImportSchema = z.object({
@@ -90,6 +115,7 @@ export const monitorImportSchema = z.object({
.object({
name: z.string().trim().min(1, "Monitor name is required"),
url: z.string().trim().regex(urlRegex, "Invalid URL"),
port: z.number().min(1).max(65535).optional(),
secret: z.string().trim().optional(),
type: z.enum(MonitorTypes),
interval: z.number().min(1, "Interval must be greater than 0"),