1
0

feat: 将 demo 项目转化为 HTTP 拨测监控工具

新增 YAML 配置解析(Bun 内置 YAML)、SQLite 数据存储(bun:sqlite)、按 interval 分组并发拨测引擎、REST API(/api/summary、/api/targets、/api/targets/:id/history、/api/targets/:id/trend)、React 前端 Dashboard(统计卡片、目标表格、可展开详情面板、recharts 趋势图)。CLI 简化为仅接受配置文件路径。移除 /api/demo 路由和相关 demo 代码。保留 /health、静态资源服务和 SPA fallback。
This commit is contained in:
2026-05-09 17:04:25 +08:00
parent 9267f6585c
commit 57d3a5cfb4
43 changed files with 2910 additions and 525 deletions

168
README.md
View File

@@ -1,53 +1,113 @@
# Gateway Checker
基于 Bun + TypeScript 的前后端一体化 demo。开发期使用 Vite + React 提供前端 HMR后端由 Bun 提供 API生产期先构建前端静态资源再将前端资源和 Bun 后端打包为单个 executable
基于 Bun + TypeScript 的 HTTP 拨测监控工具。通过 YAML 配置文件定义拨测目标,后端按配置定时并发拨测,结果持久化到本地 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 拨测执行
engine.ts 调度引擎(按 interval 分组、组内并发)
shared/
api.ts 前后端共享 TypeScript 类型
web/ Vite + React 前端 Dashboard
components/ UI 组件
hooks/ 数据轮询 hooks
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: "./data"
defaults:
interval: "30s"
timeout: "10s"
method: "GET"
targets:
- name: "示例服务"
url: "https://httpbin.org/get"
interval: "60s"
- name: "POST 检查"
url: "https://httpbin.org/post"
method: "POST"
headers:
Content-Type: "application/json"
body: '{"ping": true}'
expect:
status: [200]
maxLatencyMs: 5000
```
### 配置说明
- **server**: 服务配置(均可省略,使用默认值)
- `host`: 监听地址,默认 `127.0.0.1`
- `port`: 监听端口,默认 `3000`
- `dataDir`: 数据目录,默认 `./data`
- **defaults**: 全局默认值(均可省略)
- `interval`: 拨测间隔,默认 `30s`
- `timeout`: 请求超时,默认 `10s`
- `method`: HTTP 方法,默认 `GET`
- `headers`: 全局 headers
- **targets**: 拨测目标列表(必填)
- `name`: 目标名称(必填,唯一)
- `url`: 目标 URL必填
- `method``headers``body`: 请求参数
- `interval``timeout`: 覆盖全局默认值
- `expect`: 期望校验
- `status`: 可接受的状态码列表
- `bodyContains`: 响应体包含的文本
- `maxLatencyMs`: 最大延迟阈值(毫秒)
时长格式支持:`30s``5m``500ms`
## API 端点
| 端点 | 说明 |
|------|------|
| `GET /health` | 健康检查 |
| `GET /api/summary` | 总览统计total/up/down/avgLatencyMs/lastCheckTime |
| `GET /api/targets` | 目标列表及最新状态和统计摘要 |
| `GET /api/targets/:id/history?limit=20` | 指定目标的最近 N 条拨测记录 |
| `GET /api/targets/:id/trend?hours=24` | 指定目标的按小时聚合趋势 |
## 代码质量
```bash
@@ -57,23 +117,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 +127,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 +154,21 @@ 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 或独立静态站点的路径。
## 目标状态判定
两层判定模型:
- **success**: 请求是否成功完成(收到 HTTP 响应)
- **matched**: 是否符合 expect 规则(无 expect 时默认为 true
- **UP** = success AND matched
- **DOWN** = NOT success OR NOT matched
## 已知限制
当前 demo 不包含数据库、认证、SSR、React Router 或 UI 组件库。单 executable 是按目标平台构建的产物,不是一个文件同时覆盖 macOS、Linux 和 Windows
当前不做告警通知、数据自动清理、拨测目标动态增删、认证鉴权和分布式部署

View File

@@ -7,6 +7,7 @@
"dependencies": {
"react": "^19.2.6",
"react-dom": "^19.2.6",
"recharts": "^3.8.1",
},
"devDependencies": {
"@eslint/js": "^10.0.1",
@@ -103,6 +104,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 +138,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 +176,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=="],
@@ -191,20 +218,48 @@
"caniuse-lite": ["caniuse-lite@1.0.30001792", "https://registry.npmmirror.com/caniuse-lite/-/caniuse-lite-1.0.30001792.tgz", {}, "sha512-hVLMUZFgR4JJ6ACt1uEESvQN1/dBVqPAKY0hgrV70eN3391K6juAfTjKZLKvOMsx8PxA7gsY1/tLMMTcfFLLpw=="],
"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=="],
"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=="],
"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=="],
"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 +284,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=="],
@@ -257,8 +314,12 @@
"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=="],
@@ -345,6 +406,18 @@
"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=="],
"scheduler": ["scheduler@0.27.0", "https://registry.npmmirror.com/scheduler/-/scheduler-0.27.0.tgz", {}, "sha512-eNv+WrVbKu1f3vbYJT/xtiF5syA5HPIMtf9IgY/nKg0sWqzAUEvqY/xm7OcZc/qafLx/iO9FgOmeSAp4v5ti/Q=="],
@@ -357,6 +430,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=="],
@@ -375,6 +450,10 @@
"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=="],
"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=="],
@@ -391,6 +470,8 @@
"@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=="],

View File

@@ -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 在前端开发工作流文档中说明日常检查和完整验证命令。

View File

@@ -9,45 +9,34 @@
#### Scenario: 启动运行时服务器
- **WHEN** server 进程成功启动
- **THEN** 它 SHALL 监听配置的 host 和 port并记录实际 server URL
- **THEN** 它 SHALL 监听 YAML 配置文件中指定的 host 和 port并记录实际 server URL
#### Scenario: 提供运行时配置
- **WHEN** 通过支持的运行时配置提供 hostport
#### Scenario: 通过 YAML 配置提供运行时参数
- **WHEN** 通过 YAML 配置文件提供 hostport、数据目录等参数
- **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 使用明确的缓存策略。

View File

@@ -0,0 +1,63 @@
## Purpose
定义拨测系统的 REST API 端点:总览统计、目标列表含状态、历史记录和趋势聚合。
## 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 为 nullstats 中 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 状态码和错误信息

View File

@@ -0,0 +1,57 @@
## Purpose
定义 HTTP 拨测工具的 YAML 配置文件格式、解析校验规则和 CLI 启动流程。
## 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()` 将内容解析为配置对象

View File

@@ -0,0 +1,73 @@
## Purpose
定义拨测系统的 React 前端 Dashboard统计卡片、目标列表表格、可展开详情面板和趋势图可视化。
## 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 显示错误提示,并在下一次轮询周期自动重试

View File

@@ -0,0 +1,60 @@
## Purpose
定义基于 SQLite 的拨测数据持久化存储targets 同步、check_results 追加写入、索引与聚合查询。
## 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 返回按小时分组的聚合数据,包括每小时的平均延迟和可用率

View File

@@ -0,0 +1,87 @@
## Purpose
定义拨测调度引擎的行为:按 interval 分组定时、组内并发拨测、expect 结果校验和结果持久化。
## Requirements
### Requirement: 按 interval 分组调度
系统 SHALL 将拨测目标按 interval 值分组,每组使用独立的定时器进行调度。
#### Scenario: 相同 interval 的目标共享定时器
- **WHEN** 多个 target 配置了相同的 interval如 30s
- **THEN** 系统 SHALL 使用同一个 `setInterval` 定时器,每次 tick 并发拨测所有该组目标
#### Scenario: 不同 interval 的目标各自调度
- **WHEN** target A 配置 15s intervaltarget 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

View File

@@ -35,6 +35,7 @@
},
"dependencies": {
"react": "^19.2.6",
"react-dom": "^19.2.6"
"react-dom": "^19.2.6",
"recharts": "^3.8.1"
}
}

16
probes.example.yaml Normal file
View File

@@ -0,0 +1,16 @@
server:
host: "127.0.0.1"
port: 3000
dataDir: "/tmp/probes_data"
defaults:
interval: "5s"
timeout: "10s"
method: "GET"
targets:
- name: "Baidu"
url: "https://www.baidu.com"
expect:
status: [200]
maxLatencyMs: 10000

View 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);
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);
});
`,
);

View File

@@ -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",

View File

@@ -1,21 +1,35 @@
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");
writeFileSync(
configPath,
`targets:
- name: "httpbin"
url: "https://httpbin.org/get"
interval: "5m"
timeout: "15s"
expect:
status: [200]
`,
);
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)], {
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 +41,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 +81,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 +125,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}`);

View File

