1
0

Compare commits

...

4 Commits

Author SHA1 Message Date
393e8da5fd feat: 新增 ICMP/Ping checker 设计提案
- 定义 ping target 配置:host、count、packetSize
- 定义 ping expect 断言:alive、maxPacketLoss、maxAvgLatencyMs、maxMaxLatencyMs
- 设计跨平台 ping 输出解析器(Linux/macOS/Windows 含多语言支持)
- 双重超时保障:ping 命令自身超时 + AbortSignal 兜底
- 扩展 checker-runner-abstraction spec 支持 ping checker 子进程控制
- 更新 probe-config spec 支持 ping type 配置
2026-05-18 00:33:11 +08:00
0a9a9016be feat: 新增 TCP checker,支持端口可达性探测与 banner 读取
- 新增 src/server/checker/runner/tcp/ 自包含目录(types/schema/validate/execute/expect)
- 注册 TcpChecker 到 checkerRegistry,schema/engine/store/config-loader 自动委托
- 支持 expect.connected 正反向语义(默认期待可达,可配置期待不可达)
- 支持 readBanner opt-in banner 读取,受 bannerReadTimeout + maxBannerBytes 双重限制
- 复用电有 expect/operator/duration/failure 基础设施
- 新增 3 个测试文件 51 条用例(execute/validate/expect),全量 634 测试通过
- 更新 README/DEVELOPMENT/probes.example.yaml,新增 tcp-checker capability spec
2026-05-17 23:53:37 +08:00
31fd3a2a43 refactor: 统一 target name/description 可空语义,前端展示 fallback 到 id
- schema: name/description 允许省略或显式 null,TypeBox Union([Null, String])
- 类型: RawTargetConfig/ResolvedTargetBase/子类型/StoredTarget/TargetStatus name 改为 string | null
- checker resolve: name: t.name ?? null,不再 fallback 到 id
- 语义校验: 拒绝空字符串和纯空白 name
- SQLite: targets.name 列改为可空 TEXT
- 前端: 新增 getTargetDisplayName(target) 展示 name ?? id
- 测试: 覆盖 name/description null 全场景,查找改为按 id
- 文档: 更新 README/DEVELOPMENT 和 6 个 openspec specs
2026-05-17 20:12:39 +08:00
f7193e98ff feat: 新增 target description 字段,收紧 id/name 长度,调整延迟列和名称列 2026-05-17 18:42:46 +08:00
61 changed files with 3077 additions and 144 deletions

View File

@@ -59,6 +59,8 @@ src/
index.ts 注册入口(显式数组 + 循环注册) index.ts 注册入口(显式数组 + 循环注册)
http/ HTTP Checker自包含模块含 types/schema/execute/expect/validate/body http/ HTTP Checker自包含模块含 types/schema/execute/expect/validate/body
cmd/ Cmd Checker自包含模块含 types/schema/execute/expect/validate/text cmd/ Cmd Checker自包含模块含 types/schema/execute/expect/validate/text
db/ DB Checker自包含模块含 types/schema/execute/expect/validate
tcp/ TCP Checker自包含模块含 types/schema/execute/expect/validate
shared/ shared/
api.ts 前后端共享 TypeScript 类型 api.ts 前后端共享 TypeScript 类型
web/ React 前端 Dashboard通过 Bun HTML import 集成) web/ React 前端 Dashboard通过 Bun HTML import 集成)
@@ -463,7 +465,7 @@ TcpChecker implements Checker
**Schema** **Schema**
- `targets`idTEXT PRIMARY KEY配置 target id、name展示名称、type、target展示摘要、configJSON、interval_ms、timeout_ms、expectJSON、grp - `targets`idTEXT PRIMARY KEY配置 target id、nameTEXT可 NULL展示名称、descriptionTEXT可 NULL描述、type、target展示摘要、configJSON、interval_ms、timeout_ms、expectJSON、grp
- `check_results`target_idTEXT FK CASCADE引用配置 target id、timestamp、matched0/1、duration_ms、status_detail、failureJSON - `check_results`target_idTEXT FK CASCADE引用配置 target id、timestamp、matched0/1、duration_ms、status_detail、failureJSON
- 复合索引:`(target_id, timestamp)` - 复合索引:`(target_id, timestamp)`

View File

