- 新增 body 分组校验:contains、regex、json(JSONPath)、css(CSS选择器)、xpath - 新增操作符系统:equals、contains、match、empty、exists、gte、lte、gt、lt - 新增 headers 响应头校验 - 引入 cheerio、xpath、@xmldom/xmldom 依赖 - BREAKING: expect.bodyContains 迁移至 expect.body.contains
292 lines
8.1 KiB
TypeScript
292 lines
8.1 KiB
TypeScript
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
|
|
}
|
|
}
|