1
0

Compare commits

...

3 Commits

Author SHA1 Message Date
b591dcca97 feat: target 软删除机制,配置移除时保留历史数据 2026-05-20 00:43:39 +08:00
9b53c746f6 refactor: ICMP checker type 从 ping 统一改为 icmp,修复前端 UI 细节
- ICMP checker 的 type/configKey/YAML 配置键/接口属性名从 ping 改为 icmp
- IcmpChecker 添加 platform 构造函数注入,修复 Windows 测试兼容性
- 前端 target 表格延迟列优化:标题简化为「延迟」,单位下移到单元格,宽度 80px
- Drawer 概览页 Descriptions 添加 tableLayout=auto 收窄 label 宽度
- 同步更新 README.md、DEVELOPMENT.md、probes.example.yaml、JSON Schema 和全部测试
2026-05-20 00:02:23 +08:00
375dd3492b feat: 结构化 observation 替代 statusDetail,API 层动态构造 detail
- CheckResult: statusDetail -> observation (持久化) + detail (API 动态派生)
- 存储: status_detail 列 -> observation TEXT (JSON)
- CheckerDefinition: 新增 buildDetail(observation) 方法
- 各 checker 返回结构化 observation,API 层通过 registry 调用 buildDetail
- HTTP: bodyPreview 在 status/header 失败时也提前采集
- UDP: observation 包含 durationMs,未响应归为 error failure
- CMD: 超时/输出超限时保留已收集 observation
- TCP: connectTimeMs 仅含连接建立耗时,不含 banner 等待
- 新增 buildDetail 单测和 mapCheckResult 覆盖测试
- 同步 openspec 主规范,归档 checker-observation 变更
2026-05-19 22:49:00 +08:00
79 changed files with 1441 additions and 1220 deletions

View File

@@ -63,7 +63,7 @@ src/
cmd/ Cmd Checker自包含模块含 types/schema/execute/expect/validate
db/ DB Checker自包含模块含 types/schema/execute/expect/validate
tcp/ TCP Checker自包含模块含 types/schema/execute/expect/validate
icmp/ Ping Checker自包含模块含 types/schema/execute/expect/validate/parse
icmp/ ICMP Checker自包含模块含 types/schema/execute/expect/validate/parse
udp/ UDP Checker自包含模块含 types/schema/execute/expect/validate/encoding
llm/ LLM Checker自包含模块含 types/schema/execute/expect/validate/provider/observation
shared/
@@ -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`idTEXT PRIMARY KEY配置 target id、nameTEXT可 NULL展示名称、descriptionTEXT可 NULL描述、type、target展示摘要、configJSON、interval_ms、timeout_ms、expectJSON、grp
- `check_results`target_idTEXT FK CASCADE引用配置 target id、timestamp、matched0/1、duration_ms、status_detail、failureJSON
- `check_results`target_idTEXT FK CASCADE引用配置 target id、timestamp、matched0/1、duration_ms、observationJSON TEXT、failureJSON
- 复合索引:`(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` 触发 abortchecker 必须使用 `CheckerContext.signal` 感知超时HTTP 将 signal 传给 `fetch()`Cmd 和 Ping 在 signal abort 时 `proc.kill()`
- **结果写入**:检查结果通过 `store.insertCheckResult()` 写入 SQLiteengine 基于配置 target id 确认目标仍存在
- **超时控制**`ProbeEngine` 为每次检查创建 `AbortController` 并按 `target.timeoutMs` 触发 abortchecker 必须使用 `CheckerContext.signal` 感知超时HTTP 将 signal 传给 `fetch()`Cmd 和 ICMP 在 signal abort 时 `proc.kill()`
- **结果写入**:检查结果通过 `store.insertCheckResult()` 写入 SQLiteengine 基于配置 target id 确认目标仍存在detail 为 API 层从 observation 派生,不进入存储层
- **异常可观测**`probeGroup()``Promise.allSettled` 的 rejected 结果通过索引关联 target并写入 `phase:"internal"` 的失败记录
- **数据清理**:当 `retentionMs > 0`engine 启动时立即执行一次 `store.prune()`,之后每小时定时执行,按 `timestamp` 清理过期数据
- **生命周期**`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

View File

@@ -10,12 +10,13 @@
---
DiAL 是一个自托管的拨测监控工具,支持 **HTTP**、**命令行**、**数据库**、**TCP**、**UDP**、**Ping** 和 **LLM** 多种拨测类型。通过 YAML 配置文件定义拨测目标,后端定时并发执行拨测并将结果持久化到本地 SQLite前端 Dashboard 展示各目标的实时状态、可用率和耗时趋势。
DiAL 是一个自托管的拨测监控工具,支持 **HTTP**、**命令行**、**数据库**、**TCP**、**UDP**、**ICMP** 和 **LLM** 多种拨测类型。通过 YAML 配置文件定义拨测目标,后端定时并发执行拨测并将结果持久化到本地 SQLite前端 Dashboard 展示各目标的实时状态、可用率和耗时趋势。
**功能亮点:**
- 多种拨测类型HTTPGET/POST/PUT 等、Cmd命令行执行、DBPostgreSQL/MySQL/SQLite、TCP端口可达性 + Banner 探测、UDP自定义 payload 请求-响应)、PingICMP 存活、延迟、丢包率、LLM大模型服务应用层健康检查
- 多种拨测类型HTTPGET/POST/PUT 等、Cmd命令行执行、DBPostgreSQL/MySQL/SQLite、TCP端口可达性 + Banner 探测、UDP自定义 payload 请求-响应、ICMP存活检测、延迟、丢包率、LLM大模型服务应用层健康检查
- 丰富的校验规则状态码、响应头、JSONPath、CSS 选择器、XPath、正则匹配、数值比较等
- 结构化观测数据:检查结果保留 HTTP body 预览、TCP/UDP 响应摘要、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

