From bcfb907bd34c40dcfafdff0ba31b821c9cfa8d83 Mon Sep 17 00:00:00 2001 From: lanyuanxiaoyao Date: Wed, 13 May 2026 16:48:56 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20=E5=9F=BA=E7=A1=80=E8=AE=BE=E6=96=BD?= =?UTF-8?q?=E5=8A=A0=E5=9B=BA=20=E2=80=94=20=E4=BF=AE=E5=A4=8D=E6=9E=84?= =?UTF-8?q?=E5=BB=BA=E3=80=81=E6=95=B0=E6=8D=AE=E4=BF=9D=E7=95=99=E3=80=81?= =?UTF-8?q?=E9=94=99=E8=AF=AF=E8=BE=B9=E7=95=8C=E3=80=81bundle=20=E6=8B=86?= =?UTF-8?q?=E5=88=86?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 修复 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 到主规范 --- DEVELOPMENT.md | 20 ++--- README.md | 2 + openspec/specs/data-retention/spec.md | 43 ++++++++++ .../specs/frontend-error-boundary/spec.md | 31 +++++++ openspec/specs/probe-config/spec.md | 15 ++++ openspec/specs/probe-dashboard/spec.md | 11 +++ openspec/specs/probe-data-store/spec.md | 15 ++++ openspec/specs/probe-engine/spec.md | 15 ++++ .../specs/single-executable-packaging/spec.md | 10 ++- probe-config.schema.json | 3 + scripts/build.ts | 13 ++- src/server/checker/config-loader.ts | 14 +++- src/server/checker/engine.ts | 14 +++- src/server/checker/schema/builder.ts | 5 +- src/server/checker/store.ts | 7 ++ src/server/checker/types.ts | 1 + src/server/checker/utils.ts | 7 +- src/server/dev.ts | 2 +- src/web/components/ErrorBoundary.tsx | 38 +++++++++ src/web/main.tsx | 11 ++- src/web/styles.css | 5 ++ tests/server/checker/config-loader.test.ts | 57 +++++++++++++ tests/server/checker/engine.test.ts | 51 ++++++++++++ tests/server/checker/store.test.ts | 83 +++++++++++++++++++ vite.config.ts | 11 +++ 25 files changed, 458 insertions(+), 26 deletions(-) create mode 100644 openspec/specs/data-retention/spec.md create mode 100644 openspec/specs/frontend-error-boundary/spec.md create mode 100644 src/web/components/ErrorBoundary.tsx diff --git a/DEVELOPMENT.md b/DEVELOPMENT.md index 4628c36..b1742c7 100644 --- a/DEVELOPMENT.md +++ b/DEVELOPMENT.md @@ -286,16 +286,16 @@ export const tcpCheckerSchemas: CheckerSchemas = { **可复用的共享 fragments**(来自 `schema/fragments.ts`): -| Fragment | 用途 | -| ---------------------------- | ---------------------------------------------- | -| `durationSchema` | 时长字符串(`"30s"`、`"5m"`、`"500ms"`) | -| `sizeSchema` | 大小单位(字符串如 `"10MB"` 或整数) | -| `statusCodePatternSchema` | 状态码(`100`-`599` 或 `"2xx"`) | -| `stringMapSchema` | `Record`(用于 headers / env) | -| `createBodyRulesSchema()` | body 规则数组(json/css/xpath/contains/regex) | -| `createTextRulesSchema()` | 文本规则数组(stdout/stderr) | -| `createPureOperatorSchema()` | 操作符对象 | -| `operatorProperties()` | 所有操作符字段的 Record | +| Fragment | 用途 | +| ---------------------------- | -------------------------------------------------------- | +| `durationSchema` | 时长字符串(`"30s"`、`"5m"`、`"2h"`、`"7d"`、`"500ms"`) | +| `sizeSchema` | 大小单位(字符串如 `"10MB"` 或整数) | +| `statusCodePatternSchema` | 状态码(`100`-`599` 或 `"2xx"`) | +| `stringMapSchema` | `Record`(用于 headers / env) | +| `createBodyRulesSchema()` | body 规则数组(json/css/xpath/contains/regex) | +| `createTextRulesSchema()` | 文本规则数组(stdout/stderr) | +| `createPureOperatorSchema()` | 操作符对象 | +| `operatorProperties()` | 所有操作符字段的 Record | **注意**:默认对象策略为 `additionalProperties: false`。只有明确的动态键值表(如 `http.headers`、`command.env`)可以开放任意键名。 diff --git a/README.md b/README.md index 994e081..98512ae 100644 --- a/README.md +++ b/README.md @@ -33,6 +33,7 @@ server: runtime: maxConcurrentChecks: 20 + retention: "7d" defaults: interval: "5s" @@ -98,6 +99,7 @@ targets: - `dataDir`: 数据目录,默认 `./data` - **runtime**: 运行时配置 - `maxConcurrentChecks`: 最大并发拨测数,默认 `20` + - `retention`: 历史数据保留时长,默认 `7d`,支持 `ms`/`s`/`m`/`h`/`d` 单位 - **defaults**: 全局默认值(均可省略) - `interval`: 拨测间隔,默认 `30s` - `timeout`: 超时时间,默认 `10s` diff --git a/openspec/specs/data-retention/spec.md b/openspec/specs/data-retention/spec.md new file mode 100644 index 0000000..59ac514 --- /dev/null +++ b/openspec/specs/data-retention/spec.md @@ -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 不注册清理定时器,数据永久保留 diff --git a/openspec/specs/frontend-error-boundary/spec.md b/openspec/specs/frontend-error-boundary/spec.md new file mode 100644 index 0000000..10d6f04 --- /dev/null +++ b/openspec/specs/frontend-error-boundary/spec.md @@ -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 diff --git a/openspec/specs/probe-config/spec.md b/openspec/specs/probe-config/spec.md index 3689a4b..93ca42f 100644 --- a/openspec/specs/probe-config/spec.md +++ b/openspec/specs/probe-config/spec.md @@ -276,3 +276,18 @@ #### Scenario: 不配置 expect - **WHEN** target 未配置任何 expect 规则 - **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"` diff --git a/openspec/specs/probe-dashboard/spec.md b/openspec/specs/probe-dashboard/spec.md index 6301f40..0ae3e91 100644 --- a/openspec/specs/probe-dashboard/spec.md +++ b/openspec/specs/probe-dashboard/spec.md @@ -32,3 +32,14 @@ Dashboard SHALL 使用 TDesign 组件正确处理加载状态和 API 错误。 #### Scenario: API 请求失败 - **WHEN** 前端 API 请求失败 - **THEN** 页面 SHALL 使用 TDesign Alert 组件(theme=error)显示错误提示 + +### Requirement: 前端构建产物拆分 +前端生产构建 SHALL 将 vendor 依赖拆分为独立 chunk,利用浏览器并行加载和长期缓存。 + +#### Scenario: vendor chunk 拆分 +- **WHEN** 执行前端生产构建 +- **THEN** 构建产物 SHALL 包含独立的 vendor chunk(react、tdesign、recharts 各自独立),而非单个 bundle + +#### Scenario: 业务代码变更不影响 vendor 缓存 +- **WHEN** 仅修改业务代码(src/web/ 下非 node_modules 文件)并重新构建 +- **THEN** vendor chunk 的文件名(含 hash)SHALL 保持不变,浏览器缓存 SHALL 继续有效 diff --git a/openspec/specs/probe-data-store/spec.md b/openspec/specs/probe-data-store/spec.md index 535717f..bf65ed5 100644 --- a/openspec/specs/probe-data-store/spec.md +++ b/openspec/specs/probe-data-store/spec.md @@ -126,3 +126,18 @@ #### Scenario: command target config 序列化 - **WHEN** 同步 command target - **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 不受影响 diff --git a/openspec/specs/probe-engine/spec.md b/openspec/specs/probe-engine/spec.md index 6ba7b9a..715e07f 100644 --- a/openspec/specs/probe-engine/spec.md +++ b/openspec/specs/probe-engine/spec.md @@ -230,3 +230,18 @@ HTTP checker SHALL 将运行期失败归属到实际失败阶段。请求、网 #### Scenario: 选择 command runner - **WHEN** target.type 为 `command` - **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 不注册清理定时器 diff --git a/openspec/specs/single-executable-packaging/spec.md b/openspec/specs/single-executable-packaging/spec.md index efca54f..7389dba 100644 --- a/openspec/specs/single-executable-packaging/spec.md +++ b/openspec/specs/single-executable-packaging/spec.md @@ -27,7 +27,7 @@ - **THEN** 生成的嵌入资源模块 SHALL 保持语义一致且不依赖文件系统遍历顺序 ### 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 - **WHEN** 生成的 executable 在兼容目标平台上运行 @@ -49,6 +49,14 @@ - **WHEN** 生产构建在任意步骤失败(前端构建、中间产物生成、Bun 编译) - **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: 外部运行时配置 executable MUST 将环境相关运行时配置保留在嵌入的前端和 server bundle 之外。 diff --git a/probe-config.schema.json b/probe-config.schema.json index 67dcc46..68e83f9 100644 --- a/probe-config.schema.json +++ b/probe-config.schema.json @@ -99,6 +99,9 @@ "maxConcurrentChecks": { "minimum": 1, "type": "integer" + }, + "retention": { + "type": "string" } } }, diff --git a/scripts/build.ts b/scripts/build.ts index 3f4e72d..5a5a04a 100644 --- a/scripts/build.ts +++ b/scripts/build.ts @@ -112,21 +112,26 @@ import { ProbeStore } from "../src/server/checker/store"; import { ProbeEngine } from "../src/server/checker/engine"; import { startServer } from "../src/server/server"; import { readRuntimeConfig } from "../src/server/config"; -import { registerCheckers } from "../src/server/checker/runner"; import { staticAssets } from "./static-assets"; async function main() { - registerCheckers(); - 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); + 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: "production", diff --git a/src/server/checker/config-loader.ts b/src/server/checker/config-loader.ts index 8f2517c..bcea036 100644 --- a/src/server/checker/config-loader.ts +++ b/src/server/checker/config-loader.ts @@ -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 { 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 { 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; diff --git a/src/server/checker/engine.ts b/src/server/checker/engine.ts index fee9336..4b0d302 100644 --- a/src/server/checker/engine.ts +++ b/src/server/checker/engine.ts @@ -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(); private targets: ResolvedTargetBase[]; private timers: Array> = []; - 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 { diff --git a/src/server/checker/schema/builder.ts b/src/server/checker/schema/builder.ts index acd8c50..f7949f8 100644 --- a/src/server/checker/schema/builder.ts +++ b/src/server/checker/schema/builder.ts @@ -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 }, ), ), diff --git a/src/server/checker/store.ts b/src/server/checker/store.ts index 4ea0237..cef3ebc 100644 --- a/src/server/checker/store.ts +++ b/src/server/checker/store.ts @@ -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<{ diff --git a/src/server/checker/types.ts b/src/server/checker/types.ts index 1891933..d85eee0 100644 --- a/src/server/checker/types.ts +++ b/src/server/checker/types.ts @@ -12,6 +12,7 @@ export interface DefaultsConfig { export interface EngineRuntimeConfig { maxConcurrentChecks?: number; + retention?: string; } export interface ExpectOperator { diff --git a/src/server/checker/utils.ts b/src/server/checker/utils.ts index b3eda7d..63c25af 100644 --- a/src/server/checker/utils.ts +++ b/src/server/checker/utils.ts @@ -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 = { 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}",解析结果必须为正整数毫秒`); } diff --git a/src/server/dev.ts b/src/server/dev.ts index 1c40a8d..88c16d9 100644 --- a/src/server/dev.ts +++ b/src/server/dev.ts @@ -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 = () => { diff --git a/src/web/components/ErrorBoundary.tsx b/src/web/components/ErrorBoundary.tsx new file mode 100644 index 0000000..3122c9b --- /dev/null +++ b/src/web/components/ErrorBoundary.tsx @@ -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 { + 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 ( + + + + + ); + } + return this.props.children; + } +} diff --git a/src/web/main.tsx b/src/web/main.tsx index 3dad36c..3aec7c4 100644 --- a/src/web/main.tsx +++ b/src/web/main.tsx @@ -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( - - - - + + + + + + , ); diff --git a/src/web/styles.css b/src/web/styles.css index 5e02321..db26141 100644 --- a/src/web/styles.css +++ b/src/web/styles.css @@ -156,3 +156,8 @@ .summary-cards-row { margin-bottom: var(--td-comp-margin-xl); } + +.error-boundary-fallback { + padding-top: 20vh; + width: 100%; +} diff --git a/tests/server/checker/config-loader.test.ts b/tests/server/checker/config-loader.test.ts index 6d28c12..247c6b7 100644 --- a/tests/server/checker/config-loader.test.ts +++ b/tests/server/checker/config-loader.test.ts @@ -43,6 +43,16 @@ describe("parseDuration", () => { 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("拒绝非正整数毫秒结果", () => { expect(() => parseDuration("0ms")).toThrow("正整数毫秒"); expect(() => parseDuration("1.5ms")).toThrow("正整数毫秒"); @@ -1214,4 +1224,51 @@ targets: "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" +`, + "无效的时长格式", + ); + }); }); diff --git a/tests/server/checker/engine.test.ts b/tests/server/checker/engine.test.ts index cb5b0cf..45a7c96 100644 --- a/tests/server/checker/engine.test.ts +++ b/tests/server/checker/engine.test.ts @@ -220,4 +220,55 @@ describe("ProbeEngine", () => { 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(); + }); }); diff --git a/tests/server/checker/store.test.ts b/tests/server/checker/store.test.ts index 4f4fac9..e43bce9 100644 --- a/tests/server/checker/store.test.ts +++ b/tests/server/checker/store.test.ts @@ -419,4 +419,87 @@ describe("ProbeStore", () => { 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(); + }); }); diff --git a/vite.config.ts b/vite.config.ts index 5ff9edb..b1d3375 100644 --- a/vite.config.ts +++ b/vite.config.ts @@ -8,6 +8,17 @@ export default defineConfig({ assetsDir: "assets", emptyOutDir: true, 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()], root: "src/web",