1
0

feat: 新增 ICMP/Ping checker,支持跨平台主机存活检测与延迟监控

实现 type: ping checker,通过 Bun.spawn 调用系统 ping 命令,自行实现跨平台
输出解析器(Linux/macOS/Windows 含中文 locale),支持 alive、丢包率、延迟、
耗时等 expect 断言,复用现有 checker 架构零外部依赖。

包含完整的类型定义、TypeBox schema、语义校验、命令构建、解析、断言、执行、
注册、配置加载测试,以及 probe-config.schema.json 更新和文档更新。

审查修复:提取 buildPingCommand 为独立纯函数并补充跨平台单测,补充
maxDurationMs/maxAvgLatencyMs 类型非法和空字符串 host 边界测试用例。

变更已归档,delta specs 已同步至 main specs。
This commit is contained in:
2026-05-18 10:45:17 +08:00
parent c51bc5a0d8
commit 550c427814
30 changed files with 1132 additions and 330 deletions

View File

@@ -61,6 +61,7 @@ src/
cmd/ Cmd Checker自包含模块含 types/schema/execute/expect/validate/text
db/ DB Checker自包含模块含 types/schema/execute/expect/validate
tcp/ TCP Checker自包含模块含 types/schema/execute/expect/validate
icmp/ Ping Checker自包含模块含 types/schema/execute/expect/validate/parse
shared/
api.ts 前后端共享 TypeScript 类型
web/ React 前端 Dashboard通过 Bun HTML import 集成)
@@ -248,7 +249,8 @@ checkerRegistry单例
├── runner/index.ts ← 显式数组注册,新增 checker 只需一行
│ ├── new HttpChecker()
│ ├── new CommandChecker()
── new TcpChecker() ← 新增
── new TcpChecker()
│ └── new IcmpChecker() ← 新增
├── schema/builder.ts ← 自动遍历 registry 生成全量 JSON Schema
├── schema/validate.ts ← 自动遍历 registry 构建 Ajv 校验
@@ -474,7 +476,7 @@ TcpChecker implements Checker
- **调度**`ProbeEngine``es-toolkit/groupBy` 按 interval 分组,每组独立 `setInterval` 定时触发
- **并发控制**`es-toolkit/Semaphore` 限制全局最大并发数(`maxConcurrentChecks`,默认 20`acquire()` 阻塞等待
- **Runner 选择**`engine.runCheck()` 通过 `checkerRegistry.get(target.type)` 获取 checker并调用 `checker.execute(target, { signal })`
- **超时控制**`ProbeEngine` 为每次检查创建 `AbortController` 并按 `target.timeoutMs` 触发 abortchecker 必须使用 `CheckerContext.signal` 感知超时HTTP 将 signal 传给 `fetch()`Cmd 在 signal abort 时 `proc.kill()`
- **超时控制**`ProbeEngine` 为每次检查创建 `AbortController` 并按 `target.timeoutMs` 触发 abortchecker 必须使用 `CheckerContext.signal` 感知超时HTTP 将 signal 传给 `fetch()`Cmd 和 Ping 在 signal abort 时 `proc.kill()`
- **结果写入**:检查结果通过 `store.insertCheckResult()` 写入 SQLiteengine 基于配置 target id 确认目标仍存在
- **异常可观测**`probeGroup()``Promise.allSettled` 的 rejected 结果通过索引关联 target并写入 `phase:"internal"` 的失败记录
- **数据清理**:当 `retentionMs > 0`engine 启动时立即执行一次 `store.prune()`,之后每小时定时执行,按 `timestamp` 清理过期数据

View File

@@ -10,11 +10,11 @@
---
DiAL 是一个自托管的拨测监控工具,支持 **HTTP**、**命令行**、**数据库** 和 **TCP** 种拨测类型。通过 YAML 配置文件定义拨测目标,后端定时并发执行拨测并将结果持久化到本地 SQLite前端 Dashboard 展示各目标的实时状态、可用率和耗时趋势。
DiAL 是一个自托管的拨测监控工具,支持 **HTTP**、**命令行**、**数据库**、**TCP****Ping** 种拨测类型。通过 YAML 配置文件定义拨测目标,后端定时并发执行拨测并将结果持久化到本地 SQLite前端 Dashboard 展示各目标的实时状态、可用率和耗时趋势。
**功能亮点:**
- 多种拨测类型HTTPGET/POST/PUT 等、Cmd命令行执行、DBPostgreSQL/MySQL/SQLite、TCP端口可达性 + Banner 探测)
- 多种拨测类型HTTPGET/POST/PUT 等、Cmd命令行执行、DBPostgreSQL/MySQL/SQLite、TCP端口可达性 + Banner 探测)、PingICMP 存活、延迟、丢包率)
- 丰富的校验规则状态码、响应头、JSONPath、CSS 选择器、XPath、正则匹配、数值比较等
- 响应式 Dashboard实时状态、可用率统计、耗时趋势图、手动/自动刷新
- 多主题支持:系统、明亮、黑暗三种主题模式
@@ -24,6 +24,8 @@ DiAL 是一个自托管的拨测监控工具,支持 **HTTP**、**命令行**
**前置条件:** [Bun](https://bun.sh/) >= 1.0
Ping checker 依赖系统 `ping` 命令。精简容器镜像需额外安装,例如 Alpine 可安装 `iputils-ping`
```bash
# 克隆仓库
git clone https://github.com/your-org/DiAL.git
@@ -141,6 +143,20 @@ targets:
port: 6379
expect:
maxDurationMs: 3000
- id: "gateway-ping"
name: "网关 ICMP 可达"
type: ping
ping:
host: "10.0.0.1"
count: 3
packetSize: 56
expect:
alive: true
maxPacketLoss: 10
maxAvgLatencyMs: 100
maxMaxLatencyMs: 300
maxDurationMs: 5000
```
### 配置说明
@@ -190,7 +206,7 @@ targets:
| `id` | 目标唯一标识,最长 30 字符,支持字母数字、下划线、连字符,不参与变量替换 | 是 |
| `name` | 展示名称,最长 30 字符,支持变量替换,可省略或显式 null前端展示时 null 回退到 `id` | 否 |
| `description` | 目标描述,最长 500 字符,支持变量替换,可省略或显式 null允许空字符串 | 否 |
| `type` | 目标类型:`http``cmd``db``tcp` | 是 |
| `type` | 目标类型:`http``cmd``db``tcp``ping` | 是 |
| `group` | 分组名称 | 否,默认 `"default"` |
| `interval` | 覆盖全局拨测间隔 | 否 |
| `timeout` | 覆盖全局超时时间 | 否 |
@@ -232,6 +248,16 @@ targets:
| `tcp.bannerReadTimeout` | banner 读取超时(毫秒),默认 `2000` |
| `tcp.maxBannerBytes` | banner 最大字节数,支持 `KB`/`MB`/`GB` 单位,默认 `4KB` |
**Ping 类型** (`type: ping`)
| 字段 | 说明 |
| ----------------- | ----------------------------------- |
| `ping.host` | 目标主机地址 |
| `ping.count` | ICMP 包数量,默认 `3`,范围 `1-100` |
| `ping.packetSize` | ICMP 包大小bytes默认 `56` |
Ping checker 通过系统 `ping` 命令执行 ICMP 探测,支持 Linux、macOS 和 Windows 输出解析。
#### expect — 期望校验
| 字段 | 适用类型 | 说明 |
@@ -246,6 +272,10 @@ targets:
| `rows` | DB | 查询结果逐行校验(数组,列名→操作符映射) |
| `connected` | TCP | 期望连接结果,`true`(默认)可达或 `false` 期望不可达 |
| `banner` | TCP | Banner 文本校验(操作符对象,需开启 `tcp.readBanner` |
| `alive` | Ping | 期望主机可达性,默认 `true` |
| `maxPacketLoss` | Ping | 最大丢包率百分比,范围 `0-100` |
| `maxAvgLatencyMs` | Ping | 最大平均延迟(毫秒) |
| `maxMaxLatencyMs` | Ping | 最大单次延迟(毫秒) |
**body 校验项**(数组中可混合使用):

View File

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

View File

@@ -1,190 +0,0 @@
## Context
项目当前有 HTTP、CMD、DB、TCP 四种 checker均遵循 `CheckerDefinition<TResolved>` 接口规范。TCP checker 是最近实现的网络层 checker其模式Bun.spawn / 原生 socket + AbortSignal + 断言链)是 ICMP checker 的直接参考。
ICMP Ping 是最基础的网络探测手段,但 Node.js/Bun 生态中没有合适的纯 JS ICMP 实现(均依赖 `raw-socket` 原生 addonBun N-API 兼容性不确定且需要 root 权限)。现有的命令封装库(`ping``pingman`)虽然提供了跨平台解析,但它们封装了 `child_process.spawn` 且不暴露子进程引用,无法配合我们的 `ctx.signal` 超时控制机制。
经过调研和讨论,确定自行实现:用 `Bun.spawn` 调用系统 `ping` 命令 + 自行编写跨平台输出解析器。
## Goals / Non-Goals
**Goals:**
- 实现 `type: ping` checker支持主机存活检测、延迟监控、丢包率检查
- 跨平台支持 Linux、macOS、Windows含中文 Windows
- 完全复用现有 checker 架构registry、schema、expect、failure
- 零外部依赖
**Non-Goals:**
- 不实现 traceroute 功能
- 不实现 IPv6 专项支持(系统 ping 命令会自动处理 IPv6 地址)
- 不实现原始 ICMP socket权限要求过高
- 不提供 per-packet 逐包结果(只提供 summary 统计)
## Decisions
### Decision 1: 调用系统 `ping` 命令而非原始 ICMP socket
**选择**: 通过 `Bun.spawn` 调用系统 `ping` 可执行文件
**替代方案**:
- 原始 ICMP socket`raw-socket` addon需要 root/CAP_NET_RAW 权限Bun N-API 兼容性不确定
- 三方库 `pingman`/`ping`:封装了 spawn 但不暴露子进程引用,无法配合 AbortSignal 超时控制
**理由**: 系统 `ping` 命令无需特殊权限(大多数系统),`Bun.spawn` 给我们完全的子进程生命周期控制,与现有 cmd checker 模式一致。
### Decision 2: 自行实现跨平台解析器
**选择**: 在 `parse.ts` 中实现 `parsePingOutput(stdout, platform)` 函数,用正则匹配 summary 统计行
**替代方案**:
- 引入 `pingman` 作为依赖使用其 parser模块不是公开 APIdeep import 不稳定
- 引入 `ping`danielzzz的 parser同上且返回值类型全是 string
**理由**: 我们只需要 summary 行的统计数据transmitted/received/loss/min/avg/max不需要逐包 body 解析。三套正则约 40-50 行代码,完全可控且零依赖。
### Decision 3: 跨平台命令构建策略
```
平台判断: process.platform (win32 vs 其他)
Linux:
ping -c <count> -s <packetSize> -W <timeoutSec> <host>
macOS:
ping -c <count> -s <packetSize> -W <timeoutMs> <host>
Windows:
ping -n <count> -l <packetSize> -w <timeoutMs> <host>
```
**超时参数传递策略:双重保障**
传递平台对应的超时参数(`-W`/`-w`),同时保留外层 `ctx.signal` + `proc.kill()` 作为兜底:
- **ping 命令自身超时**:确保每个 ICMP 包在指定时间内超时返回,避免 ping 进程因网络异常而无限等待
- **外层 AbortSignal**:作为最终兜底,防止 ping 命令因任何原因卡死不退出
各平台超时参数单位差异:
- Linux `-W`:单位为**秒**(整数),需将 timeoutMs 转换为秒(向上取整)
- macOS `-W`:单位为**毫秒**(整数)
- Windows `-w`:单位为**毫秒**(整数)
超时值计算:使用外层 `timeoutMs` 作为 ping 命令的超时参数值。这样 ping 命令自身会在 timeout 内完成,外层 signal 作为额外保障。
### Decision 4: Windows 多语言输出解析策略
Windows `ping` 输出语言跟随系统 locale中文系统输出中文、英文系统输出英文、日文系统输出日文等
**选择**: 基于数字模式和行结构匹配,不依赖关键词
具体策略:
- **丢包行**: 匹配 `(\d+).*?(\d+).*?(\d+).*?(\d+(?:\.\d+)?%)` 模式——提取"已发送"、"已接收"、"丢失"和百分比数字,不依赖中英文关键词
- **延迟行**: 匹配 `(\d+)ms.*?(\d+)ms.*?(\d+)ms` 模式——提取 min/max/avg 三个数字Windows 输出顺序固定为 Minimum/Maximum/Average
**替代方案**: 枚举所有语言的关键词——维护成本高,且无法覆盖所有 locale
### Decision 5: 解析结果数据结构
```typescript
interface PingStats {
alive: boolean;
transmitted: number;
received: number;
packetLoss: number; // 0-100
minLatencyMs: number | null;
avgLatencyMs: number | null;
maxLatencyMs: number | null;
}
```
`latencyMs` 字段为 `null` 表示主机不可达时无延迟数据。
### Decision 6: 断言执行顺序(短路)
```
alive → packetLoss → avgLatency → maxLatency → duration
```
理由:
1. `alive` 是最基础的判断,不可达时后续断言无意义
2. `packetLoss` 比延迟更严重(丢包意味着部分请求完全失败)
3. `avgLatency``maxLatency` 是延迟质量指标
4. `duration` 是整体执行时间兜底
### Decision 7: ping 命令不存在时的错误处理
当系统未安装 `ping` 命令时(常见于精简容器镜像如 Alpine`Bun.spawn` 会抛出 ENOENT 错误。
处理方式:在 spawn 阶段 try/catch返回结构化错误
```typescript
failure: errorFailure("ping", "spawn", `ping 命令不可用: ${error.message}`)
statusDetail: "ping command not found"
```
文档中注明系统依赖:容器环境需确保 `ping` 命令可用(如 Alpine 需安装 `iputils-ping`)。
### Decision 8: configKey 和 type 命名
**选择**: `type: "ping"`, `configKey: "ping"`
**替代方案**: `type: "icmp"` — 但 `ping` 更贴近用户认知,且配置中 `ping.host``icmp.host` 更直观。
### Decision 9: 超时控制与子进程生命周期
与 cmd checker 相同的模式:
```typescript
ctx.signal.addEventListener("abort", () => {
try { proc.kill(); } catch { /* best-effort */ }
}, { once: true });
```
当 signal abort 时 kill 子进程,然后在结果中记录超时错误。这需要修改 `checker-runner-abstraction` spec 中"仅 cmd checker 可在 signal abort 时 proc.kill()"的约束。
### Decision 10: Linux/macOS 解析正则
```
丢包统计行:
Linux: "3 packets transmitted, 3 received, 0% packet loss, time 2003ms"
macOS: "3 packets transmitted, 3 packets received, 0.0% packet loss"
正则: /(\d+)\s+packets?\s+transmitted.*?(\d+)\s+(?:packets?\s+)?received.*?(\d+(?:\.\d+)?)%\s+packet\s+loss/
延迟统计行:
Linux: "rtt min/avg/max/mdev = 1.234/2.345/3.456/0.567 ms"
macOS: "round-trip min/avg/max/stddev = 1.234/2.345/3.456/0.567 ms"
正则: /(?:rtt|round-trip).*?=\s*([\d.]+)\/([\d.]+)\/([\d.]+)/
```
### Decision 11: Windows 解析正则
```
丢包统计行(数字模式,语言无关):
英文: "Packets: Sent = 3, Received = 3, Lost = 0 (0% loss)"
中文: "数据包: 已发送 = 3已接收 = 3丢失 = 0 (0% 丢失)"
正则: /=\s*(\d+).*?=\s*(\d+).*?=\s*(\d+).*?(\d+(?:\.\d+)?)%/
延迟统计行(数字模式,语言无关):
英文: "Minimum = 1ms, Maximum = 3ms, Average = 2ms"
中文: "最短 = 1ms最长 = 3ms平均 = 2ms"
正则: /=\s*(\d+)ms.*?=\s*(\d+)ms.*?=\s*(\d+)ms/
```
Windows 延迟顺序固定为 min/max/avg注意与 Linux/macOS 的 min/avg/max 不同)。
## Risks / Trade-offs
### [Risk] 系统未安装 ping 命令 → 清晰的错误提示 + 文档说明
容器环境Alpine、scratch可能不包含 ping。通过 spawn 阶段 catch ENOENT 给出明确提示,并在 README 中注明依赖。
### [Risk] 未知 locale 的 Windows 输出无法解析 → 降级为 alive=false + 原始输出
如果正则无法匹配任何统计行,将 alive 判定为 `received > 0`(通过检查 exit codeWindows ping 在全部丢包时 exit code 为 1延迟字段为 null。statusDetail 展示原始输出前 80 字符供用户排查。
### [Risk] ping 命令被防火墙/网络策略阻断 → 用户可预期的行为
ICMP 在某些网络环境中被阻断。这不是 checker 的 bug而是网络配置问题。checker 会正确报告 `alive=false` 和 100% packet loss。
### [Trade-off] 双重超时保障
传递 `-W`/`-w` 超时参数给 ping 命令,同时保留外层 AbortSignal + proc.kill() 兜底。
优势ping 命令自身会在超时后正常退出,不依赖外部 kill即使 ping 命令因异常卡死,外层 signal 仍能强制终止。
劣势需要处理三平台超时参数的单位差异Linux 秒 vs macOS/Windows 毫秒),增加少量命令构建复杂度。
### [Trade-off] 不引入三方库
优势:零依赖、完全可控、与项目规范一致。
劣势:需要自行维护跨平台解析正则。但 ping 输出格式极其稳定(几十年未变),维护成本极低。

View File

@@ -1,28 +0,0 @@
## Why
项目当前支持 HTTP、CMD、DB、TCP 四种 checker缺少最基础的网络层存活检测能力。ICMP Ping 是运维监控的基石——主机存活、网络延迟、丢包率是判断网络健康的第一手指标,也是区分"网络层故障"与"应用层故障"的关键手段。
## What Changes
- 新增 `type: ping` checker通过调用系统 `ping` 命令实现 ICMP 探测
- 支持配置 host、count包数量、packetSize包大小用于 MTU 测试)
- 支持断言alive可达性、maxAvgLatencyMs平均延迟、maxMaxLatencyMs最大延迟/抖动、maxPacketLoss丢包率、maxDurationMs整体耗时
- 自行实现跨平台Linux/macOS/Windowsping 输出解析器,不引入三方库
- 文档注明 ICMP checker 依赖系统 `ping` 命令存在(容器环境需确保已安装,如 Alpine 需 `iputils-ping`
## Capabilities
### New Capabilities
- `icmp-checker`: 定义 ICMP/Ping checker 的配置格式、命令执行、跨平台输出解析、expect 校验和状态摘要
### Modified Capabilities
- `checker-runner-abstraction`: 超时控制 requirement 中"仅 cmd checker 可在 signal abort 时 proc.kill()"需扩展为"cmd checker 和 ping checker",因为 ping checker 同样 spawn 子进程
- `probe-config`: 配置格式需扩展支持 `type: ping` 的 target 配置、`ping` 领域分组和对应的 expect 字段
## Impact
- 后端代码:新增 `src/server/checker/runner/icmp/` 模块,注册到 CheckerRegistry
- 配置 schema`probe-config.schema.json` 需更新,新增 ping target 和 expect 的 schema 片段
- 测试:新增 `tests/server/checker/runner/icmp/` 测试套件
- 文档README.md 和 DEVELOPMENT.md 需更新,注明 ping 命令的系统依赖
- 无新增三方依赖

View File

@@ -1,16 +0,0 @@
## MODIFIED Requirements
### Requirement: 超时控制由引擎注入 signal
Checker 实现的 `execute()` MUST 使用 `ctx.signal` 感知超时,不得自行创建 `AbortController``setTimeout` 用于超时控制。Cmd checker 和 ping checker 可在 signal abort 时 `proc.kill()` 以确保子进程被终止。
#### Scenario: HTTP checker 使用 signal
- **WHEN** HttpChecker 执行 HTTP 请求
- **THEN** SHALL 将 `ctx.signal` 传入 `fetch()``signal` 选项,不自行创建 `AbortController`
#### Scenario: Cmd checker 响应 signal
- **WHEN** CommandChecker 执行命令且 signal 被 abort
- **THEN** SHALL 调用 `proc.kill()` 终止子进程,并在 CheckResult 中记录超时错误
#### Scenario: Ping checker 响应 signal
- **WHEN** IcmpChecker 执行 ping 命令且 signal 被 abort
- **THEN** SHALL 调用 `proc.kill()` 终止 ping 子进程,并在 CheckResult 中记录超时错误

View File

@@ -1,38 +0,0 @@
## MODIFIED Requirements
### Requirement: YAML 配置文件格式
系统 SHALL 支持通过 YAML 配置文件定义全部运行参数,包括 server 配置、runtime 配置、可选的 variables 段、checker 默认值和 typed target 列表(含可选 group 字段。target MUST 使用 `id` 字段作为唯一标识符MUST 使用 `type` 字段声明 checker 类型SHALL 支持可选的 `name` 字段作为展示名称元信息SHALL 支持可选的 `description` 字段作为目标说明。`name``description` 均 SHALL 允许省略或显式配置为 `null`;省略或显式 null 时解析结果 SHALL 保留为 null。HTTP 领域字段 MUST 放在 `http` 分组cmd 领域字段 MUST 放在 `cmd` 分组db 领域字段 MUST 放在 `db` 分组tcp 领域字段 MUST 放在 `tcp` 分组ping 领域字段 MUST 放在 `ping` 分组。HTTP target 的 `http` 分组 SHALL 支持可选的 `ignoreSSL`(布尔值)和 `maxRedirects`非负整数字段。Db target 的 `db` 分组 SHALL 支持 `url`(必填)和 `query`可选字段。Tcp target 的 `tcp` 分组 SHALL 支持 `host`(必填)、`port`(必填)、`readBanner`(可选)、`bannerReadTimeout`(可选)和 `maxBannerBytes`可选字段。Ping target 的 `ping` 分组 SHALL 支持 `host`(必填)、`count`(可选,默认 3`packetSize`(可选,默认 56字段。
`defaults.http` 分组 SHALL 仅支持 `headers`(可选)和 `maxBodyBytes`(可选)字段。`defaults.http` 分组 MUST NOT 支持 `method` 字段。`defaults.tcp` 分组 SHALL 仅支持 `bannerReadTimeout`(可选)和 `maxBannerBytes`(可选)字段。
#### Scenario: 最简 ping 配置文件解析
- **WHEN** 系统读取只包含一个 `type: ping` target`id``ping.host`)的 YAML 配置文件
- **THEN** 系统 SHALL 使用内置默认值填充未指定的字段interval=30s, timeout=10s, group="default", ping.count=3, ping.packetSize=56并保留 name=null、description=null
### Requirement: 配置校验
系统 SHALL 在启动时对 YAML 配置进行完整校验,校验失败时以非零状态退出并输出清晰的错误信息。系统 SHALL 使用 TypeBox 定义配置契约和 raw config TypeScript 类型,由 Ajv 校验 TypeBox 生成的 JSON Schema再执行启动期语义 validator。配置加载流程 SHALL 明确区分 `RawProbeConfig``ValidatedProbeConfig``ResolvedConfig` 三段生命周期,并在 YAML 解析之后、AJV 校验之前执行变量替换阶段。JSON Schema 契约 SHALL 覆盖业务无关的结构规则,包括字段类型、必填字段、枚举、数组与对象形状、数值范围和未知字段。语义 validator SHALL 覆盖契约不适合表达的业务规则,包括 target id 唯一性、id 命名规则校验、checker type 注册状态、时长和大小解析、HTTP URL、正则可编译、JSONPath 子集和 XPath 可编译。
契约校验和语义 validator SHALL 统一产出 `ConfigValidationIssue`,最终由配置加载流程统一渲染为中文错误信息。
系统 SHALL 导出完整 `probe-config.schema.json`,该文件 SHALL 与运行期 TypeBox fragments 生成的 JSON Schema 保持一致,用于用户配置引用和编辑器提示。
`headers``env``variables` 等明确声明为动态键值表的对象外,配置中的未知字段 SHALL 导致启动期配置错误。系统 MUST NOT 静默忽略未知字段。
#### Scenario: ping target 缺少 host
- **WHEN** YAML 中某个 target 配置 `type: ping` 但缺少 `ping.host`
- **THEN** 系统 SHALL 以错误退出,提示该 target 缺少 ping.host 字段
#### Scenario: ping expect 未知字段
- **WHEN** YAML 中 ping target 的 expect 包含非 ping expect 字段
- **THEN** 系统 SHALL 以配置错误退出,提示 expect 包含未知字段
### Requirement: expect 配置增强
系统 SHALL 支持 typed target 的领域专用 expect 配置,包括 HTTP 的 `status`(支持精确数字和范围模式)、`headers``body`cmd 的 `exitCode``stdout``stderr`tcp 的 `connected``banner`,以及 ping 的 `alive``maxPacketLoss``maxAvgLatencyMs``maxMaxLatencyMs`。内容类 expect MUST 使用数组表达配置顺序。
#### Scenario: 解析 ping expect 配置
- **WHEN** YAML 配置文件中 ping target 的 expect 包含 alive、maxPacketLoss、maxAvgLatencyMs、maxMaxLatencyMs 和 maxDurationMs
- **THEN** 系统 SHALL 正确解析并存储为 ping target 的 expect 字段
#### Scenario: 不配置 ping expect
- **WHEN** ping target 未配置任何 expect 规则
- **THEN** 系统 SHALL 正常处理expect 字段为 undefined执行时使用默认 alive=true 语义

View File

@@ -1,44 +0,0 @@
## 1. 类型与 Schema 定义
- [ ] 1.1 创建 `src/server/checker/runner/icmp/types.ts`,定义 PingTargetConfig、PingExpectConfig、ResolvedPingConfig、ResolvedPingTarget、PingStats 接口
- [ ] 1.2 创建 `src/server/checker/runner/icmp/schema.ts`,定义 TypeBox schemaconfig、expect导出 icmpCheckerSchemas
## 2. 跨平台解析器
- [ ] 2.1 创建 `src/server/checker/runner/icmp/parse.ts`,实现 parsePingOutput(stdout, platform) 函数,支持 Linux/macOS/Windows 三平台解析
- [ ] 2.2 创建 `tests/server/checker/runner/icmp/parse.test.ts`,覆盖 Linux、macOS、Windows 英文、Windows 中文、全部丢包、无法解析等场景
## 3. 断言函数
- [ ] 3.1 创建 `src/server/checker/runner/icmp/expect.ts`,实现 checkAlive、checkPacketLoss、checkAvgLatency、checkMaxLatency 函数
- [ ] 3.2 创建 `tests/server/checker/runner/icmp/expect.test.ts`,覆盖各断言函数的通过和失败场景
## 4. 配置校验
- [ ] 4.1 创建 `src/server/checker/runner/icmp/validate.ts`,实现 validatePingConfig 语义校验
- [ ] 4.2 创建 `tests/server/checker/runner/icmp/validate.test.ts`,覆盖 host 缺失、count/packetSize 非法、未知字段等场景
## 5. Checker 主体实现
- [ ] 5.1 创建 `src/server/checker/runner/icmp/execute.ts`,实现 IcmpChecker classexecute、resolve、serialize、validate
- [ ] 5.2 创建 `src/server/checker/runner/icmp/index.ts`,导出 IcmpChecker
- [ ] 5.3 在 `src/server/checker/runner/index.ts` 中 import IcmpChecker 并将 `new IcmpChecker()` 添加到 checkers 数组
## 6. 集成测试
- [ ] 6.1 创建 `tests/server/checker/runner/icmp/execute.test.ts`,测试 IcmpChecker 的 execute 方法mock Bun.spawn
- [ ] 6.2 更新 `tests/server/checker/runner/registry.test.ts`,验证 ping type 已注册
- [ ] 6.3 更新 `tests/server/checker/config-loader.test.ts`,添加 ping target 配置解析和校验测试
## 7. Schema 导出与文档
- [ ] 7.1 更新 `probe-config.schema.json`,包含 ping target 和 expect 的 schema 片段
- [ ] 7.2 更新 `probes.example.yaml`,添加 ping checker 配置示例
- [ ] 7.3 更新 README.md添加 ping checker 说明和系统依赖ping 命令)注明
- [ ] 7.4 更新 DEVELOPMENT.md如有必要
## 8. 质量保障
- [ ] 8.1 执行完整测试套件bun test确保所有测试通过
- [ ] 8.2 执行代码检查lint和格式检查format确保无错误
- [ ] 8.3 验证 probe-config.schema.json 与运行期 schema 一致(运行 schema 生成脚本)

View File

@@ -176,7 +176,7 @@
- **THEN** SHALL 调用 `runner/cmd/expect.ts` 中的 `checkExitCode()`
### Requirement: 超时控制由引擎注入 signal
Checker 实现的 `execute()` MUST 使用 `ctx.signal` 感知超时,不得自行创建 `AbortController``setTimeout` 用于超时控制。仅 cmd checker 可在 signal abort 时 `proc.kill()` 以确保子进程被终止。
Checker 实现的 `execute()` MUST 使用 `ctx.signal` 感知超时,不得自行创建 `AbortController``setTimeout` 用于超时控制。Cmd checker 和 ping checker 可在 signal abort 时 `proc.kill()` 以确保子进程被终止。
#### Scenario: HTTP checker 使用 signal
- **WHEN** HttpChecker 执行 HTTP 请求
@@ -186,6 +186,10 @@ Checker 实现的 `execute()` MUST 使用 `ctx.signal` 感知超时,不得自
- **WHEN** CommandChecker 执行命令且 signal 被 abort
- **THEN** SHALL 调用 `proc.kill()` 终止子进程,并在 CheckResult 中记录超时错误
#### Scenario: Ping checker 响应 signal
- **WHEN** IcmpChecker 执行 ping 命令且 signal 被 abort
- **THEN** SHALL 调用 `proc.kill()` 终止 ping 子进程,并在 CheckResult 中记录超时错误
### Requirement: CheckFailure.phase 使用 string 类型
`shared/api.ts``CheckFailure.phase` 的类型 SHALL 定义为 `string`,替代原有的硬编码联合类型 `"status" | "duration" | "headers" | "body" | "exitCode" | "stdout" | "stderr"`

View File

@@ -5,7 +5,7 @@
## Requirements
### Requirement: YAML 配置文件格式
系统 SHALL 支持通过 YAML 配置文件定义全部运行参数,包括 server 配置、runtime 配置、可选的 variables 段、checker 默认值和 typed target 列表(含可选 group 字段。target MUST 使用 `id` 字段作为唯一标识符MUST 使用 `type` 字段声明 checker 类型SHALL 支持可选的 `name` 字段作为展示名称元信息SHALL 支持可选的 `description` 字段作为目标说明。`name``description` 均 SHALL 允许省略或显式配置为 `null`;省略或显式 null 时解析结果 SHALL 保留为 null。HTTP 领域字段 MUST 放在 `http` 分组cmd 领域字段 MUST 放在 `cmd` 分组db 领域字段 MUST 放在 `db` 分组tcp 领域字段 MUST 放在 `tcp` 分组。HTTP target 的 `http` 分组 SHALL 支持可选的 `ignoreSSL`(布尔值)和 `maxRedirects`非负整数字段。Db target 的 `db` 分组 SHALL 支持 `url`(必填)和 `query`可选字段。Tcp target 的 `tcp` 分组 SHALL 支持 `host`(必填)、`port`(必填)、`readBanner`(可选)、`bannerReadTimeout`(可选)和 `maxBannerBytes`(可选)字段。
系统 SHALL 支持通过 YAML 配置文件定义全部运行参数,包括 server 配置、runtime 配置、可选的 variables 段、checker 默认值和 typed target 列表(含可选 group 字段。target MUST 使用 `id` 字段作为唯一标识符MUST 使用 `type` 字段声明 checker 类型SHALL 支持可选的 `name` 字段作为展示名称元信息SHALL 支持可选的 `description` 字段作为目标说明。`name``description` 均 SHALL 允许省略或显式配置为 `null`;省略或显式 null 时解析结果 SHALL 保留为 null。HTTP 领域字段 MUST 放在 `http` 分组cmd 领域字段 MUST 放在 `cmd` 分组db 领域字段 MUST 放在 `db` 分组tcp 领域字段 MUST 放在 `tcp` 分组ping 领域字段 MUST 放在 `ping` 分组。HTTP target 的 `http` 分组 SHALL 支持可选的 `ignoreSSL`(布尔值)和 `maxRedirects`非负整数字段。Db target 的 `db` 分组 SHALL 支持 `url`(必填)和 `query`可选字段。Tcp target 的 `tcp` 分组 SHALL 支持 `host`(必填)、`port`(必填)、`readBanner`(可选)、`bannerReadTimeout`(可选)和 `maxBannerBytes`(可选)字段。Ping target 的 `ping` 分组 SHALL 支持 `host`(必填)、`count`(可选,默认 3`packetSize`(可选,默认 56字段。
`defaults.http` 分组 SHALL 仅支持 `headers`(可选)和 `maxBodyBytes`(可选)字段。`defaults.http` 分组 MUST NOT 支持 `method` 字段。`defaults.tcp` 分组 SHALL 仅支持 `bannerReadTimeout`(可选)和 `maxBannerBytes`(可选)字段。
@@ -57,6 +57,10 @@
- **WHEN** HTTP target 未配置 `http.method` 且 defaults.http 中无 method 字段
- **THEN** 系统 SHALL 使用内置默认值 GET 作为该 target 的请求方法
#### Scenario: 最简 ping 配置文件解析
- **WHEN** 系统读取只包含一个 `type: ping` target`id``ping.host`)的 YAML 配置文件
- **THEN** 系统 SHALL 使用内置默认值填充未指定的字段interval=30s, timeout=10s, group="default", ping.count=3, ping.packetSize=56并保留 name=null、description=null
### Requirement: CLI 参数
系统 SHALL 通过单一命令行参数接受 YAML 配置文件路径。
@@ -177,6 +181,14 @@
- **WHEN** YAML 中某个 target 的 `expect.maxDurationMs` 不是非负有限数字
- **THEN** 系统 SHALL 以错误退出,提示该 target 的 expect.maxDurationMs 格式错误
#### Scenario: ping target 缺少 host
- **WHEN** YAML 中某个 target 配置 `type: ping` 但缺少 `ping.host`
- **THEN** 系统 SHALL 以错误退出,提示该 target 缺少 ping.host 字段
#### Scenario: ping expect 未知字段
- **WHEN** YAML 中 ping target 的 expect 包含非 ping expect 字段
- **THEN** 系统 SHALL 以配置错误退出,提示 expect 包含未知字段
#### Scenario: HTTP expect headers 非法
- **WHEN** YAML 中某个 HTTP target 的 `expect.headers` 不是对象,或某个 header 期望既不是字符串也不是合法 operator
- **THEN** 系统 SHALL 以错误退出,提示该 target 的 expect.headers 格式错误
@@ -273,7 +285,7 @@
- **THEN** 系统 SHALL 调用 `Bun.YAML.parse()` 将内容解析为配置对象
### Requirement: expect 配置增强
系统 SHALL 支持 typed target 的领域专用 expect 配置,包括 HTTP 的 `status`(支持精确数字和范围模式)、`headers``body`cmd 的 `exitCode``stdout``stderr`。内容类 expect MUST 使用数组表达配置顺序。
系统 SHALL 支持 typed target 的领域专用 expect 配置,包括 HTTP 的 `status`(支持精确数字和范围模式)、`headers``body`cmd 的 `exitCode``stdout``stderr`tcp 的 `connected``banner`,以及 ping 的 `alive``maxPacketLoss``maxAvgLatencyMs``maxMaxLatencyMs`。内容类 expect MUST 使用数组表达配置顺序。
#### Scenario: 解析 HTTP expect 配置
- **WHEN** YAML 配置文件中 HTTP target 的 expect 包含 status、headers、body 规则数组及内部方法
@@ -307,6 +319,14 @@
- **WHEN** target 未配置任何 expect 规则
- **THEN** 系统 SHALL 正常处理expect 字段为 undefined
#### Scenario: 解析 ping expect 配置
- **WHEN** YAML 配置文件中 ping target 的 expect 包含 alive、maxPacketLoss、maxAvgLatencyMs、maxMaxLatencyMs 和 maxDurationMs
- **THEN** 系统 SHALL 正确解析并存储为 ping target 的 expect 字段
#### Scenario: 不配置 ping expect
- **WHEN** ping target 未配置任何 expect 规则
- **THEN** 系统 SHALL 正常处理expect 字段为 undefined执行时使用默认 alive=true 语义
### Requirement: 数据保留配置字段
配置 schema 的 `runtime` 段 SHALL 支持 `retention` 字段,类型为字符串,格式为 `<数字><单位>`(单位:`d` 天、`h` 小时、`m` 分钟),用于指定历史数据保留时长。

View File

@@ -83,6 +83,11 @@
]
}
}
},
"ping": {
"additionalProperties": false,
"type": "object",
"properties": {}
}
}
},
@@ -1142,6 +1147,107 @@
}
}
}
},
{
"additionalProperties": false,
"type": "object",
"required": [
"id",
"type",
"ping"
],
"properties": {
"description": {
"anyOf": [
{
"type": "null"
},
{
"maxLength": 500,
"type": "string"
}
]
},
"expect": {
"additionalProperties": false,
"type": "object",
"properties": {
"alive": {
"type": "boolean"
},
"maxAvgLatencyMs": {
"minimum": 0,
"type": "number"
},
"maxDurationMs": {
"minimum": 0,
"type": "number"
},
"maxMaxLatencyMs": {
"minimum": 0,
"type": "number"
},
"maxPacketLoss": {
"maximum": 100,
"minimum": 0,
"type": "number"
}
}
},
"group": {
"type": "string"
},
"id": {
"maxLength": 30,
"minLength": 1,
"type": "string"
},
"interval": {
"type": "string"
},
"name": {
"anyOf": [
{
"type": "null"
},
{
"maxLength": 30,
"minLength": 1,
"type": "string"
}
]
},
"timeout": {
"type": "string"
},
"type": {
"const": "ping",
"type": "string"
},
"ping": {
"additionalProperties": false,
"type": "object",
"required": [
"host"
],
"properties": {
"count": {
"maximum": 100,
"minimum": 1,
"type": "integer"
},
"host": {
"minLength": 1,
"type": "string"
},
"packetSize": {
"maximum": 65500,
"minimum": 1,
"type": "integer"
}
}
}
}
}
]
}

