From 375dd3492bfc89eb66715c1425850e158394e8fd Mon Sep 17 00:00:00 2001 From: lanyuanxiaoyao Date: Tue, 19 May 2026 22:49:00 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20=E7=BB=93=E6=9E=84=E5=8C=96=20observati?= =?UTF-8?q?on=20=E6=9B=BF=E4=BB=A3=20statusDetail=EF=BC=8CAPI=20=E5=B1=82?= =?UTF-8?q?=E5=8A=A8=E6=80=81=E6=9E=84=E9=80=A0=20detail?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 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 变更 --- DEVELOPMENT.md | 6 +- README.md | 3 + .../checker-observation/.openspec.yaml | 2 - .../changes/checker-observation/design.md | 159 ---------------- .../changes/checker-observation/proposal.md | 37 ---- .../specs/checker-runner-abstraction/spec.md | 40 ---- .../specs/cmd-checker/spec.md | 24 --- .../specs/icmp-checker/spec.md | 16 -- .../specs/llm-checker/spec.md | 39 ---- .../specs/probe-api/spec.md | 48 ----- .../specs/probe-data-store/spec.md | 41 ---- .../specs/probe-engine/spec.md | 16 -- .../specs/target-detail-drawer/spec.md | 16 -- .../specs/tcp-checker/spec.md | 51 ----- .../specs/udp-checker/spec.md | 55 ------ openspec/changes/checker-observation/tasks.md | 56 ------ .../specs/checker-observation/spec.md | 8 +- .../specs/checker-runner-abstraction/spec.md | 12 +- openspec/specs/cmd-checker/spec.md | 12 +- openspec/specs/icmp-checker/spec.md | 16 +- openspec/specs/llm-checker/spec.md | 8 +- openspec/specs/probe-api/spec.md | 24 ++- openspec/specs/probe-data-store/spec.md | 8 +- openspec/specs/probe-engine/spec.md | 8 +- openspec/specs/target-detail-drawer/spec.md | 2 +- openspec/specs/tcp-checker/spec.md | 16 +- openspec/specs/udp-checker/spec.md | 22 +-- src/server/checker/engine.ts | 5 +- src/server/checker/runner/cmd/execute.ts | 51 +++-- src/server/checker/runner/db/execute.ts | 71 ++++--- src/server/checker/runner/http/execute.ts | 174 +++++++++++++---- src/server/checker/runner/icmp/execute.ts | 76 ++++++-- src/server/checker/runner/llm/execute.ts | 85 +++++---- src/server/checker/runner/llm/observation.ts | 43 +++++ src/server/checker/runner/llm/types.ts | 27 ++- src/server/checker/runner/tcp/execute.ts | 77 ++++++-- src/server/checker/runner/types.ts | 1 + src/server/checker/runner/udp/execute.ts | 177 +++++++++++------- src/server/checker/store.ts | 8 +- src/server/checker/types.ts | 2 +- src/server/helpers.ts | 27 ++- src/server/routes/dashboard.ts | 2 +- src/server/routes/history.ts | 2 +- src/shared/api.ts | 3 +- src/web/components/OverviewTab.tsx | 2 +- src/web/constants/history-table-columns.tsx | 4 +- tests/server/app.test.ts | 18 +- tests/server/checker/engine.test.ts | 6 +- .../server/checker/runner/cmd/runner.test.ts | 8 +- .../server/checker/runner/db/execute.test.ts | 8 +- tests/server/checker/runner/detail.test.ts | 67 +++++++ .../server/checker/runner/http/runner.test.ts | 19 +- .../checker/runner/icmp/execute.test.ts | 14 +- .../server/checker/runner/llm/execute.test.ts | 17 +- tests/server/checker/runner/registry.test.ts | 1 + .../server/checker/runner/tcp/execute.test.ts | 13 +- .../server/checker/runner/udp/execute.test.ts | 12 +- tests/server/checker/store.test.ts | 36 ++-- tests/server/helpers.test.ts | 43 ++++- tests/web/components/OverviewTab.test.tsx | 3 +- .../components/TargetDetailDrawer.test.tsx | 3 +- tests/web/components/TargetGroup.test.tsx | 6 +- .../constants/target-table-columns.test.ts | 6 +- .../constants/target-table-sorters.test.ts | 18 +- 64 files changed, 915 insertions(+), 965 deletions(-) delete mode 100644 openspec/changes/checker-observation/.openspec.yaml delete mode 100644 openspec/changes/checker-observation/design.md delete mode 100644 openspec/changes/checker-observation/proposal.md delete mode 100644 openspec/changes/checker-observation/specs/checker-runner-abstraction/spec.md delete mode 100644 openspec/changes/checker-observation/specs/cmd-checker/spec.md delete mode 100644 openspec/changes/checker-observation/specs/icmp-checker/spec.md delete mode 100644 openspec/changes/checker-observation/specs/llm-checker/spec.md delete mode 100644 openspec/changes/checker-observation/specs/probe-api/spec.md delete mode 100644 openspec/changes/checker-observation/specs/probe-data-store/spec.md delete mode 100644 openspec/changes/checker-observation/specs/probe-engine/spec.md delete mode 100644 openspec/changes/checker-observation/specs/target-detail-drawer/spec.md delete mode 100644 openspec/changes/checker-observation/specs/tcp-checker/spec.md delete mode 100644 openspec/changes/checker-observation/specs/udp-checker/spec.md delete mode 100644 openspec/changes/checker-observation/tasks.md rename openspec/{changes/checker-observation => }/specs/checker-observation/spec.md (94%) create mode 100644 tests/server/checker/runner/detail.test.ts diff --git a/DEVELOPMENT.md b/DEVELOPMENT.md index 21b3ec8..25eace4 100644 --- a/DEVELOPMENT.md +++ b/DEVELOPMENT.md @@ -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` diff --git a/README.md b/README.md index f86b868..5d925bb 100644 --- a/README.md +++ b/README.md @@ -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 diff --git a/openspec/changes/checker-observation/.openspec.yaml b/openspec/changes/checker-observation/.openspec.yaml deleted file mode 100644 index 28882f7..0000000 --- a/openspec/changes/checker-observation/.openspec.yaml +++ /dev/null @@ -1,2 +0,0 @@ -schema: spec-driven -created: 2026-05-19 diff --git a/openspec/changes/checker-observation/design.md b/openspec/changes/checker-observation/design.md deleted file mode 100644 index 02ff266..0000000 --- a/openspec/changes/checker-observation/design.md +++ /dev/null @@ -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 | null` - -**选择**: 使用 `Record` 作为 observation 的共享类型,各 checker 内部定义强类型 interface 用于构造,序列化后类型擦除。 - -**替代方案**: -- Discriminated union(`{ type: "http", ... } | { type: "tcp", ... }`):类型安全但导致共享层与所有 checker 类型耦合,每新增一个 checker 都需要修改共享类型 -- `unknown`:过于宽松,缺乏结构约束 - -**理由**: 折中方案。共享层不关心 observation 的内部结构(只需序列化/反序列化),类型安全由各 checker 内部保证。buildDetail 方法接收 `Record` 并在内部做类型断言。 - -### 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 | 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`,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 描述解码问题。 diff --git a/openspec/changes/checker-observation/proposal.md b/openspec/changes/checker-observation/proposal.md deleted file mode 100644 index 27ba368..0000000 --- a/openspec/changes/checker-observation/proposal.md +++ /dev/null @@ -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 | 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、前端展示的测试需适配新字段 diff --git a/openspec/changes/checker-observation/specs/checker-runner-abstraction/spec.md b/openspec/changes/checker-observation/specs/checker-runner-abstraction/spec.md deleted file mode 100644 index 712d585..0000000 --- a/openspec/changes/checker-observation/specs/checker-runner-abstraction/spec.md +++ /dev/null @@ -1,40 +0,0 @@ -## MODIFIED Requirements - -### Requirement: Checker 接口定义 -系统 SHALL 在 `src/server/checker/runner/types.ts` 中定义面向扩展的泛型 `CheckerDefinition`,包含 `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` 接口签名 -- **THEN** `resolve` 的返回值 SHALL 为 `TResolved`;`execute` 的参数 SHALL 为 `TResolved`;`serialize` 的参数 SHALL 为 `TResolved` - -#### Scenario: checker 实现无需手动断言 -- **WHEN** HttpChecker 实现 `CheckerDefinition` -- **THEN** `execute` 方法的 target 参数类型 SHALL 直接为 `ResolvedHttpTarget`,无需在方法内部使用 `as` 类型断言 - -#### Scenario: registry 使用默认泛型参数 -- **WHEN** CheckerRegistry 存储和返回 checker 实例 -- **THEN** registry 内部 SHALL 使用 `CheckerDefinition`(等价于 `CheckerDefinition`),实现类型擦除 - -#### Scenario: buildDetail 方法签名 -- **WHEN** 开发者实现 buildDetail 方法 -- **THEN** 方法签名 SHALL 为 `buildDetail(observation: Record): string | null`,接收 observation 对象并返回人可读摘要字符串或 null - -#### Scenario: buildDetail 由 API 层调用 -- **WHEN** API 序列化 CheckResult -- **THEN** API 层 SHALL 通过 registry 获取对应 checker 并调用 buildDetail,而非由 execute 方法直接生成 detail diff --git a/openspec/changes/checker-observation/specs/cmd-checker/spec.md b/openspec/changes/checker-observation/specs/cmd-checker/spec.md deleted file mode 100644 index fffc310..0000000 --- a/openspec/changes/checker-observation/specs/cmd-checker/spec.md +++ /dev/null @@ -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 diff --git a/openspec/changes/checker-observation/specs/icmp-checker/spec.md b/openspec/changes/checker-observation/specs/icmp-checker/spec.md deleted file mode 100644 index 9340dcd..0000000 --- a/openspec/changes/checker-observation/specs/icmp-checker/spec.md +++ /dev/null @@ -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)` diff --git a/openspec/changes/checker-observation/specs/llm-checker/spec.md b/openspec/changes/checker-observation/specs/llm-checker/spec.md deleted file mode 100644 index 8aba918..0000000 --- a/openspec/changes/checker-observation/specs/llm-checker/spec.md +++ /dev/null @@ -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 通过 diff --git a/openspec/changes/checker-observation/specs/probe-api/spec.md b/openspec/changes/checker-observation/specs/probe-api/spec.md deleted file mode 100644 index c27f24d..0000000 --- a/openspec/changes/checker-observation/specs/probe-api/spec.md +++ /dev/null @@ -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 | 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 | 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 diff --git a/openspec/changes/checker-observation/specs/probe-data-store/spec.md b/openspec/changes/checker-observation/specs/probe-data-store/spec.md deleted file mode 100644 index 3dac28c..0000000 --- a/openspec/changes/checker-observation/specs/probe-data-store/spec.md +++ /dev/null @@ -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 字段 diff --git a/openspec/changes/checker-observation/specs/probe-engine/spec.md b/openspec/changes/checker-observation/specs/probe-engine/spec.md deleted file mode 100644 index a7492c7..0000000 --- a/openspec/changes/checker-observation/specs/probe-engine/spec.md +++ /dev/null @@ -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" 和具体不匹配信息 diff --git a/openspec/changes/checker-observation/specs/target-detail-drawer/spec.md b/openspec/changes/checker-observation/specs/target-detail-drawer/spec.md deleted file mode 100644 index 5cd40fd..0000000 --- a/openspec/changes/checker-observation/specs/target-detail-drawer/spec.md +++ /dev/null @@ -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 请求对应页码的服务端数据,表格更新 diff --git a/openspec/changes/checker-observation/specs/tcp-checker/spec.md b/openspec/changes/checker-observation/specs/tcp-checker/spec.md deleted file mode 100644 index 2f87be9..0000000 --- a/openspec/changes/checker-observation/specs/tcp-checker/spec.md +++ /dev/null @@ -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 展示过长文本 diff --git a/openspec/changes/checker-observation/specs/udp-checker/spec.md b/openspec/changes/checker-observation/specs/udp-checker/spec.md deleted file mode 100644 index 6cf712a..0000000 --- a/openspec/changes/checker-observation/specs/udp-checker/spec.md +++ /dev/null @@ -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` 转换并截断后的响应摘要 diff --git a/openspec/changes/checker-observation/tasks.md b/openspec/changes/checker-observation/tasks.md deleted file mode 100644 index 1b1debe..0000000 --- a/openspec/changes/checker-observation/tasks.md +++ /dev/null @@ -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 机制 diff --git a/openspec/changes/checker-observation/specs/checker-observation/spec.md b/openspec/specs/checker-observation/spec.md similarity index 94% rename from openspec/changes/checker-observation/specs/checker-observation/spec.md rename to openspec/specs/checker-observation/spec.md index c6b1b8e..99b756a 100644 --- a/openspec/changes/checker-observation/specs/checker-observation/spec.md +++ b/openspec/specs/checker-observation/spec.md @@ -1,4 +1,8 @@ -## ADDED Requirements +## Purpose + +定义 CheckResult 的 observation 数据模型、各 checker 类型 observation 结构、截断策略、序列化规则和 detail 动态构造机制。 + +## Requirements ### Requirement: Observation 数据模型 CheckResult SHALL 包含 `observation: Record | 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 发送数据后收到响应 diff --git a/openspec/specs/checker-runner-abstraction/spec.md b/openspec/specs/checker-runner-abstraction/spec.md index 80a7d95..c637173 100644 --- a/openspec/specs/checker-runner-abstraction/spec.md +++ b/openspec/specs/checker-runner-abstraction/spec.md @@ -50,11 +50,11 @@ - **THEN** checker SHALL 返回 `ConfigValidationIssue`,而不是直接抛出最终用户错误字符串 ### Requirement: Checker 接口定义 -系统 SHALL 在 `src/server/checker/runner/types.ts` 中定义面向扩展的泛型 `CheckerDefinition`,包含 `type`、`configKey`、TypeBox 配置契约、启动期语义校验、`resolve`、`execute`、`serialize` 成员。泛型参数 SHALL 约束 `execute` 和 `serialize` 方法的 target 参数类型,使 checker 实现内部获得编译期类型安全。默认泛型参数 `= ResolvedTargetBase` 保证中间层(registry、engine、config-loader)无需指定泛型。 +系统 SHALL 在 `src/server/checker/runner/types.ts` 中定义面向扩展的泛型 `CheckerDefinition`,包含 `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`),实现类型擦除 +#### Scenario: buildDetail 方法签名 +- **WHEN** 开发者实现 buildDetail 方法 +- **THEN** 方法签名 SHALL 为 `buildDetail(observation: Record): 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`(使用默认泛型参数),对外提供类型擦除后的接口。 diff --git a/openspec/specs/cmd-checker/spec.md b/openspec/specs/cmd-checker/spec.md index f8dbb91..11a1081 100644 --- a/openspec/specs/cmd-checker/spec.md +++ b/openspec/specs/cmd-checker/spec.md @@ -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 断言。 diff --git a/openspec/specs/icmp-checker/spec.md b/openspec/specs/icmp-checker/spec.md index 026c8d2..d66b562 100644 --- a/openspec/specs/icmp-checker/spec.md +++ b/openspec/specs/icmp-checker/spec.md @@ -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)` diff --git a/openspec/specs/llm-checker/spec.md b/openspec/specs/llm-checker/spec.md index 52ca32c..be19d9b 100644 --- a/openspec/specs/llm-checker/spec.md +++ b/openspec/specs/llm-checker/spec.md @@ -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** 实现完成后执行质量检查 diff --git a/openspec/specs/probe-api/spec.md b/openspec/specs/probe-api/spec.md index 363afe7..86fd149 100644 --- a/openspec/specs/probe-api/spec.md +++ b/openspec/specs/probe-api/spec.md @@ -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 | 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 | 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` 端点,不受拨测功能影响。 diff --git a/openspec/specs/probe-data-store/spec.md b/openspec/specs/probe-data-store/spec.md index 4e21d42..3072b86 100644 --- a/openspec/specs/probe-data-store/spec.md +++ b/openspec/specs/probe-data-store/spec.md @@ -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 不匹配 diff --git a/openspec/specs/probe-engine/spec.md b/openspec/specs/probe-engine/spec.md index 55c18db..9a2aa16 100644 --- a/openspec/specs/probe-engine/spec.md +++ b/openspec/specs/probe-engine/spec.md @@ -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 执行检查。 diff --git a/openspec/specs/target-detail-drawer/spec.md b/openspec/specs/target-detail-drawer/spec.md index 960d795..2f55bb8 100644 --- a/openspec/specs/target-detail-drawer/spec.md +++ b/openspec/specs/target-detail-drawer/spec.md @@ -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** 检查结果总数超过一页 diff --git a/openspec/specs/tcp-checker/spec.md b/openspec/specs/tcp-checker/spec.md index 5fcee7d..d4ff7f5 100644 --- a/openspec/specs/tcp-checker/spec.md +++ b/openspec/specs/tcp-checker/spec.md @@ -36,27 +36,27 @@ - **THEN** `target` 展示摘要 SHALL 为 `:`,`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 读取在内的完整执行耗时。 diff --git a/openspec/specs/udp-checker/spec.md b/openspec/specs/udp-checker/spec.md index d01b0b6..f79d9ba 100644 --- a/openspec/specs/udp-checker/spec.md +++ b/openspec/specs/udp-checker/spec.md @@ -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` 转换并截断后的响应摘要 diff --git a/src/server/checker/engine.ts b/src/server/checker/engine.ts index b4dc52e..ee72bdc 100644 --- a/src/server/checker/engine.ts +++ b/src/server/checker/engine.ts @@ -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, }); diff --git a/src/server/checker/runner/cmd/execute.ts b/src/server/checker/runner/cmd/execute.ts index d00a366..0a744fb 100644 --- a/src/server/checker/runner/cmd/execute.ts +++ b/src/server/checker/runner/cmd/execute.ts @@ -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 { readonly configKey = "cmd"; @@ -20,6 +23,11 @@ export class CommandChecker implements CheckerDefinition readonly type = "cmd"; + buildDetail(observation: Record): null | string { + const exitCode = observation["exitCode"]; + return typeof exitCode === "number" ? `exitCode=${exitCode}` : null; + } + async execute(t: ResolvedCommandTarget, ctx: CheckerContext): Promise { const timestamp = new Date().toISOString(); const start = performance.now(); @@ -37,10 +45,11 @@ export class CommandChecker implements CheckerDefinition } 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 } 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 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 = { 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 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 }); 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 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 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 } 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); +} diff --git a/src/server/checker/runner/db/execute.ts b/src/server/checker/runner/db/execute.ts index 35a1e86..5ea6c7d 100644 --- a/src/server/checker/runner/db/execute.ts +++ b/src/server/checker/runner/db/execute.ts @@ -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 { readonly configKey = "db"; @@ -21,16 +22,27 @@ export class DbChecker implements CheckerDefinition { readonly type = "db"; + buildDetail(observation: Record): 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 { 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 { { 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 = { + 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 { }); 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 = { 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 { }); 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 { } 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 { } return { + detail: null, durationMs, failure: null, matched: true, - statusDetail: `${Array.isArray(rows) ? rows.length : 0} rows`, + observation, targetId: t.id, timestamp, }; diff --git a/src/server/checker/runner/http/execute.ts b/src/server/checker/runner/http/execute.ts index c84ca4c..5565d9b 100644 --- a/src/server/checker/runner/http/execute.ts +++ b/src/server/checker/runner/http/execute.ts @@ -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 { readonly type = "http"; + buildDetail(observation: Record): null | string { + const statusCode = observation["statusCode"]; + return typeof statusCode === "number" ? `HTTP ${statusCode}` : null; + } + async execute(t: ResolvedHttpTarget, ctx: CheckerContext): Promise { const timestamp = new Date().toISOString(); const expect = t.expect; @@ -39,39 +45,102 @@ export class HttpChecker implements CheckerDefinition { }); 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 { 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 { 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 { } } +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, + 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 = { + 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, maxCount = 20): Record { + const entries = Object.entries(headers); + if (entries.length <= maxCount) return headers; + return Object.fromEntries(entries.slice(0, maxCount)); } diff --git a/src/server/checker/runner/icmp/execute.ts b/src/server/checker/runner/icmp/execute.ts index 24eefc4..cd63b42 100644 --- a/src/server/checker/runner/icmp/execute.ts +++ b/src/server/checker/runner/icmp/execute.ts @@ -22,6 +22,35 @@ export class IcmpChecker implements CheckerDefinition { readonly type = "ping"; + buildDetail(observation: Record): 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 { const timestamp = new Date().toISOString(); const start = performance.now(); @@ -36,10 +65,11 @@ export class IcmpChecker implements CheckerDefinition { } 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 { 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 { 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 = { + 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 { } } -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): Promise { } return text; } - -function truncateOutput(output: string, maxLen = 80): string { - if (output.length <= maxLen) return output; - return `${output.slice(0, maxLen)}…`; -} diff --git a/src/server/checker/runner/llm/execute.ts b/src/server/checker/runner/llm/execute.ts index b343c97..955022c 100644 --- a/src/server/checker/runner/llm/execute.ts +++ b/src/server/checker/runner/llm/execute.ts @@ -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 { readonly schemas = llmCheckerSchemas; readonly type = "llm"; + buildDetail(observation: Record): 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 | 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 | 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 | 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 { const timestamp = new Date().toISOString(); const expect = t.expect; @@ -45,10 +83,11 @@ export class LlmChecker implements CheckerDefinition { 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 { 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 { 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 { 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 { 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 { 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, maxCount: number): Record { + const entries = Object.entries(headers); + if (entries.length <= maxCount) return headers; + return Object.fromEntries(entries.slice(0, maxCount)); +} diff --git a/src/server/checker/runner/llm/types.ts b/src/server/checker/runner/llm/types.ts index 42ffda7..2c547cc 100644 --- a/src/server/checker/runner/llm/types.ts +++ b/src/server/checker/runner/llm/types.ts @@ -15,6 +15,7 @@ export interface LlmCheckObservation { usage: LlmUsageObservation | null; warnings: string[]; } + export interface LlmDefaultsConfig { headers?: Record; ignoreSSL?: boolean; @@ -33,13 +34,37 @@ export interface LlmExpectConfig { stream?: LlmStreamExpect; usage?: LlmUsageExpect; } - export interface LlmHttpMetadata { headers: Record; status: number; statusText: string; } +export interface LlmPersistedHttpMetadata { + [key: string]: unknown; + headers: Record; + 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 { diff --git a/src/server/checker/runner/tcp/execute.ts b/src/server/checker/runner/tcp/execute.ts index 85232bf..af67832 100644 --- a/src/server/checker/runner/tcp/execute.ts +++ b/src/server/checker/runner/tcp/execute.ts @@ -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 { @@ -25,6 +25,21 @@ export class TcpChecker implements CheckerDefinition { readonly type = "tcp"; + buildDetail(observation: Record): 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 { const timestamp = new Date().toISOString(); const start = performance.now(); @@ -42,36 +57,46 @@ export class TcpChecker implements CheckerDefinition { if (!connectResult.ok) { const durationMs = Math.round(performance.now() - start); + const observation: Record = { + 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 { 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 { 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 { const banner = connectResult.banner ?? ""; closeSocket(socket); + const observation: Record = { + 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 { }); 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 { 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); +} diff --git a/src/server/checker/runner/types.ts b/src/server/checker/runner/types.ts index c28eb5c..762ccca 100644 --- a/src/server/checker/runner/types.ts +++ b/src/server/checker/runner/types.ts @@ -10,6 +10,7 @@ export interface CheckerContext { } export interface CheckerDefinition { + buildDetail(observation: Record): null | string; readonly configKey: string; execute(target: TResolved, ctx: CheckerContext): Promise; resolve(target: RawTargetConfig, context: ResolveContext): TResolved; diff --git a/src/server/checker/runner/udp/execute.ts b/src/server/checker/runner/udp/execute.ts index 8a517dd..377345a 100644 --- a/src/server/checker/runner/udp/execute.ts +++ b/src/server/checker/runner/udp/execute.ts @@ -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 { readonly schemas = udpCheckerSchemas; readonly type = "udp"; + buildDetail(observation: Record): 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 { const timestamp = new Date().toISOString(); const start = performance.now(); @@ -47,35 +64,21 @@ export class UdpChecker implements CheckerDefinition { if (!exchangeResult.ok) { const durationMs = Math.round(performance.now() - start); - if (expect?.responded === false) { - return { - durationMs, - failure: null, - matched: true, - statusDetail: exchangeResult.error, - targetId: t.id, - timestamp, - }; - } + const observation: Record = { + durationMs, + error: exchangeResult.error, + responded: false, + responsePreview: null, + responseSize: null, + sourceAddress: null, + sourcePort: null, + }; return { + detail: null, durationMs, failure: errorFailure("response", "response", exchangeResult.error), matched: false, - statusDetail: null, - targetId: t.id, - timestamp, - }; - } - - const expectedResponded = expect?.responded ?? true; - const respondedResult = checkResponded(exchangeResult.responded, expectedResponded); - if (!respondedResult.matched) { - const durationMs = Math.round(performance.now() - start); - return { - durationMs, - failure: respondedResult.failure, - matched: false, - statusDetail: null, + observation, targetId: t.id, timestamp, }; @@ -83,6 +86,31 @@ export class UdpChecker implements CheckerDefinition { 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 = { + 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 { }); 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 = { + 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 { `响应超过 ${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 { 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 { 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 { 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 { 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 { }); 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 { } } -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, diff --git a/src/server/checker/store.ts b/src/server/checker/store.ts index 9cde4ce..8e3ccbb 100644 --- a/src/server/checker/store.ts +++ b/src/server/checker/store.ts @@ -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; 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, ); } diff --git a/src/server/checker/types.ts b/src/server/checker/types.ts index 9be9bf9..b586814 100644 --- a/src/server/checker/types.ts +++ b/src/server/checker/types.ts @@ -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; } diff --git a/src/server/helpers.ts b/src/server/helpers.ts index a7cfadb..913ccf8 100644 --- a/src/server/helpers.ts +++ b/src/server/helpers.ts @@ -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 = null; + if (row.observation) { + try { + observation = JSON.parse(row.observation) as Record; + } 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, }; } diff --git a/src/server/routes/dashboard.ts b/src/server/routes/dashboard.ts index 04633a7..827c8e8 100644 --- a/src/server/routes/dashboard.ts +++ b/src/server/routes/dashboard.ts @@ -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, diff --git a/src/server/routes/history.ts b/src/server/routes/history.ts index 438bc7c..190d5dc 100644 --- a/src/server/routes/history.ts +++ b/src/server/routes/history.ts @@ -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, diff --git a/src/shared/api.ts b/src/shared/api.ts index 5a0a824..f2c98d0 100644 --- a/src/shared/api.ts +++ b/src/shared/api.ts @@ -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; timestamp: string; } diff --git a/src/web/components/OverviewTab.tsx b/src/web/components/OverviewTab.tsx index 9862cc3..0c4fa9c 100644 --- a/src/web/components/OverviewTab.tsx +++ b/src/web/components/OverviewTab.tsx @@ -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 }, ]} /> diff --git a/src/web/constants/history-table-columns.tsx b/src/web/constants/history-table-columns.tsx index 4f1c1f7..1bbe9b0 100644 --- a/src/web/constants/history-table-columns.tsx +++ b/src/web/constants/history-table-columns.tsx @@ -27,10 +27,10 @@ export const HISTORY_COLUMNS: Array> = [ }, { cell: ({ row }: PrimaryTableCellParams) => { - 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: "详情", }, ]; diff --git a/tests/server/app.test.ts b/tests/server/app.test.ts index 3296127..4bc0530 100644 --- a/tests/server/app.test.ts +++ b/tests/server/app.test.ts @@ -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", }); diff --git a/tests/server/checker/engine.test.ts b/tests/server/checker/engine.test.ts index ef59bde..bd8f666 100644 --- a/tests/server/checker/engine.test.ts +++ b/tests/server/checker/engine.test.ts @@ -90,7 +90,7 @@ describe("ProbeEngine", () => { const results = (mockStore as unknown as { _results: Array> })._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)["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> })._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)["statusCode"]).toBe(200); } finally { void httpServer.stop(); } diff --git a/tests/server/checker/runner/cmd/runner.test.ts b/tests/server/checker/runner/cmd/runner.test.ts index d462c10..1996326 100644 --- a/tests/server/checker/runner/cmd/runner.test.ts +++ b/tests/server/checker/runner/cmd/runner.test.ts @@ -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 () => { diff --git a/tests/server/checker/runner/db/execute.test.ts b/tests/server/checker/runner/db/execute.test.ts index 41eea02..98132ff 100644 --- a/tests/server/checker/runner/db/execute.test.ts +++ b/tests/server/checker/runner/db/execute.test.ts @@ -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 () => { diff --git a/tests/server/checker/runner/detail.test.ts b/tests/server/checker/runner/detail.test.ts new file mode 100644 index 0000000..6a9b5a0 --- /dev/null +++ b/tests/server/checker/runner/detail.test.ts @@ -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"); + }); +}); diff --git a/tests/server/checker/runner/http/runner.test.ts b/tests/server/checker/runner/http/runner.test.ts index 49fcaa0..5a0b90c 100644 --- a/tests/server/checker/runner/http/runner.test.ts +++ b/tests/server/checker/runner/http/runner.test.ts @@ -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 () => { diff --git a/tests/server/checker/runner/icmp/execute.test.ts b/tests/server/checker/runner/icmp/execute.test.ts index c7d51aa..26521e1 100644 --- a/tests/server/checker/runner/icmp/execute.test.ts +++ b/tests/server/checker/runner/icmp/execute.test.ts @@ -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 () => { diff --git a/tests/server/checker/runner/llm/execute.test.ts b/tests/server/checker/runner/llm/execute.test.ts index 69105f8..47658d1 100644 --- a/tests/server/checker/runner/llm/execute.test.ts +++ b/tests/server/checker/runner/llm/execute.test.ts @@ -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 () => { diff --git a/tests/server/checker/runner/registry.test.ts b/tests/server/checker/runner/registry.test.ts index 9571252..3e1c8cb 100644 --- a/tests/server/checker/runner/registry.test.ts +++ b/tests/server/checker/runner/registry.test.ts @@ -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({} as unknown as CheckResult), resolve: () => ({}) as unknown as ResolvedTargetBase, diff --git a/tests/server/checker/runner/tcp/execute.test.ts b/tests/server/checker/runner/tcp/execute.test.ts index a178650..a480e35 100644 --- a/tests/server/checker/runner/tcp/execute.test.ts +++ b/tests/server/checker/runner/tcp/execute.test.ts @@ -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 () => { diff --git a/tests/server/checker/runner/udp/execute.test.ts b/tests/server/checker/runner/udp/execute.test.ts index b866abe..86b1540 100644 --- a/tests/server/checker/runner/udp/execute.test.ts +++ b/tests/server/checker/runner/udp/execute.test.ts @@ -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(); } diff --git a/tests/server/checker/store.test.ts b/tests/server/checker/store.test.ts index e2616ea..13c2a70 100644 --- a/tests/server/checker/store.test.ts +++ b/tests/server/checker/store.test.ts @@ -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(), }); diff --git a/tests/server/helpers.test.ts b/tests/server/helpers.test.ts index cd9446f..c55bbdb 100644 --- a/tests/server/helpers.test.ts +++ b/tests/server/helpers.test.ts @@ -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 { + 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(); + }); +}); diff --git a/tests/web/components/OverviewTab.test.tsx b/tests/web/components/OverviewTab.test.tsx index ad2fba3..59ef3ed 100644 --- a/tests/web/components/OverviewTab.test.tsx +++ b/tests/web/components/OverviewTab.test.tsx @@ -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", diff --git a/tests/web/components/TargetDetailDrawer.test.tsx b/tests/web/components/TargetDetailDrawer.test.tsx index 2e5b9b7..0f26d54 100644 --- a/tests/web/components/TargetDetailDrawer.test.tsx +++ b/tests/web/components/TargetDetailDrawer.test.tsx @@ -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", diff --git a/tests/web/components/TargetGroup.test.tsx b/tests/web/components/TargetGroup.test.tsx index 877bbdc..8fe083e 100644 --- a/tests/web/components/TargetGroup.test.tsx +++ b/tests/web/components/TargetGroup.test.tsx @@ -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", diff --git a/tests/web/constants/target-table-columns.test.ts b/tests/web/constants/target-table-columns.test.ts index e7e498a..385369a 100644 --- a/tests/web/constants/target-table-columns.test.ts +++ b/tests/web/constants/target-table-columns.test.ts @@ -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", }, }), diff --git a/tests/web/constants/target-table-sorters.test.ts b/tests/web/constants/target-table-sorters.test.ts index dac66be..3e2e9f0 100644 --- a/tests/web/constants/target-table-sorters.test.ts +++ b/tests/web/constants/target-table-sorters.test.ts @@ -24,10 +24,10 @@ function makeTarget(overrides: Partial = {}): 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); });