View File

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

View File

@@ -1,159 +0,0 @@
## Context
当前所有 checkerHTTP/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 typedashboard 路由遍历 targetshistory 路由已查出 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 描述解码问题。

View File

@@ -1,37 +0,0 @@
## Why
各 checker 在执行过程中收集了丰富的结构化数据HTTP 状态码/headers/body、ICMP 延迟/丢包率、LLM token 用量/首 token 延迟等),但 `CheckResult` 仅有一个 `statusDetail: string | null` 字段所有观测数据被压缩为人可读字符串后丢弃。这导致排障时无法获取失败上下文HTTP 502 返回了什么 bodyCMD 的 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 改为 observationdetail 不进入存储层
- `probe-data-store`: check_results 表 schema 变更status_detail → observationinsert/query 方法适配新字段;同步修正现有 spec 中已过时的 target_id 类型为当前代码实际使用的 TEXT
- `probe-api`: CheckResult API 合约变更statusDetail → detail + observation序列化层需根据 target type 动态构造 detail
- `cmd-checker`: CMD 执行结果改为返回 observationdetail 由 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 需改造返回 observationLLM 还涉及 types.ts、observation.ts、expect.ts 的执行期/持久化结构分层engine.ts/store.ts/helpers.ts/routes 适配新字段
- **前端**: 2 处源码 statusDetail 引用改为 detailhistory-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、前端展示的测试需适配新字段

View File

@@ -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

View File

@@ -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

View File

