1
0

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:
2026-05-10 22:25:21 +08:00
parent 599d973cbd
commit b8810f1182
46 changed files with 3562 additions and 1062 deletions

View File

@@ -1,16 +1,15 @@
import { Database } from "bun:sqlite";
import { mkdirSync as fsMkdirSync } from "node:fs";
import { dirname } from "node:path";
import type { ResolvedTarget, StoredCheckResult, StoredTarget } from "./types";
import type { CheckFailure, 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,
type TEXT NOT NULL,
target TEXT NOT NULL,
config TEXT NOT NULL DEFAULT '{}',
interval_ms INTEGER NOT NULL,
timeout_ms INTEGER NOT NULL,
expect TEXT
@@ -23,10 +22,10 @@ CREATE TABLE IF NOT EXISTS check_results (
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,
duration_ms REAL,
status_detail TEXT,
failure TEXT,
FOREIGN KEY (target_id) REFERENCES targets(id)
)
`;
@@ -59,40 +58,24 @@ export class ProbeStore {
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 (?, ?, ?, ?, ?, ?, ?, ?)",
"INSERT INTO targets (name, type, target, config, 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 = ?",
"UPDATE targets SET type = ?, target = ?, config = ?, 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;
for (const t of targets) {
const type = t.type;
const target = buildTargetDisplay(t);
const config = buildTargetConfig(t);
const expect = t.expect ? JSON.stringify(t.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)!,
);
if (existingMap.has(t.name)) {
updateStmt.run(type, target, config, t.intervalMs, t.timeoutMs, expect, existingMap.get(t.name)!);
} else {
insertStmt.run(
target.name,
target.url,
target.method,
headers,
target.body ?? null,
target.intervalMs,
target.timeoutMs,
expect,
);
insertStmt.run(t.name, type, target, config, t.intervalMs, t.timeoutMs, expect);
}
}
@@ -120,24 +103,24 @@ export class ProbeStore {
targetId: number;
timestamp: string;
success: boolean;
statusCode: number | null;
latencyMs: number | null;
error: string | null;
matched: boolean;
durationMs: number | null;
statusDetail: string | null;
failure: CheckFailure | null;
}): void {
if (this.closed) return;
this.db
.prepare(
"INSERT INTO check_results (target_id, timestamp, success, status_code, latency_ms, error, matched) VALUES (?, ?, ?, ?, ?, ?, ?)",
"INSERT INTO check_results (target_id, timestamp, success, matched, duration_ms, status_detail, failure) VALUES (?, ?, ?, ?, ?, ?, ?)",
)
.run(
result.targetId,
result.timestamp,
result.success ? 1 : 0,
result.statusCode,
result.latencyMs,
result.error,
result.matched ? 1 : 0,
result.durationMs,
result.statusDetail,
result.failure ? JSON.stringify(result.failure) : null,
);
}
@@ -156,30 +139,30 @@ export class ProbeStore {
getTargetStats(targetId: number): {
totalChecks: number;
availability: number;
avgLatencyMs: number | null;
p99LatencyMs: number | null;
avgDurationMs: number | null;
p99DurationMs: 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
AVG(CASE WHEN success = 1 THEN duration_ms END) as avgDurationMs
FROM check_results
WHERE target_id = ?`,
)
.get(targetId) as { totalChecks: number; upCount: number; avgLatencyMs: number | null };
.get(targetId) as { totalChecks: number; upCount: number; avgDurationMs: number | null };
const p99Row = this.db
.prepare(
`SELECT latency_ms as p99LatencyMs
`SELECT duration_ms as p99DurationMs
FROM check_results
WHERE target_id = ? AND success = 1
ORDER BY latency_ms DESC
ORDER BY duration_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;
.get(targetId, targetId) as { p99DurationMs: number | null } | undefined;
const totalChecks = row.totalChecks;
const availability = totalChecks > 0 ? (row.upCount / totalChecks) * 100 : 0;
@@ -187,8 +170,8 @@ export class ProbeStore {
return {
totalChecks,
availability: Math.round(availability * 100) / 100,
avgLatencyMs: row.avgLatencyMs !== null ? Math.round(row.avgLatencyMs * 100) / 100 : null,
p99LatencyMs: p99Row?.p99LatencyMs ?? null,
avgDurationMs: row.avgDurationMs !== null ? Math.round(row.avgDurationMs * 100) / 100 : null,
p99DurationMs: p99Row?.p99DurationMs ?? null,
};
}
@@ -197,7 +180,7 @@ export class ProbeStore {
hours = 24,
): Array<{
hour: string;
avgLatencyMs: number | null;
avgDurationMs: number | null;
availability: number;
totalChecks: number;
}> {
@@ -205,7 +188,7 @@ export class ProbeStore {
.prepare(
`SELECT
strftime('%Y-%m-%dT%H:00:00', timestamp) as hour,
AVG(CASE WHEN success = 1 THEN latency_ms END) as avgLatencyMs,
AVG(CASE WHEN success = 1 THEN duration_ms END) as avgDurationMs,
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
@@ -215,7 +198,7 @@ export class ProbeStore {
)
.all(targetId, hours) as Array<{
hour: string;
avgLatencyMs: number | null;
avgDurationMs: number | null;
availability: number;
totalChecks: number;
}>;
@@ -225,14 +208,14 @@ export class ProbeStore {
total: number;
up: number;
down: number;
avgLatencyMs: number | null;
avgDurationMs: number | null;
lastCheckTime: string | null;
} {
const targets = this.getTargets();
let up = 0;
let down = 0;
let totalLatency = 0;
let latencyCount = 0;
let totalDuration = 0;
let durationCount = 0;
let lastCheckTime: string | null = null;
for (const target of targets) {
@@ -245,9 +228,9 @@ export class ProbeStore {
down++;
}
if (latest.latency_ms !== null) {
totalLatency += latest.latency_ms;
latencyCount++;
if (latest.duration_ms !== null) {
totalDuration += latest.duration_ms;
durationCount++;
}
if (!lastCheckTime || latest.timestamp > lastCheckTime) {
@@ -262,7 +245,7 @@ export class ProbeStore {
total: targets.length,
up,
down,
avgLatencyMs: latencyCount > 0 ? Math.round((totalLatency / latencyCount) * 100) / 100 : null,
avgDurationMs: durationCount > 0 ? Math.round((totalDuration / durationCount) * 100) / 100 : null,
lastCheckTime,
};
}
@@ -270,10 +253,10 @@ export class ProbeStore {
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 ?",
"SELECT duration_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();
.all(targetId, limit) as Array<{ duration_ms: number }>;
return rows.map((r) => r.duration_ms).reverse();
}
close(): void {
@@ -282,6 +265,33 @@ export class ProbeStore {
}
}
function buildTargetDisplay(t: ResolvedTarget): string {
if (t.type === "http") {
return t.http.url;
}
const parts = [t.command.exec, ...t.command.args];
return `exec ${parts.join(" ")}`;
}
function buildTargetConfig(t: ResolvedTarget): string {
if (t.type === "http") {
return JSON.stringify({
url: t.http.url,
method: t.http.method,
headers: t.http.headers,
body: t.http.body,
maxBodyBytes: t.http.maxBodyBytes,
});
}
return JSON.stringify({
exec: t.command.exec,
args: t.command.args,
cwd: t.command.cwd,
env: t.command.env,
maxOutputBytes: t.command.maxOutputBytes,
});
}
function ensureDir(dir: string): void {
try {
fsMkdirSync(dir, { recursive: true });