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 变更
This commit is contained in:
@@ -193,7 +193,7 @@ export function handleMetrics(idStr: string, url: URL, store: ProbeStore, mode:
|
||||
- `createHealthResponse()` — 构造健康检查响应
|
||||
- `formatDuration(ms)` — 毫秒转为可读时长字符串
|
||||
- `jsonResponse(body, options)` — JSON 响应构造
|
||||
- `mapCheckResult(row)` — 数据库行转 API CheckResult
|
||||
- `mapCheckResult(row, type)` — 数据库行转 API CheckResult,反序列化 observation 并按 checker type 动态生成 detail
|
||||
- **`middleware.ts`**:API 参数校验函数(`validateTargetId`、`validateTimeRange`、`validatePagination`、`validateDashboardWindow`、`validateRecentLimit`、`validateMetricsBucket`,其中 `pageSize` 和 `recentLimit` 上限为 `200`)
|
||||
|
||||
### 1.5 类型定义规范
|
||||
@@ -478,7 +478,7 @@ TcpChecker implements Checker
|
||||
**Schema**:
|
||||
|
||||
- `targets` 表:id(TEXT PRIMARY KEY,配置 target id)、name(TEXT,可 NULL,展示名称)、description(TEXT,可 NULL,描述)、type、target(展示摘要)、config(JSON)、interval_ms、timeout_ms、expect(JSON)、grp
|
||||
- `check_results` 表:target_id(TEXT FK CASCADE,引用配置 target id)、timestamp、matched(0/1)、duration_ms、status_detail、failure(JSON)
|
||||
- `check_results` 表:target_id(TEXT FK CASCADE,引用配置 target id)、timestamp、matched(0/1)、duration_ms、observation(JSON TEXT)、failure(JSON)
|
||||
- 复合索引:`(target_id, timestamp)`
|
||||
|
||||
### 1.9 拨测引擎
|
||||
@@ -487,7 +487,7 @@ TcpChecker implements Checker
|
||||
- **并发控制**:`es-toolkit/Semaphore` 限制全局最大并发数(`maxConcurrentChecks`,默认 20),`acquire()` 阻塞等待
|
||||
- **Runner 选择**:`engine.runCheck()` 通过 `checkerRegistry.get(target.type)` 获取 checker,并调用 `checker.execute(target, { signal })`
|
||||
- **超时控制**:`ProbeEngine` 为每次检查创建 `AbortController` 并按 `target.timeoutMs` 触发 abort;checker 必须使用 `CheckerContext.signal` 感知超时,HTTP 将 signal 传给 `fetch()`,Cmd 和 Ping 在 signal abort 时 `proc.kill()`
|
||||
- **结果写入**:检查结果通过 `store.insertCheckResult()` 写入 SQLite,engine 基于配置 target id 确认目标仍存在
|
||||
- **结果写入**:检查结果通过 `store.insertCheckResult()` 写入 SQLite,engine 基于配置 target id 确认目标仍存在;detail 为 API 层从 observation 派生,不进入存储层
|
||||
- **异常可观测**:`probeGroup()` 对 `Promise.allSettled` 的 rejected 结果通过索引关联 target,并写入 `phase:"internal"` 的失败记录
|
||||
- **数据清理**:当 `retentionMs > 0` 时,engine 启动时立即执行一次 `store.prune()`,之后每小时定时执行,按 `timestamp` 清理过期数据
|
||||
- **生命周期**:`start()`/`stop()` 管理定时器(含调度定时器和清理定时器),`stop()` 清理所有 `setInterval`
|
||||
|
||||
@@ -16,6 +16,7 @@ DiAL 是一个自托管的拨测监控工具,支持 **HTTP**、**命令行**
|
||||
|
||||
- 多种拨测类型:HTTP(GET/POST/PUT 等)、Cmd(命令行执行)、DB(PostgreSQL/MySQL/SQLite)、TCP(端口可达性 + Banner 探测)、UDP(自定义 payload 请求-响应)、Ping(ICMP 存活、延迟、丢包率)、LLM(大模型服务应用层健康检查)
|
||||
- 丰富的校验规则:状态码、响应头、JSONPath、CSS 选择器、XPath、正则匹配、数值比较等
|
||||
- 结构化观测数据:检查结果保留 HTTP body 预览、TCP/UDP 响应摘要、Ping 丢包率、CMD 输出预览、LLM token 用量等 observation,便于排障和后续分析
|
||||
- 响应式 Dashboard:实时状态、可用率统计、耗时趋势图、手动/自动刷新
|
||||
- 多主题支持:系统、明亮、黑暗三种主题模式
|
||||
- 零外部依赖:数据存储使用 SQLite,无需额外数据库服务
|
||||
@@ -357,6 +358,8 @@ Ping checker 通过系统 `ping` 命令执行 ICMP 探测,支持 Linux、macOS
|
||||
|
||||
执行失败(网络错误、超时、进程崩溃)和 expect 不匹配都统一为 DOWN,通过 `failure.kind` 区分原因(`"error"` vs `"mismatch"`)。
|
||||
|
||||
API 返回的检查结果包含 `detail` 和 `observation`:`detail` 是后端按 checker 类型从结构化 observation 动态生成的人可读摘要,`observation` 保存该次检查的结构化观测数据。`detail` 不写入 SQLite,存储层仅持久化 `observation` JSON、`failure` JSON、匹配状态、耗时和时间戳。
|
||||
|
||||
## 开发
|
||||
|
||||
```bash
|
||||
|
||||
@@ -1,2 +0,0 @@
|
||||
schema: spec-driven
|
||||
created: 2026-05-19
|
||||
@@ -1,159 +0,0 @@
|
||||
## Context
|
||||
|
||||
当前所有 checker(HTTP/TCP/UDP/ICMP/DB/CMD/LLM)在 execute 执行后,将丰富的结构化观测数据压缩为一个 `statusDetail: string | null` 字符串,然后丢弃原始数据。`CheckResult` 仅有 5 个字段(durationMs、failure、matched、statusDetail、timestamp),无法承载排障所需的上下文信息,也无法支持历史观测数据的结构化分析。
|
||||
|
||||
现有代码中,LLM checker 已有执行期 `LlmCheckObservation` 对象且 `expect.output` 依赖其中的完整 `outputText`,ICMP/Ping checker 已有 `PingStats` 结构,其余 checker 的观测数据散布在 execute 函数的局部变量中。
|
||||
|
||||
前端通过 `statusDetail` 字符串直接展示摘要,仅在 `history-table-columns.tsx` 和 `OverviewTab.tsx` 两处引用。
|
||||
|
||||
项目未上线,不需要考虑向前兼容和数据迁移。
|
||||
|
||||
现有 `openspec/specs/probe-data-store/spec.md` 中 `check_results.target_id` 仍写为 INTEGER,但当前代码中的 `targets.id` 和 `check_results.target_id` 均为 TEXT。本 change 的 `probe-data-store` delta 在替换 status_detail 时同步修正该过时 spec 描述,不引入额外代码类型变更。
|
||||
|
||||
## Goals / Non-Goals
|
||||
|
||||
**Goals:**
|
||||
- 在 CheckResult 中引入结构化 observation 字段,持久化完整观测数据
|
||||
- 用 `detail` 字段替代 `statusDetail`,从 observation 动态构造,不再持久化
|
||||
- 各 checker 定义各自的 observation schema 和 detail 构造逻辑
|
||||
- 对大文本/集合字段执行截断,控制存储膨胀
|
||||
- 前端展示行为不变(仍显示人可读摘要字符串)
|
||||
|
||||
**Non-Goals:**
|
||||
- 前端 observation 详情展开 UI(后续单独做)
|
||||
- observation 的 JSON 深层查询(不需要 json_extract 等)
|
||||
- 数据脱敏(仅截断,不脱敏)
|
||||
- 引入新的序列化依赖(使用原生 JSON)
|
||||
|
||||
## Decisions
|
||||
|
||||
### Decision 1: observation 字段类型使用 `Record<string, unknown> | null`
|
||||
|
||||
**选择**: 使用 `Record<string, unknown>` 作为 observation 的共享类型,各 checker 内部定义强类型 interface 用于构造,序列化后类型擦除。
|
||||
|
||||
**替代方案**:
|
||||
- Discriminated union(`{ type: "http", ... } | { type: "tcp", ... }`):类型安全但导致共享层与所有 checker 类型耦合,每新增一个 checker 都需要修改共享类型
|
||||
- `unknown`:过于宽松,缺乏结构约束
|
||||
|
||||
**理由**: 折中方案。共享层不关心 observation 的内部结构(只需序列化/反序列化),类型安全由各 checker 内部保证。buildDetail 方法接收 `Record<string, unknown>` 并在内部做类型断言。
|
||||
|
||||
### Decision 2: 存储格式使用 JSON TEXT
|
||||
|
||||
**选择**: observation 列使用 SQLite TEXT 类型,JSON.stringify 写入,JSON.parse 读出。
|
||||
|
||||
**替代方案**:
|
||||
- BLOB + MessagePack:更紧凑,但需要引入 `@msgpack/msgpack` 依赖
|
||||
- BLOB + JSON Buffer:不引入依赖,但 sqlite3 CLI 无法直接查看内容
|
||||
|
||||
**理由**: 不引入新依赖(项目规范);开发者可以用 sqlite3 CLI 直接查看和调试 observation 数据。空间换可读性,实际膨胀有限(截断策略控制了上界)。
|
||||
|
||||
### Decision 3: detail 在 API 序列化层动态构造
|
||||
|
||||
**选择**: detail 不持久化到数据库,在 API 路由的 `mapCheckResult` 中通过 `checkerRegistry.get(type).buildDetail(observation)` 动态生成。
|
||||
|
||||
**替代方案**:
|
||||
- 在 execute 中同时生成 detail 并持久化:简单但存储冗余数据,且 detail 格式变更需要回填
|
||||
- 在前端从 observation 构造:前端需要了解所有 checker 类型的 detail 格式,违反关注点分离
|
||||
|
||||
**理由**: detail 是 observation 的派生数据,不应独立存储。API 层已经能获取 target type(dashboard 路由遍历 targets,history 路由已查出 target),调用 buildDetail 的成本极低。mapCheckResult 函数签名调整为接收 type 参数。
|
||||
|
||||
### Decision 4: statusDetail 重命名为 detail
|
||||
|
||||
**选择**: API 合约中 `statusDetail` 字段重命名为 `detail`。
|
||||
|
||||
**理由**: 更简洁。项目未上线,无兼容性负担。
|
||||
|
||||
### Decision 5: CheckerDefinition 接口新增 buildDetail 方法
|
||||
|
||||
**选择**: 在 `CheckerDefinition` 接口中新增 `buildDetail(observation: Record<string, unknown>): string | null`。
|
||||
|
||||
**替代方案**:
|
||||
- 独立的 detailBuilder registry:过度设计,且打破 checker 内聚性
|
||||
- 在 observation 中嵌入 detail 字段:混淆数据和展示
|
||||
|
||||
**理由**: buildDetail 是 checker 领域知识的一部分,与 execute/serialize 同属 checker 职责。放在接口中保持 checker 内聚。
|
||||
|
||||
### Decision 6: 可收集的负向结果仍写入 observation
|
||||
|
||||
**选择**: observation 为 null 仅表示无法形成有意义的领域观测,例如进程 spawn 失败、执行框架内部异常、请求在拿到响应前失败且没有可记录元数据。TCP 连接拒绝、UDP 未收到响应、Ping 不可达、DB 连接失败、CMD 非零退出、HTTP 非 2xx/expect 不匹配、LLM 返回错误状态等可收集上下文的负向结果 SHALL 返回结构化 observation,并通过 failure 表示失败原因。
|
||||
|
||||
**理由**: observation 的目标是排障和趋势分析。可预期的负向结果本身就是关键观测数据,如果统一写 null 会丢失连接错误、丢包率、stderr、HTTP status/body 等上下文。
|
||||
|
||||
### Decision 7: HTTP 成功拿到响应后始终采集 bodyPreview
|
||||
|
||||
**选择**: HTTP checker 在 fetch 成功返回 Response 后,无论是否配置 body expect,都读取响应体前 1024 字符作为 `bodyPreview`。当配置 body expect 时,仍按现有 `maxBodyBytes` 读取完整可校验范围,并从已读取文本派生 `bodyPreview`。
|
||||
|
||||
**理由**: 主要排障诉求包含“HTTP 502 返回了什么 body”。如果仅在配置 body expect 时读取 body,默认 HTTP 探测无法提供失败响应正文。该行为会增加一次响应体读取成本,但 preview 上限较小,且 HTTP 响应体不会被后续其他逻辑复用。
|
||||
|
||||
### Decision 8: 截断策略
|
||||
|
||||
各 checker 的截断上限:
|
||||
|
||||
| 字段 | 上限 | 说明 |
|
||||
|------|------|------|
|
||||
| HTTP bodyPreview | 1024 chars | 错误页面/API 错误体足够排障 |
|
||||
| HTTP headers | 前 20 个 | 避免大量自定义 header 膨胀 |
|
||||
| TCP banner | 256 chars | 与现有 truncateBanner 逻辑一致 |
|
||||
| UDP responsePreview | 512 chars | 协议响应通常较短 |
|
||||
| DB rowsPreview | 前 5 行 | 验证查询结果形态即可 |
|
||||
| CMD stdoutPreview | 1024 chars | 错误日志/诊断输出的前段 |
|
||||
| CMD stderrPreview | 1024 chars | 同上 |
|
||||
| LLM outputPreview | 512 chars | 输出文本摘要即可 |
|
||||
| LLM headers | 前 20 个 | 同 HTTP |
|
||||
|
||||
### Decision 9: execute 返回值变更策略
|
||||
|
||||
各 checker 的 execute 方法改造方式:
|
||||
|
||||
- **LLM**: 执行期继续使用包含完整 `outputText` 的 `LlmCheckObservation` 支撑 expect 校验;写入 CheckResult.observation 前派生持久化 observation,将 `outputText` 转为 `outputPreview` 和 `outputLength`,并截断 HTTP headers。不能直接把执行期 `outputText` 替换为 preview,否则会破坏 `expect.output`。
|
||||
- **ICMP**: 已有 `PingStats`,直接作为 observation 基础,补充 error 字段。
|
||||
- **HTTP/TCP/UDP/DB/CMD**: 在 execute 函数中将散布的局部变量聚合为 observation 对象。现有的 `buildStatusDetail` 辅助函数逻辑迁移到 `buildDetail` 方法中,输入改为 observation。
|
||||
|
||||
### Decision 10: 数据流架构
|
||||
|
||||
```
|
||||
Checker.execute()
|
||||
│
|
||||
├─ observation: { statusCode: 200, headers: {...}, ... }
|
||||
├─ detail: 不设置
|
||||
├─ matched / failure / durationMs / timestamp
|
||||
│
|
||||
▼
|
||||
Engine.writeResult()
|
||||
│
|
||||
├─ observation → JSON.stringify → TEXT 列
|
||||
├─ failure → JSON.stringify → TEXT 列
|
||||
├─ matched / durationMs / timestamp → 原样写入
|
||||
│
|
||||
▼
|
||||
API Route (dashboard / history)
|
||||
│
|
||||
├─ 已知 target.type
|
||||
├─ observation → JSON.parse
|
||||
├─ detail = checkerRegistry.get(type).buildDetail(obs)
|
||||
│
|
||||
▼
|
||||
API Response (CheckResult)
|
||||
│
|
||||
├─ observation: { ... } ← 结构化数据,前端可用于未来排障 UI
|
||||
├─ detail: "HTTP 200" ← 人可读摘要,前端直接展示
|
||||
├─ matched / failure / durationMs / timestamp
|
||||
│
|
||||
▼
|
||||
Frontend
|
||||
│
|
||||
├─ 显示 detail(与原 statusDetail 行为一致)
|
||||
└─ observation 暂不使用
|
||||
```
|
||||
|
||||
## Risks / Trade-offs
|
||||
|
||||
**[存储膨胀]** → observation JSON 比原 statusDetail 字符串占用更多空间。通过截断策略控制上界:单条 observation 最大约 5-10KB(含 CMD stdout + stderr 各 1024 chars),远小于 SQLite 单行存储上限。配合已有的 data-retention prune 机制,整体可控。
|
||||
|
||||
**[buildDetail 性能]** → 每次 API 请求都需要 JSON.parse + buildDetail 调用。对于 dashboard(仅取 latest check per target)和 history(分页,默认 20 条/页),开销极小。如果未来需要批量处理大量记录,可以考虑缓存或批量优化。
|
||||
|
||||
**[类型安全断层]** → observation 在共享层是 `Record<string, unknown>`,buildDetail 内部需要做类型断言。如果 observation 结构与 buildDetail 期望不一致,会产生运行时错误。通过 checker 内部 execute、持久化 observation 派生函数和 buildDetail 共享同一个 observation interface(仅 checker 内部可见)来降低风险。
|
||||
|
||||
**[前端字段重命名]** → statusDetail → detail 需要修改前端 2 处引用。变更量小,但需要确保前端编译通过。
|
||||
|
||||
**[HTTP body 读取成本]** → HTTP checker 将在拿到响应后读取 body preview,即使未配置 body expect。通过 1024 字符 preview 上限控制额外内存占用;如果 body 解码失败,仍应保留 status/headers/contentLength 等已收集 observation,并通过 failure 描述解码问题。
|
||||
@@ -1,37 +0,0 @@
|
||||
## Why
|
||||
|
||||
各 checker 在执行过程中收集了丰富的结构化数据(HTTP 状态码/headers/body、ICMP 延迟/丢包率、LLM token 用量/首 token 延迟等),但 `CheckResult` 仅有一个 `statusDetail: string | null` 字段,所有观测数据被压缩为人可读字符串后丢弃。这导致:排障时无法获取失败上下文(HTTP 502 返回了什么 body?CMD 的 stderr 输出了什么?)、无法对历史观测数据做结构化查询和趋势分析(ICMP 丢包率变化、LLM token 消耗趋势)。
|
||||
|
||||
## What Changes
|
||||
|
||||
- **BREAKING**: `CheckResult` 移除 `statusDetail` 字段,新增 `detail: string | null` 和 `observation: Record<string, unknown> | null`
|
||||
- **BREAKING**: 存储层 `check_results` 表移除 `status_detail` 列,新增 `observation TEXT` 列(JSON 格式)
|
||||
- 每个 checker 在 execute 返回时组装结构化 observation 对象,包含该类型特有的观测数据(含截断策略);可收集的负向结果保留 observation,仅无法形成领域观测时返回 null
|
||||
- `CheckerDefinition` 接口新增 `buildDetail(observation)` 方法,从 observation 动态构造人可读摘要
|
||||
- API 序列化层根据 target type 调用对应 checker 的 `buildDetail`,动态生成 `detail` 字段返回给前端
|
||||
- 前端展示层将 `statusDetail` 引用改为 `detail`,逻辑不变
|
||||
|
||||
## Capabilities
|
||||
|
||||
### New Capabilities
|
||||
- `checker-observation`: 定义 observation 数据模型、各 checker 的 observation schema、截断策略、序列化/反序列化规则
|
||||
|
||||
### Modified Capabilities
|
||||
- `checker-runner-abstraction`: CheckerDefinition 接口新增 `buildDetail` 方法;CheckResult 类型变更(statusDetail → detail + observation)
|
||||
- `probe-engine`: checker 执行结果写入字段从 status_detail 改为 observation,detail 不进入存储层
|
||||
- `probe-data-store`: check_results 表 schema 变更(status_detail → observation);insert/query 方法适配新字段;同步修正现有 spec 中已过时的 target_id 类型为当前代码实际使用的 TEXT
|
||||
- `probe-api`: CheckResult API 合约变更(statusDetail → detail + observation);序列化层需根据 target type 动态构造 detail
|
||||
- `cmd-checker`: CMD 执行结果改为返回 observation,detail 由 buildDetail 构造
|
||||
- `tcp-checker`: TCP 执行和 banner 摘要改为通过 observation/detail 表达
|
||||
- `udp-checker`: UDP 执行和响应摘要改为通过 observation/detail 表达
|
||||
- `icmp-checker`: Ping/ICMP 摘要改为通过 observation/detail 表达,API registry type 仍为 `ping`
|
||||
- `llm-checker`: LLM 执行期 observation 与持久化 preview 分层,状态摘要改为 detail
|
||||
- `target-detail-drawer`: 记录面板详情列从 statusDetail 改为 detail
|
||||
|
||||
## Impact
|
||||
|
||||
- **后端**: 7 个 checker 的 execute/buildDetail 需改造返回 observation;LLM 还涉及 types.ts、observation.ts、expect.ts 的执行期/持久化结构分层;engine.ts/store.ts/helpers.ts/routes 适配新字段
|
||||
- **前端**: 2 处源码 statusDetail 引用改为 detail(history-table-columns.tsx、OverviewTab.tsx),相关测试 fixture 同步更新
|
||||
- **存储**: SQLite DDL 变更,不做数据迁移(项目未上线);target_id 继续使用当前代码实际的 TEXT 类型
|
||||
- **依赖**: 无新增依赖,observation 使用 JSON.stringify/JSON.parse 序列化
|
||||
- **测试**: 所有涉及 CheckResult、StoredCheckResult、CheckerDefinition mock、API dashboard/history、各 checker execute/buildDetail、前端展示的测试需适配新字段
|
||||
@@ -1,40 +0,0 @@
|
||||
## MODIFIED Requirements
|
||||
|
||||
### Requirement: Checker 接口定义
|
||||
系统 SHALL 在 `src/server/checker/runner/types.ts` 中定义面向扩展的泛型 `CheckerDefinition<TResolved extends ResolvedTargetBase = ResolvedTargetBase>`,包含 `type`、`configKey`、TypeBox 配置契约、启动期语义校验、`resolve`、`execute`、`serialize`、`buildDetail` 成员。泛型参数 SHALL 约束 `execute` 和 `serialize` 方法的 target 参数类型,使 checker 实现内部获得编译期类型安全。默认泛型参数 `= ResolvedTargetBase` 保证中间层(registry、engine、config-loader)无需指定泛型。
|
||||
|
||||
#### Scenario: Checker 接口包含必要方法
|
||||
- **WHEN** 开发者实现一个新的 Checker
|
||||
- **THEN** 该实现 MUST 提供 `type`(字符串标识)、`configKey`(配置分组名)、TypeBox 配置契约、启动期语义校验、`resolve(target, context)`(解析配置并填充默认值)、`execute(target, ctx)`(执行探测返回 CheckResult)、`serialize(target)`(返回 target 展示文本和 config JSON)和 `buildDetail(observation)`(从 observation 构造人可读摘要)
|
||||
|
||||
#### Scenario: CheckerContext 注入 signal
|
||||
- **WHEN** 引擎调用 `checker.execute(target, ctx)`
|
||||
- **THEN** `ctx.signal` SHALL 是一个由引擎创建的 `AbortSignal`,在超时或引擎关闭时 abort
|
||||
|
||||
#### Scenario: resolve 不承担通用契约校验
|
||||
- **WHEN** config-loader 调用 checker.resolve()
|
||||
- **THEN** checker.resolve() SHALL 假定配置已经通过 TypeBox/Ajv 契约校验和启动期语义校验,只负责默认值填充、路径解析和领域配置转换
|
||||
|
||||
#### Scenario: type 与 configKey 默认一致
|
||||
- **WHEN** checker 定义 `type: "tcp"`
|
||||
- **THEN** checker 的 `configKey` SHALL 默认使用 `"tcp"`,对应 target 的 `tcp` 分组和 defaults.tcp 分组
|
||||
|
||||
#### Scenario: 接口方法使用泛型约束
|
||||
- **WHEN** 开发者查看 `CheckerDefinition<TResolved>` 接口签名
|
||||
- **THEN** `resolve` 的返回值 SHALL 为 `TResolved`;`execute` 的参数 SHALL 为 `TResolved`;`serialize` 的参数 SHALL 为 `TResolved`
|
||||
|
||||
#### Scenario: checker 实现无需手动断言
|
||||
- **WHEN** HttpChecker 实现 `CheckerDefinition<ResolvedHttpTarget>`
|
||||
- **THEN** `execute` 方法的 target 参数类型 SHALL 直接为 `ResolvedHttpTarget`,无需在方法内部使用 `as` 类型断言
|
||||
|
||||
#### Scenario: registry 使用默认泛型参数
|
||||
- **WHEN** CheckerRegistry 存储和返回 checker 实例
|
||||
- **THEN** registry 内部 SHALL 使用 `CheckerDefinition`(等价于 `CheckerDefinition<ResolvedTargetBase>`),实现类型擦除
|
||||
|
||||
#### Scenario: buildDetail 方法签名
|
||||
- **WHEN** 开发者实现 buildDetail 方法
|
||||
- **THEN** 方法签名 SHALL 为 `buildDetail(observation: Record<string, unknown>): string | null`,接收 observation 对象并返回人可读摘要字符串或 null
|
||||
|
||||
#### Scenario: buildDetail 由 API 层调用
|
||||
- **WHEN** API 序列化 CheckResult
|
||||
- **THEN** API 层 SHALL 通过 registry 获取对应 checker 并调用 buildDetail,而非由 execute 方法直接生成 detail
|
||||
@@ -1,24 +0,0 @@
|
||||
## MODIFIED Requirements
|
||||
|
||||
### Requirement: cmd checker 执行
|
||||
系统 SHALL 按 cmd target 配置执行本地命令,记录执行耗时、退出码、stdout 和 stderr observation,并在执行失败时产生结构化错误信息。
|
||||
|
||||
#### Scenario: 命令正常退出
|
||||
- **WHEN** cmd target 执行的进程正常退出且 exit code 为 0
|
||||
- **THEN** 系统 SHALL 记录 `durationMs` 和包含 exitCode、stdoutPreview、stderrPreview 的 observation,并进入 expect 校验;API detail SHALL 为 `exitCode=0`
|
||||
|
||||
#### Scenario: 命令非零退出
|
||||
- **WHEN** cmd target 执行的进程正常退出但 exit code 为 1
|
||||
- **THEN** 系统 SHALL 记录包含 exitCode、stdoutPreview、stderrPreview 的 observation,并由 expect.exitCode 决定 matched 结果;API detail SHALL 为 `exitCode=1`
|
||||
|
||||
#### Scenario: 命令启动失败
|
||||
- **WHEN** cmd target 的 exec 不存在或无法启动
|
||||
- **THEN** 系统 SHALL 记录 `matched=false`,observation SHALL 为 null,并在 failure 中写入 kind=`error` 和可读错误信息
|
||||
|
||||
#### Scenario: 命令超时
|
||||
- **WHEN** cmd target 在 timeout 时间内未结束
|
||||
- **THEN** 系统 MUST 终止该子进程,记录 `matched=false`,并在 failure 中写入命令超时信息;如已收集输出片段,observation SHALL 包含 stdoutPreview、stderrPreview 和 error
|
||||
|
||||
#### Scenario: 命令输出超限
|
||||
- **WHEN** cmd target 的 stdout 和 stderr 合计输出超过 `maxOutputBytes`
|
||||
- **THEN** 系统 MUST 停止收集输出并终止该检查,记录 `matched=false`,并在 failure 中写入输出超限信息;observation SHALL 包含已截断输出预览和 error
|
||||
@@ -1,16 +0,0 @@
|
||||
## MODIFIED Requirements
|
||||
|
||||
### Requirement: ping detail 摘要
|
||||
系统 SHALL 在 ping API 序列化时从 observation 动态生成结构化 detail 摘要,展示关键指标。API registry type SHALL 仍为 `ping`。
|
||||
|
||||
#### Scenario: 目标可达无丢包
|
||||
- **WHEN** ping observation 为 alive=true, avgLatencyMs=12, packetLoss=0%, transmitted=3, received=3
|
||||
- **THEN** detail SHALL 为 `alive, avg 12ms, loss 0% (3/3)`
|
||||
|
||||
#### Scenario: 目标可达有丢包
|
||||
- **WHEN** ping observation 为 alive=true, avgLatencyMs=156, maxLatencyMs=340, packetLoss=33%, transmitted=3, received=2
|
||||
- **THEN** detail SHALL 包含 avg、max 和 loss 信息
|
||||
|
||||
#### Scenario: 目标不可达
|
||||
- **WHEN** ping observation 为 alive=false, transmitted=3, received=0
|
||||
- **THEN** detail SHALL 为 `unreachable (0/3 received)`
|
||||
@@ -1,39 +0,0 @@
|
||||
## MODIFIED Requirements
|
||||
|
||||
### Requirement: LLM Failure Phase 与状态摘要
|
||||
LLM checker SHALL 使用 `request`、`status`、`headers`、`stream`、`output`、`finishReason`、`rawFinishReason`、`usage`、`duration` 作为第一版 failure phase。成功结果的 API detail SHALL 从持久化 observation 动态构造,简短描述 provider、mode、HTTP status、finish reason、raw finish reason、first token、输出长度和 token usage 中可用的信息。observation 和 detail MUST NOT 写入完整 prompt、完整输出或 key。
|
||||
|
||||
#### Scenario: request failure
|
||||
- **WHEN** 模型请求因网络错误、认证调用异常、AbortSignal 或无 HTTP metadata 的 SDK 错误失败
|
||||
- **THEN** LLM checker SHALL 返回 `phase: "request"` 的 error failure
|
||||
|
||||
#### Scenario: output mismatch failure
|
||||
- **WHEN** 模型输出不满足 `expect.output`
|
||||
- **THEN** LLM checker SHALL 返回 `phase: "output"` 的 mismatch failure
|
||||
|
||||
#### Scenario: 非流式成功摘要
|
||||
- **WHEN** `provider: openai` 的非流式检查成功
|
||||
- **THEN** detail SHALL 使用类似 `LLM openai http 200 finish=stop, output=2 chars, usage=12/2 tokens` 的简短格式
|
||||
|
||||
#### Scenario: 流式成功摘要
|
||||
- **WHEN** `provider: anthropic` 的流式检查成功且存在 raw finish reason
|
||||
- **THEN** detail SHALL 使用类似 `LLM anthropic stream 200 finish=stop raw=end_turn, firstToken=624ms, output=2 chars` 的简短格式
|
||||
|
||||
#### Scenario: serialize 展示文本
|
||||
- **WHEN** store 同步 LLM target
|
||||
- **THEN** LLM checker `serialize()` SHALL 返回类似 `openai:gpt-4o-mini @ https://api.openai.com/v1` 的 target 展示文本和 resolved config JSON
|
||||
|
||||
### Requirement: LLM Checker 测试策略
|
||||
LLM checker 的自动化测试 MUST 不访问真实外部模型服务。测试 SHALL 使用本地 mock HTTP/SSE 服务模拟 OpenAI Chat Completions、OpenAI Responses 和 Anthropic Messages 的成功、错误和流式响应。测试 SHALL 覆盖 schema、语义校验、defaults 合并、变量替换、provider factory、observation、expect、execute、registry 注册、配置加载和 JSON Schema 导出。
|
||||
|
||||
#### Scenario: 本地 mock provider 测试成功路径
|
||||
- **WHEN** 测试运行 LLM checker 的 OpenAI、OpenAI Responses 和 Anthropic 成功路径
|
||||
- **THEN** 测试 SHALL 使用本地 mock 服务返回 provider 响应,不依赖外部网络或真实 API key
|
||||
|
||||
#### Scenario: 本地 mock provider 测试错误路径
|
||||
- **WHEN** 测试运行 401、429、500、超时、stream error、stream abort、缺 usage 或无文本输出路径
|
||||
- **THEN** 测试 SHALL 断言 LLM checker 返回符合 spec 的 matched、failure phase、actual、detail 和 observation
|
||||
|
||||
#### Scenario: 质量检查覆盖 LLM checker
|
||||
- **WHEN** 实现完成后执行质量检查
|
||||
- **THEN** `bun run schema:check`、`bun run check` SHALL 通过
|
||||
@@ -1,48 +0,0 @@
|
||||
## MODIFIED Requirements
|
||||
|
||||
### Requirement: 新增共享类型
|
||||
系统 SHALL 在 `src/shared/api.ts` 中定义 Dashboard 和 Metrics 相关共享类型。CheckResult SHALL 包含 durationMs(null | number)、failure(CheckFailure | null)、matched(boolean)、detail(null | string)、observation(Record<string, unknown> | null)、timestamp(string)。其中 detail 替代原 statusDetail 字段名。
|
||||
|
||||
#### Scenario: DashboardResponse 类型
|
||||
- **WHEN** 前后端共享 `DashboardResponse` 类型
|
||||
- **THEN** 该类型 SHALL 包含 summary 和 targets 字段
|
||||
|
||||
#### Scenario: TargetStatus 类型
|
||||
- **WHEN** 前后端共享 `TargetStatus` 类型
|
||||
- **THEN** 该类型 SHALL 包含目标基本信息字段(id、name、description、group、type、target、interval)、stats(totalChecks、upChecks、downChecks、availability)、currentStreak 和 recentSamples 字段,其中 name 和 description 类型均为 null 或字符串
|
||||
|
||||
#### Scenario: TargetMetricsResponse 类型
|
||||
- **WHEN** 前后端共享 `TargetMetricsResponse` 类型
|
||||
- **THEN** 该类型 SHALL 包含 targetId、window、stats 和 trend 字段
|
||||
|
||||
#### Scenario: TrendPoint 类型
|
||||
- **WHEN** 前后端共享 `TrendPoint` 类型
|
||||
- **THEN** 该类型 SHALL 包含 bucketStart、avgDurationMs、minDurationMs、maxDurationMs、availability、totalChecks、upChecks、downChecks 字段
|
||||
|
||||
#### Scenario: CheckResult 类型变更
|
||||
- **WHEN** 前端或后端引用 CheckResult 类型
|
||||
- **THEN** 该类型 SHALL 包含 `timestamp: string`、`matched: boolean`、`durationMs: number | null`、`detail: string | null`、`observation: Record<string, unknown> | null`、`failure` 字段,不包含 statusDetail 字段,不包含 success 字段
|
||||
|
||||
#### Scenario: RecentSample 类型
|
||||
- **WHEN** 前后端共享 `RecentSample` 类型
|
||||
- **THEN** 该类型 SHALL 包含 `timestamp: string`、`durationMs: number | null`、`up: boolean` 字段,其中 up 为 boolean 且等于 matched
|
||||
|
||||
#### Scenario: HistoryResponse 类型
|
||||
- **WHEN** 前后端共享 `HistoryResponse` 类型
|
||||
- **THEN** 该类型 SHALL 包含 `items: CheckResult[]`、`total: number`、`page: number`、`pageSize: number` 字段
|
||||
|
||||
#### Scenario: API 序列化构造 detail
|
||||
- **WHEN** API 路由序列化 StoredCheckResult 为 API 响应
|
||||
- **THEN** 系统 SHALL 从 StoredCheckResult 中反序列化 observation,根据 target type 通过 checkerRegistry 获取对应 checker 并调用 buildDetail(observation) 动态生成 detail 字段
|
||||
|
||||
#### Scenario: mapCheckResult 接收 type 参数
|
||||
- **WHEN** 序列化辅助函数 mapCheckResult 被调用
|
||||
- **THEN** 函数 SHALL 接收 target type 参数,用于从 registry 获取对应 checker 调用 buildDetail
|
||||
|
||||
#### Scenario: Dashboard API 传递 type
|
||||
- **WHEN** Dashboard 路由序列化 latestCheck
|
||||
- **THEN** 路由 SHALL 将 target.type 传递给 mapCheckResult
|
||||
|
||||
#### Scenario: History API 传递 type
|
||||
- **WHEN** History 路由序列化历史记录列表
|
||||
- **THEN** 路由 SHALL 将已查询的 target.type 传递给 mapCheckResult
|
||||
@@ -1,41 +0,0 @@
|
||||
## MODIFIED Requirements
|
||||
|
||||
### Requirement: SQLite 数据库初始化
|
||||
系统 SHALL 使用 Bun 内置 `bun:sqlite` 模块在配置的数据目录下创建 SQLite 数据库文件,并以 WAL 模式运行。数据库 schema MUST 支持 typed checker target 和结构化检查结果,targets 表 MUST 包含 `grp` 列存储分组信息,且 targets 表的 `name` 和 `description` 列 MUST 允许 NULL。
|
||||
|
||||
#### Scenario: 首次启动创建数据库
|
||||
- **WHEN** 指定的数据目录下不存在数据库文件
|
||||
- **THEN** 系统 SHALL 创建数据库文件并初始化 targets 表和 check_results 表,check_results 表包含 id(INTEGER PRIMARY KEY AUTOINCREMENT)、target_id(TEXT NOT NULL)、timestamp(TEXT NOT NULL)、matched(INTEGER NOT NULL)、duration_ms(REAL)、observation(TEXT)、failure(TEXT),不包含 status_detail 列,不包含 success 列
|
||||
|
||||
#### Scenario: targets name 列允许 NULL
|
||||
- **WHEN** 系统首次创建 targets 表
|
||||
- **THEN** targets.name 列 SHALL 允许存储 NULL
|
||||
|
||||
#### Scenario: 数据目录不存在
|
||||
- **WHEN** 配置的数据目录路径不存在
|
||||
- **THEN** 系统 SHALL 自动创建该目录
|
||||
|
||||
#### Scenario: 数据库已存在时启动
|
||||
- **WHEN** 数据库文件已存在
|
||||
- **THEN** 系统 SHALL 直接打开数据库,不重新建表
|
||||
|
||||
#### Scenario: 外键约束
|
||||
- **THEN** 系统 SHALL 启用 `PRAGMA foreign_keys = ON`
|
||||
|
||||
#### Scenario: 级联删除
|
||||
- **THEN** check_results 表的外键约束 SHALL 使用 `ON DELETE CASCADE`,确保删除目标时自动清理关联结果记录
|
||||
|
||||
### Requirement: check_results 表追加写入
|
||||
系统 SHALL 将每次检查结果追加写入 check_results 表,不更新或删除已有记录。
|
||||
|
||||
#### Scenario: 写入检查结果
|
||||
- **WHEN** 一次 checker 执行完成
|
||||
- **THEN** 系统 SHALL 插入一条包含 target_id、timestamp、matched、duration_ms、observation、failure 的记录,其中 observation 使用 JSON.stringify 序列化为 TEXT
|
||||
|
||||
#### Scenario: 查询检查结果
|
||||
- **WHEN** 系统查询 latest check 或历史 check_results
|
||||
- **THEN** 存储层 SHALL 返回 observation 字段而非 status_detail 字段,供 API 序列化层反序列化并构造 detail
|
||||
|
||||
#### Scenario: 写入结构化失败信息
|
||||
- **WHEN** checker 执行失败或 expect 不匹配
|
||||
- **THEN** 系统 SHALL 将首个失败原因序列化写入 failure 字段
|
||||
@@ -1,16 +0,0 @@
|
||||
## MODIFIED Requirements
|
||||
|
||||
### Requirement: 拨测结果记录
|
||||
系统 SHALL 在每次 checker 完成后,将结果写入 SQLite 数据存储,包含 target_id、timestamp、matched、duration_ms、observation、failure 字段。detail SHALL 为 API 层派生字段,不写入存储层;系统 SHALL NOT 写入 status_detail 字段。
|
||||
|
||||
#### Scenario: 成功检查结果记录
|
||||
- **WHEN** checker 成功执行且 expect 全部匹配
|
||||
- **THEN** 系统 SHALL 记录 matched=true、duration_ms、observation,failure 为 null
|
||||
|
||||
#### Scenario: 执行失败结果记录
|
||||
- **WHEN** checker 执行失败(网络错误、超时、命令启动失败、输出超限等)
|
||||
- **THEN** 系统 SHALL 记录 matched=false、failure.kind="error" 和具体错误信息,并在可收集领域观测数据时记录 observation
|
||||
|
||||
#### Scenario: expect 不匹配结果记录
|
||||
- **WHEN** checker 执行成功但 expect 不匹配
|
||||
- **THEN** 系统 SHALL 记录 matched=false、observation、failure.kind="mismatch" 和具体不匹配信息
|
||||
@@ -1,16 +0,0 @@
|
||||
## MODIFIED Requirements
|
||||
|
||||
### Requirement: 记录面板
|
||||
记录 Tab SHALL 展示分页检查结果列表,使用 TDesign PrimaryTable。
|
||||
|
||||
#### Scenario: 检查结果表格
|
||||
- **WHEN** 记录面板渲染且数据可用
|
||||
- **THEN** 面板 SHALL 使用 TDesign PrimaryTable 展示检查结果,列包含:状态(StatusDot 圆点)、时间(YYYY-MM-DD HH:mm:ss 格式)、耗时(标题含 ms 单位,单元格仅显示数值,居中对齐)、详情(detail 和 failure.message 用冒号拼接)
|
||||
|
||||
#### Scenario: 服务端分页
|
||||
- **WHEN** 检查结果总数超过一页
|
||||
- **THEN** 表格 SHALL 使用内建 pagination(disableDataPage=true),分页器显示在表格底部
|
||||
|
||||
#### Scenario: 翻页触发请求
|
||||
- **WHEN** 用户切换分页页码
|
||||
- **THEN** 系统 SHALL 请求对应页码的服务端数据,表格更新
|
||||
@@ -1,51 +0,0 @@
|
||||
## MODIFIED Requirements
|
||||
|
||||
### Requirement: tcp checker 执行
|
||||
系统 SHALL 按 tcp target 配置建立 TCP 连接,记录完整执行耗时和 TCP observation,并在连接失败、超时或资源超限时产生结构化失败信息。
|
||||
|
||||
#### Scenario: TCP 连接成功
|
||||
- **WHEN** tcp target 指向可连接的 TCP 服务,且未配置 expect 或 `expect.connected` 为 `true`
|
||||
- **THEN** 系统 SHALL 记录 `matched=true`、`durationMs` 和包含 connected、connectTimeMs、banner 的 observation,并关闭 socket
|
||||
|
||||
#### Scenario: TCP 连接失败
|
||||
- **WHEN** tcp target 指向不可连接的 host/port,且未配置 expect 或 `expect.connected` 为 `true`
|
||||
- **THEN** 系统 SHALL 记录 `matched=false`,observation SHALL 包含 connected=false 和错误信息,failure 的 kind 为 `error`,phase 为 `connect`,message 包含可读连接失败原因
|
||||
|
||||
#### Scenario: 期望端口不可达且连接失败
|
||||
- **WHEN** tcp target 配置 `expect.connected: false`,且 TCP 连接失败
|
||||
- **THEN** 系统 SHALL 记录 `matched=true`,observation SHALL 包含 connected=false 和实际连接失败原因,API detail SHALL 展示实际连接失败原因摘要
|
||||
|
||||
#### Scenario: 期望端口不可达但连接成功
|
||||
- **WHEN** tcp target 配置 `expect.connected: false`,但 TCP 连接成功
|
||||
- **THEN** 系统 SHALL 记录 `matched=false`,observation SHALL 包含 connected=true,failure 的 kind 为 `mismatch`,phase 为 `connected`
|
||||
|
||||
#### Scenario: TCP 执行超时
|
||||
- **WHEN** 引擎注入的 `ctx.signal` 在 TCP 连接或 banner 读取过程中 abort
|
||||
- **THEN** 系统 SHALL best-effort 关闭 socket,记录 `matched=false`,failure 的 kind 为 `error`,phase 为 `connect` 或 `banner`,message 包含超时信息,并在可收集时记录 observation
|
||||
|
||||
#### Scenario: duration 包含 banner 读取
|
||||
- **WHEN** tcp target 开启 `readBanner` 且服务端延迟发送 banner
|
||||
- **THEN** 结果中的 `durationMs` SHALL 覆盖连接建立、banner 等待、banner 读取和 expect 校验的完整耗时
|
||||
|
||||
### Requirement: tcp banner 读取
|
||||
系统 SHALL 仅在 `tcp.readBanner: true` 时读取服务端主动发送的 banner 数据,并同时受 `bannerReadTimeout` 和 `maxBannerBytes` 限制。
|
||||
|
||||
#### Scenario: 默认不读取 banner
|
||||
- **WHEN** tcp target 未配置 `readBanner` 或配置为 `false`
|
||||
- **THEN** 系统 SHALL 在连接建立后立即进入 connected 和 duration 校验,不等待服务端数据
|
||||
|
||||
#### Scenario: 读取服务端 banner
|
||||
- **WHEN** tcp target 配置 `readBanner: true`,且服务端连接后发送 `220 smtp.example.com ESMTP`
|
||||
- **THEN** 系统 SHALL 收集 banner 文本,并允许后续 `expect.banner` 对该文本执行 operator 断言
|
||||
|
||||
#### Scenario: banner 等待超时无数据
|
||||
- **WHEN** tcp target 配置 `readBanner: true`,但服务端在 `bannerReadTimeout` 内未发送任何数据
|
||||
- **THEN** 系统 SHALL 将 banner 视为空字符串并继续执行 expect 校验,不将无 banner 本身作为连接错误
|
||||
|
||||
#### Scenario: banner 读取超过最大字节数
|
||||
- **WHEN** 服务端发送的 banner 数据超过 `maxBannerBytes`
|
||||
- **THEN** 系统 SHALL 停止读取并记录 `matched=false`、failure.kind=`error`、failure.phase=`banner` 的结构化错误
|
||||
|
||||
#### Scenario: banner detail 截断展示
|
||||
- **WHEN** tcp target 成功读取到较长 banner
|
||||
- **THEN** observation.banner SHALL 保存截断后的 banner 摘要,API detail SHALL 展示截断后的 banner 摘要,避免 UI 展示过长文本
|
||||
@@ -1,55 +0,0 @@
|
||||
## MODIFIED Requirements
|
||||
|
||||
### Requirement: udp checker 执行
|
||||
系统 SHALL 使用 Bun connected UDP socket 向目标发送单个 datagram,等待第一个 UDP 响应 datagram,记录完整执行耗时和 UDP observation,并在发送失败、超时、资源超限或底层 socket 错误时产生结构化失败信息。
|
||||
|
||||
#### Scenario: UDP 请求响应成功
|
||||
- **WHEN** udp target 指向会返回 `PONG` 的 UDP 服务,且未配置 expect 或 `expect.responded` 为 `true`
|
||||
- **THEN** 系统 SHALL 记录 `matched=true`、`durationMs` 和包含响应大小的 observation,并关闭 socket
|
||||
|
||||
#### Scenario: 使用 hostname 执行 UDP 探测
|
||||
- **WHEN** udp target 的 `udp.host` 为可解析域名或 `localhost`
|
||||
- **THEN** 系统 SHALL 使用 Bun connected UDP socket 完成发送和接收,不要求配置 IP 地址
|
||||
|
||||
#### Scenario: 只处理第一个响应 datagram
|
||||
- **WHEN** UDP 服务对一次请求返回多个 datagram
|
||||
- **THEN** 系统 SHALL 仅使用第一个收到的 UDP datagram 执行 expect 校验,并关闭 socket
|
||||
|
||||
#### Scenario: UDP 无响应且默认期望响应
|
||||
- **WHEN** udp target 指向在 timeout 内不返回 UDP datagram 的服务,且未配置 expect 或 `expect.responded` 为 `true`
|
||||
- **THEN** 系统 SHALL 在 `ctx.signal` abort 后记录 `matched=false`,observation SHALL 包含 responded=false 和 error,failure 的 kind 为 `error`,phase 为 `response`,message 包含超时或未响应信息
|
||||
|
||||
#### Scenario: 期望无响应且实际无响应
|
||||
- **WHEN** udp target 配置 `expect.responded: false`,且 timeout 内未收到 UDP datagram
|
||||
- **THEN** 系统 SHALL 记录 `matched=true`,observation SHALL 包含 responded=false,API detail SHALL 表示未收到响应
|
||||
|
||||
#### Scenario: 期望无响应但实际收到响应
|
||||
- **WHEN** udp target 配置 `expect.responded: false`,但收到了 UDP datagram
|
||||
- **THEN** 系统 SHALL 记录 `matched=false`,observation SHALL 包含 responded=true 和响应摘要,failure 的 kind 为 `mismatch`,phase 为 `responded`
|
||||
|
||||
#### Scenario: UDP socket 底层错误
|
||||
- **WHEN** Bun UDP socket 在发送或接收过程中触发 error 事件
|
||||
- **THEN** 系统 SHALL best-effort 关闭 socket,并记录 `matched=false`、failure.kind=`error` 和可读错误信息,并在可收集时记录 observation
|
||||
|
||||
#### Scenario: ICMP unreachable 不作为 UDP 响应
|
||||
- **WHEN** 底层系统因目标端口不可达产生 ICMP unreachable
|
||||
- **THEN** 系统 SHALL NOT 将其视为 `responded=true` 的 UDP datagram 响应
|
||||
|
||||
#### Scenario: UDP 执行超时关闭 socket
|
||||
- **WHEN** 引擎注入的 `ctx.signal` 在 UDP 发送或等待响应过程中 abort
|
||||
- **THEN** 系统 SHALL best-effort 关闭 UDP socket,并记录结构化超时或未响应结果
|
||||
|
||||
### Requirement: udp detail 摘要
|
||||
系统 SHALL 在 udp API 序列化时从 observation 动态生成简短 detail 摘要,展示关键结果并避免返回过长响应内容。
|
||||
|
||||
#### Scenario: 收到响应的摘要
|
||||
- **WHEN** udp target 收到 4 字节响应且完整执行耗时为 12ms
|
||||
- **THEN** detail SHALL 包含 `responded in 12ms` 和 `4 bytes`
|
||||
|
||||
#### Scenario: 未收到响应的摘要
|
||||
- **WHEN** udp target 配置 `expect.responded: false` 且 timeout 内未收到 UDP datagram
|
||||
- **THEN** detail SHALL 包含 `no response` 和执行耗时
|
||||
|
||||
#### Scenario: 响应内容摘要截断
|
||||
- **WHEN** udp target 收到较长响应内容
|
||||
- **THEN** detail SHALL 只展示按 `responseEncoding` 转换并截断后的响应摘要
|
||||
@@ -1,56 +0,0 @@
|
||||
## 1. 共享类型与接口变更
|
||||
|
||||
- [ ] 1.1 修改 `src/shared/api.ts` 中 CheckResult 类型:移除 statusDetail,新增 detail 和 observation 字段
|
||||
- [ ] 1.2 修改 `src/server/checker/types.ts` 中 StoredCheckResult 类型:status_detail 替换为 observation
|
||||
- [ ] 1.3 修改 `src/server/checker/runner/types.ts` 中 CheckerDefinition 接口:新增 buildDetail 方法
|
||||
|
||||
## 2. 存储层适配
|
||||
|
||||
- [ ] 2.1 修改 `src/server/checker/store.ts` 中 check_results 表 DDL:status_detail 列替换为 observation TEXT 列
|
||||
- [ ] 2.2 修改 `src/server/checker/store.ts` 中 insertCheckResult 方法:写入 observation(JSON.stringify)替代 statusDetail
|
||||
- [ ] 2.3 修改 `src/server/checker/store.ts` 中 getHistory、getLatestCheck、getLatestChecksMap 读取类型:返回 observation 字段替代 status_detail
|
||||
- [ ] 2.4 修改 `src/server/checker/engine.ts` 中 writeResult 方法:传递 observation 替代 statusDetail,异常兜底结果 observation 为 null
|
||||
|
||||
## 3. Checker execute 改造(返回 observation 替代 statusDetail)
|
||||
|
||||
- [ ] 3.1 改造 HTTP checker execute.ts:组装 observation 对象(statusCode/headers/bodyPreview/contentType/contentLength),拿到响应后始终采集 bodyPreview,移除 statusDetail 赋值
|
||||
- [ ] 3.2 改造 TCP checker execute.ts:组装 observation 对象(connected/connectTimeMs/banner/error),移除 statusDetail 赋值和 buildStatusDetail 函数
|
||||
- [ ] 3.3 改造 UDP checker execute.ts:组装 observation 对象(responded/responseSize/responsePreview/sourceAddress/sourcePort/error),移除 statusDetail 赋值和 build*Detail 函数
|
||||
- [ ] 3.4 改造 ICMP checker execute.ts:组装 observation 对象(复用 PingStats 字段 + error),移除 statusDetail 赋值和 buildStatusDetail 函数
|
||||
- [ ] 3.5 改造 DB checker execute.ts:组装 observation 对象(connected/rowCount/rowsPreview/error),移除 statusDetail 赋值
|
||||
- [ ] 3.6 改造 CMD checker execute.ts:组装 observation 对象(exitCode/stdoutPreview/stderrPreview/error),移除 statusDetail 赋值
|
||||
- [ ] 3.7 改造 LLM checker types.ts 和 observation.ts:保留执行期完整 outputText,新增持久化 observation 派生结构(outputPreview/outputLength/截断 headers)
|
||||
- [ ] 3.8 改造 LLM checker execute.ts:返回持久化 observation,继续用执行期 LlmCheckObservation 执行 expect,移除 buildStatusDetail 函数
|
||||
|
||||
## 4. Checker buildDetail 实现
|
||||
|
||||
- [ ] 4.1 为 HTTP checker 实现 buildDetail 方法:返回 `"HTTP {statusCode}"` 格式
|
||||
- [ ] 4.2 为 TCP checker 实现 buildDetail 方法:返回连接状态和 banner 摘要
|
||||
- [ ] 4.3 为 UDP checker 实现 buildDetail 方法:返回响应状态和大小摘要
|
||||
- [ ] 4.4 为 ICMP checker 实现 buildDetail 方法:返回存活状态、平均延迟和丢包率摘要
|
||||
- [ ] 4.5 为 DB checker 实现 buildDetail 方法:返回连接状态或行数摘要
|
||||
- [ ] 4.6 为 CMD checker 实现 buildDetail 方法:返回 `"exitCode={N}"` 格式
|
||||
- [ ] 4.7 为 LLM checker 实现 buildDetail 方法:返回 provider/mode/status/finish/output/usage 摘要
|
||||
|
||||
## 5. API 序列化层适配
|
||||
|
||||
- [ ] 5.1 修改 `src/server/helpers.ts` 中 mapCheckResult:接收 type 参数,反序列化 observation,observation 为 null 时 detail 为 null,否则调用 buildDetail 动态构造 detail
|
||||
- [ ] 5.2 修改 `src/server/routes/dashboard.ts`:传递 target.type 给 mapCheckResult
|
||||
- [ ] 5.3 修改 `src/server/routes/history.ts`:传递 target.type 给 mapCheckResult
|
||||
|
||||
## 6. 前端适配
|
||||
|
||||
- [ ] 6.1 修改 `src/web/constants/history-table-columns.tsx`:statusDetail 引用改为 detail
|
||||
- [ ] 6.2 修改 `src/web/components/OverviewTab.tsx`:statusDetail 引用改为 detail
|
||||
|
||||
## 7. 测试与质量保障
|
||||
|
||||
- [ ] 7.1 更新所有涉及 CheckResult 的现有测试,适配 statusDetail → detail + observation 字段变更
|
||||
- [ ] 7.2 为各 checker 的 buildDetail 方法编写单元测试
|
||||
- [ ] 7.3 更新 CheckerDefinition mock、store、engine、dashboard/history API、前端组件与 constants 的测试 fixture
|
||||
- [ ] 7.4 为 mapCheckResult 编写 observation JSON parse、null observation、unknown type 或 malformed observation 的覆盖测试
|
||||
- [ ] 7.5 执行完整测试套件、代码检查和格式检查,确保无回归
|
||||
|
||||
## 8. 文档更新
|
||||
|
||||
- [ ] 8.1 更新 README.md 和/或 DEVELOPMENT.md,反映 CheckResult 类型变更和 observation 机制
|
||||
@@ -1,4 +1,8 @@
|
||||
## ADDED Requirements
|
||||
## Purpose
|
||||
|
||||
定义 CheckResult 的 observation 数据模型、各 checker 类型 observation 结构、截断策略、序列化规则和 detail 动态构造机制。
|
||||
|
||||
## Requirements
|
||||
|
||||
### Requirement: Observation 数据模型
|
||||
CheckResult SHALL 包含 `observation: Record<string, unknown> | null` 字段,用于承载 checker 执行过程中收集的结构化观测数据。observation 为 null 表示执行过程中无法形成有意义的领域观测数据(如进程 spawn 失败、内部异常、请求在拿到响应前失败且无可记录元数据等场景)。各 checker SHALL 自行定义 observation 的内部结构,不做跨 checker 类型的统一约束。
|
||||
@@ -34,7 +38,7 @@ TCP checker 的 observation SHALL 包含 connected(boolean)、connectTimeMs
|
||||
- **THEN** observation SHALL 包含 connected=false 和错误信息
|
||||
|
||||
### Requirement: UDP Checker Observation
|
||||
UDP checker 的 observation SHALL 包含 responded(boolean)、responseSize(number | null)、responsePreview(string | null,截断)、sourceAddress(string | null)、sourcePort(number | null)、error(string | null)。
|
||||
UDP checker 的 observation SHALL 包含 responded(boolean)、durationMs(number)、responseSize(number | null)、responsePreview(string | null,截断)、sourceAddress(string | null)、sourcePort(number | null)、error(string | null)。durationMs 用于 API 序列化层生成包含耗时的 UDP detail 摘要。
|
||||
|
||||
#### Scenario: UDP 收到响应
|
||||
- **WHEN** UDP 发送数据后收到响应
|
||||
@@ -50,11 +50,11 @@
|
||||
- **THEN** checker SHALL 返回 `ConfigValidationIssue`,而不是直接抛出最终用户错误字符串
|
||||
|
||||
### Requirement: Checker 接口定义
|
||||
系统 SHALL 在 `src/server/checker/runner/types.ts` 中定义面向扩展的泛型 `CheckerDefinition<TResolved extends ResolvedTargetBase = ResolvedTargetBase>`,包含 `type`、`configKey`、TypeBox 配置契约、启动期语义校验、`resolve`、`execute`、`serialize` 成员。泛型参数 SHALL 约束 `execute` 和 `serialize` 方法的 target 参数类型,使 checker 实现内部获得编译期类型安全。默认泛型参数 `= ResolvedTargetBase` 保证中间层(registry、engine、config-loader)无需指定泛型。
|
||||
系统 SHALL 在 `src/server/checker/runner/types.ts` 中定义面向扩展的泛型 `CheckerDefinition<TResolved extends ResolvedTargetBase = ResolvedTargetBase>`,包含 `type`、`configKey`、TypeBox 配置契约、启动期语义校验、`resolve`、`execute`、`serialize`、`buildDetail` 成员。泛型参数 SHALL 约束 `execute` 和 `serialize` 方法的 target 参数类型,使 checker 实现内部获得编译期类型安全。默认泛型参数 `= ResolvedTargetBase` 保证中间层(registry、engine、config-loader)无需指定泛型。
|
||||
|
||||
#### Scenario: Checker 接口包含必要方法
|
||||
- **WHEN** 开发者实现一个新的 Checker
|
||||
- **THEN** 该实现 MUST 提供 `type`(字符串标识)、`configKey`(配置分组名)、TypeBox 配置契约、启动期语义校验、`resolve(target, context)`(解析配置并填充默认值)、`execute(target, ctx)`(执行探测返回 CheckResult)和 `serialize(target)`(返回 target 展示文本和 config JSON)
|
||||
- **THEN** 该实现 MUST 提供 `type`(字符串标识)、`configKey`(配置分组名)、TypeBox 配置契约、启动期语义校验、`resolve(target, context)`(解析配置并填充默认值)、`execute(target, ctx)`(执行探测返回 CheckResult)、`serialize(target)`(返回 target 展示文本和 config JSON)和 `buildDetail(observation)`(从 observation 构造人可读摘要)
|
||||
|
||||
#### Scenario: CheckerContext 注入 signal
|
||||
- **WHEN** 引擎调用 `checker.execute(target, ctx)`
|
||||
@@ -80,6 +80,14 @@
|
||||
- **WHEN** CheckerRegistry 存储和返回 checker 实例
|
||||
- **THEN** registry 内部 SHALL 使用 `CheckerDefinition`(等价于 `CheckerDefinition<ResolvedTargetBase>`),实现类型擦除
|
||||
|
||||
#### Scenario: buildDetail 方法签名
|
||||
- **WHEN** 开发者实现 buildDetail 方法
|
||||
- **THEN** 方法签名 SHALL 为 `buildDetail(observation: Record<string, unknown>): string | null`,接收 observation 对象并返回人可读摘要字符串或 null
|
||||
|
||||
#### Scenario: buildDetail 由 API 层调用
|
||||
- **WHEN** API 序列化 CheckResult
|
||||
- **THEN** API 层 SHALL 通过 registry 获取对应 checker 并调用 buildDetail,而非由 execute 方法直接生成 detail
|
||||
|
||||
### Requirement: CheckerRegistry 注册中心
|
||||
系统 SHALL 在 `src/server/checker/runner/registry.ts` 中提供 `CheckerRegistry` 类,支持 `register(checker)`、`get(type)` 和 `supportedTypes`。重复注册同一 type SHALL 抛出错误。registry 内部 SHALL 存储 `CheckerDefinition`(使用默认泛型参数),对外提供类型擦除后的接口。
|
||||
|
||||
|
||||
@@ -32,27 +32,27 @@
|
||||
- **THEN** 系统 MUST NOT 向子进程 stdin 写入数据,避免命令因等待输入而阻塞
|
||||
|
||||
### Requirement: cmd checker 执行
|
||||
系统 SHALL 按 cmd target 配置执行本地命令,记录执行耗时、退出码、stdout 和 stderr,并在执行失败时产生结构化错误信息。
|
||||
系统 SHALL 按 cmd target 配置执行本地命令,记录执行耗时、退出码、stdout 和 stderr observation,并在执行失败时产生结构化错误信息。
|
||||
|
||||
#### Scenario: 命令正常退出
|
||||
- **WHEN** cmd target 执行的进程正常退出且 exit code 为 0
|
||||
- **THEN** 系统 SHALL 记录 `durationMs`、`statusDetail="exitCode=0"`,并进入 expect 校验
|
||||
- **THEN** 系统 SHALL 记录 `durationMs` 和包含 exitCode、stdoutPreview、stderrPreview 的 observation,并进入 expect 校验;API detail SHALL 为 `exitCode=0`
|
||||
|
||||
#### Scenario: 命令非零退出
|
||||
- **WHEN** cmd target 执行的进程正常退出但 exit code 为 1
|
||||
- **THEN** 系统 SHALL 记录 `statusDetail="exitCode=1"`,并由 expect.exitCode 决定 matched 结果
|
||||
- **THEN** 系统 SHALL 记录包含 exitCode、stdoutPreview、stderrPreview 的 observation,并由 expect.exitCode 决定 matched 结果;API detail SHALL 为 `exitCode=1`
|
||||
|
||||
#### Scenario: 命令启动失败
|
||||
- **WHEN** cmd target 的 exec 不存在或无法启动
|
||||
- **THEN** 系统 SHALL 记录 `matched=false`,并在 failure 中写入 kind=`error` 和可读错误信息
|
||||
- **THEN** 系统 SHALL 记录 `matched=false`,observation SHALL 为 null,并在 failure 中写入 kind=`error` 和可读错误信息
|
||||
|
||||
#### Scenario: 命令超时
|
||||
- **WHEN** cmd target 在 timeout 时间内未结束
|
||||
- **THEN** 系统 MUST 终止该子进程,记录 `matched=false`,并在 failure 中写入命令超时信息
|
||||
- **THEN** 系统 MUST 终止该子进程,记录 `matched=false`,并在 failure 中写入命令超时信息;如已收集输出片段,observation SHALL 包含 stdoutPreview、stderrPreview 和 error
|
||||
|
||||
#### Scenario: 命令输出超限
|
||||
- **WHEN** cmd target 的 stdout 和 stderr 合计输出超过 `maxOutputBytes`
|
||||
- **THEN** 系统 MUST 停止收集输出并终止该检查,记录 `matched=false`,并在 failure 中写入输出超限信息
|
||||
- **THEN** 系统 MUST 停止收集输出并终止该检查,记录 `matched=false`,并在 failure 中写入输出超限信息;observation SHALL 包含已截断输出预览和 error
|
||||
|
||||
### Requirement: cmd expect 校验
|
||||
系统 SHALL 支持 cmd 专用 expect,包括 `exitCode`、`durationMs`、`stdout` 和 `stderr`,并按 exitCode、durationMs、stdout、stderr 的阶段顺序快速失败。`exitCode` SHALL 保持有限整数数组语义,未配置时默认 `[0]`。`durationMs` SHALL 使用共享 `ValueMatcher` 校验完整命令执行耗时。`stdout` 和 `stderr` MUST 使用共享 `ContentRules` 数组,直接 matcher 作用于对应输出文本,`json` extractor SHALL 支持对 JSON CLI 输出执行 JSONPath 断言。
|
||||
|
||||
@@ -172,17 +172,17 @@
|
||||
- **WHEN** YAML 中 ping target 的 `expect.maxLatencyMs` 不是合法 `ValueMatcher`
|
||||
- **THEN** 系统 SHALL 以配置错误退出,提示 expect.maxLatencyMs 格式错误
|
||||
|
||||
### Requirement: ping statusDetail 摘要
|
||||
系统 SHALL 在 ping 执行成功后生成结构化 statusDetail 摘要,展示关键指标。
|
||||
### Requirement: ping detail 摘要
|
||||
系统 SHALL 在 ping API 序列化时从 observation 动态生成结构化 detail 摘要,展示关键指标。API registry type SHALL 仍为 `ping`。
|
||||
|
||||
#### Scenario: 目标可达无丢包
|
||||
- **WHEN** ping 结果为 alive=true, avg=12ms, packetLoss=0%, transmitted=3, received=3
|
||||
- **THEN** statusDetail SHALL 为 `alive, avg 12ms, loss 0% (3/3)`
|
||||
- **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 结果为 alive=true, avg=156ms, max=340ms, packetLoss=33%, transmitted=3, received=2
|
||||
- **THEN** statusDetail SHALL 包含 avg、max 和 loss 信息
|
||||
- **WHEN** ping 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** ping observation 为 alive=false, transmitted=3, received=0
|
||||
- **THEN** detail SHALL 为 `unreachable (0/3 received)`
|
||||
|
||||
@@ -205,7 +205,7 @@ LLM checker SHALL 仅允许 `mode: stream` 使用 `expect.stream`。`expect.stre
|
||||
- **THEN** 默认 `stream.completed=true` SHALL NOT 阻断基于 status 和 headers 的 APICallError 状态探测
|
||||
|
||||
### Requirement: LLM Failure Phase 与状态摘要
|
||||
LLM checker SHALL 使用 `request`、`status`、`headers`、`stream`、`output`、`finishReason`、`rawFinishReason`、`usage`、`duration` 作为第一版 failure phase。成功结果的 `statusDetail` SHALL 简短描述 provider、mode、HTTP status、finish reason、raw finish reason、first token、输出长度和 token usage 中可用的信息。`statusDetail` MUST NOT 写入完整 prompt、完整输出或 key。
|
||||
LLM checker SHALL 使用 `request`、`status`、`headers`、`stream`、`output`、`finishReason`、`rawFinishReason`、`usage`、`duration` 作为第一版 failure phase。成功结果的 API detail SHALL 从持久化 observation 动态构造,简短描述 provider、mode、HTTP status、finish reason、raw finish reason、first token、输出长度和 token usage 中可用的信息。observation 和 detail MUST NOT 写入完整 prompt、完整输出或 key。
|
||||
|
||||
#### Scenario: request failure
|
||||
- **WHEN** 模型请求因网络错误、认证调用异常、AbortSignal 或无 HTTP metadata 的 SDK 错误失败
|
||||
@@ -217,11 +217,11 @@ LLM checker SHALL 使用 `request`、`status`、`headers`、`stream`、`output`
|
||||
|
||||
#### Scenario: 非流式成功摘要
|
||||
- **WHEN** `provider: openai` 的非流式检查成功
|
||||
- **THEN** `statusDetail` SHALL 使用类似 `LLM openai http 200 finish=stop, output=2 chars, usage=12/2 tokens` 的简短格式
|
||||
- **THEN** detail SHALL 使用类似 `LLM openai http 200 finish=stop, output=2 chars, usage=12/2 tokens` 的简短格式
|
||||
|
||||
#### Scenario: 流式成功摘要
|
||||
- **WHEN** `provider: anthropic` 的流式检查成功且存在 raw finish reason
|
||||
- **THEN** `statusDetail` SHALL 使用类似 `LLM anthropic stream 200 finish=stop raw=end_turn, firstToken=624ms, output=2 chars` 的简短格式
|
||||
- **THEN** detail SHALL 使用类似 `LLM anthropic stream 200 finish=stop raw=end_turn, firstToken=624ms, output=2 chars` 的简短格式
|
||||
|
||||
#### Scenario: serialize 展示文本
|
||||
- **WHEN** store 同步 LLM target
|
||||
@@ -236,7 +236,7 @@ LLM checker 的自动化测试 MUST 不访问真实外部模型服务。测试 S
|
||||
|
||||
#### Scenario: 本地 mock provider 测试错误路径
|
||||
- **WHEN** 测试运行 401、429、500、超时、stream error、stream abort、缺 usage 或无文本输出路径
|
||||
- **THEN** 测试 SHALL 断言 LLM checker 返回符合 spec 的 matched、failure phase、actual 和 statusDetail
|
||||
- **THEN** 测试 SHALL 断言 LLM checker 返回符合 spec 的 matched、failure phase、actual、detail 和 observation
|
||||
|
||||
#### Scenario: 质量检查覆盖 LLM checker
|
||||
- **WHEN** 实现完成后执行质量检查
|
||||
|
||||
@@ -105,7 +105,7 @@ Dashboard API SHALL 返回基于时间窗口计算的目标统计和连续状态
|
||||
- **THEN** 系统 SHALL 返回 400 状态码和错误信息
|
||||
|
||||
### Requirement: 新增共享类型
|
||||
系统 SHALL 在 `src/shared/api.ts` 中定义 Dashboard 和 Metrics 相关共享类型。
|
||||
系统 SHALL 在 `src/shared/api.ts` 中定义 Dashboard 和 Metrics 相关共享类型。CheckResult SHALL 包含 durationMs(null | number)、failure(CheckFailure | null)、matched(boolean)、detail(null | string)、observation(Record<string, unknown> | null)、timestamp(string)。其中 detail 替代原 statusDetail 字段名。
|
||||
|
||||
#### Scenario: DashboardResponse 类型
|
||||
- **WHEN** 前后端共享 `DashboardResponse` 类型
|
||||
@@ -123,9 +123,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 +135,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` 端点,不受拨测功能影响。
|
||||
|
||||
|
||||
@@ -9,7 +9,7 @@
|
||||
|
||||
#### Scenario: 首次启动创建数据库
|
||||
- **WHEN** 指定的数据目录下不存在数据库文件
|
||||
- **THEN** 系统 SHALL 创建数据库文件并初始化 targets 表和 check_results 表,check_results 表包含 id(INTEGER PRIMARY KEY AUTOINCREMENT)、target_id(INTEGER NOT NULL)、timestamp(TEXT NOT NULL)、matched(INTEGER NOT NULL)、duration_ms(REAL)、status_detail(TEXT)、failure(TEXT),不包含 success 列
|
||||
- **THEN** 系统 SHALL 创建数据库文件并初始化 targets 表和 check_results 表,check_results 表包含 id(INTEGER PRIMARY KEY AUTOINCREMENT)、target_id(TEXT NOT NULL)、timestamp(TEXT NOT NULL)、matched(INTEGER NOT NULL)、duration_ms(REAL)、observation(TEXT)、failure(TEXT),不包含 status_detail 列,不包含 success 列
|
||||
|
||||
#### Scenario: targets name 列允许 NULL
|
||||
- **WHEN** 系统首次创建 targets 表
|
||||
@@ -61,7 +61,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 不匹配
|
||||
|
||||
@@ -214,19 +214,19 @@ HTTP checker SHALL 将运行期失败归属到实际失败阶段。请求、网
|
||||
- **THEN** 系统 SHALL 记录 `failure.phase="body"`,且 SHALL NOT 将该失败记录为 request 错误
|
||||
|
||||
### Requirement: 拨测结果记录
|
||||
系统 SHALL 在每次 checker 完成后,将结果写入 SQLite 数据存储,包含 target_id、timestamp、matched、duration_ms、status_detail、failure 字段。
|
||||
系统 SHALL 在每次 checker 完成后,将结果写入 SQLite 数据存储,包含 target_id、timestamp、matched、duration_ms、observation、failure 字段。detail SHALL 为 API 层派生字段,不写入存储层;系统 SHALL NOT 写入 status_detail 字段。
|
||||
|
||||
#### Scenario: 成功检查结果记录
|
||||
- **WHEN** checker 成功执行且 expect 全部匹配
|
||||
- **THEN** 系统 SHALL 记录 matched=true、duration_ms、status_detail,failure 为 null
|
||||
- **THEN** 系统 SHALL 记录 matched=true、duration_ms、observation,failure 为 null
|
||||
|
||||
#### Scenario: 执行失败结果记录
|
||||
- **WHEN** checker 执行失败(网络错误、超时、命令启动失败、输出超限等)
|
||||
- **THEN** 系统 SHALL 记录 matched=false、failure.kind="error" 和具体错误信息
|
||||
- **THEN** 系统 SHALL 记录 matched=false、failure.kind="error" 和具体错误信息,并在可收集领域观测数据时记录 observation
|
||||
|
||||
#### Scenario: expect 不匹配结果记录
|
||||
- **WHEN** checker 执行成功但 expect 不匹配
|
||||
- **THEN** 系统 SHALL 记录 matched=false、failure.kind="mismatch" 和具体不匹配信息
|
||||
- **THEN** 系统 SHALL 记录 matched=false、observation、failure.kind="mismatch" 和具体不匹配信息
|
||||
|
||||
### Requirement: runner 选择
|
||||
系统 SHALL 根据 target.type 选择对应 runner 执行检查。
|
||||
|
||||
@@ -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** 检查结果总数超过一页
|
||||
|
||||
@@ -36,27 +36,27 @@
|
||||
- **THEN** `target` 展示摘要 SHALL 为 `<host>:<port>`,`config` JSON SHALL 包含 resolved 后的 host、port、readBanner、bannerReadTimeout 和 maxBannerBytes
|
||||
|
||||
### Requirement: tcp checker 执行
|
||||
系统 SHALL 按 tcp target 配置建立 TCP 连接,记录完整执行耗时,并在连接失败、超时或资源超限时产生结构化失败信息。
|
||||
系统 SHALL 按 tcp target 配置建立 TCP 连接,记录完整执行耗时和 TCP observation,并在连接失败、超时或资源超限时产生结构化失败信息。
|
||||
|
||||
#### Scenario: TCP 连接成功
|
||||
- **WHEN** tcp target 指向可连接的 TCP 服务,且未配置 expect 或 `expect.connected` 为 `true`
|
||||
- **THEN** 系统 SHALL 记录 `matched=true`、`durationMs` 和 `statusDetail`,并关闭 socket
|
||||
- **THEN** 系统 SHALL 记录 `matched=true`、`durationMs` 和包含 connected、connectTimeMs、banner 的 observation,并关闭 socket
|
||||
|
||||
#### Scenario: TCP 连接失败
|
||||
- **WHEN** tcp target 指向不可连接的 host/port,且未配置 expect 或 `expect.connected` 为 `true`
|
||||
- **THEN** 系统 SHALL 记录 `matched=false`,failure 的 kind 为 `error`,phase 为 `connect`,message 包含可读连接失败原因
|
||||
- **THEN** 系统 SHALL 记录 `matched=false`,observation SHALL 包含 connected=false 和错误信息,failure 的 kind 为 `error`,phase 为 `connect`,message 包含可读连接失败原因
|
||||
|
||||
#### Scenario: 期望端口不可达且连接失败
|
||||
- **WHEN** tcp target 配置 `expect.connected: false`,且 TCP 连接失败
|
||||
- **THEN** 系统 SHALL 记录 `matched=true`,statusDetail SHALL 展示实际连接失败原因摘要
|
||||
- **THEN** 系统 SHALL 记录 `matched=true`,observation SHALL 包含 connected=false 和实际连接失败原因,API detail SHALL 展示实际连接失败原因摘要
|
||||
|
||||
#### Scenario: 期望端口不可达但连接成功
|
||||
- **WHEN** tcp target 配置 `expect.connected: false`,但 TCP 连接成功
|
||||
- **THEN** 系统 SHALL 记录 `matched=false`,failure 的 kind 为 `mismatch`,phase 为 `connected`
|
||||
- **THEN** 系统 SHALL 记录 `matched=false`,observation SHALL 包含 connected=true,failure 的 kind 为 `mismatch`,phase 为 `connected`
|
||||
|
||||
#### Scenario: TCP 执行超时
|
||||
- **WHEN** 引擎注入的 `ctx.signal` 在 TCP 连接或 banner 读取过程中 abort
|
||||
- **THEN** 系统 SHALL best-effort 关闭 socket,记录 `matched=false`,failure 的 kind 为 `error`,phase 为 `connect` 或 `banner`,message 包含超时信息
|
||||
- **THEN** 系统 SHALL best-effort 关闭 socket,记录 `matched=false`,failure 的 kind 为 `error`,phase 为 `connect` 或 `banner`,message 包含超时信息,并在可收集时记录 observation
|
||||
|
||||
#### Scenario: duration 包含 banner 读取
|
||||
- **WHEN** tcp target 开启 `readBanner` 且服务端延迟发送 banner
|
||||
@@ -81,9 +81,9 @@
|
||||
- **WHEN** 服务端发送的 banner 数据超过 `maxBannerBytes`
|
||||
- **THEN** 系统 SHALL 停止读取并记录 `matched=false`、failure.kind=`error`、failure.phase=`banner` 的结构化错误
|
||||
|
||||
#### Scenario: banner statusDetail 截断展示
|
||||
#### Scenario: banner detail 截断展示
|
||||
- **WHEN** tcp target 成功读取到较长 banner
|
||||
- **THEN** `statusDetail` SHALL 展示截断后的 banner 摘要,避免 UI 和历史记录写入过长文本
|
||||
- **THEN** observation.banner SHALL 保存截断后的 banner 摘要,API detail SHALL 展示截断后的 banner 摘要,避免 UI 展示过长文本
|
||||
|
||||
### Requirement: tcp expect 校验
|
||||
系统 SHALL 支持 tcp 专属 expect,包括 `connected`、`banner` 和 `durationMs`,并按 connected、banner、durationMs 的阶段顺序快速失败。`connected` SHALL 保持布尔状态语义,未配置时默认 `true`。`banner` MUST 使用共享 `ContentRules` 数组,并仅在 `tcp.readBanner: true` 时允许配置。`durationMs` SHALL 使用共享 `ValueMatcher` 校验包含连接和 banner 读取在内的完整执行耗时。
|
||||
|
||||
@@ -75,11 +75,11 @@
|
||||
- **THEN** 系统 SHALL 以配置错误退出,提示 udp.payload 与 udp.encoding 不匹配
|
||||
|
||||
### Requirement: udp checker 执行
|
||||
系统 SHALL 使用 Bun connected UDP socket 向目标发送单个 datagram,等待第一个 UDP 响应 datagram,记录完整执行耗时,并在发送失败、超时、资源超限或底层 socket 错误时产生结构化失败信息。
|
||||
系统 SHALL 使用 Bun connected UDP socket 向目标发送单个 datagram,等待第一个 UDP 响应 datagram,记录完整执行耗时和 UDP observation,并在发送失败、超时、资源超限或底层 socket 错误时产生结构化失败信息。
|
||||
|
||||
#### Scenario: UDP 请求响应成功
|
||||
- **WHEN** udp target 指向会返回 `PONG` 的 UDP 服务,且未配置 expect 或 `expect.responded` 为 `true`
|
||||
- **THEN** 系统 SHALL 记录 `matched=true`、`durationMs` 和包含响应大小的 `statusDetail`,并关闭 socket
|
||||
- **THEN** 系统 SHALL 记录 `matched=true`、`durationMs` 和包含响应大小的 observation,并关闭 socket
|
||||
|
||||
#### Scenario: 使用 hostname 执行 UDP 探测
|
||||
- **WHEN** udp target 的 `udp.host` 为可解析域名或 `localhost`
|
||||
@@ -91,19 +91,19 @@
|
||||
|
||||
#### Scenario: UDP 无响应且默认期望响应
|
||||
- **WHEN** udp target 指向在 timeout 内不返回 UDP datagram 的服务,且未配置 expect 或 `expect.responded` 为 `true`
|
||||
- **THEN** 系统 SHALL 在 `ctx.signal` abort 后记录 `matched=false`,failure 的 kind 为 `error`,phase 为 `response`,message 包含超时或未响应信息
|
||||
- **THEN** 系统 SHALL 在 `ctx.signal` abort 后记录 `matched=false`,observation SHALL 包含 responded=false 和 error,failure 的 kind 为 `error`,phase 为 `response`,message 包含超时或未响应信息
|
||||
|
||||
#### Scenario: 期望无响应且实际无响应
|
||||
- **WHEN** udp target 配置 `expect.responded: false`,且 timeout 内未收到 UDP datagram
|
||||
- **THEN** 系统 SHALL 记录 `matched=true`,statusDetail SHALL 表示未收到响应
|
||||
- **THEN** 系统 SHALL 记录 `matched=true`,observation SHALL 包含 responded=false,API detail SHALL 表示未收到响应
|
||||
|
||||
#### Scenario: 期望无响应但实际收到响应
|
||||
- **WHEN** udp target 配置 `expect.responded: false`,但收到了 UDP datagram
|
||||
- **THEN** 系统 SHALL 记录 `matched=false`,failure 的 kind 为 `mismatch`,phase 为 `responded`
|
||||
- **THEN** 系统 SHALL 记录 `matched=false`,observation SHALL 包含 responded=true 和响应摘要,failure 的 kind 为 `mismatch`,phase 为 `responded`
|
||||
|
||||
#### Scenario: UDP socket 底层错误
|
||||
- **WHEN** Bun UDP socket 在发送或接收过程中触发 error 事件
|
||||
- **THEN** 系统 SHALL best-effort 关闭 socket,并记录 `matched=false`、failure.kind=`error` 和可读错误信息
|
||||
- **THEN** 系统 SHALL best-effort 关闭 socket,并记录 `matched=false`、failure.kind=`error` 和可读错误信息,并在可收集时记录 observation
|
||||
|
||||
#### Scenario: ICMP unreachable 不作为 UDP 响应
|
||||
- **WHEN** 底层系统因目标端口不可达产生 ICMP unreachable
|
||||
@@ -187,17 +187,17 @@
|
||||
- **WHEN** YAML 中 udp target 的 expect 包含 `status: [200]`、`maxDurationMs: 1000` 或其他非 udp expect 字段
|
||||
- **THEN** 系统 SHALL 以配置错误退出,提示 expect 包含未知字段
|
||||
|
||||
### Requirement: udp statusDetail 摘要
|
||||
系统 SHALL 在 udp 执行后生成简短 statusDetail 摘要,展示关键结果并避免写入过长响应内容。
|
||||
### Requirement: udp detail 摘要
|
||||
系统 SHALL 在 udp API 序列化时从 observation 动态生成简短 detail 摘要,展示关键结果和执行耗时并避免返回过长响应内容。UDP observation SHALL 包含 durationMs 以支持 detail 构造。
|
||||
|
||||
#### Scenario: 收到响应的摘要
|
||||
- **WHEN** udp target 收到 4 字节响应且完整执行耗时为 12ms
|
||||
- **THEN** statusDetail SHALL 包含 `responded in 12ms` 和 `4 bytes`
|
||||
- **THEN** detail SHALL 包含 `responded in 12ms` 和 `4 bytes`
|
||||
|
||||
#### Scenario: 未收到响应的摘要
|
||||
- **WHEN** udp target 配置 `expect.responded: false` 且 timeout 内未收到 UDP datagram
|
||||
- **THEN** statusDetail SHALL 包含 `no response` 和执行耗时
|
||||
- **THEN** detail SHALL 包含 `no response` 和执行耗时
|
||||
|
||||
#### Scenario: 响应内容摘要截断
|
||||
- **WHEN** udp target 收到较长响应内容
|
||||
- **THEN** statusDetail SHALL 只展示按 `responseEncoding` 转换并截断后的响应摘要
|
||||
- **THEN** detail SHALL 只展示按 `responseEncoding` 转换并截断后的响应摘要
|
||||
|
||||
@@ -73,10 +73,11 @@ export class ProbeEngine {
|
||||
console.warn("探针执行失败:", result.reason);
|
||||
if (!target) continue;
|
||||
this.writeResult({
|
||||
detail: null,
|
||||
durationMs: null,
|
||||
failure: errorFailure("internal", "engine", formatReason(result.reason)),
|
||||
matched: false,
|
||||
statusDetail: null,
|
||||
observation: null,
|
||||
targetId: target.id,
|
||||
timestamp: new Date().toISOString(),
|
||||
});
|
||||
@@ -110,7 +111,7 @@ export class ProbeEngine {
|
||||
durationMs: result.durationMs,
|
||||
failure: result.failure,
|
||||
matched: result.matched,
|
||||
statusDetail: result.statusDetail,
|
||||
observation: result.observation ?? null,
|
||||
targetId: result.targetId,
|
||||
timestamp: result.timestamp,
|
||||
});
|
||||
|
||||
@@ -13,6 +13,9 @@ import { checkExitCode } from "./expect";
|
||||
import { commandCheckerSchemas } from "./schema";
|
||||
import { validateCommandConfig } from "./validate";
|
||||
|
||||
const STDOUT_PREVIEW_MAX = 1024;
|
||||
const STDERR_PREVIEW_MAX = 1024;
|
||||
|
||||
export class CommandChecker implements CheckerDefinition<ResolvedCommandTarget> {
|
||||
readonly configKey = "cmd";
|
||||
|
||||
@@ -20,6 +23,11 @@ export class CommandChecker implements CheckerDefinition<ResolvedCommandTarget>
|
||||
|
||||
readonly type = "cmd";
|
||||
|
||||
buildDetail(observation: Record<string, unknown>): null | string {
|
||||
const exitCode = observation["exitCode"];
|
||||
return typeof exitCode === "number" ? `exitCode=${exitCode}` : null;
|
||||
}
|
||||
|
||||
async execute(t: ResolvedCommandTarget, ctx: CheckerContext): Promise<CheckResult> {
|
||||
const timestamp = new Date().toISOString();
|
||||
const start = performance.now();
|
||||
@@ -37,10 +45,11 @@ export class CommandChecker implements CheckerDefinition<ResolvedCommandTarget>
|
||||
} catch (error) {
|
||||
const durationMs = Math.round(performance.now() - start);
|
||||
return {
|
||||
detail: null,
|
||||
durationMs,
|
||||
failure: errorFailure("exitCode", "spawn", isError(error) ? error.message : String(error)),
|
||||
matched: false,
|
||||
statusDetail: null,
|
||||
observation: null,
|
||||
targetId: t.id,
|
||||
timestamp,
|
||||
};
|
||||
@@ -70,10 +79,11 @@ export class CommandChecker implements CheckerDefinition<ResolvedCommandTarget>
|
||||
} catch {
|
||||
const durationMs = Math.round(performance.now() - start);
|
||||
return {
|
||||
detail: null,
|
||||
durationMs,
|
||||
failure: errorFailure("exitCode", "execution", "输出读取失败"),
|
||||
matched: false,
|
||||
statusDetail: null,
|
||||
observation: null,
|
||||
targetId: t.id,
|
||||
timestamp,
|
||||
};
|
||||
@@ -83,24 +93,33 @@ export class CommandChecker implements CheckerDefinition<ResolvedCommandTarget>
|
||||
|
||||
const durationMs = Math.round(performance.now() - start);
|
||||
const exitCode = proc.exitCode ?? 1;
|
||||
const stdoutPreview = truncatePreview(outputResult.stdout, STDOUT_PREVIEW_MAX);
|
||||
const stderrPreview = truncatePreview(outputResult.stderr, STDERR_PREVIEW_MAX);
|
||||
const observation: Record<string, unknown> = { error: null, exitCode, stderrPreview, stdoutPreview };
|
||||
|
||||
if (outputResult.exceeded) {
|
||||
const message = `输出超过限制 ${t.cmd.maxOutputBytes} 字节`;
|
||||
observation["error"] = message;
|
||||
return {
|
||||
detail: null,
|
||||
durationMs,
|
||||
failure: errorFailure("exitCode", "output", `输出超过限制 ${t.cmd.maxOutputBytes} 字节`),
|
||||
failure: errorFailure("exitCode", "output", message),
|
||||
matched: false,
|
||||
statusDetail: `exitCode=${exitCode}`,
|
||||
observation,
|
||||
targetId: t.id,
|
||||
timestamp,
|
||||
};
|
||||
}
|
||||
|
||||
if (ctx.signal.aborted) {
|
||||
const message = `命令执行超时 (${t.timeoutMs}ms)`;
|
||||
observation["error"] = message;
|
||||
return {
|
||||
detail: null,
|
||||
durationMs,
|
||||
failure: errorFailure("exitCode", "timeout", `命令执行超时 (${t.timeoutMs}ms)`),
|
||||
failure: errorFailure("exitCode", "timeout", message),
|
||||
matched: false,
|
||||
statusDetail: null,
|
||||
observation,
|
||||
targetId: t.id,
|
||||
timestamp,
|
||||
};
|
||||
@@ -109,10 +128,11 @@ export class CommandChecker implements CheckerDefinition<ResolvedCommandTarget>
|
||||
const exitCodeResult = checkExitCode(exitCode, t.expect?.exitCode ?? [0]);
|
||||
if (!exitCodeResult.matched) {
|
||||
return {
|
||||
detail: null,
|
||||
durationMs,
|
||||
failure: exitCodeResult.failure,
|
||||
matched: false,
|
||||
statusDetail: `exitCode=${exitCode}`,
|
||||
observation,
|
||||
targetId: t.id,
|
||||
timestamp,
|
||||
};
|
||||
@@ -125,10 +145,11 @@ export class CommandChecker implements CheckerDefinition<ResolvedCommandTarget>
|
||||
});
|
||||
if (!durationResult.matched) {
|
||||
return {
|
||||
detail: null,
|
||||
durationMs,
|
||||
failure: durationResult.failure,
|
||||
matched: false,
|
||||
statusDetail: `exitCode=${exitCode}`,
|
||||
observation,
|
||||
targetId: t.id,
|
||||
timestamp,
|
||||
};
|
||||
@@ -138,10 +159,11 @@ export class CommandChecker implements CheckerDefinition<ResolvedCommandTarget>
|
||||
const stdoutResult = checkContentRules(outputResult.stdout, t.expect.stdout, { path: "stdout", phase: "stdout" });
|
||||
if (!stdoutResult.matched) {
|
||||
return {
|
||||
detail: null,
|
||||
durationMs,
|
||||
failure: stdoutResult.failure,
|
||||
matched: false,
|
||||
statusDetail: `exitCode=${exitCode}`,
|
||||
observation,
|
||||
targetId: t.id,
|
||||
timestamp,
|
||||
};
|
||||
@@ -152,10 +174,11 @@ export class CommandChecker implements CheckerDefinition<ResolvedCommandTarget>
|
||||
const stderrResult = checkContentRules(outputResult.stderr, t.expect.stderr, { path: "stderr", phase: "stderr" });
|
||||
if (!stderrResult.matched) {
|
||||
return {
|
||||
detail: null,
|
||||
durationMs,
|
||||
failure: stderrResult.failure,
|
||||
matched: false,
|
||||
statusDetail: `exitCode=${exitCode}`,
|
||||
observation,
|
||||
targetId: t.id,
|
||||
timestamp,
|
||||
};
|
||||
@@ -163,10 +186,11 @@ export class CommandChecker implements CheckerDefinition<ResolvedCommandTarget>
|
||||
}
|
||||
|
||||
return {
|
||||
detail: null,
|
||||
durationMs,
|
||||
failure: null,
|
||||
matched: true,
|
||||
statusDetail: `exitCode=${exitCode}`,
|
||||
observation,
|
||||
targetId: t.id,
|
||||
timestamp,
|
||||
};
|
||||
@@ -269,3 +293,8 @@ async function readOutput(
|
||||
|
||||
return { exceeded, stderr: err, stdout: out };
|
||||
}
|
||||
|
||||
function truncatePreview(text: string, maxLen: number): string {
|
||||
if (text.length <= maxLen) return text;
|
||||
return text.slice(0, maxLen);
|
||||
}
|
||||
|
||||
@@ -13,6 +13,7 @@ import { dbCheckerSchemas } from "./schema";
|
||||
import { validateDbConfig } from "./validate";
|
||||
|
||||
const PROBE_QUERY = "SELECT 1";
|
||||
const ROWS_PREVIEW_MAX = 5;
|
||||
|
||||
export class DbChecker implements CheckerDefinition<ResolvedDbTarget> {
|
||||
readonly configKey = "db";
|
||||
@@ -21,16 +22,27 @@ export class DbChecker implements CheckerDefinition<ResolvedDbTarget> {
|
||||
|
||||
readonly type = "db";
|
||||
|
||||
buildDetail(observation: Record<string, unknown>): null | string {
|
||||
const connected = observation["connected"];
|
||||
if (connected !== true) {
|
||||
const error = observation["error"];
|
||||
return typeof error === "string" ? `connection failed: ${error}` : "not connected";
|
||||
}
|
||||
const rowCount = observation["rowCount"];
|
||||
if (typeof rowCount === "number") {
|
||||
return `${rowCount} rows`;
|
||||
}
|
||||
return "connected";
|
||||
}
|
||||
|
||||
async execute(t: ResolvedDbTarget, ctx: CheckerContext): Promise<CheckResult> {
|
||||
const timestamp = new Date().toISOString();
|
||||
const start = performance.now();
|
||||
let db: SQL | undefined;
|
||||
|
||||
try {
|
||||
// 创建连接(SQLite 不需要 max 选项)
|
||||
db = new SQL(t.db.url);
|
||||
|
||||
// 监听 abort signal
|
||||
ctx.signal.addEventListener(
|
||||
"abort",
|
||||
() => {
|
||||
@@ -41,24 +53,30 @@ export class DbChecker implements CheckerDefinition<ResolvedDbTarget> {
|
||||
{ once: true },
|
||||
);
|
||||
|
||||
// 连接测试(Bun SQL 是 lazy 的,首次查询才真正连接)
|
||||
try {
|
||||
await db.unsafe(PROBE_QUERY);
|
||||
} catch (error) {
|
||||
const durationMs = Math.round(performance.now() - start);
|
||||
const errorMsg = isError(error) ? error.message : String(error);
|
||||
return {
|
||||
detail: null,
|
||||
durationMs,
|
||||
failure: errorFailure("connect", "connect", isError(error) ? error.message : String(error)),
|
||||
failure: errorFailure("connect", "connect", errorMsg),
|
||||
matched: false,
|
||||
statusDetail: null,
|
||||
observation: { connected: false, error: errorMsg, rowCount: null, rowsPreview: null },
|
||||
targetId: t.id,
|
||||
timestamp,
|
||||
};
|
||||
}
|
||||
|
||||
// 无 query 时仅测试连接
|
||||
if (!t.db.query) {
|
||||
const durationMs = Math.round(performance.now() - start);
|
||||
const observation: Record<string, unknown> = {
|
||||
connected: true,
|
||||
error: null,
|
||||
rowCount: null,
|
||||
rowsPreview: null,
|
||||
};
|
||||
const durationResult = checkValueMatcher(durationMs, t.expect?.durationMs, {
|
||||
message: "durationMs mismatch",
|
||||
path: "durationMs",
|
||||
@@ -66,55 +84,60 @@ export class DbChecker implements CheckerDefinition<ResolvedDbTarget> {
|
||||
});
|
||||
if (!durationResult.matched) {
|
||||
return {
|
||||
detail: null,
|
||||
durationMs,
|
||||
failure: durationResult.failure,
|
||||
matched: false,
|
||||
statusDetail: "connected",
|
||||
observation,
|
||||
targetId: t.id,
|
||||
timestamp,
|
||||
};
|
||||
}
|
||||
return {
|
||||
detail: null,
|
||||
durationMs,
|
||||
failure: null,
|
||||
matched: true,
|
||||
statusDetail: "connected",
|
||||
observation,
|
||||
targetId: t.id,
|
||||
timestamp,
|
||||
};
|
||||
}
|
||||
|
||||
// 执行用户 SQL
|
||||
let rows: unknown[];
|
||||
try {
|
||||
rows = await db.unsafe(t.db.query);
|
||||
} catch (error) {
|
||||
const durationMs = Math.round(performance.now() - start);
|
||||
const errorMsg = isError(error) ? error.message : String(error);
|
||||
return {
|
||||
detail: null,
|
||||
durationMs,
|
||||
failure: errorFailure("query", "query", isError(error) ? error.message : String(error)),
|
||||
failure: errorFailure("query", "query", errorMsg),
|
||||
matched: false,
|
||||
statusDetail: null,
|
||||
observation: { connected: true, error: errorMsg, rowCount: null, rowsPreview: null },
|
||||
targetId: t.id,
|
||||
timestamp,
|
||||
};
|
||||
}
|
||||
|
||||
const durationMs = Math.round(performance.now() - start);
|
||||
const rowCount = Array.isArray(rows) ? rows.length : 0;
|
||||
const rowsPreview = Array.isArray(rows) ? rows.slice(0, ROWS_PREVIEW_MAX) : null;
|
||||
const observation: Record<string, unknown> = { connected: true, error: null, rowCount, rowsPreview };
|
||||
|
||||
// 检查是否超时
|
||||
if (ctx.signal.aborted) {
|
||||
return {
|
||||
detail: null,
|
||||
durationMs,
|
||||
failure: errorFailure("query", "timeout", `查询超时 (${t.timeoutMs}ms)`),
|
||||
matched: false,
|
||||
statusDetail: null,
|
||||
observation,
|
||||
targetId: t.id,
|
||||
timestamp,
|
||||
};
|
||||
}
|
||||
|
||||
// duration 断言
|
||||
const durationResult = checkValueMatcher(durationMs, t.expect?.durationMs, {
|
||||
message: "durationMs mismatch",
|
||||
path: "durationMs",
|
||||
@@ -122,39 +145,40 @@ export class DbChecker implements CheckerDefinition<ResolvedDbTarget> {
|
||||
});
|
||||
if (!durationResult.matched) {
|
||||
return {
|
||||
detail: null,
|
||||
durationMs,
|
||||
failure: durationResult.failure,
|
||||
matched: false,
|
||||
statusDetail: `${Array.isArray(rows) ? rows.length : 0} rows`,
|
||||
observation,
|
||||
targetId: t.id,
|
||||
timestamp,
|
||||
};
|
||||
}
|
||||
|
||||
// rowCount 断言
|
||||
if (t.expect?.rowCount) {
|
||||
const rowCountResult = checkRowCount(Array.isArray(rows) ? rows.length : 0, t.expect.rowCount);
|
||||
const rowCountResult = checkRowCount(rowCount, t.expect.rowCount);
|
||||
if (!rowCountResult.matched) {
|
||||
return {
|
||||
detail: null,
|
||||
durationMs,
|
||||
failure: rowCountResult.failure,
|
||||
matched: false,
|
||||
statusDetail: `${Array.isArray(rows) ? rows.length : 0} rows`,
|
||||
observation,
|
||||
targetId: t.id,
|
||||
timestamp,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
// rows 断言
|
||||
if (t.expect?.rows && t.expect.rows.length > 0) {
|
||||
const rowsResult = checkRows(rows, t.expect.rows);
|
||||
if (!rowsResult.matched) {
|
||||
return {
|
||||
detail: null,
|
||||
durationMs,
|
||||
failure: rowsResult.failure,
|
||||
matched: false,
|
||||
statusDetail: `${Array.isArray(rows) ? rows.length : 0} rows`,
|
||||
observation,
|
||||
targetId: t.id,
|
||||
timestamp,
|
||||
};
|
||||
@@ -162,14 +186,14 @@ export class DbChecker implements CheckerDefinition<ResolvedDbTarget> {
|
||||
}
|
||||
|
||||
if (t.expect?.result && t.expect.result.length > 0) {
|
||||
const rowCount = Array.isArray(rows) ? rows.length : 0;
|
||||
const resultCheck = checkContentRules({ rowCount, rows }, t.expect.result, { path: "result", phase: "result" });
|
||||
if (!resultCheck.matched) {
|
||||
return {
|
||||
detail: null,
|
||||
durationMs,
|
||||
failure: resultCheck.failure,
|
||||
matched: false,
|
||||
statusDetail: `${rowCount} rows`,
|
||||
observation,
|
||||
targetId: t.id,
|
||||
timestamp,
|
||||
};
|
||||
@@ -177,10 +201,11 @@ export class DbChecker implements CheckerDefinition<ResolvedDbTarget> {
|
||||
}
|
||||
|
||||
return {
|
||||
detail: null,
|
||||
durationMs,
|
||||
failure: null,
|
||||
matched: true,
|
||||
statusDetail: `${Array.isArray(rows) ? rows.length : 0} rows`,
|
||||
observation,
|
||||
targetId: t.id,
|
||||
timestamp,
|
||||
};
|
||||
|
||||
@@ -13,6 +13,7 @@ import { httpCheckerSchemas } from "./schema";
|
||||
import { validateHttpConfig } from "./validate";
|
||||
|
||||
const CHARSET_RE = /charset="?([^";\s]+)"?/i;
|
||||
const BODY_PREVIEW_BYTES = 1024;
|
||||
const REDIRECT_STATUSES = new Set([301, 302, 303, 307, 308]);
|
||||
const SENSITIVE_HEADERS = new Set(["authorization", "cookie"]);
|
||||
|
||||
@@ -23,6 +24,11 @@ export class HttpChecker implements CheckerDefinition<ResolvedHttpTarget> {
|
||||
|
||||
readonly type = "http";
|
||||
|
||||
buildDetail(observation: Record<string, unknown>): null | string {
|
||||
const statusCode = observation["statusCode"];
|
||||
return typeof statusCode === "number" ? `HTTP ${statusCode}` : null;
|
||||
}
|
||||
|
||||
async execute(t: ResolvedHttpTarget, ctx: CheckerContext): Promise<CheckResult> {
|
||||
const timestamp = new Date().toISOString();
|
||||
const expect = t.expect;
|
||||
@@ -39,39 +45,102 @@ export class HttpChecker implements CheckerDefinition<ResolvedHttpTarget> {
|
||||
});
|
||||
|
||||
const statusCode = response.status;
|
||||
const responseHeaders = Object.fromEntries(response.headers);
|
||||
const responseHeaders = truncateHeaders(Object.fromEntries(response.headers));
|
||||
const hasBodyRules = !!(expect?.body && expect.body.length > 0);
|
||||
const bodyReadResult = await readBodyStream(
|
||||
response,
|
||||
hasBodyRules ? t.http.maxBodyBytes : BODY_PREVIEW_BYTES,
|
||||
!hasBodyRules,
|
||||
);
|
||||
let bodyPreview: null | string = null;
|
||||
let bodyText: null | string = null;
|
||||
let bodyDecodeFailure: CheckResult["failure"] = null;
|
||||
|
||||
if (bodyReadResult.data.byteLength > 0) {
|
||||
const decodeResult = decodeBody(bodyReadResult.data, response.headers);
|
||||
if (decodeResult.ok) {
|
||||
bodyText = decodeResult.text;
|
||||
bodyPreview = truncateBodyPreview(decodeResult.text);
|
||||
} else {
|
||||
bodyDecodeFailure = decodeResult.failure;
|
||||
}
|
||||
}
|
||||
|
||||
const statusResult = checkStatus(statusCode, expect?.status ?? [200]);
|
||||
if (!statusResult.matched) {
|
||||
return makeResult(t, timestamp, performance.now() - start, statusResult.failure, statusCode);
|
||||
return makeResult(
|
||||
t,
|
||||
timestamp,
|
||||
performance.now() - start,
|
||||
statusResult.failure,
|
||||
response,
|
||||
responseHeaders,
|
||||
bodyPreview,
|
||||
);
|
||||
}
|
||||
|
||||
const headersResult = checkHeaders(responseHeaders, expect?.headers);
|
||||
const headersResult = checkHeaders(Object.fromEntries(response.headers), expect?.headers);
|
||||
if (!headersResult.matched) {
|
||||
return makeResult(t, timestamp, performance.now() - start, headersResult.failure, statusCode);
|
||||
return makeResult(
|
||||
t,
|
||||
timestamp,
|
||||
performance.now() - start,
|
||||
headersResult.failure,
|
||||
response,
|
||||
responseHeaders,
|
||||
bodyPreview,
|
||||
);
|
||||
}
|
||||
|
||||
const hasBodyRules = !!(expect?.body && expect.body.length > 0);
|
||||
|
||||
const earlyTimeout = hasBodyRules ? checkEarlyTimeout(start, expect?.durationMs) : null;
|
||||
if (earlyTimeout) {
|
||||
return makeResult(t, timestamp, earlyTimeout.elapsed, earlyTimeout.failure, statusCode);
|
||||
return makeResult(
|
||||
t,
|
||||
timestamp,
|
||||
earlyTimeout.elapsed,
|
||||
earlyTimeout.failure,
|
||||
response,
|
||||
responseHeaders,
|
||||
bodyPreview,
|
||||
);
|
||||
}
|
||||
|
||||
if (!bodyReadResult.ok) {
|
||||
return makeResult(
|
||||
t,
|
||||
timestamp,
|
||||
performance.now() - start,
|
||||
bodyReadResult.failure,
|
||||
response,
|
||||
responseHeaders,
|
||||
bodyPreview,
|
||||
);
|
||||
}
|
||||
|
||||
if (bodyDecodeFailure) {
|
||||
return makeResult(
|
||||
t,
|
||||
timestamp,
|
||||
performance.now() - start,
|
||||
bodyDecodeFailure,
|
||||
response,
|
||||
responseHeaders,
|
||||
bodyPreview,
|
||||
);
|
||||
}
|
||||
|
||||
if (hasBodyRules) {
|
||||
const bodyReadResult = await readBodyStream(response, t.http.maxBodyBytes);
|
||||
if (!bodyReadResult.ok) {
|
||||
return makeResult(t, timestamp, performance.now() - start, bodyReadResult.failure, statusCode);
|
||||
}
|
||||
|
||||
const decodeResult = decodeBody(bodyReadResult.data, response.headers);
|
||||
if (!decodeResult.ok) {
|
||||
return makeResult(t, timestamp, performance.now() - start, decodeResult.failure, statusCode);
|
||||
}
|
||||
|
||||
const bodyResult = checkContentRules(decodeResult.text, expect.body, { path: "body", phase: "body" });
|
||||
const bodyResult = checkContentRules(bodyText ?? "", expect.body, { path: "body", phase: "body" });
|
||||
if (!bodyResult.matched) {
|
||||
return makeResult(t, timestamp, performance.now() - start, bodyResult.failure, statusCode);
|
||||
return makeResult(
|
||||
t,
|
||||
timestamp,
|
||||
performance.now() - start,
|
||||
bodyResult.failure,
|
||||
response,
|
||||
responseHeaders,
|
||||
bodyPreview,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -82,15 +151,16 @@ export class HttpChecker implements CheckerDefinition<ResolvedHttpTarget> {
|
||||
phase: "duration",
|
||||
});
|
||||
if (!durationResult.matched) {
|
||||
return makeResult(t, timestamp, durationMs, durationResult.failure, statusCode);
|
||||
return makeResult(t, timestamp, durationMs, durationResult.failure, response, responseHeaders, bodyPreview);
|
||||
}
|
||||
|
||||
return makeResult(t, timestamp, durationMs, null, statusCode);
|
||||
return makeResult(t, timestamp, durationMs, null, response, responseHeaders, bodyPreview);
|
||||
} catch (error) {
|
||||
const durationMs = Math.round(performance.now() - start);
|
||||
const isTimeout = ctx.signal.aborted || (error instanceof DOMException && error.name === "AbortError");
|
||||
|
||||
return {
|
||||
detail: null,
|
||||
durationMs,
|
||||
failure: errorFailure(
|
||||
"request",
|
||||
@@ -98,7 +168,7 @@ export class HttpChecker implements CheckerDefinition<ResolvedHttpTarget> {
|
||||
isTimeout ? `请求超时 (${t.timeoutMs}ms)` : isError(error) ? error.message : String(error),
|
||||
),
|
||||
matched: false,
|
||||
statusDetail: null,
|
||||
observation: null,
|
||||
targetId: t.id,
|
||||
timestamp,
|
||||
};
|
||||
@@ -155,6 +225,17 @@ export class HttpChecker implements CheckerDefinition<ResolvedHttpTarget> {
|
||||
}
|
||||
}
|
||||
|
||||
function assembleChunks(chunks: Uint8Array[], totalBytes: number): Uint8Array {
|
||||
const result = new Uint8Array(totalBytes);
|
||||
let offset = 0;
|
||||
for (const chunk of chunks) {
|
||||
result.set(chunk, offset);
|
||||
offset += chunk.byteLength;
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
function buildRedirectInit(init: RequestInit, statusCode: number, fromUrl: string, toUrl: string): RequestInit {
|
||||
let newInit = { ...init };
|
||||
const method = init.method?.toUpperCase();
|
||||
@@ -269,13 +350,28 @@ function makeResult(
|
||||
timestamp: string,
|
||||
elapsed: number,
|
||||
failure: CheckResult["failure"],
|
||||
statusCode: number,
|
||||
response: Response,
|
||||
headers: Record<string, string>,
|
||||
bodyPreview: null | string = null,
|
||||
): CheckResult {
|
||||
const contentType = response.headers.get("content-type");
|
||||
const contentLengthHeader = response.headers.get("content-length");
|
||||
const contentLength = contentLengthHeader ? Number(contentLengthHeader) : null;
|
||||
|
||||
const observation: Record<string, unknown> = {
|
||||
bodyPreview,
|
||||
contentLength: Number.isFinite(contentLength) ? contentLength : null,
|
||||
contentType,
|
||||
headers,
|
||||
statusCode: response.status,
|
||||
};
|
||||
|
||||
return {
|
||||
detail: null,
|
||||
durationMs: Math.round(elapsed),
|
||||
failure,
|
||||
matched: failure === null,
|
||||
statusDetail: `HTTP ${statusCode}`,
|
||||
observation,
|
||||
targetId: t.id,
|
||||
timestamp,
|
||||
};
|
||||
@@ -284,7 +380,8 @@ function makeResult(
|
||||
async function readBodyStream(
|
||||
response: Response,
|
||||
maxBodyBytes: number,
|
||||
): Promise<{ data: Uint8Array; ok: true } | { failure: CheckResult["failure"]; ok: false }> {
|
||||
truncateOnLimit = false,
|
||||
): Promise<{ data: Uint8Array; failure: CheckResult["failure"]; ok: false } | { data: Uint8Array; ok: true }> {
|
||||
const reader = response.body?.getReader();
|
||||
if (!reader) {
|
||||
return { data: new Uint8Array(0), ok: true };
|
||||
@@ -300,12 +397,19 @@ async function readBodyStream(
|
||||
|
||||
totalBytes += value.byteLength;
|
||||
if (totalBytes > maxBodyBytes) {
|
||||
const allowedBytes = value.byteLength - (totalBytes - maxBodyBytes);
|
||||
if (truncateOnLimit && allowedBytes > 0) {
|
||||
chunks.push(value.slice(0, allowedBytes));
|
||||
}
|
||||
try {
|
||||
await reader.cancel();
|
||||
} catch {
|
||||
/* ignore cancel error */
|
||||
}
|
||||
const data = assembleChunks(chunks, Math.min(totalBytes, maxBodyBytes));
|
||||
if (truncateOnLimit) return { data, ok: true };
|
||||
return {
|
||||
data,
|
||||
failure: errorFailure("body", "body", `响应体大小超过限制 ${maxBodyBytes}`),
|
||||
ok: false,
|
||||
};
|
||||
@@ -317,12 +421,16 @@ async function readBodyStream(
|
||||
reader.releaseLock();
|
||||
}
|
||||
|
||||
const result = new Uint8Array(totalBytes);
|
||||
let offset = 0;
|
||||
for (const chunk of chunks) {
|
||||
result.set(chunk, offset);
|
||||
offset += chunk.byteLength;
|
||||
}
|
||||
|
||||
return { data: result, ok: true };
|
||||
return { data: assembleChunks(chunks, totalBytes), ok: true };
|
||||
}
|
||||
|
||||
function truncateBodyPreview(text: string, maxLen = 1024): string {
|
||||
if (text.length <= maxLen) return text;
|
||||
return text.slice(0, maxLen);
|
||||
}
|
||||
|
||||
function truncateHeaders(headers: Record<string, string>, maxCount = 20): Record<string, string> {
|
||||
const entries = Object.entries(headers);
|
||||
if (entries.length <= maxCount) return headers;
|
||||
return Object.fromEntries(entries.slice(0, maxCount));
|
||||
}
|
||||
|
||||
@@ -22,6 +22,35 @@ export class IcmpChecker implements CheckerDefinition<ResolvedPingTarget> {
|
||||
|
||||
readonly type = "ping";
|
||||
|
||||
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();
|
||||
const start = performance.now();
|
||||
@@ -36,10 +65,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)}`),
|
||||
matched: false,
|
||||
statusDetail: "ping command not found",
|
||||
observation: null,
|
||||
targetId: t.id,
|
||||
timestamp,
|
||||
};
|
||||
@@ -63,10 +93,11 @@ 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)`),
|
||||
matched: false,
|
||||
statusDetail: null,
|
||||
observation: null,
|
||||
targetId: t.id,
|
||||
timestamp,
|
||||
};
|
||||
@@ -75,21 +106,42 @@ export class IcmpChecker implements CheckerDefinition<ResolvedPingTarget> {
|
||||
const stats = parsePingOutput(stdout, process.platform);
|
||||
if (!stats) {
|
||||
return {
|
||||
detail: null,
|
||||
durationMs,
|
||||
failure: errorFailure("ping", "parse", "无法解析 ping 输出"),
|
||||
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,
|
||||
};
|
||||
@@ -126,17 +178,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 +220,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)}…`;
|
||||
}
|
||||
|
||||
@@ -5,7 +5,7 @@ import { isError } from "es-toolkit";
|
||||
|
||||
import type { CheckResult, RawTargetConfig } from "../../types";
|
||||
import type { CheckerContext, CheckerDefinition, CheckerValidationInput, ResolveContext } from "../types";
|
||||
import type { LlmCheckObservation, LlmExpectConfig, LlmTargetConfig, ResolvedLlmTarget } from "./types";
|
||||
import type { LlmExpectConfig, LlmTargetConfig, ResolvedLlmTarget } from "./types";
|
||||
|
||||
import { errorFailure } from "../../expect/failure";
|
||||
import { checkValueMatcher } from "../../expect/matcher";
|
||||
@@ -14,6 +14,7 @@ import {
|
||||
buildObservationFromApiCallError,
|
||||
buildObservationFromGenerateText,
|
||||
buildObservationFromStreamText,
|
||||
toPersistedObservation,
|
||||
} from "./observation";
|
||||
import { createProviderModel } from "./provider";
|
||||
import { llmCheckerSchemas } from "./schema";
|
||||
@@ -24,6 +25,43 @@ export class LlmChecker implements CheckerDefinition<ResolvedLlmTarget> {
|
||||
readonly schemas = llmCheckerSchemas;
|
||||
readonly type = "llm";
|
||||
|
||||
buildDetail(observation: Record<string, unknown>): null | string {
|
||||
const provider = observation["provider"];
|
||||
const mode = observation["mode"];
|
||||
const parts: string[] = [`LLM ${String(provider)} ${String(mode)}`];
|
||||
|
||||
const http = observation["http"] as null | Record<string, unknown> | undefined;
|
||||
if (http && typeof http["status"] === "number") {
|
||||
parts.push(String(http["status"]));
|
||||
}
|
||||
|
||||
if (typeof observation["finishReason"] === "string") {
|
||||
parts.push(`finish=${observation["finishReason"]}`);
|
||||
}
|
||||
|
||||
if (typeof observation["rawFinishReason"] === "string") {
|
||||
parts.push(`raw=${observation["rawFinishReason"]}`);
|
||||
}
|
||||
|
||||
const stream = observation["stream"] as null | Record<string, unknown> | undefined;
|
||||
if (stream && typeof stream["firstTokenMs"] === "number") {
|
||||
parts.push(`firstToken=${stream["firstTokenMs"]}ms`);
|
||||
}
|
||||
|
||||
if (typeof observation["outputLength"] === "number") {
|
||||
parts.push(`output=${observation["outputLength"]} chars`);
|
||||
}
|
||||
|
||||
const usage = observation["usage"] as null | Record<string, unknown> | undefined;
|
||||
if (usage) {
|
||||
const inputTokens = typeof usage["inputTokens"] === "number" ? usage["inputTokens"] : 0;
|
||||
const outputTokens = typeof usage["outputTokens"] === "number" ? usage["outputTokens"] : 0;
|
||||
parts.push(`usage=${inputTokens}/${outputTokens} tokens`);
|
||||
}
|
||||
|
||||
return parts.join(", ");
|
||||
}
|
||||
|
||||
async execute(t: ResolvedLlmTarget, ctx: CheckerContext): Promise<CheckResult> {
|
||||
const timestamp = new Date().toISOString();
|
||||
const expect = t.expect;
|
||||
@@ -45,10 +83,11 @@ export class LlmChecker implements CheckerDefinition<ResolvedLlmTarget> {
|
||||
|
||||
if (observation.http === null) {
|
||||
return {
|
||||
detail: null,
|
||||
durationMs,
|
||||
failure: errorFailure("request", "request", error.message),
|
||||
matched: false,
|
||||
statusDetail: null,
|
||||
observation: null,
|
||||
targetId: t.id,
|
||||
timestamp,
|
||||
};
|
||||
@@ -63,10 +102,11 @@ export class LlmChecker implements CheckerDefinition<ResolvedLlmTarget> {
|
||||
const failure = expectResult.failure ?? durationResult.failure;
|
||||
|
||||
return {
|
||||
detail: null,
|
||||
durationMs,
|
||||
failure,
|
||||
matched: failure === null,
|
||||
statusDetail: buildStatusDetail(observation),
|
||||
observation: toPersistedObservation(observation),
|
||||
targetId: t.id,
|
||||
timestamp,
|
||||
};
|
||||
@@ -74,6 +114,7 @@ export class LlmChecker implements CheckerDefinition<ResolvedLlmTarget> {
|
||||
|
||||
const isTimeout = ctx.signal.aborted || (error instanceof DOMException && error.name === "AbortError");
|
||||
return {
|
||||
detail: null,
|
||||
durationMs,
|
||||
failure: errorFailure(
|
||||
"request",
|
||||
@@ -81,7 +122,7 @@ export class LlmChecker implements CheckerDefinition<ResolvedLlmTarget> {
|
||||
isTimeout ? `请求超时 (${t.timeoutMs}ms)` : isError(error) ? error.message : String(error),
|
||||
),
|
||||
matched: false,
|
||||
statusDetail: null,
|
||||
observation: null,
|
||||
targetId: t.id,
|
||||
timestamp,
|
||||
};
|
||||
@@ -222,10 +263,11 @@ export class LlmChecker implements CheckerDefinition<ResolvedLlmTarget> {
|
||||
const failure = expectResult.failure ?? durationResult.failure;
|
||||
|
||||
return {
|
||||
detail: null,
|
||||
durationMs,
|
||||
failure,
|
||||
matched: failure === null,
|
||||
statusDetail: buildStatusDetail(observation),
|
||||
observation: toPersistedObservation(observation),
|
||||
targetId: t.id,
|
||||
timestamp,
|
||||
};
|
||||
@@ -268,10 +310,11 @@ export class LlmChecker implements CheckerDefinition<ResolvedLlmTarget> {
|
||||
const failure = expectResult.failure ?? durationResult.failure;
|
||||
|
||||
return {
|
||||
detail: null,
|
||||
durationMs,
|
||||
failure,
|
||||
matched: failure === null,
|
||||
statusDetail: buildStatusDetail(observation),
|
||||
observation: toPersistedObservation(observation),
|
||||
targetId: t.id,
|
||||
timestamp,
|
||||
};
|
||||
@@ -291,33 +334,3 @@ function buildSdkOptions(config: ResolvedLlmTarget["llm"]): Record<string, unkno
|
||||
if (opts.seed !== undefined) options["seed"] = opts.seed;
|
||||
return options;
|
||||
}
|
||||
|
||||
function buildStatusDetail(observation: LlmCheckObservation): string {
|
||||
const parts: string[] = [`LLM ${observation.provider} ${observation.mode}`];
|
||||
|
||||
if (observation.http) {
|
||||
parts.push(String(observation.http.status));
|
||||
}
|
||||
|
||||
if (observation.finishReason) {
|
||||
parts.push(`finish=${observation.finishReason}`);
|
||||
}
|
||||
|
||||
if (observation.rawFinishReason) {
|
||||
parts.push(`raw=${observation.rawFinishReason}`);
|
||||
}
|
||||
|
||||
if (observation.stream?.firstTokenMs != null) {
|
||||
parts.push(`firstToken=${observation.stream.firstTokenMs}ms`);
|
||||
}
|
||||
|
||||
if (observation.outputText !== null) {
|
||||
parts.push(`output=${observation.outputText.length} chars`);
|
||||
}
|
||||
|
||||
if (observation.usage) {
|
||||
parts.push(`usage=${observation.usage.inputTokens}/${observation.usage.outputTokens} tokens`);
|
||||
}
|
||||
|
||||
return parts.join(", ");
|
||||
}
|
||||
|
||||
@@ -4,11 +4,15 @@ import type {
|
||||
LlmCheckObservation,
|
||||
LlmHttpMetadata,
|
||||
LlmMode,
|
||||
LlmPersistedHttpMetadata,
|
||||
LlmPersistedObservation,
|
||||
LlmProvider,
|
||||
LlmStreamObservation,
|
||||
LlmUsageObservation,
|
||||
} from "./types";
|
||||
|
||||
import { LLM_HEADERS_MAX, LLM_OUTPUT_PREVIEW_MAX } from "./types";
|
||||
|
||||
export function buildObservationFromApiCallError(
|
||||
error: APICallError,
|
||||
provider: LlmProvider,
|
||||
@@ -129,3 +133,42 @@ export async function buildObservationFromStreamText(
|
||||
warnings,
|
||||
};
|
||||
}
|
||||
|
||||
export function toPersistedObservation(obs: LlmCheckObservation): LlmPersistedObservation {
|
||||
const outputText = obs.outputText;
|
||||
const outputPreview =
|
||||
outputText !== null
|
||||
? outputText.length <= LLM_OUTPUT_PREVIEW_MAX
|
||||
? outputText
|
||||
: outputText.slice(0, LLM_OUTPUT_PREVIEW_MAX)
|
||||
: null;
|
||||
const outputLength = outputText !== null ? outputText.length : null;
|
||||
|
||||
const http: LlmPersistedHttpMetadata | null = obs.http
|
||||
? {
|
||||
headers: truncateHeaders(obs.http.headers, LLM_HEADERS_MAX),
|
||||
status: obs.http.status,
|
||||
statusText: obs.http.statusText,
|
||||
}
|
||||
: null;
|
||||
|
||||
return {
|
||||
finishReason: obs.finishReason,
|
||||
http,
|
||||
mode: obs.mode,
|
||||
model: obs.model,
|
||||
outputLength,
|
||||
outputPreview,
|
||||
provider: obs.provider,
|
||||
rawFinishReason: obs.rawFinishReason,
|
||||
stream: obs.stream,
|
||||
usage: obs.usage,
|
||||
warnings: obs.warnings,
|
||||
};
|
||||
}
|
||||
|
||||
function truncateHeaders(headers: Record<string, string>, maxCount: number): Record<string, string> {
|
||||
const entries = Object.entries(headers);
|
||||
if (entries.length <= maxCount) return headers;
|
||||
return Object.fromEntries(entries.slice(0, maxCount));
|
||||
}
|
||||
|
||||
@@ -15,6 +15,7 @@ export interface LlmCheckObservation {
|
||||
usage: LlmUsageObservation | null;
|
||||
warnings: string[];
|
||||
}
|
||||
|
||||
export interface LlmDefaultsConfig {
|
||||
headers?: Record<string, string>;
|
||||
ignoreSSL?: boolean;
|
||||
@@ -33,13 +34,37 @@ export interface LlmExpectConfig {
|
||||
stream?: LlmStreamExpect;
|
||||
usage?: LlmUsageExpect;
|
||||
}
|
||||
|
||||
export interface LlmHttpMetadata {
|
||||
headers: Record<string, string>;
|
||||
status: number;
|
||||
statusText: string;
|
||||
}
|
||||
|
||||
export interface LlmPersistedHttpMetadata {
|
||||
[key: string]: unknown;
|
||||
headers: Record<string, string>;
|
||||
status: number;
|
||||
statusText: string;
|
||||
}
|
||||
|
||||
export interface LlmPersistedObservation {
|
||||
[key: string]: unknown;
|
||||
finishReason: null | string;
|
||||
http: LlmPersistedHttpMetadata | null;
|
||||
mode: LlmMode;
|
||||
model: string;
|
||||
outputLength: null | number;
|
||||
outputPreview: null | string;
|
||||
provider: LlmProvider;
|
||||
rawFinishReason: null | string;
|
||||
stream: LlmStreamObservation | null;
|
||||
usage: LlmUsageObservation | null;
|
||||
warnings: string[];
|
||||
}
|
||||
|
||||
export const LLM_HEADERS_MAX = 20;
|
||||
export const LLM_OUTPUT_PREVIEW_MAX = 512;
|
||||
|
||||
export type LlmMode = "http" | "stream";
|
||||
|
||||
export interface LlmOptions {
|
||||
|
||||
@@ -15,7 +15,7 @@ const DEFAULT_BANNER_READ_TIMEOUT = 2000;
|
||||
const DEFAULT_MAX_BANNER_BYTES = 4096;
|
||||
|
||||
type ConnectAndBannerResult =
|
||||
| { banner?: string; bannerExceeded?: boolean; ok: true; socket: { close(): void } }
|
||||
| { banner?: string; bannerExceeded?: boolean; connectTimeMs: number; ok: true; socket: { close(): void } }
|
||||
| { error: string; ok: false };
|
||||
|
||||
export class TcpChecker implements CheckerDefinition<ResolvedTcpTarget> {
|
||||
@@ -25,6 +25,21 @@ export class TcpChecker implements CheckerDefinition<ResolvedTcpTarget> {
|
||||
|
||||
readonly type = "tcp";
|
||||
|
||||
buildDetail(observation: Record<string, unknown>): null | string {
|
||||
const connected = observation["connected"];
|
||||
if (connected !== true) {
|
||||
const error = observation["error"];
|
||||
return typeof error === "string" ? `connection failed: ${error}` : "not connected";
|
||||
}
|
||||
const connectTimeMs = observation["connectTimeMs"];
|
||||
const banner = observation["banner"];
|
||||
const parts: string[] = [`connected in ${typeof connectTimeMs === "number" ? connectTimeMs : "?"}ms`];
|
||||
if (typeof banner === "string" && banner.length > 0) {
|
||||
parts.push(`banner: ${truncateBanner(banner)}`);
|
||||
}
|
||||
return parts.join(", ");
|
||||
}
|
||||
|
||||
async execute(t: ResolvedTcpTarget, ctx: CheckerContext): Promise<CheckResult> {
|
||||
const timestamp = new Date().toISOString();
|
||||
const start = performance.now();
|
||||
@@ -42,36 +57,46 @@ export class TcpChecker implements CheckerDefinition<ResolvedTcpTarget> {
|
||||
|
||||
if (!connectResult.ok) {
|
||||
const durationMs = Math.round(performance.now() - start);
|
||||
const observation: Record<string, unknown> = {
|
||||
banner: null,
|
||||
connected: false,
|
||||
connectTimeMs: null,
|
||||
error: connectResult.error,
|
||||
};
|
||||
if (expect?.connected === false) {
|
||||
return {
|
||||
detail: null,
|
||||
durationMs,
|
||||
failure: null,
|
||||
matched: true,
|
||||
statusDetail: connectResult.error,
|
||||
observation,
|
||||
targetId: t.id,
|
||||
timestamp,
|
||||
};
|
||||
}
|
||||
return {
|
||||
detail: null,
|
||||
durationMs,
|
||||
failure: errorFailure("connect", "connect", connectResult.error),
|
||||
matched: false,
|
||||
statusDetail: null,
|
||||
observation,
|
||||
targetId: t.id,
|
||||
timestamp,
|
||||
};
|
||||
}
|
||||
|
||||
const socket = connectResult.socket;
|
||||
const connectTimeMs = connectResult.connectTimeMs;
|
||||
|
||||
if (ctx.signal.aborted) {
|
||||
closeSocket(socket);
|
||||
const durationMs = Math.round(performance.now() - start);
|
||||
return {
|
||||
detail: null,
|
||||
durationMs,
|
||||
failure: errorFailure("connect", "connect", `连接超时 (${t.timeoutMs}ms)`),
|
||||
matched: false,
|
||||
statusDetail: null,
|
||||
observation: null,
|
||||
targetId: t.id,
|
||||
timestamp,
|
||||
};
|
||||
@@ -83,10 +108,11 @@ export class TcpChecker implements CheckerDefinition<ResolvedTcpTarget> {
|
||||
closeSocket(socket);
|
||||
const durationMs = Math.round(performance.now() - start);
|
||||
return {
|
||||
detail: null,
|
||||
durationMs,
|
||||
failure: connectedResult.failure,
|
||||
matched: false,
|
||||
statusDetail: "connected",
|
||||
observation: { banner: null, connected: true, connectTimeMs, error: null },
|
||||
targetId: t.id,
|
||||
timestamp,
|
||||
};
|
||||
@@ -96,10 +122,11 @@ export class TcpChecker implements CheckerDefinition<ResolvedTcpTarget> {
|
||||
closeSocket(socket);
|
||||
const durationMs = Math.round(performance.now() - start);
|
||||
return {
|
||||
detail: null,
|
||||
durationMs,
|
||||
failure: errorFailure("banner", "banner", `banner 数据超过 ${t.tcp.maxBannerBytes} 字节限制`),
|
||||
matched: false,
|
||||
statusDetail: null,
|
||||
observation: { banner: null, connected: true, connectTimeMs, error: null },
|
||||
targetId: t.id,
|
||||
timestamp,
|
||||
};
|
||||
@@ -108,15 +135,23 @@ export class TcpChecker implements CheckerDefinition<ResolvedTcpTarget> {
|
||||
const banner = connectResult.banner ?? "";
|
||||
closeSocket(socket);
|
||||
|
||||
const observation: Record<string, unknown> = {
|
||||
banner: banner ? truncateBannerForObservation(banner) : null,
|
||||
connected: true,
|
||||
connectTimeMs,
|
||||
error: null,
|
||||
};
|
||||
|
||||
if (expect?.banner) {
|
||||
const bannerCheck = checkBanner(banner, expect.banner);
|
||||
if (!bannerCheck.matched) {
|
||||
const durationMs = Math.round(performance.now() - start);
|
||||
return {
|
||||
detail: null,
|
||||
durationMs,
|
||||
failure: bannerCheck.failure,
|
||||
matched: false,
|
||||
statusDetail: banner ? truncateBanner(banner) : null,
|
||||
observation,
|
||||
targetId: t.id,
|
||||
timestamp,
|
||||
};
|
||||
@@ -131,26 +166,29 @@ export class TcpChecker implements CheckerDefinition<ResolvedTcpTarget> {
|
||||
});
|
||||
if (!durationResult.matched) {
|
||||
return {
|
||||
detail: null,
|
||||
durationMs,
|
||||
failure: durationResult.failure,
|
||||
matched: false,
|
||||
statusDetail: buildStatusDetail(banner, durationMs),
|
||||
observation,
|
||||
targetId: t.id,
|
||||
timestamp,
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
detail: null,
|
||||
durationMs,
|
||||
failure: null,
|
||||
matched: true,
|
||||
statusDetail: buildStatusDetail(banner, durationMs),
|
||||
observation,
|
||||
targetId: t.id,
|
||||
timestamp,
|
||||
};
|
||||
} catch (error) {
|
||||
const durationMs = Math.round(performance.now() - start);
|
||||
return {
|
||||
detail: null,
|
||||
durationMs,
|
||||
failure: errorFailure(
|
||||
"connect",
|
||||
@@ -158,7 +196,7 @@ export class TcpChecker implements CheckerDefinition<ResolvedTcpTarget> {
|
||||
ctx.signal.aborted ? `连接超时 (${t.timeoutMs}ms)` : isError(error) ? error.message : String(error),
|
||||
),
|
||||
matched: false,
|
||||
statusDetail: null,
|
||||
observation: null,
|
||||
targetId: t.id,
|
||||
timestamp,
|
||||
};
|
||||
@@ -221,12 +259,6 @@ function assembleChunks(chunks: Uint8Array[], totalBytes: number): Uint8Array {
|
||||
return result;
|
||||
}
|
||||
|
||||
function buildStatusDetail(banner: string, durationMs: number): string {
|
||||
const base = `connected in ${durationMs}ms`;
|
||||
if (!banner) return base;
|
||||
return `${base}, banner: ${truncateBanner(banner)}`;
|
||||
}
|
||||
|
||||
function closeSocket(socket: { close(): void }) {
|
||||
try {
|
||||
socket.close();
|
||||
@@ -292,11 +324,13 @@ async function connectAndMaybeReadBanner(
|
||||
};
|
||||
|
||||
try {
|
||||
const connectStart = performance.now();
|
||||
const socket = await Bun.connect({
|
||||
hostname,
|
||||
port,
|
||||
socket: socketHandlers,
|
||||
});
|
||||
const connectTimeMs = Math.round(performance.now() - connectStart);
|
||||
|
||||
if (signal.aborted) {
|
||||
closeSocket(socket);
|
||||
@@ -304,7 +338,7 @@ async function connectAndMaybeReadBanner(
|
||||
}
|
||||
|
||||
if (!readBanner) {
|
||||
return { bannerExceeded: false, ok: true, socket };
|
||||
return { bannerExceeded: false, connectTimeMs, ok: true, socket };
|
||||
}
|
||||
|
||||
const timer = setTimeout(() => {
|
||||
@@ -332,11 +366,11 @@ async function connectAndMaybeReadBanner(
|
||||
signal.removeEventListener("abort", onAbort);
|
||||
|
||||
if (bannerExceeded) {
|
||||
return { bannerExceeded: true, ok: true, socket };
|
||||
return { bannerExceeded: true, connectTimeMs, ok: true, socket };
|
||||
}
|
||||
|
||||
const banner = new TextDecoder().decode(assembleChunks(chunks, totalBytes));
|
||||
return { banner, bannerExceeded: false, ok: true, socket };
|
||||
return { banner, bannerExceeded: false, connectTimeMs, ok: true, socket };
|
||||
} catch (error) {
|
||||
if (signal.aborted) {
|
||||
return { error: "连接超时", ok: false };
|
||||
@@ -360,3 +394,8 @@ function truncateBanner(banner: string, maxLen = 80): string {
|
||||
if (banner.length <= maxLen) return banner;
|
||||
return `${banner.slice(0, maxLen)}…`;
|
||||
}
|
||||
|
||||
function truncateBannerForObservation(banner: string, maxLen = 256): string {
|
||||
if (banner.length <= maxLen) return banner;
|
||||
return banner.slice(0, maxLen);
|
||||
}
|
||||
|
||||
@@ -10,6 +10,7 @@ export interface CheckerContext {
|
||||
}
|
||||
|
||||
export interface CheckerDefinition<TResolved extends ResolvedTargetBase = ResolvedTargetBase> {
|
||||
buildDetail(observation: Record<string, unknown>): null | string;
|
||||
readonly configKey: string;
|
||||
execute(target: TResolved, ctx: CheckerContext): Promise<CheckResult>;
|
||||
resolve(target: RawTargetConfig, context: ResolveContext): TResolved;
|
||||
|
||||
@@ -13,7 +13,6 @@ import { udpCheckerSchemas } from "./schema";
|
||||
import { validateUdpConfig } from "./validate";
|
||||
|
||||
const DEFAULT_MAX_RESPONSE_BYTES = 4096;
|
||||
const RESPONSE_PREVIEW_MAX = 80;
|
||||
|
||||
type UdpExchangeResult =
|
||||
| {
|
||||
@@ -35,6 +34,24 @@ export class UdpChecker implements CheckerDefinition<ResolvedUdpTarget> {
|
||||
readonly schemas = udpCheckerSchemas;
|
||||
readonly type = "udp";
|
||||
|
||||
buildDetail(observation: Record<string, unknown>): null | string {
|
||||
const responded = observation["responded"];
|
||||
const durationMs = observation["durationMs"];
|
||||
const duration = typeof durationMs === "number" ? `${durationMs}ms` : "?ms";
|
||||
if (responded !== true) {
|
||||
return `no response in ${duration}`;
|
||||
}
|
||||
const responseSize = observation["responseSize"];
|
||||
const parts: string[] = [
|
||||
`responded in ${duration}, ${typeof responseSize === "number" ? responseSize : "?"} bytes`,
|
||||
];
|
||||
const preview = observation["responsePreview"];
|
||||
if (typeof preview === "string" && preview.length > 0) {
|
||||
parts.push(`response: ${preview.length > 80 ? `${preview.slice(0, 80)}…` : preview}`);
|
||||
}
|
||||
return parts.join(", ");
|
||||
}
|
||||
|
||||
async execute(t: ResolvedUdpTarget, ctx: CheckerContext): Promise<CheckResult> {
|
||||
const timestamp = new Date().toISOString();
|
||||
const start = performance.now();
|
||||
@@ -47,35 +64,21 @@ export class UdpChecker implements CheckerDefinition<ResolvedUdpTarget> {
|
||||
|
||||
if (!exchangeResult.ok) {
|
||||
const durationMs = Math.round(performance.now() - start);
|
||||
if (expect?.responded === false) {
|
||||
return {
|
||||
const observation: Record<string, unknown> = {
|
||||
durationMs,
|
||||
failure: null,
|
||||
matched: true,
|
||||
statusDetail: exchangeResult.error,
|
||||
targetId: t.id,
|
||||
timestamp,
|
||||
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,
|
||||
|
||||
@@ -28,7 +28,7 @@ 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
|
||||
)
|
||||
@@ -281,21 +281,21 @@ export class ProbeStore {
|
||||
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,
|
||||
);
|
||||
}
|
||||
|
||||
@@ -60,7 +60,7 @@ export interface StoredCheckResult {
|
||||
failure: null | string;
|
||||
id: number;
|
||||
matched: number;
|
||||
status_detail: null | string;
|
||||
observation: null | string;
|
||||
target_id: string;
|
||||
timestamp: string;
|
||||
}
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
import type { ApiErrorResponse, CheckFailure, CheckResult, HealthResponse, RuntimeMode } from "../shared/api";
|
||||
import type { StoredCheckResult } from "./checker/types";
|
||||
|
||||
import { checkerRegistry } from "./checker/runner";
|
||||
|
||||
export function createApiError(error: string, status: number): ApiErrorResponse {
|
||||
return { error, status };
|
||||
}
|
||||
@@ -45,7 +47,7 @@ export function jsonResponse(
|
||||
});
|
||||
}
|
||||
|
||||
export function mapCheckResult(row: StoredCheckResult): CheckResult {
|
||||
export function mapCheckResult(row: StoredCheckResult, type: string): CheckResult {
|
||||
let failure: CheckFailure | null = null;
|
||||
if (row.failure) {
|
||||
try {
|
||||
@@ -56,11 +58,32 @@ export function mapCheckResult(row: StoredCheckResult): CheckResult {
|
||||
}
|
||||
}
|
||||
|
||||
let observation: null | Record<string, unknown> = null;
|
||||
if (row.observation) {
|
||||
try {
|
||||
observation = JSON.parse(row.observation) as Record<string, unknown>;
|
||||
} catch {
|
||||
console.warn(`无法解析 observation 数据: target_id=${row.target_id}, timestamp=${row.timestamp}`);
|
||||
observation = null;
|
||||
}
|
||||
}
|
||||
|
||||
let detail: null | string = null;
|
||||
if (observation !== null) {
|
||||
try {
|
||||
const checker = checkerRegistry.get(type);
|
||||
detail = checker.buildDetail(observation);
|
||||
} catch {
|
||||
detail = null;
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
detail,
|
||||
durationMs: row.duration_ms,
|
||||
failure,
|
||||
matched: row.matched === 1,
|
||||
statusDetail: row.status_detail,
|
||||
observation,
|
||||
timestamp: row.timestamp,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -60,7 +60,7 @@ export function handleDashboard(url: URL, store: ProbeStore, mode: RuntimeMode):
|
||||
group: target.grp,
|
||||
id: target.id,
|
||||
interval: formatDuration(target.interval_ms),
|
||||
latestCheck: latest ? mapCheckResult(latest) : null,
|
||||
latestCheck: latest ? mapCheckResult(latest, target.type) : null,
|
||||
name: target.name,
|
||||
recentSamples: recentSamples.map((sample) => ({
|
||||
durationMs: sample.duration_ms,
|
||||
|
||||
@@ -21,7 +21,7 @@ export function handleHistory(idStr: string, url: URL, store: ProbeStore, mode:
|
||||
|
||||
const result = store.getHistory(idResult.id, timeResult.from, timeResult.to, pageResult.page, pageResult.pageSize);
|
||||
const response: HistoryResponse = {
|
||||
items: result.items.map(mapCheckResult),
|
||||
items: result.items.map((row) => mapCheckResult(row, target.type)),
|
||||
page: result.page,
|
||||
pageSize: result.pageSize,
|
||||
total: result.total,
|
||||
|
||||
@@ -13,10 +13,11 @@ export interface CheckFailure {
|
||||
}
|
||||
|
||||
export interface CheckResult {
|
||||
detail: null | string;
|
||||
durationMs: null | number;
|
||||
failure: CheckFailure | null;
|
||||
matched: boolean;
|
||||
statusDetail: null | string;
|
||||
observation: null | Record<string, unknown>;
|
||||
timestamp: string;
|
||||
}
|
||||
|
||||
|
||||
@@ -37,7 +37,7 @@ 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 },
|
||||
]}
|
||||
/>
|
||||
|
||||
@@ -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: "详情",
|
||||
},
|
||||
];
|
||||
|
||||
@@ -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",
|
||||
});
|
||||
|
||||
@@ -90,7 +90,7 @@ describe("ProbeEngine", () => {
|
||||
const results = (mockStore as unknown as { _results: Array<Record<string, unknown>> })._results;
|
||||
expect(results.length).toBe(1);
|
||||
expect(results[0]!["matched"]).toBe(true);
|
||||
expect(results[0]!["statusDetail"]).toBe("exitCode=0");
|
||||
expect((results[0]!["observation"] as Record<string, unknown>)["exitCode"]).toBe(0);
|
||||
});
|
||||
|
||||
test("多个目标并发执行", async () => {
|
||||
@@ -181,7 +181,7 @@ describe("ProbeEngine", () => {
|
||||
expect(results[0]!["targetId"]).toBe("reject-cmd");
|
||||
expect(results[0]!["matched"]).toBe(false);
|
||||
expect(results[0]!["durationMs"]).toBeNull();
|
||||
expect(results[0]!["statusDetail"]).toBeNull();
|
||||
expect(results[0]!["observation"]).toBeNull();
|
||||
expect(results[0]!["failure"]).toEqual({
|
||||
kind: "error",
|
||||
message: "boom",
|
||||
@@ -288,7 +288,7 @@ describe("ProbeEngine", () => {
|
||||
const results = (mockStore as unknown as { _results: Array<Record<string, unknown>> })._results;
|
||||
expect(results.length).toBe(1);
|
||||
expect(results[0]!["matched"]).toBe(true);
|
||||
expect(results[0]!["statusDetail"]).toBe("HTTP 200");
|
||||
expect((results[0]!["observation"] as Record<string, unknown>)["statusCode"]).toBe(200);
|
||||
} finally {
|
||||
void httpServer.stop();
|
||||
}
|
||||
|
||||
@@ -45,14 +45,14 @@ describe("CommandChecker", () => {
|
||||
test("exitCode=0 成功", async () => {
|
||||
const result = await checker.execute(makeTarget({ args: ["-e", "process.exit(0)"], exec: "bun" }), makeCtx());
|
||||
expect(result.matched).toBe(true);
|
||||
expect(result.statusDetail).toBe("exitCode=0");
|
||||
expect(result.observation).toMatchObject({ exitCode: 0 });
|
||||
expect(result.failure).toBeNull();
|
||||
});
|
||||
|
||||
test("exitCode=1 不匹配默认 [0]", async () => {
|
||||
const result = await checker.execute(makeTarget({ args: ["-e", "process.exit(1)"], exec: "bun" }), makeCtx());
|
||||
expect(result.matched).toBe(false);
|
||||
expect(result.statusDetail).toBe("exitCode=1");
|
||||
expect(result.observation).toMatchObject({ exitCode: 1 });
|
||||
expect(result.failure!.phase).toBe("exitCode");
|
||||
});
|
||||
|
||||
@@ -62,7 +62,7 @@ describe("CommandChecker", () => {
|
||||
makeCtx(),
|
||||
);
|
||||
expect(result.matched).toBe(true);
|
||||
expect(result.statusDetail).toBe("exitCode=1");
|
||||
expect(result.observation).toMatchObject({ exitCode: 1 });
|
||||
});
|
||||
|
||||
test("命令不存在返回 spawn 错误", async () => {
|
||||
@@ -79,6 +79,7 @@ describe("CommandChecker", () => {
|
||||
);
|
||||
expect(result.matched).toBe(false);
|
||||
expect(result.failure!.message).toContain("超时");
|
||||
expect(result.observation?.["error"]).toContain("超时");
|
||||
});
|
||||
|
||||
test("stdout 输出捕获", async () => {
|
||||
@@ -130,6 +131,7 @@ describe("CommandChecker", () => {
|
||||
);
|
||||
expect(result.matched).toBe(false);
|
||||
expect(result.failure!.message).toContain("超过限制");
|
||||
expect(result.observation?.["error"]).toContain("超过限制");
|
||||
});
|
||||
|
||||
test("durationMs 非空", async () => {
|
||||
|
||||
@@ -34,14 +34,14 @@ describe("DbChecker", () => {
|
||||
test("无 query 时仅测试连接成功", async () => {
|
||||
const result = await checker.execute(makeTarget({}), makeCtx());
|
||||
expect(result.matched).toBe(true);
|
||||
expect(result.statusDetail).toBe("connected");
|
||||
expect(result.observation).toMatchObject({ connected: true });
|
||||
expect(result.failure).toBeNull();
|
||||
});
|
||||
|
||||
test("执行查询成功", async () => {
|
||||
const result = await checker.execute(makeTarget({ query: "SELECT 1 as num, 'hello' as str" }), makeCtx());
|
||||
expect(result.matched).toBe(true);
|
||||
expect(result.statusDetail).toBe("1 rows");
|
||||
expect(result.observation).toMatchObject({ connected: true, rowCount: 1 });
|
||||
expect(result.durationMs).toBeGreaterThanOrEqual(0);
|
||||
});
|
||||
|
||||
@@ -51,13 +51,13 @@ describe("DbChecker", () => {
|
||||
makeCtx(),
|
||||
);
|
||||
expect(result.matched).toBe(true);
|
||||
expect(result.statusDetail).toBe("3 rows");
|
||||
expect(result.observation).toMatchObject({ connected: true, rowCount: 3 });
|
||||
});
|
||||
|
||||
test("查询返回空结果", async () => {
|
||||
const result = await checker.execute(makeTarget({ query: "SELECT 1 as n WHERE 1=0" }), makeCtx());
|
||||
expect(result.matched).toBe(true);
|
||||
expect(result.statusDetail).toBe("0 rows");
|
||||
expect(result.observation).toMatchObject({ connected: true, rowCount: 0 });
|
||||
});
|
||||
|
||||
test("连接失败返回 connect phase 错误", async () => {
|
||||
|
||||
67
tests/server/checker/runner/detail.test.ts
Normal file
67
tests/server/checker/runner/detail.test.ts
Normal file
@@ -0,0 +1,67 @@
|
||||
import { describe, expect, test } from "bun:test";
|
||||
|
||||
import { CommandChecker } from "../../../../src/server/checker/runner/cmd/execute";
|
||||
import { DbChecker } from "../../../../src/server/checker/runner/db/execute";
|
||||
import { HttpChecker } from "../../../../src/server/checker/runner/http/execute";
|
||||
import { IcmpChecker } from "../../../../src/server/checker/runner/icmp/execute";
|
||||
import { LlmChecker } from "../../../../src/server/checker/runner/llm/execute";
|
||||
import { TcpChecker } from "../../../../src/server/checker/runner/tcp/execute";
|
||||
import { UdpChecker } from "../../../../src/server/checker/runner/udp/execute";
|
||||
|
||||
describe("Checker buildDetail", () => {
|
||||
test("HTTP detail", () => {
|
||||
expect(new HttpChecker().buildDetail({ statusCode: 200 })).toBe("HTTP 200");
|
||||
});
|
||||
|
||||
test("TCP detail", () => {
|
||||
const detail = new TcpChecker().buildDetail({
|
||||
banner: "220 smtp.example.com ESMTP",
|
||||
connected: true,
|
||||
connectTimeMs: 12,
|
||||
});
|
||||
expect(detail).toContain("connected in 12ms");
|
||||
expect(detail).toContain("banner:");
|
||||
});
|
||||
|
||||
test("UDP detail", () => {
|
||||
const checker = new UdpChecker();
|
||||
expect(checker.buildDetail({ durationMs: 12, responded: true, responsePreview: "PONG", responseSize: 4 })).toBe(
|
||||
"responded in 12ms, 4 bytes, response: PONG",
|
||||
);
|
||||
expect(checker.buildDetail({ durationMs: 200, responded: false })).toBe("no response in 200ms");
|
||||
});
|
||||
|
||||
test("Ping detail", () => {
|
||||
const checker = new IcmpChecker();
|
||||
expect(checker.buildDetail({ alive: true, avgLatencyMs: 12, packetLoss: 0, received: 3, transmitted: 3 })).toBe(
|
||||
"alive, avg 12ms, loss 0% (3/3)",
|
||||
);
|
||||
expect(checker.buildDetail({ alive: false, received: 0, transmitted: 3 })).toBe("unreachable (0/3 received)");
|
||||
});
|
||||
|
||||
test("DB detail", () => {
|
||||
const checker = new DbChecker();
|
||||
expect(checker.buildDetail({ connected: true, rowCount: 3 })).toBe("3 rows");
|
||||
expect(checker.buildDetail({ connected: true, rowCount: null })).toBe("connected");
|
||||
});
|
||||
|
||||
test("CMD detail", () => {
|
||||
expect(new CommandChecker().buildDetail({ exitCode: 0 })).toBe("exitCode=0");
|
||||
});
|
||||
|
||||
test("LLM detail", () => {
|
||||
const detail = new LlmChecker().buildDetail({
|
||||
finishReason: "stop",
|
||||
http: { status: 200 },
|
||||
mode: "http",
|
||||
outputLength: 2,
|
||||
provider: "openai",
|
||||
usage: { inputTokens: 12, outputTokens: 2, totalTokens: 14 },
|
||||
});
|
||||
expect(detail).toContain("LLM openai http");
|
||||
expect(detail).toContain("200");
|
||||
expect(detail).toContain("finish=stop");
|
||||
expect(detail).toContain("output=2 chars");
|
||||
expect(detail).toContain("usage=12/2 tokens");
|
||||
});
|
||||
});
|
||||
@@ -184,7 +184,7 @@ describe("HttpChecker", () => {
|
||||
test("成功请求 200", async () => {
|
||||
const result = await checker.execute(makeTarget({ url: `${baseUrl}/ok` }), makeCtx());
|
||||
expect(result.matched).toBe(true);
|
||||
expect(result.statusDetail).toBe("HTTP 200");
|
||||
expect(result.observation).toMatchObject({ statusCode: 200 });
|
||||
expect(result.durationMs).not.toBeNull();
|
||||
expect(result.failure).toBeNull();
|
||||
});
|
||||
@@ -192,7 +192,7 @@ describe("HttpChecker", () => {
|
||||
test("404 不匹配默认 status [200]", async () => {
|
||||
const result = await checker.execute(makeTarget({ url: `${baseUrl}/notfound` }), makeCtx());
|
||||
expect(result.matched).toBe(false);
|
||||
expect(result.statusDetail).toBe("HTTP 404");
|
||||
expect(result.observation).toMatchObject({ bodyPreview: "not found", statusCode: 404 });
|
||||
expect(result.failure!.phase).toBe("status");
|
||||
});
|
||||
|
||||
@@ -218,6 +218,7 @@ describe("HttpChecker", () => {
|
||||
makeCtx(),
|
||||
);
|
||||
expect(result.matched).toBe(false);
|
||||
expect(result.observation).toMatchObject({ bodyPreview: "hello world", statusCode: 200 });
|
||||
expect(result.failure!.phase).toBe("headers");
|
||||
});
|
||||
|
||||
@@ -328,13 +329,13 @@ describe("HttpChecker", () => {
|
||||
test("maxRedirects=0 不跟随重定向", async () => {
|
||||
const result = await checker.execute(makeTarget({ maxRedirects: 0, url: `${baseUrl}/redirect` }), makeCtx());
|
||||
expect(result.matched).toBe(false);
|
||||
expect(result.statusDetail).toBe("HTTP 301");
|
||||
expect(result.observation).toMatchObject({ statusCode: 301 });
|
||||
});
|
||||
|
||||
test("maxRedirects>0 跟随重定向", async () => {
|
||||
const result = await checker.execute(makeTarget({ maxRedirects: 5, url: `${baseUrl}/redirect` }), makeCtx());
|
||||
expect(result.matched).toBe(true);
|
||||
expect(result.statusDetail).toBe("HTTP 200");
|
||||
expect(result.observation).toMatchObject({ statusCode: 200 });
|
||||
});
|
||||
|
||||
test("maxRedirects 精确限制跟随次数", async () => {
|
||||
@@ -343,7 +344,7 @@ describe("HttpChecker", () => {
|
||||
makeCtx(),
|
||||
);
|
||||
expect(result.matched).toBe(false);
|
||||
expect(result.statusDetail).toBe("HTTP 302");
|
||||
expect(result.observation).toMatchObject({ statusCode: 302 });
|
||||
});
|
||||
|
||||
test("maxRedirects 允许足够次数时到达最终目标", async () => {
|
||||
@@ -352,7 +353,7 @@ describe("HttpChecker", () => {
|
||||
makeCtx(),
|
||||
);
|
||||
expect(result.matched).toBe(true);
|
||||
expect(result.statusDetail).toBe("HTTP 200");
|
||||
expect(result.observation).toMatchObject({ statusCode: 200 });
|
||||
});
|
||||
|
||||
test("ignoreSSL 跳过自签名证书校验", async () => {
|
||||
@@ -370,14 +371,14 @@ describe("HttpChecker", () => {
|
||||
makeCtx(),
|
||||
);
|
||||
expect(strictResult.matched).toBe(false);
|
||||
expect(strictResult.statusDetail).toBeNull();
|
||||
expect(strictResult.observation).toBeNull();
|
||||
|
||||
const ignoredResult = await checker.execute(
|
||||
makeTarget({ ignoreSSL: true, url: `https://localhost:${httpsServer.port}/` }),
|
||||
makeCtx(),
|
||||
);
|
||||
expect(ignoredResult.matched).toBe(true);
|
||||
expect(ignoredResult.statusDetail).toBe("HTTP 200");
|
||||
expect(ignoredResult.observation).toMatchObject({ statusCode: 200 });
|
||||
} finally {
|
||||
void httpsServer.stop();
|
||||
}
|
||||
@@ -594,7 +595,7 @@ describe("HttpChecker", () => {
|
||||
makeCtx(),
|
||||
);
|
||||
expect(result.matched).toBe(true);
|
||||
expect(result.statusDetail).toBe("HTTP 200");
|
||||
expect(result.observation).toMatchObject({ statusCode: 200 });
|
||||
});
|
||||
|
||||
test("混合 body rules 集成检查", async () => {
|
||||
|
||||
@@ -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,7 +87,7 @@ 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 () => {
|
||||
@@ -98,7 +104,7 @@ rtt min/avg/max/mdev = 1.234/156.000/340.000/0.567 ms`);
|
||||
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.observation).toBeNull();
|
||||
});
|
||||
|
||||
test("预 abort 返回超时错误", async () => {
|
||||
|
||||
@@ -111,10 +111,10 @@ describe("LlmChecker execute - 非流式", () => {
|
||||
const result = await checker.execute(makeTarget(), makeCtx());
|
||||
expect(result.matched).toBe(true);
|
||||
expect(result.failure).toBeNull();
|
||||
expect(result.statusDetail).toContain("openai");
|
||||
expect(result.statusDetail).toContain("http");
|
||||
expect(result.statusDetail).toContain("200");
|
||||
expect(result.statusDetail).toContain("finish=stop");
|
||||
expect(result.observation).toMatchObject({ provider: "openai" });
|
||||
expect(result.observation).toMatchObject({ mode: "http" });
|
||||
expect(result.observation).toMatchObject({ http: { status: 200 } });
|
||||
expect(result.observation).toMatchObject({ finishReason: "stop" });
|
||||
});
|
||||
|
||||
test("status expect 不匹配", async () => {
|
||||
@@ -168,12 +168,11 @@ describe("LlmChecker execute - 非流式", () => {
|
||||
expect(result.failure?.phase).toBe("request");
|
||||
});
|
||||
|
||||
test("statusDetail 包含 output 长度和 usage", async () => {
|
||||
test("observation 包含 output 长度和 usage", async () => {
|
||||
const result = await checker.execute(makeTarget(), makeCtx());
|
||||
expect(result.statusDetail).toContain("output=");
|
||||
expect(result.statusDetail).toContain("chars");
|
||||
expect(result.statusDetail).toContain("usage=");
|
||||
expect(result.statusDetail).toContain("tokens");
|
||||
expect(result.observation).toHaveProperty("outputPreview");
|
||||
expect(result.observation).toHaveProperty("outputLength");
|
||||
expect(result.observation).toHaveProperty("usage");
|
||||
});
|
||||
|
||||
test("无文本输出且配置 output expect 失败", async () => {
|
||||
|
||||
@@ -9,6 +9,7 @@ import { CheckerRegistry } from "../../../../src/server/checker/runner/registry"
|
||||
|
||||
function createChecker(type: string): Checker {
|
||||
return {
|
||||
buildDetail: () => null,
|
||||
configKey: type,
|
||||
execute: () => Promise.resolve<CheckResult>({} as unknown as CheckResult),
|
||||
resolve: () => ({}) as unknown as ResolvedTargetBase,
|
||||
|
||||
@@ -127,7 +127,7 @@ describe("TcpChecker execute", () => {
|
||||
expect(result.matched).toBe(true);
|
||||
expect(result.failure).toBeNull();
|
||||
expect(result.durationMs).toBeGreaterThanOrEqual(0);
|
||||
expect(result.statusDetail).toMatch(/^connected in \d+ms$/);
|
||||
expect(result.observation).toMatchObject({ connected: true });
|
||||
});
|
||||
|
||||
test("TCP 连接失败", async () => {
|
||||
@@ -145,7 +145,7 @@ describe("TcpChecker execute", () => {
|
||||
);
|
||||
expect(result.matched).toBe(true);
|
||||
expect(result.failure).toBeNull();
|
||||
expect(result.statusDetail).toBeTruthy();
|
||||
expect(result.observation).toMatchObject({ connected: false });
|
||||
});
|
||||
|
||||
test("期望端口不可达但连接成功", async () => {
|
||||
@@ -171,8 +171,11 @@ describe("TcpChecker execute", () => {
|
||||
makeCtx(),
|
||||
);
|
||||
expect(result.matched).toBe(true);
|
||||
expect(result.statusDetail).toContain("banner:");
|
||||
expect(result.statusDetail).toContain("220 smtp.example.com ESMTP");
|
||||
const obs = result.observation!;
|
||||
expect(obs).toMatchObject({
|
||||
connected: true,
|
||||
});
|
||||
expect(obs["banner"]).toContain("220 smtp.example.com ESMTP");
|
||||
});
|
||||
|
||||
test("banner operator 校验通过", async () => {
|
||||
@@ -206,7 +209,7 @@ describe("TcpChecker execute", () => {
|
||||
makeCtx(),
|
||||
);
|
||||
expect(result.matched).toBe(true);
|
||||
expect(result.statusDetail).not.toContain("banner:");
|
||||
expect(result.observation?.["banner"]).toBeFalsy();
|
||||
});
|
||||
|
||||
test("banner 超时空字符串继续执行", async () => {
|
||||
|
||||
@@ -60,7 +60,7 @@ describe("UdpChecker execute", () => {
|
||||
cleanup();
|
||||
expect(result.matched).toBe(true);
|
||||
expect(result.failure).toBeNull();
|
||||
expect(result.statusDetail).toContain("responded");
|
||||
expect(result.observation).toMatchObject({ responded: true });
|
||||
expect(result.durationMs).toBeGreaterThanOrEqual(0);
|
||||
} finally {
|
||||
server.close();
|
||||
@@ -103,7 +103,10 @@ describe("UdpChecker execute", () => {
|
||||
const result = await checker.execute(target, { signal: controller.signal });
|
||||
clearTimeout(timer);
|
||||
expect(result.matched).toBe(false);
|
||||
expect(result.failure).not.toBeNull();
|
||||
expect(result.failure).toMatchObject({ kind: "error", phase: "response" });
|
||||
expect(result.observation?.["durationMs"]).toBeGreaterThanOrEqual(0);
|
||||
expect(result.observation?.["error"]).toBeTruthy();
|
||||
expect(result.observation).toMatchObject({ responded: false });
|
||||
} finally {
|
||||
server.close();
|
||||
}
|
||||
@@ -131,7 +134,8 @@ describe("UdpChecker execute", () => {
|
||||
const result = await checker.execute(target, { signal: controller.signal });
|
||||
clearTimeout(timer);
|
||||
expect(result.matched).toBe(true);
|
||||
expect(result.statusDetail).toContain("no response");
|
||||
expect(result.observation?.["durationMs"]).toBeGreaterThanOrEqual(0);
|
||||
expect(result.observation).toMatchObject({ error: null, responded: false });
|
||||
} finally {
|
||||
server.close();
|
||||
}
|
||||
@@ -223,7 +227,7 @@ describe("UdpChecker execute", () => {
|
||||
const result = await checker.execute(target, { signal });
|
||||
cleanup();
|
||||
expect(result.matched).toBe(true);
|
||||
expect(result.statusDetail).toContain("5 bytes");
|
||||
expect(result.observation).toMatchObject({ responded: true, responseSize: 5 });
|
||||
} finally {
|
||||
server.close();
|
||||
}
|
||||
|
||||
@@ -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",
|
||||
});
|
||||
@@ -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",
|
||||
});
|
||||
@@ -493,7 +493,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 +535,7 @@ describe("ProbeStore", () => {
|
||||
durationMs: 100,
|
||||
failure: null,
|
||||
matched: false,
|
||||
statusDetail: null,
|
||||
observation: null,
|
||||
targetId: targetBId,
|
||||
timestamp: "2025-01-01T00:03:00.000Z",
|
||||
});
|
||||
@@ -543,7 +543,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 +551,7 @@ describe("ProbeStore", () => {
|
||||
durationMs: 100,
|
||||
failure: null,
|
||||
matched: false,
|
||||
statusDetail: null,
|
||||
observation: null,
|
||||
targetId: targetAId,
|
||||
timestamp: "2025-01-01T00:01:00.000Z",
|
||||
});
|
||||
@@ -574,7 +574,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 +582,7 @@ describe("ProbeStore", () => {
|
||||
durationMs: 100,
|
||||
failure: null,
|
||||
matched: true,
|
||||
statusDetail: "200 OK",
|
||||
observation: null,
|
||||
targetId: t.id,
|
||||
timestamp: new Date().toISOString(),
|
||||
});
|
||||
@@ -605,7 +605,7 @@ describe("ProbeStore", () => {
|
||||
durationMs: 100,
|
||||
failure: null,
|
||||
matched: true,
|
||||
statusDetail: "200 OK",
|
||||
observation: null,
|
||||
targetId: t.id,
|
||||
timestamp: new Date().toISOString(),
|
||||
});
|
||||
@@ -626,7 +626,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 +634,7 @@ describe("ProbeStore", () => {
|
||||
durationMs: 200,
|
||||
failure: null,
|
||||
matched: true,
|
||||
statusDetail: "200 OK",
|
||||
observation: null,
|
||||
targetId: t.id,
|
||||
timestamp: new Date(now).toISOString(),
|
||||
});
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
import { describe, expect, test } from "bun:test";
|
||||
|
||||
import { createApiError, createHeaders, formatDuration, jsonResponse } from "../../src/server/helpers";
|
||||
import type { StoredCheckResult } from "../../src/server/checker/types";
|
||||
|
||||
import { createApiError, createHeaders, formatDuration, jsonResponse, mapCheckResult } from "../../src/server/helpers";
|
||||
|
||||
describe("createApiError", () => {
|
||||
test("创建错误响应对象", () => {
|
||||
@@ -107,3 +109,42 @@ describe("formatDuration", () => {
|
||||
expect(formatDuration(61123)).toBe("61123ms");
|
||||
});
|
||||
});
|
||||
|
||||
function makeRow(overrides: Partial<StoredCheckResult> = {}): StoredCheckResult {
|
||||
return {
|
||||
duration_ms: 12,
|
||||
failure: null,
|
||||
id: 1,
|
||||
matched: 1,
|
||||
observation: null,
|
||||
target_id: "target-1",
|
||||
timestamp: "2025-01-01T00:00:00.000Z",
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
describe("mapCheckResult", () => {
|
||||
test("反序列化 observation 并构造 detail", () => {
|
||||
const result = mapCheckResult(makeRow({ observation: JSON.stringify({ statusCode: 200 }) }), "http");
|
||||
expect(result.detail).toBe("HTTP 200");
|
||||
expect(result.observation).toEqual({ statusCode: 200 });
|
||||
});
|
||||
|
||||
test("null observation 返回 null detail", () => {
|
||||
const result = mapCheckResult(makeRow(), "http");
|
||||
expect(result.detail).toBeNull();
|
||||
expect(result.observation).toBeNull();
|
||||
});
|
||||
|
||||
test("未知 type 不影响响应序列化", () => {
|
||||
const result = mapCheckResult(makeRow({ observation: JSON.stringify({ statusCode: 200 }) }), "unknown");
|
||||
expect(result.detail).toBeNull();
|
||||
expect(result.observation).toEqual({ statusCode: 200 });
|
||||
});
|
||||
|
||||
test("损坏 observation JSON 返回 null observation", () => {
|
||||
const result = mapCheckResult(makeRow({ observation: "{invalid json" }), "http");
|
||||
expect(result.detail).toBeNull();
|
||||
expect(result.observation).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -14,10 +14,11 @@ describe("OverviewTab", () => {
|
||||
id: "1",
|
||||
interval: "30s",
|
||||
latestCheck: {
|
||||
detail: "200 OK",
|
||||
durationMs: 100,
|
||||
failure: null,
|
||||
matched: true,
|
||||
statusDetail: "200 OK",
|
||||
observation: null,
|
||||
timestamp: "2025-01-15T10:00:00.000Z",
|
||||
},
|
||||
name: "test-target",
|
||||
|
||||
@@ -14,10 +14,11 @@ describe("TargetDetailDrawer", () => {
|
||||
id: "1",
|
||||
interval: "30s",
|
||||
latestCheck: {
|
||||
detail: "200 OK",
|
||||
durationMs: 100,
|
||||
failure: null,
|
||||
matched: true,
|
||||
statusDetail: "200 OK",
|
||||
observation: null,
|
||||
timestamp: "2025-01-15T10:00:00.000Z",
|
||||
},
|
||||
name: "test-target",
|
||||
|
||||
@@ -20,10 +20,11 @@ describe("TargetGroup", () => {
|
||||
id: "1",
|
||||
interval: "30s",
|
||||
latestCheck: {
|
||||
detail: "200 OK",
|
||||
durationMs: 100,
|
||||
failure: null,
|
||||
matched: true,
|
||||
statusDetail: "200 OK",
|
||||
observation: null,
|
||||
timestamp: "2025-01-15T10:00:00.000Z",
|
||||
},
|
||||
name: "target-1",
|
||||
@@ -39,10 +40,11 @@ describe("TargetGroup", () => {
|
||||
id: "2",
|
||||
interval: "30s",
|
||||
latestCheck: {
|
||||
detail: "500 Internal Server Error",
|
||||
durationMs: 100,
|
||||
failure: { kind: "error", message: "Failed", path: "$", phase: "status" },
|
||||
matched: false,
|
||||
statusDetail: "500 Internal Server Error",
|
||||
observation: null,
|
||||
timestamp: "2025-01-15T10:00:00.000Z",
|
||||
},
|
||||
name: "target-2",
|
||||
|
||||
@@ -110,10 +110,11 @@ 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",
|
||||
},
|
||||
}),
|
||||
@@ -139,10 +140,11 @@ 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",
|
||||
},
|
||||
}),
|
||||
|
||||
@@ -24,10 +24,10 @@ function makeTarget(overrides: Partial<TargetStatus> = {}): TargetStatus {
|
||||
describe("statusSorter", () => {
|
||||
test("DOWN 排在 UP 前面", () => {
|
||||
const up = makeTarget({
|
||||
latestCheck: { durationMs: 10, failure: null, matched: true, statusDetail: null, timestamp: "" },
|
||||
latestCheck: { detail: null, durationMs: 10, failure: null, matched: true, observation: null, timestamp: "" },
|
||||
});
|
||||
const down = makeTarget({
|
||||
latestCheck: { durationMs: 10, failure: null, matched: false, statusDetail: null, timestamp: "" },
|
||||
latestCheck: { detail: null, durationMs: 10, failure: null, matched: false, observation: null, timestamp: "" },
|
||||
});
|
||||
expect(statusSorter(down, up)).toBeLessThan(0);
|
||||
expect(statusSorter(up, down)).toBeGreaterThan(0);
|
||||
@@ -35,10 +35,10 @@ describe("statusSorter", () => {
|
||||
|
||||
test("相同状态返回 0", () => {
|
||||
const a = makeTarget({
|
||||
latestCheck: { durationMs: 10, failure: null, matched: true, statusDetail: null, timestamp: "" },
|
||||
latestCheck: { detail: null, durationMs: 10, failure: null, matched: true, observation: null, timestamp: "" },
|
||||
});
|
||||
const b = makeTarget({
|
||||
latestCheck: { durationMs: 20, failure: null, matched: true, statusDetail: null, timestamp: "" },
|
||||
latestCheck: { detail: null, durationMs: 20, failure: null, matched: true, observation: null, timestamp: "" },
|
||||
});
|
||||
expect(statusSorter(a, b)).toBe(0);
|
||||
});
|
||||
@@ -46,7 +46,7 @@ describe("statusSorter", () => {
|
||||
test("无 latestCheck 的目标排在最后", () => {
|
||||
const noCheck = makeTarget();
|
||||
const up = makeTarget({
|
||||
latestCheck: { durationMs: 10, failure: null, matched: true, statusDetail: null, timestamp: "" },
|
||||
latestCheck: { detail: null, durationMs: 10, failure: null, matched: true, observation: null, timestamp: "" },
|
||||
});
|
||||
expect(statusSorter(noCheck, up)).toBeGreaterThan(0);
|
||||
});
|
||||
@@ -75,20 +75,20 @@ describe("availabilitySorter", () => {
|
||||
describe("latencySorter", () => {
|
||||
test("低延迟排前面", () => {
|
||||
const fast = makeTarget({
|
||||
latestCheck: { durationMs: 50, failure: null, matched: true, statusDetail: null, timestamp: "" },
|
||||
latestCheck: { detail: null, durationMs: 50, failure: null, matched: true, observation: null, timestamp: "" },
|
||||
});
|
||||
const slow = makeTarget({
|
||||
latestCheck: { durationMs: 200, failure: null, matched: true, statusDetail: null, timestamp: "" },
|
||||
latestCheck: { detail: null, durationMs: 200, failure: null, matched: true, observation: null, timestamp: "" },
|
||||
});
|
||||
expect(latencySorter(fast, slow)).toBeLessThan(0);
|
||||
});
|
||||
|
||||
test("无延迟排最后", () => {
|
||||
const noLatency = makeTarget({
|
||||
latestCheck: { durationMs: null, failure: null, matched: true, statusDetail: null, timestamp: "" },
|
||||
latestCheck: { detail: null, durationMs: null, failure: null, matched: true, observation: null, timestamp: "" },
|
||||
});
|
||||
const hasLatency = makeTarget({
|
||||
latestCheck: { durationMs: 100, failure: null, matched: true, statusDetail: null, timestamp: "" },
|
||||
latestCheck: { detail: null, durationMs: 100, failure: null, matched: true, observation: null, timestamp: "" },
|
||||
});
|
||||
expect(latencySorter(noLatency, hasLatency)).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user