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:
@@ -1,4 +1,14 @@
|
||||
import type { ApiErrorResponse, DemoResponse, HealthResponse, RuntimeMode } from "../shared/api";
|
||||
import type {
|
||||
ApiErrorResponse,
|
||||
CheckResult,
|
||||
HealthResponse,
|
||||
RuntimeMode,
|
||||
SummaryResponse,
|
||||
TargetStatus,
|
||||
TrendPoint,
|
||||
} from "../shared/api";
|
||||
import type { StoredCheckResult } from "./checker/types";
|
||||
import type { ProbeStore } from "./checker/store";
|
||||
|
||||
export interface StaticAssets {
|
||||
indexHtml: Blob;
|
||||
@@ -8,6 +18,7 @@ export interface StaticAssets {
|
||||
export interface AppOptions {
|
||||
mode: RuntimeMode;
|
||||
staticAssets?: StaticAssets;
|
||||
store?: ProbeStore;
|
||||
}
|
||||
|
||||
export function createFetchHandler(options: AppOptions) {
|
||||
@@ -22,19 +33,15 @@ export function createFetchHandler(options: AppOptions) {
|
||||
return jsonResponse(createHealthResponse(), { method: request.method, mode: options.mode });
|
||||
}
|
||||
|
||||
if (url.pathname === "/api/demo") {
|
||||
if (!allowsGetHead(request.method)) {
|
||||
return methodNotAllowedResponse(["GET", "HEAD"], options.mode);
|
||||
}
|
||||
|
||||
return jsonResponse(createDemoResponse(options.mode), { method: request.method, mode: options.mode });
|
||||
if (url.pathname.startsWith("/api/") && options.store) {
|
||||
return handleApiRoute(url, request, options.store, options.mode);
|
||||
}
|
||||
|
||||
if (url.pathname.startsWith("/api/")) {
|
||||
return jsonResponse(createApiError("API route not found", 404), {
|
||||
return jsonResponse(createApiError("Service not ready", 503), {
|
||||
method: request.method,
|
||||
mode: options.mode,
|
||||
status: 404,
|
||||
status: 503,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -49,19 +56,159 @@ export function createFetchHandler(options: AppOptions) {
|
||||
};
|
||||
}
|
||||
|
||||
function createDemoResponse(mode: RuntimeMode): DemoResponse {
|
||||
function handleApiRoute(url: URL, request: Request, store: ProbeStore, mode: RuntimeMode): Response {
|
||||
const { method } = request;
|
||||
|
||||
if (!allowsGetHead(method)) {
|
||||
return methodNotAllowedResponse(["GET", "HEAD"], mode);
|
||||
}
|
||||
|
||||
if (url.pathname === "/api/summary") {
|
||||
return jsonResponse(createSummaryResponse(store), { method, mode });
|
||||
}
|
||||
|
||||
if (url.pathname === "/api/targets") {
|
||||
return jsonResponse(createTargetsResponse(store), { method, mode });
|
||||
}
|
||||
|
||||
const historyMatch = url.pathname.match(/^\/api\/targets\/([^/]+)\/history$/);
|
||||
if (historyMatch) {
|
||||
return handleHistory(historyMatch[1]!, url, method, store, mode);
|
||||
}
|
||||
|
||||
const trendMatch = url.pathname.match(/^\/api\/targets\/([^/]+)\/trend$/);
|
||||
if (trendMatch) {
|
||||
return handleTrend(trendMatch[1]!, url, method, store, mode);
|
||||
}
|
||||
|
||||
return jsonResponse(createApiError("API route not found", 404), { method, mode, status: 404 });
|
||||
}
|
||||
|
||||
function handleHistory(
|
||||
idStr: string,
|
||||
url: URL,
|
||||
method: string,
|
||||
store: ProbeStore,
|
||||
mode: RuntimeMode,
|
||||
): Response {
|
||||
const id = Number(idStr);
|
||||
|
||||
if (!Number.isInteger(id) || id <= 0) {
|
||||
return jsonResponse(createApiError("Invalid target ID", 400), { method, mode, status: 400 });
|
||||
}
|
||||
|
||||
const target = store.getTargetById(id);
|
||||
if (!target) {
|
||||
return jsonResponse(createApiError("Target not found", 404), { method, mode, status: 404 });
|
||||
}
|
||||
|
||||
const limitParam = url.searchParams.get("limit");
|
||||
let limit = 20;
|
||||
|
||||
if (limitParam !== null) {
|
||||
limit = Number(limitParam);
|
||||
if (!Number.isInteger(limit) || limit <= 0) {
|
||||
return jsonResponse(createApiError("Invalid limit parameter", 400), { method, mode, status: 400 });
|
||||
}
|
||||
}
|
||||
|
||||
const rows = store.getHistory(id, limit);
|
||||
const results: CheckResult[] = rows.map(mapCheckResult);
|
||||
|
||||
return jsonResponse(results, { method, mode });
|
||||
}
|
||||
|
||||
function handleTrend(
|
||||
idStr: string,
|
||||
url: URL,
|
||||
method: string,
|
||||
store: ProbeStore,
|
||||
mode: RuntimeMode,
|
||||
): Response {
|
||||
const id = Number(idStr);
|
||||
|
||||
if (!Number.isInteger(id) || id <= 0) {
|
||||
return jsonResponse(createApiError("Invalid target ID", 400), { method, mode, status: 400 });
|
||||
}
|
||||
|
||||
const target = store.getTargetById(id);
|
||||
if (!target) {
|
||||
return jsonResponse(createApiError("Target not found", 404), { method, mode, status: 404 });
|
||||
}
|
||||
|
||||
const hoursParam = url.searchParams.get("hours");
|
||||
let hours = 24;
|
||||
|
||||
if (hoursParam !== null) {
|
||||
hours = Number(hoursParam);
|
||||
if (!Number.isInteger(hours) || hours <= 0) {
|
||||
return jsonResponse(createApiError("Invalid hours parameter", 400), { method, mode, status: 400 });
|
||||
}
|
||||
}
|
||||
|
||||
const trend: TrendPoint[] = store.getTrend(id, hours).map((row) => ({
|
||||
hour: row.hour,
|
||||
avgLatencyMs: row.avgLatencyMs,
|
||||
availability: Math.round(row.availability * 100) / 100,
|
||||
totalChecks: row.totalChecks,
|
||||
}));
|
||||
|
||||
return jsonResponse(trend, { method, mode });
|
||||
}
|
||||
|
||||
function createSummaryResponse(store: ProbeStore): SummaryResponse {
|
||||
const summary = store.getSummary();
|
||||
return {
|
||||
message: "Bun 后端已通过 /api/demo 连接到 React 前端。",
|
||||
runtime: {
|
||||
mode,
|
||||
bunVersion: Bun.version,
|
||||
platform: process.platform,
|
||||
arch: process.arch,
|
||||
timestamp: new Date().toISOString(),
|
||||
},
|
||||
total: summary.total,
|
||||
up: summary.up,
|
||||
down: summary.down,
|
||||
avgLatencyMs: summary.avgLatencyMs,
|
||||
lastCheckTime: summary.lastCheckTime,
|
||||
};
|
||||
}
|
||||
|
||||
function createTargetsResponse(store: ProbeStore): TargetStatus[] {
|
||||
const targets = store.getTargets();
|
||||
|
||||
return targets.map((target) => {
|
||||
const latest = store.getLatestCheck(target.id);
|
||||
const stats = store.getTargetStats(target.id);
|
||||
|
||||
return {
|
||||
id: target.id,
|
||||
name: target.name,
|
||||
url: target.url,
|
||||
method: target.method,
|
||||
interval: formatDuration(target.interval_ms),
|
||||
latestCheck: latest ? mapCheckResult(latest) : null,
|
||||
sparkline: store.getSparkline(target.id),
|
||||
stats: {
|
||||
totalChecks: stats.totalChecks,
|
||||
availability: stats.availability,
|
||||
avgLatencyMs: stats.avgLatencyMs,
|
||||
p99LatencyMs: stats.p99LatencyMs,
|
||||
},
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
function mapCheckResult(row: StoredCheckResult): CheckResult {
|
||||
return {
|
||||
timestamp: row.timestamp,
|
||||
success: row.success === 1,
|
||||
statusCode: row.status_code,
|
||||
latencyMs: row.latency_ms,
|
||||
error: row.error,
|
||||
matched: row.matched === 1,
|
||||
};
|
||||
}
|
||||
|
||||
function formatDuration(ms: number): string {
|
||||
if (ms >= 60000 && ms % 60000 === 0) return `${ms / 60000}m`;
|
||||
if (ms >= 1000 && ms % 1000 === 0) return `${ms / 1000}s`;
|
||||
return `${ms}ms`;
|
||||
}
|
||||
|
||||
function createHealthResponse(): HealthResponse {
|
||||
return {
|
||||
ok: true,
|
||||
@@ -87,7 +234,7 @@ function methodNotAllowedResponse(allow: string[], mode: RuntimeMode): Response
|
||||
}
|
||||
|
||||
function jsonResponse(
|
||||
body: ApiErrorResponse | DemoResponse | HealthResponse,
|
||||
body: unknown,
|
||||
options: { method?: string; mode: RuntimeMode; status?: number; headers?: HeadersInit },
|
||||
): Response {
|
||||
const headers = createHeaders(options.mode, {
|
||||
|
||||
104
src/server/checker/config-loader.ts
Normal file
104
src/server/checker/config-loader.ts
Normal 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;
|
||||
}
|
||||
87
src/server/checker/engine.ts
Normal file
87
src/server/checker/engine.ts
Normal 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[];
|
||||
}
|
||||
65
src/server/checker/fetcher.ts
Normal file
65
src/server/checker/fetcher.ts
Normal 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
277
src/server/checker/store.ts
Normal 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
|
||||
}
|
||||
}
|
||||
79
src/server/checker/types.ts
Normal file
79
src/server/checker/types.ts
Normal 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;
|
||||
}
|
||||
@@ -1,39 +1,12 @@
|
||||
export function readRuntimeConfig(argv: string[] = process.argv.slice(2)): { configPath: string } {
|
||||
if (argv.length === 0) {
|
||||
throw new Error("需要指定 YAML 配置文件路径\n用法: gateway-checker <config.yaml>");
|
||||
}
|
||||
|
||||
return { configPath: argv[0]! };
|
||||
}
|
||||
|
||||
export interface RuntimeConfig {
|
||||
host: string;
|
||||
port: number;
|
||||
}
|
||||
|
||||
const DEFAULT_HOST = "127.0.0.1";
|
||||
const DEFAULT_PORT = 3000;
|
||||
|
||||
export function readRuntimeConfig(
|
||||
argv: string[] = process.argv.slice(2),
|
||||
env: Record<string, string | undefined> = Bun.env,
|
||||
): RuntimeConfig {
|
||||
const host = readOption(argv, "host") ?? env.HOST ?? DEFAULT_HOST;
|
||||
const portValue = readOption(argv, "port") ?? env.PORT ?? String(DEFAULT_PORT);
|
||||
const port = Number(portValue);
|
||||
|
||||
if (!Number.isInteger(port) || port < 0 || port > 65535) {
|
||||
throw new Error(`无效端口: ${portValue}`);
|
||||
}
|
||||
|
||||
return { host, port };
|
||||
}
|
||||
|
||||
function readOption(argv: string[], name: string): string | undefined {
|
||||
const prefix = `--${name}=`;
|
||||
const inline = argv.find((value) => value.startsWith(prefix));
|
||||
|
||||
if (inline) {
|
||||
return inline.slice(prefix.length);
|
||||
}
|
||||
|
||||
const index = argv.indexOf(`--${name}`);
|
||||
|
||||
if (index >= 0) {
|
||||
return argv[index + 1];
|
||||
}
|
||||
|
||||
return undefined;
|
||||
}
|
||||
|
||||
@@ -1,7 +1,27 @@
|
||||
import { readRuntimeConfig } from "./config";
|
||||
import { loadConfig } from "./checker/config-loader";
|
||||
import { ProbeStore } from "./checker/store";
|
||||
import { ProbeEngine } from "./checker/engine";
|
||||
import { startServer } from "./server";
|
||||
import { readRuntimeConfig } from "./config";
|
||||
|
||||
startServer({
|
||||
config: readRuntimeConfig(),
|
||||
mode: "development",
|
||||
async function main() {
|
||||
const { configPath } = readRuntimeConfig();
|
||||
const config = await loadConfig(configPath);
|
||||
|
||||
const store = new ProbeStore(`${config.dataDir}/probe.db`);
|
||||
store.syncTargets(config.targets);
|
||||
|
||||
const engine = new ProbeEngine(store, config.targets);
|
||||
engine.start();
|
||||
|
||||
startServer({
|
||||
config: { host: config.host, port: config.port },
|
||||
mode: "development",
|
||||
store,
|
||||
});
|
||||
}
|
||||
|
||||
void main().catch((error) => {
|
||||
console.error("启动失败:", error instanceof Error ? error.message : error);
|
||||
process.exit(1);
|
||||
});
|
||||
|
||||
@@ -1,21 +1,25 @@
|
||||
import type { RuntimeMode } from "../shared/api";
|
||||
import { createFetchHandler, type StaticAssets } from "./app";
|
||||
import { readRuntimeConfig, type RuntimeConfig } from "./config";
|
||||
import type { StaticAssets } from "./app";
|
||||
import type { ProbeStore } from "./checker/store";
|
||||
import { createFetchHandler } from "./app";
|
||||
import type { RuntimeConfig } from "./config";
|
||||
|
||||
export interface StartServerOptions {
|
||||
config?: RuntimeConfig;
|
||||
config: RuntimeConfig;
|
||||
mode: RuntimeMode;
|
||||
staticAssets?: StaticAssets;
|
||||
store?: ProbeStore;
|
||||
}
|
||||
|
||||
export function startServer(options: StartServerOptions) {
|
||||
const config = options.config ?? readRuntimeConfig();
|
||||
const { config, mode, staticAssets, store } = options;
|
||||
const server = Bun.serve({
|
||||
hostname: config.host,
|
||||
port: config.port,
|
||||
fetch: createFetchHandler({
|
||||
mode: options.mode,
|
||||
staticAssets: options.staticAssets,
|
||||
mode,
|
||||
staticAssets,
|
||||
store,
|
||||
}),
|
||||
});
|
||||
|
||||
|
||||
Reference in New Issue
Block a user