diff --git a/README.md b/README.md index 1a2a0dc..8fab0db 100644 --- a/README.md +++ b/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 -scripts/ 开发、构建和 smoke test 脚本 -tests/ Bun test 测试 -openspec/ OpenSpec 变更与规格文档 + server/ + app.ts Bun HTTP 路由(API + 静态资源 + SPA fallback) + config.ts CLI 参数解析 + dev.ts 开发期启动入口 + server.ts HTTP server 启动 + checker/ + types.ts 类型定义 + config-loader.ts YAML 配置解析与校验 + store.ts SQLite 数据存储 + fetcher.ts HTTP 拨测执行 + engine.ts 调度引擎(按 interval 分组、组内并发) + shared/ + api.ts 前后端共享 TypeScript 类型 + web/ Vite + React 前端 Dashboard + components/ UI 组件 + hooks/ 数据轮询 hooks +scripts/ 开发、构建和 smoke test 脚本 +tests/ Bun test 测试 +openspec/ OpenSpec 变更与规格文档 ``` -## 开发命令 +## 快速开始 ```bash bun install -bun run dev +cp probes.example.yaml probes.yaml +bun run dev probes.yaml ``` -`bun run dev` 会同时启动: - -- Bun 后端:默认 `http://127.0.0.1:3000` -- Vite 前端:默认 `http://127.0.0.1:5173` - -开发期请打开 Vite 前端地址。前端通过相对路径 `/api/demo` 调用后端,Vite 会把 `/api/*` 代理到 Bun 后端,因此浏览器不需要 CORS 配置。 - -全栈开发命令使用 `PORT` 作为后端端口覆盖来源,并将同一端口传给 Vite proxy: - -```bash -PORT=4000 bun run dev -``` +`bun run dev` 会同时启动 Bun 后端和 Vite 前端。开发期请打开 Vite 前端地址 `http://127.0.0.1:5173`。 也可以分别运行: ```bash -bun run dev:server +bun run dev:server probes.yaml bun run dev:web ``` -分别运行时,若后端不是默认 `3000` 端口,需要为 Vite 指定同一个后端端口: +## 配置文件 -```bash -PORT=4000 bun run dev:server -BACKEND_PORT=4000 bun run dev:web +程序通过 YAML 配置文件定义所有运行参数: + +```yaml +server: + host: "127.0.0.1" + port: 3000 + dataDir: "./data" + +defaults: + interval: "30s" + timeout: "10s" + method: "GET" + +targets: + - name: "示例服务" + url: "https://httpbin.org/get" + interval: "60s" + + - name: "POST 检查" + url: "https://httpbin.org/post" + method: "POST" + headers: + Content-Type: "application/json" + body: '{"ping": true}' + expect: + status: [200] + maxLatencyMs: 5000 ``` +### 配置说明 + +- **server**: 服务配置(均可省略,使用默认值) + - `host`: 监听地址,默认 `127.0.0.1` + - `port`: 监听端口,默认 `3000` + - `dataDir`: 数据目录,默认 `./data` +- **defaults**: 全局默认值(均可省略) + - `interval`: 拨测间隔,默认 `30s` + - `timeout`: 请求超时,默认 `10s` + - `method`: HTTP 方法,默认 `GET` + - `headers`: 全局 headers +- **targets**: 拨测目标列表(必填) + - `name`: 目标名称(必填,唯一) + - `url`: 目标 URL(必填) + - `method`、`headers`、`body`: 请求参数 + - `interval`、`timeout`: 覆盖全局默认值 + - `expect`: 期望校验 + - `status`: 可接受的状态码列表 + - `bodyContains`: 响应体包含的文本 + - `maxLatencyMs`: 最大延迟阈值(毫秒) + +时长格式支持:`30s`、`5m`、`500ms` + +## API 端点 + +| 端点 | 说明 | +|------|------| +| `GET /health` | 健康检查 | +| `GET /api/summary` | 总览统计(total/up/down/avgLatencyMs/lastCheckTime) | +| `GET /api/targets` | 目标列表及最新状态和统计摘要 | +| `GET /api/targets/:id/history?limit=20` | 指定目标的最近 N 条拨测记录 | +| `GET /api/targets/:id/trend?hours=24` | 指定目标的按小时聚合趋势 | + ## 代码质量 ```bash @@ -57,23 +117,7 @@ bun run format bun run check ``` -- `lint` 使用 ESLint 检查 TypeScript、React Hooks 和前后端边界。 -- `format:check` 使用 Prettier 检查代码格式。 -- `format` 使用 Prettier 重写受管理文件格式。 -- `check` 依次运行 `typecheck`、`lint`、`format:check` 和单元测试,适合日常开发期间快速验证。 - -Prettier 不格式化 `openspec/`、`dist/`、`.build/`、`node_modules/`、`bun.lock` 和临时构建产物。 - -## Demo 验证 - -开发期打开 `http://127.0.0.1:5173`,页面应显示 `/api/demo` 返回的后端 message、Bun 版本、平台和响应时间。 - -直接验证 API: - -```bash -curl http://127.0.0.1:3000/api/demo -curl http://127.0.0.1:3000/health -``` +- `check` 依次运行 `typecheck`、`lint`、`format:check` 和单元测试。 ## 构建 executable @@ -83,31 +127,23 @@ bun run build 构建流程: -- 运行 `vite build`,输出前端资源到 `dist/web` -- 生成临时 `.build/static-assets.ts`,用 Bun file import 嵌入 Vite 产物 -- 生成临时 `.build/server-entry.ts`,作为生产 executable 的 server 入口 -- 运行 `Bun.build({ compile })`,输出 `dist/gateway-checker` +1. 运行 `vite build`,输出前端资源到 `dist/web` +2. 生成临时 `.build/static-assets.ts`,嵌入 Vite 产物 +3. 生成临时 `.build/server-entry.ts`,作为生产入口 +4. 运行 `Bun.build({ compile })`,输出 `dist/gateway-checker` 运行 executable: ```bash -./dist/gateway-checker +./dist/gateway-checker probes.yaml ``` -生产期默认访问 `http://127.0.0.1:3000`。同一个 executable 会服务 `/api/demo`、`/health`、`/assets/*` 和前端 SPA fallback。 - ## 运行参数 -默认配置: - -- `HOST=127.0.0.1` -- `PORT=3000` - -可以通过环境变量或 CLI 参数覆盖: +CLI 只接受一个参数:YAML 配置文件路径。 ```bash -PORT=4000 ./dist/gateway-checker -./dist/gateway-checker --host 0.0.0.0 --port 4000 +./dist/gateway-checker ./probes.yaml ``` ## 测试 @@ -118,15 +154,21 @@ bun run verify ``` - `check` 适合日常开发,包含类型检查、lint、格式检查和单元测试。 -- `verify` 适合提交前或发布前,先运行 `check`,再重新构建生产 executable 并运行 smoke test。 -- `test:smoke` 会启动生成的 executable,并检查 `/api/demo`、`/health`、前端根路径、静态资源、未知 API、未知静态资源、生产模式、缓存响应头、低风险安全响应头和 SPA fallback。 +- `verify` 先运行 `check`,再重新构建生产 executable 并运行 smoke test。 ## 前后端边界 -前端只通过 HTTP 调用后端,默认 API 路径为相对 `/api/*`。共享类型放在 `src/shared`,前端不得 import `src/server` 的运行时实现。 +前端只通过 HTTP 调用后端,API 路径为 `/api/*`。共享类型放在 `src/shared`,前端不得 import `src/server` 的运行时实现。 -这保证了当前可以单文件部署,也保留未来将前端拆到 CDN 或独立静态站点的路径。 +## 目标状态判定 + +两层判定模型: + +- **success**: 请求是否成功完成(收到 HTTP 响应) +- **matched**: 是否符合 expect 规则(无 expect 时默认为 true) +- **UP** = success AND matched +- **DOWN** = NOT success OR NOT matched ## 已知限制 -当前 demo 不包含数据库、认证、SSR、React Router 或 UI 组件库。单 executable 是按目标平台构建的产物,不是一个文件同时覆盖 macOS、Linux 和 Windows。 +当前不做告警通知、数据自动清理、拨测目标动态增删、认证鉴权和分布式部署。 diff --git a/bun.lock b/bun.lock index d469b13..bdf1b11 100644 --- a/bun.lock +++ b/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=="], diff --git a/openspec/specs/frontend-development-workflow/spec.md b/openspec/specs/frontend-development-workflow/spec.md index 4f94b4c..04f436a 100644 --- a/openspec/specs/frontend-development-workflow/spec.md +++ b/openspec/specs/frontend-development-workflow/spec.md @@ -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 在前端开发工作流文档中说明日常检查和完整验证命令。 diff --git a/openspec/specs/fullstack-app-runtime/spec.md b/openspec/specs/fullstack-app-runtime/spec.md index 010836d..192a9a6 100644 --- a/openspec/specs/fullstack-app-runtime/spec.md +++ b/openspec/specs/fullstack-app-runtime/spec.md @@ -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 使用明确的缓存策略。 diff --git a/openspec/specs/probe-api/spec.md b/openspec/specs/probe-api/spec.md new file mode 100644 index 0000000..56240e7 --- /dev/null +++ b/openspec/specs/probe-api/spec.md @@ -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 状态码和错误信息 diff --git a/openspec/specs/probe-config/spec.md b/openspec/specs/probe-config/spec.md new file mode 100644 index 0000000..cc6c3b5 --- /dev/null +++ b/openspec/specs/probe-config/spec.md @@ -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()` 将内容解析为配置对象 diff --git a/openspec/specs/probe-dashboard/spec.md b/openspec/specs/probe-dashboard/spec.md new file mode 100644 index 0000000..2e53459 --- /dev/null +++ b/openspec/specs/probe-dashboard/spec.md @@ -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 显示错误提示,并在下一次轮询周期自动重试 diff --git a/openspec/specs/probe-data-store/spec.md b/openspec/specs/probe-data-store/spec.md new file mode 100644 index 0000000..c77ba0c --- /dev/null +++ b/openspec/specs/probe-data-store/spec.md @@ -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 返回按小时分组的聚合数据,包括每小时的平均延迟和可用率 diff --git a/openspec/specs/probe-engine/spec.md b/openspec/specs/probe-engine/spec.md new file mode 100644 index 0000000..f3c5359 --- /dev/null +++ b/openspec/specs/probe-engine/spec.md @@ -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 diff --git a/package.json b/package.json index 96a0955..7d63314 100644 --- a/package.json +++ b/package.json @@ -35,6 +35,7 @@ }, "dependencies": { "react": "^19.2.6", - "react-dom": "^19.2.6" + "react-dom": "^19.2.6", + "recharts": "^3.8.1" } } diff --git a/probes.example.yaml b/probes.example.yaml new file mode 100644 index 0000000..0539a98 --- /dev/null +++ b/probes.example.yaml @@ -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 diff --git a/scripts/build.ts b/scripts/build.ts index 409a7ed..3e7e2c7 100644 --- a/scripts/build.ts +++ b/scripts/build.ts @@ -98,14 +98,34 @@ ${assetEntries.join("\n")} async function writeGeneratedEntry() { await writeFile( generatedEntryPath, - `import { readRuntimeConfig } from "../src/server/config"; + `import { loadConfig } from "../src/server/checker/config-loader"; +import { ProbeStore } from "../src/server/checker/store"; +import { ProbeEngine } from "../src/server/checker/engine"; import { startServer } from "../src/server/server"; +import { readRuntimeConfig } from "../src/server/config"; import { staticAssets } from "./static-assets"; -startServer({ - config: readRuntimeConfig(), - mode: "production", - staticAssets, +async function main() { + const { configPath } = readRuntimeConfig(); + const config = await loadConfig(configPath); + + const store = new ProbeStore(config.dataDir + "/probe.db"); + store.syncTargets(config.targets); + + const engine = new ProbeEngine(store, config.targets); + engine.start(); + + startServer({ + config: { host: config.host, port: config.port }, + mode: "production", + staticAssets, + store, + }); +} + +void main().catch((error) => { + console.error("启动失败:", error instanceof Error ? error.message : error); + process.exit(1); }); `, ); diff --git a/scripts/dev.ts b/scripts/dev.ts index f222c1c..23e751d 100644 --- a/scripts/dev.ts +++ b/scripts/dev.ts @@ -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", diff --git a/scripts/smoke.ts b/scripts/smoke.ts index b87bac2..5f5e18d 100644 --- a/scripts/smoke.ts +++ b/scripts/smoke.ts @@ -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(`${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(`${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(url: string, status: number): Promise<{ body: T; response: Response }> { +async function expectJson(url: string, status: number): Promise<{ body: T; response: Response }> { const response = await fetch(url); assert(response.status === status, `${url} 应返回 ${status},实际为 ${response.status}`); diff --git a/src/server/app.ts b/src/server/app.ts index c54cd9e..8410613 100644 --- a/src/server/app.ts +++ b/src/server/app.ts @@ -1,4 +1,14 @@ -import type { ApiErrorResponse, DemoResponse, HealthResponse, RuntimeMode } from "../shared/api"; +import type { + ApiErrorResponse, + CheckResult, + HealthResponse, + RuntimeMode, + SummaryResponse, + TargetStatus, + TrendPoint, +} from "../shared/api"; +import type { StoredCheckResult } from "./checker/types"; +import type { ProbeStore } from "./checker/store"; export interface StaticAssets { indexHtml: Blob; @@ -8,6 +18,7 @@ export interface StaticAssets { export interface AppOptions { mode: RuntimeMode; staticAssets?: StaticAssets; + store?: ProbeStore; } export function createFetchHandler(options: AppOptions) { @@ -22,19 +33,15 @@ export function createFetchHandler(options: AppOptions) { return jsonResponse(createHealthResponse(), { method: request.method, mode: options.mode }); } - if (url.pathname === "/api/demo") { - if (!allowsGetHead(request.method)) { - return methodNotAllowedResponse(["GET", "HEAD"], options.mode); - } - - return jsonResponse(createDemoResponse(options.mode), { method: request.method, mode: options.mode }); + if (url.pathname.startsWith("/api/") && options.store) { + return handleApiRoute(url, request, options.store, options.mode); } if (url.pathname.startsWith("/api/")) { - return jsonResponse(createApiError("API route not found", 404), { + return jsonResponse(createApiError("Service not ready", 503), { method: request.method, mode: options.mode, - status: 404, + status: 503, }); } @@ -49,19 +56,159 @@ export function createFetchHandler(options: AppOptions) { }; } -function createDemoResponse(mode: RuntimeMode): DemoResponse { +function handleApiRoute(url: URL, request: Request, store: ProbeStore, mode: RuntimeMode): Response { + const { method } = request; + + if (!allowsGetHead(method)) { + return methodNotAllowedResponse(["GET", "HEAD"], mode); + } + + if (url.pathname === "/api/summary") { + return jsonResponse(createSummaryResponse(store), { method, mode }); + } + + if (url.pathname === "/api/targets") { + return jsonResponse(createTargetsResponse(store), { method, mode }); + } + + const historyMatch = url.pathname.match(/^\/api\/targets\/([^/]+)\/history$/); + if (historyMatch) { + return handleHistory(historyMatch[1]!, url, method, store, mode); + } + + const trendMatch = url.pathname.match(/^\/api\/targets\/([^/]+)\/trend$/); + if (trendMatch) { + return handleTrend(trendMatch[1]!, url, method, store, mode); + } + + return jsonResponse(createApiError("API route not found", 404), { method, mode, status: 404 }); +} + +function handleHistory( + idStr: string, + url: URL, + method: string, + store: ProbeStore, + mode: RuntimeMode, +): Response { + const id = Number(idStr); + + if (!Number.isInteger(id) || id <= 0) { + return jsonResponse(createApiError("Invalid target ID", 400), { method, mode, status: 400 }); + } + + const target = store.getTargetById(id); + if (!target) { + return jsonResponse(createApiError("Target not found", 404), { method, mode, status: 404 }); + } + + const limitParam = url.searchParams.get("limit"); + let limit = 20; + + if (limitParam !== null) { + limit = Number(limitParam); + if (!Number.isInteger(limit) || limit <= 0) { + return jsonResponse(createApiError("Invalid limit parameter", 400), { method, mode, status: 400 }); + } + } + + const rows = store.getHistory(id, limit); + const results: CheckResult[] = rows.map(mapCheckResult); + + return jsonResponse(results, { method, mode }); +} + +function handleTrend( + idStr: string, + url: URL, + method: string, + store: ProbeStore, + mode: RuntimeMode, +): Response { + const id = Number(idStr); + + if (!Number.isInteger(id) || id <= 0) { + return jsonResponse(createApiError("Invalid target ID", 400), { method, mode, status: 400 }); + } + + const target = store.getTargetById(id); + if (!target) { + return jsonResponse(createApiError("Target not found", 404), { method, mode, status: 404 }); + } + + const hoursParam = url.searchParams.get("hours"); + let hours = 24; + + if (hoursParam !== null) { + hours = Number(hoursParam); + if (!Number.isInteger(hours) || hours <= 0) { + return jsonResponse(createApiError("Invalid hours parameter", 400), { method, mode, status: 400 }); + } + } + + const trend: TrendPoint[] = store.getTrend(id, hours).map((row) => ({ + hour: row.hour, + avgLatencyMs: row.avgLatencyMs, + availability: Math.round(row.availability * 100) / 100, + totalChecks: row.totalChecks, + })); + + return jsonResponse(trend, { method, mode }); +} + +function createSummaryResponse(store: ProbeStore): SummaryResponse { + const summary = store.getSummary(); return { - message: "Bun 后端已通过 /api/demo 连接到 React 前端。", - runtime: { - mode, - bunVersion: Bun.version, - platform: process.platform, - arch: process.arch, - timestamp: new Date().toISOString(), - }, + total: summary.total, + up: summary.up, + down: summary.down, + avgLatencyMs: summary.avgLatencyMs, + lastCheckTime: summary.lastCheckTime, }; } +function createTargetsResponse(store: ProbeStore): TargetStatus[] { + const targets = store.getTargets(); + + return targets.map((target) => { + const latest = store.getLatestCheck(target.id); + const stats = store.getTargetStats(target.id); + + return { + id: target.id, + name: target.name, + url: target.url, + method: target.method, + interval: formatDuration(target.interval_ms), + latestCheck: latest ? mapCheckResult(latest) : null, + sparkline: store.getSparkline(target.id), + stats: { + totalChecks: stats.totalChecks, + availability: stats.availability, + avgLatencyMs: stats.avgLatencyMs, + p99LatencyMs: stats.p99LatencyMs, + }, + }; + }); +} + +function mapCheckResult(row: StoredCheckResult): CheckResult { + return { + timestamp: row.timestamp, + success: row.success === 1, + statusCode: row.status_code, + latencyMs: row.latency_ms, + error: row.error, + matched: row.matched === 1, + }; +} + +function formatDuration(ms: number): string { + if (ms >= 60000 && ms % 60000 === 0) return `${ms / 60000}m`; + if (ms >= 1000 && ms % 1000 === 0) return `${ms / 1000}s`; + return `${ms}ms`; +} + function createHealthResponse(): HealthResponse { return { ok: true, @@ -87,7 +234,7 @@ function methodNotAllowedResponse(allow: string[], mode: RuntimeMode): Response } function jsonResponse( - body: ApiErrorResponse | DemoResponse | HealthResponse, + body: unknown, options: { method?: string; mode: RuntimeMode; status?: number; headers?: HeadersInit }, ): Response { const headers = createHeaders(options.mode, { diff --git a/src/server/checker/config-loader.ts b/src/server/checker/config-loader.ts new file mode 100644 index 0000000..f0938f8 --- /dev/null +++ b/src/server/checker/config-loader.ts @@ -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 { + 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(); + + 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; +} diff --git a/src/server/checker/engine.ts b/src/server/checker/engine.ts new file mode 100644 index 0000000..75efd30 --- /dev/null +++ b/src/server/checker/engine.ts @@ -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[] = []; + private store: ProbeStore; + private targetNameToId: Map = 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 { + const groups = new Map(); + + 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 { + 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 { + 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[]; +} diff --git a/src/server/checker/fetcher.ts b/src/server/checker/fetcher.ts new file mode 100644 index 0000000..d7041b3 --- /dev/null +++ b/src/server/checker/fetcher.ts @@ -0,0 +1,65 @@ +import type { CheckResult, ExpectConfig, ResolvedTarget } from "./types"; + +export async function fetchTarget(target: ResolvedTarget): Promise { + 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; +} diff --git a/src/server/checker/store.ts b/src/server/checker/store.ts new file mode 100644 index 0000000..131e9b4 --- /dev/null +++ b/src/server/checker/store.ts @@ -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 + } +} diff --git a/src/server/checker/types.ts b/src/server/checker/types.ts new file mode 100644 index 0000000..8bb67e7 --- /dev/null +++ b/src/server/checker/types.ts @@ -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; +} + +export interface TargetConfig { + name: string; + url: string; + method?: string; + headers?: Record; + 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; + 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; +} diff --git a/src/server/config.ts b/src/server/config.ts index f31e70c..124d8e3 100644 --- a/src/server/config.ts +++ b/src/server/config.ts @@ -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 "); + } + + 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 = 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; -} diff --git a/src/server/dev.ts b/src/server/dev.ts index 29dbe1a..11bef7a 100644 --- a/src/server/dev.ts +++ b/src/server/dev.ts @@ -1,7 +1,27 @@ -import { readRuntimeConfig } from "./config"; +import { loadConfig } from "./checker/config-loader"; +import { ProbeStore } from "./checker/store"; +import { ProbeEngine } from "./checker/engine"; import { startServer } from "./server"; +import { readRuntimeConfig } from "./config"; -startServer({ - config: readRuntimeConfig(), - mode: "development", +async function main() { + const { configPath } = readRuntimeConfig(); + const config = await loadConfig(configPath); + + const store = new ProbeStore(`${config.dataDir}/probe.db`); + store.syncTargets(config.targets); + + const engine = new ProbeEngine(store, config.targets); + engine.start(); + + startServer({ + config: { host: config.host, port: config.port }, + mode: "development", + store, + }); +} + +void main().catch((error) => { + console.error("启动失败:", error instanceof Error ? error.message : error); + process.exit(1); }); diff --git a/src/server/server.ts b/src/server/server.ts index fa3a9ac..7ef1506 100644 --- a/src/server/server.ts +++ b/src/server/server.ts @@ -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, }), }); diff --git a/src/shared/api.ts b/src/shared/api.ts index 1daeb39..89523aa 100644 --- a/src/shared/api.ts +++ b/src/shared/api.ts @@ -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; +} diff --git a/src/web/app.tsx b/src/web/app.tsx index 2f14f66..83f0e4d 100644 --- a/src/web/app.tsx +++ b/src/web/app.tsx @@ -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({ 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 ( -
-
-

