1
0

feat: 重构为多类型 checker 通用框架,支持 HTTP 与命令检查

- 引入 typed target 判别联合,支持 http 与 command 两种 checker
- expect 重构为有序规则数组,按配置顺序快速失败并生成结构化 failure
- 新增 command runner,支持 exec + args 本地命令执行
- 引入全局并发限制 maxConcurrentChecks 和 size 解析 (KB/MB/GB)
- HTTP/command 各自独立 expect pipeline,应用领域默认成功语义
- SQLite schema、API、Dashboard 全链路调整为 checker 通用契约
- 补充完整测试覆盖(192 tests),更新 README 与示例配置
This commit is contained in:
2026-05-10 22:25:21 +08:00
parent 599d973cbd
commit b8810f1182
46 changed files with 3562 additions and 1062 deletions

136
README.md
View File

@@ -1,6 +1,6 @@
# Gateway Checker
基于 Bun + TypeScript 的 HTTP 拨测监控工具。通过 YAML 配置文件定义拨测目标,后端按配置定时并发拨测,结果持久化到本地 SQLite前端 Dashboard 展示各目标实时状态、可用率、延迟趋势等。
基于 Bun + TypeScript 的多类型拨测监控工具。通过 YAML 配置文件定义 HTTP 和命令行拨测目标,后端按配置定时并发拨测,结果持久化到本地 SQLite前端 Dashboard 展示各目标实时状态、可用率、耗时趋势等。
## 项目结构
@@ -15,7 +15,12 @@ src/
types.ts 类型定义
config-loader.ts YAML 配置解析与校验
store.ts SQLite 数据存储
fetcher.ts HTTP 拨测执行
http-runner.ts HTTP 拨测执行
command-runner.ts 命令行拨测执行
http-expect.ts HTTP 响应断言
command-expect.ts 命令行输出断言
failure.ts 失败信息类型
size.ts 大小单位解析
engine.ts 调度引擎(按 interval 分组、组内并发)
shared/
api.ts 前后端共享 TypeScript 类型
@@ -52,48 +57,65 @@ bun run dev:web
server:
host: "127.0.0.1"
port: 3000
dataDir: "./data"
dataDir: "/tmp/probes_data"
runtime:
maxConcurrentChecks: 20
defaults:
interval: "30s"
interval: "5s"
timeout: "10s"
method: "GET"
http:
method: GET
maxBodyBytes: "100MB"
command:
maxOutputBytes: "100MB"
targets:
- name: "示例服务"
url: "https://httpbin.org/get"
interval: "60s"
- name: "POST 检查"
url: "https://httpbin.org/post"
method: "POST"
headers:
Content-Type: "application/json"
body: '{"ping": true}'
- name: "Baidu"
type: http
http:
url: "https://www.baidu.com"
expect:
status: [200]
maxLatencyMs: 5000
maxDurationMs: 10000
- name: "JSON API 监控"
- name: "JSON API 示例"
type: http
http:
url: "https://httpbin.org/json"
expect:
status: [200]
headers:
Content-Type: application/json
Content-Type:
contains: "application/json"
body:
contains: "slideshow"
json:
$.slideshow.title: "Sample Slide Show"
- contains: "slideshow"
- json:
path: "$.slideshow.title"
equals: "Sample Slide Show"
- name: "HTML 页面监控"
- name: "HTML 页面示例"
type: http
http:
url: "https://httpbin.org/html"
expect:
status: [200]
body:
css:
"h1": "Herman Melville - Moby-Dick"
xpath:
"/html/body/h1/text()": "Herman Melville - Moby-Dick"
- contains: "Moby-Dick"
- xpath:
path: "/html/body/h1/text()"
equals: "Herman Melville - Moby-Dick"
- name: "Nginx 进程检查"
type: command
command:
exec: "pgrep"
args: ["nginx"]
expect:
exitCode: [0]
stdout:
- match: "\\d+"
```
### 配置说明
@@ -102,40 +124,68 @@ targets:
- `host`: 监听地址,默认 `127.0.0.1`
- `port`: 监听端口,默认 `3000`
- `dataDir`: 数据目录,默认 `./data`
- **runtime**: 运行时配置
- `maxConcurrentChecks`: 最大并发拨测数,默认 `20`
- **defaults**: 全局默认值(均可省略)
- `interval`: 拨测间隔,默认 `30s`
- `timeout`: 请求超时,默认 `10s`
- `timeout`: 超时时间,默认 `10s`
- `http`: HTTP 类型默认值
- `method`: HTTP 方法,默认 `GET`
- `headers`: 全局 headers
- `maxBodyBytes`: 响应体最大字节数,默认 `100MB`
- `command`: Command 类型默认值
- `maxOutputBytes`: 输出最大字节数,默认 `100MB`
- **targets**: 拨测目标列表(必填)
- `name`: 目标名称(必填,唯一)
- `url`: 目标 URL(必填)
- `type`: 目标类型,`http``command`(必填)
- `http`: HTTP 拨测配置type 为 http 时必填)
- `url`: 目标 URL
- `method``headers``body`: 请求参数
- `command`: 命令行拨测配置type 为 command 时必填)
- `exec`: 可执行文件名或路径
- `args`: 命令行参数列表
- `interval``timeout`: 覆盖全局默认值
- `expect`: 期望校验
- `status`: 可接受的状态码列表
- `headers`: 响应头校验(键值对,全部匹配
- `maxLatencyMs`: 最大延迟阈值(毫秒
- `body`: 响应体校验(可组合使用
- `status`: 可接受的状态码列表HTTP
- `exitCode`: 可接受的退出码列表Command
- `headers`: 响应头校验HTTP支持 `equals``contains` 等操作符
- `maxDurationMs`: 最大耗时阈值(毫秒
- `body`: HTTP 响应体校验(数组,可组合使用)
- `contains`: 响应体包含的文本
- `regex`: 响应体匹配的正则表达式
- `json`: JSONPath 提取值比较(路径 → 期望值
- `css`: CSS 选择器提取 HTML 元素比较(选择器 → 期望值,可选 `attr` 提取属性)
- `xpath`: XPath 提取 XML/HTML 节点比较(路径 → 期望值)
- body 比较支持操作符:`equals`(默认)、`contains``match`(正则)、`empty``exists``gte``lte``gt``lt`
- `match`: 响应体匹配的正则表达式
- `json`: JSONPath 提取值比较(`path` + 比较操作符
- `css`: CSS 选择器提取 HTML 元素比较
- `xpath`: XPath 提取 XML/HTML 节点比较
- `stdout` / `stderr`: Command 输出校验(数组,同 body 格式)
- 比较操作符:`equals`(默认)、`contains``match`(正则)、`empty``exists``gte``lte``gt``lt`
大小说明:`maxBodyBytes``maxOutputBytes` 支持单位 `KB``MB``GB`,也可直接使用数字(字节数)。
时长格式支持:`30s``5m``500ms`
## API 端点
| 端点 | 说明 |
| --------------------------------------- | ---------------------------------------------------- |
| --------------------------------------- | ----------------------------------------------------- |
| `GET /health` | 健康检查 |
| `GET /api/summary` | 总览统计total/up/down/avgLatencyMs/lastCheckTime |
| `GET /api/summary` | 总览统计total/up/down/avgDurationMs/lastCheckTime |
| `GET /api/targets` | 目标列表及最新状态和统计摘要 |
| `GET /api/targets/:id/history?limit=20` | 指定目标的最近 N 条拨测记录 |
| `GET /api/targets/:id/trend?hours=24` | 指定目标的按小时聚合趋势 |
### 响应字段
**SummaryResponse**: `total``up``down``avgDurationMs``lastCheckTime`
**TargetStatus**: `id``name``type`http/command`target`URL 或命令摘要)、`interval``latestCheck``stats``sparkline`
**CheckResult**: `timestamp``success``matched``durationMs``statusDetail``failure`
**CheckFailure**: `kind`error/mismatch`phase``path``expected``actual``message`
**TargetStats**: `totalChecks``availability``avgDurationMs``p99DurationMs`
**TrendPoint**: `hour``avgDurationMs``availability``totalChecks`
## 代码质量
```bash
@@ -190,13 +240,13 @@ bun run verify
## 目标状态判定
两层判定模型:
两层判定模型,适用于 HTTP 和 Command 两种类型
- **success**: 请求是否成功完成(收到 HTTP 响应
- **success**: 拨测是否成功完成HTTP 收到响应 / Command 正常退出
- **matched**: 是否符合 expect 规则(无 expect 时默认为 true
- **UP** = success AND matched
- **DOWN** = NOT success OR NOT matched
## 已知限制
当前不做告警通知、数据自动清理、拨测目标动态增删、认证鉴权和分布式部署。
当前不做告警通知、数据自动清理、拨测目标动态增删、认证鉴权和分布式部署。Command 类型拨测不支持 Windows 环境。

View File

@@ -0,0 +1,82 @@
## Purpose
TBD
## Requirements
### Requirement: command target 配置
系统 SHALL 支持 `type: command` 的 target 配置,通过 `command.exec``command.args` 描述本地命令,并使用 command 专用字段配置工作目录、环境变量和输出限制。
#### Scenario: 解析 command target
- **WHEN** YAML 中 target 配置 `type: command``command.exec: "pgrep"``command.args: ["nginx"]`
- **THEN** 系统 SHALL 将其解析为 command checker并保留 exec、args、cwd、env、maxOutputBytes、interval、timeout 和 expect 配置
#### Scenario: command target 缺少 exec
- **WHEN** YAML 中 target 配置 `type: command` 但缺少 `command.exec`
- **THEN** 系统 SHALL 以配置错误退出,并提示该 target 缺少 command.exec 字段
#### Scenario: cwd 相对配置文件目录解析
- **WHEN** command target 配置 `command.cwd: "scripts"` 且配置文件位于 `/opt/checker/probes.yaml`
- **THEN** 系统 SHALL 将 cwd 解析为 `/opt/checker/scripts`
#### Scenario: command 不使用 shell
- **WHEN** command target 配置 `exec``args`
- **THEN** 系统 MUST 直接执行该程序和参数,不通过 shell 解释整段命令字符串
#### Scenario: env 默认继承并允许覆盖
- **WHEN** command target 配置 `command.env: {LANG: "C"}` 且当前进程环境包含 `PATH`
- **THEN** 系统 SHALL 继承当前进程的全部环境变量,并将 `LANG` 覆盖为 `"C"`
#### Scenario: 不支持 stdin
- **WHEN** command target 配置并执行命令
- **THEN** 系统 MUST NOT 向子进程 stdin 写入数据,避免命令因等待输入而阻塞
### Requirement: command checker 执行
系统 SHALL 按 command target 配置执行本地命令记录执行耗时、退出码、stdout 和 stderr并在执行失败时产生结构化错误信息。
#### Scenario: 命令正常退出
- **WHEN** command target 执行的进程正常退出且 exit code 为 0
- **THEN** 系统 SHALL 记录 `success=true``durationMs``statusDetail="exitCode=0"`,并进入 expect 校验
#### Scenario: 命令非零退出
- **WHEN** command target 执行的进程正常退出但 exit code 为 1
- **THEN** 系统 SHALL 记录 `success=true``statusDetail="exitCode=1"`,并由 expect.exitCode 决定 matched 结果
#### Scenario: 命令启动失败
- **WHEN** command target 的 exec 不存在或无法启动
- **THEN** 系统 SHALL 记录 `success=false``matched=false`,并在 failure 中写入 kind=`error`、phase=`exitCode` 和可读错误信息
#### Scenario: 命令超时
- **WHEN** command target 在 timeout 时间内未结束
- **THEN** 系统 MUST 终止该子进程,记录 `success=false``matched=false`,并在 failure 中写入命令超时信息
#### Scenario: 命令输出超限
- **WHEN** command target 的 stdout 和 stderr 合计输出超过 `maxOutputBytes`
- **THEN** 系统 MUST 停止收集输出并终止该检查,记录 `success=false``matched=false`,并在 failure 中写入输出超限信息
### Requirement: command expect 校验
系统 SHALL 支持 command 专用 expect包括 `exitCode``stdout``stderr`,并按 exitCode、duration、stdout、stderr 的阶段顺序快速失败。
#### Scenario: 默认 exitCode 成功语义
- **WHEN** command target 未显式配置 `expect.exitCode`
- **THEN** 系统 SHALL 使用默认 `expect.exitCode: [0]` 进行校验
#### Scenario: 显式 exitCode 校验
- **WHEN** command target 配置 `expect.exitCode: [0, 2]` 且实际 exit code 为 2
- **THEN** 系统 SHALL 判定 exitCode 阶段通过,并继续后续 expect 阶段
#### Scenario: exitCode 不匹配快速失败
- **WHEN** command target 配置 `expect.exitCode: [0]` 且实际 exit code 为 1
- **THEN** 系统 SHALL 立即返回 `matched=false`,并在 failure 中写入 phase=`exitCode`、path=`expect.exitCode`、expected 和 actual
#### Scenario: stdout 按配置顺序校验
- **WHEN** command target 配置 `expect.stdout` 为两个规则,第一条通过且第二条失败
- **THEN** 系统 SHALL 先执行第一条 stdout 规则,再执行第二条,并将 failure.path 指向失败的 `expect.stdout[1]`
#### Scenario: stderr 校验为空
- **WHEN** command target 配置 `expect.stderr: [{empty: true}]` 且实际 stderr 为空字符串
- **THEN** 系统 SHALL 判定 stderr 阶段通过
#### Scenario: stdout 失败后不检查 stderr
- **WHEN** command target 同时配置 stdout 和 stderr 规则,且 stdout 规则失败
- **THEN** 系统 SHALL 快速失败并 MUST NOT 继续执行 stderr 规则

View File

@@ -5,113 +5,124 @@
## Requirements
### Requirement: 响应体多种校验方法
系统 SHALL 支持对 HTTP 响应体进行五种可组合的校验方法contains子串、regex正则、jsonJSONPath、cssCSS 选择器、xpathXPath配置在 `expect.body` 分组下
系统 SHALL 支持对 HTTP 响应体进行五种可组合的校验方法contains子串、regex正则、jsonJSONPath、cssCSS 选择器、xpathXPath。这些方法 MUST 配置在 `expect.body` 有序数组中
#### Scenario: contains 子串匹配
- **WHEN** 目标配置 `expect.body.contains: "healthy"`,且响应体包含 `"healthy"`
- **THEN** 系统 SHALL 判定 matched 为 true
- **WHEN** HTTP target 配置 `expect.body: [{contains: "healthy"}]`,且响应体包含 `"healthy"`
- **THEN** 系统 SHALL 判定该 body 规则通过
#### Scenario: contains 不匹配
- **WHEN** 目标配置 `expect.body.contains: "healthy"`,且响应体不包含该文本
- **THEN** 系统 SHALL 判定 matched 为 false
- **WHEN** HTTP target 配置 `expect.body: [{contains: "healthy"}]`,且响应体不包含该文本
- **THEN** 系统 SHALL 判定 matched 为 false,并记录该规则的 failure.path
#### Scenario: regex 正则匹配
- **WHEN** 目标配置 `expect.body.regex: '"status"\\s*:\\s*"ok"'`,且响应体匹配该正则
- **THEN** 系统 SHALL 判定 matched 为 true
- **WHEN** HTTP target 配置 `expect.body: [{regex: '"status"\\s*:\\s*"ok"'}]`,且响应体匹配该正则
- **THEN** 系统 SHALL 判定该 body 规则通过
#### Scenario: regex 不匹配
- **WHEN** 目标配置 `expect.body.regex: '"status"\\s*:\\s*"ok"'`,且响应体不匹配该正则
- **THEN** 系统 SHALL 判定 matched 为 false
- **WHEN** HTTP target 配置 regex body 规则,且响应体不匹配该正则
- **THEN** 系统 SHALL 判定 matched 为 false,并记录该规则的 failure.path
#### Scenario: json JSONPath 等值匹配
- **WHEN** 目标配置 `expect.body.json: {"$.status": "ok"}`,且响应 JSON 中 `$.status` 值为 `"ok"`
- **THEN** 系统 SHALL 判定 matched 为 true
- **WHEN** HTTP target 配置 `expect.body: [{json: {path: "$.status", equals: "ok"}}]`,且响应 JSON 中 `$.status` 值为 `"ok"`
- **THEN** 系统 SHALL 判定该 body 规则通过
#### Scenario: json JSONPath 值不匹配
- **WHEN** 目标配置 `expect.body.json: {"$.status": "ok"}`,且响应 JSON 中 `$.status` 值为 `"error"`
- **THEN** 系统 SHALL 判定 matched 为 false
- **WHEN** HTTP target 配置 json body 规则,且提取值不符合期望
- **THEN** 系统 SHALL 判定 matched 为 false,并记录包含 JSONPath 的 failure.path
#### Scenario: json 解析失败
- **WHEN** 目标配置了 `expect.body.json` 但响应体不是合法 JSON
- **WHEN** HTTP target 配置了 json body 规则但响应体不是合法 JSON
- **THEN** 系统 SHALL 判定 matched 为 false
#### Scenario: css 选择器匹配
- **WHEN** 目标配置 `expect.body.css: {"div#health": "OK"}`,且 HTML 中存在 `div#health` 元素文本为 `"OK"`
- **THEN** 系统 SHALL 判定 matched 为 true
- **WHEN** HTTP target 配置 `expect.body: [{css: {selector: "div#health", equals: "OK"}}]`,且 HTML 中存在 `div#health` 元素文本为 `"OK"`
- **THEN** 系统 SHALL 判定该 body 规则通过
#### Scenario: css 选择器匹配属性值
- **WHEN** 目标配置 css 规则带 `attr: "content"` 用于提取属性,且属性值匹配期望
- **THEN** 系统 SHALL 判定 matched 为 true
- **WHEN** HTTP target 配置 css 规则带 `attr: "content"` 用于提取属性,且属性值匹配期望
- **THEN** 系统 SHALL 判定该 body 规则通过
#### Scenario: css 选择器无匹配元素
- **WHEN** 目标配置了 css 选择器但 HTML 中无匹配元素
- **WHEN** HTTP target 配置了 css 选择器但 HTML 中无匹配元素
- **THEN** 系统 SHALL 判定 matched 为 false
#### Scenario: xpath 表达式匹配
- **WHEN** 目标配置 `expect.body.xpath: {"/root/status/text()": "ok"}`,且 XML 中 `/root/status` 节点文本为 `"ok"`
- **THEN** 系统 SHALL 判定 matched 为 true
- **WHEN** HTTP target 配置 `expect.body: [{xpath: {path: "/root/status/text()", equals: "ok"}}]`,且 XML 中 `/root/status` 节点文本为 `"ok"`
- **THEN** 系统 SHALL 判定该 body 规则通过
#### Scenario: xpath 表达式无匹配节点
- **WHEN** 目标配置了 xpath 表达式但 XML 中无匹配节点
- **WHEN** HTTP target 配置了 xpath 表达式但 XML 中无匹配节点
- **THEN** 系统 SHALL 判定 matched 为 false
### Requirement: 多种 body 校验方法 AND 组合
系统 SHALL 支持同时配置多种 body 校验方法,所有方法均通过时 matched 方为 true。
系统 SHALL 支持`expect.body` 数组中同时配置多种 body 校验方法,所有方法均通过时 matched 方为 true。
#### Scenario: 多种方法全部通过
- **WHEN** 目标同时配置 `body.contains``body.json``body.regex`,且全部通过
- **WHEN** HTTP target 的 `expect.body` 数组依次配置 contains、json、regex且全部通过
- **THEN** 系统 SHALL 判定 matched 为 true
#### Scenario: 多种方法任一失败
- **WHEN** 目标同时配置 `body.contains``body.json`,且 `body.contains` 不通过
- **THEN** 系统 SHALL 判定 matched 为 false且不再检查 `body.json`
- **WHEN** HTTP target 的 `expect.body` 数组第一条 contains 不通过,后续还有 json 规则
- **THEN** 系统 SHALL 判定 matched 为 false且不再检查后续 json 规则
### Requirement: 操作符系统
系统 SHALL 支持对 body 校验的提取值使用以下操作符进行比较equals默认等值、contains子串包含、match正则匹配、empty空值判断、exists存在性判断、gte/lte/gt/lt数值比较
系统 SHALL 支持对提取值和文本值使用以下操作符进行比较equals默认等值、contains子串包含、match正则匹配、empty空值判断、exists存在性判断、gte/lte/gt/lt数值比较
#### Scenario: 标量值隐式 equals
- **WHEN** jsonPath 配置的期望值为标量(字符串/数字/布尔/null`$.status: ok`
- **THEN** 系统 SHALL 使用 equals 操作符,对提取值做严格相等比较
- **WHEN** 配置的期望值为标量(字符串/数字/布尔/null`equals: "ok"`
- **THEN** 系统 SHALL 使用 equals 操作符,对实际值做严格相等比较
#### Scenario: 显式 contains 操作符
- **WHEN** 配置 `$.message: {contains: "success"}`,且提取值包含 `"success"`
- **THEN** 系统 SHALL 判定 matched 为 true
- **WHEN** 配置 `{contains: "success"}`,且实际值包含 `"success"`
- **THEN** 系统 SHALL 判定该规则通过
#### Scenario: 显式 match 操作符
- **WHEN** 配置 `$.version: {match: '\\d+\\.\\d+\\.\\d+'}`,且提取值匹配该正则
- **THEN** 系统 SHALL 判定 matched 为 true
- **WHEN** 配置 `{match: '\\d+\\.\\d+\\.\\d+'}`,且实际值匹配该正则
- **THEN** 系统 SHALL 判定该规则通过
#### Scenario: empty 操作符判断为空
- **WHEN** 配置 `$.items: {empty: true}`,且提取值为空数组 `[]`
- **THEN** 系统 SHALL 判定 matched 为 true
- **WHEN** 配置 `{empty: true}`,且实际值为空数组 `[]`
- **THEN** 系统 SHALL 判定该规则通过
#### Scenario: empty 操作符判断非空
- **WHEN** 配置 `$.items: {empty: false}`,且提取值为 `[1, 2]`
- **THEN** 系统 SHALL 判定 matched 为 true
- **WHEN** 配置 `{empty: false}`,且实际值为 `[1, 2]`
- **THEN** 系统 SHALL 判定该规则通过
#### Scenario: exists 操作符判断存在
- **WHEN** 配置 `$.error: {exists: false}`,且 JSON 中不存在 `error` 字段
- **THEN** 系统 SHALL 判定 matched 为 true
- **WHEN** 配置 `{exists: false}`,且实际值不存在
- **THEN** 系统 SHALL 判定该规则通过
#### Scenario: gte 数值比较
- **WHEN** 配置 `$.count: {gte: 10}`,且提取值为 `15`(数字)
- **THEN** 系统 SHALL 判定 matched 为 true
- **WHEN** 配置 `{gte: 10}`,且实际值为 `15`(数字)
- **THEN** 系统 SHALL 判定该规则通过
#### Scenario: gt/lt 数值比较
- **WHEN** 配置 `$.latency: {gt: 0, lt: 1000}`,且提取值为 `500`
- **THEN** 系统 SHALL 对同一字段进行多操作符复合比较,全部通过则 matched 为 true
- **WHEN** 配置 `{gt: 0, lt: 1000}`,且实际值为 `500`
- **THEN** 系统 SHALL 对同一字段进行多操作符复合比较,全部通过则该规则通过
### Requirement: 响应头校验
系统 SHALL 支持通过 `expect.headers` 配置对响应头进行键值对校验
系统 SHALL 支持通过 `expect.headers` 配置对 HTTP 响应头进行键值规则校验header 名称匹配 MUST 不区分大小写
#### Scenario: 响应头匹配
- **WHEN** 目标配置 `expect.headers: {"Content-Type": "application/json"}`,且响应包含该 header 且值匹配
- **THEN** 系统 SHALL 判定 matched 为 true
- **WHEN** HTTP target 配置 `expect.headers: {"Content-Type": {contains: "application/json"}}`,且响应包含该 header 且值匹配
- **THEN** 系统 SHALL 判定 headers 阶段通过
#### Scenario: 响应头不匹配
- **WHEN** 目标配置 `expect.headers: {"Content-Type": "application/json"}`,且响应 header 值为 `"text/html"`
- **WHEN** HTTP target 配置 `expect.headers: {"Content-Type": {equals: "application/json"}}`,且响应 header 值为 `"text/html"`
- **THEN** 系统 SHALL 判定 matched 为 false
#### Scenario: 响应头缺失
- **WHEN** 目标配置了某个 header 但响应中不存在该 header
- **WHEN** HTTP target 配置了某个 header 但响应中不存在该 header
- **THEN** 系统 SHALL 判定 matched 为 false
### Requirement: 结构化 expect 失败信息
系统 SHALL 在任一 expect 规则失败时生成结构化 failure用于标识失败阶段、规则路径、期望值、实际值和可读错误信息。
#### Scenario: body 规则失败信息
- **WHEN** HTTP target 的 `expect.body[1].json` 规则失败
- **THEN** failure SHALL 包含 kind=`mismatch`、phase=`body`、path 指向 `expect.body[1]`,并包含 message
#### Scenario: actual 值截断
- **WHEN** 失败规则的实际值超过系统允许记录的摘要长度
- **THEN** 系统 MUST 截断 actual 摘要,而不是持久化完整响应体或命令输出

View File

@@ -9,14 +9,14 @@
#### Scenario: 获取总览统计
- **WHEN** 客户端请求 `GET /api/summary`
- **THEN** 系统 SHALL 返回 JSON 包含 total总目标数、up正常数、down异常数、avgLatencyMs所有目标平均延迟、lastCheckTime最近一次拨测时间)
- **THEN** 系统 SHALL 返回 JSON 包含 total总目标数、up正常数、down异常数、avgDurationMs所有目标平均耗时、lastCheckTime最近一次检查时间)
### Requirement: 目标列表 API
系统 SHALL 提供 `GET /api/targets` 端点,返回所有目标及其最新状态和统计摘要。
系统 SHALL 提供 `GET /api/targets` 端点,返回所有 typed target 及其最新状态和统计摘要。
#### Scenario: 获取目标列表
- **WHEN** 客户端请求 `GET /api/targets`
- **THEN** 系统 SHALL 返回 JSON 数组,每个元素包含目标基本信息、最近一次拨测结果timestamp、success、statusCode、latencyMs、error、matched和统计摘要totalChecks、availability、avgLatencyMs、p99LatencyMs
- **THEN** 系统 SHALL 返回 JSON 数组,每个元素包含目标基本信息id、name、type、target、interval、最近一次检查结果timestamp、success、matched、durationMs、statusDetail、failure和统计摘要totalChecks、availability、avgDurationMs、p99DurationMs
#### Scenario: 目标无历史记录
- **WHEN** 某目标尚未执行过任何拨测
@@ -27,7 +27,7 @@
#### Scenario: 获取最近历史记录
- **WHEN** 客户端请求 `GET /api/targets/1/history?limit=20`
- **THEN** 系统 SHALL 返回最多 20 条拨测记录,按时间倒序排列
- **THEN** 系统 SHALL 返回最多 20 条检查记录,按时间倒序排列,且每条包含 success、matched、durationMs、statusDetail 和 failure
#### Scenario: 使用默认 limit
- **WHEN** 客户端请求 `GET /api/targets/1/history`(未指定 limit
@@ -38,7 +38,7 @@
#### Scenario: 获取 24 小时趋势
- **WHEN** 客户端请求 `GET /api/targets/1/trend?hours=24`
- **THEN** 系统 SHALL 返回按小时分组的聚合数据,每个数据点包含 hour、avgLatencyMs、availability、totalChecks
- **THEN** 系统 SHALL 返回按小时分组的聚合数据,每个数据点包含 hour、avgDurationMs、availability、totalChecks
#### Scenario: 使用默认时间范围
- **WHEN** 客户端请求 `GET /api/targets/1/trend`(未指定 hours
@@ -61,3 +61,14 @@
#### Scenario: 无效的 limit 参数
- **WHEN** 客户端请求 `GET /api/targets/1/history?limit=abc`
- **THEN** 系统 SHALL 返回 400 状态码和错误信息
### Requirement: 失败信息 API 契约
系统 SHALL 通过 API 返回结构化 failure 信息,供 Dashboard 展示和后续排查。
#### Scenario: 返回 expect 不匹配信息
- **WHEN** 最近一次检查结果包含 failure.kind=`mismatch`
- **THEN** `/api/targets``/api/targets/:id/history` SHALL 返回该 failure 的 kind、phase、path、expected、actual、message 字段
#### Scenario: 无失败信息
- **WHEN** 检查结果 success=true 且 matched=true
- **THEN** API SHALL 返回 failure 为 null

View File

@@ -5,19 +5,23 @@
## Requirements
### Requirement: YAML 配置文件格式
系统 SHALL 支持通过 YAML 配置文件定义全部运行参数,包括 server 配置、数据目录、拨测默认值和拨测目标列表
系统 SHALL 支持通过 YAML 配置文件定义全部运行参数,包括 server 配置、runtime 配置、checker 默认值和 typed target 列表。target MUST 使用 `type` 字段声明 checker 类型HTTP 领域字段 MUST 放在 `http` 分组command 领域字段 MUST 放在 `command` 分组
#### Scenario: 完整配置文件解析
- **WHEN** 系统启动并读取包含 server、defaults、targets 的 YAML 配置文件
- **THEN** 系统 SHALL 正确解析所有字段并用于初始化服务
- **WHEN** 系统启动并读取包含 server、runtime、defaults、targets 的 YAML 配置文件
- **THEN** 系统 SHALL 正确解析所有字段并用于初始化服务、调度引擎和对应 checker runner
#### Scenario: 最简配置文件解析
- **WHEN** 系统读取只包含 targets 列表的 YAML 配置文件(省略 server 和 defaults
- **THEN** 系统 SHALL 使用内置默认值填充未指定的字段host=127.0.0.1, port=3000, dir=./data, interval=30s, timeout=10s, method=GET
#### Scenario: 最简 HTTP 配置文件解析
- **WHEN** 系统读取只包含一个 `type: http` target 和 `http.url` 的 YAML 配置文件(省略 server、runtime、defaults 和 expect
- **THEN** 系统 SHALL 使用内置默认值填充未指定的字段host=127.0.0.1, port=3000, dir=./data, interval=30s, timeout=10s, runtime.maxConcurrentChecks=20, http.method=GET, http.maxBodyBytes=100MB
#### Scenario: 最简 command 配置文件解析
- **WHEN** 系统读取只包含一个 `type: command` target 和 `command.exec` 的 YAML 配置文件
- **THEN** 系统 SHALL 使用内置默认值填充未指定的字段interval=30s, timeout=10s, command.cwd 为配置文件所在目录, command.maxOutputBytes=100MB
#### Scenario: per-target 配置覆盖全局默认值
- **WHEN** 某个 target 指定 interval、timeout 或 method
- **THEN** 该 target SHALL 使用其自身的值,不受 defaults 影响
- **WHEN** 某个 target 指定 interval、timeout 或对应领域分组中的默认字段
- **THEN** 该 target SHALL 使用其自身的值,不受 defaults 中对应字段影响
### Requirement: CLI 参数
系统 SHALL 通过单一命令行参数接受 YAML 配置文件路径。
@@ -38,17 +42,59 @@
系统 SHALL 在启动时对 YAML 配置进行完整校验,校验失败时以非零状态退出并输出清晰的错误信息。
#### Scenario: target 缺少必填字段
- **WHEN** YAML 中某个 target 缺少 name 或 url 字段
- **WHEN** YAML 中某个 target 缺少 name 或 type 字段
- **THEN** 系统 SHALL 以错误退出,提示哪个 target 缺少哪个字段
#### Scenario: HTTP target 缺少 url
- **WHEN** YAML 中某个 target 配置 `type: http` 但缺少 `http.url`
- **THEN** 系统 SHALL 以错误退出,提示该 target 缺少 http.url 字段
#### Scenario: command target 缺少 exec
- **WHEN** YAML 中某个 target 配置 `type: command` 但缺少 `command.exec`
- **THEN** 系统 SHALL 以错误退出,提示该 target 缺少 command.exec 字段
#### Scenario: target type 非法
- **WHEN** YAML 中某个 target 的 type 不是 `http``command`
- **THEN** 系统 SHALL 以错误退出,提示不支持的 target type
#### Scenario: target name 重复
- **WHEN** YAML 中存在两个 name 相同的 target
- **THEN** 系统 SHALL 以错误退出,提示重复的 name
#### Scenario: interval 格式非法
- **WHEN** interval 或 timeout 值不是有效的时长格式(如 `30s``5m`
- **WHEN** interval 或 timeout 值不是有效的时长格式(如 `30s``5m``500ms`
- **THEN** 系统 SHALL 以错误退出并提示格式错误
#### Scenario: maxConcurrentChecks 非法
- **WHEN** runtime.maxConcurrentChecks 不是正整数
- **THEN** 系统 SHALL 以错误退出并提示 runtime.maxConcurrentChecks 格式错误
#### Scenario: size 格式非法
- **WHEN** maxBodyBytes 或 maxOutputBytes 值不是有效的 size 格式
- **THEN** 系统 SHALL 以错误退出并提示支持 B、KB、MB、GB 格式
### Requirement: size 配置解析
系统 SHALL 支持使用单位字符串配置读取上限,单位包括 `B``KB``MB``GB`
#### Scenario: 解析 MB
- **WHEN** YAML 中配置 `maxBodyBytes: "100MB"`
- **THEN** 系统 SHALL 将其解析为 104857600 bytes
#### Scenario: 解析 KB
- **WHEN** YAML 中配置 `maxOutputBytes: "512KB"`
- **THEN** 系统 SHALL 将其解析为 524288 bytes
### Requirement: runtime 并发配置
系统 SHALL 支持 `runtime.maxConcurrentChecks` 配置全局最大并发检查数。
#### Scenario: 使用默认并发限制
- **WHEN** YAML 中未配置 runtime.maxConcurrentChecks
- **THEN** 系统 SHALL 使用默认值 20
#### Scenario: 配置并发限制
- **WHEN** YAML 中配置 `runtime.maxConcurrentChecks: 5`
- **THEN** 系统 SHALL 将全局最大并发检查数设置为 5
### Requirement: YAML 配置使用 Bun 内置解析
系统 SHALL 使用 Bun 内置的 `Bun.YAML.parse()` 解析配置文件,不引入外部 YAML 解析库。
@@ -57,21 +103,28 @@
- **THEN** 系统 SHALL 调用 `Bun.YAML.parse()` 将内容解析为配置对象
### Requirement: expect 配置增强
系统 SHALL 支持增强的 expect 配置格式,包括 `headers` 响应头校验和 `body` 分组下的多种校验方法contains、regex、json、css、xpath
系统 SHALL 支持 typed target 的领域专用 expect 配置,包括 HTTP 的 `status``headers``body` 和 command 的 `exitCode``stdout``stderr`。内容类 expect MUST 使用数组表达配置顺序
#### Scenario: 解析增强的 expect 配置
- **WHEN** YAML 配置文件中 target 的 expect 包含 headers、body 组及内部方法
- **THEN** 系统 SHALL 正确解析并存储为 ResolvedTarget 的 expect 字段
#### Scenario: 解析 HTTP expect 配置
- **WHEN** YAML 配置文件中 HTTP target 的 expect 包含 status、headers、body 规则数组及内部方法
- **THEN** 系统 SHALL 正确解析并存储为 HTTP target 的 expect 字段
#### Scenario: 解析仅含 body.contains 的最简配置
- **WHEN** YAML target 配置 `expect.body.contains: "healthy"`
- **THEN** 系统 SHALL 正确解析,功能等价于旧版 `expect.bodyContains`
#### Scenario: 解析 command expect 配置
- **WHEN** YAML 配置文件中 command target expect 包含 exitCode、stdout 和 stderr 规则数组
- **THEN** 系统 SHALL 正确解析并存储为 command target 的 expect 字段
#### Scenario: 解析 body 有序规则数组
- **WHEN** YAML 中 HTTP target 配置 `expect.body` 为 contains、json、regex 三个数组项
- **THEN** 系统 SHALL 保留数组顺序,供执行阶段按配置顺序快速失败
#### Scenario: 不配置 HTTP status
- **WHEN** HTTP target 未配置 `expect.status`
- **THEN** 系统 SHALL 在执行 expect 时使用默认 `status: [200]` 语义
#### Scenario: 不配置 command exitCode
- **WHEN** command target 未配置 `expect.exitCode`
- **THEN** 系统 SHALL 在执行 expect 时使用默认 `exitCode: [0]` 语义
#### Scenario: 不配置 expect
- **WHEN** target 未配置任何 expect 规则
- **THEN** 系统 SHALL 正常处理expect 字段为 undefined
#### Scenario: 旧版 bodyContains 字段不再支持
- **WHEN** YAML 中使用 `expect.bodyContains: "xxx"` 格式
- **THEN** 该字段 SHALL 被忽略(系统仅识别 `expect.body.contains`
- **Migration**: 将配置文件中 `expect.bodyContains: "xxx"` 改为 `expect.body.contains: "xxx"`

View File

@@ -5,22 +5,22 @@
## Requirements
### Requirement: 总览统计卡片
Dashboard SHALL 在页面顶部展示总览统计卡片,包含总目标数、正常数、异常数和平均延迟
Dashboard SHALL 在页面顶部展示总览统计卡片,包含总目标数、正常数、异常数和平均耗时
#### Scenario: 展示统计卡片
- **WHEN** 用户打开 Dashboard 页面
- **THEN** 页面顶部 SHALL 显示 4 个统计卡片:全部目标数、正常目标数、异常目标数、所有目标平均延迟
- **THEN** 页面顶部 SHALL 显示 4 个统计卡片:全部目标数、正常目标数、异常目标数、所有目标平均耗时
#### Scenario: 统计数据自动刷新
- **WHEN** 页面处于打开状态
- **THEN** 统计卡片 SHALL 每 5-10 秒自动刷新数据
### Requirement: 目标列表表格
Dashboard SHALL 展示所有拨测目标的列表表格,包含名称、URL、当前状态、最新延迟和迷你趋势线。
Dashboard SHALL 展示所有 checker target 的列表表格,包含名称、类型、目标摘要、当前状态、最新耗时、最近失败原因和迷你趋势线。
#### Scenario: 展示目标列表
- **WHEN** 用户打开 Dashboard 页面
- **THEN** 页面 SHALL 显示表格,每行包含目标名称、URL、状态指示圆点(UP / DOWN、最新延迟值、迷你 Sparkline 趋势线
- **THEN** 页面 SHALL 显示表格,每行包含目标名称、类型、目标摘要、状态指示圆点UP / DOWN、最新耗时值、最近失败原因摘要、迷你 Sparkline 趋势线
#### Scenario: 状态指示圆点
- **WHEN** 目标最近一次拨测 success=true 且 matched=true
@@ -33,7 +33,7 @@ Dashboard SHALL 支持在目标列表中展开某行,显示该目标的详细
#### Scenario: 展开目标详情
- **WHEN** 用户点击目标列表中的某一行
- **THEN** 该行下方 SHALL 展开详情面板,包含:可用率百分比、平均延迟、P99 延迟、24 小时延迟趋势折线图、最近 5-10 条拨测记录列表
- **THEN** 该行下方 SHALL 展开详情面板,包含:可用率百分比、平均耗时、P99 耗时、24 小时耗时趋势折线图、最近 5-10 条检查记录列表、领域状态详情和失败信息
#### Scenario: 收起目标详情
- **WHEN** 用户再次点击已展开的目标行
@@ -44,22 +44,33 @@ Dashboard SHALL 支持在目标列表中展开某行,显示该目标的详细
- **THEN** 系统 SHALL 此时请求该目标的趋势数据,而非页面加载时预加载所有目标的趋势数据
### Requirement: 历史记录展示
Dashboard SHALL 在目标详情面板中展示最近的拨测记录,包含时间、状态码、延迟和成功/失败标记。
Dashboard SHALL 在目标详情面板中展示最近的检查记录,包含时间、领域状态详情、耗时、成功/失败标记和失败信息
#### Scenario: 展示历史记录
- **WHEN** 用户展开目标详情面板
- **THEN** 面板 SHALL 显示最近拨测记录列表,每条包含时间戳、HTTP 状态码(或错误信息)、延迟毫秒数、成功/失败图标
- **THEN** 面板 SHALL 显示最近检查记录列表,每条包含时间戳、statusDetail如 HTTP 200 或 exitCode=1、耗时毫秒数、UP/DOWN 标记和 failure.message如存在
### Requirement: 趋势图可视化
Dashboard SHALL 使用 recharts 库渲染趋势图,包括目标列表中的迷你 Sparkline 和详情面板中的完整折线图。
#### Scenario: 表格行内迷你趋势线
- **WHEN** 目标列表表格渲染
- **THEN** 每行 SHALL 包含一个基于 recharts 的迷你折线图,展示最近的延迟趋势
- **THEN** 每行 SHALL 包含一个基于 recharts 的迷你折线图,展示最近的耗时趋势
#### Scenario: 详情面板完整趋势图
- **WHEN** 用户展开目标详情面板
- **THEN** 面板 SHALL 展示基于 recharts 的完整折线图X 轴为时间小时Y 轴为平均延迟,并标注可用率
- **THEN** 面板 SHALL 展示基于 recharts 的完整折线图X 轴为时间小时Y 轴为平均耗时,并标注可用率
### Requirement: checker 类型展示
Dashboard SHALL 在列表和详情中明确展示 target 的 checker 类型。
#### Scenario: 展示 HTTP 类型
- **WHEN** 目标 type 为 `http`
- **THEN** Dashboard SHALL 在类型列显示 HTTP并将目标摘要显示为 URL
#### Scenario: 展示 command 类型
- **WHEN** 目标 type 为 `command`
- **THEN** Dashboard SHALL 在类型列显示 Command并将目标摘要显示为命令摘要
### Requirement: 页面加载与错误状态
Dashboard SHALL 正确处理加载状态和 API 错误。

View File

@@ -5,11 +5,11 @@
## Requirements
### Requirement: SQLite 数据库初始化
系统 SHALL 使用 Bun 内置 `bun:sqlite` 模块在配置的数据目录下创建 SQLite 数据库文件,并以 WAL 模式运行。
系统 SHALL 使用 Bun 内置 `bun:sqlite` 模块在配置的数据目录下创建 SQLite 数据库文件,并以 WAL 模式运行。数据库 schema MUST 支持 typed checker target 和结构化检查结果。
#### Scenario: 首次启动创建数据库
- **WHEN** 指定的数据目录下不存在数据库文件
- **THEN** 系统 SHALL 创建数据库文件并初始化 targets 和 check_results 表
- **THEN** 系统 SHALL 创建数据库文件并初始化包含 type、target、config、duration_ms、status_detail、failure 等字段的 targets 和 check_results 表
#### Scenario: 数据目录不存在
- **WHEN** 配置的数据目录路径不存在
@@ -20,22 +20,26 @@
- **THEN** 系统 SHALL 直接打开数据库,不重新建表
### Requirement: targets 表同步
系统 SHALL 在启动时将 YAML 配置中的目标列表同步到 SQLite targets 表。
系统 SHALL 在启动时将 YAML 配置中的目标列表同步到 SQLite targets 表,并持久化 target 类型、展示摘要、领域配置、调度配置和 expect 配置
#### Scenario: 首次同步目标
- **WHEN** 数据库为空且 YAML 中定义了 N 个目标
- **THEN** 系统 SHALL 将所有目标插入 targets 表
- **WHEN** 数据库为空且 YAML 中定义了 N 个 typed target
- **THEN** 系统 SHALL 将所有目标插入 targets 表,包含 name、type、target、config、interval_ms、timeout_ms 和 expect
#### Scenario: 配置变更后重新同步
- **WHEN** YAML 配置发生变更(新增、删除或修改目标)后重启
- **THEN** 系统 SHALL 根据 name 字段匹配:新增的插入、删除的移除、修改的更新
### Requirement: check_results 表追加写入
系统 SHALL 将每次拨测结果追加写入 check_results 表,不更新或删除已有记录。
系统 SHALL 将每次检查结果追加写入 check_results 表,不更新或删除已有记录。
#### Scenario: 写入拨测结果
- **WHEN** 一次拨测完成
- **THEN** 系统 SHALL 插入一条包含 target_id、timestamp、success、status_code、latency_ms、error、matched 的记录
#### Scenario: 写入检查结果
- **WHEN** 一次 checker 执行完成
- **THEN** 系统 SHALL 插入一条包含 target_id、timestamp、success、matched、duration_ms、status_detail、failure 的记录
#### Scenario: 写入结构化失败信息
- **WHEN** checker 执行失败或 expect 不匹配
- **THEN** 系统 SHALL 将首个失败原因序列化写入 failure 字段
### Requirement: 时间范围查询索引
系统 SHALL 在 check_results 表上创建 (target_id, timestamp) 复合索引,加速按目标和时间范围的查询。
@@ -45,16 +49,35 @@
- **THEN** 系统 SHALL 使用索引快速定位,无需全表扫描
### Requirement: 聚合查询支持
数据存储 SHALL 支持按时间段聚合查询,用于计算可用率、平均延迟、P99 延迟等统计指标。
数据存储 SHALL 支持按时间段聚合查询,用于计算可用率、平均耗时、P99 耗时等统计指标。
#### Scenario: 计算目标可用率
- **WHEN** 查询某目标在指定时间范围内的可用率
- **THEN** 系统 SHALL 返回 UP (success=true AND matched=true) 的记录数占总记录数的百分比
#### Scenario: 计算目标平均延迟
- **WHEN** 查询某目标在指定时间范围内的平均延迟
- **THEN** 系统 SHALL 返回 latency_ms 的平均值(仅计算 success=true 的记录)
#### Scenario: 计算目标平均耗时
- **WHEN** 查询某目标在指定时间范围内的平均耗时
- **THEN** 系统 SHALL 返回 duration_ms 的平均值(仅计算 success=true 的记录)
#### Scenario: 按小时聚合趋势数据
- **WHEN** 查询某目标在指定时间范围内的趋势数据
- **THEN** 系统 SHALL 返回按小时分组的聚合数据,包括每小时的平均延迟和可用率
- **THEN** 系统 SHALL 返回按小时分组的聚合数据,包括每小时的平均耗时和可用率
### Requirement: 目标展示摘要持久化
数据存储 SHALL 为每个 target 持久化一个领域无关的展示摘要字段 `target`
#### Scenario: HTTP target 展示摘要
- **WHEN** 同步 HTTP target
- **THEN** targets.target SHALL 存储该 target 的 URL
#### Scenario: command target 展示摘要
- **WHEN** 同步 command target
- **THEN** targets.target SHALL 存储由 exec 和 args 组成的命令摘要
#### Scenario: HTTP target config 序列化
- **WHEN** 同步 HTTP target
- **THEN** targets.config SHALL 存储 JSON包含 url、method、headers、body、maxBodyBytes
#### Scenario: command target config 序列化
- **WHEN** 同步 command target
- **THEN** targets.config SHALL 存储 JSON包含 exec、args、cwd、env、maxOutputBytes

View File

@@ -16,18 +16,22 @@
- **THEN** 系统 SHALL 创建两个独立定时器,分别按各自频率调度
### Requirement: 组内并发拨测
系统 SHALL 在每次调度 tick 时,使用 `Promise.all` 并发执行同组内所有目标的拨测
系统 SHALL 在每次调度 tick 时并发执行同组内目标的检查,但实际同时运行的检查数 MUST 受全局 `runtime.maxConcurrentChecks` 限制
#### Scenario: 同组目标并发执行
- **WHEN** 调度器触发一次 tick该组有 3 个目标
- **THEN** 系统 SHALL 同时发起 3 个 HTTP 请求,而非顺序执行
- **WHEN** 调度器触发一次 tick该组有 3 个目标,且全局并发余量至少为 3
- **THEN** 系统 SHALL 同时执行 3 个 checker,而非顺序执行
#### Scenario: 单个目标失败不影响同组其他目标
- **WHEN** 同组中某个目标的拨测请求超时或失败
- **THEN** 其他目标的拨测 SHALL 正常完成并记录结果
- **WHEN** 同组中某个目标的检查请求超时或失败
- **THEN** 其他目标的检查 SHALL 正常完成并记录结果
#### Scenario: 全局并发限制生效
- **WHEN** 调度器同时触发 10 个目标且 runtime.maxConcurrentChecks 为 3
- **THEN** 系统 MUST 同时最多运行 3 个检查,其余检查等待并发槽位释放
### Requirement: HTTP 拨测执行
系统 SHALL 对每个目标执行 HTTP 请求,支持 GET、POST、PUT、DELETE、PATCH、HEAD 方法,并携带配置的 headers 和 body。
系统 SHALL 对 `type: http`目标执行 HTTP 请求,支持 GET、POST、PUT、DELETE、PATCH、HEAD 方法,并携带 `http.headers``http.body`
#### Scenario: 执行 GET 请求
- **WHEN** 目标配置 method 为 GET
@@ -41,82 +45,97 @@
- **WHEN** 目标配置了 headers如 Authorization
- **THEN** 系统 SHALL 在请求中包含所有配置的 headers
### Requirement: 请求超时控制
系统 SHALL 对每次拨测请求实施超时控制,超时时间使用目标配置的 timeout 值。
#### Scenario: HTTP body 读取上限
- **WHEN** HTTP response body 超过该 target 的 maxBodyBytes
- **THEN** 系统 MUST 停止读取并记录 `success=false``matched=false` 和结构化输出超限错误
#### Scenario: 请求超时
- **WHEN** 拨测请求在 timeout 时间内未收到响应
### Requirement: 请求超时控制
系统 SHALL 对每次 checker 执行实施超时控制,超时时间使用目标配置的 timeout 值。
#### Scenario: HTTP 请求超时
- **WHEN** HTTP 请求在 timeout 时间内未收到响应
- **THEN** 系统 SHALL 中止该请求,记录为失败并标注超时错误
#### Scenario: command 执行超时
- **WHEN** command 进程在 timeout 时间内未退出
- **THEN** 系统 MUST 终止该子进程,记录为失败并标注超时错误
#### Scenario: 请求在超时前完成
- **WHEN** 拨测请求在 timeout 时间内收到响应
- **THEN** 系统 SHALL 正常记录响应结果
- **WHEN** checker 在超时前完成执行
- **THEN** 系统 SHALL 正常记录执行结果并进入 expect 校验
### Requirement: expect 校验
系统 SHALL 在拨测完成后根据目标的 expect 配置校验响应,校验结果记入 check result。
系统 SHALL 在 checker 执行完成后根据目标类型的 expect 配置校验观测结果,校验结果和首个失败原因记入 check result。
#### Scenario: 校验状态码
- **WHEN** 目标配置 `expect.status: [200, 201]`
#### Scenario: HTTP 默认状态码
- **WHEN** HTTP target 未配置 `expect.status`
- **THEN** 系统 SHALL 按默认 `status: [200]` 校验响应状态码
#### Scenario: 校验 HTTP 状态码
- **WHEN** HTTP target 配置了 `expect.status: [200, 201]`
- **THEN** 系统 SHALL 检查响应状态码是否在列表中,将匹配结果记录到 matched 字段
#### Scenario: 校验响应头
- **WHEN** 目标配置了 `expect.headers: {"Content-Type": "application/json"}`
- **THEN** 系统 SHALL 检查响应头是否包含指定键值对,全部匹配时将 matched 设为 true
#### Scenario: 校验 HTTP 响应头
- **WHEN** HTTP target 配置了 `expect.headers: {"Content-Type": {contains: "application/json"}}`
- **THEN** 系统 SHALL 检查响应头是否符合指定规则,全部匹配时继续后续阶段
#### Scenario: 校验响应体包含
- **WHEN** 目标配置了 `expect.body.contains: "healthy"`
- **THEN** 系统 SHALL 检查响应体是否包含该文本,将匹配结果记录到 matched 字段
#### Scenario: 校验 HTTP 响应体
- **WHEN** HTTP target 配置了有序 `expect.body` 规则数组
- **THEN** 系统 SHALL 按数组顺序执行 body 规则,任一失败立即记录 failure 并停止后续规则
#### Scenario: 校验响应体正则
- **WHEN** 目标配置 `expect.body.regex: '"status"\\s*:\\s*"ok"'`
- **THEN** 系统 SHALL 检查响应体是否匹配该正则,将匹配结果记录到 matched 字段
#### Scenario: command 默认 exitCode
- **WHEN** command target 未配置 `expect.exitCode`
- **THEN** 系统 SHALL 按默认 `exitCode: [0]` 校验命令退出码
#### Scenario: 校验 JSON 响应
- **WHEN** 目标配置了 `expect.body.json: {"$.status": "ok"}`
- **THEN** 系统 SHALL 解析 JSON 并检查 JSONPath 对应值是否符合期望,将匹配结果记录到 matched 字段
#### Scenario: 校验 command stdout
- **WHEN** command target 配置了有序 `expect.stdout` 规则数组
- **THEN** 系统 SHALL 按数组顺序执行 stdout 规则,任一失败立即记录 failure 并停止后续规则
#### Scenario: 校验 HTML 响应CSS 选择器)
- **WHEN** 目标配置了 `expect.body.css: {"div#health": "OK"}`
- **THEN** 系统 SHALL 解析 HTML 并用 CSS 选择器提取元素文本进行比较,将匹配结果记录到 matched 字段
#### Scenario: 校验 HTML/XML 响应XPath
- **WHEN** 目标配置了 `expect.body.xpath: {"/root/status/text()": "ok"}`
- **THEN** 系统 SHALL 解析文档并用 XPath 提取节点文本进行比较,将匹配结果记录到 matched 字段
#### Scenario: 校验延迟阈值
- **WHEN** 目标配置了 `expect.maxLatencyMs: 3000`
- **THEN** 系统 SHALL 检查实际延迟是否超过阈值,将匹配结果记录到 matched 字段
#### Scenario: 无 expect 配置
- **WHEN** 目标未配置任何 expect 规则
- **THEN** 系统 SHALL 将 matched 字段设为 true
#### Scenario: 校验耗时阈值
- **WHEN** 目标配置了 `expect.maxDurationMs`
- **THEN** 系统 SHALL 检查实际 durationMs 是否超过阈值,将匹配结果记录到 matched 字段
#### Scenario: 多条 expect 规则
- **WHEN** 目标同时配置了 status、headers、body.contains、body.json 和 maxLatencyMs
- **THEN** 系统 SHALL 所有规则全部通过时 matched 为 true任一不通过则为 false
#### Scenario: 多种 body 方法 AND 组合
- **WHEN** 目标在 body 分组下配置了 contains、json、css 多种方法
- **THEN** 系统 SHALL 按 contains → regex → json → css → xpath 顺序执行,任一失败立即返回 false
- **WHEN** 目标同时配置状态、duration、元数据和内容规则
- **THEN** 系统 SHALL 所有规则全部通过时 matched 为 true任一不通过则为 false 并记录首个失败原因
### Requirement: Body 校验按需解析
系统 SHALL 仅在配置了对应 body 校验方法时才解析响应体为对应格式,避免不必要的解析开销。
系统 SHALL 仅在 HTTP target 配置了 body 校验且 status、duration、headers 阶段均通过时才读取并解析响应体,避免不必要的读取和解析开销。
#### Scenario: status 失败时不读取 body
- **WHEN** HTTP target 的 status 阶段不匹配
- **THEN** 系统 SHALL 立即返回 matched=false且 MUST NOT 读取 response body
#### Scenario: 仅配置 contains 时不解析 JSON
- **WHEN** 目标仅配置 `expect.body.contains` 而未配置 json/css/xpath
- **WHEN** HTTP target 仅配置 body contains 规则而未配置 json/css/xpath 规则
- **THEN** 系统 SHALL 不执行 JSON.parse 或 HTML/XML 解析
#### Scenario: 配置 json 时解析 JSON 失败
- **WHEN** 目标配置了 `expect.body.json` 但响应体不是合法 JSON
- **THEN** 系统 SHALL 判定 matched 为 false
- **WHEN** HTTP target 配置了 body json 规则但响应体不是合法 JSON
- **THEN** 系统 SHALL 判定 matched 为 false,并记录 json 规则对应的 failure.path
### Requirement: 拨测结果记录
系统 SHALL 在每次拨测完成后,将结果写入 SQLite 数据存储,包含 target_id、timestamp、success、status_code、latency_ms、error、matched 字段。
系统 SHALL 在每次 checker 完成后,将结果写入 SQLite 数据存储,包含 target_id、timestamp、success、matched、duration_ms、status_detail、failure 字段。
#### Scenario: 成功拨测结果记录
- **WHEN** 拨测请求成功完成(收到 HTTP 响应)
- **THEN** 系统 SHALL 记录 success=true、status_code、latency_ms、matched
#### Scenario: 成功检查结果记录
- **WHEN** checker 成功执行且 expect 全部匹配
- **THEN** 系统 SHALL 记录 success=true、matched=true、duration_ms、status_detailfailure 为 null
#### Scenario: 失败拨测结果记录
- **WHEN** 拨测请求失败(网络错误、超时等)
- **THEN** 系统 SHALL 记录 success=false、error 信息status_code 和 latency_ms 为 null
#### Scenario: 执行失败结果记录
- **WHEN** checker 执行失败(网络错误、超时、命令启动失败、输出超限等)
- **THEN** 系统 SHALL 记录 success=false、matched=false、failure.kind="error" 和具体错误信息
#### Scenario: expect 不匹配结果记录
- **WHEN** checker 执行成功但 expect 不匹配
- **THEN** 系统 SHALL 记录 success=true、matched=false、failure.kind="mismatch" 和具体不匹配信息
### Requirement: runner 选择
系统 SHALL 根据 target.type 选择对应 runner 执行检查。
#### Scenario: 选择 HTTP runner
- **WHEN** target.type 为 `http`
- **THEN** 系统 SHALL 使用 HTTP runner 执行该目标
#### Scenario: 选择 command runner
- **WHEN** target.type 为 `command`
- **THEN** 系统 SHALL 使用 command runner 执行该目标

View File

@@ -3,34 +3,207 @@ server:
port: 3000
dataDir: "/tmp/probes_data"
runtime:
maxConcurrentChecks: 20
defaults:
interval: "5s"
interval: "30s"
timeout: "10s"
method: "GET"
http:
method: GET
maxBodyBytes: "10MB"
command:
maxOutputBytes: "1MB"
targets:
- name: "Baidu"
# ========== HTTP targets ==========
- name: "Baidu 首页可用"
type: http
http:
url: "https://www.baidu.com"
expect:
status: [200]
maxLatencyMs: 10000
maxDurationMs: 5000
- name: "JSON API 示例"
- name: "JSON API — 完整流水线"
type: http
interval: "1m"
timeout: "15s"
http:
url: "https://httpbin.org/json"
expect:
status: [200]
headers:
Content-Type: application/json
Accept: "application/json"
expect:
headers:
Content-Type:
contains: "application/json"
maxDurationMs: 8000
body:
contains: "slideshow"
json:
$.slideshow.title: "Sample Slide Show"
- json:
path: "$.slideshow.title"
equals: "Sample Slide Show"
- json:
path: "$.slideshow.slides[0].title"
contains: "Wake"
- json:
path: "$.slideshow.slides[0].type"
equals: "all"
- regex: '"title"'
- name: "HTML 页面示例"
- name: "HTML 页面 — CSS 选择器"
type: http
http:
url: "https://httpbin.org/html"
expect:
body:
- css:
selector: "h1"
contains: "Moby-Dick"
- css:
selector: "body"
exists: true
- name: "HTML 页面 — XPath 提取节点文本"
type: http
http:
url: "https://httpbin.org/html"
expect:
body:
- xpath:
path: "/html/body/h1/text()"
contains: "Melville"
- name: "POST 接口测试"
type: http
http:
url: "https://httpbin.org/post"
method: POST
headers:
Content-Type: "application/json"
body: '{"action":"check","version":1}'
expect:
status: [200]
body:
contains: "Moby-Dick"
xpath:
"/html/body/h1/text()": "Herman Melville - Moby-Dick"
- json:
path: "$.json.action"
equals: "check"
- json:
path: "$.json.version"
gte: 1
- name: "请求头验证"
type: http
http:
url: "https://httpbin.org/headers"
headers:
X-Custom-Header: "gateway-checker"
expect:
status: [200]
body:
- json:
path: "$.headers.X-Custom-Header"
equals: "gateway-checker"
- name: "响应头自定义校验"
type: http
http:
url: "https://httpbin.org/response-headers"
headers:
accept: "application/json"
expect:
body:
- json:
path: "$.Content-Type"
equals: "application/json"
- name: "多状态码允许"
type: http
http:
url: "https://httpbin.org/status/200"
expect:
status: [200, 201, 204]
# ========== Command targets ==========
- name: "uname 输出匹配"
type: command
command:
exec: "uname"
args: ["-s"]
expect:
exitCode: [0]
stdout:
- match: "^[A-Z][a-z]+$"
- name: "echo 自定义文本输出"
type: command
command:
exec: "echo"
args: ["check ok"]
expect:
stdout:
- equals: "check ok\n"
maxDurationMs: 3000
- name: "ls 目录无 stderr"
type: command
command:
exec: "ls"
args: ["/tmp"]
cwd: "/"
expect:
exitCode: [0]
stderr:
- empty: true
- name: "date 输出包含年份"
type: command
command:
exec: "date"
args: ["+%Y"]
expect:
stdout:
- match: "^20\\d{2}\n?$"
- name: "wc 行数计数"
type: command
command:
exec: "wc"
args: ["-l"]
cwd: "/etc"
env:
LANG: "C"
expect:
stdout:
- match: "\\d+"
- name: "hostname 非空输出"
type: command
command:
exec: "hostname"
expect:
stdout:
- match: ".+"
- name: "多规则 stdout 顺序校验"
type: command
interval: "5m"
command:
exec: "echo"
args: ["version: 2.0.1, status: healthy"]
expect:
stdout:
- contains: "version:"
- match: "\\d+\\.\\d+\\.\\d+"
- contains: "healthy"
- name: "stderr 内容检查"
type: command
command:
exec: "ls"
args: ["/nonexistent-path-checker-test"]
expect:
exitCode: [0, 1, 2]
stderr:
- contains: "No such file"

View File

@@ -1,4 +1,4 @@
import { readdir, rm } from "node:fs/promises";
import { rm } from "node:fs/promises";
import { resolve } from "node:path";
const root = resolve(import.meta.dir, "..");
@@ -9,7 +9,7 @@ const patterns: Array<{ glob: string; desc: string }> = [
];
for (const { glob, desc } of patterns) {
const entries = await Array.fromAsync(new Bun.Glob(glob).scan({ root, dot: true }));
const entries = await Array.fromAsync(new Bun.Glob(glob).scan({ cwd: root, dot: true }));
if (entries.length === 0) continue;
for (const entry of entries) {
const full = resolve(root, entry);

View File

@@ -16,6 +16,8 @@ writeFileSync(
configPath,
`targets:
- name: "httpbin"
type: http
http:
url: "https://httpbin.org/get"
interval: "5m"
timeout: "15s"

View File

@@ -1,5 +1,6 @@
import type {
ApiErrorResponse,
CheckFailure,
CheckResult,
HealthResponse,
RuntimeMode,
@@ -136,7 +137,7 @@ function handleTrend(idStr: string, url: URL, method: string, store: ProbeStore,
const trend: TrendPoint[] = store.getTrend(id, hours).map((row) => ({
hour: row.hour,
avgLatencyMs: row.avgLatencyMs,
avgDurationMs: row.avgDurationMs,
availability: Math.round(row.availability * 100) / 100,
totalChecks: row.totalChecks,
}));
@@ -150,7 +151,7 @@ function createSummaryResponse(store: ProbeStore): SummaryResponse {
total: summary.total,
up: summary.up,
down: summary.down,
avgLatencyMs: summary.avgLatencyMs,
avgDurationMs: summary.avgDurationMs,
lastCheckTime: summary.lastCheckTime,
};
}
@@ -165,29 +166,34 @@ function createTargetsResponse(store: ProbeStore): TargetStatus[] {
return {
id: target.id,
name: target.name,
url: target.url,
method: target.method,
type: target.type,
target: target.target,
interval: formatDuration(target.interval_ms),
latestCheck: latest ? mapCheckResult(latest) : null,
sparkline: store.getSparkline(target.id),
stats: {
totalChecks: stats.totalChecks,
availability: stats.availability,
avgLatencyMs: stats.avgLatencyMs,
p99LatencyMs: stats.p99LatencyMs,
avgDurationMs: stats.avgDurationMs,
p99DurationMs: stats.p99DurationMs,
},
};
});
}
function mapCheckResult(row: StoredCheckResult): CheckResult {
let failure: CheckFailure | null = null;
if (row.failure) {
failure = JSON.parse(row.failure) as CheckFailure;
}
return {
timestamp: row.timestamp,
success: row.success === 1,
statusCode: row.status_code,
latencyMs: row.latency_ms,
error: row.error,
matched: row.matched === 1,
durationMs: row.duration_ms,
statusDetail: row.status_detail,
failure,
};
}

View File

@@ -1,188 +0,0 @@
import type { BodyExpectConfig, CssExpect, ExpectOperator, ExpectValue } from "./types";
import * as cheerio from "cheerio";
import * as xpath from "xpath";
import { DOMParser } from "@xmldom/xmldom";
const isObject = (v: unknown): v is Record<string, unknown> => v !== null && typeof v === "object" && !Array.isArray(v);
export function evaluateJsonPath(json: unknown, path: string): unknown {
if (!path.startsWith("$.")) return undefined;
const segments = path.slice(2).split(".");
let current: unknown = json;
for (const seg of segments) {
const bracketMatch = /^(.+?)\[(\d+)\]$/.exec(seg);
if (bracketMatch) {
current = (current as Record<string, unknown>)?.[bracketMatch[1]!];
const idx = parseInt(bracketMatch[2]!, 10);
if (!Array.isArray(current) || idx >= current.length) return undefined;
current = current[idx];
} else {
if (current === null || current === undefined) return undefined;
current = (current as Record<string, unknown>)[seg];
}
}
return current;
}
export function applyOperator(actual: unknown, op: ExpectOperator): boolean {
for (const [key, expected] of Object.entries(op)) {
if (expected === undefined) continue;
switch (key) {
case "equals":
if (actual !== expected) return false;
break;
case "contains":
if (!String(actual).includes(expected as string)) return false;
break;
case "match":
if (!new RegExp(expected as string).test(String(actual))) return false;
break;
case "empty": {
const isEmpty =
actual === null ||
actual === undefined ||
actual === "" ||
(Array.isArray(actual) && actual.length === 0) ||
(typeof actual === "object" && Object.keys(actual as object).length === 0);
if (expected !== isEmpty) return false;
break;
}
case "exists":
if (expected) {
if (actual === undefined) return false;
} else {
if (actual !== undefined) return false;
}
break;
case "gte":
if (!(Number(actual) >= (expected as number))) return false;
break;
case "lte":
if (!(Number(actual) <= (expected as number))) return false;
break;
case "gt":
if (!(Number(actual) > (expected as number))) return false;
break;
case "lt":
if (!(Number(actual) < (expected as number))) return false;
break;
}
}
return true;
}
function checkExpectValue(actual: unknown, expected: ExpectValue): boolean {
if (isObject(expected)) {
return applyOperator(actual, expected as ExpectOperator);
}
return applyOperator(actual, { equals: expected as string | number | boolean | null });
}
function checkBodyContains(body: string, contains: string): boolean {
return body.includes(contains);
}
function checkBodyRegex(body: string, regex: string): boolean {
return new RegExp(regex).test(body);
}
function checkBodyJson(body: string, rules: Record<string, ExpectValue>): boolean {
let json: unknown;
try {
json = JSON.parse(body);
} catch {
return false;
}
for (const [path, expected] of Object.entries(rules)) {
const actual = evaluateJsonPath(json, path);
if (!checkExpectValue(actual, expected)) return false;
}
return true;
}
function checkBodyCss(body: string, rules: Record<string, CssExpect>): boolean {
let $: cheerio.CheerioAPI;
try {
$ = cheerio.load(body);
} catch {
return false;
}
for (const [selector, expected] of Object.entries(rules)) {
if (!checkCssRule($, selector, expected)) return false;
}
return true;
}
function checkCssRule($: cheerio.CheerioAPI, selector: string, expected: CssExpect): boolean {
if (!isObject(expected)) {
const el = $(selector);
return el.length > 0 && el.text() === String(expected);
}
const rule = expected as ExpectOperator & { attr?: string };
const { attr, ...operators } = rule;
const opKeys = Object.keys(operators);
if (opKeys.length === 0) {
if (attr !== undefined) {
return $(selector).attr(attr) !== undefined;
}
return $(selector).length > 0;
}
if (operators.exists === true) {
return $(selector).length > 0;
}
if (operators.exists === false) {
return $(selector).length === 0;
}
const el = $(selector);
if (el.length === 0) return false;
const actual = attr ? el.attr(attr) : el.text();
return applyOperator(actual ?? "", operators);
}
function checkBodyXpath(body: string, rules: Record<string, ExpectValue>): boolean {
let doc: ReturnType<DOMParser["parseFromString"]>;
try {
doc = new DOMParser().parseFromString(body, "text/xml");
} catch {
return false;
}
for (const [path, expected] of Object.entries(rules)) {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const nodes = xpath.select(path, doc as any);
if (!nodes || !Array.isArray(nodes) || nodes.length === 0) return false;
const node = nodes[0]!;
const actual = node.nodeValue ?? (node as unknown as Element).textContent ?? "";
if (!checkExpectValue(actual, expected)) return false;
}
return true;
}
export function checkBodyExpect(body: string, config?: BodyExpectConfig): boolean {
if (!config) return true;
if (config.contains !== undefined && !checkBodyContains(body, config.contains)) return false;
if (config.regex !== undefined && !checkBodyRegex(body, config.regex)) return false;
if (config.json !== undefined && !checkBodyJson(body, config.json)) return false;
if (config.css !== undefined && !checkBodyCss(body, config.css)) return false;
if (config.xpath !== undefined && !checkBodyXpath(body, config.xpath)) return false;
return true;
}

View File

@@ -0,0 +1,155 @@
import type { CheckResult, ResolvedCommandTarget } from "./types";
import { checkCommandExpect } from "./expect/command";
import { errorFailure } from "./expect/failure";
async function readOutput(
stdout: ReadableStream<Uint8Array>,
stderr: ReadableStream<Uint8Array>,
kill: () => void,
maxBytes: number,
): Promise<{ stdout: string; stderr: string; exceeded: boolean }> {
let totalBytes = 0;
let exceeded = false;
let killed = false;
async function readStream(stream: ReadableStream<Uint8Array>): Promise<string> {
const reader = stream.getReader();
const decoder = new TextDecoder();
let text = "";
try {
while (true) {
const { done, value } = await reader.read();
if (done) break;
totalBytes += value.byteLength;
text += decoder.decode(value, { stream: true });
if (totalBytes > maxBytes && !killed) {
exceeded = true;
killed = true;
try {
kill();
} catch {
/* best-effort kill */
}
}
}
} catch {
/* stream already closed */
} finally {
try {
reader.releaseLock();
} catch {
/* already released */
}
}
return text;
}
const [out, err] = await Promise.all([readStream(stdout), readStream(stderr)]);
return { stdout: out, stderr: err, exceeded };
}
export async function runCommandCheck(target: ResolvedCommandTarget): Promise<CheckResult> {
const timestamp = new Date().toISOString();
const start = performance.now();
let proc: ReturnType<typeof Bun.spawn>;
try {
proc = Bun.spawn([target.command.exec, ...target.command.args], {
cwd: target.command.cwd,
env: target.command.env,
stdout: "pipe",
stderr: "pipe",
});
} catch (error) {
const durationMs = Math.round(performance.now() - start);
return {
targetName: target.name,
timestamp,
success: false,
matched: false,
durationMs,
statusDetail: null,
failure: errorFailure("exitCode", "spawn", error instanceof Error ? error.message : String(error)),
};
}
let timedOut = false;
const timeoutId = setTimeout(() => {
timedOut = true;
try {
proc.kill();
} catch {
/* best-effort kill */
}
}, target.timeoutMs);
let outputResult: { stdout: string; stderr: string; exceeded: boolean };
try {
outputResult = await readOutput(
proc.stdout as ReadableStream<Uint8Array>,
proc.stderr as ReadableStream<Uint8Array>,
() => proc.kill(),
target.command.maxOutputBytes,
);
} catch {
clearTimeout(timeoutId);
const durationMs = Math.round(performance.now() - start);
return {
targetName: target.name,
timestamp,
success: false,
matched: false,
durationMs,
statusDetail: null,
failure: errorFailure("exitCode", "execution", "输出读取失败"),
};
}
await proc.exited;
clearTimeout(timeoutId);
const durationMs = Math.round(performance.now() - start);
const exitCode = proc.exitCode ?? 1;
if (outputResult.exceeded) {
return {
targetName: target.name,
timestamp,
success: false,
matched: false,
durationMs,
statusDetail: `exitCode=${exitCode}`,
failure: errorFailure("exitCode", "output", `输出超过限制 ${target.command.maxOutputBytes} 字节`),
};
}
if (timedOut) {
return {
targetName: target.name,
timestamp,
success: false,
matched: false,
durationMs,
statusDetail: null,
failure: errorFailure("exitCode", "timeout", `命令执行超时 (${target.timeoutMs}ms)`),
};
}
const obs = { exitCode, stdout: outputResult.stdout, stderr: outputResult.stderr, durationMs };
const expectResult = checkCommandExpect(obs, target.expect);
return {
targetName: target.name,
timestamp,
success: expectResult.matched,
matched: expectResult.matched,
durationMs,
statusDetail: `exitCode=${exitCode}`,
failure: expectResult.failure,
};
}

View File

@@ -1,16 +1,39 @@
import type { ProbeConfig, ResolvedTarget } from "./types";
import type {
CommandDefaultsConfig,
CommandTargetConfig,
DefaultsConfig,
HttpDefaultsConfig,
HttpExpectConfig,
HttpTargetConfig,
ProbeConfig,
ResolvedCommandTarget,
ResolvedHttpTarget,
ResolvedTarget,
RuntimeConfig,
TargetConfig,
TargetType,
} from "./types";
import { parseSize } from "./size";
import { resolve } from "node:path";
import { dirname } from "node:path";
const DEFAULT_HOST = "127.0.0.1";
const DEFAULT_PORT = 3000;
const DEFAULT_DATA_DIR = "./data";
const DEFAULT_INTERVAL = "30s";
const DEFAULT_TIMEOUT = "10s";
const DEFAULT_METHOD = "GET";
const DEFAULT_HTTP_METHOD = "GET";
const DEFAULT_MAX_BODY_BYTES = "100MB";
const DEFAULT_MAX_OUTPUT_BYTES = "100MB";
const DEFAULT_MAX_CONCURRENT_CHECKS = 20;
const SUPPORTED_TYPES: TargetType[] = ["http", "command"];
export interface ResolvedConfig {
host: string;
port: number;
dataDir: string;
configDir: string;
maxConcurrentChecks: number;
targets: ResolvedTarget[];
}
@@ -30,7 +53,9 @@ export async function loadConfig(configPath: string): Promise<ResolvedConfig> {
validateConfig(raw);
const configDir = dirname(resolve(configPath));
const server = raw.server ?? {};
const runtime = raw.runtime ?? {};
const defaults = raw.defaults ?? {};
const host = server.host ?? DEFAULT_HOST;
@@ -41,23 +66,102 @@ export async function loadConfig(configPath: string): Promise<ResolvedConfig> {
throw new Error(`无效端口号: ${port},需要 0-65535 之间的整数`);
}
const maxConcurrentChecks = validateRuntime(runtime);
const defaultIntervalMs = parseDuration(defaults.interval ?? DEFAULT_INTERVAL);
const defaultTimeoutMs = parseDuration(defaults.timeout ?? DEFAULT_TIMEOUT);
const defaultMethod = defaults.method ?? DEFAULT_METHOD;
const defaultHeaders = defaults.headers ?? {};
const targets: ResolvedTarget[] = raw.targets.map((target) => ({
const targets: ResolvedTarget[] = raw.targets.map((target) =>
resolveTarget(target, defaults, defaultIntervalMs, defaultTimeoutMs, configDir),
);
return { host, port, dataDir, configDir, maxConcurrentChecks, targets };
}
function validateRuntime(runtime: RuntimeConfig): number {
if (runtime.maxConcurrentChecks === undefined) return DEFAULT_MAX_CONCURRENT_CHECKS;
if (
typeof runtime.maxConcurrentChecks !== "number" ||
!Number.isInteger(runtime.maxConcurrentChecks) ||
runtime.maxConcurrentChecks <= 0
) {
throw new Error("runtime.maxConcurrentChecks 必须为正整数");
}
return runtime.maxConcurrentChecks;
}
function resolveTarget(
target: TargetConfig,
defaults: DefaultsConfig,
defaultIntervalMs: number,
defaultTimeoutMs: number,
configDir: string,
): ResolvedTarget {
const intervalMs = parseDuration(target.interval ?? defaults.interval ?? DEFAULT_INTERVAL);
const timeoutMs = parseDuration(target.timeout ?? defaults.timeout ?? DEFAULT_TIMEOUT);
if (target.type === "http") {
return resolveHttpTarget(target, defaults.http, intervalMs, timeoutMs);
}
return resolveCommandTarget(target, defaults.command, intervalMs, timeoutMs, configDir);
}
function resolveHttpTarget(
target: TargetConfig & { type: "http"; http: HttpTargetConfig },
httpDefaults: HttpDefaultsConfig | undefined,
intervalMs: number,
timeoutMs: number,
): ResolvedHttpTarget {
const maxBodyBytes = parseSize(target.http.maxBodyBytes ?? httpDefaults?.maxBodyBytes ?? DEFAULT_MAX_BODY_BYTES);
return {
type: "http",
name: target.name,
url: target.url,
method: target.method ?? defaultMethod,
headers: { ...defaultHeaders, ...(target.headers ?? {}) },
body: target.body,
intervalMs: parseDuration(target.interval ?? defaults.interval ?? DEFAULT_INTERVAL),
timeoutMs: parseDuration(target.timeout ?? defaults.timeout ?? DEFAULT_TIMEOUT),
expect: target.expect,
}));
http: {
url: target.http.url,
method: target.http.method ?? httpDefaults?.method ?? DEFAULT_HTTP_METHOD,
headers: { ...(httpDefaults?.headers ?? {}), ...(target.http.headers ?? {}) },
body: target.http.body,
maxBodyBytes,
},
intervalMs,
timeoutMs,
expect: target.expect as HttpExpectConfig | undefined,
};
}
return { host, port, dataDir, targets };
function resolveCommandTarget(
target: TargetConfig & { type: "command"; command: CommandTargetConfig },
commandDefaults: CommandDefaultsConfig | undefined,
intervalMs: number,
timeoutMs: number,
configDir: string,
): ResolvedCommandTarget {
const cwd = target.command.cwd ?? commandDefaults?.cwd ?? ".";
const resolvedCwd = resolve(configDir, cwd);
const maxOutputBytes = parseSize(
target.command.maxOutputBytes ?? commandDefaults?.maxOutputBytes ?? DEFAULT_MAX_OUTPUT_BYTES,
);
const env = { ...process.env, ...(target.command.env ?? {}) } as Record<string, string>;
return {
type: "command",
name: target.name,
command: {
exec: target.command.exec,
args: target.command.args ?? [],
cwd: resolvedCwd,
env,
maxOutputBytes,
},
intervalMs,
timeoutMs,
expect: target.expect as import("./types").CommandExpectConfig | undefined,
};
}
function validateConfig(config: ProbeConfig): void {
@@ -68,21 +172,41 @@ function validateConfig(config: ProbeConfig): void {
const names = new Set<string>();
for (let i = 0; i < config.targets.length; i++) {
const target = config.targets[i]!;
const raw = config.targets[i] as unknown as Record<string, unknown>;
if (!target.name || typeof target.name !== "string" || target.name.trim() === "") {
const name = raw["name"];
if (!name || typeof name !== "string" || (name as string).trim() === "") {
throw new Error(`${i + 1} 个 target 缺少 name 字段`);
}
if (!target.url || typeof target.url !== "string" || target.url.trim() === "") {
throw new Error(`target "${target.name}" 缺少 url 字段`);
const type = raw["type"];
if (!type || typeof type !== "string") {
throw new Error(`target "${name}" 缺少 type 字段`);
}
if (names.has(target.name)) {
throw new Error(`target name 重复: "${target.name}"`);
if (!SUPPORTED_TYPES.includes(type as TargetType)) {
throw new Error(`target "${name}" 使用不支持的 type: "${type}",支持: ${SUPPORTED_TYPES.join(", ")}`);
}
names.add(target.name);
if (type === "http") {
const http = raw["http"] as Record<string, unknown> | undefined;
if (!http?.["url"] || typeof http["url"] !== "string" || (http["url"] as string).trim() === "") {
throw new Error(`target "${name}" 缺少 http.url 字段`);
}
}
if (type === "command") {
const cmd = raw["command"] as Record<string, unknown> | undefined;
if (!cmd?.["exec"] || typeof cmd["exec"] !== "string" || (cmd["exec"] as string).trim() === "") {
throw new Error(`target "${name}" 缺少 command.exec 字段`);
}
}
if (names.has(name as string)) {
throw new Error(`target name 重复: "${name}"`);
}
names.add(name as string);
}
}
@@ -90,7 +214,6 @@ const DURATION_REGEX = /^(\d+(?:\.\d+)?)(ms|s|m)$/;
export function parseDuration(value: string): number {
const match = DURATION_REGEX.exec(value);
if (!match) {
throw new Error(`无效的时长格式: "${value}",支持格式如 "30s"、"5m"、"500ms"`);
}

View File

@@ -1,15 +1,21 @@
import type { CheckResult, ResolvedTarget } from "./types";
import type { ProbeStore } from "./store";
import { fetchTarget } from "./fetcher";
import { runHttpCheck } from "./fetcher";
import { runCommandCheck } from "./command-runner";
export class ProbeEngine {
private timers: ReturnType<typeof setInterval>[] = [];
private store: ProbeStore;
private targets: ResolvedTarget[];
private targetNameToId: Map<string, number> = new Map();
private maxConcurrentChecks: number;
private running = 0;
private queue: Array<() => void> = [];
constructor(store: ProbeStore, targets: ResolvedTarget[]) {
constructor(store: ProbeStore, targets: ResolvedTarget[], maxConcurrentChecks?: number) {
this.store = store;
this.targets = targets;
this.maxConcurrentChecks = maxConcurrentChecks ?? 10;
this.refreshCache();
}
@@ -46,8 +52,36 @@ export class ProbeEngine {
return groups;
}
private async acquire(): Promise<void> {
if (this.running < this.maxConcurrentChecks) {
this.running++;
return;
}
return new Promise<void>((resolve) => {
this.queue.push(resolve);
});
}
private release(): void {
const next = this.queue.shift();
if (next) {
next();
} else {
this.running--;
}
}
private async probeGroup(targets: ResolvedTarget[]): Promise<void> {
const results = await Promise.allSettled(targets.map((t) => this.probeOne(t)));
const results = await Promise.allSettled(
targets.map(async (target) => {
await this.acquire();
try {
return await this.runCheck(target);
} finally {
this.release();
}
}),
);
for (const result of results) {
if (result.status === "fulfilled") {
@@ -56,23 +90,27 @@ export class ProbeEngine {
}
}
private async probeOne(target: ResolvedTarget): Promise<CheckResult> {
return fetchTarget(target);
private async runCheck(target: ResolvedTarget): Promise<CheckResult> {
switch (target.type) {
case "http":
return runHttpCheck(target);
case "command":
return runCommandCheck(target);
}
}
private writeResult(result: CheckResult): void {
const targetId = this.targetNameToId.get(result.targetName);
if (!targetId) return;
this.store.insertCheckResult({
targetId,
timestamp: result.timestamp,
success: result.success,
statusCode: result.statusCode,
latencyMs: result.latencyMs,
error: result.error,
matched: result.matched,
durationMs: result.durationMs,
statusDetail: result.statusDetail,
failure: result.failure,
});
}
@@ -82,6 +120,4 @@ export class ProbeEngine {
this.targetNameToId.set(target.name, target.id);
}
}
private targets: ResolvedTarget[];
}

View File

@@ -0,0 +1,302 @@
import type { BodyRule, CheckFailure, CssRule, ExpectOperator, ExpectValue, JsonRule, XpathRule } from "../types";
import * as cheerio from "cheerio";
import * as xpath from "xpath";
import { DOMParser } from "@xmldom/xmldom";
import { mismatchFailure, errorFailure } from "./failure";
const isObject = (v: unknown): v is Record<string, unknown> => v !== null && typeof v === "object" && !Array.isArray(v);
export function evaluateJsonPath(json: unknown, path: string): unknown {
if (!path.startsWith("$.")) return undefined;
const segments = path.slice(2).split(".");
let current: unknown = json;
for (const seg of segments) {
const bracketMatch = /^(.+?)\[(\d+)\]$/.exec(seg);
if (bracketMatch) {
current = (current as Record<string, unknown>)?.[bracketMatch[1]!];
const idx = parseInt(bracketMatch[2]!, 10);
if (!Array.isArray(current) || idx >= current.length) return undefined;
current = current[idx];
} else {
if (current === null || current === undefined) return undefined;
current = (current as Record<string, unknown>)[seg];
}
}
return current;
}
export function applyOperator(actual: unknown, op: ExpectOperator): boolean {
for (const [key, expected] of Object.entries(op)) {
if (expected === undefined) continue;
switch (key) {
case "equals":
if (actual !== expected) return false;
break;
case "contains":
if (!String(actual).includes(expected as string)) return false;
break;
case "match":
if (!new RegExp(expected as string).test(String(actual))) return false;
break;
case "empty": {
const isEmpty =
actual === null ||
actual === undefined ||
actual === "" ||
(Array.isArray(actual) && actual.length === 0) ||
(typeof actual === "object" && Object.keys(actual as object).length === 0);
if (expected !== isEmpty) return false;
break;
}
case "exists":
if (expected) {
if (actual === undefined) return false;
} else {
if (actual !== undefined) return false;
}
break;
case "gte":
if (!(Number(actual) >= (expected as number))) return false;
break;
case "lte":
if (!(Number(actual) <= (expected as number))) return false;
break;
case "gt":
if (!(Number(actual) > (expected as number))) return false;
break;
case "lt":
if (!(Number(actual) < (expected as number))) return false;
break;
}
}
return true;
}
export function checkExpectValue(actual: unknown, expected: ExpectValue): boolean {
if (isObject(expected)) {
return applyOperator(actual, expected as ExpectOperator);
}
return applyOperator(actual, { equals: expected as string | number | boolean | null });
}
function checkJsonRule(
body: string,
rule: JsonRule,
rulePath: string,
): { matched: boolean; failure: CheckFailure | null } {
const { path, ...operators } = rule;
const fullPath = `${rulePath}.json(${path})`;
let json: unknown;
try {
json = JSON.parse(body);
} catch {
return {
matched: false,
failure: errorFailure("body", fullPath, "body is not valid JSON"),
};
}
const actual = evaluateJsonPath(json, path);
const opKeys = Object.keys(operators);
if (opKeys.length === 0) {
if (actual === undefined) {
return {
matched: false,
failure: mismatchFailure("body", fullPath, "defined", actual, `path ${path} is undefined`),
};
}
return { matched: true, failure: null };
}
const matched = applyOperator(actual, operators);
if (!matched) {
return {
matched: false,
failure: mismatchFailure("body", fullPath, operators, actual, `json path ${path} mismatch`),
};
}
return { matched: true, failure: null };
}
function checkCssRule(
body: string,
rule: CssRule,
rulePath: string,
): { matched: boolean; failure: CheckFailure | null } {
const { selector, attr, ...operators } = rule;
const fullPath = `${rulePath}.css(${selector}${attr ? `@${attr}` : ""})`;
let $: cheerio.CheerioAPI;
try {
$ = cheerio.load(body);
} catch {
return {
matched: false,
failure: errorFailure("body", fullPath, "failed to parse HTML"),
};
}
const el = $(selector);
const opKeys = Object.keys(operators);
if (opKeys.length === 0) {
if (attr !== undefined) {
if (el.attr(attr) === undefined) {
return {
matched: false,
failure: mismatchFailure("body", fullPath, "attr present", undefined, `attribute ${attr} not found`),
};
}
return { matched: true, failure: null };
}
if (el.length === 0) {
return {
matched: false,
failure: mismatchFailure("body", fullPath, "element found", "no match", `selector ${selector} not found`),
};
}
return { matched: true, failure: null };
}
if (operators.exists === true) {
if (el.length === 0) {
return {
matched: false,
failure: mismatchFailure("body", fullPath, true, false, `selector ${selector} not found`),
};
}
return { matched: true, failure: null };
}
if (operators.exists === false) {
if (el.length > 0) {
return {
matched: false,
failure: mismatchFailure("body", fullPath, false, true, `selector ${selector} exists`),
};
}
return { matched: true, failure: null };
}
if (el.length === 0) {
return {
matched: false,
failure: mismatchFailure("body", fullPath, "element found", "no match", `selector ${selector} not found`),
};
}
const actual = attr ? el.attr(attr) : el.text();
const matched = applyOperator(actual ?? "", operators);
if (!matched) {
return {
matched: false,
failure: mismatchFailure("body", fullPath, operators, actual, `css selector ${selector} mismatch`),
};
}
return { matched: true, failure: null };
}
function checkXpathRule(
body: string,
rule: XpathRule,
rulePath: string,
): { matched: boolean; failure: CheckFailure | null } {
const { path, ...operators } = rule;
const fullPath = `${rulePath}.xpath(${path})`;
let doc: ReturnType<DOMParser["parseFromString"]>;
try {
doc = new DOMParser().parseFromString(body, "text/xml");
} catch {
return {
matched: false,
failure: errorFailure("body", fullPath, "failed to parse XML/HTML"),
};
}
const nodes = xpath.select(path, doc as unknown as Node);
if (!nodes || !Array.isArray(nodes) || nodes.length === 0) {
return {
matched: false,
failure: mismatchFailure("body", fullPath, "node found", "no match", `xpath ${path} not found`),
};
}
const node = nodes[0]!;
const actual = node.nodeValue ?? (node as unknown as Element).textContent ?? "";
const opKeys = Object.keys(operators);
if (opKeys.length === 0) {
return { matched: true, failure: null };
}
const matched = applyOperator(actual, operators);
if (!matched) {
return {
matched: false,
failure: mismatchFailure("body", fullPath, operators, actual, `xpath ${path} mismatch`),
};
}
return { matched: true, failure: null };
}
function checkSingleBodyRule(
body: string,
rule: BodyRule,
index: number,
): { matched: boolean; failure: CheckFailure | null } {
const rulePath = `body[${index}]`;
if ("contains" in rule) {
const matched = body.includes(rule.contains);
if (!matched) {
return {
matched: false,
failure: mismatchFailure("body", rulePath, rule.contains, body, `body does not contain "${rule.contains}"`),
};
}
return { matched: true, failure: null };
}
if ("regex" in rule) {
const matched = new RegExp(rule.regex).test(body);
if (!matched) {
return {
matched: false,
failure: mismatchFailure("body", rulePath, `/${rule.regex}/`, body, `body does not match /${rule.regex}/`),
};
}
return { matched: true, failure: null };
}
if ("json" in rule) {
return checkJsonRule(body, rule.json, rulePath);
}
if ("css" in rule) {
return checkCssRule(body, rule.css, rulePath);
}
if ("xpath" in rule) {
return checkXpathRule(body, rule.xpath, rulePath);
}
return { matched: true, failure: null };
}
export function checkBodyExpect(body: string, rules?: BodyRule[]): { matched: boolean; failure: CheckFailure | null } {
if (!rules || rules.length === 0) return { matched: true, failure: null };
for (let i = 0; i < rules.length; i++) {
const result = checkSingleBodyRule(body, rules[i]!, i);
if (!result.matched) return result;
}
return { matched: true, failure: null };
}

View File

@@ -0,0 +1,91 @@
import type { CheckFailure, CommandExpectConfig, TextRule } from "../types";
import { applyOperator } from "./body";
import { mismatchFailure } from "./failure";
export interface CommandObservation {
exitCode: number;
stdout: string;
stderr: string;
durationMs: number;
}
function checkExitCode(obs: CommandObservation, allowed: number[]): { matched: boolean; failure: CheckFailure | null } {
if (!allowed.includes(obs.exitCode)) {
return {
matched: false,
failure: mismatchFailure(
"exitCode",
"exitCode",
allowed,
obs.exitCode,
`exitCode ${obs.exitCode} not in [${allowed}]`,
),
};
}
return { matched: true, failure: null };
}
function checkDuration(
obs: CommandObservation,
maxDurationMs?: number,
): { matched: boolean; failure: CheckFailure | null } {
if (maxDurationMs === undefined) return { matched: true, failure: null };
if (obs.durationMs > maxDurationMs) {
return {
matched: false,
failure: mismatchFailure(
"duration",
"duration",
`<=${maxDurationMs}ms`,
obs.durationMs,
`duration ${obs.durationMs}ms > ${maxDurationMs}ms`,
),
};
}
return { matched: true, failure: null };
}
function checkTextRules(
text: string,
rules: TextRule[],
phase: "stdout" | "stderr",
): { matched: boolean; failure: CheckFailure | null } {
for (let i = 0; i < rules.length; i++) {
const rule = rules[i]!;
const path = `${phase}[${i}]`;
if (!applyOperator(text, rule)) {
return {
matched: false,
failure: mismatchFailure(phase, path, rule, text, `${phase} rule at index ${i} mismatch`),
};
}
}
return { matched: true, failure: null };
}
export function checkCommandExpect(
obs: CommandObservation,
expect?: CommandExpectConfig,
): { matched: boolean; failure: CheckFailure | null } {
if (!expect) {
return checkExitCode(obs, [0]);
}
const exitCodeResult = checkExitCode(obs, expect.exitCode ?? [0]);
if (!exitCodeResult.matched) return exitCodeResult;
const durationResult = checkDuration(obs, expect.maxDurationMs);
if (!durationResult.matched) return durationResult;
if (expect.stdout && expect.stdout.length > 0) {
const stdoutResult = checkTextRules(obs.stdout, expect.stdout, "stdout");
if (!stdoutResult.matched) return stdoutResult;
}
if (expect.stderr && expect.stderr.length > 0) {
const stderrResult = checkTextRules(obs.stderr, expect.stderr, "stderr");
if (!stderrResult.matched) return stderrResult;
}
return { matched: true, failure: null };
}

View File

@@ -0,0 +1,34 @@
import type { CheckFailure } from "../types";
export function truncateActual(value: unknown, maxLen = 200): unknown {
if (value === undefined || value === null) return value;
const str = String(value);
if (str.length <= maxLen) return value;
return str.slice(0, maxLen) + "...";
}
export function mismatchFailure(
phase: CheckFailure["phase"],
path: string,
expected: unknown,
actual: unknown,
message: string,
): CheckFailure {
return {
kind: "mismatch",
phase,
path,
expected,
actual: truncateActual(actual),
message,
};
}
export function errorFailure(phase: CheckFailure["phase"], path: string, message: string): CheckFailure {
return {
kind: "error",
phase,
path,
message,
};
}

View File

@@ -0,0 +1,122 @@
import type { BodyRule, CheckFailure, HeaderExpect, HttpExpectConfig } from "../types";
import { checkBodyExpect } from "./body";
import { applyOperator } from "./body";
import { mismatchFailure, errorFailure } from "./failure";
export interface HttpObservation {
statusCode: number;
headers: Record<string, string>;
body: string | null;
durationMs: number;
}
function checkStatus(obs: HttpObservation, allowed: number[]): { matched: boolean; failure: CheckFailure | null } {
if (!allowed.includes(obs.statusCode)) {
return {
matched: false,
failure: mismatchFailure(
"status",
"status",
allowed,
obs.statusCode,
`status ${obs.statusCode} not in [${allowed}]`,
),
};
}
return { matched: true, failure: null };
}
function checkDuration(
obs: HttpObservation,
maxDurationMs?: number,
): { matched: boolean; failure: CheckFailure | null } {
if (maxDurationMs === undefined) return { matched: true, failure: null };
if (obs.durationMs > maxDurationMs) {
return {
matched: false,
failure: mismatchFailure(
"duration",
"duration",
`<=${maxDurationMs}ms`,
obs.durationMs,
`duration ${obs.durationMs}ms > ${maxDurationMs}ms`,
),
};
}
return { matched: true, failure: null };
}
function checkHeaders(
obs: HttpObservation,
headerExpects?: Record<string, HeaderExpect>,
): { matched: boolean; failure: CheckFailure | null } {
if (!headerExpects) return { matched: true, failure: null };
for (const [key, expected] of Object.entries(headerExpects)) {
const actualValue = obs.headers[key.toLowerCase()];
const path = `headers.${key}`;
if (typeof expected === "string") {
if (actualValue !== expected) {
return {
matched: false,
failure: mismatchFailure("headers", path, expected, actualValue, `header ${key} mismatch`),
};
}
} else {
if (actualValue === undefined) {
if (expected.exists !== false) {
return {
matched: false,
failure: mismatchFailure("headers", path, "defined", undefined, `header ${key} not found`),
};
}
continue;
}
if (!applyOperator(actualValue, expected)) {
return {
matched: false,
failure: mismatchFailure("headers", path, expected, actualValue, `header ${key} mismatch`),
};
}
}
}
return { matched: true, failure: null };
}
function checkBody(obs: HttpObservation, bodyRules?: BodyRule[]): { matched: boolean; failure: CheckFailure | null } {
if (!bodyRules || bodyRules.length === 0) return { matched: true, failure: null };
if (obs.body === null) {
return {
matched: false,
failure: errorFailure("body", "body", "body is null but body rules are configured"),
};
}
return checkBodyExpect(obs.body, bodyRules);
}
export function checkHttpExpect(
obs: HttpObservation,
expect?: HttpExpectConfig,
): { matched: boolean; failure: CheckFailure | null } {
if (!expect) {
return checkStatus(obs, [200]);
}
const statusResult = checkStatus(obs, expect.status ?? [200]);
if (!statusResult.matched) return statusResult;
const durationResult = checkDuration(obs, expect.maxDurationMs);
if (!durationResult.matched) return durationResult;
const headersResult = checkHeaders(obs, expect.headers);
if (!headersResult.matched) return headersResult;
const bodyResult = checkBody(obs, expect.body);
if (!bodyResult.matched) return bodyResult;
return { matched: true, failure: null };
}

View File

@@ -1,52 +1,6 @@
import type { CheckResult, ExpectConfig, ResolvedTarget } from "./types";
import { checkBodyExpect } from "./body-expect";
export async function fetchTarget(target: ResolvedTarget): Promise<CheckResult> {
const timestamp = new Date().toISOString();
const controller = new AbortController();
const timeoutId = setTimeout(() => controller.abort(), target.timeoutMs);
try {
const start = performance.now();
const response = await fetch(target.url, {
method: target.method,
headers: target.headers,
body: target.method !== "GET" && target.method !== "HEAD" ? target.body : undefined,
signal: controller.signal,
});
const latencyMs = Math.round(performance.now() - start);
const body = await response.text();
const responseHeaders = headersToRecord(response.headers);
const matched = checkExpect(response.status, body, latencyMs, responseHeaders, target.expect);
return {
targetName: target.name,
timestamp,
success: true,
statusCode: response.status,
latencyMs,
error: null,
matched,
};
} catch (error) {
const isTimeout = error instanceof DOMException && error.name === "AbortError";
return {
targetName: target.name,
timestamp,
success: false,
statusCode: null,
latencyMs: null,
error: isTimeout ? `请求超时 (${target.timeoutMs}ms)` : error instanceof Error ? error.message : String(error),
matched: false,
};
} finally {
clearTimeout(timeoutId);
}
}
import type { CheckResult, ResolvedHttpTarget } from "./types";
import { checkHttpExpect } from "./expect/http";
import { errorFailure } from "./expect/failure";
function headersToRecord(headers: Headers): Record<string, string> {
const result: Record<string, string> = {};
@@ -56,35 +10,95 @@ function headersToRecord(headers: Headers): Record<string, string> {
return result;
}
export function checkExpect(
statusCode: number,
body: string,
latencyMs: number,
responseHeaders: Record<string, string>,
expect?: ExpectConfig,
): boolean {
if (!expect) return true;
export async function runHttpCheck(target: ResolvedHttpTarget): Promise<CheckResult> {
const timestamp = new Date().toISOString();
const controller = new AbortController();
const timeoutId = setTimeout(() => controller.abort(), target.timeoutMs);
if (expect.status && !expect.status.includes(statusCode)) {
return false;
try {
const start = performance.now();
const response = await fetch(target.http.url, {
method: target.http.method,
headers: target.http.headers,
body: target.http.method !== "GET" && target.http.method !== "HEAD" ? target.http.body : undefined,
signal: controller.signal,
});
const durationMs = Math.round(performance.now() - start);
const statusCode = response.status;
const responseHeaders = headersToRecord(response.headers);
const hasBodyRules = !!(target.expect?.body && target.expect.body.length > 0);
const preBodyExpect = target.expect
? { status: target.expect.status, maxDurationMs: target.expect.maxDurationMs, headers: target.expect.headers }
: undefined;
const preBodyObs = { statusCode, headers: responseHeaders, body: null as string | null, durationMs };
const preBodyResult = checkHttpExpect(preBodyObs, preBodyExpect);
if (!hasBodyRules || !preBodyResult.matched) {
clearTimeout(timeoutId);
return {
targetName: target.name,
timestamp,
success: preBodyResult.matched,
matched: preBodyResult.matched,
durationMs,
statusDetail: `HTTP ${statusCode}`,
failure: preBodyResult.failure,
};
}
if (expect.headers) {
for (const [key, expectedValue] of Object.entries(expect.headers)) {
const actualValue = responseHeaders[key.toLowerCase()];
if (!actualValue || actualValue !== expectedValue) {
return false;
}
}
const bodyBuffer = await response.arrayBuffer();
clearTimeout(timeoutId);
if (bodyBuffer.byteLength > target.http.maxBodyBytes) {
return {
targetName: target.name,
timestamp,
success: false,
matched: false,
durationMs,
statusDetail: `HTTP ${statusCode}`,
failure: errorFailure(
"body",
"body",
`响应体大小 ${bodyBuffer.byteLength} 超过限制 ${target.http.maxBodyBytes}`,
),
};
}
if (!checkBodyExpect(body, expect.body)) {
return false;
}
const body = new TextDecoder().decode(bodyBuffer);
const fullObs = { statusCode, headers: responseHeaders, body, durationMs };
const fullResult = checkHttpExpect(fullObs, target.expect);
if (expect.maxLatencyMs !== undefined && latencyMs > expect.maxLatencyMs) {
return false;
}
return {
targetName: target.name,
timestamp,
success: fullResult.matched,
matched: fullResult.matched,
durationMs,
statusDetail: `HTTP ${statusCode}`,
failure: fullResult.failure,
};
} catch (error) {
clearTimeout(timeoutId);
const isTimeout = error instanceof DOMException && error.name === "AbortError";
return true;
return {
targetName: target.name,
timestamp,
success: false,
matched: false,
durationMs: null,
statusDetail: null,
failure: errorFailure(
"status",
"request",
isTimeout ? `请求超时 (${target.timeoutMs}ms)` : error instanceof Error ? error.message : String(error),
),
};
}
}

View File

@@ -0,0 +1,22 @@
const SIZE_REGEX = /^(\d+(?:\.\d+)?)(B|KB|MB|GB)$/;
export function parseSize(value: string | number): number {
if (typeof value === "number") return value;
const match = SIZE_REGEX.exec(value);
if (!match) {
throw new Error(`无效的 size 格式: "${value}",支持格式如 "100MB"、"512KB"、"1GB"、"1024B"`);
}
const num = parseFloat(match[1]!);
const unit = match[2]!;
if (unit === "B") return num;
if (unit === "KB") return num * 1024;
if (unit === "MB") return num * 1024 * 1024;
return num * 1024 * 1024 * 1024;
}
export const DEFAULT_MAX_BODY_BYTES = parseSize("100MB");
export const DEFAULT_MAX_OUTPUT_BYTES = parseSize("100MB");
export const DEFAULT_MAX_CONCURRENT_CHECKS = 20;

View File

@@ -1,16 +1,15 @@
import { Database } from "bun:sqlite";
import { mkdirSync as fsMkdirSync } from "node:fs";
import { dirname } from "node:path";
import type { ResolvedTarget, StoredCheckResult, StoredTarget } from "./types";
import type { CheckFailure, ResolvedTarget, StoredCheckResult, StoredTarget } from "./types";
const CREATE_TARGETS_TABLE = `
CREATE TABLE IF NOT EXISTS targets (
id INTEGER PRIMARY KEY AUTOINCREMENT,
name TEXT NOT NULL UNIQUE,
url TEXT NOT NULL,
method TEXT NOT NULL DEFAULT 'GET',
headers TEXT NOT NULL DEFAULT '{}',
body TEXT,
type TEXT NOT NULL,
target TEXT NOT NULL,
config TEXT NOT NULL DEFAULT '{}',
interval_ms INTEGER NOT NULL,
timeout_ms INTEGER NOT NULL,
expect TEXT
@@ -23,10 +22,10 @@ CREATE TABLE IF NOT EXISTS check_results (
target_id INTEGER NOT NULL,
timestamp TEXT NOT NULL,
success INTEGER NOT NULL,
status_code INTEGER,
latency_ms REAL,
error TEXT,
matched INTEGER NOT NULL,
duration_ms REAL,
status_detail TEXT,
failure TEXT,
FOREIGN KEY (target_id) REFERENCES targets(id)
)
`;
@@ -59,40 +58,24 @@ export class ProbeStore {
const configNames = new Set(targets.map((t) => t.name));
const insertStmt = this.db.prepare(
"INSERT INTO targets (name, url, method, headers, body, interval_ms, timeout_ms, expect) VALUES (?, ?, ?, ?, ?, ?, ?, ?)",
"INSERT INTO targets (name, type, target, config, interval_ms, timeout_ms, expect) VALUES (?, ?, ?, ?, ?, ?, ?)",
);
const updateStmt = this.db.prepare(
"UPDATE targets SET url = ?, method = ?, headers = ?, body = ?, interval_ms = ?, timeout_ms = ?, expect = ? WHERE id = ?",
"UPDATE targets SET type = ?, target = ?, config = ?, interval_ms = ?, timeout_ms = ?, expect = ? WHERE id = ?",
);
const deleteStmt = this.db.prepare("DELETE FROM targets WHERE id = ?");
const tx = this.db.transaction(() => {
for (const target of targets) {
const headers = JSON.stringify(target.headers);
const expect = target.expect ? JSON.stringify(target.expect) : null;
for (const t of targets) {
const type = t.type;
const target = buildTargetDisplay(t);
const config = buildTargetConfig(t);
const expect = t.expect ? JSON.stringify(t.expect) : null;
if (existingMap.has(target.name)) {
updateStmt.run(
target.url,
target.method,
headers,
target.body ?? null,
target.intervalMs,
target.timeoutMs,
expect,
existingMap.get(target.name)!,
);
if (existingMap.has(t.name)) {
updateStmt.run(type, target, config, t.intervalMs, t.timeoutMs, expect, existingMap.get(t.name)!);
} else {
insertStmt.run(
target.name,
target.url,
target.method,
headers,
target.body ?? null,
target.intervalMs,
target.timeoutMs,
expect,
);
insertStmt.run(t.name, type, target, config, t.intervalMs, t.timeoutMs, expect);
}
}
@@ -120,24 +103,24 @@ export class ProbeStore {
targetId: number;
timestamp: string;
success: boolean;
statusCode: number | null;
latencyMs: number | null;
error: string | null;
matched: boolean;
durationMs: number | null;
statusDetail: string | null;
failure: CheckFailure | null;
}): void {
if (this.closed) return;
this.db
.prepare(
"INSERT INTO check_results (target_id, timestamp, success, status_code, latency_ms, error, matched) VALUES (?, ?, ?, ?, ?, ?, ?)",
"INSERT INTO check_results (target_id, timestamp, success, matched, duration_ms, status_detail, failure) VALUES (?, ?, ?, ?, ?, ?, ?)",
)
.run(
result.targetId,
result.timestamp,
result.success ? 1 : 0,
result.statusCode,
result.latencyMs,
result.error,
result.matched ? 1 : 0,
result.durationMs,
result.statusDetail,
result.failure ? JSON.stringify(result.failure) : null,
);
}
@@ -156,30 +139,30 @@ export class ProbeStore {
getTargetStats(targetId: number): {
totalChecks: number;
availability: number;
avgLatencyMs: number | null;
p99LatencyMs: number | null;
avgDurationMs: number | null;
p99DurationMs: number | null;
} {
const row = this.db
.prepare(
`SELECT
COUNT(*) as totalChecks,
COALESCE(SUM(CASE WHEN matched = 1 THEN 1 ELSE 0 END), 0) as upCount,
AVG(CASE WHEN success = 1 THEN latency_ms END) as avgLatencyMs
AVG(CASE WHEN success = 1 THEN duration_ms END) as avgDurationMs
FROM check_results
WHERE target_id = ?`,
)
.get(targetId) as { totalChecks: number; upCount: number; avgLatencyMs: number | null };
.get(targetId) as { totalChecks: number; upCount: number; avgDurationMs: number | null };
const p99Row = this.db
.prepare(
`SELECT latency_ms as p99LatencyMs
`SELECT duration_ms as p99DurationMs
FROM check_results
WHERE target_id = ? AND success = 1
ORDER BY latency_ms DESC
ORDER BY duration_ms DESC
LIMIT 1
OFFSET (SELECT COUNT(*) FROM check_results WHERE target_id = ? AND success = 1) * 99 / 100`,
)
.get(targetId, targetId) as { p99LatencyMs: number | null } | undefined;
.get(targetId, targetId) as { p99DurationMs: number | null } | undefined;
const totalChecks = row.totalChecks;
const availability = totalChecks > 0 ? (row.upCount / totalChecks) * 100 : 0;
@@ -187,8 +170,8 @@ export class ProbeStore {
return {
totalChecks,
availability: Math.round(availability * 100) / 100,
avgLatencyMs: row.avgLatencyMs !== null ? Math.round(row.avgLatencyMs * 100) / 100 : null,
p99LatencyMs: p99Row?.p99LatencyMs ?? null,
avgDurationMs: row.avgDurationMs !== null ? Math.round(row.avgDurationMs * 100) / 100 : null,
p99DurationMs: p99Row?.p99DurationMs ?? null,
};
}
@@ -197,7 +180,7 @@ export class ProbeStore {
hours = 24,
): Array<{
hour: string;
avgLatencyMs: number | null;
avgDurationMs: number | null;
availability: number;
totalChecks: number;
}> {
@@ -205,7 +188,7 @@ export class ProbeStore {
.prepare(
`SELECT
strftime('%Y-%m-%dT%H:00:00', timestamp) as hour,
AVG(CASE WHEN success = 1 THEN latency_ms END) as avgLatencyMs,
AVG(CASE WHEN success = 1 THEN duration_ms END) as avgDurationMs,
CASE WHEN COUNT(*) > 0 THEN (SUM(CASE WHEN matched = 1 THEN 1 ELSE 0 END) * 100.0 / COUNT(*)) ELSE 0 END as availability,
COUNT(*) as totalChecks
FROM check_results
@@ -215,7 +198,7 @@ export class ProbeStore {
)
.all(targetId, hours) as Array<{
hour: string;
avgLatencyMs: number | null;
avgDurationMs: number | null;
availability: number;
totalChecks: number;
}>;
@@ -225,14 +208,14 @@ export class ProbeStore {
total: number;
up: number;
down: number;
avgLatencyMs: number | null;
avgDurationMs: number | null;
lastCheckTime: string | null;
} {
const targets = this.getTargets();
let up = 0;
let down = 0;
let totalLatency = 0;
let latencyCount = 0;
let totalDuration = 0;
let durationCount = 0;
let lastCheckTime: string | null = null;
for (const target of targets) {
@@ -245,9 +228,9 @@ export class ProbeStore {
down++;
}
if (latest.latency_ms !== null) {
totalLatency += latest.latency_ms;
latencyCount++;
if (latest.duration_ms !== null) {
totalDuration += latest.duration_ms;
durationCount++;
}
if (!lastCheckTime || latest.timestamp > lastCheckTime) {
@@ -262,7 +245,7 @@ export class ProbeStore {
total: targets.length,
up,
down,
avgLatencyMs: latencyCount > 0 ? Math.round((totalLatency / latencyCount) * 100) / 100 : null,
avgDurationMs: durationCount > 0 ? Math.round((totalDuration / durationCount) * 100) / 100 : null,
lastCheckTime,
};
}
@@ -270,10 +253,10 @@ export class ProbeStore {
getSparkline(targetId: number, limit = 20): number[] {
const rows = this.db
.prepare(
"SELECT latency_ms FROM check_results WHERE target_id = ? AND success = 1 ORDER BY timestamp DESC LIMIT ?",
"SELECT duration_ms FROM check_results WHERE target_id = ? AND success = 1 ORDER BY timestamp DESC LIMIT ?",
)
.all(targetId, limit) as Array<{ latency_ms: number }>;
return rows.map((r) => r.latency_ms).reverse();
.all(targetId, limit) as Array<{ duration_ms: number }>;
return rows.map((r) => r.duration_ms).reverse();
}
close(): void {
@@ -282,6 +265,33 @@ export class ProbeStore {
}
}
function buildTargetDisplay(t: ResolvedTarget): string {
if (t.type === "http") {
return t.http.url;
}
const parts = [t.command.exec, ...t.command.args];
return `exec ${parts.join(" ")}`;
}
function buildTargetConfig(t: ResolvedTarget): string {
if (t.type === "http") {
return JSON.stringify({
url: t.http.url,
method: t.http.method,
headers: t.http.headers,
body: t.http.body,
maxBodyBytes: t.http.maxBodyBytes,
});
}
return JSON.stringify({
exec: t.command.exec,
args: t.command.args,
cwd: t.command.cwd,
env: t.command.env,
maxOutputBytes: t.command.maxOutputBytes,
});
}
function ensureDir(dir: string): void {
try {
fsMkdirSync(dir, { recursive: true });

View File

@@ -1,5 +1,8 @@
export type TargetType = "http" | "command";
export interface ProbeConfig {
server?: ServerConfig;
runtime?: RuntimeConfig;
defaults?: DefaultsConfig;
targets: TargetConfig[];
}
@@ -10,19 +13,49 @@ export interface ServerConfig {
dataDir?: string;
}
export interface RuntimeConfig {
maxConcurrentChecks?: number;
}
export interface HttpDefaultsConfig {
method?: string;
headers?: Record<string, string>;
maxBodyBytes?: string;
}
export interface CommandDefaultsConfig {
cwd?: string;
maxOutputBytes?: string;
}
export interface DefaultsConfig {
interval?: string;
timeout?: string;
method?: string;
headers?: Record<string, string>;
http?: HttpDefaultsConfig;
command?: CommandDefaultsConfig;
}
export interface TargetConfig {
name: string;
export interface HttpTargetConfig {
url: string;
method?: string;
headers?: Record<string, string>;
body?: string;
maxBodyBytes?: string;
}
export interface CommandTargetConfig {
exec: string;
args?: string[];
cwd?: string;
env?: Record<string, string>;
maxOutputBytes?: string;
}
export type TargetConfig = BaseTargetConfig &
({ type: "http"; http: HttpTargetConfig } | { type: "command"; command: CommandTargetConfig });
interface BaseTargetConfig {
name: string;
interval?: string;
timeout?: string;
expect?: ExpectConfig;
@@ -42,51 +75,100 @@ export interface ExpectOperator {
export type ExpectValue = string | number | boolean | null | ExpectOperator;
export type CssExpect = ExpectValue | (ExpectOperator & { attr?: string });
export type TextRule = ExpectOperator;
export interface BodyExpectConfig {
contains?: string;
regex?: string;
json?: Record<string, ExpectValue>;
css?: Record<string, CssExpect>;
xpath?: Record<string, ExpectValue>;
}
export type JsonRule = { path: string } & ExpectOperator;
export interface ExpectConfig {
export type CssRule = { selector: string; attr?: string } & ExpectOperator;
export type XpathRule = { path: string } & ExpectOperator;
export type BodyRule =
| { contains: string }
| { regex: string }
| { json: JsonRule }
| { css: CssRule }
| { xpath: XpathRule };
export type HeaderExpect = string | ExpectOperator;
export interface HttpExpectConfig {
status?: number[];
maxLatencyMs?: number;
headers?: Record<string, string>;
body?: BodyExpectConfig;
maxDurationMs?: number;
headers?: Record<string, HeaderExpect>;
body?: BodyRule[];
}
export interface ResolvedTarget {
export interface CommandExpectConfig {
exitCode?: number[];
maxDurationMs?: number;
stdout?: TextRule[];
stderr?: TextRule[];
}
export type ExpectConfig = HttpExpectConfig | CommandExpectConfig;
export interface ResolvedHttpTarget {
type: "http";
name: string;
http: ResolvedHttpConfig;
intervalMs: number;
timeoutMs: number;
expect?: HttpExpectConfig;
}
export interface ResolvedHttpConfig {
url: string;
method: string;
headers: Record<string, string>;
body?: string;
maxBodyBytes: number;
}
export interface ResolvedCommandTarget {
type: "command";
name: string;
command: ResolvedCommandConfig;
intervalMs: number;
timeoutMs: number;
expect?: ExpectConfig;
expect?: CommandExpectConfig;
}
export interface ResolvedCommandConfig {
exec: string;
args: string[];
cwd: string;
env: Record<string, string>;
maxOutputBytes: number;
}
export type ResolvedTarget = ResolvedHttpTarget | ResolvedCommandTarget;
export interface CheckFailure {
kind: "error" | "mismatch";
phase: "status" | "duration" | "headers" | "body" | "exitCode" | "stdout" | "stderr";
path: string;
expected?: unknown;
actual?: unknown;
message: string;
}
export interface CheckResult {
targetName: string;
timestamp: string;
success: boolean;
statusCode: number | null;
latencyMs: number | null;
error: string | null;
matched: boolean;
durationMs: number | null;
statusDetail: string | null;
failure: CheckFailure | null;
}
export interface StoredTarget {
id: number;
name: string;
url: string;
method: string;
headers: string;
body: string | null;
type: TargetType;
target: string;
config: string;
interval_ms: number;
timeout_ms: number;
expect: string | null;
@@ -97,8 +179,8 @@ export interface StoredCheckResult {
target_id: number;
timestamp: string;
success: number;
status_code: number | null;
latency_ms: number | null;
error: string | null;
matched: number;
duration_ms: number | null;
status_detail: string | null;
failure: string | null;
}

View File

@@ -11,7 +11,7 @@ async function main() {
const store = new ProbeStore(`${config.dataDir}/probe.db`);
store.syncTargets(config.targets);
const engine = new ProbeEngine(store, config.targets);
const engine = new ProbeEngine(store, config.targets, config.maxConcurrentChecks);
engine.start();
startServer({

View File

@@ -15,15 +15,15 @@ export interface SummaryResponse {
total: number;
up: number;
down: number;
avgLatencyMs: number | null;
avgDurationMs: number | null;
lastCheckTime: string | null;
}
export interface TargetStatus {
id: number;
name: string;
url: string;
method: string;
type: string;
target: string;
interval: string;
latestCheck: CheckResult | null;
stats: TargetStats;
@@ -33,22 +33,31 @@ export interface TargetStatus {
export interface TargetStats {
totalChecks: number;
availability: number;
avgLatencyMs: number | null;
p99LatencyMs: number | null;
avgDurationMs: number | null;
p99DurationMs: number | null;
}
export interface CheckResult {
timestamp: string;
success: boolean;
statusCode: number | null;
latencyMs: number | null;
error: string | null;
matched: boolean;
durationMs: number | null;
statusDetail: string | null;
failure: CheckFailure | null;
}
export interface CheckFailure {
kind: "error" | "mismatch";
phase: string;
path: string;
expected?: unknown;
actual?: unknown;
message: string;
}
export interface TrendPoint {
hour: string;
avgLatencyMs: number | null;
avgDurationMs: number | null;
availability: number;
totalChecks: number;
}

View File

@@ -1,7 +1,7 @@
import { Line, LineChart, ResponsiveContainer } from "recharts";
interface SparklineChartProps {
data: Array<{ latency: number }>;
data: Array<{ duration: number }>;
}
export function SparklineChart({ data }: SparklineChartProps) {
@@ -12,7 +12,7 @@ export function SparklineChart({ data }: SparklineChartProps) {
return (
<ResponsiveContainer width={80} height={32}>
<LineChart data={data}>
<Line type="monotone" dataKey="latency" stroke="#356dd2" strokeWidth={1.5} dot={false} />
<Line type="monotone" dataKey="duration" stroke="#356dd2" strokeWidth={1.5} dot={false} />
</LineChart>
</ResponsiveContainer>
);

View File

@@ -17,8 +17,8 @@ export function SummaryCards({ summary, loading }: SummaryCardsProps) {
{ label: "正常", value: summary.up, className: "card-up" },
{ label: "异常", value: summary.down, className: "card-down" },
{
label: "平均延迟",
value: summary.avgLatencyMs !== null ? `${Math.round(summary.avgLatencyMs)}ms` : "-",
label: "平均耗时",
value: summary.avgDurationMs !== null ? `${Math.round(summary.avgDurationMs)}ms` : "-",
className: "card-latency",
},
];

View File

@@ -26,8 +26,9 @@ export function TargetDetail({ target }: TargetDetailProps) {
}, [target.id]);
useEffect(() => {
void fetchTrend();
void fetchHistory();
fetchTrend();
// eslint-disable-next-line react-hooks/set-state-in-effect
fetchHistory();
}, [fetchTrend, fetchHistory]);
const { stats } = target;
@@ -49,15 +50,15 @@ export function TargetDetail({ target }: TargetDetailProps) {
</span>
</div>
<div className="detail-stat">
<span className="detail-stat-label"></span>
<span className="detail-stat-label"></span>
<span className="detail-stat-value">
{stats.avgLatencyMs !== null ? `${Math.round(stats.avgLatencyMs)}ms` : "-"}
{stats.avgDurationMs !== null ? `${Math.round(stats.avgDurationMs)}ms` : "-"}
</span>
</div>
<div className="detail-stat">
<span className="detail-stat-label">P99 </span>
<span className="detail-stat-label">P99 </span>
<span className="detail-stat-value">
{stats.p99LatencyMs !== null ? `${Math.round(stats.p99LatencyMs)}ms` : "-"}
{stats.p99DurationMs !== null ? `${Math.round(stats.p99DurationMs)}ms` : "-"}
</span>
</div>
</div>
@@ -79,9 +80,11 @@ export function TargetDetail({ target }: TargetDetailProps) {
{item.success && item.matched ? "UP" : "DOWN"}
</span>
<span className="history-time">{new Date(item.timestamp).toLocaleString("zh-CN")}</span>
{item.statusCode && <span className="history-code">{item.statusCode}</span>}
{item.latencyMs !== null && <span className="history-latency">{Math.round(item.latencyMs)}ms</span>}
{item.error && <span className="history-error">{item.error}</span>}
{item.statusDetail && <span className="history-code">{item.statusDetail}</span>}
{item.durationMs !== null && (
<span className="history-latency">{Math.round(item.durationMs)}ms</span>
)}
{item.failure?.message && <span className="history-error">{item.failure.message}</span>}
</div>
))}
</div>

View File

@@ -11,7 +11,7 @@ interface TargetRowProps {
export function TargetRow({ target, expanded, onToggle }: TargetRowProps) {
const isUp = target.latestCheck?.success && target.latestCheck?.matched;
const sparklineData = target.sparkline.map((latency) => ({ latency }));
const sparklineData = target.sparkline.map((duration) => ({ duration }));
return (
<tr className={`target-row ${expanded ? "expanded" : ""}`} onClick={onToggle}>
@@ -19,11 +19,11 @@ export function TargetRow({ target, expanded, onToggle }: TargetRowProps) {
<StatusDot up={!!isUp} />
</td>
<td className="col-name">{target.name}</td>
<td className="col-url">{target.url}</td>
<td className="col-method">{target.method}</td>
<td className="col-latency">
{target.latestCheck?.latencyMs !== null && target.latestCheck?.latencyMs !== undefined
? `${Math.round(target.latestCheck.latencyMs)}ms`
<td className="col-target">{target.target}</td>
<td className="col-type">{target.type === "http" ? "HTTP" : "Command"}</td>
<td className="col-duration">
{target.latestCheck?.durationMs !== null && target.latestCheck?.durationMs !== undefined
? `${Math.round(target.latestCheck.durationMs)}ms`
: "-"}
</td>
<td className="col-sparkline">

View File

@@ -25,9 +25,9 @@ export function TargetTable({ targets, loading }: TargetTableProps) {
<tr>
<th className="col-status"></th>
<th className="col-name"></th>
<th className="col-url">URL</th>
<th className="col-method"></th>
<th className="col-latency"></th>
<th className="col-target"></th>
<th className="col-type"></th>
<th className="col-duration"></th>
<th className="col-sparkline"></th>
</tr>
</thead>

View File

@@ -27,7 +27,7 @@ export function TrendChart({ data, loading }: TrendChartProps) {
<CartesianGrid strokeDasharray="3 3" stroke="#e2e8f0" />
<XAxis dataKey="hour" tick={{ fontSize: 12 }} stroke="#94a3b8" />
<YAxis
yAxisId="latency"
yAxisId="duration"
tick={{ fontSize: 12 }}
stroke="#94a3b8"
label={{ value: "ms", position: "insideTopRight", fontSize: 11 }}
@@ -44,19 +44,19 @@ export function TrendChart({ data, loading }: TrendChartProps) {
formatter={(value: unknown, name: unknown) => {
const num = Number(value);
const nameStr = String(name);
if (nameStr === "avgLatencyMs") return [`${Math.round(num)}ms`, "平均延迟"];
if (nameStr === "avgDurationMs") return [`${Math.round(num)}ms`, "平均耗时"];
if (nameStr === "availability") return [`${num.toFixed(1)}%`, "可用率"];
return [String(value), nameStr];
}}
/>
<Line
yAxisId="latency"
yAxisId="duration"
type="monotone"
dataKey="avgLatencyMs"
dataKey="avgDurationMs"
stroke="#356dd2"
strokeWidth={2}
dot={false}
name="avgLatencyMs"
name="avgDurationMs"
/>
<Line
yAxisId="availability"

View File

@@ -29,7 +29,8 @@ export function useSummary(intervalMs = 8000) {
}, []);
useEffect(() => {
void fetchSummary();
// eslint-disable-next-line react-hooks/set-state-in-effect
fetchSummary();
const timer = setInterval(fetchSummary, intervalMs);
return () => {
clearInterval(timer);

View File

@@ -29,7 +29,8 @@ export function useTargets(intervalMs = 8000) {
}, []);
useEffect(() => {
void fetchTargets();
// eslint-disable-next-line react-hooks/set-state-in-effect
fetchTargets();
const timer = setInterval(fetchTargets, intervalMs);
return () => {
clearInterval(timer);

View File

@@ -141,7 +141,7 @@ body {
font-weight: 600;
}
.col-url {
.col-target {
color: #61728a;
font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace;
font-size: 0.82rem;
@@ -151,12 +151,12 @@ body {
white-space: nowrap;
}
.col-method {
width: 64px;
.col-type {
width: 80px;
text-align: center;
}
.col-latency {
.col-duration {
width: 80px;
text-align: right;
font-variant-numeric: tabular-nums;
@@ -317,7 +317,7 @@ body {
grid-template-columns: repeat(2, 1fr);
}
.col-method,
.col-type,
.col-sparkline {
display: none;
}

View File

@@ -26,18 +26,27 @@ describe("API 路由", () => {
store = new ProbeStore(join(tempDir, "test.db"));
store.syncTargets([
{
type: "http",
name: "test-a",
http: {
url: "http://a.com",
method: "GET",
headers: {},
maxBodyBytes: 104857600,
},
intervalMs: 30000,
timeoutMs: 10000,
},
{
type: "command",
name: "test-b",
url: "http://b.com",
method: "POST",
headers: {},
command: {
exec: "echo",
args: ["hello"],
cwd: "/tmp",
env: {},
maxOutputBytes: 104857600,
},
intervalMs: 60000,
timeoutMs: 5000,
},
@@ -48,19 +57,26 @@ describe("API 路由", () => {
targetId: targets[0]!.id,
timestamp: "2025-01-01T00:00:00.000Z",
success: true,
statusCode: 200,
latencyMs: 150,
error: null,
matched: true,
durationMs: 150,
statusDetail: "200 OK",
failure: null,
});
store.insertCheckResult({
targetId: targets[0]!.id,
timestamp: "2025-01-01T00:00:30.000Z",
success: false,
statusCode: null,
latencyMs: null,
error: "timeout",
matched: false,
durationMs: null,
statusDetail: null,
failure: {
kind: "error",
phase: "status",
path: "$.status",
expected: 200,
actual: 500,
message: "状态码不匹配",
},
});
fetchHandler = createFetchHandler({ mode: "test", staticAssets, store });
@@ -88,6 +104,7 @@ describe("API 路由", () => {
expect(body.up).toBeGreaterThanOrEqual(0);
expect(body.down).toBeGreaterThanOrEqual(0);
expect(body.up + body.down).toBe(2);
expect(body.avgDurationMs).toBeDefined();
});
test("/api/targets 返回目标列表", async () => {
@@ -96,12 +113,23 @@ describe("API 路由", () => {
expect(response.status).toBe(200);
expect(body).toHaveLength(2);
expect(body[0]!.name).toBe("test-a");
expect(body[0]!.latestCheck).not.toBeNull();
expect(body[0]!.latestCheck!.success).toBe(false);
expect(body[0]!.sparkline).toBeDefined();
expect(Array.isArray(body[0]!.sparkline)).toBe(true);
expect(body[1]!.latestCheck).toBeNull();
const tA = body.find((t) => t.name === "test-a")!;
expect(tA.type).toBe("http");
expect(tA.target).toBe("http://a.com");
expect(tA.latestCheck).not.toBeNull();
expect(tA.latestCheck!.success).toBe(false);
expect(tA.latestCheck!.matched).toBe(false);
expect(tA.latestCheck!.failure).not.toBeNull();
expect(tA.sparkline).toBeDefined();
expect(Array.isArray(tA.sparkline)).toBe(true);
expect(tA.stats.avgDurationMs).toBeDefined();
expect(tA.stats.p99DurationMs).toBeDefined();
const tB = body.find((t) => t.name === "test-b")!;
expect(tB.type).toBe("command");
expect(tB.target).toBe("exec echo hello");
expect(tB.latestCheck).toBeNull();
});
test("/api/targets/:id/history 返回历史记录", async () => {
@@ -111,6 +139,8 @@ describe("API 路由", () => {
expect(response.status).toBe(200);
expect(body).toHaveLength(2);
expect(body[0].failure).not.toBeNull();
expect(body[0].failure.kind).toBe("error");
});
test("/api/targets/:id/history 支持 limit 参数", async () => {

View File

@@ -0,0 +1,131 @@
import { describe, expect, test } from "bun:test";
import { runCommandCheck } from "../../../src/server/checker/command-runner";
import type { ResolvedCommandTarget } from "../../../src/server/checker/types";
function makeTarget(
command: Partial<ResolvedCommandTarget["command"]>,
overrides?: Partial<ResolvedCommandTarget>,
): ResolvedCommandTarget {
return {
type: "command",
name: "test-cmd",
command: {
exec: "echo",
args: ["hello"],
cwd: "/tmp",
env: {},
maxOutputBytes: 1024 * 1024,
...command,
},
intervalMs: 60000,
timeoutMs: 5000,
...overrides,
};
}
describe("runCommandCheck", () => {
test("exitCode=0 成功", async () => {
const result = await runCommandCheck(makeTarget({ exec: "true", args: [] }));
expect(result.success).toBe(true);
expect(result.matched).toBe(true);
expect(result.statusDetail).toBe("exitCode=0");
expect(result.failure).toBeNull();
});
test("exitCode=1 不匹配默认 [0]", async () => {
const result = await runCommandCheck(makeTarget({ exec: "false", args: [] }));
expect(result.success).toBe(false);
expect(result.matched).toBe(false);
expect(result.statusDetail).toBe("exitCode=1");
expect(result.failure).not.toBeNull();
expect(result.failure!.phase).toBe("exitCode");
});
test("exitCode=1 匹配自定义 [1]", async () => {
const result = await runCommandCheck(makeTarget({ exec: "false", args: [] }, { expect: { exitCode: [1] } }));
expect(result.success).toBe(true);
expect(result.matched).toBe(true);
expect(result.statusDetail).toBe("exitCode=1");
});
test("命令不存在返回 spawn 错误", async () => {
const result = await runCommandCheck(makeTarget({ exec: "/nonexistent/command/xyz" }));
expect(result.success).toBe(false);
expect(result.failure).not.toBeNull();
expect(result.failure!.phase).toBe("exitCode");
expect(result.failure!.message).toBeTruthy();
});
test("超时返回错误", async () => {
const result = await runCommandCheck(makeTarget({ exec: "sleep", args: ["10"] }, { timeoutMs: 100 }));
expect(result.success).toBe(false);
expect(result.failure).not.toBeNull();
expect(result.failure!.message).toContain("超时");
});
test("stdout 输出捕获", async () => {
const result = await runCommandCheck(makeTarget({ exec: "echo", args: ["hello world"] }));
expect(result.success).toBe(true);
});
test("stdout 匹配 expect", async () => {
const result = await runCommandCheck(
makeTarget({ exec: "echo", args: ["hello"] }, { expect: { stdout: [{ contains: "hello" }] } }),
);
expect(result.success).toBe(true);
expect(result.matched).toBe(true);
});
test("stdout 不匹配 expect", async () => {
const result = await runCommandCheck(
makeTarget({ exec: "echo", args: ["hello"] }, { expect: { stdout: [{ contains: "nonexistent" }] } }),
);
expect(result.success).toBe(false);
expect(result.failure!.phase).toBe("stdout");
});
test("stderr 匹配 expect", async () => {
const result = await runCommandCheck(
makeTarget({ exec: "bash", args: ["-c", "echo error >&2"] }, { expect: { stderr: [{ contains: "error" }] } }),
);
expect(result.success).toBe(true);
expect(result.matched).toBe(true);
});
test("输出超过 maxOutputBytes", async () => {
const result = await runCommandCheck(
makeTarget({
exec: "bash",
args: ["-c", "yes | head -1000"],
maxOutputBytes: 10,
}),
);
expect(result.success).toBe(false);
expect(result.failure).not.toBeNull();
expect(result.failure!.message).toContain("超过限制");
});
test("durationMs 非空", async () => {
const result = await runCommandCheck(makeTarget({ exec: "true", args: [] }));
expect(result.durationMs).not.toBeNull();
expect(result.durationMs!).toBeGreaterThanOrEqual(0);
});
test("ls 命令执行成功", async () => {
const result = await runCommandCheck(makeTarget({ exec: "ls", args: ["/tmp"] }));
expect(result.success).toBe(true);
expect(result.statusDetail).toBe("exitCode=0");
});
test("不使用 shell通配符不被展开", async () => {
const result = await runCommandCheck(
makeTarget({ exec: "echo", args: ["*"] }, { expect: { stdout: [{ contains: "*" }] } }),
);
expect(result.success).toBe(true);
});
test("不提供 stdin等待输入的命令会阻塞超时", async () => {
const result = await runCommandCheck(makeTarget({ exec: "bash", args: ["-c", "read line"] }, { timeoutMs: 500 }));
expect(result.success).toBe(false);
});
});

View File

@@ -55,6 +55,66 @@ describe("loadConfig", () => {
await rm(tempDir, { recursive: true, force: true });
});
test("解析最简 HTTP 配置", async () => {
const configPath = join(tempDir, "minimal-http.yaml");
await writeFile(
configPath,
`targets:
- name: "test"
type: http
http:
url: "http://example.com"
`,
);
const config = await loadConfig(configPath);
expect(config.host).toBe("127.0.0.1");
expect(config.port).toBe(3000);
expect(config.dataDir).toBe("./data");
expect(config.maxConcurrentChecks).toBe(20);
expect(config.targets).toHaveLength(1);
const t = config.targets[0]!;
expect(t.type).toBe("http");
if (t.type === "http") {
expect(t.name).toBe("test");
expect(t.http.url).toBe("http://example.com");
expect(t.http.method).toBe("GET");
expect(t.http.headers).toEqual({});
expect(t.http.maxBodyBytes).toBe(104857600);
expect(t.intervalMs).toBe(30000);
expect(t.timeoutMs).toBe(10000);
}
});
test("解析最简 command 配置", async () => {
const subdir = join(tempDir, "subdir");
await mkdir(subdir, { recursive: true });
const configPath = join(subdir, "cmd.yaml");
await writeFile(
configPath,
`targets:
- name: "check-nginx"
type: command
command:
exec: "pgrep"
args: ["nginx"]
`,
);
const config = await loadConfig(configPath);
expect(config.targets).toHaveLength(1);
const t = config.targets[0]!;
expect(t.type).toBe("command");
if (t.type === "command") {
expect(t.name).toBe("check-nginx");
expect(t.command.exec).toBe("pgrep");
expect(t.command.args).toEqual(["nginx"]);
expect(t.command.cwd).toBe(subdir);
expect(t.command.maxOutputBytes).toBe(104857600);
expect(t.command.env.PATH).toBeDefined();
}
});
test("解析完整配置", async () => {
const configPath = join(tempDir, "full.yaml");
await writeFile(
@@ -63,13 +123,36 @@ describe("loadConfig", () => {
host: "0.0.0.0"
port: 8080
dataDir: "./my-data"
runtime:
maxConcurrentChecks: 5
defaults:
interval: "15s"
timeout: "5s"
http:
method: "POST"
headers:
Authorization: "Bearer token"
maxBodyBytes: "50MB"
command:
cwd: "/tmp"
maxOutputBytes: "10MB"
targets:
- name: "test"
- name: "http-target"
type: http
interval: "1m"
http:
url: "http://example.com"
expect:
status: [200]
body:
- contains: "ok"
- name: "cmd-target"
type: command
command:
exec: "ls"
args: ["/tmp"]
expect:
exitCode: [0]
`,
);
@@ -77,39 +160,27 @@ targets:
expect(config.host).toBe("0.0.0.0");
expect(config.port).toBe(8080);
expect(config.dataDir).toBe("./my-data");
expect(config.targets).toHaveLength(1);
expect(config.targets[0]).toEqual({
name: "test",
url: "http://example.com",
method: "POST",
headers: {},
body: undefined,
intervalMs: 15000,
timeoutMs: 5000,
expect: undefined,
});
});
test("解析最简配置(只有 targets", async () => {
const configPath = join(tempDir, "minimal.yaml");
await writeFile(
configPath,
`targets:
- name: "t1"
url: "http://a.com"
- name: "t2"
url: "http://b.com"
interval: "1m"
`,
);
const config = await loadConfig(configPath);
expect(config.host).toBe("127.0.0.1");
expect(config.port).toBe(3000);
expect(config.dataDir).toBe("./data");
expect(config.maxConcurrentChecks).toBe(5);
expect(config.targets).toHaveLength(2);
expect(config.targets[0]!.intervalMs).toBe(30000);
expect(config.targets[1]!.intervalMs).toBe(60000);
const http = config.targets[0]!;
expect(http.type).toBe("http");
if (http.type === "http") {
expect(http.http.url).toBe("http://example.com");
expect(http.http.method).toBe("POST");
expect(http.http.headers).toEqual({ Authorization: "Bearer token" });
expect(http.http.maxBodyBytes).toBe(52428800);
expect(http.intervalMs).toBe(60000);
expect(http.timeoutMs).toBe(5000);
}
const cmd = config.targets[1]!;
expect(cmd.type).toBe("command");
if (cmd.type === "command") {
expect(cmd.command.exec).toBe("ls");
expect(cmd.command.args).toEqual(["/tmp"]);
expect(cmd.command.maxOutputBytes).toBe(10485760);
}
});
test("per-target 覆盖 defaults", async () => {
@@ -119,26 +190,29 @@ targets:
`defaults:
interval: "30s"
timeout: "10s"
http:
method: "GET"
headers:
Authorization: "Bearer token"
maxBodyBytes: "10MB"
targets:
- name: "override-all"
url: "http://example.com"
method: "POST"
type: http
interval: "5m"
timeout: "30s"
headers:
X-Custom: "value"
http:
url: "http://example.com"
method: "POST"
maxBodyBytes: "1MB"
`,
);
const config = await loadConfig(configPath);
const target = config.targets[0]!;
expect(target.method).toBe("POST");
expect(target.intervalMs).toBe(300000);
expect(target.timeoutMs).toBe(30000);
expect(target.headers).toEqual({ Authorization: "Bearer token", "X-Custom": "value" });
const t = config.targets[0]!;
if (t.type === "http") {
expect(t.http.method).toBe("POST");
expect(t.intervalMs).toBe(300000);
expect(t.timeoutMs).toBe(30000);
expect(t.http.maxBodyBytes).toBe(1048576);
}
});
test("配置文件不存在抛出错误", async () => {
@@ -150,23 +224,63 @@ targets:
await writeFile(
configPath,
`targets:
- url: "http://example.com"
- type: http
http:
url: "http://example.com"
`,
);
await expect(loadConfig(configPath)).rejects.toThrow("缺少 name 字段");
});
test("target 缺少 url 抛出错误", async () => {
test("target 缺少 type 抛出错误", async () => {
const configPath = join(tempDir, "no-type.yaml");
await writeFile(
configPath,
`targets:
- name: "test"
http:
url: "http://example.com"
`,
);
await expect(loadConfig(configPath)).rejects.toThrow("缺少 type 字段");
});
test("HTTP target 缺少 url 抛出错误", async () => {
const configPath = join(tempDir, "no-url.yaml");
await writeFile(
configPath,
`targets:
- name: "test"
type: http
http: {}
`,
);
await expect(loadConfig(configPath)).rejects.toThrow("缺少 http.url 字段");
});
await expect(loadConfig(configPath)).rejects.toThrow("缺少 url 字段");
test("command target 缺少 exec 抛出错误", async () => {
const configPath = join(tempDir, "no-exec.yaml");
await writeFile(
configPath,
`targets:
- name: "test"
type: command
command: {}
`,
);
await expect(loadConfig(configPath)).rejects.toThrow("缺少 command.exec 字段");
});
test("非法 target type 抛出错误", async () => {
const configPath = join(tempDir, "bad-type.yaml");
await writeFile(
configPath,
`targets:
- name: "test"
type: dns
`,
);
await expect(loadConfig(configPath)).rejects.toThrow("不支持的 type");
});
test("target name 重复抛出错误", async () => {
@@ -175,19 +289,21 @@ targets:
configPath,
`targets:
- name: "dup"
type: http
http:
url: "http://a.com"
- name: "dup"
type: http
http:
url: "http://b.com"
`,
);
await expect(loadConfig(configPath)).rejects.toThrow("target name 重复");
});
test("targets 为空数组抛出错误", async () => {
const configPath = join(tempDir, "empty-targets.yaml");
await writeFile(configPath, `targets: []`);
await expect(loadConfig(configPath)).rejects.toThrow("至少一个 target");
});
@@ -199,33 +315,168 @@ targets:
port: 99999
targets:
- name: "t"
type: http
http:
url: "http://a.com"
`,
);
await expect(loadConfig(configPath)).rejects.toThrow("无效端口号");
});
test("非法 maxConcurrentChecks 抛出错误", async () => {
const configPath = join(tempDir, "bad-concurrency.yaml");
await writeFile(
configPath,
`runtime:
maxConcurrentChecks: -1
targets:
- name: "t"
type: http
http:
url: "http://a.com"
`,
);
await expect(loadConfig(configPath)).rejects.toThrow("maxConcurrentChecks 必须为正整数");
});
test("非法 size 格式抛出错误", async () => {
const configPath = join(tempDir, "bad-size.yaml");
await writeFile(
configPath,
`defaults:
http:
maxBodyBytes: "100TB"
targets:
- name: "t"
type: http
http:
url: "http://a.com"
`,
);
await expect(loadConfig(configPath)).rejects.toThrow("无效的 size 格式");
});
test("非法 interval 格式抛出错误", async () => {
const configPath = join(tempDir, "bad-interval.yaml");
await writeFile(
configPath,
`targets:
- name: "t"
type: http
interval: "30x"
http:
url: "http://a.com"
`,
);
await expect(loadConfig(configPath)).rejects.toThrow("无效的时长格式");
});
test("解析 expect 配置", async () => {
const configPath = join(tempDir, "expect.yaml");
await writeFile(
configPath,
`targets:
- name: "with-expect"
type: http
http:
url: "http://example.com"
expect:
status: [200, 201]
body:
contains: "ok"
maxLatencyMs: 3000
- contains: "ok"
- json:
path: "$.status"
equals: "ok"
maxDurationMs: 3000
`,
);
const config = await loadConfig(configPath);
expect(config.targets[0]!.expect).toEqual({
const t = config.targets[0]!;
if (t.type === "http") {
expect(t.expect).toEqual({
status: [200, 201],
body: { contains: "ok" },
maxLatencyMs: 3000,
});
body: [{ contains: "ok" }, { json: { path: "$.status", equals: "ok" } }],
maxDurationMs: 3000,
});
}
});
test("解析 command expect 配置", async () => {
const configPath = join(tempDir, "cmd-expect.yaml");
await writeFile(
configPath,
`targets:
- name: "cmd-with-expect"
type: command
command:
exec: "mycheck"
expect:
exitCode: [0, 2]
stdout:
- contains: "ok"
- match: "done"
stderr:
- empty: true
maxDurationMs: 5000
`,
);
const config = await loadConfig(configPath);
const t = config.targets[0]!;
if (t.type === "command") {
expect(t.expect).toEqual({
exitCode: [0, 2],
stdout: [{ contains: "ok" }, { match: "done" }],
stderr: [{ empty: true }],
maxDurationMs: 5000,
});
}
});
test("command cwd 相对配置文件目录", async () => {
const subdir = join(tempDir, "cwd-test");
await mkdir(subdir, { recursive: true });
const configPath = join(subdir, "cwd.yaml");
await writeFile(
configPath,
`targets:
- name: "cwd-test"
type: command
command:
exec: "ls"
cwd: "scripts"
`,
);
const config = await loadConfig(configPath);
const t = config.targets[0]!;
if (t.type === "command") {
expect(t.command.cwd).toBe(join(subdir, "scripts"));
}
});
test("command env 覆盖", async () => {
const configPath = join(tempDir, "env.yaml");
await writeFile(
configPath,
`targets:
- name: "env-test"
type: command
command:
exec: "echo"
env:
LANG: "C"
CUSTOM_VAR: "test"
`,
);
const config = await loadConfig(configPath);
const t = config.targets[0]!;
if (t.type === "command") {
expect(t.command.env.LANG).toBe("C");
expect(t.command.env.CUSTOM_VAR).toBe("test");
expect(t.command.env.PATH).toBeDefined();
}
});
});

View File

@@ -1,96 +1,204 @@
import { afterAll, beforeAll, describe, expect, test } from "bun:test";
import { ProbeStore } from "../../../src/server/checker/store";
import { describe, expect, test } from "bun:test";
import { ProbeEngine } from "../../../src/server/checker/engine";
import type { ResolvedTarget } from "../../../src/server/checker/types";
import { mkdir, rm } from "node:fs/promises";
import { join } from "node:path";
import { tmpdir } from "node:os";
import type { ProbeStore } from "../../../src/server/checker/store";
import type { ResolvedCommandTarget, ResolvedHttpTarget, ResolvedTarget } from "../../../src/server/checker/types";
function createMockStore(targetNames: string[]) {
let nextId = 1;
const targets = targetNames.map((name) => ({ id: nextId++, name }));
const results: Array<Record<string, unknown>> = [];
return {
getTargets() {
return targets.map(({ id, name }) => ({
id,
name,
type: "command" as const,
target: "",
config: "",
interval_ms: 60000,
timeout_ms: 5000,
expect: null,
}));
},
insertCheckResult(result: Record<string, unknown>) {
results.push(result);
},
_results: results,
};
}
function makeCommandTarget(name: string, overrides?: Partial<ResolvedCommandTarget>): ResolvedCommandTarget {
return {
type: "command",
name,
command: {
exec: "echo",
args: ["hello"],
cwd: "/tmp",
env: {},
maxOutputBytes: 1024 * 1024,
},
intervalMs: 60000,
timeoutMs: 5000,
...overrides,
};
}
describe("ProbeEngine", () => {
let tempDir: string;
let store: ProbeStore;
const target: ResolvedTarget = {
name: "httpbin",
url: "https://httpbin.org/get",
method: "GET",
headers: {},
intervalMs: 60000,
timeoutMs: 10000,
};
beforeAll(async () => {
tempDir = join(tmpdir(), `gc-engine-test-${Date.now()}`);
await mkdir(tempDir, { recursive: true });
store = new ProbeStore(join(tempDir, "test.db"));
store.syncTargets([target]);
});
afterAll(async () => {
store.close();
await rm(tempDir, { recursive: true, force: true });
});
test("groupByInterval 分组逻辑", () => {
const targets: ResolvedTarget[] = [
{ name: "a", url: "http://a.com", method: "GET", headers: {}, intervalMs: 30000, timeoutMs: 10000 },
{ name: "b", url: "http://b.com", method: "GET", headers: {}, intervalMs: 30000, timeoutMs: 10000 },
{ name: "c", url: "http://c.com", method: "GET", headers: {}, intervalMs: 60000, timeoutMs: 10000 },
];
const engine = new ProbeEngine(store, targets);
engine.start();
engine.stop();
// 只要能启动和停止不出错就行
expect(true).toBe(true);
});
test("engine start/stop 不抛错", () => {
const engine = new ProbeEngine(store, [target]);
test("start/stop 不抛错", () => {
const mockStore = createMockStore(["test"]) as unknown as ProbeStore;
const targets: ResolvedTarget[] = [makeCommandTarget("test")];
const engine = new ProbeEngine(mockStore, targets);
engine.start();
engine.stop();
expect(true).toBe(true);
});
test("单次拨测写入数据库", async () => {
const engine = new ProbeEngine(store, [target]);
// 手动调用 probeGroup 不启动 timer
test("单次 probeGroup 执行 command 检查", async () => {
const target = makeCommandTarget("cmd-echo");
const mockStore = createMockStore(["cmd-echo"]) as unknown as ProbeStore;
const engine = new ProbeEngine(mockStore, [target]);
const probeGroup = (engine as unknown as { probeGroup: (t: ResolvedTarget[]) => Promise<void> }).probeGroup.bind(
engine,
);
await probeGroup([target]);
const dbTargets = store.getTargets();
const latest = store.getLatestCheck(dbTargets[0]!.id);
expect(latest).not.toBeNull();
expect(latest!.success === 1 || latest!.success === 0).toBe(true);
const results = (mockStore as unknown as { _results: Array<Record<string, unknown>> })._results;
expect(results.length).toBe(1);
expect(results[0]!.success).toBe(true);
expect(results[0]!.matched).toBe(true);
expect(results[0]!.statusDetail).toBe("exitCode=0");
});
test("单目标失败隔离", async () => {
const badTarget: ResolvedTarget = {
name: "bad-target",
url: "http://127.0.0.1:1/impossible",
method: "GET",
headers: {},
intervalMs: 60000,
timeoutMs: 2000,
};
test("多个目标并发执行", async () => {
const targetA = makeCommandTarget("echo-a", {
command: { exec: "echo", args: ["a"], cwd: "/tmp", env: {}, maxOutputBytes: 1024 * 1024 },
});
const targetB = makeCommandTarget("echo-b", {
command: { exec: "echo", args: ["b"], cwd: "/tmp", env: {}, maxOutputBytes: 1024 * 1024 },
});
store.syncTargets([target, badTarget]);
const mockStore = createMockStore(["echo-a", "echo-b"]) as unknown as ProbeStore;
const engine = new ProbeEngine(mockStore, [targetA, targetB]);
const engine = new ProbeEngine(store, [target, badTarget]);
const probeGroup = (engine as unknown as { probeGroup: (t: ResolvedTarget[]) => Promise<void> }).probeGroup.bind(
engine,
);
await probeGroup([target, badTarget]);
await probeGroup([targetA, targetB]);
const dbTargets = store.getTargets();
const goodResult = store.getLatestCheck(dbTargets.find((t) => t.name === "httpbin")!.id);
const badResult = store.getLatestCheck(dbTargets.find((t) => t.name === "bad-target")!.id);
const results = (mockStore as unknown as { _results: Array<Record<string, unknown>> })._results;
expect(results.length).toBe(2);
});
expect(goodResult).not.toBeNull();
expect(badResult).not.toBeNull();
expect(badResult!.success).toBe(0);
test("失败目标不阻塞其他目标", async () => {
const badTarget = makeCommandTarget("bad-cmd", {
command: { exec: "false", args: [], cwd: "/tmp", env: {}, maxOutputBytes: 1024 * 1024 },
});
const goodTarget = makeCommandTarget("good-cmd");
const mockStore = createMockStore(["bad-cmd", "good-cmd"]) as unknown as ProbeStore;
const engine = new ProbeEngine(mockStore, [badTarget, goodTarget]);
const probeGroup = (engine as unknown as { probeGroup: (t: ResolvedTarget[]) => Promise<void> }).probeGroup.bind(
engine,
);
await probeGroup([badTarget, goodTarget]);
const results = (mockStore as unknown as { _results: Array<Record<string, unknown>> })._results;
expect(results.length).toBe(2);
const badResult = results.find((r) => r.success === false);
const goodResult = results.find((r) => r.success === true);
expect(badResult).toBeDefined();
expect(goodResult).toBeDefined();
});
test("并发限制 maxConcurrentChecks", async () => {
const targets = Array.from({ length: 5 }, (_, i) =>
makeCommandTarget(`cmd-${i}`, {
command: { exec: "echo", args: [String(i)], cwd: "/tmp", env: {}, maxOutputBytes: 1024 * 1024 },
}),
);
const mockStore = createMockStore(targets.map((t) => t.name)) as unknown as ProbeStore;
const engine = new ProbeEngine(mockStore, targets, 2);
const probeGroup = (engine as unknown as { probeGroup: (t: ResolvedTarget[]) => Promise<void> }).probeGroup.bind(
engine,
);
await probeGroup(targets);
const results = (mockStore as unknown as { _results: Array<Record<string, unknown>> })._results;
expect(results.length).toBe(5);
for (const r of results) {
expect(r.success).toBe(true);
}
});
test("groupByInterval 按间隔分组", () => {
const targetA = makeCommandTarget("a", { intervalMs: 30000 });
const targetB = makeCommandTarget("b", { intervalMs: 30000 });
const targetC = makeCommandTarget("c", { intervalMs: 60000 });
const mockStore = createMockStore(["a", "b", "c"]) as unknown as ProbeStore;
const engine = new ProbeEngine(mockStore, [targetA, targetB, targetC]);
engine.start();
engine.stop();
expect(true).toBe(true);
});
test("未注册的 targetName 不写入结果", async () => {
const target = makeCommandTarget("unknown-target");
const mockStore = createMockStore(["other-name"]) as unknown as ProbeStore;
const engine = new ProbeEngine(mockStore, [target]);
const probeGroup = (engine as unknown as { probeGroup: (t: ResolvedTarget[]) => Promise<void> }).probeGroup.bind(
engine,
);
await probeGroup([target]);
const results = (mockStore as unknown as { _results: Array<Record<string, unknown>> })._results;
expect(results.length).toBe(0);
});
test("HTTP 目标运行", async () => {
const httpServer = Bun.serve({
port: 0,
fetch() {
return new Response("ok");
},
});
try {
const httpTarget: ResolvedHttpTarget = {
type: "http",
name: "http-test",
http: {
url: `http://localhost:${httpServer.port}/`,
method: "GET",
headers: {},
maxBodyBytes: 1024 * 1024,
},
intervalMs: 60000,
timeoutMs: 5000,
};
const mockStore = createMockStore(["http-test"]) as unknown as ProbeStore;
const engine = new ProbeEngine(mockStore, [httpTarget]);
const probeGroup = (engine as unknown as { probeGroup: (t: ResolvedTarget[]) => Promise<void> }).probeGroup.bind(
engine,
);
await probeGroup([httpTarget]);
const results = (mockStore as unknown as { _results: Array<Record<string, unknown>> })._results;
expect(results.length).toBe(1);
expect(results[0]!.success).toBe(true);
expect(results[0]!.statusDetail).toBe("HTTP 200");
} finally {
httpServer.stop();
}
});
});

View File

@@ -1,5 +1,10 @@
import { describe, expect, test } from "bun:test";
import { applyOperator, checkBodyExpect, evaluateJsonPath } from "../../../src/server/checker/body-expect";
import {
applyOperator,
checkBodyExpect,
checkExpectValue,
evaluateJsonPath,
} from "../../../../src/server/checker/expect/body";
describe("evaluateJsonPath", () => {
const obj = {
@@ -125,106 +130,158 @@ describe("applyOperator", () => {
});
});
describe("checkBodyExpect", () => {
test("无 body config 返回 true", () => {
expect(checkBodyExpect("anything", undefined)).toBe(true);
describe("checkExpectValue", () => {
test("原始值直接比较", () => {
expect(checkExpectValue("ok", "ok")).toBe(true);
expect(checkExpectValue("ok", "error")).toBe(false);
expect(checkExpectValue(42, 42)).toBe(true);
expect(checkExpectValue(null, null)).toBe(true);
});
test("contains 匹配", () => {
expect(checkBodyExpect("hello world", { contains: "hello" })).toBe(true);
expect(checkBodyExpect("hello world", { contains: "missing" })).toBe(false);
test("对象作为操作符", () => {
expect(checkExpectValue(42, { gte: 10 })).toBe(true);
expect(checkExpectValue(42, { gte: 100 })).toBe(false);
expect(checkExpectValue("hello", { contains: "ell" })).toBe(true);
});
});
test("regex 匹配", () => {
expect(checkBodyExpect("status: ok", { regex: "ok" })).toBe(true);
expect(checkBodyExpect("status: error", { regex: "ok" })).toBe(false);
describe("checkBodyExpect (BodyRule[])", () => {
test("无规则返回匹配成功", () => {
const r = checkBodyExpect("anything");
expect(r.matched).toBe(true);
expect(r.failure).toBeNull();
});
test("json 简单等值匹配", () => {
test("空规则数组返回匹配成功", () => {
const r = checkBodyExpect("anything", []);
expect(r.matched).toBe(true);
expect(r.failure).toBeNull();
});
test("contains 规则匹配成功", () => {
const r = checkBodyExpect("hello world", [{ contains: "hello" }]);
expect(r.matched).toBe(true);
expect(r.failure).toBeNull();
});
test("contains 规则匹配失败", () => {
const r = checkBodyExpect("hello world", [{ contains: "missing" }]);
expect(r.matched).toBe(false);
expect(r.failure).not.toBeNull();
expect(r.failure!.kind).toBe("mismatch");
expect(r.failure!.phase).toBe("body");
expect(r.failure!.path).toBe("body[0]");
});
test("regex 规则匹配成功", () => {
const r = checkBodyExpect("status: ok", [{ regex: "ok" }]);
expect(r.matched).toBe(true);
});
test("regex 规则匹配失败", () => {
const r = checkBodyExpect("status: error", [{ regex: "^ok$" }]);
expect(r.matched).toBe(false);
expect(r.failure!.path).toBe("body[0]");
});
test("json 等值匹配成功", () => {
const body = JSON.stringify({ status: "ok", code: 0 });
expect(checkBodyExpect(body, { json: { "$.status": "ok" } })).toBe(true);
expect(checkBodyExpect(body, { json: { "$.code": 0 } })).toBe(true);
expect(checkBodyExpect(body, { json: { "$.status": "error" } })).toBe(false);
const r = checkBodyExpect(body, [{ json: { path: "$.status", equals: "ok" } }]);
expect(r.matched).toBe(true);
});
test("json 等值匹配失败", () => {
const body = JSON.stringify({ status: "ok" });
const r = checkBodyExpect(body, [{ json: { path: "$.status", equals: "error" } }]);
expect(r.matched).toBe(false);
expect(r.failure!.kind).toBe("mismatch");
});
test("json 操作符匹配", () => {
const body = JSON.stringify({ count: 42, version: "v2.1.0", message: "success" });
expect(checkBodyExpect(body, { json: { "$.count": { gte: 10 } } })).toBe(true);
expect(checkBodyExpect(body, { json: { "$.version": { match: "\\d+\\.\\d+\\.\\d+" } } })).toBe(true);
expect(checkBodyExpect(body, { json: { "$.message": { contains: "success" } } })).toBe(true);
expect(checkBodyExpect(body, { json: { "$.count": { gte: 100 } } })).toBe(false);
const body = JSON.stringify({ count: 42, version: "v2.1.0" });
expect(checkBodyExpect(body, [{ json: { path: "$.count", gte: 10 } }]).matched).toBe(true);
expect(checkBodyExpect(body, [{ json: { path: "$.version", match: "\\d+\\.\\d+\\.\\d+" } }]).matched).toBe(true);
expect(checkBodyExpect(body, [{ json: { path: "$.count", gte: 100 } }]).matched).toBe(false);
});
test("json 路径不存在", () => {
const body = JSON.stringify({ status: "ok" });
expect(checkBodyExpect(body, { json: { "$.notExist": "value" } })).toBe(false);
const r = checkBodyExpect(body, [{ json: { path: "$.notExist", equals: "value" } }]);
expect(r.matched).toBe(false);
});
test("json 解析失败", () => {
expect(checkBodyExpect("not json", { json: { "$.status": "ok" } })).toBe(false);
const r = checkBodyExpect("not json", [{ json: { path: "$.status", equals: "ok" } }]);
expect(r.matched).toBe(false);
expect(r.failure!.kind).toBe("error");
});
test("css textContent 匹配", () => {
test("css 文本内容匹配", () => {
const html = "<div id='health'>OK</div><span class='ver'>1.0</span>";
expect(checkBodyExpect(html, { css: { "div#health": "OK" } })).toBe(true);
expect(checkBodyExpect(html, { css: { "span.ver": "1.0" } })).toBe(true);
expect(checkBodyExpect(html, { css: { "div#health": "ERROR" } })).toBe(false);
expect(checkBodyExpect(html, [{ css: { selector: "div#health", equals: "OK" } }]).matched).toBe(true);
expect(checkBodyExpect(html, [{ css: { selector: "span.ver", equals: "1.0" } }]).matched).toBe(true);
expect(checkBodyExpect(html, [{ css: { selector: "div#health", equals: "ERROR" } }]).matched).toBe(false);
});
test("css 选择器无匹配元素", () => {
const html = "<div>OK</div>";
expect(checkBodyExpect(html, { css: { "span.missing": "OK" } })).toBe(false);
const r = checkBodyExpect(html, [{ css: { selector: "span.missing", equals: "OK" } }]);
expect(r.matched).toBe(false);
});
test("css attr 提取", () => {
const html = '<meta name="version" content="2.0.1"><link rel="icon" href="/favicon.ico">';
expect(checkBodyExpect(html, { css: { 'meta[name="version"]': { attr: "content", equals: "2.0.1" } } })).toBe(true);
const html = '<meta name="version" content="2.0.1">';
expect(
checkBodyExpect(html, { css: { 'meta[name="version"]': { attr: "content", match: "\\d+\\.\\d+\\.\\d+" } } }),
checkBodyExpect(html, [{ css: { selector: 'meta[name="version"]', attr: "content", equals: "2.0.1" } }]).matched,
).toBe(true);
expect(
checkBodyExpect(html, [
{ css: { selector: 'meta[name="version"]', attr: "content", match: "\\d+\\.\\d+\\.\\d+" } },
]).matched,
).toBe(true);
expect(checkBodyExpect(html, { css: { 'link[rel="icon"]': { attr: "href", contains: "favicon" } } })).toBe(true);
});
test("css exists 检查", () => {
const html = "<div id='test'>OK</div>";
expect(checkBodyExpect(html, { css: { "div#test": { exists: true } } })).toBe(true);
expect(checkBodyExpect(html, { css: { "span#missing": { exists: false } } })).toBe(true);
expect(checkBodyExpect(html, { css: { "div#test": { exists: false } } })).toBe(false);
expect(checkBodyExpect(html, [{ css: { selector: "div#test", exists: true } }]).matched).toBe(true);
expect(checkBodyExpect(html, [{ css: { selector: "span#missing", exists: false } }]).matched).toBe(true);
expect(checkBodyExpect(html, [{ css: { selector: "div#test", exists: false } }]).matched).toBe(false);
});
test("xpath 节点文本匹配", () => {
const xml = "<root><status>ok</status><code>200</code></root>";
expect(checkBodyExpect(xml, { xpath: { "/root/status/text()": "ok" } })).toBe(true);
expect(checkBodyExpect(xml, { xpath: { "/root/status/text()": "error" } })).toBe(false);
expect(checkBodyExpect(xml, [{ xpath: { path: "/root/status/text()", equals: "ok" } }]).matched).toBe(true);
expect(checkBodyExpect(xml, [{ xpath: { path: "/root/status/text()", equals: "error" } }]).matched).toBe(false);
});
test("xpath 无匹配节点", () => {
const xml = "<root><status>ok</status></root>";
expect(checkBodyExpect(xml, { xpath: { "/root/missing/text()": "ok" } })).toBe(false);
const r = checkBodyExpect(xml, [{ xpath: { path: "/root/missing/text()", equals: "ok" } }]);
expect(r.matched).toBe(false);
});
test("xpath 包含匹配", () => {
const html = "<html><body><div id='msg'>success</div></body></html>";
expect(checkBodyExpect(html, { xpath: { "//div[@id='msg']/text()": "success" } })).toBe(true);
});
test("多种 body 方法 AND 组合", () => {
const body = JSON.stringify({ status: "healthy", count: 5 });
expect(
checkBodyExpect(body, {
contains: "healthy",
json: { "$.status": "healthy", "$.count": { gte: 1 } },
}),
).toBe(true);
});
test("多种 body 方法部分失败", () => {
test("规则数组按顺序检查,第一条失败立即返回", () => {
const body = JSON.stringify({ status: "error" });
expect(
checkBodyExpect(body, {
contains: "healthy",
json: { "$.status": "error" },
}),
).toBe(false);
const r = checkBodyExpect(body, [{ contains: "healthy" }, { json: { path: "$.status", equals: "error" } }]);
expect(r.matched).toBe(false);
expect(r.failure!.path).toBe("body[0]");
});
test("多条规则全部通过", () => {
const body = JSON.stringify({ status: "healthy", count: 5 });
const r = checkBodyExpect(body, [
{ contains: "healthy" },
{ json: { path: "$.status", equals: "healthy" } },
{ json: { path: "$.count", gte: 1 } },
]);
expect(r.matched).toBe(true);
expect(r.failure).toBeNull();
});
test("第二条规则失败返回正确索引", () => {
const body = JSON.stringify({ status: "ok" });
const r = checkBodyExpect(body, [{ contains: "ok" }, { json: { path: "$.status", equals: "error" } }]);
expect(r.matched).toBe(false);
expect(r.failure!.path).toContain("body[1]");
});
});

View File

@@ -0,0 +1,168 @@
import { describe, expect, test } from "bun:test";
import { checkCommandExpect } from "../../../../src/server/checker/expect/command";
import type { CommandObservation } from "../../../../src/server/checker/expect/command";
import type { CommandExpectConfig } from "../../../../src/server/checker/types";
function obs(overrides: Partial<CommandObservation> = {}): CommandObservation {
return {
exitCode: 0,
stdout: "",
stderr: "",
durationMs: 100,
...overrides,
};
}
describe("checkCommandExpect", () => {
test("无 expect 配置时默认检查 exitCode [0] 匹配成功", () => {
const r = checkCommandExpect(obs());
expect(r.matched).toBe(true);
expect(r.failure).toBeNull();
});
test("无 expect 配置时 exitCode 非 0 匹配失败", () => {
const r = checkCommandExpect(obs({ exitCode: 1 }));
expect(r.matched).toBe(false);
expect(r.failure).not.toBeNull();
expect(r.failure!.phase).toBe("exitCode");
expect(r.failure!.kind).toBe("mismatch");
});
test("exitCode 匹配指定退出码", () => {
const cfg: CommandExpectConfig = { exitCode: [0, 1] };
expect(checkCommandExpect(obs({ exitCode: 0 }), cfg).matched).toBe(true);
expect(checkCommandExpect(obs({ exitCode: 1 }), cfg).matched).toBe(true);
expect(checkCommandExpect(obs({ exitCode: 2 }), cfg).matched).toBe(false);
});
test("exitCode 不匹配返回 phase=exitCode 的失败", () => {
const r = checkCommandExpect(obs({ exitCode: 2 }), { exitCode: [0] });
expect(r.matched).toBe(false);
expect(r.failure!.phase).toBe("exitCode");
expect(r.failure!.expected).toEqual([0]);
expect(r.failure!.actual).toBe(2);
});
test("duration 在限制内匹配成功", () => {
const r = checkCommandExpect(obs({ durationMs: 50 }), { maxDurationMs: 100 });
expect(r.matched).toBe(true);
});
test("duration 超过限制匹配失败", () => {
const r = checkCommandExpect(obs({ durationMs: 200 }), { maxDurationMs: 100 });
expect(r.matched).toBe(false);
expect(r.failure!.phase).toBe("duration");
});
test("stdout TextRule 数组匹配", () => {
const o = obs({ stdout: "build completed successfully" });
expect(checkCommandExpect(o, { stdout: [{ contains: "completed" }] }).matched).toBe(true);
expect(checkCommandExpect(o, { stdout: [{ contains: "failed" }] }).matched).toBe(false);
expect(checkCommandExpect(o, { stdout: [{ match: "completed.*successfully$" }] }).matched).toBe(true);
});
test("stdout 多条规则全部通过", () => {
const o = obs({ stdout: "version: 3.2.1, build: ok" });
const r = checkCommandExpect(o, {
stdout: [{ contains: "version" }, { match: "\\d+\\.\\d+\\.\\d+" }],
});
expect(r.matched).toBe(true);
});
test("stdout 第一条规则失败立即返回", () => {
const o = obs({ stdout: "error occurred" });
const r = checkCommandExpect(o, {
stdout: [{ contains: "success" }, { contains: "error" }],
});
expect(r.matched).toBe(false);
expect(r.failure!.phase).toBe("stdout");
expect(r.failure!.path).toBe("stdout[0]");
});
test("stderr TextRule 数组匹配", () => {
const o = obs({ stderr: "warning: deprecated" });
expect(checkCommandExpect(o, { stderr: [{ contains: "warning" }] }).matched).toBe(true);
expect(checkCommandExpect(o, { stderr: [{ contains: "error" }] }).matched).toBe(false);
});
test("stdout 失败阻止 stderr 检查", () => {
const o = obs({ stdout: "bad output", stderr: "warning message" });
const r = checkCommandExpect(o, {
exitCode: [0],
stdout: [{ contains: "success" }],
stderr: [{ contains: "warning" }],
});
expect(r.matched).toBe(false);
expect(r.failure!.phase).toBe("stdout");
});
test("stdout 通过但 stderr 失败", () => {
const o = obs({ stdout: "ok", stderr: "fatal error" });
const r = checkCommandExpect(o, {
stdout: [{ contains: "ok" }],
stderr: [{ equals: "clean" }],
});
expect(r.matched).toBe(false);
expect(r.failure!.phase).toBe("stderr");
});
test("完整流水线 exitCode->duration->stdout->stderr 全部通过", () => {
const o = obs({
exitCode: 0,
durationMs: 50,
stdout: "build success",
stderr: "",
});
const r = checkCommandExpect(o, {
exitCode: [0],
maxDurationMs: 100,
stdout: [{ contains: "success" }],
stderr: [{ empty: true }],
});
expect(r.matched).toBe(true);
expect(r.failure).toBeNull();
});
test("完整流水线 exitCode 通过但 duration 失败", () => {
const o = obs({ exitCode: 0, durationMs: 500 });
const r = checkCommandExpect(o, {
exitCode: [0],
maxDurationMs: 100,
stdout: [{ contains: "ok" }],
});
expect(r.matched).toBe(false);
expect(r.failure!.phase).toBe("duration");
});
test("完整流水线 exitCode/duration 通过但 stdout 失败", () => {
const o = obs({ exitCode: 0, durationMs: 50, stdout: "error" });
const r = checkCommandExpect(o, {
exitCode: [0],
maxDurationMs: 100,
stdout: [{ contains: "success" }],
});
expect(r.matched).toBe(false);
expect(r.failure!.phase).toBe("stdout");
});
test("完整流水线 exitCode/duration/stdout 通过但 stderr 失败", () => {
const o = obs({ exitCode: 0, durationMs: 50, stdout: "ok", stderr: "warning" });
const r = checkCommandExpect(o, {
exitCode: [0],
maxDurationMs: 100,
stdout: [{ contains: "ok" }],
stderr: [{ empty: true }],
});
expect(r.matched).toBe(false);
expect(r.failure!.phase).toBe("stderr");
});
test("stdout 操作符组合", () => {
const o = obs({ stdout: "count: 42" });
expect(
checkCommandExpect(o, {
stdout: [{ contains: "count" }, { match: "\\d+" }],
}).matched,
).toBe(true);
});
});

View File

@@ -0,0 +1,79 @@
import { describe, expect, test } from "bun:test";
import { truncateActual, mismatchFailure, errorFailure } from "../../../../src/server/checker/expect/failure";
describe("truncateActual", () => {
test("短字符串不截断", () => {
expect(truncateActual("hello")).toBe("hello");
});
test("恰好等于限制长度不截断", () => {
const str = "a".repeat(200);
expect(truncateActual(str)).toBe(str);
});
test("超过限制长度截断并加省略号", () => {
const str = "a".repeat(300);
const result = truncateActual(str) as string;
expect(result.length).toBe(203);
expect(result.endsWith("...")).toBe(true);
expect(result.startsWith("a".repeat(200))).toBe(true);
});
test("自定义最大长度", () => {
const str = "abcdefghij";
const result = truncateActual(str, 5) as string;
expect(result).toBe("abcde...");
});
test("null 不截断", () => {
expect(truncateActual(null)).toBe(null);
});
test("undefined 不截断", () => {
expect(truncateActual(undefined)).toBe(undefined);
});
test("数字转换为字符串后判断", () => {
expect(truncateActual(42)).toBe(42);
expect(truncateActual(123456789, 3) as string).toBe("123...");
});
});
describe("mismatchFailure", () => {
test("返回正确的 mismatch 结构", () => {
const f = mismatchFailure("status", "status", [200], 500, "status mismatch");
expect(f).toEqual({
kind: "mismatch",
phase: "status",
path: "status",
expected: [200],
actual: 500,
message: "status mismatch",
});
});
test("自动截断过长的 actual", () => {
const longStr = "x".repeat(300);
const f = mismatchFailure("body", "body[0]", "short", longStr, "too long");
expect((f.actual as string).endsWith("...")).toBe(true);
expect((f.actual as string).length).toBe(203);
});
});
describe("errorFailure", () => {
test("返回正确的 error 结构", () => {
const f = errorFailure("body", "body[0].json($.x)", "body is not valid JSON");
expect(f).toEqual({
kind: "error",
phase: "body",
path: "body[0].json($.x)",
message: "body is not valid JSON",
});
});
test("不含 expected 和 actual 字段", () => {
const f = errorFailure("headers", "headers.x", "header missing");
expect(f).not.toHaveProperty("expected");
expect(f).not.toHaveProperty("actual");
});
});

View File

@@ -0,0 +1,165 @@
import { describe, expect, test } from "bun:test";
import { checkHttpExpect } from "../../../../src/server/checker/expect/http";
import type { HttpObservation } from "../../../../src/server/checker/expect/http";
import type { HttpExpectConfig } from "../../../../src/server/checker/types";
function obs(overrides: Partial<HttpObservation> = {}): HttpObservation {
return {
statusCode: 200,
headers: {},
body: "",
durationMs: 100,
...overrides,
};
}
describe("checkHttpExpect", () => {
test("无 expect 配置时默认检查 status [200] 匹配成功", () => {
const r = checkHttpExpect(obs());
expect(r.matched).toBe(true);
expect(r.failure).toBeNull();
});
test("无 expect 配置时 status 非 200 匹配失败", () => {
const r = checkHttpExpect(obs({ statusCode: 500 }));
expect(r.matched).toBe(false);
expect(r.failure).not.toBeNull();
expect(r.failure!.phase).toBe("status");
expect(r.failure!.kind).toBe("mismatch");
});
test("status 匹配指定状态码", () => {
const cfg: HttpExpectConfig = { status: [200, 301] };
expect(checkHttpExpect(obs({ statusCode: 200 }), cfg).matched).toBe(true);
expect(checkHttpExpect(obs({ statusCode: 301 }), cfg).matched).toBe(true);
expect(checkHttpExpect(obs({ statusCode: 404 }), cfg).matched).toBe(false);
});
test("status 不匹配返回 phase=status 的失败", () => {
const r = checkHttpExpect(obs({ statusCode: 503 }), { status: [200] });
expect(r.matched).toBe(false);
expect(r.failure!.phase).toBe("status");
expect(r.failure!.expected).toEqual([200]);
expect(r.failure!.actual).toBe(503);
});
test("duration 在限制内匹配成功", () => {
const r = checkHttpExpect(obs({ durationMs: 50 }), { maxDurationMs: 100 });
expect(r.matched).toBe(true);
});
test("duration 超过限制匹配失败", () => {
const r = checkHttpExpect(obs({ durationMs: 200 }), { maxDurationMs: 100 });
expect(r.matched).toBe(false);
expect(r.failure!.phase).toBe("duration");
});
test("duration 恰好等于限制匹配成功", () => {
const r = checkHttpExpect(obs({ durationMs: 100 }), { maxDurationMs: 100 });
expect(r.matched).toBe(true);
});
test("headers 字符串格式检查(等于)", () => {
const o = obs({ headers: { "content-type": "application/json", "x-api": "v1" } });
expect(checkHttpExpect(o, { headers: { "content-type": "application/json" } }).matched).toBe(true);
expect(checkHttpExpect(o, { headers: { "content-type": "text/html" } }).matched).toBe(false);
});
test("headers 操作符格式检查", () => {
const o = obs({ headers: { "content-type": "application/json" } });
expect(checkHttpExpect(o, { headers: { "content-type": { contains: "json" } } }).matched).toBe(true);
expect(checkHttpExpect(o, { headers: { "content-type": { match: "^application/" } } }).matched).toBe(true);
expect(checkHttpExpect(o, { headers: { "content-type": { contains: "xml" } } }).matched).toBe(false);
});
test("headers 大小写不敏感匹配", () => {
const o = obs({ headers: { "content-type": "application/json" } });
expect(checkHttpExpect(o, { headers: { "Content-Type": "application/json" } }).matched).toBe(true);
});
test("headers 不存在时返回失败", () => {
const o = obs({ headers: {} });
const r = checkHttpExpect(o, { headers: { "x-missing": "value" } });
expect(r.matched).toBe(false);
expect(r.failure!.phase).toBe("headers");
});
test("body 规则数组按顺序检查", () => {
const o = obs({ body: JSON.stringify({ status: "ok", count: 5 }) });
const r = checkHttpExpect(o, {
body: [{ contains: "ok" }, { json: { path: "$.count", gte: 1 } }],
});
expect(r.matched).toBe(true);
});
test("body 第一条规则失败立即返回", () => {
const o = obs({ body: "hello world" });
const r = checkHttpExpect(o, {
body: [{ contains: "missing" }, { contains: "hello" }],
});
expect(r.matched).toBe(false);
expect(r.failure!.path).toBe("body[0]");
});
test("body 为 null 但有 body 规则时报错", () => {
const o = obs({ body: null });
const r = checkHttpExpect(o, { body: [{ contains: "test" }] });
expect(r.matched).toBe(false);
expect(r.failure!.kind).toBe("error");
});
test("完整流水线 status->duration->headers->body 全部通过", () => {
const o = obs({
statusCode: 200,
headers: { "content-type": "application/json" },
body: JSON.stringify({ status: "healthy" }),
durationMs: 50,
});
const r = checkHttpExpect(o, {
status: [200],
maxDurationMs: 100,
headers: { "content-type": { contains: "json" } },
body: [{ json: { path: "$.status", equals: "healthy" } }],
});
expect(r.matched).toBe(true);
expect(r.failure).toBeNull();
});
test("完整流水线 status 通过但 duration 失败", () => {
const o = obs({ statusCode: 200, durationMs: 500 });
const r = checkHttpExpect(o, {
status: [200],
maxDurationMs: 100,
});
expect(r.matched).toBe(false);
expect(r.failure!.phase).toBe("duration");
});
test("完整流水线 status 和 duration 通过但 headers 失败", () => {
const o = obs({ statusCode: 200, durationMs: 50, headers: { "x-api": "v1" } });
const r = checkHttpExpect(o, {
status: [200],
maxDurationMs: 100,
headers: { "x-api": "v2" },
});
expect(r.matched).toBe(false);
expect(r.failure!.phase).toBe("headers");
});
test("完整流水线 status/duration/headers 通过但 body 失败", () => {
const o = obs({
statusCode: 200,
durationMs: 50,
headers: { "content-type": "text/plain" },
body: "error occurred",
});
const r = checkHttpExpect(o, {
status: [200],
maxDurationMs: 100,
headers: { "content-type": "text/plain" },
body: [{ contains: "success" }],
});
expect(r.matched).toBe(false);
expect(r.failure!.phase).toBe("body");
});
});

View File

@@ -1,118 +1,258 @@
import { describe, expect, test } from "bun:test";
import { checkExpect } from "../../../src/server/checker/fetcher";
import { afterAll, beforeAll, describe, expect, test } from "bun:test";
import { runHttpCheck } from "../../../src/server/checker/fetcher";
const emptyHeaders: Record<string, string> = {};
describe("checkExpect", () => {
test("无 expect 配置时 matched 为 true", () => {
expect(checkExpect(200, "ok", 100, emptyHeaders, undefined)).toBe(true);
describe("runHttpCheck", () => {
test("checkExpect 已移除", async () => {
const mod = await import("../../../src/server/checker/fetcher");
expect((mod as Record<string, unknown>).checkExpect).toBeUndefined();
expect((mod as Record<string, unknown>).fetchTarget).toBeUndefined();
});
});
test("status 匹配", () => {
expect(checkExpect(200, "", 100, emptyHeaders, { status: [200, 201] })).toBe(true);
expect(checkExpect(201, "", 100, emptyHeaders, { status: [200, 201] })).toBe(true);
expect(checkExpect(404, "", 100, emptyHeaders, { status: [200, 201] })).toBe(false);
});
describe("runHttpCheck 集成", () => {
let server: ReturnType<typeof Bun.serve>;
let baseUrl: string;
test("headers 匹配", () => {
const headers = { "content-type": "application/json", "x-custom": "test" };
expect(checkExpect(200, "", 100, headers, { headers: { "Content-Type": "application/json" } })).toBe(true);
expect(checkExpect(200, "", 100, headers, { headers: { "Content-Type": "text/html" } })).toBe(false);
expect(checkExpect(200, "", 100, headers, { headers: { "X-Missing": "test" } })).toBe(false);
beforeAll(() => {
server = Bun.serve({
port: 0,
fetch(req) {
const url = new URL(req.url);
switch (url.pathname) {
case "/ok":
return new Response("hello world", {
headers: { "content-type": "text/plain", "x-custom": "test-value" },
});
test("body.contains 匹配", () => {
expect(checkExpect(200, "hello world", 100, emptyHeaders, { body: { contains: "hello" } })).toBe(true);
expect(checkExpect(200, "hello world", 100, emptyHeaders, { body: { contains: "missing" } })).toBe(false);
case "/json":
return new Response(JSON.stringify({ status: "ok" }), {
headers: { "content-type": "application/json" },
});
test("body.regex 匹配", () => {
expect(checkExpect(200, "status: ok", 100, emptyHeaders, { body: { regex: "status.*ok" } })).toBe(true);
expect(checkExpect(200, "status: error", 100, emptyHeaders, { body: { regex: "status.*ok" } })).toBe(false);
case "/echo":
return new Response(JSON.stringify({ method: req.method, body: req.body ? "present" : "empty" }), {
headers: { "content-type": "application/json" },
});
test("body.json 匹配", () => {
expect(
checkExpect(200, JSON.stringify({ status: "ok" }), 100, emptyHeaders, { body: { json: { "$.status": "ok" } } }),
).toBe(true);
expect(
checkExpect(200, JSON.stringify({ status: "error" }), 100, emptyHeaders, {
body: { json: { "$.status": "ok" } },
}),
).toBe(false);
});
test("body.json 解析失败", () => {
expect(checkExpect(200, "not json", 100, emptyHeaders, { body: { json: { "$.status": "ok" } } })).toBe(false);
});
test("body 多种方法 AND 组合", () => {
expect(
checkExpect(200, "healthy", 100, emptyHeaders, {
body: {
contains: "healthy",
regex: "healthy",
case "/large":
return new Response("x".repeat(2000));
case "/notfound":
return new Response("not found", { status: 404 });
case "/slow":
return new Response("slow", { status: 200 });
default:
return new Response("ok");
}
},
}),
).toBe(true);
});
baseUrl = `http://localhost:${server.port}`;
});
expect(
checkExpect(200, "healthy", 100, emptyHeaders, {
body: {
contains: "healthy",
regex: "unhealthy",
afterAll(() => {
server.stop();
});
function makeTarget(overrides: {
url?: string;
method?: string;
body?: string;
headers?: Record<string, string>;
expect?: Record<string, unknown>;
maxBodyBytes?: number;
timeoutMs?: number;
}) {
return {
type: "http" as const,
name: "test-http",
http: {
url: overrides.url ?? `${baseUrl}/ok`,
method: overrides.method ?? "GET",
headers: overrides.headers ?? ({} as Record<string, string>),
body: overrides.body,
maxBodyBytes: overrides.maxBodyBytes ?? 1024 * 1024,
},
}),
).toBe(false);
intervalMs: 60000,
timeoutMs: overrides.timeoutMs ?? 5000,
expect: overrides.expect as import("../../../src/server/checker/types").HttpExpectConfig | undefined,
};
}
test("成功请求 200", async () => {
const result = await runHttpCheck(makeTarget({ url: `${baseUrl}/ok` }));
expect(result.success).toBe(true);
expect(result.matched).toBe(true);
expect(result.statusDetail).toBe("HTTP 200");
expect(result.durationMs).not.toBeNull();
expect(result.failure).toBeNull();
});
test("maxLatencyMs 匹配", () => {
expect(checkExpect(200, "", 100, emptyHeaders, { maxLatencyMs: 200 })).toBe(true);
expect(checkExpect(200, "", 300, emptyHeaders, { maxLatencyMs: 200 })).toBe(false);
expect(checkExpect(200, "", 200, emptyHeaders, { maxLatencyMs: 200 })).toBe(true);
test("404 不匹配默认 status [200]", async () => {
const result = await runHttpCheck(makeTarget({ url: `${baseUrl}/notfound` }));
expect(result.success).toBe(false);
expect(result.matched).toBe(false);
expect(result.statusDetail).toBe("HTTP 404");
expect(result.failure).not.toBeNull();
expect(result.failure!.phase).toBe("status");
});
test("多条 expect 全部通过", () => {
expect(
checkExpect(200, "healthy", 100, emptyHeaders, {
status: [200],
body: { contains: "healthy" },
maxLatencyMs: 200,
test("404 匹配自定义 status [404]", async () => {
const result = await runHttpCheck(
makeTarget({
url: `${baseUrl}/notfound`,
expect: { status: [404] },
}),
).toBe(true);
);
expect(result.success).toBe(true);
expect(result.matched).toBe(true);
});
test("多条 expect 部分失败", () => {
expect(
checkExpect(200, "healthy", 500, emptyHeaders, {
status: [200],
body: { contains: "healthy" },
maxLatencyMs: 200,
test("headers 检查通过", async () => {
const result = await runHttpCheck(
makeTarget({
url: `${baseUrl}/ok`,
expect: { headers: { "x-custom": "test-value" } },
}),
).toBe(false);
);
expect(result.success).toBe(true);
expect(result.matched).toBe(true);
});
test("status + headers + body + maxLatencyMs 全组合", () => {
const headers = { "content-type": "application/json" };
expect(
checkExpect(200, JSON.stringify({ status: "ok" }), 100, headers, {
status: [200],
headers: { "Content-Type": "application/json" },
body: { contains: "ok", json: { "$.status": "ok" } },
maxLatencyMs: 200,
test("headers 检查失败", async () => {
const result = await runHttpCheck(
makeTarget({
url: `${baseUrl}/ok`,
expect: { headers: { "x-custom": "wrong-value" } },
}),
).toBe(true);
);
expect(result.success).toBe(false);
expect(result.failure!.phase).toBe("headers");
});
test("全组合中 headers 失败", () => {
const headers = { "content-type": "text/html" };
expect(
checkExpect(200, JSON.stringify({ status: "ok" }), 100, headers, {
status: [200],
headers: { "Content-Type": "application/json" },
body: { contains: "ok", json: { "$.status": "ok" } },
maxLatencyMs: 200,
test("body contains 检查", async () => {
const result = await runHttpCheck(
makeTarget({
url: `${baseUrl}/ok`,
expect: { body: [{ contains: "hello" }] },
}),
).toBe(false);
);
expect(result.success).toBe(true);
expect(result.matched).toBe(true);
});
test("body contains 失败", async () => {
const result = await runHttpCheck(
makeTarget({
url: `${baseUrl}/ok`,
expect: { body: [{ contains: "nonexistent" }] },
}),
);
expect(result.success).toBe(false);
expect(result.failure!.phase).toBe("body");
});
test("body json 检查", async () => {
const result = await runHttpCheck(
makeTarget({
url: `${baseUrl}/json`,
expect: { body: [{ json: { path: "$.status", equals: "ok" } }] },
}),
);
expect(result.success).toBe(true);
});
test("响应体超过 maxBodyBytes", async () => {
const result = await runHttpCheck(
makeTarget({
url: `${baseUrl}/large`,
maxBodyBytes: 100,
expect: { body: [{ contains: "x" }] },
}),
);
expect(result.success).toBe(false);
expect(result.failure).not.toBeNull();
expect(result.failure!.phase).toBe("body");
expect(result.failure!.message).toContain("超过限制");
});
test("请求超时", async () => {
const timeoutServer = Bun.serve({
port: 0,
async fetch() {
await new Promise((resolve) => setTimeout(resolve, 10000));
return new Response("late");
},
});
try {
const result = await runHttpCheck(
makeTarget({
url: `http://localhost:${timeoutServer.port}/`,
timeoutMs: 100,
}),
);
expect(result.success).toBe(false);
expect(result.failure).not.toBeNull();
expect(result.failure!.message).toContain("超时");
} finally {
timeoutServer.stop();
}
});
test("快速失败status 失败时不读取 body", async () => {
const result = await runHttpCheck(
makeTarget({
url: `${baseUrl}/notfound`,
expect: { status: [200], body: [{ contains: "something" }] },
}),
);
expect(result.success).toBe(false);
expect(result.failure!.phase).toBe("status");
});
test("快速失败headers 失败时不读取 body", async () => {
const result = await runHttpCheck(
makeTarget({
url: `${baseUrl}/ok`,
expect: { headers: { "x-missing": "value" }, body: [{ contains: "hello" }] },
}),
);
expect(result.success).toBe(false);
expect(result.failure!.phase).toBe("headers");
});
test("status 通过但 body 失败", async () => {
const result = await runHttpCheck(
makeTarget({
url: `${baseUrl}/ok`,
expect: { status: [200], body: [{ contains: "not-in-body" }] },
}),
);
expect(result.success).toBe(false);
expect(result.failure!.phase).toBe("body");
});
test("无 expect 时默认检查 status 200", async () => {
const result = await runHttpCheck(makeTarget({ url: `${baseUrl}/ok`, expect: undefined }));
expect(result.success).toBe(true);
});
test("POST 请求携带 body", async () => {
const result = await runHttpCheck(
makeTarget({
url: `${baseUrl}/echo`,
method: "POST",
body: "test-body",
headers: { "content-type": "text/plain" },
expect: { status: [200], body: [{ json: { path: "$.body", equals: "present" } }] },
}),
);
expect(result.success).toBe(true);
});
test("仅 contains 规则时不解析 JSON", async () => {
const result = await runHttpCheck(
makeTarget({
url: `${baseUrl}/ok`,
expect: { body: [{ contains: "hello world" }] },
}),
);
expect(result.success).toBe(true);
});
});

View File

@@ -0,0 +1,43 @@
import { describe, expect, test } from "bun:test";
import { parseSize, DEFAULT_MAX_BODY_BYTES, DEFAULT_MAX_OUTPUT_BYTES } from "../../../src/server/checker/size";
describe("parseSize", () => {
test("解析 B", () => {
expect(parseSize("1024B")).toBe(1024);
expect(parseSize("0B")).toBe(0);
});
test("解析 KB", () => {
expect(parseSize("1KB")).toBe(1024);
expect(parseSize("512KB")).toBe(524288);
});
test("解析 MB", () => {
expect(parseSize("1MB")).toBe(1048576);
expect(parseSize("100MB")).toBe(104857600);
});
test("解析 GB", () => {
expect(parseSize("1GB")).toBe(1073741824);
});
test("解析小数", () => {
expect(parseSize("1.5MB")).toBe(1572864);
});
test("数字直接返回", () => {
expect(parseSize(2048)).toBe(2048);
});
test("无效格式抛出错误", () => {
expect(() => parseSize("100")).toThrow("无效的 size 格式");
expect(() => parseSize("100MBB")).toThrow("无效的 size 格式");
expect(() => parseSize("abc")).toThrow("无效的 size 格式");
expect(() => parseSize("")).toThrow("无效的 size 格式");
});
test("默认值", () => {
expect(DEFAULT_MAX_BODY_BYTES).toBe(104857600);
expect(DEFAULT_MAX_OUTPUT_BYTES).toBe(104857600);
});
});

View File

@@ -1,34 +1,42 @@
import { afterAll, beforeAll, describe, expect, test } from "bun:test";
import { ProbeStore } from "../../../src/server/checker/store";
import type { ResolvedTarget } from "../../../src/server/checker/types";
import type { CheckFailure, ResolvedTarget } from "../../../src/server/checker/types";
import { mkdir, rm } from "node:fs/promises";
import { join } from "node:path";
import { tmpdir } from "node:os";
const httpTarget: ResolvedTarget = {
type: "http",
name: "test-http",
http: {
url: "https://example.com/health",
method: "GET",
headers: { Accept: "application/json" },
maxBodyBytes: 104857600,
},
intervalMs: 30000,
timeoutMs: 10000,
expect: { status: [200], maxDurationMs: 3000 },
};
const commandTarget: ResolvedTarget = {
type: "command",
name: "test-cmd",
command: {
exec: "ping",
args: ["-c", "1", "localhost"],
cwd: "/tmp",
env: {},
maxOutputBytes: 104857600,
},
intervalMs: 60000,
timeoutMs: 5000,
};
describe("ProbeStore", () => {
let tempDir: string;
let store: ProbeStore;
const target1: ResolvedTarget = {
name: "test-a",
url: "http://a.com",
method: "GET",
headers: {},
intervalMs: 30000,
timeoutMs: 10000,
};
const target2: ResolvedTarget = {
name: "test-b",
url: "http://b.com",
method: "POST",
headers: { "Content-Type": "application/json" },
body: '{"ping": true}',
intervalMs: 60000,
timeoutMs: 5000,
expect: { status: [200], maxLatencyMs: 3000 },
};
beforeAll(async () => {
tempDir = join(tmpdir(), `gc-store-test-${Date.now()}`);
await mkdir(tempDir, { recursive: true });
@@ -44,42 +52,62 @@ describe("ProbeStore", () => {
expect(store.getTargets()).toHaveLength(0);
});
test("同步新增 targets", () => {
store.syncTargets([target1, target2]);
test("同步 http 和 command targets", () => {
store.syncTargets([httpTarget, commandTarget]);
const targets = store.getTargets();
expect(targets).toHaveLength(2);
expect(targets[0]!.name).toBe("test-a");
expect(targets[1]!.name).toBe("test-b");
expect(targets[0]!.name).toBe("test-http");
expect(targets[1]!.name).toBe("test-cmd");
});
test("同步后 target 字段正确", () => {
const targets = store.getTargets();
const t2 = targets.find((t) => t.name === "test-b")!;
expect(t2.url).toBe("http://b.com");
expect(t2.method).toBe("POST");
expect(JSON.parse(t2.headers)).toEqual({ "Content-Type": "application/json" });
expect(t2.body).toBe('{"ping": true}');
expect(t2.interval_ms).toBe(60000);
expect(t2.expect).toBe(JSON.stringify({ status: [200], maxLatencyMs: 3000 }));
test("http target 字段正确", () => {
const t = store.getTargets().find((t) => t.name === "test-http")!;
expect(t.type).toBe("http");
expect(t.target).toBe("https://example.com/health");
const config = JSON.parse(t.config);
expect(config.url).toBe("https://example.com/health");
expect(config.method).toBe("GET");
expect(config.headers).toEqual({ Accept: "application/json" });
expect(config.maxBodyBytes).toBe(104857600);
expect(t.interval_ms).toBe(30000);
expect(t.timeout_ms).toBe(10000);
expect(JSON.parse(t.expect!)).toEqual({ status: [200], maxDurationMs: 3000 });
});
test("command target 字段正确", () => {
const t = store.getTargets().find((t) => t.name === "test-cmd")!;
expect(t.type).toBe("command");
expect(t.target).toBe("exec ping -c 1 localhost");
const config = JSON.parse(t.config);
expect(config.exec).toBe("ping");
expect(config.args).toEqual(["-c", "1", "localhost"]);
expect(config.cwd).toBe("/tmp");
expect(config.maxOutputBytes).toBe(104857600);
expect(t.interval_ms).toBe(60000);
expect(t.timeout_ms).toBe(5000);
expect(t.expect).toBeNull();
});
test("同步更新已有 target", () => {
store.syncTargets([{ ...target1, url: "http://a-v2.com" }, target2]);
const targets = store.getTargets();
const t1 = targets.find((t) => t.name === "test-a")!;
expect(t1.url).toBe("http://a-v2.com");
expect(targets).toHaveLength(2);
const updated: ResolvedTarget = {
...httpTarget,
http: { ...httpTarget.http, url: "https://example.com/v2" },
};
store.syncTargets([updated, commandTarget]);
const t = store.getTargets().find((t) => t.name === "test-http")!;
expect(t.target).toBe("https://example.com/v2");
expect(store.getTargets()).toHaveLength(2);
});
test("同步删除 target", () => {
store.syncTargets([target1]);
store.syncTargets([httpTarget]);
const targets = store.getTargets();
expect(targets).toHaveLength(1);
expect(targets[0]!.name).toBe("test-a");
expect(targets[0]!.name).toBe("test-http");
});
test("重新同步回来", () => {
store.syncTargets([target1, target2]);
store.syncTargets([httpTarget, commandTarget]);
expect(store.getTargets()).toHaveLength(2);
});
@@ -87,14 +115,15 @@ describe("ProbeStore", () => {
const targets = store.getTargets();
const found = store.getTargetById(targets[0]!.id);
expect(found).toBeDefined();
expect(found!.name).toBe("test-a");
expect(found!.name).toBe("test-http");
});
test("getTargetById 不存在", () => {
expect(store.getTargetById(99999)).toBeNull();
});
test("写入和查询 check result", () => {
test("写入 check result 并查询", () => {
store.syncTargets([httpTarget, commandTarget]);
const targets = store.getTargets();
const t1Id = targets[0]!.id;
@@ -102,30 +131,39 @@ describe("ProbeStore", () => {
targetId: t1Id,
timestamp: "2025-01-01T00:00:00.000Z",
success: true,
statusCode: 200,
latencyMs: 150,
error: null,
matched: true,
durationMs: 150.5,
statusDetail: "200 OK",
failure: null,
});
store.insertCheckResult({
targetId: t1Id,
timestamp: "2025-01-01T00:00:30.000Z",
success: true,
statusCode: 200,
latencyMs: 300,
error: null,
matched: true,
durationMs: 300,
statusDetail: "200 OK",
failure: null,
});
const failure: CheckFailure = {
kind: "error",
phase: "duration",
path: "$.maxDurationMs",
expected: 3000,
actual: 5000,
message: "请求耗时 5000ms 超过限制 3000ms",
};
store.insertCheckResult({
targetId: t1Id,
timestamp: "2025-01-01T00:01:00.000Z",
success: false,
statusCode: null,
latencyMs: null,
error: "timeout",
matched: false,
durationMs: null,
statusDetail: null,
failure,
});
const history = store.getHistory(t1Id, 10);
@@ -134,7 +172,11 @@ describe("ProbeStore", () => {
const latest = store.getLatestCheck(t1Id)!;
expect(latest.success).toBe(0);
expect(latest.error).toBe("timeout");
expect(latest.failure).not.toBeNull();
const parsedFailure = JSON.parse(latest.failure!) as CheckFailure;
expect(parsedFailure.kind).toBe("error");
expect(parsedFailure.phase).toBe("duration");
expect(parsedFailure.message).toBe("请求耗时 5000ms 超过限制 3000ms");
});
test("getHistory 默认 limit=20", () => {
@@ -144,12 +186,12 @@ describe("ProbeStore", () => {
for (let i = 0; i < 25; i++) {
store.insertCheckResult({
targetId: t1Id,
timestamp: `2025-01-01T00:${String(i).padStart(2, "0")}:00.000Z`,
timestamp: `2025-01-01T01:${String(i).padStart(2, "0")}:00.000Z`,
success: true,
statusCode: 200,
latencyMs: 100 + i,
error: null,
matched: true,
durationMs: 100 + i,
statusDetail: "200 OK",
failure: null,
});
}
@@ -157,7 +199,7 @@ describe("ProbeStore", () => {
expect(history).toHaveLength(20);
});
test("getTargetStats 计算可用率和延迟", () => {
test("getTargetStats 计算可用率和 duration", () => {
const targets = store.getTargets();
const t1Id = targets[0]!.id;
@@ -165,17 +207,19 @@ describe("ProbeStore", () => {
expect(stats.totalChecks).toBeGreaterThan(0);
expect(stats.availability).toBeGreaterThanOrEqual(0);
expect(stats.availability).toBeLessThanOrEqual(100);
expect(stats.avgLatencyMs).not.toBeNull();
expect(stats.avgDurationMs).not.toBeNull();
expect(typeof stats.avgDurationMs).toBe("number");
});
test("无记录目标的 stats", () => {
const targets = store.getTargets();
const t2Id = targets.find((t) => t.name === "test-b")!.id;
const t2Id = targets.find((t) => t.name === "test-cmd")!.id;
const stats = store.getTargetStats(t2Id);
expect(stats.totalChecks).toBe(0);
expect(stats.availability).toBe(0);
expect(stats.avgLatencyMs).toBeNull();
expect(stats.avgDurationMs).toBeNull();
expect(stats.p99DurationMs).toBeNull();
});
test("getSummary 返回总览统计", () => {
@@ -183,6 +227,7 @@ describe("ProbeStore", () => {
expect(summary.total).toBe(2);
expect(summary.up + summary.down).toBe(2);
expect(summary.lastCheckTime).not.toBeNull();
expect(summary.avgDurationMs).not.toBeNull();
});
test("getTrend 返回趋势数据", () => {
@@ -191,5 +236,30 @@ describe("ProbeStore", () => {
const trend = store.getTrend(t1Id, 24);
expect(Array.isArray(trend)).toBe(true);
if (trend.length > 0) {
expect(trend[0]!.hour).toBeDefined();
expect(trend[0]!.avgDurationMs).toBeDefined();
expect(trend[0]!.availability).toBeGreaterThanOrEqual(0);
expect(trend[0]!.totalChecks).toBeGreaterThan(0);
}
});
test("getSparkline 返回 duration 数组", () => {
const targets = store.getTargets();
const t1Id = targets[0]!.id;
const sparkline = store.getSparkline(t1Id);
expect(Array.isArray(sparkline)).toBe(true);
expect(sparkline.length).toBeGreaterThan(0);
for (const val of sparkline) {
expect(typeof val).toBe("number");
}
});
test("关闭后操作不报错", () => {
const closedStore = new ProbeStore(join(tempDir, "closed.db"));
closedStore.close();
expect(closedStore.getTargets()).toHaveLength(0);
expect(closedStore.getTargetById(1)).toBeNull();
});
});