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:
162
README.md
162
README.md
@@ -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
|
||||
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。
|
||||
当前不做告警通知、数据自动清理、拨测目标动态增删、认证鉴权和分布式部署。
|
||||
|
||||
81
bun.lock
81
bun.lock
@@ -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=="],
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
## Purpose
|
||||
|
||||
定义 Vite + React + TypeScript 前端开发工作流、开发期 API 代理、共享契约和端到端 demo 的行为要求。
|
||||
定义 Vite + React + TypeScript 前端开发工作流、开发期 API 代理和共享契约的行为要求。
|
||||
|
||||
## Requirements
|
||||
|
||||
@@ -18,8 +18,8 @@
|
||||
### Requirement: 前端开发期 API 代理
|
||||
前端开发服务器 SHALL 在本地开发期间将 `/api/*` 请求代理到 Bun 后端服务。
|
||||
|
||||
#### Scenario: 前端开发期调用 API
|
||||
- **WHEN** 浏览器从 Vite 开发源请求 `/api/demo`
|
||||
#### Scenario: 前端开发期调用拨测 API
|
||||
- **WHEN** 浏览器从 Vite 开发源请求 `/api/summary`、`/api/targets` 等拨测 API
|
||||
- **THEN** Vite SHALL 将请求转发到 Bun 后端服务,且不需要浏览器 CORS 配置
|
||||
|
||||
#### Scenario: 开发期访问非 API 前端路由
|
||||
@@ -52,23 +52,12 @@
|
||||
- **WHEN** host 或 port 在开发环境和生产环境之间变化
|
||||
- **THEN** 前端 API 调用 SHALL 无需修改源码即可继续工作
|
||||
|
||||
### Requirement: 端到端开发 demo
|
||||
项目 SHALL 提供一个可见的开发 demo,用于证明 React 前端可以通过 Vite 代理调用 Bun 后端。
|
||||
|
||||
#### Scenario: Demo 页面展示后端响应
|
||||
- **WHEN** 开发者启动文档化的开发命令并打开前端 URL
|
||||
- **THEN** 页面 SHALL 调用 `/api/demo` 并展示 Bun 后端返回的数据
|
||||
|
||||
#### Scenario: 开发期后端不可用
|
||||
- **WHEN** 前端 demo 无法访问 `/api/demo`
|
||||
- **THEN** 页面 SHALL 展示清晰的错误状态,而不是静默显示为成功
|
||||
|
||||
### Requirement: 集成开发命令
|
||||
项目 SHALL 提供一个文档化命令,用于在 demo 开发期间同时运行前端和后端。
|
||||
项目 SHALL 提供一个文档化命令,用于在开发期间同时运行前端和后端。
|
||||
|
||||
#### Scenario: 启动全栈开发
|
||||
- **WHEN** 开发者运行文档化的全栈开发命令
|
||||
- **THEN** 系统 SHALL 启动 Vite 前端开发服务器和 `/api/demo` 所需的 Bun 后端服务器
|
||||
- **THEN** 系统 SHALL 启动 Vite 前端开发服务器和 Bun 后端服务器
|
||||
|
||||
### Requirement: 开发质量命令文档化
|
||||
项目 SHALL 在前端开发工作流文档中说明日常检查和完整验证命令。
|
||||
|
||||
@@ -9,45 +9,34 @@
|
||||
|
||||
#### Scenario: 启动运行时服务器
|
||||
- **WHEN** server 进程成功启动
|
||||
- **THEN** 它 SHALL 监听配置的 host 和 port,并记录实际 server URL
|
||||
- **THEN** 它 SHALL 监听 YAML 配置文件中指定的 host 和 port,并记录实际 server URL
|
||||
|
||||
#### Scenario: 提供运行时配置
|
||||
- **WHEN** 通过支持的运行时配置提供 host 或 port
|
||||
#### Scenario: 通过 YAML 配置提供运行时参数
|
||||
- **WHEN** 通过 YAML 配置文件提供 host、port、数据目录等参数
|
||||
- **THEN** server SHALL 使用该值,且不需要重新构建
|
||||
|
||||
#### Scenario: CLI 只接受配置文件路径
|
||||
- **WHEN** 用户通过命令行启动程序
|
||||
- **THEN** 系统 SHALL 只接受一个命令行参数作为 YAML 配置文件路径
|
||||
|
||||
#### Scenario: 提供拨测相关 API
|
||||
- **WHEN** server 启动完成
|
||||
- **THEN** 系统 SHALL 提供 `/api/summary`、`/api/targets`、`/api/targets/:id/history`、`/api/targets/:id/trend` 端点
|
||||
|
||||
### Requirement: HTTP method 语义
|
||||
系统 SHALL 为运行时端点提供明确的 HTTP method 语义,避免不支持的 method 被错误地当作成功请求处理。
|
||||
|
||||
#### Scenario: GET 请求访问运行时端点
|
||||
- **WHEN** 客户端使用 `GET` 请求 `/health` 或 `/api/demo`
|
||||
- **WHEN** 客户端使用 `GET` 请求 `/health` 或 `/api/*` 端点
|
||||
- **THEN** Bun server SHALL 返回对应端点的成功响应
|
||||
|
||||
#### Scenario: HEAD 请求访问运行时端点
|
||||
- **WHEN** 客户端使用 `HEAD` 请求 `/health` 或 `/api/demo`
|
||||
- **WHEN** 客户端使用 `HEAD` 请求 `/health` 或 `/api/*` 端点
|
||||
- **THEN** Bun server SHALL 返回与 `GET` 相同的成功状态和 headers,但 MUST NOT 返回响应体
|
||||
|
||||
#### Scenario: 不支持的 method 访问运行时端点
|
||||
- **WHEN** 客户端使用不支持的 method 请求 `/health` 或 `/api/demo`
|
||||
- **THEN** Bun server MUST 返回 JSON 405 响应,并带有描述允许 method 的 `Allow` header
|
||||
|
||||
### Requirement: 运行配置校验
|
||||
系统 SHALL 对运行时 host 和 port 配置提供稳定、可测试的解析与校验行为。
|
||||
|
||||
#### Scenario: 使用默认运行配置
|
||||
- **WHEN** 未提供 host 或 port 覆盖
|
||||
- **THEN** server SHALL 使用 README 文档化的默认 host 和 port
|
||||
|
||||
#### Scenario: CLI 参数优先于环境变量
|
||||
- **WHEN** CLI 参数和环境变量同时提供同一项运行配置
|
||||
- **THEN** server SHALL 使用 CLI 参数中的值
|
||||
|
||||
#### Scenario: 拒绝无效端口
|
||||
- **WHEN** port 配置不是整数、小于 0 或大于 65535
|
||||
- **THEN** server MUST 拒绝启动并报告无效端口
|
||||
|
||||
#### Scenario: 接受端口边界值
|
||||
- **WHEN** port 配置为 0 或 65535
|
||||
- **THEN** server SHALL 将其作为有效端口配置处理
|
||||
- **WHEN** 客户端使用不支持的 method 请求 `/health` 或 `/api/*` 端点
|
||||
- **THEN** Bun server SHALL 返回 405 状态码和 Allow header
|
||||
|
||||
### Requirement: API 路由命名空间
|
||||
系统 MUST 将 `/api/*` 保留给后端 API 路由。
|
||||
@@ -71,17 +60,6 @@
|
||||
- **WHEN** 客户端使用不支持的 method 请求已存在的 API 路由
|
||||
- **THEN** Bun server MUST 返回包含 `error` 和 `status` 字段的 JSON 405 响应
|
||||
|
||||
### Requirement: Demo API 端点
|
||||
系统 SHALL 暴露 `/api/demo` 作为稳定 demo 端点,用于证明前后端集成可用。
|
||||
|
||||
#### Scenario: Demo API 成功响应
|
||||
- **WHEN** 客户端请求 `/api/demo`
|
||||
- **THEN** Bun server SHALL 返回包含可读 message 和 runtime metadata 的 JSON 响应
|
||||
|
||||
#### Scenario: Demo API 内容类型
|
||||
- **WHEN** 客户端请求 `/api/demo`
|
||||
- **THEN** Bun server SHALL 返回 JSON content type 的响应
|
||||
|
||||
### Requirement: 健康检查端点
|
||||
系统 SHALL 在前端 SPA fallback 之外暴露健康检查端点。
|
||||
|
||||
@@ -100,10 +78,6 @@
|
||||
- **WHEN** 客户端请求 `/`
|
||||
- **THEN** Bun server SHALL 返回前端入口 HTML 文档
|
||||
|
||||
#### Scenario: 生产 demo 页面调用 API
|
||||
- **WHEN** 客户端从生产 Bun runtime 打开前端页面
|
||||
- **THEN** demo 页面 SHALL 能够从同源调用 `/api/demo` 并展示后端响应
|
||||
|
||||
### Requirement: 生产缓存策略
|
||||
系统 SHALL 为生产静态资源和前端入口 HTML 使用明确的缓存策略。
|
||||
|
||||
|
||||
63
openspec/specs/probe-api/spec.md
Normal file
63
openspec/specs/probe-api/spec.md
Normal 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 为 null,stats 中 totalChecks 为 0
|
||||
|
||||
### Requirement: 历史记录 API
|
||||
系统 SHALL 提供 `GET /api/targets/:id/history` 端点,返回指定目标的最近 N 条拨测记录。
|
||||
|
||||
#### Scenario: 获取最近历史记录
|
||||
- **WHEN** 客户端请求 `GET /api/targets/1/history?limit=20`
|
||||
- **THEN** 系统 SHALL 返回最多 20 条拨测记录,按时间倒序排列
|
||||
|
||||
#### Scenario: 使用默认 limit
|
||||
- **WHEN** 客户端请求 `GET /api/targets/1/history`(未指定 limit)
|
||||
- **THEN** 系统 SHALL 默认返回最近 20 条记录
|
||||
|
||||
### Requirement: 趋势聚合 API
|
||||
系统 SHALL 提供 `GET /api/targets/:id/trend` 端点,返回指定目标按小时聚合的趋势数据。
|
||||
|
||||
#### Scenario: 获取 24 小时趋势
|
||||
- **WHEN** 客户端请求 `GET /api/targets/1/trend?hours=24`
|
||||
- **THEN** 系统 SHALL 返回按小时分组的聚合数据,每个数据点包含 hour、avgLatencyMs、availability、totalChecks
|
||||
|
||||
#### Scenario: 使用默认时间范围
|
||||
- **WHEN** 客户端请求 `GET /api/targets/1/trend`(未指定 hours)
|
||||
- **THEN** 系统 SHALL 默认返回最近 24 小时的趋势数据
|
||||
|
||||
### Requirement: 保留健康检查端点
|
||||
系统 SHALL 保留 `GET /health` 端点,不受拨测功能影响。
|
||||
|
||||
#### Scenario: 访问健康检查
|
||||
- **WHEN** 客户端请求 `GET /health`
|
||||
- **THEN** 系统 SHALL 返回与之前格式一致的健康检查响应
|
||||
|
||||
### Requirement: API 错误处理
|
||||
系统 SHALL 对不存在的目标 ID 和无效参数返回适当的 HTTP 错误响应。
|
||||
|
||||
#### Scenario: 查询不存在的目标
|
||||
- **WHEN** 客户端请求 `GET /api/targets/999/history`
|
||||
- **THEN** 系统 SHALL 返回 404 状态码和错误信息
|
||||
|
||||
#### Scenario: 无效的 limit 参数
|
||||
- **WHEN** 客户端请求 `GET /api/targets/1/history?limit=abc`
|
||||
- **THEN** 系统 SHALL 返回 400 状态码和错误信息
|
||||
57
openspec/specs/probe-config/spec.md
Normal file
57
openspec/specs/probe-config/spec.md
Normal 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()` 将内容解析为配置对象
|
||||
73
openspec/specs/probe-dashboard/spec.md
Normal file
73
openspec/specs/probe-dashboard/spec.md
Normal 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 显示错误提示,并在下一次轮询周期自动重试
|
||||
60
openspec/specs/probe-data-store/spec.md
Normal file
60
openspec/specs/probe-data-store/spec.md
Normal 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 返回按小时分组的聚合数据,包括每小时的平均延迟和可用率
|
||||
87
openspec/specs/probe-engine/spec.md
Normal file
87
openspec/specs/probe-engine/spec.md
Normal 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 interval,target B 配置 30s interval
|
||||
- **THEN** 系统 SHALL 创建两个独立定时器,分别按各自频率调度
|
||||
|
||||
### Requirement: 组内并发拨测
|
||||
系统 SHALL 在每次调度 tick 时,使用 `Promise.all` 并发执行同组内所有目标的拨测。
|
||||
|
||||
#### Scenario: 同组目标并发执行
|
||||
- **WHEN** 调度器触发一次 tick,该组有 3 个目标
|
||||
- **THEN** 系统 SHALL 同时发起 3 个 HTTP 请求,而非顺序执行
|
||||
|
||||
#### Scenario: 单个目标失败不影响同组其他目标
|
||||
- **WHEN** 同组中某个目标的拨测请求超时或失败
|
||||
- **THEN** 其他目标的拨测 SHALL 正常完成并记录结果
|
||||
|
||||
### Requirement: HTTP 拨测执行
|
||||
系统 SHALL 对每个目标执行 HTTP 请求,支持 GET、POST、PUT、DELETE、PATCH、HEAD 方法,并携带配置的 headers 和 body。
|
||||
|
||||
#### Scenario: 执行 GET 请求
|
||||
- **WHEN** 目标配置 method 为 GET
|
||||
- **THEN** 系统 SHALL 发送 GET 请求到目标 URL
|
||||
|
||||
#### Scenario: 执行 POST 请求带 body
|
||||
- **WHEN** 目标配置 method 为 POST 且指定了 body 和 Content-Type header
|
||||
- **THEN** 系统 SHALL 发送带指定 body 的 POST 请求
|
||||
|
||||
#### Scenario: 携带自定义 headers
|
||||
- **WHEN** 目标配置了 headers(如 Authorization)
|
||||
- **THEN** 系统 SHALL 在请求中包含所有配置的 headers
|
||||
|
||||
### Requirement: 请求超时控制
|
||||
系统 SHALL 对每次拨测请求实施超时控制,超时时间使用目标配置的 timeout 值。
|
||||
|
||||
#### Scenario: 请求超时
|
||||
- **WHEN** 拨测请求在 timeout 时间内未收到响应
|
||||
- **THEN** 系统 SHALL 中止该请求,记录为失败并标注超时错误
|
||||
|
||||
#### Scenario: 请求在超时前完成
|
||||
- **WHEN** 拨测请求在 timeout 时间内收到响应
|
||||
- **THEN** 系统 SHALL 正常记录响应结果
|
||||
|
||||
### Requirement: expect 校验
|
||||
系统 SHALL 在拨测完成后根据目标的 expect 配置校验响应,校验结果记入 check result。
|
||||
|
||||
#### Scenario: 校验状态码
|
||||
- **WHEN** 目标配置了 `expect.status: [200, 201]`
|
||||
- **THEN** 系统 SHALL 检查响应状态码是否在列表中,将匹配结果记录到 matched 字段
|
||||
|
||||
#### Scenario: 校验响应体包含
|
||||
- **WHEN** 目标配置了 `expect.bodyContains: "healthy"`
|
||||
- **THEN** 系统 SHALL 检查响应体是否包含该文本,将匹配结果记录到 matched 字段
|
||||
|
||||
#### Scenario: 校验延迟阈值
|
||||
- **WHEN** 目标配置了 `expect.maxLatencyMs: 3000`
|
||||
- **THEN** 系统 SHALL 检查实际延迟是否超过阈值,将匹配结果记录到 matched 字段
|
||||
|
||||
#### Scenario: 无 expect 配置
|
||||
- **WHEN** 目标未配置任何 expect 规则
|
||||
- **THEN** 系统 SHALL 将 matched 字段设为 true
|
||||
|
||||
#### Scenario: 多条 expect 规则
|
||||
- **WHEN** 目标同时配置了 status、bodyContains 和 maxLatencyMs
|
||||
- **THEN** 系统 SHALL 所有规则全部通过时 matched 为 true,任一不通过则为 false
|
||||
|
||||
### Requirement: 拨测结果记录
|
||||
系统 SHALL 在每次拨测完成后,将结果写入 SQLite 数据存储,包含 target_id、timestamp、success、status_code、latency_ms、error、matched 字段。
|
||||
|
||||
#### Scenario: 成功拨测结果记录
|
||||
- **WHEN** 拨测请求成功完成(收到 HTTP 响应)
|
||||
- **THEN** 系统 SHALL 记录 success=true、status_code、latency_ms、matched
|
||||
|
||||
#### Scenario: 失败拨测结果记录
|
||||
- **WHEN** 拨测请求失败(网络错误、超时等)
|
||||
- **THEN** 系统 SHALL 记录 success=false、error 信息,status_code 和 latency_ms 为 null
|
||||
@@ -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
16
probes.example.yaml
Normal 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
|
||||
@@ -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(),
|
||||
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);
|
||||
});
|
||||
`,
|
||||
);
|
||||
|
||||
@@ -3,6 +3,8 @@ interface ChildProcessInfo {
|
||||
process: Bun.Subprocess;
|
||||
}
|
||||
|
||||
const configPath = process.argv[2];
|
||||
|
||||
const env = {
|
||||
...process.env,
|
||||
BACKEND_PORT: process.env.PORT ?? "3000",
|
||||
@@ -11,7 +13,7 @@ const env = {
|
||||
const children: ChildProcessInfo[] = [
|
||||
{
|
||||
name: "server",
|
||||
process: Bun.spawn(["bun", "run", "dev:server"], {
|
||||
process: Bun.spawn(["bun", "run", "dev:server", ...(configPath ? [configPath] : [])], {
|
||||
env,
|
||||
stdout: "inherit",
|
||||
stderr: "inherit",
|
||||
|
||||
@@ -1,21 +1,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}`);
|
||||
|
||||
@@ -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,17 +56,157 @@ 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 {
|
||||
@@ -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, {
|
||||
|
||||
104
src/server/checker/config-loader.ts
Normal file
104
src/server/checker/config-loader.ts
Normal 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;
|
||||
}
|
||||
87
src/server/checker/engine.ts
Normal file
87
src/server/checker/engine.ts
Normal 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[];
|
||||
}
|
||||
65
src/server/checker/fetcher.ts
Normal file
65
src/server/checker/fetcher.ts
Normal 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
277
src/server/checker/store.ts
Normal 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
|
||||
}
|
||||
}
|
||||
79
src/server/checker/types.ts
Normal file
79
src/server/checker/types.ts
Normal 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;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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(),
|
||||
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);
|
||||
});
|
||||
|
||||
@@ -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,
|
||||
}),
|
||||
});
|
||||
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
19
src/web/components/SparklineChart.tsx
Normal file
19
src/web/components/SparklineChart.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
7
src/web/components/StatusDot.tsx
Normal file
7
src/web/components/StatusDot.tsx
Normal file
@@ -0,0 +1,7 @@
|
||||
interface StatusDotProps {
|
||||
up: boolean;
|
||||
}
|
||||
|
||||
export function StatusDot({ up }: StatusDotProps) {
|
||||
return <span className={`status-dot ${up ? "status-up" : "status-down"}`} />;
|
||||
}
|
||||
36
src/web/components/SummaryCards.tsx
Normal file
36
src/web/components/SummaryCards.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
106
src/web/components/TargetDetail.tsx
Normal file
106
src/web/components/TargetDetail.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
34
src/web/components/TargetRow.tsx
Normal file
34
src/web/components/TargetRow.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
66
src/web/components/TargetTable.tsx
Normal file
66
src/web/components/TargetTable.tsx
Normal 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} />}
|
||||
</>
|
||||
);
|
||||
}
|
||||
46
src/web/components/TrendChart.tsx
Normal file
46
src/web/components/TrendChart.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
41
src/web/hooks/useSummary.ts
Normal file
41
src/web/hooks/useSummary.ts
Normal 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 };
|
||||
}
|
||||
41
src/web/hooks/useTargets.ts
Normal file
41
src/web/hooks/useTargets.ts
Normal 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
30
src/web/hooks/useTrend.ts
Normal 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 };
|
||||
}
|
||||
@@ -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>
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
|
||||
test("/health 返回机器可读健康检查", async () => {
|
||||
fetchHandler = createFetchHandler({ mode: "test", staticAssets, store });
|
||||
});
|
||||
|
||||
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");
|
||||
});
|
||||
|
||||
test("前端路由 fallback 到入口 HTML", async () => {
|
||||
const response = await fetchHandler(new Request("http://localhost/dashboard"));
|
||||
const body = await response.text();
|
||||
|
||||
expect(response.status).toBe(200);
|
||||
expect(body).toContain("Gateway Checker Demo");
|
||||
});
|
||||
|
||||
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");
|
||||
}
|
||||
});
|
||||
|
||||
test("静态资源和 SPA fallback 正常工作", async () => {
|
||||
const root = await fetchHandler(new Request("http://localhost/"));
|
||||
expect(root.status).toBe(200);
|
||||
|
||||
const fallback = await fetchHandler(new Request("http://localhost/dashboard"));
|
||||
expect(fallback.status).toBe(200);
|
||||
|
||||
const asset = await fetchHandler(new Request("http://localhost/assets/app.js"));
|
||||
expect(asset.status).toBe(200);
|
||||
});
|
||||
});
|
||||
|
||||
230
tests/server/checker/config-loader.test.ts
Normal file
230
tests/server/checker/config-loader.test.ts
Normal 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,
|
||||
});
|
||||
});
|
||||
});
|
||||
92
tests/server/checker/engine.test.ts
Normal file
92
tests/server/checker/engine.test.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
45
tests/server/checker/fetcher.test.ts
Normal file
45
tests/server/checker/fetcher.test.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
195
tests/server/checker/store.test.ts
Normal file
195
tests/server/checker/store.test.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
@@ -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 配置文件路径");
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user