View File

@@ -170,3 +170,20 @@ targets:
expect:
banner:
contains: "ESMTP"
# ========== Ping targets ==========
- id: "gateway-ping"
name: "网关 ICMP 可达"
type: ping
group: "基础设施"
ping:
host: "127.0.0.1"
count: 3
packetSize: 56
expect:
alive: true
maxPacketLoss: 10
maxAvgLatencyMs: 100
maxMaxLatencyMs: 300
maxDurationMs: 5000

View File

@@ -0,0 +1,18 @@
import type { ResolvedPingTarget } from "./types";
export function buildPingCommand(t: ResolvedPingTarget, platform: NodeJS.Platform = process.platform): string[] {
if (platform === "win32") {
return [
"ping",
"-n",
String(t.ping.count),
"-l",
String(t.ping.packetSize),
"-w",
String(t.timeoutMs),
t.ping.host,
];
}
const timeout = platform === "linux" ? String(Math.ceil(t.timeoutMs / 1000)) : String(t.timeoutMs);
return ["ping", "-c", String(t.ping.count), "-s", String(t.ping.packetSize), "-W", timeout, t.ping.host];
}

View File

@@ -0,0 +1,182 @@
import { isError } from "es-toolkit";
import type { CheckResult, RawTargetConfig } from "../../types";
import type { CheckerContext, CheckerDefinition, CheckerValidationInput, ResolveContext } from "../types";
import type { PingExpectConfig, PingStats, PingTargetConfig, ResolvedPingTarget } from "./types";
import { checkDuration } from "../../expect/duration";
import { errorFailure } from "../../expect/failure";
import { buildPingCommand } from "./command";
import { checkAlive, checkAvgLatency, checkMaxLatency, checkPacketLoss } from "./expect";
import { parsePingOutput } from "./parse";
import { icmpCheckerSchemas } from "./schema";
import { validatePingConfig } from "./validate";
const DEFAULT_COUNT = 3;
const DEFAULT_PACKET_SIZE = 56;
export class IcmpChecker implements CheckerDefinition<ResolvedPingTarget> {
readonly configKey = "ping";
readonly schemas = icmpCheckerSchemas;
readonly type = "ping";
async execute(t: ResolvedPingTarget, ctx: CheckerContext): Promise<CheckResult> {
const timestamp = new Date().toISOString();
const start = performance.now();
let proc: ReturnType<typeof Bun.spawn>;
try {
proc = Bun.spawn(buildPingCommand(t), {
stderr: "pipe",
stdin: "ignore",
stdout: "pipe",
});
} catch (error) {
const durationMs = Math.round(performance.now() - start);
return {
durationMs,
failure: errorFailure("ping", "spawn", `ping 命令不可用: ${isError(error) ? error.message : String(error)}`),
matched: false,
statusDetail: "ping command not found",
targetId: t.id,
timestamp,
};
}
ctx.signal.addEventListener(
"abort",
() => {
try {
proc.kill();
} catch {
/* best-effort kill */
}
},
{ once: true },
);
const stdout = await readStream(proc.stdout as ReadableStream<Uint8Array>);
await proc.exited;
const durationMs = Math.round(performance.now() - start);
if (ctx.signal.aborted) {
return {
durationMs,
failure: errorFailure("ping", "timeout", `ping 执行超时 (${t.timeoutMs}ms)`),
matched: false,
statusDetail: null,
targetId: t.id,
timestamp,
};
}
const stats = parsePingOutput(stdout, process.platform);
if (!stats) {
return {
durationMs,
failure: errorFailure("ping", "parse", "无法解析 ping 输出"),
matched: false,
statusDetail: truncateOutput(stdout),
targetId: t.id,
timestamp,
};
}
const result = checkStats(stats, t.expect, durationMs);
return {
durationMs,
failure: result.failure,
matched: result.matched,
statusDetail: buildStatusDetail(stats),
targetId: t.id,
timestamp,
};
}
resolve(target: RawTargetConfig, context: ResolveContext): ResolvedPingTarget {
const t = target as RawTargetConfig & { ping: PingTargetConfig; type: "ping" };
return {
description: null,
expect: target.expect as PingExpectConfig | undefined,
group: target.group ?? "default",
id: t.id,
intervalMs: context.defaultIntervalMs,
name: t.name ?? null,
ping: {
count: t.ping.count ?? DEFAULT_COUNT,
host: t.ping.host,
packetSize: t.ping.packetSize ?? DEFAULT_PACKET_SIZE,
},
timeoutMs: context.defaultTimeoutMs,
type: "ping",
} satisfies ResolvedPingTarget;
}
serialize(t: ResolvedPingTarget): { config: string; target: string } {
return {
config: JSON.stringify(t.ping),
target: `ping ${t.ping.host}`,
};
}
validate(input: CheckerValidationInput) {
return validatePingConfig(input);
}
}
function buildStatusDetail(stats: PingStats): string {
if (!stats.alive) return `unreachable (${stats.received}/${stats.transmitted} received)`;
const avg = stats.avgLatencyMs === null ? "n/a" : formatNumber(stats.avgLatencyMs);
const loss = formatNumber(stats.packetLoss);
let detail = `alive, avg ${avg}ms, loss ${loss}% (${stats.received}/${stats.transmitted})`;
if (stats.packetLoss > 0 && stats.maxLatencyMs !== null) {
detail = `${detail}, max ${formatNumber(stats.maxLatencyMs)}ms`;
}
return detail;
}
function checkStats(stats: PingStats, expect: PingExpectConfig | undefined, durationMs: number) {
const aliveResult = checkAlive(stats.alive, expect?.alive ?? true);
if (!aliveResult.matched) return aliveResult;
const packetLossResult = checkPacketLoss(stats.packetLoss, expect?.maxPacketLoss);
if (!packetLossResult.matched) return packetLossResult;
const avgLatencyResult = checkAvgLatency(stats.avgLatencyMs, expect?.maxAvgLatencyMs);
if (!avgLatencyResult.matched) return avgLatencyResult;
const maxLatencyResult = checkMaxLatency(stats.maxLatencyMs, expect?.maxMaxLatencyMs);
if (!maxLatencyResult.matched) return maxLatencyResult;
return checkDuration(durationMs, expect?.maxDurationMs);
}
function formatNumber(value: number): string {
return Number.isInteger(value) ? String(value) : String(Number(value.toFixed(3)));
}
async function readStream(stream: ReadableStream<Uint8Array>): Promise<string> {
const reader = stream.getReader();
const decoder = new TextDecoder();
let text = "";
try {
while (true) {
const { done, value } = await reader.read();
if (done) break;
text += decoder.decode(value, { stream: true });
}
text += decoder.decode();
} catch {
/* stream already closed */
} finally {
try {
reader.releaseLock();
} catch {
/* already released */
}
}
return text;
}
function truncateOutput(output: string, maxLen = 80): string {
if (output.length <= maxLen) return output;
return `${output.slice(0, maxLen)}`;
}