@@ -10,11 +10,11 @@
--- ---
DiAL 是一个自托管的拨测监控工具,支持 **HTTP**、**命令行** 和 **数据库** 种拨测类型。通过 YAML 配置文件定义拨测目标,后端定时并发执行拨测并将结果持久化到本地 SQLite前端 Dashboard 展示各目标的实时状态、可用率和耗时趋势。 DiAL 是一个自托管的拨测监控工具,支持 **HTTP**、**命令行**、**数据库****TCP** 种拨测类型。通过 YAML 配置文件定义拨测目标,后端定时并发执行拨测并将结果持久化到本地 SQLite前端 Dashboard 展示各目标的实时状态、可用率和耗时趋势。
**功能亮点:** **功能亮点:**
- 多种拨测类型HTTPGET/POST/PUT 等、Cmd命令行执行、DBPostgreSQL/MySQL/SQLite - 多种拨测类型HTTPGET/POST/PUT 等、Cmd命令行执行、DBPostgreSQL/MySQL/SQLite、TCP端口可达性 + Banner 探测)
- 丰富的校验规则状态码、响应头、JSONPath、CSS 选择器、XPath、正则匹配、数值比较等 - 丰富的校验规则状态码、响应头、JSONPath、CSS 选择器、XPath、正则匹配、数值比较等
- 响应式 Dashboard实时状态、可用率统计、耗时趋势图、手动/自动刷新 - 响应式 Dashboard实时状态、可用率统计、耗时趋势图、手动/自动刷新
- 多主题支持:系统、明亮、黑暗三种主题模式 - 多主题支持:系统、明亮、黑暗三种主题模式
@@ -132,6 +132,15 @@ targets:
rowCount: { gte: 1 } rowCount: { gte: 1 }
rows: rows:
- cnt: { gte: 0 } - cnt: { gte: 0 }
- id: "redis-port"
name: "Redis 端口可达"
type: tcp
tcp:
host: "127.0.0.1"
port: 6379
expect:
maxDurationMs: 3000
``` ```
### 配置说明 ### 配置说明
@@ -176,14 +185,15 @@ targets:
每个 target 的通用字段: 每个 target 的通用字段:
| 字段 | 说明 | 必填 | | 字段 | 说明 | 必填 |
| ---------- | ---------------------------------------------------------- | -------------------- | | ------------- | ------------------------------------------------------------------------------------ | -------------------- |
| `id` | 目标唯一标识,支持字母数字、下划线、连字符,不参与变量替换 | 是 | | `id` | 目标唯一标识,最长 30 字符,支持字母数字、下划线、连字符,不参与变量替换 | 是 |
| `name` | 展示名称,支持变量替换;省略时使用 `id` | 否 | | `name` | 展示名称,最长 30 字符,支持变量替换,可省略或显式 null前端展示时 null 回退到 `id` | 否 |
| `type` | 目标类型:`http``cmd``db` | | | `description` | 目标描述,最长 500 字符,支持变量替换,可省略或显式 null允许空字符串 | |
| `group` | 分组名称 | 否,默认 `"default"` | | `type` | 目标类型:`http``cmd``db``tcp` | |
| `interval` | 覆盖全局拨测间隔 | 否 | | `group` | 分组名称 | 否,默认 `"default"` |
| `timeout` | 覆盖全局超时时间 | 否 | | `interval` | 覆盖全局拨测间隔 | 否 |
| `timeout` | 覆盖全局超时时间 | 否 |
**HTTP 类型** (`type: http`) **HTTP 类型** (`type: http`)
@@ -212,6 +222,16 @@ targets:
| `db.url` | 数据库连接字符串,支持 `postgres://``mysql://``sqlite://` | | `db.url` | 数据库连接字符串,支持 `postgres://``mysql://``sqlite://` |
| `db.query` | SQL 查询语句(不配置时仅测试连接) | | `db.query` | SQL 查询语句(不配置时仅测试连接) |
**TCP 类型** (`type: tcp`)
| 字段 | 说明 |
| ----------------------- | ------------------------------------------------------- |
| `tcp.host` | 目标主机地址 |
| `tcp.port` | 目标端口1-65535 |
| `tcp.readBanner` | 是否读取服务端 banner默认 `false` |
| `tcp.bannerReadTimeout` | banner 读取超时(毫秒),默认 `2000` |
| `tcp.maxBannerBytes` | banner 最大字节数,支持 `KB`/`MB`/`GB` 单位,默认 `4KB` |
#### expect — 期望校验 #### expect — 期望校验
| 字段 | 适用类型 | 说明 | | 字段 | 适用类型 | 说明 |
@@ -224,6 +244,8 @@ targets:
| `stdout` / `stderr` | Cmd | 输出校验(数组,每项一个操作符对象) | | `stdout` / `stderr` | Cmd | 输出校验(数组,每项一个操作符对象) |
| `rowCount` | DB | 查询返回行数校验(操作符对象) | | `rowCount` | DB | 查询返回行数校验(操作符对象) |
| `rows` | DB | 查询结果逐行校验(数组,列名→操作符映射) | | `rows` | DB | 查询结果逐行校验(数组,列名→操作符映射) |
| `connected` | TCP | 期望连接结果,`true`(默认)可达或 `false` 期望不可达 |
| `banner` | TCP | Banner 文本校验(操作符对象,需开启 `tcp.readBanner` |
**body 校验项**(数组中可混合使用): **body 校验项**(数组中可混合使用):

View File

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

View File

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

View File

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

View File

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

View File

@@ -0,0 +1,192 @@
## Purpose
定义 ICMP/Ping checker 的配置格式、命令执行、跨平台输出解析、expect 校验、失败结构和状态摘要。
## Requirements
### Requirement: ping target 配置
系统 SHALL 支持 `type: ping` 的 target 配置,通过 `ping.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: ping target 缺少 host
- **WHEN** YAML 中 target 配置 `type: ping` 但缺少 `ping.host`
- **THEN** 系统 SHALL 以配置错误退出,并提示该 target 缺少 ping.host 字段
#### Scenario: ping host 类型非法
- **WHEN** YAML 中 ping target 的 `ping.host` 不是非空字符串
- **THEN** 系统 SHALL 以配置错误退出,提示 ping.host 必须为非空字符串
#### Scenario: ping count 配置
- **WHEN** YAML 中 ping target 配置 `ping.count: 5`
- **THEN** 系统 SHALL 使用 5 作为 ICMP 包发送数量
#### Scenario: ping count 非法
- **WHEN** YAML 中 ping target 的 `ping.count` 不是 1 到 100 之间的正整数
- **THEN** 系统 SHALL 以配置错误退出,提示 ping.count 必须为 1-100 的正整数
#### Scenario: ping packetSize 配置
- **WHEN** YAML 中 ping target 配置 `ping.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: ping 分组未知字段失败
- **WHEN** YAML 中 ping target 的 `ping` 分组包含 `timeout: 5` 等未知字段
- **THEN** 系统 SHALL 以配置错误退出,提示 ping 分组包含未知字段
#### Scenario: ping 序列化展示摘要
- **WHEN** 系统同步 ping target 到 targets 表
- **THEN** `target` 展示摘要 SHALL 为 `ping <host>``config` JSON SHALL 包含 resolved 后的 host、count 和 packetSize
### Requirement: ping checker 执行
系统 SHALL 通过调用系统 `ping` 命令执行 ICMP 探测,记录完整执行耗时,并在命令不可用、超时或解析失败时产生结构化失败信息。
#### Scenario: ping 命令构建Linux
- **WHEN** 系统平台为 linuxping 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
- **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
- **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 命令不可用" 和原始错误信息
#### Scenario: ping 执行超时
- **WHEN** 引擎注入的 `ctx.signal` 在 ping 命令执行过程中 abort
- **THEN** 系统 SHALL 调用 `proc.kill()` 终止子进程,记录 `matched=false`failure 的 kind 为 `error`phase 为 `ping`message 包含超时信息
#### Scenario: ping 目标可达
- **WHEN** ping target 指向可达主机,且 ping 命令正常返回
- **THEN** 系统 SHALL 解析 stdout 获取统计数据,并按断言链执行 expect 校验
#### Scenario: ping 目标不可达
- **WHEN** ping target 指向不可达主机,且 ping 命令返回 100% packet loss
- **THEN** 系统 SHALL 解析 stdout 获取统计数据,`alive` 为 false延迟字段为 null
#### Scenario: duration 覆盖完整执行
- **WHEN** ping 命令执行完成
- **THEN** 结果中的 `durationMs` SHALL 覆盖从 spawn 到进程退出的完整耗时
### Requirement: 跨平台 ping 输出解析
系统 SHALL 实现跨平台 ping 输出解析器,支持 Linux、macOS 和 Windows含多语言 locale从 stdout 中提取 transmitted、received、packetLoss、minLatencyMs、avgLatencyMs、maxLatencyMs。
#### Scenario: 解析 Linux ping 输出
- **WHEN** 平台为 linuxstdout 包含 "3 packets transmitted, 3 received, 0% packet loss" 和 "rtt min/avg/max/mdev = 1.234/2.345/3.456/0.567 ms"
- **THEN** 系统 SHALL 解析为 transmitted=3, received=3, packetLoss=0, minLatencyMs=1.234, avgLatencyMs=2.345, maxLatencyMs=3.456
#### Scenario: 解析 macOS ping 输出
- **WHEN** 平台为 darwinstdout 包含 "3 packets transmitted, 3 packets received, 0.0% packet loss" 和 "round-trip min/avg/max/stddev = 1.234/2.345/3.456/0.567 ms"
- **THEN** 系统 SHALL 解析为 transmitted=3, received=3, packetLoss=0, minLatencyMs=1.234, avgLatencyMs=2.345, maxLatencyMs=3.456
#### Scenario: 解析 Windows 英文 ping 输出
- **WHEN** 平台为 win32stdout 包含 "Packets: Sent = 3, Received = 3, Lost = 0 (0% loss)" 和 "Minimum = 1ms, Maximum = 3ms, Average = 2ms"
- **THEN** 系统 SHALL 解析为 transmitted=3, received=3, packetLoss=0, minLatencyMs=1, avgLatencyMs=2, maxLatencyMs=3
#### Scenario: 解析 Windows 中文 ping 输出
- **WHEN** 平台为 win32stdout 包含 "数据包: 已发送 = 3已接收 = 3丢失 = 0 (0% 丢失)" 和 "最短 = 1ms最长 = 3ms平均 = 2ms"
- **THEN** 系统 SHALL 解析为 transmitted=3, received=3, packetLoss=0, minLatencyMs=1, avgLatencyMs=2, maxLatencyMs=3
#### Scenario: 解析全部丢包(无延迟行)
- **WHEN** stdout 包含丢包统计行但无延迟统计行100% packet loss
- **THEN** 系统 SHALL 解析为 alive=false延迟字段min/avg/max均为 null
#### Scenario: 输出无法解析
- **WHEN** stdout 不匹配任何已知的统计行格式
- **THEN** 系统 SHALL 记录 `matched=false`failure 的 kind 为 `error`phase 为 `ping`path 为 `parse`message 包含 "无法解析 ping 输出"
### Requirement: ping expect 校验
系统 SHALL 支持 ping 专属 expect包括 `alive``maxPacketLoss``maxAvgLatencyMs``maxMaxLatencyMs``maxDurationMs`,并按 alive、packetLoss、avgLatency、maxLatency、duration 的阶段顺序快速失败。
#### Scenario: 默认 alive 成功语义
- **WHEN** ping target 未显式配置 `expect.alive`
- **THEN** 系统 SHALL 使用默认 `expect.alive: true` 进行校验
#### Scenario: alive 校验通过
- **WHEN** ping target 配置 `expect.alive: true`,且目标主机可达
- **THEN** 系统 SHALL 判定 alive 阶段通过
#### Scenario: alive 校验失败
- **WHEN** ping target 配置 `expect.alive: true`,且目标主机不可达
- **THEN** 系统 SHALL 返回 `matched=false`failure 的 kind 为 `mismatch`phase 为 `alive`
#### Scenario: 反向 alive 断言
- **WHEN** ping target 配置 `expect.alive: false`,且目标主机不可达
- **THEN** 系统 SHALL 判定 alive 阶段通过(`matched=true`
#### Scenario: 反向 alive 断言失败
- **WHEN** ping target 配置 `expect.alive: false`,但目标主机可达
- **THEN** 系统 SHALL 返回 `matched=false`failure 的 kind 为 `mismatch`phase 为 `alive`
#### Scenario: maxPacketLoss 校验通过
- **WHEN** ping target 配置 `expect.maxPacketLoss: 10`,且实际丢包率为 0%
- **THEN** 系统 SHALL 判定 packetLoss 阶段通过
#### Scenario: maxPacketLoss 校验失败
- **WHEN** ping target 配置 `expect.maxPacketLoss: 10`,且实际丢包率为 33%
- **THEN** 系统 SHALL 返回 `matched=false`failure 的 kind 为 `mismatch`phase 为 `packetLoss`
#### Scenario: maxAvgLatencyMs 校验通过
- **WHEN** ping target 配置 `expect.maxAvgLatencyMs: 200`,且实际平均延迟为 12ms
- **THEN** 系统 SHALL 判定 avgLatency 阶段通过
#### Scenario: maxAvgLatencyMs 校验失败
- **WHEN** ping target 配置 `expect.maxAvgLatencyMs: 100`,且实际平均延迟为 156ms
- **THEN** 系统 SHALL 返回 `matched=false`failure 的 kind 为 `mismatch`phase 为 `avgLatency`
#### Scenario: maxMaxLatencyMs 校验通过
- **WHEN** ping target 配置 `expect.maxMaxLatencyMs: 500`,且实际最大延迟为 340ms
- **THEN** 系统 SHALL 判定 maxLatency 阶段通过
#### Scenario: maxMaxLatencyMs 校验失败
- **WHEN** ping target 配置 `expect.maxMaxLatencyMs: 200`,且实际最大延迟为 340ms
- **THEN** 系统 SHALL 返回 `matched=false`failure 的 kind 为 `mismatch`phase 为 `maxLatency`
#### Scenario: maxDurationMs 校验
- **WHEN** ping target 配置 `expect.maxDurationMs: 5000`,且完整执行耗时超过 5000ms
- **THEN** 系统 SHALL 返回 `matched=false`failure 的 phase 为 `duration`
#### Scenario: alive=false 时跳过延迟断言
- **WHEN** ping target 配置 `expect.alive: true``expect.maxAvgLatencyMs: 100`,且目标不可达
- **THEN** 系统 SHALL 在 alive 阶段即返回失败,不执行后续延迟断言
#### Scenario: ping expect 未知字段失败
- **WHEN** YAML 中 ping target 的 expect 包含 `status: [200]` 或其他非 ping expect 字段
- **THEN** 系统 SHALL 以配置错误退出,提示 expect 包含未知字段
#### Scenario: maxPacketLoss 类型非法
- **WHEN** YAML 中 ping target 的 `expect.maxPacketLoss` 不是 0 到 100 之间的数字
- **THEN** 系统 SHALL 以配置错误退出,提示 expect.maxPacketLoss 必须为 0-100 的数字
#### Scenario: maxAvgLatencyMs 类型非法
- **WHEN** YAML 中 ping target 的 `expect.maxAvgLatencyMs` 不是非负有限数字
- **THEN** 系统 SHALL 以配置错误退出,提示 expect.maxAvgLatencyMs 格式错误
#### Scenario: maxMaxLatencyMs 类型非法
- **WHEN** YAML 中 ping target 的 `expect.maxMaxLatencyMs` 不是非负有限数字
- **THEN** 系统 SHALL 以配置错误退出,提示 expect.maxMaxLatencyMs 格式错误
### Requirement: ping statusDetail 摘要
系统 SHALL 在 ping 执行成功后生成结构化 statusDetail 摘要,展示关键指标。
#### Scenario: 目标可达无丢包
- **WHEN** ping 结果为 alive=true, avg=12ms, packetLoss=0%, transmitted=3, received=3
- **THEN** statusDetail SHALL 为 `alive, avg 12ms, loss 0% (3/3)`
#### Scenario: 目标可达有丢包
- **WHEN** ping 结果为 alive=true, avg=156ms, max=340ms, packetLoss=33%, transmitted=3, received=2
- **THEN** statusDetail SHALL 包含 avg、max 和 loss 信息
#### Scenario: 目标不可达
- **WHEN** ping 结果为 alive=false, transmitted=3, received=0
- **THEN** statusDetail SHALL 为 `unreachable (0/3 received)`

View File

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

View File

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

View File

@@ -17,7 +17,15 @@
#### Scenario: targets 字段 #### Scenario: targets 字段
- **WHEN** Dashboard 响应包含 targets - **WHEN** Dashboard 响应包含 targets
- **THEN** targets 数组中每个元素 SHALL 包含目标基本信息id、name、group、type、target、interval、latestCheck、stats、currentStreak 和 recentSamples 字段 - **THEN** targets 数组中每个元素 SHALL 包含目标基本信息id、name、description、group、type、target、interval、latestCheck、stats、currentStreak 和 recentSamples 字段,其中 name 和 description 均为 null 或字符串
#### Scenario: target name 字段为 null
- **WHEN** 某个 target 未配置 `name` 或显式配置 `name: null`
- **THEN** Dashboard targets 响应中对应元素 SHALL 返回 `name: null`
#### Scenario: target description 字段
- **WHEN** 某个 target 配置了 `description`
- **THEN** Dashboard targets 响应中对应元素 SHALL 返回该 description 值
#### Scenario: window 参数缺失 #### Scenario: window 参数缺失
- **WHEN** 客户端请求 `GET /api/dashboard` 未提供 window 参数 - **WHEN** 客户端请求 `GET /api/dashboard` 未提供 window 参数
@@ -105,7 +113,7 @@ Dashboard API SHALL 返回基于时间窗口计算的目标统计和连续状态
#### Scenario: TargetStatus 类型 #### Scenario: TargetStatus 类型
- **WHEN** 前后端共享 `TargetStatus` 类型 - **WHEN** 前后端共享 `TargetStatus` 类型
- **THEN** 该类型 SHALL 包含 statstotalChecks、upChecks、downChecks、availability、currentStreak 和 recentSamples 字段 - **THEN** 该类型 SHALL 包含目标基本信息字段id、name、description、group、type、target、intervalstatstotalChecks、upChecks、downChecks、availability、currentStreak 和 recentSamples 字段,其中 name 和 description 类型均为 null 或字符串
#### Scenario: TargetMetricsResponse 类型 #### Scenario: TargetMetricsResponse 类型
- **WHEN** 前后端共享 `TargetMetricsResponse` 类型 - **WHEN** 前后端共享 `TargetMetricsResponse` 类型

View File

@@ -5,9 +5,9 @@
## Requirements ## Requirements
### Requirement: YAML 配置文件格式 ### Requirement: YAML 配置文件格式
系统 SHALL 支持通过 YAML 配置文件定义全部运行参数,包括 server 配置、runtime 配置、可选的 variables 段、checker 默认值和 typed target 列表(含可选 group 字段。target MUST 使用 `id` 字段作为唯一标识符MUST 使用 `type` 字段声明 checker 类型SHALL 支持可选的 `name` 字段作为展示名称(缺省 fallback 到 id。HTTP 领域字段 MUST 放在 `http` 分组cmd 领域字段 MUST 放在 `cmd` 分组db 领域字段 MUST 放在 `db` 分组。HTTP target 的 `http` 分组 SHALL 支持可选的 `ignoreSSL`(布尔值)和 `maxRedirects`非负整数字段。Db target 的 `db` 分组 SHALL 支持 `url`(必填)和 `query`(可选)字段。 系统 SHALL 支持通过 YAML 配置文件定义全部运行参数,包括 server 配置、runtime 配置、可选的 variables 段、checker 默认值和 typed target 列表(含可选 group 字段。target MUST 使用 `id` 字段作为唯一标识符MUST 使用 `type` 字段声明 checker 类型SHALL 支持可选的 `name` 字段作为展示名称元信息SHALL 支持可选的 `description` 字段作为目标说明。`name``description` 均 SHALL 允许省略或显式配置为 `null`;省略或显式 null 时解析结果 SHALL 保留为 null。HTTP 领域字段 MUST 放在 `http` 分组cmd 领域字段 MUST 放在 `cmd` 分组db 领域字段 MUST 放在 `db` 分组tcp 领域字段 MUST 放在 `tcp` 分组。HTTP target 的 `http` 分组 SHALL 支持可选的 `ignoreSSL`(布尔值)和 `maxRedirects`非负整数字段。Db target 的 `db` 分组 SHALL 支持 `url`(必填)和 `query`(可选)字段。Tcp target 的 `tcp` 分组 SHALL 支持 `host`(必填)、`port`(必填)、`readBanner`(可选)、`bannerReadTimeout`(可选)和 `maxBannerBytes`(可选)字段。
`defaults.http` 分组 SHALL 仅支持 `headers`(可选)和 `maxBodyBytes`(可选)字段。`defaults.http` 分组 MUST NOT 支持 `method` 字段。 `defaults.http` 分组 SHALL 仅支持 `headers`(可选)和 `maxBodyBytes`(可选)字段。`defaults.http` 分组 MUST NOT 支持 `method` 字段。`defaults.tcp` 分组 SHALL 仅支持 `bannerReadTimeout`(可选)和 `maxBannerBytes`(可选)字段。
#### Scenario: 完整配置文件解析 #### Scenario: 完整配置文件解析
- **WHEN** 系统启动并读取包含 server、runtime、variables、defaults、targets含 id、group 字段)的 YAML 配置文件 - **WHEN** 系统启动并读取包含 server、runtime、variables、defaults、targets含 id、group 字段)的 YAML 配置文件
@@ -15,11 +15,11 @@
#### Scenario: 最简 HTTP 配置文件解析 #### Scenario: 最简 HTTP 配置文件解析
- **WHEN** 系统读取只包含一个 `type: http` target`id``http.url`)的 YAML 配置文件(省略 server、runtime、variables、defaults 和 expect - **WHEN** 系统读取只包含一个 `type: http` target`id``http.url`)的 YAML 配置文件(省略 server、runtime、variables、defaults 和 expect
- **THEN** 系统 SHALL 使用内置默认值填充未指定的字段host=127.0.0.1, port=3000, dir=./data, interval=30s, timeout=10s, runtime.maxConcurrentChecks=20, http.method=GET, http.maxBodyBytes=100MB, http.ignoreSSL=false, http.maxRedirects=0, group="default", name fallback 到 id - **THEN** 系统 SHALL 使用内置默认值填充未指定的字段host=127.0.0.1, port=3000, dir=./data, interval=30s, timeout=10s, runtime.maxConcurrentChecks=20, http.method=GET, http.maxBodyBytes=100MB, http.ignoreSSL=false, http.maxRedirects=0, group="default"),并保留 name=null、description=null
#### Scenario: 最简 cmd 配置文件解析 #### Scenario: 最简 cmd 配置文件解析
- **WHEN** 系统读取只包含一个 `type: cmd` target`id``cmd.exec`)的 YAML 配置文件 - **WHEN** 系统读取只包含一个 `type: cmd` target`id``cmd.exec`)的 YAML 配置文件
- **THEN** 系统 SHALL 使用内置默认值填充未指定的字段interval=30s, timeout=10s, cmd.cwd 为配置文件所在目录, cmd.maxOutputBytes=100MB, name fallback 到 id - **THEN** 系统 SHALL 使用内置默认值填充未指定的字段interval=30s, timeout=10s, cmd.cwd 为配置文件所在目录, cmd.maxOutputBytes=100MB),并保留 name=null、description=null
#### Scenario: per-target 配置覆盖全局默认值 #### Scenario: per-target 配置覆盖全局默认值
- **WHEN** 某个 target 指定 interval、timeout 或对应领域分组中的默认字段 - **WHEN** 某个 target 指定 interval、timeout 或对应领域分组中的默认字段
@@ -35,7 +35,15 @@
#### Scenario: 最简 db 配置文件解析 #### Scenario: 最简 db 配置文件解析
- **WHEN** 系统读取只包含一个 `type: db` target`id``db.url`)的 YAML 配置文件 - **WHEN** 系统读取只包含一个 `type: db` target`id``db.url`)的 YAML 配置文件
- **THEN** 系统 SHALL 使用内置默认值填充未指定的字段interval=30s, timeout=10s, group="default", name fallback 到 id - **THEN** 系统 SHALL 使用内置默认值填充未指定的字段interval=30s, timeout=10s, group="default"),并保留 name=null、description=null
#### Scenario: 最简 tcp 配置文件解析
- **WHEN** 系统读取只包含一个 `type: tcp` target`id``tcp.host``tcp.port`)的 YAML 配置文件
- **THEN** 系统 SHALL 使用内置默认值填充未指定的字段interval=30s, timeout=10s, group="default", tcp.readBanner=false, tcp.bannerReadTimeout=2000, tcp.maxBannerBytes=4096并保留 name=null、description=null
#### Scenario: defaults.tcp 配置 banner 默认值
- **WHEN** YAML 配置中 defaults.tcp 设置 `bannerReadTimeout``maxBannerBytes`
- **THEN** 未显式覆盖对应字段的 tcp target SHALL 使用 defaults.tcp 中的值
#### Scenario: defaults.http.method 触发校验错误 #### Scenario: defaults.http.method 触发校验错误
- **WHEN** 配置文件中出现 `defaults.http.method` 字段 - **WHEN** 配置文件中出现 `defaults.http.method` 字段
@@ -328,3 +336,100 @@
#### Scenario: dataDir 使用默认值 #### Scenario: dataDir 使用默认值
- **WHEN** 未配置 `server.dataDir`(使用默认值 `./data` - **WHEN** 未配置 `server.dataDir`(使用默认值 `./data`
- **THEN** 系统 SHALL 将默认值 `./data` 基于 configDir 解析为绝对路径 - **THEN** 系统 SHALL 将默认值 `./data` 基于 configDir 解析为绝对路径
### Requirement: target 通用元信息字段约束
系统 SHALL 在 YAML target 通用字段中支持 `description` 字段,并对 `id``name``description` 执行契约校验。`id` MUST 为 1 到 30 个字符。`name` MUST 为 null 或 1 到 30 个字符的字符串,且语义校验 SHALL 拒绝仅包含空白字符的 name。`description` MUST 为 null 或不超过 500 个字符的字符串,且 MAY 为空字符串。
#### Scenario: description 字段解析
- **WHEN** 系统读取包含 `description: "检查生产 API 健康状态"` 的 target
- **THEN** 系统 SHALL 将该字段解析为 target 的目标说明
#### Scenario: name 为 null 通过校验
- **WHEN** 系统读取包含 `name: null` 或省略 `name` 的 target
- **THEN** 系统 SHALL 接受该配置
#### Scenario: name 仅包含空白字符报错
- **WHEN** 系统读取包含 `name: " "` 的 target
- **THEN** 系统 SHALL 以配置错误退出,提示 name 不能为空白
#### Scenario: description 为空字符串
- **WHEN** 系统读取包含 `description: ""` 的 target
- **THEN** 系统 SHALL 接受该配置,且不触发长度错误
#### Scenario: description 为 null 通过校验
- **WHEN** 系统读取包含 `description: null` 或省略 `description` 的 target
- **THEN** 系统 SHALL 接受该配置
#### Scenario: description 类型非法
- **WHEN** YAML 中某个 target 的 `description` 字段不是字符串也不是 null
- **THEN** 系统 SHALL 以错误退出,提示 description 字段类型错误
#### Scenario: description 超过最大长度
- **WHEN** YAML 中某个 target 的 `description` 字段超过 500 个字符
- **THEN** 系统 SHALL 以错误退出,提示 description 字段长度错误
#### Scenario: id 超过最大长度
- **WHEN** YAML 中某个 target 的 `id` 字段超过 30 个字符
- **THEN** 系统 SHALL 以错误退出,提示 id 字段长度错误
#### Scenario: name 超过最大长度
- **WHEN** YAML 中某个 target 的 `name` 字段超过 30 个字符
- **THEN** 系统 SHALL 以错误退出,提示 name 字段长度错误
#### Scenario: 变量替换后 description 超长
- **WHEN** target 的 `description` 通过变量替换后超过 500 个字符
- **THEN** 系统 SHALL 在契约校验阶段以错误退出,提示 description 字段长度错误
### Requirement: 配置 schema 导出包含 target 元信息约束
系统 SHALL 在导出的 `probe-config.schema.json` 中包含 target `id``name``description` 的长度约束和可空类型,用于编辑器提示和外部校验。
#### Scenario: schema 导出 description
- **WHEN** 系统导出 `probe-config.schema.json`
- **THEN** target schema SHALL 包含可选的 `description` 字段,类型为 string 或 null字符串最大长度为 500
#### Scenario: schema 导出 id 和 name
- **WHEN** 系统导出 `probe-config.schema.json`
- **THEN** target schema SHALL 声明 `id` 的 minLength 为 1、maxLength 为 30并声明 `name` 为可选字段,类型为 string 或 null字符串的 minLength 为 1、maxLength 为 30
### Requirement: TCP 配置校验
系统 SHALL 在启动期对 tcp checker 的配置契约和语义执行严格校验。Tcp target 的 `tcp` 分组 SHALL 只允许 `host``port``readBanner``bannerReadTimeout``maxBannerBytes` 字段Tcp expect SHALL 只允许 `connected``maxDurationMs``banner` 字段。未知字段、非法类型、非法端口、非法 size 和不可编译正则 MUST 导致启动期配置错误。
#### Scenario: tcp host 类型非法
- **WHEN** YAML 中 tcp target 的 `tcp.host` 不是非空字符串
- **THEN** 系统 SHALL 以配置错误退出,提示 tcp.host 必须为非空字符串
#### Scenario: tcp port 类型非法
- **WHEN** YAML 中 tcp target 的 `tcp.port` 不是整数
- **THEN** 系统 SHALL 以配置错误退出,提示 tcp.port 必须为整数端口
#### Scenario: tcp readBanner 类型非法
- **WHEN** YAML 中 tcp target 的 `tcp.readBanner` 不是布尔值
- **THEN** 系统 SHALL 以配置错误退出,提示 tcp.readBanner 必须为布尔值
#### Scenario: tcp bannerReadTimeout 非法
- **WHEN** YAML 中 tcp target 或 defaults.tcp 的 `bannerReadTimeout` 不是非负有限数字
- **THEN** 系统 SHALL 以配置错误退出,提示 bannerReadTimeout 格式错误
#### Scenario: tcp maxBannerBytes 非法
- **WHEN** YAML 中 tcp target 或 defaults.tcp 的 `maxBannerBytes` 不是合法 size 值
- **THEN** 系统 SHALL 以配置错误退出,提示 maxBannerBytes 格式错误
#### Scenario: tcp expect connected 类型非法
- **WHEN** YAML 中 tcp target 的 `expect.connected` 不是布尔值
- **THEN** 系统 SHALL 以配置错误退出,提示 expect.connected 必须为布尔值
#### Scenario: tcp expect banner 非法
- **WHEN** YAML 中 tcp target 的 `expect.banner` 不是合法 operator 对象
- **THEN** 系统 SHALL 以配置错误退出,提示 expect.banner 格式错误
#### Scenario: tcp expect banner match 正则非法
- **WHEN** YAML 中 tcp target 配置 `expect.banner: { match: "[invalid" }`
- **THEN** 系统 SHALL 在启动期配置校验失败,而不是延迟到运行期抛错
#### Scenario: tcp 分组未知字段失败
- **WHEN** YAML 中 tcp target 的 `tcp` 分组包含 `tls: true` 等未知字段
- **THEN** 系统 SHALL 以配置错误退出,提示 tcp 分组包含未知字段
#### Scenario: defaults.tcp 未知字段失败
- **WHEN** YAML 中 defaults.tcp 包含 `host` 或其他非默认字段
- **THEN** 系统 SHALL 以配置错误退出,提示 defaults.tcp 包含未知字段

View File

@@ -5,12 +5,16 @@
## Requirements ## Requirements
### Requirement: SQLite 数据库初始化 ### Requirement: SQLite 数据库初始化
系统 SHALL 使用 Bun 内置 `bun:sqlite` 模块在配置的数据目录下创建 SQLite 数据库文件,并以 WAL 模式运行。数据库 schema MUST 支持 typed checker target 和结构化检查结果targets 表 MUST 包含 `grp` 列存储分组信息。 系统 SHALL 使用 Bun 内置 `bun:sqlite` 模块在配置的数据目录下创建 SQLite 数据库文件,并以 WAL 模式运行。数据库 schema MUST 支持 typed checker target 和结构化检查结果targets 表 MUST 包含 `grp` 列存储分组信息,且 targets 表的 `name``description` 列 MUST 允许 NULL
#### Scenario: 首次启动创建数据库 #### Scenario: 首次启动创建数据库
- **WHEN** 指定的数据目录下不存在数据库文件 - **WHEN** 指定的数据目录下不存在数据库文件
- **THEN** 系统 SHALL 创建数据库文件并初始化 targets 表和 check_results 表check_results 表包含 idINTEGER PRIMARY KEY AUTOINCREMENT、target_idINTEGER NOT NULL、timestampTEXT NOT NULL、matchedINTEGER NOT NULL、duration_msREAL、status_detailTEXT、failureTEXT不包含 success 列 - **THEN** 系统 SHALL 创建数据库文件并初始化 targets 表和 check_results 表check_results 表包含 idINTEGER PRIMARY KEY AUTOINCREMENT、target_idINTEGER NOT NULL、timestampTEXT NOT NULL、matchedINTEGER NOT NULL、duration_msREAL、status_detailTEXT、failureTEXT不包含 success 列
#### Scenario: targets name 列允许 NULL
- **WHEN** 系统首次创建 targets 表
- **THEN** targets.name 列 SHALL 允许存储 NULL
#### Scenario: 数据目录不存在 #### Scenario: 数据目录不存在
- **WHEN** 配置的数据目录路径不存在 - **WHEN** 配置的数据目录路径不存在
- **THEN** 系统 SHALL 自动创建该目录 - **THEN** 系统 SHALL 自动创建该目录
@@ -26,15 +30,31 @@
- **THEN** check_results 表的外键约束 SHALL 使用 `ON DELETE CASCADE`,确保删除目标时自动清理关联结果记录 - **THEN** check_results 表的外键约束 SHALL 使用 `ON DELETE CASCADE`,确保删除目标时自动清理关联结果记录
### Requirement: targets 表同步 ### Requirement: targets 表同步
系统 SHALL 在启动时将 YAML 配置中的目标列表同步到 SQLite targets 表,并持久化 target 类型、展示摘要、领域配置、调度配置、expect 配置分组信息。 系统 SHALL 在启动时将 YAML 配置中的目标列表同步到 SQLite targets 表,并持久化 target 类型、展示名称元信息、展示摘要、领域配置、调度配置、expect 配置分组信息和目标说明
#### Scenario: 首次同步目标 #### Scenario: 首次同步目标
- **WHEN** 数据库为空且 YAML 中定义了 N 个 typed target - **WHEN** 数据库为空且 YAML 中定义了 N 个 typed target
- **THEN** 系统 SHALL 将所有目标插入 targets 表,包含 name、type、target、config、interval_ms、timeout_ms、expect 和 grp - **THEN** 系统 SHALL 将所有目标插入 targets 表,包含 name、description、type、target、config、interval_ms、timeout_ms、expect 和 grp,其中 name 和 description 均可为 NULL
#### Scenario: 配置变更后重新同步 #### Scenario: 配置变更后重新同步
- **WHEN** YAML 配置发生变更(新增、删除或修改目标)后重启 - **WHEN** YAML 配置发生变更(新增、删除或修改目标)后重启
- **THEN** 系统 SHALL 根据 id 字段匹配:新增的插入、删除的移除、修改的更新(含 grp 字段) - **THEN** 系统 SHALL 根据 id 字段匹配:新增的插入、删除的移除、修改的更新(含 name、description 和 grp 字段)
#### Scenario: 未配置 name
- **WHEN** YAML target 未配置 `name`
- **THEN** targets 表 SHALL 将该目标的 name 存储为 NULL
#### Scenario: name 显式 null
- **WHEN** YAML target 配置 `name: null`
- **THEN** targets 表 SHALL 将该目标的 name 存储为 NULL
#### Scenario: 未配置 description
- **WHEN** YAML target 未配置 `description`
- **THEN** targets 表 SHALL 将该目标的 description 存储为 NULL
#### Scenario: description 显式 null
- **WHEN** YAML target 配置 `description: null`
- **THEN** targets 表 SHALL 将该目标的 description 存储为 NULL
### Requirement: check_results 表追加写入 ### Requirement: check_results 表追加写入
系统 SHALL 将每次检查结果追加写入 check_results 表,不更新或删除已有记录。 系统 SHALL 将每次检查结果追加写入 check_results 表,不更新或删除已有记录。

View File

@@ -13,7 +13,11 @@ Dashboard SHALL 在用户点击目标表格行后从右侧滑出 Drawer展示
#### Scenario: Drawer 标题栏 #### Scenario: Drawer 标题栏
- **WHEN** Drawer 渲染 - **WHEN** Drawer 渲染
- **THEN** 标题栏 SHALL 使用 TDesign Space 组件align="center")布局,包含 StatusDot、目标名称TDesign Typography.Text strong和类型标签TDesign Tag直接显示 target.type 原始文本),以及内建关闭按钮。不使用内联 style 的 flex 布局 - **THEN** 标题栏 SHALL 使用 TDesign Space 组件align="center")布局,包含 StatusDot、目标展示名称(取值为 `target.name ?? target.id`,使用 TDesign Typography.Text strong和类型标签TDesign Tag直接显示 target.type 原始文本),以及内建关闭按钮。不使用内联 style 的 flex 布局
#### Scenario: Drawer 标题栏 name 为 null
- **WHEN** Drawer 渲染某个 `target.name` 为 null 的目标
- **THEN** 标题栏 SHALL 显示该目标的 `target.id` 作为目标展示名称
#### Scenario: 关闭 Drawer #### Scenario: 关闭 Drawer
- **WHEN** 用户点击关闭按钮、ESC 键或遮罩层 - **WHEN** 用户点击关闭按钮、ESC 键或遮罩层
@@ -215,7 +219,11 @@ Drawer 内部 SHALL 使用 TDesign Tabs 组织概览和记录两个面板。Tabs
#### Scenario: 基本信息内容 #### Scenario: 基本信息内容
- **WHEN** 概览面板渲染 - **WHEN** 概览面板渲染
- **THEN** Descriptions SHALL 展示:目标地址、检查间隔、最新检查时间、状态详情 - **THEN** Descriptions SHALL 展示:目标地址、检查间隔、最新检查时间、状态详情、描述,其中描述 SHALL 位于最后一行
#### Scenario: 描述行占满整行
- **WHEN** 概览面板渲染基本信息
- **THEN** 描述项 SHALL 占据 Descriptions 的一整行,内容 SHALL 使用 `target.description ?? ""`,即使 description 为空也 SHALL 渲染该项
#### Scenario: 统计区上下布局卡片 #### Scenario: 统计区上下布局卡片
- **WHEN** 概览面板渲染且有统计数据 - **WHEN** 概览面板渲染且有统计数据

