表格布局替换为按分组展示的卡片式布局,新增 group 字段配置和 TargetBoard/TargetCard 等组件。模态框详情页支持时间范围筛选和分页,SummaryCards 减为 3 个。API 端点变更:trend/history 改用 from/to 参数,history 支持分页。recentSampleCount 硬编码为 30。
238 lines
7.1 KiB
TypeScript
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;
|
|
}
|