1
0

refactor: 全面优化后端代码质量与架构

- app.ts 单体路由拆分为 routes/ + helpers + middleware + static 独立模块
- 类型去重:CheckFailure/CheckResult 以 shared/api.ts 为唯一源头,收紧 phase 联合类型
- es-toolkit 替换:isPlainObject/isNil/isEmptyObject/isEqual/isError/Semaphore/groupBy
- Bun 内置 API:Object.fromEntries 替代手写 headersToRecord
- bun:sqlite 规范:prepare() → query() 利用内置缓存,避免 N+1 查询
- 新增 getLatestChecksMap/allGetTargetStats 批量查询方法
- 新增 backend-code-quality/api-route-separation/batch-data-queries 规范
- 补充 openspec/config.yaml 后端开发规范与 DEVELOPMENT.md 后端开发指引
This commit is contained in:
2026-05-12 15:15:36 +08:00
parent 696db6ffb5
commit f7facb7232
24 changed files with 868 additions and 368 deletions

View File

@@ -1,16 +1,13 @@
import type {
ApiErrorResponse,
CheckFailure,
CheckResult,
HealthResponse,
HistoryResponse,
RuntimeMode,
SummaryResponse,
TargetStatus,
TrendPoint,
} from "../shared/api";
import type { StoredCheckResult } from "./checker/types";
import type { RuntimeMode } from "../shared/api";
import type { ProbeStore } from "./checker/store";
import { jsonResponse, createApiError } from "./helpers";
import { guardGetHead } from "./middleware";
import { serveStaticAsset } from "./static";
import { handleHealth } from "./routes/health";
import { handleSummary } from "./routes/summary";
import { handleTargets } from "./routes/targets";
import { handleHistory } from "./routes/history";
import { handleTrend } from "./routes/trend";
export interface StaticAssets {
indexHtml: Blob;
@@ -28,11 +25,7 @@ export function createFetchHandler(options: AppOptions) {
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 });
return handleHealth(request.method, options.mode);
}
if (url.pathname.startsWith("/api/") && options.store) {
@@ -59,18 +52,17 @@ export function createFetchHandler(options: AppOptions) {
}
function handleApiRoute(url: URL, request: Request, store: ProbeStore, mode: RuntimeMode): Response {
const { method } = request;
const guardResult = guardGetHead(request.method, mode);
if (guardResult) return guardResult;
if (!allowsGetHead(method)) {
return methodNotAllowedResponse(["GET", "HEAD"], mode);
}
const method = request.method;
if (url.pathname === "/api/summary") {
return jsonResponse(createSummaryResponse(store), { method, mode });
return handleSummary(store, method, mode);
}
if (url.pathname === "/api/targets") {
return jsonResponse(createTargetsResponse(store), { method, mode });
return handleTargets(store, method, mode);
}
const historyMatch = url.pathname.match(/^\/api\/targets\/([^/]+)\/history$/);
@@ -85,260 +77,3 @@ function handleApiRoute(url: URL, request: Request, store: ProbeStore, mode: Run
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";
}

View File

@@ -1,3 +1,4 @@
import { isError } from "es-toolkit";
import type { CheckResult, ResolvedCommandTarget } from "./types";
import { checkCommandExpect } from "./expect/command";
import { errorFailure } from "./expect/failure";
@@ -73,7 +74,7 @@ export async function runCommandCheck(target: ResolvedCommandTarget): Promise<Ch
matched: false,
durationMs,
statusDetail: null,
failure: errorFailure("exitCode", "spawn", error instanceof Error ? error.message : String(error)),
failure: errorFailure("exitCode", "spawn", isError(error) ? error.message : String(error)),
};
}

View File