@@ -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)`

View File

@@ -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 通过

View File

@@ -1,48 +0,0 @@
## MODIFIED Requirements
### Requirement: 新增共享类型
系统 SHALL 在 `src/shared/api.ts` 中定义 Dashboard 和 Metrics 相关共享类型。CheckResult SHALL 包含 durationMsnull | number、failureCheckFailure | null、matchedboolean、detailnull | string、observationRecord<string, unknown> | null、timestampstring。其中 detail 替代原 statusDetail 字段名。
#### Scenario: DashboardResponse 类型
- **WHEN** 前后端共享 `DashboardResponse` 类型
- **THEN** 该类型 SHALL 包含 summary 和 targets 字段
#### Scenario: TargetStatus 类型
- **WHEN** 前后端共享 `TargetStatus` 类型
- **THEN** 该类型 SHALL 包含目标基本信息字段id、name、description、group、type、target、interval、statstotalChecks、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

View File

@@ -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 表包含 idINTEGER PRIMARY KEY AUTOINCREMENT、target_idTEXT NOT NULL、timestampTEXT NOT NULL、matchedINTEGER NOT NULL、duration_msREAL、observationTEXT、failureTEXT不包含 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 字段

View File

@@ -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、observationfailure 为 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" 和具体不匹配信息

View File

@@ -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 使用内建 paginationdisableDataPage=true分页器显示在表格底部
#### Scenario: 翻页触发请求
- **WHEN** 用户切换分页页码
- **THEN** 系统 SHALL 请求对应页码的服务端数据,表格更新

View File

@@ -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=truefailure 的 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 展示过长文本

View File

@@ -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 和 errorfailure 的 kind 为 `error`phase 为 `response`message 包含超时或未响应信息
#### Scenario: 期望无响应且实际无响应
- **WHEN** udp target 配置 `expect.responded: false`,且 timeout 内未收到 UDP datagram
- **THEN** 系统 SHALL 记录 `matched=true`observation SHALL 包含 responded=falseAPI 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` 转换并截断后的响应摘要

View File

@@ -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 表 DDLstatus_detail 列替换为 observation TEXT 列
- [ ] 2.2 修改 `src/server/checker/store.ts` 中 insertCheckResult 方法:写入 observationJSON.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 参数,反序列化 observationobservation 为 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 机制

View File

@@ -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 包含 connectedboolean、connectTimeMs
- **THEN** observation SHALL 包含 connected=false 和错误信息
### Requirement: UDP Checker Observation
UDP checker 的 observation SHALL 包含 respondedboolean、responseSizenumber | null、responsePreviewstring | null截断、sourceAddressstring | null、sourcePortnumber | null、errorstring | null
UDP checker 的 observation SHALL 包含 respondedbooleandurationMsnumberresponseSizenumber | null、responsePreviewstring | null截断、sourceAddressstring | null、sourcePortnumber | null、errorstring | nulldurationMs 用于 API 序列化层生成包含耗时的 UDP detail 摘要。
#### Scenario: UDP 收到响应
- **WHEN** UDP 发送数据后收到响应

View File