Vite + React + Bun

-

Gateway Checker Demo

-

这个页面用于验证前端开发、Bun 后端 API 和单可执行文件打包链路已经跑通。

-
+
+
+

Gateway Checker

+

HTTP 拨测监控面板

+
-
-
- -

后端连接状态

+ {error && ( +
+ 请求失败: {error},将在下一次轮询周期自动重试
+ )} - {demoState.status === "loading" ?

正在请求 /api/demo...

: null} - - {demoState.status === "error" ? ( -
- 请求失败 -

{demoState.message}

-
- ) : null} - - {demoState.status === "success" ? ( -
-

{demoState.data.message}

-
-
-
运行模式
-
{demoState.data.runtime.mode}
-
-
-
Bun 版本
-
{demoState.data.runtime.bunVersion}
-
-
-
平台
-
- {demoState.data.runtime.platform}/{demoState.data.runtime.arch} -
-
-
-
响应时间
-
{demoState.data.runtime.timestamp}
-
-
-
- ) : null} -
+ +
); } diff --git a/src/web/components/SparklineChart.tsx b/src/web/components/SparklineChart.tsx new file mode 100644 index 0000000..462cbbe --- /dev/null +++ b/src/web/components/SparklineChart.tsx @@ -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 -; + } + + return ( + + + + + + ); +} diff --git a/src/web/components/StatusDot.tsx b/src/web/components/StatusDot.tsx new file mode 100644 index 0000000..85e543c --- /dev/null +++ b/src/web/components/StatusDot.tsx @@ -0,0 +1,7 @@ +interface StatusDotProps { + up: boolean; +} + +export function StatusDot({ up }: StatusDotProps) { + return ; +} diff --git a/src/web/components/SummaryCards.tsx b/src/web/components/SummaryCards.tsx new file mode 100644 index 0000000..ca3c9fb --- /dev/null +++ b/src/web/components/SummaryCards.tsx @@ -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
加载中...
; + } + + 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 ( +
+ {cards.map((card) => ( +
+
{card.value}
+
{card.label}
+
+ ))} +
+ ); +} diff --git a/src/web/components/TargetDetail.tsx b/src/web/components/TargetDetail.tsx new file mode 100644 index 0000000..3aabb1b --- /dev/null +++ b/src/web/components/TargetDetail.tsx @@ -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([]); + 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 ( + + +
+
+
+ 状态 + + {isUp ? "UP" : "DOWN"} + +
+
+ 可用率 + + {stats.totalChecks > 0 ? `${stats.availability.toFixed(1)}%` : "-"} + +
+
+ 平均延迟 + + {stats.avgLatencyMs !== null ? `${Math.round(stats.avgLatencyMs)}ms` : "-"} + +
+
+ P99 延迟 + + {stats.p99LatencyMs !== null ? `${Math.round(stats.p99LatencyMs)}ms` : "-"} + +
+
+ +
+

24 小时趋势

+ +
+ +
+

最近检查记录

+ {historyLoading ? ( +

加载中...

+ ) : history.length > 0 ? ( +
+ {history.map((item, idx) => ( +
+ + {item.success && item.matched ? "UP" : "DOWN"} + + + {new Date(item.timestamp).toLocaleString("zh-CN")} + + {item.statusCode && ( + {item.statusCode} + )} + {item.latencyMs !== null && ( + {Math.round(item.latencyMs)}ms + )} + {item.error && ( + {item.error} + )} +
+ ))} +
+ ) : ( +

暂无检查记录

+ )} +
+
+ + + ); +} diff --git a/src/web/components/TargetRow.tsx b/src/web/components/TargetRow.tsx new file mode 100644 index 0000000..e9f03f6 --- /dev/null +++ b/src/web/components/TargetRow.tsx @@ -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 ( + + + + + {target.name} + {target.url} + {target.method} + + {target.latestCheck?.latencyMs !== null && target.latestCheck?.latencyMs !== undefined + ? `${Math.round(target.latestCheck.latencyMs)}ms` + : "-"} + + + + + + ); +} diff --git a/src/web/components/TargetTable.tsx b/src/web/components/TargetTable.tsx new file mode 100644 index 0000000..e529c23 --- /dev/null +++ b/src/web/components/TargetTable.tsx @@ -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(null); + + if (loading && targets.length === 0) { + return
加载目标列表...
; + } + + if (targets.length === 0) { + return
暂无拨测目标
; + } + + return ( + + + + + + + + + + + + + {targets.map((target) => { + const isExpanded = expandedId === target.id; + return ( + setExpandedId(isExpanded ? null : target.id)} + /> + ); + })} + +
状态名称URL方法延迟趋势
+ ); +} + +function TargetRowWrapper({ + target, + expanded, + onToggle, +}: { + target: TargetStatus; + expanded: boolean; + onToggle: () => void; +}) { + return ( + <> + + {expanded && } + + ); +} diff --git a/src/web/components/TrendChart.tsx b/src/web/components/TrendChart.tsx new file mode 100644 index 0000000..2fae47b --- /dev/null +++ b/src/web/components/TrendChart.tsx @@ -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
加载趋势数据...
; + } + + if (data.length === 0) { + return
暂无趋势数据
; + } + + const chartData = data.map((point) => ({ + ...point, + hour: point.hour.slice(11, 16), + })); + + return ( +
+ + + + + + + { + 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]; + }} + /> + + + + +
+ ); +} diff --git a/src/web/hooks/useSummary.ts b/src/web/hooks/useSummary.ts new file mode 100644 index 0000000..675b496 --- /dev/null +++ b/src/web/hooks/useSummary.ts @@ -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(null); + const [error, setError] = useState(null); + const [loading, setLoading] = useState(true); + const abortRef = useRef(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 }; +} diff --git a/src/web/hooks/useTargets.ts b/src/web/hooks/useTargets.ts new file mode 100644 index 0000000..a5485c0 --- /dev/null +++ b/src/web/hooks/useTargets.ts @@ -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([]); + const [error, setError] = useState(null); + const [loading, setLoading] = useState(true); + const abortRef = useRef(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 }; +} diff --git a/src/web/hooks/useTrend.ts b/src/web/hooks/useTrend.ts new file mode 100644 index 0000000..7e144bb --- /dev/null +++ b/src/web/hooks/useTrend.ts @@ -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([]); + const [error, setError] = useState(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 }; +} diff --git a/src/web/index.html b/src/web/index.html index a2d1c56..8349490 100644 --- a/src/web/index.html +++ b/src/web/index.html @@ -3,8 +3,8 @@ - - Gateway Checker Demo + + Gateway Checker
diff --git a/src/web/styles.css b/src/web/styles.css index 8da3106..0129c34 100644 --- a/src/web/styles.css +++ b/src/web/styles.css @@ -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; } } diff --git a/tests/server/app.test.ts b/tests/server/app.test.ts index ec653f2..7608eb6 100644 --- a/tests/server/app.test.ts +++ b/tests/server/app.test.ts @@ -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(['Gateway Checker Demo
'], { + indexHtml: new Blob(['Gateway Checker
'], { 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; - test("/api/demo 返回 JSON demo 响应", async () => { - const response = await fetchHandler(new Request("http://localhost/api/demo")); - const body = await response.json(); + beforeAll(async () => { + tempDir = join(tmpdir(), `gc-api-test-${Date.now()}`); + await mkdir(tempDir, { recursive: true }); + store = new ProbeStore(join(tempDir, "test.db")); + store.syncTargets([ + { + name: "test-a", + url: "http://a.com", + method: "GET", + headers: {}, + intervalMs: 30000, + timeoutMs: 10000, + }, + { + name: "test-b", + url: "http://b.com", + method: "POST", + headers: {}, + intervalMs: 60000, + timeoutMs: 5000, + }, + ]); - expect(response.status).toBe(200); - expect(response.headers.get("content-type")).toContain("application/json"); - expect(body.message).toContain("/api/demo"); - expect(body.runtime.mode).toBe("test"); + const targets = store.getTargets(); + store.insertCheckResult({ + targetId: targets[0]!.id, + timestamp: "2025-01-01T00:00:00.000Z", + success: true, + statusCode: 200, + latencyMs: 150, + error: null, + matched: true, + }); + store.insertCheckResult({ + targetId: targets[0]!.id, + timestamp: "2025-01-01T00:00:30.000Z", + success: false, + statusCode: null, + latencyMs: null, + error: "timeout", + matched: false, + }); + + fetchHandler = createFetchHandler({ mode: "test", staticAssets, store }); }); - test("/health 返回机器可读健康检查", async () => { + afterAll(async () => { + store.close(); + await rm(tempDir, { recursive: true, force: true }); + }); + + test("/health 返回健康检查", async () => { const response = await fetchHandler(new Request("http://localhost/health")); - const body = await response.json(); + const body = (await response.json()) as HealthResponse; expect(response.status).toBe(200); expect(body.ok).toBe(true); expect(body.service).toBe("gateway-checker"); }); - test("HEAD 请求运行时端点返回 headers 但无 body", async () => { - const response = await fetchHandler(new Request("http://localhost/api/demo", { method: "HEAD" })); - const body = await response.text(); + test("/api/summary 返回总览统计", async () => { + const response = await fetchHandler(new Request("http://localhost/api/summary")); + const body = (await response.json()) as SummaryResponse; + expect(response.status).toBe(200); + expect(body.total).toBe(2); + expect(body.up).toBeGreaterThanOrEqual(0); + expect(body.down).toBeGreaterThanOrEqual(0); + expect(body.up + body.down).toBe(2); + }); + + test("/api/targets 返回目标列表", async () => { + const response = await fetchHandler(new Request("http://localhost/api/targets")); + const body = (await response.json()) as TargetStatus[]; expect(response.status).toBe(200); - expect(response.headers.get("content-type")).toContain("application/json"); - expect(body).toBe(""); + expect(body).toHaveLength(2); + expect(body[0]!.name).toBe("test-a"); + expect(body[0]!.latestCheck).not.toBeNull(); + expect(body[0]!.latestCheck!.success).toBe(false); + expect(body[0]!.sparkline).toBeDefined(); + expect(Array.isArray(body[0]!.sparkline)).toBe(true); + expect(body[1]!.latestCheck).toBeNull(); }); - test("HEAD 请求健康检查端点返回 headers 但无 body", async () => { - const response = await fetchHandler(new Request("http://localhost/health", { method: "HEAD" })); - const body = await response.text(); + test("/api/targets/:id/history 返回历史记录", async () => { + const targets = store.getTargets(); + const response = await fetchHandler(new Request(`http://localhost/api/targets/${targets[0]!.id}/history`)); + const body = await response.json(); expect(response.status).toBe(200); - expect(response.headers.get("content-type")).toContain("application/json"); - expect(body).toBe(""); + expect(body).toHaveLength(2); }); - test("运行时端点拒绝不支持的 method", async () => { - const response = await fetchHandler(new Request("http://localhost/api/demo", { method: "POST" })); + test("/api/targets/:id/history 支持 limit 参数", async () => { + const targets = store.getTargets(); + const response = await fetchHandler(new Request(`http://localhost/api/targets/${targets[0]!.id}/history?limit=1`)); const body = await response.json(); - expect(response.status).toBe(405); - expect(response.headers.get("allow")).toBe("GET, HEAD"); - expect(response.headers.get("content-type")).toContain("application/json"); - expect(body.status).toBe(405); - expect(body.error).toBe("Method not allowed"); + expect(response.status).toBe(200); + expect(body).toHaveLength(1); }); - test("健康检查端点拒绝不支持的 method", async () => { - const response = await fetchHandler(new Request("http://localhost/health", { method: "POST" })); + test("/api/targets/:id/trend 返回趋势数据", async () => { + const targets = store.getTargets(); + const response = await fetchHandler(new Request(`http://localhost/api/targets/${targets[0]!.id}/trend`)); const body = await response.json(); - expect(response.status).toBe(405); - expect(response.headers.get("allow")).toBe("GET, HEAD"); - expect(response.headers.get("content-type")).toContain("application/json"); - expect(body.status).toBe(405); - expect(body.error).toBe("Method not allowed"); + expect(response.status).toBe(200); + expect(Array.isArray(body)).toBe(true); }); - test("未知 /api/* 路由返回 JSON 404", async () => { + test("查询不存在的目标返回 404", async () => { + const response = await fetchHandler(new Request("http://localhost/api/targets/99999/history")); + const body = await response.json(); + + expect(response.status).toBe(404); + expect(body.error).toBe("Target not found"); + }); + + test("无效 limit 参数返回 400", async () => { + const targets = store.getTargets(); + const response = await fetchHandler(new Request(`http://localhost/api/targets/${targets[0]!.id}/history?limit=abc`)); + const body = await response.json(); + + expect(response.status).toBe(400); + expect(body.error).toBe("Invalid limit parameter"); + }); + + test("无效目标 ID 返回 400", async () => { + const response = await fetchHandler(new Request("http://localhost/api/targets/abc/history")); + const body = await response.json(); + + expect(response.status).toBe(400); + expect(body.error).toBe("Invalid target ID"); + }); + + test("未知 /api/* 返回 404", async () => { const response = await fetchHandler(new Request("http://localhost/api/missing")); - const body = await response.json(); expect(response.status).toBe(404); - expect(response.headers.get("content-type")).toContain("application/json"); - expect(body.error).toBe("API route not found"); - expect(body.status).toBe(404); }); - test("生产根路径返回前端入口", async () => { - const response = await fetchHandler(new Request("http://localhost/")); + test("HEAD 请求返回 headers 无 body", async () => { + const response = await fetchHandler(new Request("http://localhost/api/summary", { method: "HEAD" })); const body = await response.text(); expect(response.status).toBe(200); - expect(response.headers.get("content-type")).toContain("text/html"); - expect(response.headers.get("cache-control")).toBe("no-cache"); - expect(body).toContain("Gateway Checker Demo"); + expect(body).toBe(""); }); - test("生产静态资源返回正确内容类型", async () => { - const response = await fetchHandler(new Request("http://localhost/assets/app.js")); - const body = await response.text(); + test("不支持的 method 返回 405", async () => { + const response = await fetchHandler(new Request("http://localhost/api/summary", { method: "POST" })); - expect(response.status).toBe(200); - expect(response.headers.get("content-type")).toContain("text/javascript"); - expect(response.headers.get("cache-control")).toBe("public, max-age=31536000, immutable"); - expect(body).toContain("demo"); + expect(response.status).toBe(405); + expect(response.headers.get("allow")).toBe("GET, HEAD"); }); - test("未知静态资源返回 404 且不 fallback 到入口 HTML", async () => { - const response = await fetchHandler(new Request("http://localhost/assets/missing.js")); - const body = await response.text(); + test("生产响应包含安全 headers", async () => { + const prodHandler = createFetchHandler({ mode: "production", staticAssets, store }); + const response = await prodHandler(new Request("http://localhost/api/summary")); - expect(response.status).toBe(404); - expect(body).not.toContain("Gateway Checker Demo"); + expect(response.headers.get("x-content-type-options")).toBe("nosniff"); + expect(response.headers.get("referrer-policy")).toBe("strict-origin-when-cross-origin"); }); - test("前端路由 fallback 到入口 HTML", async () => { - const response = await fetchHandler(new Request("http://localhost/dashboard")); - const body = await response.text(); + test("静态资源和 SPA fallback 正常工作", async () => { + const root = await fetchHandler(new Request("http://localhost/")); + expect(root.status).toBe(200); - expect(response.status).toBe(200); - expect(body).toContain("Gateway Checker Demo"); - }); + const fallback = await fetchHandler(new Request("http://localhost/dashboard")); + expect(fallback.status).toBe(200); - test("生产响应包含低风险安全 headers", async () => { - const json = await productionFetchHandler(new Request("http://localhost/api/demo")); - const html = await productionFetchHandler(new Request("http://localhost/")); - const asset = await productionFetchHandler(new Request("http://localhost/assets/app.js")); - - for (const response of [json, html, asset]) { - expect(response.headers.get("x-content-type-options")).toBe("nosniff"); - expect(response.headers.get("referrer-policy")).toBe("strict-origin-when-cross-origin"); - } + const asset = await fetchHandler(new Request("http://localhost/assets/app.js")); + expect(asset.status).toBe(200); }); }); diff --git a/tests/server/checker/config-loader.test.ts b/tests/server/checker/config-loader.test.ts new file mode 100644 index 0000000..dbfc8ef --- /dev/null +++ b/tests/server/checker/config-loader.test.ts @@ -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, + }); + }); +}); diff --git a/tests/server/checker/engine.test.ts b/tests/server/checker/engine.test.ts new file mode 100644 index 0000000..426b13f --- /dev/null +++ b/tests/server/checker/engine.test.ts @@ -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 }).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 }).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); + }); +}); diff --git a/tests/server/checker/fetcher.test.ts b/tests/server/checker/fetcher.test.ts new file mode 100644 index 0000000..7e138ff --- /dev/null +++ b/tests/server/checker/fetcher.test.ts @@ -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); + }); +}); diff --git a/tests/server/checker/store.test.ts b/tests/server/checker/store.test.ts new file mode 100644 index 0000000..9e666d5 --- /dev/null +++ b/tests/server/checker/store.test.ts @@ -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); + }); +}); diff --git a/tests/server/config.test.ts b/tests/server/config.test.ts index 4b6e8c3..db305fc 100644 --- a/tests/server/config.test.ts +++ b/tests/server/config.test.ts @@ -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 配置文件路径"); }); });