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,

View File

@@ -12,7 +12,7 @@ export class ProbeEngine {
private retentionMs: number;
private semaphore: Semaphore;
private store: ProbeStore;
private targetNameToId = new Map<string, number>();
private targetIds = new Set<string>();
private targets: ResolvedTargetBase[];
private timers: Array<ReturnType<typeof setInterval>> = [];
@@ -77,7 +77,7 @@ export class ProbeEngine {
failure: errorFailure("internal", "engine", formatReason(result.reason)),
matched: false,
statusDetail: null,
targetName: target.name,
targetId: target.id,
timestamp: new Date().toISOString(),
});
}
@@ -85,9 +85,9 @@ export class ProbeEngine {
}
private refreshCache(): void {
this.targetNameToId.clear();
this.targetIds.clear();
for (const target of this.store.getTargets()) {
this.targetNameToId.set(target.name, target.id);
this.targetIds.add(target.id);
}
}
@@ -104,15 +104,14 @@ export class ProbeEngine {
}
private writeResult(result: CheckResult): void {
const targetId = this.targetNameToId.get(result.targetName);
if (!targetId) return;
if (!this.targetIds.has(result.targetId)) return;
this.store.insertCheckResult({
durationMs: result.durationMs,
failure: result.failure,
matched: result.matched,
statusDetail: result.statusDetail,
targetId,
targetId: result.targetId,
timestamp: result.timestamp,
});
}

View File

@@ -1,3 +1,6 @@
import { isString } from "es-toolkit";
import { isObject } from "es-toolkit/compat";
import type { CheckFailure } from "../types";
export function errorFailure(phase: CheckFailure["phase"], path: string, message: string): CheckFailure {
@@ -29,7 +32,7 @@ export function mismatchFailure(
export function truncateActual(value: unknown, maxLen = 200): unknown {
if (value === undefined || value === null) return value;
const str = typeof value === "string" ? value : typeof value === "object" ? JSON.stringify(value) : undefined;
const str = isString(value) ? value : isObject(value) ? JSON.stringify(value) : undefined;
if (str === undefined) return value;
if (str.length <= maxLen) return value;
return `${str.slice(0, maxLen)}…(共 ${str.length} 字符)`;

View File

@@ -1,4 +1,5 @@
import { isEmptyObject, isEqual, isNil, isPlainObject } from "es-toolkit";
import { isArray } from "es-toolkit/compat";
import type { ExpectOperator, ExpectValue } from "../types";
@@ -14,7 +15,7 @@ export function applyOperator(actual: unknown, op: ExpectOperator): boolean {
break;
case "empty": {
const isEmpty =
isNil(actual) || actual === "" || (Array.isArray(actual) && actual.length === 0) || isEmptyObject(actual);
isNil(actual) || actual === "" || (isArray(actual) && actual.length === 0) || isEmptyObject(actual);
if (expected !== isEmpty) return false;
break;
}
@@ -67,7 +68,7 @@ export function evaluateJsonPath(json: unknown, path: string): unknown {
if (bracketMatch) {
current = (current as Record<string, unknown>)?.[bracketMatch[1]!];
const idx = parseInt(bracketMatch[2]!, 10);
if (!Array.isArray(current) || idx >= current.length) return undefined;
if (!isArray(current) || idx >= current.length) return undefined;
current = current[idx];
} else {
if (current === null || current === undefined) return undefined;

View File

@@ -1,3 +1,6 @@
import { isBoolean, isNumber, isPlainObject, isString } from "es-toolkit";
import { isArray } from "es-toolkit/compat";
import type { ConfigValidationIssue } from "../schema/issues";
import type { JsonValue } from "../types";
@@ -9,17 +12,17 @@ const OPERATOR_KEY_SET = new Set<string>(OperatorKeys);
export function isJsonValue(value: unknown): value is JsonValue {
if (value === null) return true;
if (typeof value === "string" || typeof value === "boolean") return true;
if (typeof value === "number") return Number.isFinite(value);
if (Array.isArray(value)) return value.every(isJsonValue);
if (typeof value === "object") {
return Object.values(value as Record<string, unknown>).every(isJsonValue);
if (isString(value) || isBoolean(value)) return true;
if (isNumber(value)) return Number.isFinite(value);
if (isArray(value)) return value.every(isJsonValue);
if (isPlainObject(value)) {
return Object.values(value).every(isJsonValue);
}
return false;
}
export function isPlainRecord(value: unknown): value is Record<string, unknown> {
return typeof value === "object" && value !== null && !Array.isArray(value);
return isPlainObject(value);
}
export function validateOperatorObject(
@@ -54,21 +57,21 @@ export function validateOperatorValue(
): ConfigValidationIssue[] {
switch (key) {
case "contains":
return typeof value === "string" ? [] : [issue("invalid-type", path, "必须为字符串", targetName)];
return isString(value) ? [] : [issue("invalid-type", path, "必须为字符串", targetName)];
case "empty":
case "exists":
return typeof value === "boolean" ? [] : [issue("invalid-type", path, "必须为布尔值", targetName)];
return isBoolean(value) ? [] : [issue("invalid-type", path, "必须为布尔值", targetName)];
case "equals":
return isJsonValue(value) ? [] : [issue("invalid-type", path, "必须为 JSON value", targetName)];
case "gt":
case "gte":
case "lt":
case "lte":
return typeof value === "number" && Number.isFinite(value)
return isNumber(value) && Number.isFinite(value)
? []
: [issue("invalid-type", path, "必须为有限数字", targetName)];
case "match":
if (typeof value !== "string") return [issue("invalid-type", path, "必须为字符串", targetName)];
if (!isString(value)) return [issue("invalid-type", path, "必须为字符串", targetName)];
try {
new RegExp(value);
} catch {

View File

@@ -41,7 +41,7 @@ export class CommandChecker implements CheckerDefinition<ResolvedCommandTarget>
failure: errorFailure("exitCode", "spawn", isError(error) ? error.message : String(error)),
matched: false,
statusDetail: null,
targetName: t.name,
targetId: t.id,
timestamp,
};
}
@@ -74,7 +74,7 @@ export class CommandChecker implements CheckerDefinition<ResolvedCommandTarget>
failure: errorFailure("exitCode", "execution", "输出读取失败"),
matched: false,
statusDetail: null,
targetName: t.name,
targetId: t.id,
timestamp,
};
}
@@ -90,7 +90,7 @@ export class CommandChecker implements CheckerDefinition<ResolvedCommandTarget>
failure: errorFailure("exitCode", "output", `输出超过限制 ${t.cmd.maxOutputBytes} 字节`),
matched: false,
statusDetail: `exitCode=${exitCode}`,
targetName: t.name,
targetId: t.id,
timestamp,
};
}
@@ -101,7 +101,7 @@ export class CommandChecker implements CheckerDefinition<ResolvedCommandTarget>
failure: errorFailure("exitCode", "timeout", `命令执行超时 (${t.timeoutMs}ms)`),
matched: false,
statusDetail: null,
targetName: t.name,
targetId: t.id,
timestamp,
};
}
@@ -113,7 +113,7 @@ export class CommandChecker implements CheckerDefinition<ResolvedCommandTarget>
failure: exitCodeResult.failure,
matched: false,
statusDetail: `exitCode=${exitCode}`,
targetName: t.name,
targetId: t.id,
timestamp,
};
}
@@ -125,7 +125,7 @@ export class CommandChecker implements CheckerDefinition<ResolvedCommandTarget>
failure: durationResult.failure,
matched: false,
statusDetail: `exitCode=${exitCode}`,
targetName: t.name,
targetId: t.id,
timestamp,
};
}
@@ -138,7 +138,7 @@ export class CommandChecker implements CheckerDefinition<ResolvedCommandTarget>
failure: stdoutResult.failure,
matched: false,
statusDetail: `exitCode=${exitCode}`,
targetName: t.name,
targetId: t.id,
timestamp,
};
}
@@ -152,7 +152,7 @@ export class CommandChecker implements CheckerDefinition<ResolvedCommandTarget>
failure: stderrResult.failure,
matched: false,
statusDetail: `exitCode=${exitCode}`,
targetName: t.name,
targetId: t.id,
timestamp,
};
}
@@ -163,7 +163,7 @@ export class CommandChecker implements CheckerDefinition<ResolvedCommandTarget>
failure: null,
matched: true,
statusDetail: `exitCode=${exitCode}`,
targetName: t.name,
targetId: t.id,
timestamp,
};
}
@@ -189,8 +189,9 @@ export class CommandChecker implements CheckerDefinition<ResolvedCommandTarget>
},
expect: target.expect as CommandExpectConfig | undefined,
group: target.group ?? "default",
id: t.id,
intervalMs: context.defaultIntervalMs,
name: t.name,
name: t.name ?? t.id,
timeoutMs: context.defaultTimeoutMs,
type: "cmd",
} satisfies ResolvedCommandTarget;

