From 550c4278146abacdc111c5135f31101d08ad9316 Mon Sep 17 00:00:00 2001 From: lanyuanxiaoyao Date: Mon, 18 May 2026 10:45:17 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20=E6=96=B0=E5=A2=9E=20ICMP/Ping=20checke?= =?UTF-8?q?r=EF=BC=8C=E6=94=AF=E6=8C=81=E8=B7=A8=E5=B9=B3=E5=8F=B0?= =?UTF-8?q?=E4=B8=BB=E6=9C=BA=E5=AD=98=E6=B4=BB=E6=A3=80=E6=B5=8B=E4=B8=8E?= =?UTF-8?q?=E5=BB=B6=E8=BF=9F=E7=9B=91=E6=8E=A7?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 实现 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。 --- DEVELOPMENT.md | 6 +- README.md | 36 +++- .../changes/add-icmp-checker/.openspec.yaml | 2 - openspec/changes/add-icmp-checker/design.md | 190 ------------------ openspec/changes/add-icmp-checker/proposal.md | 28 --- .../specs/checker-runner-abstraction/spec.md | 16 -- .../specs/probe-config/spec.md | 38 ---- openspec/changes/add-icmp-checker/tasks.md | 44 ---- .../specs/checker-runner-abstraction/spec.md | 6 +- .../specs/icmp-checker/spec.md | 0 openspec/specs/probe-config/spec.md | 24 ++- probe-config.schema.json | 106 ++++++++++ probes.example.yaml | 17 ++ src/server/checker/runner/icmp/command.ts | 18 ++ src/server/checker/runner/icmp/execute.ts | 182 +++++++++++++++++ src/server/checker/runner/icmp/expect.ts | 44 ++++ src/server/checker/runner/icmp/index.ts | 1 + src/server/checker/runner/icmp/parse.ts | 50 +++++ src/server/checker/runner/icmp/schema.ts | 25 +++ src/server/checker/runner/icmp/types.ts | 41 ++++ src/server/checker/runner/icmp/validate.ts | 118 +++++++++++ src/server/checker/runner/index.ts | 3 +- tests/server/checker/config-loader.test.ts | 96 +++++++++ .../checker/runner/icmp/command.test.ts | 54 +++++ .../checker/runner/icmp/execute.test.ts | 126 ++++++++++++ .../server/checker/runner/icmp/expect.test.ts | 44 ++++ .../server/checker/runner/icmp/parse.test.ts | 61 ++++++ .../checker/runner/icmp/validate.test.ts | 70 +++++++ tests/server/checker/runner/registry.test.ts | 10 +- tests/setup.ts | 6 +- 30 files changed, 1132 insertions(+), 330 deletions(-) delete mode 100644 openspec/changes/add-icmp-checker/.openspec.yaml delete mode 100644 openspec/changes/add-icmp-checker/design.md delete mode 100644 openspec/changes/add-icmp-checker/proposal.md delete mode 100644 openspec/changes/add-icmp-checker/specs/checker-runner-abstraction/spec.md delete mode 100644 openspec/changes/add-icmp-checker/specs/probe-config/spec.md delete mode 100644 openspec/changes/add-icmp-checker/tasks.md rename openspec/{changes/add-icmp-checker => }/specs/icmp-checker/spec.md (100%) create mode 100644 src/server/checker/runner/icmp/command.ts create mode 100644 src/server/checker/runner/icmp/execute.ts create mode 100644 src/server/checker/runner/icmp/expect.ts create mode 100644 src/server/checker/runner/icmp/index.ts create mode 100644 src/server/checker/runner/icmp/parse.ts create mode 100644 src/server/checker/runner/icmp/schema.ts create mode 100644 src/server/checker/runner/icmp/types.ts create mode 100644 src/server/checker/runner/icmp/validate.ts create mode 100644 tests/server/checker/runner/icmp/command.test.ts create mode 100644 tests/server/checker/runner/icmp/execute.test.ts create mode 100644 tests/server/checker/runner/icmp/expect.test.ts create mode 100644 tests/server/checker/runner/icmp/parse.test.ts create mode 100644 tests/server/checker/runner/icmp/validate.test.ts diff --git a/DEVELOPMENT.md b/DEVELOPMENT.md index a3d34c4..b33391d 100644 --- a/DEVELOPMENT.md +++ b/DEVELOPMENT.md @@ -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` 触发 abort;checker 必须使用 `CheckerContext.signal` 感知超时,HTTP 将 signal 传给 `fetch()`,Cmd 在 signal abort 时 `proc.kill()` +- **超时控制**:`ProbeEngine` 为每次检查创建 `AbortController` 并按 `target.timeoutMs` 触发 abort;checker 必须使用 `CheckerContext.signal` 感知超时,HTTP 将 signal 传给 `fetch()`,Cmd 和 Ping 在 signal abort 时 `proc.kill()` - **结果写入**:检查结果通过 `store.insertCheckResult()` 写入 SQLite,engine 基于配置 target id 确认目标仍存在 - **异常可观测**:`probeGroup()` 对 `Promise.allSettled` 的 rejected 结果通过索引关联 target,并写入 `phase:"internal"` 的失败记录 - **数据清理**:当 `retentionMs > 0` 时,engine 启动时立即执行一次 `store.prune()`,之后每小时定时执行,按 `timestamp` 清理过期数据 diff --git a/README.md b/README.md index 8e1f18b..3b8cd69 100644 --- a/README.md +++ b/README.md @@ -10,11 +10,11 @@ --- -DiAL 是一个自托管的拨测监控工具,支持 **HTTP**、**命令行**、**数据库** 和 **TCP** 四种拨测类型。通过 YAML 配置文件定义拨测目标,后端定时并发执行拨测并将结果持久化到本地 SQLite,前端 Dashboard 展示各目标的实时状态、可用率和耗时趋势。 +DiAL 是一个自托管的拨测监控工具,支持 **HTTP**、**命令行**、**数据库**、**TCP** 和 **Ping** 多种拨测类型。通过 YAML 配置文件定义拨测目标,后端定时并发执行拨测并将结果持久化到本地 SQLite,前端 Dashboard 展示各目标的实时状态、可用率和耗时趋势。 **功能亮点:** -- 多种拨测类型:HTTP(GET/POST/PUT 等)、Cmd(命令行执行)、DB(PostgreSQL/MySQL/SQLite)、TCP(端口可达性 + Banner 探测) +- 多种拨测类型:HTTP(GET/POST/PUT 等)、Cmd(命令行执行)、DB(PostgreSQL/MySQL/SQLite)、TCP(端口可达性 + Banner 探测)、Ping(ICMP 存活、延迟、丢包率) - 丰富的校验规则:状态码、响应头、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 校验项**(数组中可混合使用): diff --git a/openspec/changes/add-icmp-checker/.openspec.yaml b/openspec/changes/add-icmp-checker/.openspec.yaml deleted file mode 100644 index 66da1ae..0000000 --- a/openspec/changes/add-icmp-checker/.openspec.yaml +++ /dev/null @@ -1,2 +0,0 @@ -schema: spec-driven -created: 2026-05-17 diff --git a/openspec/changes/add-icmp-checker/design.md b/openspec/changes/add-icmp-checker/design.md deleted file mode 100644 index 600f097..0000000 --- a/openspec/changes/add-icmp-checker/design.md +++ /dev/null @@ -1,190 +0,0 @@ -## Context - -项目当前有 HTTP、CMD、DB、TCP 四种 checker,均遵循 `CheckerDefinition` 接口规范。TCP checker 是最近实现的网络层 checker,其模式(Bun.spawn / 原生 socket + AbortSignal + 断言链)是 ICMP checker 的直接参考。 - -ICMP Ping 是最基础的网络探测手段,但 Node.js/Bun 生态中没有合适的纯 JS ICMP 实现(均依赖 `raw-socket` 原生 addon,Bun 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:模块不是公开 API,deep import 不稳定 -- 引入 `ping`(danielzzz)的 parser:同上,且返回值类型全是 string - -**理由**: 我们只需要 summary 行的统计数据(transmitted/received/loss/min/avg/max),不需要逐包 body 解析。三套正则约 40-50 行代码,完全可控且零依赖。 - -### Decision 3: 跨平台命令构建策略 - -``` -平台判断: process.platform (win32 vs 其他) - -Linux: - ping -c -s -W - -macOS: - ping -c -s -W - -Windows: - ping -n -l -w -``` - -**超时参数传递策略:双重保障** - -传递平台对应的超时参数(`-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 code:Windows 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 输出格式极其稳定(几十年未变),维护成本极低。 diff --git a/openspec/changes/add-icmp-checker/proposal.md b/openspec/changes/add-icmp-checker/proposal.md deleted file mode 100644 index 858e73a..0000000 --- a/openspec/changes/add-icmp-checker/proposal.md +++ /dev/null @@ -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/Windows)ping 输出解析器,不引入三方库 -- 文档注明 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 命令的系统依赖 -- 无新增三方依赖 diff --git a/openspec/changes/add-icmp-checker/specs/checker-runner-abstraction/spec.md b/openspec/changes/add-icmp-checker/specs/checker-runner-abstraction/spec.md deleted file mode 100644 index c644ddb..0000000 --- a/openspec/changes/add-icmp-checker/specs/checker-runner-abstraction/spec.md +++ /dev/null @@ -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 中记录超时错误 diff --git a/openspec/changes/add-icmp-checker/specs/probe-config/spec.md b/openspec/changes/add-icmp-checker/specs/probe-config/spec.md deleted file mode 100644 index 90c84a6..0000000 --- a/openspec/changes/add-icmp-checker/specs/probe-config/spec.md +++ /dev/null @@ -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 语义 diff --git a/openspec/changes/add-icmp-checker/tasks.md b/openspec/changes/add-icmp-checker/tasks.md deleted file mode 100644 index 156e9a8..0000000 --- a/openspec/changes/add-icmp-checker/tasks.md +++ /dev/null @@ -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 schema(config、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 class(execute、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 生成脚本) diff --git a/openspec/specs/checker-runner-abstraction/spec.md b/openspec/specs/checker-runner-abstraction/spec.md index d3569c5..853cabb 100644 --- a/openspec/specs/checker-runner-abstraction/spec.md +++ b/openspec/specs/checker-runner-abstraction/spec.md @@ -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"`。 diff --git a/openspec/changes/add-icmp-checker/specs/icmp-checker/spec.md b/openspec/specs/icmp-checker/spec.md similarity index 100% rename from openspec/changes/add-icmp-checker/specs/icmp-checker/spec.md rename to openspec/specs/icmp-checker/spec.md diff --git a/openspec/specs/probe-config/spec.md b/openspec/specs/probe-config/spec.md index d4b99b8..278e8f2 100644 --- a/openspec/specs/probe-config/spec.md +++ b/openspec/specs/probe-config/spec.md @@ -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` 分钟),用于指定历史数据保留时长。 diff --git a/probe-config.schema.json b/probe-config.schema.json index f71e442..8fba557 100644 --- a/probe-config.schema.json +++ b/probe-config.schema.json @@ -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" + } + } + } + } } ] } diff --git a/probes.example.yaml b/probes.example.yaml index e351716..b96020a 100644 --- a/probes.example.yaml +++ b/probes.example.yaml @@ -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 diff --git a/src/server/checker/runner/icmp/command.ts b/src/server/checker/runner/icmp/command.ts new file mode 100644 index 0000000..b103191 --- /dev/null +++ b/src/server/checker/runner/icmp/command.ts @@ -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]; +} diff --git a/src/server/checker/runner/icmp/execute.ts b/src/server/checker/runner/icmp/execute.ts new file mode 100644 index 0000000..199797e --- /dev/null +++ b/src/server/checker/runner/icmp/execute.ts @@ -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 { + readonly configKey = "ping"; + + readonly schemas = icmpCheckerSchemas; + + readonly type = "ping"; + + async execute(t: ResolvedPingTarget, ctx: CheckerContext): Promise { + const timestamp = new Date().toISOString(); + const start = performance.now(); + let proc: ReturnType; + + 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); + 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): Promise { + 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)}…`; +} diff --git a/src/server/checker/runner/icmp/expect.ts b/src/server/checker/runner/icmp/expect.ts new file mode 100644 index 0000000..c5abcb7 --- /dev/null +++ b/src/server/checker/runner/icmp/expect.ts @@ -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, + }; +} diff --git a/src/server/checker/runner/icmp/index.ts b/src/server/checker/runner/icmp/index.ts new file mode 100644 index 0000000..9aa7578 --- /dev/null +++ b/src/server/checker/runner/icmp/index.ts @@ -0,0 +1 @@ +export { IcmpChecker } from "./execute"; diff --git a/src/server/checker/runner/icmp/parse.ts b/src/server/checker/runner/icmp/parse.ts new file mode 100644 index 0000000..d8fb2cd --- /dev/null +++ b/src/server/checker/runner/icmp/parse.ts @@ -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, + }; +} diff --git a/src/server/checker/runner/icmp/schema.ts b/src/server/checker/runner/icmp/schema.ts new file mode 100644 index 0000000..61245c0 --- /dev/null +++ b/src/server/checker/runner/icmp/schema.ts @@ -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 }, + ), +}; diff --git a/src/server/checker/runner/icmp/types.ts b/src/server/checker/runner/icmp/types.ts new file mode 100644 index 0000000..6e0bbb8 --- /dev/null +++ b/src/server/checker/runner/icmp/types.ts @@ -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"; +} diff --git a/src/server/checker/runner/icmp/validate.ts b/src/server/checker/runner/icmp/validate.ts new file mode 100644 index 0000000..05ae11d --- /dev/null +++ b/src/server/checker/runner/icmp/validate.ts @@ -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; + 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; + if (targetRecord["type"] !== "ping") continue; + issues.push(...validatePingTarget(targetRecord, `targets[${i}]`)); + } + + return issues; +} + +function getTargetName(target: Record): 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, path: string): ConfigValidationIssue[] { + const rawExpect = target["expect"]; + if (rawExpect === undefined || rawExpect === null || !isPlainObject(rawExpect)) return []; + const expect = rawExpect as Record; + 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, 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; + + 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; +} diff --git a/src/server/checker/runner/index.ts b/src/server/checker/runner/index.ts index c899167..56f6382 100644 --- a/src/server/checker/runner/index.ts +++ b/src/server/checker/runner/index.ts @@ -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(); diff --git a/tests/server/checker/config-loader.test.ts b/tests/server/checker/config-loader.test.ts index 0e24657..f1ab85f 100644 --- a/tests/server/checker/config-loader.test.ts +++ b/tests/server/checker/config-loader.test.ts @@ -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 是未知字段", + ); + }); }); diff --git a/tests/server/checker/runner/icmp/command.test.ts b/tests/server/checker/runner/icmp/command.test.ts new file mode 100644 index 0000000..97e11c1 --- /dev/null +++ b/tests/server/checker/runner/icmp/command.test.ts @@ -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 { + 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"]); + }); +}); diff --git a/tests/server/checker/runner/icmp/execute.test.ts b/tests/server/checker/runner/icmp/execute.test.ts new file mode 100644 index 0000000..86d558b --- /dev/null +++ b/tests/server/checker/runner/icmp/execute.test.ts @@ -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 { + 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 }); + }); +}); diff --git a/tests/server/checker/runner/icmp/expect.test.ts b/tests/server/checker/runner/icmp/expect.test.ts new file mode 100644 index 0000000..c5ff3a2 --- /dev/null +++ b/tests/server/checker/runner/icmp/expect.test.ts @@ -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); + }); +}); diff --git a/tests/server/checker/runner/icmp/parse.test.ts b/tests/server/checker/runner/icmp/parse.test.ts new file mode 100644 index 0000000..c96152c --- /dev/null +++ b/tests/server/checker/runner/icmp/parse.test.ts @@ -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(); + }); +}); diff --git a/tests/server/checker/runner/icmp/validate.test.ts b/tests/server/checker/runner/icmp/validate.test.ts new file mode 100644 index 0000000..6a84993 --- /dev/null +++ b/tests/server/checker/runner/icmp/validate.test.ts @@ -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); + }); +}); diff --git a/tests/server/checker/runner/registry.test.ts b/tests/server/checker/runner/registry.test.ts index 6171401..0e440b0 100644 --- a/tests/server/checker/runner/registry.test.ts +++ b/tests/server/checker/runner/registry.test.ts @@ -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"); + }); }); diff --git a/tests/setup.ts b/tests/setup.ts index fbb4735..a68cc7a 100644 --- a/tests/setup.ts +++ b/tests/setup.ts @@ -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 = ""; +});