feat: 基础设施加固 — 修复构建、数据保留、错误边界、bundle 拆分
- 修复 build script 引用已删除的 registerCheckers,恢复生产构建 - 生产入口添加 SIGINT/SIGTERM 优雅关闭(与 dev.ts 一致) - 新增 runtime.retention 配置(默认 7d),ProbeStore.prune() 定时清理过期数据 - parseDuration 扩展支持 h/d 单位 - 新增前端 ErrorBoundary 组件,防止渲染错误白屏 - Vite codeSplitting.groups 拆分 vendor chunks(业务代码 1180KB → 47KB) - 同步 delta specs 到主规范
This commit is contained in:
@@ -15,6 +15,7 @@ const DEFAULT_DATA_DIR = "./data";
|
||||
const DEFAULT_INTERVAL = "30s";
|
||||
const DEFAULT_TIMEOUT = "10s";
|
||||
const DEFAULT_MAX_CONCURRENT_CHECKS = 20;
|
||||
const DEFAULT_RETENTION = "7d";
|
||||
|
||||
export interface ResolvedConfig {
|
||||
configDir: string;
|
||||
@@ -22,6 +23,7 @@ export interface ResolvedConfig {
|
||||
host: string;
|
||||
maxConcurrentChecks: number;
|
||||
port: number;
|
||||
retentionMs: number;
|
||||
targets: ResolvedTargetBase[];
|
||||
}
|
||||
|
||||
@@ -68,6 +70,7 @@ export async function loadConfig(configPath: string): Promise<ResolvedConfig> {
|
||||
const dataDir = server.dataDir ?? DEFAULT_DATA_DIR;
|
||||
|
||||
const maxConcurrentChecks = resolveMaxConcurrentChecks(runtime);
|
||||
const retentionMs = resolveRetention(runtime);
|
||||
|
||||
const allRuntimeIssues = [...allIssues];
|
||||
if (allRuntimeIssues.length > 0) {
|
||||
@@ -81,7 +84,7 @@ export async function loadConfig(configPath: string): Promise<ResolvedConfig> {
|
||||
resolveTarget(target, defaults, defaultIntervalMs, defaultTimeoutMs, configDir),
|
||||
);
|
||||
|
||||
return { configDir, dataDir, host, maxConcurrentChecks, port, targets };
|
||||
return { configDir, dataDir, host, maxConcurrentChecks, port, retentionMs, targets };
|
||||
}
|
||||
|
||||
function canRunSemanticValidation(value: unknown): boolean {
|
||||
@@ -117,6 +120,10 @@ function resolveMaxConcurrentChecks(runtime: EngineRuntimeConfig): number {
|
||||
return runtime.maxConcurrentChecks;
|
||||
}
|
||||
|
||||
function resolveRetention(runtime: EngineRuntimeConfig): number {
|
||||
return parseDuration(runtime.retention ?? DEFAULT_RETENTION);
|
||||
}
|
||||
|
||||
function resolveTarget(
|
||||
target: RawTargetConfig,
|
||||
defaults: DefaultsConfig,
|
||||
@@ -194,6 +201,11 @@ function validateConfig(config: RawProbeConfig): ConfigValidationIssue[] {
|
||||
|
||||
validateDurationValue(config.defaults?.interval, "defaults.interval", issues);
|
||||
validateDurationValue(config.defaults?.timeout, "defaults.timeout", issues);
|
||||
validateDurationValue(
|
||||
typeof config.runtime?.retention === "string" ? config.runtime.retention : undefined,
|
||||
"runtime.retention",
|
||||
issues,
|
||||
);
|
||||
for (let i = 0; i < config.targets.length; i++) {
|
||||
const target = config.targets[i] as unknown;
|
||||
if (!isRecord(target)) continue;
|
||||
|
||||
@@ -5,17 +5,21 @@ import type { CheckResult, ResolvedTargetBase } from "./types";
|
||||
|
||||
import { checkerRegistry } from "./runner";
|
||||
|
||||
const PRUNE_INTERVAL_MS = 3600000;
|
||||
|
||||
export class ProbeEngine {
|
||||
private retentionMs: number;
|
||||
private semaphore: Semaphore;
|
||||
private store: ProbeStore;
|
||||
private targetNameToId = new Map<string, number>();
|
||||
private targets: ResolvedTargetBase[];
|
||||
private timers: Array<ReturnType<typeof setInterval>> = [];
|
||||
|
||||
constructor(store: ProbeStore, targets: ResolvedTargetBase[], maxConcurrentChecks?: number) {
|
||||
constructor(store: ProbeStore, targets: ResolvedTargetBase[], maxConcurrentChecks?: number, retentionMs?: number) {
|
||||
this.store = store;
|
||||
this.targets = targets;
|
||||
this.semaphore = new Semaphore(maxConcurrentChecks ?? 20);
|
||||
this.retentionMs = retentionMs ?? 0;
|
||||
this.refreshCache();
|
||||
}
|
||||
|
||||
@@ -31,6 +35,14 @@ export class ProbeEngine {
|
||||
|
||||
this.timers.push(timer);
|
||||
}
|
||||
|
||||
if (this.retentionMs > 0) {
|
||||
this.store.prune(this.retentionMs);
|
||||
const pruneTimer = setInterval(() => {
|
||||
this.store.prune(this.retentionMs);
|
||||
}, PRUNE_INTERVAL_MS);
|
||||
this.timers.push(pruneTimer);
|
||||
}
|
||||
}
|
||||
|
||||
stop(): void {
|
||||
|
||||
@@ -21,7 +21,10 @@ export function createProbeConfigSchema(checkers: CheckerDefinition[], external
|
||||
defaults: Type.Optional(createDefaultsSchema(checkers)),
|
||||
runtime: Type.Optional(
|
||||
Type.Object(
|
||||
{ maxConcurrentChecks: Type.Optional(Type.Integer({ minimum: 1 })) },
|
||||
{
|
||||
maxConcurrentChecks: Type.Optional(Type.Integer({ minimum: 1 })),
|
||||
retention: Type.Optional(durationSchema),
|
||||
},
|
||||
{ additionalProperties: false },
|
||||
),
|
||||
),
|
||||
|
||||
@@ -257,6 +257,13 @@ export class ProbeStore {
|
||||
);
|
||||
}
|
||||
|
||||
prune(retentionMs: number): number {
|
||||
if (this.closed) return 0;
|
||||
const cutoff = new Date(Date.now() - retentionMs).toISOString();
|
||||
const result = this.db.run("DELETE FROM check_results WHERE timestamp < ?", [cutoff]);
|
||||
return result.changes;
|
||||
}
|
||||
|
||||
syncTargets(targets: ResolvedTargetBase[]): void {
|
||||
if (this.closed) return;
|
||||
const existingRows = this.db.query("SELECT id, name FROM targets").all() as Array<{
|
||||
|
||||
@@ -12,6 +12,7 @@ export interface DefaultsConfig {
|
||||
|
||||
export interface EngineRuntimeConfig {
|
||||
maxConcurrentChecks?: number;
|
||||
retention?: string;
|
||||
}
|
||||
|
||||
export interface ExpectOperator {
|
||||
|
||||
@@ -1,17 +1,18 @@
|
||||
const DURATION_REGEX = /^(\d+(?:\.\d+)?)(ms|s|m)$/;
|
||||
const DURATION_REGEX = /^(\d+(?:\.\d+)?)(ms|s|m|h|d)$/;
|
||||
|
||||
const SIZE_REGEX = /^(\d+(?:\.\d+)?)(B|KB|MB|GB)$/;
|
||||
|
||||
export function parseDuration(value: string): number {
|
||||
const match = DURATION_REGEX.exec(value);
|
||||
if (!match) {
|
||||
throw new Error(`无效的时长格式: "${value}",支持格式如 "30s"、"5m"、"500ms"`);
|
||||
throw new Error(`无效的时长格式: "${value}",支持格式如 "30s"、"5m"、"2h"、"7d"、"500ms"`);
|
||||
}
|
||||
|
||||
const num = parseFloat(match[1]!);
|
||||
const unit = match[2]!;
|
||||
|
||||
const durationMs = unit === "ms" ? num : unit === "s" ? num * 1000 : num * 60 * 1000;
|
||||
const multipliers: Record<string, number> = { d: 86400000, h: 3600000, m: 60000, ms: 1, s: 1000 };
|
||||
const durationMs = num * multipliers[unit]!;
|
||||
if (!Number.isInteger(durationMs) || durationMs <= 0 || !Number.isFinite(durationMs)) {
|
||||
throw new Error(`无效的时长格式: "${value}",解析结果必须为正整数毫秒`);
|
||||
}
|
||||
|
||||
@@ -11,7 +11,7 @@ async function main() {
|
||||
const store = new ProbeStore(`${config.dataDir}/probe.db`);
|
||||
store.syncTargets(config.targets);
|
||||
|
||||
const engine = new ProbeEngine(store, config.targets, config.maxConcurrentChecks);
|
||||
const engine = new ProbeEngine(store, config.targets, config.maxConcurrentChecks, config.retentionMs);
|
||||
engine.start();
|
||||
|
||||
const shutdown = () => {
|
||||
|
||||
38
src/web/components/ErrorBoundary.tsx
Normal file
38
src/web/components/ErrorBoundary.tsx
Normal file
@@ -0,0 +1,38 @@
|
||||
import type { ErrorInfo, ReactNode } from "react";
|
||||
|
||||
import { Component } from "react";
|
||||
import { Alert, Button, Space } from "tdesign-react";
|
||||
|
||||
interface Props {
|
||||
children: ReactNode;
|
||||
}
|
||||
|
||||
interface State {
|
||||
hasError: boolean;
|
||||
}
|
||||
|
||||
export class ErrorBoundary extends Component<Props, State> {
|
||||
state: State = { hasError: false };
|
||||
|
||||
static getDerivedStateFromError(): State {
|
||||
return { hasError: true };
|
||||
}
|
||||
|
||||
componentDidCatch(error: Error, info: ErrorInfo): void {
|
||||
console.error("渲染错误:", error, info.componentStack);
|
||||
}
|
||||
|
||||
render() {
|
||||
if (this.state.hasError) {
|
||||
return (
|
||||
<Space align="center" className="error-boundary-fallback" direction="vertical" size="large">
|
||||
<Alert message="页面渲染出现异常,请刷新重试" theme="error" title="页面出错" />
|
||||
<Button onClick={() => window.location.reload()} theme="primary">
|
||||
刷新页面
|
||||
</Button>
|
||||
</Space>
|
||||
);
|
||||
}
|
||||
return this.props.children;
|
||||
}
|
||||
}
|
||||
@@ -4,6 +4,7 @@ import { StrictMode } from "react";
|
||||
import { createRoot } from "react-dom/client";
|
||||
|
||||
import { App } from "./app";
|
||||
import { ErrorBoundary } from "./components/ErrorBoundary";
|
||||
import "tdesign-react/dist/reset.css";
|
||||
import "tdesign-react/dist/tdesign.min.css";
|
||||
|
||||
@@ -27,9 +28,11 @@ if (!rootElement) {
|
||||
|
||||
createRoot(rootElement).render(
|
||||
<StrictMode>
|
||||
<QueryClientProvider client={queryClient}>
|
||||
<App />
|
||||
<ReactQueryDevtools initialIsOpen={false} />
|
||||
</QueryClientProvider>
|
||||
<ErrorBoundary>
|
||||
<QueryClientProvider client={queryClient}>
|
||||
<App />
|
||||
<ReactQueryDevtools initialIsOpen={false} />
|
||||
</QueryClientProvider>
|
||||
</ErrorBoundary>
|
||||
</StrictMode>,
|
||||
);
|
||||
|
||||
@@ -156,3 +156,8 @@
|
||||
.summary-cards-row {
|
||||
margin-bottom: var(--td-comp-margin-xl);
|
||||
}
|
||||
|
||||
.error-boundary-fallback {
|
||||
padding-top: 20vh;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user