Compare commits
3 Commits
22c06820fa
...
b591dcca97
| Author | SHA1 | Date | |
|---|---|---|---|
| b591dcca97 | |||
| 9b53c746f6 | |||
| 375dd3492b |
@@ -63,7 +63,7 @@ src/
|
||||
cmd/ Cmd Checker(自包含模块,含 types/schema/execute/expect/validate)
|
||||
db/ DB Checker(自包含模块,含 types/schema/execute/expect/validate)
|
||||
tcp/ TCP Checker(自包含模块,含 types/schema/execute/expect/validate)
|
||||
icmp/ Ping Checker(自包含模块,含 types/schema/execute/expect/validate/parse)
|
||||
icmp/ ICMP Checker(自包含模块,含 types/schema/execute/expect/validate/parse)
|
||||
udp/ UDP Checker(自包含模块,含 types/schema/execute/expect/validate/encoding)
|
||||
llm/ LLM Checker(自包含模块,含 types/schema/execute/expect/validate/provider/observation)
|
||||
shared/
|
||||
@@ -193,7 +193,7 @@ export function handleMetrics(idStr: string, url: URL, store: ProbeStore, mode:
|
||||
- `createHealthResponse()` — 构造健康检查响应
|
||||
- `formatDuration(ms)` — 毫秒转为可读时长字符串
|
||||
- `jsonResponse(body, options)` — JSON 响应构造
|
||||
- `mapCheckResult(row)` — 数据库行转 API CheckResult
|
||||
- `mapCheckResult(row, type)` — 数据库行转 API CheckResult,反序列化 observation 并按 checker type 动态生成 detail
|
||||
- **`middleware.ts`**:API 参数校验函数(`validateTargetId`、`validateTimeRange`、`validatePagination`、`validateDashboardWindow`、`validateRecentLimit`、`validateMetricsBucket`,其中 `pageSize` 和 `recentLimit` 上限为 `200`)
|
||||
|
||||
### 1.5 类型定义规范
|
||||
@@ -478,7 +478,7 @@ TcpChecker implements Checker
|
||||
**Schema**:
|
||||
|
||||
- `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)
|
||||
- `check_results` 表:target_id(TEXT FK CASCADE,引用配置 target id)、timestamp、matched(0/1)、duration_ms、observation(JSON TEXT)、failure(JSON)
|
||||
- 复合索引:`(target_id, timestamp)`
|
||||
|
||||
### 1.9 拨测引擎
|
||||
@@ -486,8 +486,8 @@ TcpChecker implements Checker
|
||||
- **调度**:`ProbeEngine` 用 `es-toolkit/groupBy` 按 interval 分组,每组独立 `setInterval` 定时触发
|
||||
- **并发控制**:`es-toolkit/Semaphore` 限制全局最大并发数(`maxConcurrentChecks`,默认 20),`acquire()` 阻塞等待
|
||||
- **Runner 选择**:`engine.runCheck()` 通过 `checkerRegistry.get(target.type)` 获取 checker,并调用 `checker.execute(target, { signal })`
|
||||
- **超时控制**:`ProbeEngine` 为每次检查创建 `AbortController` 并按 `target.timeoutMs` 触发 abort;checker 必须使用 `CheckerContext.signal` 感知超时,HTTP 将 signal 传给 `fetch()`,Cmd 和 Ping 在 signal abort 时 `proc.kill()`
|
||||
- **结果写入**:检查结果通过 `store.insertCheckResult()` 写入 SQLite,engine 基于配置 target id 确认目标仍存在
|
||||
- **超时控制**:`ProbeEngine` 为每次检查创建 `AbortController` 并按 `target.timeoutMs` 触发 abort;checker 必须使用 `CheckerContext.signal` 感知超时,HTTP 将 signal 传给 `fetch()`,Cmd 和 ICMP 在 signal abort 时 `proc.kill()`
|
||||
- **结果写入**:检查结果通过 `store.insertCheckResult()` 写入 SQLite,engine 基于配置 target id 确认目标仍存在;detail 为 API 层从 observation 派生,不进入存储层
|
||||
- **异常可观测**:`probeGroup()` 对 `Promise.allSettled` 的 rejected 结果通过索引关联 target,并写入 `phase:"internal"` 的失败记录
|
||||
- **数据清理**:当 `retentionMs > 0` 时,engine 启动时立即执行一次 `store.prune()`,之后每小时定时执行,按 `timestamp` 清理过期数据
|
||||
- **生命周期**:`start()`/`stop()` 管理定时器(含调度定时器和清理定时器),`stop()` 清理所有 `setInterval`
|
||||
@@ -508,7 +508,7 @@ TcpChecker implements Checker
|
||||
|
||||
`ContentRules` 数组按顺序快速失败。数组项可以是直接 matcher,也可以是 `{ json: {...} }`、`{ css: {...} }`、`{ xpath: {...} }` 提取器规则;一条规则不能混用直接 matcher 和 extractor,多个 extractor 也不能共存。Extractor 未配置 matcher 时等价于 `exists: true`。对对象或数组源执行直接 `contains`/`regex` 时会先 JSON 序列化,`equals` 仍对原始结构做深度相等。
|
||||
|
||||
启动期语义校验统一由 `expect/validate-matcher.ts` 负责,会校验空 matcher、未知字段、字段类型、`exists:false` 组合、ContentRules 互斥性、JSONPath 子集、XPath 可编译性、regex 可编译性和 ReDoS 风险。旧字段 `match`、`maxDurationMs`、Ping 的 `max*` 阈值字段不再支持。
|
||||
启动期语义校验统一由 `expect/validate-matcher.ts` 负责,会校验空 matcher、未知字段、字段类型、`exists:false` 组合、ContentRules 互斥性、JSONPath 子集、XPath 可编译性、regex 可编译性和 ReDoS 风险。旧字段 `match`、`maxDurationMs`、ICMP 的 `max*` 阈值字段不再支持。
|
||||
|
||||
**快速失败顺序**:
|
||||
|
||||
@@ -519,7 +519,7 @@ TcpChecker implements Checker
|
||||
| DB | `durationMs → rowCount → rows → result` |
|
||||
| TCP | `connected → banner → durationMs` |
|
||||
| UDP | `responded → responseSize → response → sourceHost → sourcePort → durationMs` |
|
||||
| Ping | `alive → packetLossPercent → avgLatencyMs → maxLatencyMs → durationMs` |
|
||||
| ICMP | `alive → packetLossPercent → avgLatencyMs → maxLatencyMs → durationMs` |
|
||||
| LLM http | `status → headers → output → finishReason → rawFinishReason → usage → durationMs` |
|
||||
| LLM stream | `status → headers → stream.completed → stream.firstTokenMs → output → finishReason → rawFinishReason → usage → durationMs` |
|
||||
|
||||
@@ -535,7 +535,7 @@ expect 字段
|
||||
├─ 状态类结果,结果集合小且稳定
|
||||
│ └─ enum / boolean
|
||||
│ HTTP/LLM status、Cmd exitCode、TCP connected、
|
||||
│ UDP responded、Ping alive
|
||||
│ UDP responded、ICMP alive
|
||||
│
|
||||
├─ 数字指标 / 字符串元数据
|
||||
│ └─ ValueMatcher
|
||||
|
||||
37
README.md
37
README.md
@@ -10,12 +10,13 @@
|
||||
|
||||
---
|
||||
|
||||
DiAL 是一个自托管的拨测监控工具,支持 **HTTP**、**命令行**、**数据库**、**TCP**、**UDP**、**Ping** 和 **LLM** 多种拨测类型。通过 YAML 配置文件定义拨测目标,后端定时并发执行拨测并将结果持久化到本地 SQLite,前端 Dashboard 展示各目标的实时状态、可用率和耗时趋势。
|
||||
DiAL 是一个自托管的拨测监控工具,支持 **HTTP**、**命令行**、**数据库**、**TCP**、**UDP**、**ICMP** 和 **LLM** 多种拨测类型。通过 YAML 配置文件定义拨测目标,后端定时并发执行拨测并将结果持久化到本地 SQLite,前端 Dashboard 展示各目标的实时状态、可用率和耗时趋势。
|
||||
|
||||
**功能亮点:**
|
||||
|
||||
- 多种拨测类型:HTTP(GET/POST/PUT 等)、Cmd(命令行执行)、DB(PostgreSQL/MySQL/SQLite)、TCP(端口可达性 + Banner 探测)、UDP(自定义 payload 请求-响应)、Ping(ICMP 存活、延迟、丢包率)、LLM(大模型服务应用层健康检查)
|
||||
- 多种拨测类型:HTTP(GET/POST/PUT 等)、Cmd(命令行执行)、DB(PostgreSQL/MySQL/SQLite)、TCP(端口可达性 + Banner 探测)、UDP(自定义 payload 请求-响应)、ICMP(存活检测、延迟、丢包率)、LLM(大模型服务应用层健康检查)
|
||||
- 丰富的校验规则:状态码、响应头、JSONPath、CSS 选择器、XPath、正则匹配、数值比较等
|
||||
- 结构化观测数据:检查结果保留 HTTP body 预览、TCP/UDP 响应摘要、ICMP 丢包率、CMD 输出预览、LLM token 用量等 observation,便于排障和后续分析
|
||||
- 响应式 Dashboard:实时状态、可用率统计、耗时趋势图、手动/自动刷新
|
||||
- 多主题支持:系统、明亮、黑暗三种主题模式
|
||||
- 零外部依赖:数据存储使用 SQLite,无需额外数据库服务
|
||||
@@ -24,7 +25,7 @@ DiAL 是一个自托管的拨测监控工具,支持 **HTTP**、**命令行**
|
||||
|
||||
**前置条件:** [Bun](https://bun.sh/) >= 1.0
|
||||
|
||||
Ping checker 依赖系统 `ping` 命令。精简容器镜像需额外安装,例如 Alpine 可安装 `iputils-ping`。
|
||||
ICMP checker 依赖系统 `ping` 命令。精简容器镜像需额外安装,例如 Alpine 可安装 `iputils-ping`。
|
||||
|
||||
```bash
|
||||
# 克隆仓库
|
||||
@@ -160,10 +161,10 @@ targets:
|
||||
durationMs:
|
||||
lte: 100
|
||||
|
||||
- id: "gateway-ping"
|
||||
- id: "gateway-icmp"
|
||||
name: "网关 ICMP 可达"
|
||||
type: ping
|
||||
ping:
|
||||
type: icmp
|
||||
icmp:
|
||||
host: "10.0.0.1"
|
||||
count: 3
|
||||
packetSize: 56
|
||||
@@ -226,7 +227,7 @@ targets:
|
||||
| `id` | 目标唯一标识,最长 30 字符,支持字母数字、下划线、连字符,不参与变量替换 | 是 |
|
||||
| `name` | 展示名称,最长 30 字符,支持变量替换,可省略或显式 null;前端展示时 null 回退到 `id` | 否 |
|
||||
| `description` | 目标描述,最长 500 字符,支持变量替换,可省略或显式 null,允许空字符串 | 否 |
|
||||
| `type` | 目标类型:`http`、`cmd`、`db`、`tcp`、`udp`、`ping`、`llm` | 是 |
|
||||
| `type` | 目标类型:`http`、`cmd`、`db`、`tcp`、`udp`、`icmp`、`llm` | 是 |
|
||||
| `group` | 分组名称 | 否,默认 `"default"` |
|
||||
| `interval` | 覆盖全局拨测间隔 | 否 |
|
||||
| `timeout` | 覆盖全局超时时间 | 否 |
|
||||
@@ -268,15 +269,15 @@ targets:
|
||||
| `tcp.bannerReadTimeout` | banner 读取超时(毫秒),默认 `2000` |
|
||||
| `tcp.maxBannerBytes` | banner 最大字节数,支持 `KB`/`MB`/`GB` 单位,默认 `4KB` |
|
||||
|
||||
**Ping 类型** (`type: ping`)
|
||||
**ICMP 类型** (`type: icmp`)
|
||||
|
||||
| 字段 | 说明 |
|
||||
| ----------------- | ----------------------------------- |
|
||||
| `ping.host` | 目标主机地址 |
|
||||
| `ping.count` | ICMP 包数量,默认 `3`,范围 `1-100` |
|
||||
| `ping.packetSize` | ICMP 包大小(bytes),默认 `56` |
|
||||
| `icmp.host` | 目标主机地址 |
|
||||
| `icmp.count` | ICMP 包数量,默认 `3`,范围 `1-100` |
|
||||
| `icmp.packetSize` | ICMP 包大小(bytes),默认 `56` |
|
||||
|
||||
Ping checker 通过系统 `ping` 命令执行 ICMP 探测,支持 Linux、macOS 和 Windows 输出解析。
|
||||
ICMP checker 通过系统 `ping` 命令执行 ICMP 探测,支持 Linux、macOS 和 Windows 输出解析。
|
||||
|
||||
**LLM 类型** (`type: llm`)
|
||||
|
||||
@@ -323,10 +324,10 @@ Ping checker 通过系统 `ping` 命令执行 ICMP 探测,支持 Linux、macOS
|
||||
| `responseSize` | UDP | 响应字节数校验,使用 `ValueMatcher` |
|
||||
| `sourceHost` | UDP | 响应来源地址校验,使用 `ValueMatcher` |
|
||||
| `sourcePort` | UDP | 响应来源端口校验,使用 `ValueMatcher` |
|
||||
| `alive` | Ping | 期望主机可达性,默认 `true` |
|
||||
| `packetLossPercent` | Ping | 丢包率百分比校验,范围 `0-100`,使用 `ValueMatcher` |
|
||||
| `avgLatencyMs` | Ping | 平均延迟校验,使用 `ValueMatcher` |
|
||||
| `maxLatencyMs` | Ping | 最大单次延迟校验,使用 `ValueMatcher` |
|
||||
| `alive` | ICMP | 期望主机可达性,默认 `true` |
|
||||
| `packetLossPercent` | ICMP | 丢包率百分比校验,范围 `0-100`,使用 `ValueMatcher` |
|
||||
| `avgLatencyMs` | ICMP | 平均延迟校验,使用 `ValueMatcher` |
|
||||
| `maxLatencyMs` | ICMP | 最大单次延迟校验,使用 `ValueMatcher` |
|
||||
|
||||
**ContentRules 校验项**(`body`、`stdout`、`stderr`、`banner`、`response`、`output`、`result` 均使用数组):
|
||||
|
||||
@@ -338,7 +339,7 @@ Ping checker 通过系统 `ping` 命令执行 ICMP 探测,支持 Linux、macOS
|
||||
|
||||
**ValueMatcher 字段**:`equals`、`contains`、`regex`、`empty`、`exists`、`gte`、`lte`、`gt`、`lt`。`equals` 支持 JSON 深度相等;`regex` 固定使用无 flags 正则;提取器未配置 matcher 时等价于 `exists: true`。ValueMatcher expect 字段也可直接写 string、number、boolean 或 null,等价于 `{ equals: value }`;数组和对象必须显式写成 `{ equals: ... }`。
|
||||
|
||||
旧字段 `maxDurationMs`、`maxPacketLoss`、`maxAvgLatencyMs`、`maxMaxLatencyMs` 和旧正则字段 `match` 已移除,请分别改用 `durationMs`、Ping matcher 字段和 `regex`。
|
||||
旧字段 `maxDurationMs`、`maxPacketLoss`、`maxAvgLatencyMs`、`maxMaxLatencyMs` 和旧正则字段 `match` 已移除,请分别改用 `durationMs`、ICMP matcher 字段和 `regex`。
|
||||
|
||||
**大小说明**:`maxBodyBytes` 和 `maxOutputBytes` 支持 `KB`、`MB`、`GB` 单位,也可直接使用数字。
|
||||
|
||||
@@ -357,6 +358,8 @@ Ping checker 通过系统 `ping` 命令执行 ICMP 探测,支持 Linux、macOS
|
||||
|
||||
执行失败(网络错误、超时、进程崩溃)和 expect 不匹配都统一为 DOWN,通过 `failure.kind` 区分原因(`"error"` vs `"mismatch"`)。
|
||||
|
||||
API 返回的检查结果包含 `detail` 和 `observation`:`detail` 是后端按 checker 类型从结构化 observation 动态生成的人可读摘要,`observation` 保存该次检查的结构化观测数据。`detail` 不写入 SQLite,存储层仅持久化 `observation` JSON、`failure` JSON、匹配状态、耗时和时间戳。
|
||||
|
||||
## 开发
|
||||
|
||||
```bash
|
||||
|
||||
@@ -1,2 +0,0 @@
|
||||
schema: spec-driven
|
||||
created: 2026-05-19
|
||||
@@ -1,159 +0,0 @@
|
||||
## Context
|
||||
|
||||
当前所有 checker(HTTP/TCP/UDP/ICMP/DB/CMD/LLM)在 execute 执行后,将丰富的结构化观测数据压缩为一个 `statusDetail: string | null` 字符串,然后丢弃原始数据。`CheckResult` 仅有 5 个字段(durationMs、failure、matched、statusDetail、timestamp),无法承载排障所需的上下文信息,也无法支持历史观测数据的结构化分析。
|
||||
|
||||
现有代码中,LLM checker 已有执行期 `LlmCheckObservation` 对象且 `expect.output` 依赖其中的完整 `outputText`,ICMP/Ping checker 已有 `PingStats` 结构,其余 checker 的观测数据散布在 execute 函数的局部变量中。
|
||||
|
||||
前端通过 `statusDetail` 字符串直接展示摘要,仅在 `history-table-columns.tsx` 和 `OverviewTab.tsx` 两处引用。
|
||||
|
||||
项目未上线,不需要考虑向前兼容和数据迁移。
|
||||
|
||||
现有 `openspec/specs/probe-data-store/spec.md` 中 `check_results.target_id` 仍写为 INTEGER,但当前代码中的 `targets.id` 和 `check_results.target_id` 均为 TEXT。本 change 的 `probe-data-store` delta 在替换 status_detail 时同步修正该过时 spec 描述,不引入额外代码类型变更。
|
||||
|
||||
## Goals / Non-Goals
|
||||
|
||||
**Goals:**
|
||||
- 在 CheckResult 中引入结构化 observation 字段,持久化完整观测数据
|
||||
- 用 `detail` 字段替代 `statusDetail`,从 observation 动态构造,不再持久化
|
||||
- 各 checker 定义各自的 observation schema 和 detail 构造逻辑
|
||||
- 对大文本/集合字段执行截断,控制存储膨胀
|
||||
- 前端展示行为不变(仍显示人可读摘要字符串)
|
||||
|
||||
**Non-Goals:**
|
||||
- 前端 observation 详情展开 UI(后续单独做)
|
||||
- observation 的 JSON 深层查询(不需要 json_extract 等)
|
||||
- 数据脱敏(仅截断,不脱敏)
|
||||
- 引入新的序列化依赖(使用原生 JSON)
|
||||
|
||||
## Decisions
|
||||
|
||||
### Decision 1: observation 字段类型使用 `Record<string, unknown> | null`
|
||||
|
||||
**选择**: 使用 `Record<string, unknown>` 作为 observation 的共享类型,各 checker 内部定义强类型 interface 用于构造,序列化后类型擦除。
|
||||
|
||||
**替代方案**:
|
||||
- Discriminated union(`{ type: "http", ... } | { type: "tcp", ... }`):类型安全但导致共享层与所有 checker 类型耦合,每新增一个 checker 都需要修改共享类型
|
||||
- `unknown`:过于宽松,缺乏结构约束
|
||||
|
||||
**理由**: 折中方案。共享层不关心 observation 的内部结构(只需序列化/反序列化),类型安全由各 checker 内部保证。buildDetail 方法接收 `Record<string, unknown>` 并在内部做类型断言。
|
||||
|
||||
### Decision 2: 存储格式使用 JSON TEXT
|
||||
|
||||
**选择**: observation 列使用 SQLite TEXT 类型,JSON.stringify 写入,JSON.parse 读出。
|
||||
|
||||
**替代方案**:
|
||||
- BLOB + MessagePack:更紧凑,但需要引入 `@msgpack/msgpack` 依赖
|
||||
- BLOB + JSON Buffer:不引入依赖,但 sqlite3 CLI 无法直接查看内容
|
||||
|
||||
**理由**: 不引入新依赖(项目规范);开发者可以用 sqlite3 CLI 直接查看和调试 observation 数据。空间换可读性,实际膨胀有限(截断策略控制了上界)。
|
||||
|
||||
### Decision 3: detail 在 API 序列化层动态构造
|
||||
|
||||
**选择**: detail 不持久化到数据库,在 API 路由的 `mapCheckResult` 中通过 `checkerRegistry.get(type).buildDetail(observation)` 动态生成。
|
||||
|
||||
**替代方案**:
|
||||
- 在 execute 中同时生成 detail 并持久化:简单但存储冗余数据,且 detail 格式变更需要回填
|
||||
- 在前端从 observation 构造:前端需要了解所有 checker 类型的 detail 格式,违反关注点分离
|
||||
|
||||
**理由**: detail 是 observation 的派生数据,不应独立存储。API 层已经能获取 target type(dashboard 路由遍历 targets,history 路由已查出 target),调用 buildDetail 的成本极低。mapCheckResult 函数签名调整为接收 type 参数。
|
||||
|
||||
### Decision 4: statusDetail 重命名为 detail
|
||||
|
||||
**选择**: API 合约中 `statusDetail` 字段重命名为 `detail`。
|
||||
|
||||
**理由**: 更简洁。项目未上线,无兼容性负担。
|
||||
|
||||
### Decision 5: CheckerDefinition 接口新增 buildDetail 方法
|
||||
|
||||
**选择**: 在 `CheckerDefinition` 接口中新增 `buildDetail(observation: Record<string, unknown>): string | null`。
|
||||
|
||||
**替代方案**:
|
||||
- 独立的 detailBuilder registry:过度设计,且打破 checker 内聚性
|
||||
- 在 observation 中嵌入 detail 字段:混淆数据和展示
|
||||
|
||||
**理由**: buildDetail 是 checker 领域知识的一部分,与 execute/serialize 同属 checker 职责。放在接口中保持 checker 内聚。
|
||||
|
||||
### Decision 6: 可收集的负向结果仍写入 observation
|
||||
|
||||
**选择**: observation 为 null 仅表示无法形成有意义的领域观测,例如进程 spawn 失败、执行框架内部异常、请求在拿到响应前失败且没有可记录元数据。TCP 连接拒绝、UDP 未收到响应、Ping 不可达、DB 连接失败、CMD 非零退出、HTTP 非 2xx/expect 不匹配、LLM 返回错误状态等可收集上下文的负向结果 SHALL 返回结构化 observation,并通过 failure 表示失败原因。
|
||||
|
||||
**理由**: observation 的目标是排障和趋势分析。可预期的负向结果本身就是关键观测数据,如果统一写 null 会丢失连接错误、丢包率、stderr、HTTP status/body 等上下文。
|
||||
|
||||
### Decision 7: HTTP 成功拿到响应后始终采集 bodyPreview
|
||||
|
||||
**选择**: HTTP checker 在 fetch 成功返回 Response 后,无论是否配置 body expect,都读取响应体前 1024 字符作为 `bodyPreview`。当配置 body expect 时,仍按现有 `maxBodyBytes` 读取完整可校验范围,并从已读取文本派生 `bodyPreview`。
|
||||
|
||||
**理由**: 主要排障诉求包含“HTTP 502 返回了什么 body”。如果仅在配置 body expect 时读取 body,默认 HTTP 探测无法提供失败响应正文。该行为会增加一次响应体读取成本,但 preview 上限较小,且 HTTP 响应体不会被后续其他逻辑复用。
|
||||
|
||||
### Decision 8: 截断策略
|
||||
|
||||
各 checker 的截断上限:
|
||||
|
||||
| 字段 | 上限 | 说明 |
|
||||
|------|------|------|
|
||||
| HTTP bodyPreview | 1024 chars | 错误页面/API 错误体足够排障 |
|
||||
| HTTP headers | 前 20 个 | 避免大量自定义 header 膨胀 |
|
||||
| TCP banner | 256 chars | 与现有 truncateBanner 逻辑一致 |
|
||||
| UDP responsePreview | 512 chars | 协议响应通常较短 |
|
||||
| DB rowsPreview | 前 5 行 | 验证查询结果形态即可 |
|
||||
| CMD stdoutPreview | 1024 chars | 错误日志/诊断输出的前段 |
|
||||
| CMD stderrPreview | 1024 chars | 同上 |
|
||||
| LLM outputPreview | 512 chars | 输出文本摘要即可 |
|
||||
| LLM headers | 前 20 个 | 同 HTTP |
|
||||
|
||||
### Decision 9: execute 返回值变更策略
|
||||
|
||||
各 checker 的 execute 方法改造方式:
|
||||
|
||||
- **LLM**: 执行期继续使用包含完整 `outputText` 的 `LlmCheckObservation` 支撑 expect 校验;写入 CheckResult.observation 前派生持久化 observation,将 `outputText` 转为 `outputPreview` 和 `outputLength`,并截断 HTTP headers。不能直接把执行期 `outputText` 替换为 preview,否则会破坏 `expect.output`。
|
||||
- **ICMP**: 已有 `PingStats`,直接作为 observation 基础,补充 error 字段。
|
||||
- **HTTP/TCP/UDP/DB/CMD**: 在 execute 函数中将散布的局部变量聚合为 observation 对象。现有的 `buildStatusDetail` 辅助函数逻辑迁移到 `buildDetail` 方法中,输入改为 observation。
|
||||
|
||||
### Decision 10: 数据流架构
|
||||
|
||||
```
|
||||
Checker.execute()
|
||||
│
|
||||
├─ observation: { statusCode: 200, headers: {...}, ... }
|
||||
├─ detail: 不设置
|
||||
├─ matched / failure / durationMs / timestamp
|
||||
│
|
||||
▼
|
||||
Engine.writeResult()
|
||||
│
|
||||
├─ observation → JSON.stringify → TEXT 列
|
||||
├─ failure → JSON.stringify → TEXT 列
|
||||
├─ matched / durationMs / timestamp → 原样写入
|
||||
│
|
||||
▼
|
||||
API Route (dashboard / history)
|
||||
│
|
||||
├─ 已知 target.type
|
||||
├─ observation → JSON.parse
|
||||
├─ detail = checkerRegistry.get(type).buildDetail(obs)
|
||||
│
|
||||
▼
|
||||
API Response (CheckResult)
|
||||
│
|
||||
├─ observation: { ... } ← 结构化数据,前端可用于未来排障 UI
|
||||
├─ detail: "HTTP 200" ← 人可读摘要,前端直接展示
|
||||
├─ matched / failure / durationMs / timestamp
|
||||
│
|
||||
▼
|
||||
Frontend
|
||||
│
|
||||
├─ 显示 detail(与原 statusDetail 行为一致)
|
||||
└─ observation 暂不使用
|
||||
```
|
||||
|
||||
## Risks / Trade-offs
|
||||
|
||||
**[存储膨胀]** → observation JSON 比原 statusDetail 字符串占用更多空间。通过截断策略控制上界:单条 observation 最大约 5-10KB(含 CMD stdout + stderr 各 1024 chars),远小于 SQLite 单行存储上限。配合已有的 data-retention prune 机制,整体可控。
|
||||
|
||||
**[buildDetail 性能]** → 每次 API 请求都需要 JSON.parse + buildDetail 调用。对于 dashboard(仅取 latest check per target)和 history(分页,默认 20 条/页),开销极小。如果未来需要批量处理大量记录,可以考虑缓存或批量优化。
|
||||
|
||||
**[类型安全断层]** → observation 在共享层是 `Record<string, unknown>`,buildDetail 内部需要做类型断言。如果 observation 结构与 buildDetail 期望不一致,会产生运行时错误。通过 checker 内部 execute、持久化 observation 派生函数和 buildDetail 共享同一个 observation interface(仅 checker 内部可见)来降低风险。
|
||||
|
||||
**[前端字段重命名]** → statusDetail → detail 需要修改前端 2 处引用。变更量小,但需要确保前端编译通过。
|
||||
|
||||
**[HTTP body 读取成本]** → HTTP checker 将在拿到响应后读取 body preview,即使未配置 body expect。通过 1024 字符 preview 上限控制额外内存占用;如果 body 解码失败,仍应保留 status/headers/contentLength 等已收集 observation,并通过 failure 描述解码问题。
|
||||
@@ -1,37 +0,0 @@
|
||||
## Why
|
||||
|
||||
各 checker 在执行过程中收集了丰富的结构化数据(HTTP 状态码/headers/body、ICMP 延迟/丢包率、LLM token 用量/首 token 延迟等),但 `CheckResult` 仅有一个 `statusDetail: string | null` 字段,所有观测数据被压缩为人可读字符串后丢弃。这导致:排障时无法获取失败上下文(HTTP 502 返回了什么 body?CMD 的 stderr 输出了什么?)、无法对历史观测数据做结构化查询和趋势分析(ICMP 丢包率变化、LLM token 消耗趋势)。
|
||||
|
||||
## What Changes
|
||||
|
||||
- **BREAKING**: `CheckResult` 移除 `statusDetail` 字段,新增 `detail: string | null` 和 `observation: Record<string, unknown> | null`
|
||||
- **BREAKING**: 存储层 `check_results` 表移除 `status_detail` 列,新增 `observation TEXT` 列(JSON 格式)
|
||||
- 每个 checker 在 execute 返回时组装结构化 observation 对象,包含该类型特有的观测数据(含截断策略);可收集的负向结果保留 observation,仅无法形成领域观测时返回 null
|
||||
- `CheckerDefinition` 接口新增 `buildDetail(observation)` 方法,从 observation 动态构造人可读摘要
|
||||
- API 序列化层根据 target type 调用对应 checker 的 `buildDetail`,动态生成 `detail` 字段返回给前端
|
||||
- 前端展示层将 `statusDetail` 引用改为 `detail`,逻辑不变
|
||||
|
||||
## Capabilities
|
||||
|
||||
### New Capabilities
|
||||
- `checker-observation`: 定义 observation 数据模型、各 checker 的 observation schema、截断策略、序列化/反序列化规则
|
||||
|
||||
### Modified Capabilities
|
||||
- `checker-runner-abstraction`: CheckerDefinition 接口新增 `buildDetail` 方法;CheckResult 类型变更(statusDetail → detail + observation)
|
||||
- `probe-engine`: checker 执行结果写入字段从 status_detail 改为 observation,detail 不进入存储层
|
||||
- `probe-data-store`: check_results 表 schema 变更(status_detail → observation);insert/query 方法适配新字段;同步修正现有 spec 中已过时的 target_id 类型为当前代码实际使用的 TEXT
|
||||
- `probe-api`: CheckResult API 合约变更(statusDetail → detail + observation);序列化层需根据 target type 动态构造 detail
|
||||
- `cmd-checker`: CMD 执行结果改为返回 observation,detail 由 buildDetail 构造
|
||||
- `tcp-checker`: TCP 执行和 banner 摘要改为通过 observation/detail 表达
|
||||
- `udp-checker`: UDP 执行和响应摘要改为通过 observation/detail 表达
|
||||
- `icmp-checker`: Ping/ICMP 摘要改为通过 observation/detail 表达,API registry type 仍为 `ping`
|
||||
- `llm-checker`: LLM 执行期 observation 与持久化 preview 分层,状态摘要改为 detail
|
||||
- `target-detail-drawer`: 记录面板详情列从 statusDetail 改为 detail
|
||||
|
||||
## Impact
|
||||
|
||||
- **后端**: 7 个 checker 的 execute/buildDetail 需改造返回 observation;LLM 还涉及 types.ts、observation.ts、expect.ts 的执行期/持久化结构分层;engine.ts/store.ts/helpers.ts/routes 适配新字段
|
||||
- **前端**: 2 处源码 statusDetail 引用改为 detail(history-table-columns.tsx、OverviewTab.tsx),相关测试 fixture 同步更新
|
||||
- **存储**: SQLite DDL 变更,不做数据迁移(项目未上线);target_id 继续使用当前代码实际的 TEXT 类型
|
||||
- **依赖**: 无新增依赖,observation 使用 JSON.stringify/JSON.parse 序列化
|
||||
- **测试**: 所有涉及 CheckResult、StoredCheckResult、CheckerDefinition mock、API dashboard/history、各 checker execute/buildDetail、前端展示的测试需适配新字段
|
||||
@@ -1,40 +0,0 @@
|
||||
## MODIFIED Requirements
|
||||
|
||||
### Requirement: Checker 接口定义
|
||||
系统 SHALL 在 `src/server/checker/runner/types.ts` 中定义面向扩展的泛型 `CheckerDefinition<TResolved extends ResolvedTargetBase = ResolvedTargetBase>`,包含 `type`、`configKey`、TypeBox 配置契约、启动期语义校验、`resolve`、`execute`、`serialize`、`buildDetail` 成员。泛型参数 SHALL 约束 `execute` 和 `serialize` 方法的 target 参数类型,使 checker 实现内部获得编译期类型安全。默认泛型参数 `= ResolvedTargetBase` 保证中间层(registry、engine、config-loader)无需指定泛型。
|
||||
|
||||
#### Scenario: Checker 接口包含必要方法
|
||||
- **WHEN** 开发者实现一个新的 Checker
|
||||
- **THEN** 该实现 MUST 提供 `type`(字符串标识)、`configKey`(配置分组名)、TypeBox 配置契约、启动期语义校验、`resolve(target, context)`(解析配置并填充默认值)、`execute(target, ctx)`(执行探测返回 CheckResult)、`serialize(target)`(返回 target 展示文本和 config JSON)和 `buildDetail(observation)`(从 observation 构造人可读摘要)
|
||||
|
||||
#### Scenario: CheckerContext 注入 signal
|
||||
- **WHEN** 引擎调用 `checker.execute(target, ctx)`
|
||||
- **THEN** `ctx.signal` SHALL 是一个由引擎创建的 `AbortSignal`,在超时或引擎关闭时 abort
|
||||
|
||||
#### Scenario: resolve 不承担通用契约校验
|
||||
- **WHEN** config-loader 调用 checker.resolve()
|
||||
- **THEN** checker.resolve() SHALL 假定配置已经通过 TypeBox/Ajv 契约校验和启动期语义校验,只负责默认值填充、路径解析和领域配置转换
|
||||
|
||||
#### Scenario: type 与 configKey 默认一致
|
||||
- **WHEN** checker 定义 `type: "tcp"`
|
||||
- **THEN** checker 的 `configKey` SHALL 默认使用 `"tcp"`,对应 target 的 `tcp` 分组和 defaults.tcp 分组
|
||||
|
||||
#### Scenario: 接口方法使用泛型约束
|
||||
- **WHEN** 开发者查看 `CheckerDefinition<TResolved>` 接口签名
|
||||
- **THEN** `resolve` 的返回值 SHALL 为 `TResolved`;`execute` 的参数 SHALL 为 `TResolved`;`serialize` 的参数 SHALL 为 `TResolved`
|
||||
|
||||
#### Scenario: checker 实现无需手动断言
|
||||
- **WHEN** HttpChecker 实现 `CheckerDefinition<ResolvedHttpTarget>`
|
||||
- **THEN** `execute` 方法的 target 参数类型 SHALL 直接为 `ResolvedHttpTarget`,无需在方法内部使用 `as` 类型断言
|
||||
|
||||
#### Scenario: registry 使用默认泛型参数
|
||||
- **WHEN** CheckerRegistry 存储和返回 checker 实例
|
||||
- **THEN** registry 内部 SHALL 使用 `CheckerDefinition`(等价于 `CheckerDefinition<ResolvedTargetBase>`),实现类型擦除
|
||||
|
||||
#### Scenario: buildDetail 方法签名
|
||||
- **WHEN** 开发者实现 buildDetail 方法
|
||||
- **THEN** 方法签名 SHALL 为 `buildDetail(observation: Record<string, unknown>): string | null`,接收 observation 对象并返回人可读摘要字符串或 null
|
||||
|
||||
#### Scenario: buildDetail 由 API 层调用
|
||||
- **WHEN** API 序列化 CheckResult
|
||||
- **THEN** API 层 SHALL 通过 registry 获取对应 checker 并调用 buildDetail,而非由 execute 方法直接生成 detail
|
||||
@@ -1,24 +0,0 @@
|
||||
## MODIFIED Requirements
|
||||
|
||||
### Requirement: cmd checker 执行
|
||||
系统 SHALL 按 cmd target 配置执行本地命令,记录执行耗时、退出码、stdout 和 stderr observation,并在执行失败时产生结构化错误信息。
|
||||
|
||||
#### Scenario: 命令正常退出
|
||||
- **WHEN** cmd target 执行的进程正常退出且 exit code 为 0
|
||||
- **THEN** 系统 SHALL 记录 `durationMs` 和包含 exitCode、stdoutPreview、stderrPreview 的 observation,并进入 expect 校验;API detail SHALL 为 `exitCode=0`
|
||||
|
||||
#### Scenario: 命令非零退出
|
||||
- **WHEN** cmd target 执行的进程正常退出但 exit code 为 1
|
||||
- **THEN** 系统 SHALL 记录包含 exitCode、stdoutPreview、stderrPreview 的 observation,并由 expect.exitCode 决定 matched 结果;API detail SHALL 为 `exitCode=1`
|
||||
|
||||
#### Scenario: 命令启动失败
|
||||
- **WHEN** cmd target 的 exec 不存在或无法启动
|
||||
- **THEN** 系统 SHALL 记录 `matched=false`,observation SHALL 为 null,并在 failure 中写入 kind=`error` 和可读错误信息
|
||||
|
||||
#### Scenario: 命令超时
|
||||
- **WHEN** cmd target 在 timeout 时间内未结束
|
||||
- **THEN** 系统 MUST 终止该子进程,记录 `matched=false`,并在 failure 中写入命令超时信息;如已收集输出片段,observation SHALL 包含 stdoutPreview、stderrPreview 和 error
|
||||
|
||||
#### Scenario: 命令输出超限
|
||||
- **WHEN** cmd target 的 stdout 和 stderr 合计输出超过 `maxOutputBytes`
|
||||
- **THEN** 系统 MUST 停止收集输出并终止该检查,记录 `matched=false`,并在 failure 中写入输出超限信息;observation SHALL 包含已截断输出预览和 error
|
||||
@@ -1,16 +0,0 @@
|
||||
## MODIFIED Requirements
|
||||
|
||||
### Requirement: ping detail 摘要
|
||||
系统 SHALL 在 ping API 序列化时从 observation 动态生成结构化 detail 摘要,展示关键指标。API registry type SHALL 仍为 `ping`。
|
||||
|
||||
#### Scenario: 目标可达无丢包
|
||||
- **WHEN** ping observation 为 alive=true, avgLatencyMs=12, packetLoss=0%, transmitted=3, received=3
|
||||
- **THEN** detail SHALL 为 `alive, avg 12ms, loss 0% (3/3)`
|
||||
|
||||
#### Scenario: 目标可达有丢包
|
||||
- **WHEN** ping observation 为 alive=true, avgLatencyMs=156, maxLatencyMs=340, packetLoss=33%, transmitted=3, received=2
|
||||
- **THEN** detail SHALL 包含 avg、max 和 loss 信息
|
||||
|
||||
#### Scenario: 目标不可达
|
||||
- **WHEN** ping observation 为 alive=false, transmitted=3, received=0
|
||||
- **THEN** detail SHALL 为 `unreachable (0/3 received)`
|
||||
@@ -1,39 +0,0 @@
|
||||
## MODIFIED Requirements
|
||||
|
||||
### Requirement: LLM Failure Phase 与状态摘要
|
||||
LLM checker SHALL 使用 `request`、`status`、`headers`、`stream`、`output`、`finishReason`、`rawFinishReason`、`usage`、`duration` 作为第一版 failure phase。成功结果的 API detail SHALL 从持久化 observation 动态构造,简短描述 provider、mode、HTTP status、finish reason、raw finish reason、first token、输出长度和 token usage 中可用的信息。observation 和 detail MUST NOT 写入完整 prompt、完整输出或 key。
|
||||
|
||||
#### Scenario: request failure
|
||||
- **WHEN** 模型请求因网络错误、认证调用异常、AbortSignal 或无 HTTP metadata 的 SDK 错误失败
|
||||
- **THEN** LLM checker SHALL 返回 `phase: "request"` 的 error failure
|
||||
|
||||
#### Scenario: output mismatch failure
|
||||
- **WHEN** 模型输出不满足 `expect.output`
|
||||
- **THEN** LLM checker SHALL 返回 `phase: "output"` 的 mismatch failure
|
||||
|
||||
#### Scenario: 非流式成功摘要
|
||||
- **WHEN** `provider: openai` 的非流式检查成功
|
||||
- **THEN** detail SHALL 使用类似 `LLM openai http 200 finish=stop, output=2 chars, usage=12/2 tokens` 的简短格式
|
||||
|
||||
#### Scenario: 流式成功摘要
|
||||
- **WHEN** `provider: anthropic` 的流式检查成功且存在 raw finish reason
|
||||
- **THEN** detail SHALL 使用类似 `LLM anthropic stream 200 finish=stop raw=end_turn, firstToken=624ms, output=2 chars` 的简短格式
|
||||
|
||||
#### Scenario: serialize 展示文本
|
||||
- **WHEN** store 同步 LLM target
|
||||
- **THEN** LLM checker `serialize()` SHALL 返回类似 `openai:gpt-4o-mini @ https://api.openai.com/v1` 的 target 展示文本和 resolved config JSON
|
||||
|
||||
### Requirement: LLM Checker 测试策略
|
||||
LLM checker 的自动化测试 MUST 不访问真实外部模型服务。测试 SHALL 使用本地 mock HTTP/SSE 服务模拟 OpenAI Chat Completions、OpenAI Responses 和 Anthropic Messages 的成功、错误和流式响应。测试 SHALL 覆盖 schema、语义校验、defaults 合并、变量替换、provider factory、observation、expect、execute、registry 注册、配置加载和 JSON Schema 导出。
|
||||
|
||||
#### Scenario: 本地 mock provider 测试成功路径
|
||||
- **WHEN** 测试运行 LLM checker 的 OpenAI、OpenAI Responses 和 Anthropic 成功路径
|
||||
- **THEN** 测试 SHALL 使用本地 mock 服务返回 provider 响应,不依赖外部网络或真实 API key
|
||||
|
||||
#### Scenario: 本地 mock provider 测试错误路径
|
||||
- **WHEN** 测试运行 401、429、500、超时、stream error、stream abort、缺 usage 或无文本输出路径
|
||||
- **THEN** 测试 SHALL 断言 LLM checker 返回符合 spec 的 matched、failure phase、actual、detail 和 observation
|
||||
|
||||
#### Scenario: 质量检查覆盖 LLM checker
|
||||
- **WHEN** 实现完成后执行质量检查
|
||||
- **THEN** `bun run schema:check`、`bun run check` SHALL 通过
|
||||
@@ -1,48 +0,0 @@
|
||||
## MODIFIED Requirements
|
||||
|
||||
### Requirement: 新增共享类型
|
||||
系统 SHALL 在 `src/shared/api.ts` 中定义 Dashboard 和 Metrics 相关共享类型。CheckResult SHALL 包含 durationMs(null | number)、failure(CheckFailure | null)、matched(boolean)、detail(null | string)、observation(Record<string, unknown> | null)、timestamp(string)。其中 detail 替代原 statusDetail 字段名。
|
||||
|
||||
#### Scenario: DashboardResponse 类型
|
||||
- **WHEN** 前后端共享 `DashboardResponse` 类型
|
||||
- **THEN** 该类型 SHALL 包含 summary 和 targets 字段
|
||||
|
||||
#### Scenario: TargetStatus 类型
|
||||
- **WHEN** 前后端共享 `TargetStatus` 类型
|
||||
- **THEN** 该类型 SHALL 包含目标基本信息字段(id、name、description、group、type、target、interval)、stats(totalChecks、upChecks、downChecks、availability)、currentStreak 和 recentSamples 字段,其中 name 和 description 类型均为 null 或字符串
|
||||
|
||||
#### Scenario: TargetMetricsResponse 类型
|
||||
- **WHEN** 前后端共享 `TargetMetricsResponse` 类型
|
||||
- **THEN** 该类型 SHALL 包含 targetId、window、stats 和 trend 字段
|
||||
|
||||
#### Scenario: TrendPoint 类型
|
||||
- **WHEN** 前后端共享 `TrendPoint` 类型
|
||||
- **THEN** 该类型 SHALL 包含 bucketStart、avgDurationMs、minDurationMs、maxDurationMs、availability、totalChecks、upChecks、downChecks 字段
|
||||
|
||||
#### Scenario: CheckResult 类型变更
|
||||
- **WHEN** 前端或后端引用 CheckResult 类型
|
||||
- **THEN** 该类型 SHALL 包含 `timestamp: string`、`matched: boolean`、`durationMs: number | null`、`detail: string | null`、`observation: Record<string, unknown> | null`、`failure` 字段,不包含 statusDetail 字段,不包含 success 字段
|
||||
|
||||
#### Scenario: RecentSample 类型
|
||||
- **WHEN** 前后端共享 `RecentSample` 类型
|
||||
- **THEN** 该类型 SHALL 包含 `timestamp: string`、`durationMs: number | null`、`up: boolean` 字段,其中 up 为 boolean 且等于 matched
|
||||
|
||||
#### Scenario: HistoryResponse 类型
|
||||
- **WHEN** 前后端共享 `HistoryResponse` 类型
|
||||
- **THEN** 该类型 SHALL 包含 `items: CheckResult[]`、`total: number`、`page: number`、`pageSize: number` 字段
|
||||
|
||||
#### Scenario: API 序列化构造 detail
|
||||
- **WHEN** API 路由序列化 StoredCheckResult 为 API 响应
|
||||
- **THEN** 系统 SHALL 从 StoredCheckResult 中反序列化 observation,根据 target type 通过 checkerRegistry 获取对应 checker 并调用 buildDetail(observation) 动态生成 detail 字段
|
||||
|
||||
#### Scenario: mapCheckResult 接收 type 参数
|
||||
- **WHEN** 序列化辅助函数 mapCheckResult 被调用
|
||||
- **THEN** 函数 SHALL 接收 target type 参数,用于从 registry 获取对应 checker 调用 buildDetail
|
||||
|
||||
#### Scenario: Dashboard API 传递 type
|
||||
- **WHEN** Dashboard 路由序列化 latestCheck
|
||||
- **THEN** 路由 SHALL 将 target.type 传递给 mapCheckResult
|
||||
|
||||
#### Scenario: History API 传递 type
|
||||
- **WHEN** History 路由序列化历史记录列表
|
||||
- **THEN** 路由 SHALL 将已查询的 target.type 传递给 mapCheckResult
|
||||
@@ -1,41 +0,0 @@
|
||||
## MODIFIED Requirements
|
||||
|
||||
### Requirement: SQLite 数据库初始化
|
||||
系统 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(TEXT NOT NULL)、timestamp(TEXT NOT NULL)、matched(INTEGER NOT NULL)、duration_ms(REAL)、observation(TEXT)、failure(TEXT),不包含 status_detail 列,不包含 success 列
|
||||
|
||||
#### Scenario: targets name 列允许 NULL
|
||||
- **WHEN** 系统首次创建 targets 表
|
||||
- **THEN** targets.name 列 SHALL 允许存储 NULL
|
||||
|
||||
#### Scenario: 数据目录不存在
|
||||
- **WHEN** 配置的数据目录路径不存在
|
||||
- **THEN** 系统 SHALL 自动创建该目录
|
||||
|
||||
#### Scenario: 数据库已存在时启动
|
||||
- **WHEN** 数据库文件已存在
|
||||
- **THEN** 系统 SHALL 直接打开数据库,不重新建表
|
||||
|
||||
#### Scenario: 外键约束
|
||||
- **THEN** 系统 SHALL 启用 `PRAGMA foreign_keys = ON`
|
||||
|
||||
#### Scenario: 级联删除
|
||||
- **THEN** check_results 表的外键约束 SHALL 使用 `ON DELETE CASCADE`,确保删除目标时自动清理关联结果记录
|
||||
|
||||
### Requirement: check_results 表追加写入
|
||||
系统 SHALL 将每次检查结果追加写入 check_results 表,不更新或删除已有记录。
|
||||
|
||||
#### Scenario: 写入检查结果
|
||||
- **WHEN** 一次 checker 执行完成
|
||||
- **THEN** 系统 SHALL 插入一条包含 target_id、timestamp、matched、duration_ms、observation、failure 的记录,其中 observation 使用 JSON.stringify 序列化为 TEXT
|
||||
|
||||
#### Scenario: 查询检查结果
|
||||
- **WHEN** 系统查询 latest check 或历史 check_results
|
||||
- **THEN** 存储层 SHALL 返回 observation 字段而非 status_detail 字段,供 API 序列化层反序列化并构造 detail
|
||||
|
||||
#### Scenario: 写入结构化失败信息
|
||||
- **WHEN** checker 执行失败或 expect 不匹配
|
||||
- **THEN** 系统 SHALL 将首个失败原因序列化写入 failure 字段
|
||||
@@ -1,16 +0,0 @@
|
||||
## MODIFIED Requirements
|
||||
|
||||
### Requirement: 拨测结果记录
|
||||
系统 SHALL 在每次 checker 完成后,将结果写入 SQLite 数据存储,包含 target_id、timestamp、matched、duration_ms、observation、failure 字段。detail SHALL 为 API 层派生字段,不写入存储层;系统 SHALL NOT 写入 status_detail 字段。
|
||||
|
||||
#### Scenario: 成功检查结果记录
|
||||
- **WHEN** checker 成功执行且 expect 全部匹配
|
||||
- **THEN** 系统 SHALL 记录 matched=true、duration_ms、observation,failure 为 null
|
||||
|
||||
#### Scenario: 执行失败结果记录
|
||||
- **WHEN** checker 执行失败(网络错误、超时、命令启动失败、输出超限等)
|
||||
- **THEN** 系统 SHALL 记录 matched=false、failure.kind="error" 和具体错误信息,并在可收集领域观测数据时记录 observation
|
||||
|
||||
#### Scenario: expect 不匹配结果记录
|
||||
- **WHEN** checker 执行成功但 expect 不匹配
|
||||
- **THEN** 系统 SHALL 记录 matched=false、observation、failure.kind="mismatch" 和具体不匹配信息
|
||||
@@ -1,16 +0,0 @@
|
||||
## MODIFIED Requirements
|
||||
|
||||
### Requirement: 记录面板
|
||||
记录 Tab SHALL 展示分页检查结果列表,使用 TDesign PrimaryTable。
|
||||
|
||||
#### Scenario: 检查结果表格
|
||||
- **WHEN** 记录面板渲染且数据可用
|
||||
- **THEN** 面板 SHALL 使用 TDesign PrimaryTable 展示检查结果,列包含:状态(StatusDot 圆点)、时间(YYYY-MM-DD HH:mm:ss 格式)、耗时(标题含 ms 单位,单元格仅显示数值,居中对齐)、详情(detail 和 failure.message 用冒号拼接)
|
||||
|
||||
#### Scenario: 服务端分页
|
||||
- **WHEN** 检查结果总数超过一页
|
||||
- **THEN** 表格 SHALL 使用内建 pagination(disableDataPage=true),分页器显示在表格底部
|
||||
|
||||
#### Scenario: 翻页触发请求
|
||||
- **WHEN** 用户切换分页页码
|
||||
- **THEN** 系统 SHALL 请求对应页码的服务端数据,表格更新
|
||||
@@ -1,51 +0,0 @@
|
||||
## MODIFIED Requirements
|
||||
|
||||
### Requirement: tcp checker 执行
|
||||
系统 SHALL 按 tcp target 配置建立 TCP 连接,记录完整执行耗时和 TCP observation,并在连接失败、超时或资源超限时产生结构化失败信息。
|
||||
|
||||
#### Scenario: TCP 连接成功
|
||||
- **WHEN** tcp target 指向可连接的 TCP 服务,且未配置 expect 或 `expect.connected` 为 `true`
|
||||
- **THEN** 系统 SHALL 记录 `matched=true`、`durationMs` 和包含 connected、connectTimeMs、banner 的 observation,并关闭 socket
|
||||
|
||||
#### Scenario: TCP 连接失败
|
||||
- **WHEN** tcp target 指向不可连接的 host/port,且未配置 expect 或 `expect.connected` 为 `true`
|
||||
- **THEN** 系统 SHALL 记录 `matched=false`,observation SHALL 包含 connected=false 和错误信息,failure 的 kind 为 `error`,phase 为 `connect`,message 包含可读连接失败原因
|
||||
|
||||
#### Scenario: 期望端口不可达且连接失败
|
||||
- **WHEN** tcp target 配置 `expect.connected: false`,且 TCP 连接失败
|
||||
- **THEN** 系统 SHALL 记录 `matched=true`,observation SHALL 包含 connected=false 和实际连接失败原因,API detail SHALL 展示实际连接失败原因摘要
|
||||
|
||||
#### Scenario: 期望端口不可达但连接成功
|
||||
- **WHEN** tcp target 配置 `expect.connected: false`,但 TCP 连接成功
|
||||
- **THEN** 系统 SHALL 记录 `matched=false`,observation SHALL 包含 connected=true,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 包含超时信息,并在可收集时记录 observation
|
||||
|
||||
#### 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 detail 截断展示
|
||||
- **WHEN** tcp target 成功读取到较长 banner
|
||||
- **THEN** observation.banner SHALL 保存截断后的 banner 摘要,API detail SHALL 展示截断后的 banner 摘要,避免 UI 展示过长文本
|
||||
@@ -1,55 +0,0 @@
|
||||
## MODIFIED Requirements
|
||||
|
||||
### Requirement: udp checker 执行
|
||||
系统 SHALL 使用 Bun connected UDP socket 向目标发送单个 datagram,等待第一个 UDP 响应 datagram,记录完整执行耗时和 UDP observation,并在发送失败、超时、资源超限或底层 socket 错误时产生结构化失败信息。
|
||||
|
||||
#### Scenario: UDP 请求响应成功
|
||||
- **WHEN** udp target 指向会返回 `PONG` 的 UDP 服务,且未配置 expect 或 `expect.responded` 为 `true`
|
||||
- **THEN** 系统 SHALL 记录 `matched=true`、`durationMs` 和包含响应大小的 observation,并关闭 socket
|
||||
|
||||
#### Scenario: 使用 hostname 执行 UDP 探测
|
||||
- **WHEN** udp target 的 `udp.host` 为可解析域名或 `localhost`
|
||||
- **THEN** 系统 SHALL 使用 Bun connected UDP socket 完成发送和接收,不要求配置 IP 地址
|
||||
|
||||
#### Scenario: 只处理第一个响应 datagram
|
||||
- **WHEN** UDP 服务对一次请求返回多个 datagram
|
||||
- **THEN** 系统 SHALL 仅使用第一个收到的 UDP datagram 执行 expect 校验,并关闭 socket
|
||||
|
||||
#### Scenario: UDP 无响应且默认期望响应
|
||||
- **WHEN** udp target 指向在 timeout 内不返回 UDP datagram 的服务,且未配置 expect 或 `expect.responded` 为 `true`
|
||||
- **THEN** 系统 SHALL 在 `ctx.signal` abort 后记录 `matched=false`,observation SHALL 包含 responded=false 和 error,failure 的 kind 为 `error`,phase 为 `response`,message 包含超时或未响应信息
|
||||
|
||||
#### Scenario: 期望无响应且实际无响应
|
||||
- **WHEN** udp target 配置 `expect.responded: false`,且 timeout 内未收到 UDP datagram
|
||||
- **THEN** 系统 SHALL 记录 `matched=true`,observation SHALL 包含 responded=false,API detail SHALL 表示未收到响应
|
||||
|
||||
#### Scenario: 期望无响应但实际收到响应
|
||||
- **WHEN** udp target 配置 `expect.responded: false`,但收到了 UDP datagram
|
||||
- **THEN** 系统 SHALL 记录 `matched=false`,observation SHALL 包含 responded=true 和响应摘要,failure 的 kind 为 `mismatch`,phase 为 `responded`
|
||||
|
||||
#### Scenario: UDP socket 底层错误
|
||||
- **WHEN** Bun UDP socket 在发送或接收过程中触发 error 事件
|
||||
- **THEN** 系统 SHALL best-effort 关闭 socket,并记录 `matched=false`、failure.kind=`error` 和可读错误信息,并在可收集时记录 observation
|
||||
|
||||
#### Scenario: ICMP unreachable 不作为 UDP 响应
|
||||
- **WHEN** 底层系统因目标端口不可达产生 ICMP unreachable
|
||||
- **THEN** 系统 SHALL NOT 将其视为 `responded=true` 的 UDP datagram 响应
|
||||
|
||||
#### Scenario: UDP 执行超时关闭 socket
|
||||
- **WHEN** 引擎注入的 `ctx.signal` 在 UDP 发送或等待响应过程中 abort
|
||||
- **THEN** 系统 SHALL best-effort 关闭 UDP socket,并记录结构化超时或未响应结果
|
||||
|
||||
### Requirement: udp detail 摘要
|
||||
系统 SHALL 在 udp API 序列化时从 observation 动态生成简短 detail 摘要,展示关键结果并避免返回过长响应内容。
|
||||
|
||||
#### Scenario: 收到响应的摘要
|
||||
- **WHEN** udp target 收到 4 字节响应且完整执行耗时为 12ms
|
||||
- **THEN** detail SHALL 包含 `responded in 12ms` 和 `4 bytes`
|
||||
|
||||
#### Scenario: 未收到响应的摘要
|
||||
- **WHEN** udp target 配置 `expect.responded: false` 且 timeout 内未收到 UDP datagram
|
||||
- **THEN** detail SHALL 包含 `no response` 和执行耗时
|
||||
|
||||
#### Scenario: 响应内容摘要截断
|
||||
- **WHEN** udp target 收到较长响应内容
|
||||
- **THEN** detail SHALL 只展示按 `responseEncoding` 转换并截断后的响应摘要
|
||||
@@ -1,56 +0,0 @@
|
||||
## 1. 共享类型与接口变更
|
||||
|
||||
- [ ] 1.1 修改 `src/shared/api.ts` 中 CheckResult 类型:移除 statusDetail,新增 detail 和 observation 字段
|
||||
- [ ] 1.2 修改 `src/server/checker/types.ts` 中 StoredCheckResult 类型:status_detail 替换为 observation
|
||||
- [ ] 1.3 修改 `src/server/checker/runner/types.ts` 中 CheckerDefinition 接口:新增 buildDetail 方法
|
||||
|
||||
## 2. 存储层适配
|
||||
|
||||
- [ ] 2.1 修改 `src/server/checker/store.ts` 中 check_results 表 DDL:status_detail 列替换为 observation TEXT 列
|
||||
- [ ] 2.2 修改 `src/server/checker/store.ts` 中 insertCheckResult 方法:写入 observation(JSON.stringify)替代 statusDetail
|
||||
- [ ] 2.3 修改 `src/server/checker/store.ts` 中 getHistory、getLatestCheck、getLatestChecksMap 读取类型:返回 observation 字段替代 status_detail
|
||||
- [ ] 2.4 修改 `src/server/checker/engine.ts` 中 writeResult 方法:传递 observation 替代 statusDetail,异常兜底结果 observation 为 null
|
||||
|
||||
## 3. Checker execute 改造(返回 observation 替代 statusDetail)
|
||||
|
||||
- [ ] 3.1 改造 HTTP checker execute.ts:组装 observation 对象(statusCode/headers/bodyPreview/contentType/contentLength),拿到响应后始终采集 bodyPreview,移除 statusDetail 赋值
|
||||
- [ ] 3.2 改造 TCP checker execute.ts:组装 observation 对象(connected/connectTimeMs/banner/error),移除 statusDetail 赋值和 buildStatusDetail 函数
|
||||
- [ ] 3.3 改造 UDP checker execute.ts:组装 observation 对象(responded/responseSize/responsePreview/sourceAddress/sourcePort/error),移除 statusDetail 赋值和 build*Detail 函数
|
||||
- [ ] 3.4 改造 ICMP checker execute.ts:组装 observation 对象(复用 PingStats 字段 + error),移除 statusDetail 赋值和 buildStatusDetail 函数
|
||||
- [ ] 3.5 改造 DB checker execute.ts:组装 observation 对象(connected/rowCount/rowsPreview/error),移除 statusDetail 赋值
|
||||
- [ ] 3.6 改造 CMD checker execute.ts:组装 observation 对象(exitCode/stdoutPreview/stderrPreview/error),移除 statusDetail 赋值
|
||||
- [ ] 3.7 改造 LLM checker types.ts 和 observation.ts:保留执行期完整 outputText,新增持久化 observation 派生结构(outputPreview/outputLength/截断 headers)
|
||||
- [ ] 3.8 改造 LLM checker execute.ts:返回持久化 observation,继续用执行期 LlmCheckObservation 执行 expect,移除 buildStatusDetail 函数
|
||||
|
||||
## 4. Checker buildDetail 实现
|
||||
|
||||
- [ ] 4.1 为 HTTP checker 实现 buildDetail 方法:返回 `"HTTP {statusCode}"` 格式
|
||||
- [ ] 4.2 为 TCP checker 实现 buildDetail 方法:返回连接状态和 banner 摘要
|
||||
- [ ] 4.3 为 UDP checker 实现 buildDetail 方法:返回响应状态和大小摘要
|
||||
- [ ] 4.4 为 ICMP checker 实现 buildDetail 方法:返回存活状态、平均延迟和丢包率摘要
|
||||
- [ ] 4.5 为 DB checker 实现 buildDetail 方法:返回连接状态或行数摘要
|
||||
- [ ] 4.6 为 CMD checker 实现 buildDetail 方法:返回 `"exitCode={N}"` 格式
|
||||
- [ ] 4.7 为 LLM checker 实现 buildDetail 方法:返回 provider/mode/status/finish/output/usage 摘要
|
||||
|
||||
## 5. API 序列化层适配
|
||||
|
||||
- [ ] 5.1 修改 `src/server/helpers.ts` 中 mapCheckResult:接收 type 参数,反序列化 observation,observation 为 null 时 detail 为 null,否则调用 buildDetail 动态构造 detail
|
||||
- [ ] 5.2 修改 `src/server/routes/dashboard.ts`:传递 target.type 给 mapCheckResult
|
||||
- [ ] 5.3 修改 `src/server/routes/history.ts`:传递 target.type 给 mapCheckResult
|
||||
|
||||
## 6. 前端适配
|
||||
|
||||
- [ ] 6.1 修改 `src/web/constants/history-table-columns.tsx`:statusDetail 引用改为 detail
|
||||
- [ ] 6.2 修改 `src/web/components/OverviewTab.tsx`:statusDetail 引用改为 detail
|
||||
|
||||
## 7. 测试与质量保障
|
||||
|
||||
- [ ] 7.1 更新所有涉及 CheckResult 的现有测试,适配 statusDetail → detail + observation 字段变更
|
||||
- [ ] 7.2 为各 checker 的 buildDetail 方法编写单元测试
|
||||
- [ ] 7.3 更新 CheckerDefinition mock、store、engine、dashboard/history API、前端组件与 constants 的测试 fixture
|
||||
- [ ] 7.4 为 mapCheckResult 编写 observation JSON parse、null observation、unknown type 或 malformed observation 的覆盖测试
|
||||
- [ ] 7.5 执行完整测试套件、代码检查和格式检查,确保无回归
|
||||
|
||||
## 8. 文档更新
|
||||
|
||||
- [ ] 8.1 更新 README.md 和/或 DEVELOPMENT.md,反映 CheckResult 类型变更和 observation 机制
|
||||
@@ -1,4 +1,8 @@
|
||||
## ADDED Requirements
|
||||
## Purpose
|
||||
|
||||
定义 CheckResult 的 observation 数据模型、各 checker 类型 observation 结构、截断策略、序列化规则和 detail 动态构造机制。
|
||||
|
||||
## Requirements
|
||||
|
||||
### Requirement: Observation 数据模型
|
||||
CheckResult SHALL 包含 `observation: Record<string, unknown> | null` 字段,用于承载 checker 执行过程中收集的结构化观测数据。observation 为 null 表示执行过程中无法形成有意义的领域观测数据(如进程 spawn 失败、内部异常、请求在拿到响应前失败且无可记录元数据等场景)。各 checker SHALL 自行定义 observation 的内部结构,不做跨 checker 类型的统一约束。
|
||||
@@ -34,7 +38,7 @@ TCP checker 的 observation SHALL 包含 connected(boolean)、connectTimeMs
|
||||
- **THEN** observation SHALL 包含 connected=false 和错误信息
|
||||
|
||||
### Requirement: UDP Checker Observation
|
||||
UDP checker 的 observation SHALL 包含 responded(boolean)、responseSize(number | null)、responsePreview(string | null,截断)、sourceAddress(string | null)、sourcePort(number | null)、error(string | null)。
|
||||
UDP checker 的 observation SHALL 包含 responded(boolean)、durationMs(number)、responseSize(number | null)、responsePreview(string | null,截断)、sourceAddress(string | null)、sourcePort(number | null)、error(string | null)。durationMs 用于 API 序列化层生成包含耗时的 UDP detail 摘要。
|
||||
|
||||
#### Scenario: UDP 收到响应
|
||||
- **WHEN** UDP 发送数据后收到响应
|
||||
@@ -50,11 +50,11 @@
|
||||
- **THEN** checker SHALL 返回 `ConfigValidationIssue`,而不是直接抛出最终用户错误字符串
|
||||
|
||||
### Requirement: Checker 接口定义
|
||||
系统 SHALL 在 `src/server/checker/runner/types.ts` 中定义面向扩展的泛型 `CheckerDefinition<TResolved extends ResolvedTargetBase = ResolvedTargetBase>`,包含 `type`、`configKey`、TypeBox 配置契约、启动期语义校验、`resolve`、`execute`、`serialize` 成员。泛型参数 SHALL 约束 `execute` 和 `serialize` 方法的 target 参数类型,使 checker 实现内部获得编译期类型安全。默认泛型参数 `= ResolvedTargetBase` 保证中间层(registry、engine、config-loader)无需指定泛型。
|
||||
系统 SHALL 在 `src/server/checker/runner/types.ts` 中定义面向扩展的泛型 `CheckerDefinition<TResolved extends ResolvedTargetBase = ResolvedTargetBase>`,包含 `type`、`configKey`、TypeBox 配置契约、启动期语义校验、`resolve`、`execute`、`serialize`、`buildDetail` 成员。泛型参数 SHALL 约束 `execute` 和 `serialize` 方法的 target 参数类型,使 checker 实现内部获得编译期类型安全。默认泛型参数 `= ResolvedTargetBase` 保证中间层(registry、engine、config-loader)无需指定泛型。
|
||||
|
||||
#### Scenario: Checker 接口包含必要方法
|
||||
- **WHEN** 开发者实现一个新的 Checker
|
||||
- **THEN** 该实现 MUST 提供 `type`(字符串标识)、`configKey`(配置分组名)、TypeBox 配置契约、启动期语义校验、`resolve(target, context)`(解析配置并填充默认值)、`execute(target, ctx)`(执行探测返回 CheckResult)和 `serialize(target)`(返回 target 展示文本和 config JSON)
|
||||
- **THEN** 该实现 MUST 提供 `type`(字符串标识)、`configKey`(配置分组名)、TypeBox 配置契约、启动期语义校验、`resolve(target, context)`(解析配置并填充默认值)、`execute(target, ctx)`(执行探测返回 CheckResult)、`serialize(target)`(返回 target 展示文本和 config JSON)和 `buildDetail(observation)`(从 observation 构造人可读摘要)
|
||||
|
||||
#### Scenario: CheckerContext 注入 signal
|
||||
- **WHEN** 引擎调用 `checker.execute(target, ctx)`
|
||||
@@ -80,6 +80,14 @@
|
||||
- **WHEN** CheckerRegistry 存储和返回 checker 实例
|
||||
- **THEN** registry 内部 SHALL 使用 `CheckerDefinition`(等价于 `CheckerDefinition<ResolvedTargetBase>`),实现类型擦除
|
||||
|
||||
#### Scenario: buildDetail 方法签名
|
||||
- **WHEN** 开发者实现 buildDetail 方法
|
||||
- **THEN** 方法签名 SHALL 为 `buildDetail(observation: Record<string, unknown>): string | null`,接收 observation 对象并返回人可读摘要字符串或 null
|
||||
|
||||
#### Scenario: buildDetail 由 API 层调用
|
||||
- **WHEN** API 序列化 CheckResult
|
||||
- **THEN** API 层 SHALL 通过 registry 获取对应 checker 并调用 buildDetail,而非由 execute 方法直接生成 detail
|
||||
|
||||
### Requirement: CheckerRegistry 注册中心
|
||||
系统 SHALL 在 `src/server/checker/runner/registry.ts` 中提供 `CheckerRegistry` 类,支持 `register(checker)`、`get(type)` 和 `supportedTypes`。重复注册同一 type SHALL 抛出错误。registry 内部 SHALL 存储 `CheckerDefinition`(使用默认泛型参数),对外提供类型擦除后的接口。
|
||||
|
||||
|
||||
@@ -32,27 +32,27 @@
|
||||
- **THEN** 系统 MUST NOT 向子进程 stdin 写入数据,避免命令因等待输入而阻塞
|
||||
|
||||
### Requirement: cmd checker 执行
|
||||
系统 SHALL 按 cmd target 配置执行本地命令,记录执行耗时、退出码、stdout 和 stderr,并在执行失败时产生结构化错误信息。
|
||||
系统 SHALL 按 cmd target 配置执行本地命令,记录执行耗时、退出码、stdout 和 stderr observation,并在执行失败时产生结构化错误信息。
|
||||
|
||||
#### Scenario: 命令正常退出
|
||||
- **WHEN** cmd target 执行的进程正常退出且 exit code 为 0
|
||||
- **THEN** 系统 SHALL 记录 `durationMs`、`statusDetail="exitCode=0"`,并进入 expect 校验
|
||||
- **THEN** 系统 SHALL 记录 `durationMs` 和包含 exitCode、stdoutPreview、stderrPreview 的 observation,并进入 expect 校验;API detail SHALL 为 `exitCode=0`
|
||||
|
||||
#### Scenario: 命令非零退出
|
||||
- **WHEN** cmd target 执行的进程正常退出但 exit code 为 1
|
||||
- **THEN** 系统 SHALL 记录 `statusDetail="exitCode=1"`,并由 expect.exitCode 决定 matched 结果
|
||||
- **THEN** 系统 SHALL 记录包含 exitCode、stdoutPreview、stderrPreview 的 observation,并由 expect.exitCode 决定 matched 结果;API detail SHALL 为 `exitCode=1`
|
||||
|
||||
#### Scenario: 命令启动失败
|
||||
- **WHEN** cmd target 的 exec 不存在或无法启动
|
||||
- **THEN** 系统 SHALL 记录 `matched=false`,并在 failure 中写入 kind=`error` 和可读错误信息
|
||||
- **THEN** 系统 SHALL 记录 `matched=false`,observation SHALL 为 null,并在 failure 中写入 kind=`error` 和可读错误信息
|
||||
|
||||
#### Scenario: 命令超时
|
||||
- **WHEN** cmd target 在 timeout 时间内未结束
|
||||
- **THEN** 系统 MUST 终止该子进程,记录 `matched=false`,并在 failure 中写入命令超时信息
|
||||
- **THEN** 系统 MUST 终止该子进程,记录 `matched=false`,并在 failure 中写入命令超时信息;如已收集输出片段,observation SHALL 包含 stdoutPreview、stderrPreview 和 error
|
||||
|
||||
#### Scenario: 命令输出超限
|
||||
- **WHEN** cmd target 的 stdout 和 stderr 合计输出超过 `maxOutputBytes`
|
||||
- **THEN** 系统 MUST 停止收集输出并终止该检查,记录 `matched=false`,并在 failure 中写入输出超限信息
|
||||
- **THEN** 系统 MUST 停止收集输出并终止该检查,记录 `matched=false`,并在 failure 中写入输出超限信息;observation SHALL 包含已截断输出预览和 error
|
||||
|
||||
### Requirement: cmd expect 校验
|
||||
系统 SHALL 支持 cmd 专用 expect,包括 `exitCode`、`durationMs`、`stdout` 和 `stderr`,并按 exitCode、durationMs、stdout、stderr 的阶段顺序快速失败。`exitCode` SHALL 保持有限整数数组语义,未配置时默认 `[0]`。`durationMs` SHALL 使用共享 `ValueMatcher` 校验完整命令执行耗时。`stdout` 和 `stderr` MUST 使用共享 `ContentRules` 数组,直接 matcher 作用于对应输出文本,`json` extractor SHALL 支持对 JSON CLI 输出执行 JSONPath 断言。
|
||||
|
||||
@@ -24,7 +24,7 @@
|
||||
- **THEN** 系统 SHALL 在配置校验阶段报错,拒绝启动
|
||||
|
||||
### Requirement: 定时清理调度
|
||||
系统 SHALL 以固定间隔(1 小时)定期执行数据清理,删除超过保留时长的历史检查结果。
|
||||
系统 SHALL 以固定间隔(1 小时)定期执行数据清理,删除超过保留时长的历史检查结果,并清理已无关联检查结果的非活跃目标行。
|
||||
|
||||
#### Scenario: 引擎启动后首次清理
|
||||
- **WHEN** ProbeEngine 启动
|
||||
@@ -34,6 +34,10 @@
|
||||
- **WHEN** 清理定时器触发
|
||||
- **THEN** 系统 SHALL 删除 `check_results` 表中 `timestamp` 早于 `now - retentionMs` 的所有记录
|
||||
|
||||
#### Scenario: 清理空壳非活跃目标
|
||||
- **WHEN** 清理定时器触发且 check_results 过期清理执行完毕
|
||||
- **THEN** 系统 SHALL 删除 `targets` 表中 `active = 0` 且在 `check_results` 表中不存在任何关联记录的目标行
|
||||
|
||||
#### Scenario: 引擎停止时清除定时器
|
||||
- **WHEN** ProbeEngine.stop() 被调用
|
||||
- **THEN** 系统 SHALL 清除清理定时器,不再执行后续清理
|
||||
|
||||
@@ -1,84 +1,88 @@
|
||||
## Purpose
|
||||
|
||||
定义 ICMP/Ping checker 的配置格式、命令执行、跨平台输出解析、expect 校验、失败结构和状态摘要。
|
||||
定义 ICMP checker 的配置格式、命令执行、跨平台输出解析、expect 校验、失败结构和状态摘要。
|
||||
|
||||
## Requirements
|
||||
|
||||
### Requirement: ping target 配置
|
||||
系统 SHALL 支持 `type: ping` 的 target 配置,通过 `ping.host` 描述目标主机地址,并通过可选字段控制探测行为。
|
||||
### Requirement: icmp target 配置
|
||||
系统 SHALL 支持 `type: icmp` 的 target 配置,通过 `icmp.host` 描述目标主机地址,并通过可选字段控制探测行为。
|
||||
|
||||
#### Scenario: 解析最简 ping target
|
||||
- **WHEN** YAML 中 target 配置 `type: ping` 和 `ping.host: "10.0.0.1"`
|
||||
- **THEN** 系统 SHALL 将其解析为 ping checker,并填充 `count=3`、`packetSize=56`、interval、timeout、group 和 expect 配置
|
||||
#### Scenario: 解析最简 icmp target
|
||||
- **WHEN** YAML 中 target 配置 `type: icmp` 和 `icmp.host: "10.0.0.1"`
|
||||
- **THEN** 系统 SHALL 将其解析为 icmp checker,并填充 `count=3`、`packetSize=56`、interval、timeout、group 和 expect 配置
|
||||
|
||||
#### Scenario: ping target 缺少 host
|
||||
- **WHEN** YAML 中 target 配置 `type: ping` 但缺少 `ping.host`
|
||||
- **THEN** 系统 SHALL 以配置错误退出,并提示该 target 缺少 ping.host 字段
|
||||
#### Scenario: icmp target 缺少 host
|
||||
- **WHEN** YAML 中 target 配置 `type: icmp` 但缺少 `icmp.host`
|
||||
- **THEN** 系统 SHALL 以配置错误退出,并提示该 target 缺少 icmp.host 字段
|
||||
|
||||
#### Scenario: ping host 类型非法
|
||||
- **WHEN** YAML 中 ping target 的 `ping.host` 不是非空字符串
|
||||
- **THEN** 系统 SHALL 以配置错误退出,提示 ping.host 必须为非空字符串
|
||||
#### Scenario: icmp host 类型非法
|
||||
- **WHEN** YAML 中 icmp target 的 `icmp.host` 不是非空字符串
|
||||
- **THEN** 系统 SHALL 以配置错误退出,提示 icmp.host 必须为非空字符串
|
||||
|
||||
#### Scenario: ping count 配置
|
||||
- **WHEN** YAML 中 ping target 配置 `ping.count: 5`
|
||||
#### Scenario: icmp count 配置
|
||||
- **WHEN** YAML 中 icmp target 配置 `icmp.count: 5`
|
||||
- **THEN** 系统 SHALL 使用 5 作为 ICMP 包发送数量
|
||||
|
||||
#### Scenario: ping count 非法
|
||||
- **WHEN** YAML 中 ping target 的 `ping.count` 不是 1 到 100 之间的正整数
|
||||
- **THEN** 系统 SHALL 以配置错误退出,提示 ping.count 必须为 1-100 的正整数
|
||||
#### Scenario: icmp count 非法
|
||||
- **WHEN** YAML 中 icmp target 的 `icmp.count` 不是 1 到 100 之间的正整数
|
||||
- **THEN** 系统 SHALL 以配置错误退出,提示 icmp.count 必须为 1-100 的正整数
|
||||
|
||||
#### Scenario: ping packetSize 配置
|
||||
- **WHEN** YAML 中 ping target 配置 `ping.packetSize: 1472`
|
||||
#### Scenario: icmp packetSize 配置
|
||||
- **WHEN** YAML 中 icmp target 配置 `icmp.packetSize: 1472`
|
||||
- **THEN** 系统 SHALL 使用 1472 作为 ICMP 包大小(bytes)
|
||||
|
||||
#### Scenario: ping packetSize 非法
|
||||
- **WHEN** YAML 中 ping target 的 `ping.packetSize` 不是 1 到 65500 之间的正整数
|
||||
- **THEN** 系统 SHALL 以配置错误退出,提示 ping.packetSize 必须为 1-65500 的正整数
|
||||
#### Scenario: icmp packetSize 非法
|
||||
- **WHEN** YAML 中 icmp target 的 `icmp.packetSize` 不是 1 到 65500 之间的正整数
|
||||
- **THEN** 系统 SHALL 以配置错误退出,提示 icmp.packetSize 必须为 1-65500 的正整数
|
||||
|
||||
#### Scenario: ping 分组未知字段失败
|
||||
- **WHEN** YAML 中 ping target 的 `ping` 分组包含 `timeout: 5` 等未知字段
|
||||
- **THEN** 系统 SHALL 以配置错误退出,提示 ping 分组包含未知字段
|
||||
#### Scenario: icmp 分组未知字段失败
|
||||
- **WHEN** YAML 中 icmp target 的 `icmp` 分组包含 `timeout: 5` 等未知字段
|
||||
- **THEN** 系统 SHALL 以配置错误退出,提示 icmp 分组包含未知字段
|
||||
|
||||
#### Scenario: ping 序列化展示摘要
|
||||
- **WHEN** 系统同步 ping target 到 targets 表
|
||||
- **THEN** `target` 展示摘要 SHALL 为 `ping <host>`,`config` JSON SHALL 包含 resolved 后的 host、count 和 packetSize
|
||||
#### Scenario: icmp 序列化展示摘要
|
||||
- **WHEN** 系统同步 icmp target 到 targets 表
|
||||
- **THEN** `target` 展示摘要 SHALL 为 `icmp <host>`,`config` JSON SHALL 包含 resolved 后的 host、count 和 packetSize
|
||||
|
||||
### Requirement: ping checker 执行
|
||||
系统 SHALL 通过调用系统 `ping` 命令执行 ICMP 探测,记录完整执行耗时,并在命令不可用、超时或解析失败时产生结构化失败信息。
|
||||
### Requirement: icmp checker 执行
|
||||
系统 SHALL 通过调用系统 `ping` 命令执行 ICMP 探测,记录完整执行耗时,并在命令不可用、超时或解析失败时产生结构化失败信息。`IcmpChecker` SHALL 通过构造函数参数支持 platform 注入,默认使用 `process.platform`。
|
||||
|
||||
#### Scenario: ping 命令构建(Linux)
|
||||
- **WHEN** 系统平台为 linux,ping target 配置 host="10.0.0.1"、count=3、packetSize=56,且外层 timeoutMs=10000
|
||||
- **WHEN** 系统平台为 linux,icmp target 配置 host="10.0.0.1"、count=3、packetSize=56,且外层 timeoutMs=10000
|
||||
- **THEN** 系统 SHALL 执行 `ping -c 3 -s 56 -W 10 10.0.0.1`(-W 单位为秒,向上取整)
|
||||
|
||||
#### Scenario: ping 命令构建(macOS)
|
||||
- **WHEN** 系统平台为 darwin,ping target 配置 host="10.0.0.1"、count=3、packetSize=56,且外层 timeoutMs=10000
|
||||
- **WHEN** 系统平台为 darwin,icmp target 配置 host="10.0.0.1"、count=3、packetSize=56,且外层 timeoutMs=10000
|
||||
- **THEN** 系统 SHALL 执行 `ping -c 3 -s 56 -W 10000 10.0.0.1`(-W 单位为毫秒)
|
||||
|
||||
#### Scenario: ping 命令构建(Windows)
|
||||
- **WHEN** 系统平台为 win32,ping target 配置 host="10.0.0.1"、count=3、packetSize=56,且外层 timeoutMs=10000
|
||||
- **WHEN** 系统平台为 win32,icmp target 配置 host="10.0.0.1"、count=3、packetSize=56,且外层 timeoutMs=10000
|
||||
- **THEN** 系统 SHALL 执行 `ping -n 3 -l 56 -w 10000 10.0.0.1`(-w 单位为毫秒)
|
||||
|
||||
#### Scenario: ping 命令不存在
|
||||
- **WHEN** 系统未安装 `ping` 命令(spawn 抛出 ENOENT)
|
||||
- **THEN** 系统 SHALL 记录 `matched=false`,failure 的 kind 为 `error`,phase 为 `ping`,path 为 `spawn`,message 包含 "ping 命令不可用" 和原始错误信息
|
||||
- **THEN** 系统 SHALL 记录 `matched=false`,failure 的 kind 为 `error`,phase 为 `icmp`,path 为 `spawn`,message 包含 "icmp 命令不可用" 和原始错误信息
|
||||
|
||||
#### Scenario: ping 执行超时
|
||||
- **WHEN** 引擎注入的 `ctx.signal` 在 ping 命令执行过程中 abort
|
||||
- **THEN** 系统 SHALL 调用 `proc.kill()` 终止子进程,记录 `matched=false`,failure 的 kind 为 `error`,phase 为 `ping`,message 包含超时信息
|
||||
- **THEN** 系统 SHALL 调用 `proc.kill()` 终止子进程,记录 `matched=false`,failure 的 kind 为 `error`,phase 为 `icmp`,message 包含超时信息
|
||||
|
||||
#### Scenario: ping 目标可达
|
||||
- **WHEN** ping target 指向可达主机,且 ping 命令正常返回
|
||||
- **WHEN** icmp target 指向可达主机,且 ping 命令正常返回
|
||||
- **THEN** 系统 SHALL 解析 stdout 获取统计数据,并按断言链执行 expect 校验
|
||||
|
||||
#### Scenario: ping 目标不可达
|
||||
- **WHEN** ping target 指向不可达主机,且 ping 命令返回 100% packet loss
|
||||
- **WHEN** icmp target 指向不可达主机,且 ping 命令返回 100% packet loss
|
||||
- **THEN** 系统 SHALL 解析 stdout 获取统计数据,`alive` 为 false,延迟字段为 null
|
||||
|
||||
#### Scenario: duration 覆盖完整执行
|
||||
- **WHEN** ping 命令执行完成
|
||||
- **THEN** 结果中的 `durationMs` SHALL 覆盖从 spawn 到进程退出的完整耗时
|
||||
|
||||
### Requirement: 跨平台 ping 输出解析
|
||||
#### Scenario: platform 注入用于测试
|
||||
- **WHEN** 构造 `new IcmpChecker("linux")`
|
||||
- **THEN** execute 方法 SHALL 使用注入的 "linux" 作为平台参数,而非 `process.platform`
|
||||
|
||||
### Requirement: 跨平台 icmp 输出解析
|
||||
系统 SHALL 实现跨平台 ping 输出解析器,支持 Linux、macOS 和 Windows(含多语言 locale),从 stdout 中提取 transmitted、received、packetLoss、minLatencyMs、avgLatencyMs、maxLatencyMs。
|
||||
|
||||
#### Scenario: 解析 Linux ping 输出
|
||||
@@ -103,86 +107,86 @@
|
||||
|
||||
#### Scenario: 输出无法解析
|
||||
- **WHEN** stdout 不匹配任何已知的统计行格式
|
||||
- **THEN** 系统 SHALL 记录 `matched=false`,failure 的 kind 为 `error`,phase 为 `ping`,path 为 `parse`,message 包含 "无法解析 ping 输出"
|
||||
- **THEN** 系统 SHALL 记录 `matched=false`,failure 的 kind 为 `error`,phase 为 `icmp`,path 为 `parse`,message 包含 "无法解析 icmp 输出"
|
||||
|
||||
### Requirement: ping expect 校验
|
||||
系统 SHALL 支持 ping 专属 expect,包括 `alive`、`packetLossPercent`、`avgLatencyMs`、`maxLatencyMs` 和 `durationMs`,并按 alive、packetLossPercent、avgLatencyMs、maxLatencyMs、durationMs 的阶段顺序快速失败。`alive` SHALL 保持布尔状态语义,未配置时默认 `true`。`packetLossPercent` SHALL 表示 0 到 100 的丢包率百分比,并使用共享 `ValueMatcher`。`avgLatencyMs`、`maxLatencyMs` 和 `durationMs` SHALL 使用共享 `ValueMatcher`。
|
||||
### Requirement: icmp expect 校验
|
||||
系统 SHALL 支持 icmp 专属 expect,包括 `alive`、`packetLossPercent`、`avgLatencyMs`、`maxLatencyMs` 和 `durationMs`,并按 alive、packetLossPercent、avgLatencyMs、maxLatencyMs、durationMs 的阶段顺序快速失败。`alive` SHALL 保持布尔状态语义,未配置时默认 `true`。`packetLossPercent` SHALL 表示 0 到 100 的丢包率百分比,并使用共享 `ValueMatcher`。`avgLatencyMs`、`maxLatencyMs` 和 `durationMs` SHALL 使用共享 `ValueMatcher`。
|
||||
|
||||
#### Scenario: 默认 alive 成功语义
|
||||
- **WHEN** ping target 未显式配置 `expect.alive`
|
||||
- **WHEN** icmp target 未显式配置 `expect.alive`
|
||||
- **THEN** 系统 SHALL 使用默认 `expect.alive: true` 进行校验
|
||||
|
||||
#### Scenario: alive 校验通过
|
||||
- **WHEN** ping target 配置 `expect.alive: true`,且目标主机可达
|
||||
- **WHEN** icmp target 配置 `expect.alive: true`,且目标主机可达
|
||||
- **THEN** 系统 SHALL 判定 alive 阶段通过
|
||||
|
||||
#### Scenario: alive 校验失败
|
||||
- **WHEN** ping target 配置 `expect.alive: true`,且目标主机不可达
|
||||
- **WHEN** icmp target 配置 `expect.alive: true`,且目标主机不可达
|
||||
- **THEN** 系统 SHALL 返回 `matched=false`,failure 的 kind 为 `mismatch`,phase 为 `alive`
|
||||
|
||||
#### Scenario: 反向 alive 断言
|
||||
- **WHEN** ping target 配置 `expect.alive: false`,且目标主机不可达
|
||||
- **WHEN** icmp target 配置 `expect.alive: false`,且目标主机不可达
|
||||
- **THEN** 系统 SHALL 判定 alive 阶段通过(`matched=true`)
|
||||
|
||||
#### Scenario: packetLossPercent 校验通过
|
||||
- **WHEN** ping target 配置 `expect.packetLossPercent: {lte: 10}`,且实际丢包率为 0%
|
||||
- **WHEN** icmp target 配置 `expect.packetLossPercent: {lte: 10}`,且实际丢包率为 0%
|
||||
- **THEN** 系统 SHALL 判定 packetLossPercent 阶段通过
|
||||
|
||||
#### Scenario: packetLossPercent 校验失败
|
||||
- **WHEN** ping target 配置 `expect.packetLossPercent: {lte: 10}`,且实际丢包率为 33%
|
||||
- **WHEN** icmp target 配置 `expect.packetLossPercent: {lte: 10}`,且实际丢包率为 33%
|
||||
- **THEN** 系统 SHALL 返回 `matched=false`,failure 的 kind 为 `mismatch`,phase 为 `packetLoss`
|
||||
|
||||
#### Scenario: avgLatencyMs 校验通过
|
||||
- **WHEN** ping target 配置 `expect.avgLatencyMs: {lte: 200}`,且实际平均延迟为 12ms
|
||||
- **WHEN** icmp target 配置 `expect.avgLatencyMs: {lte: 200}`,且实际平均延迟为 12ms
|
||||
- **THEN** 系统 SHALL 判定 avgLatency 阶段通过
|
||||
|
||||
#### Scenario: avgLatencyMs 校验失败
|
||||
- **WHEN** ping target 配置 `expect.avgLatencyMs: {lte: 100}`,且实际平均延迟为 156ms
|
||||
- **WHEN** icmp target 配置 `expect.avgLatencyMs: {lte: 100}`,且实际平均延迟为 156ms
|
||||
- **THEN** 系统 SHALL 返回 `matched=false`,failure 的 kind 为 `mismatch`,phase 为 `avgLatency`
|
||||
|
||||
#### Scenario: maxLatencyMs 校验通过
|
||||
- **WHEN** ping target 配置 `expect.maxLatencyMs: {lte: 500}`,且实际最大延迟为 340ms
|
||||
- **WHEN** icmp target 配置 `expect.maxLatencyMs: {lte: 500}`,且实际最大延迟为 340ms
|
||||
- **THEN** 系统 SHALL 判定 maxLatency 阶段通过
|
||||
|
||||
#### Scenario: maxLatencyMs 校验失败
|
||||
- **WHEN** ping target 配置 `expect.maxLatencyMs: {lte: 200}`,且实际最大延迟为 340ms
|
||||
- **WHEN** icmp target 配置 `expect.maxLatencyMs: {lte: 200}`,且实际最大延迟为 340ms
|
||||
- **THEN** 系统 SHALL 返回 `matched=false`,failure 的 kind 为 `mismatch`,phase 为 `maxLatency`
|
||||
|
||||
#### Scenario: durationMs 校验
|
||||
- **WHEN** ping target 配置 `expect.durationMs: {lte: 5000}`,且完整执行耗时超过 5000ms
|
||||
- **WHEN** icmp target 配置 `expect.durationMs: {lte: 5000}`,且完整执行耗时超过 5000ms
|
||||
- **THEN** 系统 SHALL 返回 `matched=false`,failure 的 phase 为 `duration`
|
||||
|
||||
#### Scenario: alive=false 时跳过延迟断言
|
||||
- **WHEN** ping target 配置 `expect.alive: true` 和 `expect.avgLatencyMs: {lte: 100}`,且目标不可达
|
||||
- **WHEN** icmp target 配置 `expect.alive: true` 和 `expect.avgLatencyMs: {lte: 100}`,且目标不可达
|
||||
- **THEN** 系统 SHALL 在 alive 阶段即返回失败,不执行后续延迟断言
|
||||
|
||||
#### Scenario: ping expect 未知字段失败
|
||||
- **WHEN** YAML 中 ping target 的 expect 包含 `status: [200]`、`maxPacketLoss`、`maxAvgLatencyMs`、`maxMaxLatencyMs`、`maxDurationMs` 或其他非 ping expect 字段
|
||||
#### Scenario: icmp expect 未知字段失败
|
||||
- **WHEN** YAML 中 icmp target 的 expect 包含 `status: [200]`、`maxPacketLoss`、`maxAvgLatencyMs`、`maxMaxLatencyMs`、`maxDurationMs` 或其他非 icmp expect 字段
|
||||
- **THEN** 系统 SHALL 以配置错误退出,提示 expect 包含未知字段
|
||||
|
||||
#### Scenario: packetLossPercent 类型非法
|
||||
- **WHEN** YAML 中 ping target 的 `expect.packetLossPercent` 不是合法 `ValueMatcher`,或其数值范围无法用于 0 到 100 的百分比断言
|
||||
- **WHEN** YAML 中 icmp target 的 `expect.packetLossPercent` 不是合法 `ValueMatcher`,或其数值范围无法用于 0 到 100 的百分比断言
|
||||
- **THEN** 系统 SHALL 以配置错误退出,提示 expect.packetLossPercent 格式错误
|
||||
|
||||
#### Scenario: avgLatencyMs 类型非法
|
||||
- **WHEN** YAML 中 ping target 的 `expect.avgLatencyMs` 不是合法 `ValueMatcher`
|
||||
- **WHEN** YAML 中 icmp target 的 `expect.avgLatencyMs` 不是合法 `ValueMatcher`
|
||||
- **THEN** 系统 SHALL 以配置错误退出,提示 expect.avgLatencyMs 格式错误
|
||||
|
||||
#### Scenario: maxLatencyMs 类型非法
|
||||
- **WHEN** YAML 中 ping target 的 `expect.maxLatencyMs` 不是合法 `ValueMatcher`
|
||||
- **WHEN** YAML 中 icmp target 的 `expect.maxLatencyMs` 不是合法 `ValueMatcher`
|
||||
- **THEN** 系统 SHALL 以配置错误退出,提示 expect.maxLatencyMs 格式错误
|
||||
|
||||
### Requirement: ping statusDetail 摘要
|
||||
系统 SHALL 在 ping 执行成功后生成结构化 statusDetail 摘要,展示关键指标。
|
||||
### Requirement: icmp detail 摘要
|
||||
系统 SHALL 在 icmp API 序列化时从 observation 动态生成结构化 detail 摘要,展示关键指标。API registry type SHALL 仍为 `icmp`。
|
||||
|
||||
#### Scenario: 目标可达无丢包
|
||||
- **WHEN** ping 结果为 alive=true, avg=12ms, packetLoss=0%, transmitted=3, received=3
|
||||
- **THEN** statusDetail SHALL 为 `alive, avg 12ms, loss 0% (3/3)`
|
||||
- **WHEN** icmp observation 为 alive=true, avgLatencyMs=12, packetLoss=0%, transmitted=3, received=3
|
||||
- **THEN** detail SHALL 为 `alive, avg 12ms, loss 0% (3/3)`
|
||||
|
||||
#### Scenario: 目标可达有丢包
|
||||
- **WHEN** ping 结果为 alive=true, avg=156ms, max=340ms, packetLoss=33%, transmitted=3, received=2
|
||||
- **THEN** statusDetail SHALL 包含 avg、max 和 loss 信息
|
||||
- **WHEN** icmp observation 为 alive=true, avgLatencyMs=156, maxLatencyMs=340, packetLoss=33%, transmitted=3, received=2
|
||||
- **THEN** detail SHALL 包含 avg、max 和 loss 信息
|
||||
|
||||
#### Scenario: 目标不可达
|
||||
- **WHEN** ping 结果为 alive=false, transmitted=3, received=0
|
||||
- **THEN** statusDetail SHALL 为 `unreachable (0/3 received)`
|
||||
- **WHEN** icmp observation 为 alive=false, transmitted=3, received=0
|
||||
- **THEN** detail SHALL 为 `unreachable (0/3 received)`
|
||||
|
||||
@@ -205,7 +205,7 @@ LLM checker SHALL 仅允许 `mode: stream` 使用 `expect.stream`。`expect.stre
|
||||
- **THEN** 默认 `stream.completed=true` SHALL NOT 阻断基于 status 和 headers 的 APICallError 状态探测
|
||||
|
||||
### Requirement: LLM Failure Phase 与状态摘要
|
||||
LLM checker SHALL 使用 `request`、`status`、`headers`、`stream`、`output`、`finishReason`、`rawFinishReason`、`usage`、`duration` 作为第一版 failure phase。成功结果的 `statusDetail` SHALL 简短描述 provider、mode、HTTP status、finish reason、raw finish reason、first token、输出长度和 token usage 中可用的信息。`statusDetail` MUST NOT 写入完整 prompt、完整输出或 key。
|
||||
LLM checker SHALL 使用 `request`、`status`、`headers`、`stream`、`output`、`finishReason`、`rawFinishReason`、`usage`、`duration` 作为第一版 failure phase。成功结果的 API detail SHALL 从持久化 observation 动态构造,简短描述 provider、mode、HTTP status、finish reason、raw finish reason、first token、输出长度和 token usage 中可用的信息。observation 和 detail MUST NOT 写入完整 prompt、完整输出或 key。
|
||||
|
||||
#### Scenario: request failure
|
||||
- **WHEN** 模型请求因网络错误、认证调用异常、AbortSignal 或无 HTTP metadata 的 SDK 错误失败
|
||||
@@ -217,11 +217,11 @@ LLM checker SHALL 使用 `request`、`status`、`headers`、`stream`、`output`
|
||||
|
||||
#### Scenario: 非流式成功摘要
|
||||
- **WHEN** `provider: openai` 的非流式检查成功
|
||||
- **THEN** `statusDetail` SHALL 使用类似 `LLM openai http 200 finish=stop, output=2 chars, usage=12/2 tokens` 的简短格式
|
||||
- **THEN** detail SHALL 使用类似 `LLM openai http 200 finish=stop, output=2 chars, usage=12/2 tokens` 的简短格式
|
||||
|
||||
#### Scenario: 流式成功摘要
|
||||
- **WHEN** `provider: anthropic` 的流式检查成功且存在 raw finish reason
|
||||
- **THEN** `statusDetail` SHALL 使用类似 `LLM anthropic stream 200 finish=stop raw=end_turn, firstToken=624ms, output=2 chars` 的简短格式
|
||||
- **THEN** detail SHALL 使用类似 `LLM anthropic stream 200 finish=stop raw=end_turn, firstToken=624ms, output=2 chars` 的简短格式
|
||||
|
||||
#### Scenario: serialize 展示文本
|
||||
- **WHEN** store 同步 LLM target
|
||||
@@ -236,7 +236,7 @@ LLM checker 的自动化测试 MUST 不访问真实外部模型服务。测试 S
|
||||
|
||||
#### Scenario: 本地 mock provider 测试错误路径
|
||||
- **WHEN** 测试运行 401、429、500、超时、stream error、stream abort、缺 usage 或无文本输出路径
|
||||
- **THEN** 测试 SHALL 断言 LLM checker 返回符合 spec 的 matched、failure phase、actual 和 statusDetail
|
||||
- **THEN** 测试 SHALL 断言 LLM checker 返回符合 spec 的 matched、failure phase、actual、detail 和 observation
|
||||
|
||||
#### Scenario: 质量检查覆盖 LLM checker
|
||||
- **WHEN** 实现完成后执行质量检查
|
||||
|
||||
@@ -5,19 +5,19 @@
|
||||
## Requirements
|
||||
|
||||
### Requirement: Dashboard 聚合 API
|
||||
系统 SHALL 提供 `GET /api/dashboard` 端点,返回 Dashboard 首屏所需的总览统计和目标列表数据。
|
||||
系统 SHALL 提供 `GET /api/dashboard` 端点,返回 Dashboard 首屏所需的总览统计和目标列表数据。targets 列表 SHALL 仅包含活跃目标。
|
||||
|
||||
#### Scenario: 获取 Dashboard 数据
|
||||
- **WHEN** 客户端请求 `GET /api/dashboard?window=24h&recentLimit=30`
|
||||
- **THEN** 系统 SHALL 返回 JSON 包含 summary 和 targets 字段
|
||||
- **THEN** 系统 SHALL 返回 JSON 包含 summary 和 targets 字段,targets 仅包含 active=1 的目标
|
||||
|
||||
#### Scenario: summary 字段
|
||||
- **WHEN** Dashboard 响应包含 summary
|
||||
- **THEN** summary SHALL 包含 total(总目标数)、up(当前正常目标数)、down(当前异常目标数)、lastCheckTime(最近一次检查时间)、incidents(指定窗口内异常事件数)、window(from/to/label)字段
|
||||
- **THEN** summary SHALL 仅统计活跃目标:total(活跃目标数)、up(活跃正常目标数)、down(活跃异常目标数)、lastCheckTime(最近一次检查时间)、incidents(活跃目标在指定窗口内异常事件数)、window(from/to/label)字段
|
||||
|
||||
#### Scenario: targets 字段
|
||||
- **WHEN** Dashboard 响应包含 targets
|
||||
- **THEN** targets 数组中每个元素 SHALL 包含目标基本信息(id、name、description、group、type、target、interval)、latestCheck、stats、currentStreak 和 recentSamples 字段,其中 name 和 description 均为 null 或字符串
|
||||
- **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`
|
||||
@@ -75,25 +75,37 @@ Dashboard API SHALL 返回基于时间窗口计算的目标统计和连续状态
|
||||
- **THEN** 每个 recentSamples 元素 SHALL 包含 timestamp、durationMs、up 字段,其中 up 为 boolean 且等于 matched
|
||||
|
||||
### Requirement: 单目标指标 API
|
||||
系统 SHALL 提供 `GET /api/targets/:id/metrics` 端点,返回 Drawer 概览所需的单目标统计和趋势数据。端点的详细计算规则(P95/P99、MTTR、故障分析、趋势分桶等)定义在 `target-metrics-api` 能力中。
|
||||
系统 SHALL 提供 `GET /api/targets/:id/metrics` 端点,返回 Drawer 概览所需的单目标统计和趋势数据。仅活跃目标的指标 SHALL 可查询。端点的详细计算规则(P95/P99、MTTR、故障分析、趋势分桶等)定义在 `target-metrics-api` 能力中。
|
||||
|
||||
#### Scenario: 指定时间范围查询指标
|
||||
- **WHEN** 客户端请求 `GET /api/targets/1/metrics?from=ISO&to=ISO&bucket=1h`
|
||||
- **WHEN** 客户端请求 `GET /api/targets/1/metrics?from=ISO&to=ISO&bucket=1h` 且该目标为活跃目标
|
||||
- **THEN** 系统 SHALL 返回 targetId、window、stats、trend 字段
|
||||
|
||||
#### Scenario: from 或 to 参数缺失
|
||||
- **WHEN** 客户端请求 `GET /api/targets/1/metrics` 未提供 from 或 to 参数
|
||||
- **THEN** 系统 SHALL 返回 400 状态码和错误信息
|
||||
|
||||
#### Scenario: 目标不存在或非活跃
|
||||
- **WHEN** 客户端请求 `GET /api/targets/999/metrics?from=ISO&to=ISO` 且该目标不存在或 active=0
|
||||
- **THEN** 系统 SHALL 返回 404 状态码和错误信息
|
||||
|
||||
#### Scenario: 无效的目标 ID
|
||||
- **WHEN** 客户端请求 `GET /api/targets/abc/metrics?from=ISO&to=ISO`
|
||||
- **THEN** 系统 SHALL 返回 400 状态码和错误信息
|
||||
|
||||
#### Scenario: bucket 参数缺失
|
||||
- **WHEN** 客户端请求 `GET /api/targets/1/metrics?from=ISO&to=ISO` 未提供 bucket 参数
|
||||
- **THEN** 系统 SHALL 默认使用 bucket=`1h`
|
||||
|
||||
#### Scenario: 不支持的 bucket 参数
|
||||
- **WHEN** 客户端请求 `GET /api/targets/1/metrics?from=ISO&to=ISO&bucket=5m`
|
||||
- **THEN** 系统 SHALL 返回 400 状态码和错误信息
|
||||
|
||||
### Requirement: 历史记录 API
|
||||
系统 SHALL 保留 `GET /api/targets/:id/history` 端点,支持时间范围筛选和分页返回指定目标的拨测记录。
|
||||
系统 SHALL 保留 `GET /api/targets/:id/history` 端点,支持时间范围筛选和分页返回指定目标的拨测记录。仅活跃目标的历史记录 SHALL 可查询。
|
||||
|
||||
#### Scenario: 获取指定时间范围内的历史记录
|
||||
- **WHEN** 客户端请求 `GET /api/targets/1/history?from=ISO&to=ISO&page=1&pageSize=20`
|
||||
- **WHEN** 客户端请求 `GET /api/targets/1/history?from=ISO&to=ISO&page=1&pageSize=20` 且该目标为活跃目标
|
||||
- **THEN** 系统 SHALL 返回带分页信息的历史记录,包含 items、total、page、pageSize,按时间倒序排列
|
||||
|
||||
#### Scenario: 使用默认分页参数
|
||||
@@ -104,8 +116,12 @@ Dashboard API SHALL 返回基于时间窗口计算的目标统计和连续状态
|
||||
- **WHEN** 客户端请求 `GET /api/targets/1/history` 未提供 from 或 to 参数
|
||||
- **THEN** 系统 SHALL 返回 400 状态码和错误信息
|
||||
|
||||
#### Scenario: 目标不存在或非活跃
|
||||
- **WHEN** 客户端请求 `GET /api/targets/999/history?from=ISO&to=ISO` 且该目标不存在或 active=0
|
||||
- **THEN** 系统 SHALL 返回 404 状态码和错误信息
|
||||
|
||||
### Requirement: 新增共享类型
|
||||
系统 SHALL 在 `src/shared/api.ts` 中定义 Dashboard 和 Metrics 相关共享类型。
|
||||
系统 SHALL 在 `src/shared/api.ts` 中定义 Dashboard 和 Metrics 相关共享类型。CheckResult SHALL 包含 durationMs(null | number)、failure(CheckFailure | null)、matched(boolean)、detail(null | string)、observation(Record<string, unknown> | null)、timestamp(string)。其中 detail 替代原 statusDetail 字段名。
|
||||
|
||||
#### Scenario: DashboardResponse 类型
|
||||
- **WHEN** 前后端共享 `DashboardResponse` 类型
|
||||
@@ -123,9 +139,9 @@ Dashboard API SHALL 返回基于时间窗口计算的目标统计和连续状态
|
||||
- **WHEN** 前后端共享 `TrendPoint` 类型
|
||||
- **THEN** 该类型 SHALL 包含 bucketStart、avgDurationMs、minDurationMs、maxDurationMs、availability、totalChecks、upChecks、downChecks 字段
|
||||
|
||||
#### Scenario: CheckResult 类型
|
||||
- **WHEN** 前后端共享 `CheckResult` 类型
|
||||
- **THEN** 该类型 SHALL 包含 `timestamp: string`、`matched: boolean`、`durationMs: number | null`、`statusDetail: string | null`、`failure` 字段,不包含 success 字段
|
||||
#### Scenario: CheckResult 类型变更
|
||||
- **WHEN** 前端或后端引用 CheckResult 类型
|
||||
- **THEN** 该类型 SHALL 包含 `timestamp: string`、`matched: boolean`、`durationMs: number | null`、`detail: string | null`、`observation: Record<string, unknown> | null`、`failure` 字段,不包含 statusDetail 字段,不包含 success 字段
|
||||
|
||||
#### Scenario: RecentSample 类型
|
||||
- **WHEN** 前后端共享 `RecentSample` 类型
|
||||
@@ -135,6 +151,22 @@ Dashboard API SHALL 返回基于时间窗口计算的目标统计和连续状态
|
||||
- **WHEN** 前后端共享 `HistoryResponse` 类型
|
||||
- **THEN** 该类型 SHALL 包含 `items: CheckResult[]`、`total: number`、`page: number`、`pageSize: number` 字段
|
||||
|
||||
#### Scenario: API 序列化构造 detail
|
||||
- **WHEN** API 路由序列化 StoredCheckResult 为 API 响应
|
||||
- **THEN** 系统 SHALL 从 StoredCheckResult 中反序列化 observation,根据 target type 通过 checkerRegistry 获取对应 checker 并调用 buildDetail(observation) 动态生成 detail 字段
|
||||
|
||||
#### Scenario: mapCheckResult 接收 type 参数
|
||||
- **WHEN** 序列化辅助函数 mapCheckResult 被调用
|
||||
- **THEN** 函数 SHALL 接收 target type 参数,用于从 registry 获取对应 checker 调用 buildDetail
|
||||
|
||||
#### Scenario: Dashboard API 传递 type
|
||||
- **WHEN** Dashboard 路由序列化 latestCheck
|
||||
- **THEN** 路由 SHALL 将 target.type 传递给 mapCheckResult
|
||||
|
||||
#### Scenario: History API 传递 type
|
||||
- **WHEN** History 路由序列化历史记录列表
|
||||
- **THEN** 路由 SHALL 将已查询的 target.type 传递给 mapCheckResult
|
||||
|
||||
### Requirement: 保留健康检查端点
|
||||
系统 SHALL 保留 `GET /health` 端点,不受拨测功能影响。
|
||||
|
||||
@@ -143,12 +175,16 @@ Dashboard API SHALL 返回基于时间窗口计算的目标统计和连续状态
|
||||
- **THEN** 系统 SHALL 返回与之前格式一致的健康检查响应
|
||||
|
||||
### Requirement: API 错误处理
|
||||
系统 SHALL 对不存在的目标 ID、无效参数和超出范围的分页参数返回适当的 HTTP 错误响应。
|
||||
系统 SHALL 对不存在的目标 ID、非活跃目标、无效参数和超出范围的分页参数返回适当的 HTTP 错误响应。非活跃目标与不存在的目标 SHALL 返回相同的 404 响应。
|
||||
|
||||
#### Scenario: 查询不存在的目标
|
||||
- **WHEN** 客户端请求 `GET /api/targets/999/metrics?from=ISO&to=ISO`
|
||||
- **THEN** 系统 SHALL 返回 404 状态码和错误信息
|
||||
|
||||
#### Scenario: 查询非活跃目标
|
||||
- **WHEN** 客户端请求 `GET /api/targets/<id>/metrics?from=ISO&to=ISO` 且该目标 active=0
|
||||
- **THEN** 系统 SHALL 返回 404 状态码和错误信息
|
||||
|
||||
#### Scenario: 无效的 from/to 参数
|
||||
- **WHEN** 客户端请求 `GET /api/targets/1/metrics?from=invalid&to=ISO`
|
||||
- **THEN** 系统 SHALL 返回 400 状态码和错误信息
|
||||
|
||||
@@ -5,9 +5,9 @@
|
||||
## Requirements
|
||||
|
||||
### Requirement: YAML 配置文件格式
|
||||
系统 SHALL 支持通过 YAML 配置文件定义全部运行参数,包括 server 配置、runtime 配置、可选的 variables 段、checker 默认值和 typed target 列表(含可选 group 字段)。target MUST 使用 `id` 字段作为唯一标识符,MUST 使用 `type` 字段声明 checker 类型,SHALL 支持可选的 `name` 字段作为展示名称元信息,SHALL 支持可选的 `description` 字段作为目标说明。`name` 和 `description` 均 SHALL 允许省略或显式配置为 `null`;省略或显式 null 时解析结果 SHALL 保留为 null。HTTP 领域字段 MUST 放在 `http` 分组,cmd 领域字段 MUST 放在 `cmd` 分组,db 领域字段 MUST 放在 `db` 分组,tcp 领域字段 MUST 放在 `tcp` 分组,ping 领域字段 MUST 放在 `ping` 分组,udp 领域字段 MUST 放在 `udp` 分组,LLM 领域字段 MUST 放在 `llm` 分组。HTTP target 的 `http` 分组 SHALL 支持可选的 `ignoreSSL`(布尔值)和 `maxRedirects`(非负整数)字段。Db target 的 `db` 分组 SHALL 支持 `url`(必填)和 `query`(可选)字段。Tcp target 的 `tcp` 分组 SHALL 支持 `host`(必填)、`port`(必填)、`readBanner`(可选)、`bannerReadTimeout`(可选)和 `maxBannerBytes`(可选)字段。Ping target 的 `ping` 分组 SHALL 支持 `host`(必填)、`count`(可选,默认 3)和 `packetSize`(可选,默认 56)字段。Udp target 的 `udp` 分组 SHALL 支持 `host`(必填)、`port`(必填)、`payload`(可选,默认空字符串)、`encoding`(可选,默认 `text`)、`responseEncoding`(可选,默认 `text`)和 `maxResponseBytes`(可选,默认 4096)字段。LLM target 的 `llm` 分组 SHALL 支持 `provider`(必填)、`url`(必填)、`model`(必填)、`prompt`(必填)、`mode`(可选,默认 `http`)、`key`(可选,默认空字符串)、`authToken`(可选)、`headers`(可选)、`ignoreSSL`(可选,默认 `false`)、`options`(可选)和 `providerOptions`(可选)字段。
|
||||
系统 SHALL 支持通过 YAML 配置文件定义全部运行参数,包括 server 配置、runtime 配置、可选的 variables 段、checker 默认值和 typed target 列表(含可选 group 字段)。target MUST 使用 `id` 字段作为唯一标识符,MUST 使用 `type` 字段声明 checker 类型,SHALL 支持可选的 `name` 字段作为展示名称元信息,SHALL 支持可选的 `description` 字段作为目标说明。`name` 和 `description` 均 SHALL 允许省略或显式配置为 `null`;省略或显式 null 时解析结果 SHALL 保留为 null。HTTP 领域字段 MUST 放在 `http` 分组,cmd 领域字段 MUST 放在 `cmd` 分组,db 领域字段 MUST 放在 `db` 分组,tcp 领域字段 MUST 放在 `tcp` 分组,icmp 领域字段 MUST 放在 `icmp` 分组,udp 领域字段 MUST 放在 `udp` 分组,LLM 领域字段 MUST 放在 `llm` 分组。HTTP target 的 `http` 分组 SHALL 支持可选的 `ignoreSSL`(布尔值)和 `maxRedirects`(非负整数)字段。Db target 的 `db` 分组 SHALL 支持 `url`(必填)和 `query`(可选)字段。Tcp target 的 `tcp` 分组 SHALL 支持 `host`(必填)、`port`(必填)、`readBanner`(可选)、`bannerReadTimeout`(可选)和 `maxBannerBytes`(可选)字段。Icmp target 的 `icmp` 分组 SHALL 支持 `host`(必填)、`count`(可选,默认 3)和 `packetSize`(可选,默认 56)字段。Udp target 的 `udp` 分组 SHALL 支持 `host`(必填)、`port`(必填)、`payload`(可选,默认空字符串)、`encoding`(可选,默认 `text`)、`responseEncoding`(可选,默认 `text`)和 `maxResponseBytes`(可选,默认 4096)字段。LLM target 的 `llm` 分组 SHALL 支持 `provider`(必填)、`url`(必填)、`model`(必填)、`prompt`(必填)、`mode`(可选,默认 `http`)、`key`(可选,默认空字符串)、`authToken`(可选)、`headers`(可选)、`ignoreSSL`(可选,默认 `false`)、`options`(可选)和 `providerOptions`(可选)字段。
|
||||
|
||||
`defaults.http` 分组 SHALL 仅支持 `headers`(可选)和 `maxBodyBytes`(可选)字段。`defaults.http` 分组 MUST NOT 支持 `method` 字段。`defaults.tcp` 分组 SHALL 仅支持 `bannerReadTimeout`(可选)和 `maxBannerBytes`(可选)字段。`defaults.udp` 分组 SHALL 仅支持 `encoding`(可选)、`responseEncoding`(可选)和 `maxResponseBytes`(可选)字段。`defaults.llm` 分组 SHALL 仅支持 `mode`(可选)、`headers`(可选)、`ignoreSSL`(可选)、`options`(可选)和 `providerOptions`(可选)字段。
|
||||
`defaults.http` 分组 SHALL 仅支持 `headers`(可选)和 `maxBodyBytes`(可选)字段。`defaults.http` 分组 MUST NOT 支持 `method` 字段。`defaults.tcp` 分组 SHALL 仅支持 `bannerReadTimeout`(可选)和 `maxBannerBytes`(可选)字段。`defaults.icmp` 分组 SHALL 仅支持空对象。`defaults.udp` 分组 SHALL 仅支持 `encoding`(可选)、`responseEncoding`(可选)和 `maxResponseBytes`(可选)字段。`defaults.llm` 分组 SHALL 仅支持 `mode`(可选)、`headers`(可选)、`ignoreSSL`(可选)、`options`(可选)和 `providerOptions`(可选)字段。
|
||||
|
||||
#### Scenario: 完整配置文件解析
|
||||
- **WHEN** 系统启动并读取包含 server、runtime、variables、defaults、targets(含 id、group 字段)的 YAML 配置文件
|
||||
@@ -57,9 +57,9 @@
|
||||
- **WHEN** HTTP target 未配置 `http.method` 且 defaults.http 中无 method 字段
|
||||
- **THEN** 系统 SHALL 使用内置默认值 GET 作为该 target 的请求方法
|
||||
|
||||
#### Scenario: 最简 ping 配置文件解析
|
||||
- **WHEN** 系统读取只包含一个 `type: ping` target(含 `id` 和 `ping.host`)的 YAML 配置文件
|
||||
- **THEN** 系统 SHALL 使用内置默认值填充未指定的字段(interval=30s, timeout=10s, group="default", ping.count=3, ping.packetSize=56),并保留 name=null、description=null
|
||||
#### Scenario: 最简 icmp 配置文件解析
|
||||
- **WHEN** 系统读取只包含一个 `type: icmp` target(含 `id` 和 `icmp.host`)的 YAML 配置文件
|
||||
- **THEN** 系统 SHALL 使用内置默认值填充未指定的字段(interval=30s, timeout=10s, group="default", icmp.count=3, icmp.packetSize=56),并保留 name=null、description=null
|
||||
|
||||
#### Scenario: 最简 udp 配置文件解析
|
||||
- **WHEN** 系统读取只包含一个 `type: udp` target(含 `id`、`udp.host` 和 `udp.port`)的 YAML 配置文件
|
||||
@@ -219,9 +219,9 @@
|
||||
- **WHEN** YAML 中某个 target 配置 ValueMatcher 字段值为对象 `{foo: "bar"}`,且 `foo` 不是合法 matcher 字段
|
||||
- **THEN** 系统 SHALL 以错误退出,提示 `foo` 是未知 matcher;如需对象 equals 匹配应写成 `{equals: {foo: "bar"}}`
|
||||
|
||||
#### Scenario: ping target 缺少 host
|
||||
- **WHEN** YAML 中某个 target 配置 `type: ping` 但缺少 `ping.host`
|
||||
- **THEN** 系统 SHALL 以错误退出,提示该 target 缺少 ping.host 字段
|
||||
#### Scenario: icmp target 缺少 host
|
||||
- **WHEN** YAML 中某个 target 配置 `type: icmp` 但缺少 `icmp.host`
|
||||
- **THEN** 系统 SHALL 以错误退出,提示该 target 缺少 icmp.host 字段
|
||||
|
||||
#### Scenario: ping expect 未知字段
|
||||
- **WHEN** YAML 中 ping target 的 expect 包含非 ping expect 字段
|
||||
|
||||
@@ -5,11 +5,11 @@
|
||||
## Requirements
|
||||
|
||||
### Requirement: SQLite 数据库初始化
|
||||
系统 SHALL 使用 Bun 内置 `bun:sqlite` 模块在配置的数据目录下创建 SQLite 数据库文件,并以 WAL 模式运行。数据库 schema MUST 支持 typed checker target 和结构化检查结果,targets 表 MUST 包含 `grp` 列存储分组信息,且 targets 表的 `name` 和 `description` 列 MUST 允许 NULL。
|
||||
系统 SHALL 使用 Bun 内置 `bun:sqlite` 模块在配置的数据目录下创建 SQLite 数据库文件,并以 WAL 模式运行。数据库 schema MUST 支持 typed checker target 和结构化检查结果,targets 表 MUST 包含 `grp` 列存储分组信息、`active` 列标记活跃状态,且 targets 表的 `name` 和 `description` 列 MUST 允许 NULL。check_results 表的外键约束 SHALL 使用 `ON DELETE RESTRICT`。
|
||||
|
||||
#### 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 列
|
||||
- **THEN** 系统 SHALL 创建数据库文件并初始化 targets 表(含 active INTEGER NOT NULL DEFAULT 1 列)和 check_results 表(外键约束为 ON DELETE RESTRICT),check_results 表包含 id(INTEGER PRIMARY KEY AUTOINCREMENT)、target_id(TEXT NOT NULL)、timestamp(TEXT NOT NULL)、matched(INTEGER NOT NULL)、duration_ms(REAL)、observation(TEXT)、failure(TEXT),不包含 status_detail 列,不包含 success 列
|
||||
|
||||
#### Scenario: targets name 列允许 NULL
|
||||
- **WHEN** 系统首次创建 targets 表
|
||||
@@ -26,19 +26,27 @@
|
||||
#### Scenario: 外键约束
|
||||
- **THEN** 系统 SHALL 启用 `PRAGMA foreign_keys = ON`
|
||||
|
||||
#### Scenario: 级联删除
|
||||
- **THEN** check_results 表的外键约束 SHALL 使用 `ON DELETE CASCADE`,确保删除目标时自动清理关联结果记录
|
||||
#### Scenario: 级联删除改为限制删除
|
||||
- **THEN** check_results 表的外键约束 SHALL 使用 `ON DELETE RESTRICT`,确保删除 target 时数据库层面阻止操作而非级联删除关联记录
|
||||
|
||||
### Requirement: targets 表同步
|
||||
系统 SHALL 在启动时将 YAML 配置中的目标列表同步到 SQLite targets 表,并持久化 target 类型、展示名称元信息、展示摘要、领域配置、调度配置、expect 配置、分组信息和目标说明。
|
||||
系统 SHALL 在启动时将 YAML 配置中的目标列表同步到 SQLite targets 表,并持久化 target 类型、展示名称元信息、展示摘要、领域配置、调度配置、expect 配置、分组信息和目标说明。配置中不存在的 target SHALL 被标记为非活跃而非删除。
|
||||
|
||||
#### Scenario: 首次同步目标
|
||||
- **WHEN** 数据库为空且 YAML 中定义了 N 个 typed target
|
||||
- **THEN** 系统 SHALL 将所有目标插入 targets 表,包含 name、description、type、target、config、interval_ms、timeout_ms、expect 和 grp,其中 name 和 description 均可为 NULL
|
||||
- **THEN** 系统 SHALL 将所有目标插入 targets 表,包含 name、description、type、target、config、interval_ms、timeout_ms、expect、grp 和 active=1,其中 name 和 description 均可为 NULL
|
||||
|
||||
#### Scenario: 配置变更后重新同步
|
||||
- **WHEN** YAML 配置发生变更(新增、删除或修改目标)后重启
|
||||
- **THEN** 系统 SHALL 根据 id 字段匹配:新增的插入、删除的移除、修改的更新(含 name、description 和 grp 字段)
|
||||
- **THEN** 系统 SHALL 根据 id 字段匹配:新增的插入(active=1)、删除的设置 active=0(不删除行)、修改的更新(含 name、description 和 grp 字段),已存在的目标 SHALL 设置 active=1
|
||||
|
||||
#### Scenario: 配置中移除目标
|
||||
- **WHEN** YAML 配置中移除了某个 target,该 target 在数据库中存在且 active=1
|
||||
- **THEN** 系统 SHALL 将该 target 的 active 设置为 0,保留该行及所有关联的 check_results
|
||||
|
||||
#### Scenario: 配置中恢复已移除目标
|
||||
- **WHEN** YAML 配置中重新添加了之前移除的 target(数据库中 active=0)
|
||||
- **THEN** 系统 SHALL 将该 target 的 active 设置为 1,并更新其他字段,历史 check_results 保留不变
|
||||
|
||||
#### Scenario: 未配置 name
|
||||
- **WHEN** YAML target 未配置 `name`
|
||||
@@ -61,7 +69,11 @@
|
||||
|
||||
#### Scenario: 写入检查结果
|
||||
- **WHEN** 一次 checker 执行完成
|
||||
- **THEN** 系统 SHALL 插入一条包含 target_id、timestamp、matched、duration_ms、status_detail、failure 的记录
|
||||
- **THEN** 系统 SHALL 插入一条包含 target_id、timestamp、matched、duration_ms、observation、failure 的记录,其中 observation 使用 JSON.stringify 序列化为 TEXT
|
||||
|
||||
#### Scenario: 查询检查结果
|
||||
- **WHEN** 系统查询 latest check 或历史 check_results
|
||||
- **THEN** 存储层 SHALL 返回 observation 字段而非 status_detail 字段,供 API 序列化层反序列化并构造 detail
|
||||
|
||||
#### Scenario: 写入结构化失败信息
|
||||
- **WHEN** checker 执行失败或 expect 不匹配
|
||||
@@ -75,11 +87,11 @@
|
||||
- **THEN** 系统 SHALL 使用索引快速定位,无需全表扫描
|
||||
|
||||
### Requirement: 目标列表按分组排序
|
||||
系统 SHALL 保证 targets 查询结果按分组排序返回。
|
||||
系统 SHALL 保证 targets 查询结果按分组排序返回,且仅返回活跃目标。
|
||||
|
||||
#### Scenario: 分组排序查询
|
||||
- **WHEN** 查询所有 targets
|
||||
- **THEN** 结果 SHALL 将 "default" 分组目标排在首位,其余分组按 YAML 配置中首次出现的顺序(即 id 自增顺序)排列
|
||||
- **THEN** 结果 SHALL 仅返回 active=1 的目标,将 "default" 分组目标排在首位,其余分组按 YAML 配置中首次出现的顺序(即 id 自增顺序)排列
|
||||
|
||||
### Requirement: 聚合查询支持
|
||||
数据存储 SHALL 支持按时间段获取指标计算所需数据,用于后端应用层计算可用率、平均耗时、延迟范围、趋势分桶和可靠性指标。
|
||||
@@ -101,30 +113,34 @@
|
||||
- **THEN** 系统 SHALL 基于 latestCheck.matched 判定目标 UP 或 DOWN:matched=true 为 UP,matched=false 为 DOWN
|
||||
|
||||
### Requirement: Dashboard 数据查询支持
|
||||
ProbeStore SHALL 提供 Dashboard 聚合响应所需的批量取数能力。
|
||||
ProbeStore SHALL 提供 Dashboard 聚合响应所需的批量取数能力,且所有查询 SHALL 仅涉及活跃 target。
|
||||
|
||||
#### Scenario: 批量获取最新检查
|
||||
- **WHEN** Dashboard API 需要计算当前 up/down 和 lastCheckTime
|
||||
- **THEN** Store SHALL 支持批量获取每个 target 的最新检查记录,避免 N+1 查询
|
||||
- **THEN** Store SHALL 支持批量获取每个活跃 target 的最新检查记录,避免 N+1 查询
|
||||
|
||||
#### Scenario: 批量获取窗口统计基础数据
|
||||
- **WHEN** Dashboard API 需要计算各 target 在指定 window 内的 totalChecks、upChecks、downChecks 和 availability
|
||||
- **THEN** Store SHALL 支持按 target_id 批量返回指定时间窗口内的基础计数数据
|
||||
- **THEN** Store SHALL 支持按 target_id 批量返回指定时间窗口内的基础计数数据,仅包含活跃 target
|
||||
|
||||
#### Scenario: 批量获取最近样本
|
||||
- **WHEN** Dashboard API 需要展示 recentSamples 和计算 capped currentStreak
|
||||
- **THEN** Store SHALL 支持批量获取每个 target 最近 recentLimit 条检查记录,按 target_id 分组且每组按 timestamp 降序排列
|
||||
- **THEN** Store SHALL 支持批量获取每个活跃 target 最近 recentLimit 条检查记录,按 target_id 分组且每组按 timestamp 降序排列
|
||||
|
||||
#### Scenario: 获取 Dashboard 异常事件序列
|
||||
- **WHEN** Dashboard API 需要计算 incidents
|
||||
- **THEN** Store SHALL 支持获取指定时间窗口内所有 target 的 `{ target_id, timestamp, matched }` 序列,按 target_id 和 timestamp 升序排列,供后端应用层计算状态翻转
|
||||
- **THEN** Store SHALL 支持获取指定时间窗口内所有活跃 target 的 `{ target_id, timestamp, matched }` 序列,按 target_id 和 timestamp 升序排列,供后端应用层计算状态翻转
|
||||
|
||||
### Requirement: 单目标指标取数支持
|
||||
ProbeStore SHALL 提供单目标 metrics 响应所需的取数能力。
|
||||
ProbeStore SHALL 提供单目标 metrics 响应所需的取数能力。仅活跃 target 的指标 SHALL 可查询。
|
||||
|
||||
#### Scenario: 获取目标检查点序列
|
||||
- **WHEN** Metrics API 需要计算趋势分桶、故障段、MTTR、最长故障、故障次数和连续状态
|
||||
- **THEN** Store SHALL 支持获取指定 target 在 from 到 to 时间范围内的 `{ timestamp, matched, duration_ms }` 数组,按 timestamp 升序排列
|
||||
- **THEN** Store SHALL 支持获取指定活跃 target 在 from 到 to 时间范围内的 `{ timestamp, matched, duration_ms }` 数组,按 timestamp 升序排列
|
||||
|
||||
#### Scenario: 目标不活跃
|
||||
- **WHEN** 查询 inactive target 的指标
|
||||
- **THEN** Store SHALL 返回 null(getTargetById 不匹配 active=1 的条件)
|
||||
|
||||
#### Scenario: 无检查记录
|
||||
- **WHEN** 时间窗口内无检查记录
|
||||
@@ -176,7 +192,7 @@ ProbeStore SHALL 提供 `getTargetDurations(targetId, from, to)` 方法,返回
|
||||
- **THEN** targets.config SHALL 存储 JSON,包含 exec、args、cwd、env、maxOutputBytes
|
||||
|
||||
### Requirement: 数据清理方法
|
||||
ProbeStore SHALL 提供 `prune(retentionMs: number)` 方法,删除超过保留时长的历史检查结果并返回删除行数。
|
||||
ProbeStore SHALL 提供 `prune(retentionMs: number)` 方法,删除超过保留时长的历史检查结果并返回删除行数,同时清理已无关联检查结果的非活跃目标行。
|
||||
|
||||
#### Scenario: 清理过期数据
|
||||
- **WHEN** 调用 `prune(604800000)`(7 天毫秒数)
|
||||
@@ -189,3 +205,15 @@ ProbeStore SHALL 提供 `prune(retentionMs: number)` 方法,删除超过保留
|
||||
#### Scenario: 清理不影响保留期内数据
|
||||
- **WHEN** 调用 `prune()` 且存在保留期内和保留期外的记录
|
||||
- **THEN** 系统 SHALL 仅删除保留期外的记录,保留期内的记录 SHALL 不受影响
|
||||
|
||||
#### Scenario: 清理空壳非活跃目标
|
||||
- **WHEN** prune 执行完毕后,存在 active=0 的 target 且该 target 在 check_results 表中无任何关联记录
|
||||
- **THEN** 系统 SHALL 删除该空壳 target 行
|
||||
|
||||
#### Scenario: 非活跃目标仍有历史数据时不清理
|
||||
- **WHEN** 存在 active=0 的 target 但该 target 在 check_results 表中仍有关联记录
|
||||
- **THEN** 系统 SHALL NOT 删除该 target 行
|
||||
|
||||
#### Scenario: 活跃目标永不清理
|
||||
- **WHEN** 存在 active=1 的 target 且该 target 在 check_results 表中无关联记录
|
||||
- **THEN** 系统 SHALL NOT 删除该 target 行
|
||||
|
||||
@@ -214,19 +214,19 @@ HTTP checker SHALL 将运行期失败归属到实际失败阶段。请求、网
|
||||
- **THEN** 系统 SHALL 记录 `failure.phase="body"`,且 SHALL NOT 将该失败记录为 request 错误
|
||||
|
||||
### Requirement: 拨测结果记录
|
||||
系统 SHALL 在每次 checker 完成后,将结果写入 SQLite 数据存储,包含 target_id、timestamp、matched、duration_ms、status_detail、failure 字段。
|
||||
系统 SHALL 在每次 checker 完成后,将结果写入 SQLite 数据存储,包含 target_id、timestamp、matched、duration_ms、observation、failure 字段。detail SHALL 为 API 层派生字段,不写入存储层;系统 SHALL NOT 写入 status_detail 字段。
|
||||
|
||||
#### Scenario: 成功检查结果记录
|
||||
- **WHEN** checker 成功执行且 expect 全部匹配
|
||||
- **THEN** 系统 SHALL 记录 matched=true、duration_ms、status_detail,failure 为 null
|
||||
- **THEN** 系统 SHALL 记录 matched=true、duration_ms、observation,failure 为 null
|
||||
|
||||
#### Scenario: 执行失败结果记录
|
||||
- **WHEN** checker 执行失败(网络错误、超时、命令启动失败、输出超限等)
|
||||
- **THEN** 系统 SHALL 记录 matched=false、failure.kind="error" 和具体错误信息
|
||||
- **THEN** 系统 SHALL 记录 matched=false、failure.kind="error" 和具体错误信息,并在可收集领域观测数据时记录 observation
|
||||
|
||||
#### Scenario: expect 不匹配结果记录
|
||||
- **WHEN** checker 执行成功但 expect 不匹配
|
||||
- **THEN** 系统 SHALL 记录 matched=false、failure.kind="mismatch" 和具体不匹配信息
|
||||
- **THEN** 系统 SHALL 记录 matched=false、observation、failure.kind="mismatch" 和具体不匹配信息
|
||||
|
||||
### Requirement: runner 选择
|
||||
系统 SHALL 根据 target.type 选择对应 runner 执行检查。
|
||||
@@ -240,7 +240,7 @@ HTTP checker SHALL 将运行期失败归属到实际失败阶段。请求、网
|
||||
- **THEN** 系统 SHALL 使用 cmd runner 执行该目标
|
||||
|
||||
### Requirement: 定期数据清理
|
||||
ProbeEngine SHALL 在启动时注册数据清理定时器,定期调用 ProbeStore.prune() 清理过期数据。
|
||||
ProbeEngine SHALL 在启动时注册数据清理定时器,定期调用 ProbeStore.prune() 清理过期数据和空壳非活跃目标。
|
||||
|
||||
#### Scenario: 引擎启动注册清理
|
||||
- **WHEN** ProbeEngine.start() 被调用且 retentionMs > 0
|
||||
|
||||
@@ -215,7 +215,7 @@ Drawer 内部 SHALL 使用 TDesign Tabs 组织概览和记录两个面板。Tabs
|
||||
|
||||
#### Scenario: 基本信息直接展示
|
||||
- **WHEN** 概览面板渲染
|
||||
- **THEN** 面板 SHALL 在"基本信息"区域直接使用 TDesign Descriptions 组件展示配置信息(不折叠)
|
||||
- **THEN** 面板 SHALL 在"基本信息"区域直接使用 TDesign Descriptions 组件展示配置信息(不折叠),Descriptions SHALL 配置 `tableLayout="auto"` 使 label 宽度自适应内容
|
||||
|
||||
#### Scenario: 基本信息内容
|
||||
- **WHEN** 概览面板渲染
|
||||
@@ -303,7 +303,7 @@ Drawer 顶部的时间范围快捷按钮和日期范围选择器 SHALL 在同一
|
||||
|
||||
#### Scenario: 检查结果表格
|
||||
- **WHEN** 记录面板渲染且数据可用
|
||||
- **THEN** 面板 SHALL 使用 TDesign PrimaryTable 展示检查结果,列包含:状态(StatusDot 圆点)、时间(YYYY-MM-DD HH:mm:ss 格式)、耗时(标题含 ms 单位,单元格仅显示数值,居中对齐)、详情(statusDetail 和 failure.message 用冒号拼接)
|
||||
- **THEN** 面板 SHALL 使用 TDesign PrimaryTable 展示检查结果,列包含:状态(StatusDot 圆点)、时间(YYYY-MM-DD HH:mm:ss 格式)、耗时(标题含 ms 单位,单元格仅显示数值,居中对齐)、详情(detail 和 failure.message 用冒号拼接)
|
||||
|
||||
#### Scenario: 服务端分页
|
||||
- **WHEN** 检查结果总数超过一页
|
||||
|
||||
@@ -72,7 +72,7 @@ Dashboard SHALL 按 group 字段将目标分组,每个分组使用 TDesign Car
|
||||
|
||||
#### Scenario: 延迟列
|
||||
- **WHEN** 表格渲染
|
||||
- **THEN** 延迟列标题 SHALL 展示为"延迟(ms)",单元格 SHALL 显示最近一次检查的延迟毫秒数值并右对齐,颜色通过 CSS 类控制;超过 9999ms 时 SHALL 显示为"9999+"
|
||||
- **THEN** 延迟列标题 SHALL 展示为"延迟",宽度 SHALL 为 80px,单元格 SHALL 显示最近一次检查的延迟数值并附加 " ms" 后缀(如 "156 ms"),右对齐,颜色通过 CSS 类控制;超过 9999ms 时 SHALL 显示为"9999+ ms"
|
||||
|
||||
#### Scenario: 间隔列移除
|
||||
- **WHEN** 表格渲染
|
||||
|
||||
@@ -36,27 +36,27 @@
|
||||
- **THEN** `target` 展示摘要 SHALL 为 `<host>:<port>`,`config` JSON SHALL 包含 resolved 后的 host、port、readBanner、bannerReadTimeout 和 maxBannerBytes
|
||||
|
||||
### Requirement: tcp checker 执行
|
||||
系统 SHALL 按 tcp target 配置建立 TCP 连接,记录完整执行耗时,并在连接失败、超时或资源超限时产生结构化失败信息。
|
||||
系统 SHALL 按 tcp target 配置建立 TCP 连接,记录完整执行耗时和 TCP observation,并在连接失败、超时或资源超限时产生结构化失败信息。
|
||||
|
||||
#### Scenario: TCP 连接成功
|
||||
- **WHEN** tcp target 指向可连接的 TCP 服务,且未配置 expect 或 `expect.connected` 为 `true`
|
||||
- **THEN** 系统 SHALL 记录 `matched=true`、`durationMs` 和 `statusDetail`,并关闭 socket
|
||||
- **THEN** 系统 SHALL 记录 `matched=true`、`durationMs` 和包含 connected、connectTimeMs、banner 的 observation,并关闭 socket
|
||||
|
||||
#### Scenario: TCP 连接失败
|
||||
- **WHEN** tcp target 指向不可连接的 host/port,且未配置 expect 或 `expect.connected` 为 `true`
|
||||
- **THEN** 系统 SHALL 记录 `matched=false`,failure 的 kind 为 `error`,phase 为 `connect`,message 包含可读连接失败原因
|
||||
- **THEN** 系统 SHALL 记录 `matched=false`,observation SHALL 包含 connected=false 和错误信息,failure 的 kind 为 `error`,phase 为 `connect`,message 包含可读连接失败原因
|
||||
|
||||
#### Scenario: 期望端口不可达且连接失败
|
||||
- **WHEN** tcp target 配置 `expect.connected: false`,且 TCP 连接失败
|
||||
- **THEN** 系统 SHALL 记录 `matched=true`,statusDetail SHALL 展示实际连接失败原因摘要
|
||||
- **THEN** 系统 SHALL 记录 `matched=true`,observation SHALL 包含 connected=false 和实际连接失败原因,API detail SHALL 展示实际连接失败原因摘要
|
||||
|
||||
#### Scenario: 期望端口不可达但连接成功
|
||||
- **WHEN** tcp target 配置 `expect.connected: false`,但 TCP 连接成功
|
||||
- **THEN** 系统 SHALL 记录 `matched=false`,failure 的 kind 为 `mismatch`,phase 为 `connected`
|
||||
- **THEN** 系统 SHALL 记录 `matched=false`,observation SHALL 包含 connected=true,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 包含超时信息
|
||||
- **THEN** 系统 SHALL best-effort 关闭 socket,记录 `matched=false`,failure 的 kind 为 `error`,phase 为 `connect` 或 `banner`,message 包含超时信息,并在可收集时记录 observation
|
||||
|
||||
#### Scenario: duration 包含 banner 读取
|
||||
- **WHEN** tcp target 开启 `readBanner` 且服务端延迟发送 banner
|
||||
@@ -81,9 +81,9 @@
|
||||
- **WHEN** 服务端发送的 banner 数据超过 `maxBannerBytes`
|
||||
- **THEN** 系统 SHALL 停止读取并记录 `matched=false`、failure.kind=`error`、failure.phase=`banner` 的结构化错误
|
||||
|
||||
#### Scenario: banner statusDetail 截断展示
|
||||
#### Scenario: banner detail 截断展示
|
||||
- **WHEN** tcp target 成功读取到较长 banner
|
||||
- **THEN** `statusDetail` SHALL 展示截断后的 banner 摘要,避免 UI 和历史记录写入过长文本
|
||||
- **THEN** observation.banner SHALL 保存截断后的 banner 摘要,API detail SHALL 展示截断后的 banner 摘要,避免 UI 展示过长文本
|
||||
|
||||
### Requirement: tcp expect 校验
|
||||
系统 SHALL 支持 tcp 专属 expect,包括 `connected`、`banner` 和 `durationMs`,并按 connected、banner、durationMs 的阶段顺序快速失败。`connected` SHALL 保持布尔状态语义,未配置时默认 `true`。`banner` MUST 使用共享 `ContentRules` 数组,并仅在 `tcp.readBanner: true` 时允许配置。`durationMs` SHALL 使用共享 `ValueMatcher` 校验包含连接和 banner 读取在内的完整执行耗时。
|
||||
|
||||
@@ -75,11 +75,11 @@
|
||||
- **THEN** 系统 SHALL 以配置错误退出,提示 udp.payload 与 udp.encoding 不匹配
|
||||
|
||||
### Requirement: udp checker 执行
|
||||
系统 SHALL 使用 Bun connected UDP socket 向目标发送单个 datagram,等待第一个 UDP 响应 datagram,记录完整执行耗时,并在发送失败、超时、资源超限或底层 socket 错误时产生结构化失败信息。
|
||||
系统 SHALL 使用 Bun connected UDP socket 向目标发送单个 datagram,等待第一个 UDP 响应 datagram,记录完整执行耗时和 UDP observation,并在发送失败、超时、资源超限或底层 socket 错误时产生结构化失败信息。
|
||||
|
||||
#### Scenario: UDP 请求响应成功
|
||||
- **WHEN** udp target 指向会返回 `PONG` 的 UDP 服务,且未配置 expect 或 `expect.responded` 为 `true`
|
||||
- **THEN** 系统 SHALL 记录 `matched=true`、`durationMs` 和包含响应大小的 `statusDetail`,并关闭 socket
|
||||
- **THEN** 系统 SHALL 记录 `matched=true`、`durationMs` 和包含响应大小的 observation,并关闭 socket
|
||||
|
||||
#### Scenario: 使用 hostname 执行 UDP 探测
|
||||
- **WHEN** udp target 的 `udp.host` 为可解析域名或 `localhost`
|
||||
@@ -91,19 +91,19 @@
|
||||
|
||||
#### Scenario: UDP 无响应且默认期望响应
|
||||
- **WHEN** udp target 指向在 timeout 内不返回 UDP datagram 的服务,且未配置 expect 或 `expect.responded` 为 `true`
|
||||
- **THEN** 系统 SHALL 在 `ctx.signal` abort 后记录 `matched=false`,failure 的 kind 为 `error`,phase 为 `response`,message 包含超时或未响应信息
|
||||
- **THEN** 系统 SHALL 在 `ctx.signal` abort 后记录 `matched=false`,observation SHALL 包含 responded=false 和 error,failure 的 kind 为 `error`,phase 为 `response`,message 包含超时或未响应信息
|
||||
|
||||
#### Scenario: 期望无响应且实际无响应
|
||||
- **WHEN** udp target 配置 `expect.responded: false`,且 timeout 内未收到 UDP datagram
|
||||
- **THEN** 系统 SHALL 记录 `matched=true`,statusDetail SHALL 表示未收到响应
|
||||
- **THEN** 系统 SHALL 记录 `matched=true`,observation SHALL 包含 responded=false,API detail SHALL 表示未收到响应
|
||||
|
||||
#### Scenario: 期望无响应但实际收到响应
|
||||
- **WHEN** udp target 配置 `expect.responded: false`,但收到了 UDP datagram
|
||||
- **THEN** 系统 SHALL 记录 `matched=false`,failure 的 kind 为 `mismatch`,phase 为 `responded`
|
||||
- **THEN** 系统 SHALL 记录 `matched=false`,observation SHALL 包含 responded=true 和响应摘要,failure 的 kind 为 `mismatch`,phase 为 `responded`
|
||||
|
||||
#### Scenario: UDP socket 底层错误
|
||||
- **WHEN** Bun UDP socket 在发送或接收过程中触发 error 事件
|
||||
- **THEN** 系统 SHALL best-effort 关闭 socket,并记录 `matched=false`、failure.kind=`error` 和可读错误信息
|
||||
- **THEN** 系统 SHALL best-effort 关闭 socket,并记录 `matched=false`、failure.kind=`error` 和可读错误信息,并在可收集时记录 observation
|
||||
|
||||
#### Scenario: ICMP unreachable 不作为 UDP 响应
|
||||
- **WHEN** 底层系统因目标端口不可达产生 ICMP unreachable
|
||||
@@ -187,17 +187,17 @@
|
||||
- **WHEN** YAML 中 udp target 的 expect 包含 `status: [200]`、`maxDurationMs: 1000` 或其他非 udp expect 字段
|
||||
- **THEN** 系统 SHALL 以配置错误退出,提示 expect 包含未知字段
|
||||
|
||||
### Requirement: udp statusDetail 摘要
|
||||
系统 SHALL 在 udp 执行后生成简短 statusDetail 摘要,展示关键结果并避免写入过长响应内容。
|
||||
### Requirement: udp detail 摘要
|
||||
系统 SHALL 在 udp API 序列化时从 observation 动态生成简短 detail 摘要,展示关键结果和执行耗时并避免返回过长响应内容。UDP observation SHALL 包含 durationMs 以支持 detail 构造。
|
||||
|
||||
#### Scenario: 收到响应的摘要
|
||||
- **WHEN** udp target 收到 4 字节响应且完整执行耗时为 12ms
|
||||
- **THEN** statusDetail SHALL 包含 `responded in 12ms` 和 `4 bytes`
|
||||
- **THEN** detail SHALL 包含 `responded in 12ms` 和 `4 bytes`
|
||||
|
||||
#### Scenario: 未收到响应的摘要
|
||||
- **WHEN** udp target 配置 `expect.responded: false` 且 timeout 内未收到 UDP datagram
|
||||
- **THEN** statusDetail SHALL 包含 `no response` 和执行耗时
|
||||
- **THEN** detail SHALL 包含 `no response` 和执行耗时
|
||||
|
||||
#### Scenario: 响应内容摘要截断
|
||||
- **WHEN** udp target 收到较长响应内容
|
||||
- **THEN** statusDetail SHALL 只展示按 `responseEncoding` 转换并截断后的响应摘要
|
||||
- **THEN** detail SHALL 只展示按 `responseEncoding` 转换并截断后的响应摘要
|
||||
|
||||
@@ -52,3 +52,7 @@ probes.example.yaml 中的 cmd 类型示例 SHALL 使用跨平台命令(如 `b
|
||||
#### Scenario: 示例命令跨平台可执行
|
||||
- **WHEN** 用户在 Windows、macOS 或 Linux 上直接使用 probes.example.yaml 中的 cmd 示例
|
||||
- **THEN** 所有 cmd 示例 SHALL 能正常执行,不依赖平台特定命令
|
||||
|
||||
#### Scenario: ICMP checker 测试使用 platform 注入
|
||||
- **WHEN** 在 Windows 上运行 ICMP checker 测试,mock 的 stdout 为 Unix 格式
|
||||
- **THEN** 测试 SHALL 通过 `new IcmpChecker("linux")` 构造 checker 实例,使 parsePingOutput 使用 Unix 解析器,确保测试在所有平台上通过
|
||||
|
||||
@@ -84,7 +84,7 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"ping": {
|
||||
"icmp": {
|
||||
"additionalProperties": false,
|
||||
"type": "object",
|
||||
"properties": {}
|
||||
@@ -2479,7 +2479,7 @@
|
||||
"required": [
|
||||
"id",
|
||||
"type",
|
||||
"ping"
|
||||
"icmp"
|
||||
],
|
||||
"properties": {
|
||||
"description": {
|
||||
@@ -2829,10 +2829,10 @@
|
||||
"type": "string"
|
||||
},
|
||||
"type": {
|
||||
"const": "ping",
|
||||
"const": "icmp",
|
||||
"type": "string"
|
||||
},
|
||||
"ping": {
|
||||
"icmp": {
|
||||
"additionalProperties": false,
|
||||
"type": "object",
|
||||
"required": [
|
||||
|
||||
@@ -178,13 +178,13 @@ targets:
|
||||
banner:
|
||||
- contains: "ESMTP"
|
||||
|
||||
# ========== Ping targets ==========
|
||||
# ========== ICMP targets ==========
|
||||
|
||||
- id: "gateway-ping"
|
||||
- id: "gateway-icmp"
|
||||
name: "网关 ICMP 可达"
|
||||
type: ping
|
||||
type: icmp
|
||||
group: "基础设施"
|
||||
ping:
|
||||
icmp:
|
||||
host: "127.0.0.1"
|
||||
count: 3
|
||||
packetSize: 56
|
||||
|
||||
@@ -73,10 +73,11 @@ export class ProbeEngine {
|
||||
console.warn("探针执行失败:", result.reason);
|
||||
if (!target) continue;
|
||||
this.writeResult({
|
||||
detail: null,
|
||||
durationMs: null,
|
||||
failure: errorFailure("internal", "engine", formatReason(result.reason)),
|
||||
matched: false,
|
||||
statusDetail: null,
|
||||
observation: null,
|
||||
targetId: target.id,
|
||||
timestamp: new Date().toISOString(),
|
||||
});
|
||||
@@ -110,7 +111,7 @@ export class ProbeEngine {
|
||||
durationMs: result.durationMs,
|
||||
failure: result.failure,
|
||||
matched: result.matched,
|
||||
statusDetail: result.statusDetail,
|
||||
observation: result.observation ?? null,
|
||||
targetId: result.targetId,
|
||||
timestamp: result.timestamp,
|
||||
});
|
||||
|
||||
@@ -13,6 +13,9 @@ import { checkExitCode } from "./expect";
|
||||
import { commandCheckerSchemas } from "./schema";
|
||||
import { validateCommandConfig } from "./validate";
|
||||
|
||||
const STDOUT_PREVIEW_MAX = 1024;
|
||||
const STDERR_PREVIEW_MAX = 1024;
|
||||
|
||||
export class CommandChecker implements CheckerDefinition<ResolvedCommandTarget> {
|
||||
readonly configKey = "cmd";
|
||||
|
||||
@@ -20,6 +23,11 @@ export class CommandChecker implements CheckerDefinition<ResolvedCommandTarget>
|
||||
|
||||
readonly type = "cmd";
|
||||
|
||||
buildDetail(observation: Record<string, unknown>): null | string {
|
||||
const exitCode = observation["exitCode"];
|
||||
return typeof exitCode === "number" ? `exitCode=${exitCode}` : null;
|
||||
}
|
||||
|
||||
async execute(t: ResolvedCommandTarget, ctx: CheckerContext): Promise<CheckResult> {
|
||||
const timestamp = new Date().toISOString();
|
||||
const start = performance.now();
|
||||
@@ -37,10 +45,11 @@ export class CommandChecker implements CheckerDefinition<ResolvedCommandTarget>
|
||||
} catch (error) {
|
||||
const durationMs = Math.round(performance.now() - start);
|
||||
return {
|
||||
detail: null,
|
||||
durationMs,
|
||||
failure: errorFailure("exitCode", "spawn", isError(error) ? error.message : String(error)),
|
||||
matched: false,
|
||||
statusDetail: null,
|
||||
observation: null,
|
||||
targetId: t.id,
|
||||
timestamp,
|
||||
};
|
||||
@@ -70,10 +79,11 @@ export class CommandChecker implements CheckerDefinition<ResolvedCommandTarget>
|
||||
} catch {
|
||||
const durationMs = Math.round(performance.now() - start);
|
||||
return {
|
||||
detail: null,
|
||||
durationMs,
|
||||
failure: errorFailure("exitCode", "execution", "输出读取失败"),
|
||||
matched: false,
|
||||
statusDetail: null,
|
||||
observation: null,
|
||||
targetId: t.id,
|
||||
timestamp,
|
||||
};
|
||||
@@ -83,24 +93,33 @@ export class CommandChecker implements CheckerDefinition<ResolvedCommandTarget>
|
||||
|
||||
const durationMs = Math.round(performance.now() - start);
|
||||
const exitCode = proc.exitCode ?? 1;
|
||||
const stdoutPreview = truncatePreview(outputResult.stdout, STDOUT_PREVIEW_MAX);
|
||||
const stderrPreview = truncatePreview(outputResult.stderr, STDERR_PREVIEW_MAX);
|
||||
const observation: Record<string, unknown> = { error: null, exitCode, stderrPreview, stdoutPreview };
|
||||
|
||||
if (outputResult.exceeded) {
|
||||
const message = `输出超过限制 ${t.cmd.maxOutputBytes} 字节`;
|
||||
observation["error"] = message;
|
||||
return {
|
||||
detail: null,
|
||||
durationMs,
|
||||
failure: errorFailure("exitCode", "output", `输出超过限制 ${t.cmd.maxOutputBytes} 字节`),
|
||||
failure: errorFailure("exitCode", "output", message),
|
||||
matched: false,
|
||||
statusDetail: `exitCode=${exitCode}`,
|
||||
observation,
|
||||
targetId: t.id,
|
||||
timestamp,
|
||||
};
|
||||
}
|
||||
|
||||
if (ctx.signal.aborted) {
|
||||
const message = `命令执行超时 (${t.timeoutMs}ms)`;
|
||||
observation["error"] = message;
|
||||
return {
|
||||
detail: null,
|
||||
durationMs,
|
||||
failure: errorFailure("exitCode", "timeout", `命令执行超时 (${t.timeoutMs}ms)`),
|
||||
failure: errorFailure("exitCode", "timeout", message),
|
||||
matched: false,
|
||||
statusDetail: null,
|
||||
observation,
|
||||
targetId: t.id,
|
||||
timestamp,
|
||||
};
|
||||
@@ -109,10 +128,11 @@ export class CommandChecker implements CheckerDefinition<ResolvedCommandTarget>
|
||||
const exitCodeResult = checkExitCode(exitCode, t.expect?.exitCode ?? [0]);
|
||||
if (!exitCodeResult.matched) {
|
||||
return {
|
||||
detail: null,
|
||||
durationMs,
|
||||
failure: exitCodeResult.failure,
|
||||
matched: false,
|
||||
statusDetail: `exitCode=${exitCode}`,
|
||||
observation,
|
||||
targetId: t.id,
|
||||
timestamp,
|
||||
};
|
||||
@@ -125,10 +145,11 @@ export class CommandChecker implements CheckerDefinition<ResolvedCommandTarget>
|
||||
});
|
||||
if (!durationResult.matched) {
|
||||
return {
|
||||
detail: null,
|
||||
durationMs,
|
||||
failure: durationResult.failure,
|
||||
matched: false,
|
||||
statusDetail: `exitCode=${exitCode}`,
|
||||
observation,
|
||||
targetId: t.id,
|
||||
timestamp,
|
||||
};
|
||||
@@ -138,10 +159,11 @@ export class CommandChecker implements CheckerDefinition<ResolvedCommandTarget>
|
||||
const stdoutResult = checkContentRules(outputResult.stdout, t.expect.stdout, { path: "stdout", phase: "stdout" });
|
||||
if (!stdoutResult.matched) {
|
||||
return {
|
||||
detail: null,
|
||||
durationMs,
|
||||
failure: stdoutResult.failure,
|
||||
matched: false,
|
||||
statusDetail: `exitCode=${exitCode}`,
|
||||
observation,
|
||||
targetId: t.id,
|
||||
timestamp,
|
||||
};
|
||||
@@ -152,10 +174,11 @@ export class CommandChecker implements CheckerDefinition<ResolvedCommandTarget>
|
||||
const stderrResult = checkContentRules(outputResult.stderr, t.expect.stderr, { path: "stderr", phase: "stderr" });
|
||||
if (!stderrResult.matched) {
|
||||
return {
|
||||
detail: null,
|
||||
durationMs,
|
||||
failure: stderrResult.failure,
|
||||
matched: false,
|
||||
statusDetail: `exitCode=${exitCode}`,
|
||||
observation,
|
||||
targetId: t.id,
|
||||
timestamp,
|
||||
};
|
||||
@@ -163,10 +186,11 @@ export class CommandChecker implements CheckerDefinition<ResolvedCommandTarget>
|
||||
}
|
||||
|
||||
return {
|
||||
detail: null,
|
||||
durationMs,
|
||||
failure: null,
|
||||
matched: true,
|
||||
statusDetail: `exitCode=${exitCode}`,
|
||||
observation,
|
||||
targetId: t.id,
|
||||
timestamp,
|
||||
};
|
||||
@@ -269,3 +293,8 @@ async function readOutput(
|
||||
|
||||
return { exceeded, stderr: err, stdout: out };
|
||||
}
|
||||
|
||||
function truncatePreview(text: string, maxLen: number): string {
|
||||
if (text.length <= maxLen) return text;
|
||||
return text.slice(0, maxLen);
|
||||
}
|
||||
|
||||
@@ -13,6 +13,7 @@ import { dbCheckerSchemas } from "./schema";
|
||||
import { validateDbConfig } from "./validate";
|
||||
|
||||
const PROBE_QUERY = "SELECT 1";
|
||||
const ROWS_PREVIEW_MAX = 5;
|
||||
|
||||
export class DbChecker implements CheckerDefinition<ResolvedDbTarget> {
|
||||
readonly configKey = "db";
|
||||
@@ -21,16 +22,27 @@ export class DbChecker implements CheckerDefinition<ResolvedDbTarget> {
|
||||
|
||||
readonly type = "db";
|
||||
|
||||
buildDetail(observation: Record<string, unknown>): null | string {
|
||||
const connected = observation["connected"];
|
||||
if (connected !== true) {
|
||||
const error = observation["error"];
|
||||
return typeof error === "string" ? `connection failed: ${error}` : "not connected";
|
||||
}
|
||||
const rowCount = observation["rowCount"];
|
||||
if (typeof rowCount === "number") {
|
||||
return `${rowCount} rows`;
|
||||
}
|
||||
return "connected";
|
||||
}
|
||||
|
||||
async execute(t: ResolvedDbTarget, ctx: CheckerContext): Promise<CheckResult> {
|
||||
const timestamp = new Date().toISOString();
|
||||
const start = performance.now();
|
||||
let db: SQL | undefined;
|
||||
|
||||
try {
|
||||
// 创建连接(SQLite 不需要 max 选项)
|
||||
db = new SQL(t.db.url);
|
||||
|
||||
// 监听 abort signal
|
||||
ctx.signal.addEventListener(
|
||||
"abort",
|
||||
() => {
|
||||
@@ -41,24 +53,30 @@ export class DbChecker implements CheckerDefinition<ResolvedDbTarget> {
|
||||
{ once: true },
|
||||
);
|
||||
|
||||
// 连接测试(Bun SQL 是 lazy 的,首次查询才真正连接)
|
||||
try {
|
||||
await db.unsafe(PROBE_QUERY);
|
||||
} catch (error) {
|
||||
const durationMs = Math.round(performance.now() - start);
|
||||
const errorMsg = isError(error) ? error.message : String(error);
|
||||
return {
|
||||
detail: null,
|
||||
durationMs,
|
||||
failure: errorFailure("connect", "connect", isError(error) ? error.message : String(error)),
|
||||
failure: errorFailure("connect", "connect", errorMsg),
|
||||
matched: false,
|
||||
statusDetail: null,
|
||||
observation: { connected: false, error: errorMsg, rowCount: null, rowsPreview: null },
|
||||
targetId: t.id,
|
||||
timestamp,
|
||||
};
|
||||
}
|
||||
|
||||
// 无 query 时仅测试连接
|
||||
if (!t.db.query) {
|
||||
const durationMs = Math.round(performance.now() - start);
|
||||
const observation: Record<string, unknown> = {
|
||||
connected: true,
|
||||
error: null,
|
||||
rowCount: null,
|
||||
rowsPreview: null,
|
||||
};
|
||||
const durationResult = checkValueMatcher(durationMs, t.expect?.durationMs, {
|
||||
message: "durationMs mismatch",
|
||||
path: "durationMs",
|
||||
@@ -66,55 +84,60 @@ export class DbChecker implements CheckerDefinition<ResolvedDbTarget> {
|
||||
});
|
||||
if (!durationResult.matched) {
|
||||
return {
|
||||
detail: null,
|
||||
durationMs,
|
||||
failure: durationResult.failure,
|
||||
matched: false,
|
||||
statusDetail: "connected",
|
||||
observation,
|
||||
targetId: t.id,
|
||||
timestamp,
|
||||
};
|
||||
}
|
||||
return {
|
||||
detail: null,
|
||||
durationMs,
|
||||
failure: null,
|
||||
matched: true,
|
||||
statusDetail: "connected",
|
||||
observation,
|
||||
targetId: t.id,
|
||||
timestamp,
|
||||
};
|
||||
}
|
||||
|
||||
// 执行用户 SQL
|
||||
let rows: unknown[];
|
||||
try {
|
||||
rows = await db.unsafe(t.db.query);
|
||||
} catch (error) {
|
||||
const durationMs = Math.round(performance.now() - start);
|
||||
const errorMsg = isError(error) ? error.message : String(error);
|
||||
return {
|
||||
detail: null,
|
||||
durationMs,
|
||||
failure: errorFailure("query", "query", isError(error) ? error.message : String(error)),
|
||||
failure: errorFailure("query", "query", errorMsg),
|
||||
matched: false,
|
||||
statusDetail: null,
|
||||
observation: { connected: true, error: errorMsg, rowCount: null, rowsPreview: null },
|
||||
targetId: t.id,
|
||||
timestamp,
|
||||
};
|
||||
}
|
||||
|
||||
const durationMs = Math.round(performance.now() - start);
|
||||
const rowCount = Array.isArray(rows) ? rows.length : 0;
|
||||
const rowsPreview = Array.isArray(rows) ? rows.slice(0, ROWS_PREVIEW_MAX) : null;
|
||||
const observation: Record<string, unknown> = { connected: true, error: null, rowCount, rowsPreview };
|
||||
|
||||
// 检查是否超时
|
||||
if (ctx.signal.aborted) {
|
||||
return {
|
||||
detail: null,
|
||||
durationMs,
|
||||
failure: errorFailure("query", "timeout", `查询超时 (${t.timeoutMs}ms)`),
|
||||
matched: false,
|
||||
statusDetail: null,
|
||||
observation,
|
||||
targetId: t.id,
|
||||
timestamp,
|
||||
};
|
||||
}
|
||||
|
||||
// duration 断言
|
||||
const durationResult = checkValueMatcher(durationMs, t.expect?.durationMs, {
|
||||
message: "durationMs mismatch",
|
||||
path: "durationMs",
|
||||
@@ -122,39 +145,40 @@ export class DbChecker implements CheckerDefinition<ResolvedDbTarget> {
|
||||
});
|
||||
if (!durationResult.matched) {
|
||||
return {
|
||||
detail: null,
|
||||
durationMs,
|
||||
failure: durationResult.failure,
|
||||
matched: false,
|
||||
statusDetail: `${Array.isArray(rows) ? rows.length : 0} rows`,
|
||||
observation,
|
||||
targetId: t.id,
|
||||
timestamp,
|
||||
};
|
||||
}
|
||||
|
||||
// rowCount 断言
|
||||
if (t.expect?.rowCount) {
|
||||
const rowCountResult = checkRowCount(Array.isArray(rows) ? rows.length : 0, t.expect.rowCount);
|
||||
const rowCountResult = checkRowCount(rowCount, t.expect.rowCount);
|
||||
if (!rowCountResult.matched) {
|
||||
return {
|
||||
detail: null,
|
||||
durationMs,
|
||||
failure: rowCountResult.failure,
|
||||
matched: false,
|
||||
statusDetail: `${Array.isArray(rows) ? rows.length : 0} rows`,
|
||||
observation,
|
||||
targetId: t.id,
|
||||
timestamp,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
// rows 断言
|
||||
if (t.expect?.rows && t.expect.rows.length > 0) {
|
||||
const rowsResult = checkRows(rows, t.expect.rows);
|
||||
if (!rowsResult.matched) {
|
||||
return {
|
||||
detail: null,
|
||||
durationMs,
|
||||
failure: rowsResult.failure,
|
||||
matched: false,
|
||||
statusDetail: `${Array.isArray(rows) ? rows.length : 0} rows`,
|
||||
observation,
|
||||
targetId: t.id,
|
||||
timestamp,
|
||||
};
|
||||
@@ -162,14 +186,14 @@ export class DbChecker implements CheckerDefinition<ResolvedDbTarget> {
|
||||
}
|
||||
|
||||
if (t.expect?.result && t.expect.result.length > 0) {
|
||||
const rowCount = Array.isArray(rows) ? rows.length : 0;
|
||||
const resultCheck = checkContentRules({ rowCount, rows }, t.expect.result, { path: "result", phase: "result" });
|
||||
if (!resultCheck.matched) {
|
||||
return {
|
||||
detail: null,
|
||||
durationMs,
|
||||
failure: resultCheck.failure,
|
||||
matched: false,
|
||||
statusDetail: `${rowCount} rows`,
|
||||
observation,
|
||||
targetId: t.id,
|
||||
timestamp,
|
||||
};
|
||||
@@ -177,10 +201,11 @@ export class DbChecker implements CheckerDefinition<ResolvedDbTarget> {
|
||||
}
|
||||
|
||||
return {
|
||||
detail: null,
|
||||
durationMs,
|
||||
failure: null,
|
||||
matched: true,
|
||||
statusDetail: `${Array.isArray(rows) ? rows.length : 0} rows`,
|
||||
observation,
|
||||
targetId: t.id,
|
||||
timestamp,
|
||||
};
|
||||
|
||||
@@ -13,6 +13,7 @@ import { httpCheckerSchemas } from "./schema";
|
||||
import { validateHttpConfig } from "./validate";
|
||||
|
||||
const CHARSET_RE = /charset="?([^";\s]+)"?/i;
|
||||
const BODY_PREVIEW_BYTES = 1024;
|
||||
const REDIRECT_STATUSES = new Set([301, 302, 303, 307, 308]);
|
||||
const SENSITIVE_HEADERS = new Set(["authorization", "cookie"]);
|
||||
|
||||
@@ -23,6 +24,11 @@ export class HttpChecker implements CheckerDefinition<ResolvedHttpTarget> {
|
||||
|
||||
readonly type = "http";
|
||||
|
||||
buildDetail(observation: Record<string, unknown>): null | string {
|
||||
const statusCode = observation["statusCode"];
|
||||
return typeof statusCode === "number" ? `HTTP ${statusCode}` : null;
|
||||
}
|
||||
|
||||
async execute(t: ResolvedHttpTarget, ctx: CheckerContext): Promise<CheckResult> {
|
||||
const timestamp = new Date().toISOString();
|
||||
const expect = t.expect;
|
||||
@@ -39,39 +45,102 @@ export class HttpChecker implements CheckerDefinition<ResolvedHttpTarget> {
|
||||
});
|
||||
|
||||
const statusCode = response.status;
|
||||
const responseHeaders = Object.fromEntries(response.headers);
|
||||
const responseHeaders = truncateHeaders(Object.fromEntries(response.headers));
|
||||
const hasBodyRules = !!(expect?.body && expect.body.length > 0);
|
||||
const bodyReadResult = await readBodyStream(
|
||||
response,
|
||||
hasBodyRules ? t.http.maxBodyBytes : BODY_PREVIEW_BYTES,
|
||||
!hasBodyRules,
|
||||
);
|
||||
let bodyPreview: null | string = null;
|
||||
let bodyText: null | string = null;
|
||||
let bodyDecodeFailure: CheckResult["failure"] = null;
|
||||
|
||||
if (bodyReadResult.data.byteLength > 0) {
|
||||
const decodeResult = decodeBody(bodyReadResult.data, response.headers);
|
||||
if (decodeResult.ok) {
|
||||
bodyText = decodeResult.text;
|
||||
bodyPreview = truncateBodyPreview(decodeResult.text);
|
||||
} else {
|
||||
bodyDecodeFailure = decodeResult.failure;
|
||||
}
|
||||
}
|
||||
|
||||
const statusResult = checkStatus(statusCode, expect?.status ?? [200]);
|
||||
if (!statusResult.matched) {
|
||||
return makeResult(t, timestamp, performance.now() - start, statusResult.failure, statusCode);
|
||||
return makeResult(
|
||||
t,
|
||||
timestamp,
|
||||
performance.now() - start,
|
||||
statusResult.failure,
|
||||
response,
|
||||
responseHeaders,
|
||||
bodyPreview,
|
||||
);
|
||||
}
|
||||
|
||||
const headersResult = checkHeaders(responseHeaders, expect?.headers);
|
||||
const headersResult = checkHeaders(Object.fromEntries(response.headers), expect?.headers);
|
||||
if (!headersResult.matched) {
|
||||
return makeResult(t, timestamp, performance.now() - start, headersResult.failure, statusCode);
|
||||
return makeResult(
|
||||
t,
|
||||
timestamp,
|
||||
performance.now() - start,
|
||||
headersResult.failure,
|
||||
response,
|
||||
responseHeaders,
|
||||
bodyPreview,
|
||||
);
|
||||
}
|
||||
|
||||
const hasBodyRules = !!(expect?.body && expect.body.length > 0);
|
||||
|
||||
const earlyTimeout = hasBodyRules ? checkEarlyTimeout(start, expect?.durationMs) : null;
|
||||
if (earlyTimeout) {
|
||||
return makeResult(t, timestamp, earlyTimeout.elapsed, earlyTimeout.failure, statusCode);
|
||||
return makeResult(
|
||||
t,
|
||||
timestamp,
|
||||
earlyTimeout.elapsed,
|
||||
earlyTimeout.failure,
|
||||
response,
|
||||
responseHeaders,
|
||||
bodyPreview,
|
||||
);
|
||||
}
|
||||
|
||||
if (!bodyReadResult.ok) {
|
||||
return makeResult(
|
||||
t,
|
||||
timestamp,
|
||||
performance.now() - start,
|
||||
bodyReadResult.failure,
|
||||
response,
|
||||
responseHeaders,
|
||||
bodyPreview,
|
||||
);
|
||||
}
|
||||
|
||||
if (bodyDecodeFailure) {
|
||||
return makeResult(
|
||||
t,
|
||||
timestamp,
|
||||
performance.now() - start,
|
||||
bodyDecodeFailure,
|
||||
response,
|
||||
responseHeaders,
|
||||
bodyPreview,
|
||||
);
|
||||
}
|
||||
|
||||
if (hasBodyRules) {
|
||||
const bodyReadResult = await readBodyStream(response, t.http.maxBodyBytes);
|
||||
if (!bodyReadResult.ok) {
|
||||
return makeResult(t, timestamp, performance.now() - start, bodyReadResult.failure, statusCode);
|
||||
}
|
||||
|
||||
const decodeResult = decodeBody(bodyReadResult.data, response.headers);
|
||||
if (!decodeResult.ok) {
|
||||
return makeResult(t, timestamp, performance.now() - start, decodeResult.failure, statusCode);
|
||||
}
|
||||
|
||||
const bodyResult = checkContentRules(decodeResult.text, expect.body, { path: "body", phase: "body" });
|
||||
const bodyResult = checkContentRules(bodyText ?? "", expect.body, { path: "body", phase: "body" });
|
||||
if (!bodyResult.matched) {
|
||||
return makeResult(t, timestamp, performance.now() - start, bodyResult.failure, statusCode);
|
||||
return makeResult(
|
||||
t,
|
||||
timestamp,
|
||||
performance.now() - start,
|
||||
bodyResult.failure,
|
||||
response,
|
||||
responseHeaders,
|
||||
bodyPreview,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -82,15 +151,16 @@ export class HttpChecker implements CheckerDefinition<ResolvedHttpTarget> {
|
||||
phase: "duration",
|
||||
});
|
||||
if (!durationResult.matched) {
|
||||
return makeResult(t, timestamp, durationMs, durationResult.failure, statusCode);
|
||||
return makeResult(t, timestamp, durationMs, durationResult.failure, response, responseHeaders, bodyPreview);
|
||||
}
|
||||
|
||||
return makeResult(t, timestamp, durationMs, null, statusCode);
|
||||
return makeResult(t, timestamp, durationMs, null, response, responseHeaders, bodyPreview);
|
||||
} catch (error) {
|
||||
const durationMs = Math.round(performance.now() - start);
|
||||
const isTimeout = ctx.signal.aborted || (error instanceof DOMException && error.name === "AbortError");
|
||||
|
||||
return {
|
||||
detail: null,
|
||||
durationMs,
|
||||
failure: errorFailure(
|
||||
"request",
|
||||
@@ -98,7 +168,7 @@ export class HttpChecker implements CheckerDefinition<ResolvedHttpTarget> {
|
||||
isTimeout ? `请求超时 (${t.timeoutMs}ms)` : isError(error) ? error.message : String(error),
|
||||
),
|
||||
matched: false,
|
||||
statusDetail: null,
|
||||
observation: null,
|
||||
targetId: t.id,
|
||||
timestamp,
|
||||
};
|
||||
@@ -155,6 +225,17 @@ export class HttpChecker implements CheckerDefinition<ResolvedHttpTarget> {
|
||||
}
|
||||
}
|
||||
|
||||
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 buildRedirectInit(init: RequestInit, statusCode: number, fromUrl: string, toUrl: string): RequestInit {
|
||||
let newInit = { ...init };
|
||||
const method = init.method?.toUpperCase();
|
||||
@@ -269,13 +350,28 @@ function makeResult(
|
||||
timestamp: string,
|
||||
elapsed: number,
|
||||
failure: CheckResult["failure"],
|
||||
statusCode: number,
|
||||
response: Response,
|
||||
headers: Record<string, string>,
|
||||
bodyPreview: null | string = null,
|
||||
): CheckResult {
|
||||
const contentType = response.headers.get("content-type");
|
||||
const contentLengthHeader = response.headers.get("content-length");
|
||||
const contentLength = contentLengthHeader ? Number(contentLengthHeader) : null;
|
||||
|
||||
const observation: Record<string, unknown> = {
|
||||
bodyPreview,
|
||||
contentLength: Number.isFinite(contentLength) ? contentLength : null,
|
||||
contentType,
|
||||
headers,
|
||||
statusCode: response.status,
|
||||
};
|
||||
|
||||
return {
|
||||
detail: null,
|
||||
durationMs: Math.round(elapsed),
|
||||
failure,
|
||||
matched: failure === null,
|
||||
statusDetail: `HTTP ${statusCode}`,
|
||||
observation,
|
||||
targetId: t.id,
|
||||
timestamp,
|
||||
};
|
||||
@@ -284,7 +380,8 @@ function makeResult(
|
||||
async function readBodyStream(
|
||||
response: Response,
|
||||
maxBodyBytes: number,
|
||||
): Promise<{ data: Uint8Array; ok: true } | { failure: CheckResult["failure"]; ok: false }> {
|
||||
truncateOnLimit = false,
|
||||
): Promise<{ data: Uint8Array; failure: CheckResult["failure"]; ok: false } | { data: Uint8Array; ok: true }> {
|
||||
const reader = response.body?.getReader();
|
||||
if (!reader) {
|
||||
return { data: new Uint8Array(0), ok: true };
|
||||
@@ -300,12 +397,19 @@ async function readBodyStream(
|
||||
|
||||
totalBytes += value.byteLength;
|
||||
if (totalBytes > maxBodyBytes) {
|
||||
const allowedBytes = value.byteLength - (totalBytes - maxBodyBytes);
|
||||
if (truncateOnLimit && allowedBytes > 0) {
|
||||
chunks.push(value.slice(0, allowedBytes));
|
||||
}
|
||||
try {
|
||||
await reader.cancel();
|
||||
} catch {
|
||||
/* ignore cancel error */
|
||||
}
|
||||
const data = assembleChunks(chunks, Math.min(totalBytes, maxBodyBytes));
|
||||
if (truncateOnLimit) return { data, ok: true };
|
||||
return {
|
||||
data,
|
||||
failure: errorFailure("body", "body", `响应体大小超过限制 ${maxBodyBytes}`),
|
||||
ok: false,
|
||||
};
|
||||
@@ -317,12 +421,16 @@ async function readBodyStream(
|
||||
reader.releaseLock();
|
||||
}
|
||||
|
||||
const result = new Uint8Array(totalBytes);
|
||||
let offset = 0;
|
||||
for (const chunk of chunks) {
|
||||
result.set(chunk, offset);
|
||||
offset += chunk.byteLength;
|
||||
}
|
||||
|
||||
return { data: result, ok: true };
|
||||
return { data: assembleChunks(chunks, totalBytes), ok: true };
|
||||
}
|
||||
|
||||
function truncateBodyPreview(text: string, maxLen = 1024): string {
|
||||
if (text.length <= maxLen) return text;
|
||||
return text.slice(0, maxLen);
|
||||
}
|
||||
|
||||
function truncateHeaders(headers: Record<string, string>, maxCount = 20): Record<string, string> {
|
||||
const entries = Object.entries(headers);
|
||||
if (entries.length <= maxCount) return headers;
|
||||
return Object.fromEntries(entries.slice(0, maxCount));
|
||||
}
|
||||
|
||||
@@ -5,14 +5,14 @@ export function buildPingCommand(t: ResolvedPingTarget, platform: NodeJS.Platfor
|
||||
return [
|
||||
"ping",
|
||||
"-n",
|
||||
String(t.ping.count),
|
||||
String(t.icmp.count),
|
||||
"-l",
|
||||
String(t.ping.packetSize),
|
||||
String(t.icmp.packetSize),
|
||||
"-w",
|
||||
String(t.timeoutMs),
|
||||
t.ping.host,
|
||||
t.icmp.host,
|
||||
];
|
||||
}
|
||||
const timeout = platform === "linux" ? String(Math.ceil(t.timeoutMs / 1000)) : String(t.timeoutMs);
|
||||
return ["ping", "-c", String(t.ping.count), "-s", String(t.ping.packetSize), "-W", timeout, t.ping.host];
|
||||
return ["ping", "-c", String(t.icmp.count), "-s", String(t.icmp.packetSize), "-W", timeout, t.icmp.host];
|
||||
}
|
||||
|
||||
@@ -16,11 +16,46 @@ const DEFAULT_COUNT = 3;
|
||||
const DEFAULT_PACKET_SIZE = 56;
|
||||
|
||||
export class IcmpChecker implements CheckerDefinition<ResolvedPingTarget> {
|
||||
readonly configKey = "ping";
|
||||
readonly configKey = "icmp";
|
||||
|
||||
readonly platform: NodeJS.Platform;
|
||||
|
||||
readonly schemas = icmpCheckerSchemas;
|
||||
|
||||
readonly type = "ping";
|
||||
readonly type = "icmp";
|
||||
|
||||
constructor(platform: NodeJS.Platform = process.platform) {
|
||||
this.platform = platform;
|
||||
}
|
||||
|
||||
buildDetail(observation: Record<string, unknown>): null | string {
|
||||
const alive = observation["alive"];
|
||||
const transmitted = observation["transmitted"];
|
||||
const received = observation["received"];
|
||||
|
||||
if (alive !== true) {
|
||||
const rx = typeof received === "number" ? received : 0;
|
||||
const tx = typeof transmitted === "number" ? transmitted : 0;
|
||||
return `unreachable (${rx}/${tx} received)`;
|
||||
}
|
||||
|
||||
const avg = observation["avgLatencyMs"];
|
||||
const loss = observation["packetLoss"];
|
||||
const avgStr = typeof avg === "number" ? formatNumber(avg) : "n/a";
|
||||
const lossStr = typeof loss === "number" ? formatNumber(loss) : "0";
|
||||
const rx = typeof received === "number" ? received : 0;
|
||||
const tx = typeof transmitted === "number" ? transmitted : 0;
|
||||
let detail = `alive, avg ${avgStr}ms, loss ${lossStr}% (${rx}/${tx})`;
|
||||
|
||||
if (typeof loss === "number" && loss > 0) {
|
||||
const max = observation["maxLatencyMs"];
|
||||
if (typeof max === "number") {
|
||||
detail = `${detail}, max ${formatNumber(max)}ms`;
|
||||
}
|
||||
}
|
||||
|
||||
return detail;
|
||||
}
|
||||
|
||||
async execute(t: ResolvedPingTarget, ctx: CheckerContext): Promise<CheckResult> {
|
||||
const timestamp = new Date().toISOString();
|
||||
@@ -36,10 +71,11 @@ export class IcmpChecker implements CheckerDefinition<ResolvedPingTarget> {
|
||||
} catch (error) {
|
||||
const durationMs = Math.round(performance.now() - start);
|
||||
return {
|
||||
detail: null,
|
||||
durationMs,
|
||||
failure: errorFailure("ping", "spawn", `ping 命令不可用: ${isError(error) ? error.message : String(error)}`),
|
||||
failure: errorFailure("icmp", "spawn", `icmp 命令不可用: ${isError(error) ? error.message : String(error)}`),
|
||||
matched: false,
|
||||
statusDetail: "ping command not found",
|
||||
observation: null,
|
||||
targetId: t.id,
|
||||
timestamp,
|
||||
};
|
||||
@@ -63,61 +99,83 @@ export class IcmpChecker implements CheckerDefinition<ResolvedPingTarget> {
|
||||
const durationMs = Math.round(performance.now() - start);
|
||||
if (ctx.signal.aborted) {
|
||||
return {
|
||||
detail: null,
|
||||
durationMs,
|
||||
failure: errorFailure("ping", "timeout", `ping 执行超时 (${t.timeoutMs}ms)`),
|
||||
failure: errorFailure("icmp", "timeout", `icmp 执行超时 (${t.timeoutMs}ms)`),
|
||||
matched: false,
|
||||
statusDetail: null,
|
||||
observation: null,
|
||||
targetId: t.id,
|
||||
timestamp,
|
||||
};
|
||||
}
|
||||
|
||||
const stats = parsePingOutput(stdout, process.platform);
|
||||
const stats = parsePingOutput(stdout, this.platform);
|
||||
if (!stats) {
|
||||
return {
|
||||
detail: null,
|
||||
durationMs,
|
||||
failure: errorFailure("ping", "parse", "无法解析 ping 输出"),
|
||||
failure: errorFailure("icmp", "parse", "无法解析 icmp 输出"),
|
||||
matched: false,
|
||||
statusDetail: truncateOutput(stdout),
|
||||
observation: {
|
||||
alive: false,
|
||||
avgLatencyMs: null,
|
||||
error: "parse failed",
|
||||
maxLatencyMs: null,
|
||||
minLatencyMs: null,
|
||||
packetLoss: 100,
|
||||
received: 0,
|
||||
transmitted: 0,
|
||||
},
|
||||
targetId: t.id,
|
||||
timestamp,
|
||||
};
|
||||
}
|
||||
|
||||
const result = checkStats(stats, t.expect, durationMs);
|
||||
const observation: Record<string, unknown> = {
|
||||
alive: stats.alive,
|
||||
avgLatencyMs: stats.avgLatencyMs,
|
||||
error: null,
|
||||
maxLatencyMs: stats.maxLatencyMs,
|
||||
minLatencyMs: stats.minLatencyMs,
|
||||
packetLoss: stats.packetLoss,
|
||||
received: stats.received,
|
||||
transmitted: stats.transmitted,
|
||||
};
|
||||
return {
|
||||
detail: null,
|
||||
durationMs,
|
||||
failure: result.failure,
|
||||
matched: result.matched,
|
||||
statusDetail: buildStatusDetail(stats),
|
||||
observation,
|
||||
targetId: t.id,
|
||||
timestamp,
|
||||
};
|
||||
}
|
||||
|
||||
resolve(target: RawTargetConfig, context: ResolveContext): ResolvedPingTarget {
|
||||
const t = target as RawTargetConfig & { ping: PingTargetConfig; type: "ping" };
|
||||
const t = target as RawTargetConfig & { icmp: PingTargetConfig; type: "icmp" };
|
||||
return {
|
||||
description: null,
|
||||
expect: target.expect as PingExpectConfig | undefined,
|
||||
group: target.group ?? "default",
|
||||
icmp: {
|
||||
count: t.icmp.count ?? DEFAULT_COUNT,
|
||||
host: t.icmp.host,
|
||||
packetSize: t.icmp.packetSize ?? DEFAULT_PACKET_SIZE,
|
||||
},
|
||||
id: t.id,
|
||||
intervalMs: context.defaultIntervalMs,
|
||||
name: t.name ?? null,
|
||||
ping: {
|
||||
count: t.ping.count ?? DEFAULT_COUNT,
|
||||
host: t.ping.host,
|
||||
packetSize: t.ping.packetSize ?? DEFAULT_PACKET_SIZE,
|
||||
},
|
||||
timeoutMs: context.defaultTimeoutMs,
|
||||
type: "ping",
|
||||
type: "icmp",
|
||||
} satisfies ResolvedPingTarget;
|
||||
}
|
||||
|
||||
serialize(t: ResolvedPingTarget): { config: string; target: string } {
|
||||
return {
|
||||
config: JSON.stringify(t.ping),
|
||||
target: `ping ${t.ping.host}`,
|
||||
config: JSON.stringify(t.icmp),
|
||||
target: `icmp ${t.icmp.host}`,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -126,17 +184,6 @@ export class IcmpChecker implements CheckerDefinition<ResolvedPingTarget> {
|
||||
}
|
||||
}
|
||||
|
||||
function buildStatusDetail(stats: PingStats): string {
|
||||
if (!stats.alive) return `unreachable (${stats.received}/${stats.transmitted} received)`;
|
||||
const avg = stats.avgLatencyMs === null ? "n/a" : formatNumber(stats.avgLatencyMs);
|
||||
const loss = formatNumber(stats.packetLoss);
|
||||
let detail = `alive, avg ${avg}ms, loss ${loss}% (${stats.received}/${stats.transmitted})`;
|
||||
if (stats.packetLoss > 0 && stats.maxLatencyMs !== null) {
|
||||
detail = `${detail}, max ${formatNumber(stats.maxLatencyMs)}ms`;
|
||||
}
|
||||
return detail;
|
||||
}
|
||||
|
||||
function checkStats(stats: PingStats, expect: PingExpectConfig | undefined, durationMs: number) {
|
||||
const aliveResult = checkAlive(stats.alive, expect?.alive ?? true);
|
||||
if (!aliveResult.matched) return aliveResult;
|
||||
@@ -179,8 +226,3 @@ async function readStream(stream: ReadableStream<Uint8Array>): Promise<string> {
|
||||
}
|
||||
return text;
|
||||
}
|
||||
|
||||
function truncateOutput(output: string, maxLen = 80): string {
|
||||
if (output.length <= maxLen) return output;
|
||||
return `${output.slice(0, maxLen)}…`;
|
||||
}
|
||||
|
||||
@@ -11,7 +11,7 @@ export function checkAlive(actual: boolean, expected: boolean): ExpectResult {
|
||||
"alive",
|
||||
expected,
|
||||
actual,
|
||||
expected ? "期望主机可达但 ping 不可达" : "期望主机不可达但 ping 可达",
|
||||
expected ? "期望主机可达但 icmp 不可达" : "期望主机不可达但 icmp 可达",
|
||||
),
|
||||
matched: false,
|
||||
};
|
||||
|
||||
@@ -34,9 +34,9 @@ export interface ResolvedPingConfig {
|
||||
export interface ResolvedPingTarget extends ResolvedTargetBase {
|
||||
expect?: PingExpectConfig;
|
||||
group: string;
|
||||
icmp: ResolvedPingConfig;
|
||||
intervalMs: number;
|
||||
name: null | string;
|
||||
ping: ResolvedPingConfig;
|
||||
timeoutMs: number;
|
||||
type: "ping";
|
||||
type: "icmp";
|
||||
}
|
||||
|
||||
@@ -10,15 +10,15 @@ import { issue, joinPath } from "../../schema/issues";
|
||||
export function validatePingConfig(input: CheckerValidationInput): ConfigValidationIssue[] {
|
||||
const issues: ConfigValidationIssue[] = [];
|
||||
|
||||
const defaults = input.defaults["ping"];
|
||||
const defaults = input.defaults["icmp"];
|
||||
if (defaults !== undefined && defaults !== null) {
|
||||
const targetName = "defaults.ping";
|
||||
const targetName = "defaults.icmp";
|
||||
if (!isPlainObject(defaults)) {
|
||||
issues.push(issue("invalid-type", "defaults.ping", "必须为对象", targetName));
|
||||
issues.push(issue("invalid-type", "defaults.icmp", "必须为对象", targetName));
|
||||
} else {
|
||||
const pingDefaults = defaults as Record<string, unknown>;
|
||||
for (const key of Object.keys(pingDefaults)) {
|
||||
issues.push(issue("unknown-field", joinPath("defaults.ping", key), "是未知字段", targetName));
|
||||
const icmpDefaults = defaults as Record<string, unknown>;
|
||||
for (const key of Object.keys(icmpDefaults)) {
|
||||
issues.push(issue("unknown-field", joinPath("defaults.icmp", key), "是未知字段", targetName));
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -27,7 +27,7 @@ export function validatePingConfig(input: CheckerValidationInput): ConfigValidat
|
||||
const target = input.targets[i] as unknown;
|
||||
if (!isPlainObject(target)) continue;
|
||||
const targetRecord = target as Record<string, unknown>;
|
||||
if (targetRecord["type"] !== "ping") continue;
|
||||
if (targetRecord["type"] !== "icmp") continue;
|
||||
issues.push(...validatePingTarget(targetRecord, `targets[${i}]`));
|
||||
}
|
||||
|
||||
@@ -71,39 +71,39 @@ function validatePingExpect(target: Record<string, unknown>, path: string): Conf
|
||||
function validatePingTarget(target: Record<string, unknown>, path: string): ConfigValidationIssue[] {
|
||||
const issues: ConfigValidationIssue[] = [];
|
||||
const targetName = getTargetName(target);
|
||||
const rawPing = target["ping"];
|
||||
const rawIcmp = target["icmp"];
|
||||
|
||||
if (!isPlainObject(rawPing)) {
|
||||
issues.push(issue("required", joinPath(path, "ping"), "缺少 ping 配置分组", targetName));
|
||||
if (!isPlainObject(rawIcmp)) {
|
||||
issues.push(issue("required", joinPath(path, "icmp"), "缺少 icmp 配置分组", targetName));
|
||||
issues.push(...validatePingExpect(target, path));
|
||||
return issues;
|
||||
}
|
||||
const ping = rawPing as Record<string, unknown>;
|
||||
const icmp = rawIcmp as Record<string, unknown>;
|
||||
|
||||
if (!isString(ping["host"]) || ping["host"].trim() === "") {
|
||||
issues.push(issue("required", joinPath(joinPath(path, "ping"), "host"), "缺少 ping.host 字段", targetName));
|
||||
if (!isString(icmp["host"]) || icmp["host"].trim() === "") {
|
||||
issues.push(issue("required", joinPath(joinPath(path, "icmp"), "host"), "缺少 icmp.host 字段", targetName));
|
||||
}
|
||||
if (ping["count"] !== undefined) {
|
||||
const count = ping["count"];
|
||||
if (icmp["count"] !== undefined) {
|
||||
const count = icmp["count"];
|
||||
if (!isNumber(count) || !Number.isInteger(count) || count < 1 || count > 100) {
|
||||
issues.push(
|
||||
issue("invalid-value", joinPath(joinPath(path, "ping"), "count"), "必须为 1-100 的正整数", targetName),
|
||||
issue("invalid-value", joinPath(joinPath(path, "icmp"), "count"), "必须为 1-100 的正整数", targetName),
|
||||
);
|
||||
}
|
||||
}
|
||||
if (ping["packetSize"] !== undefined) {
|
||||
const packetSize = ping["packetSize"];
|
||||
if (icmp["packetSize"] !== undefined) {
|
||||
const packetSize = icmp["packetSize"];
|
||||
if (!isNumber(packetSize) || !Number.isInteger(packetSize) || packetSize < 1 || packetSize > 65500) {
|
||||
issues.push(
|
||||
issue("invalid-value", joinPath(joinPath(path, "ping"), "packetSize"), "必须为 1-65500 的正整数", targetName),
|
||||
issue("invalid-value", joinPath(joinPath(path, "icmp"), "packetSize"), "必须为 1-65500 的正整数", targetName),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
const allowedPingKeys = new Set(["count", "host", "packetSize"]);
|
||||
for (const key of Object.keys(ping)) {
|
||||
if (!allowedPingKeys.has(key)) {
|
||||
issues.push(issue("unknown-field", joinPath(joinPath(path, "ping"), key), "是未知字段", targetName));
|
||||
const allowedIcmpKeys = new Set(["count", "host", "packetSize"]);
|
||||
for (const key of Object.keys(icmp)) {
|
||||
if (!allowedIcmpKeys.has(key)) {
|
||||
issues.push(issue("unknown-field", joinPath(joinPath(path, "icmp"), key), "是未知字段", targetName));
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -5,7 +5,7 @@ import { isError } from "es-toolkit";
|
||||
|
||||
import type { CheckResult, RawTargetConfig } from "../../types";
|
||||
import type { CheckerContext, CheckerDefinition, CheckerValidationInput, ResolveContext } from "../types";
|
||||
import type { LlmCheckObservation, LlmExpectConfig, LlmTargetConfig, ResolvedLlmTarget } from "./types";
|
||||
import type { LlmExpectConfig, LlmTargetConfig, ResolvedLlmTarget } from "./types";
|
||||
|
||||
import { errorFailure } from "../../expect/failure";
|
||||
import { checkValueMatcher } from "../../expect/matcher";
|
||||
@@ -14,6 +14,7 @@ import {
|
||||
buildObservationFromApiCallError,
|
||||
buildObservationFromGenerateText,
|
||||
buildObservationFromStreamText,
|
||||
toPersistedObservation,
|
||||
} from "./observation";
|
||||
import { createProviderModel } from "./provider";
|
||||
import { llmCheckerSchemas } from "./schema";
|
||||
@@ -24,6 +25,43 @@ export class LlmChecker implements CheckerDefinition<ResolvedLlmTarget> {
|
||||
readonly schemas = llmCheckerSchemas;
|
||||
readonly type = "llm";
|
||||
|
||||
buildDetail(observation: Record<string, unknown>): null | string {
|
||||
const provider = observation["provider"];
|
||||
const mode = observation["mode"];
|
||||
const parts: string[] = [`LLM ${String(provider)} ${String(mode)}`];
|
||||
|
||||
const http = observation["http"] as null | Record<string, unknown> | undefined;
|
||||
if (http && typeof http["status"] === "number") {
|
||||
parts.push(String(http["status"]));
|
||||
}
|
||||
|
||||
if (typeof observation["finishReason"] === "string") {
|
||||
parts.push(`finish=${observation["finishReason"]}`);
|
||||
}
|
||||
|
||||
if (typeof observation["rawFinishReason"] === "string") {
|
||||
parts.push(`raw=${observation["rawFinishReason"]}`);
|
||||
}
|
||||
|
||||
const stream = observation["stream"] as null | Record<string, unknown> | undefined;
|
||||
if (stream && typeof stream["firstTokenMs"] === "number") {
|
||||
parts.push(`firstToken=${stream["firstTokenMs"]}ms`);
|
||||
}
|
||||
|
||||
if (typeof observation["outputLength"] === "number") {
|
||||
parts.push(`output=${observation["outputLength"]} chars`);
|
||||
}
|
||||
|
||||
const usage = observation["usage"] as null | Record<string, unknown> | undefined;
|
||||
if (usage) {
|
||||
const inputTokens = typeof usage["inputTokens"] === "number" ? usage["inputTokens"] : 0;
|
||||
const outputTokens = typeof usage["outputTokens"] === "number" ? usage["outputTokens"] : 0;
|
||||
parts.push(`usage=${inputTokens}/${outputTokens} tokens`);
|
||||
}
|
||||
|
||||
return parts.join(", ");
|
||||
}
|
||||
|
||||
async execute(t: ResolvedLlmTarget, ctx: CheckerContext): Promise<CheckResult> {
|
||||
const timestamp = new Date().toISOString();
|
||||
const expect = t.expect;
|
||||
@@ -45,10 +83,11 @@ export class LlmChecker implements CheckerDefinition<ResolvedLlmTarget> {
|
||||
|
||||
if (observation.http === null) {
|
||||
return {
|
||||
detail: null,
|
||||
durationMs,
|
||||
failure: errorFailure("request", "request", error.message),
|
||||
matched: false,
|
||||
statusDetail: null,
|
||||
observation: null,
|
||||
targetId: t.id,
|
||||
timestamp,
|
||||
};
|
||||
@@ -63,10 +102,11 @@ export class LlmChecker implements CheckerDefinition<ResolvedLlmTarget> {
|
||||
const failure = expectResult.failure ?? durationResult.failure;
|
||||
|
||||
return {
|
||||
detail: null,
|
||||
durationMs,
|
||||
failure,
|
||||
matched: failure === null,
|
||||
statusDetail: buildStatusDetail(observation),
|
||||
observation: toPersistedObservation(observation),
|
||||
targetId: t.id,
|
||||
timestamp,
|
||||
};
|
||||
@@ -74,6 +114,7 @@ export class LlmChecker implements CheckerDefinition<ResolvedLlmTarget> {
|
||||
|
||||
const isTimeout = ctx.signal.aborted || (error instanceof DOMException && error.name === "AbortError");
|
||||
return {
|
||||
detail: null,
|
||||
durationMs,
|
||||
failure: errorFailure(
|
||||
"request",
|
||||
@@ -81,7 +122,7 @@ export class LlmChecker implements CheckerDefinition<ResolvedLlmTarget> {
|
||||
isTimeout ? `请求超时 (${t.timeoutMs}ms)` : isError(error) ? error.message : String(error),
|
||||
),
|
||||
matched: false,
|
||||
statusDetail: null,
|
||||
observation: null,
|
||||
targetId: t.id,
|
||||
timestamp,
|
||||
};
|
||||
@@ -222,10 +263,11 @@ export class LlmChecker implements CheckerDefinition<ResolvedLlmTarget> {
|
||||
const failure = expectResult.failure ?? durationResult.failure;
|
||||
|
||||
return {
|
||||
detail: null,
|
||||
durationMs,
|
||||
failure,
|
||||
matched: failure === null,
|
||||
statusDetail: buildStatusDetail(observation),
|
||||
observation: toPersistedObservation(observation),
|
||||
targetId: t.id,
|
||||
timestamp,
|
||||
};
|
||||
@@ -268,10 +310,11 @@ export class LlmChecker implements CheckerDefinition<ResolvedLlmTarget> {
|
||||
const failure = expectResult.failure ?? durationResult.failure;
|
||||
|
||||
return {
|
||||
detail: null,
|
||||
durationMs,
|
||||
failure,
|
||||
matched: failure === null,
|
||||
statusDetail: buildStatusDetail(observation),
|
||||
observation: toPersistedObservation(observation),
|
||||
targetId: t.id,
|
||||
timestamp,
|
||||
};
|
||||
@@ -291,33 +334,3 @@ function buildSdkOptions(config: ResolvedLlmTarget["llm"]): Record<string, unkno
|
||||
if (opts.seed !== undefined) options["seed"] = opts.seed;
|
||||
return options;
|
||||
}
|
||||
|
||||
function buildStatusDetail(observation: LlmCheckObservation): string {
|
||||
const parts: string[] = [`LLM ${observation.provider} ${observation.mode}`];
|
||||
|
||||
if (observation.http) {
|
||||
parts.push(String(observation.http.status));
|
||||
}
|
||||
|
||||
if (observation.finishReason) {
|
||||
parts.push(`finish=${observation.finishReason}`);
|
||||
}
|
||||
|
||||
if (observation.rawFinishReason) {
|
||||
parts.push(`raw=${observation.rawFinishReason}`);
|
||||
}
|
||||
|
||||
if (observation.stream?.firstTokenMs != null) {
|
||||
parts.push(`firstToken=${observation.stream.firstTokenMs}ms`);
|
||||
}
|
||||
|
||||
if (observation.outputText !== null) {
|
||||
parts.push(`output=${observation.outputText.length} chars`);
|
||||
}
|
||||
|
||||
if (observation.usage) {
|
||||
parts.push(`usage=${observation.usage.inputTokens}/${observation.usage.outputTokens} tokens`);
|
||||
}
|
||||
|
||||
return parts.join(", ");
|
||||
}
|
||||
|
||||
@@ -4,11 +4,15 @@ import type {
|
||||
LlmCheckObservation,
|
||||
LlmHttpMetadata,
|
||||
LlmMode,
|
||||
LlmPersistedHttpMetadata,
|
||||
LlmPersistedObservation,
|
||||
LlmProvider,
|
||||
LlmStreamObservation,
|
||||
LlmUsageObservation,
|
||||
} from "./types";
|
||||
|
||||
import { LLM_HEADERS_MAX, LLM_OUTPUT_PREVIEW_MAX } from "./types";
|
||||
|
||||
export function buildObservationFromApiCallError(
|
||||
error: APICallError,
|
||||
provider: LlmProvider,
|
||||
@@ -129,3 +133,42 @@ export async function buildObservationFromStreamText(
|
||||
warnings,
|
||||
};
|
||||
}
|
||||
|
||||
export function toPersistedObservation(obs: LlmCheckObservation): LlmPersistedObservation {
|
||||
const outputText = obs.outputText;
|
||||
const outputPreview =
|
||||
outputText !== null
|
||||
? outputText.length <= LLM_OUTPUT_PREVIEW_MAX
|
||||
? outputText
|
||||
: outputText.slice(0, LLM_OUTPUT_PREVIEW_MAX)
|
||||
: null;
|
||||
const outputLength = outputText !== null ? outputText.length : null;
|
||||
|
||||
const http: LlmPersistedHttpMetadata | null = obs.http
|
||||
? {
|
||||
headers: truncateHeaders(obs.http.headers, LLM_HEADERS_MAX),
|
||||
status: obs.http.status,
|
||||
statusText: obs.http.statusText,
|
||||
}
|
||||
: null;
|
||||
|
||||
return {
|
||||
finishReason: obs.finishReason,
|
||||
http,
|
||||
mode: obs.mode,
|
||||
model: obs.model,
|
||||
outputLength,
|
||||
outputPreview,
|
||||
provider: obs.provider,
|
||||
rawFinishReason: obs.rawFinishReason,
|
||||
stream: obs.stream,
|
||||
usage: obs.usage,
|
||||
warnings: obs.warnings,
|
||||
};
|
||||
}
|
||||
|
||||
function truncateHeaders(headers: Record<string, string>, maxCount: number): Record<string, string> {
|
||||
const entries = Object.entries(headers);
|
||||
if (entries.length <= maxCount) return headers;
|
||||
return Object.fromEntries(entries.slice(0, maxCount));
|
||||
}
|
||||
|
||||
@@ -15,6 +15,7 @@ export interface LlmCheckObservation {
|
||||
usage: LlmUsageObservation | null;
|
||||
warnings: string[];
|
||||
}
|
||||
|
||||
export interface LlmDefaultsConfig {
|
||||
headers?: Record<string, string>;
|
||||
ignoreSSL?: boolean;
|
||||
@@ -33,13 +34,37 @@ export interface LlmExpectConfig {
|
||||
stream?: LlmStreamExpect;
|
||||
usage?: LlmUsageExpect;
|
||||
}
|
||||
|
||||
export interface LlmHttpMetadata {
|
||||
headers: Record<string, string>;
|
||||
status: number;
|
||||
statusText: string;
|
||||
}
|
||||
|
||||
export interface LlmPersistedHttpMetadata {
|
||||
[key: string]: unknown;
|
||||
headers: Record<string, string>;
|
||||
status: number;
|
||||
statusText: string;
|
||||
}
|
||||
|
||||
export interface LlmPersistedObservation {
|
||||
[key: string]: unknown;
|
||||
finishReason: null | string;
|
||||
http: LlmPersistedHttpMetadata | null;
|
||||
mode: LlmMode;
|
||||
model: string;
|
||||
outputLength: null | number;
|
||||
outputPreview: null | string;
|
||||
provider: LlmProvider;
|
||||
rawFinishReason: null | string;
|
||||
stream: LlmStreamObservation | null;
|
||||
usage: LlmUsageObservation | null;
|
||||
warnings: string[];
|
||||
}
|
||||
|
||||
export const LLM_HEADERS_MAX = 20;
|
||||
export const LLM_OUTPUT_PREVIEW_MAX = 512;
|
||||
|
||||
export type LlmMode = "http" | "stream";
|
||||
|
||||
export interface LlmOptions {
|
||||
|
||||
@@ -15,7 +15,7 @@ const DEFAULT_BANNER_READ_TIMEOUT = 2000;
|
||||
const DEFAULT_MAX_BANNER_BYTES = 4096;
|
||||
|
||||
type ConnectAndBannerResult =
|
||||
| { banner?: string; bannerExceeded?: boolean; ok: true; socket: { close(): void } }
|
||||
| { banner?: string; bannerExceeded?: boolean; connectTimeMs: number; ok: true; socket: { close(): void } }
|
||||
| { error: string; ok: false };
|
||||
|
||||
export class TcpChecker implements CheckerDefinition<ResolvedTcpTarget> {
|
||||
@@ -25,6 +25,21 @@ export class TcpChecker implements CheckerDefinition<ResolvedTcpTarget> {
|
||||
|
||||
readonly type = "tcp";
|
||||
|
||||
buildDetail(observation: Record<string, unknown>): null | string {
|
||||
const connected = observation["connected"];
|
||||
if (connected !== true) {
|
||||
const error = observation["error"];
|
||||
return typeof error === "string" ? `connection failed: ${error}` : "not connected";
|
||||
}
|
||||
const connectTimeMs = observation["connectTimeMs"];
|
||||
const banner = observation["banner"];
|
||||
const parts: string[] = [`connected in ${typeof connectTimeMs === "number" ? connectTimeMs : "?"}ms`];
|
||||
if (typeof banner === "string" && banner.length > 0) {
|
||||
parts.push(`banner: ${truncateBanner(banner)}`);
|
||||
}
|
||||
return parts.join(", ");
|
||||
}
|
||||
|
||||
async execute(t: ResolvedTcpTarget, ctx: CheckerContext): Promise<CheckResult> {
|
||||
const timestamp = new Date().toISOString();
|
||||
const start = performance.now();
|
||||
@@ -42,36 +57,46 @@ export class TcpChecker implements CheckerDefinition<ResolvedTcpTarget> {
|
||||
|
||||
if (!connectResult.ok) {
|
||||
const durationMs = Math.round(performance.now() - start);
|
||||
const observation: Record<string, unknown> = {
|
||||
banner: null,
|
||||
connected: false,
|
||||
connectTimeMs: null,
|
||||
error: connectResult.error,
|
||||
};
|
||||
if (expect?.connected === false) {
|
||||
return {
|
||||
detail: null,
|
||||
durationMs,
|
||||
failure: null,
|
||||
matched: true,
|
||||
statusDetail: connectResult.error,
|
||||
observation,
|
||||
targetId: t.id,
|
||||
timestamp,
|
||||
};
|
||||
}
|
||||
return {
|
||||
detail: null,
|
||||
durationMs,
|
||||
failure: errorFailure("connect", "connect", connectResult.error),
|
||||
matched: false,
|
||||
statusDetail: null,
|
||||
observation,
|
||||
targetId: t.id,
|
||||
timestamp,
|
||||
};
|
||||
}
|
||||
|
||||
const socket = connectResult.socket;
|
||||
const connectTimeMs = connectResult.connectTimeMs;
|
||||
|
||||
if (ctx.signal.aborted) {
|
||||
closeSocket(socket);
|
||||
const durationMs = Math.round(performance.now() - start);
|
||||
return {
|
||||
detail: null,
|
||||
durationMs,
|
||||
failure: errorFailure("connect", "connect", `连接超时 (${t.timeoutMs}ms)`),
|
||||
matched: false,
|
||||
statusDetail: null,
|
||||
observation: null,
|
||||
targetId: t.id,
|
||||
timestamp,
|
||||
};
|
||||
@@ -83,10 +108,11 @@ export class TcpChecker implements CheckerDefinition<ResolvedTcpTarget> {
|
||||
closeSocket(socket);
|
||||
const durationMs = Math.round(performance.now() - start);
|
||||
return {
|
||||
detail: null,
|
||||
durationMs,
|
||||
failure: connectedResult.failure,
|
||||
matched: false,
|
||||
statusDetail: "connected",
|
||||
observation: { banner: null, connected: true, connectTimeMs, error: null },
|
||||
targetId: t.id,
|
||||
timestamp,
|
||||
};
|
||||
@@ -96,10 +122,11 @@ export class TcpChecker implements CheckerDefinition<ResolvedTcpTarget> {
|
||||
closeSocket(socket);
|
||||
const durationMs = Math.round(performance.now() - start);
|
||||
return {
|
||||
detail: null,
|
||||
durationMs,
|
||||
failure: errorFailure("banner", "banner", `banner 数据超过 ${t.tcp.maxBannerBytes} 字节限制`),
|
||||
matched: false,
|
||||
statusDetail: null,
|
||||
observation: { banner: null, connected: true, connectTimeMs, error: null },
|
||||
targetId: t.id,
|
||||
timestamp,
|
||||
};
|
||||
@@ -108,15 +135,23 @@ export class TcpChecker implements CheckerDefinition<ResolvedTcpTarget> {
|
||||
const banner = connectResult.banner ?? "";
|
||||
closeSocket(socket);
|
||||
|
||||
const observation: Record<string, unknown> = {
|
||||
banner: banner ? truncateBannerForObservation(banner) : null,
|
||||
connected: true,
|
||||
connectTimeMs,
|
||||
error: null,
|
||||
};
|
||||
|
||||
if (expect?.banner) {
|
||||
const bannerCheck = checkBanner(banner, expect.banner);
|
||||
if (!bannerCheck.matched) {
|
||||
const durationMs = Math.round(performance.now() - start);
|
||||
return {
|
||||
detail: null,
|
||||
durationMs,
|
||||
failure: bannerCheck.failure,
|
||||
matched: false,
|
||||
statusDetail: banner ? truncateBanner(banner) : null,
|
||||
observation,
|
||||
targetId: t.id,
|
||||
timestamp,
|
||||
};
|
||||
@@ -131,26 +166,29 @@ export class TcpChecker implements CheckerDefinition<ResolvedTcpTarget> {
|
||||
});
|
||||
if (!durationResult.matched) {
|
||||
return {
|
||||
detail: null,
|
||||
durationMs,
|
||||
failure: durationResult.failure,
|
||||
matched: false,
|
||||
statusDetail: buildStatusDetail(banner, durationMs),
|
||||
observation,
|
||||
targetId: t.id,
|
||||
timestamp,
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
detail: null,
|
||||
durationMs,
|
||||
failure: null,
|
||||
matched: true,
|
||||
statusDetail: buildStatusDetail(banner, durationMs),
|
||||
observation,
|
||||
targetId: t.id,
|
||||
timestamp,
|
||||
};
|
||||
} catch (error) {
|
||||
const durationMs = Math.round(performance.now() - start);
|
||||
return {
|
||||
detail: null,
|
||||
durationMs,
|
||||
failure: errorFailure(
|
||||
"connect",
|
||||
@@ -158,7 +196,7 @@ export class TcpChecker implements CheckerDefinition<ResolvedTcpTarget> {
|
||||
ctx.signal.aborted ? `连接超时 (${t.timeoutMs}ms)` : isError(error) ? error.message : String(error),
|
||||
),
|
||||
matched: false,
|
||||
statusDetail: null,
|
||||
observation: null,
|
||||
targetId: t.id,
|
||||
timestamp,
|
||||
};
|
||||
@@ -221,12 +259,6 @@ function assembleChunks(chunks: Uint8Array[], totalBytes: number): Uint8Array {
|
||||
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();
|
||||
@@ -292,11 +324,13 @@ async function connectAndMaybeReadBanner(
|
||||
};
|
||||
|
||||
try {
|
||||
const connectStart = performance.now();
|
||||
const socket = await Bun.connect({
|
||||
hostname,
|
||||
port,
|
||||
socket: socketHandlers,
|
||||
});
|
||||
const connectTimeMs = Math.round(performance.now() - connectStart);
|
||||
|
||||
if (signal.aborted) {
|
||||
closeSocket(socket);
|
||||
@@ -304,7 +338,7 @@ async function connectAndMaybeReadBanner(
|
||||
}
|
||||
|
||||
if (!readBanner) {
|
||||
return { bannerExceeded: false, ok: true, socket };
|
||||
return { bannerExceeded: false, connectTimeMs, ok: true, socket };
|
||||
}
|
||||
|
||||
const timer = setTimeout(() => {
|
||||
@@ -332,11 +366,11 @@ async function connectAndMaybeReadBanner(
|
||||
signal.removeEventListener("abort", onAbort);
|
||||
|
||||
if (bannerExceeded) {
|
||||
return { bannerExceeded: true, ok: true, socket };
|
||||
return { bannerExceeded: true, connectTimeMs, ok: true, socket };
|
||||
}
|
||||
|
||||
const banner = new TextDecoder().decode(assembleChunks(chunks, totalBytes));
|
||||
return { banner, bannerExceeded: false, ok: true, socket };
|
||||
return { banner, bannerExceeded: false, connectTimeMs, ok: true, socket };
|
||||
} catch (error) {
|
||||
if (signal.aborted) {
|
||||
return { error: "连接超时", ok: false };
|
||||
@@ -360,3 +394,8 @@ function truncateBanner(banner: string, maxLen = 80): string {
|
||||
if (banner.length <= maxLen) return banner;
|
||||
return `${banner.slice(0, maxLen)}…`;
|
||||
}
|
||||
|
||||
function truncateBannerForObservation(banner: string, maxLen = 256): string {
|
||||
if (banner.length <= maxLen) return banner;
|
||||
return banner.slice(0, maxLen);
|
||||
}
|
||||
|
||||
@@ -10,6 +10,7 @@ export interface CheckerContext {
|
||||
}
|
||||
|
||||
export interface CheckerDefinition<TResolved extends ResolvedTargetBase = ResolvedTargetBase> {
|
||||
buildDetail(observation: Record<string, unknown>): null | string;
|
||||
readonly configKey: string;
|
||||
execute(target: TResolved, ctx: CheckerContext): Promise<CheckResult>;
|
||||
resolve(target: RawTargetConfig, context: ResolveContext): TResolved;
|
||||
|
||||
@@ -13,7 +13,6 @@ import { udpCheckerSchemas } from "./schema";
|
||||
import { validateUdpConfig } from "./validate";
|
||||
|
||||
const DEFAULT_MAX_RESPONSE_BYTES = 4096;
|
||||
const RESPONSE_PREVIEW_MAX = 80;
|
||||
|
||||
type UdpExchangeResult =
|
||||
| {
|
||||
@@ -35,6 +34,24 @@ export class UdpChecker implements CheckerDefinition<ResolvedUdpTarget> {
|
||||
readonly schemas = udpCheckerSchemas;
|
||||
readonly type = "udp";
|
||||
|
||||
buildDetail(observation: Record<string, unknown>): null | string {
|
||||
const responded = observation["responded"];
|
||||
const durationMs = observation["durationMs"];
|
||||
const duration = typeof durationMs === "number" ? `${durationMs}ms` : "?ms";
|
||||
if (responded !== true) {
|
||||
return `no response in ${duration}`;
|
||||
}
|
||||
const responseSize = observation["responseSize"];
|
||||
const parts: string[] = [
|
||||
`responded in ${duration}, ${typeof responseSize === "number" ? responseSize : "?"} bytes`,
|
||||
];
|
||||
const preview = observation["responsePreview"];
|
||||
if (typeof preview === "string" && preview.length > 0) {
|
||||
parts.push(`response: ${preview.length > 80 ? `${preview.slice(0, 80)}…` : preview}`);
|
||||
}
|
||||
return parts.join(", ");
|
||||
}
|
||||
|
||||
async execute(t: ResolvedUdpTarget, ctx: CheckerContext): Promise<CheckResult> {
|
||||
const timestamp = new Date().toISOString();
|
||||
const start = performance.now();
|
||||
@@ -47,35 +64,21 @@ export class UdpChecker implements CheckerDefinition<ResolvedUdpTarget> {
|
||||
|
||||
if (!exchangeResult.ok) {
|
||||
const durationMs = Math.round(performance.now() - start);
|
||||
if (expect?.responded === false) {
|
||||
return {
|
||||
durationMs,
|
||||
failure: null,
|
||||
matched: true,
|
||||
statusDetail: exchangeResult.error,
|
||||
targetId: t.id,
|
||||
timestamp,
|
||||
};
|
||||
}
|
||||
const observation: Record<string, unknown> = {
|
||||
durationMs,
|
||||
error: exchangeResult.error,
|
||||
responded: false,
|
||||
responsePreview: null,
|
||||
responseSize: null,
|
||||
sourceAddress: null,
|
||||
sourcePort: null,
|
||||
};
|
||||
return {
|
||||
detail: null,
|
||||
durationMs,
|
||||
failure: errorFailure("response", "response", exchangeResult.error),
|
||||
matched: false,
|
||||
statusDetail: null,
|
||||
targetId: t.id,
|
||||
timestamp,
|
||||
};
|
||||
}
|
||||
|
||||
const expectedResponded = expect?.responded ?? true;
|
||||
const respondedResult = checkResponded(exchangeResult.responded, expectedResponded);
|
||||
if (!respondedResult.matched) {
|
||||
const durationMs = Math.round(performance.now() - start);
|
||||
return {
|
||||
durationMs,
|
||||
failure: respondedResult.failure,
|
||||
matched: false,
|
||||
statusDetail: null,
|
||||
observation,
|
||||
targetId: t.id,
|
||||
timestamp,
|
||||
};
|
||||
@@ -83,6 +86,31 @@ export class UdpChecker implements CheckerDefinition<ResolvedUdpTarget> {
|
||||
|
||||
if (!exchangeResult.responded) {
|
||||
const durationMs = Math.round(performance.now() - start);
|
||||
const expectedResponded = expect?.responded ?? true;
|
||||
const noResponseMessage = "未收到 UDP 响应";
|
||||
const error = expectedResponded ? noResponseMessage : null;
|
||||
const observation: Record<string, unknown> = {
|
||||
durationMs,
|
||||
error,
|
||||
responded: false,
|
||||
responsePreview: null,
|
||||
responseSize: null,
|
||||
sourceAddress: null,
|
||||
sourcePort: null,
|
||||
};
|
||||
|
||||
if (expectedResponded) {
|
||||
return {
|
||||
detail: null,
|
||||
durationMs,
|
||||
failure: errorFailure("response", "response", noResponseMessage),
|
||||
matched: false,
|
||||
observation,
|
||||
targetId: t.id,
|
||||
timestamp,
|
||||
};
|
||||
}
|
||||
|
||||
const durationResult = checkValueMatcher(durationMs, expect?.durationMs, {
|
||||
message: "durationMs mismatch",
|
||||
path: "durationMs",
|
||||
@@ -90,39 +118,69 @@ export class UdpChecker implements CheckerDefinition<ResolvedUdpTarget> {
|
||||
});
|
||||
if (!durationResult.matched) {
|
||||
return {
|
||||
detail: null,
|
||||
durationMs,
|
||||
failure: durationResult.failure,
|
||||
matched: false,
|
||||
statusDetail: buildNoResponseDetail(durationMs),
|
||||
observation,
|
||||
targetId: t.id,
|
||||
timestamp,
|
||||
};
|
||||
}
|
||||
return {
|
||||
detail: null,
|
||||
durationMs,
|
||||
failure: null,
|
||||
matched: true,
|
||||
statusDetail: buildNoResponseDetail(durationMs),
|
||||
observation,
|
||||
targetId: t.id,
|
||||
timestamp,
|
||||
};
|
||||
}
|
||||
|
||||
const durationMs = Math.round(performance.now() - start);
|
||||
const responsePreview = truncateResponsePreview(encodeResponse(exchangeResult.data, t.udp.responseEncoding));
|
||||
|
||||
const observation: Record<string, unknown> = {
|
||||
durationMs,
|
||||
error: null,
|
||||
responded: true,
|
||||
responsePreview,
|
||||
responseSize: exchangeResult.data.byteLength,
|
||||
sourceAddress: exchangeResult.sourceAddress,
|
||||
sourcePort: exchangeResult.sourcePort,
|
||||
};
|
||||
|
||||
const expectedResponded = expect?.responded ?? true;
|
||||
const respondedResult = checkResponded(true, expectedResponded);
|
||||
if (!respondedResult.matched) {
|
||||
return {
|
||||
detail: null,
|
||||
durationMs,
|
||||
failure: respondedResult.failure,
|
||||
matched: false,
|
||||
observation,
|
||||
targetId: t.id,
|
||||
timestamp,
|
||||
};
|
||||
}
|
||||
|
||||
if (exchangeResult.flags.truncated) {
|
||||
const durationMs = Math.round(performance.now() - start);
|
||||
return {
|
||||
detail: null,
|
||||
durationMs,
|
||||
failure: errorFailure("response", "response", "响应 datagram 被内核截断"),
|
||||
matched: false,
|
||||
statusDetail: null,
|
||||
observation,
|
||||
targetId: t.id,
|
||||
timestamp,
|
||||
};
|
||||
}
|
||||
|
||||
if (exchangeResult.data.byteLength > t.udp.maxResponseBytes) {
|
||||
const durationMs = Math.round(performance.now() - start);
|
||||
observation["error"] = `响应超过 ${t.udp.maxResponseBytes} 字节限制 (${exchangeResult.data.byteLength} bytes)`;
|
||||
return {
|
||||
detail: null,
|
||||
durationMs,
|
||||
failure: errorFailure(
|
||||
"response",
|
||||
@@ -130,7 +188,7 @@ export class UdpChecker implements CheckerDefinition<ResolvedUdpTarget> {
|
||||
`响应超过 ${t.udp.maxResponseBytes} 字节限制 (${exchangeResult.data.byteLength} bytes)`,
|
||||
),
|
||||
matched: false,
|
||||
statusDetail: null,
|
||||
observation,
|
||||
targetId: t.id,
|
||||
timestamp,
|
||||
};
|
||||
@@ -139,12 +197,12 @@ export class UdpChecker implements CheckerDefinition<ResolvedUdpTarget> {
|
||||
if (expect?.responseSize) {
|
||||
const sizeResult = checkResponseSize(exchangeResult.data.byteLength, expect.responseSize);
|
||||
if (!sizeResult.matched) {
|
||||
const durationMs = Math.round(performance.now() - start);
|
||||
return {
|
||||
detail: null,
|
||||
durationMs,
|
||||
failure: sizeResult.failure,
|
||||
matched: false,
|
||||
statusDetail: null,
|
||||
observation,
|
||||
targetId: t.id,
|
||||
timestamp,
|
||||
};
|
||||
@@ -155,12 +213,12 @@ export class UdpChecker implements CheckerDefinition<ResolvedUdpTarget> {
|
||||
const responseText = encodeResponse(exchangeResult.data, t.udp.responseEncoding);
|
||||
const textResult = checkResponseText(responseText, expect.response);
|
||||
if (!textResult.matched) {
|
||||
const durationMs = Math.round(performance.now() - start);
|
||||
return {
|
||||
detail: null,
|
||||
durationMs,
|
||||
failure: textResult.failure,
|
||||
matched: false,
|
||||
statusDetail: null,
|
||||
observation,
|
||||
targetId: t.id,
|
||||
timestamp,
|
||||
};
|
||||
@@ -170,12 +228,12 @@ export class UdpChecker implements CheckerDefinition<ResolvedUdpTarget> {
|
||||
if (expect?.sourceHost) {
|
||||
const sourceResult = checkSourceHost(exchangeResult.sourceAddress, expect.sourceHost);
|
||||
if (!sourceResult.matched) {
|
||||
const durationMs = Math.round(performance.now() - start);
|
||||
return {
|
||||
detail: null,
|
||||
durationMs,
|
||||
failure: sourceResult.failure,
|
||||
matched: false,
|
||||
statusDetail: null,
|
||||
observation,
|
||||
targetId: t.id,
|
||||
timestamp,
|
||||
};
|
||||
@@ -185,19 +243,18 @@ export class UdpChecker implements CheckerDefinition<ResolvedUdpTarget> {
|
||||
if (expect?.sourcePort) {
|
||||
const sourceResult = checkSourcePort(exchangeResult.sourcePort, expect.sourcePort);
|
||||
if (!sourceResult.matched) {
|
||||
const durationMs = Math.round(performance.now() - start);
|
||||
return {
|
||||
detail: null,
|
||||
durationMs,
|
||||
failure: sourceResult.failure,
|
||||
matched: false,
|
||||
statusDetail: null,
|
||||
observation,
|
||||
targetId: t.id,
|
||||
timestamp,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
const durationMs = Math.round(performance.now() - start);
|
||||
const durationResult = checkValueMatcher(durationMs, expect?.durationMs, {
|
||||
message: "durationMs mismatch",
|
||||
path: "durationMs",
|
||||
@@ -205,40 +262,33 @@ export class UdpChecker implements CheckerDefinition<ResolvedUdpTarget> {
|
||||
});
|
||||
if (!durationResult.matched) {
|
||||
return {
|
||||
detail: null,
|
||||
durationMs,
|
||||
failure: durationResult.failure,
|
||||
matched: false,
|
||||
statusDetail: buildRespondedDetail(
|
||||
exchangeResult.data.byteLength,
|
||||
durationMs,
|
||||
t.udp.responseEncoding,
|
||||
exchangeResult.data,
|
||||
),
|
||||
observation,
|
||||
targetId: t.id,
|
||||
timestamp,
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
detail: null,
|
||||
durationMs,
|
||||
failure: null,
|
||||
matched: true,
|
||||
statusDetail: buildRespondedDetail(
|
||||
exchangeResult.data.byteLength,
|
||||
durationMs,
|
||||
t.udp.responseEncoding,
|
||||
exchangeResult.data,
|
||||
),
|
||||
observation,
|
||||
targetId: t.id,
|
||||
timestamp,
|
||||
};
|
||||
} catch (error) {
|
||||
const durationMs = Math.round(performance.now() - start);
|
||||
return {
|
||||
detail: null,
|
||||
durationMs,
|
||||
failure: errorFailure("response", "response", isError(error) ? error.message : String(error)),
|
||||
matched: false,
|
||||
statusDetail: null,
|
||||
observation: null,
|
||||
targetId: t.id,
|
||||
timestamp,
|
||||
};
|
||||
@@ -287,20 +337,6 @@ export class UdpChecker implements CheckerDefinition<ResolvedUdpTarget> {
|
||||
}
|
||||
}
|
||||
|
||||
function buildNoResponseDetail(durationMs: number): string {
|
||||
return `no response in ${durationMs}ms`;
|
||||
}
|
||||
|
||||
function buildRespondedDetail(size: number, durationMs: number, encoding: string, data: Uint8Array): string {
|
||||
let detail = `responded in ${durationMs}ms, ${size} bytes`;
|
||||
if (size > 0 && size <= RESPONSE_PREVIEW_MAX) {
|
||||
const preview = encodeResponse(data, encoding as "base64" | "hex" | "text");
|
||||
const truncated = preview.length > RESPONSE_PREVIEW_MAX ? `${preview.slice(0, RESPONSE_PREVIEW_MAX)}…` : preview;
|
||||
detail = `${detail}, response: ${truncated}`;
|
||||
}
|
||||
return detail;
|
||||
}
|
||||
|
||||
function simplifyUdpError(message: string): string {
|
||||
const lower = message.toLowerCase();
|
||||
if (lower.includes("econnrefused") || lower.includes("connection refused")) return "connection refused";
|
||||
@@ -311,6 +347,11 @@ function simplifyUdpError(message: string): string {
|
||||
return message;
|
||||
}
|
||||
|
||||
function truncateResponsePreview(text: string, maxLen = 512): string {
|
||||
if (text.length <= maxLen) return text;
|
||||
return text.slice(0, maxLen);
|
||||
}
|
||||
|
||||
async function udpExchange(
|
||||
hostname: string,
|
||||
port: number,
|
||||
|
||||
@@ -17,7 +17,8 @@ CREATE TABLE IF NOT EXISTS targets (
|
||||
interval_ms INTEGER NOT NULL,
|
||||
timeout_ms INTEGER NOT NULL,
|
||||
expect TEXT,
|
||||
grp TEXT NOT NULL DEFAULT 'default'
|
||||
grp TEXT NOT NULL DEFAULT 'default',
|
||||
active INTEGER NOT NULL DEFAULT 1
|
||||
)
|
||||
`;
|
||||
|
||||
@@ -28,9 +29,9 @@ CREATE TABLE IF NOT EXISTS check_results (
|
||||
timestamp TEXT NOT NULL,
|
||||
matched INTEGER NOT NULL,
|
||||
duration_ms REAL,
|
||||
status_detail TEXT,
|
||||
observation TEXT,
|
||||
failure TEXT,
|
||||
FOREIGN KEY (target_id) REFERENCES targets(id) ON DELETE CASCADE
|
||||
FOREIGN KEY (target_id) REFERENCES targets(id) ON DELETE RESTRICT
|
||||
)
|
||||
`;
|
||||
|
||||
@@ -58,6 +59,11 @@ export class ProbeStore {
|
||||
this.db.close();
|
||||
}
|
||||
|
||||
deleteTargetRaw(id: string): void {
|
||||
if (this.closed) return;
|
||||
this.db.run("DELETE FROM targets WHERE id = ?", [id]);
|
||||
}
|
||||
|
||||
getAllRecentSamples(
|
||||
limit: number,
|
||||
): Map<string, Array<{ duration_ms: null | number; matched: number; timestamp: string }>> {
|
||||
@@ -74,6 +80,7 @@ export class ProbeStore {
|
||||
matched,
|
||||
ROW_NUMBER() OVER (PARTITION BY target_id ORDER BY timestamp DESC) as row_num
|
||||
FROM check_results
|
||||
WHERE target_id IN (SELECT id FROM targets WHERE active = 1)
|
||||
)
|
||||
WHERE row_num <= ?
|
||||
ORDER BY target_id, timestamp DESC`,
|
||||
@@ -107,6 +114,7 @@ export class ProbeStore {
|
||||
COALESCE(SUM(CASE WHEN matched = 0 THEN 1 ELSE 0 END), 0) as downChecks
|
||||
FROM check_results
|
||||
WHERE timestamp >= ? AND timestamp <= ?
|
||||
AND target_id IN (SELECT id FROM targets WHERE active = 1)
|
||||
GROUP BY target_id`,
|
||||
)
|
||||
.all(from, to) as Array<{ downChecks: number; target_id: string; totalChecks: number; upChecks: number }>;
|
||||
@@ -138,6 +146,7 @@ export class ProbeStore {
|
||||
`SELECT target_id, timestamp, matched
|
||||
FROM check_results
|
||||
WHERE timestamp >= ? AND timestamp <= ?
|
||||
AND target_id IN (SELECT id FROM targets WHERE active = 1)
|
||||
ORDER BY target_id ASC, timestamp ASC`,
|
||||
)
|
||||
.all(from, to) as Array<{ matched: number; target_id: string; timestamp: string }>;
|
||||
@@ -177,6 +186,7 @@ export class ProbeStore {
|
||||
INNER JOIN (
|
||||
SELECT target_id, MAX(timestamp) as max_ts
|
||||
FROM check_results
|
||||
WHERE target_id IN (SELECT id FROM targets WHERE active = 1)
|
||||
GROUP BY target_id
|
||||
) latest ON cr.target_id = latest.target_id AND cr.timestamp = latest.max_ts`,
|
||||
)
|
||||
@@ -199,9 +209,15 @@ export class ProbeStore {
|
||||
}>;
|
||||
}
|
||||
|
||||
getTargetActive(id: string): null | number {
|
||||
if (this.closed) return null;
|
||||
const row = this.db.query("SELECT active FROM targets WHERE id = ?").get(id) as null | { active: number };
|
||||
return row?.active ?? null;
|
||||
}
|
||||
|
||||
getTargetById(id: string): null | StoredTarget {
|
||||
if (this.closed) return null;
|
||||
return this.db.query("SELECT * FROM targets WHERE id = ?").get(id) as null | StoredTarget;
|
||||
return this.db.query("SELECT * FROM targets WHERE id = ? AND active = 1").get(id) as null | StoredTarget;
|
||||
}
|
||||
|
||||
getTargetCheckpoints(
|
||||
@@ -239,7 +255,7 @@ export class ProbeStore {
|
||||
getTargets(): StoredTarget[] {
|
||||
if (this.closed) return [];
|
||||
return this.db
|
||||
.query("SELECT * FROM targets ORDER BY CASE WHEN grp='default' THEN 0 ELSE 1 END, id")
|
||||
.query("SELECT * FROM targets WHERE active = 1 ORDER BY CASE WHEN grp='default' THEN 0 ELSE 1 END, id")
|
||||
.all() as StoredTarget[];
|
||||
}
|
||||
|
||||
@@ -277,25 +293,31 @@ export class ProbeStore {
|
||||
};
|
||||
}
|
||||
|
||||
hasTargetRow(id: string): boolean {
|
||||
if (this.closed) return false;
|
||||
const row = this.db.query("SELECT 1 FROM targets WHERE id = ?").get(id) as null | { "1": number };
|
||||
return row !== null;
|
||||
}
|
||||
|
||||
insertCheckResult(result: {
|
||||
durationMs: null | number;
|
||||
failure: CheckFailure | null;
|
||||
matched: boolean;
|
||||
statusDetail: null | string;
|
||||
observation: null | Record<string, unknown>;
|
||||
targetId: string;
|
||||
timestamp: string;
|
||||
}): void {
|
||||
if (this.closed) return;
|
||||
this.db
|
||||
.query(
|
||||
"INSERT INTO check_results (target_id, timestamp, matched, duration_ms, status_detail, failure) VALUES (?, ?, ?, ?, ?, ?)",
|
||||
"INSERT INTO check_results (target_id, timestamp, matched, duration_ms, observation, failure) VALUES (?, ?, ?, ?, ?, ?)",
|
||||
)
|
||||
.run(
|
||||
result.targetId,
|
||||
result.timestamp,
|
||||
result.matched ? 1 : 0,
|
||||
result.durationMs,
|
||||
result.statusDetail,
|
||||
result.observation ? JSON.stringify(result.observation) : null,
|
||||
result.failure ? JSON.stringify(result.failure) : null,
|
||||
);
|
||||
}
|
||||
@@ -304,6 +326,9 @@ export class ProbeStore {
|
||||
if (this.closed) return 0;
|
||||
const cutoff = new Date(Date.now() - retentionMs).toISOString();
|
||||
const result = this.db.run("DELETE FROM check_results WHERE timestamp < ?", [cutoff]);
|
||||
this.db.run(
|
||||
"DELETE FROM targets WHERE active = 0 AND NOT EXISTS (SELECT 1 FROM check_results WHERE check_results.target_id = targets.id)",
|
||||
);
|
||||
return result.changes;
|
||||
}
|
||||
|
||||
@@ -317,9 +342,9 @@ export class ProbeStore {
|
||||
"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 = ?, description = ?, 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 = ?, active = 1 WHERE id = ?",
|
||||
);
|
||||
const deleteStmt = this.db.prepare("DELETE FROM targets WHERE id = ?");
|
||||
const deactivateStmt = this.db.prepare("UPDATE targets SET active = 0 WHERE id = ? AND active = 1");
|
||||
|
||||
const tx = this.db.transaction(() => {
|
||||
for (const t of targets) {
|
||||
@@ -338,7 +363,7 @@ export class ProbeStore {
|
||||
|
||||
for (const id of existingIds) {
|
||||
if (!configIds.has(id)) {
|
||||
deleteStmt.run(id);
|
||||
deactivateStmt.run(id);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
@@ -60,12 +60,13 @@ export interface StoredCheckResult {
|
||||
failure: null | string;
|
||||
id: number;
|
||||
matched: number;
|
||||
status_detail: null | string;
|
||||
observation: null | string;
|
||||
target_id: string;
|
||||
timestamp: string;
|
||||
}
|
||||
|
||||
export interface StoredTarget {
|
||||
active: number;
|
||||
config: string;
|
||||
description: null | string;
|
||||
expect: null | string;
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
import type { ApiErrorResponse, CheckFailure, CheckResult, HealthResponse, RuntimeMode } from "../shared/api";
|
||||
import type { StoredCheckResult } from "./checker/types";
|
||||
|
||||
import { checkerRegistry } from "./checker/runner";
|
||||
|
||||
export function createApiError(error: string, status: number): ApiErrorResponse {
|
||||
return { error, status };
|
||||
}
|
||||
@@ -45,7 +47,7 @@ export function jsonResponse(
|
||||
});
|
||||
}
|
||||
|
||||
export function mapCheckResult(row: StoredCheckResult): CheckResult {
|
||||
export function mapCheckResult(row: StoredCheckResult, type: string): CheckResult {
|
||||
let failure: CheckFailure | null = null;
|
||||
if (row.failure) {
|
||||
try {
|
||||
@@ -56,11 +58,32 @@ export function mapCheckResult(row: StoredCheckResult): CheckResult {
|
||||
}
|
||||
}
|
||||
|
||||
let observation: null | Record<string, unknown> = null;
|
||||
if (row.observation) {
|
||||
try {
|
||||
observation = JSON.parse(row.observation) as Record<string, unknown>;
|
||||
} catch {
|
||||
console.warn(`无法解析 observation 数据: target_id=${row.target_id}, timestamp=${row.timestamp}`);
|
||||
observation = null;
|
||||
}
|
||||
}
|
||||
|
||||
let detail: null | string = null;
|
||||
if (observation !== null) {
|
||||
try {
|
||||
const checker = checkerRegistry.get(type);
|
||||
detail = checker.buildDetail(observation);
|
||||
} catch {
|
||||
detail = null;
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
detail,
|
||||
durationMs: row.duration_ms,
|
||||
failure,
|
||||
matched: row.matched === 1,
|
||||
statusDetail: row.status_detail,
|
||||
observation,
|
||||
timestamp: row.timestamp,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -60,7 +60,7 @@ export function handleDashboard(url: URL, store: ProbeStore, mode: RuntimeMode):
|
||||
group: target.grp,
|
||||
id: target.id,
|
||||
interval: formatDuration(target.interval_ms),
|
||||
latestCheck: latest ? mapCheckResult(latest) : null,
|
||||
latestCheck: latest ? mapCheckResult(latest, target.type) : null,
|
||||
name: target.name,
|
||||
recentSamples: recentSamples.map((sample) => ({
|
||||
durationMs: sample.duration_ms,
|
||||
|
||||
@@ -21,7 +21,7 @@ export function handleHistory(idStr: string, url: URL, store: ProbeStore, mode:
|
||||
|
||||
const result = store.getHistory(idResult.id, timeResult.from, timeResult.to, pageResult.page, pageResult.pageSize);
|
||||
const response: HistoryResponse = {
|
||||
items: result.items.map(mapCheckResult),
|
||||
items: result.items.map((row) => mapCheckResult(row, target.type)),
|
||||
page: result.page,
|
||||
pageSize: result.pageSize,
|
||||
total: result.total,
|
||||
|
||||
@@ -13,10 +13,11 @@ export interface CheckFailure {
|
||||
}
|
||||
|
||||
export interface CheckResult {
|
||||
detail: null | string;
|
||||
durationMs: null | number;
|
||||
failure: CheckFailure | null;
|
||||
matched: boolean;
|
||||
statusDetail: null | string;
|
||||
observation: null | Record<string, unknown>;
|
||||
timestamp: string;
|
||||
}
|
||||
|
||||
|
||||
@@ -37,9 +37,10 @@ export function OverviewTab({ metricsData, metricsLoading, target }: OverviewTab
|
||||
content: target.latestCheck ? new Date(target.latestCheck.timestamp).toLocaleString("zh-CN") : "-",
|
||||
label: "最新检查时间",
|
||||
},
|
||||
{ content: target.latestCheck?.statusDetail ?? "-", label: "状态详情" },
|
||||
{ content: target.latestCheck?.detail ?? "-", label: "状态详情" },
|
||||
{ content: target.description ?? "", label: "描述", span: 2 },
|
||||
]}
|
||||
tableLayout="auto"
|
||||
/>
|
||||
|
||||
<Divider align="left">统计</Divider>
|
||||
|
||||
@@ -27,10 +27,10 @@ export const HISTORY_COLUMNS: Array<PrimaryTableCol<CheckResult>> = [
|
||||
},
|
||||
{
|
||||
cell: ({ row }: PrimaryTableCellParams<CheckResult>) => {
|
||||
const parts = [row.statusDetail, row.failure?.message].filter(Boolean);
|
||||
const parts = [row.detail, row.failure?.message].filter(Boolean);
|
||||
return parts.length > 0 ? parts.join(":") : "-";
|
||||
},
|
||||
colKey: "statusDetail",
|
||||
colKey: "detail",
|
||||
title: "详情",
|
||||
},
|
||||
];
|
||||
|
||||
@@ -89,13 +89,13 @@ export function createTargetTableColumns(checkerTypes: string[]): Array<PrimaryT
|
||||
if (ms === null || ms === undefined) return <span className="text-disabled">-</span>;
|
||||
const colorClass = ms <= 100 ? "latency-ok" : ms <= 500 ? "latency-warn" : "latency-error";
|
||||
const latencyText = ms > 9999 ? "9999+" : `${Math.round(ms)}`;
|
||||
return <span className={`${colorClass} latency-value tabular-nums`}>{latencyText}</span>;
|
||||
return <span className={`${colorClass} latency-value tabular-nums`}>{latencyText} ms</span>;
|
||||
},
|
||||
colKey: "latestCheck.durationMs",
|
||||
sorter: latencySorter,
|
||||
sortType: "all",
|
||||
title: "延迟(ms)",
|
||||
width: 75,
|
||||
title: "延迟",
|
||||
width: 80,
|
||||
},
|
||||
];
|
||||
}
|
||||
|
||||
@@ -80,7 +80,7 @@ describe("API 路由", () => {
|
||||
durationMs: 100,
|
||||
failure: null,
|
||||
matched: true,
|
||||
statusDetail: "200 OK",
|
||||
observation: null,
|
||||
targetId: targets[0]!.id,
|
||||
timestamp: "2025-01-01T00:00:00.000Z",
|
||||
});
|
||||
@@ -95,7 +95,7 @@ describe("API 路由", () => {
|
||||
phase: "status",
|
||||
},
|
||||
matched: false,
|
||||
statusDetail: null,
|
||||
observation: null,
|
||||
targetId: targets[0]!.id,
|
||||
timestamp: "2025-01-01T00:10:00.000Z",
|
||||
});
|
||||
@@ -110,7 +110,7 @@ describe("API 路由", () => {
|
||||
phase: "status",
|
||||
},
|
||||
matched: false,
|
||||
statusDetail: null,
|
||||
observation: null,
|
||||
targetId: targets[0]!.id,
|
||||
timestamp: "2025-01-01T00:20:00.000Z",
|
||||
});
|
||||
@@ -118,7 +118,7 @@ describe("API 路由", () => {
|
||||
durationMs: 200,
|
||||
failure: null,
|
||||
matched: true,
|
||||
statusDetail: "200 OK",
|
||||
observation: null,
|
||||
targetId: targets[0]!.id,
|
||||
timestamp: "2025-01-01T00:40:00.000Z",
|
||||
});
|
||||
@@ -126,7 +126,7 @@ describe("API 路由", () => {
|
||||
durationMs: 400,
|
||||
failure: null,
|
||||
matched: true,
|
||||
statusDetail: "200 OK",
|
||||
observation: null,
|
||||
targetId: targets[0]!.id,
|
||||
timestamp: "2025-01-01T01:10:00.000Z",
|
||||
});
|
||||
@@ -136,7 +136,7 @@ describe("API 路由", () => {
|
||||
durationMs: 120,
|
||||
failure: null,
|
||||
matched: true,
|
||||
statusDetail: "200 OK",
|
||||
observation: null,
|
||||
targetId: targets[0]!.id,
|
||||
timestamp: new Date(now - 90 * 60 * 1000).toISOString(),
|
||||
});
|
||||
@@ -151,7 +151,7 @@ describe("API 路由", () => {
|
||||
phase: "status",
|
||||
},
|
||||
matched: false,
|
||||
statusDetail: null,
|
||||
observation: null,
|
||||
targetId: targets[0]!.id,
|
||||
timestamp: new Date(now - 60 * 60 * 1000).toISOString(),
|
||||
});
|
||||
@@ -166,7 +166,7 @@ describe("API 路由", () => {
|
||||
phase: "status",
|
||||
},
|
||||
matched: false,
|
||||
statusDetail: null,
|
||||
observation: null,
|
||||
targetId: targets[0]!.id,
|
||||
timestamp: new Date(now - 30 * 60 * 1000).toISOString(),
|
||||
});
|
||||
@@ -428,7 +428,7 @@ describe("API 路由", () => {
|
||||
durationMs: 100,
|
||||
failure: { kind: "error", message: "test", path: "$", phase: "body" },
|
||||
matched: false,
|
||||
statusDetail: "200 OK",
|
||||
observation: null,
|
||||
targetId: t1Id,
|
||||
timestamp: "2025-06-01T00:00:00.000Z",
|
||||
});
|
||||
|
||||
@@ -2009,14 +2009,14 @@ targets:
|
||||
expect(t.expect?.durationMs).toEqual({ lte: 5000 });
|
||||
});
|
||||
|
||||
test("解析最简 ping 配置", async () => {
|
||||
const configPath = join(tempDir, "minimal-ping.yaml");
|
||||
test("解析最简 icmp 配置", async () => {
|
||||
const configPath = join(tempDir, "minimal-icmp.yaml");
|
||||
await writeFile(
|
||||
configPath,
|
||||
`targets:
|
||||
- id: "gateway"
|
||||
type: ping
|
||||
ping:
|
||||
type: icmp
|
||||
icmp:
|
||||
host: "10.0.0.1"
|
||||
`,
|
||||
);
|
||||
@@ -2024,21 +2024,21 @@ targets:
|
||||
const config = await loadConfig(configPath);
|
||||
expect(config.targets).toHaveLength(1);
|
||||
const t = config.targets[0]! as ResolvedPingTarget;
|
||||
expect(t.type).toBe("ping");
|
||||
expect(t.ping).toEqual({ count: 3, host: "10.0.0.1", packetSize: 56 });
|
||||
expect(t.type).toBe("icmp");
|
||||
expect(t.icmp).toEqual({ count: 3, host: "10.0.0.1", packetSize: 56 });
|
||||
expect(t.group).toBe("default");
|
||||
expect(t.intervalMs).toBe(30000);
|
||||
expect(t.timeoutMs).toBe(10000);
|
||||
});
|
||||
|
||||
test("解析 ping expect 配置", async () => {
|
||||
const configPath = join(tempDir, "ping-expect.yaml");
|
||||
test("解析 icmp expect 配置", async () => {
|
||||
const configPath = join(tempDir, "icmp-expect.yaml");
|
||||
await writeFile(
|
||||
configPath,
|
||||
`targets:
|
||||
- id: "gateway"
|
||||
type: ping
|
||||
ping:
|
||||
type: icmp
|
||||
icmp:
|
||||
host: "10.0.0.1"
|
||||
count: 5
|
||||
packetSize: 1472
|
||||
@@ -2057,7 +2057,7 @@ targets:
|
||||
|
||||
const config = await loadConfig(configPath);
|
||||
const t = config.targets[0]! as ResolvedPingTarget;
|
||||
expect(t.ping).toEqual({ count: 5, host: "10.0.0.1", packetSize: 1472 });
|
||||
expect(t.icmp).toEqual({ count: 5, host: "10.0.0.1", packetSize: 1472 });
|
||||
expect(t.expect).toEqual({
|
||||
alive: true,
|
||||
avgLatencyMs: { lte: 200 },
|
||||
@@ -2067,39 +2067,39 @@ targets:
|
||||
});
|
||||
});
|
||||
|
||||
test("ping 缺少 host 抛出错误", async () => {
|
||||
test("icmp 缺少 host 抛出错误", async () => {
|
||||
await expectConfigError(
|
||||
"ping-no-host.yaml",
|
||||
"icmp-no-host.yaml",
|
||||
`targets:
|
||||
- id: "gateway"
|
||||
type: ping
|
||||
ping: {}
|
||||
type: icmp
|
||||
icmp: {}
|
||||
`,
|
||||
"ping.host",
|
||||
"icmp.host",
|
||||
);
|
||||
});
|
||||
|
||||
test("ping count 非法抛出错误", async () => {
|
||||
test("icmp count 非法抛出错误", async () => {
|
||||
await expectConfigError(
|
||||
"ping-bad-count.yaml",
|
||||
"icmp-bad-count.yaml",
|
||||
`targets:
|
||||
- id: "gateway"
|
||||
type: ping
|
||||
ping:
|
||||
type: icmp
|
||||
icmp:
|
||||
host: "10.0.0.1"
|
||||
count: 0
|
||||
`,
|
||||
"ping.count",
|
||||
"icmp.count",
|
||||
);
|
||||
});
|
||||
|
||||
test("ping expect 未知字段抛出错误", async () => {
|
||||
test("icmp expect 未知字段抛出错误", async () => {
|
||||
await expectConfigError(
|
||||
"ping-unknown-expect.yaml",
|
||||
"icmp-unknown-expect.yaml",
|
||||
`targets:
|
||||
- id: "gateway"
|
||||
type: ping
|
||||
ping:
|
||||
type: icmp
|
||||
icmp:
|
||||
host: "10.0.0.1"
|
||||
expect:
|
||||
status: [200]
|
||||
|
||||
@@ -90,7 +90,7 @@ describe("ProbeEngine", () => {
|
||||
const results = (mockStore as unknown as { _results: Array<Record<string, unknown>> })._results;
|
||||
expect(results.length).toBe(1);
|
||||
expect(results[0]!["matched"]).toBe(true);
|
||||
expect(results[0]!["statusDetail"]).toBe("exitCode=0");
|
||||
expect((results[0]!["observation"] as Record<string, unknown>)["exitCode"]).toBe(0);
|
||||
});
|
||||
|
||||
test("多个目标并发执行", async () => {
|
||||
@@ -181,7 +181,7 @@ describe("ProbeEngine", () => {
|
||||
expect(results[0]!["targetId"]).toBe("reject-cmd");
|
||||
expect(results[0]!["matched"]).toBe(false);
|
||||
expect(results[0]!["durationMs"]).toBeNull();
|
||||
expect(results[0]!["statusDetail"]).toBeNull();
|
||||
expect(results[0]!["observation"]).toBeNull();
|
||||
expect(results[0]!["failure"]).toEqual({
|
||||
kind: "error",
|
||||
message: "boom",
|
||||
@@ -288,7 +288,7 @@ describe("ProbeEngine", () => {
|
||||
const results = (mockStore as unknown as { _results: Array<Record<string, unknown>> })._results;
|
||||
expect(results.length).toBe(1);
|
||||
expect(results[0]!["matched"]).toBe(true);
|
||||
expect(results[0]!["statusDetail"]).toBe("HTTP 200");
|
||||
expect((results[0]!["observation"] as Record<string, unknown>)["statusCode"]).toBe(200);
|
||||
} finally {
|
||||
void httpServer.stop();
|
||||
}
|
||||
|
||||
@@ -45,14 +45,14 @@ describe("CommandChecker", () => {
|
||||
test("exitCode=0 成功", async () => {
|
||||
const result = await checker.execute(makeTarget({ args: ["-e", "process.exit(0)"], exec: "bun" }), makeCtx());
|
||||
expect(result.matched).toBe(true);
|
||||
expect(result.statusDetail).toBe("exitCode=0");
|
||||
expect(result.observation).toMatchObject({ exitCode: 0 });
|
||||
expect(result.failure).toBeNull();
|
||||
});
|
||||
|
||||
test("exitCode=1 不匹配默认 [0]", async () => {
|
||||
const result = await checker.execute(makeTarget({ args: ["-e", "process.exit(1)"], exec: "bun" }), makeCtx());
|
||||
expect(result.matched).toBe(false);
|
||||
expect(result.statusDetail).toBe("exitCode=1");
|
||||
expect(result.observation).toMatchObject({ exitCode: 1 });
|
||||
expect(result.failure!.phase).toBe("exitCode");
|
||||
});
|
||||
|
||||
@@ -62,7 +62,7 @@ describe("CommandChecker", () => {
|
||||
makeCtx(),
|
||||
);
|
||||
expect(result.matched).toBe(true);
|
||||
expect(result.statusDetail).toBe("exitCode=1");
|
||||
expect(result.observation).toMatchObject({ exitCode: 1 });
|
||||
});
|
||||
|
||||
test("命令不存在返回 spawn 错误", async () => {
|
||||
@@ -79,6 +79,7 @@ describe("CommandChecker", () => {
|
||||
);
|
||||
expect(result.matched).toBe(false);
|
||||
expect(result.failure!.message).toContain("超时");
|
||||
expect(result.observation?.["error"]).toContain("超时");
|
||||
});
|
||||
|
||||
test("stdout 输出捕获", async () => {
|
||||
@@ -130,6 +131,7 @@ describe("CommandChecker", () => {
|
||||
);
|
||||
expect(result.matched).toBe(false);
|
||||
expect(result.failure!.message).toContain("超过限制");
|
||||
expect(result.observation?.["error"]).toContain("超过限制");
|
||||
});
|
||||
|
||||
test("durationMs 非空", async () => {
|
||||
|
||||
@@ -34,14 +34,14 @@ describe("DbChecker", () => {
|
||||
test("无 query 时仅测试连接成功", async () => {
|
||||
const result = await checker.execute(makeTarget({}), makeCtx());
|
||||
expect(result.matched).toBe(true);
|
||||
expect(result.statusDetail).toBe("connected");
|
||||
expect(result.observation).toMatchObject({ connected: true });
|
||||
expect(result.failure).toBeNull();
|
||||
});
|
||||
|
||||
test("执行查询成功", async () => {
|
||||
const result = await checker.execute(makeTarget({ query: "SELECT 1 as num, 'hello' as str" }), makeCtx());
|
||||
expect(result.matched).toBe(true);
|
||||
expect(result.statusDetail).toBe("1 rows");
|
||||
expect(result.observation).toMatchObject({ connected: true, rowCount: 1 });
|
||||
expect(result.durationMs).toBeGreaterThanOrEqual(0);
|
||||
});
|
||||
|
||||
@@ -51,13 +51,13 @@ describe("DbChecker", () => {
|
||||
makeCtx(),
|
||||
);
|
||||
expect(result.matched).toBe(true);
|
||||
expect(result.statusDetail).toBe("3 rows");
|
||||
expect(result.observation).toMatchObject({ connected: true, rowCount: 3 });
|
||||
});
|
||||
|
||||
test("查询返回空结果", async () => {
|
||||
const result = await checker.execute(makeTarget({ query: "SELECT 1 as n WHERE 1=0" }), makeCtx());
|
||||
expect(result.matched).toBe(true);
|
||||
expect(result.statusDetail).toBe("0 rows");
|
||||
expect(result.observation).toMatchObject({ connected: true, rowCount: 0 });
|
||||
});
|
||||
|
||||
test("连接失败返回 connect phase 错误", async () => {
|
||||
|
||||
67
tests/server/checker/runner/detail.test.ts
Normal file
67
tests/server/checker/runner/detail.test.ts
Normal file
@@ -0,0 +1,67 @@
|
||||
import { describe, expect, test } from "bun:test";
|
||||
|
||||
import { CommandChecker } from "../../../../src/server/checker/runner/cmd/execute";
|
||||
import { DbChecker } from "../../../../src/server/checker/runner/db/execute";
|
||||
import { HttpChecker } from "../../../../src/server/checker/runner/http/execute";
|
||||
import { IcmpChecker } from "../../../../src/server/checker/runner/icmp/execute";
|
||||
import { LlmChecker } from "../../../../src/server/checker/runner/llm/execute";
|
||||
import { TcpChecker } from "../../../../src/server/checker/runner/tcp/execute";
|
||||
import { UdpChecker } from "../../../../src/server/checker/runner/udp/execute";
|
||||
|
||||
describe("Checker buildDetail", () => {
|
||||
test("HTTP detail", () => {
|
||||
expect(new HttpChecker().buildDetail({ statusCode: 200 })).toBe("HTTP 200");
|
||||
});
|
||||
|
||||
test("TCP detail", () => {
|
||||
const detail = new TcpChecker().buildDetail({
|
||||
banner: "220 smtp.example.com ESMTP",
|
||||
connected: true,
|
||||
connectTimeMs: 12,
|
||||
});
|
||||
expect(detail).toContain("connected in 12ms");
|
||||
expect(detail).toContain("banner:");
|
||||
});
|
||||
|
||||
test("UDP detail", () => {
|
||||
const checker = new UdpChecker();
|
||||
expect(checker.buildDetail({ durationMs: 12, responded: true, responsePreview: "PONG", responseSize: 4 })).toBe(
|
||||
"responded in 12ms, 4 bytes, response: PONG",
|
||||
);
|
||||
expect(checker.buildDetail({ durationMs: 200, responded: false })).toBe("no response in 200ms");
|
||||
});
|
||||
|
||||
test("Ping detail", () => {
|
||||
const checker = new IcmpChecker();
|
||||
expect(checker.buildDetail({ alive: true, avgLatencyMs: 12, packetLoss: 0, received: 3, transmitted: 3 })).toBe(
|
||||
"alive, avg 12ms, loss 0% (3/3)",
|
||||
);
|
||||
expect(checker.buildDetail({ alive: false, received: 0, transmitted: 3 })).toBe("unreachable (0/3 received)");
|
||||
});
|
||||
|
||||
test("DB detail", () => {
|
||||
const checker = new DbChecker();
|
||||
expect(checker.buildDetail({ connected: true, rowCount: 3 })).toBe("3 rows");
|
||||
expect(checker.buildDetail({ connected: true, rowCount: null })).toBe("connected");
|
||||
});
|
||||
|
||||
test("CMD detail", () => {
|
||||
expect(new CommandChecker().buildDetail({ exitCode: 0 })).toBe("exitCode=0");
|
||||
});
|
||||
|
||||
test("LLM detail", () => {
|
||||
const detail = new LlmChecker().buildDetail({
|
||||
finishReason: "stop",
|
||||
http: { status: 200 },
|
||||
mode: "http",
|
||||
outputLength: 2,
|
||||
provider: "openai",
|
||||
usage: { inputTokens: 12, outputTokens: 2, totalTokens: 14 },
|
||||
});
|
||||
expect(detail).toContain("LLM openai http");
|
||||
expect(detail).toContain("200");
|
||||
expect(detail).toContain("finish=stop");
|
||||
expect(detail).toContain("output=2 chars");
|
||||
expect(detail).toContain("usage=12/2 tokens");
|
||||
});
|
||||
});
|
||||
@@ -184,7 +184,7 @@ describe("HttpChecker", () => {
|
||||
test("成功请求 200", async () => {
|
||||
const result = await checker.execute(makeTarget({ url: `${baseUrl}/ok` }), makeCtx());
|
||||
expect(result.matched).toBe(true);
|
||||
expect(result.statusDetail).toBe("HTTP 200");
|
||||
expect(result.observation).toMatchObject({ statusCode: 200 });
|
||||
expect(result.durationMs).not.toBeNull();
|
||||
expect(result.failure).toBeNull();
|
||||
});
|
||||
@@ -192,7 +192,7 @@ describe("HttpChecker", () => {
|
||||
test("404 不匹配默认 status [200]", async () => {
|
||||
const result = await checker.execute(makeTarget({ url: `${baseUrl}/notfound` }), makeCtx());
|
||||
expect(result.matched).toBe(false);
|
||||
expect(result.statusDetail).toBe("HTTP 404");
|
||||
expect(result.observation).toMatchObject({ bodyPreview: "not found", statusCode: 404 });
|
||||
expect(result.failure!.phase).toBe("status");
|
||||
});
|
||||
|
||||
@@ -218,6 +218,7 @@ describe("HttpChecker", () => {
|
||||
makeCtx(),
|
||||
);
|
||||
expect(result.matched).toBe(false);
|
||||
expect(result.observation).toMatchObject({ bodyPreview: "hello world", statusCode: 200 });
|
||||
expect(result.failure!.phase).toBe("headers");
|
||||
});
|
||||
|
||||
@@ -328,13 +329,13 @@ describe("HttpChecker", () => {
|
||||
test("maxRedirects=0 不跟随重定向", async () => {
|
||||
const result = await checker.execute(makeTarget({ maxRedirects: 0, url: `${baseUrl}/redirect` }), makeCtx());
|
||||
expect(result.matched).toBe(false);
|
||||
expect(result.statusDetail).toBe("HTTP 301");
|
||||
expect(result.observation).toMatchObject({ statusCode: 301 });
|
||||
});
|
||||
|
||||
test("maxRedirects>0 跟随重定向", async () => {
|
||||
const result = await checker.execute(makeTarget({ maxRedirects: 5, url: `${baseUrl}/redirect` }), makeCtx());
|
||||
expect(result.matched).toBe(true);
|
||||
expect(result.statusDetail).toBe("HTTP 200");
|
||||
expect(result.observation).toMatchObject({ statusCode: 200 });
|
||||
});
|
||||
|
||||
test("maxRedirects 精确限制跟随次数", async () => {
|
||||
@@ -343,7 +344,7 @@ describe("HttpChecker", () => {
|
||||
makeCtx(),
|
||||
);
|
||||
expect(result.matched).toBe(false);
|
||||
expect(result.statusDetail).toBe("HTTP 302");
|
||||
expect(result.observation).toMatchObject({ statusCode: 302 });
|
||||
});
|
||||
|
||||
test("maxRedirects 允许足够次数时到达最终目标", async () => {
|
||||
@@ -352,7 +353,7 @@ describe("HttpChecker", () => {
|
||||
makeCtx(),
|
||||
);
|
||||
expect(result.matched).toBe(true);
|
||||
expect(result.statusDetail).toBe("HTTP 200");
|
||||
expect(result.observation).toMatchObject({ statusCode: 200 });
|
||||
});
|
||||
|
||||
test("ignoreSSL 跳过自签名证书校验", async () => {
|
||||
@@ -370,14 +371,14 @@ describe("HttpChecker", () => {
|
||||
makeCtx(),
|
||||
);
|
||||
expect(strictResult.matched).toBe(false);
|
||||
expect(strictResult.statusDetail).toBeNull();
|
||||
expect(strictResult.observation).toBeNull();
|
||||
|
||||
const ignoredResult = await checker.execute(
|
||||
makeTarget({ ignoreSSL: true, url: `https://localhost:${httpsServer.port}/` }),
|
||||
makeCtx(),
|
||||
);
|
||||
expect(ignoredResult.matched).toBe(true);
|
||||
expect(ignoredResult.statusDetail).toBe("HTTP 200");
|
||||
expect(ignoredResult.observation).toMatchObject({ statusCode: 200 });
|
||||
} finally {
|
||||
void httpsServer.stop();
|
||||
}
|
||||
@@ -594,7 +595,7 @@ describe("HttpChecker", () => {
|
||||
makeCtx(),
|
||||
);
|
||||
expect(result.matched).toBe(true);
|
||||
expect(result.statusDetail).toBe("HTTP 200");
|
||||
expect(result.observation).toMatchObject({ statusCode: 200 });
|
||||
});
|
||||
|
||||
test("混合 body rules 集成检查", async () => {
|
||||
|
||||
@@ -8,12 +8,12 @@ function makeTarget(overrides?: Partial<ResolvedPingTarget>): ResolvedPingTarget
|
||||
return {
|
||||
description: null,
|
||||
group: "default",
|
||||
icmp: { count: 3, host: "10.0.0.1", packetSize: 56 },
|
||||
id: "test",
|
||||
intervalMs: 30000,
|
||||
name: null,
|
||||
ping: { count: 3, host: "10.0.0.1", packetSize: 56 },
|
||||
timeoutMs: 10000,
|
||||
type: "ping",
|
||||
type: "icmp",
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
@@ -46,7 +46,7 @@ describe("buildPingCommand", () => {
|
||||
|
||||
test("自定义 count 和 packetSize", () => {
|
||||
const cmd = buildPingCommand(
|
||||
makeTarget({ ping: { count: 5, host: "10.0.0.1", packetSize: 1472 }, timeoutMs: 5000 }),
|
||||
makeTarget({ icmp: { count: 5, host: "10.0.0.1", packetSize: 1472 }, timeoutMs: 5000 }),
|
||||
"linux",
|
||||
);
|
||||
expect(cmd).toEqual(["ping", "-c", "5", "-s", "1472", "-W", "5", "10.0.0.1"]);
|
||||
|
||||
@@ -5,7 +5,7 @@ import type { CheckerContext } from "../../../../../src/server/checker/runner/ty
|
||||
|
||||
import { IcmpChecker } from "../../../../../src/server/checker/runner/icmp/execute";
|
||||
|
||||
const checker = new IcmpChecker();
|
||||
const checker = new IcmpChecker("linux");
|
||||
const originalSpawn = Bun.spawn;
|
||||
|
||||
afterEach(() => {
|
||||
@@ -21,12 +21,12 @@ function makeTarget(overrides?: Partial<ResolvedPingTarget>): ResolvedPingTarget
|
||||
return {
|
||||
description: null,
|
||||
group: "default",
|
||||
icmp: { count: 3, host: "127.0.0.1", packetSize: 56 },
|
||||
id: "ping-local",
|
||||
intervalMs: 30000,
|
||||
name: null,
|
||||
ping: { count: 3, host: "127.0.0.1", packetSize: 56 },
|
||||
timeoutMs: 10000,
|
||||
type: "ping",
|
||||
type: "icmp",
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
@@ -54,7 +54,13 @@ rtt min/avg/max/mdev = 1.234/2.345/3.456/0.567 ms`);
|
||||
const result = await checker.execute(makeTarget(), makeCtx());
|
||||
expect(result.matched).toBe(true);
|
||||
expect(result.failure).toBeNull();
|
||||
expect(result.statusDetail).toBe("alive, avg 2.345ms, loss 0% (3/3)");
|
||||
expect(result.observation).toMatchObject({
|
||||
alive: true,
|
||||
avgLatencyMs: 2.345,
|
||||
packetLoss: 0,
|
||||
received: 3,
|
||||
transmitted: 3,
|
||||
});
|
||||
expect(calls[0]).toContain("ping");
|
||||
});
|
||||
|
||||
@@ -66,7 +72,7 @@ rtt min/avg/max/mdev = 1.234/2.345/3.456/0.567 ms`);
|
||||
);
|
||||
expect(result.matched).toBe(false);
|
||||
expect(result.failure?.phase).toBe("alive");
|
||||
expect(result.statusDetail).toBe("unreachable (0/3 received)");
|
||||
expect(result.observation).toMatchObject({ alive: false, received: 0, transmitted: 3 });
|
||||
});
|
||||
|
||||
test("反向 alive 断言通过", async () => {
|
||||
@@ -81,24 +87,24 @@ rtt min/avg/max/mdev = 1.234/156.000/340.000/0.567 ms`);
|
||||
const result = await checker.execute(makeTarget({ expect: { packetLossPercent: { lte: 10 } } }), makeCtx());
|
||||
expect(result.matched).toBe(false);
|
||||
expect(result.failure?.phase).toBe("packetLoss");
|
||||
expect(result.statusDetail).toContain("max 340ms");
|
||||
expect(result.observation).toMatchObject({ alive: true, maxLatencyMs: 340 });
|
||||
});
|
||||
|
||||
test("解析失败返回结构化错误", async () => {
|
||||
mockSpawn("unexpected output");
|
||||
const result = await checker.execute(makeTarget(), makeCtx());
|
||||
expect(result.matched).toBe(false);
|
||||
expect(result.failure).toMatchObject({ kind: "error", path: "parse", phase: "ping" });
|
||||
expect(result.failure).toMatchObject({ kind: "error", path: "parse", phase: "icmp" });
|
||||
});
|
||||
|
||||
test("spawn 失败返回 ping 命令不可用", async () => {
|
||||
test("spawn 失败返回 icmp 命令不可用", async () => {
|
||||
Bun.spawn = mock(() => {
|
||||
throw new Error("ENOENT");
|
||||
});
|
||||
const result = await checker.execute(makeTarget(), makeCtx());
|
||||
expect(result.matched).toBe(false);
|
||||
expect(result.failure?.message).toContain("ping 命令不可用");
|
||||
expect(result.statusDetail).toBe("ping command not found");
|
||||
expect(result.failure?.message).toContain("icmp 命令不可用");
|
||||
expect(result.observation).toBeNull();
|
||||
});
|
||||
|
||||
test("预 abort 返回超时错误", async () => {
|
||||
@@ -107,23 +113,23 @@ rtt min/avg/max/mdev = 1.234/156.000/340.000/0.567 ms`);
|
||||
controller.abort();
|
||||
const result = await checker.execute(makeTarget(), { signal: controller.signal });
|
||||
expect(result.matched).toBe(false);
|
||||
expect(result.failure).toMatchObject({ path: "timeout", phase: "ping" });
|
||||
expect(result.failure).toMatchObject({ path: "timeout", phase: "icmp" });
|
||||
});
|
||||
});
|
||||
|
||||
describe("IcmpChecker resolve", () => {
|
||||
test("解析默认值", () => {
|
||||
const target = checker.resolve(
|
||||
{ id: "ping", ping: { host: "10.0.0.1" }, type: "ping" },
|
||||
{ icmp: { host: "10.0.0.1" }, id: "ping", type: "icmp" },
|
||||
{ configDir: "/tmp", defaultIntervalMs: 30000, defaults: {}, defaultTimeoutMs: 10000 },
|
||||
);
|
||||
expect(target.ping).toEqual({ count: 3, host: "10.0.0.1", packetSize: 56 });
|
||||
expect(target.icmp).toEqual({ count: 3, host: "10.0.0.1", packetSize: 56 });
|
||||
expect(target.group).toBe("default");
|
||||
});
|
||||
|
||||
test("serialize 返回摘要和配置", () => {
|
||||
const serialized = checker.serialize(makeTarget({ ping: { count: 5, host: "10.0.0.1", packetSize: 1472 } }));
|
||||
expect(serialized.target).toBe("ping 10.0.0.1");
|
||||
const serialized = checker.serialize(makeTarget({ icmp: { count: 5, host: "10.0.0.1", packetSize: 1472 } }));
|
||||
expect(serialized.target).toBe("icmp 10.0.0.1");
|
||||
expect(JSON.parse(serialized.config)).toEqual({ count: 5, host: "10.0.0.1", packetSize: 1472 });
|
||||
});
|
||||
});
|
||||
|
||||
@@ -10,61 +10,61 @@ function validate(target: RawTargetConfig) {
|
||||
|
||||
describe("validatePingConfig", () => {
|
||||
test("有效配置无错误", () => {
|
||||
expect(validate({ id: "ping", ping: { count: 3, host: "127.0.0.1", packetSize: 56 }, type: "ping" })).toEqual([]);
|
||||
expect(validate({ icmp: { count: 3, host: "127.0.0.1", packetSize: 56 }, id: "icmp", type: "icmp" })).toEqual([]);
|
||||
});
|
||||
|
||||
test("host 缺失", () => {
|
||||
const issues = validate({ id: "ping", ping: {}, type: "ping" });
|
||||
expect(issues.some((item) => item.path.endsWith("ping.host"))).toBe(true);
|
||||
const issues = validate({ icmp: {}, id: "icmp", type: "icmp" });
|
||||
expect(issues.some((item) => item.path.endsWith("icmp.host"))).toBe(true);
|
||||
});
|
||||
|
||||
test("host 类型非法", () => {
|
||||
const issues = validate({ id: "ping", ping: { host: 123 }, type: "ping" });
|
||||
expect(issues.some((item) => item.path.endsWith("ping.host"))).toBe(true);
|
||||
const issues = validate({ icmp: { host: 123 }, id: "icmp", type: "icmp" });
|
||||
expect(issues.some((item) => item.path.endsWith("icmp.host"))).toBe(true);
|
||||
});
|
||||
|
||||
test("count 非法", () => {
|
||||
const issues = validate({ id: "ping", ping: { count: 0, host: "127.0.0.1" }, type: "ping" });
|
||||
expect(issues.some((item) => item.path.endsWith("ping.count"))).toBe(true);
|
||||
const issues = validate({ icmp: { count: 0, host: "127.0.0.1" }, id: "icmp", type: "icmp" });
|
||||
expect(issues.some((item) => item.path.endsWith("icmp.count"))).toBe(true);
|
||||
});
|
||||
|
||||
test("packetSize 非法", () => {
|
||||
const issues = validate({ id: "ping", ping: { host: "127.0.0.1", packetSize: 65501 }, type: "ping" });
|
||||
expect(issues.some((item) => item.path.endsWith("ping.packetSize"))).toBe(true);
|
||||
const issues = validate({ icmp: { host: "127.0.0.1", packetSize: 65501 }, id: "icmp", type: "icmp" });
|
||||
expect(issues.some((item) => item.path.endsWith("icmp.packetSize"))).toBe(true);
|
||||
});
|
||||
|
||||
test("ping 未知字段", () => {
|
||||
const issues = validate({ id: "ping", ping: { host: "127.0.0.1", timeout: 5 }, type: "ping" });
|
||||
expect(issues.some((item) => item.code === "unknown-field" && item.path.endsWith("ping.timeout"))).toBe(true);
|
||||
test("icmp 未知字段", () => {
|
||||
const issues = validate({ icmp: { host: "127.0.0.1", timeout: 5 }, id: "icmp", type: "icmp" });
|
||||
expect(issues.some((item) => item.code === "unknown-field" && item.path.endsWith("icmp.timeout"))).toBe(true);
|
||||
});
|
||||
|
||||
test("expect 未知字段", () => {
|
||||
const issues = validate({ expect: { status: [200] }, id: "ping", ping: { host: "127.0.0.1" }, type: "ping" });
|
||||
const issues = validate({ expect: { status: [200] }, icmp: { host: "127.0.0.1" }, id: "icmp", type: "icmp" });
|
||||
expect(issues.some((item) => item.path.endsWith("expect.status"))).toBe(true);
|
||||
});
|
||||
|
||||
test("expect 数值旧字段非法", () => {
|
||||
const issues = validate({ expect: { maxPacketLoss: 101 }, id: "ping", ping: { host: "127.0.0.1" }, type: "ping" });
|
||||
const issues = validate({ expect: { maxPacketLoss: 101 }, icmp: { host: "127.0.0.1" }, id: "icmp", type: "icmp" });
|
||||
expect(issues.some((item) => item.path.endsWith("expect.maxPacketLoss"))).toBe(true);
|
||||
});
|
||||
|
||||
test("durationMs 数组简写非法", () => {
|
||||
const issues = validate({ expect: { durationMs: [1, 2] }, id: "ping", ping: { host: "127.0.0.1" }, type: "ping" });
|
||||
const issues = validate({ expect: { durationMs: [1, 2] }, icmp: { host: "127.0.0.1" }, id: "icmp", type: "icmp" });
|
||||
expect(issues.some((item) => item.path.endsWith("expect.durationMs"))).toBe(true);
|
||||
});
|
||||
|
||||
test("avgLatencyMs 对象简写非法", () => {
|
||||
const issues = validate({
|
||||
expect: { avgLatencyMs: { foo: "bar" } },
|
||||
id: "ping",
|
||||
ping: { host: "127.0.0.1" },
|
||||
type: "ping",
|
||||
icmp: { host: "127.0.0.1" },
|
||||
id: "icmp",
|
||||
type: "icmp",
|
||||
});
|
||||
expect(issues.some((item) => item.path.endsWith("expect.avgLatencyMs.foo"))).toBe(true);
|
||||
});
|
||||
|
||||
test("host 为空字符串", () => {
|
||||
const issues = validate({ id: "ping", ping: { host: " " }, type: "ping" });
|
||||
expect(issues.some((item) => item.path.endsWith("ping.host"))).toBe(true);
|
||||
const issues = validate({ icmp: { host: " " }, id: "icmp", type: "icmp" });
|
||||
expect(issues.some((item) => item.path.endsWith("icmp.host"))).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -111,10 +111,10 @@ describe("LlmChecker execute - 非流式", () => {
|
||||
const result = await checker.execute(makeTarget(), makeCtx());
|
||||
expect(result.matched).toBe(true);
|
||||
expect(result.failure).toBeNull();
|
||||
expect(result.statusDetail).toContain("openai");
|
||||
expect(result.statusDetail).toContain("http");
|
||||
expect(result.statusDetail).toContain("200");
|
||||
expect(result.statusDetail).toContain("finish=stop");
|
||||
expect(result.observation).toMatchObject({ provider: "openai" });
|
||||
expect(result.observation).toMatchObject({ mode: "http" });
|
||||
expect(result.observation).toMatchObject({ http: { status: 200 } });
|
||||
expect(result.observation).toMatchObject({ finishReason: "stop" });
|
||||
});
|
||||
|
||||
test("status expect 不匹配", async () => {
|
||||
@@ -168,12 +168,11 @@ describe("LlmChecker execute - 非流式", () => {
|
||||
expect(result.failure?.phase).toBe("request");
|
||||
});
|
||||
|
||||
test("statusDetail 包含 output 长度和 usage", async () => {
|
||||
test("observation 包含 output 长度和 usage", async () => {
|
||||
const result = await checker.execute(makeTarget(), makeCtx());
|
||||
expect(result.statusDetail).toContain("output=");
|
||||
expect(result.statusDetail).toContain("chars");
|
||||
expect(result.statusDetail).toContain("usage=");
|
||||
expect(result.statusDetail).toContain("tokens");
|
||||
expect(result.observation).toHaveProperty("outputPreview");
|
||||
expect(result.observation).toHaveProperty("outputLength");
|
||||
expect(result.observation).toHaveProperty("usage");
|
||||
});
|
||||
|
||||
test("无文本输出且配置 output expect 失败", async () => {
|
||||
|
||||
@@ -9,6 +9,7 @@ import { CheckerRegistry } from "../../../../src/server/checker/runner/registry"
|
||||
|
||||
function createChecker(type: string): Checker {
|
||||
return {
|
||||
buildDetail: () => null,
|
||||
configKey: type,
|
||||
execute: () => Promise.resolve<CheckResult>({} as unknown as CheckResult),
|
||||
resolve: () => ({}) as unknown as ResolvedTargetBase,
|
||||
@@ -66,8 +67,8 @@ describe("CheckerRegistry", () => {
|
||||
const second = createDefaultCheckerRegistry();
|
||||
first.register(createChecker("custom"));
|
||||
|
||||
expect(first.supportedTypes).toEqual(["http", "cmd", "db", "tcp", "ping", "udp", "llm", "custom"]);
|
||||
expect(second.supportedTypes).toEqual(["http", "cmd", "db", "tcp", "ping", "udp", "llm"]);
|
||||
expect(first.supportedTypes).toEqual(["http", "cmd", "db", "tcp", "icmp", "udp", "llm", "custom"]);
|
||||
expect(second.supportedTypes).toEqual(["http", "cmd", "db", "tcp", "icmp", "udp", "llm"]);
|
||||
expect(
|
||||
first.definitions.every(
|
||||
(checker) => checker.schemas.config && checker.schemas.defaults && checker.schemas.expect,
|
||||
@@ -75,9 +76,9 @@ describe("CheckerRegistry", () => {
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
test("默认 registry 注册 ping type", () => {
|
||||
test("默认 registry 注册 icmp type", () => {
|
||||
const registry = createDefaultCheckerRegistry();
|
||||
expect(registry.supportedTypes).toContain("ping");
|
||||
expect(registry.get("ping").configKey).toBe("ping");
|
||||
expect(registry.supportedTypes).toContain("icmp");
|
||||
expect(registry.get("icmp").configKey).toBe("icmp");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -40,9 +40,9 @@ describe("ValueMatcher primitive shorthand in checker validators", () => {
|
||||
},
|
||||
{
|
||||
expect: { avgLatencyMs: 1, durationMs: 100, maxLatencyMs: 2, packetLossPercent: 0 },
|
||||
id: "ping",
|
||||
ping: { host: "127.0.0.1" },
|
||||
type: "ping",
|
||||
icmp: { host: "127.0.0.1" },
|
||||
id: "icmp",
|
||||
type: "icmp",
|
||||
validate: validatePingConfig,
|
||||
},
|
||||
{
|
||||
|
||||
@@ -127,7 +127,7 @@ describe("TcpChecker execute", () => {
|
||||
expect(result.matched).toBe(true);
|
||||
expect(result.failure).toBeNull();
|
||||
expect(result.durationMs).toBeGreaterThanOrEqual(0);
|
||||
expect(result.statusDetail).toMatch(/^connected in \d+ms$/);
|
||||
expect(result.observation).toMatchObject({ connected: true });
|
||||
});
|
||||
|
||||
test("TCP 连接失败", async () => {
|
||||
@@ -145,7 +145,7 @@ describe("TcpChecker execute", () => {
|
||||
);
|
||||
expect(result.matched).toBe(true);
|
||||
expect(result.failure).toBeNull();
|
||||
expect(result.statusDetail).toBeTruthy();
|
||||
expect(result.observation).toMatchObject({ connected: false });
|
||||
});
|
||||
|
||||
test("期望端口不可达但连接成功", async () => {
|
||||
@@ -171,8 +171,11 @@ describe("TcpChecker execute", () => {
|
||||
makeCtx(),
|
||||
);
|
||||
expect(result.matched).toBe(true);
|
||||
expect(result.statusDetail).toContain("banner:");
|
||||
expect(result.statusDetail).toContain("220 smtp.example.com ESMTP");
|
||||
const obs = result.observation!;
|
||||
expect(obs).toMatchObject({
|
||||
connected: true,
|
||||
});
|
||||
expect(obs["banner"]).toContain("220 smtp.example.com ESMTP");
|
||||
});
|
||||
|
||||
test("banner operator 校验通过", async () => {
|
||||
@@ -206,7 +209,7 @@ describe("TcpChecker execute", () => {
|
||||
makeCtx(),
|
||||
);
|
||||
expect(result.matched).toBe(true);
|
||||
expect(result.statusDetail).not.toContain("banner:");
|
||||
expect(result.observation?.["banner"]).toBeFalsy();
|
||||
});
|
||||
|
||||
test("banner 超时空字符串继续执行", async () => {
|
||||
|
||||
@@ -60,7 +60,7 @@ describe("UdpChecker execute", () => {
|
||||
cleanup();
|
||||
expect(result.matched).toBe(true);
|
||||
expect(result.failure).toBeNull();
|
||||
expect(result.statusDetail).toContain("responded");
|
||||
expect(result.observation).toMatchObject({ responded: true });
|
||||
expect(result.durationMs).toBeGreaterThanOrEqual(0);
|
||||
} finally {
|
||||
server.close();
|
||||
@@ -103,7 +103,10 @@ describe("UdpChecker execute", () => {
|
||||
const result = await checker.execute(target, { signal: controller.signal });
|
||||
clearTimeout(timer);
|
||||
expect(result.matched).toBe(false);
|
||||
expect(result.failure).not.toBeNull();
|
||||
expect(result.failure).toMatchObject({ kind: "error", phase: "response" });
|
||||
expect(result.observation?.["durationMs"]).toBeGreaterThanOrEqual(0);
|
||||
expect(result.observation?.["error"]).toBeTruthy();
|
||||
expect(result.observation).toMatchObject({ responded: false });
|
||||
} finally {
|
||||
server.close();
|
||||
}
|
||||
@@ -131,7 +134,8 @@ describe("UdpChecker execute", () => {
|
||||
const result = await checker.execute(target, { signal: controller.signal });
|
||||
clearTimeout(timer);
|
||||
expect(result.matched).toBe(true);
|
||||
expect(result.statusDetail).toContain("no response");
|
||||
expect(result.observation?.["durationMs"]).toBeGreaterThanOrEqual(0);
|
||||
expect(result.observation).toMatchObject({ error: null, responded: false });
|
||||
} finally {
|
||||
server.close();
|
||||
}
|
||||
@@ -223,7 +227,7 @@ describe("UdpChecker execute", () => {
|
||||
const result = await checker.execute(target, { signal });
|
||||
cleanup();
|
||||
expect(result.matched).toBe(true);
|
||||
expect(result.statusDetail).toContain("5 bytes");
|
||||
expect(result.observation).toMatchObject({ responded: true, responseSize: 5 });
|
||||
} finally {
|
||||
server.close();
|
||||
}
|
||||
|
||||
@@ -134,7 +134,7 @@ describe("ProbeStore", () => {
|
||||
expect(store.getTargets()).toHaveLength(2);
|
||||
});
|
||||
|
||||
test("同步删除 target", () => {
|
||||
test("同步软删除 target", () => {
|
||||
store.syncTargets([httpTarget]);
|
||||
const targets = store.getTargets();
|
||||
expect(targets).toHaveLength(1);
|
||||
@@ -163,7 +163,7 @@ describe("ProbeStore", () => {
|
||||
durationMs: 150.5,
|
||||
failure: null,
|
||||
matched: true,
|
||||
statusDetail: "200 OK",
|
||||
observation: null,
|
||||
targetId: "test-http",
|
||||
timestamp: "2025-01-01T00:00:00.000Z",
|
||||
});
|
||||
@@ -172,7 +172,7 @@ describe("ProbeStore", () => {
|
||||
durationMs: 300,
|
||||
failure: null,
|
||||
matched: true,
|
||||
statusDetail: "200 OK",
|
||||
observation: null,
|
||||
targetId: "test-http",
|
||||
timestamp: "2025-01-01T00:00:30.000Z",
|
||||
});
|
||||
@@ -190,7 +190,7 @@ describe("ProbeStore", () => {
|
||||
durationMs: null,
|
||||
failure,
|
||||
matched: false,
|
||||
statusDetail: null,
|
||||
observation: null,
|
||||
targetId: "test-http",
|
||||
timestamp: "2025-01-01T00:01:00.000Z",
|
||||
});
|
||||
@@ -214,7 +214,7 @@ describe("ProbeStore", () => {
|
||||
durationMs: 100 + i,
|
||||
failure: null,
|
||||
matched: true,
|
||||
statusDetail: "200 OK",
|
||||
observation: null,
|
||||
targetId: "test-http",
|
||||
timestamp: `2025-01-01T01:${String(i).padStart(2, "0")}:00.000Z`,
|
||||
});
|
||||
@@ -302,7 +302,7 @@ describe("ProbeStore", () => {
|
||||
durationMs: 100 + index,
|
||||
failure: null,
|
||||
matched: index !== 1,
|
||||
statusDetail: "200 OK",
|
||||
observation: null,
|
||||
targetId: targetAId,
|
||||
timestamp,
|
||||
});
|
||||
@@ -311,7 +311,7 @@ describe("ProbeStore", () => {
|
||||
durationMs: 200,
|
||||
failure: null,
|
||||
matched: true,
|
||||
statusDetail: "200 OK",
|
||||
observation: null,
|
||||
targetId: targetBId,
|
||||
timestamp: "2025-01-01T00:03:00.000Z",
|
||||
});
|
||||
@@ -319,7 +319,7 @@ describe("ProbeStore", () => {
|
||||
durationMs: null,
|
||||
failure: { kind: "error", message: "fail", path: "request", phase: "request" },
|
||||
matched: false,
|
||||
statusDetail: null,
|
||||
observation: null,
|
||||
targetId: targetBId,
|
||||
timestamp: "2025-01-01T00:04:00.000Z",
|
||||
});
|
||||
@@ -346,7 +346,7 @@ describe("ProbeStore", () => {
|
||||
expect(closedStore.getTargetById("closed-target")).toBeNull();
|
||||
});
|
||||
|
||||
test("删除 target 级联删除 check_results", () => {
|
||||
test("移除 target 软删除保留 check_results", () => {
|
||||
const cascadeStore = new ProbeStore(join(tempDir, "cascade.db"));
|
||||
const cascadeTarget: ResolvedHttpTarget = {
|
||||
description: null,
|
||||
@@ -373,7 +373,7 @@ describe("ProbeStore", () => {
|
||||
durationMs: 100,
|
||||
failure: null,
|
||||
matched: true,
|
||||
statusDetail: "200 OK",
|
||||
observation: null,
|
||||
targetId: t.id,
|
||||
timestamp: "2025-01-01T00:00:00.000Z",
|
||||
});
|
||||
@@ -381,7 +381,7 @@ describe("ProbeStore", () => {
|
||||
durationMs: null,
|
||||
failure: { kind: "error", message: "fail", path: "$", phase: "status" },
|
||||
matched: false,
|
||||
statusDetail: null,
|
||||
observation: null,
|
||||
targetId: t.id,
|
||||
timestamp: "2025-01-01T00:01:00.000Z",
|
||||
});
|
||||
@@ -391,7 +391,8 @@ describe("ProbeStore", () => {
|
||||
cascadeStore.syncTargets([]);
|
||||
|
||||
expect(cascadeStore.getTargets()).toHaveLength(0);
|
||||
expect(cascadeStore.getLatestCheck(t.id)).toBeNull();
|
||||
expect(cascadeStore.getLatestCheck(t.id)).not.toBeNull();
|
||||
expect(cascadeStore.getHistory(t.id, "2000-01-01T00:00:00.000Z", "2099-12-31T23:59:59.999Z").total).toBe(2);
|
||||
|
||||
cascadeStore.close();
|
||||
});
|
||||
@@ -493,7 +494,7 @@ describe("ProbeStore", () => {
|
||||
durationMs: 100,
|
||||
failure: null,
|
||||
matched,
|
||||
statusDetail: matched ? "200 OK" : "500 ERROR",
|
||||
observation: null,
|
||||
targetId,
|
||||
timestamp: `2025-01-01T00:0${index}:00.000Z`,
|
||||
});
|
||||
@@ -535,7 +536,7 @@ describe("ProbeStore", () => {
|
||||
durationMs: 100,
|
||||
failure: null,
|
||||
matched: false,
|
||||
statusDetail: null,
|
||||
observation: null,
|
||||
targetId: targetBId,
|
||||
timestamp: "2025-01-01T00:03:00.000Z",
|
||||
});
|
||||
@@ -543,7 +544,7 @@ describe("ProbeStore", () => {
|
||||
durationMs: 100,
|
||||
failure: null,
|
||||
matched: true,
|
||||
statusDetail: "200 OK",
|
||||
observation: null,
|
||||
targetId: targetAId,
|
||||
timestamp: "2025-01-01T00:02:00.000Z",
|
||||
});
|
||||
@@ -551,7 +552,7 @@ describe("ProbeStore", () => {
|
||||
durationMs: 100,
|
||||
failure: null,
|
||||
matched: false,
|
||||
statusDetail: null,
|
||||
observation: null,
|
||||
targetId: targetAId,
|
||||
timestamp: "2025-01-01T00:01:00.000Z",
|
||||
});
|
||||
@@ -574,7 +575,7 @@ describe("ProbeStore", () => {
|
||||
durationMs: 100,
|
||||
failure: null,
|
||||
matched: true,
|
||||
statusDetail: "200 OK",
|
||||
observation: null,
|
||||
targetId: t.id,
|
||||
timestamp: "2020-01-01T00:00:00.000Z",
|
||||
});
|
||||
@@ -582,7 +583,7 @@ describe("ProbeStore", () => {
|
||||
durationMs: 100,
|
||||
failure: null,
|
||||
matched: true,
|
||||
statusDetail: "200 OK",
|
||||
observation: null,
|
||||
targetId: t.id,
|
||||
timestamp: new Date().toISOString(),
|
||||
});
|
||||
@@ -605,7 +606,7 @@ describe("ProbeStore", () => {
|
||||
durationMs: 100,
|
||||
failure: null,
|
||||
matched: true,
|
||||
statusDetail: "200 OK",
|
||||
observation: null,
|
||||
targetId: t.id,
|
||||
timestamp: new Date().toISOString(),
|
||||
});
|
||||
@@ -626,7 +627,7 @@ describe("ProbeStore", () => {
|
||||
durationMs: 100,
|
||||
failure: null,
|
||||
matched: true,
|
||||
statusDetail: "200 OK",
|
||||
observation: null,
|
||||
targetId: t.id,
|
||||
timestamp: new Date(now - 3600000).toISOString(),
|
||||
});
|
||||
@@ -634,7 +635,7 @@ describe("ProbeStore", () => {
|
||||
durationMs: 200,
|
||||
failure: null,
|
||||
matched: true,
|
||||
statusDetail: "200 OK",
|
||||
observation: null,
|
||||
targetId: t.id,
|
||||
timestamp: new Date(now).toISOString(),
|
||||
});
|
||||
@@ -686,4 +687,185 @@ describe("ProbeStore", () => {
|
||||
expect(t.name).toBeNull();
|
||||
nullNameStore.close();
|
||||
});
|
||||
|
||||
test("targets 表 active 列默认值为 1", () => {
|
||||
const activeStore = new ProbeStore(join(tempDir, "active-default.db"));
|
||||
activeStore.syncTargets([{ ...httpTarget, id: "active-test", name: "active-test" }]);
|
||||
const t = activeStore.getTargets()[0]!;
|
||||
expect(t.active).toBe(1);
|
||||
activeStore.close();
|
||||
});
|
||||
|
||||
test("check_results 外键约束为 RESTRICT", () => {
|
||||
const restrictStore = new ProbeStore(join(tempDir, "restrict.db"));
|
||||
restrictStore.syncTargets([{ ...httpTarget, id: "restrict-test", name: "restrict-test" }]);
|
||||
const t = restrictStore.getTargets()[0]!;
|
||||
restrictStore.insertCheckResult({
|
||||
durationMs: 100,
|
||||
failure: null,
|
||||
matched: true,
|
||||
observation: null,
|
||||
targetId: t.id,
|
||||
timestamp: "2025-01-01T00:00:00.000Z",
|
||||
});
|
||||
expect(() => {
|
||||
restrictStore.deleteTargetRaw(t.id);
|
||||
}).toThrow();
|
||||
restrictStore.close();
|
||||
});
|
||||
|
||||
test("syncTargets 新增目标 active=1", () => {
|
||||
const softStore = new ProbeStore(join(tempDir, "soft-insert.db"));
|
||||
softStore.syncTargets([{ ...httpTarget, id: "soft-a", name: "soft-a" }]);
|
||||
const t = softStore.getTargets()[0]!;
|
||||
expect(t.active).toBe(1);
|
||||
softStore.close();
|
||||
});
|
||||
|
||||
test("syncTargets 移除目标设置 active=0", () => {
|
||||
const softStore = new ProbeStore(join(tempDir, "soft-remove.db"));
|
||||
softStore.syncTargets([
|
||||
{ ...httpTarget, id: "soft-remove-a", name: "soft-remove-a" },
|
||||
{ ...httpTarget, id: "soft-remove-b", name: "soft-remove-b" },
|
||||
]);
|
||||
expect(softStore.getTargets()).toHaveLength(2);
|
||||
|
||||
softStore.syncTargets([{ ...httpTarget, id: "soft-remove-a", name: "soft-remove-a" }]);
|
||||
expect(softStore.getTargets()).toHaveLength(1);
|
||||
expect(softStore.getTargets()[0]!.id).toBe("soft-remove-a");
|
||||
|
||||
expect(softStore.getTargetActive("soft-remove-b")).toBe(0);
|
||||
|
||||
softStore.close();
|
||||
});
|
||||
|
||||
test("syncTargets 恢复已移除目标 active=1", () => {
|
||||
const restoreStore = new ProbeStore(join(tempDir, "soft-restore.db"));
|
||||
restoreStore.syncTargets([{ ...httpTarget, id: "restore-a", name: "restore-a" }]);
|
||||
|
||||
restoreStore.insertCheckResult({
|
||||
durationMs: 100,
|
||||
failure: null,
|
||||
matched: true,
|
||||
observation: null,
|
||||
targetId: "restore-a",
|
||||
timestamp: "2025-01-01T00:00:00.000Z",
|
||||
});
|
||||
|
||||
restoreStore.syncTargets([]);
|
||||
expect(restoreStore.getTargets()).toHaveLength(0);
|
||||
|
||||
restoreStore.syncTargets([{ ...httpTarget, id: "restore-a", name: "restore-a-updated" }]);
|
||||
expect(restoreStore.getTargets()).toHaveLength(1);
|
||||
expect(restoreStore.getTargets()[0]!.active).toBe(1);
|
||||
expect(restoreStore.getTargets()[0]!.name).toBe("restore-a-updated");
|
||||
|
||||
const history = restoreStore.getHistory("restore-a", "2000-01-01T00:00:00.000Z", "2099-12-31T23:59:59.999Z");
|
||||
expect(history.total).toBe(1);
|
||||
|
||||
restoreStore.close();
|
||||
});
|
||||
|
||||
test("syncTargets 更新属性同时保持 active=1", () => {
|
||||
const updateStore = new ProbeStore(join(tempDir, "soft-update.db"));
|
||||
updateStore.syncTargets([{ ...httpTarget, id: "update-active", name: "old-name" }]);
|
||||
|
||||
updateStore.syncTargets([{ ...httpTarget, id: "update-active", name: "new-name" }]);
|
||||
const t = updateStore.getTargets()[0]!;
|
||||
expect(t.name).toBe("new-name");
|
||||
expect(t.active).toBe(1);
|
||||
|
||||
updateStore.close();
|
||||
});
|
||||
|
||||
test("getTargets 不返回 inactive target", () => {
|
||||
const filterStore = new ProbeStore(join(tempDir, "filter-targets.db"));
|
||||
filterStore.syncTargets([
|
||||
{ ...httpTarget, id: "filter-active", name: "filter-active" },
|
||||
{ ...httpTarget, id: "filter-inactive", name: "filter-inactive" },
|
||||
]);
|
||||
filterStore.syncTargets([{ ...httpTarget, id: "filter-active", name: "filter-active" }]);
|
||||
|
||||
const targets = filterStore.getTargets();
|
||||
expect(targets).toHaveLength(1);
|
||||
expect(targets[0]!.id).toBe("filter-active");
|
||||
|
||||
filterStore.close();
|
||||
});
|
||||
|
||||
test("getTargetById 对 inactive target 返回 null", () => {
|
||||
const filterStore = new ProbeStore(join(tempDir, "filter-byid.db"));
|
||||
filterStore.syncTargets([{ ...httpTarget, id: "filter-id-test", name: "filter-id-test" }]);
|
||||
expect(filterStore.getTargetById("filter-id-test")).not.toBeNull();
|
||||
|
||||
filterStore.syncTargets([]);
|
||||
expect(filterStore.getTargetById("filter-id-test")).toBeNull();
|
||||
|
||||
filterStore.close();
|
||||
});
|
||||
|
||||
test("prune 清理空壳 inactive target", () => {
|
||||
const pruneStore = new ProbeStore(join(tempDir, "prune-shell.db"));
|
||||
pruneStore.syncTargets([{ ...httpTarget, id: "prune-shell-target", name: "prune-shell-target" }]);
|
||||
|
||||
pruneStore.insertCheckResult({
|
||||
durationMs: 100,
|
||||
failure: null,
|
||||
matched: true,
|
||||
observation: null,
|
||||
targetId: "prune-shell-target",
|
||||
timestamp: "2020-01-01T00:00:00.000Z",
|
||||
});
|
||||
|
||||
pruneStore.syncTargets([]);
|
||||
|
||||
expect(pruneStore.getTargetActive("prune-shell-target")).toBe(0);
|
||||
|
||||
pruneStore.prune(86400000);
|
||||
|
||||
expect(pruneStore.hasTargetRow("prune-shell-target")).toBeFalse();
|
||||
|
||||
pruneStore.close();
|
||||
});
|
||||
|
||||
test("prune 保留有历史数据的 inactive target", () => {
|
||||
const pruneStore = new ProbeStore(join(tempDir, "prune-keep-inactive.db"));
|
||||
pruneStore.syncTargets([{ ...httpTarget, id: "prune-keep-target", name: "prune-keep-target" }]);
|
||||
|
||||
pruneStore.insertCheckResult({
|
||||
durationMs: 100,
|
||||
failure: null,
|
||||
matched: true,
|
||||
observation: null,
|
||||
targetId: "prune-keep-target",
|
||||
timestamp: "2020-01-01T00:00:00.000Z",
|
||||
});
|
||||
pruneStore.insertCheckResult({
|
||||
durationMs: 100,
|
||||
failure: null,
|
||||
matched: true,
|
||||
observation: null,
|
||||
targetId: "prune-keep-target",
|
||||
timestamp: new Date().toISOString(),
|
||||
});
|
||||
|
||||
pruneStore.syncTargets([]);
|
||||
|
||||
pruneStore.prune(86400000);
|
||||
|
||||
expect(pruneStore.getTargetActive("prune-keep-target")).toBe(0);
|
||||
|
||||
pruneStore.close();
|
||||
});
|
||||
|
||||
test("prune 不清理无数据的 active target", () => {
|
||||
const pruneStore = new ProbeStore(join(tempDir, "prune-no-active.db"));
|
||||
pruneStore.syncTargets([{ ...httpTarget, id: "prune-active-target", name: "prune-active-target" }]);
|
||||
|
||||
pruneStore.prune(86400000);
|
||||
|
||||
expect(pruneStore.getTargetActive("prune-active-target")).toBe(1);
|
||||
|
||||
pruneStore.close();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
import { describe, expect, test } from "bun:test";
|
||||
|
||||
import { createApiError, createHeaders, formatDuration, jsonResponse } from "../../src/server/helpers";
|
||||
import type { StoredCheckResult } from "../../src/server/checker/types";
|
||||
|
||||
import { createApiError, createHeaders, formatDuration, jsonResponse, mapCheckResult } from "../../src/server/helpers";
|
||||
|
||||
describe("createApiError", () => {
|
||||
test("创建错误响应对象", () => {
|
||||
@@ -107,3 +109,42 @@ describe("formatDuration", () => {
|
||||
expect(formatDuration(61123)).toBe("61123ms");
|
||||
});
|
||||
});
|
||||
|
||||
function makeRow(overrides: Partial<StoredCheckResult> = {}): StoredCheckResult {
|
||||
return {
|
||||
duration_ms: 12,
|
||||
failure: null,
|
||||
id: 1,
|
||||
matched: 1,
|
||||
observation: null,
|
||||
target_id: "target-1",
|
||||
timestamp: "2025-01-01T00:00:00.000Z",
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
describe("mapCheckResult", () => {
|
||||
test("反序列化 observation 并构造 detail", () => {
|
||||
const result = mapCheckResult(makeRow({ observation: JSON.stringify({ statusCode: 200 }) }), "http");
|
||||
expect(result.detail).toBe("HTTP 200");
|
||||
expect(result.observation).toEqual({ statusCode: 200 });
|
||||
});
|
||||
|
||||
test("null observation 返回 null detail", () => {
|
||||
const result = mapCheckResult(makeRow(), "http");
|
||||
expect(result.detail).toBeNull();
|
||||
expect(result.observation).toBeNull();
|
||||
});
|
||||
|
||||
test("未知 type 不影响响应序列化", () => {
|
||||
const result = mapCheckResult(makeRow({ observation: JSON.stringify({ statusCode: 200 }) }), "unknown");
|
||||
expect(result.detail).toBeNull();
|
||||
expect(result.observation).toEqual({ statusCode: 200 });
|
||||
});
|
||||
|
||||
test("损坏 observation JSON 返回 null observation", () => {
|
||||
const result = mapCheckResult(makeRow({ observation: "{invalid json" }), "http");
|
||||
expect(result.detail).toBeNull();
|
||||
expect(result.observation).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -14,10 +14,11 @@ describe("OverviewTab", () => {
|
||||
id: "1",
|
||||
interval: "30s",
|
||||
latestCheck: {
|
||||
detail: "200 OK",
|
||||
durationMs: 100,
|
||||
failure: null,
|
||||
matched: true,
|
||||
statusDetail: "200 OK",
|
||||
observation: null,
|
||||
timestamp: "2025-01-15T10:00:00.000Z",
|
||||
},
|
||||
name: "test-target",
|
||||
|
||||
@@ -14,10 +14,11 @@ describe("TargetDetailDrawer", () => {
|
||||
id: "1",
|
||||
interval: "30s",
|
||||
latestCheck: {
|
||||
detail: "200 OK",
|
||||
durationMs: 100,
|
||||
failure: null,
|
||||
matched: true,
|
||||
statusDetail: "200 OK",
|
||||
observation: null,
|
||||
timestamp: "2025-01-15T10:00:00.000Z",
|
||||
},
|
||||
name: "test-target",
|
||||
|
||||
@@ -20,10 +20,11 @@ describe("TargetGroup", () => {
|
||||
id: "1",
|
||||
interval: "30s",
|
||||
latestCheck: {
|
||||
detail: "200 OK",
|
||||
durationMs: 100,
|
||||
failure: null,
|
||||
matched: true,
|
||||
statusDetail: "200 OK",
|
||||
observation: null,
|
||||
timestamp: "2025-01-15T10:00:00.000Z",
|
||||
},
|
||||
name: "target-1",
|
||||
@@ -39,10 +40,11 @@ describe("TargetGroup", () => {
|
||||
id: "2",
|
||||
interval: "30s",
|
||||
latestCheck: {
|
||||
detail: "500 Internal Server Error",
|
||||
durationMs: 100,
|
||||
failure: { kind: "error", message: "Failed", path: "$", phase: "status" },
|
||||
matched: false,
|
||||
statusDetail: "500 Internal Server Error",
|
||||
observation: null,
|
||||
timestamp: "2025-01-15T10:00:00.000Z",
|
||||
},
|
||||
name: "target-2",
|
||||
|
||||
@@ -110,26 +110,27 @@ describe("createTargetTableColumns", () => {
|
||||
colIndex: 6,
|
||||
row: makeTarget({
|
||||
latestCheck: {
|
||||
detail: "200",
|
||||
durationMs: 12000,
|
||||
failure: null,
|
||||
matched: true,
|
||||
statusDetail: "200",
|
||||
observation: null,
|
||||
timestamp: "2026-01-01T00:00:00.000Z",
|
||||
},
|
||||
}),
|
||||
rowIndex: 0,
|
||||
});
|
||||
|
||||
expect(element.props.children).toBe("9999+");
|
||||
expect(element.props.children).toEqual(["9999+", " ms"]);
|
||||
expect(element.props.className).toContain("latency-value");
|
||||
});
|
||||
|
||||
test("延迟列标题为 延迟(ms)", () => {
|
||||
test("延迟列标题为 延迟", () => {
|
||||
const latencyColumn = getColumn(createTargetTableColumns(["http"]), "latestCheck.durationMs");
|
||||
expect(latencyColumn.title).toBe("延迟(ms)");
|
||||
expect(latencyColumn.title).toBe("延迟");
|
||||
});
|
||||
|
||||
test("延迟列正常值不包含 ms 单位", () => {
|
||||
test("延迟列正常值包含 ms 单位", () => {
|
||||
const latencyColumn = getColumn(createTargetTableColumns(["http"]), "latestCheck.durationMs");
|
||||
const renderCell = latencyColumn.cell as (params: PrimaryTableCellParams<TargetStatus>) => {
|
||||
props: { children: string; className: string };
|
||||
@@ -139,16 +140,17 @@ describe("createTargetTableColumns", () => {
|
||||
colIndex: 6,
|
||||
row: makeTarget({
|
||||
latestCheck: {
|
||||
detail: "200",
|
||||
durationMs: 123,
|
||||
failure: null,
|
||||
matched: true,
|
||||
statusDetail: "200",
|
||||
observation: null,
|
||||
timestamp: "2026-01-01T00:00:00.000Z",
|
||||
},
|
||||
}),
|
||||
rowIndex: 0,
|
||||
});
|
||||
expect(element.props.children).toBe("123");
|
||||
expect(element.props.children).toEqual(["123", " ms"]);
|
||||
});
|
||||
|
||||
test("名称列无排序配置", () => {
|
||||
|
||||
@@ -24,10 +24,10 @@ function makeTarget(overrides: Partial<TargetStatus> = {}): TargetStatus {
|
||||
describe("statusSorter", () => {
|
||||
test("DOWN 排在 UP 前面", () => {
|
||||
const up = makeTarget({
|
||||
latestCheck: { durationMs: 10, failure: null, matched: true, statusDetail: null, timestamp: "" },
|
||||
latestCheck: { detail: null, durationMs: 10, failure: null, matched: true, observation: null, timestamp: "" },
|
||||
});
|
||||
const down = makeTarget({
|
||||
latestCheck: { durationMs: 10, failure: null, matched: false, statusDetail: null, timestamp: "" },
|
||||
latestCheck: { detail: null, durationMs: 10, failure: null, matched: false, observation: null, timestamp: "" },
|
||||
});
|
||||
expect(statusSorter(down, up)).toBeLessThan(0);
|
||||
expect(statusSorter(up, down)).toBeGreaterThan(0);
|
||||
@@ -35,10 +35,10 @@ describe("statusSorter", () => {
|
||||
|
||||
test("相同状态返回 0", () => {
|
||||
const a = makeTarget({
|
||||
latestCheck: { durationMs: 10, failure: null, matched: true, statusDetail: null, timestamp: "" },
|
||||
latestCheck: { detail: null, durationMs: 10, failure: null, matched: true, observation: null, timestamp: "" },
|
||||
});
|
||||
const b = makeTarget({
|
||||
latestCheck: { durationMs: 20, failure: null, matched: true, statusDetail: null, timestamp: "" },
|
||||
latestCheck: { detail: null, durationMs: 20, failure: null, matched: true, observation: null, timestamp: "" },
|
||||
});
|
||||
expect(statusSorter(a, b)).toBe(0);
|
||||
});
|
||||
@@ -46,7 +46,7 @@ describe("statusSorter", () => {
|
||||
test("无 latestCheck 的目标排在最后", () => {
|
||||
const noCheck = makeTarget();
|
||||
const up = makeTarget({
|
||||
latestCheck: { durationMs: 10, failure: null, matched: true, statusDetail: null, timestamp: "" },
|
||||
latestCheck: { detail: null, durationMs: 10, failure: null, matched: true, observation: null, timestamp: "" },
|
||||
});
|
||||
expect(statusSorter(noCheck, up)).toBeGreaterThan(0);
|
||||
});
|
||||
@@ -75,20 +75,20 @@ describe("availabilitySorter", () => {
|
||||
describe("latencySorter", () => {
|
||||
test("低延迟排前面", () => {
|
||||
const fast = makeTarget({
|
||||
latestCheck: { durationMs: 50, failure: null, matched: true, statusDetail: null, timestamp: "" },
|
||||
latestCheck: { detail: null, durationMs: 50, failure: null, matched: true, observation: null, timestamp: "" },
|
||||
});
|
||||
const slow = makeTarget({
|
||||
latestCheck: { durationMs: 200, failure: null, matched: true, statusDetail: null, timestamp: "" },
|
||||
latestCheck: { detail: null, durationMs: 200, failure: null, matched: true, observation: null, timestamp: "" },
|
||||
});
|
||||
expect(latencySorter(fast, slow)).toBeLessThan(0);
|
||||
});
|
||||
|
||||
test("无延迟排最后", () => {
|
||||
const noLatency = makeTarget({
|
||||
latestCheck: { durationMs: null, failure: null, matched: true, statusDetail: null, timestamp: "" },
|
||||
latestCheck: { detail: null, durationMs: null, failure: null, matched: true, observation: null, timestamp: "" },
|
||||
});
|
||||
const hasLatency = makeTarget({
|
||||
latestCheck: { durationMs: 100, failure: null, matched: true, statusDetail: null, timestamp: "" },
|
||||
latestCheck: { detail: null, durationMs: 100, failure: null, matched: true, observation: null, timestamp: "" },
|
||||
});
|
||||
expect(latencySorter(noLatency, hasLatency)).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user