1
0
Files
DiAL/src/server/app.ts
lanyuanxiaoyao 3fa1b3957e refactor: 项目更名为 DiAL(统一拨测平台)
将 gateway-checker/Gateway Checker 统一替换为 dial-server/DiAL
- 包名、可执行文件名、API service 标识改为 dial-server
- UI 标题改为 DiAL,副标题改为统一拨测平台
- 同步更新测试断言、构建脚本、示例配置和文档
2026-05-11 22:23:17 +08:00

345 lines
10 KiB
TypeScript

import type {
ApiErrorResponse,
CheckFailure,
CheckResult,
HealthResponse,
HistoryResponse,
RuntimeMode,
SummaryResponse,
TargetStatus,
TrendPoint,
} from "../shared/api";
import type { StoredCheckResult } from "./checker/types";
import type { ProbeStore } from "./checker/store";
export interface StaticAssets {
indexHtml: Blob;
files: Record<string, Blob>;
}
export interface AppOptions {
mode: RuntimeMode;
staticAssets?: StaticAssets;
store?: ProbeStore;
}
export function createFetchHandler(options: AppOptions) {
return (request: Request): Response => {
const url = new URL(request.url);
if (url.pathname === "/health") {
if (!allowsGetHead(request.method)) {
return methodNotAllowedResponse(["GET", "HEAD"], options.mode);
}
return jsonResponse(createHealthResponse(), { 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("Service not ready", 503), {
method: request.method,
mode: options.mode,
status: 503,
});
}
if (options.staticAssets) {
return serveStaticAsset(url.pathname, options.staticAssets, options.mode);
}
return new Response("开发期请通过 Vite 前端地址访问页面。", {
status: 404,
headers: { "Content-Type": "text/plain; charset=utf-8" },
});
};
}
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 from = url.searchParams.get("from");
const to = url.searchParams.get("to");
if (!from || !to) {
return jsonResponse(createApiError("from and to parameters are required", 400), { method, mode, status: 400 });
}
const fromDate = new Date(from);
const toDate = new Date(to);
if (isNaN(fromDate.getTime()) || isNaN(toDate.getTime())) {
return jsonResponse(createApiError("Invalid from or to parameter format", 400), { method, mode, status: 400 });
}
const pageParam = url.searchParams.get("page");
const pageSizeParam = url.searchParams.get("pageSize");
let page = 1;
let pageSize = 20;
if (pageParam !== null) {
page = Number(pageParam);
if (!Number.isInteger(page) || page <= 0) {
return jsonResponse(createApiError("Invalid page parameter", 400), { method, mode, status: 400 });
}
}
if (pageSizeParam !== null) {
pageSize = Number(pageSizeParam);
if (!Number.isInteger(pageSize) || pageSize <= 0) {
return jsonResponse(createApiError("Invalid pageSize parameter", 400), { method, mode, status: 400 });
}
}
const result = store.getHistory(id, from, to, page, pageSize);
const response: HistoryResponse = {
items: result.items.map(mapCheckResult),
total: result.total,
page: result.page,
pageSize: result.pageSize,
};
return jsonResponse(response, { 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 from = url.searchParams.get("from");
const to = url.searchParams.get("to");
if (!from || !to) {
return jsonResponse(createApiError("from and to parameters are required", 400), { method, mode, status: 400 });
}
const fromDate = new Date(from);
const toDate = new Date(to);
if (isNaN(fromDate.getTime()) || isNaN(toDate.getTime())) {
return jsonResponse(createApiError("Invalid from or to parameter format", 400), { method, mode, status: 400 });
}
const trend: TrendPoint[] = store.getTrend(id, from, to).map((row) => ({
hour: row.hour,
avgDurationMs: row.avgDurationMs,
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 {
total: summary.total,
up: summary.up,
down: summary.down,
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);
const recentSamples = store.getRecentSamples(target.id, 30);
return {
id: target.id,
name: target.name,
type: target.type,
target: target.target,
group: target.grp,
interval: formatDuration(target.interval_ms),
latestCheck: latest ? mapCheckResult(latest) : null,
recentSamples: recentSamples.map((s) => ({
timestamp: s.timestamp,
durationMs: s.duration_ms,
up: s.matched === 1,
})),
stats: {
totalChecks: stats.totalChecks,
availability: stats.availability,
},
};
});
}
function mapCheckResult(row: StoredCheckResult): CheckResult {
let failure: CheckFailure | null = null;
if (row.failure) {
try {
failure = JSON.parse(row.failure) as CheckFailure;
} catch {
console.warn(`无法解析 failure 数据: target_id=${row.target_id}, timestamp=${row.timestamp}`);
failure = null;
}
}
return {
timestamp: row.timestamp,
matched: row.matched === 1,
durationMs: row.duration_ms,
statusDetail: row.status_detail,
failure,
};
}
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,
service: "dial-server",
timestamp: new Date().toISOString(),
};
}
function createApiError(error: string, status: number): ApiErrorResponse {
return { error, status };
}
function allowsGetHead(method: string): boolean {
return method === "GET" || method === "HEAD";
}
function methodNotAllowedResponse(allow: string[], mode: RuntimeMode): Response {
return jsonResponse(createApiError("Method not allowed", 405), {
mode,
status: 405,
headers: { Allow: allow.join(", ") },
});
}
function jsonResponse(
body: unknown,
options: { method?: string; mode: RuntimeMode; status?: number; headers?: HeadersInit },
): Response {
const headers = createHeaders(options.mode, {
"Content-Type": "application/json; charset=utf-8",
...options.headers,
});
const responseBody = options.method === "HEAD" ? null : JSON.stringify(body);
return new Response(responseBody, {
status: options.status,
headers,
});
}
function serveStaticAsset(pathname: string, staticAssets: StaticAssets, mode: RuntimeMode): Response {
if (pathname === "/") {
return htmlResponse(staticAssets.indexHtml, mode);
}
const asset = staticAssets.files[pathname];
if (asset) {
return new Response(asset, {
headers: createHeaders(mode, {
"Content-Type": contentTypeFor(pathname),
"Cache-Control": "public, max-age=31536000, immutable",
}),
});
}
if (pathname.startsWith("/assets/") || hasFileExtension(pathname)) {
return new Response("Not Found", {
status: 404,
headers: createHeaders(mode, { "Content-Type": "text/plain; charset=utf-8" }),
});
}
return htmlResponse(staticAssets.indexHtml, mode);
}
function htmlResponse(indexHtml: Blob, mode: RuntimeMode): Response {
return new Response(indexHtml, {
headers: createHeaders(mode, {
"Content-Type": "text/html; charset=utf-8",
"Cache-Control": "no-cache",
}),
});
}
function createHeaders(mode: RuntimeMode, init: HeadersInit): Headers {
const headers = new Headers(init);
if (mode === "production") {
headers.set("X-Content-Type-Options", "nosniff");
headers.set("Referrer-Policy", "strict-origin-when-cross-origin");
}
return headers;
}
function hasFileExtension(pathname: string): boolean {
return /\/[^/]+\.[^/]+$/.test(pathname);
}
function contentTypeFor(pathname: string): string {
if (pathname.endsWith(".js") || pathname.endsWith(".mjs")) return "text/javascript; charset=utf-8";
if (pathname.endsWith(".css")) return "text/css; charset=utf-8";
if (pathname.endsWith(".svg")) return "image/svg+xml";
if (pathname.endsWith(".json")) return "application/json; charset=utf-8";
if (pathname.endsWith(".png")) return "image/png";
if (pathname.endsWith(".jpg") || pathname.endsWith(".jpeg")) return "image/jpeg";
if (pathname.endsWith(".ico")) return "image/x-icon";
return "application/octet-stream";
}