View File

@@ -1,3 +1,6 @@
import { isNumber, isPlainObject, isString } from "es-toolkit";
import { isArray } from "es-toolkit/compat";
import type { ConfigValidationIssue } from "../../schema/issues";
import type { CheckerValidationInput } from "../types";
@@ -7,7 +10,8 @@ import { parseSize } from "../../utils";
export function validateCommandConfig(input: CheckerValidationInput): ConfigValidationIssue[] {
const issues: ConfigValidationIssue[] = [];
const defaults = isRecord(input.defaults) && isRecord(input.defaults["cmd"]) ? input.defaults["cmd"] : undefined;
const defaults =
isPlainObject(input.defaults) && isPlainObject(input.defaults["cmd"]) ? input.defaults["cmd"] : undefined;
if (isSizeInput(defaults?.["maxOutputBytes"])) {
issues.push(...validateSizeValue(defaults["maxOutputBytes"], "defaults.cmd.maxOutputBytes"));
@@ -15,7 +19,7 @@ export function validateCommandConfig(input: CheckerValidationInput): ConfigVali
for (let i = 0; i < input.targets.length; i++) {
const target = input.targets[i] as unknown;
if (!isRecord(target)) continue;
if (!isPlainObject(target)) continue;
if (target["type"] !== "cmd") continue;
issues.push(...validateCommandTarget(target, `targets[${i}]`));
}
@@ -24,25 +28,22 @@ export function validateCommandConfig(input: CheckerValidationInput): ConfigVali
}
function getTargetName(target: Record<string, unknown>): string | undefined {
return typeof target["name"] === "string" ? target["name"] : undefined;
if (isString(target["name"])) return target["name"];
return isString(target["id"]) ? target["id"] : undefined;
}
function isNonNegativeFiniteNumber(value: unknown): boolean {
return typeof value === "number" && Number.isFinite(value) && value >= 0;
}
function isRecord(value: unknown): value is Record<string, unknown> {
return typeof value === "object" && value !== null && !Array.isArray(value);
return isNumber(value) && Number.isFinite(value) && value >= 0;
}
function isSizeInput(value: unknown): value is number | string {
return typeof value === "number" || typeof value === "string";
return isNumber(value) || isString(value);
}
function validateCommandExpect(target: Record<string, unknown>, path: string): ConfigValidationIssue[] {
const targetName = getTargetName(target);
const expect = target["expect"];
if (expect === undefined || expect === null || !isRecord(expect)) return [];
if (expect === undefined || expect === null || !isPlainObject(expect)) return [];
const issues: ConfigValidationIssue[] = [];
const expectPath = joinPath(path, "expect");
if (expect["stdout"] !== undefined) {
@@ -61,12 +62,12 @@ function validateCommandTarget(target: Record<string, unknown>, path: string): C
const issues: ConfigValidationIssue[] = [];
const targetName = getTargetName(target);
const cmd = target["cmd"];
if (!isRecord(cmd)) {
if (!isPlainObject(cmd)) {
issues.push(issue("required", joinPath(path, "cmd"), "缺少 cmd.exec 字段", targetName));
issues.push(...validateCommandExpect(target, path));
return issues;
}
if (typeof cmd["exec"] !== "string" || cmd["exec"].trim() === "") {
if (!isString(cmd["exec"]) || cmd["exec"].trim() === "") {
issues.push(issue("required", joinPath(joinPath(path, "cmd"), "exec"), "缺少 cmd.exec 字段", targetName));
}
if (isSizeInput(cmd["maxOutputBytes"])) {
@@ -88,6 +89,6 @@ function validateSizeValue(value: number | string, path: string, targetName?: st
}
function validateTextRules(rules: unknown, path: string, targetName?: string): ConfigValidationIssue[] {
if (!Array.isArray(rules)) return [issue("invalid-type", path, "必须为数组", targetName)];
if (!isArray(rules)) return [issue("invalid-type", path, "必须为数组", targetName)];
return rules.flatMap((rule, index) => validateOperatorObject(rule, `${path}[${index}]`, targetName));
}

View File

@@ -1,5 +1,6 @@
import { SQL } from "bun";
import { isError } from "es-toolkit";
import { isArray } from "es-toolkit/compat";
import type { CheckResult, RawTargetConfig } from "../../types";
import type { CheckerContext, CheckerDefinition, CheckerValidationInput, ResolveContext } from "../types";
@@ -50,7 +51,7 @@ export class DbChecker implements CheckerDefinition<ResolvedDbTarget> {
failure: errorFailure("connect", "connect", isError(error) ? error.message : String(error)),
matched: false,
statusDetail: null,
targetName: t.name,
targetId: t.id,
timestamp,
};
}
@@ -65,7 +66,7 @@ export class DbChecker implements CheckerDefinition<ResolvedDbTarget> {
failure: durationResult.failure,
matched: false,
statusDetail: "connected",
targetName: t.name,
targetId: t.id,
timestamp,
};
}
@@ -74,7 +75,7 @@ export class DbChecker implements CheckerDefinition<ResolvedDbTarget> {
failure: null,
matched: true,
statusDetail: "connected",
targetName: t.name,
targetId: t.id,
timestamp,
};
}
@@ -90,7 +91,7 @@ export class DbChecker implements CheckerDefinition<ResolvedDbTarget> {
failure: errorFailure("query", "query", isError(error) ? error.message : String(error)),
matched: false,
statusDetail: null,
targetName: t.name,
targetId: t.id,
timestamp,
};
}
@@ -104,7 +105,7 @@ export class DbChecker implements CheckerDefinition<ResolvedDbTarget> {
failure: errorFailure("query", "timeout", `查询超时 (${t.timeoutMs}ms)`),
matched: false,
statusDetail: null,
targetName: t.name,
targetId: t.id,
timestamp,
};
}
@@ -116,8 +117,8 @@ export class DbChecker implements CheckerDefinition<ResolvedDbTarget> {
durationMs,
failure: durationResult.failure,
matched: false,
statusDetail: `${Array.isArray(rows) ? rows.length : 0} rows`,
targetName: t.name,
statusDetail: `${isArray(rows) ? rows.length : 0} rows`,
targetId: t.id,
timestamp,
};
}
@@ -130,8 +131,8 @@ export class DbChecker implements CheckerDefinition<ResolvedDbTarget> {
durationMs,
failure: rowCountResult.failure,
matched: false,
statusDetail: `${Array.isArray(rows) ? rows.length : 0} rows`,
targetName: t.name,
statusDetail: `${isArray(rows) ? rows.length : 0} rows`,
targetId: t.id,
timestamp,
};
}
@@ -145,8 +146,8 @@ export class DbChecker implements CheckerDefinition<ResolvedDbTarget> {
durationMs,
failure: rowsResult.failure,
matched: false,
statusDetail: `${Array.isArray(rows) ? rows.length : 0} rows`,
targetName: t.name,
statusDetail: `${isArray(rows) ? rows.length : 0} rows`,
targetId: t.id,
timestamp,
};
}
@@ -156,8 +157,8 @@ export class DbChecker implements CheckerDefinition<ResolvedDbTarget> {
durationMs,
failure: null,
matched: true,
statusDetail: `${Array.isArray(rows) ? rows.length : 0} rows`,
targetName: t.name,
statusDetail: `${isArray(rows) ? rows.length : 0} rows`,
targetId: t.id,
timestamp,
};
} finally {
@@ -181,8 +182,9 @@ export class DbChecker implements CheckerDefinition<ResolvedDbTarget> {
},
expect: target.expect as DbExpectConfig | undefined,
group: target.group ?? "default",
id: t.id,
intervalMs: context.defaultIntervalMs,
name: t.name,
name: t.name ?? t.id,
timeoutMs: context.defaultTimeoutMs,
type: "db",
} satisfies ResolvedDbTarget;

