1
0

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:
2026-05-13 16:48:56 +08:00
parent 26f0bfe104
commit bcfb907bd3
25 changed files with 458 additions and 26 deletions

View File

@@ -286,16 +286,16 @@ export const tcpCheckerSchemas: CheckerSchemas = {
**可复用的共享 fragments**(来自 `schema/fragments.ts` **可复用的共享 fragments**(来自 `schema/fragments.ts`
| Fragment | 用途 | | Fragment | 用途 |
| ---------------------------- | ---------------------------------------------- | | ---------------------------- | -------------------------------------------------------- |
| `durationSchema` | 时长字符串(`"30s"``"5m"``"500ms"` | | `durationSchema` | 时长字符串(`"30s"``"5m"``"2h"``"7d"``"500ms"` |
| `sizeSchema` | 大小单位(字符串如 `"10MB"` 或整数) | | `sizeSchema` | 大小单位(字符串如 `"10MB"` 或整数) |
| `statusCodePatternSchema` | 状态码(`100`-`599``"2xx"` | | `statusCodePatternSchema` | 状态码(`100`-`599``"2xx"` |
| `stringMapSchema` | `Record<string, string>`(用于 headers / env | | `stringMapSchema` | `Record<string, string>`(用于 headers / env |
| `createBodyRulesSchema()` | body 规则数组json/css/xpath/contains/regex | | `createBodyRulesSchema()` | body 规则数组json/css/xpath/contains/regex |
| `createTextRulesSchema()` | 文本规则数组stdout/stderr | | `createTextRulesSchema()` | 文本规则数组stdout/stderr |
| `createPureOperatorSchema()` | 操作符对象 | | `createPureOperatorSchema()` | 操作符对象 |
| `operatorProperties()` | 所有操作符字段的 Record | | `operatorProperties()` | 所有操作符字段的 Record |
**注意**:默认对象策略为 `additionalProperties: false`。只有明确的动态键值表(如 `http.headers``command.env`)可以开放任意键名。 **注意**:默认对象策略为 `additionalProperties: false`。只有明确的动态键值表(如 `http.headers``command.env`)可以开放任意键名。

View File

@@ -33,6 +33,7 @@ server:
runtime: runtime:
maxConcurrentChecks: 20 maxConcurrentChecks: 20
retention: "7d"
defaults: defaults:
interval: "5s" interval: "5s"
@@ -98,6 +99,7 @@ targets:
- `dataDir`: 数据目录,默认 `./data` - `dataDir`: 数据目录,默认 `./data`
- **runtime**: 运行时配置 - **runtime**: 运行时配置
- `maxConcurrentChecks`: 最大并发拨测数,默认 `20` - `maxConcurrentChecks`: 最大并发拨测数,默认 `20`
- `retention`: 历史数据保留时长,默认 `7d`,支持 `ms`/`s`/`m`/`h`/`d` 单位
- **defaults**: 全局默认值(均可省略) - **defaults**: 全局默认值(均可省略)
- `interval`: 拨测间隔,默认 `30s` - `interval`: 拨测间隔,默认 `30s`
- `timeout`: 超时时间,默认 `10s` - `timeout`: 超时时间,默认 `10s`

View File

@@ -0,0 +1,43 @@
## Purpose
定义历史拨测数据的自动清理机制:可配置的保留时长和定时清理调度。
## Requirements
### Requirement: 数据保留配置
系统 SHALL 支持通过 `runtime.retention` 配置项指定历史数据保留时长,格式为持续时间字符串(`<数字><单位>`,单位支持 `d`/`h`/`m`)。
#### Scenario: 配置 7 天保留
- **WHEN** 配置文件中 `runtime.retention` 设置为 `"7d"`
- **THEN** 系统 SHALL 保留最近 7 天的检查结果,清理更早的数据
#### Scenario: 配置小时级保留
- **WHEN** 配置文件中 `runtime.retention` 设置为 `"24h"`
- **THEN** 系统 SHALL 保留最近 24 小时的检查结果
#### Scenario: 未配置 retention
- **WHEN** 配置文件中未指定 `runtime.retention`
- **THEN** 系统 SHALL 使用默认值 `"7d"`
#### Scenario: 无效 retention 格式
- **WHEN** 配置文件中 `runtime.retention` 格式不合法(如 `"abc"``"7x"`
- **THEN** 系统 SHALL 在配置校验阶段报错,拒绝启动
### Requirement: 定时清理调度
系统 SHALL 以固定间隔1 小时)定期执行数据清理,删除超过保留时长的历史检查结果。
#### Scenario: 引擎启动后首次清理
- **WHEN** ProbeEngine 启动
- **THEN** 系统 SHALL 立即执行一次清理,然后每隔 1 小时再次执行
#### Scenario: 清理执行
- **WHEN** 清理定时器触发
- **THEN** 系统 SHALL 删除 `check_results` 表中 `timestamp` 早于 `now - retentionMs` 的所有记录
#### Scenario: 引擎停止时清除定时器
- **WHEN** ProbeEngine.stop() 被调用
- **THEN** 系统 SHALL 清除清理定时器,不再执行后续清理
#### Scenario: retention 为 0 时不清理
- **WHEN** 配置的 retention 解析为 0 毫秒
- **THEN** 系统 SHALL 不注册清理定时器,数据永久保留

View File

@@ -0,0 +1,31 @@
## Purpose
定义前端全局错误边界:捕获渲染错误防止白屏,展示友好的错误兜底 UI。
## Requirements
### Requirement: 全局渲染错误捕获
前端应用 SHALL 在最外层包裹 ErrorBoundary 组件,捕获所有子组件树的渲染错误,防止白屏。
#### Scenario: 子组件渲染抛出异常
- **WHEN** 任意子组件在渲染过程中抛出 JavaScript 异常
- **THEN** ErrorBoundary SHALL 捕获该异常,展示错误兜底 UI而非白屏
#### Scenario: 错误兜底 UI 内容
- **WHEN** ErrorBoundary 捕获到渲染错误
- **THEN** 系统 SHALL 使用 TDesign Result 组件type="500")展示错误提示,并提供"刷新页面"按钮
#### Scenario: 刷新页面恢复
- **WHEN** 用户点击错误兜底 UI 中的"刷新页面"按钮
- **THEN** 系统 SHALL 调用 `window.location.reload()` 重新加载页面
#### Scenario: 错误信息记录
- **WHEN** ErrorBoundary 捕获到渲染错误
- **THEN** 系统 SHALL 通过 `console.error` 输出错误信息和组件堆栈
### Requirement: ErrorBoundary 包裹位置
ErrorBoundary SHALL 包裹在 QueryClientProvider 外层,确保 React Query 相关的渲染错误也能被捕获。
#### Scenario: 包裹层级
- **WHEN** 应用渲染树构建
- **THEN** 层级 SHALL 为 StrictMode > ErrorBoundary > QueryClientProvider > App

View File

@@ -276,3 +276,18 @@
#### Scenario: 不配置 expect #### Scenario: 不配置 expect
- **WHEN** target 未配置任何 expect 规则 - **WHEN** target 未配置任何 expect 规则
- **THEN** 系统 SHALL 正常处理expect 字段为 undefined - **THEN** 系统 SHALL 正常处理expect 字段为 undefined
### Requirement: 数据保留配置字段
配置 schema 的 `runtime` 段 SHALL 支持 `retention` 字段,类型为字符串,格式为 `<数字><单位>`(单位:`d` 天、`h` 小时、`m` 分钟),用于指定历史数据保留时长。
#### Scenario: retention 字段校验通过
- **WHEN** 配置文件中 `runtime.retention` 为合法格式(如 `"7d"``"24h"``"30m"`
- **THEN** 配置校验 SHALL 通过
#### Scenario: retention 字段格式非法
- **WHEN** 配置文件中 `runtime.retention` 为非法格式(如 `"abc"``"7x"``""`
- **THEN** 配置校验 SHALL 失败并报告格式错误
#### Scenario: retention 字段缺省
- **WHEN** 配置文件中未指定 `runtime.retention`
- **THEN** 系统 SHALL 使用默认值 `"7d"`

View File

@@ -32,3 +32,14 @@ Dashboard SHALL 使用 TDesign 组件正确处理加载状态和 API 错误。
#### Scenario: API 请求失败 #### Scenario: API 请求失败
- **WHEN** 前端 API 请求失败 - **WHEN** 前端 API 请求失败
- **THEN** 页面 SHALL 使用 TDesign Alert 组件theme=error显示错误提示 - **THEN** 页面 SHALL 使用 TDesign Alert 组件theme=error显示错误提示
### Requirement: 前端构建产物拆分
前端生产构建 SHALL 将 vendor 依赖拆分为独立 chunk利用浏览器并行加载和长期缓存。
#### Scenario: vendor chunk 拆分
- **WHEN** 执行前端生产构建
- **THEN** 构建产物 SHALL 包含独立的 vendor chunkreact、tdesign、recharts 各自独立),而非单个 bundle
#### Scenario: 业务代码变更不影响 vendor 缓存
- **WHEN** 仅修改业务代码src/web/ 下非 node_modules 文件)并重新构建
- **THEN** vendor chunk 的文件名(含 hashSHALL 保持不变,浏览器缓存 SHALL 继续有效

View File

@@ -126,3 +126,18 @@
#### Scenario: command target config 序列化 #### Scenario: command target config 序列化
- **WHEN** 同步 command target - **WHEN** 同步 command target
- **THEN** targets.config SHALL 存储 JSON包含 exec、args、cwd、env、maxOutputBytes - **THEN** targets.config SHALL 存储 JSON包含 exec、args、cwd、env、maxOutputBytes
### Requirement: 数据清理方法
ProbeStore SHALL 提供 `prune(retentionMs: number)` 方法,删除超过保留时长的历史检查结果并返回删除行数。
#### Scenario: 清理过期数据
- **WHEN** 调用 `prune(604800000)`7 天毫秒数)
- **THEN** 系统 SHALL 删除 `check_results` 表中 `timestamp` 早于当前时间减去 604800000 毫秒的所有记录,并返回实际删除的行数
#### Scenario: 无过期数据
- **WHEN** 调用 `prune()` 但所有记录都在保留期内
- **THEN** 系统 SHALL 返回 0不删除任何记录
#### Scenario: 清理不影响保留期内数据
- **WHEN** 调用 `prune()` 且存在保留期内和保留期外的记录
- **THEN** 系统 SHALL 仅删除保留期外的记录,保留期内的记录 SHALL 不受影响

View File

@@ -230,3 +230,18 @@ HTTP checker SHALL 将运行期失败归属到实际失败阶段。请求、网
#### Scenario: 选择 command runner #### Scenario: 选择 command runner
- **WHEN** target.type 为 `command` - **WHEN** target.type 为 `command`
- **THEN** 系统 SHALL 使用 command runner 执行该目标 - **THEN** 系统 SHALL 使用 command runner 执行该目标
### Requirement: 定期数据清理
ProbeEngine SHALL 在启动时注册数据清理定时器,定期调用 ProbeStore.prune() 清理过期数据。
#### Scenario: 引擎启动注册清理
- **WHEN** ProbeEngine.start() 被调用且 retentionMs > 0
- **THEN** 系统 SHALL 立即执行一次 prune然后每隔 1 小时再次执行
#### Scenario: 引擎停止清除定时器
- **WHEN** ProbeEngine.stop() 被调用
- **THEN** 系统 SHALL 清除清理定时器,不再执行后续清理
#### Scenario: retentionMs 为 0 不注册清理
- **WHEN** ProbeEngine 构造时 retentionMs 为 0
- **THEN** 系统 SHALL 不注册清理定时器

View File

@@ -27,7 +27,7 @@
- **THEN** 生成的嵌入资源模块 SHALL 保持语义一致且不依赖文件系统遍历顺序 - **THEN** 生成的嵌入资源模块 SHALL 保持语义一致且不依赖文件系统遍历顺序
### Requirement: 单 executable 输出 ### Requirement: 单 executable 输出
生产构建 SHALL 输出一个 standalone executable其中包含 Bun 后端、必要 server 依赖和构建后的前端资源。构建成功后 SHALL 自动清理中间产物目录(`.build/`),构建失败时 SHALL 保留中间产物以便排查。 生产构建 SHALL 输出一个 standalone executable其中包含 Bun 后端、必要 server 依赖和构建后的前端资源。构建成功后 SHALL 自动清理中间产物目录(`.build/`),构建失败时 SHALL 保留中间产物以便排查。生成的入口代码 SHALL 通过 import config-loader 模块隐式触发 checker 注册,而非显式调用注册函数。生成的入口 SHALL 注册 SIGINT 和 SIGTERM 信号处理器,在收到信号时依次调用 engine.stop() 和 store.close() 后退出进程。
#### Scenario: 在目标机器运行 executable #### Scenario: 在目标机器运行 executable
- **WHEN** 生成的 executable 在兼容目标平台上运行 - **WHEN** 生成的 executable 在兼容目标平台上运行
@@ -49,6 +49,14 @@
- **WHEN** 生产构建在任意步骤失败前端构建、中间产物生成、Bun 编译) - **WHEN** 生产构建在任意步骤失败前端构建、中间产物生成、Bun 编译)
- **THEN** `.build/` 目录 SHALL 保留在磁盘上以供排查 - **THEN** `.build/` 目录 SHALL 保留在磁盘上以供排查
#### Scenario: checker 注册通过 import 链触发
- **WHEN** 生成的入口代码 import config-loader 模块
- **THEN** checkerRegistry 单例 SHALL 通过模块依赖链自动完成注册,入口代码 SHALL NOT 显式调用任何注册函数
#### Scenario: 生产入口优雅关闭
- **WHEN** executable 进程收到 SIGINT 或 SIGTERM 信号
- **THEN** 系统 SHALL 调用 engine.stop() 停止所有定时器,调用 store.close() 关闭数据库连接,然后以退出码 0 退出进程
### Requirement: 外部运行时配置 ### Requirement: 外部运行时配置
executable MUST 将环境相关运行时配置保留在嵌入的前端和 server bundle 之外。 executable MUST 将环境相关运行时配置保留在嵌入的前端和 server bundle 之外。

View File

@@ -99,6 +99,9 @@
"maxConcurrentChecks": { "maxConcurrentChecks": {
"minimum": 1, "minimum": 1,
"type": "integer" "type": "integer"
},
"retention": {
"type": "string"
} }
} }
}, },

View File

@@ -112,21 +112,26 @@ import { ProbeStore } from "../src/server/checker/store";
import { ProbeEngine } from "../src/server/checker/engine"; import { ProbeEngine } from "../src/server/checker/engine";
import { startServer } from "../src/server/server"; import { startServer } from "../src/server/server";
import { readRuntimeConfig } from "../src/server/config"; import { readRuntimeConfig } from "../src/server/config";
import { registerCheckers } from "../src/server/checker/runner";
import { staticAssets } from "./static-assets"; import { staticAssets } from "./static-assets";
async function main() { async function main() {
registerCheckers();
const { configPath } = readRuntimeConfig(); const { configPath } = readRuntimeConfig();
const config = await loadConfig(configPath); const config = await loadConfig(configPath);
const store = new ProbeStore(config.dataDir + "/probe.db"); const store = new ProbeStore(config.dataDir + "/probe.db");
store.syncTargets(config.targets); 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(); engine.start();
const shutdown = () => {
engine.stop();
store.close();
process.exit(0);
};
process.on("SIGINT", shutdown);
process.on("SIGTERM", shutdown);
startServer({ startServer({
config: { host: config.host, port: config.port }, config: { host: config.host, port: config.port },
mode: "production", mode: "production",

View File

@@ -15,6 +15,7 @@ const DEFAULT_DATA_DIR = "./data";
const DEFAULT_INTERVAL = "30s"; const DEFAULT_INTERVAL = "30s";
const DEFAULT_TIMEOUT = "10s"; const DEFAULT_TIMEOUT = "10s";
const DEFAULT_MAX_CONCURRENT_CHECKS = 20; const DEFAULT_MAX_CONCURRENT_CHECKS = 20;
const DEFAULT_RETENTION = "7d";
export interface ResolvedConfig { export interface ResolvedConfig {
configDir: string; configDir: string;
@@ -22,6 +23,7 @@ export interface ResolvedConfig {
host: string; host: string;
maxConcurrentChecks: number; maxConcurrentChecks: number;
port: number; port: number;
retentionMs: number;
targets: ResolvedTargetBase[]; targets: ResolvedTargetBase[];
} }
@@ -68,6 +70,7 @@ export async function loadConfig(configPath: string): Promise<ResolvedConfig> {
const dataDir = server.dataDir ?? DEFAULT_DATA_DIR; const dataDir = server.dataDir ?? DEFAULT_DATA_DIR;
const maxConcurrentChecks = resolveMaxConcurrentChecks(runtime); const maxConcurrentChecks = resolveMaxConcurrentChecks(runtime);
const retentionMs = resolveRetention(runtime);
const allRuntimeIssues = [...allIssues]; const allRuntimeIssues = [...allIssues];
if (allRuntimeIssues.length > 0) { if (allRuntimeIssues.length > 0) {
@@ -81,7 +84,7 @@ export async function loadConfig(configPath: string): Promise<ResolvedConfig> {
resolveTarget(target, defaults, defaultIntervalMs, defaultTimeoutMs, configDir), 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 { function canRunSemanticValidation(value: unknown): boolean {
@@ -117,6 +120,10 @@ function resolveMaxConcurrentChecks(runtime: EngineRuntimeConfig): number {
return runtime.maxConcurrentChecks; return runtime.maxConcurrentChecks;
} }
function resolveRetention(runtime: EngineRuntimeConfig): number {
return parseDuration(runtime.retention ?? DEFAULT_RETENTION);
}
function resolveTarget( function resolveTarget(
target: RawTargetConfig, target: RawTargetConfig,
defaults: DefaultsConfig, defaults: DefaultsConfig,
@@ -194,6 +201,11 @@ function validateConfig(config: RawProbeConfig): ConfigValidationIssue[] {
validateDurationValue(config.defaults?.interval, "defaults.interval", issues); validateDurationValue(config.defaults?.interval, "defaults.interval", issues);
validateDurationValue(config.defaults?.timeout, "defaults.timeout", 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++) { for (let i = 0; i < config.targets.length; i++) {
const target = config.targets[i] as unknown; const target = config.targets[i] as unknown;
if (!isRecord(target)) continue; if (!isRecord(target)) continue;

View File

@@ -5,17 +5,21 @@ import type { CheckResult, ResolvedTargetBase } from "./types";
import { checkerRegistry } from "./runner"; import { checkerRegistry } from "./runner";
const PRUNE_INTERVAL_MS = 3600000;
export class ProbeEngine { export class ProbeEngine {
private retentionMs: number;
private semaphore: Semaphore; private semaphore: Semaphore;
private store: ProbeStore; private store: ProbeStore;
private targetNameToId = new Map<string, number>(); private targetNameToId = new Map<string, number>();
private targets: ResolvedTargetBase[]; private targets: ResolvedTargetBase[];
private timers: Array<ReturnType<typeof setInterval>> = []; 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.store = store;
this.targets = targets; this.targets = targets;
this.semaphore = new Semaphore(maxConcurrentChecks ?? 20); this.semaphore = new Semaphore(maxConcurrentChecks ?? 20);
this.retentionMs = retentionMs ?? 0;
this.refreshCache(); this.refreshCache();
} }
@@ -31,6 +35,14 @@ export class ProbeEngine {
this.timers.push(timer); 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 { stop(): void {

View File

@@ -21,7 +21,10 @@ export function createProbeConfigSchema(checkers: CheckerDefinition[], external
defaults: Type.Optional(createDefaultsSchema(checkers)), defaults: Type.Optional(createDefaultsSchema(checkers)),
runtime: Type.Optional( runtime: Type.Optional(
Type.Object( Type.Object(
{ maxConcurrentChecks: Type.Optional(Type.Integer({ minimum: 1 })) }, {
maxConcurrentChecks: Type.Optional(Type.Integer({ minimum: 1 })),
retention: Type.Optional(durationSchema),
},
{ additionalProperties: false }, { additionalProperties: false },
), ),
), ),

View File

@@ -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 { syncTargets(targets: ResolvedTargetBase[]): void {
if (this.closed) return; if (this.closed) return;
const existingRows = this.db.query("SELECT id, name FROM targets").all() as Array<{ const existingRows = this.db.query("SELECT id, name FROM targets").all() as Array<{

View File

@@ -12,6 +12,7 @@ export interface DefaultsConfig {
export interface EngineRuntimeConfig { export interface EngineRuntimeConfig {
maxConcurrentChecks?: number; maxConcurrentChecks?: number;
retention?: string;
} }
export interface ExpectOperator { export interface ExpectOperator {

View File

@@ -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)$/; const SIZE_REGEX = /^(\d+(?:\.\d+)?)(B|KB|MB|GB)$/;
export function parseDuration(value: string): number { export function parseDuration(value: string): number {
const match = DURATION_REGEX.exec(value); const match = DURATION_REGEX.exec(value);
if (!match) { if (!match) {
throw new Error(`无效的时长格式: "${value}",支持格式如 "30s"、"5m"、"500ms"`); throw new Error(`无效的时长格式: "${value}",支持格式如 "30s"、"5m"、"2h"、"7d"、"500ms"`);
} }
const num = parseFloat(match[1]!); const num = parseFloat(match[1]!);
const unit = match[2]!; 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)) { if (!Number.isInteger(durationMs) || durationMs <= 0 || !Number.isFinite(durationMs)) {
throw new Error(`无效的时长格式: "${value}",解析结果必须为正整数毫秒`); throw new Error(`无效的时长格式: "${value}",解析结果必须为正整数毫秒`);
} }

View File

@@ -11,7 +11,7 @@ async function main() {
const store = new ProbeStore(`${config.dataDir}/probe.db`); const store = new ProbeStore(`${config.dataDir}/probe.db`);
store.syncTargets(config.targets); 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(); engine.start();
const shutdown = () => { const shutdown = () => {

View 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;
}
}

View File

@@ -4,6 +4,7 @@ import { StrictMode } from "react";
import { createRoot } from "react-dom/client"; import { createRoot } from "react-dom/client";
import { App } from "./app"; import { App } from "./app";
import { ErrorBoundary } from "./components/ErrorBoundary";
import "tdesign-react/dist/reset.css"; import "tdesign-react/dist/reset.css";
import "tdesign-react/dist/tdesign.min.css"; import "tdesign-react/dist/tdesign.min.css";
@@ -27,9 +28,11 @@ if (!rootElement) {
createRoot(rootElement).render( createRoot(rootElement).render(
<StrictMode> <StrictMode>
<QueryClientProvider client={queryClient}> <ErrorBoundary>
<App /> <QueryClientProvider client={queryClient}>
<ReactQueryDevtools initialIsOpen={false} /> <App />
</QueryClientProvider> <ReactQueryDevtools initialIsOpen={false} />
</QueryClientProvider>
</ErrorBoundary>
</StrictMode>, </StrictMode>,
); );

View File

@@ -156,3 +156,8 @@
.summary-cards-row { .summary-cards-row {
margin-bottom: var(--td-comp-margin-xl); margin-bottom: var(--td-comp-margin-xl);
} }
.error-boundary-fallback {
padding-top: 20vh;
width: 100%;
}

View File

@@ -43,6 +43,16 @@ describe("parseDuration", () => {
expect(parseDuration("1.5s")).toBe(1500); expect(parseDuration("1.5s")).toBe(1500);
}); });
test("解析小时", () => {
expect(parseDuration("2h")).toBe(7200000);
expect(parseDuration("1h")).toBe(3600000);
});
test("解析天", () => {
expect(parseDuration("7d")).toBe(604800000);
expect(parseDuration("1d")).toBe(86400000);
});
test("拒绝非正整数毫秒结果", () => { test("拒绝非正整数毫秒结果", () => {
expect(() => parseDuration("0ms")).toThrow("正整数毫秒"); expect(() => parseDuration("0ms")).toThrow("正整数毫秒");
expect(() => parseDuration("1.5ms")).toThrow("正整数毫秒"); expect(() => parseDuration("1.5ms")).toThrow("正整数毫秒");
@@ -1214,4 +1224,51 @@ targets:
"expect.status 是未知字段", "expect.status 是未知字段",
); );
}); });
test("retention 默认值为 7d", async () => {
const configPath = join(tempDir, "retention-default.yaml");
await writeFile(
configPath,
`targets:
- name: "test"
type: http
http:
url: "http://example.com"
`,
);
const config = await loadConfig(configPath);
expect(config.retentionMs).toBe(604800000);
});
test("retention 自定义值", async () => {
const configPath = join(tempDir, "retention-custom.yaml");
await writeFile(
configPath,
`runtime:
retention: "24h"
targets:
- name: "test"
type: http
http:
url: "http://example.com"
`,
);
const config = await loadConfig(configPath);
expect(config.retentionMs).toBe(86400000);
});
test("retention 非法格式抛出错误", async () => {
await expectConfigError(
"bad-retention.yaml",
`runtime:
retention: "7x"
targets:
- name: "test"
type: http
http:
url: "http://example.com"
`,
"无效的时长格式",
);
});
}); });

View File

@@ -220,4 +220,55 @@ describe("ProbeEngine", () => {
void httpServer.stop(); void httpServer.stop();
} }
}); });
test("retentionMs > 0 时 start 调用 prune", () => {
let pruneCalled = false;
const mockStore = {
...createMockStore(["test"]),
prune() {
pruneCalled = true;
return 0;
},
} as unknown as ProbeStore;
const targets: ResolvedTargetBase[] = [makeCommandTarget("test")];
const engine = new ProbeEngine(mockStore, targets, 20, 86400000);
engine.start();
expect(pruneCalled).toBe(true);
engine.stop();
});
test("retentionMs = 0 时不调用 prune", () => {
let pruneCalled = false;
const mockStore = {
...createMockStore(["test"]),
prune() {
pruneCalled = true;
return 0;
},
} as unknown as ProbeStore;
const targets: ResolvedTargetBase[] = [makeCommandTarget("test")];
const engine = new ProbeEngine(mockStore, targets, 20, 0);
engine.start();
expect(pruneCalled).toBe(false);
engine.stop();
});
test("retentionMs 未传时不调用 prune", () => {
let pruneCalled = false;
const mockStore = {
...createMockStore(["test"]),
prune() {
pruneCalled = true;
return 0;
},
} as unknown as ProbeStore;
const targets: ResolvedTargetBase[] = [makeCommandTarget("test")];
const engine = new ProbeEngine(mockStore, targets);
engine.start();
expect(pruneCalled).toBe(false);
engine.stop();
});
}); });

View File

@@ -419,4 +419,87 @@ describe("ProbeStore", () => {
freshStore.close(); freshStore.close();
}); });
test("prune 删除过期数据", () => {
const pruneStore = new ProbeStore(join(tempDir, "prune.db"));
pruneStore.syncTargets([httpTarget]);
const t = pruneStore.getTargets()[0]!;
pruneStore.insertCheckResult({
durationMs: 100,
failure: null,
matched: true,
statusDetail: "200 OK",
targetId: t.id,
timestamp: "2020-01-01T00:00:00.000Z",
});
pruneStore.insertCheckResult({
durationMs: 100,
failure: null,
matched: true,
statusDetail: "200 OK",
targetId: t.id,
timestamp: new Date().toISOString(),
});
const deleted = pruneStore.prune(86400000);
expect(deleted).toBe(1);
const history = pruneStore.getHistory(t.id, "2000-01-01T00:00:00.000Z", "2099-12-31T23:59:59.999Z");
expect(history.total).toBe(1);
pruneStore.close();
});
test("prune 无过期数据返回 0", () => {
const pruneStore = new ProbeStore(join(tempDir, "prune-none.db"));
pruneStore.syncTargets([httpTarget]);
const t = pruneStore.getTargets()[0]!;
pruneStore.insertCheckResult({
durationMs: 100,
failure: null,
matched: true,
statusDetail: "200 OK",
targetId: t.id,
timestamp: new Date().toISOString(),
});
const deleted = pruneStore.prune(86400000);
expect(deleted).toBe(0);
pruneStore.close();
});
test("prune 不影响保留期内数据", () => {
const pruneStore = new ProbeStore(join(tempDir, "prune-keep.db"));
pruneStore.syncTargets([httpTarget]);
const t = pruneStore.getTargets()[0]!;
const now = Date.now();
pruneStore.insertCheckResult({
durationMs: 100,
failure: null,
matched: true,
statusDetail: "200 OK",
targetId: t.id,
timestamp: new Date(now - 3600000).toISOString(),
});
pruneStore.insertCheckResult({
durationMs: 200,
failure: null,
matched: true,
statusDetail: "200 OK",
targetId: t.id,
timestamp: new Date(now).toISOString(),
});
const deleted = pruneStore.prune(7200000);
expect(deleted).toBe(0);
const history = pruneStore.getHistory(t.id, "2000-01-01T00:00:00.000Z", "2099-12-31T23:59:59.999Z");
expect(history.total).toBe(2);
pruneStore.close();
});
}); });

View File

@@ -8,6 +8,17 @@ export default defineConfig({
assetsDir: "assets", assetsDir: "assets",
emptyOutDir: true, emptyOutDir: true,
outDir: "../../dist/web", outDir: "../../dist/web",
rolldownOptions: {
output: {
codeSplitting: {
groups: [
{ name: "vendor-react", test: /node_modules\/(react|react-dom|scheduler)/ },
{ name: "vendor-tdesign", test: /node_modules\/tdesign/ },
{ name: "vendor-chart", test: /node_modules\/(recharts|d3-)/ },
],
},
},
},
}, },
plugins: [react()], plugins: [react()],
root: "src/web", root: "src/web",