View File

@@ -5,7 +5,7 @@
## Requirements ## Requirements
### Requirement: target id 字段 ### Requirement: target id 字段
每个 target SHALL 包含必填的 `id` 字段作为唯一标识符。`id` SHALL 符合 `[a-zA-Z0-9][a-zA-Z0-9_-]*` 命名规则。`id` MUST 在所有 targets 中全局唯一。`id` MUST NOT 参与变量替换。 每个 target SHALL 包含必填的 `id` 字段作为唯一标识符。`id` SHALL 符合 `[a-zA-Z0-9][a-zA-Z0-9_-]*` 命名规则。`id` 长度 MUST 为 1 到 30 个字符。`id` MUST 在所有 targets 中全局唯一。`id` MUST NOT 参与变量替换。
#### Scenario: 合法 id #### Scenario: 合法 id
- **WHEN** target 配置 `id: "api-health"` - **WHEN** target 配置 `id: "api-health"`
@@ -23,6 +23,10 @@
- **WHEN** target 配置 `id: ""` - **WHEN** target 配置 `id: ""`
- **THEN** 系统 SHALL 以配置错误退出,提示 id 不能为空 - **THEN** 系统 SHALL 以配置错误退出,提示 id 不能为空
#### Scenario: id 超过最大长度报错
- **WHEN** target 配置超过 30 个字符的 `id`
- **THEN** 系统 SHALL 以配置错误退出,提示 id 长度不合法
#### Scenario: id 不合法报错 #### Scenario: id 不合法报错
- **WHEN** target 配置 `id: "_invalid"``id: "-start"``id: "has space"` - **WHEN** target 配置 `id: "_invalid"``id: "-start"``id: "has space"`
- **THEN** 系统 SHALL 以配置错误退出,提示 id 不符合命名规则 - **THEN** 系统 SHALL 以配置错误退出,提示 id 不符合命名规则
@@ -32,20 +36,75 @@
- **THEN** 系统 SHALL 以配置错误退出,提示 id 重复 - **THEN** 系统 SHALL 以配置错误退出,提示 id 重复
### Requirement: target name 字段 ### Requirement: target name 字段
每个 target SHALL 支持可选的 `name` 字段作为展示名称。`name` 缺省时 SHALL fallback 到 `id` 的值作为展示名称`name` SHALL 支持变量替换。`name` MUST NOT 要求全局唯一。 每个 target SHALL 支持可选的 `name` 字段作为展示名称元信息`name` 缺省或显式配置为 `null` 时 SHALL 在配置解析、运行时模型、存储和 API 中保留为 null。前端展示目标名称时 SHALL 使用 `name ?? id`,但该 fallback MUST NOT 改变 target 本身的 name 值。显式配置的 `name` MUST 为长度 1 到 30 个字符的字符串,且去除首尾空白后 MUST 不为空`name` SHALL 支持变量替换。`name` MUST NOT 要求全局唯一MUST NOT 参与 target 唯一性判定
#### Scenario: 配置 name #### Scenario: 配置 name
- **WHEN** target 配置 `id: "api-health"``name: "API 健康检查"` - **WHEN** target 配置 `id: "api-health"``name: "API 健康检查"`
- **THEN** 系统 SHALL 使用 "API 健康检查" 作为展示名称 - **THEN** 系统 SHALL 在解析后保留 name 为 "API 健康检查"
#### Scenario: name 使用变量 #### Scenario: name 使用变量
- **WHEN** target 配置 `name: "${env} API 健康检查"` 且 variables 中 `env: "生产"` - **WHEN** target 配置 `name: "${env} API 健康检查"` 且 variables 中 `env: "生产"`
- **THEN** 系统 SHALL 将展示名称解析为 "生产 API 健康检查" - **THEN** 系统 SHALL 将 name 解析为 "生产 API 健康检查"
#### Scenario: name 缺省 fallback 到 id #### Scenario: name 缺省保留为 null
- **WHEN** target 配置 `id: "api-health"` 但未配置 `name` - **WHEN** target 配置 `id: "api-health"` 但未配置 `name`
- **THEN** 系统 SHALL 使用 "api-health" 作为展示名称 - **THEN** 系统 SHALL 在解析、存储和 API 响应中保留 name 为 null
#### Scenario: name 显式 null
- **WHEN** target 配置 `id: "api-health"``name: null`
- **THEN** 系统 SHALL 接受该配置,并在解析、存储和 API 响应中保留 name 为 null
#### Scenario: name 空 YAML 值
- **WHEN** target 配置 `id: "api-health"``name:` 后不提供值
- **THEN** 系统 SHALL 将该 name 按 null 处理,并接受该配置
#### Scenario: name 为 null 时展示 fallback
- **WHEN** 前端展示 name 为 null 的 target
- **THEN** 前端 SHALL 显示该 target 的 id 作为目标名称文案
#### Scenario: name 为空字符串报错
- **WHEN** target 配置 `name: ""`
- **THEN** 系统 SHALL 以配置错误退出,提示 name 不能为空
#### Scenario: name 仅包含空白字符报错
- **WHEN** target 配置 `name: " "`
- **THEN** 系统 SHALL 以配置错误退出,提示 name 不能为空
#### Scenario: name 超过最大长度报错
- **WHEN** target 配置超过 30 个字符的 `name`
- **THEN** 系统 SHALL 以配置错误退出,提示 name 长度不合法
#### Scenario: 多个 target 使用相同 name #### Scenario: 多个 target 使用相同 name
- **WHEN** 两个 target 配置不同 id 但相同 `name: "健康检查"` - **WHEN** 两个 target 配置不同 id 但相同 `name: "健康检查"`
- **THEN** 系统 SHALL 接受该配置不报错name 不要求全局唯一) - **THEN** 系统 SHALL 接受该配置不报错name 不要求全局唯一)
### Requirement: target description 字段
每个 target SHALL 支持可选的 `description` 字段作为目标说明。`description` 缺省或显式配置为 `null` 时 SHALL 在配置解析、运行时模型、存储和 API 中保留为 null。`description` SHALL 支持变量替换。`description` 字符串长度 MUST 不超过 500 个字符,且允许为空字符串。`description` MUST NOT 参与 target 唯一性判定。
#### Scenario: 配置 description
- **WHEN** target 配置 `description: "检查生产 API 健康状态"`
- **THEN** 系统 SHALL 使用该值作为目标说明
#### Scenario: description 使用变量
- **WHEN** target 配置 `description: "${env} 环境健康检查"` 且 variables 中 `env: "生产"`
- **THEN** 系统 SHALL 将目标说明解析为 "生产 环境健康检查"
#### Scenario: description 缺省
- **WHEN** target 未配置 `description`
- **THEN** 系统 SHALL 接受该配置,且目标说明为 null
#### Scenario: description 显式 null
- **WHEN** target 配置 `description: null`
- **THEN** 系统 SHALL 接受该配置,且目标说明为 null
#### Scenario: description 空 YAML 值
- **WHEN** target 配置 `description:` 后不提供值
- **THEN** 系统 SHALL 将该 description 按 null 处理,并接受该配置
#### Scenario: description 为空字符串
- **WHEN** target 配置 `description: ""`
- **THEN** 系统 SHALL 接受该配置,且目标说明为空字符串
#### Scenario: description 超过最大长度报错
- **WHEN** target 配置超过 500 个字符的 `description`
- **THEN** 系统 SHALL 以配置错误退出,提示 description 长度不合法

View File

@@ -44,7 +44,11 @@ Dashboard SHALL 按 group 字段将目标分组,每个分组使用 TDesign Car
#### Scenario: 名称列 #### Scenario: 名称列
- **WHEN** 表格渲染 - **WHEN** 表格渲染
- **THEN** 名称列 SHALL 显示目标名称,支持字母排序zh-CNellipsis 超长名称自动省略并 Tooltip 显示全名 - **THEN** 名称列 SHALL 显示目标展示名称,取值为 `target.name ?? target.id`ellipsis 超长名称自动省略并 Tooltip 显示全名,且 SHALL NOT 支持排序
#### Scenario: name 为 null 的名称列
- **WHEN** 表格渲染某个 `target.name` 为 null 的目标
- **THEN** 名称列 SHALL 显示该目标的 `target.id`
#### Scenario: 类型列 #### Scenario: 类型列
- **WHEN** 表格渲染 - **WHEN** 表格渲染
@@ -68,7 +72,7 @@ Dashboard SHALL 按 group 字段将目标分组,每个分组使用 TDesign Car
#### Scenario: 延迟列 #### Scenario: 延迟列
- **WHEN** 表格渲染 - **WHEN** 表格渲染
- **THEN** 延迟列 SHALL 显示最近一次检查的延迟毫秒数右对齐,颜色通过 CSS 类控制;超过 9999ms 时 SHALL 显示为"9999+ms" - **THEN** 延迟列标题 SHALL 展示为"延迟(ms)",单元格 SHALL 显示最近一次检查的延迟毫秒数值并右对齐,颜色通过 CSS 类控制;超过 9999ms 时 SHALL 显示为"9999+"
#### Scenario: 间隔列移除 #### Scenario: 间隔列移除
- **WHEN** 表格渲染 - **WHEN** 表格渲染

View File

@@ -0,0 +1,113 @@
## Purpose
定义 TCP checker 的配置格式、连接执行、banner 读取、expect 校验、失败结构和状态摘要。
## Requirements
### Requirement: tcp target 配置
系统 SHALL 支持 `type: tcp` 的 target 配置,通过 `tcp.host``tcp.port` 描述目标 TCP 地址,并通过可选字段控制 banner 读取行为。
#### Scenario: 解析最简 tcp target
- **WHEN** YAML 中 target 配置 `type: tcp``tcp.host: "127.0.0.1"``tcp.port: 6379`
- **THEN** 系统 SHALL 将其解析为 tcp checker并填充 `readBanner=false``bannerReadTimeout=2000``maxBannerBytes=4096`、interval、timeout、group 和 expect 配置
#### Scenario: tcp target 缺少 host
- **WHEN** YAML 中 target 配置 `type: tcp` 但缺少 `tcp.host`
- **THEN** 系统 SHALL 以配置错误退出,并提示该 target 缺少 tcp.host 字段
#### Scenario: tcp target 缺少 port
- **WHEN** YAML 中 target 配置 `type: tcp` 但缺少 `tcp.port`
- **THEN** 系统 SHALL 以配置错误退出,并提示该 target 缺少 tcp.port 字段
#### Scenario: tcp port 范围非法
- **WHEN** YAML 中 tcp target 的 `tcp.port` 不是 1 到 65535 之间的整数
- **THEN** 系统 SHALL 以配置错误退出,并提示 tcp.port 必须为合法 TCP 端口
#### Scenario: tcp defaults 覆盖 banner 参数
- **WHEN** YAML 中配置 `defaults.tcp.bannerReadTimeout: 1000``defaults.tcp.maxBannerBytes: "8KB"`
- **THEN** 未显式配置对应字段的 tcp target SHALL 使用 defaults.tcp 中的值
#### Scenario: per-target banner 参数覆盖 defaults
- **WHEN** defaults.tcp 配置了 banner 参数,且某个 tcp target 显式配置 `tcp.bannerReadTimeout``tcp.maxBannerBytes`
- **THEN** 该 target SHALL 使用自身 tcp 分组中的值
#### Scenario: tcp 序列化展示摘要
- **WHEN** 系统同步 tcp target 到 targets 表
- **THEN** `target` 展示摘要 SHALL 为 `<host>:<port>``config` JSON SHALL 包含 resolved 后的 host、port、readBanner、bannerReadTimeout 和 maxBannerBytes
### Requirement: tcp checker 执行
系统 SHALL 按 tcp target 配置建立 TCP 连接,记录完整执行耗时,并在连接失败、超时或资源超限时产生结构化失败信息。
#### Scenario: TCP 连接成功
- **WHEN** tcp target 指向可连接的 TCP 服务,且未配置 expect 或 `expect.connected``true`
- **THEN** 系统 SHALL 记录 `matched=true``durationMs``statusDetail`,并关闭 socket
#### Scenario: TCP 连接失败
- **WHEN** tcp target 指向不可连接的 host/port且未配置 expect 或 `expect.connected``true`
- **THEN** 系统 SHALL 记录 `matched=false`failure 的 kind 为 `error`phase 为 `connect`message 包含可读连接失败原因
#### Scenario: 期望端口不可达且连接失败
- **WHEN** tcp target 配置 `expect.connected: false`,且 TCP 连接失败
- **THEN** 系统 SHALL 记录 `matched=true`statusDetail SHALL 展示实际连接失败原因摘要
#### Scenario: 期望端口不可达但连接成功
- **WHEN** tcp target 配置 `expect.connected: false`,但 TCP 连接成功
- **THEN** 系统 SHALL 记录 `matched=false`failure 的 kind 为 `mismatch`phase 为 `connected`
#### Scenario: TCP 执行超时
- **WHEN** 引擎注入的 `ctx.signal` 在 TCP 连接或 banner 读取过程中 abort
- **THEN** 系统 SHALL best-effort 关闭 socket记录 `matched=false`failure 的 kind 为 `error`phase 为 `connect``banner`message 包含超时信息
#### Scenario: duration 包含 banner 读取
- **WHEN** tcp target 开启 `readBanner` 且服务端延迟发送 banner
- **THEN** 结果中的 `durationMs` SHALL 覆盖连接建立、banner 等待、banner 读取和 expect 校验的完整耗时
### Requirement: tcp banner 读取
系统 SHALL 仅在 `tcp.readBanner: true` 时读取服务端主动发送的 banner 数据,并同时受 `bannerReadTimeout``maxBannerBytes` 限制。
#### Scenario: 默认不读取 banner
- **WHEN** tcp target 未配置 `readBanner` 或配置为 `false`
- **THEN** 系统 SHALL 在连接建立后立即进入 connected 和 duration 校验,不等待服务端数据
#### Scenario: 读取服务端 banner
- **WHEN** tcp target 配置 `readBanner: true`,且服务端连接后发送 `220 smtp.example.com ESMTP`
- **THEN** 系统 SHALL 收集 banner 文本,并允许后续 `expect.banner` 对该文本执行 operator 断言
#### Scenario: banner 等待超时无数据
- **WHEN** tcp target 配置 `readBanner: true`,但服务端在 `bannerReadTimeout` 内未发送任何数据
- **THEN** 系统 SHALL 将 banner 视为空字符串并继续执行 expect 校验,不将无 banner 本身作为连接错误
#### Scenario: banner 读取超过最大字节数
- **WHEN** 服务端发送的 banner 数据超过 `maxBannerBytes`
- **THEN** 系统 SHALL 停止读取并记录 `matched=false`、failure.kind=`error`、failure.phase=`banner` 的结构化错误
#### Scenario: banner statusDetail 截断展示
- **WHEN** tcp target 成功读取到较长 banner
- **THEN** `statusDetail` SHALL 展示截断后的 banner 摘要,避免 UI 和历史记录写入过长文本
### Requirement: tcp expect 校验
系统 SHALL 支持 tcp 专属 expect包括 `connected``banner``maxDurationMs`,并按 connected、banner、duration 的阶段顺序快速失败。
#### Scenario: 默认 connected 成功语义
- **WHEN** tcp target 未显式配置 `expect.connected`
- **THEN** 系统 SHALL 使用默认 `expect.connected: true` 进行校验
#### Scenario: maxDurationMs 校验
- **WHEN** tcp target 配置 `expect.maxDurationMs: 100`,且完整执行耗时超过 100ms
- **THEN** 系统 SHALL 返回 `matched=false`failure 的 phase 为 `duration`
#### Scenario: banner operator 校验通过
- **WHEN** tcp target 配置 `readBanner: true``expect.banner: { contains: "ESMTP" }`,且实际 banner 包含 `ESMTP`
- **THEN** 系统 SHALL 判定 banner 阶段通过
#### Scenario: banner operator 校验失败
- **WHEN** tcp target 配置 `readBanner: true``expect.banner: { contains: "ESMTP" }`,且实际 banner 不包含 `ESMTP`
- **THEN** 系统 SHALL 返回 `matched=false`failure 的 kind 为 `mismatch`phase 为 `banner`path 为 `banner`
#### Scenario: expect.banner 未开启 readBanner
- **WHEN** tcp target 配置 `expect.banner`,但 `tcp.readBanner` 未配置为 `true`
- **THEN** 系统 SHALL 在启动期配置校验失败,提示 banner 断言需要启用 tcp.readBanner
#### Scenario: tcp expect 未知字段失败
- **WHEN** YAML 中 tcp target 的 expect 包含 `status: [200]` 或其他非 tcp expect 字段
- **THEN** 系统 SHALL 以配置错误退出,提示 expect 包含未知字段

View File

