1
0

feat: 配置变量系统与 target id/name 双字段标识

- 新增顶层 variables 段支持 string/number/boolean 字面量
- target 字符串字段支持 、、{...} 转义语法
- 变量解析优先级: variables -> process.env -> 默认值 -> 报错
- 完整引用保留原始类型,部分引用拼接为字符串
- 变量替换在 YAML 解析后、AJV 校验前执行
- 替换仅作用于 targets,跳过 id/type 字段
- target 新增必填 id 字段作为唯一标识,name 改为可选展示名称
- 数据库存储/API/前端全面迁移到 id 标识
- 统一 checker 运行时类型检查为 es-toolkit predicates
- 同步 delta specs 到主 specs,归档 config-variables 变更
This commit is contained in:
2026-05-17 00:37:54 +08:00
parent 366b3211c8
commit 7926514986
53 changed files with 1538 additions and 333 deletions

View File

@@ -1,3 +1,5 @@
import { isNumber, isPlainObject, isString } from "es-toolkit";
import { isArray } from "es-toolkit/compat";
import { dirname, resolve } from "node:path";
import type { ConfigValidationIssue } from "./schema/issues";
@@ -8,6 +10,7 @@ import { issue, throwConfigIssues } from "./schema/issues";
import { asValidatedConfig, type RawProbeConfig } from "./schema/types";
import { validateProbeConfigContract } from "./schema/validate";
import { parseDuration } from "./utils";
import { resolveVariables } from "./variables";
const DEFAULT_HOST = "127.0.0.1";
const DEFAULT_PORT = 3000;
@@ -41,11 +44,17 @@ export async function loadConfig(configPath: string): Promise<ResolvedConfig> {
throw new Error("配置文件内容为空或格式无效");
}
const contractResult = validateProbeConfigContract(parsed, checkerRegistry);
if (contractResult.config === null && !canRunSemanticValidation(parsed)) {
const variableResult = resolveVariables(parsed);
if (variableResult.issues.length > 0) {
throwConfigIssues(dedupeIssues(variableResult.issues));
}
const resolvedVariablesConfig = variableResult.config;
const contractResult = validateProbeConfigContract(resolvedVariablesConfig, checkerRegistry);
if (contractResult.config === null && !canRunSemanticValidation(resolvedVariablesConfig)) {
throwConfigIssues(contractResult.issues);
}
const semanticInput = (contractResult.config ?? parsed) as RawProbeConfig;
const semanticInput = (contractResult.config ?? resolvedVariablesConfig) as RawProbeConfig;
const validationIssues = validateConfig(semanticInput);
const allIssues = [...contractResult.issues, ...validationIssues];
@@ -88,14 +97,14 @@ export async function loadConfig(configPath: string): Promise<ResolvedConfig> {
}
function canRunSemanticValidation(value: unknown): boolean {
return typeof value === "object" && value !== null;
return isPlainObject(value);
}
function dedupeIssues(issues: ConfigValidationIssue[]): ConfigValidationIssue[] {
const seen = new Set<string>();
const result: ConfigValidationIssue[] = [];
for (const item of issues) {
const key = `${item.code}:${item.path}:${item.message}:${item.targetName ?? ""}`;
const key = `${item.code}:${item.path}:${item.message}:${item.targetName ?? ""}:${item.targetId ?? ""}`;
if (seen.has(key)) continue;
seen.add(key);
result.push(item);
@@ -103,16 +112,12 @@ function dedupeIssues(issues: ConfigValidationIssue[]): ConfigValidationIssue[]
return result;
}
function isRecord(value: unknown): value is Record<string, unknown> {
return typeof value === "object" && value !== null && !Array.isArray(value);
}
export { parseDuration } from "./utils";
function resolveMaxConcurrentChecks(runtime: EngineRuntimeConfig): number {
if (runtime.maxConcurrentChecks === undefined) return DEFAULT_MAX_CONCURRENT_CHECKS;
if (
typeof runtime.maxConcurrentChecks !== "number" ||
!isNumber(runtime.maxConcurrentChecks) ||
!Number.isInteger(runtime.maxConcurrentChecks) ||
runtime.maxConcurrentChecks <= 0
)
@@ -145,29 +150,36 @@ function resolveTarget(
function validateConfig(config: RawProbeConfig): ConfigValidationIssue[] {
const issues: ConfigValidationIssue[] = [];
if (!Array.isArray(config.targets) || config.targets.length === 0) {
if (!isArray(config.targets) || config.targets.length === 0) {
issues.push(issue("required", "targets", "配置文件必须包含至少一个 target"));
return issues;
}
const names = new Set<string>();
const ids = new Set<string>();
const supportedTypes = checkerRegistry.supportedTypes;
for (let i = 0; i < config.targets.length; i++) {
const rawTarget = config.targets[i] as unknown;
if (!isRecord(rawTarget)) {
if (!isPlainObject(rawTarget)) {
issues.push(issue("invalid-type", `targets[${i}]`, "必须为对象"));
continue;
}
const raw = rawTarget;
const raw = rawTarget as Record<string, unknown>;
const name = raw["name"];
if (!name || typeof name !== "string" || name.trim() === "") {
issues.push(issue("required", `targets[${i}].name`, "缺少 name 字段"));
const id: unknown = raw["id"];
if (!isString(id) || id.trim() === "") {
issues.push(issue("required", `targets[${i}].id`, "缺少 id 字段"));
continue;
}
const type = raw["type"];
if (!type || typeof type !== "string") {
if (!/^[a-zA-Z0-9][a-zA-Z0-9_-]*$/.test(id)) {
issues.push(issue("invalid-format", `targets[${i}].id`, "id 不符合命名规则", id));
}
const nameValue: unknown = raw["name"];
const name = isString(nameValue) ? nameValue : id;
const type: unknown = raw["type"];
if (!isString(type)) {
issues.push(issue("required", `targets[${i}].type`, "缺少 type 字段", name));
continue;
}
@@ -183,16 +195,16 @@ function validateConfig(config: RawProbeConfig): ConfigValidationIssue[] {
);
}
const group = raw["group"];
if (group !== undefined && typeof group !== "string") {
const group: unknown = raw["group"];
if (group !== undefined && !isString(group)) {
issues.push(issue("invalid-type", `targets[${i}].group`, "必须为字符串", name));
}
if (names.has(name)) {
issues.push(issue("duplicate-name", `targets[${i}].name`, `target name 重复: "${name}"`, name));
if (ids.has(id)) {
issues.push(issue("duplicate-id", `targets[${i}].id`, `target id 重复: "${id}"`, name));
}
names.add(name);
ids.add(id);
}
for (const checker of checkerRegistry.definitions) {
@@ -202,22 +214,29 @@ function validateConfig(config: RawProbeConfig): ConfigValidationIssue[] {
validateDurationValue(config.defaults?.interval, "defaults.interval", issues);
validateDurationValue(config.defaults?.timeout, "defaults.timeout", issues);
validateDurationValue(
typeof config.runtime?.retention === "string" ? config.runtime.retention : undefined,
isString(config.runtime?.retention) ? config.runtime.retention : undefined,
"runtime.retention",
issues,
);
for (let i = 0; i < config.targets.length; i++) {
const target = config.targets[i] as unknown;
if (!isRecord(target)) continue;
const targetName = typeof target["name"] === "string" ? target["name"] : undefined;
if (!isPlainObject(target)) continue;
const targetRecord = target as Record<string, unknown>;
const targetNameValue: unknown = targetRecord["name"];
const targetIdValue: unknown = targetRecord["id"];
const targetName = isString(targetNameValue)
? targetNameValue
: isString(targetIdValue)
? targetIdValue
: undefined;
validateDurationValue(
typeof target["interval"] === "string" ? target["interval"] : undefined,
isString(targetRecord["interval"]) ? targetRecord["interval"] : undefined,
`targets[${i}].interval`,
issues,
targetName,
);
validateDurationValue(
typeof target["timeout"] === "string" ? target["timeout"] : undefined,
isString(targetRecord["timeout"]) ? targetRecord["timeout"] : undefined,
`targets[${i}].timeout`,
issues,
targetName,