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;
}

View File

@@ -0,0 +1,87 @@
import type { CheckResult, ResolvedTarget } from "./types";
import type { ProbeStore } from "./store";
import { fetchTarget } from "./fetcher";
export class ProbeEngine {
private timers: ReturnType<typeof setInterval>[] = [];
private store: ProbeStore;
private targetNameToId: Map<string, number> = new Map();
constructor(store: ProbeStore, targets: ResolvedTarget[]) {
this.store = store;
this.targets = targets;
this.refreshCache();
}
start(): void {
const groups = this.groupByInterval(this.targets);
for (const [intervalMs, groupTargets] of groups) {
void this.probeGroup(groupTargets);
const timer = setInterval(() => {
void this.probeGroup(groupTargets);
}, intervalMs);
this.timers.push(timer);
}
}
stop(): void {
for (const timer of this.timers) {
clearInterval(timer);
}
this.timers = [];
}
private groupByInterval(targets: ResolvedTarget[]): Map<number, ResolvedTarget[]> {
const groups = new Map<number, ResolvedTarget[]>();
for (const target of targets) {
const group = groups.get(target.intervalMs) ?? [];
group.push(target);
groups.set(target.intervalMs, group);
}
return groups;
}
private async probeGroup(targets: ResolvedTarget[]): Promise<void> {
const results = await Promise.allSettled(targets.map((t) => this.probeOne(t)));
for (const result of results) {
if (result.status === "fulfilled") {
this.writeResult(result.value);
}
}
}
private async probeOne(target: ResolvedTarget): Promise<CheckResult> {
return fetchTarget(target);
}
private writeResult(result: CheckResult): void {
const targetId = this.targetNameToId.get(result.targetName);
if (!targetId) return;
this.store.insertCheckResult({
targetId,
timestamp: result.timestamp,
success: result.success,
statusCode: result.statusCode,
latencyMs: result.latencyMs,
error: result.error,
matched: result.matched,
});
}
private refreshCache(): void {
this.targetNameToId.clear();
for (const target of this.store.getTargets()) {
this.targetNameToId.set(target.name, target.id);
}
}
private targets: ResolvedTarget[];
}

View File

@@ -0,0 +1,65 @@
import type { CheckResult, ExpectConfig, ResolvedTarget } from "./types";
export async function fetchTarget(target: ResolvedTarget): Promise<CheckResult> {
const timestamp = new Date().toISOString();
const controller = new AbortController();
const timeoutId = setTimeout(() => controller.abort(), target.timeoutMs);
try {
const start = performance.now();
const response = await fetch(target.url, {
method: target.method,
headers: target.headers,
body: target.method !== "GET" && target.method !== "HEAD" ? target.body : undefined,
signal: controller.signal,
});
const latencyMs = Math.round(performance.now() - start);
const body = await response.text();
const matched = checkExpect(response.status, body, latencyMs, target.expect);
return {
targetName: target.name,
timestamp,
success: true,
statusCode: response.status,
latencyMs,
error: null,
matched,
};
} catch (error) {
const isTimeout = error instanceof DOMException && error.name === "AbortError";
return {
targetName: target.name,
timestamp,
success: false,
statusCode: null,
latencyMs: null,
error: isTimeout ? `请求超时 (${target.timeoutMs}ms)` : (error instanceof Error ? error.message : String(error)),
matched: false,
};
} finally {
clearTimeout(timeoutId);
}
}
export function checkExpect(statusCode: number, body: string, latencyMs: number, expect?: ExpectConfig): boolean {
if (!expect) return true;
if (expect.status && !expect.status.includes(statusCode)) {
return false;
}
if (expect.bodyContains && !body.includes(expect.bodyContains)) {
return false;
}
if (expect.maxLatencyMs !== undefined && latencyMs > expect.maxLatencyMs) {
return false;
}
return true;
}

277
src/server/checker/store.ts Normal file
View File