View File

@@ -0,0 +1,44 @@
import type { ExpectResult } from "../../expect/types";
import { mismatchFailure } from "../../expect/failure";
export function checkAlive(actual: boolean, expected: boolean): ExpectResult {
if (actual === expected) return { failure: null, matched: true };
return {
failure: mismatchFailure(
"alive",
"alive",
expected,
actual,
expected ? "期望主机可达但 ping 不可达" : "期望主机不可达但 ping 可达",
),
matched: false,
};
}
export function checkAvgLatency(actual: null | number, max: number | undefined): ExpectResult {
if (max === undefined) return { failure: null, matched: true };
if (actual !== null && actual <= max) return { failure: null, matched: true };
return {
failure: mismatchFailure("avgLatency", "avgLatencyMs", `<=${max}ms`, actual, `平均延迟超过 ${max}ms`),
matched: false,
};
}
export function checkMaxLatency(actual: null | number, max: number | undefined): ExpectResult {
if (max === undefined) return { failure: null, matched: true };
if (actual !== null && actual <= max) return { failure: null, matched: true };
return {
failure: mismatchFailure("maxLatency", "maxLatencyMs", `<=${max}ms`, actual, `最大延迟超过 ${max}ms`),
matched: false,
};
}
export function checkPacketLoss(actual: number, max: number | undefined): ExpectResult {
if (max === undefined) return { failure: null, matched: true };
if (actual <= max) return { failure: null, matched: true };
return {
failure: mismatchFailure("packetLoss", "packetLoss", `<=${max}%`, actual, `丢包率 ${actual}% > ${max}%`),
matched: false,
};
}

