1
0

refactor: ICMP checker type 从 ping 统一改为 icmp,修复前端 UI 细节

- ICMP checker 的 type/configKey/YAML 配置键/接口属性名从 ping 改为 icmp
- IcmpChecker 添加 platform 构造函数注入,修复 Windows 测试兼容性
- 前端 target 表格延迟列优化:标题简化为「延迟」,单位下移到单元格,宽度 80px
- Drawer 概览页 Descriptions 添加 tableLayout=auto 收窄 label 宽度
- 同步更新 README.md、DEVELOPMENT.md、probes.example.yaml、JSON Schema 和全部测试
This commit is contained in:
2026-05-20 00:02:23 +08:00
parent 375dd3492b
commit 9b53c746f6
23 changed files with 239 additions and 224 deletions

View File

@@ -63,7 +63,7 @@ src/
cmd/ Cmd Checker自包含模块含 types/schema/execute/expect/validate
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
icmp/ ICMP Checker自包含模块含 types/schema/execute/expect/validate/parse
udp/ UDP Checker自包含模块含 types/schema/execute/expect/validate/encoding
llm/ LLM Checker自包含模块含 types/schema/execute/expect/validate/provider/observation
shared/
@@ -486,7 +486,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 和 Ping 在 signal abort 时 `proc.kill()`
- **超时控制**`ProbeEngine` 为每次检查创建 `AbortController` 并按 `target.timeoutMs` 触发 abortchecker 必须使用 `CheckerContext.signal` 感知超时HTTP 将 signal 传给 `fetch()`Cmd 和 ICMP 在 signal abort 时 `proc.kill()`
- **结果写入**:检查结果通过 `store.insertCheckResult()` 写入 SQLiteengine 基于配置 target id 确认目标仍存在detail 为 API 层从 observation 派生,不进入存储层
- **异常可观测**`probeGroup()``Promise.allSettled` 的 rejected 结果通过索引关联 target并写入 `phase:"internal"` 的失败记录
- **数据清理**:当 `retentionMs > 0`engine 启动时立即执行一次 `store.prune()`,之后每小时定时执行,按 `timestamp` 清理过期数据
@@ -508,7 +508,7 @@ TcpChecker implements Checker
`ContentRules` 数组按顺序快速失败。数组项可以是直接 matcher也可以是 `{ json: {...} }``{ css: {...} }``{ xpath: {...} }` 提取器规则;一条规则不能混用直接 matcher 和 extractor多个 extractor 也不能共存。Extractor 未配置 matcher 时等价于 `exists: true`。对对象或数组源执行直接 `contains`/`regex` 时会先 JSON 序列化,`equals` 仍对原始结构做深度相等。
启动期语义校验统一由 `expect/validate-matcher.ts` 负责,会校验空 matcher、未知字段、字段类型、`exists:false` 组合、ContentRules 互斥性、JSONPath 子集、XPath 可编译性、regex 可编译性和 ReDoS 风险。旧字段 `match``maxDurationMs`Ping`max*` 阈值字段不再支持。
启动期语义校验统一由 `expect/validate-matcher.ts` 负责,会校验空 matcher、未知字段、字段类型、`exists:false` 组合、ContentRules 互斥性、JSONPath 子集、XPath 可编译性、regex 可编译性和 ReDoS 风险。旧字段 `match``maxDurationMs`ICMP`max*` 阈值字段不再支持。
**快速失败顺序**
@@ -519,7 +519,7 @@ TcpChecker implements Checker
| DB | `durationMs → rowCount → rows → result` |
| TCP | `connected → banner → durationMs` |
| UDP | `responded → responseSize → response → sourceHost → sourcePort → durationMs` |
| Ping | `alive → packetLossPercent → avgLatencyMs → maxLatencyMs → durationMs` |
| ICMP | `alive → packetLossPercent → avgLatencyMs → maxLatencyMs → durationMs` |
| LLM http | `status → headers → output → finishReason → rawFinishReason → usage → durationMs` |
| LLM stream | `status → headers → stream.completed → stream.firstTokenMs → output → finishReason → rawFinishReason → usage → durationMs` |
@@ -535,7 +535,7 @@ expect 字段
├─ 状态类结果,结果集合小且稳定
│ └─ enum / boolean
│ HTTP/LLM status、Cmd exitCode、TCP connected、
│ UDP responded、Ping alive
│ UDP responded、ICMP alive
├─ 数字指标 / 字符串元数据
│ └─ ValueMatcher

View File

