1
0

feat: 新增 ICMP/Ping checker,支持跨平台主机存活检测与延迟监控

实现 type: ping checker,通过 Bun.spawn 调用系统 ping 命令,自行实现跨平台
输出解析器(Linux/macOS/Windows 含中文 locale),支持 alive、丢包率、延迟、
耗时等 expect 断言,复用现有 checker 架构零外部依赖。

包含完整的类型定义、TypeBox schema、语义校验、命令构建、解析、断言、执行、
注册、配置加载测试,以及 probe-config.schema.json 更新和文档更新。

审查修复:提取 buildPingCommand 为独立纯函数并补充跨平台单测,补充
maxDurationMs/maxAvgLatencyMs 类型非法和空字符串 host 边界测试用例。

变更已归档,delta specs 已同步至 main specs。
This commit is contained in:
2026-05-18 10:45:17 +08:00
parent c51bc5a0d8
commit 550c427814
30 changed files with 1132 additions and 330 deletions

View File

@@ -0,0 +1,118 @@
import { isNumber, isPlainObject, isString } from "es-toolkit";
import type { ConfigValidationIssue } from "../../schema/issues";
import type { CheckerValidationInput } from "../types";
import { issue, joinPath } from "../../schema/issues";
export function validatePingConfig(input: CheckerValidationInput): ConfigValidationIssue[] {
const issues: ConfigValidationIssue[] = [];
const defaults = input.defaults["ping"];
if (defaults !== undefined && defaults !== null) {
const targetName = "defaults.ping";
if (!isPlainObject(defaults)) {
issues.push(issue("invalid-type", "defaults.ping", "必须为对象", targetName));
} else {
const pingDefaults = defaults as Record<string, unknown>;
for (const key of Object.keys(pingDefaults)) {
issues.push(issue("unknown-field", joinPath("defaults.ping", key), "是未知字段", targetName));
}
}
}
for (let i = 0; i < input.targets.length; i++) {
const target = input.targets[i] as unknown;
if (!isPlainObject(target)) continue;
const targetRecord = target as Record<string, unknown>;
if (targetRecord["type"] !== "ping") continue;
issues.push(...validatePingTarget(targetRecord, `targets[${i}]`));
}
return issues;
}
function getTargetName(target: Record<string, unknown>): string | undefined {
if (isString(target["name"])) return target["name"];
return isString(target["id"]) ? target["id"] : undefined;
}
function isNonNegativeFiniteNumber(value: unknown): boolean {
return isNumber(value) && Number.isFinite(value) && value >= 0;
}
function validatePingExpect(target: Record<string, unknown>, path: string): ConfigValidationIssue[] {
const rawExpect = target["expect"];
if (rawExpect === undefined || rawExpect === null || !isPlainObject(rawExpect)) return [];
const expect = rawExpect as Record<string, unknown>;
const issues: ConfigValidationIssue[] = [];
const targetName = getTargetName(target);
const expectPath = joinPath(path, "expect");
if (expect["alive"] !== undefined && typeof expect["alive"] !== "boolean") {
issues.push(issue("invalid-type", joinPath(expectPath, "alive"), "必须为布尔值", targetName));
}
if (expect["maxPacketLoss"] !== undefined) {
const value = expect["maxPacketLoss"];
if (!isNumber(value) || !Number.isFinite(value) || value < 0 || value > 100) {
issues.push(issue("invalid-value", joinPath(expectPath, "maxPacketLoss"), "必须为 0-100 的数字", targetName));
}
}
for (const key of ["maxAvgLatencyMs", "maxMaxLatencyMs", "maxDurationMs"]) {
if (expect[key] !== undefined && !isNonNegativeFiniteNumber(expect[key])) {
issues.push(issue("invalid-type", joinPath(expectPath, key), "必须为非负有限数字", targetName));
}
}
const allowedKeys = new Set(["alive", "maxAvgLatencyMs", "maxDurationMs", "maxMaxLatencyMs", "maxPacketLoss"]);
for (const key of Object.keys(expect)) {
if (!allowedKeys.has(key)) {
issues.push(issue("unknown-field", joinPath(expectPath, key), "是未知字段", targetName));
}
}
return issues;
}
function validatePingTarget(target: Record<string, unknown>, path: string): ConfigValidationIssue[] {
const issues: ConfigValidationIssue[] = [];
const targetName = getTargetName(target);
const rawPing = target["ping"];
if (!isPlainObject(rawPing)) {
issues.push(issue("required", joinPath(path, "ping"), "缺少 ping 配置分组", targetName));
issues.push(...validatePingExpect(target, path));
return issues;
}
const ping = rawPing as Record<string, unknown>;
if (!isString(ping["host"]) || ping["host"].trim() === "") {
issues.push(issue("required", joinPath(joinPath(path, "ping"), "host"), "缺少 ping.host 字段", targetName));
}
if (ping["count"] !== undefined) {
const count = ping["count"];
if (!isNumber(count) || !Number.isInteger(count) || count < 1 || count > 100) {
issues.push(
issue("invalid-value", joinPath(joinPath(path, "ping"), "count"), "必须为 1-100 的正整数", targetName),
);
}
}
if (ping["packetSize"] !== undefined) {
const packetSize = ping["packetSize"];
if (!isNumber(packetSize) || !Number.isInteger(packetSize) || packetSize < 1 || packetSize > 65500) {
issues.push(
issue("invalid-value", joinPath(joinPath(path, "ping"), "packetSize"), "必须为 1-65500 的正整数", targetName),
);
}
}
const allowedPingKeys = new Set(["count", "host", "packetSize"]);
for (const key of Object.keys(ping)) {
if (!allowedPingKeys.has(key)) {
issues.push(issue("unknown-field", joinPath(joinPath(path, "ping"), key), "是未知字段", targetName));
}
}
issues.push(...validatePingExpect(target, path));
return issues;
}