@@ -62,6 +62,27 @@
"additionalProperties": false, "additionalProperties": false,
"type": "object", "type": "object",
"properties": {} "properties": {}
},
"tcp": {
"additionalProperties": false,
"type": "object",
"properties": {
"bannerReadTimeout": {
"minimum": 0,
"type": "number"
},
"maxBannerBytes": {
"anyOf": [
{
"type": "string"
},
{
"minimum": 0,
"type": "integer"
}
]
}
}
} }
} }
}, },
@@ -109,6 +130,17 @@
"http" "http"
], ],
"properties": { "properties": {
"description": {
"anyOf": [
{
"type": "null"
},
{
"maxLength": 500,
"type": "string"
}
]
},
"expect": { "expect": {
"additionalProperties": false, "additionalProperties": false,
"type": "object", "type": "object",
@@ -404,6 +436,7 @@
"type": "string" "type": "string"
}, },
"id": { "id": {
"maxLength": 30,
"minLength": 1, "minLength": 1,
"type": "string" "type": "string"
}, },
@@ -411,8 +444,16 @@
"type": "string" "type": "string"
}, },
"name": { "name": {
"minLength": 1, "anyOf": [
"type": "string" {
"type": "null"
},
{
"maxLength": 30,
"minLength": 1,
"type": "string"
}
]
}, },
"timeout": { "timeout": {
"type": "string" "type": "string"
@@ -504,6 +545,17 @@
"cmd" "cmd"
], ],
"properties": { "properties": {
"description": {
"anyOf": [
{
"type": "null"
},
{
"maxLength": 500,
"type": "string"
}
]
},
"expect": { "expect": {
"additionalProperties": false, "additionalProperties": false,
"type": "object", "type": "object",
@@ -640,6 +692,7 @@
"type": "string" "type": "string"
}, },
"id": { "id": {
"maxLength": 30,
"minLength": 1, "minLength": 1,
"type": "string" "type": "string"
}, },
@@ -647,8 +700,16 @@
"type": "string" "type": "string"
}, },
"name": { "name": {
"minLength": 1, "anyOf": [
"type": "string" {
"type": "null"
},
{
"maxLength": 30,
"minLength": 1,
"type": "string"
}
]
}, },
"timeout": { "timeout": {
"type": "string" "type": "string"
@@ -707,6 +768,17 @@
"db" "db"
], ],
"properties": { "properties": {
"description": {
"anyOf": [
{
"type": "null"
},
{
"maxLength": 500,
"type": "string"
}
]
},
"expect": { "expect": {
"additionalProperties": false, "additionalProperties": false,
"type": "object", "type": "object",
@@ -869,6 +941,7 @@
"type": "string" "type": "string"
}, },
"id": { "id": {
"maxLength": 30,
"minLength": 1, "minLength": 1,
"type": "string" "type": "string"
}, },
@@ -876,8 +949,16 @@
"type": "string" "type": "string"
}, },
"name": { "name": {
"minLength": 1, "anyOf": [
"type": "string" {
"type": "null"
},
{
"maxLength": 30,
"minLength": 1,
"type": "string"
}
]
}, },
"timeout": { "timeout": {
"type": "string" "type": "string"
@@ -904,6 +985,163 @@
} }
} }
} }
},
{
"additionalProperties": false,
"type": "object",
"required": [
"id",
"type",
"tcp"
],
"properties": {
"description": {
"anyOf": [
{
"type": "null"
},
{
"maxLength": 500,
"type": "string"
}
]
},
"expect": {
"additionalProperties": false,
"type": "object",
"properties": {
"banner": {
"additionalProperties": false,
"minProperties": 1,
"type": "object",
"properties": {
"contains": {
"type": "string"
},
"empty": {
"type": "boolean"
},
"equals": {
"anyOf": [
{
"type": "string"
},
{
"type": "number"
},
{
"type": "boolean"
},
{
"type": "null"
},
{
"items": {},
"type": "array"
},
{
"additionalProperties": {},
"type": "object"
}
]
},
"exists": {
"type": "boolean"
},
"gt": {
"type": "number"
},
"gte": {
"type": "number"
},
"lt": {
"type": "number"
},
"lte": {
"type": "number"
},
"match": {
"type": "string"
}
}
},
"connected": {
"type": "boolean"
},
"maxDurationMs": {
"minimum": 0,
"type": "number"
}
}
},
"group": {
"type": "string"
},
"id": {
"maxLength": 30,
"minLength": 1,
"type": "string"
},
"interval": {
"type": "string"
},
"name": {
"anyOf": [
{
"type": "null"
},
{
"maxLength": 30,
"minLength": 1,
"type": "string"
}
]
},
"timeout": {
"type": "string"
},
"type": {
"const": "tcp",
"type": "string"
},
"tcp": {
"additionalProperties": false,
"type": "object",
"required": [
"host",
"port"
],
"properties": {
"bannerReadTimeout": {
"minimum": 0,
"type": "number"
},
"host": {
"minLength": 1,
"type": "string"
},
"maxBannerBytes": {
"anyOf": [
{
"type": "string"
},
{
"minimum": 0,
"type": "integer"
}
]
},
"port": {
"maximum": 65535,
"minimum": 1,
"type": "integer"
},
"readBanner": {
"type": "boolean"
}
}
}
}
} }
] ]
} }

View File

@@ -27,6 +27,7 @@ targets:
- id: "baidu-home" - id: "baidu-home"
name: "Baidu 首页可用" name: "Baidu 首页可用"
description: "监控百度首页的可用性和响应时间"
type: http type: http
group: "搜索引擎" group: "搜索引擎"
http: http:
@@ -144,3 +145,28 @@ targets:
exists: true exists: true
role: role:
contains: "engineer" contains: "engineer"
# ========== TCP targets ==========
- id: "redis-port"
name: "Redis 端口可达"
type: tcp
group: "基础设施"
tcp:
host: "127.0.0.1"
port: 6379
expect:
maxDurationMs: 3000
- id: "smtp-banner"
name: "SMTP Banner 探测"
type: tcp
group: "基础设施"
tcp:
host: "127.0.0.1"
port: 25
readBanner: true
bannerReadTimeout: 3000
expect:
banner:
contains: "ESMTP"

View File

@@ -144,6 +144,7 @@ function resolveTarget(
result.intervalMs = intervalMs; result.intervalMs = intervalMs;
result.timeoutMs = timeoutMs; result.timeoutMs = timeoutMs;
result.description = target.description ?? null;
return result; return result;
} }
@@ -178,6 +179,10 @@ function validateConfig(config: RawProbeConfig): ConfigValidationIssue[] {
const nameValue: unknown = raw["name"]; const nameValue: unknown = raw["name"];
const name = isString(nameValue) ? nameValue : id; const name = isString(nameValue) ? nameValue : id;
if (isString(nameValue) && nameValue.trim() === "") {
issues.push(issue("invalid-value", `targets[${i}].name`, "name 不能为空白", name));
}
const type: unknown = raw["type"]; const type: unknown = raw["type"];
if (!isString(type)) { if (!isString(type)) {
issues.push(issue("required", `targets[${i}].type`, "缺少 type 字段", name)); issues.push(issue("required", `targets[${i}].type`, "缺少 type 字段", name));

View File

@@ -187,11 +187,12 @@ export class CommandChecker implements CheckerDefinition<ResolvedCommandTarget>
exec: t.cmd.exec, exec: t.cmd.exec,
maxOutputBytes, maxOutputBytes,
}, },
description: null,
expect: target.expect as CommandExpectConfig | undefined, expect: target.expect as CommandExpectConfig | undefined,
group: target.group ?? "default", group: target.group ?? "default",
id: t.id, id: t.id,
intervalMs: context.defaultIntervalMs, intervalMs: context.defaultIntervalMs,
name: t.name ?? t.id, name: t.name ?? null,
timeoutMs: context.defaultTimeoutMs, timeoutMs: context.defaultTimeoutMs,
type: "cmd", type: "cmd",
} satisfies ResolvedCommandTarget; } satisfies ResolvedCommandTarget;

View File

@@ -33,7 +33,7 @@ export interface ResolvedCommandTarget extends ResolvedTargetBase {
expect?: CommandExpectConfig; expect?: CommandExpectConfig;
group: string; group: string;
intervalMs: number; intervalMs: number;
name: string; name: null | string;
timeoutMs: number; timeoutMs: number;
type: "cmd"; type: "cmd";
} }

View File

@@ -180,11 +180,12 @@ export class DbChecker implements CheckerDefinition<ResolvedDbTarget> {
query: t.db.query, query: t.db.query,
url: t.db.url, url: t.db.url,
}, },
description: null,
expect: target.expect as DbExpectConfig | undefined, expect: target.expect as DbExpectConfig | undefined,
group: target.group ?? "default", group: target.group ?? "default",
id: t.id, id: t.id,
intervalMs: context.defaultIntervalMs, intervalMs: context.defaultIntervalMs,
name: t.name ?? t.id, name: t.name ?? null,
timeoutMs: context.defaultTimeoutMs, timeoutMs: context.defaultTimeoutMs,
type: "db", type: "db",
} satisfies ResolvedDbTarget; } satisfies ResolvedDbTarget;

View File

@@ -21,7 +21,7 @@ export interface ResolvedDbTarget extends ResolvedTargetBase {
expect?: DbExpectConfig; expect?: DbExpectConfig;
group: string; group: string;
intervalMs: number; intervalMs: number;
name: string; name: null | string;
timeoutMs: number; timeoutMs: number;
type: "db"; type: "db";
} }

View File

