feat: 重构为多类型 checker 通用框架,支持 HTTP 与命令检查
- 引入 typed target 判别联合,支持 http 与 command 两种 checker - expect 重构为有序规则数组,按配置顺序快速失败并生成结构化 failure - 新增 command runner,支持 exec + args 本地命令执行 - 引入全局并发限制 maxConcurrentChecks 和 size 解析 (KB/MB/GB) - HTTP/command 各自独立 expect pipeline,应用领域默认成功语义 - SQLite schema、API、Dashboard 全链路调整为 checker 通用契约 - 补充完整测试覆盖(192 tests),更新 README 与示例配置
This commit is contained in:
@@ -1,34 +1,42 @@
|
||||
import { afterAll, beforeAll, describe, expect, test } from "bun:test";
|
||||
import { ProbeStore } from "../../../src/server/checker/store";
|
||||
import type { ResolvedTarget } from "../../../src/server/checker/types";
|
||||
import type { CheckFailure, ResolvedTarget } from "../../../src/server/checker/types";
|
||||
import { mkdir, rm } from "node:fs/promises";
|
||||
import { join } from "node:path";
|
||||
import { tmpdir } from "node:os";
|
||||
|
||||
const httpTarget: ResolvedTarget = {
|
||||
type: "http",
|
||||
name: "test-http",
|
||||
http: {
|
||||
url: "https://example.com/health",
|
||||
method: "GET",
|
||||
headers: { Accept: "application/json" },
|
||||
maxBodyBytes: 104857600,
|
||||
},
|
||||
intervalMs: 30000,
|
||||
timeoutMs: 10000,
|
||||
expect: { status: [200], maxDurationMs: 3000 },
|
||||
};
|
||||
|
||||
const commandTarget: ResolvedTarget = {
|
||||
type: "command",
|
||||
name: "test-cmd",
|
||||
command: {
|
||||
exec: "ping",
|
||||
args: ["-c", "1", "localhost"],
|
||||
cwd: "/tmp",
|
||||
env: {},
|
||||
maxOutputBytes: 104857600,
|
||||
},
|
||||
intervalMs: 60000,
|
||||
timeoutMs: 5000,
|
||||
};
|
||||
|
||||
describe("ProbeStore", () => {
|
||||
let tempDir: string;
|
||||
let store: ProbeStore;
|
||||
|
||||
const target1: ResolvedTarget = {
|
||||
name: "test-a",
|
||||
url: "http://a.com",
|
||||
method: "GET",
|
||||
headers: {},
|
||||
intervalMs: 30000,
|
||||
timeoutMs: 10000,
|
||||
};
|
||||
|
||||
const target2: ResolvedTarget = {
|
||||
name: "test-b",
|
||||
url: "http://b.com",
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: '{"ping": true}',
|
||||
intervalMs: 60000,
|
||||
timeoutMs: 5000,
|
||||
expect: { status: [200], maxLatencyMs: 3000 },
|
||||
};
|
||||
|
||||
beforeAll(async () => {
|
||||
tempDir = join(tmpdir(), `gc-store-test-${Date.now()}`);
|
||||
await mkdir(tempDir, { recursive: true });
|
||||
@@ -44,42 +52,62 @@ describe("ProbeStore", () => {
|
||||
expect(store.getTargets()).toHaveLength(0);
|
||||
});
|
||||
|
||||
test("同步新增 targets", () => {
|
||||
store.syncTargets([target1, target2]);
|
||||
test("同步 http 和 command targets", () => {
|
||||
store.syncTargets([httpTarget, commandTarget]);
|
||||
const targets = store.getTargets();
|
||||
expect(targets).toHaveLength(2);
|
||||
expect(targets[0]!.name).toBe("test-a");
|
||||
expect(targets[1]!.name).toBe("test-b");
|
||||
expect(targets[0]!.name).toBe("test-http");
|
||||
expect(targets[1]!.name).toBe("test-cmd");
|
||||
});
|
||||
|
||||
test("同步后 target 字段正确", () => {
|
||||
const targets = store.getTargets();
|
||||
const t2 = targets.find((t) => t.name === "test-b")!;
|
||||
expect(t2.url).toBe("http://b.com");
|
||||
expect(t2.method).toBe("POST");
|
||||
expect(JSON.parse(t2.headers)).toEqual({ "Content-Type": "application/json" });
|
||||
expect(t2.body).toBe('{"ping": true}');
|
||||
expect(t2.interval_ms).toBe(60000);
|
||||
expect(t2.expect).toBe(JSON.stringify({ status: [200], maxLatencyMs: 3000 }));
|
||||
test("http target 字段正确", () => {
|
||||
const t = store.getTargets().find((t) => t.name === "test-http")!;
|
||||
expect(t.type).toBe("http");
|
||||
expect(t.target).toBe("https://example.com/health");
|
||||
const config = JSON.parse(t.config);
|
||||
expect(config.url).toBe("https://example.com/health");
|
||||
expect(config.method).toBe("GET");
|
||||
expect(config.headers).toEqual({ Accept: "application/json" });
|
||||
expect(config.maxBodyBytes).toBe(104857600);
|
||||
expect(t.interval_ms).toBe(30000);
|
||||
expect(t.timeout_ms).toBe(10000);
|
||||
expect(JSON.parse(t.expect!)).toEqual({ status: [200], maxDurationMs: 3000 });
|
||||
});
|
||||
|
||||
test("command target 字段正确", () => {
|
||||
const t = store.getTargets().find((t) => t.name === "test-cmd")!;
|
||||
expect(t.type).toBe("command");
|
||||
expect(t.target).toBe("exec ping -c 1 localhost");
|
||||
const config = JSON.parse(t.config);
|
||||
expect(config.exec).toBe("ping");
|
||||
expect(config.args).toEqual(["-c", "1", "localhost"]);
|
||||
expect(config.cwd).toBe("/tmp");
|
||||
expect(config.maxOutputBytes).toBe(104857600);
|
||||
expect(t.interval_ms).toBe(60000);
|
||||
expect(t.timeout_ms).toBe(5000);
|
||||
expect(t.expect).toBeNull();
|
||||
});
|
||||
|
||||
test("同步更新已有 target", () => {
|
||||
store.syncTargets([{ ...target1, url: "http://a-v2.com" }, target2]);
|
||||
const targets = store.getTargets();
|
||||
const t1 = targets.find((t) => t.name === "test-a")!;
|
||||
expect(t1.url).toBe("http://a-v2.com");
|
||||
expect(targets).toHaveLength(2);
|
||||
const updated: ResolvedTarget = {
|
||||
...httpTarget,
|
||||
http: { ...httpTarget.http, url: "https://example.com/v2" },
|
||||
};
|
||||
store.syncTargets([updated, commandTarget]);
|
||||
const t = store.getTargets().find((t) => t.name === "test-http")!;
|
||||
expect(t.target).toBe("https://example.com/v2");
|
||||
expect(store.getTargets()).toHaveLength(2);
|
||||
});
|
||||
|
||||
test("同步删除 target", () => {
|
||||
store.syncTargets([target1]);
|
||||
store.syncTargets([httpTarget]);
|
||||
const targets = store.getTargets();
|
||||
expect(targets).toHaveLength(1);
|
||||
expect(targets[0]!.name).toBe("test-a");
|
||||
expect(targets[0]!.name).toBe("test-http");
|
||||
});
|
||||
|
||||
test("重新同步回来", () => {
|
||||
store.syncTargets([target1, target2]);
|
||||
store.syncTargets([httpTarget, commandTarget]);
|
||||
expect(store.getTargets()).toHaveLength(2);
|
||||
});
|
||||
|
||||
@@ -87,14 +115,15 @@ describe("ProbeStore", () => {
|
||||
const targets = store.getTargets();
|
||||
const found = store.getTargetById(targets[0]!.id);
|
||||
expect(found).toBeDefined();
|
||||
expect(found!.name).toBe("test-a");
|
||||
expect(found!.name).toBe("test-http");
|
||||
});
|
||||
|
||||
test("getTargetById 不存在", () => {
|
||||
expect(store.getTargetById(99999)).toBeNull();
|
||||
});
|
||||
|
||||
test("写入和查询 check result", () => {
|
||||
test("写入 check result 并查询", () => {
|
||||
store.syncTargets([httpTarget, commandTarget]);
|
||||
const targets = store.getTargets();
|
||||
const t1Id = targets[0]!.id;
|
||||
|
||||
@@ -102,30 +131,39 @@ describe("ProbeStore", () => {
|
||||
targetId: t1Id,
|
||||
timestamp: "2025-01-01T00:00:00.000Z",
|
||||
success: true,
|
||||
statusCode: 200,
|
||||
latencyMs: 150,
|
||||
error: null,
|
||||
matched: true,
|
||||
durationMs: 150.5,
|
||||
statusDetail: "200 OK",
|
||||
failure: null,
|
||||
});
|
||||
|
||||
store.insertCheckResult({
|
||||
targetId: t1Id,
|
||||
timestamp: "2025-01-01T00:00:30.000Z",
|
||||
success: true,
|
||||
statusCode: 200,
|
||||
latencyMs: 300,
|
||||
error: null,
|
||||
matched: true,
|
||||
durationMs: 300,
|
||||
statusDetail: "200 OK",
|
||||
failure: null,
|
||||
});
|
||||
|
||||
const failure: CheckFailure = {
|
||||
kind: "error",
|
||||
phase: "duration",
|
||||
path: "$.maxDurationMs",
|
||||
expected: 3000,
|
||||
actual: 5000,
|
||||
message: "请求耗时 5000ms 超过限制 3000ms",
|
||||
};
|
||||
|
||||
store.insertCheckResult({
|
||||
targetId: t1Id,
|
||||
timestamp: "2025-01-01T00:01:00.000Z",
|
||||
success: false,
|
||||
statusCode: null,
|
||||
latencyMs: null,
|
||||
error: "timeout",
|
||||
matched: false,
|
||||
durationMs: null,
|
||||
statusDetail: null,
|
||||
failure,
|
||||
});
|
||||
|
||||
const history = store.getHistory(t1Id, 10);
|
||||
@@ -134,7 +172,11 @@ describe("ProbeStore", () => {
|
||||
|
||||
const latest = store.getLatestCheck(t1Id)!;
|
||||
expect(latest.success).toBe(0);
|
||||
expect(latest.error).toBe("timeout");
|
||||
expect(latest.failure).not.toBeNull();
|
||||
const parsedFailure = JSON.parse(latest.failure!) as CheckFailure;
|
||||
expect(parsedFailure.kind).toBe("error");
|
||||
expect(parsedFailure.phase).toBe("duration");
|
||||
expect(parsedFailure.message).toBe("请求耗时 5000ms 超过限制 3000ms");
|
||||
});
|
||||
|
||||
test("getHistory 默认 limit=20", () => {
|
||||
@@ -144,12 +186,12 @@ describe("ProbeStore", () => {
|
||||
for (let i = 0; i < 25; i++) {
|
||||
store.insertCheckResult({
|
||||
targetId: t1Id,
|
||||
timestamp: `2025-01-01T00:${String(i).padStart(2, "0")}:00.000Z`,
|
||||
timestamp: `2025-01-01T01:${String(i).padStart(2, "0")}:00.000Z`,
|
||||
success: true,
|
||||
statusCode: 200,
|
||||
latencyMs: 100 + i,
|
||||
error: null,
|
||||
matched: true,
|
||||
durationMs: 100 + i,
|
||||
statusDetail: "200 OK",
|
||||
failure: null,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -157,7 +199,7 @@ describe("ProbeStore", () => {
|
||||
expect(history).toHaveLength(20);
|
||||
});
|
||||
|
||||
test("getTargetStats 计算可用率和延迟", () => {
|
||||
test("getTargetStats 计算可用率和 duration", () => {
|
||||
const targets = store.getTargets();
|
||||
const t1Id = targets[0]!.id;
|
||||
|
||||
@@ -165,17 +207,19 @@ describe("ProbeStore", () => {
|
||||
expect(stats.totalChecks).toBeGreaterThan(0);
|
||||
expect(stats.availability).toBeGreaterThanOrEqual(0);
|
||||
expect(stats.availability).toBeLessThanOrEqual(100);
|
||||
expect(stats.avgLatencyMs).not.toBeNull();
|
||||
expect(stats.avgDurationMs).not.toBeNull();
|
||||
expect(typeof stats.avgDurationMs).toBe("number");
|
||||
});
|
||||
|
||||
test("无记录目标的 stats", () => {
|
||||
const targets = store.getTargets();
|
||||
const t2Id = targets.find((t) => t.name === "test-b")!.id;
|
||||
const t2Id = targets.find((t) => t.name === "test-cmd")!.id;
|
||||
|
||||
const stats = store.getTargetStats(t2Id);
|
||||
expect(stats.totalChecks).toBe(0);
|
||||
expect(stats.availability).toBe(0);
|
||||
expect(stats.avgLatencyMs).toBeNull();
|
||||
expect(stats.avgDurationMs).toBeNull();
|
||||
expect(stats.p99DurationMs).toBeNull();
|
||||
});
|
||||
|
||||
test("getSummary 返回总览统计", () => {
|
||||
@@ -183,6 +227,7 @@ describe("ProbeStore", () => {
|
||||
expect(summary.total).toBe(2);
|
||||
expect(summary.up + summary.down).toBe(2);
|
||||
expect(summary.lastCheckTime).not.toBeNull();
|
||||
expect(summary.avgDurationMs).not.toBeNull();
|
||||
});
|
||||
|
||||
test("getTrend 返回趋势数据", () => {
|
||||
@@ -191,5 +236,30 @@ describe("ProbeStore", () => {
|
||||
|
||||
const trend = store.getTrend(t1Id, 24);
|
||||
expect(Array.isArray(trend)).toBe(true);
|
||||
if (trend.length > 0) {
|
||||
expect(trend[0]!.hour).toBeDefined();
|
||||
expect(trend[0]!.avgDurationMs).toBeDefined();
|
||||
expect(trend[0]!.availability).toBeGreaterThanOrEqual(0);
|
||||
expect(trend[0]!.totalChecks).toBeGreaterThan(0);
|
||||
}
|
||||
});
|
||||
|
||||
test("getSparkline 返回 duration 数组", () => {
|
||||
const targets = store.getTargets();
|
||||
const t1Id = targets[0]!.id;
|
||||
|
||||
const sparkline = store.getSparkline(t1Id);
|
||||
expect(Array.isArray(sparkline)).toBe(true);
|
||||
expect(sparkline.length).toBeGreaterThan(0);
|
||||
for (const val of sparkline) {
|
||||
expect(typeof val).toBe("number");
|
||||
}
|
||||
});
|
||||
|
||||
test("关闭后操作不报错", () => {
|
||||
const closedStore = new ProbeStore(join(tempDir, "closed.db"));
|
||||
closedStore.close();
|
||||
expect(closedStore.getTargets()).toHaveLength(0);
|
||||
expect(closedStore.getTargetById(1)).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user