View File

@@ -1,3 +1,6 @@
import { isPlainObject } from "es-toolkit";
import { isArray } from "es-toolkit/compat";
import type { ExpectResult } from "../../expect/types";
import type { ExpectOperator, ExpectValue } from "../../types";
@@ -5,7 +8,7 @@ import { mismatchFailure } from "../../expect/failure";
import { checkExpectValue } from "../../expect/operator";
export function checkRowCount(rows: unknown, op: ExpectOperator): ExpectResult {
const actual = Array.isArray(rows) ? rows.length : 0;
const actual = isArray(rows) ? rows.length : 0;
const matched = checkExpectValue(actual, op);
if (!matched) {
return {
@@ -17,7 +20,7 @@ export function checkRowCount(rows: unknown, op: ExpectOperator): ExpectResult {
}
export function checkRows(rows: unknown, rules: Array<Record<string, ExpectValue>>): ExpectResult {
if (!Array.isArray(rows)) {
if (!isArray(rows)) {
return {
failure: mismatchFailure("row", "rows", rules, rows, "查询结果不是数组"),
matched: false,
@@ -34,7 +37,7 @@ export function checkRows(rows: unknown, rules: Array<Record<string, ExpectValue
}
const row = rows[i]! as null | Record<string, unknown> | undefined;
if (!row || typeof row !== "object" || Array.isArray(row)) {
if (!isPlainObject(row)) {
return {
failure: mismatchFailure("row", `rows[${i}]`, "object", row, `${i + 1} 行不是对象`),
matched: false,

View File

@@ -1,3 +1,6 @@
import { isNumber, isPlainObject, isString } from "es-toolkit";
import { isArray } from "es-toolkit/compat";
import type { ConfigValidationIssue } from "../../schema/issues";
import type { CheckerValidationInput } from "../types";
@@ -10,7 +13,7 @@ export function validateDbConfig(input: CheckerValidationInput): ConfigValidatio
for (let i = 0; i < input.targets.length; i++) {
const target = input.targets[i] as unknown;
if (!isRecord(target)) continue;
if (!isPlainObject(target)) continue;
if (target["type"] !== "db") continue;
issues.push(...validateDbTarget(target, `targets[${i}]`));
}
@@ -22,28 +25,29 @@ function collectRowOperators(rows: unknown[], path: string, targetName?: string)
const issues: ConfigValidationIssue[] = [];
for (let i = 0; i < rows.length; i++) {
const row = rows[i]!;
if (!isRecord(row)) {
if (!isPlainObject(row)) {
issues.push(issue("invalid-type", `${path}[${i}]`, "必须为对象", targetName));
continue;
}
for (const [col, value] of Object.entries(row)) {
const colPath = `${path}[${i}].${col}`;
if (isRecord(value) && Object.keys(value).some((k) => k === "match")) {
if (isPlainObject(value) && Object.keys(value).some((k) => k === "match")) {
// 检查 match 正则
const match = value["match"];
if (typeof match === "string") {
const valueRecord = value as Record<string, unknown>;
const match: unknown = valueRecord["match"];
if (isString(match)) {
try {
new RegExp(match);
} catch {
issues.push(issue("invalid-regex", colPath, "正则不合法", targetName));
}
if (typeof match === "string" && isUnsafeRegex(match)) {
if (isUnsafeRegex(match)) {
issues.push(issue("unsafe-regex", colPath, "正则存在 ReDoS 风险", targetName));
}
}
}
// 校验 operator 对象
if (isRecord(value)) {
if (isPlainObject(value)) {
issues.push(...validateOperatorObject(value, colPath, targetName, { requireAtLeastOne: false }));
}
}
@@ -52,21 +56,18 @@ function collectRowOperators(rows: unknown[], path: string, targetName?: string)
}
function getTargetName(target: Record<string, unknown>): string | undefined {
return typeof target["name"] === "string" ? target["name"] : undefined;
if (isString(target["name"])) return target["name"];
return isString(target["id"]) ? target["id"] : undefined;
}
function isNonNegativeFiniteNumber(value: unknown): boolean {
return typeof value === "number" && Number.isFinite(value) && value >= 0;
}
function isRecord(value: unknown): value is Record<string, unknown> {
return typeof value === "object" && value !== null && !Array.isArray(value);
return isNumber(value) && Number.isFinite(value) && value >= 0;
}
function validateDbExpect(target: Record<string, unknown>, path: string): ConfigValidationIssue[] {
const targetName = getTargetName(target);
const expect = target["expect"];
if (expect === undefined || expect === null || !isRecord(expect)) return [];
if (expect === undefined || expect === null || !isPlainObject(expect)) return [];
const issues: ConfigValidationIssue[] = [];
const expectPath = joinPath(path, "expect");
@@ -79,7 +80,7 @@ function validateDbExpect(target: Record<string, unknown>, path: string): Config
}
if (expect["rows"] !== undefined) {
if (!Array.isArray(expect["rows"])) {
if (!isArray(expect["rows"])) {
issues.push(issue("invalid-type", joinPath(expectPath, "rows"), "必须为数组", targetName));
} else {
issues.push(...collectRowOperators(expect["rows"], joinPath(expectPath, "rows"), targetName));
@@ -102,20 +103,20 @@ function validateDbTarget(target: Record<string, unknown>, path: string): Config
const targetName = getTargetName(target);
const db = target["db"];
if (!isRecord(db)) {
if (!isPlainObject(db)) {
issues.push(issue("required", joinPath(path, "db"), "缺少 db.url 字段", targetName));
issues.push(...validateDbExpect(target, path));
return issues;
}
// url 必填
if (typeof db["url"] !== "string" || db["url"].trim() === "") {
if (!isString(db["url"]) || db["url"].trim() === "") {
issues.push(issue("required", joinPath(joinPath(path, "db"), "url"), "缺少 db.url 字段", targetName));
}
// query 可选但不能为空字符串
if (db["query"] !== undefined) {
if (typeof db["query"] !== "string") {
if (!isString(db["query"])) {
issues.push(issue("invalid-type", joinPath(joinPath(path, "db"), "query"), "必须为字符串", targetName));
} else if (db["query"].trim() === "") {
issues.push(

View File

@@ -1,5 +1,6 @@
import { DOMParser } from "@xmldom/xmldom";
import * as cheerio from "cheerio";
import { isArray } from "es-toolkit/compat";
import * as xpath from "xpath";
import type { ExpectResult } from "../../expect/types";
@@ -177,7 +178,7 @@ function checkXpathRule(body: string, rule: XpathRule, rulePath: string): Expect
}
const nodes = xpath.select(path, doc as unknown as Node);
if (!nodes || !Array.isArray(nodes) || nodes.length === 0) {
if (!nodes || !isArray(nodes) || nodes.length === 0) {
return {
failure: mismatchFailure("body", fullPath, "node found", "no match", `xpath ${path} not found`),
matched: false,

View File

@@ -1,4 +1,5 @@
import { isError } from "es-toolkit";
import { isObject } from "es-toolkit/compat";
import type { CheckResult, RawTargetConfig } from "../../types";
import type { CheckerContext, CheckerDefinition, CheckerValidationInput, ResolveContext } from "../types";
@@ -95,7 +96,7 @@ export class HttpChecker implements CheckerDefinition<ResolvedHttpTarget> {
),
matched: false,
statusDetail: null,
targetName: t.name,
targetId: t.id,
timestamp,
};
}
@@ -122,8 +123,9 @@ export class HttpChecker implements CheckerDefinition<ResolvedHttpTarget> {
method,
url: t.http.url,
},
id: t.id,
intervalMs: context.defaultIntervalMs,
name: t.name,
name: t.name ?? t.id,
timeoutMs: context.defaultTimeoutMs,
type: "http",
} satisfies ResolvedHttpTarget;
@@ -154,10 +156,7 @@ function buildRedirectInit(init: RequestInit, statusCode: number, fromUrl: strin
const method = init.method?.toUpperCase();
if (statusCode === 303 || ((statusCode === 301 || statusCode === 302) && method === "POST")) {
const headers =
typeof init.headers === "object" && init.headers !== null
? { ...(init.headers as Record<string, string>) }
: undefined;
const headers = isObject(init.headers) ? { ...(init.headers as Record<string, string>) } : undefined;
if (headers) {
for (const key of Object.keys(headers)) {
const lower = key.toLowerCase();
@@ -172,7 +171,7 @@ function buildRedirectInit(init: RequestInit, statusCode: number, fromUrl: strin
try {
const fromOrigin = new URL(fromUrl).origin;
const toOrigin = new URL(toUrl).origin;
if (fromOrigin !== toOrigin && newInit.headers && typeof newInit.headers === "object") {
if (fromOrigin !== toOrigin && isObject(newInit.headers)) {
const headers = { ...(newInit.headers as Record<string, string>) };
for (const key of Object.keys(headers)) {
if (SENSITIVE_HEADERS.has(key.toLowerCase())) {
@@ -264,7 +263,7 @@ function makeResult(
failure,
matched: failure === null,
statusDetail: `HTTP ${statusCode}`,
targetName: t.name,
targetId: t.id,
timestamp,
};
}

View File

@@ -1,3 +1,5 @@
import { isNumber, isString } from "es-toolkit";
import type { ExpectResult } from "../../expect/types";
import type { HeaderExpect } from "./types";
@@ -14,7 +16,7 @@ export function checkHeaders(
const actualValue = headers[key.toLowerCase()];
const path = `headers.${key}`;
if (typeof expected === "string") {
if (isString(expected)) {
if (actualValue !== expected) {
return {
failure: mismatchFailure("headers", path, expected, actualValue, `header ${key} mismatch`),
@@ -45,7 +47,7 @@ export function checkHeaders(
export function checkStatus(statusCode: number, allowed: Array<number | string>): ExpectResult {
const matched = allowed.some((pattern) => {
if (typeof pattern === "number") return statusCode === pattern;
if (isNumber(pattern)) return statusCode === pattern;
const base = parseInt(pattern[0]!, 10) * 100;
return statusCode >= base && statusCode < base + 100;
});

View File

@@ -1,4 +1,6 @@
import { DOMParser } from "@xmldom/xmldom";
import { isNumber, isString } from "es-toolkit";
import { isArray } from "es-toolkit/compat";
import * as xpath from "xpath";
import type { ConfigValidationIssue } from "../../schema/issues";
@@ -14,7 +16,7 @@ const ALLOWED_PROTOCOLS = new Set(["http:", "https:"]);
const OPERATOR_KEY_SET = new Set<string>(OperatorKeys);
export function validateBodyRules(body: unknown, path: string, targetName?: string): ConfigValidationIssue[] {
if (!Array.isArray(body)) return [issue("invalid-type", path, "必须为数组", targetName)];
if (!isArray(body)) return [issue("invalid-type", path, "必须为数组", targetName)];
return body.flatMap((rule, index) => validateSingleBodyRule(rule, `${path}[${index}]`, targetName));
}
@@ -75,24 +77,25 @@ function collectOperatorObject(
}
function getTargetName(target: Record<string, unknown>): string | undefined {
return typeof target["name"] === "string" ? target["name"] : undefined;
if (isString(target["name"])) return target["name"];
return isString(target["id"]) ? target["id"] : undefined;
}
function isNonNegativeFiniteNumber(value: unknown): boolean {
return typeof value === "number" && Number.isFinite(value) && value >= 0;
return isNumber(value) && Number.isFinite(value) && value >= 0;
}
function isSizeInput(value: unknown): value is number | string {
return typeof value === "number" || typeof value === "string";
return isNumber(value) || isString(value);
}
function validateCssRule(rule: unknown, path: string, targetName?: string): ConfigValidationIssue[] {
if (!isPlainRecord(rule)) return [issue("invalid-type", path, "必须为对象", targetName)];
const issues: ConfigValidationIssue[] = [];
if (typeof rule["selector"] !== "string" || rule["selector"].trim() === "") {
if (!isString(rule["selector"]) || rule["selector"].trim() === "") {
issues.push(issue("invalid-type", joinPath(path, "selector"), "必须为非空字符串", targetName));
}
if ("attr" in rule && typeof rule["attr"] !== "string") {
if ("attr" in rule && !isString(rule["attr"])) {
issues.push(issue("invalid-type", joinPath(path, "attr"), "必须为字符串", targetName));
}
const result = collectOperatorObject(rule, new Set(["attr", "selector"]), path, targetName);
@@ -112,7 +115,7 @@ function validateHttpExpect(target: Record<string, unknown>, path: string): Conf
if (isPlainRecord(expect["headers"])) {
for (const [key, value] of Object.entries(expect["headers"])) {
if (typeof value === "string") continue;
if (isString(value)) continue;
issues.push(...validateOperatorObject(value, joinPath(joinPath(expectPath, "headers"), key), targetName));
}
}
@@ -121,7 +124,7 @@ function validateHttpExpect(target: Record<string, unknown>, path: string): Conf
issues.push(...validateBodyRules(expect["body"], joinPath(expectPath, "body"), targetName));
}
if (Array.isArray(expect["status"])) {
if (isArray(expect["status"])) {
issues.push(...validateStatusValues(expect["status"], joinPath(expectPath, "status"), targetName));
}
@@ -141,7 +144,7 @@ function validateHttpTarget(target: Record<string, unknown>, path: string): Conf
issues.push(...validateHttpExpect(target, path));
return issues;
}
if (typeof http["url"] !== "string" || http["url"].trim() === "") {
if (!isString(http["url"]) || http["url"].trim() === "") {
issues.push(issue("required", joinPath(joinPath(path, "http"), "url"), "缺少 http.url 字段", targetName));
} else {
try {
@@ -172,7 +175,7 @@ function validateHttpTarget(target: Record<string, unknown>, path: string): Conf
function validateJsonRule(rule: unknown, path: string, targetName?: string): ConfigValidationIssue[] {
if (!isPlainRecord(rule)) return [issue("invalid-type", path, "必须为对象", targetName)];
const issues: ConfigValidationIssue[] = [];
if (typeof rule["path"] !== "string") {
if (!isString(rule["path"])) {
issues.push(issue("invalid-type", joinPath(path, "path"), "必须为字符串", targetName));
} else {
issues.push(...validateJsonPath(rule["path"], path, targetName));
@@ -186,7 +189,7 @@ function validateJsonRule(rule: unknown, path: string, targetName?: string): Con
}
function validateRegexRule(rule: unknown, path: string, targetName?: string): ConfigValidationIssue[] {
if (typeof rule !== "string") return [issue("invalid-type", path, "必须为字符串", targetName)];
if (!isString(rule)) return [issue("invalid-type", path, "必须为字符串", targetName)];
try {
new RegExp(rule);
} catch {
@@ -210,7 +213,7 @@ function validateSingleBodyRule(rule: unknown, path: string, targetName?: string
switch (ruleType) {
case "contains":
return typeof rule["contains"] === "string"
return isString(rule["contains"])
? []
: [issue("invalid-type", joinPath(path, "contains"), "必须为字符串", targetName)];
case "css":
@@ -238,13 +241,13 @@ function validateStatusValues(values: unknown[], path: string, targetName?: stri
for (let i = 0; i < values.length; i++) {
const value = values[i];
const itemPath = `${path}[${i}]`;
if (typeof value === "number") {
if (isNumber(value)) {
if (!Number.isInteger(value) || value < 100 || value > 599) {
issues.push(issue("invalid-status", itemPath, "status 数字必须为 100-599 之间的整数", targetName));
}
continue;
}
if (typeof value === "string") {
if (isString(value)) {
if (!/^[1-5]xx$/.test(value)) {
issues.push(issue("invalid-status", itemPath, "status 模式必须为 1xx 到 5xx", targetName));
}
@@ -258,7 +261,7 @@ function validateStatusValues(values: unknown[], path: string, targetName?: stri
function validateXpathRule(rule: unknown, path: string, targetName?: string): ConfigValidationIssue[] {
if (!isPlainRecord(rule)) return [issue("invalid-type", path, "必须为对象", targetName)];
const issues: ConfigValidationIssue[] = [];
if (typeof rule["path"] !== "string" || rule["path"].trim() === "") {
if (!isString(rule["path"]) || rule["path"].trim() === "") {
issues.push(issue("invalid-type", joinPath(path, "path"), "必须为非空字符串", targetName));
} else {
try {

View File

@@ -4,7 +4,7 @@ import { Type } from "@sinclair/typebox";
import type { CheckerDefinition } from "../runner/types";
import { durationSchema } from "./fragments";
import { durationSchema, variableValueSchema } from "./fragments";
export function createExternalProbeConfigSchema(checkers: CheckerDefinition[]): Record<string, unknown> {
return {
@@ -41,6 +41,7 @@ export function createProbeConfigSchema(checkers: CheckerDefinition[], external
targets: Type.Array(external ? createExternalTargetSchema(checkers) : createBaseTargetSchema(checkers), {
minItems: 1,
}),
variables: Type.Optional(Type.Record(Type.String({ pattern: "^[a-zA-Z_][a-zA-Z0-9_]*$" }), variableValueSchema)),
},
{ additionalProperties: false },
);
@@ -50,8 +51,9 @@ export function createTargetSchema(checker: CheckerDefinition): TSchema {
const properties: Record<string, TSchema> = {
expect: Type.Optional(checker.schemas.expect),
group: Type.Optional(Type.String()),
id: Type.String({ minLength: 1 }),
interval: Type.Optional(durationSchema),
name: Type.String({ minLength: 1 }),
name: Type.Optional(Type.String({ minLength: 1 })),
timeout: Type.Optional(durationSchema),
type: Type.Literal(checker.type),
};
@@ -67,8 +69,9 @@ function createBaseTargetSchema(checkers: CheckerDefinition[]): TSchema {
return Type.Object(
{
group: Type.Optional(Type.String()),
id: Type.String({ minLength: 1 }),
interval: Type.Optional(durationSchema),
name: Type.String({ minLength: 1 }),
name: Type.Optional(Type.String({ minLength: 1 })),
timeout: Type.Optional(durationSchema),
type: Type.Union(checkers.map((checker) => Type.Literal(checker.type)) as unknown as [TSchema, ...TSchema[]]),
},

View File

@@ -29,6 +29,8 @@ export const jsonValueSchema = Type.Unsafe<JsonValue>({
export const sizeSchema = Type.Union([Type.String(), Type.Integer({ minimum: 0 })]);
export const variableValueSchema = Type.Union([Type.String(), Type.Number(), Type.Boolean()]);
export const statusCodePatternSchema = Type.Union([
Type.Integer({ maximum: 599, minimum: 100 }),
Type.String({ pattern: "^[1-5]xx$" }),

View File

@@ -2,6 +2,7 @@ export interface ConfigValidationIssue {
code: string;
message: string;
path: string;
targetId?: string;
targetName?: string;
}
@@ -9,8 +10,20 @@ export function formatConfigIssues(issues: ConfigValidationIssue[]): string {
return issues.map(formatConfigIssue).join("\n");
}
export function issue(code: string, path: string, message: string, targetName?: string): ConfigValidationIssue {
return targetName === undefined ? { code, message, path } : { code, message, path, targetName };
export function issue(
code: string,
path: string,
message: string,
targetName?: string,
targetId?: string,
): ConfigValidationIssue {
return {
code,
message,
path,
...(targetName === undefined ? {} : { targetName }),
...(targetId === undefined ? {} : { targetId }),
};
}
export function joinPath(base: string, key: string): string {
@@ -28,6 +41,15 @@ export function throwConfigIssues(issues: ConfigValidationIssue[]): never {
}
function formatConfigIssue(issue: ConfigValidationIssue): string {
if (issue.targetId) {
const path = issue.path.replace(/^targets\[\d+\]\.?/, "");
const renderedPath = path === "" ? "配置" : path;
const label =
issue.targetName && issue.targetName !== issue.targetId
? `target "${issue.targetName}" (id: "${issue.targetId}")`
: `target id "${issue.targetId}"`;
return `${label}${renderedPath} ${issue.message}`;
}
if (issue.targetName) {
const path = issue.path.replace(/^targets\[\d+\]\.?/, "");
const renderedPath = path === "" ? "配置" : path;

View File

@@ -1,6 +1,8 @@
import type { ErrorObject } from "ajv";
import Ajv from "ajv";
import { isPlainObject, isString } from "es-toolkit";
import { isArray } from "es-toolkit/compat";
import type { CheckerRegistry } from "../runner/registry";
import type { ConfigValidationIssue } from "./issues";
@@ -29,12 +31,19 @@ export function validateProbeConfigContract(
issues.push(...issuesFromAjvErrors(rootValidate.errors ?? [], config));
}
if (isRecord(config) && isUnknownArray(config["targets"])) {
const targets = config["targets"];
if (isPlainObject(config)) {
const configRecord = config as Record<string, unknown>;
const targetsValue: unknown = configRecord["targets"];
if (!isArray(targetsValue))
return issues.length > 0 ? { config: null, issues } : { config: config as RawProbeConfig, issues: [] };
const targets = targetsValue;
for (let i = 0; i < targets.length; i++) {
const target = targets[i];
if (!isRecord(target) || typeof target["type"] !== "string") continue;
const checker = registry.tryGet(target["type"]);
const target: unknown = targets[i];
if (!isPlainObject(target)) continue;
const targetRecord = target as Record<string, unknown>;
const targetType: unknown = targetRecord["type"];
if (!isString(targetType)) continue;
const checker = registry.tryGet(targetType);
if (!checker) continue;
const targetValidate = ajv.compile(createTargetSchema(checker));
if (!targetValidate(target)) {
@@ -62,13 +71,9 @@ function hasMoreSpecificError(keywords: Set<string>): boolean {
return ["const", "enum", "maximum", "minimum", "minLength", "pattern"].some((keyword) => keywords.has(keyword));
}
function isRecord(value: unknown): value is Record<string, unknown> {
return typeof value === "object" && value !== null && !Array.isArray(value);
}
function issueFromAjvError(error: ErrorObject, root: unknown, basePath: string): ConfigValidationIssue {
const path = buildIssuePath(basePath, error);
const targetName = targetNameFromPath(root, path);
const targetName = targetDisplayNameFromPath(root, path);
switch (error.keyword) {
case "additionalProperties":
return issue("unknown-field", path, "是未知字段", targetName);
@@ -91,10 +96,6 @@ function issueFromAjvError(error: ErrorObject, root: unknown, basePath: string):
}
}
function isUnknownArray(value: unknown): value is unknown[] {
return Array.isArray(value);
}
function joinBasePath(basePath: string, path: string): string {
if (basePath === "") return path;
if (path === "") return basePath;
@@ -136,10 +137,17 @@ function normalizeAjvErrors(errors: ErrorObject[], basePath: string): ErrorObjec
});
}
function targetNameFromPath(root: unknown, path: string): string | undefined {
function targetDisplayNameFromPath(root: unknown, path: string): string | undefined {
const match = /^targets\[(\d+)\]/.exec(path);
if (!match || !isRecord(root) || !isUnknownArray(root["targets"])) return undefined;
const target = root["targets"][Number(match[1])];
if (!isRecord(target) || typeof target["name"] !== "string") return undefined;
return target["name"];
if (!match || !isPlainObject(root)) return undefined;
const rootRecord = root as Record<string, unknown>;
const targetsValue: unknown = rootRecord["targets"];
if (!isArray(targetsValue)) return undefined;
const target: unknown = targetsValue[Number(match[1])];
if (!isPlainObject(target)) return undefined;
const targetRecord = target as Record<string, unknown>;
const targetName: unknown = targetRecord["name"];
if (isString(targetName)) return targetName;
const targetId: unknown = targetRecord["id"];
return isString(targetId) ? targetId : undefined;
}

View File

@@ -8,8 +8,8 @@ import { checkerRegistry } from "./runner";
const CREATE_TARGETS_TABLE = `
CREATE TABLE IF NOT EXISTS targets (
id INTEGER PRIMARY KEY AUTOINCREMENT,
name TEXT NOT NULL UNIQUE,
id TEXT PRIMARY KEY,
name TEXT NOT NULL,
type TEXT NOT NULL,
target TEXT NOT NULL,
config TEXT NOT NULL DEFAULT '{}',
@@ -23,7 +23,7 @@ CREATE TABLE IF NOT EXISTS targets (
const CREATE_CHECK_RESULTS_TABLE = `
CREATE TABLE IF NOT EXISTS check_results (
id INTEGER PRIMARY KEY AUTOINCREMENT,
target_id INTEGER NOT NULL,
target_id TEXT NOT NULL,
timestamp TEXT NOT NULL,
matched INTEGER NOT NULL,
duration_ms REAL,
@@ -59,7 +59,7 @@ export class ProbeStore {
getAllRecentSamples(
limit: number,
): Map<number, Array<{ duration_ms: null | number; matched: number; timestamp: string }>> {
): Map<string, Array<{ duration_ms: null | number; matched: number; timestamp: string }>> {
if (this.closed) return new Map();
const rows = this.db
@@ -80,11 +80,11 @@ export class ProbeStore {
.all(limit) as Array<{
duration_ms: null | number;
matched: number;
target_id: number;
target_id: string;
timestamp: string;
}>;
const result = new Map<number, Array<{ duration_ms: null | number; matched: number; timestamp: string }>>();
const result = new Map<string, Array<{ duration_ms: null | number; matched: number; timestamp: string }>>();
for (const row of rows) {
const samples = result.get(row.target_id) ?? [];
samples.push({ duration_ms: row.duration_ms, matched: row.matched, timestamp: row.timestamp });
@@ -96,7 +96,7 @@ export class ProbeStore {
getAllTargetWindowStats(
from: string,
to: string,
): Map<number, { availability: number; downChecks: number; totalChecks: number; upChecks: number }> {
): Map<string, { availability: number; downChecks: number; totalChecks: number; upChecks: number }> {
if (this.closed) return new Map();
const rows = this.db
@@ -108,10 +108,10 @@ export class ProbeStore {
WHERE timestamp >= ? AND timestamp <= ?
GROUP BY target_id`,
)
.all(from, to) as Array<{ downChecks: number; target_id: number; totalChecks: number; upChecks: number }>;
.all(from, to) as Array<{ downChecks: number; target_id: string; totalChecks: number; upChecks: number }>;
const result = new Map<
number,
string,
{ availability: number; downChecks: number; totalChecks: number; upChecks: number }
>();
for (const row of rows) {
@@ -129,7 +129,7 @@ export class ProbeStore {
getDashboardIncidentStates(
from: string,
to: string,
): Array<{ matched: number; target_id: number; timestamp: string }> {
): Array<{ matched: number; target_id: string; timestamp: string }> {
if (this.closed) return [];
return this.db
@@ -139,11 +139,11 @@ export class ProbeStore {
WHERE timestamp >= ? AND timestamp <= ?
ORDER BY target_id ASC, timestamp ASC`,
)
.all(from, to) as Array<{ matched: number; target_id: number; timestamp: string }>;
.all(from, to) as Array<{ matched: number; target_id: string; timestamp: string }>;
}
getHistory(
targetId: number,
targetId: string,
from: string,
to: string,
page = 1,
@@ -163,13 +163,13 @@ export class ProbeStore {
return { items, page, pageSize, total: countRow.total };
}
getLatestCheck(targetId: number): null | StoredCheckResult {
getLatestCheck(targetId: string): null | StoredCheckResult {
return this.db
.query("SELECT * FROM check_results WHERE target_id = ? ORDER BY timestamp DESC LIMIT 1")
.get(targetId) as null | StoredCheckResult;
}
getLatestChecksMap(): Map<number, StoredCheckResult> {
getLatestChecksMap(): Map<string, StoredCheckResult> {
const rows = this.db
.query(
`SELECT cr.* FROM check_results cr
@@ -184,7 +184,7 @@ export class ProbeStore {
}
getRecentSamples(
targetId: number,
targetId: string,
limit: number,
): Array<{ duration_ms: null | number; matched: number; timestamp: string }> {
return this.db
@@ -198,13 +198,13 @@ export class ProbeStore {
}>;
}
getTargetById(id: number): null | StoredTarget {
getTargetById(id: string): null | StoredTarget {
if (this.closed) return null;
return this.db.query("SELECT * FROM targets WHERE id = ?").get(id) as null | StoredTarget;
}
getTargetCheckpoints(
targetId: number,
targetId: string,
from: string,
to: string,
): Array<{ duration_ms: null | number; matched: number; timestamp: string }> {
@@ -220,7 +220,7 @@ export class ProbeStore {
.all(targetId, from, to) as Array<{ duration_ms: null | number; matched: number; timestamp: string }>;
}
getTargetDurations(targetId: number, from: string, to: string): number[] {
getTargetDurations(targetId: string, from: string, to: string): number[] {
if (this.closed) return [];
const rows = this.db
@@ -243,7 +243,7 @@ export class ProbeStore {
}
getTargetWindowStats(
targetId: number,
targetId: string,
from: string,
to: string,
): {
@@ -281,7 +281,7 @@ export class ProbeStore {
failure: CheckFailure | null;
matched: boolean;
statusDetail: null | string;
targetId: number;
targetId: string;
timestamp: string;
}): void {
if (this.closed) return;
@@ -308,18 +308,18 @@ export class ProbeStore {
syncTargets(targets: ResolvedTargetBase[]): void {
if (this.closed) return;
const existingRows = this.db.query("SELECT id, name FROM targets").all() as Array<{
id: number;
const existingRows = this.db.query("SELECT id FROM targets").all() as Array<{
id: string;
name: string;
}>;
const existingMap = new Map(existingRows.map((r) => [r.name, r.id]));
const configNames = new Set(targets.map((t) => t.name));
const existingIds = new Set(existingRows.map((r) => r.id));
const configIds = new Set(targets.map((t) => t.id));
const insertStmt = this.db.prepare(
"INSERT INTO targets (name, type, target, config, interval_ms, timeout_ms, expect, grp) VALUES (?, ?, ?, ?, ?, ?, ?, ?)",
"INSERT INTO targets (id, name, type, target, config, interval_ms, timeout_ms, expect, grp) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)",
);
const updateStmt = this.db.prepare(
"UPDATE targets SET type = ?, target = ?, config = ?, interval_ms = ?, timeout_ms = ?, expect = ?, grp = ? WHERE id = ?",
"UPDATE targets SET name = ?, type = ?, target = ?, config = ?, interval_ms = ?, timeout_ms = ?, expect = ?, grp = ? WHERE id = ?",
);
const deleteStmt = this.db.prepare("DELETE FROM targets WHERE id = ?");
@@ -331,15 +331,15 @@ export class ProbeStore {
const config = serialized.config;
const expect = t.expect ? JSON.stringify(t.expect) : null;
if (existingMap.has(t.name)) {
updateStmt.run(type, target, config, t.intervalMs, t.timeoutMs, expect, t.group, existingMap.get(t.name)!);
if (existingIds.has(t.id)) {
updateStmt.run(t.name, type, target, config, t.intervalMs, t.timeoutMs, expect, t.group, t.id);
} else {
insertStmt.run(t.name, type, target, config, t.intervalMs, t.timeoutMs, expect, t.group);
insertStmt.run(t.id, t.name, type, target, config, t.intervalMs, t.timeoutMs, expect, t.group);
}
}
for (const [name, id] of existingMap) {
if (!configNames.has(name)) {
for (const id of existingIds) {
if (!configIds.has(id)) {
deleteStmt.run(id);
}
}

View File

@@ -1,7 +1,7 @@
import type { CheckResult as ApiCheckResult, CheckFailure } from "../../shared/api";
export interface CheckResult extends ApiCheckResult {
targetName: string;
targetId: string;
}
export interface DefaultsConfig {
@@ -36,14 +36,16 @@ export interface ProbeConfig {
runtime?: EngineRuntimeConfig;
server?: ServerConfig;
targets: RawTargetConfig[];
variables?: Record<string, VariableValue>;
}
export interface RawTargetConfig {
[configKey: string]: unknown;
expect?: unknown;
group?: string;
id: string;
interval?: string;
name: string;
name?: string;
timeout?: string;
type: string;
}
@@ -52,6 +54,7 @@ export interface ResolvedTargetBase {
[key: string]: unknown;
expect?: unknown;
group: string;
id: string;
intervalMs: number;
name: string;
timeoutMs: number;
@@ -70,7 +73,7 @@ export interface StoredCheckResult {
id: number;
matched: number;
status_detail: null | string;
target_id: number;
target_id: string;
timestamp: string;
}
@@ -78,7 +81,7 @@ export interface StoredTarget {
config: string;
expect: null | string;
grp: string;
id: number;
id: string;
interval_ms: number;
name: string;
target: string;
@@ -86,4 +89,6 @@ export interface StoredTarget {
type: string;
}
export type VariableValue = boolean | number | string;
export type { CheckFailure };

View File

@@ -1,3 +1,5 @@
import { isNumber } from "es-toolkit";
const DURATION_REGEX = /^(\d+(?:\.\d+)?)(ms|s|m|h|d)$/;
const SIZE_REGEX = /^(\d+(?:\.\d+)?)(B|KB|MB|GB)$/;
@@ -20,7 +22,7 @@ export function parseDuration(value: string): number {
}
export function parseSize(value: number | string): number {
if (typeof value === "number") {
if (isNumber(value)) {
if (!Number.isInteger(value) || value < 0 || !Number.isSafeInteger(value)) {
throw new Error(`无效的 size 数值: ${value},必须为非负安全整数`);
}

View File

@@ -0,0 +1,205 @@
import { isBoolean, isNumber, isPlainObject, isString } from "es-toolkit";
import { isArray } from "es-toolkit/compat";
import type { ConfigValidationIssue } from "./schema/issues";
import type { VariableValue } from "./types";
import { issue, joinPath } from "./schema/issues";
const VARIABLE_NAME_PATTERN = /^[a-zA-Z_][a-zA-Z0-9_]*$/;
const VARIABLE_REFERENCE_PATTERN = /\$\{([a-zA-Z_][a-zA-Z0-9_]*)(?:\|([^}]*))?\}/g;
const COMPLETE_VARIABLE_REFERENCE_PATTERN = /^\$\{([a-zA-Z_][a-zA-Z0-9_]*)(?:\|([^}]*))?\}$/;
const ESCAPED_VARIABLE_PATTERN = /\$\$\{([^}]*)\}/g;
interface VariableReference {
defaultValue?: string;
key: string;
}
interface VariableResolutionIssueContext {
path: string;
targetId?: string;
targetName?: string;
}
export function extractVariables(config: unknown): {
issues: ConfigValidationIssue[];
variables: Map<string, VariableValue>;
} {
const issues: ConfigValidationIssue[] = [];
const variables = new Map<string, VariableValue>();
if (!isPlainObject(config)) {
return { issues, variables };
}
const configRecord = config as Record<string, unknown>;
if (configRecord["variables"] === undefined) {
return { issues, variables };
}
const rawVariables: unknown = configRecord["variables"];
if (!isPlainObject(rawVariables)) {
issues.push(issue("invalid-type", "variables", "必须为对象"));
return { issues, variables };
}
for (const [key, value] of Object.entries(rawVariables as Record<string, unknown>)) {
const path = joinPath("variables", key);
if (!VARIABLE_NAME_PATTERN.test(key)) {
issues.push(issue("invalid-format", path, "变量名不符合命名规则"));
continue;
}
if (!isVariableValue(value)) {
issues.push(issue("invalid-type", path, `变量值不允许为 ${describeInvalidVariableValue(value)}`));
continue;
}
variables.set(key, value);
}
return { issues, variables };
}
export function resolveVariables(config: unknown): { config: unknown; issues: ConfigValidationIssue[] } {
const { issues, variables } = extractVariables(config);
if (!isPlainObject(config)) {
return { config, issues };
}
const configRecord = config as Record<string, unknown>;
const rawTargets: unknown = configRecord["targets"];
if (!isArray(rawTargets)) {
return { config, issues };
}
const targets = rawTargets.map((target, index) => resolveTargetVariables(target, index, variables, issues));
return { config: { ...config, targets }, issues };
}
function describeInvalidVariableValue(value: unknown): string {
if (value === null) return "null";
if (isArray(value)) return "array";
return typeof value;
}
function inferStringValue(value: string): VariableValue {
const numberValue = Number(value);
if (Number.isFinite(numberValue)) return numberValue;
if (value === "true") return true;
if (value === "false") return false;
return value;
}
function isVariableValue(value: unknown): value is VariableValue {
return isString(value) || isNumber(value) || isBoolean(value);
}
function parseVariableReference(match: RegExpExecArray): VariableReference {
return { defaultValue: match[2], key: match[1]! };
}
function replaceStringValue(
value: string,
variables: Map<string, VariableValue>,
issues: ConfigValidationIssue[],
context: VariableResolutionIssueContext,
): string | VariableValue {
const trimmed = value.trim();
const completeMatch = COMPLETE_VARIABLE_REFERENCE_PATTERN.exec(trimmed);
if (completeMatch) {
const resolved = resolveVariableReference(parseVariableReference(completeMatch), variables, issues, context);
return resolved ?? value;
}
const escaped: string[] = [];
const protectedValue = value.replace(ESCAPED_VARIABLE_PATTERN, (_match, body: string) => {
const token = `\u0000${escaped.length}\u0000`;
escaped.push(`\${${body}}`);
return token;
});
const replaced = protectedValue.replace(
VARIABLE_REFERENCE_PATTERN,
(match, key: string, defaultValue: string | undefined) => {
const resolved = resolveVariableReference({ defaultValue, key }, variables, issues, context);
return resolved === undefined ? match : String(resolved);
},
);
return escaped.reduce((result, literal, index) => result.replace(`\u0000${index}\u0000`, literal), replaced);
}
function resolveTargetVariables(
target: unknown,
index: number,
variables: Map<string, VariableValue>,
issues: ConfigValidationIssue[],
): unknown {
if (!isPlainObject(target)) return target;
const targetRecord = target as Record<string, unknown>;
const idValue: unknown = targetRecord["id"];
const nameValue: unknown = targetRecord["name"];
const targetId = isString(idValue) ? idValue : undefined;
const targetName = isString(nameValue) ? nameValue : targetId;
return resolveValue(target, `targets[${index}]`, variables, issues, {
path: `targets[${index}]`,
targetId,
targetName,
});
}
function resolveValue(
value: unknown,
path: string,
variables: Map<string, VariableValue>,
issues: ConfigValidationIssue[],
context: VariableResolutionIssueContext,
): unknown {
if (isString(value)) {
return replaceStringValue(value, variables, issues, { ...context, path });
}
if (isArray(value)) {
return value.map((item, index) =>
resolveValue(item, `${path}[${index}]`, variables, issues, { ...context, path: `${path}[${index}]` }),
);
}
if (!isPlainObject(value)) return value;
const result: Record<string, unknown> = {};
for (const [key, item] of Object.entries(value)) {
const itemPath = joinPath(path, key);
result[key] =
key === "id" || key === "type"
? item
: resolveValue(item, itemPath, variables, issues, { ...context, path: itemPath });
}
return result;
}
function resolveVariableReference(
reference: VariableReference,
variables: Map<string, VariableValue>,
issues: ConfigValidationIssue[],
context: VariableResolutionIssueContext,
): undefined | VariableValue {
if (variables.has(reference.key)) {
return variables.get(reference.key);
}
if (Object.prototype.hasOwnProperty.call(process.env, reference.key)) {
return inferStringValue(process.env[reference.key] ?? "");
}
if (reference.defaultValue !== undefined) {
return inferStringValue(reference.defaultValue);
}
issues.push(
issue(
"unresolved-variable",
context.path,
`引用了未定义的变量 "${reference.key}",且环境变量中也不存在,未设置默认值`,
context.targetName,
context.targetId,
),
);
return undefined;
}

View File

@@ -69,12 +69,11 @@ export function validateRecentLimit(limitParam: null | string, mode: RuntimeMode
return { recentLimit };
}
export function validateTargetId(idStr: string, mode: RuntimeMode): Response | { id: number } {
const id = Number(idStr);
if (!Number.isInteger(id) || id <= 0) {
export function validateTargetId(idStr: string, mode: RuntimeMode): Response | { id: string } {
if (!/^[a-zA-Z0-9][a-zA-Z0-9_-]*$/.test(idStr)) {
return jsonResponse(createApiError("Invalid target ID", 400), { mode, status: 400 });
}
return { id };
return { id: idStr };
}
export function validateTimeRange(

View File

@@ -88,9 +88,9 @@ export function handleDashboard(url: URL, store: ProbeStore, mode: RuntimeMode):
}
function groupDashboardIncidentStates(
states: Array<{ matched: number; target_id: number; timestamp: string }>,
): Map<number, MetricCheckpoint[]> {
const result = new Map<number, MetricCheckpoint[]>();
states: Array<{ matched: number; target_id: string; timestamp: string }>,
): Map<string, MetricCheckpoint[]> {
const result = new Map<string, MetricCheckpoint[]>();
for (const state of states) {
const list = result.get(state.target_id) ?? [];
list.push({ durationMs: null, matched: state.matched === 1, timestamp: state.timestamp });