@@ -0,0 +1,277 @@
import { Database } from "bun:sqlite";
import { mkdirSync as fsMkdirSync } from "node:fs";
import { dirname } from "node:path";
import type { ResolvedTarget, StoredCheckResult, StoredTarget } from "./types";
const CREATE_TARGETS_TABLE = `
CREATE TABLE IF NOT EXISTS targets (
id INTEGER PRIMARY KEY AUTOINCREMENT,
name TEXT NOT NULL UNIQUE,
url TEXT NOT NULL,
method TEXT NOT NULL DEFAULT 'GET',
headers TEXT NOT NULL DEFAULT '{}',
body TEXT,
interval_ms INTEGER NOT NULL,
timeout_ms INTEGER NOT NULL,
expect TEXT
)
`;
const CREATE_CHECK_RESULTS_TABLE = `
CREATE TABLE IF NOT EXISTS check_results (
id INTEGER PRIMARY KEY AUTOINCREMENT,
target_id INTEGER NOT NULL,
timestamp TEXT NOT NULL,
success INTEGER NOT NULL,
status_code INTEGER,
latency_ms REAL,
error TEXT,
matched INTEGER NOT NULL,
FOREIGN KEY (target_id) REFERENCES targets(id)
)
`;
const CREATE_INDEX = `
CREATE INDEX IF NOT EXISTS idx_check_results_target_timestamp
ON check_results (target_id, timestamp)
`;
export class ProbeStore {
private db: Database;
private closed = false;
constructor(dbPath: string) {
ensureDir(dirname(dbPath));
this.db = new Database(dbPath, { create: true });
this.db.run("PRAGMA journal_mode = WAL");
this.db.run(CREATE_TARGETS_TABLE);
this.db.run(CREATE_CHECK_RESULTS_TABLE);
this.db.run(CREATE_INDEX);
}
syncTargets(targets: ResolvedTarget[]): void {
if (this.closed) return;
const existingRows = this.db.query("SELECT id, name FROM targets").all() as Array<{
id: number;
name: string;
}>;
const existingMap = new Map(existingRows.map((r) => [r.name, r.id]));
const configNames = new Set(targets.map((t) => t.name));
const insertStmt = this.db.prepare(
"INSERT INTO targets (name, url, method, headers, body, interval_ms, timeout_ms, expect) VALUES (?, ?, ?, ?, ?, ?, ?, ?)",
);
const updateStmt = this.db.prepare(
"UPDATE targets SET url = ?, method = ?, headers = ?, body = ?, interval_ms = ?, timeout_ms = ?, expect = ? WHERE id = ?",
);
const deleteStmt = this.db.prepare("DELETE FROM targets WHERE id = ?");
const tx = this.db.transaction(() => {
for (const target of targets) {
const headers = JSON.stringify(target.headers);
const expect = target.expect ? JSON.stringify(target.expect) : null;
if (existingMap.has(target.name)) {
updateStmt.run(
target.url,
target.method,
headers,
target.body ?? null,
target.intervalMs,
target.timeoutMs,
expect,
existingMap.get(target.name)!,
);
} else {
insertStmt.run(target.name, target.url, target.method, headers, target.body ?? null, target.intervalMs, target.timeoutMs, expect);
}
}
for (const [name, id] of existingMap) {
if (!configNames.has(name)) {
deleteStmt.run(id);
}
}
});
tx();
}
getTargets(): StoredTarget[] {
if (this.closed) return [];
return this.db.query("SELECT * FROM targets ORDER BY id").all() as StoredTarget[];
}
getTargetById(id: number): StoredTarget | null {
if (this.closed) return null;
return this.db.query("SELECT * FROM targets WHERE id = ?").get(id) as StoredTarget | null;
}
insertCheckResult(result: {
targetId: number;
timestamp: string;
success: boolean;
statusCode: number | null;
latencyMs: number | null;
error: string | null;
matched: boolean;
}): void {
if (this.closed) return;
this.db
.prepare(
"INSERT INTO check_results (target_id, timestamp, success, status_code, latency_ms, error, matched) VALUES (?, ?, ?, ?, ?, ?, ?)",
)
.run(
result.targetId,
result.timestamp,
result.success ? 1 : 0,
result.statusCode,
result.latencyMs,
result.error,
result.matched ? 1 : 0,
);
}
getLatestCheck(targetId: number): StoredCheckResult | null {
return this.db.query("SELECT * FROM check_results WHERE target_id = ? ORDER BY timestamp DESC LIMIT 1").get(targetId) as
| StoredCheckResult
| null;
}
getHistory(targetId: number, limit = 20): StoredCheckResult[] {
return this.db
.prepare("SELECT * FROM check_results WHERE target_id = ? ORDER BY timestamp DESC LIMIT ?")
.all(targetId, limit) as StoredCheckResult[];
}
getTargetStats(targetId: number): {
totalChecks: number;
availability: number;
avgLatencyMs: number | null;
p99LatencyMs: number | null;
} {
const row = this.db
.prepare(
`SELECT
COUNT(*) as totalChecks,
COALESCE(SUM(CASE WHEN matched = 1 THEN 1 ELSE 0 END), 0) as upCount,
AVG(CASE WHEN success = 1 THEN latency_ms END) as avgLatencyMs
FROM check_results
WHERE target_id = ?`,
)
.get(targetId) as { totalChecks: number; upCount: number; avgLatencyMs: number | null };
const p99Row = this.db
.prepare(
`SELECT latency_ms as p99LatencyMs
FROM check_results
WHERE target_id = ? AND success = 1
ORDER BY latency_ms DESC
LIMIT 1
OFFSET (SELECT COUNT(*) FROM check_results WHERE target_id = ? AND success = 1) * 99 / 100`,
)
.get(targetId, targetId) as { p99LatencyMs: number | null } | undefined;
const totalChecks = row.totalChecks;
const availability = totalChecks > 0 ? (row.upCount / totalChecks) * 100 : 0;
return {
totalChecks,
availability: Math.round(availability * 100) / 100,
avgLatencyMs: row.avgLatencyMs !== null ? Math.round(row.avgLatencyMs * 100) / 100 : null,
p99LatencyMs: p99Row?.p99LatencyMs ?? null,
};
}
getTrend(targetId: number, hours = 24): Array<{
hour: string;
avgLatencyMs: number | null;
availability: number;
totalChecks: number;
}> {
return this.db
.prepare(
`SELECT
strftime('%Y-%m-%dT%H:00:00', timestamp) as hour,
AVG(CASE WHEN success = 1 THEN latency_ms END) as avgLatencyMs,
CASE WHEN COUNT(*) > 0 THEN (SUM(CASE WHEN matched = 1 THEN 1 ELSE 0 END) * 100.0 / COUNT(*)) ELSE 0 END as availability,
COUNT(*) as totalChecks
FROM check_results
WHERE target_id = ? AND timestamp >= datetime('now', '-' || ? || ' hours')
GROUP BY hour
ORDER BY hour`,
)
.all(targetId, hours) as Array<{
hour: string;
avgLatencyMs: number | null;
availability: number;
totalChecks: number;
}>;
}
getSummary(): {
total: number;
up: number;
down: number;
avgLatencyMs: number | null;
lastCheckTime: string | null;
} {
const targets = this.getTargets();
let up = 0;
let down = 0;
let totalLatency = 0;
let latencyCount = 0;
let lastCheckTime: string | null = null;
for (const target of targets) {
const latest = this.getLatestCheck(target.id);
if (latest) {
if (latest.success && latest.matched) {
up++;
} else {
down++;
}
if (latest.latency_ms !== null) {
totalLatency += latest.latency_ms;
latencyCount++;
}
if (!lastCheckTime || latest.timestamp > lastCheckTime) {
lastCheckTime = latest.timestamp;
}
} else {
down++;
}
}
return {
total: targets.length,
up,
down,
avgLatencyMs: latencyCount > 0 ? Math.round((totalLatency / latencyCount) * 100) / 100 : null,
lastCheckTime,
};
}
getSparkline(targetId: number, limit = 20): number[] {
const rows = this.db
.prepare("SELECT latency_ms FROM check_results WHERE target_id = ? AND success = 1 ORDER BY timestamp DESC LIMIT ?")
.all(targetId, limit) as Array<{ latency_ms: number }>;
return rows.map((r) => r.latency_ms).reverse();
}
close(): void {
this.closed = true;
this.db.close();
}
}
function ensureDir(dir: string): void {
try {
fsMkdirSync(dir, { recursive: true });
} catch {
// already exists
}
}

