1
0

Compare commits

...

10 Commits

Author SHA1 Message Date
76b47006fe feat: 新增两个 OpenSpec 变更提案 — 前端架构重构与 HTTP Checker 质量加固
- frontend-architecture-refactor: 拆分 hooks/组件、类型筛选器动态化
- http-checker-quality-hardening: ReDoS 防护、failure 格式修正、测试补全
2026-05-13 18:40:08 +08:00
147a2559ae refactor: 后端架构加固 — 泛型化、批量查询、bootstrap 统一、路径修复与 pageSize 上限
- CheckerDefinition 泛型化,HTTP/Command checker 移除 resolved target 断言
- 新增 ProbeStore.getAllRecentSamples 消除 targets 路由 N+1 查询
- 统一 getAllTargetStats 与 getTargetStats 的 availability 精度
- Engine rejected 结果写入 internal error 记录,提升可观测性
- 新增 bootstrap.ts 统一 dev/production 启动序列
- dataDir 相对路径改为基于配置文件目录解析
- validatePagination 增加 pageSize 上限 200 校验
- 修复 ErrorBoundary override 标记
- 更新 README/DEVELOPMENT 文档,新增完整测试覆盖
2026-05-13 18:15:46 +08:00
6ea185315f docs: 修正 DEVELOPMENT.md 与实际代码的差异并精简 tcp 示例 2026-05-13 17:27:33 +08:00
ecd47748d2 docs: 修正 README 配置说明与实际代码的差异 2026-05-13 17:27:23 +08:00
bcfb907bd3 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 到主规范
2026-05-13 16:48:56 +08:00
26f0bfe104 docs: 归档 checker 内聚化重构变更,同步 delta specs 到主规范
- 归档 refactor-checker-coherence 变更至 archive/2026-05-13-refactor-checker-cohesion/
- 新增主规范 checker-cohesion-structure(12 条需求)
- 更新主规范 checker-runner-abstraction(新增 base interface 类型相关场景)
2026-05-13 15:02:59 +08:00
bb6b2bc20b refactor: checker 模块内聚化 — 每个 checker 自包含于独立目录
将 checker 架构重构为完全内聚模式:每个 checker 目录包含自身的
types、schema、validate、execute、expect 和 index,新增 checker
只需创建一个目录并在 runner/index.ts 添加一行注册。

主要变更:
- runner/shared/ 拆分:断言基础设施迁入 checker/expect/,
  body.ts 迁入 http/,text.ts 迁入 command/
- config-contract/ 重命名为 schema/,schema.ts → builder.ts
- size.ts + parseDuration 合并为 utils.ts
- 顶层 types.ts 改为 base interface + index signature,
  checker 专属类型下沉到各自 types.ts
- runner/index.ts 改为显式数组注册模式
- 更新 DEVELOPMENT.md 项目结构和开发新 Checker 指南
2026-05-13 14:38:21 +08:00
c396c29402 docs: 添加 checker 内聚化重构方案及归档历史变更
新增 refactor-checker-cohesion 变更提案,包含 proposal、design、
specs 和 tasks,定义 checker 目录内聚结构规范。同时归档已完成的
历史变更记录。
2026-05-13 13:30:05 +08:00
aade0bbff7 refactor: 清理 checker 遗留边界 2026-05-13 12:53:03 +08:00
7b20b59b79 feat: 重构配置校验为 TypeBox + Ajv + semantic validator,严格禁止未知字段
- 新增 config-contract 模块(TypeBox fragments、Ajv 契约校验、ConfigValidationIssue)
- CheckerDefinition 扩展为含 configKey、schemas、validate 的完整插件接口
- HTTP/Command 各自维护 contract.ts + validate.ts,校验从 resolve 中分离
- resolve 不再承担校验,只做默认值合并和路径/单位解析
- config-loader 流程: unknown → RawProbeConfig → ValidatedProbeConfig → ResolvedConfig
- 导出 probe-config.schema.json,新增 schema/schema:check 脚本
- 更新 DEVELOPMENT.md 新增 1.7 开发新 Checker 完整指引
- 同步更新 4 个 main specs(probe-config、command-checker、expect-body-checkers、checker-runner-abstraction)
2026-05-13 12:19:36 +08:00
102 changed files with 5678 additions and 1349 deletions

1
.gitignore vendored
View File

@@ -408,6 +408,7 @@ temp
.agents
skills-lock.json
.worktrees
data/
!scripts/build/
backend/bin
backend/server

View File

@@ -10,3 +10,4 @@ bun.lock
.agents/
skills-lock.json
data/
probe-config.schema.json

View File

@@ -20,54 +20,70 @@
```text
src/
server/
app.ts Bun HTTP 路由入口(路由分发 + API 汇聚)
config.ts CLI 参数解析
dev.ts 生产/开发启动入口
server.ts HTTP server 启动工厂
helpers.ts 共享响应格式化工具jsonResponse、createHeaders 等
middleware.ts API 参数校验中间件guardGetHead、validateTargetId 等
app.ts Bun HTTP 路由入口(路由分发 + API 汇聚、StaticAssets 接口定义
bootstrap.ts 后端统一启动引导loadConfig → store → engine → startServer → shutdown
config.ts CLI 参数解析(仅提取配置文件路径)
dev.ts 开发模式启动入口
server.ts HTTP server 启动工厂(接收 StartServerOptions
helpers.ts 共享响应格式化工具(见下方函数清单
middleware.ts API 参数校验中间件guardGetHead、validateTargetId、validateTimeRange、validatePagination
static.ts 静态资源服务与 SPA fallback
routes/ API 路由 handler按端点拆分
health.ts GET /health
routes/ API 路由 handler按端点拆分,签名因端点而异
health.ts GET /health(无 store 参数)
summary.ts GET /api/summary
targets.ts GET /api/targets
history.ts GET /api/targets/:id/history
trend.ts GET /api/targets/:id/trend
checker/
types.ts 类型定义
config-loader.ts YAML 配置解析与校验
store.ts SQLite 数据存储
engine.ts 调度引擎(按 interval 分组的 es-toolkit groupBy + Semaphore 并发控制
size.ts 大小单位解析
runner/ Checker 统一抽象与注册机制
types.ts Checker 接口、CheckerContext、ResolveContext
registry.ts CheckerRegistry 注册中心
index.ts 注册入口registerCheckers
shared/ 共享 expect 断言函数(跨 checker 复用
failure.ts 失败信息类型
types.ts 基础类型定义ResolvedTargetBase、RawTargetConfig、DefaultsConfig、CheckResult 等基础 interface
config-loader.ts YAML 配置解析、契约校验、语义校验与运行期解析(输出 ResolvedConfig
schema/ TypeBox + Ajv 配置契约、schema fragments、issue 渲染和 schema 导出入口
builder.ts 全量 JSON Schema 组装(遍历 registry 生成
fragments.ts 共享 TypeBox schema 片段duration、size、operator 等)
validate.ts Ajv 契约校验入口
issues.ts 校验问题类型与渲染
types.ts schema 层类型
export.ts JSON Schema 文件导出
store.ts SQLite 数据存储(含 syncTargets、prune 等生命周期方法
engine.ts 调度引擎(按 interval 分组的 es-toolkit groupBy + Semaphore 并发控制 + 数据清理)
utils.ts 共享工具函数parseSize、parseDuration
expect/ 共享 expect 断言基础设施(跨 checker 复用)
types.ts ExpectResult 共享断言类型
failure.ts 失败信息构造errorFailure、mismatchFailure、truncateActual
operator.ts 操作符系统applyOperator、evaluateJsonPath
duration.ts 耗时断言
text.ts 文本规则断言
body.ts Body 规则断言JSONPath/XPath/CSS/contains/regex
http/ HTTP Checker 子包
runner.ts HttpCheckerresolve/execute/serialize
expect.ts HTTP 专用断言status/headers
validate.ts HTTP 配置与 expect 启动期校验
command/ Command Checker 子包
runner.ts CommandCheckerresolve/execute/serialize
expect.ts Command 专用断言exitCode
duration.ts 耗时断言checkDuration
validate-operator.ts 操作符语义校验validateOperatorObject、isJsonValue、isPlainRecord
runner/ Checker 统一抽象与注册机制
types.ts CheckerDefinition、CheckerContext、CheckerSchemas、ResolveContext
registry.ts CheckerRegistry 注册中心
index.ts 注册入口(显式数组 + 循环注册
http/ HTTP Checker自包含模块含 types/schema/execute/expect/validate/body
command/ Command Checker(自包含模块,含 types/schema/execute/expect/validate/text
shared/
api.ts 前后端共享 TypeScript 类型
web/ Vite + React 前端 Dashboard
components/ UI 组件表格、分组、Drawer、状态条等
constants/ 常量定义(列配置、类型映射、排序/筛选/颜色阈值函数
hooks/ TanStack Query 数据层useTargetDetail 集成轮询/条件查询)
app.tsx 根组件(编排全局状态与布局
main.tsx 入口QueryClient 挂载 + ErrorBoundary + ReactQueryDevtools
styles.css 全局样式与自定义 CSS 变量
components/ UI 组件(见下方组件清单)
constants/ 常量与纯函数
target-type-display.ts 类型名称映射
target-table-columns.tsx 表格列定义
target-table-filters.ts 表格筛选器
target-table-sorters.ts 表格排序器
color-threshold.ts 可用率颜色阈值函数
hooks/ TanStack Query 数据层
useTargetDetail.ts 集成轮询/条件查询的组合 hook
utils/ 前端工具函数
scripts/ 开发、构建和 smoke test 脚本
tests/ Bun test 测试
time.ts 时间处理subtractHours
scripts/ 开发、构建、schema 生成和 smoke test 脚本
tests/ Bun test 测试(结构镜像 src 目录)
openspec/ OpenSpec 变更与规格文档
probe-config.schema.json 用户配置 JSON Schema 导出物(用于 IDE 自动补全和校验)
```
> **说明**`runner/http/` 和 `runner/command/` 的完整文件结构见 [1.7.1 架构总览](#171-架构总览) 中的标准文件表。
## 前后端边界
前端只通过 HTTP 调用后端API 路径为 `/api/*`。共享类型放在 `src/shared`,前端不得 import `src/server` 的运行时实现。
@@ -80,13 +96,19 @@ openspec/ OpenSpec 变更与规格文档
```
启动流程:
dev.ts → readRuntimeConfig(cli args) → loadConfig(yaml)
ProbeStore(db) → ProbeEngine(store, targets) → startServer(store)
dev.ts / build entry → readRuntimeConfig(cli args, 仅提取 configPath)
bootstrap({ configPath, mode, staticAssets? })
→ loadConfig(yaml) → ResolvedConfig{ host, port, dataDir, maxConcurrentChecks, retentionMs, targets }
→ ProbeStore(db) → store.syncTargets(targets)
→ ProbeEngine(store, targets, maxConcurrentChecks, retentionMs) → engine.start()
→ startServer({ config, mode, store, staticAssets? })
→ 注册 SIGINT/SIGTERM shutdownengine.stop + store.close
运行时:
定时器(tick) → ProbeEngine.probeGroup()
HTTP: fetcher.ts / Command: command-runner.ts
→ runner/*/expect.ts 校验 → store.insertCheckResult()
checkerRegistry.get(target.type).execute()
→ runner/*/expect.ts 校验 → engine.writeResult() → store.insertCheckResult()
数据清理: 定时 prune(retentionMs),每小时执行一次
HTTP 请求:
Request → app.ts(路由分发) → routes/*.ts(handler)
@@ -109,19 +131,29 @@ HTTP 请求:
### 1.3 API 路由开发
路由文件位于 `src/server/routes/`每个端点一个文件。handler 函数签名统一为
路由文件位于 `src/server/routes/`每个端点一个文件。handler 函数签名因端点而异
```typescript
export function handleXxx(params, store: ProbeStore, method: string, mode: RuntimeMode): Response;
// 无 store 的路由(健康检查不依赖数据库)
export function handleHealth(method: string, mode: RuntimeMode): Response;
// 仅有 store 的路由
export function handleSummary(store: ProbeStore, method: string, mode: RuntimeMode): Response;
export function handleTargets(store: ProbeStore, method: string, mode: RuntimeMode): Response;
// 带 target ID 和查询参数的路由
export function handleHistory(idStr: string, url: URL, method: string, store: ProbeStore, mode: RuntimeMode): Response;
export function handleTrend(idStr: string, url: URL, method: string, store: ProbeStore, mode: RuntimeMode): Response;
```
**请求处理流程**
1. `app.ts``createFetchHandler` 作为总入口,根据 URL pattern 匹配路由
2. API 路由统一经过 `guardGetHead` 做方法检查(仅允许 GET/HEAD
3. 各 handler 内部通过 `middleware.ts` 提供的 `validateTargetId``validateTimeRange``validatePagination` 做参数校验
4. 校验函数返回 `Response` 表示校验失败(直接返回),返回数据对象表示通过
5. 业务逻辑通过 `store` 查询数据,用 `helpers.ts``jsonResponse``mapCheckResult``formatDuration` 等格式化输出
2. `/health` 路由独立处理,不经过 `guardGetHead`(使用 `helpers.ts``allowsGetHead` 自行校验方法
3. `/api/*` 路由统一经过 `guardGetHead` 做方法检查(仅允许 GET/HEAD返回 `null` 表示通过
4. 各 handler 内部通过 `middleware.ts` 提供的 `validateTargetId``validateTimeRange``validatePagination` 做参数校验,`pageSize` 最大值为 `200`
5. 校验函数返回 `Response` 实例表示校验失败(直接返回),返回数据对象表示通过
6. 业务逻辑通过 `store` 查询数据,用 `helpers.ts``jsonResponse``mapCheckResult``formatDuration` 等格式化输出
**新增路由步骤**
@@ -132,8 +164,16 @@ export function handleXxx(params, store: ProbeStore, method: string, mode: Runti
### 1.4 共享工具
- **`helpers.ts`**:跨路由共用的响应工具函数`jsonResponse``createHeaders``createApiError``mapCheckResult``formatDuration``createHealthResponse`
- **`middleware.ts`**API 参数校验函数(`guardGetHead``validateTargetId``validateTimeRange``validatePagination`
- **`helpers.ts`**:跨路由共用的响应工具函数
- `allowsGetHead(method)` — 判断是否为 GET/HEAD 方法
- `createApiError(error, status)` — 构造 API 错误体
- `createHeaders(mode, init)` — 创建响应 Headers生产模式附加安全头
- `createHealthResponse()` — 构造健康检查响应
- `formatDuration(ms)` — 毫秒转为可读时长字符串
- `jsonResponse(body, options)` — JSON 响应构造(自动处理 HEAD 空体)
- `mapCheckResult(row)` — 数据库行转 API CheckResult
- `methodNotAllowedResponse(allow, mode)` — 构造 405 响应
- **`middleware.ts`**API 参数校验函数(`guardGetHead``validateTargetId``validateTimeRange``validatePagination`,其中 `pageSize` 上限为 `200`
- **`static.ts`**:生产模式下的静态资源服务与 SPA fallback
### 1.5 类型定义规范
@@ -143,12 +183,254 @@ export function handleXxx(params, store: ProbeStore, method: string, mode: Runti
- **严格联合类型**优先于宽类型:如 `phase: "status" | "duration" | ...` 而非 `phase: string`
- **后端内部扩展**`checker/types.ts``CheckResult` 通过 `extends` 共享版本的 `ApiCheckResult` 增加 `targetName` 等内部字段
- 存储层类型(`StoredTarget``StoredCheckResult`)独立定义,与 API 类型分离
- 配置类型(`ProbeConfig``TargetConfig`)支持 discriminated union通过 `type` 字段区分 http/command
- **Checker 类型分层**
- `checker/types.ts` 定义 base interface`ResolvedTargetBase``RawTargetConfig``DefaultsConfig`),使用 index signature 支持扩展
- 各 checker 在自己的 `types.ts` 中定义具体类型(如 `ResolvedHttpTarget``ResolvedCommandTarget`),满足 base interface 约束
- 中间层engine、store、config-loader只依赖 base interface不感知具体 checker 类型
- `CheckerDefinition<TResolved extends ResolvedTargetBase = ResolvedTargetBase>` 使用泛型约束 `resolve` 返回值以及 `execute``serialize` 的 target 参数
- checker 实现指定具体 `ResolvedXxxTarget` 类型中间层registry、engine、config-loader、store使用默认泛型参数完成类型擦除
- Checker 内部 `execute``serialize` 直接接收具体类型;`resolve` 输入仍是 `RawTargetConfig`,可在读取 checker 专属原始配置时做必要窄化
### 1.6 数据存储规范
### 1.6 配置契约与校验
配置加载流程固定为:`unknown -> RawProbeConfig -> ValidatedProbeConfig -> ResolvedConfig`
`config-loader.ts` 只负责 YAML 解析、契约调度、公共语义校验和最终运行期解析checker 专属规则必须下沉到对应 checker 的 `schema.ts``validate.ts`
`ResolvedConfig` 包含以下字段:
| 字段 | 来源 | 默认值 |
| --------------------- | -------------------------------------------------- | ---------------- |
| `configDir` | 配置文件所在目录 | — |
| `dataDir` | `server.dataDir`(基于配置文件目录解析为绝对路径) | `configDir/data` |
| `host` | `server.host` | `127.0.0.1` |
| `port` | `server.port` | `3000` |
| `maxConcurrentChecks` | `runtime.maxConcurrentChecks` | `20` |
| `retentionMs` | `runtime.retention` | `7d` |
| `targets` | `targets[]` 经 resolve 后 | — |
契约层使用 `src/server/checker/schema/` 中的 TypeBox fragments 生成 JSON Schema并用 Ajv 执行启动期校验。Ajv 必须保持严格拒绝模式:`allErrors: true`、不启用类型强制转换、不注入默认值、不自动删除未知字段。
默认对象策略是 `additionalProperties: false`。只有明确声明的动态键值表可以开放任意键名,例如 `http.headers``defaults.http.headers``expect.headers``command.env`
契约校验和语义 validator 都必须返回 `ConfigValidationIssue[]`,不要在 validator 内直接拼接最终用户错误字符串。最终错误由 `formatConfigIssues()` 统一渲染,错误路径需要尽量包含 `targetName``defaults`/root 路径。
新增或修改配置字段时必须同步更新TypeBox schema fragments、`probe-config.schema.json` 导出、对应语义 validator、单元测试和 README/DEVELOPMENT 用户文档。提交前运行 `bun run schema:check` 确认导出 schema 与 fragments 一致。
### 1.7 开发新 Checker
Checker 是本项目的核心扩展单元。架构设计目标是**完全内聚**:每个 checker 是 `src/server/checker/runner/<type>/` 下的自包含目录,包含该 checker 所需的全部类型、schema、校验、执行逻辑和断言。新增一个 checker 只需创建一个目录并在 `runner/index.ts` 中添加一行注册。
以下以新增 `tcp` 类型 checker 为例,说明完整的开发步骤。
#### 1.7.1 架构总览
```
checkerRegistry单例
├── runner/index.ts ← 显式数组注册,新增 checker 只需一行
│ ├── new HttpChecker()
│ ├── new CommandChecker()
│ └── new TcpChecker() ← 新增
├── schema/builder.ts ← 自动遍历 registry 生成全量 JSON Schema
├── schema/validate.ts ← 自动遍历 registry 构建 Ajv 校验
├── config-loader.ts ← 自动遍历 registry 调用 validate() + resolve()
├── engine.ts ← 自动按 target.type 分发到 execute()
└── store.ts ← 自动按 target.type 分发到 serialize()
```
每个 checker 目录的标准文件结构:
| 文件 | 职责 |
| ------------- | ------------------------------------------------------------------------------------- |
| `index.ts` | 模块入口re-export Checker 类 |
| `types.ts` | Checker 专属类型ResolvedXxxTarget、XxxTargetConfig、XxxExpectConfig 等) |
| `schema.ts` | TypeBox 契约 schemaconfig / defaults / expect 三部分) |
| `validate.ts` | 启动期语义校验JSON Schema 无法表达的规则) |
| `execute.ts` | Checker 类resolve默认值合并 + 解析、execute执行检查、serializeDB 持久化) |
| `expect.ts` | Checker 专用断言函数 |
| `*.ts` | 其他 checker 专属逻辑(如 http/body.ts、command/text.ts |
#### 1.7.2 步骤一:创建 Checker 目录与类型
`src/server/checker/runner/tcp/types.ts` 中定义 checker 专属类型(参考 `http/types.ts``command/types.ts`
- `XxxTargetConfig` — YAML 原始配置类型
- `XxxExpectConfig` — expect 字段类型
- `XxxDefaultsConfig` — defaults 专属字段类型
- `ResolvedXxxTarget extends ResolvedTargetBase` — resolve 后的完整类型,含 `type: "xxx"` 字面量
**注意**:不需要修改顶层 `checker/types.ts`。base interface 使用 index signature`[key: string]: unknown`checker 专属类型通过 `extends ResolvedTargetBase` 自动兼容。
#### 1.7.3 步骤二:创建 TypeBox 契约 Schema
`src/server/checker/runner/tcp/schema.ts` 中定义 `CheckerSchemas`config / defaults / expect 三部分)。参考 `http/schema.ts``command/schema.ts`,使用 `schema/fragments.ts` 中的共享片段。
**可复用的共享 fragments**(来自 `schema/fragments.ts`
| Fragment | 用途 |
| ---------------------------- | -------------------------------------------------------- |
| `durationSchema` | 时长字符串(`"30s"``"5m"``"2h"``"7d"``"500ms"` |
| `sizeSchema` | 大小单位(字符串如 `"10MB"` 或整数) |
| `statusCodePatternSchema` | 状态码(`100`-`599``"2xx"` |
| `stringMapSchema` | `Record<string, string>`(用于 headers / env |
| `createBodyRulesSchema()` | body 规则数组json/css/xpath/contains/regex |
| `createTextRulesSchema()` | 文本规则数组stdout/stderr |
| `createPureOperatorSchema()` | 操作符对象 |
| `operatorProperties()` | 所有操作符字段的 Record |
**注意**:默认对象策略为 `additionalProperties: false`。只有明确的动态键值表(如 `http.headers``command.env`)可以开放任意键名。
#### 1.7.4 步骤三:实现语义校验
`src/server/checker/runner/tcp/validate.ts` 中实现 JSON Schema 无法表达的语义规则(参考 `http/validate.ts``command/validate.ts`)。函数签名统一为:
```typescript
export function validateTcpConfig(input: CheckerValidationInput): ConfigValidationIssue[];
```
**共享校验工具**`expect/validate-operator.ts`
| 函数 | 用途 |
| --------------------------------------------------------- | ---------------------- |
| `validateOperatorObject(ops, path, targetName, options?)` | 校验操作符对象 |
| `isJsonValue(value)` | 判断是否为合法 JSON 值 |
#### 1.7.5 步骤四:实现 Checker 类
`src/server/checker/runner/tcp/execute.ts` 中实现 `CheckerDefinition` 接口的全部成员(参考 `http/execute.ts``command/execute.ts`
```
TcpChecker implements Checker
readonly configKey ← "tcp"(对应 YAML 中的 target.tcp 字段)
readonly type ← "tcp"
readonly schemas ← tcpCheckerSchemas
validate(input) ← 调用 validateTcpConfig(input)
resolve(target, ctx)← 默认值合并 + 解析,返回 satisfies ResolvedTcpTarget
execute(target, ctx)← 执行检查,返回 CheckResult
serialize(target) ← 返回 { config, target } 用于 DB 持久化
```
**`resolve()` 规范**
- 只做默认值合并、路径解析、单位转换,**不执行校验**
- 返回 `satisfies ResolvedXxxTarget` 确保类型正确
- 通过 `context.defaults[this.configKey]` 访问 checker 专属默认值(需 `as` 断言为具体类型)
**`execute()` 规范**
- 始终记录 `timestamp`ISO 字符串)和 `start = performance.now()`
- 通过 `ctx.signal``AbortSignal`)支持超时取消
- 首个 expect 失败即停止,返回带 `failure` 的结果
- 成功时 `failure: null, matched: true`
- 异常时使用 `errorFailure(phase, path, message)` 构造 failure
- 不匹配时使用 `mismatchFailure(phase, path, expected, actual, message)` 构造 failure
**可用的共享断言工具**`checker/expect/`
| 模块 | 函数 | 用途 |
| ---------------------- | ----------------------------------------------------- | ------------------------------------- |
| `failure.ts` | `errorFailure(phase, path, msg)` | 构造错误类型 failure |
| `failure.ts` | `mismatchFailure(phase, path, expected, actual, msg)` | 构造不匹配类型 failure |
| `failure.ts` | `truncateActual(value, maxLen?)` | 截断过长的 actual 值(默认 200 字符) |
| `duration.ts` | `checkDuration(ms, maxMs?)` | 耗时断言 |
| `operator.ts` | `applyOperator(actual, operator)` | 执行单个操作符比较 |
| `operator.ts` | `evaluateJsonPath(json, path)` | JSONPath 提取 |
| `validate-operator.ts` | `validateOperatorObject(ops, path, name)` | 操作符语义校验 |
**Checker 专属断言**(如需要)放在同目录的 `expect.ts` 中,参考 `http/expect.ts`checkStatus、checkHeaders`command/expect.ts`checkExitCode
#### 1.7.6 步骤五:创建模块入口并注册
创建 `src/server/checker/runner/tcp/index.ts`re-export Checker 类)。
`src/server/checker/runner/index.ts` 中添加一行导入和一个数组元素(参考现有 HttpChecker/CommandChecker
注册后,以下管线会通过 registry 自动委托,**无需新增 type 分支**
| 模块 | 自动行为 |
| -------------------- | ------------------------------------------------------------------------ |
| `schema/builder.ts` | 遍历 registry 生成全量 JSON Schemadefaults.tcp + target.tcp + expect |
| `schema/validate.ts` | 按注册 checker 构建 Ajv 校验,自动识别 `type: tcp` |
| `config-loader.ts` | 遍历 registry 调用每个 checker 的 `validate()` + `resolve()` |
| `engine.ts` | 按 `target.type` 从 registry 取对应 checker 执行 `execute()` |
| `store.ts` | 按 `target.type` 从 registry 取对应 checker 执行 `serialize()` |
注意:自动适配指上述中间层不需要新增 `switch/case` 或类型分支;开发者仍需按后续步骤更新前端展示常量、配置示例、文档和测试。
#### 1.7.7 步骤六:更新前端展示
| 文件 | 修改内容 |
| ------------------------------------------- | ------------------------------------------------------------ |
| `src/web/constants/target-type-display.ts` | 在 `TARGET_TYPE_DISPLAY` 中添加 `"tcp": "TCP"` |
| `src/web/constants/target-table-filters.ts` | 在 `typeFilter.list` 中添加 `{ label: "TCP", value: "tcp" }` |
#### 1.7.8 步骤七:编写测试
测试文件放在 `tests/server/checker/runner/tcp/` 下,镜像源文件结构。必须覆盖:
| 测试类别 | 覆盖内容 | 参考 |
| ---------------- | ------------------------------------------ | ---------------------------------------------------------- |
| **契约测试** | TypeBox schema 与 JSON Schema 导出一致性 | `config-contract/validate.test.ts` |
| **语义校验测试** | `validateTcpConfig()` 各种合法/非法输入 | `http/validate.test.ts`(通过 `runner.test.ts` 间接测试) |
| **resolve 测试** | 默认值合并、路径解析、单位转换 | `http/runner.test.ts``HttpChecker.resolve` describe 块 |
| **execute 测试** | 成功/失败/超时/expect 各种规则组合 | `http/runner.test.ts` 的集成测试 |
| **注册测试** | fresh registry 不污染全局、多 checker 注册 | `registry.test.ts` |
| **配置加载测试** | 含新 checker 的 YAML 完整加载流程 | `config-loader.test.ts` |
#### 1.7.9 步骤八:更新文档和 Schema
| 操作 | 命令/文件 |
| --------------------------------- | -------------------------------------------- |
| 重新生成 JSON Schema 导出 | `bun run schema` |
| 检查导出 schema 与 fragments 一致 | `bun run schema:check` |
| 更新配置示例 | `probes.example.yaml` 中添加新类型示例 |
| 更新用户文档 | `README.md` 中的配置格式说明 |
| 更新项目结构 | `DEVELOPMENT.md` 项目结构中的 runner/ 目录树 |
#### 1.7.10 完整检查清单
```
□ src/server/checker/runner/tcp/types.ts — 专属类型extends ResolvedTargetBase
□ src/server/checker/runner/tcp/schema.ts — TypeBox schemas
□ src/server/checker/runner/tcp/validate.ts — 语义校验
□ src/server/checker/runner/tcp/execute.ts — Checker 类
□ src/server/checker/runner/tcp/expect.ts — 专用断言(如需要)
□ src/server/checker/runner/tcp/index.ts — 模块入口re-export
□ src/server/checker/runner/index.ts — 注册(一行导入 + 一个数组元素)
□ src/web/constants/target-type-display.ts — 前端类型标签
□ src/web/constants/target-table-filters.ts — 前端类型筛选
□ tests/ — 契约 + 校验 + resolve + execute + 注册 测试
□ probes.example.yaml — 配置示例
□ bun run schema + bun run schema:check — Schema 导出同步
□ bun run check — 全量质量检查通过
□ bun run verify — 完整验证(含 build + smoke test
□ README.md — 用户文档
□ DEVELOPMENT.md — 项目结构目录树
```
### 1.8 数据存储规范
基于 `bun:sqlite`WAL 模式运行,数据库文件位于配置的 `dataDir` 下。
**核心方法**
| 方法 | 用途 |
| ----------------------- | ---------------------------------------------------------------- |
| `syncTargets(targets)` | 启动期同步 targets基于 name 做 upsert + delete 事务) |
| `insertCheckResult()` | 写入单条检查结果 |
| `getTargets()` | 查询全部 targetsdefault 分组优先排序) |
| `getLatestChecksMap()` | 批量获取每个 target 的最新检查结果(单次 SQL 聚合) |
| `getAllTargetStats()` | 批量获取每个 target 的可用率统计GROUP BY 聚合) |
| `getAllRecentSamples()` | 批量获取每个 target 的最近 N 条采样window function |
| `getSummary()` | 获取总览统计(基于 `getLatestChecksMap` 内存计算 up/down/total |
| `getTrend()` | 获取按小时聚合的趋势数据 |
| `getHistory()` | 分页查询历史记录 |
| `getRecentSamples()` | 获取最近 N 条采样数据(用于状态条渲染) |
| `prune(retentionMs)` | 按 retention 策略清理过期数据(由 engine 定时调用) |
**Statement 使用规范**
| 场景 | 方式 | 原因 |
@@ -160,7 +442,7 @@ export function handleXxx(params, store: ProbeStore, method: string, mode: Runti
- 避免 N+1 查询:批量场景优先用单次 SQL 聚合GROUP BY、子查询 JOIN+ 内存组装
- 新增批量查询方法时必须编写对应单元测试
- `getSummary()``GET /api/targets` 的响应组装已通过 `getLatestChecksMap` + `getAllTargetStats` 实现批量查询
- `getSummary()``GET /api/targets` 的响应组装已通过 `getLatestChecksMap` + `getAllTargetStats` + `getAllRecentSamples` 实现批量查询
**Schema**
@@ -168,23 +450,25 @@ export function handleXxx(params, store: ProbeStore, method: string, mode: Runti
- `check_results`target_idFK CASCADE、timestamp、matched0/1、duration_ms、status_detail、failureJSON
- 复合索引:`(target_id, timestamp)`
### 1.7 拨测引擎
### 1.9 拨测引擎
- **调度**`ProbeEngine``es-toolkit/groupBy` 按 interval 分组,每组独立 `setInterval` 定时触发
- **并发控制**`es-toolkit/Semaphore` 限制全局最大并发数(`maxConcurrentChecks``acquire()` 阻塞等待
- **Runner 选择**`engine.runCheck()` `target.type` 分发到 `runHttpCheck``runCommandCheck`
- **超时控制**HTTP 用 `AbortController`Command 用 `setTimeout` + `proc.kill()`
- **并发控制**`es-toolkit/Semaphore` 限制全局最大并发数(`maxConcurrentChecks`,默认 20`acquire()` 阻塞等待
- **Runner 选择**`engine.runCheck()` 通过 `checkerRegistry.get(target.type)` 获取 checker并调用 `checker.execute(target, { signal })`
- **超时控制**`ProbeEngine` 为每次检查创建 `AbortController` 并按 `target.timeoutMs` 触发 abortchecker 必须使用 `CheckerContext.signal` 感知超时HTTP 将 signal 传给 `fetch()`Command 在 signal abort 时 `proc.kill()`
- **结果写入**:检查结果通过 `store.insertCheckResult()` 写入 SQLiteengine 通过 `targetNameToId` 缓存 name→id 映射
- **生命周期**`start()`/`stop()` 管理定时器,`stop()` 清理所有 `setInterval`
- **异常可观测**`probeGroup()``Promise.allSettled` 的 rejected 结果通过索引关联 target并写入 `phase:"internal"` 的失败记录
- **数据清理**:当 `retentionMs > 0`engine 启动时立即执行一次 `store.prune()`,之后每小时定时执行,按 `timestamp` 清理过期数据
- **生命周期**`start()`/`stop()` 管理定时器(含调度定时器和清理定时器),`stop()` 清理所有 `setInterval`
### 1.8 expect 断言系统
### 1.10 expect 断言系统
两层模型:**观测值收集** → **规则校验**
两层模型:**观测值收集** → **规则校验**共享断言基础设施位于 `checker/expect/`checker 专属断言位于各自目录。
**HTTP 校验流程**
```
runHttpCheck → 收集观测(statusCode/headers)
HttpChecker.execute → 收集观测(statusCode/headers)
→ status → headers → (early duration) → body(按需) → (final duration)
→ 首个失败即停止,返回 CheckFailure
```
@@ -194,12 +478,12 @@ HTTP checker 的 `durationMs` 覆盖完整执行(含重定向、响应体读
**Command 校验流程**
```
runCommandCheck → 收集观测(exitCode/stdout/stderr/durationMs)
→ checkCommandExpect → exitCode → duration → stdout → stderr
CommandChecker.execute → 收集观测(exitCode/stdout/stderr/durationMs)
→ exitCode → duration → stdout → stderr
→ 首个失败即停止
```
**Body 规则类型**
**Body 规则类型**`runner/http/body.ts`
- `contains`:文本包含匹配
- `regex`正则表达式匹配注意body 正则字段为 `regex`,不是 `match`
@@ -207,18 +491,25 @@ runCommandCheck → 收集观测(exitCode/stdout/stderr/durationMs)
- `css`cheerio CSS 选择器 + 操作符比较
- `xpath`XPath 节点提取 + 操作符比较
**操作符**`equals`(深度比较,`es-toolkit/isEqual`)、`contains``match`(正则)、`empty``isNil`+`isEmptyObject`)、`exists``gte`/`lte`/`gt`/`lt`
**文本规则**`runner/command/text.ts`stdout/stderr 文本匹配,支持 `contains``match`(正则)、操作符比较
### 1.9 错误模式
**操作符**`expect/operator.ts``equals`(深度比较,`es-toolkit/isEqual`)、`contains``match`(正则)、`empty``isNil`+`isEmptyObject`)、`exists``gte`/`lte`/`gt`/`lt`
### 1.11 错误模式
- **API 错误**`{ error: "描述", status: <code> }`,状态码 400/404/405/503
- **CheckFailure**`{ kind: "error"|"mismatch", phase, path, expected?, actual?, message }`
- **错误处理**expect 校验失败记录首个失败原因;网络/超时/进程崩溃统一为 `kind:"error"`,请求/TLS/timeout 错误归属 `phase:"request"`body 超限/解码/解析错误归属 `phase:"body"`
- **日志**:解析失败等非致命异常用 `console.warn`,启动失败用 `console.error` + `process.exit(1)`
### 1.10 测试规范
### 1.12 测试规范
- 测试文件与源文件对应:`tests/server/checker/runner/shared/body.test.ts``src/server/checker/runner/shared/body.ts`
- 测试目录 `tests/` 镜像 `src/` 目录结构,但共享模块的测试集中放在 `tests/server/checker/runner/shared/`
- `tests/server/checker/runner/shared/failure.test.ts``src/server/checker/expect/failure.ts`
- `tests/server/checker/runner/shared/duration.test.ts``src/server/checker/expect/duration.ts`
- `tests/server/checker/runner/shared/operator.test.ts``src/server/checker/expect/operator.ts`
- `tests/server/checker/runner/shared/body.test.ts``src/server/checker/runner/http/body.ts`
- `tests/server/checker/runner/shared/text.test.ts``src/server/checker/runner/command/text.ts`
- 使用 `bun:test` 框架(`describe`/`test`/`expect`),测试数据库用临时目录 + `tmpdir()`
- 新增 store 方法必须编写单元测试;新增 API 端点必须在 `app.test.ts` 中添加集成测试
- 测试后清理:`afterAll``store.close()` + `rm(tempDir, { recursive: true })`
@@ -230,12 +521,12 @@ runCommandCheck → 收集观测(exitCode/stdout/stderr/durationMs)
### 2.1 技术栈概览
| 层面 | 技术 | 用途 |
| ------ | ----------------------------------- | ---------------------------- |
| ------ | --------------------------------------------------- | ---------------------------- |
| 框架 | React 19 | UI 组件开发 |
| 构建 | Vite 8 | 开发服务与生产构建 |
| 语言 | TypeScript 6 | 类型安全 |
| UI 库 | TDesign React + tdesign-icons-react | UI 组件与图标 |
| 数据层 | TanStack Query (React Query) | 服务端状态管理与自动轮询 |
| 数据层 | TanStack Query (React Query) + React Query Devtools | 服务端状态管理与自动轮询 |
| 图表 | Recharts | 拨测趋势折线图与状态环状图 |
| 路由 | 无(单页面 Dashboard | 仅需 Drawer/Tab 做页面内导航 |
@@ -245,18 +536,21 @@ runCommandCheck → 收集观测(exitCode/stdout/stderr/durationMs)
```
main.tsx
└── QueryClientProviderTanStack Query 全局挂载)
└── App根组件
── SummaryCards总览统计卡片
└── useSummary() ─── GET /api/summary8s 轮询
└── TargetBoard目标列表
├── useTargets() ─── GET /api/targets8s 轮询)
└── TargetGroup[](按 group 字段分组
── PrimaryTable ← TARGET_TABLE_COLUMNS列定义排序/筛选/渲染
└── TargetDetailDrawer目标详情抽屉
└── useTargetDetail() ── 按需发起 trend + history 查询
── Tab: 概览 → Statistic + TrendChart + StatusDonut + Descriptions
└── Tab: 记录 → PrimaryTable分页历史记录
└── StrictMode
└── ErrorBoundaryReact 错误边界
── QueryClientProviderTanStack Query 全局挂载
├── App根组件
│ ├── SummaryCards总览统计卡片
│ │ └── useSummary() ─── GET /api/summary8s 轮询)
└── TargetBoard目标列表
── useTargets() ─── GET /api/targets8s 轮询
│ └── TargetGroup[](按 group 字段分组
│ └── PrimaryTable ← TARGET_TABLE_COLUMNS列定义排序/筛选/渲染)
── TargetDetailDrawer目标详情抽屉
└── useTargetDetail() ── 按需发起 trend + history 查询
│ ├── Tab: 概览 → Statistic + TrendChart + StatusDonut + Descriptions
│ └── Tab: 记录 → PrimaryTable分页历史记录
└── ReactQueryDevtools开发工具仅开发环境
```
**数据层架构**
@@ -362,14 +656,15 @@ export function TargetGroup({ name, targets, onTargetClick }: TargetGroupProps)
- **展示组件**`components/`):纯渲染逻辑,通过 props 接收数据,通过回调返回事件
- **容器逻辑**放在 hooks 中,组件只做数据消费
- **常量数据**(列定义、排序器、筛选器)放在 `constants/`,不放在组件内部
- **常量数据**(列定义、排序器、筛选器、颜色阈值)放在 `constants/`,不放在组件内部
- **工具函数**(时间处理等)放在 `utils/`,保持纯函数无副作用
#### 现有组件清单
| 组件 | 文件 | 用途 |
| -------------------- | ----------------------------------- | ---------------------------------- |
| -------------------- | ----------------------------------- | ----------------------------------------- |
| `App` | `app.tsx` | 根组件,编排全局状态与布局 |
| `ErrorBoundary` | `components/ErrorBoundary.tsx` | React 错误边界,捕获渲染异常并展示降级 UI |
| `SummaryCards` | `components/SummaryCards.tsx` | 总览统计卡片(全部/正常/异常) |
| `TargetBoard` | `components/TargetBoard.tsx` | 按分组渲染目标表格列表 |
| `TargetGroup` | `components/TargetGroup.tsx` | 单个分组标题 + PrimaryTable |
@@ -420,7 +715,7 @@ export function TargetGroup({ name, targets, onTargetClick }: TargetGroupProps)
### 2.7 前端测试规范
- 测试目录:`tests/web/`,结构对应 `src/web/`
- 重点测试 **constants/** 中的纯函数(排序器、筛选器、颜色阈值等)
- 重点测试 **constants/** 中的纯函数(排序器、筛选器、颜色阈值、类型映射等)
- 使用 `bun:test` 框架
---
@@ -461,9 +756,10 @@ bun run dev:web
#### 开发期代理
Vite 配置了开发代理(`vite.config.ts`
Vite 配置了开发代理(`vite.config.ts`和代码分割策略
```typescript
// 开发代理
server: {
proxy: {
"/api": {
@@ -472,6 +768,11 @@ server: {
},
},
}
// 生产代码分割rolldownOptions.output.codeSplitting.groups
// vendor-react: react/react-dom/scheduler
// vendor-tdesign: tdesign
// vendor-chart: recharts/d3-*
```
前端访问 `/api/*`Vite 开发服务器自动转发到后端 `http://127.0.0.1:${backendPort}`,无需 CORS 配置。
@@ -482,7 +783,7 @@ server: {
#### 生产期集成
生产可执行文件是单体应用:前端静态资源嵌入 binary后端同时提供 API 和静态文件服务。
生产可执行文件是单体应用:前端静态资源嵌入 binary(通过 `StaticAssets` 接口:`files: Record<string, Blob>` + `indexHtml: Blob`,后端同时提供 API 和静态文件服务。
```
./dist/dial-server probes.yaml
@@ -521,7 +822,7 @@ bun run build
└── 导出 staticAssets: StaticAssets 对象
3. 生成 .build/server-entry.ts临时文件
└── import 后端入口模块 + staticAssets作为 Bun.build 入口
└── import bootstrap + staticAssets调用 production bootstrap作为 Bun.build 入口
4. Bun.build({ compile, minify, sourcemap: "linked" })
└── 输出dist/dial-server单文件可执行 binary
@@ -592,9 +893,11 @@ bun run test:smoke
### 3.6 脚本说明
| 脚本 | 文件 | 说明 |
| -------------------- | ------------------ | ------------------------------ |
| ---------------------- | ----------------------------------- | ------------------------------- |
| `bun run dev` | `scripts/dev.ts` | 同时启动前后端开发服务 |
| `bun run build` | `scripts/build.ts` | Vite 构建 + Bun 编译可执行文件 |
| `bun run schema` | `scripts/generate-config-schema.ts` | 生成 `probe-config.schema.json` |
| `bun run schema:check` | `scripts/generate-config-schema.ts` | 检查配置 schema 导出物是否同步 |
| `bun run test:smoke` | `scripts/smoke.ts` | 构建后的端到端验证 |
| `bun run clean` | `scripts/clean.ts` | 清理构建缓存与临时文件 |
@@ -608,11 +911,12 @@ bun run test:smoke
### 3.8 项目配置文件
| 文件 | 用途 |
| --------------------- | ---------------------------------------------- |
| ---------------------- | ---------------------------------------------- |
| `package.json` | 项目信息、脚本、依赖声明 |
| `tsconfig.json` | TypeScript 配置ESNext 模块、严格模式) |
| `vite.config.ts` | Vite 开发代理与构建配置 |
| `vite.config.ts` | Vite 开发代理与构建配置(含代码分割策略) |
| `eslint.config.js` | ESLint 规则(含前端不得 import server 的检查) |
| `commitlint.config.js` | commitlint 提交信息格式校验 |
| `.prettierrc.json` | Prettier 格式化规则(`printWidth: 120` |
| `.prettierignore` | Prettier 排除路径 |
| `probes.example.yaml` | 配置文件示例 |
@@ -648,9 +952,10 @@ bun run test:smoke
```bash
bun run lint # ESLint 检查含类型感知规则、导入排序、导入验证、Prettier 格式)
bun run format # Prettier 自动格式化
bun run schema:check # 检查 probe-config.schema.json 是否与 TypeBox fragments 同步
bun run typecheck # TypeScript 类型检查(含 noUnusedLocals、noPropertyAccessFromIndexSignature
bun test # 运行所有测试
bun run check # 一键运行 typecheck + lint + test
bun run check # 一键运行 schema:check + typecheck + lint + test
```
`check` 是日常开发推荐的质量检查命令。
@@ -683,6 +988,7 @@ bun run check # 一键运行 typecheck + lint + test
| `noUnusedParameters` | false | 保留关闭(路由 handler 统一签名需要,如 `handleXxx(store, method, mode)` |
| `noPropertyAccessFromIndexSignature` | true | 禁止通过点号访问索引签名属性,强制使用括号语法 |
| `noUncheckedIndexedAccess` | true | 数组/Map 访问必须运行时真值检查 |
| `noImplicitOverride` | true | 子类覆盖父类方法时必须显式使用 `override` 关键字 |
| `verbatimModuleSyntax` | true | 强制 `import type` 纯类型导入 |
### Git Hooks
@@ -707,4 +1013,4 @@ bun run verify # 完整验证check + 构建 + smoke test
## 已知限制
当前不做告警通知、数据自动清理、拨测目标动态增删、认证鉴权和分布式部署。Command 类型拨测不支持 Windows 环境。
当前不做告警通知、拨测目标动态增删、认证鉴权和分布式部署。Command 类型拨测不支持 Windows 环境。

View File

@@ -24,6 +24,8 @@ bun run dev:web
程序通过 YAML 配置文件定义所有运行参数:
```yaml
# yaml-language-server: $schema=./probe-config.schema.json
server:
host: "127.0.0.1"
port: 3000
@@ -31,15 +33,16 @@ server:
runtime:
maxConcurrentChecks: 20
retention: "7d"
defaults:
interval: "5s"
timeout: "10s"
http:
method: GET
maxBodyBytes: "100MB"
maxBodyBytes: "10MB"
command:
maxOutputBytes: "100MB"
maxOutputBytes: "1MB"
targets:
- name: "Baidu"
@@ -93,24 +96,27 @@ targets:
- **server**: 服务配置(均可省略,使用默认值)
- `host`: 监听地址,默认 `127.0.0.1`
- `port`: 监听端口,默认 `3000`
- `dataDir`: 数据目录,默认 `./data`
- `dataDir`: 数据目录,默认 `./data`,相对路径基于配置文件所在目录解析
- **runtime**: 运行时配置
- `maxConcurrentChecks`: 最大并发拨测数,默认 `20`
- `retention`: 历史数据保留时长,默认 `7d`,支持 `ms`/`s`/`m`/`h`/`d` 单位
- **defaults**: 全局默认值(均可省略)
- `interval`: 拨测间隔,默认 `30s`
- `timeout`: 超时时间,默认 `10s`
- `http`: HTTP 类型默认值
- `method`: HTTP 方法,默认 `GET`,支持 `GET``HEAD``POST``PUT``PATCH``DELETE``OPTIONS`
- `method`: HTTP 方法,默认 `GET`必须使用大写枚举值,支持 `GET``HEAD``POST``PUT``PATCH``DELETE``OPTIONS`
- `maxBodyBytes`: 响应体最大字节数,默认 `100MB`
- `headers`: 默认请求头target 中的 headers 会合并覆盖 defaults 中的同名头)
- `command`: Command 类型默认值
- `maxOutputBytes`: 输出最大字节数,默认 `100MB`
- `cwd`: 默认工作目录(相对于配置文件所在目录解析,默认 `.`
- **targets**: 拨测目标列表(必填)
- `name`: 目标名称(必填,唯一)
- `type`: 目标类型,`http``command`(必填)
- `group`: 分组名称(可选,默认 `"default"`
- `http`: HTTP 拨测配置type 为 http 时必填)
- `url`: 目标 URL
- `method``headers``body`: 请求参数
- `method``headers``body`: 请求参数`headers` 会与 `defaults.http.headers` 合并target 优先)
- `ignoreSSL`: 是否忽略 HTTPS 证书校验,默认 `false`,用于自签名或私有证书服务
- `maxRedirects`: 最大重定向跟随次数,默认 `0`(不跟随重定向)
- `command`: 命令行拨测配置type 为 command 时必填)
@@ -120,33 +126,46 @@ targets:
- `cwd`: 工作目录(可选,相对于配置文件所在目录解析,默认 `.`
- `interval``timeout`: 覆盖全局默认值
- `expect`: 期望校验
- `status`: 可接受的状态码列表HTTP支持精确状态码和范围模式`"2xx"`)混合配置
- `exitCode`: 可接受的退出码列表Command
- `headers`: 响应头校验HTTP支持 `equals``contains`操作符)
- `maxDurationMs`: 最大耗时阈值(毫秒)HTTP 类型覆盖完整执行(含重定向、响应体读取和 expect 校验)
- `status`: 可接受的状态码列表HTTP支持精确状态码和范围模式`"2xx"`)混合配置;未指定时默认 `[200]`
- `exitCode`: 可接受的退出码列表Command;未指定时不校验退出码
- `headers`: 响应头校验HTTP支持字符串精确匹配或操作符对象
- `maxDurationMs`: 最大耗时阈值(毫秒)
- HTTP覆盖完整执行含重定向、响应体读取和 expect 校验)
- Command覆盖命令执行耗时含 stdout/stderr 读取)
- `body`: HTTP 响应体校验(数组,可组合使用)
- `contains`: 响应体包含的文本
- `regex`: 响应体匹配的正则表达式
- `json`: JSONPath 提取值比较`path` + 比较操作符)
- `json`: JSONPath 提取值比较
- `path`: JSONPath 表达式(必填,如 `$.slideshow.title`
- 比较操作符(可选,无操作符时仅检查路径对应值是否存在)
- `css`: CSS 选择器提取 HTML 元素比较
- `selector`: CSS 选择器(必填)
- `attr`: 提取元素属性值而非文本内容(可选,如 `href``class`
- 比较操作符(可选,无操作符时仅检查元素是否存在)
- `xpath`: XPath 提取 XML/HTML 节点比较
- `stdout` / `stderr`: Command 输出校验(数组,同 body 格式
- `path`: XPath 表达式(必填,如 `/html/body/h1/text()`
- 比较操作符(可选,无操作符时仅检查节点是否存在)
- `stdout` / `stderr`: Command 输出校验(数组,每项为一个操作符对象)
- 比较操作符:`equals`(默认)、`contains``match`(正则)、`empty``exists``gte``lte``gt``lt`
大小说明:`maxBodyBytes``maxOutputBytes` 支持单位 `KB``MB``GB`,也可直接使用数字(非负安全整数字节数)。
配置校验:系统启动时严格校验所有已支持字段类型和格式,非法配置会阻止启动并输出清晰的错误信息。未知字段会被忽略,不影响启动和运行。
配置校验:系统启动时会先用 TypeBox 生成的 JSON Schema 契约校验字段类型、必填字段、枚举、数组/对象形状和未知字段,再执行语义 validator 校验 target name 唯一性、URL、正则、JSONPath、XPath、size/duration 解析等规则。非法配置会阻止启动并输出中文错误信息。
时长格式支持:`30s``5m``500ms`
未知字段:除 `http.headers``defaults.http.headers``expect.headers``command.env` 等动态键值表外,未知字段会导致启动失败。配置备注请使用 YAML 注释,不要添加 `note``comment` 等未声明字段。
JSON Schema仓库根目录导出 `probe-config.schema.json`,可在 YAML 文件顶部添加 `# yaml-language-server: $schema=./probe-config.schema.json` 获取编辑器提示和静态校验。该 schema 由运行期契约 fragments 生成,提交前可用 `bun run schema:check` 检查同步。
时长格式支持:`500ms``30s``5m``2h``7d`
## API 端点
| 端点 | 说明 |
| ----------------------------------------------------------------- | --------------------------------------- |
| ----------------------------------------------------------------- | ------------------------------------------------------------ |
| `GET /health` | 健康检查 |
| `GET /api/summary` | 总览统计total/up/down/lastCheckTime |
| `GET /api/targets` | 目标列表及最新状态、分组和采样数据 |
| `GET /api/targets/:id/history?from=ISO&to=ISO&page=1&pageSize=20` | 指定目标的拨测记录(时间范围 + 分页) |
| `GET /api/targets/:id/history?from=ISO&to=ISO&page=1&pageSize=20` | 指定目标的拨测记录(时间范围 + 分页`pageSize` 最大 `200` |
| `GET /api/targets/:id/trend?from=ISO&to=ISO` | 指定目标的按小时聚合趋势 |
### 响应字段
@@ -159,7 +178,7 @@ targets:
**CheckResult**: `timestamp``matched``durationMs``statusDetail``failure`
**CheckFailure**: `kind`error/mismatch`phase``path``expected``actual``message`
**CheckFailure**: `kind`error/mismatch`phase``path``message``expected?`(仅 mismatch`actual?`(仅 mismatch
**TargetStats**: `totalChecks``availability`
@@ -176,8 +195,8 @@ API 错误返回 `ApiErrorResponse` 格式:
```
| 状态码 | 触发场景 |
| ------ | ----------------------------------------------------------------------- |
| 400 | 参数格式错误(无效 ID、from/to 缺失或格式错误、page/pageSize 非正整数) |
| ------ | ------------------------------------------------------------------------------------------ |
| 400 | 参数格式错误(无效 ID、from/to 缺失或格式错误、page/pageSize 非正整数、pageSize 超过 200 |
| 404 | 目标不存在、API 路由未匹配 |
| 405 | 非 GET 方法请求 API 路由 |
@@ -193,7 +212,7 @@ CLI 只接受一个参数YAML 配置文件路径。
单层判定模型,适用于 HTTP 和 Command 两种类型:
- **matched**: 是否符合 expect 规则HTTP 无 expect 时默认检查 status 200
- **matched**: 是否符合 expect 规则HTTP 未指定 `expect.status` 时默认检查 `[200]`
- **UP** = matched
- **DOWN** = NOT matched

View File

@@ -5,8 +5,10 @@
"": {
"name": "gateway-checker",
"dependencies": {
"@sinclair/typebox": "^0.34.49",
"@tanstack/react-query": "^5.100.10",
"@xmldom/xmldom": "^0.9.10",
"ajv": "^8.20.0",
"cheerio": "^1.2.0",
"es-toolkit": "^1.46.1",
"react": "^19.2.6",
@@ -203,6 +205,8 @@
"@simple-libs/stream-utils": ["@simple-libs/stream-utils@1.2.0", "https://registry.npmmirror.com/@simple-libs/stream-utils/-/stream-utils-1.2.0.tgz", {}, "sha512-KxXvfapcixpz6rVEB6HPjOUZT22yN6v0vI0urQSk1L8MlEWPDFCZkhw2xmkyoTGYeFw7tWTZd7e3lVzRZRN/EA=="],
"@sinclair/typebox": ["@sinclair/typebox@0.34.49", "https://registry.npmmirror.com/@sinclair/typebox/-/typebox-0.34.49.tgz", {}, "sha512-brySQQs7Jtn0joV8Xh9ZV/hZb9Ozb0pmazDIASBkYKCjXrXU3mpcFahmK/z4YDhGkQvP9mWJbVyahdtU5wQA+A=="],
"@standard-schema/spec": ["@standard-schema/spec@1.1.0", "https://registry.npmmirror.com/@standard-schema/spec/-/spec-1.1.0.tgz", {}, "sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w=="],
"@standard-schema/utils": ["@standard-schema/utils@0.3.0", "https://registry.npmmirror.com/@standard-schema/utils/-/utils-0.3.0.tgz", {}, "sha512-e7Mew686owMaPJVNNLs55PUvgz371nKgwsc4vxE49zsODpJEnxgxRo2y/OKrqueavXgZNMDVj3DdHFlaSAeU8g=="],
@@ -323,7 +327,7 @@
"acorn-jsx": ["acorn-jsx@5.3.2", "https://registry.npmmirror.com/acorn-jsx/-/acorn-jsx-5.3.2.tgz", { "peerDependencies": { "acorn": "^6.0.0 || ^7.0.0 || ^8.0.0" } }, "sha512-rq9s+JNhf0IChjtDXxllJ7g41oZk5SlXtp0LHwyA5cejwn7vKmKp4pPri6YEePv2PU65sAsegbXtIinmDFDXgQ=="],
"ajv": ["ajv@6.15.0", "https://registry.npmmirror.com/ajv/-/ajv-6.15.0.tgz", { "dependencies": { "fast-deep-equal": "^3.1.1", "fast-json-stable-stringify": "^2.0.0", "json-schema-traverse": "^0.4.1", "uri-js": "^4.2.2" } }, "sha512-fgFx7Hfoq60ytK2c7DhnF8jIvzYgOMxfugjLOSMHjLIPgenqa7S7oaagATUq99mV6IYvN2tRmC0wnTYX6iPbMw=="],
"ajv": ["ajv@8.20.0", "https://registry.npmmirror.com/ajv/-/ajv-8.20.0.tgz", { "dependencies": { "fast-deep-equal": "^3.1.3", "fast-uri": "^3.0.1", "json-schema-traverse": "^1.0.0", "require-from-string": "^2.0.2" } }, "sha512-Thbli+OlOj+iMPYFBVBfJ3OmCAnaSyNn4M1vz9T6Gka5Jt9ba/HIR56joy65tY6kx/FCF5VXNB819Y7/GUrBGA=="],
"ansi-escapes": ["ansi-escapes@7.3.0", "https://registry.npmmirror.com/ansi-escapes/-/ansi-escapes-7.3.0.tgz", { "dependencies": { "environment": "^1.0.0" } }, "sha512-BvU8nYgGQBxcmMuEeUEmNTvrMVjJNSH7RgW24vXexN4Ven6qCvy4TntnvlnwnMLTVlcRQQdbRY8NKnaIoeWDNg=="],
@@ -709,7 +713,7 @@
"json-parse-even-better-errors": ["json-parse-even-better-errors@2.3.1", "https://registry.npmmirror.com/json-parse-even-better-errors/-/json-parse-even-better-errors-2.3.1.tgz", {}, "sha512-xyFwyhro/JEof6Ghe2iz2NcXoj2sloNsWr/XsERDK/oiPCfaNhl5ONfp+jQdAZRQQ0IJWNzH9zIZF7li91kh2w=="],
"json-schema-traverse": ["json-schema-traverse@0.4.1", "https://registry.npmmirror.com/json-schema-traverse/-/json-schema-traverse-0.4.1.tgz", {}, "sha512-xbbCH5dCYU5T8LcEhhuh7HJ88HXuW3qsI3Y0zOZFKfZEHcpWiHU/Jxzk629Brsab/mMiHQti9wMP+845RPe3Vg=="],
"json-schema-traverse": ["json-schema-traverse@1.0.0", "https://registry.npmmirror.com/json-schema-traverse/-/json-schema-traverse-1.0.0.tgz", {}, "sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug=="],
"json-stable-stringify-without-jsonify": ["json-stable-stringify-without-jsonify@1.0.1", "https://registry.npmmirror.com/json-stable-stringify-without-jsonify/-/json-stable-stringify-without-jsonify-1.0.1.tgz", {}, "sha512-Bdboy+l7tA3OGW6FjyFHWkP5LuByj1Tk33Ljyq0axyzdk9//JSi2u3fP1QSmd1KNwq6VOKYGlAu87CisVir6Pw=="],
@@ -1039,8 +1043,6 @@
"@babel/core/json5": ["json5@2.2.3", "https://registry.npmmirror.com/json5/-/json5-2.2.3.tgz", { "bin": { "json5": "lib/cli.js" } }, "sha512-XmOWe7eyHYH14cLdVPoyg+GOH3rYX++KpzrylJwSW98t3Nk+U8XOl8FWKOgwtzdb8lXGf6zYwDUzeHMWfxasyg=="],
"@commitlint/config-validator/ajv": ["ajv@8.20.0", "https://registry.npmmirror.com/ajv/-/ajv-8.20.0.tgz", { "dependencies": { "fast-deep-equal": "^3.1.3", "fast-uri": "^3.0.1", "json-schema-traverse": "^1.0.0", "require-from-string": "^2.0.2" } }, "sha512-Thbli+OlOj+iMPYFBVBfJ3OmCAnaSyNn4M1vz9T6Gka5Jt9ba/HIR56joy65tY6kx/FCF5VXNB819Y7/GUrBGA=="],
"@commitlint/is-ignored/semver": ["semver@7.8.0", "https://registry.npmmirror.com/semver/-/semver-7.8.0.tgz", { "bin": { "semver": "bin/semver.js" } }, "sha512-AcM7dV/5ul4EekoQ29Agm5vri8JNqRyj39o0qpX6vDF2GZrtutZl5RwgD1XnZjiTAfncsJhMI48QQH3sN87YNA=="],
"@conventional-changelog/git-client/semver": ["semver@7.8.0", "https://registry.npmmirror.com/semver/-/semver-7.8.0.tgz", { "bin": { "semver": "bin/semver.js" } }, "sha512-AcM7dV/5ul4EekoQ29Agm5vri8JNqRyj39o0qpX6vDF2GZrtutZl5RwgD1XnZjiTAfncsJhMI48QQH3sN87YNA=="],
@@ -1067,6 +1069,8 @@
"cliui/wrap-ansi": ["wrap-ansi@9.0.2", "https://registry.npmmirror.com/wrap-ansi/-/wrap-ansi-9.0.2.tgz", { "dependencies": { "ansi-styles": "^6.2.1", "string-width": "^7.0.0", "strip-ansi": "^7.1.0" } }, "sha512-42AtmgqjV+X1VpdOfyTGOYRi0/zsoLqtXQckTmqTeybT+BDIbM/Guxo7x3pE2vtpr1ok6xRqM9OpBe+Jyoqyww=="],
"eslint/ajv": ["ajv@6.15.0", "https://registry.npmmirror.com/ajv/-/ajv-6.15.0.tgz", { "dependencies": { "fast-deep-equal": "^3.1.1", "fast-json-stable-stringify": "^2.0.0", "json-schema-traverse": "^0.4.1", "uri-js": "^4.2.2" } }, "sha512-fgFx7Hfoq60ytK2c7DhnF8jIvzYgOMxfugjLOSMHjLIPgenqa7S7oaagATUq99mV6IYvN2tRmC0wnTYX6iPbMw=="],
"eslint-import-resolver-node/debug": ["debug@3.2.7", "https://registry.npmmirror.com/debug/-/debug-3.2.7.tgz", { "dependencies": { "ms": "^2.1.1" } }, "sha512-CFjzYYAi4ThfiQvizrFQevTTXHtnCqWfe7x1AhgEscTz6ZbLbfoLRLPugTQyBth6f8ZERVUSyWHFD/7Wu4t1XQ=="],
"eslint-module-utils/debug": ["debug@3.2.7", "https://registry.npmmirror.com/debug/-/debug-3.2.7.tgz", { "dependencies": { "ms": "^2.1.1" } }, "sha512-CFjzYYAi4ThfiQvizrFQevTTXHtnCqWfe7x1AhgEscTz6ZbLbfoLRLPugTQyBth6f8ZERVUSyWHFD/7Wu4t1XQ=="],
@@ -1099,10 +1103,10 @@
"wrap-ansi/string-width": ["string-width@8.2.1", "https://registry.npmmirror.com/string-width/-/string-width-8.2.1.tgz", { "dependencies": { "get-east-asian-width": "^1.5.0", "strip-ansi": "^7.1.2" } }, "sha512-IIaP0g3iy9Cyy18w3M9YcaDudujEAVHKt3a3QJg1+sr/oX96TbaGUubG0hJyCjCBThFH+tFpcIyoUHUn1ogaLA=="],
"@commitlint/config-validator/ajv/json-schema-traverse": ["json-schema-traverse@1.0.0", "https://registry.npmmirror.com/json-schema-traverse/-/json-schema-traverse-1.0.0.tgz", {}, "sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug=="],
"eslint-plugin-import/minimatch/brace-expansion": ["brace-expansion@1.1.14", "https://registry.npmmirror.com/brace-expansion/-/brace-expansion-1.1.14.tgz", { "dependencies": { "balanced-match": "^1.0.0", "concat-map": "0.0.1" } }, "sha512-MWPGfDxnyzKU7rNOW9SP/c50vi3xrmrua/+6hfPbCS2ABNWfx24vPidzvC7krjU/RTo235sV776ymlsMtGKj8g=="],
"eslint/ajv/json-schema-traverse": ["json-schema-traverse@0.4.1", "https://registry.npmmirror.com/json-schema-traverse/-/json-schema-traverse-0.4.1.tgz", {}, "sha512-xbbCH5dCYU5T8LcEhhuh7HJ88HXuW3qsI3Y0zOZFKfZEHcpWiHU/Jxzk629Brsab/mMiHQti9wMP+845RPe3Vg=="],
"eslint-plugin-import/minimatch/brace-expansion/balanced-match": ["balanced-match@1.0.2", "https://registry.npmmirror.com/balanced-match/-/balanced-match-1.0.2.tgz", {}, "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw=="],
}
}

View File

@@ -0,0 +1,2 @@
schema: spec-driven
created: 2026-05-13

View File

@@ -0,0 +1,121 @@
## Context
DiAL 后端是基于 Bun 的拨测服务,当前有 2 个 checker 类型http、commandtarget 规模预计增长到 100checker 类型预计超过 5 种。
现状问题:
1. `GET /api/targets` 对每个 target 单独查询 `getRecentSamples`,产生 N+3 次 SQL 查询
2. `ProbeEngine.probeGroup` 中 rejected 结果仅 `console.warn`,前端无法感知异常
3. `dev.ts``scripts/build.ts` 生成的 entry 各自维护相同的启动序列
4. `config-loader.ts``dataDir` 未基于 `configDir` 解析,相对路径依赖进程 cwd
5. `validatePagination` 无 pageSize 上限,可被滥用
6. `CheckerDefinition` 接口方法参数为 `ResolvedTargetBase`checker 内部需手动 `as` 断言
## Goals / Non-Goals
**Goals:**
- 消除 targets 路由的 N+1 查询,支撑 100 target 规模
- Engine 异常可观测rejected 结果写入数据库,前端可见
- 启动逻辑单一来源,降低维护成本
- 修复 dataDir 路径解析 bug
- API 防御性pageSize 上限
- CheckerDefinition 泛型化checker 开发者获得编译期类型安全
**Non-Goals:**
- 不做配置热更新
- 不做 API 认证/鉴权
- 不做通知/告警系统
- 不改变 `ResolvedTargetBase` 的 index signatureregistry 层仍用类型擦除)
- 不改变前端行为
## Decisions
### Decision 1: 批量查询 recentSamples 使用 window function
**选择**:在 `ProbeStore` 中新增 `getAllRecentSamples(limit: number)` 方法,使用 SQLite window function `ROW_NUMBER() OVER (PARTITION BY target_id ORDER BY timestamp DESC)` 一次查询所有 target 的最近 N 条采样。
**替代方案**
- UNION ALL 拼接每个 target 的子查询 → SQL 长度随 target 数线性增长,不可控
- 应用层批量(一次查全部再内存分组)→ 数据量大时内存开销高
**理由**window function 是 SQLite 3.25+ 原生支持的特性Bun 内置的 SQLite 版本满足要求。单次查询SQL 固定长度,性能最优。
### Decision 2: Engine rejected 写入 internal error 记录
**选择**:在 `probeGroup` 中,对 `rejected` 的结果构造一条 `matched: false``failure: { kind: "error", phase: "internal", path: "engine", message: reason }` 的 check_result 写入 store。
**替代方案**
- 单独的错误日志表 → 增加 schema 复杂度,前端需要额外查询
- 仅保留 console.warn → 现状,不可观测
**理由**:复用现有 check_results 表和 failure 结构,前端无需改动即可展示异常状态。`phase: "internal"` 区分于正常的 checker 执行失败。通过 `Promise.allSettled` 的索引关联回 target 数组,确保能获取 targetName。
### Decision 3: 抽取 bootstrap.ts
**选择**:新增 `src/server/bootstrap.ts`,导出 `bootstrap(options: BootstrapOptions)` 函数封装完整启动序列loadConfig → ProbeStore → syncTargets → ProbeEngine → startServer → 注册 shutdown handler。
**接口设计**
```typescript
interface BootstrapOptions {
configPath: string;
mode: RuntimeMode;
staticAssets?: StaticAssets;
}
```
`dev.ts` 调用 `bootstrap({ configPath, mode: "development" })`
`build.ts` 生成的 entry 调用 `bootstrap({ configPath, mode: "production", staticAssets })`
**替代方案**
- 保持两处重复 → 维护负担随启动逻辑复杂化线性增长
### Decision 4: dataDir 基于 configDir 解析
**选择**:在 `config-loader.ts``loadConfig` 中,对 `dataDir` 使用 `resolve(configDir, dataDir)` 处理。如果 `dataDir` 是绝对路径,`resolve` 会直接返回绝对路径,不影响绝对路径用户。
**影响**:行为变更——之前相对路径基于 cwd现在基于配置文件目录。由于项目未上线无需向前兼容。
### Decision 5: pageSize 上限 200
**选择**:在 `middleware.ts``validatePagination` 中增加 `pageSize > 200` 的校验,返回 400。
**常量定义**`MAX_PAGE_SIZE = 200`,定义在 `middleware.ts` 中。
**理由**200 条/页对于拨测历史记录的展示场景足够。前端当前使用 20不受影响。
### Decision 6: CheckerDefinition 泛型化
**选择**
```typescript
interface CheckerDefinition<TResolved extends ResolvedTargetBase = ResolvedTargetBase> {
execute(target: TResolved, ctx: CheckerContext): Promise<CheckResult>;
resolve(target: RawTargetConfig, context: ResolveContext): TResolved;
serialize(target: TResolved): { config: string; target: string };
validate(input: CheckerValidationInput): ConfigValidationIssue[];
readonly configKey: string;
readonly schemas: CheckerSchemas;
readonly type: string;
}
```
- 默认泛型参数 `= ResolvedTargetBase` 保证 registry 等中间层无需指定泛型
- `CheckerRegistry` 内部存储 `CheckerDefinition<ResolvedTargetBase>`(类型擦除)
- 各 checker 实现 `implements CheckerDefinition<ResolvedHttpTarget>` 等具体类型
- checker 内部 `execute``serialize` 方法直接接收具体类型,无需 `as` 断言
**替代方案**
- Discriminated union → 每加 checker 改 union违背插件化设计
- 维持现状 → 5+ checker 时 `as` 断言散落各处
**影响范围**
- `runner/types.ts`:接口加泛型参数
- `runner/registry.ts`:内部 Map 类型为 `CheckerDefinition`(使用默认参数)
- `http/execute.ts``command/execute.ts``implements CheckerDefinition<具体类型>`,移除方法内的 `as` 断言
- `engine.ts``config-loader.ts``store.ts`:不变(依赖 base interface
## Risks / Trade-offs
- **window function 兼容性** → Bun 内置 SQLite >= 3.25,已验证支持。如果未来需要外部 SQLite需确认版本。
- **Engine rejected 写入依赖索引关联** → 通过 `Promise.allSettled` 的索引关联回 target 数组获取 targetName。前提是 `probeGroup` 的 targets 数组与 `Promise.allSettled` 结果数组保持一一对应,当前实现满足此条件。
- **bootstrap.ts 增加一层间接** → 启动流程从 2 处直接代码变为 1 处函数调用。复杂度不增加,只是位置移动。
- **泛型擦除在 registry 层** → `registry.get()` 返回 `CheckerDefinition`base 类型engine 调用时仍是 base 类型。这是设计意图:中间层不感知具体 checker 类型。

View File

@@ -0,0 +1,34 @@
## Why
后端在 target 规模增长(预计到 100和 checker 类型扩展(预计超过 5 种)的趋势下,存在查询性能瓶颈、可观测性盲区、启动逻辑重复、路径解析 bug 和类型安全不足等问题。本次变更集中修复这些架构短板,为后续扩展打好基础。
## What Changes
- **targets 路由 N+1 查询优化**`handleTargets` 中对每个 target 单独调用 `getRecentSamples` 改为批量查询,消除 N 次独立 SQL
- **Engine rejected 结果持久化**`probeGroup``Promise.allSettled` 的 rejected 结果写入 `matched: false` 的 check_resultfailure 标记为 internal error替代仅 `console.warn`
- **启动逻辑统一**:抽取 `bootstrap.ts``dev.ts` 和 build 生成的 entry 共用同一启动序列,消除重复
- **dataDir 相对路径修复**`config-loader.ts` 中用 `resolve(configDir, dataDir)` 处理相对路径,确保从任意 cwd 启动时数据库位置一致
- **validatePagination 加 pageSize 上限**:限制最大 pageSize 为 200超出返回 400
- **CheckerDefinition 泛型化**:为 `CheckerDefinition` 加泛型参数 `<TResolved extends ResolvedTargetBase>`checker 内部获得完整类型安全registry 用类型擦除保持解耦
- **availability 精度统一**`getAllTargetStats``getTargetStats` 的 availability 计算精度不一致,统一为相同的四舍五入策略
## Capabilities
### New Capabilities
- `server-bootstrap`: 统一的服务启动引导流程dev 和 production 共用
### Modified Capabilities
- `batch-data-queries`: 新增 `getAllRecentSamples` 批量采样查询,消除 targets 路由的 N+1 问题;修复 availability 精度不一致
- `probe-engine`: Engine 对 rejected 结果写入 matched:false 记录而非静默丢弃
- `probe-config`: dataDir 相对路径基于 configDir 解析
- `probe-api`: validatePagination 增加 pageSize 上限校验
- `checker-runner-abstraction`: CheckerDefinition 接口泛型化checker 内部类型安全
## Impact
- **代码**`src/server/` 下约 8 个文件变更,新增 `bootstrap.ts``store.ts` 的批量查询方法;另修复 `src/web/components/ErrorBoundary.tsx``override` 标记typecheck 前置修复)
- **API**pageSize 超过 200 时返回 400新增约束当前前端未使用超大 pageSize
- **构建**`scripts/build.ts` 生成的 entry 改为调用 bootstrap
- **测试**:需新增/更新 engine、store、middleware、bootstrap 相关测试

View File

@@ -0,0 +1,44 @@
## ADDED Requirements
### Requirement: 批量查询所有目标的最近采样数据
系统 SHALL 提供 `getAllRecentSamples(limit: number)` 方法,通过单次 SQL 查询获取所有 target 的最近 N 条采样数据,返回 `Map<number, Array<{ timestamp: string; duration_ms: number | null; matched: number }>>` 结构。
#### Scenario: 获取所有目标的最近采样
- **WHEN** 调用 `getAllRecentSamples(30)`
- **THEN** 系统 SHALL 通过单次 SQL 查询获取每个 target 最近 30 条记录,返回按 target_id 索引的 Map
#### Scenario: 目标无历史记录
- **WHEN** 某 target 在 check_results 表中无任何记录
- **THEN** 该 target_id 在返回的 Map 中 SHALL 不存在对应的 key
#### Scenario: 采样数据排序
- **WHEN** 获取采样数据
- **THEN** 每个 target 的记录 SHALL 按 timestamp 降序排列(最新在前)
## MODIFIED Requirements
### Requirement: targets 列表使用批量方法
`handleTargets`routes/targets.ts 中生成 TargetStatus[] 的逻辑SHALL 使用 `getLatestChecksMap``getAllTargetStats``getAllRecentSamples` 替代逐目标查询,消除 N+1 查询。
#### Scenario: 目标列表使用批量查询
- **WHEN** 处理 `GET /api/targets` 请求
- **THEN** 系统 SHALL 分别调用 `getLatestChecksMap()``getAllTargetStats()``getAllRecentSamples(30)` 批量获取数据,在内存中组装 TargetStatus 数组,而非对每个 target 逐条查询数据库
#### Scenario: 目标无采样数据
- **WHEN** 某 target 在 getAllRecentSamples 返回的 Map 中不存在
- **THEN** 该 target 的 recentSamples SHALL 为空数组
### Requirement: 批量查询目标统计
系统 SHALL 提供 `getAllTargetStats` 方法,通过单次 SQL GROUP BY 聚合查询获取所有 target 的拨测统计totalChecks 和 availability。availability 计算精度 SHALL 与 `getTargetStats` 一致,统一使用 `Math.round(value * 100) / 100` 保留两位小数。
#### Scenario: 获取所有目标的聚合统计
- **WHEN** 调用 `getAllTargetStats()`
- **THEN** 系统 SHALL 执行单次 GROUP BY 聚合查询,在内存中计算 availability 并返回 `Map<number, { totalChecks, availability }>`
#### Scenario: availability 精度
- **WHEN** 计算 availabilityupCount / totalChecks * 100
- **THEN** 结果 SHALL 使用 `Math.round(value * 100) / 100` 四舍五入保留两位小数,与 `getTargetStats` 方法一致
#### Scenario: 目标无历史记录
- **WHEN** 某 target 在 check_results 表中无任何记录
- **THEN** 该 target_id 在返回的 Map 中 SHALL 不存在对应的 key

View File

@@ -0,0 +1,47 @@
## MODIFIED Requirements
### Requirement: Checker 接口定义
系统 SHALL 在 `src/server/checker/runner/types.ts` 中定义面向扩展的泛型 `CheckerDefinition<TResolved extends ResolvedTargetBase = ResolvedTargetBase>`,包含 `type``configKey`、TypeBox 配置契约、启动期语义校验、`resolve``execute``serialize` 成员。泛型参数 SHALL 约束 `execute``serialize` 方法的 target 参数类型,使 checker 实现内部获得编译期类型安全。默认泛型参数 `= ResolvedTargetBase` 保证中间层registry、engine、config-loader无需指定泛型。
#### Scenario: Checker 接口包含必要方法
- **WHEN** 开发者实现一个新的 Checker
- **THEN** 该实现 MUST 提供 `type`(字符串标识)、`configKey`配置分组名、TypeBox 配置契约、启动期语义校验、`resolve(target, context): TResolved`(解析配置并填充默认值)、`execute(target: TResolved, ctx)`(执行探测返回 CheckResult`serialize(target: TResolved)`(返回 target 展示文本和 config JSON
#### Scenario: CheckerContext 注入 signal
- **WHEN** 引擎调用 `checker.execute(target, ctx)`
- **THEN** `ctx.signal` SHALL 是一个由引擎创建的 `AbortSignal`,在超时或引擎关闭时 abort
#### Scenario: resolve 不承担通用契约校验
- **WHEN** config-loader 调用 checker.resolve()
- **THEN** checker.resolve() SHALL 假定配置已经通过 TypeBox/Ajv 契约校验和启动期语义校验,只负责默认值填充、路径解析和领域配置转换
#### Scenario: 接口方法使用泛型约束
- **WHEN** 开发者查看 `CheckerDefinition<TResolved>` 接口签名
- **THEN** `resolve` 的返回值 SHALL 为 `TResolved``execute` 的参数 SHALL 为 `TResolved``serialize` 的参数 SHALL 为 `TResolved`
#### Scenario: checker 实现无需手动断言
- **WHEN** HttpChecker 实现 `CheckerDefinition<ResolvedHttpTarget>`
- **THEN** `execute` 方法的 target 参数类型 SHALL 直接为 `ResolvedHttpTarget`,无需在方法内部使用 `as` 类型断言
#### Scenario: registry 使用默认泛型参数
- **WHEN** CheckerRegistry 存储和返回 checker 实例
- **THEN** registry 内部 SHALL 使用 `CheckerDefinition`(等价于 `CheckerDefinition<ResolvedTargetBase>`),实现类型擦除
### Requirement: CheckerRegistry 注册中心
系统 SHALL 在 `src/server/checker/runner/registry.ts` 中提供 `CheckerRegistry` 类,支持 `register(checker)``get(type)``supportedTypes`。重复注册同一 type SHALL 抛出错误。registry 内部 SHALL 存储 `CheckerDefinition`(使用默认泛型参数),对外提供类型擦除后的接口。
#### Scenario: 注册并获取 Checker
- **WHEN** 调用 `registry.register(new HttpChecker())` 后再调用 `registry.get("http")`
- **THEN** 返回的 SHALL 是之前注册的 HttpChecker 实例(类型为 `CheckerDefinition`
#### Scenario: 获取未注册的 type
- **WHEN** 调用 `registry.get("unknown")` 且未注册对应 type 的 checker
- **THEN** 系统 SHALL 抛出错误,提示不支持的 probe type
#### Scenario: 重复注册
- **WHEN** 同一 type 值被重复 `register()`
- **THEN** 系统 SHALL 抛出错误,提示该 type 已注册
#### Scenario: 查询支持的 type 列表
- **WHEN** 注册了 "http" 和 "command" 两个 checker 后查询 `registry.supportedTypes`
- **THEN** 返回的数组 SHALL 包含 `["http", "command"]`(按注册顺序)

View File

@@ -0,0 +1,32 @@
## MODIFIED Requirements
### Requirement: API 错误处理
系统 SHALL 对不存在的目标 ID、无效参数和超出范围的分页参数返回适当的 HTTP 错误响应。
#### Scenario: 查询不存在的目标
- **WHEN** 客户端请求 `GET /api/targets/999/history`
- **THEN** 系统 SHALL 返回 404 状态码和错误信息
#### Scenario: 无效的 from/to 参数
- **WHEN** 客户端请求 `GET /api/targets/1/history?from=invalid`
- **THEN** 系统 SHALL 返回 400 状态码和错误信息
#### Scenario: 无效的分页参数
- **WHEN** 客户端请求 `GET /api/targets/1/history?from=ISO&to=ISO&page=abc`
- **THEN** 系统 SHALL 返回 400 状态码和错误信息
#### Scenario: pageSize 超过上限
- **WHEN** 客户端请求 `GET /api/targets/1/history?from=ISO&to=ISO&pageSize=201`
- **THEN** 系统 SHALL 返回 400 状态码和错误信息,提示 pageSize 不能超过 200
#### Scenario: pageSize 等于上限
- **WHEN** 客户端请求 `GET /api/targets/1/history?from=ISO&to=ISO&pageSize=200`
- **THEN** 系统 SHALL 正常返回数据
#### Scenario: from 或 to 参数缺失
- **WHEN** 客户端请求 `GET /api/targets/1/trend` 未提供 from 或 to 参数
- **THEN** 系统 SHALL 返回 400 状态码和错误信息
#### Scenario: 无效的目标 ID
- **WHEN** 客户端请求 `GET /api/targets/abc/history`
- **THEN** 系统 SHALL 返回 400 状态码和错误信息

View File

@@ -0,0 +1,16 @@
## MODIFIED Requirements
### Requirement: 数据目录路径解析
配置加载流程 SHALL 将 `server.dataDir` 相对路径基于配置文件所在目录configDir解析为绝对路径。绝对路径 SHALL 保持不变。
#### Scenario: dataDir 为相对路径
- **WHEN** 配置文件位于 `/opt/dial/probes.yaml`,且 `server.dataDir` 配置为 `./data`
- **THEN** 系统 SHALL 将 dataDir 解析为 `/opt/dial/data`,而非依赖进程 cwd
#### Scenario: dataDir 为绝对路径
- **WHEN** `server.dataDir` 配置为 `/var/lib/dial/data`
- **THEN** 系统 SHALL 直接使用该绝对路径,不做额外解析
#### Scenario: dataDir 使用默认值
- **WHEN** 未配置 `server.dataDir`(使用默认值 `./data`
- **THEN** 系统 SHALL 将默认值 `./data` 基于 configDir 解析为绝对路径

View File

@@ -0,0 +1,24 @@
## MODIFIED Requirements
### Requirement: 组内并发拨测
系统 SHALL 在每次调度 tick 时并发执行同组内目标的检查,但实际同时运行的检查数 MUST 受全局 `runtime.maxConcurrentChecks` 限制。当某个目标的 checker 执行 rejected非正常 CheckResult 返回,而是 Promise reject系统 SHALL 将该异常记录为 `matched: false` 的 check_result而非仅 console.warn。
#### Scenario: 同组目标并发执行
- **WHEN** 调度器触发一次 tick该组有 3 个目标,且全局并发余量至少为 3
- **THEN** 系统 SHALL 同时执行 3 个 checker而非顺序执行
#### Scenario: 单个目标失败不影响同组其他目标
- **WHEN** 同组中某个目标的检查请求超时或失败checker 正常返回 CheckResult
- **THEN** 其他目标的检查 SHALL 正常完成并记录结果
#### Scenario: 同组中某个目标的 checker 执行 rejected
- **WHEN** 同组中某个目标的 checker 执行抛出未捕获异常Promise rejected
- **THEN** 系统 SHALL 为该目标写入一条 `matched: false` 的 check_resultfailure 为 `{ kind: "error", phase: "internal", path: "engine", message: <rejected reason> }`,其他目标的检查 SHALL 不受影响
#### Scenario: rejected 结果通过索引关联 targetName
- **WHEN** checker 执行 rejected
- **THEN** 系统 SHALL 通过 Promise.allSettled 的索引关联回 target 数组,获取对应的 targetName 用于写入 check_result
#### Scenario: 全局并发限制生效
- **WHEN** 调度器同时触发 10 个目标且 runtime.maxConcurrentChecks 为 3
- **THEN** 系统 MUST 同时最多运行 3 个检查,其余检查等待并发槽位释放

View File

@@ -0,0 +1,38 @@
## ADDED Requirements
### Requirement: 统一启动引导函数
系统 SHALL 提供 `src/server/bootstrap.ts` 导出 `bootstrap(options: BootstrapOptions)` 函数,封装完整的服务启动序列:加载配置、创建 store、同步 targets、创建并启动 engine、启动 HTTP server、注册 shutdown handler。
#### Scenario: 开发模式启动
- **WHEN** `dev.ts` 调用 `bootstrap({ configPath, mode: "development" })`
- **THEN** 系统 SHALL 完成完整启动序列,不传入 staticAssets
#### Scenario: 生产模式启动
- **WHEN** build entry 调用 `bootstrap({ configPath, mode: "production", staticAssets })`
- **THEN** 系统 SHALL 完成完整启动序列,并将 staticAssets 传递给 startServer
#### Scenario: 启动失败处理
- **WHEN** 启动过程中任何步骤抛出异常
- **THEN** 系统 SHALL 输出错误信息并以非零退出码退出进程
#### Scenario: 优雅关机
- **WHEN** 进程收到 SIGINT 或 SIGTERM 信号
- **THEN** bootstrap 注册的 shutdown handler SHALL 调用 engine.stop() 和 store.close() 后退出
### Requirement: BootstrapOptions 接口
`bootstrap` 函数 SHALL 接受 `BootstrapOptions` 参数,包含 `configPath: string``mode: RuntimeMode``staticAssets?: StaticAssets`
#### Scenario: 最小配置
- **WHEN** 仅传入 configPath 和 mode
- **THEN** 系统 SHALL 正常启动staticAssets 为 undefined
### Requirement: dev.ts 和 build entry 使用 bootstrap
`dev.ts``scripts/build.ts` 生成的 server entry SHALL 调用 `bootstrap()` 而非各自维护启动序列。
#### Scenario: dev.ts 调用 bootstrap
- **WHEN** 开发者运行 `bun run dev:server`
- **THEN** `dev.ts` SHALL 调用 `bootstrap` 完成启动
#### Scenario: build entry 调用 bootstrap
- **WHEN** 生产可执行文件启动
- **THEN** 生成的 entry SHALL 调用 `bootstrap` 完成启动

View File

@@ -0,0 +1,42 @@
## 1. CheckerDefinition 泛型化
- [x] 1.1 修改 `src/server/checker/runner/types.ts`:为 CheckerDefinition 接口添加泛型参数 `<TResolved extends ResolvedTargetBase = ResolvedTargetBase>`,约束 execute、resolve、serialize 方法的 target 参数类型
- [x] 1.2 修改 `src/server/checker/runner/registry.ts`:内部 Map 类型使用 `CheckerDefinition`(默认泛型参数),确保类型擦除
- [x] 1.3 修改 `src/server/checker/runner/http/execute.ts`HttpChecker 实现 `CheckerDefinition<ResolvedHttpTarget>`,移除 execute/serialize 方法内的 `as ResolvedHttpTarget` 断言resolve 方法内对 RawTargetConfig 的断言保留,泛型不覆盖输入参数窄化)
- [x] 1.4 修改 `src/server/checker/runner/command/execute.ts`CommandChecker 实现 `CheckerDefinition<ResolvedCommandTarget>`,移除 execute/serialize 方法内的 `as ResolvedCommandTarget` 断言resolve 方法内对 RawTargetConfig 的断言保留)
- [x] 1.5 修复 `src/web/components/ErrorBoundary.tsx``override` 标记(`noImplicitOverride` 规则要求的既有代码修复),运行 `bun run typecheck` 确认类型系统无错误
## 2. ProbeStore 批量查询优化
- [x] 2.1 在 `src/server/checker/store.ts` 中新增 `getAllRecentSamples(limit: number)` 方法,使用 `ROW_NUMBER() OVER (PARTITION BY target_id ORDER BY timestamp DESC)` 实现单次批量查询
- [x] 2.2 修改 `src/server/checker/store.ts``getAllTargetStats` 的 availability 计算:将 `Math.round((row.upCount / row.totalChecks) * 10000) / 100` 改为 `Math.round((row.upCount / row.totalChecks) * 100 * 100) / 100`,与 `getTargetStats` 精度一致
- [x] 2.3 修改 `src/server/routes/targets.ts``handleTargets` 使用 `store.getAllRecentSamples(30)` 替代循环调用 `store.getRecentSamples`
- [x] 2.4 在 `tests/server/checker/store.test.ts` 中新增 `getAllRecentSamples` 的单元测试和 availability 精度一致性测试
## 3. Engine rejected 结果持久化
- [x] 3.1 修改 `src/server/checker/engine.ts``probeGroup` 中对 rejected 结果通过索引关联 target构造 `matched: false``failure: { kind: "error", phase: "internal", path: "engine", message }` 的 check_result 写入 store
- [x] 3.2 在 `tests/server/checker/engine.test.ts` 中新增 rejected 结果写入的测试用例
## 4. 启动逻辑统一
- [x] 4.1 新增 `src/server/bootstrap.ts`,导出 `bootstrap(options: BootstrapOptions)` 函数,封装 loadConfig → ProbeStore → syncTargets → ProbeEngine → startServer → shutdown handler 完整序列
- [x] 4.2 修改 `src/server/dev.ts`:改为调用 `bootstrap({ configPath, mode: "development" })`
- [x] 4.3 修改 `scripts/build.ts`:生成的 server entry 改为调用 `bootstrap({ configPath, mode: "production", staticAssets })`
- [x] 4.4 在 `tests/server/` 中新增 bootstrap 相关测试
## 5. dataDir 路径修复
- [x] 5.1 修改 `src/server/checker/config-loader.ts`:对 dataDir 使用 `resolve(configDir, dataDir)` 处理相对路径
- [x] 5.2 在 `tests/server/checker/config-loader.test.ts` 中新增 dataDir 路径解析的测试用例
## 6. pageSize 上限
- [x] 6.1 修改 `src/server/middleware.ts``validatePagination` 增加 `pageSize > 200` 的校验,返回 400
- [x] 6.2 在 `tests/server/app.test.ts` 中新增 pageSize 超限的测试用例
## 7. 质量保障与文档
- [x] 7.1 运行 `bun run check`schema:check + typecheck + lint + test确认全部通过
- [x] 7.2 运行 `bun run build` 确认构建成功
- [x] 7.3 更新 DEVELOPMENT.md 中相关章节bootstrap 启动流程、CheckerDefinition 泛型说明、pageSize 上限说明)

View File

@@ -0,0 +1,2 @@
schema: spec-driven
created: 2026-05-13

View File

@@ -0,0 +1,145 @@
## Context
当前前端代码约 970 行,功能完整但存在以下架构问题:
1. **hook 职责过重**`hooks/useTargetDetail.ts`113 行)同时承载全局查询(`useSummary``useTargets`、Drawer 状态管理、条件查询和通用 `fetchJson` 封装,文件名与实际职责不匹配
2. **组件体积膨胀**`TargetDetailDrawer.tsx`228 行)混合了时间选择逻辑、两个 Tab 的完整渲染、列定义和统计计算
3. **类型维护重复**`target-type-display.ts``target-table-filters.ts` 各自硬编码 checker 类型列表,新增 checker 需改两处前端文件
4. **测试覆盖不足**:仅 `constants/` 下 4 个纯函数有测试,`utils/time.ts` 和组件内统计逻辑未覆盖
5. **小问题**`StatusDonut` 用数组索引做 key、`StatusBar` 硬编码 30 格、`TrendChart` 冗余 loading prop
后端 `CheckerRegistry` 已有 `supportedTypes` 属性,可直接暴露给前端。项目未上线,不需要向前兼容。
## Goals / Non-Goals
**Goals:**
- hook 按职责拆分,文件名匹配实际内容
- `TargetDetailDrawer` 拆分为 3 个组件,每个 < 100 行
- 类型筛选器由后端 meta API 驱动,新增 checker 前端零改动
- 删除 type label 转换层,直接使用 type 原始文本
- 补齐前端纯函数测试
- 修复已知小问题
**Non-Goals:**
- 不引入路由库或状态管理库
- 不重构后端 API 结构
- 不改变现有轮询策略和 QueryClient 配置
- 不新增 CSS 文件(继续使用单一 `styles.css`
## Decisions
### Decision 1: hook 拆分策略
**选择**:按查询层级拆分为 `use-queries.ts``use-target-detail.ts`
- `use-queries.ts``queryKeys``fetchJson`(不导出)、`useSummary``useTargets``useMeta`
- `use-target-detail.ts`:仅保留 Drawer 状态管理(`selectedTargetId`、时间范围、分页、`openDrawer`/`closeDrawer`和条件查询trend/history
**理由**全局查询summary/targets/meta是面板级别的与 Drawer 详情无关。拆分后各文件职责单一,命名自解释。`fetchJson` 仅被 query 层使用,留在 `use-queries.ts` 内部不导出。
**备选方案**
- 仅重命名为 `useQueries.ts` — 解决命名问题但不解决职责混合
- 每个 query 一个文件 — 过度拆分,增加文件数量但无实质收益
### Decision 2: TargetDetailDrawer 拆分方式
**选择**:拆为 3 个组件 + 1 个常量文件
```
TargetDetailDrawer.tsx ← Drawer 壳 + 时间选择 + Tab 切换
OverviewTab.tsx ← 统计 + TrendChart + StatusDonut + Descriptions
HistoryTab.tsx ← PrimaryTable + 分页
constants/history-table-columns.tsx ← HISTORY_COLUMNS
```
**理由**:两个 Tab 的内容完全独立,拆分后各组件 < 100 行。`HISTORY_COLUMNS``TARGET_TABLE_COLUMNS` 性质相同,应放在 `constants/` 下保持一致。
统计计算逻辑(`totalChecks`/`upChecks`/`downChecks`)提取为 `utils/stats.ts` 纯函数,便于测试和 `useMemo`
### Decision 3: Meta API 设计
**选择**`GET /api/meta` 返回 `{ checkerTypes: string[] }`
```typescript
// src/shared/api.ts
export interface MetaResponse {
checkerTypes: string[];
}
```
**理由**
-`checkerRegistry.supportedTypes` 直接获取,无需额外维护
- 返回 `string[]` 而非 `{ key, label }[]`,因为决策是不做 label 转换
- 端点命名为 `/api/meta` 而非 `/api/types`,为未来扩展预留空间(如版本号、功能开关等)
- `staleTime: Infinity`,应用生命周期内只请求一次
**备选方案**
- 从 targets 响应中动态提取 — 只能获取"当前有数据的类型",不能获取"系统支持的全部类型"
- 在 health 端点中附带 — 语义不匹配health 应保持最小化
### Decision 4: 类型展示策略
**选择**:删除 `target-type-display.ts`,所有展示位置直接使用 `target.type` 原始文本
**影响位置**
- `target-table-columns.tsx` 类型列 cell`row.type` 直接渲染
- `TargetDetailDrawer.tsx` 标题栏 Tag`target.type` 直接渲染
- `typeFilter` 列表:从 meta API 获取label 和 value 均为原始 type 文本
**理由**type 文本本身已足够清晰(`http``command``tcp`),无需额外映射层。消除了前后端重复维护的问题。
### Decision 5: 列定义动态化
**选择**`TARGET_TABLE_COLUMNS` 从静态常量改为工厂函数
```typescript
export function createTargetTableColumns(checkerTypes: string[]): PrimaryTableCol<TargetStatus>[] {
const typeFilter = {
list: [
{ label: "全部", value: "" },
...checkerTypes.map(t => ({ label: t, value: t })),
],
type: "single" as const,
};
// ... 返回列定义数组
}
```
**数据流**`useMeta()``checkerTypes``createTargetTableColumns(checkerTypes)``TargetGroup` columns prop
**理由**`typeFilter` 是唯一需要动态数据的部分,通过工厂函数注入参数,保持列定义的纯函数特性。`statusFilter` 保持静态UP/DOWN 是固定的)。
`TargetGroup` 新增 `columns` prop 接收动态列定义,`TargetBoard` 负责调用工厂函数并传递。
### Decision 6: StatusBar maxSlots 参数化
**选择**:新增 `maxSlots` prop默认 30组件根据 prop 渲染格数
```typescript
interface StatusBarProps {
samples: Array<{ up: boolean }>;
maxSlots?: number;
}
```
**理由**:消除硬编码魔数,使组件可复用。默认值 30 保持向后兼容。
### Decision 7: TrendChart 移除 loading prop
**选择**:移除 `loading` prop组件只接收 `data: TrendPoint[]`
**理由**:调用方(`OverviewTab`)已用 `Skeleton` 处理 loading 状态,`TrendChart` 只在有数据时渲染。组件内部的 `if (loading)` 分支和 `loading={false}` 传参都是死代码。
## Risks / Trade-offs
| Risk | Mitigation |
|------|-----------|
| 列定义改为函数后,每次 `checkerTypes` 变化会重新创建列数组 | `useMemo` 包裹 `createTargetTableColumns` 调用meta 数据 `staleTime: Infinity` 确保不会频繁变化 |
| meta API 在 targets 之前未返回时,筛选器暂时为空 | meta 请求极轻量(无 DB 查询),通常先于 targets 返回;即使晚到,筛选器会在数据到达后自动出现 |
| 拆分后组件间 props 传递增多 | 层级仅增加一层Drawer → Tabprops 类型明确,不会造成 prop drilling 问题 |
## Open Questions
无。方案已在 explore 阶段与用户确认。

View File

@@ -0,0 +1,34 @@
## Why
前端代码经过多轮功能迭代后,出现了 hook 职责过重(`useTargetDetail.ts` 承载全部数据层)、组件体积膨胀(`TargetDetailDrawer` 228 行混合多种逻辑)、类型筛选器与后端硬编码重复维护等架构问题。需要通过拆分、动态化和测试补齐来提升可维护性,使新增 checker 类型时前端零改动。
## What Changes
- 拆分 `hooks/useTargetDetail.ts``use-queries.ts`(全局查询)和 `use-target-detail.ts`Drawer 状态管理)
- 拆分 `TargetDetailDrawer.tsx` 为 Drawer 壳、`OverviewTab.tsx``HistoryTab.tsx` 三个组件
-`HISTORY_COLUMNS` 移至 `constants/history-table-columns.tsx`
- 提取统计计算逻辑为 `utils/stats.ts` 纯函数
- 后端新增 `GET /api/meta` 端点,返回 `checkerTypes` 列表
- 前端新增 `useMeta()` hook 消费 meta API动态生成类型筛选器
- **BREAKING** 删除 `constants/target-type-display.ts`,前端直接使用 type 原始文本,不再做 label 转换
- 列定义从静态常量改为工厂函数 `createTargetTableColumns(checkerTypes)`
- 修复小问题:`StatusDonut` key、`StatusBar` 硬编码、`TrendChart` 冗余 prop、统计计算 useMemo
- 补充前端测试:`utils/time.ts``utils/stats.ts`、动态列生成
## Capabilities
### New Capabilities
- `meta-api`: 后端 meta 信息 API提供 checker 类型列表等运行时元数据
### Modified Capabilities
- `target-type-display`: 移除前端静态映射,改为直接使用后端返回的 type 原始文本,筛选器列表由 meta API 动态驱动
- `tanstack-query-data-layer`: hook 文件拆分为 `use-queries.ts``use-target-detail.ts`,新增 `useMeta()` 查询
- `target-detail-drawer`: 组件拆分为 Drawer 壳 + OverviewTab + HistoryTab统计计算提取为纯函数
- `target-table`: 列定义从静态常量改为工厂函数,接收动态 checkerTypes 参数;类型列直接显示 type 原始文本
## Impact
- 后端:新增 `src/server/routes/meta.ts``src/shared/api.ts` 增加 `MetaResponse` 类型、`app.ts` 注册路由
- 前端hooks/components/constants 目录结构调整,删除 `target-type-display.ts`
- 测试:删除 `target-type-display.test.ts`,新增 `time.test.ts``stats.test.ts`,更新 `target-table-filters.test.ts`
- 文档:更新 DEVELOPMENT.md 中前端目录结构和组件清单

View File

@@ -0,0 +1,27 @@
## ADDED Requirements
### Requirement: Meta 信息 API
系统 SHALL 提供 `GET /api/meta` 端点,返回系统运行时元数据。
#### Scenario: 获取 checker 类型列表
- **WHEN** 客户端请求 `GET /api/meta`
- **THEN** 系统 SHALL 返回 JSON `{ checkerTypes: string[] }`,包含所有已注册的 checker 类型标识符(如 `["http", "command"]`
#### Scenario: 类型列表来源
- **WHEN** 系统启动并注册了 checker
- **THEN** `/api/meta` 返回的 `checkerTypes` SHALL 与 `CheckerRegistry.supportedTypes` 完全一致
#### Scenario: 仅允许 GET/HEAD 方法
- **WHEN** 客户端使用 POST/PUT/DELETE 等方法请求 `/api/meta`
- **THEN** 系统 SHALL 返回 405 状态码
#### Scenario: HEAD 请求返回空体
- **WHEN** 客户端使用 HEAD 方法请求 `/api/meta`
- **THEN** 系统 SHALL 返回 200 状态码和正确的 Content-Type headerbody 为空
### Requirement: MetaResponse 共享类型
系统 SHALL 在 `src/shared/api.ts` 中定义 `MetaResponse` 类型。
#### Scenario: MetaResponse 类型定义
- **WHEN** 前后端引用 `MetaResponse` 类型
- **THEN** 该类型 SHALL 包含 `checkerTypes: string[]` 字段

View File

@@ -0,0 +1,109 @@
## MODIFIED Requirements
### Requirement: TanStack Query 数据层
前端 SHALL 使用 TanStack Query@tanstack/react-query管理所有 API 请求,数据层代码 SHALL 按职责拆分为独立 hook 文件。
#### Scenario: QueryClient 配置
- **WHEN** 应用启动
- **THEN** 系统 SHALL 创建 QueryClient默认配置 retry=1、refetchOnWindowFocus=true、staleTime=5000
#### Scenario: QueryClientProvider 挂载
- **WHEN** 应用渲染
- **THEN** 根组件 SHALL 包裹在 QueryClientProvider 中,提供 QueryClient 实例
### Requirement: queryKey 工厂
系统 SHALL 提供统一的 queryKey 工厂函数,确保 queryKey 的唯一性和一致性。
#### Scenario: summary queryKey
- **WHEN** 查询 summary 数据
- **THEN** queryKey SHALL 为 ["summary"]
#### Scenario: targets queryKey
- **WHEN** 查询 targets 数据
- **THEN** queryKey SHALL 为 ["targets"]
#### Scenario: meta queryKey
- **WHEN** 查询 meta 数据
- **THEN** queryKey SHALL 为 ["meta"]
#### Scenario: trend queryKey
- **WHEN** 查询某目标的趋势数据
- **THEN** queryKey SHALL 为 ["trend", targetId, from, to]
#### Scenario: history queryKey
- **WHEN** 查询某目标的历史记录
- **THEN** queryKey SHALL 为 ["history", targetId, from, to, page]
### Requirement: Hook 文件拆分
数据层 hook SHALL 按职责拆分为独立文件。
#### Scenario: 全局查询 hook 文件
- **WHEN** 开发者需要使用全局面板级查询
- **THEN** `useSummary``useTargets``useMeta` SHALL 从 `hooks/use-queries.ts` 导出
#### Scenario: Drawer 状态 hook 文件
- **WHEN** 开发者需要使用 Drawer 状态管理
- **THEN** `useTargetDetail` SHALL 从 `hooks/use-target-detail.ts` 导出
#### Scenario: fetchJson 不导出
- **WHEN** 数据层内部需要 fetch 封装
- **THEN** `fetchJson` SHALL 定义在 `use-queries.ts` 内部,不作为公共 API 导出
#### Scenario: queryKeys 不导出
- **WHEN** 数据层内部需要 query key
- **THEN** `queryKeys` 对象 SHALL 定义在 `use-queries.ts` 内部,不作为公共 API 导出
### Requirement: Meta 查询
系统 SHALL 提供 `useMeta` hook 查询系统元数据。
#### Scenario: meta 查询配置
- **WHEN** 应用启动
- **THEN** `useMeta` SHALL 请求 `/api/meta`,配置 `staleTime: Infinity`(应用生命周期内只请求一次)
#### Scenario: meta 数据返回
- **WHEN** meta 查询成功
- **THEN** hook SHALL 返回 `MetaResponse` 类型数据,包含 `checkerTypes` 字段
### Requirement: Summary 轮询查询
系统 SHALL 使用 useQuery 实现总览统计的自动轮询。
#### Scenario: summary 自动轮询
- **WHEN** Dashboard 页面处于打开状态
- **THEN** 系统 SHALL 每 8 秒自动请求 /api/summary使用 refetchInterval=8000
#### Scenario: summary 后台刷新
- **WHEN** 页面处于后台标签页
- **THEN** 系统 SHALL 暂停轮询refetchIntervalInBackground=false
### Requirement: Targets 轮询查询
系统 SHALL 使用 useQuery 实现目标列表的自动轮询。
#### Scenario: targets 自动轮询
- **WHEN** Dashboard 页面处于打开状态
- **THEN** 系统 SHALL 每 8 秒自动请求 /api/targets使用 refetchInterval=8000
### Requirement: 条件查询
趋势和历史记录查询 SHALL 使用 enabled 条件控制,仅在目标被选中时触发。
#### Scenario: 未选中目标时不请求
- **WHEN** 用户未点击任何目标表格行
- **THEN** trend 和 history 的 useQuery SHALL enabled=false不发起请求
#### Scenario: 选中目标时自动请求
- **WHEN** 用户点击目标表格行
- **THEN** trend 和 history 的 useQuery SHALL enabled=true自动发起请求
#### Scenario: 时间范围变化时重新请求
- **WHEN** 用户更改时间范围
- **THEN** trend 和 history 的 useQuery SHALL 因 queryKey 变化自动重新请求
### Requirement: 开发调试面板
开发环境下 SHALL 挂载 TanStack Query Devtools。
#### Scenario: 开发环境显示 Devtools
- **WHEN** 应用在开发模式下运行
- **THEN** 页面 SHALL 显示 ReactQueryDevtools 浮动面板
#### Scenario: 生产环境排除 Devtools
- **WHEN** 应用在生产模式下构建
- **THEN** ReactQueryDevtools SHALL 不被包含在产物中

View File

@@ -0,0 +1,91 @@
## MODIFIED Requirements
### Requirement: 目标详情 Drawer
Dashboard SHALL 在用户点击目标表格行后从右侧滑出 Drawer展示该目标的详细统计信息和检查记录。Drawer 标题栏和内容不使用内联 style。Drawer 内容 SHALL 拆分为独立的 Tab 组件。
#### Scenario: 打开 Drawer
- **WHEN** 用户点击某个目标表格行
- **THEN** 系统 SHALL 从右侧滑出 Drawerplacement="right"),宽度为视口 60%
#### Scenario: Drawer 标题栏
- **WHEN** Drawer 渲染
- **THEN** 标题栏 SHALL 使用 TDesign Space 组件align="center")布局,包含 StatusDot、目标名称TDesign Typography.Text strong和类型标签TDesign Tag直接显示 target.type 原始文本),以及内建关闭按钮。不使用内联 style 的 flex 布局
#### Scenario: 关闭 Drawer
- **WHEN** 用户点击关闭按钮、ESC 键或遮罩层
- **THEN** Drawer SHALL 关闭
#### Scenario: Drawer 无底部按钮
- **WHEN** Drawer 渲染
- **THEN** Drawer SHALL 不显示底部操作栏footer={false}
#### Scenario: Drawer 数据同步
- **WHEN** Drawer 打开期间后台轮询刷新了 targets 数据
- **THEN** Drawer 中 selectedTarget 的状态 SHALL 随之同步更新
#### Scenario: 切换目标重置 Tab
- **WHEN** 用户从目标 A 切换到目标 B点击不同的表格行
- **THEN** Drawer SHALL 重置为概览 Tab使用 key={target.id} 确保组件状态不残留
#### Scenario: Drawer 内容区间距
- **WHEN** Drawer 内容渲染
- **THEN** 时间选择器、Tabs 等区块之间的间距 SHALL 通过 TDesign Space 组件direction="vertical", size={16})统一管理,不使用内联 style 的 marginBottom
### Requirement: 概览面板组件化
概览 Tab SHALL 作为独立组件 `OverviewTab` 实现,接收数据 props 进行渲染。
#### Scenario: OverviewTab 组件职责
- **WHEN** 概览 Tab 渲染
- **THEN** `OverviewTab` 组件 SHALL 负责统计卡片、趋势图、状态分布环形图和基本信息的渲染
#### Scenario: 统计计算使用纯函数
- **WHEN** OverviewTab 需要计算 totalChecks、upChecks、downChecks
- **THEN** 计算逻辑 SHALL 通过 `utils/stats.ts` 中的纯函数实现,并使用 `useMemo` 缓存结果
#### Scenario: OverviewTab props
- **WHEN** OverviewTab 渲染
- **THEN** 组件 SHALL 接收 `target: TargetStatus``trendData: TrendPoint[]``trendLoading: boolean` 作为 props
### Requirement: 记录面板组件化
记录 Tab SHALL 作为独立组件 `HistoryTab` 实现。
#### Scenario: HistoryTab 组件职责
- **WHEN** 记录 Tab 渲染
- **THEN** `HistoryTab` 组件 SHALL 负责检查结果表格和分页的渲染
#### Scenario: HistoryTab props
- **WHEN** HistoryTab 渲染
- **THEN** 组件 SHALL 接收 `historyData: HistoryResponse``historyLoading: boolean``onPageChange: (page: number) => void` 作为 props
#### Scenario: 历史记录列定义外置
- **WHEN** HistoryTab 渲染表格
- **THEN** 列定义 SHALL 从 `constants/history-table-columns.tsx` 导入,不在组件内部定义
### Requirement: TrendChart 简化
TrendChart 组件 SHALL 仅接收数据 props不处理 loading 状态。
#### Scenario: TrendChart 无 loading prop
- **WHEN** TrendChart 渲染
- **THEN** 组件 SHALL 仅接收 `data: TrendPoint[]` prop不接收 `loading` prop
#### Scenario: TrendChart 空数据
- **WHEN** TrendChart 接收空数组
- **THEN** 组件 SHALL 显示"暂无趋势数据"占位文本
### Requirement: StatusDonut key 修复
StatusDonut 组件 SHALL 使用语义化的 key。
#### Scenario: Pie Cell key
- **WHEN** StatusDonut 渲染 Pie Cell 列表
- **THEN** 每个 Cell 的 key SHALL 使用 data item 的 `name` 字段,不使用数组索引
### Requirement: StatusBar 参数化
StatusBar 组件 SHALL 支持可配置的格数。
#### Scenario: maxSlots prop
- **WHEN** StatusBar 渲染
- **THEN** 组件 SHALL 接收可选的 `maxSlots` prop默认 30根据该值渲染对应数量的格子
#### Scenario: 格子渲染逻辑
- **WHEN** StatusBar 渲染且 samples 数量少于 maxSlots
- **THEN** 多余的格子 SHALL 显示为 empty 状态

View File

@@ -0,0 +1,69 @@
## MODIFIED Requirements
### Requirement: 表格列定义
每个分组的 PrimaryTable SHALL 包含状态、名称、类型、可用率、最近状态条、延迟、间隔 7 列,不含分组列(同组内冗余)。列渲染不使用内联 style。列定义 SHALL 通过工厂函数动态生成。
#### Scenario: 状态列
- **WHEN** 表格渲染
- **THEN** 状态列 SHALL 使用 StatusDot 组件渲染,标题显示"#",宽度 60pxfixed="left"居中对齐支持筛选UP/DOWN/全部。StatusDot SHALL 通过 CSS 类(`.status-dot--up` / `.status-dot--down`)控制颜色,不使用内联 style
#### Scenario: 名称列
- **WHEN** 表格渲染
- **THEN** 名称列 SHALL 显示目标名称支持字母排序zh-CNellipsis 超长名称自动省略并 Tooltip 显示全名
#### Scenario: 类型列
- **WHEN** 表格渲染
- **THEN** 类型列 SHALL 使用 TDesign Tag 组件size=small, theme=primary, variant=light-outline直接显示 target.type 原始文本,支持单选筛选
#### Scenario: 类型筛选器动态生成
- **WHEN** 表格渲染
- **THEN** 类型列的筛选器列表 SHALL 从 meta API 返回的 `checkerTypes` 动态生成,包含"全部"选项和每个 checker 类型选项label 和 value 均为 type 原始文本)
#### Scenario: 可用率列
- **WHEN** 表格渲染
- **THEN** 可用率列 SHALL 使用 TDesign Progress 组件theme=line, size=small渲染颜色通过 CSS 自定义属性 `--avail-N`(基于项目自定义色值)控制,每 10% 一档label 显示百分比数值支持排序升序优先最差排最前。color-threshold 函数 SHALL 返回 CSS 自定义属性引用而非硬编码色值
#### Scenario: 最近状态列
- **WHEN** 表格渲染
- **THEN** 最近状态列 SHALL 使用 StatusBar 组件渲染采样色块,宽度 220px。StatusBar SHALL 通过 CSS 类(`.status-bar-block--up` / `.status-bar-block--down` / `.status-bar-block--empty`)控制色块颜色,不使用内联 style
#### Scenario: 延迟列
- **WHEN** 表格渲染
- **THEN** 延迟列 SHALL 显示最近一次检查的延迟毫秒数,右对齐。颜色 SHALL 通过 CSS 类实现≤100ms 使用 `.latency-ok`、100-500ms 使用 `.latency-warn`、>500ms 使用 `.latency-error`。无数据 SHALL 使用 `.text-disabled` 类显示 "-",数值 SHALL 使用 `.tabular-nums` 类等宽显示。不使用内联 style
#### Scenario: 间隔列
- **WHEN** 表格渲染
- **THEN** 间隔列 SHALL 显示检查间隔(如 "5s"、"30s"),居中对齐,宽度 72px
### Requirement: 列定义工厂函数
列定义 SHALL 通过工厂函数生成,接收动态参数。
#### Scenario: createTargetTableColumns 函数
- **WHEN** 需要生成表格列定义
- **THEN** 系统 SHALL 调用 `createTargetTableColumns(checkerTypes: string[])` 函数,返回 `PrimaryTableCol<TargetStatus>[]`
#### Scenario: checkerTypes 为空数组
- **WHEN** meta API 尚未返回或返回空数组
- **THEN** 类型列的筛选器 SHALL 仅包含"全部"选项
#### Scenario: 列定义缓存
- **WHEN** TargetBoard 组件渲染
- **THEN** 列定义 SHALL 通过 `useMemo` 缓存,仅在 `checkerTypes` 变化时重新生成
### Requirement: TargetGroup 接收 columns prop
TargetGroup 组件 SHALL 通过 prop 接收列定义,不再直接导入静态常量。
#### Scenario: columns prop
- **WHEN** TargetGroup 渲染
- **THEN** 组件 SHALL 接收 `columns: PrimaryTableCol<TargetStatus>[]` prop 并传递给 PrimaryTable
#### Scenario: TargetBoard 传递 columns
- **WHEN** TargetBoard 渲染子组件
- **THEN** TargetBoard SHALL 调用 `createTargetTableColumns` 生成列定义并传递给每个 TargetGroup
### Requirement: 列定义复用
所有分组的表格 SHALL 共享同一套列定义常量。
#### Scenario: 列定义提取为常量
- **WHEN** 多个分组表格渲染
- **THEN** 列定义 SHALL 从独立的 constants/target-table-columns.tsx 导入,不在组件中重复定义

View File

@@ -0,0 +1,13 @@
## REMOVED Requirements
### Requirement: 类型显示名称映射
**Reason**: 前端不再维护 type → label 的静态映射,直接使用后端返回的 type 原始文本展示。类型筛选器列表改由 meta API 动态驱动。
**Migration**: 所有使用 `getTargetTypeDisplay(type)` 的位置改为直接使用 `type` 字符串。`TARGET_TYPE_DISPLAY` 常量和 `target-type-display.ts` 文件删除。
### Requirement: 映射可扩展性
**Reason**: 不再需要前端映射扩展机制,新增 checker 类型时后端注册即自动通过 meta API 暴露给前端。
**Migration**: 无需迁移,删除即可。
### Requirement: 类型安全
**Reason**: 不再有映射常量,无需 TypeScript 类型推导和 fallback 逻辑。
**Migration**: 无需迁移,删除即可。

View File

@@ -0,0 +1,48 @@
## 1. 后端 Meta API
- [ ] 1.1 在 `src/shared/api.ts` 中新增 `MetaResponse` 类型定义
- [ ] 1.2 创建 `src/server/routes/meta.ts`,实现 `handleMeta``checkerRegistry.supportedTypes` 返回数据
- [ ] 1.3 在 `src/server/app.ts` 中注册 `/api/meta` 路由
- [ ] 1.4 在 `tests/server/app.test.ts` 中添加 `/api/meta` 端点测试GET/HEAD/405
## 2. 前端 Hook 拆分
- [ ] 2.1 创建 `src/web/hooks/use-queries.ts`,迁入 `queryKeys``fetchJson``useSummary``useTargets`,新增 `useMeta`
- [ ] 2.2 重写 `src/web/hooks/use-target-detail.ts`,仅保留 Drawer 状态管理和条件查询trend/history
- [ ] 2.3 更新 `src/web/app.tsx` 的 import 路径适配新 hook 文件
## 3. 前端组件拆分
- [ ] 3.1 创建 `src/web/utils/stats.ts`,提取统计计算纯函数(`computeTrendStats`
- [ ] 3.2 创建 `src/web/constants/history-table-columns.tsx`,将 `HISTORY_COLUMNS` 从 Drawer 中移出
- [ ] 3.3 创建 `src/web/components/OverviewTab.tsx`,从 TargetDetailDrawer 中提取概览面板逻辑
- [ ] 3.4 创建 `src/web/components/HistoryTab.tsx`,从 TargetDetailDrawer 中提取记录面板逻辑
- [ ] 3.5 精简 `src/web/components/TargetDetailDrawer.tsx`,仅保留 Drawer 壳 + 时间选择 + Tab 切换
## 4. 类型筛选器动态化
- [ ] 4.1 将 `src/web/constants/target-table-columns.tsx` 中的 `TARGET_TABLE_COLUMNS` 改为工厂函数 `createTargetTableColumns(checkerTypes)`
- [ ] 4.2 从 `src/web/constants/target-table-filters.ts` 中移除 `typeFilter``statusFilter` 保留)
- [ ] 4.3 更新 `src/web/components/TargetBoard.tsx`,调用 `useMeta` + `useMemo` 生成列定义并传递给 TargetGroup
- [ ] 4.4 更新 `src/web/components/TargetGroup.tsx`,新增 `columns` prop 替代静态导入
- [ ] 4.5 删除 `src/web/constants/target-type-display.ts`
- [ ] 4.6 更新 `src/web/components/TargetDetailDrawer.tsx` 标题栏,直接使用 `target.type` 替代 `getTargetTypeDisplay`
## 5. 小问题修复
- [ ] 5.1 修复 `StatusDonut.tsx`Cell key 从 `index` 改为 `data[index].name`
- [ ] 5.2 修复 `StatusBar.tsx`:新增 `maxSlots` prop默认 30用 prop 驱动格数渲染
- [ ] 5.3 修复 `TrendChart.tsx`:移除 `loading` prop仅保留 `data` prop
## 6. 测试补充与更新
- [ ] 6.1 创建 `tests/web/utils/time.test.ts`,测试 `subtractHours`正常、跨天、跨月、0 小时)
- [ ] 6.2 创建 `tests/web/utils/stats.test.ts`,测试 `computeTrendStats` 纯函数
- [ ] 6.3 更新 `tests/web/constants/target-table-filters.test.ts`,移除 `typeFilter` 相关测试
- [ ] 6.4 删除 `tests/web/constants/target-type-display.test.ts`
- [ ] 6.5 创建 `tests/web/constants/target-table-columns.test.ts`,测试 `createTargetTableColumns` 工厂函数
## 7. 质量保障与文档
- [ ] 7.1 执行 `bun run check` 确保类型检查、lint、测试全部通过
- [ ] 7.2 更新 DEVELOPMENT.md 中前端目录结构、组件清单和 hook 说明

View File

@@ -0,0 +1,2 @@
schema: spec-driven
created: 2026-05-13

View File

@@ -0,0 +1,93 @@
## Context
HTTP checker 是 DiAL 拨测系统的核心 runner 之一,负责对 HTTP 目标执行请求并校验响应。经审查发现以下质量问题:
1. **actual 值截断格式不符合 spec**spec 要求 failure 中的 actual 摘要需截断并附带字符计数,但当前 `truncateActual` 函数只加省略号无计数,导致用户无法判断原始响应体规模。
2. **ReDoS 风险**:用户配置的 regex body 规则和 match operator 直接对大响应体执行 `new RegExp().test()`,恶意或不当正则可能导致 CPU 阻塞。
3. **JSON 重复解析**:多条 json body 规则各自独立调用 `JSON.parse(body)`,对大 JSON 响应体造成不必要的重复开销。
4. **CSS 规则分支冗余**`checkCssRule` 中"无 operator 时检查元素存在"和"exists: true"是重复逻辑。
5. **重定向测试不足**303、307/308、相对路径 Location 等分支缺少测试覆盖。
当前代码结构:
- `src/server/checker/expect/failure.ts` — failure 构造函数
- `src/server/checker/runner/http/body.ts` — body 规则检查
- `src/server/checker/runner/http/execute.ts` — HTTP 执行主流程
- `src/server/checker/expect/operator.ts` — operator 匹配逻辑
## Goals / Non-Goals
**Goals:**
- 实现 failure actual 值截断,满足 spec 要求
- 消除 regex 相关的 ReDoS 风险
- 优化多条 JSON 规则的解析性能
- 精简冗余代码分支
- 补全重定向和集成测试覆盖
**Non-Goals:**
- 不改变 CheckResult / CheckFailure 的类型结构(截断在构造时完成,对外接口不变)
- 不引入新依赖
- 不改变 HTTP checker 的功能行为(纯内部质量改进)
- 不添加 response timing 分段记录(暂缓)
- 不添加重试机制(拨测场景下重试会掩盖网络问题信号)
## Decisions
### Decision 1: actual 截断在 mismatchFailure 构造点统一实施
**选择**:在 `expect/failure.ts``mismatchFailure` 函数内部对 actual 参数截断,阈值 200 字符。
**替代方案**
- 在存储层store.ts insertCheckResult截断 — 但这样 API 实时返回的 failure 仍然很大
- 在每个调用点手动截断 — 分散且容易遗漏
**理由**:构造点截断是最集中的拦截位置,所有 mismatch failure 都经过此函数一处修改全局生效。expected 值不截断(来自用户配置,通常很短)。
**截断格式**`<前 200 字符>…(共 N 字符)` — 保留前缀便于诊断,附带总长度便于判断规模(省略号为单字符 U+2026
### Decision 2: ReDoS 防护使用正则复杂度静态检测
**选择**:在启动期 validate 阶段对 regex body 规则和 match operator 进行静态复杂度检测,拒绝含有嵌套量词等危险模式的正则。运行期不做额外防护。
**替代方案**
- 运行期用 AbortSignal + setTimeout 强制中断 — Bun 的 RegExp 执行不可中断,无法实现
- 使用 safe-regex 库 — 引入新依赖,违反项目规范
- 限制正则执行的输入长度 — 会影响正常大响应体的匹配
**理由**:自行实现轻量级检测函数,检查常见 ReDoS 模式(嵌套量词 `(a+)+`、重叠交替 `(a|a)*`)。在 validate 阶段拒绝危险正则,比运行期防护更可靠——配置错误应该在启动时暴露。
**检测规则**
- 嵌套量词:量词内包含量词(如 `(a+)+``(a*)*``(a+)*`
- 重叠字符类交替后跟量词:`(x|x)+` 模式
### Decision 3: JSON parse 结果缓存在 checkBodyExpect 层
**选择**:在 `checkBodyExpect` 函数中,首次遇到 json 规则时执行 `JSON.parse`,将结果缓存并传递给后续 json 规则复用。
**实现方式**:修改 `checkSingleBodyRule` 签名,接受可选的 `parsedJson` 参数;在 `checkBodyExpect` 循环中维护一个 `let parsedJson: { ok: boolean; value?: unknown; error?: string }` 状态。
**理由**:改动最小,不改变外部接口,只在内部传递缓存。对于非 json 规则contains、regex、css、xpath无影响。
### Decision 4: CSS 规则分支合并策略
**选择**:将 `checkCssRule` 重构为线性流程:
1. 解析 HTML
2. 处理 `exists: false`(元素不存在即通过)
3. 查找元素(不存在则失败)
4. 处理 `exists: true`(到这里已确认存在,直接通过)
5. 提取值attr 或 text
6. 无 operator 时检查值非 undefined 即通过
7. 有 operator 时执行匹配
**理由**:消除当前三层嵌套判断中的重复逻辑,使控制流线性化,更易理解和维护。
### Decision 5: execute.ts 提前 duration 检查保留但加注释
**选择**:保留第 56-74 行的提前 duration 检查逻辑(它是有效的性能优化——避免读取注定超时的 body但重构为独立的 helper 函数使意图更明确。
**理由**:删除它会导致超时场景下仍然读取完整 body 后才报错,浪费网络带宽和时间。提取为 `checkEarlyTimeout` 函数名即可自解释。
## Risks / Trade-offs
- **ReDoS 静态检测的误报**:过于严格的检测可能拒绝合法但看起来复杂的正则。→ 缓解:只检测最常见的嵌套量词模式,不做过度分析;提供清晰的错误信息指导用户修改。
- **actual 截断丢失诊断信息**:截断后用户无法看到完整 actual 值。→ 缓解200 字符的前缀通常足够定位问题;如需完整响应体,用户应直接请求目标 URL 查看。
- **JSON parse 缓存的内存占用**:对于大 JSON 响应体,缓存的 parsed 对象会在整个 body rules 检查期间驻留内存。→ 缓解:这是短暂的(单次检查周期内),且原本每条规则都会各自 parse 一份,缓存反而减少了峰值内存。

View File

@@ -0,0 +1,28 @@
## Why
HTTP checker 经过审查发现若干质量问题failure 中 actual 值截断格式不符合 spec 要求缺少字符计数导致诊断信息不完整、regex 规则缺少 ReDoS 防护存在 CPU 阻塞风险、多条 JSON body 规则重复 parse 造成不必要开销、CSS 规则分支冗余、重定向测试覆盖不足。需要统一修复以提升健壮性和代码质量。
## What Changes
- 修正 `mismatchFailure` 中 actual 值截断格式,添加字符计数信息,格式为 `前 N 字符…(共 M 字符)`
- 为 regex body 规则和 match operator 添加 ReDoS 防护(执行超时或正则复杂度检测)
- 优化多条 JSON body 规则共享同一次 `JSON.parse` 结果,避免重复解析
- 精简 `body.ts``checkCssRule` 的冗余分支逻辑
- 精简 `execute.ts` 中提前 duration 检查的代码结构
- 补充重定向相关测试303 method 转换、307/308 保持 method、相对路径 Location、混合 body rules 集成测试
## Capabilities
### New Capabilities
### Modified Capabilities
- `expect-body-checkers`: 新增 actual 值截断的具体实现要求spec 已声明但未细化截断阈值和格式)
## Impact
- `src/server/checker/expect/failure.ts` — 新增截断逻辑
- `src/server/checker/runner/http/body.ts` — JSON parse 优化、CSS 分支精简
- `src/server/checker/runner/http/execute.ts` — duration 检查精简
- `src/server/checker/expect/operator.ts` — match operator ReDoS 防护
- `tests/server/checker/runner/http/runner.test.ts` — 补充重定向和集成测试
- `tests/server/checker/runner/shared/body.test.ts` — 补充截断相关测试

View File

@@ -0,0 +1,101 @@
## MODIFIED Requirements
> 注:仅展示变更的 scenarios其余 scenarios 保持不变
### Requirement: 结构化 expect 失败信息
系统 SHALL 在任一 expect 规则失败时生成结构化 failure用于标识失败阶段、规则路径、期望值、实际值和可读错误信息。actual 值 SHALL 在构造时截断至不超过 200 字符超出部分以省略标记和总字符数替代。expected 值不截断。
#### Scenario: body 规则失败信息
- **WHEN** HTTP target 的 `expect.body[1].json` 规则失败
- **THEN** failure SHALL 包含 kind=`mismatch`、phase=`body`、path 指向 `expect.body[1]`,并包含 message
#### Scenario: actual 值截断
- **WHEN** 失败规则的实际值为字符串且长度超过 200 字符
- **THEN** failure.actual SHALL 为前 200 字符加 `…(共 N 字符)` 后缀,其中 N 为原始总字符数
#### Scenario: actual 值未超限
- **WHEN** 失败规则的实际值为字符串且长度不超过 200 字符
- **THEN** failure.actual SHALL 保留完整原始值,不做截断
#### Scenario: actual 值为对象或数组
- **WHEN** 失败规则的实际值为对象或数组,且 JSON 序列化后长度超过 200 字符
- **THEN** failure.actual SHALL 为序列化后前 200 字符加 `…(共 N 字符)` 后缀
#### Scenario: actual 值为标量
- **WHEN** 失败规则的实际值为 number、boolean、null 或 undefined
- **THEN** failure.actual SHALL 保留原始值,不做截断
### Requirement: HTTP expect 规则启动期校验
系统 SHALL 在启动期校验 HTTP expect 中已支持字段的类型、格式、未知字段和可编译表达式。HTTP expect、body rule、json/css/xpath rule 和 operator 对象中的未知字段 SHALL 导致启动期配置失败。每个 body rule 对象 MUST 恰好包含 contains、regex、json、css、xpath 中的一种规则类型。纯 operator 对象 MUST 至少包含一个已知 operatorbody 提取规则可以不配置 operator并以路径、元素或节点存在作为通过语义。`equals` operator SHALL 支持任意 JSON value包括数组和对象。系统 SHALL 在启动期对 regex body 规则和 match operator 的正则表达式进行 ReDoS 安全检测,含有嵌套量词等危险模式的正则 SHALL 导致启动期配置失败。
#### Scenario: body rule 使用 regex 字段
- **WHEN** HTTP target 配置 `expect.body: [{regex: "ok|healthy"}]` 且 regex 可编译且无 ReDoS 风险
- **THEN** 系统 SHALL 接受该配置,并在运行期按 regex body 规则匹配响应体
#### Scenario: body rule 不支持 match 字段
- **WHEN** HTTP target 配置 `expect.body: [{match: "ok"}]` 且该规则没有 contains、regex、json、css、xpath 任一支持字段
- **THEN** 系统 SHALL 在启动期配置校验失败
#### Scenario: body rule 未知字段启动失败
- **WHEN** HTTP target 配置 `expect.body: [{contains: "ok", note: "ignored"}]`
- **THEN** 系统 SHALL 在启动期配置校验失败,提示 `note` 是未知字段
#### Scenario: body rule 多支持字段非法
- **WHEN** HTTP target 的同一条 body rule 同时配置 contains 和 regex
- **THEN** 系统 SHALL 在启动期配置校验失败
#### Scenario: operator match 正则非法
- **WHEN** HTTP target 的 expect.headers、json、css 或 xpath operator 配置了不可编译的 match 正则
- **THEN** 系统 SHALL 在启动期配置校验失败
#### Scenario: operator 数值比较类型非法
- **WHEN** HTTP target 的 expect operator 配置 gt、gte、lt 或 lte且对应值不是有限数字
- **THEN** 系统 SHALL 在启动期配置校验失败
#### Scenario: operator 布尔类型非法
- **WHEN** HTTP target 的 expect operator 配置 empty 或 exists且对应值不是布尔值
- **THEN** 系统 SHALL 在启动期配置校验失败
#### Scenario: JSONPath 子集非法
- **WHEN** HTTP target 的 json body rule path 不符合系统支持的 JSONPath 子集
- **THEN** 系统 SHALL 在启动期配置校验失败
#### Scenario: operator 未知字段非法
- **WHEN** HTTP target 的 expect operator 配置了 `foo: "bar"` 等未知 operator 字段
- **THEN** 系统 SHALL 在启动期配置校验失败
#### Scenario: equals 支持对象
- **WHEN** HTTP target 配置 `expect.body: [{json: {path: "$.payload", equals: {status: "ok"}}}]`
- **THEN** 系统 SHALL 接受该配置,并在运行期使用深度相等比较提取值和对象期望
#### Scenario: equals 支持数组
- **WHEN** HTTP target 配置 `expect.body: [{json: {path: "$.items", equals: ["a", "b"]}}]`
- **THEN** 系统 SHALL 接受该配置,并在运行期使用深度相等比较提取值和数组期望
#### Scenario: 纯 operator 对象不能为空
- **WHEN** HTTP target 的 `expect.headers` 中某个 header 期望配置为空对象 `{}`
- **THEN** 系统 SHALL 在启动期配置校验失败,要求显式配置至少一个 operator
#### Scenario: json rule 允许存在性语义
- **WHEN** HTTP target 配置 `expect.body: [{json: {path: "$.status"}}]`
- **THEN** 系统 SHALL 接受该配置,并在运行期以 JSONPath 值存在作为通过语义
#### Scenario: css rule 未知字段非法
- **WHEN** HTTP target 配置 `expect.body: [{css: {selector: "h1", unknown: true}}]`
- **THEN** 系统 SHALL 在启动期配置校验失败,提示未知字段
#### Scenario: xpath rule 未知字段非法
- **WHEN** HTTP target 配置 `expect.body: [{xpath: {path: "/html/body", unknown: true}}]`
- **THEN** 系统 SHALL 在启动期配置校验失败,提示未知字段
#### Scenario: regex body 规则含嵌套量词启动失败
- **WHEN** HTTP target 配置 `expect.body: [{regex: "(a+)+$"}]`
- **THEN** 系统 SHALL 在启动期配置校验失败,提示正则存在 ReDoS 风险
#### Scenario: match operator 含嵌套量词启动失败
- **WHEN** HTTP target 的 expect operator 配置 `{match: "(\\d+)*x"}`
- **THEN** 系统 SHALL 在启动期配置校验失败,提示正则存在 ReDoS 风险
#### Scenario: 安全正则通过校验
- **WHEN** HTTP target 配置 `expect.body: [{regex: "\\d{1,3}\\.\\d{1,3}\\.\\d{1,3}\\.\\d{1,3}"}]`
- **THEN** 系统 SHALL 接受该配置(无嵌套量词,无 ReDoS 风险)

View File

@@ -0,0 +1,38 @@
## 1. failure actual 截断
- [ ] 1.1 修改 `src/server/checker/expect/failure.ts``truncateActual` 函数,截断后缀从 `...` 改为 `…(共 N 字符)`,其中省略号为单字符 U+2026
- [ ] 1.2 更新 `tests/server/checker/runner/shared/failure.test.ts` 中截断相关测试断言,匹配新格式(检查省略号为单字符且带字符计数)
## 2. ReDoS 防护
- [ ] 2.1 在 `src/server/checker/expect/` 下新增 `redos.ts`,实现 `isUnsafeRegex(pattern: string): boolean` 函数,检测嵌套量词模式
- [ ] 2.2 在 `src/server/checker/runner/http/validate.ts``validateRegexRule``src/server/checker/expect/validate-operator.ts` 的 match 校验中调用 `isUnsafeRegex`,不安全时返回 issue
- [ ] 2.3 在 `tests/server/checker/runner/http/runner.test.ts` 中补充 ReDoS 正则启动校验失败的测试用例
- [ ] 2.4 在 `tests/server/checker/runner/shared/` 下新增 `redos.test.ts`,覆盖常见 ReDoS 模式和安全正则的判定
## 3. JSON parse 优化
- [ ] 3.1 修改 `src/server/checker/runner/http/body.ts``checkBodyExpect` 函数,维护 parsedJson 缓存状态,首次 json 规则 parse 后复用结果
- [ ] 3.2 修改 `checkJsonRule` 签名接受可选的预解析 JSON 对象,避免重复 `JSON.parse`
- [ ] 3.3 在 `tests/server/checker/runner/shared/body.test.ts` 中补充多条 json 规则共享 parse 结果的测试(验证行为正确性)
## 4. CSS 规则精简
- [ ] 4.1 重构 `src/server/checker/runner/http/body.ts``checkCssRule` 为线性流程:解析 HTML → exists:false 短路 → 查找元素 → exists:true 短路 → 提取值 → operator 匹配
- [ ] 4.2 确认 `tests/server/checker/runner/shared/body.test.ts` 中现有 CSS 测试全部通过
## 5. execute.ts 精简
- [ ] 5.1 将 `src/server/checker/runner/http/execute.ts` 第 56-74 行的提前 duration 检查提取为 `checkEarlyTimeout` 辅助函数,明确意图
## 6. 补充测试
- [ ] 6.1 在 `tests/server/checker/runner/http/runner.test.ts` 中补充 303 重定向 method 转 GET 的测试
- [ ] 6.2 在 `tests/server/checker/runner/http/runner.test.ts` 中补充 307/308 保持原始 method 和 body 的测试
- [ ] 6.3 在 `tests/server/checker/runner/http/runner.test.ts` 中补充相对路径 Location header 重定向的测试
- [ ] 6.4 在 `tests/server/checker/runner/http/runner.test.ts` 中补充混合 body rulescontains + json + css集成测试
## 7. 质量保障
- [ ] 7.1 执行完整测试套件 `bun test`、代码检查 `bun run lint`、格式检查 `bun run format:check` 确保无回归
- [ ] 7.2 更新 DEVELOPMENT.md 中 ReDoS 校验相关说明(如有必要)

View File

@@ -0,0 +1,153 @@
## Purpose
定义 checker 模块的内聚化组织结构,确保每个 checker 以独立目录形式存在包含其全部类型定义、schema 声明、语义校验、执行逻辑和断言逻辑。同时定义共享的 expect/ 和 schema/ 基础设施,以及严格的依赖方向约束。
## Requirements
### Requirement: Checker 目录内聚结构
每个 checker SHALL 以独立目录形式存在于 `src/server/checker/runner/<type>/`,目录内 SHALL 包含该 checker 的全部类型定义、schema 声明、语义校验、执行逻辑和断言逻辑。
#### Scenario: HTTP checker 目录完整性
- **WHEN** 开发者查看 `src/server/checker/runner/http/` 目录
- **THEN** 该目录 SHALL 包含 `index.ts``types.ts``schema.ts``execute.ts``expect.ts``body.ts``validate.ts`
#### Scenario: Command checker 目录完整性
- **WHEN** 开发者查看 `src/server/checker/runner/command/` 目录
- **THEN** 该目录 SHALL 包含 `index.ts``types.ts``schema.ts``execute.ts``expect.ts``text.ts``validate.ts`
#### Scenario: 新增 checker 最小改动
- **WHEN** 开发者新增一个 checker 类型(如 dns
- **THEN** 开发者 SHALL 只需创建 `src/server/checker/runner/dns/` 目录及其内部文件,并在 `runner/index.ts` 注册列表中添加一行 import 和一行数组项
### Requirement: Checker 目录文件职责
每个 checker 目录内的文件 SHALL 遵循统一的职责划分。
#### Scenario: index.ts 仅做 re-export
- **WHEN** 开发者查看某 checker 的 `index.ts`
- **THEN** 该文件 SHALL 仅包含对 `execute.ts` 中 Checker 类的 re-export不包含任何逻辑
#### Scenario: types.ts 包含该 checker 全部专属类型
- **WHEN** 开发者需要该 checker 的配置类型、resolved 类型或 expect 类型
- **THEN** 这些类型 SHALL 全部定义在该 checker 目录的 `types.ts` 中,不在顶层 `types.ts`
#### Scenario: schema.ts 包含 TypeBox schema 定义
- **WHEN** 开发者需要该 checker 的 config/defaults/expect schema
- **THEN** 这些 schema SHALL 定义在该 checker 目录的 `schema.ts`
#### Scenario: execute.ts 包含 Checker 类实现
- **WHEN** 开发者需要查看该 checker 的执行逻辑
- **THEN** Checker 类(实现 CheckerDefinition 接口SHALL 定义在 `execute.ts`
#### Scenario: validate.ts 包含该 checker 全部语义校验
- **WHEN** 开发者需要查看该 checker 的配置校验逻辑
- **THEN** 该 checker 专属的语义校验函数 SHALL 全部定义在 `validate.ts`
#### Scenario: expect.ts 包含该 checker 专属断言
- **WHEN** 开发者需要查看该 checker 的断言逻辑
- **THEN** 该 checker 专属的断言函数 SHALL 定义在 `expect.ts`
### Requirement: 断言基础设施目录
系统 SHALL 在 `src/server/checker/expect/` 目录中提供所有 checker 共享的断言基础设施。
#### Scenario: expect 共享类型位置
- **WHEN** 任何 checker 需要使用断言相关的共享类型(如 `ExpectResult`
- **THEN** 这些类型 SHALL 从 `src/server/checker/expect/types.ts` 导入
#### Scenario: operator 断言引擎位置
- **WHEN** 任何 checker 需要使用 `applyOperator``evaluateJsonPath``checkExpectValue`
- **THEN** 这些函数 SHALL 从 `src/server/checker/expect/operator.ts` 导入
#### Scenario: duration 断言位置
- **WHEN** 任何 checker 需要使用 `checkDuration`
- **THEN** 该函数 SHALL 从 `src/server/checker/expect/duration.ts` 导入
#### Scenario: failure 构造器位置
- **WHEN** 任何 checker 需要使用 `errorFailure``mismatchFailure`
- **THEN** 这些函数 SHALL 从 `src/server/checker/expect/failure.ts` 导入
#### Scenario: operator 校验位置
- **WHEN** 任何 checker 的 validate 需要使用 `validateOperatorObject`
- **THEN** 该函数 SHALL 从 `src/server/checker/expect/validate-operator.ts` 导入
#### Scenario: ExpectResult 类型位置
- **WHEN** 任何 checker 需要使用 `ExpectResult` 类型
- **THEN** 该类型 SHALL 从 `src/server/checker/expect/types.ts` 导入
### Requirement: Schema 目录结构
系统 SHALL 在 `src/server/checker/schema/` 目录中组织配置 schema 体系,替代原 `config-contract/` 目录。
#### Scenario: schema 目录包含 builder
- **WHEN** 系统需要从 registry 动态构建整体配置 schema
- **THEN** 该逻辑 SHALL 位于 `src/server/checker/schema/builder.ts`
#### Scenario: schema 目录包含 fragments
- **WHEN** checker 的 schema.ts 需要引用共享 schema 片段(如 durationSchema、sizeSchema
- **THEN** 这些片段 SHALL 从 `src/server/checker/schema/fragments.ts` 导入
#### Scenario: schema 目录包含 Ajv 校验入口
- **WHEN** config-loader 需要执行契约校验
- **THEN** 校验入口 SHALL 位于 `src/server/checker/schema/validate.ts`
#### Scenario: schema 目录包含 issue 工具
- **WHEN** 任何校验逻辑需要构造 ConfigValidationIssue
- **THEN** issue 类型和工具函数 SHALL 从 `src/server/checker/schema/issues.ts` 导入
### Requirement: 工具函数归集
系统 SHALL 在 `src/server/checker/utils.ts` 中提供纯工具函数。
#### Scenario: parseSize 位置
- **WHEN** 任何模块需要解析 size 字符串(如 "100MB"
- **THEN** `parseSize` SHALL 从 `src/server/checker/utils.ts` 导入
#### Scenario: parseDuration 位置
- **WHEN** 任何模块需要解析 duration 字符串(如 "30s"
- **THEN** `parseDuration` SHALL 从 `src/server/checker/utils.ts` 导入
### Requirement: 依赖方向约束
checker 系统内的模块依赖 SHALL 遵循严格的分层方向。
#### Scenario: checker 之间无横向依赖
- **WHEN** 开发者查看任何 checker 目录的 import 语句
- **THEN** 该 checker SHALL NOT 导入其他 checker 目录的任何模块
#### Scenario: expect/ 不依赖 runner/
- **WHEN** 开发者查看 `expect/` 目录的 import 语句
- **THEN** `expect/` 中的文件 SHALL NOT 导入 `runner/` 目录的任何模块
#### Scenario: schema/ 不依赖 runner/ 的具体 checker
- **WHEN** 开发者查看 `schema/` 目录的 import 语句
- **THEN** `schema/` 中的文件 SHALL 仅通过 `CheckerDefinition` 接口与 checker 交互SHALL NOT 直接导入具体 checker 目录
### Requirement: 显式注册列表
系统 SHALL 在 `src/server/checker/runner/index.ts` 中使用显式 import 列表注册所有 checker。
#### Scenario: 注册入口结构
- **WHEN** 开发者查看 `runner/index.ts`
- **THEN** 该文件 SHALL 包含所有 checker 的静态 import 和一个 checker 实例数组,通过循环调用 `registry.register()` 完成注册
#### Scenario: 新增 checker 注册
- **WHEN** 开发者新增一个 checker
- **THEN** 开发者 SHALL 在 `runner/index.ts` 中添加一行 import 和一行数组项,无需修改其他文件
### Requirement: 公共类型文件瘦身
顶层 `src/server/checker/types.ts` SHALL 仅保留跨 checker 共享的 base 类型和存储相关类型。
#### Scenario: types.ts 不包含 checker 专属类型
- **WHEN** 开发者查看顶层 `types.ts`
- **THEN** 该文件 SHALL NOT 包含 `HttpTargetConfig``ResolvedHttpTarget``CommandExpectConfig``BodyRule``TextRule` 等 checker 专属类型
#### Scenario: types.ts 保留 base 类型
- **WHEN** 开发者查看顶层 `types.ts`
- **THEN** 该文件 SHALL 包含 `ResolvedTargetBase``RawTargetConfig``DefaultsConfig``CheckResult``ExpectOperator``CheckFailure``StoredTarget``StoredCheckResult``JsonValue` 等公共类型
#### Scenario: ResolvedTargetBase 替代联合类型
- **WHEN** engine、store、config-loader 需要引用 resolved target 类型
- **THEN** 这些模块 SHALL 使用 `ResolvedTargetBase` interface不再使用硬编码联合类型
#### Scenario: DefaultsConfig 为宽松 base 形式
- **WHEN** 开发者查看顶层 `types.ts` 中的 `DefaultsConfig`
- **THEN** 该 interface SHALL 仅包含公共字段(`interval?``timeout?`)和 index signature`[checkerKey: string]: unknown`SHALL NOT 包含 `command?``http?` 等 checker 专属字段
#### Scenario: 各 checker validate 自行 narrow defaults
- **WHEN** checker 的 `validate()` 方法需要访问自身的 defaults 配置
- **THEN** checker SHALL 从 `DefaultsConfig` 中通过 `defaults[configKey]` 获取并自行 narrow 为具体类型

View File

@@ -4,17 +4,78 @@
## Requirements
### Requirement: Checker 配置契约片段
系统 SHALL 支持 checker 提供自身 TypeBox 配置契约片段,用于描述该 checker 的 defaults 分组、target 领域分组和 expect 分组。公共配置加载模块 SHALL 通过 registry 获取已注册 checker 的契约片段,并组合为启动期 Ajv 契约校验流程和外部 `probe-config.schema.json` 导出流程。
#### Scenario: HTTP checker 提供契约片段
- **WHEN** HTTP checker 被注册
- **THEN** registry SHALL 能提供 HTTP defaults、HTTP target 和 HTTP expect 的 TypeBox 契约片段
#### Scenario: Command checker 提供契约片段
- **WHEN** Command checker 被注册
- **THEN** registry SHALL 能提供 Command defaults、Command target 和 Command expect 的 TypeBox 契约片段
#### Scenario: 新 checker 只维护自身契约
- **WHEN** 开发者新增一个 checker 类型
- **THEN** 该 checker SHALL 提供自身 TypeBox 配置契约和语义 validator而不需要把 checker 专属字段写入中央手工校验逻辑
#### Scenario: 外部 schema 通过 registry 生成
- **WHEN** 系统生成 `probe-config.schema.json`
- **THEN** 生成流程 SHALL 从 registry 获取已注册 checker 的契约片段,并将其组合进完整配置 schema
#### Scenario: 契约组装不依赖全局 singleton
- **WHEN** 测试或 schema 生成流程需要组装配置契约
- **THEN** 系统 SHALL 支持传入 fresh CheckerRegistry 实例完成契约组装,避免重复注册或全局状态污染
### Requirement: Checker 启动期语义校验
系统 SHALL 支持 checker 提供启动期语义 validator用于校验 TypeBox/Ajv 契约不适合表达或需要 checker 业务知识判断的配置规则。语义 validator MUST 在 resolver 填充最终 ResolvedTarget 之前执行,并 MUST 返回 `ConfigValidationIssue[]`
#### Scenario: checker 语义校验先于 resolve
- **WHEN** config-loader 准备解析一个 target
- **THEN** 系统 SHALL 先完成该 target 的 checker 语义校验,再调用 checker.resolve()
#### Scenario: 语义校验失败阻止启动
- **WHEN** checker 语义 validator 发现非法配置
- **THEN** 系统 SHALL 以配置错误退出,不进入 checker 执行阶段
### Requirement: 结构化配置校验 issue
系统 SHALL 使用统一 `ConfigValidationIssue` 表示配置校验问题,至少包含 `code``path``message`,并支持可选 `targetName`。契约校验和 checker 语义校验都 SHALL 产出该结构,由配置加载模块统一渲染为中文错误。
#### Scenario: Ajv 错误转换为 issue
- **WHEN** Ajv 校验发现 required、type 或 additionalProperties 错误
- **THEN** 系统 SHALL 将该错误转换为 `ConfigValidationIssue`,保留配置路径和可读 message
#### Scenario: checker validator 返回 issue
- **WHEN** checker 语义 validator 发现非法 XPath 或正则表达式
- **THEN** checker SHALL 返回 `ConfigValidationIssue`,而不是直接抛出最终用户错误字符串
### Requirement: Checker 接口定义
系统 SHALL 在 `src/server/checker/runner/types.ts` 中定义 `Checker` 接口,包含 `type``resolve``execute``serialize` 四个成员。`CheckerContext` SHALL 包含引擎注入的 `AbortSignal`
系统 SHALL 在 `src/server/checker/runner/types.ts` 中定义面向扩展的 `CheckerDefinition`,包含 `type``configKey`、TypeBox 配置契约、启动期语义校验、`resolve``execute``serialize` 成员。`CheckerContext` SHALL 包含引擎注入的 `AbortSignal`接口方法的参数和返回值 SHALL 使用 base interface 类型(`RawTargetConfig``ResolvedTargetBase`),各 checker 实现内部自行 narrow 到具体类型。
#### Scenario: Checker 接口包含必要方法
- **WHEN** 开发者实现一个新的 Checker
- **THEN** 该实现 MUST 提供 `type`(字符串标识)、`resolve(target, context)`(解析配置并校验)、`execute(target, ctx)`(执行探测返回 CheckResult`serialize(target)`(返回 target 展示文本和 config JSON
- **THEN** 该实现 MUST 提供 `type`(字符串标识)、`configKey`配置分组名、TypeBox 配置契约、启动期语义校验、`resolve(target, context)`(解析配置并填充默认值)、`execute(target, ctx)`(执行探测返回 CheckResult`serialize(target)`(返回 target 展示文本和 config JSON
#### Scenario: CheckerContext 注入 signal
- **WHEN** 引擎调用 `checker.execute(target, ctx)`
- **THEN** `ctx.signal` SHALL 是一个由引擎创建的 `AbortSignal`,在超时或引擎关闭时 abort
#### Scenario: resolve 不承担通用契约校验
- **WHEN** config-loader 调用 checker.resolve()
- **THEN** checker.resolve() SHALL 假定配置已经通过 TypeBox/Ajv 契约校验和启动期语义校验,只负责默认值填充、路径解析和领域配置转换
#### Scenario: type 与 configKey 默认一致
- **WHEN** checker 定义 `type: "tcp"`
- **THEN** checker 的 `configKey` SHALL 默认使用 `"tcp"`,对应 target 的 `tcp` 分组和 defaults.tcp 分组
#### Scenario: 接口方法使用 base 类型
- **WHEN** 开发者查看 `CheckerDefinition` 接口签名
- **THEN** `resolve` 的参数 SHALL 为 `RawTargetConfig`,返回值 SHALL 为 `ResolvedTargetBase``execute` 的参数 SHALL 为 `ResolvedTargetBase``serialize` 的参数 SHALL 为 `ResolvedTargetBase`
#### Scenario: checker 实现内部 narrow
- **WHEN** HttpChecker 的 execute 方法接收 `ResolvedTargetBase` 参数
- **THEN** 方法内部 SHALL 将参数 narrow 为 `ResolvedHttpTarget`(通过 type assertion然后使用具体类型的字段
### Requirement: CheckerRegistry 注册中心
系统 SHALL 在 `src/server/checker/runner/registry.ts` 中提供 `CheckerRegistry` 类,支持 `register(checker)``get(type)``supportedTypes`。重复注册同一 type SHALL 抛出错误。
@@ -46,27 +107,31 @@
- **THEN** engine SHALL 创建 `AbortController`,设置超时定时器,将 `controller.signal` 注入 `CheckerContext`,执行完成后清理定时器
### Requirement: 配置解析通过 registry 委托 checker
系统 SHALL 在 `config-loader.ts` `resolveTarget()` 中通过 `checkerRegistry.get(target.type).resolve(target, context)` 委托解析,替代原有的 `if/else` 分支`validateConfig()` SHALL 仅校验通用字段name 非空、name 不重复、group 类型,不包含 type 专属字段校验。
系统 SHALL 在 `config-loader.ts`配置加载流程中通过 `checkerRegistry` 发现已注册 checker组合公共 TypeBox 契约与 checker 契约,并将 checker 专属语义校验和解析委托给对应 checker`validateConfig()` SHALL 仅保留公共语义校验name 非空、name 不重复、group 类型、type 已注册等)和契约调度职责,不包含 checker 专属字段校验。
#### Scenario: 配置契约通过 registry 组合
- **WHEN** config-loader 校验配置文件
- **THEN** config-loader SHALL 从 `checkerRegistry` 获取已注册 checker 的契约片段,并用于校验 defaults 与 targets 中对应 checker 的配置形状
#### Scenario: 配置解析委托 checker
- **WHEN** config-loader 解析一个 type 为 "command" 的 target
- **THEN** config-loader SHALL 调用 `checkerRegistry.get("command").resolve()` 进行解析、校验和默认值填充
- **THEN** config-loader SHALL 调用 `checkerRegistry.get("command")` 获取对应 checker并委托该 checker 执行语义校验和 resolve
#### Scenario: 通用字段校验保留在 config-loader
- **WHEN** YAML 配置中某个 target 缺少 name 或 type 字段
- **THEN** config-loader 的 `validateConfig()` SHALL 仍负责校验这些通用字段
- **THEN** config-loader 的公共校验流程 SHALL 仍负责校验这些通用字段
#### Scenario: type 专属校验下沉到 checker
- **WHEN** YAML 配置中 HTTP target 缺少 `http.url`
- **THEN** HttpChecker 的 `resolve()` SHALL 抛出校验错误,提示缺少必填字段
- **THEN** HTTP checker 的契约或语义校验 SHALL 抛出校验错误,提示缺少必填字段
#### Scenario: HTTP method 非法校验
- **WHEN** YAML 配置中 HTTP target 的 `http.method`合法方法列表中
- **THEN** HttpChecker `resolve()` SHALL 抛出校验错误,提示 method 不合法
- **WHEN** YAML 配置中 HTTP target 的 `http.method`是大写合法方法枚举值
- **THEN** HTTP checker 契约或语义校验 SHALL 抛出校验错误,提示 method 不合法
#### Scenario: URL 格式校验
- **WHEN** YAML 配置中 HTTP target 的 `http.url` 不以 `http://``https://` 开头
- **THEN** HttpChecker 的 `resolve()` SHALL 抛出校验错误,提示 URL 格式不合法
- **THEN** HttpChecker 的语义校验 SHALL 抛出校验错误,提示 URL 格式不合法
### Requirement: 存储序列化通过 registry 获取展示格式
系统 SHALL 在 `ProbeStore.syncTargets()` 中通过 `checkerRegistry.get(t.type).serialize(t)` 获取每个 target 的展示摘要(`target` 列)和配置 JSON`config` 列),替代 `buildTargetDisplay()` / `buildTargetConfig()` 中的类型分支。
@@ -76,19 +141,27 @@
- **THEN** store SHALL 对每个 target 调用对应 checker 的 `serialize()` 方法获取 `{ target, config }`
### Requirement: 共享 expect 断言函数
系统 SHALL 在 `src/server/checker/runner/shared/` 中提供可被多个 checker 复用的 expect 函数。checker 专用的 expect 函数 SHALL 保留在各自子包内。
系统 SHALL 在 `src/server/checker/expect/` 中提供可被多个 checker 复用的 expect 函数。checker 专用的 expect 函数 SHALL 保留在各自子包内。仅被单个 checker 使用的断言模块 SHALL 位于该 checker 目录内。
#### Scenario: 共享 duration 断言
- **WHEN** 任何 checker 需要校验执行耗时
- **THEN** SHALL 调用 `runner/shared/duration.ts` 中的 `checkDuration(durationMs, maxDurationMs?)`,返回统一的 `ExpectResult`
- **THEN** SHALL 调用 `expect/duration.ts` 中的 `checkDuration(durationMs, maxDurationMs?)`,返回统一的 `ExpectResult`
#### Scenario: 共享 text 规则断言
- **WHEN** 任何 checker 需要对文本输出执行有序规则校验
- **THEN** SHALL 调用 `runner/shared/text.ts` 中的 `checkTextRules(text, rules, phase)`,返回统一的 `ExpectResult`
#### Scenario: 共享 operator 断言
- **WHEN** 任何 checker 需要对值执行 operator 匹配
- **THEN** SHALL 调用 `expect/operator.ts` 中的 `applyOperator(actual, op)`
#### Scenario: 共享 body 规则断言
- **WHEN** 任何 checker 需要对文本体执行 contains/regex/json/css/xpath 规则校验
- **THEN** SHALL 调用 `runner/shared/body.ts` 中的 `checkBodyExpect(body, rules)`,返回统一的 `ExpectResult`
#### Scenario: 共享 failure 构造
- **WHEN** 任何 checker 需要构造 CheckFailure 对象
- **THEN** SHALL 调用 `expect/failure.ts` 中的 `errorFailure()``mismatchFailure()`
#### Scenario: HTTP body 断言位于 HTTP 目录
- **WHEN** HTTP checker 需要对响应体执行 contains/regex/json/css/xpath 规则校验
- **THEN** SHALL 调用 `runner/http/body.ts` 中的 `checkBodyExpect(body, rules)`
#### Scenario: Command text 断言位于 Command 目录
- **WHEN** Command checker 需要对 stdout/stderr 执行文本规则校验
- **THEN** SHALL 调用 `runner/command/text.ts` 中的 `checkTextRules(text, rules, phase)`
#### Scenario: HTTP 专用 expect
- **WHEN** HTTP checker 需要校验响应状态码和响应头

View File

@@ -80,3 +80,62 @@
#### Scenario: stdout 失败后不检查 stderr
- **WHEN** command target 同时配置 stdout 和 stderr 规则,且 stdout 规则失败
- **THEN** 系统 SHALL 快速失败并 MUST NOT 继续执行 stderr 规则
### Requirement: command checker 启动期配置校验
系统 SHALL 在启动期对 command checker 的配置契约和语义执行严格校验。Command target 的 `command` 分组 SHALL 只允许 `exec``args``cwd``env``maxOutputBytes` 字段Command expect SHALL 只允许 `exitCode``maxDurationMs``stdout``stderr` 字段。未知字段、非法类型和不可编译正则 MUST 导致启动期配置错误。`expect.exitCode` SHALL 保留原有有限整数数组语义,不限制到特定平台范围。
#### Scenario: command args 类型非法
- **WHEN** YAML 中 command target 配置 `command.args` 不是字符串数组
- **THEN** 系统 SHALL 以配置错误退出,提示 command.args 格式错误
#### Scenario: command cwd 类型非法
- **WHEN** YAML 中 command target 配置 `command.cwd` 不是字符串
- **THEN** 系统 SHALL 以配置错误退出,提示 command.cwd 必须为字符串
#### Scenario: command env 值类型非法
- **WHEN** YAML 中 command target 配置 `command.env`,且任一环境变量值不是字符串
- **THEN** 系统 SHALL 以配置错误退出,提示 command.env 对应变量值必须为字符串
#### Scenario: command maxOutputBytes 非法
- **WHEN** YAML 中 command target 或 defaults.command 配置的 `maxOutputBytes` 不是合法 size 值
- **THEN** 系统 SHALL 以配置错误退出,提示 maxOutputBytes 格式错误
#### Scenario: command 分组未知字段失败
- **WHEN** YAML 中 command target 的 `command` 分组包含 `shell: true` 等未知字段
- **THEN** 系统 SHALL 以配置错误退出,提示 command 分组包含未知字段
#### Scenario: command expect exitCode 类型非法
- **WHEN** YAML 中 command target 配置 `expect.exitCode` 不是整数数组
- **THEN** 系统 SHALL 以配置错误退出,提示 expect.exitCode 必须为整数数组
#### Scenario: command expect exitCode 不限制平台范围
- **WHEN** YAML 中 command target 配置 `expect.exitCode` 为有限整数数组
- **THEN** 系统 SHALL 接受该数组,不额外限制为 0-255 等平台相关范围
#### Scenario: command expect maxDurationMs 非法
- **WHEN** YAML 中 command target 配置 `expect.maxDurationMs` 不是非负有限数字
- **THEN** 系统 SHALL 以配置错误退出,提示 expect.maxDurationMs 格式错误
#### Scenario: stdout 必须为规则数组
- **WHEN** YAML 中 command target 配置 `expect.stdout` 但其值不是数组
- **THEN** 系统 SHALL 以配置错误退出,提示 expect.stdout 必须为数组
#### Scenario: stderr 必须为规则数组
- **WHEN** YAML 中 command target 配置 `expect.stderr` 但其值不是数组
- **THEN** 系统 SHALL 以配置错误退出,提示 expect.stderr 必须为数组
#### Scenario: stdout text rule 空对象非法
- **WHEN** YAML 中 command target 配置 `expect.stdout: [{}]`
- **THEN** 系统 SHALL 以配置错误退出,提示 stdout 规则必须包含至少一个合法 operator
#### Scenario: stderr text rule 未知字段非法
- **WHEN** YAML 中 command target 配置 `expect.stderr: [{foo: "bar"}]`
- **THEN** 系统 SHALL 以配置错误退出,提示 stderr 规则包含未知 operator
#### Scenario: stdout match 正则非法
- **WHEN** YAML 中 command target 配置 `expect.stdout: [{match: "[invalid"}]`
- **THEN** 系统 SHALL 在启动期配置校验失败,而不是延迟到运行期抛错
#### Scenario: command expect 未知字段失败
- **WHEN** YAML 中 command target 的 expect 包含 `status: [200]` 或其他非 command expect 字段
- **THEN** 系统 SHALL 以配置错误退出,提示 expect 包含未知字段

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

@@ -163,7 +163,7 @@
- **THEN** 系统 SHALL 在启动期配置校验失败
### Requirement: HTTP expect 规则启动期校验
系统 SHALL 在启动期校验 HTTP expect 中已支持字段的类型、格式和可编译表达式。未知字段 SHALL 被忽略,但每个规则对象 MUST 至少包含可产生有效断言的支持字段
系统 SHALL 在启动期校验 HTTP expect 中已支持字段的类型、格式、未知字段和可编译表达式。HTTP expect、body rule、json/css/xpath rule 和 operator 对象中的未知字段 SHALL 导致启动期配置失败。每个 body rule 对象 MUST 恰好包含 contains、regex、json、css、xpath 中的一种规则类型。纯 operator 对象 MUST 至少包含一个已知 operatorbody 提取规则可以不配置 operator并以路径、元素或节点存在作为通过语义。`equals` operator SHALL 支持任意 JSON value包括数组和对象
#### Scenario: body rule 使用 regex 字段
- **WHEN** HTTP target 配置 `expect.body: [{regex: "ok|healthy"}]` 且 regex 可编译
@@ -173,9 +173,9 @@
- **WHEN** HTTP target 配置 `expect.body: [{match: "ok"}]` 且该规则没有 contains、regex、json、css、xpath 任一支持字段
- **THEN** 系统 SHALL 在启动期配置校验失败
#### Scenario: body rule 忽略未知字段
#### Scenario: body rule 忽略未知字段 → body rule 未知字段启动失败
- **WHEN** HTTP target 配置 `expect.body: [{contains: "ok", note: "ignored"}]`
- **THEN** 系统 SHALL 忽略 note 字段并按 contains 规则校验响应体
- **THEN** 系统 SHALL 在启动期配置校验失败,提示 `note` 是未知字段
#### Scenario: body rule 多支持字段非法
- **WHEN** HTTP target 的同一条 body rule 同时配置 contains 和 regex
@@ -197,6 +197,34 @@
- **WHEN** HTTP target 的 json body rule path 不符合系统支持的 JSONPath 子集
- **THEN** 系统 SHALL 在启动期配置校验失败
#### Scenario: operator 未知字段非法
- **WHEN** HTTP target 的 expect operator 配置了 `foo: "bar"` 等未知 operator 字段
- **THEN** 系统 SHALL 在启动期配置校验失败
#### Scenario: equals 支持对象
- **WHEN** HTTP target 配置 `expect.body: [{json: {path: "$.payload", equals: {status: "ok"}}}]`
- **THEN** 系统 SHALL 接受该配置,并在运行期使用深度相等比较提取值和对象期望
#### Scenario: equals 支持数组
- **WHEN** HTTP target 配置 `expect.body: [{json: {path: "$.items", equals: ["a", "b"]}}]`
- **THEN** 系统 SHALL 接受该配置,并在运行期使用深度相等比较提取值和数组期望
#### Scenario: 纯 operator 对象不能为空
- **WHEN** HTTP target 的 `expect.headers` 中某个 header 期望配置为空对象 `{}`
- **THEN** 系统 SHALL 在启动期配置校验失败,要求显式配置至少一个 operator
#### Scenario: json rule 允许存在性语义
- **WHEN** HTTP target 配置 `expect.body: [{json: {path: "$.status"}}]`
- **THEN** 系统 SHALL 接受该配置,并在运行期以 JSONPath 值存在作为通过语义
#### Scenario: css rule 未知字段非法
- **WHEN** HTTP target 配置 `expect.body: [{css: {selector: "h1", unknown: true}}]`
- **THEN** 系统 SHALL 在启动期配置校验失败,提示未知字段
#### Scenario: xpath rule 未知字段非法
- **WHEN** HTTP target 配置 `expect.body: [{xpath: {path: "/html/body", unknown: true}}]`
- **THEN** 系统 SHALL 在启动期配置校验失败,提示未知字段
### Requirement: HTTP body 运行期失败结构化
系统 SHALL 将 HTTP body 运行期失败记录为结构化 CheckFailure并保留与具体规则相关的 phase 和 path。响应内容不符合配置 SHALL 记录为 mismatch响应内容无法按配置解析或解码 SHALL 记录为 error。

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

@@ -47,7 +47,13 @@
- **THEN** 系统 SHALL 以错误退出并提示文件不存在
### Requirement: 配置校验
系统 SHALL 在启动时对 YAML 配置进行完整校验,校验失败时以非零状态退出并输出清晰的错误信息。HTTP checker SHALL 对已支持字段执行严格启动期校验;未知字段 SHALL 被忽略,不触发启动失败且不影响运行行为
系统 SHALL 在启动时对 YAML 配置进行完整校验,校验失败时以非零状态退出并输出清晰的错误信息。系统 SHALL 使用 TypeBox 定义配置契约和 raw config TypeScript 类型,由 Ajv 校验 TypeBox 生成的 JSON Schema再执行启动期语义 validator。配置加载流程 SHALL 明确区分 `RawProbeConfig``ValidatedProbeConfig``ResolvedConfig` 三段生命周期。JSON Schema 契约 SHALL 覆盖业务无关的结构规则,包括字段类型、必填字段、枚举、数组与对象形状、数值范围和未知字段。语义 validator SHALL 覆盖契约不适合表达的业务规则,包括 target name 唯一性、checker type 注册状态、时长和大小解析、HTTP URL、正则可编译、JSONPath 子集和 XPath 可编译
契约校验和语义 validator SHALL 统一产出 `ConfigValidationIssue`,最终由配置加载流程统一渲染为中文错误信息。
系统 SHALL 导出完整 `probe-config.schema.json`,该文件 SHALL 与运行期 TypeBox fragments 生成的 JSON Schema 保持一致,用于用户配置引用和编辑器提示。
`headers``env` 等明确声明为动态键值表的对象外,配置中的未知字段 SHALL 导致启动期配置错误。系统 MUST NOT 静默忽略未知字段。
#### Scenario: target 缺少必填字段
- **WHEN** YAML 中某个 target 缺少 name 或 type 字段
@@ -62,8 +68,8 @@
- **THEN** 系统 SHALL 以错误退出,提示该 target 缺少 command.exec 字段
#### Scenario: target type 非法
- **WHEN** YAML 中某个 target 的 type 不是 `http``command`
- **THEN** 系统 SHALL 以错误退出,提示不支持的 target type
- **WHEN** YAML 中某个 target 的 type 不是已注册 checker 类型
- **THEN** 系统 SHALL 以错误退出,提示不支持的 target type 和当前支持的 type 列表
#### Scenario: target name 重复
- **WHEN** YAML 中存在两个 name 相同的 target
@@ -81,16 +87,28 @@
- **WHEN** runtime.maxConcurrentChecks 不是正整数
- **THEN** 系统 SHALL 以错误退出并提示 runtime.maxConcurrentChecks 格式错误
#### Scenario: interval 或 timeout 解析结果非法
- **WHEN** interval 或 timeout 解析结果不是正整数毫秒(如 `0ms``1.5ms`
- **THEN** 系统 SHALL 以错误退出并提示必须为正整数毫秒
#### Scenario: size 格式非法
- **WHEN** maxBodyBytes 或 maxOutputBytes 值不是有效的 size 格式
- **THEN** 系统 SHALL 以错误退出并提示支持 B、KB、MB、GB 格式
#### Scenario: size 解析结果非法
- **WHEN** maxBodyBytes 或 maxOutputBytes 解析结果不是非负安全整数字节数(如 `1.5B`
- **THEN** 系统 SHALL 以错误退出并提示必须为非负安全整数字节数
#### Scenario: HTTP method 非法
- **WHEN** YAML 中某个 HTTP target 的 `http.method` 不是 GET、HEAD、POST、PUT、PATCH、DELETE、OPTIONS 之一
- **THEN** 系统 SHALL 以错误退出,提示该 target 的 method 不合法
#### Scenario: HTTP method 小写非法
- **WHEN** YAML 中某个 HTTP target 配置 `http.method: get`
- **THEN** 系统 SHALL 以错误退出,提示该 target 的 method 必须为大写枚举值
#### Scenario: URL 格式非法
- **WHEN** YAML 中某个 HTTP target 的 `http.url` `http://` `https://` 开头
- **WHEN** YAML 中某个 HTTP target 的 `http.url`是合法 URL或协议不是 `http:` / `https:`
- **THEN** 系统 SHALL 以错误退出,提示该 target 的 URL 格式不合法
#### Scenario: maxRedirects 非法
@@ -106,7 +124,7 @@
- **THEN** 系统 SHALL 以错误退出,提示该 target 的 ignoreSSL 必须为布尔值
#### Scenario: HTTP headers 类型非法
- **WHEN** YAML 中某个 HTTP target 的 `http.headers` 不是对象,或任一 header 名和值不能作为字符串使用
- **WHEN** YAML 中某个 HTTP target 的 `http.headers` 不是对象,或任一 header 值不是字符串
- **THEN** 系统 SHALL 以错误退出,提示该 target 的 http.headers 格式错误
#### Scenario: HTTP body 类型非法
@@ -165,11 +183,37 @@
- **WHEN** YAML 中某个 HTTP expect operator 的 match 不是可编译正则字符串empty/exists 不是布尔值,或 gt/gte/lt/lte 不是有限数字
- **THEN** 系统 SHALL 以错误退出,提示对应 operator 配置不合法
#### Scenario: unknown 字段忽略
- **WHEN** YAML 中某个 HTTP target、expect 或 rule 对象包含未知字段,且所有已支持字段均合法
- **THEN** 系统 SHALL 忽略未知字段并正常启动
#### Scenario: expect operator 类型非法
- **WHEN** YAML 中某个 expect operator 的 match 不是可编译正则字符串empty/exists 不是布尔值,或 gt/gte/lt/lte 不是有限数字
- **THEN** 系统 SHALL 以错误退出,提示对应 operator 配置不合法
### Requirement: size 配置解析
#### Scenario: unknown 字段失败
- **WHEN** YAML 中任一结构化配置对象包含契约未声明的字段,且该对象不是明确允许动态键的对象
- **THEN** 系统 SHALL 以错误退出,提示未知字段所在路径
#### Scenario: 动态 headers 字段允许
- **WHEN** YAML 中 `http.headers``defaults.http.headers``expect.headers` 包含任意 header 名称,且对应值符合契约
- **THEN** 系统 SHALL 接受这些动态 header 名称
#### Scenario: 动态 env 字段允许
- **WHEN** YAML 中 `command.env` 包含任意环境变量名称,且对应值为字符串
- **THEN** 系统 SHALL 接受这些动态 env 名称
#### Scenario: JSON Schema 不修改输入
- **WHEN** 系统执行 JSON Schema 契约校验
- **THEN** 系统 MUST NOT 通过契约校验器强制转换类型、注入默认值或删除未知字段
#### Scenario: 配置生命周期分离
- **WHEN** 系统加载配置文件
- **THEN** 系统 SHALL 按 `unknown -> RawProbeConfig -> ValidatedProbeConfig -> ResolvedConfig` 的顺序执行契约校验、语义校验和运行期配置解析
#### Scenario: 结构化校验 issue
- **WHEN** 契约校验或语义 validator 发现非法配置
- **THEN** 系统 SHALL 先生成包含 code、path、message 和可选 targetName 的结构化 `ConfigValidationIssue`,再统一渲染为中文错误
#### Scenario: 导出配置 JSON Schema
- **WHEN** 仓库生成或检查配置契约
- **THEN** 根目录 SHALL 存在 draft-07 `probe-config.schema.json`,且其内容 SHALL 与当前公共 fragments 和已注册 checker fragments 组装出的完整 schema 一致
系统 SHALL 支持使用单位字符串配置读取上限,单位包括 `B``KB``MB``GB`
#### Scenario: 解析 MB
@@ -232,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"`

View File

@@ -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 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 序列化
- **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 不受影响

View File

@@ -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 不注册清理定时器

View File

@@ -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 之外。

View File

@@ -9,7 +9,9 @@
"build": "bun run scripts/build.ts",
"lint": "eslint .",
"format": "prettier . --write",
"check": "bun run typecheck && bun run lint && bun test",
"schema": "bun run scripts/generate-config-schema.ts",
"schema:check": "bun run scripts/generate-config-schema.ts --check",
"check": "bun run schema:check && bun run typecheck && bun run lint && bun test",
"verify": "bun run check && bun run build && bun run test:smoke",
"test": "bun test",
"test:smoke": "bun run scripts/smoke.ts",
@@ -42,8 +44,10 @@
"vite": "^8.0.11"
},
"dependencies": {
"@sinclair/typebox": "^0.34.49",
"@tanstack/react-query": "^5.100.10",
"@xmldom/xmldom": "^0.9.10",
"ajv": "^8.20.0",
"cheerio": "^1.2.0",
"es-toolkit": "^1.46.1",
"react": "^19.2.6",

727
probe-config.schema.json Normal file
View File

@@ -0,0 +1,727 @@
{
"additionalProperties": false,
"type": "object",
"required": [
"targets"
],
"properties": {
"defaults": {
"additionalProperties": false,
"type": "object",
"properties": {
"interval": {
"type": "string"
},
"timeout": {
"type": "string"
},
"http": {
"additionalProperties": false,
"type": "object",
"properties": {
"headers": {
"additionalProperties": {
"type": "string"
},
"type": "object"
},
"maxBodyBytes": {
"anyOf": [
{
"type": "string"
},
{
"minimum": 0,
"type": "integer"
}
]
},
"method": {
"anyOf": [
{
"const": "DELETE",
"type": "string"
},
{
"const": "GET",
"type": "string"
},
{
"const": "HEAD",
"type": "string"
},
{
"const": "OPTIONS",
"type": "string"
},
{
"const": "PATCH",
"type": "string"
},
{
"const": "POST",
"type": "string"
},
{
"const": "PUT",
"type": "string"
}
]
}
}
},
"command": {
"additionalProperties": false,
"type": "object",
"properties": {
"cwd": {
"type": "string"
},
"maxOutputBytes": {
"anyOf": [
{
"type": "string"
},
{
"minimum": 0,
"type": "integer"
}
]
}
}
}
}
},
"runtime": {
"additionalProperties": false,
"type": "object",
"properties": {
"maxConcurrentChecks": {
"minimum": 1,
"type": "integer"
},
"retention": {
"type": "string"
}
}
},
"server": {
"additionalProperties": false,
"type": "object",
"properties": {
"dataDir": {
"type": "string"
},
"host": {
"type": "string"
},
"port": {
"maximum": 65535,
"minimum": 0,
"type": "integer"
}
}
},
"targets": {
"minItems": 1,
"type": "array",
"items": {
"anyOf": [
{
"additionalProperties": false,
"type": "object",
"required": [
"name",
"type",
"http"
],
"properties": {
"expect": {
"additionalProperties": false,
"type": "object",
"properties": {
"body": {
"type": "array",
"items": {
"additionalProperties": false,
"type": "object",
"properties": {
"contains": {
"type": "string"
},
"css": {
"additionalProperties": false,
"type": "object",
"required": [
"selector"
],
"properties": {
"attr": {
"type": "string"
},
"selector": {
"minLength": 1,
"type": "string"
},
"contains": {
"type": "string"
},
"empty": {
"type": "boolean"
},
"equals": {
"anyOf": [
{
"type": "string"
},
{
"type": "number"
},
{
"type": "boolean"
},
{
"type": "null"
},
{
"items": {},
"type": "array"
},
{
"additionalProperties": {},
"type": "object"
}
]
},
"exists": {
"type": "boolean"
},
"gt": {
"type": "number"
},
"gte": {
"type": "number"
},
"lt": {
"type": "number"
},
"lte": {
"type": "number"
},
"match": {
"type": "string"
}
}
},
"json": {
"additionalProperties": false,
"type": "object",
"required": [
"path"
],
"properties": {
"path": {
"type": "string"
},
"contains": {
"type": "string"
},
"empty": {
"type": "boolean"
},
"equals": {
"anyOf": [
{
"type": "string"
},
{
"type": "number"
},
{
"type": "boolean"
},
{
"type": "null"
},
{
"items": {},
"type": "array"
},
{
"additionalProperties": {},
"type": "object"
}
]
},
"exists": {
"type": "boolean"
},
"gt": {
"type": "number"
},
"gte": {
"type": "number"
},
"lt": {
"type": "number"
},
"lte": {
"type": "number"
},
"match": {
"type": "string"
}
}
},
"regex": {
"type": "string"
},
"xpath": {
"additionalProperties": false,
"type": "object",
"required": [
"path"
],
"properties": {
"path": {
"minLength": 1,
"type": "string"
},
"contains": {
"type": "string"
},
"empty": {
"type": "boolean"
},
"equals": {
"anyOf": [
{
"type": "string"
},
{
"type": "number"
},
{
"type": "boolean"
},
{
"type": "null"
},
{
"items": {},
"type": "array"
},
{
"additionalProperties": {},
"type": "object"
}
]
},
"exists": {
"type": "boolean"
},
"gt": {
"type": "number"
},
"gte": {
"type": "number"
},
"lt": {
"type": "number"
},
"lte": {
"type": "number"
},
"match": {
"type": "string"
}
}
}
}
}
},
"headers": {
"additionalProperties": {
"anyOf": [
{
"type": "string"
},
{
"additionalProperties": false,
"minProperties": 1,
"type": "object",
"properties": {
"contains": {
"type": "string"
},
"empty": {
"type": "boolean"
},
"equals": {
"anyOf": [
{
"type": "string"
},
{
"type": "number"
},
{
"type": "boolean"
},
{
"type": "null"
},
{
"items": {},
"type": "array"
},
{
"additionalProperties": {},
"type": "object"
}
]
},
"exists": {
"type": "boolean"
},
"gt": {
"type": "number"
},
"gte": {
"type": "number"
},
"lt": {
"type": "number"
},
"lte": {
"type": "number"
},
"match": {
"type": "string"
}
}
}
]
},
"type": "object"
},
"maxDurationMs": {
"minimum": 0,
"type": "number"
},
"status": {
"type": "array",
"items": {
"anyOf": [
{
"maximum": 599,
"minimum": 100,
"type": "integer"
},
{
"pattern": "^[1-5]xx$",
"type": "string"
}
]
}
}
}
},
"group": {
"type": "string"
},
"interval": {
"type": "string"
},
"name": {
"minLength": 1,
"type": "string"
},
"timeout": {
"type": "string"
},
"type": {
"const": "http",
"type": "string"
},
"http": {
"additionalProperties": false,
"type": "object",
"required": [
"url"
],
"properties": {
"body": {
"type": "string"
},
"headers": {
"additionalProperties": {
"type": "string"
},
"type": "object"
},
"ignoreSSL": {
"type": "boolean"
},
"maxBodyBytes": {
"anyOf": [
{
"type": "string"
},
{
"minimum": 0,
"type": "integer"
}
]
},
"maxRedirects": {
"minimum": 0,
"type": "integer"
},
"method": {
"anyOf": [
{
"const": "DELETE",
"type": "string"
},
{
"const": "GET",
"type": "string"
},
{
"const": "HEAD",
"type": "string"
},
{
"const": "OPTIONS",
"type": "string"
},
{
"const": "PATCH",
"type": "string"
},
{
"const": "POST",
"type": "string"
},
{
"const": "PUT",
"type": "string"
}
]
},
"url": {
"minLength": 1,
"type": "string"
}
}
}
}
},
{
"additionalProperties": false,
"type": "object",
"required": [
"name",
"type",
"command"
],
"properties": {
"expect": {
"additionalProperties": false,
"type": "object",
"properties": {
"exitCode": {
"type": "array",
"items": {
"type": "integer"
}
},
"maxDurationMs": {
"minimum": 0,
"type": "number"
},
"stderr": {
"type": "array",
"items": {
"additionalProperties": false,
"minProperties": 1,
"type": "object",
"properties": {
"contains": {
"type": "string"
},
"empty": {
"type": "boolean"
},
"equals": {
"anyOf": [
{
"type": "string"
},
{
"type": "number"
},
{
"type": "boolean"
},
{
"type": "null"
},
{
"items": {},
"type": "array"
},
{
"additionalProperties": {},
"type": "object"
}
]
},
"exists": {
"type": "boolean"
},
"gt": {
"type": "number"
},
"gte": {
"type": "number"
},
"lt": {
"type": "number"
},
"lte": {
"type": "number"
},
"match": {
"type": "string"
}
}
}
},
"stdout": {
"type": "array",
"items": {
"additionalProperties": false,
"minProperties": 1,
"type": "object",
"properties": {
"contains": {
"type": "string"
},
"empty": {
"type": "boolean"
},
"equals": {
"anyOf": [
{
"type": "string"
},
{
"type": "number"
},
{
"type": "boolean"
},
{
"type": "null"
},
{
"items": {},
"type": "array"
},
{
"additionalProperties": {},
"type": "object"
}
]
},
"exists": {
"type": "boolean"
},
"gt": {
"type": "number"
},
"gte": {
"type": "number"
},
"lt": {
"type": "number"
},
"lte": {
"type": "number"
},
"match": {
"type": "string"
}
}
}
}
}
},
"group": {
"type": "string"
},
"interval": {
"type": "string"
},
"name": {
"minLength": 1,
"type": "string"
},
"timeout": {
"type": "string"
},
"type": {
"const": "command",
"type": "string"
},
"command": {
"additionalProperties": false,
"type": "object",
"required": [
"exec"
],
"properties": {
"args": {
"type": "array",
"items": {
"type": "string"
}
},
"cwd": {
"type": "string"
},
"env": {
"additionalProperties": {
"type": "string"
},
"type": "object"
},
"exec": {
"minLength": 1,
"type": "string"
},
"maxOutputBytes": {
"anyOf": [
{
"type": "string"
},
{
"minimum": 0,
"type": "integer"
}
]
}
}
}
}
}
]
}
}
},
"$id": "https://dial.local/probe-config.schema.json",
"$schema": "http://json-schema.org/draft-07/schema#",
"definitions": {}
}

View File

@@ -107,32 +107,13 @@ ${assetEntries.join("\n")}
async function writeGeneratedEntry() {
await writeFile(
generatedEntryPath,
`import { loadConfig } from "../src/server/checker/config-loader";
import { ProbeStore } from "../src/server/checker/store";
import { ProbeEngine } from "../src/server/checker/engine";
import { startServer } from "../src/server/server";
`import { bootstrap } from "../src/server/bootstrap";
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);
engine.start();
startServer({
config: { host: config.host, port: config.port },
mode: "production",
staticAssets,
store,
});
await bootstrap({ configPath, mode: "production", staticAssets });
}
void main().catch((error) => {

View File

@@ -0,0 +1,16 @@
import { createDefaultCheckerRegistry } from "../src/server/checker/runner";
import { createProbeConfigJsonSchema } from "../src/server/checker/schema/export";
const schemaPath = "probe-config.schema.json";
const schema = `${JSON.stringify(createProbeConfigJsonSchema(createDefaultCheckerRegistry()), null, 2)}\n`;
if (process.argv.includes("--check")) {
const existing = await Bun.file(schemaPath)
.text()
.catch(() => null);
if (existing !== schema) {
throw new Error(`${schemaPath} 未同步,请运行 bun run schema`);
}
} else {
await Bun.write(schemaPath, schema);
}

83
src/server/bootstrap.ts Normal file
View File

@@ -0,0 +1,83 @@
import { join } from "node:path";
import type { RuntimeMode } from "../shared/api";
import type { StaticAssets } from "./app";
import type { StartServerOptions } from "./server";
import { loadConfig, type ResolvedConfig } from "./checker/config-loader";
import { ProbeEngine } from "./checker/engine";
import { ProbeStore } from "./checker/store";
import { startServer } from "./server";
export interface BootstrapDependencies {
createEngine?: (
store: ProbeStore,
targets: ResolvedConfig["targets"],
maxConcurrentChecks: number,
retentionMs: number,
) => BootstrapEngine;
createStore?: (dbPath: string) => ProbeStore;
exit?: (code: number) => never;
loadConfig?: (configPath: string) => Promise<ResolvedConfig>;
logError?: (...data: unknown[]) => void;
onSignal?: (signal: ShutdownSignal, handler: () => void) => void;
startServer?: (options: StartServerOptions) => unknown;
}
export interface BootstrapOptions {
configPath: string;
mode: RuntimeMode;
staticAssets?: StaticAssets;
}
type BootstrapEngine = Pick<ProbeEngine, "start" | "stop">;
type ShutdownSignal = "SIGINT" | "SIGTERM";
export async function bootstrap(options: BootstrapOptions, dependencies: BootstrapDependencies = {}): Promise<void> {
const load = dependencies.loadConfig ?? loadConfig;
const createStore = dependencies.createStore ?? ((dbPath: string) => new ProbeStore(dbPath));
const createEngine =
dependencies.createEngine ??
((store: ProbeStore, targets: ResolvedConfig["targets"], maxConcurrentChecks: number, retentionMs: number) =>
new ProbeEngine(store, targets, maxConcurrentChecks, retentionMs));
const serve = dependencies.startServer ?? startServer;
const onSignal =
dependencies.onSignal ??
((signal: ShutdownSignal, handler: () => void) => {
process.on(signal, handler);
});
const exit = dependencies.exit ?? ((code: number) => process.exit(code));
const logError = dependencies.logError ?? console.error;
let store: ProbeStore | undefined;
let engine: BootstrapEngine | undefined;
try {
const config = await load(options.configPath);
store = createStore(join(config.dataDir, "probe.db"));
store.syncTargets(config.targets);
engine = createEngine(store, config.targets, config.maxConcurrentChecks, config.retentionMs);
engine.start();
const shutdown = () => {
engine?.stop();
store?.close();
exit(0);
};
onSignal("SIGINT", shutdown);
onSignal("SIGTERM", shutdown);
serve({
config: { host: config.host, port: config.port },
mode: options.mode,
staticAssets: options.staticAssets,
store,
});
} catch (error) {
engine?.stop();
store?.close();
logError("启动失败:", error instanceof Error ? error.message : error);
exit(1);
}
}

View File

@@ -1,8 +1,13 @@
import { dirname, resolve } from "node:path";
import type { DefaultsConfig, EngineRuntimeConfig, ProbeConfig, ResolvedTarget, TargetConfig } from "./types";
import type { ConfigValidationIssue } from "./schema/issues";
import type { DefaultsConfig, EngineRuntimeConfig, RawTargetConfig, ResolvedTargetBase } from "./types";
import { checkerRegistry } from "./runner";
import { issue, throwConfigIssues } from "./schema/issues";
import { asValidatedConfig, type RawProbeConfig } from "./schema/types";
import { validateProbeConfigContract } from "./schema/validate";
import { parseDuration } from "./utils";
const DEFAULT_HOST = "127.0.0.1";
const DEFAULT_PORT = 3000;
@@ -10,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;
@@ -17,7 +23,8 @@ export interface ResolvedConfig {
host: string;
maxConcurrentChecks: number;
port: number;
targets: ResolvedTarget[];
retentionMs: number;
targets: ResolvedTargetBase[];
}
export async function loadConfig(configPath: string): Promise<ResolvedConfig> {
@@ -28,45 +35,102 @@ export async function loadConfig(configPath: string): Promise<ResolvedConfig> {
}
const content = await file.text();
const raw = Bun.YAML.parse(content) as null | ProbeConfig;
const parsed = Bun.YAML.parse(content);
if (!raw) {
if (!parsed) {
throw new Error("配置文件内容为空或格式无效");
}
validateConfig(raw);
const contractResult = validateProbeConfigContract(parsed, checkerRegistry);
if (contractResult.config === null && !canRunSemanticValidation(parsed)) {
throwConfigIssues(contractResult.issues);
}
const semanticInput = (contractResult.config ?? parsed) as RawProbeConfig;
const validationIssues = validateConfig(semanticInput);
const allIssues = [...contractResult.issues, ...validationIssues];
if (contractResult.config === null) {
if (allIssues.length > 0) {
throwConfigIssues(dedupeIssues(allIssues));
}
throw new Error("配置文件内容为空或格式无效");
}
const raw = contractResult.config;
const validated = asValidatedConfig(raw);
const configDir = dirname(resolve(configPath));
const server = raw.server ?? {};
const runtime = raw.runtime ?? {};
const defaults = raw.defaults ?? {};
const server = validated.server ?? {};
const runtime = validated.runtime ?? {};
const defaults = validated.defaults ?? {};
const host = server.host ?? DEFAULT_HOST;
const port = server.port ?? DEFAULT_PORT;
const dataDir = server.dataDir ?? DEFAULT_DATA_DIR;
const dataDir = resolve(configDir, server.dataDir ?? DEFAULT_DATA_DIR);
if (!Number.isInteger(port) || port < 0 || port > 65535) {
throw new Error(`无效端口号: ${port},需要 0-65535 之间的整数`);
const maxConcurrentChecks = resolveMaxConcurrentChecks(runtime);
const retentionMs = resolveRetention(runtime);
const allRuntimeIssues = [...allIssues];
if (allRuntimeIssues.length > 0) {
throwConfigIssues(dedupeIssues(allRuntimeIssues));
}
const maxConcurrentChecks = validateRuntime(runtime);
const defaultIntervalMs = parseDuration(defaults.interval ?? DEFAULT_INTERVAL);
const defaultTimeoutMs = parseDuration(defaults.timeout ?? DEFAULT_TIMEOUT);
const targets: ResolvedTarget[] = raw.targets.map((target) =>
const targets: ResolvedTargetBase[] = validated.targets.map((target) =>
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 {
return typeof value === "object" && value !== null;
}
function dedupeIssues(issues: ConfigValidationIssue[]): ConfigValidationIssue[] {
const seen = new Set<string>();
const result: ConfigValidationIssue[] = [];
for (const item of issues) {
const key = `${item.code}:${item.path}:${item.message}:${item.targetName ?? ""}`;
if (seen.has(key)) continue;
seen.add(key);
result.push(item);
}
return result;
}
function isRecord(value: unknown): value is Record<string, unknown> {
return typeof value === "object" && value !== null && !Array.isArray(value);
}
export { parseDuration } from "./utils";
function resolveMaxConcurrentChecks(runtime: EngineRuntimeConfig): number {
if (runtime.maxConcurrentChecks === undefined) return DEFAULT_MAX_CONCURRENT_CHECKS;
if (
typeof runtime.maxConcurrentChecks !== "number" ||
!Number.isInteger(runtime.maxConcurrentChecks) ||
runtime.maxConcurrentChecks <= 0
)
return DEFAULT_MAX_CONCURRENT_CHECKS;
return runtime.maxConcurrentChecks;
}
function resolveRetention(runtime: EngineRuntimeConfig): number {
return parseDuration(runtime.retention ?? DEFAULT_RETENTION);
}
function resolveTarget(
target: TargetConfig,
target: RawTargetConfig,
defaults: DefaultsConfig,
defaultIntervalMs: number,
defaultTimeoutMs: number,
configDir: string,
): ResolvedTarget {
): ResolvedTargetBase {
const intervalMs = parseDuration(target.interval ?? defaults.interval ?? DEFAULT_INTERVAL);
const timeoutMs = parseDuration(target.timeout ?? defaults.timeout ?? DEFAULT_TIMEOUT);
@@ -79,70 +143,100 @@ function resolveTarget(
return result;
}
function validateConfig(config: ProbeConfig): void {
if (!config.targets || !Array.isArray(config.targets) || config.targets.length === 0) {
throw new Error("配置文件必须包含至少一个 target");
function validateConfig(config: RawProbeConfig): ConfigValidationIssue[] {
const issues: ConfigValidationIssue[] = [];
if (!Array.isArray(config.targets) || config.targets.length === 0) {
issues.push(issue("required", "targets", "配置文件必须包含至少一个 target"));
return issues;
}
const names = new Set<string>();
const supportedTypes = checkerRegistry.supportedTypes;
for (let i = 0; i < config.targets.length; i++) {
const raw = config.targets[i] as unknown as Record<string, unknown>;
const rawTarget = config.targets[i] as unknown;
if (!isRecord(rawTarget)) {
issues.push(issue("invalid-type", `targets[${i}]`, "必须为对象"));
continue;
}
const raw = rawTarget;
const name = raw["name"];
if (!name || typeof name !== "string" || name.trim() === "") {
throw new Error(`${i + 1} 个 target 缺少 name 字段`);
issues.push(issue("required", `targets[${i}].name`, "缺少 name 字段"));
continue;
}
const type = raw["type"];
if (!type || typeof type !== "string") {
throw new Error(`target "${name}" 缺少 type 字段`);
issues.push(issue("required", `targets[${i}].type`, "缺少 type 字段", name));
continue;
}
if (!supportedTypes.includes(type)) {
throw new Error(`target "${name}" 使用不支持的 type: "${type}",支持: ${supportedTypes.join(", ")}`);
issues.push(
issue(
"unsupported-type",
`targets[${i}].type`,
`使用不支持的 type: "${type}",支持: ${supportedTypes.join(", ")}`,
name,
),
);
}
const group = raw["group"];
if (group !== undefined && typeof group !== "string") {
throw new Error(`target "${name}" 的 group 字段必须为字符串`);
issues.push(issue("invalid-type", `targets[${i}].group`, "必须为字符串", name));
}
if (names.has(name)) {
throw new Error(`target name 重复: "${name}"`);
issues.push(issue("duplicate-name", `targets[${i}].name`, `target name 重复: "${name}"`, name));
}
names.add(name);
}
}
function validateRuntime(runtime: EngineRuntimeConfig): number {
if (runtime.maxConcurrentChecks === undefined) return DEFAULT_MAX_CONCURRENT_CHECKS;
if (
typeof runtime.maxConcurrentChecks !== "number" ||
!Number.isInteger(runtime.maxConcurrentChecks) ||
runtime.maxConcurrentChecks <= 0
) {
throw new Error("runtime.maxConcurrentChecks 必须为正整数");
for (const checker of checkerRegistry.definitions) {
issues.push(...checker.validate({ defaults: config.defaults ?? {}, targets: config.targets }));
}
return runtime.maxConcurrentChecks;
}
const DURATION_REGEX = /^(\d+(?:\.\d+)?)(ms|s|m)$/;
export function parseDuration(value: string): number {
const match = DURATION_REGEX.exec(value);
if (!match) {
throw new Error(`无效的时长格式: "${value}",支持格式如 "30s"、"5m"、"500ms"`);
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;
const targetName = typeof target["name"] === "string" ? target["name"] : undefined;
validateDurationValue(
typeof target["interval"] === "string" ? target["interval"] : undefined,
`targets[${i}].interval`,
issues,
targetName,
);
validateDurationValue(
typeof target["timeout"] === "string" ? target["timeout"] : undefined,
`targets[${i}].timeout`,
issues,
targetName,
);
}
const num = parseFloat(match[1]!);
const unit = match[2]!;
if (unit === "ms") return num;
if (unit === "s") return num * 1000;
return num * 60 * 1000;
return issues;
}
function validateDurationValue(
value: string | undefined,
path: string,
issues: ConfigValidationIssue[],
targetName?: string,
): void {
if (value === undefined) return;
try {
parseDuration(value);
} catch (error) {
issues.push(issue("invalid-duration", path, error instanceof Error ? error.message : "时长格式不合法", targetName));
}
}

View File

@@ -1,21 +1,26 @@
import { groupBy, Semaphore } from "es-toolkit";
import { groupBy, isError, Semaphore } from "es-toolkit";
import type { ProbeStore } from "./store";
import type { CheckResult, ResolvedTarget } from "./types";
import type { CheckResult, ResolvedTargetBase } from "./types";
import { errorFailure } from "./expect/failure";
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: ResolvedTarget[];
private targets: ResolvedTargetBase[];
private timers: Array<ReturnType<typeof setInterval>> = [];
constructor(store: ProbeStore, targets: ResolvedTarget[], 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 +36,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 {
@@ -40,7 +53,7 @@ export class ProbeEngine {
this.timers = [];
}
private async probeGroup(targets: ResolvedTarget[]): Promise<void> {
private async probeGroup(targets: ResolvedTargetBase[]): Promise<void> {
const results = await Promise.allSettled(
targets.map(async (target) => {
await this.semaphore.acquire();
@@ -52,11 +65,21 @@ export class ProbeEngine {
}),
);
for (const result of results) {
for (const [index, result] of results.entries()) {
if (result.status === "fulfilled") {
this.writeResult(result.value);
} else {
const target = targets[index];
console.warn("探针执行失败:", result.reason);
if (!target) continue;
this.writeResult({
durationMs: null,
failure: errorFailure("internal", "engine", formatReason(result.reason)),
matched: false,
statusDetail: null,
targetName: target.name,
timestamp: new Date().toISOString(),
});
}
}
}
@@ -68,7 +91,7 @@ export class ProbeEngine {
}
}
private async runCheck(target: ResolvedTarget): Promise<CheckResult> {
private async runCheck(target: ResolvedTargetBase): Promise<CheckResult> {
const checker = checkerRegistry.get(target.type);
const controller = new AbortController();
const timeoutId = setTimeout(() => controller.abort(), target.timeoutMs);
@@ -94,3 +117,7 @@ export class ProbeEngine {
});
}
}
function formatReason(reason: unknown): string {
return isError(reason) ? reason.message : String(reason);
}

View File

@@ -1,12 +1,7 @@
import type { CheckFailure } from "../../types";
import type { ExpectResult } from "./types";
import { mismatchFailure } from "./failure";
export interface ExpectResult {
failure: CheckFailure | null;
matched: boolean;
}
export function checkDuration(durationMs: number, maxDurationMs?: number): ExpectResult {
if (maxDurationMs === undefined) return { failure: null, matched: true };
if (durationMs > maxDurationMs) {

View File

@@ -1,4 +1,4 @@
import type { CheckFailure } from "../../types";
import type { CheckFailure } from "../types";
export function errorFailure(phase: CheckFailure["phase"], path: string, message: string): CheckFailure {
return {

View File

@@ -1,6 +1,8 @@
import { isEmptyObject, isEqual, isNil, isPlainObject } from "es-toolkit";
import type { ExpectOperator, ExpectValue } from "../../types";
import type { ExpectOperator, ExpectValue } from "../types";
const OPERATOR_KEYS = new Set(["contains", "empty", "equals", "exists", "gt", "gte", "lt", "lte", "match"]);
export function applyOperator(actual: unknown, op: ExpectOperator): boolean {
for (const [key, expected] of Object.entries(op)) {
@@ -48,10 +50,10 @@ export function applyOperator(actual: unknown, op: ExpectOperator): boolean {
}
export function checkExpectValue(actual: unknown, expected: ExpectValue): boolean {
if (isPlainObject(expected)) {
return applyOperator(actual, expected);
if (isPlainObject(expected) && Object.keys(expected).some((key) => OPERATOR_KEYS.has(key))) {
return applyOperator(actual, expected as ExpectOperator);
}
return applyOperator(actual, { equals: expected });
return applyOperator(actual, { equals: expected as Exclude<ExpectValue, ExpectOperator> });
}
export function evaluateJsonPath(json: unknown, path: string): unknown {

View File

@@ -0,0 +1,6 @@
import type { CheckFailure } from "../types";
export interface ExpectResult {
failure: CheckFailure | null;
matched: boolean;
}

View File

@@ -0,0 +1,80 @@
import type { ConfigValidationIssue } from "../schema/issues";
import type { JsonValue } from "../types";
import { OperatorKeys } from "../schema/fragments";
import { issue, joinPath } from "../schema/issues";
const OPERATOR_KEY_SET = new Set<string>(OperatorKeys);
export function isJsonValue(value: unknown): value is JsonValue {
if (value === null) return true;
if (typeof value === "string" || typeof value === "boolean") return true;
if (typeof value === "number") return Number.isFinite(value);
if (Array.isArray(value)) return value.every(isJsonValue);
if (typeof value === "object") {
return Object.values(value as Record<string, unknown>).every(isJsonValue);
}
return false;
}
export function isPlainRecord(value: unknown): value is Record<string, unknown> {
return typeof value === "object" && value !== null && !Array.isArray(value);
}
export function validateOperatorObject(
operators: unknown,
path: string,
targetName?: string,
options: { requireAtLeastOne: boolean } = { requireAtLeastOne: true },
): ConfigValidationIssue[] {
if (!isPlainRecord(operators)) return [issue("invalid-type", path, "必须为操作符对象", targetName)];
const issues: ConfigValidationIssue[] = [];
let found = 0;
for (const [key, value] of Object.entries(operators)) {
if (!OPERATOR_KEY_SET.has(key)) {
issues.push(issue("unknown-operator", joinPath(path, key), "是未知 operator", targetName));
continue;
}
if (value === undefined) continue;
found++;
issues.push(...validateOperatorValue(key, value, joinPath(path, key), targetName));
}
if (options.requireAtLeastOne && found === 0) {
issues.push(issue("empty-operator", path, "必须包含至少一个合法 operator", targetName));
}
return issues;
}
export function validateOperatorValue(
key: string,
value: unknown,
path: string,
targetName?: string,
): ConfigValidationIssue[] {
switch (key) {
case "contains":
return typeof value === "string" ? [] : [issue("invalid-type", path, "必须为字符串", targetName)];
case "empty":
case "exists":
return typeof value === "boolean" ? [] : [issue("invalid-type", path, "必须为布尔值", targetName)];
case "equals":
return isJsonValue(value) ? [] : [issue("invalid-type", path, "必须为 JSON value", targetName)];
case "gt":
case "gte":
case "lt":
case "lte":
return typeof value === "number" && Number.isFinite(value)
? []
: [issue("invalid-type", path, "必须为有限数字", targetName)];
case "match":
if (typeof value !== "string") return [issue("invalid-type", path, "必须为字符串", targetName)];
try {
new RegExp(value);
return [];
} catch {
return [issue("invalid-regex", path, "正则不合法", targetName)];
}
default:
return [issue("unknown-operator", path, "是未知 operator", targetName)];
}
}

View File

@@ -1,26 +1,26 @@
import { isError } from "es-toolkit";
import { resolve } from "node:path";
import type {
CheckResult,
CommandTargetConfig,
ResolvedCommandTarget,
ResolvedTarget,
TargetConfig,
} from "../../types";
import type { Checker, CheckerContext, ResolveContext } from "../types";
import type { CheckResult, RawTargetConfig } from "../../types";
import type { CheckerContext, CheckerDefinition, CheckerValidationInput, ResolveContext } from "../types";
import type { CommandExpectConfig, CommandTargetConfig, ResolvedCommandTarget } from "./types";
import { parseSize } from "../../size";
import { checkDuration } from "../shared/duration";
import { errorFailure } from "../shared/failure";
import { checkTextRules } from "../shared/text";
import { checkDuration } from "../../expect/duration";
import { errorFailure } from "../../expect/failure";
import { parseSize } from "../../utils";
import { checkExitCode } from "./expect";
import { commandCheckerSchemas } from "./schema";
import { checkTextRules } from "./text";
import { validateCommandConfig } from "./validate";
export class CommandChecker implements CheckerDefinition<ResolvedCommandTarget> {
readonly configKey = "command";
readonly schemas = commandCheckerSchemas;
export class CommandChecker implements Checker {
readonly type = "command";
async execute(target: ResolvedTarget, ctx: CheckerContext): Promise<CheckResult> {
const t = target as ResolvedCommandTarget;
async execute(t: ResolvedCommandTarget, ctx: CheckerContext): Promise<CheckResult> {
const timestamp = new Date().toISOString();
const start = performance.now();
@@ -168,13 +168,9 @@ export class CommandChecker implements Checker {
};
}
resolve(target: TargetConfig, context: ResolveContext): ResolvedTarget {
const t = target as TargetConfig & { command: CommandTargetConfig; type: "command" };
const commandDefaults = context.defaults.command;
if (!t.command.exec || t.command.exec.trim() === "") {
throw new Error(`target "${t.name}" 缺少 command.exec 字段`);
}
resolve(target: RawTargetConfig, context: ResolveContext): ResolvedCommandTarget {
const t = target as RawTargetConfig & { command: CommandTargetConfig; type: "command" };
const commandDefaults = context.defaults["command"] as undefined | { cwd?: string; maxOutputBytes?: string };
const cwd = t.command.cwd ?? commandDefaults?.cwd ?? ".";
const resolvedCwd = resolve(context.configDir, cwd);
@@ -191,7 +187,7 @@ export class CommandChecker implements Checker {
exec: t.command.exec,
maxOutputBytes,
},
expect: target.expect,
expect: target.expect as CommandExpectConfig | undefined,
group: target.group ?? "default",
intervalMs: context.defaultIntervalMs,
name: t.name,
@@ -200,8 +196,7 @@ export class CommandChecker implements Checker {
} satisfies ResolvedCommandTarget;
}
serialize(target: ResolvedTarget): { config: string; target: string } {
const t = target as ResolvedCommandTarget;
serialize(t: ResolvedCommandTarget): { config: string; target: string } {
const parts = [t.command.exec, ...t.command.args];
return {
config: JSON.stringify({
@@ -214,6 +209,10 @@ export class CommandChecker implements Checker {
target: `exec ${parts.join(" ")}`,
};
}
validate(input: CheckerValidationInput) {
return validateCommandConfig(input);
}
}
async function readOutput(

View File

@@ -1,6 +1,6 @@
import type { ExpectResult } from "../shared/duration";
import type { ExpectResult } from "../../expect/types";
import { mismatchFailure } from "../shared/failure";
import { mismatchFailure } from "../../expect/failure";
export function checkExitCode(exitCode: number, allowed: number[]): ExpectResult {
if (!allowed.includes(exitCode)) {

View File

@@ -0,0 +1 @@
export { CommandChecker } from "./execute";

View File

@@ -0,0 +1,34 @@
import { Type } from "@sinclair/typebox";
import type { CheckerSchemas } from "../types";
import { createTextRulesSchema, sizeSchema, stringMapSchema } from "../../schema/fragments";
export const commandCheckerSchemas: CheckerSchemas = {
config: Type.Object(
{
args: Type.Optional(Type.Array(Type.String())),
cwd: Type.Optional(Type.String()),
env: Type.Optional(stringMapSchema),
exec: Type.String({ minLength: 1 }),
maxOutputBytes: Type.Optional(sizeSchema),
},
{ additionalProperties: false },
),
defaults: Type.Object(
{
cwd: Type.Optional(Type.String()),
maxOutputBytes: Type.Optional(sizeSchema),
},
{ additionalProperties: false },
),
expect: Type.Object(
{
exitCode: Type.Optional(Type.Array(Type.Integer())),
maxDurationMs: Type.Optional(Type.Number({ minimum: 0 })),
stderr: Type.Optional(createTextRulesSchema()),
stdout: Type.Optional(createTextRulesSchema()),
},
{ additionalProperties: false },
),
};

View File

@@ -1,8 +1,8 @@
import type { TextRule } from "../../types";
import type { ExpectResult } from "./duration";
import type { ExpectResult } from "../../expect/types";
import type { TextRule } from "./types";
import { mismatchFailure } from "./failure";
import { applyOperator } from "./operator";
import { mismatchFailure } from "../../expect/failure";
import { applyOperator } from "../../expect/operator";
export function checkTextRules(text: string, rules: TextRule[], phase: string): ExpectResult {
for (let i = 0; i < rules.length; i++) {

View File

@@ -0,0 +1,41 @@
import type { ExpectOperator, ResolvedTargetBase } from "../../types";
export interface CommandDefaultsConfig {
cwd?: string;
maxOutputBytes?: string;
}
export interface CommandExpectConfig {
exitCode?: number[];
maxDurationMs?: number;
stderr?: TextRule[];
stdout?: TextRule[];
}
export interface CommandTargetConfig {
args?: string[];
cwd?: string;
env?: Record<string, string>;
exec: string;
maxOutputBytes?: string;
}
export interface ResolvedCommandConfig {
args: string[];
cwd: string;
env: Record<string, string>;
exec: string;
maxOutputBytes: number;
}
export interface ResolvedCommandTarget extends ResolvedTargetBase {
command: ResolvedCommandConfig;
expect?: CommandExpectConfig;
group: string;
intervalMs: number;
name: string;
timeoutMs: number;
type: "command";
}
export type TextRule = ExpectOperator;

View File

@@ -0,0 +1,98 @@
import type { ConfigValidationIssue } from "../../schema/issues";
import type { CheckerValidationInput } from "../types";
import { validateOperatorObject } from "../../expect/validate-operator";
import { issue, joinPath } from "../../schema/issues";
import { parseSize } from "../../utils";
export function validateCommandConfig(input: CheckerValidationInput): ConfigValidationIssue[] {
const issues: ConfigValidationIssue[] = [];
const defaults =
isRecord(input.defaults) && isRecord(input.defaults["command"]) ? input.defaults["command"] : undefined;
if (isSizeInput(defaults?.["maxOutputBytes"])) {
issues.push(...validateSizeValue(defaults["maxOutputBytes"], "defaults.command.maxOutputBytes"));
}
for (let i = 0; i < input.targets.length; i++) {
const target = input.targets[i] as unknown;
if (!isRecord(target)) continue;
if (target["type"] !== "command") continue;
issues.push(...validateCommandTarget(target, `targets[${i}]`));
}
return issues;
}
function getTargetName(target: Record<string, unknown>): string | undefined {
return typeof target["name"] === "string" ? target["name"] : undefined;
}
function isNonNegativeFiniteNumber(value: unknown): boolean {
return typeof value === "number" && Number.isFinite(value) && value >= 0;
}
function isRecord(value: unknown): value is Record<string, unknown> {
return typeof value === "object" && value !== null && !Array.isArray(value);
}
function isSizeInput(value: unknown): value is number | string {
return typeof value === "number" || typeof value === "string";
}
function validateCommandExpect(target: Record<string, unknown>, path: string): ConfigValidationIssue[] {
const targetName = getTargetName(target);
const expect = target["expect"];
if (expect === undefined || expect === null || !isRecord(expect)) return [];
const issues: ConfigValidationIssue[] = [];
const expectPath = joinPath(path, "expect");
if (expect["stdout"] !== undefined) {
issues.push(...validateTextRules(expect["stdout"], joinPath(expectPath, "stdout"), targetName));
}
if (expect["stderr"] !== undefined) {
issues.push(...validateTextRules(expect["stderr"], joinPath(expectPath, "stderr"), targetName));
}
if (expect["maxDurationMs"] !== undefined && !isNonNegativeFiniteNumber(expect["maxDurationMs"])) {
issues.push(issue("invalid-type", joinPath(expectPath, "maxDurationMs"), "必须为非负有限数字", targetName));
}
return issues;
}
function validateCommandTarget(target: Record<string, unknown>, path: string): ConfigValidationIssue[] {
const issues: ConfigValidationIssue[] = [];
const targetName = getTargetName(target);
const command = target["command"];
if (!isRecord(command)) {
issues.push(issue("required", joinPath(path, "command"), "缺少 command.exec 字段", targetName));
issues.push(...validateCommandExpect(target, path));
return issues;
}
if (typeof command["exec"] !== "string" || command["exec"].trim() === "") {
issues.push(issue("required", joinPath(joinPath(path, "command"), "exec"), "缺少 command.exec 字段", targetName));
}
if (isSizeInput(command["maxOutputBytes"])) {
issues.push(
...validateSizeValue(
command["maxOutputBytes"],
joinPath(joinPath(path, "command"), "maxOutputBytes"),
targetName,
),
);
}
issues.push(...validateCommandExpect(target, path));
return issues;
}
function validateSizeValue(value: number | string, path: string, targetName?: string): ConfigValidationIssue[] {
try {
parseSize(value);
return [];
} catch (error) {
return [issue("invalid-size", path, error instanceof Error ? error.message : "size 格式不合法", targetName)];
}
}
function validateTextRules(rules: unknown, path: string, targetName?: string): ConfigValidationIssue[] {
if (!Array.isArray(rules)) return [issue("invalid-type", path, "必须为数组", targetName)];
return rules.flatMap((rule, index) => validateOperatorObject(rule, `${path}[${index}]`, targetName));
}

View File

@@ -2,11 +2,11 @@ import { DOMParser } from "@xmldom/xmldom";
import * as cheerio from "cheerio";
import * as xpath from "xpath";
import type { BodyRule, CssRule, JsonRule, XpathRule } from "../../types";
import type { ExpectResult } from "./duration";
import type { ExpectResult } from "../../expect/types";
import type { BodyRule, CssRule, JsonRule, XpathRule } from "./types";
import { errorFailure, mismatchFailure } from "./failure";
import { applyOperator, evaluateJsonPath } from "./operator";
import { errorFailure, mismatchFailure } from "../../expect/failure";
import { applyOperator, evaluateJsonPath } from "../../expect/operator";
export function checkBodyExpect(body: string, rules?: BodyRule[]): ExpectResult {
if (!rules || rules.length === 0) return { failure: null, matched: true };

View File

@@ -1,26 +1,29 @@
import { isError } from "es-toolkit";
import type { CheckResult, HttpTargetConfig, ResolvedHttpTarget, ResolvedTarget, TargetConfig } from "../../types";
import type { Checker, CheckerContext, ResolveContext } from "../types";
import type { CheckResult, RawTargetConfig } from "../../types";
import type { CheckerContext, CheckerDefinition, CheckerValidationInput, ResolveContext } from "../types";
import type { HttpExpectConfig, HttpTargetConfig, ResolvedHttpTarget } from "./types";
import { parseSize } from "../../size";
import { checkBodyExpect } from "../shared/body";
import { checkDuration } from "../shared/duration";
import { errorFailure, mismatchFailure } from "../shared/failure";
import { checkDuration } from "../../expect/duration";
import { errorFailure, mismatchFailure } from "../../expect/failure";
import { parseSize } from "../../utils";
import { checkBodyExpect } from "./body";
import { checkHeaders, checkStatus } from "./expect";
import { validateHttpConfig, validateHttpExpect } from "./validate";
import { httpCheckerSchemas } from "./schema";
import { validateHttpConfig } from "./validate";
const ALLOWED_METHODS = new Set(["DELETE", "GET", "HEAD", "OPTIONS", "PATCH", "POST", "PUT"]);
const CHARSET_RE = /charset="?([^";\s]+)"?/i;
const URL_RE = /^https?:\/\/.+/;
const REDIRECT_STATUSES = new Set([301, 302, 303, 307, 308]);
const SENSITIVE_HEADERS = new Set(["authorization", "cookie"]);
export class HttpChecker implements Checker {
export class HttpChecker implements CheckerDefinition<ResolvedHttpTarget> {
readonly configKey = "http";
readonly schemas = httpCheckerSchemas;
readonly type = "http";
async execute(target: ResolvedTarget, ctx: CheckerContext): Promise<CheckResult> {
const t = target as ResolvedHttpTarget;
async execute(t: ResolvedHttpTarget, ctx: CheckerContext): Promise<CheckResult> {
const timestamp = new Date().toISOString();
const expect = t.expect;
const start = performance.now();
@@ -113,53 +116,17 @@ export class HttpChecker implements Checker {
}
}
resolve(target: TargetConfig, context: ResolveContext): ResolvedTarget {
const t = target as TargetConfig & { http: HttpTargetConfig; type: "http" };
const httpDefaults = context.defaults.http;
if (!t.http || typeof t.http !== "object") {
throw new Error(`target "${t.name}" 缺少 http.url 字段`);
}
validateHttpConfig(t.http, t.name);
if (typeof t.http.url !== "string" || t.http.url.trim() === "") {
throw new Error(`target "${t.name}" 缺少 http.url 字段`);
}
const rawMethod = t.http.method ?? httpDefaults?.method ?? "GET";
if (typeof rawMethod !== "string") {
throw new Error(`target "${t.name}" 的 http.method 必须为字符串`);
}
const method = rawMethod.toUpperCase();
if (!ALLOWED_METHODS.has(method)) {
throw new Error(
`target "${t.name}" 的 http.method "${method}" 不合法,合法值: ${[...ALLOWED_METHODS].join(", ")}`,
);
}
if (!URL_RE.test(t.http.url)) {
throw new Error(`target "${t.name}" 的 http.url "${t.http.url}" 格式不合法,必须以 http:// 或 https:// 开头`);
}
if (t.http.ignoreSSL !== undefined && typeof t.http.ignoreSSL !== "boolean") {
throw new Error(`target "${t.name}" 的 http.ignoreSSL 必须为布尔值`);
}
if (
t.http.maxRedirects !== undefined &&
(typeof t.http.maxRedirects !== "number" || !Number.isInteger(t.http.maxRedirects) || t.http.maxRedirects < 0)
) {
throw new Error(`target "${t.name}" 的 http.maxRedirects 必须为非负整数`);
}
validateHttpExpect(target.expect, t.name);
resolve(target: RawTargetConfig, context: ResolveContext): ResolvedHttpTarget {
const t = target as RawTargetConfig & { http: HttpTargetConfig; type: "http" };
const httpDefaults = context.defaults["http"] as
| undefined
| { headers?: Record<string, string>; maxBodyBytes?: string; method?: string };
const method = t.http.method ?? httpDefaults?.method ?? "GET";
const maxBodyBytes = parseSize(t.http.maxBodyBytes ?? httpDefaults?.maxBodyBytes ?? "100MB");
return {
expect: target.expect,
expect: target.expect as HttpExpectConfig | undefined,
group: target.group ?? "default",
http: {
body: t.http.body,
@@ -177,8 +144,7 @@ export class HttpChecker implements Checker {
} satisfies ResolvedHttpTarget;
}
serialize(target: ResolvedTarget): { config: string; target: string } {
const t = target as ResolvedHttpTarget;
serialize(t: ResolvedHttpTarget): { config: string; target: string } {
return {
config: JSON.stringify({
body: t.http.body,
@@ -192,6 +158,10 @@ export class HttpChecker implements Checker {
target: t.http.url,
};
}
validate(input: CheckerValidationInput) {
return validateHttpConfig(input);
}
}
function buildRedirectInit(init: RequestInit, statusCode: number, fromUrl: string, toUrl: string): RequestInit {

View File

@@ -1,10 +1,8 @@
import type { HeaderExpect, HttpExpectConfig } from "../../types";
import type { ExpectResult } from "../shared/duration";
import type { ExpectResult } from "../../expect/types";
import type { HeaderExpect } from "./types";
import { checkBodyExpect } from "../shared/body";
import { checkDuration } from "../shared/duration";
import { errorFailure, mismatchFailure } from "../shared/failure";
import { applyOperator } from "../shared/operator";
import { mismatchFailure } from "../../expect/failure";
import { applyOperator } from "../../expect/operator";
export function checkHeaders(
headers: Record<string, string>,
@@ -45,40 +43,6 @@ export function checkHeaders(
return { failure: null, matched: true };
}
export function checkHttpExpect(
statusCode: number,
headers: Record<string, string>,
body: null | string,
durationMs: number,
expect?: HttpExpectConfig,
): ExpectResult {
if (!expect) {
return checkStatus(statusCode, [200]);
}
const statusResult = checkStatus(statusCode, expect.status ?? [200]);
if (!statusResult.matched) return statusResult;
const headersResult = checkHeaders(headers, expect.headers);
if (!headersResult.matched) return headersResult;
if (expect.body && expect.body.length > 0) {
if (body === null) {
return {
failure: errorFailure("body", "body", "body is null but body rules are configured"),
matched: false,
};
}
const bodyResult = checkBodyExpect(body, expect.body);
if (!bodyResult.matched) return bodyResult;
}
const durationResult = checkDuration(durationMs, expect.maxDurationMs);
if (!durationResult.matched) return durationResult;
return { failure: null, matched: true };
}
export function checkStatus(statusCode: number, allowed: Array<number | string>): ExpectResult {
const matched = allowed.some((pattern) => {
if (typeof pattern === "number") return statusCode === pattern;

View File

@@ -0,0 +1 @@
export { HttpChecker } from "./execute";

View File

@@ -0,0 +1,44 @@
import { Type } from "@sinclair/typebox";
import type { CheckerSchemas } from "../types";
import {
createBodyRulesSchema,
createHeaderExpectSchema,
httpMethodSchema,
sizeSchema,
statusCodePatternSchema,
stringMapSchema,
} from "../../schema/fragments";
export const httpCheckerSchemas: CheckerSchemas = {
config: Type.Object(
{
body: Type.Optional(Type.String()),
headers: Type.Optional(stringMapSchema),
ignoreSSL: Type.Optional(Type.Boolean()),
maxBodyBytes: Type.Optional(sizeSchema),
maxRedirects: Type.Optional(Type.Integer({ minimum: 0 })),
method: Type.Optional(httpMethodSchema),
url: Type.String({ minLength: 1 }),
},
{ additionalProperties: false },
),
defaults: Type.Object(
{
headers: Type.Optional(stringMapSchema),
maxBodyBytes: Type.Optional(sizeSchema),
method: Type.Optional(httpMethodSchema),
},
{ additionalProperties: false },
),
expect: Type.Object(
{
body: Type.Optional(createBodyRulesSchema()),
headers: Type.Optional(createHeaderExpectSchema()),
maxDurationMs: Type.Optional(Type.Number({ minimum: 0 })),
status: Type.Optional(Type.Array(statusCodePatternSchema)),
},
{ additionalProperties: false },
),
};

View File

@@ -0,0 +1,59 @@
import type { ExpectOperator, ResolvedTargetBase } from "../../types";
export type BodyRule =
| { contains: string }
| { css: CssRule }
| { json: JsonRule }
| { regex: string }
| { xpath: XpathRule };
export type CssRule = ExpectOperator & { attr?: string; selector: string };
export type HeaderExpect = ExpectOperator | string;
export interface HttpDefaultsConfig {
headers?: Record<string, string>;
maxBodyBytes?: string;
method?: string;
}
export interface HttpExpectConfig {
body?: BodyRule[];
headers?: Record<string, HeaderExpect>;
maxDurationMs?: number;
status?: Array<number | string>;
}
export interface HttpTargetConfig {
body?: string;
headers?: Record<string, string>;
ignoreSSL?: boolean;
maxBodyBytes?: string;
maxRedirects?: number;
method?: string;
url: string;
}
export type JsonRule = ExpectOperator & { path: string };
export interface ResolvedHttpConfig {
body?: string;
headers: Record<string, string>;
ignoreSSL: boolean;
maxBodyBytes: number;
maxRedirects: number;
method: string;
url: string;
}
export interface ResolvedHttpTarget extends ResolvedTargetBase {
expect?: HttpExpectConfig;
group: string;
http: ResolvedHttpConfig;
intervalMs: number;
name: string;
timeoutMs: number;
type: "http";
}
export type XpathRule = ExpectOperator & { path: string };

View File

@@ -1,251 +1,276 @@
import { DOMParser } from "@xmldom/xmldom";
import * as xpath from "xpath";
const BODY_RULE_TYPES = ["contains", "regex", "json", "css", "xpath"];
import type { ConfigValidationIssue } from "../../schema/issues";
import type { CheckerValidationInput } from "../types";
const OPERATOR_KEYS = new Set(["contains", "empty", "equals", "exists", "gt", "gte", "lt", "lte", "match"]);
import { isPlainRecord, validateOperatorObject } from "../../expect/validate-operator";
import { BodyRuleTypeKeys, OperatorKeys } from "../../schema/fragments";
import { issue, joinPath } from "../../schema/issues";
import { parseSize } from "../../utils";
export function validateHttpConfig(http: unknown, targetName: string): void {
if (!http || typeof http !== "object") {
throw new Error(`target "${targetName}" 缺少 http 配置`);
}
const ALLOWED_PROTOCOLS = new Set(["http:", "https:"]);
const OPERATOR_KEY_SET = new Set<string>(OperatorKeys);
const h = http as Record<string, unknown>;
if ("headers" in h && h["headers"] !== undefined) {
if (typeof h["headers"] !== "object" || h["headers"] === null || Array.isArray(h["headers"])) {
throw new Error(`target "${targetName}" 的 http.headers 必须为对象`);
}
for (const [key, value] of Object.entries(h["headers"] as Record<string, unknown>)) {
if (typeof value !== "string") {
throw new Error(`target "${targetName}" 的 http.headers.${key} 必须为字符串`);
}
}
}
if ("body" in h && h["body"] !== undefined) {
if (typeof h["body"] !== "string") {
throw new Error(`target "${targetName}" 的 http.body 必须为字符串`);
}
}
export function validateBodyRules(body: unknown, path: string, targetName?: string): ConfigValidationIssue[] {
if (!Array.isArray(body)) return [issue("invalid-type", path, "必须为数组", targetName)];
return body.flatMap((rule, index) => validateSingleBodyRule(rule, `${path}[${index}]`, targetName));
}
export function validateHttpExpect(expect: unknown, targetName: string): void {
if (expect === undefined || expect === null) return;
if (typeof expect !== "object" || Array.isArray(expect)) {
throw new Error(`target "${targetName}" 的 expect 必须为对象`);
export function validateHttpConfig(input: CheckerValidationInput): ConfigValidationIssue[] {
const issues: ConfigValidationIssue[] = [];
const defaults =
isPlainRecord(input.defaults) && isPlainRecord(input.defaults["http"]) ? input.defaults["http"] : undefined;
if (isSizeInput(defaults?.["maxBodyBytes"])) {
issues.push(...validateSizeValue(defaults["maxBodyBytes"], "defaults.http.maxBodyBytes"));
}
const e = expect as Record<string, unknown>;
for (let i = 0; i < input.targets.length; i++) {
const target = input.targets[i] as unknown;
if (!isPlainRecord(target)) continue;
if (target["type"] !== "http") continue;
issues.push(...validateHttpTarget(target, `targets[${i}]`));
}
if ("status" in e) validateStatus(e["status"], targetName);
if ("maxDurationMs" in e) validateMaxDurationMs(e["maxDurationMs"], targetName);
if ("headers" in e) validateExpectHeaders(e["headers"], targetName);
if ("body" in e) validateBodyRules(e["body"], targetName);
return issues;
}
function validateBodyRules(body: unknown, targetName: string): void {
if (!Array.isArray(body)) {
throw new Error(`target "${targetName}" 的 expect.body 必须为数组`);
export function validateJsonPath(path: string, rulePath: string, targetName?: string): ConfigValidationIssue[] {
if (!path.startsWith("$.") || path.length <= 2) {
return [issue("invalid-jsonpath", joinPath(rulePath, "path"), '必须为以 "$." 开头的有效 JSONPath', targetName)];
}
for (let i = 0; i < body.length; i++) {
validateSingleBodyRule(body[i], i, targetName);
}
}
function validateExpectHeaders(headers: unknown, targetName: string): void {
if (typeof headers !== "object" || headers === null || Array.isArray(headers)) {
throw new Error(`target "${targetName}" 的 expect.headers 必须为对象`);
}
for (const [key, value] of Object.entries(headers as Record<string, unknown>)) {
if (typeof value === "string") continue;
if (typeof value === "object" && value !== null && !Array.isArray(value)) {
validateOperators(value as Record<string, unknown>, targetName, `expect.headers.${key}`);
} else {
throw new Error(`target "${targetName}" 的 expect.headers.${key} 必须为字符串或操作符对象`);
}
}
}
function validateJsonPath(path: string, targetName: string, rulePath: string): void {
const issues: ConfigValidationIssue[] = [];
const segments = path.slice(2).split(".");
for (const seg of segments) {
if (seg === "") {
throw new Error(`target "${targetName}" 的 ${rulePath}.path 包含空段`);
issues.push(issue("invalid-jsonpath", joinPath(rulePath, "path"), "包含空段", targetName));
}
const bracketMatch = /^(.+?)\[(\d+)\]$/.exec(seg);
if (bracketMatch?.[1]!.trim() === "") {
throw new Error(`target "${targetName}" 的 ${rulePath}.path 数组访问缺少属性名`);
issues.push(issue("invalid-jsonpath", joinPath(rulePath, "path"), "数组访问缺少属性名", targetName));
}
}
return issues;
}
function validateMaxDurationMs(value: unknown, targetName: string): void {
if (typeof value !== "number" || !Number.isFinite(value) || value < 0) {
throw new Error(`target "${targetName}" 的 expect.maxDurationMs 必须为非负有限数字`);
function collectOperatorObject(
object: Record<string, unknown>,
allowedKeys: Set<string>,
path: string,
targetName?: string,
): { issues: ConfigValidationIssue[]; operators: Record<string, unknown> } {
const issues: ConfigValidationIssue[] = [];
const operators: Record<string, unknown> = {};
for (const [key, value] of Object.entries(object)) {
if (allowedKeys.has(key)) continue;
if (OPERATOR_KEY_SET.has(key)) {
operators[key] = value;
} else {
issues.push(issue("unknown-field", joinPath(path, key), "是未知字段", targetName));
}
}
return { issues, operators };
}
function validateOperators(ops: Record<string, unknown>, targetName: string, path: string): void {
for (const [key, value] of Object.entries(ops)) {
if (!OPERATOR_KEYS.has(key)) continue;
switch (key) {
case "contains":
if (typeof value !== "string") {
throw new Error(`target "${targetName}" 的 ${path}.contains 必须为字符串`);
function getTargetName(target: Record<string, unknown>): string | undefined {
return typeof target["name"] === "string" ? target["name"] : undefined;
}
function isNonNegativeFiniteNumber(value: unknown): boolean {
return typeof value === "number" && Number.isFinite(value) && value >= 0;
}
function isSizeInput(value: unknown): value is number | string {
return typeof value === "number" || typeof value === "string";
}
function validateCssRule(rule: unknown, path: string, targetName?: string): ConfigValidationIssue[] {
if (!isPlainRecord(rule)) return [issue("invalid-type", path, "必须为对象", targetName)];
const issues: ConfigValidationIssue[] = [];
if (typeof rule["selector"] !== "string" || rule["selector"].trim() === "") {
issues.push(issue("invalid-type", joinPath(path, "selector"), "必须为非空字符串", targetName));
}
break;
case "empty":
case "exists":
if (typeof value !== "boolean") {
throw new Error(`target "${targetName}" 的 ${path}.${key} 必须为布尔值`);
if ("attr" in rule && typeof rule["attr"] !== "string") {
issues.push(issue("invalid-type", joinPath(path, "attr"), "必须为字符串", targetName));
}
break;
case "equals":
if (typeof value !== "boolean" && typeof value !== "number" && typeof value !== "string" && value !== null) {
throw new Error(`target "${targetName}" 的 ${path}.equals 类型不合法`);
const result = collectOperatorObject(rule, new Set(["attr", "selector"]), path, targetName);
issues.push(
...result.issues,
...validateOperatorObject(result.operators, path, targetName, { requireAtLeastOne: false }),
);
return issues;
}
function validateHttpExpect(target: Record<string, unknown>, path: string): ConfigValidationIssue[] {
const targetName = getTargetName(target);
const expect = target["expect"];
if (expect === undefined || expect === null || !isPlainRecord(expect)) return [];
const issues: ConfigValidationIssue[] = [];
const expectPath = joinPath(path, "expect");
if (isPlainRecord(expect["headers"])) {
for (const [key, value] of Object.entries(expect["headers"])) {
if (typeof value === "string") continue;
issues.push(...validateOperatorObject(value, joinPath(joinPath(expectPath, "headers"), key), targetName));
}
if (typeof value === "number" && !Number.isFinite(value)) {
throw new Error(`target "${targetName}" 的 ${path}.equals 不能为 NaN 或 Infinity`);
}
break;
case "gt":
case "gte":
case "lt":
case "lte":
if (typeof value !== "number" || !Number.isFinite(value)) {
throw new Error(`target "${targetName}" 的 ${path}.${key} 必须为有限数字`);
if (expect["body"] !== undefined) {
issues.push(...validateBodyRules(expect["body"], joinPath(expectPath, "body"), targetName));
}
break;
case "match":
if (typeof value !== "string") {
throw new Error(`target "${targetName}" 的 ${path}.match 必须为字符串`);
if (Array.isArray(expect["status"])) {
issues.push(...validateStatusValues(expect["status"], joinPath(expectPath, "status"), targetName));
}
if (expect["maxDurationMs"] !== undefined && !isNonNegativeFiniteNumber(expect["maxDurationMs"])) {
issues.push(issue("invalid-type", joinPath(expectPath, "maxDurationMs"), "必须为非负有限数字", targetName));
}
return issues;
}
function validateHttpTarget(target: Record<string, unknown>, path: string): ConfigValidationIssue[] {
const issues: ConfigValidationIssue[] = [];
const targetName = getTargetName(target);
const http = target["http"];
if (!isPlainRecord(http)) {
issues.push(issue("required", joinPath(path, "http"), "缺少 http.url 字段", targetName));
issues.push(...validateHttpExpect(target, path));
return issues;
}
if (typeof http["url"] !== "string" || http["url"].trim() === "") {
issues.push(issue("required", joinPath(joinPath(path, "http"), "url"), "缺少 http.url 字段", targetName));
} else {
try {
new RegExp(value);
const url = new URL(http["url"]);
if (!ALLOWED_PROTOCOLS.has(url.protocol)) {
issues.push(
issue(
"invalid-url",
joinPath(joinPath(path, "http"), "url"),
"格式不合法,必须以 http:// 或 https:// 开头",
targetName,
),
);
}
} catch {
throw new Error(`target "${targetName}" 的 ${path}.match 正则不合法`);
issues.push(issue("invalid-url", joinPath(joinPath(path, "http"), "url"), "格式不合法", targetName));
}
break;
}
if (isSizeInput(http["maxBodyBytes"])) {
issues.push(
...validateSizeValue(http["maxBodyBytes"], joinPath(joinPath(path, "http"), "maxBodyBytes"), targetName),
);
}
issues.push(...validateHttpExpect(target, path));
return issues;
}
function validateJsonRule(rule: unknown, path: string, targetName?: string): ConfigValidationIssue[] {
if (!isPlainRecord(rule)) return [issue("invalid-type", path, "必须为对象", targetName)];
const issues: ConfigValidationIssue[] = [];
if (typeof rule["path"] !== "string") {
issues.push(issue("invalid-type", joinPath(path, "path"), "必须为字符串", targetName));
} else {
issues.push(...validateJsonPath(rule["path"], path, targetName));
}
const result = collectOperatorObject(rule, new Set(["path"]), path, targetName);
issues.push(
...result.issues,
...validateOperatorObject(result.operators, path, targetName, { requireAtLeastOne: false }),
);
return issues;
}
function validateRegexRule(rule: unknown, path: string, targetName?: string): ConfigValidationIssue[] {
if (typeof rule !== "string") return [issue("invalid-type", path, "必须为字符串", targetName)];
try {
new RegExp(rule);
return [];
} catch {
return [issue("invalid-regex", path, "正则不合法", targetName)];
}
}
function validateSingleBodyRule(rule: unknown, index: number, targetName: string): void {
if (typeof rule !== "object" || rule === null) {
throw new Error(`target "${targetName}" 的 expect.body[${index}] 必须为对象`);
}
const ruleObj = rule as Record<string, unknown>;
const found: string[] = [];
for (const type of BODY_RULE_TYPES) {
if (type in ruleObj) found.push(type);
}
if (found.length === 0) {
throw new Error(
`target "${targetName}" 的 expect.body[${index}] 缺少支持的规则类型contains/regex/json/css/xpath`,
);
}
if (found.length > 1) {
throw new Error(
`target "${targetName}" 的 expect.body[${index}] 只能配置一种规则类型,当前包含: ${found.join(", ")}`,
);
}
function validateSingleBodyRule(rule: unknown, path: string, targetName?: string): ConfigValidationIssue[] {
if (!isPlainRecord(rule)) return [issue("invalid-type", path, "必须为对象", targetName)];
const found = BodyRuleTypeKeys.filter((type) => type in rule);
if (found.length === 0) return [issue("missing-body-rule", path, "缺少支持的规则类型", targetName)];
if (found.length > 1) return [issue("multiple-body-rules", path, "只能配置一种规则类型", targetName)];
const ruleType = found[0]!;
const rulePath = `expect.body[${index}]`;
const issues: ConfigValidationIssue[] = [];
for (const key of Object.keys(rule)) {
if (key !== ruleType) issues.push(issue("unknown-field", joinPath(path, key), "是未知字段", targetName));
}
if (issues.length > 0) return issues;
switch (ruleType) {
case "contains":
if (typeof ruleObj["contains"] !== "string") {
throw new Error(`target "${targetName}" 的 ${rulePath}.contains 必须为字符串`);
}
break;
case "css": {
const cssRule = ruleObj["css"];
if (typeof cssRule !== "object" || cssRule === null) {
throw new Error(`target "${targetName}" 的 ${rulePath}.css 必须为对象`);
}
const cr = cssRule as Record<string, unknown>;
if (typeof cr["selector"] !== "string" || cr["selector"].trim() === "") {
throw new Error(`target "${targetName}" 的 ${rulePath}.css.selector 必须为非空字符串`);
}
const cssOps: Record<string, unknown> = {};
for (const [k, v] of Object.entries(cr)) {
if (k !== "selector" && k !== "attr") cssOps[k] = v;
}
validateOperators(cssOps, targetName, `${rulePath}.css`);
break;
}
case "json": {
const jsonRule = ruleObj["json"];
if (typeof jsonRule !== "object" || jsonRule === null) {
throw new Error(`target "${targetName}" 的 ${rulePath}.json 必须为对象`);
}
const jr = jsonRule as Record<string, unknown>;
if (typeof jr["path"] !== "string" || !jr["path"].startsWith("$.") || jr["path"].length <= 2) {
throw new Error(`target "${targetName}" 的 ${rulePath}.json.path 必须为以 "$." 开头的有效 JSONPath`);
}
validateJsonPath(jr["path"], targetName, `${rulePath}.json`);
const jsonOps: Record<string, unknown> = {};
for (const [k, v] of Object.entries(jr)) {
if (k !== "path") jsonOps[k] = v;
}
validateOperators(jsonOps, targetName, `${rulePath}.json`);
break;
}
return typeof rule["contains"] === "string"
? []
: [issue("invalid-type", joinPath(path, "contains"), "必须为字符串", targetName)];
case "css":
return validateCssRule(rule["css"], joinPath(path, "css"), targetName);
case "json":
return validateJsonRule(rule["json"], joinPath(path, "json"), targetName);
case "regex":
if (typeof ruleObj["regex"] !== "string") {
throw new Error(`target "${targetName}" 的 ${rulePath}.regex 必须为字符串`);
}
try {
new RegExp(ruleObj["regex"]);
} catch {
throw new Error(`target "${targetName}" 的 ${rulePath}.regex 正则不合法`);
}
break;
case "xpath": {
const xpathRule = ruleObj["xpath"];
if (typeof xpathRule !== "object" || xpathRule === null) {
throw new Error(`target "${targetName}" 的 ${rulePath}.xpath 必须为对象`);
}
const xr = xpathRule as Record<string, unknown>;
if (typeof xr["path"] !== "string" || xr["path"].trim() === "") {
throw new Error(`target "${targetName}" 的 ${rulePath}.xpath.path 必须为非空字符串`);
}
try {
const doc = new DOMParser().parseFromString("<x/>", "text/xml");
xpath.select(xr["path"], doc as unknown as Node);
} catch {
throw new Error(`target "${targetName}" 的 ${rulePath}.xpath.path xpath 不合法`);
}
const xpathOps: Record<string, unknown> = {};
for (const [k, v] of Object.entries(xr)) {
if (k !== "path") xpathOps[k] = v;
}
validateOperators(xpathOps, targetName, `${rulePath}.xpath`);
break;
}
return validateRegexRule(rule["regex"], joinPath(path, "regex"), targetName);
case "xpath":
return validateXpathRule(rule["xpath"], joinPath(path, "xpath"), targetName);
}
}
function validateStatus(status: unknown, targetName: string): void {
if (!Array.isArray(status)) {
throw new Error(`target "${targetName}" 的 expect.status 必须为数组`);
}
for (const p of status) {
if (typeof p === "number") {
if (!Number.isInteger(p) || p < 100 || p > 599) {
throw new Error(`target "${targetName}" 的 expect.status 数字 ${p} 不合法,必须为 100-599 之间的整数`);
}
} else if (typeof p === "string") {
if (!/^[1-5]xx$/.test(p)) {
throw new Error(`target "${targetName}" 的 expect.status 模式 "${p}" 不合法,字符串必须为 "1xx" 到 "5xx" 格式`);
}
} else {
throw new Error(`target "${targetName}" 的 expect.status 只能包含数字或范围模式字符串`);
}
function validateSizeValue(value: number | string, path: string, targetName?: string): ConfigValidationIssue[] {
try {
parseSize(value);
return [];
} catch (error) {
return [issue("invalid-size", path, error instanceof Error ? error.message : "size 格式不合法", targetName)];
}
}
function validateStatusValues(values: unknown[], path: string, targetName?: string): ConfigValidationIssue[] {
const issues: ConfigValidationIssue[] = [];
for (let i = 0; i < values.length; i++) {
const value = values[i];
const itemPath = `${path}[${i}]`;
if (typeof value === "number") {
if (!Number.isInteger(value) || value < 100 || value > 599) {
issues.push(issue("invalid-status", itemPath, "status 数字必须为 100-599 之间的整数", targetName));
}
continue;
}
if (typeof value === "string") {
if (!/^[1-5]xx$/.test(value)) {
issues.push(issue("invalid-status", itemPath, "status 模式必须为 1xx 到 5xx", targetName));
}
continue;
}
issues.push(issue("invalid-status", itemPath, "status 必须为整数或 1xx 到 5xx 模式", targetName));
}
return issues;
}
function validateXpathRule(rule: unknown, path: string, targetName?: string): ConfigValidationIssue[] {
if (!isPlainRecord(rule)) return [issue("invalid-type", path, "必须为对象", targetName)];
const issues: ConfigValidationIssue[] = [];
if (typeof rule["path"] !== "string" || rule["path"].trim() === "") {
issues.push(issue("invalid-type", joinPath(path, "path"), "必须为非空字符串", targetName));
} else {
try {
const doc = new DOMParser().parseFromString("<x/>", "text/xml");
xpath.select(rule["path"], doc as unknown as Node);
} catch {
issues.push(issue("invalid-xpath", joinPath(path, "path"), "xpath 不合法", targetName));
}
}
const result = collectOperatorObject(rule, new Set(["path"]), path, targetName);
issues.push(
...result.issues,
...validateOperatorObject(result.operators, path, targetName, { requireAtLeastOne: false }),
);
return issues;
}

View File

@@ -1,10 +1,15 @@
import { CommandChecker } from "./command/runner";
import { HttpChecker } from "./http/runner";
import { checkerRegistry } from "./registry";
import { CommandChecker } from "./command";
import { HttpChecker } from "./http";
import { CheckerRegistry } from "./registry";
export function registerCheckers(): void {
checkerRegistry.register(new HttpChecker());
checkerRegistry.register(new CommandChecker());
const checkers = [new HttpChecker(), new CommandChecker()];
export function createDefaultCheckerRegistry(): CheckerRegistry {
const registry = new CheckerRegistry();
for (const checker of checkers) {
registry.register(checker);
}
return registry;
}
export { checkerRegistry } from "./registry";
export const checkerRegistry = createDefaultCheckerRegistry();

View File

@@ -1,13 +1,17 @@
import type { Checker } from "./types";
import type { CheckerDefinition } from "./types";
export class CheckerRegistry {
get definitions(): CheckerDefinition[] {
return [...this.checkers.values()];
}
get supportedTypes(): string[] {
return [...this.checkers.keys()];
}
private checkers = new Map<string, Checker>();
private checkers = new Map<string, CheckerDefinition>();
get(type: string): Checker {
get(type: string): CheckerDefinition {
const checker = this.checkers.get(type);
if (!checker) {
throw new Error(`不支持的 probe type: "${type}"`);
@@ -15,12 +19,14 @@ export class CheckerRegistry {
return checker;
}
register(checker: Checker): void {
register(checker: CheckerDefinition): void {
if (this.checkers.has(checker.type)) {
throw new Error(`Checker type "${checker.type}" 已注册`);
}
this.checkers.set(checker.type, checker);
}
}
export const checkerRegistry = new CheckerRegistry();
tryGet(type: string): CheckerDefinition | undefined {
return this.checkers.get(type);
}
}

View File

@@ -1,16 +1,35 @@
import type { CheckResult, DefaultsConfig, ResolvedTarget, TargetConfig } from "../types";
import type { TSchema } from "@sinclair/typebox";
export interface Checker {
execute(target: ResolvedTarget, ctx: CheckerContext): Promise<CheckResult>;
resolve(target: TargetConfig, context: ResolveContext): ResolvedTarget;
serialize(target: ResolvedTarget): { config: string; target: string };
readonly type: string;
}
import type { ConfigValidationIssue } from "../schema/issues";
import type { CheckResult, DefaultsConfig, RawTargetConfig, ResolvedTargetBase } from "../types";
export type Checker<TResolved extends ResolvedTargetBase = ResolvedTargetBase> = CheckerDefinition<TResolved>;
export interface CheckerContext {
signal: AbortSignal;
}
export interface CheckerDefinition<TResolved extends ResolvedTargetBase = ResolvedTargetBase> {
readonly configKey: string;
execute(target: TResolved, ctx: CheckerContext): Promise<CheckResult>;
resolve(target: RawTargetConfig, context: ResolveContext): TResolved;
readonly schemas: CheckerSchemas;
serialize(target: TResolved): { config: string; target: string };
readonly type: string;
validate(input: CheckerValidationInput): ConfigValidationIssue[];
}
export interface CheckerSchemas {
config: TSchema;
defaults: TSchema;
expect: TSchema;
}
export interface CheckerValidationInput {
defaults: DefaultsConfig;
targets: RawTargetConfig[];
}
export interface ResolveContext {
configDir: string;
defaultIntervalMs: number;

View File

@@ -0,0 +1,92 @@
import type { TSchema } from "@sinclair/typebox";
import { Type } from "@sinclair/typebox";
import type { CheckerDefinition } from "../runner/types";
import { durationSchema } from "./fragments";
export function createExternalProbeConfigSchema(checkers: CheckerDefinition[]): Record<string, unknown> {
return {
...cloneSchema(createProbeConfigSchema(checkers, true)),
$id: "https://dial.local/probe-config.schema.json",
$schema: "http://json-schema.org/draft-07/schema#",
definitions: {},
};
}
export function createProbeConfigSchema(checkers: CheckerDefinition[], external = false): TSchema {
return Type.Object(
{
defaults: Type.Optional(createDefaultsSchema(checkers)),
runtime: Type.Optional(
Type.Object(
{
maxConcurrentChecks: Type.Optional(Type.Integer({ minimum: 1 })),
retention: Type.Optional(durationSchema),
},
{ additionalProperties: false },
),
),
server: Type.Optional(
Type.Object(
{
dataDir: Type.Optional(Type.String()),
host: Type.Optional(Type.String()),
port: Type.Optional(Type.Integer({ maximum: 65535, minimum: 0 })),
},
{ additionalProperties: false },
),
),
targets: Type.Array(external ? createExternalTargetSchema(checkers) : createBaseTargetSchema(checkers), {
minItems: 1,
}),
},
{ additionalProperties: false },
);
}
export function createTargetSchema(checker: CheckerDefinition): TSchema {
const properties: Record<string, TSchema> = {
expect: Type.Optional(checker.schemas.expect),
group: Type.Optional(Type.String()),
interval: Type.Optional(durationSchema),
name: Type.String({ minLength: 1 }),
timeout: Type.Optional(durationSchema),
type: Type.Literal(checker.type),
};
properties[checker.configKey] = checker.schemas.config;
return Type.Object(properties, { additionalProperties: false });
}
function cloneSchema(schema: TSchema): Record<string, unknown> {
return JSON.parse(JSON.stringify(schema)) as Record<string, unknown>;
}
function createBaseTargetSchema(checkers: CheckerDefinition[]): TSchema {
return Type.Object(
{
group: Type.Optional(Type.String()),
interval: Type.Optional(durationSchema),
name: Type.String({ minLength: 1 }),
timeout: Type.Optional(durationSchema),
type: Type.Union(checkers.map((checker) => Type.Literal(checker.type)) as unknown as [TSchema, ...TSchema[]]),
},
{ additionalProperties: true },
);
}
function createDefaultsSchema(checkers: CheckerDefinition[]): TSchema {
const properties: Record<string, TSchema> = {
interval: Type.Optional(durationSchema),
timeout: Type.Optional(durationSchema),
};
for (const checker of checkers) {
properties[checker.configKey] = Type.Optional(checker.schemas.defaults);
}
return Type.Object(properties, { additionalProperties: false });
}
function createExternalTargetSchema(checkers: CheckerDefinition[]): TSchema {
return Type.Union(checkers.map((checker) => createTargetSchema(checker)) as [TSchema, ...TSchema[]]);
}

View File

@@ -0,0 +1,7 @@
import type { CheckerRegistry } from "../runner/registry";
import { createExternalProbeConfigSchema } from "./builder";
export function createProbeConfigJsonSchema(registry: CheckerRegistry): Record<string, unknown> {
return createExternalProbeConfigSchema(registry.definitions);
}

View File

@@ -0,0 +1,98 @@
import type { TSchema } from "@sinclair/typebox";
import { Type } from "@sinclair/typebox";
import type { JsonValue } from "./types";
export const HTTP_METHODS = ["DELETE", "GET", "HEAD", "OPTIONS", "PATCH", "POST", "PUT"] as const;
export const BodyRuleTypeKeys = ["contains", "regex", "json", "css", "xpath"] as const;
export const OperatorKeys = ["contains", "empty", "equals", "exists", "gt", "gte", "lt", "lte", "match"] as const;
export const durationSchema = Type.String();
export const httpMethodSchema = Type.Union(
HTTP_METHODS.map((method) => Type.Literal(method)) as unknown as [TSchema, ...TSchema[]],
);
export const jsonValueSchema = Type.Unsafe<JsonValue>({
anyOf: [
{ type: "string" },
{ type: "number" },
{ type: "boolean" },
{ type: "null" },
{ items: {}, type: "array" },
{ additionalProperties: {}, type: "object" },
],
});
export const sizeSchema = Type.Union([Type.String(), Type.Integer({ minimum: 0 })]);
export const statusCodePatternSchema = Type.Union([
Type.Integer({ maximum: 599, minimum: 100 }),
Type.String({ pattern: "^[1-5]xx$" }),
]);
export const stringMapSchema = Type.Unsafe<Record<string, string>>({
additionalProperties: { type: "string" },
type: "object",
});
export function createBodyRulesSchema(): TSchema {
return Type.Array(
Type.Object(
{
contains: Type.Optional(Type.String()),
css: Type.Optional(
Type.Object(
{ attr: Type.Optional(Type.String()), selector: Type.String({ minLength: 1 }), ...operatorProperties() },
{ additionalProperties: false },
),
),
json: Type.Optional(
Type.Object({ path: Type.String(), ...operatorProperties() }, { additionalProperties: false }),
),
regex: Type.Optional(Type.String()),
xpath: Type.Optional(
Type.Object(
{ path: Type.String({ minLength: 1 }), ...operatorProperties() },
{ additionalProperties: false },
),
),
},
{ additionalProperties: false },
),
);
}
export function createHeaderExpectSchema(): TSchema {
return Type.Unsafe<Record<string, unknown>>({
additionalProperties: {
anyOf: [{ type: "string" }, createPureOperatorSchema()],
},
type: "object",
});
}
export function createPureOperatorSchema(): TSchema {
return Type.Object(operatorProperties(), { additionalProperties: false, minProperties: 1 });
}
export function createTextRulesSchema(): TSchema {
return Type.Array(createPureOperatorSchema());
}
export function operatorProperties(): Record<string, TSchema> {
return {
contains: Type.Optional(Type.String()),
empty: Type.Optional(Type.Boolean()),
equals: Type.Optional(jsonValueSchema),
exists: Type.Optional(Type.Boolean()),
gt: Type.Optional(Type.Number()),
gte: Type.Optional(Type.Number()),
lt: Type.Optional(Type.Number()),
lte: Type.Optional(Type.Number()),
match: Type.Optional(Type.String()),
};
}

View File

@@ -0,0 +1,37 @@
export interface ConfigValidationIssue {
code: string;
message: string;
path: string;
targetName?: string;
}
export function formatConfigIssues(issues: ConfigValidationIssue[]): string {
return issues.map(formatConfigIssue).join("\n");
}
export function issue(code: string, path: string, message: string, targetName?: string): ConfigValidationIssue {
return targetName === undefined ? { code, message, path } : { code, message, path, targetName };
}
export function joinPath(base: string, key: string): string {
if (base === "") return key;
if (key.startsWith("[")) return `${base}${key}`;
return `${base}.${key}`;
}
export function renderPath(path: string): string {
return path === "" ? "配置文件" : path;
}
export function throwConfigIssues(issues: ConfigValidationIssue[]): never {
throw new Error(formatConfigIssues(issues));
}
function formatConfigIssue(issue: ConfigValidationIssue): string {
if (issue.targetName) {
const path = issue.path.replace(/^targets\[\d+\]\.?/, "");
const renderedPath = path === "" ? "配置" : path;
return `target "${issue.targetName}" 的 ${renderedPath} ${issue.message}`;
}
return `${renderPath(issue.path)} ${issue.message}`;
}

View File

@@ -0,0 +1,13 @@
import type { ProbeConfig } from "../types";
declare const validatedConfigBrand: unique symbol;
export type JsonValue = boolean | JsonValue[] | null | number | string | { [key: string]: JsonValue };
export type RawProbeConfig = ProbeConfig;
export type ValidatedProbeConfig = RawProbeConfig & { readonly [validatedConfigBrand]: true };
export function asValidatedConfig(config: RawProbeConfig): ValidatedProbeConfig {
return config as ValidatedProbeConfig;
}

View File

@@ -0,0 +1,145 @@
import type { ErrorObject } from "ajv";
import Ajv from "ajv";
import type { CheckerRegistry } from "../runner/registry";
import type { ConfigValidationIssue } from "./issues";
import type { RawProbeConfig } from "./types";
import { createProbeConfigSchema, createTargetSchema } from "./builder";
import { issue } from "./issues";
export function createConfigAjv(): Ajv {
return new Ajv({ allErrors: true, coerceTypes: false, removeAdditional: false, strict: true, useDefaults: false });
}
export function issuesFromAjvErrors(errors: ErrorObject[], root: unknown, basePath = ""): ConfigValidationIssue[] {
return normalizeAjvErrors(errors, basePath).map((error) => issueFromAjvError(error, root, basePath));
}
export function validateProbeConfigContract(
config: unknown,
registry: CheckerRegistry,
): { config: null; issues: ConfigValidationIssue[] } | { config: RawProbeConfig; issues: [] } {
const ajv = createConfigAjv();
const checkers = registry.definitions;
const issues: ConfigValidationIssue[] = [];
const rootValidate = ajv.compile(createProbeConfigSchema(checkers));
if (!rootValidate(config)) {
issues.push(...issuesFromAjvErrors(rootValidate.errors ?? [], config));
}
if (isRecord(config) && isUnknownArray(config["targets"])) {
const targets = config["targets"];
for (let i = 0; i < targets.length; i++) {
const target = targets[i];
if (!isRecord(target) || typeof target["type"] !== "string") continue;
const checker = registry.tryGet(target["type"]);
if (!checker) continue;
const targetValidate = ajv.compile(createTargetSchema(checker));
if (!targetValidate(target)) {
issues.push(...issuesFromAjvErrors(targetValidate.errors ?? [], config, `targets[${i}]`));
}
}
}
return issues.length > 0 ? { config: null, issues } : { config: config as RawProbeConfig, issues: [] };
}
function buildIssuePath(basePath: string, error: ErrorObject): string {
const pointerPath = jsonPointerToPath(error.instancePath);
let path = basePath ? joinBasePath(basePath, pointerPath) : pointerPath;
if (error.keyword === "required" && "missingProperty" in error.params) {
path = joinBasePath(path, String(error.params["missingProperty"]));
}
if (error.keyword === "additionalProperties" && "additionalProperty" in error.params) {
path = joinBasePath(path, String(error.params["additionalProperty"]));
}
return path;
}
function hasMoreSpecificError(keywords: Set<string>): boolean {
return ["const", "enum", "maximum", "minimum", "minLength", "pattern"].some((keyword) => keywords.has(keyword));
}
function isRecord(value: unknown): value is Record<string, unknown> {
return typeof value === "object" && value !== null && !Array.isArray(value);
}
function issueFromAjvError(error: ErrorObject, root: unknown, basePath: string): ConfigValidationIssue {
const path = buildIssuePath(basePath, error);
const targetName = targetNameFromPath(root, path);
switch (error.keyword) {
case "additionalProperties":
return issue("unknown-field", path, "是未知字段", targetName);
case "const":
case "enum":
return issue("invalid-value", path, "不在允许范围内", targetName);
case "maximum":
case "minimum":
return issue("invalid-range", path, "数值范围不合法", targetName);
case "minLength":
return issue("invalid-format", path, "不能为空", targetName);
case "pattern":
return issue("invalid-format", path, "格式不合法", targetName);
case "required":
return issue("required", path, "缺少必填字段", targetName);
case "type":
return issue("invalid-type", path, "类型不合法", targetName);
default:
return issue("invalid-config", path, error.message ?? "配置不合法", targetName);
}
}
function isUnknownArray(value: unknown): value is unknown[] {
return Array.isArray(value);
}
function joinBasePath(basePath: string, path: string): string {
if (basePath === "") return path;
if (path === "") return basePath;
if (path.startsWith("[")) return `${basePath}${path}`;
return `${basePath}.${path}`;
}
function jsonPointerToPath(pointer: string): string {
if (pointer === "") return "";
return pointer
.slice(1)
.split("/")
.map((part) => part.replaceAll("~1", "/").replaceAll("~0", "~"))
.reduce((path, part) => (/^\d+$/.test(part) ? `${path}[${part}]` : joinBasePath(path, part)), "");
}
function normalizeAjvErrors(errors: ErrorObject[], basePath: string): ErrorObject[] {
const nonCompositeErrors = errors.filter((error) => error.keyword !== "anyOf" && error.keyword !== "oneOf");
const candidates = nonCompositeErrors.length > 0 ? nonCompositeErrors : errors;
const keywordsByPath = new Map<string, Set<string>>();
for (const error of candidates) {
const path = buildIssuePath(basePath, error);
const keywords = keywordsByPath.get(path) ?? new Set<string>();
keywords.add(error.keyword);
keywordsByPath.set(path, keywords);
}
const seenValueErrors = new Set<string>();
return candidates.filter((error) => {
const path = buildIssuePath(basePath, error);
const keywords = keywordsByPath.get(path) ?? new Set<string>();
if (error.keyword === "type" && hasMoreSpecificError(keywords)) return false;
if (error.keyword === "const" || error.keyword === "enum") {
if (seenValueErrors.has(path)) return false;
seenValueErrors.add(path);
}
return true;
});
}
function targetNameFromPath(root: unknown, path: string): string | undefined {
const match = /^targets\[(\d+)\]/.exec(path);
if (!match || !isRecord(root) || !isUnknownArray(root["targets"])) return undefined;
const target = root["targets"][Number(match[1])];
if (!isRecord(target) || typeof target["name"] !== "string") return undefined;
return target["name"];
}

View File

@@ -1,23 +0,0 @@
const SIZE_REGEX = /^(\d+(?:\.\d+)?)(B|KB|MB|GB)$/;
export function parseSize(value: number | string): number {
if (typeof value === "number") {
if (!Number.isInteger(value) || value < 0 || !Number.isSafeInteger(value)) {
throw new Error(`无效的 size 数值: ${value},必须为非负安全整数`);
}
return value;
}
const match = SIZE_REGEX.exec(value);
if (!match) {
throw new Error(`无效的 size 格式: "${value}",支持格式如 "100MB"、"512KB"、"1GB"、"1024B"`);
}
const num = parseFloat(match[1]!);
const unit = match[2]!;
if (unit === "B") return num;
if (unit === "KB") return num * 1024;
if (unit === "MB") return num * 1024 * 1024;
return num * 1024 * 1024 * 1024;
}

View File

@@ -2,7 +2,7 @@ import { Database } from "bun:sqlite";
import { mkdirSync as fsMkdirSync } from "node:fs";
import { dirname } from "node:path";
import type { CheckFailure, ResolvedTarget, StoredCheckResult, StoredTarget } from "./types";
import type { CheckFailure, ResolvedTargetBase, StoredCheckResult, StoredTarget } from "./types";
import { checkerRegistry } from "./runner";
@@ -57,6 +57,40 @@ export class ProbeStore {
this.db.close();
}
getAllRecentSamples(
limit: number,
): Map<number, Array<{ duration_ms: null | number; matched: number; timestamp: string }>> {
const rows = this.db
.query(
`SELECT target_id, timestamp, duration_ms, matched
FROM (
SELECT
target_id,
timestamp,
duration_ms,
matched,
ROW_NUMBER() OVER (PARTITION BY target_id ORDER BY timestamp DESC) as row_num
FROM check_results
)
WHERE row_num <= ?
ORDER BY target_id, timestamp DESC`,
)
.all(limit) as Array<{
duration_ms: null | number;
matched: number;
target_id: number;
timestamp: string;
}>;
const result = new Map<number, Array<{ duration_ms: null | number; matched: number; timestamp: string }>>();
for (const row of rows) {
const samples = result.get(row.target_id) ?? [];
samples.push({ duration_ms: row.duration_ms, matched: row.matched, timestamp: row.timestamp });
result.set(row.target_id, samples);
}
return result;
}
getAllTargetStats(): Map<number, { availability: number; totalChecks: number }> {
const rows = this.db
.query(
@@ -69,7 +103,7 @@ export class ProbeStore {
const result = new Map<number, { availability: number; totalChecks: number }>();
for (const row of rows) {
const availability = row.totalChecks > 0 ? Math.round((row.upCount / row.totalChecks) * 10000) / 100 : 0;
const availability = row.totalChecks > 0 ? Math.round((row.upCount / row.totalChecks) * 100 * 100) / 100 : 0;
result.set(row.target_id, { availability, totalChecks: row.totalChecks });
}
return result;
@@ -257,7 +291,14 @@ export class ProbeStore {
);
}
syncTargets(targets: ResolvedTarget[]): void {
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<{
id: number;
@@ -277,8 +318,9 @@ export class ProbeStore {
const tx = this.db.transaction(() => {
for (const t of targets) {
const type = t.type;
const target = buildTargetDisplay(t);
const config = buildTargetConfig(t);
const serialized = checkerRegistry.get(t.type).serialize(t);
const target = serialized.target;
const config = serialized.config;
const expect = t.expect ? JSON.stringify(t.expect) : null;
if (existingMap.has(t.name)) {
@@ -299,14 +341,6 @@ export class ProbeStore {
}
}
function buildTargetConfig(t: ResolvedTarget): string {
return checkerRegistry.get(t.type).serialize(t).config;
}
function buildTargetDisplay(t: ResolvedTarget): string {
return checkerRegistry.get(t.type).serialize(t).target;
}
function ensureDir(dir: string): void {
try {
fsMkdirSync(dir, { recursive: true });

View File

@@ -1,55 +1,24 @@
import type { CheckResult as ApiCheckResult, CheckFailure } from "../../shared/api";
export type BodyRule =
| { contains: string }
| { css: CssRule }
| { json: JsonRule }
| { regex: string }
| { xpath: XpathRule };
export interface CheckResult extends ApiCheckResult {
targetName: string;
}
export interface CommandDefaultsConfig {
cwd?: string;
maxOutputBytes?: string;
}
export interface CommandExpectConfig {
exitCode?: number[];
maxDurationMs?: number;
stderr?: TextRule[];
stdout?: TextRule[];
}
export interface CommandTargetConfig {
args?: string[];
cwd?: string;
env?: Record<string, string>;
exec: string;
maxOutputBytes?: string;
}
export type CssRule = ExpectOperator & { attr?: string; selector: string };
export interface DefaultsConfig {
command?: CommandDefaultsConfig;
http?: HttpDefaultsConfig;
[checkerKey: string]: unknown;
interval?: string;
timeout?: string;
}
export interface EngineRuntimeConfig {
maxConcurrentChecks?: number;
retention?: string;
}
export type ExpectConfig = CommandExpectConfig | HttpExpectConfig;
export interface ExpectOperator {
contains?: string;
empty?: boolean;
equals?: boolean | null | number | string;
equals?: JsonValue;
exists?: boolean;
gt?: number;
gte?: number;
@@ -58,82 +27,37 @@ export interface ExpectOperator {
match?: string;
}
export type ExpectValue = boolean | ExpectOperator | null | number | string;
export type ExpectValue = ExpectOperator | JsonValue;
export type HeaderExpect = ExpectOperator | string;
export interface HttpDefaultsConfig {
headers?: Record<string, string>;
maxBodyBytes?: string;
method?: string;
}
export interface HttpExpectConfig {
body?: BodyRule[];
headers?: Record<string, HeaderExpect>;
maxDurationMs?: number;
status?: Array<number | string>;
}
export interface HttpTargetConfig {
body?: string;
headers?: Record<string, string>;
ignoreSSL?: boolean;
maxBodyBytes?: string;
maxRedirects?: number;
method?: string;
url: string;
}
export type JsonRule = ExpectOperator & { path: string };
export type JsonValue = boolean | JsonValue[] | null | number | string | { [key: string]: JsonValue };
export interface ProbeConfig {
defaults?: DefaultsConfig;
runtime?: EngineRuntimeConfig;
server?: ServerConfig;
targets: TargetConfig[];
targets: RawTargetConfig[];
}
export interface ResolvedCommandConfig {
args: string[];
cwd: string;
env: Record<string, string>;
exec: string;
maxOutputBytes: number;
export interface RawTargetConfig {
[configKey: string]: unknown;
expect?: unknown;
group?: string;
interval?: string;
name: string;
timeout?: string;
type: string;
}
export interface ResolvedCommandTarget {
command: ResolvedCommandConfig;
expect?: CommandExpectConfig;
export interface ResolvedTargetBase {
[key: string]: unknown;
expect?: unknown;
group: string;
intervalMs: number;
name: string;
timeoutMs: number;
type: "command";
type: string;
}
export interface ResolvedHttpConfig {
body?: string;
headers: Record<string, string>;
ignoreSSL: boolean;
maxBodyBytes: number;
maxRedirects: number;
method: string;
url: string;
}
export interface ResolvedHttpTarget {
expect?: HttpExpectConfig;
group: string;
http: ResolvedHttpConfig;
intervalMs: number;
name: string;
timeoutMs: number;
type: "http";
}
export type ResolvedTarget = ResolvedCommandTarget | ResolvedHttpTarget;
export interface ServerConfig {
dataDir?: string;
host?: string;
@@ -159,23 +83,7 @@ export interface StoredTarget {
name: string;
target: string;
timeout_ms: number;
type: TargetType;
type: string;
}
export type TargetConfig = BaseTargetConfig &
({ command: CommandTargetConfig; type: "command" } | { http: HttpTargetConfig; type: "http" });
export type TargetType = "command" | "http";
export type { CheckFailure };
export type TextRule = ExpectOperator;
export type XpathRule = ExpectOperator & { path: string };
interface BaseTargetConfig {
expect?: ExpectConfig;
group?: string;
interval?: string;
name: string;
timeout?: string;
}

View File

@@ -0,0 +1,44 @@
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"、"2h"、"7d"、"500ms"`);
}
const num = parseFloat(match[1]!);
const unit = match[2]!;
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}",解析结果必须为正整数毫秒`);
}
return durationMs;
}
export function parseSize(value: number | string): number {
if (typeof value === "number") {
if (!Number.isInteger(value) || value < 0 || !Number.isSafeInteger(value)) {
throw new Error(`无效的 size 数值: ${value},必须为非负安全整数`);
}
return value;
}
const match = SIZE_REGEX.exec(value);
if (!match) {
throw new Error(`无效的 size 格式: "${value}",支持格式如 "100MB"、"512KB"、"1GB"、"1024B"`);
}
const num = parseFloat(match[1]!);
const unit = match[2]!;
const bytes =
unit === "B" ? num : unit === "KB" ? num * 1024 : unit === "MB" ? num * 1024 * 1024 : num * 1024 * 1024 * 1024;
if (!Number.isInteger(bytes) || bytes < 0 || !Number.isSafeInteger(bytes)) {
throw new Error(`无效的 size 数值: ${value},必须解析为非负安全整数字节数`);
}
return bytes;
}

View File

@@ -1,35 +1,9 @@
import { loadConfig } from "./checker/config-loader";
import { ProbeEngine } from "./checker/engine";
import { registerCheckers } from "./checker/runner";
import { ProbeStore } from "./checker/store";
import { bootstrap } from "./bootstrap";
import { readRuntimeConfig } from "./config";
import { startServer } from "./server";
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);
engine.start();
const shutdown = () => {
engine.stop();
store.close();
process.exit(0);
};
process.on("SIGINT", shutdown);
process.on("SIGTERM", shutdown);
startServer({
config: { host: config.host, port: config.port },
mode: "development",
store,
});
await bootstrap({ configPath, mode: "development" });
}
void main().catch((error) => {

View File

@@ -2,6 +2,8 @@ import type { RuntimeMode } from "../shared/api";
import { allowsGetHead, createApiError, jsonResponse, methodNotAllowedResponse } from "./helpers";
const MAX_PAGE_SIZE = 200;
export function guardGetHead(method: string, mode: RuntimeMode): null | Response {
if (!allowsGetHead(method)) {
return methodNotAllowedResponse(["GET", "HEAD"], mode);
@@ -29,6 +31,9 @@ export function validatePagination(
if (!Number.isInteger(pageSize) || pageSize <= 0) {
return jsonResponse(createApiError("Invalid pageSize parameter", 400), { mode, status: 400 });
}
if (pageSize > MAX_PAGE_SIZE) {
return jsonResponse(createApiError(`pageSize must not exceed ${MAX_PAGE_SIZE}`, 400), { mode, status: 400 });
}
}
return { page, pageSize };

View File

@@ -7,11 +7,12 @@ export function handleTargets(store: ProbeStore, method: string, mode: RuntimeMo
const targets = store.getTargets();
const latestChecksMap = store.getLatestChecksMap();
const allStats = store.getAllTargetStats();
const allRecentSamples = store.getAllRecentSamples(30);
const result: TargetStatus[] = targets.map((target) => {
const latest = latestChecksMap.get(target.id) ?? null;
const stats = allStats.get(target.id) ?? { availability: 0, totalChecks: 0 };
const recentSamples = store.getRecentSamples(target.id, 30);
const recentSamples = allRecentSamples.get(target.id) ?? [];
return {
group: target.grp,

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> {
override state: State = { hasError: false };
static getDerivedStateFromError(): State {
return { hasError: true };
}
override componentDidCatch(error: Error, info: ErrorInfo): void {
console.error("渲染错误:", error, info.componentStack);
}
override 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 { 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>
<ErrorBoundary>
<QueryClientProvider client={queryClient}>
<App />
<ReactQueryDevtools initialIsOpen={false} />
</QueryClientProvider>
</ErrorBoundary>
</StrictMode>,
);

View File

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

View File

@@ -7,8 +7,8 @@ import type { HealthResponse, HistoryResponse, SummaryResponse, TargetStatus } f
import { createFetchHandler, type StaticAssets } from "../../src/server/app";
import { checkerRegistry } from "../../src/server/checker/runner";
import { CommandChecker } from "../../src/server/checker/runner/command/runner";
import { HttpChecker } from "../../src/server/checker/runner/http/runner";
import { CommandChecker } from "../../src/server/checker/runner/command/execute";
import { HttpChecker } from "../../src/server/checker/runner/http/execute";
import { ProbeStore } from "../../src/server/checker/store";
import { rmRetry } from "../helpers";
@@ -183,6 +183,19 @@ describe("API 路由", () => {
expect(body.total).toBe(2);
});
test("history pageSize 超过上限返回 400", async () => {
const targets = store.getTargets();
const from = "2024-01-01T00:00:00.000Z";
const to = "2026-12-31T23:59:59.999Z";
const response = fetchHandler(
new Request(`http://localhost/api/targets/${targets[0]!.id}/history?from=${from}&to=${to}&pageSize=201`),
);
const body = (await response.json()) as Record<string, unknown>;
expect(response.status).toBe(400);
expect(body["error"]).toBe("pageSize must not exceed 200");
});
test("/api/targets/:id/trend 返回趋势数据", async () => {
const targets = store.getTargets();
const from = "2024-01-01T00:00:00.000Z";

View File

@@ -0,0 +1,141 @@
import { describe, expect, test } from "bun:test";
import type { StaticAssets } from "../../src/server/app";
import type { ResolvedConfig } from "../../src/server/checker/config-loader";
import type { ProbeEngine } from "../../src/server/checker/engine";
import type { ProbeStore } from "../../src/server/checker/store";
import type { ResolvedTargetBase } from "../../src/server/checker/types";
import { bootstrap, type BootstrapDependencies } from "../../src/server/bootstrap";
type ShutdownSignal = "SIGINT" | "SIGTERM";
const target: ResolvedTargetBase = {
group: "default",
intervalMs: 30000,
name: "test",
timeoutMs: 5000,
type: "command",
};
function createHarness(overrides: BootstrapDependencies = {}) {
const calls: string[] = [];
const shutdownHandlers = new Map<ShutdownSignal, () => void>();
const config: ResolvedConfig = {
configDir: "/tmp",
dataDir: "/tmp/dial-data",
host: "127.0.0.1",
maxConcurrentChecks: 3,
port: 3000,
retentionMs: 1000,
targets: [target],
};
const store = {
close() {
calls.push("store.close");
},
syncTargets(targets: ResolvedTargetBase[]) {
calls.push(`syncTargets:${targets.length}`);
},
} as unknown as ProbeStore;
const engine = {
start() {
calls.push("engine.start");
},
stop() {
calls.push("engine.stop");
},
} as unknown as ProbeEngine;
const dependencies: BootstrapDependencies = {
createEngine(actualStore, targets, maxConcurrentChecks, retentionMs) {
expect(actualStore).toBe(store);
calls.push(`createEngine:${targets.length}:${maxConcurrentChecks}:${retentionMs}`);
return engine;
},
createStore(dbPath) {
calls.push(`createStore:${dbPath}`);
return store;
},
exit(code) {
calls.push(`exit:${code}`);
throw new Error(`exit:${code}`);
},
loadConfig(configPath) {
calls.push(`loadConfig:${configPath}`);
return Promise.resolve(config);
},
logError(...data) {
calls.push(`logError:${String(data[1])}`);
},
onSignal(signal, handler) {
calls.push(`onSignal:${signal}`);
shutdownHandlers.set(signal, handler);
},
startServer(options) {
expect(options.config).toEqual({ host: config.host, port: config.port });
expect(options.store).toBe(store);
calls.push(`startServer:${options.mode}:${options.staticAssets ? "static" : "no-static"}`);
},
...overrides,
};
return { calls, dependencies, shutdownHandlers };
}
describe("bootstrap", () => {
test("开发模式执行完整启动序列", async () => {
const { calls, dependencies } = createHarness();
await bootstrap({ configPath: "/tmp/probes.yaml", mode: "development" }, dependencies);
expect(calls).toEqual([
"loadConfig:/tmp/probes.yaml",
"createStore:/tmp/dial-data/probe.db",
"syncTargets:1",
"createEngine:1:3:1000",
"engine.start",
"onSignal:SIGINT",
"onSignal:SIGTERM",
"startServer:development:no-static",
]);
});
test("生产模式传递 staticAssets", async () => {
const { calls, dependencies } = createHarness();
const staticAssets: StaticAssets = { files: {}, indexHtml: new Blob(["ok"]) };
await bootstrap({ configPath: "/tmp/probes.yaml", mode: "production", staticAssets }, dependencies);
expect(calls.at(-1)).toBe("startServer:production:static");
});
test("收到退出信号时停止 engine 并关闭 store", async () => {
const { calls, dependencies, shutdownHandlers } = createHarness();
await bootstrap({ configPath: "/tmp/probes.yaml", mode: "development" }, dependencies);
expect(() => shutdownHandlers.get("SIGINT")!()).toThrow("exit:0");
expect(calls.slice(-3)).toEqual(["engine.stop", "store.close", "exit:0"]);
});
test("启动失败时输出错误并以非零退出", async () => {
const { calls, dependencies } = createHarness({
loadConfig() {
return Promise.reject(new Error("bad config"));
},
});
let error: unknown;
try {
await bootstrap({ configPath: "/tmp/probes.yaml", mode: "development" }, dependencies);
} catch (caught) {
error = caught;
}
expect(error).toBeInstanceOf(Error);
expect((error as Error).message).toBe("exit:1");
expect(calls).toEqual(["logError:bad config", "exit:1"]);
});
});

View File

@@ -0,0 +1,71 @@
import Ajv from "ajv";
import { describe, expect, test } from "bun:test";
import { createDefaultCheckerRegistry } from "../../../../src/server/checker/runner";
import { createProbeConfigJsonSchema } from "../../../../src/server/checker/schema/export";
import { formatConfigIssues, issue } from "../../../../src/server/checker/schema/issues";
import { validateProbeConfigContract } from "../../../../src/server/checker/schema/validate";
describe("config contract", () => {
test("导出的 probe-config.schema.json 与 fragments 生成结果一致", async () => {
const expected = `${JSON.stringify(createProbeConfigJsonSchema(createDefaultCheckerRegistry()), null, 2)}\n`;
const actual = await Bun.file("probe-config.schema.json").text();
expect(actual).toBe(expected);
});
test("导出 schema 拒绝未知字段和小写 HTTP method", () => {
const ajv = new Ajv({
allErrors: true,
coerceTypes: false,
removeAdditional: false,
strict: true,
useDefaults: false,
});
const validate = ajv.compile(createProbeConfigJsonSchema(createDefaultCheckerRegistry()));
expect(
validate({
targets: [
{
http: { method: "get", unknownHttpField: true, url: "https://example.com" },
name: "api",
type: "http",
},
],
}),
).toBe(false);
});
test("Ajv 错误转换为中文结构化 issue", () => {
const result = validateProbeConfigContract(
{
targets: [
{
group: 123,
http: { extra: true },
name: "api",
type: "http",
},
],
unknownRoot: true,
},
createDefaultCheckerRegistry(),
);
expect(result.config).toBeNull();
const message = formatConfigIssues(result.issues);
expect(message).toContain("unknownRoot 是未知字段");
expect(message).toContain('target "api" 的 group 类型不合法');
expect(message).toContain('target "api" 的 http.url 缺少必填字段');
expect(message).toContain('target "api" 的 http.extra 是未知字段');
});
test("ConfigValidationIssue 聚合渲染保留契约和语义错误", () => {
const message = formatConfigIssues([
issue("unknown-field", "targets[0].http.extra", "是未知字段", "api"),
issue("invalid-regex", "targets[0].expect.body[0].regex", "正则不合法", "api"),
]);
expect(message).toBe('target "api" 的 http.extra 是未知字段\ntarget "api" 的 expect.body[0].regex 正则不合法');
});
});

View File

@@ -3,10 +3,13 @@ import { mkdir, rm, writeFile } from "node:fs/promises";
import { tmpdir } from "node:os";
import { join } from "node:path";
import type { ResolvedCommandTarget } from "../../../src/server/checker/runner/command/types";
import type { ResolvedHttpTarget } from "../../../src/server/checker/runner/http/types";
import { loadConfig, parseDuration } from "../../../src/server/checker/config-loader";
import { checkerRegistry } from "../../../src/server/checker/runner";
import { CommandChecker } from "../../../src/server/checker/runner/command/runner";
import { HttpChecker } from "../../../src/server/checker/runner/http/runner";
import { CommandChecker } from "../../../src/server/checker/runner/command/execute";
import { HttpChecker } from "../../../src/server/checker/runner/http/execute";
import { readRuntimeConfig } from "../../../src/server/config";
function ensureRegistered() {
@@ -40,6 +43,21 @@ 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("正整数毫秒");
});
test("无效格式抛出错误", () => {
expect(() => parseDuration("30")).toThrow("无效的时长格式");
expect(() => parseDuration("abc")).toThrow("无效的时长格式");
@@ -70,6 +88,19 @@ describe("loadConfig", () => {
await rm(tempDir, { force: true, recursive: true });
});
async function expectConfigError(fileName: string, content: string, message: string): Promise<void> {
const configPath = join(tempDir, fileName);
await writeFile(configPath, content);
let error: unknown;
try {
await loadConfig(configPath);
} catch (caught) {
error = caught;
}
expect(error).toBeInstanceOf(Error);
expect((error as Error).message).toContain(message);
}
test("解析最简 HTTP 配置", async () => {
const configPath = join(tempDir, "minimal-http.yaml");
await writeFile(
@@ -85,12 +116,11 @@ describe("loadConfig", () => {
const config = await loadConfig(configPath);
expect(config.host).toBe("127.0.0.1");
expect(config.port).toBe(3000);
expect(config.dataDir).toBe("./data");
expect(config.dataDir).toBe(join(tempDir, "data"));
expect(config.maxConcurrentChecks).toBe(20);
expect(config.targets).toHaveLength(1);
const t = config.targets[0]!;
const t = config.targets[0]! as ResolvedHttpTarget;
expect(t.type).toBe("http");
if (t.type === "http") {
expect(t.name).toBe("test");
expect(t.http.url).toBe("http://example.com");
expect(t.http.method).toBe("GET");
@@ -100,7 +130,6 @@ describe("loadConfig", () => {
expect(t.http.maxRedirects).toBe(0);
expect(t.intervalMs).toBe(30000);
expect(t.timeoutMs).toBe(10000);
}
});
test("解析最简 command 配置", async () => {
@@ -120,16 +149,14 @@ describe("loadConfig", () => {
const config = await loadConfig(configPath);
expect(config.targets).toHaveLength(1);
const t = config.targets[0]!;
const t = config.targets[0]! as ResolvedCommandTarget;
expect(t.type).toBe("command");
if (t.type === "command") {
expect(t.name).toBe("check-nginx");
expect(t.command.exec).toBe("pgrep");
expect(t.command.args).toEqual(["nginx"]);
expect(t.command.cwd).toBe(subdir);
expect(t.command.maxOutputBytes).toBe(104857600);
expect(t.command.env["PATH"]).toBeDefined();
}
});
test("解析完整配置", async () => {
@@ -178,13 +205,12 @@ targets:
const config = await loadConfig(configPath);
expect(config.host).toBe("0.0.0.0");
expect(config.port).toBe(8080);
expect(config.dataDir).toBe("./my-data");
expect(config.dataDir).toBe(join(tempDir, "my-data"));
expect(config.maxConcurrentChecks).toBe(5);
expect(config.targets).toHaveLength(2);
const http = config.targets[0]!;
const http = config.targets[0]! as ResolvedHttpTarget;
expect(http.type).toBe("http");
if (http.type === "http") {
expect(http.http.url).toBe("http://example.com");
expect(http.http.method).toBe("POST");
expect(http.http.headers).toEqual({ Authorization: "Bearer token" });
@@ -194,15 +220,31 @@ targets:
expect(http.expect?.status).toEqual(["2xx", 301]);
expect(http.intervalMs).toBe(60000);
expect(http.timeoutMs).toBe(5000);
}
const cmd = config.targets[1]!;
const cmd = config.targets[1]! as ResolvedCommandTarget;
expect(cmd.type).toBe("command");
if (cmd.type === "command") {
expect(cmd.command.exec).toBe("ls");
expect(cmd.command.args).toEqual(["/tmp"]);
expect(cmd.command.maxOutputBytes).toBe(10485760);
}
});
test("绝对 dataDir 保持不变", async () => {
const dataDir = join(tempDir, "absolute-data");
const configPath = join(tempDir, "absolute-data-dir.yaml");
await writeFile(
configPath,
`server:
dataDir: "${dataDir}"
targets:
- name: "test"
type: http
http:
url: "http://example.com"
`,
);
const config = await loadConfig(configPath);
expect(config.dataDir).toBe(dataDir);
});
test("per-target 覆盖 defaults", async () => {
@@ -228,13 +270,11 @@ targets:
);
const config = await loadConfig(configPath);
const t = config.targets[0]!;
if (t.type === "http") {
const t = config.targets[0]! as ResolvedHttpTarget;
expect(t.http.method).toBe("POST");
expect(t.intervalMs).toBe(300000);
expect(t.timeoutMs).toBe(30000);
expect(t.http.maxBodyBytes).toBe(1048576);
}
});
test("配置文件不存在抛出错误", async () => {
@@ -310,7 +350,7 @@ targets:
`,
);
// eslint-disable-next-line @typescript-eslint/await-thenable
await expect(loadConfig(configPath)).rejects.toThrow("ignoreSSL 必须为布尔值");
await expect(loadConfig(configPath)).rejects.toThrow("http.ignoreSSL 类型不合法");
});
test("HTTP target maxRedirects 非负整数校验", async () => {
@@ -326,7 +366,7 @@ targets:
`,
);
// eslint-disable-next-line @typescript-eslint/await-thenable
await expect(loadConfig(configPath)).rejects.toThrow("maxRedirects 必须为非负整数");
await expect(loadConfig(configPath)).rejects.toThrow("http.maxRedirects 类型不合法");
});
test("HTTP target status 模式非法抛出错误", async () => {
@@ -413,7 +453,7 @@ targets:
`,
);
// eslint-disable-next-line @typescript-eslint/await-thenable
await expect(loadConfig(configPath)).rejects.toThrow("无效端口号");
await expect(loadConfig(configPath)).rejects.toThrow("server.port 数值范围不合法");
});
test("非法 maxConcurrentChecks 抛出错误", async () => {
@@ -430,7 +470,7 @@ targets:
`,
);
// eslint-disable-next-line @typescript-eslint/await-thenable
await expect(loadConfig(configPath)).rejects.toThrow("maxConcurrentChecks 必须为正整数");
await expect(loadConfig(configPath)).rejects.toThrow("runtime.maxConcurrentChecks 数值范围不合法");
});
test("非法 size 格式抛出错误", async () => {
@@ -546,10 +586,8 @@ targets:
);
const config = await loadConfig(configPath);
const t = config.targets[0]!;
if (t.type === "command") {
const t = config.targets[0] as ResolvedCommandTarget;
expect(t.command.cwd).toBe(join(subdir, "scripts"));
}
});
test("command env 覆盖", async () => {
@@ -568,12 +606,10 @@ targets:
);
const config = await loadConfig(configPath);
const t = config.targets[0]!;
if (t.type === "command") {
const t = config.targets[0] as ResolvedCommandTarget;
expect(t.command.env["LANG"]).toBe("C");
expect(t.command.env["CUSTOM_VAR"]).toBe("test");
expect(t.command.env["PATH"]).toBeDefined();
}
});
test("解析 group 字段", async () => {
@@ -623,7 +659,7 @@ targets:
);
// eslint-disable-next-line @typescript-eslint/await-thenable
await expect(loadConfig(configPath)).rejects.toThrow("group 字段必须为字符串");
await expect(loadConfig(configPath)).rejects.toThrow("group 必须为字符串");
});
test("HTTP headers 非字符串值抛出错误", async () => {
@@ -656,7 +692,7 @@ targets:
`,
);
// eslint-disable-next-line @typescript-eslint/await-thenable
await expect(loadConfig(configPath)).rejects.toThrow("http.body 必须为字符串");
await expect(loadConfig(configPath)).rejects.toThrow("http.body 类型不合法");
});
test("maxBodyBytes 负数抛出错误", async () => {
@@ -930,7 +966,7 @@ targets:
await expect(loadConfig(configPath)).rejects.toThrow("exists 必须为布尔值");
});
test("未知字段忽略不影响启动", async () => {
test("未知字段导致启动失败", async () => {
const configPath = join(tempDir, "unknown-fields.yaml");
await writeFile(
configPath,
@@ -948,12 +984,8 @@ targets:
note: "ignored"
`,
);
const config = await loadConfig(configPath);
expect(config.targets).toHaveLength(1);
const t = config.targets[0]!;
if (t.type === "http") {
expect(t.expect?.status).toEqual([200]);
}
// eslint-disable-next-line @typescript-eslint/await-thenable
await expect(loadConfig(configPath)).rejects.toThrow("unknownHttpField 是未知字段");
});
test("xpath path 非空字符串校验", async () => {
@@ -989,6 +1021,273 @@ targets:
`,
);
// eslint-disable-next-line @typescript-eslint/await-thenable
await expect(loadConfig(configPath)).rejects.toThrow("expect.headers 必须为对象");
await expect(loadConfig(configPath)).rejects.toThrow("expect.headers 类型不合法");
});
test("HTTP method 小写输入失败", async () => {
await expectConfigError(
"lowercase-method.yaml",
`targets:
- name: "test"
type: http
http:
url: "http://example.com"
method: get
`,
"http.method 不在允许范围内",
);
});
test("defaults.http.method 小写输入失败", async () => {
await expectConfigError(
"lowercase-default-method.yaml",
`defaults:
http:
method: post
targets:
- name: "test"
type: http
http:
url: "http://example.com"
`,
"defaults.http.method 不在允许范围内",
);
});
test("HTTP method 大写输入通过", async () => {
const configPath = join(tempDir, "uppercase-method.yaml");
await writeFile(
configPath,
`targets:
- name: "test"
type: http
http:
url: "http://example.com"
method: POST
`,
);
const config = await loadConfig(configPath);
const target = config.targets[0] as ResolvedHttpTarget;
expect(target.type).toBe("http");
expect(target.http.method).toBe("POST");
});
test("动态 headers 和 env 允许任意键名", async () => {
const configPath = join(tempDir, "dynamic-maps.yaml");
await writeFile(
configPath,
`defaults:
http:
headers:
X-Default-Header: "default"
targets:
- name: "http-test"
type: http
http:
url: "http://example.com"
headers:
X-Custom-Header: "custom"
expect:
headers:
X-Response-Header:
contains: "ok"
- name: "cmd-test"
type: command
command:
exec: "true"
env:
CUSTOM_ENV_NAME: "custom"
`,
);
const config = await loadConfig(configPath);
const http = config.targets[0] as ResolvedHttpTarget;
const command = config.targets[1] as ResolvedCommandTarget;
expect(http.type).toBe("http");
expect(command.type).toBe("command");
expect(http.http.headers["X-Default-Header"]).toBe("default");
expect(http.http.headers["X-Custom-Header"]).toBe("custom");
expect(command.command.env["CUSTOM_ENV_NAME"]).toBe("custom");
});
test("command args 类型非法", async () => {
await expectConfigError(
"bad-command-args.yaml",
`targets:
- name: "cmd"
type: command
command:
exec: "echo"
args: "hello"
`,
"command.args 类型不合法",
);
});
test("command cwd 类型非法", async () => {
await expectConfigError(
"bad-command-cwd.yaml",
`targets:
- name: "cmd"
type: command
command:
exec: "echo"
cwd: 123
`,
"command.cwd 类型不合法",
);
});
test("command env 值类型非法", async () => {
await expectConfigError(
"bad-command-env.yaml",
`targets:
- name: "cmd"
type: command
command:
exec: "echo"
env:
COUNT: 123
`,
"command.env.COUNT 类型不合法",
);
});
test("command maxOutputBytes 非法", async () => {
await expectConfigError(
"bad-command-max-output.yaml",
`targets:
- name: "cmd"
type: command
command:
exec: "echo"
maxOutputBytes: "1TB"
`,
"maxOutputBytes 无效的 size 格式",
);
});
test("command expect exitCode 类型非法", async () => {
await expectConfigError(
"bad-command-exit-code.yaml",
`targets:
- name: "cmd"
type: command
command:
exec: "echo"
expect:
exitCode: [1.5]
`,
"expect.exitCode[0] 类型不合法",
);
});
test("command stdout 空 text rule 非法", async () => {
await expectConfigError(
"bad-command-stdout-empty.yaml",
`targets:
- name: "cmd"
type: command
command:
exec: "echo"
expect:
stdout:
- {}
`,
"stdout[0] 必须包含至少一个合法 operator",
);
});
test("command stderr 未知 operator 非法", async () => {
await expectConfigError(
"bad-command-stderr-operator.yaml",
`targets:
- name: "cmd"
type: command
command:
exec: "echo"
expect:
stderr:
- foo: "bar"
`,
"expect.stderr[0].foo 是未知字段",
);
});
test("command stdout match 正则非法", async () => {
await expectConfigError(
"bad-command-stdout-regex.yaml",
`targets:
- name: "cmd"
type: command
command:
exec: "echo"
expect:
stdout:
- match: "[invalid"
`,
"stdout[0].match 正则不合法",
);
});
test("command expect 未知字段失败", async () => {
await expectConfigError(
"bad-command-expect-unknown.yaml",
`targets:
- name: "cmd"
type: command
command:
exec: "echo"
expect:
status: [200]
`,
"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

@@ -1,12 +1,14 @@
import { describe, expect, test } from "bun:test";
import type { ResolvedCommandTarget } from "../../../src/server/checker/runner/command/types";
import type { ResolvedHttpTarget } from "../../../src/server/checker/runner/http/types";
import type { ProbeStore } from "../../../src/server/checker/store";
import type { ResolvedCommandTarget, ResolvedHttpTarget, ResolvedTarget } from "../../../src/server/checker/types";
import type { ResolvedTargetBase } from "../../../src/server/checker/types";
import { ProbeEngine } from "../../../src/server/checker/engine";
import { checkerRegistry } from "../../../src/server/checker/runner";
import { CommandChecker } from "../../../src/server/checker/runner/command/runner";
import { HttpChecker } from "../../../src/server/checker/runner/http/runner";
import { CommandChecker } from "../../../src/server/checker/runner/command/execute";
import { HttpChecker } from "../../../src/server/checker/runner/http/execute";
function createMockStore(targetNames: string[]) {
let nextId = 1;
@@ -63,7 +65,7 @@ describe("ProbeEngine", () => {
test("start/stop 不抛错", () => {
ensureRegistered();
const mockStore = createMockStore(["test"]) as unknown as ProbeStore;
const targets: ResolvedTarget[] = [makeCommandTarget("test")];
const targets: ResolvedTargetBase[] = [makeCommandTarget("test")];
const engine = new ProbeEngine(mockStore, targets);
engine.start();
engine.stop();
@@ -75,9 +77,9 @@ describe("ProbeEngine", () => {
const mockStore = createMockStore(["cmd-echo"]) as unknown as ProbeStore;
const engine = new ProbeEngine(mockStore, [target]);
const probeGroup = (engine as unknown as { probeGroup: (t: ResolvedTarget[]) => Promise<void> }).probeGroup.bind(
engine,
);
const probeGroup = (
engine as unknown as { probeGroup: (t: ResolvedTargetBase[]) => Promise<void> }
).probeGroup.bind(engine);
await probeGroup([target]);
const results = (mockStore as unknown as { _results: Array<Record<string, unknown>> })._results;
@@ -97,9 +99,9 @@ describe("ProbeEngine", () => {
const mockStore = createMockStore(["echo-a", "echo-b"]) as unknown as ProbeStore;
const engine = new ProbeEngine(mockStore, [targetA, targetB]);
const probeGroup = (engine as unknown as { probeGroup: (t: ResolvedTarget[]) => Promise<void> }).probeGroup.bind(
engine,
);
const probeGroup = (
engine as unknown as { probeGroup: (t: ResolvedTargetBase[]) => Promise<void> }
).probeGroup.bind(engine);
await probeGroup([targetA, targetB]);
const results = (mockStore as unknown as { _results: Array<Record<string, unknown>> })._results;
@@ -115,9 +117,9 @@ describe("ProbeEngine", () => {
const mockStore = createMockStore(["bad-cmd", "good-cmd"]) as unknown as ProbeStore;
const engine = new ProbeEngine(mockStore, [badTarget, goodTarget]);
const probeGroup = (engine as unknown as { probeGroup: (t: ResolvedTarget[]) => Promise<void> }).probeGroup.bind(
engine,
);
const probeGroup = (
engine as unknown as { probeGroup: (t: ResolvedTargetBase[]) => Promise<void> }
).probeGroup.bind(engine);
await probeGroup([badTarget, goodTarget]);
const results = (mockStore as unknown as { _results: Array<Record<string, unknown>> })._results;
@@ -129,6 +131,48 @@ describe("ProbeEngine", () => {
expect(goodResult).toBeDefined();
});
test("checker rejected 时写入 internal error 结果", async () => {
ensureRegistered();
const checker = checkerRegistry.get("command");
const originalExecute = checker.execute.bind(checker);
checker.execute = async (target, ctx) => {
if (target.name === "reject-cmd") {
throw new Error("boom");
}
return originalExecute(target, ctx);
};
try {
const rejectTarget = makeCommandTarget("reject-cmd");
const goodTarget = makeCommandTarget("good-cmd");
const mockStore = createMockStore(["reject-cmd", "good-cmd"]) as unknown as ProbeStore;
const engine = new ProbeEngine(mockStore, [rejectTarget, goodTarget]);
const probeGroup = (
engine as unknown as { probeGroup: (t: ResolvedTargetBase[]) => Promise<void> }
).probeGroup.bind(engine);
await probeGroup([rejectTarget, goodTarget]);
const results = (mockStore as unknown as { _results: Array<Record<string, unknown>> })._results;
expect(results.length).toBe(2);
expect(results[0]!["targetId"]).toBe(1);
expect(results[0]!["matched"]).toBe(false);
expect(results[0]!["durationMs"]).toBeNull();
expect(results[0]!["statusDetail"]).toBeNull();
expect(results[0]!["failure"]).toEqual({
kind: "error",
message: "boom",
path: "engine",
phase: "internal",
});
expect(typeof results[0]!["timestamp"]).toBe("string");
expect(results[1]!["targetId"]).toBe(2);
expect(results[1]!["matched"]).toBe(true);
} finally {
checker.execute = originalExecute;
}
});
test("并发限制 maxConcurrentChecks", async () => {
const targets = Array.from({ length: 5 }, (_, i) =>
makeCommandTarget(`cmd-${i}`, {
@@ -139,9 +183,9 @@ describe("ProbeEngine", () => {
const mockStore = createMockStore(targets.map((t) => t.name)) as unknown as ProbeStore;
const engine = new ProbeEngine(mockStore, targets, 2);
const probeGroup = (engine as unknown as { probeGroup: (t: ResolvedTarget[]) => Promise<void> }).probeGroup.bind(
engine,
);
const probeGroup = (
engine as unknown as { probeGroup: (t: ResolvedTargetBase[]) => Promise<void> }
).probeGroup.bind(engine);
await probeGroup(targets);
const results = (mockStore as unknown as { _results: Array<Record<string, unknown>> })._results;
@@ -168,9 +212,9 @@ describe("ProbeEngine", () => {
const mockStore = createMockStore(["other-name"]) as unknown as ProbeStore;
const engine = new ProbeEngine(mockStore, [target]);
const probeGroup = (engine as unknown as { probeGroup: (t: ResolvedTarget[]) => Promise<void> }).probeGroup.bind(
engine,
);
const probeGroup = (
engine as unknown as { probeGroup: (t: ResolvedTargetBase[]) => Promise<void> }
).probeGroup.bind(engine);
await probeGroup([target]);
const results = (mockStore as unknown as { _results: Array<Record<string, unknown>> })._results;
@@ -205,9 +249,9 @@ describe("ProbeEngine", () => {
const mockStore = createMockStore(["http-test"]) as unknown as ProbeStore;
const engine = new ProbeEngine(mockStore, [httpTarget]);
const probeGroup = (engine as unknown as { probeGroup: (t: ResolvedTarget[]) => Promise<void> }).probeGroup.bind(
engine,
);
const probeGroup = (
engine as unknown as { probeGroup: (t: ResolvedTargetBase[]) => Promise<void> }
).probeGroup.bind(engine);
await probeGroup([httpTarget]);
const results = (mockStore as unknown as { _results: Array<Record<string, unknown>> })._results;
@@ -218,4 +262,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();
});
});

View File

@@ -1,12 +1,16 @@
import { describe, expect, test } from "bun:test";
import type { ResolvedCommandTarget } from "../../../../../src/server/checker/runner/command/types";
import type { CheckerContext } from "../../../../../src/server/checker/runner/types";
import type { ResolvedCommandTarget } from "../../../../../src/server/checker/types";
import { CommandChecker } from "../../../../../src/server/checker/runner/command/runner";
import { CommandChecker } from "../../../../../src/server/checker/runner/command/execute";
const checker = new CommandChecker();
const processEnv = Object.fromEntries(
Object.entries(process.env).filter((entry): entry is [string, string] => entry[1] !== undefined),
);
function makeCtx(timeoutMs = 5000): CheckerContext {
const controller = new AbortController();
setTimeout(() => controller.abort(), timeoutMs);
@@ -21,7 +25,7 @@ function makeTarget(
command: {
args: ["hello"],
cwd: "/tmp",
env: {},
env: processEnv,
exec: "echo",
maxOutputBytes: 1024 * 1024,
...command,
@@ -125,6 +129,22 @@ describe("CommandChecker", () => {
expect(result.matched).toBe(true);
});
test("execute 使用 resolved env", async () => {
const result = await checker.execute(
makeTarget(
{
args: ["-e", "console.log(process.env.DIAL_TEST_ENV ?? '')"],
env: { DIAL_TEST_ENV: "resolved-env" },
exec: process.execPath,
},
{ expect: { stdout: [{ contains: "resolved-env" }] } },
),
makeCtx(),
);
expect(result.matched).toBe(true);
});
test("serialize 返回命令摘要和 config JSON", () => {
const target = makeTarget({ args: ["hello"], exec: "echo" });
const s = checker.serialize(target);

View File

@@ -1,152 +1,66 @@
import { describe, expect, test } from "bun:test";
import { checkHttpExpect, checkStatus } from "../../../../../src/server/checker/runner/http/expect";
import { checkHeaders, checkStatus } from "../../../../../src/server/checker/runner/http/expect";
function obs(
overrides: { body?: null | string; durationMs?: number; headers?: Record<string, string>; statusCode?: number } = {},
) {
return {
body: overrides.body ?? "",
durationMs: overrides.durationMs ?? 100,
headers: overrides.headers ?? {},
statusCode: overrides.statusCode ?? 200,
};
}
describe("checkHttpExpect", () => {
test("无 expect 配置时默认检查 status [200] 匹配成功", () => {
const r = checkHttpExpect(obs().statusCode, obs().headers, obs().body, obs().durationMs);
expect(r.matched).toBe(true);
expect(r.failure).toBeNull();
describe("checkHeaders", () => {
test("未配置 headers expect 时匹配成功", () => {
const result = checkHeaders({});
expect(result.matched).toBe(true);
expect(result.failure).toBeNull();
});
test("无 expect 配置时 status 非 200 匹配失败", () => {
const r = checkHttpExpect(500, {}, "", 100);
expect(r.matched).toBe(false);
expect(r.failure).not.toBeNull();
expect(r.failure!.phase).toBe("status");
expect(r.failure!.kind).toBe("mismatch");
test("字符串格式按等值匹配", () => {
const headers = { "content-type": "application/json", "x-api": "v1" };
expect(checkHeaders(headers, { "content-type": "application/json" }).matched).toBe(true);
expect(checkHeaders(headers, { "content-type": "text/html" }).matched).toBe(false);
});
test("status 匹配指定状态码", () => {
const cfg = { status: [200, 301] };
expect(checkHttpExpect(200, {}, "", 100, cfg).matched).toBe(true);
expect(checkHttpExpect(301, {}, "", 100, cfg).matched).toBe(true);
expect(checkHttpExpect(404, {}, "", 100, cfg).matched).toBe(false);
test("header 名称按小写响应头匹配", () => {
const headers = { "content-type": "application/json" };
expect(checkHeaders(headers, { "Content-Type": "application/json" }).matched).toBe(true);
});
test("status 不匹配返回 phase=status 的失败", () => {
const r = checkHttpExpect(503, {}, "", 100, { status: [200] });
expect(r.matched).toBe(false);
expect(r.failure!.phase).toBe("status");
expect(r.failure!.expected).toEqual([200]);
expect(r.failure!.actual).toBe(503);
test("操作符格式匹配", () => {
const headers = { "content-type": "application/json" };
expect(checkHeaders(headers, { "content-type": { contains: "json" } }).matched).toBe(true);
expect(checkHeaders(headers, { "content-type": { match: "^application/" } }).matched).toBe(true);
expect(checkHeaders(headers, { "content-type": { contains: "xml" } }).matched).toBe(false);
});
test("duration 在限制内匹配成功", () => {
const r = checkHttpExpect(200, {}, "", 50, { maxDurationMs: 100 });
expect(r.matched).toBe(true);
test("缺失 header 默认返回失败", () => {
const result = checkHeaders({}, { "x-missing": "value" });
expect(result.matched).toBe(false);
expect(result.failure!.phase).toBe("headers");
expect(result.failure!.kind).toBe("mismatch");
});
test("duration 超过限制匹配失败", () => {
const r = checkHttpExpect(200, {}, "", 200, { maxDurationMs: 100 });
expect(r.matched).toBe(false);
expect(r.failure!.phase).toBe("duration");
});
test("缺失 header 且 exists=false 时匹配成功", () => {
const result = checkHeaders({}, { "x-missing": { exists: false } });
test("duration 恰好等于限制匹配成功", () => {
const r = checkHttpExpect(200, {}, "", 100, { maxDurationMs: 100 });
expect(r.matched).toBe(true);
});
test("headers 字符串格式检查(等于)", () => {
const h = { "content-type": "application/json", "x-api": "v1" };
expect(checkHttpExpect(200, h, "", 100, { headers: { "content-type": "application/json" } }).matched).toBe(true);
expect(checkHttpExpect(200, h, "", 100, { headers: { "content-type": "text/html" } }).matched).toBe(false);
});
test("headers 操作符格式检查", () => {
const h = { "content-type": "application/json" };
expect(checkHttpExpect(200, h, "", 100, { headers: { "content-type": { contains: "json" } } }).matched).toBe(true);
expect(checkHttpExpect(200, h, "", 100, { headers: { "content-type": { match: "^application/" } } }).matched).toBe(
true,
);
expect(checkHttpExpect(200, h, "", 100, { headers: { "content-type": { contains: "xml" } } }).matched).toBe(false);
});
test("headers 大小写不敏感匹配", () => {
const h = { "content-type": "application/json" };
expect(checkHttpExpect(200, h, "", 100, { headers: { "Content-Type": "application/json" } }).matched).toBe(true);
});
test("headers 不存在时返回失败", () => {
const r = checkHttpExpect(200, {}, "", 100, { headers: { "x-missing": "value" } });
expect(r.matched).toBe(false);
expect(r.failure!.phase).toBe("headers");
});
test("body 规则数组按顺序检查", () => {
const body = JSON.stringify({ count: 5, status: "ok" });
const r = checkHttpExpect(200, {}, body, 100, {
body: [{ contains: "ok" }, { json: { gte: 1, path: "$.count" } }],
});
expect(r.matched).toBe(true);
});
test("body 第一条规则失败立即返回", () => {
const r = checkHttpExpect(200, {}, "hello world", 100, {
body: [{ contains: "missing" }, { contains: "hello" }],
});
expect(r.matched).toBe(false);
expect(r.failure!.path).toBe("body[0]");
});
test("body 为 null 但有 body 规则时报错", () => {
const r = checkHttpExpect(200, {}, null, 100, { body: [{ contains: "test" }] });
expect(r.matched).toBe(false);
expect(r.failure!.kind).toBe("error");
});
test("完整流水线 status->headers->body->duration 全部通过", () => {
const r = checkHttpExpect(200, { "content-type": "application/json" }, JSON.stringify({ status: "healthy" }), 50, {
body: [{ json: { equals: "healthy", path: "$.status" } }],
headers: { "content-type": { contains: "json" } },
maxDurationMs: 100,
status: [200],
});
expect(r.matched).toBe(true);
expect(r.failure).toBeNull();
});
test("完整流水线 status 和 headers 通过但 duration 失败", () => {
const r = checkHttpExpect(200, {}, "", 500, { maxDurationMs: 100, status: [200] });
expect(r.matched).toBe(false);
expect(r.failure!.phase).toBe("duration");
});
test("完整流水线 status 通过但 headers 失败", () => {
const r = checkHttpExpect(200, { "x-api": "v1" }, "", 50, {
headers: { "x-api": "v2" },
maxDurationMs: 100,
status: [200],
});
expect(r.matched).toBe(false);
expect(r.failure!.phase).toBe("headers");
});
test("完整流水线 status/headers 通过但 body 失败", () => {
const r = checkHttpExpect(200, { "content-type": "text/plain" }, "error occurred", 50, {
body: [{ contains: "success" }],
headers: { "content-type": "text/plain" },
maxDurationMs: 100,
status: [200],
});
expect(r.matched).toBe(false);
expect(r.failure!.phase).toBe("body");
expect(result.matched).toBe(true);
expect(result.failure).toBeNull();
});
});
describe("checkStatus 范围匹配", () => {
test("无 expect 配置时默认 status [200] 可由调用方使用 checkStatus 表达", () => {
const result = checkStatus(200, [200]);
expect(result.matched).toBe(true);
expect(result.failure).toBeNull();
});
test("status 不匹配返回 phase=status 的失败", () => {
const result = checkStatus(503, [200]);
expect(result.matched).toBe(false);
expect(result.failure!.phase).toBe("status");
expect(result.failure!.expected).toEqual([200]);
expect(result.failure!.actual).toBe(503);
});
test("2xx 范围匹配 200", () => {
expect(checkStatus(200, ["2xx"]).matched).toBe(true);
});
@@ -163,11 +77,11 @@ describe("checkStatus 范围匹配", () => {
expect(checkStatus(503, ["5xx"]).matched).toBe(true);
});
test("混合精确值与范围模式 — 精确命中", () => {
test("混合精确值与范围模式命中精确值", () => {
expect(checkStatus(301, ["2xx", 301]).matched).toBe(true);
});
test("混合精确值与范围模式 — 范围命中", () => {
test("混合精确值与范围模式命中范围", () => {
expect(checkStatus(204, ["2xx", 301]).matched).toBe(true);
});
@@ -180,12 +94,6 @@ describe("checkStatus 范围匹配", () => {
expect(checkStatus(404, [200, 201]).matched).toBe(false);
});
test("范围匹配失败返回 phase=status 的 failure", () => {
const r = checkStatus(404, ["2xx"]);
expect(r.matched).toBe(false);
expect(r.failure!.phase).toBe("status");
});
test("1xx 范围匹配 101", () => {
expect(checkStatus(101, ["1xx"]).matched).toBe(true);
});

View File

@@ -1,12 +1,18 @@
import { afterAll, beforeAll, describe, expect, test } from "bun:test";
import type { ResolvedHttpTarget } from "../../../../../src/server/checker/runner/http/types";
import type { CheckerContext, ResolveContext } from "../../../../../src/server/checker/runner/types";
import type { ResolvedHttpTarget } from "../../../../../src/server/checker/types";
import { HttpChecker } from "../../../../../src/server/checker/runner/http/execute";
import { checkStatus } from "../../../../../src/server/checker/runner/http/expect";
import { HttpChecker } from "../../../../../src/server/checker/runner/http/runner";
import { formatConfigIssues } from "../../../../../src/server/checker/schema/issues";
const checker = new HttpChecker();
function validateHttpTarget(target: unknown): string {
return formatConfigIssues(checker.validate({ defaults: {}, targets: [target as never] }));
}
const SELF_SIGNED_CERT = `-----BEGIN CERTIFICATE-----
MIIDJTCCAg2gAwIBAgIUTwQU8FzvnvxNYR7mMO0DLcnq+wQwDQYJKoZIhvcNAQEL
BQAwFDESMBAGA1UEAwwJbG9jYWxob3N0MB4XDTI2MDUxMjE1NDAyOFoXDTM2MDUw
@@ -561,257 +567,168 @@ describe("HttpChecker", () => {
});
test("6xx 范围模式启动校验失败", () => {
expect(() =>
checker.resolve(
{ expect: { status: ["6xx"] }, http: { url: "https://example.com" }, name: "test", type: "http" },
{ configDir: ".", defaultIntervalMs: 30000, defaults: {}, defaultTimeoutMs: 10000 },
),
).toThrow("5xx");
const errors = validateHttpTarget({
expect: { status: ["6xx"] },
http: { url: "https://example.com" },
name: "test",
type: "http",
});
expect(errors).toContain("status 模式必须为 1xx 到 5xx");
});
test("status 数字 99 启动校验失败", () => {
expect(() =>
checker.resolve(
{ expect: { status: [99] }, http: { url: "https://example.com" }, name: "test", type: "http" },
{ configDir: ".", defaultIntervalMs: 30000, defaults: {}, defaultTimeoutMs: 10000 },
),
).toThrow("100-599");
const errors = validateHttpTarget({
expect: { status: [99] },
http: { url: "https://example.com" },
name: "test",
type: "http",
});
expect(errors).toContain("100-599");
});
test("body rule 忽略未知字段", () => {
const result = checker.resolve(
{
test("body rule 未知字段启动失败", () => {
const errors = validateHttpTarget({
expect: { body: [{ contains: "ok", note: "ignored" }], status: [200] },
http: { url: "https://example.com" },
name: "test",
type: "http",
} as unknown as Parameters<HttpChecker["resolve"]>[0],
{ configDir: ".", defaultIntervalMs: 30000, defaults: {}, defaultTimeoutMs: 10000 },
);
expect((result as ResolvedHttpTarget).expect?.body).toEqual([
{ contains: "ok", note: "ignored" },
] as unknown as Array<{ contains: string }>);
});
expect(errors).toContain("note 是未知字段");
});
test("body rule 使用 match 字段启动失败", () => {
expect(() =>
checker.resolve(
{
const errors = validateHttpTarget({
expect: { body: [{ match: "ok" }] },
http: { url: "https://example.com" },
name: "test",
type: "http",
} as unknown as Parameters<HttpChecker["resolve"]>[0],
{ configDir: ".", defaultIntervalMs: 30000, defaults: {}, defaultTimeoutMs: 10000 },
),
).toThrow("缺少支持的规则类型");
});
expect(errors).toContain("缺少支持的规则类型");
});
test("非法 regex 启动校验失败", () => {
expect(() =>
checker.resolve(
{
const errors = validateHttpTarget({
expect: { body: [{ regex: "[invalid" }] },
http: { url: "https://example.com" },
name: "test",
type: "http",
} as unknown as Parameters<HttpChecker["resolve"]>[0],
{ configDir: ".", defaultIntervalMs: 30000, defaults: {}, defaultTimeoutMs: 10000 },
),
).toThrow("regex 正则不合法");
});
expect(errors).toContain("regex 正则不合法");
});
test("非法 JSONPath 启动校验失败", () => {
expect(() =>
checker.resolve(
{
const errors = validateHttpTarget({
expect: { body: [{ json: { equals: "ok", path: "status" } }] },
http: { url: "https://example.com" },
name: "test",
type: "http",
},
{ configDir: ".", defaultIntervalMs: 30000, defaults: {}, defaultTimeoutMs: 10000 },
),
).toThrow("json.path");
});
expect(errors).toContain("json.path");
});
test("非法 operator match 启动校验失败", () => {
expect(() =>
checker.resolve(
{
const errors = validateHttpTarget({
expect: { headers: { "x-test": { match: "[invalid" } } },
http: { url: "https://example.com" },
name: "test",
type: "http",
},
{ configDir: ".", defaultIntervalMs: 30000, defaults: {}, defaultTimeoutMs: 10000 },
),
).toThrow("match 正则不合法");
});
expect(errors).toContain("match 正则不合法");
});
test("非法 operator gte 类型启动失败", () => {
expect(() =>
checker.resolve(
{
const errors = validateHttpTarget({
expect: { body: [{ json: { gte: "abc", path: "$.count" } }] },
http: { url: "https://example.com" },
name: "test",
type: "http",
} as unknown as Parameters<HttpChecker["resolve"]>[0],
{ configDir: ".", defaultIntervalMs: 30000, defaults: {}, defaultTimeoutMs: 10000 },
),
).toThrow("gte 必须为有限数字");
});
expect(errors).toContain("gte 必须为有限数字");
});
test("非法 operator exists 类型启动失败", () => {
expect(() =>
checker.resolve(
{
const errors = validateHttpTarget({
expect: { body: [{ json: { exists: "yes", path: "$.status" } }] },
http: { url: "https://example.com" },
name: "test",
type: "http",
} as unknown as Parameters<HttpChecker["resolve"]>[0],
{ configDir: ".", defaultIntervalMs: 30000, defaults: {}, defaultTimeoutMs: 10000 },
),
).toThrow("exists 必须为布尔值");
});
expect(errors).toContain("exists 必须为布尔值");
});
test("纯 operator 空对象启动失败", () => {
const errors = validateHttpTarget({
expect: { headers: { "x-test": {} } },
http: { url: "https://example.com" },
name: "test",
type: "http",
});
expect(errors).toContain("必须包含至少一个合法 operator");
});
test("body rule 多个支持字段启动失败", () => {
expect(() =>
checker.resolve(
{
const errors = validateHttpTarget({
expect: { body: [{ contains: "ok", regex: "ok" }] },
http: { url: "https://example.com" },
name: "test",
type: "http",
} as unknown as Parameters<HttpChecker["resolve"]>[0],
{ configDir: ".", defaultIntervalMs: 30000, defaults: {}, defaultTimeoutMs: 10000 },
),
).toThrow("只能配置一种规则类型");
});
expect(errors).toContain("只能配置一种规则类型");
});
test("body rule 缺少支持字段启动失败", () => {
expect(() =>
checker.resolve(
{
const errors = validateHttpTarget({
expect: { body: [{ foo: "bar" }] },
http: { url: "https://example.com" },
name: "test",
type: "http",
} as unknown as Parameters<HttpChecker["resolve"]>[0],
{ configDir: ".", defaultIntervalMs: 30000, defaults: {}, defaultTimeoutMs: 10000 },
),
).toThrow("缺少支持的规则类型");
});
expect(errors).toContain("缺少支持的规则类型");
});
test("css selector 为空启动失败", () => {
expect(() =>
checker.resolve(
{
const errors = validateHttpTarget({
expect: { body: [{ css: { selector: "" } }] },
http: { url: "https://example.com" },
name: "test",
type: "http",
} as unknown as Parameters<HttpChecker["resolve"]>[0],
{ configDir: ".", defaultIntervalMs: 30000, defaults: {}, defaultTimeoutMs: 10000 },
),
).toThrow("css.selector 必须为非空字符串");
});
expect(errors).toContain("css.selector 必须为非空字符串");
});
test("xpath path 为空启动失败", () => {
expect(() =>
checker.resolve(
{
const errors = validateHttpTarget({
expect: { body: [{ xpath: { path: "" } }] },
http: { url: "https://example.com" },
name: "test",
type: "http",
} as unknown as Parameters<HttpChecker["resolve"]>[0],
{ configDir: ".", defaultIntervalMs: 30000, defaults: {}, defaultTimeoutMs: 10000 },
),
).toThrow("xpath.path 必须为非空字符串");
});
expect(errors).toContain("xpath.path 必须为非空字符串");
});
test("expect.headers 非对象启动失败", () => {
expect(() =>
checker.resolve(
{
expect: { headers: "invalid" },
test("json rule 允许存在性语义", () => {
const errors = validateHttpTarget({
expect: { body: [{ json: { path: "$.status" } }] },
http: { url: "https://example.com" },
name: "test",
type: "http",
} as unknown as Parameters<HttpChecker["resolve"]>[0],
{ configDir: ".", defaultIntervalMs: 30000, defaults: {}, defaultTimeoutMs: 10000 },
),
).toThrow("expect.headers 必须为对象");
});
expect(errors).toBe("");
});
test("expect.body 非数组启动失败", () => {
expect(() =>
checker.resolve(
{
expect: { body: "not-array" },
http: { url: "https://example.com" },
name: "test",
type: "http",
} as unknown as Parameters<HttpChecker["resolve"]>[0],
{ configDir: ".", defaultIntervalMs: 30000, defaults: {}, defaultTimeoutMs: 10000 },
),
).toThrow("expect.body 必须为数组");
});
test("maxDurationMs 负数启动失败", () => {
expect(() =>
checker.resolve(
{
expect: { maxDurationMs: -100 },
http: { url: "https://example.com" },
name: "test",
type: "http",
test("equals 支持对象和数组", () => {
const errors = validateHttpTarget({
expect: {
body: [
{ json: { equals: { status: "ok" }, path: "$.payload" } },
{ json: { equals: ["a", "b"], path: "$.items" } },
],
},
{ configDir: ".", defaultIntervalMs: 30000, defaults: {}, defaultTimeoutMs: 10000 },
),
).toThrow("maxDurationMs 必须为非负有限数字");
});
test("http.body 非字符串启动失败", () => {
expect(() =>
checker.resolve(
{
http: { body: 123, url: "https://example.com" },
http: { url: "https://example.com" },
name: "test",
type: "http",
} as unknown as Parameters<HttpChecker["resolve"]>[0],
{ configDir: ".", defaultIntervalMs: 30000, defaults: {}, defaultTimeoutMs: 10000 },
),
).toThrow("http.body 必须为字符串");
});
test("http.headers 非字符串值启动失败", () => {
expect(() =>
checker.resolve(
{
http: { headers: { "X-Test": 123 }, url: "https://example.com" },
name: "test",
type: "http",
} as unknown as Parameters<HttpChecker["resolve"]>[0],
{ configDir: ".", defaultIntervalMs: 30000, defaults: {}, defaultTimeoutMs: 10000 },
),
).toThrow("http.headers");
});
test("http.headers 非对象启动失败", () => {
expect(() =>
checker.resolve(
{
http: { headers: "invalid", url: "https://example.com" },
name: "test",
type: "http",
} as unknown as Parameters<HttpChecker["resolve"]>[0],
{ configDir: ".", defaultIntervalMs: 30000, defaults: {}, defaultTimeoutMs: 10000 },
),
).toThrow("http.headers 必须为对象");
expect(errors).toBe("");
});
});
@@ -825,60 +742,14 @@ describe("HttpChecker.resolve", () => {
};
}
test("method 非法抛出错误", () => {
expect(() =>
checker.resolve(
{ http: { method: "INVALID", url: "https://example.com" }, name: "test", type: "http" },
makeResolveContext(),
),
).toThrow("不合法");
});
test("URL 不以 http(s):// 开头抛出错误", () => {
expect(() =>
checker.resolve({ http: { url: "ftp://example.com" }, name: "test", type: "http" }, makeResolveContext()),
).toThrow("格式不合法");
});
test("maxRedirects 为负数抛出错误", () => {
expect(() =>
checker.resolve(
{ http: { maxRedirects: -1, url: "https://example.com" }, name: "test", type: "http" },
makeResolveContext(),
),
).toThrow("非负整数");
});
test("maxRedirects 非整数抛出错误", () => {
const target = {
http: { maxRedirects: 1.5, url: "https://example.com" },
name: "test",
type: "http",
} as unknown as Parameters<HttpChecker["resolve"]>[0];
expect(() => checker.resolve(target, makeResolveContext())).toThrow("非负整数");
});
test("ignoreSSL 非布尔值抛出错误", () => {
const target = {
http: { ignoreSSL: "true", url: "https://example.com" },
name: "test",
type: "http",
} as unknown as Parameters<HttpChecker["resolve"]>[0];
expect(() => checker.resolve(target, makeResolveContext())).toThrow("ignoreSSL 必须为布尔值");
});
test("缺少 http 分组抛出清晰错误", () => {
const target = { name: "test", type: "http" } as unknown as Parameters<HttpChecker["resolve"]>[0];
expect(() => checker.resolve(target, makeResolveContext())).toThrow("缺少 http.url 字段");
});
test("expect.status 非法模式抛出错误", () => {
expect(() =>
checker.resolve(
{ expect: { status: ["abc"] }, http: { url: "https://example.com" }, name: "test", type: "http" },
makeResolveContext(),
),
).toThrow("不合法");
const errors = validateHttpTarget({
expect: { status: ["abc"] },
http: { url: "https://example.com" },
name: "test",
type: "http",
});
expect(errors).toContain("status 模式必须为 1xx 到 5xx");
});
test("ignoreSSL 默认值为 false", () => {
@@ -886,7 +757,7 @@ describe("HttpChecker.resolve", () => {
{ http: { url: "https://example.com" }, name: "test", type: "http" },
makeResolveContext(),
);
expect((result as ResolvedHttpTarget).http.ignoreSSL).toBe(false);
expect(result.http.ignoreSSL).toBe(false);
});
test("maxRedirects 默认值为 0", () => {
@@ -894,15 +765,7 @@ describe("HttpChecker.resolve", () => {
{ http: { url: "https://example.com" }, name: "test", type: "http" },
makeResolveContext(),
);
expect((result as ResolvedHttpTarget).http.maxRedirects).toBe(0);
});
test("method 统一转大写", () => {
const result = checker.resolve(
{ http: { method: "get", url: "https://example.com" }, name: "test", type: "http" },
makeResolveContext(),
);
expect((result as ResolvedHttpTarget).http.method).toBe("GET");
expect(result.http.maxRedirects).toBe(0);
});
test("合法 status 范围模式通过校验", () => {
@@ -910,7 +773,7 @@ describe("HttpChecker.resolve", () => {
{ expect: { status: ["2xx", 301] }, http: { url: "https://example.com" }, name: "test", type: "http" },
makeResolveContext(),
);
expect((result as ResolvedHttpTarget).expect?.status).toEqual(["2xx", 301]);
expect(result.expect?.status).toEqual(["2xx", 301]);
});
test("显式 ignoreSSL 和 maxRedirects 正确解析", () => {
@@ -918,7 +781,7 @@ describe("HttpChecker.resolve", () => {
{ http: { ignoreSSL: true, maxRedirects: 3, url: "https://example.com" }, name: "test", type: "http" },
makeResolveContext(),
);
expect((result as ResolvedHttpTarget).http.ignoreSSL).toBe(true);
expect((result as ResolvedHttpTarget).http.maxRedirects).toBe(3);
expect(result.http.ignoreSSL).toBe(true);
expect(result.http.maxRedirects).toBe(3);
});
});

View File

@@ -1,16 +1,25 @@
import { Type } from "@sinclair/typebox";
import { describe, expect, test } from "bun:test";
import type { Checker } from "../../../../src/server/checker/runner/types";
import type { CheckResult, ResolvedTarget } from "../../../../src/server/checker/types";
import type { CheckResult, ResolvedTargetBase } from "../../../../src/server/checker/types";
import { createDefaultCheckerRegistry } from "../../../../src/server/checker/runner";
import { CheckerRegistry } from "../../../../src/server/checker/runner/registry";
function createChecker(type: string): Checker {
return {
configKey: type,
execute: () => Promise.resolve<CheckResult>({} as unknown as CheckResult),
resolve: () => ({}) as unknown as ResolvedTarget,
resolve: () => ({}) as unknown as ResolvedTargetBase,
schemas: {
config: Type.Object({}, { additionalProperties: false }),
defaults: Type.Object({}, { additionalProperties: false }),
expect: Type.Object({}, { additionalProperties: false }),
},
serialize: () => ({ config: "", target: "" }),
type,
validate: () => [],
};
}
@@ -39,4 +48,30 @@ describe("CheckerRegistry", () => {
registry.register(createChecker("command"));
expect(registry.supportedTypes).toEqual(["http", "command"]);
});
test("definitions 返回注册定义", () => {
const registry = new CheckerRegistry();
const checker = createChecker("http");
registry.register(checker);
expect(registry.definitions).toEqual([checker]);
});
test("tryGet 未注册返回 undefined", () => {
const registry = new CheckerRegistry();
expect(registry.tryGet("missing")).toBeUndefined();
});
test("默认 registry 创建 fresh 实例且互不污染", () => {
const first = createDefaultCheckerRegistry();
const second = createDefaultCheckerRegistry();
first.register(createChecker("custom"));
expect(first.supportedTypes).toEqual(["http", "command", "custom"]);
expect(second.supportedTypes).toEqual(["http", "command"]);
expect(
first.definitions.every(
(checker) => checker.schemas.config && checker.schemas.defaults && checker.schemas.expect,
),
).toBe(true);
});
});

View File

@@ -1,6 +1,6 @@
import { describe, expect, test } from "bun:test";
import { checkBodyExpect } from "../../../../../src/server/checker/runner/shared/body";
import { checkBodyExpect } from "../../../../../src/server/checker/runner/http/body";
describe("checkBodyExpect (BodyRule[])", () => {
test("无规则返回匹配成功", () => {

View File

@@ -1,6 +1,6 @@
import { describe, expect, test } from "bun:test";
import { checkDuration } from "../../../../../src/server/checker/runner/shared/duration";
import { checkDuration } from "../../../../../src/server/checker/expect/duration";
describe("checkDuration", () => {
test("未配置 maxDurationMs 返回匹配成功", () => {

View File

@@ -1,6 +1,6 @@
import { describe, expect, test } from "bun:test";
import { errorFailure, mismatchFailure, truncateActual } from "../../../../../src/server/checker/runner/shared/failure";
import { errorFailure, mismatchFailure, truncateActual } from "../../../../../src/server/checker/expect/failure";
describe("truncateActual", () => {
test("短字符串不截断", () => {

View File

@@ -1,10 +1,6 @@
import { describe, expect, test } from "bun:test";
import {
applyOperator,
checkExpectValue,
evaluateJsonPath,
} from "../../../../../src/server/checker/runner/shared/operator";
import { applyOperator, checkExpectValue, evaluateJsonPath } from "../../../../../src/server/checker/expect/operator";
describe("evaluateJsonPath", () => {
const obj = {
@@ -69,6 +65,13 @@ describe("applyOperator", () => {
expect(applyOperator(true, { equals: true })).toBe(true);
});
test("equals 支持 JSON 对象和数组", () => {
expect(applyOperator({ status: "ok" }, { equals: { status: "ok" } })).toBe(true);
expect(applyOperator({ status: "ok" }, { equals: { status: "fail" } })).toBe(false);
expect(applyOperator(["a", "b"], { equals: ["a", "b"] })).toBe(true);
expect(applyOperator(["a", "b"], { equals: ["b", "a"] })).toBe(false);
});
test("contains 操作符", () => {
expect(applyOperator("hello world", { contains: "hello" })).toBe(true);
expect(applyOperator("hello world", { contains: "missing" })).toBe(false);

View File

@@ -1,6 +1,6 @@
import { describe, expect, test } from "bun:test";
import { checkTextRules } from "../../../../../src/server/checker/runner/shared/text";
import { checkTextRules } from "../../../../../src/server/checker/runner/command/text";
describe("checkTextRules", () => {
test("无规则返回匹配成功", () => {

View File

@@ -1,6 +1,6 @@
import { describe, expect, test } from "bun:test";
import { parseSize } from "../../../src/server/checker/size";
import { parseSize } from "../../../src/server/checker/utils";
describe("parseSize", () => {
test("解析 B", () => {
@@ -24,6 +24,7 @@ describe("parseSize", () => {
test("解析小数", () => {
expect(parseSize("1.5MB")).toBe(1572864);
expect(parseSize("1.5KB")).toBe(1536);
});
test("数字直接返回", () => {
@@ -48,4 +49,8 @@ describe("parseSize", () => {
expect(() => parseSize("abc")).toThrow("无效的 size 格式");
expect(() => parseSize("")).toThrow("无效的 size 格式");
});
test("字符串解析为非整数字节时抛出错误", () => {
expect(() => parseSize("1.5B")).toThrow("非负安全整数字节数");
});
});

Some files were not shown because too many files have changed in this diff Show More