View File

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

View File

@@ -0,0 +1,50 @@
import type { PingStats } from "./types";
export type PingPlatform = "darwin" | "linux" | "win32" | NodeJS.Platform;
export function parsePingOutput(stdout: string, platform: PingPlatform): null | PingStats {
return platform === "win32" ? parseWindowsOutput(stdout) : parseUnixOutput(stdout);
}
function parseUnixOutput(stdout: string): null | PingStats {
const packetMatch =
/(\d+)\s+packets?\s+transmitted.*?(\d+)\s+(?:packets?\s+)?received.*?(\d+(?:\.\d+)?)%\s+packet\s+loss/i.exec(
stdout,
);
if (!packetMatch) return null;
const transmitted = Number(packetMatch[1]);
const received = Number(packetMatch[2]);
const packetLoss = Number(packetMatch[3]);
const latencyMatch = /(?:rtt|round-trip).*?=\s*([\d.]+)\/([\d.]+)\/([\d.]+)/i.exec(stdout);
return {
alive: received > 0,
avgLatencyMs: latencyMatch ? Number(latencyMatch[2]) : null,
maxLatencyMs: latencyMatch ? Number(latencyMatch[3]) : null,
minLatencyMs: latencyMatch ? Number(latencyMatch[1]) : null,
packetLoss,
received,
transmitted,
};
}
function parseWindowsOutput(stdout: string): null | PingStats {
const packetMatch = /=\s*(\d+).*?=\s*(\d+).*?=\s*(\d+).*?(\d+(?:\.\d+)?)%/s.exec(stdout);
if (!packetMatch) return null;
const transmitted = Number(packetMatch[1]);
const received = Number(packetMatch[2]);
const packetLoss = Number(packetMatch[4]);
const latencyMatch = /=\s*(\d+)ms.*?=\s*(\d+)ms.*?=\s*(\d+)ms/s.exec(stdout);
return {
alive: received > 0,
avgLatencyMs: latencyMatch ? Number(latencyMatch[3]) : null,
maxLatencyMs: latencyMatch ? Number(latencyMatch[2]) : null,
minLatencyMs: latencyMatch ? Number(latencyMatch[1]) : null,
packetLoss,
received,
transmitted,
};
}