@@ -10,13 +10,13 @@
---
DiAL 是一个自托管的拨测监控工具,支持 **HTTP**、**命令行**、**数据库**、**TCP**、**UDP**、**Ping** 和 **LLM** 多种拨测类型。通过 YAML 配置文件定义拨测目标,后端定时并发执行拨测并将结果持久化到本地 SQLite前端 Dashboard 展示各目标的实时状态、可用率和耗时趋势。
DiAL 是一个自托管的拨测监控工具,支持 **HTTP**、**命令行**、**数据库**、**TCP**、**UDP**、**ICMP** 和 **LLM** 多种拨测类型。通过 YAML 配置文件定义拨测目标,后端定时并发执行拨测并将结果持久化到本地 SQLite前端 Dashboard 展示各目标的实时状态、可用率和耗时趋势。
**功能亮点:**
- 多种拨测类型HTTPGET/POST/PUT 等、Cmd命令行执行、DBPostgreSQL/MySQL/SQLite、TCP端口可达性 + Banner 探测、UDP自定义 payload 请求-响应)、PingICMP 存活、延迟、丢包率、LLM大模型服务应用层健康检查
- 多种拨测类型HTTPGET/POST/PUT 等、Cmd命令行执行、DBPostgreSQL/MySQL/SQLite、TCP端口可达性 + Banner 探测、UDP自定义 payload 请求-响应、ICMP存活检测、延迟、丢包率、LLM大模型服务应用层健康检查
- 丰富的校验规则状态码、响应头、JSONPath、CSS 选择器、XPath、正则匹配、数值比较等
- 结构化观测数据:检查结果保留 HTTP body 预览、TCP/UDP 响应摘要、Ping 丢包率、CMD 输出预览、LLM token 用量等 observation便于排障和后续分析
- 结构化观测数据:检查结果保留 HTTP body 预览、TCP/UDP 响应摘要、ICMP 丢包率、CMD 输出预览、LLM token 用量等 observation便于排障和后续分析
- 响应式 Dashboard实时状态、可用率统计、耗时趋势图、手动/自动刷新
- 多主题支持:系统、明亮、黑暗三种主题模式
- 零外部依赖:数据存储使用 SQLite无需额外数据库服务
@@ -25,7 +25,7 @@ DiAL 是一个自托管的拨测监控工具,支持 **HTTP**、**命令行**
**前置条件:** [Bun](https://bun.sh/) >= 1.0
Ping checker 依赖系统 `ping` 命令。精简容器镜像需额外安装,例如 Alpine 可安装 `iputils-ping`
ICMP checker 依赖系统 `ping` 命令。精简容器镜像需额外安装,例如 Alpine 可安装 `iputils-ping`
```bash
# 克隆仓库
@@ -161,10 +161,10 @@ targets:
durationMs:
lte: 100
- id: "gateway-ping"
- id: "gateway-icmp"
name: "网关 ICMP 可达"
type: ping
ping:
type: icmp
icmp:
host: "10.0.0.1"
count: 3
packetSize: 56
@@ -227,7 +227,7 @@ targets:
| `id` | 目标唯一标识,最长 30 字符,支持字母数字、下划线、连字符,不参与变量替换 | 是 |
| `name` | 展示名称,最长 30 字符,支持变量替换,可省略或显式 null前端展示时 null 回退到 `id` | 否 |
| `description` | 目标描述,最长 500 字符,支持变量替换,可省略或显式 null允许空字符串 | 否 |
| `type` | 目标类型:`http``cmd``db``tcp``udp``ping``llm` | 是 |
| `type` | 目标类型:`http``cmd``db``tcp``udp``icmp``llm` | 是 |
| `group` | 分组名称 | 否,默认 `"default"` |
| `interval` | 覆盖全局拨测间隔 | 否 |
| `timeout` | 覆盖全局超时时间 | 否 |
@@ -269,15 +269,15 @@ targets:
| `tcp.bannerReadTimeout` | banner 读取超时(毫秒),默认 `2000` |
| `tcp.maxBannerBytes` | banner 最大字节数,支持 `KB`/`MB`/`GB` 单位,默认 `4KB` |
**Ping 类型** (`type: ping`)
**ICMP 类型** (`type: icmp`)
| 字段 | 说明 |
| ----------------- | ----------------------------------- |
| `ping.host` | 目标主机地址 |
| `ping.count` | ICMP 包数量,默认 `3`,范围 `1-100` |
| `ping.packetSize` | ICMP 包大小bytes默认 `56` |
| `icmp.host` | 目标主机地址 |
| `icmp.count` | ICMP 包数量,默认 `3`,范围 `1-100` |
| `icmp.packetSize` | ICMP 包大小bytes默认 `56` |
Ping checker 通过系统 `ping` 命令执行 ICMP 探测,支持 Linux、macOS 和 Windows 输出解析。
ICMP checker 通过系统 `ping` 命令执行 ICMP 探测,支持 Linux、macOS 和 Windows 输出解析。
**LLM 类型** (`type: llm`)
@@ -324,10 +324,10 @@ Ping checker 通过系统 `ping` 命令执行 ICMP 探测,支持 Linux、macOS
| `responseSize` | UDP | 响应字节数校验,使用 `ValueMatcher` |
| `sourceHost` | UDP | 响应来源地址校验,使用 `ValueMatcher` |
| `sourcePort` | UDP | 响应来源端口校验,使用 `ValueMatcher` |
| `alive` | Ping | 期望主机可达性,默认 `true` |
| `packetLossPercent` | Ping | 丢包率百分比校验,范围 `0-100`,使用 `ValueMatcher` |
| `avgLatencyMs` | Ping | 平均延迟校验,使用 `ValueMatcher` |
| `maxLatencyMs` | Ping | 最大单次延迟校验,使用 `ValueMatcher` |
| `alive` | ICMP | 期望主机可达性,默认 `true` |
| `packetLossPercent` | ICMP | 丢包率百分比校验,范围 `0-100`,使用 `ValueMatcher` |
| `avgLatencyMs` | ICMP | 平均延迟校验,使用 `ValueMatcher` |
| `maxLatencyMs` | ICMP | 最大单次延迟校验,使用 `ValueMatcher` |
**ContentRules 校验项**`body``stdout``stderr``banner``response``output``result` 均使用数组):
@@ -339,7 +339,7 @@ Ping checker 通过系统 `ping` 命令执行 ICMP 探测,支持 Linux、macOS
**ValueMatcher 字段**`equals``contains``regex``empty``exists``gte``lte``gt``lt``equals` 支持 JSON 深度相等;`regex` 固定使用无 flags 正则;提取器未配置 matcher 时等价于 `exists: true`。ValueMatcher expect 字段也可直接写 string、number、boolean 或 null等价于 `{ equals: value }`;数组和对象必须显式写成 `{ equals: ... }`
旧字段 `maxDurationMs``maxPacketLoss``maxAvgLatencyMs``maxMaxLatencyMs` 和旧正则字段 `match` 已移除,请分别改用 `durationMs`Ping matcher 字段和 `regex`
旧字段 `maxDurationMs``maxPacketLoss``maxAvgLatencyMs``maxMaxLatencyMs` 和旧正则字段 `match` 已移除,请分别改用 `durationMs`ICMP matcher 字段和 `regex`
**大小说明**`maxBodyBytes``maxOutputBytes` 支持 `KB``MB``GB` 单位,也可直接使用数字。

View File

@@ -1,84 +1,88 @@
## Purpose
定义 ICMP/Ping checker 的配置格式、命令执行、跨平台输出解析、expect 校验、失败结构和状态摘要。
定义 ICMP checker 的配置格式、命令执行、跨平台输出解析、expect 校验、失败结构和状态摘要。
## Requirements
### Requirement: ping target 配置
系统 SHALL 支持 `type: ping` 的 target 配置,通过 `ping.host` 描述目标主机地址,并通过可选字段控制探测行为。
### Requirement: icmp target 配置
系统 SHALL 支持 `type: icmp` 的 target 配置,通过 `icmp.host` 描述目标主机地址,并通过可选字段控制探测行为。
#### Scenario: 解析最简 ping target
- **WHEN** YAML 中 target 配置 `type: ping``ping.host: "10.0.0.1"`
- **THEN** 系统 SHALL 将其解析为 ping checker并填充 `count=3``packetSize=56`、interval、timeout、group 和 expect 配置
#### Scenario: 解析最简 icmp target
- **WHEN** YAML 中 target 配置 `type: icmp``icmp.host: "10.0.0.1"`
- **THEN** 系统 SHALL 将其解析为 icmp checker并填充 `count=3``packetSize=56`、interval、timeout、group 和 expect 配置
#### Scenario: ping target 缺少 host
- **WHEN** YAML 中 target 配置 `type: ping` 但缺少 `ping.host`
- **THEN** 系统 SHALL 以配置错误退出,并提示该 target 缺少 ping.host 字段
#### Scenario: icmp target 缺少 host
- **WHEN** YAML 中 target 配置 `type: icmp` 但缺少 `icmp.host`
- **THEN** 系统 SHALL 以配置错误退出,并提示该 target 缺少 icmp.host 字段
#### Scenario: ping host 类型非法
- **WHEN** YAML 中 ping target 的 `ping.host` 不是非空字符串
- **THEN** 系统 SHALL 以配置错误退出,提示 ping.host 必须为非空字符串
#### Scenario: icmp host 类型非法
- **WHEN** YAML 中 icmp target 的 `icmp.host` 不是非空字符串
- **THEN** 系统 SHALL 以配置错误退出,提示 icmp.host 必须为非空字符串
#### Scenario: ping count 配置
- **WHEN** YAML 中 ping target 配置 `ping.count: 5`
#### Scenario: icmp count 配置
- **WHEN** YAML 中 icmp target 配置 `icmp.count: 5`
- **THEN** 系统 SHALL 使用 5 作为 ICMP 包发送数量
#### Scenario: ping count 非法
- **WHEN** YAML 中 ping target 的 `ping.count` 不是 1 到 100 之间的正整数
- **THEN** 系统 SHALL 以配置错误退出,提示 ping.count 必须为 1-100 的正整数
#### Scenario: icmp count 非法
- **WHEN** YAML 中 icmp target 的 `icmp.count` 不是 1 到 100 之间的正整数
- **THEN** 系统 SHALL 以配置错误退出,提示 icmp.count 必须为 1-100 的正整数
#### Scenario: ping packetSize 配置
- **WHEN** YAML 中 ping target 配置 `ping.packetSize: 1472`
#### Scenario: icmp packetSize 配置
- **WHEN** YAML 中 icmp target 配置 `icmp.packetSize: 1472`
- **THEN** 系统 SHALL 使用 1472 作为 ICMP 包大小bytes
#### Scenario: ping packetSize 非法
- **WHEN** YAML 中 ping target 的 `ping.packetSize` 不是 1 到 65500 之间的正整数
- **THEN** 系统 SHALL 以配置错误退出,提示 ping.packetSize 必须为 1-65500 的正整数
#### Scenario: icmp packetSize 非法
- **WHEN** YAML 中 icmp target 的 `icmp.packetSize` 不是 1 到 65500 之间的正整数
- **THEN** 系统 SHALL 以配置错误退出,提示 icmp.packetSize 必须为 1-65500 的正整数
#### Scenario: ping 分组未知字段失败
- **WHEN** YAML 中 ping target 的 `ping` 分组包含 `timeout: 5` 等未知字段
- **THEN** 系统 SHALL 以配置错误退出,提示 ping 分组包含未知字段
#### Scenario: icmp 分组未知字段失败
- **WHEN** YAML 中 icmp target 的 `icmp` 分组包含 `timeout: 5` 等未知字段
- **THEN** 系统 SHALL 以配置错误退出,提示 icmp 分组包含未知字段
#### Scenario: ping 序列化展示摘要
- **WHEN** 系统同步 ping target 到 targets 表
- **THEN** `target` 展示摘要 SHALL 为 `ping <host>``config` JSON SHALL 包含 resolved 后的 host、count 和 packetSize
#### Scenario: icmp 序列化展示摘要
- **WHEN** 系统同步 icmp target 到 targets 表
- **THEN** `target` 展示摘要 SHALL 为 `icmp <host>``config` JSON SHALL 包含 resolved 后的 host、count 和 packetSize
### Requirement: ping checker 执行
系统 SHALL 通过调用系统 `ping` 命令执行 ICMP 探测,记录完整执行耗时,并在命令不可用、超时或解析失败时产生结构化失败信息。
### Requirement: icmp checker 执行
系统 SHALL 通过调用系统 `ping` 命令执行 ICMP 探测,记录完整执行耗时,并在命令不可用、超时或解析失败时产生结构化失败信息。`IcmpChecker` SHALL 通过构造函数参数支持 platform 注入,默认使用 `process.platform`
#### Scenario: ping 命令构建Linux
- **WHEN** 系统平台为 linuxping target 配置 host="10.0.0.1"、count=3、packetSize=56且外层 timeoutMs=10000
- **WHEN** 系统平台为 linuxicmp target 配置 host="10.0.0.1"、count=3、packetSize=56且外层 timeoutMs=10000
- **THEN** 系统 SHALL 执行 `ping -c 3 -s 56 -W 10 10.0.0.1`-W 单位为秒,向上取整)
#### Scenario: ping 命令构建macOS
- **WHEN** 系统平台为 darwinping target 配置 host="10.0.0.1"、count=3、packetSize=56且外层 timeoutMs=10000
- **WHEN** 系统平台为 darwinicmp target 配置 host="10.0.0.1"、count=3、packetSize=56且外层 timeoutMs=10000
- **THEN** 系统 SHALL 执行 `ping -c 3 -s 56 -W 10000 10.0.0.1`-W 单位为毫秒)
#### Scenario: ping 命令构建Windows
- **WHEN** 系统平台为 win32ping target 配置 host="10.0.0.1"、count=3、packetSize=56且外层 timeoutMs=10000
- **WHEN** 系统平台为 win32icmp target 配置 host="10.0.0.1"、count=3、packetSize=56且外层 timeoutMs=10000
- **THEN** 系统 SHALL 执行 `ping -n 3 -l 56 -w 10000 10.0.0.1`-w 单位为毫秒)
#### Scenario: ping 命令不存在
- **WHEN** 系统未安装 `ping` 命令spawn 抛出 ENOENT
- **THEN** 系统 SHALL 记录 `matched=false`failure 的 kind 为 `error`phase 为 `ping`path 为 `spawn`message 包含 "ping 命令不可用" 和原始错误信息
- **THEN** 系统 SHALL 记录 `matched=false`failure 的 kind 为 `error`phase 为 `icmp`path 为 `spawn`message 包含 "icmp 命令不可用" 和原始错误信息
#### Scenario: ping 执行超时
- **WHEN** 引擎注入的 `ctx.signal` 在 ping 命令执行过程中 abort
- **THEN** 系统 SHALL 调用 `proc.kill()` 终止子进程,记录 `matched=false`failure 的 kind 为 `error`phase 为 `ping`message 包含超时信息
- **THEN** 系统 SHALL 调用 `proc.kill()` 终止子进程,记录 `matched=false`failure 的 kind 为 `error`phase 为 `icmp`message 包含超时信息
#### Scenario: ping 目标可达
- **WHEN** ping target 指向可达主机,且 ping 命令正常返回
- **WHEN** icmp target 指向可达主机,且 ping 命令正常返回
- **THEN** 系统 SHALL 解析 stdout 获取统计数据,并按断言链执行 expect 校验
#### Scenario: ping 目标不可达
- **WHEN** ping target 指向不可达主机,且 ping 命令返回 100% packet loss
- **WHEN** icmp target 指向不可达主机,且 ping 命令返回 100% packet loss
- **THEN** 系统 SHALL 解析 stdout 获取统计数据,`alive` 为 false延迟字段为 null
#### Scenario: duration 覆盖完整执行
- **WHEN** ping 命令执行完成
- **THEN** 结果中的 `durationMs` SHALL 覆盖从 spawn 到进程退出的完整耗时
### Requirement: 跨平台 ping 输出解析
#### Scenario: platform 注入用于测试
- **WHEN** 构造 `new IcmpChecker("linux")`
- **THEN** execute 方法 SHALL 使用注入的 "linux" 作为平台参数,而非 `process.platform`
### Requirement: 跨平台 icmp 输出解析
系统 SHALL 实现跨平台 ping 输出解析器,支持 Linux、macOS 和 Windows含多语言 locale从 stdout 中提取 transmitted、received、packetLoss、minLatencyMs、avgLatencyMs、maxLatencyMs。
#### Scenario: 解析 Linux ping 输出
@@ -103,86 +107,86 @@
#### Scenario: 输出无法解析
- **WHEN** stdout 不匹配任何已知的统计行格式
- **THEN** 系统 SHALL 记录 `matched=false`failure 的 kind 为 `error`phase 为 `ping`path 为 `parse`message 包含 "无法解析 ping 输出"
- **THEN** 系统 SHALL 记录 `matched=false`failure 的 kind 为 `error`phase 为 `icmp`path 为 `parse`message 包含 "无法解析 icmp 输出"
### Requirement: ping expect 校验
系统 SHALL 支持 ping 专属 expect包括 `alive``packetLossPercent``avgLatencyMs``maxLatencyMs``durationMs`,并按 alive、packetLossPercent、avgLatencyMs、maxLatencyMs、durationMs 的阶段顺序快速失败。`alive` SHALL 保持布尔状态语义,未配置时默认 `true``packetLossPercent` SHALL 表示 0 到 100 的丢包率百分比,并使用共享 `ValueMatcher``avgLatencyMs``maxLatencyMs``durationMs` SHALL 使用共享 `ValueMatcher`
### Requirement: icmp expect 校验
系统 SHALL 支持 icmp 专属 expect包括 `alive``packetLossPercent``avgLatencyMs``maxLatencyMs``durationMs`,并按 alive、packetLossPercent、avgLatencyMs、maxLatencyMs、durationMs 的阶段顺序快速失败。`alive` SHALL 保持布尔状态语义,未配置时默认 `true``packetLossPercent` SHALL 表示 0 到 100 的丢包率百分比,并使用共享 `ValueMatcher``avgLatencyMs``maxLatencyMs``durationMs` SHALL 使用共享 `ValueMatcher`
#### Scenario: 默认 alive 成功语义
- **WHEN** ping target 未显式配置 `expect.alive`
- **WHEN** icmp target 未显式配置 `expect.alive`
- **THEN** 系统 SHALL 使用默认 `expect.alive: true` 进行校验
#### Scenario: alive 校验通过
- **WHEN** ping target 配置 `expect.alive: true`,且目标主机可达
- **WHEN** icmp target 配置 `expect.alive: true`,且目标主机可达
- **THEN** 系统 SHALL 判定 alive 阶段通过
#### Scenario: alive 校验失败
- **WHEN** ping target 配置 `expect.alive: true`,且目标主机不可达
- **WHEN** icmp target 配置 `expect.alive: true`,且目标主机不可达
- **THEN** 系统 SHALL 返回 `matched=false`failure 的 kind 为 `mismatch`phase 为 `alive`
#### Scenario: 反向 alive 断言
- **WHEN** ping target 配置 `expect.alive: false`,且目标主机不可达
- **WHEN** icmp target 配置 `expect.alive: false`,且目标主机不可达
- **THEN** 系统 SHALL 判定 alive 阶段通过(`matched=true`
#### Scenario: packetLossPercent 校验通过
- **WHEN** ping target 配置 `expect.packetLossPercent: {lte: 10}`,且实际丢包率为 0%
- **WHEN** icmp target 配置 `expect.packetLossPercent: {lte: 10}`,且实际丢包率为 0%
- **THEN** 系统 SHALL 判定 packetLossPercent 阶段通过
#### Scenario: packetLossPercent 校验失败
- **WHEN** ping target 配置 `expect.packetLossPercent: {lte: 10}`,且实际丢包率为 33%
- **WHEN** icmp target 配置 `expect.packetLossPercent: {lte: 10}`,且实际丢包率为 33%
- **THEN** 系统 SHALL 返回 `matched=false`failure 的 kind 为 `mismatch`phase 为 `packetLoss`
#### Scenario: avgLatencyMs 校验通过
- **WHEN** ping target 配置 `expect.avgLatencyMs: {lte: 200}`,且实际平均延迟为 12ms
- **WHEN** icmp target 配置 `expect.avgLatencyMs: {lte: 200}`,且实际平均延迟为 12ms
- **THEN** 系统 SHALL 判定 avgLatency 阶段通过
#### Scenario: avgLatencyMs 校验失败
- **WHEN** ping target 配置 `expect.avgLatencyMs: {lte: 100}`,且实际平均延迟为 156ms
- **WHEN** icmp target 配置 `expect.avgLatencyMs: {lte: 100}`,且实际平均延迟为 156ms
- **THEN** 系统 SHALL 返回 `matched=false`failure 的 kind 为 `mismatch`phase 为 `avgLatency`
#### Scenario: maxLatencyMs 校验通过
- **WHEN** ping target 配置 `expect.maxLatencyMs: {lte: 500}`,且实际最大延迟为 340ms
- **WHEN** icmp target 配置 `expect.maxLatencyMs: {lte: 500}`,且实际最大延迟为 340ms
- **THEN** 系统 SHALL 判定 maxLatency 阶段通过
#### Scenario: maxLatencyMs 校验失败
- **WHEN** ping target 配置 `expect.maxLatencyMs: {lte: 200}`,且实际最大延迟为 340ms
- **WHEN** icmp target 配置 `expect.maxLatencyMs: {lte: 200}`,且实际最大延迟为 340ms
- **THEN** 系统 SHALL 返回 `matched=false`failure 的 kind 为 `mismatch`phase 为 `maxLatency`
#### Scenario: durationMs 校验
- **WHEN** ping target 配置 `expect.durationMs: {lte: 5000}`,且完整执行耗时超过 5000ms
- **WHEN** icmp target 配置 `expect.durationMs: {lte: 5000}`,且完整执行耗时超过 5000ms
- **THEN** 系统 SHALL 返回 `matched=false`failure 的 phase 为 `duration`
#### Scenario: alive=false 时跳过延迟断言
- **WHEN** ping target 配置 `expect.alive: true``expect.avgLatencyMs: {lte: 100}`,且目标不可达
- **WHEN** icmp target 配置 `expect.alive: true``expect.avgLatencyMs: {lte: 100}`,且目标不可达
- **THEN** 系统 SHALL 在 alive 阶段即返回失败,不执行后续延迟断言
#### Scenario: ping expect 未知字段失败
- **WHEN** YAML 中 ping target 的 expect 包含 `status: [200]``maxPacketLoss``maxAvgLatencyMs``maxMaxLatencyMs``maxDurationMs` 或其他非 ping expect 字段
#### Scenario: icmp expect 未知字段失败
- **WHEN** YAML 中 icmp target 的 expect 包含 `status: [200]``maxPacketLoss``maxAvgLatencyMs``maxMaxLatencyMs``maxDurationMs` 或其他非 icmp expect 字段
- **THEN** 系统 SHALL 以配置错误退出,提示 expect 包含未知字段
#### Scenario: packetLossPercent 类型非法
- **WHEN** YAML 中 ping target 的 `expect.packetLossPercent` 不是合法 `ValueMatcher`,或其数值范围无法用于 0 到 100 的百分比断言
- **WHEN** YAML 中 icmp target 的 `expect.packetLossPercent` 不是合法 `ValueMatcher`,或其数值范围无法用于 0 到 100 的百分比断言
- **THEN** 系统 SHALL 以配置错误退出,提示 expect.packetLossPercent 格式错误
#### Scenario: avgLatencyMs 类型非法
- **WHEN** YAML 中 ping target 的 `expect.avgLatencyMs` 不是合法 `ValueMatcher`
- **WHEN** YAML 中 icmp target 的 `expect.avgLatencyMs` 不是合法 `ValueMatcher`
- **THEN** 系统 SHALL 以配置错误退出,提示 expect.avgLatencyMs 格式错误
#### Scenario: maxLatencyMs 类型非法
- **WHEN** YAML 中 ping target 的 `expect.maxLatencyMs` 不是合法 `ValueMatcher`
- **WHEN** YAML 中 icmp target 的 `expect.maxLatencyMs` 不是合法 `ValueMatcher`
- **THEN** 系统 SHALL 以配置错误退出,提示 expect.maxLatencyMs 格式错误
### Requirement: ping detail 摘要
系统 SHALL 在 ping API 序列化时从 observation 动态生成结构化 detail 摘要展示关键指标。API registry type SHALL 仍为 `ping`
### Requirement: icmp detail 摘要
系统 SHALL 在 icmp API 序列化时从 observation 动态生成结构化 detail 摘要展示关键指标。API registry type SHALL 仍为 `icmp`
#### Scenario: 目标可达无丢包
- **WHEN** ping observation 为 alive=true, avgLatencyMs=12, packetLoss=0%, transmitted=3, received=3
- **WHEN** icmp observation 为 alive=true, avgLatencyMs=12, packetLoss=0%, transmitted=3, received=3
- **THEN** detail SHALL 为 `alive, avg 12ms, loss 0% (3/3)`
#### Scenario: 目标可达有丢包
- **WHEN** ping observation 为 alive=true, avgLatencyMs=156, maxLatencyMs=340, packetLoss=33%, transmitted=3, received=2
- **WHEN** icmp observation 为 alive=true, avgLatencyMs=156, maxLatencyMs=340, packetLoss=33%, transmitted=3, received=2
- **THEN** detail SHALL 包含 avg、max 和 loss 信息
#### Scenario: 目标不可达
- **WHEN** ping observation 为 alive=false, transmitted=3, received=0
- **WHEN** icmp observation 为 alive=false, transmitted=3, received=0
- **THEN** detail SHALL 为 `unreachable (0/3 received)`

View File

@@ -5,9 +5,9 @@
## 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` 分组udp 领域字段 MUST 放在 `udp` 分组LLM 领域字段 MUST 放在 `llm` 分组。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字段。Udp target 的 `udp` 分组 SHALL 支持 `host`(必填)、`port`(必填)、`payload`(可选,默认空字符串)、`encoding`(可选,默认 `text`)、`responseEncoding`(可选,默认 `text`)和 `maxResponseBytes`(可选,默认 4096字段。LLM target 的 `llm` 分组 SHALL 支持 `provider`(必填)、`url`(必填)、`model`(必填)、`prompt`(必填)、`mode`(可选,默认 `http`)、`key`(可选,默认空字符串)、`authToken`(可选)、`headers`(可选)、`ignoreSSL`(可选,默认 `false`)、`options`(可选)和 `providerOptions`(可选)字段。
系统 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` 分组,icmp 领域字段 MUST 放在 `icmp` 分组udp 领域字段 MUST 放在 `udp` 分组LLM 领域字段 MUST 放在 `llm` 分组。HTTP target 的 `http` 分组 SHALL 支持可选的 `ignoreSSL`(布尔值)和 `maxRedirects`非负整数字段。Db target 的 `db` 分组 SHALL 支持 `url`(必填)和 `query`可选字段。Tcp target 的 `tcp` 分组 SHALL 支持 `host`(必填)、`port`(必填)、`readBanner`(可选)、`bannerReadTimeout`(可选)和 `maxBannerBytes`(可选)字段。Icmp target 的 `icmp` 分组 SHALL 支持 `host`(必填)、`count`(可选,默认 3`packetSize`(可选,默认 56字段。Udp target 的 `udp` 分组 SHALL 支持 `host`(必填)、`port`(必填)、`payload`(可选,默认空字符串)、`encoding`(可选,默认 `text`)、`responseEncoding`(可选,默认 `text`)和 `maxResponseBytes`(可选,默认 4096字段。LLM target 的 `llm` 分组 SHALL 支持 `provider`(必填)、`url`(必填)、`model`(必填)、`prompt`(必填)、`mode`(可选,默认 `http`)、`key`(可选,默认空字符串)、`authToken`(可选)、`headers`(可选)、`ignoreSSL`(可选,默认 `false`)、`options`(可选)和 `providerOptions`(可选)字段。
`defaults.http` 分组 SHALL 仅支持 `headers`(可选)和 `maxBodyBytes`(可选)字段。`defaults.http` 分组 MUST NOT 支持 `method` 字段。`defaults.tcp` 分组 SHALL 仅支持 `bannerReadTimeout`(可选)和 `maxBannerBytes`(可选)字段。`defaults.udp` 分组 SHALL 仅支持 `encoding`(可选)、`responseEncoding`(可选)和 `maxResponseBytes`(可选)字段。`defaults.llm` 分组 SHALL 仅支持 `mode`(可选)、`headers`(可选)、`ignoreSSL`(可选)、`options`(可选)和 `providerOptions`(可选)字段。
`defaults.http` 分组 SHALL 仅支持 `headers`(可选)和 `maxBodyBytes`(可选)字段。`defaults.http` 分组 MUST NOT 支持 `method` 字段。`defaults.tcp` 分组 SHALL 仅支持 `bannerReadTimeout`(可选)和 `maxBannerBytes`(可选)字段。`defaults.icmp` 分组 SHALL 仅支持空对象。`defaults.udp` 分组 SHALL 仅支持 `encoding`(可选)、`responseEncoding`(可选)和 `maxResponseBytes`(可选)字段。`defaults.llm` 分组 SHALL 仅支持 `mode`(可选)、`headers`(可选)、`ignoreSSL`(可选)、`options`(可选)和 `providerOptions`(可选)字段。
#### Scenario: 完整配置文件解析
- **WHEN** 系统启动并读取包含 server、runtime、variables、defaults、targets含 id、group 字段)的 YAML 配置文件
@@ -57,9 +57,9 @@
- **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
#### Scenario: 最简 icmp 配置文件解析
- **WHEN** 系统读取只包含一个 `type: icmp` target`id``icmp.host`)的 YAML 配置文件
- **THEN** 系统 SHALL 使用内置默认值填充未指定的字段interval=30s, timeout=10s, group="default", icmp.count=3, icmp.packetSize=56并保留 name=null、description=null
#### Scenario: 最简 udp 配置文件解析
- **WHEN** 系统读取只包含一个 `type: udp` target`id``udp.host``udp.port`)的 YAML 配置文件
@@ -219,9 +219,9 @@
- **WHEN** YAML 中某个 target 配置 ValueMatcher 字段值为对象 `{foo: "bar"}`,且 `foo` 不是合法 matcher 字段
- **THEN** 系统 SHALL 以错误退出,提示 `foo` 是未知 matcher如需对象 equals 匹配应写成 `{equals: {foo: "bar"}}`
#### Scenario: ping target 缺少 host
- **WHEN** YAML 中某个 target 配置 `type: ping` 但缺少 `ping.host`
- **THEN** 系统 SHALL 以错误退出,提示该 target 缺少 ping.host 字段
#### Scenario: icmp target 缺少 host
- **WHEN** YAML 中某个 target 配置 `type: icmp` 但缺少 `icmp.host`
- **THEN** 系统 SHALL 以错误退出,提示该 target 缺少 icmp.host 字段
#### Scenario: ping expect 未知字段
- **WHEN** YAML 中 ping target 的 expect 包含非 ping expect 字段

View File

@@ -215,7 +215,7 @@ Drawer 内部 SHALL 使用 TDesign Tabs 组织概览和记录两个面板。Tabs
#### Scenario: 基本信息直接展示
- **WHEN** 概览面板渲染
- **THEN** 面板 SHALL 在"基本信息"区域直接使用 TDesign Descriptions 组件展示配置信息(不折叠)
- **THEN** 面板 SHALL 在"基本信息"区域直接使用 TDesign Descriptions 组件展示配置信息(不折叠)Descriptions SHALL 配置 `tableLayout="auto"` 使 label 宽度自适应内容
#### Scenario: 基本信息内容
- **WHEN** 概览面板渲染

View File

@@ -72,7 +72,7 @@ Dashboard SHALL 按 group 字段将目标分组,每个分组使用 TDesign Car
#### Scenario: 延迟列
- **WHEN** 表格渲染
- **THEN** 延迟列标题 SHALL 展示为"延迟(ms)",单元格 SHALL 显示最近一次检查的延迟毫秒数值并右对齐,颜色通过 CSS 类控制;超过 9999ms 时 SHALL 显示为"9999+"
- **THEN** 延迟列标题 SHALL 展示为"延迟",宽度 SHALL 为 80px,单元格 SHALL 显示最近一次检查的延迟数值并附加 " ms" 后缀(如 "156 ms"右对齐,颜色通过 CSS 类控制;超过 9999ms 时 SHALL 显示为"9999+ ms"
#### Scenario: 间隔列移除
- **WHEN** 表格渲染

View File

@@ -52,3 +52,7 @@ probes.example.yaml 中的 cmd 类型示例 SHALL 使用跨平台命令(如 `b
#### Scenario: 示例命令跨平台可执行
- **WHEN** 用户在 Windows、macOS 或 Linux 上直接使用 probes.example.yaml 中的 cmd 示例
- **THEN** 所有 cmd 示例 SHALL 能正常执行,不依赖平台特定命令
#### Scenario: ICMP checker 测试使用 platform 注入
- **WHEN** 在 Windows 上运行 ICMP checker 测试mock 的 stdout 为 Unix 格式
- **THEN** 测试 SHALL 通过 `new IcmpChecker("linux")` 构造 checker 实例,使 parsePingOutput 使用 Unix 解析器,确保测试在所有平台上通过

View File

@@ -84,7 +84,7 @@
}
}
},
"ping": {
"icmp": {
"additionalProperties": false,
"type": "object",
"properties": {}
@@ -2479,7 +2479,7 @@
"required": [
"id",
"type",
"ping"
"icmp"
],
"properties": {
"description": {
@@ -2829,10 +2829,10 @@
"type": "string"
},
"type": {
"const": "ping",
"const": "icmp",
"type": "string"
},
"ping": {
"icmp": {
"additionalProperties": false,
"type": "object",
"required": [

View File

@@ -178,13 +178,13 @@ targets:
banner:
- contains: "ESMTP"
# ========== Ping targets ==========
# ========== ICMP targets ==========
- id: "gateway-ping"
- id: "gateway-icmp"
name: "网关 ICMP 可达"
type: ping
type: icmp
group: "基础设施"
ping:
icmp:
host: "127.0.0.1"
count: 3
packetSize: 56

View File

@@ -5,14 +5,14 @@ export function buildPingCommand(t: ResolvedPingTarget, platform: NodeJS.Platfor
return [
"ping",
"-n",
String(t.ping.count),
String(t.icmp.count),
"-l",
String(t.ping.packetSize),
String(t.icmp.packetSize),
"-w",
String(t.timeoutMs),
t.ping.host,
t.icmp.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];
return ["ping", "-c", String(t.icmp.count), "-s", String(t.icmp.packetSize), "-W", timeout, t.icmp.host];
}

View File

@@ -16,11 +16,17 @@ const DEFAULT_COUNT = 3;
const DEFAULT_PACKET_SIZE = 56;
export class IcmpChecker implements CheckerDefinition<ResolvedPingTarget> {
readonly configKey = "ping";
readonly configKey = "icmp";
readonly platform: NodeJS.Platform;
readonly schemas = icmpCheckerSchemas;
readonly type = "ping";
readonly type = "icmp";
constructor(platform: NodeJS.Platform = process.platform) {
this.platform = platform;
}
buildDetail(observation: Record<string, unknown>): null | string {
const alive = observation["alive"];
@@ -67,7 +73,7 @@ export class IcmpChecker implements CheckerDefinition<ResolvedPingTarget> {
return {
detail: null,
durationMs,
failure: errorFailure("ping", "spawn", `ping 命令不可用: ${isError(error) ? error.message : String(error)}`),
failure: errorFailure("icmp", "spawn", `icmp 命令不可用: ${isError(error) ? error.message : String(error)}`),
matched: false,
observation: null,
targetId: t.id,
@@ -95,7 +101,7 @@ export class IcmpChecker implements CheckerDefinition<ResolvedPingTarget> {
return {
detail: null,
durationMs,
failure: errorFailure("ping", "timeout", `ping 执行超时 (${t.timeoutMs}ms)`),
failure: errorFailure("icmp", "timeout", `icmp 执行超时 (${t.timeoutMs}ms)`),
matched: false,
observation: null,
targetId: t.id,
@@ -103,12 +109,12 @@ export class IcmpChecker implements CheckerDefinition<ResolvedPingTarget> {
};
}
const stats = parsePingOutput(stdout, process.platform);
const stats = parsePingOutput(stdout, this.platform);
if (!stats) {
return {
detail: null,
durationMs,
failure: errorFailure("ping", "parse", "无法解析 ping 输出"),
failure: errorFailure("icmp", "parse", "无法解析 icmp 输出"),
matched: false,
observation: {
alive: false,
@@ -148,28 +154,28 @@ export class IcmpChecker implements CheckerDefinition<ResolvedPingTarget> {
}
resolve(target: RawTargetConfig, context: ResolveContext): ResolvedPingTarget {
const t = target as RawTargetConfig & { ping: PingTargetConfig; type: "ping" };
const t = target as RawTargetConfig & { icmp: PingTargetConfig; type: "icmp" };
return {
description: null,
expect: target.expect as PingExpectConfig | undefined,
group: target.group ?? "default",
icmp: {
count: t.icmp.count ?? DEFAULT_COUNT,
host: t.icmp.host,
packetSize: t.icmp.packetSize ?? DEFAULT_PACKET_SIZE,
},
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",
type: "icmp",
} satisfies ResolvedPingTarget;
}
serialize(t: ResolvedPingTarget): { config: string; target: string } {
return {
config: JSON.stringify(t.ping),
target: `ping ${t.ping.host}`,
config: JSON.stringify(t.icmp),
target: `icmp ${t.icmp.host}`,
};
}

View File

@@ -11,7 +11,7 @@ export function checkAlive(actual: boolean, expected: boolean): ExpectResult {
"alive",
expected,
actual,
expected ? "期望主机可达但 ping 不可达" : "期望主机不可达但 ping 可达",
expected ? "期望主机可达但 icmp 不可达" : "期望主机不可达但 icmp 可达",
),
matched: false,
};

View File

@@ -34,9 +34,9 @@ export interface ResolvedPingConfig {
export interface ResolvedPingTarget extends ResolvedTargetBase {
expect?: PingExpectConfig;
group: string;
icmp: ResolvedPingConfig;
intervalMs: number;
name: null | string;
ping: ResolvedPingConfig;
timeoutMs: number;
type: "ping";
type: "icmp";
}

View File

@@ -10,15 +10,15 @@ import { issue, joinPath } from "../../schema/issues";
export function validatePingConfig(input: CheckerValidationInput): ConfigValidationIssue[] {
const issues: ConfigValidationIssue[] = [];
const defaults = input.defaults["ping"];
const defaults = input.defaults["icmp"];
if (defaults !== undefined && defaults !== null) {
const targetName = "defaults.ping";
const targetName = "defaults.icmp";
if (!isPlainObject(defaults)) {
issues.push(issue("invalid-type", "defaults.ping", "必须为对象", targetName));
issues.push(issue("invalid-type", "defaults.icmp", "必须为对象", 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));
const icmpDefaults = defaults as Record<string, unknown>;
for (const key of Object.keys(icmpDefaults)) {
issues.push(issue("unknown-field", joinPath("defaults.icmp", key), "是未知字段", targetName));
}
}
}
@@ -27,7 +27,7 @@ export function validatePingConfig(input: CheckerValidationInput): ConfigValidat
const target = input.targets[i] as unknown;
if (!isPlainObject(target)) continue;
const targetRecord = target as Record<string, unknown>;
if (targetRecord["type"] !== "ping") continue;
if (targetRecord["type"] !== "icmp") continue;
issues.push(...validatePingTarget(targetRecord, `targets[${i}]`));
}
@@ -71,39 +71,39 @@ function validatePingExpect(target: Record<string, unknown>, path: string): Conf
function validatePingTarget(target: Record<string, unknown>, path: string): ConfigValidationIssue[] {
const issues: ConfigValidationIssue[] = [];
const targetName = getTargetName(target);
const rawPing = target["ping"];
const rawIcmp = target["icmp"];
if (!isPlainObject(rawPing)) {
issues.push(issue("required", joinPath(path, "ping"), "缺少 ping 配置分组", targetName));
if (!isPlainObject(rawIcmp)) {
issues.push(issue("required", joinPath(path, "icmp"), "缺少 icmp 配置分组", targetName));
issues.push(...validatePingExpect(target, path));
return issues;
}
const ping = rawPing as Record<string, unknown>;
const icmp = rawIcmp as Record<string, unknown>;
if (!isString(ping["host"]) || ping["host"].trim() === "") {
issues.push(issue("required", joinPath(joinPath(path, "ping"), "host"), "缺少 ping.host 字段", targetName));
if (!isString(icmp["host"]) || icmp["host"].trim() === "") {
issues.push(issue("required", joinPath(joinPath(path, "icmp"), "host"), "缺少 icmp.host 字段", targetName));
}
if (ping["count"] !== undefined) {
const count = ping["count"];
if (icmp["count"] !== undefined) {
const count = icmp["count"];
if (!isNumber(count) || !Number.isInteger(count) || count < 1 || count > 100) {
issues.push(
issue("invalid-value", joinPath(joinPath(path, "ping"), "count"), "必须为 1-100 的正整数", targetName),
issue("invalid-value", joinPath(joinPath(path, "icmp"), "count"), "必须为 1-100 的正整数", targetName),
);
}
}
if (ping["packetSize"] !== undefined) {
const packetSize = ping["packetSize"];
if (icmp["packetSize"] !== undefined) {
const packetSize = icmp["packetSize"];
if (!isNumber(packetSize) || !Number.isInteger(packetSize) || packetSize < 1 || packetSize > 65500) {
issues.push(
issue("invalid-value", joinPath(joinPath(path, "ping"), "packetSize"), "必须为 1-65500 的正整数", targetName),
issue("invalid-value", joinPath(joinPath(path, "icmp"), "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));
const allowedIcmpKeys = new Set(["count", "host", "packetSize"]);
for (const key of Object.keys(icmp)) {
if (!allowedIcmpKeys.has(key)) {
issues.push(issue("unknown-field", joinPath(joinPath(path, "icmp"), key), "是未知字段", targetName));
}
}

View File

@@ -40,6 +40,7 @@ export function OverviewTab({ metricsData, metricsLoading, target }: OverviewTab
{ content: target.latestCheck?.detail ?? "-", label: "状态详情" },
{ content: target.description ?? "", label: "描述", span: 2 },
]}
tableLayout="auto"
/>
<Divider align="left"></Divider>

View File

@@ -89,13 +89,13 @@ export function createTargetTableColumns(checkerTypes: string[]): Array<PrimaryT
if (ms === null || ms === undefined) return <span className="text-disabled">-</span>;
const colorClass = ms <= 100 ? "latency-ok" : ms <= 500 ? "latency-warn" : "latency-error";
const latencyText = ms > 9999 ? "9999+" : `${Math.round(ms)}`;
return <span className={`${colorClass} latency-value tabular-nums`}>{latencyText}</span>;
return <span className={`${colorClass} latency-value tabular-nums`}>{latencyText} ms</span>;
},
colKey: "latestCheck.durationMs",
sorter: latencySorter,
sortType: "all",
title: "延迟(ms)",
width: 75,
title: "延迟",
width: 80,
},
];
}

View File

@@ -2009,14 +2009,14 @@ targets:
expect(t.expect?.durationMs).toEqual({ lte: 5000 });
});
test("解析最简 ping 配置", async () => {
const configPath = join(tempDir, "minimal-ping.yaml");
test("解析最简 icmp 配置", async () => {
const configPath = join(tempDir, "minimal-icmp.yaml");
await writeFile(
configPath,
`targets:
- id: "gateway"
type: ping
ping:
type: icmp
icmp:
host: "10.0.0.1"
`,
);
@@ -2024,21 +2024,21 @@ targets:
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.type).toBe("icmp");
expect(t.icmp).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");
test("解析 icmp expect 配置", async () => {
const configPath = join(tempDir, "icmp-expect.yaml");
await writeFile(
configPath,
`targets:
- id: "gateway"
type: ping
ping:
type: icmp
icmp:
host: "10.0.0.1"
count: 5
packetSize: 1472
@@ -2057,7 +2057,7 @@ targets:
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.icmp).toEqual({ count: 5, host: "10.0.0.1", packetSize: 1472 });
expect(t.expect).toEqual({
alive: true,
avgLatencyMs: { lte: 200 },
@@ -2067,39 +2067,39 @@ targets:
});
});
test("ping 缺少 host 抛出错误", async () => {
test("icmp 缺少 host 抛出错误", async () => {
await expectConfigError(
"ping-no-host.yaml",
"icmp-no-host.yaml",
`targets:
- id: "gateway"
type: ping
ping: {}
type: icmp
icmp: {}
`,
"ping.host",
"icmp.host",
);
});
test("ping count 非法抛出错误", async () => {
test("icmp count 非法抛出错误", async () => {
await expectConfigError(
"ping-bad-count.yaml",
"icmp-bad-count.yaml",
`targets:
- id: "gateway"
type: ping
ping:
type: icmp
icmp:
host: "10.0.0.1"
count: 0
`,
"ping.count",
"icmp.count",
);
});
test("ping expect 未知字段抛出错误", async () => {
test("icmp expect 未知字段抛出错误", async () => {
await expectConfigError(
"ping-unknown-expect.yaml",
"icmp-unknown-expect.yaml",
`targets:
- id: "gateway"
type: ping
ping:
type: icmp
icmp:
host: "10.0.0.1"
expect:
status: [200]

View File

@@ -8,12 +8,12 @@ function makeTarget(overrides?: Partial<ResolvedPingTarget>): ResolvedPingTarget
return {
description: null,
group: "default",
icmp: { count: 3, host: "10.0.0.1", packetSize: 56 },
id: "test",
intervalMs: 30000,
name: null,
ping: { count: 3, host: "10.0.0.1", packetSize: 56 },
timeoutMs: 10000,
type: "ping",
type: "icmp",
...overrides,
};
}
@@ -46,7 +46,7 @@ describe("buildPingCommand", () => {
test("自定义 count 和 packetSize", () => {
const cmd = buildPingCommand(
makeTarget({ ping: { count: 5, host: "10.0.0.1", packetSize: 1472 }, timeoutMs: 5000 }),
makeTarget({ icmp: { 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

@@ -5,7 +5,7 @@ import type { CheckerContext } from "../../../../../src/server/checker/runner/ty
import { IcmpChecker } from "../../../../../src/server/checker/runner/icmp/execute";
const checker = new IcmpChecker();
const checker = new IcmpChecker("linux");
const originalSpawn = Bun.spawn;
afterEach(() => {
@@ -21,12 +21,12 @@ function makeTarget(overrides?: Partial<ResolvedPingTarget>): ResolvedPingTarget
return {
description: null,
group: "default",
icmp: { count: 3, host: "127.0.0.1", packetSize: 56 },
id: "ping-local",
intervalMs: 30000,
name: null,
ping: { count: 3, host: "127.0.0.1", packetSize: 56 },
timeoutMs: 10000,
type: "ping",
type: "icmp",
...overrides,
};
}
@@ -94,16 +94,16 @@ rtt min/avg/max/mdev = 1.234/156.000/340.000/0.567 ms`);
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" });
expect(result.failure).toMatchObject({ kind: "error", path: "parse", phase: "icmp" });
});
test("spawn 失败返回 ping 命令不可用", async () => {
test("spawn 失败返回 icmp 命令不可用", 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.failure?.message).toContain("icmp 命令不可用");
expect(result.observation).toBeNull();
});
@@ -113,23 +113,23 @@ rtt min/avg/max/mdev = 1.234/156.000/340.000/0.567 ms`);
controller.abort();
const result = await checker.execute(makeTarget(), { signal: controller.signal });
expect(result.matched).toBe(false);
expect(result.failure).toMatchObject({ path: "timeout", phase: "ping" });
expect(result.failure).toMatchObject({ path: "timeout", phase: "icmp" });
});
});
describe("IcmpChecker resolve", () => {
test("解析默认值", () => {
const target = checker.resolve(
{ id: "ping", ping: { host: "10.0.0.1" }, type: "ping" },
{ icmp: { host: "10.0.0.1" }, id: "ping", type: "icmp" },
{ configDir: "/tmp", defaultIntervalMs: 30000, defaults: {}, defaultTimeoutMs: 10000 },
);
expect(target.ping).toEqual({ count: 3, host: "10.0.0.1", packetSize: 56 });
expect(target.icmp).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");
const serialized = checker.serialize(makeTarget({ icmp: { count: 5, host: "10.0.0.1", packetSize: 1472 } }));
expect(serialized.target).toBe("icmp 10.0.0.1");
expect(JSON.parse(serialized.config)).toEqual({ count: 5, host: "10.0.0.1", packetSize: 1472 });
});
});

View File

@@ -10,61 +10,61 @@ function validate(target: RawTargetConfig) {
describe("validatePingConfig", () => {
test("有效配置无错误", () => {
expect(validate({ id: "ping", ping: { count: 3, host: "127.0.0.1", packetSize: 56 }, type: "ping" })).toEqual([]);
expect(validate({ icmp: { count: 3, host: "127.0.0.1", packetSize: 56 }, id: "icmp", type: "icmp" })).toEqual([]);
});
test("host 缺失", () => {
const issues = validate({ id: "ping", ping: {}, type: "ping" });
expect(issues.some((item) => item.path.endsWith("ping.host"))).toBe(true);
const issues = validate({ icmp: {}, id: "icmp", type: "icmp" });
expect(issues.some((item) => item.path.endsWith("icmp.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);
const issues = validate({ icmp: { host: 123 }, id: "icmp", type: "icmp" });
expect(issues.some((item) => item.path.endsWith("icmp.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);
const issues = validate({ icmp: { count: 0, host: "127.0.0.1" }, id: "icmp", type: "icmp" });
expect(issues.some((item) => item.path.endsWith("icmp.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);
const issues = validate({ icmp: { host: "127.0.0.1", packetSize: 65501 }, id: "icmp", type: "icmp" });
expect(issues.some((item) => item.path.endsWith("icmp.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("icmp 未知字段", () => {
const issues = validate({ icmp: { host: "127.0.0.1", timeout: 5 }, id: "icmp", type: "icmp" });
expect(issues.some((item) => item.code === "unknown-field" && item.path.endsWith("icmp.timeout"))).toBe(true);
});
test("expect 未知字段", () => {
const issues = validate({ expect: { status: [200] }, id: "ping", ping: { host: "127.0.0.1" }, type: "ping" });
const issues = validate({ expect: { status: [200] }, icmp: { host: "127.0.0.1" }, id: "icmp", type: "icmp" });
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" });
const issues = validate({ expect: { maxPacketLoss: 101 }, icmp: { host: "127.0.0.1" }, id: "icmp", type: "icmp" });
expect(issues.some((item) => item.path.endsWith("expect.maxPacketLoss"))).toBe(true);
});
test("durationMs 数组简写非法", () => {
const issues = validate({ expect: { durationMs: [1, 2] }, id: "ping", ping: { host: "127.0.0.1" }, type: "ping" });
const issues = validate({ expect: { durationMs: [1, 2] }, icmp: { host: "127.0.0.1" }, id: "icmp", type: "icmp" });
expect(issues.some((item) => item.path.endsWith("expect.durationMs"))).toBe(true);
});
test("avgLatencyMs 对象简写非法", () => {
const issues = validate({
expect: { avgLatencyMs: { foo: "bar" } },
id: "ping",
ping: { host: "127.0.0.1" },
type: "ping",
icmp: { host: "127.0.0.1" },
id: "icmp",
type: "icmp",
});
expect(issues.some((item) => item.path.endsWith("expect.avgLatencyMs.foo"))).toBe(true);
});
test("host 为空字符串", () => {
const issues = validate({ id: "ping", ping: { host: " " }, type: "ping" });
expect(issues.some((item) => item.path.endsWith("ping.host"))).toBe(true);
const issues = validate({ icmp: { host: " " }, id: "icmp", type: "icmp" });
expect(issues.some((item) => item.path.endsWith("icmp.host"))).toBe(true);
});
});

View File

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

View File

@@ -40,9 +40,9 @@ describe("ValueMatcher primitive shorthand in checker validators", () => {
},
{
expect: { avgLatencyMs: 1, durationMs: 100, maxLatencyMs: 2, packetLossPercent: 0 },
id: "ping",
ping: { host: "127.0.0.1" },
type: "ping",
icmp: { host: "127.0.0.1" },
id: "icmp",
type: "icmp",
validate: validatePingConfig,
},
{

View File

@@ -121,16 +121,16 @@ describe("createTargetTableColumns", () => {
rowIndex: 0,
});
expect(element.props.children).toBe("9999+");
expect(element.props.children).toEqual(["9999+", " ms"]);
expect(element.props.className).toContain("latency-value");
});
test("延迟列标题为 延迟(ms)", () => {
test("延迟列标题为 延迟", () => {
const latencyColumn = getColumn(createTargetTableColumns(["http"]), "latestCheck.durationMs");
expect(latencyColumn.title).toBe("延迟(ms)");
expect(latencyColumn.title).toBe("延迟");
});
test("延迟列正常值包含 ms 单位", () => {
test("延迟列正常值包含 ms 单位", () => {
const latencyColumn = getColumn(createTargetTableColumns(["http"]), "latestCheck.durationMs");
const renderCell = latencyColumn.cell as (params: PrimaryTableCellParams<TargetStatus>) => {
props: { children: string; className: string };
@@ -150,7 +150,7 @@ describe("createTargetTableColumns", () => {
}),
rowIndex: 0,
});
expect(element.props.children).toBe("123");
expect(element.props.children).toEqual(["123", " ms"]);
});
test("名称列无排序配置", () => {