Compare commits
4 Commits
7926514986
...
393e8da5fd
| Author | SHA1 | Date | |
|---|---|---|---|
| 393e8da5fd | |||
| 0a9a9016be | |||
| 31fd3a2a43 | |||
| f7193e98ff |
@@ -59,6 +59,8 @@ src/
|
||||
index.ts 注册入口(显式数组 + 循环注册)
|
||||
http/ HTTP Checker(自包含模块,含 types/schema/execute/expect/validate/body)
|
||||
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/
|
||||
api.ts 前后端共享 TypeScript 类型
|
||||
web/ React 前端 Dashboard(通过 Bun HTML import 集成)
|
||||
@@ -463,7 +465,7 @@ TcpChecker implements Checker
|
||||
|
||||
**Schema**:
|
||||
|
||||
- `targets` 表:id(TEXT PRIMARY KEY,配置 target id)、name(展示名称)、type、target(展示摘要)、config(JSON)、interval_ms、timeout_ms、expect(JSON)、grp
|
||||
- `targets` 表:id(TEXT PRIMARY KEY,配置 target id)、name(TEXT,可 NULL,展示名称)、description(TEXT,可 NULL,描述)、type、target(展示摘要)、config(JSON)、interval_ms、timeout_ms、expect(JSON)、grp
|
||||
- `check_results` 表:target_id(TEXT FK CASCADE,引用配置 target id)、timestamp、matched(0/1)、duration_ms、status_detail、failure(JSON)
|
||||
- 复合索引:`(target_id, timestamp)`
|
||||
|
||||
|
||||
34
README.md
34
README.md
@@ -10,11 +10,11 @@
|
||||
|
||||
---
|
||||
|
||||
DiAL 是一个自托管的拨测监控工具,支持 **HTTP**、**命令行** 和 **数据库** 三种拨测类型。通过 YAML 配置文件定义拨测目标,后端定时并发执行拨测并将结果持久化到本地 SQLite,前端 Dashboard 展示各目标的实时状态、可用率和耗时趋势。
|
||||
DiAL 是一个自托管的拨测监控工具,支持 **HTTP**、**命令行**、**数据库** 和 **TCP** 四种拨测类型。通过 YAML 配置文件定义拨测目标,后端定时并发执行拨测并将结果持久化到本地 SQLite,前端 Dashboard 展示各目标的实时状态、可用率和耗时趋势。
|
||||
|
||||
**功能亮点:**
|
||||
|
||||
- 多种拨测类型:HTTP(GET/POST/PUT 等)、Cmd(命令行执行)、DB(PostgreSQL/MySQL/SQLite)
|
||||
- 多种拨测类型:HTTP(GET/POST/PUT 等)、Cmd(命令行执行)、DB(PostgreSQL/MySQL/SQLite)、TCP(端口可达性 + Banner 探测)
|
||||
- 丰富的校验规则:状态码、响应头、JSONPath、CSS 选择器、XPath、正则匹配、数值比较等
|
||||
- 响应式 Dashboard:实时状态、可用率统计、耗时趋势图、手动/自动刷新
|
||||
- 多主题支持:系统、明亮、黑暗三种主题模式
|
||||
@@ -132,6 +132,15 @@ targets:
|
||||
rowCount: { gte: 1 }
|
||||
rows:
|
||||
- cnt: { gte: 0 }
|
||||
|
||||
- id: "redis-port"
|
||||
name: "Redis 端口可达"
|
||||
type: tcp
|
||||
tcp:
|
||||
host: "127.0.0.1"
|
||||
port: 6379
|
||||
expect:
|
||||
maxDurationMs: 3000
|
||||
```
|
||||
|
||||
### 配置说明
|
||||
@@ -177,10 +186,11 @@ targets:
|
||||
每个 target 的通用字段:
|
||||
|
||||
| 字段 | 说明 | 必填 |
|
||||
| ---------- | ---------------------------------------------------------- | -------------------- |
|
||||
| `id` | 目标唯一标识,支持字母数字、下划线、连字符,不参与变量替换 | 是 |
|
||||
| `name` | 展示名称,支持变量替换;省略时使用 `id` | 否 |
|
||||
| `type` | 目标类型:`http`、`cmd`、`db` | 是 |
|
||||
| ------------- | ------------------------------------------------------------------------------------ | -------------------- |
|
||||
| `id` | 目标唯一标识,最长 30 字符,支持字母数字、下划线、连字符,不参与变量替换 | 是 |
|
||||
| `name` | 展示名称,最长 30 字符,支持变量替换,可省略或显式 null;前端展示时 null 回退到 `id` | 否 |
|
||||
| `description` | 目标描述,最长 500 字符,支持变量替换,可省略或显式 null,允许空字符串 | 否 |
|
||||
| `type` | 目标类型:`http`、`cmd`、`db`、`tcp` | 是 |
|
||||
| `group` | 分组名称 | 否,默认 `"default"` |
|
||||
| `interval` | 覆盖全局拨测间隔 | 否 |
|
||||
| `timeout` | 覆盖全局超时时间 | 否 |
|
||||
@@ -212,6 +222,16 @@ targets:
|
||||
| `db.url` | 数据库连接字符串,支持 `postgres://`、`mysql://`、`sqlite://` |
|
||||
| `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 — 期望校验
|
||||
|
||||
| 字段 | 适用类型 | 说明 |
|
||||
@@ -224,6 +244,8 @@ targets:
|
||||
| `stdout` / `stderr` | Cmd | 输出校验(数组,每项一个操作符对象) |
|
||||
| `rowCount` | DB | 查询返回行数校验(操作符对象) |
|
||||
| `rows` | DB | 查询结果逐行校验(数组,列名→操作符映射) |
|
||||
| `connected` | TCP | 期望连接结果,`true`(默认)可达或 `false` 期望不可达 |
|
||||
| `banner` | TCP | Banner 文本校验(操作符对象,需开启 `tcp.readBanner`) |
|
||||
|
||||
**body 校验项**(数组中可混合使用):
|
||||
|
||||
|
||||
2
openspec/changes/add-icmp-checker/.openspec.yaml
Normal file
2
openspec/changes/add-icmp-checker/.openspec.yaml
Normal file
@@ -0,0 +1,2 @@
|
||||
schema: spec-driven
|
||||
created: 2026-05-17
|
||||
190
openspec/changes/add-icmp-checker/design.md
Normal file
190
openspec/changes/add-icmp-checker/design.md
Normal 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` 原生 addon,Bun N-API 兼容性不确定且需要 root 权限)。现有的命令封装库(`ping`、`pingman`)虽然提供了跨平台解析,但它们封装了 `child_process.spawn` 且不暴露子进程引用,无法配合我们的 `ctx.signal` 超时控制机制。
|
||||
|
||||
经过调研和讨论,确定自行实现:用 `Bun.spawn` 调用系统 `ping` 命令 + 自行编写跨平台输出解析器。
|
||||
|
||||
## Goals / Non-Goals
|
||||
|
||||
**Goals:**
|
||||
- 实现 `type: ping` checker,支持主机存活检测、延迟监控、丢包率检查
|
||||
- 跨平台支持 Linux、macOS、Windows(含中文 Windows)
|
||||
- 完全复用现有 checker 架构(registry、schema、expect、failure)
|
||||
- 零外部依赖
|
||||
|
||||
**Non-Goals:**
|
||||
- 不实现 traceroute 功能
|
||||
- 不实现 IPv6 专项支持(系统 ping 命令会自动处理 IPv6 地址)
|
||||
- 不实现原始 ICMP socket(权限要求过高)
|
||||
- 不提供 per-packet 逐包结果(只提供 summary 统计)
|
||||
|
||||
## Decisions
|
||||
|
||||
### Decision 1: 调用系统 `ping` 命令而非原始 ICMP socket
|
||||
|
||||
**选择**: 通过 `Bun.spawn` 调用系统 `ping` 可执行文件
|
||||
|
||||
**替代方案**:
|
||||
- 原始 ICMP socket(`raw-socket` addon):需要 root/CAP_NET_RAW 权限,Bun N-API 兼容性不确定
|
||||
- 三方库 `pingman`/`ping`:封装了 spawn 但不暴露子进程引用,无法配合 AbortSignal 超时控制
|
||||
|
||||
**理由**: 系统 `ping` 命令无需特殊权限(大多数系统),`Bun.spawn` 给我们完全的子进程生命周期控制,与现有 cmd checker 模式一致。
|
||||
|
||||
### Decision 2: 自行实现跨平台解析器
|
||||
|
||||
**选择**: 在 `parse.ts` 中实现 `parsePingOutput(stdout, platform)` 函数,用正则匹配 summary 统计行
|
||||
|
||||
**替代方案**:
|
||||
- 引入 `pingman` 作为依赖使用其 parser:模块不是公开 API,deep import 不稳定
|
||||
- 引入 `ping`(danielzzz)的 parser:同上,且返回值类型全是 string
|
||||
|
||||
**理由**: 我们只需要 summary 行的统计数据(transmitted/received/loss/min/avg/max),不需要逐包 body 解析。三套正则约 40-50 行代码,完全可控且零依赖。
|
||||
|
||||
### Decision 3: 跨平台命令构建策略
|
||||
|
||||
```
|
||||
平台判断: process.platform (win32 vs 其他)
|
||||
|
||||
Linux:
|
||||
ping -c <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 code:Windows ping 在全部丢包时 exit code 为 1),延迟字段为 null。statusDetail 展示原始输出前 80 字符供用户排查。
|
||||
|
||||
### [Risk] ping 命令被防火墙/网络策略阻断 → 用户可预期的行为
|
||||
ICMP 在某些网络环境中被阻断。这不是 checker 的 bug,而是网络配置问题。checker 会正确报告 `alive=false` 和 100% packet loss。
|
||||
|
||||
### [Trade-off] 双重超时保障
|
||||
传递 `-W`/`-w` 超时参数给 ping 命令,同时保留外层 AbortSignal + proc.kill() 兜底。
|
||||
优势:ping 命令自身会在超时后正常退出,不依赖外部 kill;即使 ping 命令因异常卡死,外层 signal 仍能强制终止。
|
||||
劣势:需要处理三平台超时参数的单位差异(Linux 秒 vs macOS/Windows 毫秒),增加少量命令构建复杂度。
|
||||
|
||||
### [Trade-off] 不引入三方库
|
||||
优势:零依赖、完全可控、与项目规范一致。
|
||||
劣势:需要自行维护跨平台解析正则。但 ping 输出格式极其稳定(几十年未变),维护成本极低。
|
||||
28
openspec/changes/add-icmp-checker/proposal.md
Normal file
28
openspec/changes/add-icmp-checker/proposal.md
Normal 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/Windows)ping 输出解析器,不引入三方库
|
||||
- 文档注明 ICMP checker 依赖系统 `ping` 命令存在(容器环境需确保已安装,如 Alpine 需 `iputils-ping`)
|
||||
|
||||
## Capabilities
|
||||
|
||||
### New Capabilities
|
||||
- `icmp-checker`: 定义 ICMP/Ping checker 的配置格式、命令执行、跨平台输出解析、expect 校验和状态摘要
|
||||
|
||||
### Modified Capabilities
|
||||
- `checker-runner-abstraction`: 超时控制 requirement 中"仅 cmd checker 可在 signal abort 时 proc.kill()"需扩展为"cmd checker 和 ping checker",因为 ping checker 同样 spawn 子进程
|
||||
- `probe-config`: 配置格式需扩展支持 `type: ping` 的 target 配置、`ping` 领域分组和对应的 expect 字段
|
||||
|
||||
## Impact
|
||||
|
||||
- 后端代码:新增 `src/server/checker/runner/icmp/` 模块,注册到 CheckerRegistry
|
||||
- 配置 schema:`probe-config.schema.json` 需更新,新增 ping target 和 expect 的 schema 片段
|
||||
- 测试:新增 `tests/server/checker/runner/icmp/` 测试套件
|
||||
- 文档:README.md 和 DEVELOPMENT.md 需更新,注明 ping 命令的系统依赖
|
||||
- 无新增三方依赖
|
||||
@@ -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 中记录超时错误
|
||||
192
openspec/changes/add-icmp-checker/specs/icmp-checker/spec.md
Normal file
192
openspec/changes/add-icmp-checker/specs/icmp-checker/spec.md
Normal 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** 系统平台为 linux,ping target 配置 host="10.0.0.1"、count=3、packetSize=56,且外层 timeoutMs=10000
|
||||
- **THEN** 系统 SHALL 执行 `ping -c 3 -s 56 -W 10 10.0.0.1`(-W 单位为秒,向上取整)
|
||||
|
||||
#### Scenario: ping 命令构建(macOS)
|
||||
- **WHEN** 系统平台为 darwin,ping target 配置 host="10.0.0.1"、count=3、packetSize=56,且外层 timeoutMs=10000
|
||||
- **THEN** 系统 SHALL 执行 `ping -c 3 -s 56 -W 10000 10.0.0.1`(-W 单位为毫秒)
|
||||
|
||||
#### Scenario: ping 命令构建(Windows)
|
||||
- **WHEN** 系统平台为 win32,ping target 配置 host="10.0.0.1"、count=3、packetSize=56,且外层 timeoutMs=10000
|
||||
- **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** 平台为 linux,stdout 包含 "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** 平台为 darwin,stdout 包含 "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** 平台为 win32,stdout 包含 "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** 平台为 win32,stdout 包含 "数据包: 已发送 = 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)`
|
||||
38
openspec/changes/add-icmp-checker/specs/probe-config/spec.md
Normal file
38
openspec/changes/add-icmp-checker/specs/probe-config/spec.md
Normal 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 语义
|
||||
44
openspec/changes/add-icmp-checker/tasks.md
Normal file
44
openspec/changes/add-icmp-checker/tasks.md
Normal 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 schema(config、expect),导出 icmpCheckerSchemas
|
||||
|
||||
## 2. 跨平台解析器
|
||||
|
||||
- [ ] 2.1 创建 `src/server/checker/runner/icmp/parse.ts`,实现 parsePingOutput(stdout, platform) 函数,支持 Linux/macOS/Windows 三平台解析
|
||||
- [ ] 2.2 创建 `tests/server/checker/runner/icmp/parse.test.ts`,覆盖 Linux、macOS、Windows 英文、Windows 中文、全部丢包、无法解析等场景
|
||||
|
||||
## 3. 断言函数
|
||||
|
||||
- [ ] 3.1 创建 `src/server/checker/runner/icmp/expect.ts`,实现 checkAlive、checkPacketLoss、checkAvgLatency、checkMaxLatency 函数
|
||||
- [ ] 3.2 创建 `tests/server/checker/runner/icmp/expect.test.ts`,覆盖各断言函数的通过和失败场景
|
||||
|
||||
## 4. 配置校验
|
||||
|
||||
- [ ] 4.1 创建 `src/server/checker/runner/icmp/validate.ts`,实现 validatePingConfig 语义校验
|
||||
- [ ] 4.2 创建 `tests/server/checker/runner/icmp/validate.test.ts`,覆盖 host 缺失、count/packetSize 非法、未知字段等场景
|
||||
|
||||
## 5. Checker 主体实现
|
||||
|
||||
- [ ] 5.1 创建 `src/server/checker/runner/icmp/execute.ts`,实现 IcmpChecker class(execute、resolve、serialize、validate)
|
||||
- [ ] 5.2 创建 `src/server/checker/runner/icmp/index.ts`,导出 IcmpChecker
|
||||
- [ ] 5.3 在 `src/server/checker/runner/index.ts` 中 import IcmpChecker 并将 `new IcmpChecker()` 添加到 checkers 数组
|
||||
|
||||
## 6. 集成测试
|
||||
|
||||
- [ ] 6.1 创建 `tests/server/checker/runner/icmp/execute.test.ts`,测试 IcmpChecker 的 execute 方法(mock Bun.spawn)
|
||||
- [ ] 6.2 更新 `tests/server/checker/runner/registry.test.ts`,验证 ping type 已注册
|
||||
- [ ] 6.3 更新 `tests/server/checker/config-loader.test.ts`,添加 ping target 配置解析和校验测试
|
||||
|
||||
## 7. Schema 导出与文档
|
||||
|
||||
- [ ] 7.1 更新 `probe-config.schema.json`,包含 ping target 和 expect 的 schema 片段
|
||||
- [ ] 7.2 更新 `probes.example.yaml`,添加 ping checker 配置示例
|
||||
- [ ] 7.3 更新 README.md,添加 ping checker 说明和系统依赖(ping 命令)注明
|
||||
- [ ] 7.4 更新 DEVELOPMENT.md(如有必要)
|
||||
|
||||
## 8. 质量保障
|
||||
|
||||
- [ ] 8.1 执行完整测试套件(bun test),确保所有测试通过
|
||||
- [ ] 8.2 执行代码检查(lint)和格式检查(format),确保无错误
|
||||
- [ ] 8.3 验证 probe-config.schema.json 与运行期 schema 一致(运行 schema 生成脚本)
|
||||
@@ -17,7 +17,15 @@
|
||||
|
||||
#### Scenario: 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 参数缺失
|
||||
- **WHEN** 客户端请求 `GET /api/dashboard` 未提供 window 参数
|
||||
@@ -105,7 +113,7 @@ Dashboard API SHALL 返回基于时间窗口计算的目标统计和连续状态
|
||||
|
||||
#### Scenario: TargetStatus 类型
|
||||
- **WHEN** 前后端共享 `TargetStatus` 类型
|
||||
- **THEN** 该类型 SHALL 包含 stats(totalChecks、upChecks、downChecks、availability)、currentStreak 和 recentSamples 字段
|
||||
- **THEN** 该类型 SHALL 包含目标基本信息字段(id、name、description、group、type、target、interval)、stats(totalChecks、upChecks、downChecks、availability)、currentStreak 和 recentSamples 字段,其中 name 和 description 类型均为 null 或字符串
|
||||
|
||||
#### Scenario: TargetMetricsResponse 类型
|
||||
- **WHEN** 前后端共享 `TargetMetricsResponse` 类型
|
||||
|
||||
@@ -5,9 +5,9 @@
|
||||
## Requirements
|
||||
|
||||
### 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: 完整配置文件解析
|
||||
- **WHEN** 系统启动并读取包含 server、runtime、variables、defaults、targets(含 id、group 字段)的 YAML 配置文件
|
||||
@@ -15,11 +15,11 @@
|
||||
|
||||
#### Scenario: 最简 HTTP 配置文件解析
|
||||
- **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 配置文件解析
|
||||
- **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 配置覆盖全局默认值
|
||||
- **WHEN** 某个 target 指定 interval、timeout 或对应领域分组中的默认字段
|
||||
@@ -35,7 +35,15 @@
|
||||
|
||||
#### Scenario: 最简 db 配置文件解析
|
||||
- **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 触发校验错误
|
||||
- **WHEN** 配置文件中出现 `defaults.http.method` 字段
|
||||
@@ -328,3 +336,100 @@
|
||||
#### Scenario: dataDir 使用默认值
|
||||
- **WHEN** 未配置 `server.dataDir`(使用默认值 `./data`)
|
||||
- **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 包含未知字段
|
||||
|
||||
@@ -5,12 +5,16 @@
|
||||
## Requirements
|
||||
|
||||
### 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: 首次启动创建数据库
|
||||
- **WHEN** 指定的数据目录下不存在数据库文件
|
||||
- **THEN** 系统 SHALL 创建数据库文件并初始化 targets 表和 check_results 表,check_results 表包含 id(INTEGER PRIMARY KEY AUTOINCREMENT)、target_id(INTEGER NOT NULL)、timestamp(TEXT NOT NULL)、matched(INTEGER NOT NULL)、duration_ms(REAL)、status_detail(TEXT)、failure(TEXT),不包含 success 列
|
||||
|
||||
#### Scenario: targets name 列允许 NULL
|
||||
- **WHEN** 系统首次创建 targets 表
|
||||
- **THEN** targets.name 列 SHALL 允许存储 NULL
|
||||
|
||||
#### Scenario: 数据目录不存在
|
||||
- **WHEN** 配置的数据目录路径不存在
|
||||
- **THEN** 系统 SHALL 自动创建该目录
|
||||
@@ -26,15 +30,31 @@
|
||||
- **THEN** check_results 表的外键约束 SHALL 使用 `ON DELETE CASCADE`,确保删除目标时自动清理关联结果记录
|
||||
|
||||
### Requirement: targets 表同步
|
||||
系统 SHALL 在启动时将 YAML 配置中的目标列表同步到 SQLite targets 表,并持久化 target 类型、展示摘要、领域配置、调度配置、expect 配置和分组信息。
|
||||
系统 SHALL 在启动时将 YAML 配置中的目标列表同步到 SQLite targets 表,并持久化 target 类型、展示名称元信息、展示摘要、领域配置、调度配置、expect 配置、分组信息和目标说明。
|
||||
|
||||
#### Scenario: 首次同步目标
|
||||
- **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: 配置变更后重新同步
|
||||
- **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 表追加写入
|
||||
系统 SHALL 将每次检查结果追加写入 check_results 表,不更新或删除已有记录。
|
||||
|
||||
@@ -13,7 +13,11 @@ Dashboard SHALL 在用户点击目标表格行后从右侧滑出 Drawer,展示
|
||||
|
||||
#### Scenario: 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
|
||||
- **WHEN** 用户点击关闭按钮、ESC 键或遮罩层
|
||||
@@ -215,7 +219,11 @@ Drawer 内部 SHALL 使用 TDesign Tabs 组织概览和记录两个面板。Tabs
|
||||
|
||||
#### Scenario: 基本信息内容
|
||||
- **WHEN** 概览面板渲染
|
||||
- **THEN** Descriptions SHALL 展示:目标地址、检查间隔、最新检查时间、状态详情
|
||||
- **THEN** Descriptions SHALL 展示:目标地址、检查间隔、最新检查时间、状态详情、描述,其中描述 SHALL 位于最后一行
|
||||
|
||||
#### Scenario: 描述行占满整行
|
||||
- **WHEN** 概览面板渲染基本信息
|
||||
- **THEN** 描述项 SHALL 占据 Descriptions 的一整行,内容 SHALL 使用 `target.description ?? ""`,即使 description 为空也 SHALL 渲染该项
|
||||
|
||||
#### Scenario: 统计区上下布局卡片
|
||||
- **WHEN** 概览面板渲染且有统计数据
|
||||
|
||||
@@ -5,7 +5,7 @@
|
||||
## Requirements
|
||||
|
||||
### 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
|
||||
- **WHEN** target 配置 `id: "api-health"`
|
||||
@@ -23,6 +23,10 @@
|
||||
- **WHEN** target 配置 `id: ""`
|
||||
- **THEN** 系统 SHALL 以配置错误退出,提示 id 不能为空
|
||||
|
||||
#### Scenario: id 超过最大长度报错
|
||||
- **WHEN** target 配置超过 30 个字符的 `id`
|
||||
- **THEN** 系统 SHALL 以配置错误退出,提示 id 长度不合法
|
||||
|
||||
#### Scenario: id 不合法报错
|
||||
- **WHEN** target 配置 `id: "_invalid"` 或 `id: "-start"` 或 `id: "has space"`
|
||||
- **THEN** 系统 SHALL 以配置错误退出,提示 id 不符合命名规则
|
||||
@@ -32,20 +36,75 @@
|
||||
- **THEN** 系统 SHALL 以配置错误退出,提示 id 重复
|
||||
|
||||
### 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
|
||||
- **WHEN** target 配置 `id: "api-health"` 和 `name: "API 健康检查"`
|
||||
- **THEN** 系统 SHALL 使用 "API 健康检查" 作为展示名称
|
||||
- **THEN** 系统 SHALL 在解析后保留 name 为 "API 健康检查"
|
||||
|
||||
#### Scenario: name 使用变量
|
||||
- **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`
|
||||
- **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
|
||||
- **WHEN** 两个 target 配置不同 id 但相同 `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 长度不合法
|
||||
|
||||
@@ -44,7 +44,11 @@ Dashboard SHALL 按 group 字段将目标分组,每个分组使用 TDesign Car
|
||||
|
||||
#### Scenario: 名称列
|
||||
- **WHEN** 表格渲染
|
||||
- **THEN** 名称列 SHALL 显示目标名称,支持字母排序(zh-CN),ellipsis 超长名称自动省略并 Tooltip 显示全名
|
||||
- **THEN** 名称列 SHALL 显示目标展示名称,取值为 `target.name ?? target.id`,ellipsis 超长名称自动省略并 Tooltip 显示全名,且 SHALL NOT 支持排序
|
||||
|
||||
#### Scenario: name 为 null 的名称列
|
||||
- **WHEN** 表格渲染某个 `target.name` 为 null 的目标
|
||||
- **THEN** 名称列 SHALL 显示该目标的 `target.id`
|
||||
|
||||
#### Scenario: 类型列
|
||||
- **WHEN** 表格渲染
|
||||
@@ -68,7 +72,7 @@ Dashboard SHALL 按 group 字段将目标分组,每个分组使用 TDesign Car
|
||||
|
||||
#### Scenario: 延迟列
|
||||
- **WHEN** 表格渲染
|
||||
- **THEN** 延迟列 SHALL 显示最近一次检查的延迟毫秒数,右对齐,颜色通过 CSS 类控制;超过 9999ms 时 SHALL 显示为"9999+ms"
|
||||
- **THEN** 延迟列标题 SHALL 展示为"延迟(ms)",单元格 SHALL 显示最近一次检查的延迟毫秒数值并右对齐,颜色通过 CSS 类控制;超过 9999ms 时 SHALL 显示为"9999+"
|
||||
|
||||
#### Scenario: 间隔列移除
|
||||
- **WHEN** 表格渲染
|
||||
|
||||
113
openspec/specs/tcp-checker/spec.md
Normal file
113
openspec/specs/tcp-checker/spec.md
Normal 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 包含未知字段
|
||||
@@ -62,6 +62,27 @@
|
||||
"additionalProperties": false,
|
||||
"type": "object",
|
||||
"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"
|
||||
],
|
||||
"properties": {
|
||||
"description": {
|
||||
"anyOf": [
|
||||
{
|
||||
"type": "null"
|
||||
},
|
||||
{
|
||||
"maxLength": 500,
|
||||
"type": "string"
|
||||
}
|
||||
]
|
||||
},
|
||||
"expect": {
|
||||
"additionalProperties": false,
|
||||
"type": "object",
|
||||
@@ -404,6 +436,7 @@
|
||||
"type": "string"
|
||||
},
|
||||
"id": {
|
||||
"maxLength": 30,
|
||||
"minLength": 1,
|
||||
"type": "string"
|
||||
},
|
||||
@@ -411,8 +444,16 @@
|
||||
"type": "string"
|
||||
},
|
||||
"name": {
|
||||
"anyOf": [
|
||||
{
|
||||
"type": "null"
|
||||
},
|
||||
{
|
||||
"maxLength": 30,
|
||||
"minLength": 1,
|
||||
"type": "string"
|
||||
}
|
||||
]
|
||||
},
|
||||
"timeout": {
|
||||
"type": "string"
|
||||
@@ -504,6 +545,17 @@
|
||||
"cmd"
|
||||
],
|
||||
"properties": {
|
||||
"description": {
|
||||
"anyOf": [
|
||||
{
|
||||
"type": "null"
|
||||
},
|
||||
{
|
||||
"maxLength": 500,
|
||||
"type": "string"
|
||||
}
|
||||
]
|
||||
},
|
||||
"expect": {
|
||||
"additionalProperties": false,
|
||||
"type": "object",
|
||||
@@ -640,6 +692,7 @@
|
||||
"type": "string"
|
||||
},
|
||||
"id": {
|
||||
"maxLength": 30,
|
||||
"minLength": 1,
|
||||
"type": "string"
|
||||
},
|
||||
@@ -647,8 +700,16 @@
|
||||
"type": "string"
|
||||
},
|
||||
"name": {
|
||||
"anyOf": [
|
||||
{
|
||||
"type": "null"
|
||||
},
|
||||
{
|
||||
"maxLength": 30,
|
||||
"minLength": 1,
|
||||
"type": "string"
|
||||
}
|
||||
]
|
||||
},
|
||||
"timeout": {
|
||||
"type": "string"
|
||||
@@ -707,6 +768,17 @@
|
||||
"db"
|
||||
],
|
||||
"properties": {
|
||||
"description": {
|
||||
"anyOf": [
|
||||
{
|
||||
"type": "null"
|
||||
},
|
||||
{
|
||||
"maxLength": 500,
|
||||
"type": "string"
|
||||
}
|
||||
]
|
||||
},
|
||||
"expect": {
|
||||
"additionalProperties": false,
|
||||
"type": "object",
|
||||
@@ -869,6 +941,7 @@
|
||||
"type": "string"
|
||||
},
|
||||
"id": {
|
||||
"maxLength": 30,
|
||||
"minLength": 1,
|
||||
"type": "string"
|
||||
},
|
||||
@@ -876,8 +949,16 @@
|
||||
"type": "string"
|
||||
},
|
||||
"name": {
|
||||
"anyOf": [
|
||||
{
|
||||
"type": "null"
|
||||
},
|
||||
{
|
||||
"maxLength": 30,
|
||||
"minLength": 1,
|
||||
"type": "string"
|
||||
}
|
||||
]
|
||||
},
|
||||
"timeout": {
|
||||
"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"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
@@ -27,6 +27,7 @@ targets:
|
||||
|
||||
- id: "baidu-home"
|
||||
name: "Baidu 首页可用"
|
||||
description: "监控百度首页的可用性和响应时间"
|
||||
type: http
|
||||
group: "搜索引擎"
|
||||
http:
|
||||
@@ -144,3 +145,28 @@ targets:
|
||||
exists: true
|
||||
role:
|
||||
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"
|
||||
|
||||
@@ -144,6 +144,7 @@ function resolveTarget(
|
||||
|
||||
result.intervalMs = intervalMs;
|
||||
result.timeoutMs = timeoutMs;
|
||||
result.description = target.description ?? null;
|
||||
|
||||
return result;
|
||||
}
|
||||
@@ -178,6 +179,10 @@ function validateConfig(config: RawProbeConfig): ConfigValidationIssue[] {
|
||||
const nameValue: unknown = raw["name"];
|
||||
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"];
|
||||
if (!isString(type)) {
|
||||
issues.push(issue("required", `targets[${i}].type`, "缺少 type 字段", name));
|
||||
|
||||
@@ -187,11 +187,12 @@ export class CommandChecker implements CheckerDefinition<ResolvedCommandTarget>
|
||||
exec: t.cmd.exec,
|
||||
maxOutputBytes,
|
||||
},
|
||||
description: null,
|
||||
expect: target.expect as CommandExpectConfig | undefined,
|
||||
group: target.group ?? "default",
|
||||
id: t.id,
|
||||
intervalMs: context.defaultIntervalMs,
|
||||
name: t.name ?? t.id,
|
||||
name: t.name ?? null,
|
||||
timeoutMs: context.defaultTimeoutMs,
|
||||
type: "cmd",
|
||||
} satisfies ResolvedCommandTarget;
|
||||
|
||||
@@ -33,7 +33,7 @@ export interface ResolvedCommandTarget extends ResolvedTargetBase {
|
||||
expect?: CommandExpectConfig;
|
||||
group: string;
|
||||
intervalMs: number;
|
||||
name: string;
|
||||
name: null | string;
|
||||
timeoutMs: number;
|
||||
type: "cmd";
|
||||
}
|
||||
|
||||
@@ -180,11 +180,12 @@ export class DbChecker implements CheckerDefinition<ResolvedDbTarget> {
|
||||
query: t.db.query,
|
||||
url: t.db.url,
|
||||
},
|
||||
description: null,
|
||||
expect: target.expect as DbExpectConfig | undefined,
|
||||
group: target.group ?? "default",
|
||||
id: t.id,
|
||||
intervalMs: context.defaultIntervalMs,
|
||||
name: t.name ?? t.id,
|
||||
name: t.name ?? null,
|
||||
timeoutMs: context.defaultTimeoutMs,
|
||||
type: "db",
|
||||
} satisfies ResolvedDbTarget;
|
||||
|
||||
@@ -21,7 +21,7 @@ export interface ResolvedDbTarget extends ResolvedTargetBase {
|
||||
expect?: DbExpectConfig;
|
||||
group: string;
|
||||
intervalMs: number;
|
||||
name: string;
|
||||
name: null | string;
|
||||
timeoutMs: number;
|
||||
type: "db";
|
||||
}
|
||||
|
||||
@@ -112,6 +112,7 @@ export class HttpChecker implements CheckerDefinition<ResolvedHttpTarget> {
|
||||
const maxBodyBytes = parseSize(t.http.maxBodyBytes ?? httpDefaults?.maxBodyBytes ?? "100MB");
|
||||
|
||||
return {
|
||||
description: null,
|
||||
expect: target.expect as HttpExpectConfig | undefined,
|
||||
group: target.group ?? "default",
|
||||
http: {
|
||||
@@ -125,7 +126,7 @@ export class HttpChecker implements CheckerDefinition<ResolvedHttpTarget> {
|
||||
},
|
||||
id: t.id,
|
||||
intervalMs: context.defaultIntervalMs,
|
||||
name: t.name ?? t.id,
|
||||
name: t.name ?? null,
|
||||
timeoutMs: context.defaultTimeoutMs,
|
||||
type: "http",
|
||||
} satisfies ResolvedHttpTarget;
|
||||
|
||||
@@ -51,7 +51,7 @@ export interface ResolvedHttpTarget extends ResolvedTargetBase {
|
||||
group: string;
|
||||
http: ResolvedHttpConfig;
|
||||
intervalMs: number;
|
||||
name: string;
|
||||
name: null | string;
|
||||
timeoutMs: number;
|
||||
type: "http";
|
||||
}
|
||||
|
||||
@@ -2,8 +2,9 @@ import { CommandChecker } from "./cmd";
|
||||
import { DbChecker } from "./db";
|
||||
import { HttpChecker } from "./http";
|
||||
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 {
|
||||
const registry = new CheckerRegistry();
|
||||
|
||||
358
src/server/checker/runner/tcp/execute.ts
Normal file
358
src/server/checker/runner/tcp/execute.ts
Normal 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)}…`;
|
||||
}
|
||||
30
src/server/checker/runner/tcp/expect.ts
Normal file
30
src/server/checker/runner/tcp/expect.ts
Normal 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,
|
||||
};
|
||||
}
|
||||
1
src/server/checker/runner/tcp/index.ts
Normal file
1
src/server/checker/runner/tcp/index.ts
Normal file
@@ -0,0 +1 @@
|
||||
export { TcpChecker } from "./execute";
|
||||
33
src/server/checker/runner/tcp/schema.ts
Normal file
33
src/server/checker/runner/tcp/schema.ts
Normal 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 },
|
||||
),
|
||||
};
|
||||
38
src/server/checker/runner/tcp/types.ts
Normal file
38
src/server/checker/runner/tcp/types.ts
Normal 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;
|
||||
}
|
||||
163
src/server/checker/runner/tcp/validate.ts
Normal file
163
src/server/checker/runner/tcp/validate.ts
Normal 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 };
|
||||
@@ -49,11 +49,12 @@ export function createProbeConfigSchema(checkers: CheckerDefinition[], external
|
||||
|
||||
export function createTargetSchema(checker: CheckerDefinition): TSchema {
|
||||
const properties: Record<string, TSchema> = {
|
||||
description: Type.Optional(Type.Union([Type.Null(), Type.String({ maxLength: 500 })])),
|
||||
expect: Type.Optional(checker.schemas.expect),
|
||||
group: Type.Optional(Type.String()),
|
||||
id: Type.String({ minLength: 1 }),
|
||||
id: Type.String({ maxLength: 30, minLength: 1 }),
|
||||
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),
|
||||
type: Type.Literal(checker.type),
|
||||
};
|
||||
@@ -68,10 +69,11 @@ function cloneSchema(schema: TSchema): Record<string, unknown> {
|
||||
function createBaseTargetSchema(checkers: CheckerDefinition[]): TSchema {
|
||||
return Type.Object(
|
||||
{
|
||||
description: Type.Optional(Type.Union([Type.Null(), Type.String({ maxLength: 500 })])),
|
||||
group: Type.Optional(Type.String()),
|
||||
id: Type.String({ minLength: 1 }),
|
||||
id: Type.String({ maxLength: 30, minLength: 1 }),
|
||||
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),
|
||||
type: Type.Union(checkers.map((checker) => Type.Literal(checker.type)) as unknown as [TSchema, ...TSchema[]]),
|
||||
},
|
||||
|
||||
@@ -9,7 +9,8 @@ import { checkerRegistry } from "./runner";
|
||||
const CREATE_TARGETS_TABLE = `
|
||||
CREATE TABLE IF NOT EXISTS targets (
|
||||
id TEXT PRIMARY KEY,
|
||||
name TEXT NOT NULL,
|
||||
name TEXT,
|
||||
description TEXT,
|
||||
type TEXT NOT NULL,
|
||||
target TEXT NOT NULL,
|
||||
config TEXT NOT NULL DEFAULT '{}',
|
||||
@@ -308,18 +309,15 @@ export class ProbeStore {
|
||||
|
||||
syncTargets(targets: ResolvedTargetBase[]): void {
|
||||
if (this.closed) return;
|
||||
const existingRows = this.db.query("SELECT id FROM targets").all() as Array<{
|
||||
id: string;
|
||||
name: string;
|
||||
}>;
|
||||
const existingRows = this.db.query("SELECT id FROM targets").all() as Array<{ id: string }>;
|
||||
const existingIds = new Set(existingRows.map((r) => r.id));
|
||||
const configIds = new Set(targets.map((t) => t.id));
|
||||
|
||||
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(
|
||||
"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 = ?");
|
||||
|
||||
@@ -332,9 +330,9 @@ export class ProbeStore {
|
||||
const expect = t.expect ? JSON.stringify(t.expect) : null;
|
||||
|
||||
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 {
|
||||
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);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -41,22 +41,24 @@ export interface ProbeConfig {
|
||||
|
||||
export interface RawTargetConfig {
|
||||
[configKey: string]: unknown;
|
||||
description?: null | string;
|
||||
expect?: unknown;
|
||||
group?: string;
|
||||
id: string;
|
||||
interval?: string;
|
||||
name?: string;
|
||||
name?: null | string;
|
||||
timeout?: string;
|
||||
type: string;
|
||||
}
|
||||
|
||||
export interface ResolvedTargetBase {
|
||||
[key: string]: unknown;
|
||||
description: null | string;
|
||||
expect?: unknown;
|
||||
group: string;
|
||||
id: string;
|
||||
intervalMs: number;
|
||||
name: string;
|
||||
name: null | string;
|
||||
timeoutMs: number;
|
||||
type: string;
|
||||
}
|
||||
@@ -79,11 +81,12 @@ export interface StoredCheckResult {
|
||||
|
||||
export interface StoredTarget {
|
||||
config: string;
|
||||
description: null | string;
|
||||
expect: null | string;
|
||||
grp: string;
|
||||
id: string;
|
||||
interval_ms: number;
|
||||
name: string;
|
||||
name: null | string;
|
||||
target: string;
|
||||
timeout_ms: number;
|
||||
type: string;
|
||||
|
||||
@@ -56,6 +56,7 @@ export function handleDashboard(url: URL, store: ProbeStore, mode: RuntimeMode):
|
||||
|
||||
return {
|
||||
currentStreak,
|
||||
description: target.description,
|
||||
group: target.grp,
|
||||
id: target.id,
|
||||
interval: formatDuration(target.interval_ms),
|
||||
|
||||
@@ -99,11 +99,12 @@ export interface TargetStats {
|
||||
|
||||
export interface TargetStatus {
|
||||
currentStreak: CurrentStreak | null;
|
||||
description: null | string;
|
||||
group: string;
|
||||
id: string;
|
||||
interval: string;
|
||||
latestCheck: CheckResult | null;
|
||||
name: string;
|
||||
name: null | string;
|
||||
recentSamples: RecentSample[];
|
||||
stats: TargetStats;
|
||||
target: string;
|
||||
|
||||
@@ -38,6 +38,7 @@ export function OverviewTab({ metricsData, metricsLoading, target }: OverviewTab
|
||||
label: "最新检查时间",
|
||||
},
|
||||
{ content: target.latestCheck?.statusDetail ?? "-", label: "状态详情" },
|
||||
{ content: target.description ?? "", label: "描述", span: 2 },
|
||||
]}
|
||||
/>
|
||||
|
||||
|
||||
@@ -5,6 +5,7 @@ import { DateRangePicker, Drawer, RadioGroup, Space, Tabs, Tag, Typography } fro
|
||||
|
||||
import type { HistoryResponse, TargetMetricsResponse, TargetStatus } from "../../shared/api";
|
||||
|
||||
import { getTargetDisplayName } from "../utils/target";
|
||||
import { subtractHours } from "../utils/time";
|
||||
import { HistoryTab } from "./HistoryTab";
|
||||
import { OverviewTab } from "./OverviewTab";
|
||||
@@ -90,7 +91,7 @@ export function TargetDetailDrawer({
|
||||
target ? (
|
||||
<Space align="center" size={12}>
|
||||
<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">
|
||||
{target.type}
|
||||
</Tag>
|
||||
|
||||
@@ -6,9 +6,10 @@ import type { TargetStatus } from "../../shared/api";
|
||||
|
||||
import { StatusBar } from "../components/StatusBar";
|
||||
import { StatusDot } from "../components/StatusDot";
|
||||
import { getTargetDisplayName } from "../utils/target";
|
||||
import { getAvailabilityProgressColor } from "./color-threshold";
|
||||
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>> {
|
||||
return [
|
||||
@@ -22,10 +23,9 @@ export function createTargetTableColumns(checkerTypes: string[]): Array<PrimaryT
|
||||
width: 60,
|
||||
},
|
||||
{
|
||||
cell: ({ row }: PrimaryTableCellParams<TargetStatus>) => getTargetDisplayName(row),
|
||||
colKey: "name",
|
||||
ellipsis: true,
|
||||
sorter: nameSorter,
|
||||
sortType: "all",
|
||||
title: "名称",
|
||||
},
|
||||
{
|
||||
@@ -88,13 +88,13 @@ export function createTargetTableColumns(checkerTypes: string[]): Array<PrimaryT
|
||||
const ms = row.latestCheck?.durationMs;
|
||||
if (ms === null || ms === undefined) return <span className="text-disabled">-</span>;
|
||||
const colorClass = ms <= 100 ? "latency-ok" : ms <= 500 ? "latency-warn" : "latency-error";
|
||||
const latencyText = ms > 9999 ? "9999+ms" : `${Math.round(ms)}ms`;
|
||||
const latencyText = ms > 9999 ? "9999+" : `${Math.round(ms)}`;
|
||||
return <span className={`${colorClass} latency-value tabular-nums`}>{latencyText}</span>;
|
||||
},
|
||||
colKey: "latestCheck.durationMs",
|
||||
sorter: latencySorter,
|
||||
sortType: "all",
|
||||
title: "延迟",
|
||||
title: "延迟(ms)",
|
||||
width: 75,
|
||||
},
|
||||
];
|
||||
|
||||
@@ -13,10 +13,6 @@ export function latencySorter(a: TargetStatus, b: TargetStatus): number {
|
||||
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 {
|
||||
return getStatusRank(a) - getStatusRank(b);
|
||||
}
|
||||
|
||||
5
src/web/utils/target.ts
Normal file
5
src/web/utils/target.ts
Normal file
@@ -0,0 +1,5 @@
|
||||
import type { TargetStatus } from "../../shared/api";
|
||||
|
||||
export function getTargetDisplayName(target: TargetStatus): string {
|
||||
return target.name ?? target.id;
|
||||
}
|
||||
@@ -41,6 +41,7 @@ describe("API 路由", () => {
|
||||
store = new ProbeStore(join(tempDir, "test.db"));
|
||||
store.syncTargets([
|
||||
{
|
||||
description: null,
|
||||
group: "default",
|
||||
http: {
|
||||
headers: {},
|
||||
@@ -64,6 +65,7 @@ describe("API 路由", () => {
|
||||
exec: "echo",
|
||||
maxOutputBytes: 104857600,
|
||||
},
|
||||
description: null,
|
||||
group: "default",
|
||||
id: "test-b",
|
||||
intervalMs: 60000,
|
||||
|
||||
@@ -11,6 +11,7 @@ import { bootstrap, type BootstrapDependencies } from "../../src/server/bootstra
|
||||
type ShutdownSignal = "SIGINT" | "SIGTERM";
|
||||
|
||||
const target: ResolvedTargetBase = {
|
||||
description: null,
|
||||
group: "default",
|
||||
id: "test",
|
||||
intervalMs: 30000,
|
||||
|
||||
@@ -5,6 +5,7 @@ import { join } from "node:path";
|
||||
|
||||
import type { ResolvedCommandTarget } from "../../../src/server/checker/runner/cmd/types";
|
||||
import type { ResolvedHttpTarget } from "../../../src/server/checker/runner/http/types";
|
||||
import type { ResolvedTcpTarget } from "../../../src/server/checker/runner/tcp/types";
|
||||
|
||||
import { loadConfig, parseDuration } from "../../../src/server/checker/config-loader";
|
||||
import { checkerRegistry } from "../../../src/server/checker/runner";
|
||||
@@ -234,7 +235,7 @@ targets:
|
||||
expect(cmd.cmd.maxOutputBytes).toBe(10485760);
|
||||
});
|
||||
|
||||
test("name 缺省时 fallback 到 id", async () => {
|
||||
test("name 缺省时保留为 null", async () => {
|
||||
const configPath = join(tempDir, "name-fallback.yaml");
|
||||
await writeFile(
|
||||
configPath,
|
||||
@@ -249,7 +250,105 @@ targets:
|
||||
const config = await loadConfig(configPath);
|
||||
const target = config.targets[0]!;
|
||||
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 () => {
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -55,6 +55,7 @@ function makeCommandTarget(name: string, overrides?: Partial<ResolvedCommandTarg
|
||||
exec: "bun",
|
||||
maxOutputBytes: 1024 * 1024,
|
||||
},
|
||||
description: null,
|
||||
group: "default",
|
||||
id: name,
|
||||
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 probeGroup = (
|
||||
@@ -259,6 +260,7 @@ describe("ProbeEngine", () => {
|
||||
|
||||
try {
|
||||
const httpTarget: ResolvedHttpTarget = {
|
||||
description: null,
|
||||
group: "default",
|
||||
http: {
|
||||
headers: {},
|
||||
|
||||
@@ -30,6 +30,7 @@ function makeTarget(
|
||||
maxOutputBytes: 1024 * 1024,
|
||||
...cmd,
|
||||
},
|
||||
description: null,
|
||||
group: "default",
|
||||
id: "test-cmd",
|
||||
intervalMs: 60000,
|
||||
|
||||
@@ -19,6 +19,7 @@ function makeTarget(db: Partial<ResolvedDbTarget["db"]>, overrides?: Partial<Res
|
||||
url: "sqlite://:memory:",
|
||||
...db,
|
||||
},
|
||||
description: null,
|
||||
group: "default",
|
||||
id: "test-db",
|
||||
intervalMs: 60000,
|
||||
|
||||
@@ -155,6 +155,7 @@ describe("HttpChecker", () => {
|
||||
url?: string;
|
||||
}): ResolvedHttpTarget {
|
||||
return {
|
||||
description: null,
|
||||
expect: overrides.expect,
|
||||
group: "default",
|
||||
http: {
|
||||
|
||||
@@ -66,8 +66,8 @@ describe("CheckerRegistry", () => {
|
||||
const second = createDefaultCheckerRegistry();
|
||||
first.register(createChecker("custom"));
|
||||
|
||||
expect(first.supportedTypes).toEqual(["http", "cmd", "db", "custom"]);
|
||||
expect(second.supportedTypes).toEqual(["http", "cmd", "db"]);
|
||||
expect(first.supportedTypes).toEqual(["http", "cmd", "db", "tcp", "custom"]);
|
||||
expect(second.supportedTypes).toEqual(["http", "cmd", "db", "tcp"]);
|
||||
expect(
|
||||
first.definitions.every(
|
||||
(checker) => checker.schemas.config && checker.schemas.defaults && checker.schemas.expect,
|
||||
|
||||
367
tests/server/checker/runner/tcp/execute.test.ts
Normal file
367
tests/server/checker/runner/tcp/execute.test.ts
Normal 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 必填 handler,echo server 不处理数据
|
||||
},
|
||||
end(socket) {
|
||||
try {
|
||||
socket.close();
|
||||
} catch {
|
||||
// best-effort 关闭
|
||||
}
|
||||
},
|
||||
error() {
|
||||
// Bun.listen 必填 handler,测试 server 忽略错误
|
||||
},
|
||||
open() {
|
||||
// Bun.listen 必填 handler,open 时无需操作
|
||||
},
|
||||
},
|
||||
});
|
||||
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");
|
||||
});
|
||||
});
|
||||
65
tests/server/checker/runner/tcp/expect.test.ts
Normal file
65
tests/server/checker/runner/tcp/expect.test.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
191
tests/server/checker/runner/tcp/validate.test.ts
Normal file
191
tests/server/checker/runner/tcp/validate.test.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
@@ -24,11 +24,8 @@ beforeAll(() => {
|
||||
ensureRegistered();
|
||||
});
|
||||
|
||||
function targetId(store: ProbeStore, name: string): string {
|
||||
return store.getTargets().find((target) => target.name === name)!.id;
|
||||
}
|
||||
|
||||
const httpTarget: ResolvedHttpTarget = {
|
||||
description: null,
|
||||
expect: { maxDurationMs: 3000, status: [200] },
|
||||
group: "default",
|
||||
http: {
|
||||
@@ -54,6 +51,7 @@ const commandTarget: ResolvedCommandTarget = {
|
||||
exec: "ping",
|
||||
maxOutputBytes: 104857600,
|
||||
},
|
||||
description: null,
|
||||
group: "default",
|
||||
id: "test-cmd",
|
||||
intervalMs: 60000,
|
||||
@@ -85,11 +83,11 @@ describe("ProbeStore", () => {
|
||||
store.syncTargets([httpTarget, commandTarget]);
|
||||
const targets = store.getTargets();
|
||||
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 字段正确", () => {
|
||||
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.target).toBe("https://example.com/health");
|
||||
const config = JSON.parse(t.config) as {
|
||||
@@ -112,7 +110,7 @@ describe("ProbeStore", () => {
|
||||
});
|
||||
|
||||
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.target).toBe("exec ping -c 1 localhost");
|
||||
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" },
|
||||
};
|
||||
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(store.getTargets()).toHaveLength(2);
|
||||
});
|
||||
@@ -149,7 +147,7 @@ describe("ProbeStore", () => {
|
||||
});
|
||||
|
||||
test("getTargetById", () => {
|
||||
const found = store.getTargetById(targetId(store, "test-http"));
|
||||
const found = store.getTargetById("test-http");
|
||||
expect(found).toBeDefined();
|
||||
expect(found!.name).toBe("test-http");
|
||||
});
|
||||
@@ -160,14 +158,13 @@ describe("ProbeStore", () => {
|
||||
|
||||
test("写入 check result 并查询", () => {
|
||||
store.syncTargets([httpTarget, commandTarget]);
|
||||
const t1Id = targetId(store, "test-http");
|
||||
|
||||
store.insertCheckResult({
|
||||
durationMs: 150.5,
|
||||
failure: null,
|
||||
matched: true,
|
||||
statusDetail: "200 OK",
|
||||
targetId: t1Id,
|
||||
targetId: "test-http",
|
||||
timestamp: "2025-01-01T00:00:00.000Z",
|
||||
});
|
||||
|
||||
@@ -176,7 +173,7 @@ describe("ProbeStore", () => {
|
||||
failure: null,
|
||||
matched: true,
|
||||
statusDetail: "200 OK",
|
||||
targetId: t1Id,
|
||||
targetId: "test-http",
|
||||
timestamp: "2025-01-01T00:00:30.000Z",
|
||||
});
|
||||
|
||||
@@ -194,15 +191,15 @@ describe("ProbeStore", () => {
|
||||
failure,
|
||||
matched: false,
|
||||
statusDetail: null,
|
||||
targetId: t1Id,
|
||||
targetId: "test-http",
|
||||
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[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.failure).not.toBeNull();
|
||||
const parsedFailure = JSON.parse(latest.failure!) as CheckFailure;
|
||||
@@ -212,27 +209,23 @@ describe("ProbeStore", () => {
|
||||
});
|
||||
|
||||
test("getHistory 默认 limit=20", () => {
|
||||
const t1Id = targetId(store, "test-http");
|
||||
|
||||
for (let i = 0; i < 25; i++) {
|
||||
store.insertCheckResult({
|
||||
durationMs: 100 + i,
|
||||
failure: null,
|
||||
matched: true,
|
||||
statusDetail: "200 OK",
|
||||
targetId: t1Id,
|
||||
targetId: "test-http",
|
||||
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);
|
||||
});
|
||||
|
||||
test("getTargetWindowStats 按时间窗口计算基础计数", () => {
|
||||
const t1Id = targetId(store, "test-http");
|
||||
|
||||
const stats = store.getTargetWindowStats(t1Id, "2025-01-01T00:00:00.000Z", "2025-01-01T00:01:00.000Z");
|
||||
const stats = store.getTargetWindowStats("test-http", "2025-01-01T00:00:00.000Z", "2025-01-01T00:01:00.000Z");
|
||||
expect(stats.totalChecks).toBeGreaterThan(0);
|
||||
expect(stats.upChecks + stats.downChecks).toBe(stats.totalChecks);
|
||||
expect(stats.availability).toBeGreaterThanOrEqual(0);
|
||||
@@ -240,9 +233,7 @@ describe("ProbeStore", () => {
|
||||
});
|
||||
|
||||
test("无记录目标的窗口 stats", () => {
|
||||
const t2Id = targetId(store, "test-cmd");
|
||||
|
||||
const stats = store.getTargetWindowStats(t2Id, "2025-01-01T00:00:00.000Z", "2025-01-01T00:01:00.000Z");
|
||||
const stats = store.getTargetWindowStats("test-cmd", "2025-01-01T00:00:00.000Z", "2025-01-01T00:01:00.000Z");
|
||||
expect(stats.totalChecks).toBe(0);
|
||||
expect(stats.upChecks).toBe(0);
|
||||
expect(stats.downChecks).toBe(0);
|
||||
@@ -251,16 +242,14 @@ describe("ProbeStore", () => {
|
||||
|
||||
test("getLatestChecksMap 支持 Dashboard 组装当前状态", () => {
|
||||
const latestChecksMap = store.getLatestChecksMap();
|
||||
const latest = latestChecksMap.get(targetId(store, "test-http"));
|
||||
const latest = latestChecksMap.get("test-http");
|
||||
|
||||
expect(latest).toBeDefined();
|
||||
expect(latest!.timestamp).toBe("2025-01-01T01:24:00.000Z");
|
||||
});
|
||||
|
||||
test("getTargetCheckpoints 返回窗口内升序检查点", () => {
|
||||
const t1Id = targetId(store, "test-http");
|
||||
|
||||
const checkpoints = store.getTargetCheckpoints(t1Id, "2025-01-01T00:00:00.000Z", "2025-01-01T00:01:00.000Z");
|
||||
const checkpoints = store.getTargetCheckpoints("test-http", "2025-01-01T00:00:00.000Z", "2025-01-01T00:01:00.000Z");
|
||||
expect(checkpoints).toEqual([
|
||||
{ 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" },
|
||||
@@ -269,16 +258,12 @@ describe("ProbeStore", () => {
|
||||
});
|
||||
|
||||
test("getTargetDurations 返回成功检查耗时升序数组", () => {
|
||||
const t1Id = targetId(store, "test-http");
|
||||
|
||||
const durations = store.getTargetDurations(t1Id, "2025-01-01T00:00:00.000Z", "2025-01-01T00:01:00.000Z");
|
||||
const durations = store.getTargetDurations("test-http", "2025-01-01T00:00:00.000Z", "2025-01-01T00:01:00.000Z");
|
||||
expect(durations).toEqual([150.5, 300]);
|
||||
});
|
||||
|
||||
test("getRecentSamples 返回最近采样数据", () => {
|
||||
const t1Id = targetId(store, "test-http");
|
||||
|
||||
const samples = store.getRecentSamples(t1Id, 10);
|
||||
const samples = store.getRecentSamples("test-http", 10);
|
||||
expect(Array.isArray(samples)).toBe(true);
|
||||
expect(samples.length).toBeGreaterThan(0);
|
||||
for (const sample of samples) {
|
||||
@@ -304,9 +289,9 @@ describe("ProbeStore", () => {
|
||||
};
|
||||
sampleStore.syncTargets([httpA, httpB, httpEmpty]);
|
||||
const targets = sampleStore.getTargets();
|
||||
const targetAId = targets.find((t) => t.name === "sample-http-a")!.id;
|
||||
const targetBId = targets.find((t) => t.name === "sample-http-b")!.id;
|
||||
const emptyTargetId = targets.find((t) => t.name === "sample-http-empty")!.id;
|
||||
const targetAId = targets.find((t) => t.id === "sample-http-a")!.id;
|
||||
const targetBId = targets.find((t) => t.id === "sample-http-b")!.id;
|
||||
const emptyTargetId = targets.find((t) => t.id === "sample-http-empty")!.id;
|
||||
|
||||
for (const [index, timestamp] of [
|
||||
"2025-01-01T00:00:00.000Z",
|
||||
@@ -364,6 +349,7 @@ describe("ProbeStore", () => {
|
||||
test("删除 target 级联删除 check_results", () => {
|
||||
const cascadeStore = new ProbeStore(join(tempDir, "cascade.db"));
|
||||
const cascadeTarget: ResolvedHttpTarget = {
|
||||
description: null,
|
||||
group: "default",
|
||||
http: {
|
||||
headers: {},
|
||||
@@ -427,6 +413,7 @@ describe("ProbeStore", () => {
|
||||
const freshStore = new ProbeStore(join(tempDir, "fresh-map.db"));
|
||||
freshStore.syncTargets([
|
||||
{
|
||||
description: null,
|
||||
group: "default",
|
||||
http: {
|
||||
headers: {},
|
||||
@@ -451,19 +438,16 @@ describe("ProbeStore", () => {
|
||||
});
|
||||
|
||||
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");
|
||||
expect(stats).toBeInstanceOf(Map);
|
||||
|
||||
const stats1 = stats.get(t1Id);
|
||||
const stats1 = stats.get("test-http");
|
||||
expect(stats1).toBeDefined();
|
||||
expect(stats1!.totalChecks).toBeGreaterThan(0);
|
||||
expect(stats1!.upChecks + stats1!.downChecks).toBe(stats1!.totalChecks);
|
||||
expect(stats1!.availability).toBeGreaterThanOrEqual(0);
|
||||
|
||||
const stats2 = stats.get(t2Id);
|
||||
const stats2 = stats.get("test-cmd");
|
||||
if (stats2) {
|
||||
expect(stats2.totalChecks).toBe(0);
|
||||
expect(stats2.availability).toBe(0);
|
||||
@@ -474,6 +458,7 @@ describe("ProbeStore", () => {
|
||||
const freshStore = new ProbeStore(join(tempDir, "fresh-stats.db"));
|
||||
freshStore.syncTargets([
|
||||
{
|
||||
description: null,
|
||||
group: "default",
|
||||
http: {
|
||||
headers: {},
|
||||
@@ -543,8 +528,8 @@ describe("ProbeStore", () => {
|
||||
};
|
||||
incidentStore.syncTargets([httpA, httpB]);
|
||||
const targets = incidentStore.getTargets();
|
||||
const targetAId = targets.find((target) => target.name === "incident-http-a")!.id;
|
||||
const targetBId = targets.find((target) => target.name === "incident-http-b")!.id;
|
||||
const targetAId = targets.find((target) => target.id === "incident-http-a")!.id;
|
||||
const targetBId = targets.find((target) => target.id === "incident-http-b")!.id;
|
||||
|
||||
incidentStore.insertCheckResult({
|
||||
durationMs: 100,
|
||||
@@ -662,4 +647,43 @@ describe("ProbeStore", () => {
|
||||
|
||||
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();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -9,6 +9,7 @@ import { OverviewTab } from "../../../src/web/components/OverviewTab";
|
||||
describe("OverviewTab", () => {
|
||||
const target: TargetStatus = {
|
||||
currentStreak: null,
|
||||
description: null,
|
||||
group: "default",
|
||||
id: "1",
|
||||
interval: "30s",
|
||||
|
||||
@@ -19,6 +19,7 @@ describe("TargetBoard", () => {
|
||||
const targets: TargetStatus[] = [
|
||||
{
|
||||
currentStreak: null,
|
||||
description: null,
|
||||
group: "default",
|
||||
id: "1",
|
||||
interval: "30s",
|
||||
@@ -31,6 +32,7 @@ describe("TargetBoard", () => {
|
||||
},
|
||||
{
|
||||
currentStreak: null,
|
||||
description: null,
|
||||
group: "production",
|
||||
id: "2",
|
||||
interval: "30s",
|
||||
|
||||
@@ -9,6 +9,7 @@ import { TargetDetailDrawer } from "../../../src/web/components/TargetDetailDraw
|
||||
describe("TargetDetailDrawer", () => {
|
||||
const target: TargetStatus = {
|
||||
currentStreak: null,
|
||||
description: null,
|
||||
group: "default",
|
||||
id: "1",
|
||||
interval: "30s",
|
||||
@@ -120,4 +121,10 @@ describe("TargetDetailDrawer", () => {
|
||||
const dragLine = wrapper.querySelector('[style*="col-resize"]');
|
||||
expect(dragLine).not.toBeNull();
|
||||
});
|
||||
|
||||
test("name 为 null 时标题显示 id", () => {
|
||||
const nullNameTarget = { ...target, name: null };
|
||||
render(<TargetDetailDrawer {...defaultProps} target={nullNameTarget} />);
|
||||
expect(document.body.textContent).toContain("1");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -15,6 +15,7 @@ describe("TargetGroup", () => {
|
||||
const targets: TargetStatus[] = [
|
||||
{
|
||||
currentStreak: null,
|
||||
description: null,
|
||||
group: "default",
|
||||
id: "1",
|
||||
interval: "30s",
|
||||
@@ -33,6 +34,7 @@ describe("TargetGroup", () => {
|
||||
},
|
||||
{
|
||||
currentStreak: null,
|
||||
description: null,
|
||||
group: "default",
|
||||
id: "2",
|
||||
interval: "30s",
|
||||
|
||||
@@ -20,6 +20,7 @@ function getColumn(columns: Array<PrimaryTableCol<TargetStatus>>, colKey: string
|
||||
function makeTarget(overrides: Partial<TargetStatus> = {}): TargetStatus {
|
||||
return {
|
||||
currentStreak: null,
|
||||
description: null,
|
||||
group: "default",
|
||||
id: "1",
|
||||
interval: "5s",
|
||||
@@ -119,7 +120,64 @@ describe("createTargetTableColumns", () => {
|
||||
rowIndex: 0,
|
||||
});
|
||||
|
||||
expect(element.props.children).toBe("9999+ms");
|
||||
expect(element.props.children).toBe("9999+");
|
||||
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");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -2,16 +2,12 @@ import { describe, expect, test } from "bun:test";
|
||||
|
||||
import type { TargetStatus } from "../../../src/shared/api";
|
||||
|
||||
import {
|
||||
availabilitySorter,
|
||||
latencySorter,
|
||||
nameSorter,
|
||||
statusSorter,
|
||||
} from "../../../src/web/constants/target-table-sorters";
|
||||
import { availabilitySorter, latencySorter, statusSorter } from "../../../src/web/constants/target-table-sorters";
|
||||
|
||||
function makeTarget(overrides: Partial<TargetStatus> = {}): TargetStatus {
|
||||
return {
|
||||
currentStreak: null,
|
||||
description: null,
|
||||
group: "default",
|
||||
id: "1",
|
||||
interval: "5s",
|
||||
@@ -97,18 +93,3 @@ describe("latencySorter", () => {
|
||||
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");
|
||||
});
|
||||
});
|
||||
|
||||
32
tests/web/utils/target.test.ts
Normal file
32
tests/web/utils/target.test.ts
Normal 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");
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user