View File

@@ -0,0 +1,25 @@
import { Type } from "@sinclair/typebox";
import type { CheckerSchemas } from "../types";
export const icmpCheckerSchemas: CheckerSchemas = {
config: Type.Object(
{
count: Type.Optional(Type.Integer({ maximum: 100, minimum: 1 })),
host: Type.String({ minLength: 1 }),
packetSize: Type.Optional(Type.Integer({ maximum: 65500, minimum: 1 })),
},
{ additionalProperties: false },
),
defaults: Type.Object({}, { additionalProperties: false }),
expect: Type.Object(
{
alive: Type.Optional(Type.Boolean()),
maxAvgLatencyMs: Type.Optional(Type.Number({ minimum: 0 })),
maxDurationMs: Type.Optional(Type.Number({ minimum: 0 })),
maxMaxLatencyMs: Type.Optional(Type.Number({ minimum: 0 })),
maxPacketLoss: Type.Optional(Type.Number({ maximum: 100, minimum: 0 })),
},
{ additionalProperties: false },
),
};

View File

@@ -0,0 +1,41 @@
import type { ResolvedTargetBase } from "../../types";
export interface PingExpectConfig {
alive?: boolean;
maxAvgLatencyMs?: number;
maxDurationMs?: number;
maxMaxLatencyMs?: number;
maxPacketLoss?: number;
}
export interface PingStats {
alive: boolean;
avgLatencyMs: null | number;
maxLatencyMs: null | number;
minLatencyMs: null | number;
packetLoss: number;
received: number;
transmitted: number;
}
export interface PingTargetConfig {
count?: number;
host: string;
packetSize?: number;
}
export interface ResolvedPingConfig {
count: number;
host: string;
packetSize: number;
}
export interface ResolvedPingTarget extends ResolvedTargetBase {
expect?: PingExpectConfig;
group: string;
intervalMs: number;
name: null | string;
ping: ResolvedPingConfig;
timeoutMs: number;
type: "ping";
}

