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:
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -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} 字符)`;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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));
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
});
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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[]]),
|
||||
},
|
||||
|
||||
@@ -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$" }),
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 };
|
||||
|
||||
@@ -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},必须为非负安全整数`);
|
||||
}
|
||||
|
||||
205
src/server/checker/variables.ts
Normal file
205
src/server/checker/variables.ts
Normal 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;
|
||||
}
|
||||
@@ -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(
|
||||
|
||||
@@ -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 });
|
||||
|
||||
@@ -81,7 +81,7 @@ export interface TargetMetricsResponse {
|
||||
totalChecks: number;
|
||||
upChecks: number;
|
||||
};
|
||||
targetId: number;
|
||||
targetId: string;
|
||||
trend: TrendPoint[];
|
||||
window: {
|
||||
bucket: "1h";
|
||||
@@ -100,7 +100,7 @@ export interface TargetStats {
|
||||
export interface TargetStatus {
|
||||
currentStreak: CurrentStreak | null;
|
||||
group: string;
|
||||
id: number;
|
||||
id: string;
|
||||
interval: string;
|
||||
latestCheck: CheckResult | null;
|
||||
name: string;
|
||||
|
||||
@@ -5,7 +5,7 @@ import type { DashboardResponse, MetaResponse, TargetMetricsResponse } from "../
|
||||
const queryKeys = {
|
||||
dashboard: () => ["dashboard", "24h", 30] as const,
|
||||
meta: () => ["meta"] as const,
|
||||
metrics: (targetId: number, from: string, to: string, bucket: "1h") =>
|
||||
metrics: (targetId: string, from: string, to: string, bucket: "1h") =>
|
||||
["metrics", targetId, from, to, bucket] as const,
|
||||
};
|
||||
|
||||
@@ -32,7 +32,7 @@ export function useMeta() {
|
||||
});
|
||||
}
|
||||
|
||||
export function useTargetMetrics(targetId: null | number, from: string, to: string, bucket: "1h") {
|
||||
export function useTargetMetrics(targetId: null | string, from: string, to: string, bucket: "1h") {
|
||||
return useQuery({
|
||||
enabled: targetId !== null && !!from && !!to,
|
||||
queryFn: () => {
|
||||
|
||||
@@ -7,11 +7,11 @@ import { subtractHours } from "../utils/time";
|
||||
import { fetchJson, useDashboard, useTargetMetrics } from "./use-queries";
|
||||
|
||||
const detailQueryKeys = {
|
||||
history: (targetId: number, from: string, to: string, page: number) => ["history", targetId, from, to, page] as const,
|
||||
history: (targetId: string, from: string, to: string, page: number) => ["history", targetId, from, to, page] as const,
|
||||
};
|
||||
|
||||
export function useTargetDetail() {
|
||||
const [selectedTargetId, setSelectedTargetId] = useState<null | number>(null);
|
||||
const [selectedTargetId, setSelectedTargetId] = useState<null | string>(null);
|
||||
const [timeFrom, setTimeFrom] = useState("");
|
||||
const [timeTo, setTimeTo] = useState("");
|
||||
const [historyPage, setHistoryPage] = useState(1);
|
||||
|
||||
Reference in New Issue
Block a user