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:
@@ -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`)可以开放任意键名。
|
||||||
|
|
||||||
|
|||||||
@@ -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`
|
||||||
|
|||||||
43
openspec/specs/data-retention/spec.md
Normal file
43
openspec/specs/data-retention/spec.md
Normal 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 不注册清理定时器,数据永久保留
|
||||||
31
openspec/specs/frontend-error-boundary/spec.md
Normal file
31
openspec/specs/frontend-error-boundary/spec.md
Normal 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
|
||||||
@@ -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"`
|
||||||
|
|||||||
@@ -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 chunk(react、tdesign、recharts 各自独立),而非单个 bundle
|
||||||
|
|
||||||
|
#### Scenario: 业务代码变更不影响 vendor 缓存
|
||||||
|
- **WHEN** 仅修改业务代码(src/web/ 下非 node_modules 文件)并重新构建
|
||||||
|
- **THEN** vendor chunk 的文件名(含 hash)SHALL 保持不变,浏览器缓存 SHALL 继续有效
|
||||||
|
|||||||
@@ -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 不受影响
|
||||||
|
|||||||
@@ -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 不注册清理定时器
|
||||||
|
|||||||
@@ -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 之外。
|
||||||
|
|
||||||
|
|||||||
@@ -99,6 +99,9 @@
|
|||||||
"maxConcurrentChecks": {
|
"maxConcurrentChecks": {
|
||||||
"minimum": 1,
|
"minimum": 1,
|
||||||
"type": "integer"
|
"type": "integer"
|
||||||
|
},
|
||||||
|
"retention": {
|
||||||
|
"type": "string"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -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",
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
@@ -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 },
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
|||||||
@@ -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<{
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
@@ -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}",解析结果必须为正整数毫秒`);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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 = () => {
|
||||||
|
|||||||
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 { 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>,
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -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%;
|
||||||
|
}
|
||||||
|
|||||||
@@ -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"
|
||||||
|
`,
|
||||||
|
"无效的时长格式",
|
||||||
|
);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -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();
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -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();
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -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",
|
||||||
|
|||||||
Reference in New Issue
Block a user