1
0
Files
DiAL/src/server/checker/config-loader.ts
lanyuanxiaoyao 548b44d28e feat: 重构 Dashboard 为卡片式分组布局
表格布局替换为按分组展示的卡片式布局,新增 group 字段配置和 TargetBoard/TargetCard 等组件。模态框详情页支持时间范围筛选和分页,SummaryCards 减为 3 个。API 端点变更:trend/history 改用 from/to 参数,history 支持分页。recentSampleCount 硬编码为 30。
2026-05-11 08:54:21 +08:00

238 lines
7.1 KiB
TypeScript

import type {
CommandDefaultsConfig,
CommandTargetConfig,
DefaultsConfig,
HttpDefaultsConfig,
HttpExpectConfig,
HttpTargetConfig,
ProbeConfig,
ResolvedCommandTarget,
ResolvedHttpTarget,
ResolvedTarget,
RuntimeConfig,
TargetConfig,
TargetType,
} from "./types";
import { parseSize } from "./size";
import { resolve } from "node:path";
import { dirname } from "node:path";
const DEFAULT_HOST = "127.0.0.1";
const DEFAULT_PORT = 3000;
const DEFAULT_DATA_DIR = "./data";
const DEFAULT_INTERVAL = "30s";
const DEFAULT_TIMEOUT = "10s";
const DEFAULT_HTTP_METHOD = "GET";
const DEFAULT_MAX_BODY_BYTES = "100MB";
const DEFAULT_MAX_OUTPUT_BYTES = "100MB";
const DEFAULT_MAX_CONCURRENT_CHECKS = 20;
const SUPPORTED_TYPES: TargetType[] = ["http", "command"];
export interface ResolvedConfig {
host: string;
port: number;
dataDir: string;
configDir: string;
maxConcurrentChecks: number;
targets: ResolvedTarget[];
}
export async function loadConfig(configPath: string): Promise<ResolvedConfig> {
const file = Bun.file(configPath);
if (!(await file.exists())) {
throw new Error(`配置文件不存在: ${configPath}`);
}
const content = await file.text();
const raw = Bun.YAML.parse(content) as ProbeConfig | null;
if (!raw) {
throw new Error("配置文件内容为空或格式无效");
}
validateConfig(raw);
const configDir = dirname(resolve(configPath));
const server = raw.server ?? {};
const runtime = raw.runtime ?? {};
const defaults = raw.defaults ?? {};
const host = server.host ?? DEFAULT_HOST;
const port = server.port ?? DEFAULT_PORT;
const dataDir = server.dataDir ?? DEFAULT_DATA_DIR;
if (!Number.isInteger(port) || port < 0 || port > 65535) {
throw new Error(`无效端口号: ${port},需要 0-65535 之间的整数`);
}
const maxConcurrentChecks = validateRuntime(runtime);
const defaultIntervalMs = parseDuration(defaults.interval ?? DEFAULT_INTERVAL);
const defaultTimeoutMs = parseDuration(defaults.timeout ?? DEFAULT_TIMEOUT);
const targets: ResolvedTarget[] = raw.targets.map((target) =>
resolveTarget(target, defaults, defaultIntervalMs, defaultTimeoutMs, configDir),
);
return { host, port, dataDir, configDir, maxConcurrentChecks, targets };
}
function validateRuntime(runtime: RuntimeConfig): number {
if (runtime.maxConcurrentChecks === undefined) return DEFAULT_MAX_CONCURRENT_CHECKS;
if (
typeof runtime.maxConcurrentChecks !== "number" ||
!Number.isInteger(runtime.maxConcurrentChecks) ||
runtime.maxConcurrentChecks <= 0
) {
throw new Error("runtime.maxConcurrentChecks 必须为正整数");
}
return runtime.maxConcurrentChecks;
}
function resolveTarget(
target: TargetConfig,
defaults: DefaultsConfig,
defaultIntervalMs: number,
defaultTimeoutMs: number,
configDir: string,
): ResolvedTarget {
const intervalMs = parseDuration(target.interval ?? defaults.interval ?? DEFAULT_INTERVAL);
const timeoutMs = parseDuration(target.timeout ?? defaults.timeout ?? DEFAULT_TIMEOUT);
const group = target.group ?? "default";
if (target.type === "http") {
return resolveHttpTarget(target, defaults.http, intervalMs, timeoutMs, group);
}
return resolveCommandTarget(target, defaults.command, intervalMs, timeoutMs, configDir, group);
}
function resolveHttpTarget(
target: TargetConfig & { type: "http"; http: HttpTargetConfig },
httpDefaults: HttpDefaultsConfig | undefined,
intervalMs: number,
timeoutMs: number,
group: string,
): ResolvedHttpTarget {
const maxBodyBytes = parseSize(target.http.maxBodyBytes ?? httpDefaults?.maxBodyBytes ?? DEFAULT_MAX_BODY_BYTES);
return {
type: "http",
name: target.name,
group,
http: {
url: target.http.url,
method: target.http.method ?? httpDefaults?.method ?? DEFAULT_HTTP_METHOD,
headers: { ...(httpDefaults?.headers ?? {}), ...(target.http.headers ?? {}) },
body: target.http.body,
maxBodyBytes,
},
intervalMs,
timeoutMs,
expect: target.expect as HttpExpectConfig | undefined,
};
}
function resolveCommandTarget(
target: TargetConfig & { type: "command"; command: CommandTargetConfig },
commandDefaults: CommandDefaultsConfig | undefined,
intervalMs: number,
timeoutMs: number,
configDir: string,
group: string,
): ResolvedCommandTarget {
const cwd = target.command.cwd ?? commandDefaults?.cwd ?? ".";
const resolvedCwd = resolve(configDir, cwd);
const maxOutputBytes = parseSize(
target.command.maxOutputBytes ?? commandDefaults?.maxOutputBytes ?? DEFAULT_MAX_OUTPUT_BYTES,
);
const env = { ...process.env, ...(target.command.env ?? {}) } as Record<string, string>;
return {
type: "command",
name: target.name,
group,
command: {
exec: target.command.exec,
args: target.command.args ?? [],
cwd: resolvedCwd,
env,
maxOutputBytes,
},
intervalMs,
timeoutMs,
expect: target.expect as import("./types").CommandExpectConfig | undefined,
};
}
function validateConfig(config: ProbeConfig): void {
if (!config.targets || !Array.isArray(config.targets) || config.targets.length === 0) {
throw new Error("配置文件必须包含至少一个 target");
}
const names = new Set<string>();
for (let i = 0; i < config.targets.length; i++) {
const raw = config.targets[i] as unknown as Record<string, unknown>;
const name = raw["name"];
if (!name || typeof name !== "string" || (name as string).trim() === "") {
throw new Error(`${i + 1} 个 target 缺少 name 字段`);
}
const type = raw["type"];
if (!type || typeof type !== "string") {
throw new Error(`target "${name}" 缺少 type 字段`);
}
if (!SUPPORTED_TYPES.includes(type as TargetType)) {
throw new Error(`target "${name}" 使用不支持的 type: "${type}",支持: ${SUPPORTED_TYPES.join(", ")}`);
}
if (type === "http") {
const http = raw["http"] as Record<string, unknown> | undefined;
if (!http?.["url"] || typeof http["url"] !== "string" || (http["url"] as string).trim() === "") {
throw new Error(`target "${name}" 缺少 http.url 字段`);
}
}
if (type === "command") {
const cmd = raw["command"] as Record<string, unknown> | undefined;
if (!cmd?.["exec"] || typeof cmd["exec"] !== "string" || (cmd["exec"] as string).trim() === "") {
throw new Error(`target "${name}" 缺少 command.exec 字段`);
}
}
const group = raw["group"];
if (group !== undefined && typeof group !== "string") {
throw new Error(`target "${name}" 的 group 字段必须为字符串`);
}
if (names.has(name as string)) {
throw new Error(`target name 重复: "${name}"`);
}
names.add(name as string);
}
}
const DURATION_REGEX = /^(\d+(?:\.\d+)?)(ms|s|m)$/;
export function parseDuration(value: string): number {
const match = DURATION_REGEX.exec(value);
if (!match) {
throw new Error(`无效的时长格式: "${value}",支持格式如 "30s"、"5m"、"500ms"`);
}
const num = parseFloat(match[1]!);
const unit = match[2]!;
if (unit === "ms") return num;
if (unit === "s") return num * 1000;
return num * 60 * 1000;
}