@@ -112,6 +112,7 @@ export class HttpChecker implements CheckerDefinition<ResolvedHttpTarget> {
const maxBodyBytes = parseSize(t.http.maxBodyBytes ?? httpDefaults?.maxBodyBytes ?? "100MB"); const maxBodyBytes = parseSize(t.http.maxBodyBytes ?? httpDefaults?.maxBodyBytes ?? "100MB");
return { return {
description: null,
expect: target.expect as HttpExpectConfig | undefined, expect: target.expect as HttpExpectConfig | undefined,
group: target.group ?? "default", group: target.group ?? "default",
http: { http: {
@@ -125,7 +126,7 @@ export class HttpChecker implements CheckerDefinition<ResolvedHttpTarget> {
}, },
id: t.id, id: t.id,
intervalMs: context.defaultIntervalMs, intervalMs: context.defaultIntervalMs,
name: t.name ?? t.id, name: t.name ?? null,
timeoutMs: context.defaultTimeoutMs, timeoutMs: context.defaultTimeoutMs,
type: "http", type: "http",
} satisfies ResolvedHttpTarget; } satisfies ResolvedHttpTarget;

View File

@@ -51,7 +51,7 @@ export interface ResolvedHttpTarget extends ResolvedTargetBase {
group: string; group: string;
http: ResolvedHttpConfig; http: ResolvedHttpConfig;
intervalMs: number; intervalMs: number;
name: string; name: null | string;
timeoutMs: number; timeoutMs: number;
type: "http"; type: "http";
} }

View File

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

View File

@@ -0,0 +1,358 @@
import { isError } from "es-toolkit";
import type { CheckResult, RawTargetConfig } from "../../types";
import type { CheckerContext, CheckerDefinition, CheckerValidationInput, ResolveContext } from "../types";
import type { ResolvedTcpTarget, TcpExpectConfig, TcpTargetConfig } from "./types";
import { checkDuration } from "../../expect/duration";
import { errorFailure } from "../../expect/failure";
import { parseSize } from "../../utils";
import { checkBanner, checkConnected } from "./expect";
import { tcpCheckerSchemas } from "./schema";
import { validateTcpConfig } from "./validate";
const DEFAULT_BANNER_READ_TIMEOUT = 2000;
const DEFAULT_MAX_BANNER_BYTES = 4096;
type ConnectAndBannerResult =
| { banner?: string; bannerExceeded?: boolean; ok: true; socket: { close(): void } }
| { error: string; ok: false };
export class TcpChecker implements CheckerDefinition<ResolvedTcpTarget> {
readonly configKey = "tcp";
readonly schemas = tcpCheckerSchemas;
readonly type = "tcp";
async execute(t: ResolvedTcpTarget, ctx: CheckerContext): Promise<CheckResult> {
const timestamp = new Date().toISOString();
const start = performance.now();
const expect = t.expect;
try {
const connectResult = await connectAndMaybeReadBanner(
t.tcp.host,
t.tcp.port,
t.tcp.readBanner,
t.tcp.bannerReadTimeout,
t.tcp.maxBannerBytes,
ctx.signal,
);
if (!connectResult.ok) {
const durationMs = Math.round(performance.now() - start);
if (expect?.connected === false) {
return {
durationMs,
failure: null,
matched: true,
statusDetail: connectResult.error,
targetId: t.id,
timestamp,
};
}
return {
durationMs,
failure: errorFailure("connect", "connect", connectResult.error),
matched: false,
statusDetail: null,
targetId: t.id,
timestamp,
};
}
const socket = connectResult.socket;
if (ctx.signal.aborted) {
closeSocket(socket);
const durationMs = Math.round(performance.now() - start);
return {
durationMs,
failure: errorFailure("connect", "connect", `连接超时 (${t.timeoutMs}ms)`),
matched: false,
statusDetail: null,
targetId: t.id,
timestamp,
};
}
const expectedConnected = expect?.connected ?? true;
const connectedResult = checkConnected(true, expectedConnected);
if (!connectedResult.matched) {
closeSocket(socket);
const durationMs = Math.round(performance.now() - start);
return {
durationMs,
failure: connectedResult.failure,
matched: false,
statusDetail: "connected",
targetId: t.id,
timestamp,
};
}
if (connectResult.bannerExceeded) {
closeSocket(socket);
const durationMs = Math.round(performance.now() - start);
return {
durationMs,
failure: errorFailure("banner", "banner", `banner 数据超过 ${t.tcp.maxBannerBytes} 字节限制`),
matched: false,
statusDetail: null,
targetId: t.id,
timestamp,
};
}
const banner = connectResult.banner ?? "";
closeSocket(socket);
if (expect?.banner) {
const bannerCheck = checkBanner(banner, expect.banner);
if (!bannerCheck.matched) {
const durationMs = Math.round(performance.now() - start);
return {
durationMs,
failure: bannerCheck.failure,
matched: false,
statusDetail: banner ? truncateBanner(banner) : null,
targetId: t.id,
timestamp,
};
}
}
const durationMs = Math.round(performance.now() - start);
const durationResult = checkDuration(durationMs, expect?.maxDurationMs);
if (!durationResult.matched) {
return {
durationMs,
failure: durationResult.failure,
matched: false,
statusDetail: buildStatusDetail(banner, durationMs),
targetId: t.id,
timestamp,
};
}
return {
durationMs,
failure: null,
matched: true,
statusDetail: buildStatusDetail(banner, durationMs),
targetId: t.id,
timestamp,
};
} catch (error) {
const durationMs = Math.round(performance.now() - start);
return {
durationMs,
failure: errorFailure(
"connect",
"connect",
ctx.signal.aborted ? `连接超时 (${t.timeoutMs}ms)` : isError(error) ? error.message : String(error),
),
matched: false,
statusDetail: null,
targetId: t.id,
timestamp,
};
}
}
resolve(target: RawTargetConfig, context: ResolveContext): ResolvedTcpTarget {
const t = target as RawTargetConfig & { tcp: TcpTargetConfig; type: "tcp" };
const tcpDefaults = context.defaults["tcp"] as
| undefined
| { bannerReadTimeout?: number; maxBannerBytes?: number | string };
const maxBannerBytes = parseSize(t.tcp.maxBannerBytes ?? tcpDefaults?.maxBannerBytes ?? DEFAULT_MAX_BANNER_BYTES);
const bannerReadTimeout = t.tcp.bannerReadTimeout ?? tcpDefaults?.bannerReadTimeout ?? DEFAULT_BANNER_READ_TIMEOUT;
return {
description: null,
expect: target.expect as TcpExpectConfig | undefined,
group: target.group ?? "default",
id: t.id,
intervalMs: context.defaultIntervalMs,
name: t.name ?? null,
tcp: {
bannerReadTimeout,
host: t.tcp.host,
maxBannerBytes,
port: t.tcp.port,
readBanner: t.tcp.readBanner ?? false,
},
timeoutMs: context.defaultTimeoutMs,
type: "tcp",
} satisfies ResolvedTcpTarget;
}
serialize(t: ResolvedTcpTarget): { config: string; target: string } {
return {
config: JSON.stringify({
bannerReadTimeout: t.tcp.bannerReadTimeout,
host: t.tcp.host,
maxBannerBytes: t.tcp.maxBannerBytes,
port: t.tcp.port,
readBanner: t.tcp.readBanner,
}),
target: `${t.tcp.host}:${t.tcp.port}`,
};
}
validate(input: CheckerValidationInput) {
return validateTcpConfig(input);
}
}
function assembleChunks(chunks: Uint8Array[], totalBytes: number): Uint8Array {
const result = new Uint8Array(totalBytes);
let offset = 0;
for (const chunk of chunks) {
result.set(chunk, offset);
offset += chunk.byteLength;
}
return result;
}
function buildStatusDetail(banner: string, durationMs: number): string {
const base = `connected in ${durationMs}ms`;
if (!banner) return base;
return `${base}, banner: ${truncateBanner(banner)}`;
}
function closeSocket(socket: { close(): void }) {
try {
socket.close();
} catch {
/* best-effort close */
}
}
async function connectAndMaybeReadBanner(
hostname: string,
port: number,
readBanner: boolean,
bannerTimeoutMs: number,
maxBannerBytes: number,
signal: AbortSignal,
): Promise<ConnectAndBannerResult> {
const chunks: Uint8Array[] = [];
let totalBytes = 0;
let bannerSettled = false;
let bannerExceeded = false;
let bannerResolve: ((value: void) => void) | undefined;
const bannerPromise = new Promise<void>((resolve) => {
bannerResolve = resolve;
});
const socketHandlers: Record<string, (...args: unknown[]) => void> = {
close() {
if (readBanner && !bannerSettled) {
bannerSettled = true;
bannerResolve!();
}
},
data(_socket: unknown, data: unknown) {
if (!readBanner || bannerSettled) return;
const bytes = data as Uint8Array;
totalBytes += bytes.byteLength;
if (totalBytes > maxBannerBytes) {
bannerSettled = true;
bannerExceeded = true;
bannerResolve!();
return;
}
chunks.push(bytes);
},
drain() {
// Bun socket handler 必填项TCP checker 不关注 drain 事件
},
end() {
if (readBanner && !bannerSettled) {
bannerSettled = true;
bannerResolve!();
}
},
error() {
if (readBanner && !bannerSettled) {
bannerSettled = true;
bannerResolve!();
}
},
open() {
// Bun socket handler 必填项,连接成功由 Bun.connect() resolve 表示
},
};
try {
const socket = await Bun.connect({
hostname,
port,
socket: socketHandlers,
});
if (signal.aborted) {
closeSocket(socket);
return { error: "连接已取消", ok: false };
}
if (!readBanner) {
return { bannerExceeded: false, ok: true, socket };
}
const timer = setTimeout(() => {
if (bannerSettled) return;
bannerSettled = true;
bannerResolve!();
}, bannerTimeoutMs);
const onAbort = () => {
if (bannerSettled) return;
bannerSettled = true;
clearTimeout(timer);
bannerResolve!();
};
if (signal.aborted) {
clearTimeout(timer);
closeSocket(socket);
return { error: "连接已取消", ok: false };
}
signal.addEventListener("abort", onAbort, { once: true });
await bannerPromise;
clearTimeout(timer);
signal.removeEventListener("abort", onAbort);
if (bannerExceeded) {
return { bannerExceeded: true, ok: true, socket };
}
const banner = new TextDecoder().decode(assembleChunks(chunks, totalBytes));
return { banner, bannerExceeded: false, ok: true, socket };
} catch (error) {
if (signal.aborted) {
return { error: "连接超时", ok: false };
}
const message = isError(error) ? error.message : String(error);
return { error: simplifyConnectError(message), ok: false };
}
}
function simplifyConnectError(message: string): string {
const lower = message.toLowerCase();
if (lower.includes("econnrefused") || lower.includes("connection refused")) return "connection refused";
if (lower.includes("enoent") || lower.includes("not found")) return "host not found";
if (lower.includes("etimedout") || lower.includes("timed out")) return "connection timed out";
if (lower.includes("econnreset") || lower.includes("reset")) return "connection reset";
if (lower.includes("enetwork") || lower.includes("network")) return "network error";
return message;
}
function truncateBanner(banner: string, maxLen = 80): string {
if (banner.length <= maxLen) return banner;
return `${banner.slice(0, maxLen)}`;
}

View File

@@ -0,0 +1,30 @@
import type { ExpectResult } from "../../expect/types";
import type { ExpectOperator } from "../../types";
import { mismatchFailure } from "../../expect/failure";
import { applyOperator } from "../../expect/operator";
export function checkBanner(banner: string, op: ExpectOperator): ExpectResult {
const matched = applyOperator(banner, op);
if (!matched) {
return {
failure: mismatchFailure("banner", "banner", op, banner, `banner 不满足条件`),
matched: false,
};
}
return { failure: null, matched: true };
}
export function checkConnected(connected: boolean, expected: boolean): ExpectResult {
if (connected === expected) return { failure: null, matched: true };
if (!connected && expected) {
return {
failure: mismatchFailure("connected", "connected", true, false, "期望端口可达但连接失败"),
matched: false,
};
}
return {
failure: mismatchFailure("connected", "connected", false, true, "期望端口不可达但连接成功"),
matched: false,
};
}

View File

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

View File

@@ -0,0 +1,33 @@
import { Type } from "@sinclair/typebox";
import type { CheckerSchemas } from "../types";
import { createPureOperatorSchema, sizeSchema } from "../../schema/fragments";
export const tcpCheckerSchemas: CheckerSchemas = {
config: Type.Object(
{
bannerReadTimeout: Type.Optional(Type.Number({ minimum: 0 })),
host: Type.String({ minLength: 1 }),
maxBannerBytes: Type.Optional(sizeSchema),
port: Type.Integer({ maximum: 65535, minimum: 1 }),
readBanner: Type.Optional(Type.Boolean()),
},
{ additionalProperties: false },
),
defaults: Type.Object(
{
bannerReadTimeout: Type.Optional(Type.Number({ minimum: 0 })),
maxBannerBytes: Type.Optional(sizeSchema),
},
{ additionalProperties: false },
),
expect: Type.Object(
{
banner: Type.Optional(createPureOperatorSchema()),
connected: Type.Optional(Type.Boolean()),
maxDurationMs: Type.Optional(Type.Number({ minimum: 0 })),
},
{ additionalProperties: false },
),
};

View File

@@ -0,0 +1,38 @@
import type { ExpectOperator, ResolvedTargetBase } from "../../types";
export interface ResolvedTcpConfig {
bannerReadTimeout: number;
host: string;
maxBannerBytes: number;
port: number;
readBanner: boolean;
}
export interface ResolvedTcpTarget extends ResolvedTargetBase {
expect?: TcpExpectConfig;
group: string;
intervalMs: number;
name: null | string;
tcp: ResolvedTcpConfig;
timeoutMs: number;
type: "tcp";
}
export interface TcpDefaultsConfig {
bannerReadTimeout?: number;
maxBannerBytes?: number | string;
}
export interface TcpExpectConfig {
banner?: ExpectOperator;
connected?: boolean;
maxDurationMs?: number;
}
export interface TcpTargetConfig {
bannerReadTimeout?: number;
host: string;
maxBannerBytes?: number | string;
port: number;
readBanner?: boolean;
}

View File

@@ -0,0 +1,163 @@
import { isNumber, isPlainObject, isString } from "es-toolkit";
import type { ConfigValidationIssue } from "../../schema/issues";
import type { CheckerValidationInput } from "../types";
import { validateOperatorObject } from "../../expect/validate-operator";
import { issue, joinPath } from "../../schema/issues";
export function validateTcpConfig(input: CheckerValidationInput): ConfigValidationIssue[] {
const issues: ConfigValidationIssue[] = [];
issues.push(...validateTcpDefaults(input));
for (let i = 0; i < input.targets.length; i++) {
const target = input.targets[i] as unknown;
if (!isPlainObject(target)) continue;
if (target["type"] !== "tcp") continue;
issues.push(...validateTcpTarget(target, `targets[${i}]`));
}
return issues;
}
function getTargetName(target: Record<string, unknown>): string | undefined {
if (isString(target["name"])) return target["name"];
return isString(target["id"]) ? target["id"] : undefined;
}
function isNonNegativeFiniteNumber(value: unknown): boolean {
return isNumber(value) && Number.isFinite(value) && value >= 0;
}
function validateTcpDefaults(input: CheckerValidationInput): ConfigValidationIssue[] {
const issues: ConfigValidationIssue[] = [];
const defaults = input.defaults["tcp"];
if (defaults === undefined || defaults === null || !isPlainObject(defaults)) return issues;
const targetName = "defaults.tcp";
if (defaults["bannerReadTimeout"] !== undefined && !isNonNegativeFiniteNumber(defaults["bannerReadTimeout"])) {
issues.push(issue("invalid-type", "defaults.tcp.bannerReadTimeout", "必须为非负有限数字", targetName));
}
if (defaults["maxBannerBytes"] !== undefined) {
if (
!isString(defaults["maxBannerBytes"]) &&
!(
isNumber(defaults["maxBannerBytes"]) &&
Number.isFinite(defaults["maxBannerBytes"]) &&
defaults["maxBannerBytes"] >= 0
)
) {
issues.push(issue("invalid-value", "defaults.tcp.maxBannerBytes", "必须为合法 size 值", targetName));
}
}
const allowedKeys = new Set(["bannerReadTimeout", "maxBannerBytes"]);
for (const key of Object.keys(defaults)) {
if (!allowedKeys.has(key)) {
issues.push(issue("unknown-field", joinPath("defaults.tcp", key), "是未知字段", targetName));
}
}
return issues;
}
function validateTcpExpect(
target: Record<string, unknown>,
path: string,
readBanner: boolean,
): ConfigValidationIssue[] {
const targetName = getTargetName(target);
const expect = target["expect"];
if (expect === undefined || expect === null || !isPlainObject(expect)) return [];
const issues: ConfigValidationIssue[] = [];
const expectPath = joinPath(path, "expect");
if (expect["connected"] !== undefined && typeof expect["connected"] !== "boolean") {
issues.push(issue("invalid-type", joinPath(expectPath, "connected"), "必须为布尔值", targetName));
}
if (expect["maxDurationMs"] !== undefined && !isNonNegativeFiniteNumber(expect["maxDurationMs"])) {
issues.push(issue("invalid-type", joinPath(expectPath, "maxDurationMs"), "必须为非负有限数字", targetName));
}
if (expect["banner"] !== undefined) {
if (!readBanner) {
issues.push(
issue("invalid-value", joinPath(expectPath, "banner"), "banner 断言需要启用 tcp.readBanner", targetName),
);
} else {
issues.push(...validateOperatorObject(expect["banner"], joinPath(expectPath, "banner"), targetName));
}
}
const allowedKeys = new Set(["banner", "connected", "maxDurationMs"]);
for (const key of Object.keys(expect)) {
if (!allowedKeys.has(key)) {
issues.push(issue("unknown-field", joinPath(expectPath, key), "是未知字段", targetName));
}
}
return issues;
}
function validateTcpTarget(target: Record<string, unknown>, path: string): ConfigValidationIssue[] {
const issues: ConfigValidationIssue[] = [];
const targetName = getTargetName(target);
const tcp = target["tcp"];
if (!isPlainObject(tcp)) {
issues.push(issue("required", joinPath(path, "tcp"), "缺少 tcp 配置分组", targetName));
issues.push(...validateTcpExpect(target, path, false));
return issues;
}
if (!isString(tcp["host"]) || tcp["host"].trim() === "") {
issues.push(issue("required", joinPath(joinPath(path, "tcp"), "host"), "缺少 tcp.host 字段", targetName));
}
if (tcp["port"] === undefined) {
issues.push(issue("required", joinPath(joinPath(path, "tcp"), "port"), "缺少 tcp.port 字段", targetName));
} else if (!isNumber(tcp["port"]) || !Number.isInteger(tcp["port"]) || tcp["port"] < 1 || tcp["port"] > 65535) {
issues.push(
issue("invalid-value", joinPath(joinPath(path, "tcp"), "port"), "必须为 1-65535 之间的整数", targetName),
);
}
if (tcp["readBanner"] !== undefined && typeof tcp["readBanner"] !== "boolean") {
issues.push(issue("invalid-type", joinPath(joinPath(path, "tcp"), "readBanner"), "必须为布尔值", targetName));
}
if (tcp["bannerReadTimeout"] !== undefined && !isNonNegativeFiniteNumber(tcp["bannerReadTimeout"])) {
issues.push(
issue("invalid-type", joinPath(joinPath(path, "tcp"), "bannerReadTimeout"), "必须为非负有限数字", targetName),
);
}
if (tcp["maxBannerBytes"] !== undefined) {
if (
!isString(tcp["maxBannerBytes"]) &&
!(isNumber(tcp["maxBannerBytes"]) && Number.isFinite(tcp["maxBannerBytes"]) && tcp["maxBannerBytes"] >= 0)
) {
issues.push(
issue("invalid-value", joinPath(joinPath(path, "tcp"), "maxBannerBytes"), "必须为合法 size 值", targetName),
);
}
}
const allowedTcpKeys = new Set(["bannerReadTimeout", "host", "maxBannerBytes", "port", "readBanner"]);
for (const key of Object.keys(tcp)) {
if (!allowedTcpKeys.has(key)) {
issues.push(issue("unknown-field", joinPath(joinPath(path, "tcp"), key), "是未知字段", targetName));
}
}
const readBanner = tcp["readBanner"] === true;
issues.push(...validateTcpExpect(target, path, readBanner));
return issues;
}
export { validateTcpDefaults };

View File

@@ -49,11 +49,12 @@ export function createProbeConfigSchema(checkers: CheckerDefinition[], external
export function createTargetSchema(checker: CheckerDefinition): TSchema { export function createTargetSchema(checker: CheckerDefinition): TSchema {
const properties: Record<string, TSchema> = { const properties: Record<string, TSchema> = {
description: Type.Optional(Type.Union([Type.Null(), Type.String({ maxLength: 500 })])),
expect: Type.Optional(checker.schemas.expect), expect: Type.Optional(checker.schemas.expect),
group: Type.Optional(Type.String()), group: Type.Optional(Type.String()),
id: Type.String({ minLength: 1 }), id: Type.String({ maxLength: 30, minLength: 1 }),
interval: Type.Optional(durationSchema), interval: Type.Optional(durationSchema),
name: Type.Optional(Type.String({ minLength: 1 })), name: Type.Optional(Type.Union([Type.Null(), Type.String({ maxLength: 30, minLength: 1 })])),
timeout: Type.Optional(durationSchema), timeout: Type.Optional(durationSchema),
type: Type.Literal(checker.type), type: Type.Literal(checker.type),
}; };
@@ -68,10 +69,11 @@ function cloneSchema(schema: TSchema): Record<string, unknown> {
function createBaseTargetSchema(checkers: CheckerDefinition[]): TSchema { function createBaseTargetSchema(checkers: CheckerDefinition[]): TSchema {
return Type.Object( return Type.Object(
{ {
description: Type.Optional(Type.Union([Type.Null(), Type.String({ maxLength: 500 })])),
group: Type.Optional(Type.String()), group: Type.Optional(Type.String()),
id: Type.String({ minLength: 1 }), id: Type.String({ maxLength: 30, minLength: 1 }),
interval: Type.Optional(durationSchema), interval: Type.Optional(durationSchema),
name: Type.Optional(Type.String({ minLength: 1 })), name: Type.Optional(Type.Union([Type.Null(), Type.String({ maxLength: 30, minLength: 1 })])),
timeout: Type.Optional(durationSchema), timeout: Type.Optional(durationSchema),
type: Type.Union(checkers.map((checker) => Type.Literal(checker.type)) as unknown as [TSchema, ...TSchema[]]), type: Type.Union(checkers.map((checker) => Type.Literal(checker.type)) as unknown as [TSchema, ...TSchema[]]),
}, },

View File

@@ -9,7 +9,8 @@ import { checkerRegistry } from "./runner";
const CREATE_TARGETS_TABLE = ` const CREATE_TARGETS_TABLE = `
CREATE TABLE IF NOT EXISTS targets ( CREATE TABLE IF NOT EXISTS targets (
id TEXT PRIMARY KEY, id TEXT PRIMARY KEY,
name TEXT NOT NULL, name TEXT,
description TEXT,
type TEXT NOT NULL, type TEXT NOT NULL,
target TEXT NOT NULL, target TEXT NOT NULL,
config TEXT NOT NULL DEFAULT '{}', config TEXT NOT NULL DEFAULT '{}',
@@ -308,18 +309,15 @@ export class ProbeStore {
syncTargets(targets: ResolvedTargetBase[]): void { syncTargets(targets: ResolvedTargetBase[]): void {
if (this.closed) return; if (this.closed) return;
const existingRows = this.db.query("SELECT id FROM targets").all() as Array<{ const existingRows = this.db.query("SELECT id FROM targets").all() as Array<{ id: string }>;
id: string;
name: string;
}>;
const existingIds = new Set(existingRows.map((r) => r.id)); const existingIds = new Set(existingRows.map((r) => r.id));
const configIds = new Set(targets.map((t) => t.id)); const configIds = new Set(targets.map((t) => t.id));
const insertStmt = this.db.prepare( const insertStmt = this.db.prepare(
"INSERT INTO targets (id, name, type, target, config, interval_ms, timeout_ms, expect, grp) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)", "INSERT INTO targets (id, name, description, type, target, config, interval_ms, timeout_ms, expect, grp) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)",
); );
const updateStmt = this.db.prepare( const updateStmt = this.db.prepare(
"UPDATE targets SET name = ?, type = ?, target = ?, config = ?, interval_ms = ?, timeout_ms = ?, expect = ?, grp = ? WHERE id = ?", "UPDATE targets SET name = ?, description = ?, type = ?, target = ?, config = ?, interval_ms = ?, timeout_ms = ?, expect = ?, grp = ? WHERE id = ?",
); );
const deleteStmt = this.db.prepare("DELETE FROM targets WHERE id = ?"); const deleteStmt = this.db.prepare("DELETE FROM targets WHERE id = ?");
@@ -332,9 +330,9 @@ export class ProbeStore {
const expect = t.expect ? JSON.stringify(t.expect) : null; const expect = t.expect ? JSON.stringify(t.expect) : null;
if (existingIds.has(t.id)) { if (existingIds.has(t.id)) {
updateStmt.run(t.name, type, target, config, t.intervalMs, t.timeoutMs, expect, t.group, t.id); updateStmt.run(t.name, t.description, type, target, config, t.intervalMs, t.timeoutMs, expect, t.group, t.id);
} else { } else {
insertStmt.run(t.id, t.name, type, target, config, t.intervalMs, t.timeoutMs, expect, t.group); insertStmt.run(t.id, t.name, t.description, type, target, config, t.intervalMs, t.timeoutMs, expect, t.group);
} }
} }

View File

@@ -41,22 +41,24 @@ export interface ProbeConfig {
export interface RawTargetConfig { export interface RawTargetConfig {
[configKey: string]: unknown; [configKey: string]: unknown;
description?: null | string;
expect?: unknown; expect?: unknown;
group?: string; group?: string;
id: string; id: string;
interval?: string; interval?: string;
name?: string; name?: null | string;
timeout?: string; timeout?: string;
type: string; type: string;
} }
export interface ResolvedTargetBase { export interface ResolvedTargetBase {
[key: string]: unknown; [key: string]: unknown;
description: null | string;
expect?: unknown; expect?: unknown;
group: string; group: string;
id: string; id: string;
intervalMs: number; intervalMs: number;
name: string; name: null | string;
timeoutMs: number; timeoutMs: number;
type: string; type: string;
} }
@@ -79,11 +81,12 @@ export interface StoredCheckResult {
export interface StoredTarget { export interface StoredTarget {
config: string; config: string;
description: null | string;
expect: null | string; expect: null | string;
grp: string; grp: string;
id: string; id: string;
interval_ms: number; interval_ms: number;
name: string; name: null | string;
target: string; target: string;
timeout_ms: number; timeout_ms: number;
type: string; type: string;

View File

@@ -56,6 +56,7 @@ export function handleDashboard(url: URL, store: ProbeStore, mode: RuntimeMode):
return { return {
currentStreak, currentStreak,
description: target.description,
group: target.grp, group: target.grp,
id: target.id, id: target.id,
interval: formatDuration(target.interval_ms), interval: formatDuration(target.interval_ms),

View File

@@ -99,11 +99,12 @@ export interface TargetStats {
export interface TargetStatus { export interface TargetStatus {
currentStreak: CurrentStreak | null; currentStreak: CurrentStreak | null;
description: null | string;
group: string; group: string;
id: string; id: string;
interval: string; interval: string;
latestCheck: CheckResult | null; latestCheck: CheckResult | null;
name: string; name: null | string;
recentSamples: RecentSample[]; recentSamples: RecentSample[];
stats: TargetStats; stats: TargetStats;
target: string; target: string;

View File

@@ -38,6 +38,7 @@ export function OverviewTab({ metricsData, metricsLoading, target }: OverviewTab
label: "最新检查时间", label: "最新检查时间",
}, },
{ content: target.latestCheck?.statusDetail ?? "-", label: "状态详情" }, { content: target.latestCheck?.statusDetail ?? "-", label: "状态详情" },
{ content: target.description ?? "", label: "描述", span: 2 },
]} ]}
/> />

View File

@@ -5,6 +5,7 @@ import { DateRangePicker, Drawer, RadioGroup, Space, Tabs, Tag, Typography } fro
import type { HistoryResponse, TargetMetricsResponse, TargetStatus } from "../../shared/api"; import type { HistoryResponse, TargetMetricsResponse, TargetStatus } from "../../shared/api";
import { getTargetDisplayName } from "../utils/target";
import { subtractHours } from "../utils/time"; import { subtractHours } from "../utils/time";
import { HistoryTab } from "./HistoryTab"; import { HistoryTab } from "./HistoryTab";
import { OverviewTab } from "./OverviewTab"; import { OverviewTab } from "./OverviewTab";
@@ -90,7 +91,7 @@ export function TargetDetailDrawer({
target ? ( target ? (
<Space align="center" size={12}> <Space align="center" size={12}>
<StatusDot up={!!isUp} /> <StatusDot up={!!isUp} />
<Typography.Text strong>{target.name}</Typography.Text> <Typography.Text strong>{getTargetDisplayName(target)}</Typography.Text>
<Tag size="small" theme="primary" variant="light-outline"> <Tag size="small" theme="primary" variant="light-outline">
{target.type} {target.type}
</Tag> </Tag>

View File

@@ -6,9 +6,10 @@ import type { TargetStatus } from "../../shared/api";
import { StatusBar } from "../components/StatusBar"; import { StatusBar } from "../components/StatusBar";
import { StatusDot } from "../components/StatusDot"; import { StatusDot } from "../components/StatusDot";
import { getTargetDisplayName } from "../utils/target";
import { getAvailabilityProgressColor } from "./color-threshold"; import { getAvailabilityProgressColor } from "./color-threshold";
import { statusFilter } from "./target-table-filters"; import { statusFilter } from "./target-table-filters";
import { availabilitySorter, latencySorter, nameSorter } from "./target-table-sorters"; import { availabilitySorter, latencySorter } from "./target-table-sorters";
export function createTargetTableColumns(checkerTypes: string[]): Array<PrimaryTableCol<TargetStatus>> { export function createTargetTableColumns(checkerTypes: string[]): Array<PrimaryTableCol<TargetStatus>> {
return [ return [
@@ -22,10 +23,9 @@ export function createTargetTableColumns(checkerTypes: string[]): Array<PrimaryT
width: 60, width: 60,
}, },
{ {
cell: ({ row }: PrimaryTableCellParams<TargetStatus>) => getTargetDisplayName(row),
colKey: "name", colKey: "name",
ellipsis: true, ellipsis: true,
sorter: nameSorter,
sortType: "all",
title: "名称", title: "名称",
}, },
{ {
@@ -88,13 +88,13 @@ export function createTargetTableColumns(checkerTypes: string[]): Array<PrimaryT
const ms = row.latestCheck?.durationMs; const ms = row.latestCheck?.durationMs;
if (ms === null || ms === undefined) return <span className="text-disabled">-</span>; if (ms === null || ms === undefined) return <span className="text-disabled">-</span>;
const colorClass = ms <= 100 ? "latency-ok" : ms <= 500 ? "latency-warn" : "latency-error"; const colorClass = ms <= 100 ? "latency-ok" : ms <= 500 ? "latency-warn" : "latency-error";
const latencyText = ms > 9999 ? "9999+ms" : `${Math.round(ms)}ms`; 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}</span>;
}, },
colKey: "latestCheck.durationMs", colKey: "latestCheck.durationMs",
sorter: latencySorter, sorter: latencySorter,
sortType: "all", sortType: "all",
title: "延迟", title: "延迟(ms)",
width: 75, width: 75,
}, },
]; ];

View File

@@ -13,10 +13,6 @@ export function latencySorter(a: TargetStatus, b: TargetStatus): number {
return (a.latestCheck?.durationMs ?? Infinity) - (b.latestCheck?.durationMs ?? Infinity); return (a.latestCheck?.durationMs ?? Infinity) - (b.latestCheck?.durationMs ?? Infinity);
} }
export function nameSorter(a: TargetStatus, b: TargetStatus): number {
return a.name.localeCompare(b.name, "zh-CN");
}
export function statusSorter(a: TargetStatus, b: TargetStatus): number { export function statusSorter(a: TargetStatus, b: TargetStatus): number {
return getStatusRank(a) - getStatusRank(b); return getStatusRank(a) - getStatusRank(b);
} }

5
src/web/utils/target.ts Normal file
View File

@@ -0,0 +1,5 @@
import type { TargetStatus } from "../../shared/api";
export function getTargetDisplayName(target: TargetStatus): string {
return target.name ?? target.id;
}

View File

@@ -41,6 +41,7 @@ describe("API 路由", () => {
store = new ProbeStore(join(tempDir, "test.db")); store = new ProbeStore(join(tempDir, "test.db"));
store.syncTargets([ store.syncTargets([
{ {
description: null,
group: "default", group: "default",
http: { http: {
headers: {}, headers: {},
@@ -64,6 +65,7 @@ describe("API 路由", () => {
exec: "echo", exec: "echo",
maxOutputBytes: 104857600, maxOutputBytes: 104857600,
}, },
description: null,
group: "default", group: "default",
id: "test-b", id: "test-b",
intervalMs: 60000, intervalMs: 60000,

View File

@@ -11,6 +11,7 @@ import { bootstrap, type BootstrapDependencies } from "../../src/server/bootstra
type ShutdownSignal = "SIGINT" | "SIGTERM"; type ShutdownSignal = "SIGINT" | "SIGTERM";
const target: ResolvedTargetBase = { const target: ResolvedTargetBase = {
description: null,
group: "default", group: "default",
id: "test", id: "test",
intervalMs: 30000, intervalMs: 30000,

View File

@@ -5,6 +5,7 @@ import { join } from "node:path";
import type { ResolvedCommandTarget } from "../../../src/server/checker/runner/cmd/types"; import type { ResolvedCommandTarget } from "../../../src/server/checker/runner/cmd/types";
import type { ResolvedHttpTarget } from "../../../src/server/checker/runner/http/types"; import type { ResolvedHttpTarget } from "../../../src/server/checker/runner/http/types";
import type { ResolvedTcpTarget } from "../../../src/server/checker/runner/tcp/types";
import { loadConfig, parseDuration } from "../../../src/server/checker/config-loader"; import { loadConfig, parseDuration } from "../../../src/server/checker/config-loader";
import { checkerRegistry } from "../../../src/server/checker/runner"; import { checkerRegistry } from "../../../src/server/checker/runner";
@@ -234,7 +235,7 @@ targets:
expect(cmd.cmd.maxOutputBytes).toBe(10485760); expect(cmd.cmd.maxOutputBytes).toBe(10485760);
}); });
test("name 缺省时 fallback 到 id", async () => { test("name 缺省时保留为 null", async () => {
const configPath = join(tempDir, "name-fallback.yaml"); const configPath = join(tempDir, "name-fallback.yaml");
await writeFile( await writeFile(
configPath, configPath,
@@ -249,7 +250,105 @@ targets:
const config = await loadConfig(configPath); const config = await loadConfig(configPath);
const target = config.targets[0]!; const target = config.targets[0]!;
expect(target.id).toBe("api-health"); expect(target.id).toBe("api-health");
expect(target.name).toBe("api-health"); expect(target.name).toBeNull();
});
test("name 显式 null 保留为 null", async () => {
const configPath = join(tempDir, "name-explicit-null.yaml");
await writeFile(
configPath,
`targets:
- id: "api-health"
name: null
type: http
http:
url: "http://example.com"
`,
);
const config = await loadConfig(configPath);
expect(config.targets[0]!.name).toBeNull();
});
test("name YAML 空值保留为 null", async () => {
const configPath = join(tempDir, "name-yaml-null.yaml");
await writeFile(
configPath,
`targets:
- id: "api-health"
name:
type: http
http:
url: "http://example.com"
`,
);
const config = await loadConfig(configPath);
expect(config.targets[0]!.name).toBeNull();
});
test("name 为空字符串抛出错误", async () => {
const configPath = join(tempDir, "empty-name.yaml");
await writeFile(
configPath,
`targets:
- id: "api-health"
name: ""
type: http
http:
url: "http://example.com"
`,
);
// eslint-disable-next-line @typescript-eslint/await-thenable
await expect(loadConfig(configPath)).rejects.toThrow("name 不能为空白");
});
test("name 仅包含空白字符抛出错误", async () => {
const configPath = join(tempDir, "whitespace-name.yaml");
await writeFile(
configPath,
`targets:
- id: "api-health"
name: " "
type: http
http:
url: "http://example.com"
`,
);
// eslint-disable-next-line @typescript-eslint/await-thenable
await expect(loadConfig(configPath)).rejects.toThrow("name 不能为空白");
});
test("description 显式 null 保留为 null", async () => {
const configPath = join(tempDir, "description-null.yaml");
await writeFile(
configPath,
`targets:
- id: "api-health"
description: null
type: http
http:
url: "http://example.com"
`,
);
const config = await loadConfig(configPath);
expect(config.targets[0]!.description).toBeNull();
});
test("description YAML 空值保留为 null", async () => {
const configPath = join(tempDir, "description-yaml-null.yaml");
await writeFile(
configPath,
`targets:
- id: "api-health"
description:
type: http
http:
url: "http://example.com"
`,
);
const config = await loadConfig(configPath);
expect(config.targets[0]!.description).toBeNull();
}); });
test("name 支持变量替换且不要求唯一", async () => { test("name 支持变量替换且不要求唯一", async () => {
@@ -1534,4 +1633,345 @@ targets:
"无效的时长格式", "无效的时长格式",
); );
}); });
test("解析 description 字段", async () => {
const configPath = join(tempDir, "description.yaml");
await writeFile(
configPath,
`targets:
- id: "api-health"
description: "检查生产 API 健康状态"
type: http
http:
url: "http://example.com"
`,
);
const config = await loadConfig(configPath);
expect(config.targets[0]!.description).toBe("检查生产 API 健康状态");
});
test("description 使用变量替换", async () => {
const configPath = join(tempDir, "description-var.yaml");
await writeFile(
configPath,
`variables:
env: "生产"
targets:
- id: "api-health"
description: "\${env} 环境健康检查"
type: http
http:
url: "http://example.com"
`,
);
const config = await loadConfig(configPath);
expect(config.targets[0]!.description).toBe("生产 环境健康检查");
});
test("description 缺省为 null", async () => {
const configPath = join(tempDir, "no-description.yaml");
await writeFile(
configPath,
`targets:
- id: "api-health"
type: http
http:
url: "http://example.com"
`,
);
const config = await loadConfig(configPath);
expect(config.targets[0]!.description).toBeNull();
});
test("description 为空字符串通过", async () => {
const configPath = join(tempDir, "empty-description.yaml");
await writeFile(
configPath,
`targets:
- id: "api-health"
description: ""
type: http
http:
url: "http://example.com"
`,
);
const config = await loadConfig(configPath);
expect(config.targets[0]!.description).toBe("");
});
test("description 非字符串抛出错误", async () => {
const configPath = join(tempDir, "bad-description-type.yaml");
await writeFile(
configPath,
`targets:
- id: "api-health"
description: 123
type: http
http:
url: "http://example.com"
`,
);
// eslint-disable-next-line @typescript-eslint/await-thenable
await expect(loadConfig(configPath)).rejects.toThrow("description");
});
test("description 超过 500 字符抛出错误", async () => {
const configPath = join(tempDir, "long-description.yaml");
await writeFile(
configPath,
`targets:
- id: "api-health"
description: "${"a".repeat(501)}"
type: http
http:
url: "http://example.com"
`,
);
// eslint-disable-next-line @typescript-eslint/await-thenable
await expect(loadConfig(configPath)).rejects.toThrow("description");
});
test("变量替换后 description 超长抛出错误", async () => {
const configPath = join(tempDir, "var-long-description.yaml");
await writeFile(
configPath,
`variables:
prefix: "${"x".repeat(490)}"
targets:
- id: "api-health"
description: "\${prefix}${"a".repeat(15)}"
type: http
http:
url: "http://example.com"
`,
);
// eslint-disable-next-line @typescript-eslint/await-thenable
await expect(loadConfig(configPath)).rejects.toThrow("description");
});
test("id 超过 30 字符抛出错误", async () => {
const configPath = join(tempDir, "long-id.yaml");
await writeFile(
configPath,
`targets:
- id: "${"a".repeat(31)}"
type: http
http:
url: "http://example.com"
`,
);
// eslint-disable-next-line @typescript-eslint/await-thenable
await expect(loadConfig(configPath)).rejects.toThrow("id");
});
test("name 超过 30 字符抛出错误", async () => {
const configPath = join(tempDir, "long-name.yaml");
await writeFile(
configPath,
`targets:
- id: "test"
name: "${"a".repeat(31)}"
type: http
http:
url: "http://example.com"
`,
);
// eslint-disable-next-line @typescript-eslint/await-thenable
await expect(loadConfig(configPath)).rejects.toThrow("name");
});
test("解析最简 tcp 配置", async () => {
const configPath = join(tempDir, "minimal-tcp.yaml");
await writeFile(
configPath,
`targets:
- id: "redis-port"
type: tcp
tcp:
host: "127.0.0.1"
port: 6379
`,
);
const config = await loadConfig(configPath);
expect(config.targets).toHaveLength(1);
const t = config.targets[0]! as ResolvedTcpTarget;
expect(t.type).toBe("tcp");
expect(t.id).toBe("redis-port");
expect(t.name).toBeNull();
expect(t.tcp.host).toBe("127.0.0.1");
expect(t.tcp.port).toBe(6379);
expect(t.tcp.readBanner).toBe(false);
expect(t.tcp.bannerReadTimeout).toBe(2000);
expect(t.tcp.maxBannerBytes).toBe(4096);
expect(t.group).toBe("default");
expect(t.intervalMs).toBe(30000);
expect(t.timeoutMs).toBe(10000);
});
test("tcp 缺少 host 抛出错误", async () => {
await expectConfigError(
"tcp-no-host.yaml",
`targets:
- id: "t"
type: tcp
tcp:
port: 80
`,
"tcp.host",
);
});
test("tcp 缺少 port 抛出错误", async () => {
await expectConfigError(
"tcp-no-port.yaml",
`targets:
- id: "t"
type: tcp
tcp:
host: "127.0.0.1"
`,
"tcp.port",
);
});
test("tcp 非法端口范围抛出错误", async () => {
await expectConfigError(
"tcp-bad-port.yaml",
`targets:
- id: "t"
type: tcp
tcp:
host: "127.0.0.1"
port: 99999
`,
"tcp.port",
);
});
test("tcp 未知分组字段抛出错误", async () => {
await expectConfigError(
"tcp-unknown-field.yaml",
`targets:
- id: "t"
type: tcp
tcp:
host: "127.0.0.1"
port: 80
tls: true
`,
"是未知字段",
);
});
test("tcp readBanner 开启并配置 expect.banner", async () => {
const configPath = join(tempDir, "tcp-banner.yaml");
await writeFile(
configPath,
`targets:
- id: "smtp-check"
type: tcp
tcp:
host: "127.0.0.1"
port: 25
readBanner: true
expect:
banner:
contains: "ESMTP"
`,
);
const config = await loadConfig(configPath);
const t = config.targets[0]! as ResolvedTcpTarget;
expect(t.tcp.readBanner).toBe(true);
expect(t.expect?.banner).toEqual({ contains: "ESMTP" });
});
test("tcp expect.banner 未开启 readBanner 抛出错误", async () => {
await expectConfigError(
"tcp-banner-no-read.yaml",
`targets:
- id: "t"
type: tcp
tcp:
host: "127.0.0.1"
port: 25
expect:
banner:
contains: "ESMTP"
`,
"banner 断言需要启用 tcp.readBanner",
);
});
test("tcp defaults 覆盖 banner 参数", async () => {
const configPath = join(tempDir, "tcp-defaults.yaml");
await writeFile(
configPath,
`defaults:
tcp:
bannerReadTimeout: 1000
maxBannerBytes: "8KB"
targets:
- id: "t1"
type: tcp
tcp:
host: "127.0.0.1"
port: 80
- id: "t2"
type: tcp
tcp:
host: "127.0.0.1"
port: 81
bannerReadTimeout: 3000
`,
);
const config = await loadConfig(configPath);
const t1 = config.targets[0]! as ResolvedTcpTarget;
expect(t1.tcp.bannerReadTimeout).toBe(1000);
expect(t1.tcp.maxBannerBytes).toBe(8192);
const t2 = config.targets[1]! as ResolvedTcpTarget;
expect(t2.tcp.bannerReadTimeout).toBe(3000);
expect(t2.tcp.maxBannerBytes).toBe(8192);
});
test("tcp expect 未知字段抛出错误", async () => {
await expectConfigError(
"tcp-unknown-expect.yaml",
`targets:
- id: "t"
type: tcp
tcp:
host: "127.0.0.1"
port: 80
expect:
status: [200]
`,
"是未知字段",
);
});
test("tcp expect connected 和 maxDurationMs", async () => {
const configPath = join(tempDir, "tcp-expect-connected.yaml");
await writeFile(
configPath,
`targets:
- id: "t"
type: tcp
tcp:
host: "127.0.0.1"
port: 80
expect:
connected: false
maxDurationMs: 5000
`,
);
const config = await loadConfig(configPath);
const t = config.targets[0]! as ResolvedTcpTarget;
expect(t.expect?.connected).toBe(false);
expect(t.expect?.maxDurationMs).toBe(5000);
});
}); });

View File

@@ -55,6 +55,7 @@ function makeCommandTarget(name: string, overrides?: Partial<ResolvedCommandTarg
exec: "bun", exec: "bun",
maxOutputBytes: 1024 * 1024, maxOutputBytes: 1024 * 1024,
}, },
description: null,
group: "default", group: "default",
id: name, id: name,
intervalMs: 60000, intervalMs: 60000,
@@ -208,7 +209,7 @@ describe("ProbeEngine", () => {
}), }),
); );
const mockStore = createMockStore(targets.map((t) => t.name)) as unknown as ProbeStore; const mockStore = createMockStore(targets.map((t) => t.name ?? t.id)) as unknown as ProbeStore;
const engine = new ProbeEngine(mockStore, targets, 2); const engine = new ProbeEngine(mockStore, targets, 2);
const probeGroup = ( const probeGroup = (
@@ -259,6 +260,7 @@ describe("ProbeEngine", () => {
try { try {
const httpTarget: ResolvedHttpTarget = { const httpTarget: ResolvedHttpTarget = {
description: null,
group: "default", group: "default",
http: { http: {
headers: {}, headers: {},

View File

@@ -30,6 +30,7 @@ function makeTarget(
maxOutputBytes: 1024 * 1024, maxOutputBytes: 1024 * 1024,
...cmd, ...cmd,
}, },
description: null,
group: "default", group: "default",
id: "test-cmd", id: "test-cmd",
intervalMs: 60000, intervalMs: 60000,

View File

@@ -19,6 +19,7 @@ function makeTarget(db: Partial<ResolvedDbTarget["db"]>, overrides?: Partial<Res
url: "sqlite://:memory:", url: "sqlite://:memory:",
...db, ...db,
}, },
description: null,
group: "default", group: "default",
id: "test-db", id: "test-db",
intervalMs: 60000, intervalMs: 60000,

View File

@@ -155,6 +155,7 @@ describe("HttpChecker", () => {
url?: string; url?: string;
}): ResolvedHttpTarget { }): ResolvedHttpTarget {
return { return {
description: null,
expect: overrides.expect, expect: overrides.expect,
group: "default", group: "default",
http: { http: {

View File

@@ -66,8 +66,8 @@ describe("CheckerRegistry", () => {
const second = createDefaultCheckerRegistry(); const second = createDefaultCheckerRegistry();
first.register(createChecker("custom")); first.register(createChecker("custom"));
expect(first.supportedTypes).toEqual(["http", "cmd", "db", "custom"]); expect(first.supportedTypes).toEqual(["http", "cmd", "db", "tcp", "custom"]);
expect(second.supportedTypes).toEqual(["http", "cmd", "db"]); expect(second.supportedTypes).toEqual(["http", "cmd", "db", "tcp"]);
expect( expect(
first.definitions.every( first.definitions.every(
(checker) => checker.schemas.config && checker.schemas.defaults && checker.schemas.expect, (checker) => checker.schemas.config && checker.schemas.defaults && checker.schemas.expect,

View File

@@ -0,0 +1,367 @@
import { afterAll, beforeAll, describe, expect, test } from "bun:test";
import type { ResolvedTcpTarget } from "../../../../../src/server/checker/runner/tcp/types";
import type { CheckerContext } from "../../../../../src/server/checker/runner/types";
import { TcpChecker } from "../../../../../src/server/checker/runner/tcp/execute";
const checker = new TcpChecker();
let server: Bun.TCPSocketListener;
let serverPort: number;
let bannerServer: Bun.TCPSocketListener;
let bannerServerPort: number;
let largeBannerServer: Bun.TCPSocketListener;
let largeBannerServerPort: number;
function makeCtx(timeoutMs = 5000): CheckerContext {
const controller = new AbortController();
setTimeout(() => controller.abort(), timeoutMs);
return { signal: controller.signal };
}
function makeTarget(tcp: Partial<ResolvedTcpTarget["tcp"]>, overrides?: Partial<ResolvedTcpTarget>): ResolvedTcpTarget {
return {
description: null,
group: "default",
id: "test-tcp",
intervalMs: 60000,
name: "test-tcp",
tcp: {
bannerReadTimeout: 2000,
host: "127.0.0.1",
maxBannerBytes: 4096,
port: serverPort,
readBanner: false,
...tcp,
},
timeoutMs: 5000,
type: "tcp",
...overrides,
};
}
beforeAll(() => {
server = Bun.listen({
hostname: "127.0.0.1",
port: 0,
socket: {
data() {
// Bun.listen 必填 handlerecho server 不处理数据
},
end(socket) {
try {
socket.close();
} catch {
// best-effort 关闭
}
},
error() {
// Bun.listen 必填 handler测试 server 忽略错误
},
open() {
// Bun.listen 必填 handleropen 时无需操作
},
},
});
serverPort = server.port;
bannerServer = Bun.listen({
hostname: "127.0.0.1",
port: 0,
socket: {
data() {
// Bun.listen 必填 handler
},
end(socket) {
try {
socket.close();
} catch {
// best-effort 关闭
}
},
error() {
// Bun.listen 必填 handler
},
open(socket) {
socket.write("220 smtp.example.com ESMTP\r\n");
},
},
});
bannerServerPort = bannerServer.port;
largeBannerServer = Bun.listen({
hostname: "127.0.0.1",
port: 0,
socket: {
data() {
// Bun.listen 必填 handler
},
end(socket) {
try {
socket.close();
} catch {
// best-effort 关闭
}
},
error() {
// Bun.listen 必填 handler
},
open(socket) {
socket.write("X".repeat(8192));
},
},
});
largeBannerServerPort = largeBannerServer.port;
});
afterAll(() => {
server.stop();
bannerServer.stop();
largeBannerServer.stop();
});
describe("TcpChecker execute", () => {
test("TCP 连接成功", async () => {
const result = await checker.execute(makeTarget({}), makeCtx());
expect(result.matched).toBe(true);
expect(result.failure).toBeNull();
expect(result.durationMs).toBeGreaterThanOrEqual(0);
expect(result.statusDetail).toMatch(/^connected in \d+ms$/);
});
test("TCP 连接失败", async () => {
const result = await checker.execute(makeTarget({ host: "127.0.0.1", port: 1 }), makeCtx());
expect(result.matched).toBe(false);
expect(result.failure!.kind).toBe("error");
expect(result.failure!.phase).toBe("connect");
expect(result.failure!.message).toBeTruthy();
});
test("期望端口不可达且连接失败", async () => {
const result = await checker.execute(
makeTarget({ host: "127.0.0.1", port: 1 }, { expect: { connected: false } }),
makeCtx(),
);
expect(result.matched).toBe(true);
expect(result.failure).toBeNull();
expect(result.statusDetail).toBeTruthy();
});
test("期望端口不可达但连接成功", async () => {
const result = await checker.execute(makeTarget({}, { expect: { connected: false } }), makeCtx());
expect(result.matched).toBe(false);
expect(result.failure!.kind).toBe("mismatch");
expect(result.failure!.phase).toBe("connected");
});
test("maxDurationMs 超时返回失败", async () => {
const result = await checker.execute(makeTarget({}, { expect: { maxDurationMs: -1 } }), makeCtx());
expect(result.matched).toBe(false);
expect(result.failure!.phase).toBe("duration");
});
test("读取服务端 banner 成功", async () => {
const result = await checker.execute(
makeTarget({
host: "127.0.0.1",
port: bannerServerPort,
readBanner: true,
}),
makeCtx(),
);
expect(result.matched).toBe(true);
expect(result.statusDetail).toContain("banner:");
expect(result.statusDetail).toContain("220 smtp.example.com ESMTP");
});
test("banner operator 校验通过", async () => {
const result = await checker.execute(
makeTarget(
{ host: "127.0.0.1", port: bannerServerPort, readBanner: true },
{ expect: { banner: { contains: "ESMTP" } } },
),
makeCtx(),
);
expect(result.matched).toBe(true);
});
test("banner operator 校验失败", async () => {
const result = await checker.execute(
makeTarget(
{ host: "127.0.0.1", port: bannerServerPort, readBanner: true },
{ expect: { banner: { contains: "POSTFIX" } } },
),
makeCtx(),
);
expect(result.matched).toBe(false);
expect(result.failure!.kind).toBe("mismatch");
expect(result.failure!.phase).toBe("banner");
expect(result.failure!.path).toBe("banner");
});
test("默认不读取 banner", async () => {
const result = await checker.execute(
makeTarget({ host: "127.0.0.1", port: bannerServerPort, readBanner: false }),
makeCtx(),
);
expect(result.matched).toBe(true);
expect(result.statusDetail).not.toContain("banner:");
});
test("banner 超时空字符串继续执行", async () => {
const result = await checker.execute(
makeTarget({
bannerReadTimeout: 100,
host: "127.0.0.1",
port: serverPort,
readBanner: true,
}),
makeCtx(),
);
expect(result.matched).toBe(true);
});
test("banner 读取超过最大字节数", async () => {
const result = await checker.execute(
makeTarget({
bannerReadTimeout: 2000,
host: "127.0.0.1",
maxBannerBytes: 1024,
port: largeBannerServerPort,
readBanner: true,
}),
makeCtx(),
);
expect(result.matched).toBe(false);
expect(result.failure!.kind).toBe("error");
expect(result.failure!.phase).toBe("banner");
expect(result.failure!.message).toContain("字节限制");
});
test("TCP 执行超时(预 abort", async () => {
const controller = new AbortController();
controller.abort();
const result = await checker.execute(makeTarget({ host: "127.0.0.1", port: serverPort }), {
signal: controller.signal,
});
expect(result.matched).toBe(false);
expect(result.failure!.phase).toBe("connect");
});
test("banner 读取过程中 abort", async () => {
const controller = new AbortController();
setTimeout(() => controller.abort(), 10);
const result = await checker.execute(
makeTarget({
bannerReadTimeout: 5000,
host: "127.0.0.1",
port: serverPort,
readBanner: true,
}),
{ signal: controller.signal },
);
expect(result.matched).toBe(false);
expect(result.failure!.kind).toBe("error");
expect(["connect", "banner"]).toContain(result.failure!.phase);
});
test("serialize 返回 host:port 和 config JSON", () => {
const target = makeTarget({ host: "10.0.0.1", port: 8080 });
const s = checker.serialize(target);
expect(s.target).toBe("10.0.0.1:8080");
const config = JSON.parse(s.config) as Record<string, unknown>;
expect(config["host"]).toBe("10.0.0.1");
expect(config["port"]).toBe(8080);
expect(config["readBanner"]).toBe(false);
});
});
describe("TcpChecker resolve", () => {
test("最简 tcp 配置解析默认值", () => {
const target = checker.resolve(
{ id: "t", tcp: { host: "127.0.0.1", port: 6379 }, type: "tcp" },
{ configDir: "/tmp", defaultIntervalMs: 30000, defaults: {}, defaultTimeoutMs: 10000 },
);
expect(target.tcp.host).toBe("127.0.0.1");
expect(target.tcp.port).toBe(6379);
expect(target.tcp.readBanner).toBe(false);
expect(target.tcp.bannerReadTimeout).toBe(2000);
expect(target.tcp.maxBannerBytes).toBe(4096);
expect(target.group).toBe("default");
expect(target.name).toBeNull();
expect(target.intervalMs).toBe(30000);
expect(target.timeoutMs).toBe(10000);
});
test("bannerReadTimeout 和 maxBannerBytes 支持 per-target 覆盖", () => {
const target = checker.resolve(
{
id: "t",
tcp: { bannerReadTimeout: 5000, host: "127.0.0.1", maxBannerBytes: "1KB", port: 80, readBanner: true },
type: "tcp",
},
{ configDir: "/tmp", defaultIntervalMs: 30000, defaults: {}, defaultTimeoutMs: 10000 },
);
expect(target.tcp.bannerReadTimeout).toBe(5000);
expect(target.tcp.maxBannerBytes).toBe(1024);
expect(target.tcp.readBanner).toBe(true);
});
test("defaults.tcp 合并到 target", () => {
const target = checker.resolve(
{ id: "t", tcp: { host: "127.0.0.1", port: 80 }, type: "tcp" },
{
configDir: "/tmp",
defaultIntervalMs: 30000,
defaults: { tcp: { bannerReadTimeout: 1000, maxBannerBytes: "8KB" } },
defaultTimeoutMs: 10000,
},
);
expect(target.tcp.bannerReadTimeout).toBe(1000);
expect(target.tcp.maxBannerBytes).toBe(8192);
});
test("per-target 覆盖 defaults.tcp", () => {
const target = checker.resolve(
{ id: "t", tcp: { bannerReadTimeout: 5000, host: "127.0.0.1", port: 80 }, type: "tcp" },
{
configDir: "/tmp",
defaultIntervalMs: 30000,
defaults: { tcp: { bannerReadTimeout: 1000 } },
defaultTimeoutMs: 10000,
},
);
expect(target.tcp.bannerReadTimeout).toBe(5000);
});
test("maxBannerBytes 整数默认值解析", () => {
const target = checker.resolve(
{ id: "t", tcp: { host: "127.0.0.1", maxBannerBytes: 2048, port: 80 }, type: "tcp" },
{ configDir: "/tmp", defaultIntervalMs: 30000, defaults: {}, defaultTimeoutMs: 10000 },
);
expect(target.tcp.maxBannerBytes).toBe(2048);
});
test("expect 配置解析", () => {
const target = checker.resolve(
{
expect: { banner: { contains: "ESMTP" }, connected: false, maxDurationMs: 5000 },
id: "t",
tcp: { host: "127.0.0.1", port: 80, readBanner: true },
type: "tcp",
},
{ configDir: "/tmp", defaultIntervalMs: 30000, defaults: {}, defaultTimeoutMs: 10000 },
);
expect(target.expect).toEqual({ banner: { contains: "ESMTP" }, connected: false, maxDurationMs: 5000 });
});
test("name 和 group 解析", () => {
const target = checker.resolve(
{ group: "infra", id: "t", name: "redis", tcp: { host: "127.0.0.1", port: 80 }, type: "tcp" },
{ configDir: "/tmp", defaultIntervalMs: 30000, defaults: {}, defaultTimeoutMs: 10000 },
);
expect(target.name).toBe("redis");
expect(target.group).toBe("infra");
});
});

View File

@@ -0,0 +1,65 @@
import { describe, expect, test } from "bun:test";
import { checkBanner, checkConnected } from "../../../../../src/server/checker/runner/tcp/expect";
describe("checkConnected", () => {
test("connected=true 期望 true 匹配", () => {
const result = checkConnected(true, true);
expect(result.matched).toBe(true);
expect(result.failure).toBeNull();
});
test("connected=false 期望 false 匹配", () => {
const result = checkConnected(false, false);
expect(result.matched).toBe(true);
expect(result.failure).toBeNull();
});
test("connected=false 期望 true 不匹配", () => {
const result = checkConnected(false, true);
expect(result.matched).toBe(false);
expect(result.failure!.kind).toBe("mismatch");
expect(result.failure!.phase).toBe("connected");
});
test("connected=true 期望 false 不匹配", () => {
const result = checkConnected(true, false);
expect(result.matched).toBe(false);
expect(result.failure!.kind).toBe("mismatch");
expect(result.failure!.phase).toBe("connected");
});
});
describe("checkBanner", () => {
test("contains 匹配", () => {
const result = checkBanner("220 smtp.example.com ESMTP", { contains: "ESMTP" });
expect(result.matched).toBe(true);
});
test("contains 不匹配", () => {
const result = checkBanner("220 smtp.example.com ESMTP", { contains: "POSTFIX" });
expect(result.matched).toBe(false);
expect(result.failure!.kind).toBe("mismatch");
expect(result.failure!.phase).toBe("banner");
});
test("match 正则匹配", () => {
const result = checkBanner("220 smtp.example.com ESMTP", { match: "^220" });
expect(result.matched).toBe(true);
});
test("空 banner 与 contains 空字符串", () => {
const result = checkBanner("", { contains: "" });
expect(result.matched).toBe(true);
});
test("多 operator 同时匹配", () => {
const result = checkBanner("220 ESMTP", { contains: "ESMTP", match: "^220" });
expect(result.matched).toBe(true);
});
test("多 operator 部分不匹配", () => {
const result = checkBanner("220 ESMTP", { contains: "ESMTP", match: "^250" });
expect(result.matched).toBe(false);
});
});

View File

@@ -0,0 +1,191 @@
import { describe, expect, test } from "bun:test";
import type { CheckerValidationInput } from "../../../../../src/server/checker/runner/types";
import { validateTcpConfig } from "../../../../../src/server/checker/runner/tcp/validate";
function makeInput(targets: unknown[], defaults?: Record<string, unknown>): CheckerValidationInput {
return {
defaults: defaults ?? {},
targets: targets as CheckerValidationInput["targets"],
};
}
describe("validateTcpConfig", () => {
test("合法 tcp target 无错误", () => {
const issues = validateTcpConfig(makeInput([{ id: "t1", tcp: { host: "127.0.0.1", port: 80 }, type: "tcp" }]));
expect(issues).toHaveLength(0);
});
test("缺少 tcp 分组", () => {
const issues = validateTcpConfig(makeInput([{ id: "t1", type: "tcp" }]));
expect(issues.length).toBeGreaterThan(0);
expect(issues.some((i) => i.message.includes("tcp"))).toBe(true);
});
test("缺少 host", () => {
const issues = validateTcpConfig(makeInput([{ id: "t1", tcp: { port: 80 }, type: "tcp" }]));
expect(issues.some((i) => i.path.includes("host"))).toBe(true);
});
test("缺少 port", () => {
const issues = validateTcpConfig(makeInput([{ id: "t1", tcp: { host: "127.0.0.1" }, type: "tcp" }]));
expect(issues.some((i) => i.path.includes("port"))).toBe(true);
});
test("端口超范围", () => {
const issues = validateTcpConfig(makeInput([{ id: "t1", tcp: { host: "127.0.0.1", port: 99999 }, type: "tcp" }]));
expect(issues.some((i) => i.path.includes("port"))).toBe(true);
});
test("端口为 0", () => {
const issues = validateTcpConfig(makeInput([{ id: "t1", tcp: { host: "127.0.0.1", port: 0 }, type: "tcp" }]));
expect(issues.some((i) => i.path.includes("port"))).toBe(true);
});
test("readBanner 非布尔值", () => {
const issues = validateTcpConfig(
makeInput([{ id: "t1", tcp: { host: "127.0.0.1", port: 80, readBanner: "yes" }, type: "tcp" }]),
);
expect(issues.some((i) => i.path.includes("readBanner"))).toBe(true);
});
test("bannerReadTimeout 非数字", () => {
const issues = validateTcpConfig(
makeInput([{ id: "t1", tcp: { bannerReadTimeout: "slow", host: "127.0.0.1", port: 80 }, type: "tcp" }]),
);
expect(issues.some((i) => i.path.includes("bannerReadTimeout"))).toBe(true);
});
test("tcp 分组未知字段", () => {
const issues = validateTcpConfig(
makeInput([{ id: "t1", tcp: { host: "127.0.0.1", port: 80, tls: true }, type: "tcp" }]),
);
expect(issues.some((i) => i.message.includes("未知字段"))).toBe(true);
});
test("expect.banner 未开启 readBanner", () => {
const issues = validateTcpConfig(
makeInput([
{
expect: { banner: { contains: "ESMTP" } },
id: "t1",
tcp: { host: "127.0.0.1", port: 25 },
type: "tcp",
},
]),
);
expect(issues.some((i) => i.message.includes("readBanner"))).toBe(true);
});
test("expect.banner 开启 readBanner 无错误", () => {
const issues = validateTcpConfig(
makeInput([
{
expect: { banner: { contains: "ESMTP" } },
id: "t1",
tcp: { host: "127.0.0.1", port: 25, readBanner: true },
type: "tcp",
},
]),
);
expect(issues).toHaveLength(0);
});
test("expect connected 非布尔值", () => {
const issues = validateTcpConfig(
makeInput([
{
expect: { connected: "yes" },
id: "t1",
tcp: { host: "127.0.0.1", port: 80 },
type: "tcp",
},
]),
);
expect(issues.some((i) => i.path.includes("connected"))).toBe(true);
});
test("expect maxDurationMs 非数字", () => {
const issues = validateTcpConfig(
makeInput([
{
expect: { maxDurationMs: "slow" },
id: "t1",
tcp: { host: "127.0.0.1", port: 80 },
type: "tcp",
},
]),
);
expect(issues.some((i) => i.path.includes("maxDurationMs"))).toBe(true);
});
test("expect 未知字段", () => {
const issues = validateTcpConfig(
makeInput([
{
expect: { status: [200] },
id: "t1",
tcp: { host: "127.0.0.1", port: 80 },
type: "tcp",
},
]),
);
expect(issues.some((i) => i.message.includes("未知字段"))).toBe(true);
});
test("expect.banner match 正则非法", () => {
const issues = validateTcpConfig(
makeInput([
{
expect: { banner: { match: "[invalid" } },
id: "t1",
tcp: { host: "127.0.0.1", port: 25, readBanner: true },
type: "tcp",
},
]),
);
expect(issues.some((i) => i.message.includes("正则"))).toBe(true);
});
test("非 tcp 类型 target 跳过", () => {
const issues = validateTcpConfig(makeInput([{ http: { url: "http://example.com" }, id: "t1", type: "http" }]));
expect(issues).toHaveLength(0);
});
test("defaults.tcp 合法字段无错误", () => {
const issues = validateTcpConfig(
makeInput([{ id: "t1", tcp: { host: "127.0.0.1", port: 80 }, type: "tcp" }], {
tcp: { bannerReadTimeout: 1000, maxBannerBytes: "8KB" },
}),
);
expect(issues).toHaveLength(0);
});
test("defaults.tcp 未知字段", () => {
const issues = validateTcpConfig(
makeInput([{ id: "t1", tcp: { host: "127.0.0.1", port: 80 }, type: "tcp" }], {
tcp: { bannerReadTimeout: 1000, host: "127.0.0.1" },
}),
);
expect(issues.some((i) => i.message.includes("未知字段"))).toBe(true);
});
test("defaults.tcp bannerReadTimeout 非法", () => {
const issues = validateTcpConfig(
makeInput([{ id: "t1", tcp: { host: "127.0.0.1", port: 80 }, type: "tcp" }], {
tcp: { bannerReadTimeout: "slow" },
}),
);
expect(issues.some((i) => i.path.includes("bannerReadTimeout"))).toBe(true);
});
test("defaults.tcp maxBannerBytes 非法", () => {
const issues = validateTcpConfig(
makeInput([{ id: "t1", tcp: { host: "127.0.0.1", port: 80 }, type: "tcp" }], {
tcp: { maxBannerBytes: true },
}),
);
expect(issues.some((i) => i.path.includes("maxBannerBytes"))).toBe(true);
});
});

View File

@@ -24,11 +24,8 @@ beforeAll(() => {
ensureRegistered(); ensureRegistered();
}); });
function targetId(store: ProbeStore, name: string): string {
return store.getTargets().find((target) => target.name === name)!.id;
}
const httpTarget: ResolvedHttpTarget = { const httpTarget: ResolvedHttpTarget = {
description: null,
expect: { maxDurationMs: 3000, status: [200] }, expect: { maxDurationMs: 3000, status: [200] },
group: "default", group: "default",
http: { http: {
@@ -54,6 +51,7 @@ const commandTarget: ResolvedCommandTarget = {
exec: "ping", exec: "ping",
maxOutputBytes: 104857600, maxOutputBytes: 104857600,
}, },
description: null,
group: "default", group: "default",
id: "test-cmd", id: "test-cmd",
intervalMs: 60000, intervalMs: 60000,
@@ -85,11 +83,11 @@ describe("ProbeStore", () => {
store.syncTargets([httpTarget, commandTarget]); store.syncTargets([httpTarget, commandTarget]);
const targets = store.getTargets(); const targets = store.getTargets();
expect(targets).toHaveLength(2); expect(targets).toHaveLength(2);
expect(targets.map((target) => target.name).sort()).toEqual(["test-cmd", "test-http"]); expect(targets.map((target) => target.id).sort()).toEqual(["test-cmd", "test-http"]);
}); });
test("http target 字段正确", () => { test("http target 字段正确", () => {
const t = store.getTargets().find((t) => t.name === "test-http")!; const t = store.getTargets().find((t) => t.id === "test-http")!;
expect(t.type).toBe("http"); expect(t.type).toBe("http");
expect(t.target).toBe("https://example.com/health"); expect(t.target).toBe("https://example.com/health");
const config = JSON.parse(t.config) as { const config = JSON.parse(t.config) as {
@@ -112,7 +110,7 @@ describe("ProbeStore", () => {
}); });
test("cmd target 字段正确", () => { test("cmd target 字段正确", () => {
const t = store.getTargets().find((t) => t.name === "test-cmd")!; const t = store.getTargets().find((t) => t.id === "test-cmd")!;
expect(t.type).toBe("cmd"); expect(t.type).toBe("cmd");
expect(t.target).toBe("exec ping -c 1 localhost"); expect(t.target).toBe("exec ping -c 1 localhost");
const config = JSON.parse(t.config) as { args: string[]; cwd: string; exec: string; maxOutputBytes: number }; const config = JSON.parse(t.config) as { args: string[]; cwd: string; exec: string; maxOutputBytes: number };
@@ -131,7 +129,7 @@ describe("ProbeStore", () => {
http: { ...httpTarget.http, url: "https://example.com/v2" }, http: { ...httpTarget.http, url: "https://example.com/v2" },
}; };
store.syncTargets([updated, commandTarget]); store.syncTargets([updated, commandTarget]);
const t = store.getTargets().find((t) => t.name === "test-http")!; const t = store.getTargets().find((t) => t.id === "test-http")!;
expect(t.target).toBe("https://example.com/v2"); expect(t.target).toBe("https://example.com/v2");
expect(store.getTargets()).toHaveLength(2); expect(store.getTargets()).toHaveLength(2);
}); });
@@ -149,7 +147,7 @@ describe("ProbeStore", () => {
}); });
test("getTargetById", () => { test("getTargetById", () => {
const found = store.getTargetById(targetId(store, "test-http")); const found = store.getTargetById("test-http");
expect(found).toBeDefined(); expect(found).toBeDefined();
expect(found!.name).toBe("test-http"); expect(found!.name).toBe("test-http");
}); });
@@ -160,14 +158,13 @@ describe("ProbeStore", () => {
test("写入 check result 并查询", () => { test("写入 check result 并查询", () => {
store.syncTargets([httpTarget, commandTarget]); store.syncTargets([httpTarget, commandTarget]);
const t1Id = targetId(store, "test-http");
store.insertCheckResult({ store.insertCheckResult({
durationMs: 150.5, durationMs: 150.5,
failure: null, failure: null,
matched: true, matched: true,
statusDetail: "200 OK", statusDetail: "200 OK",
targetId: t1Id, targetId: "test-http",
timestamp: "2025-01-01T00:00:00.000Z", timestamp: "2025-01-01T00:00:00.000Z",
}); });
@@ -176,7 +173,7 @@ describe("ProbeStore", () => {
failure: null, failure: null,
matched: true, matched: true,
statusDetail: "200 OK", statusDetail: "200 OK",
targetId: t1Id, targetId: "test-http",
timestamp: "2025-01-01T00:00:30.000Z", timestamp: "2025-01-01T00:00:30.000Z",
}); });
@@ -194,15 +191,15 @@ describe("ProbeStore", () => {
failure, failure,
matched: false, matched: false,
statusDetail: null, statusDetail: null,
targetId: t1Id, targetId: "test-http",
timestamp: "2025-01-01T00:01:00.000Z", timestamp: "2025-01-01T00:01:00.000Z",
}); });
const history = store.getHistory(t1Id, "2025-01-01T00:00:00.000Z", "2025-12-31T23:59:59.999Z", 1, 10); const history = store.getHistory("test-http", "2025-01-01T00:00:00.000Z", "2025-12-31T23:59:59.999Z", 1, 10);
expect(history.items).toHaveLength(3); expect(history.items).toHaveLength(3);
expect(history.items[0]!.timestamp).toBe("2025-01-01T00:01:00.000Z"); expect(history.items[0]!.timestamp).toBe("2025-01-01T00:01:00.000Z");
const latest = store.getLatestCheck(t1Id)!; const latest = store.getLatestCheck("test-http")!;
expect(latest.matched).toBe(0); expect(latest.matched).toBe(0);
expect(latest.failure).not.toBeNull(); expect(latest.failure).not.toBeNull();
const parsedFailure = JSON.parse(latest.failure!) as CheckFailure; const parsedFailure = JSON.parse(latest.failure!) as CheckFailure;
@@ -212,27 +209,23 @@ describe("ProbeStore", () => {
}); });
test("getHistory 默认 limit=20", () => { test("getHistory 默认 limit=20", () => {
const t1Id = targetId(store, "test-http");
for (let i = 0; i < 25; i++) { for (let i = 0; i < 25; i++) {
store.insertCheckResult({ store.insertCheckResult({
durationMs: 100 + i, durationMs: 100 + i,
failure: null, failure: null,
matched: true, matched: true,
statusDetail: "200 OK", statusDetail: "200 OK",
targetId: t1Id, targetId: "test-http",
timestamp: `2025-01-01T01:${String(i).padStart(2, "0")}:00.000Z`, timestamp: `2025-01-01T01:${String(i).padStart(2, "0")}:00.000Z`,
}); });
} }
const history = store.getHistory(t1Id, "2025-01-01T00:00:00.000Z", "2025-12-31T23:59:59.999Z"); const history = store.getHistory("test-http", "2025-01-01T00:00:00.000Z", "2025-12-31T23:59:59.999Z");
expect(history.items).toHaveLength(20); expect(history.items).toHaveLength(20);
}); });
test("getTargetWindowStats 按时间窗口计算基础计数", () => { test("getTargetWindowStats 按时间窗口计算基础计数", () => {
const t1Id = targetId(store, "test-http"); const stats = store.getTargetWindowStats("test-http", "2025-01-01T00:00:00.000Z", "2025-01-01T00:01:00.000Z");
const stats = store.getTargetWindowStats(t1Id, "2025-01-01T00:00:00.000Z", "2025-01-01T00:01:00.000Z");
expect(stats.totalChecks).toBeGreaterThan(0); expect(stats.totalChecks).toBeGreaterThan(0);
expect(stats.upChecks + stats.downChecks).toBe(stats.totalChecks); expect(stats.upChecks + stats.downChecks).toBe(stats.totalChecks);
expect(stats.availability).toBeGreaterThanOrEqual(0); expect(stats.availability).toBeGreaterThanOrEqual(0);
@@ -240,9 +233,7 @@ describe("ProbeStore", () => {
}); });
test("无记录目标的窗口 stats", () => { test("无记录目标的窗口 stats", () => {
const t2Id = targetId(store, "test-cmd"); const stats = store.getTargetWindowStats("test-cmd", "2025-01-01T00:00:00.000Z", "2025-01-01T00:01:00.000Z");
const stats = store.getTargetWindowStats(t2Id, "2025-01-01T00:00:00.000Z", "2025-01-01T00:01:00.000Z");
expect(stats.totalChecks).toBe(0); expect(stats.totalChecks).toBe(0);
expect(stats.upChecks).toBe(0); expect(stats.upChecks).toBe(0);
expect(stats.downChecks).toBe(0); expect(stats.downChecks).toBe(0);
@@ -251,16 +242,14 @@ describe("ProbeStore", () => {
test("getLatestChecksMap 支持 Dashboard 组装当前状态", () => { test("getLatestChecksMap 支持 Dashboard 组装当前状态", () => {
const latestChecksMap = store.getLatestChecksMap(); const latestChecksMap = store.getLatestChecksMap();
const latest = latestChecksMap.get(targetId(store, "test-http")); const latest = latestChecksMap.get("test-http");
expect(latest).toBeDefined(); expect(latest).toBeDefined();
expect(latest!.timestamp).toBe("2025-01-01T01:24:00.000Z"); expect(latest!.timestamp).toBe("2025-01-01T01:24:00.000Z");
}); });
test("getTargetCheckpoints 返回窗口内升序检查点", () => { test("getTargetCheckpoints 返回窗口内升序检查点", () => {
const t1Id = targetId(store, "test-http"); const checkpoints = store.getTargetCheckpoints("test-http", "2025-01-01T00:00:00.000Z", "2025-01-01T00:01:00.000Z");
const checkpoints = store.getTargetCheckpoints(t1Id, "2025-01-01T00:00:00.000Z", "2025-01-01T00:01:00.000Z");
expect(checkpoints).toEqual([ expect(checkpoints).toEqual([
{ duration_ms: 150.5, matched: 1, timestamp: "2025-01-01T00:00:00.000Z" }, { duration_ms: 150.5, matched: 1, timestamp: "2025-01-01T00:00:00.000Z" },
{ duration_ms: 300, matched: 1, timestamp: "2025-01-01T00:00:30.000Z" }, { duration_ms: 300, matched: 1, timestamp: "2025-01-01T00:00:30.000Z" },
@@ -269,16 +258,12 @@ describe("ProbeStore", () => {
}); });
test("getTargetDurations 返回成功检查耗时升序数组", () => { test("getTargetDurations 返回成功检查耗时升序数组", () => {
const t1Id = targetId(store, "test-http"); const durations = store.getTargetDurations("test-http", "2025-01-01T00:00:00.000Z", "2025-01-01T00:01:00.000Z");
const durations = store.getTargetDurations(t1Id, "2025-01-01T00:00:00.000Z", "2025-01-01T00:01:00.000Z");
expect(durations).toEqual([150.5, 300]); expect(durations).toEqual([150.5, 300]);
}); });
test("getRecentSamples 返回最近采样数据", () => { test("getRecentSamples 返回最近采样数据", () => {
const t1Id = targetId(store, "test-http"); const samples = store.getRecentSamples("test-http", 10);
const samples = store.getRecentSamples(t1Id, 10);
expect(Array.isArray(samples)).toBe(true); expect(Array.isArray(samples)).toBe(true);
expect(samples.length).toBeGreaterThan(0); expect(samples.length).toBeGreaterThan(0);
for (const sample of samples) { for (const sample of samples) {
@@ -304,9 +289,9 @@ describe("ProbeStore", () => {
}; };
sampleStore.syncTargets([httpA, httpB, httpEmpty]); sampleStore.syncTargets([httpA, httpB, httpEmpty]);
const targets = sampleStore.getTargets(); const targets = sampleStore.getTargets();
const targetAId = targets.find((t) => t.name === "sample-http-a")!.id; const targetAId = targets.find((t) => t.id === "sample-http-a")!.id;
const targetBId = targets.find((t) => t.name === "sample-http-b")!.id; const targetBId = targets.find((t) => t.id === "sample-http-b")!.id;
const emptyTargetId = targets.find((t) => t.name === "sample-http-empty")!.id; const emptyTargetId = targets.find((t) => t.id === "sample-http-empty")!.id;
for (const [index, timestamp] of [ for (const [index, timestamp] of [
"2025-01-01T00:00:00.000Z", "2025-01-01T00:00:00.000Z",
@@ -364,6 +349,7 @@ describe("ProbeStore", () => {
test("删除 target 级联删除 check_results", () => { test("删除 target 级联删除 check_results", () => {
const cascadeStore = new ProbeStore(join(tempDir, "cascade.db")); const cascadeStore = new ProbeStore(join(tempDir, "cascade.db"));
const cascadeTarget: ResolvedHttpTarget = { const cascadeTarget: ResolvedHttpTarget = {
description: null,
group: "default", group: "default",
http: { http: {
headers: {}, headers: {},
@@ -427,6 +413,7 @@ describe("ProbeStore", () => {
const freshStore = new ProbeStore(join(tempDir, "fresh-map.db")); const freshStore = new ProbeStore(join(tempDir, "fresh-map.db"));
freshStore.syncTargets([ freshStore.syncTargets([
{ {
description: null,
group: "default", group: "default",
http: { http: {
headers: {}, headers: {},
@@ -451,19 +438,16 @@ describe("ProbeStore", () => {
}); });
test("getAllTargetWindowStats 返回所有 target 的窗口聚合统计", () => { test("getAllTargetWindowStats 返回所有 target 的窗口聚合统计", () => {
const t1Id = targetId(store, "test-http");
const t2Id = targetId(store, "test-cmd");
const stats = store.getAllTargetWindowStats("2024-01-01T00:00:00.000Z", "2026-12-31T23:59:59.999Z"); const stats = store.getAllTargetWindowStats("2024-01-01T00:00:00.000Z", "2026-12-31T23:59:59.999Z");
expect(stats).toBeInstanceOf(Map); expect(stats).toBeInstanceOf(Map);
const stats1 = stats.get(t1Id); const stats1 = stats.get("test-http");
expect(stats1).toBeDefined(); expect(stats1).toBeDefined();
expect(stats1!.totalChecks).toBeGreaterThan(0); expect(stats1!.totalChecks).toBeGreaterThan(0);
expect(stats1!.upChecks + stats1!.downChecks).toBe(stats1!.totalChecks); expect(stats1!.upChecks + stats1!.downChecks).toBe(stats1!.totalChecks);
expect(stats1!.availability).toBeGreaterThanOrEqual(0); expect(stats1!.availability).toBeGreaterThanOrEqual(0);
const stats2 = stats.get(t2Id); const stats2 = stats.get("test-cmd");
if (stats2) { if (stats2) {
expect(stats2.totalChecks).toBe(0); expect(stats2.totalChecks).toBe(0);
expect(stats2.availability).toBe(0); expect(stats2.availability).toBe(0);
@@ -474,6 +458,7 @@ describe("ProbeStore", () => {
const freshStore = new ProbeStore(join(tempDir, "fresh-stats.db")); const freshStore = new ProbeStore(join(tempDir, "fresh-stats.db"));
freshStore.syncTargets([ freshStore.syncTargets([
{ {
description: null,
group: "default", group: "default",
http: { http: {
headers: {}, headers: {},
@@ -543,8 +528,8 @@ describe("ProbeStore", () => {
}; };
incidentStore.syncTargets([httpA, httpB]); incidentStore.syncTargets([httpA, httpB]);
const targets = incidentStore.getTargets(); const targets = incidentStore.getTargets();
const targetAId = targets.find((target) => target.name === "incident-http-a")!.id; const targetAId = targets.find((target) => target.id === "incident-http-a")!.id;
const targetBId = targets.find((target) => target.name === "incident-http-b")!.id; const targetBId = targets.find((target) => target.id === "incident-http-b")!.id;
incidentStore.insertCheckResult({ incidentStore.insertCheckResult({
durationMs: 100, durationMs: 100,
@@ -662,4 +647,43 @@ describe("ProbeStore", () => {
pruneStore.close(); pruneStore.close();
}); });
test("syncTargets 持久化 description", () => {
const descStore = new ProbeStore(join(tempDir, "desc.db"));
const targetWithDesc: ResolvedHttpTarget = {
...httpTarget,
description: "检查 API 健康状态",
id: "desc-test",
name: "desc-test",
};
descStore.syncTargets([targetWithDesc]);
const t = descStore.getTargets()[0]!;
expect(t.description).toBe("检查 API 健康状态");
descStore.close();
});
test("未配置 description 时持久化为 null", () => {
const noDescStore = new ProbeStore(join(tempDir, "no-desc.db"));
noDescStore.syncTargets([{ ...httpTarget, description: null, id: "no-desc", name: "no-desc" }]);
const t = noDescStore.getTargets()[0]!;
expect(t.description).toBeNull();
noDescStore.close();
});
test("同步更新 description", () => {
const updateDescStore = new ProbeStore(join(tempDir, "update-desc.db"));
updateDescStore.syncTargets([{ ...httpTarget, description: "旧描述", id: "update-desc", name: "update-desc" }]);
updateDescStore.syncTargets([{ ...httpTarget, description: "新描述", id: "update-desc", name: "update-desc" }]);
const t = updateDescStore.getTargets()[0]!;
expect(t.description).toBe("新描述");
updateDescStore.close();
});
test("name 为 null 时持久化为 null", () => {
const nullNameStore = new ProbeStore(join(tempDir, "null-name.db"));
nullNameStore.syncTargets([{ ...httpTarget, id: "null-name", name: null }]);
const t = nullNameStore.getTargets()[0]!;
expect(t.name).toBeNull();
nullNameStore.close();
});
}); });

View File

@@ -9,6 +9,7 @@ import { OverviewTab } from "../../../src/web/components/OverviewTab";
describe("OverviewTab", () => { describe("OverviewTab", () => {
const target: TargetStatus = { const target: TargetStatus = {
currentStreak: null, currentStreak: null,
description: null,
group: "default", group: "default",
id: "1", id: "1",
interval: "30s", interval: "30s",

View File

@@ -19,6 +19,7 @@ describe("TargetBoard", () => {
const targets: TargetStatus[] = [ const targets: TargetStatus[] = [
{ {
currentStreak: null, currentStreak: null,
description: null,
group: "default", group: "default",
id: "1", id: "1",
interval: "30s", interval: "30s",
@@ -31,6 +32,7 @@ describe("TargetBoard", () => {
}, },
{ {
currentStreak: null, currentStreak: null,
description: null,
group: "production", group: "production",
id: "2", id: "2",
interval: "30s", interval: "30s",

View File

@@ -9,6 +9,7 @@ import { TargetDetailDrawer } from "../../../src/web/components/TargetDetailDraw
describe("TargetDetailDrawer", () => { describe("TargetDetailDrawer", () => {
const target: TargetStatus = { const target: TargetStatus = {
currentStreak: null, currentStreak: null,
description: null,
group: "default", group: "default",
id: "1", id: "1",
interval: "30s", interval: "30s",
@@ -120,4 +121,10 @@ describe("TargetDetailDrawer", () => {
const dragLine = wrapper.querySelector('[style*="col-resize"]'); const dragLine = wrapper.querySelector('[style*="col-resize"]');
expect(dragLine).not.toBeNull(); expect(dragLine).not.toBeNull();
}); });
test("name 为 null 时标题显示 id", () => {
const nullNameTarget = { ...target, name: null };
render(<TargetDetailDrawer {...defaultProps} target={nullNameTarget} />);
expect(document.body.textContent).toContain("1");
});
}); });

View File

@@ -15,6 +15,7 @@ describe("TargetGroup", () => {
const targets: TargetStatus[] = [ const targets: TargetStatus[] = [
{ {
currentStreak: null, currentStreak: null,
description: null,
group: "default", group: "default",
id: "1", id: "1",
interval: "30s", interval: "30s",
@@ -33,6 +34,7 @@ describe("TargetGroup", () => {
}, },
{ {
currentStreak: null, currentStreak: null,
description: null,
group: "default", group: "default",
id: "2", id: "2",
interval: "30s", interval: "30s",

View File

@@ -20,6 +20,7 @@ function getColumn(columns: Array<PrimaryTableCol<TargetStatus>>, colKey: string
function makeTarget(overrides: Partial<TargetStatus> = {}): TargetStatus { function makeTarget(overrides: Partial<TargetStatus> = {}): TargetStatus {
return { return {
currentStreak: null, currentStreak: null,
description: null,
group: "default", group: "default",
id: "1", id: "1",
interval: "5s", interval: "5s",
@@ -119,7 +120,64 @@ describe("createTargetTableColumns", () => {
rowIndex: 0, rowIndex: 0,
}); });
expect(element.props.children).toBe("9999+ms"); expect(element.props.children).toBe("9999+");
expect(element.props.className).toContain("latency-value"); expect(element.props.className).toContain("latency-value");
}); });
test("延迟列标题为 延迟(ms)", () => {
const latencyColumn = getColumn(createTargetTableColumns(["http"]), "latestCheck.durationMs");
expect(latencyColumn.title).toBe("延迟(ms)");
});
test("延迟列正常值不包含 ms 单位", () => {
const latencyColumn = getColumn(createTargetTableColumns(["http"]), "latestCheck.durationMs");
const renderCell = latencyColumn.cell as (params: PrimaryTableCellParams<TargetStatus>) => {
props: { children: string; className: string };
};
const element = renderCell({
col: latencyColumn,
colIndex: 6,
row: makeTarget({
latestCheck: {
durationMs: 123,
failure: null,
matched: true,
statusDetail: "200",
timestamp: "2026-01-01T00:00:00.000Z",
},
}),
rowIndex: 0,
});
expect(element.props.children).toBe("123");
});
test("名称列无排序配置", () => {
const nameColumn = getColumn(createTargetTableColumns(["http"]), "name");
expect(nameColumn.sorter).toBeUndefined();
expect(nameColumn.sortType).toBeUndefined();
});
test("名称列 name 为 null 时显示 id", () => {
const nameColumn = getColumn(createTargetTableColumns(["http"]), "name");
const renderCell = nameColumn.cell as (params: PrimaryTableCellParams<TargetStatus>) => string;
const result = renderCell({
col: nameColumn,
colIndex: 1,
row: makeTarget({ id: "my-api", name: null }),
rowIndex: 0,
});
expect(result).toBe("my-api");
});
test("名称列 name 有值时显示 name", () => {
const nameColumn = getColumn(createTargetTableColumns(["http"]), "name");
const renderCell = nameColumn.cell as (params: PrimaryTableCellParams<TargetStatus>) => string;
const result = renderCell({
col: nameColumn,
colIndex: 1,
row: makeTarget({ id: "my-api", name: "我的 API" }),
rowIndex: 0,
});
expect(result).toBe("我的 API");
});
}); });

View File

@@ -2,16 +2,12 @@ import { describe, expect, test } from "bun:test";
import type { TargetStatus } from "../../../src/shared/api"; import type { TargetStatus } from "../../../src/shared/api";
import { import { availabilitySorter, latencySorter, statusSorter } from "../../../src/web/constants/target-table-sorters";
availabilitySorter,
latencySorter,
nameSorter,
statusSorter,
} from "../../../src/web/constants/target-table-sorters";
function makeTarget(overrides: Partial<TargetStatus> = {}): TargetStatus { function makeTarget(overrides: Partial<TargetStatus> = {}): TargetStatus {
return { return {
currentStreak: null, currentStreak: null,
description: null,
group: "default", group: "default",
id: "1", id: "1",
interval: "5s", interval: "5s",
@@ -97,18 +93,3 @@ describe("latencySorter", () => {
expect(latencySorter(noLatency, hasLatency)).toBeGreaterThan(0); expect(latencySorter(noLatency, hasLatency)).toBeGreaterThan(0);
}); });
}); });
describe("nameSorter", () => {
test("按名称字母排序", () => {
const a = makeTarget({ name: "Alpha" });
const b = makeTarget({ name: "Beta" });
expect(nameSorter(a, b)).toBeLessThan(0);
});
test("中文名称排序", () => {
const a = makeTarget({ name: "百度" });
const b = makeTarget({ name: "谷歌" });
const result = nameSorter(a, b);
expect(typeof result).toBe("number");
});
});

View File

@@ -0,0 +1,32 @@
import { describe, expect, test } from "bun:test";
import type { TargetStatus } from "../../../src/shared/api";
import { getTargetDisplayName } from "../../../src/web/utils/target";
function makeTarget(overrides: Partial<TargetStatus> = {}): TargetStatus {
return {
currentStreak: null,
description: null,
group: "default",
id: "api-health",
interval: "30s",
latestCheck: null,
name: null,
recentSamples: [],
stats: { availability: 100, downChecks: 0, totalChecks: 0, upChecks: 0 },
target: "https://example.com",
type: "http",
...overrides,
};
}
describe("getTargetDisplayName", () => {
test("name 为 null 时返回 id", () => {
expect(getTargetDisplayName(makeTarget())).toBe("api-health");
});
test("name 有值时返回 name", () => {
expect(getTargetDisplayName(makeTarget({ name: "我的 API" }))).toBe("我的 API");
});
});