Compare commits
10 Commits
9267f6585c
...
767f26617e
| Author | SHA1 | Date | |
|---|---|---|---|
| 767f26617e | |||
| 48a9e96ec2 | |||
| d873484938 | |||
| 80d5f4cdf4 | |||
| 0ee10b47c9 | |||
| 35ba56888b | |||
| 548b44d28e | |||
| b8810f1182 | |||
| 599d973cbd | |||
| 57d3a5cfb4 |
272
README.md
272
README.md
@@ -1,53 +1,216 @@
|
||||
# Gateway Checker
|
||||
|
||||
基于 Bun + TypeScript 的前后端一体化 demo。开发期使用 Vite + React 提供前端 HMR,后端由 Bun 提供 API;生产期先构建前端静态资源,再将前端资源和 Bun 后端打包为单个 executable。
|
||||
基于 Bun + TypeScript 的多类型拨测监控工具。通过 YAML 配置文件定义 HTTP 和命令行拨测目标,后端按配置定时并发拨测,结果持久化到本地 SQLite,前端 Dashboard 展示各目标实时状态、可用率、耗时趋势等。
|
||||
|
||||
## 项目结构
|
||||
|
||||
```text
|
||||
src/
|
||||
server/ Bun 后端运行时、API、静态资源 fallback
|
||||
shared/ 前后端共享 TypeScript 类型
|
||||
web/ Vite + React 前端 demo
|
||||
scripts/ 开发、构建和 smoke test 脚本
|
||||
tests/ Bun test 测试
|
||||
openspec/ OpenSpec 变更与规格文档
|
||||
server/
|
||||
app.ts Bun HTTP 路由(API + 静态资源 + SPA fallback)
|
||||
config.ts CLI 参数解析
|
||||
dev.ts 开发期启动入口
|
||||
server.ts HTTP server 启动
|
||||
checker/
|
||||
types.ts 类型定义
|
||||
config-loader.ts YAML 配置解析与校验
|
||||
store.ts SQLite 数据存储
|
||||
fetcher.ts HTTP 拨测执行
|
||||
command-runner.ts 命令行拨测执行
|
||||
size.ts 大小单位解析
|
||||
engine.ts 调度引擎(按 interval 分组、组内并发)
|
||||
expect/
|
||||
http.ts HTTP 响应断言
|
||||
command.ts 命令行输出断言
|
||||
body.ts HTTP body 断言(JSONPath/XPath/CSS)
|
||||
failure.ts 失败信息类型
|
||||
shared/
|
||||
api.ts 前后端共享 TypeScript 类型
|
||||
web/ Vite + React 前端 Dashboard
|
||||
components/ UI 组件(卡片、分组、模态框、状态条等)
|
||||
constants/ 常量定义(类型映射等)
|
||||
hooks/ 数据轮询和详情管理 hooks
|
||||
utils/ 前端工具函数
|
||||
scripts/ 开发、构建和 smoke test 脚本
|
||||
tests/ Bun test 测试
|
||||
openspec/ OpenSpec 变更与规格文档
|
||||
```
|
||||
|
||||
## 开发命令
|
||||
## 快速开始
|
||||
|
||||
```bash
|
||||
bun install
|
||||
bun run dev
|
||||
cp probes.example.yaml probes.yaml
|
||||
bun run dev probes.yaml
|
||||
```
|
||||
|
||||
`bun run dev` 会同时启动:
|
||||
|
||||
- Bun 后端:默认 `http://127.0.0.1:3000`
|
||||
- Vite 前端:默认 `http://127.0.0.1:5173`
|
||||
|
||||
开发期请打开 Vite 前端地址。前端通过相对路径 `/api/demo` 调用后端,Vite 会把 `/api/*` 代理到 Bun 后端,因此浏览器不需要 CORS 配置。
|
||||
|
||||
全栈开发命令使用 `PORT` 作为后端端口覆盖来源,并将同一端口传给 Vite proxy:
|
||||
|
||||
```bash
|
||||
PORT=4000 bun run dev
|
||||
```
|
||||
`bun run dev` 会同时启动 Bun 后端和 Vite 前端。开发期请打开 Vite 前端地址 `http://127.0.0.1:5173`。
|
||||
|
||||
也可以分别运行:
|
||||
|
||||
```bash
|
||||
bun run dev:server
|
||||
bun run dev:server probes.yaml
|
||||
bun run dev:web
|
||||
```
|
||||
|
||||
分别运行时,若后端不是默认 `3000` 端口,需要为 Vite 指定同一个后端端口:
|
||||
## 配置文件
|
||||
|
||||
```bash
|
||||
PORT=4000 bun run dev:server
|
||||
BACKEND_PORT=4000 bun run dev:web
|
||||
程序通过 YAML 配置文件定义所有运行参数:
|
||||
|
||||
```yaml
|
||||
server:
|
||||
host: "127.0.0.1"
|
||||
port: 3000
|
||||
dataDir: "/tmp/probes_data"
|
||||
|
||||
runtime:
|
||||
maxConcurrentChecks: 20
|
||||
|
||||
defaults:
|
||||
interval: "5s"
|
||||
timeout: "10s"
|
||||
http:
|
||||
method: GET
|
||||
maxBodyBytes: "100MB"
|
||||
command:
|
||||
maxOutputBytes: "100MB"
|
||||
|
||||
targets:
|
||||
- name: "Baidu"
|
||||
type: http
|
||||
http:
|
||||
url: "https://www.baidu.com"
|
||||
expect:
|
||||
status: [200]
|
||||
maxDurationMs: 10000
|
||||
|
||||
- name: "JSON API 示例"
|
||||
type: http
|
||||
http:
|
||||
url: "https://httpbin.org/json"
|
||||
expect:
|
||||
status: [200]
|
||||
headers:
|
||||
Content-Type:
|
||||
contains: "application/json"
|
||||
body:
|
||||
- contains: "slideshow"
|
||||
- json:
|
||||
path: "$.slideshow.title"
|
||||
equals: "Sample Slide Show"
|
||||
|
||||
- name: "HTML 页面示例"
|
||||
type: http
|
||||
http:
|
||||
url: "https://httpbin.org/html"
|
||||
expect:
|
||||
status: [200]
|
||||
body:
|
||||
- contains: "Moby-Dick"
|
||||
- xpath:
|
||||
path: "/html/body/h1/text()"
|
||||
equals: "Herman Melville - Moby-Dick"
|
||||
|
||||
- name: "Nginx 进程检查"
|
||||
type: command
|
||||
command:
|
||||
exec: "pgrep"
|
||||
args: ["nginx"]
|
||||
expect:
|
||||
exitCode: [0]
|
||||
stdout:
|
||||
- match: "\\d+"
|
||||
```
|
||||
|
||||
### 配置说明
|
||||
|
||||
- **server**: 服务配置(均可省略,使用默认值)
|
||||
- `host`: 监听地址,默认 `127.0.0.1`
|
||||
- `port`: 监听端口,默认 `3000`
|
||||
- `dataDir`: 数据目录,默认 `./data`
|
||||
- **runtime**: 运行时配置
|
||||
- `maxConcurrentChecks`: 最大并发拨测数,默认 `20`
|
||||
- **defaults**: 全局默认值(均可省略)
|
||||
- `interval`: 拨测间隔,默认 `30s`
|
||||
- `timeout`: 超时时间,默认 `10s`
|
||||
- `http`: HTTP 类型默认值
|
||||
- `method`: HTTP 方法,默认 `GET`
|
||||
- `maxBodyBytes`: 响应体最大字节数,默认 `100MB`
|
||||
- `command`: Command 类型默认值
|
||||
- `maxOutputBytes`: 输出最大字节数,默认 `100MB`
|
||||
- **targets**: 拨测目标列表(必填)
|
||||
- `name`: 目标名称(必填,唯一)
|
||||
- `type`: 目标类型,`http` 或 `command`(必填)
|
||||
- `group`: 分组名称(可选,默认 `"default"`)
|
||||
- `http`: HTTP 拨测配置(type 为 http 时必填)
|
||||
- `url`: 目标 URL
|
||||
- `method`、`headers`、`body`: 请求参数
|
||||
- `command`: 命令行拨测配置(type 为 command 时必填)
|
||||
- `exec`: 可执行文件名或路径
|
||||
- `args`: 命令行参数列表
|
||||
- `env`: 环境变量覆盖(可选,继承进程环境变量并合并覆盖)
|
||||
- `cwd`: 工作目录(可选,相对于配置文件所在目录解析,默认 `.`)
|
||||
- `interval`、`timeout`: 覆盖全局默认值
|
||||
- `expect`: 期望校验
|
||||
- `status`: 可接受的状态码列表(HTTP)
|
||||
- `exitCode`: 可接受的退出码列表(Command)
|
||||
- `headers`: 响应头校验(HTTP,支持 `equals`、`contains` 等操作符)
|
||||
- `maxDurationMs`: 最大耗时阈值(毫秒)
|
||||
- `body`: HTTP 响应体校验(数组,可组合使用)
|
||||
- `contains`: 响应体包含的文本
|
||||
- `match`: 响应体匹配的正则表达式
|
||||
- `json`: JSONPath 提取值比较(`path` + 比较操作符)
|
||||
- `css`: CSS 选择器提取 HTML 元素比较
|
||||
- `xpath`: XPath 提取 XML/HTML 节点比较
|
||||
- `stdout` / `stderr`: Command 输出校验(数组,同 body 格式)
|
||||
- 比较操作符:`equals`(默认)、`contains`、`match`(正则)、`empty`、`exists`、`gte`、`lte`、`gt`、`lt`
|
||||
|
||||
大小说明:`maxBodyBytes` 和 `maxOutputBytes` 支持单位 `KB`、`MB`、`GB`,也可直接使用数字(字节数)。
|
||||
|
||||
时长格式支持:`30s`、`5m`、`500ms`
|
||||
|
||||
## API 端点
|
||||
|
||||
| 端点 | 说明 |
|
||||
| ----------------------------------------------------------------- | --------------------------------------- |
|
||||
| `GET /health` | 健康检查 |
|
||||
| `GET /api/summary` | 总览统计(total/up/down/lastCheckTime) |
|
||||
| `GET /api/targets` | 目标列表及最新状态、分组和采样数据 |
|
||||
| `GET /api/targets/:id/history?from=ISO&to=ISO&page=1&pageSize=20` | 指定目标的拨测记录(时间范围 + 分页) |
|
||||
| `GET /api/targets/:id/trend?from=ISO&to=ISO` | 指定目标的按小时聚合趋势 |
|
||||
|
||||
### 响应字段
|
||||
|
||||
**SummaryResponse**: `total`、`up`、`down`、`lastCheckTime`
|
||||
|
||||
**TargetStatus**: `id`、`name`、`type`(http/command)、`target`(URL 或命令摘要)、`group`、`interval`、`latestCheck`、`stats`、`recentSamples`
|
||||
|
||||
**RecentSample**: `timestamp`、`durationMs`、`up`
|
||||
|
||||
**CheckResult**: `timestamp`、`matched`、`durationMs`、`statusDetail`、`failure`
|
||||
|
||||
**CheckFailure**: `kind`(error/mismatch)、`phase`、`path`、`expected`、`actual`、`message`
|
||||
|
||||
**TargetStats**: `totalChecks`、`availability`
|
||||
|
||||
**TrendPoint**: `hour`、`avgDurationMs`、`availability`、`totalChecks`
|
||||
|
||||
**HistoryResponse**: `items`(CheckResult[])、`total`、`page`、`pageSize`
|
||||
|
||||
### 错误响应
|
||||
|
||||
API 错误返回 `ApiErrorResponse` 格式:
|
||||
|
||||
```json
|
||||
{ "error": "描述信息", "status": 400 }
|
||||
```
|
||||
|
||||
| 状态码 | 触发场景 |
|
||||
| ------ | ----------------------------------------------------------------------- |
|
||||
| 400 | 参数格式错误(无效 ID、from/to 缺失或格式错误、page/pageSize 非正整数) |
|
||||
| 404 | 目标不存在、API 路由未匹配 |
|
||||
| 405 | 非 GET 方法请求 API 路由 |
|
||||
|
||||
## 代码质量
|
||||
|
||||
```bash
|
||||
@@ -57,23 +220,7 @@ bun run format
|
||||
bun run check
|
||||
```
|
||||
|
||||
- `lint` 使用 ESLint 检查 TypeScript、React Hooks 和前后端边界。
|
||||
- `format:check` 使用 Prettier 检查代码格式。
|
||||
- `format` 使用 Prettier 重写受管理文件格式。
|
||||
- `check` 依次运行 `typecheck`、`lint`、`format:check` 和单元测试,适合日常开发期间快速验证。
|
||||
|
||||
Prettier 不格式化 `openspec/`、`dist/`、`.build/`、`node_modules/`、`bun.lock` 和临时构建产物。
|
||||
|
||||
## Demo 验证
|
||||
|
||||
开发期打开 `http://127.0.0.1:5173`,页面应显示 `/api/demo` 返回的后端 message、Bun 版本、平台和响应时间。
|
||||
|
||||
直接验证 API:
|
||||
|
||||
```bash
|
||||
curl http://127.0.0.1:3000/api/demo
|
||||
curl http://127.0.0.1:3000/health
|
||||
```
|
||||
- `check` 依次运行 `typecheck`、`lint`、`format:check` 和单元测试。
|
||||
|
||||
## 构建 executable
|
||||
|
||||
@@ -83,31 +230,23 @@ bun run build
|
||||
|
||||
构建流程:
|
||||
|
||||
- 运行 `vite build`,输出前端资源到 `dist/web`
|
||||
- 生成临时 `.build/static-assets.ts`,用 Bun file import 嵌入 Vite 产物
|
||||
- 生成临时 `.build/server-entry.ts`,作为生产 executable 的 server 入口
|
||||
- 运行 `Bun.build({ compile })`,输出 `dist/gateway-checker`
|
||||
1. 运行 `vite build`,输出前端资源到 `dist/web`
|
||||
2. 生成临时 `.build/static-assets.ts`,嵌入 Vite 产物
|
||||
3. 生成临时 `.build/server-entry.ts`,作为生产入口
|
||||
4. 运行 `Bun.build({ compile })`,输出 `dist/gateway-checker`
|
||||
|
||||
运行 executable:
|
||||
|
||||
```bash
|
||||
./dist/gateway-checker
|
||||
./dist/gateway-checker probes.yaml
|
||||
```
|
||||
|
||||
生产期默认访问 `http://127.0.0.1:3000`。同一个 executable 会服务 `/api/demo`、`/health`、`/assets/*` 和前端 SPA fallback。
|
||||
|
||||
## 运行参数
|
||||
|
||||
默认配置:
|
||||
|
||||
- `HOST=127.0.0.1`
|
||||
- `PORT=3000`
|
||||
|
||||
可以通过环境变量或 CLI 参数覆盖:
|
||||
CLI 只接受一个参数:YAML 配置文件路径。
|
||||
|
||||
```bash
|
||||
PORT=4000 ./dist/gateway-checker
|
||||
./dist/gateway-checker --host 0.0.0.0 --port 4000
|
||||
./dist/gateway-checker ./probes.yaml
|
||||
```
|
||||
|
||||
## 测试
|
||||
@@ -118,15 +257,22 @@ bun run verify
|
||||
```
|
||||
|
||||
- `check` 适合日常开发,包含类型检查、lint、格式检查和单元测试。
|
||||
- `verify` 适合提交前或发布前,先运行 `check`,再重新构建生产 executable 并运行 smoke test。
|
||||
- `test:smoke` 会启动生成的 executable,并检查 `/api/demo`、`/health`、前端根路径、静态资源、未知 API、未知静态资源、生产模式、缓存响应头、低风险安全响应头和 SPA fallback。
|
||||
- `verify` 先运行 `check`,再重新构建生产 executable 并运行 smoke test。
|
||||
|
||||
## 前后端边界
|
||||
|
||||
前端只通过 HTTP 调用后端,默认 API 路径为相对 `/api/*`。共享类型放在 `src/shared`,前端不得 import `src/server` 的运行时实现。
|
||||
前端只通过 HTTP 调用后端,API 路径为 `/api/*`。共享类型放在 `src/shared`,前端不得 import `src/server` 的运行时实现。
|
||||
|
||||
这保证了当前可以单文件部署,也保留未来将前端拆到 CDN 或独立静态站点的路径。
|
||||
## 目标状态判定
|
||||
|
||||
单层判定模型,适用于 HTTP 和 Command 两种类型:
|
||||
|
||||
- **matched**: 是否符合 expect 规则(无 expect 时默认为 true)
|
||||
- **UP** = matched
|
||||
- **DOWN** = NOT matched
|
||||
|
||||
执行失败(网络错误、超时、进程崩溃)和 expect 不匹配都统一为 `matched=false`,通过 `failure.kind` 区分(`"error"` vs `"mismatch"`)。
|
||||
|
||||
## 已知限制
|
||||
|
||||
当前 demo 不包含数据库、认证、SSR、React Router 或 UI 组件库。单 executable 是按目标平台构建的产物,不是一个文件同时覆盖 macOS、Linux 和 Windows。
|
||||
当前不做告警通知、数据自动清理、拨测目标动态增删、认证鉴权和分布式部署。Command 类型拨测不支持 Windows 环境。
|
||||
|
||||
134
bun.lock
134
bun.lock
@@ -5,8 +5,12 @@
|
||||
"": {
|
||||
"name": "gateway-checker",
|
||||
"dependencies": {
|
||||
"@xmldom/xmldom": "^0.9.10",
|
||||
"cheerio": "^1.2.0",
|
||||
"react": "^19.2.6",
|
||||
"react-dom": "^19.2.6",
|
||||
"recharts": "^3.8.1",
|
||||
"xpath": "^0.0.34",
|
||||
},
|
||||
"devDependencies": {
|
||||
"@eslint/js": "^10.0.1",
|
||||
@@ -103,6 +107,8 @@
|
||||
|
||||
"@oxc-project/types": ["@oxc-project/types@0.128.0", "https://registry.npmmirror.com/@oxc-project/types/-/types-0.128.0.tgz", {}, "sha512-huv1Y/LzBJkBVHt3OlC7u0zHBW9qXf1FdD7sGmc1rXc2P1mTwHssYv7jyGx5KAACSCH+9B3Bhn6Z9luHRvf7pQ=="],
|
||||
|
||||
"@reduxjs/toolkit": ["@reduxjs/toolkit@2.11.2", "https://registry.npmmirror.com/@reduxjs/toolkit/-/toolkit-2.11.2.tgz", { "dependencies": { "@standard-schema/spec": "^1.0.0", "@standard-schema/utils": "^0.3.0", "immer": "^11.0.0", "redux": "^5.0.1", "redux-thunk": "^3.1.0", "reselect": "^5.1.0" }, "peerDependencies": { "react": "^16.9.0 || ^17.0.0 || ^18 || ^19", "react-redux": "^7.2.1 || ^8.1.3 || ^9.0.0" }, "optionalPeers": ["react", "react-redux"] }, "sha512-Kd6kAHTA6/nUpp8mySPqj3en3dm0tdMIgbttnQ1xFMVpufoj+ADi8pXLBsd4xzTRHQa7t/Jv8W5UnCuW4kuWMQ=="],
|
||||
|
||||
"@rolldown/binding-android-arm64": ["@rolldown/binding-android-arm64@1.0.0-rc.18", "https://registry.npmmirror.com/@rolldown/binding-android-arm64/-/binding-android-arm64-1.0.0-rc.18.tgz", { "os": "android", "cpu": "arm64" }, "sha512-lIDyUAfD7U3+BWKzdxMbJcsYHuqXqmGz40aeRqvuAm3y5TkJSYTBW2RDrn65DJFPQqVjUAUqq5uz8urzQ8aBdQ=="],
|
||||
|
||||
"@rolldown/binding-darwin-arm64": ["@rolldown/binding-darwin-arm64@1.0.0-rc.18", "https://registry.npmmirror.com/@rolldown/binding-darwin-arm64/-/binding-darwin-arm64-1.0.0-rc.18.tgz", { "os": "darwin", "cpu": "arm64" }, "sha512-apJq2ktnGp27nSInMR5Vcj8kY6xJzDAvfdIFlpDcAK/w4cDO58qVoi1YQsES/SKiFNge/6e4CUzgjfHduYqWpQ=="],
|
||||
@@ -135,10 +141,32 @@
|
||||
|
||||
"@rolldown/pluginutils": ["@rolldown/pluginutils@1.0.0-rc.7", "https://registry.npmmirror.com/@rolldown/pluginutils/-/pluginutils-1.0.0-rc.7.tgz", {}, "sha512-qujRfC8sFVInYSPPMLQByRh7zhwkGFS4+tyMQ83srV1qrxL4g8E2tyxVVyxd0+8QeBM1mIk9KbWxkegRr76XzA=="],
|
||||
|
||||
"@standard-schema/spec": ["@standard-schema/spec@1.1.0", "https://registry.npmmirror.com/@standard-schema/spec/-/spec-1.1.0.tgz", {}, "sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w=="],
|
||||
|
||||
"@standard-schema/utils": ["@standard-schema/utils@0.3.0", "https://registry.npmmirror.com/@standard-schema/utils/-/utils-0.3.0.tgz", {}, "sha512-e7Mew686owMaPJVNNLs55PUvgz371nKgwsc4vxE49zsODpJEnxgxRo2y/OKrqueavXgZNMDVj3DdHFlaSAeU8g=="],
|
||||
|
||||
"@tybys/wasm-util": ["@tybys/wasm-util@0.10.2", "https://registry.npmmirror.com/@tybys/wasm-util/-/wasm-util-0.10.2.tgz", { "dependencies": { "tslib": "^2.4.0" } }, "sha512-RoBvJ2X0wuKlWFIjrwffGw1IqZHKQqzIchKaadZZfnNpsAYp2mM0h36JtPCjNDAHGgYez/15uMBpfGwchhiMgg=="],
|
||||
|
||||
"@types/bun": ["@types/bun@1.3.13", "https://registry.npmmirror.com/@types/bun/-/bun-1.3.13.tgz", { "dependencies": { "bun-types": "1.3.13" } }, "sha512-9fqXWk5YIHGGnUau9TEi+qdlTYDAnOj+xLCmSTwXfAIqXr2x4tytJb43E9uCvt09zJURKXwAtkoH4nLQfzeTXw=="],
|
||||
|
||||
"@types/d3-array": ["@types/d3-array@3.2.2", "https://registry.npmmirror.com/@types/d3-array/-/d3-array-3.2.2.tgz", {}, "sha512-hOLWVbm7uRza0BYXpIIW5pxfrKe0W+D5lrFiAEYR+pb6w3N2SwSMaJbXdUfSEv+dT4MfHBLtn5js0LAWaO6otw=="],
|
||||
|
||||
"@types/d3-color": ["@types/d3-color@3.1.3", "https://registry.npmmirror.com/@types/d3-color/-/d3-color-3.1.3.tgz", {}, "sha512-iO90scth9WAbmgv7ogoq57O9YpKmFBbmoEoCHDB2xMBY0+/KVrqAaCDyCE16dUspeOvIxFFRI+0sEtqDqy2b4A=="],
|
||||
|
||||
"@types/d3-ease": ["@types/d3-ease@3.0.2", "https://registry.npmmirror.com/@types/d3-ease/-/d3-ease-3.0.2.tgz", {}, "sha512-NcV1JjO5oDzoK26oMzbILE6HW7uVXOHLQvHshBUW4UMdZGfiY6v5BeQwh9a9tCzv+CeefZQHJt5SRgK154RtiA=="],
|
||||
|
||||
"@types/d3-interpolate": ["@types/d3-interpolate@3.0.4", "https://registry.npmmirror.com/@types/d3-interpolate/-/d3-interpolate-3.0.4.tgz", { "dependencies": { "@types/d3-color": "*" } }, "sha512-mgLPETlrpVV1YRJIglr4Ez47g7Yxjl1lj7YKsiMCb27VJH9W8NVM6Bb9d8kkpG/uAQS5AmbA48q2IAolKKo1MA=="],
|
||||
|
||||
"@types/d3-path": ["@types/d3-path@3.1.1", "https://registry.npmmirror.com/@types/d3-path/-/d3-path-3.1.1.tgz", {}, "sha512-VMZBYyQvbGmWyWVea0EHs/BwLgxc+MKi1zLDCONksozI4YJMcTt8ZEuIR4Sb1MMTE8MMW49v0IwI5+b7RmfWlg=="],
|
||||
|
||||
"@types/d3-scale": ["@types/d3-scale@4.0.9", "https://registry.npmmirror.com/@types/d3-scale/-/d3-scale-4.0.9.tgz", { "dependencies": { "@types/d3-time": "*" } }, "sha512-dLmtwB8zkAeO/juAMfnV+sItKjlsw2lKdZVVy6LRr0cBmegxSABiLEpGVmSJJ8O08i4+sGR6qQtb6WtuwJdvVw=="],
|
||||
|
||||
"@types/d3-shape": ["@types/d3-shape@3.1.8", "https://registry.npmmirror.com/@types/d3-shape/-/d3-shape-3.1.8.tgz", { "dependencies": { "@types/d3-path": "*" } }, "sha512-lae0iWfcDeR7qt7rA88BNiqdvPS5pFVPpo5OfjElwNaT2yyekbM0C9vK+yqBqEmHr6lDkRnYNoTBYlAgJa7a4w=="],
|
||||
|
||||
"@types/d3-time": ["@types/d3-time@3.0.4", "https://registry.npmmirror.com/@types/d3-time/-/d3-time-3.0.4.tgz", {}, "sha512-yuzZug1nkAAaBlBBikKZTgzCeA+k1uy4ZFwWANOfKw5z5LRhV0gNA7gNkKm7HoK+HRN0wX3EkxGk0fpbWhmB7g=="],
|
||||
|
||||
"@types/d3-timer": ["@types/d3-timer@3.0.2", "https://registry.npmmirror.com/@types/d3-timer/-/d3-timer-3.0.2.tgz", {}, "sha512-Ps3T8E8dZDam6fUyNiMkekK3XUsaUEik+idO9/YjPtfj2qruF8tFBXS7XhtE4iIXBLxhmLjP3SXpLhVf21I9Lw=="],
|
||||
|
||||
"@types/esrecurse": ["@types/esrecurse@4.3.1", "https://registry.npmmirror.com/@types/esrecurse/-/esrecurse-4.3.1.tgz", {}, "sha512-xJBAbDifo5hpffDBuHl0Y8ywswbiAp/Wi7Y/GtAgSlZyIABppyurxVueOPE8LUQOxdlgi6Zqce7uoEpqNTeiUw=="],
|
||||
|
||||
"@types/estree": ["@types/estree@1.0.9", "https://registry.npmmirror.com/@types/estree/-/estree-1.0.9.tgz", {}, "sha512-GhdPgy1el4/ImP05X05Uw4cw2/M93BCUmnEvWZNStlCzEKME4Fkk+YpoA5OiHNQmoS7Cafb8Xa3Pya8m1Qrzeg=="],
|
||||
@@ -151,6 +179,8 @@
|
||||
|
||||
"@types/react-dom": ["@types/react-dom@19.2.3", "https://registry.npmmirror.com/@types/react-dom/-/react-dom-19.2.3.tgz", { "peerDependencies": { "@types/react": "^19.2.0" } }, "sha512-jp2L/eY6fn+KgVVQAOqYItbF0VY/YApe5Mz2F0aykSO8gx31bYCZyvSeYxCHKvzHG5eZjc+zyaS5BrBWya2+kQ=="],
|
||||
|
||||
"@types/use-sync-external-store": ["@types/use-sync-external-store@0.0.6", "https://registry.npmmirror.com/@types/use-sync-external-store/-/use-sync-external-store-0.0.6.tgz", {}, "sha512-zFDAD+tlpf2r4asuHEj0XH6pY6i0g5NeAHPn+15wk3BV6JA69eERFXC1gyGThDkVa1zCyKr5jox1+2LbV/AMLg=="],
|
||||
|
||||
"@typescript-eslint/eslint-plugin": ["@typescript-eslint/eslint-plugin@8.59.2", "https://registry.npmmirror.com/@typescript-eslint/eslint-plugin/-/eslint-plugin-8.59.2.tgz", { "dependencies": { "@eslint-community/regexpp": "^4.12.2", "@typescript-eslint/scope-manager": "8.59.2", "@typescript-eslint/type-utils": "8.59.2", "@typescript-eslint/utils": "8.59.2", "@typescript-eslint/visitor-keys": "8.59.2", "ignore": "^7.0.5", "natural-compare": "^1.4.0", "ts-api-utils": "^2.5.0" }, "peerDependencies": { "@typescript-eslint/parser": "^8.59.2", "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", "typescript": ">=4.8.4 <6.1.0" } }, "sha512-j/bwmkBvHUtPNxzuWe5z6BEk3q54YRyGlBXkSsmfoih7zNrBvl5A9A98anlp/7JbyZcWIJ8KXo/3Tq/DjFLtuQ=="],
|
||||
|
||||
"@typescript-eslint/parser": ["@typescript-eslint/parser@8.59.2", "https://registry.npmmirror.com/@typescript-eslint/parser/-/parser-8.59.2.tgz", { "dependencies": { "@typescript-eslint/scope-manager": "8.59.2", "@typescript-eslint/types": "8.59.2", "@typescript-eslint/typescript-estree": "8.59.2", "@typescript-eslint/visitor-keys": "8.59.2", "debug": "^4.4.3" }, "peerDependencies": { "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", "typescript": ">=4.8.4 <6.1.0" } }, "sha512-plR3pp6D+SSUn1HM7xvSkx12/DhoHInI2YF35KAcVFNZvlC0gtrWqx7Qq1oH2Ssgi0vlFRCTbP+DZc7B9+TtsQ=="],
|
||||
@@ -173,6 +203,8 @@
|
||||
|
||||
"@vitejs/plugin-react": ["@vitejs/plugin-react@6.0.1", "https://registry.npmmirror.com/@vitejs/plugin-react/-/plugin-react-6.0.1.tgz", { "dependencies": { "@rolldown/pluginutils": "1.0.0-rc.7" }, "peerDependencies": { "@rolldown/plugin-babel": "^0.1.7 || ^0.2.0", "babel-plugin-react-compiler": "^1.0.0", "vite": "^8.0.0" }, "optionalPeers": ["@rolldown/plugin-babel", "babel-plugin-react-compiler"] }, "sha512-l9X/E3cDb+xY3SWzlG1MOGt2usfEHGMNIaegaUGFsLkb3RCn/k8/TOXBcab+OndDI4TBtktT8/9BwwW8Vi9KUQ=="],
|
||||
|
||||
"@xmldom/xmldom": ["@xmldom/xmldom@0.9.10", "https://registry.npmmirror.com/@xmldom/xmldom/-/xmldom-0.9.10.tgz", {}, "sha512-A9gOqLdi6cV4ibazAjcQufGj0B1y/vDqYrcuP6d/6x8P27gRS8643Dj9o1dEKtB6O7fwxb2FgBmJS2mX7gpvdw=="],
|
||||
|
||||
"acorn": ["acorn@8.16.0", "https://registry.npmmirror.com/acorn/-/acorn-8.16.0.tgz", { "bin": { "acorn": "bin/acorn" } }, "sha512-UVJyE9MttOsBQIDKw1skb9nAwQuR5wuGD3+82K6JgJlm/Y+KI92oNsMNGZCYdDsVtRHSak0pcV5Dno5+4jh9sw=="],
|
||||
|
||||
"acorn-jsx": ["acorn-jsx@5.3.2", "https://registry.npmmirror.com/acorn-jsx/-/acorn-jsx-5.3.2.tgz", { "peerDependencies": { "acorn": "^6.0.0 || ^7.0.0 || ^8.0.0" } }, "sha512-rq9s+JNhf0IChjtDXxllJ7g41oZk5SlXtp0LHwyA5cejwn7vKmKp4pPri6YEePv2PU65sAsegbXtIinmDFDXgQ=="],
|
||||
@@ -183,6 +215,8 @@
|
||||
|
||||
"baseline-browser-mapping": ["baseline-browser-mapping@2.10.28", "https://registry.npmmirror.com/baseline-browser-mapping/-/baseline-browser-mapping-2.10.28.tgz", { "bin": { "baseline-browser-mapping": "dist/cli.cjs" } }, "sha512-Ic44hnOtFIgravCunj1ifSoQPSUrkNiJuH9Mf6jr2jjoA74icqV8wU0KuadXeOR8zuIJMOoTv0GuQjZ9ZYNMeA=="],
|
||||
|
||||
"boolbase": ["boolbase@1.0.0", "https://registry.npmmirror.com/boolbase/-/boolbase-1.0.0.tgz", {}, "sha512-JZOSA7Mo9sNGB8+UjSgzdLtokWAky1zbztM3WRLCbZ70/3cTANmQmOdR7y2g+J0e2WXywy1yS468tY+IruqEww=="],
|
||||
|
||||
"brace-expansion": ["brace-expansion@5.0.6", "https://registry.npmmirror.com/brace-expansion/-/brace-expansion-5.0.6.tgz", { "dependencies": { "balanced-match": "^4.0.2" } }, "sha512-kLpxurY4Z4r9sgMsyG0Z9uzsBlgiU/EFKhj/h91/8yHu0edo7XuixOIH3VcJ8kkxs6/jPzoI6U9Vj3WqbMQ94g=="],
|
||||
|
||||
"browserslist": ["browserslist@4.28.2", "https://registry.npmmirror.com/browserslist/-/browserslist-4.28.2.tgz", { "dependencies": { "baseline-browser-mapping": "^2.10.12", "caniuse-lite": "^1.0.30001782", "electron-to-chromium": "^1.5.328", "node-releases": "^2.0.36", "update-browserslist-db": "^1.2.3" }, "bin": { "browserslist": "cli.js" } }, "sha512-48xSriZYYg+8qXna9kwqjIVzuQxi+KYWp2+5nCYnYKPTr0LvD89Jqk2Or5ogxz0NUMfIjhh2lIUX/LyX9B4oIg=="],
|
||||
@@ -191,20 +225,68 @@
|
||||
|
||||
"caniuse-lite": ["caniuse-lite@1.0.30001792", "https://registry.npmmirror.com/caniuse-lite/-/caniuse-lite-1.0.30001792.tgz", {}, "sha512-hVLMUZFgR4JJ6ACt1uEESvQN1/dBVqPAKY0hgrV70eN3391K6juAfTjKZLKvOMsx8PxA7gsY1/tLMMTcfFLLpw=="],
|
||||
|
||||
"cheerio": ["cheerio@1.2.0", "https://registry.npmmirror.com/cheerio/-/cheerio-1.2.0.tgz", { "dependencies": { "cheerio-select": "^2.1.0", "dom-serializer": "^2.0.0", "domhandler": "^5.0.3", "domutils": "^3.2.2", "encoding-sniffer": "^0.2.1", "htmlparser2": "^10.1.0", "parse5": "^7.3.0", "parse5-htmlparser2-tree-adapter": "^7.1.0", "parse5-parser-stream": "^7.1.2", "undici": "^7.19.0", "whatwg-mimetype": "^4.0.0" } }, "sha512-WDrybc/gKFpTYQutKIK6UvfcuxijIZfMfXaYm8NMsPQxSYvf+13fXUJ4rztGGbJcBQ/GF55gvrZ0Bc0bj/mqvg=="],
|
||||
|
||||
"cheerio-select": ["cheerio-select@2.1.0", "https://registry.npmmirror.com/cheerio-select/-/cheerio-select-2.1.0.tgz", { "dependencies": { "boolbase": "^1.0.0", "css-select": "^5.1.0", "css-what": "^6.1.0", "domelementtype": "^2.3.0", "domhandler": "^5.0.3", "domutils": "^3.0.1" } }, "sha512-9v9kG0LvzrlcungtnJtpGNxY+fzECQKhK4EGJX2vByejiMX84MFNQw4UxPJl3bFbTMw+Dfs37XaIkCwTZfLh4g=="],
|
||||
|
||||
"clsx": ["clsx@2.1.1", "https://registry.npmmirror.com/clsx/-/clsx-2.1.1.tgz", {}, "sha512-eYm0QWBtUrBWZWG0d386OGAw16Z995PiOVo2B7bjWSbHedGl5e0ZWaq65kOGgUSNesEIDkB9ISbTg/JK9dhCZA=="],
|
||||
|
||||
"convert-source-map": ["convert-source-map@2.0.0", "https://registry.npmmirror.com/convert-source-map/-/convert-source-map-2.0.0.tgz", {}, "sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg=="],
|
||||
|
||||
"cross-spawn": ["cross-spawn@7.0.6", "https://registry.npmmirror.com/cross-spawn/-/cross-spawn-7.0.6.tgz", { "dependencies": { "path-key": "^3.1.0", "shebang-command": "^2.0.0", "which": "^2.0.1" } }, "sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA=="],
|
||||
|
||||
"css-select": ["css-select@5.2.2", "https://registry.npmmirror.com/css-select/-/css-select-5.2.2.tgz", { "dependencies": { "boolbase": "^1.0.0", "css-what": "^6.1.0", "domhandler": "^5.0.2", "domutils": "^3.0.1", "nth-check": "^2.0.1" } }, "sha512-TizTzUddG/xYLA3NXodFM0fSbNizXjOKhqiQQwvhlspadZokn1KDy0NZFS0wuEubIYAV5/c1/lAr0TaaFXEXzw=="],
|
||||
|
||||
"css-what": ["css-what@6.2.2", "https://registry.npmmirror.com/css-what/-/css-what-6.2.2.tgz", {}, "sha512-u/O3vwbptzhMs3L1fQE82ZSLHQQfto5gyZzwteVIEyeaY5Fc7R4dapF/BvRoSYFeqfBk4m0V1Vafq5Pjv25wvA=="],
|
||||
|
||||
"csstype": ["csstype@3.2.3", "https://registry.npmmirror.com/csstype/-/csstype-3.2.3.tgz", {}, "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ=="],
|
||||
|
||||
"d3-array": ["d3-array@3.2.4", "https://registry.npmmirror.com/d3-array/-/d3-array-3.2.4.tgz", { "dependencies": { "internmap": "1 - 2" } }, "sha512-tdQAmyA18i4J7wprpYq8ClcxZy3SC31QMeByyCFyRt7BVHdREQZ5lpzoe5mFEYZUWe+oq8HBvk9JjpibyEV4Jg=="],
|
||||
|
||||
"d3-color": ["d3-color@3.1.0", "https://registry.npmmirror.com/d3-color/-/d3-color-3.1.0.tgz", {}, "sha512-zg/chbXyeBtMQ1LbD/WSoW2DpC3I0mpmPdW+ynRTj/x2DAWYrIY7qeZIHidozwV24m4iavr15lNwIwLxRmOxhA=="],
|
||||
|
||||
"d3-ease": ["d3-ease@3.0.1", "https://registry.npmmirror.com/d3-ease/-/d3-ease-3.0.1.tgz", {}, "sha512-wR/XK3D3XcLIZwpbvQwQ5fK+8Ykds1ip7A2Txe0yxncXSdq1L9skcG7blcedkOX+ZcgxGAmLX1FrRGbADwzi0w=="],
|
||||
|
||||
"d3-format": ["d3-format@3.1.2", "https://registry.npmmirror.com/d3-format/-/d3-format-3.1.2.tgz", {}, "sha512-AJDdYOdnyRDV5b6ArilzCPPwc1ejkHcoyFarqlPqT7zRYjhavcT3uSrqcMvsgh2CgoPbK3RCwyHaVyxYcP2Arg=="],
|
||||
|
||||
"d3-interpolate": ["d3-interpolate@3.0.1", "https://registry.npmmirror.com/d3-interpolate/-/d3-interpolate-3.0.1.tgz", { "dependencies": { "d3-color": "1 - 3" } }, "sha512-3bYs1rOD33uo8aqJfKP3JWPAibgw8Zm2+L9vBKEHJ2Rg+viTR7o5Mmv5mZcieN+FRYaAOWX5SJATX6k1PWz72g=="],
|
||||
|
||||
"d3-path": ["d3-path@3.1.0", "https://registry.npmmirror.com/d3-path/-/d3-path-3.1.0.tgz", {}, "sha512-p3KP5HCf/bvjBSSKuXid6Zqijx7wIfNW+J/maPs+iwR35at5JCbLUT0LzF1cnjbCHWhqzQTIN2Jpe8pRebIEFQ=="],
|
||||
|
||||
"d3-scale": ["d3-scale@4.0.2", "https://registry.npmmirror.com/d3-scale/-/d3-scale-4.0.2.tgz", { "dependencies": { "d3-array": "2.10.0 - 3", "d3-format": "1 - 3", "d3-interpolate": "1.2.0 - 3", "d3-time": "2.1.1 - 3", "d3-time-format": "2 - 4" } }, "sha512-GZW464g1SH7ag3Y7hXjf8RoUuAFIqklOAq3MRl4OaWabTFJY9PN/E1YklhXLh+OQ3fM9yS2nOkCoS+WLZ6kvxQ=="],
|
||||
|
||||
"d3-shape": ["d3-shape@3.2.0", "https://registry.npmmirror.com/d3-shape/-/d3-shape-3.2.0.tgz", { "dependencies": { "d3-path": "^3.1.0" } }, "sha512-SaLBuwGm3MOViRq2ABk3eLoxwZELpH6zhl3FbAoJ7Vm1gofKx6El1Ib5z23NUEhF9AsGl7y+dzLe5Cw2AArGTA=="],
|
||||
|
||||
"d3-time": ["d3-time@3.1.0", "https://registry.npmmirror.com/d3-time/-/d3-time-3.1.0.tgz", { "dependencies": { "d3-array": "2 - 3" } }, "sha512-VqKjzBLejbSMT4IgbmVgDjpkYrNWUYJnbCGo874u7MMKIWsILRX+OpX/gTk8MqjpT1A/c6HY2dCA77ZN0lkQ2Q=="],
|
||||
|
||||
"d3-time-format": ["d3-time-format@4.1.0", "https://registry.npmmirror.com/d3-time-format/-/d3-time-format-4.1.0.tgz", { "dependencies": { "d3-time": "1 - 3" } }, "sha512-dJxPBlzC7NugB2PDLwo9Q8JiTR3M3e4/XANkreKSUxF8vvXKqm1Yfq4Q5dl8budlunRVlUUaDUgFt7eA8D6NLg=="],
|
||||
|
||||
"d3-timer": ["d3-timer@3.0.1", "https://registry.npmmirror.com/d3-timer/-/d3-timer-3.0.1.tgz", {}, "sha512-ndfJ/JxxMd3nw31uyKoY2naivF+r29V+Lc0svZxe1JvvIRmi8hUsrMvdOwgS1o6uBHmiz91geQ0ylPP0aj1VUA=="],
|
||||
|
||||
"debug": ["debug@4.4.3", "https://registry.npmmirror.com/debug/-/debug-4.4.3.tgz", { "dependencies": { "ms": "^2.1.3" } }, "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA=="],
|
||||
|
||||
"decimal.js-light": ["decimal.js-light@2.5.1", "https://registry.npmmirror.com/decimal.js-light/-/decimal.js-light-2.5.1.tgz", {}, "sha512-qIMFpTMZmny+MMIitAB6D7iVPEorVw6YQRWkvarTkT4tBeSLLiHzcwj6q0MmYSFCiVpiqPJTJEYIrpcPzVEIvg=="],
|
||||
|
||||
"deep-is": ["deep-is@0.1.4", "https://registry.npmmirror.com/deep-is/-/deep-is-0.1.4.tgz", {}, "sha512-oIPzksmTg4/MriiaYGO+okXDT7ztn/w3Eptv/+gSIdMdKsJo0u4CfYNFJPy+4SKMuCqGw2wxnA+URMg3t8a/bQ=="],
|
||||
|
||||
"detect-libc": ["detect-libc@2.1.2", "https://registry.npmmirror.com/detect-libc/-/detect-libc-2.1.2.tgz", {}, "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ=="],
|
||||
|
||||
"dom-serializer": ["dom-serializer@2.0.0", "https://registry.npmmirror.com/dom-serializer/-/dom-serializer-2.0.0.tgz", { "dependencies": { "domelementtype": "^2.3.0", "domhandler": "^5.0.2", "entities": "^4.2.0" } }, "sha512-wIkAryiqt/nV5EQKqQpo3SToSOV9J0DnbJqwK7Wv/Trc92zIAYZ4FlMu+JPFW1DfGFt81ZTCGgDEabffXeLyJg=="],
|
||||
|
||||
"domelementtype": ["domelementtype@2.3.0", "https://registry.npmmirror.com/domelementtype/-/domelementtype-2.3.0.tgz", {}, "sha512-OLETBj6w0OsagBwdXnPdN0cnMfF9opN69co+7ZrbfPGrdpPVNBUj02spi6B1N7wChLQiPn4CSH/zJvXw56gmHw=="],
|
||||
|
||||
"domhandler": ["domhandler@5.0.3", "https://registry.npmmirror.com/domhandler/-/domhandler-5.0.3.tgz", { "dependencies": { "domelementtype": "^2.3.0" } }, "sha512-cgwlv/1iFQiFnU96XXgROh8xTeetsnJiDsTc7TYCLFd9+/WNkIqPTxiM/8pSd8VIrhXGTf1Ny1q1hquVqDJB5w=="],
|
||||
|
||||
"domutils": ["domutils@3.2.2", "https://registry.npmmirror.com/domutils/-/domutils-3.2.2.tgz", { "dependencies": { "dom-serializer": "^2.0.0", "domelementtype": "^2.3.0", "domhandler": "^5.0.3" } }, "sha512-6kZKyUajlDuqlHKVX1w7gyslj9MPIXzIFiz/rGu35uC1wMi+kMhQwGhl4lt9unC9Vb9INnY9Z3/ZA3+FhASLaw=="],
|
||||
|
||||
"electron-to-chromium": ["electron-to-chromium@1.5.353", "https://registry.npmmirror.com/electron-to-chromium/-/electron-to-chromium-1.5.353.tgz", {}, "sha512-kOrWphBi8TOZyiJZqsgqIle0lw+tzmnQK83pV9dZUd01Nm2POECSyFQMAuarzZdYqQW7FH9RaYOuaRo3h+bQ3w=="],
|
||||
|
||||
"encoding-sniffer": ["encoding-sniffer@0.2.1", "https://registry.npmmirror.com/encoding-sniffer/-/encoding-sniffer-0.2.1.tgz", { "dependencies": { "iconv-lite": "^0.6.3", "whatwg-encoding": "^3.1.1" } }, "sha512-5gvq20T6vfpekVtqrYQsSCFZ1wEg5+wW0/QaZMWkFr6BqD3NfKs0rLCx4rrVlSWJeZb5NBJgVLswK/w2MWU+Gw=="],
|
||||
|
||||
"entities": ["entities@4.5.0", "https://registry.npmmirror.com/entities/-/entities-4.5.0.tgz", {}, "sha512-V0hjH4dGPh9Ao5p0MoRY6BVqtwCjhz6vI5LT8AJ55H+4g9/4vbHx1I54fS0XuclLhDHArPQCiMjDxjaL8fPxhw=="],
|
||||
|
||||
"es-toolkit": ["es-toolkit@1.46.1", "https://registry.npmmirror.com/es-toolkit/-/es-toolkit-1.46.1.tgz", {}, "sha512-5eNtXOs3tbfxXOj04tjjseeWkRWaoCjdEI+96DgwzZoe6c9juL49pXlzAFTI72aWC9Y8p7168g6XIKjh7k6pyQ=="],
|
||||
|
||||
"escalade": ["escalade@3.2.0", "https://registry.npmmirror.com/escalade/-/escalade-3.2.0.tgz", {}, "sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA=="],
|
||||
|
||||
"escape-string-regexp": ["escape-string-regexp@4.0.0", "https://registry.npmmirror.com/escape-string-regexp/-/escape-string-regexp-4.0.0.tgz", {}, "sha512-TtpcNJ3XAzx3Gq8sWRzJaVajRs0uVxA2YAkdb1jm2YkPz4G6egUFAyA3n5vtEIZefPk5Wa4UXbKuS5fKkJWdgA=="],
|
||||
@@ -229,6 +311,8 @@
|
||||
|
||||
"esutils": ["esutils@2.0.3", "https://registry.npmmirror.com/esutils/-/esutils-2.0.3.tgz", {}, "sha512-kVscqXk4OCp68SZ0dkgEKVi6/8ij300KBWTJq32P/dYeWTSwK41WyTxalN1eRmA5Z9UU/LX9D7FWSmV9SAYx6g=="],
|
||||
|
||||
"eventemitter3": ["eventemitter3@5.0.4", "https://registry.npmmirror.com/eventemitter3/-/eventemitter3-5.0.4.tgz", {}, "sha512-mlsTRyGaPBjPedk6Bvw+aqbsXDtoAyAzm5MO7JgU+yVRyMQ5O8bD4Kcci7BS85f93veegeCPkL8R4GLClnjLFw=="],
|
||||
|
||||
"fast-deep-equal": ["fast-deep-equal@3.1.3", "https://registry.npmmirror.com/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz", {}, "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q=="],
|
||||
|
||||
"fast-json-stable-stringify": ["fast-json-stable-stringify@2.1.0", "https://registry.npmmirror.com/fast-json-stable-stringify/-/fast-json-stable-stringify-2.1.0.tgz", {}, "sha512-lhd/wF+Lk98HZoTCtlVraHtfh5XYijIjalXck7saUtuanSDyLMxnHhSXEDJqHxD7msR8D0uCmqlkwjCV8xvwHw=="],
|
||||
@@ -255,10 +339,18 @@
|
||||
|
||||
"hermes-parser": ["hermes-parser@0.25.1", "https://registry.npmmirror.com/hermes-parser/-/hermes-parser-0.25.1.tgz", { "dependencies": { "hermes-estree": "0.25.1" } }, "sha512-6pEjquH3rqaI6cYAXYPcz9MS4rY6R4ngRgrgfDshRptUZIc3lw0MCIJIGDj9++mfySOuPTHB4nrSW99BCvOPIA=="],
|
||||
|
||||
"htmlparser2": ["htmlparser2@10.1.0", "https://registry.npmmirror.com/htmlparser2/-/htmlparser2-10.1.0.tgz", { "dependencies": { "domelementtype": "^2.3.0", "domhandler": "^5.0.3", "domutils": "^3.2.2", "entities": "^7.0.1" } }, "sha512-VTZkM9GWRAtEpveh7MSF6SjjrpNVNNVJfFup7xTY3UpFtm67foy9HDVXneLtFVt4pMz5kZtgNcvCniNFb1hlEQ=="],
|
||||
|
||||
"iconv-lite": ["iconv-lite@0.6.3", "https://registry.npmmirror.com/iconv-lite/-/iconv-lite-0.6.3.tgz", { "dependencies": { "safer-buffer": ">= 2.1.2 < 3.0.0" } }, "sha512-4fCk79wshMdzMp2rH06qWrJE4iolqLhCUH+OiuIgU++RB0+94NlDL81atO7GX55uUKueo0txHNtvEyI6D7WdMw=="],
|
||||
|
||||
"ignore": ["ignore@5.3.2", "https://registry.npmmirror.com/ignore/-/ignore-5.3.2.tgz", {}, "sha512-hsBTNUqQTDwkWtcdYI2i06Y/nUBEsNEDJKjWdigLvegy8kDuJAS8uRlpkkcQpyEXL0Z/pjDy5HBmMjRCJ2gq+g=="],
|
||||
|
||||
"immer": ["immer@10.2.0", "https://registry.npmmirror.com/immer/-/immer-10.2.0.tgz", {}, "sha512-d/+XTN3zfODyjr89gM3mPq1WNX2B8pYsu7eORitdwyA2sBubnTl3laYlBk4sXY5FUa5qTZGBDPJICVbvqzjlbw=="],
|
||||
|
||||
"imurmurhash": ["imurmurhash@0.1.4", "https://registry.npmmirror.com/imurmurhash/-/imurmurhash-0.1.4.tgz", {}, "sha512-JmXMZ6wuvDmLiHEml9ykzqO6lwFbof0GG4IkcGaENdCRDDmMVnny7s5HsIgHCbaq0w2MyPhDqkhTUgS2LU2PHA=="],
|
||||
|
||||
"internmap": ["internmap@2.0.3", "https://registry.npmmirror.com/internmap/-/internmap-2.0.3.tgz", {}, "sha512-5Hh7Y1wQbvY5ooGgPbDaL5iYLAPzMTUrjMulskHLH6wnv/A+1q5rgEaiuqEjB+oxGXIVZs1FF+R/KPN3ZSQYYg=="],
|
||||
|
||||
"is-extglob": ["is-extglob@2.1.1", "https://registry.npmmirror.com/is-extglob/-/is-extglob-2.1.1.tgz", {}, "sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ=="],
|
||||
|
||||
"is-glob": ["is-glob@4.0.3", "https://registry.npmmirror.com/is-glob/-/is-glob-4.0.3.tgz", { "dependencies": { "is-extglob": "^2.1.1" } }, "sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg=="],
|
||||
@@ -319,12 +411,20 @@
|
||||
|
||||
"node-releases": ["node-releases@2.0.38", "https://registry.npmmirror.com/node-releases/-/node-releases-2.0.38.tgz", {}, "sha512-3qT/88Y3FbH/Kx4szpQQ4HzUbVrHPKTLVpVocKiLfoYvw9XSGOX2FmD2d6DrXbVYyAQTF2HeF6My8jmzx7/CRw=="],
|
||||
|
||||
"nth-check": ["nth-check@2.1.1", "https://registry.npmmirror.com/nth-check/-/nth-check-2.1.1.tgz", { "dependencies": { "boolbase": "^1.0.0" } }, "sha512-lqjrjmaOoAnWfMmBPL+XNnynZh2+swxiX3WUE0s4yEHI6m+AwrK2UZOimIRl3X/4QctVqS8AiZjFqyOGrMXb/w=="],
|
||||
|
||||
"optionator": ["optionator@0.9.4", "https://registry.npmmirror.com/optionator/-/optionator-0.9.4.tgz", { "dependencies": { "deep-is": "^0.1.3", "fast-levenshtein": "^2.0.6", "levn": "^0.4.1", "prelude-ls": "^1.2.1", "type-check": "^0.4.0", "word-wrap": "^1.2.5" } }, "sha512-6IpQ7mKUxRcZNLIObR0hz7lxsapSSIYNZJwXPGeF0mTVqGKFIXj1DQcMoT22S3ROcLyY/rz0PWaWZ9ayWmad9g=="],
|
||||
|
||||
"p-limit": ["p-limit@3.1.0", "https://registry.npmmirror.com/p-limit/-/p-limit-3.1.0.tgz", { "dependencies": { "yocto-queue": "^0.1.0" } }, "sha512-TYOanM3wGwNGsZN2cVTYPArw454xnXj5qmWF1bEoAc4+cU/ol7GVh7odevjp1FNHduHc3KZMcFduxU5Xc6uJRQ=="],
|
||||
|
||||
"p-locate": ["p-locate@5.0.0", "https://registry.npmmirror.com/p-locate/-/p-locate-5.0.0.tgz", { "dependencies": { "p-limit": "^3.0.2" } }, "sha512-LaNjtRWUBY++zB5nE/NwcaoMylSPk+S+ZHNB1TzdbMJMny6dynpAGt7X/tl/QYq3TIeE6nxHppbo2LGymrG5Pw=="],
|
||||
|
||||
"parse5": ["parse5@7.3.0", "https://registry.npmmirror.com/parse5/-/parse5-7.3.0.tgz", { "dependencies": { "entities": "^6.0.0" } }, "sha512-IInvU7fabl34qmi9gY8XOVxhYyMyuH2xUNpb2q8/Y+7552KlejkRvqvD19nMoUW/uQGGbqNpA6Tufu5FL5BZgw=="],
|
||||
|
||||
"parse5-htmlparser2-tree-adapter": ["parse5-htmlparser2-tree-adapter@7.1.0", "https://registry.npmmirror.com/parse5-htmlparser2-tree-adapter/-/parse5-htmlparser2-tree-adapter-7.1.0.tgz", { "dependencies": { "domhandler": "^5.0.3", "parse5": "^7.0.0" } }, "sha512-ruw5xyKs6lrpo9x9rCZqZZnIUntICjQAd0Wsmp396Ul9lN/h+ifgVV1x1gZHi8euej6wTfpqX8j+BFQxF0NS/g=="],
|
||||
|
||||
"parse5-parser-stream": ["parse5-parser-stream@7.1.2", "https://registry.npmmirror.com/parse5-parser-stream/-/parse5-parser-stream-7.1.2.tgz", { "dependencies": { "parse5": "^7.0.0" } }, "sha512-JyeQc9iwFLn5TbvvqACIF/VXG6abODeB3Fwmv/TGdLk2LfbWkaySGY72at4+Ty7EkPZj854u4CrICqNk2qIbow=="],
|
||||
|
||||
"path-exists": ["path-exists@4.0.0", "https://registry.npmmirror.com/path-exists/-/path-exists-4.0.0.tgz", {}, "sha512-ak9Qy5Q7jYb2Wwcey5Fpvg2KoAc/ZIhLSLOSBmRmygPsGwkVVt0fZa0qrtMz+m6tJTAHfZQ8FnmB4MG4LWy7/w=="],
|
||||
|
||||
"path-key": ["path-key@3.1.1", "https://registry.npmmirror.com/path-key/-/path-key-3.1.1.tgz", {}, "sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q=="],
|
||||
@@ -345,8 +445,22 @@
|
||||
|
||||
"react-dom": ["react-dom@19.2.6", "https://registry.npmmirror.com/react-dom/-/react-dom-19.2.6.tgz", { "dependencies": { "scheduler": "^0.27.0" }, "peerDependencies": { "react": "^19.2.6" } }, "sha512-0prMI+hvBbPjsWnxDLxlCGyM8PN6UuWjEUCYmZhO67xIV9Xasa/r/vDnq+Xyq4Lo27g8QSbO5YzARu0D1Sps3g=="],
|
||||
|
||||
"react-is": ["react-is@19.2.6", "https://registry.npmmirror.com/react-is/-/react-is-19.2.6.tgz", {}, "sha512-XjBR15BhXuylgWGuslhDKqlSayuqvqBX91BP8pauG8kd1zY8kotkNWbXksTCNRarse4kuGbe2kIY05ARtwNIvw=="],
|
||||
|
||||
"react-redux": ["react-redux@9.2.0", "https://registry.npmmirror.com/react-redux/-/react-redux-9.2.0.tgz", { "dependencies": { "@types/use-sync-external-store": "^0.0.6", "use-sync-external-store": "^1.4.0" }, "peerDependencies": { "@types/react": "^18.2.25 || ^19", "react": "^18.0 || ^19", "redux": "^5.0.0" }, "optionalPeers": ["@types/react", "redux"] }, "sha512-ROY9fvHhwOD9ySfrF0wmvu//bKCQ6AeZZq1nJNtbDC+kk5DuSuNX/n6YWYF/SYy7bSba4D4FSz8DJeKY/S/r+g=="],
|
||||
|
||||
"recharts": ["recharts@3.8.1", "https://registry.npmmirror.com/recharts/-/recharts-3.8.1.tgz", { "dependencies": { "@reduxjs/toolkit": "^1.9.0 || 2.x.x", "clsx": "^2.1.1", "decimal.js-light": "^2.5.1", "es-toolkit": "^1.39.3", "eventemitter3": "^5.0.1", "immer": "^10.1.1", "react-redux": "8.x.x || 9.x.x", "reselect": "5.1.1", "tiny-invariant": "^1.3.3", "use-sync-external-store": "^1.2.2", "victory-vendor": "^37.0.2" }, "peerDependencies": { "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0", "react-dom": "^16.0.0 || ^17.0.0 || ^18.0.0 || ^19.0.0", "react-is": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" } }, "sha512-mwzmO1s9sFL0TduUpwndxCUNoXsBw3u3E/0+A+cLcrSfQitSG62L32N69GhqUrrT5qKcAE3pCGVINC6pqkBBQg=="],
|
||||
|
||||
"redux": ["redux@5.0.1", "https://registry.npmmirror.com/redux/-/redux-5.0.1.tgz", {}, "sha512-M9/ELqF6fy8FwmkpnF0S3YKOqMyoWJ4+CS5Efg2ct3oY9daQvd/Pc71FpGZsVsbl3Cpb+IIcjBDUnnyBdQbq4w=="],
|
||||
|
||||
"redux-thunk": ["redux-thunk@3.1.0", "https://registry.npmmirror.com/redux-thunk/-/redux-thunk-3.1.0.tgz", { "peerDependencies": { "redux": "^5.0.0" } }, "sha512-NW2r5T6ksUKXCabzhL9z+h206HQw/NJkcLm1GPImRQ8IzfXwRGqjVhKJGauHirT0DAuyy6hjdnMZaRoAcy0Klw=="],
|
||||
|
||||
"reselect": ["reselect@5.1.1", "https://registry.npmmirror.com/reselect/-/reselect-5.1.1.tgz", {}, "sha512-K/BG6eIky/SBpzfHZv/dd+9JBFiS4SWV7FIujVyJRux6e45+73RaUHXLmIR1f7WOMaQ0U1km6qwklRQxpJJY0w=="],
|
||||
|
||||
"rolldown": ["rolldown@1.0.0-rc.18", "https://registry.npmmirror.com/rolldown/-/rolldown-1.0.0-rc.18.tgz", { "dependencies": { "@oxc-project/types": "=0.128.0", "@rolldown/pluginutils": "1.0.0-rc.18" }, "optionalDependencies": { "@rolldown/binding-android-arm64": "1.0.0-rc.18", "@rolldown/binding-darwin-arm64": "1.0.0-rc.18", "@rolldown/binding-darwin-x64": "1.0.0-rc.18", "@rolldown/binding-freebsd-x64": "1.0.0-rc.18", "@rolldown/binding-linux-arm-gnueabihf": "1.0.0-rc.18", "@rolldown/binding-linux-arm64-gnu": "1.0.0-rc.18", "@rolldown/binding-linux-arm64-musl": "1.0.0-rc.18", "@rolldown/binding-linux-ppc64-gnu": "1.0.0-rc.18", "@rolldown/binding-linux-s390x-gnu": "1.0.0-rc.18", "@rolldown/binding-linux-x64-gnu": "1.0.0-rc.18", "@rolldown/binding-linux-x64-musl": "1.0.0-rc.18", "@rolldown/binding-openharmony-arm64": "1.0.0-rc.18", "@rolldown/binding-wasm32-wasi": "1.0.0-rc.18", "@rolldown/binding-win32-arm64-msvc": "1.0.0-rc.18", "@rolldown/binding-win32-x64-msvc": "1.0.0-rc.18" }, "bin": { "rolldown": "bin/cli.mjs" } }, "sha512-phmyKBpuBdRYDf4hgyynGAYn/rDDe+iZXKVJ7WX5b1zQzpLkP5oJRPGsfJuHdzPMlyyEO/4sPW6yfSx2gf7lVg=="],
|
||||
|
||||
"safer-buffer": ["safer-buffer@2.1.2", "https://registry.npmmirror.com/safer-buffer/-/safer-buffer-2.1.2.tgz", {}, "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg=="],
|
||||
|
||||
"scheduler": ["scheduler@0.27.0", "https://registry.npmmirror.com/scheduler/-/scheduler-0.27.0.tgz", {}, "sha512-eNv+WrVbKu1f3vbYJT/xtiF5syA5HPIMtf9IgY/nKg0sWqzAUEvqY/xm7OcZc/qafLx/iO9FgOmeSAp4v5ti/Q=="],
|
||||
|
||||
"semver": ["semver@6.3.1", "https://registry.npmmirror.com/semver/-/semver-6.3.1.tgz", { "bin": { "semver": "bin/semver.js" } }, "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA=="],
|
||||
@@ -357,6 +471,8 @@
|
||||
|
||||
"source-map-js": ["source-map-js@1.2.1", "https://registry.npmmirror.com/source-map-js/-/source-map-js-1.2.1.tgz", {}, "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA=="],
|
||||
|
||||
"tiny-invariant": ["tiny-invariant@1.3.3", "https://registry.npmmirror.com/tiny-invariant/-/tiny-invariant-1.3.3.tgz", {}, "sha512-+FbBPE1o9QAYvviau/qC5SE3caw21q3xkvWKBtja5vgqOWIHHJ3ioaq1VPfn/Szqctz2bU/oYeKd9/z5BL+PVg=="],
|
||||
|
||||
"tinyglobby": ["tinyglobby@0.2.16", "https://registry.npmmirror.com/tinyglobby/-/tinyglobby-0.2.16.tgz", { "dependencies": { "fdir": "^6.5.0", "picomatch": "^4.0.4" } }, "sha512-pn99VhoACYR8nFHhxqix+uvsbXineAasWm5ojXoN8xEwK5Kd3/TrhNn1wByuD52UxWRLy8pu+kRMniEi6Eq9Zg=="],
|
||||
|
||||
"ts-api-utils": ["ts-api-utils@2.5.0", "https://registry.npmmirror.com/ts-api-utils/-/ts-api-utils-2.5.0.tgz", { "peerDependencies": { "typescript": ">=4.8.4" } }, "sha512-OJ/ibxhPlqrMM0UiNHJ/0CKQkoKF243/AEmplt3qpRgkW8VG7IfOS41h7V8TjITqdByHzrjcS/2si+y4lIh8NA=="],
|
||||
@@ -369,18 +485,30 @@
|
||||
|
||||
"typescript-eslint": ["typescript-eslint@8.59.2", "https://registry.npmmirror.com/typescript-eslint/-/typescript-eslint-8.59.2.tgz", { "dependencies": { "@typescript-eslint/eslint-plugin": "8.59.2", "@typescript-eslint/parser": "8.59.2", "@typescript-eslint/typescript-estree": "8.59.2", "@typescript-eslint/utils": "8.59.2" }, "peerDependencies": { "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", "typescript": ">=4.8.4 <6.1.0" } }, "sha512-pJw051uomb3ZeCzGTpRb8RbEqB5Y4WWet8gl/GcTlU35BSx0PVdZ86/bqkQCyKKuraVQEK7r6kBHQXF+fBhkoQ=="],
|
||||
|
||||
"undici": ["undici@7.25.0", "https://registry.npmmirror.com/undici/-/undici-7.25.0.tgz", {}, "sha512-xXnp4kTyor2Zq+J1FfPI6Eq3ew5h6Vl0F/8d9XU5zZQf1tX9s2Su1/3PiMmUANFULpmksxkClamIZcaUqryHsQ=="],
|
||||
|
||||
"undici-types": ["undici-types@7.19.2", "https://registry.npmmirror.com/undici-types/-/undici-types-7.19.2.tgz", {}, "sha512-qYVnV5OEm2AW8cJMCpdV20CDyaN3g0AjDlOGf1OW4iaDEx8MwdtChUp4zu4H0VP3nDRF/8RKWH+IPp9uW0YGZg=="],
|
||||
|
||||
"update-browserslist-db": ["update-browserslist-db@1.2.3", "https://registry.npmmirror.com/update-browserslist-db/-/update-browserslist-db-1.2.3.tgz", { "dependencies": { "escalade": "^3.2.0", "picocolors": "^1.1.1" }, "peerDependencies": { "browserslist": ">= 4.21.0" }, "bin": { "update-browserslist-db": "cli.js" } }, "sha512-Js0m9cx+qOgDxo0eMiFGEueWztz+d4+M3rGlmKPT+T4IS/jP4ylw3Nwpu6cpTTP8R1MAC1kF4VbdLt3ARf209w=="],
|
||||
|
||||
"uri-js": ["uri-js@4.4.1", "https://registry.npmmirror.com/uri-js/-/uri-js-4.4.1.tgz", { "dependencies": { "punycode": "^2.1.0" } }, "sha512-7rKUyy33Q1yc98pQ1DAmLtwX109F7TIfWlW1Ydo8Wl1ii1SeHieeh0HHfPeL2fMXK6z0s8ecKs9frCuLJvndBg=="],
|
||||
|
||||
"use-sync-external-store": ["use-sync-external-store@1.6.0", "https://registry.npmmirror.com/use-sync-external-store/-/use-sync-external-store-1.6.0.tgz", { "peerDependencies": { "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" } }, "sha512-Pp6GSwGP/NrPIrxVFAIkOQeyw8lFenOHijQWkUTrDvrF4ALqylP2C/KCkeS9dpUM3KvYRQhna5vt7IL95+ZQ9w=="],
|
||||
|
||||
"victory-vendor": ["victory-vendor@37.3.6", "https://registry.npmmirror.com/victory-vendor/-/victory-vendor-37.3.6.tgz", { "dependencies": { "@types/d3-array": "^3.0.3", "@types/d3-ease": "^3.0.0", "@types/d3-interpolate": "^3.0.1", "@types/d3-scale": "^4.0.2", "@types/d3-shape": "^3.1.0", "@types/d3-time": "^3.0.0", "@types/d3-timer": "^3.0.0", "d3-array": "^3.1.6", "d3-ease": "^3.0.1", "d3-interpolate": "^3.0.1", "d3-scale": "^4.0.2", "d3-shape": "^3.1.0", "d3-time": "^3.0.0", "d3-timer": "^3.0.1" } }, "sha512-SbPDPdDBYp+5MJHhBCAyI7wKM3d5ivekigc2Dk2s7pgbZ9wIgIBYGVw4zGHBml/qTFbexrofXW6Gu4noGxrOwQ=="],
|
||||
|
||||
"vite": ["vite@8.0.11", "https://registry.npmmirror.com/vite/-/vite-8.0.11.tgz", { "dependencies": { "lightningcss": "^1.32.0", "picomatch": "^4.0.4", "postcss": "^8.5.14", "rolldown": "1.0.0-rc.18", "tinyglobby": "^0.2.16" }, "optionalDependencies": { "fsevents": "~2.3.3" }, "peerDependencies": { "@types/node": "^20.19.0 || >=22.12.0", "@vitejs/devtools": "^0.1.18", "esbuild": "^0.27.0 || ^0.28.0", "jiti": ">=1.21.0", "less": "^4.0.0", "sass": "^1.70.0", "sass-embedded": "^1.70.0", "stylus": ">=0.54.8", "sugarss": "^5.0.0", "terser": "^5.16.0", "tsx": "^4.8.1", "yaml": "^2.4.2" }, "optionalPeers": ["@types/node", "@vitejs/devtools", "esbuild", "jiti", "less", "sass", "sass-embedded", "stylus", "sugarss", "terser", "tsx", "yaml"], "bin": { "vite": "bin/vite.js" } }, "sha512-Jz1mxtUBR5xTT65VOdJZUUeoyLtqljmFkiUXhPTLZka3RDc9vpi/xXkyrnsdRcm2lIi3l3GPMnAidTsEGIj3Ow=="],
|
||||
|
||||
"whatwg-encoding": ["whatwg-encoding@3.1.1", "https://registry.npmmirror.com/whatwg-encoding/-/whatwg-encoding-3.1.1.tgz", { "dependencies": { "iconv-lite": "0.6.3" } }, "sha512-6qN4hJdMwfYBtE3YBTTHhoeuUrDBPZmbQaxWAqSALV/MeEnR5z1xd8UKud2RAkFoPkmB+hli1TZSnyi84xz1vQ=="],
|
||||
|
||||
"whatwg-mimetype": ["whatwg-mimetype@4.0.0", "https://registry.npmmirror.com/whatwg-mimetype/-/whatwg-mimetype-4.0.0.tgz", {}, "sha512-QaKxh0eNIi2mE9p2vEdzfagOKHCcj1pJ56EEHGQOVxp8r9/iszLUUV7v89x9O1p/T+NlTM5W7jW6+cz4Fq1YVg=="],
|
||||
|
||||
"which": ["which@2.0.2", "https://registry.npmmirror.com/which/-/which-2.0.2.tgz", { "dependencies": { "isexe": "^2.0.0" }, "bin": { "node-which": "./bin/node-which" } }, "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA=="],
|
||||
|
||||
"word-wrap": ["word-wrap@1.2.5", "https://registry.npmmirror.com/word-wrap/-/word-wrap-1.2.5.tgz", {}, "sha512-BN22B5eaMMI9UMtjrGd5g5eCYPpCPDUy0FJXbYsaT5zYxjFOckS53SQDE3pWkVoWpHXVb3BrYcEN4Twa55B5cA=="],
|
||||
|
||||
"xpath": ["xpath@0.0.34", "https://registry.npmmirror.com/xpath/-/xpath-0.0.34.tgz", {}, "sha512-FxF6+rkr1rNSQrhUNYrAFJpRXNzlDoMxeXN5qI84939ylEv3qqPFKa85Oxr6tDaJKqwW6KKyo2v26TSv3k6LeA=="],
|
||||
|
||||
"yallist": ["yallist@3.1.1", "https://registry.npmmirror.com/yallist/-/yallist-3.1.1.tgz", {}, "sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g=="],
|
||||
|
||||
"yocto-queue": ["yocto-queue@0.1.0", "https://registry.npmmirror.com/yocto-queue/-/yocto-queue-0.1.0.tgz", {}, "sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q=="],
|
||||
@@ -391,10 +519,16 @@
|
||||
|
||||
"@eslint-community/eslint-utils/eslint-visitor-keys": ["eslint-visitor-keys@3.4.3", "https://registry.npmmirror.com/eslint-visitor-keys/-/eslint-visitor-keys-3.4.3.tgz", {}, "sha512-wpc+LXeiyiisxPlEkUzU6svyS1frIO3Mgxj1fdy7Pm8Ygzguax2N3Fa/D/ag1WqbOprdI+uY6wMUl8/a2G+iag=="],
|
||||
|
||||
"@reduxjs/toolkit/immer": ["immer@11.1.8", "https://registry.npmmirror.com/immer/-/immer-11.1.8.tgz", {}, "sha512-/tbkHMW7y10Lx6i1crLjD4/OhNkRG+Fo7byZHtah0547nIeXYcpIXaUh0IAQY6gO5459qpGGYapcEOHtFXkIuA=="],
|
||||
|
||||
"@typescript-eslint/eslint-plugin/ignore": ["ignore@7.0.5", "https://registry.npmmirror.com/ignore/-/ignore-7.0.5.tgz", {}, "sha512-Hs59xBNfUIunMFgWAbGX5cq6893IbWg4KnrjbYwX3tx0ztorVgTDA6B2sxf8ejHJ4wz8BqGUMYlnzNBer5NvGg=="],
|
||||
|
||||
"@typescript-eslint/typescript-estree/semver": ["semver@7.8.0", "https://registry.npmmirror.com/semver/-/semver-7.8.0.tgz", { "bin": { "semver": "bin/semver.js" } }, "sha512-AcM7dV/5ul4EekoQ29Agm5vri8JNqRyj39o0qpX6vDF2GZrtutZl5RwgD1XnZjiTAfncsJhMI48QQH3sN87YNA=="],
|
||||
|
||||
"htmlparser2/entities": ["entities@7.0.1", "https://registry.npmmirror.com/entities/-/entities-7.0.1.tgz", {}, "sha512-TWrgLOFUQTH994YUyl1yT4uyavY5nNB5muff+RtWaqNVCAK408b5ZnnbNAUEWLTCpum9w6arT70i1XdQ4UeOPA=="],
|
||||
|
||||
"parse5/entities": ["entities@6.0.1", "https://registry.npmmirror.com/entities/-/entities-6.0.1.tgz", {}, "sha512-aN97NXWF6AWBTahfVOIrB/NShkzi5H7F9r1s9mD3cDj4Ko5f2qhhVoYMibXF7GlLveb/D2ioWay8lxI97Ven3g=="],
|
||||
|
||||
"rolldown/@rolldown/pluginutils": ["@rolldown/pluginutils@1.0.0-rc.18", "https://registry.npmmirror.com/@rolldown/pluginutils/-/pluginutils-1.0.0-rc.18.tgz", {}, "sha512-CUY5Mnhe64xQBGZEEXQ5WyZwsc1JU3vAZLIxtrsBt3LO6UOb+C8GunVKqe9sT8NeWb4lqSaoJtp2xo6GxT1MNw=="],
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,2 @@
|
||||
schema: spec-driven
|
||||
created: 2026-05-09
|
||||
@@ -0,0 +1,121 @@
|
||||
## Context
|
||||
|
||||
当前项目是 Bun + TypeScript 的最小工程,入口文件只有 `index.ts`,尚未形成前端、后端、共享类型、测试和构建的边界。目标是在保持 Bun 单文件部署优势的同时,引入完整的 Vite + React 前端开发体验。
|
||||
|
||||
业界成熟实践通常不是让后端参与前端 HMR,而是开发期让 Vite dev server 独立承载前端,使用 proxy 将 `/api/*` 转发给后端;生产期由前端构建生成静态资源,再由后端服务这些资源。Go/Rust 生态常用类似 `embed` 的方式把 Vite `dist/` 打入单个二进制,Bun 可通过 `bun build --compile` 与 embedded files/full-stack asset 能力实现同类目标。
|
||||
|
||||
```
|
||||
开发期
|
||||
|
||||
Browser
|
||||
|
|
||||
v
|
||||
Vite dev server :5173
|
||||
|-- React HMR
|
||||
|-- /api/* proxy
|
||||
|
|
||||
v
|
||||
Bun API server :3000
|
||||
|
||||
生产期
|
||||
|
||||
Browser
|
||||
|
|
||||
v
|
||||
gateway-checker executable
|
||||
|-- /api/* Bun API
|
||||
|-- /health 健康检查
|
||||
|-- /assets/* Vite 静态资源
|
||||
|-- /* React SPA fallback
|
||||
```
|
||||
|
||||
## Goals / Non-Goals
|
||||
|
||||
**Goals:**
|
||||
|
||||
- 建立 Vite + React + TypeScript 前端应用结构,保留开发期 HMR。
|
||||
- 建立 Bun 后端服务结构,统一承载 API、健康检查和生产前端资源。
|
||||
- 提供一个可运行 demo,前端页面通过 `/api/demo` 调用后端并展示响应。
|
||||
- 建立前后端共享类型边界,避免重复定义基础接口类型。
|
||||
- 建立生产构建链路,输出单个 Bun standalone executable。
|
||||
- 保持前端可拆离:前端只通过 HTTP `/api/*` 依赖后端,不直接 import 后端实现。
|
||||
- 更新 README,记录结构、命令、测试、构建和运行方式。
|
||||
|
||||
**Non-Goals:**
|
||||
|
||||
- 不引入 SSR、React Server Components、Next.js 或其他全栈框架。
|
||||
- 不引入数据库、认证、用户系统或业务功能。
|
||||
- 不要求一次性完成多平台发布矩阵,只定义可扩展的 target 机制。
|
||||
- 不把运行期配置、日志或可变数据嵌入 executable。
|
||||
- 不在开发期强制单端口访问;开发期可以使用 Vite 端口作为浏览器入口。
|
||||
|
||||
## Decisions
|
||||
|
||||
### Decision: 使用 Vite + React 作为前端开发框架
|
||||
|
||||
采用 Vite + React + TypeScript,开发期由 Vite 提供 HMR,生产期由 `vite build` 输出静态资源。React 适合后续构建复杂管理界面、状态页、图表和交互式检测视图。
|
||||
|
||||
替代方案:使用 Bun 原生 HTML imports。该方案更简单、依赖更少,但前端生态、插件、测试和组件体系弱于 Vite。
|
||||
|
||||
替代方案:使用 Next.js。该方案能力更强,但 SSR/路由/部署模型与“Bun 单 executable”目标存在额外摩擦。
|
||||
|
||||
### Decision: 开发期 Vite proxy `/api/*` 到 Bun 后端
|
||||
|
||||
浏览器开发入口默认使用 Vite dev server,前端请求统一使用相对路径 `/api/*`。Vite 负责把这些请求代理到 Bun 后端服务,从而保持同源开发体验,避免 CORS 和硬编码后端地址。
|
||||
|
||||
替代方案:Bun 后端反向代理 Vite dev server。该方案可以让开发期也统一一个端口,但会增加胶水代码,并且容易干扰 Vite HMR 行为。
|
||||
|
||||
### Decision: 生产期由 Bun 服务 Vite `dist/`
|
||||
|
||||
生产构建先执行 Vite build,再让 Bun 后端服务 `index.html`、`assets/*` 和其他静态资源。非 API、非静态资源路径 fallback 到 `index.html`,用于支持 React SPA 路由刷新。
|
||||
|
||||
替代方案:前端独立部署到 CDN。该方案扩展性更好,但不满足当前“一个可执行程序包含前后端”的目标。
|
||||
|
||||
### Decision: 单 executable 是发布形态,不是代码耦合方式
|
||||
|
||||
前端和后端在源码层保持清晰边界,只通过 HTTP API 和共享类型协作。打包层负责将 Vite 产物嵌入 Bun executable。这样未来若需要 CDN、独立前端部署或多客户端复用 API,不需要重写后端业务代码。
|
||||
|
||||
替代方案:后端源码直接 import 前端源码或前端模块。该方案短期简单,但会模糊运行时边界,增加后续拆离成本。
|
||||
|
||||
### Decision: API 路径统一保留在 `/api/*`
|
||||
|
||||
所有业务 API 使用 `/api/*` 前缀,健康检查使用 `/health`。demo API 使用 `/api/demo`,返回前端可展示的 JSON 响应。生产期路由优先级为 API、健康检查、静态资源、SPA fallback。未命中的 `/api/*` 必须返回 JSON 404,不能 fallback 到前端页面。
|
||||
|
||||
替代方案:API 与页面路径混排。该方案不利于 Vite proxy、生产 fallback 和未来前端独立部署。
|
||||
|
||||
### Decision: 运行配置使用环境变量或 CLI 参数
|
||||
|
||||
host、port、日志级别等运行期配置不嵌入 executable,优先从 CLI 参数或环境变量读取。executable 内只包含只读程序代码和前端静态资源。
|
||||
|
||||
替代方案:构建期写死配置。该方案部署简单,但不同环境需要重新构建,且不利于发布同一个二进制。
|
||||
|
||||
### Decision: demo 是验收基线而不是业务功能
|
||||
|
||||
demo 只证明前端开发、后端 API、生产静态服务和 executable 打包链路能跑通。页面应展示来自 `/api/demo` 的后端响应,并在 README 中记录开发期访问方式和 executable 运行后的验证方式。
|
||||
|
||||
替代方案:只搭建空白 React 页面和空 API。该方案能证明结构存在,但不能证明前后端开发和打包后联通链路真实可用。
|
||||
|
||||
## Risks / Trade-offs
|
||||
|
||||
- [Risk] Bun standalone executable 与 Vite `dist/` 嵌入方式相对 Go `embed` 更年轻。→ Mitigation: 先实现最小静态资源嵌入和端到端构建测试,再扩展多平台构建。
|
||||
- [Risk] Vite hashed assets、SPA fallback 和 Bun 静态路由可能出现路径映射问题。→ Mitigation: 对 `/`, `/assets/*`, 前端路由刷新和 `/api/*` 404 编写测试或构建后验证。
|
||||
- [Risk] 依赖数量会明显增加。→ Mitigation: 初期只引入 Vite、React、React DOM 和必要类型,不引入 UI 组件库、状态管理或路由库,除非后续需求明确。
|
||||
- [Risk] 单 executable 会把前端资源大小计入二进制。→ Mitigation: 保留 Vite 产物压缩能力,后续可按需启用分离部署或 CDN。
|
||||
- [Risk] 开发期前端和后端是两个进程,启动命令更复杂。→ Mitigation: 提供 `dev:web`、`dev:server` 和 `dev` 聚合脚本,并在 README 中说明。
|
||||
|
||||
## Migration Plan
|
||||
|
||||
1. 保留当前最小入口语义,重构为新的 server 入口。
|
||||
2. 新增 web、server、shared 和 scripts 目录结构。
|
||||
3. 引入最小 Vite + React 依赖并配置开发代理。
|
||||
4. 实现 Bun API、健康检查和生产静态资源服务。
|
||||
5. 增加测试和构建验证。
|
||||
6. 更新 README 作为项目结构和命令的权威说明。
|
||||
|
||||
回滚策略:如果 Vite 集成阻塞,可以保留 Bun 后端结构,移除 web 目录和前端构建脚本,退回 Bun 原生 HTML imports 或后端-only 形态。
|
||||
|
||||
## Open Questions
|
||||
|
||||
- 前端是否需要路由库,例如 React Router,还是先保持单页面组件状态?
|
||||
- 是否需要 UI 组件库,例如 TDesign、shadcn/ui 或保持纯 CSS?
|
||||
- 生产 executable 首期目标平台是当前 macOS,还是同时需要 Linux x64/arm64?
|
||||
@@ -0,0 +1,33 @@
|
||||
## Why
|
||||
|
||||
当前项目只有 Bun + TypeScript 的最小入口,尚不能支撑完整的前后端服务开发。引入 Vite + React 开发体验,并保持 Bun 后端可打包为单个可执行程序,可以同时满足本地快速迭代、前后端同源集成和简单部署的目标。
|
||||
|
||||
## What Changes
|
||||
|
||||
- 新增基于 Vite + React + TypeScript 的前端应用开发能力,开发期保留 Vite HMR。
|
||||
- 新增 Bun 后端服务作为 API 与生产静态资源承载层,API 统一位于 `/api/*`。
|
||||
- 新增开发期前端代理后端 API 的同源调用约定,避免前端写死后端地址。
|
||||
- 新增生产期构建链路:先构建前端静态资源,再将后端与前端产物打包为单个 Bun standalone executable。
|
||||
- 新增可验收 demo:前端页面调用后端 API 并展示响应,开发期和 executable 运行期都能验证前后端联通。
|
||||
- 新增 SPA fallback 行为:生产环境非 API 前端路由返回前端入口页面。
|
||||
- 更新 README,记录项目结构、开发命令、测试命令、构建命令与部署方式。
|
||||
|
||||
## Capabilities
|
||||
|
||||
### New Capabilities
|
||||
- `frontend-development-workflow`: 约定 Vite + React 前端开发、API proxy、共享类型和本地开发命令。
|
||||
- `fullstack-app-runtime`: 约定 Bun 服务在运行期同时提供 API、健康检查、静态资源和 SPA fallback。
|
||||
- `single-executable-packaging`: 约定生产构建将 Vite 前端产物和 Bun 后端服务打包为单个可执行程序。
|
||||
|
||||
### Modified Capabilities
|
||||
|
||||
无。当前 `openspec/specs/` 为空,没有既有 capability 需要修改。
|
||||
|
||||
## Impact
|
||||
|
||||
- 代码结构将从单入口 `index.ts` 扩展为前端、后端、共享类型和构建脚本的模块化结构。
|
||||
- 依赖将新增 Vite、React、React DOM 及相关 TypeScript 类型;测试依赖按实现方案最小化引入。
|
||||
- 开发脚本将覆盖前端 dev server、后端 dev server、并行开发、测试和生产构建。
|
||||
- 生产产物将从直接运行 TypeScript 入口变为 `dist/` 下的平台相关 executable。
|
||||
- demo 验收将覆盖开发期联调和生产 executable 运行后的前端页面、API 与健康检查。
|
||||
- README 需要同步说明模块结构、API 路径约定、构建产物和运行参数。
|
||||
@@ -0,0 +1,63 @@
|
||||
## ADDED Requirements
|
||||
|
||||
### Requirement: Vite React 开发服务器
|
||||
系统 SHALL 提供基于 Vite + React + TypeScript 的前端开发工作流,并支持热模块替换。
|
||||
|
||||
#### Scenario: 启动前端开发服务器
|
||||
- **WHEN** 开发者启动前端开发命令
|
||||
- **THEN** 前端 SHALL 由 Vite 提供服务,并启用 React 热模块替换
|
||||
|
||||
#### Scenario: 构建前端静态资源
|
||||
- **WHEN** 开发者运行前端生产构建命令
|
||||
- **THEN** 系统 SHALL 产出可由 Bun 后端服务的前端静态资源
|
||||
|
||||
### Requirement: 前端开发期 API 代理
|
||||
前端开发服务器 SHALL 在本地开发期间将 `/api/*` 请求代理到 Bun 后端服务。
|
||||
|
||||
#### Scenario: 前端开发期调用 API
|
||||
- **WHEN** 浏览器从 Vite 开发源请求 `/api/demo`
|
||||
- **THEN** Vite SHALL 将请求转发到 Bun 后端服务,且不需要浏览器 CORS 配置
|
||||
|
||||
#### Scenario: 开发期访问非 API 前端路由
|
||||
- **WHEN** 浏览器从 Vite 开发源请求非 API 前端路由
|
||||
- **THEN** Vite SHALL 将该请求作为前端应用流量处理,而不是转发到后端
|
||||
|
||||
### Requirement: 前端使用相对 API 路径
|
||||
除非有文档化的部署配置覆盖该行为,前端代码 MUST 通过相对 `/api/*` URL 调用后端 API。
|
||||
|
||||
#### Scenario: 前端获取后端数据
|
||||
- **WHEN** 前端代码调用后端 API
|
||||
- **THEN** 请求 URL 默认 MUST 使用相对 `/api/*` 路径
|
||||
|
||||
#### Scenario: 运行环境变化
|
||||
- **WHEN** host 或 port 在开发环境和生产环境之间变化
|
||||
- **THEN** 前端 API 调用 SHALL 无需修改源码即可继续工作
|
||||
|
||||
### Requirement: 端到端开发 demo
|
||||
项目 SHALL 提供一个可见的开发 demo,用于证明 React 前端可以通过 Vite 代理调用 Bun 后端。
|
||||
|
||||
#### Scenario: Demo 页面展示后端响应
|
||||
- **WHEN** 开发者启动文档化的开发命令并打开前端 URL
|
||||
- **THEN** 页面 SHALL 调用 `/api/demo` 并展示 Bun 后端返回的数据
|
||||
|
||||
#### Scenario: 开发期后端不可用
|
||||
- **WHEN** 前端 demo 无法访问 `/api/demo`
|
||||
- **THEN** 页面 SHALL 展示清晰的错误状态,而不是静默显示为成功
|
||||
|
||||
### Requirement: 集成开发命令
|
||||
项目 SHALL 提供一个文档化命令,用于在 demo 开发期间同时运行前端和后端。
|
||||
|
||||
#### Scenario: 启动全栈开发
|
||||
- **WHEN** 开发者运行文档化的全栈开发命令
|
||||
- **THEN** 系统 SHALL 启动 Vite 前端开发服务器和 `/api/demo` 所需的 Bun 后端服务器
|
||||
|
||||
### Requirement: 共享 TypeScript 契约
|
||||
项目 SHALL 为前端和后端共同使用的请求与响应类型提供共享 TypeScript 边界。
|
||||
|
||||
#### Scenario: 定义 API 响应结构
|
||||
- **WHEN** 前端和后端都需要某个 API 响应类型
|
||||
- **THEN** 该类型 SHALL 定义在 shared 模块中,而不是在两端重复定义
|
||||
|
||||
#### Scenario: 前端导入共享类型
|
||||
- **WHEN** 前端代码导入共享 API 类型
|
||||
- **THEN** 该导入 SHALL 不要求将后端运行时实现打包进前端
|
||||
@@ -0,0 +1,67 @@
|
||||
## ADDED Requirements
|
||||
|
||||
### Requirement: Bun HTTP 运行时
|
||||
系统 SHALL 运行一个 Bun HTTP server,由单个进程提供后端 API、健康检查、生产静态资源和 SPA fallback 行为。
|
||||
|
||||
#### Scenario: 启动运行时服务器
|
||||
- **WHEN** server 进程成功启动
|
||||
- **THEN** 它 SHALL 监听配置的 host 和 port,并记录实际 server URL
|
||||
|
||||
#### Scenario: 提供运行时配置
|
||||
- **WHEN** 通过支持的运行时配置提供 host 或 port
|
||||
- **THEN** server SHALL 使用该值,且不需要重新构建
|
||||
|
||||
### Requirement: API 路由命名空间
|
||||
系统 MUST 将 `/api/*` 保留给后端 API 路由。
|
||||
|
||||
#### Scenario: API 路由匹配
|
||||
- **WHEN** 请求匹配已注册的 `/api/*` 路由
|
||||
- **THEN** Bun server SHALL 返回 API handler 的响应
|
||||
|
||||
#### Scenario: API 路由未命中
|
||||
- **WHEN** 请求访问未注册的 `/api/*` 路由
|
||||
- **THEN** Bun server MUST 返回 JSON 404 响应,而不是前端 HTML 文档
|
||||
|
||||
### Requirement: Demo API 端点
|
||||
系统 SHALL 暴露 `/api/demo` 作为稳定 demo 端点,用于证明前后端集成可用。
|
||||
|
||||
#### Scenario: Demo API 成功响应
|
||||
- **WHEN** 客户端请求 `/api/demo`
|
||||
- **THEN** Bun server SHALL 返回包含可读 message 和 runtime metadata 的 JSON 响应
|
||||
|
||||
#### Scenario: Demo API 内容类型
|
||||
- **WHEN** 客户端请求 `/api/demo`
|
||||
- **THEN** Bun server SHALL 返回 JSON content type 的响应
|
||||
|
||||
### Requirement: 健康检查端点
|
||||
系统 SHALL 在前端 SPA fallback 之外暴露健康检查端点。
|
||||
|
||||
#### Scenario: 健康检查成功
|
||||
- **WHEN** 客户端请求 `/health`
|
||||
- **THEN** Bun server SHALL 返回成功的、机器可读的健康检查响应
|
||||
|
||||
### Requirement: 生产静态资源服务
|
||||
系统 SHALL 在生产模式下由 Bun runtime 服务 Vite 生产资源。
|
||||
|
||||
#### Scenario: 请求构建后的资源
|
||||
- **WHEN** 客户端请求构建后的前端资源,例如 `/assets/app.js`
|
||||
- **THEN** Bun server SHALL 返回该资源并带有适当的 content type
|
||||
|
||||
#### Scenario: 请求前端根路径
|
||||
- **WHEN** 客户端请求 `/`
|
||||
- **THEN** Bun server SHALL 返回前端入口 HTML 文档
|
||||
|
||||
#### Scenario: 生产 demo 页面调用 API
|
||||
- **WHEN** 客户端从生产 Bun runtime 打开前端页面
|
||||
- **THEN** demo 页面 SHALL 能够从同源调用 `/api/demo` 并展示后端响应
|
||||
|
||||
### Requirement: SPA fallback 行为
|
||||
系统 SHALL 在生产环境中为非 API、非静态资源的前端路由返回前端入口 HTML 文档。
|
||||
|
||||
#### Scenario: 刷新前端路由
|
||||
- **WHEN** 客户端请求前端路由,例如 `/dashboard`
|
||||
- **THEN** Bun server SHALL 返回前端入口 HTML 文档
|
||||
|
||||
#### Scenario: 保留 API 错误语义
|
||||
- **WHEN** 客户端请求未知的 `/api/*` 路由
|
||||
- **THEN** Bun server MUST NOT 返回前端入口 HTML 文档
|
||||
@@ -0,0 +1,49 @@
|
||||
## ADDED Requirements
|
||||
|
||||
### Requirement: 生产构建顺序
|
||||
生产构建 MUST 在编译 Bun 后端 executable 之前先构建 Vite 前端。
|
||||
|
||||
#### Scenario: 运行生产构建
|
||||
- **WHEN** 开发者运行生产构建命令
|
||||
- **THEN** 系统 MUST 在调用 Bun standalone executable 编译之前生成前端静态资源
|
||||
|
||||
#### Scenario: 前端构建失败
|
||||
- **WHEN** 前端生产构建失败
|
||||
- **THEN** 系统 MUST 停止生产构建,且不能输出 stale executable
|
||||
|
||||
### Requirement: 单 executable 输出
|
||||
生产构建 SHALL 输出一个 standalone executable,其中包含 Bun 后端、必要 server 依赖和构建后的前端资源。
|
||||
|
||||
#### Scenario: 在目标机器运行 executable
|
||||
- **WHEN** 生成的 executable 在兼容目标平台上运行
|
||||
- **THEN** 它 SHALL 启动全栈应用,且不要求目标机器安装 Node.js、Bun、Vite 或 `node_modules`
|
||||
|
||||
#### Scenario: 服务嵌入的前端
|
||||
- **WHEN** executable 收到前端根路径请求
|
||||
- **THEN** 它 SHALL 从 executable 内包含的资源服务前端,且不需要外部 `dist/` 目录
|
||||
|
||||
#### Scenario: 服务嵌入 demo API 和页面
|
||||
- **WHEN** 生成的 executable 启动,且浏览器打开前端根路径
|
||||
- **THEN** 页面 SHALL 展示同一个 executable 进程中 `/api/demo` 返回的数据
|
||||
|
||||
### Requirement: 外部运行时配置
|
||||
executable MUST 将环境相关运行时配置保留在嵌入的前端和 server bundle 之外。
|
||||
|
||||
#### Scenario: 修改监听端口
|
||||
- **WHEN** 操作者修改受支持的 port 配置
|
||||
- **THEN** 同一个 executable SHALL 在不重新构建的情况下监听新端口
|
||||
|
||||
#### Scenario: 缺少可选配置
|
||||
- **WHEN** 可选运行时配置被省略
|
||||
- **THEN** executable SHALL 使用文档化的默认值
|
||||
|
||||
### Requirement: 构建验证
|
||||
项目 SHALL 提供验证,证明生产 executable 可以服务 API、健康检查、静态资源和 SPA fallback 路由。
|
||||
|
||||
#### Scenario: 验证 executable 路由
|
||||
- **WHEN** 构建验证针对生成的 executable 运行
|
||||
- **THEN** 它 SHALL 检查 `/api/demo`、`/health`、前端根路径、静态资源和前端 fallback 请求
|
||||
|
||||
#### Scenario: 验证失败
|
||||
- **WHEN** 任一代表性生产路由检查失败
|
||||
- **THEN** 验证 SHALL 使构建或测试命令失败
|
||||
@@ -0,0 +1,41 @@
|
||||
## 1. 项目结构与依赖
|
||||
|
||||
- [x] 1.1 创建 `src/server`、`src/web`、`src/shared`、`scripts` 和测试目录结构
|
||||
- [x] 1.2 调整 `package.json` 脚本以覆盖前端开发、后端开发、并行开发、测试和生产构建
|
||||
- [x] 1.3 引入 Vite、React、React DOM 和必要 TypeScript 类型依赖
|
||||
- [x] 1.4 创建或更新 README 记录项目结构、开发规范和命令
|
||||
|
||||
## 2. 前端开发工作流
|
||||
|
||||
- [x] 2.1 创建 Vite + React + TypeScript 前端入口和基础页面
|
||||
- [x] 2.2 配置 Vite 开发服务器将 `/api/*` 代理到 Bun 后端
|
||||
- [x] 2.3 实现前端 demo 页面调用相对路径 `/api/demo` 并展示成功和失败状态
|
||||
- [x] 2.4 建立 `src/shared` 共享类型并确保前端不引入后端运行时实现
|
||||
- [x] 2.5 提供一个 documented fullstack dev command 同时启动 Vite 前端和 Bun 后端
|
||||
|
||||
## 3. Bun 后端运行时
|
||||
|
||||
- [x] 3.1 创建 Bun server 入口并支持 host 和 port 运行期配置
|
||||
- [x] 3.2 实现 `/health` 健康检查响应
|
||||
- [x] 3.3 实现 `/api/demo` JSON 路由并返回前端可展示的 message 和 runtime metadata
|
||||
- [x] 3.4 实现未命中 `/api/*` 路由返回 JSON 404 的行为
|
||||
- [x] 3.5 实现生产环境静态资源服务和 SPA fallback 行为
|
||||
|
||||
## 4. 单可执行程序构建
|
||||
|
||||
- [x] 4.1 创建生产构建脚本,确保先执行 Vite build 再执行 Bun compile
|
||||
- [x] 4.2 将 Vite `dist/` 产物嵌入 Bun executable,运行时不依赖外部 `dist/` 目录
|
||||
- [x] 4.3 配置 executable 输出路径和当前平台默认构建目标
|
||||
- [x] 4.4 确保 executable 运行不依赖本机 Node.js、Bun、Vite 或 `node_modules`
|
||||
|
||||
## 5. 测试与验证
|
||||
|
||||
- [x] 5.1 为 `/api/demo`、`/health`、API 404 和 SPA fallback 增加测试
|
||||
- [x] 5.2 为生产构建脚本增加失败中断或防止 stale executable 的验证
|
||||
- [x] 5.3 增加构建后 executable smoke test 覆盖 `/api/demo`、健康检查、静态资源、前端 fallback 和 demo 页面内容
|
||||
- [x] 5.4 运行完整测试和生产构建,确认所有任务满足 specs
|
||||
|
||||
## 6. 文档收尾
|
||||
|
||||
- [x] 6.1 更新 README 中的运行参数、构建产物、部署方式和已知限制
|
||||
- [x] 6.2 在 README 中记录前端可拆离原则、`/api/*` 路径约定和 demo 验证步骤
|
||||
@@ -0,0 +1,2 @@
|
||||
schema: spec-driven
|
||||
created: 2026-05-09
|
||||
@@ -0,0 +1,52 @@
|
||||
## Context
|
||||
|
||||
当前项目根目录存在三项冗余:
|
||||
|
||||
1. `index.ts` — 仅包含 `import "./src/server/dev.ts"`,与 `package.json` 的 `"start"` 脚本功能完全重复,无任何其他文件或脚本引用它
|
||||
2. `"module": "src/server/dev.ts"` — `private: true` 项目不会被发布,ESM 入口字段无消费者;且指向一个启动服务器的副作用文件本身就不合理
|
||||
3. `.build/` 目录 — 由 `scripts/build.ts` 在每次构建时生成,包含 `server-entry.ts` 和 `static-assets.ts` 两个中间文件。构建完成后这些文件残留在磁盘上,虽已被 `.gitignore` 忽略但不必要地占用空间
|
||||
|
||||
项目构建流程为:`vite build` → 生成 `.build/` 中间文件 → `Bun.build()` 编译为单可执行文件 → 输出到 `dist/gateway-checker`。
|
||||
|
||||
## Goals / Non-Goals
|
||||
|
||||
**Goals:**
|
||||
- 移除无实际作用的文件和配置,减少项目结构噪音
|
||||
- 构建成功后自动清理中间产物,保持项目目录整洁
|
||||
- 构建失败时保留中间产物以便排查问题
|
||||
|
||||
**Non-Goals:**
|
||||
- 不改变构建产物本身(输出路径、可执行文件行为不变)
|
||||
- 不引入新的构建步骤或依赖
|
||||
- 不调整 RuntimeMode 或开发/生产模式的区分逻辑
|
||||
|
||||
## Decisions
|
||||
|
||||
### Decision 1: 直接删除 `index.ts`
|
||||
|
||||
**选择**:删除文件
|
||||
**备选**:保留但添加注释说明用途
|
||||
**理由**:无任何脚本、文件或构建流程引用它,保留只会增加困惑。`package.json` 的 `"start"` 脚本已直接指向 `src/server/dev.ts`。
|
||||
|
||||
### Decision 2: 移除 `"module"` 字段
|
||||
|
||||
**选择**:从 `package.json` 中删除 `"module"` 字段
|
||||
**备选**:改为 `"main"` 字段
|
||||
**理由**:`private: true` 意味着不会被 npm 发布,任何入口字段都没有消费者。改为 `"main"` 同样无意义,因为这不是一个库。完全移除最简洁。
|
||||
|
||||
### Decision 3: 构建成功后清理 `.build/`
|
||||
|
||||
**选择**:在 `Bun.build()` 成功后调用 `await rm(buildDir, { recursive: true, force: true })`
|
||||
**备选**:
|
||||
- 始终保留 `.build/`(当前行为)
|
||||
- 使用临时目录(`os.tmpdir()`)
|
||||
|
||||
**理由**:`build.ts` 开头已导入 `rm` 且已在构建开始时执行清理,只需在成功路径末尾复用同一行代码。构建失败时 `.build/` 自然保留,兼顾排查需求。不使用临时目录是因为 `Bun.build()` 的 `import ... with { type: "file" }` 需要相对路径引用 `dist/web/` 下的实际文件,临时目录增加路径复杂度。
|
||||
|
||||
具体修改位置:`scripts/build.ts` 第 53 行 `console.log(...)` 之后。
|
||||
|
||||
## Risks / Trade-offs
|
||||
|
||||
- **[极低风险] 删除 `index.ts` 影响外部工具**:如果 CI/CD 或其他自动化流程通过 `bun index.ts` 启动,会中断。→ 检查确认无此类用法。`package.json` 中 `"start": "bun src/server/dev.ts"` 是标准入口。
|
||||
- **[极低风险] 移除 `module` 字段影响 IDE 解析**:某些 IDE 可能依赖 `module` 字段进行跳转。→ 实际指向 `dev.ts`,对代码导航无帮助。
|
||||
- **[低风险] 清理 `.build/` 后无法排查偶现构建问题**:→ 构建失败时 `.build/` 保留,只有成功时才清理,排查路径完整。
|
||||
@@ -0,0 +1,25 @@
|
||||
## Why
|
||||
|
||||
项目根目录存在冗余文件和无效配置:`index.ts` 与 `start` 脚本功能完全重复,`package.json` 的 `module` 字段在 `private` 项目中无实际作用,`.build/` 中间产物在构建成功后未清理导致磁盘残留。这些虽不影响运行,但增加了维护负担和项目结构的困惑。
|
||||
|
||||
## What Changes
|
||||
|
||||
- 删除根目录 `index.ts`,它是 `src/server/dev.ts` 的无意义包装,无任何脚本或文件引用它
|
||||
- 移除 `package.json` 中的 `"module": "src/server/dev.ts"` 字段,`private: true` 的应用项目不需要此字段,且指向副作用文件作为 ESM 入口本身就不合理
|
||||
- 在 `scripts/build.ts` 中,`Bun.build()` 成功后自动清理 `.build/` 目录,构建失败时保留以便排查
|
||||
|
||||
## Capabilities
|
||||
|
||||
### New Capabilities
|
||||
|
||||
(无新增能力)
|
||||
|
||||
### Modified Capabilities
|
||||
|
||||
- `single-executable-packaging`: 构建流程新增成功后清理 `.build/` 中间产物目录的步骤
|
||||
|
||||
## Impact
|
||||
|
||||
- 删除文件:`index.ts`
|
||||
- 修改文件:`package.json`(移除 1 行)、`scripts/build.ts`(新增 1 行)
|
||||
- 不影响任何现有功能、API 或开发工作流
|
||||
@@ -0,0 +1,24 @@
|
||||
## MODIFIED Requirements
|
||||
|
||||
### Requirement: 单 executable 输出
|
||||
生产构建 SHALL 输出一个 standalone executable,其中包含 Bun 后端、必要 server 依赖和构建后的前端资源。构建成功后 SHALL 自动清理中间产物目录(`.build/`),构建失败时 SHALL 保留中间产物以便排查。
|
||||
|
||||
#### Scenario: 在目标机器运行 executable
|
||||
- **WHEN** 生成的 executable 在兼容目标平台上运行
|
||||
- **THEN** 它 SHALL 启动全栈应用,且不要求目标机器安装 Node.js、Bun、Vite 或 `node_modules`
|
||||
|
||||
#### Scenario: 服务嵌入的前端
|
||||
- **WHEN** executable 收到前端根路径请求
|
||||
- **THEN** 它 SHALL 从 executable 内包含的资源服务前端,且不需要外部 `dist/` 目录
|
||||
|
||||
#### Scenario: 服务嵌入 demo API 和页面
|
||||
- **WHEN** 生成的 executable 启动,且浏览器打开前端根路径
|
||||
- **THEN** 页面 SHALL 展示同一个 executable 进程中 `/api/demo` 返回的数据
|
||||
|
||||
#### Scenario: 构建成功后清理中间产物
|
||||
- **WHEN** 生产构建成功完成并输出 executable
|
||||
- **THEN** 系统 SHALL 自动删除 `.build/` 目录及其所有内容
|
||||
|
||||
#### Scenario: 构建失败时保留中间产物
|
||||
- **WHEN** 生产构建在任意步骤失败(前端构建、中间产物生成、Bun 编译)
|
||||
- **THEN** `.build/` 目录 SHALL 保留在磁盘上以供排查
|
||||
@@ -0,0 +1,13 @@
|
||||
## 1. 移除冗余文件和配置
|
||||
|
||||
- [x] 1.1 删除根目录 `index.ts` 文件
|
||||
- [x] 1.2 从 `package.json` 中移除 `"module": "src/server/dev.ts"` 字段
|
||||
|
||||
## 2. 构建后清理中间产物
|
||||
|
||||
- [x] 2.1 在 `scripts/build.ts` 的 `Bun.build()` 成功后添加清理 `.build/` 目录的代码
|
||||
|
||||
## 3. 验证
|
||||
|
||||
- [x] 3.1 运行 `bun run check` 确认类型检查、lint、格式化、测试全部通过
|
||||
- [x] 3.2 运行 `bun run verify` 确认完整构建和 smoke test 通过
|
||||
@@ -0,0 +1,2 @@
|
||||
schema: spec-driven
|
||||
created: 2026-05-09
|
||||
@@ -0,0 +1,90 @@
|
||||
## Context
|
||||
|
||||
当前项目是 Bun + TypeScript 的前后端一体化 demo:开发期由 Vite React 提供前端 HMR,Bun 提供 `/api/*` 和 `/health`;生产期先构建 Vite 静态资源,再通过 Bun file import 将资源和后端编译为单 executable。
|
||||
|
||||
现有能力已经通过 `typecheck`、单元测试和 executable smoke test 验证,但真实业务开发尚未开始。此变更聚焦平台基础设施硬化,目标是在业务 API、数据模型和前端业务页面扩展之前,先把开发联调、代码质量、格式一致性、HTTP 契约和生产验证闭环固化下来。
|
||||
|
||||
## Goals / Non-Goals
|
||||
|
||||
**Goals:**
|
||||
|
||||
- 引入 ESLint 审查代码质量、React Hooks 规则和前后端边界。
|
||||
- 引入 Prettier 统一代码风格,但不格式化 `openspec/`,避免影响 OpenSpec 文档和 tasks 一行一个任务的规则。
|
||||
- 提供快速 `check` 和完整 `verify` 两层验证命令。
|
||||
- 让开发期 Vite proxy 目标端口和 Bun server 监听端口保持一致。
|
||||
- 补齐 HTTP method、JSON 404/405、静态资源缓存和低风险安全头的运行时契约。
|
||||
- 增强生产 executable smoke test,确保验证的是当前源码构建出的生产产物。
|
||||
- 同步 README,使文档描述与脚本、构建中间产物和验证流程一致。
|
||||
|
||||
**Non-Goals:**
|
||||
|
||||
- 不开发 gateway checker 真实业务能力。
|
||||
- 不引入数据库、持久化、认证、React Router 或 UI 组件库。
|
||||
- 不新增 CI 配置;本次仅提供本地 `check` 和 `verify` 命令,CI 接入留给后续仓库托管策略。
|
||||
- 不引入 CSP;本次只加入低风险安全响应头,避免提前约束未来前端资源策略。
|
||||
- 不做大规模目录重构或业务框架抽象。
|
||||
|
||||
## Decisions
|
||||
|
||||
### ESLint 和 Prettier 分工
|
||||
|
||||
ESLint 只承担质量审查和边界约束,不承担缩进、换行、引号等格式职责。Prettier 专门负责代码风格,避免 ESLint stylistic 规则和格式化器重复工作。
|
||||
|
||||
备选方案是只引入 ESLint 并启用 stylistic 规则,但后续维护成本更高,且容易和编辑器格式化行为冲突。另一个备选方案是只引入 Prettier,但它无法检查 React Hooks、未处理 Promise 或前端误导入后端实现等质量问题。
|
||||
|
||||
本次采用的最小依赖集合为 `eslint`、`@eslint/js`、`typescript-eslint`、`eslint-plugin-react-hooks`、`eslint-plugin-react-refresh` 和 `prettier`。暂不引入 `eslint-config-prettier`,除非实现阶段引入会与 Prettier 冲突的 ESLint preset 或 stylistic 规则。
|
||||
|
||||
### 验证命令分层
|
||||
|
||||
新增 `check` 和 `verify` 两层命令:
|
||||
|
||||
```text
|
||||
check
|
||||
├─ typecheck
|
||||
├─ lint
|
||||
├─ format:check
|
||||
└─ test
|
||||
|
||||
verify
|
||||
├─ check
|
||||
├─ build
|
||||
└─ test:smoke
|
||||
```
|
||||
|
||||
`check` 面向日常开发,反馈快;`verify` 面向提交前或发布前验证,包含生产构建和 executable smoke test。备选方案是只提供 `verify`,但每次都构建 executable 会降低日常迭代速度。
|
||||
|
||||
### Prettier 忽略范围
|
||||
|
||||
Prettier SHALL 忽略 `openspec/`、`dist/`、`.build/`、`node_modules/`、`bun.lock` 和临时构建产物。`openspec/` 排除是显式决策,因为 OpenSpec tasks 要求一行一个任务,Markdown 自动折行可能破坏审阅体验和规则遵循。
|
||||
|
||||
### 开发期端口配置
|
||||
|
||||
文档化的全栈开发命令以 `PORT` 作为后端端口的唯一对外配置。Vite proxy 使用的 `BACKEND_PORT` 应由开发脚本从 `PORT` 派生,或者明确作为内部变量,避免用户只改 `BACKEND_PORT` 导致 proxy 与 server 分叉。直接运行 Bun server 或生产 executable 时仍可继续使用现有 CLI 参数覆盖 host 和 port。
|
||||
|
||||
### 运行配置校验
|
||||
|
||||
运行配置继续保持 CLI 参数优先于环境变量,缺省时使用 README 文档化默认值。端口配置必须拒绝非整数、小于 0 或大于 65535 的值,并通过单元测试覆盖默认值、优先级、非法输入和边界值,避免开发期和生产期配置行为分叉。
|
||||
|
||||
### HTTP method 和错误契约
|
||||
|
||||
现有 demo 端点按路径匹配,后续业务扩展前需要先固化 method 语义。`/health` 和 `/api/demo` 以 `GET` 为主,并支持 `HEAD` 返回相同状态和 headers 但无响应体;不支持的 method 返回 JSON 405,并带 `Allow` header。未知 `/api/*` 继续返回 JSON 404,不能落入前端 HTML fallback。
|
||||
|
||||
### 生产响应头策略
|
||||
|
||||
生产 HTML 使用 `Cache-Control: no-cache`,Vite hash 静态资源使用长缓存 `public, max-age=31536000, immutable`。所有生产 HTTP 响应增加低风险安全头,例如 `X-Content-Type-Options: nosniff` 和 `Referrer-Policy`。CSP 暂不纳入本次变更,避免后续业务页面接入外部资源时产生过早约束。
|
||||
|
||||
### 构建确定性
|
||||
|
||||
生成 `.build/static-assets.ts` 时,嵌入资源列表应按稳定顺序输出。这样可以减少重复构建时的无意义差异,也方便 smoke test 和后续审查定位问题。
|
||||
|
||||
### Smoke test 增强
|
||||
|
||||
`test:smoke` SHALL 针对当前构建出的 executable 验证生产行为,包括 `/health`、`/api/demo`、未知 API、根 HTML、SPA fallback、静态资源、未知静态资源、生产 runtime mode、缓存头和低风险安全头。`verify` 必须先执行 build 再 smoke,避免验证旧产物。
|
||||
|
||||
## Risks / Trade-offs
|
||||
|
||||
- 新增 ESLint 和 Prettier 会增加开发依赖与初次配置成本 → 采用最小依赖集合,只启用与当前项目直接相关的规则。
|
||||
- 现有代码可能被 Prettier 产生格式化改动 → 本次作为平台硬化变更集中处理,后续业务变更减少格式噪音。
|
||||
- 405 和 HEAD 行为会让 HTTP handler 稍复杂 → 在业务 API 扩展前处理,避免未来每个端点重复补语义。
|
||||
- 安全头不包含 CSP,安全强度有限 → 先采用低风险头,CSP 在前端资源来源稳定后单独设计。
|
||||
- `verify` 包含构建和 smoke,运行更慢 → 保留快速 `check` 作为日常反馈通道。
|
||||
@@ -0,0 +1,35 @@
|
||||
## Why
|
||||
|
||||
当前项目已经具备 Bun 后端、Vite React 前端、生产静态资源嵌入和单 executable 打包链路,但仍处于 demo 基础设施阶段。真实业务开发开始前,需要先收紧前后端开发、运行时 HTTP 契约、代码质量门禁和生产验证闭环,避免后续业务变更建立在不稳定或不可重复验证的基础上。
|
||||
|
||||
## What Changes
|
||||
|
||||
- 增加 ESLint 作为代码质量、React Hooks 和前后端边界审查工具。
|
||||
- 增加 Prettier 作为代码风格格式化工具,并排除 `openspec/`、构建产物和依赖目录。
|
||||
- 增加快速 `check` 命令和完整 `verify` 命令,其中 `verify` SHALL 覆盖类型检查、lint、格式检查、单元测试、生产构建和 executable smoke test。
|
||||
- 明确开发期 Bun server 与 Vite proxy 的端口配置一致性,避免前端代理端口和后端监听端口分叉。
|
||||
- 补充运行配置校验要求,包括默认值、CLI 与环境变量优先级、无效端口拒绝和端口边界行为。
|
||||
- 强化 HTTP 运行时契约,包括 method 语义、JSON 404/405 错误、静态资源缓存策略和低风险安全响应头。
|
||||
- 强化单 executable 构建验证,包括确定性资源生成、生产模式验证、静态资源响应头、未知 API、未知 asset 和 SPA fallback 检查。
|
||||
- 修正 OpenSpec `tasks` artifact 规则键名,避免 CLI 状态命令产生无效规则警告。
|
||||
- 同步更新 README,说明质量门禁、验证命令、构建中间产物和运行配置边界。
|
||||
|
||||
## Capabilities
|
||||
|
||||
### New Capabilities
|
||||
- `code-quality-gates`: 定义 ESLint、Prettier、`check` 和 `verify` 的质量门禁行为要求。
|
||||
|
||||
### Modified Capabilities
|
||||
- `fullstack-app-runtime`: 补充运行配置校验、HTTP method、JSON 错误、静态资源缓存和低风险安全响应头等运行时契约。
|
||||
- `frontend-development-workflow`: 补充开发期 Bun server 与 Vite proxy 配置一致性的要求。
|
||||
- `single-executable-packaging`: 补充确定性构建、完整验证命令和 smoke 覆盖增强要求。
|
||||
|
||||
## Impact
|
||||
|
||||
- 影响 `package.json` scripts 和开发依赖,新增 lint、format、check、verify 相关命令。
|
||||
- 影响 ESLint、Prettier 配置文件和忽略规则。
|
||||
- 影响 `src/server/*` 的 HTTP method、错误响应、静态资源响应头和配置处理。
|
||||
- 影响 `scripts/build.ts`、`scripts/dev.ts`、`scripts/smoke.ts` 的构建、开发联调和验证逻辑。
|
||||
- 影响 `tests/`,需要补充配置解析、HTTP 语义、静态资源响应和验证行为相关测试。
|
||||
- 影响 `openspec/config.yaml`,修正 `tasks` artifact 规则键名。
|
||||
- 影响 `README.md`,需要同步开发命令、验证命令、构建流程和边界说明。
|
||||
@@ -0,0 +1,53 @@
|
||||
## ADDED Requirements
|
||||
|
||||
### Requirement: ESLint 代码质量门禁
|
||||
项目 SHALL 提供 ESLint 代码质量门禁,用于审查 TypeScript、React 前端、脚本和测试代码中的质量问题。
|
||||
|
||||
#### Scenario: 运行 lint 检查
|
||||
- **WHEN** 开发者运行文档化的 lint 命令
|
||||
- **THEN** 系统 SHALL 使用 ESLint 检查项目源码、脚本和测试代码,并在发现违规时以非零状态退出
|
||||
|
||||
#### Scenario: 检查 React Hooks 规则
|
||||
- **WHEN** 前端 React 代码违反 Hooks 调用规则
|
||||
- **THEN** lint 命令 MUST 失败并报告对应违规
|
||||
|
||||
#### Scenario: 保护前后端边界
|
||||
- **WHEN** `src/web` 前端代码导入 `src/server` 后端运行时实现
|
||||
- **THEN** lint 命令 MUST 失败并报告前后端边界违规
|
||||
|
||||
### Requirement: Prettier 代码格式门禁
|
||||
项目 SHALL 提供 Prettier 格式化和格式检查命令,用于统一代码风格。
|
||||
|
||||
#### Scenario: 检查代码格式
|
||||
- **WHEN** 开发者运行文档化的格式检查命令
|
||||
- **THEN** 系统 SHALL 使用 Prettier 检查受管理文件,并在发现未格式化文件时以非零状态退出
|
||||
|
||||
#### Scenario: 自动格式化代码
|
||||
- **WHEN** 开发者运行文档化的格式化命令
|
||||
- **THEN** 系统 SHALL 使用 Prettier 重写受管理文件的格式
|
||||
|
||||
#### Scenario: 排除 OpenSpec 文档和生成产物
|
||||
- **WHEN** Prettier 格式化或格式检查运行
|
||||
- **THEN** 系统 MUST 排除 `openspec/`、`dist/`、`.build/`、`node_modules/`、`bun.lock` 和临时构建产物
|
||||
|
||||
### Requirement: 快速检查命令
|
||||
项目 SHALL 提供快速 `check` 命令,用于日常开发期间验证代码质量和基础行为。
|
||||
|
||||
#### Scenario: 运行快速检查
|
||||
- **WHEN** 开发者运行 `bun run check`
|
||||
- **THEN** 系统 SHALL 依次执行类型检查、lint、格式检查和单元测试
|
||||
|
||||
#### Scenario: 快速检查失败
|
||||
- **WHEN** `check` 中任一子检查失败
|
||||
- **THEN** `check` MUST 以非零状态退出且不静默忽略失败
|
||||
|
||||
### Requirement: 完整验证命令
|
||||
项目 SHALL 提供完整 `verify` 命令,用于提交前或发布前验证当前源码、测试和生产 executable 行为。
|
||||
|
||||
#### Scenario: 运行完整验证
|
||||
- **WHEN** 开发者运行 `bun run verify`
|
||||
- **THEN** 系统 SHALL 先运行 `check`,再运行生产构建和 executable smoke test
|
||||
|
||||
#### Scenario: 完整验证失败
|
||||
- **WHEN** `verify` 中任一阶段失败
|
||||
- **THEN** `verify` MUST 以非零状态退出且不能继续声明验证成功
|
||||
@@ -0,0 +1,23 @@
|
||||
## ADDED Requirements
|
||||
|
||||
### Requirement: 开发期后端端口一致性
|
||||
项目 SHALL 保证文档化的全栈开发命令中,Vite proxy 目标端口与 Bun 后端监听端口来自同一配置来源。
|
||||
|
||||
#### Scenario: 使用默认开发端口
|
||||
- **WHEN** 开发者未提供端口覆盖并运行文档化的全栈开发命令
|
||||
- **THEN** Bun 后端 SHALL 监听默认端口,且 Vite SHALL 将 `/api/*` 代理到同一端口
|
||||
|
||||
#### Scenario: 使用 PORT 覆盖开发端口
|
||||
- **WHEN** 开发者通过 `PORT` 覆盖后端端口并运行文档化的全栈开发命令
|
||||
- **THEN** Bun 后端 SHALL 监听该端口,且 Vite SHALL 将 `/api/*` 代理到同一端口
|
||||
|
||||
#### Scenario: 避免代理端口与后端端口分叉
|
||||
- **WHEN** 开发期脚本需要向 Vite 传递后端端口
|
||||
- **THEN** 该代理端口 MUST 从文档化的后端端口配置派生,而不是作为独立对外配置导致分叉
|
||||
|
||||
### Requirement: 开发质量命令文档化
|
||||
项目 SHALL 在前端开发工作流文档中说明日常检查和完整验证命令。
|
||||
|
||||
#### Scenario: 查阅开发命令
|
||||
- **WHEN** 开发者阅读 README 的开发或测试章节
|
||||
- **THEN** 文档 SHALL 说明 `check` 用于日常开发检查,`verify` 用于提交前或发布前完整验证
|
||||
@@ -0,0 +1,76 @@
|
||||
## ADDED Requirements
|
||||
|
||||
### Requirement: HTTP method 语义
|
||||
系统 SHALL 为运行时端点提供明确的 HTTP method 语义,避免不支持的 method 被错误地当作成功请求处理。
|
||||
|
||||
#### Scenario: GET 请求访问运行时端点
|
||||
- **WHEN** 客户端使用 `GET` 请求 `/health` 或 `/api/demo`
|
||||
- **THEN** Bun server SHALL 返回对应端点的成功响应
|
||||
|
||||
#### Scenario: HEAD 请求访问运行时端点
|
||||
- **WHEN** 客户端使用 `HEAD` 请求 `/health` 或 `/api/demo`
|
||||
- **THEN** Bun server SHALL 返回与 `GET` 相同的成功状态和 headers,但 MUST NOT 返回响应体
|
||||
|
||||
#### Scenario: 不支持的 method 访问运行时端点
|
||||
- **WHEN** 客户端使用不支持的 method 请求 `/health` 或 `/api/demo`
|
||||
- **THEN** Bun server MUST 返回 JSON 405 响应,并带有描述允许 method 的 `Allow` header
|
||||
|
||||
### Requirement: 运行配置校验
|
||||
系统 SHALL 对运行时 host 和 port 配置提供稳定、可测试的解析与校验行为。
|
||||
|
||||
#### Scenario: 使用默认运行配置
|
||||
- **WHEN** 未提供 host 或 port 覆盖
|
||||
- **THEN** server SHALL 使用 README 文档化的默认 host 和 port
|
||||
|
||||
#### Scenario: CLI 参数优先于环境变量
|
||||
- **WHEN** CLI 参数和环境变量同时提供同一项运行配置
|
||||
- **THEN** server SHALL 使用 CLI 参数中的值
|
||||
|
||||
#### Scenario: 拒绝无效端口
|
||||
- **WHEN** port 配置不是整数、小于 0 或大于 65535
|
||||
- **THEN** server MUST 拒绝启动并报告无效端口
|
||||
|
||||
#### Scenario: 接受端口边界值
|
||||
- **WHEN** port 配置为 0 或 65535
|
||||
- **THEN** server SHALL 将其作为有效端口配置处理
|
||||
|
||||
### Requirement: API 错误响应一致性
|
||||
系统 SHALL 为 API 命名空间内的错误返回机器可读 JSON 响应。
|
||||
|
||||
#### Scenario: 未知 API 路由
|
||||
- **WHEN** 客户端请求未知的 `/api/*` 路由
|
||||
- **THEN** Bun server MUST 返回包含 `error` 和 `status` 字段的 JSON 404 响应,而不是前端 HTML 文档
|
||||
|
||||
#### Scenario: API method 不允许
|
||||
- **WHEN** 客户端使用不支持的 method 请求已存在的 API 路由
|
||||
- **THEN** Bun server MUST 返回包含 `error` 和 `status` 字段的 JSON 405 响应
|
||||
|
||||
### Requirement: 生产缓存策略
|
||||
系统 SHALL 为生产静态资源和前端入口 HTML 使用明确的缓存策略。
|
||||
|
||||
#### Scenario: 请求前端入口 HTML
|
||||
- **WHEN** 生产 Bun server 返回前端入口 HTML 文档
|
||||
- **THEN** 响应 SHALL 使用 `Cache-Control: no-cache`
|
||||
|
||||
#### Scenario: 请求构建后的静态资源
|
||||
- **WHEN** 生产 Bun server 返回 Vite 构建后的静态资源
|
||||
- **THEN** 响应 SHALL 使用长缓存策略 `public, max-age=31536000, immutable`
|
||||
|
||||
#### Scenario: 请求未知静态资源
|
||||
- **WHEN** 客户端请求不存在的 `/assets/*` 资源或带文件扩展名的未知路径
|
||||
- **THEN** Bun server MUST 返回 404,且 MUST NOT 返回前端入口 HTML 文档
|
||||
|
||||
### Requirement: 低风险安全响应头
|
||||
系统 SHALL 在生产运行时响应中附加低风险安全响应头,提升基础安全性且不提前约束未来前端资源策略。
|
||||
|
||||
#### Scenario: 生产 HTML 响应包含安全头
|
||||
- **WHEN** 生产 Bun server 返回前端 HTML 文档
|
||||
- **THEN** 响应 SHALL 包含 `X-Content-Type-Options: nosniff` 和 `Referrer-Policy` headers
|
||||
|
||||
#### Scenario: 生产 JSON 响应包含安全头
|
||||
- **WHEN** 生产 Bun server 返回 `/health` 或 `/api/*` JSON 响应
|
||||
- **THEN** 响应 SHALL 包含 `X-Content-Type-Options: nosniff` 和 `Referrer-Policy` headers
|
||||
|
||||
#### Scenario: 生产静态资源响应包含安全头
|
||||
- **WHEN** 生产 Bun server 返回 Vite 构建后的静态资源
|
||||
- **THEN** 响应 SHALL 包含 `X-Content-Type-Options: nosniff` 和 `Referrer-Policy` headers
|
||||
@@ -0,0 +1,33 @@
|
||||
## ADDED Requirements
|
||||
|
||||
### Requirement: 构建生成确定性
|
||||
生产构建 SHALL 以稳定顺序生成嵌入静态资源清单,减少重复构建产生无意义差异。
|
||||
|
||||
#### Scenario: 生成静态资源清单
|
||||
- **WHEN** 生产构建扫描 Vite 输出目录并生成嵌入资源模块
|
||||
- **THEN** 资源条目 SHALL 按稳定顺序输出
|
||||
|
||||
#### Scenario: 重复构建相同前端产物
|
||||
- **WHEN** Vite 输出内容未变化且生产构建重复运行
|
||||
- **THEN** 生成的嵌入资源模块 SHALL 保持语义一致且不依赖文件系统遍历顺序
|
||||
|
||||
## MODIFIED Requirements
|
||||
|
||||
### Requirement: 构建验证
|
||||
项目 SHALL 提供验证,证明生产 executable 可以服务 API、健康检查、静态资源和 SPA fallback 路由,并且完整验证 MUST 针对当前源码重新构建后的 executable 运行。
|
||||
|
||||
#### Scenario: 验证 executable 路由
|
||||
- **WHEN** 构建验证针对生成的 executable 运行
|
||||
- **THEN** 它 SHALL 检查 `/api/demo`、`/health`、前端根路径、静态资源、未知 API、未知静态资源和前端 fallback 请求
|
||||
|
||||
#### Scenario: 验证生产模式和响应头
|
||||
- **WHEN** 构建验证针对生成的 executable 运行
|
||||
- **THEN** 它 SHALL 检查 demo 响应处于 production runtime mode,并验证代表性 HTML、JSON 和静态资源响应的缓存或低风险安全 headers
|
||||
|
||||
#### Scenario: 完整验证重新构建 executable
|
||||
- **WHEN** 开发者运行完整验证命令
|
||||
- **THEN** 系统 MUST 先基于当前源码执行生产构建,再对新生成的 executable 运行 smoke test
|
||||
|
||||
#### Scenario: 验证失败
|
||||
- **WHEN** 任一代表性生产路由、响应头、生产模式或构建阶段检查失败
|
||||
- **THEN** 验证 SHALL 使构建或测试命令失败
|
||||
@@ -0,0 +1,34 @@
|
||||
## 1. 质量门禁配置
|
||||
|
||||
- [x] 1.1 添加 `eslint`、`@eslint/js`、`typescript-eslint`、`eslint-plugin-react-hooks`、`eslint-plugin-react-refresh` 和 `prettier` 开发依赖并更新 lockfile
|
||||
- [x] 1.2 在 `package.json` 新增 `lint`、`format`、`format:check`、`check`、`verify` 脚本
|
||||
- [x] 1.3 配置 ESLint 检查 TypeScript、React、脚本和测试代码,并启用 React Hooks 规则
|
||||
- [x] 1.4 配置 ESLint 禁止 `src/web` 导入 `src/server` 后端运行时实现
|
||||
- [x] 1.5 配置 Prettier 和忽略规则,确保排除 `openspec/`、`dist/`、`.build/`、`node_modules/`、`bun.lock` 和临时构建产物
|
||||
|
||||
## 2. 开发期配置一致性
|
||||
|
||||
- [x] 2.1 调整全栈开发脚本,使 Vite proxy 端口从文档化的后端端口配置派生
|
||||
- [x] 2.2 调整或确认运行配置校验,覆盖默认值、CLI 优先级、无效端口和端口边界行为
|
||||
- [x] 2.3 补充运行配置测试,覆盖默认端口、`PORT` 覆盖、CLI 优先级、无效端口和端口边界
|
||||
|
||||
## 3. HTTP 运行时契约
|
||||
|
||||
- [x] 3.1 为 `/health` 和 `/api/demo` 实现 `GET` 与 `HEAD` 语义,并对不支持 method 返回 JSON 405 和 `Allow` header
|
||||
- [x] 3.2 统一 API 404 和 405 错误响应结构,确保包含 `error` 和 `status` 字段
|
||||
- [x] 3.3 为生产 HTML、JSON 和静态资源响应添加低风险安全 headers
|
||||
- [x] 3.4 明确生产 HTML、静态资源和未知静态资源的缓存与 404 行为
|
||||
- [x] 3.5 补充 HTTP handler 单元测试,覆盖 method、HEAD、JSON 错误、缓存 headers、安全 headers 和未知静态资源
|
||||
|
||||
## 4. 构建与 Smoke 验证
|
||||
|
||||
- [x] 4.1 调整生产构建脚本,按稳定顺序生成嵌入静态资源清单
|
||||
- [x] 4.2 增强 executable smoke test,验证 production runtime mode、未知 API、未知静态资源、SPA fallback、缓存 headers 和低风险安全 headers
|
||||
- [x] 4.3 确保 `verify` 先运行 `check`,再基于当前源码执行生产构建和 smoke test
|
||||
|
||||
## 5. 文档与最终验证
|
||||
|
||||
- [x] 5.1 更新 README,说明 `check`、`verify`、lint、format、构建中间产物、运行配置和验证边界
|
||||
- [x] 5.2 运行 `bun run check` 并修复发现的问题
|
||||
- [x] 5.3 运行 `bun run verify` 并修复发现的问题
|
||||
- [x] 5.4 修正 `openspec/config.yaml` 中 `tasks` artifact 规则键名并确认 OpenSpec CLI 不再告警
|
||||
@@ -0,0 +1,2 @@
|
||||
schema: spec-driven
|
||||
created: 2026-05-09
|
||||
130
openspec/changes/archive/2026-05-09-http-probe-checker/design.md
Normal file
130
openspec/changes/archive/2026-05-09-http-probe-checker/design.md
Normal file
@@ -0,0 +1,130 @@
|
||||
## Context
|
||||
|
||||
Gateway Checker 当前是一个 Bun + React 全栈脚手架,仅包含 demo 验证逻辑(`/api/demo` 端点 + 前端展示连接状态)。项目已有完整的开发、构建、打包、测试链路。需要将其转化为一个可用的 HTTP 拨测工具。
|
||||
|
||||
现有基础设施:
|
||||
- Bun 后端:路由框架(`createFetchHandler`)、服务启动(`startServer`)、运行时配置解析(`readRuntimeConfig`)
|
||||
- React 前端:Vite + React + TypeScript,开发期通过 Vite proxy 转发 `/api/*`
|
||||
- 构建:Vite 前端构建 + Bun 单 executable 打包
|
||||
- 测试:Bun test + smoke test
|
||||
|
||||
## Goals / Non-Goals
|
||||
|
||||
**Goals:**
|
||||
- 提供完整的 HTTP 拨测能力:YAML 配置 → 定时并发拨测 → 结果持久化 → 可视化展示
|
||||
- 支持灵活的拨测配置:per-target interval、自定义 method/header/body、expect 校验
|
||||
- 前端 Dashboard 实时展示:总览统计、目标状态列表、历史记录、延迟趋势图
|
||||
- 保持现有项目架构风格和构建打包链路
|
||||
- 零外部运行时依赖新增(仅前端 recharts)
|
||||
|
||||
**Non-Goals:**
|
||||
- 不做告警通知(邮件/短信/Webhook),仅 Dashboard 展示
|
||||
- 不做数据自动清理/过期策略,保留全部历史记录
|
||||
- 不做 SSE/WebSocket 实时推送,用轮询即可
|
||||
- 不做拨测目标动态增删(需修改 YAML 后重启)
|
||||
- 不做认证/鉴权
|
||||
- 不做分布式/集群部署
|
||||
|
||||
## Decisions
|
||||
|
||||
### 1. 配置管理:YAML 统一配置 + 单 CLI 参数
|
||||
|
||||
**选择**:所有配置(server、数据目录、拨测默认值、目标列表)统一到 YAML 文件,CLI 只接受一个参数即配置文件路径。
|
||||
|
||||
**替代方案**:
|
||||
- CLI 参数 + 环境变量覆盖部分配置 → 配置分散,维护成本高
|
||||
- TOML 格式 → Bun 无内置支持,需引入依赖
|
||||
|
||||
**理由**:
|
||||
- 用户明确要求"配置统一到 YAML 文件"
|
||||
- `Bun.YAML.parse()` 内置支持,零依赖
|
||||
- 单参数 CLI 最简洁:`./gateway-checker ./probes.yaml`
|
||||
|
||||
### 2. 数据存储:SQLite(bun:sqlite)
|
||||
|
||||
**选择**:使用 Bun 内置 `bun:sqlite` 模块,WAL 模式运行。
|
||||
|
||||
**替代方案**:
|
||||
- JSONL 文件追加 → 聚合查询需全表扫描,趋势计算复杂
|
||||
- 外部 SQLite 库(better-sqlite3)→ bun:sqlite 已内置,无需引入
|
||||
|
||||
**理由**:
|
||||
- 趋势分析需要 `AVG(latency) GROUP BY hour` 等聚合查询,SQL 原生支持
|
||||
- bun:sqlite 是 Bun 内置模块,不违反"不引入新依赖"约束
|
||||
- WAL 模式支持并发读写
|
||||
- 单 `.db` 文件,便于管理
|
||||
|
||||
### 3. 调度模型:按 interval 分组 + 组内并发
|
||||
|
||||
**选择**:将所有 target 按其 interval 值分组,每组一个 `setInterval` timer,组内使用 `Promise.all` 并发拨测。
|
||||
|
||||
**替代方案**:
|
||||
- 全局统一 tick → 无法支持 per-target interval
|
||||
- 每个 target 独立 timer → 目标多时 timer 数量大,资源浪费
|
||||
- 使用调度队列(如 BullMQ)→ 过度设计
|
||||
|
||||
**理由**:
|
||||
- 支持 per-target interval,满足不同服务不同频率的需求
|
||||
- 相同 interval 的目标共享 timer,timer 数量 = 不同 interval 值的数量
|
||||
- 组内并发保证批量效率,组间隔离互不影响
|
||||
|
||||
### 4. 前端更新策略:轮询
|
||||
|
||||
**选择**:前端每 5-10 秒轮询 `/api/summary` 和 `/api/targets`。
|
||||
|
||||
**替代方案**:
|
||||
- SSE 服务端推送 → 实现复杂,拨测间隔 15-60s 级别无必要
|
||||
- WebSocket → 更复杂,过度设计
|
||||
|
||||
**理由**:
|
||||
- 拨测间隔本身是 15-60s,5s 轮询延迟完全可接受
|
||||
- 实现简单,无需维护长连接状态
|
||||
- Dashboard 面板按需加载趋势数据(展开详情时请求)
|
||||
|
||||
### 5. 趋势图:recharts
|
||||
|
||||
**选择**:引入 recharts 作为前端图表库。
|
||||
|
||||
**替代方案**:
|
||||
- 纯 SVG 手写 sparkline → 零依赖但代码量大,交互能力有限
|
||||
- Chart.js → 非 React 原生,需要 wrapper
|
||||
- D3 → 过于底层
|
||||
|
||||
**理由**:
|
||||
- 用户确认允许引入轻量图表库
|
||||
- recharts 是 React 原生图表库,与现有 React 技术栈一致
|
||||
- 支持折线图、迷你 Sparkline,满足需求
|
||||
- 社区活跃,文档完善
|
||||
|
||||
### 6. 目标状态判定模型
|
||||
|
||||
**选择**:两层判定——`success`(请求是否完成)+ `matched`(是否符合 expect 规则)。
|
||||
|
||||
```
|
||||
● UP = success ✓ && matched ✓
|
||||
● DOWN = !success || !matched
|
||||
```
|
||||
|
||||
**理由**:
|
||||
- 区分"网络不可达"和"返回了非预期状态码"两种故障场景
|
||||
- expect 规则可选,不配置时 matched 默认为 true
|
||||
- 前端可以根据 `success`/`matched` 分别展示不同故障原因
|
||||
|
||||
### 7. 数据库 Schema 设计
|
||||
|
||||
**targets 表**:从 YAML 同步初始化,运行时只读。
|
||||
**check_results 表**:只追加写入,索引 `(target_id, timestamp)` 加速历史查询。
|
||||
|
||||
**理由**:
|
||||
- targets 从 YAML 来,不提供运行时动态增删(符合 Non-Goals)
|
||||
- check_results 追加写入,无需更新/删除,简单可靠
|
||||
- 按时间范围查询是最高频操作,复合索引覆盖
|
||||
|
||||
## Risks / Trade-offs
|
||||
|
||||
- **[YAML 格式错误导致启动失败]** → 解析时做完整校验,输出清晰错误信息(字段缺失、格式不对、值非法等),提前失败而非运行时出错
|
||||
- **[并发拨测对目标服务器压力]** → 每组内 Promise.all 并发,但同一 group 的 tick 间隔内不会重复拨测。如果用户配置了大量目标且 interval 很短,可能对目标产生压力,这是用户配置责任
|
||||
- **[SQLite 数据文件增长]** → 当前不清理,长期运行会增长。预留清理策略接口,后续可通过配置保留天数
|
||||
- **[recharts 包体积]** → recharts gzip 后约 70KB,会增加前端 bundle 大小。对于内部工具可接受
|
||||
- **[拨测请求超时阻塞]** → 使用 `AbortController` + `setTimeout` 实现超时,避免单个慢请求阻塞整组
|
||||
- **[进程重启后丢失 timer 状态]** → 拨测是幂等的(无状态定时任务),重启后立即开始新一轮即可,无需恢复状态
|
||||
@@ -0,0 +1,36 @@
|
||||
## Why
|
||||
|
||||
项目当前只有 demo 验证链路(`/api/demo` + 前端展示连接状态),缺少核心业务逻辑。需要一个 HTTP 拨测工具,通过 YAML 配置文件定义拨测目标(URL、method、header、body、期望条件等),后端按配置定时、并行批量拨测,结果持久化到本地 SQLite,前端 Dashboard 展示各目标实时状态、可用率、延迟趋势等。
|
||||
|
||||
## What Changes
|
||||
|
||||
- **清理 demo 样例代码**:移除 `/api/demo` 路由、`DemoResponse` 类型、前端 demo 展示逻辑,保留路由框架、服务启动、构建打包链路和 `/health` 端点
|
||||
- **新增 YAML 配置文件解析**:使用 Bun 内置 `Bun.YAML.parse()` 读取拨测规则文件,包含 server 配置、数据目录、全局默认值和拨测目标列表
|
||||
- **简化 CLI 参数**:只保留一个命令行参数——配置文件路径,所有配置统一到 YAML 文件
|
||||
- **新增 SQLite 数据存储**:使用 `bun:sqlite` 存储拨测目标(从 YAML 同步)和拨测结果(追加写入),支持索引查询
|
||||
- **新增拨测调度引擎**:按 target 的 interval 分组,每组独立 timer,组内 `Promise.all` 并发拨测,支持 expect 校验(状态码、响应体、延迟阈值)
|
||||
- **新增 REST API 层**:提供总览统计、目标列表含当前状态、历史记录、趋势聚合等接口
|
||||
- **新增前端 Dashboard**:使用 React 组件展示统计卡片、目标列表表格(含状态圆点和迷你趋势线)、可展开详情面板(含完整趋势图),通过轮询 5-10s 更新数据
|
||||
- **引入 recharts 依赖**:用于趋势图和迷你 Sparkline 可视化
|
||||
|
||||
## Capabilities
|
||||
|
||||
### New Capabilities
|
||||
- `probe-config`: YAML 配置文件格式定义、解析校验与 CLI 启动流程
|
||||
- `probe-engine`: 拨测调度引擎——按 interval 分组定时、并发拨测、expect 校验、结果存储
|
||||
- `probe-data-store`: SQLite 数据存储——targets 同步、results 追加、索引与聚合查询
|
||||
- `probe-api`: REST API 层——总览统计、目标列表含状态、历史记录、趋势聚合
|
||||
- `probe-dashboard`: React 前端 Dashboard——统计卡片、目标表格、详情面板、趋势图
|
||||
|
||||
### Modified Capabilities
|
||||
- `fullstack-app-runtime`: CLI 参数从 `--host/--port` 简化为单个配置文件路径参数;移除 `/api/demo` 路由;新增 `/api/*` 拨测相关 API 路由
|
||||
- `frontend-development-workflow`: 前端从 demo 展示页面替换为拨测 Dashboard;移除 `/api/demo` 相关代理场景
|
||||
|
||||
## Impact
|
||||
|
||||
- **代码变更**:`src/server/app.ts` 路由重写、`src/server/config.ts` 简化、`src/shared/api.ts` 类型重写、`src/web/` 前端全部重写
|
||||
- **新增模块**:`src/server/checker/` 目录(engine、fetcher、store、config-loader、types)
|
||||
- **新增依赖**:`recharts`(前端图表)
|
||||
- **无新增外部依赖**:YAML 解析使用 Bun 内置 `Bun.YAML`,SQLite 使用 Bun 内置 `bun:sqlite`
|
||||
- **构建打包**:现有 single executable 打包链路不变,YAML 配置文件为外部文件不嵌入 executable
|
||||
- **API 变更**:**BREAKING** 移除 `/api/demo`,新增 `/api/summary`、`/api/targets`、`/api/targets/:id/history`、`/api/targets/:id/trend`
|
||||
@@ -0,0 +1,12 @@
|
||||
## MODIFIED Requirements
|
||||
|
||||
### Requirement: 前端开发期 API 代理
|
||||
前端开发服务器 SHALL 在本地开发期间将 `/api/*` 请求代理到 Bun 后端服务。
|
||||
|
||||
#### Scenario: 前端开发期调用拨测 API
|
||||
- **WHEN** 浏览器从 Vite 开发源请求 `/api/summary`、`/api/targets` 等拨测 API
|
||||
- **THEN** Vite SHALL 将请求转发到 Bun 后端服务,且不需要浏览器 CORS 配置
|
||||
|
||||
#### Scenario: 开发期访问非 API 前端路由
|
||||
- **WHEN** 浏览器从 Vite 开发源请求非 API 前端路由
|
||||
- **THEN** Vite SHALL 将该请求作为前端应用流量处理,而不是转发到后端
|
||||
@@ -0,0 +1,35 @@
|
||||
## MODIFIED Requirements
|
||||
|
||||
### Requirement: Bun HTTP 运行时
|
||||
系统 SHALL 运行一个 Bun HTTP server,由单个进程提供后端 API、健康检查、生产静态资源和 SPA fallback 行为。
|
||||
|
||||
#### Scenario: 启动运行时服务器
|
||||
- **WHEN** server 进程成功启动
|
||||
- **THEN** 它 SHALL 监听 YAML 配置文件中指定的 host 和 port,并记录实际 server URL
|
||||
|
||||
#### Scenario: 通过 YAML 配置提供运行时参数
|
||||
- **WHEN** 通过 YAML 配置文件提供 host、port、数据目录等参数
|
||||
- **THEN** server SHALL 使用该值,且不需要重新构建
|
||||
|
||||
#### Scenario: CLI 只接受配置文件路径
|
||||
- **WHEN** 用户通过命令行启动程序
|
||||
- **THEN** 系统 SHALL 只接受一个命令行参数作为 YAML 配置文件路径
|
||||
|
||||
#### Scenario: 提供拨测相关 API
|
||||
- **WHEN** server 启动完成
|
||||
- **THEN** 系统 SHALL 提供 `/api/summary`、`/api/targets`、`/api/targets/:id/history`、`/api/targets/:id/trend` 端点
|
||||
|
||||
### Requirement: HTTP method 语义
|
||||
系统 SHALL 为运行时端点提供明确的 HTTP method 语义,避免不支持的 method 被错误地当作成功请求处理。
|
||||
|
||||
#### Scenario: GET 请求访问运行时端点
|
||||
- **WHEN** 客户端使用 `GET` 请求 `/health` 或 `/api/*` 端点
|
||||
- **THEN** Bun server SHALL 返回对应端点的成功响应
|
||||
|
||||
#### Scenario: HEAD 请求访问运行时端点
|
||||
- **WHEN** 客户端使用 `HEAD` 请求 `/health` 或 `/api/*` 端点
|
||||
- **THEN** Bun server SHALL 返回与 `GET` 相同的成功状态和 headers,但 MUST NOT 返回响应体
|
||||
|
||||
#### Scenario: 不支持的 method 访问运行时端点
|
||||
- **WHEN** 客户端使用不支持的 method 请求 `/health` 或 `/api/*` 端点
|
||||
- **THEN** Bun server SHALL 返回 405 状态码和 Allow header
|
||||
@@ -0,0 +1,59 @@
|
||||
## ADDED Requirements
|
||||
|
||||
### Requirement: 总览统计 API
|
||||
系统 SHALL 提供 `GET /api/summary` 端点,返回所有目标的总体统计信息。
|
||||
|
||||
#### Scenario: 获取总览统计
|
||||
- **WHEN** 客户端请求 `GET /api/summary`
|
||||
- **THEN** 系统 SHALL 返回 JSON 包含 total(总目标数)、up(正常数)、down(异常数)、avgLatencyMs(所有目标平均延迟)、lastCheckTime(最近一次拨测时间)
|
||||
|
||||
### Requirement: 目标列表 API
|
||||
系统 SHALL 提供 `GET /api/targets` 端点,返回所有目标及其最新状态和统计摘要。
|
||||
|
||||
#### Scenario: 获取目标列表
|
||||
- **WHEN** 客户端请求 `GET /api/targets`
|
||||
- **THEN** 系统 SHALL 返回 JSON 数组,每个元素包含目标基本信息、最近一次拨测结果(timestamp、success、statusCode、latencyMs、error、matched)和统计摘要(totalChecks、availability、avgLatencyMs、p99LatencyMs)
|
||||
|
||||
#### Scenario: 目标无历史记录
|
||||
- **WHEN** 某目标尚未执行过任何拨测
|
||||
- **THEN** 其 latestCheck 为 null,stats 中 totalChecks 为 0
|
||||
|
||||
### Requirement: 历史记录 API
|
||||
系统 SHALL 提供 `GET /api/targets/:id/history` 端点,返回指定目标的最近 N 条拨测记录。
|
||||
|
||||
#### Scenario: 获取最近历史记录
|
||||
- **WHEN** 客户端请求 `GET /api/targets/1/history?limit=20`
|
||||
- **THEN** 系统 SHALL 返回最多 20 条拨测记录,按时间倒序排列
|
||||
|
||||
#### Scenario: 使用默认 limit
|
||||
- **WHEN** 客户端请求 `GET /api/targets/1/history`(未指定 limit)
|
||||
- **THEN** 系统 SHALL 默认返回最近 20 条记录
|
||||
|
||||
### Requirement: 趋势聚合 API
|
||||
系统 SHALL 提供 `GET /api/targets/:id/trend` 端点,返回指定目标按小时聚合的趋势数据。
|
||||
|
||||
#### Scenario: 获取 24 小时趋势
|
||||
- **WHEN** 客户端请求 `GET /api/targets/1/trend?hours=24`
|
||||
- **THEN** 系统 SHALL 返回按小时分组的聚合数据,每个数据点包含 hour、avgLatencyMs、availability、totalChecks
|
||||
|
||||
#### Scenario: 使用默认时间范围
|
||||
- **WHEN** 客户端请求 `GET /api/targets/1/trend`(未指定 hours)
|
||||
- **THEN** 系统 SHALL 默认返回最近 24 小时的趋势数据
|
||||
|
||||
### Requirement: 保留健康检查端点
|
||||
系统 SHALL 保留 `GET /health` 端点,不受拨测功能影响。
|
||||
|
||||
#### Scenario: 访问健康检查
|
||||
- **WHEN** 客户端请求 `GET /health`
|
||||
- **THEN** 系统 SHALL 返回与之前格式一致的健康检查响应
|
||||
|
||||
### Requirement: API 错误处理
|
||||
系统 SHALL 对不存在的目标 ID 和无效参数返回适当的 HTTP 错误响应。
|
||||
|
||||
#### Scenario: 查询不存在的目标
|
||||
- **WHEN** 客户端请求 `GET /api/targets/999/history`
|
||||
- **THEN** 系统 SHALL 返回 404 状态码和错误信息
|
||||
|
||||
#### Scenario: 无效的 limit 参数
|
||||
- **WHEN** 客户端请求 `GET /api/targets/1/history?limit=abc`
|
||||
- **THEN** 系统 SHALL 返回 400 状态码和错误信息
|
||||
@@ -0,0 +1,53 @@
|
||||
## ADDED Requirements
|
||||
|
||||
### Requirement: YAML 配置文件格式
|
||||
系统 SHALL 支持通过 YAML 配置文件定义全部运行参数,包括 server 配置、数据目录、拨测默认值和拨测目标列表。
|
||||
|
||||
#### Scenario: 完整配置文件解析
|
||||
- **WHEN** 系统启动并读取包含 server、defaults、targets 的 YAML 配置文件
|
||||
- **THEN** 系统 SHALL 正确解析所有字段并用于初始化服务
|
||||
|
||||
#### Scenario: 最简配置文件解析
|
||||
- **WHEN** 系统读取只包含 targets 列表的 YAML 配置文件(省略 server 和 defaults)
|
||||
- **THEN** 系统 SHALL 使用内置默认值填充未指定的字段(host=127.0.0.1, port=3000, dir=./data, interval=30s, timeout=10s, method=GET)
|
||||
|
||||
#### Scenario: per-target 配置覆盖全局默认值
|
||||
- **WHEN** 某个 target 指定了 interval、timeout 或 method
|
||||
- **THEN** 该 target SHALL 使用其自身的值,不受 defaults 影响
|
||||
|
||||
### Requirement: CLI 参数
|
||||
系统 SHALL 通过单一命令行参数接受 YAML 配置文件路径。
|
||||
|
||||
#### Scenario: 指定配置文件启动
|
||||
- **WHEN** 用户执行 `./gateway-checker ./probes.yaml`
|
||||
- **THEN** 系统 SHALL 读取并解析指定路径的 YAML 文件作为配置
|
||||
|
||||
#### Scenario: 未提供配置文件路径
|
||||
- **WHEN** 用户启动程序时未提供任何命令行参数
|
||||
- **THEN** 系统 SHALL 以错误退出并提示需要指定配置文件路径
|
||||
|
||||
#### Scenario: 配置文件不存在
|
||||
- **WHEN** 用户指定的配置文件路径不存在
|
||||
- **THEN** 系统 SHALL 以错误退出并提示文件不存在
|
||||
|
||||
### Requirement: 配置校验
|
||||
系统 SHALL 在启动时对 YAML 配置进行完整校验,校验失败时以非零状态退出并输出清晰的错误信息。
|
||||
|
||||
#### Scenario: target 缺少必填字段
|
||||
- **WHEN** YAML 中某个 target 缺少 name 或 url 字段
|
||||
- **THEN** 系统 SHALL 以错误退出,提示哪个 target 缺少哪个字段
|
||||
|
||||
#### Scenario: target name 重复
|
||||
- **WHEN** YAML 中存在两个 name 相同的 target
|
||||
- **THEN** 系统 SHALL 以错误退出,提示重复的 name
|
||||
|
||||
#### Scenario: interval 格式非法
|
||||
- **WHEN** interval 或 timeout 值不是有效的时长格式(如 `30s`、`5m`)
|
||||
- **THEN** 系统 SHALL 以错误退出并提示格式错误
|
||||
|
||||
### Requirement: YAML 配置使用 Bun 内置解析
|
||||
系统 SHALL 使用 Bun 内置的 `Bun.YAML.parse()` 解析配置文件,不引入外部 YAML 解析库。
|
||||
|
||||
#### Scenario: 解析 YAML 内容
|
||||
- **WHEN** 系统读取 YAML 文件内容
|
||||
- **THEN** 系统 SHALL 调用 `Bun.YAML.parse()` 将内容解析为配置对象
|
||||
@@ -0,0 +1,69 @@
|
||||
## ADDED Requirements
|
||||
|
||||
### Requirement: 总览统计卡片
|
||||
Dashboard SHALL 在页面顶部展示总览统计卡片,包含总目标数、正常数、异常数和平均延迟。
|
||||
|
||||
#### Scenario: 展示统计卡片
|
||||
- **WHEN** 用户打开 Dashboard 页面
|
||||
- **THEN** 页面顶部 SHALL 显示 4 个统计卡片:全部目标数、正常目标数、异常目标数、所有目标平均延迟
|
||||
|
||||
#### Scenario: 统计数据自动刷新
|
||||
- **WHEN** 页面处于打开状态
|
||||
- **THEN** 统计卡片 SHALL 每 5-10 秒自动刷新数据
|
||||
|
||||
### Requirement: 目标列表表格
|
||||
Dashboard SHALL 展示所有拨测目标的列表表格,包含名称、URL、当前状态、最新延迟和迷你趋势线。
|
||||
|
||||
#### Scenario: 展示目标列表
|
||||
- **WHEN** 用户打开 Dashboard 页面
|
||||
- **THEN** 页面 SHALL 显示表格,每行包含目标名称、URL、状态指示圆点(● UP / ● DOWN)、最新延迟值、迷你 Sparkline 趋势线
|
||||
|
||||
#### Scenario: 状态指示圆点
|
||||
- **WHEN** 目标最近一次拨测 success=true 且 matched=true
|
||||
- **THEN** 状态圆点 SHALL 显示为绿色(UP)
|
||||
- **WHEN** 目标最近一次拨测 success=false 或 matched=false
|
||||
- **THEN** 状态圆点 SHALL 显示为红色(DOWN)
|
||||
|
||||
### Requirement: 可展开的目标详情面板
|
||||
Dashboard SHALL 支持在目标列表中展开某行,显示该目标的详细状态、统计摘要、趋势图和最近历史记录。
|
||||
|
||||
#### Scenario: 展开目标详情
|
||||
- **WHEN** 用户点击目标列表中的某一行
|
||||
- **THEN** 该行下方 SHALL 展开详情面板,包含:可用率百分比、平均延迟、P99 延迟、24 小时延迟趋势折线图、最近 5-10 条拨测记录列表
|
||||
|
||||
#### Scenario: 收起目标详情
|
||||
- **WHEN** 用户再次点击已展开的目标行
|
||||
- **THEN** 详情面板 SHALL 收起
|
||||
|
||||
#### Scenario: 趋势图按需加载
|
||||
- **WHEN** 用户展开某个目标的详情面板
|
||||
- **THEN** 系统 SHALL 此时请求该目标的趋势数据,而非页面加载时预加载所有目标的趋势数据
|
||||
|
||||
### Requirement: 历史记录展示
|
||||
Dashboard SHALL 在目标详情面板中展示最近的拨测记录,包含时间、状态码、延迟和成功/失败标记。
|
||||
|
||||
#### Scenario: 展示历史记录
|
||||
- **WHEN** 用户展开目标详情面板
|
||||
- **THEN** 面板 SHALL 显示最近拨测记录列表,每条包含时间戳、HTTP 状态码(或错误信息)、延迟毫秒数、成功/失败图标
|
||||
|
||||
### Requirement: 趋势图可视化
|
||||
Dashboard SHALL 使用 recharts 库渲染趋势图,包括目标列表中的迷你 Sparkline 和详情面板中的完整折线图。
|
||||
|
||||
#### Scenario: 表格行内迷你趋势线
|
||||
- **WHEN** 目标列表表格渲染
|
||||
- **THEN** 每行 SHALL 包含一个基于 recharts 的迷你折线图,展示最近的延迟趋势
|
||||
|
||||
#### Scenario: 详情面板完整趋势图
|
||||
- **WHEN** 用户展开目标详情面板
|
||||
- **THEN** 面板 SHALL 展示基于 recharts 的完整折线图,X 轴为时间(小时),Y 轴为平均延迟,并标注可用率
|
||||
|
||||
### Requirement: 页面加载与错误状态
|
||||
Dashboard SHALL 正确处理加载状态和 API 错误。
|
||||
|
||||
#### Scenario: 首次加载
|
||||
- **WHEN** 页面首次加载且数据尚未返回
|
||||
- **THEN** 页面 SHALL 显示加载状态指示
|
||||
|
||||
#### Scenario: API 请求失败
|
||||
- **WHEN** 前端轮询 API 请求失败
|
||||
- **THEN** 页面 SHALL 显示错误提示,并在下一次轮询周期自动重试
|
||||
@@ -0,0 +1,56 @@
|
||||
## ADDED Requirements
|
||||
|
||||
### Requirement: SQLite 数据库初始化
|
||||
系统 SHALL 使用 Bun 内置 `bun:sqlite` 模块在配置的数据目录下创建 SQLite 数据库文件,并以 WAL 模式运行。
|
||||
|
||||
#### Scenario: 首次启动创建数据库
|
||||
- **WHEN** 指定的数据目录下不存在数据库文件
|
||||
- **THEN** 系统 SHALL 创建数据库文件并初始化 targets 和 check_results 表
|
||||
|
||||
#### Scenario: 数据目录不存在
|
||||
- **WHEN** 配置的数据目录路径不存在
|
||||
- **THEN** 系统 SHALL 自动创建该目录
|
||||
|
||||
#### Scenario: 数据库已存在时启动
|
||||
- **WHEN** 数据库文件已存在
|
||||
- **THEN** 系统 SHALL 直接打开数据库,不重新建表
|
||||
|
||||
### Requirement: targets 表同步
|
||||
系统 SHALL 在启动时将 YAML 配置中的目标列表同步到 SQLite targets 表。
|
||||
|
||||
#### Scenario: 首次同步目标
|
||||
- **WHEN** 数据库为空且 YAML 中定义了 N 个目标
|
||||
- **THEN** 系统 SHALL 将所有目标插入 targets 表
|
||||
|
||||
#### Scenario: 配置变更后重新同步
|
||||
- **WHEN** YAML 配置发生变更(新增、删除或修改目标)后重启
|
||||
- **THEN** 系统 SHALL 根据 name 字段匹配:新增的插入、删除的移除、修改的更新
|
||||
|
||||
### Requirement: check_results 表追加写入
|
||||
系统 SHALL 将每次拨测结果追加写入 check_results 表,不更新或删除已有记录。
|
||||
|
||||
#### Scenario: 写入拨测结果
|
||||
- **WHEN** 一次拨测完成
|
||||
- **THEN** 系统 SHALL 插入一条包含 target_id、timestamp、success、status_code、latency_ms、error、matched 的记录
|
||||
|
||||
### Requirement: 时间范围查询索引
|
||||
系统 SHALL 在 check_results 表上创建 (target_id, timestamp) 复合索引,加速按目标和时间范围的查询。
|
||||
|
||||
#### Scenario: 查询某目标的历史记录
|
||||
- **WHEN** 查询指定 target_id 的最近 N 条记录
|
||||
- **THEN** 系统 SHALL 使用索引快速定位,无需全表扫描
|
||||
|
||||
### Requirement: 聚合查询支持
|
||||
数据存储 SHALL 支持按时间段聚合查询,用于计算可用率、平均延迟、P99 延迟等统计指标。
|
||||
|
||||
#### Scenario: 计算目标可用率
|
||||
- **WHEN** 查询某目标在指定时间范围内的可用率
|
||||
- **THEN** 系统 SHALL 返回 UP (success=true AND matched=true) 的记录数占总记录数的百分比
|
||||
|
||||
#### Scenario: 计算目标平均延迟
|
||||
- **WHEN** 查询某目标在指定时间范围内的平均延迟
|
||||
- **THEN** 系统 SHALL 返回 latency_ms 的平均值(仅计算 success=true 的记录)
|
||||
|
||||
#### Scenario: 按小时聚合趋势数据
|
||||
- **WHEN** 查询某目标在指定时间范围内的趋势数据
|
||||
- **THEN** 系统 SHALL 返回按小时分组的聚合数据,包括每小时的平均延迟和可用率
|
||||
@@ -0,0 +1,83 @@
|
||||
## ADDED Requirements
|
||||
|
||||
### Requirement: 按 interval 分组调度
|
||||
系统 SHALL 将拨测目标按 interval 值分组,每组使用独立的定时器进行调度。
|
||||
|
||||
#### Scenario: 相同 interval 的目标共享定时器
|
||||
- **WHEN** 多个 target 配置了相同的 interval(如 30s)
|
||||
- **THEN** 系统 SHALL 使用同一个 `setInterval` 定时器,每次 tick 并发拨测所有该组目标
|
||||
|
||||
#### Scenario: 不同 interval 的目标各自调度
|
||||
- **WHEN** target A 配置 15s interval,target B 配置 30s interval
|
||||
- **THEN** 系统 SHALL 创建两个独立定时器,分别按各自频率调度
|
||||
|
||||
### Requirement: 组内并发拨测
|
||||
系统 SHALL 在每次调度 tick 时,使用 `Promise.all` 并发执行同组内所有目标的拨测。
|
||||
|
||||
#### Scenario: 同组目标并发执行
|
||||
- **WHEN** 调度器触发一次 tick,该组有 3 个目标
|
||||
- **THEN** 系统 SHALL 同时发起 3 个 HTTP 请求,而非顺序执行
|
||||
|
||||
#### Scenario: 单个目标失败不影响同组其他目标
|
||||
- **WHEN** 同组中某个目标的拨测请求超时或失败
|
||||
- **THEN** 其他目标的拨测 SHALL 正常完成并记录结果
|
||||
|
||||
### Requirement: HTTP 拨测执行
|
||||
系统 SHALL 对每个目标执行 HTTP 请求,支持 GET、POST、PUT、DELETE、PATCH、HEAD 方法,并携带配置的 headers 和 body。
|
||||
|
||||
#### Scenario: 执行 GET 请求
|
||||
- **WHEN** 目标配置 method 为 GET
|
||||
- **THEN** 系统 SHALL 发送 GET 请求到目标 URL
|
||||
|
||||
#### Scenario: 执行 POST 请求带 body
|
||||
- **WHEN** 目标配置 method 为 POST 且指定了 body 和 Content-Type header
|
||||
- **THEN** 系统 SHALL 发送带指定 body 的 POST 请求
|
||||
|
||||
#### Scenario: 携带自定义 headers
|
||||
- **WHEN** 目标配置了 headers(如 Authorization)
|
||||
- **THEN** 系统 SHALL 在请求中包含所有配置的 headers
|
||||
|
||||
### Requirement: 请求超时控制
|
||||
系统 SHALL 对每次拨测请求实施超时控制,超时时间使用目标配置的 timeout 值。
|
||||
|
||||
#### Scenario: 请求超时
|
||||
- **WHEN** 拨测请求在 timeout 时间内未收到响应
|
||||
- **THEN** 系统 SHALL 中止该请求,记录为失败并标注超时错误
|
||||
|
||||
#### Scenario: 请求在超时前完成
|
||||
- **WHEN** 拨测请求在 timeout 时间内收到响应
|
||||
- **THEN** 系统 SHALL 正常记录响应结果
|
||||
|
||||
### Requirement: expect 校验
|
||||
系统 SHALL 在拨测完成后根据目标的 expect 配置校验响应,校验结果记入 check result。
|
||||
|
||||
#### Scenario: 校验状态码
|
||||
- **WHEN** 目标配置了 `expect.status: [200, 201]`
|
||||
- **THEN** 系统 SHALL 检查响应状态码是否在列表中,将匹配结果记录到 matched 字段
|
||||
|
||||
#### Scenario: 校验响应体包含
|
||||
- **WHEN** 目标配置了 `expect.bodyContains: "healthy"`
|
||||
- **THEN** 系统 SHALL 检查响应体是否包含该文本,将匹配结果记录到 matched 字段
|
||||
|
||||
#### Scenario: 校验延迟阈值
|
||||
- **WHEN** 目标配置了 `expect.maxLatencyMs: 3000`
|
||||
- **THEN** 系统 SHALL 检查实际延迟是否超过阈值,将匹配结果记录到 matched 字段
|
||||
|
||||
#### Scenario: 无 expect 配置
|
||||
- **WHEN** 目标未配置任何 expect 规则
|
||||
- **THEN** 系统 SHALL 将 matched 字段设为 true
|
||||
|
||||
#### Scenario: 多条 expect 规则
|
||||
- **WHEN** 目标同时配置了 status、bodyContains 和 maxLatencyMs
|
||||
- **THEN** 系统 SHALL 所有规则全部通过时 matched 为 true,任一不通过则为 false
|
||||
|
||||
### Requirement: 拨测结果记录
|
||||
系统 SHALL 在每次拨测完成后,将结果写入 SQLite 数据存储,包含 target_id、timestamp、success、status_code、latency_ms、error、matched 字段。
|
||||
|
||||
#### Scenario: 成功拨测结果记录
|
||||
- **WHEN** 拨测请求成功完成(收到 HTTP 响应)
|
||||
- **THEN** 系统 SHALL 记录 success=true、status_code、latency_ms、matched
|
||||
|
||||
#### Scenario: 失败拨测结果记录
|
||||
- **WHEN** 拨测请求失败(网络错误、超时等)
|
||||
- **THEN** 系统 SHALL 记录 success=false、error 信息,status_code 和 latency_ms 为 null
|
||||
@@ -0,0 +1,62 @@
|
||||
## 1. 项目准备与依赖
|
||||
|
||||
- [x] 1.1 清理 demo 代码:移除 /api/demo 路由、DemoResponse 类型、前端 demo 展示逻辑
|
||||
- [x] 1.2 安装 recharts 依赖
|
||||
- [x] 1.3 创建 src/server/checker/ 目录结构和类型定义文件 types.ts
|
||||
- [x] 1.4 创建示例 YAML 配置文件 probes.example.yaml
|
||||
|
||||
## 2. 配置解析层
|
||||
|
||||
- [x] 2.1 实现 YAML 配置类型定义(ProbeConfig、TargetConfig、ExpectConfig 等)
|
||||
- [x] 2.2 实现 config-loader.ts:读取文件 + Bun.YAML.parse + 配置校验(必填字段、name 唯一性、interval 格式、port 范围)
|
||||
- [x] 2.3 重写 src/server/config.ts:CLI 只接受配置文件路径参数,从 YAML 读取 host/port/dataDir
|
||||
- [x] 2.4 为配置解析和校验编写完整测试
|
||||
|
||||
## 3. 数据存储层
|
||||
|
||||
- [x] 3.1 实现 store.ts:SQLite 初始化(建表、WAL 模式、复合索引)、数据目录自动创建
|
||||
- [x] 3.2 实现 targets 表同步逻辑(根据 name 匹配:新增插入、删除移除、修改更新)
|
||||
- [x] 3.3 实现 check_results 追加写入方法
|
||||
- [x] 3.4 实现查询方法:按 target+时间范围查询、按小时聚合趋势、计算可用率/平均延迟/P99
|
||||
- [x] 3.5 为数据存储层编写完整测试(初始化、同步、写入、查询、聚合)
|
||||
|
||||
## 4. 拨测引擎
|
||||
|
||||
- [x] 4.1 实现 fetcher.ts:HTTP 请求执行(method/header/body)+ AbortController 超时控制
|
||||
- [x] 4.2 实现 expect 校验逻辑(status 列表匹配、bodyContains、maxLatencyMs)
|
||||
- [x] 4.3 实现 engine.ts:按 interval 分组 → setInterval → Promise.all 并发拨测 → 结果写入 store
|
||||
- [x] 4.4 为 fetcher 和 expect 校验编写完整测试(使用 mock HTTP server)
|
||||
- [x] 4.5 为调度引擎编写完整测试(分组逻辑、并发执行、单目标失败隔离)
|
||||
|
||||
## 5. API 路由层
|
||||
|
||||
- [x] 5.1 定义 src/shared/api.ts 响应类型(SummaryResponse、TargetStatus、CheckResult、TrendPoint)
|
||||
- [x] 5.2 重写 src/server/app.ts:注册新 API 路由(/api/summary、/api/targets、/api/targets/:id/history、/api/targets/:id/trend),保留 /health
|
||||
- [x] 5.3 实现 API 错误处理(目标不存在返回 404、参数无效返回 400)
|
||||
- [x] 5.4 为 API 路由编写完整测试(各端点正常响应、边界情况、错误处理)
|
||||
|
||||
## 6. 前端 Dashboard
|
||||
|
||||
- [x] 6.1 创建前端组件目录结构 src/web/components/ 和 src/web/hooks/
|
||||
- [x] 6.2 实现 hooks:useSummary(轮询 /api/summary)、useTargets(轮询 /api/targets)、useTrend(按需加载趋势数据)
|
||||
- [x] 6.3 实现 StatusDot 组件(绿色 UP / 红色 DOWN 圆点)
|
||||
- [x] 6.4 实现 SummaryCards 组件(4 个统计卡片)
|
||||
- [x] 6.5 实现 SparklineChart 组件(recharts 迷你折线图)
|
||||
- [x] 6.6 实现 TrendChart 组件(recharts 完整折线图,含时间轴和双 Y 轴)
|
||||
- [x] 6.7 实现 TargetRow 组件(表格行:名称、URL、状态、延迟、Sparkline,可展开)
|
||||
- [x] 6.8 实现 TargetDetail 组件(展开面板:统计摘要、趋势图、历史记录列表)
|
||||
- [x] 6.9 实现 TargetTable 组件(组合 TargetRow 和 TargetDetail)
|
||||
- [x] 6.10 重写 App.tsx:组合 SummaryCards + TargetTable,处理加载和错误状态
|
||||
- [x] 6.11 重写 styles.css:Dashboard 布局样式(卡片、表格、详情面板、响应式)
|
||||
- [x] 6.12 更新 vite.config.ts 代理配置确保 /api/* 转发
|
||||
|
||||
## 7. 集成与启动流程
|
||||
|
||||
- [x] 7.1 重写 src/server/dev.ts 和 src/server/server.ts:启动流程为 读取配置 → 初始化 store → 同步 targets → 启动 engine → 启动 HTTP server
|
||||
- [x] 7.2 更新构建脚本确保 recharts 正确打包进 executable
|
||||
- [x] 7.3 更新 README.md:新的 CLI 用法、YAML 配置说明、API 端点文档、项目结构变更
|
||||
|
||||
## 8. 端到端验证
|
||||
|
||||
- [x] 8.1 更新 smoke test 脚本适配新的 API 端点和前端路由
|
||||
- [x] 8.2 手动验证完整流程:YAML 配置 → 启动 → 拨测执行 → Dashboard 展示
|
||||
@@ -0,0 +1,2 @@
|
||||
schema: spec-driven
|
||||
created: 2026-05-09
|
||||
@@ -0,0 +1,255 @@
|
||||
## Context
|
||||
|
||||
当前实现的配置、执行、存储、API 和 Dashboard 都以 HTTP 请求为中心:`target.url` 是必填字段,执行器直接 `fetch(url)`,结果存储包含 `status_code` 与 `latency_ms`,前端展示 URL、method 和 HTTP 状态码。这种模型无法承载本地命令等非 HTTP checker,也让 `expect` 只能表达 HTTP response 的 status/header/body。
|
||||
|
||||
项目尚未上线,不需要兼容旧 YAML、旧数据库 schema 或旧 API 契约,因此本次设计选择直接建立 typed target 与领域专用 expect,而不是添加兼容分支。目标是让 HTTP 变成 runner 的一种实现,同时新增 command runner,并为未来其他 checker 类型保留清晰扩展点。
|
||||
|
||||
```text
|
||||
YAML target
|
||||
│
|
||||
▼
|
||||
ResolvedTarget(type)
|
||||
│
|
||||
▼
|
||||
ProbeEngine + concurrency limit
|
||||
│
|
||||
├─ http runner
|
||||
│ └─ HTTP expect pipeline
|
||||
│
|
||||
└─ command runner
|
||||
└─ command expect pipeline
|
||||
│
|
||||
▼
|
||||
CheckResult(success, matched, durationMs, statusDetail, failure)
|
||||
│
|
||||
▼
|
||||
SQLite + API + Dashboard
|
||||
```
|
||||
|
||||
## Goals / Non-Goals
|
||||
|
||||
**Goals:**
|
||||
|
||||
- 使用 `target.type` 建模不同 checker 类型,v1 支持 `http` 与 `command`。
|
||||
- 将 HTTP 配置放入 `target.http`,将命令配置放入 `target.command`,移除顶层 HTTP 字段。
|
||||
- 为各 checker 类型定义领域专用 expect 名称,HTTP 使用 `status`、`headers`、`body`,command 使用 `exitCode`、`stdout`、`stderr`。
|
||||
- 为不同 checker 类型提供默认成功语义:HTTP 默认 `status: [200]`,command 默认 `exitCode: [0]`。
|
||||
- 将可排序内容检查表达为数组,保证 `body`、`stdout`、`stderr` 按配置顺序执行。
|
||||
- 在 runner 和 expect pipeline 层共同实现快速失败,避免 status/header 已失败时仍读取或解析 body。
|
||||
- 使用 `durationMs` 表达 checker 执行耗时,替代 HTTP-only 的 `latencyMs`。
|
||||
- 引入结构化失败信息并入库,区分执行错误和 expect 不匹配,耗时阈值字段统一为 `maxDurationMs`。
|
||||
- 引入全局并发限制和 100MB 默认读取上限,避免 HTTP body 或 command 输出造成资源失控。
|
||||
|
||||
**Non-Goals:**
|
||||
|
||||
- 不兼容旧的顶层 `url`、`method`、`headers`、`body` 配置。
|
||||
- 不做旧 SQLite schema 迁移;实现阶段可以按新 schema 初始化和测试。
|
||||
- 不支持 shell 字符串命令;command v1 仅支持 `exec + args`。
|
||||
- 不持久化完整 HTTP body、stdout 或 stderr,只持久化结构化失败摘要。
|
||||
- 不引入新的解析或执行依赖。
|
||||
- 不在本次实现告警通知、认证鉴权或动态增删目标。
|
||||
|
||||
## Decisions
|
||||
|
||||
### 1. 使用判别联合建模 Target
|
||||
|
||||
配置和解析后的目标都使用 `type` 判别:
|
||||
|
||||
```yaml
|
||||
targets:
|
||||
- name: "HTTP 健康检查"
|
||||
type: http
|
||||
http:
|
||||
url: "https://example.com/health"
|
||||
method: GET
|
||||
|
||||
- name: "Nginx 进程检查"
|
||||
type: command
|
||||
command:
|
||||
exec: "pgrep"
|
||||
args: ["nginx"]
|
||||
```
|
||||
|
||||
理由:HTTP 与 command 的领域字段差异明显,强行把 URL、exec、status、exitCode 抽成统一字段会降低语义清晰度。判别联合可以让 TypeScript 在执行器选择、配置校验和 expect 校验中获得更明确的类型约束。
|
||||
|
||||
替代方案:保留顶层 `url` 并通过字段存在性推断 HTTP。该方案兼容性更好,但会继续让 HTTP 成为隐式默认类型,不符合当前无兼容包袱下的最佳模型。
|
||||
|
||||
### 2. defaults 分为通用和领域分组
|
||||
|
||||
建议配置形态:
|
||||
|
||||
```yaml
|
||||
runtime:
|
||||
maxConcurrentChecks: 20
|
||||
|
||||
defaults:
|
||||
interval: "30s"
|
||||
timeout: "10s"
|
||||
http:
|
||||
method: GET
|
||||
maxBodyBytes: "100MB"
|
||||
command:
|
||||
cwd: "."
|
||||
maxOutputBytes: "100MB"
|
||||
```
|
||||
|
||||
通用默认值只覆盖所有 checker 都共享的调度与超时字段,领域默认值只覆盖对应 target type。target 自身配置优先级高于 defaults。
|
||||
|
||||
替代方案:继续使用 `defaults.method`、`defaults.headers` 等 HTTP 字段。该方案会在 command target 中产生无意义字段,因此不采用。
|
||||
|
||||
### 3. 默认 expect 是逻辑默认值
|
||||
|
||||
当用户未显式配置对应状态类 expect 时,runner 在校验阶段应用领域默认值,而不是把默认值写回用户配置。
|
||||
|
||||
HTTP 默认:`status: [200]`。
|
||||
|
||||
Command 默认:`exitCode: [0]`。
|
||||
|
||||
示例:
|
||||
|
||||
```yaml
|
||||
expect:
|
||||
body:
|
||||
- contains: "ok"
|
||||
```
|
||||
|
||||
该 HTTP target 仍然先检查 `status == 200`,再检查 body。这样用户只写内容检查时不会把 HTTP 500 错误响应误判为 UP。
|
||||
|
||||
替代方案:只有完全不写 `expect` 时才应用默认值。该方案会让“只写 body”绕过 status 检查,不符合默认成功语义,因此不采用。
|
||||
|
||||
### 4. Expect pipeline 使用固定阶段顺序和有序规则数组
|
||||
|
||||
HTTP 顺序:
|
||||
|
||||
```text
|
||||
status -> duration -> headers -> body[0] -> body[1] -> ...
|
||||
```
|
||||
|
||||
Command 顺序:
|
||||
|
||||
```text
|
||||
exitCode -> duration -> stdout[0] -> stdout[1] -> ... -> stderr[0] -> stderr[1] -> ...
|
||||
```
|
||||
|
||||
`body`、`stdout`、`stderr` 使用数组表达配置顺序:
|
||||
|
||||
```yaml
|
||||
expect:
|
||||
body:
|
||||
- contains: "healthy"
|
||||
- json:
|
||||
path: "$.status"
|
||||
equals: "ok"
|
||||
- regex: '"version":"\\d+\\.\\d+"'
|
||||
```
|
||||
|
||||
理由:对象字段天然更像无序集合,不适合表达用户指定的检查顺序。数组规则可以直接生成 `path`,例如 `expect.body[1].json($.status)`,方便失败定位。
|
||||
|
||||
替代方案:保留对象结构并约定 contains/regex/json/css/xpath 固定顺序。该方案无法满足“按配置文件中的配置顺序依次检查”的要求,因此不采用。
|
||||
|
||||
### 5. 复用通用值操作符,但保持领域 expect 名称
|
||||
|
||||
保留并扩展现有操作符:`equals`、`contains`、`match`、`empty`、`exists`、`gte`、`lte`、`gt`、`lt`。这些操作符可用于 HTTP header、HTTP body 提取值、command stdout/stderr 文本等。
|
||||
|
||||
领域名称保持专用:HTTP 使用 `status`,command 使用 `exitCode`;HTTP body 可使用 `json/css/xpath`,command 输出只使用文本规则和通用操作符。
|
||||
|
||||
替代方案:把所有值统一抽象成 `status`、`metadata`、`payload`。该方案过度泛化,会让 YAML 对使用者不直观,因此不采用。
|
||||
|
||||
### 6. Runner 负责按需产生 Observation
|
||||
|
||||
HTTP runner 不应总是读取完整 response body。它先发起请求并取得 status、headers 和 duration,再运行 status/duration/headers 阶段;只有配置中存在 body 规则且前置阶段通过时,才读取 body,并受 `maxBodyBytes` 限制。
|
||||
|
||||
Command runner 需要执行命令并收集 exitCode、duration、stdout、stderr。stdout 和 stderr 合计受 `maxOutputBytes` 限制,默认 `100MB`。命令超时或输出超限时,runner 产生 `success=false` 和 `failure.kind=error`。
|
||||
|
||||
替代方案:runner 总是完整产生所有字段,再交给 expect。该方案实现简单,但无法真正快速失败,也无法避免不必要的资源读取,因此不采用。
|
||||
|
||||
### 7. Command 执行不经过 shell
|
||||
|
||||
command target 使用 `exec + args`,实现阶段优先使用 Bun 可用的子进程 API,并禁止默认 shell 展开。
|
||||
|
||||
```yaml
|
||||
command:
|
||||
exec: "pgrep"
|
||||
args: ["nginx"]
|
||||
cwd: "."
|
||||
env:
|
||||
LANG: "C"
|
||||
```
|
||||
|
||||
`cwd` 相对配置文件所在目录解析。`env` 默认继承当前进程环境并允许覆盖指定键。v1 不支持 stdin,避免命令阻塞。
|
||||
|
||||
替代方案:允许 `shell: "pgrep nginx | wc -l"`。该方案更灵活,但引入转义、注入和跨平台 shell 差异,不适合作为第一版默认能力。
|
||||
|
||||
### 8. 全局并发限制由 ProbeEngine 统一执行
|
||||
|
||||
`runtime.maxConcurrentChecks` 默认 20。调度仍按 interval 分组触发,但每个目标进入全局并发池后再执行,避免同一 tick 或多个 tick 同时启动过多 HTTP 请求和本地进程。
|
||||
|
||||
理由:command target 可能启动本地进程,继续无限 `Promise.allSettled` 会有资源风险。全局限制比按组限制更容易理解,也能覆盖不同 interval 组同时触发的情况。
|
||||
|
||||
替代方案:为 HTTP 和 command 分别设置并发上限。该方案更精细,但增加配置复杂度,当前需求只要求全局默认值。
|
||||
|
||||
### 9. CheckResult 使用结构化 failure
|
||||
|
||||
结果模型区分 runner 执行失败和 expect 不匹配:
|
||||
|
||||
```ts
|
||||
interface CheckFailure {
|
||||
kind: "error" | "mismatch";
|
||||
phase: "status" | "duration" | "headers" | "body" | "exitCode" | "stdout" | "stderr";
|
||||
path: string;
|
||||
expected?: unknown;
|
||||
actual?: unknown;
|
||||
message: string;
|
||||
}
|
||||
```
|
||||
|
||||
`success=false` 表示 runner 未能正常产生可校验结果,例如网络错误、超时、命令启动失败、输出超限。`matched=false` 表示 runner 执行成功但 expect 不匹配。`failure` 字段存储首个失败原因,实际值需要截断,避免超长内容或敏感内容进入数据库和 API。
|
||||
|
||||
替代方案:继续只存 `error` 字符串。该方案无法区分执行失败与规则不匹配,也不能准确定位失败 path,因此不采用。
|
||||
|
||||
### 10. 存储、API、Dashboard 改为 checker 通用语义
|
||||
|
||||
SQLite schema 建议从 HTTP-only 字段调整为:
|
||||
|
||||
```text
|
||||
targets:
|
||||
id, name, type, target, config, interval_ms, timeout_ms, expect
|
||||
|
||||
check_results:
|
||||
id, target_id, timestamp, success, matched, duration_ms, status_detail, failure
|
||||
```
|
||||
|
||||
`target` 是用于展示和搜索的目标摘要,例如 HTTP URL 或 command 命令行摘要;`config` 持久化解析后的领域配置 JSON;`status_detail` 存储领域状态摘要,例如 `HTTP 200` 或 `exitCode=1`。
|
||||
|
||||
API 共享类型使用 `durationMs`、`statusDetail`、`failure`,Dashboard 表格展示“类型、目标、状态、耗时、最近失败原因、趋势”。HTTP 详情可显示 status code,command 详情可显示 exit code,但列表层不使用 HTTP-only 列名。
|
||||
|
||||
替代方案:继续保留 `url`、`method`、`status_code`、`latency_ms` 并为 command 填空。该方案会把领域语义混在一起,后续扩展成本高,因此不采用。
|
||||
|
||||
### 11. Size 字符串解析
|
||||
|
||||
新增 size 解析支持 `B`、`KB`、`MB`、`GB`,默认 `100MB` 等于 `104857600` bytes。HTTP `maxBodyBytes` 限制单次 body 读取,command `maxOutputBytes` 限制 stdout 和 stderr 合计读取。
|
||||
|
||||
理由:YAML 直接写字节数可读性差,二进制单位更适合内存和 buffer 限制。
|
||||
|
||||
替代方案:复用 duration 解析或只接受 number。前者语义不匹配,后者配置可读性差。
|
||||
|
||||
## Risks / Trade-offs
|
||||
|
||||
- [Risk] `maxConcurrentChecks=20` 且单次读取上限为 `100MB` 时理论内存峰值较高 → [Mitigation] 提供全局并发限制和 per-target/per-default 读取上限,文档明确资源上限由用户配置共同决定。
|
||||
- [Risk] 结构化失败信息可能包含敏感响应片段或命令输出 → [Mitigation] 只存首个失败原因,`actual` 做长度截断,默认不持久化完整 body/stdout/stderr。
|
||||
- [Risk] command checker 允许执行本地命令,有误配置或高开销命令风险 → [Mitigation] 不支持 shell,强制 timeout,限制输出大小,使用全局并发限制。
|
||||
- [Risk] 不兼容旧配置会导致现有样例和测试全部失效 → [Mitigation] 项目未上线,实施时同步更新 README、示例配置、单元测试和 smoke test。
|
||||
- [Risk] SQLite schema 重建会丢失旧数据 → [Mitigation] 当前无上线数据,不做迁移;若后续需要升级已部署实例,应另起兼容迁移 change。
|
||||
|
||||
## Migration Plan
|
||||
|
||||
- 更新类型定义、配置解析和 README 示例,先让新 YAML 契约成为唯一入口。
|
||||
- 重构存储 schema 和共享 API 类型,再更新 Dashboard 使用新字段。
|
||||
- 引入 expect 规则数组和结构化 failure,迁移 HTTP runner 到新 pipeline。
|
||||
- 添加 command runner,并接入 ProbeEngine 的 runner 选择与全局并发限制。
|
||||
- 更新测试覆盖配置、HTTP expect、command expect、存储、API、Dashboard 和 smoke test。
|
||||
- 运行 `bun run check` 和 `bun run verify`,确保完整质量门禁通过。
|
||||
|
||||
## Open Questions
|
||||
|
||||
无。当前讨论已确认默认 HTTP status 使用 `[200]`、默认并发限制使用全局配置、HTTP body 与 command 输出默认上限均为 `100MB`。
|
||||
@@ -0,0 +1,39 @@
|
||||
## Why
|
||||
|
||||
当前系统以 HTTP 请求作为唯一 checker 形态,`target`、`expect`、存储、API 和 Dashboard 都围绕 URL、HTTP method、status code 与 response body 建模,无法自然表达本地命令检查等非 HTTP 场景。项目尚未上线,没有兼容性约束,适合一次性重构为面向多种 checker 类型的清晰模型。
|
||||
|
||||
## What Changes
|
||||
|
||||
- **BREAKING**: 移除顶层 `target.url`、`target.method`、`target.headers`、`target.body` 配置形态,改为 `target.type` 判别不同 checker 类型,并将领域字段放入 `http` 或 `command` 分组。
|
||||
- **BREAKING**: `expect.body` 从对象分组改为有序规则数组,按配置顺序执行并快速失败。
|
||||
- 引入 `http` target 类型,支持 HTTP URL、method、headers、body、最大 body 读取字节数和 HTTP 专用 expect。
|
||||
- 引入 `command` target 类型,支持本地命令 `exec + args`、`cwd`、`env`、最大输出读取字节数和 command 专用 expect。
|
||||
- 为不同 checker 类型提供领域默认成功语义:HTTP 默认 `expect.status: [200]`,command 默认 `expect.exitCode: [0]`。
|
||||
- 引入全局并发限制 `runtime.maxConcurrentChecks`,默认值为 20。
|
||||
- 引入 size 配置解析,支持 `B`、`KB`、`MB`、`GB`,HTTP `maxBodyBytes` 和 command `maxOutputBytes` 默认均为 `100MB`。
|
||||
- 调整 expect 执行管线:先执行状态类检查,再执行耗时检查,再执行元数据或内容检查;同一内容字段内部按数组顺序检查,任一失败立即返回结构化失败信息。
|
||||
- 将 check result 的失败信息结构化入库,区分 runner 执行错误与 expect 不匹配,便于后续追查。
|
||||
- 将 DB、API、Dashboard 从 HTTP-only 字段命名调整为通用 checker 展示模型,同时保留 HTTP 和 command 的领域专用细节。
|
||||
- 同步更新 README、示例配置和测试,覆盖 typed target、默认 expect、快速失败、输出限制、失败信息和 Dashboard 展示。
|
||||
|
||||
## Capabilities
|
||||
|
||||
### New Capabilities
|
||||
|
||||
- `command-checker`: 定义本地命令 checker 的配置、执行、安全边界、默认成功语义、输出限制和 expect 校验。
|
||||
|
||||
### Modified Capabilities
|
||||
|
||||
- `probe-config`: YAML 配置从 HTTP-only target 改为 typed target,新增 runtime 并发限制、HTTP/command 默认配置和 size 字符串解析。
|
||||
- `probe-engine`: 调度引擎从固定 HTTP fetch 改为按 target type 选择 runner,并在全局并发限制下执行检查。
|
||||
- `expect-body-checkers`: HTTP body expect 改为有序规则数组,通用值操作符可复用于 stdout/stderr/header/body 等不同字段。
|
||||
- `probe-data-store`: targets 和 check_results schema 从 HTTP-only 字段改为 checker 通用字段,并持久化结构化失败信息。
|
||||
- `probe-api`: API 响应从 URL/method/statusCode/latencyMs 为中心改为 type/target/durationMs/statusDetail/failure 等通用 checker 契约。
|
||||
- `probe-dashboard`: Dashboard 从 HTTP 拨测视图调整为 checker 通用视图,展示类型、目标、耗时、最近失败原因和领域状态详情。
|
||||
|
||||
## Impact
|
||||
|
||||
- 影响后端类型定义、配置加载校验、调度执行、HTTP runner、command runner、expect 校验模块、SQLite schema、聚合查询和 API 映射。
|
||||
- 影响前后端共享 API 类型和 Dashboard 表格、详情、历史记录、趋势图展示字段。
|
||||
- 影响 README、`probes.example.yaml`、单元测试和 smoke test 配置样例。
|
||||
- 不引入新依赖,优先复用 Bun、TypeScript、现有 cheerio/xpath 和 SQLite 能力。
|
||||
@@ -0,0 +1,78 @@
|
||||
## ADDED 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 规则
|
||||
@@ -0,0 +1,126 @@
|
||||
## MODIFIED Requirements
|
||||
|
||||
### Requirement: 响应体多种校验方法
|
||||
系统 SHALL 支持对 HTTP 响应体进行五种可组合的校验方法:contains(子串)、regex(正则)、json(JSONPath)、css(CSS 选择器)、xpath(XPath)。这些方法 MUST 配置在 `expect.body` 有序数组中。
|
||||
|
||||
#### Scenario: contains 子串匹配
|
||||
- **WHEN** HTTP target 配置 `expect.body: [{contains: "healthy"}]`,且响应体包含 `"healthy"`
|
||||
- **THEN** 系统 SHALL 判定该 body 规则通过
|
||||
|
||||
#### Scenario: contains 不匹配
|
||||
- **WHEN** HTTP target 配置 `expect.body: [{contains: "healthy"}]`,且响应体不包含该文本
|
||||
- **THEN** 系统 SHALL 判定 matched 为 false,并记录该规则的 failure.path
|
||||
|
||||
#### Scenario: regex 正则匹配
|
||||
- **WHEN** HTTP target 配置 `expect.body: [{regex: '"status"\\s*:\\s*"ok"'}]`,且响应体匹配该正则
|
||||
- **THEN** 系统 SHALL 判定该 body 规则通过
|
||||
|
||||
#### Scenario: regex 不匹配
|
||||
- **WHEN** HTTP target 配置 regex body 规则,且响应体不匹配该正则
|
||||
- **THEN** 系统 SHALL 判定 matched 为 false,并记录该规则的 failure.path
|
||||
|
||||
#### Scenario: json JSONPath 等值匹配
|
||||
- **WHEN** HTTP target 配置 `expect.body: [{json: {path: "$.status", equals: "ok"}}]`,且响应 JSON 中 `$.status` 值为 `"ok"`
|
||||
- **THEN** 系统 SHALL 判定该 body 规则通过
|
||||
|
||||
#### Scenario: json JSONPath 值不匹配
|
||||
- **WHEN** HTTP target 配置 json body 规则,且提取值不符合期望
|
||||
- **THEN** 系统 SHALL 判定 matched 为 false,并记录包含 JSONPath 的 failure.path
|
||||
|
||||
#### Scenario: json 解析失败
|
||||
- **WHEN** HTTP target 配置了 json body 规则但响应体不是合法 JSON
|
||||
- **THEN** 系统 SHALL 判定 matched 为 false
|
||||
|
||||
#### Scenario: css 选择器匹配
|
||||
- **WHEN** HTTP target 配置 `expect.body: [{css: {selector: "div#health", equals: "OK"}}]`,且 HTML 中存在 `div#health` 元素文本为 `"OK"`
|
||||
- **THEN** 系统 SHALL 判定该 body 规则通过
|
||||
|
||||
#### Scenario: css 选择器匹配属性值
|
||||
- **WHEN** HTTP target 配置 css 规则带 `attr: "content"` 用于提取属性,且属性值匹配期望
|
||||
- **THEN** 系统 SHALL 判定该 body 规则通过
|
||||
|
||||
#### Scenario: css 选择器无匹配元素
|
||||
- **WHEN** HTTP target 配置了 css 选择器但 HTML 中无匹配元素
|
||||
- **THEN** 系统 SHALL 判定 matched 为 false
|
||||
|
||||
#### Scenario: xpath 表达式匹配
|
||||
- **WHEN** HTTP target 配置 `expect.body: [{xpath: {path: "/root/status/text()", equals: "ok"}}]`,且 XML 中 `/root/status` 节点文本为 `"ok"`
|
||||
- **THEN** 系统 SHALL 判定该 body 规则通过
|
||||
|
||||
#### Scenario: xpath 表达式无匹配节点
|
||||
- **WHEN** HTTP target 配置了 xpath 表达式但 XML 中无匹配节点
|
||||
- **THEN** 系统 SHALL 判定 matched 为 false
|
||||
|
||||
### Requirement: 多种 body 校验方法 AND 组合
|
||||
系统 SHALL 支持在 `expect.body` 数组中同时配置多种 body 校验方法,所有方法均通过时 matched 方为 true。
|
||||
|
||||
#### Scenario: 多种方法全部通过
|
||||
- **WHEN** HTTP target 的 `expect.body` 数组依次配置 contains、json、regex,且全部通过
|
||||
- **THEN** 系统 SHALL 判定 matched 为 true
|
||||
|
||||
#### Scenario: 多种方法任一失败
|
||||
- **WHEN** HTTP target 的 `expect.body` 数组第一条 contains 不通过,后续还有 json 规则
|
||||
- **THEN** 系统 SHALL 判定 matched 为 false,且不再检查后续 json 规则
|
||||
|
||||
### Requirement: 操作符系统
|
||||
系统 SHALL 支持对提取值和文本值使用以下操作符进行比较:equals(默认等值)、contains(子串包含)、match(正则匹配)、empty(空值判断)、exists(存在性判断)、gte/lte/gt/lt(数值比较)。
|
||||
|
||||
#### Scenario: 标量值隐式 equals
|
||||
- **WHEN** 配置的期望值为标量(字符串/数字/布尔/null),如 `equals: "ok"`
|
||||
- **THEN** 系统 SHALL 使用 equals 操作符,对实际值做严格相等比较
|
||||
|
||||
#### Scenario: 显式 contains 操作符
|
||||
- **WHEN** 配置 `{contains: "success"}`,且实际值包含 `"success"`
|
||||
- **THEN** 系统 SHALL 判定该规则通过
|
||||
|
||||
#### Scenario: 显式 match 操作符
|
||||
- **WHEN** 配置 `{match: '\\d+\\.\\d+\\.\\d+'}`,且实际值匹配该正则
|
||||
- **THEN** 系统 SHALL 判定该规则通过
|
||||
|
||||
#### Scenario: empty 操作符判断为空
|
||||
- **WHEN** 配置 `{empty: true}`,且实际值为空数组 `[]`
|
||||
- **THEN** 系统 SHALL 判定该规则通过
|
||||
|
||||
#### Scenario: empty 操作符判断非空
|
||||
- **WHEN** 配置 `{empty: false}`,且实际值为 `[1, 2]`
|
||||
- **THEN** 系统 SHALL 判定该规则通过
|
||||
|
||||
#### Scenario: exists 操作符判断存在
|
||||
- **WHEN** 配置 `{exists: false}`,且实际值不存在
|
||||
- **THEN** 系统 SHALL 判定该规则通过
|
||||
|
||||
#### Scenario: gte 数值比较
|
||||
- **WHEN** 配置 `{gte: 10}`,且实际值为 `15`(数字)
|
||||
- **THEN** 系统 SHALL 判定该规则通过
|
||||
|
||||
#### Scenario: gt/lt 数值比较
|
||||
- **WHEN** 配置 `{gt: 0, lt: 1000}`,且实际值为 `500`
|
||||
- **THEN** 系统 SHALL 对同一字段进行多操作符复合比较,全部通过则该规则通过
|
||||
|
||||
### Requirement: 响应头校验
|
||||
系统 SHALL 支持通过 `expect.headers` 配置对 HTTP 响应头进行键值规则校验,header 名称匹配 MUST 不区分大小写。
|
||||
|
||||
#### Scenario: 响应头匹配
|
||||
- **WHEN** HTTP target 配置 `expect.headers: {"Content-Type": {contains: "application/json"}}`,且响应包含该 header 且值匹配
|
||||
- **THEN** 系统 SHALL 判定 headers 阶段通过
|
||||
|
||||
#### Scenario: 响应头不匹配
|
||||
- **WHEN** HTTP target 配置 `expect.headers: {"Content-Type": {equals: "application/json"}}`,且响应 header 值为 `"text/html"`
|
||||
- **THEN** 系统 SHALL 判定 matched 为 false
|
||||
|
||||
#### Scenario: 响应头缺失
|
||||
- **WHEN** HTTP target 配置了某个 header 但响应中不存在该 header
|
||||
- **THEN** 系统 SHALL 判定 matched 为 false
|
||||
|
||||
## ADDED Requirements
|
||||
|
||||
### 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 摘要,而不是持久化完整响应体或命令输出
|
||||
@@ -0,0 +1,54 @@
|
||||
## MODIFIED Requirements
|
||||
|
||||
### Requirement: 总览统计 API
|
||||
系统 SHALL 提供 `GET /api/summary` 端点,返回所有目标的总体统计信息。
|
||||
|
||||
#### Scenario: 获取总览统计
|
||||
- **WHEN** 客户端请求 `GET /api/summary`
|
||||
- **THEN** 系统 SHALL 返回 JSON 包含 total(总目标数)、up(正常数)、down(异常数)、avgDurationMs(所有目标平均耗时)、lastCheckTime(最近一次检查时间)
|
||||
|
||||
### Requirement: 目标列表 API
|
||||
系统 SHALL 提供 `GET /api/targets` 端点,返回所有 typed target 及其最新状态和统计摘要。
|
||||
|
||||
#### Scenario: 获取目标列表
|
||||
- **WHEN** 客户端请求 `GET /api/targets`
|
||||
- **THEN** 系统 SHALL 返回 JSON 数组,每个元素包含目标基本信息(id、name、type、target、interval)、最近一次检查结果(timestamp、success、matched、durationMs、statusDetail、failure)和统计摘要(totalChecks、availability、avgDurationMs、p99DurationMs)
|
||||
|
||||
#### Scenario: 目标无历史记录
|
||||
- **WHEN** 某目标尚未执行过任何检查
|
||||
- **THEN** 其 latestCheck 为 null,stats 中 totalChecks 为 0
|
||||
|
||||
### Requirement: 历史记录 API
|
||||
系统 SHALL 提供 `GET /api/targets/:id/history` 端点,返回指定目标的最近 N 条检查记录。
|
||||
|
||||
#### Scenario: 获取最近历史记录
|
||||
- **WHEN** 客户端请求 `GET /api/targets/1/history?limit=20`
|
||||
- **THEN** 系统 SHALL 返回最多 20 条检查记录,按时间倒序排列,且每条包含 success、matched、durationMs、statusDetail 和 failure
|
||||
|
||||
#### Scenario: 使用默认 limit
|
||||
- **WHEN** 客户端请求 `GET /api/targets/1/history`(未指定 limit)
|
||||
- **THEN** 系统 SHALL 默认返回最近 20 条记录
|
||||
|
||||
### Requirement: 趋势聚合 API
|
||||
系统 SHALL 提供 `GET /api/targets/:id/trend` 端点,返回指定目标按小时聚合的趋势数据。
|
||||
|
||||
#### Scenario: 获取 24 小时趋势
|
||||
- **WHEN** 客户端请求 `GET /api/targets/1/trend?hours=24`
|
||||
- **THEN** 系统 SHALL 返回按小时分组的聚合数据,每个数据点包含 hour、avgDurationMs、availability、totalChecks
|
||||
|
||||
#### Scenario: 使用默认时间范围
|
||||
- **WHEN** 客户端请求 `GET /api/targets/1/trend`(未指定 hours)
|
||||
- **THEN** 系统 SHALL 默认返回最近 24 小时的趋势数据
|
||||
|
||||
## ADDED Requirements
|
||||
|
||||
### 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
|
||||
@@ -0,0 +1,102 @@
|
||||
## MODIFIED Requirements
|
||||
|
||||
### Requirement: YAML 配置文件格式
|
||||
系统 SHALL 支持通过 YAML 配置文件定义全部运行参数,包括 server 配置、runtime 配置、checker 默认值和 typed target 列表。target MUST 使用 `type` 字段声明 checker 类型,HTTP 领域字段 MUST 放在 `http` 分组,command 领域字段 MUST 放在 `command` 分组。
|
||||
|
||||
#### Scenario: 完整配置文件解析
|
||||
- **WHEN** 系统启动并读取包含 server、runtime、defaults、targets 的 YAML 配置文件
|
||||
- **THEN** 系统 SHALL 正确解析所有字段并用于初始化服务、调度引擎和对应 checker runner
|
||||
|
||||
#### Scenario: 最简 HTTP 配置文件解析
|
||||
- **WHEN** 系统读取只包含一个 `type: http` target 和 `http.url` 的 YAML 配置文件(省略 server、runtime、defaults 和 expect)
|
||||
- **THEN** 系统 SHALL 使用内置默认值填充未指定的字段(host=127.0.0.1, port=3000, dir=./data, interval=30s, timeout=10s, runtime.maxConcurrentChecks=20, http.method=GET, http.maxBodyBytes=100MB)
|
||||
|
||||
#### Scenario: 最简 command 配置文件解析
|
||||
- **WHEN** 系统读取只包含一个 `type: command` target 和 `command.exec` 的 YAML 配置文件
|
||||
- **THEN** 系统 SHALL 使用内置默认值填充未指定的字段(interval=30s, timeout=10s, command.cwd 为配置文件所在目录, command.maxOutputBytes=100MB)
|
||||
|
||||
#### Scenario: per-target 配置覆盖全局默认值
|
||||
- **WHEN** 某个 target 指定 interval、timeout 或对应领域分组中的默认字段
|
||||
- **THEN** 该 target SHALL 使用其自身的值,不受 defaults 中对应字段影响
|
||||
|
||||
### Requirement: 配置校验
|
||||
系统 SHALL 在启动时对 YAML 配置进行完整校验,校验失败时以非零状态退出并输出清晰的错误信息。
|
||||
|
||||
#### Scenario: target 缺少必填字段
|
||||
- **WHEN** YAML 中某个 target 缺少 name 或 type 字段
|
||||
- **THEN** 系统 SHALL 以错误退出,提示哪个 target 缺少哪个字段
|
||||
|
||||
#### Scenario: HTTP target 缺少 url
|
||||
- **WHEN** YAML 中某个 target 配置 `type: http` 但缺少 `http.url`
|
||||
- **THEN** 系统 SHALL 以错误退出,提示该 target 缺少 http.url 字段
|
||||
|
||||
#### Scenario: command target 缺少 exec
|
||||
- **WHEN** YAML 中某个 target 配置 `type: command` 但缺少 `command.exec`
|
||||
- **THEN** 系统 SHALL 以错误退出,提示该 target 缺少 command.exec 字段
|
||||
|
||||
#### Scenario: target type 非法
|
||||
- **WHEN** YAML 中某个 target 的 type 不是 `http` 或 `command`
|
||||
- **THEN** 系统 SHALL 以错误退出,提示不支持的 target type
|
||||
|
||||
#### Scenario: target name 重复
|
||||
- **WHEN** YAML 中存在两个 name 相同的 target
|
||||
- **THEN** 系统 SHALL 以错误退出,提示重复的 name
|
||||
|
||||
#### Scenario: interval 格式非法
|
||||
- **WHEN** interval 或 timeout 值不是有效的时长格式(如 `30s`、`5m`、`500ms`)
|
||||
- **THEN** 系统 SHALL 以错误退出并提示格式错误
|
||||
|
||||
#### Scenario: maxConcurrentChecks 非法
|
||||
- **WHEN** runtime.maxConcurrentChecks 不是正整数
|
||||
- **THEN** 系统 SHALL 以错误退出并提示 runtime.maxConcurrentChecks 格式错误
|
||||
|
||||
#### Scenario: size 格式非法
|
||||
- **WHEN** maxBodyBytes 或 maxOutputBytes 值不是有效的 size 格式
|
||||
- **THEN** 系统 SHALL 以错误退出并提示支持 B、KB、MB、GB 格式
|
||||
|
||||
### Requirement: expect 配置增强
|
||||
系统 SHALL 支持 typed target 的领域专用 expect 配置,包括 HTTP 的 `status`、`headers`、`body` 和 command 的 `exitCode`、`stdout`、`stderr`。内容类 expect MUST 使用数组表达配置顺序。
|
||||
|
||||
#### Scenario: 解析 HTTP expect 配置
|
||||
- **WHEN** YAML 配置文件中 HTTP target 的 expect 包含 status、headers、body 规则数组及内部方法
|
||||
- **THEN** 系统 SHALL 正确解析并存储为 HTTP target 的 expect 字段
|
||||
|
||||
#### Scenario: 解析 command expect 配置
|
||||
- **WHEN** YAML 配置文件中 command target 的 expect 包含 exitCode、stdout 和 stderr 规则数组
|
||||
- **THEN** 系统 SHALL 正确解析并存储为 command target 的 expect 字段
|
||||
|
||||
#### Scenario: 解析 body 有序规则数组
|
||||
- **WHEN** YAML 中 HTTP target 配置 `expect.body` 为 contains、json、regex 三个数组项
|
||||
- **THEN** 系统 SHALL 保留数组顺序,供执行阶段按配置顺序快速失败
|
||||
|
||||
#### Scenario: 不配置 HTTP status
|
||||
- **WHEN** HTTP target 未配置 `expect.status`
|
||||
- **THEN** 系统 SHALL 在执行 expect 时使用默认 `status: [200]` 语义
|
||||
|
||||
#### Scenario: 不配置 command exitCode
|
||||
- **WHEN** command target 未配置 `expect.exitCode`
|
||||
- **THEN** 系统 SHALL 在执行 expect 时使用默认 `exitCode: [0]` 语义
|
||||
|
||||
## ADDED Requirements
|
||||
|
||||
### 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
|
||||
@@ -0,0 +1,71 @@
|
||||
## MODIFIED Requirements
|
||||
|
||||
### Requirement: 总览统计卡片
|
||||
Dashboard SHALL 在页面顶部展示总览统计卡片,包含总目标数、正常数、异常数和平均耗时。
|
||||
|
||||
#### Scenario: 展示统计卡片
|
||||
- **WHEN** 用户打开 Dashboard 页面
|
||||
- **THEN** 页面顶部 SHALL 显示 4 个统计卡片:全部目标数、正常目标数、异常目标数、所有目标平均耗时
|
||||
|
||||
#### Scenario: 统计数据自动刷新
|
||||
- **WHEN** 页面处于打开状态
|
||||
- **THEN** 统计卡片 SHALL 每 5-10 秒自动刷新数据
|
||||
|
||||
### Requirement: 目标列表表格
|
||||
Dashboard SHALL 展示所有 checker target 的列表表格,包含名称、类型、目标摘要、当前状态、最新耗时、最近失败原因和迷你趋势线。
|
||||
|
||||
#### Scenario: 展示目标列表
|
||||
- **WHEN** 用户打开 Dashboard 页面
|
||||
- **THEN** 页面 SHALL 显示表格,每行包含目标名称、类型、目标摘要、状态指示圆点(UP / DOWN)、最新耗时值、最近失败原因摘要、迷你 Sparkline 趋势线
|
||||
|
||||
#### Scenario: 状态指示圆点
|
||||
- **WHEN** 目标最近一次检查 success=true 且 matched=true
|
||||
- **THEN** 状态圆点 SHALL 显示为绿色(UP)
|
||||
- **WHEN** 目标最近一次检查 success=false 或 matched=false
|
||||
- **THEN** 状态圆点 SHALL 显示为红色(DOWN)
|
||||
|
||||
### Requirement: 可展开的目标详情面板
|
||||
Dashboard SHALL 支持在目标列表中展开某行,显示该目标的详细状态、统计摘要、趋势图和最近历史记录。
|
||||
|
||||
#### Scenario: 展开目标详情
|
||||
- **WHEN** 用户点击目标列表中的某一行
|
||||
- **THEN** 该行下方 SHALL 展开详情面板,包含:可用率百分比、平均耗时、P99 耗时、24 小时耗时趋势折线图、最近 5-10 条检查记录列表、领域状态详情和失败信息
|
||||
|
||||
#### Scenario: 收起目标详情
|
||||
- **WHEN** 用户再次点击已展开的目标行
|
||||
- **THEN** 详情面板 SHALL 收起
|
||||
|
||||
#### Scenario: 趋势图按需加载
|
||||
- **WHEN** 用户展开某个目标的详情面板
|
||||
- **THEN** 系统 SHALL 此时请求该目标的趋势数据,而非页面加载时预加载所有目标的趋势数据
|
||||
|
||||
### Requirement: 历史记录展示
|
||||
Dashboard SHALL 在目标详情面板中展示最近的检查记录,包含时间、领域状态详情、耗时、成功/失败标记和失败信息。
|
||||
|
||||
#### Scenario: 展示历史记录
|
||||
- **WHEN** 用户展开目标详情面板
|
||||
- **THEN** 面板 SHALL 显示最近检查记录列表,每条包含时间戳、statusDetail(如 HTTP 200 或 exitCode=1)、耗时毫秒数、UP/DOWN 标记和 failure.message(如存在)
|
||||
|
||||
### Requirement: 趋势图可视化
|
||||
Dashboard SHALL 使用 recharts 库渲染趋势图,包括目标列表中的迷你 Sparkline 和详情面板中的完整折线图。
|
||||
|
||||
#### Scenario: 表格行内迷你趋势线
|
||||
- **WHEN** 目标列表表格渲染
|
||||
- **THEN** 每行 SHALL 包含一个基于 recharts 的迷你折线图,展示最近的耗时趋势
|
||||
|
||||
#### Scenario: 详情面板完整趋势图
|
||||
- **WHEN** 用户展开目标详情面板
|
||||
- **THEN** 面板 SHALL 展示基于 recharts 的完整折线图,X 轴为时间(小时),Y 轴为平均耗时,并标注可用率
|
||||
|
||||
## ADDED Requirements
|
||||
|
||||
### 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,并将目标摘要显示为命令摘要
|
||||
@@ -0,0 +1,74 @@
|
||||
## MODIFIED Requirements
|
||||
|
||||
### Requirement: SQLite 数据库初始化
|
||||
系统 SHALL 使用 Bun 内置 `bun:sqlite` 模块在配置的数据目录下创建 SQLite 数据库文件,并以 WAL 模式运行。数据库 schema MUST 支持 typed checker target 和结构化检查结果。
|
||||
|
||||
#### Scenario: 首次启动创建数据库
|
||||
- **WHEN** 指定的数据目录下不存在数据库文件
|
||||
- **THEN** 系统 SHALL 创建数据库文件并初始化包含 type、target、config、duration_ms、status_detail、failure 等字段的 targets 和 check_results 表
|
||||
|
||||
#### Scenario: 数据目录不存在
|
||||
- **WHEN** 配置的数据目录路径不存在
|
||||
- **THEN** 系统 SHALL 自动创建该目录
|
||||
|
||||
#### Scenario: 数据库已存在时启动
|
||||
- **WHEN** 数据库文件已存在
|
||||
- **THEN** 系统 SHALL 直接打开数据库,不重新建表
|
||||
|
||||
### Requirement: targets 表同步
|
||||
系统 SHALL 在启动时将 YAML 配置中的目标列表同步到 SQLite targets 表,并持久化 target 类型、展示摘要、领域配置、调度配置和 expect 配置。
|
||||
|
||||
#### Scenario: 首次同步目标
|
||||
- **WHEN** 数据库为空且 YAML 中定义了 N 个 typed target
|
||||
- **THEN** 系统 SHALL 将所有目标插入 targets 表,包含 name、type、target、config、interval_ms、timeout_ms 和 expect
|
||||
|
||||
#### Scenario: 配置变更后重新同步
|
||||
- **WHEN** YAML 配置发生变更(新增、删除或修改目标)后重启
|
||||
- **THEN** 系统 SHALL 根据 name 字段匹配:新增的插入、删除的移除、修改的更新
|
||||
|
||||
### Requirement: check_results 表追加写入
|
||||
系统 SHALL 将每次检查结果追加写入 check_results 表,不更新或删除已有记录。
|
||||
|
||||
#### Scenario: 写入检查结果
|
||||
- **WHEN** 一次 checker 执行完成
|
||||
- **THEN** 系统 SHALL 插入一条包含 target_id、timestamp、success、matched、duration_ms、status_detail、failure 的记录
|
||||
|
||||
#### Scenario: 写入结构化失败信息
|
||||
- **WHEN** checker 执行失败或 expect 不匹配
|
||||
- **THEN** 系统 SHALL 将首个失败原因序列化写入 failure 字段
|
||||
|
||||
### Requirement: 聚合查询支持
|
||||
数据存储 SHALL 支持按时间段聚合查询,用于计算可用率、平均耗时、P99 耗时等统计指标。
|
||||
|
||||
#### Scenario: 计算目标可用率
|
||||
- **WHEN** 查询某目标在指定时间范围内的可用率
|
||||
- **THEN** 系统 SHALL 返回 UP (success=true AND matched=true) 的记录数占总记录数的百分比
|
||||
|
||||
#### Scenario: 计算目标平均耗时
|
||||
- **WHEN** 查询某目标在指定时间范围内的平均耗时
|
||||
- **THEN** 系统 SHALL 返回 duration_ms 的平均值(仅计算 success=true 的记录)
|
||||
|
||||
#### Scenario: 按小时聚合趋势数据
|
||||
- **WHEN** 查询某目标在指定时间范围内的趋势数据
|
||||
- **THEN** 系统 SHALL 返回按小时分组的聚合数据,包括每小时的平均耗时和可用率
|
||||
|
||||
## ADDED Requirements
|
||||
|
||||
### 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
|
||||
@@ -0,0 +1,128 @@
|
||||
## MODIFIED Requirements
|
||||
|
||||
### Requirement: 组内并发拨测
|
||||
系统 SHALL 在每次调度 tick 时并发执行同组内目标的检查,但实际同时运行的检查数 MUST 受全局 `runtime.maxConcurrentChecks` 限制。
|
||||
|
||||
#### Scenario: 同组目标并发执行
|
||||
- **WHEN** 调度器触发一次 tick,该组有 3 个目标,且全局并发余量至少为 3
|
||||
- **THEN** 系统 SHALL 同时执行 3 个 checker,而非顺序执行
|
||||
|
||||
#### Scenario: 单个目标失败不影响同组其他目标
|
||||
- **WHEN** 同组中某个目标的检查请求超时或失败
|
||||
- **THEN** 其他目标的检查 SHALL 正常完成并记录结果
|
||||
|
||||
#### Scenario: 全局并发限制生效
|
||||
- **WHEN** 调度器同时触发 10 个目标且 runtime.maxConcurrentChecks 为 3
|
||||
- **THEN** 系统 MUST 同时最多运行 3 个检查,其余检查等待并发槽位释放
|
||||
|
||||
### Requirement: HTTP 拨测执行
|
||||
系统 SHALL 对 `type: http` 的目标执行 HTTP 请求,支持 GET、POST、PUT、DELETE、PATCH、HEAD 方法,并携带 `http.headers` 和 `http.body`。
|
||||
|
||||
#### Scenario: 执行 GET 请求
|
||||
- **WHEN** HTTP target 配置 http.method 为 GET
|
||||
- **THEN** 系统 SHALL 发送 GET 请求到 http.url
|
||||
|
||||
#### Scenario: 执行 POST 请求带 body
|
||||
- **WHEN** HTTP target 配置 http.method 为 POST 且指定了 http.body 和 Content-Type header
|
||||
- **THEN** 系统 SHALL 发送带指定 body 的 POST 请求
|
||||
|
||||
#### Scenario: 携带自定义 headers
|
||||
- **WHEN** HTTP target 配置了 http.headers(如 Authorization)
|
||||
- **THEN** 系统 SHALL 在请求中包含所有配置的 headers
|
||||
|
||||
#### Scenario: HTTP body 读取上限
|
||||
- **WHEN** HTTP response body 超过该 target 的 maxBodyBytes
|
||||
- **THEN** 系统 MUST 停止读取并记录 `success=false`、`matched=false` 和结构化输出超限错误
|
||||
|
||||
### Requirement: 请求超时控制
|
||||
系统 SHALL 对每次 checker 执行实施超时控制,超时时间使用目标配置的 timeout 值。
|
||||
|
||||
#### Scenario: HTTP 请求超时
|
||||
- **WHEN** HTTP 请求在 timeout 时间内未收到响应
|
||||
- **THEN** 系统 SHALL 中止该请求,记录为失败并标注超时错误
|
||||
|
||||
#### Scenario: command 执行超时
|
||||
- **WHEN** command 进程在 timeout 时间内未退出
|
||||
- **THEN** 系统 MUST 终止该子进程,记录为失败并标注超时错误
|
||||
|
||||
#### Scenario: 请求在超时前完成
|
||||
- **WHEN** checker 在超时前完成执行
|
||||
- **THEN** 系统 SHALL 正常记录执行结果并进入 expect 校验
|
||||
|
||||
### Requirement: expect 校验
|
||||
系统 SHALL 在 checker 执行完成后根据目标类型的 expect 配置校验观测结果,校验结果和首个失败原因记入 check result。
|
||||
|
||||
#### Scenario: HTTP 默认状态码
|
||||
- **WHEN** HTTP target 未配置 `expect.status`
|
||||
- **THEN** 系统 SHALL 按默认 `status: [200]` 校验响应状态码
|
||||
|
||||
#### Scenario: 校验 HTTP 状态码
|
||||
- **WHEN** HTTP target 配置了 `expect.status: [200, 201]`
|
||||
- **THEN** 系统 SHALL 检查响应状态码是否在列表中,将匹配结果记录到 matched 字段
|
||||
|
||||
#### Scenario: 校验 HTTP 响应头
|
||||
- **WHEN** HTTP target 配置了 `expect.headers: {"Content-Type": {contains: "application/json"}}`
|
||||
- **THEN** 系统 SHALL 检查响应头是否符合指定规则,全部匹配时继续后续阶段
|
||||
|
||||
#### Scenario: 校验 HTTP 响应体
|
||||
- **WHEN** HTTP target 配置了有序 `expect.body` 规则数组
|
||||
- **THEN** 系统 SHALL 按数组顺序执行 body 规则,任一失败立即记录 failure 并停止后续规则
|
||||
|
||||
#### Scenario: command 默认 exitCode
|
||||
- **WHEN** command target 未配置 `expect.exitCode`
|
||||
- **THEN** 系统 SHALL 按默认 `exitCode: [0]` 校验命令退出码
|
||||
|
||||
#### Scenario: 校验 command stdout
|
||||
- **WHEN** command target 配置了有序 `expect.stdout` 规则数组
|
||||
- **THEN** 系统 SHALL 按数组顺序执行 stdout 规则,任一失败立即记录 failure 并停止后续规则
|
||||
|
||||
#### Scenario: 校验耗时阈值
|
||||
- **WHEN** 目标配置了 `expect.maxDurationMs`
|
||||
- **THEN** 系统 SHALL 检查实际 durationMs 是否超过阈值,将匹配结果记录到 matched 字段
|
||||
|
||||
#### Scenario: 多条 expect 规则
|
||||
- **WHEN** 目标同时配置状态、duration、元数据和内容规则
|
||||
- **THEN** 系统 SHALL 所有规则全部通过时 matched 为 true,任一不通过则为 false 并记录首个失败原因
|
||||
|
||||
### Requirement: Body 校验按需解析
|
||||
系统 SHALL 仅在 HTTP target 配置了 body 校验且 status、duration、headers 阶段均通过时才读取并解析响应体,避免不必要的读取和解析开销。
|
||||
|
||||
#### Scenario: status 失败时不读取 body
|
||||
- **WHEN** HTTP target 的 status 阶段不匹配
|
||||
- **THEN** 系统 SHALL 立即返回 matched=false,且 MUST NOT 读取 response body
|
||||
|
||||
#### Scenario: 仅配置 contains 时不解析 JSON
|
||||
- **WHEN** HTTP target 仅配置 body contains 规则而未配置 json/css/xpath 规则
|
||||
- **THEN** 系统 SHALL 不执行 JSON.parse 或 HTML/XML 解析
|
||||
|
||||
#### Scenario: 配置 json 时解析 JSON 失败
|
||||
- **WHEN** HTTP target 配置了 body json 规则但响应体不是合法 JSON
|
||||
- **THEN** 系统 SHALL 判定 matched 为 false,并记录 json 规则对应的 failure.path
|
||||
|
||||
### Requirement: 拨测结果记录
|
||||
系统 SHALL 在每次 checker 完成后,将结果写入 SQLite 数据存储,包含 target_id、timestamp、success、matched、duration_ms、status_detail、failure 字段。
|
||||
|
||||
#### Scenario: 成功检查结果记录
|
||||
- **WHEN** checker 成功执行且 expect 全部匹配
|
||||
- **THEN** 系统 SHALL 记录 success=true、matched=true、duration_ms、status_detail,failure 为 null
|
||||
|
||||
#### Scenario: 执行失败结果记录
|
||||
- **WHEN** checker 执行失败(网络错误、超时、命令启动失败、输出超限等)
|
||||
- **THEN** 系统 SHALL 记录 success=false、matched=false、failure.kind="error" 和具体错误信息
|
||||
|
||||
#### Scenario: expect 不匹配结果记录
|
||||
- **WHEN** checker 执行成功但 expect 不匹配
|
||||
- **THEN** 系统 SHALL 记录 success=true、matched=false、failure.kind="mismatch" 和具体不匹配信息
|
||||
|
||||
## ADDED Requirements
|
||||
|
||||
### 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 执行该目标
|
||||
@@ -0,0 +1,50 @@
|
||||
## 1. 类型与配置契约
|
||||
|
||||
- [x] 1.1 重构 checker 类型定义为 `http` 与 `command` 判别联合,并新增 `CheckFailure`、`durationMs`、`statusDetail` 等结果字段,将 `maxLatencyMs` 重命名为 `maxDurationMs`
|
||||
- [x] 1.2 更新 YAML 配置类型,新增 `runtime.maxConcurrentChecks`、`defaults.http`、`defaults.command` 和 typed target 配置
|
||||
- [x] 1.3 实现 size 解析工具,支持 `B`、`KB`、`MB`、`GB` 并覆盖 `100MB=104857600` 的测试
|
||||
- [x] 1.4 重构 config-loader 校验逻辑,移除顶层 HTTP 字段支持并校验 type、http.url、command.exec、并发和 size 格式;ResolvedConfig 需携带配置文件目录,用于 command cwd 相对路径解析
|
||||
- [x] 1.5 更新配置解析测试,覆盖最简 HTTP、最简 command、per-target 覆盖、默认值、非法 type、缺失字段和非法 size
|
||||
|
||||
## 2. Expect 与失败信息
|
||||
|
||||
- [x] 2.1 抽取通用值操作符,使 equals、contains、match、empty、exists、gte、lte、gt、lt 可复用于 header、body、stdout 和 stderr
|
||||
- [x] 2.2 将 HTTP `expect.body` 重构为有序规则数组,并支持 contains、regex、json、css、xpath 规则
|
||||
- [x] 2.3 实现 HTTP expect pipeline,按 status、duration、headers、body[] 顺序执行并应用默认 `status: [200]`
|
||||
- [x] 2.4 实现 command expect pipeline,按 exitCode、duration、stdout[]、stderr[] 顺序执行并应用默认 `exitCode: [0]`
|
||||
- [x] 2.5 实现结构化 failure 生成与 actual 摘要截断,区分 `error` 和 `mismatch`
|
||||
- [x] 2.6 将 expect 相关文件(body、http、command、failure)移入 `checker/expect/` 子目录,统一导入路径并更新测试文件引用
|
||||
- [x] 2.7 更新 expect 单元测试,覆盖规则顺序、快速失败、默认 status、默认 exitCode、失败 path 和 actual 截断
|
||||
|
||||
## 3. Runner 与调度引擎
|
||||
|
||||
- [x] 3.1 将现有 fetcher 拆分或重命名为 HTTP runner,并改为读取 status、duration、headers 后再按需读取 body
|
||||
- [x] 3.2 在 HTTP runner 中实现 maxBodyBytes 限制、超时处理、statusDetail 和结构化执行错误
|
||||
- [x] 3.3 新增 command runner,使用 `exec + args` 执行本地命令且不经过 shell
|
||||
- [x] 3.4 在 command runner 中实现 cwd 相对配置文件目录解析、env 覆盖、timeout kill 和 maxOutputBytes 合计限制
|
||||
- [x] 3.5 重构 ProbeEngine 按 target.type 选择 runner,并引入全局 maxConcurrentChecks 并发池
|
||||
- [x] 3.6 更新 runner 和 engine 测试,覆盖 HTTP 快速失败不读 body、command 非零退出、启动失败、超时、输出超限和并发限制
|
||||
|
||||
## 4. 存储与 API
|
||||
|
||||
- [x] 4.1 重建 SQLite schema,使用 targets 的 type、target、config 字段和 check_results 的 duration_ms、status_detail、failure 字段
|
||||
- [x] 4.2 更新目标同步逻辑,持久化 HTTP URL 摘要和 command 命令摘要
|
||||
- [x] 4.3 更新检查结果写入和聚合查询,使用 duration_ms 计算平均耗时、P99 耗时和趋势数据
|
||||
- [x] 4.4 更新 shared API 类型,将 avgLatencyMs、p99LatencyMs、latencyMs、statusCode 替换为 avgDurationMs、p99DurationMs、durationMs、statusDetail 和 failure
|
||||
- [x] 4.5 更新 API handler 映射逻辑,返回 type、target、durationMs、statusDetail、failure 和新的统计字段
|
||||
- [x] 4.6 更新 store 和 API 测试,覆盖结构化 failure 入库、目标摘要、summary、targets、history 和 trend 响应
|
||||
|
||||
## 5. Dashboard 与文档
|
||||
|
||||
- [x] 5.1 更新 Dashboard 总览卡片、目标表格和详情面板,将 URL/方法/延迟改为类型、目标、耗时和失败原因展示
|
||||
- [x] 5.2 更新趋势图和 Sparkline 数据字段,从 latency 切换为 duration
|
||||
- [x] 5.3 更新前端类型引用和组件测试或相关断言,覆盖 HTTP 与 command target 展示
|
||||
- [x] 5.4 更新 README 的项目说明、配置说明、目标状态判定、API 字段和已知限制
|
||||
- [x] 5.5 更新 `probes.example.yaml`,提供 HTTP 与 command typed target 示例以及 100MB 默认说明
|
||||
- [x] 5.6 更新 smoke test 配置和断言,确保生产 executable 可使用新配置启动并服务 API 与 Dashboard
|
||||
|
||||
## 6. 质量验证
|
||||
|
||||
- [x] 6.1 运行 `bun run check`,修复类型检查、lint、格式检查和单元测试问题
|
||||
- [x] 6.2 运行 `bun run verify`,修复生产构建和 smoke test 问题
|
||||
- [x] 6.3 复查 OpenSpec change 与实现一致性,确认所有任务完成且 README、测试和示例同步更新
|
||||
@@ -0,0 +1,2 @@
|
||||
schema: spec-driven
|
||||
created: 2026-05-09
|
||||
@@ -0,0 +1,112 @@
|
||||
## Context
|
||||
|
||||
当前 expect 校验通过 `checkExpect()` 函数(`src/server/checker/fetcher.ts`)实现,仅支持 status 白名单、bodyContains 子串匹配、maxLatencyMs 延迟阈值。body 校验能力薄弱,无法处理 JSON 结构化数据和 HTML/XML 页面内容校验。
|
||||
|
||||
本次设计将 body 校验扩展为五种可组合方法,并引入操作符系统统一提取值的比较逻辑。同时新增响应头校验。
|
||||
|
||||
项目约束:Bun 1.3.13 运行时、TypeScript、SQLite 持久化、YAML 配置格式。
|
||||
|
||||
## Goals / Non-Goals
|
||||
|
||||
**Goals:**
|
||||
- body 校验支持五大方法:contains、regex、json、css、xpath,任意 AND 组合
|
||||
- 操作符系统:equals(默认)、contains、match、empty、exists、gte、lte、gt、lt
|
||||
- 响应头校验 headers
|
||||
- 保持 matched/success 两层判定模型不变
|
||||
- 所有新逻辑有完整单元测试
|
||||
|
||||
**Non-Goals:**
|
||||
- 不支持 json/csv/xpath 的 OR 组合(当前全 AND)
|
||||
- 不支持 JSONPath 的通配符/过滤器(`.items[*].name`、`.items[?(@.price>10)]`)
|
||||
- 不支持 CSS 伪类选择器(如 `:nth-child`)
|
||||
- 不改变前端 Dashboard UI
|
||||
- 不做告警通知
|
||||
|
||||
## Decisions
|
||||
|
||||
### D1: body 分组嵌套结构
|
||||
|
||||
选择 `expect.body.<method>` 而非平铺 `expect.bodyXxx`。
|
||||
|
||||
**理由**:五种 body 方法语义上同属一层,嵌套结构比平铺更清晰,YAML 可读性更好。代价是将 `bodyContains` 从 `ExpectConfig` 顶层迁移至 `body.contains`,属于 **BREAKING** 变更,但项目尚在早期,影响极小。
|
||||
|
||||
**替代方案**:平铺 `expect.bodyContains`、`expect.bodyRegex` 等。不选,因随着方法增多字段名会越来越长且缺乏层次。
|
||||
|
||||
### D2: 操作符采用"标量=equals,对象=显式"的二态模型
|
||||
|
||||
```yaml
|
||||
json:
|
||||
$.status: ok # 标量 → equals
|
||||
$.data.count: # 对象 → 显式操作符
|
||||
gte: 1
|
||||
```
|
||||
|
||||
**理由**:90% 的拨测场景只需要等值比较,标量语法最简洁。需要复杂比较时展开为对象,二态在同一个 map 中共存,无需额外字段指示意图。
|
||||
|
||||
**替代方案**:每个规则必须是 `{ path, operator, value }` 对象。过于冗长,不如二态模型灵活。
|
||||
|
||||
### D3: CSS 选择器通过 `attr` 切换提取维度
|
||||
|
||||
```yaml
|
||||
css:
|
||||
"div.status": OK # 默认 textContent
|
||||
"meta[name=build-hash]": # 提取属性
|
||||
attr: content
|
||||
empty: false
|
||||
```
|
||||
|
||||
**理由**:99% 的 CSS 选择器场景只需要 textContent。通过可选的 `attr` 字段覆盖属性提取场景,保持常见用法最简。
|
||||
|
||||
**替代方案**:在选择器字符串中编码(如 `meta[name=build]@content`)。不选,语法污染。
|
||||
|
||||
### D4: 依赖选型 cheerio + xpath + @xmldom/xmldom
|
||||
|
||||
| 包 | 用途 | 选型理由 |
|
||||
|----|------|---------|
|
||||
| cheerio | CSS 选择器 HTML 解析 | npm 27M+ 周下载,jQuery API 熟悉度高,依赖树由同一组织维护 |
|
||||
| xpath | XPath 1.0 引擎 | npm 600K+ 周下载,轻量,业界标准 |
|
||||
| @xmldom/xmldom | xpath 的 DOM 实现 | 2M+ 周下载,xmldom 官方维护 |
|
||||
|
||||
**替代方案**:jsdom(体积大,~200KB)、linkedom(不支持 XPath)。不选。
|
||||
|
||||
cheerio 和 xpath 使用不同的 DOM 模型,同一个 HTML body 需要各自解析。拨测场景(秒级频率,非高并发 HTML 解析)性能开销可忽略。
|
||||
|
||||
### D5: body 方法按需解析,短路 AND 执行
|
||||
|
||||
整体 checkExpect 执行顺序为 `status → headers → body → maxLatencyMs`,均为 AND 短路。body 内部执行顺序:
|
||||
|
||||
```
|
||||
body 内部:
|
||||
1. contains → 文本匹配,失败立即返回
|
||||
2. regex → 文本匹配,失败立即返回
|
||||
3. json → 仅当 json 配置存在时解析 JSON(否则跳过)
|
||||
4. css → 仅当 css 配置存在时解析 HTML(cheerio)
|
||||
5. xpath → 仅当 xpath 配置存在时解析 HTML/XML(xmldom)
|
||||
|
||||
解析失败(JSON.parse 异常、cheerio 加载失败)→ matched=false
|
||||
```
|
||||
|
||||
**理由**:避免不必要的解析开销(例如只配了 contains 时不解析 JSON/HTML)。AND 短路语义与现有 expect 规则保持一致。
|
||||
|
||||
### D6: 操作符的类型转换策略
|
||||
|
||||
| 操作符 | 提取值类型 | 转换逻辑 |
|
||||
|--------|-----------|---------|
|
||||
| equals | 保留原类型 | strict === 比较 |
|
||||
| contains | 强制 toString() | actual.toString().includes(expected) |
|
||||
| match | 强制 toString() | new RegExp(pattern).test(actual.toString()) |
|
||||
| empty | - | null、undefined、""、[]、{} → 判定为空 |
|
||||
| exists | - | undefined vs 非 undefined |
|
||||
| gte/lte/gt/lt | 强制 Number() | Number(actual) >= expected |
|
||||
|
||||
CSS/XPath 提取的值始终是 string,数字比较时自动 Number() 转换。JSON 提取的值保留原类型(number/boolean/null/string)。
|
||||
|
||||
单个提取值可配置多个操作符(如 `{gte: 10, lte: 100}`),所有操作符全部通过才算该字段通过,语义为 AND。
|
||||
|
||||
## Risks / Trade-offs
|
||||
|
||||
- **[兼容性风险]** `bodyContains` → `body.contains` 是 BREAKING 变更 → 通过 README 和示例配置文件说明,现有用户量极小,影响可控
|
||||
- **[性能风险]** cheerio 和 xpath 各自解析 HTML → 同一 body 可能解析两次 → 拨测场景下无需缓存,单次解析耗时 <5ms,整体影响可忽略
|
||||
- **[JSONPath 功能局限]** 自实现简易路径解析不支持通配符和过滤器 → 通过文档说明限制,后续可按需增强
|
||||
- **[XPath 浏览器兼容]** xpath 使用 xmldom 而非浏览器原生 evaluate → 语义上等价,测试覆盖保证行为正确
|
||||
- **[依赖体积]** 新增 3 个包增加约 95KB → 这是 executable 构建,Bun compile 会打包进二进制,对最终产物影响有限
|
||||
@@ -0,0 +1,39 @@
|
||||
## Why
|
||||
|
||||
当前 expect 规则仅有 `status`、`bodyContains`、`maxLatencyMs` 三条,无法满足 API 网关拨测中对 JSON 返回值字段校验、HTML 页面内容校验、响应头校验等常见需求。body 校验能力单薄(仅子串匹配),需要增强为多种可组合的校验方法,覆盖主流响应格式。
|
||||
|
||||
## What Changes
|
||||
|
||||
- 新增 `headers` 规则,支持按响应头键值对校验
|
||||
- 重构 body 校验:将独立的 `bodyContains` 移至 `body` 分组下,新增五种 body 校验方法:
|
||||
- `contains`:子串匹配(从原 `bodyContains` 迁移)
|
||||
- `regex`:正则表达式全文匹配
|
||||
- `json`:JSONPath 提取值后比较
|
||||
- `css`:CSS 选择器提取 HTML 元素文本/属性后比较
|
||||
- `xpath`:XPath 提取 XML/HTML 节点后比较
|
||||
- body 五种方法可任意组合,AND 串联
|
||||
- 新增操作符系统:`equals`(默认)、`contains`、`match`(正则)、`empty`、`exists`、`gte`、`lte`、`gt`、`lt`
|
||||
- 新增依赖:`cheerio`(CSS 选择器)、`xpath` + `@xmldom/xmldom`(XPath 引擎)
|
||||
- **BREAKING**:`expect.bodyContains` 迁移至 `expect.body.contains`
|
||||
|
||||
## Capabilities
|
||||
|
||||
### New Capabilities
|
||||
|
||||
- `expect-body-checkers`:body 响应校验方法集(contains/regex/json/css/xpath)及操作符系统
|
||||
|
||||
### Modified Capabilities
|
||||
|
||||
- `probe-config`:expect 配置 schema 变更,新增 headers/body 分组,bodyContains 迁移
|
||||
- `probe-engine`:checkExpect 函数扩展,支持新的 body 校验方法和操作符
|
||||
|
||||
## Impact
|
||||
|
||||
- 类型定义:`src/server/checker/types.ts`(ExpectConfig/BodyExpectConfig/ExpectOperator)
|
||||
- 配置加载:`src/server/checker/config-loader.ts`(解析新的 expect 结构)
|
||||
- 拨测执行:`src/server/checker/fetcher.ts`(checkExpect 扩展)
|
||||
- 数据存储:`src/server/checker/store.ts`(expect JSON 序列化兼容)
|
||||
- 前端展示:状态判定逻辑不变(matched 字段语义不变)
|
||||
- 配置文件:`probes.example.yaml`(更新示例)
|
||||
- README.md:更新配置文档
|
||||
- 依赖:`package.json` 新增 cheerio、xpath、@xmldom/xmldom
|
||||
@@ -0,0 +1,113 @@
|
||||
## ADDED Requirements
|
||||
|
||||
### Requirement: 响应体多种校验方法
|
||||
系统 SHALL 支持对 HTTP 响应体进行五种可组合的校验方法:contains(子串)、regex(正则)、json(JSONPath)、css(CSS 选择器)、xpath(XPath),配置在 `expect.body` 分组下。
|
||||
|
||||
#### Scenario: contains 子串匹配
|
||||
- **WHEN** 目标配置 `expect.body.contains: "healthy"`,且响应体包含 `"healthy"`
|
||||
- **THEN** 系统 SHALL 判定 matched 为 true
|
||||
|
||||
#### Scenario: contains 不匹配
|
||||
- **WHEN** 目标配置 `expect.body.contains: "healthy"`,且响应体不包含该文本
|
||||
- **THEN** 系统 SHALL 判定 matched 为 false
|
||||
|
||||
#### Scenario: regex 正则匹配
|
||||
- **WHEN** 目标配置 `expect.body.regex: '"status"\\s*:\\s*"ok"'`,且响应体匹配该正则
|
||||
- **THEN** 系统 SHALL 判定 matched 为 true
|
||||
|
||||
#### Scenario: regex 不匹配
|
||||
- **WHEN** 目标配置 `expect.body.regex: '"status"\\s*:\\s*"ok"'`,且响应体不匹配该正则
|
||||
- **THEN** 系统 SHALL 判定 matched 为 false
|
||||
|
||||
#### Scenario: json JSONPath 等值匹配
|
||||
- **WHEN** 目标配置 `expect.body.json: {"$.status": "ok"}`,且响应 JSON 中 `$.status` 值为 `"ok"`
|
||||
- **THEN** 系统 SHALL 判定 matched 为 true
|
||||
|
||||
#### Scenario: json JSONPath 值不匹配
|
||||
- **WHEN** 目标配置 `expect.body.json: {"$.status": "ok"}`,且响应 JSON 中 `$.status` 值为 `"error"`
|
||||
- **THEN** 系统 SHALL 判定 matched 为 false
|
||||
|
||||
#### Scenario: json 解析失败
|
||||
- **WHEN** 目标配置了 `expect.body.json` 但响应体不是合法 JSON
|
||||
- **THEN** 系统 SHALL 判定 matched 为 false
|
||||
|
||||
#### Scenario: css 选择器匹配
|
||||
- **WHEN** 目标配置 `expect.body.css: {"div#health": "OK"}`,且 HTML 中存在 `div#health` 元素文本为 `"OK"`
|
||||
- **THEN** 系统 SHALL 判定 matched 为 true
|
||||
|
||||
#### Scenario: css 选择器匹配属性值
|
||||
- **WHEN** 目标配置 css 规则带 `attr: "content"` 用于提取属性,且属性值匹配期望
|
||||
- **THEN** 系统 SHALL 判定 matched 为 true
|
||||
|
||||
#### Scenario: css 选择器无匹配元素
|
||||
- **WHEN** 目标配置了 css 选择器但 HTML 中无匹配元素
|
||||
- **THEN** 系统 SHALL 判定 matched 为 false
|
||||
|
||||
#### Scenario: xpath 表达式匹配
|
||||
- **WHEN** 目标配置 `expect.body.xpath: {"/root/status/text()": "ok"}`,且 XML 中 `/root/status` 节点文本为 `"ok"`
|
||||
- **THEN** 系统 SHALL 判定 matched 为 true
|
||||
|
||||
#### Scenario: xpath 表达式无匹配节点
|
||||
- **WHEN** 目标配置了 xpath 表达式但 XML 中无匹配节点
|
||||
- **THEN** 系统 SHALL 判定 matched 为 false
|
||||
|
||||
### Requirement: 多种 body 校验方法 AND 组合
|
||||
系统 SHALL 支持同时配置多种 body 校验方法,所有方法均通过时 matched 方为 true。
|
||||
|
||||
#### Scenario: 多种方法全部通过
|
||||
- **WHEN** 目标同时配置 `body.contains`、`body.json`、`body.regex`,且全部通过
|
||||
- **THEN** 系统 SHALL 判定 matched 为 true
|
||||
|
||||
#### Scenario: 多种方法任一失败
|
||||
- **WHEN** 目标同时配置 `body.contains` 和 `body.json`,且 `body.contains` 不通过
|
||||
- **THEN** 系统 SHALL 判定 matched 为 false,且不再检查 `body.json`
|
||||
|
||||
### Requirement: 操作符系统
|
||||
系统 SHALL 支持对 body 校验的提取值使用以下操作符进行比较:equals(默认等值)、contains(子串包含)、match(正则匹配)、empty(空值判断)、exists(存在性判断)、gte/lte/gt/lt(数值比较)。
|
||||
|
||||
#### Scenario: 标量值隐式 equals
|
||||
- **WHEN** jsonPath 配置的期望值为标量(字符串/数字/布尔/null),如 `$.status: ok`
|
||||
- **THEN** 系统 SHALL 使用 equals 操作符,对提取值做严格相等比较
|
||||
|
||||
#### Scenario: 显式 contains 操作符
|
||||
- **WHEN** 配置 `$.message: {contains: "success"}`,且提取值包含 `"success"`
|
||||
- **THEN** 系统 SHALL 判定 matched 为 true
|
||||
|
||||
#### Scenario: 显式 match 操作符
|
||||
- **WHEN** 配置 `$.version: {match: '\\d+\\.\\d+\\.\\d+'}`,且提取值匹配该正则
|
||||
- **THEN** 系统 SHALL 判定 matched 为 true
|
||||
|
||||
#### Scenario: empty 操作符判断为空
|
||||
- **WHEN** 配置 `$.items: {empty: true}`,且提取值为空数组 `[]`
|
||||
- **THEN** 系统 SHALL 判定 matched 为 true
|
||||
|
||||
#### Scenario: empty 操作符判断非空
|
||||
- **WHEN** 配置 `$.items: {empty: false}`,且提取值为 `[1, 2]`
|
||||
- **THEN** 系统 SHALL 判定 matched 为 true
|
||||
|
||||
#### Scenario: exists 操作符判断存在
|
||||
- **WHEN** 配置 `$.error: {exists: false}`,且 JSON 中不存在 `error` 字段
|
||||
- **THEN** 系统 SHALL 判定 matched 为 true
|
||||
|
||||
#### Scenario: gte 数值比较
|
||||
- **WHEN** 配置 `$.count: {gte: 10}`,且提取值为 `15`(数字)
|
||||
- **THEN** 系统 SHALL 判定 matched 为 true
|
||||
|
||||
#### Scenario: gt/lt 数值比较
|
||||
- **WHEN** 配置 `$.latency: {gt: 0, lt: 1000}`,且提取值为 `500`
|
||||
- **THEN** 系统 SHALL 对同一字段进行多操作符复合比较,全部通过则 matched 为 true
|
||||
|
||||
### Requirement: 响应头校验
|
||||
系统 SHALL 支持通过 `expect.headers` 配置对响应头进行键值对校验。
|
||||
|
||||
#### Scenario: 响应头匹配
|
||||
- **WHEN** 目标配置 `expect.headers: {"Content-Type": "application/json"}`,且响应包含该 header 且值匹配
|
||||
- **THEN** 系统 SHALL 判定 matched 为 true
|
||||
|
||||
#### Scenario: 响应头不匹配
|
||||
- **WHEN** 目标配置 `expect.headers: {"Content-Type": "application/json"}`,且响应 header 值为 `"text/html"`
|
||||
- **THEN** 系统 SHALL 判定 matched 为 false
|
||||
|
||||
#### Scenario: 响应头缺失
|
||||
- **WHEN** 目标配置了某个 header 但响应中不存在该 header
|
||||
- **THEN** 系统 SHALL 判定 matched 为 false
|
||||
@@ -0,0 +1,21 @@
|
||||
## ADDED Requirements
|
||||
|
||||
### Requirement: expect 配置增强
|
||||
系统 SHALL 支持增强的 expect 配置格式,包括 `headers` 响应头校验和 `body` 分组下的多种校验方法(contains、regex、json、css、xpath)。
|
||||
|
||||
#### Scenario: 解析增强的 expect 配置
|
||||
- **WHEN** YAML 配置文件中 target 的 expect 包含 headers、body 分组及内部方法
|
||||
- **THEN** 系统 SHALL 正确解析并存储为 ResolvedTarget 的 expect 字段
|
||||
|
||||
#### Scenario: 解析仅含 body.contains 的最简配置
|
||||
- **WHEN** YAML 中 target 配置 `expect.body.contains: "healthy"`
|
||||
- **THEN** 系统 SHALL 正确解析,功能等价于旧版 `expect.bodyContains`
|
||||
|
||||
#### Scenario: 不配置 expect
|
||||
- **WHEN** target 未配置任何 expect 规则
|
||||
- **THEN** 系统 SHALL 正常处理,expect 字段为 undefined
|
||||
|
||||
#### Scenario: 旧版 bodyContains 字段不再支持
|
||||
- **WHEN** YAML 中使用 `expect.bodyContains: "xxx"` 格式
|
||||
- **THEN** 该字段 SHALL 被忽略(系统仅识别 `expect.body.contains`)
|
||||
- **Migration**: 将配置文件中 `expect.bodyContains: "xxx"` 改为 `expect.body.contains: "xxx"`
|
||||
@@ -0,0 +1,61 @@
|
||||
## MODIFIED Requirements
|
||||
|
||||
### Requirement: expect 校验
|
||||
系统 SHALL 在拨测完成后根据目标的 expect 配置校验响应,校验结果记入 check result。
|
||||
|
||||
#### Scenario: 校验状态码
|
||||
- **WHEN** 目标配置了 `expect.status: [200, 201]`
|
||||
- **THEN** 系统 SHALL 检查响应状态码是否在列表中,将匹配结果记录到 matched 字段
|
||||
|
||||
#### Scenario: 校验响应头
|
||||
- **WHEN** 目标配置了 `expect.headers: {"Content-Type": "application/json"}`
|
||||
- **THEN** 系统 SHALL 检查响应头是否包含指定键值对,全部匹配时将 matched 设为 true
|
||||
|
||||
#### Scenario: 校验响应体包含
|
||||
- **WHEN** 目标配置了 `expect.body.contains: "healthy"`
|
||||
- **THEN** 系统 SHALL 检查响应体是否包含该文本,将匹配结果记录到 matched 字段
|
||||
|
||||
#### Scenario: 校验响应体正则
|
||||
- **WHEN** 目标配置了 `expect.body.regex: '"status"\\s*:\\s*"ok"'`
|
||||
- **THEN** 系统 SHALL 检查响应体是否匹配该正则,将匹配结果记录到 matched 字段
|
||||
|
||||
#### Scenario: 校验 JSON 响应
|
||||
- **WHEN** 目标配置了 `expect.body.json: {"$.status": "ok"}`
|
||||
- **THEN** 系统 SHALL 解析 JSON 并检查 JSONPath 对应值是否符合期望,将匹配结果记录到 matched 字段
|
||||
|
||||
#### Scenario: 校验 HTML 响应(CSS 选择器)
|
||||
- **WHEN** 目标配置了 `expect.body.css: {"div#health": "OK"}`
|
||||
- **THEN** 系统 SHALL 解析 HTML 并用 CSS 选择器提取元素文本进行比较,将匹配结果记录到 matched 字段
|
||||
|
||||
#### Scenario: 校验 HTML/XML 响应(XPath)
|
||||
- **WHEN** 目标配置了 `expect.body.xpath: {"/root/status/text()": "ok"}`
|
||||
- **THEN** 系统 SHALL 解析文档并用 XPath 提取节点文本进行比较,将匹配结果记录到 matched 字段
|
||||
|
||||
#### Scenario: 校验延迟阈值
|
||||
- **WHEN** 目标配置了 `expect.maxLatencyMs: 3000`
|
||||
- **THEN** 系统 SHALL 检查实际延迟是否超过阈值,将匹配结果记录到 matched 字段
|
||||
|
||||
#### Scenario: 无 expect 配置
|
||||
- **WHEN** 目标未配置任何 expect 规则
|
||||
- **THEN** 系统 SHALL 将 matched 字段设为 true
|
||||
|
||||
#### Scenario: 多条 expect 规则
|
||||
- **WHEN** 目标同时配置了 status、headers、body.contains、body.json 和 maxLatencyMs
|
||||
- **THEN** 系统 SHALL 所有规则全部通过时 matched 为 true,任一不通过则为 false
|
||||
|
||||
#### Scenario: 多种 body 方法 AND 组合
|
||||
- **WHEN** 目标在 body 分组下配置了 contains、json、css 多种方法
|
||||
- **THEN** 系统 SHALL 按 contains → regex → json → css → xpath 顺序执行,任一失败立即返回 false
|
||||
|
||||
## ADDED Requirements
|
||||
|
||||
### Requirement: Body 校验按需解析
|
||||
系统 SHALL 仅在配置了对应 body 校验方法时才解析响应体为对应格式,避免不必要的解析开销。
|
||||
|
||||
#### Scenario: 仅配置 contains 时不解析 JSON
|
||||
- **WHEN** 目标仅配置 `expect.body.contains` 而未配置 json/css/xpath
|
||||
- **THEN** 系统 SHALL 不执行 JSON.parse 或 HTML/XML 解析
|
||||
|
||||
#### Scenario: 配置 json 时解析 JSON 失败
|
||||
- **WHEN** 目标配置了 `expect.body.json` 但响应体不是合法 JSON
|
||||
- **THEN** 系统 SHALL 判定 matched 为 false
|
||||
@@ -0,0 +1,53 @@
|
||||
## 1. 依赖安装
|
||||
|
||||
- [x] 1.1 安装 cheerio、xpath、@xmldom/xmldom 依赖
|
||||
|
||||
## 2. 类型定义
|
||||
|
||||
- [x] 2.1 在 types.ts 中定义 ExpectOperator、BodyExpectConfig 接口
|
||||
- [x] 2.2 更新 ExpectConfig 接口,新增 headers 字段,将 bodyContains 替换为 body 分组
|
||||
- [x] 2.3 新增 CssExpect 类型(ExpectValue | ExpectOperator & { attr?: string })
|
||||
- [x] 2.4 导出 ExpectValue 联合类型
|
||||
|
||||
## 3. Body 校验核心实现
|
||||
|
||||
- [x] 3.1 实现简易 JSONPath 求值函数 evaluateJsonPath(支持 $.a.b、$.a[0].b 等基本路径)
|
||||
- [x] 3.2 实现操作符比较函数 applyOperator(equals/contains/match/empty/exists/gte/lte/gt/lt)
|
||||
- [x] 3.3 实现 checkExpectValue 函数:标量 → equals,对象 → 遍历操作符
|
||||
- [x] 3.4 实现 checkBodyContains:body.includes 包装
|
||||
- [x] 3.5 实现 checkBodyRegex:new RegExp().test 包装
|
||||
- [x] 3.6 实现 checkBodyJson:JSON.parse + evaluateJsonPath + applyOperator
|
||||
- [x] 3.7 实现 checkBodyCss:cheerio.load + 选择器查询 + text/attr 提取 + applyOperator
|
||||
- [x] 3.8 实现 checkBodyXpath:xmldom 解析 + xpath 引擎 evaluate + applyOperator
|
||||
|
||||
## 4. Expect 校验重构
|
||||
|
||||
- [x] 4.1 重构 checkExpect 函数,新增 headers 检查逻辑
|
||||
- [x] 4.2 将 bodyContains 检查替换为 checkBodyExpect 调用,按需分发到五种子方法
|
||||
- [x] 4.3 实现 checkBodyExpect 主入口:按 contains → regex → json → css → xpath 顺序 AND 短路执行
|
||||
|
||||
## 5. 配置加载
|
||||
|
||||
- [x] 5.1 确认 config-loader 中 expect 透传逻辑对新结构的兼容性,更新类型引用
|
||||
|
||||
## 6. 数据存储兼容
|
||||
|
||||
- [x] 6.1 验证 store.ts 中 expect JSON 序列化对新结构的兼容性,必要时调整
|
||||
|
||||
## 7. 测试
|
||||
|
||||
- [x] 7.1 为 evaluateJsonPath 编写单元测试(嵌套对象、数组索引、不存在路径、边界情况)
|
||||
- [x] 7.2 为 applyOperator 编写单元测试(9 种操作符各至少 2 个 case)
|
||||
- [x] 7.3 为 checkBodyContains/checkBodyRegex 编写单元测试
|
||||
- [x] 7.4 为 checkBodyJson 编写单元测试(等值匹配、操作符匹配、JSON 解析失败、路径不存在)
|
||||
- [x] 7.5 为 checkBodyCss 编写单元测试(text 提取、attr 提取、无匹配元素)
|
||||
- [x] 7.6 为 checkBodyXpath 编写单元测试(节点文本、属性值、无匹配节点、XML 解析失败)
|
||||
- [x] 7.7 为 checkExpect 新增测试用例(headers 校验、body 多种方法 AND 组合、全量规则)
|
||||
- [x] 7.8 更新 config-loader 测试用例(新 expect 格式解析、向后兼容验证)
|
||||
- [x] 7.9 端到端模拟测试:构造完整 expect 配置并验证 checkExpect 整体行为
|
||||
|
||||
## 8. 文档与示例
|
||||
|
||||
- [x] 8.1 更新 probes.example.yaml,展示 headers 和 body 分组全部用法示例
|
||||
- [x] 8.2 更新 README.md 配置说明章节,补充 expect.body 和 headers 的文档
|
||||
- [x] 8.3 更新 README.md 依赖列表(如有需要)
|
||||
@@ -0,0 +1,2 @@
|
||||
schema: spec-driven
|
||||
created: 2026-05-10
|
||||
122
openspec/changes/archive/2026-05-11-card-ui-refactor/design.md
Normal file
122
openspec/changes/archive/2026-05-11-card-ui-refactor/design.md
Normal file
@@ -0,0 +1,122 @@
|
||||
## Context
|
||||
|
||||
当前 Dashboard 使用 `TargetTable` + `TargetRow` + `TargetDetail` 的表格布局,所有目标扁平排列在同一个表格中,点击行展开内联详情面板。前端组件结构为:
|
||||
|
||||
```
|
||||
App → SummaryCards(4) + TargetTable → TargetRow → TargetDetail(内联)
|
||||
```
|
||||
|
||||
后端 API 提供 `GET /api/targets` 返回含 `sparkline: number[]` 的目标列表,`GET /api/targets/:id/trend?hours=24` 返回趋势数据,`GET /api/targets/:id/history?limit=20` 返回历史记录。全局汇总含 `avgDurationMs`。
|
||||
|
||||
本次重构涉及全栈变更:YAML 配置格式、后端数据存储、API 接口、前端组件和样式。
|
||||
|
||||
## Goals / Non-Goals
|
||||
|
||||
**Goals:**
|
||||
|
||||
- 引入 target 分组概念,按组展示卡片,default 组排最前
|
||||
- 卡片内同时展示状态条(UP/DOWN 可视化)和迷你 sparkline(耗时趋势)
|
||||
- 模态框提供丰富的详情查看体验:多维统计图 + 带分页的检查结果列表
|
||||
- 模态框支持自定义时间范围筛选(分钟精度)和快捷时间范围按钮
|
||||
- 移除无实际意义的全局平均耗时统计
|
||||
- 统一卡片迷你可视化的采样数量为全局可配置项 `recentSampleCount`
|
||||
- 不引入新依赖,复用现有 recharts 库
|
||||
|
||||
**Non-Goals:**
|
||||
|
||||
- 分组折叠/展开功能
|
||||
- 分组排序自定义(固定为 default 最前,其余按 YAML 出现顺序)
|
||||
- per-target 的 sparkline 数量自定义(统一使用全局配置)
|
||||
- 模态框内的状态筛选(仅支持时间范围筛选)
|
||||
- 卡片内显示可用率数字
|
||||
- 按耗时阈值筛选
|
||||
|
||||
## Decisions
|
||||
|
||||
### D1: group 配置采用扁平字段(方案 A)
|
||||
|
||||
**选择**: 在每个 target 上加 `group?: string` 可选字段。
|
||||
|
||||
**替代方案**: 嵌套结构(`targets: [{ group: "x", items: [...] }]`)。
|
||||
|
||||
**理由**: 扁平字段是增量变更,完全向后兼容,不破坏现有 targets 数组格式。嵌套结构会改变整个配置文件的顶层结构,影响面大且无额外收益。
|
||||
|
||||
### D2: sparkline 替换为 recentSamples 结构化数据
|
||||
|
||||
**选择**: 将 `sparkline: number[]` 替换为 `recentSamples: RecentSample[]`,每个 sample 包含 `timestamp`、`durationMs`、`up`。
|
||||
|
||||
**替代方案**: 新增独立的 status-bar API。
|
||||
|
||||
**理由**: 合并为一个接口减少请求数,前端一次数据同时满足状态条和 sparkline 两种可视化。`timestamp` 的包含使得 hover tooltip 有意义。
|
||||
|
||||
### D3: recentSampleCount 固定为 30
|
||||
|
||||
**选择**: StatusBar 和 MiniSparkline 的采样数量硬编码为 30。
|
||||
|
||||
**理由**: 30 是合理的默认值,覆盖最近 30 次检查,无需暴露配置项增加复杂度。
|
||||
|
||||
### D4: 模态框时间筛选同时支持快捷按钮和自定义日期选择器
|
||||
|
||||
**选择**: 快捷按钮(1h/6h/24h/7d)与分钟精度日期选择器并存,联动设计——点击快捷按钮自动填入日期,手动修改日期则快捷按钮取消高亮。
|
||||
|
||||
**理由**: 快捷按钮覆盖绝大多数场景,日期选择器提供精确控制能力。分钟精度对于拨测监控场景足够精确。
|
||||
|
||||
### D5: trend API 改用 from/to 时间范围参数
|
||||
|
||||
**选择**: `GET /api/targets/:id/trend?from=ISO&to=ISO` 替代 `?hours=24`。
|
||||
|
||||
**理由**: 模态框支持自定义时间范围,hours 参数无法表达任意时间范围。from/to 是更通用的设计。
|
||||
|
||||
### D6: history API 新增分页支持
|
||||
|
||||
**选择**: `GET /api/targets/:id/history?from=ISO&to=ISO&page=1&pageSize=20`,返回 `{ items, total, page, pageSize }`。
|
||||
|
||||
**理由**: 自定义时间范围可能导致大量数据(如选择 7 天范围),分页避免一次性传输过多数据。
|
||||
|
||||
### D7: SummaryCards 从 4 个减为 3 个
|
||||
|
||||
**选择**: 移除"平均耗时"卡片,保留"全部/正常/异常"。
|
||||
|
||||
**理由**: 引入分组后,不同分组目标的平均耗时混合计算没有实际参考价值。具体目标的耗时信息在模态框中查看。
|
||||
|
||||
### D8: targets 表使用 grp 列名
|
||||
|
||||
**选择**: 数据库列名使用 `grp` 而非 `group`。
|
||||
|
||||
**理由**: `group` 是 SQL 关键字,使用 `grp` 避免转义问题。API 层和前端仍使用 `group` 作为字段名。
|
||||
|
||||
### D9: 卡片固定宽度 280px + CSS Grid auto-fill 响应式
|
||||
|
||||
**选择**: `grid-template-columns: repeat(auto-fill, 280px)` 实现响应式布局。
|
||||
|
||||
**替代方案**: 百分比宽度或 flex-wrap。
|
||||
|
||||
**理由**: 固定宽度保证卡片内容一致性,auto-fill 自动适应视口宽度变化,从 1 列到多列无缝适配。
|
||||
|
||||
### D10: 分组排序由后端 SQL 保证
|
||||
|
||||
**选择**: `ORDER BY CASE WHEN grp='default' THEN 0 ELSE 1 END, grp, id`。
|
||||
|
||||
**理由**: 后端排序后前端只需顺序遍历渲染,无需额外排序逻辑。分组名按 YAML 首次出现顺序(即 id 顺序)自然排序。
|
||||
|
||||
### D11: 环形图(Donut Chart)展示状态分布
|
||||
|
||||
**选择**: 模态框统计图使用 recharts 的 PieChart + 内部标签实现环形图,中间显示可用率百分比。
|
||||
|
||||
**替代方案**: 纯饼图。
|
||||
|
||||
**理由**: 环形图中间可展示关键数字(可用率 %),信息密度更高。
|
||||
|
||||
### D12: 状态条使用连续色块
|
||||
|
||||
**选择**: 方块数量固定 30 个,每个 6px 宽 2px 间距,UP 绿色 `#1fbf75`,DOWN 红色 `#e5484d`,无数据灰色 `#e2e8f0`。
|
||||
|
||||
**理由**: 类似 GitHub contribution graph 的可视化方式,直观展示最近检查状态。
|
||||
|
||||
## Risks / Trade-offs
|
||||
|
||||
- [卡片信息密度] 卡片宽度仅 280px,同时放状态条和 sparkline 可能显得拥挤 → 状态条和 sparkline 各占一行,垂直堆叠,控制高度在合理范围
|
||||
- [API BREAKING 变更] sparkline → recentSamples、trend hours → from/to、history limit → page/pageSize 均为不兼容变更 → 项目未上线无需向前兼容,一次性完成
|
||||
- [targets 表 schema 变更] 新增 grp 列需要数据库 migration → SQLite ALTER TABLE ADD COLUMN 是安全操作,新列有默认值不影响已有数据
|
||||
- [模态框复杂度] 时间选择器 + 分页 + 多图表实现复杂度较高 → 拆分为独立子组件,每个组件职责单一
|
||||
- [recentSampleCount 默认值] 固定为 30,无法通过配置调整 → 合理值,30 覆盖足够长的最近检查周期
|
||||
@@ -0,0 +1,38 @@
|
||||
## Why
|
||||
|
||||
当前 Dashboard 使用表格列表展示所有拨测目标,缺乏分组组织能力,无法直观反映目标的归属关系和批量状态。表格内联展开的详情面板信息密度低、交互不流畅。需要重构为卡片式布局,引入分组概念,并通过模态框提供更丰富的详情查看体验。同时移除全局平均耗时统计(跨分组平均耗时无实际意义),并优化 API 以支持时间范围筛选和分页。
|
||||
|
||||
## What Changes
|
||||
|
||||
- **BREAKING**: 将前端从表格布局重构为按分组展示的卡片式布局,每个卡片固定宽度,响应式排列
|
||||
- **BREAKING**: target 配置新增可选 `group` 字段,未指定时默认为 `"default"`,default 分组排最前
|
||||
|
||||
- **BREAKING**: 点击卡片弹出模态框替代内联展开详情,模态框左侧展示多维统计图(可用率趋势、耗时趋势、状态分布环形图),右侧展示带分页的检查结果列表
|
||||
- **BREAKING**: 模态框支持时间范围筛选,包含快捷按钮(1h/6h/24h/7d)和自定义日期时间选择器(分钟精度)
|
||||
- **BREAKING**: API 接口变更:sparkline 替换为 recentSamples(包含状态信息);trend/history 支持 `from/to` 时间范围参数;history 支持分页
|
||||
- **BREAKING**: 移除 SummaryResponse.avgDurationMs 及相关计算逻辑,SummaryCards 从 4 个变为 3 个(全部/正常/异常)
|
||||
- **BREAKING**: 移除 TargetStats.avgDurationMs 和 TargetStats.p99DurationMs,这些统计仅在模态框详情中按需展示
|
||||
|
||||
## Capabilities
|
||||
|
||||
### New Capabilities
|
||||
|
||||
- `target-grouping`: target 分组能力,包括 YAML 配置的 group 字段、后端存储与 API 返回、前端按分组展示(带统计的分组标题)
|
||||
- `card-dashboard`: 卡片式 Dashboard 布局,包括分组卡片网格、卡片内状态条和迷你 sparkline 双可视化、卡片点击交互
|
||||
- `target-detail-modal`: 目标详情模态框,包括时间范围筛选器(快捷按钮 + 分钟精度日期选择器)、左侧多维统计图(可用率趋势折线、耗时趋势折线、状态分布环形图)、右侧分页检查结果列表
|
||||
|
||||
### Modified Capabilities
|
||||
|
||||
- `probe-config`: 新增 `targets[].group` 可选字段
|
||||
- `probe-api`: API 端点变更——summary 移除 avgDurationMs;targets 返回 group 和 recentSamples 替代 sparkline;trend 改用 from/to 参数替代 hours;history 改用 from/to + page/pageSize 并返回带分页信息的结构
|
||||
- `probe-data-store`: targets 表新增 grp 列存储分组信息;新增 getRecentSamples 方法替代 getSparkline;trend/history 查询改用时间范围参数;history 查询支持分页;移除 avgDurationMs 相关聚合
|
||||
- `probe-dashboard`: 全面重构前端组件,从表格布局改为卡片式分组布局;SummaryCards 减为 3 个;TargetTable/TargetRow/TargetDetail 替换为 TargetBoard/TargetCard/TargetDetailModal 等
|
||||
|
||||
## Impact
|
||||
|
||||
- **配置文件**: `probes.example.yaml` 需更新示例,新增 group 字段示例
|
||||
- **后端**: `types.ts`、`config-loader.ts`、`store.ts`、`app.ts` 需修改;targets 表需 schema migration
|
||||
- **共享类型**: `src/shared/api.ts` 需修改(新增 RecentSample、HistoryResponse 类型,移除/修改部分字段)
|
||||
- **前端**: 组件全面重构,新增 StatusBar、GroupHeader、StatusDonut、TimeRangePicker、Pagination 等组件;CSS 样式全面重写
|
||||
- **测试**: 后端测试需覆盖新 API 参数和返回结构,前端测试需更新
|
||||
- **依赖**: 不引入新依赖,全部使用现有 recharts 库
|
||||
@@ -0,0 +1,70 @@
|
||||
## ADDED Requirements
|
||||
|
||||
### Requirement: 分组卡片布局
|
||||
Dashboard SHALL 按分组展示所有拨测目标,每个分组包含带统计的分组标题和固定宽度的卡片网格。
|
||||
|
||||
#### Scenario: 按分组展示目标
|
||||
- **WHEN** 用户打开 Dashboard 页面
|
||||
- **THEN** 页面 SHALL 按分组展示目标卡片,"默认分组" 排在最上面,其余分组按 YAML 配置顺序排列
|
||||
|
||||
#### Scenario: 分组标题带统计
|
||||
- **WHEN** 页面渲染某个分组
|
||||
- **THEN** 分组标题 SHALL 显示分组名称、该分组内目标总数、正常数和异常数,格式为 `分组名 (N个, X UP / Y DOWN)`
|
||||
|
||||
#### Scenario: "default" 分组显示名称
|
||||
- **WHEN** 分组名称为 "default"
|
||||
- **THEN** 分组标题 SHALL 显示 "默认分组"
|
||||
|
||||
### Requirement: 响应式卡片网格
|
||||
Dashboard SHALL 使用固定宽度的卡片配合响应式网格布局。
|
||||
|
||||
#### Scenario: 卡片固定宽度
|
||||
- **WHEN** 页面渲染卡片
|
||||
- **THEN** 每个卡片 SHALL 固定宽度 280px
|
||||
|
||||
#### Scenario: 响应式列数
|
||||
- **WHEN** 视口宽度变化
|
||||
- **THEN** 卡片网格 SHALL 自动调整列数,使用 CSS Grid auto-fill 适配可用空间
|
||||
|
||||
### Requirement: 目标卡片内容
|
||||
每个目标卡片 SHALL 展示目标名称、当前状态、类型标签、状态条和迷你耗时趋势线。
|
||||
|
||||
#### Scenario: 卡片第一行内容
|
||||
- **WHEN** 卡片渲染
|
||||
- **THEN** 卡片第一行 SHALL 展示状态指示圆点(UP 绿色 / DOWN 红色)、目标名称和类型标签(HTTP / Command)
|
||||
|
||||
#### Scenario: 卡片状态指示圆点
|
||||
- **WHEN** 目标最近一次拨测 success=true 且 matched=true
|
||||
- **THEN** 卡片状态圆点 SHALL 显示为绿色
|
||||
- **WHEN** 目标最近一次拨测 success=false 或 matched=false
|
||||
- **THEN** 卡片状态圆点 SHALL 显示为红色
|
||||
|
||||
#### Scenario: 卡片状态条可视化
|
||||
- **WHEN** 卡片渲染且 recentSamples 数据可用
|
||||
- **THEN** 卡片 SHALL 展示一条状态条,每个采样点为一个色块:UP 显示绿色(#1fbf75),DOWN 显示红色(#e5484d),无数据显示灰色(#e2e8f0)
|
||||
|
||||
#### Scenario: 卡片迷你耗时趋势线
|
||||
- **WHEN** 卡片渲染且 recentSamples 中有 durationMs 数据
|
||||
- **THEN** 卡片 SHALL 展示基于 recharts 的迷你折线图,展示最近 N 次检查的耗时趋势
|
||||
|
||||
### Requirement: 卡片交互
|
||||
卡片 SHALL 支持 hover 效果和点击打开模态框。
|
||||
|
||||
#### Scenario: 卡片 hover 效果
|
||||
- **WHEN** 鼠标悬停在卡片上
|
||||
- **THEN** 卡片 SHALL 显示上浮效果(阴影加深)
|
||||
|
||||
#### Scenario: 卡片点击打开详情
|
||||
- **WHEN** 用户点击某个目标卡片
|
||||
- **THEN** 系统 SHALL 打开该目标的详情模态框
|
||||
|
||||
### Requirement: SummaryCards 变更
|
||||
Dashboard 顶部 SHALL 展示 3 个统计卡片:全部目标数、正常数、异常数。
|
||||
|
||||
#### Scenario: 展示 3 个统计卡片
|
||||
- **WHEN** 用户打开 Dashboard 页面
|
||||
- **THEN** 页面顶部 SHALL 显示 3 个统计卡片:全部目标数、正常目标数、异常目标数
|
||||
|
||||
#### Scenario: 统计数据自动刷新
|
||||
- **WHEN** 页面处于打开状态
|
||||
- **THEN** 统计卡片 SHALL 每 5-10 秒自动刷新数据
|
||||
@@ -0,0 +1,96 @@
|
||||
## ADDED Requirements
|
||||
|
||||
### Requirement: 目标列表返回分组和采样数据
|
||||
`GET /api/targets` SHALL 返回每个目标的分组信息和结构化采样数据,替代原有 sparkline。
|
||||
|
||||
#### Scenario: 返回分组信息
|
||||
- **WHEN** 客户端请求 `GET /api/targets`
|
||||
- **THEN** 响应中每个目标 SHALL 包含 `group` 字段,值为该目标所属的分组名称
|
||||
|
||||
#### Scenario: 返回 recentSamples
|
||||
- **WHEN** 客户端请求 `GET /api/targets`
|
||||
- **THEN** 响应中每个目标 SHALL 包含 `recentSamples` 数组,每个元素包含 `timestamp`(ISO 8601)、`durationMs`(number | null)、`up`(boolean,success && matched)
|
||||
|
||||
#### Scenario: recentSamples 数量
|
||||
- **WHEN** 客户端请求 `GET /api/targets`
|
||||
- **THEN** 每个目标的 recentSamples SHALL 最多包含 30 个元素,按时间倒序排列
|
||||
|
||||
#### Scenario: 目标无历史记录
|
||||
- **WHEN** 某目标尚未执行过任何拨测
|
||||
- **THEN** 其 recentSamples SHALL 为空数组
|
||||
|
||||
### Requirement: 趋势 API 支持时间范围
|
||||
`GET /api/targets/:id/trend` SHALL 支持 `from` 和 `to` 查询参数指定时间范围。
|
||||
|
||||
#### Scenario: 指定时间范围查询趋势
|
||||
- **WHEN** 客户端请求 `GET /api/targets/1/trend?from=2026-05-03T00:00:00Z&to=2026-05-10T00:00:00Z`
|
||||
- **THEN** 系统 SHALL 返回指定时间范围内按小时分组的聚合数据
|
||||
|
||||
#### Scenario: from 或 to 参数缺失
|
||||
- **WHEN** 客户端请求 `GET /api/targets/1/trend` 未提供 from 或 to 参数
|
||||
- **THEN** 系统 SHALL 返回 400 状态码和错误信息
|
||||
|
||||
#### Scenario: 无效的时间参数
|
||||
- **WHEN** 客户端请求 `GET /api/targets/1/trend?from=invalid`
|
||||
- **THEN** 系统 SHALL 返回 400 状态码和错误信息
|
||||
|
||||
### Requirement: 历史记录 API 支持时间范围和分页
|
||||
`GET /api/targets/:id/history` SHALL 支持 `from`、`to` 时间范围参数和 `page`、`pageSize` 分页参数,返回带分页信息的结构。
|
||||
|
||||
#### Scenario: 指定时间范围和分页
|
||||
- **WHEN** 客户端请求 `GET /api/targets/1/history?from=ISO&to=ISO&page=1&pageSize=20`
|
||||
- **THEN** 系统 SHALL 返回 JSON 包含 `items`(检查结果数组)、`total`(满足条件的总记录数)、`page`(当前页码)、`pageSize`(每页大小)
|
||||
|
||||
#### Scenario: 使用默认分页参数
|
||||
- **WHEN** 客户端请求 `GET /api/targets/1/history?from=ISO&to=ISO` 未指定 page 或 pageSize
|
||||
- **THEN** 系统 SHALL 使用默认 page=1, pageSize=20
|
||||
|
||||
#### Scenario: from 或 to 参数缺失
|
||||
- **WHEN** 客户端请求 `GET /api/targets/1/history` 未提供 from 或 to 参数
|
||||
- **THEN** 系统 SHALL 返回 400 状态码和错误信息
|
||||
|
||||
#### Scenario: 无效的分页参数
|
||||
- **WHEN** 客户端请求 `GET /api/targets/1/history?from=ISO&to=ISO&page=abc`
|
||||
- **THEN** 系统 SHALL 返回 400 状态码和错误信息
|
||||
|
||||
### Requirement: 新增共享类型
|
||||
系统 SHALL 在 `src/shared/api.ts` 中定义 `RecentSample` 和 `HistoryResponse` 类型。
|
||||
|
||||
#### Scenario: RecentSample 类型
|
||||
- **WHEN** 前后端共享 `RecentSample` 类型
|
||||
- **THEN** 该类型 SHALL 包含 `timestamp: string`、`durationMs: number | null`、`up: boolean` 字段
|
||||
|
||||
#### Scenario: HistoryResponse 类型
|
||||
- **WHEN** 前后端共享 `HistoryResponse` 类型
|
||||
- **THEN** 该类型 SHALL 包含 `items: CheckResult[]`、`total: number`、`page: number`、`pageSize: number` 字段
|
||||
|
||||
## MODIFIED Requirements
|
||||
|
||||
### Requirement: 总览统计 API
|
||||
系统 SHALL 提供 `GET /api/summary` 端点,返回所有目标的总体统计信息(不含平均耗时)。
|
||||
|
||||
#### Scenario: 获取总览统计
|
||||
- **WHEN** 客户端请求 `GET /api/summary`
|
||||
- **THEN** 系统 SHALL 返回 JSON 包含 total(总目标数)、up(正常数)、down(异常数)、lastCheckTime(最近一次检查时间)
|
||||
|
||||
### Requirement: 目标列表 API
|
||||
系统 SHALL 提供 `GET /api/targets` 端点,返回所有 typed target 及其最新状态、分组信息和结构化采样数据。
|
||||
|
||||
#### Scenario: 获取目标列表
|
||||
- **WHEN** 客户端请求 `GET /api/targets`
|
||||
- **THEN** 系统 SHALL 返回 JSON 数组,每个元素包含目标基本信息(id、name、group、type、target、interval)、最近一次检查结果(timestamp、success、matched、durationMs、statusDetail、failure)和结构化采样数据 recentSamples(代替原 sparkline)
|
||||
|
||||
#### Scenario: 目标无历史记录
|
||||
- **WHEN** 某目标尚未执行过任何拨测
|
||||
- **THEN** 其 latestCheck 为 null,recentSamples 为空数组
|
||||
|
||||
### Requirement: 历史记录 API
|
||||
系统 SHALL 提供 `GET /api/targets/:id/history` 端点,支持时间范围筛选和分页返回指定目标的拨测记录。
|
||||
|
||||
#### Scenario: 获取指定时间范围内的历史记录
|
||||
- **WHEN** 客户端请求 `GET /api/targets/1/history?from=ISO&to=ISO&page=1&pageSize=20`
|
||||
- **THEN** 系统 SHALL 返回带分页信息的历史记录,包含 items、total、page、pageSize,按时间倒序排列
|
||||
|
||||
#### Scenario: 使用默认分页参数
|
||||
- **WHEN** 客户端请求 `GET /api/targets/1/history?from=ISO&to=ISO`(未指定 page 或 pageSize)
|
||||
- **THEN** 系统 SHALL 使用默认 page=1, pageSize=20
|
||||
@@ -0,0 +1,29 @@
|
||||
## ADDED Requirements
|
||||
|
||||
### Requirement: target 分组字段
|
||||
系统 SHALL 支持在每个 target 上配置可选的 `group` 字段。
|
||||
|
||||
#### Scenario: 配置分组名称
|
||||
- **WHEN** YAML 配置中某个 target 指定 `group: "搜索引擎"`
|
||||
- **THEN** 系统 SHALL 将该 group 值解析并传递给后续模块
|
||||
|
||||
#### Scenario: group 字段可选
|
||||
- **WHEN** YAML 配置中某个 target 未指定 `group` 字段
|
||||
- **THEN** 系统 SHALL 使用默认值 "default"
|
||||
|
||||
## MODIFIED Requirements
|
||||
|
||||
### Requirement: YAML 配置文件格式
|
||||
系统 SHALL 支持通过 YAML 配置文件定义全部运行参数,包括 server 配置、runtime 配置、checker 默认值和 typed target 列表(含可选 group 字段)。target MUST 使用 `type` 字段声明 checker 类型,HTTP 领域字段 MUST 放在 `http` 分组,command 领域字段 MUST 放在 `command` 分组。
|
||||
|
||||
#### Scenario: 完整配置文件解析
|
||||
- **WHEN** 系统启动并读取包含 server、runtime、defaults、targets(含 group 字段)的 YAML 配置文件
|
||||
- **THEN** 系统 SHALL 正确解析所有字段并用于初始化服务、调度引擎和对应 checker runner
|
||||
|
||||
#### Scenario: 最简 HTTP 配置文件解析
|
||||
- **WHEN** 系统读取只包含一个 `type: http` target 和 `http.url` 的 YAML 配置文件(省略 server、runtime、defaults 和 expect)
|
||||
- **THEN** 系统 SHALL 使用内置默认值填充未指定的字段(host=127.0.0.1, port=3000, dir=./data, interval=30s, timeout=10s, runtime.maxConcurrentChecks=20, http.method=GET, http.maxBodyBytes=100MB, group="default")
|
||||
|
||||
#### Scenario: per-target 配置覆盖全局默认值
|
||||
- **WHEN** 某个 target 指定 interval、timeout 或对应领域分组中的默认字段
|
||||
- **THEN** 该 target SHALL 使用其自身的值,不受 defaults 中对应字段影响
|
||||
@@ -0,0 +1,113 @@
|
||||
## ADDED Requirements
|
||||
|
||||
### Requirement: 卡片式分组布局
|
||||
Dashboard SHALL 使用按分组展示的卡片式布局替代表格布局,每个分组包含带统计的分组标题和响应式卡片网格。
|
||||
|
||||
#### Scenario: 按分组渲染卡片
|
||||
- **WHEN** 用户打开 Dashboard 页面
|
||||
- **THEN** 页面 SHALL 按 group 字段将目标分组展示,每组一个区域,"默认分组" 排在最上面
|
||||
|
||||
#### Scenario: 无分组时的展示
|
||||
- **WHEN** 所有目标均属于 "default" 分组
|
||||
- **THEN** 页面 SHALL 显示一个 "默认分组" 区域,卡片正常展示
|
||||
|
||||
### Requirement: 分组标题展示
|
||||
Dashboard SHALL 在每个分组区域上方显示带统计信息的分组标题。
|
||||
|
||||
#### Scenario: 显示分组统计
|
||||
- **WHEN** 渲染分组区域
|
||||
- **THEN** 分组标题 SHALL 显示格式为 `分组名 (N个, X UP / Y DOWN)` 的统计信息
|
||||
|
||||
#### Scenario: default 分组标题
|
||||
- **WHEN** 分组名为 "default"
|
||||
- **THEN** 标题 SHALL 显示 "默认分组"
|
||||
|
||||
### Requirement: 目标卡片交互
|
||||
Dashboard SHALL 支持点击卡片弹出模态框查看详情。
|
||||
|
||||
#### Scenario: 点击卡片打开模态框
|
||||
- **WHEN** 用户点击某个目标卡片
|
||||
- **THEN** 系统 SHALL 弹出目标详情模态框,展示该目标的统计图表和检查记录
|
||||
|
||||
### Requirement: 卡片状态条可视化
|
||||
Dashboard SHALL 在卡片中展示最近 N 次检查的状态条,每个采样点用色块表示 UP/DOWN 状态。
|
||||
|
||||
#### Scenario: 渲染状态条
|
||||
- **WHEN** 卡片的 recentSamples 数据可用
|
||||
- **THEN** 卡片 SHALL 展示一条由色块组成的状态条,UP 为绿色,DOWN 为红色,无数据为灰色
|
||||
|
||||
### Requirement: 卡片迷你耗时趋势线
|
||||
Dashboard SHALL 在卡片中展示基于 recentSamples 的迷你耗时折线图。
|
||||
|
||||
#### Scenario: 渲染迷你趋势线
|
||||
- **WHEN** 卡片的 recentSamples 中有 durationMs 数据
|
||||
- **THEN** 卡片 SHALL 展示基于 recharts 的迷你折线图,展示最近采样的耗时趋势
|
||||
|
||||
### Requirement: 目标详情模态框
|
||||
Dashboard SHALL 提供模态框展示目标详情,包含时间范围筛选、多维统计图和分页检查记录列表。
|
||||
|
||||
#### Scenario: 模态框布局
|
||||
- **WHEN** 模态框打开
|
||||
- **THEN** 模态框 SHALL 占据视口 80% 宽度,图表区在上方展示统计图,检查记录列表在下方展示
|
||||
|
||||
#### Scenario: 时间范围筛选
|
||||
- **WHEN** 模态框打开
|
||||
- **THEN** 筛选栏 SHALL 包含快捷按钮(1h/6h/24h/7d)和分钟精度的自定义日期时间选择器
|
||||
|
||||
#### Scenario: 统计图表
|
||||
- **WHEN** 模态框加载完成
|
||||
- **THEN** 左侧 SHALL 展示可用率趋势折线图、耗时趋势折线图和状态分布环形图
|
||||
|
||||
#### Scenario: 检查记录分页
|
||||
- **WHEN** 检查记录超过一页
|
||||
- **THEN** 右侧列表底部 SHALL 展示分页器
|
||||
|
||||
## MODIFIED Requirements
|
||||
|
||||
### Requirement: 总览统计卡片
|
||||
Dashboard SHALL 在页面顶部展示总览统计卡片,包含总目标数、正常数和异常数(移除平均耗时)。
|
||||
|
||||
#### Scenario: 展示统计卡片
|
||||
- **WHEN** 用户打开 Dashboard 页面
|
||||
- **THEN** 页面顶部 SHALL 显示 3 个统计卡片:全部目标数、正常目标数、异常目标数
|
||||
|
||||
#### Scenario: 统计数据自动刷新
|
||||
- **WHEN** 页面处于打开状态
|
||||
- **THEN** 统计卡片 SHALL 每 5-10 秒自动刷新数据
|
||||
|
||||
### Requirement: 页面加载与错误状态
|
||||
Dashboard SHALL 正确处理加载状态和 API 错误,适配卡片式布局。
|
||||
|
||||
#### Scenario: 首次加载
|
||||
- **WHEN** 页面首次加载且数据尚未返回
|
||||
- **THEN** 页面 SHALL 显示加载状态指示
|
||||
|
||||
#### Scenario: API 请求失败
|
||||
- **WHEN** 前端轮询 API 请求失败
|
||||
- **THEN** 页面 SHALL 显示错误提示,并在下一次轮询周期自动重试
|
||||
|
||||
#### Scenario: 模态框内部加载状态
|
||||
- **WHEN** 模态框内趋势数据或历史记录正在加载
|
||||
- **THEN** 对应图表或列表区域 SHALL 显示加载指示
|
||||
|
||||
## REMOVED Requirements
|
||||
|
||||
### Requirement: 目标列表表格
|
||||
**Reason**: 替换为卡片式分组布局
|
||||
**Migration**: 使用 TargetBoard + TargetGroup + CardGrid + TargetCard 替代 TargetTable + TargetRow
|
||||
|
||||
### Requirement: 可展开的目标详情面板
|
||||
**Reason**: 替换为目标详情模态框
|
||||
**Migration**: 使用 TargetDetailModal 替代内联展开的 TargetDetail
|
||||
|
||||
### Requirement: 历史记录展示
|
||||
**Reason**: 合并到目标详情模态框的需求中
|
||||
**Migration**: 历史记录在模态框右侧展示,支持时间范围筛选和分页
|
||||
|
||||
### Requirement: 趋势图可视化
|
||||
**Reason**: 合并到目标详情模态框的需求中,卡片内的迷你图独立定义
|
||||
**Migration**: 模态框内的趋势图在 target-detail-modal spec 中定义,卡片内的迷你图在 card-dashboard spec 中定义
|
||||
|
||||
### Requirement: checker 类型展示
|
||||
**Reason**: 功能保留但合并到卡片内容需求中,无需独立 requirement
|
||||
**Migration**: 类型标签在卡片的行1中展示
|
||||
@@ -0,0 +1,67 @@
|
||||
## ADDED Requirements
|
||||
|
||||
### Requirement: targets 表分组列
|
||||
系统 SHALL 在 targets 表中新增 `grp` 列存储分组信息。
|
||||
|
||||
#### Scenario: 新增 grp 列
|
||||
- **WHEN** 数据库初始化
|
||||
- **THEN** targets 表 SHALL 包含 `grp TEXT NOT NULL DEFAULT 'default'` 列
|
||||
|
||||
#### Scenario: 同步分组信息
|
||||
- **WHEN** 系统同步 targets 到数据库
|
||||
- **THEN** 每个 target 的 grp 列 SHALL 存储其 group 配置值,未配置的存储 'default'
|
||||
|
||||
### Requirement: 结构化采样数据查询
|
||||
系统 SHALL 提供 `getRecentSamples` 方法替代 `getSparkline`,返回包含状态信息的结构化采样数据。
|
||||
|
||||
#### Scenario: 获取最近采样数据
|
||||
- **WHEN** 调用 `getRecentSamples(targetId, 30)`
|
||||
- **THEN** 系统 SHALL 返回最多 30 条记录,每条包含 timestamp、duration_ms、success、matched
|
||||
|
||||
#### Scenario: 采样数据排序
|
||||
- **WHEN** 获取采样数据
|
||||
- **THEN** 记录 SHALL 按 timestamp 降序排列(最新在前)
|
||||
|
||||
### Requirement: 趋势数据时间范围查询
|
||||
系统 SHALL 支持按任意时间范围查询趋势聚合数据,替代固定 hours 参数。
|
||||
|
||||
#### Scenario: 按时间范围查询趋势
|
||||
- **WHEN** 查询指定 target 在 from 到 to 时间范围内的趋势数据
|
||||
- **THEN** 系统 SHALL 返回按小时分组的聚合数据,包括每小时的 avgDurationMs、availability 和 totalChecks
|
||||
|
||||
### Requirement: 历史记录时间范围和分页查询
|
||||
系统 SHALL 支持按时间范围筛选并分页查询历史记录。
|
||||
|
||||
#### Scenario: 按时间范围筛选历史记录
|
||||
- **WHEN** 查询指定 target 在 from 到 to 时间范围内的历史记录
|
||||
- **THEN** 系统 SHALL 返回该时间范围内的记录,按 timestamp 降序排列
|
||||
|
||||
#### Scenario: 分页查询历史记录
|
||||
- **WHEN** 查询指定 page 和 pageSize 的历史记录
|
||||
- **THEN** 系统 SHALL 返回对应页的数据和总记录数
|
||||
|
||||
### Requirement: 目标列表按分组排序
|
||||
系统 SHALL 保证 targets 查询结果按分组排序返回。
|
||||
|
||||
#### Scenario: 分组排序查询
|
||||
- **WHEN** 查询所有 targets
|
||||
- **THEN** 结果 SHALL 将 "default" 分组目标排在首位,其余分组按分组名称和目标插入顺序排列
|
||||
|
||||
## MODIFIED Requirements
|
||||
|
||||
### Requirement: targets 表同步
|
||||
系统 SHALL 在启动时将 YAML 配置中的目标列表同步到 SQLite targets 表,并持久化 target 类型、展示摘要、领域配置、调度配置、expect 配置和分组信息。
|
||||
|
||||
#### Scenario: 首次同步目标
|
||||
- **WHEN** 数据库为空且 YAML 中定义了 N 个 typed target
|
||||
- **THEN** 系统 SHALL 将所有目标插入 targets 表,包含 name、type、target、config、interval_ms、timeout_ms、expect 和 grp
|
||||
|
||||
#### Scenario: 配置变更后重新同步
|
||||
- **WHEN** YAML 配置发生变更(新增、删除或修改目标)后重启
|
||||
- **THEN** 系统 SHALL 根据 name 字段匹配:新增的插入、删除的移除、修改的更新(含 grp 字段)
|
||||
|
||||
## REMOVED Requirements
|
||||
|
||||
### Requirement: sparkline 查询
|
||||
**Reason**: 替换为结构化 getRecentSamples 方法
|
||||
**Migration**: 使用 getRecentSamples 替代 getSparkline,新方法返回更丰富的结构化数据
|
||||
@@ -0,0 +1,72 @@
|
||||
## ADDED Requirements
|
||||
|
||||
### Requirement: 目标详情模态框
|
||||
Dashboard SHALL 在用户点击目标卡片后弹出模态框,展示该目标的详细统计图表和检查结果列表。
|
||||
|
||||
#### Scenario: 打开模态框
|
||||
- **WHEN** 用户点击某个目标卡片
|
||||
- **THEN** 系统 SHALL 弹出模态框,占据视口 80% 宽度,展示该目标的详情
|
||||
|
||||
#### Scenario: 模态框默认时间范围
|
||||
- **WHEN** 模态框打开
|
||||
- **THEN** 筛选器 SHALL 默认选中"最近 24 小时"
|
||||
|
||||
#### Scenario: 关闭模态框
|
||||
- **WHEN** 用户点击模态框关闭按钮或模态框外部区域
|
||||
- **THEN** 模态框 SHALL 关闭
|
||||
|
||||
### Requirement: 时间范围筛选
|
||||
模态框 SHALL 支持通过快捷按钮和自定义日期时间选择器筛选数据的时间范围。
|
||||
|
||||
#### Scenario: 快捷时间范围按钮
|
||||
- **WHEN** 模态框渲染
|
||||
- **THEN** 筛选栏 SHALL 显示快捷按钮:1h、6h、24h、7d,当前选中的按钮高亮显示
|
||||
|
||||
#### Scenario: 点击快捷按钮
|
||||
- **WHEN** 用户点击快捷按钮(如 "24h")
|
||||
- **THEN** 筛选器 SHALL 自动设置对应的起止时间,日期选择器显示对应的时间范围,该按钮高亮
|
||||
|
||||
#### Scenario: 自定义日期时间选择
|
||||
- **WHEN** 用户通过日期时间选择器修改起止时间(分钟精度)
|
||||
- **THEN** 快捷按钮 SHALL 取消高亮,表示当前为自定义时间范围
|
||||
|
||||
#### Scenario: 筛选触发数据刷新
|
||||
- **WHEN** 时间范围发生变化(快捷按钮或自定义选择)
|
||||
- **THEN** 系统 SHALL 重新请求该时间范围内的趋势数据和历史记录
|
||||
|
||||
### Requirement: 统计图表展示
|
||||
模态框左侧 SHALL 展示可用率趋势折线图、耗时趋势折线图和状态分布环形图。
|
||||
|
||||
#### Scenario: 可用率趋势折线图
|
||||
- **WHEN** 模态框加载完成且趋势数据可用
|
||||
- **THEN** 左侧 SHALL 展示可用率随时间变化的折线图,Y 轴为可用率百分比
|
||||
|
||||
#### Scenario: 耗时趋势折线图
|
||||
- **WHEN** 模态框加载完成且趋势数据可用
|
||||
- **THEN** 左侧 SHALL 展示耗时随时间变化的折线图,Y 轴为耗时毫秒数
|
||||
|
||||
#### Scenario: 状态分布环形图
|
||||
- **WHEN** 模态框加载完成
|
||||
- **THEN** 左侧 SHALL 展示环形图(Donut Chart),外圈显示 UP/DOWN 比例(绿色/红色),中间显示可用率百分比数字
|
||||
|
||||
### Requirement: 检查结果列表
|
||||
模态框右侧 SHALL 展示当前筛选时间范围内的检查结果列表,支持分页浏览。
|
||||
|
||||
#### Scenario: 展示检查结果
|
||||
- **WHEN** 模态框加载完成且历史记录可用
|
||||
- **THEN** 右侧 SHALL 展示检查结果列表,每条包含时间戳、UP/DOWN 状态标记、耗时毫秒数、statusDetail 和 failure 信息
|
||||
|
||||
#### Scenario: 分页导航
|
||||
- **WHEN** 检查结果总数超过一页
|
||||
- **THEN** 列表底部 SHALL 展示分页器,用户可点击切换页码
|
||||
|
||||
#### Scenario: 翻页刷新
|
||||
- **WHEN** 用户点击分页器切换页码
|
||||
- **THEN** 系统 SHALL 请求对应页码的历史记录数据,列表更新
|
||||
|
||||
### Requirement: 模态框布局
|
||||
模态框 SHALL 采用自上而下布局,上方展示统计图表,下方展示检查记录列表。
|
||||
|
||||
#### Scenario: 自上而下渲染
|
||||
- **WHEN** 模态框渲染
|
||||
- **THEN** 内容区域 SHALL 分为上下两部分,上方展示统计图表,下方展示检查结果列表和分页器
|
||||
@@ -0,0 +1,41 @@
|
||||
## ADDED Requirements
|
||||
|
||||
### Requirement: target 分组配置
|
||||
系统 SHALL 支持在每个 target 上配置可选的 `group` 字段,用于将目标归类到不同分组。未指定 `group` 的 target SHALL 归入 `"default"` 分组。
|
||||
|
||||
#### Scenario: 配置分组名称
|
||||
- **WHEN** YAML 配置中某个 target 指定 `group: "搜索引擎"`
|
||||
- **THEN** 系统 SHALL 将该 target 归类到 "搜索引擎" 分组
|
||||
|
||||
#### Scenario: 不配置分组
|
||||
- **WHEN** YAML 配置中某个 target 未指定 `group` 字段
|
||||
- **THEN** 系统 SHALL 将该 target 归类到 "default" 分组
|
||||
|
||||
#### Scenario: group 字段类型校验
|
||||
- **WHEN** YAML 配置中某个 target 的 `group` 字段不是字符串
|
||||
- **THEN** 系统 SHALL 以错误退出并提示 group 字段类型错误
|
||||
|
||||
### Requirement: 分组排序
|
||||
系统 SHALL 保证 "default" 分组始终排在最前面,其余分组按配置文件中首次出现的顺序排列。
|
||||
|
||||
#### Scenario: default 分组排最前
|
||||
- **WHEN** 配置中存在多个分组(包括 "default" 和自定义分组)
|
||||
- **THEN** API 返回的目标列表中 "default" 分组的目标 SHALL 排在其他分组之前
|
||||
|
||||
#### Scenario: 自定义分组按出现顺序
|
||||
- **WHEN** 配置中 "搜索引擎" 分组在 "后端服务" 分组之前首次出现
|
||||
- **THEN** API 返回中 "搜索引擎" 分组 SHALL 排在 "后端服务" 分组之前
|
||||
|
||||
### Requirement: 分组信息 API 传递
|
||||
系统 SHALL 在 API 响应中返回每个 target 的分组信息。
|
||||
|
||||
#### Scenario: targets 列表包含分组
|
||||
- **WHEN** 客户端请求 `GET /api/targets`
|
||||
- **THEN** 响应中每个目标 SHALL 包含 `group` 字段,值为该目标所属的分组名称
|
||||
|
||||
### Requirement: 分组存储
|
||||
系统 SHALL 在数据库 targets 表中持久化每个 target 的分组信息。
|
||||
|
||||
#### Scenario: 持久化分组信息
|
||||
- **WHEN** 系统同步 targets 到数据库
|
||||
- **THEN** 每个 target 的 `grp` 列 SHALL 存储其分组名称,未配置分组的存储 `"default"`
|
||||
@@ -0,0 +1,64 @@
|
||||
## 1. 后端:配置与类型
|
||||
|
||||
- [x] 1.1 types.ts: BaseTargetConfig 新增 group?: string, ResolvedTarget 新增 group: string
|
||||
- [x] 1.2 config-loader.ts: 解析 group 字段(默认 "default")
|
||||
- [x] 1.3 shared/api.ts: 新增 RecentSample 接口和 HistoryResponse 接口,移除 SummaryResponse.avgDurationMs,移除 TargetStats.avgDurationMs 和 TargetStats.p99DurationMs,TargetStatus 中 sparkline 替换为 recentSamples: RecentSample[],新增 group: string
|
||||
- [x] 1.4 编写 config-loader 的 group 解析校验测试
|
||||
|
||||
## 2. 后端:数据存储
|
||||
|
||||
- [x] 2.1 store.ts: targets 表新增 grp 列(ALTER TABLE 或重建建表语句),syncTargets 写入 grp 值
|
||||
- [x] 2.2 store.ts: 新增 getRecentSamples(targetId, limit) 方法替代 getSparkline,返回包含 timestamp/duration_ms/success/matched 的结构化数据
|
||||
- [x] 2.3 store.ts: getTrend 改用 from/to 时间范围参数替代 hours
|
||||
- [x] 2.4 store.ts: getHistory 改用 from/to 时间范围 + page/pageSize 分页参数,返回 { items, total, page, pageSize }
|
||||
- [x] 2.5 store.ts: getTargets 排序改为 ORDER BY CASE WHEN grp='default' THEN 0 ELSE 1 END, grp, id
|
||||
- [x] 2.6 store.ts: getSummary 移除 avgDurationMs 计算逻辑
|
||||
- [x] 2.7 store.ts: 移除 getSparkline 方法
|
||||
- [x] 2.8 编写 store 的新增/变更方法的完整测试
|
||||
|
||||
## 3. 后端:API 路由
|
||||
|
||||
- [x] 3.1 app.ts: createSummaryResponse 移除 avgDurationMs 字段
|
||||
- [x] 3.2 app.ts: createTargetsResponse 返回 group 和 recentSamples 替代 sparkline,移除 stats 中的 avgDurationMs 和 p99DurationMs
|
||||
- [x] 3.3 app.ts: handleTrend 改用 from/to 查询参数(替代 hours),校验参数格式
|
||||
- [x] 3.4 app.ts: handleHistory 改用 from/to + page/pageSize 参数,返回 HistoryResponse 结构(含 total)
|
||||
- [x] 3.5 app.ts: 移除 mapCheckResult 中已不需要的字段映射
|
||||
- [x] 3.6 编写 API 路由的测试,覆盖 from/to 参数校验、分页参数校验、recentSamples 返回结构
|
||||
|
||||
## 4. 前端:组件重构
|
||||
|
||||
- [x] 4.1 新增 StatusBar 组件:渲染 recentSampleCount 个色块(UP 绿/DOWN 红/无数据灰)
|
||||
- [x] 4.2 改造 SparklineChart 为 MiniSparkline:接收 RecentSample[] 数据,提取 durationMs 绘制迷你折线图
|
||||
- [x] 4.3 新增 GroupHeader 组件:显示分组名称和统计信息(分组名 (N个, X UP / Y DOWN)),default 显示"默认分组"
|
||||
- [x] 4.4 新增 TargetCard 组件:固定 280px 宽,行1 为 StatusDot + 名称 + 类型标签,行2 为 StatusBar + MiniSparkline,hover 上浮效果,点击触发回调
|
||||
- [x] 4.5 新增 CardGrid 组件:CSS Grid auto-fill 280px 响应式布局,接收 targets 数组渲染 TargetCard
|
||||
- [x] 4.6 新增 TargetGroup 组件:组合 GroupHeader + CardGrid,接收分组名和该组 targets
|
||||
- [x] 4.7 新增 TargetBoard 组件:接收 targets 数组,前端按 group 分组,顺序渲染 TargetGroup
|
||||
- [x] 4.8 新增 StatusDonut 组件:基于 recharts PieChart 实现环形图,中间显示可用率百分比,外圈 UP/DOWN 比例
|
||||
- [x] 4.9 新增 TimeRangePicker 组件:快捷按钮(1h/6h/24h/7d)+ 分钟精度日期选择器,联动逻辑
|
||||
- [x] 4.10 新增 Pagination 组件:显示页码按钮,支持翻页回调
|
||||
- [x] 4.11 新增 TargetDetailModal 组件:模态框布局(80% 视口宽),筛选栏 + 左侧图表区(40%)+ 右侧列表区(60%),组合 TrendChart/StatusDonut/Pagination
|
||||
- [x] 4.12 改造 TrendChart:适配 from/to 参数的时间范围,替代 hours
|
||||
- [x] 4.13 改造 app.tsx:SummaryCards 从 4 卡片改为 3 卡片,TargetTable 替换为 TargetBoard,模态框状态管理
|
||||
- [x] 4.14 移除 TargetTable、TargetRow、旧版 TargetDetail 组件
|
||||
|
||||
## 5. 前端:Hooks 与数据层
|
||||
|
||||
- [x] 5.1 新增 useTargetDetail hook:管理模态框状态,封装 trend + history 的并行请求逻辑
|
||||
- [x] 5.2 改造 useTrend hook:改用 from/to 参数请求 trend API
|
||||
- [x] 5.3 新增 useHistory hook:使用 from/to + page/pageSize 请求 history API,返回 HistoryResponse 结构
|
||||
|
||||
## 6. 前端:样式
|
||||
|
||||
- [x] 6.1 重写 styles.css:移除表格相关样式,新增卡片样式(280px 固定宽、圆角、阴影)、分组样式、模态框样式(backdrop + 居中 + 左右分栏)、StatusBar 样式(色块)、TimeRangePicker 样式、Pagination 样式、响应式媒体查询
|
||||
- [x] 6.2 SummaryCards grid 改为 repeat(3, 1fr)
|
||||
|
||||
## 7. 文档与配置示例
|
||||
|
||||
- [x] 7.1 更新 probes.example.yaml:新增 group 字段示例
|
||||
- [x] 7.2 更新 README.md:配置说明新增 group,API 端点变更说明,项目结构更新组件列表
|
||||
|
||||
## 8. 质量保障
|
||||
|
||||
- [x] 8.1 执行 bun run check(typecheck + lint + format:check + 单元测试),修复所有问题
|
||||
- [x] 8.2 执行 bun run verify(check + build + smoke test),确保构建产物正常运行
|
||||
@@ -0,0 +1,2 @@
|
||||
schema: spec-driven
|
||||
created: 2026-05-11
|
||||
@@ -0,0 +1,63 @@
|
||||
## Context
|
||||
|
||||
当前系统采用 `success` + `matched` 两层判定模型:
|
||||
- `success`:拨测是否成功完成(HTTP 收到响应 / Command 正常退出)
|
||||
- `matched`:expect 规则是否匹配
|
||||
- UP = `success AND matched`
|
||||
|
||||
但实际代码中 `fetcher.ts` 和 `command-runner.ts` 均将 `success` 设为 `expectResult.matched`,导致 `success ≡ matched`。两层模型从未真正生效,`success` 字段是冗余的。
|
||||
|
||||
同时发现两个附带问题:
|
||||
1. 分组排序使用 `ORDER BY grp`(字母序),spec 要求按 YAML 首现顺序
|
||||
2. `command-runner` 未设置 `stdin: "ignore"`,spec 要求禁止写入 stdin
|
||||
|
||||
## Goals / Non-Goals
|
||||
|
||||
**Goals:**
|
||||
|
||||
- 移除 `success` 字段,将判定模型简化为 `matched` 单层判定
|
||||
- 修复分组排序为 YAML 首现顺序
|
||||
- 确保 command-runner 禁用 stdin
|
||||
|
||||
**Non-Goals:**
|
||||
|
||||
- 不涉及 `success` 以外的其他字段变更
|
||||
- 不涉及前端 UI 样式或布局调整
|
||||
- 不涉及新增功能特性
|
||||
- 不处理代码质量问题(P2/P3 级别留给后续 change)
|
||||
|
||||
## Decisions
|
||||
|
||||
### 1. 判定模型简化为 matched 单层
|
||||
|
||||
**选择**: 移除 `success`,仅保留 `matched`
|
||||
|
||||
**理由**: `success` 与 `matched` 在实现中始终同值,没有独立语义。执行失败(网络错误、超时、进程崩溃)和 expect 不匹配都统一为 `matched=false`,通过 `failure.kind` 区分(`"error"` vs `"mismatch"`)。
|
||||
|
||||
**替代方案**: 修复 `success` 使其真正独立(如 HTTP 返回 404 时 success=true, matched=false)。被否决——当前项目不需要区分"请求成功但内容不符"和"请求失败",单层判定更简洁。
|
||||
|
||||
### 2. 可用率计算基于 matched
|
||||
|
||||
**选择**: `availability = matched=true 的记录数 / 总记录数 * 100`
|
||||
|
||||
avgDurationMs 仅计算 `matched=true` 记录的平均耗时:`AVG(CASE WHEN matched = 1 THEN duration_ms END)`
|
||||
|
||||
**理由**: 用户关心的是"健康请求"的性能趋势,排除失败请求的干扰。
|
||||
|
||||
### 3. 分组排序改为按 id 排序
|
||||
|
||||
**选择**: `ORDER BY CASE WHEN grp='default' THEN 0 ELSE 1 END, id`
|
||||
|
||||
**理由**: 目标按 YAML 顺序插入,`id` 自增,`ORDER BY id` 天然等于 YAML 首现顺序。无需额外存储排序权重。
|
||||
|
||||
### 4. 数据库迁移策略
|
||||
|
||||
**选择**: 直接删除旧数据库文件重新创建
|
||||
|
||||
**理由**: 项目未上线,无向前兼容要求。SQLite 不支持 `DROP COLUMN`(需重建表),直接删除是最简方案。
|
||||
|
||||
## Risks / Trade-offs
|
||||
|
||||
- [风险] 数据库 schema 变更导致已有数据丢失 → 项目未上线,可接受
|
||||
- [风险] 前端 API 响应格式变更 → 前端代码同步修改,全量测试覆盖
|
||||
- [风险] 大量文件同时修改可能引入遗漏 → 通过 `bun run verify` 全量验证
|
||||
@@ -0,0 +1,38 @@
|
||||
## Why
|
||||
|
||||
当前系统使用 `success` + `matched` 两层判定模型,但实际实现中 `success` 始终等于 `matched`(两者永远同值),导致两层模型退化为单层。`success` 字段没有提供任何独立信息,反而增加了理解成本和维护负担。此外,分组排序使用字母序而非 YAML 配置中的首现顺序,与 spec 要求不一致。
|
||||
|
||||
## What Changes
|
||||
|
||||
- **BREAKING**: 移除 `success` 字段,将判定模型简化为 `matched` 单层判定
|
||||
- 数据库 `check_results` 表移除 `success` 列
|
||||
- 所有 CheckResult 类型(服务端、共享、前端)移除 `success` 字段
|
||||
- UP/DOWN 判定统一为 `matched=true` / `matched=false`
|
||||
- availability 计算简化为 `matched=true 占比`
|
||||
- avgDurationMs 仅计算 `matched=true` 记录的平均耗时
|
||||
- 修复分组排序:非 default 分组按 YAML 首现顺序(即 `id` 顺序)排列,而非字母序
|
||||
- `command-runner` 添加 `stdin: "ignore"`,符合 spec 要求
|
||||
|
||||
## Capabilities
|
||||
|
||||
### New Capabilities
|
||||
|
||||
无
|
||||
|
||||
### Modified Capabilities
|
||||
|
||||
- `probe-engine`: 移除结果记录中的 `success` 字段,简化为 `matched` + `failure`
|
||||
- `probe-api`: `CheckResult` 和 `RecentSample` 移除 `success` 字段;UP 判定改为 `matched`
|
||||
- `probe-data-store`: 数据库 schema 移除 `success` 列;可用率定义简化;排序规则修正
|
||||
- `probe-dashboard`: UP/DOWN 判定改为 `matched`
|
||||
- `card-dashboard`: UP/DOWN 判定改为 `matched`
|
||||
- `command-checker`: 移除所有 `success` 引用;确保 stdin 禁用
|
||||
- `target-grouping`: 排序规则明确为按 id 排序(YAML 首现顺序)
|
||||
|
||||
## Impact
|
||||
|
||||
- **数据库**: `check_results` 表结构变更(移除列),已有数据库需删除重建(项目未上线,无向前兼容要求)
|
||||
- **API**: `CheckResult` 响应移除 `success` 字段,`RecentSample.up` 计算逻辑变更
|
||||
- **前端**: 所有依赖 `success` 字段的组件需更新(TargetCard、TargetDetailModal、TargetGroup)
|
||||
- **测试**: 5 个测试文件需同步移除 `success` 相关断言
|
||||
- **README**: 目标状态判定模型说明需更新
|
||||
@@ -0,0 +1,12 @@
|
||||
## MODIFIED Requirements
|
||||
|
||||
### Requirement: 卡片状态展示
|
||||
系统 SHALL 在卡片上展示目标的 UP/DOWN 状态。
|
||||
|
||||
#### Scenario: 卡片 UP 状态
|
||||
- **WHEN** 目标最近一次拨测 matched=true
|
||||
- **THEN** 系统 SHALL 显示绿色状态点
|
||||
|
||||
#### Scenario: 卡片 DOWN 状态
|
||||
- **WHEN** 目标最近一次拨测 matched=false
|
||||
- **THEN** 系统 SHALL 显示红色状态点
|
||||
@@ -0,0 +1,26 @@
|
||||
## MODIFIED Requirements
|
||||
|
||||
### Requirement: 命令执行
|
||||
系统 SHALL 使用 Bun.spawn 执行命令类型目标,继承父进程环境变量并支持覆盖。
|
||||
|
||||
#### Scenario: 禁止 stdin 交互
|
||||
- **THEN** 系统 MUST 设置 stdin 为 "ignore",防止子进程等待标准输入而阻塞
|
||||
|
||||
### Requirement: 结果记录
|
||||
系统 SHALL 记录命令执行的完整结果。
|
||||
|
||||
#### Scenario: 命令成功执行
|
||||
- **WHEN** 命令正常退出
|
||||
- **THEN** 系统 SHALL 记录 durationMs、statusDetail="exitCode=N",并进入 expect 校验
|
||||
|
||||
#### Scenario: 命令启动失败
|
||||
- **WHEN** 命令无法启动
|
||||
- **THEN** 系统 SHALL 记录 matched=false,并在 failure 中写入 kind=error 和具体错误信息
|
||||
|
||||
#### Scenario: 命令超时
|
||||
- **WHEN** 命令执行超过 timeout 限制
|
||||
- **THEN** 系统 MUST 终止该子进程,记录 matched=false,并在 failure 中写入命令超时信息
|
||||
|
||||
#### Scenario: 输出超限
|
||||
- **WHEN** 命令输出超过 maxOutputBytes 限制
|
||||
- **THEN** 系统 MUST 停止收集输出并终止该检查,记录 matched=false,并在 failure 中写入输出超限信息
|
||||
@@ -0,0 +1,17 @@
|
||||
## MODIFIED Requirements
|
||||
|
||||
### Requirement: 目标列表 API
|
||||
系统 SHALL 提供 `GET /api/targets` 端点,返回所有目标及其最新状态。
|
||||
|
||||
#### Scenario: recentSamples.up 判定
|
||||
- **WHEN** 系统返回 recentSamples 数组
|
||||
- **THEN** 每个元素的 `up` 字段 SHALL 为 `matched === true`
|
||||
|
||||
### Requirement: 共享类型
|
||||
系统 SHALL 在 `src/shared/api.ts` 中定义前后端共享的 TypeScript 类型。
|
||||
|
||||
#### Scenario: CheckResult 类型
|
||||
- **THEN** `CheckResult` 类型 SHALL 包含 timestamp、matched、durationMs、statusDetail、failure 字段,不包含 success 字段
|
||||
|
||||
#### Scenario: RecentSample 类型
|
||||
- **THEN** `RecentSample` 类型 SHALL 包含 timestamp、durationMs、up 字段,其中 up 为 boolean 且等于 matched
|
||||
@@ -0,0 +1,12 @@
|
||||
## MODIFIED Requirements
|
||||
|
||||
### Requirement: 状态判定与展示
|
||||
系统 SHALL 根据最近一次拨测结果展示目标状态。
|
||||
|
||||
#### Scenario: 目标 UP 状态
|
||||
- **WHEN** 目标最近一次拨测 matched=true
|
||||
- **THEN** 系统 SHALL 显示绿色 UP 状态
|
||||
|
||||
#### Scenario: 目标 DOWN 状态
|
||||
- **WHEN** 目标最近一次拨测 matched=false
|
||||
- **THEN** 系统 SHALL 显示红色 DOWN 状态
|
||||
@@ -0,0 +1,41 @@
|
||||
## MODIFIED Requirements
|
||||
|
||||
### Requirement: 数据库表结构
|
||||
系统 SHALL 使用 SQLite 存储 targets 和 check_results 两张表。
|
||||
|
||||
#### Scenario: check_results 表结构
|
||||
- **THEN** check_results 表 SHALL 包含 id(INTEGER PRIMARY KEY AUTOINCREMENT)、target_id(INTEGER NOT NULL)、timestamp(TEXT NOT NULL)、matched(INTEGER NOT NULL)、duration_ms(REAL)、status_detail(TEXT)、failure(TEXT)列,不包含 success 列
|
||||
|
||||
### Requirement: 结果写入
|
||||
系统 SHALL 将每次拨测结果插入 check_results 表。
|
||||
|
||||
#### Scenario: 插入结果记录
|
||||
- **THEN** 系统 SHALL 插入一条包含 target_id、timestamp、matched、duration_ms、status_detail、failure 的记录
|
||||
|
||||
### Requirement: 可用率计算
|
||||
系统 SHALL 计算目标在指定时间范围内的可用率。
|
||||
|
||||
#### Scenario: 可用率定义
|
||||
- **THEN** 系统 SHALL 返回 matched=true 的记录数占总记录数的百分比
|
||||
|
||||
#### Scenario: 平均耗时
|
||||
- **THEN** 系统 SHALL 返回 duration_ms 的平均值(仅计算 matched=true 的记录)
|
||||
|
||||
### Requirement: 目标排序
|
||||
系统 SHALL 按分组排序返回目标列表。
|
||||
|
||||
#### Scenario: 分组排序规则
|
||||
- **WHEN** 查询目标列表
|
||||
- **THEN** "default" 分组 SHALL 排在最前,其余分组 SHALL 按 YAML 配置中首次出现的顺序(即 id 自增顺序)排列
|
||||
|
||||
### Requirement: 最近采样查询
|
||||
系统 SHALL 提供获取目标最近 N 条采样记录的方法。
|
||||
|
||||
#### Scenario: 采样记录返回字段
|
||||
- **THEN** 系统 SHALL 返回最多 N 条记录,每条包含 timestamp、duration_ms、matched
|
||||
|
||||
### Requirement: 汇总查询
|
||||
系统 SHALL 提供全局汇总统计。
|
||||
|
||||
#### Scenario: UP/DOWN 判定
|
||||
- **THEN** 系统 SHALL 基于 latestCheck.matched 判定目标 UP 或 DOWN:matched=true 为 UP,matched=false 为 DOWN
|
||||
@@ -0,0 +1,21 @@
|
||||
## MODIFIED Requirements
|
||||
|
||||
### Requirement: 结果记录
|
||||
系统 SHALL 在每次 checker 完成后,将结果写入 SQLite 数据存储,包含 target_id、timestamp、matched、duration_ms、status_detail、failure 字段。
|
||||
|
||||
#### Scenario: 执行成功且 expect 全部匹配
|
||||
- **WHEN** checker 执行成功且所有 expect 规则匹配
|
||||
- **THEN** 系统 SHALL 记录 matched=true、duration_ms、status_detail,failure 为 null
|
||||
|
||||
#### Scenario: 执行失败(网络错误、超时、进程异常)
|
||||
- **THEN** 系统 SHALL 记录 matched=false、failure.kind="error" 和具体错误信息
|
||||
|
||||
#### Scenario: expect 不匹配
|
||||
- **THEN** 系统 SHALL 记录 matched=false、failure.kind="mismatch" 和具体不匹配信息
|
||||
|
||||
### Requirement: 输出读取限制
|
||||
系统 SHALL 对 command checker 的 stdout/stderr 输出设置大小限制。
|
||||
|
||||
#### Scenario: 输出超过 maxOutputBytes
|
||||
- **WHEN** 子进程输出超过 maxOutputBytes 限制
|
||||
- **THEN** 系统 MUST 停止读取并记录 matched=false 和结构化输出超限错误
|
||||
@@ -0,0 +1,8 @@
|
||||
## MODIFIED Requirements
|
||||
|
||||
### Requirement: 分组排序
|
||||
系统 SHALL 对非 default 分组按 YAML 配置中的首次出现顺序排列。
|
||||
|
||||
#### Scenario: 非默认分组排序
|
||||
- **WHEN** 查询目标列表
|
||||
- **THEN** 非 default 分组 SHALL 按 id 自增顺序排列(即 YAML 配置中的首次出现顺序),而非字母序
|
||||
@@ -0,0 +1,49 @@
|
||||
## 1. 核心类型变更
|
||||
|
||||
- [x] 1.1 从 `src/server/checker/types.ts` 的 CheckResult 和 StoredCheckResult 类型中移除 `success` 字段
|
||||
- [x] 1.2 从 `src/shared/api.ts` 的 CheckResult 类型中移除 `success` 字段
|
||||
|
||||
## 2. 数据存储层变更
|
||||
|
||||
- [x] 2.1 修改 `src/server/checker/store.ts`:DDL 移除 `success` 列,INSERT 语句移除 success 绑定,所有查询中移除 success 引用
|
||||
- [x] 2.2 修改 `src/server/checker/store.ts`:getSummary 中 UP 判定改为 `latest.matched`
|
||||
- [x] 2.3 修改 `src/server/checker/store.ts`:getTargetStats 可用率计算改为 `matched = 1`
|
||||
- [x] 2.4 修改 `src/server/checker/store.ts`:getTrend 中 availability 和 avgDurationMs 改为基于 `matched = 1`
|
||||
- [x] 2.5 修改 `src/server/checker/store.ts`:getRecentSamples 返回类型移除 success,SELECT 移除 success 列
|
||||
- [x] 2.6 修改 `src/server/checker/store.ts`:分组排序 ORDER BY 移除 `grp`,改为 `ORDER BY CASE WHEN grp='default' THEN 0 ELSE 1 END, id`
|
||||
|
||||
## 3. 拨测执行层变更
|
||||
|
||||
- [x] 3.1 修改 `src/server/checker/fetcher.ts`:所有 CheckResult 返回值中移除 `success` 字段
|
||||
- [x] 3.2 修改 `src/server/checker/command-runner.ts`:所有 CheckResult 返回值中移除 `success` 字段
|
||||
- [x] 3.3 修改 `src/server/checker/command-runner.ts`:Bun.spawn 添加 `stdin: "ignore"`
|
||||
- [x] 3.4 修改 `src/server/checker/engine.ts`:writeResult 调用中移除 `success` 传递
|
||||
|
||||
## 4. API 路由层变更
|
||||
|
||||
- [x] 4.1 修改 `src/server/app.ts`:mapCheckResult 移除 `success` 字段映射
|
||||
- [x] 4.2 修改 `src/server/app.ts`:recentSamples.up 判定改为 `s.matched === 1`
|
||||
|
||||
## 5. 前端组件变更
|
||||
|
||||
- [x] 5.1 修改 `src/web/components/TargetCard.tsx`:isUp 判定改为 `target.latestCheck?.matched`
|
||||
- [x] 5.2 修改 `src/web/components/TargetGroup.tsx`:up 计数改为 `t.latestCheck?.matched`
|
||||
- [x] 5.3 修改 `src/web/components/TargetDetailModal.tsx`:isUp 和 history 行状态改为基于 `matched`
|
||||
|
||||
## 6. 测试同步
|
||||
|
||||
- [x] 6.1 更新 `tests/server/checker/fetcher.test.ts`:移除所有 `success` 相关断言,改为 `matched` 断言
|
||||
- [x] 6.2 更新 `tests/server/checker/command-runner.test.ts`:移除所有 `success` 相关断言,改为 `matched` 断言
|
||||
- [x] 6.3 更新 `tests/server/checker/engine.test.ts`:移除所有 `success` 相关断言,改为 `matched` 断言
|
||||
- [x] 6.4 更新 `tests/server/checker/store.test.ts`:插入数据移除 `success` 字段,查询断言移除 `success` 检查
|
||||
- [x] 6.5 更新 `tests/server/app.test.ts`:API 响应断言移除 `success` 字段
|
||||
|
||||
## 7. 质量验证
|
||||
|
||||
- [x] 7.1 执行 `bun run check`(typecheck + lint + format + test)确保全部通过
|
||||
- [x] 7.2 执行 `bun run verify`(check + build + smoke test)确保全部通过
|
||||
|
||||
## 8. 文档更新
|
||||
|
||||
- [x] 8.1 更新 README.md:目标状态判定模型改为 matched 单层判定,移除 success 说明
|
||||
- [x] 8.2 更新 README.md:响应字段中移除 CheckResult 的 success 字段描述
|
||||
@@ -11,6 +11,7 @@ context: |
|
||||
- 禁止创建git操作task
|
||||
- 积极使用subagents精心设计并行任务,节省上下文空间,加速任务执行
|
||||
- 优先使用提问工具对用户进行提问
|
||||
- (当前项目未上线,不需要考虑向前兼容)
|
||||
|
||||
rules:
|
||||
proposal:
|
||||
@@ -19,3 +20,6 @@ rules:
|
||||
- 先前的讨论技术方案要尽可能体现在设计文档中,便于指导实现阶段不偏离已定的技术路线
|
||||
tasks:
|
||||
- 一行一个任务,严禁任务内容跨行
|
||||
- 如果是代码存在更新必须
|
||||
- 执行完整的测试、代码检查、格式检查等质量保障手段
|
||||
- 更新README文档
|
||||
|
||||
98
openspec/specs/card-dashboard/spec.md
Normal file
98
openspec/specs/card-dashboard/spec.md
Normal file
@@ -0,0 +1,98 @@
|
||||
## Purpose
|
||||
|
||||
定义 Dashboard 的卡片式分组布局:按分组展示目标卡片、响应式网格、卡片内容结构和交互行为。
|
||||
|
||||
## Requirements
|
||||
|
||||
### Requirement: 分组卡片布局
|
||||
Dashboard SHALL 按分组展示所有拨测目标,每个分组包含带统计的分组标题和固定宽度的卡片网格。
|
||||
|
||||
#### Scenario: 按分组展示目标
|
||||
- **WHEN** 用户打开 Dashboard 页面
|
||||
- **THEN** 页面 SHALL 按分组展示目标卡片,"默认分组" 排在最上面,其余分组按 YAML 配置顺序排列
|
||||
|
||||
#### Scenario: 分组标题带统计徽章
|
||||
- **WHEN** 页面渲染某个分组
|
||||
- **THEN** 分组标题 SHALL 显示分组名称和三个徽章:总数(蓝色)、正常数(绿色)、异常数(红色),徽章仅显示数字
|
||||
|
||||
#### Scenario: 分组统计徽章提示
|
||||
- **WHEN** 鼠标悬停在分组统计徽章上
|
||||
- **THEN** 徽章 SHALL 显示提示文字("总数"、"正常"、"异常")
|
||||
|
||||
#### Scenario: "default" 分组显示名称
|
||||
- **WHEN** 分组名称为 "default"
|
||||
- **THEN** 分组标题 SHALL 显示 "默认分组"
|
||||
|
||||
### Requirement: 响应式卡片网格
|
||||
Dashboard SHALL 使用固定宽度的卡片配合 Flexbox 流动布局,容器无最大宽度限制。
|
||||
|
||||
#### Scenario: Dashboard 容器占满宽度
|
||||
- **WHEN** 用户打开 Dashboard 页面
|
||||
- **THEN** Dashboard 容器 SHALL 占满浏览器宽度,不设置 max-width 限制
|
||||
|
||||
#### Scenario: 卡片固定宽度
|
||||
- **WHEN** 页面渲染卡片(包括 Summary Cards 和 Target Cards)
|
||||
- **THEN** 每个卡片 SHALL 固定宽度 280px,使用 CSS 变量 `--dashboard-card-width` 统一控制
|
||||
|
||||
#### Scenario: 流动式布局
|
||||
- **WHEN** 视口宽度变化
|
||||
- **THEN** 卡片网格 SHALL 使用 Flexbox wrap 自动换行,根据可用宽度调整单行卡片数量
|
||||
|
||||
#### Scenario: 卡片左对齐
|
||||
- **WHEN** 页面渲染卡片网格
|
||||
- **THEN** 卡片 SHALL 左对齐排列,右侧自然留白
|
||||
|
||||
#### Scenario: 统一间距
|
||||
- **WHEN** 页面渲染 Summary Cards 和 Target Cards
|
||||
- **THEN** 两种卡片网格 SHALL 使用相同的 gap 间距(16px)
|
||||
|
||||
### Requirement: 目标卡片内容
|
||||
每个目标卡片 SHALL 展示目标名称、当前状态、类型标签、状态条和迷你耗时趋势线,采用垂直三层布局。
|
||||
|
||||
#### Scenario: 卡片第一层内容
|
||||
- **WHEN** 卡片渲染
|
||||
- **THEN** 卡片第一层 SHALL 展示状态指示圆点(UP 绿色 / DOWN 红色)、目标名称和类型标签(HTTP / CMD)
|
||||
|
||||
#### Scenario: 卡片名称完整提示
|
||||
- **WHEN** 目标名称过长被截断显示
|
||||
- **THEN** 鼠标悬停在名称上 SHALL 通过浏览器原生 tooltip 显示完整名称
|
||||
|
||||
#### Scenario: 卡片状态指示圆点
|
||||
- **WHEN** 目标最近一次拨测 matched=true
|
||||
- **THEN** 卡片状态圆点 SHALL 显示为绿色
|
||||
- **WHEN** 目标最近一次拨测 matched=false
|
||||
- **THEN** 卡片状态圆点 SHALL 显示为红色
|
||||
|
||||
#### Scenario: 卡片第二层状态条
|
||||
- **WHEN** 卡片渲染且 recentSamples 数据可用
|
||||
- **THEN** 卡片第二层 SHALL 独占一行展示状态条,包含 30 个色块,每个采样点为一个色块:UP 显示绿色(#1fbf75),DOWN 显示红色(#e5484d),无数据显示灰色(#e2e8f0)
|
||||
|
||||
#### Scenario: 卡片第三层迷你耗时趋势线
|
||||
- **WHEN** 卡片渲染且 recentSamples 中有 durationMs 数据
|
||||
- **THEN** 卡片第三层 SHALL 独占一行展示基于 recharts 的迷你折线图,宽度占满卡片内容区(约 238px),高度 40px,展示最近 30 次检查的耗时趋势
|
||||
|
||||
#### Scenario: 卡片垂直布局间距
|
||||
- **WHEN** 卡片渲染
|
||||
- **THEN** 卡片三层之间 SHALL 使用 12px 的间距(gap)
|
||||
|
||||
### Requirement: 卡片交互
|
||||
卡片 SHALL 支持 hover 效果和点击打开模态框。
|
||||
|
||||
#### Scenario: 卡片 hover 效果
|
||||
- **WHEN** 鼠标悬停在卡片上
|
||||
- **THEN** 卡片 SHALL 显示上浮效果(阴影加深)
|
||||
|
||||
#### Scenario: 卡片点击打开详情
|
||||
- **WHEN** 用户点击某个目标卡片
|
||||
- **THEN** 系统 SHALL 打开该目标的详情模态框
|
||||
|
||||
### Requirement: 平滑过渡动画
|
||||
卡片 SHALL 具有平滑的交互过渡动画效果。
|
||||
|
||||
#### Scenario: 卡片悬停动画
|
||||
- **WHEN** 鼠标悬停在卡片上
|
||||
- **THEN** 卡片 SHALL 平滑过渡显示上浮效果(阴影加深、轻微上移),过渡时长 0.3s
|
||||
|
||||
#### Scenario: 布局变化过渡
|
||||
- **WHEN** 视口宽度变化导致卡片重新排列
|
||||
- **THEN** 卡片位置变化 SHALL 有平滑的过渡动画
|
||||
82
openspec/specs/command-checker/spec.md
Normal file
82
openspec/specs/command-checker/spec.md
Normal file
@@ -0,0 +1,82 @@
|
||||
## Purpose
|
||||
|
||||
定义 Command 类型拨测目标:通过 `type: command` 配置执行本地命令(如进程检查、脚本健康检测),捕获 exit code、stdout、stderr,按 expect 规则校验并生成 matched 判定。
|
||||
|
||||
## 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 记录 `durationMs`、`statusDetail="exitCode=0"`,并进入 expect 校验
|
||||
|
||||
#### Scenario: 命令非零退出
|
||||
- **WHEN** command target 执行的进程正常退出但 exit code 为 1
|
||||
- **THEN** 系统 SHALL 记录 `statusDetail="exitCode=1"`,并由 expect.exitCode 决定 matched 结果
|
||||
|
||||
#### Scenario: 命令启动失败
|
||||
- **WHEN** command target 的 exec 不存在或无法启动
|
||||
- **THEN** 系统 SHALL 记录 `matched=false`,并在 failure 中写入 kind=`error` 和可读错误信息
|
||||
|
||||
#### Scenario: 命令超时
|
||||
- **WHEN** command target 在 timeout 时间内未结束
|
||||
- **THEN** 系统 MUST 终止该子进程,记录 `matched=false`,并在 failure 中写入命令超时信息
|
||||
|
||||
#### Scenario: 命令输出超限
|
||||
- **WHEN** command target 的 stdout 和 stderr 合计输出超过 `maxOutputBytes`
|
||||
- **THEN** 系统 MUST 停止收集输出并终止该检查,记录 `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 规则
|
||||
128
openspec/specs/expect-body-checkers/spec.md
Normal file
128
openspec/specs/expect-body-checkers/spec.md
Normal file
@@ -0,0 +1,128 @@
|
||||
## Purpose
|
||||
|
||||
定义 HTTP 拨测中响应体校验方法集(contains/regex/json/css/xpath)、操作符系统和响应头校验的行为规范。
|
||||
|
||||
## Requirements
|
||||
|
||||
### Requirement: 响应体多种校验方法
|
||||
系统 SHALL 支持对 HTTP 响应体进行五种可组合的校验方法:contains(子串)、regex(正则)、json(JSONPath)、css(CSS 选择器)、xpath(XPath)。这些方法 MUST 配置在 `expect.body` 有序数组中。
|
||||
|
||||
#### Scenario: contains 子串匹配
|
||||
- **WHEN** HTTP target 配置 `expect.body: [{contains: "healthy"}]`,且响应体包含 `"healthy"`
|
||||
- **THEN** 系统 SHALL 判定该 body 规则通过
|
||||
|
||||
#### Scenario: contains 不匹配
|
||||
- **WHEN** HTTP target 配置 `expect.body: [{contains: "healthy"}]`,且响应体不包含该文本
|
||||
- **THEN** 系统 SHALL 判定 matched 为 false,并记录该规则的 failure.path
|
||||
|
||||
#### Scenario: regex 正则匹配
|
||||
- **WHEN** HTTP target 配置 `expect.body: [{regex: '"status"\\s*:\\s*"ok"'}]`,且响应体匹配该正则
|
||||
- **THEN** 系统 SHALL 判定该 body 规则通过
|
||||
|
||||
#### Scenario: regex 不匹配
|
||||
- **WHEN** HTTP target 配置 regex body 规则,且响应体不匹配该正则
|
||||
- **THEN** 系统 SHALL 判定 matched 为 false,并记录该规则的 failure.path
|
||||
|
||||
#### Scenario: json JSONPath 等值匹配
|
||||
- **WHEN** HTTP target 配置 `expect.body: [{json: {path: "$.status", equals: "ok"}}]`,且响应 JSON 中 `$.status` 值为 `"ok"`
|
||||
- **THEN** 系统 SHALL 判定该 body 规则通过
|
||||
|
||||
#### Scenario: json JSONPath 值不匹配
|
||||
- **WHEN** HTTP target 配置 json body 规则,且提取值不符合期望
|
||||
- **THEN** 系统 SHALL 判定 matched 为 false,并记录包含 JSONPath 的 failure.path
|
||||
|
||||
#### Scenario: json 解析失败
|
||||
- **WHEN** HTTP target 配置了 json body 规则但响应体不是合法 JSON
|
||||
- **THEN** 系统 SHALL 判定 matched 为 false
|
||||
|
||||
#### Scenario: css 选择器匹配
|
||||
- **WHEN** HTTP target 配置 `expect.body: [{css: {selector: "div#health", equals: "OK"}}]`,且 HTML 中存在 `div#health` 元素文本为 `"OK"`
|
||||
- **THEN** 系统 SHALL 判定该 body 规则通过
|
||||
|
||||
#### Scenario: css 选择器匹配属性值
|
||||
- **WHEN** HTTP target 配置 css 规则带 `attr: "content"` 用于提取属性,且属性值匹配期望
|
||||
- **THEN** 系统 SHALL 判定该 body 规则通过
|
||||
|
||||
#### Scenario: css 选择器无匹配元素
|
||||
- **WHEN** HTTP target 配置了 css 选择器但 HTML 中无匹配元素
|
||||
- **THEN** 系统 SHALL 判定 matched 为 false
|
||||
|
||||
#### Scenario: xpath 表达式匹配
|
||||
- **WHEN** HTTP target 配置 `expect.body: [{xpath: {path: "/root/status/text()", equals: "ok"}}]`,且 XML 中 `/root/status` 节点文本为 `"ok"`
|
||||
- **THEN** 系统 SHALL 判定该 body 规则通过
|
||||
|
||||
#### Scenario: xpath 表达式无匹配节点
|
||||
- **WHEN** HTTP target 配置了 xpath 表达式但 XML 中无匹配节点
|
||||
- **THEN** 系统 SHALL 判定 matched 为 false
|
||||
|
||||
### Requirement: 多种 body 校验方法 AND 组合
|
||||
系统 SHALL 支持在 `expect.body` 数组中同时配置多种 body 校验方法,所有方法均通过时 matched 方为 true。
|
||||
|
||||
#### Scenario: 多种方法全部通过
|
||||
- **WHEN** HTTP target 的 `expect.body` 数组依次配置 contains、json、regex,且全部通过
|
||||
- **THEN** 系统 SHALL 判定 matched 为 true
|
||||
|
||||
#### Scenario: 多种方法任一失败
|
||||
- **WHEN** HTTP target 的 `expect.body` 数组第一条 contains 不通过,后续还有 json 规则
|
||||
- **THEN** 系统 SHALL 判定 matched 为 false,且不再检查后续 json 规则
|
||||
|
||||
### Requirement: 操作符系统
|
||||
系统 SHALL 支持对提取值和文本值使用以下操作符进行比较:equals(默认等值)、contains(子串包含)、match(正则匹配)、empty(空值判断)、exists(存在性判断)、gte/lte/gt/lt(数值比较)。
|
||||
|
||||
#### Scenario: 标量值隐式 equals
|
||||
- **WHEN** 配置的期望值为标量(字符串/数字/布尔/null),如 `equals: "ok"`
|
||||
- **THEN** 系统 SHALL 使用 equals 操作符,对实际值做严格相等比较
|
||||
|
||||
#### Scenario: 显式 contains 操作符
|
||||
- **WHEN** 配置 `{contains: "success"}`,且实际值包含 `"success"`
|
||||
- **THEN** 系统 SHALL 判定该规则通过
|
||||
|
||||
#### Scenario: 显式 match 操作符
|
||||
- **WHEN** 配置 `{match: '\\d+\\.\\d+\\.\\d+'}`,且实际值匹配该正则
|
||||
- **THEN** 系统 SHALL 判定该规则通过
|
||||
|
||||
#### Scenario: empty 操作符判断为空
|
||||
- **WHEN** 配置 `{empty: true}`,且实际值为空数组 `[]`
|
||||
- **THEN** 系统 SHALL 判定该规则通过
|
||||
|
||||
#### Scenario: empty 操作符判断非空
|
||||
- **WHEN** 配置 `{empty: false}`,且实际值为 `[1, 2]`
|
||||
- **THEN** 系统 SHALL 判定该规则通过
|
||||
|
||||
#### Scenario: exists 操作符判断存在
|
||||
- **WHEN** 配置 `{exists: false}`,且实际值不存在
|
||||
- **THEN** 系统 SHALL 判定该规则通过
|
||||
|
||||
#### Scenario: gte 数值比较
|
||||
- **WHEN** 配置 `{gte: 10}`,且实际值为 `15`(数字)
|
||||
- **THEN** 系统 SHALL 判定该规则通过
|
||||
|
||||
#### Scenario: gt/lt 数值比较
|
||||
- **WHEN** 配置 `{gt: 0, lt: 1000}`,且实际值为 `500`
|
||||
- **THEN** 系统 SHALL 对同一字段进行多操作符复合比较,全部通过则该规则通过
|
||||
|
||||
### Requirement: 响应头校验
|
||||
系统 SHALL 支持通过 `expect.headers` 配置对 HTTP 响应头进行键值规则校验,header 名称匹配 MUST 不区分大小写。
|
||||
|
||||
#### Scenario: 响应头匹配
|
||||
- **WHEN** HTTP target 配置 `expect.headers: {"Content-Type": {contains: "application/json"}}`,且响应包含该 header 且值匹配
|
||||
- **THEN** 系统 SHALL 判定 headers 阶段通过
|
||||
|
||||
#### Scenario: 响应头不匹配
|
||||
- **WHEN** HTTP target 配置 `expect.headers: {"Content-Type": {equals: "application/json"}}`,且响应 header 值为 `"text/html"`
|
||||
- **THEN** 系统 SHALL 判定 matched 为 false
|
||||
|
||||
#### Scenario: 响应头缺失
|
||||
- **WHEN** HTTP target 配置了某个 header 但响应中不存在该 header
|
||||
- **THEN** 系统 SHALL 判定 matched 为 false
|
||||
|
||||
### Requirement: 结构化 expect 失败信息
|
||||
系统 SHALL 在任一 expect 规则失败时生成结构化 failure,用于标识失败阶段、规则路径、期望值、实际值和可读错误信息。
|
||||
|
||||
#### Scenario: body 规则失败信息
|
||||
- **WHEN** HTTP target 的 `expect.body[1].json` 规则失败
|
||||
- **THEN** failure SHALL 包含 kind=`mismatch`、phase=`body`、path 指向 `expect.body[1]`,并包含 message
|
||||
|
||||
#### Scenario: actual 值截断
|
||||
- **WHEN** 失败规则的实际值超过系统允许记录的摘要长度
|
||||
- **THEN** 系统 MUST 截断 actual 摘要,而不是持久化完整响应体或命令输出
|
||||
@@ -1,6 +1,6 @@
|
||||
## Purpose
|
||||
|
||||
定义 Vite + React + TypeScript 前端开发工作流、开发期 API 代理、共享契约和端到端 demo 的行为要求。
|
||||
定义 Vite + React + TypeScript 前端开发工作流、开发期 API 代理和共享契约的行为要求。
|
||||
|
||||
## Requirements
|
||||
|
||||
@@ -18,8 +18,8 @@
|
||||
### Requirement: 前端开发期 API 代理
|
||||
前端开发服务器 SHALL 在本地开发期间将 `/api/*` 请求代理到 Bun 后端服务。
|
||||
|
||||
#### Scenario: 前端开发期调用 API
|
||||
- **WHEN** 浏览器从 Vite 开发源请求 `/api/demo`
|
||||
#### Scenario: 前端开发期调用拨测 API
|
||||
- **WHEN** 浏览器从 Vite 开发源请求 `/api/summary`、`/api/targets` 等拨测 API
|
||||
- **THEN** Vite SHALL 将请求转发到 Bun 后端服务,且不需要浏览器 CORS 配置
|
||||
|
||||
#### Scenario: 开发期访问非 API 前端路由
|
||||
@@ -52,23 +52,12 @@
|
||||
- **WHEN** host 或 port 在开发环境和生产环境之间变化
|
||||
- **THEN** 前端 API 调用 SHALL 无需修改源码即可继续工作
|
||||
|
||||
### Requirement: 端到端开发 demo
|
||||
项目 SHALL 提供一个可见的开发 demo,用于证明 React 前端可以通过 Vite 代理调用 Bun 后端。
|
||||
|
||||
#### Scenario: Demo 页面展示后端响应
|
||||
- **WHEN** 开发者启动文档化的开发命令并打开前端 URL
|
||||
- **THEN** 页面 SHALL 调用 `/api/demo` 并展示 Bun 后端返回的数据
|
||||
|
||||
#### Scenario: 开发期后端不可用
|
||||
- **WHEN** 前端 demo 无法访问 `/api/demo`
|
||||
- **THEN** 页面 SHALL 展示清晰的错误状态,而不是静默显示为成功
|
||||
|
||||
### Requirement: 集成开发命令
|
||||
项目 SHALL 提供一个文档化命令,用于在 demo 开发期间同时运行前端和后端。
|
||||
项目 SHALL 提供一个文档化命令,用于在开发期间同时运行前端和后端。
|
||||
|
||||
#### Scenario: 启动全栈开发
|
||||
- **WHEN** 开发者运行文档化的全栈开发命令
|
||||
- **THEN** 系统 SHALL 启动 Vite 前端开发服务器和 `/api/demo` 所需的 Bun 后端服务器
|
||||
- **THEN** 系统 SHALL 启动 Vite 前端开发服务器和 Bun 后端服务器
|
||||
|
||||
### Requirement: 开发质量命令文档化
|
||||
项目 SHALL 在前端开发工作流文档中说明日常检查和完整验证命令。
|
||||
|
||||
@@ -9,45 +9,34 @@
|
||||
|
||||
#### Scenario: 启动运行时服务器
|
||||
- **WHEN** server 进程成功启动
|
||||
- **THEN** 它 SHALL 监听配置的 host 和 port,并记录实际 server URL
|
||||
- **THEN** 它 SHALL 监听 YAML 配置文件中指定的 host 和 port,并记录实际 server URL
|
||||
|
||||
#### Scenario: 提供运行时配置
|
||||
- **WHEN** 通过支持的运行时配置提供 host 或 port
|
||||
#### Scenario: 通过 YAML 配置提供运行时参数
|
||||
- **WHEN** 通过 YAML 配置文件提供 host、port、数据目录等参数
|
||||
- **THEN** server SHALL 使用该值,且不需要重新构建
|
||||
|
||||
#### Scenario: CLI 只接受配置文件路径
|
||||
- **WHEN** 用户通过命令行启动程序
|
||||
- **THEN** 系统 SHALL 只接受一个命令行参数作为 YAML 配置文件路径
|
||||
|
||||
#### Scenario: 提供拨测相关 API
|
||||
- **WHEN** server 启动完成
|
||||
- **THEN** 系统 SHALL 提供 `/api/summary`、`/api/targets`、`/api/targets/:id/history`、`/api/targets/:id/trend` 端点
|
||||
|
||||
### Requirement: HTTP method 语义
|
||||
系统 SHALL 为运行时端点提供明确的 HTTP method 语义,避免不支持的 method 被错误地当作成功请求处理。
|
||||
|
||||
#### Scenario: GET 请求访问运行时端点
|
||||
- **WHEN** 客户端使用 `GET` 请求 `/health` 或 `/api/demo`
|
||||
- **WHEN** 客户端使用 `GET` 请求 `/health` 或 `/api/*` 端点
|
||||
- **THEN** Bun server SHALL 返回对应端点的成功响应
|
||||
|
||||
#### Scenario: HEAD 请求访问运行时端点
|
||||
- **WHEN** 客户端使用 `HEAD` 请求 `/health` 或 `/api/demo`
|
||||
- **WHEN** 客户端使用 `HEAD` 请求 `/health` 或 `/api/*` 端点
|
||||
- **THEN** Bun server SHALL 返回与 `GET` 相同的成功状态和 headers,但 MUST NOT 返回响应体
|
||||
|
||||
#### Scenario: 不支持的 method 访问运行时端点
|
||||
- **WHEN** 客户端使用不支持的 method 请求 `/health` 或 `/api/demo`
|
||||
- **THEN** Bun server MUST 返回 JSON 405 响应,并带有描述允许 method 的 `Allow` header
|
||||
|
||||
### Requirement: 运行配置校验
|
||||
系统 SHALL 对运行时 host 和 port 配置提供稳定、可测试的解析与校验行为。
|
||||
|
||||
#### Scenario: 使用默认运行配置
|
||||
- **WHEN** 未提供 host 或 port 覆盖
|
||||
- **THEN** server SHALL 使用 README 文档化的默认 host 和 port
|
||||
|
||||
#### Scenario: CLI 参数优先于环境变量
|
||||
- **WHEN** CLI 参数和环境变量同时提供同一项运行配置
|
||||
- **THEN** server SHALL 使用 CLI 参数中的值
|
||||
|
||||
#### Scenario: 拒绝无效端口
|
||||
- **WHEN** port 配置不是整数、小于 0 或大于 65535
|
||||
- **THEN** server MUST 拒绝启动并报告无效端口
|
||||
|
||||
#### Scenario: 接受端口边界值
|
||||
- **WHEN** port 配置为 0 或 65535
|
||||
- **THEN** server SHALL 将其作为有效端口配置处理
|
||||
- **WHEN** 客户端使用不支持的 method 请求 `/health` 或 `/api/*` 端点
|
||||
- **THEN** Bun server SHALL 返回 405 状态码和 Allow header
|
||||
|
||||
### Requirement: API 路由命名空间
|
||||
系统 MUST 将 `/api/*` 保留给后端 API 路由。
|
||||
@@ -71,17 +60,6 @@
|
||||
- **WHEN** 客户端使用不支持的 method 请求已存在的 API 路由
|
||||
- **THEN** Bun server MUST 返回包含 `error` 和 `status` 字段的 JSON 405 响应
|
||||
|
||||
### Requirement: Demo API 端点
|
||||
系统 SHALL 暴露 `/api/demo` 作为稳定 demo 端点,用于证明前后端集成可用。
|
||||
|
||||
#### Scenario: Demo API 成功响应
|
||||
- **WHEN** 客户端请求 `/api/demo`
|
||||
- **THEN** Bun server SHALL 返回包含可读 message 和 runtime metadata 的 JSON 响应
|
||||
|
||||
#### Scenario: Demo API 内容类型
|
||||
- **WHEN** 客户端请求 `/api/demo`
|
||||
- **THEN** Bun server SHALL 返回 JSON content type 的响应
|
||||
|
||||
### Requirement: 健康检查端点
|
||||
系统 SHALL 在前端 SPA fallback 之外暴露健康检查端点。
|
||||
|
||||
@@ -100,10 +78,6 @@
|
||||
- **WHEN** 客户端请求 `/`
|
||||
- **THEN** Bun server SHALL 返回前端入口 HTML 文档
|
||||
|
||||
#### Scenario: 生产 demo 页面调用 API
|
||||
- **WHEN** 客户端从生产 Bun runtime 打开前端页面
|
||||
- **THEN** demo 页面 SHALL 能够从同源调用 `/api/demo` 并展示后端响应
|
||||
|
||||
### Requirement: 生产缓存策略
|
||||
系统 SHALL 为生产静态资源和前端入口 HTML 使用明确的缓存策略。
|
||||
|
||||
@@ -144,3 +118,10 @@
|
||||
#### Scenario: 保留 API 错误语义
|
||||
- **WHEN** 客户端请求未知的 `/api/*` 路由
|
||||
- **THEN** Bun server MUST NOT 返回前端入口 HTML 文档
|
||||
|
||||
### Requirement: 优雅关机
|
||||
系统 SHALL 在收到终止信号时正确清理资源。
|
||||
|
||||
#### Scenario: SIGINT/SIGTERM 处理
|
||||
- **WHEN** 开发模式进程收到 SIGINT 或 SIGTERM 信号
|
||||
- **THEN** 系统 SHALL 调用 engine.stop() 停止调度、调用 store.close() 关闭数据库连接后退出进程
|
||||
|
||||
124
openspec/specs/probe-api/spec.md
Normal file
124
openspec/specs/probe-api/spec.md
Normal file
@@ -0,0 +1,124 @@
|
||||
## Purpose
|
||||
|
||||
定义拨测系统的 REST API 端点:总览统计、目标列表含分组和结构化采样数据、带时间范围和分页的历史记录、按时间范围的趋势聚合。
|
||||
|
||||
## Requirements
|
||||
|
||||
### Requirement: 总览统计 API
|
||||
系统 SHALL 提供 `GET /api/summary` 端点,返回所有目标的总体统计信息(不含平均耗时)。
|
||||
|
||||
#### Scenario: 获取总览统计
|
||||
- **WHEN** 客户端请求 `GET /api/summary`
|
||||
- **THEN** 系统 SHALL 返回 JSON 包含 total(总目标数)、up(正常数)、down(异常数)、lastCheckTime(最近一次检查时间)
|
||||
|
||||
### Requirement: 目标列表 API
|
||||
系统 SHALL 提供 `GET /api/targets` 端点,返回所有 typed target 及其最新状态、分组信息和结构化采样数据。
|
||||
|
||||
#### Scenario: 获取目标列表
|
||||
- **WHEN** 客户端请求 `GET /api/targets`
|
||||
- **THEN** 系统 SHALL 返回 JSON 数组,每个元素包含目标基本信息(id、name、group、type、target、interval)、最近一次检查结果(timestamp、matched、durationMs、statusDetail、failure)、统计摘要(totalChecks、availability)和结构化采样数据 recentSamples(代替原 sparkline)
|
||||
|
||||
#### Scenario: 目标无历史记录
|
||||
- **WHEN** 某目标尚未执行过任何拨测
|
||||
- **THEN** 其 latestCheck 为 null,recentSamples 为空数组
|
||||
|
||||
### Requirement: 历史记录 API
|
||||
系统 SHALL 提供 `GET /api/targets/:id/history` 端点,支持时间范围筛选和分页返回指定目标的拨测记录。
|
||||
|
||||
#### Scenario: 获取指定时间范围内的历史记录
|
||||
- **WHEN** 客户端请求 `GET /api/targets/1/history?from=ISO&to=ISO&page=1&pageSize=20`
|
||||
- **THEN** 系统 SHALL 返回带分页信息的历史记录,包含 items、total、page、pageSize,按时间倒序排列
|
||||
|
||||
#### Scenario: 使用默认分页参数
|
||||
- **WHEN** 客户端请求 `GET /api/targets/1/history?from=ISO&to=ISO`(未指定 page 或 pageSize)
|
||||
- **THEN** 系统 SHALL 使用默认 page=1, pageSize=20
|
||||
|
||||
#### Scenario: from 或 to 参数缺失
|
||||
- **WHEN** 客户端请求 `GET /api/targets/1/history` 未提供 from 或 to 参数
|
||||
- **THEN** 系统 SHALL 返回 400 状态码和错误信息
|
||||
|
||||
### Requirement: 趋势 API 支持时间范围
|
||||
系统 SHALL 提供 `GET /api/targets/:id/trend` 端点,支持 `from` 和 `to` 查询参数指定时间范围。
|
||||
|
||||
#### Scenario: 指定时间范围查询趋势
|
||||
- **WHEN** 客户端请求 `GET /api/targets/1/trend?from=ISO&to=ISO`
|
||||
- **THEN** 系统 SHALL 返回指定时间范围内按小时分组的聚合数据
|
||||
|
||||
#### Scenario: from 或 to 参数缺失
|
||||
- **WHEN** 客户端请求 `GET /api/targets/1/trend` 未提供 from 或 to 参数
|
||||
- **THEN** 系统 SHALL 返回 400 状态码和错误信息
|
||||
|
||||
### Requirement: 目标列表返回分组和采样数据
|
||||
`GET /api/targets` SHALL 返回每个目标的分组信息和结构化采样数据,替代原有 sparkline。
|
||||
|
||||
#### Scenario: 返回分组信息
|
||||
- **WHEN** 客户端请求 `GET /api/targets`
|
||||
- **THEN** 响应中每个目标 SHALL 包含 `group` 字段,值为该目标所属的分组名称
|
||||
|
||||
#### Scenario: 返回 recentSamples
|
||||
- **WHEN** 客户端请求 `GET /api/targets`
|
||||
- **THEN** 响应中每个目标 SHALL 包含 `recentSamples` 数组,每个元素包含 `timestamp`(ISO 8601)、`durationMs`(number | null)、`up`(boolean,matched === true)
|
||||
|
||||
#### Scenario: recentSamples 数量
|
||||
- **WHEN** 客户端请求 `GET /api/targets`
|
||||
- **THEN** 每个目标的 recentSamples SHALL 最多包含 30 个元素,按时间倒序排列
|
||||
|
||||
#### Scenario: 目标无历史记录
|
||||
- **WHEN** 某目标尚未执行过任何拨测
|
||||
- **THEN** 其 recentSamples SHALL 为空数组
|
||||
|
||||
### Requirement: 新增共享类型
|
||||
系统 SHALL 在 `src/shared/api.ts` 中定义 `CheckResult`、`RecentSample` 和 `HistoryResponse` 类型。
|
||||
|
||||
#### Scenario: CheckResult 类型
|
||||
- **WHEN** 前后端共享 `CheckResult` 类型
|
||||
- **THEN** 该类型 SHALL 包含 `timestamp: string`、`matched: boolean`、`durationMs: number | null`、`statusDetail: string | null`、`failure` 字段,不包含 success 字段
|
||||
|
||||
#### Scenario: RecentSample 类型
|
||||
- **WHEN** 前后端共享 `RecentSample` 类型
|
||||
- **THEN** 该类型 SHALL 包含 `timestamp: string`、`durationMs: number | null`、`up: boolean` 字段,其中 up 为 boolean 且等于 matched
|
||||
|
||||
#### Scenario: HistoryResponse 类型
|
||||
- **WHEN** 前后端共享 `HistoryResponse` 类型
|
||||
- **THEN** 该类型 SHALL 包含 `items: CheckResult[]`、`total: number`、`page: number`、`pageSize: number` 字段
|
||||
|
||||
### Requirement: 保留健康检查端点
|
||||
系统 SHALL 保留 `GET /health` 端点,不受拨测功能影响。
|
||||
|
||||
#### Scenario: 访问健康检查
|
||||
- **WHEN** 客户端请求 `GET /health`
|
||||
- **THEN** 系统 SHALL 返回与之前格式一致的健康检查响应
|
||||
|
||||
### Requirement: API 错误处理
|
||||
系统 SHALL 对不存在的目标 ID 和无效参数返回适当的 HTTP 错误响应。
|
||||
|
||||
#### Scenario: 查询不存在的目标
|
||||
- **WHEN** 客户端请求 `GET /api/targets/999/history`
|
||||
- **THEN** 系统 SHALL 返回 404 状态码和错误信息
|
||||
|
||||
#### Scenario: 无效的 from/to 参数
|
||||
- **WHEN** 客户端请求 `GET /api/targets/1/history?from=invalid`
|
||||
- **THEN** 系统 SHALL 返回 400 状态码和错误信息
|
||||
|
||||
#### Scenario: 无效的分页参数
|
||||
- **WHEN** 客户端请求 `GET /api/targets/1/history?from=ISO&to=ISO&page=abc`
|
||||
- **THEN** 系统 SHALL 返回 400 状态码和错误信息
|
||||
|
||||
#### Scenario: from 或 to 参数缺失
|
||||
- **WHEN** 客户端请求 `GET /api/targets/1/trend` 未提供 from 或 to 参数
|
||||
- **THEN** 系统 SHALL 返回 400 状态码和错误信息
|
||||
|
||||
#### Scenario: 无效的目标 ID
|
||||
- **WHEN** 客户端请求 `GET /api/targets/abc/history`
|
||||
- **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** 检查结果 matched=true
|
||||
- **THEN** API SHALL 返回 failure 为 null
|
||||
134
openspec/specs/probe-config/spec.md
Normal file
134
openspec/specs/probe-config/spec.md
Normal file
@@ -0,0 +1,134 @@
|
||||
## Purpose
|
||||
|
||||
定义 HTTP 拨测工具的 YAML 配置文件格式、解析校验规则和 CLI 启动流程。
|
||||
|
||||
## Requirements
|
||||
|
||||
### Requirement: YAML 配置文件格式
|
||||
系统 SHALL 支持通过 YAML 配置文件定义全部运行参数,包括 server 配置、runtime 配置、checker 默认值和 typed target 列表(含可选 group 字段)。target MUST 使用 `type` 字段声明 checker 类型,HTTP 领域字段 MUST 放在 `http` 分组,command 领域字段 MUST 放在 `command` 分组。
|
||||
|
||||
#### Scenario: 完整配置文件解析
|
||||
- **WHEN** 系统启动并读取包含 server、runtime、defaults、targets(含 group 字段)的 YAML 配置文件
|
||||
- **THEN** 系统 SHALL 正确解析所有字段并用于初始化服务、调度引擎和对应 checker runner
|
||||
|
||||
#### Scenario: 最简 HTTP 配置文件解析
|
||||
- **WHEN** 系统读取只包含一个 `type: http` target 和 `http.url` 的 YAML 配置文件(省略 server、runtime、defaults 和 expect)
|
||||
- **THEN** 系统 SHALL 使用内置默认值填充未指定的字段(host=127.0.0.1, port=3000, dir=./data, interval=30s, timeout=10s, runtime.maxConcurrentChecks=20, http.method=GET, http.maxBodyBytes=100MB, group="default")
|
||||
|
||||
#### Scenario: 最简 command 配置文件解析
|
||||
- **WHEN** 系统读取只包含一个 `type: command` target 和 `command.exec` 的 YAML 配置文件
|
||||
- **THEN** 系统 SHALL 使用内置默认值填充未指定的字段(interval=30s, timeout=10s, command.cwd 为配置文件所在目录, command.maxOutputBytes=100MB)
|
||||
|
||||
#### Scenario: per-target 配置覆盖全局默认值
|
||||
- **WHEN** 某个 target 指定 interval、timeout 或对应领域分组中的默认字段
|
||||
- **THEN** 该 target SHALL 使用其自身的值,不受 defaults 中对应字段影响
|
||||
|
||||
### Requirement: CLI 参数
|
||||
系统 SHALL 通过单一命令行参数接受 YAML 配置文件路径。
|
||||
|
||||
#### Scenario: 指定配置文件启动
|
||||
- **WHEN** 用户执行 `./gateway-checker ./probes.yaml`
|
||||
- **THEN** 系统 SHALL 读取并解析指定路径的 YAML 文件作为配置
|
||||
|
||||
#### Scenario: 未提供配置文件路径
|
||||
- **WHEN** 用户启动程序时未提供任何命令行参数
|
||||
- **THEN** 系统 SHALL 以错误退出并提示需要指定配置文件路径
|
||||
|
||||
#### Scenario: 配置文件不存在
|
||||
- **WHEN** 用户指定的配置文件路径不存在
|
||||
- **THEN** 系统 SHALL 以错误退出并提示文件不存在
|
||||
|
||||
### Requirement: 配置校验
|
||||
系统 SHALL 在启动时对 YAML 配置进行完整校验,校验失败时以非零状态退出并输出清晰的错误信息。
|
||||
|
||||
#### Scenario: target 缺少必填字段
|
||||
- **WHEN** YAML 中某个 target 缺少 name 或 type 字段
|
||||
- **THEN** 系统 SHALL 以错误退出,提示哪个 target 缺少哪个字段
|
||||
|
||||
#### Scenario: HTTP target 缺少 url
|
||||
- **WHEN** YAML 中某个 target 配置 `type: http` 但缺少 `http.url`
|
||||
- **THEN** 系统 SHALL 以错误退出,提示该 target 缺少 http.url 字段
|
||||
|
||||
#### Scenario: command target 缺少 exec
|
||||
- **WHEN** YAML 中某个 target 配置 `type: command` 但缺少 `command.exec`
|
||||
- **THEN** 系统 SHALL 以错误退出,提示该 target 缺少 command.exec 字段
|
||||
|
||||
#### Scenario: target type 非法
|
||||
- **WHEN** YAML 中某个 target 的 type 不是 `http` 或 `command`
|
||||
- **THEN** 系统 SHALL 以错误退出,提示不支持的 target type
|
||||
|
||||
#### Scenario: target name 重复
|
||||
- **WHEN** YAML 中存在两个 name 相同的 target
|
||||
- **THEN** 系统 SHALL 以错误退出,提示重复的 name
|
||||
|
||||
#### Scenario: group 字段类型校验
|
||||
- **WHEN** YAML 中某个 target 的 `group` 字段不是字符串
|
||||
- **THEN** 系统 SHALL 以错误退出并提示 group 字段类型错误
|
||||
|
||||
#### Scenario: interval 格式非法
|
||||
- **WHEN** interval 或 timeout 值不是有效的时长格式(如 `30s`、`5m`、`500ms`)
|
||||
- **THEN** 系统 SHALL 以错误退出并提示格式错误
|
||||
|
||||
#### Scenario: maxConcurrentChecks 非法
|
||||
- **WHEN** runtime.maxConcurrentChecks 不是正整数
|
||||
- **THEN** 系统 SHALL 以错误退出并提示 runtime.maxConcurrentChecks 格式错误
|
||||
|
||||
#### Scenario: size 格式非法
|
||||
- **WHEN** maxBodyBytes 或 maxOutputBytes 值不是有效的 size 格式
|
||||
- **THEN** 系统 SHALL 以错误退出并提示支持 B、KB、MB、GB 格式
|
||||
|
||||
### Requirement: size 配置解析
|
||||
系统 SHALL 支持使用单位字符串配置读取上限,单位包括 `B`、`KB`、`MB` 和 `GB`。
|
||||
|
||||
#### Scenario: 解析 MB
|
||||
- **WHEN** YAML 中配置 `maxBodyBytes: "100MB"`
|
||||
- **THEN** 系统 SHALL 将其解析为 104857600 bytes
|
||||
|
||||
#### Scenario: 解析 KB
|
||||
- **WHEN** YAML 中配置 `maxOutputBytes: "512KB"`
|
||||
- **THEN** 系统 SHALL 将其解析为 524288 bytes
|
||||
|
||||
### Requirement: runtime 并发配置
|
||||
系统 SHALL 支持 `runtime.maxConcurrentChecks` 配置全局最大并发检查数。
|
||||
|
||||
#### Scenario: 使用默认并发限制
|
||||
- **WHEN** YAML 中未配置 runtime.maxConcurrentChecks
|
||||
- **THEN** 系统 SHALL 使用默认值 20
|
||||
|
||||
#### Scenario: 配置并发限制
|
||||
- **WHEN** YAML 中配置 `runtime.maxConcurrentChecks: 5`
|
||||
- **THEN** 系统 SHALL 将全局最大并发检查数设置为 5
|
||||
|
||||
### Requirement: YAML 配置使用 Bun 内置解析
|
||||
系统 SHALL 使用 Bun 内置的 `Bun.YAML.parse()` 解析配置文件,不引入外部 YAML 解析库。
|
||||
|
||||
#### Scenario: 解析 YAML 内容
|
||||
- **WHEN** 系统读取 YAML 文件内容
|
||||
- **THEN** 系统 SHALL 调用 `Bun.YAML.parse()` 将内容解析为配置对象
|
||||
|
||||
### Requirement: expect 配置增强
|
||||
系统 SHALL 支持 typed target 的领域专用 expect 配置,包括 HTTP 的 `status`、`headers`、`body` 和 command 的 `exitCode`、`stdout`、`stderr`。内容类 expect MUST 使用数组表达配置顺序。
|
||||
|
||||
#### Scenario: 解析 HTTP expect 配置
|
||||
- **WHEN** YAML 配置文件中 HTTP target 的 expect 包含 status、headers、body 规则数组及内部方法
|
||||
- **THEN** 系统 SHALL 正确解析并存储为 HTTP target 的 expect 字段
|
||||
|
||||
#### Scenario: 解析 command expect 配置
|
||||
- **WHEN** YAML 配置文件中 command target 的 expect 包含 exitCode、stdout 和 stderr 规则数组
|
||||
- **THEN** 系统 SHALL 正确解析并存储为 command target 的 expect 字段
|
||||
|
||||
#### Scenario: 解析 body 有序规则数组
|
||||
- **WHEN** YAML 中 HTTP target 配置 `expect.body` 为 contains、json、regex 三个数组项
|
||||
- **THEN** 系统 SHALL 保留数组顺序,供执行阶段按配置顺序快速失败
|
||||
|
||||
#### Scenario: 不配置 HTTP status
|
||||
- **WHEN** HTTP target 未配置 `expect.status`
|
||||
- **THEN** 系统 SHALL 在执行 expect 时使用默认 `status: [200]` 语义
|
||||
|
||||
#### Scenario: 不配置 command exitCode
|
||||
- **WHEN** command target 未配置 `expect.exitCode`
|
||||
- **THEN** 系统 SHALL 在执行 expect 时使用默认 `exitCode: [0]` 语义
|
||||
|
||||
#### Scenario: 不配置 expect
|
||||
- **WHEN** target 未配置任何 expect 规则
|
||||
- **THEN** 系统 SHALL 正常处理,expect 字段为 undefined
|
||||
57
openspec/specs/probe-dashboard/spec.md
Normal file
57
openspec/specs/probe-dashboard/spec.md
Normal file
@@ -0,0 +1,57 @@
|
||||
## Purpose
|
||||
|
||||
定义拨测系统的 React 前端 Dashboard:统计卡片、按分组卡片式布局、状态条和迷你趋势线可视化、目标详情模态框和时间范围筛选。
|
||||
|
||||
## Requirements
|
||||
|
||||
### Requirement: 总览统计卡片
|
||||
Dashboard SHALL 在页面顶部展示总览统计卡片,包含总目标数、正常数和异常数。
|
||||
|
||||
#### Scenario: 展示统计卡片
|
||||
- **WHEN** 用户打开 Dashboard 页面
|
||||
- **THEN** 页面顶部 SHALL 显示 3 个统计卡片:全部目标数、正常目标数、异常目标数
|
||||
|
||||
#### Scenario: 统计数据自动刷新
|
||||
- **WHEN** 页面处于打开状态
|
||||
- **THEN** 统计卡片 SHALL 每 5-10 秒自动刷新数据
|
||||
|
||||
### Requirement: 卡片式分组布局
|
||||
Dashboard SHALL 使用按分组展示的卡片式布局替代表格布局,每个分组包含带统计的分组标题和响应式卡片网格。
|
||||
|
||||
> 卡片布局、响应式网格、卡片内容和交互的详细规范见 `card-dashboard`。
|
||||
|
||||
#### Scenario: 按分组渲染卡片
|
||||
- **WHEN** 用户打开 Dashboard 页面
|
||||
- **THEN** 页面 SHALL 按 group 字段将目标分组展示,每组一个区域,"默认分组" 排在最上面
|
||||
|
||||
#### Scenario: 无分组时的展示
|
||||
- **WHEN** 所有目标均属于 "default" 分组
|
||||
- **THEN** 页面 SHALL 显示一个 "默认分组" 区域,卡片正常展示
|
||||
|
||||
### Requirement: 目标详情模态框
|
||||
Dashboard SHALL 提供模态框展示目标详情,包含时间范围筛选、多维统计图和分页检查记录列表。
|
||||
|
||||
> 模态框的时间范围筛选、统计图表、检查结果列表和布局的详细规范见 `target-detail-modal`。
|
||||
|
||||
#### Scenario: 打开模态框
|
||||
- **WHEN** 用户点击某个目标卡片
|
||||
- **THEN** 系统 SHALL 弹出模态框,占据视口 80% 宽度,展示该目标的详情
|
||||
|
||||
#### Scenario: 关闭模态框
|
||||
- **WHEN** 用户点击模态框关闭按钮或模态框外部区域
|
||||
- **THEN** 模态框 SHALL 关闭
|
||||
|
||||
### Requirement: 页面加载与错误状态
|
||||
Dashboard SHALL 正确处理加载状态和 API 错误,适配卡片式布局。
|
||||
|
||||
#### Scenario: 首次加载
|
||||
- **WHEN** 页面首次加载且数据尚未返回
|
||||
- **THEN** 页面 SHALL 显示加载状态指示
|
||||
|
||||
#### Scenario: API 请求失败
|
||||
- **WHEN** 前端轮询 API 请求失败
|
||||
- **THEN** 页面 SHALL 显示错误提示,并在下一次轮询周期自动重试
|
||||
|
||||
#### Scenario: 模态框内部加载状态
|
||||
- **WHEN** 模态框内趋势数据或历史记录正在加载
|
||||
- **THEN** 对应图表或列表区域 SHALL 显示加载指示
|
||||
128
openspec/specs/probe-data-store/spec.md
Normal file
128
openspec/specs/probe-data-store/spec.md
Normal file
@@ -0,0 +1,128 @@
|
||||
## Purpose
|
||||
|
||||
定义基于 SQLite 的拨测数据持久化存储:targets 同步(含分组信息)、check_results 追加写入、结构化采样数据查询、时间范围和分页查询、索引与聚合查询。
|
||||
|
||||
## Requirements
|
||||
|
||||
### Requirement: SQLite 数据库初始化
|
||||
系统 SHALL 使用 Bun 内置 `bun:sqlite` 模块在配置的数据目录下创建 SQLite 数据库文件,并以 WAL 模式运行。数据库 schema MUST 支持 typed checker target 和结构化检查结果,targets 表 MUST 包含 `grp` 列存储分组信息。
|
||||
|
||||
#### Scenario: 首次启动创建数据库
|
||||
- **WHEN** 指定的数据目录下不存在数据库文件
|
||||
- **THEN** 系统 SHALL 创建数据库文件并初始化 targets 表和 check_results 表,check_results 表包含 id(INTEGER PRIMARY KEY AUTOINCREMENT)、target_id(INTEGER NOT NULL)、timestamp(TEXT NOT NULL)、matched(INTEGER NOT NULL)、duration_ms(REAL)、status_detail(TEXT)、failure(TEXT),不包含 success 列
|
||||
|
||||
#### Scenario: 数据目录不存在
|
||||
- **WHEN** 配置的数据目录路径不存在
|
||||
- **THEN** 系统 SHALL 自动创建该目录
|
||||
|
||||
#### Scenario: 数据库已存在时启动
|
||||
- **WHEN** 数据库文件已存在
|
||||
- **THEN** 系统 SHALL 直接打开数据库,不重新建表
|
||||
|
||||
#### Scenario: 外键约束
|
||||
- **THEN** 系统 SHALL 启用 `PRAGMA foreign_keys = ON`
|
||||
|
||||
#### Scenario: 级联删除
|
||||
- **THEN** check_results 表的外键约束 SHALL 使用 `ON DELETE CASCADE`,确保删除目标时自动清理关联结果记录
|
||||
|
||||
### Requirement: targets 表同步
|
||||
系统 SHALL 在启动时将 YAML 配置中的目标列表同步到 SQLite targets 表,并持久化 target 类型、展示摘要、领域配置、调度配置、expect 配置和分组信息。
|
||||
|
||||
#### Scenario: 首次同步目标
|
||||
- **WHEN** 数据库为空且 YAML 中定义了 N 个 typed target
|
||||
- **THEN** 系统 SHALL 将所有目标插入 targets 表,包含 name、type、target、config、interval_ms、timeout_ms、expect 和 grp
|
||||
|
||||
#### Scenario: 配置变更后重新同步
|
||||
- **WHEN** YAML 配置发生变更(新增、删除或修改目标)后重启
|
||||
- **THEN** 系统 SHALL 根据 name 字段匹配:新增的插入、删除的移除、修改的更新(含 grp 字段)
|
||||
|
||||
### Requirement: check_results 表追加写入
|
||||
系统 SHALL 将每次检查结果追加写入 check_results 表,不更新或删除已有记录。
|
||||
|
||||
#### Scenario: 写入检查结果
|
||||
- **WHEN** 一次 checker 执行完成
|
||||
- **THEN** 系统 SHALL 插入一条包含 target_id、timestamp、matched、duration_ms、status_detail、failure 的记录
|
||||
|
||||
#### Scenario: 写入结构化失败信息
|
||||
- **WHEN** checker 执行失败或 expect 不匹配
|
||||
- **THEN** 系统 SHALL 将首个失败原因序列化写入 failure 字段
|
||||
|
||||
### Requirement: 时间范围查询索引
|
||||
系统 SHALL 在 check_results 表上创建 (target_id, timestamp) 复合索引,加速按目标和时间范围的查询。
|
||||
|
||||
#### Scenario: 查询某目标的历史记录
|
||||
- **WHEN** 查询指定 target_id 的最近 N 条记录
|
||||
- **THEN** 系统 SHALL 使用索引快速定位,无需全表扫描
|
||||
|
||||
### Requirement: 目标列表按分组排序
|
||||
系统 SHALL 保证 targets 查询结果按分组排序返回。
|
||||
|
||||
#### Scenario: 分组排序查询
|
||||
- **WHEN** 查询所有 targets
|
||||
- **THEN** 结果 SHALL 将 "default" 分组目标排在首位,其余分组按 YAML 配置中首次出现的顺序(即 id 自增顺序)排列
|
||||
|
||||
### Requirement: 结构化采样数据查询
|
||||
系统 SHALL 提供 `getRecentSamples` 方法替代 `getSparkline`,返回包含状态信息的结构化采样数据。
|
||||
|
||||
#### Scenario: 获取最近采样数据
|
||||
- **WHEN** 调用 `getRecentSamples(targetId, 30)`
|
||||
- **THEN** 系统 SHALL 返回最多 30 条记录,每条包含 timestamp、duration_ms、matched
|
||||
|
||||
#### Scenario: 采样数据排序
|
||||
- **WHEN** 获取采样数据
|
||||
- **THEN** 记录 SHALL 按 timestamp 降序排列(最新在前)
|
||||
|
||||
### Requirement: 趋势数据时间范围查询
|
||||
系统 SHALL 支持按任意时间范围查询趋势聚合数据,替代固定 hours 参数。
|
||||
|
||||
#### Scenario: 按时间范围查询趋势
|
||||
- **WHEN** 查询指定 target 在 from 到 to 时间范围内的趋势数据
|
||||
- **THEN** 系统 SHALL 返回按小时分组的聚合数据,包括每小时的 avgDurationMs、availability 和 totalChecks
|
||||
|
||||
### Requirement: 历史记录时间范围和分页查询
|
||||
系统 SHALL 支持按时间范围筛选并分页查询历史记录。
|
||||
|
||||
#### Scenario: 按时间范围筛选历史记录
|
||||
- **WHEN** 查询指定 target 在 from 到 to 时间范围内的历史记录
|
||||
- **THEN** 系统 SHALL 返回该时间范围内的记录,按 timestamp 降序排列
|
||||
|
||||
#### Scenario: 分页查询历史记录
|
||||
- **WHEN** 查询指定 page 和 pageSize 的历史记录
|
||||
- **THEN** 系统 SHALL 返回对应页的数据和总记录数
|
||||
|
||||
### Requirement: 聚合查询支持
|
||||
数据存储 SHALL 支持按时间段聚合查询,用于计算可用率、平均耗时、P99 耗时等统计指标。
|
||||
|
||||
#### Scenario: 计算目标可用率
|
||||
- **WHEN** 查询某目标在指定时间范围内的可用率
|
||||
- **THEN** 系统 SHALL 返回 matched=true 的记录数占总记录数的百分比
|
||||
|
||||
#### Scenario: 计算目标平均耗时
|
||||
- **WHEN** 查询某目标在指定时间范围内的平均耗时
|
||||
- **THEN** 系统 SHALL 返回 duration_ms 的平均值(仅计算 matched=true 的记录)
|
||||
|
||||
#### Scenario: 按小时聚合趋势数据
|
||||
- **WHEN** 查询某目标在指定时间范围内的趋势数据
|
||||
- **THEN** 系统 SHALL 返回按小时分组的聚合数据,包括每小时的平均耗时和可用率
|
||||
|
||||
#### Scenario: UP/DOWN 判定
|
||||
- **THEN** 系统 SHALL 基于 latestCheck.matched 判定目标 UP 或 DOWN:matched=true 为 UP,matched=false 为 DOWN
|
||||
|
||||
### 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
|
||||
141
openspec/specs/probe-engine/spec.md
Normal file
141
openspec/specs/probe-engine/spec.md
Normal file
@@ -0,0 +1,141 @@
|
||||
## Purpose
|
||||
|
||||
定义拨测调度引擎的行为:按 interval 分组定时、组内并发拨测、expect 结果校验和结果持久化。
|
||||
|
||||
## Requirements
|
||||
|
||||
### Requirement: 按 interval 分组调度
|
||||
系统 SHALL 将拨测目标按 interval 值分组,每组使用独立的定时器进行调度。
|
||||
|
||||
#### Scenario: 相同 interval 的目标共享定时器
|
||||
- **WHEN** 多个 target 配置了相同的 interval(如 30s)
|
||||
- **THEN** 系统 SHALL 使用同一个 `setInterval` 定时器,每次 tick 并发拨测所有该组目标
|
||||
|
||||
#### Scenario: 不同 interval 的目标各自调度
|
||||
- **WHEN** target A 配置 15s interval,target B 配置 30s interval
|
||||
- **THEN** 系统 SHALL 创建两个独立定时器,分别按各自频率调度
|
||||
|
||||
### Requirement: 组内并发拨测
|
||||
系统 SHALL 在每次调度 tick 时并发执行同组内目标的检查,但实际同时运行的检查数 MUST 受全局 `runtime.maxConcurrentChecks` 限制。
|
||||
|
||||
#### Scenario: 同组目标并发执行
|
||||
- **WHEN** 调度器触发一次 tick,该组有 3 个目标,且全局并发余量至少为 3
|
||||
- **THEN** 系统 SHALL 同时执行 3 个 checker,而非顺序执行
|
||||
|
||||
#### Scenario: 单个目标失败不影响同组其他目标
|
||||
- **WHEN** 同组中某个目标的检查请求超时或失败
|
||||
- **THEN** 其他目标的检查 SHALL 正常完成并记录结果
|
||||
|
||||
#### Scenario: 全局并发限制生效
|
||||
- **WHEN** 调度器同时触发 10 个目标且 runtime.maxConcurrentChecks 为 3
|
||||
- **THEN** 系统 MUST 同时最多运行 3 个检查,其余检查等待并发槽位释放
|
||||
|
||||
### Requirement: HTTP 拨测执行
|
||||
系统 SHALL 对 `type: http` 的目标执行 HTTP 请求,支持 GET、POST、PUT、DELETE、PATCH、HEAD 方法,并携带 `http.headers` 和 `http.body`。
|
||||
|
||||
#### Scenario: 执行 GET 请求
|
||||
- **WHEN** 目标配置 method 为 GET
|
||||
- **THEN** 系统 SHALL 发送 GET 请求到目标 URL
|
||||
|
||||
#### Scenario: 执行 POST 请求带 body
|
||||
- **WHEN** 目标配置 method 为 POST 且指定了 body 和 Content-Type header
|
||||
- **THEN** 系统 SHALL 发送带指定 body 的 POST 请求
|
||||
|
||||
#### Scenario: 携带自定义 headers
|
||||
- **WHEN** 目标配置了 headers(如 Authorization)
|
||||
- **THEN** 系统 SHALL 在请求中包含所有配置的 headers
|
||||
|
||||
#### Scenario: HTTP body 读取上限
|
||||
- **WHEN** HTTP response body 超过该 target 的 maxBodyBytes
|
||||
- **THEN** 系统 MUST 停止读取并记录 `matched=false` 和结构化输出超限错误
|
||||
|
||||
### Requirement: 请求超时控制
|
||||
系统 SHALL 对每次 checker 执行实施超时控制,超时时间使用目标配置的 timeout 值。
|
||||
|
||||
#### Scenario: HTTP 请求超时
|
||||
- **WHEN** HTTP 请求在 timeout 时间内未收到响应
|
||||
- **THEN** 系统 SHALL 中止该请求,记录为失败并标注超时错误
|
||||
|
||||
#### Scenario: command 执行超时
|
||||
- **WHEN** command 进程在 timeout 时间内未退出
|
||||
- **THEN** 系统 MUST 终止该子进程,记录为失败并标注超时错误
|
||||
|
||||
#### Scenario: 请求在超时前完成
|
||||
- **WHEN** checker 在超时前完成执行
|
||||
- **THEN** 系统 SHALL 正常记录执行结果并进入 expect 校验
|
||||
|
||||
### Requirement: expect 校验
|
||||
系统 SHALL 在 checker 执行完成后根据目标类型的 expect 配置校验观测结果,校验结果和首个失败原因记入 check result。
|
||||
|
||||
#### Scenario: HTTP 默认状态码
|
||||
- **WHEN** HTTP target 未配置 `expect.status`
|
||||
- **THEN** 系统 SHALL 按默认 `status: [200]` 校验响应状态码
|
||||
|
||||
#### Scenario: 校验 HTTP 状态码
|
||||
- **WHEN** HTTP target 配置了 `expect.status: [200, 201]`
|
||||
- **THEN** 系统 SHALL 检查响应状态码是否在列表中,将匹配结果记录到 matched 字段
|
||||
|
||||
#### Scenario: 校验 HTTP 响应头
|
||||
- **WHEN** HTTP target 配置了 `expect.headers: {"Content-Type": {contains: "application/json"}}`
|
||||
- **THEN** 系统 SHALL 检查响应头是否符合指定规则,全部匹配时继续后续阶段
|
||||
|
||||
#### Scenario: 校验 HTTP 响应体
|
||||
- **WHEN** HTTP target 配置了有序 `expect.body` 规则数组
|
||||
- **THEN** 系统 SHALL 按数组顺序执行 body 规则,任一失败立即记录 failure 并停止后续规则
|
||||
|
||||
#### Scenario: command 默认 exitCode
|
||||
- **WHEN** command target 未配置 `expect.exitCode`
|
||||
- **THEN** 系统 SHALL 按默认 `exitCode: [0]` 校验命令退出码
|
||||
|
||||
#### Scenario: 校验 command stdout
|
||||
- **WHEN** command target 配置了有序 `expect.stdout` 规则数组
|
||||
- **THEN** 系统 SHALL 按数组顺序执行 stdout 规则,任一失败立即记录 failure 并停止后续规则
|
||||
|
||||
#### Scenario: 校验耗时阈值
|
||||
- **WHEN** 目标配置了 `expect.maxDurationMs`
|
||||
- **THEN** 系统 SHALL 检查实际 durationMs 是否超过阈值,将匹配结果记录到 matched 字段
|
||||
|
||||
#### Scenario: 多条 expect 规则
|
||||
- **WHEN** 目标同时配置状态、duration、元数据和内容规则
|
||||
- **THEN** 系统 SHALL 所有规则全部通过时 matched 为 true,任一不通过则为 false 并记录首个失败原因
|
||||
|
||||
### Requirement: Body 校验按需解析
|
||||
系统 SHALL 仅在 HTTP target 配置了 body 校验且 status、duration、headers 阶段均通过时才读取并解析响应体,避免不必要的读取和解析开销。
|
||||
|
||||
#### Scenario: status 失败时不读取 body
|
||||
- **WHEN** HTTP target 的 status 阶段不匹配
|
||||
- **THEN** 系统 SHALL 立即返回 matched=false,且 MUST NOT 读取 response body
|
||||
|
||||
#### Scenario: 仅配置 contains 时不解析 JSON
|
||||
- **WHEN** HTTP target 仅配置 body contains 规则而未配置 json/css/xpath 规则
|
||||
- **THEN** 系统 SHALL 不执行 JSON.parse 或 HTML/XML 解析
|
||||
|
||||
#### Scenario: 配置 json 时解析 JSON 失败
|
||||
- **WHEN** HTTP target 配置了 body json 规则但响应体不是合法 JSON
|
||||
- **THEN** 系统 SHALL 判定 matched 为 false,并记录 json 规则对应的 failure.path
|
||||
|
||||
### Requirement: 拨测结果记录
|
||||
系统 SHALL 在每次 checker 完成后,将结果写入 SQLite 数据存储,包含 target_id、timestamp、matched、duration_ms、status_detail、failure 字段。
|
||||
|
||||
#### Scenario: 成功检查结果记录
|
||||
- **WHEN** checker 成功执行且 expect 全部匹配
|
||||
- **THEN** 系统 SHALL 记录 matched=true、duration_ms、status_detail,failure 为 null
|
||||
|
||||
#### Scenario: 执行失败结果记录
|
||||
- **WHEN** checker 执行失败(网络错误、超时、命令启动失败、输出超限等)
|
||||
- **THEN** 系统 SHALL 记录 matched=false、failure.kind="error" 和具体错误信息
|
||||
|
||||
#### Scenario: expect 不匹配结果记录
|
||||
- **WHEN** checker 执行成功但 expect 不匹配
|
||||
- **THEN** 系统 SHALL 记录 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 执行该目标
|
||||
@@ -37,9 +37,9 @@
|
||||
- **WHEN** executable 收到前端根路径请求
|
||||
- **THEN** 它 SHALL 从 executable 内包含的资源服务前端,且不需要外部 `dist/` 目录
|
||||
|
||||
#### Scenario: 服务嵌入 demo API 和页面
|
||||
#### Scenario: 服务嵌入 API 和页面
|
||||
- **WHEN** 生成的 executable 启动,且浏览器打开前端根路径
|
||||
- **THEN** 页面 SHALL 展示同一个 executable 进程中 `/api/demo` 返回的数据
|
||||
- **THEN** 页面 SHALL 展示同一个 executable 进程中 `/api/summary` 和 `/api/targets` 返回的数据
|
||||
|
||||
#### Scenario: 构建成功后清理中间产物
|
||||
- **WHEN** 生产构建成功完成并输出 executable
|
||||
@@ -65,11 +65,11 @@ executable MUST 将环境相关运行时配置保留在嵌入的前端和 server
|
||||
|
||||
#### Scenario: 验证 executable 路由
|
||||
- **WHEN** 构建验证针对生成的 executable 运行
|
||||
- **THEN** 它 SHALL 检查 `/api/demo`、`/health`、前端根路径、静态资源、未知 API、未知静态资源和前端 fallback 请求
|
||||
- **THEN** 它 SHALL 检查 `/api/summary`、`/api/targets`、`/health`、前端根路径、静态资源、未知 API、未知静态资源和前端 fallback 请求
|
||||
|
||||
#### Scenario: 验证生产模式和响应头
|
||||
- **WHEN** 构建验证针对生成的 executable 运行
|
||||
- **THEN** 它 SHALL 检查 demo 响应处于 production runtime mode,并验证代表性 HTML、JSON 和静态资源响应的缓存或低风险安全 headers
|
||||
- **THEN** 它 SHALL 检查 API 响应处于 production runtime mode,并验证代表性 HTML、JSON 和静态资源响应的缓存或低风险安全 headers
|
||||
|
||||
#### Scenario: 完整验证重新构建 executable
|
||||
- **WHEN** 开发者运行完整验证命令
|
||||
|
||||
87
openspec/specs/target-detail-modal/spec.md
Normal file
87
openspec/specs/target-detail-modal/spec.md
Normal file
@@ -0,0 +1,87 @@
|
||||
## Purpose
|
||||
|
||||
定义目标详情模态框:时间范围筛选(快捷按钮 + 日期选择器)、多维统计图(可用率趋势、耗时趋势、状态分布环形图)和分页检查结果列表。
|
||||
|
||||
## Requirements
|
||||
|
||||
### Requirement: 目标详情模态框
|
||||
Dashboard SHALL 在用户点击目标卡片后弹出模态框,展示该目标的详细统计图表和检查结果列表。
|
||||
|
||||
#### Scenario: 打开模态框
|
||||
- **WHEN** 用户点击某个目标卡片
|
||||
- **THEN** 系统 SHALL 弹出模态框,占据视口 80% 宽度,展示该目标的详情
|
||||
|
||||
#### Scenario: 模态框默认时间范围
|
||||
- **WHEN** 模态框打开
|
||||
- **THEN** 筛选器 SHALL 默认选中"最近 24 小时"
|
||||
|
||||
#### Scenario: 关闭模态框
|
||||
- **WHEN** 用户点击模态框关闭按钮或模态框外部区域
|
||||
- **THEN** 模态框 SHALL 关闭
|
||||
|
||||
### Requirement: 时间范围筛选
|
||||
模态框 SHALL 支持通过快捷按钮和自定义日期时间选择器筛选数据的时间范围。
|
||||
|
||||
#### Scenario: 快捷时间范围按钮
|
||||
- **WHEN** 模态框渲染
|
||||
- **THEN** 筛选栏 SHALL 显示快捷按钮:1h、6h、24h、7d,当前选中的按钮高亮显示
|
||||
|
||||
#### Scenario: 点击快捷按钮
|
||||
- **WHEN** 用户点击快捷按钮(如 "24h")
|
||||
- **THEN** 筛选器 SHALL 自动设置对应的起止时间,日期选择器显示对应的时间范围,该按钮高亮
|
||||
|
||||
#### Scenario: 自定义日期时间选择
|
||||
- **WHEN** 用户通过日期时间选择器修改起止时间(分钟精度)
|
||||
- **THEN** 快捷按钮 SHALL 取消高亮,表示当前为自定义时间范围
|
||||
|
||||
#### Scenario: 筛选触发数据刷新
|
||||
- **WHEN** 时间范围发生变化(快捷按钮或自定义选择)
|
||||
- **THEN** 系统 SHALL 重新请求该时间范围内的趋势数据和历史记录
|
||||
|
||||
### Requirement: 统计图表展示
|
||||
模态框图表区 SHALL 展示可用率趋势折线图、耗时趋势折线图和状态分布环形图。
|
||||
|
||||
#### Scenario: 可用率趋势折线图
|
||||
- **WHEN** 模态框加载完成且趋势数据可用
|
||||
- **THEN** 图表区 SHALL 展示可用率随时间变化的折线图,Y 轴为可用率百分比
|
||||
|
||||
#### Scenario: 耗时趋势折线图
|
||||
- **WHEN** 模态框加载完成且趋势数据可用
|
||||
- **THEN** 图表区 SHALL 展示耗时随时间变化的折线图,Y 轴为耗时毫秒数
|
||||
|
||||
#### Scenario: 状态分布环形图
|
||||
- **WHEN** 模态框加载完成
|
||||
- **THEN** 图表区 SHALL 展示环形图(Donut Chart),外圈显示 UP/DOWN 比例(绿色/红色),中间显示可用率百分比数字
|
||||
|
||||
### Requirement: 检查结果列表
|
||||
模态框检查记录列表 SHALL 展示当前筛选时间范围内的检查结果列表,支持分页浏览。
|
||||
|
||||
#### Scenario: 展示检查结果
|
||||
- **WHEN** 模态框加载完成且历史记录可用
|
||||
- **THEN** 检查记录列表 SHALL 展示检查结果,每条包含时间戳、UP/DOWN 状态标记、耗时毫秒数、statusDetail 和 failure 信息
|
||||
|
||||
#### Scenario: 分页导航
|
||||
- **WHEN** 检查结果总数超过一页
|
||||
- **THEN** 列表底部 SHALL 展示分页器,用户可点击切换页码
|
||||
|
||||
#### Scenario: 翻页刷新
|
||||
- **WHEN** 用户点击分页器切换页码
|
||||
- **THEN** 系统 SHALL 请求对应页码的历史记录数据,列表更新
|
||||
|
||||
### Requirement: 模态框布局
|
||||
模态框 SHALL 采用自上而下布局,上方展示统计图表,下方展示检查记录列表。
|
||||
|
||||
#### Scenario: 自上而下渲染
|
||||
- **WHEN** 模态框渲染
|
||||
- **THEN** 内容区域 SHALL 分为上下两部分,上方展示统计图表,下方展示检查结果列表和分页器
|
||||
|
||||
### Requirement: 模态框标题栏类型标签
|
||||
模态框标题栏 SHALL 显示目标类型标签,使用统一的类型显示映射系统。
|
||||
|
||||
#### Scenario: 类型标签显示
|
||||
- **WHEN** 模态框标题栏渲染
|
||||
- **THEN** 标题栏 SHALL 在目标名称旁显示类型标签(HTTP / CMD)
|
||||
|
||||
#### Scenario: 类型标签使用映射系统
|
||||
- **WHEN** 模态框渲染类型标签
|
||||
- **THEN** 类型标签 SHALL 使用统一的类型显示映射函数,不硬编码映射逻辑
|
||||
45
openspec/specs/target-grouping/spec.md
Normal file
45
openspec/specs/target-grouping/spec.md
Normal file
@@ -0,0 +1,45 @@
|
||||
## Purpose
|
||||
|
||||
定义 target 分组能力:YAML 配置中的 group 字段、后端存储、API 传递和前端分组排序。
|
||||
|
||||
## Requirements
|
||||
|
||||
### Requirement: target 分组配置
|
||||
系统 SHALL 支持在每个 target 上配置可选的 `group` 字段,用于将目标归类到不同分组。未指定 `group` 的 target SHALL 归入 `"default"` 分组。
|
||||
|
||||
#### Scenario: 配置分组名称
|
||||
- **WHEN** YAML 配置中某个 target 指定 `group: "搜索引擎"`
|
||||
- **THEN** 系统 SHALL 将该 target 归类到 "搜索引擎" 分组
|
||||
|
||||
#### Scenario: 不配置分组
|
||||
- **WHEN** YAML 配置中某个 target 未指定 `group` 字段
|
||||
- **THEN** 系统 SHALL 将该 target 归类到 "default" 分组
|
||||
|
||||
#### Scenario: group 字段类型校验
|
||||
- **WHEN** YAML 配置中某个 target 的 `group` 字段不是字符串
|
||||
- **THEN** 系统 SHALL 以错误退出并提示 group 字段类型错误
|
||||
|
||||
### Requirement: 分组排序
|
||||
系统 SHALL 保证 "default" 分组始终排在最前面,其余分组按配置文件中首次出现的顺序排列。
|
||||
|
||||
#### Scenario: default 分组排最前
|
||||
- **WHEN** 配置中存在多个分组(包括 "default" 和自定义分组)
|
||||
- **THEN** API 返回的目标列表中 "default" 分组的目标 SHALL 排在其他分组之前
|
||||
|
||||
#### Scenario: 自定义分组按出现顺序
|
||||
- **WHEN** 配置中 "搜索引擎" 分组在 "后端服务" 分组之前首次出现
|
||||
- **THEN** API 返回中 "搜索引擎" 分组 SHALL 排在 "后端服务" 分组之前
|
||||
|
||||
### Requirement: 分组信息 API 传递
|
||||
系统 SHALL 在 API 响应中返回每个 target 的分组信息。
|
||||
|
||||
#### Scenario: targets 列表包含分组
|
||||
- **WHEN** 客户端请求 `GET /api/targets`
|
||||
- **THEN** 响应中每个目标 SHALL 包含 `group` 字段,值为该目标所属的分组名称
|
||||
|
||||
### Requirement: 分组存储
|
||||
系统 SHALL 在数据库 targets 表中持久化每个 target 的分组信息。
|
||||
|
||||
#### Scenario: 持久化分组信息
|
||||
- **WHEN** 系统同步 targets 到数据库
|
||||
- **THEN** 每个 target 的 `grp` 列 SHALL 存储其分组名称,未配置分组的存储 `"default"`
|
||||
42
openspec/specs/target-type-display/spec.md
Normal file
42
openspec/specs/target-type-display/spec.md
Normal file
@@ -0,0 +1,42 @@
|
||||
## Purpose
|
||||
|
||||
定义目标类型(Target Type)的前端显示名称映射系统,支持从后端类型标识符到前端展示名称的可扩展转换。
|
||||
|
||||
## Requirements
|
||||
|
||||
### Requirement: 类型显示名称映射
|
||||
系统 SHALL 提供目标类型到显示名称的映射,将后端类型标识符转换为前端展示的简短名称。
|
||||
|
||||
#### Scenario: HTTP 类型显示
|
||||
- **WHEN** 目标类型为 "http"
|
||||
- **THEN** 前端 SHALL 显示 "HTTP"
|
||||
|
||||
#### Scenario: Command 类型显示
|
||||
- **WHEN** 目标类型为 "command"
|
||||
- **THEN** 前端 SHALL 显示 "CMD"
|
||||
|
||||
#### Scenario: 未知类型处理
|
||||
- **WHEN** 目标类型不在映射表中
|
||||
- **THEN** 前端 SHALL 将类型名称转换为大写显示
|
||||
|
||||
### Requirement: 映射可扩展性
|
||||
类型映射系统 SHALL 支持后续新增类型,无需修改多处代码。
|
||||
|
||||
#### Scenario: 新增类型映射
|
||||
- **WHEN** 需要新增目标类型(如 "tcp"、"dns"、"grpc")
|
||||
- **THEN** 开发者 SHALL 仅需在映射常量中添加一条记录
|
||||
|
||||
#### Scenario: 映射单一数据源
|
||||
- **WHEN** 前端组件需要显示目标类型
|
||||
- **THEN** 组件 SHALL 调用统一的映射函数,不直接硬编码映射逻辑
|
||||
|
||||
### Requirement: 类型安全
|
||||
类型映射系统 SHALL 提供类型安全的访问方式。
|
||||
|
||||
#### Scenario: TypeScript 类型推导
|
||||
- **WHEN** 使用映射常量
|
||||
- **THEN** TypeScript SHALL 能够推导出正确的类型(使用 `as const`)
|
||||
|
||||
#### Scenario: 运行时安全
|
||||
- **WHEN** 传入无效类型
|
||||
- **THEN** 系统 SHALL 返回 fallback 值,不抛出异常
|
||||
@@ -34,7 +34,11 @@
|
||||
"vite": "^8.0.11"
|
||||
},
|
||||
"dependencies": {
|
||||
"@xmldom/xmldom": "^0.9.10",
|
||||
"cheerio": "^1.2.0",
|
||||
"react": "^19.2.6",
|
||||
"react-dom": "^19.2.6"
|
||||
"react-dom": "^19.2.6",
|
||||
"recharts": "^3.8.1",
|
||||
"xpath": "^0.0.34"
|
||||
}
|
||||
}
|
||||
|
||||
212
probes.example.yaml
Normal file
212
probes.example.yaml
Normal file
@@ -0,0 +1,212 @@
|
||||
server:
|
||||
host: "127.0.0.1"
|
||||
port: 3000
|
||||
dataDir: "/tmp/probes_data"
|
||||
|
||||
runtime:
|
||||
maxConcurrentChecks: 20
|
||||
|
||||
defaults:
|
||||
interval: "30s"
|
||||
timeout: "10s"
|
||||
http:
|
||||
method: GET
|
||||
maxBodyBytes: "10MB"
|
||||
command:
|
||||
maxOutputBytes: "1MB"
|
||||
|
||||
targets:
|
||||
# ========== HTTP targets ==========
|
||||
|
||||
- name: "Baidu 首页可用"
|
||||
type: http
|
||||
group: "搜索引擎"
|
||||
http:
|
||||
url: "https://www.baidu.com"
|
||||
expect:
|
||||
status: [200]
|
||||
maxDurationMs: 5000
|
||||
|
||||
- name: "JSON API — 完整流水线"
|
||||
type: http
|
||||
group: "后端服务"
|
||||
interval: "1m"
|
||||
timeout: "15s"
|
||||
http:
|
||||
url: "https://httpbin.org/json"
|
||||
headers:
|
||||
Accept: "application/json"
|
||||
expect:
|
||||
headers:
|
||||
Content-Type:
|
||||
contains: "application/json"
|
||||
maxDurationMs: 8000
|
||||
body:
|
||||
- json:
|
||||
path: "$.slideshow.title"
|
||||
equals: "Sample Slide Show"
|
||||
- json:
|
||||
path: "$.slideshow.slides[0].title"
|
||||
contains: "Wake"
|
||||
- json:
|
||||
path: "$.slideshow.slides[0].type"
|
||||
equals: "all"
|
||||
- regex: '"title"'
|
||||
|
||||
- name: "HTML 页面 — CSS 选择器"
|
||||
type: http
|
||||
http:
|
||||
url: "https://httpbin.org/html"
|
||||
expect:
|
||||
body:
|
||||
- css:
|
||||
selector: "h1"
|
||||
contains: "Moby-Dick"
|
||||
- css:
|
||||
selector: "body"
|
||||
exists: true
|
||||
|
||||
- name: "HTML 页面 — XPath 提取节点文本"
|
||||
type: http
|
||||
http:
|
||||
url: "https://httpbin.org/html"
|
||||
expect:
|
||||
body:
|
||||
- xpath:
|
||||
path: "/html/body/h1/text()"
|
||||
contains: "Melville"
|
||||
|
||||
- name: "POST 接口测试"
|
||||
type: http
|
||||
http:
|
||||
url: "https://httpbin.org/post"
|
||||
method: POST
|
||||
headers:
|
||||
Content-Type: "application/json"
|
||||
body: '{"action":"check","version":1}'
|
||||
expect:
|
||||
status: [200]
|
||||
body:
|
||||
- json:
|
||||
path: "$.json.action"
|
||||
equals: "check"
|
||||
- json:
|
||||
path: "$.json.version"
|
||||
gte: 1
|
||||
|
||||
- name: "请求头验证"
|
||||
type: http
|
||||
http:
|
||||
url: "https://httpbin.org/headers"
|
||||
headers:
|
||||
X-Custom-Header: "gateway-checker"
|
||||
expect:
|
||||
status: [200]
|
||||
body:
|
||||
- json:
|
||||
path: "$.headers.X-Custom-Header"
|
||||
equals: "gateway-checker"
|
||||
|
||||
- name: "响应头自定义校验"
|
||||
type: http
|
||||
http:
|
||||
url: "https://httpbin.org/response-headers"
|
||||
headers:
|
||||
accept: "application/json"
|
||||
expect:
|
||||
body:
|
||||
- json:
|
||||
path: "$.Content-Type"
|
||||
equals: "application/json"
|
||||
|
||||
- name: "多状态码允许"
|
||||
type: http
|
||||
http:
|
||||
url: "https://httpbin.org/status/200"
|
||||
expect:
|
||||
status: [200, 201, 204]
|
||||
|
||||
# ========== Command targets ==========
|
||||
|
||||
- name: "uname 输出匹配"
|
||||
type: command
|
||||
group: "系统检查"
|
||||
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"
|
||||
@@ -98,14 +98,34 @@ ${assetEntries.join("\n")}
|
||||
async function writeGeneratedEntry() {
|
||||
await writeFile(
|
||||
generatedEntryPath,
|
||||
`import { readRuntimeConfig } from "../src/server/config";
|
||||
`import { loadConfig } from "../src/server/checker/config-loader";
|
||||
import { ProbeStore } from "../src/server/checker/store";
|
||||
import { ProbeEngine } from "../src/server/checker/engine";
|
||||
import { startServer } from "../src/server/server";
|
||||
import { readRuntimeConfig } from "../src/server/config";
|
||||
import { staticAssets } from "./static-assets";
|
||||
|
||||
startServer({
|
||||
config: readRuntimeConfig(),
|
||||
mode: "production",
|
||||
staticAssets,
|
||||
async function main() {
|
||||
const { configPath } = readRuntimeConfig();
|
||||
const config = await loadConfig(configPath);
|
||||
|
||||
const store = new ProbeStore(config.dataDir + "/probe.db");
|
||||
store.syncTargets(config.targets);
|
||||
|
||||
const engine = new ProbeEngine(store, config.targets, config.maxConcurrentChecks);
|
||||
engine.start();
|
||||
|
||||
startServer({
|
||||
config: { host: config.host, port: config.port },
|
||||
mode: "production",
|
||||
staticAssets,
|
||||
store,
|
||||
});
|
||||
}
|
||||
|
||||
void main().catch((error) => {
|
||||
console.error("启动失败:", error instanceof Error ? error.message : error);
|
||||
process.exit(1);
|
||||
});
|
||||
`,
|
||||
);
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { readdir, rm } from "node:fs/promises";
|
||||
import { rm } from "node:fs/promises";
|
||||
import { resolve } from "node:path";
|
||||
|
||||
const root = resolve(import.meta.dir, "..");
|
||||
@@ -9,7 +9,7 @@ const patterns: Array<{ glob: string; desc: string }> = [
|
||||
];
|
||||
|
||||
for (const { glob, desc } of patterns) {
|
||||
const entries = await Array.fromAsync(new Bun.Glob(glob).scan({ root, dot: true }));
|
||||
const entries = await Array.fromAsync(new Bun.Glob(glob).scan({ cwd: root, dot: true }));
|
||||
if (entries.length === 0) continue;
|
||||
for (const entry of entries) {
|
||||
const full = resolve(root, entry);
|
||||
|
||||
@@ -3,6 +3,8 @@ interface ChildProcessInfo {
|
||||
process: Bun.Subprocess;
|
||||
}
|
||||
|
||||
const configPath = process.argv[2];
|
||||
|
||||
const env = {
|
||||
...process.env,
|
||||
BACKEND_PORT: process.env.PORT ?? "3000",
|
||||
@@ -11,7 +13,7 @@ const env = {
|
||||
const children: ChildProcessInfo[] = [
|
||||
{
|
||||
name: "server",
|
||||
process: Bun.spawn(["bun", "run", "dev:server"], {
|
||||
process: Bun.spawn(["bun", "run", "dev:server", ...(configPath ? [configPath] : [])], {
|
||||
env,
|
||||
stdout: "inherit",
|
||||
stderr: "inherit",
|
||||
|
||||
@@ -1,21 +1,39 @@
|
||||
import { access } from "node:fs/promises";
|
||||
import { fileURLToPath } from "node:url";
|
||||
import type { DemoResponse, HealthResponse } from "../src/shared/api";
|
||||
import { mkdtempSync, rmSync, writeFileSync } from "node:fs";
|
||||
import { join } from "node:path";
|
||||
import { tmpdir } from "node:os";
|
||||
import type { HealthResponse, SummaryResponse } from "../src/shared/api";
|
||||
|
||||
const executablePath = process.argv[2] ?? fileURLToPath(new URL("../dist/gateway-checker", import.meta.url));
|
||||
|
||||
await assertExecutableExists(executablePath);
|
||||
|
||||
const tempDir = mkdtempSync(join(tmpdir(), "gc-smoke-"));
|
||||
const configPath = join(tempDir, "probes.yaml");
|
||||
|
||||
const port = await getFreePort();
|
||||
const baseUrl = `http://127.0.0.1:${port}`;
|
||||
const app = Bun.spawn([executablePath, "--host", "127.0.0.1", "--port", String(port)], {
|
||||
|
||||
writeFileSync(
|
||||
configPath,
|
||||
`server:
|
||||
port: ${port}
|
||||
targets:
|
||||
- name: "httpbin"
|
||||
type: http
|
||||
http:
|
||||
url: "https://httpbin.org/get"
|
||||
interval: "5m"
|
||||
timeout: "15s"
|
||||
expect:
|
||||
status: [200]
|
||||
`,
|
||||
);
|
||||
const app = Bun.spawn([executablePath, configPath], {
|
||||
stdout: "pipe",
|
||||
stderr: "pipe",
|
||||
env: {
|
||||
...process.env,
|
||||
HOST: "127.0.0.1",
|
||||
PORT: String(port),
|
||||
},
|
||||
env: { ...process.env },
|
||||
});
|
||||
const stdout = readStream(app.stdout);
|
||||
const stderr = readStream(app.stderr);
|
||||
@@ -27,37 +45,36 @@ try {
|
||||
assert(health.ok === true, "健康检查响应缺少 ok=true");
|
||||
assertSecurityHeaders(healthResponse, "/health");
|
||||
|
||||
const { body: demo, response: demoResponse } = await expectJson<DemoResponse>(`${baseUrl}/api/demo`, 200);
|
||||
assert(demo.message.includes("/api/demo"), "demo 响应未包含预期 message");
|
||||
assert(demo.runtime.mode === "production", "demo 响应 runtime mode 应为 production");
|
||||
assertSecurityHeaders(demoResponse, "/api/demo");
|
||||
const { body: summary } = await expectJson<SummaryResponse>(`${baseUrl}/api/summary`, 200);
|
||||
assert(summary.total === 1, "总览统计: total 应为 1");
|
||||
assertSecurityHeaders(await fetch(`${baseUrl}/api/summary`), "/api/summary");
|
||||
|
||||
const { body: targets } = await expectJson(`${baseUrl}/api/targets`, 200);
|
||||
assert(Array.isArray(targets), "/api/targets 应返回数组");
|
||||
assert(targets.length === 1, "/api/targets 应有 1 个目标");
|
||||
assert(targets[0].name === "httpbin", "目标名称应为 httpbin");
|
||||
|
||||
const missingApi = await fetch(`${baseUrl}/api/not-found`);
|
||||
assert(missingApi.status === 404, "未知 API 应返回 404");
|
||||
assert(missingApi.headers.get("content-type")?.includes("application/json") === true, "未知 API 应返回 JSON");
|
||||
assertSecurityHeaders(missingApi, "/api/not-found");
|
||||
|
||||
const missingTarget = await fetch(`${baseUrl}/api/targets/99999/history`);
|
||||
assert(missingTarget.status === 404, "不存在的目标应返回 404");
|
||||
|
||||
const { body: rootHtml, response: rootResponse } = await expectText(`${baseUrl}/`, 200);
|
||||
assert(rootHtml.includes("Gateway Checker Demo"), "前端根页面缺少 demo 标题");
|
||||
assert(rootHtml.includes("Gateway Checker"), "前端根页面缺少标题");
|
||||
assert(rootResponse.headers.get("cache-control") === "no-cache", "前端根页面应使用 no-cache");
|
||||
assertSecurityHeaders(rootResponse, "/");
|
||||
|
||||
const { body: fallbackHtml, response: fallbackResponse } = await expectText(`${baseUrl}/dashboard`, 200);
|
||||
assert(fallbackHtml.includes("Gateway Checker Demo"), "SPA fallback 未返回前端入口页面");
|
||||
assert(fallbackResponse.headers.get("cache-control") === "no-cache", "SPA fallback 应使用 no-cache");
|
||||
assertSecurityHeaders(fallbackResponse, "/dashboard");
|
||||
const { body: fallbackHtml } = await expectText(`${baseUrl}/dashboard`, 200);
|
||||
assert(fallbackHtml.includes("Gateway Checker"), "SPA fallback 未返回前端入口页面");
|
||||
|
||||
const assetPath = rootHtml.match(/(?:src|href)="(\/assets\/[^"]+)"/)?.[1];
|
||||
assert(assetPath !== undefined, "前端入口页面未引用 /assets/* 资源");
|
||||
|
||||
const asset = await fetch(`${baseUrl}${assetPath}`);
|
||||
assert(asset.status === 200, `静态资源 ${assetPath} 未返回 200`);
|
||||
assert(asset.headers.get("cache-control") === "public, max-age=31536000, immutable", "静态资源应使用长缓存");
|
||||
assertSecurityHeaders(asset, assetPath);
|
||||
|
||||
const missingAsset = await expectText(`${baseUrl}/assets/not-found.js`, 404);
|
||||
assert(!missingAsset.body.includes("Gateway Checker Demo"), "未知静态资源不应返回前端入口页面");
|
||||
assertSecurityHeaders(missingAsset.response, "/assets/not-found.js");
|
||||
assert(!missingAsset.body.includes("Gateway Checker"), "未知静态资源不应返回前端入口页面");
|
||||
|
||||
console.log(`Smoke test passed: ${baseUrl}`);
|
||||
} catch (error) {
|
||||
@@ -68,6 +85,7 @@ try {
|
||||
throw new Error(`executable smoke test 失败: ${message}\nstdout:\n${out}\nstderr:\n${err}`, { cause: error });
|
||||
} finally {
|
||||
app.kill();
|
||||
rmSync(tempDir, { recursive: true, force: true });
|
||||
}
|
||||
|
||||
async function assertExecutableExists(path: string) {
|
||||
@@ -111,7 +129,7 @@ async function waitForServer(url: string) {
|
||||
throw new Error(`服务未在超时时间内启动: ${url}`);
|
||||
}
|
||||
|
||||
async function expectJson<T>(url: string, status: number): Promise<{ body: T; response: Response }> {
|
||||
async function expectJson<T = unknown>(url: string, status: number): Promise<{ body: T; response: Response }> {
|
||||
const response = await fetch(url);
|
||||
|
||||
assert(response.status === status, `${url} 应返回 ${status},实际为 ${response.status}`);
|
||||
|
||||
@@ -1,4 +1,16 @@
|
||||
import type { ApiErrorResponse, DemoResponse, HealthResponse, RuntimeMode } from "../shared/api";
|
||||
import type {
|
||||
ApiErrorResponse,
|
||||
CheckFailure,
|
||||
CheckResult,
|
||||
HealthResponse,
|
||||
HistoryResponse,
|
||||
RuntimeMode,
|
||||
SummaryResponse,
|
||||
TargetStatus,
|
||||
TrendPoint,
|
||||
} from "../shared/api";
|
||||
import type { StoredCheckResult } from "./checker/types";
|
||||
import type { ProbeStore } from "./checker/store";
|
||||
|
||||
export interface StaticAssets {
|
||||
indexHtml: Blob;
|
||||
@@ -8,6 +20,7 @@ export interface StaticAssets {
|
||||
export interface AppOptions {
|
||||
mode: RuntimeMode;
|
||||
staticAssets?: StaticAssets;
|
||||
store?: ProbeStore;
|
||||
}
|
||||
|
||||
export function createFetchHandler(options: AppOptions) {
|
||||
@@ -22,19 +35,15 @@ export function createFetchHandler(options: AppOptions) {
|
||||
return jsonResponse(createHealthResponse(), { method: request.method, mode: options.mode });
|
||||
}
|
||||
|
||||
if (url.pathname === "/api/demo") {
|
||||
if (!allowsGetHead(request.method)) {
|
||||
return methodNotAllowedResponse(["GET", "HEAD"], options.mode);
|
||||
}
|
||||
|
||||
return jsonResponse(createDemoResponse(options.mode), { method: request.method, mode: options.mode });
|
||||
if (url.pathname.startsWith("/api/") && options.store) {
|
||||
return handleApiRoute(url, request, options.store, options.mode);
|
||||
}
|
||||
|
||||
if (url.pathname.startsWith("/api/")) {
|
||||
return jsonResponse(createApiError("API route not found", 404), {
|
||||
return jsonResponse(createApiError("Service not ready", 503), {
|
||||
method: request.method,
|
||||
mode: options.mode,
|
||||
status: 404,
|
||||
status: 503,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -49,17 +58,187 @@ export function createFetchHandler(options: AppOptions) {
|
||||
};
|
||||
}
|
||||
|
||||
function createDemoResponse(mode: RuntimeMode): DemoResponse {
|
||||
return {
|
||||
message: "Bun 后端已通过 /api/demo 连接到 React 前端。",
|
||||
runtime: {
|
||||
mode,
|
||||
bunVersion: Bun.version,
|
||||
platform: process.platform,
|
||||
arch: process.arch,
|
||||
timestamp: new Date().toISOString(),
|
||||
},
|
||||
function handleApiRoute(url: URL, request: Request, store: ProbeStore, mode: RuntimeMode): Response {
|
||||
const { method } = request;
|
||||
|
||||
if (!allowsGetHead(method)) {
|
||||
return methodNotAllowedResponse(["GET", "HEAD"], mode);
|
||||
}
|
||||
|
||||
if (url.pathname === "/api/summary") {
|
||||
return jsonResponse(createSummaryResponse(store), { method, mode });
|
||||
}
|
||||
|
||||
if (url.pathname === "/api/targets") {
|
||||
return jsonResponse(createTargetsResponse(store), { method, mode });
|
||||
}
|
||||
|
||||
const historyMatch = url.pathname.match(/^\/api\/targets\/([^/]+)\/history$/);
|
||||
if (historyMatch) {
|
||||
return handleHistory(historyMatch[1]!, url, method, store, mode);
|
||||
}
|
||||
|
||||
const trendMatch = url.pathname.match(/^\/api\/targets\/([^/]+)\/trend$/);
|
||||
if (trendMatch) {
|
||||
return handleTrend(trendMatch[1]!, url, method, store, mode);
|
||||
}
|
||||
|
||||
return jsonResponse(createApiError("API route not found", 404), { method, mode, status: 404 });
|
||||
}
|
||||
|
||||
function handleHistory(idStr: string, url: URL, method: string, store: ProbeStore, mode: RuntimeMode): Response {
|
||||
const id = Number(idStr);
|
||||
|
||||
if (!Number.isInteger(id) || id <= 0) {
|
||||
return jsonResponse(createApiError("Invalid target ID", 400), { method, mode, status: 400 });
|
||||
}
|
||||
|
||||
const target = store.getTargetById(id);
|
||||
if (!target) {
|
||||
return jsonResponse(createApiError("Target not found", 404), { method, mode, status: 404 });
|
||||
}
|
||||
|
||||
const from = url.searchParams.get("from");
|
||||
const to = url.searchParams.get("to");
|
||||
|
||||
if (!from || !to) {
|
||||
return jsonResponse(createApiError("from and to parameters are required", 400), { method, mode, status: 400 });
|
||||
}
|
||||
|
||||
const fromDate = new Date(from);
|
||||
const toDate = new Date(to);
|
||||
if (isNaN(fromDate.getTime()) || isNaN(toDate.getTime())) {
|
||||
return jsonResponse(createApiError("Invalid from or to parameter format", 400), { method, mode, status: 400 });
|
||||
}
|
||||
|
||||
const pageParam = url.searchParams.get("page");
|
||||
const pageSizeParam = url.searchParams.get("pageSize");
|
||||
let page = 1;
|
||||
let pageSize = 20;
|
||||
|
||||
if (pageParam !== null) {
|
||||
page = Number(pageParam);
|
||||
if (!Number.isInteger(page) || page <= 0) {
|
||||
return jsonResponse(createApiError("Invalid page parameter", 400), { method, mode, status: 400 });
|
||||
}
|
||||
}
|
||||
|
||||
if (pageSizeParam !== null) {
|
||||
pageSize = Number(pageSizeParam);
|
||||
if (!Number.isInteger(pageSize) || pageSize <= 0) {
|
||||
return jsonResponse(createApiError("Invalid pageSize parameter", 400), { method, mode, status: 400 });
|
||||
}
|
||||
}
|
||||
|
||||
const result = store.getHistory(id, from, to, page, pageSize);
|
||||
const response: HistoryResponse = {
|
||||
items: result.items.map(mapCheckResult),
|
||||
total: result.total,
|
||||
page: result.page,
|
||||
pageSize: result.pageSize,
|
||||
};
|
||||
|
||||
return jsonResponse(response, { method, mode });
|
||||
}
|
||||
|
||||
function handleTrend(idStr: string, url: URL, method: string, store: ProbeStore, mode: RuntimeMode): Response {
|
||||
const id = Number(idStr);
|
||||
|
||||
if (!Number.isInteger(id) || id <= 0) {
|
||||
return jsonResponse(createApiError("Invalid target ID", 400), { method, mode, status: 400 });
|
||||
}
|
||||
|
||||
const target = store.getTargetById(id);
|
||||
if (!target) {
|
||||
return jsonResponse(createApiError("Target not found", 404), { method, mode, status: 404 });
|
||||
}
|
||||
|
||||
const from = url.searchParams.get("from");
|
||||
const to = url.searchParams.get("to");
|
||||
|
||||
if (!from || !to) {
|
||||
return jsonResponse(createApiError("from and to parameters are required", 400), { method, mode, status: 400 });
|
||||
}
|
||||
|
||||
const fromDate = new Date(from);
|
||||
const toDate = new Date(to);
|
||||
if (isNaN(fromDate.getTime()) || isNaN(toDate.getTime())) {
|
||||
return jsonResponse(createApiError("Invalid from or to parameter format", 400), { method, mode, status: 400 });
|
||||
}
|
||||
|
||||
const trend: TrendPoint[] = store.getTrend(id, from, to).map((row) => ({
|
||||
hour: row.hour,
|
||||
avgDurationMs: row.avgDurationMs,
|
||||
availability: Math.round(row.availability * 100) / 100,
|
||||
totalChecks: row.totalChecks,
|
||||
}));
|
||||
|
||||
return jsonResponse(trend, { method, mode });
|
||||
}
|
||||
|
||||
function createSummaryResponse(store: ProbeStore): SummaryResponse {
|
||||
const summary = store.getSummary();
|
||||
return {
|
||||
total: summary.total,
|
||||
up: summary.up,
|
||||
down: summary.down,
|
||||
lastCheckTime: summary.lastCheckTime,
|
||||
};
|
||||
}
|
||||
|
||||
function createTargetsResponse(store: ProbeStore): TargetStatus[] {
|
||||
const targets = store.getTargets();
|
||||
|
||||
return targets.map((target) => {
|
||||
const latest = store.getLatestCheck(target.id);
|
||||
const stats = store.getTargetStats(target.id);
|
||||
const recentSamples = store.getRecentSamples(target.id, 30);
|
||||
|
||||
return {
|
||||
id: target.id,
|
||||
name: target.name,
|
||||
type: target.type,
|
||||
target: target.target,
|
||||
group: target.grp,
|
||||
interval: formatDuration(target.interval_ms),
|
||||
latestCheck: latest ? mapCheckResult(latest) : null,
|
||||
recentSamples: recentSamples.map((s) => ({
|
||||
timestamp: s.timestamp,
|
||||
durationMs: s.duration_ms,
|
||||
up: s.matched === 1,
|
||||
})),
|
||||
stats: {
|
||||
totalChecks: stats.totalChecks,
|
||||
availability: stats.availability,
|
||||
},
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
function mapCheckResult(row: StoredCheckResult): CheckResult {
|
||||
let failure: CheckFailure | null = null;
|
||||
if (row.failure) {
|
||||
try {
|
||||
failure = JSON.parse(row.failure) as CheckFailure;
|
||||
} catch {
|
||||
console.warn(`无法解析 failure 数据: target_id=${row.target_id}, timestamp=${row.timestamp}`);
|
||||
failure = null;
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
timestamp: row.timestamp,
|
||||
matched: row.matched === 1,
|
||||
durationMs: row.duration_ms,
|
||||
statusDetail: row.status_detail,
|
||||
failure,
|
||||
};
|
||||
}
|
||||
|
||||
function formatDuration(ms: number): string {
|
||||
if (ms >= 60000 && ms % 60000 === 0) return `${ms / 60000}m`;
|
||||
if (ms >= 1000 && ms % 1000 === 0) return `${ms / 1000}s`;
|
||||
return `${ms}ms`;
|
||||
}
|
||||
|
||||
function createHealthResponse(): HealthResponse {
|
||||
@@ -87,7 +266,7 @@ function methodNotAllowedResponse(allow: string[], mode: RuntimeMode): Response
|
||||
}
|
||||
|
||||
function jsonResponse(
|
||||
body: ApiErrorResponse | DemoResponse | HealthResponse,
|
||||
body: unknown,
|
||||
options: { method?: string; mode: RuntimeMode; status?: number; headers?: HeadersInit },
|
||||
): Response {
|
||||
const headers = createHeaders(options.mode, {
|
||||
|
||||
151
src/server/checker/command-runner.ts
Normal file
151
src/server/checker/command-runner.ts
Normal file
@@ -0,0 +1,151 @@
|
||||
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,
|
||||
stdin: "ignore",
|
||||
stdout: "pipe",
|
||||
stderr: "pipe",
|
||||
});
|
||||
} catch (error) {
|
||||
const durationMs = Math.round(performance.now() - start);
|
||||
return {
|
||||
targetName: target.name,
|
||||
timestamp,
|
||||
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,
|
||||
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,
|
||||
matched: false,
|
||||
durationMs,
|
||||
statusDetail: `exitCode=${exitCode}`,
|
||||
failure: errorFailure("exitCode", "output", `输出超过限制 ${target.command.maxOutputBytes} 字节`),
|
||||
};
|
||||
}
|
||||
|
||||
if (timedOut) {
|
||||
return {
|
||||
targetName: target.name,
|
||||
timestamp,
|
||||
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,
|
||||
matched: expectResult.matched,
|
||||
durationMs,
|
||||
statusDetail: `exitCode=${exitCode}`,
|
||||
failure: expectResult.failure,
|
||||
};
|
||||
}
|
||||
237
src/server/checker/config-loader.ts
Normal file
237
src/server/checker/config-loader.ts
Normal file
@@ -0,0 +1,237 @@
|
||||
import type {
|
||||
CommandDefaultsConfig,
|
||||
CommandTargetConfig,
|
||||
DefaultsConfig,
|
||||
HttpDefaultsConfig,
|
||||
HttpExpectConfig,
|
||||
HttpTargetConfig,
|
||||
ProbeConfig,
|
||||
ResolvedCommandTarget,
|
||||
ResolvedHttpTarget,
|
||||
ResolvedTarget,
|
||||
EngineRuntimeConfig,
|
||||
TargetConfig,
|
||||
TargetType,
|
||||
} from "./types";
|
||||
import { parseSize } from "./size";
|
||||
import { resolve } from "node:path";
|
||||
import { dirname } from "node:path";
|
||||
|
||||
const DEFAULT_HOST = "127.0.0.1";
|
||||
const DEFAULT_PORT = 3000;
|
||||
const DEFAULT_DATA_DIR = "./data";
|
||||
const DEFAULT_INTERVAL = "30s";
|
||||
const DEFAULT_TIMEOUT = "10s";
|
||||
const DEFAULT_HTTP_METHOD = "GET";
|
||||
const DEFAULT_MAX_BODY_BYTES = "100MB";
|
||||
const DEFAULT_MAX_OUTPUT_BYTES = "100MB";
|
||||
const DEFAULT_MAX_CONCURRENT_CHECKS = 20;
|
||||
const SUPPORTED_TYPES: TargetType[] = ["http", "command"];
|
||||
|
||||
export interface ResolvedConfig {
|
||||
host: string;
|
||||
port: number;
|
||||
dataDir: string;
|
||||
configDir: string;
|
||||
maxConcurrentChecks: number;
|
||||
targets: ResolvedTarget[];
|
||||
}
|
||||
|
||||
export async function loadConfig(configPath: string): Promise<ResolvedConfig> {
|
||||
const file = Bun.file(configPath);
|
||||
|
||||
if (!(await file.exists())) {
|
||||
throw new Error(`配置文件不存在: ${configPath}`);
|
||||
}
|
||||
|
||||
const content = await file.text();
|
||||
const raw = Bun.YAML.parse(content) as ProbeConfig | null;
|
||||
|
||||
if (!raw) {
|
||||
throw new Error("配置文件内容为空或格式无效");
|
||||
}
|
||||
|
||||
validateConfig(raw);
|
||||
|
||||
const configDir = dirname(resolve(configPath));
|
||||
const server = raw.server ?? {};
|
||||
const runtime = raw.runtime ?? {};
|
||||
const defaults = raw.defaults ?? {};
|
||||
|
||||
const host = server.host ?? DEFAULT_HOST;
|
||||
const port = server.port ?? DEFAULT_PORT;
|
||||
const dataDir = server.dataDir ?? DEFAULT_DATA_DIR;
|
||||
|
||||
if (!Number.isInteger(port) || port < 0 || port > 65535) {
|
||||
throw new Error(`无效端口号: ${port},需要 0-65535 之间的整数`);
|
||||
}
|
||||
|
||||
const maxConcurrentChecks = validateRuntime(runtime);
|
||||
const defaultIntervalMs = parseDuration(defaults.interval ?? DEFAULT_INTERVAL);
|
||||
const defaultTimeoutMs = parseDuration(defaults.timeout ?? DEFAULT_TIMEOUT);
|
||||
|
||||
const targets: ResolvedTarget[] = raw.targets.map((target) =>
|
||||
resolveTarget(target, defaults, defaultIntervalMs, defaultTimeoutMs, configDir),
|
||||
);
|
||||
|
||||
return { host, port, dataDir, configDir, maxConcurrentChecks, targets };
|
||||
}
|
||||
|
||||
function validateRuntime(runtime: EngineRuntimeConfig): 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);
|
||||
const group = target.group ?? "default";
|
||||
|
||||
if (target.type === "http") {
|
||||
return resolveHttpTarget(target, defaults.http, intervalMs, timeoutMs, group);
|
||||
}
|
||||
|
||||
return resolveCommandTarget(target, defaults.command, intervalMs, timeoutMs, configDir, group);
|
||||
}
|
||||
|
||||
function resolveHttpTarget(
|
||||
target: TargetConfig & { type: "http"; http: HttpTargetConfig },
|
||||
httpDefaults: HttpDefaultsConfig | undefined,
|
||||
intervalMs: number,
|
||||
timeoutMs: number,
|
||||
group: string,
|
||||
): ResolvedHttpTarget {
|
||||
const maxBodyBytes = parseSize(target.http.maxBodyBytes ?? httpDefaults?.maxBodyBytes ?? DEFAULT_MAX_BODY_BYTES);
|
||||
|
||||
return {
|
||||
type: "http",
|
||||
name: target.name,
|
||||
group,
|
||||
http: {
|
||||
url: target.http.url,
|
||||
method: target.http.method ?? httpDefaults?.method ?? DEFAULT_HTTP_METHOD,
|
||||
headers: { ...(httpDefaults?.headers ?? {}), ...(target.http.headers ?? {}) },
|
||||
body: target.http.body,
|
||||
maxBodyBytes,
|
||||
},
|
||||
intervalMs,
|
||||
timeoutMs,
|
||||
expect: target.expect as HttpExpectConfig | undefined,
|
||||
};
|
||||
}
|
||||
|
||||
function resolveCommandTarget(
|
||||
target: TargetConfig & { type: "command"; command: CommandTargetConfig },
|
||||
commandDefaults: CommandDefaultsConfig | undefined,
|
||||
intervalMs: number,
|
||||
timeoutMs: number,
|
||||
configDir: string,
|
||||
group: 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,
|
||||
group,
|
||||
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 {
|
||||
if (!config.targets || !Array.isArray(config.targets) || config.targets.length === 0) {
|
||||
throw new Error("配置文件必须包含至少一个 target");
|
||||
}
|
||||
|
||||
const names = new Set<string>();
|
||||
|
||||
for (let i = 0; i < config.targets.length; i++) {
|
||||
const raw = config.targets[i] as unknown as Record<string, unknown>;
|
||||
|
||||
const name = raw["name"];
|
||||
if (!name || typeof name !== "string" || (name as string).trim() === "") {
|
||||
throw new Error(`第 ${i + 1} 个 target 缺少 name 字段`);
|
||||
}
|
||||
|
||||
const type = raw["type"];
|
||||
if (!type || typeof type !== "string") {
|
||||
throw new Error(`target "${name}" 缺少 type 字段`);
|
||||
}
|
||||
|
||||
if (!SUPPORTED_TYPES.includes(type as TargetType)) {
|
||||
throw new Error(`target "${name}" 使用不支持的 type: "${type}",支持: ${SUPPORTED_TYPES.join(", ")}`);
|
||||
}
|
||||
|
||||
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 字段`);
|
||||
}
|
||||
}
|
||||
|
||||
const group = raw["group"];
|
||||
if (group !== undefined && typeof group !== "string") {
|
||||
throw new Error(`target "${name}" 的 group 字段必须为字符串`);
|
||||
}
|
||||
|
||||
if (names.has(name as string)) {
|
||||
throw new Error(`target name 重复: "${name}"`);
|
||||
}
|
||||
|
||||
names.add(name as string);
|
||||
}
|
||||
}
|
||||
|
||||
const DURATION_REGEX = /^(\d+(?:\.\d+)?)(ms|s|m)$/;
|
||||
|
||||
export function parseDuration(value: string): number {
|
||||
const match = DURATION_REGEX.exec(value);
|
||||
if (!match) {
|
||||
throw new Error(`无效的时长格式: "${value}",支持格式如 "30s"、"5m"、"500ms"`);
|
||||
}
|
||||
|
||||
const num = parseFloat(match[1]!);
|
||||
const unit = match[2]!;
|
||||
|
||||
if (unit === "ms") return num;
|
||||
if (unit === "s") return num * 1000;
|
||||
return num * 60 * 1000;
|
||||
}
|
||||
124
src/server/checker/engine.ts
Normal file
124
src/server/checker/engine.ts
Normal file
@@ -0,0 +1,124 @@
|
||||
import type { CheckResult, ResolvedTarget } from "./types";
|
||||
import type { ProbeStore } from "./store";
|
||||
import { runHttpCheck } from "./fetcher";
|
||||
import { runCommandCheck } from "./command-runner";
|
||||
|
||||
export class ProbeEngine {
|
||||
private timers: ReturnType<typeof setInterval>[] = [];
|
||||
private store: ProbeStore;
|
||||
private targets: ResolvedTarget[];
|
||||
private targetNameToId: Map<string, number> = new Map();
|
||||
private maxConcurrentChecks: number;
|
||||
private running = 0;
|
||||
private queue: Array<() => void> = [];
|
||||
|
||||
constructor(store: ProbeStore, targets: ResolvedTarget[], maxConcurrentChecks?: number) {
|
||||
this.store = store;
|
||||
this.targets = targets;
|
||||
this.maxConcurrentChecks = maxConcurrentChecks ?? 20;
|
||||
this.refreshCache();
|
||||
}
|
||||
|
||||
start(): void {
|
||||
const groups = this.groupByInterval(this.targets);
|
||||
|
||||
for (const [intervalMs, groupTargets] of groups) {
|
||||
void this.probeGroup(groupTargets);
|
||||
|
||||
const timer = setInterval(() => {
|
||||
void this.probeGroup(groupTargets);
|
||||
}, intervalMs);
|
||||
|
||||
this.timers.push(timer);
|
||||
}
|
||||
}
|
||||
|
||||
stop(): void {
|
||||
for (const timer of this.timers) {
|
||||
clearInterval(timer);
|
||||
}
|
||||
this.timers = [];
|
||||
}
|
||||
|
||||
private groupByInterval(targets: ResolvedTarget[]): Map<number, ResolvedTarget[]> {
|
||||
const groups = new Map<number, ResolvedTarget[]>();
|
||||
|
||||
for (const target of targets) {
|
||||
const group = groups.get(target.intervalMs) ?? [];
|
||||
group.push(target);
|
||||
groups.set(target.intervalMs, group);
|
||||
}
|
||||
|
||||
return groups;
|
||||
}
|
||||
|
||||
private async acquire(): Promise<void> {
|
||||
if (this.running < this.maxConcurrentChecks) {
|
||||
this.running++;
|
||||
return;
|
||||
}
|
||||
return new Promise<void>((resolve) => {
|
||||
this.queue.push(resolve);
|
||||
});
|
||||
}
|
||||
|
||||
private release(): void {
|
||||
const next = this.queue.shift();
|
||||
if (next) {
|
||||
next();
|
||||
} else {
|
||||
this.running--;
|
||||
}
|
||||
}
|
||||
|
||||
private async probeGroup(targets: ResolvedTarget[]): Promise<void> {
|
||||
const results = await Promise.allSettled(
|
||||
targets.map(async (target) => {
|
||||
await this.acquire();
|
||||
try {
|
||||
return await this.runCheck(target);
|
||||
} finally {
|
||||
this.release();
|
||||
}
|
||||
}),
|
||||
);
|
||||
|
||||
for (const result of results) {
|
||||
if (result.status === "fulfilled") {
|
||||
this.writeResult(result.value);
|
||||
} else {
|
||||
console.warn("探针执行失败:", result.reason);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private async runCheck(target: ResolvedTarget): Promise<CheckResult> {
|
||||
switch (target.type) {
|
||||
case "http":
|
||||
return runHttpCheck(target);
|
||||
case "command":
|
||||
return runCommandCheck(target);
|
||||
}
|
||||
}
|
||||
|
||||
private writeResult(result: CheckResult): void {
|
||||
const targetId = this.targetNameToId.get(result.targetName);
|
||||
if (!targetId) return;
|
||||
|
||||
this.store.insertCheckResult({
|
||||
targetId,
|
||||
timestamp: result.timestamp,
|
||||
matched: result.matched,
|
||||
durationMs: result.durationMs,
|
||||
statusDetail: result.statusDetail,
|
||||
failure: result.failure,
|
||||
});
|
||||
}
|
||||
|
||||
private refreshCache(): void {
|
||||
this.targetNameToId.clear();
|
||||
for (const target of this.store.getTargets()) {
|
||||
this.targetNameToId.set(target.name, target.id);
|
||||
}
|
||||
}
|
||||
}
|
||||
302
src/server/checker/expect/body.ts
Normal file
302
src/server/checker/expect/body.ts
Normal 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 };
|
||||
}
|
||||
91
src/server/checker/expect/command.ts
Normal file
91
src/server/checker/expect/command.ts
Normal 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 };
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user