1
0

Compare commits

..

13 Commits

Author SHA1 Message Date
2f8fd8bd9c refactor: 将 memory checker 重命名为 mem
- 类型标识符 memory → mem
- 类名 MemoryChecker → MemChecker
- 内部类型名统一 Memory* → Mem*
- 内部函数名统一 *Memory* → *Mem*
- 目录重命名 memory/ → mem/(源码、测试、文档)
- 配置键 memory: → mem:
- 重新生成 probe-config.schema.json
- 保留中文"内存"用户提示

破坏性变更:无向后兼容
2026-05-27 18:19:29 +08:00
3390eb5e8d fix: 强化 CPU/memory checker 错误处理、timeout 遵守和快照校验
- Memory checker: reader 与 ctx.signal race,abort 返回 memory/timeout,reject 保持 memory/snapshot
- CPU checker: 第二次快照异常返回 cpu/snapshot,计算前校验空数组/核心数不一致/非有限值/负 delta
- CPU 计算: 零 delta 安全处理,observation 不含 NaN/Infinity
- 文档: CPU 互补描述修正,Memory timeout 约束说明
- 测试: +18 覆盖 timeout、异常和边界输入
2026-05-27 16:33:39 +08:00
145bb8fd04 feat: 新增 memory checker,支持系统级内存和交换空间检测 2026-05-27 00:05:06 +08:00
358f8d011a chore: 放宽权限配置 & 归档 openspec 变更记录 2026-05-26 22:36:33 +08:00
c2dcfab80c feat: 新增本机 CPU checker
- 新增 type: cpu checker,基于 os.cpus() 两次快照计算 CPU 使用率
- 配置项:sampleDuration(默认 1s)、includePerCore(默认 false)
- expect 字段:usagePercent、idlePercent、maxCoreUsagePercent、minCoreUsagePercent、durationMs
- idlePercent 与 usagePercent 互补恒等于 100,百分比范围 0-100
- logicalCoreCount 仅输出到 observation,不作为 expect 字段
- 不暴露 userPercent / systemPercent
- 语义校验禁止 sampleDuration >= timeout
- 支持 AbortSignal 超时取消
- 完整测试覆盖:schema、validate、normalize、resolve、calculate、execute、expect、config-loader
- 新增用户文档 docs/user/checkers/cpu.md
- 更新 checker 索引、配置类型列表、示例配置和 schema
2026-05-26 22:34:57 +08:00
f38286d74d feat: 添加 pi 配置文件
- 添加 .pi/mcp.json 配置 tdesign-mcp-server
- 添加 .pi/extensions/pi-permission-system/config.json 权限配置
- 更新 .gitignore 以跟踪 .pi/mcp.json 和 .pi/extensions
2026-05-26 15:37:27 +08:00
08b61cbf47 refactor: ProbeEngine 调度引擎重写为 per-target setTimeout 链
将 per-group setInterval + groupBy 调度模式改为 per-target setTimeout 链,
实现 catch-up 语义(超时后立即补执行)、AbortController 优雅停止、
循环内错误隔离和 overrun warn 日志。
移除 groupBy/probeGroup/timers,新增 sleep/runLoop/runOnce。
新增 croner 依赖供后续 cron 表达式支持使用。
2026-05-26 11:35:06 +08:00
c120690cf1 feat: target 时间配置校验,interval 最小 10s,timeout 不大于 interval
在配置加载阶段新增通用 target 时间字段语义校验:
- interval 解析后不得小于 10s
- timeout 解析后不得大于同一 target 的 interval
- 默认值(30s / 10s)参与校验
- 变量引用先解析再校验
- 格式错误优先于关系错误,避免级联提示
2026-05-25 17:48:51 +08:00
77c6015b3a refactor: 将 checker normalize 职责下沉到各 runner 目录
- 新增 CheckerDefinition.normalize 必需方法,typecheck 兜底遗漏实现
- 新增 expect/normalize.ts 共享 helper(compactExpect、normalizeValue、
  normalizeContent、normalizeKeyed)
- 为 HTTP、Cmd、DB、TCP、UDP、ICMP、LLM、WS、DNS 各新增独立 normalize.ts
- 简化 normalizer.ts:删除所有 checker type switch,改为 registry 委托
- 修复 DNS authoring 简写 bug:durationMs、valueCount、result 等字段
  现可通过完整加载链路
- 新增 DNS 回归测试和 registry 级合同测试
- 更新 docs/development/checker.md:补充 normalize 规范、文件结构、
  测试要求和 checklist
2026-05-25 16:16:41 +08:00
c1db793073 feat: WS checker,支持可达性检测和单次请求-响应交互验证 2026-05-25 14:13:43 +08:00
714b635aef docs: 重构文档体系
- 合并 DEVELOPMENT.md 至 docs/development/README.md
- 合并 CONTRIBUTING.md 至 docs/development/checker.md
- 合并 build-release.md 至 release.md
- 合并 testing-quality.md 内容至各专题文档
- 合并 status-model.md 至 expectations.md
- 新增 docs/user/README.md 用户入口
- 简化 docs/README.md 文档路由
- 各专题文档新增适用场景和更新触发条件
- 更新 openspec/config.yaml 文档规则
2026-05-25 10:47:52 +08:00
a6504d5a62 docs: 重构文档体系 2026-05-24 20:18:18 +08:00
483cdc596b feat: DNS checker,自研 codec/transport,支持 system/server 双模式,UDP/TCP + TC fallback 2026-05-24 17:06:22 +08:00
116 changed files with 15424 additions and 2376 deletions

3
.gitignore vendored
View File

@@ -403,6 +403,9 @@ cython_debug/
!.claude/settings.json
.opencode
.codex
.pi/*
!.pi/mcp.json
!.pi/extensions
openspec/changes/archive
temp
.agents

View File

@@ -0,0 +1,19 @@
{
"$schema": "https://raw.githubusercontent.com/gotgenes/pi-permission-system/main/schemas/permissions.schema.json",
"permission": {
"*": "allow",
"write": "allow",
"edit": "allow",
"bash": {
"*": "allow",
"npm *": "deny",
"npx *": "deny",
"pnpm *": "deny",
"pnpx *": "deny"
},
"external_directory": {
"*": "ask",
"/tmp/*": "allow"
}
}
}

8
.pi/mcp.json Normal file
View File

@@ -0,0 +1,8 @@
{
"mcpServers": {
"tdesign-mcp-server": {
"command": "bunx",
"args": ["tdesign-mcp-server@latest"]
}
}
}

File diff suppressed because it is too large Load Diff

596
README.md
View File

@@ -10,31 +10,16 @@
---
DiAL 是一个自托管的拨测监控工具,支持 **HTTP**、**命令行**、**数据库**、**TCP**、**UDP**、**ICMP** 和 **LLM** 多种拨测类型。通过 YAML 配置文件定义拨测目标,后端定时并发执行拨测并将结果持久化到本地 SQLite前端 Dashboard 展示各目标的实时状态、可用率和耗时趋势。
DiAL 是一个自托管的拨测监控工具,支持 **HTTP**、**命令行**、**数据库**、**TCP**、**UDP**、**DNS**、**ICMP** 和 **LLM** 多种拨测类型。通过 YAML 配置文件定义拨测目标,后端定时并发执行拨测并将结果持久化到本地 SQLite前端 Dashboard 展示各目标的实时状态、可用率和耗时趋势。
**功能亮点:**
## 功能亮点
-种拨测类型HTTPGET/POST/PUT 等、Cmd命令行执行、DBPostgreSQL/MySQL/SQLite、TCP端口可达性 + Banner 探测、UDP自定义 payload 请求-响应、ICMP存活检测、延迟、丢包率、LLM大模型服务应用层健康检查
- 丰富校验规则状态码、响应头、JSONPath、CSS 选择器、XPath、正则匹配、数值比较等
- 结构化观测数据:检查结果保留按需读取的 HTTP body 预览、TCP/UDP 响应摘要、ICMP 丢包率、CMD 输出预览、LLM token 用量等 observation便于排障和后续分析
- 响应式 Dashboard实时状态、可用率统计、动态粒度趋势图avg/P95 + 状态条、手动/自动刷新、版本号展示
-类型拨测HTTP、Cmd、DB、TCP、UDP、DNS、ICMP、LLM
- 丰富校验规则状态码、响应头、JSONPath、CSS 选择器、XPath、正则匹配、数值比较等
- 结构化观测数据HTTP body 预览、TCP/UDP 响应摘要、ICMP 丢包率、CMD 输出、LLM token 用量等
- 内置 Dashboard实时状态、可用率统计、趋势图、最近状态条、手动/自动刷新、版本号展示
- 多主题支持:系统、明亮、黑暗三种主题模式
- 零外部依赖:数据存储使用 SQLite无需额外数据库服务
## 版本管理
DiAL 使用 `package.json.version` 作为唯一版本源Dashboard Header 展示当前运行实例版本号(如 `v0.1.0`)。
**版本升迁命令:**
```bash
bun run version:patch # 升迁 patch 版本0.1.0 -> 0.1.1
bun run version:minor # 升迁 minor 版本0.1.0 -> 0.2.0
bun run version:major # 升迁 major 版本0.1.0 -> 1.0.0
bun run version:set 0.2.0 # 显式设置版本
```
版本升迁仅更新 `package.json`,不自动创建 git commit、tag 或 changelog。
- 自托管部署:本地 SQLite 存储,无需额外数据库服务
## 应用截图
@@ -50,572 +35,55 @@ bun run version:set 0.2.0 # 显式设置版本
ICMP checker 依赖系统 `ping` 命令。精简容器镜像需额外安装,例如 Alpine 可安装 `iputils-ping`
```bash
# 克隆仓库
git clone https://github.com/your-org/DiAL.git
cd DiAL
# 安装依赖
bun install
# 复制示例配置并按需修改
cp probes.example.yaml probes.yaml
# 启动开发服务器
bun run dev probes.yaml
```
`bun run dev` 会同时启动 Vite 开发服务器(`http://127.0.0.1:5173`)和 API 服务器(`http://127.0.0.1:3000`),访问前端地址即可使用 Dashboard。
## 生产部署
```bash
# 构建
bun run build
# 运行
./dist/dial-server ./probes.yaml
```
构建产物为独立可执行文件,只需一个 YAML 配置文件即可运行。
### Docker 部署
DiAL 提供基于 Alpine 的多阶段镜像。构建阶段使用 Bun 生成 musl 目标单可执行文件,运行阶段只包含 `dial-server`、基础证书、`ping`、musl executable 必需运行库、时区数据和容器运行所需目录。
```bash
# 构建当前架构镜像
docker build -t dial:alpine .
# 运行容器,使用内置容器配置示例
docker run --rm -p 3000:3000 -v dial-data:/data/dial dial:alpine
# 使用自定义配置文件
docker run --rm -p 3000:3000 \
-v "$PWD/docker/probes.yaml:/etc/dial/probes.yaml:ro" \
-v dial-data:/data/dial \
dial:alpine
```
容器默认读取 `/etc/dial/probes.yaml`,推荐将数据卷挂载到 `/data/dial`。容器专用示例配置位于 [`docker/probes.yaml`](docker/probes.yaml),默认监听 `0.0.0.0:3000`,并将 SQLite 数据和日志写入 `/data/dial`
多架构镜像可通过 Docker Buildx 构建:
```bash
docker buildx build --platform linux/amd64,linux/arm64 -t dial:alpine .
```
如需在容器中运行 ICMP checker除镜像内置的 `iputils-ping` 外,还需要授予 `NET_RAW` capability
```bash
docker run --rm --cap-add=NET_RAW -p 3000:3000 -v dial-data:/data/dial dial:alpine
```
官方镜像不内置 `bun``node``curl``dig``psql``mysql``redis-cli` 等 CMD checker 可能需要的额外命令。需要这些命令时请使用派生镜像自行安装:
```dockerfile
FROM dial:alpine
USER root
RUN apk add --no-cache curl bind-tools postgresql-client
USER dial
```
### 跨平台发布打包
```bash
# 编译全部 7 个目标平台
bun run release
# 编译指定平台
bun run release --target linux-x64
bun run release --target linux-x64,windows-x64,darwin-arm64
```
支持的目标平台:`linux-x64``linux-arm64``linux-x64-musl``linux-arm64-musl``windows-x64``darwin-x64``darwin-arm64`
**产出物结构:**
```
dist/release/
├── binaries/ ← 裸二进制(带版本号和平台标识)
│ ├── dial-server-0.1.0-linux-x64
│ ├── dial-server-0.1.0-windows-x64.exe
│ └── ...
└── packages/ ← tar.gz 压缩包 + SHA256 校验和
├── dial-server_0.1.0_linux_x64.tar.gz
├── dial-server_0.1.0_linux_x64.tar.gz.sha256
└── ...
```
压缩包内含可执行文件、`probes.example.yaml``LICENSE`,解压后可直接使用。
## 配置文件
程序通过 YAML 配置文件定义所有运行参数,完整示例参见 [`probes.example.yaml`](probes.example.yaml)。
### 配置文件结构
## 最小配置示例
```yaml
# yaml-language-server: $schema=./probe-config.schema.json
server: # 服务配置(均可省略)
listen:
host: "127.0.0.1"
port: "${server_port}"
storage:
dataDir: "/tmp/probes_data"
retention: "${retention}"
logging:
level: "${log_level|info}"
file:
path: "<dataDir>/logs/dial.log"
probes: # 拨测运行时配置(可省略)
execution:
maxConcurrentChecks: "${max_checks}"
variables: # 配置变量(可省略)
env_name: "生产"
base_url: "https://api.example.com"
server_port: 3000
retention: "7d"
max_checks: 20
default_interval: "30s" # 通过变量在多个 target 间共享常用值
default_timeout: "10s"
targets: # 拨测目标列表(必填)
targets:
- id: "baidu-home"
name: "Baidu"
type: http
http:
url: "https://www.baidu.com"
expect:
# ...
- id: "my-cmd"
name: "脚本检查"
type: cmd
cmd:
# ...
expect:
# ...
# ... 更多 targets
status: [200]
durationMs:
lte: 5000
```
### server.listen — 监听配置
完整配置、checker、expect 和部署说明参见 [用户文档](docs/user/README.md)、[配置文件](docs/user/configuration.md)、[Checker 参考](docs/user/checkers/README.md) 和 [校验规则](docs/user/expectations.md)。
| 字段 | 说明 | 必填 | 默认值 |
| ------ | -------- | ---- | ----------- |
| `host` | 监听地址 | 否 | `127.0.0.1` |
| `port` | 监听端口 | 否 | `3000` |
## 生产运行
### server.storage — 存储配置
| 字段 | 说明 | 必填 | 默认值 |
| ----------- | ------------------------------------------------ | ---- | -------- |
| `dataDir` | 数据目录,相对路径基于配置文件所在目录解析 | 否 | `./data` |
| `retention` | 历史数据保留时长,支持 `ms`/`s`/`m`/`h`/`d` 单位 | 否 | `7d` |
### probes.execution — 拨测运行时配置
| 字段 | 说明 | 必填 | 默认值 |
| --------------------- | -------------- | ---- | ------ |
| `maxConcurrentChecks` | 最大并发拨测数 | 否 | `20` |
### server.logging — 日志配置
| 字段 | 说明 | 必填 | 默认值 |
| ---------------------------------------- | ---------------------------------------------- | ---- | ------------------------- |
| `server.logging.level` | 全局日志等级console 和 file 未指定时继承此值 | 否 | `info` |
| `server.logging.console.level` | 控制台日志等级 | 否 | 继承 `level` |
| `server.logging.file.level` | 文件日志等级 | 否 | 继承 `level` |
| `server.logging.file.path` | 日志文件路径,相对路径基于配置文件目录解析 | 否 | `<dataDir>/logs/dial.log` |
| `server.logging.file.rotation.size` | 按大小滚动,支持 `KB`/`MB`/`GB` 单位 | 否 | `50MB` |
| `server.logging.file.rotation.frequency` | 按时间滚动:`hourly``daily``weekly` | 否 | `daily` |
| `server.logging.file.rotation.maxFiles` | 保留的归档文件数量(不含活跃日志) | 否 | `14` |
日志等级支持:`trace``debug``info``warn``error``fatal`
控制台始终输出pretty 格式),文件始终输出 JSONL 格式并支持滚动。`rotation.size``rotation.frequency` 任一条件触发即滚动。
### 内置默认值
未显式配置时,系统使用以下内置默认值:
- `interval``30s`(拨测间隔)
- `timeout``10s`(超时时间)
- 各 checker 专属默认值见对应章节
如需在配置文件中共享相同的配置值,可使用 `variables` 定义变量,然后在 `server``probes``targets` 中通过 `${var}` 引用。例如在 `variables` 中定义 `default_interval: "30s"`,在多个 target 的 `interval` 字段写 `${default_interval}`
### variables — 配置变量
`variables` 是顶层动态键值表key 必须符合 `[a-zA-Z_][a-zA-Z0-9_]*`value 仅支持 string、number、boolean。`server``probes``targets` 中的字符串值可引用变量:
- `${key}`:引用 variables 或环境变量
- `${key|default}`:变量和环境变量都不存在时使用默认值,第一个 `|` 后的内容为默认值
- `${key|}`:变量和环境变量都不存在时使用空字符串作为默认值
- `$${key}`:转义输出字面量 `${key}`
解析优先级为 `variables -> process.env -> 默认值`,三者均不存在时配置校验失败。字段值完整等于单个变量引用时会保留 number/boolean/string 类型,环境变量和默认值会做类型推断,但空字符串保持为字符串;部分拼接时统一转为字符串。变量替换作用于 `server``probes``targets`,不作用于 `variables` 段自身,且不会替换 `targets[].id``targets[].type` 字段;对象 key 不参与替换。
配置加载内部区分三层形态:用户 YAML 属于 Authoring Config允许变量引用和 expect 简写;`normalizeAuthoringConfig()` 会在启动时完成变量替换、expect primitive/keyed/content 简写展开并移除 `variables` 段,生成 Normalized Configchecker 的 `resolve()` 只在 ResolvedConfig 阶段补默认值并解析 duration、size、路径和运行期环境。根目录 `probe-config.schema.json` 面向 Authoring Config因此 VSCode 校验会接受 `server.listen.port: "${server_port|3000}"``http.maxRedirects: "${MAX|5}"``expect.durationMs: 5000` 这类写法。
### targets — 拨测目标列表(必填)
每个 target 的通用字段:
| 字段 | 说明 | 必填 | 默认值 |
| ------------- | ------------------------------------------------------------------------------------ | ---- | --------- |
| `id` | 目标唯一标识,最长 30 字符,支持字母数字、下划线、连字符,不参与变量替换 | 是 | |
| `name` | 展示名称,最长 30 字符,支持变量替换,可省略或显式 null前端展示时 null 回退到 `id` | 否 | |
| `description` | 目标描述,最长 500 字符,支持变量替换,可省略或显式 null允许空字符串 | 否 | |
| `type` | 目标类型:`http``cmd``db``tcp``udp``icmp``llm` | 是 | |
| `group` | 分组名称 | 否 | `default` |
| `interval` | 拨测间隔,未配置时使用内置默认值 `30s` | 否 | `30s` |
| `timeout` | 超时时间,未配置时使用内置默认值 `10s` | 否 | `10s` |
---
### HTTP Checker`type: http`
**配置项**
| 字段 | 说明 | 必填 | 默认值 |
| ------------------- | ------------------- | ---- | ------- |
| `http.url` | 目标 URL | 是 | |
| `http.method` | HTTP 方法 | 否 | `GET` |
| `http.headers` | 请求头 | 否 | |
| `http.body` | 请求体 | 否 | |
| `http.ignoreSSL` | 忽略 HTTPS 证书校验 | 否 | `false` |
| `http.maxRedirects` | 最大重定向跟随次数 | 否 | `0` |
**expect 校验项**
| 字段 | 说明 | 必填 | 默认值 |
| ------------ | -------------------------------------------------- | ---- | ------- |
| `status` | 可接受的状态码列表,支持精确码和范围(如 `"2xx"` | 否 | `[200]` |
| `headers` | 响应头校验,使用动态键名和 `KeyedExpectations` | 否 | |
| `body` | 响应体校验,使用 `ContentExpectations` 数组 | 否 | |
| `durationMs` | 完整执行耗时校验,使用 `ValueMatcher` | 否 | |
**配置示例**
```yaml
- id: "json-api"
name: "JSON API 示例"
type: http
http:
url: "https://httpbin.org/json"
headers:
Authorization: "Bearer token"
expect:
status: [200]
headers:
Content-Type:
contains: "application/json"
body:
- json:
path: "$.slideshow.title"
equals: "Sample Slide Show"
durationMs:
lte: 10000
```bash
bun run build
./dist/dial-server ./probes.yaml
```
---
### Cmd Checker`type: cmd`
**配置项**
| 字段 | 说明 | 必填 | 默认值 |
| ---------- | -------------------------------------- | ---- | ------ |
| `cmd.exec` | 可执行文件名或路径 | 是 | |
| `cmd.args` | 命令行参数列表 | 否 | `[]` |
| `cmd.env` | 环境变量覆盖(继承进程环境变量并合并) | 否 | |
| `cmd.cwd` | 工作目录(相对于配置文件所在目录) | 否 | |
**expect 校验项**
| 字段 | 说明 | 必填 | 默认值 |
| ------------ | --------------------------------------------- | ---- | ------ |
| `exitCode` | 可接受的退出码列表 | 否 | `[0]` |
| `stdout` | 标准输出校验,使用 `ContentExpectations` 数组 | 否 | |
| `stderr` | 标准错误校验,使用 `ContentExpectations` 数组 | 否 | |
| `durationMs` | 完整执行耗时校验,使用 `ValueMatcher` | 否 | |
**配置示例**
```yaml
- id: "bun-script"
name: "Bun 脚本检查"
type: cmd
cmd:
exec: "bun"
args: ["-e", "console.log('ok')"]
expect:
exitCode: [0]
stdout:
- contains: "ok"
```
---
### DB Checker`type: db`
**配置项**
| 字段 | 说明 | 必填 | 默认值 |
| ---------- | ------------------------------------------------------------- | ---- | ------ |
| `db.url` | 数据库连接字符串,支持 `postgres://``mysql://``sqlite://` | 是 | |
| `db.query` | SQL 查询语句,不配置时仅测试连接 | 否 | |
**expect 校验项**
| 字段 | 说明 | 必填 | 默认值 |
| ------------ | ----------------------------------------------------------------------- | ---- | ------ |
| `rowCount` | 查询返回行数校验,使用 `ValueMatcher` | 否 | |
| `rows` | 查询结果逐行校验,数组内每行为列名到 `KeyedExpectations` 的映射 | 否 | |
| `result` | 完整查询结果 `{ rows, rowCount }` 校验,使用 `ContentExpectations` 数组 | 否 | |
| `durationMs` | 完整执行耗时校验,使用 `ValueMatcher` | 否 | |
**配置示例**
```yaml
- id: "sqlite-query"
name: "SQLite 数据库检查"
type: db
db:
url: "sqlite:///path/to/db.sqlite"
query: "SELECT COUNT(*) as cnt FROM users WHERE status = 'active'"
expect:
durationMs:
lte: 5000
rowCount: { gte: 1 }
rows:
- cnt: { gte: 0 }
```
---
### TCP Checker`type: tcp`
**配置项**
| 字段 | 说明 | 必填 | 默认值 |
| ----------------------- | ------------------------------------------- | ---- | ------- |
| `tcp.host` | 目标主机地址 | 是 | |
| `tcp.port` | 目标端口1-65535 | 是 | |
| `tcp.readBanner` | 是否读取服务端 banner | 否 | `false` |
| `tcp.bannerReadTimeout` | banner 读取超时(毫秒) | 否 | `2000` |
| `tcp.maxBannerBytes` | banner 最大字节数,支持 `KB`/`MB`/`GB` 单位 | 否 | `4KB` |
**expect 校验项**
| 字段 | 说明 | 必填 | 默认值 |
| ------------ | ------------------------------------------------------------------------- | ---- | ------ |
| `connected` | 期望连接结果,`true` 可达或 `false` 期望不可达 | 否 | `true` |
| `banner` | Banner 内容校验,使用 `ContentExpectations` 数组,需开启 `tcp.readBanner` | 否 | |
| `durationMs` | 完整执行耗时校验,使用 `ValueMatcher` | 否 | |
**配置示例**
```yaml
- id: "redis-port"
name: "Redis 端口可达"
type: tcp
tcp:
host: "127.0.0.1"
port: 6379
expect:
durationMs:
lte: 3000
```
---
### UDP Checker`type: udp`
**配置项**
| 字段 | 说明 | 必填 | 默认值 |
| ---------------------- | ---------------------------------------- | ---- | ------ |
| `udp.host` | 目标主机地址 | 是 | |
| `udp.port` | 目标端口1-65535 | 是 | |
| `udp.payload` | 发送数据 | 否 | `""` |
| `udp.encoding` | payload 编码:`text``hex``base64` | 否 | `text` |
| `udp.responseEncoding` | 响应解码:`text``hex``base64` | 否 | `text` |
| `udp.maxResponseBytes` | 响应最大字节数,支持 `KB`/`MB`/`GB` 单位 | 否 | `4KB` |
**expect 校验项**
| 字段 | 说明 | 必填 | 默认值 |
| -------------- | --------------------------------------------- | ---- | ------ |
| `responded` | 期望是否收到响应 | 否 | `true` |
| `response` | 响应内容校验,使用 `ContentExpectations` 数组 | 否 | |
| `responseSize` | 响应字节数校验,使用 `ValueMatcher` | 否 | |
| `sourceHost` | 响应来源地址校验,使用 `ValueMatcher` | 否 | |
| `sourcePort` | 响应来源端口校验,使用 `ValueMatcher` | 否 | |
| `durationMs` | 完整执行耗时校验,使用 `ValueMatcher` | 否 | |
**配置示例**
```yaml
- id: "udp-heartbeat"
name: "UDP 心跳检测"
type: udp
udp:
host: "127.0.0.1"
port: 9000
payload: "PING"
expect:
responded: true
response:
- contains: "PONG"
durationMs:
lte: 100
```
---
### ICMP Checker`type: icmp`
**配置项**
| 字段 | 说明 | 必填 | 默认值 |
| ----------------- | ------------------------- | ---- | ------ |
| `icmp.host` | 目标主机地址 | 是 | |
| `icmp.count` | ICMP 包数量,范围 `1-100` | 否 | `3` |
| `icmp.packetSize` | ICMP 包大小bytes | 否 | `56` |
ICMP checker 通过系统 `ping` 命令执行 ICMP 探测,支持 Linux、macOS 和 Windows 输出解析。
**expect 校验项**
| 字段 | 说明 | 必填 | 默认值 |
| ------------------- | --------------------------------------------------- | ---- | ------ |
| `alive` | 期望主机可达性 | 否 | `true` |
| `packetLossPercent` | 丢包率百分比校验,范围 `0-100`,使用 `ValueMatcher` | 否 | |
| `avgLatencyMs` | 平均延迟校验,使用 `ValueMatcher` | 否 | |
| `maxLatencyMs` | 最大单次延迟校验,使用 `ValueMatcher` | 否 | |
| `durationMs` | 完整执行耗时校验,使用 `ValueMatcher` | 否 | |
**配置示例**
```yaml
- id: "gateway-icmp"
name: "网关 ICMP 可达"
type: icmp
icmp:
host: "10.0.0.1"
count: 3
packetSize: 56
expect:
alive: true
packetLossPercent:
lte: 10
avgLatencyMs:
lte: 100
maxLatencyMs:
lte: 300
durationMs:
lte: 5000
```
---
### LLM Checker`type: llm`
**配置项**
| 字段 | 说明 | 必填 | 默认值 |
| --------------------- | ------------------------------------------------------ | ---- | ------- |
| `llm.provider` | 模型提供方:`openai``openai-responses``anthropic` | 是 | |
| `llm.url` | API base URL | 是 | |
| `llm.model` | 模型名称 | 是 | |
| `llm.prompt` | 单轮 prompt | 是 | |
| `llm.mode` | 调用模式:`http`(非流式)或 `stream`(流式) | 否 | `http` |
| `llm.key` | API key支持 `${VAR}` 变量替换 | 否 | `""` |
| `llm.authToken` | Bearer token`anthropic` provider`key` 互斥) | 否 | |
| `llm.headers` | 附加请求头 | 否 | |
| `llm.ignoreSSL` | 忽略 HTTPS 证书校验 | 否 | `false` |
| `llm.options` | 生成选项 | 否 | |
| `llm.providerOptions` | Provider 专属选项 | 否 | |
`llm.options` 支持 `maxOutputTokens`(默认 `16`)、`temperature`(默认 `0`)、`topP``topK``presencePenalty``frequencyPenalty``stopSequences``seed`
**expect 校验项**
| 字段 | 说明 | 必填 | 默认值 |
| ----------------- | ---------------------------------------------------------------------- | ---- | ------- |
| `status` | 可接受的状态码列表,支持精确码和范围(如 `"2xx"` | 否 | `[200]` |
| `headers` | 响应头校验,使用动态键名和 `KeyedExpectations` | 否 | |
| `output` | 模型输出校验,使用 `ContentExpectations` 数组 | 否 | |
| `finishReason` | finish reason 校验,使用 `ValueMatcher` | 否 | |
| `rawFinishReason` | 原始 finish reason 校验,使用 `ValueMatcher` | 否 | |
| `usage` | Token usage 校验(`inputTokens`/`outputTokens`/`totalTokens` matcher | 否 | |
| `stream` | 流式断言(`completed``firstTokenMs` matcher`mode: stream` | 否 | |
| `durationMs` | 完整执行耗时校验,使用 `ValueMatcher` | 否 | |
**配置示例**
```yaml
- id: "llm-openai-probe"
name: "OpenAI 健康检查"
type: llm
llm:
provider: openai
url: "https://api.openai.com/v1"
model: "gpt-4o-mini"
prompt: "Say OK"
key: "${OPENAI_API_KEY}"
expect:
status: [200]
finishReason: "stop"
output:
- contains: "OK"
```
---
### 通用校验规则
#### ContentExpectations 校验项
`body``stdout``stderr``banner``response``output``result` 均使用数组:
- `contains` — 响应体包含指定文本
- `regex` — 正则匹配(启动期会拒绝存在 ReDoS 风险的模式)
- `json` — JSONPath 提取值比较(`path` 必填,如 `$.slideshow.title`
- `css` — CSS 选择器提取 HTML 元素(`selector` 必填,`attr` 可选提取属性)
- `xpath` — XPath 提取 XML/HTML 节点(`path` 必填,如 `/html/body/h1/text()`
#### ValueMatcher
`equals``contains``regex``empty``exists``gte``lte``gt``lt``equals` 支持 JSON 深度相等;`regex` 固定使用无 flags 正则;提取器未配置 matcher 时等价于 `exists: true`。ValueMatcher expect 字段也可直接写 string、number、boolean 或 null等价于 `{ equals: value }`;数组和对象必须显式写成 `{ equals: ... }`
#### KeyedExpectations
`headers``rows` 中每行使用的校验结构,支持 ValueMatcher 的全部字段。
### 补充说明
- **大小说明**`maxBodyBytes``maxOutputBytes``maxResponseBytes``maxBannerBytes` 支持 `KB``MB``GB` 单位,也可直接使用数字
- **时长格式**`500ms``30s``5m``2h``7d`
- **JSON Schema**:仓库根目录导出 `probe-config.schema.json`,在 YAML 文件顶部添加 `# yaml-language-server: $schema=./probe-config.schema.json` 即可在编辑器中获得提示和校验
- **旧字段移除**`maxDurationMs``maxPacketLoss``maxAvgLatencyMs``maxMaxLatencyMs` 和旧正则字段 `match` 已移除,请分别改用 `durationMs`、ICMP matcher 字段和 `regex`
> **注意:** 配置校验在启动时执行,非法配置会阻止启动并输出错误信息。除动态键值表(`headers`、`env`、`variables`)外,未知字段会导致启动失败,请使用 YAML 注释。
## 目标状态判定
采用单层判定模型:
- **UP** = 拨测结果符合 expect 规则
- **DOWN** = 拨测结果不符合 expect 规则
执行失败(网络错误、超时、进程崩溃)和 expect 不匹配都统一为 DOWN通过 `failure.kind` 区分原因(`"error"` vs `"mismatch"`)。
API 返回的检查结果包含 `detail``observation``detail` 是后端按 checker 类型从结构化 observation 动态生成的人可读摘要,`observation` 保存该次检查的结构化观测数据。`detail` 不写入 SQLite存储层仅持久化 `observation` JSON、`failure` JSON、匹配状态、耗时和时间戳。
Docker、跨平台发布包和运行时注意事项参见 [部署文档](docs/user/deployment.md)。
## 文档导航
| 入口 | 内容 |
| -------------------------------------------- | ------------------------------------------- |
| [文档总览](docs/README.md) | 全部文档入口和文档归属矩阵 |
| [用户文档](docs/user/README.md) | 配置、部署、expect、排障和 checker 使用入口 |
| [配置文件](docs/user/configuration.md) | YAML 结构、变量、server、targets 通用字段 |
| [Checker 参考](docs/user/checkers/README.md) | 所有 checker 的配置、expect 和示例 |
| [校验规则](docs/user/expectations.md) | expect 规则、状态判定、failure、observation |
| [部署文档](docs/user/deployment.md) | 构建、Docker、发布包和容器运行边界 |
| [故障排查](docs/user/troubleshooting.md) | 常见运行问题和排查入口 |
| [开发文档](docs/development/README.md) | 开发入口、常用命令、质量门禁和专题索引 |
## 开发
@@ -624,7 +92,7 @@ bun run check # schema:check + typecheck + lint + test
bun run verify # check + build
```
开发相关文档(项目结构、构建、测试、代码规范等)请参阅 [DEVELOPMENT.md](DEVELOPMENT.md)。
开发入口参见 [开发文档](docs/development/README.md)。新增或修改 checker 前请先阅读 [Checker 开发](docs/development/checker.md)。
## License

View File

@@ -14,6 +14,7 @@
"ai": "^6",
"ajv": "^8.20.0",
"cheerio": "^1.2.0",
"croner": "^10.0.1",
"es-toolkit": "^1.46.1",
"pino": "^10.3.1",
"pino-pretty": "^13.1.3",
@@ -21,6 +22,7 @@
"react": "^19.2.6",
"react-dom": "^19.2.6",
"recharts": "^3.8.1",
"systeminformation": "^5.31.6",
"tdesign-icons-react": "^0.6.4",
"tdesign-react": "^1.16.9",
"xpath": "^0.0.34",
@@ -497,6 +499,8 @@
"cosmiconfig-typescript-loader": ["cosmiconfig-typescript-loader@6.3.0", "https://registry.npmmirror.com/cosmiconfig-typescript-loader/-/cosmiconfig-typescript-loader-6.3.0.tgz", { "dependencies": { "jiti": "2.6.1" }, "peerDependencies": { "@types/node": "*", "cosmiconfig": ">=9", "typescript": ">=5" } }, "sha512-Akr82WH1Wfqatyiqpj8HDkO2o2KmJRu1FhKfSNJP3K4IdXwHfEyL7MOb62i1AGQVLtIQM+iCE9CGOtrfhR+mmA=="],
"croner": ["croner@10.0.1", "https://registry.npmmirror.com/croner/-/croner-10.0.1.tgz", {}, "sha512-ixNtAJndqh173VQ4KodSdJEI6nuioBWI0V1ITNKhZZsO0pEMoDxz539T4FTTbSZ/xIOSuDnzxLVRqBVSvPNE2g=="],
"cross-spawn": ["cross-spawn@7.0.6", "https://registry.npmmirror.com/cross-spawn/-/cross-spawn-7.0.6.tgz", { "dependencies": { "path-key": "^3.1.0", "shebang-command": "^2.0.0", "which": "^2.0.1" } }, "sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA=="],
"css-select": ["css-select@5.2.2", "https://registry.npmmirror.com/css-select/-/css-select-5.2.2.tgz", { "dependencies": { "boolbase": "^1.0.0", "css-what": "^6.1.0", "domhandler": "^5.0.2", "domutils": "^3.0.1", "nth-check": "^2.0.1" } }, "sha512-TizTzUddG/xYLA3NXodFM0fSbNizXjOKhqiQQwvhlspadZokn1KDy0NZFS0wuEubIYAV5/c1/lAr0TaaFXEXzw=="],
@@ -1131,6 +1135,8 @@
"synckit": ["synckit@0.11.12", "https://registry.npmmirror.com/synckit/-/synckit-0.11.12.tgz", { "dependencies": { "@pkgr/core": "^0.2.9" } }, "sha512-Bh7QjT8/SuKUIfObSXNHNSK6WHo6J1tHCqJsuaFDP7gP0fkzSfTxI8y85JrppZ0h8l0maIgc2tfuZQ6/t3GtnQ=="],
"systeminformation": ["systeminformation@5.31.6", "https://registry.npmmirror.com/systeminformation/-/systeminformation-5.31.6.tgz", { "os": "!aix", "bin": { "systeminformation": "lib/cli.js" } }, "sha512-Uv2b2uGGM6ns+26czgW2cYRabYdnswM0ddSOOlryHOaelzsmDSet1iM/NT7VOYxW8x/BW+HkY+b1Ve2pLTSGSA=="],
"tar-stream": ["tar-stream@3.2.0", "https://registry.npmmirror.com/tar-stream/-/tar-stream-3.2.0.tgz", { "dependencies": { "b4a": "^1.6.4", "bare-fs": "^4.5.5", "fast-fifo": "^1.2.0", "streamx": "^2.15.0" } }, "sha512-ojzvCvVaNp6aOTFmG7jaRD0meowIAuPc3cMMhSgKiVWws1GyHbGd/xvnyuRKcKlMpt3qvxx6r0hreCNITP9hIg=="],
"tdesign-icons-react": ["tdesign-icons-react@0.6.4", "https://registry.npmmirror.com/tdesign-icons-react/-/tdesign-icons-react-0.6.4.tgz", { "dependencies": { "@babel/runtime": "^7.16.5", "classnames": "^2.2.6" }, "peerDependencies": { "react": ">=16.13.1", "react-dom": ">=16.13.1" } }, "sha512-USAoi9vBWcwcJT45VqR3dRqX1MeAsn/RhHVx4bLwplhrlvE80ZQ1N9V+6F3HqE1Qe9mMDbtRM8Ul80+lALScww=="],

127
docs/README.md Normal file
View File

@@ -0,0 +1,127 @@
# DiAL 文档
本文档是 DiAL 的文档路由入口。AI 工具和开发者应先阅读本文件判断本次任务需要读取和更新哪些专题文档,再按任务类型读取最小必要上下文。
## 目录索引
```text
docs/
README.md
development/
README.md
architecture.md
backend.md
frontend.md
release.md
checker.md
user/
README.md
configuration.md
deployment.md
expectations.md
troubleshooting.md
checkers/
README.md
http.md
cmd.md
db.md
tcp.md
udp.md
icmp.md
dns.md
llm.md
```
`docs/prompts/` 是提示词资产目录,不属于常规开发流程和用户使用文档。代码、配置、部署或 checker 变更不需要更新该目录,除非任务明确要求维护提示词资产。
## 入口文档
| 入口 | 定位 |
| ------------------------------------------- | ------------------------------------------ |
| [项目 README](../README.md) | 项目整体介绍、快速开始、核心能力、文档引导 |
| [开发文档](development/README.md) | 开发入口、全局规则、常用命令、质量门禁 |
| [用户文档](user/README.md) | 用户使用入口、配置、部署、expect、排障 |
| [Checker 用户参考](user/checkers/README.md) | 各 checker 的配置项、expect 字段和示例 |
## 按任务阅读路径
| 任务 | 必读文档 |
| ----------------------------------- | ------------------------------------------------------------------------------------------------------------ |
| 修改项目介绍或快速开始 | [项目 README](../README.md)、本文档 |
| 修改开发流程、质量门禁或工程规则 | [开发文档](development/README.md)、本文档、[OpenSpec 配置](../openspec/config.yaml) |
| 修改架构边界或启动流程 | [开发文档](development/README.md)、[架构与边界](development/architecture.md) |
| 修改后端 API、store、engine、logger | [开发文档](development/README.md)、[后端开发](development/backend.md) |
| 修改前端 | [开发文档](development/README.md)、[前端开发](development/frontend.md) |
| 新增或修改 checker | [Checker 开发](development/checker.md)、[Checker 用户参考](user/checkers/README.md)、相近 checker 文档 |
| 修改配置 schema | [配置文件](user/configuration.md)、[后端开发](development/backend.md)、相关 checker 文档 |
| 修改 expect 或状态模型 | [校验规则](user/expectations.md)、[后端开发](development/backend.md)、[Checker 开发](development/checker.md) |
| 修改构建、Docker、release | [构建与发布](development/release.md)、[部署文档](user/deployment.md) |
| 修改故障处理或运行依赖 | [故障排查](user/troubleshooting.md)、相关用户文档 |
| 修改文档规则或文档目录结构 | 本文档、[OpenSpec 配置](../openspec/config.yaml) |
## 文档归属矩阵
| 变更类型 | 默认更新位置 |
| -------------------------------------------------------------- | -------------------------------------------------------------- |
| 项目定位、核心能力、快速开始、顶层文档导航 | `README.md` |
| 文档路由、文档更新规则、文档归属矩阵 | `docs/README.md``openspec/config.yaml` |
| 开发入口、常用命令、质量门禁、全局工程规则、OpenSpec 约定 | `docs/development/README.md` |
| 架构边界、启动流程、运行时流程、前后端边界 | `docs/development/architecture.md` |
| 后端 API、共享类型、store、engine、logger、expect 基础设施 | `docs/development/backend.md` |
| 前端技术栈、组件、样式、数据层、前端测试 | `docs/development/frontend.md` |
| checker 开发机制、schema/validate/resolve/execute/expect 约定 | `docs/development/checker.md` |
| 构建、发布、Dockerfile、脚本、前后端静态资源集成 | `docs/development/release.md` |
| YAML 顶层结构、server、variables、targets 通用字段 | `docs/user/configuration.md` |
| checker 配置、expect 字段、示例、用户可见 checker 行为 | `docs/user/checkers/<type>.md``docs/user/checkers/README.md` |
| ValueMatcher、ContentExpectations、KeyedExpectations、状态模型 | `docs/user/expectations.md` |
| 构建产物运行、Docker 参数、发布包、运行时依赖 | `docs/user/deployment.md` |
| 常见运行问题、依赖命令、容器权限、配置校验问题 | `docs/user/troubleshooting.md` |
## development 文档如何更新
开发文档解释“如何实现和维护”。代码变更影响开发者理解、开发流程、测试方式或架构边界时,必须更新 `docs/development/` 对应文档。
- 全局规则、常用命令、质量门禁、目录边界、OpenSpec 约定更新到 `docs/development/README.md`
- 架构图、启动链路、运行时流程、前后端边界更新到 `docs/development/architecture.md`
- 后端 API、配置加载、store、engine、logger、expect 基础设施和后端测试规范更新到 `docs/development/backend.md`
- 前端技术栈、组件边界、数据流、样式规则和前端测试规范更新到 `docs/development/frontend.md`
- checker 开发机制、文件结构、schema、validate、resolve、execute、expect、测试 checklist 更新到 `docs/development/checker.md`
- 构建、Docker、release、脚本和发布验证更新到 `docs/development/release.md`
- 不新增“杂项”开发文档;优先把内容放入上述最贴近的专题,确需新增专题时先更新本文档和 `openspec/config.yaml`
## user 文档如何更新
用户文档解释“如何使用”和“用户能观察到什么”。变更影响用户配置、运行、部署、checker 行为、expect 规则、状态结果或排障方式时,必须更新 `docs/user/` 对应文档。
- 配置事实来源是 TypeBox schema、`probe-config.schema.json`、语义校验器和测试;`docs/user/configuration.md` 负责解释顶层结构和通用字段。
- checker 专属字段和示例只在 `docs/user/checkers/<type>.md` 完整展开,`docs/user/checkers/README.md` 只维护类型索引和选择建议。
- expect 断言模型、UP/DOWN、`failure``observation`、快速失败顺序更新到 `docs/user/expectations.md`
- Docker、生产运行、发布包和运行时依赖更新到 `docs/user/deployment.md`
- 常见错误和排查路径更新到 `docs/user/troubleshooting.md`
- 用户文档避免解释内部实现细节,需要实现细节时链接到 `docs/development/`
## 文档影响分析
每次代码变更都必须执行文档影响分析。
```text
代码或配置变更
-> 用户能感知吗?更新 docs/user/ 或 README.md
-> 开发者需要知道吗?更新 docs/development/
-> 文档规则变化吗?更新 docs/README.md 和 openspec/config.yaml
-> 都不是?收尾说明写明无需更新文档及原因
```
同一事实只在最贴近读者的文档中完整展开,其他文档使用链接引用。根目录 README 保持轻量不承载完整配置参考、checker 表或实现教程。
## 收尾说明示例
```text
文档影响分析:本次修改了 HTTP checker 的配置字段,已更新 docs/user/checkers/http.md、docs/user/configuration.md 和 probe-config.schema.json。
```
无需更新文档时:
```text
文档影响分析:本次仅调整内部测试 helper未改变用户可见行为、配置、架构边界或开发流程因此无需更新文档。
```

115
docs/development/README.md Normal file
View File

@@ -0,0 +1,115 @@
# 开发文档
本文档是 DiAL 的开发入口。AI 工具和开发者应先阅读 [`../README.md`](../README.md) 判断文档归属,再阅读本文和最小必要专题。
适用场景修改源码、测试、构建脚本、开发流程、架构边界、checker 开发机制或项目工程规则。
## 专题索引
| 文档 | 内容 |
| ---------------------------------- | ------------------------------------------------------------------------------------------------- |
| [architecture.md](architecture.md) | 项目结构、启动流程、运行时流程、HTTP 请求流程、前后端边界 |
| [backend.md](backend.md) | 后端库优先级、API 路由、共享 helpers、类型规范、配置契约、store、engine、logger、expect、错误模型 |
| [frontend.md](frontend.md) | React、TDesign、TanStack Query、组件、样式和前端测试规范 |
| [checker.md](checker.md) | 新增或修改 checker 的实现机制、测试要求、文档同步和 checklist |
| [release.md](release.md) | 开发服务、前后端集成、构建、Docker、release、脚本、环境变量 |
| [../README.md](../README.md) | 文档路由、文档归属矩阵、development/user 文档更新规则 |
## 常用命令
| 命令 | 说明 |
| -------------------------------- | ---------------------------------------- |
| `bun install` | 安装依赖 |
| `bun run dev probes.yaml` | 启动双进程开发环境 |
| `bun run dev:server probes.yaml` | 仅启动后端 API server |
| `bun run dev:web` | 仅启动 Vite dev server |
| `bun run schema` | 生成 `probe-config.schema.json` |
| `bun run schema:check` | 检查导出 schema 是否同步 |
| `bun run typecheck` | TypeScript 类型检查 |
| `bun run lint` | ESLint 和 Prettier 格式检查 |
| `bun run format` | Prettier 自动格式化 |
| `bun test` | 运行全部测试 |
| `bun run check` | `schema:check + typecheck + lint + test` |
| `bun run build` | 构建生产可执行文件 |
| `bun run verify` | `check + build` 完整验证 |
| `bun run release` | 跨平台发布打包 |
| `bun run clean` | 清理构建缓存与产物 |
## 质量门禁
代码变更必须按影响范围执行验证。
| 变更类型 | 必跑命令 |
| -------------------------------- | --------------------------------------------------------- |
| 常规代码变更 | `bun run check` |
| 构建、部署、发布、前后端集成变更 | `bun run verify` |
| 配置 schema 变化 | `bun run schema``bun run schema:check``bun run check` |
| checker 新增或修改 | `bun run schema``bun run schema:check``bun run check` |
| 仅文档变更 | 检查链接、索引和文档归属一致性 |
正式提交或影响构建产物时优先运行 `bun run verify`。如果因环境限制无法执行完整验证,必须在收尾说明中记录未执行项和原因。
## 全局工程规则
- 使用中文编写注释、文档和项目内交流内容。
- 仅使用 `bun` 作为包管理器,禁止使用 npm、pnpm、yarn。
- 运行工具使用 `bunx`,禁止使用 npx、pnpx。
- 新增代码优先复用已有组件、工具和依赖库,不引入新依赖;确需新增依赖时先说明原因。
- 后端优先使用 Bun 内置 API其次是 es-toolkit、标准 Web API、主流三方库最后才自行实现。
- 前端样式优先使用 TDesign 组件、组件 props、TDesign CSS tokens、`styles.css` CSS 类,最后才自行开发组件。
- 前端禁止组件内联 `style`、覆盖 TDesign 内部类名、使用 `!important`、硬编码色值。
- 当前项目未上线,不需要为旧行为做向前兼容,除非用户明确要求。
## 包管理、依赖与提交
- 仅使用 `bun` 安装依赖和运行项目脚本,锁文件为 `bun.lock`
- 新增依赖前先确认 Bun 内置 API、es-toolkit、标准 Web API、现有三方库和项目公共工具是否已满足需求。
- Git 提交信息使用中文,格式为 `类型: 简短描述`
- 提交类型限定为 `feat``fix``refactor``docs``style``test``chore`
- 多行提交描述时,标题和正文之间空一行。
## 目录边界
| 目录 | 约定 |
| ------------------- | ---------------------------------------------------------- |
| `src/server/` | Bun 后端代码,不能 import `src/web/`HTML import 集成除外 |
| `src/web/` | React Dashboard不能 import `src/server/` 运行时实现 |
| `src/shared/` | 前后端共享 TypeScript 类型 |
| `scripts/` | 独立运行脚本,可 import 项目源码 |
| `tests/` | 测试目录,结构镜像 `src/` |
| `docs/user/` | 用户使用、配置、部署、checker 和排障文档 |
| `docs/development/` | 架构、后端、前端、发布和 checker 开发文档 |
| `openspec/` | OpenSpec 变更管理与规格文档 |
## 文档影响分析
每次代码变更都必须执行文档影响分析。
| 如果变更影响 | 更新 |
| --------------------------------------------------- | ------------------------------------------ |
| 用户可见行为、配置、checker、expect、部署、状态模型 | `docs/user/` 对应文档 |
| 开发流程、架构、测试、构建发布、checker 开发机制 | `docs/development/` 对应文档 |
| 项目定位、快速开始、核心能力列表、文档导航 | `README.md` |
| 文档同步规则或文档归属矩阵 | `docs/README.md``openspec/config.yaml` |
如果无需更新文档,必须在收尾说明中说明原因。详细规则见 [文档总览](../README.md)。
## OpenSpec 协作规则
- 本项目 OpenSpec 使用 `fast-drive` schema变更文档只包含 `design.md``tasks.md`,不创建 `proposal.md``specs/*.md`
- `design.md` 是 scope、requirements、decisions、guardrails、execution direction 和 verification expectations 的 source of truth。
- `tasks.md` 必须从 `design.md` 派生,一行一个 checkbox 任务。
- 实现阶段按 `tasks.md` 顺序执行,完成后立即标记任务状态。
## 事实来源
| 主题 | 事实来源 |
| -------------- | ---------------------------------------------------------- |
| 代码结构和实现 | `src/``scripts/``tests/` |
| 配置 schema | TypeBox fragments、`probe-config.schema.json`、schema 测试 |
| 项目全局规则 | `openspec/config.yaml`、本文档、本目录专题文档 |
| checker 流程 | [checker.md](checker.md) |
## 更新触发条件
修改常用命令、质量门禁、全局工程规则、目录边界、OpenSpec 协作方式或开发文档索引时,必须更新本文档。

View File

@@ -0,0 +1,114 @@
# 架构与边界
本文档说明 DiAL 的项目结构、启动链路、运行时流程、HTTP 请求流程和前后端边界。
适用场景修改目录边界、启动流程、运行时调度、HTTP server、前后端集成方式或主要模块职责。
## 项目结构
```text
src/
server/
bootstrap.ts
config.ts
dev.ts
logger.ts
main.ts
server.ts
helpers.ts
middleware.ts
version.ts
routes/
checker/
config-loader.ts
variables.ts
schema/
store.ts
engine.ts
expect/
runner/
shared/
api.ts
web/
app.tsx
main.tsx
styles.css
components/
constants/
hooks/
utils/
scripts/
tests/
docs/
openspec/
probe-config.schema.json
```
## 启动流程
```text
dev.ts / main.ts
-> readRuntimeConfig(cli args)
-> bootstrap({ configPath, mode })
-> loadConfig(yaml)
-> createRuntimeLogger(logging)
-> ProbeStore(db)
-> store.syncTargets(targets)
-> ProbeEngine(...).start()
-> startServer({ config, mode, store, logger })
-> 注册 SIGINT/SIGTERM shutdown
```
`loadConfig()` 的处理顺序YAML 解析 -> Authoring normalize变量替换 + expect 简写展开)-> Normalized 契约校验 -> 语义校验 -> resolve。
## 运行时流程
```text
定时器 tick
-> ProbeEngine.probeGroup()
-> checkerRegistry.get(target.type).execute()
-> runner/*/expect.ts 校验
-> engine.writeResult()
-> store.insertCheckResult()
```
数据清理由 engine 定时调用 `store.prune(retentionMs)`,每小时执行一次。
## HTTP 请求流程
```text
Request
-> Bun.serve routes 声明式匹配
-> routes/*.ts handler
-> middleware.ts 参数校验
-> helpers.ts 响应格式化
-> Response
```
生产模式下,非 API 路径由 fetch fallback 处理静态资源和 SPA fallback。开发模式下Vite proxy 将 `/api``/health` 请求转发到 Bun API server。
## 前后端边界
- 前端只通过 HTTP 调用后端API 路径为 `/api/*`
- 共享类型放在 `src/shared/`
- 前端不得 import `src/server/` 的运行时实现。
- 后端不得依赖 `src/web/` 运行时代码HTML import 集成除外。
## 主要模块职责
| 模块 | 职责 |
| ------------------------------------- | ------------------------------------------- |
| `src/server/bootstrap.ts` | 统一启动引导和 shutdown 编排 |
| `src/server/server.ts` | Bun HTTP server 和 routes 注册 |
| `src/server/routes/` | API handler按端点拆分 |
| `src/server/checker/config-loader.ts` | YAML 解析、契约校验、语义校验、resolve 调度 |
| `src/server/checker/store.ts` | SQLite 数据存储 |
| `src/server/checker/engine.ts` | 定时调度、并发控制、结果写入、数据清理 |
| `src/server/checker/runner/` | 各 checker 自包含实现 |
| `src/server/checker/expect/` | 跨 checker 复用的断言基础设施 |
| `src/web/` | React Dashboard |
| `src/shared/api.ts` | 前后端共享 API 类型 |
## 更新触发条件
修改项目结构、启动流程、运行时流程、HTTP 请求流程、前后端边界或主要模块职责时,必须更新本文档。

142
docs/development/backend.md Normal file
View File

@@ -0,0 +1,142 @@
# 后端开发
本文档说明 DiAL 后端的 API、配置加载、存储、拨测引擎、日志、expect 和错误模型开发约定。
适用场景:修改 `src/server/``src/shared/api.ts`、后端测试、配置契约、API 响应、store、engine、logger 或 expect 基础设施。
## 库使用优先级
| 优先级 | 来源 | 典型用途 |
| ------ | ------------ | -------------------------------------------------------------- |
| 1 | Bun 内置 API | `Bun.serve``bun:sqlite``Bun.spawn``Bun.file``Bun.YAML` |
| 2 | es-toolkit | 类型判断、深度比较、错误判断、并发控制、集合操作 |
| 3 | 标准 Web API | `Object.fromEntries``Headers``fetch``AbortController` |
| 4 | 主流三方库 | cheerio、xpath、@xmldom/xmldom |
| 5 | 自行实现 | 仅在以上都无法满足时 |
新增依赖前必须先检查上述每一层是否已有可用方案。
## API 路由开发
路由文件位于 `src/server/routes/`,每个端点一个文件。路由通过 `server.ts``Bun.serve({ routes })` 声明式注册,使用 per-method handler 对象。
新增路由步骤:
1.`src/server/routes/` 下创建 `<name>.ts`
2. 实现 handler 函数并 export。
3.`server.ts``routes` 对象中注册路径和 method handler。
4.`tests/server/app.test.ts` 中添加集成测试。
请求参数校验使用 `middleware.ts` 提供的 `validateTargetId``validateTimeRange``validatePagination``validateDashboardWindow``validateRecentLimit``validateMetricsBucket`
## 共享 helpers
| 函数 | 用途 |
| ------------------------------- | ------------------------------------ |
| `createApiError(error, status)` | 构造 API 错误体 |
| `createHeaders(mode, init)` | 创建响应 Headers生产模式附加安全头 |
| `createHealthResponse()` | 构造健康检查响应 |
| `formatDuration(ms)` | 毫秒转为可读时长字符串 |
| `jsonResponse(body, options)` | JSON 响应构造 |
| `mapCheckResult(row, type)` | 数据库行转 API CheckResult |
## 类型规范
- 共享类型以 `src/shared/api.ts` 为唯一源头。
- 严格联合类型优先于宽类型。
- 存储层类型与 API 类型分离。
- checker 具体类型在各自目录定义,中间层通过 base interface 和 registry 完成类型擦除。
- 纯类型导入使用 `import type`
## 配置契约与校验
配置加载流程固定为:`unknown -> AuthoringProbeConfig -> NormalizedProbeConfig -> ValidatedProbeConfig -> ResolvedConfig`
| 层级 | 职责 |
| ---------- | ------------------------------------------------ |
| Authoring | 用户 YAML 可书写形态,允许变量引用和 expect 简写 |
| Normalized | 变量替换和 expect 简写展开后的契约校验形态 |
| Validated | 通过契约校验和语义校验的形态 |
| Resolved | checker `resolve()` 后的运行期配置 |
Ajv 保持严格拒绝模式:`allErrors: true`、不启用类型强制转换、不注入默认值、不自动删除未知字段。默认对象策略是 `additionalProperties: false`,只有明确的动态键值表可以开放任意键名。
新增或修改配置字段时必须同步更新 TypeBox schema fragments、`probe-config.schema.json`、语义 validator、测试和对应用户文档并运行 `bun run schema:check`
## 数据存储
存储层基于 `bun:sqlite`WAL 模式运行,数据库文件位于配置的 `dataDir` 下。
| 方法 | 用途 |
| ------------------------------------------ | ---------------------------------- |
| `syncTargets(targets)` | 启动期同步 targets |
| `insertCheckResult()` | 写入单条检查结果 |
| `getTargets()` | 查询全部 targets |
| `getLatestChecksMap()` | 批量获取每个 target 的最新检查结果 |
| `getAllTargetWindowStats(from, to)` | 批量获取窗口基础计数 |
| `getDashboardIncidentStates(from, to)` | 获取 Dashboard 窗口状态序列 |
| `getAllRecentSamples(limit)` | 批量获取最近采样 |
| `getTargetCheckpoints(targetId, from, to)` | 获取单目标窗口检查点序列 |
| `getTargetDurations(targetId, from, to)` | 获取单目标成功耗时数组 |
| `getHistory()` | 分页查询历史记录 |
| `prune(retentionMs)` | 清理过期数据 |
数据库只负责存储、筛选、排序、分页、LIMIT 和基础聚合。指标语义在后端应用层实现。
## 拨测引擎
- 按 interval 分组,每组独立定时触发。
- 使用 `es-toolkit/Semaphore` 限制全局最大并发数。
- 通过 `checkerRegistry.get(target.type)` 选择 runner。
- 每次检查创建 `AbortController` 并按 `target.timeoutMs` 触发 abort。
- 状态变化通过注入的 `Logger` 输出结构化日志。
## 日志模块
后端运行时代码统一通过 `Logger` 接口输出日志,禁止直接使用 `console.*`。配置加载失败前使用 `ConsoleFallbackLogger`
| 实现 | 用途 |
| ----------------------- | --------------------------------------------- |
| `PinoLoggerWrapper` | 生产运行时,封装 Pino、pino-pretty、pino-roll |
| `NoopLogger` | 静默丢弃日志 |
| `MemoryLogger` | 测试替身 |
| `ConsoleFallbackLogger` | 配置加载失败前的降级日志 |
敏感信息会自动 redact `authorization``cookie``set-cookie``authToken``key``password``token``apiKey` 及其嵌套路径。
## expect 系统
共享断言基础设施位于 `src/server/checker/expect/`。新增或修改 checker 的 expect 字段时,按以下原则选择模型:
| 模型 | 用途 | 典型字段 |
| --------------------- | ---------------------------- | ------------------------------------------------------------------- |
| enum / boolean | 状态类结果,结果集合小且稳定 | HTTP status、Cmd exitCode、TCP connected、UDP responded、ICMP alive |
| `ValueMatcher` | 数字指标和字符串元数据 | durationMs、rowCount、finishReason、usage |
| `ContentExpectations` | 返回内容或半结构化内容 | body、stdout、stderr、banner、response、output、result |
| `KeyedExpectations` | 动态键值断言 | headers、DB rows 列值 |
详细 checker 开发流程见 [Checker 开发](checker.md)。
## 错误模型
- API 错误:`{ error: "描述", status: <code> }`
- CheckFailure`{ kind: "error" | "mismatch", phase, path, expected?, actual?, message }`
expect 校验失败记录首个失败原因;网络、超时、进程崩溃统一为 `kind: "error"`
## 后端测试与验证
| 变更类型 | 测试重点 |
| ---------------------- | ---------------------------------------- |
| API 路由 | `tests/server/app.test.ts` 集成行为 |
| 配置 schema 或语义校验 | schema 导出、合法配置、非法配置 |
| store | SQLite 写入、查询、分页、聚合和清理 |
| engine | 调度、并发、超时、结果写入和状态变化日志 |
| expect 基础设施 | matcher 语义、快速失败、错误信息 |
| checker runner | 见 [Checker 开发](checker.md#测试要求) |
后端运行时代码统一通过注入的 Logger 输出日志,禁止直接使用 `console.*`。新增或修改后端逻辑通常需要运行 `bun run check`;影响构建产物或前后端集成时运行 `bun run verify`
## 更新触发条件
修改后端 API、共享类型、配置契约、store、engine、logger、expect 基础设施、错误模型或后端测试规范时,必须更新本文档。

221
docs/development/checker.md Normal file
View File

@@ -0,0 +1,221 @@
# Checker 开发
Checker 是 DiAL 的核心扩展单元。每个 checker 是 `src/server/checker/runner/<type>/` 下的自包含目录包含类型、schema、语义校验、执行逻辑、序列化和断言。
适用场景:新增 checker、修改 checker 配置或 expect、调整 checker 注册机制、改动 checker 测试或用户文档同步规则。
新增或修改 checker 前必须阅读 [开发入口](README.md)、[配置文件](../user/configuration.md)、[校验规则](../user/expectations.md) 和 [Checker 用户文档](../user/checkers/README.md)。还应阅读现有同类 checker 的实现和测试,例如 `src/server/checker/runner/http/``tests/server/checker/runner/http/`
## 设计原则
- 每个 checker 必须自包含在 `src/server/checker/runner/<type>/`
- checker 专属类型、schema、validate、execute、expect、normalize 和协议辅助逻辑放在同一目录。
- 注册只修改 `src/server/checker/runner/index.ts`,中间层不新增 type switch。
- schema 层只描述契约,语义规则放入 `validate.ts`
- `resolve()` 只做默认值填充、路径解析和单位转换,不执行校验。
- `execute()` 必须支持 `CheckerContext.signal` 超时取消。
- expect 字段必须选择合适断言模型,不为了统一而滥用 ValueMatcher。
- failure phase 命名遵循去单位后缀规则,例如 `durationMs` 对应 `duration`
## 架构目标
```text
checkerRegistry
├── runner/index.ts
├── schema/builder.ts
├── schema/validate.ts
├── config-loader.ts
├── engine.ts
└── store.ts
```
注册后,中间层通过 registry 自动委托 schema 生成、契约校验、配置 normalize、配置 resolve、执行和序列化。新增 checker 不应在中间层新增 `switch/case` 或类型分支。
## 标准文件结构
| 文件 | 职责 |
| -------------- | ------------------------------------------------------- |
| `index.ts` | 模块入口re-export Checker 类 |
| `types.ts` | Checker 专属类型 |
| `schema.ts` | TypeBox 契约 schema包含 config 和 expect |
| `validate.ts` | 启动期语义校验 |
| `normalize.ts` | Checker 专属 authoring expect 归一化 |
| `execute.ts` | Checker 类,实现 normalize、resolve、execute、serialize |
| `expect.ts` | Checker 专用断言函数 |
| 其他文件 | 协议解析、编码、provider 适配、平台命令封装等专属逻辑 |
## 类型定义
`types.ts` 中定义:
- `RawXxxTargetConfig`
- `RawXxxExpectConfig`
- `ResolvedXxxExpectConfig`
- `ResolvedXxxTarget extends ResolvedTargetBase`
不需要修改顶层 `checker/types.ts`。base interface 使用 index signature 支持扩展。
## Schema
checker 必须提供 `CheckerSchemas`,包含 Authoring 和 Normalized 两套 config/expect 片段。Authoring 描述用户 YAML 可写 DSLNormalized 描述 normalizer 输出。
常用 fragments
| Fragment | 用途 |
| ----------------------------------- | ------------------------- |
| `durationSchema` | 时长字符串 |
| `sizeSchema` | 大小单位 |
| `statusCodePatternSchema` | HTTP 状态码或范围 |
| `stringMapSchema` | headers、env 等字符串映射 |
| `createValueMatcherSchema()` | ValueMatcher |
| `createContentExpectationsSchema()` | ContentExpectations |
| `createKeyedExpectationsSchema()` | KeyedExpectations |
默认对象策略为 `additionalProperties: false`。只有明确的动态键值表可以开放任意键名。
## 语义校验
`validate.ts` 中实现 JSON Schema 无法表达的规则,统一返回 `ConfigValidationIssue[]`,不要直接拼接最终错误字符串。
共享校验工具包括:
| 函数 | 用途 |
| -------------------------------- | ---------------------------- |
| `validateRawValueExpectation` | 校验 Raw ValueExpectation |
| `validateRawContentExpectations` | 校验 ContentExpectations |
| `validateRawKeyedExpectations` | 校验 KeyedExpectations |
| `validateJsonPath` | 校验项目支持的 JSONPath 子集 |
| `isJsonValue` | 判断合法 JSON value |
## normalize 规范
`normalize()``CheckerDefinition` 中定义为必需方法,负责将 authoring expect DSL 转换为 normalized 形态。输入为变量已解析后的 target输出为适配 normalized schema 的 target。该方法在 `resolve()` 和 normalized contract 校验之前执行。
`normalize.ts` 中实现 `normalizeTargetExpect` 函数,`execute.ts` 中的 `normalize` 方法委托到该函数。
共享 normalize helper 位于 `src/server/checker/expect/normalize.ts`
| 函数 | 用途 |
| ------------------ | -------------------------------------------------------- |
| `compactExpect` | 合并两个 expect record过滤 undefined 字段 |
| `normalizeValue` | ValueMatcher 原始值简写展开为 `{equals: value}` |
| `normalizeContent` | ContentExpectations 简写展开为 normalized 形态 |
| `normalizeKeyed` | KeyedExpectations 对象形态展开为 `[{key, matcher}]` 数组 |
```typescript
import { compactExpect, normalizeContent, normalizeKeyed, normalizeValue } from "../../expect/normalize";
export function normalizeTargetExpect(target: RawTargetConfig): RawTargetConfig {
if (target.expect === undefined || !isPlainObject(target.expect)) return target;
const raw = target.expect as Record<string, unknown>;
return {
...target,
expect: compactExpect(raw, {
/* checker 专属字段映射 */
}),
};
}
```
expect 字段的归一化规则ValueMatcher 字段调用 `normalizeValue()`ContentExpectations 字段调用 `normalizeContent()`KeyedExpectations 字段调用 `normalizeKeyed()`boolean/enum/array 等非断言模型字段直接透传。
## resolve 规范
`resolve()` 只做内置默认值填充、路径解析、单位转换,不执行校验。输入已经通过 Normalized schema 和语义校验expect 已是 normalized 形态。
```typescript
const expect = target.expect as ResolvedXxxExpectConfig | undefined;
const resolvedExpect: ResolvedXxxExpectConfig = expect
? { ...expect, status: expect.status ?? [200] }
: { status: [200] };
```
返回值使用 `satisfies ResolvedXxxTarget` 确保类型正确。
## execute 规范
- 始终记录 `timestamp``start = performance.now()`
- 通过 `ctx.signal` 支持超时取消。
- 首个 expect 失败即停止,返回带 `failure` 的结果。
- 成功时 `failure: null, matched: true`
- 异常时使用 `errorFailure()`
- 不匹配时使用 `mismatchFailure()`
- `expected` 参数应传用户可读值,必要时使用 `displayValueExpectation()`
## expect 字段选择
| 场景 | 模型 |
| ------------------------------------ | ------------------- |
| 状态类结果且集合小而稳定 | enum 或 boolean |
| 单值数字指标或字符串元数据 | ValueMatcher |
| 文本、JSON、HTML、XML 或半结构化内容 | ContentExpectations |
| 动态键值表 | KeyedExpectations |
不要为了统一而把状态类字段改成 ValueMatcher。一个 expect 字段只能对应一种断言模型。
## 注册
1. 创建 `src/server/checker/runner/<type>/index.ts`
2.`src/server/checker/runner/index.ts` 添加导入。
3. 在 registry 初始化数组中添加 checker 实例。
注册后schema builder、validate、config-loader、engine、store 会自动按 registry 分发。
## 测试要求
测试文件放在 `tests/server/checker/runner/<type>/`,结构镜像源文件。
| 测试类别 | 覆盖内容 |
| -------------- | ---------------------------------------------------- |
| 契约测试 | TypeBox schema 与 JSON Schema 导出一致性 |
| 语义校验测试 | 合法和非法配置 |
| normalize 测试 | authoring expect 简写展开和 normalized contract 通过 |
| resolve 测试 | 默认值合并、路径解析、单位转换 |
| execute 测试 | 成功、失败、超时、expect 组合 |
| 注册测试 | registry 注册行为 |
| 配置加载测试 | 含新 checker 的 YAML 完整加载流程 |
## 文档和 schema 更新
新增或修改 checker 时通常需要更新:
- `probes.example.yaml`
- `probe-config.schema.json`,通过 `bun run schema` 生成
- `docs/user/checkers/<type>.md`
- `docs/user/checkers/README.md`
- `docs/user/expectations.md`,仅当断言模型、状态模型或通用规则变化
- `docs/user/configuration.md`,仅当 target 通用字段或配置加载形态变化
- `docs/development/checker.md`,仅当 checker 开发机制、测试要求或 checklist 变化
- `docs/README.md``openspec/config.yaml`,仅当文档同步规则变化
## 验证命令
新增或修改 checker 后通常需要运行:
```bash
bun run schema
bun run schema:check
bun run check
```
影响构建、Docker 或发布包时追加运行 `bun run verify`
## 完成检查清单
```text
□ checker 类型、schema、validate、normalize、resolve、execute、serialize 已实现
□ checker 已在 runner/index.ts 注册
□ 配置契约、语义校验和 JSON Schema 导出已同步
□ probes.example.yaml 已添加或更新示例
□ tests/server/checker/runner/<type>/ 已覆盖契约、校验、normalize、resolve、execute、注册和配置加载
□ docs/user/checkers/<type>.md 已添加或更新
□ docs/user/checkers/README.md 已添加或更新
□ 文档影响分析已完成,必要文档已同步
□ bun run schema 和 bun run schema:check 已通过
□ bun run check 已通过
□ bun run verify 已通过或记录未执行原因
```
## 更新触发条件
修改 checker 开发机制、目录结构、schema/validate/normalize/resolve/execute/expect 约定、测试要求、验证命令或文档同步 checklist 时,必须更新本文档。

View File

@@ -0,0 +1,130 @@
# 前端开发
本文档说明 DiAL Dashboard 的 React、TDesign、TanStack Query、组件、样式和前端测试约定。
适用场景:修改 `src/web/`、前端共享类型使用方式、Dashboard 数据流、组件结构、样式规则或前端测试。
## 技术栈
| 层面 | 技术 | 用途 |
| ------ | ------------------------------------- | ---------------------------------------------- |
| 框架 | React 19 | UI 组件开发 |
| 构建 | Bun HTML import + Vite dev server | 开发服务与生产构建 |
| 语言 | TypeScript 6 | 类型安全 |
| UI 库 | TDesign React + tdesign-icons-react | UI 组件与图标 |
| 数据层 | TanStack Query + React Query Devtools | 服务端状态管理与自动轮询 |
| 图表 | Recharts | 拨测趋势图 |
| 动画 | @number-flow/react | 倒计时数字滚动过渡 |
| 路由 | 无 | 单页面 Dashboard通过 Drawer/Tab 做页面内导航 |
不引入 React Router 或额外状态管理库。TanStack Query 承担服务端状态,组件内状态使用 `useState`
## 组件树与数据流
```text
main.tsx
└── StrictMode
└── ErrorBoundary
└── QueryClientProvider
├── App
│ ├── useThemePreference()
│ ├── useDashboard(refreshInterval)
│ ├── SummaryCards
│ └── TargetBoard
│ └── TargetGroup[]
│ └── PrimaryTable
│ └── TargetDetailDrawer
│ └── useTargetDetail()
│ ├── OverviewTab
│ └── HistoryTab
└── ReactQueryDevtools
```
## TanStack Query 规范
Query key 使用 structured array排序为 scope -> id -> 参数。
```typescript
const queryKeys = {
dashboard: () => ["dashboard", "24h", 30] as const,
meta: () => ["meta"] as const,
metrics: (targetId: number, from: string, to: string, bucket: "auto" | MetricsBucket) =>
["metrics", targetId, from, to, bucket] as const,
history: (targetId: number, from: string, to: string, page: number) => ["history", targetId, from, to, page] as const,
};
```
全局面板级查询可持续刷新,详情级查询必须按 Drawer 状态和 Tab 状态条件启用。
## fetch 封装
统一使用 `fetch`,不引入 axios。错误抛异常由 TanStack Query 的 `error` 状态承接。
```typescript
async function fetchJson<T>(url: string): Promise<T> {
const response = await fetch(url);
if (!response.ok) throw new Error(`HTTP ${response.status}`);
return response.json() as Promise<T>;
}
```
## 组件开发规范
- 每个 React 组件一个 `.tsx` 文件,文件名使用 PascalCase。
- 组件 props 定义为 `interface XxxProps`,紧邻组件函数声明。
- 类型从 `../../shared/api` 导入,使用 `import type`
- 展示组件放在 `components/`,通过 props 接收数据,通过回调返回事件。
- 容器逻辑放在 hooks 中,组件只做数据消费。
- 列定义、排序器、筛选器、颜色阈值等常量放在 `constants/`
- 时间处理等纯函数放在 `utils/`
## 现有组件
| 组件 | 用途 |
| -------------------- | ----------------------------------------------------------------- |
| `App` | 根组件Layout + HeadMenu 骨架、主题模式、刷新控制、Skeleton 加载 |
| `ErrorBoundary` | React 错误边界 |
| `SummaryCards` | 总览统计卡片 |
| `TargetBoard` | 按分组渲染目标表格列表 |
| `TargetGroup` | 单个分组 Card + PrimaryTable |
| `TargetDetailDrawer` | 目标详情抽屉 |
| `OverviewTab` | 目标详情概览 |
| `HistoryTab` | 目标历史记录表格和分页 |
| `TrendChart` | 趋势折线图 |
| `StatusDot` | 圆形状态指示点 |
| `StatusBar` | 最近采样状态条 |
| `RefreshCountdown` | Header 刷新倒计时和手动刷新按钮 |
## 样式规范
前端基于 TDesign React 构建 UI样式开发优先级
1. TDesign 组件
2. TDesign 组件 props
3. TDesign CSS tokens`--td-*`
4. `styles.css` CSS 类
5. 自行开发组件
红线:
- 严禁在组件中使用 `style` 属性内联调整样式。
- 严禁通过 CSS 覆盖 TDesign 组件内部类名。
- 严禁使用 `!important`
- 颜色统一使用 TDesign CSS tokens不使用硬编码色值。
## 前端测试与验证
- 测试目录为 `tests/web/`,结构对应 `src/web/`
- 单元测试重点覆盖 `constants/``utils/` 和 hooks 中的纯逻辑。
- 组件测试使用 jsdom 和 `@testing-library/react`
- 测试用户行为而非实现细节。
- 只 mock 系统边界,例如 `fetch`
- 使用真实的 QueryClientProvider 包裹依赖 TanStack Query 的组件。
- 异步错误断言使用 helper 或显式 try/catch避免依赖 Bun `expect(...).rejects``await-thenable` 规则的类型不匹配。
- 组件测试环境由 `tests/setup.ts``bunfig.toml` preload 提供,包含 ResizeObserver、IntersectionObserver、matchMedia、attachEvent 和 Recharts mock。
前端逻辑变更通常需要运行 `bun run check`。影响生产静态资源、前后端集成或构建流程时运行 `bun run verify`
## 更新触发条件
修改前端技术栈、组件边界、数据流、样式规则、测试环境或前端验证方式时,必须更新本文档。

127
docs/development/release.md Normal file
View File

@@ -0,0 +1,127 @@
# 构建与发布
本文档说明开发服务、前后端集成、生产构建、Docker 镜像、跨平台 release 和相关脚本维护方式。
适用场景:修改 `scripts/`、构建流程、Dockerfile、静态资源集成、release 打包、运行时环境变量或部署产物。
## 开发期运行
```bash
bun run dev probes.yaml
```
`scripts/dev.ts` 同时启动两个进程:
| 进程 | 用途 |
| --------------- | ------------------------------------------------- |
| Bun API server | 后端 API 服务,`--watch` 监听后端文件变更自动重启 |
| Vite dev server | 前端 SPA、HMR、模块热替换 |
也可以单独启动:
```bash
bun run dev:server probes.yaml
bun run dev:web
```
## 前后端集成
开发模式下Vite 通过 proxy 将 `/api/*``/health` 转发到 Bun。
生产模式下,前端通过 Vite 构建为静态资源,通过 `import with { type: "file" }` 嵌入 Bun 可执行文件。非 API 路径由 fetch fallback 处理:有文件扩展名的返回静态资源或 404无扩展名的返回 SPA index.html。
## 构建
```bash
bun run build
```
构建流程:
```text
1. Vite build -> dist/web/
2. Code generation -> .build/static-assets.ts + .build/server-entry.ts
3. Bun compile -> dist/dial-server
```
构建参数:
| 环境变量 | 说明 |
| -------------- | ---------------- |
| `BUN_TARGET` | 交叉编译目标平台 |
| `BUILD_TARGET` | 交叉编译目标平台 |
## Docker 镜像
Docker 镜像使用 Alpine 多阶段构建,保持与生产单可执行文件交付模型一致。
```text
oven/bun:1-alpine -> bun install --frozen-lockfile
-> BUN_TARGET=bun-linux-*-musl bun run build
-> dist/dial-server
alpine -> 仅复制 /usr/local/bin/dial-server
-> 安装 ca-certificates、iputils-ping、libgcc、libstdc++、tzdata
-> 使用非 root dial 用户运行
```
Dockerfile 通过 `TARGETARCH` 选择 Bun compile target。
| `TARGETARCH` | `BUN_TARGET` |
| ------------ | ---------------------- |
| `amd64` | `bun-linux-x64-musl` |
| `arm64` | `bun-linux-arm64-musl` |
## Release
```bash
bun run release
bun run release --target linux-x64
bun run release --target linux-x64,windows-x64,darwin-arm64
```
release 流程:
```text
1. Vite build -> dist/web/
2. Code generation -> .build/
3. 多目标 Bun compile -> dist/release/binaries/
4. tar.gz 打包 -> dist/release/packages/
```
支持的平台见 [用户部署文档](../user/deployment.md#跨平台发布包)。
## 脚本说明
| 脚本 | 文件 | 说明 |
| ---------------------- | ----------------------------------- | ------------------------------ |
| `bun run dev` | `scripts/dev.ts` | 双进程开发服务 |
| `bun run dev:server` | `src/server/dev.ts` | 仅启动后端 API server |
| `bun run dev:web` | Vite CLI | 仅启动 Vite dev server |
| `bun run build` | `scripts/build.ts` | Vite -> codegen -> Bun compile |
| `bun run release` | `scripts/release.ts` | 多目标交叉编译和打包 |
| `bun run schema` | `scripts/generate-config-schema.ts` | 生成配置 JSON Schema |
| `bun run schema:check` | `scripts/generate-config-schema.ts` | 检查配置 JSON Schema 同步 |
| `bun run clean` | `scripts/clean.ts` | 清理构建缓存与临时文件 |
## 维护约定
- `scripts/build-common.ts` 中的 import specifier 输出必须使用 `/` 分隔符。
- 跨平台路径测试不得用当前平台 `path.sep` 伪装其他平台,应使用 `node:path.win32` 或等价注入方式模拟。
- 如本地 Docker 环境不支持 buildx 或多架构模拟,需在变更记录中说明未执行原因。
## 发布验证
| 变更类型 | 验证方式 |
| ---------------- | --------------------------------------- |
| 构建脚本 | `bun run verify` |
| release 脚本 | `bun run release` 或指定受影响 target |
| Dockerfile | 本地 `docker build`,无法执行时说明原因 |
| 静态资源集成 | `bun run build`,必要时启动产物手动验证 |
| 配置 schema 同步 | `bun run schema:check` |
影响用户部署方式、Docker 运行参数、发布包内容或运行时依赖时,必须同步更新 [用户部署文档](../user/deployment.md)。
## 更新触发条件
修改开发服务、前后端集成、构建产物、Docker 镜像、release target、脚本参数或发布验证方式时必须更新本文档。

View File

@@ -7,7 +7,6 @@
| 文件 | 用途 |
| ------------------------------------------------------ | ------------------------------------------------------------------------ |
| [prompt-smart-merge.md](prompt-smart-merge.md) | 批量合并 `dev*` 分支到目标分支,含规则探测、依赖分析、冲突处理、安全回退 |
| [prompt-spec-review.md](prompt-spec-review.md) | 审查和整理 `openspec/specs/` 下的稳定规范,提升可检索性和一致性 |
| [prompt-proposal-review.md](prompt-proposal-review.md) | 审查 proposal/design/tasks/specs 与讨论、代码现状、OpenSpec 规范的一致性 |
| [prompt-apply-review.md](prompt-apply-review.md) | 审查 apply 后代码、测试、变更文档的一致性,并补齐遗漏或回写文档 |
@@ -85,7 +84,7 @@
- 是否以代码、文档、讨论或用户确认为准
- 何时必须使用提问工具确认
- 删除、重写前是否必须备份
- 改动后是否必须同步 README、测试、变更文档
- 改动后是否必须同步相关用户文档、开发文档、测试、变更文档
### 4. 计划与执行分离
@@ -124,7 +123,7 @@
- 作用域边界:改什么,不改什么
- 真相来源优先级:代码 / README / spec / 讨论 / 用户确认
- 风险动作边界删除、重写、提交、推送、回退、stash、merge 等
- 同步要求:测试、README、变更文档、现有 spec 是否要同步
- 同步要求:测试、用户文档、开发文档、变更文档、现有 spec 是否要同步
- 降级规则:信息不足时如何处理
避免:
@@ -142,7 +141,7 @@
推荐做法:
- 先读仓库规则来源,如 `README.md`、配置、架构文档、近期提交、任务入口
- 先读仓库规则来源,如 `README.md``DEVELOPMENT.md``CONTRIBUTING.md``docs/README.md`配置、架构文档、近期提交、任务入口
- 先读直接相关 artifacts再扩展到相关代码和测试
- 需要探测时,要求 AI 先探测再决定,不把仓库结构写死在提示词里

View File

@@ -1,143 +0,0 @@
请审查并整理 `openspec/specs/` 下的稳定规范,使其成为可搜索、边界清晰、无冗余、与当前业务一致的能力索引,按以下流程执行。
## 约束
- `openspec/specs/` 描述长期稳定的业务能力、规则和外部行为,不记录变更过程、迁移说明、实现路径、内部类型名、组件 props、样式数值、层级分层等实现细节
- 用户可感知或对外暴露的契约可以保留:公开 API 路径、请求/响应字段、协议名、错误码、数据约束、交互结果
- `Requirement``Scenario` 应描述业务能力、外部行为或稳定约束,不以“使用某层/某组件/某库实现”作为标题或核心表述
- 不把当前代码自动视为唯一真相若代码、README、现有 spec 冲突且无法判断应以哪边为准,列入待确认清单,不直接改写规范
- 仅删除内容已被其他规范完整覆盖且无独立检索价值的规范;非冗余内容仅迁移、合并、拆分或重命名
- 每批重构执行前用提问工具获得用户确认;删除或重写前先备份原文件为 `{file}.bak.{timestamp}`
- 命名、Purpose、Requirement 标题都必须保留用户下一次最可能搜索的业务关键词
- 使用subagents处理计算密集或多步骤的并行任务如代码实现、测试执行文件读取直接使用Read工具并行调用禁止用subagent转发文件内容
## 1. 收集
读取:
- `openspec/config.yaml`
- `README.md`以及与模块结构、API、架构相关的 README 或文档
- `openspec/specs/*/spec.md`
默认不读取 `openspec/changes/**`、历史 proposal/design/tasks 作为稳定规范整理依据;仅在用户明确要求“连同历史变更一起校对”时再纳入。
先建立索引,不直接开始改写:
| 索引 | 内容 |
| -------------- | ----------------------------------------------------------------------------- |
| `spec_index[]` | 每个 spec 的目录名、Purpose、Requirement 摘要、关键词、外部契约、疑似重叠对象 |
| `domain_map[]` | 从 README、API、模块文档中提炼的核心业务域、横切能力和术语 |
| `term_map[]` | 同义词、旧名、缩写和推荐标准术语 |
| `suspects[]` | 需要进一步对照代码或测试确认的 spec |
仅对 `suspects[]` 做定向读取:
- 读取与该 spec 对应的源码、测试、README 或架构文档
- 不对 `backend/``frontend/` 做无差别逐文件扫描
判定依据优先级:
- 当前稳定 spec 与 README 共同支持的事实,可直接视为高置信度
- 仅代码可见但 README 和 spec 未体现的内容,先判断它是稳定外部行为还是临时实现细节
- 代码、README、现有 spec 互相冲突且无法自动定夺时,进入 `待确认清单`
## 2. 审查
按 spec、Requirement、Scenario 三层检查:
| 维度 | 检查点 |
| --------- | --------------------------------------------------------------------------------- |
| 过时 | 描述的能力、术语、外部契约是否仍成立;是否存在 `TBD``TODO`、占位说明 |
| 冲突 | 不同规范是否对同一行为给出不同约束、命名或边界 |
| 重复/重叠 | 是否在文件级、Requirement 级、Scenario 级重复描述同一能力 |
| 错位 | 内容是否放错能力域;横切规则是否混入实体规范;平台实现是否混入通用能力规范 |
| 粒度 | 是否过大导致难检索,或过碎导致回答一个问题必须同时打开多个 spec |
| 术语 | 同一概念是否混用多个名字;旧名、别名、缩写是否需要归一并保留检索入口 |
| 命名/检索 | 目录名、Purpose、Requirement 标题是否准确;是否能被 README、API、业务术语直接命中 |
| 规范性 | 是否使用 SHALL/WHEN/THEN是否混入变更记录、迁移说明、内部实现或 UI/代码细节 |
| 完整性 | Purpose 是否明确;是否存在空目录、非 spec 噪音文件、无清晰归属的孤立规范 |
重构判定规则:
- 若两个 spec 回答的是同一个核心问题,或其中一个只是另一个的子集,优先合并
- 若一个 spec 混合多个独立检索意图,或同时包含横切规则与业务流程,优先拆分
- 若内容正确但目录名、Purpose 或 Requirement 标题不利于检索,优先重命名或改写标题
- 若多个术语指向同一概念,统一到一个标准术语,并在 Purpose 或 Requirement 中保留必要别名以支持搜索
- 若某段内容只是内部实现细节,且不影响外部行为理解,删除该段而不是为其单独保留 spec
- 若某个具体值同时属于外部契约与内部实现,按“是否对调用方可见、是否影响兼容性”判断是否保留
### 命名约定
命名优先复用仓库已存在的稳定术语,如 `provider``model``stats``protocol``proxy``logging``validation``migration``frontend``desktop``mysql`
| 类型 | 模式 | 示例 |
| ------------ | ---------------------------------------------------------- | -------------------------------------------------- |
| 实体生命周期 | `{entity}-management` | `provider-management``model-management` |
| 横切能力 | `{concern}``{concern}-{qualifier}` | `error-handling``structured-logging` |
| 协议/适配 | `{protocol}-{capability}``protocol-adapter-{protocol}` | `openai-protocol-proxy``protocol-adapter-openai` |
| 运行面/入口 | `{surface}``{surface}-{capability}` | `frontend``desktop-app` |
| 基础设施 | `{resource}-{operation}` | `database-migration``mysql-driver` |
命名原则:
- 1-4 个词,保持单一主题
- 优先使用业务名词,不使用 `basic``general``misc``info``data` 等泛化词
- 不使用 `crud``list``table``display` 等实现模式词,除非它本身就是外部契约的一部分
- 同一主题的命名模式保持一致,不同时混用多套前后缀
## 3. 报告
输出分析结果:
1. **问题总览表**:问题类型 × 涉及规范数
2. **规范关系表**:每个 spec 的主主题、重叠对象、冲突对象、建议动作
3. **术语归一表**:旧术语 / 别名 / 缩写 → 推荐标准术语
4. **逐项分析**:每个有问题的规范说明位置、问题、影响、建议和目标规范
5. **待确认清单**代码、README、现有 spec 冲突且无法自动定夺的事项
6. **重构方案**:按优先级分批
7. **重构后目录结构**:预期的新 `openspec/specs/` 目录树
优先级建议:
- P0删除空目录、非 spec 噪音文件、占位内容
- P1删除完全冗余规范将其内容映射到主规范
- P2合并重复/子集规范;拆分错位或过大规范
- P3重命名目录、改写 Purpose 和 Requirement 标题以提升检索性
- P4修正过时描述清理实现细节、迁移说明和变更记录
若所有问题清单为空,输出“审查通过,未发现问题”,跳至步骤 5。
## 4. 计划(用户确认)
先针对 `待确认清单` 用提问工具逐项向用户确认。
再按批次展示完整重构计划,每批必须包含:
- 操作类型:删除、重命名、迁移、合并、拆分、改写
- 路径变化:源路径 → 目标路径
- 内容映射:源 spec 的 Requirement / Scenario 将迁移到哪里
- 术语处理:哪些旧词保留为检索入口,哪些词统一替换
- 执行原因:为什么这样做更利于检索、去重和边界清晰
- 验证方式:如何确认没有丢失约束或引入新的冲突
用提问工具获得当前批次确认后再执行。
## 5. 执行
按 P0 → P4 逐批执行已确认的重构。
执行要求:
- 合并或拆分时先写目标 spec再删除或重命名源 spec
- 删除前确认其 Requirement 和 Scenario 已被完整保留、迁移或判定为纯冗余
- 每批执行后重新读取受影响的 spec并复核结构和内容
每批执行后至少验证:
- 目录结构完整,`openspec/specs/*/spec.md` 可正常读取
- 不存在未承接的 Requirement 或 Scenario
- Purpose、Requirement 标题、目录名可以直接表达主能力
- 不再包含 `TBD`、变更记录、迁移说明、内部实现细节或噪音文件
- 若本批涉及代码对照项,相关外部契约描述与当前仓库现状一致,或已列入残留待确认
收尾时输出:修改文件清单、备份文件清单、最终目录树、残留待确认事项和整理摘要。

35
docs/user/README.md Normal file
View File

@@ -0,0 +1,35 @@
# 用户文档
本文档是 DiAL 的用户使用入口说明如何阅读配置、部署、expect 规则、故障排查和各 checker 参考。
适用场景:编写 YAML 配置、部署 DiAL、理解拨测结果、排查运行问题、查询某个 checker 的字段和示例。
## 文档索引
| 文档 | 内容 |
| ---------------------------------------- | ------------------------------------------------- |
| [configuration.md](configuration.md) | YAML 顶层结构、变量、server、targets 通用字段 |
| [deployment.md](deployment.md) | 生产构建、Docker、ICMP 权限、发布包运行方式 |
| [expectations.md](expectations.md) | expect 规则、状态判定、failure、observation |
| [troubleshooting.md](troubleshooting.md) | 配置校验、变量、ICMP、CMD、Docker、证书和正则问题 |
| [checkers/README.md](checkers/README.md) | 各 checker 的配置项、expect 字段和示例 |
## 按任务阅读
| 任务 | 建议阅读 |
| --------------------- | ---------------------------------------------------------------------- |
| 首次运行 | [项目快速开始](../../README.md#快速开始)、[配置文件](configuration.md) |
| 编写配置 | [配置文件](configuration.md)、[Checker 参考](checkers/README.md) |
| 编写 expect | [校验规则](expectations.md)、对应 checker 文档 |
| 容器或生产部署 | [部署](deployment.md)、[故障排查](troubleshooting.md) |
| 排查启动或运行问题 | [故障排查](troubleshooting.md)、相关 checker 文档 |
| 查询 checker 专属字段 | [Checker 参考](checkers/README.md) |
## 用户文档更新规则
- 配置结构、变量、server、probes、targets 通用字段变化时,更新 [configuration.md](configuration.md)。
- checker 配置项、expect 字段、示例或运行行为变化时,更新 `checkers/<type>.md` 和 [checkers/README.md](checkers/README.md)。
- expect 模型、状态判定、failure、observation 或快速失败顺序变化时,更新 [expectations.md](expectations.md)。
- 构建产物运行方式、Docker 参数、镜像内置依赖、发布包结构变化时,更新 [deployment.md](deployment.md)。
- 常见错误、运行依赖、权限、证书或配置校验排查方式变化时,更新 [troubleshooting.md](troubleshooting.md)。
- 用户文档只解释“如何使用”和“用户能观察到什么”,实现细节放入 [`../development/`](../development/README.md)。

View File

@@ -0,0 +1,49 @@
# Checker 参考
Checker 是 DiAL 的拨测执行单元。每个 target 通过 `type` 选择一个 checker并配置对应的专属字段和 `expect` 规则。
适用场景:查询 checker 类型选择、专属配置、expect 字段、示例和各 checker 文档入口。
## 支持的类型
| 类型 | 用途 | 文档 |
| -------- | -------------------------------------- | ------------------- |
| `http` | HTTP/HTTPS 应用层健康检查 | [HTTP](http.md) |
| `cmd` | 执行本地命令或脚本 | [Cmd](cmd.md) |
| `db` | PostgreSQL/MySQL/SQLite 连接和查询检查 | [DB](db.md) |
| `tcp` | TCP 端口可达性和 banner 探测 | [TCP](tcp.md) |
| `udp` | UDP payload 请求-响应检查 | [UDP](udp.md) |
| `icmp` | 基于系统 `ping` 的存活、延迟、丢包检查 | [ICMP](icmp.md) |
| `dns` | 本机解析或指定 DNS server 协议级检查 | [DNS](dns.md) |
| `llm` | 大模型服务应用层健康检查 | [LLM](llm.md) |
| `ws` | WebSocket 可达性和消息交互检查 | [WS](ws.md) |
| `cpu` | 本机 CPU 使用率健康检查 | [CPU](cpu.md) |
| `memory` | 本机系统内存使用状况检查 | [Memory](memory.md) |
## 选择建议
| 目标 | 推荐 checker |
| ---------------------------------- | ------------ |
| Web API、网页、HTTP 状态码或响应体 | `http` |
| 本机脚本、外部命令、CLI 工具 | `cmd` |
| 数据库连接或查询结果 | `db` |
| 端口是否可连接、服务 banner | `tcp` |
| UDP 服务响应或简单心跳 | `udp` |
| 主机可达性、延迟、丢包率 | `icmp` |
| 域名解析值、DNS RCODE、TTL、flags | `dns` |
| LLM API 是否可用、输出是否符合预期 | `llm` |
| WebSocket 可达性或消息交互验证 | `ws` |
| 本机 CPU 使用率健康检查 | `cpu` |
| 本机系统内存使用状况检查 | `memory` |
## 通用字段
所有 checker 都共享 target 通用字段,见 [配置文件](../configuration.md#targets-通用字段)。
## 通用断言模型
各 checker 的 `expect` 字段复用 `ValueMatcher``ContentExpectations``KeyedExpectations`。详情见 [校验规则](../expectations.md)。
## 更新触发条件
新增、移除或修改 checker 类型、用途、选择建议、通用字段或通用断言模型时必须更新本文档。checker 专属字段变化还必须同步更新对应 `checkers/<type>.md`

38
docs/user/checkers/cmd.md Normal file
View File

@@ -0,0 +1,38 @@
# Cmd Checker
`type: cmd` 用于执行本地命令或脚本并校验退出码、stdout、stderr 和耗时。
## 配置项
| 字段 | 说明 | 必填 | 默认值 |
| ---------- | ------------------------------------ | ---- | ------ |
| `cmd.exec` | 可执行文件名或路径 | 是 | 无 |
| `cmd.args` | 命令行参数列表 | 否 | `[]` |
| `cmd.env` | 环境变量覆盖,继承进程环境变量并合并 | 否 | 无 |
| `cmd.cwd` | 工作目录,相对于配置文件所在目录 | 否 | 无 |
## expect 校验项
| 字段 | 说明 | 必填 | 默认值 |
| ------------ | --------------------------------------------- | ---- | ------ |
| `exitCode` | 可接受的退出码列表 | 否 | `[0]` |
| `stdout` | 标准输出校验,使用 `ContentExpectations` 数组 | 否 | 无 |
| `stderr` | 标准错误校验,使用 `ContentExpectations` 数组 | 否 | 无 |
| `durationMs` | 完整执行耗时校验,使用 `ValueMatcher` | 否 | 无 |
## 示例
```yaml
- id: "bun-script"
name: "Bun 脚本检查"
type: cmd
cmd:
exec: "bun"
args: ["-e", "console.log('ok')"]
expect:
exitCode: [0]
stdout:
- contains: "ok"
```
Docker 官方镜像不内置常见外部命令。容器内使用 CMD checker 时,按需通过派生镜像安装依赖命令。

74
docs/user/checkers/cpu.md Normal file
View File

@@ -0,0 +1,74 @@
# CPU Checker
`type: cpu` 用于检查本机 CPU 使用率,基于两次系统快照计算总体和每核心的忙碌比例。
## 配置项
| 字段 | 说明 | 必填 | 默认值 |
| -------------------- | -------------------------------- | ---- | ------- |
| `cpu.sampleDuration` | CPU 采样窗口,支持时长格式 | 否 | `1s` |
| `cpu.includePerCore` | 是否在结果中输出每核心使用率数组 | 否 | `false` |
`sampleDuration` 必须小于 target 的 `timeout`
## expect 校验项
| 字段 | 说明 | 必填 | 默认值 |
| --------------------- | ----------------------------------------------------------------------------------------------- | ---- | ------ |
| `usagePercent` | 总体 CPU 使用率,范围 `0-100`,使用 `ValueMatcher` | 否 | 无 |
| `idlePercent` | 总体 CPU 空闲率,与 `usagePercent` 互补,两者之和恒为 100`idlePercent + usagePercent = 100` | 否 | 无 |
| `maxCoreUsagePercent` | 单核心最高使用率,使用 `ValueMatcher` | 否 | 无 |
| `minCoreUsagePercent` | 单核心最低使用率,使用 `ValueMatcher` | 否 | 无 |
| `durationMs` | 完整执行耗时校验,使用 `ValueMatcher` | 否 | 无 |
所有百分比字段范围为 `0-100`,表示所有可见逻辑 CPU 的总体比例,不是"核心数 × 100"。
## 示例
```yaml
- id: "local-cpu"
name: "本机 CPU"
type: cpu
interval: "30s"
timeout: "5s"
cpu:
sampleDuration: "1s"
expect:
usagePercent:
lte: 85
maxCoreUsagePercent:
lte: 95
```
输出每核心使用率:
```yaml
- id: "local-cpu-detail"
name: "本机 CPU 详细"
type: cpu
cpu:
sampleDuration: "2s"
includePerCore: true
expect:
usagePercent:
lte: 80
```
## 语义说明
CPU checker 采集的是 DiAL 进程运行环境通过系统 API`os.cpus()`)可见的 CPU 视图。在容器中,它可能不等于严格的 cgroup quota 使用率。
`usagePercent``idlePercent` 互补,恒等于 100。`sampleDuration` 决定了两次快照之间的等待时间,窗口越长结果越稳定,但会增加 checker 执行耗时。
## 不支持的功能
- CPU 温度、电源状态、频率
- `userPercent` / `systemPercent`(用户态/系统态占比)
- `loadAverage`(系统负载均值)
- 进程级 CPU 使用率
- Linux cgroup 精确 CPU 计算
- `logicalCoreCount` 作为 expect 字段(仅在 observation 中输出)
## 更新触发条件
修改 CPU checker 配置、expect 字段、行为或语义时,必须更新本文档。

38
docs/user/checkers/db.md Normal file
View File

@@ -0,0 +1,38 @@
# DB Checker
`type: db` 用于数据库连接和查询结果检查,支持 PostgreSQL、MySQL 和 SQLite。
## 配置项
| 字段 | 说明 | 必填 | 默认值 |
| ---------- | ------------------------------------------------------------- | ---- | ------ |
| `db.url` | 数据库连接字符串,支持 `postgres://``mysql://``sqlite://` | 是 | 无 |
| `db.query` | SQL 查询语句,不配置时仅测试连接 | 否 | 无 |
## expect 校验项
| 字段 | 说明 | 必填 | 默认值 |
| ------------ | ----------------------------------------------------------------------- | ---- | ------ |
| `rowCount` | 查询返回行数校验,使用 `ValueMatcher` | 否 | 无 |
| `rows` | 查询结果逐行校验,数组内每行为列名到 `KeyedExpectations` 的映射 | 否 | 无 |
| `result` | 完整查询结果 `{ rows, rowCount }` 校验,使用 `ContentExpectations` 数组 | 否 | 无 |
| `durationMs` | 完整执行耗时校验,使用 `ValueMatcher` | 否 | 无 |
## 示例
```yaml
- id: "sqlite-query"
name: "SQLite 数据库检查"
type: db
db:
url: "sqlite:///path/to/db.sqlite"
query: "SELECT COUNT(*) as cnt FROM users WHERE status = 'active'"
expect:
durationMs:
lte: 5000
rowCount:
gte: 1
rows:
- cnt:
gte: 0
```

104
docs/user/checkers/dns.md Normal file
View File

@@ -0,0 +1,104 @@
# DNS Checker
`type: dns` 支持两种解析模式:本机解析器检查和指定 DNS server 协议级检查。
## resolver 模式
| 模式 | 说明 |
| -------- | ----------------------------------------------------------------------------- |
| `system` | 使用本机 DNS 解析器检查域名是否能解析到预期地址 |
| `server` | 直接向指定 DNS server 发起 UDP/TCP 深度拨测,检查 RCODE、TTL、flags、记录值等 |
## `dns.resolver: system` 配置项
| 字段 | 说明 | 必填 | 默认值 |
| -------------- | ----------------------------- | ---- | -------- |
| `dns.resolver` | 解析模式 | 是 | `system` |
| `dns.name` | 待解析域名 | 是 | 无 |
| `dns.family` | 地址族:`any``ipv4``ipv6` | 否 | `any` |
### system 模式 expect
| 字段 | 说明 | 断言模型 |
| ------------ | -------------------- | --------------------------------- |
| `values` | 解析结果地址集合断言 | DNS 集合include/exclude/exact |
| `valueCount` | 解析结果数量 | ValueMatcher |
| `durationMs` | 解析耗时 | ValueMatcher |
```yaml
- id: "dns-system-api"
name: "本机 DNS 解析"
type: dns
dns:
resolver: system
name: "api.example.com"
family: any
expect:
values:
exact:
- "203.0.113.10"
durationMs:
lte: 500
```
## `dns.resolver: server` 配置项
| 字段 | 说明 | 必填 | 默认值 |
| ---------------------- | --------------------------------- | ---- | -------- |
| `dns.resolver` | 解析模式 | 是 | `server` |
| `dns.server` | DNS server 地址 | 是 | 无 |
| `dns.name` | 查询域名 | 是 | 无 |
| `dns.port` | DNS server 端口 | 否 | `53` |
| `dns.protocol` | 传输协议:`udp``tcp` | 否 | `udp` |
| `dns.recordType` | DNS 记录类型 | 否 | `A` |
| `dns.recursionDesired` | 是否设置 RD flag | 否 | `true` |
| `dns.tcpFallback` | UDP 响应 TC=1 时是否 TCP fallback | 否 | `true` |
| `dns.maxResponseBytes` | 响应最大字节数 | 否 | `4KB` |
`recordType` 可选值:`A``AAAA``CNAME``NS``MX``TXT``SOA``SRV``CAA``PTR`
### server 模式 expect
| 字段 | 说明 | 断言模型 |
| -------------------- | ---------------------------------- | --------------------------------- |
| `responded` | 是否收到 DNS response | boolean |
| `rcode` | 期望 RCODE 列表,如 `NOERROR` | string[] |
| `values` | 目标类型记录值集合断言 | DNS 集合include/exclude/exact |
| `valueCount` | 目标类型记录数量 | ValueMatcher |
| `answerCount` | answer section 总记录数 | ValueMatcher |
| `ttlMin` | answer 中最小 TTL | ValueMatcher |
| `ttlMax` | answer 中最大 TTL | ValueMatcher |
| `authoritative` | AA flag | boolean |
| `recursionAvailable` | RA flag | boolean |
| `truncated` | TC flag | boolean |
| `authenticatedData` | AD flag | boolean |
| `result` | 完整结构化响应的 JSONPath 兜底断言 | ContentExpectations |
| `durationMs` | 完整查询耗时 | ValueMatcher |
```yaml
- id: "dns-server-api"
name: "Cloudflare DNS A 记录"
type: dns
dns:
resolver: server
server: "1.1.1.1"
name: "api.example.com"
recordType: A
expect:
rcode: ["NOERROR"]
values:
include:
- "203.0.113.10"
ttlMin:
gte: 60
durationMs:
lte: 200
```
## 注意事项
- 未配置 expect 时,`system` 模式默认要求解析成功且 `valueCount > 0``server` 模式默认要求 `NOERROR + valueCount > 0`
- 显式配置非 `NOERROR` rcode`NXDOMAIN`)时,不自动要求 `valueCount > 0`
- `values.exact` 忽略返回顺序。
- 对 A/AAAA 查询CNAME 链不计入 `values`,单独放入 `cnameChain`
- `values` 按记录类型规范化为字符串,例如 MX 为 `"10 mail.example.com"`SRV 为 `"10 60 443 server.example.com"`

View File

@@ -0,0 +1,48 @@
# HTTP Checker
`type: http` 用于 HTTP/HTTPS 应用层健康检查。
## 配置项
| 字段 | 说明 | 必填 | 默认值 |
| ------------------- | ------------------- | ---- | ------- |
| `http.url` | 目标 URL | 是 | 无 |
| `http.method` | HTTP 方法 | 否 | `GET` |
| `http.headers` | 请求头 | 否 | 无 |
| `http.body` | 请求体 | 否 | 无 |
| `http.ignoreSSL` | 忽略 HTTPS 证书校验 | 否 | `false` |
| `http.maxRedirects` | 最大重定向跟随次数 | 否 | `0` |
## expect 校验项
| 字段 | 说明 | 必填 | 默认值 |
| ------------ | -------------------------------------------------- | ---- | ------- |
| `status` | 可接受的状态码列表,支持精确码和范围(如 `"2xx"` | 否 | `[200]` |
| `headers` | 响应头校验,使用 `KeyedExpectations` | 否 | 无 |
| `body` | 响应体校验,使用 `ContentExpectations` 数组 | 否 | 无 |
| `durationMs` | 完整执行耗时校验,使用 `ValueMatcher` | 否 | 无 |
## 示例
```yaml
- id: "json-api"
name: "JSON API 示例"
type: http
http:
url: "https://httpbin.org/json"
headers:
Authorization: "Bearer token"
expect:
status: [200]
headers:
Content-Type:
contains: "application/json"
body:
- json:
path: "$.slideshow.title"
equals: "Sample Slide Show"
durationMs:
lte: 10000
```
HTTP checker 的 `durationMs` 覆盖完整执行,包括重定向、按需响应体读取、解码和 expect 校验。未配置 body expectation、status 失败或 headers 失败时不会读取 body。

View File

@@ -0,0 +1,45 @@
# ICMP Checker
`type: icmp` 使用系统 `ping` 命令执行 ICMP 探测,支持 Linux、macOS 和 Windows 输出解析。
## 配置项
| 字段 | 说明 | 必填 | 默认值 |
| ----------------- | ------------------------- | ---- | ------ |
| `icmp.host` | 目标主机地址 | 是 | 无 |
| `icmp.count` | ICMP 包数量,范围 `1-100` | 否 | `3` |
| `icmp.packetSize` | ICMP 包大小bytes | 否 | `56` |
## expect 校验项
| 字段 | 说明 | 必填 | 默认值 |
| ------------------- | --------------------------------------------------- | ---- | ------ |
| `alive` | 期望主机可达性 | 否 | `true` |
| `packetLossPercent` | 丢包率百分比校验,范围 `0-100`,使用 `ValueMatcher` | 否 | 无 |
| `avgLatencyMs` | 平均延迟校验,使用 `ValueMatcher` | 否 | 无 |
| `maxLatencyMs` | 最大单次延迟校验,使用 `ValueMatcher` | 否 | 无 |
| `durationMs` | 完整执行耗时校验,使用 `ValueMatcher` | 否 | 无 |
## 示例
```yaml
- id: "gateway-icmp"
name: "网关 ICMP 可达"
type: icmp
icmp:
host: "10.0.0.1"
count: 3
packetSize: 56
expect:
alive: true
packetLossPercent:
lte: 10
avgLatencyMs:
lte: 100
maxLatencyMs:
lte: 300
durationMs:
lte: 5000
```
容器中运行 ICMP checker 通常需要 `--cap-add=NET_RAW`,详情见 [部署文档](../deployment.md#icmp-权限)。

53
docs/user/checkers/llm.md Normal file
View File

@@ -0,0 +1,53 @@
# LLM Checker
`type: llm` 用于大模型服务应用层健康检查。
## 配置项
| 字段 | 说明 | 必填 | 默认值 |
| --------------------- | ----------------------------------------------------- | ---- | ------- |
| `llm.provider` | 模型提供方:`openai``openai-responses``anthropic` | 是 | 无 |
| `llm.url` | API base URL | 是 | 无 |
| `llm.model` | 模型名称 | 是 | 无 |
| `llm.prompt` | 单轮 prompt | 是 | 无 |
| `llm.mode` | 调用模式:`http``stream` | 否 | `http` |
| `llm.key` | API key支持 `${VAR}` 变量替换 | 否 | `""` |
| `llm.authToken` | Bearer token`anthropic` provider`key` 互斥 | 否 | 无 |
| `llm.headers` | 附加请求头 | 否 | 无 |
| `llm.ignoreSSL` | 忽略 HTTPS 证书校验 | 否 | `false` |
| `llm.options` | 生成选项 | 否 | 无 |
| `llm.providerOptions` | Provider 专属选项 | 否 | 无 |
`llm.options` 支持 `maxOutputTokens`(默认 `16`)、`temperature`(默认 `0`)、`topP``topK``presencePenalty``frequencyPenalty``stopSequences``seed`
## expect 校验项
| 字段 | 说明 | 必填 | 默认值 |
| ----------------- | --------------------------------------------------------------------------- | ---- | ------- |
| `status` | 可接受的状态码列表,支持精确码和范围(如 `"2xx"` | 否 | `[200]` |
| `headers` | 响应头校验,使用 `KeyedExpectations` | 否 | 无 |
| `output` | 模型输出校验,使用 `ContentExpectations` 数组 | 否 | 无 |
| `finishReason` | finish reason 校验,使用 `ValueMatcher` | 否 | 无 |
| `rawFinishReason` | 原始 finish reason 校验,使用 `ValueMatcher` | 否 | 无 |
| `usage` | Token usage 校验,支持 `inputTokens``outputTokens``totalTokens` matcher | 否 | 无 |
| `stream` | 流式断言,支持 `completed``firstTokenMs` matcher`mode: stream` | 否 | 无 |
| `durationMs` | 完整执行耗时校验,使用 `ValueMatcher` | 否 | 无 |
## 示例
```yaml
- id: "llm-openai-probe"
name: "OpenAI 健康检查"
type: llm
llm:
provider: openai
url: "https://api.openai.com/v1"
model: "gpt-4o-mini"
prompt: "Say OK"
key: "${OPENAI_API_KEY}"
expect:
status: [200]
finishReason: "stop"
output:
- contains: "OK"
```

119
docs/user/checkers/mem.md Normal file
View File

@@ -0,0 +1,119 @@
# Mem Checker
`type: mem` 用于检查本机系统级内存使用状况,包括物理内存和交换空间的使用率及字节数。
## 配置项
Mem checker 配置为空对象,无需额外参数:
```yaml
mem: {}
```
## expect 校验项
### 百分比字段
| 字段 | 说明 | 必填 | 默认值 |
| ------------------ | -------------------------------------------------------------------------- | ---- | ------ |
| `usagePercent` | 真实内存使用率 = `activeBytes / totalBytes × 100`,不含 buffers/cache 假象 | 否 | 无 |
| `usedPercent` | 原始已用百分比 = `usedBytes / totalBytes × 100`,包含 buffers/cache | 否 | 无 |
| `freePercent` | 空闲百分比 = `freeBytes / totalBytes × 100` | 否 | 无 |
| `activePercent` | 活跃内存百分比 = `activeBytes / totalBytes × 100` | 否 | 无 |
| `availablePercent` | 可用内存百分比 = `availableBytes / totalBytes × 100` | 否 | 无 |
| `swapUsagePercent` | 交换空间使用率,当系统无交换分区时为 `null` | 否 | 无 |
所有百分比字段范围为 `0-100`,使用 `ValueMatcher`
### 字节字段
| 字段 | 说明 | 必填 | 默认值 |
| ---------------- | ----------------------------------------- | ---- | ------ |
| `activeBytes` | 活跃内存字节数 | 否 | 无 |
| `usedBytes` | 已用内存字节数(含 buffers/cache | 否 | 无 |
| `freeBytes` | 空闲内存字节数 | 否 | 无 |
| `availableBytes` | 可用内存字节数 | 否 | 无 |
| `totalBytes` | 物理内存总字节数 | 否 | 无 |
| `swapUsedBytes` | 交换空间已用字节数,无交换分区时为 `null` | 否 | 无 |
| `swapFreeBytes` | 交换空间空闲字节数,无交换分区时为 `null` | 否 | 无 |
| `swapTotalBytes` | 交换空间总字节数,无交换分区时为 `0` | 否 | 无 |
| `buffcacheBytes` | 缓冲缓存字节数,部分平台可能为 `null` | 否 | 无 |
字节字段支持数字(字节数)或大小字符串(如 `"512MB"``"1GB"`),使用 `ValueMatcher`
### 通用字段
| 字段 | 说明 | 必填 | 默认值 |
| ------------ | ------------------------------------- | ---- | ------ |
| `durationMs` | 完整执行耗时校验,使用 `ValueMatcher` | 否 | 无 |
## 示例
检查内存使用率不超过 85%
```yaml
- id: "local-memory"
name: "本机内存"
type: mem
interval: "30s"
timeout: "5s"
mem: {}
expect:
usagePercent:
lte: 85
```
检查可用内存不低于 4GB
```yaml
- id: "local-memory-available"
name: "可用内存检查"
type: mem
mem: {}
expect:
availableBytes:
gte: "4GB"
```
同时检查内存和交换空间:
```yaml
- id: "local-memory-swap"
name: "内存和交换空间"
type: mem
mem: {}
expect:
usagePercent:
lte: 80
swapUsagePercent:
lte: 50
```
## 语义说明
Mem checker 通过 `systeminformation` 库读取系统内存数据,在 Linux、macOS 和 Windows 上均可运行。
- **`usagePercent`** 使用 `activeBytes / totalBytes` 计算,反映真实的内存压力,不受 Linux buffers/cache 缓存影响。推荐使用此字段进行内存健康检查。
- **`usedPercent`** 使用 `usedBytes / totalBytes` 计算,包含 buffers/cache。在 Linux 上此值通常高于 `usagePercent`
- **Swap 字段**:当系统未配置交换分区时,`swapTotalBytes``0``swapUsagePercent``null`(非 `0`)。
- **`buffcacheBytes`**:反映 Linux 的 buffers + cache 用量,在其他平台上可能为 `null`
Mem checker 是即时读取(非采样),无需 `sampleDuration`,执行速度远快于 CPU checker。虽然读取本身很快但仍受 target `timeout` 约束——若底层系统调用悬挂或阻塞超过 `timeout`checker 会返回 `mem/timeout` failure。
## 跨平台注意事项
- Windows 环境依赖 PowerShell 5+ 获取部分内存指标
- `buffcacheBytes` 在非 Linux 平台上可能返回 `null`
- 容器环境中内存数据可能不反映 cgroup 内存限制
## 不支持的功能
- 进程级内存使用(如 RSS、VSZ
- cgroup/container 内存限制精度
- 内存趋势采样和历史记录
- 内存条物理布局信息
- 详细内存分类slab、reclaimable、dirty 等)作为 expect 字段
## 更新触发条件
修改 Mem checker 配置、expect 字段、行为或语义时,必须更新本文档。

35
docs/user/checkers/tcp.md Normal file
View File

@@ -0,0 +1,35 @@
# TCP Checker
`type: tcp` 用于 TCP 端口可达性和可选 banner 探测。
## 配置项
| 字段 | 说明 | 必填 | 默认值 |
| ----------------------- | --------------------------------------------- | ---- | ------- |
| `tcp.host` | 目标主机地址 | 是 | 无 |
| `tcp.port` | 目标端口,范围 `1-65535` | 是 | 无 |
| `tcp.readBanner` | 是否读取服务端 banner | 否 | `false` |
| `tcp.bannerReadTimeout` | banner 读取超时,毫秒 | 否 | `2000` |
| `tcp.maxBannerBytes` | banner 最大字节数,支持 `KB``MB``GB` 单位 | 否 | `4KB` |
## expect 校验项
| 字段 | 说明 | 必填 | 默认值 |
| ------------ | ------------------------------------------------------------------------- | ---- | ------ |
| `connected` | 期望连接结果,`true` 可达或 `false` 期望不可达 | 否 | `true` |
| `banner` | Banner 内容校验,使用 `ContentExpectations` 数组,需开启 `tcp.readBanner` | 否 | 无 |
| `durationMs` | 完整执行耗时校验,使用 `ValueMatcher` | 否 | 无 |
## 示例
```yaml
- id: "redis-port"
name: "Redis 端口可达"
type: tcp
tcp:
host: "127.0.0.1"
port: 6379
expect:
durationMs:
lte: 3000
```

43
docs/user/checkers/udp.md Normal file
View File

@@ -0,0 +1,43 @@
# UDP Checker
`type: udp` 用于 UDP payload 请求-响应检查。
## 配置项
| 字段 | 说明 | 必填 | 默认值 |
| ---------------------- | ------------------------------------------ | ---- | ------ |
| `udp.host` | 目标主机地址 | 是 | 无 |
| `udp.port` | 目标端口,范围 `1-65535` | 是 | 无 |
| `udp.payload` | 发送数据 | 否 | `""` |
| `udp.encoding` | payload 编码:`text``hex``base64` | 否 | `text` |
| `udp.responseEncoding` | 响应解码:`text``hex``base64` | 否 | `text` |
| `udp.maxResponseBytes` | 响应最大字节数,支持 `KB``MB``GB` 单位 | 否 | `4KB` |
## expect 校验项
| 字段 | 说明 | 必填 | 默认值 |
| -------------- | --------------------------------------------- | ---- | ------ |
| `responded` | 期望是否收到响应 | 否 | `true` |
| `response` | 响应内容校验,使用 `ContentExpectations` 数组 | 否 | 无 |
| `responseSize` | 响应字节数校验,使用 `ValueMatcher` | 否 | 无 |
| `sourceHost` | 响应来源地址校验,使用 `ValueMatcher` | 否 | 无 |
| `sourcePort` | 响应来源端口校验,使用 `ValueMatcher` | 否 | 无 |
| `durationMs` | 完整执行耗时校验,使用 `ValueMatcher` | 否 | 无 |
## 示例
```yaml
- id: "udp-heartbeat"
name: "UDP 心跳检测"
type: udp
udp:
host: "127.0.0.1"
port: 9000
payload: "PING"
expect:
responded: true
response:
- contains: "PONG"
durationMs:
lte: 100
```

81
docs/user/checkers/ws.md Normal file
View File

@@ -0,0 +1,81 @@
# WS Checker
`type: ws` 用于 WebSocket 服务可达性检查和消息交互验证。
## 配置项
| 字段 | 说明 | 必填 | 默认值 |
| -------------------- | ---------------------------------------------- | ---- | ------- |
| `ws.url` | 目标 URL必须以 `ws://``wss://` 开头 | 是 | 无 |
| `ws.headers` | 握手 HTTP 头 | 否 | `{}` |
| `ws.subprotocols` | 子协议协商 | 否 | `[]` |
| `ws.ignoreSSL` | 忽略 TLS 证书校验 | 否 | `false` |
| `ws.send` | 发送的 text 消息,配置后进入请求-响应模式 | 否 | 无 |
| `ws.receiveTimeout` | 等待响应超时,毫秒 | 否 | `5000` |
| `ws.maxMessageBytes` | 单条消息最大字节数,支持 `KB``MB``GB` 单位 | 否 | `4KB` |
## expect 校验项
| 字段 | 说明 | 必填 | 默认值 |
| ------------------ | --------------------------------------------------------------------- | ---- | ------ |
| `connected` | 期望连接结果,`true` 可达或 `false` 期望不可达 | 否 | `true` |
| `handshakeHeaders` | 握手响应头校验,使用 `KeyedExpectations` | 否 | 无 |
| `message` | 收到的消息内容校验,使用 `ContentExpectations` 数组,需配置 `ws.send` | 否 | 无 |
| `connectTimeMs` | 连接建立耗时校验,使用 `ValueMatcher` | 否 | 无 |
| `durationMs` | 完整执行耗时校验,使用 `ValueMatcher` | 否 | 无 |
## 两种模式
不配置 `ws.send` 时只做可达性检查(连接后立即关闭),配置 `ws.send` 后进入请求-响应模式(发送消息并等待首条响应)。
## 示例
可达性检查:
```yaml
- id: "ws-reachability"
name: "WebSocket 服务可达"
type: ws
ws:
url: "wss://api.example.com/ws"
expect:
durationMs:
lte: 3000
```
带鉴权的请求-响应:
```yaml
- id: "ws-echo"
name: "WebSocket Echo 检查"
type: ws
ws:
url: "wss://echo.example.com/ws"
headers:
Authorization: "Bearer ${TOKEN}"
subprotocols: ["json"]
send: '{"action":"ping"}'
receiveTimeout: 3000
expect:
handshakeHeaders:
Sec-WebSocket-Protocol:
equals: "json"
message:
- json:
path: "$.action"
equals: "pong"
durationMs:
lte: 5000
```
期望不可达:
```yaml
- id: "ws-internal-down"
name: "内部服务已下线"
type: ws
ws:
url: "ws://internal.monitor:9443/ws"
expect:
connected: false
```

135
docs/user/configuration.md Normal file
View File

@@ -0,0 +1,135 @@
# 配置文件
DiAL 通过 YAML 配置文件定义运行参数和拨测目标。完整可运行示例参见 [`../../probes.example.yaml`](../../probes.example.yaml)。配置 JSON Schema 位于 [`../../probe-config.schema.json`](../../probe-config.schema.json)。
## 配置结构
```yaml
# yaml-language-server: $schema=./probe-config.schema.json
server:
listen:
host: "127.0.0.1"
port: "${server_port}"
storage:
dataDir: "/tmp/probes_data"
retention: "${retention}"
logging:
level: "${log_level|info}"
file:
path: "<dataDir>/logs/dial.log"
probes:
execution:
maxConcurrentChecks: "${max_checks}"
variables:
server_port: 3000
retention: "7d"
max_checks: 20
default_interval: "30s"
default_timeout: "10s"
targets:
- id: "baidu-home"
name: "Baidu"
type: http
interval: "${default_interval}"
timeout: "${default_timeout}"
http:
url: "https://www.baidu.com"
expect:
status: [200]
```
## server.listen
| 字段 | 说明 | 必填 | 默认值 |
| ------ | -------- | ---- | ----------- |
| `host` | 监听地址 | 否 | `127.0.0.1` |
| `port` | 监听端口 | 否 | `3000` |
## server.storage
| 字段 | 说明 | 必填 | 默认值 |
| ----------- | ---------------------------------------------------- | ---- | -------- |
| `dataDir` | 数据目录,相对路径基于配置文件所在目录解析 | 否 | `./data` |
| `retention` | 历史数据保留时长,支持 `ms``s``m``h``d` 单位 | 否 | `7d` |
## probes.execution
| 字段 | 说明 | 必填 | 默认值 |
| --------------------- | -------------- | ---- | ------ |
| `maxConcurrentChecks` | 最大并发拨测数 | 否 | `20` |
## server.logging
| 字段 | 说明 | 必填 | 默认值 |
| ---------------------------------------- | ---------------------------------------------- | ---- | ------------------------- |
| `server.logging.level` | 全局日志等级console 和 file 未指定时继承此值 | 否 | `info` |
| `server.logging.console.level` | 控制台日志等级 | 否 | 继承 `level` |
| `server.logging.file.level` | 文件日志等级 | 否 | 继承 `level` |
| `server.logging.file.path` | 日志文件路径,相对路径基于配置文件目录解析 | 否 | `<dataDir>/logs/dial.log` |
| `server.logging.file.rotation.size` | 按大小滚动,支持 `KB``MB``GB` 单位 | 否 | `50MB` |
| `server.logging.file.rotation.frequency` | 按时间滚动:`hourly``daily``weekly` | 否 | `daily` |
| `server.logging.file.rotation.maxFiles` | 保留的归档文件数量,不含活跃日志 | 否 | `14` |
日志等级支持:`trace``debug``info``warn``error``fatal`
控制台始终输出 pretty 格式,文件始终输出 JSONL 格式并支持滚动。`rotation.size``rotation.frequency` 任一条件触发即滚动。
## 内置默认值
| 字段 | 默认值 | 约束 |
| ---------- | ------ | ----------------------- |
| `interval` | `30s` | 最小 `10s` |
| `timeout` | `10s` | 必须小于等于 `interval` |
各 checker 专属默认值见 [Checker 参考](checkers/README.md)。
## variables
`variables` 是顶层动态键值表key 必须符合 `[a-zA-Z_][a-zA-Z0-9_]*`value 仅支持 string、number、boolean。`server``probes``targets` 中的字符串值可引用变量。
| 语法 | 说明 |
| ----------------- | ------------------------------------------ |
| `${key}` | 引用 variables 或环境变量 |
| `${key\|default}` | variables 和环境变量都不存在时使用默认值 |
| `${key\|}` | variables 和环境变量都不存在时使用空字符串 |
| `$${key}` | 转义输出字面量 `${key}` |
解析优先级为 `variables -> process.env -> 默认值`。三者均不存在时配置校验失败。字段值完整等于单个变量引用时会保留 number、boolean、string 类型;部分拼接时统一转为字符串。
变量替换作用于 `server``probes``targets`,不作用于 `variables` 段自身,且不会替换 `targets[].id``targets[].type` 字段;对象 key 不参与替换。
## 配置加载形态
配置加载内部区分三层形态:
| 形态 | 说明 |
| ----------------- | ------------------------------------------------------------------------------------- |
| Authoring Config | 用户 YAML 可书写形态,允许变量引用和 expect 简写 |
| Normalized Config | `normalizeAuthoringConfig()` 完成变量替换、expect 简写展开并移除 `variables` 后的形态 |
| ResolvedConfig | checker `resolve()` 补默认值并解析 duration、size、路径和运行期环境后的形态 |
根目录 `probe-config.schema.json` 面向 Authoring Config因此 VSCode 校验会接受 `server.listen.port: "${server_port|3000}"``http.maxRedirects: "${MAX|5}"``expect.durationMs: 5000` 这类写法。
## targets 通用字段
| 字段 | 说明 | 必填 | 默认值 |
| ------------- | ------------------------------------------------------------------------------------ | ---- | --------- |
| `id` | 目标唯一标识,最长 30 字符,支持字母数字、下划线、连字符,不参与变量替换 | 是 | 无 |
| `name` | 展示名称,最长 30 字符,支持变量替换,可省略或显式 null前端展示时 null 回退到 `id` | 否 | 无 |
| `description` | 目标描述,最长 500 字符,支持变量替换,可省略或显式 null允许空字符串 | 否 | 无 |
| `type` | 目标类型:`http``cmd``db``tcp``udp``dns``icmp``llm``ws``cpu` | 是 | 无 |
| `group` | 分组名称 | 否 | `default` |
| `interval` | 拨测间隔,最小 `10s` | 否 | `30s` |
| `timeout` | 超时时间,必须小于等于 `interval` | 否 | `10s` |
## Checker 专属配置
每个 target 必须根据 `type` 配置对应的 checker 专属字段。详情见 [Checker 参考](checkers/README.md)。
## 校验规则
`expect` 字段按 checker 类型不同而变化。通用断言模型见 [校验规则](expectations.md)。

109
docs/user/deployment.md Normal file
View File

@@ -0,0 +1,109 @@
# 部署
本文档说明如何构建、运行、容器化和发布 DiAL。开发环境运行见 [README 快速开始](../../README.md#快速开始)。
## 生产构建和运行
```bash
bun run build
./dist/dial-server ./probes.yaml
```
构建产物为独立可执行文件,只需要一个 YAML 配置文件即可运行。
启动后:
| 地址 | 行为 |
| ------------------------------ | ------------------ |
| `http://127.0.0.1:3000/` | 返回前端 Dashboard |
| `http://127.0.0.1:3000/api/*` | 返回后端 API |
| `http://127.0.0.1:3000/health` | 返回健康检查 |
## Docker 部署
DiAL 提供基于 Alpine 的多阶段镜像。构建阶段使用 Bun 生成 musl 目标单可执行文件,运行阶段只包含 `dial-server`、基础证书、`ping`、Bun musl executable 必需运行库、时区数据和容器运行目录。
```bash
docker build -t dial:alpine .
docker run --rm -p 3000:3000 -v dial-data:/data/dial dial:alpine
```
容器默认读取 `/etc/dial/probes.yaml`,推荐将数据卷挂载到 `/data/dial`
使用自定义配置文件:
```bash
docker run --rm -p 3000:3000 \
-v "$PWD/docker/probes.yaml:/etc/dial/probes.yaml:ro" \
-v dial-data:/data/dial \
dial:alpine
```
容器专用示例配置位于 [`../../docker/probes.yaml`](../../docker/probes.yaml),默认监听 `0.0.0.0:3000`,并将 SQLite 数据和日志写入 `/data/dial`
## ICMP 权限
如需在容器中运行 ICMP checker除镜像内置 `iputils-ping` 外,还需要授予 `NET_RAW` capability
```bash
docker run --rm --cap-add=NET_RAW -p 3000:3000 -v dial-data:/data/dial dial:alpine
```
## CMD checker 额外命令
官方镜像不内置 `bun``node``curl``dig``psql``mysql``redis-cli` 等 CMD checker 可能需要的额外命令。需要这些命令时请使用派生镜像自行安装:
```dockerfile
FROM dial:alpine
USER root
RUN apk add --no-cache curl bind-tools postgresql-client
USER dial
```
## 多架构镜像
```bash
docker buildx build --platform linux/amd64,linux/arm64 -t dial:alpine .
```
Dockerfile 通过 Docker 提供的 `TARGETARCH` 选择 Bun compile target。
| `TARGETARCH` | `BUN_TARGET` |
| ------------ | ---------------------- |
| `amd64` | `bun-linux-x64-musl` |
| `arm64` | `bun-linux-arm64-musl` |
## 跨平台发布包
```bash
bun run release
bun run release --target linux-x64
bun run release --target linux-x64,windows-x64,darwin-arm64
```
支持的目标平台:
| CLI 参数 | Bun CompileTarget |
| ------------------ | ---------------------- |
| `linux-x64` | `bun-linux-x64` |
| `linux-arm64` | `bun-linux-arm64` |
| `linux-x64-musl` | `bun-linux-x64-musl` |
| `linux-arm64-musl` | `bun-linux-arm64-musl` |
| `windows-x64` | `bun-windows-x64` |
| `darwin-x64` | `bun-darwin-x64` |
| `darwin-arm64` | `bun-darwin-arm64` |
产出物结构:
```text
dist/release/
├── binaries/
│ ├── dial-server-0.1.0-linux-x64
│ └── dial-server-0.1.0-windows-x64.exe
└── packages/
├── dial-server_0.1.0_linux_x64.tar.gz
└── dial-server_0.1.0_linux_x64.tar.gz.sha256
```
压缩包内含可执行文件、`probes.example.yaml``LICENSE`,解压后可直接使用。

161
docs/user/expectations.md Normal file
View File

@@ -0,0 +1,161 @@
# 校验规则
本文档说明 `expect` 规则、状态判定、failure、observation 和各 checker 的快速失败顺序。
适用场景:编写 `expect`、理解 UP/DOWN、排查 mismatch/error、查看返回结果中的 `failure``observation`
`expect` 描述拨测结果必须满足的条件。不同 checker 暴露不同字段,但共享三类基础断言模型:`ValueMatcher``ContentExpectations``KeyedExpectations`
## 状态判定
DiAL 使用单层状态模型。
| 状态 | 含义 |
| ------ | ---------------------------------------- |
| `UP` | 拨测结果符合 `expect` 规则 |
| `DOWN` | 拨测结果不符合 `expect` 规则,或执行失败 |
执行失败(网络错误、超时、进程崩溃)和 expect 不匹配都统一为 `DOWN`,通过 `failure.kind` 区分原因。
| `failure.kind` | 含义 |
| -------------- | ---------------------------------------- |
| `error` | 网络、超时、进程、协议解析或内部执行错误 |
| `mismatch` | 拨测完成,但结果不满足 expect |
## API 结果字段
API 返回的检查结果包含 `detail``observation`
| 字段 | 说明 |
| ------------- | ------------------------------------------------------------ |
| `detail` | 后端按 checker 类型从结构化 observation 动态生成的人可读摘要 |
| `observation` | 保存该次检查的结构化观测数据 |
| `failure` | 保存首个错误或不匹配原因 |
| `matched` | 是否符合 expect |
| `durationMs` | 本次检查耗时 |
| `timestamp` | 本次检查时间 |
`detail` 不写入 SQLite。存储层仅持久化 `observation` JSON、`failure` JSON、匹配状态、耗时和时间戳。
## observation 示例
不同 checker 的 observation 字段不同,常见信息包括:
| Checker | observation 内容示例 |
| ------- | ------------------------------------------------------------------ |
| HTTP | 状态码、响应头、按需读取的 body 预览 |
| Cmd | exit code、stdout/stderr 预览 |
| TCP | 连接结果、banner 摘要 |
| UDP | 响应内容、来源地址、响应大小 |
| ICMP | 存活结果、丢包率、平均延迟、最大延迟 |
| DNS | RCODE、记录值、TTL、flags、CNAME 链 |
| LLM | HTTP 状态、模型输出、finish reason、token usage、流式首 token 时间 |
| WS | 连接结果、连接耗时、握手头、消息内容、消息大小 |
Dashboard 基于存储的检查结果计算实时状态、可用率、耗时趋势、P95、状态条和故障段等指标。指标语义由后端应用层实现SQLite 主要负责存储、筛选、排序、分页和基础聚合。
## ContentExpectations
`body``stdout``stderr``banner``response``output``result``message` 等返回内容字段均使用数组。
| 规则 | 说明 |
| ---------- | ------------------------------------------------------ |
| `contains` | 内容包含指定文本 |
| `regex` | 正则匹配,启动期会拒绝存在 ReDoS 风险的模式 |
| `json` | JSONPath 提取值比较,`path` 必填 |
| `css` | CSS 选择器提取 HTML 元素,`selector` 必填,`attr` 可选 |
| `xpath` | XPath 提取 XML/HTML 节点,`path` 必填 |
示例:
```yaml
expect:
body:
- contains: "ok"
- json:
path: "$.status"
equals: "ready"
```
ContentExpectations 数组按顺序快速失败。数组项可以是直接 matcher也可以是 `json``css``xpath` 提取器规则。一条规则不能混用直接 matcher 和 extractor多个 extractor 也不能共存。Extractor 未配置 matcher 时等价于 `exists: true`
## ValueMatcher
`ValueMatcher` 用于单个标量值、数字指标和字符串元数据。
| 字段 | 说明 |
| ---------- | ------------------------------- |
| `equals` | 精确匹配,支持 JSON 深度相等 |
| `contains` | 字符串包含 |
| `regex` | 正则匹配,固定使用无 flags 正则 |
| `empty` | 判断是否为空 |
| `exists` | 判断是否存在 |
| `gte` | 大于等于 |
| `lte` | 小于等于 |
| `gt` | 大于 |
| `lt` | 小于 |
一个 matcher 对象内多个字段为 AND 语义。`exists: false` 不能和其他 matcher 组合。
ValueMatcher expect 字段可直接写 string、number、boolean 或 null等价于 `{ equals: value }`。数组和对象必须显式写成 `{ equals: ... }`
```yaml
expect:
durationMs:
lte: 5000
finishReason: "stop"
```
## KeyedExpectations
`headers`、DB `rows[]` 中的列值等动态键值对象使用 `KeyedExpectations`。每个键的值支持 `ValueMatcher` 的全部字段,字面量值自动等价于 `{ equals: value }`
```yaml
expect:
headers:
Content-Type:
contains: "application/json"
```
## 大小和时长格式
| 类型 | 示例 |
| ---- | -------------------------------- |
| 大小 | `4KB``10MB``1GB`、直接数字 |
| 时长 | `500ms``30s``5m``2h``7d` |
`maxBodyBytes``maxOutputBytes``maxResponseBytes``maxBannerBytes` 等大小字段支持 `KB``MB``GB` 单位。
## 快速失败顺序
| Checker | 顺序 |
| ---------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| HTTP | `status -> headers -> body -> durationMs` |
| Cmd | `exitCode -> durationMs -> stdout -> stderr` |
| DB | `durationMs -> rowCount -> rows -> result` |
| TCP | `connected -> banner -> durationMs` |
| UDP | `responded -> responseSize -> response -> sourceHost -> sourcePort -> durationMs` |
| ICMP | `alive -> packetLossPercent -> avgLatencyMs -> maxLatencyMs -> durationMs` |
| DNS system | `values -> valueCount -> durationMs` |
| DNS server | `responded -> rcode -> values -> valueCount -> answerCount -> ttlMin -> ttlMax -> authoritative -> recursionAvailable -> truncated -> authenticatedData -> result -> durationMs` |
| LLM http | `status -> headers -> output -> finishReason -> rawFinishReason -> usage -> durationMs` |
| LLM stream | `status -> headers -> stream.completed -> stream.firstTokenMs -> output -> finishReason -> rawFinishReason -> usage -> durationMs` |
| WS | `connected -> handshakeHeaders -> message -> connectTimeMs -> durationMs` |
## JSON Schema
仓库根目录导出 `probe-config.schema.json`。在 YAML 文件顶部添加以下注释可在编辑器中获得提示和校验:
```yaml
# yaml-language-server: $schema=./probe-config.schema.json
```
## 已移除字段
旧字段 `maxDurationMs``maxPacketLoss``maxAvgLatencyMs``maxMaxLatencyMs` 和旧正则字段 `match` 已移除,请分别改用 `durationMs`、ICMP matcher 字段和 `regex`
非法配置会阻止启动并输出错误信息。除动态键值表(`headers``env``variables`)外,未知字段会导致启动失败,请使用 YAML 注释表达说明。
## 更新触发条件
修改 expect 断言模型、状态判定、failure 字段、observation 字段、快速失败顺序或已移除字段说明时,必须更新本文档。

View File

@@ -0,0 +1,73 @@
# 故障排查
本文档记录常见运行问题和排查入口。
## 配置校验失败
DiAL 启动时会校验 YAML 配置。除动态键值表(`headers``env``variables`)外,未知字段会导致启动失败。
排查顺序:
1. 在 YAML 顶部添加 `# yaml-language-server: $schema=./probe-config.schema.json`
2. 对照 [配置文件](configuration.md) 检查顶层结构和通用字段。
3. 对照 [Checker 参考](checkers/README.md) 检查 checker 专属字段。
4. 对照 [校验规则](expectations.md) 检查 expect 写法。
## 变量无法解析
变量解析优先级为 `variables -> process.env -> 默认值`。如果三者均不存在,配置校验会失败。
常见修复:
| 问题 | 修复 |
| -------------- | ----------------------------------- |
| 环境变量未设置 | 设置环境变量或在 `variables` 中声明 |
| 希望允许空值 | 使用 `${key\|}` |
| 希望提供默认值 | 使用 `${key\|default}` |
| 希望输出字面量 | 使用 `$${key}` |
## ICMP checker 无法运行
ICMP checker 依赖系统 `ping` 命令。
| 环境 | 处理 |
| ------------------- | -------------------------------------- |
| Alpine 或精简镜像 | 安装 `iputils-ping` |
| Docker 容器 | 运行容器时增加 `--cap-add=NET_RAW` |
| Windows/macOS/Linux | 确认系统 `ping` 可执行且输出格式受支持 |
Docker 示例:
```bash
docker run --rm --cap-add=NET_RAW -p 3000:3000 -v dial-data:/data/dial dial:alpine
```
## CMD checker 找不到命令
官方 Docker 镜像不内置 `bun``node``curl``dig``psql``mysql``redis-cli` 等额外命令。需要这些命令时请使用派生镜像安装。
```dockerfile
FROM dial:alpine
USER root
RUN apk add --no-cache curl bind-tools postgresql-client
USER dial
```
## Docker 数据或日志丢失
推荐将数据卷挂载到 `/data/dial`,并在配置中使用该目录作为 storage dataDir。
```bash
docker run --rm -p 3000:3000 -v dial-data:/data/dial dial:alpine
```
容器示例配置位于 [`../../docker/probes.yaml`](../../docker/probes.yaml)。
## HTTP 或 LLM 证书问题
HTTP 和 LLM checker 支持 `ignoreSSL`。该选项适合内网、自签名证书或测试环境;生产环境应优先修复证书链。
## 正则规则被拒绝
`regex` 启动期会执行 ReDoS 风险检测。被拒绝时应改写为更明确、回溯风险更低的表达式。

View File

@@ -3,13 +3,21 @@ schema: fast-drive
context: |
- 使用中文(注释、文档、交流),面向中文开发者
- openspec文档的关键字按openspec规范使用不要翻译为中文
- **优先阅读README.md和DEVELOPMENT.md**获取项目概览与开发规范所有代码风格、命名、注解、依赖、API等规范以DEVELOPMENT.md为准
- 涉及模块结构、API、实体等变更时同步更新README.md
- 本项目openspec使用fast-drive自定义schema变更文档只包含design.md和tasks.md无proposal.md和specs
- **优先阅读docs/README.md**判断文档归属和本次任务需要读取的专题文档
- README.md用于项目概览、快速开始和顶层文档引导docs/user/README.md用于用户使用入口docs/development/README.md用于开发入口、全局规则和质量门禁
- 所有代码风格、命名、注解、依赖、API等开发规范以docs/development/README.md和docs/development/下对应专题文档为准
- 新增或修改checker时必须阅读docs/development/checker.md、docs/user/checkers/README.md和相近checker用户文档
- 每次代码变更都必须执行文档影响分析判断是否影响用户可见行为、配置格式、checker行为、expect规则、API、部署方式、开发流程、架构边界、测试规范或构建发布流程
- 若影响用户使用方式、配置格式、checker行为、expect规则、部署方式或运行行为必须同步更新docs/user/下对应文档README.md仅在项目定位、快速开始、核心能力列表或文档导航变化时更新
- 若影响开发流程、架构边界、质量门禁、测试规范、构建发布流程或checker开发机制必须同步更新docs/development/README.md或docs/development/下对应专题文档
- 若影响文档同步规则或文档归属矩阵必须同步更新docs/README.md和openspec/config.yaml
- 若无需更新文档,必须在收尾说明中说明原因
- 新增代码优先复用已有组件、工具、依赖库,不引入新依赖
- 新增的逻辑必须编写完善的测试,并保证测试的正确性,不允许跳过任何测试
- 这是基于bun实现的前端后一体化项目使用bun作为唯一包管理器严禁使用pnpm、npm使用bunx运行工具严禁使用npx、pnpx
- src/server目录下是基于bun实现的后端代码
- 后端库使用优先级Bun 内置 API > es-toolkit > 主流三方库 > 项目公共工具 > 自行实现
- 后端库使用优先级Bun 内置 API > es-toolkit > 标准 Web API > 主流三方库 > 项目公共工具 > 自行实现
- src/web目录下是基于Bun HTML import、React、TDesign实现的前端代码
- 前端样式开发优先级TDesign组件 > 组件props > TDesign CSS tokens(--td-*) > styles.css CSS类 > 自行开发组件
- 前端严禁组件内联style属性、CSS覆盖TD内部类名、使用!important、硬编码色值
@@ -26,4 +34,6 @@ rules:
- 一行一个任务,严禁任务内容跨行
- 如果是代码存在更新必须
- 执行完整的测试、代码检查、格式检查等质量保障手段
- 更新 README.md 和/或 DEVELOPMENT.md
- 执行文档影响分析,并按影响范围更新对应文档;若无需更新文档,必须在任务或收尾说明中明确写出原因
- 新增或修改checker时必须更新docs/user/checkers/下对应用户文档并在checker开发机制变化时更新docs/development/checker.md
- 新增或修改配置字段时必须更新probe-config.schema.json、probes.example.yaml、docs/user/configuration.md或对应checker文档

View File

@@ -63,6 +63,7 @@
"ai": "^6",
"ajv": "^8.20.0",
"cheerio": "^1.2.0",
"croner": "^10.0.1",
"es-toolkit": "^1.46.1",
"pino": "^10.3.1",
"pino-pretty": "^13.1.3",
@@ -70,6 +71,7 @@
"react": "^19.2.6",
"react-dom": "^19.2.6",
"recharts": "^3.8.1",
"systeminformation": "^5.31.6",
"tdesign-icons-react": "^0.6.4",
"tdesign-react": "^1.16.9",
"xpath": "^0.0.34"

File diff suppressed because it is too large Load Diff

View File

@@ -207,6 +207,67 @@ targets:
durationMs:
lte: 5000
# ========== DNS targets ==========
# 本机 DNS 解析检查system 模式)
- id: "dns-system-localhost"
name: "本机 DNS 解析"
type: dns
group: "DNS"
dns:
resolver: system
name: "localhost"
family: ipv4
expect:
values:
exact:
- "127.0.0.1"
durationMs:
lte: 200
# DNS server 拨测server 模式A 记录)
- id: "dns-server-cf"
name: "Cloudflare DNS A 记录"
type: dns
group: "DNS"
dns:
resolver: server
server: "1.1.1.1"
name: "example.com"
recordType: A
expect:
rcode: ["NOERROR"]
ttlMin:
gte: 60
durationMs:
lte: 500
# 负向 DNS 检查NXDOMAIN
- id: "dns-nxdomain-check"
name: "负向 DNS 检查"
type: dns
group: "DNS"
dns:
resolver: server
server: "1.1.1.1"
name: "this-domain-should-not-exist.example.com"
recordType: A
expect:
rcode: ["NXDOMAIN"]
# MX 记录检查
- id: "dns-mx-check"
name: "MX 记录检查"
type: dns
group: "DNS"
dns:
resolver: server
server: "1.1.1.1"
name: "gmail.com"
recordType: MX
expect:
rcode: ["NOERROR"]
# ========== UDP targets ==========
- id: "udp-heartbeat"
@@ -266,3 +327,54 @@ targets:
finishReason: "stop"
output:
- contains: "OK"
# ========== WS targets ==========
- id: "ws-reachability"
name: "WebSocket 服务可达"
type: ws
group: "基础设施"
ws:
url: "wss://echo.websocket.org"
expect:
durationMs:
lte: 5000
- id: "ws-echo-check"
name: "WebSocket Echo 交互检查"
type: ws
group: "基础设施"
ws:
url: "wss://echo.websocket.org"
send: "hello"
receiveTimeout: 3000
expect:
message:
- contains: "hello"
durationMs:
lte: 5000
- id: "local-cpu"
name: "本机 CPU"
type: cpu
group: "基础设施"
interval: "30s"
timeout: "5s"
cpu:
sampleDuration: "1s"
expect:
usagePercent:
lte: 85
maxCoreUsagePercent:
lte: 95
- id: "local-memory"
name: "本机内存"
type: mem
group: "基础设施"
interval: "30s"
timeout: "5s"
mem: {}
expect:
usagePercent:
lte: 85

View File

@@ -32,6 +32,8 @@ const DEFAULT_ROTATION_SIZE = "50MB";
const DEFAULT_ROTATION_FREQUENCY: RotationFrequency = "daily";
const DEFAULT_ROTATION_MAX_FILES = 14;
const MINIMUM_INTERVAL_MS = parseDuration("10s");
const VALID_LOG_LEVELS: LogLevel[] = ["trace", "debug", "info", "warn", "error", "fatal"];
const VALID_ROTATION_FREQUENCIES: RotationFrequency[] = ["hourly", "daily", "weekly"];
@@ -60,7 +62,7 @@ export async function loadConfig(configPath: string): Promise<ResolvedConfig> {
throw new Error("配置文件内容为空或格式无效");
}
const normalizeResult = normalizeAuthoringConfig(parsed);
const normalizeResult = normalizeAuthoringConfig(parsed, checkerRegistry);
if (normalizeResult.issues.length > 0) {
throwConfigIssues(dedupeIssues(normalizeResult.issues));
}
@@ -208,6 +210,14 @@ function resolveTarget(
return result;
}
function tryParseDuration(value: string): null | number {
try {
return parseDuration(value);
} catch {
return null;
}
}
function validateConfig(config: NormalizedProbeConfig): ConfigValidationIssue[] {
const issues: ConfigValidationIssue[] = [];
if (!Array.isArray(config.targets) || config.targets.length === 0) {
@@ -291,18 +301,21 @@ function validateConfig(config: NormalizedProbeConfig): ConfigValidationIssue[]
: isString(targetIdValue)
? targetIdValue
: undefined;
validateDurationValue(
isString(targetRecord["interval"]) ? targetRecord["interval"] : undefined,
`targets[${i}].interval`,
issues,
targetName,
);
validateDurationValue(
isString(targetRecord["timeout"]) ? targetRecord["timeout"] : undefined,
`targets[${i}].timeout`,
issues,
targetName,
);
const intervalRaw = isString(targetRecord["interval"]) ? targetRecord["interval"] : undefined;
const timeoutRaw = isString(targetRecord["timeout"]) ? targetRecord["timeout"] : undefined;
validateDurationValue(intervalRaw, `targets[${i}].interval`, issues, targetName);
validateDurationValue(timeoutRaw, `targets[${i}].timeout`, issues, targetName);
const intervalMs = tryParseDuration(intervalRaw ?? DEFAULT_INTERVAL);
const timeoutMs = tryParseDuration(timeoutRaw ?? DEFAULT_TIMEOUT);
if (intervalMs !== null && intervalMs < MINIMUM_INTERVAL_MS) {
issues.push(issue("invalid-value", `targets[${i}].interval`, "interval 不能小于 10s", targetName));
}
if (intervalMs !== null && timeoutMs !== null && timeoutMs > intervalMs) {
issues.push(issue("invalid-value", `targets[${i}].timeout`, "timeout 不能大于 interval", targetName));
}
}
return issues;

View File

@@ -1,4 +1,4 @@
import { groupBy, isError, Semaphore } from "es-toolkit";
import { isError, Semaphore } from "es-toolkit";
import type { Logger } from "../logger";
import type { ProbeStore } from "./store";
@@ -11,14 +11,15 @@ import { checkerRegistry } from "./runner";
const PRUNE_INTERVAL_MS = 3600000;
export class ProbeEngine {
private abort: AbortController | null = null;
private lastMatched = new Map<string, boolean>();
private logger: Logger;
private pruneTimer: null | ReturnType<typeof setInterval> = null;
private retentionMs: number;
private semaphore: Semaphore;
private store: ProbeStore;
private targetIds = new Set<string>();
private targets: ResolvedTargetBase[];
private timers: Array<ReturnType<typeof setInterval>> = [];
constructor(
store: ProbeStore,
@@ -37,32 +38,28 @@ export class ProbeEngine {
}
start(): void {
const groups = groupBy(this.targets, (t) => t.intervalMs);
this.abort = new AbortController();
const signal = this.abort.signal;
for (const [intervalMs, groupTargets] of Object.entries(groups)) {
void this.probeGroup(groupTargets);
const timer = setInterval(() => {
void this.probeGroup(groupTargets);
}, Number(intervalMs));
this.timers.push(timer);
for (const target of this.targets) {
void this.runLoop(target, signal);
}
if (this.retentionMs > 0) {
this.store.prune(this.retentionMs);
const pruneTimer = setInterval(() => {
this.pruneTimer = setInterval(() => {
this.store.prune(this.retentionMs);
}, PRUNE_INTERVAL_MS);
this.timers.push(pruneTimer);
}
}
stop(): void {
for (const timer of this.timers) {
clearInterval(timer);
this.abort?.abort();
this.abort = null;
if (this.pruneTimer) {
clearInterval(this.pruneTimer);
this.pruneTimer = null;
}
this.timers = [];
}
private initStateCache(): void {
@@ -108,44 +105,6 @@ export class ProbeEngine {
this.lastMatched.set(result.targetId, current);
}
private async probeGroup(targets: ResolvedTargetBase[]): Promise<void> {
const results = await Promise.allSettled(
targets.map(async (target) => {
await this.semaphore.acquire();
try {
return await this.runCheck(target);
} finally {
this.semaphore.release();
}
}),
);
for (const [index, result] of results.entries()) {
if (result.status === "fulfilled") {
this.writeResult(result.value);
this.logStateChange(result.value);
this.logCheckDebug(result.value);
} else {
const target = targets[index];
if (target) {
this.logger.error(
{ reason: formatReason(result.reason), targetId: target.id, targetType: target.type },
`探针执行失败: ${formatReason(result.reason)}`,
);
this.writeResult({
detail: null,
durationMs: null,
failure: errorFailure("internal", "engine", formatReason(result.reason)),
matched: false,
observation: null,
targetId: target.id,
timestamp: new Date().toISOString(),
});
}
}
}
}
private refreshCache(): void {
this.targetIds.clear();
for (const target of this.store.getTargets()) {
@@ -165,6 +124,62 @@ export class ProbeEngine {
}
}
private async runLoop(target: ResolvedTargetBase, signal: AbortSignal): Promise<void> {
while (!signal.aborted) {
const start = performance.now();
try {
await this.runOnce(target, signal);
} catch {
break;
}
const elapsed = performance.now() - start;
if (elapsed > target.intervalMs) {
this.logger.warn(
{ elapsed, intervalMs: target.intervalMs, targetId: target.id },
`拨测超时: ${target.id} 耗时 ${Math.round(elapsed)}ms > 间隔 ${target.intervalMs}ms`,
);
}
const delay = Math.max(0, target.intervalMs - elapsed);
try {
await sleep(delay, signal);
} catch {
break;
}
}
}
private async runOnce(target: ResolvedTargetBase, signal?: AbortSignal): Promise<CheckResult> {
await this.semaphore.acquire();
if (signal?.aborted) {
this.semaphore.release();
throw new DOMException("Aborted", "AbortError");
}
try {
const result = await this.runCheck(target);
this.writeResult(result);
this.logStateChange(result);
this.logCheckDebug(result);
return result;
} catch (error) {
const reason = formatReason(error);
this.logger.error({ reason, targetId: target.id, targetType: target.type }, `探针执行失败: ${reason}`);
const errorResult: CheckResult = {
detail: null,
durationMs: null,
failure: errorFailure("internal", "engine", reason),
matched: false,
observation: null,
targetId: target.id,
timestamp: new Date().toISOString(),
};
this.writeResult(errorResult);
return errorResult;
} finally {
this.semaphore.release();
}
}
private writeResult(result: CheckResult): void {
if (!this.targetIds.has(result.targetId)) return;
@@ -182,3 +197,24 @@ export class ProbeEngine {
function formatReason(reason: unknown): string {
return isError(reason) ? reason.message : String(reason);
}
function sleep(ms: number, signal: AbortSignal): Promise<void> {
return new Promise<void>((resolve, reject) => {
if (signal.aborted) {
reject(new DOMException("Aborted", "AbortError"));
return;
}
const timer = setTimeout(() => {
signal.removeEventListener("abort", onAbort);
resolve();
}, ms);
function onAbort() {
clearTimeout(timer);
reject(new DOMException("Aborted", "AbortError"));
}
signal.addEventListener("abort", onAbort, { once: true });
});
}

View File

@@ -0,0 +1,50 @@
import { isPlainObject } from "es-toolkit";
import { resolveContentExpectations } from "./content";
import { CONTENT_EXTRACTOR_KEY_SET, MATCHER_KEY_SET } from "./keys";
import { isValueMatcherObject, isValueMatcherPrimitive, resolveValueExpectation } from "./value";
type ExpectRecord = Record<string, unknown>;
export function compactExpect(original: ExpectRecord, overrides: ExpectRecord): ExpectRecord {
const result: ExpectRecord = {};
for (const [key, value] of Object.entries(original)) {
if (value !== undefined) result[key] = value;
}
for (const [key, value] of Object.entries(overrides)) {
if (value !== undefined) result[key] = value;
}
return result;
}
export function normalizeContent(value: unknown): unknown {
if (value === undefined) return undefined;
if (!Array.isArray(value)) return value;
return (value as unknown[]).map((entry): unknown => {
if (!canNormalizeContentEntry(entry)) return entry;
const resolved = resolveContentExpectations([entry] as never);
return resolved?.[0];
});
}
export function normalizeKeyed(value: unknown): unknown {
if (value === undefined) return undefined;
if (!isPlainObject(value)) return value;
return Object.entries(value as ExpectRecord).map(([key, item]) => ({ key, matcher: normalizeValue(item) }));
}
export function normalizeValue(value: unknown): unknown {
if (value === undefined) return undefined;
if (isValueMatcherPrimitive(value) || isValueMatcherObject(value)) return resolveValueExpectation(value);
return value;
}
function canNormalizeContentEntry(value: unknown): boolean {
if (!isPlainObject(value)) return false;
const keys = Object.keys(value);
const extractorKeys = keys.filter((key) => CONTENT_EXTRACTOR_KEY_SET.has(key));
const matcherKeys = keys.filter((key) => MATCHER_KEY_SET.has(key));
if (extractorKeys.length === 0) return matcherKeys.length > 0 && matcherKeys.length === keys.length;
if (extractorKeys.length !== 1 || matcherKeys.length > 0 || keys.length !== 1) return false;
return isPlainObject((value as ExpectRecord)[extractorKeys[0]!]);
}

View File

@@ -1,17 +1,17 @@
import { isPlainObject } from "es-toolkit";
import type { CheckerRegistry } from "./runner/registry";
import type { ConfigValidationIssue } from "./schema/issues";
import type { AuthoringProbeConfig, NormalizedProbeConfig } from "./schema/types";
import type { RawTargetConfig } from "./types";
import { resolveContentExpectations } from "./expect/content";
import { CONTENT_EXTRACTOR_KEY_SET, MATCHER_KEY_SET } from "./expect/keys";
import { isValueMatcherObject, isValueMatcherPrimitive, resolveValueExpectation } from "./expect/value";
import { checkerRegistry } from "./runner";
import { resolveVariables } from "./variables";
type ExpectRecord = Record<string, unknown>;
export function normalizeAuthoringConfig(config: unknown): {
export function normalizeAuthoringConfig(
config: unknown,
registry: CheckerRegistry = checkerRegistry,
): {
config: unknown;
issues: ConfigValidationIssue[];
} {
@@ -23,165 +23,20 @@ export function normalizeAuthoringConfig(config: unknown): {
const normalized = { ...(variableResult.config as Record<string, unknown>) };
delete normalized["variables"];
if (Array.isArray(normalized["targets"])) {
normalized["targets"] = normalized["targets"].map((target) => normalizeTarget(target));
normalized["targets"] = normalized["targets"].map((target) => normalizeTarget(target, registry));
}
return { config: normalized, issues: variableResult.issues };
}
function canNormalizeContentEntry(value: unknown): boolean {
if (!isPlainObject(value)) return false;
const keys = Object.keys(value);
const extractorKeys = keys.filter((key) => CONTENT_EXTRACTOR_KEY_SET.has(key));
const matcherKeys = keys.filter((key) => MATCHER_KEY_SET.has(key));
if (extractorKeys.length === 0) return matcherKeys.length > 0 && matcherKeys.length === keys.length;
if (extractorKeys.length !== 1 || matcherKeys.length > 0 || keys.length !== 1) return false;
return isPlainObject((value as ExpectRecord)[extractorKeys[0]!]);
}
function compact(original: ExpectRecord, overrides: ExpectRecord): ExpectRecord {
const result: ExpectRecord = {};
for (const [key, value] of Object.entries(original)) {
if (value !== undefined) result[key] = value;
}
for (const [key, value] of Object.entries(overrides)) {
if (value !== undefined) result[key] = value;
}
return result;
}
function normalizeCommandExpect(raw: ExpectRecord): ExpectRecord {
return compact(raw, {
durationMs: normalizeValue(raw["durationMs"]),
exitCode: raw["exitCode"],
stderr: normalizeContent(raw["stderr"]),
stdout: normalizeContent(raw["stdout"]),
});
}
function normalizeContent(value: unknown): unknown {
if (value === undefined) return undefined;
if (!Array.isArray(value)) return value;
return (value as unknown[]).map((entry): unknown => {
if (!canNormalizeContentEntry(entry)) return entry;
const resolved = resolveContentExpectations([entry] as never);
return resolved?.[0];
});
}
function normalizeDbExpect(raw: ExpectRecord): ExpectRecord {
return compact(raw, {
durationMs: normalizeValue(raw["durationMs"]),
result: normalizeContent(raw["result"]),
rowCount: normalizeValue(raw["rowCount"]),
rows: Array.isArray(raw["rows"]) ? raw["rows"].map((row) => normalizeKeyed(row)) : raw["rows"],
});
}
function normalizeExpect(type: string, expect: unknown): unknown {
if (!isPlainObject(expect)) return expect;
const raw = expect as ExpectRecord;
switch (type) {
case "cmd":
return normalizeCommandExpect(raw);
case "db":
return normalizeDbExpect(raw);
case "http":
return normalizeHttpExpect(raw);
case "icmp":
return normalizeIcmpExpect(raw);
case "llm":
return normalizeLlmExpect(raw);
case "tcp":
return normalizeTcpExpect(raw);
case "udp":
return normalizeUdpExpect(raw);
default:
return expect;
}
}
function normalizeHttpExpect(raw: ExpectRecord): ExpectRecord {
return compact(raw, {
body: normalizeContent(raw["body"]),
durationMs: normalizeValue(raw["durationMs"]),
headers: normalizeKeyed(raw["headers"]),
status: raw["status"],
});
}
function normalizeIcmpExpect(raw: ExpectRecord): ExpectRecord {
return compact(raw, {
alive: raw["alive"],
avgLatencyMs: normalizeValue(raw["avgLatencyMs"]),
durationMs: normalizeValue(raw["durationMs"]),
maxLatencyMs: normalizeValue(raw["maxLatencyMs"]),
packetLossPercent: normalizeValue(raw["packetLossPercent"]),
});
}
function normalizeKeyed(value: unknown): unknown {
if (value === undefined) return undefined;
if (!isPlainObject(value)) return value;
return Object.entries(value as ExpectRecord).map(([key, item]) => ({ key, matcher: normalizeValue(item) }));
}
function normalizeLlmExpect(raw: ExpectRecord): ExpectRecord {
return compact(raw, {
durationMs: normalizeValue(raw["durationMs"]),
finishReason: normalizeValue(raw["finishReason"]),
headers: normalizeKeyed(raw["headers"]),
output: normalizeContent(raw["output"]),
rawFinishReason: normalizeValue(raw["rawFinishReason"]),
status: raw["status"],
stream: isPlainObject(raw["stream"])
? compact(raw["stream"] as ExpectRecord, {
completed: (raw["stream"] as ExpectRecord)["completed"],
firstTokenMs: normalizeValue((raw["stream"] as ExpectRecord)["firstTokenMs"]),
})
: raw["stream"],
usage: isPlainObject(raw["usage"])
? compact(raw["usage"] as ExpectRecord, {
inputTokens: normalizeValue((raw["usage"] as ExpectRecord)["inputTokens"]),
outputTokens: normalizeValue((raw["usage"] as ExpectRecord)["outputTokens"]),
totalTokens: normalizeValue((raw["usage"] as ExpectRecord)["totalTokens"]),
})
: raw["usage"],
});
}
function normalizeTarget(target: unknown): unknown {
function normalizeTarget(target: unknown, registry: CheckerRegistry): unknown {
if (!isPlainObject(target)) return target;
const result = { ...(target as RawTargetConfig) };
if (result.expect !== undefined) {
result.expect = normalizeExpect(result.type, result.expect);
}
return result;
}
function normalizeTcpExpect(raw: ExpectRecord): ExpectRecord {
return compact(raw, {
banner: normalizeContent(raw["banner"]),
connected: raw["connected"],
durationMs: normalizeValue(raw["durationMs"]),
});
}
function normalizeUdpExpect(raw: ExpectRecord): ExpectRecord {
return compact(raw, {
durationMs: normalizeValue(raw["durationMs"]),
responded: raw["responded"],
response: normalizeContent(raw["response"]),
responseSize: normalizeValue(raw["responseSize"]),
sourceHost: normalizeValue(raw["sourceHost"]),
sourcePort: normalizeValue(raw["sourcePort"]),
});
}
function normalizeValue(value: unknown): unknown {
if (value === undefined) return undefined;
if (isValueMatcherPrimitive(value) || isValueMatcherObject(value)) return resolveValueExpectation(value);
return value;
const type = result.type;
if (typeof type !== "string") return result;
const checker = registry?.tryGet(type);
if (!checker) return result;
return checker.normalize(result);
}
export type { AuthoringProbeConfig, NormalizedProbeConfig };

View File

@@ -10,6 +10,7 @@ import { errorFailure } from "../../expect/failure";
import { checkValueExpectation } from "../../expect/value";
import { parseSize } from "../../utils";
import { checkExitCode } from "./expect";
import { normalizeTargetExpect } from "./normalize";
import { commandCheckerSchemas } from "./schema";
import { validateCommandConfig } from "./validate";
@@ -202,6 +203,10 @@ export class CommandChecker implements CheckerDefinition<ResolvedCommandTarget>
};
}
normalize(target: RawTargetConfig): RawTargetConfig {
return normalizeTargetExpect(target);
}
resolve(target: RawTargetConfig, context: ResolveContext): ResolvedCommandTarget {
const t = target as RawTargetConfig & { cmd: CommandTargetConfig; type: "cmd" };

View File

@@ -0,0 +1,19 @@
import { isPlainObject } from "es-toolkit";
import type { RawTargetConfig } from "../../types";
import { compactExpect, normalizeContent, normalizeValue } from "../../expect/normalize";
export function normalizeTargetExpect(target: RawTargetConfig): RawTargetConfig {
if (target.expect === undefined || !isPlainObject(target.expect)) return target;
const raw = target.expect as Record<string, unknown>;
return {
...target,
expect: compactExpect(raw, {
durationMs: normalizeValue(raw["durationMs"]),
exitCode: raw["exitCode"],
stderr: normalizeContent(raw["stderr"]),
stdout: normalizeContent(raw["stdout"]),
}),
};
}

View File

@@ -0,0 +1,111 @@
import { cpus } from "node:os";
import type { CpuCoreSnapshot, CpuStats } from "./types";
/**
* 根据两次 CPU times 快照计算使用率统计。
*
* - usagePercent = 100 - idlePercent互补关系恒等于 100
* - idlePercent = 所有核心 idle delta 之和 ÷ 所有核心 total delta 之和 × 100
* - maxCoreUsagePercent / minCoreUsagePercent 为单核心粒度的最高/最低使用率
* - 所有百分比范围 0-100保留 1 位小数
*/
export function calculateCpuStats(before: CpuCoreSnapshot[], after: CpuCoreSnapshot[]): CpuStats {
let totalIdleDelta = 0;
let totalDelta = 0;
const perCoreUsage: number[] = [];
for (let i = 0; i < before.length; i++) {
const b = before[i]!.times;
const a = after[i]!.times;
const idleDelta = a.idle - b.idle;
const userDelta = a.user - b.user;
const niceDelta = a.nice - b.nice;
const sysDelta = a.sys - b.sys;
const irqDelta = a.irq - b.irq;
const coreTotalDelta = userDelta + niceDelta + sysDelta + idleDelta + irqDelta;
const coreIdleDelta = idleDelta;
totalIdleDelta += coreIdleDelta;
totalDelta += coreTotalDelta;
const coreUsagePercent = coreTotalDelta === 0 ? 0 : round1((1 - coreIdleDelta / coreTotalDelta) * 100);
perCoreUsage.push(coreUsagePercent);
}
const idlePercent = totalDelta === 0 ? 0 : round1((totalIdleDelta / totalDelta) * 100);
const usagePercent = totalDelta === 0 ? 0 : round1(100 - idlePercent);
const maxCoreUsagePercent = Math.max(...perCoreUsage);
const minCoreUsagePercent = Math.min(...perCoreUsage);
return {
idlePercent,
logicalCoreCount: before.length,
maxCoreUsagePercent,
minCoreUsagePercent,
perCoreUsagePercent: perCoreUsage,
usagePercent,
};
}
/**
* 读取当前 CPU 各核心 times 快照。
* 委托给 node:os 的 os.cpus(),便于测试时注入 mock。
*/
export function readCpuSnapshot(): CpuCoreSnapshot[] {
return cpus().map((cpu) => ({
times: {
idle: cpu.times.idle,
irq: cpu.times.irq,
nice: cpu.times.nice,
sys: cpu.times.sys,
user: cpu.times.user,
},
}));
}
export function validateCpuSnapshots(before: CpuCoreSnapshot[], after: CpuCoreSnapshot[]): null | string {
if (before.length === 0 || after.length === 0) {
return "CPU 快照为空";
}
if (before.length !== after.length) {
return `CPU 快照核心数不一致: before=${before.length}, after=${after.length}`;
}
for (let i = 0; i < before.length; i++) {
const bTimes = before[i]!.times;
const aTimes = after[i]!.times;
for (const [name, value] of Object.entries(bTimes)) {
if (!Number.isFinite(value)) {
return `CPU 快照包含非有限值: before[${i}].times.${name}=${value}`;
}
}
for (const [name, value] of Object.entries(aTimes)) {
if (!Number.isFinite(value)) {
return `CPU 快照包含非有限值: after[${i}].times.${name}=${value}`;
}
}
const idleDelta = aTimes.idle - bTimes.idle;
const userDelta = aTimes.user - bTimes.user;
const niceDelta = aTimes.nice - bTimes.nice;
const sysDelta = aTimes.sys - bTimes.sys;
const irqDelta = aTimes.irq - bTimes.irq;
const coreTotalDelta = userDelta + niceDelta + sysDelta + idleDelta + irqDelta;
if (coreTotalDelta < 0) {
return `CPU 快照包含负数 delta: core[${i}] totalDelta=${coreTotalDelta}`;
}
}
return null;
}
function round1(value: number): number {
return Math.round(value * 10) / 10;
}

View File

@@ -0,0 +1,219 @@
import type { CheckResult, RawTargetConfig } from "../../types";
import type { CheckerContext, CheckerDefinition, CheckerValidationInput, ResolveContext } from "../types";
import type { CpuCoreSnapshot, CpuStats, CpuTargetConfig, ResolvedCpuExpectConfig, ResolvedCpuTarget } from "./types";
import { errorFailure } from "../../expect/failure";
import { checkValueExpectation } from "../../expect/value";
import { parseDuration } from "../../utils";
import { calculateCpuStats, readCpuSnapshot, validateCpuSnapshots } from "./calculate";
import { checkIdlePercent, checkMaxCoreUsage, checkMinCoreUsage, checkUsagePercent } from "./expect";
import { normalizeTargetExpect } from "./normalize";
import { cpuCheckerSchemas } from "./schema";
import { validateCpuConfig } from "./validate";
const DEFAULT_SAMPLE_DURATION_MS = 1000;
/**
* 可注入的 CPU 快照读取函数,便于测试。
* 生产环境使用 node:os 的 os.cpus()。
*/
export type SnapshotReader = () => CpuCoreSnapshot[];
export class CpuChecker implements CheckerDefinition<ResolvedCpuTarget> {
readonly configKey = "cpu";
readonly schemas = cpuCheckerSchemas;
readonly type = "cpu";
constructor(private readonly readSnapshot: SnapshotReader = readCpuSnapshot) {}
buildDetail(observation: Record<string, unknown>): null | string {
const usage = observation["usagePercent"];
const usageStr = typeof usage === "number" ? formatNumber(usage) : "n/a";
const maxCore = observation["maxCoreUsagePercent"];
const maxStr = typeof maxCore === "number" ? formatNumber(maxCore) : "n/a";
const cores = observation["logicalCoreCount"];
const coresStr = typeof cores === "number" ? String(cores) : "?";
return `usage ${usageStr}%, max core ${maxStr}%, ${coresStr} cores`;
}
async execute(t: ResolvedCpuTarget, ctx: CheckerContext): Promise<CheckResult> {
const timestamp = new Date().toISOString();
const start = performance.now();
let before: CpuCoreSnapshot[];
try {
before = this.readSnapshot();
} catch (error) {
const durationMs = Math.round(performance.now() - start);
return {
detail: null,
durationMs,
failure: errorFailure(
"cpu",
"snapshot",
`CPU 快照读取失败: ${error instanceof Error ? error.message : String(error)}`,
),
matched: false,
observation: null,
targetId: t.id,
timestamp,
};
}
// 采样等待,支持 AbortSignal 取消
const aborted = await waitForDuration(t.cpu.sampleDurationMs, ctx.signal);
let after: CpuCoreSnapshot[];
if (aborted) {
const durationMs = Math.round(performance.now() - start);
return {
detail: null,
durationMs,
failure: errorFailure("cpu", "timeout", `CPU 采样超时 (${t.timeoutMs}ms)`),
matched: false,
observation: null,
targetId: t.id,
timestamp,
};
}
try {
after = this.readSnapshot();
} catch (error) {
const durationMs = Math.round(performance.now() - start);
return {
detail: null,
durationMs,
failure: errorFailure(
"cpu",
"snapshot",
`CPU 快照读取失败: ${error instanceof Error ? error.message : String(error)}`,
),
matched: false,
observation: null,
targetId: t.id,
timestamp,
};
}
const validationError = validateCpuSnapshots(before, after);
if (validationError !== null) {
const durationMs = Math.round(performance.now() - start);
return {
detail: null,
durationMs,
failure: errorFailure("cpu", "snapshot", validationError),
matched: false,
observation: null,
targetId: t.id,
timestamp,
};
}
const stats = calculateCpuStats(before, after);
const durationMs = Math.round(performance.now() - start);
const result = checkStats(stats, t.expect, durationMs);
const observation: Record<string, unknown> = {
error: null,
idlePercent: stats.idlePercent,
logicalCoreCount: stats.logicalCoreCount,
maxCoreUsagePercent: stats.maxCoreUsagePercent,
minCoreUsagePercent: stats.minCoreUsagePercent,
usagePercent: stats.usagePercent,
};
if (t.cpu.includePerCore) {
observation["perCoreUsagePercent"] = stats.perCoreUsagePercent;
}
return {
detail: null,
durationMs,
failure: result.failure,
matched: result.matched,
observation,
targetId: t.id,
timestamp,
};
}
normalize(target: RawTargetConfig): RawTargetConfig {
return normalizeTargetExpect(target);
}
resolve(target: RawTargetConfig, context: ResolveContext): ResolvedCpuTarget {
const t = target as RawTargetConfig & { cpu: CpuTargetConfig; type: "cpu" };
const rawSampleDuration = t.cpu.sampleDuration;
const sampleDurationMs = rawSampleDuration ? parseDuration(rawSampleDuration) : DEFAULT_SAMPLE_DURATION_MS;
const includePerCore = t.cpu.includePerCore ?? false;
return {
cpu: { includePerCore, sampleDurationMs },
description: null,
expect: target.expect as ResolvedCpuExpectConfig | undefined,
group: target.group ?? "default",
id: t.id,
intervalMs: context.defaultIntervalMs,
name: t.name ?? null,
timeoutMs: context.defaultTimeoutMs,
type: "cpu",
} satisfies ResolvedCpuTarget;
}
serialize(t: ResolvedCpuTarget): { config: string; target: string } {
return {
config: JSON.stringify(t.cpu),
target: `cpu sample ${t.cpu.sampleDurationMs}ms`,
};
}
validate(input: CheckerValidationInput) {
return validateCpuConfig(input);
}
}
function checkStats(stats: CpuStats, expect: ResolvedCpuExpectConfig | undefined, durationMs: number) {
const usageResult = checkUsagePercent(stats.usagePercent, expect?.usagePercent);
if (!usageResult.matched) return usageResult;
const idleResult = checkIdlePercent(stats.idlePercent, expect?.idlePercent);
if (!idleResult.matched) return idleResult;
const maxCoreResult = checkMaxCoreUsage(stats.maxCoreUsagePercent, expect?.maxCoreUsagePercent);
if (!maxCoreResult.matched) return maxCoreResult;
const minCoreResult = checkMinCoreUsage(stats.minCoreUsagePercent, expect?.minCoreUsagePercent);
if (!minCoreResult.matched) return minCoreResult;
return checkValueExpectation(durationMs, expect?.durationMs, {
message: "durationMs mismatch",
path: "durationMs",
phase: "duration",
});
}
function formatNumber(value: number): string {
return Number.isInteger(value) ? String(value) : String(Number(value.toFixed(1)));
}
/**
* 等待指定毫秒,支持 AbortSignal 取消。
* 返回 true 表示被中断abortedfalse 表示正常完成。
*/
async function waitForDuration(ms: number, signal: AbortSignal): Promise<boolean> {
if (signal.aborted) return true;
return new Promise<boolean>((resolve) => {
const timer = setTimeout(() => {
signal.removeEventListener("abort", onAbort);
resolve(false);
}, ms);
function onAbort() {
clearTimeout(timer);
resolve(true);
}
signal.addEventListener("abort", onAbort, { once: true });
});
}

View File

@@ -0,0 +1,35 @@
import type { ExpectationResult, ValueExpectation } from "../../expect/types";
import { checkValueExpectation } from "../../expect/value";
export function checkIdlePercent(actual: number, matcher: undefined | ValueExpectation): ExpectationResult {
return checkValueExpectation(actual, matcher, {
message: "CPU 空闲率不满足条件",
path: "idlePercent",
phase: "idle",
});
}
export function checkMaxCoreUsage(actual: number, matcher: undefined | ValueExpectation): ExpectationResult {
return checkValueExpectation(actual, matcher, {
message: "单核心最大使用率不满足条件",
path: "maxCoreUsagePercent",
phase: "maxCoreUsage",
});
}
export function checkMinCoreUsage(actual: number, matcher: undefined | ValueExpectation): ExpectationResult {
return checkValueExpectation(actual, matcher, {
message: "单核心最小使用率不满足条件",
path: "minCoreUsagePercent",
phase: "minCoreUsage",
});
}
export function checkUsagePercent(actual: number, matcher: undefined | ValueExpectation): ExpectationResult {
return checkValueExpectation(actual, matcher, {
message: "CPU 使用率不满足条件",
path: "usagePercent",
phase: "usage",
});
}

View File

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

View File

@@ -0,0 +1,20 @@
import { isPlainObject } from "es-toolkit";
import type { RawTargetConfig } from "../../types";
import { compactExpect, normalizeValue } from "../../expect/normalize";
export function normalizeTargetExpect(target: RawTargetConfig): RawTargetConfig {
if (target.expect === undefined || !isPlainObject(target.expect)) return target;
const raw = target.expect as Record<string, unknown>;
return {
...target,
expect: compactExpect(raw, {
durationMs: normalizeValue(raw["durationMs"]),
idlePercent: normalizeValue(raw["idlePercent"]),
maxCoreUsagePercent: normalizeValue(raw["maxCoreUsagePercent"]),
minCoreUsagePercent: normalizeValue(raw["minCoreUsagePercent"]),
usagePercent: normalizeValue(raw["usagePercent"]),
}),
};
}

View File

@@ -0,0 +1,46 @@
import { Type } from "@sinclair/typebox";
import type { CheckerSchemas } from "../types";
import {
createAuthoringFieldSchema,
createAuthoringValueExpectationSchema,
createNormalizedValueExpectationSchema,
durationSchema,
} from "../../schema/fragments";
export const cpuCheckerSchemas: CheckerSchemas = {
authoring: {
config: createCpuConfigSchema("authoring"),
expect: createCpuExpectSchema("authoring"),
},
normalized: {
config: createCpuConfigSchema("normalized"),
expect: createCpuExpectSchema("normalized"),
},
};
function createCpuConfigSchema(kind: "authoring" | "normalized") {
return Type.Object(
{
includePerCore: Type.Optional(kind === "authoring" ? createAuthoringFieldSchema(Type.Boolean()) : Type.Boolean()),
sampleDuration: Type.Optional(kind === "authoring" ? createAuthoringFieldSchema(durationSchema) : durationSchema),
},
{ additionalProperties: false },
);
}
function createCpuExpectSchema(kind: "authoring" | "normalized") {
const valueSchema =
kind === "authoring" ? createAuthoringValueExpectationSchema() : createNormalizedValueExpectationSchema();
return Type.Object(
{
durationMs: Type.Optional(valueSchema),
idlePercent: Type.Optional(valueSchema),
maxCoreUsagePercent: Type.Optional(valueSchema),
minCoreUsagePercent: Type.Optional(valueSchema),
usagePercent: Type.Optional(valueSchema),
},
{ additionalProperties: false },
);
}

View File

@@ -0,0 +1,59 @@
import type { RawValueExpectation, ValueExpectation } from "../../expect/types";
import type { ResolvedTargetBase } from "../../types";
export interface CpuCoreSnapshot {
times: CpuTimesSnapshot;
}
export interface CpuStats {
idlePercent: number;
logicalCoreCount: number;
maxCoreUsagePercent: number;
minCoreUsagePercent: number;
perCoreUsagePercent: number[];
usagePercent: number;
}
export interface CpuTargetConfig {
includePerCore?: boolean;
sampleDuration?: string;
}
export interface CpuTimesSnapshot {
idle: number;
irq: number;
nice: number;
sys: number;
user: number;
}
export interface RawCpuExpectConfig {
durationMs?: RawValueExpectation;
idlePercent?: RawValueExpectation;
maxCoreUsagePercent?: RawValueExpectation;
minCoreUsagePercent?: RawValueExpectation;
usagePercent?: RawValueExpectation;
}
export interface ResolvedCpuConfig {
includePerCore: boolean;
sampleDurationMs: number;
}
export interface ResolvedCpuExpectConfig {
durationMs?: ValueExpectation;
idlePercent?: ValueExpectation;
maxCoreUsagePercent?: ValueExpectation;
minCoreUsagePercent?: ValueExpectation;
usagePercent?: ValueExpectation;
}
export interface ResolvedCpuTarget extends ResolvedTargetBase {
cpu: ResolvedCpuConfig;
expect?: ResolvedCpuExpectConfig;
group: string;
intervalMs: number;
name: null | string;
timeoutMs: number;
type: "cpu";
}

View File

@@ -0,0 +1,136 @@
import { isString } from "es-toolkit";
import type { ConfigValidationIssue } from "../../schema/issues";
import type { CheckerValidationInput } from "../types";
import { isPlainRecord, validateRawValueExpectation } from "../../expect/validate";
import { issue, joinPath } from "../../schema/issues";
import { parseDuration } from "../../utils";
export function validateCpuConfig(input: CheckerValidationInput): ConfigValidationIssue[] {
const issues: ConfigValidationIssue[] = [];
for (let i = 0; i < input.targets.length; i++) {
const target = input.targets[i] as unknown;
if (!isPlainRecord(target)) continue;
if (target["type"] !== "cpu") continue;
issues.push(...validateCpuTarget(target, `targets[${i}]`));
}
return issues;
}
function getTargetName(target: Record<string, unknown>): string | undefined {
if (isString(target["name"])) return target["name"];
return isString(target["id"]) ? target["id"] : undefined;
}
function validateCpuExpect(target: Record<string, unknown>, path: string): ConfigValidationIssue[] {
const rawExpect = target["expect"];
if (rawExpect === undefined || rawExpect === null || !isPlainRecord(rawExpect)) return [];
const expect = rawExpect;
const issues: ConfigValidationIssue[] = [];
const targetName = getTargetName(target);
const expectPath = joinPath(path, "expect");
const valueFields = ["durationMs", "idlePercent", "maxCoreUsagePercent", "minCoreUsagePercent", "usagePercent"];
for (const key of valueFields) {
if (expect[key] !== undefined) {
issues.push(...validateRawValueExpectation(expect[key], joinPath(expectPath, key), targetName));
}
}
const allowedKeys = new Set(valueFields);
for (const key of Object.keys(expect)) {
if (!allowedKeys.has(key)) {
issues.push(issue("unknown-field", joinPath(expectPath, key), "是未知字段", targetName));
}
}
return issues;
}
function validateCpuTarget(target: Record<string, unknown>, path: string): ConfigValidationIssue[] {
const issues: ConfigValidationIssue[] = [];
const targetName = getTargetName(target);
// 校验 cpu 配置段
const rawCpu = target["cpu"];
if (!isPlainRecord(rawCpu)) {
issues.push(issue("required", joinPath(path, "cpu"), "缺少 cpu 配置分组", targetName));
} else {
// 校验 sampleDuration 格式
if (rawCpu["sampleDuration"] !== undefined) {
const sd = rawCpu["sampleDuration"];
if (isString(sd)) {
try {
parseDuration(sd);
} catch {
issues.push(
issue(
"invalid-value",
joinPath(joinPath(path, "cpu"), "sampleDuration"),
"sampleDuration 不是有效的时长格式",
targetName,
),
);
}
}
// 变量引用时跳过格式校验authoring 形态允许 "${...}"
}
// 校验 sampleDuration < timeout仅当两者都可解析为数值时
if (isString(rawCpu["sampleDuration"])) {
try {
const sampleMs = parseDuration(rawCpu["sampleDuration"]);
const timeout = target["timeout"];
if (isString(timeout)) {
try {
const timeoutMs = parseDuration(timeout);
if (sampleMs >= timeoutMs) {
issues.push(
issue(
"invalid-value",
joinPath(joinPath(path, "cpu"), "sampleDuration"),
"sampleDuration 必须小于 timeout",
targetName,
),
);
}
} catch {
// timeout 无法解析,由通用校验处理
}
}
// timeout 为 undefined 时使用默认值 10s
if (timeout === undefined && sampleMs >= 10000) {
issues.push(
issue(
"invalid-value",
joinPath(joinPath(path, "cpu"), "sampleDuration"),
"sampleDuration 必须小于 timeout默认 10s",
targetName,
),
);
}
} catch {
// sampleDuration 无法解析,已由上方格式校验处理
}
}
if (rawCpu["includePerCore"] !== undefined && typeof rawCpu["includePerCore"] !== "boolean") {
issues.push(issue("invalid-type", joinPath(joinPath(path, "cpu"), "includePerCore"), "必须为布尔值", targetName));
}
const allowedCpuKeys = new Set(["includePerCore", "sampleDuration"]);
for (const key of Object.keys(rawCpu)) {
if (!allowedCpuKeys.has(key)) {
issues.push(issue("unknown-field", joinPath(joinPath(path, "cpu"), key), "是未知字段", targetName));
}
}
}
// 校验 expect 字段
issues.push(...validateCpuExpect(target, path));
return issues;
}

View File

@@ -9,6 +9,7 @@ import { checkContentExpectations } from "../../expect/content";
import { errorFailure } from "../../expect/failure";
import { checkValueExpectation } from "../../expect/value";
import { checkRowCount, checkRows } from "./expect";
import { normalizeTargetExpect } from "./normalize";
import { dbCheckerSchemas } from "./schema";
import { validateDbConfig } from "./validate";
@@ -223,6 +224,10 @@ export class DbChecker implements CheckerDefinition<ResolvedDbTarget> {
}
}
normalize(target: RawTargetConfig): RawTargetConfig {
return normalizeTargetExpect(target);
}
resolve(target: RawTargetConfig, context: ResolveContext): ResolvedDbTarget {
const t = target as RawTargetConfig & { db: DbTargetConfig; type: "db" };

View File

@@ -0,0 +1,19 @@
import { isPlainObject } from "es-toolkit";
import type { RawTargetConfig } from "../../types";
import { compactExpect, normalizeContent, normalizeKeyed, normalizeValue } from "../../expect/normalize";
export function normalizeTargetExpect(target: RawTargetConfig): RawTargetConfig {
if (target.expect === undefined || !isPlainObject(target.expect)) return target;
const raw = target.expect as Record<string, unknown>;
return {
...target,
expect: compactExpect(raw, {
durationMs: normalizeValue(raw["durationMs"]),
result: normalizeContent(raw["result"]),
rowCount: normalizeValue(raw["rowCount"]),
rows: Array.isArray(raw["rows"]) ? raw["rows"].map((row) => normalizeKeyed(row)) : raw["rows"],
}),
};
}

View File

@@ -0,0 +1,440 @@
const OPCODE_MASK = 0x7800;
const RCODE_MASK = 0x000f;
const FLAG_AA = 0x0400;
const FLAG_TC = 0x0200;
const FLAG_RD = 0x0100;
const FLAG_RA = 0x0080;
const FLAG_AD = 0x0020;
const RRTYPE_A = 1;
const RRTYPE_NS = 2;
const RRTYPE_CNAME = 5;
const RRTYPE_SOA = 6;
const RRTYPE_PTR = 12;
const RRTYPE_MX = 15;
const RRTYPE_TXT = 16;
const RRTYPE_AAAA = 28;
const RRTYPE_SRV = 33;
const RRTYPE_CAA = 257;
const RCODE_NAMES: Record<number, string> = {
0: "NOERROR",
1: "FORMERR",
2: "SERVFAIL",
3: "NXDOMAIN",
4: "NOTIMP",
5: "REFUSED",
6: "YXDOMAIN",
7: "YXRRSET",
8: "NXRRSET",
9: "NOTAUTH",
10: "NOTZONE",
};
const RRTYPE_NAMES: Record<number, string> = {
[RRTYPE_A]: "A",
[RRTYPE_AAAA]: "AAAA",
[RRTYPE_CAA]: "CAA",
[RRTYPE_CNAME]: "CNAME",
[RRTYPE_MX]: "MX",
[RRTYPE_NS]: "NS",
[RRTYPE_PTR]: "PTR",
[RRTYPE_SOA]: "SOA",
[RRTYPE_SRV]: "SRV",
[RRTYPE_TXT]: "TXT",
};
const RRTYPE_BY_NAME: Record<string, number> = {
A: RRTYPE_A,
AAAA: RRTYPE_AAAA,
CAA: RRTYPE_CAA,
CNAME: RRTYPE_CNAME,
MX: RRTYPE_MX,
NS: RRTYPE_NS,
PTR: RRTYPE_PTR,
SOA: RRTYPE_SOA,
SRV: RRTYPE_SRV,
TXT: RRTYPE_TXT,
};
const CLASS_IN = 1;
export interface DnsAnswer {
class: number;
data: Record<string, unknown>;
name: string;
ttl: number;
type: number;
value: string;
}
export interface DnsFlags {
authenticatedData: boolean;
authoritative: boolean;
recursionAvailable: boolean;
recursionDesired: boolean;
truncated: boolean;
}
export interface DnsHeader {
additionalCount: number;
answerCount: number;
authorityCount: number;
flags: DnsFlags;
id: number;
opcode: number;
questionCount: number;
rcode: number;
}
export interface DnsQuestion {
name: string;
qclass: number;
qtype: number;
}
export interface DnsResponse {
additional: DnsAnswer[];
answers: DnsAnswer[];
authorities: DnsAnswer[];
header: DnsHeader;
questions: DnsQuestion[];
}
interface ParseContext {
data: Uint8Array;
offset: number;
view: DataView;
}
interface RdataResult extends Record<string, unknown> {
value: string;
}
export function buildQuery(name: string, recordType: number, recursionDesired: boolean): Uint8Array {
const queryName = name.endsWith(".") ? name.slice(0, -1) : name;
const labels = queryName === "" ? [] : queryName.split(".");
const encodedLabels: Uint8Array[] = [];
let nameSize = 1;
for (const label of labels) {
if (label === "") {
throw new Error(`无效的 DNS 名称: ${name}`);
}
if (!isAscii(label)) {
throw new Error(`DNS 名称必须使用 ASCII/Punycode: ${name}`);
}
const encoded = new TextEncoder().encode(label);
if (encoded.length > 63) {
throw new Error(`DNS 标签超过 63 字节: ${label}`);
}
nameSize += 1 + encoded.length;
encodedLabels.push(encoded);
}
if (nameSize > 255) {
throw new Error(`DNS 名称超过 255 字节: ${name}`);
}
const questionSize = nameSize + 4;
const buf = new Uint8Array(12 + questionSize);
const view = new DataView(buf.buffer);
const id = (Math.random() * 65536) | 0;
view.setUint16(0, id);
let flags = 0;
if (recursionDesired) flags |= FLAG_RD;
view.setUint16(2, flags);
view.setUint16(4, 1);
view.setUint16(6, 0);
view.setUint16(8, 0);
view.setUint16(10, 0);
let offset = 12;
for (const encoded of encodedLabels) {
buf[offset] = encoded.length;
offset++;
buf.set(encoded, offset);
offset += encoded.length;
}
buf[offset] = 0;
offset++;
view.setUint16(offset, recordType);
offset += 2;
view.setUint16(offset, CLASS_IN);
return buf;
}
export function parseResponse(data: Uint8Array): DnsResponse {
const view = new DataView(data.buffer, data.byteOffset, data.byteLength);
if (data.byteLength < 12) {
throw new Error(`DNS 响应过短: ${data.byteLength} 字节`);
}
const id = view.getUint16(0);
const flagsWord = view.getUint16(2);
const questionCount = view.getUint16(4);
const answerCount = view.getUint16(6);
const authorityCount = view.getUint16(8);
const additionalCount = view.getUint16(10);
const opcode = (flagsWord & OPCODE_MASK) >> 11;
const rcode = flagsWord & RCODE_MASK;
const flags: DnsFlags = {
authenticatedData: (flagsWord & FLAG_AD) !== 0,
authoritative: (flagsWord & FLAG_AA) !== 0,
recursionAvailable: (flagsWord & FLAG_RA) !== 0,
recursionDesired: (flagsWord & FLAG_RD) !== 0,
truncated: (flagsWord & FLAG_TC) !== 0,
};
const header: DnsHeader = {
additionalCount,
answerCount,
authorityCount,
flags,
id,
opcode,
questionCount,
rcode,
};
const ctx: ParseContext = { data, offset: 12, view };
const questions: DnsQuestion[] = [];
for (let i = 0; i < questionCount; i++) {
const name = readName(ctx);
const qtype = readUint16(ctx);
const qclass = readUint16(ctx);
questions.push({ name, qclass, qtype });
}
const answers = readAnswers(ctx, answerCount);
const authorities = readAnswers(ctx, authorityCount);
const additional = readAnswers(ctx, additionalCount);
return { additional, answers, authorities, header, questions };
}
export function rcodeName(code: number): string {
return RCODE_NAMES[code] ?? `UNKNOWN(${code})`;
}
export function rrtTypeByName(name: string): number {
const code = RRTYPE_BY_NAME[name];
if (code === undefined) throw new Error(`不支持的记录类型: ${name}`);
return code;
}
export function rrtTypeName(code: number): string {
return RRTYPE_NAMES[code] ?? `TYPE${code}`;
}
function isAscii(value: string): boolean {
for (let i = 0; i < value.length; i++) {
if (value.charCodeAt(i) > 0x7f) return false;
}
return true;
}
function normalizeIPv6(groups: string[]): string {
let maxZeroStart = -1;
let maxZeroLen = 0;
let currentStart = -1;
let currentLen = 0;
for (let i = 0; i < 8; i++) {
if (groups[i] === "0") {
if (currentStart === -1) currentStart = i;
currentLen++;
if (currentLen > maxZeroLen) {
maxZeroLen = currentLen;
maxZeroStart = currentStart;
}
} else {
currentStart = -1;
currentLen = 0;
}
}
if (maxZeroLen >= 2) {
const before = groups.slice(0, maxZeroStart).join(":");
const after = groups.slice(maxZeroStart + maxZeroLen).join(":");
if (before === "" && after === "") return "::";
if (before === "") return `::${after}`;
if (after === "") return `${before}::`;
return `${before}::${after}`;
}
return groups.join(":");
}
function parseRdata(ctx: ParseContext, type: number, rdLength: number): RdataResult {
const rdStart = ctx.offset;
const rdEnd = rdStart + rdLength;
switch (type) {
case RRTYPE_A: {
if (rdLength < 4) throw new Error("A 记录 rdata 过短");
const a = ctx.data[rdStart]!;
const b = ctx.data[rdStart + 1]!;
const c = ctx.data[rdStart + 2]!;
const d = ctx.data[rdStart + 3]!;
const address = `${a}.${b}.${c}.${d}`;
return { address, value: address };
}
case RRTYPE_AAAA: {
if (rdLength < 16) throw new Error("AAAA 记录 rdata 过短");
const groups: string[] = [];
for (let i = 0; i < 16; i += 2) {
groups.push(((ctx.data[rdStart + i]! << 8) | ctx.data[rdStart + i + 1]!).toString(16));
}
const address = normalizeIPv6(groups);
return { address, value: address };
}
case RRTYPE_CAA: {
const flags = ctx.data[rdStart]!;
const tagLen = ctx.data[rdStart + 1]!;
const tag = new TextDecoder().decode(ctx.data.subarray(rdStart + 2, rdStart + 2 + tagLen));
const valueBytes = ctx.data.subarray(rdStart + 2 + tagLen, rdEnd);
const caaValue = new TextDecoder().decode(valueBytes);
const value = `${flags} ${tag} ${caaValue}`;
return { flags, tag, value: value, valueStr: caaValue };
}
case RRTYPE_CNAME: {
const target = readName(ctx);
return { target, value: target };
}
case RRTYPE_MX: {
const preference = readUint16(ctx);
const exchange = readName(ctx);
const value = `${preference} ${exchange}`;
return { exchange, preference, value };
}
case RRTYPE_NS: {
const nsdname = readName(ctx);
return { nsdname, value: nsdname };
}
case RRTYPE_PTR: {
const ptrdname = readName(ctx);
return { ptrdname, value: ptrdname };
}
case RRTYPE_SOA: {
const mname = readName(ctx);
const rname = readName(ctx);
const serial = readUint32(ctx);
const refresh = readUint32(ctx);
const retry = readUint32(ctx);
const expire = readUint32(ctx);
const minimum = readUint32(ctx);
const value = `${mname} ${rname} ${serial} ${refresh} ${retry} ${expire} ${minimum}`;
return { expire, minimum, mname, refresh, retry, rname, serial, value };
}
case RRTYPE_SRV: {
const priority = readUint16(ctx);
const weight = readUint16(ctx);
const port = readUint16(ctx);
const target = readName(ctx);
const value = `${priority} ${weight} ${port} ${target}`;
return { port, priority, target, value, weight };
}
case RRTYPE_TXT: {
const texts: string[] = [];
let pos = rdStart;
while (pos < rdEnd) {
const txtLen = ctx.data[pos]!;
pos++;
if (pos + txtLen > rdEnd) break;
texts.push(new TextDecoder().decode(ctx.data.subarray(pos, pos + txtLen)));
pos += txtLen;
}
const fullText = texts.join("");
return { text: fullText, value: fullText };
}
default: {
const raw = Array.from(ctx.data.subarray(rdStart, rdEnd));
const value = raw.map((b) => b.toString(16).padStart(2, "0")).join(" ");
return { raw, value };
}
}
}
function readAnswers(ctx: ParseContext, count: number): DnsAnswer[] {
const answers: DnsAnswer[] = [];
for (let i = 0; i < count; i++) {
const name = readName(ctx);
const type = readUint16(ctx);
const cls = readUint16(ctx);
const ttl = readUint32(ctx);
const rdLength = readUint16(ctx);
const rdStart = ctx.offset;
const data = parseRdata(ctx, type, rdLength);
ctx.offset = rdStart + rdLength;
answers.push({ class: cls, data, name, ttl, type, value: data.value });
}
return answers;
}
function readName(ctx: ParseContext): string {
const parts: string[] = [];
const visited = new Set<number>();
let savedOffset: null | number = null;
let currentOffset = ctx.offset;
while (true) {
if (currentOffset >= ctx.data.byteLength) {
throw new Error("DNS 名称解析越界");
}
const len = ctx.data[currentOffset]!;
if (len === 0) {
currentOffset++;
break;
}
if ((len & 0xc0) === 0xc0) {
if (currentOffset + 1 >= ctx.data.byteLength) {
throw new Error("DNS 压缩指针越界");
}
savedOffset ??= currentOffset + 2;
const ptrOffset = ((len & 0x3f) << 8) | ctx.data[currentOffset + 1]!;
if (visited.has(ptrOffset)) {
throw new Error("DNS 压缩指针循环");
}
visited.add(ptrOffset);
currentOffset = ptrOffset;
continue;
}
currentOffset++;
if (currentOffset + len > ctx.data.byteLength) {
throw new Error("DNS 标签越界");
}
const label = new TextDecoder().decode(ctx.data.subarray(currentOffset, currentOffset + len));
parts.push(label);
currentOffset += len;
}
ctx.offset = savedOffset ?? currentOffset;
return parts.join(".");
}
function readUint16(ctx: ParseContext): number {
const val = ctx.view.getUint16(ctx.offset);
ctx.offset += 2;
return val;
}
function readUint32(ctx: ParseContext): number {
const val = ctx.view.getUint32(ctx.offset);
ctx.offset += 4;
return val;
}

View File

@@ -0,0 +1,906 @@
import { isError } from "es-toolkit";
import type { CheckResult, RawTargetConfig } from "../../types";
import type { CheckerContext, CheckerDefinition, CheckerValidationInput, ResolveContext } from "../types";
import type {
DnsServerConfig,
DnsSystemConfig,
ResolvedDnsServerExpectConfig,
ResolvedDnsSystemExpectConfig,
ResolvedDnsTarget,
} from "./types";
import { errorFailure, mismatchFailure } from "../../expect/failure";
import { checkValueExpectation } from "../../expect/value";
import { parseSize } from "../../utils";
import { buildQuery, rcodeName, rrtTypeByName, rrtTypeName } from "./codec";
import {
checkAnswerCount,
checkDnsValues,
checkFlag,
checkRcode,
checkResponded,
checkResult,
checkTtlMax,
checkTtlMin,
checkValueCount,
} from "./expect";
import { normalizeTargetExpect } from "./normalize";
import { dnsCheckerSchemas } from "./schema";
import { queryDns } from "./transport";
import { validateDnsConfig } from "./validate";
const DEFAULT_MAX_RESPONSE_BYTES = 4096;
const DEFAULT_PORT = 53;
const DEFAULT_RECORD_TYPE = "A";
const DEFAULT_PROTOCOL = "udp";
const DEFAULT_TCP_FALLBACK = true;
const DEFAULT_RECURSION_DESIRED = true;
const DEFAULT_FAMILY = "any";
interface LookupAddress {
address: string;
family: number;
}
type LookupOutcome = { addresses: string[]; ok: true } | { error: string; ok: false };
export class DnsChecker implements CheckerDefinition<ResolvedDnsTarget> {
readonly configKey = "dns";
readonly schemas = dnsCheckerSchemas;
readonly type = "dns";
buildDetail(observation: Record<string, unknown>): null | string {
const resolver = observation["resolver"];
const durationMs = observation["durationMs"];
const duration = typeof durationMs === "number" ? `${durationMs}ms` : "?ms";
if (resolver === "system") {
return buildSystemDetail(observation, duration);
}
return buildServerDetail(observation, duration);
}
async execute(t: ResolvedDnsTarget, ctx: CheckerContext): Promise<CheckResult> {
const timestamp = new Date().toISOString();
const start = performance.now();
try {
if (t.dns.resolver === "system") {
return await this.executeSystem(t, ctx, timestamp, start);
}
return await this.executeServer(t, ctx, timestamp, start);
} catch (error) {
const durationMs = Math.round(performance.now() - start);
return {
detail: null,
durationMs,
failure: errorFailure("query", "query", isError(error) ? error.message : String(error)),
matched: false,
observation: null,
targetId: t.id,
timestamp,
};
}
}
normalize(target: RawTargetConfig): RawTargetConfig {
return normalizeTargetExpect(target);
}
resolve(target: RawTargetConfig, context: ResolveContext): ResolvedDnsTarget {
const dns = target["dns"] as DnsServerConfig | DnsSystemConfig;
if (dns.resolver === "system") {
return this.resolveSystem(target, dns, context);
}
return this.resolveServer(target, dns, context);
}
serialize(t: ResolvedDnsTarget): { config: string; target: string } {
if (t.dns.resolver === "system") {
return {
config: JSON.stringify(t.dns),
target: `dns system ${t.dns.name}`,
};
}
return {
config: JSON.stringify(t.dns),
target: `dns ${t.dns.server}:${t.dns.port} ${t.dns.name}/${t.dns.recordType}`,
};
}
validate(input: CheckerValidationInput) {
return validateDnsConfig(input);
}
private async executeServer(
t: ResolvedDnsTarget,
ctx: CheckerContext,
timestamp: string,
start: number,
): Promise<CheckResult> {
const dns = t.dns as {
maxResponseBytes: number;
name: string;
port: number;
protocol: "tcp" | "udp";
recordType: string;
recursionDesired: boolean;
resolver: "server";
server: string;
tcpFallback: boolean;
};
const expect = t.expect as ResolvedDnsServerExpectConfig | undefined;
const qtype = rrtTypeByName(dns.recordType);
const query = buildQuery(dns.name, qtype, dns.recursionDesired);
const queryResult = await queryDns(dns.server, dns.port, query, {
maxResponseBytes: dns.maxResponseBytes,
protocol: dns.protocol,
signal: ctx.signal,
tcpFallback: dns.tcpFallback,
});
const durationMs = Math.round(performance.now() - start);
if (!queryResult.ok) {
const observation: Record<string, unknown> = {
durationMs,
error: queryResult.error,
name: dns.name,
port: dns.port,
protocol: dns.protocol,
protocolUsed: dns.protocol,
recordType: dns.recordType,
recursionDesired: dns.recursionDesired,
resolver: "server",
responded: false,
server: dns.server,
tcpFallback: dns.tcpFallback,
};
const expectedResponded = expect?.responded ?? true;
if (expectedResponded) {
return {
detail: null,
durationMs,
failure: errorFailure("query", "query", queryResult.error),
matched: false,
observation,
targetId: t.id,
timestamp,
};
}
const durationResult = checkValueExpectation(durationMs, expect?.durationMs, {
message: "durationMs mismatch",
path: "durationMs",
phase: "duration",
});
if (!durationResult.matched) {
return {
detail: null,
durationMs,
failure: durationResult.failure,
matched: false,
observation,
targetId: t.id,
timestamp,
};
}
return {
detail: null,
durationMs,
failure: null,
matched: true,
observation,
targetId: t.id,
timestamp,
};
}
const response = queryResult.response;
const rcode = rcodeName(response.header.rcode);
const protocolUsed = queryResult.protocolUsed;
const allAnswers = response.answers;
const targetAnswers = allAnswers.filter((a) => a.type === qtype);
const values = targetAnswers.map((a) => a.value);
const valueCount = values.length;
const answerCount = allAnswers.length;
let ttlMin: null | number = null;
let ttlMax: null | number = null;
if (allAnswers.length > 0) {
const ttls = allAnswers.map((a) => a.ttl);
ttlMin = Math.min(...ttls);
ttlMax = Math.max(...ttls);
}
const cnameChain: string[] = [];
const finalValues: string[] = [];
if (dns.recordType === "A" || dns.recordType === "AAAA") {
const targetType = dns.recordType === "A" ? 1 : 28;
const seen = new Set<string>();
for (const ans of allAnswers) {
if (ans.type === targetType) {
if (!seen.has(ans.value)) {
finalValues.push(ans.value);
seen.add(ans.value);
}
} else if (ans.type === 5) {
cnameChain.push(ans.value);
}
}
} else {
for (const v of values) {
if (!finalValues.includes(v)) {
finalValues.push(v);
}
}
}
const effectiveValueCount = dns.recordType === "A" || dns.recordType === "AAAA" ? finalValues.length : valueCount;
const effectiveValues = dns.recordType === "A" || dns.recordType === "AAAA" ? finalValues : values;
const observation: Record<string, unknown> = {
additionalCount: response.header.additionalCount,
answerCount,
answers: allAnswers.map((a) => ({
class: a.class === 1 ? "IN" : a.class,
data: a.data,
name: a.name,
ttl: a.ttl,
type: rrtTypeName(a.type),
value: a.value,
})),
authorityCount: response.header.authorityCount,
cnameChain,
durationMs,
error: null,
flags: {
authenticatedData: response.header.flags.authenticatedData,
authoritative: response.header.flags.authoritative,
recursionAvailable: response.header.flags.recursionAvailable,
recursionDesired: response.header.flags.recursionDesired,
truncated: response.header.flags.truncated,
},
name: dns.name,
port: dns.port,
protocol: dns.protocol,
protocolUsed,
rcode,
recordType: dns.recordType,
recursionDesired: dns.recursionDesired,
resolver: "server",
responded: true,
server: dns.server,
tcpFallback: dns.tcpFallback,
ttlMax,
ttlMin,
valueCount: effectiveValueCount,
values: effectiveValues,
};
if (!expect) {
const defaultRcodeOk = rcode === "NOERROR";
const defaultCountOk = effectiveValueCount > 0;
if (!defaultRcodeOk) {
return {
detail: null,
durationMs,
failure: mismatchFailure("rcode", "rcode", "NOERROR", rcode, `DNS 响应码: ${rcode}`),
matched: false,
observation,
targetId: t.id,
timestamp,
};
}
if (!defaultCountOk) {
return {
detail: null,
durationMs,
failure: mismatchFailure("valueCount", "valueCount", "> 0", 0, "DNS 响应成功但无目标记录"),
matched: false,
observation,
targetId: t.id,
timestamp,
};
}
return {
detail: null,
durationMs,
failure: null,
matched: true,
observation,
targetId: t.id,
timestamp,
};
}
const expectedResponded = expect.responded;
const respondedResult = checkResponded(true, expectedResponded);
if (!respondedResult.matched) {
return {
detail: null,
durationMs,
failure: respondedResult.failure,
matched: false,
observation,
targetId: t.id,
timestamp,
};
}
if (expect.rcode) {
const rcodeResult = checkRcode(rcode, expect.rcode);
if (!rcodeResult.matched) {
return {
detail: null,
durationMs,
failure: rcodeResult.failure,
matched: false,
observation,
targetId: t.id,
timestamp,
};
}
}
const hasExplicitNonNoerrorRcode = expect.rcode && !expect.rcode.includes("NOERROR");
if (expect.values) {
const valuesResult = checkDnsValues(effectiveValues, expect.values);
if (!valuesResult.matched) {
return {
detail: null,
durationMs,
failure: valuesResult.failure,
matched: false,
observation,
targetId: t.id,
timestamp,
};
}
}
if (expect.valueCount) {
const countResult = checkValueCount(effectiveValueCount, expect.valueCount);
if (!countResult.matched) {
return {
detail: null,
durationMs,
failure: countResult.failure,
matched: false,
observation,
targetId: t.id,
timestamp,
};
}
} else if (!hasExplicitNonNoerrorRcode && !expect.values) {
if (effectiveValueCount === 0) {
return {
detail: null,
durationMs,
failure: mismatchFailure("valueCount", "valueCount", "> 0", 0, "DNS 响应成功但无目标记录"),
matched: false,
observation,
targetId: t.id,
timestamp,
};
}
}
if (expect.answerCount) {
const countResult = checkAnswerCount(answerCount, expect.answerCount);
if (!countResult.matched) {
return {
detail: null,
durationMs,
failure: countResult.failure,
matched: false,
observation,
targetId: t.id,
timestamp,
};
}
}
if (expect.ttlMin !== undefined) {
if (ttlMin === null) {
return {
detail: null,
durationMs,
failure: mismatchFailure("ttlMin", "ttlMin", "可用 TTL", null, "响应中没有可用于 ttlMin 的 answer"),
matched: false,
observation,
targetId: t.id,
timestamp,
};
}
const ttlResult = checkTtlMin(ttlMin, expect.ttlMin);
if (!ttlResult.matched) {
return {
detail: null,
durationMs,
failure: ttlResult.failure,
matched: false,
observation,
targetId: t.id,
timestamp,
};
}
}
if (expect.ttlMax !== undefined) {
if (ttlMax === null) {
return {
detail: null,
durationMs,
failure: mismatchFailure("ttlMax", "ttlMax", "可用 TTL", null, "响应中没有可用于 ttlMax 的 answer"),
matched: false,
observation,
targetId: t.id,
timestamp,
};
}
const ttlResult = checkTtlMax(ttlMax, expect.ttlMax);
if (!ttlResult.matched) {
return {
detail: null,
durationMs,
failure: ttlResult.failure,
matched: false,
observation,
targetId: t.id,
timestamp,
};
}
}
const authoritativeResult = checkFlag(response.header.flags.authoritative, expect.authoritative, "authoritative");
if (authoritativeResult && !authoritativeResult.matched) {
return {
detail: null,
durationMs,
failure: authoritativeResult.failure,
matched: false,
observation,
targetId: t.id,
timestamp,
};
}
const raResult = checkFlag(
response.header.flags.recursionAvailable,
expect.recursionAvailable,
"recursionAvailable",
);
if (raResult && !raResult.matched) {
return {
detail: null,
durationMs,
failure: raResult.failure,
matched: false,
observation,
targetId: t.id,
timestamp,
};
}
const tcResult = checkFlag(response.header.flags.truncated, expect.truncated, "truncated");
if (tcResult && !tcResult.matched) {
return {
detail: null,
durationMs,
failure: tcResult.failure,
matched: false,
observation,
targetId: t.id,
timestamp,
};
}
const adResult = checkFlag(response.header.flags.authenticatedData, expect.authenticatedData, "authenticatedData");
if (adResult && !adResult.matched) {
return {
detail: null,
durationMs,
failure: adResult.failure,
matched: false,
observation,
targetId: t.id,
timestamp,
};
}
if (expect.result) {
const resultExpectation = checkResult(observation, expect.result);
if (!resultExpectation.matched) {
return {
detail: null,
durationMs,
failure: resultExpectation.failure,
matched: false,
observation,
targetId: t.id,
timestamp,
};
}
}
const durationResult = checkValueExpectation(durationMs, expect.durationMs, {
message: "durationMs mismatch",
path: "durationMs",
phase: "duration",
});
if (!durationResult.matched) {
return {
detail: null,
durationMs,
failure: durationResult.failure,
matched: false,
observation,
targetId: t.id,
timestamp,
};
}
return {
detail: null,
durationMs,
failure: null,
matched: true,
observation,
targetId: t.id,
timestamp,
};
}
private async executeSystem(
t: ResolvedDnsTarget,
ctx: CheckerContext,
timestamp: string,
start: number,
): Promise<CheckResult> {
const dns = t.dns as { family: string; name: string; resolver: "system" };
const expect = t.expect;
try {
const familyOption = dns.family === "ipv4" ? 4 : dns.family === "ipv6" ? 6 : 0;
const result = await lookupWithAbort(dns.name, familyOption, ctx.signal);
const durationMs = Math.round(performance.now() - start);
if (!result.ok) {
const observation: Record<string, unknown> = {
durationMs,
error: result.error,
family: dns.family,
name: dns.name,
resolver: "system",
valueCount: 0,
values: [],
};
const defaultExpect = !expect;
if (defaultExpect) {
return {
detail: null,
durationMs,
failure: errorFailure("resolve", "resolve", result.error),
matched: false,
observation,
targetId: t.id,
timestamp,
};
}
const durationResult = checkValueExpectation(durationMs, expect?.durationMs, {
message: "durationMs mismatch",
path: "durationMs",
phase: "duration",
});
if (!durationResult.matched) {
return {
detail: null,
durationMs,
failure: durationResult.failure,
matched: false,
observation,
targetId: t.id,
timestamp,
};
}
return {
detail: null,
durationMs,
failure: errorFailure("resolve", "resolve", result.error),
matched: false,
observation,
targetId: t.id,
timestamp,
};
}
const values = result.addresses;
const valueCount = values.length;
const observation: Record<string, unknown> = {
durationMs,
error: null,
family: dns.family,
name: dns.name,
resolver: "system",
valueCount,
values,
};
if (!expect) {
const defaultMatched = valueCount > 0;
if (!defaultMatched) {
return {
detail: null,
durationMs,
failure: mismatchFailure("valueCount", "valueCount", "> 0", 0, "解析成功但未返回任何地址"),
matched: false,
observation,
targetId: t.id,
timestamp,
};
}
return {
detail: null,
durationMs,
failure: null,
matched: true,
observation,
targetId: t.id,
timestamp,
};
}
if (expect.values) {
const valuesResult = checkDnsValues(values, expect.values);
if (!valuesResult.matched) {
return {
detail: null,
durationMs,
failure: valuesResult.failure,
matched: false,
observation,
targetId: t.id,
timestamp,
};
}
}
if (expect.valueCount) {
const countResult = checkValueCount(valueCount, expect.valueCount);
if (!countResult.matched) {
return {
detail: null,
durationMs,
failure: countResult.failure,
matched: false,
observation,
targetId: t.id,
timestamp,
};
}
}
const durationResult = checkValueExpectation(durationMs, expect.durationMs, {
message: "durationMs mismatch",
path: "durationMs",
phase: "duration",
});
if (!durationResult.matched) {
return {
detail: null,
durationMs,
failure: durationResult.failure,
matched: false,
observation,
targetId: t.id,
timestamp,
};
}
return {
detail: null,
durationMs,
failure: null,
matched: true,
observation,
targetId: t.id,
timestamp,
};
} catch (error) {
const durationMs = Math.round(performance.now() - start);
return {
detail: null,
durationMs,
failure: errorFailure("resolve", "resolve", isError(error) ? error.message : String(error)),
matched: false,
observation: null,
targetId: t.id,
timestamp,
};
}
}
private resolveServer(t: RawTargetConfig, dns: DnsServerConfig, context: ResolveContext): ResolvedDnsTarget {
const expect = t.expect as ResolvedDnsServerExpectConfig | undefined;
const resolvedExpect: ResolvedDnsServerExpectConfig | undefined = expect
? {
...expect,
responded: expect.responded ?? true,
}
: undefined;
return {
description: null,
dns: {
maxResponseBytes: parseSize(dns.maxResponseBytes ?? DEFAULT_MAX_RESPONSE_BYTES),
name: dns.name,
port: dns.port ?? DEFAULT_PORT,
protocol: dns.protocol ?? DEFAULT_PROTOCOL,
recordType: dns.recordType ?? DEFAULT_RECORD_TYPE,
recursionDesired: dns.recursionDesired ?? DEFAULT_RECURSION_DESIRED,
resolver: "server",
server: dns.server,
tcpFallback: dns.tcpFallback ?? DEFAULT_TCP_FALLBACK,
},
expect: resolvedExpect,
group: t.group ?? "default",
id: t.id,
intervalMs: context.defaultIntervalMs,
name: t.name ?? null,
timeoutMs: context.defaultTimeoutMs,
type: "dns",
} satisfies ResolvedDnsTarget;
}
private resolveSystem(t: RawTargetConfig, dns: DnsSystemConfig, context: ResolveContext): ResolvedDnsTarget {
const expect = t.expect as ResolvedDnsSystemExpectConfig | undefined;
const resolvedExpect: ResolvedDnsSystemExpectConfig | undefined = expect ? { ...expect } : undefined;
return {
description: null,
dns: {
family: dns.family ?? DEFAULT_FAMILY,
name: dns.name,
resolver: "system",
},
expect: resolvedExpect,
group: t.group ?? "default",
id: t.id,
intervalMs: context.defaultIntervalMs,
name: t.name ?? null,
timeoutMs: context.defaultTimeoutMs,
type: "dns",
} satisfies ResolvedDnsTarget;
}
}
function buildServerDetail(observation: Record<string, unknown>, duration: string): null | string {
const responded = observation["responded"];
const error = observation["error"];
const rcode = observation["rcode"];
const protocolUsed = observation["protocolUsed"];
const cnameChain = observation["cnameChain"];
if (responded !== true) {
if (typeof error === "string") {
return `查询失败: ${error} (${duration})`;
}
return `未收到响应 (${duration})`;
}
const rcodeStr = typeof rcode === "string" ? rcode : "UNKNOWN";
const valueCount = observation["valueCount"];
const count = typeof valueCount === "number" ? valueCount : 0;
const values = observation["values"];
const addrs = Array.isArray(values) ? values : [];
const protoStr = typeof protocolUsed === "string" ? protocolUsed.toUpperCase() : "?";
const parts: string[] = [rcodeStr];
if (Array.isArray(cnameChain) && cnameChain.length > 0) {
parts.push(`CNAME: ${cnameChain.join(" → ")}`);
}
if (count > 0) {
const preview = addrs.slice(0, 3).join(", ");
const suffix = count > 3 ? `${count}` : "";
parts.push(`${preview}${suffix}`);
}
parts.push(`${protoStr} ${duration}`);
return parts.join(", ");
}
function buildSystemDetail(observation: Record<string, unknown>, duration: string): null | string {
const error = observation["error"];
const valueCount = observation["valueCount"];
const values = observation["values"];
if (typeof error === "string") {
return `解析失败: ${error} (${duration})`;
}
const count = typeof valueCount === "number" ? valueCount : 0;
const addrs = Array.isArray(values) ? values : [];
if (count === 0) {
return `解析成功但无结果 (${duration})`;
}
const preview = addrs.slice(0, 3).join(", ");
const suffix = count > 3 ? `${count}` : "";
return `${preview}${suffix} (${duration})`;
}
async function lookupWithAbort(hostname: string, family: number, signal: AbortSignal): Promise<LookupOutcome> {
if (signal.aborted) {
return { error: "探测已取消", ok: false };
}
return new Promise<LookupOutcome>((resolve) => {
let settled = false;
const onAbort = () => {
if (settled) return;
settled = true;
resolve({ error: "探测超时", ok: false });
};
signal.addEventListener("abort", onAbort, { once: true });
const onResult = (err: NodeJS.ErrnoException | null, address: LookupAddress[] | string | string[]) => {
if (settled) return;
settled = true;
signal.removeEventListener("abort", onAbort);
if (err) {
resolve({ error: err.message, ok: false });
return;
}
const addresses = Array.isArray(address)
? address.map((item) => (typeof item === "string" ? item : item.address))
: [address];
resolve({ addresses, ok: true });
};
try {
import("node:dns")
.then((dns) => {
if (family === 0) {
dns.lookup(hostname, { all: true }, (err, address) => onResult(err, address));
} else {
dns.lookup(hostname, { all: true, family }, (err, address) => onResult(err, address));
}
})
.catch((e) => {
if (!settled) {
settled = true;
signal.removeEventListener("abort", onAbort);
resolve({ error: e instanceof Error ? e.message : String(e), ok: false });
}
});
} catch (e) {
if (!settled) {
settled = true;
signal.removeEventListener("abort", onAbort);
resolve({ error: e instanceof Error ? e.message : String(e), ok: false });
}
}
});
}

View File

@@ -0,0 +1,120 @@
import type { ContentExpectations, ExpectationResult, ValueExpectation } from "../../expect/types";
import type { DnsValuesExpectation } from "./types";
import { checkContentExpectations } from "../../expect/content";
import { mismatchFailure } from "../../expect/failure";
import { checkValueExpectation } from "../../expect/value";
export function checkAnswerCount(actual: number, matcher: ValueExpectation): ExpectationResult {
return checkValueExpectation(actual, matcher, {
message: "answer 数量不满足条件",
path: "answerCount",
phase: "answerCount",
});
}
export function checkDnsValues(actualValues: string[], expectation: DnsValuesExpectation): ExpectationResult {
const actualSet = new Set(actualValues);
if (expectation.exact) {
const expectedSet = new Set(expectation.exact);
if (actualSet.size !== expectedSet.size || ![...expectedSet].every((v) => actualSet.has(v))) {
return {
failure: mismatchFailure(
"values",
"values",
expectation.exact,
actualValues,
"values 集合不匹配exact 忽略顺序)",
),
matched: false,
};
}
}
if (expectation.include) {
for (const v of expectation.include) {
if (!actualSet.has(v)) {
return {
failure: mismatchFailure("values", "values", `包含 ${v}`, actualValues, `values 缺少期望值: ${v}`),
matched: false,
};
}
}
}
if (expectation.exclude) {
for (const v of expectation.exclude) {
if (actualSet.has(v)) {
return {
failure: mismatchFailure("values", "values", `排除 ${v}`, actualValues, `values 包含排除值: ${v}`),
matched: false,
};
}
}
}
return { failure: null, matched: true };
}
export function checkFlag(actual: boolean, expected: boolean | undefined, name: string): ExpectationResult | null {
if (expected === undefined) return null;
if (actual === expected) return { failure: null, matched: true };
return {
failure: mismatchFailure(name, name, expected, actual, `${name} 不匹配`),
matched: false,
};
}
export function checkRcode(actual: string, expected: string[]): ExpectationResult {
if (expected.includes(actual)) return { failure: null, matched: true };
return {
failure: mismatchFailure("rcode", "rcode", expected.join(", "), actual, `RCODE 不在期望列表中`),
matched: false,
};
}
export function checkResponded(responded: boolean, expected: boolean): ExpectationResult {
if (responded === expected) return { failure: null, matched: true };
if (!responded && expected) {
return {
failure: mismatchFailure("responded", "responded", true, false, "期望收到响应但未收到"),
matched: false,
};
}
return {
failure: mismatchFailure("responded", "responded", false, true, "期望无响应但收到响应"),
matched: false,
};
}
export function checkResult(
observation: Record<string, unknown>,
expectations: ContentExpectations,
): ExpectationResult {
return checkContentExpectations(JSON.stringify(observation), expectations, { path: "result", phase: "result" });
}
export function checkTtlMax(actual: number, matcher: ValueExpectation): ExpectationResult {
return checkValueExpectation(actual, matcher, {
message: "最大 TTL 不满足条件",
path: "ttlMax",
phase: "ttlMax",
});
}
export function checkTtlMin(actual: number, matcher: ValueExpectation): ExpectationResult {
return checkValueExpectation(actual, matcher, {
message: "最小 TTL 不满足条件",
path: "ttlMin",
phase: "ttlMin",
});
}
export function checkValueCount(actual: number, matcher: ValueExpectation): ExpectationResult {
return checkValueExpectation(actual, matcher, {
message: "value 数量不满足条件",
path: "valueCount",
phase: "valueCount",
});
}

View File

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

View File

@@ -0,0 +1,28 @@
import { isPlainObject } from "es-toolkit";
import type { RawTargetConfig } from "../../types";
import { compactExpect, normalizeContent, normalizeValue } from "../../expect/normalize";
export function normalizeTargetExpect(target: RawTargetConfig): RawTargetConfig {
if (target.expect === undefined || !isPlainObject(target.expect)) return target;
const raw = target.expect as Record<string, unknown>;
return {
...target,
expect: compactExpect(raw, {
answerCount: normalizeValue(raw["answerCount"]),
authenticatedData: raw["authenticatedData"],
authoritative: raw["authoritative"],
durationMs: normalizeValue(raw["durationMs"]),
rcode: raw["rcode"],
recursionAvailable: raw["recursionAvailable"],
responded: raw["responded"],
result: normalizeContent(raw["result"]),
truncated: raw["truncated"],
ttlMax: normalizeValue(raw["ttlMax"]),
ttlMin: normalizeValue(raw["ttlMin"]),
valueCount: normalizeValue(raw["valueCount"]),
values: raw["values"],
}),
};
}

View File

@@ -0,0 +1,107 @@
import { Type } from "@sinclair/typebox";
import type { CheckerSchemas } from "../types";
import {
createAuthoringContentExpectationsSchema,
createAuthoringFieldSchema,
createAuthoringValueExpectationSchema,
createNormalizedContentExpectationsSchema,
createNormalizedValueExpectationSchema,
sizeSchema,
} from "../../schema/fragments";
const RECORD_TYPES = ["A", "AAAA", "CAA", "CNAME", "MX", "NS", "PTR", "SOA", "SRV", "TXT"] as const;
export const dnsCheckerSchemas: CheckerSchemas = {
authoring: {
config: createDnsConfigSchema("authoring"),
expect: createDnsExpectSchema("authoring"),
},
normalized: {
config: createDnsConfigSchema("normalized"),
expect: createDnsExpectSchema("normalized"),
},
};
function createDnsConfigSchema(kind: "authoring" | "normalized") {
const recordType = createRecordTypeSchema();
const family = Type.Union([Type.Literal("any"), Type.Literal("ipv4"), Type.Literal("ipv6")]);
const systemFields = {
family: Type.Optional(kind === "authoring" ? createAuthoringFieldSchema(family) : family),
name:
kind === "authoring" ? createAuthoringFieldSchema(Type.String({ minLength: 1 })) : Type.String({ minLength: 1 }),
resolver: Type.Literal("system"),
};
const serverFields = {
maxResponseBytes: Type.Optional(sizeSchema),
name:
kind === "authoring" ? createAuthoringFieldSchema(Type.String({ minLength: 1 })) : Type.String({ minLength: 1 }),
port: Type.Optional(
kind === "authoring"
? createAuthoringFieldSchema(Type.Integer({ maximum: 65535, minimum: 1 }))
: Type.Integer({ maximum: 65535, minimum: 1 }),
),
protocol: Type.Optional(
kind === "authoring"
? createAuthoringFieldSchema(Type.Union([Type.Literal("udp"), Type.Literal("tcp")]))
: Type.Union([Type.Literal("udp"), Type.Literal("tcp")]),
),
recordType: Type.Optional(kind === "authoring" ? createAuthoringFieldSchema(recordType) : recordType),
recursionDesired: Type.Optional(kind === "authoring" ? createAuthoringFieldSchema(Type.Boolean()) : Type.Boolean()),
resolver: Type.Literal("server"),
server:
kind === "authoring" ? createAuthoringFieldSchema(Type.String({ minLength: 1 })) : Type.String({ minLength: 1 }),
tcpFallback: Type.Optional(kind === "authoring" ? createAuthoringFieldSchema(Type.Boolean()) : Type.Boolean()),
};
return Type.Union(
[
Type.Object(systemFields, { additionalProperties: false }),
Type.Object(serverFields, { additionalProperties: false }),
],
{},
);
}
function createDnsExpectSchema(kind: "authoring" | "normalized") {
const valueSchema =
kind === "authoring" ? createAuthoringValueExpectationSchema() : createNormalizedValueExpectationSchema();
const contentSchema =
kind === "authoring" ? createAuthoringContentExpectationsSchema() : createNormalizedContentExpectationsSchema();
const expectFields = {
answerCount: Type.Optional(valueSchema),
authenticatedData: Type.Optional(Type.Boolean()),
authoritative: Type.Optional(Type.Boolean()),
durationMs: Type.Optional(valueSchema),
rcode: Type.Optional(Type.Array(Type.String())),
recursionAvailable: Type.Optional(Type.Boolean()),
responded: Type.Optional(Type.Boolean()),
result: Type.Optional(contentSchema),
truncated: Type.Optional(Type.Boolean()),
ttlMax: Type.Optional(valueSchema),
ttlMin: Type.Optional(valueSchema),
valueCount: Type.Optional(valueSchema),
values: Type.Optional(createDnsValuesExpectationSchema()),
};
return Type.Object(expectFields, { additionalProperties: false });
}
function createDnsValuesExpectationSchema() {
return Type.Object(
{
exact: Type.Optional(Type.Array(Type.String())),
exclude: Type.Optional(Type.Array(Type.String())),
include: Type.Optional(Type.Array(Type.String())),
},
{ additionalProperties: false },
);
}
function createRecordTypeSchema() {
return Type.Union(RECORD_TYPES.map((t) => Type.Literal(t)));
}

View File

@@ -0,0 +1,349 @@
import type { DnsResponse } from "./codec";
import { parseResponse } from "./codec";
export type DnsQueryResult = DnsTransportError | DnsTransportResult;
export interface DnsTransportError {
error: string;
ok: false;
}
export interface DnsTransportResult {
data: Uint8Array;
ok: true;
protocolUsed: "tcp" | "udp";
response: DnsResponse;
}
interface QueryMeta {
id: number;
name: string;
qclass: number;
qtype: number;
}
export async function queryDns(
server: string,
port: number,
query: Uint8Array,
options: {
maxResponseBytes: number;
protocol: "tcp" | "udp";
signal: AbortSignal;
tcpFallback: boolean;
},
): Promise<DnsQueryResult> {
if (options.protocol === "tcp") {
return queryTcp(server, port, query, options.signal, options.maxResponseBytes);
}
const udpResult = await queryUdp(server, port, query, options.signal, options.maxResponseBytes);
if (!udpResult.ok) return udpResult;
if (udpResult.response.header.flags.truncated && options.tcpFallback) {
const tcpResult = await queryTcp(server, port, query, options.signal, options.maxResponseBytes);
if (tcpResult.ok) {
return { ...tcpResult, protocolUsed: "tcp" };
}
return udpResult;
}
return udpResult;
}
function mergeChunks(chunks: Uint8Array[], totalBytes: number): Uint8Array {
const result = new Uint8Array(totalBytes);
let offset = 0;
for (const chunk of chunks) {
result.set(chunk, offset);
offset += chunk.byteLength;
}
return result;
}
function parseAndValidateResponse(query: Uint8Array, payload: Uint8Array, protocolUsed: "tcp" | "udp"): DnsQueryResult {
try {
const response = parseResponse(payload);
const validationError = validateResponseForQuery(query, response);
if (validationError) {
return { error: validationError, ok: false };
}
return { data: payload, ok: true, protocolUsed, response };
} catch (e) {
return { error: `DNS 响应解析失败: ${e instanceof Error ? e.message : String(e)}`, ok: false };
}
}
async function queryTcp(
server: string,
port: number,
query: Uint8Array,
signal: AbortSignal,
maxResponseBytes: number,
): Promise<DnsQueryResult> {
const chunks: Uint8Array[] = [];
let totalBytes = 0;
let settled = false;
let resolver: ((value: DnsQueryResult) => void) | undefined;
const promise = new Promise<DnsQueryResult>((resolve) => {
resolver = resolve;
});
const settle = (result: DnsQueryResult) => {
if (settled) return;
settled = true;
resolver!(result);
};
const socketHandlers: Record<string, (...args: unknown[]) => void> = {
close() {
if (totalBytes >= 2) {
const full = mergeChunks(chunks, totalBytes);
const view = new DataView(full.buffer, full.byteOffset, full.byteLength);
const respLen = view.getUint16(0);
const payloadLen = Math.min(respLen, maxResponseBytes);
if (totalBytes - 2 >= payloadLen) {
if (respLen > maxResponseBytes) {
settle({ error: `TCP 响应超过 ${maxResponseBytes} 字节限制 (${respLen} bytes)`, ok: false });
return;
}
const payload = full.subarray(2, 2 + payloadLen);
settle(parseAndValidateResponse(query, payload, "tcp"));
} else {
settle({ error: `TCP 响应不完整: 期望 ${respLen} 字节,收到 ${totalBytes - 2} 字节`, ok: false });
}
} else {
settle({ error: "TCP 连接关闭,未收到响应", ok: false });
}
},
data(_socket: unknown, data: unknown) {
const buf = data instanceof Uint8Array ? data : new Uint8Array(data as ArrayBuffer);
if (totalBytes + buf.byteLength > maxResponseBytes + 2) {
const trimmed = buf.subarray(0, maxResponseBytes + 2 - totalBytes);
if (trimmed.byteLength > 0) {
chunks.push(new Uint8Array(trimmed));
totalBytes += trimmed.byteLength;
}
} else {
chunks.push(new Uint8Array(buf));
totalBytes += buf.byteLength;
}
if (totalBytes >= 2) {
const full = mergeChunks(chunks, totalBytes);
const view = new DataView(full.buffer, full.byteOffset, full.byteLength);
const respLen = view.getUint16(0);
const payloadLen = Math.min(respLen, maxResponseBytes);
if (totalBytes - 2 >= payloadLen) {
try {
(_socket as { close(): void }).close();
} catch {
/* best-effort */
}
}
}
},
error(_socket: unknown, error: unknown) {
settle({ error: error instanceof Error ? error.message : String(error), ok: false });
},
open() {
// Bun socket handler 必填项,连接成功由 Bun.connect() resolve 表示
},
};
const onAbort = () => {
settle({ error: "探测超时", ok: false });
};
signal.addEventListener("abort", onAbort, { once: true });
try {
const socket = await Bun.connect({
hostname: server,
port,
socket: socketHandlers,
});
if (signal.aborted) {
try {
socket.close();
} catch {
/* best-effort */
}
signal.removeEventListener("abort", onAbort);
return promise;
}
const lengthBuf = new Uint8Array(2);
new DataView(lengthBuf.buffer).setUint16(0, query.byteLength);
socket.write(lengthBuf);
socket.write(query);
const result = await promise;
signal.removeEventListener("abort", onAbort);
try {
socket.close();
} catch {
/* best-effort */
}
return result;
} catch (error) {
signal.removeEventListener("abort", onAbort);
if (signal.aborted) {
return { error: "探测超时", ok: false };
}
const message = error instanceof Error ? error.message : String(error);
return { error: simplifyError(message), ok: false };
}
}
async function queryUdp(
server: string,
port: number,
query: Uint8Array,
signal: AbortSignal,
maxResponseBytes: number,
): Promise<DnsQueryResult> {
try {
const socket = await Bun.udpSocket({
connect: { hostname: server, port },
socket: {
data(socket, data) {
if (data.byteLength > maxResponseBytes) {
settle({ error: `UDP 响应超过 ${maxResponseBytes} 字节限制 (${data.byteLength} bytes)`, type: "error" });
try {
socket.close();
} catch {
/* best-effort */
}
return;
}
settle({ data: new Uint8Array(data.buffer, data.byteOffset, data.byteLength), type: "data" });
try {
socket.close();
} catch {
/* best-effort */
}
},
drain() {
// Bun UDP socket handler 必填项DNS checker 不关注 drain 事件
},
error(_socket, error) {
settle({ error: error.message, type: "error" });
try {
_socket.close();
} catch {
/* best-effort */
}
},
},
});
if (signal.aborted) {
try {
socket.close();
} catch {
/* best-effort */
}
return { error: "探测已取消", ok: false };
}
let settled = false;
let resolver: ((value: { data?: Uint8Array; error?: string; type: string }) => void) | undefined;
const promise = new Promise<{ data?: Uint8Array; error?: string; type: string }>((resolve) => {
resolver = resolve;
});
const settle = (result: { data?: Uint8Array; error?: string; type: string }) => {
if (settled) return;
settled = true;
resolver!(result);
};
const onAbort = () => {
settle({ type: "abort" });
try {
socket.close();
} catch {
/* best-effort */
}
};
signal.addEventListener("abort", onAbort, { once: true });
socket.send(query);
const result = await promise;
signal.removeEventListener("abort", onAbort);
if (result.type === "error") {
return { error: result.error ?? "UDP 查询失败", ok: false };
}
if (result.type === "abort") {
return { error: "探测超时", ok: false };
}
if (!result.data) {
return { error: "未收到 UDP 响应", ok: false };
}
return parseAndValidateResponse(query, result.data, "udp");
} catch (error) {
if (signal.aborted) {
return { error: "探测超时", ok: false };
}
const message = error instanceof Error ? error.message : String(error);
return { error: simplifyError(message), ok: false };
}
}
function readQueryMeta(query: Uint8Array): null | QueryMeta {
if (query.byteLength < 12) return null;
const view = new DataView(query.buffer, query.byteOffset, query.byteLength);
const id = view.getUint16(0);
const labels: string[] = [];
let offset = 12;
while (true) {
if (offset >= query.byteLength) return null;
const len = query[offset]!;
offset++;
if (len === 0) break;
if ((len & 0xc0) !== 0 || offset + len > query.byteLength) return null;
labels.push(new TextDecoder().decode(query.subarray(offset, offset + len)));
offset += len;
}
if (offset + 4 > query.byteLength) return null;
const qtype = view.getUint16(offset);
const qclass = view.getUint16(offset + 2);
return { id, name: labels.join("."), qclass, qtype };
}
function simplifyError(message: string): string {
const lower = message.toLowerCase();
if (lower.includes("econnrefused") || lower.includes("connection refused")) return "connection refused";
if (lower.includes("enoent") || lower.includes("not found")) return "host not found";
if (lower.includes("etimedout") || lower.includes("timed out")) return "timed out";
if (lower.includes("econnreset") || lower.includes("reset")) return "connection reset";
if (lower.includes("enetwork") || lower.includes("network")) return "network error";
return message;
}
function validateResponseForQuery(query: Uint8Array, response: DnsResponse): null | string {
const meta = readQueryMeta(query);
if (!meta) return "DNS 查询报文不完整";
if (response.header.id !== meta.id) {
return `DNS 响应 ID 不匹配: 期望 ${meta.id},实际 ${response.header.id}`;
}
const question = response.questions[0];
if (question && (question.name !== meta.name || question.qtype !== meta.qtype || question.qclass !== meta.qclass)) {
return "DNS 响应 question 与查询不匹配";
}
return null;
}

View File

@@ -0,0 +1,118 @@
import type {
ContentExpectations,
RawContentExpectations,
RawValueExpectation,
ValueExpectation,
} from "../../expect/types";
import type { ResolvedTargetBase } from "../../types";
export type DnsFamily = "any" | "ipv4" | "ipv6";
export type DnsProtocol = "tcp" | "udp";
export type DnsRecordType = "A" | "AAAA" | "CAA" | "CNAME" | "MX" | "NS" | "PTR" | "SOA" | "SRV" | "TXT";
export type DnsResolver = "server" | "system";
export interface DnsServerConfig {
maxResponseBytes?: number | string;
name: string;
port?: number;
protocol?: DnsProtocol;
recordType?: DnsRecordType;
recursionDesired?: boolean;
resolver: "server";
server: string;
tcpFallback?: boolean;
}
export interface DnsSystemConfig {
family?: DnsFamily;
name: string;
resolver: "system";
}
export type DnsTargetConfig = DnsServerConfig | DnsSystemConfig;
export interface DnsValuesExpectation {
exact?: string[];
exclude?: string[];
include?: string[];
}
export interface RawDnsExpectConfig {
answerCount?: RawValueExpectation;
authenticatedData?: boolean;
authoritative?: boolean;
durationMs?: RawValueExpectation;
rcode?: string[];
recursionAvailable?: boolean;
responded?: boolean;
result?: RawContentExpectations;
truncated?: boolean;
ttlMax?: RawValueExpectation;
ttlMin?: RawValueExpectation;
valueCount?: RawValueExpectation;
values?: DnsValuesExpectation;
}
export interface RawDnsServerExpectConfig extends RawDnsExpectConfig {
responded?: boolean;
}
export interface RawDnsSystemExpectConfig {
durationMs?: RawValueExpectation;
valueCount?: RawValueExpectation;
values?: DnsValuesExpectation;
}
export type ResolvedDnsConfig = ResolvedDnsServerConfig | ResolvedDnsSystemConfig;
export type ResolvedDnsExpectConfig = ResolvedDnsServerExpectConfig | ResolvedDnsSystemExpectConfig;
export interface ResolvedDnsServerConfig {
maxResponseBytes: number;
name: string;
port: number;
protocol: DnsProtocol;
recordType: DnsRecordType;
recursionDesired: boolean;
resolver: "server";
server: string;
tcpFallback: boolean;
}
export interface ResolvedDnsServerExpectConfig {
answerCount?: ValueExpectation;
authenticatedData?: boolean;
authoritative?: boolean;
durationMs?: ValueExpectation;
rcode?: string[];
recursionAvailable?: boolean;
responded: boolean;
result?: ContentExpectations;
truncated?: boolean;
ttlMax?: ValueExpectation;
ttlMin?: ValueExpectation;
valueCount?: ValueExpectation;
values?: DnsValuesExpectation;
}
export interface ResolvedDnsSystemConfig {
family: DnsFamily;
name: string;
resolver: "system";
}
export interface ResolvedDnsSystemExpectConfig {
durationMs?: ValueExpectation;
valueCount?: ValueExpectation;
values?: DnsValuesExpectation;
}
export interface ResolvedDnsTarget extends ResolvedTargetBase {
dns: ResolvedDnsConfig;
expect?: ResolvedDnsExpectConfig;
group: string;
intervalMs: number;
name: null | string;
timeoutMs: number;
type: "dns";
}

View File

@@ -0,0 +1,351 @@
import { isNumber, isString } from "es-toolkit";
import type { ConfigValidationIssue } from "../../schema/issues";
import type { CheckerValidationInput } from "../types";
import { isPlainRecord, validateRawContentExpectations, validateRawValueExpectation } from "../../expect/validate";
import { issue, joinPath } from "../../schema/issues";
import { parseSize } from "../../utils";
const VALID_RECORD_TYPES = new Set(["A", "AAAA", "CAA", "CNAME", "MX", "NS", "PTR", "SOA", "SRV", "TXT"]);
const VALID_FAMILIES = new Set(["any", "ipv4", "ipv6"]);
const VALID_PROTOCOLS = new Set(["tcp", "udp"]);
const VALID_RCODES = new Set([
"FORMERR",
"NOERROR",
"NOTAUTH",
"NOTIMP",
"NOTZONE",
"NXDOMAIN",
"NXRRSET",
"REFUSED",
"SERVFAIL",
"YXDOMAIN",
"YXRRSET",
]);
const SYSTEM_EXPECT_KEYS = new Set(["durationMs", "valueCount", "values"]);
const SERVER_EXPECT_KEYS = new Set([
"answerCount",
"authenticatedData",
"authoritative",
"durationMs",
"rcode",
"recursionAvailable",
"responded",
"result",
"truncated",
"ttlMax",
"ttlMin",
"valueCount",
"values",
]);
const RESPONSE_EXPECT_KEYS = new Set([
"answerCount",
"authenticatedData",
"authoritative",
"rcode",
"recursionAvailable",
"result",
"truncated",
"ttlMax",
"ttlMin",
"valueCount",
"values",
]);
export function validateDnsConfig(input: CheckerValidationInput): ConfigValidationIssue[] {
const issues: ConfigValidationIssue[] = [];
for (let i = 0; i < input.targets.length; i++) {
const target = input.targets[i] as unknown;
if (!isPlainRecord(target)) continue;
if (target["type"] !== "dns") continue;
issues.push(...validateDnsTarget(target, `targets[${i}]`));
}
return issues;
}
function getTargetName(target: Record<string, unknown>): string | undefined {
if (isString(target["name"])) return target["name"];
return isString(target["id"]) ? target["id"] : undefined;
}
function validateDnsExpect(target: Record<string, unknown>, path: string, resolver: 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");
const allowedKeys = resolver === "system" ? SYSTEM_EXPECT_KEYS : SERVER_EXPECT_KEYS;
for (const key of Object.keys(expect)) {
if (!allowedKeys.has(key)) {
if (resolver === "system") {
issues.push(
issue(
"dns-unsupported-expect",
joinPath(expectPath, key),
`不支持在 dns.resolver: system 下使用system 模式仅支持 expect.values、expect.valueCount、expect.durationMs。请改用 resolver: server 或移除该字段`,
targetName,
),
);
} else {
issues.push(issue("unknown-field", joinPath(expectPath, key), "是未知字段", targetName));
}
}
}
if (expect["values"] !== undefined) {
issues.push(...validateDnsValuesExpectation(expect["values"], joinPath(expectPath, "values"), targetName));
}
if (expect["valueCount"] !== undefined) {
issues.push(...validateRawValueExpectation(expect["valueCount"], joinPath(expectPath, "valueCount"), targetName));
}
if (expect["durationMs"] !== undefined) {
issues.push(...validateRawValueExpectation(expect["durationMs"], joinPath(expectPath, "durationMs"), targetName));
}
if (resolver === "server") {
if (expect["responded"] !== undefined && typeof expect["responded"] !== "boolean") {
issues.push(issue("invalid-type", joinPath(expectPath, "responded"), "必须为布尔值", targetName));
}
if (expect["responded"] === false) {
for (const key of Object.keys(expect)) {
if (RESPONSE_EXPECT_KEYS.has(key)) {
issues.push(
issue(
"invalid-value",
joinPath(expectPath, "responded"),
"响应内容或协议断言需要 expect.responded 为 true",
targetName,
),
);
break;
}
}
}
if (expect["rcode"] !== undefined) {
if (!Array.isArray(expect["rcode"])) {
issues.push(issue("invalid-type", joinPath(expectPath, "rcode"), "必须为字符串数组", targetName));
} else {
const rcodeArray = expect["rcode"] as unknown[];
for (let j = 0; j < rcodeArray.length; j++) {
const rcode = rcodeArray[j];
if (!isString(rcode) || !VALID_RCODES.has(rcode)) {
issues.push(
issue(
"invalid-value",
joinPath(expectPath, `rcode[${j}]`),
`必须是有效的 RCODE如 NOERROR、NXDOMAIN、SERVFAIL`,
targetName,
),
);
}
}
}
}
if (expect["answerCount"] !== undefined) {
issues.push(
...validateRawValueExpectation(expect["answerCount"], joinPath(expectPath, "answerCount"), targetName),
);
}
if (expect["ttlMin"] !== undefined) {
issues.push(...validateRawValueExpectation(expect["ttlMin"], joinPath(expectPath, "ttlMin"), targetName));
}
if (expect["ttlMax"] !== undefined) {
issues.push(...validateRawValueExpectation(expect["ttlMax"], joinPath(expectPath, "ttlMax"), targetName));
}
if (expect["authoritative"] !== undefined && typeof expect["authoritative"] !== "boolean") {
issues.push(issue("invalid-type", joinPath(expectPath, "authoritative"), "必须为布尔值", targetName));
}
if (expect["recursionAvailable"] !== undefined && typeof expect["recursionAvailable"] !== "boolean") {
issues.push(issue("invalid-type", joinPath(expectPath, "recursionAvailable"), "必须为布尔值", targetName));
}
if (expect["truncated"] !== undefined && typeof expect["truncated"] !== "boolean") {
issues.push(issue("invalid-type", joinPath(expectPath, "truncated"), "必须为布尔值", targetName));
}
if (expect["authenticatedData"] !== undefined && typeof expect["authenticatedData"] !== "boolean") {
issues.push(issue("invalid-type", joinPath(expectPath, "authenticatedData"), "必须为布尔值", targetName));
}
if (expect["result"] !== undefined) {
issues.push(...validateRawContentExpectations(expect["result"], joinPath(expectPath, "result"), targetName));
}
}
return issues;
}
function validateDnsTarget(target: Record<string, unknown>, path: string): ConfigValidationIssue[] {
const issues: ConfigValidationIssue[] = [];
const targetName = getTargetName(target);
const dns = target["dns"];
if (!isPlainRecord(dns)) {
issues.push(issue("required", joinPath(path, "dns"), "缺少 dns 配置分组", targetName));
return issues;
}
const resolver: unknown = dns["resolver"];
if (resolver === undefined) {
issues.push(issue("required", joinPath(joinPath(path, "dns"), "resolver"), "缺少 dns.resolver 字段", targetName));
return issues;
}
if (resolver !== "system" && resolver !== "server") {
issues.push(
issue("invalid-value", joinPath(joinPath(path, "dns"), "resolver"), "必须为 system 或 server", targetName),
);
return issues;
}
if (!isString(dns["name"]) || dns["name"].trim() === "") {
issues.push(issue("required", joinPath(joinPath(path, "dns"), "name"), "缺少 dns.name 字段", targetName));
}
if (resolver === "system") {
const family: unknown = dns["family"];
if (family !== undefined && (!isString(family) || !VALID_FAMILIES.has(family))) {
issues.push(
issue("invalid-value", joinPath(joinPath(path, "dns"), "family"), "必须为 any、ipv4 或 ipv6", targetName),
);
}
const allowedSystemKeys = new Set(["family", "name", "resolver"]);
for (const key of Object.keys(dns)) {
if (!allowedSystemKeys.has(key)) {
issues.push(issue("unknown-field", joinPath(joinPath(path, "dns"), key), "是未知字段", targetName));
}
}
}
if (resolver === "server") {
if (!isString(dns["server"]) || dns["server"].trim() === "") {
issues.push(issue("required", joinPath(joinPath(path, "dns"), "server"), "缺少 dns.server 字段", targetName));
}
const port: unknown = dns["port"];
if (port !== undefined && (!isNumber(port) || !Number.isInteger(port) || port < 1 || port > 65535)) {
issues.push(
issue("invalid-value", joinPath(joinPath(path, "dns"), "port"), "必须为 1-65535 之间的整数", targetName),
);
}
const protocol: unknown = dns["protocol"];
if (protocol !== undefined && (!isString(protocol) || !VALID_PROTOCOLS.has(protocol))) {
issues.push(issue("invalid-value", joinPath(joinPath(path, "dns"), "protocol"), "必须为 udp 或 tcp", targetName));
}
const recordType: unknown = dns["recordType"];
if (recordType !== undefined && (!isString(recordType) || !VALID_RECORD_TYPES.has(recordType))) {
issues.push(
issue(
"invalid-value",
joinPath(joinPath(path, "dns"), "recordType"),
"必须为 A、AAAA、CAA、CNAME、MX、NS、PTR、SOA、SRV 或 TXT",
targetName,
),
);
}
issues.push(
...validateSize(dns["maxResponseBytes"], joinPath(joinPath(path, "dns"), "maxResponseBytes"), targetName),
);
if (dns["recursionDesired"] !== undefined && typeof dns["recursionDesired"] !== "boolean") {
issues.push(
issue("invalid-type", joinPath(joinPath(path, "dns"), "recursionDesired"), "必须为布尔值", targetName),
);
}
if (dns["tcpFallback"] !== undefined && typeof dns["tcpFallback"] !== "boolean") {
issues.push(issue("invalid-type", joinPath(joinPath(path, "dns"), "tcpFallback"), "必须为布尔值", targetName));
}
const allowedServerKeys = new Set([
"maxResponseBytes",
"name",
"port",
"protocol",
"recordType",
"recursionDesired",
"resolver",
"server",
"tcpFallback",
]);
for (const key of Object.keys(dns)) {
if (!allowedServerKeys.has(key)) {
issues.push(issue("unknown-field", joinPath(joinPath(path, "dns"), key), "是未知字段", targetName));
}
}
}
issues.push(...validateDnsExpect(target, path, resolver));
return issues;
}
function validateDnsValuesExpectation(
value: unknown,
path: string,
targetName: string | undefined,
): ConfigValidationIssue[] {
if (value === undefined || value === null) return [];
if (!isPlainRecord(value)) {
return [issue("invalid-type", path, "必须为包含 include/exclude/exact 的对象", targetName)];
}
const issues: ConfigValidationIssue[] = [];
const allowedKeys = new Set(["exact", "exclude", "include"]);
for (const key of Object.keys(value)) {
if (!allowedKeys.has(key)) {
issues.push(issue("unknown-field", joinPath(path, key), "是未知字段", targetName));
}
}
if (value["exact"] !== undefined) {
if (!Array.isArray(value["exact"])) {
issues.push(issue("invalid-type", joinPath(path, "exact"), "必须为字符串数组", targetName));
}
}
if (value["include"] !== undefined) {
if (!Array.isArray(value["include"])) {
issues.push(issue("invalid-type", joinPath(path, "include"), "必须为字符串数组", targetName));
}
}
if (value["exclude"] !== undefined) {
if (!Array.isArray(value["exclude"])) {
issues.push(issue("invalid-type", joinPath(path, "exclude"), "必须为字符串数组", targetName));
}
}
return issues;
}
function validateSize(value: unknown, path: string, targetName: string | undefined): ConfigValidationIssue[] {
if (value === undefined) return [];
if (!isString(value) && !isNumber(value)) {
return [issue("invalid-value", path, "必须为合法 size 值", targetName)];
}
try {
parseSize(value);
return [];
} catch {
return [issue("invalid-value", path, "必须为合法 size 值", targetName)];
}
}

View File

@@ -10,6 +10,7 @@ import { checkHeaderExpectations } from "../../expect/headers";
import { checkStatusCode } from "../../expect/status";
import { checkValueExpectation, displayValueExpectation } from "../../expect/value";
import { parseSize } from "../../utils";
import { normalizeTargetExpect } from "./normalize";
import { httpCheckerSchemas } from "./schema";
import { validateHttpConfig } from "./validate";
@@ -172,6 +173,10 @@ export class HttpChecker implements CheckerDefinition<ResolvedHttpTarget> {
}
}
normalize(target: RawTargetConfig): RawTargetConfig {
return normalizeTargetExpect(target);
}
resolve(target: RawTargetConfig, context: ResolveContext): ResolvedHttpTarget {
const t = target as RawTargetConfig & { http: HttpTargetConfig; type: "http" };

View File

@@ -0,0 +1,19 @@
import { isPlainObject } from "es-toolkit";
import type { RawTargetConfig } from "../../types";
import { compactExpect, normalizeContent, normalizeKeyed, normalizeValue } from "../../expect/normalize";
export function normalizeTargetExpect(target: RawTargetConfig): RawTargetConfig {
if (target.expect === undefined || !isPlainObject(target.expect)) return target;
const raw = target.expect as Record<string, unknown>;
return {
...target,
expect: compactExpect(raw, {
body: normalizeContent(raw["body"]),
durationMs: normalizeValue(raw["durationMs"]),
headers: normalizeKeyed(raw["headers"]),
status: raw["status"],
}),
};
}

View File

@@ -8,6 +8,7 @@ import { errorFailure } from "../../expect/failure";
import { checkValueExpectation } from "../../expect/value";
import { buildPingCommand } from "./command";
import { checkAlive, checkAvgLatency, checkMaxLatency, checkPacketLoss } from "./expect";
import { normalizeTargetExpect } from "./normalize";
import { parsePingOutput } from "./parse";
import { icmpCheckerSchemas } from "./schema";
import { validatePingConfig } from "./validate";
@@ -153,6 +154,10 @@ export class IcmpChecker implements CheckerDefinition<ResolvedPingTarget> {
};
}
normalize(target: RawTargetConfig): RawTargetConfig {
return normalizeTargetExpect(target);
}
resolve(target: RawTargetConfig, context: ResolveContext): ResolvedPingTarget {
const t = target as RawTargetConfig & { icmp: PingTargetConfig; type: "icmp" };

View File

@@ -0,0 +1,20 @@
import { isPlainObject } from "es-toolkit";
import type { RawTargetConfig } from "../../types";
import { compactExpect, normalizeValue } from "../../expect/normalize";
export function normalizeTargetExpect(target: RawTargetConfig): RawTargetConfig {
if (target.expect === undefined || !isPlainObject(target.expect)) return target;
const raw = target.expect as Record<string, unknown>;
return {
...target,
expect: compactExpect(raw, {
alive: raw["alive"],
avgLatencyMs: normalizeValue(raw["avgLatencyMs"]),
durationMs: normalizeValue(raw["durationMs"]),
maxLatencyMs: normalizeValue(raw["maxLatencyMs"]),
packetLossPercent: normalizeValue(raw["packetLossPercent"]),
}),
};
}

View File

@@ -1,11 +1,15 @@
import { CommandChecker } from "./cmd";
import { CpuChecker } from "./cpu";
import { DbChecker } from "./db";
import { DnsChecker } from "./dns";
import { HttpChecker } from "./http";
import { IcmpChecker } from "./icmp";
import { LlmChecker } from "./llm";
import { MemChecker } from "./mem";
import { CheckerRegistry } from "./registry";
import { TcpChecker } from "./tcp";
import { UdpChecker } from "./udp";
import { WsChecker } from "./ws";
const checkers = [
new HttpChecker(),
@@ -15,6 +19,10 @@ const checkers = [
new IcmpChecker(),
new UdpChecker(),
new LlmChecker(),
new DnsChecker(),
new WsChecker(),
new CpuChecker(),
new MemChecker(),
];
export function createDefaultCheckerRegistry(): CheckerRegistry {

View File

@@ -8,6 +8,7 @@ import type { LlmTargetConfig, ResolvedLlmExpectConfig, ResolvedLlmTarget } from
import { errorFailure } from "../../expect/failure";
import { checkValueExpectation } from "../../expect/value";
import { runExpects } from "./expect";
import { normalizeTargetExpect } from "./normalize";
import {
buildObservationFromApiCallError,
buildObservationFromGenerateText,
@@ -127,6 +128,10 @@ export class LlmChecker implements CheckerDefinition<ResolvedLlmTarget> {
}
}
normalize(target: RawTargetConfig): RawTargetConfig {
return normalizeTargetExpect(target);
}
resolve(target: RawTargetConfig, context: ResolveContext): ResolvedLlmTarget {
const t = target as RawTargetConfig & { llm: LlmTargetConfig; type: "llm" };

View File

@@ -0,0 +1,34 @@
import { isPlainObject } from "es-toolkit";
import type { RawTargetConfig } from "../../types";
import { compactExpect, normalizeContent, normalizeKeyed, normalizeValue } from "../../expect/normalize";
export function normalizeTargetExpect(target: RawTargetConfig): RawTargetConfig {
if (target.expect === undefined || !isPlainObject(target.expect)) return target;
const raw = target.expect as Record<string, unknown>;
return {
...target,
expect: compactExpect(raw, {
durationMs: normalizeValue(raw["durationMs"]),
finishReason: normalizeValue(raw["finishReason"]),
headers: normalizeKeyed(raw["headers"]),
output: normalizeContent(raw["output"]),
rawFinishReason: normalizeValue(raw["rawFinishReason"]),
status: raw["status"],
stream: isPlainObject(raw["stream"])
? compactExpect(raw["stream"] as Record<string, unknown>, {
completed: (raw["stream"] as Record<string, unknown>)["completed"],
firstTokenMs: normalizeValue((raw["stream"] as Record<string, unknown>)["firstTokenMs"]),
})
: raw["stream"],
usage: isPlainObject(raw["usage"])
? compactExpect(raw["usage"] as Record<string, unknown>, {
inputTokens: normalizeValue((raw["usage"] as Record<string, unknown>)["inputTokens"]),
outputTokens: normalizeValue((raw["usage"] as Record<string, unknown>)["outputTokens"]),
totalTokens: normalizeValue((raw["usage"] as Record<string, unknown>)["totalTokens"]),
})
: raw["usage"],
}),
};
}

View File

@@ -0,0 +1,60 @@
import type { Systeminformation } from "systeminformation";
import type { MemStats } from "./types";
export function calculateMemStats(data: Systeminformation.MemData): MemStats {
const totalBytes = data.total;
const usedBytes = data.used;
const activeBytes = data.active;
const availableBytes = data.available;
const freeBytes = data.free;
const usagePercent = totalBytes > 0 ? round1((activeBytes / totalBytes) * 100) : 0;
const usedPercent = totalBytes > 0 ? round1((usedBytes / totalBytes) * 100) : 0;
const freePercent = totalBytes > 0 ? round1((freeBytes / totalBytes) * 100) : 0;
const activePercent = totalBytes > 0 ? round1((activeBytes / totalBytes) * 100) : 0;
const availablePercent = totalBytes > 0 ? round1((availableBytes / totalBytes) * 100) : 0;
const swapTotalBytes = data.swaptotal;
const swapUsedBytes = data.swapused;
const swapFreeBytes = data.swapfree;
const swapUsagePercent = resolveSwapUsagePercent(swapTotalBytes, swapUsedBytes);
const buffcacheBytes = resolveNullableNumber(data.buffcache);
return {
activeBytes,
activePercent,
availableBytes,
availablePercent,
buffcacheBytes,
freeBytes,
freePercent,
swapFreeBytes: resolveNullableNumber(swapFreeBytes),
swapTotalBytes: resolveNullableNumber(swapTotalBytes),
swapUsagePercent,
swapUsedBytes: resolveNullableNumber(swapUsedBytes),
totalBytes,
usagePercent,
usedBytes,
usedPercent,
};
}
export async function readMemData(): Promise<Systeminformation.MemData> {
const si = await import("systeminformation");
return si.mem();
}
function resolveNullableNumber(value: number): null | number {
return value > 0 ? value : value === 0 ? 0 : null;
}
function resolveSwapUsagePercent(swapTotal: number, swapUsed: number): null | number {
if (swapTotal === 0) return null;
return round1((swapUsed / swapTotal) * 100);
}
function round1(value: number): number {
return Math.round(value * 10) / 10;
}

View File

@@ -0,0 +1,232 @@
import type { Systeminformation } from "systeminformation";
import type { CheckResult, RawTargetConfig } from "../../types";
import type { CheckerContext, CheckerDefinition, CheckerValidationInput, ResolveContext } from "../types";
import type { MemStats, ResolvedMemExpectConfig, ResolvedMemTarget } from "./types";
import { errorFailure } from "../../expect/failure";
import { checkValueExpectation } from "../../expect/value";
import { calculateMemStats, readMemData } from "./calculate";
import {
checkActiveBytes,
checkActivePercent,
checkAvailableBytes,
checkAvailablePercent,
checkBuffcacheBytes,
checkFreeBytes,
checkFreePercent,
checkSwapFreeBytes,
checkSwapTotalBytes,
checkSwapUsagePercent,
checkSwapUsedBytes,
checkTotalBytes,
checkUsagePercent,
checkUsedBytes,
checkUsedPercent,
} from "./expect";
import { normalizeTargetExpect } from "./normalize";
import { memCheckerSchemas } from "./schema";
import { validateMemConfig } from "./validate";
export class MemChecker implements CheckerDefinition<ResolvedMemTarget> {
readonly configKey = "mem";
readonly schemas = memCheckerSchemas;
readonly type = "mem";
constructor(private readonly reader: () => Promise<Systeminformation.MemData> = readMemData) {}
buildDetail(observation: Record<string, unknown>): null | string {
const usage = observation["usagePercent"];
const usageStr = typeof usage === "number" ? formatNumber(usage) : "n/a";
const total = observation["totalBytes"];
const totalStr = typeof total === "number" ? formatBytes(total) : "n/a";
return `usage ${usageStr}%, total ${totalStr}`;
}
async execute(t: ResolvedMemTarget, ctx: CheckerContext): Promise<CheckResult> {
const timestamp = new Date().toISOString();
const start = performance.now();
if (ctx.signal.aborted) {
const durationMs = Math.round(performance.now() - start);
return {
detail: null,
durationMs,
failure: errorFailure("mem", "timeout", "内存读取超时signal 已取消"),
matched: false,
observation: null,
targetId: t.id,
timestamp,
};
}
let data: Systeminformation.MemData;
try {
data = await raceWithSignal(this.reader(), ctx.signal);
} catch (error) {
const durationMs = Math.round(performance.now() - start);
const isTimeout =
error instanceof AbortError || (error instanceof Error && error.message === MEM_TIMEOUT_MESSAGE);
return {
detail: null,
durationMs,
failure: isTimeout
? errorFailure("mem", "timeout", "内存读取超时")
: errorFailure(
"mem",
"snapshot",
`内存数据读取失败: ${error instanceof Error ? error.message : String(error)}`,
),
matched: false,
observation: null,
targetId: t.id,
timestamp,
};
}
const durationMs = Math.round(performance.now() - start);
const stats = calculateMemStats(data);
const result = checkStats(stats, t.expect, durationMs);
const observation: Record<string, unknown> = {
activeBytes: stats.activeBytes,
activePercent: stats.activePercent,
availableBytes: stats.availableBytes,
availablePercent: stats.availablePercent,
buffcacheBytes: stats.buffcacheBytes,
error: null,
freeBytes: stats.freeBytes,
freePercent: stats.freePercent,
swapFreeBytes: stats.swapFreeBytes,
swapTotalBytes: stats.swapTotalBytes,
swapUsagePercent: stats.swapUsagePercent,
swapUsedBytes: stats.swapUsedBytes,
totalBytes: stats.totalBytes,
usagePercent: stats.usagePercent,
usedBytes: stats.usedBytes,
usedPercent: stats.usedPercent,
};
return {
detail: null,
durationMs,
failure: result.failure,
matched: result.matched,
observation,
targetId: t.id,
timestamp,
};
}
normalize(target: RawTargetConfig): RawTargetConfig {
return normalizeTargetExpect(target);
}
resolve(target: RawTargetConfig, context: ResolveContext): ResolvedMemTarget {
return {
description: null,
expect: target.expect as ResolvedMemExpectConfig | undefined,
group: target.group ?? "default",
id: target.id,
intervalMs: context.defaultIntervalMs,
mem: {},
name: target.name ?? null,
timeoutMs: context.defaultTimeoutMs,
type: "mem",
} satisfies ResolvedMemTarget;
}
serialize(t: ResolvedMemTarget): { config: string; target: string } {
return {
config: JSON.stringify(t.mem),
target: `mem`,
};
}
validate(input: CheckerValidationInput) {
return validateMemConfig(input);
}
}
function checkStats(stats: MemStats, expect: ResolvedMemExpectConfig | undefined, durationMs: number) {
let result = checkUsagePercent(stats.usagePercent, expect?.usagePercent);
if (!result.matched) return result;
result = checkUsedPercent(stats.usedPercent, expect?.usedPercent);
if (!result.matched) return result;
result = checkFreePercent(stats.freePercent, expect?.freePercent);
if (!result.matched) return result;
result = checkActivePercent(stats.activePercent, expect?.activePercent);
if (!result.matched) return result;
result = checkAvailablePercent(stats.availablePercent, expect?.availablePercent);
if (!result.matched) return result;
result = checkActiveBytes(stats.activeBytes, expect?.activeBytes);
if (!result.matched) return result;
result = checkUsedBytes(stats.usedBytes, expect?.usedBytes);
if (!result.matched) return result;
result = checkFreeBytes(stats.freeBytes, expect?.freeBytes);
if (!result.matched) return result;
result = checkAvailableBytes(stats.availableBytes, expect?.availableBytes);
if (!result.matched) return result;
result = checkTotalBytes(stats.totalBytes, expect?.totalBytes);
if (!result.matched) return result;
result = checkSwapUsagePercent(stats.swapUsagePercent, expect?.swapUsagePercent);
if (!result.matched) return result;
result = checkSwapUsedBytes(stats.swapUsedBytes, expect?.swapUsedBytes);
if (!result.matched) return result;
result = checkSwapFreeBytes(stats.swapFreeBytes, expect?.swapFreeBytes);
if (!result.matched) return result;
result = checkSwapTotalBytes(stats.swapTotalBytes, expect?.swapTotalBytes);
if (!result.matched) return result;
result = checkBuffcacheBytes(stats.buffcacheBytes, expect?.buffcacheBytes);
if (!result.matched) return result;
return checkValueExpectation(durationMs, expect?.durationMs, {
message: "durationMs mismatch",
path: "durationMs",
phase: "duration",
});
}
function formatBytes(bytes: number): string {
if (bytes >= 1073741824) return `${formatNumber(bytes / 1073741824)}GB`;
if (bytes >= 1048576) return `${formatNumber(bytes / 1048576)}MB`;
if (bytes >= 1024) return `${formatNumber(bytes / 1024)}KB`;
return `${bytes}B`;
}
function formatNumber(value: number): string {
return Number.isInteger(value) ? String(value) : String(Number(value.toFixed(1)));
}
const MEM_TIMEOUT_MESSAGE = "Memory read aborted by signal";
class AbortError extends Error {
constructor() {
super(MEM_TIMEOUT_MESSAGE);
this.name = "AbortError";
}
}
function raceWithSignal<T>(promise: Promise<T>, signal: AbortSignal): Promise<T> {
if (signal.aborted) return Promise.reject(new AbortError());
return new Promise<T>((resolve, reject) => {
function onAbort() {
reject(new AbortError());
}
signal.addEventListener("abort", onAbort, { once: true });
promise.then(
(value) => {
signal.removeEventListener("abort", onAbort);
resolve(value);
},
(error: unknown) => {
signal.removeEventListener("abort", onAbort);
reject(error instanceof Error ? error : new Error(String(error)));
},
);
});
}

View File

@@ -0,0 +1,123 @@
import type { ExpectationResult, ValueExpectation } from "../../expect/types";
import { checkValueExpectation } from "../../expect/value";
export function checkActiveBytes(actual: number, matcher: undefined | ValueExpectation): ExpectationResult {
return checkValueExpectation(actual, matcher, {
message: "内存活跃字节数不满足条件",
path: "activeBytes",
phase: "activeBytes",
});
}
export function checkActivePercent(actual: number, matcher: undefined | ValueExpectation): ExpectationResult {
return checkValueExpectation(actual, matcher, {
message: "内存活跃百分比不满足条件",
path: "activePercent",
phase: "active",
});
}
export function checkAvailableBytes(actual: number, matcher: undefined | ValueExpectation): ExpectationResult {
return checkValueExpectation(actual, matcher, {
message: "内存可用字节数不满足条件",
path: "availableBytes",
phase: "availableBytes",
});
}
export function checkAvailablePercent(actual: number, matcher: undefined | ValueExpectation): ExpectationResult {
return checkValueExpectation(actual, matcher, {
message: "内存可用百分比不满足条件",
path: "availablePercent",
phase: "available",
});
}
export function checkBuffcacheBytes(actual: null | number, matcher: undefined | ValueExpectation): ExpectationResult {
return checkValueExpectation(actual, matcher, {
message: "缓冲缓存字节数不满足条件",
path: "buffcacheBytes",
phase: "buffcacheBytes",
});
}
export function checkFreeBytes(actual: number, matcher: undefined | ValueExpectation): ExpectationResult {
return checkValueExpectation(actual, matcher, {
message: "内存空闲字节数不满足条件",
path: "freeBytes",
phase: "freeBytes",
});
}
export function checkFreePercent(actual: number, matcher: undefined | ValueExpectation): ExpectationResult {
return checkValueExpectation(actual, matcher, {
message: "内存空闲百分比不满足条件",
path: "freePercent",
phase: "free",
});
}
export function checkSwapFreeBytes(actual: null | number, matcher: undefined | ValueExpectation): ExpectationResult {
return checkValueExpectation(actual, matcher, {
message: "交换空间空闲字节数不满足条件",
path: "swapFreeBytes",
phase: "swapFreeBytes",
});
}
export function checkSwapTotalBytes(actual: null | number, matcher: undefined | ValueExpectation): ExpectationResult {
return checkValueExpectation(actual, matcher, {
message: "交换空间总字节数不满足条件",
path: "swapTotalBytes",
phase: "swapTotalBytes",
});
}
export function checkSwapUsagePercent(actual: null | number, matcher: undefined | ValueExpectation): ExpectationResult {
return checkValueExpectation(actual, matcher, {
message: "交换空间使用率不满足条件",
path: "swapUsagePercent",
phase: "swapUsage",
});
}
export function checkSwapUsedBytes(actual: null | number, matcher: undefined | ValueExpectation): ExpectationResult {
return checkValueExpectation(actual, matcher, {
message: "交换空间已用字节数不满足条件",
path: "swapUsedBytes",
phase: "swapUsedBytes",
});
}
export function checkTotalBytes(actual: number, matcher: undefined | ValueExpectation): ExpectationResult {
return checkValueExpectation(actual, matcher, {
message: "内存总字节数不满足条件",
path: "totalBytes",
phase: "totalBytes",
});
}
export function checkUsagePercent(actual: number, matcher: undefined | ValueExpectation): ExpectationResult {
return checkValueExpectation(actual, matcher, {
message: "内存使用率不满足条件",
path: "usagePercent",
phase: "usage",
});
}
export function checkUsedBytes(actual: number, matcher: undefined | ValueExpectation): ExpectationResult {
return checkValueExpectation(actual, matcher, {
message: "内存已用字节数不满足条件",
path: "usedBytes",
phase: "usedBytes",
});
}
export function checkUsedPercent(actual: number, matcher: undefined | ValueExpectation): ExpectationResult {
return checkValueExpectation(actual, matcher, {
message: "内存已用百分比不满足条件",
path: "usedPercent",
phase: "used",
});
}

View File

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

View File

@@ -0,0 +1,72 @@
import { isNumber, isPlainObject, isString } from "es-toolkit";
import type { RawTargetConfig } from "../../types";
import { compactExpect, normalizeValue } from "../../expect/normalize";
import { parseSize } from "../../utils";
const BYTE_FIELDS = new Set([
"activeBytes",
"availableBytes",
"buffcacheBytes",
"freeBytes",
"swapFreeBytes",
"swapTotalBytes",
"swapUsedBytes",
"totalBytes",
"usedBytes",
]);
const PERCENT_FIELDS = new Set([
"activePercent",
"availablePercent",
"freePercent",
"swapUsagePercent",
"usagePercent",
"usedPercent",
]);
export function normalizeByteValue(value: unknown): unknown {
if (value === undefined) return undefined;
if (isString(value)) {
const parsed = parseSize(value);
return normalizeValue(parsed);
}
if (isNumber(value)) {
return normalizeValue(value);
}
if (isPlainObject(value)) {
const obj = value as Record<string, unknown>;
const converted: Record<string, unknown> = {};
for (const [k, v] of Object.entries(obj)) {
converted[k] = isString(v) ? parseSize(v) : v;
}
return normalizeValue(converted);
}
return normalizeValue(value);
}
export function normalizeTargetExpect(target: RawTargetConfig): RawTargetConfig {
if (target.expect === undefined || !isPlainObject(target.expect)) return target;
const raw = target.expect as Record<string, unknown>;
return {
...target,
expect: compactExpect(raw, normalizeAllFields(raw)),
};
}
function normalizeAllFields(raw: Record<string, unknown>): Record<string, unknown> {
const result: Record<string, unknown> = {};
for (const key of BYTE_FIELDS) {
result[key] = normalizeByteValue(raw[key]);
}
for (const key of PERCENT_FIELDS) {
result[key] = normalizeValue(raw[key]);
}
result["durationMs"] = normalizeValue(raw["durationMs"]);
return result;
}

View File

@@ -0,0 +1,55 @@
import { Type } from "@sinclair/typebox";
import type { CheckerSchemas } from "../types";
import {
createAuthoringFieldSchema,
createAuthoringValueExpectationSchema,
createNormalizedValueExpectationSchema,
sizeSchema,
} from "../../schema/fragments";
export const memCheckerSchemas: CheckerSchemas = {
authoring: {
config: createMemConfigSchema("authoring"),
expect: createMemExpectSchema("authoring"),
},
normalized: {
config: createMemConfigSchema("normalized"),
expect: createMemExpectSchema("normalized"),
},
};
function createMemConfigSchema(_kind: "authoring" | "normalized") {
return Type.Object({}, { additionalProperties: false });
}
function createMemExpectSchema(kind: "authoring" | "normalized") {
const valueSchema =
kind === "authoring" ? createAuthoringValueExpectationSchema() : createNormalizedValueExpectationSchema();
const byteValueSchema =
kind === "authoring" ? createAuthoringFieldSchema(sizeSchema) : createNormalizedValueExpectationSchema();
return Type.Object(
{
activeBytes: Type.Optional(byteValueSchema),
activePercent: Type.Optional(valueSchema),
availableBytes: Type.Optional(byteValueSchema),
availablePercent: Type.Optional(valueSchema),
buffcacheBytes: Type.Optional(byteValueSchema),
durationMs: Type.Optional(valueSchema),
freeBytes: Type.Optional(byteValueSchema),
freePercent: Type.Optional(valueSchema),
swapFreeBytes: Type.Optional(byteValueSchema),
swapTotalBytes: Type.Optional(byteValueSchema),
swapUsagePercent: Type.Optional(valueSchema),
swapUsedBytes: Type.Optional(byteValueSchema),
totalBytes: Type.Optional(byteValueSchema),
usagePercent: Type.Optional(valueSchema),
usedBytes: Type.Optional(byteValueSchema),
usedPercent: Type.Optional(valueSchema),
},
{ additionalProperties: false },
);
}

View File

@@ -0,0 +1,75 @@
import type { Systeminformation } from "systeminformation";
import type { RawValueExpectation, ValueExpectation } from "../../expect/types";
import type { ResolvedTargetBase } from "../../types";
export type MemDataReader = () => Promise<Systeminformation.MemData>;
export interface MemStats {
activeBytes: number;
activePercent: number;
availableBytes: number;
availablePercent: number;
buffcacheBytes: null | number;
freeBytes: number;
freePercent: number;
swapFreeBytes: null | number;
swapTotalBytes: null | number;
swapUsagePercent: null | number;
swapUsedBytes: null | number;
totalBytes: number;
usagePercent: number;
usedBytes: number;
usedPercent: number;
}
export interface RawMemExpectConfig {
activeBytes?: RawValueExpectation;
activePercent?: RawValueExpectation;
availableBytes?: RawValueExpectation;
availablePercent?: RawValueExpectation;
buffcacheBytes?: RawValueExpectation;
durationMs?: RawValueExpectation;
freeBytes?: RawValueExpectation;
freePercent?: RawValueExpectation;
swapFreeBytes?: RawValueExpectation;
swapTotalBytes?: RawValueExpectation;
swapUsagePercent?: RawValueExpectation;
swapUsedBytes?: RawValueExpectation;
totalBytes?: RawValueExpectation;
usagePercent?: RawValueExpectation;
usedBytes?: RawValueExpectation;
usedPercent?: RawValueExpectation;
}
// eslint-disable-next-line @typescript-eslint/no-empty-object-type
export interface ResolvedMemConfig {}
export interface ResolvedMemExpectConfig {
activeBytes?: ValueExpectation;
activePercent?: ValueExpectation;
availableBytes?: ValueExpectation;
availablePercent?: ValueExpectation;
buffcacheBytes?: ValueExpectation;
durationMs?: ValueExpectation;
freeBytes?: ValueExpectation;
freePercent?: ValueExpectation;
swapFreeBytes?: ValueExpectation;
swapTotalBytes?: ValueExpectation;
swapUsagePercent?: ValueExpectation;
swapUsedBytes?: ValueExpectation;
totalBytes?: ValueExpectation;
usagePercent?: ValueExpectation;
usedBytes?: ValueExpectation;
usedPercent?: ValueExpectation;
}
export interface ResolvedMemTarget extends ResolvedTargetBase {
expect?: ResolvedMemExpectConfig;
group: string;
intervalMs: number;
mem: ResolvedMemConfig;
name: null | string;
timeoutMs: number;
type: "mem";
}

View File

@@ -0,0 +1,111 @@
import { isString } from "es-toolkit";
import type { ConfigValidationIssue } from "../../schema/issues";
import type { CheckerValidationInput } from "../types";
import { isPlainRecord, validateRawValueExpectation } from "../../expect/validate";
import { issue, joinPath } from "../../schema/issues";
import { parseSize } from "../../utils";
const MEM_CONFIG_KEYS = new Set<string>([]);
const MEM_EXPECT_FIELDS = [
"activeBytes",
"activePercent",
"availableBytes",
"availablePercent",
"buffcacheBytes",
"durationMs",
"freeBytes",
"freePercent",
"swapFreeBytes",
"swapTotalBytes",
"swapUsagePercent",
"swapUsedBytes",
"totalBytes",
"usagePercent",
"usedBytes",
"usedPercent",
] as const;
const BYTE_EXPECT_FIELDS = new Set([
"activeBytes",
"availableBytes",
"buffcacheBytes",
"freeBytes",
"swapFreeBytes",
"swapTotalBytes",
"swapUsedBytes",
"totalBytes",
"usedBytes",
]);
const MEM_EXPECT_KEYS = new Set<string>(MEM_EXPECT_FIELDS);
export function validateMemConfig(input: CheckerValidationInput): ConfigValidationIssue[] {
const issues: ConfigValidationIssue[] = [];
for (let i = 0; i < input.targets.length; i++) {
const target = input.targets[i] as unknown;
if (!isPlainRecord(target)) continue;
if (target["type"] !== "mem") continue;
issues.push(...validateMemTarget(target, `targets[${i}]`));
}
return issues;
}
function getTargetName(target: Record<string, unknown>): string | undefined {
if (isString(target["name"])) return target["name"];
return isString(target["id"]) ? target["id"] : undefined;
}
function validateMemExpect(target: Record<string, unknown>, path: string): ConfigValidationIssue[] {
const rawExpect = target["expect"];
if (rawExpect === undefined || rawExpect === null || !isPlainRecord(rawExpect)) return [];
const expect = rawExpect;
const issues: ConfigValidationIssue[] = [];
const targetName = getTargetName(target);
const expectPath = joinPath(path, "expect");
for (const key of MEM_EXPECT_FIELDS) {
if (expect[key] !== undefined) {
issues.push(...validateRawValueExpectation(expect[key], joinPath(expectPath, key), targetName));
if (BYTE_EXPECT_FIELDS.has(key) && isString(expect[key])) {
try {
parseSize(expect[key]);
} catch {
issues.push(issue("invalid-value", joinPath(expectPath, key), "不是有效的字节大小格式", targetName));
}
}
}
}
for (const key of Object.keys(expect)) {
if (!MEM_EXPECT_KEYS.has(key)) {
issues.push(issue("unknown-field", joinPath(expectPath, key), "是未知字段", targetName));
}
}
return issues;
}
function validateMemTarget(target: Record<string, unknown>, path: string): ConfigValidationIssue[] {
const issues: ConfigValidationIssue[] = [];
const targetName = getTargetName(target);
const rawMem = target["mem"];
if (!isPlainRecord(rawMem)) {
issues.push(issue("required", joinPath(path, "mem"), "缺少 mem 配置分组", targetName));
} else {
for (const key of Object.keys(rawMem)) {
if (!MEM_CONFIG_KEYS.has(key)) {
issues.push(issue("unknown-field", joinPath(joinPath(path, "mem"), key), "是未知字段", targetName));
}
}
}
issues.push(...validateMemExpect(target, path));
return issues;
}

View File

@@ -8,6 +8,7 @@ import { errorFailure } from "../../expect/failure";
import { checkValueExpectation } from "../../expect/value";
import { parseSize } from "../../utils";
import { checkBanner, checkConnected } from "./expect";
import { normalizeTargetExpect } from "./normalize";
import { tcpCheckerSchemas } from "./schema";
import { validateTcpConfig } from "./validate";
@@ -203,6 +204,10 @@ export class TcpChecker implements CheckerDefinition<ResolvedTcpTarget> {
}
}
normalize(target: RawTargetConfig): RawTargetConfig {
return normalizeTargetExpect(target);
}
resolve(target: RawTargetConfig, context: ResolveContext): ResolvedTcpTarget {
const t = target as RawTargetConfig & { tcp: TcpTargetConfig; type: "tcp" };

View File

@@ -0,0 +1,18 @@
import { isPlainObject } from "es-toolkit";
import type { RawTargetConfig } from "../../types";
import { compactExpect, normalizeContent, normalizeValue } from "../../expect/normalize";
export function normalizeTargetExpect(target: RawTargetConfig): RawTargetConfig {
if (target.expect === undefined || !isPlainObject(target.expect)) return target;
const raw = target.expect as Record<string, unknown>;
return {
...target,
expect: compactExpect(raw, {
banner: normalizeContent(raw["banner"]),
connected: raw["connected"],
durationMs: normalizeValue(raw["durationMs"]),
}),
};
}

View File

@@ -13,6 +13,7 @@ export interface CheckerDefinition<TResolved extends ResolvedTargetBase = Resolv
buildDetail(observation: Record<string, unknown>): null | string;
readonly configKey: string;
execute(target: TResolved, ctx: CheckerContext): Promise<CheckResult>;
normalize(target: RawTargetConfig): RawTargetConfig;
resolve(target: RawTargetConfig, context: ResolveContext): TResolved;
readonly schemas: CheckerSchemas;
serialize(target: TResolved): { config: string; target: string };

View File

@@ -9,6 +9,7 @@ import { checkValueExpectation } from "../../expect/value";
import { parseSize } from "../../utils";
import { decodePayload, encodeResponse } from "./encoding";
import { checkResponded, checkResponseSize, checkResponseText, checkSourceHost, checkSourcePort } from "./expect";
import { normalizeTargetExpect } from "./normalize";
import { udpCheckerSchemas } from "./schema";
import { validateUdpConfig } from "./validate";
@@ -295,6 +296,10 @@ export class UdpChecker implements CheckerDefinition<ResolvedUdpTarget> {
}
}
normalize(target: RawTargetConfig): RawTargetConfig {
return normalizeTargetExpect(target);
}
resolve(target: RawTargetConfig, context: ResolveContext): ResolvedUdpTarget {
const t = target as RawTargetConfig & { type: "udp"; udp: UdpTargetConfig };

View File

@@ -0,0 +1,21 @@
import { isPlainObject } from "es-toolkit";
import type { RawTargetConfig } from "../../types";
import { compactExpect, normalizeContent, normalizeValue } from "../../expect/normalize";
export function normalizeTargetExpect(target: RawTargetConfig): RawTargetConfig {
if (target.expect === undefined || !isPlainObject(target.expect)) return target;
const raw = target.expect as Record<string, unknown>;
return {
...target,
expect: compactExpect(raw, {
durationMs: normalizeValue(raw["durationMs"]),
responded: raw["responded"],
response: normalizeContent(raw["response"]),
responseSize: normalizeValue(raw["responseSize"]),
sourceHost: normalizeValue(raw["sourceHost"]),
sourcePort: normalizeValue(raw["sourcePort"]),
}),
};
}

View File

@@ -0,0 +1,533 @@
import { isError } from "es-toolkit";
import type { CheckResult, RawTargetConfig } from "../../types";
import type { CheckerContext, CheckerDefinition, CheckerValidationInput, ResolveContext } from "../types";
import type { ResolvedWsExpectConfig, ResolvedWsTarget, WsTargetConfig } from "./types";
import { errorFailure } from "../../expect/failure";
import { checkValueExpectation } from "../../expect/value";
import { parseSize } from "../../utils";
import { checkConnected, checkHandshakeHeaders, checkMessage } from "./expect";
import { normalizeTargetExpect } from "./normalize";
import { wsCheckerSchemas } from "./schema";
import { validateWsConfig } from "./validate";
const DEFAULT_MAX_MESSAGE_BYTES = 4096;
const DEFAULT_RECEIVE_TIMEOUT = 5000;
type MessageReceiveResult = { data: string; ok: true; size: number } | { error: string; ok: false };
type WsConnectResult = { error: string; ok: false } | { headers: Record<string, string>; ok: true; ws: WebSocket };
export class WsChecker implements CheckerDefinition<ResolvedWsTarget> {
readonly configKey = "ws";
readonly schemas = wsCheckerSchemas;
readonly type = "ws";
buildDetail(observation: Record<string, unknown>): null | string {
const connected = observation["connected"];
if (connected !== true) {
const error = observation["error"];
return typeof error === "string" ? `connection failed: ${error}` : "not connected";
}
const connectTimeMs = observation["connectTimeMs"];
const message = observation["message"];
const parts: string[] = [`connected in ${typeof connectTimeMs === "number" ? connectTimeMs : "?"}ms`];
if (typeof message === "string" && message.length > 0) {
parts.push(`message: ${truncateMessage(message)}`);
}
return parts.join(", ");
}
async execute(t: ResolvedWsTarget, ctx: CheckerContext): Promise<CheckResult> {
const timestamp = new Date().toISOString();
const start = performance.now();
const expect = t.expect;
try {
const connectResult = await wsConnect(t.ws, ctx.signal);
if (!connectResult.ok) {
const durationMs = Math.round(performance.now() - start);
const observation: Record<string, unknown> = {
connected: false,
connectTimeMs: null,
error: connectResult.error,
message: null,
messageSize: null,
};
if (expect?.connected === false) {
return {
detail: null,
durationMs,
failure: null,
matched: true,
observation,
targetId: t.id,
timestamp,
};
}
return {
detail: null,
durationMs,
failure: errorFailure("connect", "connect", connectResult.error),
matched: false,
observation,
targetId: t.id,
timestamp,
};
}
const ws = connectResult.ws;
const connectTimeMs = Math.round(performance.now() - start);
const handshakeHeaders = connectResult.headers;
if (ctx.signal.aborted) {
closeWs(ws);
const durationMs = Math.round(performance.now() - start);
return {
detail: null,
durationMs,
failure: errorFailure("connect", "connect", `连接超时 (${t.timeoutMs}ms)`),
matched: false,
observation: null,
targetId: t.id,
timestamp,
};
}
const expectedConnected = expect?.connected ?? true;
const connectedResult = checkConnected(true, expectedConnected);
if (!connectedResult.matched) {
closeWs(ws);
const durationMs = Math.round(performance.now() - start);
return {
detail: null,
durationMs,
failure: connectedResult.failure,
matched: false,
observation: {
connected: true,
connectTimeMs,
error: null,
handshakeHeaders,
message: null,
messageSize: null,
},
targetId: t.id,
timestamp,
};
}
if (expect?.handshakeHeaders) {
const headersResult = checkHandshakeHeaders(handshakeHeaders, expect.handshakeHeaders);
if (!headersResult.matched) {
closeWs(ws);
const durationMs = Math.round(performance.now() - start);
return {
detail: null,
durationMs,
failure: headersResult.failure,
matched: false,
observation: {
connected: true,
connectTimeMs,
error: null,
handshakeHeaders,
message: null,
messageSize: null,
},
targetId: t.id,
timestamp,
};
}
}
let messageText: null | string = null;
let messageSize: null | number = null;
if (t.ws.send) {
const messageResult: MessageReceiveResult = await wsSendAndReceive(
ws,
t.ws.send,
t.ws.receiveTimeout,
t.ws.maxMessageBytes,
ctx.signal,
);
if (!messageResult.ok) {
closeWs(ws);
const durationMs = Math.round(performance.now() - start);
const observation: Record<string, unknown> = {
connected: true,
connectTimeMs,
error: messageResult.error,
handshakeHeaders,
message: null,
messageSize: null,
};
return {
detail: null,
durationMs,
failure: errorFailure("message", "message", messageResult.error),
matched: false,
observation,
targetId: t.id,
timestamp,
};
}
messageText = truncateMessageForObservation(messageResult.data);
messageSize = messageResult.size;
if (expect?.message) {
const msgCheck = checkMessage(messageResult.data, expect.message);
if (!msgCheck.matched) {
closeWs(ws);
const durationMs = Math.round(performance.now() - start);
return {
detail: null,
durationMs,
failure: msgCheck.failure,
matched: false,
observation: {
connected: true,
connectTimeMs,
error: null,
handshakeHeaders,
message: messageText,
messageSize,
},
targetId: t.id,
timestamp,
};
}
}
}
closeWs(ws);
const observation: Record<string, unknown> = {
connected: true,
connectTimeMs,
error: null,
handshakeHeaders,
message: messageText,
messageSize,
};
if (expect?.connectTimeMs) {
const ctResult = checkValueExpectation(connectTimeMs, expect.connectTimeMs, {
message: "connectTimeMs mismatch",
path: "connectTimeMs",
phase: "connect",
});
if (!ctResult.matched) {
const durationMs = Math.round(performance.now() - start);
return {
detail: null,
durationMs,
failure: ctResult.failure,
matched: false,
observation,
targetId: t.id,
timestamp,
};
}
}
const durationMs = Math.round(performance.now() - start);
const durationResult = checkValueExpectation(durationMs, expect?.durationMs, {
message: "durationMs mismatch",
path: "durationMs",
phase: "duration",
});
if (!durationResult.matched) {
return {
detail: null,
durationMs,
failure: durationResult.failure,
matched: false,
observation,
targetId: t.id,
timestamp,
};
}
return {
detail: null,
durationMs,
failure: null,
matched: true,
observation,
targetId: t.id,
timestamp,
};
} catch (error) {
const durationMs = Math.round(performance.now() - start);
return {
detail: null,
durationMs,
failure: errorFailure(
"connect",
"connect",
ctx.signal.aborted ? `连接超时 (${t.timeoutMs}ms)` : isError(error) ? error.message : String(error),
),
matched: false,
observation: null,
targetId: t.id,
timestamp,
};
}
}
normalize(target: RawTargetConfig): RawTargetConfig {
return normalizeTargetExpect(target);
}
resolve(target: RawTargetConfig, context: ResolveContext): ResolvedWsTarget {
const t = target as RawTargetConfig & { type: "ws"; ws: WsTargetConfig };
const maxMessageBytes = parseSize(t.ws.maxMessageBytes ?? DEFAULT_MAX_MESSAGE_BYTES);
const receiveTimeout = t.ws.receiveTimeout ?? DEFAULT_RECEIVE_TIMEOUT;
const expect = target.expect as ResolvedWsExpectConfig | undefined;
const resolvedExpect: ResolvedWsExpectConfig = expect
? { ...expect, connected: expect.connected ?? true }
: { connected: true };
return {
description: null,
expect: resolvedExpect,
group: target.group ?? "default",
id: t.id,
intervalMs: context.defaultIntervalMs,
name: t.name ?? null,
timeoutMs: context.defaultTimeoutMs,
type: "ws",
ws: {
headers: { ...(t.ws.headers ?? {}) },
ignoreSSL: t.ws.ignoreSSL ?? false,
maxMessageBytes,
receiveTimeout,
send: t.ws.send,
subprotocols: t.ws.subprotocols ?? [],
url: t.ws.url,
},
} satisfies ResolvedWsTarget;
}
serialize(t: ResolvedWsTarget): { config: string; target: string } {
return {
config: JSON.stringify({
headers: t.ws.headers,
ignoreSSL: t.ws.ignoreSSL,
maxMessageBytes: t.ws.maxMessageBytes,
receiveTimeout: t.ws.receiveTimeout,
send: t.ws.send,
subprotocols: t.ws.subprotocols,
url: t.ws.url,
}),
target: t.ws.url,
};
}
validate(input: CheckerValidationInput) {
return validateWsConfig(input);
}
}
function closeWs(ws: WebSocket) {
try {
if (ws.readyState === WebSocket.OPEN || ws.readyState === WebSocket.CONNECTING) {
ws.close();
}
} catch {
/* best-effort close */
}
}
function simplifyConnectError(message: string): string {
const lower = message.toLowerCase();
if (lower.includes("econnrefused") || lower.includes("connection refused")) return "connection refused";
if (lower.includes("enoent") || lower.includes("not found")) return "host not found";
if (lower.includes("etimedout") || lower.includes("timed out")) return "connection timed out";
if (lower.includes("econnreset") || lower.includes("reset")) return "connection reset";
if (lower.includes("enetwork") || lower.includes("network")) return "network error";
if (lower.includes("certificate") || lower.includes("cert") || lower.includes("ssl") || lower.includes("tls")) {
return "tls error: certificate verification failed";
}
if (lower.includes("401") || lower.includes("unauthorized")) return "handshake failed: unauthorized (401)";
if (lower.includes("403") || lower.includes("forbidden")) return "handshake failed: forbidden (403)";
if (lower.includes("404") || lower.includes("not found")) return "handshake failed: not found (404)";
if (lower.includes("handshake") || lower.includes("upgrade")) return `handshake failed: ${message}`;
return message;
}
function truncateMessage(message: string, maxLen = 80): string {
if (message.length <= maxLen) return message;
return `${message.slice(0, maxLen)}`;
}
function truncateMessageForObservation(message: string, maxLen = 256): string {
if (message.length <= maxLen) return message;
return message.slice(0, maxLen);
}
async function wsConnect(config: ResolvedWsTarget["ws"], signal: AbortSignal): Promise<WsConnectResult> {
if (signal.aborted) {
return { error: "连接已取消", ok: false };
}
let settled = false;
let resolveFn: ((result: WsConnectResult) => void) | undefined;
const connectPromise = new Promise<WsConnectResult>((resolve) => {
resolveFn = resolve;
});
const settle = (result: WsConnectResult) => {
if (settled) return;
settled = true;
resolveFn!(result);
};
try {
const wsOptions: Bun.WebSocketOptions = {};
if (Object.keys(config.headers).length > 0) {
(wsOptions as Record<string, unknown>)["headers"] = config.headers;
}
if (config.ignoreSSL) {
(wsOptions as Record<string, unknown>)["tls"] = { rejectUnauthorized: false };
}
if (config.subprotocols.length > 0) {
(wsOptions as Record<string, unknown>)["protocols"] = config.subprotocols;
}
const ws = new WebSocket(config.url, wsOptions as never);
const onAbort = () => {
settle({ error: "连接超时", ok: false });
closeWs(ws);
};
signal.addEventListener("abort", onAbort, { once: true });
ws.addEventListener("open", () => {
signal.removeEventListener("abort", onAbort);
const headers: Record<string, string> = {};
if (ws.protocol) {
headers["sec-websocket-protocol"] = ws.protocol;
}
settle({ headers, ok: true, ws });
});
ws.addEventListener("error", () => {
signal.removeEventListener("abort", onAbort);
settle({ error: "连接失败", ok: false });
try {
ws.close();
} catch {
/* best-effort */
}
});
ws.addEventListener("close", (event) => {
signal.removeEventListener("abort", onAbort);
if (!settled) {
const code = event.code;
const reason = event.reason || "";
if (code >= 1000 && code < 2000) {
settle({
error: `handshake failed: server closed with code ${code}${reason ? `: ${reason}` : ""}`,
ok: false,
});
} else {
settle({ error: "连接关闭", ok: false });
}
}
});
return await connectPromise;
} catch (error) {
if (signal.aborted) {
return { error: "连接超时", ok: false };
}
const message = isError(error) ? error.message : String(error);
return { error: simplifyConnectError(message), ok: false };
}
}
async function wsSendAndReceive(
ws: WebSocket,
sendText: string,
receiveTimeout: number,
maxMessageBytes: number,
signal: AbortSignal,
): Promise<MessageReceiveResult> {
let settled = false;
let resolveFn: ((result: MessageReceiveResult) => void) | undefined;
const messagePromise = new Promise<MessageReceiveResult>((resolve) => {
resolveFn = resolve;
});
const settle = (result: MessageReceiveResult) => {
if (settled) return;
settled = true;
resolveFn!(result);
};
const timer = setTimeout(() => {
settle({ error: `等待响应超时 (${receiveTimeout}ms)`, ok: false });
}, receiveTimeout);
const onAbort = () => {
settle({ error: "探测已取消", ok: false });
};
signal.addEventListener("abort", onAbort, { once: true });
const cleanup = () => {
clearTimeout(timer);
signal.removeEventListener("abort", onAbort);
ws.removeEventListener("message", onMessage);
ws.removeEventListener("close", onClose);
ws.removeEventListener("error", onError);
};
const onMessage = (event: MessageEvent) => {
if (settled) return;
const data = typeof event.data === "string" ? event.data : new TextDecoder().decode(event.data as Uint8Array);
const size = new TextEncoder().encode(data).byteLength;
if (size > maxMessageBytes) {
settle({ error: `消息超过 ${maxMessageBytes} 字节限制 (${size} bytes)`, ok: false });
return;
}
settle({ data, ok: true, size });
};
const onClose = (event: CloseEvent) => {
if (settled) return;
const code = event.code;
const reason = event.reason || "";
settle({ error: `服务端关闭连接: code=${code}${reason ? ` reason=${reason}` : ""}`, ok: false });
};
const onError = () => {
if (settled) return;
settle({ error: "连接错误", ok: false });
};
ws.addEventListener("message", onMessage);
ws.addEventListener("close", onClose);
ws.addEventListener("error", onError);
try {
ws.send(sendText);
} catch (error) {
cleanup();
return { error: isError(error) ? error.message : "发送消息失败", ok: false };
}
const result = await messagePromise;
cleanup();
return result;
}

View File

@@ -0,0 +1,30 @@
import type { ContentExpectations, ExpectationResult, KeyedExpectations } from "../../expect/types";
import { checkContentExpectations } from "../../expect/content";
import { mismatchFailure } from "../../expect/failure";
import { checkHeaderExpectations } from "../../expect/headers";
export function checkConnected(connected: boolean, expected: boolean): ExpectationResult {
if (connected === expected) return { failure: null, matched: true };
if (!connected && expected) {
return {
failure: mismatchFailure("connect", "connected", true, false, "期望 WebSocket 连接成功但连接失败"),
matched: false,
};
}
return {
failure: mismatchFailure("connect", "connected", false, true, "期望 WebSocket 连接失败但连接成功"),
matched: false,
};
}
export function checkHandshakeHeaders(
headers: Record<string, unknown>,
expectations: KeyedExpectations | undefined,
): ExpectationResult {
return checkHeaderExpectations(headers, expectations);
}
export function checkMessage(message: string, expectations: ContentExpectations): ExpectationResult {
return checkContentExpectations(message, expectations, { path: "message", phase: "message" });
}

View File

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

View File

@@ -0,0 +1,20 @@
import { isPlainObject } from "es-toolkit";
import type { RawTargetConfig } from "../../types";
import { compactExpect, normalizeContent, normalizeKeyed, normalizeValue } from "../../expect/normalize";
export function normalizeTargetExpect(target: RawTargetConfig): RawTargetConfig {
if (target.expect === undefined || !isPlainObject(target.expect)) return target;
const raw = target.expect as Record<string, unknown>;
return {
...target,
expect: compactExpect(raw, {
connected: raw["connected"],
connectTimeMs: normalizeValue(raw["connectTimeMs"]),
durationMs: normalizeValue(raw["durationMs"]),
handshakeHeaders: normalizeKeyed(raw["handshakeHeaders"]),
message: normalizeContent(raw["message"]),
}),
};
}

View File

@@ -0,0 +1,66 @@
import { Type } from "@sinclair/typebox";
import type { CheckerSchemas } from "../types";
import {
createAuthoringContentExpectationsSchema,
createAuthoringFieldSchema,
createAuthoringKeyedExpectationsSchema,
createAuthoringStringMapSchema,
createAuthoringValueExpectationSchema,
createNormalizedContentExpectationsSchema,
createNormalizedKeyedExpectationsSchema,
createNormalizedValueExpectationSchema,
sizeSchema,
stringMapSchema,
} from "../../schema/fragments";
export const wsCheckerSchemas: CheckerSchemas = {
authoring: {
config: createWsConfigSchema("authoring"),
expect: createWsExpectSchema("authoring"),
},
normalized: {
config: createWsConfigSchema("normalized"),
expect: createWsExpectSchema("normalized"),
},
};
function createWsConfigSchema(kind: "authoring" | "normalized") {
const bool = Type.Boolean();
const timeout = Type.Number({ minimum: 0 });
return Type.Object(
{
headers: Type.Optional(kind === "authoring" ? createAuthoringStringMapSchema() : stringMapSchema),
ignoreSSL: Type.Optional(kind === "authoring" ? createAuthoringFieldSchema(bool) : bool),
maxMessageBytes: Type.Optional(sizeSchema),
receiveTimeout: Type.Optional(kind === "authoring" ? createAuthoringFieldSchema(timeout) : timeout),
send: Type.Optional(Type.String()),
subprotocols: Type.Optional(Type.Array(Type.String({ minLength: 1 }))),
url: Type.String({ minLength: 1 }),
},
{ additionalProperties: false },
);
}
function createWsExpectSchema(kind: "authoring" | "normalized") {
const connected = Type.Boolean();
return Type.Object(
{
connected: Type.Optional(kind === "authoring" ? createAuthoringFieldSchema(connected) : connected),
connectTimeMs: Type.Optional(
kind === "authoring" ? createAuthoringValueExpectationSchema() : createNormalizedValueExpectationSchema(),
),
durationMs: Type.Optional(
kind === "authoring" ? createAuthoringValueExpectationSchema() : createNormalizedValueExpectationSchema(),
),
handshakeHeaders: Type.Optional(
kind === "authoring" ? createAuthoringKeyedExpectationsSchema() : createNormalizedKeyedExpectationsSchema(),
),
message: Type.Optional(
kind === "authoring" ? createAuthoringContentExpectationsSchema() : createNormalizedContentExpectationsSchema(),
),
},
{ additionalProperties: false },
);
}

View File

@@ -0,0 +1,55 @@
import type {
ContentExpectations,
KeyedExpectations,
RawContentExpectations,
RawKeyedExpectations,
RawValueExpectation,
ValueExpectation,
} from "../../expect/types";
import type { ResolvedTargetBase } from "../../types";
export interface RawWsExpectConfig {
connected?: boolean;
connectTimeMs?: RawValueExpectation;
durationMs?: RawValueExpectation;
handshakeHeaders?: RawKeyedExpectations;
message?: RawContentExpectations;
}
export interface ResolvedWsConfig {
headers: Record<string, string>;
ignoreSSL: boolean;
maxMessageBytes: number;
receiveTimeout: number;
send?: string;
subprotocols: string[];
url: string;
}
export interface ResolvedWsExpectConfig {
connected: boolean;
connectTimeMs?: ValueExpectation;
durationMs?: ValueExpectation;
handshakeHeaders?: KeyedExpectations;
message?: ContentExpectations;
}
export interface ResolvedWsTarget extends ResolvedTargetBase {
expect?: ResolvedWsExpectConfig;
group: string;
intervalMs: number;
name: null | string;
timeoutMs: number;
type: "ws";
ws: ResolvedWsConfig;
}
export interface WsTargetConfig {
headers?: Record<string, string>;
ignoreSSL?: boolean;
maxMessageBytes?: number | string;
receiveTimeout?: number;
send?: string;
subprotocols?: string[];
url: string;
}

View File

@@ -0,0 +1,215 @@
import { isNumber, isString } from "es-toolkit";
import type { ConfigValidationIssue } from "../../schema/issues";
import type { CheckerValidationInput } from "../types";
import {
isPlainRecord,
validateRawContentExpectations,
validateRawKeyedExpectations,
validateRawValueExpectation,
} from "../../expect/validate";
import { issue, joinPath } from "../../schema/issues";
const ALLOWED_PROTOCOLS = new Set(["ws:", "wss:"]);
export function validateWsConfig(input: CheckerValidationInput): ConfigValidationIssue[] {
const issues: ConfigValidationIssue[] = [];
for (let i = 0; i < input.targets.length; i++) {
const target = input.targets[i] as unknown;
if (!isPlainRecord(target)) continue;
if (target["type"] !== "ws") continue;
issues.push(...validateWsTarget(target, `targets[${i}]`));
}
return issues;
}
function getTargetName(target: Record<string, unknown>): string | undefined {
if (isString(target["name"])) return target["name"];
return isString(target["id"]) ? target["id"] : undefined;
}
function validateWsExpect(target: Record<string, unknown>, path: string, hasSend: boolean): 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 (expect["connected"] !== undefined && typeof expect["connected"] !== "boolean") {
issues.push(issue("invalid-type", joinPath(expectPath, "connected"), "必须为布尔值", targetName));
}
const connectedFalse = expect["connected"] === false;
if (expect["handshakeHeaders"] !== undefined) {
if (connectedFalse) {
issues.push(
issue(
"invalid-value",
joinPath(expectPath, "handshakeHeaders"),
"handshakeHeaders 断言需要 expect.connected 为 true",
targetName,
),
);
} else {
issues.push(
...validateRawKeyedExpectations(
expect["handshakeHeaders"],
joinPath(expectPath, "handshakeHeaders"),
targetName,
{
caseInsensitive: true,
},
),
);
}
}
if (expect["message"] !== undefined) {
if (!hasSend) {
issues.push(issue("invalid-value", joinPath(expectPath, "message"), "message 断言需要配置 ws.send", targetName));
} else if (connectedFalse) {
issues.push(
issue(
"invalid-value",
joinPath(expectPath, "message"),
"message 断言需要 expect.connected 为 true",
targetName,
),
);
} else {
issues.push(...validateRawContentExpectations(expect["message"], joinPath(expectPath, "message"), targetName));
}
}
if (expect["connectTimeMs"] !== undefined) {
if (connectedFalse) {
issues.push(
issue(
"invalid-value",
joinPath(expectPath, "connectTimeMs"),
"connectTimeMs 断言需要 expect.connected 为 true",
targetName,
),
);
} else {
issues.push(
...validateRawValueExpectation(expect["connectTimeMs"], joinPath(expectPath, "connectTimeMs"), targetName),
);
}
}
if (expect["durationMs"] !== undefined) {
issues.push(...validateRawValueExpectation(expect["durationMs"], joinPath(expectPath, "durationMs"), targetName));
}
const allowedKeys = new Set(["connected", "connectTimeMs", "durationMs", "handshakeHeaders", "message"]);
for (const key of Object.keys(expect)) {
if (!allowedKeys.has(key)) {
issues.push(issue("unknown-field", joinPath(expectPath, key), "是未知字段", targetName));
}
}
return issues;
}
function validateWsTarget(target: Record<string, unknown>, path: string): ConfigValidationIssue[] {
const issues: ConfigValidationIssue[] = [];
const targetName = getTargetName(target);
const ws = target["ws"];
if (!isPlainRecord(ws)) {
issues.push(issue("required", joinPath(path, "ws"), "缺少 ws.url 字段", targetName));
issues.push(...validateWsExpect(target, path, false));
return issues;
}
if (!isString(ws["url"]) || ws["url"].trim() === "") {
issues.push(issue("required", joinPath(joinPath(path, "ws"), "url"), "缺少 ws.url 字段", targetName));
} else {
try {
const url = new URL(ws["url"]);
if (!ALLOWED_PROTOCOLS.has(url.protocol)) {
issues.push(
issue(
"invalid-url",
joinPath(joinPath(path, "ws"), "url"),
"格式不合法,必须以 ws:// 或 wss:// 开头",
targetName,
),
);
}
} catch {
issues.push(issue("invalid-url", joinPath(joinPath(path, "ws"), "url"), "格式不合法", targetName));
}
}
if (ws["subprotocols"] !== undefined) {
if (!Array.isArray(ws["subprotocols"])) {
issues.push(
issue("invalid-type", joinPath(joinPath(path, "ws"), "subprotocols"), "必须为字符串数组", targetName),
);
} else {
for (let i = 0; i < ws["subprotocols"].length; i++) {
const sp = ws["subprotocols"][i] as unknown;
if (!isString(sp) || sp.trim() === "") {
issues.push(
issue(
"invalid-value",
`${joinPath(joinPath(path, "ws"), "subprotocols")}[${i}]`,
"必须为非空字符串",
targetName,
),
);
}
}
}
}
if (ws["ignoreSSL"] !== undefined && typeof ws["ignoreSSL"] !== "boolean") {
issues.push(issue("invalid-type", joinPath(joinPath(path, "ws"), "ignoreSSL"), "必须为布尔值", targetName));
}
if (
ws["receiveTimeout"] !== undefined &&
!(isNumber(ws["receiveTimeout"]) && Number.isFinite(ws["receiveTimeout"]) && ws["receiveTimeout"] >= 0)
) {
issues.push(
issue("invalid-type", joinPath(joinPath(path, "ws"), "receiveTimeout"), "必须为非负有限数字", targetName),
);
}
if (ws["maxMessageBytes"] !== undefined) {
if (
!isString(ws["maxMessageBytes"]) &&
!(isNumber(ws["maxMessageBytes"]) && Number.isFinite(ws["maxMessageBytes"]) && ws["maxMessageBytes"] >= 0)
) {
issues.push(
issue("invalid-value", joinPath(joinPath(path, "ws"), "maxMessageBytes"), "必须为合法 size 值", targetName),
);
}
}
const allowedWsKeys = new Set([
"headers",
"ignoreSSL",
"maxMessageBytes",
"receiveTimeout",
"send",
"subprotocols",
"url",
]);
for (const key of Object.keys(ws)) {
if (!allowedWsKeys.has(key)) {
issues.push(issue("unknown-field", joinPath(joinPath(path, "ws"), key), "是未知字段", targetName));
}
}
const hasSend = isString(ws["send"]) && ws["send"].length > 0;
issues.push(...validateWsExpect(target, path, hasSend));
return issues;
}

View File

@@ -1,6 +1,7 @@
import Ajv from "ajv";
import { describe, expect, test } from "bun:test";
import { normalizeAuthoringConfig } from "../../../../src/server/checker/normalizer";
import { createDefaultCheckerRegistry } from "../../../../src/server/checker/runner";
import { createProbeConfigJsonSchema } from "../../../../src/server/checker/schema/export";
import { formatConfigIssues, issue } from "../../../../src/server/checker/schema/issues";
@@ -274,4 +275,115 @@ describe("config contract", () => {
}),
).toBe(false);
});
test("所有 checker 的 authoring ValueMatcher 简写经 normalize 后通过 normalized contract 校验", () => {
const authoringShorthandExamples: Record<string, object> = {
cmd: {
targets: [
{
cmd: { exec: "echo hello" },
expect: { durationMs: 1000 },
id: "cmd-test",
type: "cmd",
},
],
},
db: {
targets: [
{
db: { url: "sqlite://:memory:" },
expect: { durationMs: 2000 },
id: "db-test",
type: "db",
},
],
},
dns: {
targets: [
{
dns: { name: "example.com", resolver: "system" },
expect: { durationMs: 500 },
id: "dns-test",
type: "dns",
},
],
},
http: {
targets: [
{
expect: { durationMs: 5000 },
http: { url: "https://example.com" },
id: "http-test",
type: "http",
},
],
},
icmp: {
targets: [
{
expect: { packetLossPercent: 0 },
icmp: { host: "example.com" },
id: "icmp-test",
type: "icmp",
},
],
},
llm: {
targets: [
{
expect: { durationMs: 10000 },
id: "llm-test",
llm: {
model: "gpt-4o-mini",
prompt: "ping",
provider: "openai",
url: "https://example.com/v1/chat/completions",
},
type: "llm",
},
],
},
tcp: {
targets: [
{
expect: { durationMs: 3000 },
id: "tcp-test",
tcp: { host: "example.com", port: 80 },
type: "tcp",
},
],
},
udp: {
targets: [
{
expect: { durationMs: 1000 },
id: "udp-test",
type: "udp",
udp: { host: "example.com", port: 53 },
},
],
},
ws: {
targets: [
{
expect: { durationMs: 5000 },
id: "ws-test",
type: "ws",
ws: { url: "wss://example.com/ws" },
},
],
},
};
for (const [type, config] of Object.entries(authoringShorthandExamples)) {
const normalizeResult = normalizeAuthoringConfig(config, createDefaultCheckerRegistry());
expect(normalizeResult.issues).toHaveLength(0);
const contract = validateProbeConfigContract(normalizeResult.config, createDefaultCheckerRegistry());
expect(contract.config).not.toBeNull();
expect(
contract.issues,
`Checker "${type}" authoring shorthand should pass normalized contract, got issues: ${JSON.stringify(contract.issues.map((i) => `${i.path}: ${i.message}`))}`,
).toHaveLength(0);
}
});
});

View File

@@ -743,7 +743,7 @@ targets:
`targets:
- name: "test"
id: "test"
type: dns
type: ftp
`,
);
await expectConfigLoadError(configPath, "不支持的 type");
@@ -886,6 +886,144 @@ targets:
await expectConfigLoadError(configPath, "无效的时长格式");
});
test("interval 小于 10s 抛出错误", async () => {
const configPath = join(tempDir, "interval-too-small.yaml");
await writeFile(
configPath,
`targets:
- name: "t"
id: "t"
type: http
interval: "9s"
http:
url: "http://a.com"
`,
);
await expectConfigLoadError(configPath, "interval 不能小于 10s");
});
test("interval 9999ms 抛出错误", async () => {
const configPath = join(tempDir, "interval-9999ms.yaml");
await writeFile(
configPath,
`targets:
- name: "t"
id: "t"
type: http
interval: "9999ms"
http:
url: "http://a.com"
`,
);
await expectConfigLoadError(configPath, "interval 不能小于 10s");
});
test("interval 10s 通过", async () => {
const configPath = join(tempDir, "interval-10s.yaml");
await writeFile(
configPath,
`targets:
- name: "t"
id: "t"
type: http
interval: "10s"
http:
url: "http://a.com"
`,
);
const config = await loadConfig(configPath);
expect(config.targets[0]!.intervalMs).toBe(10000);
});
test("interval 10000ms 通过", async () => {
const configPath = join(tempDir, "interval-10000ms.yaml");
await writeFile(
configPath,
`targets:
- name: "t"
id: "t"
type: http
interval: "10000ms"
http:
url: "http://a.com"
`,
);
const config = await loadConfig(configPath);
expect(config.targets[0]!.intervalMs).toBe(10000);
});
test("timeout 大于 interval 抛出错误", async () => {
const configPath = join(tempDir, "timeout-gt-interval.yaml");
await writeFile(
configPath,
`targets:
- name: "t"
id: "t"
type: http
interval: "10s"
timeout: "30s"
http:
url: "http://a.com"
`,
);
await expectConfigLoadError(configPath, "timeout 不能大于 interval");
});
test("timeout 等于 interval 通过", async () => {
const configPath = join(tempDir, "timeout-eq-interval.yaml");
await writeFile(
configPath,
`targets:
- name: "t"
id: "t"
type: http
interval: "30s"
timeout: "30s"
http:
url: "http://a.com"
`,
);
const config = await loadConfig(configPath);
expect(config.targets[0]!.intervalMs).toBe(30000);
expect(config.targets[0]!.timeoutMs).toBe(30000);
});
test("变量解析后 interval 小于 10s 抛出错误", async () => {
const configPath = join(tempDir, "var-interval-too-small.yaml");
await writeFile(
configPath,
`variables:
check_interval: "5s"
targets:
- name: "t"
id: "t"
type: http
interval: "\${check_interval}"
http:
url: "http://a.com"
`,
);
await expectConfigLoadError(configPath, "interval 不能小于 10s");
});
test("变量解析后 timeout 大于 interval 抛出错误", async () => {
const configPath = join(tempDir, "var-timeout-gt-interval.yaml");
await writeFile(
configPath,
`variables:
check_timeout: "60s"
targets:
- name: "t"
id: "t"
type: http
timeout: "\${check_timeout}"
http:
url: "http://a.com"
`,
);
await expectConfigLoadError(configPath, "timeout 不能大于 interval");
});
test("解析 expect 配置", async () => {
const configPath = join(tempDir, "expect.yaml");
await writeFile(
@@ -2132,6 +2270,82 @@ targets:
);
});
test("解析最简 cpu 配置", async () => {
const configPath = join(tempDir, "minimal-cpu.yaml");
await writeFile(
configPath,
`targets:
- id: "local-cpu"
type: cpu
cpu: {}
`,
);
const config = await loadConfig(configPath);
expect(config.targets).toHaveLength(1);
const t = config.targets[0]! as Record<string, unknown>;
expect(t["type"]).toBe("cpu");
expect((t["cpu"] as Record<string, unknown>)["sampleDurationMs"]).toBe(1000);
expect((t["cpu"] as Record<string, unknown>)["includePerCore"]).toBe(false);
expect(t["group"]).toBe("default");
expect(t["intervalMs"]).toBe(30000);
expect(t["timeoutMs"]).toBe(10000);
});
test("解析 cpu expect 配置", async () => {
const configPath = join(tempDir, "cpu-expect.yaml");
await writeFile(
configPath,
`targets:
- id: "local-cpu"
type: cpu
cpu:
sampleDuration: "2s"
includePerCore: true
expect:
usagePercent: { lte: 85 }
idlePercent: { gte: 15 }
maxCoreUsagePercent: { lte: 95 }
durationMs: { lte: 3000 }
`,
);
const config = await loadConfig(configPath);
expect(config.targets).toHaveLength(1);
const t = config.targets[0]! as Record<string, unknown>;
expect((t["cpu"] as Record<string, unknown>)["sampleDurationMs"]).toBe(2000);
expect((t["cpu"] as Record<string, unknown>)["includePerCore"]).toBe(true);
expect((t["expect"] as Record<string, unknown>)["usagePercent"]).toEqual({ lte: 85 });
});
test("cpu expect 未知字段抛出错误", async () => {
await expectConfigError(
"cpu-unknown-expect.yaml",
`targets:
- id: "local-cpu"
type: cpu
cpu: {}
expect:
logicalCoreCount: { gte: 4 }
`,
"expect.logicalCoreCount 是未知字段",
);
});
test("cpu sampleDuration >= timeout 抛出错误", async () => {
await expectConfigError(
"cpu-sample-too-long.yaml",
`targets:
- id: "local-cpu"
type: cpu
timeout: "1s"
cpu:
sampleDuration: "5s"
`,
"sampleDuration 必须小于 timeout",
);
});
describe("logging 配置", () => {
test("logging 全部缺省时使用默认值", async () => {
const configPath = join(tempDir, "logging-default.yaml");

View File

@@ -50,6 +50,14 @@ function ensureRegistered() {
}
}
function getRunOnce(engine: ProbeEngine) {
return (
engine as unknown as {
runOnce: (t: ResolvedTargetBase) => Promise<Record<string, unknown>>;
}
).runOnce.bind(engine);
}
function makeCommandTarget(name: string, overrides?: Partial<ResolvedCommandTarget>): ResolvedCommandTarget {
return {
cmd: {
@@ -70,6 +78,19 @@ function makeCommandTarget(name: string, overrides?: Partial<ResolvedCommandTarg
};
}
function makeMockResult(targetId: string, overrides?: Partial<Record<string, unknown>>) {
return {
detail: null,
durationMs: 1,
failure: null,
matched: true,
observation: null,
targetId,
timestamp: new Date().toISOString(),
...overrides,
};
}
describe("ProbeEngine", () => {
test("start/stop 不抛错", () => {
ensureRegistered();
@@ -81,15 +102,12 @@ describe("ProbeEngine", () => {
expect(true).toBe(true);
});
test("单次 probeGroup 执行 cmd 检查", async () => {
test("单次 runOnce 执行 cmd 检查", async () => {
const target = makeCommandTarget("cmd-echo");
const mockStore = createMockStore(["cmd-echo"]) as unknown as ProbeStore;
const engine = new ProbeEngine(mockStore, [target]);
const probeGroup = (
engine as unknown as { probeGroup: (t: ResolvedTargetBase[]) => Promise<void> }
).probeGroup.bind(engine);
await probeGroup([target]);
await getRunOnce(engine)(target);
const results = (mockStore as unknown as { _results: Array<Record<string, unknown>> })._results;
expect(results.length).toBe(1);
@@ -119,11 +137,9 @@ describe("ProbeEngine", () => {
const mockStore = createMockStore(["echo-a", "echo-b"]) as unknown as ProbeStore;
const engine = new ProbeEngine(mockStore, [targetA, targetB]);
const runOnce = getRunOnce(engine);
const probeGroup = (
engine as unknown as { probeGroup: (t: ResolvedTargetBase[]) => Promise<void> }
).probeGroup.bind(engine);
await probeGroup([targetA, targetB]);
await Promise.all([runOnce(targetA), runOnce(targetB)]);
const results = (mockStore as unknown as { _results: Array<Record<string, unknown>> })._results;
expect(results.length).toBe(2);
@@ -143,11 +159,9 @@ describe("ProbeEngine", () => {
const mockStore = createMockStore(["bad-cmd", "good-cmd"]) as unknown as ProbeStore;
const engine = new ProbeEngine(mockStore, [badTarget, goodTarget]);
const runOnce = getRunOnce(engine);
const probeGroup = (
engine as unknown as { probeGroup: (t: ResolvedTargetBase[]) => Promise<void> }
).probeGroup.bind(engine);
await probeGroup([badTarget, goodTarget]);
await Promise.all([runOnce(badTarget), runOnce(goodTarget)]);
const results = (mockStore as unknown as { _results: Array<Record<string, unknown>> })._results;
expect(results.length).toBe(2);
@@ -173,27 +187,28 @@ describe("ProbeEngine", () => {
const goodTarget = makeCommandTarget("good-cmd");
const mockStore = createMockStore(["reject-cmd", "good-cmd"]) as unknown as ProbeStore;
const engine = new ProbeEngine(mockStore, [rejectTarget, goodTarget]);
const runOnce = getRunOnce(engine);
const probeGroup = (
engine as unknown as { probeGroup: (t: ResolvedTargetBase[]) => Promise<void> }
).probeGroup.bind(engine);
await probeGroup([rejectTarget, goodTarget]);
await Promise.all([runOnce(rejectTarget), runOnce(goodTarget)]);
const results = (mockStore as unknown as { _results: Array<Record<string, unknown>> })._results;
expect(results.length).toBe(2);
expect(results[0]!["targetId"]).toBe("reject-cmd");
expect(results[0]!["matched"]).toBe(false);
expect(results[0]!["durationMs"]).toBeNull();
expect(results[0]!["observation"]).toBeNull();
expect(results[0]!["failure"]).toEqual({
const rejectResult = results.find((r) => r["targetId"] === "reject-cmd");
const goodResult = results.find((r) => r["targetId"] === "good-cmd");
expect(rejectResult).toBeDefined();
expect(rejectResult!["matched"]).toBe(false);
expect(rejectResult!["durationMs"]).toBeNull();
expect(rejectResult!["observation"]).toBeNull();
expect(rejectResult!["failure"]).toEqual({
kind: "error",
message: "boom",
path: "engine",
phase: "internal",
});
expect(typeof results[0]!["timestamp"]).toBe("string");
expect(results[1]!["targetId"]).toBe("good-cmd");
expect(results[1]!["matched"]).toBe(true);
expect(typeof rejectResult!["timestamp"]).toBe("string");
expect(goodResult).toBeDefined();
expect(goodResult!["matched"]).toBe(true);
checker.execute = originalExecute;
});
@@ -212,11 +227,9 @@ describe("ProbeEngine", () => {
const mockStore = createMockStore(targets.map((t) => t.name ?? t.id)) as unknown as ProbeStore;
const engine = new ProbeEngine(mockStore, targets, 2);
const runOnce = getRunOnce(engine);
const probeGroup = (
engine as unknown as { probeGroup: (t: ResolvedTargetBase[]) => Promise<void> }
).probeGroup.bind(engine);
await probeGroup(targets);
await Promise.all(targets.map((t) => runOnce(t)));
const results = (mockStore as unknown as { _results: Array<Record<string, unknown>> })._results;
expect(results.length).toBe(5);
@@ -225,7 +238,7 @@ describe("ProbeEngine", () => {
}
});
test("groupByInterval 按间隔分组", () => {
test("不同 interval 的 target 独立调度", () => {
const targetA = makeCommandTarget("a", { intervalMs: 30000 });
const targetB = makeCommandTarget("b", { intervalMs: 30000 });
const targetC = makeCommandTarget("c", { intervalMs: 60000 });
@@ -242,10 +255,7 @@ 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: ResolvedTargetBase[]) => Promise<void> }
).probeGroup.bind(engine);
await probeGroup([target]);
await getRunOnce(engine)(target);
const results = (mockStore as unknown as { _results: Array<Record<string, unknown>> })._results;
expect(results.length).toBe(0);
@@ -281,10 +291,7 @@ 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: ResolvedTargetBase[]) => Promise<void> }
).probeGroup.bind(engine);
await probeGroup([httpTarget]);
await getRunOnce(engine)(httpTarget);
const results = (mockStore as unknown as { _results: Array<Record<string, unknown>> })._results;
expect(results.length).toBe(1);
@@ -365,11 +372,9 @@ describe("ProbeEngine", () => {
0,
logger,
);
const runOnce = getRunOnce(engine);
const probeGroup = (
engine as unknown as { probeGroup: (t: ResolvedTargetBase[]) => Promise<void> }
).probeGroup.bind(engine);
await probeGroup([makeCommandTarget("fail-target"), makeCommandTarget("ok-target")]);
await Promise.all([runOnce(makeCommandTarget("fail-target")), runOnce(makeCommandTarget("ok-target"))]);
const errorLogs = logger.entries.filter((e) => e.level === "error");
expect(errorLogs.length).toBeGreaterThanOrEqual(1);
@@ -393,25 +398,17 @@ describe("ProbeEngine", () => {
const originalExecute = checker.execute.bind(checker);
checker.execute = async (target) => {
if (target.id === targetId) {
return {
detail: null,
return makeMockResult(targetId, {
durationMs: 10,
failure: { kind: "error", message: "fail", path: "exitCode", phase: "body" },
matched: false,
observation: null,
targetId,
timestamp: new Date().toISOString(),
};
});
}
return originalExecute(target, { signal: new AbortController().signal });
};
const engine = new ProbeEngine(mockStore, [makeCommandTarget(targetId)], 20, 0, logger);
const probeGroup = (
engine as unknown as { probeGroup: (t: ResolvedTargetBase[]) => Promise<void> }
).probeGroup.bind(engine);
await probeGroup([makeCommandTarget(targetId)]);
await getRunOnce(engine)(makeCommandTarget(targetId));
const stateLogs = logger.entries.filter((e) => e.level === "warn" && e.msg.includes("UP → DOWN"));
expect(stateLogs.length).toBe(1);
@@ -432,11 +429,7 @@ describe("ProbeEngine", () => {
} as unknown as ProbeStore;
const engine = new ProbeEngine(mockStore, [makeCommandTarget(targetId)], 20, 0, logger);
const probeGroup = (
engine as unknown as { probeGroup: (t: ResolvedTargetBase[]) => Promise<void> }
).probeGroup.bind(engine);
await probeGroup([makeCommandTarget(targetId)]);
await getRunOnce(engine)(makeCommandTarget(targetId));
const recoverLogs = logger.entries.filter((e) => e.level === "info" && e.msg.includes("DOWN → UP"));
expect(recoverLogs.length).toBe(1);
@@ -455,11 +448,7 @@ describe("ProbeEngine", () => {
} as unknown as ProbeStore;
const engine = new ProbeEngine(mockStore, [makeCommandTarget(targetId)], 20, 0, logger);
const probeGroup = (
engine as unknown as { probeGroup: (t: ResolvedTargetBase[]) => Promise<void> }
).probeGroup.bind(engine);
await probeGroup([makeCommandTarget(targetId)]);
await getRunOnce(engine)(makeCommandTarget(targetId));
const stateChangeLogs = logger.entries.filter(
(e) => e.msg.includes("UP → DOWN") || e.msg.includes("DOWN → UP") || e.msg.includes("首次 DOWN"),
@@ -477,25 +466,17 @@ describe("ProbeEngine", () => {
const originalExecute = checker.execute.bind(checker);
checker.execute = async (target) => {
if (target.id === targetId) {
return {
detail: null,
return makeMockResult(targetId, {
durationMs: 10,
failure: { kind: "error", message: "fail", path: "exitCode", phase: "body" },
matched: false,
observation: null,
targetId,
timestamp: new Date().toISOString(),
};
});
}
return originalExecute(target, { signal: new AbortController().signal });
};
const engine = new ProbeEngine(mockStore, [makeCommandTarget(targetId)], 20, 0, logger);
const probeGroup = (
engine as unknown as { probeGroup: (t: ResolvedTargetBase[]) => Promise<void> }
).probeGroup.bind(engine);
await probeGroup([makeCommandTarget(targetId)]);
await getRunOnce(engine)(makeCommandTarget(targetId));
const firstDownLogs = logger.entries.filter((e) => e.level === "warn" && e.msg.includes("首次 DOWN"));
expect(firstDownLogs.length).toBe(1);
@@ -510,11 +491,7 @@ describe("ProbeEngine", () => {
const mockStore = createMockStore([targetId]) as unknown as ProbeStore;
const engine = new ProbeEngine(mockStore, [makeCommandTarget(targetId)], 20, 0, logger);
const probeGroup = (
engine as unknown as { probeGroup: (t: ResolvedTargetBase[]) => Promise<void> }
).probeGroup.bind(engine);
await probeGroup([makeCommandTarget(targetId)]);
await getRunOnce(engine)(makeCommandTarget(targetId));
const debugLogs = logger.entries.filter((e) => e.level === "debug");
expect(debugLogs.length).toBeGreaterThanOrEqual(1);
@@ -527,11 +504,186 @@ describe("ProbeEngine", () => {
ensureRegistered();
const mockStore = createMockStore(["no-log"]) as unknown as ProbeStore;
const engine = new ProbeEngine(mockStore, [makeCommandTarget("no-log")]);
await getRunOnce(engine)(makeCommandTarget("no-log"));
});
});
const probeGroup = (
engine as unknown as { probeGroup: (t: ResolvedTargetBase[]) => Promise<void> }
).probeGroup.bind(engine);
await probeGroup([makeCommandTarget("no-log")]);
describe("runLoop 调度行为", () => {
test("首次立即执行", async () => {
ensureRegistered();
let callCount = 0;
const checker = checkerRegistry.get("cmd");
const originalExecute = checker.execute.bind(checker);
checker.execute = (target) => {
callCount++;
return Promise.resolve(makeMockResult(target.id));
};
const target = makeCommandTarget("immediate", { intervalMs: 60000 });
const mockStore = createMockStore(["immediate"]) as unknown as ProbeStore;
const engine = new ProbeEngine(mockStore, [target]);
engine.start();
await Bun.sleep(20);
expect(callCount).toBeGreaterThanOrEqual(1);
engine.stop();
checker.execute = originalExecute;
});
test("正常调度间隔", async () => {
ensureRegistered();
const callTimes: number[] = [];
const checker = checkerRegistry.get("cmd");
const originalExecute = checker.execute.bind(checker);
checker.execute = (target) => {
callTimes.push(performance.now());
return Promise.resolve(makeMockResult(target.id));
};
const target = makeCommandTarget("interval", { intervalMs: 100 });
const mockStore = createMockStore(["interval"]) as unknown as ProbeStore;
const engine = new ProbeEngine(mockStore, [target]);
engine.start();
await Bun.sleep(280);
engine.stop();
expect(callTimes.length).toBeGreaterThanOrEqual(2);
const gap = callTimes[1]! - callTimes[0]!;
expect(gap).toBeGreaterThanOrEqual(80);
expect(gap).toBeLessThan(200);
checker.execute = originalExecute;
});
test("catch-up 语义:超时后立即补执行", async () => {
ensureRegistered();
const callTimes: number[] = [];
const checker = checkerRegistry.get("cmd");
const originalExecute = checker.execute.bind(checker);
checker.execute = async (target) => {
callTimes.push(performance.now());
if (callTimes.length === 1) {
await Bun.sleep(150);
}
return makeMockResult(target.id);
};
const target = makeCommandTarget("catchup", { intervalMs: 100 });
const mockStore = createMockStore(["catchup"]) as unknown as ProbeStore;
const engine = new ProbeEngine(mockStore, [target]);
engine.start();
await Bun.sleep(350);
engine.stop();
expect(callTimes.length).toBeGreaterThanOrEqual(2);
const gap = callTimes[1]! - callTimes[0]!;
expect(gap).toBeGreaterThanOrEqual(140);
expect(gap).toBeLessThan(220);
checker.execute = originalExecute;
});
test("overrun warn 日志", async () => {
ensureRegistered();
const logger = createMemoryLogger();
const checker = checkerRegistry.get("cmd");
const originalExecute = checker.execute.bind(checker);
checker.execute = async (target) => {
await Bun.sleep(150);
return makeMockResult(target.id, { durationMs: 150 });
};
const target = makeCommandTarget("overrun", { intervalMs: 100 });
const mockStore = createMockStore(["overrun"]) as unknown as ProbeStore;
const engine = new ProbeEngine(mockStore, [target], 20, 0, logger);
engine.start();
await Bun.sleep(250);
engine.stop();
const warnLogs = logger.entries.filter((e) => e.level === "warn" && e.msg.includes("拨测超时"));
expect(warnLogs.length).toBeGreaterThanOrEqual(1);
expect(warnLogs[0]!.obj).toBeDefined();
expect(warnLogs[0]!.obj!["targetId"]).toBe("overrun");
checker.execute = originalExecute;
});
test("无并发重叠:同一 target 不会并发执行", async () => {
ensureRegistered();
let running = 0;
let maxConcurrent = 0;
const checker = checkerRegistry.get("cmd");
const originalExecute = checker.execute.bind(checker);
checker.execute = async (target) => {
running++;
maxConcurrent = Math.max(maxConcurrent, running);
await Bun.sleep(60);
running--;
return makeMockResult(target.id, { durationMs: 60 });
};
const target = makeCommandTarget("no-overlap", { intervalMs: 70 });
const mockStore = createMockStore(["no-overlap"]) as unknown as ProbeStore;
const engine = new ProbeEngine(mockStore, [target]);
engine.start();
await Bun.sleep(350);
engine.stop();
expect(maxConcurrent).toBeLessThanOrEqual(1);
checker.execute = originalExecute;
});
test("优雅停止stop() 后循环快速退出", async () => {
ensureRegistered();
const checker = checkerRegistry.get("cmd");
const originalExecute = checker.execute.bind(checker);
checker.execute = (target) => Promise.resolve(makeMockResult(target.id));
const target = makeCommandTarget("graceful", { intervalMs: 60000 });
const mockStore = createMockStore(["graceful"]) as unknown as ProbeStore;
const engine = new ProbeEngine(mockStore, [target]);
engine.start();
await Bun.sleep(20);
const stopStart = performance.now();
engine.stop();
const stopDuration = performance.now() - stopStart;
expect(stopDuration).toBeLessThan(1000);
checker.execute = originalExecute;
});
test("错误隔离runCheck 抛异常后循环继续", async () => {
ensureRegistered();
let callCount = 0;
const checker = checkerRegistry.get("cmd");
const originalExecute = checker.execute.bind(checker);
checker.execute = (target) => {
callCount++;
if (callCount === 1) {
return Promise.reject(new Error("first fail"));
}
return Promise.resolve(makeMockResult(target.id));
};
const target = makeCommandTarget("error-isolation", { intervalMs: 50 });
const mockStore = createMockStore(["error-isolation"]) as unknown as ProbeStore;
const engine = new ProbeEngine(mockStore, [target]);
engine.start();
await Bun.sleep(180);
engine.stop();
expect(callCount).toBeGreaterThanOrEqual(2);
checker.execute = originalExecute;
});
});
});

View File

@@ -0,0 +1,177 @@
import { describe, expect, test } from "bun:test";
import type { CpuCoreSnapshot } from "../../../../../src/server/checker/runner/cpu/types";
import { calculateCpuStats, validateCpuSnapshots } from "../../../../../src/server/checker/runner/cpu/calculate";
function makeCore(user: number, nice: number, sys: number, idle: number, irq: number): CpuCoreSnapshot {
return { times: { idle, irq, nice, sys, user } };
}
describe("calculateCpuStats", () => {
test("单核心完全空闲", () => {
const before = [makeCore(0, 0, 0, 100, 0)];
const after = [makeCore(0, 0, 0, 200, 0)];
const stats = calculateCpuStats(before, after);
expect(stats.usagePercent).toBe(0);
expect(stats.idlePercent).toBe(100);
expect(stats.maxCoreUsagePercent).toBe(0);
expect(stats.minCoreUsagePercent).toBe(0);
expect(stats.logicalCoreCount).toBe(1);
});
test("单核心完全忙碌idle 不变)", () => {
const before = [makeCore(100, 0, 0, 100, 0)];
const after = [makeCore(200, 0, 0, 100, 0)];
// idle delta = 0, total delta = 100
// idlePercent = 0, usagePercent = 100
const stats = calculateCpuStats(before, after);
expect(stats.usagePercent).toBe(100);
expect(stats.idlePercent).toBe(0);
});
test("单核心部分使用", () => {
const before = [makeCore(100, 0, 0, 900, 0)];
const after = [makeCore(150, 0, 0, 950, 0)];
// idle delta = 50, total delta = 100
// idlePercent = 50, usagePercent = 50
const stats = calculateCpuStats(before, after);
expect(stats.usagePercent).toBe(50);
expect(stats.idlePercent).toBe(50);
});
test("多核心加权平均", () => {
// 核心 0: idle delta = 200, total delta = 1000 -> 80% usage
// 核心 1: idle delta = 800, total delta = 1000 -> 20% usage
const before = [makeCore(0, 0, 0, 1000, 0), makeCore(0, 0, 0, 1000, 0)];
const after = [makeCore(800, 0, 0, 1200, 0), makeCore(200, 0, 0, 1800, 0)];
const stats = calculateCpuStats(before, after);
// 总 idle = 200+800=1000, 总 delta = 1000+1000=2000
// idlePercent = 1000/2000*100 = 50
// usagePercent = 100 - 50 = 50
expect(stats.idlePercent).toBe(50);
expect(stats.usagePercent).toBe(50);
expect(stats.maxCoreUsagePercent).toBe(80);
expect(stats.minCoreUsagePercent).toBe(20);
expect(stats.logicalCoreCount).toBe(2);
expect(stats.perCoreUsagePercent).toEqual([80, 20]);
});
test("四核心各不相同", () => {
const bf = [
makeCore(1000, 0, 0, 9000, 0), // core 0 baseline
makeCore(1000, 0, 0, 9000, 0), // core 1
makeCore(1000, 0, 0, 9000, 0), // core 2
makeCore(1000, 0, 0, 9000, 0), // core 3
];
const af = [
makeCore(1900, 0, 0, 9100, 0), // delta: user=900, idle=100, total=1000 -> 90% usage, 10% idle
makeCore(1500, 0, 0, 9500, 0), // delta: user=500, idle=500, total=1000 -> 50% usage
makeCore(1200, 0, 0, 9800, 0), // delta: user=200, idle=800, total=1000 -> 20% usage
makeCore(1010, 0, 0, 9990, 0), // delta: user=10, idle=990, total=1000 -> 1% usage
];
const stats = calculateCpuStats(bf, af);
// 总 idle = 100+500+800+990 = 2390, 总 delta = 4000
// idlePercent = 2390/4000*100 = 59.75 -> 59.8
expect(stats.idlePercent).toBe(59.8);
expect(stats.usagePercent).toBe(40.2);
expect(stats.maxCoreUsagePercent).toBe(90);
expect(stats.minCoreUsagePercent).toBe(1);
expect(stats.perCoreUsagePercent).toEqual([90, 50, 20, 1]);
expect(stats.logicalCoreCount).toBe(4);
});
test("delta 为 0 时返回 0", () => {
const before = [makeCore(100, 0, 0, 100, 0)];
const after = [makeCore(100, 0, 0, 100, 0)];
const stats = calculateCpuStats(before, after);
expect(stats.usagePercent).toBe(0);
expect(stats.idlePercent).toBe(0);
});
test("保留 1 位小数", () => {
// 总 idle = 333, 总 delta = 1000 -> idlePercent = 33.3
const before = [makeCore(0, 0, 0, 1000, 0)];
const after = [makeCore(667, 0, 0, 1333, 0)];
const stats = calculateCpuStats(before, after);
// idle delta = 333, total delta = 1000
expect(stats.idlePercent).toBe(33.3);
expect(stats.usagePercent).toBe(66.7);
});
test("nice 和 irq 计入 total 但不影响 idle", () => {
const bf = [makeCore(0, 0, 0, 0, 0)];
const af = [makeCore(300, 100, 100, 400, 100)];
// total delta = 300+100+100+400+100 = 1000
// idle delta = 400
// idlePercent = 400/1000*100 = 40
const stats = calculateCpuStats(bf, af);
expect(stats.idlePercent).toBe(40);
expect(stats.usagePercent).toBe(60);
});
});
describe("validateCpuSnapshots", () => {
test("合法 snapshot 返回 null", () => {
const before = [makeCore(100, 0, 0, 900, 0)];
const after = [makeCore(200, 0, 0, 800, 0)];
expect(validateCpuSnapshots(before, after)).toBeNull();
});
test("空 before snapshot", () => {
const after = [makeCore(0, 0, 0, 0, 0)];
expect(validateCpuSnapshots([], after)).toBe("CPU 快照为空");
});
test("空 after snapshot", () => {
const before = [makeCore(0, 0, 0, 0, 0)];
expect(validateCpuSnapshots(before, [])).toBe("CPU 快照为空");
});
test("核心数不一致", () => {
const before = [makeCore(0, 0, 0, 0, 0)];
const after = [makeCore(0, 0, 0, 0, 0), makeCore(0, 0, 0, 0, 0)];
expect(validateCpuSnapshots(before, after)).toBe("CPU 快照核心数不一致: before=1, after=2");
});
test("before 包含 NaN time 值", () => {
const before = [{ times: { idle: NaN, irq: 0, nice: 0, sys: 0, user: 0 } }];
const after = [makeCore(0, 0, 0, 0, 0)];
const error = validateCpuSnapshots(before, after);
expect(error).toContain("非有限值");
expect(error).toContain("before[0]");
});
test("after 包含 Infinity time 值", () => {
const before = [makeCore(0, 0, 0, 0, 0)];
const after = [{ times: { idle: Infinity, irq: 0, nice: 0, sys: 0, user: 0 } }];
const error = validateCpuSnapshots(before, after);
expect(error).toContain("非有限值");
expect(error).toContain("after[0]");
});
test("负数 total delta", () => {
const before = [makeCore(1000, 0, 0, 0, 0)];
const after = [makeCore(100, 0, 0, 0, 0)];
const error = validateCpuSnapshots(before, after);
expect(error).toContain("负数 delta");
});
test("零 delta 合法", () => {
const before = [makeCore(100, 0, 0, 100, 0)];
const after = [makeCore(100, 0, 0, 100, 0)];
expect(validateCpuSnapshots(before, after)).toBeNull();
});
test("零 delta 不产生除零错误", () => {
const before = [makeCore(100, 0, 0, 100, 0)];
const after = [makeCore(100, 0, 0, 100, 0)];
const stats = calculateCpuStats(before, after);
expect(Number.isFinite(stats.usagePercent)).toBe(true);
expect(Number.isFinite(stats.idlePercent)).toBe(true);
expect(Number.isFinite(stats.maxCoreUsagePercent)).toBe(true);
expect(Number.isFinite(stats.minCoreUsagePercent)).toBe(true);
expect(stats.usagePercent).toBe(0);
expect(stats.idlePercent).toBe(0);
});
});

View File

@@ -0,0 +1,379 @@
import { describe, expect, test } from "bun:test";
import type { SnapshotReader } from "../../../../../src/server/checker/runner/cpu/execute";
import type { CpuCoreSnapshot } from "../../../../../src/server/checker/runner/cpu/types";
import type { RawTargetConfig } from "../../../../../src/server/checker/types";
import { CpuChecker } from "../../../../../src/server/checker/runner/cpu/execute";
function makeCore(user: number, nice: number, sys: number, idle: number, irq: number): CpuCoreSnapshot {
return { times: { idle, irq, nice, sys, user } };
}
function makeResolveContext(
overrides: Partial<{ configDir: string; defaultIntervalMs: number; defaultTimeoutMs: number }> = {},
) {
return {
configDir: "/test",
defaultIntervalMs: 30000,
defaultTimeoutMs: 10000,
...overrides,
};
}
describe("CpuChecker resolve", () => {
const checker = new CpuChecker();
test("默认值sampleDurationMs=1000, includePerCore=false", () => {
const target: RawTargetConfig = { cpu: {}, id: "cpu-test", type: "cpu" };
const resolved = checker.resolve(target, makeResolveContext());
expect(resolved.cpu.sampleDurationMs).toBe(1000);
expect(resolved.cpu.includePerCore).toBe(false);
});
test("显式配置覆盖默认值", () => {
const target: RawTargetConfig = {
cpu: { includePerCore: true, sampleDuration: "2s" },
id: "cpu-test",
type: "cpu",
};
const resolved = checker.resolve(target, makeResolveContext());
expect(resolved.cpu.sampleDurationMs).toBe(2000);
expect(resolved.cpu.includePerCore).toBe(true);
});
test("无 expect 时 expect 为 undefined", () => {
const target: RawTargetConfig = { cpu: {}, id: "cpu-test", type: "cpu" };
const resolved = checker.resolve(target, makeResolveContext());
expect(resolved.expect).toBeUndefined();
});
test("保留 expect 字段", () => {
const target: RawTargetConfig = {
cpu: {},
expect: { usagePercent: { lte: 85 } },
id: "cpu-test",
type: "cpu",
};
const resolved = checker.resolve(target, makeResolveContext());
expect(resolved.expect).toEqual({ usagePercent: { lte: 85 } });
});
test("type 为 cpu", () => {
const target: RawTargetConfig = { cpu: {}, id: "cpu-test", type: "cpu" };
const resolved = checker.resolve(target, makeResolveContext());
expect(resolved.type).toBe("cpu");
});
});
describe("CpuChecker execute", () => {
function makeSnapshotReader(_first: CpuCoreSnapshot[], _second: CpuCoreSnapshot[]): SnapshotReader {
let callCount = 0;
const snapshots = [_first, _second];
return () => {
const result = snapshots[Math.min(callCount, snapshots.length - 1)]!;
callCount++;
return result;
};
}
test("成功匹配", async () => {
// 50% usage, 50% idle
const before = [makeCore(1000, 0, 0, 9000, 0)];
const after = [makeCore(1500, 0, 0, 9500, 0)];
const reader = makeSnapshotReader(before, after);
const checker = new CpuChecker(reader);
const target: RawTargetConfig = { cpu: {}, id: "cpu-test", type: "cpu" };
const resolved = checker.resolve(target, makeResolveContext({ defaultTimeoutMs: 5000 }));
resolved.expect = { usagePercent: { lte: 85 } };
const ctx = { signal: new AbortController().signal };
const result = await checker.execute(resolved, ctx);
expect(result.matched).toBe(true);
expect(result.failure).toBeNull();
expect(result.observation).toMatchObject({
idlePercent: 50,
logicalCoreCount: 1,
usagePercent: 50,
});
// 默认不包含 perCoreUsagePercent
expect(result.observation!["perCoreUsagePercent"]).toBeUndefined();
});
test("usagePercent mismatch", async () => {
// 90% usage: before idle=0, after idle=1000, total=10000
const before = [makeCore(0, 0, 0, 0, 0)];
const after = [makeCore(9000, 0, 0, 1000, 0)];
const reader = makeSnapshotReader(before, after);
const checker = new CpuChecker(reader);
const target: RawTargetConfig = { cpu: {}, id: "cpu-test", type: "cpu" };
const resolved = checker.resolve(target, makeResolveContext({ defaultTimeoutMs: 5000 }));
resolved.expect = { usagePercent: { lte: 50 } };
const ctx = { signal: new AbortController().signal };
const result = await checker.execute(resolved, ctx);
expect(result.matched).toBe(false);
expect(result.failure?.phase).toBe("usage");
});
test("idlePercent mismatch", async () => {
// idle = 10%: before idle=0, after idle=1000, total=10000
const before = [makeCore(0, 0, 0, 0, 0)];
const after = [makeCore(9000, 0, 0, 1000, 0)];
const reader = makeSnapshotReader(before, after);
const checker = new CpuChecker(reader);
const target: RawTargetConfig = { cpu: {}, id: "cpu-test", type: "cpu" };
const resolved = checker.resolve(target, makeResolveContext({ defaultTimeoutMs: 5000 }));
resolved.expect = { idlePercent: { gte: 80 } };
const ctx = { signal: new AbortController().signal };
const result = await checker.execute(resolved, ctx);
expect(result.matched).toBe(false);
expect(result.failure?.phase).toBe("idle");
});
test("maxCoreUsagePercent mismatch", async () => {
// core 0: 95% usage, core 1: 10% usage
const bf = [makeCore(0, 0, 0, 0, 0), makeCore(0, 0, 0, 0, 0)];
const af = [makeCore(9500, 0, 0, 500, 0), makeCore(1000, 0, 0, 9000, 0)];
const reader = makeSnapshotReader(bf, af);
const checker = new CpuChecker(reader);
const target: RawTargetConfig = { cpu: {}, id: "cpu-test", type: "cpu" };
const resolved = checker.resolve(target, makeResolveContext({ defaultTimeoutMs: 5000 }));
resolved.expect = { maxCoreUsagePercent: { lte: 80 } };
const ctx = { signal: new AbortController().signal };
const result = await checker.execute(resolved, ctx);
expect(result.matched).toBe(false);
expect(result.failure?.phase).toBe("maxCoreUsage");
});
test("minCoreUsagePercent mismatch", async () => {
// core 0: 95% usage, core 1: 10% usage
const bf = [makeCore(0, 0, 0, 0, 0), makeCore(0, 0, 0, 0, 0)];
const af = [makeCore(9500, 0, 0, 500, 0), makeCore(1000, 0, 0, 9000, 0)];
const reader = makeSnapshotReader(bf, af);
const checker = new CpuChecker(reader);
const target: RawTargetConfig = { cpu: {}, id: "cpu-test", type: "cpu" };
const resolved = checker.resolve(target, makeResolveContext({ defaultTimeoutMs: 5000 }));
resolved.expect = { minCoreUsagePercent: { gte: 50 } };
const ctx = { signal: new AbortController().signal };
const result = await checker.execute(resolved, ctx);
expect(result.matched).toBe(false);
expect(result.failure?.phase).toBe("minCoreUsage");
});
test("durationMs mismatch", async () => {
const before = [makeCore(0, 0, 0, 10000, 0)];
const after = [makeCore(1000, 0, 0, 9000, 0)];
const reader = makeSnapshotReader(before, after);
const checker = new CpuChecker(reader);
const target: RawTargetConfig = { cpu: {}, id: "cpu-test", type: "cpu" };
const resolved = checker.resolve(target, makeResolveContext({ defaultTimeoutMs: 5000 }));
resolved.expect = { durationMs: { lte: 0 } };
const ctx = { signal: new AbortController().signal };
const result = await checker.execute(resolved, ctx);
expect(result.matched).toBe(false);
expect(result.failure?.phase).toBe("duration");
});
test("超时取消", async () => {
const before = [makeCore(0, 0, 0, 10000, 0)];
const after = [makeCore(1000, 0, 0, 9000, 0)];
const reader = makeSnapshotReader(before, after);
const checker = new CpuChecker(reader);
const target: RawTargetConfig = { cpu: { sampleDuration: "10s" }, id: "cpu-test", type: "cpu" };
const resolved = checker.resolve(target, makeResolveContext({ defaultTimeoutMs: 100 }));
const controller = new AbortController();
controller.abort();
const result = await checker.execute(resolved, { signal: controller.signal });
expect(result.matched).toBe(false);
expect(result.failure?.phase).toBe("cpu");
expect(result.failure?.path).toBe("timeout");
});
test("第二次 snapshot 抛错返回 cpu/snapshot failure", async () => {
const before = [makeCore(0, 0, 0, 10000, 0)];
let callCount = 0;
const reader: SnapshotReader = () => {
callCount++;
if (callCount === 1) return before;
throw new Error("second snapshot failed");
};
const checker = new CpuChecker(reader);
const target: RawTargetConfig = { cpu: {}, id: "cpu-test", type: "cpu" };
const resolved = checker.resolve(target, makeResolveContext({ defaultTimeoutMs: 5000 }));
const ctx = { signal: new AbortController().signal };
const result = await checker.execute(resolved, ctx);
expect(result.matched).toBe(false);
expect(result.failure?.phase).toBe("cpu");
expect(result.failure?.path).toBe("snapshot");
expect(result.observation).toBeNull();
});
test("空 snapshot pair 返回 cpu/snapshot failure", async () => {
const reader: SnapshotReader = () => [];
const checker = new CpuChecker(reader);
const target: RawTargetConfig = { cpu: {}, id: "cpu-test", type: "cpu" };
const resolved = checker.resolve(target, makeResolveContext({ defaultTimeoutMs: 5000 }));
const ctx = { signal: new AbortController().signal };
const result = await checker.execute(resolved, ctx);
expect(result.matched).toBe(false);
expect(result.failure?.phase).toBe("cpu");
expect(result.failure?.path).toBe("snapshot");
});
test("核心数不一致返回 cpu/snapshot failure", async () => {
let callCount = 0;
const snapshots = [[makeCore(0, 0, 0, 100, 0)], [makeCore(0, 0, 0, 100, 0), makeCore(0, 0, 0, 100, 0)]];
const reader: SnapshotReader = () => {
const result = snapshots[Math.min(callCount, snapshots.length - 1)]!;
callCount++;
return result;
};
const checker = new CpuChecker(reader);
const target: RawTargetConfig = { cpu: {}, id: "cpu-test", type: "cpu" };
const resolved = checker.resolve(target, makeResolveContext({ defaultTimeoutMs: 5000 }));
const ctx = { signal: new AbortController().signal };
const result = await checker.execute(resolved, ctx);
expect(result.matched).toBe(false);
expect(result.failure?.phase).toBe("cpu");
expect(result.failure?.path).toBe("snapshot");
expect(result.failure?.message).toContain("核心数不一致");
});
test("非有限 CPU time 值返回 cpu/snapshot failure", async () => {
let callCount = 0;
const snapshots: CpuCoreSnapshot[][] = [
[makeCore(0, 0, 0, 100, 0)],
[{ times: { idle: NaN, irq: 0, nice: 0, sys: 0, user: 100 } }],
];
const reader: SnapshotReader = () => {
const result = snapshots[Math.min(callCount, snapshots.length - 1)]!;
callCount++;
return result;
};
const checker = new CpuChecker(reader);
const target: RawTargetConfig = { cpu: {}, id: "cpu-test", type: "cpu" };
const resolved = checker.resolve(target, makeResolveContext({ defaultTimeoutMs: 5000 }));
const ctx = { signal: new AbortController().signal };
const result = await checker.execute(resolved, ctx);
expect(result.matched).toBe(false);
expect(result.failure?.phase).toBe("cpu");
expect(result.failure?.path).toBe("snapshot");
expect(result.failure?.message).toContain("非有限值");
});
test("负数 CPU time delta 返回 cpu/snapshot failure", async () => {
const before = [makeCore(1000, 0, 0, 0, 0)];
const after = [makeCore(100, 0, 0, 0, 0)];
const reader = makeSnapshotReader(before, after);
const checker = new CpuChecker(reader);
const target: RawTargetConfig = { cpu: {}, id: "cpu-test", type: "cpu" };
const resolved = checker.resolve(target, makeResolveContext({ defaultTimeoutMs: 5000 }));
const ctx = { signal: new AbortController().signal };
const result = await checker.execute(resolved, ctx);
expect(result.matched).toBe(false);
expect(result.failure?.phase).toBe("cpu");
expect(result.failure?.path).toBe("snapshot");
expect(result.failure?.message).toContain("负数 delta");
});
test("零 delta snapshot 返回稳定安全值", async () => {
const before = [makeCore(100, 0, 0, 100, 0)];
const after = [makeCore(100, 0, 0, 100, 0)];
const reader = makeSnapshotReader(before, after);
const checker = new CpuChecker(reader);
const target: RawTargetConfig = { cpu: {}, id: "cpu-test", type: "cpu" };
const resolved = checker.resolve(target, makeResolveContext({ defaultTimeoutMs: 5000 }));
const ctx = { signal: new AbortController().signal };
const result = await checker.execute(resolved, ctx);
expect(result.matched).toBe(true);
expect(result.failure).toBeNull();
expect(result.observation).toMatchObject({
idlePercent: 0,
maxCoreUsagePercent: 0,
minCoreUsagePercent: 0,
usagePercent: 0,
});
});
test("includePerCore=true 时输出 perCoreUsagePercent", async () => {
const before = [makeCore(0, 0, 0, 0, 0), makeCore(0, 0, 0, 0, 0)];
const after = [makeCore(8000, 0, 0, 2000, 0), makeCore(2000, 0, 0, 8000, 0)];
const reader = makeSnapshotReader(before, after);
const checker = new CpuChecker(reader);
const target: RawTargetConfig = { cpu: { includePerCore: true }, id: "cpu-test", type: "cpu" };
const resolved = checker.resolve(target, makeResolveContext({ defaultTimeoutMs: 5000 }));
const ctx = { signal: new AbortController().signal };
const result = await checker.execute(resolved, ctx);
expect(result.observation).toMatchObject({
perCoreUsagePercent: [80, 20],
});
});
});
describe("CpuChecker buildDetail", () => {
test("正常输出格式", () => {
const checker = new CpuChecker();
const detail = checker.buildDetail({
idlePercent: 40,
logicalCoreCount: 8,
maxCoreUsagePercent: 91.5,
minCoreUsagePercent: 8.2,
usagePercent: 60,
});
expect(detail).toBe("usage 60%, max core 91.5%, 8 cores");
});
});
describe("CpuChecker serialize", () => {
test("序列化输出", () => {
const checker = new CpuChecker();
const target: RawTargetConfig = { cpu: { sampleDuration: "1s" }, id: "cpu-test", type: "cpu" };
const resolved = checker.resolve(target, makeResolveContext());
const result = checker.serialize(resolved);
expect(result.target).toBe("cpu sample 1000ms");
const config = JSON.parse(result.config) as { sampleDurationMs: number };
expect(config.sampleDurationMs).toBe(1000);
});
});

View File

@@ -0,0 +1,55 @@
import { describe, expect, test } from "bun:test";
import {
checkIdlePercent,
checkMaxCoreUsage,
checkMinCoreUsage,
checkUsagePercent,
} from "../../../../../src/server/checker/runner/cpu/expect";
describe("CPU expect checks", () => {
test("checkUsagePercent 匹配", () => {
expect(checkUsagePercent(50, { lte: 85 }).matched).toBe(true);
});
test("checkUsagePercent 不匹配", () => {
const result = checkUsagePercent(90, { lte: 85 });
expect(result.matched).toBe(false);
expect(result.failure?.phase).toBe("usage");
});
test("checkIdlePercent 匹配", () => {
expect(checkIdlePercent(50, { gte: 15 }).matched).toBe(true);
});
test("checkIdlePercent 不匹配", () => {
const result = checkIdlePercent(10, { gte: 15 });
expect(result.matched).toBe(false);
expect(result.failure?.phase).toBe("idle");
});
test("checkMaxCoreUsage 匹配", () => {
expect(checkMaxCoreUsage(80, { lte: 95 }).matched).toBe(true);
});
test("checkMaxCoreUsage 不匹配", () => {
const result = checkMaxCoreUsage(96, { lte: 95 });
expect(result.matched).toBe(false);
expect(result.failure?.phase).toBe("maxCoreUsage");
});
test("checkMinCoreUsage 匹配", () => {
expect(checkMinCoreUsage(10, { gte: 5 }).matched).toBe(true);
});
test("checkMinCoreUsage 不匹配", () => {
const result = checkMinCoreUsage(3, { gte: 5 });
expect(result.matched).toBe(false);
expect(result.failure?.phase).toBe("minCoreUsage");
});
test("undefined matcher 直接通过", () => {
expect(checkUsagePercent(99.9, undefined).matched).toBe(true);
expect(checkIdlePercent(0, undefined).matched).toBe(true);
});
});

View File

@@ -0,0 +1,41 @@
import { describe, expect, test } from "bun:test";
import { normalizeTargetExpect } from "../../../../../src/server/checker/runner/cpu/normalize";
describe("normalizeTargetExpect (cpu)", () => {
test("无 expect 直接返回", () => {
const target = { cpu: {}, id: "test", type: "cpu" };
expect(normalizeTargetExpect(target)).toEqual(target);
});
test("expect 为非对象直接返回", () => {
const target = { cpu: {}, expect: "not-an-object", id: "test", type: "cpu" };
expect(normalizeTargetExpect(target)).toEqual(target);
});
test("ValueMatcher 简写展开", () => {
const target = { cpu: {}, expect: { usagePercent: 85 }, id: "test", type: "cpu" };
const result = normalizeTargetExpect(target);
expect((result.expect as Record<string, unknown>)["usagePercent"]).toEqual({ equals: 85 });
});
test("已经是 matcher 对象的不变", () => {
const target = { cpu: {}, expect: { usagePercent: { lte: 85 } }, id: "test", type: "cpu" };
const result = normalizeTargetExpect(target);
expect((result.expect as Record<string, unknown>)["usagePercent"]).toEqual({ lte: 85 });
});
test("多个字段同时展开", () => {
const target = {
cpu: {},
expect: { idlePercent: 15, maxCoreUsagePercent: { lte: 95 }, usagePercent: 85 },
id: "test",
type: "cpu",
};
const result = normalizeTargetExpect(target);
const expectObj = result.expect as Record<string, unknown>;
expect(expectObj["idlePercent"]).toEqual({ equals: 15 });
expect(expectObj["maxCoreUsagePercent"]).toEqual({ lte: 95 });
expect(expectObj["usagePercent"]).toEqual({ equals: 85 });
});
});

View File

@@ -0,0 +1,77 @@
import Ajv from "ajv";
import { describe, expect, test } from "bun:test";
import { cpuCheckerSchemas } from "../../../../../src/server/checker/runner/cpu/schema";
const ajv = new Ajv({ strict: false });
describe("CPU checker schema", () => {
test("authoring config 允许变量引用", () => {
const validate = ajv.compile(cpuCheckerSchemas.authoring.config);
expect(validate({ includePerCore: "${per_core|false}", sampleDuration: "${sample_dur|1s}" })).toBe(true);
});
test("normalized config 允许合法值", () => {
const validate = ajv.compile(cpuCheckerSchemas.normalized.config);
expect(validate({ includePerCore: true, sampleDuration: "1s" })).toBe(true);
});
test("normalized config 空配置通过", () => {
const validate = ajv.compile(cpuCheckerSchemas.normalized.config);
expect(validate({})).toBe(true);
});
test("config 拒绝额外字段", () => {
const validate = ajv.compile(cpuCheckerSchemas.authoring.config);
expect(validate({ extraField: true })).toBe(false);
});
test("authoring expect 允许 ValueMatcher 简写", () => {
const validate = ajv.compile(cpuCheckerSchemas.authoring.expect);
expect(validate({ usagePercent: 85 })).toBe(true);
expect(validate({ usagePercent: { lte: 85 } })).toBe(true);
});
test("normalized expect 允许 matcher 对象", () => {
const validate = ajv.compile(cpuCheckerSchemas.normalized.expect);
expect(validate({ idlePercent: { gte: 15 }, usagePercent: { lte: 85 } })).toBe(true);
});
test("expect 拒绝 logicalCoreCount 字段", () => {
const validate = ajv.compile(cpuCheckerSchemas.authoring.expect);
expect(validate({ logicalCoreCount: { gte: 4 } })).toBe(false);
});
test("expect 拒绝 userPercent 字段", () => {
const validate = ajv.compile(cpuCheckerSchemas.authoring.expect);
expect(validate({ userPercent: { lte: 50 } })).toBe(false);
});
test("expect 拒绝 systemPercent 字段", () => {
const validate = ajv.compile(cpuCheckerSchemas.authoring.expect);
expect(validate({ systemPercent: { lte: 50 } })).toBe(false);
});
test("expect 允许所有合法字段", () => {
const validate = ajv.compile(cpuCheckerSchemas.normalized.expect);
expect(
validate({
durationMs: { lte: 2000 },
idlePercent: { gte: 15 },
maxCoreUsagePercent: { lte: 95 },
minCoreUsagePercent: { gte: 0 },
usagePercent: { lte: 85 },
}),
).toBe(true);
});
test("expect 拒绝额外字段", () => {
const validate = ajv.compile(cpuCheckerSchemas.normalized.expect);
expect(validate({ unknownField: 1 })).toBe(false);
});
test("expect 空对象通过", () => {
const validate = ajv.compile(cpuCheckerSchemas.normalized.expect);
expect(validate({})).toBe(true);
});
});

View File

@@ -0,0 +1,84 @@
import { describe, expect, test } from "bun:test";
import type { RawTargetConfig } from "../../../../../src/server/checker/types";
import { validateCpuConfig } from "../../../../../src/server/checker/runner/cpu/validate";
function validate(target: RawTargetConfig) {
return validateCpuConfig({ targets: [target] });
}
describe("validateCpuConfig", () => {
test("有效配置无错误", () => {
expect(validate({ cpu: { sampleDuration: "1s" }, id: "cpu-test", type: "cpu" })).toEqual([]);
});
test("空 cpu 配置无错误", () => {
expect(validate({ cpu: {}, id: "cpu-test", type: "cpu" })).toEqual([]);
});
test("缺少 cpu 配置分组", () => {
const issues = validate({ id: "cpu-test", type: "cpu" });
expect(issues.some((i) => i.path.endsWith("cpu") && i.code === "required")).toBe(true);
});
test("无效 sampleDuration 格式", () => {
const issues = validate({ cpu: { sampleDuration: "abc" }, id: "cpu-test", type: "cpu" });
expect(issues.some((i) => i.path.endsWith("sampleDuration"))).toBe(true);
});
test("sampleDuration >= timeout 报错", () => {
const issues = validate({ cpu: { sampleDuration: "5s" }, id: "cpu-test", timeout: "5s", type: "cpu" });
expect(issues.some((i) => i.path.endsWith("sampleDuration") && i.message.includes("必须小于 timeout"))).toBe(true);
});
test("sampleDuration 大于默认 timeout (10s) 报错", () => {
const issues = validate({ cpu: { sampleDuration: "15s" }, id: "cpu-test", type: "cpu" });
expect(issues.some((i) => i.message.includes("默认 10s"))).toBe(true);
});
test("sampleDuration < timeout 通过", () => {
const issues = validate({ cpu: { sampleDuration: "1s" }, id: "cpu-test", timeout: "5s", type: "cpu" });
expect(issues.some((i) => i.path.includes("sampleDuration"))).toBe(false);
});
test("includePerCore 非布尔值报错", () => {
const issues = validate({ cpu: { includePerCore: "yes" }, id: "cpu-test", type: "cpu" });
expect(issues.some((i) => i.path.endsWith("includePerCore") && i.code === "invalid-type")).toBe(true);
});
test("cpu 未知字段报错", () => {
const issues = validate({ cpu: { extra: true }, id: "cpu-test", type: "cpu" });
expect(issues.some((i) => i.path.endsWith("extra") && i.code === "unknown-field")).toBe(true);
});
test("expect 未知字段报错", () => {
const issues = validate({ cpu: {}, expect: { logicalCoreCount: { gte: 4 } }, id: "cpu-test", type: "cpu" });
expect(issues.some((i) => i.path.endsWith("logicalCoreCount") && i.code === "unknown-field")).toBe(true);
});
test("expect userPercent 未知字段报错", () => {
const issues = validate({ cpu: {}, expect: { userPercent: { lte: 50 } }, id: "cpu-test", type: "cpu" });
expect(issues.some((i) => i.path.endsWith("userPercent") && i.code === "unknown-field")).toBe(true);
});
test("expect systemPercent 未知字段报错", () => {
const issues = validate({ cpu: {}, expect: { systemPercent: { lte: 50 } }, id: "cpu-test", type: "cpu" });
expect(issues.some((i) => i.path.endsWith("systemPercent") && i.code === "unknown-field")).toBe(true);
});
test("expect 合法 ValueMatcher 通过", () => {
const issues = validate({
cpu: {},
expect: { maxCoreUsagePercent: { lte: 95 }, usagePercent: { lte: 85 } },
id: "cpu-test",
type: "cpu",
});
expect(issues.filter((i) => i.path.includes("expect"))).toEqual([]);
});
test("expect 非法 ValueMatcher 报错", () => {
const issues = validate({ cpu: {}, expect: { usagePercent: [1, 2] }, id: "cpu-test", type: "cpu" });
expect(issues.some((i) => i.path.includes("usagePercent"))).toBe(true);
});
});

View File

@@ -2,6 +2,7 @@ import { describe, expect, test } from "bun:test";
import { CommandChecker } from "../../../../src/server/checker/runner/cmd/execute";
import { DbChecker } from "../../../../src/server/checker/runner/db/execute";
import { DnsChecker } from "../../../../src/server/checker/runner/dns/execute";
import { HttpChecker } from "../../../../src/server/checker/runner/http/execute";
import { IcmpChecker } from "../../../../src/server/checker/runner/icmp/execute";
import { LlmChecker } from "../../../../src/server/checker/runner/llm/execute";
@@ -64,4 +65,142 @@ describe("Checker buildDetail", () => {
expect(detail).toContain("output=2 chars");
expect(detail).toContain("usage=12/2 tokens");
});
const dnsChecker = new DnsChecker();
test("DnsChecker system mode: successful resolution shows addresses and duration", () => {
const detail = dnsChecker.buildDetail({
durationMs: 15,
error: null,
family: "any",
name: "example.com",
resolver: "system",
valueCount: 2,
values: ["93.184.216.34", "93.184.216.35"],
});
expect(detail).toContain("93.184.216.34");
expect(detail).toContain("15ms");
});
test("DnsChecker system mode: failed resolution shows error", () => {
const detail = dnsChecker.buildDetail({
durationMs: 100,
error: "getaddrinfo ENOTFOUND",
family: "any",
name: "example.com",
resolver: "system",
valueCount: 0,
values: [],
});
expect(detail).toContain("解析失败");
expect(detail).toContain("getaddrinfo ENOTFOUND");
});
test("DnsChecker system mode: no results shows '解析成功但无结果'", () => {
const detail = dnsChecker.buildDetail({
durationMs: 10,
error: null,
family: "any",
name: "example.com",
resolver: "system",
valueCount: 0,
values: [],
});
expect(detail).toContain("解析成功但无结果");
});
test("DnsChecker server mode: successful response shows rcode, values, protocol, duration", () => {
const detail = dnsChecker.buildDetail({
durationMs: 25,
error: null,
name: "example.com",
port: 53,
protocol: "udp",
protocolUsed: "udp",
rcode: "NOERROR",
recordType: "A",
resolver: "server",
responded: true,
server: "8.8.8.8",
valueCount: 2,
values: ["1.1.1.1", "2.2.2.2"],
});
expect(detail).toContain("NOERROR");
expect(detail).toContain("1.1.1.1");
expect(detail).toContain("UDP");
expect(detail).toContain("25ms");
});
test("DnsChecker server mode: NXDOMAIN shows rcode", () => {
const detail = dnsChecker.buildDetail({
durationMs: 30,
error: null,
name: "example.com",
port: 53,
protocol: "udp",
protocolUsed: "udp",
rcode: "NXDOMAIN",
recordType: "A",
resolver: "server",
responded: true,
server: "8.8.8.8",
valueCount: 0,
values: [],
});
expect(detail).toContain("NXDOMAIN");
});
test("DnsChecker server mode: no response shows error", () => {
const detail = dnsChecker.buildDetail({
durationMs: 500,
error: "探测超时",
name: "example.com",
port: 53,
protocol: "udp",
resolver: "server",
responded: false,
server: "8.8.8.8",
});
expect(detail).toContain("查询失败");
expect(detail).toContain("探测超时");
});
test("DnsChecker server mode: CNAME chain shows CNAME chain", () => {
const detail = dnsChecker.buildDetail({
cnameChain: ["cdn.example.com", "cdn-edge.example.net"],
durationMs: 40,
error: null,
name: "example.com",
port: 53,
protocol: "udp",
protocolUsed: "udp",
rcode: "NOERROR",
recordType: "A",
resolver: "server",
responded: true,
server: "8.8.8.8",
valueCount: 1,
values: ["93.184.216.34"],
});
expect(detail).toContain("CNAME: cdn.example.com → cdn-edge.example.net");
});
test("DnsChecker server mode: TCP fallback shows TCP in output", () => {
const detail = dnsChecker.buildDetail({
durationMs: 50,
error: null,
name: "example.com",
port: 53,
protocol: "udp",
protocolUsed: "tcp",
rcode: "NOERROR",
recordType: "A",
resolver: "server",
responded: true,
server: "8.8.8.8",
valueCount: 1,
values: ["1.2.3.4"],
});
expect(detail).toContain("TCP");
});
});

View File

@@ -0,0 +1,505 @@
import { describe, expect, it } from "bun:test";
import {
buildQuery,
parseResponse,
rcodeName,
rrtTypeByName,
rrtTypeName,
} from "../../../../../src/server/checker/runner/dns/codec";
function buildName(name: string): Uint8Array {
const parts: number[] = [];
for (const label of name.split(".")) {
const encoded = new TextEncoder().encode(label);
parts.push(encoded.length);
parts.push(...encoded);
}
parts.push(0);
return new Uint8Array(parts);
}
function buildResponse(options: {
answers?: Array<{ class?: number; name: string; rdata: Uint8Array; ttl: number; type: number }>;
flags?: { aa?: boolean; ad?: boolean; ra?: boolean; rd?: boolean; tc?: boolean };
questions?: Array<{ name: string; qclass?: number; qtype: number }>;
rcode?: number;
}): Uint8Array {
const questions = options.questions ?? [];
const answers = options.answers ?? [];
const id = 0x1234;
let flags = 0x8000;
if (options.flags?.aa) flags |= 0x0400;
if (options.flags?.tc) flags |= 0x0200;
if (options.flags?.rd) flags |= 0x0100;
if (options.flags?.ra) flags |= 0x0080;
if (options.flags?.ad) flags |= 0x0020;
flags |= (options.rcode ?? 0) & 0x000f;
const header = new Uint8Array(12);
const hv = new DataView(header.buffer);
hv.setUint16(0, id);
hv.setUint16(2, flags);
hv.setUint16(4, questions.length);
hv.setUint16(6, answers.length);
hv.setUint16(8, 0);
hv.setUint16(10, 0);
const qParts: Uint8Array[] = [];
for (const q of questions) {
const nameBytes = buildName(q.name);
const qtype = new Uint8Array(4);
const qv = new DataView(qtype.buffer);
qv.setUint16(0, q.qtype);
qv.setUint16(2, q.qclass ?? 1);
qParts.push(nameBytes, qtype);
}
const aParts: Uint8Array[] = [];
for (const a of answers) {
const nameBytes = buildName(a.name);
const rrHead = new Uint8Array(10);
const rv = new DataView(rrHead.buffer);
rv.setUint16(0, a.type);
rv.setUint16(2, a.class ?? 1);
rv.setUint32(4, a.ttl);
rv.setUint16(8, a.rdata.length);
aParts.push(nameBytes, rrHead, a.rdata);
}
const allParts = [header, ...qParts, ...aParts];
const totalLen = allParts.reduce((s, p) => s + p.length, 0);
const result = new Uint8Array(totalLen);
let offset = 0;
for (const part of allParts) {
result.set(part, offset);
offset += part.length;
}
return result;
}
describe("buildQuery", () => {
it("produces a query buffer with correct header (QR=0, OPCODE=0, RD flag)", () => {
const buf = buildQuery("example.com", 1, true);
const view = new DataView(buf.buffer, buf.byteOffset, buf.byteLength);
expect(buf.length).toBeGreaterThan(12);
const flags = view.getUint16(2);
expect(flags & 0x8000).toBe(0);
expect(flags & 0x7800).toBe(0);
expect(flags & 0x0100).toBe(0x0100);
expect(view.getUint16(4)).toBe(1);
expect(view.getUint16(6)).toBe(0);
expect(view.getUint16(8)).toBe(0);
expect(view.getUint16(10)).toBe(0);
});
it("encodes domain name correctly (labels with length prefixes, null terminator)", () => {
const buf = buildQuery("example.com", 1, false);
const nameBytes = buf.subarray(12, buf.length - 4);
const expected = buildName("example.com");
expect(Array.from(nameBytes)).toEqual(Array.from(expected));
});
it("encodes trailing-dot FQDN without an extra root label", () => {
const normal = buildQuery("example.com", 1, false);
const fqdn = buildQuery("example.com.", 1, false);
expect(Array.from(fqdn.subarray(12))).toEqual(Array.from(normal.subarray(12)));
});
it("encodes root domain", () => {
const buf = buildQuery(".", 1, false);
expect(Array.from(buf.subarray(12, buf.length - 4))).toEqual([0]);
});
it("rejects non-ASCII names instead of writing malformed UTF-8 labels", () => {
expect(() => buildQuery("é.example", 1, false)).toThrow("ASCII/Punycode");
});
it("rejects labels longer than 63 bytes", () => {
expect(() => buildQuery(`${"a".repeat(64)}.example`, 1, false)).toThrow("63 字节");
});
it("sets correct QTYPE and QCLASS", () => {
const buf = buildQuery("example.com", 28, false);
const view = new DataView(buf.buffer, buf.byteOffset, buf.byteLength);
const qtypeOffset = buf.length - 4;
expect(view.getUint16(qtypeOffset)).toBe(28);
expect(view.getUint16(qtypeOffset + 2)).toBe(1);
});
it("sets RD=1 when recursionDesired=true, RD=0 when false", () => {
const bufRD = buildQuery("example.com", 1, true);
const viewRD = new DataView(bufRD.buffer, bufRD.byteOffset, bufRD.byteLength);
expect(viewRD.getUint16(2) & 0x0100).toBe(0x0100);
const bufNoRD = buildQuery("example.com", 1, false);
const viewNoRD = new DataView(bufNoRD.buffer, bufNoRD.byteOffset, bufNoRD.byteLength);
expect(viewNoRD.getUint16(2) & 0x0100).toBe(0);
});
});
describe("parseResponse", () => {
it("parses a minimal NOERROR response (no answers)", () => {
const resp = buildResponse({
questions: [{ name: "example.com", qtype: 1 }],
rcode: 0,
});
const result = parseResponse(resp);
expect(result.header.rcode).toBe(0);
expect(result.header.answerCount).toBe(0);
expect(result.answers).toHaveLength(0);
expect(result.questions).toHaveLength(1);
expect(result.questions[0]!.name).toBe("example.com");
expect(result.questions[0]!.qtype).toBe(1);
});
it("parses response with single A record", () => {
const rdata = new Uint8Array([93, 184, 216, 34]);
const resp = buildResponse({
answers: [{ name: "example.com", rdata, ttl: 300, type: 1 }],
questions: [{ name: "example.com", qtype: 1 }],
rcode: 0,
});
const result = parseResponse(resp);
expect(result.answers).toHaveLength(1);
const a = result.answers[0]!;
expect(a.type).toBe(1);
expect(a.value).toBe("93.184.216.34");
expect(a.ttl).toBe(300);
expect(a.name).toBe("example.com");
});
it("parses response with multiple A records", () => {
const rdata1 = new Uint8Array([1, 1, 1, 1]);
const rdata2 = new Uint8Array([8, 8, 8, 8]);
const resp = buildResponse({
answers: [
{ name: "example.com", rdata: rdata1, ttl: 60, type: 1 },
{ name: "example.com", rdata: rdata2, ttl: 60, type: 1 },
],
rcode: 0,
});
const result = parseResponse(resp);
expect(result.answers).toHaveLength(2);
expect(result.answers[0]!.value).toBe("1.1.1.1");
expect(result.answers[1]!.value).toBe("8.8.8.8");
});
it("parses AAAA record", () => {
const rdata = new Uint8Array([
0x26, 0x07, 0xf8, 0xb0, 0x40, 0x05, 0x08, 0x0c, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x01,
]);
const resp = buildResponse({
answers: [{ name: "example.com", rdata, ttl: 300, type: 28 }],
});
const result = parseResponse(resp);
expect(result.answers[0]!.value).toBe("2607:f8b0:4005:80c::1");
});
it("parses CNAME record", () => {
const rdata = buildName("www.example.com");
const resp = buildResponse({
answers: [{ name: "alias.example.com", rdata, ttl: 300, type: 5 }],
});
const result = parseResponse(resp);
expect(result.answers[0]!.value).toBe("www.example.com");
expect(result.answers[0]!.data["target"]).toBe("www.example.com");
});
it("parses MX record (preference + exchange)", () => {
const exchange = buildName("mail.example.com");
const rdata = new Uint8Array(2 + exchange.length);
rdata[0] = 0x00;
rdata[1] = 0x0a;
rdata.set(exchange, 2);
const resp = buildResponse({
answers: [{ name: "example.com", rdata, ttl: 3600, type: 15 }],
});
const result = parseResponse(resp);
const mx = result.answers[0]!;
expect(mx.value).toBe("10 mail.example.com");
expect(mx.data["preference"]).toBe(10);
expect(mx.data["exchange"]).toBe("mail.example.com");
});
it("parses TXT record with single character-string", () => {
const txt = new TextEncoder().encode("v=spf1 include:_spf.example.com ~all");
const rdata = new Uint8Array(1 + txt.length);
rdata[0] = txt.length;
rdata.set(txt, 1);
const resp = buildResponse({
answers: [{ name: "example.com", rdata, ttl: 300, type: 16 }],
});
const result = parseResponse(resp);
expect(result.answers[0]!.value).toBe("v=spf1 include:_spf.example.com ~all");
});
it("parses TXT record with multiple character-strings", () => {
const s1 = new TextEncoder().encode("hello ");
const s2 = new TextEncoder().encode("world");
const rdata = new Uint8Array(1 + s1.length + 1 + s2.length);
rdata[0] = s1.length;
rdata.set(s1, 1);
rdata[1 + s1.length] = s2.length;
rdata.set(s2, 1 + s1.length + 1);
const resp = buildResponse({
answers: [{ name: "example.com", rdata, ttl: 300, type: 16 }],
});
const result = parseResponse(resp);
expect(result.answers[0]!.value).toBe("hello world");
});
it("parses NS record", () => {
const rdata = buildName("ns1.example.com");
const resp = buildResponse({
answers: [{ name: "example.com", rdata, ttl: 86400, type: 2 }],
});
const result = parseResponse(resp);
expect(result.answers[0]!.value).toBe("ns1.example.com");
expect(result.answers[0]!.data["nsdname"]).toBe("ns1.example.com");
});
it("parses SOA record (all 7 fields)", () => {
const mname = buildName("ns1.example.com");
const rname = buildName("admin.example.com");
const fixed = new Uint8Array(20);
const fv = new DataView(fixed.buffer);
fv.setUint32(0, 2024010101);
fv.setUint32(4, 3600);
fv.setUint32(8, 900);
fv.setUint32(12, 604800);
fv.setUint32(16, 86400);
const rdata = new Uint8Array(mname.length + rname.length + fixed.length);
rdata.set(mname, 0);
rdata.set(rname, mname.length);
rdata.set(fixed, mname.length + rname.length);
const resp = buildResponse({
answers: [{ name: "example.com", rdata, ttl: 3600, type: 6 }],
});
const result = parseResponse(resp);
const soa = result.answers[0]!;
expect(soa.data["mname"]).toBe("ns1.example.com");
expect(soa.data["rname"]).toBe("admin.example.com");
expect(soa.data["serial"]).toBe(2024010101);
expect(soa.data["refresh"]).toBe(3600);
expect(soa.data["retry"]).toBe(900);
expect(soa.data["expire"]).toBe(604800);
expect(soa.data["minimum"]).toBe(86400);
expect(soa.value).toBe("ns1.example.com admin.example.com 2024010101 3600 900 604800 86400");
});
it("parses SRV record (priority, weight, port, target)", () => {
const target = buildName("server.example.com");
const rdata = new Uint8Array(6 + target.length);
const rv = new DataView(rdata.buffer);
rv.setUint16(0, 10);
rv.setUint16(2, 20);
rv.setUint16(4, 443);
rdata.set(target, 6);
const resp = buildResponse({
answers: [{ name: "_https._tcp.example.com", rdata, ttl: 300, type: 33 }],
});
const result = parseResponse(resp);
const srv = result.answers[0]!;
expect(srv.data["priority"]).toBe(10);
expect(srv.data["weight"]).toBe(20);
expect(srv.data["port"]).toBe(443);
expect(srv.data["target"]).toBe("server.example.com");
expect(srv.value).toBe("10 20 443 server.example.com");
});
it("parses PTR record", () => {
const rdata = buildName("host.example.com");
const resp = buildResponse({
answers: [{ name: "1.0.0.127.in-addr.arpa", rdata, ttl: 300, type: 12 }],
questions: [{ name: "1.0.0.127.in-addr.arpa", qtype: 12 }],
});
const result = parseResponse(resp);
expect(result.answers[0]!.value).toBe("host.example.com");
expect(result.answers[0]!.data["ptrdname"]).toBe("host.example.com");
});
it("parses CAA record (flags, tag, value)", () => {
const tag = new TextEncoder().encode("issue");
const value = new TextEncoder().encode("letsencrypt.org");
const rdata = new Uint8Array(2 + tag.length + value.length);
rdata[0] = 0;
rdata[1] = tag.length;
rdata.set(tag, 2);
rdata.set(value, 2 + tag.length);
const resp = buildResponse({
answers: [{ name: "example.com", rdata, ttl: 300, type: 257 }],
});
const result = parseResponse(resp);
const caa = result.answers[0]!;
expect(caa.data["flags"]).toBe(0);
expect(caa.data["tag"]).toBe("issue");
expect(caa.data["valueStr"]).toBe("letsencrypt.org");
expect(caa.value).toBe("0 issue letsencrypt.org");
});
it("parses response with DNS name compression (pointer)", () => {
const nameBytes = buildName("example.com");
const nameLen = nameBytes.length;
const headerSize = 12;
const qSectionEnd = headerSize + nameLen + 4;
const totalSize = qSectionEnd + nameLen + 10 + 4;
const buf = new Uint8Array(totalSize);
const view = new DataView(buf.buffer);
view.setUint16(0, 0xabcd);
view.setUint16(2, 0x8180);
view.setUint16(4, 1);
view.setUint16(6, 1);
view.setUint16(8, 0);
view.setUint16(10, 0);
buf.set(nameBytes, headerSize);
view.setUint16(headerSize + nameLen, 1);
view.setUint16(headerSize + nameLen + 2, 1);
const ptrByte1 = 0xc0 | ((headerSize >> 8) & 0x3f);
const ptrByte2 = headerSize & 0xff;
buf[qSectionEnd] = ptrByte1;
buf[qSectionEnd + 1] = ptrByte2;
view.setUint16(qSectionEnd + 2, 1);
view.setUint16(qSectionEnd + 4, 1);
view.setUint32(qSectionEnd + 6, 300);
view.setUint16(qSectionEnd + 10, 4);
buf[qSectionEnd + 12] = 93;
buf[qSectionEnd + 13] = 184;
buf[qSectionEnd + 14] = 216;
buf[qSectionEnd + 15] = 34;
const result = parseResponse(buf);
expect(result.answers[0]!.name).toBe("example.com");
expect(result.answers[0]!.value).toBe("93.184.216.34");
});
it("correctly reports flags (AA, RA, RD, TC, AD)", () => {
const resp = buildResponse({
flags: { aa: true, ad: true, ra: true, rd: true, tc: false },
});
const result = parseResponse(resp);
expect(result.header.flags.authoritative).toBe(true);
expect(result.header.flags.recursionAvailable).toBe(true);
expect(result.header.flags.recursionDesired).toBe(true);
expect(result.header.flags.truncated).toBe(false);
expect(result.header.flags.authenticatedData).toBe(true);
});
it("reports correct rcode", () => {
const resp = buildResponse({ rcode: 3 });
const result = parseResponse(resp);
expect(result.header.rcode).toBe(3);
});
it("reports correct answerCount, authorityCount, additionalCount", () => {
const a1 = new Uint8Array([1, 2, 3, 4]);
const a2 = new Uint8Array([5, 6, 7, 8]);
const header = new Uint8Array(12);
const hv = new DataView(header.buffer);
hv.setUint16(0, 0x0001);
hv.setUint16(2, 0x8180);
hv.setUint16(4, 0);
hv.setUint16(6, 1);
hv.setUint16(8, 1);
hv.setUint16(10, 1);
const nameBytes = buildName("example.com");
const buildRR = (rdata: Uint8Array) => {
const rr = new Uint8Array(nameBytes.length + 10 + rdata.length);
rr.set(nameBytes, 0);
const rv = new DataView(rr.buffer);
rv.setUint16(nameBytes.length, 1);
rv.setUint16(nameBytes.length + 2, 1);
rv.setUint32(nameBytes.length + 4, 60);
rv.setUint16(nameBytes.length + 8, rdata.length);
rr.set(rdata, nameBytes.length + 10);
return rr;
};
const rr1 = buildRR(a1);
const rr2 = buildRR(a2);
const rr3 = buildRR(new Uint8Array([9, 10, 11, 12]));
const buf = new Uint8Array(12 + rr1.length + rr2.length + rr3.length);
buf.set(header, 0);
buf.set(rr1, 12);
buf.set(rr2, 12 + rr1.length);
buf.set(rr3, 12 + rr1.length + rr2.length);
const result = parseResponse(buf);
expect(result.header.answerCount).toBe(1);
expect(result.header.authorityCount).toBe(1);
expect(result.header.additionalCount).toBe(1);
expect(result.answers).toHaveLength(1);
expect(result.authorities).toHaveLength(1);
expect(result.additional).toHaveLength(1);
});
it("throws on response shorter than 12 bytes", () => {
expect(() => parseResponse(new Uint8Array(11))).toThrow();
expect(() => parseResponse(new Uint8Array(0))).toThrow();
});
it("handles unknown record types (raw hex in value)", () => {
const rdata = new Uint8Array([0xde, 0xad, 0xbe, 0xef]);
const resp = buildResponse({
answers: [{ name: "example.com", rdata, ttl: 300, type: 999 }],
});
const result = parseResponse(resp);
expect(result.answers[0]!.value).toBe("de ad be ef");
expect(result.answers[0]!.data["raw"]).toEqual([0xde, 0xad, 0xbe, 0xef]);
});
});
describe("rcodeName", () => {
it("maps known rcode numbers to names", () => {
expect(rcodeName(0)).toBe("NOERROR");
expect(rcodeName(1)).toBe("FORMERR");
expect(rcodeName(2)).toBe("SERVFAIL");
expect(rcodeName(3)).toBe("NXDOMAIN");
expect(rcodeName(4)).toBe("NOTIMP");
expect(rcodeName(5)).toBe("REFUSED");
});
it("returns UNKNOWN(n) for unknown codes", () => {
expect(rcodeName(99)).toBe("UNKNOWN(99)");
expect(rcodeName(255)).toBe("UNKNOWN(255)");
});
});
describe("rrtTypeName", () => {
it("maps known type numbers to names", () => {
expect(rrtTypeName(1)).toBe("A");
expect(rrtTypeName(28)).toBe("AAAA");
expect(rrtTypeName(5)).toBe("CNAME");
expect(rrtTypeName(15)).toBe("MX");
expect(rrtTypeName(16)).toBe("TXT");
expect(rrtTypeName(2)).toBe("NS");
expect(rrtTypeName(6)).toBe("SOA");
expect(rrtTypeName(33)).toBe("SRV");
expect(rrtTypeName(12)).toBe("PTR");
expect(rrtTypeName(257)).toBe("CAA");
});
});
describe("rrtTypeByName", () => {
it("maps known type names to numbers", () => {
expect(rrtTypeByName("A")).toBe(1);
expect(rrtTypeByName("AAAA")).toBe(28);
expect(rrtTypeByName("CNAME")).toBe(5);
expect(rrtTypeByName("MX")).toBe(15);
expect(rrtTypeByName("TXT")).toBe(16);
expect(rrtTypeByName("NS")).toBe(2);
expect(rrtTypeByName("SOA")).toBe(6);
expect(rrtTypeByName("SRV")).toBe(33);
expect(rrtTypeByName("PTR")).toBe(12);
expect(rrtTypeByName("CAA")).toBe(257);
});
it("throws for unknown type name", () => {
expect(() => rrtTypeByName("UNKNOWN_TYPE")).toThrow();
});
});

View File

@@ -0,0 +1,539 @@
import { describe, expect, it } from "bun:test";
import type {
ResolvedDnsServerExpectConfig,
ResolvedDnsSystemExpectConfig,
ResolvedDnsTarget,
} from "../../../../../src/server/checker/runner/dns/types";
import { DnsChecker } from "../../../../../src/server/checker/runner/dns/execute";
function buildName(name: string): Uint8Array {
const parts: number[] = [];
for (const label of name.split(".")) {
const encoded = new TextEncoder().encode(label);
parts.push(encoded.length);
parts.push(...encoded);
}
parts.push(0);
return new Uint8Array(parts);
}
function buildTestResponse(options: {
answers?: Array<{ class?: number; name: string; rdata: Uint8Array; ttl: number; type: number }>;
flags?: { aa?: boolean; ad?: boolean; ra?: boolean; rd?: boolean; tc?: boolean };
id: number;
questions?: Array<{ name: string; qclass?: number; qtype: number }>;
rcode?: number;
}): Uint8Array {
const questions = options.questions ?? [];
const answers = options.answers ?? [];
let flags = 0x8000;
if (options.flags?.aa) flags |= 0x0400;
if (options.flags?.tc) flags |= 0x0200;
if (options.flags?.rd) flags |= 0x0100;
if (options.flags?.ra) flags |= 0x0080;
if (options.flags?.ad) flags |= 0x0020;
flags |= (options.rcode ?? 0) & 0x000f;
const header = new Uint8Array(12);
const hv = new DataView(header.buffer);
hv.setUint16(0, options.id);
hv.setUint16(2, flags);
hv.setUint16(4, questions.length);
hv.setUint16(6, answers.length);
hv.setUint16(8, 0);
hv.setUint16(10, 0);
const qParts: Uint8Array[] = [];
for (const q of questions) {
const nameBytes = buildName(q.name);
const qtype = new Uint8Array(4);
const qv = new DataView(qtype.buffer);
qv.setUint16(0, q.qtype);
qv.setUint16(2, q.qclass ?? 1);
qParts.push(nameBytes, qtype);
}
const aParts: Uint8Array[] = [];
for (const a of answers) {
const nameBytes = buildName(a.name);
const rrHead = new Uint8Array(10);
const rv = new DataView(rrHead.buffer);
rv.setUint16(0, a.type);
rv.setUint16(2, a.class ?? 1);
rv.setUint32(4, a.ttl);
rv.setUint16(8, a.rdata.length);
aParts.push(nameBytes, rrHead, a.rdata);
}
const allParts = [header, ...qParts, ...aParts];
const totalLen = allParts.reduce((s, p) => s + p.length, 0);
const result = new Uint8Array(totalLen);
let offset = 0;
for (const part of allParts) {
result.set(part, offset);
offset += part.length;
}
return result;
}
async function createFakeDnsServer(
respondWith: (query: Uint8Array) => Uint8Array,
): Promise<{ close: () => void; port: number }> {
const socket = await Bun.udpSocket({
socket: {
data(sock, data, port, addr) {
const query = new Uint8Array(data.buffer, data.byteOffset, data.byteLength);
const response = respondWith(query);
sock.send(response, port, addr);
},
drain() {
// 必填 handler
void 0;
},
error() {
// 必填 handler
void 0;
},
},
});
return { close: () => socket.close(), port: socket.port };
}
function makeServerTarget(
overrides: Partial<ResolvedDnsTarget["dns"]> = {},
expect?: ResolvedDnsServerExpectConfig,
): ResolvedDnsTarget {
return {
description: null,
dns: {
maxResponseBytes: 4096,
name: "example.com",
port: 53,
protocol: "udp",
recordType: "A",
recursionDesired: true,
resolver: "server",
server: "127.0.0.1",
tcpFallback: false,
...overrides,
} as ResolvedDnsTarget["dns"],
expect,
group: "default",
id: "test-dns-server",
intervalMs: 30000,
name: null,
timeoutMs: 10000,
type: "dns",
};
}
function makeSignal(timeoutMs: number): { cleanup: () => void; signal: AbortSignal } {
const controller = new AbortController();
const timer = setTimeout(() => controller.abort(), timeoutMs);
return { cleanup: () => clearTimeout(timer), signal: controller.signal };
}
function makeSystemTarget(
overrides: Partial<ResolvedDnsTarget["dns"]> = {},
expect?: ResolvedDnsSystemExpectConfig,
): ResolvedDnsTarget {
return {
description: null,
dns: {
family: "any",
name: "example.com",
resolver: "system",
...overrides,
} as ResolvedDnsTarget["dns"],
expect,
group: "default",
id: "test-dns-system",
intervalMs: 30000,
name: null,
timeoutMs: 10000,
type: "dns",
};
}
const checker = new DnsChecker();
const resolveContext = { configDir: "/tmp", defaultIntervalMs: 30000, defaultTimeoutMs: 10000 };
describe("DnsChecker resolve", () => {
it("system mode: fills defaults (family=any)", () => {
const target = checker.resolve(
{ dns: { name: "example.com", resolver: "system" }, id: "test", type: "dns" },
resolveContext,
);
expect(target.type).toBe("dns");
expect(target.dns).toMatchObject({ family: "any", name: "example.com", resolver: "system" });
});
it("server mode: fills defaults", () => {
const target = checker.resolve(
{ dns: { name: "example.com", resolver: "server", server: "8.8.8.8" }, id: "test", type: "dns" },
resolveContext,
);
expect(target.dns).toMatchObject({
maxResponseBytes: 4096,
port: 53,
protocol: "udp",
recordType: "A",
recursionDesired: true,
resolver: "server",
server: "8.8.8.8",
tcpFallback: true,
});
});
it("server mode: respects user overrides", () => {
const target = checker.resolve(
{
dns: {
maxResponseBytes: 2048,
name: "example.com",
port: 5353,
protocol: "tcp",
recordType: "AAAA",
recursionDesired: false,
resolver: "server",
server: "1.1.1.1",
tcpFallback: false,
},
id: "test",
type: "dns",
},
resolveContext,
);
expect(target.dns).toMatchObject({
maxResponseBytes: 2048,
port: 5353,
protocol: "tcp",
recordType: "AAAA",
recursionDesired: false,
server: "1.1.1.1",
tcpFallback: false,
});
});
it("both modes: sets type=dns, copies id/name/group, sets intervalMs/timeoutMs from context", () => {
const sysTarget = checker.resolve(
{ dns: { name: "example.com", resolver: "system" }, group: "grp1", id: "t1", name: "my-target", type: "dns" },
resolveContext,
);
expect(sysTarget.type).toBe("dns");
expect(sysTarget.id).toBe("t1");
expect(sysTarget.name).toBe("my-target");
expect(sysTarget.group).toBe("grp1");
expect(sysTarget.intervalMs).toBe(30000);
expect(sysTarget.timeoutMs).toBe(10000);
const srvTarget = checker.resolve(
{
dns: { name: "example.com", resolver: "server", server: "8.8.8.8" },
group: "grp2",
id: "t2",
name: "my-server",
type: "dns",
},
resolveContext,
);
expect(srvTarget.type).toBe("dns");
expect(srvTarget.id).toBe("t2");
expect(srvTarget.name).toBe("my-server");
expect(srvTarget.group).toBe("grp2");
expect(srvTarget.intervalMs).toBe(30000);
expect(srvTarget.timeoutMs).toBe(10000);
});
});
describe("DnsChecker serialize", () => {
it("system mode: returns dns system <name> and JSON config", () => {
const target = makeSystemTarget();
const result = checker.serialize(target);
expect(result.target).toBe("dns system example.com");
const parsed = JSON.parse(result.config) as Record<string, unknown>;
expect(parsed["resolver"]).toBe("system");
expect(parsed["name"]).toBe("example.com");
});
it("server mode: returns dns <server>:<port> <name>/<recordType> and JSON config", () => {
const target = makeServerTarget();
const result = checker.serialize(target);
expect(result.target).toBe("dns 127.0.0.1:53 example.com/A");
const parsed = JSON.parse(result.config) as Record<string, unknown>;
expect(parsed["resolver"]).toBe("server");
expect(parsed["server"]).toBe("127.0.0.1");
});
});
describe("DnsChecker execute (system mode)", () => {
it("localhost IPv4 resolution returns matched=true", async () => {
const target = makeSystemTarget({ family: "ipv4", name: "localhost" });
const { cleanup, signal } = makeSignal(5000);
const result = await checker.execute(target, { signal });
cleanup();
expect(result.matched).toBe(true);
expect(result.failure).toBeNull();
expect(result.observation).toMatchObject({ family: "ipv4", resolver: "system" });
expect(result.observation!["valueCount"]).toBeGreaterThan(0);
});
it("pre-aborted signal returns matched=false without real lookup", async () => {
const target = makeSystemTarget({ name: "example.com" });
const controller = new AbortController();
controller.abort();
const result = await checker.execute(target, { signal: controller.signal });
expect(result.matched).toBe(false);
expect(result.failure?.phase).toBe("resolve");
expect(result.observation).toMatchObject({ resolver: "system", valueCount: 0, values: [] });
});
});
describe("DnsChecker execute (server mode)", () => {
it("successful A record query returns matched=true with correct observation fields", async () => {
const server = await createFakeDnsServer((query) => {
const view = new DataView(query.buffer, query.byteOffset, query.byteLength);
const queryId = view.getUint16(0);
return buildTestResponse({
answers: [{ name: "example.com", rdata: new Uint8Array([93, 184, 216, 34]), ttl: 300, type: 1 }],
id: queryId,
questions: [{ name: "example.com", qtype: 1 }],
});
});
try {
const target = makeServerTarget({ port: server.port });
const { cleanup, signal } = makeSignal(5000);
const result = await checker.execute(target, { signal });
cleanup();
expect(result.matched).toBe(true);
expect(result.failure).toBeNull();
expect(result.observation).toMatchObject({
rcode: "NOERROR",
resolver: "server",
responded: true,
});
expect(result.observation!["values"]).toContain("93.184.216.34");
} finally {
server.close();
}
});
it("NXDOMAIN response with expect.rcode=[NXDOMAIN] returns matched=true", async () => {
const server = await createFakeDnsServer((query) => {
const view = new DataView(query.buffer, query.byteOffset, query.byteLength);
const queryId = view.getUint16(0);
return buildTestResponse({
id: queryId,
questions: [{ name: "example.com", qtype: 1 }],
rcode: 3,
});
});
try {
const target = makeServerTarget({ port: server.port }, { rcode: ["NXDOMAIN"], responded: true });
const { cleanup, signal } = makeSignal(5000);
const result = await checker.execute(target, { signal });
cleanup();
expect(result.matched).toBe(true);
expect(result.observation!["rcode"]).toBe("NXDOMAIN");
} finally {
server.close();
}
});
it("SERVFAIL response returns matched=false with default expect", async () => {
const server = await createFakeDnsServer((query) => {
const view = new DataView(query.buffer, query.byteOffset, query.byteLength);
const queryId = view.getUint16(0);
return buildTestResponse({
id: queryId,
questions: [{ name: "example.com", qtype: 1 }],
rcode: 2,
});
});
try {
const target = makeServerTarget({ port: server.port });
const { cleanup, signal } = makeSignal(5000);
const result = await checker.execute(target, { signal });
cleanup();
expect(result.matched).toBe(false);
expect(result.failure).not.toBeNull();
expect(result.observation!["rcode"]).toBe("SERVFAIL");
} finally {
server.close();
}
});
it("server mode resolved without expect still requires NOERROR by default", async () => {
const server = await createFakeDnsServer((query) => {
const view = new DataView(query.buffer, query.byteOffset, query.byteLength);
const queryId = view.getUint16(0);
return buildTestResponse({
answers: [{ name: "example.com", rdata: new Uint8Array([93, 184, 216, 34]), ttl: 300, type: 1 }],
id: queryId,
questions: [{ name: "example.com", qtype: 1 }],
rcode: 2,
});
});
try {
const target = checker.resolve(
{
dns: { name: "example.com", port: server.port, resolver: "server", server: "127.0.0.1" },
id: "test",
type: "dns",
},
resolveContext,
);
const { cleanup, signal } = makeSignal(5000);
const result = await checker.execute(target, { signal });
cleanup();
expect(result.matched).toBe(false);
expect(result.failure?.phase).toBe("rcode");
expect(result.observation!["rcode"]).toBe("SERVFAIL");
} finally {
server.close();
}
});
it("no response (timeout) returns matched=false", async () => {
const server = await createFakeDnsServer(() => new Uint8Array(0));
try {
const target = makeServerTarget({ port: server.port });
const controller = new AbortController();
const timer = setTimeout(() => controller.abort(), 200);
const result = await checker.execute(target, { signal: controller.signal });
clearTimeout(timer);
expect(result.matched).toBe(false);
expect(result.observation!["responded"]).toBe(false);
} finally {
server.close();
}
});
it("checks values, valueCount, cnameChain for A query", async () => {
const cnameRdata = buildName("cdn.example.com");
const server = await createFakeDnsServer((query) => {
const view = new DataView(query.buffer, query.byteOffset, query.byteLength);
const queryId = view.getUint16(0);
return buildTestResponse({
answers: [
{ name: "example.com", rdata: cnameRdata, ttl: 300, type: 5 },
{ name: "example.com", rdata: new Uint8Array([93, 184, 216, 34]), ttl: 300, type: 1 },
],
id: queryId,
questions: [{ name: "example.com", qtype: 1 }],
});
});
try {
const target = makeServerTarget({ port: server.port });
const { cleanup, signal } = makeSignal(5000);
const result = await checker.execute(target, { signal });
cleanup();
expect(result.matched).toBe(true);
const obs = result.observation!;
expect(obs["values"]).toContain("93.184.216.34");
expect(obs["valueCount"]).toBe(1);
expect(obs["cnameChain"]).toEqual(["cdn.example.com"]);
} finally {
server.close();
}
});
it("non-address record values only include requested record type", async () => {
const cnameRdata = buildName("mail-alias.example.com");
const exchange = buildName("mail.example.com");
const mxRdata = new Uint8Array(2 + exchange.length);
const mxView = new DataView(mxRdata.buffer);
mxView.setUint16(0, 10);
mxRdata.set(exchange, 2);
const server = await createFakeDnsServer((query) => {
const view = new DataView(query.buffer, query.byteOffset, query.byteLength);
const queryId = view.getUint16(0);
return buildTestResponse({
answers: [
{ name: "example.com", rdata: cnameRdata, ttl: 300, type: 5 },
{ name: "example.com", rdata: mxRdata, ttl: 300, type: 15 },
],
id: queryId,
questions: [{ name: "example.com", qtype: 15 }],
});
});
try {
const target = makeServerTarget({ port: server.port, recordType: "MX" });
const { cleanup, signal } = makeSignal(5000);
const result = await checker.execute(target, { signal });
cleanup();
expect(result.matched).toBe(true);
const obs = result.observation!;
expect(obs["answerCount"]).toBe(2);
expect(obs["valueCount"]).toBe(1);
expect(obs["values"]).toEqual(["10 mail.example.com"]);
} finally {
server.close();
}
});
it("explicit ttlMin fails when response has no answer TTL", async () => {
const server = await createFakeDnsServer((query) => {
const view = new DataView(query.buffer, query.byteOffset, query.byteLength);
const queryId = view.getUint16(0);
return buildTestResponse({
id: queryId,
questions: [{ name: "example.com", qtype: 1 }],
rcode: 3,
});
});
try {
const target = makeServerTarget(
{ port: server.port },
{ rcode: ["NXDOMAIN"], responded: true, ttlMin: { gte: 0 } },
);
const { cleanup, signal } = makeSignal(5000);
const result = await checker.execute(target, { signal });
cleanup();
expect(result.matched).toBe(false);
expect(result.failure?.phase).toBe("ttlMin");
} finally {
server.close();
}
});
it("responded=false still checks explicit durationMs", async () => {
const server = await createFakeDnsServer(() => new Uint8Array(0));
try {
const target = makeServerTarget({ port: server.port }, { durationMs: { lt: 0 }, responded: false });
const { cleanup, signal } = makeSignal(5000);
const result = await checker.execute(target, { signal });
cleanup();
expect(result.matched).toBe(false);
expect(result.failure?.phase).toBe("duration");
expect(result.observation!["responded"]).toBe(false);
} finally {
server.close();
}
});
it("durationMs is present in result", async () => {
const server = await createFakeDnsServer((query) => {
const view = new DataView(query.buffer, query.byteOffset, query.byteLength);
const queryId = view.getUint16(0);
return buildTestResponse({
answers: [{ name: "example.com", rdata: new Uint8Array([1, 1, 1, 1]), ttl: 300, type: 1 }],
id: queryId,
questions: [{ name: "example.com", qtype: 1 }],
});
});
try {
const target = makeServerTarget({ port: server.port });
const { cleanup, signal } = makeSignal(5000);
const result = await checker.execute(target, { signal });
cleanup();
expect(result.durationMs).toBeGreaterThanOrEqual(0);
expect(result.observation!["durationMs"]).toBeGreaterThanOrEqual(0);
} finally {
server.close();
}
});
});

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