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

160
README.md
View File

@@ -1,6 +1,6 @@
# Gateway Checker # Gateway Checker
基于 Bun + TypeScript 的 HTTP 拨测监控工具。通过 YAML 配置文件定义拨测目标,后端按配置定时并发拨测,结果持久化到本地 SQLite前端 Dashboard 展示各目标实时状态、可用率、延迟趋势等。 基于 Bun + TypeScript 的多类型拨测监控工具。通过 YAML 配置文件定义 HTTP 和命令行拨测目标,后端按配置定时并发拨测,结果持久化到本地 SQLite前端 Dashboard 展示各目标实时状态、可用率、耗时趋势等。
## 项目结构 ## 项目结构
@@ -12,11 +12,16 @@ src/
dev.ts 开发期启动入口 dev.ts 开发期启动入口
server.ts HTTP server 启动 server.ts HTTP server 启动
checker/ checker/
types.ts 类型定义 types.ts 类型定义
config-loader.ts YAML 配置解析与校验 config-loader.ts YAML 配置解析与校验
store.ts SQLite 数据存储 store.ts SQLite 数据存储
fetcher.ts HTTP 拨测执行 http-runner.ts HTTP 拨测执行
engine.ts 调度引擎(按 interval 分组、组内并发) command-runner.ts 命令行拨测执行
http-expect.ts HTTP 响应断言
command-expect.ts 命令行输出断言
failure.ts 失败信息类型
size.ts 大小单位解析
engine.ts 调度引擎(按 interval 分组、组内并发)
shared/ shared/
api.ts 前后端共享 TypeScript 类型 api.ts 前后端共享 TypeScript 类型
web/ Vite + React 前端 Dashboard web/ Vite + React 前端 Dashboard
@@ -52,48 +57,65 @@ bun run dev:web
server: server:
host: "127.0.0.1" host: "127.0.0.1"
port: 3000 port: 3000
dataDir: "./data" dataDir: "/tmp/probes_data"
runtime:
maxConcurrentChecks: 20
defaults: defaults:
interval: "30s" interval: "5s"
timeout: "10s" timeout: "10s"
method: "GET" http:
method: GET
maxBodyBytes: "100MB"
command:
maxOutputBytes: "100MB"
targets: targets:
- name: "示例服务" - name: "Baidu"
url: "https://httpbin.org/get" type: http
interval: "60s" http:
url: "https://www.baidu.com"
- name: "POST 检查"
url: "https://httpbin.org/post"
method: "POST"
headers:
Content-Type: "application/json"
body: '{"ping": true}'
expect: expect:
status: [200] status: [200]
maxLatencyMs: 5000 maxDurationMs: 10000
- name: "JSON API 监控" - name: "JSON API 示例"
url: "https://httpbin.org/json" type: http
http:
url: "https://httpbin.org/json"
expect: expect:
status: [200] status: [200]
headers: headers:
Content-Type: application/json Content-Type:
contains: "application/json"
body: body:
contains: "slideshow" - contains: "slideshow"
json: - json:
$.slideshow.title: "Sample Slide Show" path: "$.slideshow.title"
equals: "Sample Slide Show"
- name: "HTML 页面监控" - name: "HTML 页面示例"
url: "https://httpbin.org/html" type: http
http:
url: "https://httpbin.org/html"
expect: expect:
status: [200] status: [200]
body: body:
css: - contains: "Moby-Dick"
"h1": "Herman Melville - Moby-Dick" - xpath:
xpath: path: "/html/body/h1/text()"
"/html/body/h1/text()": "Herman Melville - Moby-Dick" equals: "Herman Melville - Moby-Dick"
- name: "Nginx 进程检查"
type: command
command:
exec: "pgrep"
args: ["nginx"]
expect:
exitCode: [0]
stdout:
- match: "\\d+"
``` ```
### 配置说明 ### 配置说明
@@ -102,39 +124,67 @@ targets:
- `host`: 监听地址,默认 `127.0.0.1` - `host`: 监听地址,默认 `127.0.0.1`
- `port`: 监听端口,默认 `3000` - `port`: 监听端口,默认 `3000`
- `dataDir`: 数据目录,默认 `./data` - `dataDir`: 数据目录,默认 `./data`
- **runtime**: 运行时配置
- `maxConcurrentChecks`: 最大并发拨测数,默认 `20`
- **defaults**: 全局默认值(均可省略) - **defaults**: 全局默认值(均可省略)
- `interval`: 拨测间隔,默认 `30s` - `interval`: 拨测间隔,默认 `30s`
- `timeout`: 请求超时,默认 `10s` - `timeout`: 超时时间,默认 `10s`
- `method`: HTTP 方法,默认 `GET` - `http`: HTTP 类型默认值
- `headers`: 全局 headers - `method`: HTTP 方法,默认 `GET`
- `maxBodyBytes`: 响应体最大字节数,默认 `100MB`
- `command`: Command 类型默认值
- `maxOutputBytes`: 输出最大字节数,默认 `100MB`
- **targets**: 拨测目标列表(必填) - **targets**: 拨测目标列表(必填)
- `name`: 目标名称(必填,唯一) - `name`: 目标名称(必填,唯一)
- `url`: 目标 URL(必填) - `type`: 目标类型,`http``command`(必填)
- `method``headers``body`: 请求参数 - `http`: HTTP 拨测配置type 为 http 时必填)
- `url`: 目标 URL
- `method``headers``body`: 请求参数
- `command`: 命令行拨测配置type 为 command 时必填)
- `exec`: 可执行文件名或路径
- `args`: 命令行参数列表
- `interval``timeout`: 覆盖全局默认值 - `interval``timeout`: 覆盖全局默认值
- `expect`: 期望校验 - `expect`: 期望校验
- `status`: 可接受的状态码列表 - `status`: 可接受的状态码列表HTTP
- `headers`: 响应头校验(键值对,全部匹配 - `exitCode`: 可接受的退出码列表Command
- `maxLatencyMs`: 最大延迟阈值(毫秒 - `headers`: 响应头校验HTTP支持 `equals``contains` 等操作符
- `body`: 响应体校验(可组合使用 - `maxDurationMs`: 最大耗时阈值(毫秒
- `body`: HTTP 响应体校验(数组,可组合使用)
- `contains`: 响应体包含的文本 - `contains`: 响应体包含的文本
- `regex`: 响应体匹配的正则表达式 - `match`: 响应体匹配的正则表达式
- `json`: JSONPath 提取值比较(路径 → 期望值 - `json`: JSONPath 提取值比较(`path` + 比较操作符
- `css`: CSS 选择器提取 HTML 元素比较(选择器 → 期望值,可选 `attr` 提取属性) - `css`: CSS 选择器提取 HTML 元素比较
- `xpath`: XPath 提取 XML/HTML 节点比较(路径 → 期望值) - `xpath`: XPath 提取 XML/HTML 节点比较
- body 比较支持操作符:`equals`(默认)、`contains``match`(正则)、`empty``exists``gte``lte``gt``lt` - `stdout` / `stderr`: Command 输出校验(数组,同 body 格式)
- 比较操作符:`equals`(默认)、`contains``match`(正则)、`empty``exists``gte``lte``gt``lt`
大小说明:`maxBodyBytes``maxOutputBytes` 支持单位 `KB``MB``GB`,也可直接使用数字(字节数)。
时长格式支持:`30s``5m``500ms` 时长格式支持:`30s``5m``500ms`
## API 端点 ## API 端点
| 端点 | 说明 | | 端点 | 说明 |
| --------------------------------------- | ---------------------------------------------------- | | --------------------------------------- | ----------------------------------------------------- |
| `GET /health` | 健康检查 | | `GET /health` | 健康检查 |
| `GET /api/summary` | 总览统计total/up/down/avgLatencyMs/lastCheckTime | | `GET /api/summary` | 总览统计total/up/down/avgDurationMs/lastCheckTime |
| `GET /api/targets` | 目标列表及最新状态和统计摘要 | | `GET /api/targets` | 目标列表及最新状态和统计摘要 |
| `GET /api/targets/:id/history?limit=20` | 指定目标的最近 N 条拨测记录 | | `GET /api/targets/:id/history?limit=20` | 指定目标的最近 N 条拨测记录 |
| `GET /api/targets/:id/trend?hours=24` | 指定目标的按小时聚合趋势 | | `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`
## 代码质量 ## 代码质量
@@ -190,13 +240,13 @@ bun run verify
## 目标状态判定 ## 目标状态判定
两层判定模型: 两层判定模型,适用于 HTTP 和 Command 两种类型
- **success**: 请求是否成功完成(收到 HTTP 响应 - **success**: 拨测是否成功完成HTTP 收到响应 / Command 正常退出
- **matched**: 是否符合 expect 规则(无 expect 时默认为 true - **matched**: 是否符合 expect 规则(无 expect 时默认为 true
- **UP** = success AND matched - **UP** = success AND matched
- **DOWN** = NOT success OR NOT 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 ## Requirements
### Requirement: 响应体多种校验方法 ### Requirement: 响应体多种校验方法
系统 SHALL 支持对 HTTP 响应体进行五种可组合的校验方法contains子串、regex正则、jsonJSONPath、cssCSS 选择器、xpathXPath配置在 `expect.body` 分组下 系统 SHALL 支持对 HTTP 响应体进行五种可组合的校验方法contains子串、regex正则、jsonJSONPath、cssCSS 选择器、xpathXPath。这些方法 MUST 配置在 `expect.body` 有序数组中
#### Scenario: contains 子串匹配 #### Scenario: contains 子串匹配
- **WHEN** 目标配置 `expect.body.contains: "healthy"`,且响应体包含 `"healthy"` - **WHEN** HTTP target 配置 `expect.body: [{contains: "healthy"}]`,且响应体包含 `"healthy"`
- **THEN** 系统 SHALL 判定 matched 为 true - **THEN** 系统 SHALL 判定该 body 规则通过
#### Scenario: contains 不匹配 #### Scenario: contains 不匹配
- **WHEN** 目标配置 `expect.body.contains: "healthy"`,且响应体不包含该文本 - **WHEN** HTTP target 配置 `expect.body: [{contains: "healthy"}]`,且响应体不包含该文本
- **THEN** 系统 SHALL 判定 matched 为 false - **THEN** 系统 SHALL 判定 matched 为 false,并记录该规则的 failure.path
#### Scenario: regex 正则匹配 #### Scenario: regex 正则匹配
- **WHEN** 目标配置 `expect.body.regex: '"status"\\s*:\\s*"ok"'`,且响应体匹配该正则 - **WHEN** HTTP target 配置 `expect.body: [{regex: '"status"\\s*:\\s*"ok"'}]`,且响应体匹配该正则
- **THEN** 系统 SHALL 判定 matched 为 true - **THEN** 系统 SHALL 判定该 body 规则通过
#### Scenario: regex 不匹配 #### Scenario: regex 不匹配
- **WHEN** 目标配置 `expect.body.regex: '"status"\\s*:\\s*"ok"'`,且响应体不匹配该正则 - **WHEN** HTTP target 配置 regex body 规则,且响应体不匹配该正则
- **THEN** 系统 SHALL 判定 matched 为 false - **THEN** 系统 SHALL 判定 matched 为 false,并记录该规则的 failure.path
#### Scenario: json JSONPath 等值匹配 #### Scenario: json JSONPath 等值匹配
- **WHEN** 目标配置 `expect.body.json: {"$.status": "ok"}`,且响应 JSON 中 `$.status` 值为 `"ok"` - **WHEN** HTTP target 配置 `expect.body: [{json: {path: "$.status", equals: "ok"}}]`,且响应 JSON 中 `$.status` 值为 `"ok"`
- **THEN** 系统 SHALL 判定 matched 为 true - **THEN** 系统 SHALL 判定该 body 规则通过
#### Scenario: json JSONPath 值不匹配 #### Scenario: json JSONPath 值不匹配
- **WHEN** 目标配置 `expect.body.json: {"$.status": "ok"}`,且响应 JSON 中 `$.status` 值为 `"error"` - **WHEN** HTTP target 配置 json body 规则,且提取值不符合期望
- **THEN** 系统 SHALL 判定 matched 为 false - **THEN** 系统 SHALL 判定 matched 为 false,并记录包含 JSONPath 的 failure.path
#### Scenario: json 解析失败 #### Scenario: json 解析失败
- **WHEN** 目标配置了 `expect.body.json` 但响应体不是合法 JSON - **WHEN** HTTP target 配置了 json body 规则但响应体不是合法 JSON
- **THEN** 系统 SHALL 判定 matched 为 false - **THEN** 系统 SHALL 判定 matched 为 false
#### Scenario: css 选择器匹配 #### Scenario: css 选择器匹配
- **WHEN** 目标配置 `expect.body.css: {"div#health": "OK"}`,且 HTML 中存在 `div#health` 元素文本为 `"OK"` - **WHEN** HTTP target 配置 `expect.body: [{css: {selector: "div#health", equals: "OK"}}]`,且 HTML 中存在 `div#health` 元素文本为 `"OK"`
- **THEN** 系统 SHALL 判定 matched 为 true - **THEN** 系统 SHALL 判定该 body 规则通过
#### Scenario: css 选择器匹配属性值 #### Scenario: css 选择器匹配属性值
- **WHEN** 目标配置 css 规则带 `attr: "content"` 用于提取属性,且属性值匹配期望 - **WHEN** HTTP target 配置 css 规则带 `attr: "content"` 用于提取属性,且属性值匹配期望
- **THEN** 系统 SHALL 判定 matched 为 true - **THEN** 系统 SHALL 判定该 body 规则通过
#### Scenario: css 选择器无匹配元素 #### Scenario: css 选择器无匹配元素
- **WHEN** 目标配置了 css 选择器但 HTML 中无匹配元素 - **WHEN** HTTP target 配置了 css 选择器但 HTML 中无匹配元素
- **THEN** 系统 SHALL 判定 matched 为 false - **THEN** 系统 SHALL 判定 matched 为 false
#### Scenario: xpath 表达式匹配 #### Scenario: xpath 表达式匹配
- **WHEN** 目标配置 `expect.body.xpath: {"/root/status/text()": "ok"}`,且 XML 中 `/root/status` 节点文本为 `"ok"` - **WHEN** HTTP target 配置 `expect.body: [{xpath: {path: "/root/status/text()", equals: "ok"}}]`,且 XML 中 `/root/status` 节点文本为 `"ok"`
- **THEN** 系统 SHALL 判定 matched 为 true - **THEN** 系统 SHALL 判定该 body 规则通过
#### Scenario: xpath 表达式无匹配节点 #### Scenario: xpath 表达式无匹配节点
- **WHEN** 目标配置了 xpath 表达式但 XML 中无匹配节点 - **WHEN** HTTP target 配置了 xpath 表达式但 XML 中无匹配节点
- **THEN** 系统 SHALL 判定 matched 为 false - **THEN** 系统 SHALL 判定 matched 为 false
### Requirement: 多种 body 校验方法 AND 组合 ### Requirement: 多种 body 校验方法 AND 组合
系统 SHALL 支持同时配置多种 body 校验方法,所有方法均通过时 matched 方为 true。 系统 SHALL 支持`expect.body` 数组中同时配置多种 body 校验方法,所有方法均通过时 matched 方为 true。
#### Scenario: 多种方法全部通过 #### Scenario: 多种方法全部通过
- **WHEN** 目标同时配置 `body.contains``body.json``body.regex`,且全部通过 - **WHEN** HTTP target 的 `expect.body` 数组依次配置 contains、json、regex且全部通过
- **THEN** 系统 SHALL 判定 matched 为 true - **THEN** 系统 SHALL 判定 matched 为 true
#### Scenario: 多种方法任一失败 #### Scenario: 多种方法任一失败
- **WHEN** 目标同时配置 `body.contains``body.json`,且 `body.contains` 不通过 - **WHEN** HTTP target 的 `expect.body` 数组第一条 contains 不通过,后续还有 json 规则
- **THEN** 系统 SHALL 判定 matched 为 false且不再检查 `body.json` - **THEN** 系统 SHALL 判定 matched 为 false且不再检查后续 json 规则
### Requirement: 操作符系统 ### Requirement: 操作符系统
系统 SHALL 支持对 body 校验的提取值使用以下操作符进行比较equals默认等值、contains子串包含、match正则匹配、empty空值判断、exists存在性判断、gte/lte/gt/lt数值比较 系统 SHALL 支持对提取值和文本值使用以下操作符进行比较equals默认等值、contains子串包含、match正则匹配、empty空值判断、exists存在性判断、gte/lte/gt/lt数值比较
#### Scenario: 标量值隐式 equals #### Scenario: 标量值隐式 equals
- **WHEN** jsonPath 配置的期望值为标量(字符串/数字/布尔/null`$.status: ok` - **WHEN** 配置的期望值为标量(字符串/数字/布尔/null`equals: "ok"`
- **THEN** 系统 SHALL 使用 equals 操作符,对提取值做严格相等比较 - **THEN** 系统 SHALL 使用 equals 操作符,对实际值做严格相等比较
#### Scenario: 显式 contains 操作符 #### Scenario: 显式 contains 操作符
- **WHEN** 配置 `$.message: {contains: "success"}`,且提取值包含 `"success"` - **WHEN** 配置 `{contains: "success"}`,且实际值包含 `"success"`
- **THEN** 系统 SHALL 判定 matched 为 true - **THEN** 系统 SHALL 判定该规则通过
#### Scenario: 显式 match 操作符 #### Scenario: 显式 match 操作符
- **WHEN** 配置 `$.version: {match: '\\d+\\.\\d+\\.\\d+'}`,且提取值匹配该正则 - **WHEN** 配置 `{match: '\\d+\\.\\d+\\.\\d+'}`,且实际值匹配该正则
- **THEN** 系统 SHALL 判定 matched 为 true - **THEN** 系统 SHALL 判定该规则通过
#### Scenario: empty 操作符判断为空 #### Scenario: empty 操作符判断为空
- **WHEN** 配置 `$.items: {empty: true}`,且提取值为空数组 `[]` - **WHEN** 配置 `{empty: true}`,且实际值为空数组 `[]`
- **THEN** 系统 SHALL 判定 matched 为 true - **THEN** 系统 SHALL 判定该规则通过
#### Scenario: empty 操作符判断非空 #### Scenario: empty 操作符判断非空
- **WHEN** 配置 `$.items: {empty: false}`,且提取值为 `[1, 2]` - **WHEN** 配置 `{empty: false}`,且实际值为 `[1, 2]`
- **THEN** 系统 SHALL 判定 matched 为 true - **THEN** 系统 SHALL 判定该规则通过
#### Scenario: exists 操作符判断存在 #### Scenario: exists 操作符判断存在
- **WHEN** 配置 `$.error: {exists: false}`,且 JSON 中不存在 `error` 字段 - **WHEN** 配置 `{exists: false}`,且实际值不存在
- **THEN** 系统 SHALL 判定 matched 为 true - **THEN** 系统 SHALL 判定该规则通过
#### Scenario: gte 数值比较 #### Scenario: gte 数值比较
- **WHEN** 配置 `$.count: {gte: 10}`,且提取值为 `15`(数字) - **WHEN** 配置 `{gte: 10}`,且实际值为 `15`(数字)
- **THEN** 系统 SHALL 判定 matched 为 true - **THEN** 系统 SHALL 判定该规则通过
#### Scenario: gt/lt 数值比较 #### Scenario: gt/lt 数值比较
- **WHEN** 配置 `$.latency: {gt: 0, lt: 1000}`,且提取值为 `500` - **WHEN** 配置 `{gt: 0, lt: 1000}`,且实际值为 `500`
- **THEN** 系统 SHALL 对同一字段进行多操作符复合比较,全部通过则 matched 为 true - **THEN** 系统 SHALL 对同一字段进行多操作符复合比较,全部通过则该规则通过
### Requirement: 响应头校验 ### Requirement: 响应头校验
系统 SHALL 支持通过 `expect.headers` 配置对响应头进行键值对校验 系统 SHALL 支持通过 `expect.headers` 配置对 HTTP 响应头进行键值规则校验header 名称匹配 MUST 不区分大小写
#### Scenario: 响应头匹配 #### Scenario: 响应头匹配
- **WHEN** 目标配置 `expect.headers: {"Content-Type": "application/json"}`,且响应包含该 header 且值匹配 - **WHEN** HTTP target 配置 `expect.headers: {"Content-Type": {contains: "application/json"}}`,且响应包含该 header 且值匹配
- **THEN** 系统 SHALL 判定 matched 为 true - **THEN** 系统 SHALL 判定 headers 阶段通过
#### Scenario: 响应头不匹配 #### 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 - **THEN** 系统 SHALL 判定 matched 为 false
#### Scenario: 响应头缺失 #### Scenario: 响应头缺失
- **WHEN** 目标配置了某个 header 但响应中不存在该 header - **WHEN** HTTP target 配置了某个 header 但响应中不存在该 header
- **THEN** 系统 SHALL 判定 matched 为 false - **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: 获取总览统计 #### Scenario: 获取总览统计
- **WHEN** 客户端请求 `GET /api/summary` - **WHEN** 客户端请求 `GET /api/summary`
- **THEN** 系统 SHALL 返回 JSON 包含 total总目标数、up正常数、down异常数、avgLatencyMs所有目标平均延迟、lastCheckTime最近一次拨测时间) - **THEN** 系统 SHALL 返回 JSON 包含 total总目标数、up正常数、down异常数、avgDurationMs所有目标平均耗时、lastCheckTime最近一次检查时间)
### Requirement: 目标列表 API ### Requirement: 目标列表 API
系统 SHALL 提供 `GET /api/targets` 端点,返回所有目标及其最新状态和统计摘要。 系统 SHALL 提供 `GET /api/targets` 端点,返回所有 typed target 及其最新状态和统计摘要。
#### Scenario: 获取目标列表 #### Scenario: 获取目标列表
- **WHEN** 客户端请求 `GET /api/targets` - **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: 目标无历史记录 #### Scenario: 目标无历史记录
- **WHEN** 某目标尚未执行过任何拨测 - **WHEN** 某目标尚未执行过任何拨测
@@ -27,7 +27,7 @@
#### Scenario: 获取最近历史记录 #### Scenario: 获取最近历史记录
- **WHEN** 客户端请求 `GET /api/targets/1/history?limit=20` - **WHEN** 客户端请求 `GET /api/targets/1/history?limit=20`
- **THEN** 系统 SHALL 返回最多 20 条拨测记录,按时间倒序排列 - **THEN** 系统 SHALL 返回最多 20 条检查记录,按时间倒序排列,且每条包含 success、matched、durationMs、statusDetail 和 failure
#### Scenario: 使用默认 limit #### Scenario: 使用默认 limit
- **WHEN** 客户端请求 `GET /api/targets/1/history`(未指定 limit - **WHEN** 客户端请求 `GET /api/targets/1/history`(未指定 limit
@@ -38,7 +38,7 @@
#### Scenario: 获取 24 小时趋势 #### Scenario: 获取 24 小时趋势
- **WHEN** 客户端请求 `GET /api/targets/1/trend?hours=24` - **WHEN** 客户端请求 `GET /api/targets/1/trend?hours=24`
- **THEN** 系统 SHALL 返回按小时分组的聚合数据,每个数据点包含 hour、avgLatencyMs、availability、totalChecks - **THEN** 系统 SHALL 返回按小时分组的聚合数据,每个数据点包含 hour、avgDurationMs、availability、totalChecks
#### Scenario: 使用默认时间范围 #### Scenario: 使用默认时间范围
- **WHEN** 客户端请求 `GET /api/targets/1/trend`(未指定 hours - **WHEN** 客户端请求 `GET /api/targets/1/trend`(未指定 hours
@@ -61,3 +61,14 @@
#### Scenario: 无效的 limit 参数 #### Scenario: 无效的 limit 参数
- **WHEN** 客户端请求 `GET /api/targets/1/history?limit=abc` - **WHEN** 客户端请求 `GET /api/targets/1/history?limit=abc`
- **THEN** 系统 SHALL 返回 400 状态码和错误信息 - **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 ## Requirements
### Requirement: YAML 配置文件格式 ### Requirement: YAML 配置文件格式
系统 SHALL 支持通过 YAML 配置文件定义全部运行参数,包括 server 配置、数据目录、拨测默认值和拨测目标列表 系统 SHALL 支持通过 YAML 配置文件定义全部运行参数,包括 server 配置、runtime 配置、checker 默认值和 typed target 列表。target MUST 使用 `type` 字段声明 checker 类型HTTP 领域字段 MUST 放在 `http` 分组command 领域字段 MUST 放在 `command` 分组
#### Scenario: 完整配置文件解析 #### Scenario: 完整配置文件解析
- **WHEN** 系统启动并读取包含 server、defaults、targets 的 YAML 配置文件 - **WHEN** 系统启动并读取包含 server、runtime、defaults、targets 的 YAML 配置文件
- **THEN** 系统 SHALL 正确解析所有字段并用于初始化服务 - **THEN** 系统 SHALL 正确解析所有字段并用于初始化服务、调度引擎和对应 checker runner
#### Scenario: 最简配置文件解析 #### Scenario: 最简 HTTP 配置文件解析
- **WHEN** 系统读取只包含 targets 列表的 YAML 配置文件(省略 server 和 defaults - **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, method=GET - **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 配置覆盖全局默认值 #### Scenario: per-target 配置覆盖全局默认值
- **WHEN** 某个 target 指定 interval、timeout 或 method - **WHEN** 某个 target 指定 interval、timeout 或对应领域分组中的默认字段
- **THEN** 该 target SHALL 使用其自身的值,不受 defaults 影响 - **THEN** 该 target SHALL 使用其自身的值,不受 defaults 中对应字段影响
### Requirement: CLI 参数 ### Requirement: CLI 参数
系统 SHALL 通过单一命令行参数接受 YAML 配置文件路径。 系统 SHALL 通过单一命令行参数接受 YAML 配置文件路径。
@@ -38,17 +42,59 @@
系统 SHALL 在启动时对 YAML 配置进行完整校验,校验失败时以非零状态退出并输出清晰的错误信息。 系统 SHALL 在启动时对 YAML 配置进行完整校验,校验失败时以非零状态退出并输出清晰的错误信息。
#### Scenario: target 缺少必填字段 #### Scenario: target 缺少必填字段
- **WHEN** YAML 中某个 target 缺少 name 或 url 字段 - **WHEN** YAML 中某个 target 缺少 name 或 type 字段
- **THEN** 系统 SHALL 以错误退出,提示哪个 target 缺少哪个字段 - **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 重复 #### Scenario: target name 重复
- **WHEN** YAML 中存在两个 name 相同的 target - **WHEN** YAML 中存在两个 name 相同的 target
- **THEN** 系统 SHALL 以错误退出,提示重复的 name - **THEN** 系统 SHALL 以错误退出,提示重复的 name
#### Scenario: interval 格式非法 #### Scenario: interval 格式非法
- **WHEN** interval 或 timeout 值不是有效的时长格式(如 `30s``5m` - **WHEN** interval 或 timeout 值不是有效的时长格式(如 `30s``5m``500ms`
- **THEN** 系统 SHALL 以错误退出并提示格式错误 - **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 内置解析 ### Requirement: YAML 配置使用 Bun 内置解析
系统 SHALL 使用 Bun 内置的 `Bun.YAML.parse()` 解析配置文件,不引入外部 YAML 解析库。 系统 SHALL 使用 Bun 内置的 `Bun.YAML.parse()` 解析配置文件,不引入外部 YAML 解析库。
@@ -57,21 +103,28 @@
- **THEN** 系统 SHALL 调用 `Bun.YAML.parse()` 将内容解析为配置对象 - **THEN** 系统 SHALL 调用 `Bun.YAML.parse()` 将内容解析为配置对象
### Requirement: expect 配置增强 ### 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 配置 #### Scenario: 解析 HTTP expect 配置
- **WHEN** YAML 配置文件中 target 的 expect 包含 headers、body 组及内部方法 - **WHEN** YAML 配置文件中 HTTP target 的 expect 包含 status、headers、body 规则数组及内部方法
- **THEN** 系统 SHALL 正确解析并存储为 ResolvedTarget 的 expect 字段 - **THEN** 系统 SHALL 正确解析并存储为 HTTP target 的 expect 字段
#### Scenario: 解析仅含 body.contains 的最简配置 #### Scenario: 解析 command expect 配置
- **WHEN** YAML target 配置 `expect.body.contains: "healthy"` - **WHEN** YAML 配置文件中 command target expect 包含 exitCode、stdout 和 stderr 规则数组
- **THEN** 系统 SHALL 正确解析,功能等价于旧版 `expect.bodyContains` - **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 #### Scenario: 不配置 expect
- **WHEN** target 未配置任何 expect 规则 - **WHEN** target 未配置任何 expect 规则
- **THEN** 系统 SHALL 正常处理expect 字段为 undefined - **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 ## Requirements
### Requirement: 总览统计卡片 ### Requirement: 总览统计卡片
Dashboard SHALL 在页面顶部展示总览统计卡片,包含总目标数、正常数、异常数和平均延迟 Dashboard SHALL 在页面顶部展示总览统计卡片,包含总目标数、正常数、异常数和平均耗时
#### Scenario: 展示统计卡片 #### Scenario: 展示统计卡片
- **WHEN** 用户打开 Dashboard 页面 - **WHEN** 用户打开 Dashboard 页面
- **THEN** 页面顶部 SHALL 显示 4 个统计卡片:全部目标数、正常目标数、异常目标数、所有目标平均延迟 - **THEN** 页面顶部 SHALL 显示 4 个统计卡片:全部目标数、正常目标数、异常目标数、所有目标平均耗时
#### Scenario: 统计数据自动刷新 #### Scenario: 统计数据自动刷新
- **WHEN** 页面处于打开状态 - **WHEN** 页面处于打开状态
- **THEN** 统计卡片 SHALL 每 5-10 秒自动刷新数据 - **THEN** 统计卡片 SHALL 每 5-10 秒自动刷新数据
### Requirement: 目标列表表格 ### Requirement: 目标列表表格
Dashboard SHALL 展示所有拨测目标的列表表格,包含名称、URL、当前状态、最新延迟和迷你趋势线。 Dashboard SHALL 展示所有 checker target 的列表表格,包含名称、类型、目标摘要、当前状态、最新耗时、最近失败原因和迷你趋势线。
#### Scenario: 展示目标列表 #### Scenario: 展示目标列表
- **WHEN** 用户打开 Dashboard 页面 - **WHEN** 用户打开 Dashboard 页面
- **THEN** 页面 SHALL 显示表格,每行包含目标名称、URL、状态指示圆点(UP / DOWN、最新延迟值、迷你 Sparkline 趋势线 - **THEN** 页面 SHALL 显示表格,每行包含目标名称、类型、目标摘要、状态指示圆点UP / DOWN、最新耗时值、最近失败原因摘要、迷你 Sparkline 趋势线
#### Scenario: 状态指示圆点 #### Scenario: 状态指示圆点
- **WHEN** 目标最近一次拨测 success=true 且 matched=true - **WHEN** 目标最近一次拨测 success=true 且 matched=true
@@ -33,7 +33,7 @@ Dashboard SHALL 支持在目标列表中展开某行,显示该目标的详细
#### Scenario: 展开目标详情 #### Scenario: 展开目标详情
- **WHEN** 用户点击目标列表中的某一行 - **WHEN** 用户点击目标列表中的某一行
- **THEN** 该行下方 SHALL 展开详情面板,包含:可用率百分比、平均延迟、P99 延迟、24 小时延迟趋势折线图、最近 5-10 条拨测记录列表 - **THEN** 该行下方 SHALL 展开详情面板,包含:可用率百分比、平均耗时、P99 耗时、24 小时耗时趋势折线图、最近 5-10 条检查记录列表、领域状态详情和失败信息
#### Scenario: 收起目标详情 #### Scenario: 收起目标详情
- **WHEN** 用户再次点击已展开的目标行 - **WHEN** 用户再次点击已展开的目标行
@@ -44,22 +44,33 @@ Dashboard SHALL 支持在目标列表中展开某行,显示该目标的详细
- **THEN** 系统 SHALL 此时请求该目标的趋势数据,而非页面加载时预加载所有目标的趋势数据 - **THEN** 系统 SHALL 此时请求该目标的趋势数据,而非页面加载时预加载所有目标的趋势数据
### Requirement: 历史记录展示 ### Requirement: 历史记录展示
Dashboard SHALL 在目标详情面板中展示最近的拨测记录,包含时间、状态码、延迟和成功/失败标记。 Dashboard SHALL 在目标详情面板中展示最近的检查记录,包含时间、领域状态详情、耗时、成功/失败标记和失败信息
#### Scenario: 展示历史记录 #### Scenario: 展示历史记录
- **WHEN** 用户展开目标详情面板 - **WHEN** 用户展开目标详情面板
- **THEN** 面板 SHALL 显示最近拨测记录列表,每条包含时间戳、HTTP 状态码(或错误信息)、延迟毫秒数、成功/失败图标 - **THEN** 面板 SHALL 显示最近检查记录列表,每条包含时间戳、statusDetail如 HTTP 200 或 exitCode=1、耗时毫秒数、UP/DOWN 标记和 failure.message如存在
### Requirement: 趋势图可视化 ### Requirement: 趋势图可视化
Dashboard SHALL 使用 recharts 库渲染趋势图,包括目标列表中的迷你 Sparkline 和详情面板中的完整折线图。 Dashboard SHALL 使用 recharts 库渲染趋势图,包括目标列表中的迷你 Sparkline 和详情面板中的完整折线图。
#### Scenario: 表格行内迷你趋势线 #### Scenario: 表格行内迷你趋势线
- **WHEN** 目标列表表格渲染 - **WHEN** 目标列表表格渲染
- **THEN** 每行 SHALL 包含一个基于 recharts 的迷你折线图,展示最近的延迟趋势 - **THEN** 每行 SHALL 包含一个基于 recharts 的迷你折线图,展示最近的耗时趋势
#### Scenario: 详情面板完整趋势图 #### Scenario: 详情面板完整趋势图
- **WHEN** 用户展开目标详情面板 - **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: 页面加载与错误状态 ### Requirement: 页面加载与错误状态
Dashboard SHALL 正确处理加载状态和 API 错误。 Dashboard SHALL 正确处理加载状态和 API 错误。

View File

@@ -5,11 +5,11 @@
## Requirements ## Requirements
### Requirement: SQLite 数据库初始化 ### Requirement: SQLite 数据库初始化
系统 SHALL 使用 Bun 内置 `bun:sqlite` 模块在配置的数据目录下创建 SQLite 数据库文件,并以 WAL 模式运行。 系统 SHALL 使用 Bun 内置 `bun:sqlite` 模块在配置的数据目录下创建 SQLite 数据库文件,并以 WAL 模式运行。数据库 schema MUST 支持 typed checker target 和结构化检查结果。
#### Scenario: 首次启动创建数据库 #### Scenario: 首次启动创建数据库
- **WHEN** 指定的数据目录下不存在数据库文件 - **WHEN** 指定的数据目录下不存在数据库文件
- **THEN** 系统 SHALL 创建数据库文件并初始化 targets 和 check_results 表 - **THEN** 系统 SHALL 创建数据库文件并初始化包含 type、target、config、duration_ms、status_detail、failure 等字段的 targets 和 check_results 表
#### Scenario: 数据目录不存在 #### Scenario: 数据目录不存在
- **WHEN** 配置的数据目录路径不存在 - **WHEN** 配置的数据目录路径不存在
@@ -20,22 +20,26 @@
- **THEN** 系统 SHALL 直接打开数据库,不重新建表 - **THEN** 系统 SHALL 直接打开数据库,不重新建表
### Requirement: targets 表同步 ### Requirement: targets 表同步
系统 SHALL 在启动时将 YAML 配置中的目标列表同步到 SQLite targets 表。 系统 SHALL 在启动时将 YAML 配置中的目标列表同步到 SQLite targets 表,并持久化 target 类型、展示摘要、领域配置、调度配置和 expect 配置
#### Scenario: 首次同步目标 #### Scenario: 首次同步目标
- **WHEN** 数据库为空且 YAML 中定义了 N 个目标 - **WHEN** 数据库为空且 YAML 中定义了 N 个 typed target
- **THEN** 系统 SHALL 将所有目标插入 targets 表 - **THEN** 系统 SHALL 将所有目标插入 targets 表,包含 name、type、target、config、interval_ms、timeout_ms 和 expect
#### Scenario: 配置变更后重新同步 #### Scenario: 配置变更后重新同步
- **WHEN** YAML 配置发生变更(新增、删除或修改目标)后重启 - **WHEN** YAML 配置发生变更(新增、删除或修改目标)后重启
- **THEN** 系统 SHALL 根据 name 字段匹配:新增的插入、删除的移除、修改的更新 - **THEN** 系统 SHALL 根据 name 字段匹配:新增的插入、删除的移除、修改的更新
### Requirement: check_results 表追加写入 ### Requirement: check_results 表追加写入
系统 SHALL 将每次拨测结果追加写入 check_results 表,不更新或删除已有记录。 系统 SHALL 将每次检查结果追加写入 check_results 表,不更新或删除已有记录。
#### Scenario: 写入拨测结果 #### Scenario: 写入检查结果
- **WHEN** 一次拨测完成 - **WHEN** 一次 checker 执行完成
- **THEN** 系统 SHALL 插入一条包含 target_id、timestamp、success、status_code、latency_ms、error、matched 的记录 - **THEN** 系统 SHALL 插入一条包含 target_id、timestamp、success、matched、duration_ms、status_detail、failure 的记录
#### Scenario: 写入结构化失败信息
- **WHEN** checker 执行失败或 expect 不匹配
- **THEN** 系统 SHALL 将首个失败原因序列化写入 failure 字段
### Requirement: 时间范围查询索引 ### Requirement: 时间范围查询索引
系统 SHALL 在 check_results 表上创建 (target_id, timestamp) 复合索引,加速按目标和时间范围的查询。 系统 SHALL 在 check_results 表上创建 (target_id, timestamp) 复合索引,加速按目标和时间范围的查询。
@@ -45,16 +49,35 @@
- **THEN** 系统 SHALL 使用索引快速定位,无需全表扫描 - **THEN** 系统 SHALL 使用索引快速定位,无需全表扫描
### Requirement: 聚合查询支持 ### Requirement: 聚合查询支持
数据存储 SHALL 支持按时间段聚合查询,用于计算可用率、平均延迟、P99 延迟等统计指标。 数据存储 SHALL 支持按时间段聚合查询,用于计算可用率、平均耗时、P99 耗时等统计指标。
#### Scenario: 计算目标可用率 #### Scenario: 计算目标可用率
- **WHEN** 查询某目标在指定时间范围内的可用率 - **WHEN** 查询某目标在指定时间范围内的可用率
- **THEN** 系统 SHALL 返回 UP (success=true AND matched=true) 的记录数占总记录数的百分比 - **THEN** 系统 SHALL 返回 UP (success=true AND matched=true) 的记录数占总记录数的百分比
#### Scenario: 计算目标平均延迟 #### Scenario: 计算目标平均耗时
- **WHEN** 查询某目标在指定时间范围内的平均延迟 - **WHEN** 查询某目标在指定时间范围内的平均耗时
- **THEN** 系统 SHALL 返回 latency_ms 的平均值(仅计算 success=true 的记录) - **THEN** 系统 SHALL 返回 duration_ms 的平均值(仅计算 success=true 的记录)
#### Scenario: 按小时聚合趋势数据 #### Scenario: 按小时聚合趋势数据
- **WHEN** 查询某目标在指定时间范围内的趋势数据 - **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 创建两个独立定时器,分别按各自频率调度 - **THEN** 系统 SHALL 创建两个独立定时器,分别按各自频率调度
### Requirement: 组内并发拨测 ### Requirement: 组内并发拨测
系统 SHALL 在每次调度 tick 时,使用 `Promise.all` 并发执行同组内所有目标的拨测 系统 SHALL 在每次调度 tick 时并发执行同组内目标的检查,但实际同时运行的检查数 MUST 受全局 `runtime.maxConcurrentChecks` 限制
#### Scenario: 同组目标并发执行 #### Scenario: 同组目标并发执行
- **WHEN** 调度器触发一次 tick该组有 3 个目标 - **WHEN** 调度器触发一次 tick该组有 3 个目标,且全局并发余量至少为 3
- **THEN** 系统 SHALL 同时发起 3 个 HTTP 请求,而非顺序执行 - **THEN** 系统 SHALL 同时执行 3 个 checker,而非顺序执行
#### Scenario: 单个目标失败不影响同组其他目标 #### Scenario: 单个目标失败不影响同组其他目标
- **WHEN** 同组中某个目标的拨测请求超时或失败 - **WHEN** 同组中某个目标的检查请求超时或失败
- **THEN** 其他目标的拨测 SHALL 正常完成并记录结果 - **THEN** 其他目标的检查 SHALL 正常完成并记录结果
#### Scenario: 全局并发限制生效
- **WHEN** 调度器同时触发 10 个目标且 runtime.maxConcurrentChecks 为 3
- **THEN** 系统 MUST 同时最多运行 3 个检查,其余检查等待并发槽位释放
### Requirement: HTTP 拨测执行 ### 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 请求 #### Scenario: 执行 GET 请求
- **WHEN** 目标配置 method 为 GET - **WHEN** 目标配置 method 为 GET
@@ -41,82 +45,97 @@
- **WHEN** 目标配置了 headers如 Authorization - **WHEN** 目标配置了 headers如 Authorization
- **THEN** 系统 SHALL 在请求中包含所有配置的 headers - **THEN** 系统 SHALL 在请求中包含所有配置的 headers
### Requirement: 请求超时控制 #### Scenario: HTTP body 读取上限
系统 SHALL 对每次拨测请求实施超时控制,超时时间使用目标配置的 timeout 值。 - **WHEN** HTTP response body 超过该 target 的 maxBodyBytes
- **THEN** 系统 MUST 停止读取并记录 `success=false``matched=false` 和结构化输出超限错误
#### Scenario: 请求超时 ### Requirement: 请求超时控制
- **WHEN** 拨测请求在 timeout 时间内未收到响应 系统 SHALL 对每次 checker 执行实施超时控制,超时时间使用目标配置的 timeout 值。
#### Scenario: HTTP 请求超时
- **WHEN** HTTP 请求在 timeout 时间内未收到响应
- **THEN** 系统 SHALL 中止该请求,记录为失败并标注超时错误 - **THEN** 系统 SHALL 中止该请求,记录为失败并标注超时错误
#### Scenario: command 执行超时
- **WHEN** command 进程在 timeout 时间内未退出
- **THEN** 系统 MUST 终止该子进程,记录为失败并标注超时错误
#### Scenario: 请求在超时前完成 #### Scenario: 请求在超时前完成
- **WHEN** 拨测请求在 timeout 时间内收到响应 - **WHEN** checker 在超时前完成执行
- **THEN** 系统 SHALL 正常记录响应结果 - **THEN** 系统 SHALL 正常记录执行结果并进入 expect 校验
### Requirement: expect 校验 ### Requirement: expect 校验
系统 SHALL 在拨测完成后根据目标的 expect 配置校验响应,校验结果记入 check result。 系统 SHALL 在 checker 执行完成后根据目标类型的 expect 配置校验观测结果,校验结果和首个失败原因记入 check result。
#### Scenario: 校验状态码 #### Scenario: HTTP 默认状态码
- **WHEN** 目标配置 `expect.status: [200, 201]` - **WHEN** HTTP target 未配置 `expect.status`
- **THEN** 系统 SHALL 按默认 `status: [200]` 校验响应状态码
#### Scenario: 校验 HTTP 状态码
- **WHEN** HTTP target 配置了 `expect.status: [200, 201]`
- **THEN** 系统 SHALL 检查响应状态码是否在列表中,将匹配结果记录到 matched 字段 - **THEN** 系统 SHALL 检查响应状态码是否在列表中,将匹配结果记录到 matched 字段
#### Scenario: 校验响应头 #### Scenario: 校验 HTTP 响应头
- **WHEN** 目标配置了 `expect.headers: {"Content-Type": "application/json"}` - **WHEN** HTTP target 配置了 `expect.headers: {"Content-Type": {contains: "application/json"}}`
- **THEN** 系统 SHALL 检查响应头是否包含指定键值对,全部匹配时将 matched 设为 true - **THEN** 系统 SHALL 检查响应头是否符合指定规则,全部匹配时继续后续阶段
#### Scenario: 校验响应体包含 #### Scenario: 校验 HTTP 响应体
- **WHEN** 目标配置了 `expect.body.contains: "healthy"` - **WHEN** HTTP target 配置了有序 `expect.body` 规则数组
- **THEN** 系统 SHALL 检查响应体是否包含该文本,将匹配结果记录到 matched 字段 - **THEN** 系统 SHALL 按数组顺序执行 body 规则,任一失败立即记录 failure 并停止后续规则
#### Scenario: 校验响应体正则 #### Scenario: command 默认 exitCode
- **WHEN** 目标配置 `expect.body.regex: '"status"\\s*:\\s*"ok"'` - **WHEN** command target 未配置 `expect.exitCode`
- **THEN** 系统 SHALL 检查响应体是否匹配该正则,将匹配结果记录到 matched 字段 - **THEN** 系统 SHALL 按默认 `exitCode: [0]` 校验命令退出码
#### Scenario: 校验 JSON 响应 #### Scenario: 校验 command stdout
- **WHEN** 目标配置了 `expect.body.json: {"$.status": "ok"}` - **WHEN** command target 配置了有序 `expect.stdout` 规则数组
- **THEN** 系统 SHALL 解析 JSON 并检查 JSONPath 对应值是否符合期望,将匹配结果记录到 matched 字段 - **THEN** 系统 SHALL 按数组顺序执行 stdout 规则,任一失败立即记录 failure 并停止后续规则
#### Scenario: 校验 HTML 响应CSS 选择器) #### Scenario: 校验耗时阈值
- **WHEN** 目标配置了 `expect.body.css: {"div#health": "OK"}` - **WHEN** 目标配置了 `expect.maxDurationMs`
- **THEN** 系统 SHALL 解析 HTML 并用 CSS 选择器提取元素文本进行比较,将匹配结果记录到 matched 字段 - **THEN** 系统 SHALL 检查实际 durationMs 是否超过阈值,将匹配结果记录到 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: 多条 expect 规则 #### Scenario: 多条 expect 规则
- **WHEN** 目标同时配置了 status、headers、body.contains、body.json 和 maxLatencyMs - **WHEN** 目标同时配置状态、duration、元数据和内容规则
- **THEN** 系统 SHALL 所有规则全部通过时 matched 为 true任一不通过则为 false - **THEN** 系统 SHALL 所有规则全部通过时 matched 为 true任一不通过则为 false 并记录首个失败原因
#### Scenario: 多种 body 方法 AND 组合
- **WHEN** 目标在 body 分组下配置了 contains、json、css 多种方法
- **THEN** 系统 SHALL 按 contains → regex → json → css → xpath 顺序执行,任一失败立即返回 false
### Requirement: Body 校验按需解析 ### 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 #### Scenario: 仅配置 contains 时不解析 JSON
- **WHEN** 目标仅配置 `expect.body.contains` 而未配置 json/css/xpath - **WHEN** HTTP target 仅配置 body contains 规则而未配置 json/css/xpath 规则
- **THEN** 系统 SHALL 不执行 JSON.parse 或 HTML/XML 解析 - **THEN** 系统 SHALL 不执行 JSON.parse 或 HTML/XML 解析
#### Scenario: 配置 json 时解析 JSON 失败 #### Scenario: 配置 json 时解析 JSON 失败
- **WHEN** 目标配置了 `expect.body.json` 但响应体不是合法 JSON - **WHEN** HTTP target 配置了 body json 规则但响应体不是合法 JSON
- **THEN** 系统 SHALL 判定 matched 为 false - **THEN** 系统 SHALL 判定 matched 为 false,并记录 json 规则对应的 failure.path
### Requirement: 拨测结果记录 ### 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: 成功拨测结果记录 #### Scenario: 成功检查结果记录
- **WHEN** 拨测请求成功完成(收到 HTTP 响应) - **WHEN** checker 成功执行且 expect 全部匹配
- **THEN** 系统 SHALL 记录 success=true、status_code、latency_ms、matched - **THEN** 系统 SHALL 记录 success=true、matched=true、duration_ms、status_detailfailure 为 null
#### Scenario: 失败拨测结果记录 #### Scenario: 执行失败结果记录
- **WHEN** 拨测请求失败(网络错误、超时等) - **WHEN** checker 执行失败(网络错误、超时、命令启动失败、输出超限等)
- **THEN** 系统 SHALL 记录 success=false、error 信息status_code 和 latency_ms 为 null - **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 port: 3000
dataDir: "/tmp/probes_data" dataDir: "/tmp/probes_data"
runtime:
maxConcurrentChecks: 20
defaults: defaults:
interval: "5s" interval: "30s"
timeout: "10s" timeout: "10s"
method: "GET" http:
method: GET
maxBodyBytes: "10MB"
command:
maxOutputBytes: "1MB"
targets: targets:
- name: "Baidu" # ========== HTTP targets ==========
url: "https://www.baidu.com"
expect:
status: [200]
maxLatencyMs: 10000
- name: "JSON API 示例" - name: "Baidu 首页可用"
url: "https://httpbin.org/json" type: http
http:
url: "https://www.baidu.com"
expect: expect:
status: [200] status: [200]
maxDurationMs: 5000
- name: "JSON API — 完整流水线"
type: http
interval: "1m"
timeout: "15s"
http:
url: "https://httpbin.org/json"
headers: headers:
Content-Type: application/json Accept: "application/json"
expect:
headers:
Content-Type:
contains: "application/json"
maxDurationMs: 8000
body: body:
contains: "slideshow" - json:
json: path: "$.slideshow.title"
$.slideshow.title: "Sample Slide Show" 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 选择器"
url: "https://httpbin.org/html" 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: expect:
status: [200] status: [200]
body: body:
contains: "Moby-Dick" - json:
xpath: path: "$.json.action"
"/html/body/h1/text()": "Herman Melville - Moby-Dick" 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"; import { resolve } from "node:path";
const root = resolve(import.meta.dir, ".."); const root = resolve(import.meta.dir, "..");
@@ -9,7 +9,7 @@ const patterns: Array<{ glob: string; desc: string }> = [
]; ];
for (const { glob, desc } of patterns) { 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; if (entries.length === 0) continue;
for (const entry of entries) { for (const entry of entries) {
const full = resolve(root, entry); const full = resolve(root, entry);

View File

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

View File

@@ -1,5 +1,6 @@
import type { import type {
ApiErrorResponse, ApiErrorResponse,
CheckFailure,
CheckResult, CheckResult,
HealthResponse, HealthResponse,
RuntimeMode, RuntimeMode,
@@ -136,7 +137,7 @@ function handleTrend(idStr: string, url: URL, method: string, store: ProbeStore,
const trend: TrendPoint[] = store.getTrend(id, hours).map((row) => ({ const trend: TrendPoint[] = store.getTrend(id, hours).map((row) => ({
hour: row.hour, hour: row.hour,
avgLatencyMs: row.avgLatencyMs, avgDurationMs: row.avgDurationMs,
availability: Math.round(row.availability * 100) / 100, availability: Math.round(row.availability * 100) / 100,
totalChecks: row.totalChecks, totalChecks: row.totalChecks,
})); }));
@@ -150,7 +151,7 @@ function createSummaryResponse(store: ProbeStore): SummaryResponse {
total: summary.total, total: summary.total,
up: summary.up, up: summary.up,
down: summary.down, down: summary.down,
avgLatencyMs: summary.avgLatencyMs, avgDurationMs: summary.avgDurationMs,
lastCheckTime: summary.lastCheckTime, lastCheckTime: summary.lastCheckTime,
}; };
} }
@@ -165,29 +166,34 @@ function createTargetsResponse(store: ProbeStore): TargetStatus[] {
return { return {
id: target.id, id: target.id,
name: target.name, name: target.name,
url: target.url, type: target.type,
method: target.method, target: target.target,
interval: formatDuration(target.interval_ms), interval: formatDuration(target.interval_ms),
latestCheck: latest ? mapCheckResult(latest) : null, latestCheck: latest ? mapCheckResult(latest) : null,
sparkline: store.getSparkline(target.id), sparkline: store.getSparkline(target.id),
stats: { stats: {
totalChecks: stats.totalChecks, totalChecks: stats.totalChecks,
availability: stats.availability, availability: stats.availability,
avgLatencyMs: stats.avgLatencyMs, avgDurationMs: stats.avgDurationMs,
p99LatencyMs: stats.p99LatencyMs, p99DurationMs: stats.p99DurationMs,
}, },
}; };
}); });
} }
function mapCheckResult(row: StoredCheckResult): CheckResult { function mapCheckResult(row: StoredCheckResult): CheckResult {
let failure: CheckFailure | null = null;
if (row.failure) {
failure = JSON.parse(row.failure) as CheckFailure;
}
return { return {
timestamp: row.timestamp, timestamp: row.timestamp,
success: row.success === 1, success: row.success === 1,
statusCode: row.status_code,
latencyMs: row.latency_ms,
error: row.error,
matched: row.matched === 1, 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_HOST = "127.0.0.1";
const DEFAULT_PORT = 3000; const DEFAULT_PORT = 3000;
const DEFAULT_DATA_DIR = "./data"; const DEFAULT_DATA_DIR = "./data";
const DEFAULT_INTERVAL = "30s"; const DEFAULT_INTERVAL = "30s";
const DEFAULT_TIMEOUT = "10s"; 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 { export interface ResolvedConfig {
host: string; host: string;
port: number; port: number;
dataDir: string; dataDir: string;
configDir: string;
maxConcurrentChecks: number;
targets: ResolvedTarget[]; targets: ResolvedTarget[];
} }
@@ -30,7 +53,9 @@ export async function loadConfig(configPath: string): Promise<ResolvedConfig> {
validateConfig(raw); validateConfig(raw);
const configDir = dirname(resolve(configPath));
const server = raw.server ?? {}; const server = raw.server ?? {};
const runtime = raw.runtime ?? {};
const defaults = raw.defaults ?? {}; const defaults = raw.defaults ?? {};
const host = server.host ?? DEFAULT_HOST; const host = server.host ?? DEFAULT_HOST;
@@ -41,23 +66,102 @@ export async function loadConfig(configPath: string): Promise<ResolvedConfig> {
throw new Error(`无效端口号: ${port},需要 0-65535 之间的整数`); throw new Error(`无效端口号: ${port},需要 0-65535 之间的整数`);
} }
const maxConcurrentChecks = validateRuntime(runtime);
const defaultIntervalMs = parseDuration(defaults.interval ?? DEFAULT_INTERVAL); const defaultIntervalMs = parseDuration(defaults.interval ?? DEFAULT_INTERVAL);
const defaultTimeoutMs = parseDuration(defaults.timeout ?? DEFAULT_TIMEOUT); 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, name: target.name,
url: target.url, http: {
method: target.method ?? defaultMethod, url: target.http.url,
headers: { ...defaultHeaders, ...(target.headers ?? {}) }, method: target.http.method ?? httpDefaults?.method ?? DEFAULT_HTTP_METHOD,
body: target.body, headers: { ...(httpDefaults?.headers ?? {}), ...(target.http.headers ?? {}) },
intervalMs: parseDuration(target.interval ?? defaults.interval ?? DEFAULT_INTERVAL), body: target.http.body,
timeoutMs: parseDuration(target.timeout ?? defaults.timeout ?? DEFAULT_TIMEOUT), maxBodyBytes,
expect: target.expect, },
})); 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 { function validateConfig(config: ProbeConfig): void {
@@ -68,21 +172,41 @@ function validateConfig(config: ProbeConfig): void {
const names = new Set<string>(); const names = new Set<string>();
for (let i = 0; i < config.targets.length; i++) { 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 字段`); throw new Error(`${i + 1} 个 target 缺少 name 字段`);
} }
if (!target.url || typeof target.url !== "string" || target.url.trim() === "") { const type = raw["type"];
throw new Error(`target "${target.name}" 缺少 url 字段`); if (!type || typeof type !== "string") {
throw new Error(`target "${name}" 缺少 type 字段`);
} }
if (names.has(target.name)) { if (!SUPPORTED_TYPES.includes(type as TargetType)) {
throw new Error(`target name 重复: "${target.name}"`); 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 { export function parseDuration(value: string): number {
const match = DURATION_REGEX.exec(value); const match = DURATION_REGEX.exec(value);
if (!match) { if (!match) {
throw new Error(`无效的时长格式: "${value}",支持格式如 "30s"、"5m"、"500ms"`); throw new Error(`无效的时长格式: "${value}",支持格式如 "30s"、"5m"、"500ms"`);
} }

View File

@@ -1,15 +1,21 @@
import type { CheckResult, ResolvedTarget } from "./types"; import type { CheckResult, ResolvedTarget } from "./types";
import type { ProbeStore } from "./store"; import type { ProbeStore } from "./store";
import { fetchTarget } from "./fetcher"; import { runHttpCheck } from "./fetcher";
import { runCommandCheck } from "./command-runner";
export class ProbeEngine { export class ProbeEngine {
private timers: ReturnType<typeof setInterval>[] = []; private timers: ReturnType<typeof setInterval>[] = [];
private store: ProbeStore; private store: ProbeStore;
private targets: ResolvedTarget[];
private targetNameToId: Map<string, number> = new Map(); 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.store = store;
this.targets = targets; this.targets = targets;
this.maxConcurrentChecks = maxConcurrentChecks ?? 10;
this.refreshCache(); this.refreshCache();
} }
@@ -46,8 +52,36 @@ export class ProbeEngine {
return groups; 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> { 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) { for (const result of results) {
if (result.status === "fulfilled") { if (result.status === "fulfilled") {
@@ -56,23 +90,27 @@ export class ProbeEngine {
} }
} }
private async probeOne(target: ResolvedTarget): Promise<CheckResult> { private async runCheck(target: ResolvedTarget): Promise<CheckResult> {
return fetchTarget(target); switch (target.type) {
case "http":
return runHttpCheck(target);
case "command":
return runCommandCheck(target);
}
} }
private writeResult(result: CheckResult): void { private writeResult(result: CheckResult): void {
const targetId = this.targetNameToId.get(result.targetName); const targetId = this.targetNameToId.get(result.targetName);
if (!targetId) return; if (!targetId) return;
this.store.insertCheckResult({ this.store.insertCheckResult({
targetId, targetId,
timestamp: result.timestamp, timestamp: result.timestamp,
success: result.success, success: result.success,
statusCode: result.statusCode,
latencyMs: result.latencyMs,
error: result.error,
matched: result.matched, 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); 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 type { CheckResult, ResolvedHttpTarget } from "./types";
import { checkBodyExpect } from "./body-expect"; import { checkHttpExpect } from "./expect/http";
import { errorFailure } from "./expect/failure";
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);
}
}
function headersToRecord(headers: Headers): Record<string, string> { function headersToRecord(headers: Headers): Record<string, string> {
const result: Record<string, string> = {}; const result: Record<string, string> = {};
@@ -56,35 +10,95 @@ function headersToRecord(headers: Headers): Record<string, string> {
return result; return result;
} }
export function checkExpect( export async function runHttpCheck(target: ResolvedHttpTarget): Promise<CheckResult> {
statusCode: number, const timestamp = new Date().toISOString();
body: string, const controller = new AbortController();
latencyMs: number, const timeoutId = setTimeout(() => controller.abort(), target.timeoutMs);
responseHeaders: Record<string, string>,
expect?: ExpectConfig,
): boolean {
if (!expect) return true;
if (expect.status && !expect.status.includes(statusCode)) { try {
return false; const start = performance.now();
}
if (expect.headers) { const response = await fetch(target.http.url, {
for (const [key, expectedValue] of Object.entries(expect.headers)) { method: target.http.method,
const actualValue = responseHeaders[key.toLowerCase()]; headers: target.http.headers,
if (!actualValue || actualValue !== expectedValue) { body: target.http.method !== "GET" && target.http.method !== "HEAD" ? target.http.body : undefined,
return false; 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 (!checkBodyExpect(body, expect.body)) { const bodyBuffer = await response.arrayBuffer();
return false; clearTimeout(timeoutId);
}
if (expect.maxLatencyMs !== undefined && latencyMs > expect.maxLatencyMs) { if (bodyBuffer.byteLength > target.http.maxBodyBytes) {
return false; return {
} targetName: target.name,
timestamp,
success: false,
matched: false,
durationMs,
statusDetail: `HTTP ${statusCode}`,
failure: errorFailure(
"body",
"body",
`响应体大小 ${bodyBuffer.byteLength} 超过限制 ${target.http.maxBodyBytes}`,
),
};
}
return true; const body = new TextDecoder().decode(bodyBuffer);
const fullObs = { statusCode, headers: responseHeaders, body, durationMs };
const fullResult = checkHttpExpect(fullObs, target.expect);
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 {
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 { Database } from "bun:sqlite";
import { mkdirSync as fsMkdirSync } from "node:fs"; import { mkdirSync as fsMkdirSync } from "node:fs";
import { dirname } from "node:path"; import { dirname } from "node:path";
import type { ResolvedTarget, StoredCheckResult, StoredTarget } from "./types"; import type { CheckFailure, ResolvedTarget, StoredCheckResult, StoredTarget } from "./types";
const CREATE_TARGETS_TABLE = ` const CREATE_TARGETS_TABLE = `
CREATE TABLE IF NOT EXISTS targets ( CREATE TABLE IF NOT EXISTS targets (
id INTEGER PRIMARY KEY AUTOINCREMENT, id INTEGER PRIMARY KEY AUTOINCREMENT,
name TEXT NOT NULL UNIQUE, name TEXT NOT NULL UNIQUE,
url TEXT NOT NULL, type TEXT NOT NULL,
method TEXT NOT NULL DEFAULT 'GET', target TEXT NOT NULL,
headers TEXT NOT NULL DEFAULT '{}', config TEXT NOT NULL DEFAULT '{}',
body TEXT,
interval_ms INTEGER NOT NULL, interval_ms INTEGER NOT NULL,
timeout_ms INTEGER NOT NULL, timeout_ms INTEGER NOT NULL,
expect TEXT expect TEXT
@@ -23,10 +22,10 @@ CREATE TABLE IF NOT EXISTS check_results (
target_id INTEGER NOT NULL, target_id INTEGER NOT NULL,
timestamp TEXT NOT NULL, timestamp TEXT NOT NULL,
success INTEGER NOT NULL, success INTEGER NOT NULL,
status_code INTEGER,
latency_ms REAL,
error TEXT,
matched INTEGER NOT NULL, matched INTEGER NOT NULL,
duration_ms REAL,
status_detail TEXT,
failure TEXT,
FOREIGN KEY (target_id) REFERENCES targets(id) FOREIGN KEY (target_id) REFERENCES targets(id)
) )
`; `;
@@ -59,40 +58,24 @@ export class ProbeStore {
const configNames = new Set(targets.map((t) => t.name)); const configNames = new Set(targets.map((t) => t.name));
const insertStmt = this.db.prepare( 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( 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 deleteStmt = this.db.prepare("DELETE FROM targets WHERE id = ?");
const tx = this.db.transaction(() => { const tx = this.db.transaction(() => {
for (const target of targets) { for (const t of targets) {
const headers = JSON.stringify(target.headers); const type = t.type;
const expect = target.expect ? JSON.stringify(target.expect) : null; const target = buildTargetDisplay(t);
const config = buildTargetConfig(t);
const expect = t.expect ? JSON.stringify(t.expect) : null;
if (existingMap.has(target.name)) { if (existingMap.has(t.name)) {
updateStmt.run( updateStmt.run(type, target, config, t.intervalMs, t.timeoutMs, expect, existingMap.get(t.name)!);
target.url,
target.method,
headers,
target.body ?? null,
target.intervalMs,
target.timeoutMs,
expect,
existingMap.get(target.name)!,
);
} else { } else {
insertStmt.run( insertStmt.run(t.name, type, target, config, t.intervalMs, t.timeoutMs, expect);
target.name,
target.url,
target.method,
headers,
target.body ?? null,
target.intervalMs,
target.timeoutMs,
expect,
);
} }
} }
@@ -120,24 +103,24 @@ export class ProbeStore {
targetId: number; targetId: number;
timestamp: string; timestamp: string;
success: boolean; success: boolean;
statusCode: number | null;
latencyMs: number | null;
error: string | null;
matched: boolean; matched: boolean;
durationMs: number | null;
statusDetail: string | null;
failure: CheckFailure | null;
}): void { }): void {
if (this.closed) return; if (this.closed) return;
this.db this.db
.prepare( .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( .run(
result.targetId, result.targetId,
result.timestamp, result.timestamp,
result.success ? 1 : 0, result.success ? 1 : 0,
result.statusCode,
result.latencyMs,
result.error,
result.matched ? 1 : 0, 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): { getTargetStats(targetId: number): {
totalChecks: number; totalChecks: number;
availability: number; availability: number;
avgLatencyMs: number | null; avgDurationMs: number | null;
p99LatencyMs: number | null; p99DurationMs: number | null;
} { } {
const row = this.db const row = this.db
.prepare( .prepare(
`SELECT `SELECT
COUNT(*) as totalChecks, COUNT(*) as totalChecks,
COALESCE(SUM(CASE WHEN matched = 1 THEN 1 ELSE 0 END), 0) as upCount, 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 FROM check_results
WHERE target_id = ?`, 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 const p99Row = this.db
.prepare( .prepare(
`SELECT latency_ms as p99LatencyMs `SELECT duration_ms as p99DurationMs
FROM check_results FROM check_results
WHERE target_id = ? AND success = 1 WHERE target_id = ? AND success = 1
ORDER BY latency_ms DESC ORDER BY duration_ms DESC
LIMIT 1 LIMIT 1
OFFSET (SELECT COUNT(*) FROM check_results WHERE target_id = ? AND success = 1) * 99 / 100`, 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 totalChecks = row.totalChecks;
const availability = totalChecks > 0 ? (row.upCount / totalChecks) * 100 : 0; const availability = totalChecks > 0 ? (row.upCount / totalChecks) * 100 : 0;
@@ -187,8 +170,8 @@ export class ProbeStore {
return { return {
totalChecks, totalChecks,
availability: Math.round(availability * 100) / 100, availability: Math.round(availability * 100) / 100,
avgLatencyMs: row.avgLatencyMs !== null ? Math.round(row.avgLatencyMs * 100) / 100 : null, avgDurationMs: row.avgDurationMs !== null ? Math.round(row.avgDurationMs * 100) / 100 : null,
p99LatencyMs: p99Row?.p99LatencyMs ?? null, p99DurationMs: p99Row?.p99DurationMs ?? null,
}; };
} }
@@ -197,7 +180,7 @@ export class ProbeStore {
hours = 24, hours = 24,
): Array<{ ): Array<{
hour: string; hour: string;
avgLatencyMs: number | null; avgDurationMs: number | null;
availability: number; availability: number;
totalChecks: number; totalChecks: number;
}> { }> {
@@ -205,7 +188,7 @@ export class ProbeStore {
.prepare( .prepare(
`SELECT `SELECT
strftime('%Y-%m-%dT%H:00:00', timestamp) as hour, 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, 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 COUNT(*) as totalChecks
FROM check_results FROM check_results
@@ -215,7 +198,7 @@ export class ProbeStore {
) )
.all(targetId, hours) as Array<{ .all(targetId, hours) as Array<{
hour: string; hour: string;
avgLatencyMs: number | null; avgDurationMs: number | null;
availability: number; availability: number;
totalChecks: number; totalChecks: number;
}>; }>;
@@ -225,14 +208,14 @@ export class ProbeStore {
total: number; total: number;
up: number; up: number;
down: number; down: number;
avgLatencyMs: number | null; avgDurationMs: number | null;
lastCheckTime: string | null; lastCheckTime: string | null;
} { } {
const targets = this.getTargets(); const targets = this.getTargets();
let up = 0; let up = 0;
let down = 0; let down = 0;
let totalLatency = 0; let totalDuration = 0;
let latencyCount = 0; let durationCount = 0;
let lastCheckTime: string | null = null; let lastCheckTime: string | null = null;
for (const target of targets) { for (const target of targets) {
@@ -245,9 +228,9 @@ export class ProbeStore {
down++; down++;
} }
if (latest.latency_ms !== null) { if (latest.duration_ms !== null) {
totalLatency += latest.latency_ms; totalDuration += latest.duration_ms;
latencyCount++; durationCount++;
} }
if (!lastCheckTime || latest.timestamp > lastCheckTime) { if (!lastCheckTime || latest.timestamp > lastCheckTime) {
@@ -262,7 +245,7 @@ export class ProbeStore {
total: targets.length, total: targets.length,
up, up,
down, down,
avgLatencyMs: latencyCount > 0 ? Math.round((totalLatency / latencyCount) * 100) / 100 : null, avgDurationMs: durationCount > 0 ? Math.round((totalDuration / durationCount) * 100) / 100 : null,
lastCheckTime, lastCheckTime,
}; };
} }
@@ -270,10 +253,10 @@ export class ProbeStore {
getSparkline(targetId: number, limit = 20): number[] { getSparkline(targetId: number, limit = 20): number[] {
const rows = this.db const rows = this.db
.prepare( .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 }>; .all(targetId, limit) as Array<{ duration_ms: number }>;
return rows.map((r) => r.latency_ms).reverse(); return rows.map((r) => r.duration_ms).reverse();
} }
close(): void { 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 { function ensureDir(dir: string): void {
try { try {
fsMkdirSync(dir, { recursive: true }); fsMkdirSync(dir, { recursive: true });

View File

@@ -1,5 +1,8 @@
export type TargetType = "http" | "command";
export interface ProbeConfig { export interface ProbeConfig {
server?: ServerConfig; server?: ServerConfig;
runtime?: RuntimeConfig;
defaults?: DefaultsConfig; defaults?: DefaultsConfig;
targets: TargetConfig[]; targets: TargetConfig[];
} }
@@ -10,19 +13,49 @@ export interface ServerConfig {
dataDir?: string; 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 { export interface DefaultsConfig {
interval?: string; interval?: string;
timeout?: string; timeout?: string;
method?: string; http?: HttpDefaultsConfig;
headers?: Record<string, string>; command?: CommandDefaultsConfig;
} }
export interface TargetConfig { export interface HttpTargetConfig {
name: string;
url: string; url: string;
method?: string; method?: string;
headers?: Record<string, string>; headers?: Record<string, string>;
body?: 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; interval?: string;
timeout?: string; timeout?: string;
expect?: ExpectConfig; expect?: ExpectConfig;
@@ -42,51 +75,100 @@ export interface ExpectOperator {
export type ExpectValue = string | number | boolean | null | ExpectOperator; export type ExpectValue = string | number | boolean | null | ExpectOperator;
export type CssExpect = ExpectValue | (ExpectOperator & { attr?: string }); export type TextRule = ExpectOperator;
export interface BodyExpectConfig { export type JsonRule = { path: string } & ExpectOperator;
contains?: string;
regex?: string;
json?: Record<string, ExpectValue>;
css?: Record<string, CssExpect>;
xpath?: Record<string, ExpectValue>;
}
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[]; status?: number[];
maxLatencyMs?: number; maxDurationMs?: number;
headers?: Record<string, string>; headers?: Record<string, HeaderExpect>;
body?: BodyExpectConfig; 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; name: string;
http: ResolvedHttpConfig;
intervalMs: number;
timeoutMs: number;
expect?: HttpExpectConfig;
}
export interface ResolvedHttpConfig {
url: string; url: string;
method: string; method: string;
headers: Record<string, string>; headers: Record<string, string>;
body?: string; body?: string;
maxBodyBytes: number;
}
export interface ResolvedCommandTarget {
type: "command";
name: string;
command: ResolvedCommandConfig;
intervalMs: number; intervalMs: number;
timeoutMs: 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 { export interface CheckResult {
targetName: string; targetName: string;
timestamp: string; timestamp: string;
success: boolean; success: boolean;
statusCode: number | null;
latencyMs: number | null;
error: string | null;
matched: boolean; matched: boolean;
durationMs: number | null;
statusDetail: string | null;
failure: CheckFailure | null;
} }
export interface StoredTarget { export interface StoredTarget {
id: number; id: number;
name: string; name: string;
url: string; type: TargetType;
method: string; target: string;
headers: string; config: string;
body: string | null;
interval_ms: number; interval_ms: number;
timeout_ms: number; timeout_ms: number;
expect: string | null; expect: string | null;
@@ -97,8 +179,8 @@ export interface StoredCheckResult {
target_id: number; target_id: number;
timestamp: string; timestamp: string;
success: number; success: number;
status_code: number | null;
latency_ms: number | null;
error: string | null;
matched: number; 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`); const store = new ProbeStore(`${config.dataDir}/probe.db`);
store.syncTargets(config.targets); store.syncTargets(config.targets);
const engine = new ProbeEngine(store, config.targets); const engine = new ProbeEngine(store, config.targets, config.maxConcurrentChecks);
engine.start(); engine.start();
startServer({ startServer({

View File

@@ -15,15 +15,15 @@ export interface SummaryResponse {
total: number; total: number;
up: number; up: number;
down: number; down: number;
avgLatencyMs: number | null; avgDurationMs: number | null;
lastCheckTime: string | null; lastCheckTime: string | null;
} }
export interface TargetStatus { export interface TargetStatus {
id: number; id: number;
name: string; name: string;
url: string; type: string;
method: string; target: string;
interval: string; interval: string;
latestCheck: CheckResult | null; latestCheck: CheckResult | null;
stats: TargetStats; stats: TargetStats;
@@ -33,22 +33,31 @@ export interface TargetStatus {
export interface TargetStats { export interface TargetStats {
totalChecks: number; totalChecks: number;
availability: number; availability: number;
avgLatencyMs: number | null; avgDurationMs: number | null;
p99LatencyMs: number | null; p99DurationMs: number | null;
} }
export interface CheckResult { export interface CheckResult {
timestamp: string; timestamp: string;
success: boolean; success: boolean;
statusCode: number | null;
latencyMs: number | null;
error: string | null;
matched: boolean; 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 { export interface TrendPoint {
hour: string; hour: string;
avgLatencyMs: number | null; avgDurationMs: number | null;
availability: number; availability: number;
totalChecks: number; totalChecks: number;
} }

View File

@@ -1,7 +1,7 @@
import { Line, LineChart, ResponsiveContainer } from "recharts"; import { Line, LineChart, ResponsiveContainer } from "recharts";
interface SparklineChartProps { interface SparklineChartProps {
data: Array<{ latency: number }>; data: Array<{ duration: number }>;
} }
export function SparklineChart({ data }: SparklineChartProps) { export function SparklineChart({ data }: SparklineChartProps) {
@@ -12,7 +12,7 @@ export function SparklineChart({ data }: SparklineChartProps) {
return ( return (
<ResponsiveContainer width={80} height={32}> <ResponsiveContainer width={80} height={32}>
<LineChart data={data}> <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> </LineChart>
</ResponsiveContainer> </ResponsiveContainer>
); );

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -26,18 +26,27 @@ describe("API 路由", () => {
store = new ProbeStore(join(tempDir, "test.db")); store = new ProbeStore(join(tempDir, "test.db"));
store.syncTargets([ store.syncTargets([
{ {
type: "http",
name: "test-a", name: "test-a",
url: "http://a.com", http: {
method: "GET", url: "http://a.com",
headers: {}, method: "GET",
headers: {},
maxBodyBytes: 104857600,
},
intervalMs: 30000, intervalMs: 30000,
timeoutMs: 10000, timeoutMs: 10000,
}, },
{ {
type: "command",
name: "test-b", name: "test-b",
url: "http://b.com", command: {
method: "POST", exec: "echo",
headers: {}, args: ["hello"],
cwd: "/tmp",
env: {},
maxOutputBytes: 104857600,
},
intervalMs: 60000, intervalMs: 60000,
timeoutMs: 5000, timeoutMs: 5000,
}, },
@@ -48,19 +57,26 @@ describe("API 路由", () => {
targetId: targets[0]!.id, targetId: targets[0]!.id,
timestamp: "2025-01-01T00:00:00.000Z", timestamp: "2025-01-01T00:00:00.000Z",
success: true, success: true,
statusCode: 200,
latencyMs: 150,
error: null,
matched: true, matched: true,
durationMs: 150,
statusDetail: "200 OK",
failure: null,
}); });
store.insertCheckResult({ store.insertCheckResult({
targetId: targets[0]!.id, targetId: targets[0]!.id,
timestamp: "2025-01-01T00:00:30.000Z", timestamp: "2025-01-01T00:00:30.000Z",
success: false, success: false,
statusCode: null,
latencyMs: null,
error: "timeout",
matched: false, matched: false,
durationMs: null,
statusDetail: null,
failure: {
kind: "error",
phase: "status",
path: "$.status",
expected: 200,
actual: 500,
message: "状态码不匹配",
},
}); });
fetchHandler = createFetchHandler({ mode: "test", staticAssets, store }); fetchHandler = createFetchHandler({ mode: "test", staticAssets, store });
@@ -88,6 +104,7 @@ describe("API 路由", () => {
expect(body.up).toBeGreaterThanOrEqual(0); expect(body.up).toBeGreaterThanOrEqual(0);
expect(body.down).toBeGreaterThanOrEqual(0); expect(body.down).toBeGreaterThanOrEqual(0);
expect(body.up + body.down).toBe(2); expect(body.up + body.down).toBe(2);
expect(body.avgDurationMs).toBeDefined();
}); });
test("/api/targets 返回目标列表", async () => { test("/api/targets 返回目标列表", async () => {
@@ -96,12 +113,23 @@ describe("API 路由", () => {
expect(response.status).toBe(200); expect(response.status).toBe(200);
expect(body).toHaveLength(2); expect(body).toHaveLength(2);
expect(body[0]!.name).toBe("test-a");
expect(body[0]!.latestCheck).not.toBeNull(); const tA = body.find((t) => t.name === "test-a")!;
expect(body[0]!.latestCheck!.success).toBe(false); expect(tA.type).toBe("http");
expect(body[0]!.sparkline).toBeDefined(); expect(tA.target).toBe("http://a.com");
expect(Array.isArray(body[0]!.sparkline)).toBe(true); expect(tA.latestCheck).not.toBeNull();
expect(body[1]!.latestCheck).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 () => { test("/api/targets/:id/history 返回历史记录", async () => {
@@ -111,6 +139,8 @@ describe("API 路由", () => {
expect(response.status).toBe(200); expect(response.status).toBe(200);
expect(body).toHaveLength(2); expect(body).toHaveLength(2);
expect(body[0].failure).not.toBeNull();
expect(body[0].failure.kind).toBe("error");
}); });
test("/api/targets/:id/history 支持 limit 参数", async () => { 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 }); 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 () => { test("解析完整配置", async () => {
const configPath = join(tempDir, "full.yaml"); const configPath = join(tempDir, "full.yaml");
await writeFile( await writeFile(
@@ -63,13 +123,36 @@ describe("loadConfig", () => {
host: "0.0.0.0" host: "0.0.0.0"
port: 8080 port: 8080
dataDir: "./my-data" dataDir: "./my-data"
runtime:
maxConcurrentChecks: 5
defaults: defaults:
interval: "15s" interval: "15s"
timeout: "5s" timeout: "5s"
method: "POST" http:
method: "POST"
headers:
Authorization: "Bearer token"
maxBodyBytes: "50MB"
command:
cwd: "/tmp"
maxOutputBytes: "10MB"
targets: targets:
- name: "test" - name: "http-target"
url: "http://example.com" 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.host).toBe("0.0.0.0");
expect(config.port).toBe(8080); expect(config.port).toBe(8080);
expect(config.dataDir).toBe("./my-data"); expect(config.dataDir).toBe("./my-data");
expect(config.targets).toHaveLength(1); expect(config.maxConcurrentChecks).toBe(5);
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.targets).toHaveLength(2); 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 () => { test("per-target 覆盖 defaults", async () => {
@@ -119,26 +190,29 @@ targets:
`defaults: `defaults:
interval: "30s" interval: "30s"
timeout: "10s" timeout: "10s"
method: "GET" http:
headers: method: "GET"
Authorization: "Bearer token" maxBodyBytes: "10MB"
targets: targets:
- name: "override-all" - name: "override-all"
url: "http://example.com" type: http
method: "POST"
interval: "5m" interval: "5m"
timeout: "30s" timeout: "30s"
headers: http:
X-Custom: "value" url: "http://example.com"
method: "POST"
maxBodyBytes: "1MB"
`, `,
); );
const config = await loadConfig(configPath); const config = await loadConfig(configPath);
const target = config.targets[0]!; const t = config.targets[0]!;
expect(target.method).toBe("POST"); if (t.type === "http") {
expect(target.intervalMs).toBe(300000); expect(t.http.method).toBe("POST");
expect(target.timeoutMs).toBe(30000); expect(t.intervalMs).toBe(300000);
expect(target.headers).toEqual({ Authorization: "Bearer token", "X-Custom": "value" }); expect(t.timeoutMs).toBe(30000);
expect(t.http.maxBodyBytes).toBe(1048576);
}
}); });
test("配置文件不存在抛出错误", async () => { test("配置文件不存在抛出错误", async () => {
@@ -150,23 +224,63 @@ targets:
await writeFile( await writeFile(
configPath, configPath,
`targets: `targets:
- url: "http://example.com" - type: http
http:
url: "http://example.com"
`, `,
); );
await expect(loadConfig(configPath)).rejects.toThrow("缺少 name 字段"); 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"); const configPath = join(tempDir, "no-url.yaml");
await writeFile( await writeFile(
configPath, configPath,
`targets: `targets:
- name: "test" - 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 () => { test("target name 重复抛出错误", async () => {
@@ -175,19 +289,21 @@ targets:
configPath, configPath,
`targets: `targets:
- name: "dup" - name: "dup"
url: "http://a.com" type: http
http:
url: "http://a.com"
- name: "dup" - name: "dup"
url: "http://b.com" type: http
http:
url: "http://b.com"
`, `,
); );
await expect(loadConfig(configPath)).rejects.toThrow("target name 重复"); await expect(loadConfig(configPath)).rejects.toThrow("target name 重复");
}); });
test("targets 为空数组抛出错误", async () => { test("targets 为空数组抛出错误", async () => {
const configPath = join(tempDir, "empty-targets.yaml"); const configPath = join(tempDir, "empty-targets.yaml");
await writeFile(configPath, `targets: []`); await writeFile(configPath, `targets: []`);
await expect(loadConfig(configPath)).rejects.toThrow("至少一个 target"); await expect(loadConfig(configPath)).rejects.toThrow("至少一个 target");
}); });
@@ -199,33 +315,168 @@ targets:
port: 99999 port: 99999
targets: targets:
- name: "t" - name: "t"
url: "http://a.com" type: http
http:
url: "http://a.com"
`, `,
); );
await expect(loadConfig(configPath)).rejects.toThrow("无效端口号"); 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 () => { test("解析 expect 配置", async () => {
const configPath = join(tempDir, "expect.yaml"); const configPath = join(tempDir, "expect.yaml");
await writeFile( await writeFile(
configPath, configPath,
`targets: `targets:
- name: "with-expect" - name: "with-expect"
url: "http://example.com" type: http
http:
url: "http://example.com"
expect: expect:
status: [200, 201] status: [200, 201]
body: body:
contains: "ok" - contains: "ok"
maxLatencyMs: 3000 - json:
path: "$.status"
equals: "ok"
maxDurationMs: 3000
`, `,
); );
const config = await loadConfig(configPath); const config = await loadConfig(configPath);
expect(config.targets[0]!.expect).toEqual({ const t = config.targets[0]!;
status: [200, 201], if (t.type === "http") {
body: { contains: "ok" }, expect(t.expect).toEqual({
maxLatencyMs: 3000, status: [200, 201],
}); 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 { describe, expect, test } from "bun:test";
import { ProbeStore } from "../../../src/server/checker/store";
import { ProbeEngine } from "../../../src/server/checker/engine"; import { ProbeEngine } from "../../../src/server/checker/engine";
import type { ResolvedTarget } from "../../../src/server/checker/types"; import type { ProbeStore } from "../../../src/server/checker/store";
import { mkdir, rm } from "node:fs/promises"; import type { ResolvedCommandTarget, ResolvedHttpTarget, ResolvedTarget } from "../../../src/server/checker/types";
import { join } from "node:path";
import { tmpdir } from "node:os"; 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", () => { describe("ProbeEngine", () => {
let tempDir: string; test("start/stop 不抛错", () => {
let store: ProbeStore; const mockStore = createMockStore(["test"]) as unknown as ProbeStore;
const targets: ResolvedTarget[] = [makeCommandTarget("test")];
const target: ResolvedTarget = { const engine = new ProbeEngine(mockStore, targets);
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]);
engine.start(); engine.start();
engine.stop(); engine.stop();
expect(true).toBe(true); expect(true).toBe(true);
}); });
test("单次拨测写入数据库", async () => { test("单次 probeGroup 执行 command 检查", async () => {
const engine = new ProbeEngine(store, [target]); const target = makeCommandTarget("cmd-echo");
// 手动调用 probeGroup 不启动 timer 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( const probeGroup = (engine as unknown as { probeGroup: (t: ResolvedTarget[]) => Promise<void> }).probeGroup.bind(
engine, engine,
); );
await probeGroup([target]); await probeGroup([target]);
const dbTargets = store.getTargets(); const results = (mockStore as unknown as { _results: Array<Record<string, unknown>> })._results;
const latest = store.getLatestCheck(dbTargets[0]!.id); expect(results.length).toBe(1);
expect(latest).not.toBeNull(); expect(results[0]!.success).toBe(true);
expect(latest!.success === 1 || latest!.success === 0).toBe(true); expect(results[0]!.matched).toBe(true);
expect(results[0]!.statusDetail).toBe("exitCode=0");
}); });
test("单目标失败隔离", async () => { test("多个目标并发执行", async () => {
const badTarget: ResolvedTarget = { const targetA = makeCommandTarget("echo-a", {
name: "bad-target", command: { exec: "echo", args: ["a"], cwd: "/tmp", env: {}, maxOutputBytes: 1024 * 1024 },
url: "http://127.0.0.1:1/impossible", });
method: "GET", const targetB = makeCommandTarget("echo-b", {
headers: {}, command: { exec: "echo", args: ["b"], cwd: "/tmp", env: {}, maxOutputBytes: 1024 * 1024 },
intervalMs: 60000, });
timeoutMs: 2000,
};
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( const probeGroup = (engine as unknown as { probeGroup: (t: ResolvedTarget[]) => Promise<void> }).probeGroup.bind(
engine, engine,
); );
await probeGroup([target, badTarget]); await probeGroup([targetA, targetB]);
const dbTargets = store.getTargets(); const results = (mockStore as unknown as { _results: Array<Record<string, unknown>> })._results;
const goodResult = store.getLatestCheck(dbTargets.find((t) => t.name === "httpbin")!.id); expect(results.length).toBe(2);
const badResult = store.getLatestCheck(dbTargets.find((t) => t.name === "bad-target")!.id); });
expect(goodResult).not.toBeNull(); test("失败目标不阻塞其他目标", async () => {
expect(badResult).not.toBeNull(); const badTarget = makeCommandTarget("bad-cmd", {
expect(badResult!.success).toBe(0); 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 { 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", () => { describe("evaluateJsonPath", () => {
const obj = { const obj = {
@@ -125,106 +130,158 @@ describe("applyOperator", () => {
}); });
}); });
describe("checkBodyExpect", () => { describe("checkExpectValue", () => {
test("无 body config 返回 true", () => { test("原始值直接比较", () => {
expect(checkBodyExpect("anything", undefined)).toBe(true); 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 匹配", () => { test("对象作为操作符", () => {
expect(checkBodyExpect("hello world", { contains: "hello" })).toBe(true); expect(checkExpectValue(42, { gte: 10 })).toBe(true);
expect(checkBodyExpect("hello world", { contains: "missing" })).toBe(false); expect(checkExpectValue(42, { gte: 100 })).toBe(false);
expect(checkExpectValue("hello", { contains: "ell" })).toBe(true);
});
});
describe("checkBodyExpect (BodyRule[])", () => {
test("无规则返回匹配成功", () => {
const r = checkBodyExpect("anything");
expect(r.matched).toBe(true);
expect(r.failure).toBeNull();
}); });
test("regex 匹配", () => { test("空规则数组返回匹配成功", () => {
expect(checkBodyExpect("status: ok", { regex: "ok" })).toBe(true); const r = checkBodyExpect("anything", []);
expect(checkBodyExpect("status: error", { regex: "ok" })).toBe(false); expect(r.matched).toBe(true);
expect(r.failure).toBeNull();
}); });
test("json 简单等值匹配", () => { 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 }); const body = JSON.stringify({ status: "ok", code: 0 });
expect(checkBodyExpect(body, { json: { "$.status": "ok" } })).toBe(true); const r = checkBodyExpect(body, [{ json: { path: "$.status", equals: "ok" } }]);
expect(checkBodyExpect(body, { json: { "$.code": 0 } })).toBe(true); expect(r.matched).toBe(true);
expect(checkBodyExpect(body, { json: { "$.status": "error" } })).toBe(false); });
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 操作符匹配", () => { test("json 操作符匹配", () => {
const body = JSON.stringify({ count: 42, version: "v2.1.0", message: "success" }); const body = JSON.stringify({ count: 42, version: "v2.1.0" });
expect(checkBodyExpect(body, { json: { "$.count": { gte: 10 } } })).toBe(true); expect(checkBodyExpect(body, [{ json: { path: "$.count", gte: 10 } }]).matched).toBe(true);
expect(checkBodyExpect(body, { json: { "$.version": { match: "\\d+\\.\\d+\\.\\d+" } } })).toBe(true); expect(checkBodyExpect(body, [{ json: { path: "$.version", match: "\\d+\\.\\d+\\.\\d+" } }]).matched).toBe(true);
expect(checkBodyExpect(body, { json: { "$.message": { contains: "success" } } })).toBe(true); expect(checkBodyExpect(body, [{ json: { path: "$.count", gte: 100 } }]).matched).toBe(false);
expect(checkBodyExpect(body, { json: { "$.count": { gte: 100 } } })).toBe(false);
}); });
test("json 路径不存在", () => { test("json 路径不存在", () => {
const body = JSON.stringify({ status: "ok" }); 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 解析失败", () => { 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>"; 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: { selector: "div#health", equals: "OK" } }]).matched).toBe(true);
expect(checkBodyExpect(html, { css: { "span.ver": "1.0" } })).toBe(true); expect(checkBodyExpect(html, [{ css: { selector: "span.ver", equals: "1.0" } }]).matched).toBe(true);
expect(checkBodyExpect(html, { css: { "div#health": "ERROR" } })).toBe(false); expect(checkBodyExpect(html, [{ css: { selector: "div#health", equals: "ERROR" } }]).matched).toBe(false);
}); });
test("css 选择器无匹配元素", () => { test("css 选择器无匹配元素", () => {
const html = "<div>OK</div>"; 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 提取", () => { test("css attr 提取", () => {
const html = '<meta name="version" content="2.0.1"><link rel="icon" href="/favicon.ico">'; const html = '<meta name="version" content="2.0.1">';
expect(checkBodyExpect(html, { css: { 'meta[name="version"]': { attr: "content", equals: "2.0.1" } } })).toBe(true);
expect( 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); ).toBe(true);
expect(checkBodyExpect(html, { css: { 'link[rel="icon"]': { attr: "href", contains: "favicon" } } })).toBe(true);
}); });
test("css exists 检查", () => { test("css exists 检查", () => {
const html = "<div id='test'>OK</div>"; const html = "<div id='test'>OK</div>";
expect(checkBodyExpect(html, { css: { "div#test": { exists: true } } })).toBe(true); expect(checkBodyExpect(html, [{ css: { selector: "div#test", exists: true } }]).matched).toBe(true);
expect(checkBodyExpect(html, { css: { "span#missing": { exists: false } } })).toBe(true); expect(checkBodyExpect(html, [{ css: { selector: "span#missing", exists: false } }]).matched).toBe(true);
expect(checkBodyExpect(html, { css: { "div#test": { exists: false } } })).toBe(false); expect(checkBodyExpect(html, [{ css: { selector: "div#test", exists: false } }]).matched).toBe(false);
}); });
test("xpath 节点文本匹配", () => { test("xpath 节点文本匹配", () => {
const xml = "<root><status>ok</status><code>200</code></root>"; const xml = "<root><status>ok</status><code>200</code></root>";
expect(checkBodyExpect(xml, { xpath: { "/root/status/text()": "ok" } })).toBe(true); expect(checkBodyExpect(xml, [{ xpath: { path: "/root/status/text()", equals: "ok" } }]).matched).toBe(true);
expect(checkBodyExpect(xml, { xpath: { "/root/status/text()": "error" } })).toBe(false); expect(checkBodyExpect(xml, [{ xpath: { path: "/root/status/text()", equals: "error" } }]).matched).toBe(false);
}); });
test("xpath 无匹配节点", () => { test("xpath 无匹配节点", () => {
const xml = "<root><status>ok</status></root>"; 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 包含匹配", () => { test("规则数组按顺序检查,第一条失败立即返回", () => {
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 方法部分失败", () => {
const body = JSON.stringify({ status: "error" }); const body = JSON.stringify({ status: "error" });
expect( const r = checkBodyExpect(body, [{ contains: "healthy" }, { json: { path: "$.status", equals: "error" } }]);
checkBodyExpect(body, { expect(r.matched).toBe(false);
contains: "healthy", expect(r.failure!.path).toBe("body[0]");
json: { "$.status": "error" }, });
}),
).toBe(false); 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 { afterAll, beforeAll, describe, expect, test } from "bun:test";
import { checkExpect } from "../../../src/server/checker/fetcher"; import { runHttpCheck } from "../../../src/server/checker/fetcher";
const emptyHeaders: Record<string, string> = {}; describe("runHttpCheck", () => {
test("checkExpect 已移除", async () => {
describe("checkExpect", () => { const mod = await import("../../../src/server/checker/fetcher");
test("无 expect 配置时 matched 为 true", () => { expect((mod as Record<string, unknown>).checkExpect).toBeUndefined();
expect(checkExpect(200, "ok", 100, emptyHeaders, undefined)).toBe(true); expect((mod as Record<string, unknown>).fetchTarget).toBeUndefined();
}); });
});
test("status 匹配", () => {
expect(checkExpect(200, "", 100, emptyHeaders, { status: [200, 201] })).toBe(true); describe("runHttpCheck 集成", () => {
expect(checkExpect(201, "", 100, emptyHeaders, { status: [200, 201] })).toBe(true); let server: ReturnType<typeof Bun.serve>;
expect(checkExpect(404, "", 100, emptyHeaders, { status: [200, 201] })).toBe(false); let baseUrl: string;
});
beforeAll(() => {
test("headers 匹配", () => { server = Bun.serve({
const headers = { "content-type": "application/json", "x-custom": "test" }; port: 0,
expect(checkExpect(200, "", 100, headers, { headers: { "Content-Type": "application/json" } })).toBe(true); fetch(req) {
expect(checkExpect(200, "", 100, headers, { headers: { "Content-Type": "text/html" } })).toBe(false); const url = new URL(req.url);
expect(checkExpect(200, "", 100, headers, { headers: { "X-Missing": "test" } })).toBe(false); switch (url.pathname) {
}); case "/ok":
return new Response("hello world", {
test("body.contains 匹配", () => { headers: { "content-type": "text/plain", "x-custom": "test-value" },
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); case "/echo":
expect(checkExpect(200, "status: error", 100, emptyHeaders, { body: { regex: "status.*ok" } })).toBe(false); return new Response(JSON.stringify({ method: req.method, body: req.body ? "present" : "empty" }), {
}); headers: { "content-type": "application/json" },
});
test("body.json 匹配", () => { case "/large":
expect( return new Response("x".repeat(2000));
checkExpect(200, JSON.stringify({ status: "ok" }), 100, emptyHeaders, { body: { json: { "$.status": "ok" } } }), case "/notfound":
).toBe(true); return new Response("not found", { status: 404 });
expect( case "/slow":
checkExpect(200, JSON.stringify({ status: "error" }), 100, emptyHeaders, { return new Response("slow", { status: 200 });
body: { json: { "$.status": "ok" } }, default:
}), return new Response("ok");
).toBe(false); }
}); },
});
test("body.json 解析失败", () => { baseUrl = `http://localhost:${server.port}`;
expect(checkExpect(200, "not json", 100, emptyHeaders, { body: { json: { "$.status": "ok" } } })).toBe(false); });
});
afterAll(() => {
test("body 多种方法 AND 组合", () => { server.stop();
expect( });
checkExpect(200, "healthy", 100, emptyHeaders, {
body: { function makeTarget(overrides: {
contains: "healthy", url?: string;
regex: "healthy", method?: string;
}, body?: string;
}), headers?: Record<string, string>;
).toBe(true); expect?: Record<string, unknown>;
maxBodyBytes?: number;
expect( timeoutMs?: number;
checkExpect(200, "healthy", 100, emptyHeaders, { }) {
body: { return {
contains: "healthy", type: "http" as const,
regex: "unhealthy", name: "test-http",
}, http: {
}), url: overrides.url ?? `${baseUrl}/ok`,
).toBe(false); method: overrides.method ?? "GET",
}); headers: overrides.headers ?? ({} as Record<string, string>),
body: overrides.body,
test("maxLatencyMs 匹配", () => { maxBodyBytes: overrides.maxBodyBytes ?? 1024 * 1024,
expect(checkExpect(200, "", 100, emptyHeaders, { maxLatencyMs: 200 })).toBe(true); },
expect(checkExpect(200, "", 300, emptyHeaders, { maxLatencyMs: 200 })).toBe(false); intervalMs: 60000,
expect(checkExpect(200, "", 200, emptyHeaders, { maxLatencyMs: 200 })).toBe(true); timeoutMs: overrides.timeoutMs ?? 5000,
}); expect: overrides.expect as import("../../../src/server/checker/types").HttpExpectConfig | undefined,
};
test("多条 expect 全部通过", () => { }
expect(
checkExpect(200, "healthy", 100, emptyHeaders, { test("成功请求 200", async () => {
status: [200], const result = await runHttpCheck(makeTarget({ url: `${baseUrl}/ok` }));
body: { contains: "healthy" }, expect(result.success).toBe(true);
maxLatencyMs: 200, expect(result.matched).toBe(true);
}), expect(result.statusDetail).toBe("HTTP 200");
).toBe(true); expect(result.durationMs).not.toBeNull();
}); expect(result.failure).toBeNull();
});
test("多条 expect 部分失败", () => {
expect( test("404 不匹配默认 status [200]", async () => {
checkExpect(200, "healthy", 500, emptyHeaders, { const result = await runHttpCheck(makeTarget({ url: `${baseUrl}/notfound` }));
status: [200], expect(result.success).toBe(false);
body: { contains: "healthy" }, expect(result.matched).toBe(false);
maxLatencyMs: 200, expect(result.statusDetail).toBe("HTTP 404");
}), expect(result.failure).not.toBeNull();
).toBe(false); expect(result.failure!.phase).toBe("status");
}); });
test("status + headers + body + maxLatencyMs 全组合", () => { test("404 匹配自定义 status [404]", async () => {
const headers = { "content-type": "application/json" }; const result = await runHttpCheck(
expect( makeTarget({
checkExpect(200, JSON.stringify({ status: "ok" }), 100, headers, { url: `${baseUrl}/notfound`,
status: [200], expect: { status: [404] },
headers: { "Content-Type": "application/json" }, }),
body: { contains: "ok", json: { "$.status": "ok" } }, );
maxLatencyMs: 200, expect(result.success).toBe(true);
}), expect(result.matched).toBe(true);
).toBe(true); });
});
test("headers 检查通过", async () => {
test("全组合中 headers 失败", () => { const result = await runHttpCheck(
const headers = { "content-type": "text/html" }; makeTarget({
expect( url: `${baseUrl}/ok`,
checkExpect(200, JSON.stringify({ status: "ok" }), 100, headers, { expect: { headers: { "x-custom": "test-value" } },
status: [200], }),
headers: { "Content-Type": "application/json" }, );
body: { contains: "ok", json: { "$.status": "ok" } }, expect(result.success).toBe(true);
maxLatencyMs: 200, expect(result.matched).toBe(true);
}), });
).toBe(false);
test("headers 检查失败", async () => {
const result = await runHttpCheck(
makeTarget({
url: `${baseUrl}/ok`,
expect: { headers: { "x-custom": "wrong-value" } },
}),
);
expect(result.success).toBe(false);
expect(result.failure!.phase).toBe("headers");
});
test("body contains 检查", async () => {
const result = await runHttpCheck(
makeTarget({
url: `${baseUrl}/ok`,
expect: { body: [{ contains: "hello" }] },
}),
);
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 { afterAll, beforeAll, describe, expect, test } from "bun:test";
import { ProbeStore } from "../../../src/server/checker/store"; 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 { mkdir, rm } from "node:fs/promises";
import { join } from "node:path"; import { join } from "node:path";
import { tmpdir } from "node:os"; 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", () => { describe("ProbeStore", () => {
let tempDir: string; let tempDir: string;
let store: ProbeStore; 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 () => { beforeAll(async () => {
tempDir = join(tmpdir(), `gc-store-test-${Date.now()}`); tempDir = join(tmpdir(), `gc-store-test-${Date.now()}`);
await mkdir(tempDir, { recursive: true }); await mkdir(tempDir, { recursive: true });
@@ -44,42 +52,62 @@ describe("ProbeStore", () => {
expect(store.getTargets()).toHaveLength(0); expect(store.getTargets()).toHaveLength(0);
}); });
test("同步新增 targets", () => { test("同步 http 和 command targets", () => {
store.syncTargets([target1, target2]); store.syncTargets([httpTarget, commandTarget]);
const targets = store.getTargets(); const targets = store.getTargets();
expect(targets).toHaveLength(2); expect(targets).toHaveLength(2);
expect(targets[0]!.name).toBe("test-a"); expect(targets[0]!.name).toBe("test-http");
expect(targets[1]!.name).toBe("test-b"); expect(targets[1]!.name).toBe("test-cmd");
}); });
test("同步后 target 字段正确", () => { test("http target 字段正确", () => {
const targets = store.getTargets(); const t = store.getTargets().find((t) => t.name === "test-http")!;
const t2 = targets.find((t) => t.name === "test-b")!; expect(t.type).toBe("http");
expect(t2.url).toBe("http://b.com"); expect(t.target).toBe("https://example.com/health");
expect(t2.method).toBe("POST"); const config = JSON.parse(t.config);
expect(JSON.parse(t2.headers)).toEqual({ "Content-Type": "application/json" }); expect(config.url).toBe("https://example.com/health");
expect(t2.body).toBe('{"ping": true}'); expect(config.method).toBe("GET");
expect(t2.interval_ms).toBe(60000); expect(config.headers).toEqual({ Accept: "application/json" });
expect(t2.expect).toBe(JSON.stringify({ status: [200], maxLatencyMs: 3000 })); 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", () => { test("同步更新已有 target", () => {
store.syncTargets([{ ...target1, url: "http://a-v2.com" }, target2]); const updated: ResolvedTarget = {
const targets = store.getTargets(); ...httpTarget,
const t1 = targets.find((t) => t.name === "test-a")!; http: { ...httpTarget.http, url: "https://example.com/v2" },
expect(t1.url).toBe("http://a-v2.com"); };
expect(targets).toHaveLength(2); 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", () => { test("同步删除 target", () => {
store.syncTargets([target1]); store.syncTargets([httpTarget]);
const targets = store.getTargets(); const targets = store.getTargets();
expect(targets).toHaveLength(1); expect(targets).toHaveLength(1);
expect(targets[0]!.name).toBe("test-a"); expect(targets[0]!.name).toBe("test-http");
}); });
test("重新同步回来", () => { test("重新同步回来", () => {
store.syncTargets([target1, target2]); store.syncTargets([httpTarget, commandTarget]);
expect(store.getTargets()).toHaveLength(2); expect(store.getTargets()).toHaveLength(2);
}); });
@@ -87,14 +115,15 @@ describe("ProbeStore", () => {
const targets = store.getTargets(); const targets = store.getTargets();
const found = store.getTargetById(targets[0]!.id); const found = store.getTargetById(targets[0]!.id);
expect(found).toBeDefined(); expect(found).toBeDefined();
expect(found!.name).toBe("test-a"); expect(found!.name).toBe("test-http");
}); });
test("getTargetById 不存在", () => { test("getTargetById 不存在", () => {
expect(store.getTargetById(99999)).toBeNull(); expect(store.getTargetById(99999)).toBeNull();
}); });
test("写入和查询 check result", () => { test("写入 check result 并查询", () => {
store.syncTargets([httpTarget, commandTarget]);
const targets = store.getTargets(); const targets = store.getTargets();
const t1Id = targets[0]!.id; const t1Id = targets[0]!.id;
@@ -102,30 +131,39 @@ describe("ProbeStore", () => {
targetId: t1Id, targetId: t1Id,
timestamp: "2025-01-01T00:00:00.000Z", timestamp: "2025-01-01T00:00:00.000Z",
success: true, success: true,
statusCode: 200,
latencyMs: 150,
error: null,
matched: true, matched: true,
durationMs: 150.5,
statusDetail: "200 OK",
failure: null,
}); });
store.insertCheckResult({ store.insertCheckResult({
targetId: t1Id, targetId: t1Id,
timestamp: "2025-01-01T00:00:30.000Z", timestamp: "2025-01-01T00:00:30.000Z",
success: true, success: true,
statusCode: 200,
latencyMs: 300,
error: null,
matched: true, 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({ store.insertCheckResult({
targetId: t1Id, targetId: t1Id,
timestamp: "2025-01-01T00:01:00.000Z", timestamp: "2025-01-01T00:01:00.000Z",
success: false, success: false,
statusCode: null,
latencyMs: null,
error: "timeout",
matched: false, matched: false,
durationMs: null,
statusDetail: null,
failure,
}); });
const history = store.getHistory(t1Id, 10); const history = store.getHistory(t1Id, 10);
@@ -134,7 +172,11 @@ describe("ProbeStore", () => {
const latest = store.getLatestCheck(t1Id)!; const latest = store.getLatestCheck(t1Id)!;
expect(latest.success).toBe(0); 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", () => { test("getHistory 默认 limit=20", () => {
@@ -144,12 +186,12 @@ describe("ProbeStore", () => {
for (let i = 0; i < 25; i++) { for (let i = 0; i < 25; i++) {
store.insertCheckResult({ store.insertCheckResult({
targetId: t1Id, 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, success: true,
statusCode: 200,
latencyMs: 100 + i,
error: null,
matched: true, matched: true,
durationMs: 100 + i,
statusDetail: "200 OK",
failure: null,
}); });
} }
@@ -157,7 +199,7 @@ describe("ProbeStore", () => {
expect(history).toHaveLength(20); expect(history).toHaveLength(20);
}); });
test("getTargetStats 计算可用率和延迟", () => { test("getTargetStats 计算可用率和 duration", () => {
const targets = store.getTargets(); const targets = store.getTargets();
const t1Id = targets[0]!.id; const t1Id = targets[0]!.id;
@@ -165,17 +207,19 @@ describe("ProbeStore", () => {
expect(stats.totalChecks).toBeGreaterThan(0); expect(stats.totalChecks).toBeGreaterThan(0);
expect(stats.availability).toBeGreaterThanOrEqual(0); expect(stats.availability).toBeGreaterThanOrEqual(0);
expect(stats.availability).toBeLessThanOrEqual(100); expect(stats.availability).toBeLessThanOrEqual(100);
expect(stats.avgLatencyMs).not.toBeNull(); expect(stats.avgDurationMs).not.toBeNull();
expect(typeof stats.avgDurationMs).toBe("number");
}); });
test("无记录目标的 stats", () => { test("无记录目标的 stats", () => {
const targets = store.getTargets(); 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); const stats = store.getTargetStats(t2Id);
expect(stats.totalChecks).toBe(0); expect(stats.totalChecks).toBe(0);
expect(stats.availability).toBe(0); expect(stats.availability).toBe(0);
expect(stats.avgLatencyMs).toBeNull(); expect(stats.avgDurationMs).toBeNull();
expect(stats.p99DurationMs).toBeNull();
}); });
test("getSummary 返回总览统计", () => { test("getSummary 返回总览统计", () => {
@@ -183,6 +227,7 @@ describe("ProbeStore", () => {
expect(summary.total).toBe(2); expect(summary.total).toBe(2);
expect(summary.up + summary.down).toBe(2); expect(summary.up + summary.down).toBe(2);
expect(summary.lastCheckTime).not.toBeNull(); expect(summary.lastCheckTime).not.toBeNull();
expect(summary.avgDurationMs).not.toBeNull();
}); });
test("getTrend 返回趋势数据", () => { test("getTrend 返回趋势数据", () => {
@@ -191,5 +236,30 @@ describe("ProbeStore", () => {
const trend = store.getTrend(t1Id, 24); const trend = store.getTrend(t1Id, 24);
expect(Array.isArray(trend)).toBe(true); 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();
}); });
}); });