Compare commits
10 Commits
bce0f8e7a8
...
76b47006fe
| Author | SHA1 | Date | |
|---|---|---|---|
| 76b47006fe | |||
| 147a2559ae | |||
| 6ea185315f | |||
| ecd47748d2 | |||
| bcfb907bd3 | |||
| 26f0bfe104 | |||
| bb6b2bc20b | |||
| c396c29402 | |||
| aade0bbff7 | |||
| 7b20b59b79 |
1
.gitignore
vendored
1
.gitignore
vendored
@@ -408,6 +408,7 @@ temp
|
||||
.agents
|
||||
skills-lock.json
|
||||
.worktrees
|
||||
data/
|
||||
!scripts/build/
|
||||
backend/bin
|
||||
backend/server
|
||||
|
||||
@@ -10,3 +10,4 @@ bun.lock
|
||||
.agents/
|
||||
skills-lock.json
|
||||
data/
|
||||
probe-config.schema.json
|
||||
|
||||
544
DEVELOPMENT.md
544
DEVELOPMENT.md
@@ -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 大小单位解析
|
||||
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 耗时断言(checkDuration)
|
||||
validate-operator.ts 操作符语义校验(validateOperatorObject、isJsonValue、isPlainRecord)
|
||||
runner/ Checker 统一抽象与注册机制
|
||||
types.ts Checker 接口、CheckerContext、ResolveContext
|
||||
types.ts CheckerDefinition、CheckerContext、CheckerSchemas、ResolveContext
|
||||
registry.ts CheckerRegistry 注册中心
|
||||
index.ts 注册入口(registerCheckers)
|
||||
shared/ 共享 expect 断言函数(跨 checker 复用)
|
||||
failure.ts 失败信息类型
|
||||
operator.ts 操作符系统(applyOperator、evaluateJsonPath)
|
||||
duration.ts 耗时断言
|
||||
text.ts 文本规则断言
|
||||
body.ts Body 规则断言(JSONPath/XPath/CSS/contains/regex)
|
||||
http/ HTTP Checker 子包
|
||||
runner.ts HttpChecker(resolve/execute/serialize)
|
||||
expect.ts HTTP 专用断言(status/headers)
|
||||
validate.ts HTTP 配置与 expect 启动期校验
|
||||
command/ Command Checker 子包
|
||||
runner.ts CommandChecker(resolve/execute/serialize)
|
||||
expect.ts Command 专用断言(exitCode)
|
||||
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 shutdown(engine.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 契约 schema(config / defaults / expect 三部分) |
|
||||
| `validate.ts` | 启动期语义校验(JSON Schema 无法表达的规则) |
|
||||
| `execute.ts` | Checker 类:resolve(默认值合并 + 解析)、execute(执行检查)、serialize(DB 持久化) |
|
||||
| `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 Schema(defaults.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()` | 查询全部 targets(default 分组优先排序) |
|
||||
| `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_id(FK CASCADE)、timestamp、matched(0/1)、duration_ms、status_detail、failure(JSON)
|
||||
- 复合索引:`(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` 触发 abort;checker 必须使用 `CheckerContext.signal` 感知超时,HTTP 将 signal 传给 `fetch()`,Command 在 signal abort 时 `proc.kill()`
|
||||
- **结果写入**:检查结果通过 `store.insertCheckResult()` 写入 SQLite,engine 通过 `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 })`
|
||||
@@ -229,15 +520,15 @@ 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) | 服务端状态管理与自动轮询 |
|
||||
| 图表 | Recharts | 拨测趋势折线图与状态环状图 |
|
||||
| 路由 | 无(单页面 Dashboard) | 仅需 Drawer/Tab 做页面内导航 |
|
||||
| 层面 | 技术 | 用途 |
|
||||
| ------ | --------------------------------------------------- | ---------------------------- |
|
||||
| 框架 | React 19 | UI 组件开发 |
|
||||
| 构建 | Vite 8 | 开发服务与生产构建 |
|
||||
| 语言 | TypeScript 6 | 类型安全 |
|
||||
| UI 库 | TDesign React + tdesign-icons-react | UI 组件与图标 |
|
||||
| 数据层 | TanStack Query (React Query) + React Query Devtools | 服务端状态管理与自动轮询 |
|
||||
| 图表 | Recharts | 拨测趋势折线图与状态环状图 |
|
||||
| 路由 | 无(单页面 Dashboard) | 仅需 Drawer/Tab 做页面内导航 |
|
||||
|
||||
**不引入的依赖**:React Router(单页面场景不需要)、状态管理库(TanStack Query 即服务端状态层,组件内用 `useState` 足够)
|
||||
|
||||
@@ -245,18 +536,21 @@ runCommandCheck → 收集观测(exitCode/stdout/stderr/durationMs)
|
||||
|
||||
```
|
||||
main.tsx
|
||||
└── QueryClientProvider(TanStack Query 全局挂载)
|
||||
└── App(根组件)
|
||||
├── SummaryCards(总览统计卡片)
|
||||
│ └── useSummary() ─── GET /api/summary(8s 轮询)
|
||||
└── TargetBoard(目标列表)
|
||||
├── useTargets() ─── GET /api/targets(8s 轮询)
|
||||
└── TargetGroup[](按 group 字段分组)
|
||||
└── PrimaryTable ← TARGET_TABLE_COLUMNS(列定义:排序/筛选/渲染)
|
||||
└── TargetDetailDrawer(目标详情抽屉)
|
||||
└── useTargetDetail() ── 按需发起 trend + history 查询
|
||||
├── Tab: 概览 → Statistic + TrendChart + StatusDonut + Descriptions
|
||||
└── Tab: 记录 → PrimaryTable(分页历史记录)
|
||||
└── StrictMode
|
||||
└── ErrorBoundary(React 错误边界)
|
||||
└── QueryClientProvider(TanStack Query 全局挂载)
|
||||
├── App(根组件)
|
||||
│ ├── SummaryCards(总览统计卡片)
|
||||
│ │ └── useSummary() ─── GET /api/summary(8s 轮询)
|
||||
│ └── TargetBoard(目标列表)
|
||||
│ ├── useTargets() ─── GET /api/targets(8s 轮询)
|
||||
│ └── TargetGroup[](按 group 字段分组)
|
||||
│ └── PrimaryTable ← TARGET_TABLE_COLUMNS(列定义:排序/筛选/渲染)
|
||||
│ └── TargetDetailDrawer(目标详情抽屉)
|
||||
│ └── useTargetDetail() ── 按需发起 trend + history 查询
|
||||
│ ├── Tab: 概览 → Statistic + TrendChart + StatusDonut + Descriptions
|
||||
│ └── Tab: 记录 → PrimaryTable(分页历史记录)
|
||||
└── ReactQueryDevtools(开发工具,仅开发环境)
|
||||
```
|
||||
|
||||
**数据层架构**:
|
||||
@@ -362,23 +656,24 @@ export function TargetGroup({ name, targets, onTargetClick }: TargetGroupProps)
|
||||
|
||||
- **展示组件**(`components/`):纯渲染逻辑,通过 props 接收数据,通过回调返回事件
|
||||
- **容器逻辑**放在 hooks 中,组件只做数据消费
|
||||
- **常量数据**(列定义、排序器、筛选器)放在 `constants/`,不放在组件内部
|
||||
- **常量数据**(列定义、排序器、筛选器、颜色阈值)放在 `constants/`,不放在组件内部
|
||||
- **工具函数**(时间处理等)放在 `utils/`,保持纯函数无副作用
|
||||
|
||||
#### 现有组件清单
|
||||
|
||||
| 组件 | 文件 | 用途 |
|
||||
| -------------------- | ----------------------------------- | ---------------------------------- |
|
||||
| `App` | `app.tsx` | 根组件,编排全局状态与布局 |
|
||||
| `SummaryCards` | `components/SummaryCards.tsx` | 总览统计卡片(全部/正常/异常) |
|
||||
| `TargetBoard` | `components/TargetBoard.tsx` | 按分组渲染目标表格列表 |
|
||||
| `TargetGroup` | `components/TargetGroup.tsx` | 单个分组标题 + PrimaryTable |
|
||||
| `TargetDetailDrawer` | `components/TargetDetailDrawer.tsx` | 目标详情抽屉(概览/记录 Tab) |
|
||||
| `TrendChart` | `components/TrendChart.tsx` | Recharts 双轴折线图(耗时/可用率) |
|
||||
| `StatusDonut` | `components/StatusDonut.tsx` | Recharts 环状图(UP/DOWN 分布) |
|
||||
| `StatusDot` | `components/StatusDot.tsx` | 圆形状态指示点(绿/红) |
|
||||
| `StatusBar` | `components/StatusBar.tsx` | 最近采样状态条(多色块) |
|
||||
| `GroupHeader` | `components/GroupHeader.tsx` | 分组标题(名称 + 统计) |
|
||||
| 组件 | 文件 | 用途 |
|
||||
| -------------------- | ----------------------------------- | ----------------------------------------- |
|
||||
| `App` | `app.tsx` | 根组件,编排全局状态与布局 |
|
||||
| `ErrorBoundary` | `components/ErrorBoundary.tsx` | React 错误边界,捕获渲染异常并展示降级 UI |
|
||||
| `SummaryCards` | `components/SummaryCards.tsx` | 总览统计卡片(全部/正常/异常) |
|
||||
| `TargetBoard` | `components/TargetBoard.tsx` | 按分组渲染目标表格列表 |
|
||||
| `TargetGroup` | `components/TargetGroup.tsx` | 单个分组标题 + PrimaryTable |
|
||||
| `TargetDetailDrawer` | `components/TargetDetailDrawer.tsx` | 目标详情抽屉(概览/记录 Tab) |
|
||||
| `TrendChart` | `components/TrendChart.tsx` | Recharts 双轴折线图(耗时/可用率) |
|
||||
| `StatusDonut` | `components/StatusDonut.tsx` | Recharts 环状图(UP/DOWN 分布) |
|
||||
| `StatusDot` | `components/StatusDot.tsx` | 圆形状态指示点(绿/红) |
|
||||
| `StatusBar` | `components/StatusBar.tsx` | 最近采样状态条(多色块) |
|
||||
| `GroupHeader` | `components/GroupHeader.tsx` | 分组标题(名称 + 统计) |
|
||||
|
||||
### 2.5 新增功能开发步骤
|
||||
|
||||
@@ -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)
|
||||
@@ -591,12 +892,14 @@ bun run test:smoke
|
||||
|
||||
### 3.6 脚本说明
|
||||
|
||||
| 脚本 | 文件 | 说明 |
|
||||
| -------------------- | ------------------ | ------------------------------ |
|
||||
| `bun run dev` | `scripts/dev.ts` | 同时启动前后端开发服务 |
|
||||
| `bun run build` | `scripts/build.ts` | Vite 构建 + Bun 编译可执行文件 |
|
||||
| `bun run test:smoke` | `scripts/smoke.ts` | 构建后的端到端验证 |
|
||||
| `bun run clean` | `scripts/clean.ts` | 清理构建缓存与临时文件 |
|
||||
| 脚本 | 文件 | 说明 |
|
||||
| ---------------------- | ----------------------------------- | ------------------------------- |
|
||||
| `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` | 清理构建缓存与临时文件 |
|
||||
|
||||
### 3.7 环境变量
|
||||
|
||||
@@ -607,16 +910,17 @@ bun run test:smoke
|
||||
|
||||
### 3.8 项目配置文件
|
||||
|
||||
| 文件 | 用途 |
|
||||
| --------------------- | ---------------------------------------------- |
|
||||
| `package.json` | 项目信息、脚本、依赖声明 |
|
||||
| `tsconfig.json` | TypeScript 配置(ESNext 模块、严格模式) |
|
||||
| `vite.config.ts` | Vite 开发代理与构建配置 |
|
||||
| `eslint.config.js` | ESLint 规则(含前端不得 import server 的检查) |
|
||||
| `.prettierrc.json` | Prettier 格式化规则(`printWidth: 120`) |
|
||||
| `.prettierignore` | Prettier 排除路径 |
|
||||
| `probes.example.yaml` | 配置文件示例 |
|
||||
| `opencode.json` | OpenCode 工具配置(TDesign MCP server) |
|
||||
| 文件 | 用途 |
|
||||
| ---------------------- | ---------------------------------------------- |
|
||||
| `package.json` | 项目信息、脚本、依赖声明 |
|
||||
| `tsconfig.json` | TypeScript 配置(ESNext 模块、严格模式) |
|
||||
| `vite.config.ts` | Vite 开发代理与构建配置(含代码分割策略) |
|
||||
| `eslint.config.js` | ESLint 规则(含前端不得 import server 的检查) |
|
||||
| `commitlint.config.js` | commitlint 提交信息格式校验 |
|
||||
| `.prettierrc.json` | Prettier 格式化规则(`printWidth: 120`) |
|
||||
| `.prettierignore` | Prettier 排除路径 |
|
||||
| `probes.example.yaml` | 配置文件示例 |
|
||||
| `opencode.json` | OpenCode 工具配置(TDesign MCP server) |
|
||||
|
||||
### 3.9 依赖管理
|
||||
|
||||
@@ -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 环境。
|
||||
|
||||
73
README.md
73
README.md
@@ -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,34 +126,47 @@ 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/trend?from=ISO&to=ISO` | 指定目标的按小时聚合趋势 |
|
||||
| 端点 | 说明 |
|
||||
| ----------------------------------------------------------------- | ------------------------------------------------------------ |
|
||||
| `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` | 指定目标的拨测记录(时间范围 + 分页,`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`
|
||||
|
||||
@@ -175,11 +194,11 @@ API 错误返回 `ApiErrorResponse` 格式:
|
||||
{ "error": "描述信息", "status": 400 }
|
||||
```
|
||||
|
||||
| 状态码 | 触发场景 |
|
||||
| ------ | ----------------------------------------------------------------------- |
|
||||
| 400 | 参数格式错误(无效 ID、from/to 缺失或格式错误、page/pageSize 非正整数) |
|
||||
| 404 | 目标不存在、API 路由未匹配 |
|
||||
| 405 | 非 GET 方法请求 API 路由 |
|
||||
| 状态码 | 触发场景 |
|
||||
| ------ | ------------------------------------------------------------------------------------------ |
|
||||
| 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
|
||||
|
||||
|
||||
16
bun.lock
16
bun.lock
@@ -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=="],
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,2 @@
|
||||
schema: spec-driven
|
||||
created: 2026-05-13
|
||||
121
openspec/changes/backend-architecture-hardening/design.md
Normal file
121
openspec/changes/backend-architecture-hardening/design.md
Normal file
@@ -0,0 +1,121 @@
|
||||
## Context
|
||||
|
||||
DiAL 后端是基于 Bun 的拨测服务,当前有 2 个 checker 类型(http、command),target 规模预计增长到 100,checker 类型预计超过 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 signature(registry 层仍用类型擦除)
|
||||
- 不改变前端行为
|
||||
|
||||
## 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 类型。
|
||||
34
openspec/changes/backend-architecture-hardening/proposal.md
Normal file
34
openspec/changes/backend-architecture-hardening/proposal.md
Normal 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_result(failure 标记为 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 相关测试
|
||||
@@ -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** 计算 availability(upCount / totalChecks * 100)
|
||||
- **THEN** 结果 SHALL 使用 `Math.round(value * 100) / 100` 四舍五入保留两位小数,与 `getTargetStats` 方法一致
|
||||
|
||||
#### Scenario: 目标无历史记录
|
||||
- **WHEN** 某 target 在 check_results 表中无任何记录
|
||||
- **THEN** 该 target_id 在返回的 Map 中 SHALL 不存在对应的 key
|
||||
@@ -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"]`(按注册顺序)
|
||||
@@ -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 状态码和错误信息
|
||||
@@ -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 解析为绝对路径
|
||||
@@ -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_result,failure 为 `{ 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 个检查,其余检查等待并发槽位释放
|
||||
@@ -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` 完成启动
|
||||
42
openspec/changes/backend-architecture-hardening/tasks.md
Normal file
42
openspec/changes/backend-architecture-hardening/tasks.md
Normal 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 上限说明)
|
||||
@@ -0,0 +1,2 @@
|
||||
schema: spec-driven
|
||||
created: 2026-05-13
|
||||
145
openspec/changes/frontend-architecture-refactor/design.md
Normal file
145
openspec/changes/frontend-architecture-refactor/design.md
Normal 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 → Tab),props 类型明确,不会造成 prop drilling 问题 |
|
||||
|
||||
## Open Questions
|
||||
|
||||
无。方案已在 explore 阶段与用户确认。
|
||||
34
openspec/changes/frontend-architecture-refactor/proposal.md
Normal file
34
openspec/changes/frontend-architecture-refactor/proposal.md
Normal 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 中前端目录结构和组件清单
|
||||
@@ -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 header,body 为空
|
||||
|
||||
### Requirement: MetaResponse 共享类型
|
||||
系统 SHALL 在 `src/shared/api.ts` 中定义 `MetaResponse` 类型。
|
||||
|
||||
#### Scenario: MetaResponse 类型定义
|
||||
- **WHEN** 前后端引用 `MetaResponse` 类型
|
||||
- **THEN** 该类型 SHALL 包含 `checkerTypes: string[]` 字段
|
||||
@@ -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 不被包含在产物中
|
||||
@@ -0,0 +1,91 @@
|
||||
## MODIFIED Requirements
|
||||
|
||||
### Requirement: 目标详情 Drawer
|
||||
Dashboard SHALL 在用户点击目标表格行后从右侧滑出 Drawer,展示该目标的详细统计信息和检查记录。Drawer 标题栏和内容不使用内联 style。Drawer 内容 SHALL 拆分为独立的 Tab 组件。
|
||||
|
||||
#### Scenario: 打开 Drawer
|
||||
- **WHEN** 用户点击某个目标表格行
|
||||
- **THEN** 系统 SHALL 从右侧滑出 Drawer(placement="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 状态
|
||||
@@ -0,0 +1,69 @@
|
||||
## MODIFIED Requirements
|
||||
|
||||
### Requirement: 表格列定义
|
||||
每个分组的 PrimaryTable SHALL 包含状态、名称、类型、可用率、最近状态条、延迟、间隔 7 列,不含分组列(同组内冗余)。列渲染不使用内联 style。列定义 SHALL 通过工厂函数动态生成。
|
||||
|
||||
#### Scenario: 状态列
|
||||
- **WHEN** 表格渲染
|
||||
- **THEN** 状态列 SHALL 使用 StatusDot 组件渲染,标题显示"#",宽度 60px,fixed="left",居中对齐,支持筛选(UP/DOWN/全部)。StatusDot SHALL 通过 CSS 类(`.status-dot--up` / `.status-dot--down`)控制颜色,不使用内联 style
|
||||
|
||||
#### Scenario: 名称列
|
||||
- **WHEN** 表格渲染
|
||||
- **THEN** 名称列 SHALL 显示目标名称,支持字母排序(zh-CN),ellipsis 超长名称自动省略并 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 导入,不在组件中重复定义
|
||||
@@ -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**: 无需迁移,删除即可。
|
||||
48
openspec/changes/frontend-architecture-refactor/tasks.md
Normal file
48
openspec/changes/frontend-architecture-refactor/tasks.md
Normal 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 说明
|
||||
@@ -0,0 +1,2 @@
|
||||
schema: spec-driven
|
||||
created: 2026-05-13
|
||||
93
openspec/changes/http-checker-quality-hardening/design.md
Normal file
93
openspec/changes/http-checker-quality-hardening/design.md
Normal 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 一份,缓存反而减少了峰值内存。
|
||||
28
openspec/changes/http-checker-quality-hardening/proposal.md
Normal file
28
openspec/changes/http-checker-quality-hardening/proposal.md
Normal 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` — 补充截断相关测试
|
||||
@@ -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 至少包含一个已知 operator;body 提取规则可以不配置 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 风险)
|
||||
38
openspec/changes/http-checker-quality-hardening/tasks.md
Normal file
38
openspec/changes/http-checker-quality-hardening/tasks.md
Normal 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 rules(contains + json + css)集成测试
|
||||
|
||||
## 7. 质量保障
|
||||
|
||||
- [ ] 7.1 执行完整测试套件 `bun test`、代码检查 `bun run lint`、格式检查 `bun run format:check` 确保无回归
|
||||
- [ ] 7.2 更新 DEVELOPMENT.md 中 ReDoS 校验相关说明(如有必要)
|
||||
153
openspec/specs/checker-cohesion-structure/spec.md
Normal file
153
openspec/specs/checker-cohesion-structure/spec.md
Normal 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 为具体类型
|
||||
@@ -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 需要校验响应状态码和响应头
|
||||
|
||||
@@ -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 包含未知字段
|
||||
|
||||
43
openspec/specs/data-retention/spec.md
Normal file
43
openspec/specs/data-retention/spec.md
Normal file
@@ -0,0 +1,43 @@
|
||||
## Purpose
|
||||
|
||||
定义历史拨测数据的自动清理机制:可配置的保留时长和定时清理调度。
|
||||
|
||||
## Requirements
|
||||
|
||||
### Requirement: 数据保留配置
|
||||
系统 SHALL 支持通过 `runtime.retention` 配置项指定历史数据保留时长,格式为持续时间字符串(`<数字><单位>`,单位支持 `d`/`h`/`m`)。
|
||||
|
||||
#### Scenario: 配置 7 天保留
|
||||
- **WHEN** 配置文件中 `runtime.retention` 设置为 `"7d"`
|
||||
- **THEN** 系统 SHALL 保留最近 7 天的检查结果,清理更早的数据
|
||||
|
||||
#### Scenario: 配置小时级保留
|
||||
- **WHEN** 配置文件中 `runtime.retention` 设置为 `"24h"`
|
||||
- **THEN** 系统 SHALL 保留最近 24 小时的检查结果
|
||||
|
||||
#### Scenario: 未配置 retention
|
||||
- **WHEN** 配置文件中未指定 `runtime.retention`
|
||||
- **THEN** 系统 SHALL 使用默认值 `"7d"`
|
||||
|
||||
#### Scenario: 无效 retention 格式
|
||||
- **WHEN** 配置文件中 `runtime.retention` 格式不合法(如 `"abc"`、`"7x"`)
|
||||
- **THEN** 系统 SHALL 在配置校验阶段报错,拒绝启动
|
||||
|
||||
### Requirement: 定时清理调度
|
||||
系统 SHALL 以固定间隔(1 小时)定期执行数据清理,删除超过保留时长的历史检查结果。
|
||||
|
||||
#### Scenario: 引擎启动后首次清理
|
||||
- **WHEN** ProbeEngine 启动
|
||||
- **THEN** 系统 SHALL 立即执行一次清理,然后每隔 1 小时再次执行
|
||||
|
||||
#### Scenario: 清理执行
|
||||
- **WHEN** 清理定时器触发
|
||||
- **THEN** 系统 SHALL 删除 `check_results` 表中 `timestamp` 早于 `now - retentionMs` 的所有记录
|
||||
|
||||
#### Scenario: 引擎停止时清除定时器
|
||||
- **WHEN** ProbeEngine.stop() 被调用
|
||||
- **THEN** 系统 SHALL 清除清理定时器,不再执行后续清理
|
||||
|
||||
#### Scenario: retention 为 0 时不清理
|
||||
- **WHEN** 配置的 retention 解析为 0 毫秒
|
||||
- **THEN** 系统 SHALL 不注册清理定时器,数据永久保留
|
||||
@@ -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 至少包含一个已知 operator;body 提取规则可以不配置 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。
|
||||
|
||||
|
||||
31
openspec/specs/frontend-error-boundary/spec.md
Normal file
31
openspec/specs/frontend-error-boundary/spec.md
Normal file
@@ -0,0 +1,31 @@
|
||||
## Purpose
|
||||
|
||||
定义前端全局错误边界:捕获渲染错误防止白屏,展示友好的错误兜底 UI。
|
||||
|
||||
## Requirements
|
||||
|
||||
### Requirement: 全局渲染错误捕获
|
||||
前端应用 SHALL 在最外层包裹 ErrorBoundary 组件,捕获所有子组件树的渲染错误,防止白屏。
|
||||
|
||||
#### Scenario: 子组件渲染抛出异常
|
||||
- **WHEN** 任意子组件在渲染过程中抛出 JavaScript 异常
|
||||
- **THEN** ErrorBoundary SHALL 捕获该异常,展示错误兜底 UI,而非白屏
|
||||
|
||||
#### Scenario: 错误兜底 UI 内容
|
||||
- **WHEN** ErrorBoundary 捕获到渲染错误
|
||||
- **THEN** 系统 SHALL 使用 TDesign Result 组件(type="500")展示错误提示,并提供"刷新页面"按钮
|
||||
|
||||
#### Scenario: 刷新页面恢复
|
||||
- **WHEN** 用户点击错误兜底 UI 中的"刷新页面"按钮
|
||||
- **THEN** 系统 SHALL 调用 `window.location.reload()` 重新加载页面
|
||||
|
||||
#### Scenario: 错误信息记录
|
||||
- **WHEN** ErrorBoundary 捕获到渲染错误
|
||||
- **THEN** 系统 SHALL 通过 `console.error` 输出错误信息和组件堆栈
|
||||
|
||||
### Requirement: ErrorBoundary 包裹位置
|
||||
ErrorBoundary SHALL 包裹在 QueryClientProvider 外层,确保 React Query 相关的渲染错误也能被捕获。
|
||||
|
||||
#### Scenario: 包裹层级
|
||||
- **WHEN** 应用渲染树构建
|
||||
- **THEN** 层级 SHALL 为 StrictMode > ErrorBoundary > QueryClientProvider > App
|
||||
@@ -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"`
|
||||
|
||||
@@ -32,3 +32,14 @@ Dashboard SHALL 使用 TDesign 组件正确处理加载状态和 API 错误。
|
||||
#### Scenario: API 请求失败
|
||||
- **WHEN** 前端 API 请求失败
|
||||
- **THEN** 页面 SHALL 使用 TDesign Alert 组件(theme=error)显示错误提示
|
||||
|
||||
### Requirement: 前端构建产物拆分
|
||||
前端生产构建 SHALL 将 vendor 依赖拆分为独立 chunk,利用浏览器并行加载和长期缓存。
|
||||
|
||||
#### Scenario: vendor chunk 拆分
|
||||
- **WHEN** 执行前端生产构建
|
||||
- **THEN** 构建产物 SHALL 包含独立的 vendor chunk(react、tdesign、recharts 各自独立),而非单个 bundle
|
||||
|
||||
#### Scenario: 业务代码变更不影响 vendor 缓存
|
||||
- **WHEN** 仅修改业务代码(src/web/ 下非 node_modules 文件)并重新构建
|
||||
- **THEN** vendor chunk 的文件名(含 hash)SHALL 保持不变,浏览器缓存 SHALL 继续有效
|
||||
|
||||
@@ -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 不受影响
|
||||
|
||||
@@ -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 不注册清理定时器
|
||||
|
||||
@@ -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 之外。
|
||||
|
||||
|
||||
@@ -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
727
probe-config.schema.json
Normal 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": {}
|
||||
}
|
||||
@@ -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) => {
|
||||
|
||||
16
scripts/generate-config-schema.ts
Normal file
16
scripts/generate-config-schema.ts
Normal 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
83
src/server/bootstrap.ts
Normal 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);
|
||||
}
|
||||
}
|
||||
@@ -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));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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) {
|
||||
@@ -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 {
|
||||
@@ -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 {
|
||||
6
src/server/checker/expect/types.ts
Normal file
6
src/server/checker/expect/types.ts
Normal file
@@ -0,0 +1,6 @@
|
||||
import type { CheckFailure } from "../types";
|
||||
|
||||
export interface ExpectResult {
|
||||
failure: CheckFailure | null;
|
||||
matched: boolean;
|
||||
}
|
||||
80
src/server/checker/expect/validate-operator.ts
Normal file
80
src/server/checker/expect/validate-operator.ts
Normal 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)];
|
||||
}
|
||||
}
|
||||
@@ -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(
|
||||
@@ -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)) {
|
||||
|
||||
1
src/server/checker/runner/command/index.ts
Normal file
1
src/server/checker/runner/command/index.ts
Normal file
@@ -0,0 +1 @@
|
||||
export { CommandChecker } from "./execute";
|
||||
34
src/server/checker/runner/command/schema.ts
Normal file
34
src/server/checker/runner/command/schema.ts
Normal 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 },
|
||||
),
|
||||
};
|
||||
@@ -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++) {
|
||||
41
src/server/checker/runner/command/types.ts
Normal file
41
src/server/checker/runner/command/types.ts
Normal 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;
|
||||
98
src/server/checker/runner/command/validate.ts
Normal file
98
src/server/checker/runner/command/validate.ts
Normal 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));
|
||||
}
|
||||
@@ -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 };
|
||||
@@ -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 {
|
||||
@@ -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;
|
||||
|
||||
1
src/server/checker/runner/http/index.ts
Normal file
1
src/server/checker/runner/http/index.ts
Normal file
@@ -0,0 +1 @@
|
||||
export { HttpChecker } from "./execute";
|
||||
44
src/server/checker/runner/http/schema.ts
Normal file
44
src/server/checker/runner/http/schema.ts
Normal 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 },
|
||||
),
|
||||
};
|
||||
59
src/server/checker/runner/http/types.ts
Normal file
59
src/server/checker/runner/http/types.ts
Normal 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 };
|
||||
@@ -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 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 必须为字符串`);
|
||||
}
|
||||
break;
|
||||
case "empty":
|
||||
case "exists":
|
||||
if (typeof value !== "boolean") {
|
||||
throw new Error(`target "${targetName}" 的 ${path}.${key} 必须为布尔值`);
|
||||
}
|
||||
break;
|
||||
case "equals":
|
||||
if (typeof value !== "boolean" && typeof value !== "number" && typeof value !== "string" && value !== null) {
|
||||
throw new Error(`target "${targetName}" 的 ${path}.equals 类型不合法`);
|
||||
}
|
||||
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} 必须为有限数字`);
|
||||
}
|
||||
break;
|
||||
case "match":
|
||||
if (typeof value !== "string") {
|
||||
throw new Error(`target "${targetName}" 的 ${path}.match 必须为字符串`);
|
||||
}
|
||||
try {
|
||||
new RegExp(value);
|
||||
} catch {
|
||||
throw new Error(`target "${targetName}" 的 ${path}.match 正则不合法`);
|
||||
}
|
||||
break;
|
||||
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 validateSingleBodyRule(rule: unknown, index: number, targetName: string): void {
|
||||
if (typeof rule !== "object" || rule === null) {
|
||||
throw new Error(`target "${targetName}" 的 expect.body[${index}] 必须为对象`);
|
||||
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));
|
||||
}
|
||||
if ("attr" in rule && typeof rule["attr"] !== "string") {
|
||||
issues.push(issue("invalid-type", joinPath(path, "attr"), "必须为字符串", targetName));
|
||||
}
|
||||
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));
|
||||
}
|
||||
}
|
||||
|
||||
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 (expect["body"] !== undefined) {
|
||||
issues.push(...validateBodyRules(expect["body"], joinPath(expectPath, "body"), targetName));
|
||||
}
|
||||
|
||||
if (found.length === 0) {
|
||||
throw new Error(
|
||||
`target "${targetName}" 的 expect.body[${index}] 缺少支持的规则类型(contains/regex/json/css/xpath)`,
|
||||
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 {
|
||||
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 {
|
||||
issues.push(issue("invalid-url", joinPath(joinPath(path, "http"), "url"), "格式不合法", targetName));
|
||||
}
|
||||
}
|
||||
if (isSizeInput(http["maxBodyBytes"])) {
|
||||
issues.push(
|
||||
...validateSizeValue(http["maxBodyBytes"], joinPath(joinPath(path, "http"), "maxBodyBytes"), targetName),
|
||||
);
|
||||
}
|
||||
if (found.length > 1) {
|
||||
throw new Error(
|
||||
`target "${targetName}" 的 expect.body[${index}] 只能配置一种规则类型,当前包含: ${found.join(", ")}`,
|
||||
);
|
||||
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, 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;
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
92
src/server/checker/schema/builder.ts
Normal file
92
src/server/checker/schema/builder.ts
Normal 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[]]);
|
||||
}
|
||||
7
src/server/checker/schema/export.ts
Normal file
7
src/server/checker/schema/export.ts
Normal 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);
|
||||
}
|
||||
98
src/server/checker/schema/fragments.ts
Normal file
98
src/server/checker/schema/fragments.ts
Normal 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()),
|
||||
};
|
||||
}
|
||||
37
src/server/checker/schema/issues.ts
Normal file
37
src/server/checker/schema/issues.ts
Normal 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}`;
|
||||
}
|
||||
13
src/server/checker/schema/types.ts
Normal file
13
src/server/checker/schema/types.ts
Normal 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;
|
||||
}
|
||||
145
src/server/checker/schema/validate.ts
Normal file
145
src/server/checker/schema/validate.ts
Normal 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"];
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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 });
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
44
src/server/checker/utils.ts
Normal file
44
src/server/checker/utils.ts
Normal 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;
|
||||
}
|
||||
@@ -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) => {
|
||||
|
||||
@@ -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 };
|
||||
|
||||
@@ -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,
|
||||
|
||||
38
src/web/components/ErrorBoundary.tsx
Normal file
38
src/web/components/ErrorBoundary.tsx
Normal file
@@ -0,0 +1,38 @@
|
||||
import type { ErrorInfo, ReactNode } from "react";
|
||||
|
||||
import { Component } from "react";
|
||||
import { Alert, Button, Space } from "tdesign-react";
|
||||
|
||||
interface Props {
|
||||
children: ReactNode;
|
||||
}
|
||||
|
||||
interface State {
|
||||
hasError: boolean;
|
||||
}
|
||||
|
||||
export class ErrorBoundary extends Component<Props, State> {
|
||||
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;
|
||||
}
|
||||
}
|
||||
@@ -4,6 +4,7 @@ import { StrictMode } from "react";
|
||||
import { createRoot } from "react-dom/client";
|
||||
|
||||
import { App } from "./app";
|
||||
import { ErrorBoundary } from "./components/ErrorBoundary";
|
||||
import "tdesign-react/dist/reset.css";
|
||||
import "tdesign-react/dist/tdesign.min.css";
|
||||
|
||||
@@ -27,9 +28,11 @@ if (!rootElement) {
|
||||
|
||||
createRoot(rootElement).render(
|
||||
<StrictMode>
|
||||
<QueryClientProvider client={queryClient}>
|
||||
<App />
|
||||
<ReactQueryDevtools initialIsOpen={false} />
|
||||
</QueryClientProvider>
|
||||
<ErrorBoundary>
|
||||
<QueryClientProvider client={queryClient}>
|
||||
<App />
|
||||
<ReactQueryDevtools initialIsOpen={false} />
|
||||
</QueryClientProvider>
|
||||
</ErrorBoundary>
|
||||
</StrictMode>,
|
||||
);
|
||||
|
||||
@@ -156,3 +156,8 @@
|
||||
.summary-cards-row {
|
||||
margin-bottom: var(--td-comp-margin-xl);
|
||||
}
|
||||
|
||||
.error-boundary-fallback {
|
||||
padding-top: 20vh;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
@@ -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";
|
||||
|
||||
141
tests/server/bootstrap.test.ts
Normal file
141
tests/server/bootstrap.test.ts
Normal 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"]);
|
||||
});
|
||||
});
|
||||
71
tests/server/checker/config-contract/validate.test.ts
Normal file
71
tests/server/checker/config-contract/validate.test.ts
Normal 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 正则不合法');
|
||||
});
|
||||
});
|
||||
@@ -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,22 +116,20 @@ 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");
|
||||
expect(t.http.headers).toEqual({});
|
||||
expect(t.http.ignoreSSL).toBe(false);
|
||||
expect(t.http.maxBodyBytes).toBe(104857600);
|
||||
expect(t.http.maxRedirects).toBe(0);
|
||||
expect(t.intervalMs).toBe(30000);
|
||||
expect(t.timeoutMs).toBe(10000);
|
||||
}
|
||||
expect(t.name).toBe("test");
|
||||
expect(t.http.url).toBe("http://example.com");
|
||||
expect(t.http.method).toBe("GET");
|
||||
expect(t.http.headers).toEqual({});
|
||||
expect(t.http.ignoreSSL).toBe(false);
|
||||
expect(t.http.maxBodyBytes).toBe(104857600);
|
||||
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();
|
||||
}
|
||||
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,31 +205,46 @@ 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" });
|
||||
expect(http.http.ignoreSSL).toBe(true);
|
||||
expect(http.http.maxBodyBytes).toBe(52428800);
|
||||
expect(http.http.maxRedirects).toBe(5);
|
||||
expect(http.expect?.status).toEqual(["2xx", 301]);
|
||||
expect(http.intervalMs).toBe(60000);
|
||||
expect(http.timeoutMs).toBe(5000);
|
||||
}
|
||||
expect(http.http.url).toBe("http://example.com");
|
||||
expect(http.http.method).toBe("POST");
|
||||
expect(http.http.headers).toEqual({ Authorization: "Bearer token" });
|
||||
expect(http.http.ignoreSSL).toBe(true);
|
||||
expect(http.http.maxBodyBytes).toBe(52428800);
|
||||
expect(http.http.maxRedirects).toBe(5);
|
||||
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);
|
||||
}
|
||||
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") {
|
||||
expect(t.http.method).toBe("POST");
|
||||
expect(t.intervalMs).toBe(300000);
|
||||
expect(t.timeoutMs).toBe(30000);
|
||||
expect(t.http.maxBodyBytes).toBe(1048576);
|
||||
}
|
||||
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") {
|
||||
expect(t.command.cwd).toBe(join(subdir, "scripts"));
|
||||
}
|
||||
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") {
|
||||
expect(t.command.env["LANG"]).toBe("C");
|
||||
expect(t.command.env["CUSTOM_VAR"]).toBe("test");
|
||||
expect(t.command.env["PATH"]).toBeDefined();
|
||||
}
|
||||
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"
|
||||
`,
|
||||
"无效的时长格式",
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
|
||||
@@ -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(
|
||||
{
|
||||
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 }>);
|
||||
test("body rule 未知字段启动失败", () => {
|
||||
const errors = validateHttpTarget({
|
||||
expect: { body: [{ contains: "ok", note: "ignored" }], status: [200] },
|
||||
http: { url: "https://example.com" },
|
||||
name: "test",
|
||||
type: "http",
|
||||
});
|
||||
expect(errors).toContain("note 是未知字段");
|
||||
});
|
||||
|
||||
test("body rule 使用 match 字段启动失败", () => {
|
||||
expect(() =>
|
||||
checker.resolve(
|
||||
{
|
||||
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("缺少支持的规则类型");
|
||||
const errors = validateHttpTarget({
|
||||
expect: { body: [{ match: "ok" }] },
|
||||
http: { url: "https://example.com" },
|
||||
name: "test",
|
||||
type: "http",
|
||||
});
|
||||
expect(errors).toContain("缺少支持的规则类型");
|
||||
});
|
||||
|
||||
test("非法 regex 启动校验失败", () => {
|
||||
expect(() =>
|
||||
checker.resolve(
|
||||
{
|
||||
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 正则不合法");
|
||||
const errors = validateHttpTarget({
|
||||
expect: { body: [{ regex: "[invalid" }] },
|
||||
http: { url: "https://example.com" },
|
||||
name: "test",
|
||||
type: "http",
|
||||
});
|
||||
expect(errors).toContain("regex 正则不合法");
|
||||
});
|
||||
|
||||
test("非法 JSONPath 启动校验失败", () => {
|
||||
expect(() =>
|
||||
checker.resolve(
|
||||
{
|
||||
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");
|
||||
const errors = validateHttpTarget({
|
||||
expect: { body: [{ json: { equals: "ok", path: "status" } }] },
|
||||
http: { url: "https://example.com" },
|
||||
name: "test",
|
||||
type: "http",
|
||||
});
|
||||
expect(errors).toContain("json.path");
|
||||
});
|
||||
|
||||
test("非法 operator match 启动校验失败", () => {
|
||||
expect(() =>
|
||||
checker.resolve(
|
||||
{
|
||||
expect: { headers: { "x-test": { match: "[invalid" } } },
|
||||
http: { url: "https://example.com" },
|
||||
name: "test",
|
||||
type: "http",
|
||||
},
|
||||
{ configDir: ".", defaultIntervalMs: 30000, defaults: {}, defaultTimeoutMs: 10000 },
|
||||
),
|
||||
).toThrow("match 正则不合法");
|
||||
const errors = validateHttpTarget({
|
||||
expect: { headers: { "x-test": { match: "[invalid" } } },
|
||||
http: { url: "https://example.com" },
|
||||
name: "test",
|
||||
type: "http",
|
||||
});
|
||||
expect(errors).toContain("match 正则不合法");
|
||||
});
|
||||
|
||||
test("非法 operator gte 类型启动失败", () => {
|
||||
expect(() =>
|
||||
checker.resolve(
|
||||
{
|
||||
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 必须为有限数字");
|
||||
const errors = validateHttpTarget({
|
||||
expect: { body: [{ json: { gte: "abc", path: "$.count" } }] },
|
||||
http: { url: "https://example.com" },
|
||||
name: "test",
|
||||
type: "http",
|
||||
});
|
||||
expect(errors).toContain("gte 必须为有限数字");
|
||||
});
|
||||
|
||||
test("非法 operator exists 类型启动失败", () => {
|
||||
expect(() =>
|
||||
checker.resolve(
|
||||
{
|
||||
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 必须为布尔值");
|
||||
const errors = validateHttpTarget({
|
||||
expect: { body: [{ json: { exists: "yes", path: "$.status" } }] },
|
||||
http: { url: "https://example.com" },
|
||||
name: "test",
|
||||
type: "http",
|
||||
});
|
||||
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(
|
||||
{
|
||||
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("只能配置一种规则类型");
|
||||
const errors = validateHttpTarget({
|
||||
expect: { body: [{ contains: "ok", regex: "ok" }] },
|
||||
http: { url: "https://example.com" },
|
||||
name: "test",
|
||||
type: "http",
|
||||
});
|
||||
expect(errors).toContain("只能配置一种规则类型");
|
||||
});
|
||||
|
||||
test("body rule 缺少支持字段启动失败", () => {
|
||||
expect(() =>
|
||||
checker.resolve(
|
||||
{
|
||||
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("缺少支持的规则类型");
|
||||
const errors = validateHttpTarget({
|
||||
expect: { body: [{ foo: "bar" }] },
|
||||
http: { url: "https://example.com" },
|
||||
name: "test",
|
||||
type: "http",
|
||||
});
|
||||
expect(errors).toContain("缺少支持的规则类型");
|
||||
});
|
||||
|
||||
test("css selector 为空启动失败", () => {
|
||||
expect(() =>
|
||||
checker.resolve(
|
||||
{
|
||||
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 必须为非空字符串");
|
||||
const errors = validateHttpTarget({
|
||||
expect: { body: [{ css: { selector: "" } }] },
|
||||
http: { url: "https://example.com" },
|
||||
name: "test",
|
||||
type: "http",
|
||||
});
|
||||
expect(errors).toContain("css.selector 必须为非空字符串");
|
||||
});
|
||||
|
||||
test("xpath path 为空启动失败", () => {
|
||||
expect(() =>
|
||||
checker.resolve(
|
||||
{
|
||||
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 必须为非空字符串");
|
||||
const errors = validateHttpTarget({
|
||||
expect: { body: [{ xpath: { path: "" } }] },
|
||||
http: { url: "https://example.com" },
|
||||
name: "test",
|
||||
type: "http",
|
||||
});
|
||||
expect(errors).toContain("xpath.path 必须为非空字符串");
|
||||
});
|
||||
|
||||
test("expect.headers 非对象启动失败", () => {
|
||||
expect(() =>
|
||||
checker.resolve(
|
||||
{
|
||||
expect: { headers: "invalid" },
|
||||
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 必须为对象");
|
||||
test("json rule 允许存在性语义", () => {
|
||||
const errors = validateHttpTarget({
|
||||
expect: { body: [{ json: { path: "$.status" } }] },
|
||||
http: { url: "https://example.com" },
|
||||
name: "test",
|
||||
type: "http",
|
||||
});
|
||||
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",
|
||||
},
|
||||
{ configDir: ".", defaultIntervalMs: 30000, defaults: {}, defaultTimeoutMs: 10000 },
|
||||
),
|
||||
).toThrow("maxDurationMs 必须为非负有限数字");
|
||||
});
|
||||
|
||||
test("http.body 非字符串启动失败", () => {
|
||||
expect(() =>
|
||||
checker.resolve(
|
||||
{
|
||||
http: { body: 123, 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 必须为对象");
|
||||
test("equals 支持对象和数组", () => {
|
||||
const errors = validateHttpTarget({
|
||||
expect: {
|
||||
body: [
|
||||
{ json: { equals: { status: "ok" }, path: "$.payload" } },
|
||||
{ json: { equals: ["a", "b"], path: "$.items" } },
|
||||
],
|
||||
},
|
||||
http: { url: "https://example.com" },
|
||||
name: "test",
|
||||
type: "http",
|
||||
});
|
||||
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);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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("无规则返回匹配成功", () => {
|
||||
|
||||
@@ -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 返回匹配成功", () => {
|
||||
|
||||
@@ -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("短字符串不截断", () => {
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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("无规则返回匹配成功", () => {
|
||||
|
||||
@@ -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
Reference in New Issue
Block a user