@@ -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`(使用默认泛型参数),对外提供类型擦除后的接口。

View File

@@ -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 断言。

View File

@@ -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 清除清理定时器,不再执行后续清理

View File

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

View File

@@ -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** 实现完成后执行质量检查

View File

@@ -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指定窗口内异常事件数、windowfrom/to/label字段
- **THEN** summary SHALL 仅统计活跃目标:total活跃目标数、up活跃正常目标数、down活跃异常目标数、lastCheckTime最近一次检查时间、incidents活跃目标在指定窗口内异常事件数、windowfrom/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 包含 durationMsnull | number、failureCheckFailure | null、matchedboolean、detailnull | string、observationRecord<string, unknown> | null、timestampstring。其中 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 状态码和错误信息

View File

@@ -5,9 +5,9 @@
## Requirements
### Requirement: YAML 配置文件格式
系统 SHALL 支持通过 YAML 配置文件定义全部运行参数,包括 server 配置、runtime 配置、可选的 variables 段、checker 默认值和 typed target 列表(含可选 group 字段。target MUST 使用 `id` 字段作为唯一标识符MUST 使用 `type` 字段声明 checker 类型SHALL 支持可选的 `name` 字段作为展示名称元信息SHALL 支持可选的 `description` 字段作为目标说明。`name``description` 均 SHALL 允许省略或显式配置为 `null`;省略或显式 null 时解析结果 SHALL 保留为 null。HTTP 领域字段 MUST 放在 `http` 分组cmd 领域字段 MUST 放在 `cmd` 分组db 领域字段 MUST 放在 `db` 分组tcp 领域字段 MUST 放在 `tcp` 分组,ping 领域字段 MUST 放在 `ping` 分组udp 领域字段 MUST 放在 `udp` 分组LLM 领域字段 MUST 放在 `llm` 分组。HTTP target 的 `http` 分组 SHALL 支持可选的 `ignoreSSL`(布尔值)和 `maxRedirects`非负整数字段。Db target 的 `db` 分组 SHALL 支持 `url`(必填)和 `query`可选字段。Tcp target 的 `tcp` 分组 SHALL 支持 `host`(必填)、`port`(必填)、`readBanner`(可选)、`bannerReadTimeout`(可选)和 `maxBannerBytes`(可选)字段。Ping target 的 `ping` 分组 SHALL 支持 `host`(必填)、`count`(可选,默认 3`packetSize`(可选,默认 56字段。Udp target 的 `udp` 分组 SHALL 支持 `host`(必填)、`port`(必填)、`payload`(可选,默认空字符串)、`encoding`(可选,默认 `text`)、`responseEncoding`(可选,默认 `text`)和 `maxResponseBytes`(可选,默认 4096字段。LLM target 的 `llm` 分组 SHALL 支持 `provider`(必填)、`url`(必填)、`model`(必填)、`prompt`(必填)、`mode`(可选,默认 `http`)、`key`(可选,默认空字符串)、`authToken`(可选)、`headers`(可选)、`ignoreSSL`(可选,默认 `false`)、`options`(可选)和 `providerOptions`(可选)字段。
系统 SHALL 支持通过 YAML 配置文件定义全部运行参数,包括 server 配置、runtime 配置、可选的 variables 段、checker 默认值和 typed target 列表(含可选 group 字段。target MUST 使用 `id` 字段作为唯一标识符MUST 使用 `type` 字段声明 checker 类型SHALL 支持可选的 `name` 字段作为展示名称元信息SHALL 支持可选的 `description` 字段作为目标说明。`name``description` 均 SHALL 允许省略或显式配置为 `null`;省略或显式 null 时解析结果 SHALL 保留为 null。HTTP 领域字段 MUST 放在 `http` 分组cmd 领域字段 MUST 放在 `cmd` 分组db 领域字段 MUST 放在 `db` 分组tcp 领域字段 MUST 放在 `tcp` 分组,icmp 领域字段 MUST 放在 `icmp` 分组udp 领域字段 MUST 放在 `udp` 分组LLM 领域字段 MUST 放在 `llm` 分组。HTTP target 的 `http` 分组 SHALL 支持可选的 `ignoreSSL`(布尔值)和 `maxRedirects`非负整数字段。Db target 的 `db` 分组 SHALL 支持 `url`(必填)和 `query`可选字段。Tcp target 的 `tcp` 分组 SHALL 支持 `host`(必填)、`port`(必填)、`readBanner`(可选)、`bannerReadTimeout`(可选)和 `maxBannerBytes`(可选)字段。Icmp target 的 `icmp` 分组 SHALL 支持 `host`(必填)、`count`(可选,默认 3`packetSize`(可选,默认 56字段。Udp target 的 `udp` 分组 SHALL 支持 `host`(必填)、`port`(必填)、`payload`(可选,默认空字符串)、`encoding`(可选,默认 `text`)、`responseEncoding`(可选,默认 `text`)和 `maxResponseBytes`(可选,默认 4096字段。LLM target 的 `llm` 分组 SHALL 支持 `provider`(必填)、`url`(必填)、`model`(必填)、`prompt`(必填)、`mode`(可选,默认 `http`)、`key`(可选,默认空字符串)、`authToken`(可选)、`headers`(可选)、`ignoreSSL`(可选,默认 `false`)、`options`(可选)和 `providerOptions`(可选)字段。
`defaults.http` 分组 SHALL 仅支持 `headers`(可选)和 `maxBodyBytes`(可选)字段。`defaults.http` 分组 MUST NOT 支持 `method` 字段。`defaults.tcp` 分组 SHALL 仅支持 `bannerReadTimeout`(可选)和 `maxBannerBytes`(可选)字段。`defaults.udp` 分组 SHALL 仅支持 `encoding`(可选)、`responseEncoding`(可选)和 `maxResponseBytes`(可选)字段。`defaults.llm` 分组 SHALL 仅支持 `mode`(可选)、`headers`(可选)、`ignoreSSL`(可选)、`options`(可选)和 `providerOptions`(可选)字段。
`defaults.http` 分组 SHALL 仅支持 `headers`(可选)和 `maxBodyBytes`(可选)字段。`defaults.http` 分组 MUST NOT 支持 `method` 字段。`defaults.tcp` 分组 SHALL 仅支持 `bannerReadTimeout`(可选)和 `maxBannerBytes`(可选)字段。`defaults.icmp` 分组 SHALL 仅支持空对象。`defaults.udp` 分组 SHALL 仅支持 `encoding`(可选)、`responseEncoding`(可选)和 `maxResponseBytes`(可选)字段。`defaults.llm` 分组 SHALL 仅支持 `mode`(可选)、`headers`(可选)、`ignoreSSL`(可选)、`options`(可选)和 `providerOptions`(可选)字段。
#### Scenario: 完整配置文件解析
- **WHEN** 系统启动并读取包含 server、runtime、variables、defaults、targets含 id、group 字段)的 YAML 配置文件
@@ -57,9 +57,9 @@
- **WHEN** HTTP target 未配置 `http.method` 且 defaults.http 中无 method 字段
- **THEN** 系统 SHALL 使用内置默认值 GET 作为该 target 的请求方法
#### Scenario: 最简 ping 配置文件解析
- **WHEN** 系统读取只包含一个 `type: ping` target`id``ping.host`)的 YAML 配置文件
- **THEN** 系统 SHALL 使用内置默认值填充未指定的字段interval=30s, timeout=10s, group="default", ping.count=3, ping.packetSize=56并保留 name=null、description=null
#### Scenario: 最简 icmp 配置文件解析
- **WHEN** 系统读取只包含一个 `type: icmp` target`id``icmp.host`)的 YAML 配置文件
- **THEN** 系统 SHALL 使用内置默认值填充未指定的字段interval=30s, timeout=10s, group="default", icmp.count=3, icmp.packetSize=56并保留 name=null、description=null
#### Scenario: 最简 udp 配置文件解析
- **WHEN** 系统读取只包含一个 `type: udp` target`id``udp.host``udp.port`)的 YAML 配置文件
@@ -219,9 +219,9 @@
- **WHEN** YAML 中某个 target 配置 ValueMatcher 字段值为对象 `{foo: "bar"}`,且 `foo` 不是合法 matcher 字段
- **THEN** 系统 SHALL 以错误退出,提示 `foo` 是未知 matcher如需对象 equals 匹配应写成 `{equals: {foo: "bar"}}`
#### Scenario: ping target 缺少 host
- **WHEN** YAML 中某个 target 配置 `type: ping` 但缺少 `ping.host`
- **THEN** 系统 SHALL 以错误退出,提示该 target 缺少 ping.host 字段
#### Scenario: icmp target 缺少 host
- **WHEN** YAML 中某个 target 配置 `type: icmp` 但缺少 `icmp.host`
- **THEN** 系统 SHALL 以错误退出,提示该 target 缺少 icmp.host 字段
#### Scenario: ping expect 未知字段
- **WHEN** YAML 中 ping target 的 expect 包含非 ping expect 字段

View File

@@ -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 表包含 idINTEGER PRIMARY KEY AUTOINCREMENT、target_idINTEGER NOT NULL、timestampTEXT NOT NULL、matchedINTEGER NOT NULL、duration_msREALstatus_detailTEXT、failureTEXT不包含 success 列
- **THEN** 系统 SHALL 创建数据库文件并初始化 targets 表(含 active INTEGER NOT NULL DEFAULT 1 列)和 check_results 表(外键约束为 ON DELETE RESTRICTcheck_results 表包含 idINTEGER PRIMARY KEY AUTOINCREMENT、target_idTEXT NOT NULL、timestampTEXT NOT NULL、matchedINTEGER NOT NULL、duration_msREALobservationTEXT、failureTEXT不包含 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 或 DOWNmatched=true 为 UPmatched=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 返回 nullgetTargetById 不匹配 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 行

View File

@@ -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_detailfailure 为 null
- **THEN** 系统 SHALL 记录 matched=true、duration_ms、observationfailure 为 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

View File

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

View File

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

View File

@@ -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=truefailure 的 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 读取在内的完整执行耗时。

View File

@@ -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 和 errorfailure 的 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=falseAPI 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` 转换并截断后的响应摘要

