1
0

feat: 将 demo 项目转化为 HTTP 拨测监控工具

新增 YAML 配置解析(Bun 内置 YAML)、SQLite 数据存储(bun:sqlite)、按 interval 分组并发拨测引擎、REST API(/api/summary、/api/targets、/api/targets/:id/history、/api/targets/:id/trend)、React 前端 Dashboard(统计卡片、目标表格、可展开详情面板、recharts 趋势图)。CLI 简化为仅接受配置文件路径。移除 /api/demo 路由和相关 demo 代码。保留 /health、静态资源服务和 SPA fallback。
This commit is contained in:
2026-05-09 17:04:25 +08:00
parent 9267f6585c
commit 57d3a5cfb4
43 changed files with 2910 additions and 525 deletions

View File

@@ -0,0 +1,104 @@
import type { ProbeConfig, ResolvedTarget } from "./types";
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_METHOD = "GET";
export interface ResolvedConfig {
host: string;
port: number;
dataDir: string;
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 server = raw.server ?? {};
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 defaultIntervalMs = parseDuration(defaults.interval ?? DEFAULT_INTERVAL);
const defaultTimeoutMs = parseDuration(defaults.timeout ?? DEFAULT_TIMEOUT);
const defaultMethod = defaults.method ?? DEFAULT_METHOD;
const defaultHeaders = defaults.headers ?? {};
const targets: ResolvedTarget[] = raw.targets.map((target) => ({
name: target.name,
url: target.url,
method: target.method ?? defaultMethod,
headers: { ...defaultHeaders, ...(target.headers ?? {}) },
body: target.body,
intervalMs: parseDuration(target.interval ?? defaults.interval ?? DEFAULT_INTERVAL),
timeoutMs: parseDuration(target.timeout ?? defaults.timeout ?? DEFAULT_TIMEOUT),
expect: target.expect,
}));
return { host, port, dataDir, targets };
}
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 target = config.targets[i]!;
if (!target.name || typeof target.name !== "string" || target.name.trim() === "") {
throw new Error(`${i + 1} 个 target 缺少 name 字段`);
}
if (!target.url || typeof target.url !== "string" || target.url.trim() === "") {
throw new Error(`target "${target.name}" 缺少 url 字段`);
}
if (names.has(target.name)) {
throw new Error(`target name 重复: "${target.name}"`);
}
names.add(target.name);
}
}
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;
}