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

@@ -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, {