refactor: 后端架构加固 — 泛型化、批量查询、bootstrap 统一、路径修复与 pageSize 上限
- CheckerDefinition 泛型化,HTTP/Command checker 移除 resolved target 断言 - 新增 ProbeStore.getAllRecentSamples 消除 targets 路由 N+1 查询 - 统一 getAllTargetStats 与 getTargetStats 的 availability 精度 - Engine rejected 结果写入 internal error 记录,提升可观测性 - 新增 bootstrap.ts 统一 dev/production 启动序列 - dataDir 相对路径改为基于配置文件目录解析 - validatePagination 增加 pageSize 上限 200 校验 - 修复 ErrorBoundary override 标记 - 更新 README/DEVELOPMENT 文档,新增完整测试覆盖
This commit is contained in:
83
src/server/bootstrap.ts
Normal file
83
src/server/bootstrap.ts
Normal file
@@ -0,0 +1,83 @@
|
||||
import { join } from "node:path";
|
||||
|
||||
import type { RuntimeMode } from "../shared/api";
|
||||
import type { StaticAssets } from "./app";
|
||||
import type { StartServerOptions } from "./server";
|
||||
|
||||
import { loadConfig, type ResolvedConfig } from "./checker/config-loader";
|
||||
import { ProbeEngine } from "./checker/engine";
|
||||
import { ProbeStore } from "./checker/store";
|
||||
import { startServer } from "./server";
|
||||
|
||||
export interface BootstrapDependencies {
|
||||
createEngine?: (
|
||||
store: ProbeStore,
|
||||
targets: ResolvedConfig["targets"],
|
||||
maxConcurrentChecks: number,
|
||||
retentionMs: number,
|
||||
) => BootstrapEngine;
|
||||
createStore?: (dbPath: string) => ProbeStore;
|
||||
exit?: (code: number) => never;
|
||||
loadConfig?: (configPath: string) => Promise<ResolvedConfig>;
|
||||
logError?: (...data: unknown[]) => void;
|
||||
onSignal?: (signal: ShutdownSignal, handler: () => void) => void;
|
||||
startServer?: (options: StartServerOptions) => unknown;
|
||||
}
|
||||
|
||||
export interface BootstrapOptions {
|
||||
configPath: string;
|
||||
mode: RuntimeMode;
|
||||
staticAssets?: StaticAssets;
|
||||
}
|
||||
|
||||
type BootstrapEngine = Pick<ProbeEngine, "start" | "stop">;
|
||||
type ShutdownSignal = "SIGINT" | "SIGTERM";
|
||||
|
||||
export async function bootstrap(options: BootstrapOptions, dependencies: BootstrapDependencies = {}): Promise<void> {
|
||||
const load = dependencies.loadConfig ?? loadConfig;
|
||||
const createStore = dependencies.createStore ?? ((dbPath: string) => new ProbeStore(dbPath));
|
||||
const createEngine =
|
||||
dependencies.createEngine ??
|
||||
((store: ProbeStore, targets: ResolvedConfig["targets"], maxConcurrentChecks: number, retentionMs: number) =>
|
||||
new ProbeEngine(store, targets, maxConcurrentChecks, retentionMs));
|
||||
const serve = dependencies.startServer ?? startServer;
|
||||
const onSignal =
|
||||
dependencies.onSignal ??
|
||||
((signal: ShutdownSignal, handler: () => void) => {
|
||||
process.on(signal, handler);
|
||||
});
|
||||
const exit = dependencies.exit ?? ((code: number) => process.exit(code));
|
||||
const logError = dependencies.logError ?? console.error;
|
||||
|
||||
let store: ProbeStore | undefined;
|
||||
let engine: BootstrapEngine | undefined;
|
||||
|
||||
try {
|
||||
const config = await load(options.configPath);
|
||||
store = createStore(join(config.dataDir, "probe.db"));
|
||||
store.syncTargets(config.targets);
|
||||
|
||||
engine = createEngine(store, config.targets, config.maxConcurrentChecks, config.retentionMs);
|
||||
engine.start();
|
||||
|
||||
const shutdown = () => {
|
||||
engine?.stop();
|
||||
store?.close();
|
||||
exit(0);
|
||||
};
|
||||
onSignal("SIGINT", shutdown);
|
||||
onSignal("SIGTERM", shutdown);
|
||||
|
||||
serve({
|
||||
config: { host: config.host, port: config.port },
|
||||
mode: options.mode,
|
||||
staticAssets: options.staticAssets,
|
||||
store,
|
||||
});
|
||||
} catch (error) {
|
||||
engine?.stop();
|
||||
store?.close();
|
||||
logError("启动失败:", error instanceof Error ? error.message : error);
|
||||
exit(1);
|
||||
}
|
||||
}
|
||||
@@ -67,7 +67,7 @@ export async function loadConfig(configPath: string): Promise<ResolvedConfig> {
|
||||
|
||||
const host = server.host ?? DEFAULT_HOST;
|
||||
const port = server.port ?? DEFAULT_PORT;
|
||||
const dataDir = server.dataDir ?? DEFAULT_DATA_DIR;
|
||||
const dataDir = resolve(configDir, server.dataDir ?? DEFAULT_DATA_DIR);
|
||||
|
||||
const maxConcurrentChecks = resolveMaxConcurrentChecks(runtime);
|
||||
const retentionMs = resolveRetention(runtime);
|
||||
|
||||
@@ -1,8 +1,9 @@
|
||||
import { groupBy, Semaphore } from "es-toolkit";
|
||||
import { groupBy, isError, Semaphore } from "es-toolkit";
|
||||
|
||||
import type { ProbeStore } from "./store";
|
||||
import type { CheckResult, ResolvedTargetBase } from "./types";
|
||||
|
||||
import { errorFailure } from "./expect/failure";
|
||||
import { checkerRegistry } from "./runner";
|
||||
|
||||
const PRUNE_INTERVAL_MS = 3600000;
|
||||
@@ -64,11 +65,21 @@ export class ProbeEngine {
|
||||
}),
|
||||
);
|
||||
|
||||
for (const result of results) {
|
||||
for (const [index, result] of results.entries()) {
|
||||
if (result.status === "fulfilled") {
|
||||
this.writeResult(result.value);
|
||||
} else {
|
||||
const target = targets[index];
|
||||
console.warn("探针执行失败:", result.reason);
|
||||
if (!target) continue;
|
||||
this.writeResult({
|
||||
durationMs: null,
|
||||
failure: errorFailure("internal", "engine", formatReason(result.reason)),
|
||||
matched: false,
|
||||
statusDetail: null,
|
||||
targetName: target.name,
|
||||
timestamp: new Date().toISOString(),
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -106,3 +117,7 @@ export class ProbeEngine {
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
function formatReason(reason: unknown): string {
|
||||
return isError(reason) ? reason.message : String(reason);
|
||||
}
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
import { isError } from "es-toolkit";
|
||||
import { resolve } from "node:path";
|
||||
|
||||
import type { CheckResult, RawTargetConfig, ResolvedTargetBase } from "../../types";
|
||||
import type { Checker, CheckerContext, CheckerValidationInput, ResolveContext } from "../types";
|
||||
import type { CheckResult, RawTargetConfig } from "../../types";
|
||||
import type { CheckerContext, CheckerDefinition, CheckerValidationInput, ResolveContext } from "../types";
|
||||
import type { CommandExpectConfig, CommandTargetConfig, ResolvedCommandTarget } from "./types";
|
||||
|
||||
import { checkDuration } from "../../expect/duration";
|
||||
@@ -13,15 +13,14 @@ import { commandCheckerSchemas } from "./schema";
|
||||
import { checkTextRules } from "./text";
|
||||
import { validateCommandConfig } from "./validate";
|
||||
|
||||
export class CommandChecker implements Checker {
|
||||
export class CommandChecker implements CheckerDefinition<ResolvedCommandTarget> {
|
||||
readonly configKey = "command";
|
||||
|
||||
readonly schemas = commandCheckerSchemas;
|
||||
|
||||
readonly type = "command";
|
||||
|
||||
async execute(target: ResolvedTargetBase, ctx: CheckerContext): Promise<CheckResult> {
|
||||
const t = target as ResolvedCommandTarget;
|
||||
async execute(t: ResolvedCommandTarget, ctx: CheckerContext): Promise<CheckResult> {
|
||||
const timestamp = new Date().toISOString();
|
||||
const start = performance.now();
|
||||
|
||||
@@ -169,7 +168,7 @@ export class CommandChecker implements Checker {
|
||||
};
|
||||
}
|
||||
|
||||
resolve(target: RawTargetConfig, context: ResolveContext): ResolvedTargetBase {
|
||||
resolve(target: RawTargetConfig, context: ResolveContext): ResolvedCommandTarget {
|
||||
const t = target as RawTargetConfig & { command: CommandTargetConfig; type: "command" };
|
||||
const commandDefaults = context.defaults["command"] as undefined | { cwd?: string; maxOutputBytes?: string };
|
||||
|
||||
@@ -197,8 +196,7 @@ export class CommandChecker implements Checker {
|
||||
} satisfies ResolvedCommandTarget;
|
||||
}
|
||||
|
||||
serialize(target: ResolvedTargetBase): { config: string; target: string } {
|
||||
const t = target as ResolvedCommandTarget;
|
||||
serialize(t: ResolvedCommandTarget): { config: string; target: string } {
|
||||
const parts = [t.command.exec, ...t.command.args];
|
||||
return {
|
||||
config: JSON.stringify({
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { isError } from "es-toolkit";
|
||||
|
||||
import type { CheckResult, RawTargetConfig, ResolvedTargetBase } from "../../types";
|
||||
import type { Checker, CheckerContext, CheckerValidationInput, ResolveContext } from "../types";
|
||||
import type { CheckResult, RawTargetConfig } from "../../types";
|
||||
import type { CheckerContext, CheckerDefinition, CheckerValidationInput, ResolveContext } from "../types";
|
||||
import type { HttpExpectConfig, HttpTargetConfig, ResolvedHttpTarget } from "./types";
|
||||
|
||||
import { checkDuration } from "../../expect/duration";
|
||||
@@ -16,15 +16,14 @@ const CHARSET_RE = /charset="?([^";\s]+)"?/i;
|
||||
const REDIRECT_STATUSES = new Set([301, 302, 303, 307, 308]);
|
||||
const SENSITIVE_HEADERS = new Set(["authorization", "cookie"]);
|
||||
|
||||
export class HttpChecker implements Checker {
|
||||
export class HttpChecker implements CheckerDefinition<ResolvedHttpTarget> {
|
||||
readonly configKey = "http";
|
||||
|
||||
readonly schemas = httpCheckerSchemas;
|
||||
|
||||
readonly type = "http";
|
||||
|
||||
async execute(target: ResolvedTargetBase, ctx: CheckerContext): Promise<CheckResult> {
|
||||
const t = target as ResolvedHttpTarget;
|
||||
async execute(t: ResolvedHttpTarget, ctx: CheckerContext): Promise<CheckResult> {
|
||||
const timestamp = new Date().toISOString();
|
||||
const expect = t.expect;
|
||||
const start = performance.now();
|
||||
@@ -117,7 +116,7 @@ export class HttpChecker implements Checker {
|
||||
}
|
||||
}
|
||||
|
||||
resolve(target: RawTargetConfig, context: ResolveContext): ResolvedTargetBase {
|
||||
resolve(target: RawTargetConfig, context: ResolveContext): ResolvedHttpTarget {
|
||||
const t = target as RawTargetConfig & { http: HttpTargetConfig; type: "http" };
|
||||
const httpDefaults = context.defaults["http"] as
|
||||
| undefined
|
||||
@@ -145,8 +144,7 @@ export class HttpChecker implements Checker {
|
||||
} satisfies ResolvedHttpTarget;
|
||||
}
|
||||
|
||||
serialize(target: ResolvedTargetBase): { config: string; target: string } {
|
||||
const t = target as ResolvedHttpTarget;
|
||||
serialize(t: ResolvedHttpTarget): { config: string; target: string } {
|
||||
return {
|
||||
config: JSON.stringify({
|
||||
body: t.http.body,
|
||||
|
||||
@@ -3,18 +3,18 @@ import type { TSchema } from "@sinclair/typebox";
|
||||
import type { ConfigValidationIssue } from "../schema/issues";
|
||||
import type { CheckResult, DefaultsConfig, RawTargetConfig, ResolvedTargetBase } from "../types";
|
||||
|
||||
export type Checker = CheckerDefinition;
|
||||
export type Checker<TResolved extends ResolvedTargetBase = ResolvedTargetBase> = CheckerDefinition<TResolved>;
|
||||
|
||||
export interface CheckerContext {
|
||||
signal: AbortSignal;
|
||||
}
|
||||
|
||||
export interface CheckerDefinition {
|
||||
export interface CheckerDefinition<TResolved extends ResolvedTargetBase = ResolvedTargetBase> {
|
||||
readonly configKey: string;
|
||||
execute(target: ResolvedTargetBase, ctx: CheckerContext): Promise<CheckResult>;
|
||||
resolve(target: RawTargetConfig, context: ResolveContext): ResolvedTargetBase;
|
||||
execute(target: TResolved, ctx: CheckerContext): Promise<CheckResult>;
|
||||
resolve(target: RawTargetConfig, context: ResolveContext): TResolved;
|
||||
readonly schemas: CheckerSchemas;
|
||||
serialize(target: ResolvedTargetBase): { config: string; target: string };
|
||||
serialize(target: TResolved): { config: string; target: string };
|
||||
readonly type: string;
|
||||
validate(input: CheckerValidationInput): ConfigValidationIssue[];
|
||||
}
|
||||
|
||||
@@ -57,6 +57,40 @@ export class ProbeStore {
|
||||
this.db.close();
|
||||
}
|
||||
|
||||
getAllRecentSamples(
|
||||
limit: number,
|
||||
): Map<number, Array<{ duration_ms: null | number; matched: number; timestamp: string }>> {
|
||||
const rows = this.db
|
||||
.query(
|
||||
`SELECT target_id, timestamp, duration_ms, matched
|
||||
FROM (
|
||||
SELECT
|
||||
target_id,
|
||||
timestamp,
|
||||
duration_ms,
|
||||
matched,
|
||||
ROW_NUMBER() OVER (PARTITION BY target_id ORDER BY timestamp DESC) as row_num
|
||||
FROM check_results
|
||||
)
|
||||
WHERE row_num <= ?
|
||||
ORDER BY target_id, timestamp DESC`,
|
||||
)
|
||||
.all(limit) as Array<{
|
||||
duration_ms: null | number;
|
||||
matched: number;
|
||||
target_id: number;
|
||||
timestamp: string;
|
||||
}>;
|
||||
|
||||
const result = new Map<number, Array<{ duration_ms: null | number; matched: number; timestamp: string }>>();
|
||||
for (const row of rows) {
|
||||
const samples = result.get(row.target_id) ?? [];
|
||||
samples.push({ duration_ms: row.duration_ms, matched: row.matched, timestamp: row.timestamp });
|
||||
result.set(row.target_id, samples);
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
getAllTargetStats(): Map<number, { availability: number; totalChecks: number }> {
|
||||
const rows = this.db
|
||||
.query(
|
||||
@@ -69,7 +103,7 @@ export class ProbeStore {
|
||||
|
||||
const result = new Map<number, { availability: number; totalChecks: number }>();
|
||||
for (const row of rows) {
|
||||
const availability = row.totalChecks > 0 ? Math.round((row.upCount / row.totalChecks) * 10000) / 100 : 0;
|
||||
const availability = row.totalChecks > 0 ? Math.round((row.upCount / row.totalChecks) * 100 * 100) / 100 : 0;
|
||||
result.set(row.target_id, { availability, totalChecks: row.totalChecks });
|
||||
}
|
||||
return result;
|
||||
|
||||
@@ -1,32 +1,9 @@
|
||||
import { loadConfig } from "./checker/config-loader";
|
||||
import { ProbeEngine } from "./checker/engine";
|
||||
import { ProbeStore } from "./checker/store";
|
||||
import { bootstrap } from "./bootstrap";
|
||||
import { readRuntimeConfig } from "./config";
|
||||
import { startServer } from "./server";
|
||||
|
||||
async function main() {
|
||||
const { configPath } = readRuntimeConfig();
|
||||
const config = await loadConfig(configPath);
|
||||
|
||||
const store = new ProbeStore(`${config.dataDir}/probe.db`);
|
||||
store.syncTargets(config.targets);
|
||||
|
||||
const engine = new ProbeEngine(store, config.targets, config.maxConcurrentChecks, config.retentionMs);
|
||||
engine.start();
|
||||
|
||||
const shutdown = () => {
|
||||
engine.stop();
|
||||
store.close();
|
||||
process.exit(0);
|
||||
};
|
||||
process.on("SIGINT", shutdown);
|
||||
process.on("SIGTERM", shutdown);
|
||||
|
||||
startServer({
|
||||
config: { host: config.host, port: config.port },
|
||||
mode: "development",
|
||||
store,
|
||||
});
|
||||
await bootstrap({ configPath, mode: "development" });
|
||||
}
|
||||
|
||||
void main().catch((error) => {
|
||||
|
||||
@@ -2,6 +2,8 @@ import type { RuntimeMode } from "../shared/api";
|
||||
|
||||
import { allowsGetHead, createApiError, jsonResponse, methodNotAllowedResponse } from "./helpers";
|
||||
|
||||
const MAX_PAGE_SIZE = 200;
|
||||
|
||||
export function guardGetHead(method: string, mode: RuntimeMode): null | Response {
|
||||
if (!allowsGetHead(method)) {
|
||||
return methodNotAllowedResponse(["GET", "HEAD"], mode);
|
||||
@@ -29,6 +31,9 @@ export function validatePagination(
|
||||
if (!Number.isInteger(pageSize) || pageSize <= 0) {
|
||||
return jsonResponse(createApiError("Invalid pageSize parameter", 400), { mode, status: 400 });
|
||||
}
|
||||
if (pageSize > MAX_PAGE_SIZE) {
|
||||
return jsonResponse(createApiError(`pageSize must not exceed ${MAX_PAGE_SIZE}`, 400), { mode, status: 400 });
|
||||
}
|
||||
}
|
||||
|
||||
return { page, pageSize };
|
||||
|
||||
@@ -7,11 +7,12 @@ export function handleTargets(store: ProbeStore, method: string, mode: RuntimeMo
|
||||
const targets = store.getTargets();
|
||||
const latestChecksMap = store.getLatestChecksMap();
|
||||
const allStats = store.getAllTargetStats();
|
||||
const allRecentSamples = store.getAllRecentSamples(30);
|
||||
|
||||
const result: TargetStatus[] = targets.map((target) => {
|
||||
const latest = latestChecksMap.get(target.id) ?? null;
|
||||
const stats = allStats.get(target.id) ?? { availability: 0, totalChecks: 0 };
|
||||
const recentSamples = store.getRecentSamples(target.id, 30);
|
||||
const recentSamples = allRecentSamples.get(target.id) ?? [];
|
||||
|
||||
return {
|
||||
group: target.grp,
|
||||
|
||||
@@ -12,17 +12,17 @@ interface State {
|
||||
}
|
||||
|
||||
export class ErrorBoundary extends Component<Props, State> {
|
||||
state: State = { hasError: false };
|
||||
override state: State = { hasError: false };
|
||||
|
||||
static getDerivedStateFromError(): State {
|
||||
return { hasError: true };
|
||||
}
|
||||
|
||||
componentDidCatch(error: Error, info: ErrorInfo): void {
|
||||
override componentDidCatch(error: Error, info: ErrorInfo): void {
|
||||
console.error("渲染错误:", error, info.componentStack);
|
||||
}
|
||||
|
||||
render() {
|
||||
override render() {
|
||||
if (this.state.hasError) {
|
||||
return (
|
||||
<Space align="center" className="error-boundary-fallback" direction="vertical" size="large">
|
||||
|
||||
Reference in New Issue
Block a user