View File

@@ -0,0 +1,118 @@
import { isNumber, isPlainObject, isString } from "es-toolkit";
import type { ConfigValidationIssue } from "../../schema/issues";
import type { CheckerValidationInput } from "../types";
import { issue, joinPath } from "../../schema/issues";
export function validatePingConfig(input: CheckerValidationInput): ConfigValidationIssue[] {
const issues: ConfigValidationIssue[] = [];
const defaults = input.defaults["ping"];
if (defaults !== undefined && defaults !== null) {
const targetName = "defaults.ping";
if (!isPlainObject(defaults)) {
issues.push(issue("invalid-type", "defaults.ping", "必须为对象", targetName));
} else {
const pingDefaults = defaults as Record<string, unknown>;
for (const key of Object.keys(pingDefaults)) {
issues.push(issue("unknown-field", joinPath("defaults.ping", key), "是未知字段", targetName));
}
}
}
for (let i = 0; i < input.targets.length; i++) {
const target = input.targets[i] as unknown;
if (!isPlainObject(target)) continue;
const targetRecord = target as Record<string, unknown>;
if (targetRecord["type"] !== "ping") continue;
issues.push(...validatePingTarget(targetRecord, `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 isNonNegativeFiniteNumber(value: unknown): boolean {
return isNumber(value) && Number.isFinite(value) && value >= 0;
}
function validatePingExpect(target: Record<string, unknown>, path: string): ConfigValidationIssue[] {
const rawExpect = target["expect"];
if (rawExpect === undefined || rawExpect === null || !isPlainObject(rawExpect)) return [];
const expect = rawExpect as Record<string, unknown>;
const issues: ConfigValidationIssue[] = [];
const targetName = getTargetName(target);
const expectPath = joinPath(path, "expect");
if (expect["alive"] !== undefined && typeof expect["alive"] !== "boolean") {
issues.push(issue("invalid-type", joinPath(expectPath, "alive"), "必须为布尔值", targetName));
}
if (expect["maxPacketLoss"] !== undefined) {
const value = expect["maxPacketLoss"];
if (!isNumber(value) || !Number.isFinite(value) || value < 0 || value > 100) {
issues.push(issue("invalid-value", joinPath(expectPath, "maxPacketLoss"), "必须为 0-100 的数字", targetName));
}
}
for (const key of ["maxAvgLatencyMs", "maxMaxLatencyMs", "maxDurationMs"]) {
if (expect[key] !== undefined && !isNonNegativeFiniteNumber(expect[key])) {
issues.push(issue("invalid-type", joinPath(expectPath, key), "必须为非负有限数字", targetName));
}
}
const allowedKeys = new Set(["alive", "maxAvgLatencyMs", "maxDurationMs", "maxMaxLatencyMs", "maxPacketLoss"]);
for (const key of Object.keys(expect)) {
if (!allowedKeys.has(key)) {
issues.push(issue("unknown-field", joinPath(expectPath, key), "是未知字段", targetName));
}
}
return issues;
}
function validatePingTarget(target: Record<string, unknown>, path: string): ConfigValidationIssue[] {
const issues: ConfigValidationIssue[] = [];
const targetName = getTargetName(target);
const rawPing = target["ping"];
if (!isPlainObject(rawPing)) {
issues.push(issue("required", joinPath(path, "ping"), "缺少 ping 配置分组", targetName));
issues.push(...validatePingExpect(target, path));
return issues;
}
const ping = rawPing as Record<string, unknown>;
if (!isString(ping["host"]) || ping["host"].trim() === "") {
issues.push(issue("required", joinPath(joinPath(path, "ping"), "host"), "缺少 ping.host 字段", targetName));
}
if (ping["count"] !== undefined) {
const count = ping["count"];
if (!isNumber(count) || !Number.isInteger(count) || count < 1 || count > 100) {
issues.push(
issue("invalid-value", joinPath(joinPath(path, "ping"), "count"), "必须为 1-100 的正整数", targetName),
);
}
}
if (ping["packetSize"] !== undefined) {
const packetSize = ping["packetSize"];
if (!isNumber(packetSize) || !Number.isInteger(packetSize) || packetSize < 1 || packetSize > 65500) {
issues.push(
issue("invalid-value", joinPath(joinPath(path, "ping"), "packetSize"), "必须为 1-65500 的正整数", targetName),
);
}
}
const allowedPingKeys = new Set(["count", "host", "packetSize"]);
for (const key of Object.keys(ping)) {
if (!allowedPingKeys.has(key)) {
issues.push(issue("unknown-field", joinPath(joinPath(path, "ping"), key), "是未知字段", targetName));
}
}
issues.push(...validatePingExpect(target, path));
return issues;
}

View File

@@ -1,10 +1,11 @@
import { CommandChecker } from "./cmd";
import { DbChecker } from "./db";
import { HttpChecker } from "./http";
import { IcmpChecker } from "./icmp";
import { CheckerRegistry } from "./registry";
import { TcpChecker } from "./tcp";
const checkers = [new HttpChecker(), new CommandChecker(), new DbChecker(), new TcpChecker()];
const checkers = [new HttpChecker(), new CommandChecker(), new DbChecker(), new TcpChecker(), new IcmpChecker()];
export function createDefaultCheckerRegistry(): CheckerRegistry {
const registry = new CheckerRegistry();

View File

@@ -5,6 +5,7 @@ import { join } from "node:path";
import type { ResolvedCommandTarget } from "../../../src/server/checker/runner/cmd/types";
import type { ResolvedHttpTarget } from "../../../src/server/checker/runner/http/types";
import type { ResolvedPingTarget } from "../../../src/server/checker/runner/icmp/types";
import type { ResolvedTcpTarget } from "../../../src/server/checker/runner/tcp/types";
import { loadConfig, parseDuration } from "../../../src/server/checker/config-loader";
@@ -1974,4 +1975,99 @@ targets:
expect(t.expect?.connected).toBe(false);
expect(t.expect?.maxDurationMs).toBe(5000);
});
test("解析最简 ping 配置", async () => {
const configPath = join(tempDir, "minimal-ping.yaml");
await writeFile(
configPath,
`targets:
- id: "gateway"
type: ping
ping:
host: "10.0.0.1"
`,
);
const config = await loadConfig(configPath);
expect(config.targets).toHaveLength(1);
const t = config.targets[0]! as ResolvedPingTarget;
expect(t.type).toBe("ping");
expect(t.ping).toEqual({ count: 3, host: "10.0.0.1", packetSize: 56 });
expect(t.group).toBe("default");
expect(t.intervalMs).toBe(30000);
expect(t.timeoutMs).toBe(10000);
});
test("解析 ping expect 配置", async () => {
const configPath = join(tempDir, "ping-expect.yaml");
await writeFile(
configPath,
`targets:
- id: "gateway"
type: ping
ping:
host: "10.0.0.1"
count: 5
packetSize: 1472
expect:
alive: true
maxPacketLoss: 10
maxAvgLatencyMs: 200
maxMaxLatencyMs: 500
maxDurationMs: 5000
`,
);
const config = await loadConfig(configPath);
const t = config.targets[0]! as ResolvedPingTarget;
expect(t.ping).toEqual({ count: 5, host: "10.0.0.1", packetSize: 1472 });
expect(t.expect).toEqual({
alive: true,
maxAvgLatencyMs: 200,
maxDurationMs: 5000,
maxMaxLatencyMs: 500,
maxPacketLoss: 10,
});
});
test("ping 缺少 host 抛出错误", async () => {
await expectConfigError(
"ping-no-host.yaml",
`targets:
- id: "gateway"
type: ping
ping: {}
`,
"ping.host",
);
});
test("ping count 非法抛出错误", async () => {
await expectConfigError(
"ping-bad-count.yaml",
`targets:
- id: "gateway"
type: ping
ping:
host: "10.0.0.1"
count: 0
`,
"ping.count",
);
});
test("ping expect 未知字段抛出错误", async () => {
await expectConfigError(
"ping-unknown-expect.yaml",
`targets:
- id: "gateway"
type: ping
ping:
host: "10.0.0.1"
expect:
status: [200]
`,
"expect.status 是未知字段",
);
});
});

View File

@@ -0,0 +1,54 @@
import { describe, expect, test } from "bun:test";
import type { ResolvedPingTarget } from "../../../../../src/server/checker/runner/icmp/types";
import { buildPingCommand } from "../../../../../src/server/checker/runner/icmp/command";
function makeTarget(overrides?: Partial<ResolvedPingTarget>): ResolvedPingTarget {
return {
description: null,
group: "default",
id: "test",
intervalMs: 30000,
name: null,
ping: { count: 3, host: "10.0.0.1", packetSize: 56 },
timeoutMs: 10000,
type: "ping",
...overrides,
};
}
describe("buildPingCommand", () => {
test("Linux 默认参数", () => {
const cmd = buildPingCommand(makeTarget(), "linux");
expect(cmd).toEqual(["ping", "-c", "3", "-s", "56", "-W", "10", "10.0.0.1"]);
});
test("Linux 秒向上取整", () => {
const cmd = buildPingCommand(makeTarget({ timeoutMs: 10500 }), "linux");
expect(cmd[6]).toBe("11");
});
test("Linux timeoutMs < 1000 向上取整为 1", () => {
const cmd = buildPingCommand(makeTarget({ timeoutMs: 500 }), "linux");
expect(cmd[6]).toBe("1");
});
test("macOS 毫秒", () => {
const cmd = buildPingCommand(makeTarget(), "darwin");
expect(cmd).toEqual(["ping", "-c", "3", "-s", "56", "-W", "10000", "10.0.0.1"]);
});
test("Windows 格式", () => {
const cmd = buildPingCommand(makeTarget(), "win32");
expect(cmd).toEqual(["ping", "-n", "3", "-l", "56", "-w", "10000", "10.0.0.1"]);
});
test("自定义 count 和 packetSize", () => {
const cmd = buildPingCommand(
makeTarget({ ping: { count: 5, host: "10.0.0.1", packetSize: 1472 }, timeoutMs: 5000 }),
"linux",
);
expect(cmd).toEqual(["ping", "-c", "5", "-s", "1472", "-W", "5", "10.0.0.1"]);
});
});

View File

@@ -0,0 +1,126 @@
import { afterEach, describe, expect, mock, test } from "bun:test";
import type { ResolvedPingTarget } from "../../../../../src/server/checker/runner/icmp/types";
import type { CheckerContext } from "../../../../../src/server/checker/runner/types";
import { IcmpChecker } from "../../../../../src/server/checker/runner/icmp/execute";
const checker = new IcmpChecker();
const originalSpawn = Bun.spawn;
afterEach(() => {
Bun.spawn = originalSpawn;
mock.restore();
});
function makeCtx(): CheckerContext {
return { signal: new AbortController().signal };
}
function makeTarget(overrides?: Partial<ResolvedPingTarget>): ResolvedPingTarget {
return {
description: null,
group: "default",
id: "ping-local",
intervalMs: 30000,
name: null,
ping: { count: 3, host: "127.0.0.1", packetSize: 56 },
timeoutMs: 10000,
type: "ping",
...overrides,
};
}
function mockSpawn(stdout: string, exitCode = 0) {
const calls: string[][] = [];
const spawnMock = mock((command: string[]) => {
calls.push(command);
return {
exitCode,
exited: Promise.resolve(exitCode),
kill: mock(() => undefined),
stderr: new Response("").body,
stdout: new Response(stdout).body,
};
});
Bun.spawn = spawnMock as unknown as typeof Bun.spawn;
return calls;
}
describe("IcmpChecker execute", () => {
test("执行 ping 并匹配默认 alive", async () => {
const calls = mockSpawn(`3 packets transmitted, 3 received, 0% packet loss, time 2003ms
rtt min/avg/max/mdev = 1.234/2.345/3.456/0.567 ms`);
const result = await checker.execute(makeTarget(), makeCtx());
expect(result.matched).toBe(true);
expect(result.failure).toBeNull();
expect(result.statusDetail).toBe("alive, avg 2.345ms, loss 0% (3/3)");
expect(calls[0]).toContain("ping");
});
test("alive 失败短路", async () => {
mockSpawn(`3 packets transmitted, 0 received, 100% packet loss, time 2003ms`);
const result = await checker.execute(makeTarget({ expect: { alive: true, maxAvgLatencyMs: 100 } }), makeCtx());
expect(result.matched).toBe(false);
expect(result.failure?.phase).toBe("alive");
expect(result.statusDetail).toBe("unreachable (0/3 received)");
});
test("反向 alive 断言通过", async () => {
mockSpawn(`3 packets transmitted, 0 received, 100% packet loss, time 2003ms`);
const result = await checker.execute(makeTarget({ expect: { alive: false } }), makeCtx());
expect(result.matched).toBe(true);
});
test("packetLoss 断言失败", async () => {
mockSpawn(`3 packets transmitted, 2 received, 33% packet loss, time 2003ms
rtt min/avg/max/mdev = 1.234/156.000/340.000/0.567 ms`);
const result = await checker.execute(makeTarget({ expect: { maxPacketLoss: 10 } }), makeCtx());
expect(result.matched).toBe(false);
expect(result.failure?.phase).toBe("packetLoss");
expect(result.statusDetail).toContain("max 340ms");
});
test("解析失败返回结构化错误", async () => {
mockSpawn("unexpected output");
const result = await checker.execute(makeTarget(), makeCtx());
expect(result.matched).toBe(false);
expect(result.failure).toMatchObject({ kind: "error", path: "parse", phase: "ping" });
});
test("spawn 失败返回 ping 命令不可用", async () => {
Bun.spawn = mock(() => {
throw new Error("ENOENT");
});
const result = await checker.execute(makeTarget(), makeCtx());
expect(result.matched).toBe(false);
expect(result.failure?.message).toContain("ping 命令不可用");
expect(result.statusDetail).toBe("ping command not found");
});
test("预 abort 返回超时错误", async () => {
mockSpawn(`3 packets transmitted, 3 received, 0% packet loss, time 2003ms`);
const controller = new AbortController();
controller.abort();
const result = await checker.execute(makeTarget(), { signal: controller.signal });
expect(result.matched).toBe(false);
expect(result.failure).toMatchObject({ path: "timeout", phase: "ping" });
});
});
describe("IcmpChecker resolve", () => {
test("解析默认值", () => {
const target = checker.resolve(
{ id: "ping", ping: { host: "10.0.0.1" }, type: "ping" },
{ configDir: "/tmp", defaultIntervalMs: 30000, defaults: {}, defaultTimeoutMs: 10000 },
);
expect(target.ping).toEqual({ count: 3, host: "10.0.0.1", packetSize: 56 });
expect(target.group).toBe("default");
});
test("serialize 返回摘要和配置", () => {
const serialized = checker.serialize(makeTarget({ ping: { count: 5, host: "10.0.0.1", packetSize: 1472 } }));
expect(serialized.target).toBe("ping 10.0.0.1");
expect(JSON.parse(serialized.config)).toEqual({ count: 5, host: "10.0.0.1", packetSize: 1472 });
});
});

View File

@@ -0,0 +1,44 @@
import { describe, expect, test } from "bun:test";
import {
checkAlive,
checkAvgLatency,
checkMaxLatency,
checkPacketLoss,
} from "../../../../../src/server/checker/runner/icmp/expect";
describe("ping expect", () => {
test("alive 通过和失败", () => {
expect(checkAlive(true, true).matched).toBe(true);
const result = checkAlive(false, true);
expect(result.matched).toBe(false);
expect(result.failure?.phase).toBe("alive");
});
test("packetLoss 通过和失败", () => {
expect(checkPacketLoss(0, 10).matched).toBe(true);
const result = checkPacketLoss(33, 10);
expect(result.matched).toBe(false);
expect(result.failure?.phase).toBe("packetLoss");
});
test("avgLatency 通过和失败", () => {
expect(checkAvgLatency(12, 200).matched).toBe(true);
const result = checkAvgLatency(156, 100);
expect(result.matched).toBe(false);
expect(result.failure?.phase).toBe("avgLatency");
});
test("maxLatency 通过和失败", () => {
expect(checkMaxLatency(340, 500).matched).toBe(true);
const result = checkMaxLatency(340, 200);
expect(result.matched).toBe(false);
expect(result.failure?.phase).toBe("maxLatency");
});
test("未配置阈值默认通过", () => {
expect(checkPacketLoss(100, undefined).matched).toBe(true);
expect(checkAvgLatency(null, undefined).matched).toBe(true);
expect(checkMaxLatency(null, undefined).matched).toBe(true);
});
});

View File

@@ -0,0 +1,61 @@
import { describe, expect, test } from "bun:test";
import { parsePingOutput } from "../../../../../src/server/checker/runner/icmp/parse";
describe("parsePingOutput", () => {
test("解析 Linux ping 输出", () => {
const stats = parsePingOutput(
`3 packets transmitted, 3 received, 0% packet loss, time 2003ms
rtt min/avg/max/mdev = 1.234/2.345/3.456/0.567 ms`,
"linux",
);
expect(stats).toEqual({
alive: true,
avgLatencyMs: 2.345,
maxLatencyMs: 3.456,
minLatencyMs: 1.234,
packetLoss: 0,
received: 3,
transmitted: 3,
});
});
test("解析 macOS ping 输出", () => {
const stats = parsePingOutput(
`3 packets transmitted, 3 packets received, 0.0% packet loss
round-trip min/avg/max/stddev = 1.234/2.345/3.456/0.567 ms`,
"darwin",
);
expect(stats?.avgLatencyMs).toBe(2.345);
expect(stats?.packetLoss).toBe(0);
});
test("解析 Windows 英文 ping 输出", () => {
const stats = parsePingOutput(
`Packets: Sent = 3, Received = 3, Lost = 0 (0% loss),
Approximate round trip times in milli-seconds:
Minimum = 1ms, Maximum = 3ms, Average = 2ms`,
"win32",
);
expect(stats).toMatchObject({ avgLatencyMs: 2, maxLatencyMs: 3, minLatencyMs: 1, packetLoss: 0 });
});
test("解析 Windows 中文 ping 输出", () => {
const stats = parsePingOutput(
`数据包: 已发送 = 3已接收 = 3丢失 = 0 (0% 丢失)
往返行程的估计时间(以毫秒为单位):
最短 = 1ms最长 = 3ms平均 = 2ms`,
"win32",
);
expect(stats).toMatchObject({ avgLatencyMs: 2, maxLatencyMs: 3, minLatencyMs: 1, packetLoss: 0 });
});
test("解析全部丢包", () => {
const stats = parsePingOutput(`3 packets transmitted, 0 received, 100% packet loss, time 2003ms`, "linux");
expect(stats).toMatchObject({ alive: false, avgLatencyMs: null, maxLatencyMs: null, minLatencyMs: null });
});
test("无法解析返回 null", () => {
expect(parsePingOutput("unexpected output", "linux")).toBeNull();
});
});

View File

@@ -0,0 +1,70 @@
import { describe, expect, test } from "bun:test";
import type { RawTargetConfig } from "../../../../../src/server/checker/types";
import { validatePingConfig } from "../../../../../src/server/checker/runner/icmp/validate";
function validate(target: RawTargetConfig) {
return validatePingConfig({ defaults: {}, targets: [target] });
}
describe("validatePingConfig", () => {
test("有效配置无错误", () => {
expect(validate({ id: "ping", ping: { count: 3, host: "127.0.0.1", packetSize: 56 }, type: "ping" })).toEqual([]);
});
test("host 缺失", () => {
const issues = validate({ id: "ping", ping: {}, type: "ping" });
expect(issues.some((item) => item.path.endsWith("ping.host"))).toBe(true);
});
test("host 类型非法", () => {
const issues = validate({ id: "ping", ping: { host: 123 }, type: "ping" });
expect(issues.some((item) => item.path.endsWith("ping.host"))).toBe(true);
});
test("count 非法", () => {
const issues = validate({ id: "ping", ping: { count: 0, host: "127.0.0.1" }, type: "ping" });
expect(issues.some((item) => item.path.endsWith("ping.count"))).toBe(true);
});
test("packetSize 非法", () => {
const issues = validate({ id: "ping", ping: { host: "127.0.0.1", packetSize: 65501 }, type: "ping" });
expect(issues.some((item) => item.path.endsWith("ping.packetSize"))).toBe(true);
});
test("ping 未知字段", () => {
const issues = validate({ id: "ping", ping: { host: "127.0.0.1", timeout: 5 }, type: "ping" });
expect(issues.some((item) => item.code === "unknown-field" && item.path.endsWith("ping.timeout"))).toBe(true);
});
test("expect 未知字段", () => {
const issues = validate({ expect: { status: [200] }, id: "ping", ping: { host: "127.0.0.1" }, type: "ping" });
expect(issues.some((item) => item.path.endsWith("expect.status"))).toBe(true);
});
test("expect 数值非法", () => {
const issues = validate({ expect: { maxPacketLoss: 101 }, id: "ping", ping: { host: "127.0.0.1" }, type: "ping" });
expect(issues.some((item) => item.path.endsWith("expect.maxPacketLoss"))).toBe(true);
});
test("maxDurationMs 类型非法", () => {
const issues = validate({ expect: { maxDurationMs: -1 }, id: "ping", ping: { host: "127.0.0.1" }, type: "ping" });
expect(issues.some((item) => item.path.endsWith("expect.maxDurationMs"))).toBe(true);
});
test("maxAvgLatencyMs 类型非法", () => {
const issues = validate({
expect: { maxAvgLatencyMs: "slow" },
id: "ping",
ping: { host: "127.0.0.1" },
type: "ping",
});
expect(issues.some((item) => item.path.endsWith("expect.maxAvgLatencyMs"))).toBe(true);
});
test("host 为空字符串", () => {
const issues = validate({ id: "ping", ping: { host: " " }, type: "ping" });
expect(issues.some((item) => item.path.endsWith("ping.host"))).toBe(true);
});
});

View File

@@ -66,12 +66,18 @@ describe("CheckerRegistry", () => {
const second = createDefaultCheckerRegistry();
first.register(createChecker("custom"));
expect(first.supportedTypes).toEqual(["http", "cmd", "db", "tcp", "custom"]);
expect(second.supportedTypes).toEqual(["http", "cmd", "db", "tcp"]);
expect(first.supportedTypes).toEqual(["http", "cmd", "db", "tcp", "ping", "custom"]);
expect(second.supportedTypes).toEqual(["http", "cmd", "db", "tcp", "ping"]);
expect(
first.definitions.every(
(checker) => checker.schemas.config && checker.schemas.defaults && checker.schemas.expect,
),
).toBe(true);
});
test("默认 registry 注册 ping type", () => {
const registry = createDefaultCheckerRegistry();
expect(registry.supportedTypes).toContain("ping");
expect(registry.get("ping").configKey).toBe("ping");
});
});

View File

@@ -100,7 +100,7 @@ Object.defineProperty(dom.window, "customElements", {
globalThis.customElements = dom.window.customElements;
// Mock @number-flow/react globally (custom elements not supported in jsdom)
import { mock } from "bun:test";
import { afterEach, mock } from "bun:test";
import { createElement } from "react";
void mock.module("@number-flow/react", () => {
@@ -108,3 +108,7 @@ void mock.module("@number-flow/react", () => {
const NumberFlowGroup = ({ children }: { children: unknown }) => children;
return { default: NumberFlow, NumberFlowGroup };
});
afterEach(() => {
document.body.innerHTML = "";
});