View File

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

View File

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

View File

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

View File

@@ -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,
});

View File

@@ -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);
}

View File

@@ -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,
};

View File

@@ -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));
}

View File

@@ -5,14 +5,14 @@ export function buildPingCommand(t: ResolvedPingTarget, platform: NodeJS.Platfor
return [
"ping",
"-n",
String(t.ping.count),
String(t.icmp.count),
"-l",
String(t.ping.packetSize),
String(t.icmp.packetSize),
"-w",
String(t.timeoutMs),
t.ping.host,
t.icmp.host,
];
}
const timeout = platform === "linux" ? String(Math.ceil(t.timeoutMs / 1000)) : String(t.timeoutMs);
return ["ping", "-c", String(t.ping.count), "-s", String(t.ping.packetSize), "-W", timeout, t.ping.host];
return ["ping", "-c", String(t.icmp.count), "-s", String(t.icmp.packetSize), "-W", timeout, t.icmp.host];
}

View File

@@ -16,11 +16,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)}`;
}

View File

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

View File

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

View File

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

View File

@@ -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(", ");
}

View File

@@ -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));
}

View File

@@ -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 {

View File

@@ -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);
}

View File

@@ -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;

View File

@@ -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,

View File

@@ -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);
}
}
});

View File

@@ -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;

View File

@@ -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,
};
}

View File

@@ -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,

View File

@@ -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,

View File

@@ -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;
}

View File

@@ -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>

View File

@@ -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: "详情",
},
];

View File

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

View File

@@ -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",
});

View File

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

View File

@@ -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();
}

View File

@@ -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 () => {

View File

@@ -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 () => {

View 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");
});
});

View File

@@ -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 () => {

View File

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

View File

@@ -5,7 +5,7 @@ import type { CheckerContext } from "../../../../../src/server/checker/runner/ty
import { IcmpChecker } from "../../../../../src/server/checker/runner/icmp/execute";
const checker = new IcmpChecker();
const checker = new IcmpChecker("linux");
const originalSpawn = Bun.spawn;
afterEach(() => {
@@ -21,12 +21,12 @@ function makeTarget(overrides?: Partial<ResolvedPingTarget>): ResolvedPingTarget
return {
description: null,
group: "default",
icmp: { count: 3, host: "127.0.0.1", packetSize: 56 },
id: "ping-local",
intervalMs: 30000,
name: null,
ping: { count: 3, host: "127.0.0.1", packetSize: 56 },
timeoutMs: 10000,
type: "ping",
type: "icmp",
...overrides,
};
}
@@ -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 });
});
});

View File

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

View File

@@ -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 () => {

View File

@@ -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");
});
});

View File

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

View File

@@ -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 () => {

View File

@@ -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();
}

View File

@@ -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();
});
});

View File

@@ -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();
});
});

View File

@@ -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",

View File

@@ -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",

View File

@@ -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",

View File

@@ -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("名称列无排序配置", () => {

View File

@@ -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);
});