View File

@@ -0,0 +1,79 @@
export interface ProbeConfig {
server?: ServerConfig;
defaults?: DefaultsConfig;
targets: TargetConfig[];
}
export interface ServerConfig {
host?: string;
port?: number;
dataDir?: string;
}
export interface DefaultsConfig {
interval?: string;
timeout?: string;
method?: string;
headers?: Record<string, string>;
}
export interface TargetConfig {
name: string;
url: string;
method?: string;
headers?: Record<string, string>;
body?: string;
interval?: string;
timeout?: string;
expect?: ExpectConfig;
}
export interface ExpectConfig {
status?: number[];
bodyContains?: string;
maxLatencyMs?: number;
}
export interface ResolvedTarget {
name: string;
url: string;
method: string;
headers: Record<string, string>;
body?: string;
intervalMs: number;
timeoutMs: number;
expect?: ExpectConfig;
}
export interface CheckResult {
targetName: string;
timestamp: string;
success: boolean;
statusCode: number | null;
latencyMs: number | null;
error: string | null;
matched: boolean;
}
export interface StoredTarget {
id: number;
name: string;
url: string;
method: string;
headers: string;
body: string | null;
interval_ms: number;
timeout_ms: number;
expect: string | null;
}
export interface StoredCheckResult {
id: number;
target_id: number;
timestamp: string;
success: number;
status_code: number | null;
latency_ms: number | null;
error: string | null;
matched: number;
}