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, {
|
||||
|
||||
Reference in New Issue
Block a user