1
0

feat: 重构为多类型 checker 通用框架,支持 HTTP 与命令检查

- 引入 typed target 判别联合,支持 http 与 command 两种 checker
- expect 重构为有序规则数组,按配置顺序快速失败并生成结构化 failure
- 新增 command runner,支持 exec + args 本地命令执行
- 引入全局并发限制 maxConcurrentChecks 和 size 解析 (KB/MB/GB)
- HTTP/command 各自独立 expect pipeline,应用领域默认成功语义
- SQLite schema、API、Dashboard 全链路调整为 checker 通用契约
- 补充完整测试覆盖(192 tests),更新 README 与示例配置
This commit is contained in:
2026-05-10 22:25:21 +08:00
parent 599d973cbd
commit b8810f1182
46 changed files with 3562 additions and 1062 deletions

View File

@@ -1,16 +1,39 @@
import type { ProbeConfig, ResolvedTarget } from "./types";
import type {
CommandDefaultsConfig,
CommandTargetConfig,
DefaultsConfig,
HttpDefaultsConfig,
HttpExpectConfig,
HttpTargetConfig,
ProbeConfig,
ResolvedCommandTarget,
ResolvedHttpTarget,
ResolvedTarget,
RuntimeConfig,
TargetConfig,
TargetType,
} from "./types";
import { parseSize } from "./size";
import { resolve } from "node:path";
import { dirname } from "node:path";
const DEFAULT_HOST = "127.0.0.1";
const DEFAULT_PORT = 3000;
const DEFAULT_DATA_DIR = "./data";
const DEFAULT_INTERVAL = "30s";
const DEFAULT_TIMEOUT = "10s";
const DEFAULT_METHOD = "GET";
const DEFAULT_HTTP_METHOD = "GET";
const DEFAULT_MAX_BODY_BYTES = "100MB";
const DEFAULT_MAX_OUTPUT_BYTES = "100MB";
const DEFAULT_MAX_CONCURRENT_CHECKS = 20;
const SUPPORTED_TYPES: TargetType[] = ["http", "command"];
export interface ResolvedConfig {
host: string;
port: number;
dataDir: string;
configDir: string;
maxConcurrentChecks: number;
targets: ResolvedTarget[];
}
@@ -30,7 +53,9 @@ export async function loadConfig(configPath: string): Promise<ResolvedConfig> {
validateConfig(raw);
const configDir = dirname(resolve(configPath));
const server = raw.server ?? {};
const runtime = raw.runtime ?? {};
const defaults = raw.defaults ?? {};
const host = server.host ?? DEFAULT_HOST;
@@ -41,23 +66,102 @@ export async function loadConfig(configPath: string): Promise<ResolvedConfig> {
throw new Error(`无效端口号: ${port},需要 0-65535 之间的整数`);
}
const maxConcurrentChecks = validateRuntime(runtime);
const defaultIntervalMs = parseDuration(defaults.interval ?? DEFAULT_INTERVAL);
const defaultTimeoutMs = parseDuration(defaults.timeout ?? DEFAULT_TIMEOUT);
const defaultMethod = defaults.method ?? DEFAULT_METHOD;
const defaultHeaders = defaults.headers ?? {};
const targets: ResolvedTarget[] = raw.targets.map((target) => ({
const targets: ResolvedTarget[] = raw.targets.map((target) =>
resolveTarget(target, defaults, defaultIntervalMs, defaultTimeoutMs, configDir),
);
return { host, port, dataDir, configDir, maxConcurrentChecks, targets };
}
function validateRuntime(runtime: RuntimeConfig): number {
if (runtime.maxConcurrentChecks === undefined) return DEFAULT_MAX_CONCURRENT_CHECKS;
if (
typeof runtime.maxConcurrentChecks !== "number" ||
!Number.isInteger(runtime.maxConcurrentChecks) ||
runtime.maxConcurrentChecks <= 0
) {
throw new Error("runtime.maxConcurrentChecks 必须为正整数");
}
return runtime.maxConcurrentChecks;
}
function resolveTarget(
target: TargetConfig,
defaults: DefaultsConfig,
defaultIntervalMs: number,
defaultTimeoutMs: number,
configDir: string,
): ResolvedTarget {
const intervalMs = parseDuration(target.interval ?? defaults.interval ?? DEFAULT_INTERVAL);
const timeoutMs = parseDuration(target.timeout ?? defaults.timeout ?? DEFAULT_TIMEOUT);
if (target.type === "http") {
return resolveHttpTarget(target, defaults.http, intervalMs, timeoutMs);
}
return resolveCommandTarget(target, defaults.command, intervalMs, timeoutMs, configDir);
}
function resolveHttpTarget(
target: TargetConfig & { type: "http"; http: HttpTargetConfig },
httpDefaults: HttpDefaultsConfig | undefined,
intervalMs: number,
timeoutMs: number,
): ResolvedHttpTarget {
const maxBodyBytes = parseSize(target.http.maxBodyBytes ?? httpDefaults?.maxBodyBytes ?? DEFAULT_MAX_BODY_BYTES);
return {
type: "http",
name: target.name,
url: target.url,
method: target.method ?? defaultMethod,
headers: { ...defaultHeaders, ...(target.headers ?? {}) },
body: target.body,
intervalMs: parseDuration(target.interval ?? defaults.interval ?? DEFAULT_INTERVAL),
timeoutMs: parseDuration(target.timeout ?? defaults.timeout ?? DEFAULT_TIMEOUT),
expect: target.expect,
}));
http: {
url: target.http.url,
method: target.http.method ?? httpDefaults?.method ?? DEFAULT_HTTP_METHOD,
headers: { ...(httpDefaults?.headers ?? {}), ...(target.http.headers ?? {}) },
body: target.http.body,
maxBodyBytes,
},
intervalMs,
timeoutMs,
expect: target.expect as HttpExpectConfig | undefined,
};
}
return { host, port, dataDir, targets };
function resolveCommandTarget(
target: TargetConfig & { type: "command"; command: CommandTargetConfig },
commandDefaults: CommandDefaultsConfig | undefined,
intervalMs: number,
timeoutMs: number,
configDir: string,
): ResolvedCommandTarget {
const cwd = target.command.cwd ?? commandDefaults?.cwd ?? ".";
const resolvedCwd = resolve(configDir, cwd);
const maxOutputBytes = parseSize(
target.command.maxOutputBytes ?? commandDefaults?.maxOutputBytes ?? DEFAULT_MAX_OUTPUT_BYTES,
);
const env = { ...process.env, ...(target.command.env ?? {}) } as Record<string, string>;
return {
type: "command",
name: target.name,
command: {
exec: target.command.exec,
args: target.command.args ?? [],
cwd: resolvedCwd,
env,
maxOutputBytes,
},
intervalMs,
timeoutMs,
expect: target.expect as import("./types").CommandExpectConfig | undefined,
};
}
function validateConfig(config: ProbeConfig): void {
@@ -68,21 +172,41 @@ function validateConfig(config: ProbeConfig): void {
const names = new Set<string>();
for (let i = 0; i < config.targets.length; i++) {
const target = config.targets[i]!;
const raw = config.targets[i] as unknown as Record<string, unknown>;
if (!target.name || typeof target.name !== "string" || target.name.trim() === "") {
const name = raw["name"];
if (!name || typeof name !== "string" || (name as string).trim() === "") {
throw new Error(`${i + 1} 个 target 缺少 name 字段`);
}
if (!target.url || typeof target.url !== "string" || target.url.trim() === "") {
throw new Error(`target "${target.name}" 缺少 url 字段`);
const type = raw["type"];
if (!type || typeof type !== "string") {
throw new Error(`target "${name}" 缺少 type 字段`);
}
if (names.has(target.name)) {
throw new Error(`target name 重复: "${target.name}"`);
if (!SUPPORTED_TYPES.includes(type as TargetType)) {
throw new Error(`target "${name}" 使用不支持的 type: "${type}",支持: ${SUPPORTED_TYPES.join(", ")}`);
}
names.add(target.name);
if (type === "http") {
const http = raw["http"] as Record<string, unknown> | undefined;
if (!http?.["url"] || typeof http["url"] !== "string" || (http["url"] as string).trim() === "") {
throw new Error(`target "${name}" 缺少 http.url 字段`);
}
}
if (type === "command") {
const cmd = raw["command"] as Record<string, unknown> | undefined;
if (!cmd?.["exec"] || typeof cmd["exec"] !== "string" || (cmd["exec"] as string).trim() === "") {
throw new Error(`target "${name}" 缺少 command.exec 字段`);
}
}
if (names.has(name as string)) {
throw new Error(`target name 重复: "${name}"`);
}
names.add(name as string);
}
}
@@ -90,7 +214,6 @@ const DURATION_REGEX = /^(\d+(?:\.\d+)?)(ms|s|m)$/;
export function parseDuration(value: string): number {
const match = DURATION_REGEX.exec(value);
if (!match) {
throw new Error(`无效的时长格式: "${value}",支持格式如 "30s"、"5m"、"500ms"`);
}