From 9b53c746f6caf476f826eca6b502bffd6a098ea8 Mon Sep 17 00:00:00 2001 From: lanyuanxiaoyao Date: Wed, 20 May 2026 00:02:23 +0800 Subject: [PATCH] =?UTF-8?q?refactor:=20ICMP=20checker=20type=20=E4=BB=8E?= =?UTF-8?q?=20ping=20=E7=BB=9F=E4=B8=80=E6=94=B9=E4=B8=BA=20icmp=EF=BC=8C?= =?UTF-8?q?=E4=BF=AE=E5=A4=8D=E5=89=8D=E7=AB=AF=20UI=20=E7=BB=86=E8=8A=82?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 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 和全部测试 --- DEVELOPMENT.md | 10 +- README.md | 36 ++--- openspec/specs/icmp-checker/spec.md | 130 +++++++++--------- openspec/specs/probe-config/spec.md | 16 +-- openspec/specs/target-detail-drawer/spec.md | 2 +- openspec/specs/target-table/spec.md | 2 +- openspec/specs/windows-test-compat/spec.md | 4 + probe-config.schema.json | 8 +- probes.example.yaml | 8 +- src/server/checker/runner/icmp/command.ts | 8 +- src/server/checker/runner/icmp/execute.ts | 36 +++-- src/server/checker/runner/icmp/expect.ts | 2 +- src/server/checker/runner/icmp/types.ts | 4 +- src/server/checker/runner/icmp/validate.ts | 46 +++---- src/web/components/OverviewTab.tsx | 1 + src/web/constants/target-table-columns.tsx | 6 +- tests/server/checker/config-loader.test.ts | 50 +++---- .../checker/runner/icmp/command.test.ts | 6 +- .../checker/runner/icmp/execute.test.ts | 22 +-- .../checker/runner/icmp/validate.test.ts | 40 +++--- tests/server/checker/runner/registry.test.ts | 10 +- .../shared/value-matcher-shorthand.test.ts | 6 +- .../constants/target-table-columns.test.ts | 10 +- 23 files changed, 239 insertions(+), 224 deletions(-) diff --git a/DEVELOPMENT.md b/DEVELOPMENT.md index 25eace4..87d0119 100644 --- a/DEVELOPMENT.md +++ b/DEVELOPMENT.md @@ -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` 触发 abort;checker 必须使用 `CheckerContext.signal` 感知超时,HTTP 将 signal 传给 `fetch()`,Cmd 和 Ping 在 signal abort 时 `proc.kill()` +- **超时控制**:`ProbeEngine` 为每次检查创建 `AbortController` 并按 `target.timeoutMs` 触发 abort;checker 必须使用 `CheckerContext.signal` 感知超时,HTTP 将 signal 传给 `fetch()`,Cmd 和 ICMP 在 signal abort 时 `proc.kill()` - **结果写入**:检查结果通过 `store.insertCheckResult()` 写入 SQLite,engine 基于配置 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 diff --git a/README.md b/README.md index 5d925bb..e75e3cc 100644 --- a/README.md +++ b/README.md @@ -10,13 +10,13 @@ --- -DiAL 是一个自托管的拨测监控工具,支持 **HTTP**、**命令行**、**数据库**、**TCP**、**UDP**、**Ping** 和 **LLM** 多种拨测类型。通过 YAML 配置文件定义拨测目标,后端定时并发执行拨测并将结果持久化到本地 SQLite,前端 Dashboard 展示各目标的实时状态、可用率和耗时趋势。 +DiAL 是一个自托管的拨测监控工具,支持 **HTTP**、**命令行**、**数据库**、**TCP**、**UDP**、**ICMP** 和 **LLM** 多种拨测类型。通过 YAML 配置文件定义拨测目标,后端定时并发执行拨测并将结果持久化到本地 SQLite,前端 Dashboard 展示各目标的实时状态、可用率和耗时趋势。 **功能亮点:** -- 多种拨测类型:HTTP(GET/POST/PUT 等)、Cmd(命令行执行)、DB(PostgreSQL/MySQL/SQLite)、TCP(端口可达性 + Banner 探测)、UDP(自定义 payload 请求-响应)、Ping(ICMP 存活、延迟、丢包率)、LLM(大模型服务应用层健康检查) +- 多种拨测类型:HTTP(GET/POST/PUT 等)、Cmd(命令行执行)、DB(PostgreSQL/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` 单位,也可直接使用数字。 diff --git a/openspec/specs/icmp-checker/spec.md b/openspec/specs/icmp-checker/spec.md index d66b562..38cecd9 100644 --- a/openspec/specs/icmp-checker/spec.md +++ b/openspec/specs/icmp-checker/spec.md @@ -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 `,`config` JSON SHALL 包含 resolved 后的 host、count 和 packetSize +#### Scenario: icmp 序列化展示摘要 +- **WHEN** 系统同步 icmp target 到 targets 表 +- **THEN** `target` 展示摘要 SHALL 为 `icmp `,`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** 系统平台为 linux,ping target 配置 host="10.0.0.1"、count=3、packetSize=56,且外层 timeoutMs=10000 +- **WHEN** 系统平台为 linux,icmp 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** 系统平台为 darwin,ping target 配置 host="10.0.0.1"、count=3、packetSize=56,且外层 timeoutMs=10000 +- **WHEN** 系统平台为 darwin,icmp 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** 系统平台为 win32,ping target 配置 host="10.0.0.1"、count=3、packetSize=56,且外层 timeoutMs=10000 +- **WHEN** 系统平台为 win32,icmp 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)` diff --git a/openspec/specs/probe-config/spec.md b/openspec/specs/probe-config/spec.md index dacee2f..a18a059 100644 --- a/openspec/specs/probe-config/spec.md +++ b/openspec/specs/probe-config/spec.md @@ -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 字段 diff --git a/openspec/specs/target-detail-drawer/spec.md b/openspec/specs/target-detail-drawer/spec.md index 2f55bb8..16d2d92 100644 --- a/openspec/specs/target-detail-drawer/spec.md +++ b/openspec/specs/target-detail-drawer/spec.md @@ -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** 概览面板渲染 diff --git a/openspec/specs/target-table/spec.md b/openspec/specs/target-table/spec.md index 22451ec..6257617 100644 --- a/openspec/specs/target-table/spec.md +++ b/openspec/specs/target-table/spec.md @@ -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** 表格渲染 diff --git a/openspec/specs/windows-test-compat/spec.md b/openspec/specs/windows-test-compat/spec.md index 54ea0a1..ebdd5c1 100644 --- a/openspec/specs/windows-test-compat/spec.md +++ b/openspec/specs/windows-test-compat/spec.md @@ -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 解析器,确保测试在所有平台上通过 diff --git a/probe-config.schema.json b/probe-config.schema.json index 6ccab4d..649c7ce 100644 --- a/probe-config.schema.json +++ b/probe-config.schema.json @@ -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": [ diff --git a/probes.example.yaml b/probes.example.yaml index c3ca2fe..0fd1006 100644 --- a/probes.example.yaml +++ b/probes.example.yaml @@ -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 diff --git a/src/server/checker/runner/icmp/command.ts b/src/server/checker/runner/icmp/command.ts index b103191..5e38111 100644 --- a/src/server/checker/runner/icmp/command.ts +++ b/src/server/checker/runner/icmp/command.ts @@ -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]; } diff --git a/src/server/checker/runner/icmp/execute.ts b/src/server/checker/runner/icmp/execute.ts index cd63b42..c07e6e2 100644 --- a/src/server/checker/runner/icmp/execute.ts +++ b/src/server/checker/runner/icmp/execute.ts @@ -16,11 +16,17 @@ const DEFAULT_COUNT = 3; const DEFAULT_PACKET_SIZE = 56; export class IcmpChecker implements CheckerDefinition { - 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): null | string { const alive = observation["alive"]; @@ -67,7 +73,7 @@ export class IcmpChecker implements CheckerDefinition { 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 { 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 { }; } - 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 { } 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}`, }; } diff --git a/src/server/checker/runner/icmp/expect.ts b/src/server/checker/runner/icmp/expect.ts index 39547c2..1f83a40 100644 --- a/src/server/checker/runner/icmp/expect.ts +++ b/src/server/checker/runner/icmp/expect.ts @@ -11,7 +11,7 @@ export function checkAlive(actual: boolean, expected: boolean): ExpectResult { "alive", expected, actual, - expected ? "期望主机可达但 ping 不可达" : "期望主机不可达但 ping 可达", + expected ? "期望主机可达但 icmp 不可达" : "期望主机不可达但 icmp 可达", ), matched: false, }; diff --git a/src/server/checker/runner/icmp/types.ts b/src/server/checker/runner/icmp/types.ts index 5c4a870..7e634a4 100644 --- a/src/server/checker/runner/icmp/types.ts +++ b/src/server/checker/runner/icmp/types.ts @@ -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"; } diff --git a/src/server/checker/runner/icmp/validate.ts b/src/server/checker/runner/icmp/validate.ts index 4452aea..b249231 100644 --- a/src/server/checker/runner/icmp/validate.ts +++ b/src/server/checker/runner/icmp/validate.ts @@ -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; - for (const key of Object.keys(pingDefaults)) { - issues.push(issue("unknown-field", joinPath("defaults.ping", key), "是未知字段", targetName)); + const icmpDefaults = defaults as Record; + 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; - if (targetRecord["type"] !== "ping") continue; + if (targetRecord["type"] !== "icmp") continue; issues.push(...validatePingTarget(targetRecord, `targets[${i}]`)); } @@ -71,39 +71,39 @@ function validatePingExpect(target: Record, path: string): Conf function validatePingTarget(target: Record, 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; + const icmp = rawIcmp as Record; - 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)); } } diff --git a/src/web/components/OverviewTab.tsx b/src/web/components/OverviewTab.tsx index 0c4fa9c..6e1cffe 100644 --- a/src/web/components/OverviewTab.tsx +++ b/src/web/components/OverviewTab.tsx @@ -40,6 +40,7 @@ export function OverviewTab({ metricsData, metricsLoading, target }: OverviewTab { content: target.latestCheck?.detail ?? "-", label: "状态详情" }, { content: target.description ?? "", label: "描述", span: 2 }, ]} + tableLayout="auto" /> 统计 diff --git a/src/web/constants/target-table-columns.tsx b/src/web/constants/target-table-columns.tsx index 7a1c144..3a1b3db 100644 --- a/src/web/constants/target-table-columns.tsx +++ b/src/web/constants/target-table-columns.tsx @@ -89,13 +89,13 @@ export function createTargetTableColumns(checkerTypes: string[]): Array-; const colorClass = ms <= 100 ? "latency-ok" : ms <= 500 ? "latency-warn" : "latency-error"; const latencyText = ms > 9999 ? "9999+" : `${Math.round(ms)}`; - return {latencyText}; + return {latencyText} ms; }, colKey: "latestCheck.durationMs", sorter: latencySorter, sortType: "all", - title: "延迟(ms)", - width: 75, + title: "延迟", + width: 80, }, ]; } diff --git a/tests/server/checker/config-loader.test.ts b/tests/server/checker/config-loader.test.ts index bfdbfb8..5602046 100644 --- a/tests/server/checker/config-loader.test.ts +++ b/tests/server/checker/config-loader.test.ts @@ -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] diff --git a/tests/server/checker/runner/icmp/command.test.ts b/tests/server/checker/runner/icmp/command.test.ts index 97e11c1..8300de1 100644 --- a/tests/server/checker/runner/icmp/command.test.ts +++ b/tests/server/checker/runner/icmp/command.test.ts @@ -8,12 +8,12 @@ function makeTarget(overrides?: Partial): 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"]); diff --git a/tests/server/checker/runner/icmp/execute.test.ts b/tests/server/checker/runner/icmp/execute.test.ts index 26521e1..02bf67d 100644 --- a/tests/server/checker/runner/icmp/execute.test.ts +++ b/tests/server/checker/runner/icmp/execute.test.ts @@ -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 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 }); }); }); diff --git a/tests/server/checker/runner/icmp/validate.test.ts b/tests/server/checker/runner/icmp/validate.test.ts index c136f8a..98c5bcb 100644 --- a/tests/server/checker/runner/icmp/validate.test.ts +++ b/tests/server/checker/runner/icmp/validate.test.ts @@ -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); }); }); diff --git a/tests/server/checker/runner/registry.test.ts b/tests/server/checker/runner/registry.test.ts index 3e1c8cb..83c4e0e 100644 --- a/tests/server/checker/runner/registry.test.ts +++ b/tests/server/checker/runner/registry.test.ts @@ -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"); }); }); diff --git a/tests/server/checker/runner/shared/value-matcher-shorthand.test.ts b/tests/server/checker/runner/shared/value-matcher-shorthand.test.ts index 5988897..ce120f4 100644 --- a/tests/server/checker/runner/shared/value-matcher-shorthand.test.ts +++ b/tests/server/checker/runner/shared/value-matcher-shorthand.test.ts @@ -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, }, { diff --git a/tests/web/constants/target-table-columns.test.ts b/tests/web/constants/target-table-columns.test.ts index 385369a..98616ee 100644 --- a/tests/web/constants/target-table-columns.test.ts +++ b/tests/web/constants/target-table-columns.test.ts @@ -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) => { 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("名称列无排序配置", () => {