@@ -1,4 +1,14 @@
import type { ApiErrorResponse, DemoResponse, HealthResponse, RuntimeMode } from "../shared/api";
import type {
ApiErrorResponse,
CheckResult,
HealthResponse,
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 +18,7 @@ export interface StaticAssets {
export interface AppOptions {
mode: RuntimeMode;
staticAssets?: StaticAssets;
store?: ProbeStore;
}
export function createFetchHandler(options: AppOptions) {
@@ -22,19 +33,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,19 +56,159 @@ export function createFetchHandler(options: AppOptions) {
};
}
function createDemoResponse(mode: RuntimeMode): DemoResponse {
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 limitParam = url.searchParams.get("limit");
let limit = 20;
if (limitParam !== null) {
limit = Number(limitParam);
if (!Number.isInteger(limit) || limit <= 0) {
return jsonResponse(createApiError("Invalid limit parameter", 400), { method, mode, status: 400 });
}
}
const rows = store.getHistory(id, limit);
const results: CheckResult[] = rows.map(mapCheckResult);
return jsonResponse(results, { 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 hoursParam = url.searchParams.get("hours");
let hours = 24;
if (hoursParam !== null) {
hours = Number(hoursParam);
if (!Number.isInteger(hours) || hours <= 0) {
return jsonResponse(createApiError("Invalid hours parameter", 400), { method, mode, status: 400 });
}
}
const trend: TrendPoint[] = store.getTrend(id, hours).map((row) => ({
hour: row.hour,
avgLatencyMs: row.avgLatencyMs,
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 {
message: "Bun 后端已通过 /api/demo 连接到 React 前端。",
runtime: {
mode,
bunVersion: Bun.version,
platform: process.platform,
arch: process.arch,
timestamp: new Date().toISOString(),
},
total: summary.total,
up: summary.up,
down: summary.down,
avgLatencyMs: summary.avgLatencyMs,
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);
return {
id: target.id,
name: target.name,
url: target.url,
method: target.method,
interval: formatDuration(target.interval_ms),
latestCheck: latest ? mapCheckResult(latest) : null,
sparkline: store.getSparkline(target.id),
stats: {
totalChecks: stats.totalChecks,
availability: stats.availability,
avgLatencyMs: stats.avgLatencyMs,
p99LatencyMs: stats.p99LatencyMs,
},
};
});
}
function mapCheckResult(row: StoredCheckResult): CheckResult {
return {
timestamp: row.timestamp,
success: row.success === 1,
statusCode: row.status_code,
latencyMs: row.latency_ms,
error: row.error,
matched: row.matched === 1,
};
}
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 {
return {
ok: true,
@@ -87,7 +234,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, {

View File

@@ -0,0 +1,104 @@
import type { ProbeConfig, ResolvedTarget } from "./types";
const DEFAULT_HOST = "127.0.0.1";
const DEFAULT_PORT = 3000;
const DEFAULT_DATA_DIR = "./data";
const DEFAULT_INTERVAL = "30s";
const DEFAULT_TIMEOUT = "10s";
const DEFAULT_METHOD = "GET";
export interface ResolvedConfig {
host: string;
port: number;
dataDir: string;
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 server = raw.server ?? {};
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 defaultIntervalMs = parseDuration(defaults.interval ?? DEFAULT_INTERVAL);
const defaultTimeoutMs = parseDuration(defaults.timeout ?? DEFAULT_TIMEOUT);
const defaultMethod = defaults.method ?? DEFAULT_METHOD;
const defaultHeaders = defaults.headers ?? {};
const targets: ResolvedTarget[] = raw.targets.map((target) => ({
name: target.name,
url: target.url,
method: target.method ?? defaultMethod,
headers: { ...defaultHeaders, ...(target.headers ?? {}) },
body: target.body,
intervalMs: parseDuration(target.interval ?? defaults.interval ?? DEFAULT_INTERVAL),
timeoutMs: parseDuration(target.timeout ?? defaults.timeout ?? DEFAULT_TIMEOUT),
expect: target.expect,
}));
return { host, port, dataDir, targets };
}
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 target = config.targets[i]!;
if (!target.name || typeof target.name !== "string" || target.name.trim() === "") {
throw new Error(`${i + 1} 个 target 缺少 name 字段`);
}
if (!target.url || typeof target.url !== "string" || target.url.trim() === "") {
throw new Error(`target "${target.name}" 缺少 url 字段`);
}
if (names.has(target.name)) {
throw new Error(`target name 重复: "${target.name}"`);
}
names.add(target.name);
}
}
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;
}

View File

@@ -0,0 +1,87 @@
import type { CheckResult, ResolvedTarget } from "./types";
import type { ProbeStore } from "./store";
import { fetchTarget } from "./fetcher";
export class ProbeEngine {
private timers: ReturnType<typeof setInterval>[] = [];
private store: ProbeStore;
private targetNameToId: Map<string, number> = new Map();
constructor(store: ProbeStore, targets: ResolvedTarget[]) {
this.store = store;
this.targets = targets;
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 probeGroup(targets: ResolvedTarget[]): Promise<void> {
const results = await Promise.allSettled(targets.map((t) => this.probeOne(t)));
for (const result of results) {
if (result.status === "fulfilled") {
this.writeResult(result.value);
}
}
}
private async probeOne(target: ResolvedTarget): Promise<CheckResult> {
return fetchTarget(target);
}
private writeResult(result: CheckResult): void {
const targetId = this.targetNameToId.get(result.targetName);
if (!targetId) return;
this.store.insertCheckResult({
targetId,
timestamp: result.timestamp,
success: result.success,
statusCode: result.statusCode,
latencyMs: result.latencyMs,
error: result.error,
matched: result.matched,
});
}
private refreshCache(): void {
this.targetNameToId.clear();
for (const target of this.store.getTargets()) {
this.targetNameToId.set(target.name, target.id);
}
}
private targets: ResolvedTarget[];
}

View File

@@ -0,0 +1,65 @@
import type { CheckResult, ExpectConfig, ResolvedTarget } from "./types";
export async function fetchTarget(target: ResolvedTarget): Promise<CheckResult> {
const timestamp = new Date().toISOString();
const controller = new AbortController();
const timeoutId = setTimeout(() => controller.abort(), target.timeoutMs);
try {
const start = performance.now();
const response = await fetch(target.url, {
method: target.method,
headers: target.headers,
body: target.method !== "GET" && target.method !== "HEAD" ? target.body : undefined,
signal: controller.signal,
});
const latencyMs = Math.round(performance.now() - start);
const body = await response.text();
const matched = checkExpect(response.status, body, latencyMs, target.expect);
return {
targetName: target.name,
timestamp,
success: true,
statusCode: response.status,
latencyMs,
error: null,
matched,
};
} catch (error) {
const isTimeout = error instanceof DOMException && error.name === "AbortError";
return {
targetName: target.name,
timestamp,
success: false,
statusCode: null,
latencyMs: null,
error: isTimeout ? `请求超时 (${target.timeoutMs}ms)` : (error instanceof Error ? error.message : String(error)),
matched: false,
};
} finally {
clearTimeout(timeoutId);
}
}
export function checkExpect(statusCode: number, body: string, latencyMs: number, expect?: ExpectConfig): boolean {
if (!expect) return true;
if (expect.status && !expect.status.includes(statusCode)) {
return false;
}
if (expect.bodyContains && !body.includes(expect.bodyContains)) {
return false;
}
if (expect.maxLatencyMs !== undefined && latencyMs > expect.maxLatencyMs) {
return false;
}
return true;
}

277
src/server/checker/store.ts Normal file
View File

@@ -0,0 +1,277 @@
import { Database } from "bun:sqlite";
import { mkdirSync as fsMkdirSync } from "node:fs";
import { dirname } from "node:path";
import type { ResolvedTarget, StoredCheckResult, StoredTarget } from "./types";
const CREATE_TARGETS_TABLE = `
CREATE TABLE IF NOT EXISTS targets (
id INTEGER PRIMARY KEY AUTOINCREMENT,
name TEXT NOT NULL UNIQUE,
url TEXT NOT NULL,
method TEXT NOT NULL DEFAULT 'GET',
headers TEXT NOT NULL DEFAULT '{}',
body TEXT,
interval_ms INTEGER NOT NULL,
timeout_ms INTEGER NOT NULL,
expect TEXT
)
`;
const CREATE_CHECK_RESULTS_TABLE = `
CREATE TABLE IF NOT EXISTS check_results (
id INTEGER PRIMARY KEY AUTOINCREMENT,
target_id INTEGER NOT NULL,
timestamp TEXT NOT NULL,
success INTEGER NOT NULL,
status_code INTEGER,
latency_ms REAL,
error TEXT,
matched INTEGER NOT NULL,
FOREIGN KEY (target_id) REFERENCES targets(id)
)
`;
const CREATE_INDEX = `
CREATE INDEX IF NOT EXISTS idx_check_results_target_timestamp
ON check_results (target_id, timestamp)
`;
export class ProbeStore {
private db: Database;
private closed = false;
constructor(dbPath: string) {
ensureDir(dirname(dbPath));
this.db = new Database(dbPath, { create: true });
this.db.run("PRAGMA journal_mode = WAL");
this.db.run(CREATE_TARGETS_TABLE);
this.db.run(CREATE_CHECK_RESULTS_TABLE);
this.db.run(CREATE_INDEX);
}
syncTargets(targets: ResolvedTarget[]): void {
if (this.closed) return;
const existingRows = this.db.query("SELECT id, name FROM targets").all() as Array<{
id: number;
name: string;
}>;
const existingMap = new Map(existingRows.map((r) => [r.name, r.id]));
const configNames = new Set(targets.map((t) => t.name));
const insertStmt = this.db.prepare(
"INSERT INTO targets (name, url, method, headers, body, interval_ms, timeout_ms, expect) VALUES (?, ?, ?, ?, ?, ?, ?, ?)",
);
const updateStmt = this.db.prepare(
"UPDATE targets SET url = ?, method = ?, headers = ?, body = ?, interval_ms = ?, timeout_ms = ?, expect = ? WHERE id = ?",
);
const deleteStmt = this.db.prepare("DELETE FROM targets WHERE id = ?");
const tx = this.db.transaction(() => {
for (const target of targets) {
const headers = JSON.stringify(target.headers);
const expect = target.expect ? JSON.stringify(target.expect) : null;
if (existingMap.has(target.name)) {
updateStmt.run(
target.url,
target.method,
headers,
target.body ?? null,
target.intervalMs,
target.timeoutMs,
expect,
existingMap.get(target.name)!,
);
} else {
insertStmt.run(target.name, target.url, target.method, headers, target.body ?? null, target.intervalMs, target.timeoutMs, expect);
}
}
for (const [name, id] of existingMap) {
if (!configNames.has(name)) {
deleteStmt.run(id);
}
}
});
tx();
}
getTargets(): StoredTarget[] {
if (this.closed) return [];
return this.db.query("SELECT * FROM targets ORDER BY id").all() as StoredTarget[];
}
getTargetById(id: number): StoredTarget | null {
if (this.closed) return null;
return this.db.query("SELECT * FROM targets WHERE id = ?").get(id) as StoredTarget | null;
}
insertCheckResult(result: {
targetId: number;
timestamp: string;
success: boolean;
statusCode: number | null;
latencyMs: number | null;
error: string | null;
matched: boolean;
}): void {
if (this.closed) return;
this.db
.prepare(
"INSERT INTO check_results (target_id, timestamp, success, status_code, latency_ms, error, matched) VALUES (?, ?, ?, ?, ?, ?, ?)",
)
.run(
result.targetId,
result.timestamp,
result.success ? 1 : 0,
result.statusCode,
result.latencyMs,
result.error,
result.matched ? 1 : 0,
);
}
getLatestCheck(targetId: number): StoredCheckResult | null {
return this.db.query("SELECT * FROM check_results WHERE target_id = ? ORDER BY timestamp DESC LIMIT 1").get(targetId) as
| StoredCheckResult
| null;
}
getHistory(targetId: number, limit = 20): StoredCheckResult[] {
return this.db
.prepare("SELECT * FROM check_results WHERE target_id = ? ORDER BY timestamp DESC LIMIT ?")
.all(targetId, limit) as StoredCheckResult[];
}
getTargetStats(targetId: number): {
totalChecks: number;
availability: number;
avgLatencyMs: number | null;
p99LatencyMs: number | null;
} {
const row = this.db
.prepare(
`SELECT
COUNT(*) as totalChecks,
COALESCE(SUM(CASE WHEN matched = 1 THEN 1 ELSE 0 END), 0) as upCount,
AVG(CASE WHEN success = 1 THEN latency_ms END) as avgLatencyMs
FROM check_results
WHERE target_id = ?`,
)
.get(targetId) as { totalChecks: number; upCount: number; avgLatencyMs: number | null };
const p99Row = this.db
.prepare(
`SELECT latency_ms as p99LatencyMs
FROM check_results
WHERE target_id = ? AND success = 1
ORDER BY latency_ms DESC
LIMIT 1
OFFSET (SELECT COUNT(*) FROM check_results WHERE target_id = ? AND success = 1) * 99 / 100`,
)
.get(targetId, targetId) as { p99LatencyMs: number | null } | undefined;
const totalChecks = row.totalChecks;
const availability = totalChecks > 0 ? (row.upCount / totalChecks) * 100 : 0;
return {
totalChecks,
availability: Math.round(availability * 100) / 100,
avgLatencyMs: row.avgLatencyMs !== null ? Math.round(row.avgLatencyMs * 100) / 100 : null,
p99LatencyMs: p99Row?.p99LatencyMs ?? null,
};
}
getTrend(targetId: number, hours = 24): Array<{
hour: string;
avgLatencyMs: number | null;
availability: number;
totalChecks: number;
}> {
return this.db
.prepare(
`SELECT
strftime('%Y-%m-%dT%H:00:00', timestamp) as hour,
AVG(CASE WHEN success = 1 THEN latency_ms END) as avgLatencyMs,
CASE WHEN COUNT(*) > 0 THEN (SUM(CASE WHEN matched = 1 THEN 1 ELSE 0 END) * 100.0 / COUNT(*)) ELSE 0 END as availability,
COUNT(*) as totalChecks
FROM check_results
WHERE target_id = ? AND timestamp >= datetime('now', '-' || ? || ' hours')
GROUP BY hour
ORDER BY hour`,
)
.all(targetId, hours) as Array<{
hour: string;
avgLatencyMs: number | null;
availability: number;
totalChecks: number;
}>;
}
getSummary(): {
total: number;
up: number;
down: number;
avgLatencyMs: number | null;
lastCheckTime: string | null;
} {
const targets = this.getTargets();
let up = 0;
let down = 0;
let totalLatency = 0;
let latencyCount = 0;
let lastCheckTime: string | null = null;
for (const target of targets) {
const latest = this.getLatestCheck(target.id);
if (latest) {
if (latest.success && latest.matched) {
up++;
} else {
down++;
}
if (latest.latency_ms !== null) {
totalLatency += latest.latency_ms;
latencyCount++;
}
if (!lastCheckTime || latest.timestamp > lastCheckTime) {
lastCheckTime = latest.timestamp;
}
} else {
down++;
}
}
return {
total: targets.length,
up,
down,
avgLatencyMs: latencyCount > 0 ? Math.round((totalLatency / latencyCount) * 100) / 100 : null,
lastCheckTime,
};
}
getSparkline(targetId: number, limit = 20): number[] {
const rows = this.db
.prepare("SELECT latency_ms FROM check_results WHERE target_id = ? AND success = 1 ORDER BY timestamp DESC LIMIT ?")
.all(targetId, limit) as Array<{ latency_ms: number }>;
return rows.map((r) => r.latency_ms).reverse();
}
close(): void {
this.closed = true;
this.db.close();
}
}
function ensureDir(dir: string): void {
try {
fsMkdirSync(dir, { recursive: true });
} catch {
// already exists
}
}

View File

@@ -0,0 +1,79 @@
export interface ProbeConfig {
server?: ServerConfig;
defaults?: DefaultsConfig;
targets: TargetConfig[];
}
export interface ServerConfig {
host?: string;
port?: number;
dataDir?: string;
}
export interface DefaultsConfig {
interval?: string;
timeout?: string;
method?: string;
headers?: Record<string, string>;
}
export interface TargetConfig {
name: string;
url: string;
method?: string;
headers?: Record<string, string>;
body?: string;
interval?: string;
timeout?: string;
expect?: ExpectConfig;
}
export interface ExpectConfig {
status?: number[];
bodyContains?: string;
maxLatencyMs?: number;
}
export interface ResolvedTarget {
name: string;
url: string;
method: string;
headers: Record<string, string>;
body?: string;
intervalMs: number;
timeoutMs: number;
expect?: ExpectConfig;
}
export interface CheckResult {
targetName: string;
timestamp: string;
success: boolean;
statusCode: number | null;
latencyMs: number | null;
error: string | null;
matched: boolean;
}
export interface StoredTarget {
id: number;
name: string;
url: string;
method: string;
headers: string;
body: string | null;
interval_ms: number;
timeout_ms: number;
expect: string | null;
}
export interface StoredCheckResult {
id: number;
target_id: number;
timestamp: string;
success: number;
status_code: number | null;
latency_ms: number | null;
error: string | null;
matched: number;
}

View File

@@ -1,39 +1,12 @@
export function readRuntimeConfig(argv: string[] = process.argv.slice(2)): { configPath: string } {
if (argv.length === 0) {
throw new Error("需要指定 YAML 配置文件路径\n用法: gateway-checker <config.yaml>");
}
return { configPath: argv[0]! };
}
export interface RuntimeConfig {
host: string;
port: number;
}
const DEFAULT_HOST = "127.0.0.1";
const DEFAULT_PORT = 3000;
export function readRuntimeConfig(
argv: string[] = process.argv.slice(2),
env: Record<string, string | undefined> = Bun.env,
): RuntimeConfig {
const host = readOption(argv, "host") ?? env.HOST ?? DEFAULT_HOST;
const portValue = readOption(argv, "port") ?? env.PORT ?? String(DEFAULT_PORT);
const port = Number(portValue);
if (!Number.isInteger(port) || port < 0 || port > 65535) {
throw new Error(`无效端口: ${portValue}`);
}
return { host, port };
}
function readOption(argv: string[], name: string): string | undefined {
const prefix = `--${name}=`;
const inline = argv.find((value) => value.startsWith(prefix));
if (inline) {
return inline.slice(prefix.length);
}
const index = argv.indexOf(`--${name}`);
if (index >= 0) {
return argv[index + 1];
}
return undefined;
}

View File

@@ -1,7 +1,27 @@
import { readRuntimeConfig } from "./config";
import { loadConfig } from "./checker/config-loader";
import { ProbeStore } from "./checker/store";
import { ProbeEngine } from "./checker/engine";
import { startServer } from "./server";
import { readRuntimeConfig } from "./config";
startServer({
config: readRuntimeConfig(),
mode: "development",
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);
engine.start();
startServer({
config: { host: config.host, port: config.port },
mode: "development",
store,
});
}
void main().catch((error) => {
console.error("启动失败:", error instanceof Error ? error.message : error);
process.exit(1);
});

View File

@@ -1,21 +1,25 @@
import type { RuntimeMode } from "../shared/api";
import { createFetchHandler, type StaticAssets } from "./app";
import { readRuntimeConfig, type RuntimeConfig } from "./config";
import type { StaticAssets } from "./app";
import type { ProbeStore } from "./checker/store";
import { createFetchHandler } from "./app";
import type { RuntimeConfig } from "./config";
export interface StartServerOptions {
config?: RuntimeConfig;
config: RuntimeConfig;
mode: RuntimeMode;
staticAssets?: StaticAssets;
store?: ProbeStore;
}
export function startServer(options: StartServerOptions) {
const config = options.config ?? readRuntimeConfig();
const { config, mode, staticAssets, store } = options;
const server = Bun.serve({
hostname: config.host,
port: config.port,
fetch: createFetchHandler({
mode: options.mode,
staticAssets: options.staticAssets,
mode,
staticAssets,
store,
}),
});

View File

@@ -1,16 +1,5 @@
export type RuntimeMode = "development" | "production" | "test";
export interface DemoResponse {
message: string;
runtime: {
mode: RuntimeMode;
bunVersion: string;
platform: string;
arch: string;
timestamp: string;
};
}
export interface HealthResponse {
ok: true;
service: "gateway-checker";
@@ -21,3 +10,45 @@ export interface ApiErrorResponse {
error: string;
status: number;
}
export interface SummaryResponse {
total: number;
up: number;
down: number;
avgLatencyMs: number | null;
lastCheckTime: string | null;
}
export interface TargetStatus {
id: number;
name: string;
url: string;
method: string;
interval: string;
latestCheck: CheckResult | null;
stats: TargetStats;
sparkline: number[];
}
export interface TargetStats {
totalChecks: number;
availability: number;
avgLatencyMs: number | null;
p99LatencyMs: number | null;
}
export interface CheckResult {
timestamp: string;
success: boolean;
statusCode: number | null;
latencyMs: number | null;
error: string | null;
matched: boolean;
}
export interface TrendPoint {
hour: string;
avgLatencyMs: number | null;
availability: number;
totalChecks: number;
}

View File

@@ -1,91 +1,29 @@
import { useEffect, useState } from "react";
import type { DemoResponse } from "../shared/api";
type DemoState =
| { status: "loading" }
| { status: "success"; data: DemoResponse }
| { status: "error"; message: string };
import { useSummary } from "./hooks/useSummary";
import { useTargets } from "./hooks/useTargets";
import { SummaryCards } from "./components/SummaryCards";
import { TargetTable } from "./components/TargetTable";
export function App() {
const [demoState, setDemoState] = useState<DemoState>({ status: "loading" });
const { data: summary, loading: summaryLoading, error: summaryError } = useSummary();
const { data: targets, loading: targetsLoading, error: targetsError } = useTargets();
useEffect(() => {
const abortController = new AbortController();
async function loadDemo() {
try {
const response = await fetch("/api/demo", { signal: abortController.signal });
if (!response.ok) {
throw new Error(`请求失败: ${response.status}`);
}
const data = (await response.json()) as DemoResponse;
setDemoState({ status: "success", data });
} catch (error) {
if (abortController.signal.aborted) return;
setDemoState({
status: "error",
message: error instanceof Error ? error.message : "未知错误",
});
}
}
void loadDemo();
return () => abortController.abort();
}, []);
const error = summaryError || targetsError;
return (
<main className="shell">
<section className="hero" aria-labelledby="page-title">
<p className="eyebrow">Vite + React + Bun</p>
<h1 id="page-title">Gateway Checker Demo</h1>
<p className="summary">Bun API </p>
</section>
<main className="dashboard">
<header className="dashboard-header">
<h1>Gateway Checker</h1>
<p className="dashboard-subtitle">HTTP </p>
</header>
<section className="card" aria-live="polite">
<div className="card-header">
<span className="status-dot" data-state={demoState.status} />
<h2></h2>
{error && (
<div className="error-banner">
: {error}
</div>
)}
{demoState.status === "loading" ? <p> /api/demo...</p> : null}
{demoState.status === "error" ? (
<div className="error">
<strong></strong>
<p>{demoState.message}</p>
</div>
) : null}
{demoState.status === "success" ? (
<div className="result">
<p className="message">{demoState.data.message}</p>
<dl>
<div>
<dt></dt>
<dd>{demoState.data.runtime.mode}</dd>
</div>
<div>
<dt>Bun </dt>
<dd>{demoState.data.runtime.bunVersion}</dd>
</div>
<div>
<dt></dt>
<dd>
{demoState.data.runtime.platform}/{demoState.data.runtime.arch}
</dd>
</div>
<div>
<dt></dt>
<dd>{demoState.data.runtime.timestamp}</dd>
</div>
</dl>
</div>
) : null}
</section>
<SummaryCards summary={summary} loading={summaryLoading} />
<TargetTable targets={targets} loading={targetsLoading} />
</main>
);
}

View File

@@ -0,0 +1,19 @@
import { Line, LineChart, ResponsiveContainer } from "recharts";
interface SparklineChartProps {
data: Array<{ latency: number }>;
}
export function SparklineChart({ data }: SparklineChartProps) {
if (data.length === 0) {
return <span className="sparkline-empty">-</span>;
}
return (
<ResponsiveContainer width={80} height={32}>
<LineChart data={data}>
<Line type="monotone" dataKey="latency" stroke="#356dd2" strokeWidth={1.5} dot={false} />
</LineChart>
</ResponsiveContainer>
);
}

View File

@@ -0,0 +1,7 @@
interface StatusDotProps {
up: boolean;
}
export function StatusDot({ up }: StatusDotProps) {
return <span className={`status-dot ${up ? "status-up" : "status-down"}`} />;
}

View File

@@ -0,0 +1,36 @@
import type { SummaryResponse } from "../../shared/api";
interface SummaryCardsProps {
summary: SummaryResponse | null;
loading: boolean;
}
export function SummaryCards({ summary, loading }: SummaryCardsProps) {
if (loading && !summary) {
return <div className="summary-cards">...</div>;
}
if (!summary) return null;
const cards = [
{ label: "全部目标", value: summary.total, className: "card-total" },
{ label: "正常", value: summary.up, className: "card-up" },
{ label: "异常", value: summary.down, className: "card-down" },
{
label: "平均延迟",
value: summary.avgLatencyMs !== null ? `${Math.round(summary.avgLatencyMs)}ms` : "-",
className: "card-latency",
},
];
return (
<div className="summary-cards">
{cards.map((card) => (
<div key={card.className} className={`summary-card ${card.className}`}>
<div className="card-value">{card.value}</div>
<div className="card-label">{card.label}</div>
</div>
))}
</div>
);
}

View File

@@ -0,0 +1,106 @@
import { useCallback, useEffect, useState } from "react";
import type { CheckResult, TargetStatus } from "../../shared/api";
import { useTrend } from "../hooks/useTrend";
import { TrendChart } from "./TrendChart";
interface TargetDetailProps {
target: TargetStatus;
}
export function TargetDetail({ target }: TargetDetailProps) {
const { data: trendData, loading: trendLoading, fetchTrend } = useTrend(target.id);
const [history, setHistory] = useState<CheckResult[]>([]);
const [historyLoading, setHistoryLoading] = useState(false);
const fetchHistory = useCallback(async () => {
setHistoryLoading(true);
try {
const response = await fetch(`/api/targets/${target.id}/history?limit=10`);
if (response.ok) {
const data = (await response.json()) as CheckResult[];
setHistory(data);
}
} finally {
setHistoryLoading(false);
}
}, [target.id]);
useEffect(() => {
void fetchTrend();
void fetchHistory();
}, [fetchTrend, fetchHistory]);
const { stats } = target;
const isUp = target.latestCheck?.success && target.latestCheck?.matched;
return (
<tr>
<td colSpan={6} className="detail-cell">
<div className="target-detail">
<div className="detail-stats">
<div className="detail-stat">
<span className="detail-stat-label"></span>
<span className={`detail-stat-value ${isUp ? "text-up" : "text-down"}`}>
{isUp ? "UP" : "DOWN"}
</span>
</div>
<div className="detail-stat">
<span className="detail-stat-label"></span>
<span className="detail-stat-value">
{stats.totalChecks > 0 ? `${stats.availability.toFixed(1)}%` : "-"}
</span>
</div>
<div className="detail-stat">
<span className="detail-stat-label"></span>
<span className="detail-stat-value">
{stats.avgLatencyMs !== null ? `${Math.round(stats.avgLatencyMs)}ms` : "-"}
</span>
</div>
<div className="detail-stat">
<span className="detail-stat-label">P99 </span>
<span className="detail-stat-value">
{stats.p99LatencyMs !== null ? `${Math.round(stats.p99LatencyMs)}ms` : "-"}
</span>
</div>
</div>
<div className="detail-trend">
<h4>24 </h4>
<TrendChart data={trendData} loading={trendLoading} />
</div>
<div className="detail-history">
<h4></h4>
{historyLoading ? (
<p className="history-empty">...</p>
) : history.length > 0 ? (
<div className="history-list">
{history.map((item, idx) => (
<div key={idx} className="history-item">
<span className={`history-status ${item.success && item.matched ? "text-up" : "text-down"}`}>
{item.success && item.matched ? "UP" : "DOWN"}
</span>
<span className="history-time">
{new Date(item.timestamp).toLocaleString("zh-CN")}
</span>
{item.statusCode && (
<span className="history-code">{item.statusCode}</span>
)}
{item.latencyMs !== null && (
<span className="history-latency">{Math.round(item.latencyMs)}ms</span>
)}
{item.error && (
<span className="history-error">{item.error}</span>
)}
</div>
))}
</div>
) : (
<p className="history-empty"></p>
)}
</div>
</div>
</td>
</tr>
);
}

View File

@@ -0,0 +1,34 @@
import type { TargetStatus } from "../../shared/api";
import { StatusDot } from "./StatusDot";
import { SparklineChart } from "./SparklineChart";
interface TargetRowProps {
target: TargetStatus;
expanded: boolean;
onToggle: () => void;
}
export function TargetRow({ target, expanded, onToggle }: TargetRowProps) {
const isUp = target.latestCheck?.success && target.latestCheck?.matched;
const sparklineData = target.sparkline.map((latency) => ({ latency }));
return (
<tr className={`target-row ${expanded ? "expanded" : ""}`} onClick={onToggle}>
<td className="col-status">
<StatusDot up={!!isUp} />
</td>
<td className="col-name">{target.name}</td>
<td className="col-url">{target.url}</td>
<td className="col-method">{target.method}</td>
<td className="col-latency">
{target.latestCheck?.latencyMs !== null && target.latestCheck?.latencyMs !== undefined
? `${Math.round(target.latestCheck.latencyMs)}ms`
: "-"}
</td>
<td className="col-sparkline">
<SparklineChart data={sparklineData} />
</td>
</tr>
);
}

View File

@@ -0,0 +1,66 @@
import { useState } from "react";
import type { TargetStatus } from "../../shared/api";
import { TargetRow } from "./TargetRow";
import { TargetDetail } from "./TargetDetail";
interface TargetTableProps {
targets: TargetStatus[];
loading: boolean;
}
export function TargetTable({ targets, loading }: TargetTableProps) {
const [expandedId, setExpandedId] = useState<number | null>(null);
if (loading && targets.length === 0) {
return <div className="table-loading">...</div>;
}
if (targets.length === 0) {
return <div className="table-empty"></div>;
}
return (
<table className="target-table">
<thead>
<tr>
<th className="col-status"></th>
<th className="col-name"></th>
<th className="col-url">URL</th>
<th className="col-method"></th>
<th className="col-latency"></th>
<th className="col-sparkline"></th>
</tr>
</thead>
<tbody>
{targets.map((target) => {
const isExpanded = expandedId === target.id;
return (
<TargetRowWrapper
key={target.id}
target={target}
expanded={isExpanded}
onToggle={() => setExpandedId(isExpanded ? null : target.id)}
/>
);
})}
</tbody>
</table>
);
}
function TargetRowWrapper({
target,
expanded,
onToggle,
}: {
target: TargetStatus;
expanded: boolean;
onToggle: () => void;
}) {
return (
<>
<TargetRow target={target} expanded={expanded} onToggle={onToggle} />
{expanded && <TargetDetail target={target} />}
</>
);
}

View File

@@ -0,0 +1,46 @@
import { Line, LineChart, ResponsiveContainer, XAxis, YAxis, Tooltip, CartesianGrid } from "recharts";
import type { TrendPoint } from "../../shared/api";
interface TrendChartProps {
data: TrendPoint[];
loading: boolean;
}
export function TrendChart({ data, loading }: TrendChartProps) {
if (loading) {
return <div className="trend-loading">...</div>;
}
if (data.length === 0) {
return <div className="trend-empty"></div>;
}
const chartData = data.map((point) => ({
...point,
hour: point.hour.slice(11, 16),
}));
return (
<div className="trend-chart">
<ResponsiveContainer width="100%" height={240}>
<LineChart data={chartData}>
<CartesianGrid strokeDasharray="3 3" stroke="#e2e8f0" />
<XAxis dataKey="hour" tick={{ fontSize: 12 }} stroke="#94a3b8" />
<YAxis yAxisId="latency" tick={{ fontSize: 12 }} stroke="#94a3b8" label={{ value: "ms", position: "insideTopRight", fontSize: 11 }} />
<YAxis yAxisId="availability" orientation="right" domain={[0, 100]} tick={{ fontSize: 12 }} stroke="#94a3b8" label={{ value: "%", position: "insideTopLeft", fontSize: 11 }} />
<Tooltip
formatter={(value: unknown, name: unknown) => {
const num = Number(value);
const nameStr = String(name);
if (nameStr === "avgLatencyMs") return [`${Math.round(num)}ms`, "平均延迟"];
if (nameStr === "availability") return [`${num.toFixed(1)}%`, "可用率"];
return [String(value), nameStr];
}}
/>
<Line yAxisId="latency" type="monotone" dataKey="avgLatencyMs" stroke="#356dd2" strokeWidth={2} dot={false} name="avgLatencyMs" />
<Line yAxisId="availability" type="monotone" dataKey="availability" stroke="#1fbf75" strokeWidth={2} dot={false} name="availability" />
</LineChart>
</ResponsiveContainer>
</div>
);
}

View File

@@ -0,0 +1,41 @@
import { useCallback, useEffect, useRef, useState } from "react";
import type { SummaryResponse } from "../../shared/api";
export function useSummary(intervalMs = 8000) {
const [data, setData] = useState<SummaryResponse | null>(null);
const [error, setError] = useState<string | null>(null);
const [loading, setLoading] = useState(true);
const abortRef = useRef<AbortController | null>(null);
const fetchSummary = useCallback(async () => {
try {
abortRef.current?.abort();
const controller = new AbortController();
abortRef.current = controller;
const response = await fetch("/api/summary", { signal: controller.signal });
if (!response.ok) throw new Error(`HTTP ${response.status}`);
const result = (await response.json()) as SummaryResponse;
setData(result);
setError(null);
} catch (err) {
if (err instanceof DOMException && err.name === "AbortError") return;
setError(err instanceof Error ? err.message : "请求失败");
} finally {
setLoading(false);
}
}, []);
useEffect(() => {
void fetchSummary();
const timer = setInterval(fetchSummary, intervalMs);
return () => {
clearInterval(timer);
abortRef.current?.abort();
};
}, [fetchSummary, intervalMs]);
return { data, error, loading, refresh: fetchSummary };
}

View File

@@ -0,0 +1,41 @@
import { useCallback, useEffect, useRef, useState } from "react";
import type { TargetStatus } from "../../shared/api";
export function useTargets(intervalMs = 8000) {
const [data, setData] = useState<TargetStatus[]>([]);
const [error, setError] = useState<string | null>(null);
const [loading, setLoading] = useState(true);
const abortRef = useRef<AbortController | null>(null);
const fetchTargets = useCallback(async () => {
try {
abortRef.current?.abort();
const controller = new AbortController();
abortRef.current = controller;
const response = await fetch("/api/targets", { signal: controller.signal });
if (!response.ok) throw new Error(`HTTP ${response.status}`);
const result = (await response.json()) as TargetStatus[];
setData(result);
setError(null);
} catch (err) {
if (err instanceof DOMException && err.name === "AbortError") return;
setError(err instanceof Error ? err.message : "请求失败");
} finally {
setLoading(false);
}
}, []);
useEffect(() => {
void fetchTargets();
const timer = setInterval(fetchTargets, intervalMs);
return () => {
clearInterval(timer);
abortRef.current?.abort();
};
}, [fetchTargets, intervalMs]);
return { data, error, loading, refresh: fetchTargets };
}

30
src/web/hooks/useTrend.ts Normal file
View File

@@ -0,0 +1,30 @@
import { useCallback, useState } from "react";
import type { TrendPoint } from "../../shared/api";
export function useTrend(targetId: number | null) {
const [data, setData] = useState<TrendPoint[]>([]);
const [error, setError] = useState<string | null>(null);
const [loading, setLoading] = useState(false);
const fetchTrend = useCallback(async () => {
if (targetId === null) return;
setLoading(true);
setError(null);
try {
const response = await fetch(`/api/targets/${targetId}/trend?hours=24`);
if (!response.ok) throw new Error(`HTTP ${response.status}`);
const result = (await response.json()) as TrendPoint[];
setData(result);
} catch (err) {
setError(err instanceof Error ? err.message : "请求失败");
} finally {
setLoading(false);
}
}, [targetId]);
return { data, error, loading, fetchTrend };
}

View File

@@ -3,8 +3,8 @@
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<meta name="description" content="Gateway Checker Vite React Bun executable demo" />
<title>Gateway Checker Demo</title>
<meta name="description" content="Gateway Checker - HTTP 拨测监控面板" />
<title>Gateway Checker</title>
</head>
<body>
<div id="root"></div>

View File

@@ -21,155 +21,308 @@ body {
margin: 0;
}
.shell {
display: grid;
grid-template-columns: minmax(0, 1fr) minmax(320px, 460px);
gap: 32px;
align-items: center;
min-height: 100vh;
padding: 56px;
background:
radial-gradient(circle at top left, rgba(55, 125, 255, 0.18), transparent 34rem),
linear-gradient(135deg, #f8fbff 0%, #e3edf7 100%);
.dashboard {
max-width: 1100px;
margin: 0 auto;
padding: 32px 24px;
}
.hero,
.card {
border: 1px solid rgba(49, 83, 126, 0.14);
border-radius: 28px;
background: rgba(255, 255, 255, 0.78);
box-shadow: 0 24px 80px rgba(34, 57, 91, 0.16);
backdrop-filter: blur(18px);
.dashboard-header {
margin-bottom: 32px;
}
.hero {
padding: 48px;
.dashboard-header h1 {
margin: 0 0 4px;
font-size: 1.75rem;
letter-spacing: -0.03em;
}
.eyebrow {
margin: 0 0 18px;
color: #356dd2;
font-size: 0.78rem;
font-weight: 800;
letter-spacing: 0.16em;
text-transform: uppercase;
}
h1,
h2,
p {
margin-top: 0;
}
h1 {
max-width: 760px;
margin-bottom: 20px;
font-size: clamp(3rem, 8vw, 7rem);
line-height: 0.9;
letter-spacing: -0.08em;
}
.summary {
max-width: 620px;
margin-bottom: 0;
color: #42546c;
font-size: 1.2rem;
line-height: 1.8;
}
.card {
padding: 32px;
}
.card-header {
display: flex;
gap: 12px;
align-items: center;
margin-bottom: 24px;
}
.card-header h2 {
.dashboard-subtitle {
margin: 0;
font-size: 1.25rem;
color: #61728a;
font-size: 0.9rem;
}
.error-banner {
padding: 12px 16px;
margin-bottom: 16px;
border: 1px solid rgba(229, 72, 77, 0.25);
border-radius: 12px;
color: #9f2228;
background: rgba(255, 240, 240, 0.8);
font-size: 0.85rem;
}
.summary-cards {
display: grid;
grid-template-columns: repeat(4, 1fr);
gap: 16px;
margin-bottom: 32px;
}
.summary-card {
padding: 20px;
border: 1px solid rgba(49, 83, 126, 0.12);
border-radius: 16px;
background: rgba(255, 255, 255, 0.85);
box-shadow: 0 4px 16px rgba(34, 57, 91, 0.08);
}
.card-value {
font-size: 1.75rem;
font-weight: 700;
letter-spacing: -0.02em;
}
.card-label {
margin-top: 4px;
color: #61728a;
font-size: 0.8rem;
}
.card-up .card-value {
color: #1fbf75;
}
.card-down .card-value {
color: #e5484d;
}
.card-latency .card-value {
color: #356dd2;
}
.target-table {
width: 100%;
border-collapse: collapse;
background: rgba(255, 255, 255, 0.85);
border: 1px solid rgba(49, 83, 126, 0.12);
border-radius: 16px;
overflow: hidden;
box-shadow: 0 4px 16px rgba(34, 57, 91, 0.08);
}
.target-table thead th {
padding: 12px 16px;
text-align: left;
font-size: 0.78rem;
font-weight: 600;
color: #61728a;
text-transform: uppercase;
letter-spacing: 0.05em;
border-bottom: 1px solid rgba(49, 83, 126, 0.1);
background: rgba(236, 243, 252, 0.5);
}
.target-row {
cursor: pointer;
transition: background 0.15s;
}
.target-row:hover {
background: rgba(236, 243, 252, 0.6);
}
.target-row.expanded {
background: rgba(236, 243, 252, 0.5);
}
.target-row td {
padding: 12px 16px;
border-bottom: 1px solid rgba(49, 83, 126, 0.06);
font-size: 0.9rem;
}
.col-status {
width: 48px;
}
.col-name {
font-weight: 600;
}
.col-url {
color: #61728a;
font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace;
font-size: 0.82rem;
max-width: 260px;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.col-method {
width: 64px;
text-align: center;
}
.col-latency {
width: 80px;
text-align: right;
font-variant-numeric: tabular-nums;
}
.col-sparkline {
width: 100px;
}
.status-dot {
display: inline-block;
width: 12px;
height: 12px;
border-radius: 999px;
background: #f5a524;
box-shadow: 0 0 0 8px rgba(245, 165, 36, 0.14);
}
.status-dot[data-state="success"] {
.status-up {
background: #1fbf75;
box-shadow: 0 0 0 8px rgba(31, 191, 117, 0.14);
box-shadow: 0 0 0 6px rgba(31, 191, 117, 0.14);
}
.status-dot[data-state="error"] {
.status-down {
background: #e5484d;
box-shadow: 0 0 0 8px rgba(229, 72, 77, 0.14);
box-shadow: 0 0 0 6px rgba(229, 72, 77, 0.14);
}
.error {
padding: 16px;
border: 1px solid rgba(229, 72, 77, 0.25);
border-radius: 18px;
color: #9f2228;
background: rgba(255, 240, 240, 0.8);
.sparkline-empty {
color: #94a3b8;
font-size: 0.85rem;
}
.error p,
.message {
margin-bottom: 0;
.detail-cell {
padding: 0 !important;
border-bottom: 1px solid rgba(49, 83, 126, 0.1) !important;
background: rgba(240, 246, 252, 0.6);
}
.result {
.target-detail {
padding: 20px 24px;
}
.detail-stats {
display: grid;
gap: 24px;
grid-template-columns: repeat(4, 1fr);
gap: 16px;
margin-bottom: 24px;
}
.message {
color: #1c3f73;
font-size: 1.05rem;
font-weight: 700;
line-height: 1.6;
.detail-stat {
padding: 12px 16px;
border-radius: 12px;
background: rgba(255, 255, 255, 0.7);
}
dl {
display: grid;
gap: 12px;
margin: 0;
}
dl div {
display: grid;
gap: 4px;
padding: 14px 16px;
border-radius: 16px;
background: rgba(236, 243, 252, 0.74);
}
dt {
.detail-stat-label {
display: block;
font-size: 0.75rem;
color: #61728a;
margin-bottom: 4px;
}
.detail-stat-value {
font-size: 1.15rem;
font-weight: 700;
}
.text-up {
color: #1fbf75;
}
.text-down {
color: #e5484d;
}
.detail-trend {
margin-bottom: 20px;
}
.detail-trend h4,
.detail-history h4 {
margin: 0 0 12px;
font-size: 0.9rem;
color: #42546c;
}
.trend-loading,
.trend-empty {
padding: 24px;
text-align: center;
color: #94a3b8;
font-size: 0.85rem;
}
.detail-history {
margin-top: 16px;
}
.history-item {
display: flex;
gap: 12px;
align-items: center;
padding: 8px 12px;
border-radius: 8px;
background: rgba(255, 255, 255, 0.6);
font-size: 0.85rem;
}
.history-status {
font-weight: 700;
font-size: 0.78rem;
}
dd {
margin: 0;
overflow-wrap: anywhere;
.history-time {
color: #61728a;
}
.history-code {
font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace;
color: #42546c;
}
@media (max-width: 860px) {
.shell {
grid-template-columns: 1fr;
padding: 24px;
.history-latency {
font-variant-numeric: tabular-nums;
color: #356dd2;
}
.history-error {
color: #e5484d;
font-size: 0.8rem;
}
.history-empty {
color: #94a3b8;
font-size: 0.85rem;
margin: 0;
}
.table-loading,
.table-empty {
padding: 40px;
text-align: center;
color: #94a3b8;
background: rgba(255, 255, 255, 0.85);
border: 1px solid rgba(49, 83, 126, 0.12);
border-radius: 16px;
}
@media (max-width: 768px) {
.dashboard {
padding: 16px;
}
.hero,
.card {
padding: 28px;
border-radius: 22px;
.summary-cards {
grid-template-columns: repeat(2, 1fr);
}
.detail-stats {
grid-template-columns: repeat(2, 1fr);
}
.col-method,
.col-sparkline {
display: none;
}
.target-row td {
padding: 10px 12px;
}
}

View File

@@ -1,132 +1,198 @@
import { describe, expect, test } from "bun:test";
import { afterAll, beforeAll, describe, expect, test } from "bun:test";
import { createFetchHandler, type StaticAssets } from "../../src/server/app";
import { ProbeStore } from "../../src/server/checker/store";
import type { SummaryResponse, TargetStatus, HealthResponse } from "../../src/shared/api";
import { mkdir, rm } from "node:fs/promises";
import { join } from "node:path";
import { tmpdir } from "node:os";
const staticAssets: StaticAssets = {
indexHtml: new Blob(['<!doctype html><title>Gateway Checker Demo</title><div id="root"></div>'], {
indexHtml: new Blob(['<!doctype html><title>Gateway Checker</title><div id="root"></div>'], {
type: "text/html",
}),
files: {
"/assets/app.js": new Blob(["console.log('demo');"], { type: "text/javascript" }),
"/assets/app.js": new Blob(["console.log('app');"], { type: "text/javascript" }),
},
};
describe("Bun fullstack runtime", () => {
const fetchHandler = createFetchHandler({ mode: "test", staticAssets });
const productionFetchHandler = createFetchHandler({ mode: "production", staticAssets });
describe("API 路由", () => {
let tempDir: string;
let store: ProbeStore;
let fetchHandler: ReturnType<typeof createFetchHandler>;
test("/api/demo 返回 JSON demo 响应", async () => {
const response = await fetchHandler(new Request("http://localhost/api/demo"));
const body = await response.json();
beforeAll(async () => {
tempDir = join(tmpdir(), `gc-api-test-${Date.now()}`);
await mkdir(tempDir, { recursive: true });
store = new ProbeStore(join(tempDir, "test.db"));
store.syncTargets([
{
name: "test-a",
url: "http://a.com",
method: "GET",
headers: {},
intervalMs: 30000,
timeoutMs: 10000,
},
{
name: "test-b",
url: "http://b.com",
method: "POST",
headers: {},
intervalMs: 60000,
timeoutMs: 5000,
},
]);
expect(response.status).toBe(200);
expect(response.headers.get("content-type")).toContain("application/json");
expect(body.message).toContain("/api/demo");
expect(body.runtime.mode).toBe("test");
const targets = store.getTargets();
store.insertCheckResult({
targetId: targets[0]!.id,
timestamp: "2025-01-01T00:00:00.000Z",
success: true,
statusCode: 200,
latencyMs: 150,
error: null,
matched: true,
});
store.insertCheckResult({
targetId: targets[0]!.id,
timestamp: "2025-01-01T00:00:30.000Z",
success: false,
statusCode: null,
latencyMs: null,
error: "timeout",
matched: false,
});
fetchHandler = createFetchHandler({ mode: "test", staticAssets, store });
});
test("/health 返回机器可读健康检查", async () => {
afterAll(async () => {
store.close();
await rm(tempDir, { recursive: true, force: true });
});
test("/health 返回健康检查", async () => {
const response = await fetchHandler(new Request("http://localhost/health"));
const body = await response.json();
const body = (await response.json()) as HealthResponse;
expect(response.status).toBe(200);
expect(body.ok).toBe(true);
expect(body.service).toBe("gateway-checker");
});
test("HEAD 请求运行时端点返回 headers 但无 body", async () => {
const response = await fetchHandler(new Request("http://localhost/api/demo", { method: "HEAD" }));
const body = await response.text();
test("/api/summary 返回总览统计", async () => {
const response = await fetchHandler(new Request("http://localhost/api/summary"));
const body = (await response.json()) as SummaryResponse;
expect(response.status).toBe(200);
expect(body.total).toBe(2);
expect(body.up).toBeGreaterThanOrEqual(0);
expect(body.down).toBeGreaterThanOrEqual(0);
expect(body.up + body.down).toBe(2);
});
test("/api/targets 返回目标列表", async () => {
const response = await fetchHandler(new Request("http://localhost/api/targets"));
const body = (await response.json()) as TargetStatus[];
expect(response.status).toBe(200);
expect(response.headers.get("content-type")).toContain("application/json");
expect(body).toBe("");
expect(body).toHaveLength(2);
expect(body[0]!.name).toBe("test-a");
expect(body[0]!.latestCheck).not.toBeNull();
expect(body[0]!.latestCheck!.success).toBe(false);
expect(body[0]!.sparkline).toBeDefined();
expect(Array.isArray(body[0]!.sparkline)).toBe(true);
expect(body[1]!.latestCheck).toBeNull();
});
test("HEAD 请求健康检查端点返回 headers 但无 body", async () => {
const response = await fetchHandler(new Request("http://localhost/health", { method: "HEAD" }));
const body = await response.text();
test("/api/targets/:id/history 返回历史记录", async () => {
const targets = store.getTargets();
const response = await fetchHandler(new Request(`http://localhost/api/targets/${targets[0]!.id}/history`));
const body = await response.json();
expect(response.status).toBe(200);
expect(response.headers.get("content-type")).toContain("application/json");
expect(body).toBe("");
expect(body).toHaveLength(2);
});
test("运行时端点拒绝不支持的 method", async () => {
const response = await fetchHandler(new Request("http://localhost/api/demo", { method: "POST" }));
test("/api/targets/:id/history 支持 limit 参数", async () => {
const targets = store.getTargets();
const response = await fetchHandler(new Request(`http://localhost/api/targets/${targets[0]!.id}/history?limit=1`));
const body = await response.json();
expect(response.status).toBe(405);
expect(response.headers.get("allow")).toBe("GET, HEAD");
expect(response.headers.get("content-type")).toContain("application/json");
expect(body.status).toBe(405);
expect(body.error).toBe("Method not allowed");
expect(response.status).toBe(200);
expect(body).toHaveLength(1);
});
test("健康检查端点拒绝不支持的 method", async () => {
const response = await fetchHandler(new Request("http://localhost/health", { method: "POST" }));
test("/api/targets/:id/trend 返回趋势数据", async () => {
const targets = store.getTargets();
const response = await fetchHandler(new Request(`http://localhost/api/targets/${targets[0]!.id}/trend`));
const body = await response.json();
expect(response.status).toBe(405);
expect(response.headers.get("allow")).toBe("GET, HEAD");
expect(response.headers.get("content-type")).toContain("application/json");
expect(body.status).toBe(405);
expect(body.error).toBe("Method not allowed");
expect(response.status).toBe(200);
expect(Array.isArray(body)).toBe(true);
});
test("未知 /api/* 路由返回 JSON 404", async () => {
test("查询不存在的目标返回 404", async () => {
const response = await fetchHandler(new Request("http://localhost/api/targets/99999/history"));
const body = await response.json();
expect(response.status).toBe(404);
expect(body.error).toBe("Target not found");
});
test("无效 limit 参数返回 400", async () => {
const targets = store.getTargets();
const response = await fetchHandler(new Request(`http://localhost/api/targets/${targets[0]!.id}/history?limit=abc`));
const body = await response.json();
expect(response.status).toBe(400);
expect(body.error).toBe("Invalid limit parameter");
});
test("无效目标 ID 返回 400", async () => {
const response = await fetchHandler(new Request("http://localhost/api/targets/abc/history"));
const body = await response.json();
expect(response.status).toBe(400);
expect(body.error).toBe("Invalid target ID");
});
test("未知 /api/* 返回 404", async () => {
const response = await fetchHandler(new Request("http://localhost/api/missing"));
const body = await response.json();
expect(response.status).toBe(404);
expect(response.headers.get("content-type")).toContain("application/json");
expect(body.error).toBe("API route not found");
expect(body.status).toBe(404);
});
test("生产根路径返回前端入口", async () => {
const response = await fetchHandler(new Request("http://localhost/"));
test("HEAD 请求返回 headers 无 body", async () => {
const response = await fetchHandler(new Request("http://localhost/api/summary", { method: "HEAD" }));
const body = await response.text();
expect(response.status).toBe(200);
expect(response.headers.get("content-type")).toContain("text/html");
expect(response.headers.get("cache-control")).toBe("no-cache");
expect(body).toContain("Gateway Checker Demo");
expect(body).toBe("");
});
test("生产静态资源返回正确内容类型", async () => {
const response = await fetchHandler(new Request("http://localhost/assets/app.js"));
const body = await response.text();
test("不支持的 method 返回 405", async () => {
const response = await fetchHandler(new Request("http://localhost/api/summary", { method: "POST" }));
expect(response.status).toBe(200);
expect(response.headers.get("content-type")).toContain("text/javascript");
expect(response.headers.get("cache-control")).toBe("public, max-age=31536000, immutable");
expect(body).toContain("demo");
expect(response.status).toBe(405);
expect(response.headers.get("allow")).toBe("GET, HEAD");
});
test("未知静态资源返回 404 且不 fallback 到入口 HTML", async () => {
const response = await fetchHandler(new Request("http://localhost/assets/missing.js"));
const body = await response.text();
test("生产响应包含安全 headers", async () => {
const prodHandler = createFetchHandler({ mode: "production", staticAssets, store });
const response = await prodHandler(new Request("http://localhost/api/summary"));
expect(response.status).toBe(404);
expect(body).not.toContain("Gateway Checker Demo");
expect(response.headers.get("x-content-type-options")).toBe("nosniff");
expect(response.headers.get("referrer-policy")).toBe("strict-origin-when-cross-origin");
});
test("前端路由 fallback 到入口 HTML", async () => {
const response = await fetchHandler(new Request("http://localhost/dashboard"));
const body = await response.text();
test("静态资源和 SPA fallback 正常工作", async () => {
const root = await fetchHandler(new Request("http://localhost/"));
expect(root.status).toBe(200);
expect(response.status).toBe(200);
expect(body).toContain("Gateway Checker Demo");
});
const fallback = await fetchHandler(new Request("http://localhost/dashboard"));
expect(fallback.status).toBe(200);
test("生产响应包含低风险安全 headers", async () => {
const json = await productionFetchHandler(new Request("http://localhost/api/demo"));
const html = await productionFetchHandler(new Request("http://localhost/"));
const asset = await productionFetchHandler(new Request("http://localhost/assets/app.js"));
for (const response of [json, html, asset]) {
expect(response.headers.get("x-content-type-options")).toBe("nosniff");
expect(response.headers.get("referrer-policy")).toBe("strict-origin-when-cross-origin");
}
const asset = await fetchHandler(new Request("http://localhost/assets/app.js"));
expect(asset.status).toBe(200);
});
});

View File

@@ -0,0 +1,230 @@
import { beforeAll, afterAll, describe, expect, test } from "bun:test";
import { loadConfig, parseDuration } from "../../../src/server/checker/config-loader";
import { readRuntimeConfig } from "../../../src/server/config";
import { mkdir, rm, writeFile } from "node:fs/promises";
import { join } from "node:path";
import { tmpdir } from "node:os";
describe("parseDuration", () => {
test("解析秒", () => {
expect(parseDuration("30s")).toBe(30000);
expect(parseDuration("1s")).toBe(1000);
});
test("解析分钟", () => {
expect(parseDuration("5m")).toBe(300000);
expect(parseDuration("1m")).toBe(60000);
});
test("解析毫秒", () => {
expect(parseDuration("500ms")).toBe(500);
expect(parseDuration("100ms")).toBe(100);
});
test("解析小数", () => {
expect(parseDuration("1.5s")).toBe(1500);
});
test("无效格式抛出错误", () => {
expect(() => parseDuration("30")).toThrow("无效的时长格式");
expect(() => parseDuration("abc")).toThrow("无效的时长格式");
expect(() => parseDuration("30x")).toThrow("无效的时长格式");
expect(() => parseDuration("")).toThrow("无效的时长格式");
});
});
describe("readRuntimeConfig", () => {
test("返回配置文件路径", () => {
expect(readRuntimeConfig(["./probes.yaml"])).toEqual({ configPath: "./probes.yaml" });
});
test("未提供参数抛出错误", () => {
expect(() => readRuntimeConfig([])).toThrow("需要指定 YAML 配置文件路径");
});
});
describe("loadConfig", () => {
let tempDir: string;
beforeAll(async () => {
tempDir = join(tmpdir(), `gc-test-${Date.now()}`);
await mkdir(tempDir, { recursive: true });
});
afterAll(async () => {
await rm(tempDir, { recursive: true, force: true });
});
test("解析完整配置", async () => {
const configPath = join(tempDir, "full.yaml");
await writeFile(
configPath,
`server:
host: "0.0.0.0"
port: 8080
dataDir: "./my-data"
defaults:
interval: "15s"
timeout: "5s"
method: "POST"
targets:
- name: "test"
url: "http://example.com"
`,
);
const config = await loadConfig(configPath);
expect(config.host).toBe("0.0.0.0");
expect(config.port).toBe(8080);
expect(config.dataDir).toBe("./my-data");
expect(config.targets).toHaveLength(1);
expect(config.targets[0]).toEqual({
name: "test",
url: "http://example.com",
method: "POST",
headers: {},
body: undefined,
intervalMs: 15000,
timeoutMs: 5000,
expect: undefined,
});
});
test("解析最简配置(只有 targets", async () => {
const configPath = join(tempDir, "minimal.yaml");
await writeFile(
configPath,
`targets:
- name: "t1"
url: "http://a.com"
- name: "t2"
url: "http://b.com"
interval: "1m"
`,
);
const config = await loadConfig(configPath);
expect(config.host).toBe("127.0.0.1");
expect(config.port).toBe(3000);
expect(config.dataDir).toBe("./data");
expect(config.targets).toHaveLength(2);
expect(config.targets[0]!.intervalMs).toBe(30000);
expect(config.targets[1]!.intervalMs).toBe(60000);
});
test("per-target 覆盖 defaults", async () => {
const configPath = join(tempDir, "override.yaml");
await writeFile(
configPath,
`defaults:
interval: "30s"
timeout: "10s"
method: "GET"
headers:
Authorization: "Bearer token"
targets:
- name: "override-all"
url: "http://example.com"
method: "POST"
interval: "5m"
timeout: "30s"
headers:
X-Custom: "value"
`,
);
const config = await loadConfig(configPath);
const target = config.targets[0]!;
expect(target.method).toBe("POST");
expect(target.intervalMs).toBe(300000);
expect(target.timeoutMs).toBe(30000);
expect(target.headers).toEqual({ Authorization: "Bearer token", "X-Custom": "value" });
});
test("配置文件不存在抛出错误", async () => {
await expect(loadConfig("/nonexistent/file.yaml")).rejects.toThrow("配置文件不存在");
});
test("target 缺少 name 抛出错误", async () => {
const configPath = join(tempDir, "no-name.yaml");
await writeFile(
configPath,
`targets:
- url: "http://example.com"
`,
);
await expect(loadConfig(configPath)).rejects.toThrow("缺少 name 字段");
});
test("target 缺少 url 抛出错误", async () => {
const configPath = join(tempDir, "no-url.yaml");
await writeFile(
configPath,
`targets:
- name: "test"
`,
);
await expect(loadConfig(configPath)).rejects.toThrow("缺少 url 字段");
});
test("target name 重复抛出错误", async () => {
const configPath = join(tempDir, "dup-name.yaml");
await writeFile(
configPath,
`targets:
- name: "dup"
url: "http://a.com"
- name: "dup"
url: "http://b.com"
`,
);
await expect(loadConfig(configPath)).rejects.toThrow("target name 重复");
});
test("targets 为空数组抛出错误", async () => {
const configPath = join(tempDir, "empty-targets.yaml");
await writeFile(configPath, `targets: []`);
await expect(loadConfig(configPath)).rejects.toThrow("至少一个 target");
});
test("无效端口号抛出错误", async () => {
const configPath = join(tempDir, "bad-port.yaml");
await writeFile(
configPath,
`server:
port: 99999
targets:
- name: "t"
url: "http://a.com"
`,
);
await expect(loadConfig(configPath)).rejects.toThrow("无效端口号");
});
test("解析 expect 配置", async () => {
const configPath = join(tempDir, "expect.yaml");
await writeFile(
configPath,
`targets:
- name: "with-expect"
url: "http://example.com"
expect:
status: [200, 201]
bodyContains: "ok"
maxLatencyMs: 3000
`,
);
const config = await loadConfig(configPath);
expect(config.targets[0]!.expect).toEqual({
status: [200, 201],
bodyContains: "ok",
maxLatencyMs: 3000,
});
});
});

View File

@@ -0,0 +1,92 @@
import { afterAll, beforeAll, describe, expect, test } from "bun:test";
import { ProbeStore } from "../../../src/server/checker/store";
import { ProbeEngine } from "../../../src/server/checker/engine";
import type { ResolvedTarget } from "../../../src/server/checker/types";
import { mkdir, rm } from "node:fs/promises";
import { join } from "node:path";
import { tmpdir } from "node:os";
describe("ProbeEngine", () => {
let tempDir: string;
let store: ProbeStore;
const target: ResolvedTarget = {
name: "httpbin",
url: "https://httpbin.org/get",
method: "GET",
headers: {},
intervalMs: 60000,
timeoutMs: 10000,
};
beforeAll(async () => {
tempDir = join(tmpdir(), `gc-engine-test-${Date.now()}`);
await mkdir(tempDir, { recursive: true });
store = new ProbeStore(join(tempDir, "test.db"));
store.syncTargets([target]);
});
afterAll(async () => {
store.close();
await rm(tempDir, { recursive: true, force: true });
});
test("groupByInterval 分组逻辑", () => {
const targets: ResolvedTarget[] = [
{ name: "a", url: "http://a.com", method: "GET", headers: {}, intervalMs: 30000, timeoutMs: 10000 },
{ name: "b", url: "http://b.com", method: "GET", headers: {}, intervalMs: 30000, timeoutMs: 10000 },
{ name: "c", url: "http://c.com", method: "GET", headers: {}, intervalMs: 60000, timeoutMs: 10000 },
];
const engine = new ProbeEngine(store, targets);
engine.start();
engine.stop();
// 只要能启动和停止不出错就行
expect(true).toBe(true);
});
test("engine start/stop 不抛错", () => {
const engine = new ProbeEngine(store, [target]);
engine.start();
engine.stop();
expect(true).toBe(true);
});
test("单次拨测写入数据库", async () => {
const engine = new ProbeEngine(store, [target]);
// 手动调用 probeGroup 不启动 timer
const probeGroup = (engine as unknown as { probeGroup: (t: ResolvedTarget[]) => Promise<void> }).probeGroup.bind(engine);
await probeGroup([target]);
const dbTargets = store.getTargets();
const latest = store.getLatestCheck(dbTargets[0]!.id);
expect(latest).not.toBeNull();
expect(latest!.success === 1 || latest!.success === 0).toBe(true);
});
test("单目标失败隔离", async () => {
const badTarget: ResolvedTarget = {
name: "bad-target",
url: "http://127.0.0.1:1/impossible",
method: "GET",
headers: {},
intervalMs: 60000,
timeoutMs: 2000,
};
store.syncTargets([target, badTarget]);
const engine = new ProbeEngine(store, [target, badTarget]);
const probeGroup = (engine as unknown as { probeGroup: (t: ResolvedTarget[]) => Promise<void> }).probeGroup.bind(engine);
await probeGroup([target, badTarget]);
const dbTargets = store.getTargets();
const goodResult = store.getLatestCheck(dbTargets.find((t) => t.name === "httpbin")!.id);
const badResult = store.getLatestCheck(dbTargets.find((t) => t.name === "bad-target")!.id);
expect(goodResult).not.toBeNull();
expect(badResult).not.toBeNull();
expect(badResult!.success).toBe(0);
});
});

View File

@@ -0,0 +1,45 @@
import { describe, expect, test } from "bun:test";
import { checkExpect } from "../../../src/server/checker/fetcher";
describe("checkExpect", () => {
test("无 expect 配置时 matched 为 true", () => {
expect(checkExpect(200, "ok", 100, undefined)).toBe(true);
});
test("status 匹配", () => {
expect(checkExpect(200, "", 100, { status: [200, 201] })).toBe(true);
expect(checkExpect(201, "", 100, { status: [200, 201] })).toBe(true);
expect(checkExpect(404, "", 100, { status: [200, 201] })).toBe(false);
});
test("bodyContains 匹配", () => {
expect(checkExpect(200, "hello world", 100, { bodyContains: "hello" })).toBe(true);
expect(checkExpect(200, "hello world", 100, { bodyContains: "missing" })).toBe(false);
});
test("maxLatencyMs 匹配", () => {
expect(checkExpect(200, "", 100, { maxLatencyMs: 200 })).toBe(true);
expect(checkExpect(200, "", 300, { maxLatencyMs: 200 })).toBe(false);
expect(checkExpect(200, "", 200, { maxLatencyMs: 200 })).toBe(true);
});
test("多条 expect 全部通过", () => {
expect(
checkExpect(200, "healthy", 100, {
status: [200],
bodyContains: "healthy",
maxLatencyMs: 200,
}),
).toBe(true);
});
test("多条 expect 部分失败", () => {
expect(
checkExpect(200, "healthy", 500, {
status: [200],
bodyContains: "healthy",
maxLatencyMs: 200,
}),
).toBe(false);
});
});

View File

@@ -0,0 +1,195 @@
import { afterAll, beforeAll, describe, expect, test } from "bun:test";
import { ProbeStore } from "../../../src/server/checker/store";
import type { ResolvedTarget } from "../../../src/server/checker/types";
import { mkdir, rm } from "node:fs/promises";
import { join } from "node:path";
import { tmpdir } from "node:os";
describe("ProbeStore", () => {
let tempDir: string;
let store: ProbeStore;
const target1: ResolvedTarget = {
name: "test-a",
url: "http://a.com",
method: "GET",
headers: {},
intervalMs: 30000,
timeoutMs: 10000,
};
const target2: ResolvedTarget = {
name: "test-b",
url: "http://b.com",
method: "POST",
headers: { "Content-Type": "application/json" },
body: '{"ping": true}',
intervalMs: 60000,
timeoutMs: 5000,
expect: { status: [200], maxLatencyMs: 3000 },
};
beforeAll(async () => {
tempDir = join(tmpdir(), `gc-store-test-${Date.now()}`);
await mkdir(tempDir, { recursive: true });
store = new ProbeStore(join(tempDir, "test.db"));
});
afterAll(async () => {
store.close();
await rm(tempDir, { recursive: true, force: true });
});
test("初始化后无 targets", () => {
expect(store.getTargets()).toHaveLength(0);
});
test("同步新增 targets", () => {
store.syncTargets([target1, target2]);
const targets = store.getTargets();
expect(targets).toHaveLength(2);
expect(targets[0]!.name).toBe("test-a");
expect(targets[1]!.name).toBe("test-b");
});
test("同步后 target 字段正确", () => {
const targets = store.getTargets();
const t2 = targets.find((t) => t.name === "test-b")!;
expect(t2.url).toBe("http://b.com");
expect(t2.method).toBe("POST");
expect(JSON.parse(t2.headers)).toEqual({ "Content-Type": "application/json" });
expect(t2.body).toBe('{"ping": true}');
expect(t2.interval_ms).toBe(60000);
expect(t2.expect).toBe(JSON.stringify({ status: [200], maxLatencyMs: 3000 }));
});
test("同步更新已有 target", () => {
store.syncTargets([{ ...target1, url: "http://a-v2.com" }, target2]);
const targets = store.getTargets();
const t1 = targets.find((t) => t.name === "test-a")!;
expect(t1.url).toBe("http://a-v2.com");
expect(targets).toHaveLength(2);
});
test("同步删除 target", () => {
store.syncTargets([target1]);
const targets = store.getTargets();
expect(targets).toHaveLength(1);
expect(targets[0]!.name).toBe("test-a");
});
test("重新同步回来", () => {
store.syncTargets([target1, target2]);
expect(store.getTargets()).toHaveLength(2);
});
test("getTargetById", () => {
const targets = store.getTargets();
const found = store.getTargetById(targets[0]!.id);
expect(found).toBeDefined();
expect(found!.name).toBe("test-a");
});
test("getTargetById 不存在", () => {
expect(store.getTargetById(99999)).toBeNull();
});
test("写入和查询 check result", () => {
const targets = store.getTargets();
const t1Id = targets[0]!.id;
store.insertCheckResult({
targetId: t1Id,
timestamp: "2025-01-01T00:00:00.000Z",
success: true,
statusCode: 200,
latencyMs: 150,
error: null,
matched: true,
});
store.insertCheckResult({
targetId: t1Id,
timestamp: "2025-01-01T00:00:30.000Z",
success: true,
statusCode: 200,
latencyMs: 300,
error: null,
matched: true,
});
store.insertCheckResult({
targetId: t1Id,
timestamp: "2025-01-01T00:01:00.000Z",
success: false,
statusCode: null,
latencyMs: null,
error: "timeout",
matched: false,
});
const history = store.getHistory(t1Id, 10);
expect(history).toHaveLength(3);
expect(history[0]!.timestamp).toBe("2025-01-01T00:01:00.000Z");
const latest = store.getLatestCheck(t1Id)!;
expect(latest.success).toBe(0);
expect(latest.error).toBe("timeout");
});
test("getHistory 默认 limit=20", () => {
const targets = store.getTargets();
const t1Id = targets[0]!.id;
for (let i = 0; i < 25; i++) {
store.insertCheckResult({
targetId: t1Id,
timestamp: `2025-01-01T00:${String(i).padStart(2, "0")}:00.000Z`,
success: true,
statusCode: 200,
latencyMs: 100 + i,
error: null,
matched: true,
});
}
const history = store.getHistory(t1Id);
expect(history).toHaveLength(20);
});
test("getTargetStats 计算可用率和延迟", () => {
const targets = store.getTargets();
const t1Id = targets[0]!.id;
const stats = store.getTargetStats(t1Id);
expect(stats.totalChecks).toBeGreaterThan(0);
expect(stats.availability).toBeGreaterThanOrEqual(0);
expect(stats.availability).toBeLessThanOrEqual(100);
expect(stats.avgLatencyMs).not.toBeNull();
});
test("无记录目标的 stats", () => {
const targets = store.getTargets();
const t2Id = targets.find((t) => t.name === "test-b")!.id;
const stats = store.getTargetStats(t2Id);
expect(stats.totalChecks).toBe(0);
expect(stats.availability).toBe(0);
expect(stats.avgLatencyMs).toBeNull();
});
test("getSummary 返回总览统计", () => {
const summary = store.getSummary();
expect(summary.total).toBe(2);
expect(summary.up + summary.down).toBe(2);
expect(summary.lastCheckTime).not.toBeNull();
});
test("getTrend 返回趋势数据", () => {
const targets = store.getTargets();
const t1Id = targets[0]!.id;
const trend = store.getTrend(t1Id, 24);
expect(Array.isArray(trend)).toBe(true);
});
});

View File

@@ -2,37 +2,11 @@ import { describe, expect, test } from "bun:test";
import { readRuntimeConfig } from "../../src/server/config";
describe("runtime config", () => {
test("默认使用 127.0.0.1:3000", () => {
expect(readRuntimeConfig([], {})).toEqual({ host: "127.0.0.1", port: 3000 });
test("返回配置文件路径", () => {
expect(readRuntimeConfig(["./probes.yaml"])).toEqual({ configPath: "./probes.yaml" });
});
test("CLI 参数优先于环境变量", () => {
expect(readRuntimeConfig(["--host", "0.0.0.0", "--port", "4001"], { HOST: "127.0.0.1", PORT: "3001" })).toEqual({
host: "0.0.0.0",
port: 4001,
});
});
test("环境变量可以覆盖默认端口", () => {
expect(readRuntimeConfig([], { PORT: "4100" })).toEqual({ host: "127.0.0.1", port: 4100 });
});
test("支持 inline CLI 参数", () => {
expect(readRuntimeConfig(["--host=localhost", "--port=4002"], {})).toEqual({
host: "localhost",
port: 4002,
});
});
test("拒绝无效端口", () => {
expect(() => readRuntimeConfig(["--port", "invalid"], {})).toThrow("无效端口");
expect(() => readRuntimeConfig(["--port", "3000.5"], {})).toThrow("无效端口");
expect(() => readRuntimeConfig(["--port", "-1"], {})).toThrow("无效端口");
expect(() => readRuntimeConfig(["--port", "65536"], {})).toThrow("无效端口");
});
test("接受端口边界值", () => {
expect(readRuntimeConfig(["--port", "0"], {})).toEqual({ host: "127.0.0.1", port: 0 });
expect(readRuntimeConfig(["--port", "65535"], {})).toEqual({ host: "127.0.0.1", port: 65535 });
test("未提供参数抛出错误", () => {
expect(() => readRuntimeConfig([])).toThrow("需要指定 YAML 配置文件路径");
});
});