@@ -2,32 +2,31 @@ import type { CheckResult, ResolvedTarget } from "./types";
import type { ProbeStore } from "./store";
import { runHttpCheck } from "./fetcher";
import { runCommandCheck } from "./command-runner";
import { groupBy, Semaphore } from "es-toolkit";
export class ProbeEngine {
private timers: ReturnType<typeof setInterval>[] = [];
private store: ProbeStore;
private targets: ResolvedTarget[];
private targetNameToId: Map<string, number> = new Map();
private maxConcurrentChecks: number;
private running = 0;
private queue: Array<() => void> = [];
private semaphore: Semaphore;
constructor(store: ProbeStore, targets: ResolvedTarget[], maxConcurrentChecks?: number) {
this.store = store;
this.targets = targets;
this.maxConcurrentChecks = maxConcurrentChecks ?? 20;
this.semaphore = new Semaphore(maxConcurrentChecks ?? 20);
this.refreshCache();
}
start(): void {
const groups = this.groupByInterval(this.targets);
const groups = groupBy(this.targets, (t) => t.intervalMs);
for (const [intervalMs, groupTargets] of groups) {
for (const [intervalMs, groupTargets] of Object.entries(groups)) {
void this.probeGroup(groupTargets);
const timer = setInterval(() => {
void this.probeGroup(groupTargets);
}, intervalMs);
}, Number(intervalMs));
this.timers.push(timer);
}
@@ -40,45 +39,14 @@ export class ProbeEngine {
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 acquire(): Promise<void> {
if (this.running < this.maxConcurrentChecks) {
this.running++;
return;
}
return new Promise<void>((resolve) => {
this.queue.push(resolve);
});
}
private release(): void {
const next = this.queue.shift();
if (next) {
next();
} else {
this.running--;
}
}
private async probeGroup(targets: ResolvedTarget[]): Promise<void> {
const results = await Promise.allSettled(
targets.map(async (target) => {
await this.acquire();
await this.semaphore.acquire();
try {
return await this.runCheck(target);
} finally {
this.release();
this.semaphore.release();
}
}),
);

View File

@@ -3,8 +3,7 @@ import * as cheerio from "cheerio";
import * as xpath from "xpath";
import { DOMParser } from "@xmldom/xmldom";
import { mismatchFailure, errorFailure } from "./failure";
const isObject = (v: unknown): v is Record<string, unknown> => v !== null && typeof v === "object" && !Array.isArray(v);
import { isNil, isEmptyObject, isEqual, isPlainObject } from "es-toolkit";
export function evaluateJsonPath(json: unknown, path: string): unknown {
if (!path.startsWith("$.")) return undefined;
@@ -34,7 +33,7 @@ export function applyOperator(actual: unknown, op: ExpectOperator): boolean {
switch (key) {
case "equals":
if (actual !== expected) return false;
if (!isEqual(actual, expected)) return false;
break;
case "contains":
if (!String(actual).includes(expected as string)) return false;
@@ -44,11 +43,7 @@ export function applyOperator(actual: unknown, op: ExpectOperator): boolean {
break;
case "empty": {
const isEmpty =
actual === null ||
actual === undefined ||
actual === "" ||
(Array.isArray(actual) && actual.length === 0) ||
(typeof actual === "object" && Object.keys(actual as object).length === 0);
isNil(actual) || actual === "" || (Array.isArray(actual) && actual.length === 0) || isEmptyObject(actual);
if (expected !== isEmpty) return false;
break;
}
@@ -78,7 +73,7 @@ export function applyOperator(actual: unknown, op: ExpectOperator): boolean {
}
export function checkExpectValue(actual: unknown, expected: ExpectValue): boolean {
if (isObject(expected)) {
if (isPlainObject(expected)) {
return applyOperator(actual, expected as ExpectOperator);
}
return applyOperator(actual, { equals: expected as string | number | boolean | null });

View File

@@ -1,14 +1,7 @@
import type { CheckResult, ResolvedHttpTarget } from "./types";
import { checkHttpExpect } from "./expect/http";
import { errorFailure } from "./expect/failure";
function headersToRecord(headers: Headers): Record<string, string> {
const result: Record<string, string> = {};
headers.forEach((value, key) => {
result[key] = value;
});
return result;
}
import { isError } from "es-toolkit";
export async function runHttpCheck(target: ResolvedHttpTarget): Promise<CheckResult> {
const timestamp = new Date().toISOString();
@@ -27,7 +20,7 @@ export async function runHttpCheck(target: ResolvedHttpTarget): Promise<CheckRes
const durationMs = Math.round(performance.now() - start);
const statusCode = response.status;
const responseHeaders = headersToRecord(response.headers);
const responseHeaders = Object.fromEntries(response.headers);
const hasBodyRules = !!(target.expect?.body && target.expect.body.length > 0);
@@ -93,7 +86,7 @@ export async function runHttpCheck(target: ResolvedHttpTarget): Promise<CheckRes
failure: errorFailure(
"status",
"request",
isTimeout ? `请求超时 (${target.timeoutMs}ms)` : error instanceof Error ? error.message : String(error),
isTimeout ? `请求超时 (${target.timeoutMs}ms)` : isError(error) ? error.message : String(error),
),
};
}

View File

@@ -112,7 +112,7 @@ export class ProbeStore {
}): void {
if (this.closed) return;
this.db
.prepare(
.query(
"INSERT INTO check_results (target_id, timestamp, matched, duration_ms, status_detail, failure) VALUES (?, ?, ?, ?, ?, ?)",
)
.run(
@@ -139,12 +139,12 @@ export class ProbeStore {
pageSize = 20,
): { items: StoredCheckResult[]; total: number; page: number; pageSize: number } {
const countRow = this.db
.prepare("SELECT COUNT(*) as total FROM check_results WHERE target_id = ? AND timestamp >= ? AND timestamp <= ?")
.query("SELECT COUNT(*) as total FROM check_results WHERE target_id = ? AND timestamp >= ? AND timestamp <= ?")
.get(targetId, from, to) as { total: number };
const offset = (page - 1) * pageSize;
const items = this.db
.prepare(
.query(
"SELECT * FROM check_results WHERE target_id = ? AND timestamp >= ? AND timestamp <= ? ORDER BY timestamp DESC LIMIT ? OFFSET ?",
)
.all(targetId, from, to, pageSize, offset) as StoredCheckResult[];
@@ -157,7 +157,7 @@ export class ProbeStore {
availability: number;
} {
const row = this.db
.prepare(
.query(
`SELECT
COUNT(*) as totalChecks,
COALESCE(SUM(CASE WHEN matched = 1 THEN 1 ELSE 0 END), 0) as upCount
@@ -186,7 +186,7 @@ export class ProbeStore {
totalChecks: number;
}> {
return this.db
.prepare(
.query(
`SELECT
strftime('%Y-%m-%dT%H:00:00', timestamp) as hour,
AVG(CASE WHEN matched = 1 THEN duration_ms END) as avgDurationMs,
@@ -212,12 +212,13 @@ export class ProbeStore {
lastCheckTime: string | null;
} {
const targets = this.getTargets();
const latestChecksMap = this.getLatestChecksMap();
let up = 0;
let down = 0;
let lastCheckTime: string | null = null;
for (const target of targets) {
const latest = this.getLatestCheck(target.id);
const latest = latestChecksMap.get(target.id);
if (latest) {
if (latest.matched) {
@@ -247,7 +248,7 @@ export class ProbeStore {
limit: number,
): Array<{ timestamp: string; duration_ms: number | null; matched: number }> {
return this.db
.prepare(
.query(
"SELECT timestamp, duration_ms, matched FROM check_results WHERE target_id = ? ORDER BY timestamp DESC LIMIT ?",
)
.all(targetId, limit) as Array<{
@@ -257,6 +258,38 @@ export class ProbeStore {
}>;
}
getLatestChecksMap(): Map<number, StoredCheckResult> {
const rows = this.db
.query(
`SELECT cr.* FROM check_results cr
INNER JOIN (
SELECT target_id, MAX(timestamp) as max_ts
FROM check_results
GROUP BY target_id
) latest ON cr.target_id = latest.target_id AND cr.timestamp = latest.max_ts`,
)
.all() as StoredCheckResult[];
return new Map(rows.map((r) => [r.target_id, r]));
}
getAllTargetStats(): Map<number, { totalChecks: number; availability: number }> {
const rows = this.db
.query(
`SELECT target_id, COUNT(*) as totalChecks,
COALESCE(SUM(CASE WHEN matched = 1 THEN 1 ELSE 0 END), 0) as upCount
FROM check_results
GROUP BY target_id`,
)
.all() as Array<{ target_id: number; totalChecks: number; upCount: number }>;
const result = new Map<number, { totalChecks: number; availability: number }>();
for (const row of rows) {
const availability = row.totalChecks > 0 ? Math.round((row.upCount / row.totalChecks) * 10000) / 100 : 0;
result.set(row.target_id, { totalChecks: row.totalChecks, availability });
}
return result;
}
close(): void {
this.closed = true;
this.db.close();

View File

@@ -1,3 +1,5 @@
import type { CheckResult as ApiCheckResult, CheckFailure } from "../../shared/api";
export type TargetType = "http" | "command";
export interface ProbeConfig {
@@ -147,22 +149,9 @@ export interface ResolvedCommandConfig {
export type ResolvedTarget = ResolvedHttpTarget | ResolvedCommandTarget;
export interface CheckFailure {
kind: "error" | "mismatch";
phase: "status" | "duration" | "headers" | "body" | "exitCode" | "stdout" | "stderr";
path: string;
expected?: unknown;
actual?: unknown;
message: string;
}
export interface CheckResult {
export type { CheckFailure };
export interface CheckResult extends ApiCheckResult {
targetName: string;
timestamp: string;
matched: boolean;
durationMs: number | null;
statusDetail: string | null;
failure: CheckFailure | null;
}
export interface StoredTarget {

79
src/server/helpers.ts Normal file
View File

@@ -0,0 +1,79 @@
import type { ApiErrorResponse, CheckFailure, CheckResult, HealthResponse, RuntimeMode } from "../shared/api";
import type { StoredCheckResult } from "./checker/types";
export function createApiError(error: string, status: number): ApiErrorResponse {
return { error, status };
}
export 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;
}
export 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,
});
}
export function methodNotAllowedResponse(allow: string[], mode: RuntimeMode): Response {
return jsonResponse(createApiError("Method not allowed", 405), {
mode,
status: 405,
headers: { Allow: allow.join(", ") },
});
}
export function allowsGetHead(method: string): boolean {
return method === "GET" || method === "HEAD";
}
export 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`;
}
export 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,
};
}
export function createHealthResponse(): HealthResponse {
return {
ok: true,
service: "dial-server",
timestamp: new Date().toISOString(),
};
}

58
src/server/middleware.ts Normal file
View File

@@ -0,0 +1,58 @@
import type { RuntimeMode } from "../shared/api";
import { allowsGetHead, createApiError, jsonResponse, methodNotAllowedResponse } from "./helpers";
export function guardGetHead(method: string, mode: RuntimeMode): Response | null {
if (!allowsGetHead(method)) {
return methodNotAllowedResponse(["GET", "HEAD"], mode);
}
return null;
}
export function validateTargetId(idStr: string, mode: RuntimeMode): { id: number } | Response {
const id = Number(idStr);
if (!Number.isInteger(id) || id <= 0) {
return jsonResponse(createApiError("Invalid target ID", 400), { mode, status: 400 });
}
return { id };
}
export function validateTimeRange(
from: string | null,
to: string | null,
mode: RuntimeMode,
): { from: string; to: string } | Response {
if (!from || !to) {
return jsonResponse(createApiError("from and to parameters are required", 400), { mode, status: 400 });
}
if (isNaN(new Date(from).getTime()) || isNaN(new Date(to).getTime())) {
return jsonResponse(createApiError("Invalid from or to parameter format", 400), { mode, status: 400 });
}
return { from, to };
}
export function validatePagination(
pageParam: string | null,
pageSizeParam: string | null,
mode: RuntimeMode,
): { page: number; pageSize: number } | Response {
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), { mode, status: 400 });
}
}
if (pageSizeParam !== null) {
pageSize = Number(pageSizeParam);
if (!Number.isInteger(pageSize) || pageSize <= 0) {
return jsonResponse(createApiError("Invalid pageSize parameter", 400), { mode, status: 400 });
}
}
return { page, pageSize };
}

View File

@@ -0,0 +1,10 @@
import type { RuntimeMode } from "../../shared/api";
import { createHealthResponse, jsonResponse, allowsGetHead, methodNotAllowedResponse } from "../helpers";
export function handleHealth(method: string, mode: RuntimeMode): Response {
if (!allowsGetHead(method)) {
return methodNotAllowedResponse(["GET", "HEAD"], mode);
}
return jsonResponse(createHealthResponse(), { method, mode });
}

View File

@@ -0,0 +1,30 @@
import type { RuntimeMode, HistoryResponse } from "../../shared/api";
import type { ProbeStore } from "../checker/store";
import { jsonResponse, mapCheckResult } from "../helpers";
import { validateTargetId, validateTimeRange, validatePagination } from "../middleware";
export function handleHistory(idStr: string, url: URL, method: string, store: ProbeStore, mode: RuntimeMode): Response {
const idResult = validateTargetId(idStr, mode);
if (idResult instanceof Response) return idResult;
const target = store.getTargetById(idResult.id);
if (!target) {
return jsonResponse({ error: "Target not found", status: 404 } as const, { method, mode, status: 404 });
}
const timeResult = validateTimeRange(url.searchParams.get("from"), url.searchParams.get("to"), mode);
if (timeResult instanceof Response) return timeResult;
const pageResult = validatePagination(url.searchParams.get("page"), url.searchParams.get("pageSize"), mode);
if (pageResult instanceof Response) return pageResult;
const result = store.getHistory(idResult.id, timeResult.from, timeResult.to, pageResult.page, pageResult.pageSize);
const response: HistoryResponse = {
items: result.items.map(mapCheckResult),
total: result.total,
page: result.page,
pageSize: result.pageSize,
};
return jsonResponse(response, { method, mode });
}

View File

@@ -0,0 +1,15 @@
import type { RuntimeMode, SummaryResponse } from "../../shared/api";
import type { ProbeStore } from "../checker/store";
import { jsonResponse } from "../helpers";
export function handleSummary(store: ProbeStore, method: string, mode: RuntimeMode): Response {
const summary = store.getSummary();
const response: SummaryResponse = {
total: summary.total,
up: summary.up,
down: summary.down,
lastCheckTime: summary.lastCheckTime,
};
return jsonResponse(response, { method, mode });
}

View File

@@ -0,0 +1,36 @@
import type { RuntimeMode, TargetStatus } from "../../shared/api";
import type { ProbeStore } from "../checker/store";
import { formatDuration, jsonResponse, mapCheckResult } from "../helpers";
export function handleTargets(store: ProbeStore, method: string, mode: RuntimeMode): Response {
const targets = store.getTargets();
const latestChecksMap = store.getLatestChecksMap();
const allStats = store.getAllTargetStats();
const result: TargetStatus[] = targets.map((target) => {
const latest = latestChecksMap.get(target.id) ?? null;
const stats = allStats.get(target.id) ?? { totalChecks: 0, availability: 0 };
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,
},
};
});
return jsonResponse(result, { method, mode });
}

View File

@@ -0,0 +1,26 @@
import type { RuntimeMode, TrendPoint } from "../../shared/api";
import type { ProbeStore } from "../checker/store";
import { jsonResponse } from "../helpers";
import { validateTargetId, validateTimeRange } from "../middleware";
export function handleTrend(idStr: string, url: URL, method: string, store: ProbeStore, mode: RuntimeMode): Response {
const idResult = validateTargetId(idStr, mode);
if (idResult instanceof Response) return idResult;
const target = store.getTargetById(idResult.id);
if (!target) {
return jsonResponse({ error: "Target not found", status: 404 } as const, { method, mode, status: 404 });
}
const timeResult = validateTimeRange(url.searchParams.get("from"), url.searchParams.get("to"), mode);
if (timeResult instanceof Response) return timeResult;
const trend: TrendPoint[] = store.getTrend(idResult.id, timeResult.from, timeResult.to).map((row) => ({
hour: row.hour,
avgDurationMs: row.avgDurationMs,
availability: Math.round(row.availability * 100) / 100,
totalChecks: row.totalChecks,
}));
return jsonResponse(trend, { method, mode });
}

54
src/server/static.ts Normal file
View File

@@ -0,0 +1,54 @@
import type { RuntimeMode } from "../shared/api";
import { createHeaders } from "./helpers";
import type { StaticAssets } from "./app";
export 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);
}
export 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",
}),
});
}
export function hasFileExtension(pathname: string): boolean {
return /\/[^/]+\.[^/]+$/.test(pathname);
}
export 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";
}