将 gateway-checker/Gateway Checker 统一替换为 dial-server/DiAL - 包名、可执行文件名、API service 标识改为 dial-server - UI 标题改为 DiAL,副标题改为统一拨测平台 - 同步更新测试断言、构建脚本、示例配置和文档
345 lines
10 KiB
TypeScript
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";
|
|
}
|